Face Lattice

// init-lattice.js — The Face Lattice // 2D net view of Platonic solid faces with hinge relationships // Adjacent to the Dice world — same shapes, unfolded flat

(function() { if (typeof THREE === 'undefined') { console.warn('init-lattice: THREE.js not loaded'); return; }

// ── Load font: Space Mono (monospace with character) ── var LATTICE_FONT = 'Space Mono, monospace'; if (!document.getElementById('lattice-font')) { var link = document.createElement('link'); link.id = 'lattice-font'; link.rel = 'stylesheet'; link.href = 'https://fonts.googleapis.com/css2?family=Space+Mono:wght@400;700&display=swap'; document.head.appendChild(link); }

// ── Solid catalog ── var SOLIDS = [ { name: 'Tetrahedron', p: 3, q: 3 }, { name: 'Cube', p: 4, q: 3 }, { name: 'Octahedron', p: 3, q: 4 }, { name: 'Dodecahedron', p: 5, q: 3 }, { name: 'Icosahedron', p: 3, q: 5 } ]; var SOLID_COLORS = [0x8B8B8B, 0xA7A7A7, 0x8B8B8B, 0x707070, 0x707070]; var currentIdx = 0; var solidCache = {};

// ── State ── var faceMeshes = []; // THREE.Mesh per face var faceLabels = []; // THREE.Sprite per face (number label) var hingeLines = []; // THREE.Line per hinge var hingeLabels = []; // DOM elements for angle labels var selectedFace = -1; var layoutData = null; // { positions, hinges, adjacency } var currentSolid = null; var foldProgress = 0; // 0 = flat, 1 = fully folded var folding = false; var foldDir = 1;

// ── Wisp handle state ── var handleMeshes = []; // THREE.Mesh per grid vertex (octahedra) var handleGroup = new THREE.Group(); var hoveredHandle = null; // currently hovered handle mesh var draggedHandle = null; // currently dragged handle mesh var dragStartY = 0; // mouse Y at drag start var dragStartHandleY = 0; // handle Y at drag start var HANDLE_IDLE_COLOR = 0x8B8B8B; var HANDLE_HOVER_COLOR = 0xC3C3C3; var HANDLE_DRAG_COLOR = 0xE1E1E1; var HANDLE_SCALE = 0.12;

// ── Scene setup ── var container = document.getElementById('world-canvas'); if (!container) { container = document.createElement('div'); container.id = 'world-canvas'; container.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;z-index:0;'; document.body.insertBefore(container, document.body.firstChild); } container.style.display = 'block';

var scene = new THREE.Scene(); scene.background = new THREE.Color(0x121212);

// Orthographic camera — sheets stack aligned, no perspective drift var orthoSize = 12; // half-extent of visible area (updated per solid) var aspect = window.innerWidth / window.innerHeight; var camera = new THREE.OrthographicCamera( -orthoSize * aspect, orthoSize * aspect, orthoSize, -orthoSize, 0.1, 200 ); camera.position.set(0, 40, 0.01); // nearly pure top-down camera.lookAt(0, 0, 0);

var renderer = new THREE.WebGLRenderer({ antialias: true, alpha: false }); renderer.setSize(window.innerWidth, window.innerHeight); renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); renderer.toneMapping = THREE.ACESFilmicToneMapping; renderer.toneMappingExposure = 1.2; container.innerHTML = ''; container.appendChild(renderer.domElement);

var controls = null; if (THREE.OrbitControls) { controls = new THREE.OrbitControls(camera, renderer.domElement); controls.enableDamping = true; controls.dampingFactor = 0.08; controls.target.set(0, 0, 0); controls.enableRotate = true; // tilt to see mylar depth controls.minZoom = 0.3; controls.maxZoom = 5; }

function setOrthoSize(size) { orthoSize = size; var a = window.innerWidth / window.innerHeight; camera.left = -orthoSize * a; camera.right = orthoSize * a; camera.top = orthoSize; camera.bottom = -orthoSize; camera.updateProjectionMatrix(); }

function onResize() { var a = window.innerWidth / window.innerHeight; camera.left = -orthoSize * a; camera.right = orthoSize * a; camera.top = orthoSize; camera.bottom = -orthoSize; camera.updateProjectionMatrix(); renderer.setSize(window.innerWidth, window.innerHeight); } window.addEventListener('resize', onResize);

// Lighting var ambient = new THREE.AmbientLight(0x3E3E3E, 0.8); scene.add(ambient); var dirLight = new THREE.DirectionalLight(0xFFFFFF, 1.0); dirLight.position.set(5, 12, 5); scene.add(dirLight);

// Face-shaped tiling grid — mylar sheet system var gridGroup = new THREE.Group(); scene.add(gridGroup); scene.add(handleGroup);

var subdivLevel = 4; // sheets stacked per face (1–12)

// ── Collectors: line segments, grid points, sub-cell vertices ── var _segments = []; // line pairs: [ax,0,-az, bx,0,-bz, ...] var _points = []; // grid intersection points: [x, z, x, z, ...] var _cells = []; // triangle triplets: [ax,az, bx,bz, cx,cz, ...] (6 per cell)

function collectLine(ax, az, bx, bz) { _segments.push(ax, 0, -az, bx, 0, -bz); }

function collectTriSub(v0, v1, v2, n) { function pt(i, j) { var w0 = (n-i-j)/n, w1 = i/n, w2 = j/n; return [v0[0]*w0 + v1[0]*w1 + v2[0]*w2, v0[1]*w0 + v1[1]*w1 + v2[1]*w2]; } // Grid lines for (var i = 0; i <= n; i++) { for (var j = 0; j <= n - i; j++) { var p = pt(i, j); _points.push(p[0], p[1]); // grid vertex if (i < n - j) { var p2 = pt(i+1, j); collectLine(p[0], p[1], p2[0], p2[1]); } if (j < n - i) { var p3 = pt(i, j+1); collectLine(p[0], p[1], p3[0], p3[1]); } if (i > 0 && j < n - i + 1) { var p4 = pt(i-1, j+1); collectLine(p[0], p[1], p4[0], p4[1]); } } } // Sub-cell triangles for (var i = 0; i < n; i++) { for (var j = 0; j < n - i; j++) { var a = pt(i, j), b = pt(i+1, j), c = pt(i, j+1); _cells.push(a[0], a[1], b[0], b[1], c[0], c[1]); if (i + j + 1 < n) { var d = pt(i+1, j+1); _cells.push(b[0], b[1], d[0], d[1], c[0], c[1]); } } } }

function collectQuadSub(v0, v1, v2, v3, n) { function pt(i, j) { var u = i / n, w = j / n; return [ v0[0]*(1-u)*(1-w) + v1[0]*u*(1-w) + v2[0]*u*w + v3[0]*(1-u)*w, v0[1]*(1-u)*(1-w) + v1[1]*u*(1-w) + v2[1]*u*w + v3[1]*(1-u)*w ]; } // Grid lines + points for (var i = 0; i <= n; i++) { for (var j = 0; j <= n; j++) { var p = pt(i, j); _points.push(p[0], p[1]); if (i < n) { var pr = pt(i+1, j); collectLine(p[0], p[1], pr[0], pr[1]); } if (j < n) { var pu = pt(i, j+1); collectLine(p[0], p[1], pu[0], pu[1]); } } } // Sub-cell triangles (quad split along diagonal) for (var i = 0; i < n; i++) { for (var j = 0; j < n; j++) { var a = pt(i, j), b = pt(i+1, j), c = pt(i+1, j+1), d = pt(i, j+1); _cells.push(a[0], a[1], b[0], b[1], d[0], d[1]); _cells.push(b[0], b[1], c[0], c[1], d[0], d[1]); } } }

function collectPentSub(verts, n) { var cx = 0, cz = 0; for (var i = 0; i < verts.length; i++) { cx += verts[i][0]; cz += verts[i][1]; } cx /= verts.length; cz /= verts.length; var center = [cx, cz]; for (var i = 0; i < 5; i++) { collectTriSub(center, verts[i], verts[(i + 1) % 5], n); } }

// ── Build merged mylar sheet geometry (all faces in one mesh) ── function buildMylarGeo(layout) { var shapes = []; for (var fi = 0; fi < layout.positions.length; fi++) { var pos = layout.positions[fi]; if (!pos) continue; var verts = pos.verts; var shape = new THREE.Shape(); shape.moveTo(verts[0][0], verts[0][1]); for (var vi = 1; vi < verts.length; vi++) shape.lineTo(verts[vi][0], verts[vi][1]); shape.closePath(); shapes.push(shape); } // Merge all face shapes into one geometry var geos = []; for (var si = 0; si < shapes.length; si++) geos.push(new THREE.ShapeGeometry(shapes[si])); var merged = THREE.BufferGeometryUtils ? THREE.BufferGeometryUtils.mergeBufferGeometries(geos) : mergeGeos(geos); for (var gi = 0; gi < geos.length; gi++) geos[gi].dispose(); return merged; }

// Fallback merge if BufferGeometryUtils not available function mergeGeos(geos) { var totalVerts = 0, totalIdx = 0; for (var i = 0; i < geos.length; i++) { totalVerts += geos[i].attributes.position.count; totalIdx += geos[i].index ? geos[i].index.count : 0; } var pos = new Float32Array(totalVerts * 3); var idx = totalIdx > 0 ? new Uint32Array(totalIdx) : null; var vOff = 0, iOff = 0, baseV = 0; for (var i = 0; i < geos.length; i++) { var gp = geos[i].attributes.position.array; pos.set(gp, vOff); vOff += gp.length; if (idx && geos[i].index) { var gi = geos[i].index.array; for (var j = 0; j < gi.length; j++) idx[iOff + j] = gi[j] + baseV; iOff += gi.length; } baseV += geos[i].attributes.position.count; } var geo = new THREE.BufferGeometry(); geo.setAttribute('position', new THREE.BufferAttribute(pos, 3)); if (idx) geo.setIndex(new THREE.BufferAttribute(idx, 1)); return geo; }

function clearGridGroup() { gridGroup.traverse(function(obj) { if (obj.geometry) obj.geometry.dispose(); if (obj.material) obj.material.dispose(); }); while (gridGroup.children.length > 0) gridGroup.remove(gridGroup.children[0]); }

function addPoly(target, sides, r, cx, cz, rot, mat) { var pts = polygonVerts2D(sides, r, cx, cz, rot); var points = []; for (var i = 0; i <= pts.length; i++) { var p = pts[i % pts.length]; points.push(new THREE.Vector3(p[0], 0, -p[1])); } var geo = new THREE.BufferGeometry().setFromPoints(points); target.add(new THREE.Line(geo, mat)); }

function buildFaceGrid(sides, radius, layout) { clearGridGroup(); var gridColor = SOLID_COLORS[currentIdx];

// Sheet gap proportionate to tile apothem — cells become regular prisms var tileRadius = radius / subdivLevel; var tileApothem = tileRadius * Math.cos(Math.PI / sides); // Scale up so the stack reads clearly (pure apothem is too tight) var sheetGap = tileApothem * 2.5;

if (layout && layout.positions) { // ── Build one geometry per level (each level = its own resolution) ── var mylarGeo = buildMylarGeo(layout); var levelGeos = []; var levelPointArrays = []; // grid intersection points for vertical edges var levelCellArrays = []; // sub-cell triangles for diagonal edges

for (var level = 1; level <= subdivLevel; level++) { _segments = []; _points = []; _cells = []; for (var fi = 0; fi < layout.positions.length; fi++) { var pos = layout.positions[fi]; if (!pos) continue; var verts = pos.verts; if (sides === 3) collectTriSub(verts[0], verts[1], verts[2], level); else if (sides === 4) collectQuadSub(verts[0], verts[1], verts[2], verts[3], level); else if (sides === 5) collectPentSub(verts, level); } var arr = new Float32Array(_segments); var geo = new THREE.BufferGeometry(); geo.setAttribute('position', new THREE.BufferAttribute(arr, 3)); levelGeos.push(geo); levelPointArrays.push(new Float32Array(_points)); levelCellArrays.push(new Float32Array(_cells)); _segments = []; _points = []; _cells = []; }

// ── Compute net bounds for margin placement ── var bMinX = Infinity, bMaxX = -Infinity, bMinZ = Infinity, bMaxZ = -Infinity; for (var bi = 0; bi < layout.positions.length; bi++) { var bp = layout.positions[bi]; if (!bp) continue; for (var bv = 0; bv < bp.verts.length; bv++) { if (bp.verts[bv][0] < bMinX) bMinX = bp.verts[bv][0]; if (bp.verts[bv][0] > bMaxX) bMaxX = bp.verts[bv][0]; if (bp.verts[bv][1] < bMinZ) bMinZ = bp.verts[bv][1]; if (bp.verts[bv][1] > bMaxZ) bMaxZ = bp.verts[bv][1]; } } var marginX = bMaxX + radius * 0.6; var marginZ = -(bMinZ + bMaxZ) / 2;

// ── Stamp each sheet: coarsest at bottom, finest at top ── for (var level = 1; level <= subdivLevel; level++) { var sheetY = level * sheetGap;

// Horizontal mylar sheet var sheetMat = new THREE.MeshBasicMaterial({ color: gridColor, transparent: true, opacity: 0.035, side: THREE.DoubleSide, depthWrite: false }); var sheetMesh = new THREE.Mesh(mylarGeo.clone(), sheetMat); sheetMesh.rotation.x = -Math.PI / 2; sheetMesh.position.y = sheetY; gridGroup.add(sheetMesh);

// Grid lines on horizontal sheet var lineOpacity = 0.15 + (level / subdivLevel) * 0.15; var lineMat = new THREE.LineBasicMaterial({ color: gridColor, transparent: true, opacity: lineOpacity, depthWrite: false }); var linesMesh = new THREE.LineSegments(levelGeos[level - 1], lineMat); linesMesh.position.y = sheetY; gridGroup.add(linesMesh);

// ── Vertical edges: columns at each grid intersection ── var prevY = (level - 1) * sheetGap; if (prevY < 0.001) prevY = 0; var ptArr = levelPointArrays[level - 1]; var vertEdges = []; for (var pi = 0; pi < ptArr.length; pi += 2) { var px = ptArr[pi], pz = ptArr[pi + 1]; vertEdges.push(px, prevY, -pz, px, sheetY, -pz); } if (vertEdges.length > 0) { var vertGeo = new THREE.BufferGeometry(); vertGeo.setAttribute('position', new THREE.BufferAttribute(new Float32Array(vertEdges), 3)); var vertMat = new THREE.LineBasicMaterial({ color: 0xffffff, transparent: true, opacity: 0.08, depthWrite: false }); gridGroup.add(new THREE.LineSegments(vertGeo, vertMat)); }

// ── Diagonal edges: cross-sheet lines creating tetrahedral structure ── var cellArr = levelCellArrays[level - 1]; var diagEdges = []; for (var ci = 0; ci < cellArr.length; ci += 6) { var cax = cellArr[ci], caz = cellArr[ci + 1]; var cbx = cellArr[ci + 2], cbz = cellArr[ci + 3]; var ccx = cellArr[ci + 4], ccz = cellArr[ci + 5]; // A_top → B_bottom diagEdges.push(cax, sheetY, -caz, cbx, prevY, -cbz); // B_top → C_bottom diagEdges.push(cbx, sheetY, -cbz, ccx, prevY, -ccz); } if (diagEdges.length > 0) { var diagGeo = new THREE.BufferGeometry(); diagGeo.setAttribute('position', new THREE.BufferAttribute(new Float32Array(diagEdges), 3)); var diagMat = new THREE.LineBasicMaterial({ color: gridColor, transparent: true, opacity: 0.06, depthWrite: false }); gridGroup.add(new THREE.LineSegments(diagGeo, diagMat)); }

// ── Margin notation sprite ── var tilesAtLevel = sides === 3 ? level * level : sides === 4 ? level * level : level * 5; var totalAtLevel = tilesAtLevel * (layout.positions.length); var dihedral = currentSolid ? currentSolid.dihedral_degrees : 0;

var noteCanvas = document.createElement('canvas'); noteCanvas.width = 256; noteCanvas.height = 64; var nctx = noteCanvas.getContext('2d'); nctx.clearRect(0, 0, 256, 64);

// Level number var hexStr = '#' + ('000000' + gridColor.toString(16)).slice(-6); nctx.fillStyle = hexStr; nctx.font = 'bold 22px ' + LATTICE_FONT; nctx.textAlign = 'left'; nctx.textBaseline = 'top'; nctx.fillText('L' + level, 4, 4);

// Tile count nctx.fillStyle = 'rgba(255,255,255,0.5)'; nctx.font = '14px ' + LATTICE_FONT; nctx.fillText(tilesAtLevel + '/face ' + totalAtLevel + ' total', 50, 6);

// Resolution + dihedral on second line nctx.fillStyle = 'rgba(167,167,167,0.6)'; nctx.font = '12px ' + LATTICE_FONT; nctx.fillText('n=' + level + ' ' + dihedral.toFixed(1) + '\u00B0 dihedral', 50, 28);

// Hierarchy hint on third line nctx.fillStyle = 'rgba(167,167,167,0.4)'; nctx.font = '11px ' + LATTICE_FONT; nctx.fillText(tilesAtLevel + ' tiles \u2192 face \u2192 ' + (layout.positions.length) + ' faces \u2192 solid', 4, 46);

var noteTex = new THREE.CanvasTexture(noteCanvas); noteTex.minFilter = THREE.LinearFilter; var noteMat = new THREE.SpriteMaterial({ map: noteTex, transparent: true, depthTest: false }); var noteSprite = new THREE.Sprite(noteMat); noteSprite.position.set(marginX + radius * 0.8, sheetY, marginZ); noteSprite.scale.set(radius * 1.6, radius * 0.4, 1); gridGroup.add(noteSprite); }

// ── Top sheet: face numbers printed on mylar ── var topY = (subdivLevel + 1) * sheetGap;

// Mylar tint layer var topSheetMat = new THREE.MeshBasicMaterial({ color: gridColor, transparent: true, opacity: 0.025, side: THREE.DoubleSide, depthWrite: false }); var topSheetMesh = new THREE.Mesh(mylarGeo.clone(), topSheetMat); topSheetMesh.rotation.x = -Math.PI / 2; topSheetMesh.position.y = topY; gridGroup.add(topSheetMesh);

// Number label sprites on the top sheet (one per face, at face centers) for (var li = 0; li < layout.positions.length; li++) { var lpos = layout.positions[li]; if (!lpos) continue; var lCanvas = document.createElement('canvas'); lCanvas.width = 64; lCanvas.height = 64; var lc = lCanvas.getContext('2d'); lc.fillStyle = 'rgba(255,255,255,0.5)'; lc.font = 'bold 28px ' + LATTICE_FONT; lc.textAlign = 'center'; lc.textBaseline = 'middle'; lc.fillText('' + (li + 1), 32, 32); var lTex = new THREE.CanvasTexture(lCanvas); lTex.minFilter = THREE.LinearFilter; var lMat = new THREE.SpriteMaterial({ map: lTex, transparent: true, depthTest: false }); var lSprite = new THREE.Sprite(lMat); lSprite.position.set(lpos.center[0], topY + 0.01, -lpos.center[1]); lSprite.scale.set(0.35, 0.35, 1); gridGroup.add(lSprite); faceLabels.push(lSprite); }

// Margin note for the top sheet var topTiles = sides === 3 ? subdivLevel * subdivLevel : sides === 4 ? subdivLevel * subdivLevel : subdivLevel * 5; var topTotal = topTiles * layout.positions.length; var topNoteCanvas = document.createElement('canvas'); topNoteCanvas.width = 256; topNoteCanvas.height = 64; var tnc = topNoteCanvas.getContext('2d'); tnc.clearRect(0, 0, 256, 64); var hexStr2 = '#' + ('000000' + gridColor.toString(16)).slice(-6); tnc.fillStyle = hexStr2; tnc.font = 'bold 22px ' + LATTICE_FONT; tnc.textAlign = 'left'; tnc.textBaseline = 'top'; tnc.fillText('FACES', 4, 4); tnc.fillStyle = 'rgba(255,255,255,0.5)'; tnc.font = '14px ' + LATTICE_FONT; tnc.fillText(layout.positions.length + ' \u00d7 ' + sides + '-gon', 80, 6); tnc.fillStyle = 'rgba(167,167,167,0.4)'; tnc.font = '11px ' + LATTICE_FONT; tnc.fillText(topTiles + ' tiles/face \u00b7 ' + topTotal + ' total \u2192 solid', 4, 28); var tnTex = new THREE.CanvasTexture(topNoteCanvas); tnTex.minFilter = THREE.LinearFilter; var tnMat = new THREE.SpriteMaterial({ map: tnTex, transparent: true, depthTest: false }); var tnSprite = new THREE.Sprite(tnMat); tnSprite.position.set(marginX + radius * 0.8, topY, marginZ); tnSprite.scale.set(radius * 1.6, radius * 0.4, 1); gridGroup.add(tnSprite);

mylarGeo.dispose(); }

// ── Background: orange shape-lattice extending beyond the net ── var bgLayer = new THREE.Group(); bgLayer.position.y = -0.01; gridGroup.add(bgLayer); var matBg = new THREE.LineBasicMaterial({ color: 0xA7A7A7, transparent: true, opacity: 0.08 }); var seedRot = -Math.PI / 2; var extent = 14; var apothem = radius * Math.cos(Math.PI / sides);

if (sides === 3) { var edgeLen = 2 * radius * Math.sin(Math.PI / 3); var b1x = edgeLen, b1z = 0; var b2x = edgeLen / 2, b2z = 3 * apothem; for (var i = -extent; i <= extent; i++) { for (var j = -extent; j <= extent; j++) { var cx = i * b1x + j * b2x; var cz = i * b1z + j * b2z; if (Math.abs(cx) > 30 || Math.abs(cz) > 30) continue; addPoly(bgLayer, 3, radius, cx, cz, seedRot, matBg); addPoly(bgLayer, 3, radius, cx + edgeLen / 2, cz + apothem, -seedRot, matBg); } } } else if (sides === 4) { var R = radius; for (var i = -extent; i <= extent; i++) { for (var j = -extent; j <= extent; j++) { var cx = (i + j) * R; var cz = (j - i) * R; if (Math.abs(cx) > 30 || Math.abs(cz) > 30) continue; addPoly(bgLayer, 4, radius, cx, cz, seedRot, matBg); } } } else if (sides === 5) { var R = radius; var spacing = R * 2.2; for (var i = -extent; i <= extent; i++) { for (var j = -extent; j <= extent; j++) { var cx = i * spacing + (j % 2 ? spacing * 0.5 : 0); var cz = j * spacing * 0.88; if (Math.abs(cx) > 30 || Math.abs(cz) > 30) continue; var rot = (i + j) % 2 ? seedRot : seedRot + Math.PI / 5; addPoly(bgLayer, 5, R * 0.75, cx, cz, rot, matBg); } } } }

// ── Wisp handles: one octahedron per mylar sheet ── // Each handle controls its sheet's Y position (depth manipulation) var sheetMeshGroups = []; // THREE.Group per sheet level (lines + sheet + verts + diags)

function clearHandles() { for (var hi = 0; hi < handleMeshes.length; hi++) { handleGroup.remove(handleMeshes[hi]); handleMeshes[hi].geometry.dispose(); handleMeshes[hi].material.dispose(); } handleMeshes = []; sheetMeshGroups = []; hoveredHandle = null; draggedHandle = null; }

function buildHandles(layout, sheetGap) { clearHandles(); if (!layout || !layout.positions) return;

// Compute net bounds for handle placement (right margin) var bMaxX = -Infinity; for (var bi = 0; bi < layout.positions.length; bi++) { var bp = layout.positions[bi]; if (!bp) continue; for (var bv = 0; bv < bp.verts.length; bv++) { if (bp.verts[bv][0] > bMaxX) bMaxX = bp.verts[bv][0]; } } var handleX = bMaxX + 2.0; // offset right of the net

// One handle per sheet level (plus the top/faces sheet) var handleGeo = new THREE.OctahedronGeometry(HANDLE_SCALE * 2.5, 0); var totalSheets = subdivLevel + 1; // subdivLevel grid sheets + 1 faces sheet

for (var level = 1; level <= totalSheets; level++) { var sheetY = level * sheetGap; var isTopSheet = (level === totalSheets);

var mat = new THREE.MeshStandardMaterial({ color: isTopSheet ? 0xA7A7A7 : HANDLE_IDLE_COLOR, emissive: isTopSheet ? 0xA7A7A7 : HANDLE_IDLE_COLOR, emissiveIntensity: isTopSheet ? 0.5 : 0.3, metalness: 0.4, roughness: 0.3, transparent: true, opacity: 0.7 }); var mesh = new THREE.Mesh(handleGeo.clone(), mat); mesh.position.set(handleX, sheetY, 0); mesh.userData = { isHandle: true, sheetLevel: level, baseY: sheetY, sheetGap: sheetGap, isTopSheet: isTopSheet }; handleGroup.add(mesh); handleMeshes.push(mesh); }

handleGeo.dispose();

// Tag sheet meshes in gridGroup so handles can move them // Each level's meshes share the same Y position tagSheetMeshes(sheetGap, totalSheets); }

// Tag existing grid meshes by their sheet level for handle-driven movement function tagSheetMeshes(sheetGap, totalSheets) { sheetMeshGroups = []; for (var level = 0; level <= totalSheets; level++) { sheetMeshGroups[level] = []; } // Walk gridGroup children and bucket by Y position for (var ci = 0; ci < gridGroup.children.length; ci++) { var child = gridGroup.children[ci]; var y = child.position.y; if (y < 0.001) continue; // background layer // Find closest level var closestLevel = Math.round(y / sheetGap); if (closestLevel >= 1 && closestLevel <= totalSheets) { if (!sheetMeshGroups[closestLevel]) sheetMeshGroups[closestLevel] = []; sheetMeshGroups[closestLevel].push(child); } } }

function setHandleState(mesh, state) { if (!mesh) return; if (state === 'hover') { mesh.material.color.setHex(HANDLE_HOVER_COLOR); mesh.material.emissive.setHex(HANDLE_HOVER_COLOR); mesh.material.emissiveIntensity = 0.6; mesh.material.opacity = 0.85; mesh.scale.setScalar(1.3); } else if (state === 'drag') { mesh.material.color.setHex(HANDLE_DRAG_COLOR); mesh.material.emissive.setHex(HANDLE_DRAG_COLOR); mesh.material.emissiveIntensity = 0.9; mesh.material.opacity = 1.0; mesh.scale.setScalar(1.5); } else { mesh.material.color.setHex(HANDLE_IDLE_COLOR); mesh.material.emissive.setHex(HANDLE_IDLE_COLOR); mesh.material.emissiveIntensity = 0.3; mesh.material.opacity = 0.6; mesh.scale.setScalar(1.0); } }

// ── HUD ── var hud = document.createElement('div'); hud.style.cssText = 'position:fixed;bottom:16px;left:16px;color:#778;font:12px Space Mono,monospace;z-index:200;pointer-events:none;text-shadow:0 1px 4px #000;'; document.body.appendChild(hud);

var infoPanel = document.createElement('div'); infoPanel.style.cssText = 'position:fixed;top:16px;right:16px;color:#aab;font:11px Space Mono,monospace;z-index:200;' + 'background:rgba(18,18,18,0.85);border:1px solid #335;border-radius:6px;padding:10px 14px;pointer-events:none;'; document.body.appendChild(infoPanel);

// ── Solid selector bar ── var solidBar = document.createElement('div'); solidBar.style.cssText = 'position:fixed;top:16px;left:50%;transform:translateX(-50%);z-index:200;' + 'display:flex;gap:6px;pointer-events:auto;'; for (var si = 0; si < SOLIDS.length; si++) { (function(idx) { var btn = document.createElement('div'); var hex = '#' + ('000000' + SOLID_COLORS[idx].toString(16)).slice(-6); btn.style.cssText = 'padding:5px 12px;cursor:pointer;font:bold 11px monospace;border-radius:4px;' + 'border:1.5px solid ' + hex + ';color:' + hex + ';background:rgba(18,18,18,0.8);transition:all 0.2s;'; btn.textContent = SOLIDS[idx].name; btn.onmouseover = function() { this.style.background = hex; this.style.color = '#111'; }; btn.onmouseout = function() { if (idx !== currentIdx) { this.style.background = 'rgba(18,18,18,0.8)'; this.style.color = hex; } }; btn.onclick = function() { loadSolid(idx); }; solidBar.appendChild(btn); })(si); } document.body.appendChild(solidBar);

function updateSolidBar() { var btns = solidBar.children; for (var i = 0; i < btns.length; i++) { var hex = '#' + ('000000' + SOLID_COLORS[i].toString(16)).slice(-6); if (i === currentIdx) { btns[i].style.background = hex; btns[i].style.color = '#111'; } else { btns[i].style.background = 'rgba(18,18,18,0.8)'; btns[i].style.color = hex; } } }

// ── Fetch solid data ── function fetchSolid(p, q, cb) { var key = p + ',' + q; if (solidCache[key]) { cb(solidCache[key]); return; } fetch('/api/platonic/solid/' + p + '/' + q) .then(function(r) { return r.json(); }) .then(function(data) { if (!data.error) solidCache[key] = data; cb(data); }) .catch(function(e) { console.warn('Fetch solid failed:', e); }); }

// ── 2D regular polygon vertices (flat in XZ plane) ── function polygonVerts2D(sides, radius, cx, cz, rotation) { var pts = []; for (var i = 0; i < sides; i++) { var angle = rotation + (i / sides) * Math.PI * 2; pts.push([cx + Math.cos(angle) * radius, cz + Math.sin(angle) * radius]); } return pts; }

// ── Edge length of a regular polygon ── function edgeLength(sides, radius) { return 2 * radius * Math.sin(Math.PI / sides); }

// ── BFS net layout ── // Unfolds faces from seed, placing each neighbor by reflecting across their shared edge function layoutNet(solid) { var faces = solid.face_indices; var adj = solid.face_adjacency; var sides = solid.face_sides; var nFaces = faces.length;

// Compute edge length from 3D vertices to set consistent 2D scale var v0 = solid.vertices[faces[0][0]]; var v1 = solid.vertices[faces[0][1]]; var edgeLen3D = Math.sqrt( Math.pow(v1[0]-v0[0], 2) + Math.pow(v1[1]-v0[1], 2) + Math.pow(v1[2]-v0[2], 2) ); // Circumradius of regular polygon with this edge length var polyRadius = edgeLen3D / (2 * Math.sin(Math.PI / sides));

// Place seed face at origin var placed = new Array(nFaces).fill(false); var facePos = []; // { center: [x,z], rotation: angle, verts: [[x,z]...], parentFace: int, hingeEdge: [idx,idx] }

// Seed face 0 var seedVerts = polygonVerts2D(sides, polyRadius, 0, 0, -Math.PI / 2); facePos[0] = { center: [0, 0], rotation: -Math.PI / 2, verts: seedVerts, parentFace: -1, hingeEdge: null }; placed[0] = true;

var queue = [0]; var hinges = [];

while (queue.length > 0) { var fi = queue.shift(); var myVerts = facePos[fi].verts;

for (var ai = 0; ai < adj[fi].length; ai++) { var ni = adj[fi][ai].neighbor; if (placed[ni]) continue; var sharedEdge = adj[fi][ai].shared_edge; // [vertIdx1, vertIdx2] in 3D

// Find which local vertex indices of face fi correspond to the shared edge vertices var localA = -1, localB = -1; for (var li = 0; li < faces[fi].length; li++) { if (faces[fi][li] === sharedEdge[0]) localA = li; if (faces[fi][li] === sharedEdge[1]) localB = li; } if (localA < 0 || localB < 0) continue;

// The shared edge in 2D var edgeA = myVerts[localA]; var edgeB = myVerts[localB]; var edgeMid = [(edgeA[0] + edgeB[0]) / 2, (edgeA[1] + edgeB[1]) / 2];

// Edge direction and perpendicular (outward from parent face) var edgeDx = edgeB[0] - edgeA[0]; var edgeDz = edgeB[1] - edgeA[1]; var edgeL = Math.sqrt(edgeDx * edgeDx + edgeDz * edgeDz); // Normal pointing away from parent center var parentCenter = facePos[fi].center; var perpX = -edgeDz / edgeL; var perpZ = edgeDx / edgeL; // Ensure perpendicular points away from parent var toCenterX = parentCenter[0] - edgeMid[0]; var toCenterZ = parentCenter[1] - edgeMid[1]; if (perpX * toCenterX + perpZ * toCenterZ > 0) { perpX = -perpX; perpZ = -perpZ; }

// New face center: apothem distance from edge midpoint along perpendicular var apothem = polyRadius * Math.cos(Math.PI / sides); var newCx = edgeMid[0] + perpX * apothem; var newCz = edgeMid[1] + perpZ * apothem;

// New face rotation: align so that the shared edge vertices match // The edge from A to B in the new face should be B to A (reversed) var edgeAngle = Math.atan2(edgeDz, edgeDx); // In the neighbor face, find local indices of the shared edge vertices var nLocalA = -1, nLocalB = -1; for (var nli = 0; nli < faces[ni].length; nli++) { if (faces[ni][nli] === sharedEdge[0]) nLocalA = nli; if (faces[ni][nli] === sharedEdge[1]) nLocalB = nli; }

// The angle at which vertex nLocalA should appear (matching edgeB position from center) var angleToEdgeB = Math.atan2(edgeB[1] - newCz, edgeB[0] - newCx); var newRot = angleToEdgeB - (nLocalA / sides) * Math.PI * 2;

var newVerts = polygonVerts2D(sides, polyRadius, newCx, newCz, newRot);

// Verify shared vertices match — if not, try adjusting var distA = Math.hypot(newVerts[nLocalA][0] - edgeB[0], newVerts[nLocalA][1] - edgeB[1]); var distB = Math.hypot(newVerts[nLocalB][0] - edgeA[0], newVerts[nLocalB][1] - edgeA[1]); if (distA > 0.01 || distB > 0.01) { // Try reversed: nLocalA should match edgeA var angleToEdgeA = Math.atan2(edgeA[1] - newCz, edgeA[0] - newCx); newRot = angleToEdgeA - (nLocalA / sides) * Math.PI * 2; newVerts = polygonVerts2D(sides, polyRadius, newCx, newCz, newRot); }

facePos[ni] = { center: [newCx, newCz], rotation: newRot, verts: newVerts, parentFace: fi, hingeEdge: sharedEdge }; placed[ni] = true; queue.push(ni);

hinges.push({ faceA: fi, faceB: ni, edgeA: edgeA, edgeB: edgeB, midpoint: edgeMid }); } }

return { positions: facePos, hinges: hinges, polyRadius: polyRadius }; }

// ── Clear scene ── function clearNet() { for (var i = 0; i < faceMeshes.length; i++) scene.remove(faceMeshes[i]); for (var i2 = 0; i2 < faceLabels.length; i2++) scene.remove(faceLabels[i2]); for (var j = 0; j < hingeLines.length; j++) scene.remove(hingeLines[j]); for (var k = 0; k < hingeLabels.length; k++) { if (hingeLabels[k].parentNode) hingeLabels[k].parentNode.removeChild(hingeLabels[k]); } faceMeshes = []; faceLabels = []; hingeLines = []; hingeLabels = []; selectedFace = -1; layoutData = null; foldProgress = 0; folding = false; clearHandles(); }

// ── Render net ── function renderNet(solid, layout) { var sides = solid.face_sides; var baseColor = SOLID_COLORS[currentIdx]; var dihedral = solid.dihedral_degrees;

// Face meshes for (var fi = 0; fi < layout.positions.length; fi++) { var pos = layout.positions[fi]; if (!pos) continue; var verts = pos.verts;

var shape = new THREE.Shape(); shape.moveTo(verts[0][0], verts[0][1]); for (var vi = 1; vi < verts.length; vi++) { shape.lineTo(verts[vi][0], verts[vi][1]); } shape.closePath();

var geo = new THREE.ShapeGeometry(shape); // Vary brightness per face var t = layout.positions.length > 1 ? fi / (layout.positions.length - 1) : 0.5; var faceColor = new THREE.Color(baseColor); faceColor.offsetHSL(0, 0, (t - 0.5) * 0.15); var mat = new THREE.MeshPhysicalMaterial({ color: faceColor, roughness: 0.1, metalness: 0.0, clearcoat: 0.8, clearcoatRoughness: 0.05, transparent: true, opacity: 0.85, side: THREE.DoubleSide }); var mesh = new THREE.Mesh(geo, mat); mesh.rotation.x = -Math.PI / 2; // Shape is in XY, rotate to XZ mesh.position.y = 0; mesh.userData.faceIdx = fi; scene.add(mesh); faceMeshes.push(mesh);

// Wireframe outline var edgeGeo = new THREE.EdgesGeometry(geo); var edgeMat = new THREE.LineBasicMaterial({ color: 0xffffff, transparent: true, opacity: 0.4 }); var edgeLine = new THREE.LineSegments(edgeGeo, edgeMat); edgeLine.rotation.x = -Math.PI / 2; edgeLine.position.y = 0.001; scene.add(edgeLine); hingeLines.push(edgeLine);

// (face number labels rendered as top mylar sheet in buildFaceGrid) }

// Hinge lines (thicker, colored) for (var hi = 0; hi < layout.hinges.length; hi++) { var h = layout.hinges[hi]; var pts = [ new THREE.Vector3(h.edgeA[0], 0.005, -h.edgeA[1]), new THREE.Vector3(h.edgeB[0], 0.005, -h.edgeB[1]) ]; var hGeo = new THREE.BufferGeometry().setFromPoints(pts); var hMat = new THREE.LineBasicMaterial({ color: 0xA7A7A7, transparent: true, opacity: 0.35, linewidth: 2 }); var hLine = new THREE.Line(hGeo, hMat); scene.add(hLine); hingeLines.push(hLine);

// Dihedral angle label at hinge midpoint var aLabel = document.createElement('div'); aLabel.style.cssText = 'position:fixed;pointer-events:none;font:9px monospace;color:#A7A7A7;text-shadow:0 1px 3px #000;z-index:150;opacity:0;transition:opacity 0.3s;'; aLabel.textContent = dihedral.toFixed(1) + '\u00B0'; document.body.appendChild(aLabel); hingeLabels.push(aLabel); }

updateLabels(); }

// ── Update label positions (project 3D → screen) ── function updateLabels() { if (!layoutData) return;

// Hinge angle labels (DOM elements) — only visible when a face is selected for (var hi = 0; hi < layoutData.hinges.length; hi++) { if (hi >= hingeLabels.length) break; var h = layoutData.hinges[hi]; var mid = new THREE.Vector3(h.midpoint[0], 0.15, -h.midpoint[1]).project(camera); var mx = (mid.x * 0.5 + 0.5) * window.innerWidth; var my = (-mid.y * 0.5 + 0.5) * window.innerHeight; hingeLabels[hi].style.left = mx - 10 + 'px'; hingeLabels[hi].style.top = my - 8 + 'px'; // Show only if adjacent to selected face var show = selectedFace >= 0 && (h.faceA === selectedFace || h.faceB === selectedFace); hingeLabels[hi].style.opacity = show ? '1' : '0'; } }

// ── Highlight face ── function highlightFace(fi) { selectedFace = fi; if (!currentSolid || !layoutData) return; var adj = currentSolid.face_adjacency; var neighbors = {}; if (fi >= 0 && adj[fi]) { for (var ai = 0; ai < adj[fi].length; ai++) { neighbors[adj[fi][ai].neighbor] = true; } }

var baseColor = SOLID_COLORS[currentIdx]; for (var mi = 0; mi < faceMeshes.length; mi++) { var mesh = faceMeshes[mi]; var idx = mesh.userData.faceIdx; var c = new THREE.Color(baseColor); var t = layoutData.positions.length > 1 ? idx / (layoutData.positions.length - 1) : 0.5; c.offsetHSL(0, 0, (t - 0.5) * 0.15);

if (idx === fi) { // Selected face: bright, high emissive mesh.material.emissive = c.clone(); mesh.material.emissiveIntensity = 0.6; mesh.material.opacity = 1.0; } else if (neighbors[idx]) { // Neighbor: softer glow mesh.material.emissive = c.clone(); mesh.material.emissiveIntensity = 0.25; mesh.material.opacity = 0.92; } else { // Unrelated: dim mesh.material.emissive = new THREE.Color(0x000000); mesh.material.emissiveIntensity = 0; mesh.material.opacity = fi >= 0 ? 0.4 : 0.85; } }

updateLabels(); updateInfoPanel(); }

function updateInfoPanel() { if (!currentSolid) { infoPanel.style.display = 'none'; return; } infoPanel.style.display = 'block'; var s = currentSolid; var hex = '#' + ('000000' + SOLID_COLORS[currentIdx].toString(16)).slice(-6); var html = '<div style="color:' + hex + ';font-weight:bold;margin-bottom:6px">{' + s.schlafli.p + ',' + s.schlafli.q + '} ' + SOLIDS[currentIdx].name + '</div>'; html += '<div>Faces: ' + s.faces + ' \u00d7 ' + s.face_sides + '-gon</div>'; html += '<div>Edges: ' + s.edge_count + ' \u00b7 Vertices: ' + s.vertices_count + '</div>'; html += '<div style="color:#A7A7A7">Dihedral: ' + s.dihedral_degrees.toFixed(2) + '\u00B0</div>'; html += '<div style="color:#556;margin-top:4px;font-size:10px">R<sub>c</sub>=' + s.circumradius.toFixed(3) + ' \u00b7 R<sub>i</sub>=' + s.inradius.toFixed(3) + '</div>'; // Hierarchy var tilesPerFace = s.face_sides === 3 ? subdivLevel * subdivLevel : s.face_sides === 4 ? subdivLevel * subdivLevel : subdivLevel * 5; html += '<div style="color:#556;margin-top:4px;font-size:10px">'; html += tilesPerFace + ' tiles \u2192 1 face \u2192 ' + s.faces + ' faces \u2192 solid'; html += '</div>'; if (selectedFace >= 0) { var adj = s.face_adjacency[selectedFace]; html += '<div style="margin-top:6px;border-top:1px solid #333;padding-top:4px;color:#aab">'; html += 'Face ' + (selectedFace + 1) + ' \u2014 ' + adj.length + ' neighbors'; html += '<div style="color:#A7A7A7;font-size:10px">'; for (var ai = 0; ai < adj.length; ai++) { html += 'f' + (adj[ai].neighbor + 1); if (ai < adj.length - 1) html += ', '; } html += '</div></div>'; } infoPanel.innerHTML = html; }

// ── Load and render a solid ── function loadSolid(idx) { currentIdx = idx; var s = SOLIDS[idx]; updateSolidBar(); clearNet();

fetchSolid(s.p, s.q, function(data) { if (data.error) { hud.textContent = 'Error loading ' + s.name; return; } currentSolid = data; layoutData = layoutNet(data); buildFaceGrid(data.face_sides, layoutData.polyRadius, layoutData); renderNet(data, layoutData);

// Build wisp handles on the top mylar sheet var tileRadius = layoutData.polyRadius / subdivLevel; var tileApothem = tileRadius * Math.cos(Math.PI / data.face_sides); var sheetGap = tileApothem * 2.5; buildHandles(layoutData, sheetGap);

// Center camera on the net var bounds = { minX: Infinity, maxX: -Infinity, minZ: Infinity, maxZ: -Infinity }; for (var fi = 0; fi < layoutData.positions.length; fi++) { var c = layoutData.positions[fi].center; if (c[0] < bounds.minX) bounds.minX = c[0]; if (c[0] > bounds.maxX) bounds.maxX = c[0]; if (c[1] < bounds.minZ) bounds.minZ = c[1]; if (c[1] > bounds.maxZ) bounds.maxZ = c[1]; } var cx = (bounds.minX + bounds.maxX) / 2; var cz = -(bounds.minZ + bounds.maxZ) / 2; // negate to match face→world Z flip var span = Math.max(bounds.maxX - bounds.minX, bounds.maxZ - bounds.minZ) + 4; setOrthoSize(span * 0.6); if (controls) { controls.target.set(cx, 0, cz); camera.position.set(cx, 40, cz + 0.01); // top-down }

highlightFace(-1); updateHud(); }); }

function updateHud() { if (!currentSolid) return; var s = SOLIDS[currentIdx]; var tilesPerFace = currentSolid.face_sides === 3 ? subdivLevel * subdivLevel : currentSolid.face_sides === 4 ? subdivLevel * subdivLevel : subdivLevel * 5; // pentagon: 5 wedges × n subdivs each var totalTiles = tilesPerFace * currentSolid.faces; hud.textContent = s.name + ' \u00b7 ' + currentSolid.faces + ' faces \u00b7 ' + subdivLevel + '\u00d7 subdiv (' + tilesPerFace + ' tiles/face, ' + totalTiles + ' total)' + ' \u00b7 ' + handleMeshes.length + ' sheets' + ' \u00b7 +/\u2212 grid \u00b7 F fold \u00b7 1-5 solids'; }

// ── Interaction: handles + face selection ── var raycaster = new THREE.Raycaster(); var mouse = new THREE.Vector2();

function updateMouse(event) { var rect = renderer.domElement.getBoundingClientRect(); mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1; mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1; }

function onPointerDown(event) { updateMouse(event); raycaster.setFromCamera(mouse, camera);

// Check handles first (priority over faces) var handleHits = raycaster.intersectObjects(handleMeshes); if (handleHits.length > 0) { draggedHandle = handleHits[0].object; dragStartY = event.clientY; dragStartHandleY = draggedHandle.position.y; setHandleState(draggedHandle, 'drag'); if (controls) controls.enabled = false; // disable orbit during drag renderer.domElement.style.cursor = 'grabbing'; return; }

// Face selection var hits = raycaster.intersectObjects(faceMeshes); if (hits.length > 0) { var fi = hits[0].object.userData.faceIdx; highlightFace(fi === selectedFace ? -1 : fi); } else { highlightFace(-1); } }

function onPointerMove(event) { updateMouse(event);

// Handle drag: move sheet in Y if (draggedHandle) { var deltaPixels = event.clientY - dragStartY; var sensitivity = 0.02; // pixels to world units var newY = dragStartHandleY - deltaPixels * sensitivity; var deltaY = newY - draggedHandle.position.y; draggedHandle.position.y = newY;

// Move all sheet meshes at this level var level = draggedHandle.userData.sheetLevel; if (sheetMeshGroups[level]) { for (var si = 0; si < sheetMeshGroups[level].length; si++) { sheetMeshGroups[level][si].position.y += deltaY; } } return; }

// Hover detection on handles raycaster.setFromCamera(mouse, camera); var handleHits = raycaster.intersectObjects(handleMeshes); if (handleHits.length > 0) { var hit = handleHits[0].object; if (hoveredHandle !== hit) { if (hoveredHandle) setHandleState(hoveredHandle, 'idle'); hoveredHandle = hit; setHandleState(hoveredHandle, 'hover'); renderer.domElement.style.cursor = 'grab'; } } else { if (hoveredHandle) { setHandleState(hoveredHandle, 'idle'); hoveredHandle = null; renderer.domElement.style.cursor = ''; } } }

function onPointerUp(event) { if (draggedHandle) { setHandleState(draggedHandle, 'idle'); draggedHandle = null; if (controls) controls.enabled = true; renderer.domElement.style.cursor = ''; } }

renderer.domElement.addEventListener('pointerdown', onPointerDown); renderer.domElement.addEventListener('pointermove', onPointerMove); renderer.domElement.addEventListener('pointerup', onPointerUp);

// ── Keyboard ── function onKeyDown(event) { var num = parseInt(event.key); if (num >= 1 && num <= 5) { loadSolid(num - 1); return; } if (event.code === 'KeyF') { folding = true; foldDir = foldProgress < 0.5 ? 1 : -1; } // Subdivision level: +/- or =/- keys if (event.key === '=' || event.key === '+') { subdivLevel = Math.min(subdivLevel + 1, 12); if (currentSolid && layoutData) { buildFaceGrid(currentSolid.face_sides, layoutData.polyRadius, layoutData); var tr = layoutData.polyRadius / subdivLevel; var ta = tr * Math.cos(Math.PI / currentSolid.face_sides); buildHandles(layoutData, ta * 2.5); updateHud(); } } if (event.key === '-' || event.key === '_') { subdivLevel = Math.max(subdivLevel - 1, 1); if (currentSolid && layoutData) { buildFaceGrid(currentSolid.face_sides, layoutData.polyRadius, layoutData); var tr = layoutData.polyRadius / subdivLevel; var ta = tr * Math.cos(Math.PI / currentSolid.face_sides); buildHandles(layoutData, ta * 2.5); updateHud(); } } } window.addEventListener('keydown', onKeyDown);

// ── Animation loop ── function animate() { if (!renderer.domElement.parentNode) return; // cleanup check requestAnimationFrame(animate);

if (controls) controls.update(); updateLabels();

// Handle idle pulse for (var hi = 0; hi < handleMeshes.length; hi++) { var h = handleMeshes[hi]; if (h === draggedHandle || h === hoveredHandle) continue; var pulse = 0.95 + 0.05 * Math.sin(Date.now() * 0.002 + hi * 0.7); h.scale.setScalar(pulse); }

// Fold animation if (folding && layoutData && currentSolid) { foldProgress += foldDir * 0.01; if (foldProgress >= 1) { foldProgress = 1; folding = false; } if (foldProgress <= 0) { foldProgress = 0; folding = false; } applyFold(foldProgress); }

renderer.render(scene, camera); }

// ── Fold: rotate each face about its hinge edge, accumulating down the BFS tree ── // The fold angle per hinge is (π - dihedral) * t. At t=1, faces meet at the dihedral angle. function applyFold(t) { if (!layoutData || !currentSolid) return;

var nFaces = layoutData.positions.length; var dihedralRad = currentSolid.dihedral_degrees * Math.PI / 180; var foldAngle = (Math.PI - dihedralRad) * t; // how much to fold each hinge

// Build BFS order (face 0 = root, stays flat) var parentOf = []; for (var fi = 0; fi < nFaces; fi++) { parentOf[fi] = layoutData.positions[fi].parentFace; }

// Compute cumulative world transform for each face // Face 0: identity (flat on the ground, rotated -PI/2 to put XY shape into XZ) var faceMatrices = new Array(nFaces);

// Root face: flat at its layout position var root = layoutData.positions[0]; var rootMat = new THREE.Matrix4(); rootMat.makeRotationX(-Math.PI / 2); rootMat.setPosition(0, 0, 0); // root stays at origin in its local shape space faceMatrices[0] = rootMat;

// BFS order: process children after parents var order = [0]; var visited = new Array(nFaces).fill(false); visited[0] = true; for (var qi = 0; qi < order.length; qi++) { var cur = order[qi]; for (var ci = 0; ci < nFaces; ci++) { if (!visited[ci] && parentOf[ci] === cur) { visited[ci] = true; order.push(ci); } } }

// For each non-root face, compute its transform by folding about its hinge for (var oi = 1; oi < order.length; oi++) { var fi = order[oi]; var pos = layoutData.positions[fi]; var parent = pos.parentFace; var parentPos = layoutData.positions[parent];

// Find the hinge: the shared edge between this face and its parent in 2D layout // The hinge edge vertices in 2D are stored in the layout adjacency // We need to find which hinge connects parent → fi var hingeEdgeA = null, hingeEdgeB = null; for (var hi = 0; hi < layoutData.hinges.length; hi++) { var h = layoutData.hinges[hi]; if ((h.faceA === parent && h.faceB === fi) || (h.faceA === fi && h.faceB === parent)) { hingeEdgeA = h.edgeA; // [x, z] in 2D layout space hingeEdgeB = h.edgeB; break; } } if (!hingeEdgeA) { faceMatrices[fi] = faceMatrices[parent].clone(); continue; }

// Hinge in geometry XY space (layout coords → ShapeGeometry space): var hA = new THREE.Vector3(hingeEdgeA[0], hingeEdgeA[1], 0); var hB = new THREE.Vector3(hingeEdgeB[0], hingeEdgeB[1], 0);

// Determine fold direction: the child face should fold AWAY from the parent // Use 2D cross product of hinge direction × (child center - hinge midpoint) var hingeMidX = (hingeEdgeA[0] + hingeEdgeB[0]) / 2; var hingeMidZ = (hingeEdgeA[1] + hingeEdgeB[1]) / 2; var hingeDx = hingeEdgeB[0] - hingeEdgeA[0]; var hingeDz = hingeEdgeB[1] - hingeEdgeA[1]; var toChildX = pos.center[0] - hingeMidX; var toChildZ = pos.center[1] - hingeMidZ; var cross2D = hingeDx * toChildZ - hingeDz * toChildX; var foldSign = cross2D > 0 ? 1 : -1;

// Transform hinge points by parent's cumulative matrix var hAw = hA.clone().applyMatrix4(faceMatrices[parent]); var hBw = hB.clone().applyMatrix4(faceMatrices[parent]);

// Hinge axis direction (normalized) var axis = hBw.clone().sub(hAw).normalize();

// Build fold transform about hinge var m = faceMatrices[parent].clone(); var toOrigin = new THREE.Matrix4().makeTranslation(-hAw.x, -hAw.y, -hAw.z); var rot = new THREE.Matrix4().makeRotationAxis(axis, foldSign * foldAngle); var fromOrigin = new THREE.Matrix4().makeTranslation(hAw.x, hAw.y, hAw.z);

// Apply: fromOrigin * rot * toOrigin * parentMatrix // But we want to fold THIS face relative to parent, so: faceMatrices[fi] = new THREE.Matrix4() .copy(fromOrigin) .multiply(rot) .multiply(toOrigin) .multiply(m); }

// Apply transforms to face meshes for (var mi = 0; mi < faceMeshes.length; mi++) { var mesh = faceMeshes[mi]; var idx = mesh.userData.faceIdx; if (!faceMatrices[idx]) continue;

if (t < 0.001) { // Flat: reset to original layout mesh.matrixAutoUpdate = true; mesh.position.set(0, 0, 0); mesh.rotation.set(-Math.PI / 2, 0, 0); mesh.scale.set(1, 1, 1); } else { // Apply computed fold matrix mesh.matrixAutoUpdate = false; mesh.matrix.copy(faceMatrices[idx]); mesh.matrixWorldNeedsUpdate = true; } } }

animate();

// ── Load initial solid ── loadSolid(0);

// ── Cleanup ── window.__latticeCleanup = function() { clearNet(); clearHandles(); window.removeEventListener('keydown', onKeyDown); window.removeEventListener('resize', onResize); renderer.domElement.removeEventListener('pointerdown', onPointerDown); renderer.domElement.removeEventListener('pointermove', onPointerMove); renderer.domElement.removeEventListener('pointerup', onPointerUp); if (hud.parentNode) hud.parentNode.removeChild(hud); if (infoPanel.parentNode) infoPanel.parentNode.removeChild(infoPanel); if (solidBar.parentNode) solidBar.parentNode.removeChild(solidBar); scene.clear(); renderer.dispose(); if (controls) controls.dispose(); };

console.log('\u25CA Face Lattice initialized'); })();