Update exhibit

This commit is contained in:
2026-07-06 19:25:57 +10:00
parent 5ffd1346d6
commit e92ad04b7e
8 changed files with 3757 additions and 195 deletions
+5 -137
View File
@@ -1,138 +1,6 @@
# ---> Node
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/ node_modules/
jspm_packages/ *.log
.run.pid
# Snowpack dependency directory (https://snowpack.dev/) .deploy-installed
web_modules/ data/backups/
_deploy/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# vuepress v2.x temp and cache directory
.temp
.cache
# vitepress build output
**/.vitepress/dist
# vitepress cache directory
**/.vitepress/cache
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*
-18
View File
@@ -1,18 +0,0 @@
MIT License
Copyright (c) 2026 jessikitty
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
associated documentation files (the "Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the
following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial
portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT
LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO
EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
USE OR OTHER DEALINGS IN THE SOFTWARE.
+35 -2
View File
@@ -78,6 +78,32 @@ a procedural blue "Sad" wisp.
--- ---
## Scene workshop (admin GUI): `admin.html`
A 3D authoring tool over `data/scene.json` — no more hand-editing JSON. Orbit the build, click to
select, drag on the table to position. Four tabs:
- **World** — table dimensions (mm). X = length, Z = depth, Y = max ghost height.
- **Anchors** — the AR crests: marker type/id, size, position, yaw. (Note: existing scene used
`apriltag`; the detector is ArUco — the editor lets you set `aruco` and the matching id.)
- **Ghosts** — spawns with `hover` (position + radius + bob + yaw) or `patrol` (editable path of
points, loop period, face-travel) motion, plus lure colour. Drag the ghost to move it; drag
individual patrol points to reshape the path.
- **Buildings** — simple stackable box blockers for occlusion. Drag to place, set W×H×D, and
"Duplicate (stack +Y)" to pile boxes into a structure. (Occlusion rendering comes next; this
authors the geometry.)
Tools: select (V), add ghost (G), add building (B), add anchor (A). Delete/Esc work on selection.
**Save** POSTs to the server, which **backs up the old `scene.json`** to `data/backups/` first,
validates, writes, and **hot-reloads** — so connected viewers/phones get the new anchors and
ghosts immediately. **Export** downloads the JSON; **Reload** discards changes and re-fetches.
Server endpoints: `GET /api/scene` (full scene), `POST /api/scene` (validated save + backup).
> The scene schema gained a `blockers` array: `{ id, label, position:{x,y,z}, size:{x,y,z} }`,
> where `position` is the footprint corner (origin-relative) and `size` is the box extent in mm.
## Forgiving scanning (`js/detect-tuned.js`) ## Forgiving scanning (`js/detect-tuned.js`)
Raw js-aruco can feel finicky — slow to acquire, quick to drop on blur, jittery once locked. Raw js-aruco can feel finicky — slow to acquire, quick to drop on blur, jittery once locked.
@@ -142,12 +168,17 @@ key numbers — they'll tell us how much multi-marker fusion is worth before we
```bash ```bash
npm install npm install
npm start # PORT defaults to 34034 npm start # PORT defaults to 34034
# open http://localhost:34034/ar-test.html (camera needs HTTPS on phones) # open http://localhost:34034/admin.html scene workshop (authoring GUI)
# open http://localhost:34034/ar-head.html 3D ghost AR (camera needs HTTPS on phones)
# open http://localhost:34034/ god-view viewer (viewer.js) # open http://localhost:34034/ god-view viewer (viewer.js)
``` ```
Env: `PORT` (default 34034), `TICK_HZ` (default 20). Env: `PORT` (default 34034), `TICK_HZ` (default 20).
### Deploy box (blackpearl)
`./run.sh {start|stop|restart|status|logs|update}` — background runner for testing.
`./run.sh update` syncs to origin/dev and restarts. `./deploy.sh` is the pull-only one-shot.
--- ---
## Status / roadmap ## Status / roadmap
@@ -159,8 +190,10 @@ Env: `PORT` (default 34034), `TICK_HZ` (default 20).
- [x] AR PoC: camera + detection + ghost locked to marker (flat sprite) - [x] AR PoC: camera + detection + ghost locked to marker (flat sprite)
- [x] 3D LEGO-head ghost holding world orientation (`ar-head.html`) - [x] 3D LEGO-head ghost holding world orientation (`ar-head.html`)
- [x] Pose jitter meter (`jitter.html`) with session logging + JSON export - [x] Pose jitter meter (`jitter.html`) with session logging + JSON export
- [x] Forgiving detection: tuned params, multi-scale, coast + smooth (`detect-tuned.js`)
- [x] Scene workshop admin GUI (`admin.html`) — author anchors, ghosts, motion, buildings; save+backup+hot-reload
- [ ] Multi-marker pose fusion (one combined camera pose from all visible crests) - [ ] Multi-marker pose fusion (one combined camera pose from all visible crests)
- [ ] Occluder geometry: ghosts vanish behind premapped buildings (depth-only boxes) - [ ] Occluder geometry: ghosts vanish behind the building blockers (depth-only boxes)
- [ ] Phone AR client: render anchored ghosts through the camera using marker pose - [ ] Phone AR client: render anchored ghosts through the camera using marker pose
- [ ] Calibration page: scan each crest, record its measured world position - [ ] Calibration page: scan each crest, record its measured world position
- [ ] Per-ghost colour/sprite wiring from the enriched roster - [ ] Per-ghost colour/sprite wiring from the enriched roster
+2666
View File
File diff suppressed because it is too large Load Diff
+166
View File
@@ -0,0 +1,166 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Newbury Exhibit — scene workshop</title>
<style>
:root {
--red:#f65151; --yellow:#fff35d; --blue:#529eff; --cyan:#51eaf1;
--bg:#070910; --panel:#0e1220; --panel2:#141a2c; --line:#1f2740;
--ink:#e8eaf2; --dim:#7f8aa3; --dimmer:#566077;
--anchor:#00e5c0; --blocker:#c98a4b; --sel:#ffd24a;
--mono:'SF Mono','Roboto Mono',ui-monospace,Menlo,Consolas,monospace;
--sans:'Inter','Segoe UI',system-ui,sans-serif;
}
* { box-sizing:border-box; margin:0; padding:0; }
html,body { height:100%; background:var(--bg); color:var(--ink); font-family:var(--sans);
overflow:hidden; -webkit-font-smoothing:antialiased; }
button,input,select { font-family:inherit; color:inherit; }
#app { display:grid; grid-template-columns:1fr 332px; grid-template-rows:42px 1fr;
grid-template-areas:"top top" "view side"; height:100%; }
/* top bar */
#top { grid-area:top; display:flex; align-items:center; gap:14px; padding:0 14px;
background:var(--panel); border-bottom:1px solid var(--line); }
#top .brand { font-weight:700; font-size:13px; letter-spacing:-0.01em; }
#top .brand span { color:var(--cyan); }
#top .meta { font-family:var(--mono); font-size:11px; color:var(--dim); }
#top .spacer { flex:1; }
#top button { background:var(--panel2); border:1px solid var(--line); border-radius:7px;
padding:6px 12px; font-size:12px; font-weight:600; cursor:pointer; }
#top button:hover { border-color:var(--dimmer); }
#top button.primary { background:linear-gradient(135deg,var(--blue),var(--cyan)); color:#06121f; border:0; }
#top button.primary:disabled { opacity:0.5; cursor:default; }
#dirty { width:7px; height:7px; border-radius:50%; background:var(--sel); display:none; }
#dirty.show { display:inline-block; }
/* viewport */
#view { grid-area:view; position:relative; }
#c { display:block; width:100%; height:100%; }
#viewhud { position:absolute; left:12px; bottom:12px; font-family:var(--mono);
font-size:11px; color:var(--dim); background:rgba(7,9,16,0.7); border:1px solid var(--line);
border-radius:7px; padding:7px 10px; line-height:1.6; pointer-events:none; }
#viewhud b { color:var(--ink); }
#toolbar { position:absolute; left:12px; top:12px; display:flex; gap:6px; }
#toolbar button { background:rgba(14,18,32,0.9); border:1px solid var(--line); border-radius:7px;
width:38px; height:38px; cursor:pointer; font-size:15px; display:grid; place-items:center; }
#toolbar button.active { border-color:var(--cyan); color:var(--cyan); background:rgba(81,234,241,0.1); }
#toolbar .sep { width:1px; background:var(--line); margin:4px 2px; }
/* side panel */
#side { grid-area:side; background:var(--panel); border-left:1px solid var(--line);
display:flex; flex-direction:column; overflow:hidden; }
.tabs { display:flex; border-bottom:1px solid var(--line); }
.tab { flex:1; padding:9px 4px; text-align:center; font-size:11px; font-weight:600;
color:var(--dim); cursor:pointer; border-bottom:2px solid transparent; text-transform:uppercase;
letter-spacing:0.04em; }
.tab.active { color:var(--ink); border-bottom-color:var(--cyan); }
.tab .n { font-family:var(--mono); color:var(--dimmer); margin-left:3px; }
.panel { flex:1; overflow-y:auto; padding:12px; display:none; }
.panel.active { display:block; }
.panel::-webkit-scrollbar { width:9px; }
.panel::-webkit-scrollbar-thumb { background:var(--line); border-radius:5px; }
.row { display:flex; gap:7px; align-items:center; margin-bottom:8px; }
.row label { font-size:11px; color:var(--dim); width:74px; flex-shrink:0; }
.row input, .row select { flex:1; min-width:0; background:var(--panel2); border:1px solid var(--line);
border-radius:6px; padding:6px 8px; font-size:12px; font-family:var(--mono); }
.row input:focus, .row select:focus { outline:none; border-color:var(--cyan); }
.xyz { display:flex; gap:5px; }
.xyz input { width:100%; text-align:right; }
.xyz .axis { position:relative; flex:1; }
.xyz .axis::before { content:attr(data-a); position:absolute; left:6px; top:50%;
transform:translateY(-50%); font-size:9px; color:var(--dimmer); font-family:var(--mono);
pointer-events:none; }
.xyz .axis input { padding-left:16px; }
.list-item { background:var(--panel2); border:1px solid var(--line); border-radius:8px;
padding:9px 10px; margin-bottom:7px; cursor:pointer; }
.list-item:hover { border-color:var(--dimmer); }
.list-item.sel { border-color:var(--sel); box-shadow:0 0 0 1px var(--sel) inset; }
.list-item .li-top { display:flex; align-items:center; gap:7px; }
.li-dot { width:9px; height:9px; border-radius:50%; flex-shrink:0; }
.li-name { font-size:12px; font-weight:600; flex:1; overflow:hidden; text-overflow:ellipsis;
white-space:nowrap; }
.li-tag { font-family:var(--mono); font-size:10px; color:var(--dim); }
.li-sub { font-family:var(--mono); font-size:10px; color:var(--dimmer); margin-top:3px; }
.section-h { font-size:10px; text-transform:uppercase; letter-spacing:0.06em; color:var(--dimmer);
margin:14px 0 8px; font-weight:700; }
.section-h:first-child { margin-top:0; }
.btn-add { width:100%; background:var(--panel2); border:1px dashed var(--line); border-radius:8px;
padding:9px; font-size:12px; font-weight:600; color:var(--dim); cursor:pointer; }
.btn-add:hover { border-color:var(--cyan); color:var(--cyan); }
.btn-del { background:rgba(246,81,81,0.12); border:1px solid rgba(246,81,81,0.3); color:var(--red);
border-radius:6px; padding:6px 10px; font-size:11px; font-weight:600; cursor:pointer; }
.btn-sm { background:var(--panel2); border:1px solid var(--line); border-radius:6px; padding:5px 9px;
font-size:11px; cursor:pointer; }
.btn-sm:hover { border-color:var(--dimmer); }
.hint { font-size:11px; color:var(--dimmer); line-height:1.5; margin:6px 0; }
.empty { text-align:center; color:var(--dimmer); font-size:12px; padding:30px 10px; }
.pathpt { display:flex; gap:5px; align-items:center; margin-bottom:5px; }
.pathpt .idx { font-family:var(--mono); font-size:10px; color:var(--dimmer); width:16px; }
.swatches { display:flex; gap:6px; }
.sw { width:26px; height:26px; border-radius:6px; cursor:pointer; border:2px solid transparent; }
.sw.sel { border-color:#fff; }
#toast { position:fixed; bottom:18px; left:50%; transform:translateX(-50%) translateY(80px);
background:var(--panel2); border:1px solid var(--line); border-radius:9px; padding:10px 16px;
font-size:12.5px; transition:transform 0.3s; z-index:50; max-width:80vw; }
#toast.show { transform:translateX(-50%) translateY(0); }
#toast.ok { border-color:rgba(81,234,241,0.4); }
#toast.err { border-color:rgba(246,81,81,0.5); color:#ffb4b4; }
</style>
</head>
<body>
<div id="app">
<div id="top">
<div class="brand">Newbury <span>scene workshop</span></div>
<div class="meta" id="worldMeta"></div>
<div class="spacer"></div>
<span id="dirty" title="unsaved changes"></span>
<button id="reloadBtn" title="Discard changes and reload from server">Reload</button>
<button id="exportBtn" title="Download scene.json">Export</button>
<button id="saveBtn" class="primary" disabled>Save to server</button>
</div>
<div id="view">
<canvas id="c"></canvas>
<div id="toolbar">
<button data-tool="select" class="active" title="Select / move (V)"></button>
<div class="sep"></div>
<button data-tool="addSpawn" title="Add ghost spawn (G)">👻</button>
<button data-tool="addBlocker" title="Add building blocker (B)"></button>
<button data-tool="addAnchor" title="Add anchor (A)"></button>
</div>
<div id="viewhud">
cursor <b id="cursorPos"></b><br />
<span id="selInfo">nothing selected</span>
</div>
</div>
<div id="side">
<div class="tabs">
<div class="tab active" data-tab="world">World</div>
<div class="tab" data-tab="anchors">Anchors<span class="n" id="nAnchors">0</span></div>
<div class="tab" data-tab="spawns">Ghosts<span class="n" id="nSpawns">0</span></div>
<div class="tab" data-tab="blockers">Buildings<span class="n" id="nBlockers">0</span></div>
</div>
<div class="panel active" id="panel-world"></div>
<div class="panel" id="panel-anchors"></div>
<div class="panel" id="panel-spawns"></div>
<div class="panel" id="panel-blockers"></div>
</div>
</div>
<div id="toast"></div>
<script type="module" src="js/admin.js?v=1"></script>
</body>
</html>
+744
View File
@@ -0,0 +1,744 @@
/* 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(); });
+80 -12
View File
@@ -2,7 +2,7 @@ import 'dotenv/config';
import express from 'express'; import express from 'express';
import { createServer } from 'http'; import { createServer } from 'http';
import { WebSocketServer } from 'ws'; import { WebSocketServer } from 'ws';
import { readFileSync } from 'fs'; import { readFileSync, writeFileSync, copyFileSync, existsSync, mkdirSync } from 'fs';
import { fileURLToPath } from 'url'; import { fileURLToPath } from 'url';
import { dirname, join } from 'path'; import { dirname, join } from 'path';
@@ -19,6 +19,20 @@ function loadScene() {
} }
let scene = loadScene(); let scene = loadScene();
// ---- Ghost roster (for the admin picker) ---------------------------------
// Full enriched roster lives in data/ghosts.json. We serve a trimmed view with
// just what the scene editor needs to pick a ghost and auto-fill its colour.
let roster = [];
try {
const rraw = readFileSync(join(ROOT, 'data', 'ghosts.json'), 'utf-8');
roster = JSON.parse(rraw).map((g) => ({
id: g.id, name: g.name, color: g.color, rarity: g.rarity,
rarityTier: g.rarityTier, isBoss: !!g.isBoss, ability: g.abilityId,
}));
} catch (_) {
roster = []; // roster optional; editor falls back to free-text id
}
// ---- Motion solvers ------------------------------------------------------ // ---- Motion solvers ------------------------------------------------------
// Every solver takes (spawn, tSeconds) and returns { x,y,z, yawDeg }. // Every solver takes (spawn, tSeconds) and returns { x,y,z, yawDeg }.
// Positions are in world millimetres, matching scene.json. // Positions are in world millimetres, matching scene.json.
@@ -95,22 +109,60 @@ function round(n) {
// ---- HTTP + static ------------------------------------------------------- // ---- HTTP + static -------------------------------------------------------
const app = express(); const app = express();
app.use(express.json({ limit: '4mb' })); // for the admin save endpoint
app.use(express.static(join(ROOT, 'public'))); app.use(express.static(join(ROOT, 'public')));
// Scene metadata (anchors + spawn definitions) for clients to align/render. // Full scene for the admin editor (everything) and clients (anchors+spawns).
app.get('/api/scene', (_req, res) => { app.get('/api/scene', (_req, res) => {
res.json({ res.json(scene);
world: scene.world,
anchors: scene.anchors,
spawns: scene.spawns.map((s) => ({
id: s.id,
ghostId: s.ghostId,
label: s.label,
motion: s.motion,
})),
}); });
// Ghost roster for the admin picker (trimmed).
app.get('/api/ghosts', (_req, res) => {
res.json(roster);
}); });
// Admin save: validate, back up the old file, write, hot-reload.
app.post('/api/scene', (req, res) => {
const incoming = req.body;
const err = validateScene(incoming);
if (err) return res.status(400).json({ ok: false, error: err });
try {
const dataDir = join(ROOT, 'data');
const target = join(dataDir, 'scene.json');
// timestamped backup so a bad save is always recoverable
const backupDir = join(dataDir, 'backups');
if (!existsSync(backupDir)) mkdirSync(backupDir, { recursive: true });
if (existsSync(target)) {
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
copyFileSync(target, join(backupDir, `scene-${stamp}.json`));
}
writeFileSync(target, JSON.stringify(incoming, null, 2), 'utf-8');
scene = incoming; // hot-reload: live ghosts update immediately
broadcastHello(); // push new anchors/world to connected viewers
res.json({ ok: true, spawns: scene.spawns.length, anchors: scene.anchors.length, blockers: (scene.blockers || []).length });
} catch (e) {
res.status(500).json({ ok: false, error: String(e.message || e) });
}
});
// Minimal schema guard so a malformed save can't crash the motion loop.
function validateScene(s) {
if (!s || typeof s !== 'object') return 'scene must be an object';
if (!s.world || !s.world.buildSize) return 'missing world.buildSize';
if (!Array.isArray(s.anchors)) return 'anchors must be an array';
if (!Array.isArray(s.spawns)) return 'spawns must be an array';
if (s.blockers && !Array.isArray(s.blockers)) return 'blockers must be an array';
for (const sp of s.spawns) {
if (!sp.id || !sp.motion) return `spawn missing id/motion: ${JSON.stringify(sp).slice(0, 80)}`;
if (sp.motion === 'patrol' && (!Array.isArray(sp.path) || sp.path.length < 2)) {
return `patrol spawn "${sp.id}" needs a path of >=2 points`;
}
if (sp.motion === 'hover' && !sp.position) return `hover spawn "${sp.id}" needs a position`;
}
return null;
}
app.get('/api/health', (_req, res) => { app.get('/api/health', (_req, res) => {
res.json({ ok: true, viewers: wss ? wss.clients.size : 0, tickHz: TICK_HZ }); res.json({ ok: true, viewers: wss ? wss.clients.size : 0, tickHz: TICK_HZ });
}); });
@@ -120,9 +172,25 @@ const httpServer = createServer(app);
// ---- WebSocket broadcast ------------------------------------------------- // ---- WebSocket broadcast -------------------------------------------------
const wss = new WebSocketServer({ server: httpServer, path: '/sync' }); const wss = new WebSocketServer({ server: httpServer, path: '/sync' });
function helloPayload() {
return JSON.stringify({
type: 'hello',
world: scene.world,
anchors: scene.anchors,
blockers: scene.blockers || [],
});
}
function broadcastHello() {
const payload = helloPayload();
for (const client of wss.clients) {
if (client.readyState === 1) client.send(payload);
}
}
wss.on('connection', (socket) => { wss.on('connection', (socket) => {
// Send scene + immediate snapshot on connect so a new phone is instantly aligned. // Send scene + immediate snapshot on connect so a new phone is instantly aligned.
socket.send(JSON.stringify({ type: 'hello', world: scene.world, anchors: scene.anchors })); socket.send(helloPayload());
socket.send(JSON.stringify(buildSnapshot())); socket.send(JSON.stringify(buildSnapshot()));
}); });
+55 -20
View File
@@ -1,48 +1,73 @@
@echo off @echo off
REM ============================================================ REM ============================================================
REM Newbury Exhibit -> Gitea uploader REM Newbury Exhibit -> Gitea uploader
REM Wipe-and-re-clone _deploy checkout, copy tree, commit, push. REM _deploy checkout lives OUTSIDE the project (sibling folder)
REM Mirrors the newbury-nights deploy pattern. REM so it can never copy into itself (no more nested copies).
REM Default HTTPS via DNS; auto-fallback to direct LAN.
REM ============================================================ REM ============================================================
setlocal enabledelayedexpansion setlocal enabledelayedexpansion
set GITEA_HOST=gitea.hideawaygaming.com.au set GITEA_HOST=gitea.hideawaygaming.com.au
set LAN_URL=https://gitea.hideawaygaming.com.au
set OWNER=jessikitty set OWNER=jessikitty
set REPO=newbury-exhibit set REPO=newbury-exhibit
set BRANCH=dev set BRANCH=dev
set SRC=%~dp0
REM SRC = this script's folder (the project). Strip trailing backslash.
set "SRC=%~dp0"
if "%SRC:~-1%"=="\" set "SRC=%SRC:~0,-1%"
REM DEPLOY = sibling folder next to the project, NOT inside it.
for %%I in ("%SRC%\..") do set "PARENT=%%~fI"
set "DEPLOY=%PARENT%\newbury-exhibit_deploy"
set HTTPS_REMOTE=https://%GITEA_HOST%/%OWNER%/%REPO%.git
set LAN_REMOTE=%LAN_URL%/%OWNER%/%REPO%.git
echo. echo.
echo === Newbury Exhibit deploy === echo === Newbury Exhibit deploy ===
echo Source: %SRC% echo Source: %SRC%
echo Target: https://%GITEA_HOST%/%OWNER%/%REPO% (branch %BRANCH%) echo Checkout: %DEPLOY% (sibling - outside the project)
echo Target: %HTTPS_REMOTE% (branch %BRANCH%)
echo. echo.
REM --- fresh _deploy checkout --- REM --- ensure ghost roster is present (copy from NewburyNights if missing) ---
if exist "%SRC%_deploy" ( if not exist "%SRC%\data\ghosts.json" (
echo Removing old _deploy checkout... if exist "%PARENT%\NewburyNights\ghosts.enriched.json" (
rmdir /s /q "%SRC%_deploy" echo Copying ghost roster into data\ghosts.json ...
copy /y "%PARENT%\NewburyNights\ghosts.enriched.json" "%SRC%\data\ghosts.json" >nul
) )
echo Cloning %BRANCH%...
git clone -b %BRANCH% "https://%GITEA_HOST%/%OWNER%/%REPO%.git" "%SRC%_deploy"
if errorlevel 1 (
echo ERROR: clone failed. Check credentials / branch exists.
pause & exit /b 1
) )
REM --- auto-detect git identity from existing global config --- REM --- fresh _deploy checkout (clone via HTTPS; fall back to LAN) ---
if exist "%DEPLOY%" (
echo Removing old checkout...
rmdir /s /q "%DEPLOY%"
)
echo Cloning %BRANCH% via HTTPS...
git clone -b %BRANCH% "%HTTPS_REMOTE%" "%DEPLOY%"
if errorlevel 1 (
echo HTTPS clone failed, trying direct LAN...
git clone -b %BRANCH% "%LAN_REMOTE%" "%DEPLOY%"
if errorlevel 1 ( echo ERROR: clone failed on both remotes. & pause & exit /b 1 )
)
REM --- git identity from global config ---
for /f "tokens=*" %%a in ('git config --global user.name') do set GITNAME=%%a for /f "tokens=*" %%a in ('git config --global user.name') do set GITNAME=%%a
for /f "tokens=*" %%a in ('git config --global user.email') do set GITEMAIL=%%a for /f "tokens=*" %%a in ('git config --global user.email') do set GITEMAIL=%%a
if "!GITNAME!"=="" set GITNAME=jessikitty if "!GITNAME!"=="" set GITNAME=jessikitty
if "!GITEMAIL!"=="" set GITEMAIL=jess.rogerson.29@outlook.com if "!GITEMAIL!"=="" set GITEMAIL=jess.rogerson.29@outlook.com
REM --- copy project files into checkout (exclude deploy/node_modules/git) --- REM --- copy project into checkout. Source has no _deploy inside it now, ---
REM --- but still exclude the usual noise. .git in DEPLOY is protected. ---
echo Syncing files... echo Syncing files...
robocopy "%SRC%." "%SRC%_deploy" /E /XD "_deploy" "node_modules" ".git" /XF "*.log" >nul robocopy "%SRC%" "%DEPLOY%" /MIR /XD "node_modules" ".git" "newbury-exhibit_deploy" "backups" /XF "*.log" ".run.pid" ".deploy-installed" >nul
pushd "%SRC%_deploy" pushd "%DEPLOY%"
git config user.name "!GITNAME!" git config user.name "!GITNAME!"
git config user.email "!GITEMAIL!" git config user.email "!GITEMAIL!"
git config http.postBuffer 524288000
git config http.version HTTP/1.1
echo. echo.
echo === Staged changes === echo === Staged changes ===
@@ -56,11 +81,21 @@ if "!MSG!"=="" set MSG=Update exhibit
git commit -m "!MSG!" git commit -m "!MSG!"
if errorlevel 1 ( if errorlevel 1 (
echo Nothing to commit, or commit failed. echo Nothing to commit, or commit failed.
) else ( popd & pause & exit /b 0
)
echo.
echo Pushing via HTTPS...
git push origin %BRANCH% git push origin %BRANCH%
if errorlevel 1 ( echo ERROR: push failed. & popd & pause & exit /b 1 ) if errorlevel 1 (
echo HTTPS push failed ^(proxy/500?^). Retrying direct LAN...
git push "%LAN_REMOTE%" %BRANCH%
if errorlevel 1 (
echo ERROR: push failed on both remotes. Commit saved in %DEPLOY%.
popd & pause & exit /b 1
)
)
echo. echo.
echo === Pushed to %BRANCH% successfully === echo === Pushed to %BRANCH% successfully ===
)
popd popd
pause pause