diff --git a/README.md b/README.md index d439660..899e50f 100644 --- a/README.md +++ b/README.md @@ -9,8 +9,16 @@ viewpoint. Tribute project — not affiliated with or endorsed by the LEGO Group Node 20 (Express + ws) in Docker · Three.js r160 (CDN import map) · js-aruco2 (ARUCO_4X4_1000, vendored) · vanilla ES-module client. +## Units +**All world coordinates are centimetres** (1 Three.js unit = 1 cm): anchor/spawn/building +positions and sizes, wander radius, calibration readouts. Marker print size stays in mm +(`sizeMM`); the mm→cm conversion happens once in `fuse.js`. + ## Baked-in AR fixes (hard-won, do not "simplify" these) -- **POS-IT pose**: rotation used **as-is**; translation **Y and Z negated** (`-t[1]`, `-t[2]`). +- **POS-IT pose**: **raw** centered image coords into POS-IT (no Y pre-flip — pre-flipping + double-inverts the vertical axis and makes tilt respond backwards); rotation used + **as-is**; translation **Y and Z negated** (`-t[1]`, `-t[2]`). A/B toggle: `FLIP_INPUT_Y` + in `public/js/ar/pose.js`. - **Planar-ambiguity flips**: per-marker temporal consistency — pick the POS-IT solution closest to the previous frame (`public/js/ar/pose.js`). - **Phantom marker IDs**: `maxHammingDistance: 0` (`public/js/ar/detect.js`). diff --git a/data/playlist.json b/data/playlist.json index f99b188..5c36ab8 100644 --- a/data/playlist.json +++ b/data/playlist.json @@ -3,7 +3,15 @@ "dwellSeconds": 120, "crossfadeSeconds": 3, "order": "shuffle", - "include": { "colors": [], "rarities": [], "ids": [] }, - "behaviors": { "staticChance": 0.5, "wanderRadius": 0.6, "wanderSpeed": 0.15 }, + "include": { + "colors": [], + "rarities": [], + "ids": [] + }, + "behaviors": { + "staticChance": 0.5, + "wanderRadius": 60, + "wanderSpeed": 0.15 + }, "timeWindows": [] -} +} \ No newline at end of file diff --git a/data/scene.json b/data/scene.json index 4887541..32a6d82 100644 --- a/data/scene.json +++ b/data/scene.json @@ -1,20 +1,141 @@ { - "world": { "units": "m", "note": "origin = front-left corner of the table; +X right, +Z toward the back, +Y up" }, + "world": { + "units": "cm", + "note": "origin = front-left corner of the table; +X right, +Z toward the back, +Y up. All positions/sizes in centimetres; marker print size stays in mm (sizeMM)." + }, "anchors": [ - { "markerId": 0, "position": [0.20, 0.00, 0.20], "mount": "flat", "yawDeg": 0, "pitchDeg": 0, "rollDeg": 0, "sizeMM": 60, "enabled": true }, - { "markerId": 1, "position": [0.90, 0.00, 0.20], "mount": "flat", "yawDeg": 0, "pitchDeg": 0, "rollDeg": 0, "sizeMM": 60, "enabled": true }, - { "markerId": 2, "position": [0.20, 0.00, 0.90], "mount": "flat", "yawDeg": 0, "pitchDeg": 0, "rollDeg": 0, "sizeMM": 60, "enabled": true }, - { "markerId": 3, "position": [0.90, 0.00, 0.90], "mount": "flat", "yawDeg": 0, "pitchDeg": 0, "rollDeg": 0, "sizeMM": 60, "enabled": true }, - { "markerId": 4, "position": [0.55, 0.12, 0.00], "mount": "wall", "yawDeg": 180, "pitchDeg": 0, "rollDeg": 0, "sizeMM": 60, "enabled": true } + { + "markerId": 0, + "position": [ + 20.0, + 0.0, + 20.0 + ], + "mount": "flat", + "yawDeg": 0, + "pitchDeg": 0, + "rollDeg": 0, + "sizeMM": 60, + "enabled": true + }, + { + "markerId": 1, + "position": [ + 90.0, + 0.0, + 20.0 + ], + "mount": "flat", + "yawDeg": 0, + "pitchDeg": 0, + "rollDeg": 0, + "sizeMM": 60, + "enabled": true + }, + { + "markerId": 2, + "position": [ + 20.0, + 0.0, + 90.0 + ], + "mount": "flat", + "yawDeg": 0, + "pitchDeg": 0, + "rollDeg": 0, + "sizeMM": 60, + "enabled": true + }, + { + "markerId": 3, + "position": [ + 90.0, + 0.0, + 90.0 + ], + "mount": "flat", + "yawDeg": 0, + "pitchDeg": 0, + "rollDeg": 0, + "sizeMM": 60, + "enabled": true + }, + { + "markerId": 4, + "position": [ + 55.0, + 12.0, + 0.0 + ], + "mount": "wall", + "yawDeg": 180, + "pitchDeg": 0, + "rollDeg": 0, + "sizeMM": 60, + "enabled": true + } ], "buildings": [ - { "name": "Newbury High", "position": [0.55, 0.00, 0.55], "size": [0.35, 0.30, 0.25], "yawDeg": 0 } + { + "name": "Newbury High", + "position": [ + 55.0, + 0.0, + 55.0 + ], + "size": [ + 35.0, + 30.0, + 25.0 + ], + "yawDeg": 0 + } ], "spawns": [ - { "id": "spawn-1", "position": [0.35, 0.30, 0.40], "enabled": true }, - { "id": "spawn-2", "position": [0.75, 0.35, 0.40], "enabled": true }, - { "id": "spawn-3", "position": [0.55, 0.45, 0.70], "enabled": true }, - { "id": "spawn-4", "position": [0.25, 0.25, 0.75], "enabled": true }, - { "id": "spawn-5", "position": [0.85, 0.28, 0.75], "enabled": true } + { + "id": "spawn-1", + "position": [ + 35.0, + 30.0, + 40.0 + ], + "enabled": true + }, + { + "id": "spawn-2", + "position": [ + 75.0, + 35.0, + 40.0 + ], + "enabled": true + }, + { + "id": "spawn-3", + "position": [ + 55.0, + 45.0, + 70.0 + ], + "enabled": true + }, + { + "id": "spawn-4", + "position": [ + 25.0, + 25.0, + 75.0 + ], + "enabled": true + }, + { + "id": "spawn-5", + "position": [ + 85.0, + 28.0, + 75.0 + ], + "enabled": true + } ] -} +} \ No newline at end of file diff --git a/public/admin/calibrate.html b/public/admin/calibrate.html index 71beda1..5e5bb7a 100644 --- a/public/admin/calibrate.html +++ b/public/admin/calibrate.html @@ -84,7 +84,7 @@ function loop() { } const fused = fuser.fuse(estimates); - if (fused) lines.push('', `FUSED cam @ ${fused.position.toArray().map(v => v.toFixed(3)).join(', ')} m (${fused.markerCount} crest${fused.markerCount === 1 ? '' : 's'})`); + if (fused) lines.push('', `FUSED cam @ ${fused.position.toArray().map(v => v.toFixed(1)).join(', ')} cm (${fused.markerCount} crest${fused.markerCount === 1 ? '' : 's'})`); out.textContent = lines.join('\n') || 'No crests in view — show a printed Newbury Crest to the camera.'; } loop(); diff --git a/public/admin/layout.html b/public/admin/layout.html index e37f984..a003dae 100644 --- a/public/admin/layout.html +++ b/public/admin/layout.html @@ -41,7 +41,7 @@ scene.anchors ||= []; scene.buildings ||= []; scene.spawns ||= []; const cv = document.getElementById('cv'); const ctx = cv.getContext('2d'); -let view = { x: 0, z: 0, scale: 120 }; // px per metre +let view = { x: 0, z: 0, scale: 1.2 }; // px per cm let sel = null; // { kind, index } let drag = null; @@ -59,9 +59,9 @@ const s2w = (px, py) => [(px * devicePixelRatio - cv.width / 2) / (view.scale * function draw() { ctx.clearRect(0, 0, cv.width, cv.height); - // grid (0.25 m) + // grid (25 cm) ctx.strokeStyle = '#1a2244'; ctx.lineWidth = 1; - const step = 0.25 * view.scale * devicePixelRatio; + const step = 25 * view.scale * devicePixelRatio; const ox = (cv.width / 2 - view.x * view.scale * devicePixelRatio) % step; const oz = (cv.height / 2 - view.z * view.scale * devicePixelRatio) % step; for (let x = ox; x < cv.width; x += step) { ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, cv.height); ctx.stroke(); } @@ -90,7 +90,7 @@ function draw() { for (const [i, a] of scene.anchors.entries()) { const [sx, sy] = w2s(a.position[0], a.position[2]); ctx.save(); ctx.translate(sx, sy); ctx.rotate(-(a.yawDeg || 0) * Math.PI / 180); - const r = Math.max(8, (a.sizeMM / 1000) * view.scale) * devicePixelRatio; + const r = Math.max(8, (a.sizeMM / 10) * view.scale) * devicePixelRatio; ctx.fillStyle = isSel('anchor', i) ? '#51eaf1' : (a.enabled === false ? '#456' : (a.mount === 'wall' ? '#f65151' : '#3bc9a7')); if (a.mount === 'wall') { ctx.fillRect(-r, -2.5 * devicePixelRatio, 2 * r, 5 * devicePixelRatio); // edge-on line ctx.beginPath(); ctx.moveTo(0, 0); ctx.lineTo(0, -r); ctx.strokeStyle = ctx.fillStyle; ctx.lineWidth = 2; ctx.stroke(); } @@ -108,10 +108,10 @@ const isSel = (k, i) => sel && sel.kind === k && sel.index === i; function hit(px, py) { const [wx, wz] = s2w(px, py); const near = (x, z, r) => Math.hypot(wx - x, wz - z) < r; - for (const [i, a] of scene.anchors.entries()) if (near(a.position[0], a.position[2], 0.12)) return { kind: 'anchor', index: i }; - for (const [i, s] of scene.spawns.entries()) if (near(s.position[0], s.position[2], 0.12)) return { kind: 'spawn', index: i }; + for (const [i, a] of scene.anchors.entries()) if (near(a.position[0], a.position[2], 12)) return { kind: 'anchor', index: i }; + for (const [i, s] of scene.spawns.entries()) if (near(s.position[0], s.position[2], 12)) return { kind: 'spawn', index: i }; for (const [i, b] of scene.buildings.entries()) - if (Math.abs(wx - b.position[0]) < b.size[0] / 2 + 0.05 && Math.abs(wz - b.position[2]) < b.size[2] / 2 + 0.05) + if (Math.abs(wx - b.position[0]) < b.size[0] / 2 + 5 && Math.abs(wz - b.position[2]) < b.size[2] / 2 + 5) return { kind: 'building', index: i }; return null; } @@ -129,8 +129,8 @@ cv.addEventListener('pointermove', (e) => { if (drag.kind === 'move' && sel) { const [wx, wz] = s2w(e.offsetX, e.offsetY); const o = objOf(sel); - o.position[0] = Math.round(wx * 200) / 200; // 5 mm snap - o.position[2] = Math.round(wz * 200) / 200; + o.position[0] = Math.round(wx * 2) / 2; // 5 mm snap + o.position[2] = Math.round(wz * 2) / 2; panel(false); draw(); } else if (drag.kind === 'pan') { view.x = drag.vx - (e.offsetX - drag.sx) / view.scale; @@ -141,7 +141,7 @@ cv.addEventListener('pointermove', (e) => { cv.addEventListener('pointerup', () => drag = null); cv.addEventListener('wheel', (e) => { e.preventDefault(); - view.scale = Math.min(600, Math.max(30, view.scale * (e.deltaY < 0 ? 1.12 : 0.89))); + view.scale = Math.min(6, Math.max(0.3, view.scale * (e.deltaY < 0 ? 1.12 : 0.89))); draw(); }, { passive: false }); @@ -154,10 +154,10 @@ document.querySelectorAll('[data-add]').forEach(b => b.onclick = () => { scene.anchors.push({ markerId: id, position: [wx, 0, wz], mount: 'flat', yawDeg: 0, pitchDeg: 0, rollDeg: 0, sizeMM: 60, enabled: true }); sel = { kind: 'anchor', index: scene.anchors.length - 1 }; } else if (b.dataset.add === 'building') { - scene.buildings.push({ name: 'building', position: [wx, 0, wz], size: [0.4, 0.3, 0.3], yawDeg: 0 }); + scene.buildings.push({ name: 'building', position: [wx, 0, wz], size: [40, 30, 30], yawDeg: 0 }); sel = { kind: 'building', index: scene.buildings.length - 1 }; } else { - scene.spawns.push({ id: 'spawn-' + (scene.spawns.length + 1), position: [wx, 0.25, wz], enabled: true }); + scene.spawns.push({ id: 'spawn-' + (scene.spawns.length + 1), position: [wx, 25, wz], enabled: true }); sel = { kind: 'spawn', index: scene.spawns.length - 1 }; } panel(); draw(); @@ -173,7 +173,7 @@ function field(label, val, oncommit, opts) { }); if (opts?.select) return `
`; - return ``; + return ``; } function textField(label, val, oncommit) { const id = 'f' + Math.random().toString(36).slice(2, 8); @@ -187,9 +187,9 @@ function panel(rebuild = true) { let h = `flat = face-up on a surface · wall = vertical, yaw sets facing · custom = full yaw/pitch/roll
`; } else if (sel.kind === 'building') { h += textField('name', o.name || '', v => o.name = v); - h += field('x (m)', o.position[0], v => o.position[0] = v); - h += field('z (m)', o.position[2], v => o.position[2] = v); + h += field('x (cm)', o.position[0], v => o.position[0] = v); + h += field('z (cm)', o.position[2], v => o.position[2] = v); h += field('width', o.size[0], v => o.size[0] = v); h += field('height', o.size[1], v => o.size[1] = v); h += field('depth', o.size[2], v => o.size[2] = v); h += field('yaw °', o.yawDeg || 0, v => o.yawDeg = v, { step: 1 }); } else { h += textField('id', o.id, v => o.id = v); - h += field('x (m)', o.position[0], v => o.position[0] = v); - h += field('y (m)', o.position[1], v => o.position[1] = v); - h += field('z (m)', o.position[2], v => o.position[2] = v); + h += field('x (cm)', o.position[0], v => o.position[0] = v); + h += field('y (cm)', o.position[1], v => o.position[1] = v); + h += field('z (cm)', o.position[2], v => o.position[2] = v); h += field('enabled', o.enabled === false ? 'no' : 'yes', v => o.enabled = v === 'yes', { select: ['yes', 'no'] }); } h += ``; diff --git a/public/admin/playlist.html b/public/admin/playlist.html index dcabeb9..ddf9009 100644 --- a/public/admin/playlist.html +++ b/public/admin/playlist.html @@ -53,7 +53,7 @@ function render() {No anchors in the scene yet — add some in the Layout editor.
'; diff --git a/public/js/ar/fuse.js b/public/js/ar/fuse.js index d9920df..0cc81ba 100644 --- a/public/js/ar/fuse.js +++ b/public/js/ar/fuse.js @@ -41,13 +41,13 @@ export class WorldFuser { /** estimates: [{ markerId, position(mm), quaternion, area }] */ fuse(estimates) { - const MM = 0.001; + const MM2CM = 0.1; // POS-IT translation is in mm; world units are cm const cams = []; for (const e of estimates) { const entry = this.anchorMats.get(e.markerId); if (!entry) continue; - // marker pose in camera space -> matrix (translate mm->m) - _m.compose(_p.copy(e.position).multiplyScalar(MM), e.quaternion, _s.set(1, 1, 1)); + // marker pose in camera space -> matrix (translate mm->cm) + _m.compose(_p.copy(e.position).multiplyScalar(MM2CM), e.quaternion, _s.set(1, 1, 1)); _inv.copy(_m).invert(); // camera in marker space const camWorld = new THREE.Matrix4().multiplyMatrices(entry.mat, _inv); const pos = new THREE.Vector3(); diff --git a/public/js/ar/pose.js b/public/js/ar/pose.js index 1b6a388..12a2fff 100644 --- a/public/js/ar/pose.js +++ b/public/js/ar/pose.js @@ -10,6 +10,13 @@ import * as THREE from 'three'; const _m = new THREE.Matrix4(); const _q = new THREE.Quaternion(); +/* Input convention (matches the v1-validated fix): raw centered image coords are + * passed to POS-IT — NO Y pre-flip. The -t[1]/-t[2] negation below is what converts + * to the Three.js camera frame. Pre-flipping Y here double-flips the vertical axis + * and inverts pitch response (ghosts move opposite when tilting the phone). + * If tilt ever reads inverted on a device, toggle this for a quick A/B test. */ +const FLIP_INPUT_Y = false; + export class PoseEstimator { constructor(focalLength) { this.focal = focalLength; @@ -26,7 +33,7 @@ export class PoseEstimator { /** corners: aruco marker corners, image-space; cx/cy: image center. * Returns { position: THREE.Vector3 (mm, marker->camera), quaternion, error } */ estimate(markerId, corners, cx, cy, sizeMM) { - const centered = corners.map(c => ({ x: c.x - cx, y: (cy - c.y) })); + const centered = corners.map(c => ({ x: c.x - cx, y: FLIP_INPUT_Y ? (cy - c.y) : (c.y - cy) })); const pose = this.positFor(sizeMM).pose(centered); if (!pose) return null; diff --git a/public/js/exhibit.js b/public/js/exhibit.js index 851e18e..3b21535 100644 --- a/public/js/exhibit.js +++ b/public/js/exhibit.js @@ -53,7 +53,7 @@ async function start() { renderer = new THREE.WebGLRenderer({ canvas: $('#gl'), alpha: true, antialias: true }); renderer.setPixelRatio(Math.min(devicePixelRatio, 2)); scene3 = new THREE.Scene(); - camera3 = new THREE.PerspectiveCamera(60, innerWidth / innerHeight, 0.02, 50); + camera3 = new THREE.PerspectiveCamera(60, innerWidth / innerHeight, 2, 5000); scene3.add(new THREE.AmbientLight(0xffffff, 1.2)); onResize(); addEventListener('resize', onResize); diff --git a/public/js/ghosts/behavior.js b/public/js/ghosts/behavior.js index 4c5cb2b..c663d6a 100644 --- a/public/js/ghosts/behavior.js +++ b/public/js/ghosts/behavior.js @@ -17,14 +17,14 @@ export function ghostTransform(rec, nowMs, out) { if (b.type === 'wander') { const s = b.seed || 1; - const R = b.radius ?? 0.6; + const R = b.radius ?? 60; // cm const v = b.speed ?? 0.15; // smooth pseudo-random orbit-drift: two incommensurate sines per axis const ph = t * v; 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 + 0.06 * Math.sin(t * 1.7 + hashNoise(s, 3) * 6.28), + base.y + 6 * Math.sin(t * 1.7 + hashNoise(s, 3) * 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 ); @@ -37,7 +37,7 @@ export function ghostTransform(rec, nowMs, out) { const dir = ahead(t + eps).sub(ahead(t)); out.rotationY = Math.atan2(dir.x, dir.z); } else { - const amp = b.bobAmp ?? 0.06; + const amp = b.bobAmp ?? 6; // cm const hz = b.bobHz ?? 0.4; out.position.set(base.x, base.y + amp * Math.sin(t * hz * Math.PI * 2), base.z); out.rotationY = 0.25 * Math.sin(t * 0.3); // slow idle sway diff --git a/public/js/ghosts/loader.js b/public/js/ghosts/loader.js index e8f5411..b9be31f 100644 --- a/public/js/ghosts/loader.js +++ b/public/js/ghosts/loader.js @@ -59,12 +59,12 @@ async function loadOBJ(url) { function proceduralWisp() { const g = new THREE.Group(); - const body = new THREE.Mesh(new THREE.SphereGeometry(0.09, 20, 16)); + const body = new THREE.Mesh(new THREE.SphereGeometry(9, 20, 16)); body.scale.set(1, 1.35, 1); - body.position.y = 0.14; - const tail = new THREE.Mesh(new THREE.ConeGeometry(0.07, 0.16, 16)); + body.position.y = 14; + const tail = new THREE.Mesh(new THREE.ConeGeometry(7, 16, 16)); tail.rotation.x = Math.PI; - tail.position.y = 0.0; + tail.position.y = 0; g.add(body, tail); return g; } diff --git a/server/playlist.js b/server/playlist.js index e43586a..e8829a8 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: 0.6, wanderSpeed: 0.15 }, + * behaviors: { staticChance: 0.5, wanderRadius: 60, wanderSpeed: 0.15 }, * 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: 0.6, wanderSpeed: 0.15 }, + behaviors: { staticChance: 0.5, wanderRadius: 60, wanderSpeed: 0.15 }, timeWindows: [], }); this.active = new Map(); // uid -> ghost record @@ -104,8 +104,8 @@ class Playlist { uid, id: g.id, name: g.name, color: g.color, rarity: g.rarity, spawnId: spawn.id, pos: spawn.position, behavior: isStatic - ? { type: 'static', bobAmp: 0.05 + Math.random() * 0.05, bobHz: 0.3 + Math.random() * 0.3 } - : { type: 'wander', radius: b.wanderRadius ?? 0.6, speed: b.wanderSpeed ?? 0.15, seed: Math.floor(Math.random() * 1e6) }, + ? { 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) }, spawnedAt: Date.now(), until: Date.now() + (this.cfg.dwellSeconds || 120) * 1000, crossfade: this.cfg.crossfadeSeconds || 3, diff --git a/server/state.js b/server/state.js index 01454c9..820a07b 100644 --- a/server/state.js +++ b/server/state.js @@ -22,7 +22,7 @@ function writeJSON(p, obj) { fs.writeFileSync(p, JSON.stringify(obj, null, 2)); } -let scene = readJSON(LIVE, null) || readJSON(SEED, { anchors: [], buildings: [], spawns: [], world: { units: 'm' } }); +let scene = readJSON(LIVE, null) || readJSON(SEED, { anchors: [], buildings: [], spawns: [], world: { units: 'cm' } }); let ghosts = readJSON(path.join(DATA, 'ghosts.json'), []); let gradients = readJSON(path.join(DATA, 'gradients.json'), {}); let models = readJSON(MODELS_LIVE, null) || readJSON(MODELS_SEED, { models: [] });