chatcad / static /assembly_seq.js
Samarjithbiswas's picture
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
Raw
History Blame Contribute Delete
7.44 kB
// 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';
}
})();