// 분해 배치 — 보이는 조각만 겹치지 않게 선반 채우기(shelf packing) // 흡수 출처: ashemag/human-atlas (MIT) 의 explosion-layout 개념. 구현은 우리 것. // 핵심 규율: "겹치지 않는다"를 눈이 아니라 **스크립트가 단언**한다. /** * @param {{id:string, w:number, h:number}[]} parts 조각의 투영 크기 * @param {number} aspect 화면 가로/세로 비 * @returns {{cells:Map, width:number, height:number}} */ export function explodeLayout(parts, aspect = 1) { const PAD = 0.10; const cards = parts.map(p => ({ id: p.id, w: Math.max(0.05, p.w) + PAD, h: Math.max(0.05, p.h) + PAD, })); const area = cards.reduce((n, c) => n + c.w * c.h, 0); const maxW = Math.max(0.3, ...cards.map(c => c.w)); // 목표 폭: 면적을 화면비에 맞춰 정사각에 가깝게 const targetW = Math.max(maxW, Math.sqrt(area * Math.max(0.5, Math.min(1.8, aspect))) * 1.15); // 높은 것부터 채우면 선반 낭비가 준다. id 로 타이브레이크해 결과를 결정적으로. cards.sort((a, b) => b.h - a.h || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)); const cells = new Map(); let x = 0, y = 0, rowH = 0, usedW = 0; for (const c of cards) { if (x > 0 && x + c.w > targetW) { x = 0; y += rowH; rowH = 0; } cells.set(c.id, { x: x + c.w / 2, y: -y - c.h / 2, w: c.w, h: c.h }); x += c.w; usedW = Math.max(usedW, x); rowH = Math.max(rowH, c.h); } const height = y + rowH; // 중앙 정렬 cells.forEach(c => { c.x -= usedW / 2; c.y += height / 2; }); return { cells, width: usedW, height }; } /** 두 칸이 겹치는가 (경계 접촉은 허용) */ export function overlaps(a, b, eps = 1e-9) { return Math.abs(a.x - b.x) < (a.w + b.w) / 2 - eps && Math.abs(a.y - b.y) < (a.h + b.h) / 2 - eps; }