Files
newbury-exhibit/public/js/admin.js
T
2026-07-06 19:25:57 +10:00

745 lines
36 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* 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 'https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js';
import { OrbitControls } from 'https://cdn.jsdelivr.net/npm/three@0.160.0/examples/jsm/controls/OrbitControls.js';
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(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');
});
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(); });