update Tue 07/14/2026 12:21:23.58
This commit is contained in:
@@ -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
@@ -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
@@ -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
|
||||||
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -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
@@ -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>`;
|
||||||
|
|||||||
@@ -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 (0–1)</label><input id="staticChance" type="number" step="0.05" min="0" max="1" value="${cfg.behaviors.staticChance}"></div>
|
<div class="row"><label>Static chance (0–1)</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">
|
||||||
|
|||||||
@@ -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>';
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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: [] });
|
||||||
|
|||||||
Reference in New Issue
Block a user