Spaces:
Sleeping
Sleeping
Commercial overhaul: clean glass+taillight boundaries (conforming split), model-locked AI render (ControlNet img2img + paint tint), gesture control, workshop disassembly, part info cards, pro UI + new How-it-works
9032518 verified | // 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'; | |
| } | |
| })(); | |