Import v2 base (unchanged files)
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,151 @@
|
||||
/* 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;
|
||||
/* 1€ (OneEuro) filter — the standard cure for marker-pose jitter. It low-passes
|
||||
* hard when the signal is slow (phone still => kills jitter) and eases off as
|
||||
* speed rises (phone moving => stays responsive, no lag). Two knobs:
|
||||
* minCutoff — lower = steadier at rest (more smoothing when still)
|
||||
* beta — higher = snappier when moving (less lag during motion)
|
||||
* dCutoff is the cutoff for the internal speed estimate; 1.0 is standard. */
|
||||
this.oe = {
|
||||
minCutoffPos: 0.6, betaPos: 0.05,
|
||||
minCutoffAng: 0.7, betaAng: 0.06,
|
||||
dCutoff: 1.0,
|
||||
prevPos: null, dPos: new THREE.Vector3(),
|
||||
prevQuat: null, dAngRate: 0,
|
||||
};
|
||||
this.lastFuseT = 0;
|
||||
}
|
||||
|
||||
// low-pass alpha from a cutoff frequency (Hz) and timestep dt (s)
|
||||
static alpha(cutoff, dt) {
|
||||
const tau = 1 / (2 * Math.PI * cutoff);
|
||||
return 1 / (1 + tau / dt);
|
||||
}
|
||||
|
||||
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 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->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();
|
||||
const quat = new THREE.Quaternion();
|
||||
camWorld.decompose(pos, quat, _s);
|
||||
cams.push({ markerId: e.markerId, pos, quat, w: Math.max(1, e.area) });
|
||||
}
|
||||
if (!cams.length) return null;
|
||||
|
||||
/* Multi-marker fusion. Two visible markers each give an independent camera-in-world
|
||||
* estimate; if they disagree (pose noise, or real-table spacing not matching the
|
||||
* editor), naive area-weighting oscillates frame to frame because the areas jitter
|
||||
* — this is the "steady on one marker, jittery on two" symptom. Fixes:
|
||||
* 1. Deterministic order (sort by markerId) so the slerp base can't flip.
|
||||
* 2. Robust weights: quantise area into coarse buckets so tiny per-frame area
|
||||
* wobble doesn't shift the blend; a marker only dominates when genuinely much
|
||||
* closer. Equal-ish markers then average to a STABLE midpoint, not a moving one. */
|
||||
cams.sort((a, b) => a.markerId - b.markerId);
|
||||
// Quantise confidence into coarse buckets from marker size (sqrt(area) ~ linear
|
||||
// size). Near-equal markers land in the same bucket => equal weight => stable
|
||||
// midpoint. A marker only outweighs another when it's a full bucket closer, so
|
||||
// per-frame area jitter no longer shifts the blend.
|
||||
for (const c of cams) c.w = Math.max(1, Math.round(Math.sqrt(c.w) / 8));
|
||||
|
||||
// weighted position mean + incremental weighted slerp (now order-stable)
|
||||
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);
|
||||
|
||||
// spread = how far apart the individual marker estimates are (0 for one marker).
|
||||
// Large spread (>~2cm) means the markers disagree — usually real-table spacing
|
||||
// not matching the editor, which no fusion can fully hide; surfaced in dev HUD.
|
||||
let spread = 0;
|
||||
for (let i = 0; i < cams.length; i++)
|
||||
for (let j = i + 1; j < cams.length; j++)
|
||||
spread = Math.max(spread, cams[i].pos.distanceTo(cams[j].pos));
|
||||
|
||||
// ---- 1€ filter (position + orientation) ----
|
||||
const now = performance.now();
|
||||
const gap = now - this.lastFuseT;
|
||||
const dt = this.smoothPos ? Math.min(0.1, Math.max(0.001, gap / 1000)) : 1 / 30;
|
||||
this.lastFuseT = now;
|
||||
const oe = this.oe;
|
||||
|
||||
// reset on first frame or after a real tracking gap (>1.5s)
|
||||
if (!this.smoothPos || gap > 1500) {
|
||||
this.smoothPos = pos.clone(); oe.prevPos = pos.clone(); oe.dPos.set(0, 0, 0);
|
||||
this.smoothQuat = quat.clone(); oe.prevQuat = quat.clone(); oe.dAngRate = 0;
|
||||
return { position: this.smoothPos.clone(), quaternion: this.smoothQuat.clone(), markerCount: cams.length, spread };
|
||||
}
|
||||
|
||||
// POSITION: derivative -> speed -> dynamic cutoff -> low-pass
|
||||
const dPosRaw = pos.clone().sub(oe.prevPos).multiplyScalar(1 / dt); // cm/s
|
||||
const aD = WorldFuser.alpha(oe.dCutoff, dt);
|
||||
oe.dPos.lerp(dPosRaw, aD);
|
||||
const speed = oe.dPos.length();
|
||||
const cutoffP = oe.minCutoffPos + oe.betaPos * speed;
|
||||
const aP = WorldFuser.alpha(cutoffP, dt);
|
||||
this.smoothPos.lerp(pos, aP);
|
||||
oe.prevPos.copy(pos);
|
||||
|
||||
// ORIENTATION: angular speed -> dynamic cutoff -> slerp
|
||||
if (this.smoothQuat.dot(quat) < 0) quat.set(-quat.x, -quat.y, -quat.z, -quat.w);
|
||||
if (oe.prevQuat.dot(quat) < 0) oe.prevQuat.set(-oe.prevQuat.x, -oe.prevQuat.y, -oe.prevQuat.z, -oe.prevQuat.w);
|
||||
const angDelta = 2 * Math.acos(Math.min(1, Math.abs(oe.prevQuat.dot(quat)))) / dt; // rad/s
|
||||
oe.dAngRate += aD * (angDelta - oe.dAngRate);
|
||||
const cutoffA = oe.minCutoffAng + oe.betaAng * oe.dAngRate;
|
||||
const aA = WorldFuser.alpha(cutoffA, dt);
|
||||
this.smoothQuat.slerp(quat, aA);
|
||||
oe.prevQuat.copy(quat);
|
||||
|
||||
return { position: this.smoothPos.clone(), quaternion: this.smoothQuat.clone(), markerCount: cams.length, spread };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
/* 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();
|
||||
|
||||
/* Rotation frame correction. The translation is converted from POS-IT's frame to
|
||||
* the Three.js frame by F = diag(1,-1,-1) (the -t[1]/-t[2] negation). The rotation
|
||||
* needs a matching left-multiply by F: R' = F R. Confirmed on-device — orientation
|
||||
* then survives phone rotation and viewing angle. (Leaving rotation as-is ('asis')
|
||||
* puts position and orientation in different frames so pitch leaks into yaw/roll;
|
||||
* the full conjugation F R F^T ('frf') over-corrects for this pipeline.) Modes stay
|
||||
* runtime-switchable for future re-tuning; the debug overlay cycles ROT_MODES. */
|
||||
const F = new THREE.Matrix4().set(1,0,0,0, 0,-1,0,0, 0,0,-1,0, 0,0,0,1);
|
||||
const Ft = F.clone().transpose();
|
||||
export const ROT_MODES = ['fr', 'frf', 'asis', 'rf'];
|
||||
let rotMode = 'fr'; // F R — confirmed correct on-device (orientation survives rotation/angle)
|
||||
export function setRotMode(m) { if (ROT_MODES.includes(m)) rotMode = m; }
|
||||
export function getRotMode() { return rotMode; }
|
||||
|
||||
function correctRotation(R) {
|
||||
// R is a Matrix4 holding the raw POS-IT rotation.
|
||||
switch (rotMode) {
|
||||
case 'asis': return R;
|
||||
case 'fr': return _m2.multiplyMatrices(F, R);
|
||||
case 'rf': return _m2.multiplyMatrices(R, F);
|
||||
case 'frf':
|
||||
default: return _m2.multiplyMatrices(F, R).multiply(Ft);
|
||||
}
|
||||
}
|
||||
const _m2 = new THREE.Matrix4();
|
||||
|
||||
/* 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;
|
||||
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: FLIP_INPUT_Y ? (cy - c.y) : (c.y - cy) }));
|
||||
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) {
|
||||
// Raw POS-IT rotation (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
|
||||
);
|
||||
// Conjugate into the Three.js frame so rotation matches the flipped translation
|
||||
const quaternion = new THREE.Quaternion().setFromRotationMatrix(correctRotation(_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,96 @@
|
||||
/* 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 === 'path') {
|
||||
pathTransform(b, t, out);
|
||||
} else if (b.type === 'wander') {
|
||||
const s = b.seed || 1;
|
||||
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 + (b.vertical ?? 10) * Math.sin(t * v * 1.3 + hashNoise(s, 3) * 6.28)
|
||||
+ 3 * Math.sin(t * 1.7 + hashNoise(s, 6) * 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 ?? 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
|
||||
}
|
||||
|
||||
// crossfade opacity: fade in on spawn; permanent residents (until == null) never fade out
|
||||
const fade = (rec.crossfade || 3) * 1000;
|
||||
const inA = Math.min(1, (nowMs - rec.spawnedAt) / fade);
|
||||
const outA = rec.until == null ? 1 : Math.min(1, Math.max(0, (rec.until - nowMs) / fade));
|
||||
out.opacity = Math.min(inA, outA);
|
||||
return out;
|
||||
}
|
||||
|
||||
/* Walk a waypoint path at constant speed (cm/s), turning to face travel direction.
|
||||
* Deterministic: position is a pure function of elapsed time, so all viewers agree.
|
||||
* b: { points:[[x,y,z],...], mode:'loop'|'pingpong', speed, phase, seed } */
|
||||
function pathTransform(b, t, out) {
|
||||
const raw = b.points || [];
|
||||
if (raw.length < 2) { out.position.set(...(raw[0] || [0, 0, 0])); out.rotationY = 0; return; }
|
||||
const pts = b.mode === 'loop' ? [...raw, raw[0]] : raw;
|
||||
|
||||
// cumulative segment lengths
|
||||
const cum = [0];
|
||||
for (let i = 1; i < pts.length; i++) {
|
||||
const dx = pts[i][0] - pts[i - 1][0], dy = pts[i][1] - pts[i - 1][1], dz = pts[i][2] - pts[i - 1][2];
|
||||
cum.push(cum[i - 1] + Math.hypot(dx, dy, dz));
|
||||
}
|
||||
const total = cum[cum.length - 1] || 1;
|
||||
|
||||
const wrap = (d) => {
|
||||
if (b.mode === 'pingpong') { const m = ((d % (2 * total)) + 2 * total) % (2 * total); return m < total ? m : 2 * total - m; }
|
||||
return ((d % total) + total) % total;
|
||||
};
|
||||
const sample = (d, v) => {
|
||||
let i = 1; while (i < cum.length - 1 && cum[i] < d) i++;
|
||||
const f = (d - cum[i - 1]) / Math.max(cum[i] - cum[i - 1], 1e-6);
|
||||
v.set(
|
||||
pts[i - 1][0] + (pts[i][0] - pts[i - 1][0]) * f,
|
||||
pts[i - 1][1] + (pts[i][1] - pts[i - 1][1]) * f,
|
||||
pts[i - 1][2] + (pts[i][2] - pts[i - 1][2]) * f);
|
||||
return v;
|
||||
};
|
||||
|
||||
const d = wrap(t * (b.speed ?? 8) + (b.phase || 0));
|
||||
sample(d, out.position);
|
||||
// face where we're heading: sample a few cm ahead so corners turn smoothly
|
||||
const ahead = sample(wrap(t * (b.speed ?? 8) + (b.phase || 0) + 6), _look);
|
||||
const dx = ahead.x - out.position.x, dz = ahead.z - out.position.z;
|
||||
if (dx * dx + dz * dz > 1e-4) out.rotationY = Math.atan2(dx, dz);
|
||||
// gentle float so walking still reads ghostly
|
||||
out.position.y += 2 * Math.sin(t * 1.9 + (b.seed || 0));
|
||||
}
|
||||
const _look = new THREE.Vector3();
|
||||
@@ -0,0 +1,280 @@
|
||||
/* loader.js — ghost visuals.
|
||||
*
|
||||
* Multi-part OBJ ghosts: { legs|wisp, torso, head, headpiece } assembled into one Group,
|
||||
* rendered with the recovered Hidden Side gradient shader (top->bottom tint by GhostColor:
|
||||
* Red / Yellow / Blue). Procedural wisp fallback until models are uploaded.
|
||||
*
|
||||
* Per-character overrides come from /api/characters (managed in /admin/characters.html):
|
||||
* { modelId, opacity, topColor, bottomColor, heightCm, scale,
|
||||
* faceTextureUrl, torsoTextureUrl,
|
||||
* partOverrides: { <partKey>: { offset:[x,y,z], rotation:[x,y,z], scale } } }
|
||||
*
|
||||
* Part placement: each part in a model may be a bare URL string, or an object
|
||||
* { url, offset:[x,y,z], rotation:[x,y,z](degrees), scale }
|
||||
* The model manifest holds the shared default placement; a character's
|
||||
* partOverrides[key] is merged on top so an individual ghost can be nudged
|
||||
* without disturbing every other ghost that shares the same model.
|
||||
*
|
||||
* Textures: face and torso images are projected planar-front onto the mesh (UV-independent,
|
||||
* so untextured OBJs still work). Each is placed by a normalized Y band with adjustable
|
||||
* centre/size, blended over the gradient — a simple decal without needing UV-mapped models.
|
||||
*/
|
||||
import * as THREE from 'three';
|
||||
import { OBJLoader } from 'three/addons/loaders/OBJLoader.js';
|
||||
|
||||
/* Canonical part order (also the assembly/z-order). Shared with the editor. */
|
||||
export const PART_KEYS = ['legs', 'wisp', 'torso', 'head', 'headpiece'];
|
||||
|
||||
const objLoader = new OBJLoader();
|
||||
const objCache = new Map();
|
||||
const texLoader = new THREE.TextureLoader();
|
||||
const texCache = new Map();
|
||||
|
||||
/* A sampler2D uniform must ALWAYS be bound to a real texture: leaving it null makes
|
||||
* the shader program fail to link on many GPUs, which would break every untextured
|
||||
* ghost. This 1x1 fully-transparent pixel is the safe no-op binding. */
|
||||
const EMPTY_TEX = (() => {
|
||||
const t = new THREE.DataTexture(new Uint8Array([0, 0, 0, 0]), 1, 1, THREE.RGBAFormat);
|
||||
t.needsUpdate = true;
|
||||
return t;
|
||||
})();
|
||||
|
||||
function loadTexture(url) {
|
||||
if (!url) return null;
|
||||
if (!texCache.has(url)) {
|
||||
const t = texLoader.load(url, undefined, undefined,
|
||||
() => console.warn('texture failed to load:', url)); // keeps the ghost alive
|
||||
t.colorSpace = THREE.SRGBColorSpace;
|
||||
texCache.set(url, t);
|
||||
}
|
||||
return texCache.get(url);
|
||||
}
|
||||
|
||||
function gradientMaterial(top, bottom, opacity, faceTex, torsoTex, bands) {
|
||||
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 },
|
||||
uToGroup: { value: new THREE.Matrix4() }, // mesh-local -> group-local (bakes part transform)
|
||||
uTime: { value: 0 },
|
||||
faceMap: { value: faceTex || EMPTY_TEX },
|
||||
torsoMap: { value: torsoTex || EMPTY_TEX },
|
||||
hasFace: { value: faceTex ? 1 : 0 },
|
||||
hasTorso: { value: torsoTex ? 1 : 0 },
|
||||
// [centreY, halfHeight, halfWidth] in normalized model space
|
||||
faceBand: { value: new THREE.Vector3(bands.faceY, bands.faceSize, bands.faceSize) },
|
||||
torsoBand: { value: new THREE.Vector3(bands.torsoY, bands.torsoSize, bands.torsoSize) },
|
||||
},
|
||||
vertexShader: `
|
||||
varying float vY;
|
||||
varying vec3 vNormal;
|
||||
varying vec3 vLocal;
|
||||
uniform float uMinY, uMaxY;
|
||||
uniform mat4 uToGroup;
|
||||
void main() {
|
||||
// Position within the assembled group (accounts for per-part offset/rotation/scale),
|
||||
// so the gradient + decal bands span the whole figure, not each part's raw origin.
|
||||
vec3 gp = (uToGroup * vec4(position, 1.0)).xyz;
|
||||
float h = max(uMaxY - uMinY, 0.001);
|
||||
vY = clamp((gp.y - uMinY) / h, 0.0, 1.0);
|
||||
vLocal = vec3(gp.x / h, vY, gp.z / h);
|
||||
vNormal = normalize(normalMatrix * normal);
|
||||
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
|
||||
}`,
|
||||
fragmentShader: `
|
||||
varying float vY;
|
||||
varying vec3 vNormal;
|
||||
varying vec3 vLocal;
|
||||
uniform vec3 topColor, bottomColor;
|
||||
uniform float opacity, uTime;
|
||||
uniform sampler2D faceMap, torsoMap;
|
||||
uniform int hasFace, hasTorso;
|
||||
uniform vec3 faceBand, torsoBand;
|
||||
|
||||
// planar-front decal: map local X/Y into the band's UV box
|
||||
vec4 decal(sampler2D map, vec3 band) {
|
||||
vec2 uv = vec2((vLocal.x / band.z) * 0.5 + 0.5,
|
||||
((vLocal.y - band.x) / band.y) * 0.5 + 0.5);
|
||||
if (uv.x < 0.0 || uv.x > 1.0 || uv.y < 0.0 || uv.y > 1.0) return vec4(0.0);
|
||||
return texture2D(map, uv);
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
// only decal the front-facing side so images don't mirror onto the back
|
||||
float front = smoothstep(0.0, 0.35, vNormal.z);
|
||||
if (hasTorso == 1) {
|
||||
vec4 t = decal(torsoMap, torsoBand);
|
||||
c = mix(c, t.rgb, t.a * front);
|
||||
}
|
||||
if (hasFace == 1) {
|
||||
vec4 f = decal(faceMap, faceBand);
|
||||
c = mix(c, f.rgb, f.a * front);
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
const DEG = Math.PI / 180;
|
||||
|
||||
/* A part entry may be a bare URL string or { url, offset, rotation, scale }.
|
||||
* Return a normalized { url, offset:[x,y,z], rotation:[x,y,z], scale } or null. */
|
||||
export function normalizePart(entry) {
|
||||
if (!entry) return null;
|
||||
if (typeof entry === 'string') {
|
||||
return { url: entry, offset: [0, 0, 0], rotation: [0, 0, 0], scale: 1 };
|
||||
}
|
||||
if (!entry.url) return null;
|
||||
const o = Array.isArray(entry.offset) ? entry.offset : [0, 0, 0];
|
||||
const r = Array.isArray(entry.rotation) ? entry.rotation : [0, 0, 0];
|
||||
return {
|
||||
url: entry.url,
|
||||
offset: [+o[0] || 0, +o[1] || 0, +o[2] || 0],
|
||||
rotation: [+r[0] || 0, +r[1] || 0, +r[2] || 0],
|
||||
scale: entry.scale != null ? (+entry.scale || 1) : 1,
|
||||
};
|
||||
}
|
||||
|
||||
/* Merge a per-ghost override on top of the model's default placement.
|
||||
* Only fields present in the override replace the base; url always wins from base. */
|
||||
export function mergePart(base, override) {
|
||||
const b = normalizePart(base);
|
||||
if (!b) return null;
|
||||
if (!override) return b;
|
||||
const ov = override;
|
||||
return {
|
||||
url: b.url,
|
||||
offset: Array.isArray(ov.offset) ? ov.offset.map((v, i) => (v != null ? +v : b.offset[i])) : b.offset,
|
||||
rotation: Array.isArray(ov.rotation) ? ov.rotation.map((v, i) => (v != null ? +v : b.rotation[i])) : b.rotation,
|
||||
scale: ov.scale != null ? +ov.scale : b.scale,
|
||||
};
|
||||
}
|
||||
|
||||
/* Apply a normalized transform to a freshly-loaded part group. */
|
||||
function applyPartTransform(obj, p) {
|
||||
obj.position.set(p.offset[0], p.offset[1], p.offset[2]);
|
||||
obj.rotation.set(p.rotation[0] * DEG, p.rotation[1] * DEG, p.rotation[2] * DEG);
|
||||
obj.scale.setScalar(p.scale);
|
||||
obj.userData.partKey = p.key;
|
||||
return obj;
|
||||
}
|
||||
|
||||
function proceduralWisp() {
|
||||
const g = new THREE.Group();
|
||||
const body = new THREE.Mesh(new THREE.SphereGeometry(9, 20, 16));
|
||||
body.scale.set(1, 1.35, 1);
|
||||
body.position.y = 14;
|
||||
const tail = new THREE.Mesh(new THREE.ConeGeometry(7, 16, 16));
|
||||
tail.rotation.x = Math.PI;
|
||||
tail.position.y = 0;
|
||||
g.add(body, tail);
|
||||
return g;
|
||||
}
|
||||
|
||||
/* Resolve the override chain: character-specific -> defaults -> built-in. */
|
||||
function resolveOverrides(ghost, characters) {
|
||||
const d = (characters && characters.defaults) || {};
|
||||
const c = (characters && characters.byId && characters.byId[ghost.id]) || {};
|
||||
return { ...d, ...c };
|
||||
}
|
||||
|
||||
function pickModel(manifest, ov) {
|
||||
const models = (manifest && manifest.models) || [];
|
||||
if (!models.length) return null;
|
||||
if (ov.modelId) return models.find(m => m.id === ov.modelId) || models[0];
|
||||
return models[0];
|
||||
}
|
||||
|
||||
export async function buildGhost(ghost, gradients, manifest, characters) {
|
||||
const ov = resolveOverrides(ghost, characters);
|
||||
const grad = (gradients && (gradients[ghost.color] || gradients.Blue))
|
||||
|| { top: '#51eaf1', bottom: '#529eff' }; // survive a missing/!oddly-shaped gradient payload
|
||||
const top = ov.topColor || grad.top;
|
||||
const bottom = ov.bottomColor || grad.bottom;
|
||||
const opacity = ov.opacity != null ? Number(ov.opacity) : 0.92;
|
||||
const bands = {
|
||||
faceY: ov.faceY != null ? Number(ov.faceY) : 0.82,
|
||||
faceSize: ov.faceSize != null ? Number(ov.faceSize) : 0.16,
|
||||
torsoY: ov.torsoY != null ? Number(ov.torsoY) : 0.52,
|
||||
torsoSize: ov.torsoSize != null ? Number(ov.torsoSize) : 0.22,
|
||||
};
|
||||
const mat = gradientMaterial(top, bottom, opacity,
|
||||
loadTexture(ov.faceTextureUrl), loadTexture(ov.torsoTextureUrl), bands);
|
||||
|
||||
let group;
|
||||
const model = pickModel(manifest, ov);
|
||||
const partOv = ov.partOverrides || {};
|
||||
if (model && model.parts) {
|
||||
group = new THREE.Group();
|
||||
try {
|
||||
for (const key of PART_KEYS) {
|
||||
const merged = mergePart(model.parts[key], partOv[key]);
|
||||
if (!merged) continue;
|
||||
merged.key = key;
|
||||
const partObj = await loadOBJ(merged.url);
|
||||
applyPartTransform(partObj, merged);
|
||||
group.add(partObj);
|
||||
}
|
||||
if (!group.children.length) group = proceduralWisp();
|
||||
} catch (e) {
|
||||
console.warn('ghost model load failed, using wisp fallback', e);
|
||||
group = proceduralWisp();
|
||||
}
|
||||
} else {
|
||||
group = proceduralWisp();
|
||||
}
|
||||
|
||||
// gradient/decal mapping needs the model's Y range (group-local, post part-transform)
|
||||
group.updateMatrixWorld(true);
|
||||
const box = new THREE.Box3().setFromObject(group);
|
||||
const groupInv = new THREE.Matrix4().copy(group.matrixWorld).invert();
|
||||
const mats = [];
|
||||
group.traverse(o => {
|
||||
if (o.isMesh) {
|
||||
// Each mesh needs its own material instance to carry a per-mesh group-local matrix,
|
||||
// but they all share the same tunable uniforms via the tick/opacity setters below.
|
||||
const m = mat.clone();
|
||||
m.uniforms.uMinY.value = box.min.y;
|
||||
m.uniforms.uMaxY.value = box.max.y;
|
||||
m.uniforms.uToGroup.value = new THREE.Matrix4().multiplyMatrices(groupInv, o.matrixWorld);
|
||||
o.material = m;
|
||||
mats.push(m);
|
||||
}
|
||||
});
|
||||
if (!mats.length) mats.push(mat);
|
||||
|
||||
// Normalize: feet at origin, then setHeight(cm) scales any source units to world height.
|
||||
const outer = new THREE.Group();
|
||||
const inner = new THREE.Group();
|
||||
inner.position.y = -box.min.y;
|
||||
inner.add(group);
|
||||
outer.add(inner);
|
||||
const rawH = Math.max(box.max.y - box.min.y, 1e-6);
|
||||
const modelScale = (ov.scale || model?.scale || 1);
|
||||
outer.userData.setHeight = (cm) => outer.scale.setScalar(((ov.heightCm || cm) / rawH) * modelScale);
|
||||
outer.userData.setHeight(4);
|
||||
|
||||
outer.userData.material = mats[0];
|
||||
outer.userData.materials = mats;
|
||||
outer.userData.setOpacity = (v) => { for (const m of mats) m.uniforms.opacity.value = v * opacity; };
|
||||
outer.userData.tick = (t) => { for (const m of mats) m.uniforms.uTime.value = t; };
|
||||
return outer;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
/* md.js — tiny, safe markdown subset for user-authored copy.
|
||||
* Supports: # headings, **bold**, *italic*, [links](url), - lists, blank-line paragraphs.
|
||||
* Everything is HTML-escaped FIRST, so authored text can never inject markup.
|
||||
*/
|
||||
const esc = (s) => String(s)
|
||||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
.replace(/"/g, '"').replace(/'/g, ''');
|
||||
|
||||
export function renderMarkdown(src) {
|
||||
const lines = esc(src || '').split(/\r?\n/);
|
||||
const out = [];
|
||||
let para = [], list = null;
|
||||
|
||||
const flushPara = () => { if (para.length) { out.push(`<p>${inline(para.join(' '))}</p>`); para = []; } };
|
||||
const flushList = () => { if (list) { out.push(`<ul>${list.join('')}</ul>`); list = null; } };
|
||||
|
||||
for (const raw of lines) {
|
||||
const line = raw.trim();
|
||||
if (!line) { flushPara(); flushList(); continue; }
|
||||
|
||||
const h = line.match(/^(#{1,3})\s+(.*)$/);
|
||||
if (h) { flushPara(); flushList(); const n = h[1].length + 1; out.push(`<h${n}>${inline(h[2])}</h${n}>`); continue; }
|
||||
|
||||
const li = line.match(/^[-*]\s+(.*)$/);
|
||||
if (li) { flushPara(); (list ||= []).push(`<li>${inline(li[1])}</li>`); continue; }
|
||||
|
||||
flushList();
|
||||
para.push(line);
|
||||
}
|
||||
flushPara(); flushList();
|
||||
return out.join('\n');
|
||||
}
|
||||
|
||||
function inline(t) {
|
||||
return t
|
||||
.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
|
||||
.replace(/\*([^*]+)\*/g, '<em>$1</em>')
|
||||
// links: only http(s) and site-relative, so no javascript: URLs
|
||||
.replace(/\[([^\]]+)\]\((https?:\/\/[^\s)]+|\/[^\s)]*)\)/g,
|
||||
'<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>');
|
||||
}
|
||||
@@ -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`;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
/* set-footprints.js — LEGO Hidden Side set footprint presets for the layout editor.
|
||||
Units: centimetres (1 Three.js unit = 1 cm). width = X extent, depth = Z extent,
|
||||
height = Y extent. Footprint = the two horizontal axes of each build.
|
||||
Fan/tribute project; not affiliated with or endorsed by the LEGO Group.
|
||||
Sizes derived from Brickset-published model dimensions (brickset.com/sets/theme-Hidden-Side). */
|
||||
|
||||
export const SET_FOOTPRINTS = [
|
||||
{ setNumber: "70418", name: "J.B.'s Ghost Lab", width: 13, depth: 18, height: 9, year: 2019 },
|
||||
{ setNumber: "70420", name: "Graveyard Mystery", width: 14, depth: 32, height: 10, year: 2019 },
|
||||
{ setNumber: "70421", name: "El Fuego's Stunt Truck", width: 10, depth: 10, height: 17, year: 2019 },
|
||||
{ setNumber: "70422", name: "Shrimp Shack Attack", width: 19, depth: 32, height: 10, year: 2019 },
|
||||
{ setNumber: "70424", name: "Ghost Train Express", width: 14, depth: 61, height: 15, year: 2019 },
|
||||
{ setNumber: "70425", name: "Newbury Haunted High School", width: 30, depth: 43, height: 26, year: 2019 },
|
||||
{ setNumber: "70427", name: "Welcome to the Hidden Side", width: 17, depth: 22, height: 13, year: 2020 },
|
||||
{ setNumber: "70428", name: "Jack's Beach Buggy", width: 10, depth: 11, height: 8, year: 2020 },
|
||||
{ setNumber: "70429", name: "El Fuego's Stunt Plane", width: 22, depth: 22, height: 8, year: 2020 },
|
||||
{ setNumber: "70430", name: "Newbury Subway", width: 15, depth: 26, height: 14, year: 2020 },
|
||||
{ setNumber: "70431", name: "The Lighthouse of Darkness", width: 29, depth: 22, height: 18, year: 2020 },
|
||||
{ setNumber: "70432", name: "Haunted Fairground", width: 28, depth: 35, height: 27, year: 2020 },
|
||||
{ setNumber: "70433", name: "J.B.'s Submarine", width: 21, depth: 19, height: 10, year: 2020 },
|
||||
{ setNumber: "70434", name: "Supernatural Race Car", width: 19, depth: 11, height: 7, year: 2020 },
|
||||
{ setNumber: "70435", name: "Newbury Abandoned Prison", width: 19, depth: 30, height: 15, year: 2020 },
|
||||
{ setNumber: "70437", name: "Mystery Castle (closed)", width: 34, depth: 31, height: 27, year: 2020 },
|
||||
{ setNumber: "70437o", name: "Mystery Castle (open)", width: 45.5, depth: 26, height: 27, year: 2020 }
|
||||
];
|
||||
|
||||
export function getFootprint(setNumber) {
|
||||
return SET_FOOTPRINTS.find(s => s.setNumber === setNumber) || null;
|
||||
}
|
||||
|
||||
export function footprintLabel(fp) {
|
||||
return `${fp.setNumber} — ${fp.name} (${fp.width} × ${fp.depth} cm)`;
|
||||
}
|
||||
|
||||
/* Turn a preset into a building record the layout editor already understands.
|
||||
Buildings store size:[w,h,d]; footprint width→X, height→Y, depth→Z. */
|
||||
export function footprintToBuilding(fp, at = [0, 0, 0]) {
|
||||
return {
|
||||
name: `${fp.setNumber} ${fp.name}`,
|
||||
setNumber: fp.setNumber,
|
||||
position: [at[0], at[1], at[2]],
|
||||
size: [fp.width, fp.height, fp.depth],
|
||||
yawDeg: 0
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user