img2threejs / scripts /node_smoke.mjs
Mike0021's picture
Verify generated geometry against live spec
324b9a7 verified
Raw
History Blame Contribute Delete
5.1 kB
// node_smoke.mjs — headless scene-graph smoke test for a bundled factory.
//
// Asserts the generated artifact actually executes and produces a non-empty
// THREE.Group — without needing WebGL (three's scene graph is DOM-free; the
// generated factory falls back to flat materials when `document` is absent).
//
// Usage: node scripts/node_smoke.mjs <model.bundle.js> [compile-spec.json]
import * as THREE from 'three'; // only used for Box3/Vector3 helpers
import { readFile } from 'node:fs/promises';
import { resolve } from 'node:path';
import { pathToFileURL } from 'node:url';
const bundlePath = process.argv[2];
if (!bundlePath) {
console.error('usage: node node_smoke.mjs <model.bundle.js> [compile-spec.json]');
process.exit(2);
}
const specPath = process.argv[3];
let expectedComponents = null;
if (specPath) {
const spec = JSON.parse(await readFile(specPath, 'utf8'));
if (!Array.isArray(spec.componentTree) || spec.componentTree.length === 0) {
console.error('FAIL: supplied spec has no componentTree');
process.exit(1);
}
expectedComponents = spec.componentTree;
}
const mod = await import(pathToFileURL(resolve(bundlePath)).href);
const makeModel = mod.makeModel
|| Object.values(mod).find((f) => typeof f === 'function' && /Model$/.test(f.name));
if (!makeModel) {
console.error('FAIL: no create*Model export in bundle');
process.exit(1);
}
const group = makeModel({ textureSize: 128, qualityPriority: 'balanced' });
// Duck-typing, not instanceof: the bundle carries its own THREE copy, so
// instanceof against node_modules' THREE would always fail.
if (!group || group.isGroup !== true) {
console.error('FAIL: factory did not return a THREE.Group');
process.exit(1);
}
let meshes = 0;
group.traverse((node) => { if (node.isMesh) meshes += 1; });
const size = new THREE.Box3().setFromObject(group).getSize(new THREE.Vector3()).toArray();
const runtime = group.userData && group.userData.sculptRuntime;
const geometryTypes = {};
const componentBounds = {};
const inspectedIds = expectedComponents
? expectedComponents.map((component) => component.id)
: ['body', 'handle'];
for (const id of inspectedIds) {
const mesh = runtime && runtime.meshes && runtime.meshes[id];
if (!mesh) continue;
geometryTypes[id] = mesh.geometry && mesh.geometry.type;
mesh.geometry.computeBoundingBox();
const localSize = mesh.geometry.boundingBox.getSize(new THREE.Vector3());
componentBounds[id] = localSize.toArray()
.map((value, axis) => value * Math.abs(mesh.scale.getComponent(axis)))
.map((value) => Number(value.toFixed(4)));
}
const report = {
type: group.type,
children: group.children.length,
meshes,
runtimeNodes: runtime && runtime.nodes ? Object.keys(runtime.nodes).length : 0,
boundingBox: size.map((v) => Number(v.toFixed(4))),
geometryTypes,
componentBounds,
};
const failures = [];
if (group.children.length < 1) failures.push('empty scene graph');
if (meshes < 1) failures.push('no meshes');
if (!size.every(Number.isFinite)) failures.push('non-finite bounding box');
if (!runtime || !runtime.nodes) failures.push('missing userData.sculptRuntime');
const expectedGeometry = {
box: 'BoxGeometry',
sphere: 'SphereGeometry',
ellipsoid: 'SphereGeometry',
cylinder: 'CylinderGeometry',
cone: 'ConeGeometry',
capsule: 'CapsuleGeometry',
torus: 'TorusGeometry',
'plane-card': 'PlaneGeometry',
};
const near = (actual, expected) =>
Math.abs(actual - expected) <= Math.max(0.002, Math.abs(expected) * 0.03);
const assertComponent = (component) => {
const { id, primitive, dimensions } = component;
const mesh = runtime && runtime.meshes && runtime.meshes[id];
if (!mesh) {
failures.push(`missing runtime mesh for component ${id}`);
return;
}
const geometryName = expectedGeometry[primitive];
if (geometryName && geometryTypes[id] !== geometryName) {
failures.push(`${id} is ${geometryTypes[id]}, expected ${geometryName}`);
}
const expected = dimensions && [dimensions.width, dimensions.height, dimensions.depth];
const actual = componentBounds[id];
if (!expected || !expected.every((value) => Number.isFinite(value) && value > 0)) {
failures.push(`invalid expected dimensions for ${id}`);
return;
}
const axes = primitive === 'plane-card' ? [0, 1] : [0, 1, 2];
if (!actual || !axes.every((axis) => near(actual[axis], expected[axis]))) {
failures.push(
`${id} dimensions disagree with spec: actual=${JSON.stringify(actual)} expected=${JSON.stringify(expected)}`,
);
}
};
if (expectedComponents) {
expectedComponents.forEach(assertComponent);
} else if (runtime && runtime.meshes && runtime.meshes.body && runtime.meshes.handle) {
assertComponent({
id: 'body', primitive: 'cylinder',
dimensions: { width: 0.08, height: 0.1, depth: 0.08 },
});
assertComponent({
id: 'handle', primitive: 'torus',
dimensions: { width: 0.04, height: 0.06, depth: 0.015 },
});
}
console.log(JSON.stringify(report));
if (failures.length) {
console.error(`FAIL: ${failures.join('; ')}`);
process.exit(1);
}
console.log('OK');