Calibration updates

This commit is contained in:
2026-07-08 12:17:21 +10:00
parent d48a8ce786
commit 2d492baa7d
5 changed files with 473 additions and 7 deletions
+2
View File
@@ -126,6 +126,8 @@
<span id="dirty" title="unsaved changes"></span>
<button id="reloadBtn" title="Discard changes and reload from server">Reload</button>
<button id="printAnchorsBtn" title="Open printable anchor markers">Print anchors</button>
<button id="planBtn" title="Open the placement plan (top-down measurements)">Plan</button>
<button id="calibrateBtn" title="Open the marker calibration tool">Calibrate</button>
<button id="exportBtn" title="Download scene.json">Export</button>
<button id="saveBtn" class="primary" disabled>Save to server</button>
</div>
+217
View File
@@ -0,0 +1,217 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<title>Newbury Exhibit — calibrate</title>
<style>
:root { --ink:#e8eaf2; --dim:#8a93ad; --cyan:#51eaf1; --green:#4ade80; --amber:#fbbf24; --red:#f65151;
--panel:rgba(10,12,20,0.88); }
* { box-sizing:border-box; }
html,body { margin:0; height:100%; background:#05060a; color:var(--ink);
font-family:'Inter',system-ui,sans-serif; overflow:hidden; }
#video { position:absolute; top:50%; left:50%; transform:translate(-50%,-50%); }
#overlay { position:absolute; inset:0; pointer-events:none; }
.card { position:fixed; background:var(--panel); border:1px solid rgba(255,255,255,0.1);
border-radius:14px; backdrop-filter:blur(10px); }
#readout { left:10px; top:calc(10px + env(safe-area-inset-top)); padding:12px 14px; min-width:210px; }
#readout .row { display:flex; justify-content:space-between; gap:16px; font-size:13px; margin:3px 0; }
#readout .row .v { font-family:ui-monospace,monospace; font-weight:600; }
#readout h3 { margin:0 0 8px; font-size:12px; text-transform:uppercase; letter-spacing:0.05em; color:var(--dim); }
.dot { display:inline-block; width:8px; height:8px; border-radius:50%; margin-right:6px; }
.g { background:var(--green);} .a{ background:var(--amber);} .r{background:var(--red);} .o{background:#555;}
#panel { left:10px; right:10px; bottom:calc(12px + env(safe-area-inset-bottom)); padding:14px; }
#panel h3 { margin:0 0 10px; font-size:13px; }
#panel .field { display:flex; align-items:center; gap:10px; margin-bottom:10px; }
#panel label { font-size:12.5px; color:var(--dim); flex:1; }
#panel input { width:96px; background:#141a2c; border:1px solid #26304a; border-radius:8px;
color:var(--ink); padding:8px 10px; font-family:ui-monospace,monospace; font-size:13px; text-align:right; }
#panel .btns { display:flex; gap:8px; margin-top:4px; }
#panel button { flex:1; border:0; border-radius:9px; padding:11px; font-size:13px; font-weight:600; cursor:pointer; }
.primary { background:linear-gradient(135deg,#529eff,#51eaf1); color:#06121f; }
.ghost { background:#1a2033; color:var(--ink); }
#result { font-size:12.5px; line-height:1.55; margin-top:8px; color:var(--dim); }
#result b { color:var(--cyan); font-family:ui-monospace,monospace; }
#start { position:fixed; inset:0; display:flex; flex-direction:column; align-items:center; justify-content:center;
gap:16px; text-align:center; padding:26px; background:radial-gradient(circle at 50% 40%,#141a2b,#05060a 72%); }
#start h1 { font-size:24px; margin:0; } #start p { color:var(--dim); max-width:32ch; line-height:1.6; font-size:14px; margin:0; }
#go { border:0; cursor:pointer; font-size:16px; font-weight:600; color:#06121f;
background:linear-gradient(135deg,#529eff,#51eaf1); padding:14px 32px; border-radius:999px; }
#err { color:var(--red); font-size:12.5px; max-width:34ch; }
.hidden { display:none !important; }
.help { font-size:11px; color:var(--dim); margin-top:2px; line-height:1.4; }
</style>
</head>
<body>
<video id="video" playsinline muted autoplay></video>
<canvas id="overlay"></canvas>
<div class="card hidden" id="readout">
<h3>Live pose</h3>
<div class="row"><span>marker</span><span class="v" id="rId"><span class="dot o"></span>none</span></div>
<div class="row"><span>distance</span><span class="v" id="rDist"></span></div>
<div class="row"><span>view angle</span><span class="v" id="rAngle"></span></div>
<div class="row"><span>pos jitter</span><span class="v" id="rJit"></span></div>
<div class="row"><span>size used</span><span class="v" id="rSize"></span></div>
</div>
<div class="card hidden" id="panel">
<h3>Calibrate marker size</h3>
<div class="field">
<label>Measured distance camera→crest (tape)</label>
<input id="actualMm" type="number" placeholder="mm" />
</div>
<div class="btns">
<button class="ghost" id="holdBtn">Hold sample</button>
<button class="primary" id="solveBtn">Solve size</button>
</div>
<div id="result">Point at a crest, hold steady, type the real distance you measured with a tape, then Solve. It back-calculates the true marker size so on-table distances are accurate.</div>
<div class="field" style="margin-top:12px">
<label>Marker size in use</label>
<input id="sizeMm" type="number" value="96" />
</div>
<div class="help">Default 96mm = 6 cells × 16mm (outer black square). Change it here to see distance update live, or Solve to compute it from a measurement.</div>
</div>
<div id="start">
<h1>Crest calibration</h1>
<p>Measures the marker pose so ghost scale and placement are accurate. You'll need a tape measure.</p>
<button id="go">Start camera</button>
<div id="err"></div>
</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 { TunedDetector, MarkerTracker } from './js/detect-tuned.js?v=1';
const $ = (id) => document.getElementById(id);
const video = $('video'), overlay = $('overlay'), octx = overlay.getContext('2d');
const grab = document.createElement('canvas'), gctx = grab.getContext('2d', { willReadFrequently: true });
let detector, tracker, posit, focalPx = 0, running = false;
let sizeMm = 96;
const jitBuf = []; // recent positions for jitter calc
let held = null; // held sample { dist, angle }
function sizeAll() {
const vw = video.videoWidth, vh = video.videoHeight; if (!vw) return;
grab.width = vw; grab.height = vh;
const scale = Math.max(innerWidth / vw, innerHeight / vh);
const dw = vw * scale, dh = vh * scale;
for (const el of [video, overlay]) { el.style.width = dw + 'px'; el.style.height = dh + 'px'; }
overlay.width = dw; overlay.height = dh;
focalPx = vw;
posit = new POS.Posit(sizeMm, focalPx);
}
function poseOf(m) {
const c = m.corners.map((p) => ({ x: p.x - grab.width / 2, y: grab.height / 2 - p.y }));
const pose = posit.pose(c); if (!pose) return null;
const t = pose.bestTranslation;
const dist = Math.hypot(t[0], t[1], t[2]);
// view angle: how square-on we are. marker normal is rotation*(0,0,1)
const R = pose.bestRotation;
const nz = R[2][2]; // z-component of marker normal in cam space
const angle = Math.acos(Math.min(1, Math.abs(nz))) * 180 / Math.PI;
return { dist, angle, t, err: pose.bestError };
}
function setDot(el, cls, text) { el.innerHTML = `<span class="dot ${cls}"></span>${text}`; }
async function start() {
$('err').textContent = '';
try {
const stream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: { ideal: 'environment' }, width: { ideal: 1280 }, height: { ideal: 720 } }, audio: false });
video.srcObject = stream; await video.play();
} catch (e) { $('err').textContent = 'Camera error: ' + (e.message || e.name) + '. iOS needs HTTPS.'; return; }
detector = new TunedDetector({ dictionaryName: 'ARUCO_4X4_1000' }).setPreset('forgiving');
tracker = new MarkerTracker({ coastMs: 200, smooth: true });
await new Promise((r) => { if (video.readyState >= 2) r(); else video.onloadeddata = () => r(); });
sizeAll(); addEventListener('resize', sizeAll);
$('start').classList.add('hidden'); $('readout').classList.remove('hidden'); $('panel').classList.remove('hidden');
running = true; requestAnimationFrame(loop);
}
function loop(now) {
if (!running) return; requestAnimationFrame(loop);
if (video.readyState < 2 || !grab.width) return;
gctx.drawImage(video, 0, 0, grab.width, grab.height);
const img = gctx.getImageData(0, 0, grab.width, grab.height);
let raw = []; try { raw = detector.detect(img, { width: grab.width, height: grab.height }); } catch (_) {}
const markers = tracker.update(raw, now);
octx.clearRect(0, 0, overlay.width, overlay.height);
const sx = overlay.width / grab.width, sy = overlay.height / grab.height;
// choose the largest marker in view
let main = null, mainArea = 0;
for (const m of markers) {
const a = polyArea(m.corners); if (a > mainArea) { mainArea = a; main = m; }
// draw outline
octx.strokeStyle = '#51eaf1'; octx.lineWidth = 2; octx.beginPath();
m.corners.forEach((p, i) => { const x = p.x * sx, y = p.y * sy; i ? octx.lineTo(x, y) : octx.moveTo(x, y); });
octx.closePath(); octx.stroke();
}
if (main) {
const p = poseOf(main);
if (p) {
setDot($('rId'), main.coasting ? 'a' : 'g', 'id ' + main.id + (main.coasting ? ' (coast)' : ''));
$('rDist').textContent = Math.round(p.dist) + ' mm';
$('rAngle').textContent = Math.round(p.angle) + '°';
$('rSize').textContent = sizeMm + ' mm';
// jitter over ~1s
jitBuf.push({ x: p.t[0], y: p.t[1], z: p.t[2], t: now });
while (jitBuf.length && now - jitBuf[0].t > 1000) jitBuf.shift();
const jit = stddev3(jitBuf);
$('rJit').textContent = jit.toFixed(2) + ' mm';
$('rJit').parentElement.querySelector; // noop
const jitEl = $('rJit');
jitEl.style.color = jit < 0.6 ? 'var(--green)' : jit < 1.5 ? 'var(--amber)' : 'var(--red)';
window._live = p; // for hold/solve
}
} else {
setDot($('rId'), 'o', 'none'); $('rDist').textContent = '—'; $('rAngle').textContent = '—'; $('rJit').textContent = '—';
window._live = null;
}
}
function polyArea(c) { let a = 0; for (let i = 0; i < c.length; i++) { const j = (i+1)%c.length; a += c[i].x*c[j].y - c[j].x*c[i].y; } return Math.abs(a/2); }
function stddev3(buf) {
if (buf.length < 3) return 0;
const mean = buf.reduce((a,b)=>({x:a.x+b.x,y:a.y+b.y,z:a.z+b.z}),{x:0,y:0,z:0});
mean.x/=buf.length; mean.y/=buf.length; mean.z/=buf.length;
let s=0; for (const p of buf) s += (p.x-mean.x)**2+(p.y-mean.y)**2+(p.z-mean.z)**2;
return Math.sqrt(s/buf.length);
}
$('sizeMm').addEventListener('input', () => { const v = parseFloat($('sizeMm').value); if (v>0){ sizeMm=v; posit=new POS.Posit(sizeMm,focalPx);} });
$('holdBtn').addEventListener('click', () => {
if (!window._live) { $('result').textContent = 'No marker in view to hold.'; return; }
held = { dist: window._live.dist, angle: window._live.angle };
$('result').innerHTML = `Held: measured distance <b>${Math.round(held.dist)}mm</b> at <b>${Math.round(held.angle)}°</b> using size ${sizeMm}mm. Now type the real tape distance and Solve.`;
});
$('solveBtn').addEventListener('click', () => {
const actual = parseFloat($('actualMm').value);
const sample = held || (window._live ? { dist: window._live.dist } : null);
if (!sample) { $('result').textContent = 'Hold a sample or keep a marker in view first.'; return; }
if (!actual || actual <= 0) { $('result').textContent = 'Type the real measured distance (mm) first.'; return; }
// distance scales linearly with assumed marker size: trueSize = size * actual/measured
const trueSize = sizeMm * actual / sample.dist;
$('result').innerHTML = `Set <b>MARKER_SIZE_MM = ${trueSize.toFixed(1)}</b> in exhibit.js and ar-head.js.<br>`
+ `(measured ${Math.round(sample.dist)}mm vs your ${Math.round(actual)}mm at size ${sizeMm}) `
+ `— rounding to <b>${Math.round(trueSize)}mm</b> is usually fine.`;
$('sizeMm').value = Math.round(trueSize); sizeMm = Math.round(trueSize); posit = new POS.Posit(sizeMm, focalPx);
$('rSize').textContent = sizeMm + ' mm';
});
$('go').addEventListener('click', start);
</script>
</body>
</html>
+10
View File
@@ -464,6 +464,7 @@ function anchorEditor(a) {
const box = document.createElement('div'); box.style.cssText = 'padding:4px 2px 12px';
box.appendChild(textRow('Label', a.label, (v) => { a.label = v; markDirty(); renderAnchorsSoft(); }));
box.appendChild(selectRow('Marker', a.fiducialType || 'aruco', ['aruco', 'apriltag'], (v) => { a.fiducialType = v; markDirty(); }));
box.appendChild(selectRow('Mount', a.mount || 'flat', ['flat', 'wall'], (v) => { a.mount = v; markDirty(); }));
box.appendChild(numRow('Marker id', a.fiducialId, (v) => { a.fiducialId = v | 0; markDirty(); renderAnchorsSoft(); }, { step: 1, min: 0 }));
box.appendChild(numRow('Size (mm)', a.fiducialSizeMm, (v) => { a.fiducialSizeMm = v; rebuildAnchors(); markDirty(); }, { step: 1 }));
box.appendChild(xyzRow('Pos (mm)', a.position, ['x', 'y', 'z'], () => { rebuildAnchors(); markDirty(); }));
@@ -727,6 +728,15 @@ if (printAnchorsBtn) printAnchorsBtn.addEventListener('click', () => {
window.open('anchors-print.html', '_blank');
});
const planBtn = document.getElementById('planBtn');
if (planBtn) planBtn.addEventListener('click', () => {
if (dirty) toast('Tip: save first so the plan matches your latest layout', '');
window.open('placement-plan.html', '_blank');
});
const calibrateBtn = document.getElementById('calibrateBtn');
if (calibrateBtn) calibrateBtn.addEventListener('click', () => window.open('calibrate.html', '_blank'));
window.addEventListener('beforeunload', (e) => { if (dirty) { e.preventDefault(); e.returnValue = ''; } });
// ===========================================================================
+22 -7
View File
@@ -84,16 +84,31 @@ function markerToCameraMatrix(m) {
return { M, err: pose.bestError, dist: Math.hypot(t[0], t[1], t[2]) };
}
// anchor world placement -> matrix (world coords of the marker's centre + yaw).
// The marker lies flat-ish on the build; we treat its plane with the anchor yaw
// about the vertical (Y) axis. Anchor.position is the marker centre in world mm.
// anchor world placement -> matrix mapping the MARKER's local frame (as POS-IT
// sees it: marker in its XY plane, +Z out of the printed face) into WORLD space.
//
// A crest lying flat on the table has its face pointing UP, so the marker's
// local +Z must map to world +Y. That's a -90 deg rotation about X. On top of
// that we apply the anchor's yaw (spin on the table) and its world position.
//
// mount 'flat' (default): marker lies on the table, face up -> rotX(-90) then yaw about Y
// mount 'wall': marker stands vertical, face outward -> yaw about Y only
//
// The order matters: worldFromMarker = T(pos) * Ry(yaw) * planeTilt.
function anchorToWorldMatrix(anchor) {
const p = anchor.position || { x: 0, y: 0, z: 0 };
const yaw = THREE.MathUtils.degToRad(anchor.rotationDeg?.y || 0);
const m = new THREE.Matrix4();
m.makeRotationY(yaw);
m.setPosition(p.x, p.y, p.z);
return m;
const mount = anchor.mount || 'flat';
const planeTilt = new THREE.Matrix4();
if (mount === 'wall') planeTilt.identity(); // face already points sideways
else planeTilt.makeRotationX(-Math.PI / 2); // flat: tip face up to +Y
const spin = new THREE.Matrix4().makeRotationY(yaw);
const trans = new THREE.Matrix4().setPosition(p.x, p.y, p.z);
// trans * spin * planeTilt
return trans.multiply(spin).multiply(planeTilt);
}
// Given a detected marker with a known anchor, compute world->camera and apply
+222
View File
@@ -0,0 +1,222 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Newbury Exhibit — placement plan</title>
<style>
:root { --ink:#12131a; --dim:#666; --line:#cfd3da;
--red:#f65151; --yellow:#e0b400; --blue:#529eff; --anchor:#00a88f; --bldg:#c98a4b; }
* { box-sizing:border-box; }
html,body { margin:0; background:#f4f5f7; color:var(--ink);
font-family:'Inter',system-ui,sans-serif; }
#bar { position:sticky; top:0; z-index:10; background:#fff; border-bottom:1px solid var(--line);
padding:10px 16px; display:flex; align-items:center; gap:14px; flex-wrap:wrap;
box-shadow:0 1px 4px rgba(0,0,0,0.06); }
#bar h1 { font-size:15px; margin:0; font-weight:700; }
#bar h1 span { color:var(--blue); }
#bar .spacer { flex:1; }
#bar label { font-size:12px; color:var(--dim); display:flex; align-items:center; gap:5px; }
#bar button { border:0; cursor:pointer; font-weight:600; font-size:13px; padding:8px 16px;
border-radius:8px; background:#111827; color:#fff; }
#bar button.ghost { background:#eef0f4; color:#111827; }
#wrap { padding:18px; max-width:1000px; margin:0 auto; }
.planbox { background:#fff; border:1px solid #e3e5ea; border-radius:12px; padding:16px; margin-bottom:16px; }
svg { display:block; width:100%; height:auto; }
table { width:100%; border-collapse:collapse; font-size:12.5px; }
th,td { text-align:left; padding:7px 10px; border-bottom:1px solid #eceef2; }
th { font-size:11px; text-transform:uppercase; letter-spacing:0.04em; color:var(--dim); }
td.mono, th.mono { font-family:ui-monospace,monospace; }
.chip { display:inline-block; width:10px; height:10px; border-radius:3px; margin-right:6px; vertical-align:middle; }
.legend { display:flex; gap:18px; flex-wrap:wrap; font-size:12px; color:var(--dim); margin-top:10px; }
.legend span { display:flex; align-items:center; gap:6px; }
h2 { font-size:13px; margin:0 0 10px; }
.note { font-size:11px; color:var(--dim); margin-top:8px; line-height:1.5; }
@media print {
#bar { display:none; }
body { background:#fff; }
.planbox { border:none; box-shadow:none; page-break-inside:avoid; }
}
</style>
</head>
<body>
<div id="bar">
<h1>Newbury <span>placement plan</span></h1>
<div class="spacer"></div>
<label><input id="grid" type="checkbox" checked /> 100mm grid</label>
<label><input id="labels" type="checkbox" checked /> labels</label>
<button class="ghost" id="reloadBtn">Reload</button>
<button id="printBtn">Print</button>
</div>
<div id="wrap">
<div class="planbox">
<h2>Top-down plan (looking down at the table)</h2>
<div id="plan">loading…</div>
<div class="legend">
<span><i class="chip" style="background:var(--anchor)"></i> anchor (crest) — arrow = facing/yaw</span>
<span><i class="chip" style="background:var(--bldg)"></i> building footprint</span>
<span><i class="chip" style="background:var(--blue)"></i> ghost spawn / path</span>
</div>
<div class="note">Origin (0,0) is the front-left corner. X runs right (length), Z runs away from you (depth). Measure from that corner when placing pieces. Print at 100% for a to-scale reference, or just use the coordinate tables below.</div>
</div>
<div class="planbox">
<h2>Anchors — where to place each crest</h2>
<div id="anchorTable"></div>
</div>
<div class="planbox">
<h2>Buildings — footprints</h2>
<div id="bldgTable"></div>
</div>
<div class="planbox">
<h2>Ghost spawns</h2>
<div id="ghostTable"></div>
</div>
</div>
<script>
(function () {
'use strict';
const A = '#00a88f', B = '#c98a4b';
const LURE = { Red:'#f65151', Yellow:'#e0b400', Blue:'#529eff' };
let scene = null;
async function load() {
try {
const r = await fetch('/api/scene');
scene = await r.json();
} catch (e) {
document.getElementById('plan').textContent = 'Could not load /api/scene: ' + e.message;
return;
}
render();
}
function render() {
const showGrid = document.getElementById('grid').checked;
const showLabels = document.getElementById('labels').checked;
const W = scene.world.buildSize.x, D = scene.world.buildSize.z;
// SVG scaled so the longer side fits ~920px; keep mm aspect.
const pad = 46;
const scale = (920 - pad * 2) / Math.max(W, D);
const sw = W * scale + pad * 2, sh = D * scale + pad * 2;
const X = (x) => pad + x * scale;
const Z = (z) => pad + z * scale; // top-down: z downward on the page
let s = `<svg viewBox="0 0 ${sw} ${sh}" xmlns="http://www.w3.org/2000/svg" font-family="Inter,system-ui,sans-serif">`;
s += `<rect x="${pad}" y="${pad}" width="${W*scale}" height="${D*scale}" fill="#fafbfc" stroke="#b9bec8"/>`;
// grid
if (showGrid) {
for (let x = 0; x <= W; x += 100) {
s += `<line x1="${X(x)}" y1="${pad}" x2="${X(x)}" y2="${pad+D*scale}" stroke="#eceef2"/>`;
if (x % 500 === 0) s += `<text x="${X(x)}" y="${pad-6}" font-size="10" fill="#999" text-anchor="middle">${x}</text>`;
}
for (let z = 0; z <= D; z += 100) {
s += `<line x1="${pad}" y1="${Z(z)}" x2="${pad+W*scale}" y2="${Z(z)}" stroke="#eceef2"/>`;
if (z % 500 === 0) s += `<text x="${pad-8}" y="${Z(z)+3}" font-size="10" fill="#999" text-anchor="end">${z}</text>`;
}
}
// origin marker + axes
s += `<circle cx="${X(0)}" cy="${Z(0)}" r="4" fill="#111"/>`;
s += `<line x1="${X(0)}" y1="${Z(0)}" x2="${X(0)+40}" y2="${Z(0)}" stroke="#f65151" stroke-width="2"/><text x="${X(0)+46}" y="${Z(0)+4}" font-size="11" fill="#f65151">X</text>`;
s += `<line x1="${X(0)}" y1="${Z(0)}" x2="${X(0)}" y2="${Z(0)+40}" stroke="#529eff" stroke-width="2"/><text x="${X(0)+4}" y="${Z(0)+52}" font-size="11" fill="#529eff">Z</text>`;
// buildings (footprints)
for (const bl of (scene.blockers || [])) {
const x = X(bl.position.x), y = Z(bl.position.z);
const w = bl.size.x * scale, h = bl.size.z * scale;
s += `<rect x="${x}" y="${y}" width="${w}" height="${h}" fill="${B}22" stroke="${B}" stroke-width="1.5"/>`;
if (showLabels) s += `<text x="${x + w/2}" y="${y + h/2 + 3}" font-size="10" fill="${B}" text-anchor="middle">${bl.label || 'bldg'}</text>`;
}
// ghost paths + spawns
for (const sp of (scene.spawns || [])) {
const col = LURE[sp.color] || LURE.Blue;
if (sp.motion === 'patrol' && sp.path && sp.path.length > 1) {
const pts = sp.path.map(p => `${X(p.x)},${Z(p.z)}`).join(' ');
s += `<polygon points="${pts}" fill="none" stroke="${col}" stroke-width="1.5" stroke-dasharray="4 3" opacity="0.8"/>`;
for (const p of sp.path) s += `<circle cx="${X(p.x)}" cy="${Z(p.z)}" r="3" fill="${col}"/>`;
const h = sp.path[0];
if (showLabels) s += `<text x="${X(h.x)+6}" y="${Z(h.z)-6}" font-size="10" fill="${col}">${sp.label||sp.ghostId||'ghost'}</text>`;
} else if (sp.position) {
const x = X(sp.position.x), y = Z(sp.position.z);
s += `<circle cx="${x}" cy="${y}" r="6" fill="${col}" opacity="0.85"/>`;
if (sp.hoverRadiusMm) s += `<circle cx="${x}" cy="${y}" r="${sp.hoverRadiusMm*scale}" fill="none" stroke="${col}" stroke-width="1" stroke-dasharray="2 2" opacity="0.6"/>`;
if (showLabels) s += `<text x="${x+9}" y="${y+3}" font-size="10" fill="${col}">${sp.label||sp.ghostId||'ghost'}</text>`;
}
}
// anchors with orientation arrow (yaw)
for (const a of (scene.anchors || [])) {
const x = X(a.position.x), y = Z(a.position.z);
const sz = (a.fiducialSizeMm || 96) * scale;
s += `<rect x="${x - sz/2}" y="${y - sz/2}" width="${sz}" height="${sz}" fill="${A}22" stroke="${A}" stroke-width="1.5"/>`;
// yaw arrow: 0deg points +Z (down the page). rotate clockwise for +yaw.
const yaw = (a.rotationDeg && a.rotationDeg.y || 0) * Math.PI / 180;
const ax = x + Math.sin(yaw) * (sz/2 + 12), ay = y + Math.cos(yaw) * (sz/2 + 12);
s += `<line x1="${x}" y1="${y}" x2="${ax}" y2="${ay}" stroke="${A}" stroke-width="2"/>`;
s += `<circle cx="${ax}" cy="${ay}" r="3" fill="${A}"/>`;
if (showLabels) s += `<text x="${x}" y="${y - sz/2 - 6}" font-size="10" fill="${A}" text-anchor="middle">${a.label || ('id '+a.fiducialId)}</text>`;
}
s += '</svg>';
document.getElementById('plan').innerHTML = s;
renderTables();
}
function renderTables() {
// anchors
let t = `<table><thead><tr><th>Crest</th><th class="mono">id</th><th class="mono">X (mm)</th><th class="mono">Z (mm)</th><th class="mono">Y height</th><th class="mono">yaw°</th><th class="mono">size</th><th>mount</th></tr></thead><tbody>`;
for (const a of (scene.anchors || [])) {
t += `<tr><td><span class="chip" style="background:${A}"></span>${a.label||a.id}</td>`
+ `<td class="mono">${a.fiducialId}</td>`
+ `<td class="mono">${Math.round(a.position.x)}</td><td class="mono">${Math.round(a.position.z)}</td>`
+ `<td class="mono">${Math.round(a.position.y||0)}</td>`
+ `<td class="mono">${Math.round((a.rotationDeg&&a.rotationDeg.y)||0)}</td>`
+ `<td class="mono">${a.fiducialSizeMm||96}mm</td>`
+ `<td>${a.mount||'flat'}</td></tr>`;
}
t += `</tbody></table>`;
document.getElementById('anchorTable').innerHTML = (scene.anchors||[]).length ? t : '<div class="note">No anchors yet.</div>';
// buildings
let b = `<table><thead><tr><th>Building</th><th class="mono">corner X</th><th class="mono">corner Z</th><th class="mono">W (x)</th><th class="mono">H (y)</th><th class="mono">D (z)</th></tr></thead><tbody>`;
for (const bl of (scene.blockers || [])) {
b += `<tr><td><span class="chip" style="background:${B}"></span>${bl.label||bl.id}</td>`
+ `<td class="mono">${Math.round(bl.position.x)}</td><td class="mono">${Math.round(bl.position.z)}</td>`
+ `<td class="mono">${bl.size.x}</td><td class="mono">${bl.size.y}</td><td class="mono">${bl.size.z}</td></tr>`;
}
b += `</tbody></table>`;
document.getElementById('bldgTable').innerHTML = (scene.blockers||[]).length ? b : '<div class="note">No buildings yet.</div>';
// ghosts
let g = `<table><thead><tr><th>Ghost</th><th>lure</th><th>motion</th><th class="mono">X</th><th class="mono">Y</th><th class="mono">Z</th><th>detail</th></tr></thead><tbody>`;
for (const sp of (scene.spawns || [])) {
const col = LURE[sp.color] || LURE.Blue;
const home = sp.motion === 'patrol' ? (sp.path && sp.path[0]) : sp.position;
const detail = sp.motion === 'patrol' ? `${(sp.path||[]).length} pts · ${sp.patrolPeriodS||16}s loop`
: `r${sp.hoverRadiusMm||0} · bob${sp.bobAmplitudeMm||0}`;
g += `<tr><td><span class="chip" style="background:${col}"></span>${sp.label||sp.ghostId||sp.id}</td>`
+ `<td>${sp.color||'?'}</td><td>${sp.motion}</td>`
+ `<td class="mono">${home?Math.round(home.x):'-'}</td><td class="mono">${home?Math.round(home.y):'-'}</td><td class="mono">${home?Math.round(home.z):'-'}</td>`
+ `<td class="mono">${detail}</td></tr>`;
}
g += `</tbody></table>`;
document.getElementById('ghostTable').innerHTML = (scene.spawns||[]).length ? g : '<div class="note">No ghosts yet.</div>';
}
document.getElementById('grid').addEventListener('change', render);
document.getElementById('labels').addEventListener('change', render);
document.getElementById('reloadBtn').addEventListener('click', load);
document.getElementById('printBtn').addEventListener('click', () => window.print());
load();
})();
</script>
</body>
</html>