From c57499f9ce7de975f72f1cfe7f26a276f1aff43b Mon Sep 17 00:00:00 2001 From: "[._.]/ Adam Eivy" Date: Wed, 2 Sep 2026 06:33:17 +0000 Subject: [PATCH 1/2] refactor: give the three Three.js audit gates one owner for their XYZ transform math (#5681) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `threejsModel.js`, `threejsModelPenetration.js` and `threejsModelPhysicalAudit.js` each carried their own row-major 3x3 copy of the same rotation/compose/apply math, all three claiming to reproduce `THREE.Euler` order 'XYZ' — the composition the preview canvas and the exported factory actually render with. They were algebraically identical but textually different with nothing pinning them together, so the three gates could silently start measuring three different scenes. They had already drifted on input hardening: `threejsModel.js` coerced a non-finite rotation component to 0 degrees, while both audit modules fed the raw value into `Math.cos`. A stored spec with a `NaN`/`undefined` rotation therefore gave those two gates `NaN` world bounds, and every touch and overlap test against `NaN` is false — a block sitting flat on the ground was reported as `floating-part`, and a fully buried part as no overlap at all. The new `server/lib/threejsTransform.js` owns the primitives and takes the coercing behaviour as canonical (a non-finite rotation component reads as 0 degrees, a non-finite scale component as 1), so the audits measure the same scene the renderer draws. `composeTransform` takes the local TRS object-shaped, which is what stored specs already carry, so a whole part passes straight through. --- server/lib/README.md | 1 + server/lib/index.js | 1 + server/lib/threejsModel.js | 57 ++------- server/lib/threejsModelPenetration.js | 74 ++---------- server/lib/threejsModelPenetration.test.js | 16 +++ server/lib/threejsModelPhysicalAudit.js | 72 ++---------- server/lib/threejsModelPhysicalAudit.test.js | 21 ++++ server/lib/threejsTransform.js | 110 ++++++++++++++++++ server/lib/threejsTransform.test.js | 116 +++++++++++++++++++ 9 files changed, 289 insertions(+), 179 deletions(-) create mode 100644 server/lib/threejsTransform.js create mode 100644 server/lib/threejsTransform.test.js diff --git a/server/lib/README.md b/server/lib/README.md index a37946a3b0..0637a7bd31 100644 --- a/server/lib/README.md +++ b/server/lib/README.md @@ -247,6 +247,7 @@ The barrel `server/lib/index.js` is a machine-checkable enumeration of every pub | `threejsModelPhysicalAudit.js` | `evaluateThreejsPhysicalAudit(spec)` — pure bounds and pose audit gate over an already-validated Three.js spec: inspects static resting poses and animated clip poses to detect floating parts (`floating-part`), swallowed geometry (`buried-geometry`), z-fighting coplanar surfaces (`coplanar-surface`), unprovenanced appearing geometry (`unprovenanced-transition`), non-uniform parent scale cascading into descendants (`nonuniform-parent-scale`), attachments declared with nothing to hang from (`unanchored-attachment`, warning), and attachments measured further from their declared anchor than `maxOffset` allows (`attachment-far-from-anchor`, error — the spec asserted the relationship itself), across the resting pose and the sampled clip poses so a clip that carries an attachment away from its anchor is caught too. Named left/right pairs are additionally audited for handedness in the resting pose — measured in the frame of the pair’s nearest common ancestor, since the lateral plane a pair mirrors across is the one their shared parent defines and not world `x = 0` — which no bounds check can see — a limb mirrored by a 180° yaw about the vertical axis rather than a lateral reflection (`bilateral-chirality`), by a negated scale component (`bilateral-mirror-scale`), or not mirrored at all so both halves sit on one side of the lateral plane (`bilateral-pair-same-side`), all warnings. An attachment whose anchor geometry could not be measured is listed in `unmeasuredAttachments` rather than counted as passing. `buildThreejsPhysicalAuditFeedback(physicalAudit)` turns actionable findings into default refinement feedback. | | `threejsModelPlayerSource.js` | `THREEJS_PLAYER_SOURCE` — the fixed clip-player source `buildThreejsFactorySource` emits into every exported Three.js module, giving a standalone consumer `createSculptAnimationPlayer(root, { onCue })` (plus `evaluateSculptClipPose` / `collectSculptCues`) over the node map and validated `animation` block the factory already carries. A STRING constant, not generated text: nothing provider-authored is interpolated into it, so the export stays data-plus-PortOS-code. It takes `update(deltaSeconds)` from the host render loop rather than owning one, scrubs silently and fires cues only on playback (mirroring the preview), and clones a shared material before driving `opacity`. Semantics mirror `client/src/lib/threejsAnimation.js` — change one and change the other. | | `threejsModelRig.js` | `evaluateThreejsRigReadiness(spec)` — honest rig-readiness report over an already-validated Three.js spec: `{ articulationReady, reasons, jointCount, socketCount, attachmentCount, anchoredAttachmentCount, unanchoredAttachmentCount, rootJointId, subjectType }`. The schema proves the optional `articulation` graph is well *formed* (one root, no cycles or forward refs, joints and pivots pointed at real parts/sockets); this reports whether it is *useful* (more than a lone root, every child joint carrying a pivot axis, every declared attachment naming what it hangs from) and names the reason when it is not. It reports rather than rejects, and never claims skinning: PortOS generates static assemblies and declared articulation intent, not skeletons or bind poses. | +| `threejsTransform.js` | Affine transform primitives shared by every server-side gate that reconstructs where a Three.js scene-spec part sits in the world — `rotationMatrix` (row-major 3×3 matching `THREE.Euler` order `XYZ`, the composition the preview canvas and exported factory both apply), `scaleLinear`, `multiplyLinear`, `applyLinear`, `applyTransform`, `composeTransform(parent, { position, rotationDegrees, scale })`, `vectorLength`, `degreesToRadians`, `IDENTITY_LINEAR` / `IDENTITY_TRANSFORM`. One owner for math `threejsModel.js`, `threejsModelPenetration.js` and `threejsModelPhysicalAudit.js` each used to spell separately. A non-finite rotation component reads as `0` degrees and a non-finite scale component as `1`, matching what the renderer does with a malformed stored spec — feeding `null`/`NaN` through instead produced `NaN` bounds that the audits read as "no overlap" and "no defect". | | `pgFileFacade.js` | Shared PG/file store-backend backbone for the six storage dispatchers (pipeline series/issues, story builder, universe builder, catalog user-types, writers room). `isFileBackend()` (dev/test escape-hatch predicate) · `resolvePgBackend({ requirement, migrate?, loadDb, makePg })` (health-check → `ensureSchema` → one-time migration → import `db.js` → build the PG backend) · `createPgFileFacade({ makeFile, makePg })` (promise-memoized lazy selection so concurrent first calls don't run the migration twice; returns `{ getBackend, getBackendName, reset }`). Each store keeps its own `makeFile`/`makePg` factories + public surface. `createRecordStoreBackendSelector({ label, loadFileBackend, loadDbBackend, requireDbMessage?, isTestMode?, onDbReady? })` wraps the same backbone for the stores whose backends are whole MODULES rather than built objects (Creative Director, Music Video, Sprites) → `{ selectBackend, getBackendName }` where the name is `'file'`/`'postgres'`; `isTestMode` lets a store use the stronger `isTestRunner()` signal (Sprites). | | `multipart.js` | Streaming multipart/form-data parser. | | `safetensors.js` | `readSafetensorsHeader(path)` reads only the JSON header of a `.safetensors` file (never the tensor payload). `detectFlux2VariantFromHeader(header)` / `detectFlux2Variant(path)` classify a LoRA as FLUX.2 Klein `'4b'` (hidden dim 3072) vs `'9b'` (4096) by transformer-block tensor shapes, so the LoRA picker can hide off-variant weights that would silently fail to load. `classifyLoraKeyLayoutFromHeader(header)` / `classifyLoraKeyLayout(path)` classify the key layout as `LORA_KEY_LAYOUTS` (`bare` / `comfyui` / `diffusers` / `kohya` / `not_a_lora`, `null` = unreadable), `isKnownLoraKeyLayout(layout)` validates a layout read back out of persisted state, and `videoLoraLayoutIssue(layout)` returns the user-facing reason a layout can't fuse into the LTX-2 video transformer (or `null` when it can). | diff --git a/server/lib/index.js b/server/lib/index.js index bf1bf97255..85ff35595b 100644 --- a/server/lib/index.js +++ b/server/lib/index.js @@ -66,6 +66,7 @@ export * from './threejsModelPenetration.js'; export * from './threejsModelPhysicalAudit.js'; export * from './threejsModelPlayerSource.js'; export * from './threejsModelRig.js'; +export * from './threejsTransform.js'; // === Story & narrative === export * as catalogBulkParsers from './catalogBulkParsers.js'; diff --git a/server/lib/threejsModel.js b/server/lib/threejsModel.js index 5890e95055..872310713f 100644 --- a/server/lib/threejsModel.js +++ b/server/lib/threejsModel.js @@ -16,6 +16,14 @@ import { THREEJS_RENDER_PROFILE, } from './threejsModelEnvironment.js'; import { THREEJS_PLAYER_SOURCE } from './threejsModelPlayerSource.js'; +import { + applyLinear, + IDENTITY_LINEAR, + multiplyLinear, + rotationMatrix, + scaleLinear, + vectorLength, +} from './threejsTransform.js'; const idSchema = z.string().trim().min(1).max(80).regex(/^[A-Za-z][A-Za-z0-9_-]*$/); const colorSchema = z.string().regex(/^#[0-9a-fA-F]{6}$/); @@ -974,58 +982,11 @@ const isCoplanarCloud = (vertices) => { // each ancestor's transform from the outside. Keeping the linear part here is // enough for the relative thickness check and avoids making the server-side gate // depend on Three.js just to answer a geometry question. -const IDENTITY_LINEAR = [1, 0, 0, 0, 1, 0, 0, 0, 1]; - -const multiplyLinear = (a, b) => [ - (a[0] * b[0]) + (a[1] * b[3]) + (a[2] * b[6]), - (a[0] * b[1]) + (a[1] * b[4]) + (a[2] * b[7]), - (a[0] * b[2]) + (a[1] * b[5]) + (a[2] * b[8]), - (a[3] * b[0]) + (a[4] * b[3]) + (a[5] * b[6]), - (a[3] * b[1]) + (a[4] * b[4]) + (a[5] * b[7]), - (a[3] * b[2]) + (a[4] * b[5]) + (a[5] * b[8]), - (a[6] * b[0]) + (a[7] * b[3]) + (a[8] * b[6]), - (a[6] * b[1]) + (a[7] * b[4]) + (a[8] * b[7]), - (a[6] * b[2]) + (a[7] * b[5]) + (a[8] * b[8]), -]; - -const rotationLinear = (degrees = [0, 0, 0]) => { - const [x = 0, y = 0, z = 0] = degrees; - const ax = (Number.isFinite(x) ? x : 0) * (Math.PI / 180); - const ay = (Number.isFinite(y) ? y : 0) * (Math.PI / 180); - const az = (Number.isFinite(z) ? z : 0) * (Math.PI / 180); - const a = Math.cos(ax); - const b = Math.sin(ax); - const c = Math.cos(ay); - const d = Math.sin(ay); - const e = Math.cos(az); - const f = Math.sin(az); - // Row-major equivalent of THREE.Euler's default XYZ matrix. - return [ - c * e, -c * f, d, - (b * d * e) + (a * f), (-b * d * f) + (a * e), -b * c, - (-a * d * e) + (b * f), (a * d * f) + (b * e), a * c, - ]; -}; - -const scaleLinear = (scale = [1, 1, 1]) => [ - Number.isFinite(scale[0]) ? scale[0] : 1, 0, 0, - 0, Number.isFinite(scale[1]) ? scale[1] : 1, 0, - 0, 0, Number.isFinite(scale[2]) ? scale[2] : 1, -]; - const partLinear = (part) => multiplyLinear( - rotationLinear(part.rotationDegrees), + rotationMatrix(part.rotationDegrees), scaleLinear(part.scale), ); -const applyLinear = (matrix, vector) => [ - (matrix[0] * vector[0]) + (matrix[1] * vector[1]) + (matrix[2] * vector[2]), - (matrix[3] * vector[0]) + (matrix[4] * vector[1]) + (matrix[5] * vector[2]), - (matrix[6] * vector[0]) + (matrix[7] * vector[1]) + (matrix[8] * vector[2]), -]; - -const vectorLength = (vector) => Math.hypot(...vector); - const transformVertices = (vertices, matrix) => { const transformed = []; for (let index = 0; index + 2 < vertices.length; index += 3) { diff --git a/server/lib/threejsModelPenetration.js b/server/lib/threejsModelPenetration.js index e2a06934a5..89d4cc438a 100644 --- a/server/lib/threejsModelPenetration.js +++ b/server/lib/threejsModelPenetration.js @@ -35,6 +35,13 @@ */ import { listSpecNames, resolveThreejsAttachments } from './threejsModel.js'; +import { + applyLinear, + applyTransform, + composeTransform, + degreesToRadians, + IDENTITY_TRANSFORM, +} from './threejsTransform.js'; // Sampling resolution over a part's local bounding box. 8³ is enough to // estimate a containment fraction to a couple of percent, which is all the @@ -63,73 +70,6 @@ const CONTACT_FRACTION = 0.15; const EPSILON = 1e-9; -const degreesToRadians = (degrees) => (degrees * Math.PI) / 180; - -/** Row-major 3×3 linear part plus a translation — an affine transform. */ -const IDENTITY_TRANSFORM = { linear: [1, 0, 0, 0, 1, 0, 0, 0, 1], translation: [0, 0, 0] }; - -const multiplyLinear = (a, b) => { - const out = new Array(9); - for (let row = 0; row < 3; row += 1) { - for (let column = 0; column < 3; column += 1) { - out[(row * 3) + column] = (a[row * 3] * b[column]) - + (a[(row * 3) + 1] * b[3 + column]) - + (a[(row * 3) + 2] * b[6 + column]); - } - } - return out; -}; - -const applyLinear = (linear, [x, y, z]) => [ - (linear[0] * x) + (linear[1] * y) + (linear[2] * z), - (linear[3] * x) + (linear[4] * y) + (linear[5] * z), - (linear[6] * x) + (linear[7] * y) + (linear[8] * z), -]; - -const applyTransform = (transform, point) => { - const rotated = applyLinear(transform.linear, point); - return [ - rotated[0] + transform.translation[0], - rotated[1] + transform.translation[1], - rotated[2] + transform.translation[2], - ]; -}; - -// Matches `THREE.Euler` order 'XYZ', which is what the preview and the exported -// factory both apply — a different composition here would measure a part that -// is not the one on screen. -const rotationMatrix = ([xDegrees, yDegrees, zDegrees]) => { - const [c1, s1] = [Math.cos(degreesToRadians(xDegrees)), Math.sin(degreesToRadians(xDegrees))]; - const [c2, s2] = [Math.cos(degreesToRadians(yDegrees)), Math.sin(degreesToRadians(yDegrees))]; - const [c3, s3] = [Math.cos(degreesToRadians(zDegrees)), Math.sin(degreesToRadians(zDegrees))]; - return [ - c2 * c3, -c2 * s3, s2, - (c1 * s3) + (s1 * c3 * s2), (c1 * c3) - (s1 * s3 * s2), -s1 * c2, - (s1 * s3) - (c1 * c3 * s2), (s1 * c3) + (c1 * s3 * s2), c1 * c2, - ]; -}; - -const composeTransform = (parent, part) => { - const rotation = rotationMatrix(part.rotationDegrees || [0, 0, 0]); - const [sx, sy, sz] = part.scale || [1, 1, 1]; - // R · S with S diagonal — scaling columns is the whole multiplication. - const local = [ - rotation[0] * sx, rotation[1] * sy, rotation[2] * sz, - rotation[3] * sx, rotation[4] * sy, rotation[5] * sz, - rotation[6] * sx, rotation[7] * sy, rotation[8] * sz, - ]; - const position = part.position || [0, 0, 0]; - const offset = applyLinear(parent.linear, position); - return { - linear: multiplyLinear(parent.linear, local), - translation: [ - offset[0] + parent.translation[0], - offset[1] + parent.translation[1], - offset[2] + parent.translation[2], - ], - }; -}; - /** * Affine inverse, or `null` when the linear part is singular. A stored spec * predates the positive-scale bound, so a zero or mirrored component is diff --git a/server/lib/threejsModelPenetration.test.js b/server/lib/threejsModelPenetration.test.js index fb8b98d178..aa18166c1c 100644 --- a/server/lib/threejsModelPenetration.test.js +++ b/server/lib/threejsModelPenetration.test.js @@ -312,6 +312,22 @@ describe('evaluateThreejsPenetration', () => { expect(penetration.evaluatedPartCount).toBe(1); expect(penetration.findings).toEqual([]); }); +it('still measures a stored spec whose rotation or scale is not a finite triple', () => { + // Reachable only past the schema — a record stored before a bound tightened. + // The shared transform reads a non-finite component as 0 degrees / scale 1 + // (what the renderer does), so the gate keeps measuring instead of handing + // back NaN bounds that would read as `no overlap`. + const spec = makeSpec({ parts: [part('hull', box(6)), part('core', box(1))] }); + spec.parts[1].rotationDegrees = [45, undefined, NaN]; + spec.parts[1].scale = [NaN, 1, 1]; + + const penetration = evaluateThreejsPenetration(spec); + expect(penetration.evaluatedPartCount).toBe(2); + expect(codes(penetration)).toContain('buried-part'); + const [pair] = finding(penetration, 'buried-part').pairs; + expect(pair).toMatchObject({ partId: 'core', containerPartId: 'hull' }); + expect(Number.isFinite(pair.fraction)).toBe(true); + }); }); describe('buildThreejsPenetrationFeedback', () => { diff --git a/server/lib/threejsModelPhysicalAudit.js b/server/lib/threejsModelPhysicalAudit.js index c296c49235..df1a8128a7 100644 --- a/server/lib/threejsModelPhysicalAudit.js +++ b/server/lib/threejsModelPhysicalAudit.js @@ -41,6 +41,12 @@ import { listSpecNames, resolveThreejsAttachments, } from './threejsModel.js'; +import { + applyTransform, + composeTransform, + IDENTITY_TRANSFORM, + multiplyLinear, +} from './threejsTransform.js'; const EPSILON = 1e-4; const COPLANAR_TOLERANCE = 1e-3; @@ -65,68 +71,6 @@ const CHIRALITY_POSITION_TOLERANCE = 1e-3; // authoring noise that a limb near the centreline would never produce. const CHIRALITY_POSITION_RELATIVE_TOLERANCE = 0.02; -const degreesToRadians = (degrees) => (degrees * Math.PI) / 180; - -const rotationMatrix = ([xDegrees, yDegrees, zDegrees]) => { - const [c1, s1] = [Math.cos(degreesToRadians(xDegrees)), Math.sin(degreesToRadians(xDegrees))]; - const [c2, s2] = [Math.cos(degreesToRadians(yDegrees)), Math.sin(degreesToRadians(yDegrees))]; - const [c3, s3] = [Math.cos(degreesToRadians(zDegrees)), Math.sin(degreesToRadians(zDegrees))]; - return [ - c2 * c3, -c2 * s3, s2, - (c1 * s3) + (s1 * c3 * s2), (c1 * c3) - (s1 * s3 * s2), -s1 * c2, - (s1 * s3) - (c1 * c3 * s2), (s1 * c3) + (c1 * s3 * s2), c1 * c2, - ]; -}; - -const multiplyLinear = (a, b) => { - const out = new Array(9); - for (let row = 0; row < 3; row += 1) { - for (let column = 0; column < 3; column += 1) { - out[(row * 3) + column] = (a[row * 3] * b[column]) - + (a[(row * 3) + 1] * b[3 + column]) - + (a[(row * 3) + 2] * b[6 + column]); - } - } - return out; -}; - -const applyLinear = (linear, [x, y, z]) => [ - (linear[0] * x) + (linear[1] * y) + (linear[2] * z), - (linear[3] * x) + (linear[4] * y) + (linear[5] * z), - (linear[6] * x) + (linear[7] * y) + (linear[8] * z), -]; - -const applyTransform = (transform, point) => { - const rotated = applyLinear(transform.linear, point); - return [ - rotated[0] + transform.translation[0], - rotated[1] + transform.translation[1], - rotated[2] + transform.translation[2], - ]; -}; - -const IDENTITY_TRANSFORM = { linear: [1, 0, 0, 0, 1, 0, 0, 0, 1], translation: [0, 0, 0] }; - -const composeTransform = (parent, position, rotationDegrees, scale) => { - const rotation = rotationMatrix(rotationDegrees || [0, 0, 0]); - const [sx, sy, sz] = scale || [1, 1, 1]; - const local = [ - rotation[0] * sx, rotation[1] * sy, rotation[2] * sz, - rotation[3] * sx, rotation[4] * sy, rotation[5] * sz, - rotation[6] * sx, rotation[7] * sy, rotation[8] * sz, - ]; - const pos = position || [0, 0, 0]; - const offset = applyLinear(parent.linear, pos); - return { - linear: multiplyLinear(parent.linear, local), - translation: [ - offset[0] + parent.translation[0], - offset[1] + parent.translation[1], - offset[2] + parent.translation[2], - ], - }; -}; - function getLocalBounds(geometry) { if (!geometry) return null; switch (geometry.type) { @@ -331,7 +275,7 @@ function collectPoseVolumes(spec, getPartState) { opacity: part.opacity ?? 1, }; - const transform = composeTransform(parentTransform, state.position, state.rotationDegrees, state.scale); + const transform = composeTransform(parentTransform, state); transformsByPartId.set(part.id, transform); const localBounds = getLocalBounds(part.geometry); @@ -680,7 +624,7 @@ const transformsRelativeToCommonAncestor = (leftChain, rightChain) => { shared += 1; } const compose = (chain) => chain.slice(shared).reduce( - (transform, part) => composeTransform(transform, part.position, part.rotationDegrees, part.scale), + (transform, part) => composeTransform(transform, part), IDENTITY_TRANSFORM, ); return [compose(leftChain), compose(rightChain)]; diff --git a/server/lib/threejsModelPhysicalAudit.test.js b/server/lib/threejsModelPhysicalAudit.test.js index 3e224a6565..1f3016866e 100644 --- a/server/lib/threejsModelPhysicalAudit.test.js +++ b/server/lib/threejsModelPhysicalAudit.test.js @@ -18,6 +18,27 @@ describe('threejsModelPhysicalAudit', () => { }); }); + it('still measures a spec whose rotation or scale is not a finite triple', () => { + // Reachable only past the schema — a record stored before a bound tightened. + // The shared transform reads a non-finite component as 0 degrees / scale 1 + // (what the renderer does). Feeding the raw value into Math.cos instead gave + // the part NaN world bounds, and every touch test against NaN is false, so a + // block sitting flat on the ground was reported as `floating-part`. + const spec = (rotationDegrees, scale) => ({ + name: 'Stacked Blocks', + parts: [ + { id: 'ground', name: 'Ground', geometry: { type: 'box', width: 6, height: 1, depth: 6 }, position: [0, 0, 0] }, + { id: 'block', name: 'Block', geometry: { type: 'box', width: 1, height: 1, depth: 1 }, position: [0, 1, 0], rotationDegrees, scale }, + ], + }); + const wellFormed = evaluateThreejsPhysicalAudit(spec([0, 0, 0], [1, 1, 1])); + const malformed = evaluateThreejsPhysicalAudit(spec([0, undefined, NaN], [NaN, 1, 1])); + + expect(malformed.evaluatedPartCount).toBe(2); + expect(malformed.findings.map((entry) => entry.code)).not.toContain('floating-part'); + expect(malformed.findings).toEqual(wellFormed.findings); + }); + it('evaluates clean static model without findings', () => { const spec = { name: 'Clean Box', diff --git a/server/lib/threejsTransform.js b/server/lib/threejsTransform.js new file mode 100644 index 0000000000..68be0019fc --- /dev/null +++ b/server/lib/threejsTransform.js @@ -0,0 +1,110 @@ +/** + * Affine transform primitives shared by every server-side gate that has to + * reconstruct where a Three.js scene-spec part actually sits in the world. + * + * `threejsModel.js` (flatness), `threejsModelPenetration.js` (cross-part + * overlap) and `threejsModelPhysicalAudit.js` (bounds and pose) each used to + * carry their own row-major 3x3 copy of this math. They were algebraically + * identical but textually different, so nothing stopped the three gates from + * silently starting to measure three different scenes — and they had already + * drifted on input hardening, which is why the coercion below is part of the + * contract rather than a caller's problem. + * + * Matrices are row-major 3x3 flat arrays; a transform is `{ linear, translation }`. + * The rotation composition matches `THREE.Euler` order 'XYZ' — what the preview + * canvas and the exported factory both apply. A different composition here would + * measure a part that is not the one on screen. + */ + +/** Degrees to radians. Not input-guarded: callers pass schema-validated angles. */ +export const degreesToRadians = (degrees) => (degrees * Math.PI) / 180; + +export const IDENTITY_LINEAR = [1, 0, 0, 0, 1, 0, 0, 0, 1]; + +/** Row-major 3x3 linear part plus a translation — an affine transform. */ +export const IDENTITY_TRANSFORM = { linear: [...IDENTITY_LINEAR], translation: [0, 0, 0] }; + +// A non-finite angle reads as 0 degrees — see `rotationMatrix`. +const angleCosSin = (degrees) => { + const radians = degreesToRadians(Number.isFinite(degrees) ? degrees : 0); + return [Math.cos(radians), Math.sin(radians)]; +}; + +/** + * Rotation matrix for `THREE.Euler` order 'XYZ'. + * + * A non-finite component reads as `0` degrees, which is what the renderer + * already does with a malformed stored spec. Feeding `null`/`NaN` into + * `Math.cos` instead would produce `NaN` bounds that every downstream gate + * reads as "no overlap" and "no defect" — a silently passing audit. + */ +export const rotationMatrix = (rotationDegrees = [0, 0, 0]) => { + const [x, y, z] = rotationDegrees || []; + const [c1, s1] = angleCosSin(x); + const [c2, s2] = angleCosSin(y); + const [c3, s3] = angleCosSin(z); + return [ + c2 * c3, -c2 * s3, s2, + (c1 * s3) + (s1 * c3 * s2), (c1 * c3) - (s1 * s3 * s2), -s1 * c2, + (s1 * s3) - (c1 * c3 * s2), (s1 * c3) + (c1 * s3 * s2), c1 * c2, + ]; +}; + +/** Diagonal scale matrix. A non-finite component reads as `1` — an unscaled axis. */ +export const scaleLinear = (scale = [1, 1, 1]) => { + const [x, y, z] = scale || []; + return [ + Number.isFinite(x) ? x : 1, 0, 0, + 0, Number.isFinite(y) ? y : 1, 0, + 0, 0, Number.isFinite(z) ? z : 1, + ]; +}; + +export const multiplyLinear = (a, b) => [ + (a[0] * b[0]) + (a[1] * b[3]) + (a[2] * b[6]), + (a[0] * b[1]) + (a[1] * b[4]) + (a[2] * b[7]), + (a[0] * b[2]) + (a[1] * b[5]) + (a[2] * b[8]), + (a[3] * b[0]) + (a[4] * b[3]) + (a[5] * b[6]), + (a[3] * b[1]) + (a[4] * b[4]) + (a[5] * b[7]), + (a[3] * b[2]) + (a[4] * b[5]) + (a[5] * b[8]), + (a[6] * b[0]) + (a[7] * b[3]) + (a[8] * b[6]), + (a[6] * b[1]) + (a[7] * b[4]) + (a[8] * b[7]), + (a[6] * b[2]) + (a[7] * b[5]) + (a[8] * b[8]), +]; + +export const applyLinear = (linear, [x, y, z]) => [ + (linear[0] * x) + (linear[1] * y) + (linear[2] * z), + (linear[3] * x) + (linear[4] * y) + (linear[5] * z), + (linear[6] * x) + (linear[7] * y) + (linear[8] * z), +]; + +export const applyTransform = (transform, point) => { + const rotated = applyLinear(transform.linear, point); + return [ + rotated[0] + transform.translation[0], + rotated[1] + transform.translation[1], + rotated[2] + transform.translation[2], + ]; +}; + +/** + * A part's world transform: the parent transform with the part's own local + * TRS applied inside it, composed rotation-then-scale the way Three.js does. + * + * The local TRS arrives object-shaped because that is the shape stored specs + * already use — a whole `part` can be passed straight through. + */ +export const composeTransform = (parent, { position, rotationDegrees, scale } = {}) => { + const local = multiplyLinear(rotationMatrix(rotationDegrees), scaleLinear(scale)); + const offset = applyLinear(parent.linear, position || [0, 0, 0]); + return { + linear: multiplyLinear(parent.linear, local), + translation: [ + offset[0] + parent.translation[0], + offset[1] + parent.translation[1], + offset[2] + parent.translation[2], + ], + }; +}; + +export const vectorLength = (vector) => Math.hypot(...vector); diff --git a/server/lib/threejsTransform.test.js b/server/lib/threejsTransform.test.js new file mode 100644 index 0000000000..4d331221cb --- /dev/null +++ b/server/lib/threejsTransform.test.js @@ -0,0 +1,116 @@ +import { describe, expect, it } from 'vitest'; + +import { + applyLinear, + applyTransform, + composeTransform, + IDENTITY_LINEAR, + IDENTITY_TRANSFORM, + multiplyLinear, + rotationMatrix, + scaleLinear, + vectorLength, +} from './threejsTransform.js'; + +const closeTo = (actual, expected) => { + expect(actual).toHaveLength(expected.length); + actual.forEach((value, index) => expect(value).toBeCloseTo(expected[index], 10)); +}; + +describe('rotationMatrix', () => { + // Hand-computed against THREE.Euler order 'XYZ'. A transposition or a swapped + // composition order still produces a plausible-looking rotation, so nothing + // downstream would localise the defect — these three pin the convention. + it('matches the XYZ single-axis matrices', () => { + closeTo(rotationMatrix([90, 0, 0]), [1, 0, 0, 0, 0, -1, 0, 1, 0]); + closeTo(rotationMatrix([0, 90, 0]), [0, 0, 1, 0, 1, 0, -1, 0, 0]); + closeTo(rotationMatrix([0, 0, 90]), [0, -1, 0, 1, 0, 0, 0, 0, 1]); + }); + + it('rotates a basis vector right-handed about each axis', () => { + closeTo(applyLinear(rotationMatrix([90, 0, 0]), [0, 1, 0]), [0, 0, 1]); + closeTo(applyLinear(rotationMatrix([0, 90, 0]), [1, 0, 0]), [0, 0, -1]); + closeTo(applyLinear(rotationMatrix([0, 0, 90]), [1, 0, 0]), [0, 1, 0]); + }); + + it('composes X then Y then Z in that order', () => { + const composed = multiplyLinear( + multiplyLinear(rotationMatrix([30, 0, 0]), rotationMatrix([0, 40, 0])), + rotationMatrix([0, 0, 50]), + ); + closeTo(rotationMatrix([30, 40, 50]), composed); + }); + + it('reads a non-finite component as zero degrees instead of emitting NaN', () => { + // The regression: a stored spec with a null/NaN rotation used to feed + // Math.cos directly, and the NaN bounds that came back read downstream as + // "no overlap" and "no defect" — a silently passing audit. + closeTo(rotationMatrix([null, NaN, undefined]), IDENTITY_LINEAR); + closeTo(rotationMatrix([Infinity, 0, 'ninety']), IDENTITY_LINEAR); + closeTo(rotationMatrix(null), IDENTITY_LINEAR); + closeTo(rotationMatrix(), IDENTITY_LINEAR); + }); +}); + +describe('scaleLinear', () => { + it('builds the diagonal matrix', () => { + closeTo(scaleLinear([2, 3, 4]), [2, 0, 0, 0, 3, 0, 0, 0, 4]); + closeTo(scaleLinear(), IDENTITY_LINEAR); + }); + + it('reads a non-finite component as an unscaled axis', () => { + closeTo(scaleLinear([null, NaN, 4]), [1, 0, 0, 0, 1, 0, 0, 0, 4]); + closeTo(scaleLinear(null), IDENTITY_LINEAR); + }); +}); + +describe('composeTransform', () => { + it('nests a rotated, scaled child inside a rotated parent', () => { + const parent = composeTransform(IDENTITY_TRANSFORM, { + position: [1, 0, 0], + rotationDegrees: [0, 90, 0], + }); + closeTo(parent.linear, [0, 0, 1, 0, 1, 0, -1, 0, 0]); + closeTo(parent.translation, [1, 0, 0]); + + const child = composeTransform(parent, { + position: [0, 0, 2], + rotationDegrees: [0, 0, 90], + scale: [2, 1, 1], + }); + closeTo(child.linear, [0, 0, 1, 2, 0, 0, 0, 1, 0]); + closeTo(child.translation, [3, 0, 0]); + // Hand-computed world position of the child's local +X unit point. + closeTo(applyTransform(child, [1, 0, 0]), [3, 2, 0]); + }); + + it('reads an absent local TRS as the parent transform', () => { + const parent = composeTransform(IDENTITY_TRANSFORM, { position: [1, 2, 3] }); + const child = composeTransform(parent, {}); + closeTo(child.linear, parent.linear); + closeTo(child.translation, parent.translation); + closeTo(composeTransform(IDENTITY_TRANSFORM).translation, [0, 0, 0]); + }); + + it('stays finite when a stored spec carries a non-finite rotation or scale', () => { + const child = composeTransform(IDENTITY_TRANSFORM, { + position: [1, 2, 3], + rotationDegrees: [null, undefined, NaN], + scale: [NaN, null, 2], + }); + closeTo(child.linear, [1, 0, 0, 0, 1, 0, 0, 0, 2]); + closeTo(child.translation, [1, 2, 3]); + }); + + it('leaves IDENTITY_TRANSFORM unmutated so every walk starts from the same frame', () => { + composeTransform(IDENTITY_TRANSFORM, { position: [5, 5, 5], scale: [9, 9, 9] }); + expect(IDENTITY_TRANSFORM).toEqual({ linear: IDENTITY_LINEAR, translation: [0, 0, 0] }); + }); +}); + +describe('vectorLength', () => { + it('measures a 3-vector', () => { + expect(vectorLength([3, 4, 0])).toBe(5); + expect(vectorLength(applyLinear(scaleLinear([2, 1, 1]), [1, 0, 0]))).toBe(2); + }); +}); From 75885cc19e6cfaff2198dcfe96378abf9beafcd4 Mon Sep 17 00:00:00 2001 From: "[._.]/ Adam Eivy" Date: Wed, 2 Sep 2026 06:35:27 +0000 Subject: [PATCH 2/2] refactor: freeze the shared identity transform constants (#5681) `IDENTITY_LINEAR` and `IDENTITY_TRANSFORM` used to be module-private literals in each of the three gates; exporting them makes one object the frame every walk in the process starts from, so a stray write would move it for every later caller. Freeze both, and pin it with a test. --- server/lib/threejsTransform.js | 10 ++++++++-- server/lib/threejsTransform.test.js | 4 ++++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/server/lib/threejsTransform.js b/server/lib/threejsTransform.js index 68be0019fc..4c13e97d3c 100644 --- a/server/lib/threejsTransform.js +++ b/server/lib/threejsTransform.js @@ -19,10 +19,16 @@ /** Degrees to radians. Not input-guarded: callers pass schema-validated angles. */ export const degreesToRadians = (degrees) => (degrees * Math.PI) / 180; -export const IDENTITY_LINEAR = [1, 0, 0, 0, 1, 0, 0, 0, 1]; +// Frozen: these are shared across every gate in the process, and each one used +// to be a module-private literal. A stray write would silently move the frame +// every subsequent walk starts from. +export const IDENTITY_LINEAR = Object.freeze([1, 0, 0, 0, 1, 0, 0, 0, 1]); /** Row-major 3x3 linear part plus a translation — an affine transform. */ -export const IDENTITY_TRANSFORM = { linear: [...IDENTITY_LINEAR], translation: [0, 0, 0] }; +export const IDENTITY_TRANSFORM = Object.freeze({ + linear: IDENTITY_LINEAR, + translation: Object.freeze([0, 0, 0]), +}); // A non-finite angle reads as 0 degrees — see `rotationMatrix`. const angleCosSin = (degrees) => { diff --git a/server/lib/threejsTransform.test.js b/server/lib/threejsTransform.test.js index 4d331221cb..025343bbf6 100644 --- a/server/lib/threejsTransform.test.js +++ b/server/lib/threejsTransform.test.js @@ -105,6 +105,10 @@ describe('composeTransform', () => { it('leaves IDENTITY_TRANSFORM unmutated so every walk starts from the same frame', () => { composeTransform(IDENTITY_TRANSFORM, { position: [5, 5, 5], scale: [9, 9, 9] }); expect(IDENTITY_TRANSFORM).toEqual({ linear: IDENTITY_LINEAR, translation: [0, 0, 0] }); + // Frozen rather than merely unwritten: these are process-wide now. + expect(Object.isFrozen(IDENTITY_TRANSFORM)).toBe(true); + expect(Object.isFrozen(IDENTITY_TRANSFORM.linear)).toBe(true); + expect(Object.isFrozen(IDENTITY_TRANSFORM.translation)).toBe(true); }); });