/* 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}: ${obj.label || obj.id}` : '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'); }); 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'); }); 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(); });