feat: foldable 4-sided tower on page 2 — crests on all sides + top break the coplanar pose ambiguity; tower replaces the cylinder as central occluder

This commit is contained in:
2026-08-24 15:33:08 +10:00
parent 44126ec3b4
commit 87eee945fc
+149 -30
View File
@@ -21,6 +21,7 @@
.bar, .note { display:none !important; }
body { background:#fff; }
.wrap { margin:0; }
.page2 { page-break-before:always; break-before:page; }
}
</style></head>
<body>
@@ -31,14 +32,17 @@
<span id="status"></span>
</div>
<div class="note">
<b>Print at 100% / "Actual size" — not "Fit to page"</b>, on A3 landscape. Verify with the 100&nbsp;mm scale bar
before trusting any measurement: if it doesn't measure 100&nbsp;mm, the crest sizes are wrong and every pose will be
scaled with them. Stand an <b>85&nbsp;mm diameter × 160&nbsp;mm tall</b> cylinder on the dashed circle (a drink can is
~66&nbsp;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.
<b>Print both pages at 100% / "Actual size" — not "Fit to page"</b>, A3 landscape. Verify with the 100&nbsp;mm scale
bar on page 1 before trusting any measurement: if it doesn't measure 100&nbsp;mm the crest sizes are wrong and every
pose is scaled with them. <b>Page 2 folds into the tower</b>85&nbsp;mm square, 160&nbsp;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&nbsp;mm up breaks the tie.
"Load this layout" replaces the live scene and playlist — save your current layout first if you want it back.
</div>
<div class="wrap"><svg class="sheet" id="sheet" viewBox="0 0 420 297" xmlns="http://www.w3.org/2000/svg"></svg></div>
<div class="wrap page2"><svg class="sheet" id="sheet2" viewBox="0 0 420 297" xmlns="http://www.w3.org/2000/svg"></svg></div>
<script src="/vendor/cv.js"></script>
<script src="/vendor/svd.js"></script>
@@ -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 }],