Spaces:
Running
Running
| // 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'); | |