761 lines
37 KiB
JavaScript
761 lines
37 KiB
JavaScript
/* Newbury Exhibit — scene workshop (admin editor)
|
||
*
|
||
* A 3D authoring GUI over data/scene.json. Edit the table, anchors (AR crests),
|
||
* ghost spawns + their motion (hover / patrol path), and building blockers
|
||
* (stackable cubes used later for occlusion). Save writes back to the server,
|
||
* which backs up the old file and hot-reloads so live viewers update.
|
||
*
|
||
* Coordinates: world millimetres, origin at the front-left table corner.
|
||
* X = length (1500), Z = depth (700), Y = height. Three.js uses metres (×0.001).
|
||
*/
|
||
import * as THREE from 'three';
|
||
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
|
||
|
||
const MM = 0.001;
|
||
const LURE = {
|
||
Red: { top: 0xf65151, bottom: 0xff2678 },
|
||
Yellow: { top: 0xb57f0b, bottom: 0xfff35d },
|
||
Blue: { top: 0x529eff, bottom: 0x51eaf1 },
|
||
};
|
||
const COL = { anchor: 0x00e5c0, blocker: 0xc98a4b, sel: 0xffd24a };
|
||
|
||
// ---- editable state (the scene we mutate, then POST) ----------------------
|
||
let scene = null;
|
||
let roster = []; // ghost roster for the picker (from /api/ghosts)
|
||
let rosterById = new Map();
|
||
let dirty = false;
|
||
let selected = null; // { kind:'anchor'|'spawn'|'blocker', id }
|
||
let tool = 'select';
|
||
let nextIdN = 1;
|
||
|
||
// ---- three.js -------------------------------------------------------------
|
||
const canvas = document.getElementById('c');
|
||
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
|
||
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
|
||
|
||
const view3d = new THREE.Scene();
|
||
view3d.background = new THREE.Color(0x070910);
|
||
|
||
const camera = new THREE.PerspectiveCamera(50, 1, 0.01, 200);
|
||
camera.position.set(0.9, 1.1, 1.7);
|
||
|
||
const controls = new OrbitControls(camera, canvas);
|
||
controls.enableDamping = true;
|
||
|
||
view3d.add(new THREE.AmbientLight(0x8090b0, 0.9));
|
||
const key = new THREE.DirectionalLight(0xffffff, 1.0);
|
||
key.position.set(2, 3, 1.5); view3d.add(key);
|
||
|
||
// groups so we can rebuild each kind independently
|
||
const gTable = new THREE.Group(); view3d.add(gTable);
|
||
const gAnchors = new THREE.Group(); view3d.add(gAnchors);
|
||
const gSpawns = new THREE.Group(); view3d.add(gSpawns);
|
||
const gBlockers = new THREE.Group(); view3d.add(gBlockers);
|
||
const gPaths = new THREE.Group(); view3d.add(gPaths);
|
||
|
||
const raycaster = new THREE.Raycaster();
|
||
const pointer = new THREE.Vector2();
|
||
let tablePlane = null; // THREE.Plane at Y=0 for drag math
|
||
|
||
// map THREE object.uuid -> { kind, id } for picking
|
||
const pickMap = new Map();
|
||
|
||
// ---- helpers --------------------------------------------------------------
|
||
const $ = (id) => document.getElementById(id);
|
||
const mm2m = (v) => v * MM;
|
||
const v3 = (p) => new THREE.Vector3(p.x * MM, p.y * MM, p.z * MM);
|
||
function uid(prefix) { return `${prefix}-${Date.now().toString(36)}-${(nextIdN++).toString(36)}`; }
|
||
function markDirty() { dirty = true; $('dirty').classList.add('show'); $('saveBtn').disabled = false; }
|
||
|
||
function toast(msg, kind = '') {
|
||
const t = $('toast'); t.textContent = msg; t.className = 'show ' + kind;
|
||
clearTimeout(t._h); t._h = setTimeout(() => (t.className = ''), 2600);
|
||
}
|
||
|
||
// ===========================================================================
|
||
// LOAD
|
||
// ===========================================================================
|
||
async function load() {
|
||
const res = await fetch('/api/scene');
|
||
scene = await res.json();
|
||
// load roster once (optional; picker falls back to free text if missing)
|
||
if (!roster.length) {
|
||
try {
|
||
const rr = await fetch('/api/ghosts');
|
||
roster = await rr.json();
|
||
rosterById = new Map(roster.map((g) => [g.id, g]));
|
||
} catch (_) { roster = []; }
|
||
}
|
||
if (!scene.blockers) scene.blockers = [];
|
||
if (!scene.world.buildSize) scene.world.buildSize = { x: 1500, y: 600, z: 700 };
|
||
rebuildAll();
|
||
centerCamera();
|
||
renderPanels();
|
||
updateCounts();
|
||
$('worldMeta').textContent =
|
||
`${scene.world.buildSize.x}×${scene.world.buildSize.z}mm · ${scene.anchors.length} anchors · ${scene.spawns.length} ghosts · ${scene.blockers.length} buildings`;
|
||
}
|
||
|
||
function centerCamera() {
|
||
const b = scene.world.buildSize;
|
||
controls.target.set(b.x * MM / 2, 0.1, b.z * MM / 2);
|
||
controls.update();
|
||
}
|
||
|
||
// ===========================================================================
|
||
// REBUILD 3D
|
||
// ===========================================================================
|
||
function rebuildAll() { rebuildTable(); rebuildAnchors(); rebuildSpawns(); rebuildBlockers(); }
|
||
|
||
function clearGroup(g) {
|
||
pickMapPrune(g);
|
||
while (g.children.length) {
|
||
const c = g.children.pop();
|
||
c.geometry?.dispose?.();
|
||
if (Array.isArray(c.material)) c.material.forEach((m) => m.dispose?.());
|
||
else c.material?.dispose?.();
|
||
}
|
||
}
|
||
function pickMapPrune(g) {
|
||
g.traverse((o) => pickMap.delete(o.uuid));
|
||
}
|
||
|
||
function rebuildTable() {
|
||
clearGroup(gTable);
|
||
const b = scene.world.buildSize;
|
||
const w = b.x * MM, d = b.z * MM;
|
||
tablePlane = new THREE.Plane(new THREE.Vector3(0, 1, 0), 0);
|
||
|
||
const top = new THREE.Mesh(
|
||
new THREE.PlaneGeometry(w, d),
|
||
new THREE.MeshStandardMaterial({ color: 0x10131f, roughness: 0.95, side: THREE.DoubleSide })
|
||
);
|
||
top.rotation.x = -Math.PI / 2;
|
||
top.position.set(w / 2, 0, d / 2);
|
||
gTable.add(top);
|
||
|
||
// grid every 100mm
|
||
const grid = new THREE.GridHelper(Math.max(w, d), Math.round(Math.max(b.x, b.z) / 100), 0x2a3450, 0x1a2238);
|
||
grid.position.set(w / 2, 0.0005, d / 2);
|
||
// clip grid to table by scaling (approx): leave as square underlay
|
||
gTable.add(grid);
|
||
|
||
// origin corner marker + axes (X red-ish along length, Z blue along depth)
|
||
const axisLen = 0.12;
|
||
gTable.add(axisLine(0, 0, 0, axisLen, 0, 0, 0xff6b6b)); // X
|
||
gTable.add(axisLine(0, 0, 0, 0, 0, axisLen, 0x6b8cff)); // Z
|
||
const corner = new THREE.Mesh(new THREE.SphereGeometry(0.01, 12, 12),
|
||
new THREE.MeshBasicMaterial({ color: 0xffffff }));
|
||
gTable.add(corner);
|
||
}
|
||
function axisLine(x1, y1, z1, x2, y2, z2, color) {
|
||
const g = new THREE.BufferGeometry().setFromPoints([new THREE.Vector3(x1, y1, z1), new THREE.Vector3(x2, y2, z2)]);
|
||
return new THREE.Line(g, new THREE.LineBasicMaterial({ color }));
|
||
}
|
||
|
||
function rebuildAnchors() {
|
||
clearGroup(gAnchors);
|
||
for (const a of scene.anchors) {
|
||
const s = (a.fiducialSizeMm || 40) * MM;
|
||
const isSel = selected?.kind === 'anchor' && selected.id === a.id;
|
||
const mesh = new THREE.Mesh(
|
||
new THREE.BoxGeometry(s, s * 0.12, s),
|
||
new THREE.MeshStandardMaterial({
|
||
color: isSel ? COL.sel : COL.anchor, emissive: isSel ? COL.sel : COL.anchor,
|
||
emissiveIntensity: 0.45,
|
||
})
|
||
);
|
||
mesh.position.copy(v3(a.position));
|
||
mesh.rotation.y = THREE.MathUtils.degToRad(a.rotationDeg?.y || 0);
|
||
gAnchors.add(mesh);
|
||
pickMap.set(mesh.uuid, { kind: 'anchor', id: a.id });
|
||
// little post so it reads as a plaque on a stand
|
||
const post = new THREE.Mesh(new THREE.CylinderGeometry(0.004, 0.004, a.position.y * MM, 6),
|
||
new THREE.MeshStandardMaterial({ color: 0x2a3450 }));
|
||
post.position.set(a.position.x * MM, (a.position.y * MM) / 2, a.position.z * MM);
|
||
gAnchors.add(post);
|
||
}
|
||
}
|
||
|
||
function rebuildSpawns() {
|
||
clearGroup(gSpawns); clearGroup(gPaths);
|
||
for (const sp of scene.spawns) {
|
||
const isSel = selected?.kind === 'spawn' && selected.id === sp.id;
|
||
const lure = LURE[sp.color] || LURE.Blue;
|
||
const mat = new THREE.MeshStandardMaterial({
|
||
color: isSel ? COL.sel : lure.bottom, emissive: lure.top, emissiveIntensity: 0.6,
|
||
transparent: true, opacity: 0.9, roughness: 0.4,
|
||
});
|
||
const mesh = new THREE.Mesh(new THREE.IcosahedronGeometry(0.045, 1), mat);
|
||
const home = sp.motion === 'patrol' ? (sp.path?.[0] || { x: 0, y: 0, z: 0 }) : sp.position;
|
||
mesh.position.copy(v3(home));
|
||
gSpawns.add(mesh);
|
||
pickMap.set(mesh.uuid, { kind: 'spawn', id: sp.id });
|
||
|
||
if (sp.motion === 'patrol' && sp.path?.length > 1) {
|
||
const pts = sp.path.map(v3); pts.push(pts[0].clone());
|
||
const line = new THREE.Line(
|
||
new THREE.BufferGeometry().setFromPoints(pts),
|
||
new THREE.LineBasicMaterial({ color: isSel ? COL.sel : 0x51eaf1, transparent: true, opacity: 0.6 })
|
||
);
|
||
gPaths.add(line);
|
||
sp.path.forEach((p, i) => {
|
||
const node = new THREE.Mesh(new THREE.SphereGeometry(0.012, 10, 10),
|
||
new THREE.MeshBasicMaterial({ color: i === 0 ? 0xffffff : 0x51eaf1 }));
|
||
node.position.copy(v3(p));
|
||
gPaths.add(node);
|
||
pickMap.set(node.uuid, { kind: 'pathpt', id: sp.id, index: i });
|
||
});
|
||
} else if (sp.motion === 'hover' && sp.hoverRadiusMm) {
|
||
// hover ring
|
||
const r = sp.hoverRadiusMm * MM;
|
||
const ring = new THREE.Mesh(
|
||
new THREE.RingGeometry(r * 0.96, r, 32),
|
||
new THREE.MeshBasicMaterial({ color: 0x51eaf1, transparent: true, opacity: 0.3, side: THREE.DoubleSide })
|
||
);
|
||
ring.rotation.x = -Math.PI / 2;
|
||
ring.position.copy(v3(sp.position));
|
||
gPaths.add(ring);
|
||
}
|
||
}
|
||
}
|
||
|
||
function rebuildBlockers() {
|
||
clearGroup(gBlockers);
|
||
for (const bl of scene.blockers) {
|
||
const isSel = selected?.kind === 'blocker' && selected.id === bl.id;
|
||
const sz = bl.size;
|
||
const mesh = new THREE.Mesh(
|
||
new THREE.BoxGeometry(sz.x * MM, sz.y * MM, sz.z * MM),
|
||
new THREE.MeshStandardMaterial({
|
||
color: isSel ? COL.sel : COL.blocker, transparent: true, opacity: isSel ? 0.55 : 0.4,
|
||
roughness: 0.8,
|
||
})
|
||
);
|
||
// position is the CENTRE of the box base footprint; Y is base height (usually 0)
|
||
mesh.position.set(
|
||
(bl.position.x + sz.x / 2) * MM,
|
||
(bl.position.y + sz.y / 2) * MM,
|
||
(bl.position.z + sz.z / 2) * MM
|
||
);
|
||
gBlockers.add(mesh);
|
||
pickMap.set(mesh.uuid, { kind: 'blocker', id: bl.id });
|
||
// wireframe edge for clarity
|
||
const edges = new THREE.LineSegments(
|
||
new THREE.EdgesGeometry(mesh.geometry),
|
||
new THREE.LineBasicMaterial({ color: isSel ? COL.sel : 0xe0a868, transparent: true, opacity: 0.7 })
|
||
);
|
||
edges.position.copy(mesh.position);
|
||
gBlockers.add(edges);
|
||
}
|
||
}
|
||
|
||
// ===========================================================================
|
||
// PICKING + DRAG
|
||
// ===========================================================================
|
||
let dragging = null; // { kind, id, index?, offset:Vector3 }
|
||
|
||
function setPointer(e) {
|
||
const r = canvas.getBoundingClientRect();
|
||
pointer.x = ((e.clientX - r.left) / r.width) * 2 - 1;
|
||
pointer.y = -((e.clientY - r.top) / r.height) * 2 + 1;
|
||
}
|
||
|
||
function intersectTable() {
|
||
raycaster.setFromCamera(pointer, camera);
|
||
const hit = new THREE.Vector3();
|
||
raycaster.ray.intersectPlane(tablePlane, hit);
|
||
return hit; // metres
|
||
}
|
||
|
||
function pickObject() {
|
||
raycaster.setFromCamera(pointer, camera);
|
||
const hits = raycaster.intersectObjects(
|
||
[...gAnchors.children, ...gSpawns.children, ...gBlockers.children, ...gPaths.children], false);
|
||
for (const h of hits) {
|
||
const ref = pickMap.get(h.object.uuid);
|
||
if (ref) return ref;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
canvas.addEventListener('pointerdown', (e) => {
|
||
setPointer(e);
|
||
// tool-based add
|
||
if (tool !== 'select') { addAtCursor(); return; }
|
||
const ref = pickObject();
|
||
if (ref) {
|
||
if (ref.kind === 'pathpt') {
|
||
selectEntity('spawn', ref.id);
|
||
dragging = { kind: 'pathpt', id: ref.id, index: ref.index };
|
||
} else {
|
||
selectEntity(ref.kind, ref.id);
|
||
dragging = { kind: ref.kind, id: ref.id };
|
||
}
|
||
controls.enabled = false;
|
||
}
|
||
});
|
||
|
||
canvas.addEventListener('pointermove', (e) => {
|
||
setPointer(e);
|
||
const hit = intersectTable();
|
||
if (hit) $('cursorPos').textContent = `${Math.round(hit.x / MM)}, ${Math.round(hit.z / MM)} mm`;
|
||
if (!dragging || !hit) return;
|
||
const x = Math.round(hit.x / MM), z = Math.round(hit.z / MM);
|
||
applyDrag(dragging, x, z);
|
||
});
|
||
|
||
window.addEventListener('pointerup', () => {
|
||
if (dragging) { dragging = null; controls.enabled = true; renderPanels(); }
|
||
});
|
||
|
||
function applyDrag(d, x, z) {
|
||
if (d.kind === 'anchor') {
|
||
const a = scene.anchors.find((o) => o.id === d.id);
|
||
a.position.x = clampX(x); a.position.z = clampZ(z); rebuildAnchors();
|
||
} else if (d.kind === 'blocker') {
|
||
const bl = scene.blockers.find((o) => o.id === d.id);
|
||
bl.position.x = clampX(x - bl.size.x / 2); bl.position.z = clampZ(z - bl.size.z / 2); rebuildBlockers();
|
||
} else if (d.kind === 'spawn') {
|
||
const sp = scene.spawns.find((o) => o.id === d.id);
|
||
if (sp.motion === 'hover') { sp.position.x = x; sp.position.z = z; }
|
||
else if (sp.path?.length) { const dx = x - sp.path[0].x, dz = z - sp.path[0].z;
|
||
sp.path.forEach((p) => { p.x += dx; p.z += dz; }); }
|
||
rebuildSpawns();
|
||
} else if (d.kind === 'pathpt') {
|
||
const sp = scene.spawns.find((o) => o.id === d.id);
|
||
sp.path[d.index].x = x; sp.path[d.index].z = z; rebuildSpawns();
|
||
}
|
||
markDirty();
|
||
}
|
||
function clampX(x) { return Math.max(0, Math.min(scene.world.buildSize.x, x)); }
|
||
function clampZ(z) { return Math.max(0, Math.min(scene.world.buildSize.z, z)); }
|
||
|
||
function addAtCursor() {
|
||
const hit = intersectTable(); if (!hit) return;
|
||
const x = Math.round(clampX(hit.x / MM)), z = Math.round(clampZ(hit.z / MM));
|
||
if (tool === 'addSpawn') {
|
||
const sp = { id: uid('spawn'), ghostId: '', color: 'Blue', label: 'New ghost', motion: 'hover',
|
||
position: { x, y: 180, z }, hoverRadiusMm: 50, hoverPeriodS: 6, bobAmplitudeMm: 30, bobPeriodS: 3, yawDegPerS: 10 };
|
||
scene.spawns.push(sp); rebuildSpawns(); selectEntity('spawn', sp.id); setTool('select');
|
||
} else if (tool === 'addBlocker') {
|
||
const bl = { id: uid('blk'), label: 'Building', position: { x, y: 0, z }, size: { x: 120, y: 100, z: 120 } };
|
||
scene.blockers.push(bl); rebuildBlockers(); selectEntity('blocker', bl.id); setTool('select');
|
||
} else if (tool === 'addAnchor') {
|
||
const n = scene.anchors.length;
|
||
const a = { id: uid('anchor'), label: `Anchor ${n + 1}`, fiducialType: 'aruco', fiducialId: n,
|
||
fiducialSizeMm: 96, position: { x, y: 30, z }, rotationDeg: { x: 0, y: 0, z: 0 } };
|
||
scene.anchors.push(a); rebuildAnchors(); selectEntity('anchor', a.id); setTool('select');
|
||
}
|
||
markDirty(); updateCounts();
|
||
}
|
||
|
||
// ===========================================================================
|
||
// SELECTION + PANELS
|
||
// ===========================================================================
|
||
function selectEntity(kind, id) {
|
||
selected = { kind, id };
|
||
rebuildAnchors(); rebuildSpawns(); rebuildBlockers();
|
||
// switch to the relevant tab
|
||
switchTab(kind === 'anchor' ? 'anchors' : kind === 'spawn' ? 'spawns' : 'blockers');
|
||
renderPanels();
|
||
const obj = kind === 'anchor' ? scene.anchors.find((o) => o.id === id)
|
||
: kind === 'spawn' ? scene.spawns.find((o) => o.id === id)
|
||
: scene.blockers.find((o) => o.id === id);
|
||
$('selInfo').innerHTML = obj ? `${kind}: <b>${obj.label || obj.id}</b>` : 'nothing selected';
|
||
}
|
||
|
||
function updateCounts() {
|
||
$('nAnchors').textContent = scene.anchors.length;
|
||
$('nSpawns').textContent = scene.spawns.length;
|
||
$('nBlockers').textContent = scene.blockers.length;
|
||
}
|
||
|
||
// ---- panel rendering ------------------------------------------------------
|
||
function renderPanels() { renderWorld(); renderAnchors(); renderSpawns(); renderBlockers(); }
|
||
|
||
function numRow(label, value, oninput, opts = {}) {
|
||
const wrap = document.createElement('div'); wrap.className = 'row';
|
||
const l = document.createElement('label'); l.textContent = label; wrap.appendChild(l);
|
||
const i = document.createElement('input'); i.type = 'number'; i.value = value;
|
||
if (opts.step) i.step = opts.step; if (opts.min != null) i.min = opts.min;
|
||
i.addEventListener('input', () => oninput(parseFloat(i.value)));
|
||
wrap.appendChild(i); return wrap;
|
||
}
|
||
function xyzRow(label, obj, keys, onchange, opts = {}) {
|
||
const wrap = document.createElement('div'); wrap.className = 'row';
|
||
const l = document.createElement('label'); l.textContent = label; wrap.appendChild(l);
|
||
const box = document.createElement('div'); box.className = 'xyz';
|
||
for (const k of keys) {
|
||
const ax = document.createElement('div'); ax.className = 'axis'; ax.dataset.a = k;
|
||
const i = document.createElement('input'); i.type = 'number'; i.value = Math.round(obj[k]);
|
||
if (opts.step) i.step = opts.step;
|
||
i.addEventListener('input', () => { obj[k] = parseFloat(i.value) || 0; onchange(); });
|
||
ax.appendChild(i); box.appendChild(ax);
|
||
}
|
||
wrap.appendChild(box); return wrap;
|
||
}
|
||
function textRow(label, value, oninput) {
|
||
const wrap = document.createElement('div'); wrap.className = 'row';
|
||
const l = document.createElement('label'); l.textContent = label; wrap.appendChild(l);
|
||
const i = document.createElement('input'); i.type = 'text'; i.value = value || '';
|
||
i.addEventListener('input', () => oninput(i.value));
|
||
wrap.appendChild(i); return wrap;
|
||
}
|
||
function selectRow(label, value, options, oninput) {
|
||
const wrap = document.createElement('div'); wrap.className = 'row';
|
||
const l = document.createElement('label'); l.textContent = label; wrap.appendChild(l);
|
||
const s = document.createElement('select');
|
||
for (const o of options) { const op = document.createElement('option'); op.value = o; op.textContent = o; if (o === value) op.selected = true; s.appendChild(op); }
|
||
s.addEventListener('change', () => oninput(s.value));
|
||
wrap.appendChild(s); return wrap;
|
||
}
|
||
|
||
function renderWorld() {
|
||
const p = $('panel-world'); p.innerHTML = '';
|
||
const h = document.createElement('div'); h.className = 'section-h'; h.textContent = 'Table dimensions (mm)'; p.appendChild(h);
|
||
p.appendChild(xyzRow('Build size', scene.world.buildSize, ['x', 'y', 'z'], () => { rebuildTable(); centerCamera(); markDirty(); updateMeta(); }));
|
||
const hint = document.createElement('div'); hint.className = 'hint';
|
||
hint.textContent = 'X = length, Z = depth, Y = max ghost height. Max display 700 × 1500 mm. Origin is the front-left corner; all positions measured from there.';
|
||
p.appendChild(hint);
|
||
|
||
const h2 = document.createElement('div'); h2.className = 'section-h'; h2.textContent = 'Origin'; p.appendChild(h2);
|
||
const o = document.createElement('div'); o.className = 'hint';
|
||
o.textContent = scene.world.origin || 'front-left corner of baseplate, table surface = Y0';
|
||
p.appendChild(o);
|
||
}
|
||
function updateMeta() {
|
||
$('worldMeta').textContent =
|
||
`${scene.world.buildSize.x}×${scene.world.buildSize.z}mm · ${scene.anchors.length} anchors · ${scene.spawns.length} ghosts · ${scene.blockers.length} buildings`;
|
||
}
|
||
|
||
function listItem({ dot, name, tag, sub, sel, onclick, ondel }) {
|
||
const el = document.createElement('div'); el.className = 'list-item' + (sel ? ' sel' : '');
|
||
const top = document.createElement('div'); top.className = 'li-top';
|
||
if (dot) { const d = document.createElement('span'); d.className = 'li-dot'; d.style.background = dot; top.appendChild(d); }
|
||
const nm = document.createElement('span'); nm.className = 'li-name'; nm.textContent = name; top.appendChild(nm);
|
||
if (tag) { const t = document.createElement('span'); t.className = 'li-tag'; t.textContent = tag; top.appendChild(t); }
|
||
el.appendChild(top);
|
||
if (sub) { const s = document.createElement('div'); s.className = 'li-sub'; s.textContent = sub; el.appendChild(s); }
|
||
el.addEventListener('click', onclick);
|
||
return el;
|
||
}
|
||
|
||
function hexCss(n) { return '#' + n.toString(16).padStart(6, '0'); }
|
||
|
||
function renderAnchors() {
|
||
const p = $('panel-anchors'); p.innerHTML = '';
|
||
const add = document.createElement('button'); add.className = 'btn-add'; add.textContent = '+ Add anchor (or click ◎ then the table)';
|
||
add.addEventListener('click', () => { setTool('addAnchor'); toast('Click on the table to place the anchor'); });
|
||
p.appendChild(add);
|
||
if (!scene.anchors.length) { const e = document.createElement('div'); e.className = 'empty'; e.textContent = 'No anchors yet.'; p.appendChild(e); return; }
|
||
|
||
for (const a of scene.anchors) {
|
||
const sel = selected?.kind === 'anchor' && selected.id === a.id;
|
||
p.appendChild(listItem({
|
||
dot: hexCss(COL.anchor), name: a.label || a.id, tag: `id ${a.fiducialId}`,
|
||
sub: `${Math.round(a.position.x)}, ${Math.round(a.position.z)} mm · ${a.fiducialSizeMm}mm`,
|
||
sel, onclick: () => selectEntity('anchor', a.id),
|
||
}));
|
||
if (sel) p.appendChild(anchorEditor(a));
|
||
}
|
||
}
|
||
function anchorEditor(a) {
|
||
const box = document.createElement('div'); box.style.cssText = 'padding:4px 2px 12px';
|
||
box.appendChild(textRow('Label', a.label, (v) => { a.label = v; markDirty(); renderAnchorsSoft(); }));
|
||
box.appendChild(selectRow('Marker', a.fiducialType || 'aruco', ['aruco', 'apriltag'], (v) => { a.fiducialType = v; markDirty(); }));
|
||
box.appendChild(selectRow('Mount', a.mount || 'flat', ['flat', 'wall'], (v) => { a.mount = v; markDirty(); }));
|
||
box.appendChild(numRow('Marker id', a.fiducialId, (v) => { a.fiducialId = v | 0; markDirty(); renderAnchorsSoft(); }, { step: 1, min: 0 }));
|
||
box.appendChild(numRow('Size (mm)', a.fiducialSizeMm, (v) => { a.fiducialSizeMm = v; rebuildAnchors(); markDirty(); }, { step: 1 }));
|
||
box.appendChild(xyzRow('Pos (mm)', a.position, ['x', 'y', 'z'], () => { rebuildAnchors(); markDirty(); }));
|
||
box.appendChild(numRow('Yaw (°)', a.rotationDeg?.y || 0, (v) => { a.rotationDeg = a.rotationDeg || { x: 0, y: 0, z: 0 }; a.rotationDeg.y = v; rebuildAnchors(); markDirty(); }, { step: 1 }));
|
||
const del = document.createElement('button'); del.className = 'btn-del'; del.textContent = 'Delete anchor';
|
||
del.style.marginTop = '6px';
|
||
del.addEventListener('click', () => { scene.anchors = scene.anchors.filter((o) => o.id !== a.id); selected = null; rebuildAnchors(); renderAnchors(); updateCounts(); markDirty(); });
|
||
box.appendChild(del);
|
||
return box;
|
||
}
|
||
function renderAnchorsSoft() { updateCounts(); updateMeta(); }
|
||
|
||
function renderSpawns() {
|
||
const p = $('panel-spawns'); p.innerHTML = '';
|
||
const add = document.createElement('button'); add.className = 'btn-add'; add.textContent = '+ Add ghost (or click 👻 then the table)';
|
||
add.addEventListener('click', () => { setTool('addSpawn'); toast('Click on the table to place the ghost'); });
|
||
p.appendChild(add);
|
||
if (!scene.spawns.length) { const e = document.createElement('div'); e.className = 'empty'; e.textContent = 'No ghosts yet.'; p.appendChild(e); return; }
|
||
|
||
for (const sp of scene.spawns) {
|
||
const sel = selected?.kind === 'spawn' && selected.id === sp.id;
|
||
const lure = LURE[sp.color] || LURE.Blue;
|
||
p.appendChild(listItem({
|
||
dot: hexCss(lure.bottom), name: sp.label || sp.id, tag: sp.motion,
|
||
sub: sp.ghostId ? sp.ghostId : '(no ghost id)',
|
||
sel, onclick: () => selectEntity('spawn', sp.id),
|
||
}));
|
||
if (sel) p.appendChild(spawnEditor(sp));
|
||
}
|
||
}
|
||
// Searchable ghost picker: type to filter 111 ghosts, pick one -> auto-fills
|
||
// ghostId + lure colour + boss flag. Falls back to free text if no roster.
|
||
function ghostPicker(sp, onPick) {
|
||
const wrap = document.createElement('div'); wrap.className = 'row'; wrap.style.position = 'relative';
|
||
const l = document.createElement('label'); l.textContent = 'Ghost'; wrap.appendChild(l);
|
||
|
||
const field = document.createElement('div'); field.style.cssText = 'flex:1;min-width:0;position:relative;';
|
||
const input = document.createElement('input'); input.type = 'text'; input.placeholder = 'search ghosts…';
|
||
const cur = rosterById.get(sp.ghostId);
|
||
input.value = cur ? cur.name : (sp.ghostId || '');
|
||
input.style.cssText = 'width:100%;background:var(--panel2);border:1px solid var(--line);border-radius:6px;padding:6px 8px;font-size:12px;font-family:var(--mono);';
|
||
field.appendChild(input);
|
||
|
||
if (!roster.length) {
|
||
// no roster available — behave as plain free-text id
|
||
input.addEventListener('input', () => { sp.ghostId = input.value; markDirty(); });
|
||
wrap.appendChild(field); return wrap;
|
||
}
|
||
|
||
const menu = document.createElement('div');
|
||
menu.style.cssText = 'position:absolute;left:0;right:0;top:calc(100% + 3px);z-index:20;max-height:230px;overflow-y:auto;background:var(--panel);border:1px solid var(--line);border-radius:8px;box-shadow:0 12px 30px rgba(0,0,0,0.5);display:none;';
|
||
field.appendChild(menu);
|
||
|
||
const rarityDot = { Common: '#7f8aa3', Rare: '#5ad1ff', Epic: '#c07bff', Legendary: '#ffb038' };
|
||
function render(filter) {
|
||
const q = filter.trim().toLowerCase();
|
||
const matches = roster.filter((g) => !q || g.name.toLowerCase().includes(q) || g.id.includes(q) || g.color.toLowerCase().startsWith(q)).slice(0, 40);
|
||
menu.innerHTML = '';
|
||
if (!matches.length) { menu.style.display = 'none'; return; }
|
||
for (const g of matches) {
|
||
const it = document.createElement('div');
|
||
it.style.cssText = 'display:flex;align-items:center;gap:8px;padding:7px 9px;cursor:pointer;font-size:12px;border-bottom:1px solid rgba(255,255,255,0.03);';
|
||
it.onmouseenter = () => (it.style.background = 'rgba(255,255,255,0.05)');
|
||
it.onmouseleave = () => (it.style.background = 'transparent');
|
||
const cdot = document.createElement('span'); cdot.style.cssText = `width:9px;height:9px;border-radius:50%;flex-shrink:0;background:${hexCss(LURE[g.color].bottom)};`;
|
||
const nm = document.createElement('span'); nm.textContent = g.name; nm.style.cssText = 'flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;';
|
||
const meta = document.createElement('span'); meta.style.cssText = `font-family:var(--mono);font-size:10px;color:${rarityDot[g.rarity] || 'var(--dim)'};`;
|
||
meta.textContent = (g.isBoss ? '★ ' : '') + g.rarity;
|
||
it.append(cdot, nm, meta);
|
||
it.addEventListener('mousedown', (e) => { // mousedown so it fires before blur
|
||
e.preventDefault();
|
||
sp.ghostId = g.id; sp.color = g.color;
|
||
input.value = g.name; menu.style.display = 'none';
|
||
onPick(g); markDirty();
|
||
});
|
||
menu.appendChild(it);
|
||
}
|
||
menu.style.display = 'block';
|
||
}
|
||
input.addEventListener('focus', () => render(''));
|
||
input.addEventListener('input', () => { sp.ghostId = input.value; render(input.value); });
|
||
input.addEventListener('blur', () => setTimeout(() => (menu.style.display = 'none'), 120));
|
||
|
||
wrap.appendChild(field); return wrap;
|
||
}
|
||
|
||
function spawnEditor(sp) {
|
||
const box = document.createElement('div'); box.style.cssText = 'padding:4px 2px 12px';
|
||
box.appendChild(textRow('Label', sp.label, (v) => { sp.label = v; markDirty(); }));
|
||
box.appendChild(ghostPicker(sp, (g) => {
|
||
// auto-fill: colour follows ghost; label defaults to ghost name if blank/placeholder
|
||
if (!sp.label || sp.label === 'New ghost') sp.label = g.name;
|
||
rebuildSpawns(); renderSpawns();
|
||
}));
|
||
|
||
// colour swatches
|
||
const cr = document.createElement('div'); cr.className = 'row';
|
||
const cl = document.createElement('label'); cl.textContent = 'Lure'; cr.appendChild(cl);
|
||
const sws = document.createElement('div'); sws.className = 'swatches';
|
||
for (const key of ['Red', 'Yellow', 'Blue']) {
|
||
const s = document.createElement('div'); s.className = 'sw' + (sp.color === key ? ' sel' : '');
|
||
s.style.background = hexCss(LURE[key].bottom);
|
||
s.addEventListener('click', () => { sp.color = key; rebuildSpawns(); renderSpawns(); markDirty(); });
|
||
sws.appendChild(s);
|
||
}
|
||
cr.appendChild(sws); box.appendChild(cr);
|
||
|
||
box.appendChild(selectRow('Motion', sp.motion, ['hover', 'patrol'], (v) => {
|
||
sp.motion = v;
|
||
if (v === 'patrol' && !sp.path) {
|
||
const base = sp.position || { x: 200, y: 200, z: 200 };
|
||
sp.path = [{ ...base }, { x: base.x + 200, y: base.y, z: base.z }, { x: base.x + 200, y: base.y, z: base.z + 150 }];
|
||
sp.patrolPeriodS = sp.patrolPeriodS || 16; sp.faceTravel = sp.faceTravel ?? true;
|
||
}
|
||
if (v === 'hover' && !sp.position) {
|
||
sp.position = sp.path?.[0] ? { ...sp.path[0] } : { x: 200, y: 200, z: 200 };
|
||
sp.hoverRadiusMm = sp.hoverRadiusMm ?? 50; sp.hoverPeriodS = sp.hoverPeriodS ?? 6;
|
||
}
|
||
rebuildSpawns(); renderSpawns(); markDirty();
|
||
}));
|
||
|
||
if (sp.motion === 'hover') {
|
||
box.appendChild(xyzRow('Pos (mm)', sp.position, ['x', 'y', 'z'], () => { rebuildSpawns(); markDirty(); }));
|
||
box.appendChild(numRow('Hover r (mm)', sp.hoverRadiusMm ?? 0, (v) => { sp.hoverRadiusMm = v; rebuildSpawns(); markDirty(); }));
|
||
box.appendChild(numRow('Hover period s', sp.hoverPeriodS ?? 6, (v) => { sp.hoverPeriodS = v; markDirty(); }, { step: 0.5 }));
|
||
box.appendChild(numRow('Yaw °/s', sp.yawDegPerS ?? 0, (v) => { sp.yawDegPerS = v; markDirty(); }, { step: 1 }));
|
||
} else {
|
||
const h = document.createElement('div'); h.className = 'section-h'; h.textContent = 'Patrol path (mm)'; box.appendChild(h);
|
||
sp.path.forEach((pt, i) => {
|
||
const row = document.createElement('div'); row.className = 'pathpt';
|
||
const idx = document.createElement('span'); idx.className = 'idx'; idx.textContent = i; row.appendChild(idx);
|
||
const grp = xyzRow('', pt, ['x', 'y', 'z'], () => { rebuildSpawns(); markDirty(); });
|
||
grp.querySelector('label').remove(); grp.style.flex = '1'; grp.style.margin = '0';
|
||
row.appendChild(grp);
|
||
if (sp.path.length > 2) {
|
||
const rm = document.createElement('button'); rm.className = 'btn-sm'; rm.textContent = '×';
|
||
rm.addEventListener('click', () => { sp.path.splice(i, 1); rebuildSpawns(); renderSpawns(); markDirty(); });
|
||
row.appendChild(rm);
|
||
}
|
||
box.appendChild(row);
|
||
});
|
||
const addPt = document.createElement('button'); addPt.className = 'btn-sm'; addPt.textContent = '+ point';
|
||
addPt.addEventListener('click', () => { const last = sp.path[sp.path.length - 1]; sp.path.push({ x: last.x + 100, y: last.y, z: last.z }); rebuildSpawns(); renderSpawns(); markDirty(); });
|
||
box.appendChild(addPt);
|
||
box.appendChild(numRow('Loop period s', sp.patrolPeriodS ?? 16, (v) => { sp.patrolPeriodS = v; markDirty(); }, { step: 0.5 }));
|
||
box.appendChild(selectRow('Face travel', String(sp.faceTravel ?? true), ['true', 'false'], (v) => { sp.faceTravel = v === 'true'; markDirty(); }));
|
||
}
|
||
box.appendChild(numRow('Bob amp (mm)', sp.bobAmplitudeMm ?? 0, (v) => { sp.bobAmplitudeMm = v; markDirty(); }));
|
||
box.appendChild(numRow('Bob period s', sp.bobPeriodS ?? 3, (v) => { sp.bobPeriodS = v; markDirty(); }, { step: 0.5 }));
|
||
|
||
const del = document.createElement('button'); del.className = 'btn-del'; del.textContent = 'Delete ghost'; del.style.marginTop = '6px';
|
||
del.addEventListener('click', () => { scene.spawns = scene.spawns.filter((o) => o.id !== sp.id); selected = null; rebuildSpawns(); renderSpawns(); updateCounts(); markDirty(); });
|
||
box.appendChild(del);
|
||
return box;
|
||
}
|
||
|
||
function renderBlockers() {
|
||
const p = $('panel-blockers'); p.innerHTML = '';
|
||
const add = document.createElement('button'); add.className = 'btn-add'; add.textContent = '+ Add building (or click ▦ then the table)';
|
||
add.addEventListener('click', () => { setTool('addBlocker'); toast('Click on the table to place the building'); });
|
||
p.appendChild(add);
|
||
const hint = document.createElement('div'); hint.className = 'hint';
|
||
hint.textContent = 'Buildings are simple boxes used to hide ghosts behind them (occlusion). Stack or place several to approximate a structure. Position is the footprint corner; size is W×H×D.';
|
||
p.appendChild(hint);
|
||
if (!scene.blockers.length) { const e = document.createElement('div'); e.className = 'empty'; e.textContent = 'No buildings yet.'; p.appendChild(e); return; }
|
||
|
||
for (const bl of scene.blockers) {
|
||
const sel = selected?.kind === 'blocker' && selected.id === bl.id;
|
||
p.appendChild(listItem({
|
||
dot: hexCss(COL.blocker), name: bl.label || bl.id, tag: `${bl.size.x}×${bl.size.y}×${bl.size.z}`,
|
||
sub: `at ${Math.round(bl.position.x)}, ${Math.round(bl.position.z)} mm`,
|
||
sel, onclick: () => selectEntity('blocker', bl.id),
|
||
}));
|
||
if (sel) p.appendChild(blockerEditor(bl));
|
||
}
|
||
}
|
||
function blockerEditor(bl) {
|
||
const box = document.createElement('div'); box.style.cssText = 'padding:4px 2px 12px';
|
||
box.appendChild(textRow('Label', bl.label, (v) => { bl.label = v; markDirty(); }));
|
||
box.appendChild(xyzRow('Corner (mm)', bl.position, ['x', 'y', 'z'], () => { rebuildBlockers(); markDirty(); }));
|
||
box.appendChild(xyzRow('Size (mm)', bl.size, ['x', 'y', 'z'], () => { rebuildBlockers(); markDirty(); }));
|
||
const dup = document.createElement('button'); dup.className = 'btn-sm'; dup.textContent = 'Duplicate (stack +Y)';
|
||
dup.addEventListener('click', () => {
|
||
const copy = JSON.parse(JSON.stringify(bl)); copy.id = uid('blk');
|
||
copy.position.y = bl.position.y + bl.size.y; // stack on top
|
||
copy.label = (bl.label || 'Building') + ' (stack)';
|
||
scene.blockers.push(copy); rebuildBlockers(); selectEntity('blocker', copy.id); renderBlockers(); updateCounts(); markDirty();
|
||
});
|
||
box.appendChild(dup);
|
||
const del = document.createElement('button'); del.className = 'btn-del'; del.textContent = 'Delete building'; del.style.marginLeft = '6px';
|
||
del.addEventListener('click', () => { scene.blockers = scene.blockers.filter((o) => o.id !== bl.id); selected = null; rebuildBlockers(); renderBlockers(); updateCounts(); markDirty(); });
|
||
box.appendChild(del);
|
||
return box;
|
||
}
|
||
|
||
// ===========================================================================
|
||
// TABS + TOOLS
|
||
// ===========================================================================
|
||
function switchTab(name) {
|
||
document.querySelectorAll('.tab').forEach((t) => t.classList.toggle('active', t.dataset.tab === name));
|
||
document.querySelectorAll('.panel').forEach((pl) => pl.classList.toggle('active', pl.id === 'panel-' + name));
|
||
}
|
||
document.querySelectorAll('.tab').forEach((t) => t.addEventListener('click', () => switchTab(t.dataset.tab)));
|
||
|
||
function setTool(name) {
|
||
tool = name;
|
||
document.querySelectorAll('#toolbar button').forEach((b) => b.classList.toggle('active', b.dataset.tool === name));
|
||
canvas.style.cursor = name === 'select' ? 'default' : 'crosshair';
|
||
}
|
||
document.querySelectorAll('#toolbar button').forEach((b) => b.addEventListener('click', () => setTool(b.dataset.tool)));
|
||
|
||
window.addEventListener('keydown', (e) => {
|
||
if (e.target.tagName === 'INPUT' || e.target.tagName === 'SELECT') return;
|
||
if (e.key === 'v') setTool('select');
|
||
if (e.key === 'g') setTool('addSpawn');
|
||
if (e.key === 'b') setTool('addBlocker');
|
||
if (e.key === 'a') setTool('addAnchor');
|
||
if (e.key === 'Escape') { selected = null; rebuildAll(); renderPanels(); $('selInfo').textContent = 'nothing selected'; }
|
||
if ((e.key === 'Delete' || e.key === 'Backspace') && selected) {
|
||
const k = selected.kind;
|
||
if (k === 'spawn') scene.spawns = scene.spawns.filter((o) => o.id !== selected.id);
|
||
if (k === 'blocker') scene.blockers = scene.blockers.filter((o) => o.id !== selected.id);
|
||
if (k === 'anchor') scene.anchors = scene.anchors.filter((o) => o.id !== selected.id);
|
||
selected = null; rebuildAll(); renderPanels(); updateCounts(); markDirty();
|
||
}
|
||
});
|
||
|
||
// ===========================================================================
|
||
// SAVE / EXPORT / RELOAD
|
||
// ===========================================================================
|
||
$('saveBtn').addEventListener('click', async () => {
|
||
$('saveBtn').disabled = true;
|
||
try {
|
||
const res = await fetch('/api/scene', {
|
||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(scene),
|
||
});
|
||
const j = await res.json();
|
||
if (j.ok) {
|
||
dirty = false; $('dirty').classList.remove('show');
|
||
toast(`Saved · ${j.spawns} ghosts, ${j.anchors} anchors, ${j.blockers} buildings`, 'ok');
|
||
} else { toast('Save failed: ' + j.error, 'err'); $('saveBtn').disabled = false; }
|
||
} catch (e) { toast('Save error: ' + e.message, 'err'); $('saveBtn').disabled = false; }
|
||
});
|
||
|
||
$('exportBtn').addEventListener('click', () => {
|
||
const blob = new Blob([JSON.stringify(scene, null, 2)], { type: 'application/json' });
|
||
const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = 'scene.json';
|
||
a.click(); setTimeout(() => URL.revokeObjectURL(a.href), 1000);
|
||
});
|
||
|
||
$('reloadBtn').addEventListener('click', async () => {
|
||
if (dirty && !confirm('Discard unsaved changes and reload from server?')) return;
|
||
selected = null; dirty = false; $('dirty').classList.remove('show'); $('saveBtn').disabled = true;
|
||
await load();
|
||
toast('Reloaded from server');
|
||
});
|
||
|
||
const printAnchorsBtn = document.getElementById('printAnchorsBtn');
|
||
if (printAnchorsBtn) printAnchorsBtn.addEventListener('click', () => {
|
||
if (dirty) toast('Tip: save first so the print sheet matches your latest anchors', '');
|
||
window.open('anchors-print.html', '_blank');
|
||
});
|
||
|
||
const planBtn = document.getElementById('planBtn');
|
||
if (planBtn) planBtn.addEventListener('click', () => {
|
||
if (dirty) toast('Tip: save first so the plan matches your latest layout', '');
|
||
window.open('placement-plan.html', '_blank');
|
||
});
|
||
|
||
const calibrateBtn = document.getElementById('calibrateBtn');
|
||
if (calibrateBtn) calibrateBtn.addEventListener('click', () => window.open('calibrate.html', '_blank'));
|
||
|
||
window.addEventListener('beforeunload', (e) => { if (dirty) { e.preventDefault(); e.returnValue = ''; } });
|
||
|
||
// ===========================================================================
|
||
// RENDER LOOP + RESIZE
|
||
// ===========================================================================
|
||
function resize() {
|
||
const r = canvas.getBoundingClientRect();
|
||
renderer.setSize(r.width, r.height, false);
|
||
camera.aspect = r.width / r.height; camera.updateProjectionMatrix();
|
||
}
|
||
window.addEventListener('resize', resize);
|
||
|
||
function animate() {
|
||
requestAnimationFrame(animate);
|
||
controls.update();
|
||
renderer.render(view3d, camera);
|
||
}
|
||
|
||
// boot
|
||
resize();
|
||
load().then(() => { setTool('select'); animate(); });
|