// GENERATED FILE — do not edit by hand. // Source of truth: src/buddy_fusion/voxel_embed.py (VOXEL_SETUP_JS). // Regenerate with: python tools/shared/sync_voxel_rig.py // // This is the production in-game voxel render rig (Three.js scene, studio // lighting, real shape-based shadows, ACES tone mapping, turntable, OrbitControls, // and the window.voxel* builders). Booting it scans the document for // .buddy-voxel-root nodes and renders each from its .buddyVoxelConfig JSON. const VOXEL_THREE_URL = "https://esm.sh/three@0.170.0"; const VOXEL_OC_URL = "https://esm.sh/three@0.170.0/examples/jsm/controls/OrbitControls.js"; function voxelLoadThree() { if (!window.__VOXEL_THREE_PROMISE) window.__VOXEL_THREE_PROMISE = import(VOXEL_THREE_URL); return window.__VOXEL_THREE_PROMISE; } function voxelLoadOC() { if (!window.__VOXEL_OC_PROMISE) window.__VOXEL_OC_PROMISE = import(VOXEL_OC_URL); return window.__VOXEL_OC_PROMISE; } // Animal body-plan templates: the model picks an archetype, the voxelizer // builds a recognisable cute creature (Crossy-Road style) from it. `parts` // layer extra/mutated features on top. const VOXEL_ARCH = { chick: {body:[4,4,3], legs:2, legLen:2, head:[4,4,3], headMode:"top", feat:"beak"}, penguin: {body:[4,5,3], legs:2, legLen:1, head:[4,3,3], headMode:"top", feat:"beak"}, sheep: {body:[5,4,6], legs:4, legLen:2, head:[4,4,3], headMode:"front", feat:"ears"}, dog: {body:[4,3,6], legs:4, legLen:2, head:[4,3,3], headMode:"front", feat:"earsnout"}, horse: {body:[4,4,7], legs:4, legLen:3, head:[3,4,3], headMode:"front", feat:"snout"}, hippo: {body:[6,4,7], legs:4, legLen:1, head:[5,4,4], headMode:"front", feat:"snout"}, frog: {body:[6,3,5], legs:4, legLen:1, head:[6,2,5], headMode:"top", feat:"bigeyes"}, turtle: {body:[5,3,6], legs:4, legLen:1, head:[3,3,3], headMode:"front", feat:"shell"}, }; function voxelsFromGenome(g) { const cells = []; const box = (cx, cy, cz, w, h, d, color) => { for (let x=0;x box(x, 0, z, 1, legLen, 1, cLeg); if (A.legs === 2) { legAt(-1, lz - 1); legAt(1, lz - 1); } else { legAt(-lx, lz); legAt(lx, lz); legAt(-lx, -lz); legAt(lx, -lz); } // head const [hw, hh, hd] = A.head; let hyB, hz; // head bottom-y, head center-z if (A.headMode === "top") { hyB = bodyY + bh; hz = 1; } else { hyB = bodyY + Math.max(0, bh - hh) + 1; hz = Math.round(bd/2 + hd/2 - 0.5); } box(0, hyB, hz, hw, hh, hd, cBody); const headTop = hyB + hh, frontZ = hz + Math.ceil(hd/2); // eyes (this is what makes it read as alive) const eyeY = hyB + hh - 2, big = A.feat === "bigeyes"; const ex = Math.max(1, Math.round(hw/2 - 0.5)); for (const sx of [-ex, ex]) { if (big) { box(sx, headTop, hz, 1, 1, 1, WHITE); box(sx, headTop + 1, hz, 1, 1, 1, DARK); } else { box(sx, eyeY, frontZ - 1, 1, 1, 1, WHITE); box(sx, eyeY, frontZ, 1, 1, 1, DARK); } } // archetype feature if (A.feat === "beak") box(0, eyeY - 1, frontZ, 1, 1, 2, BEAK); if (A.feat === "ears" || A.feat === "earsnout") for (const sx of [-ex, ex]) box(sx, headTop, hz, 1, 1, 1, cFeat); if (A.feat === "snout" || A.feat === "earsnout") { box(0, hyB, frontZ, Math.max(1, hw - 2), 1, 1, cFeat); box(0, hyB, frontZ, 1, 1, 1, DARK); // nose } if (A.feat === "shell") box(0, bodyY + bh, 0, Math.max(1, bw - 1), 2, Math.max(1, bd - 1), cFeat); // genome parts = extra / mutated features layered on the archetype const sideX = bw / 2 + 0.5; for (const p of g.parts || []) { const n = Math.max(1, p.count | 0), col = p.color || cFeat; if (p.kind === "leg") { // extra legs along the belly for (let i = 0; i < n; i++) { const sx = (i % 2 ? 1 : -1) * lx; box(sx, 0, 0, 1, legLen, 1, col); } } else if (p.kind === "horn") { for (let i = 0; i < n; i++) box((i % 2 ? ex : -ex), headTop, hz, 1, 2, 1, col); } else if (p.kind === "wing") { for (const sx of [-1, 1]) box(sx * (sideX + 0.5), bodyY + 1, 0, 1, 2, 2, col); } else if (p.kind === "tail") { box(0, bodyY + Math.max(1, bh - 2), -(bd / 2 + 1), 1, 2, 2, col); } else if (p.kind === "arm" || p.kind === "paw") { for (const sx of [-1, 1]) { box(sx * (sideX + 0.5), bodyY + 1, 1, 1, 2, 1, col); if (p.tip && p.tip !== "none") box(sx * (sideX + 0.5), bodyY, 1, 1, 1, 1, col); } } else if (p.kind === "eye") { box(0, eyeY + 2, frontZ, 1, 1, 1, WHITE); box(0, eyeY + 2, frontZ + 0.0, 1, 1, 1, DARK); } else if (p.kind === "head") { // chimera / second head off to the side const sx2 = Math.round(bw / 2) + 1; box(sx2, hyB, hz, 3, 3, 3, col); box(sx2 - 1, hyB + 1, hz + 2, 1, 1, 1, DARK); box(sx2 + 1, hyB + 1, hz + 2, 1, 1, 1, DARK); if (p.animal === "bird") box(sx2, hyB, hz + 2, 1, 1, 2, BEAK); } } // Collapse cubes that share a cell to ONE (last writer wins, so genome parts // layer over archetype features). Coincident cubes have all six faces exactly // coplanar, which the depth buffer cannot resolve -> Z-fighting stripes when // two parts land on the same spot (e.g. a horn over an ear). De-duping by cell // removes the coincident geometry entirely (and trims the instance count). const dedup = new Map(); for (const cell of cells) dedup.set(cell.x + "," + cell.y + "," + cell.z, cell); return [...dedup.values()]; } // Freeform path: render a raw box list (model-emitted geometry) to cells. Same // coordinate convention as voxelsFromGenome (x,z = box center; y = bottom). The // final de-dupe removes coincident cells that cause Z-fighting stripes. function voxelsFromBoxes(boxes) { const cells = []; for (const b of boxes || []) { const cx = +b.x, cy = +b.y, cz = +b.z, w = +b.w, h = +b.h, d = +b.d; if ([cx, cy, cz, w, h, d].some((v) => !Number.isFinite(v))) continue; const color = b.color || "#caa06f"; for (let x = 0; x < w; x++) for (let y = 0; y < h; y++) for (let z = 0; z < d; z++) cells.push({ x: Math.round(cx + x - (w - 1) / 2), y: cy + y, z: Math.round(cz + z - (d - 1) / 2), color }); } const dedup = new Map(); for (const cell of cells) dedup.set(cell.x + "," + cell.y + "," + cell.z, cell); return [...dedup.values()]; } // Six cube faces. n = outward normal (also the neighbour cell to cull against); // u,v = the two in-plane axes used to place the 4 corners and to find the AO // neighbour cells. Corner order (-,-),(+,-),(+,+),(-,+). const VOXEL_FACES = [ { n:[ 1,0,0], u:[0,1,0], v:[0,0,1] }, { n:[-1,0,0], u:[0,1,0], v:[0,0,1] }, { n:[0, 1,0], u:[1,0,0], v:[0,0,1] }, { n:[0,-1,0], u:[1,0,0], v:[0,0,1] }, { n:[0,0, 1], u:[1,0,0], v:[0,1,0] }, { n:[0,0,-1], u:[1,0,0], v:[0,1,0] }, ]; const VOXEL_AO_SHADE = [0.55, 0.72, 0.86, 1.0]; // occluded(0) -> open(3) // Canonical voxel vertex-AO: count occupied among the two edge neighbours and // the diagonal corner; two edges occupied => fully dark. function voxelVertexAO(side1, side2, corner) { if (side1 && side2) return 0; return 3 - (side1 + side2 + corner); } // Voxel-by-voxel "build" order. Re-sorting the cells before meshing decides the // sequence in which bricks appear during the assembly animation (drawRange grows // cell-by-cell, and the merged geometry is laid out in this exact order). Default // bottom-up reads most as deliberate construction (stacking bricks); center-out // and side-sweep are swappable alternatives. const VOXEL_BUILD_ORDER = "bottom-up"; // "bottom-up" | "center-out" | "side-sweep" function voxelSortCells(cells, mode) { if (!cells || cells.length < 2) return cells; let cx = 0, cy = 0, cz = 0; for (const c of cells) { cx += c.x; cy += c.y; cz += c.z; } cx /= cells.length; cy /= cells.length; cz /= cells.length; const radXZ = (c) => (c.x - cx) * (c.x - cx) + (c.z - cz) * (c.z - cz); const rad3 = (c) => radXZ(c) + (c.y - cy) * (c.y - cy); const arr = cells.slice(); if (mode === "center-out") { arr.sort((a, b) => rad3(a) - rad3(b) || a.y - b.y || a.x - b.x || a.z - b.z); } else if (mode === "side-sweep") { arr.sort((a, b) => a.x - b.x || a.y - b.y || a.z - b.z); } else { // bottom-up: layer by layer, each layer spiralling out from the centre arr.sort((a, b) => a.y - b.y || radXZ(a) - radXZ(b) || a.x - b.x || a.z - b.z); } return arr; } // Reveal the first ``frac`` (0..1) of a built mesh's cells by clipping the draw // range to the cumulative vertex count recorded per cell. Cheap (no shader, no // re-upload) and shared by the live boot loop and the offline GIF renderer. function voxelApplyBuild(mesh, frac) { const ends = mesh.userData && mesh.userData.cellVertexEnds; if (!ends || !ends.length) return; const total = ends[ends.length - 1]; if (frac >= 1) { mesh.geometry.setDrawRange(0, total); return; } if (frac <= 0) { mesh.geometry.setDrawRange(0, 0); return; } const k = Math.floor(frac * ends.length); // cells revealed so far mesh.geometry.setDrawRange(0, k <= 0 ? 0 : ends[k - 1]); } // Merged BufferGeometry with baked per-vertex AO. We own the full voxel grid, so // AO is deterministic (not screen-space): hidden faces are culled, and each // visible face's 4 corners are darkened by neighbour occupancy. One mesh per // creature = one draw call, so dropping InstancedMesh costs nothing at our scale. // Keeps the 0.96 inset so cubes still read as discrete bricks. // Cells are sorted into build order first; the cumulative per-cell vertex count // is recorded on mesh.userData.cellVertexEnds so the assembly animation can grow // the draw range one brick at a time. // ``solid``: keep EVERY cube's 6 faces (skip hidden-face culling). The default // culled shell is a hollow surface — fine for a finished creature, but when it // assembles cube-by-cube you'd see into an empty interior. solid=true renders // each cell as a full 0.96 cube (the 0.04 gap keeps them reading as discrete // bricks, no z-fighting), so a partial build looks like a solid pile of cubes. function voxelBuildMesh(THREE, cells, solid) { const H = 0.48; // half of the 0.96 cube cells = voxelSortCells(cells, VOXEL_BUILD_ORDER); const occ = new Set(); for (const c of cells) occ.add(c.x + "," + c.y + "," + c.z); const has = (x, y, z) => occ.has(x + "," + y + "," + z); const pos = [], nor = [], col = [], cellVertexEnds = []; const base = new THREE.Color(); // Softer AO ramp in solid mode: the cube-by-cube share GIF should read clean // and bright (like a toy render), without the very-dark occluded edges the // full-strength ramp bakes in for the culled shell. const AO = solid ? [0.82, 0.89, 0.95, 1.0] : VOXEL_AO_SHADE; for (const c of cells) { base.set(c.color || "#caa06f"); for (const f of VOXEL_FACES) { const nx = f.n[0], ny = f.n[1], nz = f.n[2]; if (!solid && has(c.x + nx, c.y + ny, c.z + nz)) continue; // hidden face: skip (culled shell) const ux = f.u[0], uy = f.u[1], uz = f.u[2]; const vx = f.v[0], vy = f.v[1], vz = f.v[2]; const signs = [[-1,-1],[1,-1],[1,1],[-1,1]]; const cornerPos = [], cornerShade = []; for (const sg of signs) { const su = sg[0], sv = sg[1]; cornerPos.push([ c.x + nx*H + ux*H*su + vx*H*sv, c.y + ny*H + uy*H*su + vy*H*sv, c.z + nz*H + uz*H*su + vz*H*sv, ]); const s1 = has(c.x+nx+ux*su, c.y+ny+uy*su, c.z+nz+uz*su) ? 1 : 0; const s2 = has(c.x+nx+vx*sv, c.y+ny+vy*sv, c.z+nz+vz*sv) ? 1 : 0; const cn = has(c.x+nx+ux*su+vx*sv, c.y+ny+uy*su+vy*sv, c.z+nz+uz*su+vz*sv) ? 1 : 0; cornerShade.push(AO[voxelVertexAO(s1, s2, cn)]); } // Flip the quad diagonal to avoid AO interpolation seams (0fps trick). const flip = (cornerShade[0] + cornerShade[2]) < (cornerShade[1] + cornerShade[3]); const tris = flip ? [0,1,3, 1,2,3] : [0,1,2, 0,2,3]; for (const idx of tris) { const p = cornerPos[idx]; pos.push(p[0], p[1], p[2]); nor.push(nx, ny, nz); const sh = cornerShade[idx]; col.push(base.r * sh, base.g * sh, base.b * sh); } } cellVertexEnds.push(pos.length / 3); // cumulative vertex count after this cell } const geo = new THREE.BufferGeometry(); geo.setAttribute("position", new THREE.Float32BufferAttribute(pos, 3)); geo.setAttribute("normal", new THREE.Float32BufferAttribute(nor, 3)); geo.setAttribute("color", new THREE.Float32BufferAttribute(col, 3)); // DoubleSide: the per-face quad winding is not all-CCW across the 6 faces, so // FrontSide would back-face-cull three of them (-X/+Y/-Z) and the creature goes // see-through at the angles that expose them. Hidden faces are already culled // geometrically (one shell layer only), and normals are set explicitly outward, // so rendering both sides is correct and cheap here. const mat = new THREE.MeshStandardMaterial({ vertexColors: true, roughness: 0.85, metalness: 0.0, side: THREE.DoubleSide }); const mesh = new THREE.Mesh(geo, mat); mesh.userData.cellVertexEnds = cellVertexEnds; // per-cell cumulative vertex count for the build anim return mesh; } function voxelThumbRenderer(THREE) { if (!window.__voxelThumbRenderer) { const r = new THREE.WebGLRenderer({ antialias: true, preserveDrawingBuffer: true }); r.setPixelRatio(Math.min(2, window.devicePixelRatio || 1)); r.toneMapping = THREE.ACESFilmicToneMapping; r.toneMappingExposure = 1.25; // brighter, closer to the live/GIF rig if (THREE.SRGBColorSpace) r.outputColorSpace = THREE.SRGBColorSpace; window.__voxelThumbRenderer = r; } return window.__voxelThumbRenderer; } // Dominant hue of the creature -> a soft pastel background hue rotated AWAY from // it, so the body never sits on a same-hue wash (e.g. a red body never gets a red // bg). Low saturation, high lightness; vertical gradient. Cached by hue bucket. function voxelDominantHueDeg(THREE, cells) { const tally = new Map(); for (const cell of cells) tally.set(cell.color, (tally.get(cell.color) || 0) + 1); let best = null, bestN = -1; for (const [hex, n] of tally) { if (n > bestN) { bestN = n; best = hex; } } const c = new THREE.Color(); c.set(best || "#caa06f"); const hsl = {}; c.getHSL(hsl); return hsl.h * 360; } function voxelBgTexture(THREE, cells) { const hue = voxelDominantHueDeg(THREE, cells); const bgHue = ((hue + 150) % 360) / 360; // rotate away from the body hue const bucket = Math.round(bgHue * 24); // quantise for caching window.__voxelBg = window.__voxelBg || {}; if (!window.__voxelBg[bucket]) { const top = new THREE.Color().setHSL(bgHue, 0.32, 0.90); const bot = new THREE.Color().setHSL(bgHue, 0.38, 0.80); const cv = document.createElement("canvas"); cv.width = 4; cv.height = 256; const ctx = cv.getContext("2d"); const g = ctx.createLinearGradient(0, 0, 0, 256); g.addColorStop(0, "#" + top.getHexString()); g.addColorStop(1, "#" + bot.getHexString()); ctx.fillStyle = g; ctx.fillRect(0, 0, 4, 256); const tex = new THREE.CanvasTexture(cv); if (THREE.SRGBColorSpace) tex.colorSpace = THREE.SRGBColorSpace; window.__voxelBg[bucket] = tex; } return window.__voxelBg[bucket]; } // A soft round contact-shadow blob (dark centre -> transparent edge). Cheaper // than a shadow map and matches the reference (no visible ground, just a blob). function voxelShadowTexture(THREE) { if (!window.__voxelShadowTex) { const cv = document.createElement("canvas"); cv.width = 128; cv.height = 128; const ctx = cv.getContext("2d"); const g = ctx.createRadialGradient(64, 64, 4, 64, 64, 62); g.addColorStop(0, "rgba(0,0,0,0.42)"); g.addColorStop(0.6, "rgba(0,0,0,0.20)"); g.addColorStop(1, "rgba(0,0,0,0)"); ctx.fillStyle = g; ctx.fillRect(0, 0, 128, 128); window.__voxelShadowTex = new THREE.CanvasTexture(cv); } return window.__voxelShadowTex; } function voxelEaseReveal(p) { // ease-out-back: overshoots slightly past 1.0 then settles for a "pop". const c1 = 1.70158, c3 = c1 + 1, x = p - 1; return 1 + c3 * x * x * x + c1 * x * x; } async function setupVoxel(root) { if (!root || root.dataset.voxelInit === "1") return; const cfgEl = root.querySelector(".buddyVoxelConfig"); const stage = root.querySelector(".buddy-voxel-stage"); if (!cfgEl || !stage) return; // Defer boot until the stage has real layout. Gradio reveals a hidden column and // writes its HTML in the same tick; on the Space the stage can be 0x0 for a frame, // and booting then locked the renderer to the old 320x300 fallback -> the Splice // Bench rendered blank until a manual refresh. Bail WITHOUT marking inited so the // 400ms poll retries once layout settles (thumb stages have a fixed CSS size, so // they pass immediately). if (!stage.clientWidth || !stage.clientHeight) return; root.dataset.voxelInit = "1"; const cfg = JSON.parse(cfgEl.textContent); const THREE = await voxelLoadThree(); const W = stage.clientWidth, H = stage.clientHeight; const scene = new THREE.Scene(); const cam = new THREE.PerspectiveCamera(42, W/H, 0.1, 100); // Studio rig: warm key from upper-front, cool fill from the opposite lower // side, gentle hemisphere bounce. Tuned for ACES tone mapping (set on renderer). // Live stage rig, ported verbatim from the share GIF (buildBloodlineGif) so the // in-game creatures match the share render exactly: a bright even hemisphere so // no face (the head crown) reads dark, a high overhead key that CASTS a real // high-res shape-based shadow, and a cool fill. The shadow frustum is recentred // on the creature once bounds are known (below). scene.add(new THREE.HemisphereLight(0xffffff, 0xe7dcc6, 1.5)); const key = new THREE.DirectionalLight(0xfff6ea, 3.6); key.position.set(5, 22, 9); key.castShadow = true; key.shadow.mapSize.set(1024, 1024); key.shadow.camera.near = 1; key.shadow.camera.far = 64; key.shadow.camera.left = -11; key.shadow.camera.right = 11; key.shadow.camera.top = 11; key.shadow.camera.bottom = -11; key.shadow.bias = -0.0004; key.shadow.normalBias = 0.6; scene.add(key); scene.add(key.target); const fill = new THREE.DirectionalLight(0xdaf0ff, 1.1); fill.position.set(-7, 5, -3); scene.add(fill); let __bgCells = null; // Just the creature against the gradient sky background — no ground, podium, or // props. The renderer still accepts a multi-creature config (cfg.creatures), // but the app renders one creature per stage. Each creature keeps its own pivot // so it spins/reveals about its own centre. const genomes = (cfg.creatures && cfg.creatures.length) ? cfg.creatures : [cfg.genome]; const pivots = [], meshes = [], shadows = []; const sceneBox = new THREE.Box3(); genomes.forEach((g, i) => { const cells = (cfg.boxes && cfg.boxes.length) ? voxelsFromBoxes(cfg.boxes) : voxelsFromGenome(g); if (i === 0) __bgCells = cells; // solid=true (share-GIF parity): every cell is a full cube, so the cube-by-cube // build looks like a stacked pile instead of a hollow shell with an empty // interior, and it uses the brighter toy-render AO ramp. const mesh = voxelBuildMesh(THREE, cells, true); mesh.castShadow = true; // casts the real shaped shadow const bb = new THREE.Box3().setFromObject(mesh); const c = bb.getCenter(new THREE.Vector3()); const s = bb.getSize(new THREE.Vector3()); const gap = genomes.length > 1 ? (Math.max(s.x, s.z) + 3) : 0; const offX = genomes.length === 1 ? 0 : (i === 0 ? -gap : gap); const pivot = new THREE.Group(); mesh.position.sub(c); pivot.position.set(offX, c.y, 0); pivot.add(mesh); scene.add(pivot); pivots.push(pivot); meshes.push(mesh); const baseY = (c.y - s.y / 2) + 0.02; let shadow; if (cfg.thumb) { // Thumbnails render through the shared offscreen renderer (no shadow map, to // stay cheap across the whole tree), so they keep the soft blob sprite. shadow = new THREE.Mesh( new THREE.PlaneGeometry(1, 1), new THREE.MeshBasicMaterial({ map: voxelShadowTexture(THREE), transparent: true, depthWrite: false, opacity: 0.9 })); shadow.scale.set(s.x * 1.7 + 1, s.z * 1.7 + 1, 1); shadow.userData.maxOp = 0.9; } else { // Live stages get the share-GIF's real high-res shadow: an invisible // shadow-catcher plane that only darkens where the creature's projected // shadow lands (ShadowMaterial), so we get a clean shape-based contact // shadow, not a round blob. shadow = new THREE.Mesh( new THREE.PlaneGeometry(Math.max(s.x, s.z) * 4 + 8, Math.max(s.x, s.z) * 4 + 8), new THREE.ShadowMaterial({ opacity: 0.4 })); shadow.receiveShadow = true; shadow.userData.maxOp = 0.4; } shadow.rotation.x = -Math.PI / 2; shadow.position.set(offX, baseY, 0); scene.add(shadow); shadows.push(shadow); sceneBox.expandByPoint(new THREE.Vector3(offX - s.x / 2, c.y - s.y / 2, -s.z / 2)); sceneBox.expandByPoint(new THREE.Vector3(offX + s.x / 2, c.y + s.y / 2, s.z / 2)); }); // Per-creature pastel background, hue-rotated away from the body so it frames // without colliding (a red body never sits on a red wash). scene.background = voxelBgTexture(THREE, __bgCells || []); const sCenter = sceneBox.getCenter(new THREE.Vector3()); const baseRadius = sceneBox.getSize(new THREE.Vector3()).length() * 0.7 + 3; // framePad (>1) pulls the camera back so the creature reads smaller within the // frame (used on the bench so the top hint bubbles have clear space); frameLift // raises the aim point so the creature sits lower in the frame. Defaults keep // the tree / reveal / modal framing unchanged. const radius = baseRadius * (cfg.framePad || 1.0); const aim = sCenter.clone(); aim.y += baseRadius * (cfg.frameLift || 0.0); // Aim the shadow-casting key at the scene base so its high-res shadow frustum // stays centred on the creature. key.target.position.set(sCenter.x, sceneBox.min.y, sCenter.z); key.target.updateMatrixWorld(); // Near-frontal "hero" pose ported from the share GIF: azimuth ~22°, elevation // ~18° (mostly the front face + a peek of the top), so the creature reads like a // standard 3D-model initial view and the head crown is no longer the focal top. cam.position.set(aim.x + radius * 0.46, aim.y + radius * 0.40, aim.z + radius * 1.16); cam.lookAt(aim.x, aim.y * 0.92, aim.z); if (cfg.thumb) { // Render via ONE shared offscreen renderer reused across every thumbnail. // One WebGL context per thumbnail would blow past the browser's ~16-context // cap as the family tree grows and silently blank the oldest stages; a // single shared context keeps the total flat regardless of tree size. pivots.forEach((p) => { p.rotation.y = 0.7; }); const shared = voxelThumbRenderer(THREE); shared.setSize(W, H); shared.render(scene, cam); try { const url = shared.domElement.toDataURL("image/png"); const img = document.createElement("img"); img.src = url; img.style.width = "100%"; img.style.height = "100%"; img.style.objectFit = "contain"; img.style.display = "block"; stage.replaceChildren(img); } catch (e) {} return; } const renderer = new THREE.WebGLRenderer({ antialias: true }); renderer.setSize(W, H); renderer.setPixelRatio(Math.min(2, window.devicePixelRatio || 1)); renderer.toneMapping = THREE.ACESFilmicToneMapping; renderer.toneMappingExposure = 1.4; // share-GIF parity renderer.shadowMap.enabled = true; // real shape-based shadow renderer.shadowMap.type = THREE.PCFSoftShadowMap; if (THREE.SRGBColorSpace) renderer.outputColorSpace = THREE.SRGBColorSpace; stage.appendChild(renderer.domElement); const { OrbitControls } = await voxelLoadOC(); const controls = new OrbitControls(cam, renderer.domElement); controls.target.copy(aim); controls.enablePan = false; controls.update(); // Voxel-by-voxel build: grow each mesh's draw range cell-by-cell so the // creature assembles one brick at a time ("built by hand"). Plays once on // boot for every live stage (cfg.build !== false); the contact shadow fades in // with it. Parents on the reveal screen pass build=false to appear pre-built. const doBuild = cfg.build !== false; const maxCells = meshes.reduce((m, x) => Math.max(m, ((x.userData.cellVertexEnds) || []).length), 0); // seconds; window.__voxelBuildDur overrides (tuning / visual verification). const buildDur = window.__voxelBuildDur || Math.max(0.55, Math.min(1.5, maxCells * 0.011)); let bt = doBuild ? 0 : 1; if (doBuild) { meshes.forEach((m) => voxelApplyBuild(m, 0)); shadows.forEach((s) => { s.material.opacity = 0; }); } const shadowOp = (s, f) => { s.material.opacity = (s.userData.maxOp || 0.9) * f; }; let t0 = performance.now(); function loop(now) { // Stop + release the WebGL context when the stage leaves the DOM OR is culled // off the canvas (voxelTeardown). Clearing voxelInit lets a pooled node re-boot // when it pans back into view. if (!root.isConnected || root.dataset.voxelTeardown === "1") { renderer.dispose(); if (renderer.forceContextLoss) renderer.forceContextLoss(); const cv = renderer.domElement; if (cv && cv.parentNode) cv.parentNode.removeChild(cv); delete root.dataset.voxelInit; delete root.dataset.voxelTeardown; return; } const dt = Math.min(0.05, (now - t0)/1000); t0 = now; if (bt < 1) { bt = Math.min(1, bt + dt / buildDur); meshes.forEach((m) => voxelApplyBuild(m, bt)); shadows.forEach((s) => { shadowOp(s, bt); }); } pivots.forEach((p) => { p.rotation.y += dt * 0.5; }); controls.update(); renderer.render(scene, cam); requestAnimationFrame(loop); } requestAnimationFrame(loop); } // ---- JS -> Python bridges -------------------------------------------------- // Hidden Gradio textbox + button (elem_classes=["buddy-bridge-box"], CSS-hidden, // NOT visible=False). Write the payload with the native value setter so the // Svelte store syncs, then .click() the button whose Python handler reads it. // (A dispatched input event alone does not reliably fire the handler — see // wiki/gradio-embed-integration.md.) function buddyBridgeSet(wrapId, value) { const wrap = document.getElementById(wrapId); if (!wrap) return false; const el = wrap.querySelector("textarea, input"); if (!el) return false; const proto = el.tagName === "TEXTAREA" ? window.HTMLTextAreaElement.prototype : window.HTMLInputElement.prototype; const setter = Object.getOwnPropertyDescriptor(proto, "value").set; setter.call(el, value); el.dispatchEvent(new Event("input", { bubbles: true })); return true; } function buddyBridgeClick(btnId) { const wrap = document.getElementById(btnId); const btn = wrap && (wrap.tagName === "BUTTON" ? wrap : wrap.querySelector("button")); if (btn) btn.click(); } function buddySelect(cid) { if (buddyBridgeSet("buddy-select-in", cid)) buddyBridgeClick("buddy-select-submit"); } function buddyChip(side, prompt) { if (buddyBridgeSet("buddy-chip-in", JSON.stringify({ side: side, prompt: prompt }))) buddyBridgeClick("buddy-chip-submit"); } // Compendium (📖): add a built-in creature to the lineage. The badge flips on // instantly (client-side) for responsive feedback; the bridge then commits the // add server-side and refreshes the (hidden) Lab tree + slots. function buddyDexAdd(card) { if (!card || !card.dataset.dexId) return; card.classList.add("is-added"); if (buddyBridgeSet("buddy-dex-in", card.dataset.dexId)) buddyBridgeClick("buddy-dex-submit"); } // Pure client-side theme filter over the (statically rendered) compendium grid — // no server round-trip, so it stays snappy across all 50+ thumbnails. function buddyDexFilter(theme) { document.querySelectorAll(".buddy-dex-chip").forEach(function (c) { c.classList.toggle("is-active", c.dataset.theme === theme); }); document.querySelectorAll(".buddy-dex-card").forEach(function (card) { card.style.display = (theme === "all" || card.dataset.theme === theme) ? "" : "none"; }); } // Auto-advance to the Splice Bench once two creatures are staged. The bench reveal // MUST be driven by a dedicated bridge whose Gradio chain only toggles screens + // fills the bench — Gradio lazily mounts hidden columns and drops a column's // visible=True if the same response also writes the Pick screen's children. So // on_select just updates state/slots/tree and marks .buddy-slots[data-ready=1]; // this watcher then clicks the clean #buddy-adv-submit bridge exactly once. function buddyAutoAdvance() { var slots = document.querySelector(".buddy-slots"); // No slots in the DOM = the Pick screen is hidden (e.g. we're on the bench). Leave // the guard untouched, or returning via Back (slots still ready) would re-fire the // advance and bounce straight back to the bench. Only a genuine deselect on a // VISIBLE Pick screen (data-ready=0) re-arms the next advance. if (!slots) return; if (slots.dataset.ready === "1") { if (!window.__buddyAdvancing) { window.__buddyAdvancing = true; buddyBridgeClick("buddy-adv-submit"); } } else { window.__buddyAdvancing = false; } } // ---- pedigree tree interactions (delegated, survives re-renders) ----------- function installTreeInteractions() { if (window.__buddyTreeClicks) return; window.__buddyTreeClicks = true; document.addEventListener("click", (e) => { const chip = e.target.closest(".buddy-chip"); if (chip) { e.stopPropagation(); buddyChip(chip.dataset.side, chip.dataset.prompt); return; } const dexChip = e.target.closest(".buddy-dex-chip"); if (dexChip) { e.stopPropagation(); buddyDexFilter(dexChip.dataset.theme); return; } const dexCard = e.target.closest(".buddy-dex-card"); if (dexCard && dexCard.dataset.dexId) { buddyDexAdd(dexCard); return; } const card = e.target.closest(".buddy-tree-card"); // A canvas pan that ends on a node must NOT also select it (Stitch-style: // drag empty space OR a node to pan; a clean tap selects). if (card && card.dataset.cid && !window.__buddyPanned) { buddySelect(card.dataset.cid); return; } }); window.addEventListener("resize", drawTreeEdges); } // Parent→child connectors. Columns flow left→right by generation (parents on the // left), so lines run from a parent's right edge to the child's left edge. function drawTreeEdges() { document.querySelectorAll(".buddy-tree-wrap").forEach((wrap) => { const svg = wrap.querySelector(".buddy-tree-edges"); if (!svg) return; const wr = wrap.getBoundingClientRect(); svg.setAttribute("width", wr.width); svg.setAttribute("height", wr.height); svg.style.width = wr.width + "px"; svg.style.height = wr.height + "px"; const cards = {}; wrap.querySelectorAll(".buddy-tree-card").forEach((c) => { cards[c.dataset.cid] = c; }); let paths = ""; wrap.querySelectorAll(".buddy-tree-card[data-parents]").forEach((child) => { const ps = (child.dataset.parents || "").split(",").filter(Boolean); if (!ps.length) return; const cb = child.getBoundingClientRect(); const cx = cb.left - wr.left, cy = cb.top + cb.height / 2 - wr.top; ps.forEach((pid) => { const p = cards[pid]; if (!p) return; const pb = p.getBoundingClientRect(); const px = pb.right - wr.left, py = pb.top + pb.height / 2 - wr.top; const mx = (px + cx) / 2; paths += ``; }); }); svg.innerHTML = paths; }); } // ---- pannable canvas (Figma / Google-Stitch style) ------------------------- // The pedigree lives on a transformed pan layer inside a fixed-size clipping // viewport (.buddy-canvas). Left-drag pans; the dot grid (a tiling background on // the canvas) is shifted by the same offset so it reads as an infinite canvas. // The offset is cached on window so a tree re-render (which replaces the canvas // HTML) restores the player's last position instead of snapping back to centre. function installCanvasPan() { document.querySelectorAll(".buddy-canvas").forEach((canvas) => { if (canvas.dataset.panInit === "1") return; canvas.dataset.panInit = "1"; const pan = canvas.querySelector(".buddy-canvas-pan"); if (!pan) return; const off = window.__buddyCanvasOff || (window.__buddyCanvasOff = { x: 0, y: 0 }); let dragging = false, sx = 0, sy = 0, bx = 0, by = 0, raf = 0; const apply = () => { pan.style.transform = "translate(" + off.x + "px," + off.y + "px)"; canvas.style.backgroundPosition = off.x + "px " + off.y + "px"; }; apply(); // NB: do NOT setPointerCapture here — capturing the pointer on the canvas // swallows the node's `click`, so tapping a creature would never select it. // Move/up live on window so a drag keeps tracking past the canvas edge; a plain // tap (no movement) leaves __buddyPanned false and the node click selects. canvas.addEventListener("pointerdown", (e) => { if (e.button !== 0) return; dragging = true; window.__buddyPanned = false; sx = e.clientX; sy = e.clientY; bx = off.x; by = off.y; canvas.classList.add("is-grabbing"); }); window.addEventListener("pointermove", (e) => { if (!dragging) return; const dx = e.clientX - sx, dy = e.clientY - sy; if (Math.abs(dx) + Math.abs(dy) > 4) window.__buddyPanned = true; off.x = bx + dx; off.y = by + dy; if (!raf) raf = requestAnimationFrame(() => { raf = 0; apply(); cullCanvasNodes(); }); }); const end = () => { if (!dragging) return; dragging = false; canvas.classList.remove("is-grabbing"); cullCanvasNodes(); // Let the synthetic click fire (and be ignored via __buddyPanned) before re-arming. setTimeout(() => { window.__buddyPanned = false; }, 60); }; window.addEventListener("pointerup", end); window.addEventListener("pointercancel", end); }); } // Superseded by installTreeScissor (the tree now renders through ONE shared WebGL // context, not one-per-node). Kept as a no-op so the pan handler's existing calls // stay harmless. function cullCanvasNodes() {} // Build a creature's scene/camera/pivot WITHOUT a renderer, for the scissor pass. // Mirrors setupVoxel's live (non-thumb) rig: bright hemisphere + a high overhead // key that casts a real shaped shadow + a cool fill, ACES tone mapping, the // near-frontal hero camera, and the per-creature pastel background. function _treeBuildScene(THREE, cfg) { const scene = new THREE.Scene(); scene.add(new THREE.HemisphereLight(0xffffff, 0xe7dcc6, 1.5)); const key = new THREE.DirectionalLight(0xfff6ea, 3.6); key.position.set(5, 22, 9); key.castShadow = true; key.shadow.mapSize.set(256, 256); // small per-card map: bounds memory across many nodes key.shadow.camera.near = 1; key.shadow.camera.far = 64; key.shadow.camera.left = -11; key.shadow.camera.right = 11; key.shadow.camera.top = 11; key.shadow.camera.bottom = -11; key.shadow.bias = -0.0004; key.shadow.normalBias = 0.6; scene.add(key); scene.add(key.target); const fill = new THREE.DirectionalLight(0xdaf0ff, 1.1); fill.position.set(-7, 5, -3); scene.add(fill); const genomes = (cfg.creatures && cfg.creatures.length) ? cfg.creatures : [cfg.genome]; const cells = (cfg.boxes && cfg.boxes.length) ? voxelsFromBoxes(cfg.boxes) : voxelsFromGenome(genomes[0]); const mesh = voxelBuildMesh(THREE, cells, true); mesh.castShadow = true; const bb = new THREE.Box3().setFromObject(mesh); const ctr = bb.getCenter(new THREE.Vector3()), sz = bb.getSize(new THREE.Vector3()); const pivot = new THREE.Group(); mesh.position.sub(ctr); pivot.position.set(0, ctr.y, 0); pivot.add(mesh); scene.add(pivot); const shadow = new THREE.Mesh( new THREE.PlaneGeometry(Math.max(sz.x, sz.z) * 4 + 8, Math.max(sz.x, sz.z) * 4 + 8), new THREE.ShadowMaterial({ opacity: 0.4 })); shadow.receiveShadow = true; shadow.rotation.x = -Math.PI / 2; shadow.position.set(0, (ctr.y - sz.y / 2) + 0.02, 0); scene.add(shadow); key.target.position.set(0, bb.min.y, 0); key.target.updateMatrixWorld(); scene.background = voxelBgTexture(THREE, cells); const cam = new THREE.PerspectiveCamera(42, 1, 0.1, 100); const radius = (sz.length() * 0.7 + 3) * (cfg.framePad || 1.0); cam.position.set(radius * 0.46, ctr.y + radius * 0.40, radius * 1.16); cam.lookAt(0, ctr.y * 0.92, 0); return { scene, cam, pivot }; } // One WebGL context per .buddy-canvas: a renderer sits inside the canvas (above // its dot-grid background, below the panning card layer) and draws every node // card into its on-screen rect via the scissor test, clipped to the canvas // viewport. Card thumbs are transparent windows onto it. Re-runs each poll to // (re)attach after Gradio swaps the tree HTML and to pick up new/removed nodes. function installTreeScissor() { if (!window.voxelLoadThree) return; document.querySelectorAll(".buddy-canvas").forEach((canvas) => { // Detect the live renderer by its actual canvas child, NOT a JS-prop lock: // Gradio reuses the .buddy-canvas element and swaps its innerHTML on re-render, // which strips our GL canvas but leaves element properties intact. Keying on the // real child means a stripped renderer is correctly re-created. const existing = canvas.querySelector(":scope > canvas[data-tree-gl]"); if (existing) { if (existing.__rescan) existing.__rescan(); return; } if (canvas.__treeInit) return; // an init is already in flight for this element canvas.__treeInit = true; voxelLoadThree().then((THREE) => { canvas.__treeInit = false; if (canvas.querySelector(":scope > canvas[data-tree-gl]")) return; // another init won the race const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true }); renderer.setPixelRatio(Math.min(2, window.devicePixelRatio || 1)); renderer.setScissorTest(true); renderer.shadowMap.enabled = true; renderer.shadowMap.type = THREE.PCFSoftShadowMap; renderer.toneMapping = THREE.ACESFilmicToneMapping; renderer.toneMappingExposure = 1.4; if (THREE.SRGBColorSpace) renderer.outputColorSpace = THREE.SRGBColorSpace; renderer.setClearColor(0x000000, 0); const cv = renderer.domElement; cv.dataset.treeGl = "1"; cv.style.cssText = "position:absolute;top:0;left:0;width:100%;height:100%;z-index:0;pointer-events:none;"; canvas.insertBefore(cv, canvas.firstChild); const state = { cards: [] }; function rescan() { const seen = new Set(); canvas.querySelectorAll(".buddy-tree-card .buddy-voxel-root").forEach((root) => { const stage = root.querySelector(".buddy-voxel-stage"); const cfgEl = root.querySelector(".buddyVoxelConfig"); if (!stage || !cfgEl) return; seen.add(root); if (root.__treeCard) return; let cfg; try { cfg = JSON.parse(cfgEl.textContent); } catch (e) { return; } const card = { root, stage, cfg, built: false, scene: null, cam: null, pivot: null }; root.__treeCard = card; state.cards.push(card); }); state.cards = state.cards.filter((c) => seen.has(c.root)); } cv.__rescan = rescan; rescan(); let last = 0; function loop(now) { if (cv.parentNode !== canvas || !canvas.isConnected) { // our canvas was stripped/detached → release renderer.dispose(); if (renderer.forceContextLoss) renderer.forceContextLoss(); return; } requestAnimationFrame(loop); const dt = Math.min(0.05, (now - (last || now)) / 1000); last = now; const cw = canvas.clientWidth, ch = canvas.clientHeight; if (!cw || !ch) return; if (renderer.domElement.width !== Math.floor(cw * renderer.getPixelRatio())) renderer.setSize(cw, ch, false); const cr = canvas.getBoundingClientRect(); renderer.setScissorTest(false); renderer.clear(); renderer.setScissorTest(true); for (const c of state.cards) { const r = c.stage.getBoundingClientRect(); const xl = r.left - cr.left, yt = r.top - cr.top; const ix0 = Math.max(0, xl), iy0 = Math.max(0, yt); const ix1 = Math.min(cw, xl + r.width), iy1 = Math.min(ch, yt + r.height); if (ix1 <= ix0 || iy1 <= iy0) continue; // panned off the canvas viewport if (!c.built) { const b = _treeBuildScene(THREE, c.cfg); c.scene = b.scene; c.cam = b.cam; c.pivot = b.pivot; c.built = true; } c.pivot.rotation.y += dt * 0.5; c.cam.aspect = r.width / r.height; c.cam.updateProjectionMatrix(); renderer.setViewport(xl, ch - (yt + r.height), r.width, r.height); // full card view... renderer.setScissor(ix0, ch - iy1, ix1 - ix0, iy1 - iy0); // ...clipped to the visible part renderer.render(c.scene, c.cam); } } requestAnimationFrame(loop); }); }); } // The Splice Bench × token sits in its own flex slot between the two scenes; a // fixed margin can't stay centred as fonts/scene sizes shift. Measure and nudge it // so its centre matches the scene's vertical centre (self-correcting each tick). function centerBenchX() { document.querySelectorAll(".buddy-bench-row").forEach((row) => { const x = row.querySelector(".buddy-bench-x"), scene = row.querySelector(".buddy-bench-scene"); if (!x || !scene) return; const rr = row.getBoundingClientRect(), sr = scene.getBoundingClientRect(), xr = x.getBoundingClientRect(); const want = (sr.top - rr.top) + sr.height / 2 - xr.height / 2; const have = xr.top - rr.top; const delta = want - have; if (Math.abs(delta) > 1) x.style.marginTop = ((parseFloat(getComputedStyle(x).marginTop) || 0) + delta) + "px"; }); } function scanAndSetupVoxels() { document.querySelectorAll(".buddy-voxel-root").forEach((r) => { if (r.closest(".buddy-canvas")) return; // canvas nodes are pooled by cullCanvasNodes setupVoxel(r); }); } function installVoxelObserver() { if (window.__voxelObserver) return; window.__voxelObserver = new MutationObserver((muts) => { for (const mu of muts) for (const n of mu.addedNodes) { if (n.nodeType !== 1) continue; if (n.classList && n.classList.contains("buddy-voxel-root") && !n.closest(".buddy-canvas")) setupVoxel(n); if (n.querySelectorAll) n.querySelectorAll(".buddy-voxel-root").forEach((r) => { if (!r.closest(".buddy-canvas")) setupVoxel(r); }); } }); window.__voxelObserver.observe(document.body, { childList: true, subtree: true }); } function installVoxelPoll() { // Safety net: Gradio re-renders gr.HTML via Svelte innerHTML swaps, and the // MutationObserver misses some of those nodes when several HTML components // update in the same tick. A cheap idempotent poll (setupVoxel early-returns // on already-inited roots) guarantees every new creature gets booted, the canvas // gets its pan handlers, off-screen nodes are culled, and the bench × stays centred. if (window.__voxelPoll) return; window.__voxelPoll = setInterval(function () { scanAndSetupVoxels(); installCanvasPan(); installTreeScissor(); drawTreeEdges(); centerBenchX(); buddyAutoAdvance(); }, 400); } window.setupVoxel = setupVoxel; // Exposed for the one-off headless export of transparent buddy PNGs used by the // landing backdrop (see scripts / fusion_ui landing field), and reused by the // share-screen lineage-GIF builder (a separate js_on_load scope — it can only // reach these through window). window.voxelLoadThree = voxelLoadThree; window.voxelsFromGenome = voxelsFromGenome; window.voxelsFromBoxes = voxelsFromBoxes; window.voxelBuildMesh = voxelBuildMesh; window.voxelApplyBuild = voxelApplyBuild; window.voxelBgTexture = voxelBgTexture; window.voxelShadowTexture = voxelShadowTexture; // --- standalone boot (reports only; app uses its own VOXEL_JS_ON_LOAD) --- // A page with many cards sets window.__VOXEL_NO_AUTOBOOT before loading this rig // and drives setupVoxel itself through an IntersectionObserver context-cap // manager (one live WebGL context per visible card would blow the browser's // ~16-context limit). Pages with a handful of cards just let it auto-boot. if (!window.__VOXEL_NO_AUTOBOOT) { installVoxelObserver(); scanAndSetupVoxels(); setInterval(scanAndSetupVoxels, 400); }