diff --git a/README.md b/README.md index 899e50f..d9686e5 100644 --- a/README.md +++ b/README.md @@ -33,11 +33,16 @@ positions and sizes, wander radius, calibration readouts. Marker print size stay color/rarity filters, optional time-of-day windows, crossfade in/out. - **Behaviors**: static float (bob + idle sway) or wander (deterministic orbit-drift — a pure function of server spawn record + wall clock, so every phone computes the - same motion with zero position streaming). + same motion with zero position streaming). Wander is 3D: horizontal radius plus a + configurable vertical amplitude (`wanderVertical`, cm). - **Anchors ("Newbury Crests")**: flat (horizontal) / wall (vertical) / custom mounts, fully editable — position, yaw/pitch/roll trim, printed size, enable toggle. -- **Layout editor** (`/admin/layout.html`): to-scale top-down canvas; drag anchors, - occlusion buildings, and spawn points; property panel; 5 mm snap; save/reset-to-seed. +- **3D layout editor** (`/admin/layout.html`): Three.js orbit view with drag gizmos + (5 mm snap, green arrow = height) for anchors, occlusion buildings, and spawn points — + spawn Y is fully editable so ghosts can live at any height. Anchors render through the + exact same `anchorWorldMatrix` the AR pipeline uses, each with a white orientation arrow; + compass rose on the grid (N = +Z = back of table). Top-view button, property panel, + save/reset-to-seed. - **Playlist manager** (`/admin/playlist.html`), **crest print sheet** (`/admin/print.html`, true-to-size), **calibration** (`/admin/calibrate.html`: live distance/angle/jitter + fused pose), **client error log** (`/admin/errors.html`). diff --git a/data/playlist.json b/data/playlist.json index 5c36ab8..b35fc94 100644 --- a/data/playlist.json +++ b/data/playlist.json @@ -11,7 +11,8 @@ "behaviors": { "staticChance": 0.5, "wanderRadius": 60, - "wanderSpeed": 0.15 + "wanderSpeed": 0.15, + "wanderVertical": 10 }, "timeWindows": [] } \ No newline at end of file diff --git a/public/admin/layout.html b/public/admin/layout.html index a003dae..f4cfee5 100644 --- a/public/admin/layout.html +++ b/public/admin/layout.html @@ -1,166 +1,202 @@ -Layout Editor — Newbury Exhibit +Layout Editor (3D) — Newbury Exhibit
+ diff --git a/public/admin/playlist.html b/public/admin/playlist.html index ddf9009..4141ada 100644 --- a/public/admin/playlist.html +++ b/public/admin/playlist.html @@ -55,6 +55,7 @@ function render() {
+

Time windows (ghosts only appear inside these; empty = always on)

@@ -89,6 +90,7 @@ function render() { staticChance: +document.getElementById('staticChance').value, wanderRadius: +document.getElementById('wanderRadius').value, wanderSpeed: +document.getElementById('wanderSpeed').value, + wanderVertical: +document.getElementById('wanderVertical').value, }; try { cfg = await api('/api/playlist', 'PUT', cfg); document.getElementById('status').textContent = 'Applied ' + new Date().toLocaleTimeString(); } catch (e) { document.getElementById('status').textContent = 'Failed: ' + e.message; } diff --git a/public/admin/print.html b/public/admin/print.html index d7182da..7b81545 100644 --- a/public/admin/print.html +++ b/public/admin/print.html @@ -11,6 +11,7 @@
+
@@ -45,12 +46,43 @@ for (const a of scene.anchors) { if (code[y * 4 + x] === '1') ctx.fillRect((x + 1) * 10, (y + 1) * 10, 10, 10); } div.appendChild(cv); + + // Compass: bearing the print's TOP edge (flat) or FACE (wall) must point. + // 0° = N = +Z (back of table), 90° = E = +X, 180° = S (front), 270° = W. + const brg = a.mount === 'wall' + ? ((a.yawDeg % 360) + 360) % 360 + : (((180 + a.yawDeg) % 360) + 360) % 360; + const dirName = ['N','NE','E','SE','S','SW','W','NW'][Math.round(brg / 45) % 8]; + const rose = document.createElement('canvas'); + rose.width = rose.height = 120; + rose.style.width = rose.style.height = '18mm'; + const rc = rose.getContext('2d'); + rc.translate(60, 60); + rc.strokeStyle = '#000'; rc.lineWidth = 2; + rc.beginPath(); rc.arc(0, 0, 52, 0, 7); rc.stroke(); + rc.font = 'bold 16px system-ui'; rc.textAlign = 'center'; rc.textBaseline = 'middle'; rc.fillStyle = '#000'; + rc.fillText('N', 0, -40); rc.font = '12px system-ui'; + rc.fillText('E', 40, 0); rc.fillText('S', 0, 40); rc.fillText('W', -40, 0); + rc.save(); rc.rotate(brg * Math.PI / 180); // needle at target bearing + rc.beginPath(); rc.moveTo(0, 10); rc.lineTo(0, -46); + rc.lineTo(-6, -34); rc.moveTo(0, -46); rc.lineTo(6, -34); + rc.lineWidth = 3; rc.stroke(); rc.restore(); + rose.style.verticalAlign = 'middle'; rose.style.marginLeft = '3mm'; + div.appendChild(rose); + div.insertAdjacentHTML('beforeend', - `
Crest #${a.markerId} · ${a.sizeMM} mm · ${a.mount}` + - `${a.mount === 'wall' ? ` · yaw ${a.yawDeg}°` : ''}
` + - `pos ${a.position.map(v => v.toFixed(1)).join(', ')} cm
`); + `
Crest #${a.markerId} · ${a.sizeMM} mm · ${a.mount}
` + + (a.mount === 'wall' + ? `mount vertically, this way up · print faces ${brg}° ${dirName}` + : `lay flat · top edge of print → ${brg}° ${dirName}`) + + `
pos ${a.position.map(v => v.toFixed(1)).join(', ')} cm
`); sheet.appendChild(div); } +document.getElementById('rose').innerHTML = + `Table compass: N (0°) = +Z, the back of the table · E (90°) = +X, the right · + S (180°) = front · W (270°) = left. Each crest below shows the bearing its arrow must point — + align the needle with the table compass and the AR maths will line up exactly.`; + if (!scene.anchors.length) sheet.innerHTML = '

No anchors in the scene yet — add some in the Layout editor.

'; diff --git a/public/js/ghosts/behavior.js b/public/js/ghosts/behavior.js index c663d6a..ce2bbea 100644 --- a/public/js/ghosts/behavior.js +++ b/public/js/ghosts/behavior.js @@ -24,7 +24,8 @@ export function ghostTransform(rec, nowMs, out) { out.position.set( base.x + R * 0.9 * Math.sin(ph * 1.0 + hashNoise(s, 1) * 6.28) * 0.7 + R * 0.4 * Math.sin(ph * 2.3 + hashNoise(s, 2) * 6.28) * 0.3, - base.y + 6 * Math.sin(t * 1.7 + hashNoise(s, 3) * 6.28), + base.y + (b.vertical ?? 10) * Math.sin(t * v * 1.3 + hashNoise(s, 3) * 6.28) + + 3 * Math.sin(t * 1.7 + hashNoise(s, 6) * 6.28), base.z + R * 0.9 * Math.cos(ph * 0.8 + hashNoise(s, 4) * 6.28) * 0.7 + R * 0.4 * Math.cos(ph * 1.9 + hashNoise(s, 5) * 6.28) * 0.3 ); diff --git a/server/playlist.js b/server/playlist.js index e8829a8..4e61f81 100644 --- a/server/playlist.js +++ b/server/playlist.js @@ -7,7 +7,7 @@ * crossfadeSeconds: 3, // fade out/in overlap hint for clients * order: 'shuffle' | 'roster', // rotation order through the roster * include: { colors:[], rarities:[], ids:[] }, // empty = all - * behaviors: { staticChance: 0.5, wanderRadius: 60, wanderSpeed: 0.15 }, + * behaviors: { staticChance: 0.5, wanderRadius: 60, wanderSpeed: 0.15, wanderVertical: 10 }, * timeWindows: [] // e.g. [{ start:"09:00", end:"17:00" }] — empty = always on * } * @@ -23,7 +23,7 @@ class Playlist { this.cfg = state.getPlaylistConfig({ slots: 5, dwellSeconds: 120, crossfadeSeconds: 3, order: 'shuffle', include: { colors: [], rarities: [], ids: [] }, - behaviors: { staticChance: 0.5, wanderRadius: 60, wanderSpeed: 0.15 }, + behaviors: { staticChance: 0.5, wanderRadius: 60, wanderSpeed: 0.15, wanderVertical: 10 }, timeWindows: [], }); this.active = new Map(); // uid -> ghost record @@ -105,7 +105,7 @@ class Playlist { spawnId: spawn.id, pos: spawn.position, behavior: isStatic ? { type: 'static', bobAmp: 5 + Math.random() * 5, bobHz: 0.3 + Math.random() * 0.3 } - : { type: 'wander', radius: b.wanderRadius ?? 60, speed: b.wanderSpeed ?? 0.15, seed: Math.floor(Math.random() * 1e6) }, + : { type: 'wander', radius: b.wanderRadius ?? 60, speed: b.wanderSpeed ?? 0.15, vertical: b.wanderVertical ?? 10, seed: Math.floor(Math.random() * 1e6) }, spawnedAt: Date.now(), until: Date.now() + (this.cfg.dwellSeconds || 120) * 1000, crossfade: this.cfg.crossfadeSeconds || 3,