// Place a cast figure — optionally beside the companion — into a fitted frame. // usage: yarn node - [companion.svg] [side] < compose-scene.cjs // side = left | right (which side the companion stands on; default right) // // Implements the three cohesion rules from cast.md that govern a shared frame: // 2. the companion is 50% of STANDING adult height and never rescales for posture // 3. every Open Peeps figure gets a ground shadow — it ships without one, the companion has // one, and adding it is the single change that made the two systems sit in one room // Rule 1 (the companion's monopoly on saturated teal) needs nothing here: the peep atoms are // already ink + pale mint only. // // Bounds are MEASURED, not tabled. Pose bounds vary hugely — the bicycle needs ~1725 units // where a standing figure needs ~975 — so the figure is rasterised at low density, its ink // bounding box taken, and the result converted back to user units. Nothing to update when a // new pose is approved. const sharp = require('sharp'); const fs = require('node:fs'); const path = require('node:path'); const COMPANION_RATIO = 0.5; // cast.md rule 2, tolerance 45-55% const SHADOW_RY = 9, SHADOW_RX_FACTOR = 0.34, SHADOW_INK = '#113833', SHADOW_OPACITY = 0.13; const PAD = 40, DENSITY = 40; const flatten = (svg) => svg .replace(/var\(--peep-ink,\s*(#[0-9A-Fa-f]{6})\)/g, '$1') .replace(/var\(--peep-fill,\s*(#[0-9A-Fa-f]{6})\)/g, '$1'); const viewBoxOf = (svg) => (/viewBox="([^"]+)"/).exec(svg)[1].trim().split(/[\s,]+/).map(Number); async function measure(file) { const svg = fs.readFileSync(file, 'utf8'); const [vbX, vbY, vbW, vbH] = viewBoxOf(svg); // Measure through an EXPANDED viewBox. Several poses draw outside the rig box — the bicycle // and the wheelchair both do — so rasterising at the rig's own viewBox clips them and the // ink box comes back exactly the rig's width, which looks like a valid measurement and is // not. This is the same overflow the reference implementation compensates for by hand with // x=-1450/width=4200 for Bike; expanding first measures it instead. const grown = `${vbX - vbW} ${vbY - vbH} ${vbW * 3} ${vbH * 3}`; const probe = flatten(svg).replace(/viewBox="[^"]+"/, `viewBox="${grown}"`); const { data, info } = await sharp(Buffer.from(probe), { density: DENSITY }) .ensureAlpha().raw().toBuffer({ resolveWithObject: true }); let x0 = info.width, x1 = -1, y0 = info.height, y1 = -1; for (let y = 0; y < info.height; y++) for (let x = 0; x < info.width; x++) { if (data[(y * info.width + x) * info.channels + 3] > 8) { if (x < x0) x0 = x; if (x > x1) x1 = x; if (y < y0) y0 = y; if (y > y1) y1 = y; } } const k = (vbW * 3) / info.width; // px -> user units, through the grown box const ox = vbX - vbW, oy = vbY - vbH; // grown-box origin, back to rig coords const m = { svg, vbW, vbH, x: ox + x0 * k, y: oy + y0 * k, w: (x1 - x0 + 1) * k, h: (y1 - y0 + 1) * k }; if (m.x < vbX - 1 || m.y < vbY - 1 || m.x + m.w > vbX + vbW + 1 || m.y + m.h > vbY + vbH + 1) { console.log(` note: ${path.basename(file)} draws outside its rig viewBox — measured ${m.w.toFixed(0)}x${m.h.toFixed(0)} against rig ${vbW}x${vbH}`); } return m; } (async () => { const [personFile, outFile, companionFile, sideArg] = process.argv.slice(2); if (!outFile) { console.error('usage: compose-scene.cjs [companion.svg] [left|right]'); process.exit(1); } const side = sideArg === 'left' ? 'left' : 'right'; const p = await measure(personFile); const parts = []; const shadow = (cx, cy, w) => ``; // The person is drawn at native scale; everything else is positioned around them. const floor = p.y + p.h; let minX = p.x, maxX = p.x + p.w; const personBlock = [shadow(p.x + p.w / 2, floor, p.w), `${p.svg.slice(p.svg.indexOf('>', p.svg.indexOf(''))}`]; let companionBlock = []; if (companionFile) { const c = await measure(companionFile); const scale = (p.h * COMPANION_RATIO) / c.h; // 50% of the person's height const gap = p.w * 0.22; const cW = c.w * scale; const cx = side === 'right' ? maxX + gap : minX - gap - cW; // Translate so the companion's own ink box lands at (cx, floor-height), then scale about // the origin — order matters, the translate is in post-scale space. const tx = cx - c.x * scale, ty = (floor - c.h * scale) - c.y * scale; companionBlock = [`${c.svg.slice(c.svg.indexOf('>', c.svg.indexOf(''))}`]; minX = Math.min(minX, cx); maxX = Math.max(maxX, cx + cW); console.log(` ${path.basename(outFile).padEnd(38)} companion ${(100 * (c.h * scale) / p.h).toFixed(0)}% of figure height, ${side}`); } else { console.log(` ${path.basename(outFile).padEnd(38)} solo`); } const vbX = minX - PAD, vbY = p.y - PAD; const vbW = (maxX - minX) + PAD * 2, vbH = (floor - p.y) + PAD * 2 + SHADOW_RY; parts.push(``); parts.push(...personBlock, ...companionBlock, '', ''); fs.mkdirSync(path.dirname(outFile), { recursive: true }); fs.writeFileSync(outFile, parts.join('\n')); })();