feat: A3 test rig — printable 6-crest sheet with cylinder occluder, walker loop and static ghost; loads the matching scene+playlist from the same constants
This commit is contained in:
@@ -0,0 +1,235 @@
|
|||||||
|
<!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 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 <b>85 mm diameter × 160 mm tall</b> 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.
|
||||||
|
</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>
|
||||||
Reference in New Issue
Block a user