Files
newbury-exhibit-v3/public/admin/testsheet.html
T

236 lines
12 KiB
HTML
Raw 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; }
}
</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 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.
</div>
<div class="wrap"><svg class="sheet" id="sheet" 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 },
];
const CYL = { x: 21, z: 14.85, diaMM: 85, hMM: 160 };
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 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 } = {}) {
const t = el('text', {
x, y, 'font-size': size, 'text-anchor': anchor, fill,
'font-family': 'system-ui, sans-serif', 'font-weight': weight,
});
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' });
// ---- 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' });
// ---- 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');
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
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' });
}
// 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' });
}
// ---- 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' });
/* ---------------------------------------------------------------------------
* 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: 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,
})),
buildings: [{
id: 'test-cylinder', shape: 'cylinder',
position: [CYL.x, 0, CYL.z],
radiusCm: CYL.diaMM / 20, heightCm: CYL.hMM / 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>