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
+1 -1
View File
@@ -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();
+21 -21
View File
@@ -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 `<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) {
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>`;
if (sel.kind === 'anchor') {
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('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('mount', o.mount, v => o.mount = v, { select: ['flat', 'wall', 'custom'] });
h += field('yaw °', o.yawDeg, v => o.yawDeg = 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>`;
} 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 += `<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">
<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>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>
<div class="card">
+1 -1
View File
@@ -48,7 +48,7 @@ for (const a of scene.anchors) {
div.insertAdjacentHTML('beforeend',
`<div class="cap"><b>Crest #${a.markerId}</b> · ${a.sizeMM} mm · ${a.mount}` +
`${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);
}
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 }] */
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();
+8 -1
View File
@@ -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;
+1 -1
View File
@@ -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);
+3 -3
View File
@@ -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
+4 -4
View File
@@ -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;
}