Initial Comming

This commit is contained in:
2026-06-26 13:58:39 +10:00
parent 8b064bbd39
commit 0d1d66a85d
28 changed files with 4328 additions and 2 deletions
+214
View File
@@ -0,0 +1,214 @@
/* Newbury Exhibit — AR marker proof of concept
*
* Goal of this module: prove the full chain works on a real phone —
* camera frame -> ArUco 4x4 detection -> a ghost sprite locked onto the marker.
* No server/sync yet; this is the localisation primitive everything else builds on.
*
* Detector: js-aruco2 (global `AR`), dictionary ARUCO_4X4_1000.
*/
(function () {
'use strict';
const video = document.getElementById('video');
const overlay = document.getElementById('overlay');
const ctx = overlay.getContext('2d');
const startScreen = document.getElementById('start');
const goBtn = document.getElementById('go');
const errEl = document.getElementById('err');
const hud = document.getElementById('hud');
const seenTag = document.getElementById('seen');
const midEl = document.getElementById('mid');
const fpsEl = document.getElementById('fps');
// Offscreen canvas we actually read pixels from (matches video native size).
const grab = document.createElement('canvas');
const gctx = grab.getContext('2d', { willReadFrequently: true });
let detector = null;
let running = false;
let lastSeen = 0; // timestamp of last successful detection
const HOLD_MS = 250; // keep ghost visible briefly through dropped frames
// Ghost sprite (animated WebP if present, else procedural wisp fallback).
const ghostImg = new Image();
let ghostReady = false;
ghostImg.onload = () => { ghostReady = true; };
ghostImg.onerror = () => { ghostReady = false; }; // fall back to procedural
// Optional: drop a sprite at this path to use it. Safe if 404 -> procedural wisp.
ghostImg.src = 'assets/ghost_blue.webp';
function sizeToVideo() {
const vw = video.videoWidth, vh = video.videoHeight;
if (!vw || !vh) return;
grab.width = vw; grab.height = vh;
// Cover-fit the viewport while keeping native pixels for detection.
const scale = Math.max(window.innerWidth / vw, window.innerHeight / vh);
const dw = vw * scale, dh = vh * scale;
for (const el of [video, overlay]) {
el.style.width = dw + 'px';
el.style.height = dh + 'px';
}
overlay.width = dw; overlay.height = dh;
overlay._scale = scale; // px-per-native-pixel for drawing in display space
}
async function start() {
errEl.textContent = '';
try {
const stream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: { ideal: 'environment' }, width: { ideal: 1280 }, height: { ideal: 720 } },
audio: false,
});
video.srcObject = stream;
await video.play();
} catch (e) {
errEl.textContent = 'Camera unavailable: ' + (e.message || e.name) +
'. On iPhone this page must be served over HTTPS and you must allow camera access.';
return;
}
detector = new AR.Detector({ dictionaryName: 'ARUCO_4X4_1000' });
await new Promise((res) => {
if (video.readyState >= 2) return res();
video.onloadeddata = () => res();
});
sizeToVideo();
window.addEventListener('resize', sizeToVideo);
startScreen.classList.add('hidden');
hud.classList.remove('hidden');
running = true;
requestAnimationFrame(loop);
}
// ---- detection + render loop ----
let frames = 0, fpsStamp = performance.now();
function loop(now) {
if (!running) return;
requestAnimationFrame(loop);
if (video.readyState < 2 || !grab.width) return;
gctx.drawImage(video, 0, 0, grab.width, grab.height);
const img = gctx.getImageData(0, 0, grab.width, grab.height);
let markers = [];
try { markers = detector.detect(img); } catch (_) { markers = []; }
const s = overlay._scale || 1;
ctx.clearRect(0, 0, overlay.width, overlay.height);
if (markers.length) {
lastSeen = now;
const m = markers[0];
drawMarkerOutline(m, s);
drawGhostOnMarker(m, s, now);
seenTag.textContent = 'locked';
seenTag.className = 'tag on';
midEl.textContent = m.id;
} else if (now - lastSeen < HOLD_MS) {
// brief hold-over: keep last ghost roughly; (cheap version: skip)
seenTag.textContent = 'holding…';
seenTag.className = 'tag on';
} else {
seenTag.textContent = 'searching…';
seenTag.className = 'tag off';
midEl.textContent = '—';
}
frames++;
if (now - fpsStamp > 500) {
fpsEl.textContent = Math.round((frames * 1000) / (now - fpsStamp));
frames = 0; fpsStamp = now;
}
}
function corners(m, s) {
// js-aruco corners are in native pixel space (origin top-left).
return m.corners.map((c) => ({ x: c.x * s, y: c.y * s }));
}
function drawMarkerOutline(m, s) {
const c = corners(m, s);
ctx.lineWidth = 3;
ctx.strokeStyle = 'rgba(82,158,255,0.9)';
ctx.beginPath();
ctx.moveTo(c[0].x, c[0].y);
for (let i = 1; i < c.length; i++) ctx.lineTo(c[i].x, c[i].y);
ctx.closePath();
ctx.stroke();
// corner dot to show orientation lock (first corner)
ctx.fillStyle = 'var(--yellow)';
ctx.fillStyle = '#fff35d';
ctx.beginPath(); ctx.arc(c[0].x, c[0].y, 5, 0, Math.PI * 2); ctx.fill();
}
function centroid(c) {
let x = 0, y = 0;
for (const p of c) { x += p.x; y += p.y; }
return { x: x / c.length, y: y / c.length };
}
function markerSpan(c) {
// average side length in display px -> sprite scale reference
let sum = 0;
for (let i = 0; i < c.length; i++) {
const a = c[i], b = c[(i + 1) % c.length];
sum += Math.hypot(b.x - a.x, b.y - a.y);
}
return sum / c.length;
}
function drawGhostOnMarker(m, s, now) {
const c = corners(m, s);
const mid = centroid(c);
const span = markerSpan(c);
// Ghost hovers above the marker, gently bobbing — a tiny taste of the
// motion solver the sync server will eventually drive.
const t = now / 1000;
const bob = Math.sin(t * 2.2) * span * 0.10;
const sway = Math.cos(t * 1.3) * span * 0.06;
const cx = mid.x + sway;
const cy = mid.y - span * 0.95 + bob; // float above the plaque
const size = span * 1.4;
if (ghostReady) {
ctx.save();
ctx.globalAlpha = 0.92;
ctx.drawImage(ghostImg, cx - size / 2, cy - size / 2, size, size);
ctx.restore();
} else {
drawWisp(cx, cy, size * 0.5, t);
}
// tether line so it reads as "bound to this marker"
ctx.strokeStyle = 'rgba(81,234,241,0.35)';
ctx.lineWidth = 2;
ctx.setLineDash([4, 6]);
ctx.beginPath(); ctx.moveTo(mid.x, mid.y); ctx.lineTo(cx, cy + size * 0.25); ctx.stroke();
ctx.setLineDash([]);
}
// Procedural fallback wisp in Hidden Side "Sad/Blue" gradient.
function drawWisp(cx, cy, r, t) {
const grad = ctx.createRadialGradient(cx, cy - r * 0.3, r * 0.2, cx, cy, r);
grad.addColorStop(0, 'rgba(81,234,241,0.95)');
grad.addColorStop(0.5, 'rgba(82,158,255,0.65)');
grad.addColorStop(1, 'rgba(82,158,255,0)');
ctx.fillStyle = grad;
ctx.beginPath();
// teardrop-ish body
const wob = Math.sin(t * 3) * r * 0.08;
ctx.ellipse(cx, cy, r * 0.8 + wob, r, 0, 0, Math.PI * 2);
ctx.fill();
// eyes
ctx.fillStyle = 'rgba(8,12,30,0.85)';
ctx.beginPath(); ctx.ellipse(cx - r * 0.28, cy - r * 0.1, r * 0.10, r * 0.16, 0, 0, Math.PI * 2); ctx.fill();
ctx.beginPath(); ctx.ellipse(cx + r * 0.28, cy - r * 0.1, r * 0.10, r * 0.16, 0, 0, Math.PI * 2); ctx.fill();
}
goBtn.addEventListener('click', start);
})();
+165
View File
@@ -0,0 +1,165 @@
import * as THREE from 'https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js';
import { OrbitControls } from 'https://cdn.jsdelivr.net/npm/three@0.160.0/examples/jsm/controls/OrbitControls.js';
// ---- Config --------------------------------------------------------------
const MM = 0.001; // millimetre -> Three.js metre scale
const statusEl = document.getElementById('status');
const countEl = document.getElementById('count');
// ---- Scene setup ---------------------------------------------------------
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x0a0a14);
const camera = new THREE.PerspectiveCamera(
55,
window.innerWidth / window.innerHeight,
0.01,
100
);
camera.position.set(0.75, 0.9, 1.6);
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.target.set(0.75, 0.2, 0.35); // centre of a 1500x700 build in metres
scene.add(new THREE.AmbientLight(0x8090b0, 0.8));
const key = new THREE.DirectionalLight(0xffffff, 1.0);
key.position.set(2, 3, 1);
scene.add(key);
// ---- Ghost tint gradients (from recovered GhostType data) ----------------
const GHOST_GRADIENTS = {
Red: { top: 0xf65151, bottom: 0xff2678 },
Yellow: { top: 0xb57f0b, bottom: 0xfff35d },
Blue: { top: 0x529eff, bottom: 0x51eaf1 },
};
// We don't have per-ghost colour on the client snapshot yet, so default to Blue;
// colour wiring comes when we feed the enriched roster in. Kept simple for M1.
function ghostMaterial(colorKey = 'Blue') {
const g = GHOST_GRADIENTS[colorKey] || GHOST_GRADIENTS.Blue;
return new THREE.MeshStandardMaterial({
color: g.bottom,
emissive: g.top,
emissiveIntensity: 0.6,
transparent: true,
opacity: 0.85,
roughness: 0.4,
});
}
// ---- Build footprint + anchor markers (drawn once from /api/scene) --------
let worldDims = { x: 1500, y: 600, z: 700 };
function drawBuildFootprint() {
const w = worldDims.x * MM;
const d = worldDims.z * MM;
const geo = new THREE.PlaneGeometry(w, d);
const mat = new THREE.MeshStandardMaterial({
color: 0x141422,
roughness: 0.9,
side: THREE.DoubleSide,
});
const plane = new THREE.Mesh(geo, mat);
plane.rotation.x = -Math.PI / 2;
// origin is a corner, so shift plane so corner sits at (0,0,0)
plane.position.set(w / 2, 0, d / 2);
scene.add(plane);
const grid = new THREE.GridHelper(Math.max(w, d), 15, 0x334466, 0x223355);
grid.position.set(w / 2, 0.001, d / 2);
scene.add(grid);
}
function drawAnchors(anchors) {
const mat = new THREE.MeshStandardMaterial({
color: 0x00e5c0,
emissive: 0x00e5c0,
emissiveIntensity: 0.5,
});
anchors.forEach((a) => {
const s = (a.fiducialSizeMm || 40) * MM;
const geo = new THREE.BoxGeometry(s, s * 0.15, s);
const m = new THREE.Mesh(geo, mat);
m.position.set(a.position.x * MM, a.position.y * MM, a.position.z * MM);
scene.add(m);
});
}
// ---- Ghost pool ----------------------------------------------------------
const ghostMeshes = new Map(); // spawnId -> THREE.Mesh
function ensureGhost(spawnId) {
if (ghostMeshes.has(spawnId)) return ghostMeshes.get(spawnId);
const geo = new THREE.IcosahedronGeometry(0.05, 1); // placeholder until OBJ wired
const mesh = new THREE.Mesh(geo, ghostMaterial('Blue'));
scene.add(mesh);
ghostMeshes.set(spawnId, mesh);
return mesh;
}
function applyState(ghosts) {
const seen = new Set();
for (const g of ghosts) {
seen.add(g.spawnId);
const mesh = ensureGhost(g.spawnId);
mesh.position.set(g.pos.x * MM, g.pos.y * MM, g.pos.z * MM);
mesh.rotation.y = (g.yawDeg * Math.PI) / 180;
}
// remove any ghosts no longer present
for (const [id, mesh] of ghostMeshes) {
if (!seen.has(id)) {
scene.remove(mesh);
ghostMeshes.delete(id);
}
}
countEl.textContent = String(ghosts.length);
}
// ---- WebSocket sync ------------------------------------------------------
function connect() {
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
const ws = new WebSocket(`${proto}://${location.host}/sync`);
ws.onopen = () => {
statusEl.textContent = 'connected';
statusEl.className = 'ok';
};
ws.onclose = () => {
statusEl.textContent = 'reconnecting…';
statusEl.className = 'warn';
setTimeout(connect, 1500);
};
ws.onerror = () => ws.close();
ws.onmessage = (ev) => {
const msg = JSON.parse(ev.data);
if (msg.type === 'hello') {
if (msg.world?.buildSize) worldDims = msg.world.buildSize;
drawBuildFootprint();
if (msg.anchors) drawAnchors(msg.anchors);
} else if (msg.type === 'state') {
applyState(msg.ghosts);
}
};
}
connect();
// ---- Render loop ---------------------------------------------------------
function animate() {
requestAnimationFrame(animate);
controls.update();
renderer.render(scene, camera);
}
animate();
window.addEventListener('resize', () => {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
});