// 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 [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 [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');