// assembly_seq.js — workshop-style DISASSEMBLY / ASSEMBLY sequence for the // chat_cad car viewer ("human labor doing assembly"). // // Uses the window.__cadGesture hook (THREE/scene/camera + getParts()). On first // run each removable part mesh is split into its CONNECTED COMPONENTS (each // wheel, each lamp, each glass pane becomes its own mesh), then the parts are // removed ONE AT A TIME in a mechanic's order — wheels, rims, grille, lamps, // glass, underbody — each arcing out and landing in a labelled row on the // floor beside the car. Assembly runs the same sequence in reverse. // // Public API: window.__toggleDisassembly() -> 'disassembling'|'assembling'|'busy'|null (function () { let state = 'assembled'; // assembled | busy | disassembled let plan = null; // [{mesh, offset:Vector3, lift}] let THREE = null, H = null; const ORDER = ['wheel_tyre', 'wheel_rim', 'grille', 'headlight', 'taillight', 'glass', 'underbody']; const orderOf = (name) => { for (let i = 0; i < ORDER.length; i++) if (name.includes(ORDER[i])) return i; return ORDER.length; }; const isBody = (name) => name.includes('body') && !name.includes('under'); // ---- split an indexed mesh into connected components (union-find) ---- function splitComponents(mesh) { const g = mesh.geometry; const idx = g.getIndex(); if (!idx) return [mesh]; const ia = idx.array; // union-find over the vertex ids that this part actually uses const parent = new Map(); const find = (a) => { let r = a; while (parent.get(r) !== r) r = parent.get(r); while (parent.get(a) !== r) { const nx = parent.get(a); parent.set(a, r); a = nx; } return r; }; const uni = (a, b) => { const ra = find(a), rb = find(b); if (ra !== rb) parent.set(ra, rb); }; for (let i = 0; i < ia.length; i++) if (!parent.has(ia[i])) parent.set(ia[i], ia[i]); for (let i = 0; i < ia.length; i += 3) { uni(ia[i], ia[i + 1]); uni(ia[i], ia[i + 2]); } // bucket faces by component root const buckets = new Map(); for (let i = 0; i < ia.length; i += 3) { const r = find(ia[i]); let b = buckets.get(r); if (!b) { b = []; buckets.set(r, b); } b.push(ia[i], ia[i + 1], ia[i + 2]); } if (buckets.size <= 1) return [mesh]; const out = []; let k = 0; for (const faces of buckets.values()) { // skip debris fragments (< 30 faces) — keep them attached to component 0 if (faces.length / 3 < 30 && buckets.size > 2) { if (out[0]) { /* merge later */ } } const ng = new THREE.BufferGeometry(); ng.setAttribute('position', g.getAttribute('position')); if (g.getAttribute('normal')) ng.setAttribute('normal', g.getAttribute('normal')); ng.setIndex(faces); if (!g.getAttribute('normal')) ng.computeVertexNormals(); const nm = new THREE.Mesh(ng, mesh.material); nm.name = mesh.name + '_' + (k++); out.push(nm); } const parent3 = mesh.parent; out.forEach(m => parent3.add(m)); parent3.remove(mesh); return out; } function buildPlan() { const parts = H.getParts(); if (!parts || parts.length < 2) return null; // split every removable part into components (idempotent: split parts have '_N' suffix) let all = []; for (const m of parts) { if (isBody(m.name) || /_\d+$/.test(m.name)) { all.push(m); continue; } all = all.concat(splitComponents(m)); } const movers = all.filter(m => !isBody(m.name)); if (!movers.length) return null; // assembly bounds (from everything) const box = new THREE.Box3(); all.forEach(m => box.expandByObject(m)); const size = box.getSize(new THREE.Vector3()); const ground = box.min.z; const sideY = box.max.y + size.y * 0.55; // lay-down row beside the car // mechanic's order, then front-to-back within the same class const info = movers.map(m => { const b = new THREE.Box3().setFromObject(m); return { mesh: m, c: b.getCenter(new THREE.Vector3()), b }; }); info.sort((p, q) => (orderOf(p.mesh.name) - orderOf(q.mesh.name)) || (p.c.x - q.c.x)); // floor slots: two rows along X beside the car const n = info.length; const perRow = Math.ceil(n / 2); const stepX = (size.x * 1.15) / Math.max(perRow - 1, 1); plan = info.map((p, i) => { const row = Math.floor(i / perRow), col = i % perRow; const slot = new THREE.Vector3( box.min.x + col * stepX, sideY + row * size.y * 0.45, ground + (p.c.z - p.b.min.z)); // rest ON the floor return { mesh: p.mesh, offset: slot.sub(p.c), // translation that puts it in its slot lift: size.z * (0.5 + 0.25 * Math.random()) }; }); return plan; } const easeInOut = t => t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2; // rAF with a setTimeout watchdog: embedded/background tabs throttle rAF to // zero, which would freeze the sequence forever. Whichever fires first wins. function tick(cb) { let fired = false; const raf = requestAnimationFrame(t => { if (!fired) { fired = true; clearTimeout(to); cb(t); } }); const to = setTimeout(() => { if (!fired) { fired = true; cancelAnimationFrame(raf); cb(performance.now()); } }, 34); } function animateSeq(items, dir, done) { // dir +1 = remove, -1 = install const DUR = 650, GAP = 130; // ms per part / stagger const t0 = performance.now(); const list = dir > 0 ? items : [...items].reverse(); function frame(now) { let busy = false; for (let i = 0; i < list.length; i++) { const p = list[i]; const lt = (now - t0 - i * (DUR * 0.55 + GAP)) / DUR; // overlapped stagger if (lt < 0) { busy = true; continue; } const t = Math.min(1, lt); const e = easeInOut(t); const f = dir > 0 ? e : 1 - e; p.mesh.position.set(p.offset.x * f, p.offset.y * f, p.offset.z * f + p.lift * Math.sin(Math.PI * f)); if (t < 1) busy = true; } if (busy) tick(frame); else { list.forEach(p => { const f = dir > 0 ? 1 : 0; p.mesh.position.set(p.offset.x * f, p.offset.y * f, p.offset.z * f); }); done(); } } tick(frame); } window.__toggleDisassembly = async function () { H = window.__cadGesture; if (!H) { alert('Viewer not ready.'); return null; } THREE = H.THREE; if (state === 'busy') return 'busy'; if (state === 'assembled') { if (!plan) plan = buildPlan(); if (!plan && H.showParts) { // auto-load the labelled parts split state = 'busy'; updateBtn(); await H.showParts(); state = 'assembled'; plan = buildPlan(); } if (!plan) { alert('No multi-part car loaded yet — generate a car first (e.g. "draw me a car").'); updateBtn(); return null; } state = 'busy'; animateSeq(plan, +1, () => { state = 'disassembled'; updateBtn(); }); } else { state = 'busy'; animateSeq(plan, -1, () => { state = 'assembled'; updateBtn(); }); } updateBtn(); return state; }; function updateBtn() { const b = document.getElementById('disasmBtn'); if (!b) return; b.textContent = state === 'busy' ? 'Working…' : state === 'disassembled' ? 'Assemble' : 'Disassemble'; } })();