Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions server/lib/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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). |
Expand Down
1 change: 1 addition & 0 deletions server/lib/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
57 changes: 9 additions & 48 deletions server/lib/threejsModel.js
Original file line number Diff line number Diff line change
Expand Up @@ -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}$/);
Expand Down Expand Up @@ -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) {
Expand Down
74 changes: 7 additions & 67 deletions server/lib/threejsModelPenetration.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
16 changes: 16 additions & 0 deletions server/lib/threejsModelPenetration.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
72 changes: 8 additions & 64 deletions server/lib/threejsModelPhysicalAudit.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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) {
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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)];
Expand Down
21 changes: 21 additions & 0 deletions server/lib/threejsModelPhysicalAudit.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Loading