update Tue 07/14/2026 11:38:16.92
This commit is contained in:
@@ -0,0 +1,22 @@
|
||||
/* detect.js — tuned ArUco 4x4 detection.
|
||||
* Phantom-ID fix: reject any marker decoded with hamming distance > 0 (maxHamming: 0).
|
||||
*/
|
||||
export function createDetector() {
|
||||
return new AR.Detector({ dictionaryName: 'ARUCO_4X4_1000', maxHammingDistance: 0 });
|
||||
}
|
||||
|
||||
export function detectMarkers(detector, imageData, knownIds) {
|
||||
const markers = detector.detect(imageData);
|
||||
// keep only markers that exist in the scene, with sane geometry
|
||||
return markers.filter(m => knownIds.has(m.id) && quadArea(m.corners) > 100);
|
||||
}
|
||||
|
||||
export function quadArea(c) {
|
||||
// shoelace
|
||||
let a = 0;
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const p = c[i], q = c[(i + 1) % 4];
|
||||
a += p.x * q.y - q.x * p.y;
|
||||
}
|
||||
return Math.abs(a) / 2;
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
/* fuse.js — fuseWorld: combine per-marker camera pose estimates into one world pose.
|
||||
*
|
||||
* Each detected marker yields a camera-in-world estimate:
|
||||
* cameraWorld = anchorWorld * inverse(markerPoseInCamera)
|
||||
* Estimates are fused by confidence-weighted quaternion slerp (incremental
|
||||
* weighted average) and weighted position mean. Confidence = marker screen area
|
||||
* (bigger/closer markers dominate). A light temporal smooth removes residual jitter.
|
||||
*
|
||||
* Requiring >= 2 visible markers is handled upstream by anchor placement density;
|
||||
* fuseWorld itself works with 1..N.
|
||||
*/
|
||||
import * as THREE from 'three';
|
||||
import { anchorWorldMatrix } from './pose.js';
|
||||
|
||||
const _inv = new THREE.Matrix4();
|
||||
const _m = new THREE.Matrix4();
|
||||
const _p = new THREE.Vector3();
|
||||
const _q = new THREE.Quaternion();
|
||||
const _s = new THREE.Vector3();
|
||||
|
||||
export class WorldFuser {
|
||||
constructor() {
|
||||
this.anchorMats = new Map(); // markerId -> Matrix4
|
||||
this.smoothPos = null;
|
||||
this.smoothQuat = null;
|
||||
this.posAlpha = 0.35; // smoothing factors (higher = snappier)
|
||||
this.quatAlpha = 0.35;
|
||||
this.lastFuseT = 0;
|
||||
}
|
||||
|
||||
setScene(scene) {
|
||||
this.anchorMats.clear();
|
||||
for (const a of scene.anchors || []) {
|
||||
if (a.enabled === false) continue;
|
||||
this.anchorMats.set(a.markerId, { mat: anchorWorldMatrix(a), sizeMM: a.sizeMM || 60 });
|
||||
}
|
||||
}
|
||||
|
||||
sizeFor(markerId) { return this.anchorMats.get(markerId)?.sizeMM || 60; }
|
||||
knownIds() { return new Set(this.anchorMats.keys()); }
|
||||
|
||||
/** estimates: [{ markerId, position(mm), quaternion, area }] */
|
||||
fuse(estimates) {
|
||||
const MM = 0.001;
|
||||
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));
|
||||
_inv.copy(_m).invert(); // camera in marker space
|
||||
const camWorld = new THREE.Matrix4().multiplyMatrices(entry.mat, _inv);
|
||||
const pos = new THREE.Vector3();
|
||||
const quat = new THREE.Quaternion();
|
||||
camWorld.decompose(pos, quat, _s);
|
||||
cams.push({ pos, quat, w: Math.max(1, e.area) });
|
||||
}
|
||||
if (!cams.length) return null;
|
||||
|
||||
// weighted position mean + incremental weighted slerp for orientation
|
||||
let wSum = cams[0].w;
|
||||
const pos = cams[0].pos.clone().multiplyScalar(cams[0].w);
|
||||
const quat = cams[0].quat.clone();
|
||||
for (let i = 1; i < cams.length; i++) {
|
||||
const c = cams[i];
|
||||
// hemisphere alignment before slerp (quaternion double-cover)
|
||||
if (quat.dot(c.quat) < 0) c.quat.set(-c.quat.x, -c.quat.y, -c.quat.z, -c.quat.w);
|
||||
const t = c.w / (wSum + c.w);
|
||||
quat.slerp(c.quat, t);
|
||||
pos.add(c.pos.clone().multiplyScalar(c.w));
|
||||
wSum += c.w;
|
||||
}
|
||||
pos.multiplyScalar(1 / wSum);
|
||||
|
||||
// temporal smoothing
|
||||
const now = performance.now();
|
||||
if (this.smoothPos && now - this.lastFuseT < 500) {
|
||||
this.smoothPos.lerp(pos, this.posAlpha);
|
||||
if (this.smoothQuat.dot(quat) < 0) quat.set(-quat.x, -quat.y, -quat.z, -quat.w);
|
||||
this.smoothQuat.slerp(quat, this.quatAlpha);
|
||||
} else {
|
||||
this.smoothPos = pos.clone();
|
||||
this.smoothQuat = quat.clone();
|
||||
}
|
||||
this.lastFuseT = now;
|
||||
return { position: this.smoothPos.clone(), quaternion: this.smoothQuat.clone(), markerCount: cams.length };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
/* pose.js — marker pose estimation with the confirmed fixes:
|
||||
* 1. POS-IT rotation used AS-IS; translation Y and Z negated (-t[1], -t[2]).
|
||||
* 2. POS-IT planar ambiguity resolved by temporal consistency:
|
||||
* pick the solution (bestError vs alternativeError) closest to the previous frame.
|
||||
*
|
||||
* Requires vendor chain loaded in order: cv -> svd -> posit1 -> aruco -> dictionary.
|
||||
*/
|
||||
import * as THREE from 'three';
|
||||
|
||||
const _m = new THREE.Matrix4();
|
||||
const _q = new THREE.Quaternion();
|
||||
|
||||
export class PoseEstimator {
|
||||
constructor(focalLength) {
|
||||
this.focal = focalLength;
|
||||
this.posits = new Map(); // sizeMM -> POS.Posit
|
||||
this.prev = new Map(); // markerId -> { quat, pos, t }
|
||||
this.prevTTL = 1500; // ms before history is considered stale
|
||||
}
|
||||
|
||||
positFor(sizeMM) {
|
||||
if (!this.posits.has(sizeMM)) this.posits.set(sizeMM, new POS.Posit(sizeMM, this.focal));
|
||||
return this.posits.get(sizeMM);
|
||||
}
|
||||
|
||||
/** 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 pose = this.positFor(sizeMM).pose(centered);
|
||||
if (!pose) return null;
|
||||
|
||||
const cand = [
|
||||
this.candidate(pose.bestRotation, pose.bestTranslation, pose.bestError),
|
||||
this.candidate(pose.alternativeRotation, pose.alternativeTranslation, pose.alternativeError),
|
||||
];
|
||||
|
||||
// Temporal consistency: prefer the solution nearest the previous frame's quat.
|
||||
const prev = this.prev.get(markerId);
|
||||
let pick;
|
||||
if (prev && (performance.now() - prev.t) < this.prevTTL) {
|
||||
const d0 = Math.abs(cand[0].quaternion.dot(prev.quat));
|
||||
const d1 = Math.abs(cand[1].quaternion.dot(prev.quat));
|
||||
// only override error-order if the alternative is clearly more consistent
|
||||
pick = (d1 > d0 + 0.05) ? cand[1] : (d0 > d1 + 0.05 ? cand[0] : (cand[0].error <= cand[1].error ? cand[0] : cand[1]));
|
||||
} else {
|
||||
pick = cand[0].error <= cand[1].error ? cand[0] : cand[1];
|
||||
}
|
||||
|
||||
this.prev.set(markerId, { quat: pick.quaternion.clone(), pos: pick.position.clone(), t: performance.now() });
|
||||
return pick;
|
||||
}
|
||||
|
||||
candidate(rot, t, error) {
|
||||
// Rotation as-is (row-major 3x3 -> Matrix4)
|
||||
_m.set(
|
||||
rot[0][0], rot[0][1], rot[0][2], 0,
|
||||
rot[1][0], rot[1][1], rot[1][2], 0,
|
||||
rot[2][0], rot[2][1], rot[2][2], 0,
|
||||
0, 0, 0, 1
|
||||
);
|
||||
const quaternion = new THREE.Quaternion().setFromRotationMatrix(_m);
|
||||
// Translation: negate Y and Z only (confirmed fix)
|
||||
const position = new THREE.Vector3(t[0], -t[1], -t[2]);
|
||||
return { position, quaternion, error };
|
||||
}
|
||||
}
|
||||
|
||||
/** Build the marker->world transform for an anchor.
|
||||
* mount 'flat': marker printed face-up on a horizontal surface.
|
||||
* mount 'wall': marker on a vertical surface; yawDeg = facing direction.
|
||||
* mount 'custom': explicit yaw/pitch/roll (deg) applied in YXZ order.
|
||||
* All mounts additionally honour yaw/pitch/roll offsets for fine trim.
|
||||
*/
|
||||
export function anchorWorldMatrix(anchor) {
|
||||
const pos = new THREE.Vector3(...anchor.position);
|
||||
const yaw = THREE.MathUtils.degToRad(anchor.yawDeg || 0);
|
||||
const pitch = THREE.MathUtils.degToRad(anchor.pitchDeg || 0);
|
||||
const roll = THREE.MathUtils.degToRad(anchor.rollDeg || 0);
|
||||
|
||||
// Base orientation by mount:
|
||||
// flat: marker face-up — marker +Z (out of print face) -> world +Y
|
||||
// wall: marker vertical — marker +Z faces world +Z when yawDeg = 0
|
||||
const base = new THREE.Quaternion();
|
||||
if (anchor.mount !== 'wall' && anchor.mount !== 'custom') {
|
||||
base.setFromAxisAngle(new THREE.Vector3(1, 0, 0), -Math.PI / 2);
|
||||
}
|
||||
|
||||
// Trim (fully editable): yaw about world Y, then pitch/roll fine adjustment
|
||||
const trim = new THREE.Quaternion().setFromEuler(new THREE.Euler(pitch, yaw, roll, 'YXZ'));
|
||||
const q = trim.multiply(base);
|
||||
return new THREE.Matrix4().compose(pos, q, new THREE.Vector3(1, 1, 1));
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
/* exhibit.js — main viewer.
|
||||
* Pipeline: camera video -> ArUco detect (maxHamming 0) -> per-marker POS-IT pose
|
||||
* (rotation as-is, -t[1] -t[2]) -> fuseWorld (confidence-weighted slerp) -> Three.js
|
||||
* camera placed in world -> server-driven ghosts + building occlusion meshes.
|
||||
*/
|
||||
import * as THREE from 'three';
|
||||
import { createDetector, detectMarkers, quadArea } from './ar/detect.js';
|
||||
import { PoseEstimator } from './ar/pose.js';
|
||||
import { WorldFuser } from './ar/fuse.js';
|
||||
import { buildGhost } from './ghosts/loader.js';
|
||||
import { ghostTransform } from './ghosts/behavior.js';
|
||||
import { ExhibitNet, installErrorReporter } from './net.js';
|
||||
|
||||
const $ = (s) => document.querySelector(s);
|
||||
let info = { mode: 'dev' };
|
||||
let scene3, camera3, renderer, video, canvas2d, ctx2d;
|
||||
let detector, poseEst, fuser;
|
||||
let gradients = {}, modelManifest = null;
|
||||
const activeGhosts = new Map(); // uid -> { rec, group }
|
||||
let tracking = { markers: 0, lastSeen: 0 };
|
||||
const clockOffsetSamples = [];
|
||||
let clockOffset = 0; // serverNow - clientNow
|
||||
|
||||
async function boot() {
|
||||
info = await (await fetch('/api/info')).json();
|
||||
installErrorReporter(info.mode === 'dev');
|
||||
|
||||
const g = await (await fetch('/api/ghosts')).json();
|
||||
gradients = g.gradients.gradients || g.gradients;
|
||||
modelManifest = await (await fetch('/api/models')).json();
|
||||
|
||||
$('#start').addEventListener('click', start, { once: true });
|
||||
}
|
||||
|
||||
async function start() {
|
||||
$('#startScreen').classList.add('hidden');
|
||||
$('#hud').classList.remove('hidden');
|
||||
|
||||
// camera
|
||||
video = $('#cam');
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
video: { facingMode: 'environment', width: { ideal: 1280 }, height: { ideal: 720 } },
|
||||
audio: false,
|
||||
});
|
||||
video.srcObject = stream;
|
||||
await video.play();
|
||||
|
||||
// detection canvas
|
||||
canvas2d = document.createElement('canvas');
|
||||
ctx2d = canvas2d.getContext('2d', { willReadFrequently: true });
|
||||
|
||||
// three.js
|
||||
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);
|
||||
scene3.add(new THREE.AmbientLight(0xffffff, 1.2));
|
||||
onResize(); addEventListener('resize', onResize);
|
||||
|
||||
detector = createDetector();
|
||||
fuser = new WorldFuser();
|
||||
|
||||
// network
|
||||
const net = new ExhibitNet();
|
||||
net.on('scene', (m) => { fuser.setScene(m.scene); buildOcclusion(m.scene); })
|
||||
.on('active', async (m) => {
|
||||
for (const uid of [...activeGhosts.keys()]) removeGhost(uid);
|
||||
for (const rec of m.ghosts) await addGhost(rec);
|
||||
})
|
||||
.on('spawn', (m) => addGhost(m.ghost))
|
||||
.on('despawn', (m) => scheduleRemove(m.uid))
|
||||
.on('pong', (m) => {
|
||||
const rtt = performance.now() - m.t;
|
||||
clockOffsetSamples.push(m.server + rtt / 2 - Date.now());
|
||||
if (clockOffsetSamples.length > 5) clockOffsetSamples.shift();
|
||||
clockOffset = clockOffsetSamples.reduce((a, b) => a + b, 0) / clockOffsetSamples.length;
|
||||
});
|
||||
net.connect();
|
||||
setInterval(() => { try { net.ws.send(JSON.stringify({ type: 'ping', t: performance.now() })); } catch {} }, 5000);
|
||||
|
||||
requestAnimationFrame(loop);
|
||||
}
|
||||
|
||||
function onResize() {
|
||||
renderer.setSize(innerWidth, innerHeight);
|
||||
camera3.aspect = innerWidth / innerHeight;
|
||||
camera3.updateProjectionMatrix();
|
||||
}
|
||||
|
||||
// ---------- occlusion: invisible depth-only building volumes ----------
|
||||
const occlusionGroup = new THREE.Group();
|
||||
function buildOcclusion(scene) {
|
||||
occlusionGroup.clear();
|
||||
const mat = new THREE.MeshBasicMaterial({ colorWrite: false }); // depth-only
|
||||
for (const b of scene.buildings || []) {
|
||||
const geo = new THREE.BoxGeometry(b.size[0], b.size[1], b.size[2]);
|
||||
const mesh = new THREE.Mesh(geo, mat);
|
||||
mesh.position.set(b.position[0], b.position[1] + b.size[1] / 2, b.position[2]);
|
||||
mesh.rotation.y = THREE.MathUtils.degToRad(b.yawDeg || 0);
|
||||
mesh.renderOrder = -1;
|
||||
occlusionGroup.add(mesh);
|
||||
}
|
||||
if (!occlusionGroup.parent) scene3.add(occlusionGroup);
|
||||
}
|
||||
|
||||
// ---------- ghosts ----------
|
||||
async function addGhost(rec) {
|
||||
if (activeGhosts.has(rec.uid)) return;
|
||||
const group = await buildGhost(rec, gradients, modelManifest);
|
||||
scene3.add(group);
|
||||
activeGhosts.set(rec.uid, { rec, group });
|
||||
toast(`${rec.name} appeared`);
|
||||
}
|
||||
function removeGhost(uid) {
|
||||
const e = activeGhosts.get(uid);
|
||||
if (!e) return;
|
||||
scene3.remove(e.group);
|
||||
activeGhosts.delete(uid);
|
||||
}
|
||||
function scheduleRemove(uid) {
|
||||
const e = activeGhosts.get(uid);
|
||||
if (!e) return removeGhost(uid);
|
||||
// let the client-side crossfade (driven by rec.until) finish, then remove
|
||||
const wait = Math.max(0, e.rec.until - (Date.now() + clockOffset)) + (e.rec.crossfade || 3) * 1000;
|
||||
setTimeout(() => removeGhost(uid), Math.min(wait, 8000));
|
||||
}
|
||||
|
||||
// ---------- main loop ----------
|
||||
const _tmp = { position: new THREE.Vector3(), rotationY: 0, opacity: 1 };
|
||||
let frameCount = 0;
|
||||
|
||||
function loop(t) {
|
||||
requestAnimationFrame(loop);
|
||||
if (video && video.readyState >= 2) {
|
||||
// downscale for detection speed
|
||||
const W = 640;
|
||||
const H = Math.round(W * video.videoHeight / video.videoWidth);
|
||||
if (canvas2d.width !== W) { canvas2d.width = W; canvas2d.height = H; }
|
||||
ctx2d.drawImage(video, 0, 0, W, H);
|
||||
const img = ctx2d.getImageData(0, 0, W, H);
|
||||
|
||||
if (!poseEst) {
|
||||
// focal length in detection-canvas pixels from camera FOV assumption (~60° h-fov)
|
||||
poseEst = new PoseEstimator(W / (2 * Math.tan(THREE.MathUtils.degToRad(60) / 2)));
|
||||
}
|
||||
|
||||
const markers = detectMarkers(detector, img, fuser.knownIds());
|
||||
tracking.markers = markers.length;
|
||||
if (markers.length) {
|
||||
tracking.lastSeen = performance.now();
|
||||
const estimates = markers.map(m => {
|
||||
const e = poseEst.estimate(m.id, m.corners, W / 2, H / 2, fuser.sizeFor(m.id));
|
||||
return e && { markerId: m.id, position: e.position, quaternion: e.quaternion, area: quadArea(m.corners) };
|
||||
}).filter(Boolean);
|
||||
const fused = fuser.fuse(estimates);
|
||||
if (fused) {
|
||||
camera3.position.copy(fused.position);
|
||||
camera3.quaternion.copy(fused.quaternion);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ghosts: deterministic motion on synced clock
|
||||
const now = Date.now() + clockOffset;
|
||||
for (const { rec, group } of activeGhosts.values()) {
|
||||
ghostTransform(rec, now, _tmp);
|
||||
group.position.copy(_tmp.position);
|
||||
group.rotation.y = _tmp.rotationY;
|
||||
group.userData.setOpacity(_tmp.opacity * 0.92);
|
||||
group.userData.tick(t / 1000);
|
||||
}
|
||||
|
||||
// HUD
|
||||
if ((frameCount++ & 15) === 0) {
|
||||
const stale = performance.now() - tracking.lastSeen > 1500;
|
||||
$('#trk').textContent = stale ? 'Point at a Newbury Crest' : `Tracking ${tracking.markers} crest${tracking.markers === 1 ? '' : 's'}`;
|
||||
$('#trk').classList.toggle('warn', stale || tracking.markers < 2);
|
||||
$('#cnt').textContent = `${activeGhosts.size} ghost${activeGhosts.size === 1 ? '' : 's'} nearby`;
|
||||
}
|
||||
|
||||
renderer.render(scene3, camera3);
|
||||
}
|
||||
|
||||
let toastTimer = null;
|
||||
function toast(msg) {
|
||||
const el = $('#toast');
|
||||
el.textContent = msg;
|
||||
el.classList.add('show');
|
||||
clearTimeout(toastTimer);
|
||||
toastTimer = setTimeout(() => el.classList.remove('show'), 2500);
|
||||
}
|
||||
|
||||
boot().catch(e => { console.error(e); alert('Failed to start: ' + e.message); });
|
||||
@@ -0,0 +1,52 @@
|
||||
/* behavior.js — deterministic ghost motion so all viewers see the same movement.
|
||||
* Motion is a pure function of (server spawn record, wall-clock time): no per-client
|
||||
* randomness, so phones stay in sync without streaming positions.
|
||||
*/
|
||||
import * as THREE from 'three';
|
||||
|
||||
function hashNoise(seed, k) {
|
||||
// cheap deterministic pseudo-noise in [-1, 1]
|
||||
const x = Math.sin(seed * 127.1 + k * 311.7) * 43758.5453;
|
||||
return (x - Math.floor(x)) * 2 - 1;
|
||||
}
|
||||
|
||||
export function ghostTransform(rec, nowMs, out) {
|
||||
const t = (nowMs - rec.spawnedAt) / 1000;
|
||||
const base = new THREE.Vector3(...rec.pos);
|
||||
const b = rec.behavior || { type: 'static' };
|
||||
|
||||
if (b.type === 'wander') {
|
||||
const s = b.seed || 1;
|
||||
const R = b.radius ?? 0.6;
|
||||
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.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
|
||||
);
|
||||
// face travel direction (finite difference)
|
||||
const eps = 0.05;
|
||||
const ahead = (t2) => new THREE.Vector3(
|
||||
base.x + R * 0.9 * Math.sin(t2 * v + hashNoise(s, 1) * 6.28) * 0.7,
|
||||
0,
|
||||
base.z + R * 0.9 * Math.cos(t2 * v * 0.8 + hashNoise(s, 4) * 6.28) * 0.7);
|
||||
const dir = ahead(t + eps).sub(ahead(t));
|
||||
out.rotationY = Math.atan2(dir.x, dir.z);
|
||||
} else {
|
||||
const amp = b.bobAmp ?? 0.06;
|
||||
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
|
||||
}
|
||||
|
||||
// crossfade opacity: fade in on spawn, fade out approaching `until`
|
||||
const fade = (rec.crossfade || 3) * 1000;
|
||||
const inA = Math.min(1, (nowMs - rec.spawnedAt) / fade);
|
||||
const outA = Math.min(1, Math.max(0, (rec.until - nowMs) / fade));
|
||||
out.opacity = Math.min(inA, outA);
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
/* loader.js — ghost visuals.
|
||||
* Multi-part OBJ ghosts: { legs|wisp, torso, head, headpiece } assembled into one Group,
|
||||
* every part rendered with the recovered Hidden Side gradient shader (top->bottom tint
|
||||
* by GhostColor: Red / Yellow / Blue). Until models exist, a procedural wisp fallback
|
||||
* is used so the exhibit always has something to show.
|
||||
*
|
||||
* Model manifest (data/models.json, editable via /api/models):
|
||||
* { "models": [ { "id": "classic", "scale": 1.0,
|
||||
* "parts": { "legs": "/models/classic/legs.obj", "torso": "...", "head": "...", "headpiece": "..." } } ] }
|
||||
*/
|
||||
import * as THREE from 'three';
|
||||
import { OBJLoader } from 'three/addons/loaders/OBJLoader.js';
|
||||
|
||||
const objLoader = new OBJLoader();
|
||||
const objCache = new Map();
|
||||
|
||||
function gradientMaterial(top, bottom, opacity = 0.92) {
|
||||
return new THREE.ShaderMaterial({
|
||||
transparent: true,
|
||||
depthWrite: false,
|
||||
uniforms: {
|
||||
topColor: { value: new THREE.Color(top) },
|
||||
bottomColor: { value: new THREE.Color(bottom) },
|
||||
opacity: { value: opacity },
|
||||
uMinY: { value: 0 },
|
||||
uMaxY: { value: 1 },
|
||||
uTime: { value: 0 },
|
||||
},
|
||||
vertexShader: `
|
||||
varying float vY;
|
||||
varying vec3 vNormal;
|
||||
uniform float uMinY, uMaxY;
|
||||
void main() {
|
||||
vY = clamp((position.y - uMinY) / max(uMaxY - uMinY, 0.001), 0.0, 1.0);
|
||||
vNormal = normalize(normalMatrix * normal);
|
||||
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
|
||||
}`,
|
||||
fragmentShader: `
|
||||
varying float vY;
|
||||
varying vec3 vNormal;
|
||||
uniform vec3 topColor, bottomColor;
|
||||
uniform float opacity, uTime;
|
||||
void main() {
|
||||
vec3 c = mix(bottomColor, topColor, vY);
|
||||
float rim = pow(1.0 - abs(dot(vNormal, vec3(0.0, 0.0, 1.0))), 2.0);
|
||||
c += rim * 0.35;
|
||||
float pulse = 0.92 + 0.08 * sin(uTime * 2.2);
|
||||
gl_FragColor = vec4(c, opacity * pulse);
|
||||
}`,
|
||||
});
|
||||
}
|
||||
|
||||
async function loadOBJ(url) {
|
||||
if (objCache.has(url)) return objCache.get(url).clone();
|
||||
const obj = await objLoader.loadAsync(url);
|
||||
objCache.set(url, obj);
|
||||
return obj.clone();
|
||||
}
|
||||
|
||||
function proceduralWisp() {
|
||||
const g = new THREE.Group();
|
||||
const body = new THREE.Mesh(new THREE.SphereGeometry(0.09, 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));
|
||||
tail.rotation.x = Math.PI;
|
||||
tail.position.y = 0.0;
|
||||
g.add(body, tail);
|
||||
return g;
|
||||
}
|
||||
|
||||
export async function buildGhost(ghost, gradients, manifest) {
|
||||
const grad = gradients[ghost.color] || gradients.Blue;
|
||||
const mat = gradientMaterial(grad.top, grad.bottom);
|
||||
|
||||
let group;
|
||||
const model = (manifest?.models || [])[0]; // default model; per-ghost mapping can extend later
|
||||
if (model && model.parts) {
|
||||
group = new THREE.Group();
|
||||
try {
|
||||
for (const key of ['legs', 'wisp', 'torso', 'head', 'headpiece']) {
|
||||
const url = model.parts[key];
|
||||
if (!url) continue;
|
||||
group.add(await loadOBJ(url));
|
||||
}
|
||||
if (model.scale) group.scale.setScalar(model.scale);
|
||||
if (!group.children.length) group = proceduralWisp();
|
||||
} catch (e) {
|
||||
console.warn('ghost model load failed, using wisp fallback', e);
|
||||
group = proceduralWisp();
|
||||
}
|
||||
} else {
|
||||
group = proceduralWisp();
|
||||
}
|
||||
|
||||
// apply gradient material to every mesh; compute Y-range for gradient mapping
|
||||
const box = new THREE.Box3().setFromObject(group);
|
||||
group.traverse(o => {
|
||||
if (o.isMesh) {
|
||||
o.material = mat;
|
||||
o.material.uniforms.uMinY.value = box.min.y;
|
||||
o.material.uniforms.uMaxY.value = box.max.y;
|
||||
}
|
||||
});
|
||||
|
||||
group.userData.material = mat;
|
||||
group.userData.setOpacity = (v) => { mat.uniforms.opacity.value = v; };
|
||||
group.userData.tick = (t) => { mat.uniforms.uTime.value = t; };
|
||||
return group;
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
/* net.js — WebSocket sync client + dev-mode error pipeline. */
|
||||
export class ExhibitNet {
|
||||
constructor() {
|
||||
this.handlers = new Map();
|
||||
this.ws = null;
|
||||
this.retry = 1000;
|
||||
}
|
||||
on(type, fn) { this.handlers.set(type, fn); return this; }
|
||||
connect() {
|
||||
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
|
||||
this.ws = new WebSocket(`${proto}://${location.host}/ws`);
|
||||
this.ws.onopen = () => { this.retry = 1000; };
|
||||
this.ws.onmessage = (ev) => {
|
||||
let m; try { m = JSON.parse(ev.data); } catch { return; }
|
||||
const h = this.handlers.get(m.type);
|
||||
if (h) h(m);
|
||||
};
|
||||
this.ws.onclose = () => {
|
||||
setTimeout(() => this.connect(), this.retry);
|
||||
this.retry = Math.min(this.retry * 2, 15000);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/* Dev-mode error checking: capture JS errors + unhandled rejections, show an
|
||||
* on-screen overlay, and report to the server for the admin error log. */
|
||||
export function installErrorReporter(devMode) {
|
||||
const report = (payload) => {
|
||||
fetch('/api/client-error', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ua: navigator.userAgent, page: location.pathname, ...payload }),
|
||||
}).catch(() => {});
|
||||
if (devMode) showOverlay(payload);
|
||||
};
|
||||
window.addEventListener('error', (e) =>
|
||||
report({ kind: 'error', msg: String(e.message), src: e.filename, line: e.lineno }));
|
||||
window.addEventListener('unhandledrejection', (e) =>
|
||||
report({ kind: 'rejection', msg: String(e.reason && e.reason.message || e.reason) }));
|
||||
return report;
|
||||
}
|
||||
|
||||
let overlayEl = null;
|
||||
function showOverlay(p) {
|
||||
if (!overlayEl) {
|
||||
overlayEl = document.createElement('div');
|
||||
overlayEl.style.cssText = 'position:fixed;bottom:0;left:0;right:0;max-height:35vh;overflow:auto;' +
|
||||
'background:rgba(120,0,0,.88);color:#fff;font:12px monospace;padding:8px;z-index:99999;white-space:pre-wrap';
|
||||
document.body.appendChild(overlayEl);
|
||||
}
|
||||
overlayEl.textContent += `[${p.kind}] ${p.msg}${p.src ? ` (${p.src}:${p.line})` : ''}\n`;
|
||||
}
|
||||
Reference in New Issue
Block a user