Files

355 lines
18 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<title>A3 Test Rig — Newbury Exhibit</title>
<style>
body { font-family:system-ui,sans-serif; margin:0; background:#0b0f1c; color:#e8ecff; }
.bar { padding:14px 18px; display:flex; gap:10px; align-items:center; flex-wrap:wrap; }
.bar button { padding:9px 16px; border-radius:8px; border:0; cursor:pointer; font-weight:600; }
.bar .go { background:linear-gradient(135deg,#51eaf1,#529eff); color:#04121a; }
.bar .ghost { background:transparent; color:#e8ecff; border:1px solid rgba(255,255,255,.35); }
.note { padding:0 18px 14px; font-size:.85rem; line-height:1.6; opacity:.85; max-width:70rem; }
.note code { background:rgba(255,255,255,.1); padding:1px 5px; border-radius:4px; }
#status { font-size:.85rem; opacity:.9; }
/* The sheet is one SVG in millimetre units, so everything prints as vectors —
no raster scaling, and the crests stay crisp at any printer DPI. */
.wrap { background:#fff; margin:0 18px 24px; width:max-content; }
svg.sheet { display:block; width:420mm; height:297mm; }
@page { size: A3 landscape; margin: 0; }
@media print {
.bar, .note { display:none !important; }
body { background:#fff; }
.wrap { margin:0; }
.page2 { page-break-before:always; break-before:page; }
}
</style></head>
<body>
<div class="bar">
<button class="go" onclick="window.print()">Print A3</button>
<button class="ghost" id="load">Load this layout onto the server</button>
<span id="status"></span>
</div>
<div class="note">
<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>
<script src="/vendor/posit1.js"></script>
<script src="/vendor/aruco.js"></script>
<script src="/vendor/dictionaries/aruco_4x4_1000.js"></script>
<script type="module">
import { requireAuth, api } from './admin.js';
await requireAuth();
/* ---------------------------------------------------------------------------
* THE TEST RIG — one source of truth.
*
* World frame matches the exhibit: origin at the FRONT-LEFT corner of the sheet,
* +X right, +Z away from the viewer, +Y up, centimetres. An A3 landscape sheet
* is exactly 42.0 x 29.7 cm, so the sheet IS the table.
*
* Page mapping: page x = X*10 mm, page y = (29.7 - Z)*10 mm, i.e. the top of the
* printed page is the BACK of the table. Crests use yawDeg 180 so their printed
* top edge points to +Z (page top) — that is, they print the right way up.
* ------------------------------------------------------------------------- */
const SHEET = { w: 42.0, d: 29.7 }; // cm
const CREST_MM = 60;
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 },
];
/* 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 tower
const GHOST_STATIC = 'air-marshall-brooks';
const GHOST_WALKER = 'barney-mcphly';
// world (cm) -> page (mm)
const px = (xCm) => xCm * 10;
const py = (zCm) => (SHEET.d - zCm) * 10;
const NS = 'http://www.w3.org/2000/svg';
const svg = document.getElementById('sheet');
const el = (tag, attrs, parent = svg) => {
const n = document.createElementNS(NS, tag);
for (const [k, v] of Object.entries(attrs)) n.setAttribute(k, v);
parent.appendChild(n);
return n;
};
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;
}
// ---- sheet edge + origin ----
el('rect', { x: 0.5, y: 0.5, width: 419, height: 296, fill: '#fff', stroke: '#ccc', 'stroke-width': 0.4 });
// ruler ticks every 10mm along the front (bottom) and left edges, labelled every 50mm
for (let cm = 0; cm <= SHEET.w; cm++) {
const big = cm % 5 === 0;
el('line', { x1: px(cm), y1: py(0), x2: px(cm), y2: py(0) - (big ? 4 : 2), stroke: '#c8c8c8', 'stroke-width': 0.3 });
if (big && cm > 0 && cm < SHEET.w) text(px(cm), py(0) - 5.5, `${cm}`, { size: 2.6, fill: '#bbb' });
}
for (let cm = 0; cm <= Math.floor(SHEET.d); cm++) {
const big = cm % 5 === 0;
el('line', { x1: 0, y1: py(cm), x2: (big ? 4 : 2), y2: py(cm), stroke: '#c8c8c8', 'stroke-width': 0.3 });
if (big && cm > 0) text(5.5, py(cm) + 1, `${cm}`, { size: 2.6, anchor: 'start', fill: '#bbb' });
}
// origin marker + axes
el('circle', { cx: px(0), cy: py(0), r: 1.6, fill: 'none', stroke: '#e0645a', 'stroke-width': 0.5 });
el('line', { x1: px(0), y1: py(0), x2: px(3.4), y2: py(0), stroke: '#e0645a', 'stroke-width': 0.5 });
el('line', { x1: px(0), y1: py(0), x2: px(0), y2: py(3.4), stroke: '#e0645a', 'stroke-width': 0.5 });
text(px(3.9), py(0) + 1, '+X', { size: 3, anchor: 'start', fill: '#e0645a' });
text(px(0) + 1.5, py(3.9), '+Z', { size: 3, anchor: 'start', fill: '#e0645a' });
text(px(0) + 2, py(0) + 5, 'origin (0,0) — front-left', { size: 2.8, anchor: 'start', fill: '#e0645a' });
// ---- the walker's path ----
el('rect', {
x: px(PATH_RECT.x0), y: py(PATH_RECT.z1),
width: px(PATH_RECT.x1) - px(PATH_RECT.x0), height: py(PATH_RECT.z0) - py(PATH_RECT.z1),
fill: 'none', stroke: '#7aa7d8', 'stroke-width': 0.6, 'stroke-dasharray': '4 3',
});
for (const c of [[PATH_RECT.x0, PATH_RECT.z0], [PATH_RECT.x1, PATH_RECT.z0], [PATH_RECT.x1, PATH_RECT.z1], [PATH_RECT.x0, PATH_RECT.z1]])
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' });
// ---- 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 });
el('circle', { cx: px(STATIC_SPAWN.x), cy: py(STATIC_SPAWN.z), r: 0.7, fill: '#8f6fd0' });
text(px(STATIC_SPAWN.x) + 4, py(STATIC_SPAWN.z) + 1, 'static ghost', { size: 3, anchor: 'start', fill: '#8f6fd0' });
// ---- crests, drawn as vector cells ----
const dic = new AR.Dictionary('ARUCO_4X4_1000');
/* 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' }, 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 + CREST_MM + 6, `(${c.x}, ${c.z}) cm · ${CREST_MM} mm`, { size: 2.7, fill: '#999' });
}
// ---- scale bar (print verification) ----
const sbX = px(31), sbY = py(14.5);
el('line', { x1: sbX, y1: sbY, x2: sbX + 100, y2: sbY, stroke: '#333', 'stroke-width': 0.6 });
el('line', { x1: sbX, y1: sbY - 2, x2: sbX, y2: sbY + 2, stroke: '#333', 'stroke-width': 0.6 });
el('line', { x1: sbX + 100, y1: sbY - 2, x2: sbX + 100, y2: sbY + 2, stroke: '#333', 'stroke-width': 0.6 });
text(sbX + 50, sbY - 3.5, '100 mm — measure this to confirm 1:1', { size: 3, fill: '#333', weight: 600 });
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.
* ------------------------------------------------------------------------- */
function buildScene() {
return {
world: { units: 'cm', note: 'A3 test rig: origin = front-left corner of the sheet; +X right, +Z back, +Y up.' },
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: [{
// 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 }],
paths: [{
id: 'test-loop', mode: 'loop', enabled: true,
points: [
[PATH_RECT.x0, 0, PATH_RECT.z0], [PATH_RECT.x1, 0, PATH_RECT.z0],
[PATH_RECT.x1, 0, PATH_RECT.z1], [PATH_RECT.x0, 0, PATH_RECT.z1],
],
}],
table: { width: SHEET.w, depth: SHEET.d, offsetX: 0, offsetZ: 0, show: true },
ghostHeightCm: 4,
};
}
function buildPlaylist() {
return {
crossfadeSeconds: 3,
behaviors: {
staticChance: 0.5,
wanderRadius: 2.5, wanderSpeed: 0.15, wanderVertical: 0.4,
pathSpeed: 1.5, pathChance: 0.4,
bobAmp: 0.3, bobHz: 0.4, // 3mm idle float
},
rarity: {
weights: { Common: 1, Rare: 0.5, Epic: 0.25, Legendary: 0.1 },
movementScale: { Common: 1, Rare: 1, Epic: 0.7, Legendary: 0.4 },
},
// exactly two ghosts, both permanent: nothing spawns or despawns mid-test
residents: [
{ id: GHOST_STATIC, behavior: 'static', spawnId: STATIC_SPAWN.id },
{ id: GHOST_WALKER, behavior: 'path', pathId: 'test-loop' },
],
tracks: [], // ambient off — a fixed scene is the point
timeWindows: [],
};
}
const status = document.getElementById('status');
document.getElementById('load').addEventListener('click', async () => {
if (!confirm('Replace the live scene AND playlist with the A3 test rig?')) return;
status.textContent = 'loading…';
try {
await api('/api/scene', 'PUT', buildScene());
await api('/api/playlist', 'PUT', buildPlaylist());
status.textContent = '✓ loaded — open the exhibit and point at a crest';
} catch (e) {
status.textContent = '✗ ' + (e && e.message || e);
}
});
</script>
</body>
</html>