update Tue 07/14/2026 12:21:23.58

This commit is contained in:
2026-07-14 12:21:24 +10:00
parent b2b555ef16
commit f2bfad7ee2
14 changed files with 202 additions and 58 deletions
+9 -1
View File
@@ -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 Node 20 (Express + ws) in Docker · Three.js r160 (CDN import map) · js-aruco2
(ARUCO_4X4_1000, vendored) · vanilla ES-module client. (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) ## 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 - **Planar-ambiguity flips**: per-marker temporal consistency — pick the POS-IT solution
closest to the previous frame (`public/js/ar/pose.js`). closest to the previous frame (`public/js/ar/pose.js`).
- **Phantom marker IDs**: `maxHammingDistance: 0` (`public/js/ar/detect.js`). - **Phantom marker IDs**: `maxHammingDistance: 0` (`public/js/ar/detect.js`).
+10 -2
View File
@@ -3,7 +3,15 @@
"dwellSeconds": 120, "dwellSeconds": 120,
"crossfadeSeconds": 3, "crossfadeSeconds": 3,
"order": "shuffle", "order": "shuffle",
"include": { "colors": [], "rarities": [], "ids": [] }, "include": {
"behaviors": { "staticChance": 0.5, "wanderRadius": 0.6, "wanderSpeed": 0.15 }, "colors": [],
"rarities": [],
"ids": []
},
"behaviors": {
"staticChance": 0.5,
"wanderRadius": 60,
"wanderSpeed": 0.15
},
"timeWindows": [] "timeWindows": []
} }
+133 -12
View File
@@ -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": [ "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": 0,
{ "markerId": 2, "position": [0.20, 0.00, 0.90], "mount": "flat", "yawDeg": 0, "pitchDeg": 0, "rollDeg": 0, "sizeMM": 60, "enabled": true }, "position": [
{ "markerId": 3, "position": [0.90, 0.00, 0.90], "mount": "flat", "yawDeg": 0, "pitchDeg": 0, "rollDeg": 0, "sizeMM": 60, "enabled": true }, 20.0,
{ "markerId": 4, "position": [0.55, 0.12, 0.00], "mount": "wall", "yawDeg": 180, "pitchDeg": 0, "rollDeg": 0, "sizeMM": 60, "enabled": true } 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": [ "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": [ "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-1",
{ "id": "spawn-3", "position": [0.55, 0.45, 0.70], "enabled": true }, "position": [
{ "id": "spawn-4", "position": [0.25, 0.25, 0.75], "enabled": true }, 35.0,
{ "id": "spawn-5", "position": [0.85, 0.28, 0.75], "enabled": true } 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
}
] ]
} }
+1 -1
View File
@@ -84,7 +84,7 @@ function loop() {
} }
const fused = fuser.fuse(estimates); 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.'; out.textContent = lines.join('\n') || 'No crests in view — show a printed Newbury Crest to the camera.';
} }
loop(); loop();
+21 -21
View File
@@ -41,7 +41,7 @@ scene.anchors ||= []; scene.buildings ||= []; scene.spawns ||= [];
const cv = document.getElementById('cv'); const cv = document.getElementById('cv');
const ctx = cv.getContext('2d'); 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 sel = null; // { kind, index }
let drag = null; let drag = null;
@@ -59,9 +59,9 @@ const s2w = (px, py) => [(px * devicePixelRatio - cv.width / 2) / (view.scale *
function draw() { function draw() {
ctx.clearRect(0, 0, cv.width, cv.height); ctx.clearRect(0, 0, cv.width, cv.height);
// grid (0.25 m) // grid (25 cm)
ctx.strokeStyle = '#1a2244'; ctx.lineWidth = 1; 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 ox = (cv.width / 2 - view.x * view.scale * devicePixelRatio) % step;
const oz = (cv.height / 2 - view.z * 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(); } 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()) { for (const [i, a] of scene.anchors.entries()) {
const [sx, sy] = w2s(a.position[0], a.position[2]); const [sx, sy] = w2s(a.position[0], a.position[2]);
ctx.save(); ctx.translate(sx, sy); ctx.rotate(-(a.yawDeg || 0) * Math.PI / 180); 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')); 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 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(); } 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) { function hit(px, py) {
const [wx, wz] = s2w(px, py); const [wx, wz] = s2w(px, py);
const near = (x, z, r) => Math.hypot(wx - x, wz - z) < r; 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, 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], 0.12)) return { kind: 'spawn', 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()) 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 { kind: 'building', index: i };
return null; return null;
} }
@@ -129,8 +129,8 @@ cv.addEventListener('pointermove', (e) => {
if (drag.kind === 'move' && sel) { if (drag.kind === 'move' && sel) {
const [wx, wz] = s2w(e.offsetX, e.offsetY); const [wx, wz] = s2w(e.offsetX, e.offsetY);
const o = objOf(sel); const o = objOf(sel);
o.position[0] = Math.round(wx * 200) / 200; // 5 mm snap o.position[0] = Math.round(wx * 2) / 2; // 5 mm snap
o.position[2] = Math.round(wz * 200) / 200; o.position[2] = Math.round(wz * 2) / 2;
panel(false); draw(); panel(false); draw();
} else if (drag.kind === 'pan') { } else if (drag.kind === 'pan') {
view.x = drag.vx - (e.offsetX - drag.sx) / view.scale; 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('pointerup', () => drag = null);
cv.addEventListener('wheel', (e) => { cv.addEventListener('wheel', (e) => {
e.preventDefault(); 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(); draw();
}, { passive: false }); }, { 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 }); 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 }; sel = { kind: 'anchor', index: scene.anchors.length - 1 };
} else if (b.dataset.add === 'building') { } 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 }; sel = { kind: 'building', index: scene.buildings.length - 1 };
} else { } 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 }; sel = { kind: 'spawn', index: scene.spawns.length - 1 };
} }
panel(); draw(); panel(); draw();
@@ -173,7 +173,7 @@ function field(label, val, oncommit, opts) {
}); });
if (opts?.select) if (opts?.select)
return `<div class="row"><label>${label}</label><select id="${id}">${opts.select.map(o => `<option ${o === val ? 'selected' : ''}>${o}</option>`).join('')}</select></div>`; return `<div class="row"><label>${label}</label><select id="${id}">${opts.select.map(o => `<option ${o === val ? 'selected' : ''}>${o}</option>`).join('')}</select></div>`;
return `<div class="row"><label>${label}</label><input id="${id}" type="number" step="${opts?.step ?? 0.01}" value="${val}"></div>`; return `<div class="row"><label>${label}</label><input id="${id}" type="number" step="${opts?.step ?? 0.5}" value="${val}"></div>`;
} }
function textField(label, val, oncommit) { function textField(label, val, oncommit) {
const id = 'f' + Math.random().toString(36).slice(2, 8); const id = 'f' + Math.random().toString(36).slice(2, 8);
@@ -187,9 +187,9 @@ function panel(rebuild = true) {
let h = `<h2>${sel.kind} ${sel.index}</h2>`; let h = `<h2>${sel.kind} ${sel.index}</h2>`;
if (sel.kind === 'anchor') { if (sel.kind === 'anchor') {
h += field('marker ID', o.markerId, v => o.markerId = v | 0, { step: 1 }); h += field('marker ID', o.markerId, v => o.markerId = v | 0, { step: 1 });
h += field('x (m)', o.position[0], v => o.position[0] = v); h += field('x (cm)', o.position[0], v => o.position[0] = v);
h += field('y (m)', o.position[1], v => o.position[1] = v); h += field('y (cm)', o.position[1], v => o.position[1] = v);
h += field('z (m)', o.position[2], v => o.position[2] = v); h += field('z (cm)', o.position[2], v => o.position[2] = v);
h += field('mount', o.mount, v => o.mount = v, { select: ['flat', 'wall', 'custom'] }); h += field('mount', o.mount, v => o.mount = v, { select: ['flat', 'wall', 'custom'] });
h += field('yaw °', o.yawDeg, v => o.yawDeg = v, { step: 1 }); h += field('yaw °', o.yawDeg, v => o.yawDeg = v, { step: 1 });
h += field('pitch °', o.pitchDeg, v => o.pitchDeg = v, { step: 1 }); h += field('pitch °', o.pitchDeg, v => o.pitchDeg = v, { step: 1 });
@@ -199,17 +199,17 @@ function panel(rebuild = true) {
h += `<p class="badge">flat = face-up on a surface · wall = vertical, yaw sets facing · custom = full yaw/pitch/roll</p>`; h += `<p class="badge">flat = face-up on a surface · wall = vertical, yaw sets facing · custom = full yaw/pitch/roll</p>`;
} else if (sel.kind === 'building') { } else if (sel.kind === 'building') {
h += textField('name', o.name || '', v => o.name = v); h += textField('name', o.name || '', v => o.name = v);
h += field('x (m)', o.position[0], v => o.position[0] = v); h += field('x (cm)', o.position[0], v => o.position[0] = v);
h += field('z (m)', o.position[2], v => o.position[2] = 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('width', o.size[0], v => o.size[0] = v);
h += field('height', o.size[1], v => o.size[1] = 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('depth', o.size[2], v => o.size[2] = v);
h += field('yaw °', o.yawDeg || 0, v => o.yawDeg = v, { step: 1 }); h += field('yaw °', o.yawDeg || 0, v => o.yawDeg = v, { step: 1 });
} else { } else {
h += textField('id', o.id, v => o.id = v); h += textField('id', o.id, v => o.id = v);
h += field('x (m)', o.position[0], v => o.position[0] = v); h += field('x (cm)', o.position[0], v => o.position[0] = v);
h += field('y (m)', o.position[1], v => o.position[1] = v); h += field('y (cm)', o.position[1], v => o.position[1] = v);
h += field('z (m)', o.position[2], v => o.position[2] = 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 += field('enabled', o.enabled === false ? 'no' : 'yes', v => o.enabled = v === 'yes', { select: ['yes', 'no'] });
} }
h += `<div class="row" style="margin-top:12px"><button id="del" class="danger">Delete</button></div>`; h += `<div class="row" style="margin-top:12px"><button id="del" class="danger">Delete</button></div>`;
+1 -1
View File
@@ -53,7 +53,7 @@ function render() {
<div class="card"> <div class="card">
<h2 style="margin-top:0">Behavior</h2> <h2 style="margin-top:0">Behavior</h2>
<div class="row"><label>Static chance (01)</label><input id="staticChance" type="number" step="0.05" min="0" max="1" value="${cfg.behaviors.staticChance}"></div> <div class="row"><label>Static chance (01)</label><input id="staticChance" type="number" step="0.05" min="0" max="1" value="${cfg.behaviors.staticChance}"></div>
<div class="row"><label>Wander radius (m)</label><input id="wanderRadius" type="number" step="0.05" value="${cfg.behaviors.wanderRadius}"></div> <div class="row"><label>Wander radius (cm)</label><input id="wanderRadius" type="number" step="1" value="${cfg.behaviors.wanderRadius}"></div>
<div class="row"><label>Wander speed</label><input id="wanderSpeed" type="number" step="0.05" value="${cfg.behaviors.wanderSpeed}"></div> <div class="row"><label>Wander speed</label><input id="wanderSpeed" type="number" step="0.05" value="${cfg.behaviors.wanderSpeed}"></div>
</div> </div>
<div class="card"> <div class="card">
+1 -1
View File
@@ -48,7 +48,7 @@ for (const a of scene.anchors) {
div.insertAdjacentHTML('beforeend', div.insertAdjacentHTML('beforeend',
`<div class="cap"><b>Crest #${a.markerId}</b> · ${a.sizeMM} mm · ${a.mount}` + `<div class="cap"><b>Crest #${a.markerId}</b> · ${a.sizeMM} mm · ${a.mount}` +
`${a.mount === 'wall' ? ` · yaw ${a.yawDeg}°` : ''}<br>` + `${a.mount === 'wall' ? ` · yaw ${a.yawDeg}°` : ''}<br>` +
`pos ${a.position.map(v => v.toFixed(2)).join(', ')} m</div>`); `pos ${a.position.map(v => v.toFixed(1)).join(', ')} cm</div>`);
sheet.appendChild(div); sheet.appendChild(div);
} }
if (!scene.anchors.length) sheet.innerHTML = '<p style="padding:20px">No anchors in the scene yet — add some in the Layout editor.</p>'; if (!scene.anchors.length) sheet.innerHTML = '<p style="padding:20px">No anchors in the scene yet — add some in the Layout editor.</p>';
+3 -3
View File
@@ -41,13 +41,13 @@ export class WorldFuser {
/** estimates: [{ markerId, position(mm), quaternion, area }] */ /** estimates: [{ markerId, position(mm), quaternion, area }] */
fuse(estimates) { fuse(estimates) {
const MM = 0.001; const MM2CM = 0.1; // POS-IT translation is in mm; world units are cm
const cams = []; const cams = [];
for (const e of estimates) { for (const e of estimates) {
const entry = this.anchorMats.get(e.markerId); const entry = this.anchorMats.get(e.markerId);
if (!entry) continue; if (!entry) continue;
// marker pose in camera space -> matrix (translate mm->m) // marker pose in camera space -> matrix (translate mm->cm)
_m.compose(_p.copy(e.position).multiplyScalar(MM), e.quaternion, _s.set(1, 1, 1)); _m.compose(_p.copy(e.position).multiplyScalar(MM2CM), e.quaternion, _s.set(1, 1, 1));
_inv.copy(_m).invert(); // camera in marker space _inv.copy(_m).invert(); // camera in marker space
const camWorld = new THREE.Matrix4().multiplyMatrices(entry.mat, _inv); const camWorld = new THREE.Matrix4().multiplyMatrices(entry.mat, _inv);
const pos = new THREE.Vector3(); const pos = new THREE.Vector3();
+8 -1
View File
@@ -10,6 +10,13 @@ import * as THREE from 'three';
const _m = new THREE.Matrix4(); const _m = new THREE.Matrix4();
const _q = new THREE.Quaternion(); 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 { export class PoseEstimator {
constructor(focalLength) { constructor(focalLength) {
this.focal = focalLength; this.focal = focalLength;
@@ -26,7 +33,7 @@ export class PoseEstimator {
/** corners: aruco marker corners, image-space; cx/cy: image center. /** corners: aruco marker corners, image-space; cx/cy: image center.
* Returns { position: THREE.Vector3 (mm, marker->camera), quaternion, error } */ * Returns { position: THREE.Vector3 (mm, marker->camera), quaternion, error } */
estimate(markerId, corners, cx, cy, sizeMM) { 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); const pose = this.positFor(sizeMM).pose(centered);
if (!pose) return null; if (!pose) return null;
+1 -1
View File
@@ -53,7 +53,7 @@ async function start() {
renderer = new THREE.WebGLRenderer({ canvas: $('#gl'), alpha: true, antialias: true }); renderer = new THREE.WebGLRenderer({ canvas: $('#gl'), alpha: true, antialias: true });
renderer.setPixelRatio(Math.min(devicePixelRatio, 2)); renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
scene3 = new THREE.Scene(); 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)); scene3.add(new THREE.AmbientLight(0xffffff, 1.2));
onResize(); addEventListener('resize', onResize); onResize(); addEventListener('resize', onResize);
+3 -3
View File
@@ -17,14 +17,14 @@ export function ghostTransform(rec, nowMs, out) {
if (b.type === 'wander') { if (b.type === 'wander') {
const s = b.seed || 1; const s = b.seed || 1;
const R = b.radius ?? 0.6; const R = b.radius ?? 60; // cm
const v = b.speed ?? 0.15; const v = b.speed ?? 0.15;
// smooth pseudo-random orbit-drift: two incommensurate sines per axis // smooth pseudo-random orbit-drift: two incommensurate sines per axis
const ph = t * v; const ph = t * v;
out.position.set( out.position.set(
base.x + R * 0.9 * Math.sin(ph * 1.0 + hashNoise(s, 1) * 6.28) * 0.7 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, + 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 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 + 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)); const dir = ahead(t + eps).sub(ahead(t));
out.rotationY = Math.atan2(dir.x, dir.z); out.rotationY = Math.atan2(dir.x, dir.z);
} else { } else {
const amp = b.bobAmp ?? 0.06; const amp = b.bobAmp ?? 6; // cm
const hz = b.bobHz ?? 0.4; const hz = b.bobHz ?? 0.4;
out.position.set(base.x, base.y + amp * Math.sin(t * hz * Math.PI * 2), base.z); 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 out.rotationY = 0.25 * Math.sin(t * 0.3); // slow idle sway
+4 -4
View File
@@ -59,12 +59,12 @@ async function loadOBJ(url) {
function proceduralWisp() { function proceduralWisp() {
const g = new THREE.Group(); 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.scale.set(1, 1.35, 1);
body.position.y = 0.14; body.position.y = 14;
const tail = new THREE.Mesh(new THREE.ConeGeometry(0.07, 0.16, 16)); const tail = new THREE.Mesh(new THREE.ConeGeometry(7, 16, 16));
tail.rotation.x = Math.PI; tail.rotation.x = Math.PI;
tail.position.y = 0.0; tail.position.y = 0;
g.add(body, tail); g.add(body, tail);
return g; return g;
} }
+4 -4
View File
@@ -7,7 +7,7 @@
* crossfadeSeconds: 3, // fade out/in overlap hint for clients * crossfadeSeconds: 3, // fade out/in overlap hint for clients
* order: 'shuffle' | 'roster', // rotation order through the roster * order: 'shuffle' | 'roster', // rotation order through the roster
* include: { colors:[], rarities:[], ids:[] }, // empty = all * 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 * timeWindows: [] // e.g. [{ start:"09:00", end:"17:00" }] — empty = always on
* } * }
* *
@@ -23,7 +23,7 @@ class Playlist {
this.cfg = state.getPlaylistConfig({ this.cfg = state.getPlaylistConfig({
slots: 5, dwellSeconds: 120, crossfadeSeconds: 3, order: 'shuffle', slots: 5, dwellSeconds: 120, crossfadeSeconds: 3, order: 'shuffle',
include: { colors: [], rarities: [], ids: [] }, include: { colors: [], rarities: [], ids: [] },
behaviors: { staticChance: 0.5, wanderRadius: 0.6, wanderSpeed: 0.15 }, behaviors: { staticChance: 0.5, wanderRadius: 60, wanderSpeed: 0.15 },
timeWindows: [], timeWindows: [],
}); });
this.active = new Map(); // uid -> ghost record 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, uid, id: g.id, name: g.name, color: g.color, rarity: g.rarity,
spawnId: spawn.id, pos: spawn.position, spawnId: spawn.id, pos: spawn.position,
behavior: isStatic behavior: isStatic
? { type: 'static', bobAmp: 0.05 + Math.random() * 0.05, bobHz: 0.3 + Math.random() * 0.3 } ? { type: 'static', bobAmp: 5 + Math.random() * 5, 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: 'wander', radius: b.wanderRadius ?? 60, speed: b.wanderSpeed ?? 0.15, seed: Math.floor(Math.random() * 1e6) },
spawnedAt: Date.now(), spawnedAt: Date.now(),
until: Date.now() + (this.cfg.dwellSeconds || 120) * 1000, until: Date.now() + (this.cfg.dwellSeconds || 120) * 1000,
crossfade: this.cfg.crossfadeSeconds || 3, crossfade: this.cfg.crossfadeSeconds || 3,
+1 -1
View File
@@ -22,7 +22,7 @@ function writeJSON(p, obj) {
fs.writeFileSync(p, JSON.stringify(obj, null, 2)); 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 ghosts = readJSON(path.join(DATA, 'ghosts.json'), []);
let gradients = readJSON(path.join(DATA, 'gradients.json'), {}); let gradients = readJSON(path.join(DATA, 'gradients.json'), {});
let models = readJSON(MODELS_LIVE, null) || readJSON(MODELS_SEED, { models: [] }); let models = readJSON(MODELS_LIVE, null) || readJSON(MODELS_SEED, { models: [] });