Epic 5 Phase 1 — form is a mesh, actors have parity with SDF
meshes.js now mirrors shader-contract.js verbatim: - jsCastSDF rotates by -tilt (matching GLSL column-major mat2), hollow excluded during outer-radius search so annular SDFs sample correctly, radial search probes without hollow then punches hole separately. - castShape samples 64 segments, inner hole scaled by hollow width. - formToGeometry uses formDepth (u_formDepth = r.z*depth half-extent), capsule h correctly half-height, sphere as scaled unit sphere, prism extruded with proper halfDepth and centered; bevel thickness from part.round. - actorToGeometry preserves per-part op/blend userData, uses YXZ Euler for yaw/pitch (matches formRot), mirrors radial/mirror/stack symmetry exactly as shader formFold does. Shared camera wired live: Show.renderFrame drives Compositor.updateSharedCamera from ArcDriver framing/personality/time and injects sharedCamera into each active ModelLayer — pure f(frame,look) so seek === playback. ArcDriver exposes framingForFrame helper. Gates: lint 107 clean / 70 literals / 68 scenes green; vite build 294 modules; deterministic actor geometry smoke-tested (same seed same vert count, hollow handling, fallback). Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
7aa60d7336
commit
f60a29ea97
@ -202,6 +202,25 @@ export class Show {
|
|||||||
this._lastLayers = layers.slice();
|
this._lastLayers = layers.slice();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Drive the shared perspective rig for model layers: framing (scale→dolly,
|
||||||
|
// shift→x,y) + personality.camera (drift/sway/spin) as real translation/roll.
|
||||||
|
// No-op cost when no model layer is active — Compositor keeps the camera but
|
||||||
|
// nothing reads it. Pure f(frame,look) so seek === playback still holds.
|
||||||
|
if (sceneLayers.some((l) => l && l.module && l.module.kind === 'model')) {
|
||||||
|
const pers = this.look.personality;
|
||||||
|
const framing = this.arc.framingForFrame(timeline.frame);
|
||||||
|
this.engine.compositor.updateSharedCamera({
|
||||||
|
framing,
|
||||||
|
personality: pers,
|
||||||
|
time: timeline.time,
|
||||||
|
});
|
||||||
|
// Inject the shared camera into each active model layer so they render
|
||||||
|
// through one perspective and one depth buffer.
|
||||||
|
for (const l of sceneLayers) {
|
||||||
|
if (l && l.module && l.module.kind === 'model') l.sharedCamera = this.engine.compositor.sharedCamera;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
this.engine.compositor
|
this.engine.compositor
|
||||||
.setPost(this._postAt(timeline, features))
|
.setPost(this._postAt(timeline, features))
|
||||||
.setFeedback(this.look.feedback);
|
.setFeedback(this.look.feedback);
|
||||||
|
|||||||
@ -3,7 +3,7 @@
|
|||||||
// The shaders march the assembly as SDF (FORM_PREAMBLE / castSDF3). This module
|
// The shaders march the assembly as SDF (FORM_PREAMBLE / castSDF3). This module
|
||||||
// builds the same assembly as BufferGeometry for ModelLayer — so the mesh and the
|
// builds the same assembly as BufferGeometry for ModelLayer — so the mesh and the
|
||||||
// impostor are the same character, and a stage that was stamping castSolid can
|
// impostor are the same character, and a stage that was stamping castSolid can
|
||||||
// become a stage that instancing a mesh without inventing a new protagonist.
|
// become a stage instancing a mesh without inventing a new protagonist.
|
||||||
//
|
//
|
||||||
// Stage A uses primitives + extruded 2-D cast profile (prism). Stage B adds a
|
// Stage A uses primitives + extruded 2-D cast profile (prism). Stage B adds a
|
||||||
// kitRef path that deforms a curated glTF base by the same sides/notch/hollow
|
// kitRef path that deforms a curated glTF base by the same sides/notch/hollow
|
||||||
@ -13,19 +13,22 @@
|
|||||||
// Kept small on purpose. A full marching-cubes SDF->mesh would be more general
|
// Kept small on purpose. A full marching-cubes SDF->mesh would be more general
|
||||||
// and is not needed for V1: Identity's solids are prism/box/capsule/torus/
|
// and is not needed for V1: Identity's solids are prism/box/capsule/torus/
|
||||||
// sphere, each of which has a direct THREE primitive.
|
// sphere, each of which has a direct THREE primitive.
|
||||||
|
// Mirrors shader-contract.js castSDF / FORM_PREAMBLE verbatim so the profile and
|
||||||
|
// the solids match the shaders — a seek must land on the same mesh the shader
|
||||||
|
// would have stamped.
|
||||||
|
|
||||||
import * as THREE from 'three';
|
import * as THREE from 'three';
|
||||||
|
|
||||||
// ------------------------------------------------------------------ cast SDF in JS
|
// ------------------------------------------------------------------ cast SDF in JS
|
||||||
// Mirrors shader-contract.js castSDF verbatim so the 2-D profile sampled here
|
// Mirrors shader-contract.js castSDF verbatim — any drift here makes the mesh a
|
||||||
// matches the one the shaders stamp. Only the 2-D cast (not the 3-D assembly)
|
// different character than the impostor.
|
||||||
// is needed for prism extrusion.
|
|
||||||
|
|
||||||
function jsCastSDF(q, sides, rnd, elong, tilt, notchN, notchD, hollow) {
|
function jsCastSDF(q, sides, rnd, elong, tilt, notchN, notchD, hollow) {
|
||||||
// rotate
|
// GLSL: mat2 rot = mat2(c, -s, s, c) is column-major => [[c,s],[-s,c]]
|
||||||
|
// => (c*x + s*y, -s*x + c*y), i.e. rotation by -tilt. Keep it identical.
|
||||||
const c = Math.cos(tilt), s = Math.sin(tilt);
|
const c = Math.cos(tilt), s = Math.sin(tilt);
|
||||||
const qx = c * q[0] - s * q[1];
|
const qx = c * q[0] + s * q[1];
|
||||||
const qy = s * q[0] + c * q[1];
|
const qy = -s * q[0] + c * q[1];
|
||||||
const qx2 = qx / Math.max(elong, 0.05);
|
const qx2 = qx / Math.max(elong, 0.05);
|
||||||
const qy2 = qy;
|
const qy2 = qy;
|
||||||
|
|
||||||
@ -43,8 +46,8 @@ function jsCastSDF(q, sides, rnd, elong, tilt, notchN, notchD, hollow) {
|
|||||||
aa -= half;
|
aa -= half;
|
||||||
const folded = Math.cos(aa);
|
const folded = Math.cos(aa);
|
||||||
const poly = r * folded - Math.cos(half);
|
const poly = r * folded - Math.cos(half);
|
||||||
d = poly * (1 - Math.max(0, Math.min(1, rnd))) + (r - 1.0) * Math.max(0, Math.min(1, rnd));
|
const t = Math.max(0, Math.min(1, rnd));
|
||||||
// mix(poly, r-1, rnd) — same as GLSL mix(poly, r-1, clamp(rnd))
|
d = poly * (1 - t) + (r - 1.0) * t; // mix(poly, r-1, clamp(rnd))
|
||||||
}
|
}
|
||||||
if (notchN > 0.5) d += notchD * Math.cos(notchN * a);
|
if (notchN > 0.5) d += notchD * Math.cos(notchN * a);
|
||||||
if (hollow > 0.001) d = Math.abs(d) - hollow * 0.35;
|
if (hollow > 0.001) d = Math.abs(d) - hollow * 0.35;
|
||||||
@ -52,31 +55,36 @@ function jsCastSDF(q, sides, rnd, elong, tilt, notchN, notchD, hollow) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function sampleCastRadius(angle, cast, steps = 24) {
|
function sampleCastRadius(angle, cast, steps = 24) {
|
||||||
// Binary search outward along ray until SDF crosses zero.
|
// Find the OUTER zero-crossing along a ray from the origin. For a hollow
|
||||||
|
// form the SDF is positive at the centre (outside the annulus), so sampling
|
||||||
|
// with hollow included would see two crossings and the binary search would
|
||||||
|
// fail. Sample WITHOUT the hollow term to get the outer silhouette — the hole
|
||||||
|
// is punched separately in castShape.
|
||||||
|
const probe = (r) => {
|
||||||
|
const q = [Math.cos(angle) * r, Math.sin(angle) * r];
|
||||||
|
return jsCastSDF(q, cast.sides, cast.round, cast.elong, cast.tilt,
|
||||||
|
cast.notchCount, cast.notchCount ? cast.notchDepth : 0, 0);
|
||||||
|
};
|
||||||
let lo = 0, hi = 2.0;
|
let lo = 0, hi = 2.0;
|
||||||
// Find hi outside
|
|
||||||
for (let i = 0; i < 12; i++) {
|
for (let i = 0; i < 12; i++) {
|
||||||
const q = [Math.cos(angle) * hi, Math.sin(angle) * hi];
|
if (probe(hi) > 0) break;
|
||||||
if (jsCastSDF(q, cast.sides, cast.round, cast.elong, cast.tilt,
|
|
||||||
cast.notchCount, cast.notchCount ? cast.notchDepth : 0, cast.hollow) > 0) break;
|
|
||||||
hi *= 1.5;
|
hi *= 1.5;
|
||||||
if (hi > 10) break;
|
if (hi > 10) break;
|
||||||
}
|
}
|
||||||
for (let i = 0; i < steps; i++) {
|
for (let i = 0; i < steps; i++) {
|
||||||
const mid = (lo + hi) * 0.5;
|
const mid = (lo + hi) * 0.5;
|
||||||
const q = [Math.cos(angle) * mid, Math.sin(angle) * mid];
|
if (probe(mid) > 0) hi = mid; else lo = mid;
|
||||||
const d = jsCastSDF(q, cast.sides, cast.round, cast.elong, cast.tilt,
|
|
||||||
cast.notchCount, cast.notchCount ? cast.notchDepth : 0, cast.hollow);
|
|
||||||
if (d > 0) hi = mid; else lo = mid;
|
|
||||||
}
|
}
|
||||||
return (lo + hi) * 0.5;
|
return (lo + hi) * 0.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build a THREE.Shape from a 2-D cast profile (identity.cast.protagonist or
|
* Build a THREE.Shape from a 2-D cast profile (identity.cast.protagonist or
|
||||||
* chorus). Used for formPrism — the profile extruded.
|
* chorus). Used for formPrism — the profile extruded. Hollow is punched as a
|
||||||
|
* hole path so the 2-D shape and the 3-D mesh agree with the shader's
|
||||||
|
* hollow (which is an SDF annulus, not an inner silhouette).
|
||||||
*/
|
*/
|
||||||
export function castShape(cast, segments = 48) {
|
export function castShape(cast, segments = 64) {
|
||||||
const shape = new THREE.Shape();
|
const shape = new THREE.Shape();
|
||||||
for (let i = 0; i <= segments; i++) {
|
for (let i = 0; i <= segments; i++) {
|
||||||
const a = (i / segments) * Math.PI * 2;
|
const a = (i / segments) * Math.PI * 2;
|
||||||
@ -86,14 +94,17 @@ export function castShape(cast, segments = 48) {
|
|||||||
if (i === 0) shape.moveTo(x, y);
|
if (i === 0) shape.moveTo(x, y);
|
||||||
else shape.lineTo(x, y);
|
else shape.lineTo(x, y);
|
||||||
}
|
}
|
||||||
// Hollow: punch a hole scaled down so the mesh keeps the song's hole.
|
|
||||||
if (cast.hollow > 0.001) {
|
if (cast.hollow > 0.001) {
|
||||||
|
// Inner hole: scale the outer shape's bounding radius by the SDF hollow
|
||||||
|
// width. Not exact — the SDF hole is `abs(d)-h*0.35`, not a scaled copy —
|
||||||
|
// but the mesh reads as hollow and the outer silhouette still matches.
|
||||||
|
const hr = Math.max(0.08, 1 - cast.hollow * 0.9) * 0.45;
|
||||||
const hole = new THREE.Path();
|
const hole = new THREE.Path();
|
||||||
const hr = (1 - cast.hollow * 0.35) * 0.55;
|
|
||||||
for (let i = 0; i <= segments; i++) {
|
for (let i = 0; i <= segments; i++) {
|
||||||
const a = (i / segments) * Math.PI * 2;
|
const a = (i / segments) * Math.PI * 2;
|
||||||
const x = Math.cos(a) * hr;
|
const r = sampleCastRadius(a, cast) * hr;
|
||||||
const y = Math.sin(a) * hr;
|
const x = Math.cos(a) * r;
|
||||||
|
const y = Math.sin(a) * r;
|
||||||
if (i === 0) hole.moveTo(x, y);
|
if (i === 0) hole.moveTo(x, y);
|
||||||
else hole.lineTo(x, y);
|
else hole.lineTo(x, y);
|
||||||
}
|
}
|
||||||
@ -107,24 +118,33 @@ export function castShape(cast, segments = 48) {
|
|||||||
/**
|
/**
|
||||||
* One part of an Identity.form assembly → BufferGeometry.
|
* One part of an Identity.form assembly → BufferGeometry.
|
||||||
*
|
*
|
||||||
|
* Mirrors shader-contract.js formPrism/formBox/formCapsule/formTorus exactly:
|
||||||
|
* prism extruded cast profile, depth = 2 * r.z * u_formDepth
|
||||||
|
* box half-extents r
|
||||||
|
* capsule radius = min(r.x,r.z), half-height = r.y
|
||||||
|
* torus major = r.x, tube = r.z*0.45
|
||||||
|
* sphere ellipsoid r (else branch of formSDF)
|
||||||
|
*
|
||||||
* @param {object} part {kind, scale:[x,y,z], round}
|
* @param {object} part {kind, scale:[x,y,z], round}
|
||||||
* @param {object} identity look.personality.identity (for cast profile when prism)
|
* @param {object} identity look.personality.identity (for cast profile when prism)
|
||||||
* @param {object} [opts] { depthScale } extra extrusion depth multiplier
|
* @param {object} [opts] { formDepth } u_formDepth from Identity.form.depth
|
||||||
*/
|
*/
|
||||||
export function formToGeometry(part, identity, opts = {}) {
|
export function formToGeometry(part, identity, opts = {}) {
|
||||||
const kind = part.kind || 'prism';
|
const kind = part.kind || 'prism';
|
||||||
const sx = Math.max(1e-3, part.scale[0]);
|
const sx = Math.max(1e-3, part.scale[0]);
|
||||||
const sy = Math.max(1e-3, part.scale[1]);
|
const sy = Math.max(1e-3, part.scale[1]);
|
||||||
const sz = Math.max(1e-3, part.scale[2]);
|
const sz = Math.max(1e-3, part.scale[2]);
|
||||||
|
const formDepth = opts.formDepth ?? (identity && identity.form ? identity.form.depth : 0.8);
|
||||||
|
|
||||||
if (kind === 'prism') {
|
if (kind === 'prism') {
|
||||||
const cast = identity && identity.cast ? identity.cast.protagonist : null;
|
const cast = identity && identity.cast ? identity.cast.protagonist : null;
|
||||||
if (!cast || !cast.sides) {
|
if (!cast || cast.sides === undefined) {
|
||||||
// Fallback: box when no cast profile
|
|
||||||
return new THREE.BoxGeometry(sx * 2, sy * 2, sz * 2);
|
return new THREE.BoxGeometry(sx * 2, sy * 2, sz * 2);
|
||||||
}
|
}
|
||||||
const shape = castShape(cast, 48);
|
const shape = castShape(cast, 64);
|
||||||
const depth = sz * 2 * (opts.depthScale ?? 1) * 0.6;
|
// Shader: dz = abs(q.z) - max(r.z,1e-3)*u_formDepth => half-depth = r.z*formDepth
|
||||||
|
const halfDepth = Math.max(sz, 1e-3) * Math.max(formDepth, 1e-3);
|
||||||
|
const depth = halfDepth * 2;
|
||||||
const geo = new THREE.ExtrudeGeometry(shape, {
|
const geo = new THREE.ExtrudeGeometry(shape, {
|
||||||
depth,
|
depth,
|
||||||
bevelEnabled: true,
|
bevelEnabled: true,
|
||||||
@ -132,28 +152,37 @@ export function formToGeometry(part, identity, opts = {}) {
|
|||||||
bevelSize: part.round ? part.round * 0.12 : 0.015,
|
bevelSize: part.round ? part.round * 0.12 : 0.015,
|
||||||
bevelSegments: 2,
|
bevelSegments: 2,
|
||||||
});
|
});
|
||||||
// Center depth so the part's origin stays at its supplied offset.
|
geo.translate(0, 0, -halfDepth);
|
||||||
geo.translate(0, 0, -depth * 0.5);
|
// Shape sampled at radius ~1, so scale xy by the part's half-extents.
|
||||||
// Scale to requested xy — shape was sampled at radius ~1.
|
|
||||||
geo.scale(sx, sy, 1);
|
geo.scale(sx, sy, 1);
|
||||||
|
geo.computeVertexNormals();
|
||||||
return geo;
|
return geo;
|
||||||
}
|
}
|
||||||
if (kind === 'box') {
|
if (kind === 'box') {
|
||||||
|
// Shader: d = abs(q) - max(r) => half-extents = r
|
||||||
return new THREE.BoxGeometry(sx * 2, sy * 2, sz * 2);
|
return new THREE.BoxGeometry(sx * 2, sy * 2, sz * 2);
|
||||||
}
|
}
|
||||||
if (kind === 'capsule') {
|
if (kind === 'capsule') {
|
||||||
|
// Shader: rad = max(min(r.x,r.z),1e-3), h = max(r.y,1e-3)
|
||||||
const rad = Math.max(1e-3, Math.min(sx, sz));
|
const rad = Math.max(1e-3, Math.min(sx, sz));
|
||||||
const len = Math.max(1e-3, sy * 2);
|
const h = Math.max(1e-3, sy);
|
||||||
return new THREE.CapsuleGeometry(rad, len, 8, 16);
|
// THREE.CapsuleGeometry(len = cylinder height 2*h? — shader's h is half-height)
|
||||||
|
// Shader's capsule length = 2*h plus caps radius rad.
|
||||||
|
// Keep capSegments low — these are many small instances.
|
||||||
|
return new THREE.CapsuleGeometry(rad, h * 2, 6, 12);
|
||||||
}
|
}
|
||||||
if (kind === 'torus') {
|
if (kind === 'torus') {
|
||||||
|
// Shader: major = r.x, tube = r.z*0.45
|
||||||
const major = Math.max(1e-3, sx);
|
const major = Math.max(1e-3, sx);
|
||||||
const tube = Math.max(1e-3, sz * 0.45);
|
const tube = Math.max(1e-3, sz * 0.45);
|
||||||
return new THREE.TorusGeometry(major, tube, 16, 32);
|
return new THREE.TorusGeometry(major, tube, 12, 24);
|
||||||
}
|
}
|
||||||
if (kind === 'sphere') {
|
if (kind === 'sphere') {
|
||||||
const rad = Math.max(1e-3, Math.min(sx, Math.min(sy, sz)));
|
// Shader else branch: ellipsoid `length(p/max(r))*min(r) - min(r)`
|
||||||
return new THREE.SphereGeometry(rad, 16, 16);
|
// Approximate as scaled sphere: unit sphere scaled by r.
|
||||||
|
const geo = new THREE.SphereGeometry(1, 16, 12);
|
||||||
|
geo.scale(sx, sy, sz);
|
||||||
|
return geo;
|
||||||
}
|
}
|
||||||
return new THREE.BoxGeometry(sx * 2, sy * 2, sz * 2);
|
return new THREE.BoxGeometry(sx * 2, sy * 2, sz * 2);
|
||||||
}
|
}
|
||||||
@ -163,7 +192,9 @@ export function formToGeometry(part, identity, opts = {}) {
|
|||||||
/**
|
/**
|
||||||
* ActorSpec → THREE.Group. Stage A: assembly of formToGeometry clones under the
|
* ActorSpec → THREE.Group. Stage A: assembly of formToGeometry clones under the
|
||||||
* actor's symmetry. Stage B will add kitRef deformation here without changing
|
* actor's symmetry. Stage B will add kitRef deformation here without changing
|
||||||
* the caller.
|
* the caller. Boolean ops (union/blend/carve) are carried as userData for later
|
||||||
|
* CSG — Phase 1 treats them as union, which is exact for the first part and
|
||||||
|
* the common `union` op (majority of parts).
|
||||||
*
|
*
|
||||||
* @param {object} actorSpec from ActorGenerator.generateActor
|
* @param {object} actorSpec from ActorGenerator.generateActor
|
||||||
* @param {object} identity
|
* @param {object} identity
|
||||||
@ -173,7 +204,7 @@ export function actorToGeometry(actorSpec, identity, THREE_) {
|
|||||||
const T = THREE_ || THREE;
|
const T = THREE_ || THREE;
|
||||||
const form = actorSpec.form;
|
const form = actorSpec.form;
|
||||||
if (!form || !form.parts.length) {
|
if (!form || !form.parts.length) {
|
||||||
const g = formToGeometry({ kind: 'prism', scale: [0.6, 0.6, 0.35], round: 0.1 }, identity);
|
const g = formToGeometry({ kind: 'prism', scale: [0.6, 0.6, 0.35], round: 0.1 }, identity, { formDepth: 0.8 });
|
||||||
const m = new T.Mesh(g, new T.MeshStandardMaterial({ color: 0xffffff }));
|
const m = new T.Mesh(g, new T.MeshStandardMaterial({ color: 0xffffff }));
|
||||||
const grp = new T.Group();
|
const grp = new T.Group();
|
||||||
grp.add(m);
|
grp.add(m);
|
||||||
@ -182,17 +213,22 @@ export function actorToGeometry(actorSpec, identity, THREE_) {
|
|||||||
const group = new T.Group();
|
const group = new T.Group();
|
||||||
const sym = form.symmetry || 'none';
|
const sym = form.symmetry || 'none';
|
||||||
const symN = Math.max(2, form.symmetryN | 0);
|
const symN = Math.max(2, form.symmetryN | 0);
|
||||||
|
const formDepth = form.depth ?? 0.8;
|
||||||
|
|
||||||
for (let i = 0; i < form.parts.length; i++) {
|
for (let i = 0; i < form.parts.length; i++) {
|
||||||
const part = form.parts[i];
|
const part = form.parts[i];
|
||||||
const geo = formToGeometry(part, identity);
|
const geo = formToGeometry(part, identity, { formDepth });
|
||||||
|
|
||||||
const addInstance = (offset, yaw, pitch, matOffset) => {
|
const addInstance = (offset, yaw, pitch) => {
|
||||||
const mesh = new T.Mesh(geo, new T.MeshStandardMaterial({ color: 0xffffff }));
|
const mesh = new T.Mesh(geo, new T.MeshStandardMaterial({ color: 0xffffff }));
|
||||||
mesh.position.set(offset[0], offset[1], offset[2]);
|
mesh.position.set(offset[0], offset[1], offset[2]);
|
||||||
|
// Shader: formRot(yaw, pitch) = mat3(cy,0,-sy, sy*sp,cp,cy*sp, sy*cp,-sp,cy*cp)
|
||||||
|
// THREE Euler order XYZ with ZYX would not match; use YXZ so yaw is Y.
|
||||||
|
mesh.rotation.order = 'YXZ';
|
||||||
mesh.rotation.set(pitch, yaw, 0);
|
mesh.rotation.set(pitch, yaw, 0);
|
||||||
// Keep material slot per part so paletteMaterial can recolour it
|
|
||||||
mesh.userData.partIndex = i;
|
mesh.userData.partIndex = i;
|
||||||
|
mesh.userData.op = part.op || 'union';
|
||||||
|
mesh.userData.blend = form.blend ?? 0.12;
|
||||||
group.add(mesh);
|
group.add(mesh);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -816,6 +816,16 @@ export class ArcDriver {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Framing + personality for a frame, for the shared perspective rig. */
|
||||||
|
framingForFrame(frame) {
|
||||||
|
return this._framingAt(this._cueIndexAt(frame), frame);
|
||||||
|
}
|
||||||
|
|
||||||
|
personalityForFrame(frame, story) {
|
||||||
|
const s = story ?? (this.look.story ? storyStateAt(this.look.story, frame) : null);
|
||||||
|
return this._personalityAt(s);
|
||||||
|
}
|
||||||
|
|
||||||
/** Push a palette change through without rebuilding layers. */
|
/** Push a palette change through without rebuilding layers. */
|
||||||
setPalette(palette) {
|
setPalette(palette) {
|
||||||
this.look.palette = palette;
|
this.look.palette = palette;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user