From 87eee945fc28d1d87bb36bc8859a5514bbf4bf1a Mon Sep 17 00:00:00 2001 From: jessikitty Date: Mon, 24 Aug 2026 15:33:08 +1000 Subject: [PATCH] =?UTF-8?q?feat:=20foldable=204-sided=20tower=20on=20page?= =?UTF-8?q?=202=20=E2=80=94=20crests=20on=20all=20sides=20+=20top=20break?= =?UTF-8?q?=20the=20coplanar=20pose=20ambiguity;=20tower=20replaces=20the?= =?UTF-8?q?=20cylinder=20as=20central=20occluder?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- public/admin/testsheet.html | 179 ++++++++++++++++++++++++++++++------ 1 file changed, 149 insertions(+), 30 deletions(-) diff --git a/public/admin/testsheet.html b/public/admin/testsheet.html index c620909..47cb206 100644 --- a/public/admin/testsheet.html +++ b/public/admin/testsheet.html @@ -21,6 +21,7 @@ .bar, .note { display:none !important; } body { background:#fff; } .wrap { margin:0; } + .page2 { page-break-before:always; break-before:page; } } @@ -31,14 +32,17 @@
- Print at 100% / "Actual size" — not "Fit to page", on A3 landscape. Verify with the 100 mm scale bar - before trusting any measurement: if it doesn't measure 100 mm, the crest sizes are wrong and every pose will be - scaled with them. Stand an 85 mm diameter × 160 mm tall cylinder on the dashed circle (a drink can is - ~66 mm, so use a tube, tin or printed post). "Load this layout" replaces the live scene and playlist — - save your current layout first if you want it back. + Print both pages at 100% / "Actual size" — not "Fit to page", A3 landscape. Verify with the 100 mm scale + bar on page 1 before trusting any measurement: if it doesn't measure 100 mm the crest sizes are wrong and every + pose is scaled with them. Page 2 folds into the tower — 85 mm square, 160 mm tall, with a crest on + each of its four sides plus the top, so one is readable from any angle. It stands on the dashed square at the centre + of page 1 and doubles as the occluder. The tower is what removes the mirrored-pose flip: crests lying flat all share + a single plane, which makes two poses fit equally well, and a crest 100 mm up breaks the tie. + "Load this layout" replaces the live scene and playlist — save your current layout first if you want it back.
+
@@ -66,9 +70,25 @@ const CRESTS = [ // markerId + centre (cm) { id: 0, x: 5, z: 4 }, { id: 4, x: 21, z: 4 }, { id: 1, x: 37, z: 4 }, { id: 2, x: 5, z: 25.7 }, { id: 5, x: 21, z: 25.7 }, { id: 3, x: 37, z: 25.7 }, ]; -const CYL = { x: 21, z: 14.85, diaMM: 85, hMM: 160 }; +/* Central TOWER: a four-sided prism you fold from page 2. It is both the + * occluder AND the cure for planar ambiguity — every crest on the flat sheet + * shares one plane, and a coplanar point set has two poses that reproject + * equally well, so solvePnP intermittently returns the mirrored one. A crest + * standing 100mm above the sheet makes the point set genuinely 3D and no + * mirrored pose can fit it. Four faces + a top means one is always readable. */ +const TOWER = { x: 21, z: 14.85, sideMM: 85, hMM: 160, crestHMM: 100 }; +/* Panel order on the flat net is yaw INCREASING left-to-right: unrolling the + * prism, arc position and the marker's printed-right axis both advance with yaw. + * Starting at FRONT that gives front, left, back, right — derived, not guessed. */ +const TOWER_FACES = [ + { id: 6, yaw: 180, label: 'FRONT', hint: 'faces the front edge of the sheet' }, + { id: 7, yaw: 270, label: 'LEFT', hint: 'faces the left edge' }, + { id: 8, yaw: 0, label: 'BACK', hint: 'faces the back edge' }, + { id: 9, yaw: 90, label: 'RIGHT', hint: 'faces the right edge' }, +]; +const TOWER_TOP = { id: 10, yaw: 180 }; // flat, printed-up folds toward +Z const PATH_RECT = { x0: 12, x1: 30, z0: 8.85, z1: 20.85 }; // walker's loop -const STATIC_SPAWN = { id: 'test-static', x: 21, z: 22 }; // sits BEHIND the cylinder +const STATIC_SPAWN = { id: 'test-static', x: 21, z: 22 }; // sits BEHIND the tower const GHOST_STATIC = 'air-marshall-brooks'; const GHOST_WALKER = 'barney-mcphly'; @@ -84,11 +104,11 @@ const el = (tag, attrs, parent = svg) => { parent.appendChild(n); return n; }; -function text(x, y, str, { size = 3.2, anchor = 'middle', fill = '#888', weight = 400, rotate = 0 } = {}) { +function text(x, y, str, { size = 3.2, anchor = 'middle', fill = '#888', weight = 400, rotate = 0, root = null } = {}) { const t = el('text', { x, y, 'font-size': size, 'text-anchor': anchor, fill, 'font-family': 'system-ui, sans-serif', 'font-weight': weight, - }); + }, root || svg); if (rotate) t.setAttribute('transform', `rotate(${rotate} ${x} ${y})`); t.textContent = str; return t; @@ -127,13 +147,14 @@ for (const c of [[PATH_RECT.x0, PATH_RECT.z0], [PATH_RECT.x1, PATH_RECT.z0], [PA el('circle', { cx: px(c[0]), cy: py(c[1]), r: 1, fill: '#7aa7d8' }); text(px(PATH_RECT.x0) + 2, py(PATH_RECT.z0) - 2.5, 'walker path (loop, 60 cm)', { size: 3, anchor: 'start', fill: '#7aa7d8' }); -// ---- cylinder footprint ---- -const rMM = CYL.diaMM / 2; -el('circle', { cx: px(CYL.x), cy: py(CYL.z), r: rMM, fill: 'none', stroke: '#d08b3c', 'stroke-width': 0.7, 'stroke-dasharray': '3 2' }); -el('line', { x1: px(CYL.x) - rMM - 4, y1: py(CYL.z), x2: px(CYL.x) + rMM + 4, y2: py(CYL.z), stroke: '#d08b3c', 'stroke-width': 0.3 }); -el('line', { x1: px(CYL.x), y1: py(CYL.z) - rMM - 4, x2: px(CYL.x), y2: py(CYL.z) + rMM + 4, stroke: '#d08b3c', 'stroke-width': 0.3 }); -text(px(CYL.x), py(CYL.z) - rMM - 6, `cylinder ⌀${CYL.diaMM} mm × ${CYL.hMM} mm tall`, { size: 3.2, fill: '#d08b3c', weight: 600 }); -text(px(CYL.x), py(CYL.z) + rMM + 9, 'occluder — ghosts behind it should be hidden', { size: 2.8, fill: '#d08b3c' }); +// ---- tower footprint ---- +const hs = TOWER.sideMM / 2; +el('rect', { x: px(TOWER.x) - hs, y: py(TOWER.z) - hs, width: TOWER.sideMM, height: TOWER.sideMM, + fill: 'none', stroke: '#d08b3c', 'stroke-width': 0.7, 'stroke-dasharray': '3 2' }); +el('line', { x1: px(TOWER.x) - hs - 4, y1: py(TOWER.z), x2: px(TOWER.x) + hs + 4, y2: py(TOWER.z), stroke: '#d08b3c', 'stroke-width': 0.3 }); +el('line', { x1: px(TOWER.x), y1: py(TOWER.z) - hs - 4, x2: px(TOWER.x), y2: py(TOWER.z) + hs + 4, stroke: '#d08b3c', 'stroke-width': 0.3 }); +text(px(TOWER.x), py(TOWER.z) - hs - 6, `TOWER stands here — ${TOWER.sideMM} mm square, ${TOWER.hMM} mm tall`, { size: 3.2, fill: '#d08b3c', weight: 600 }); +text(px(TOWER.x), py(TOWER.z) + hs + 9, 'fold from page 2 · FRONT face toward the front edge', { size: 2.8, fill: '#d08b3c' }); // ---- static ghost spot ---- el('circle', { cx: px(STATIC_SPAWN.x), cy: py(STATIC_SPAWN.z), r: 2.5, fill: 'none', stroke: '#8f6fd0', 'stroke-width': 0.6 }); @@ -142,18 +163,26 @@ text(px(STATIC_SPAWN.x) + 4, py(STATIC_SPAWN.z) + 1, 'static ghost', { size: 3, // ---- crests, drawn as vector cells ---- const dic = new AR.Dictionary('ARUCO_4X4_1000'); -for (const c of CRESTS) { - const s = CREST_MM, cell = s / 6; - const x0 = px(c.x) - s / 2, y0 = py(c.z) - s / 2; - el('rect', { x: x0, y: y0, width: s, height: s, fill: '#000' }); // border + payload ground - const code = dic.codeList[c.id]; // 16 chars, row-major + +/* Draw one crest centred at (cx, cy) in page mm, into a given SVG root. + * Vector cells, so it stays exact at any printer DPI. */ +function drawCrest(root, id, cx, cy, sizeMM) { + const cell = sizeMM / 6; + const x0 = cx - sizeMM / 2, y0 = cy - sizeMM / 2; + el('rect', { x: x0, y: y0, width: sizeMM, height: sizeMM, fill: '#000' }, root); // border + ground + const code = dic.codeList[id]; // 16 chars, row-major for (let r = 0; r < 4; r++) for (let q = 0; q < 4; q++) { if (code[r * 4 + q] === '1') - el('rect', { x: x0 + (q + 1) * cell, y: y0 + (r + 1) * cell, width: cell, height: cell, fill: '#fff' }); + el('rect', { x: x0 + (q + 1) * cell, y: y0 + (r + 1) * cell, width: cell, height: cell, fill: '#fff' }, root); } + return { x0, y0 }; +} + +for (const c of CRESTS) { + const { y0 } = drawCrest(svg, c.id, px(c.x), py(c.z), CREST_MM); // ID caption, kept outside the quiet zone text(px(c.x), y0 - 3.5, `crest #${c.id}`, { size: 3.4, fill: '#555', weight: 600 }); - text(px(c.x), y0 + s + 6, `(${c.x}, ${c.z}) cm · ${CREST_MM} mm`, { size: 2.7, fill: '#999' }); + text(px(c.x), y0 + CREST_MM + 6, `(${c.x}, ${c.z}) cm · ${CREST_MM} mm`, { size: 2.7, fill: '#999' }); } // ---- scale bar (print verification) ---- @@ -165,6 +194,71 @@ text(sbX + 50, sbY - 3.5, '100 mm — measure this to confirm 1:1', { size: 3, f text(210, 292, 'Newbury Exhibit — A3 test rig · A3 landscape at 100% · sheet = table, 42.0 × 29.7 cm', { size: 3, fill: '#aaa' }); +/* --------------------------------------------------------------------------- + * PAGE 2 — the tower net. + * + * Four 85mm panels in a strip plus a glue tab, and a top flap hinged off the + * FRONT panel. Fold printed-side OUT, tape the tab, fold the flap over the top. + * Panels run left-to-right in order of increasing yaw (front, left, back, right) + * because unrolling the prism advances arc position and the marker's own + * printed-right axis together — see TOWER_FACES. + * ------------------------------------------------------------------------- */ +const svg2 = document.getElementById('sheet2'); +{ + const S = TOWER.sideMM, Hh = TOWER.hMM, TAB = 15; + const X0 = 30, PY1 = 270, PY0 = PY1 - Hh; // panel row: y 110..270 + const FY0 = PY0 - S; // top flap above panel 1 + const cut = { fill: 'none', stroke: '#333', 'stroke-width': 0.6 }; + const fold = { stroke: '#7aa7d8', 'stroke-width': 0.5, 'stroke-dasharray': '5 3' }; + + el('rect', { x: 0.5, y: 0.5, width: 419, height: 296, fill: '#fff', stroke: '#eee', 'stroke-width': 0.4 }, svg2); + + // outline: panel strip, glue tab, top flap + el('rect', { x: X0, y: PY0, width: 4 * S, height: Hh, ...cut }, svg2); + el('rect', { x: X0 + 4 * S, y: PY0, width: TAB, height: Hh, ...cut }, svg2); + el('rect', { x: X0, y: FY0, width: S, height: S, ...cut }, svg2); + + // fold lines between panels, at the tab, and at the flap hinge + for (let i = 1; i <= 4; i++) el('line', { x1: X0 + i * S, y1: PY0, x2: X0 + i * S, y2: PY1, ...fold }, svg2); + el('line', { x1: X0, y1: PY0, x2: X0 + S, y2: PY0, ...fold }, svg2); + + // the four side crests + TOWER_FACES.forEach((f, i) => { + const cx = X0 + i * S + S / 2; + const cy = PY1 - TOWER.crestHMM; // measured up from the tower's base + drawCrest(svg2, f.id, cx, cy, CREST_MM); + text(cx, cy - CREST_MM / 2 - 4, `${f.label} · crest #${f.id}`, { size: 4, fill: '#333', weight: 700, root: svg2 }); + text(cx, cy + CREST_MM / 2 + 6, f.hint, { size: 2.8, fill: '#999', root: svg2 }); + text(cx, PY1 - 6, `yaw ${f.yaw}°`, { size: 2.6, fill: '#bbb', root: svg2 }); + }); + + // top flap crest + const tcx = X0 + S / 2, tcy = FY0 + S / 2; + drawCrest(svg2, TOWER_TOP.id, tcx, tcy, CREST_MM); + text(tcx, FY0 + 5, `TOP · crest #${TOWER_TOP.id}`, { size: 4, fill: '#333', weight: 700, root: svg2 }); + + text(X0 + 4 * S + TAB / 2, PY0 + Hh / 2, 'GLUE', { size: 3, fill: '#bbb', rotate: 90, root: svg2 }); + + // instructions, kept clear of the net + const ix = X0 + S + 8, iy = FY0 + 10; + const lines = [ + `TOWER — ${S} mm square × ${Hh} mm tall. Print A3 at 100%, same as page 1.`, + '1. Cut around the solid outline (strip + glue tab + top flap).', + '2. Score and fold the dashed lines. Fold PRINTED SIDE OUT.', + '3. Curl into a square tube; tape the GLUE tab behind the FRONT panel.', + '4. Fold the top flap over and tape it down.', + '5. Stand it on the dashed square at the centre of page 1,', + ' with the FRONT panel facing the front edge of the sheet.', + '', + 'Why it matters: crests lying flat all share one plane, and a coplanar set has', + 'two poses that fit the pixels equally well — so the solver flips between the', + 'true one and its mirror. A crest 100 mm up makes the geometry properly 3D.', + ]; + lines.forEach((s, i) => text(ix, iy + i * 5.5, s, { size: 3.2, anchor: 'start', fill: i < 1 ? '#333' : '#777', weight: i < 1 ? 700 : 400, root: svg2 })); + + text(210, 292, 'Newbury Exhibit — tower net · fold printed side OUT · page 2 of 2', { size: 3, fill: '#aaa', root: svg2 }); +} + /* --------------------------------------------------------------------------- * The matching server config, built from the same constants as the drawing — * so the printed sheet and the live scene can never drift apart. @@ -172,14 +266,39 @@ text(210, 292, 'Newbury Exhibit — A3 test rig · A3 landscape at 100% · sheet function buildScene() { return { world: { units: 'cm', note: 'A3 test rig: origin = front-left corner of the sheet; +X right, +Z back, +Y up.' }, - anchors: CRESTS.map(c => ({ - markerId: c.id, position: [c.x, 0, c.z], mount: 'flat', - yawDeg: 180, pitchDeg: 0, rollDeg: 0, sizeMM: CREST_MM, enabled: true, - })), + anchors: [ + // flat, on the sheet + ...CRESTS.map(c => ({ + markerId: c.id, position: [c.x, 0, c.z], mount: 'flat', + yawDeg: 180, pitchDeg: 0, rollDeg: 0, sizeMM: CREST_MM, enabled: true, + })), + /* Tower sides: 'wall' mount, centre pushed out to the face by half the + * tower width along that face's normal, at crest height. These are the + * anchors that make the point set non-coplanar. */ + ...TOWER_FACES.map(f => { + const rad = f.yaw * Math.PI / 180; + const nx = Math.sin(rad), nz = Math.cos(rad); // face normal + const half = TOWER.sideMM / 20; // mm -> cm + return { + markerId: f.id, + position: [TOWER.x + nx * half, TOWER.crestHMM / 10, TOWER.z + nz * half], + mount: 'wall', yawDeg: f.yaw, pitchDeg: 0, rollDeg: 0, + sizeMM: CREST_MM, enabled: true, + }; + }), + // Tower top: flat, facing up, at full height + { + markerId: TOWER_TOP.id, + position: [TOWER.x, TOWER.hMM / 10, TOWER.z], + mount: 'flat', yawDeg: TOWER_TOP.yaw, pitchDeg: 0, rollDeg: 0, + sizeMM: CREST_MM, enabled: true, + }, + ], buildings: [{ - id: 'test-cylinder', shape: 'cylinder', - position: [CYL.x, 0, CYL.z], - radiusCm: CYL.diaMM / 20, heightCm: CYL.hMM / 10, + // the tower is the physical prop, so it is also the occluder + id: 'test-tower', + position: [TOWER.x, 0, TOWER.z], + size: [TOWER.sideMM / 10, TOWER.hMM / 10, TOWER.sideMM / 10], yawDeg: 0, }], spawns: [{ id: STATIC_SPAWN.id, position: [STATIC_SPAWN.x, 0, STATIC_SPAWN.z], enabled: true }],