Update exhibit
This commit is contained in:
+5
-137
@@ -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.*
|
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
|
||||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user