viewer/validate.mjs
7.2 KB · 80 lines · javascript · Download from the repository page
// Structural validation of the component model (no renderer). Run: node viewer/validate.mjsimport { COMPONENTS, DEFINITIONS, MODEL } from './components.js';import { explodedOffset } from './explode.js'; const res = []; const pass = (n, d = '') => res.push(['PASS', n, d]); const fail = (n, d = '') => res.push(['FAIL', n, d]);const byId = new Map(COMPONENTS.map((c) => [c.id, c])); // 1. unique IDsconst dup = COMPONENTS.map((c) => c.id).filter((id, i, a) => a.indexOf(id) !== i);dup.length ? fail('unique component IDs', dup.join(',')) : pass('unique component IDs', `${COMPONENTS.length} components`);// 2. parents resolve, no cycleslet bad = COMPONENTS.filter((c) => c.parent !== null && !byId.has(c.parent)).map((c) => c.id);bad.length ? fail('parents resolve', bad.join(',')) : pass('parents resolve');let cyc = 0; for (const c of COMPONENTS) { const seen = new Set(); let p = c.parent; while (p) { if (seen.has(p)) { cyc++; break; } seen.add(p); p = byId.get(p)?.parent; } }cyc ? fail('hierarchy acyclic', `${cyc} cycles`) : pass('hierarchy acyclic');// 3. parts/instances/references have geometry; assemblies don't need itbad = COMPONENTS.filter((c) => c.kind !== 'assembly' && !(c.geometry && c.geometry.length)).map((c) => c.id);bad.length ? fail('non-assembly components have geometry', bad.join(',')) : pass('non-assembly components have geometry');// 4. instance definitions resolve and instance counts match qtybad = COMPONENTS.filter((c) => c.kind === 'instance' && !DEFINITIONS[c.def]).map((c) => c.id);bad.length ? fail('instance definitions resolve', bad.join(',')) : pass('instance definitions resolve');for (const [d, def] of Object.entries(DEFINITIONS)) { const n = COMPONENTS.filter((c) => c.def === d).length; n === def.qty ? pass(`instances of ${d} = qty`, `${n}`) : fail(`instances of ${d} = qty`, `${n} instances vs qty ${def.qty}`); }// 5. explode vectors finite; every component has confidence + sourcebad = COMPONENTS.filter((c) => !c.explode || c.explode.length !== 3 || c.explode.some((v) => !Number.isFinite(v))).map((c) => c.id);bad.length ? fail('explode vectors well-formed', bad.join(',')) : pass('explode vectors well-formed');bad = COMPONENTS.filter((c) => !(c.confidence || (c.def && DEFINITIONS[c.def].confidence)) || !(c.source || (c.def && DEFINITIONS[c.def].source))).map((c) => c.id);bad.length ? fail('confidence + source on every component', bad.join(',')) : pass('confidence + source on every component');// 6. geometry inside the envelope (allow shutter above the top face, ribs behind, ports/door below)const env = MODEL.envelope; const tol = { x: [0, 190], y: [-4, 98], z: [-1, 40] }; void env;function specBox(s) { if (s.type === 'box' || s.type === 'plate' || s.type === 'ring') return { x: s.x, y: s.y, z: s.z }; const [cx, cy, cz] = s.c, h = s.len / 2; return { x: s.axis === 'x' ? [cx - h, cx + h] : [cx - s.r, cx + s.r], y: s.axis === 'y' ? [cy - h, cy + h] : [cy - s.r, cy + s.r], z: s.axis === 'z' ? [cz - h, cz + h] : [cz - s.r, cz + s.r] };}bad = [];for (const c of COMPONENTS) for (const s of c.geometry || []) { const b = specBox(s); for (const ax of ['x', 'y', 'z']) if (b[ax][0] < tol[ax][0] - 1e-9 || b[ax][1] > tol[ax][1] + 1e-9 || b[ax][0] >= b[ax][1]) bad.push(`${c.id} ${ax} ${b[ax]}`); }bad.length ? fail('geometry within envelope tolerance', bad.join(' | ')) : pass('geometry within envelope tolerance', 'X 0–190, Y −4…98, Z −1…40');// 7. AABB interference between leaf components (different top-level or same parent), excluding intended contactsconst leaves = COMPONENTS.filter((c) => c.geometry && c.geometry.length);const overlap = (a, b) => ['x', 'y', 'z'].every((ax) => Math.min(a[ax][1], b[ax][1]) - Math.max(a[ax][0], b[ax][0]) > 0.05);const allowed = (a, b) => { const pair = [a.id, b.id]; const any = (re) => pair.some((p) => re.test(p)); if (any(/^pad-/) || any(/^boss-/) || any(/window|zone|light-guide|gasket|card-|screw|pin|detent|rail|magnet/)) return true; // magnets are potted in slat 2 if (pair.includes('m2-nvme') && pair.includes('m2-nvme-socket')) return true; // mutually exclusive by SKU // pads/bosses/reference/zones/fasteners intentionally touch or pass through if (a.kind === 'reference' || b.kind === 'reference') return true; if (any(/^usb-c|rj45|rj45-door|button|vent|insert|tpu|bumper|end-cap|top-wall|bottom-wall|front-frame$/)) return true; // pass-through connectors and frame/wall contacts (cut-outs not modelled) return false;};const inter = [];for (let i = 0; i < leaves.length; i++) for (let j = i + 1; j < leaves.length; j++) { const a = leaves[i], b = leaves[j]; if (allowed(a, b)) continue; for (const sa of a.geometry) for (const sb of b.geometry) if (overlap(specBox(sa), specBox(sb))) { inter.push(`${a.id} × ${b.id}`); }}const uniqInter = [...new Set(inter)];uniqInter.length ? fail('no unintended AABB interference between parts', uniqInter.join(' | ')) : pass('no unintended AABB interference between parts', `${leaves.length} leaf components checked`);// 8. explosion math: 0% → assembled, 100% → offset, deterministic, no drift over cyclesconst opts = { scope: null, expandChildren: true };let maxErr0 = 0, maxErrCycle = 0, nonZero100 = 0;for (const c of COMPONENTS) { const o0 = explodedOffset(c, 0, opts); maxErr0 = Math.max(maxErr0, ...o0.map(Math.abs)); const o1 = explodedOffset(c, 1, opts); if (o1.some((v, k) => Math.abs(Math.abs(v) - Math.abs(c.explode[k])) > 1e-12)) nonZero100++; let ref = explodedOffset(c, 0.37, opts); for (let k = 0; k < 500; k++) { explodedOffset(c, 1, opts); explodedOffset(c, 0, opts); const again = explodedOffset(c, 0.37, opts); maxErrCycle = Math.max(maxErrCycle, ...again.map((v, i) => Math.abs(v - ref[i]))); }}maxErr0 === 0 ? pass('explosion 0% equals assembled', 'max |offset| = 0') : fail('explosion 0% equals assembled', `max |offset| ${maxErr0}`);nonZero100 === 0 ? pass('explosion 100% equals authored offset') : fail('explosion 100% equals authored offset', `${nonZero100} mismatches`);maxErrCycle === 0 ? pass('explosion deterministic over 500 cycles', 'drift 0') : fail('explosion deterministic', `drift ${maxErrCycle}`);// 9. nested offsets applied once: scoped explosion moves only direct children of the scopeconst scoped = { scope: 'battery-pack', expandChildren: false };const moved = COMPONENTS.filter((c) => explodedOffset(c, 1, scoped).some((v) => v !== 0)).map((c) => c.id);const expected = COMPONENTS.filter((c) => c.parent === 'battery-pack' && c.explode.some((v) => v)).map((c) => c.id);JSON.stringify(moved.sort()) === JSON.stringify(expected.sort()) ? pass('scoped explosion moves only the scope\'s children', moved.length + ' parts') : fail('scoped explosion', `moved ${moved} expected ${expected}`);// 10. whole-object explosion without expansion moves only top-level componentsconst top = COMPONENTS.filter((c) => explodedOffset(c, 1, { scope: null, expandChildren: false }).some((v) => v !== 0)).every((c) => c.parent === null);top ? pass('whole-object explosion (no expand) moves only top-level components') : fail('whole-object explosion moved a child'); const fails = res.filter((r) => r[0] === 'FAIL').length;for (const [s, n, d] of res) console.log(`${s.padEnd(5)} ${n}${d ? ' — ' + d : ''}`);console.log(`\n${res.length - fails} PASS, ${fails} FAIL`);process.exit(fails ? 1 : 0);