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..4c13e97d3c --- /dev/null +++ b/server/lib/threejsTransform.js @@ -0,0 +1,116 @@ +/** + * 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; + +// 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 = Object.freeze({ + linear: IDENTITY_LINEAR, + translation: Object.freeze([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..025343bbf6 --- /dev/null +++ b/server/lib/threejsTransform.test.js @@ -0,0 +1,120 @@ +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] }); + // 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); + }); +}); + +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); + }); +});