Import v2 base (unchanged files)
This commit is contained in:
@@ -0,0 +1,46 @@
|
||||
/* admin.js — shared auth + API helpers for admin pages. */
|
||||
export async function api(path, method = 'GET', body) {
|
||||
const res = await fetch(path, {
|
||||
method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-exhibit-token': localStorage.getItem('exhibitToken') || '',
|
||||
},
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
});
|
||||
if (res.status === 401) { location.href = '/admin/login.html?next=' + encodeURIComponent(location.pathname); throw new Error('unauthorized'); }
|
||||
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || res.statusText);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function requireAuth() {
|
||||
const info = await (await fetch('/api/info')).json();
|
||||
if (!info.authRequired) return info;
|
||||
// probe a guarded endpoint
|
||||
const res = await fetch('/api/client-errors', { headers: { 'x-exhibit-token': localStorage.getItem('exhibitToken') || '' } });
|
||||
if (res.status === 401) location.href = '/admin/login.html?next=' + encodeURIComponent(location.pathname);
|
||||
return info;
|
||||
}
|
||||
|
||||
export const styles = `
|
||||
:root { color-scheme: dark; }
|
||||
body { margin:0; background:#0a0d18; color:#e8ecff; font-family:system-ui,sans-serif; }
|
||||
header { display:flex; gap:14px; align-items:center; padding:10px 16px; background:#111730; }
|
||||
header a { color:#8fb8ff; text-decoration:none; font-size:.9rem; }
|
||||
header a.active { color:#51eaf1; }
|
||||
h1 { font-size:1.05rem; margin:0 auto 0 0; }
|
||||
button { background:#2a3a6e; color:#fff; border:0; border-radius:6px; padding:6px 14px; cursor:pointer; }
|
||||
button.primary { background:linear-gradient(135deg,#51eaf1,#529eff); color:#04121a; font-weight:600; }
|
||||
button.danger { background:#7a2437; }
|
||||
input, select { background:#141a33; color:#fff; border:1px solid #2a3a6e; border-radius:5px; padding:4px 8px; }
|
||||
label { font-size:.8rem; opacity:.85; }
|
||||
`;
|
||||
|
||||
export function nav(active) {
|
||||
return `<header>
|
||||
<h1>Newbury Exhibit — Admin</h1>
|
||||
${['preview', 'layout', 'plan', 'playlist', 'characters', 'landing', 'print', 'calibrate', 'errors'].map(p =>
|
||||
`<a href="/admin/${p}.html" class="${p === active ? 'active' : ''}">${p}</a>`).join('')}
|
||||
<a href="/">viewer ↗</a>
|
||||
</header>`;
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
<!doctype html>
|
||||
<html lang="en"><head><meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
|
||||
<title>Calibrate — Newbury Exhibit</title>
|
||||
<style>
|
||||
html,body{margin:0;height:100%;overflow:hidden;background:#000;color:#fff;font-family:monospace}
|
||||
#cam{position:fixed;inset:0;width:100%;height:100%;object-fit:cover;opacity:.85}
|
||||
#ov{position:fixed;inset:0;pointer-events:none}
|
||||
#out{position:fixed;top:0;left:0;right:0;background:rgba(0,0,0,.65);padding:8px 10px;font-size:12px;white-space:pre;line-height:1.5}
|
||||
</style></head>
|
||||
<body>
|
||||
<video id="cam" playsinline muted></video>
|
||||
<canvas id="ov"></canvas>
|
||||
<div id="out">Starting camera…</div>
|
||||
|
||||
<script src="/vendor/cv.js"></script>
|
||||
<script src="/vendor/svd.js"></script>
|
||||
<script src="/vendor/posit1.js"></script>
|
||||
<script src="/vendor/aruco.js"></script>
|
||||
<script src="/vendor/dictionaries/aruco_4x4_1000.js"></script>
|
||||
<script type="importmap">
|
||||
{ "imports": {
|
||||
"three": "https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js",
|
||||
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.160.0/examples/jsm/"
|
||||
} }
|
||||
</script>
|
||||
<script type="module">
|
||||
import * as THREE from 'three';
|
||||
import { createDetector, detectMarkers, quadArea } from '/js/ar/detect.js';
|
||||
import { PoseEstimator } from '/js/ar/pose.js';
|
||||
import { WorldFuser } from '/js/ar/fuse.js';
|
||||
|
||||
const scene = await (await fetch('/api/scene')).json();
|
||||
const fuser = new WorldFuser(); fuser.setScene(scene);
|
||||
const detector = createDetector();
|
||||
|
||||
const video = document.getElementById('cam');
|
||||
const ov = document.getElementById('ov'); const octx = ov.getContext('2d');
|
||||
const out = document.getElementById('out');
|
||||
const cv2 = document.createElement('canvas'); const ctx = cv2.getContext('2d', { willReadFrequently: true });
|
||||
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: 'environment', width: { ideal: 1280 } }, audio: false });
|
||||
video.srcObject = stream; await video.play();
|
||||
|
||||
let poseEst = null;
|
||||
const jitterBuf = new Map(); // markerId -> recent positions
|
||||
|
||||
function loop() {
|
||||
requestAnimationFrame(loop);
|
||||
if (video.readyState < 2) return;
|
||||
const W = 640, H = Math.round(W * video.videoHeight / video.videoWidth);
|
||||
if (cv2.width !== W) { cv2.width = W; cv2.height = H; }
|
||||
ov.width = innerWidth; ov.height = innerHeight;
|
||||
ctx.drawImage(video, 0, 0, W, H);
|
||||
const img = ctx.getImageData(0, 0, W, H);
|
||||
if (!poseEst) poseEst = new PoseEstimator(W / (2 * Math.tan(30 * Math.PI / 180)));
|
||||
|
||||
const markers = detectMarkers(detector, img, fuser.knownIds());
|
||||
const sx = innerWidth / W, sy = innerHeight / H;
|
||||
octx.clearRect(0, 0, ov.width, ov.height);
|
||||
|
||||
const lines = [];
|
||||
const estimates = [];
|
||||
for (const m of markers) {
|
||||
// outline
|
||||
octx.strokeStyle = '#51eaf1'; octx.lineWidth = 3; octx.beginPath();
|
||||
m.corners.forEach((c, i) => i ? octx.lineTo(c.x * sx, c.y * sy) : octx.moveTo(c.x * sx, c.y * sy));
|
||||
octx.closePath(); octx.stroke();
|
||||
|
||||
const e = poseEst.estimate(m.id, m.corners, W / 2, H / 2, fuser.sizeFor(m.id));
|
||||
if (!e) continue;
|
||||
estimates.push({ markerId: m.id, position: e.position, quaternion: e.quaternion, area: quadArea(m.corners) });
|
||||
|
||||
const distMM = e.position.length();
|
||||
const eul = new THREE.Euler().setFromQuaternion(e.quaternion, 'YXZ');
|
||||
const buf = jitterBuf.get(m.id) || []; buf.push(e.position.clone()); if (buf.length > 30) buf.shift(); jitterBuf.set(m.id, buf);
|
||||
const mean = buf.reduce((a, p) => a.add(p), new THREE.Vector3()).multiplyScalar(1 / buf.length);
|
||||
const jit = Math.sqrt(buf.reduce((a, p) => a + p.distanceToSquared(mean), 0) / buf.length);
|
||||
|
||||
lines.push(`crest #${String(m.id).padStart(3)} dist ${(distMM / 10).toFixed(1)} cm` +
|
||||
` yaw ${THREE.MathUtils.radToDeg(eul.y).toFixed(0).padStart(4)}°` +
|
||||
` pitch ${THREE.MathUtils.radToDeg(eul.x).toFixed(0).padStart(4)}°` +
|
||||
` jitter ${jit.toFixed(1)} mm err ${e.error.toFixed(1)}`);
|
||||
}
|
||||
|
||||
const fused = fuser.fuse(estimates);
|
||||
if (fused) lines.push('', `FUSED cam @ ${fused.position.toArray().map(v => v.toFixed(1)).join(', ')} cm (${fused.markerCount} crest${fused.markerCount === 1 ? '' : 's'})`);
|
||||
out.textContent = lines.join('\n') || 'No crests in view — show a printed Newbury Crest to the camera.';
|
||||
}
|
||||
loop();
|
||||
</script>
|
||||
</body></html>
|
||||
@@ -0,0 +1,556 @@
|
||||
<!doctype html>
|
||||
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Characters — Newbury Exhibit</title></head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="importmap">
|
||||
{ "imports": {
|
||||
"three": "https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js",
|
||||
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.160.0/examples/jsm/"
|
||||
} }
|
||||
</script>
|
||||
<script type="module">
|
||||
import * as THREE from 'three';
|
||||
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
|
||||
import { TransformControls } from 'three/addons/controls/TransformControls.js';
|
||||
import { buildGhost, PART_KEYS, normalizePart, mergePart } from '/js/ghosts/loader.js';
|
||||
import { api, requireAuth, styles, nav } from './admin.js';
|
||||
|
||||
document.head.insertAdjacentHTML('beforeend', `<style>${styles}
|
||||
#wrap { display:grid; grid-template-columns: 240px 1fr 320px; height:calc(100vh - 44px); }
|
||||
#list { background:#0d1226; overflow:auto; padding:8px; }
|
||||
#list .gh { padding:6px 9px; border-radius:6px; cursor:pointer; font-size:.82rem; display:flex; gap:7px; align-items:center; }
|
||||
#list .gh:hover { background:#1a2244; }
|
||||
#list .gh.sel { background:#2a3a6e; }
|
||||
#list .gh .dot { width:9px; height:9px; border-radius:50%; flex:none; }
|
||||
#list .gh .cust { margin-left:auto; font-size:.65rem; color:#51eaf1; }
|
||||
#list input.search { width:100%; box-sizing:border-box; margin-bottom:8px; }
|
||||
#stage { position:relative; background:#0a0e1c; }
|
||||
#gl { width:100%; height:100%; display:block; }
|
||||
#side { background:#111730; padding:12px; overflow:auto; }
|
||||
#side h2 { font-size:.9rem; margin:12px 0 6px; color:#8fb8ff; }
|
||||
#side h2:first-child { margin-top:0; }
|
||||
.row { display:flex; gap:8px; align-items:center; margin:5px 0; }
|
||||
.row label { width:96px; font-size:.75rem; }
|
||||
.row input, .row select { flex:1; min-width:0; }
|
||||
.row input[type=color] { padding:1px; height:26px; }
|
||||
.row input[type=range] { padding:0; }
|
||||
.row.tri input { width:0; flex:1; text-align:center; }
|
||||
.row.tri label { width:60px; }
|
||||
.assets { display:flex; flex-wrap:wrap; gap:6px; margin:6px 0; }
|
||||
.asset { background:#0d1226; border-radius:6px; padding:5px 8px; font-size:.72rem; display:flex; gap:6px; align-items:center; }
|
||||
.asset img { width:26px; height:26px; object-fit:cover; border-radius:3px; }
|
||||
.asset button { padding:1px 6px; font-size:.7rem; }
|
||||
.badge { font-size:.7rem; opacity:.62; line-height:1.45; }
|
||||
.drop { border:1px dashed #2a3a6e; border-radius:8px; padding:10px; text-align:center; font-size:.75rem; opacity:.8; }
|
||||
.drop.over { border-color:#51eaf1; background:#141c3a; }
|
||||
.parttabs { display:flex; flex-wrap:wrap; gap:4px; margin:6px 0; }
|
||||
.parttab { padding:3px 9px; border-radius:6px; font-size:.72rem; cursor:pointer; background:#0d1226; border:1px solid #2a3a6e; }
|
||||
.parttab.active { background:#2a3a6e; border-color:#51eaf1; color:#cfe6ff; }
|
||||
.parttab.absent { opacity:.4; cursor:default; }
|
||||
.parttab .m { color:#51eaf1; margin-left:4px; font-size:.62rem; }
|
||||
.seg { display:flex; gap:0; margin:6px 0; border:1px solid #2a3a6e; border-radius:6px; overflow:hidden; }
|
||||
.seg button { flex:1; border-radius:0; background:#0d1226; font-size:.72rem; padding:5px; }
|
||||
.seg button.on { background:#2a3a6e; color:#51eaf1; }
|
||||
.minibtn { padding:3px 8px; font-size:.7rem; }
|
||||
#overlay { position:absolute; left:10px; bottom:10px; font-size:.72rem; background:rgba(6,8,15,.6); padding:6px 10px; border-radius:8px; max-width:60%; }
|
||||
#gizhint { position:absolute; right:10px; top:10px; font-size:.72rem; background:rgba(6,8,15,.7); padding:6px 10px; border-radius:8px; display:none; }
|
||||
</style>`);
|
||||
await requireAuth();
|
||||
|
||||
const COLORS = { Red: '#ff2678', Yellow: '#fff35d', Blue: '#51eaf1' };
|
||||
const app = document.getElementById('app');
|
||||
app.innerHTML = `${nav('characters')}
|
||||
<div id="wrap">
|
||||
<div id="list"><input class="search" id="search" placeholder="Search ghosts…"><div id="ghosts"></div></div>
|
||||
<div id="stage"><canvas id="gl"></canvas>
|
||||
<div id="overlay">Live preview · drag to orbit · changes apply to all viewers on Save</div>
|
||||
<div id="gizhint"></div></div>
|
||||
<div id="side"></div>
|
||||
</div>`;
|
||||
|
||||
// ---------- data ----------
|
||||
const gdata = await api('/api/ghosts');
|
||||
const ghosts = gdata.ghosts;
|
||||
const gradients = gdata.gradients.gradients || gdata.gradients;
|
||||
let manifest = await api('/api/models');
|
||||
manifest.models ||= [];
|
||||
let assets = await api('/api/assets');
|
||||
let chars = await api('/api/characters');
|
||||
chars.byId ||= {}; chars.defaults ||= {};
|
||||
const scene = await api('/api/scene');
|
||||
const baseHeight = scene.ghostHeightCm ?? 4;
|
||||
|
||||
let selId = ghosts[0]?.id;
|
||||
let filter = '';
|
||||
let activePart = null; // which part key the gizmo/fields target
|
||||
let partScope = 'model'; // 'model' (shared default) | 'ghost' (this ghost's override)
|
||||
let gizmoMode = 'translate'; // 'translate' | 'rotate'
|
||||
let modelsDirty = false; // model manifest changed but not yet PUT
|
||||
|
||||
// ---------- preview ----------
|
||||
const cvs = document.getElementById('gl');
|
||||
const renderer = new THREE.WebGLRenderer({ canvas: cvs, antialias: true, alpha: true });
|
||||
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
|
||||
const scn = new THREE.Scene();
|
||||
scn.background = new THREE.Color(0x0a0e1c);
|
||||
const cam = new THREE.PerspectiveCamera(45, 1, 0.1, 500);
|
||||
cam.position.set(0, 4, 12);
|
||||
scn.add(new THREE.AmbientLight(0xffffff, 1.2));
|
||||
const dl = new THREE.DirectionalLight(0xffffff, 0.7); dl.position.set(4, 10, 8); scn.add(dl);
|
||||
const orbit = new OrbitControls(cam, cvs);
|
||||
orbit.target.set(0, 2.2, 0);
|
||||
const grid = new THREE.GridHelper(20, 20, 0x2a3a6e, 0x151b36); scn.add(grid);
|
||||
|
||||
// Gizmo for direct part manipulation, attached to a part's node inside the preview.
|
||||
const gizmo = new TransformControls(cam, cvs);
|
||||
gizmo.setSize(0.7);
|
||||
gizmo.addEventListener('dragging-changed', e => orbit.enabled = !e.value);
|
||||
gizmo.addEventListener('objectChange', onGizmoMove);
|
||||
scn.add(gizmo);
|
||||
|
||||
let previewObj = null;
|
||||
let partNodes = {}; // partKey -> Object3D holding that part
|
||||
|
||||
async function refreshPreview(keepGizmo = false) {
|
||||
const priorPart = activePart;
|
||||
if (gizmo.object) gizmo.detach();
|
||||
if (previewObj) { scn.remove(previewObj); previewObj = null; }
|
||||
partNodes = {};
|
||||
const g = ghosts.find(x => x.id === selId);
|
||||
if (!g) return;
|
||||
previewObj = await buildGhost(g, gradients, manifest, chars);
|
||||
previewObj.userData.setHeight(baseHeight);
|
||||
scn.add(previewObj);
|
||||
|
||||
previewObj.traverse(o => { if (o.userData && o.userData.partKey) partNodes[o.userData.partKey] = o; });
|
||||
|
||||
const h = chars.byId[selId]?.heightCm || baseHeight;
|
||||
orbit.target.set(0, h / 2, 0);
|
||||
if (!keepGizmo) cam.position.set(0, h * 0.75, h * 3);
|
||||
if (keepGizmo && priorPart && partNodes[priorPart]) attachGizmo(priorPart);
|
||||
}
|
||||
|
||||
function attachGizmo(key) {
|
||||
const node = partNodes[key];
|
||||
const hint = document.getElementById('gizhint');
|
||||
if (!node) { gizmo.detach(); if (hint) hint.style.display = 'none'; return; }
|
||||
gizmo.setMode(gizmoMode);
|
||||
gizmo.attach(node);
|
||||
if (hint) {
|
||||
hint.style.display = 'block';
|
||||
hint.textContent = `${key} · ${gizmoMode} · ${partScope === 'model' ? 'model default' : 'this ghost'}`;
|
||||
}
|
||||
}
|
||||
|
||||
// When the gizmo is dragged, read the node transform and persist it.
|
||||
function onGizmoMove() {
|
||||
if (!activePart) return;
|
||||
const node = gizmo.object;
|
||||
if (!node) return;
|
||||
writePartTransform(activePart, {
|
||||
offset: [round(node.position.x), round(node.position.y), round(node.position.z)],
|
||||
rotation: [round(THREE.MathUtils.radToDeg(node.rotation.x)),
|
||||
round(THREE.MathUtils.radToDeg(node.rotation.y)),
|
||||
round(THREE.MathUtils.radToDeg(node.rotation.z))],
|
||||
scale: round(node.scale.x),
|
||||
});
|
||||
syncPartFields();
|
||||
}
|
||||
const round = v => Math.round(v * 1000) / 1000;
|
||||
|
||||
function fit() {
|
||||
renderer.setSize(cvs.clientWidth, cvs.clientHeight, false);
|
||||
cam.aspect = cvs.clientWidth / cvs.clientHeight; cam.updateProjectionMatrix();
|
||||
}
|
||||
new ResizeObserver(fit).observe(cvs);
|
||||
(function loop(t) {
|
||||
requestAnimationFrame(loop);
|
||||
// Don't auto-spin while placing a part — rotation makes the gizmo unusable.
|
||||
if (previewObj) { previewObj.userData.tick(t / 1000); if (!activePart) previewObj.rotation.y += 0.004; }
|
||||
orbit.update(); renderer.render(scn, cam);
|
||||
})(0);
|
||||
|
||||
// ---------- ghost list ----------
|
||||
function renderList() {
|
||||
const q = filter.toLowerCase();
|
||||
document.getElementById('ghosts').innerHTML = ghosts
|
||||
.filter(g => !q || g.name.toLowerCase().includes(q))
|
||||
.map(g => `<div class="gh ${g.id === selId ? 'sel' : ''}" data-id="${g.id}">
|
||||
<span class="dot" style="background:${COLORS[g.color]}"></span>${g.name}
|
||||
${chars.byId[g.id] ? '<span class="cust">●</span>' : ''}</div>`).join('');
|
||||
document.querySelectorAll('.gh').forEach(el => el.onclick = async () => {
|
||||
selId = el.dataset.id; activePart = null; gizmo.detach();
|
||||
if (previewObj) previewObj.rotation.y = 0;
|
||||
renderList(); renderSide(); await refreshPreview();
|
||||
});
|
||||
}
|
||||
document.getElementById('search').oninput = e => { filter = e.target.value; renderList(); };
|
||||
|
||||
// ---------- override helpers ----------
|
||||
const side = document.getElementById('side');
|
||||
const ov = () => (chars.byId[selId] ||= {});
|
||||
const cur = (k, dflt) => { const v = chars.byId[selId]?.[k]; return v != null ? v : (chars.defaults[k] != null ? chars.defaults[k] : dflt); };
|
||||
|
||||
function activeModel() {
|
||||
const id = cur('modelId', '');
|
||||
const models = manifest.models || [];
|
||||
if (!models.length) return null;
|
||||
return (id && models.find(m => m.id === id)) || models[0];
|
||||
}
|
||||
|
||||
/* Effective transform for a part = model default, plus ghost override when that scope is active. */
|
||||
function effectivePart(key) {
|
||||
const model = activeModel();
|
||||
if (!model || !model.parts || !model.parts[key]) return null;
|
||||
const ghostOv = chars.byId[selId]?.partOverrides?.[key];
|
||||
return mergePart(model.parts[key], partScope === 'ghost' ? ghostOv : null);
|
||||
}
|
||||
|
||||
/* Persist a transform into whichever scope is active. */
|
||||
function writePartTransform(key, tf) {
|
||||
const model = activeModel();
|
||||
if (!model) return;
|
||||
if (partScope === 'model') {
|
||||
const base = normalizePart(model.parts[key]) || { url: model.parts[key] };
|
||||
model.parts[key] = { url: base.url, offset: tf.offset, rotation: tf.rotation, scale: tf.scale };
|
||||
modelsDirty = true; setSaveDirty();
|
||||
} else {
|
||||
const o = ov();
|
||||
o.partOverrides ||= {};
|
||||
o.partOverrides[key] = { ...(o.partOverrides[key] || {}), ...tf };
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- side panel ----------
|
||||
function renderSide() {
|
||||
const g = ghosts.find(x => x.id === selId);
|
||||
if (!g) { side.innerHTML = ''; return; }
|
||||
const grad = gradients[g.color] || gradients.Blue;
|
||||
const models = (manifest.models || []);
|
||||
const textures = assets.assets.filter(a => a.kind === 'texture');
|
||||
const model = activeModel();
|
||||
const hasParts = model && model.parts && Object.keys(model.parts).length;
|
||||
|
||||
side.innerHTML = `
|
||||
<h2>${g.name}</h2>
|
||||
<div class="badge">${g.rarity} · ${g.color} · id <code>${g.id}</code></div>
|
||||
|
||||
<h2>Model</h2>
|
||||
<div class="row"><label>model</label>
|
||||
<select id="modelId">
|
||||
<option value="">— default (${models[0]?.id || 'procedural wisp'}) —</option>
|
||||
${models.map(m => `<option value="${m.id}" ${cur('modelId', '') === m.id ? 'selected' : ''}>${m.id}</option>`).join('')}
|
||||
</select></div>
|
||||
<div class="row"><label>height (cm)</label>
|
||||
<input type="number" step="0.5" min="0.5" id="heightCm" value="${cur('heightCm', baseHeight)}"></div>
|
||||
<div class="row"><label>extra scale</label>
|
||||
<input type="number" step="0.05" min="0.05" id="scale" value="${cur('scale', 1)}"></div>
|
||||
|
||||
${hasParts ? partPlacementSection(model, g) : `<div class="badge" style="margin-top:8px">
|
||||
This ghost uses the ${model ? 'model’s single mesh' : 'procedural wisp'} — build a multi-part model (Assets box) to place parts individually.</div>`}
|
||||
|
||||
<h2>Appearance</h2>
|
||||
<div class="row"><label>opacity</label>
|
||||
<input type="range" min="0.1" max="1" step="0.02" id="opacity" value="${cur('opacity', 0.92)}">
|
||||
<span id="opv" style="width:32px;font-size:.72rem">${(+cur('opacity', 0.92)).toFixed(2)}</span></div>
|
||||
<div class="row"><label>top colour</label>
|
||||
<input type="color" id="topColor" value="${cur('topColor', grad.top)}">
|
||||
<button id="resetTop" class="minibtn">reset</button></div>
|
||||
<div class="row"><label>bottom</label>
|
||||
<input type="color" id="bottomColor" value="${cur('bottomColor', grad.bottom)}">
|
||||
<button id="resetBottom" class="minibtn">reset</button></div>
|
||||
|
||||
<h2>Textures</h2>
|
||||
<div class="badge">Front-projected decals — no UV mapping needed. Use PNGs with transparency.</div>
|
||||
<div class="row"><label>face</label>
|
||||
<select id="faceTextureUrl"><option value="">— none —</option>
|
||||
${textures.map(a => `<option value="${a.url}" ${cur('faceTextureUrl', '') === a.url ? 'selected' : ''}>${a.name}</option>`).join('')}
|
||||
</select></div>
|
||||
<div class="row"><label>face Y</label><input type="range" min="0" max="1" step="0.01" id="faceY" value="${cur('faceY', 0.82)}"></div>
|
||||
<div class="row"><label>face size</label><input type="range" min="0.04" max="0.5" step="0.01" id="faceSize" value="${cur('faceSize', 0.16)}"></div>
|
||||
<div class="row"><label>torso</label>
|
||||
<select id="torsoTextureUrl"><option value="">— none —</option>
|
||||
${textures.map(a => `<option value="${a.url}" ${cur('torsoTextureUrl', '') === a.url ? 'selected' : ''}>${a.name}</option>`).join('')}
|
||||
</select></div>
|
||||
<div class="row"><label>torso Y</label><input type="range" min="0" max="1" step="0.01" id="torsoY" value="${cur('torsoY', 0.52)}"></div>
|
||||
<div class="row"><label>torso size</label><input type="range" min="0.04" max="0.6" step="0.01" id="torsoSize" value="${cur('torsoSize', 0.22)}"></div>
|
||||
|
||||
<div class="row" style="margin-top:12px">
|
||||
<button id="clearChar" class="danger">Reset this ghost</button>
|
||||
<button id="applyAll">Apply to all</button>
|
||||
</div>
|
||||
<button id="save" class="primary" style="width:100%;padding:10px;margin-top:8px">Save characters${modelsDirty ? ' + model' : ''}</button>
|
||||
<div id="status" class="badge" style="margin-top:6px"></div>`;
|
||||
|
||||
bindCommon();
|
||||
if (hasParts) bindPartPlacement(model);
|
||||
}
|
||||
|
||||
function partPlacementSection(model, g) {
|
||||
const present = PART_KEYS.filter(k => model.parts[k]);
|
||||
if (!activePart || !model.parts[activePart]) activePart = present[0] || null;
|
||||
const p = activePart ? effectivePart(activePart) : null;
|
||||
const ghostHasOv = !!(activePart && chars.byId[selId]?.partOverrides?.[activePart]);
|
||||
|
||||
return `
|
||||
<h2>Part placement</h2>
|
||||
<div class="seg" id="scopeSeg">
|
||||
<button data-scope="model" class="${partScope === 'model' ? 'on' : ''}">Model default</button>
|
||||
<button data-scope="ghost" class="${partScope === 'ghost' ? 'on' : ''}">This ghost${ghostHasOv ? ' ●' : ''}</button>
|
||||
</div>
|
||||
<div class="badge">${partScope === 'model'
|
||||
? 'Editing the shared model — affects every ghost using it.'
|
||||
: 'Editing only ' + g.name + ' — layered over the model default.'}</div>
|
||||
<div class="parttabs" id="partTabs">
|
||||
${PART_KEYS.map(k => `<span class="parttab ${k === activePart ? 'active' : ''} ${model.parts[k] ? '' : 'absent'}" data-part="${k}">
|
||||
${k}${chars.byId[selId]?.partOverrides?.[k] ? '<span class="m">●</span>' : ''}</span>`).join('')}
|
||||
</div>
|
||||
${!activePart ? '<div class="badge">No parts in this model.</div>' : `
|
||||
<div class="seg" id="modeSeg">
|
||||
<button data-mode="translate" class="${gizmoMode === 'translate' ? 'on' : ''}">Move</button>
|
||||
<button data-mode="rotate" class="${gizmoMode === 'rotate' ? 'on' : ''}">Rotate</button>
|
||||
</div>
|
||||
<div class="row tri"><label>offset</label>
|
||||
<input type="number" step="0.05" id="pofx" value="${p.offset[0]}" title="X">
|
||||
<input type="number" step="0.05" id="pofy" value="${p.offset[1]}" title="Y">
|
||||
<input type="number" step="0.05" id="pofz" value="${p.offset[2]}" title="Z"></div>
|
||||
<div class="row tri"><label>rotation°</label>
|
||||
<input type="number" step="5" id="prox" value="${p.rotation[0]}" title="X">
|
||||
<input type="number" step="5" id="proy" value="${p.rotation[1]}" title="Y">
|
||||
<input type="number" step="5" id="proz" value="${p.rotation[2]}" title="Z"></div>
|
||||
<div class="row"><label>part scale</label>
|
||||
<input type="number" step="0.02" min="0.02" id="pscale" value="${p.scale}"></div>
|
||||
<div class="row">
|
||||
<button id="partReset" class="minibtn danger">Reset ${partScope === 'ghost' ? 'override' : 'part'}</button>
|
||||
<span class="badge">drag the gizmo or type values</span>
|
||||
</div>`}`;
|
||||
}
|
||||
|
||||
function bindPartPlacement(model) {
|
||||
document.querySelectorAll('#scopeSeg button').forEach(b => b.onclick = () => {
|
||||
partScope = b.dataset.scope; renderSide();
|
||||
if (activePart) attachGizmo(activePart);
|
||||
});
|
||||
document.querySelectorAll('#partTabs .parttab').forEach(el => el.onclick = () => {
|
||||
const k = el.dataset.part;
|
||||
if (!model.parts[k]) return;
|
||||
activePart = k; renderSide(); attachGizmo(k);
|
||||
});
|
||||
document.querySelectorAll('#modeSeg button').forEach(b => b.onclick = () => {
|
||||
gizmoMode = b.dataset.mode;
|
||||
document.querySelectorAll('#modeSeg button').forEach(x => x.classList.toggle('on', x === b));
|
||||
if (activePart) attachGizmo(activePart);
|
||||
});
|
||||
if (!activePart) return;
|
||||
|
||||
const readFields = () => ({
|
||||
offset: [num('pofx'), num('pofy'), num('pofz')],
|
||||
rotation: [num('prox'), num('proy'), num('proz')],
|
||||
scale: num('pscale', 1),
|
||||
});
|
||||
const onField = async () => { writePartTransform(activePart, readFields()); await refreshPreview(true); };
|
||||
['pofx','pofy','pofz','prox','proy','proz','pscale'].forEach(id => {
|
||||
const el = document.getElementById(id); if (el) el.oninput = onField;
|
||||
});
|
||||
document.getElementById('partReset').onclick = async () => {
|
||||
if (partScope === 'ghost') {
|
||||
const po = chars.byId[selId]?.partOverrides;
|
||||
if (po) { delete po[activePart]; if (!Object.keys(po).length) delete ov().partOverrides; }
|
||||
if (!Object.keys(chars.byId[selId] || {}).length) delete chars.byId[selId];
|
||||
} else {
|
||||
const base = normalizePart(model.parts[activePart]);
|
||||
if (base) { model.parts[activePart] = base.url; modelsDirty = true; setSaveDirty(); }
|
||||
}
|
||||
renderSide(); await refreshPreview(true);
|
||||
};
|
||||
|
||||
if (partNodes[activePart]) attachGizmo(activePart);
|
||||
}
|
||||
|
||||
const num = (id, dflt = 0) => { const v = parseFloat(document.getElementById(id)?.value); return Number.isFinite(v) ? v : dflt; };
|
||||
|
||||
function syncPartFields() {
|
||||
if (!activePart) return;
|
||||
const p = effectivePart(activePart);
|
||||
if (!p) return;
|
||||
const set = (id, v) => { const el = document.getElementById(id); if (el && document.activeElement !== el) el.value = v; };
|
||||
set('pofx', p.offset[0]); set('pofy', p.offset[1]); set('pofz', p.offset[2]);
|
||||
set('prox', p.rotation[0]); set('proy', p.rotation[1]); set('proz', p.rotation[2]);
|
||||
set('pscale', p.scale);
|
||||
}
|
||||
|
||||
function setSaveDirty() {
|
||||
const s = document.getElementById('save');
|
||||
if (s) s.textContent = 'Save characters + model';
|
||||
}
|
||||
|
||||
function bindCommon() {
|
||||
const bind = (id, key, transform = v => v) => {
|
||||
const el = document.getElementById(id);
|
||||
if (!el) return;
|
||||
el.oninput = el.onchange = async () => {
|
||||
const v = transform(el.value);
|
||||
if (v === '' || v == null) delete ov()[key]; else ov()[key] = v;
|
||||
if (id === 'opacity') document.getElementById('opv').textContent = (+v).toFixed(2);
|
||||
if (!Object.keys(chars.byId[selId] || {}).length) delete chars.byId[selId];
|
||||
if (id === 'modelId') { activePart = null; renderSide(); }
|
||||
await refreshPreview(id !== 'modelId'); renderList();
|
||||
};
|
||||
};
|
||||
bind('modelId', 'modelId');
|
||||
bind('heightCm', 'heightCm', v => parseFloat(v) || baseHeight);
|
||||
bind('scale', 'scale', v => parseFloat(v) || 1);
|
||||
bind('opacity', 'opacity', v => parseFloat(v));
|
||||
bind('topColor', 'topColor');
|
||||
bind('bottomColor', 'bottomColor');
|
||||
bind('faceTextureUrl', 'faceTextureUrl');
|
||||
bind('torsoTextureUrl', 'torsoTextureUrl');
|
||||
for (const k of ['faceY', 'faceSize', 'torsoY', 'torsoSize']) bind(k, k, v => parseFloat(v));
|
||||
|
||||
document.getElementById('resetTop').onclick = async () => { delete ov().topColor; await refreshPreview(true); renderSide(); };
|
||||
document.getElementById('resetBottom').onclick = async () => { delete ov().bottomColor; await refreshPreview(true); renderSide(); };
|
||||
document.getElementById('clearChar').onclick = async () => {
|
||||
delete chars.byId[selId]; activePart = null; await refreshPreview(); renderList(); renderSide();
|
||||
};
|
||||
document.getElementById('applyAll').onclick = async () => {
|
||||
const src = { ...(chars.byId[selId] || {}) };
|
||||
delete src.faceTextureUrl; // per-ghost faces stay per-ghost
|
||||
delete src.partOverrides; // per-ghost nudges stay per-ghost
|
||||
if (!confirm('Apply this ghost\'s model/appearance settings as the default for every ghost?')) return;
|
||||
chars.defaults = { ...chars.defaults, ...src };
|
||||
renderList(); renderSide();
|
||||
};
|
||||
document.getElementById('save').onclick = onSave;
|
||||
}
|
||||
|
||||
async function onSave() {
|
||||
const st = document.getElementById('status');
|
||||
try {
|
||||
if (modelsDirty) { manifest = await api('/api/models', 'PUT', manifest); manifest.models ||= []; modelsDirty = false; }
|
||||
chars = await api('/api/characters', 'PUT', chars); chars.byId ||= {}; chars.defaults ||= {};
|
||||
st.textContent = 'Saved ' + new Date().toLocaleTimeString(); renderList(); renderSide();
|
||||
} catch (e) { st.textContent = 'Save failed: ' + e.message; }
|
||||
}
|
||||
|
||||
// ---------- asset manager ----------
|
||||
function renderAssets() {
|
||||
const models = assets.assets.filter(a => a.kind === 'model');
|
||||
const textures = assets.assets.filter(a => a.kind === 'texture');
|
||||
let box = document.getElementById('assetBox');
|
||||
if (!box) {
|
||||
box = document.createElement('div');
|
||||
box.id = 'assetBox';
|
||||
box.style.cssText = 'margin-top:12px;border-top:1px solid #1a2244;padding-top:10px';
|
||||
document.getElementById('list').appendChild(box);
|
||||
}
|
||||
box.innerHTML = `
|
||||
<h2 style="font-size:.85rem;color:#8fb8ff;margin:0 0 6px">Assets</h2>
|
||||
<div class="drop" id="drop">Drop .obj / .png here<br><span style="opacity:.6">or</span>
|
||||
<input type="file" id="file" multiple accept=".obj,.mtl,.png,.jpg,.jpeg,.webp" style="width:100%;margin-top:5px"></div>
|
||||
<div class="badge" style="margin-top:6px">Models (${models.length})</div>
|
||||
<div class="assets">${models.map(a => `<span class="asset">${a.name}
|
||||
<button data-delasset="${a.id}" class="danger">✕</button></span>`).join('') || '<span class="badge">none</span>'}</div>
|
||||
<div class="badge">Textures (${textures.length})</div>
|
||||
<div class="assets">${textures.map(a => `<span class="asset"><img src="${a.url}">${a.name}
|
||||
<button data-delasset="${a.id}" class="danger">✕</button></span>`).join('') || '<span class="badge">none</span>'}</div>
|
||||
<button id="buildModel" style="width:100%;margin-top:6px">${manifest.models.length ? 'Build / edit model…' : 'Build model from parts…'}</button>`;
|
||||
|
||||
const drop = document.getElementById('drop');
|
||||
drop.ondragover = e => { e.preventDefault(); drop.classList.add('over'); };
|
||||
drop.ondragleave = () => drop.classList.remove('over');
|
||||
drop.ondrop = e => { e.preventDefault(); drop.classList.remove('over'); upload([...e.dataTransfer.files]); };
|
||||
document.getElementById('file').onchange = e => upload([...e.target.files]);
|
||||
document.querySelectorAll('[data-delasset]').forEach(b => b.onclick = async () => {
|
||||
if (!confirm('Delete this asset? Any character using it falls back to default.')) return;
|
||||
await api('/api/assets/' + b.dataset.delasset, 'DELETE');
|
||||
assets = await api('/api/assets'); renderAssets(); renderSide(); await refreshPreview();
|
||||
});
|
||||
document.getElementById('buildModel').onclick = () => openBuilder(activeModel()?.id || null);
|
||||
}
|
||||
|
||||
async function upload(files) {
|
||||
for (const f of files) {
|
||||
const ext = f.name.toLowerCase().split('.').pop();
|
||||
const kind = ['obj', 'mtl'].includes(ext) ? 'model' : 'texture';
|
||||
const dataBase64 = await new Promise((res, rej) => {
|
||||
const r = new FileReader();
|
||||
r.onload = () => res(String(r.result).split(',')[1]);
|
||||
r.onerror = () => rej(new Error('read failed'));
|
||||
r.readAsDataURL(f);
|
||||
});
|
||||
try { await api('/api/assets', 'POST', { name: f.name, kind, dataBase64 }); }
|
||||
catch (e) { alert(`${f.name}: ${e.message}`); }
|
||||
}
|
||||
assets = await api('/api/assets');
|
||||
renderAssets(); renderSide();
|
||||
}
|
||||
|
||||
// ---------- inline model builder (replaces sequential prompts) ----------
|
||||
let builderEl = null;
|
||||
function openBuilder(editId = null) {
|
||||
const objs = assets.assets.filter(a => a.kind === 'model' && a.name.toLowerCase().endsWith('.obj'));
|
||||
if (!objs.length) return alert('Upload some .obj parts first (drop them in the Assets box).');
|
||||
const existing = editId ? manifest.models.find(m => m.id === editId) : null;
|
||||
|
||||
if (builderEl) builderEl.remove();
|
||||
builderEl = document.createElement('div');
|
||||
builderEl.style.cssText = 'position:fixed;inset:0;background:rgba(4,6,14,.72);display:flex;align-items:center;justify-content:center;z-index:50';
|
||||
const partsInit = existing ? existing.parts : {};
|
||||
const urlToIdx = entry => { const u = typeof entry === 'string' ? entry : entry?.url; return objs.findIndex(o => o.url === u); };
|
||||
|
||||
builderEl.innerHTML = `<div style="background:#111730;border:1px solid #2a3a6e;border-radius:12px;padding:18px;width:420px;max-width:92vw;max-height:88vh;overflow:auto">
|
||||
<h2 style="margin:0 0 4px;color:#8fb8ff;font-size:1rem">${existing ? 'Edit model' : 'Build model from parts'}</h2>
|
||||
<div class="badge" style="margin-bottom:10px">Assign an uploaded .obj to each slot. Leave a slot as “— none —” to skip it. Fine placement is tuned afterward in the Part placement panel.</div>
|
||||
<div class="row"><label>model id</label>
|
||||
<input id="bmId" value="${existing ? existing.id : ''}" placeholder="e.g. minifig" ${existing ? 'readonly' : ''}></div>
|
||||
${PART_KEYS.map(k => `<div class="row"><label>${k}</label>
|
||||
<select data-slot="${k}"><option value="">— none —</option>
|
||||
${objs.map((o, i) => `<option value="${i}" ${urlToIdx(partsInit[k]) === i ? 'selected' : ''}>${o.name}</option>`).join('')}
|
||||
</select></div>`).join('')}
|
||||
<div class="row"><label>base scale</label>
|
||||
<input type="number" step="0.05" min="0.05" id="bmScale" value="${existing?.scale ?? 1}"></div>
|
||||
<div class="row" style="justify-content:flex-end;margin-top:12px">
|
||||
${existing ? `<button id="bmDelete" class="danger">Delete</button>` : ''}
|
||||
<button id="bmCancel">Cancel</button>
|
||||
<button id="bmSave" class="primary">${existing ? 'Update' : 'Create'}</button>
|
||||
</div>
|
||||
<div class="badge" id="bmStatus" style="margin-top:6px"></div>
|
||||
</div>`;
|
||||
document.body.appendChild(builderEl);
|
||||
|
||||
document.getElementById('bmCancel').onclick = () => { builderEl.remove(); builderEl = null; };
|
||||
const del = document.getElementById('bmDelete');
|
||||
if (del) del.onclick = async () => {
|
||||
if (!confirm(`Delete model "${existing.id}"? Ghosts using it fall back to the first model / wisp.`)) return;
|
||||
manifest.models = manifest.models.filter(m => m.id !== existing.id);
|
||||
manifest = await api('/api/models', 'PUT', manifest); manifest.models ||= [];
|
||||
builderEl.remove(); builderEl = null; renderAssets(); renderSide(); await refreshPreview();
|
||||
};
|
||||
document.getElementById('bmSave').onclick = async () => {
|
||||
const status = document.getElementById('bmStatus');
|
||||
const id = document.getElementById('bmId').value.trim();
|
||||
if (!id) return status.textContent = 'Give the model an id.';
|
||||
const newParts = {};
|
||||
document.querySelectorAll('[data-slot]').forEach(sel => {
|
||||
if (sel.value !== '' && objs[+sel.value]) {
|
||||
const url = objs[+sel.value].url;
|
||||
const prev = existing?.parts?.[sel.dataset.slot]; // keep placement if the file is unchanged
|
||||
newParts[sel.dataset.slot] = (prev && normalizePart(prev)?.url === url) ? prev : url;
|
||||
}
|
||||
});
|
||||
if (!Object.keys(newParts).length) return status.textContent = 'Pick at least one part.';
|
||||
const scale = parseFloat(document.getElementById('bmScale').value) || 1;
|
||||
manifest.models = manifest.models.filter(m => m.id !== id);
|
||||
manifest.models.push({ id, scale, parts: newParts });
|
||||
manifest = await api('/api/models', 'PUT', manifest); manifest.models ||= [];
|
||||
ov().modelId = id;
|
||||
if (!Object.keys(chars.byId[selId] || {}).length) delete chars.byId[selId];
|
||||
builderEl.remove(); builderEl = null;
|
||||
activePart = null; renderAssets(); renderList(); renderSide(); await refreshPreview();
|
||||
};
|
||||
}
|
||||
|
||||
renderList(); renderSide(); renderAssets(); fit(); await refreshPreview();
|
||||
</script>
|
||||
</body></html>
|
||||
@@ -0,0 +1,31 @@
|
||||
<!doctype html>
|
||||
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Client Errors — Newbury Exhibit</title></head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module">
|
||||
import { api, requireAuth, styles, nav } from './admin.js';
|
||||
document.head.insertAdjacentHTML('beforeend', `<style>${styles}
|
||||
main{max-width:900px;margin:16px auto;padding:0 16px}
|
||||
.err{background:#111730;border-radius:8px;padding:10px 12px;margin:8px 0;font:12px monospace;white-space:pre-wrap}
|
||||
.err .meta{opacity:.55;margin-bottom:4px}
|
||||
</style>`);
|
||||
await requireAuth();
|
||||
const app = document.getElementById('app');
|
||||
async function render() {
|
||||
const errs = await api('/api/client-errors');
|
||||
app.innerHTML = `${nav('errors')}<main>
|
||||
<div style="display:flex;align-items:center;gap:12px;margin:12px 0">
|
||||
<h2 style="margin:0;font-size:1rem">Client errors (${errs.length})</h2>
|
||||
<button id="refresh">Refresh</button>
|
||||
</div>
|
||||
${errs.slice().reverse().map(e => `<div class="err">
|
||||
<div class="meta">${new Date(e.t).toLocaleString()} · ${e.page || '?'} · ${(e.ua || '').slice(0, 80)}</div>
|
||||
[${e.kind}] ${e.msg}${e.src ? ` (${e.src}:${e.line})` : ''}</div>`).join('') ||
|
||||
'<em style="opacity:.6">No errors reported. Lovely.</em>'}
|
||||
</main>`;
|
||||
document.getElementById('refresh').onclick = render;
|
||||
}
|
||||
render();
|
||||
</script>
|
||||
</body></html>
|
||||
@@ -0,0 +1,181 @@
|
||||
<!doctype html>
|
||||
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Landing Page — Newbury Exhibit</title></head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module">
|
||||
import { api, requireAuth, styles, nav } from './admin.js';
|
||||
import { renderMarkdown } from '/js/md.js';
|
||||
|
||||
document.head.insertAdjacentHTML('beforeend', `<style>${styles}
|
||||
#wrap { display:grid; grid-template-columns: 1fr 420px; height:calc(100vh - 44px); }
|
||||
#form { padding:14px 18px; overflow:auto; }
|
||||
#form h2 { font-size:.9rem; color:#8fb8ff; margin:16px 0 6px; }
|
||||
#form h2:first-child { margin-top:0; }
|
||||
.row { display:flex; gap:8px; align-items:center; margin:6px 0; }
|
||||
.row label { width:120px; font-size:.78rem; }
|
||||
.row input, .row select, .row textarea { flex:1; min-width:0; font-family:inherit; }
|
||||
.row input[type=color] { padding:1px; height:28px; max-width:60px; }
|
||||
textarea { min-height:190px; background:#141a33; color:#fff; border:1px solid #2a3a6e;
|
||||
border-radius:5px; padding:8px; line-height:1.5; resize:vertical; }
|
||||
.badge { font-size:.72rem; opacity:.62; line-height:1.5; }
|
||||
.imgpick { display:flex; gap:8px; align-items:center; }
|
||||
.imgpick img { width:44px; height:44px; object-fit:contain; background:#0d1226; border-radius:5px; }
|
||||
#preview { background:#080b16; border-left:1px solid #1e2748; overflow:auto; padding:14px; }
|
||||
.phone { width:340px; height:680px; margin:0 auto; border-radius:26px; border:8px solid #222a44;
|
||||
overflow:hidden; position:relative; background:#06080f; }
|
||||
.scr { position:absolute; inset:0; display:flex; flex-direction:column; align-items:center;
|
||||
justify-content:center; text-align:center; padding:22px; background-size:cover; background-position:center; }
|
||||
.scr::before { content:''; position:absolute; inset:0; background:#06080f; }
|
||||
.scr > * { position:relative; z-index:1; }
|
||||
.scr img.logo { max-width:74%; max-height:30%; margin-bottom:14px; }
|
||||
.scr h1 { font-size:1.6rem; margin:0 0 8px; letter-spacing:.05em; }
|
||||
.scr p.sub { font-size:.85rem; opacity:.85; margin:0 0 22px; line-height:1.5; }
|
||||
.scr .b1 { padding:11px 30px; border-radius:999px; border:0; font-weight:700; font-size:.95rem; }
|
||||
.scr .b2 { margin-top:11px; padding:7px 18px; border-radius:999px; background:transparent;
|
||||
border:1px solid rgba(255,255,255,.35); font-size:.8rem; }
|
||||
.scr .disc { position:absolute; bottom:12px; left:14px; right:14px; font-size:.58rem; opacity:.55; line-height:1.35; z-index:1; }
|
||||
.dscr { position:absolute; inset:0; overflow:auto; padding:18px; background:#080b16; }
|
||||
.dscr h1 { font-size:1.25rem; margin:0 0 10px; }
|
||||
.dscr p, .dscr li { font-size:.82rem; line-height:1.6; opacity:.9; }
|
||||
.tabs { display:flex; gap:6px; justify-content:center; margin-bottom:10px; }
|
||||
</style>`);
|
||||
await requireAuth();
|
||||
|
||||
const app = document.getElementById('app');
|
||||
let L = await api('/api/landing');
|
||||
let assets = await api('/api/assets');
|
||||
let tab = 'start';
|
||||
|
||||
const images = () => assets.assets.filter(a => a.kind === 'texture');
|
||||
|
||||
function render() {
|
||||
app.innerHTML = `${nav('landing')}
|
||||
<div id="wrap">
|
||||
<div id="form">
|
||||
<h2>Artwork</h2>
|
||||
<div class="row"><label>logo</label>
|
||||
<div class="imgpick" style="flex:1">
|
||||
<img src="${L.logoUrl || ''}" onerror="this.style.visibility='hidden'">
|
||||
<select id="logoUrl" style="flex:1"><option value="">— none (show text title) —</option>
|
||||
${images().map(a => `<option value="${a.url}" ${L.logoUrl === a.url ? 'selected' : ''}>${a.name}</option>`).join('')}
|
||||
</select></div></div>
|
||||
<div class="row"><label>background</label>
|
||||
<div class="imgpick" style="flex:1">
|
||||
<img src="${L.backgroundUrl || ''}" onerror="this.style.visibility='hidden'">
|
||||
<select id="backgroundUrl" style="flex:1"><option value="">— none —</option>
|
||||
${images().map(a => `<option value="${a.url}" ${L.backgroundUrl === a.url ? 'selected' : ''}>${a.name}</option>`).join('')}
|
||||
</select></div></div>
|
||||
<div class="row"><label>upload</label><input type="file" id="file" accept=".png,.jpg,.jpeg,.webp" multiple></div>
|
||||
<div class="badge">Uploads share the asset library with the character manager.</div>
|
||||
|
||||
<h2>Landing page</h2>
|
||||
<div class="row"><label>title</label><input id="title" value="${esc(L.title)}"></div>
|
||||
<div class="badge">Shown only when no logo is selected.</div>
|
||||
<div class="row"><label>subtitle</label><input id="subtitle" value="${esc(L.subtitle)}"></div>
|
||||
<div class="row"><label>start button</label><input id="startButton" value="${esc(L.startButton)}"></div>
|
||||
<div class="row"><label>about button</label><input id="detailsButton" value="${esc(L.detailsButton)}"></div>
|
||||
|
||||
<h2>Details page</h2>
|
||||
<div class="row"><label>heading</label><input id="detailsTitle" value="${esc(L.detailsTitle)}"></div>
|
||||
<div class="row" style="align-items:flex-start"><label>body</label>
|
||||
<textarea id="detailsMarkdown">${esc(L.detailsMarkdown)}</textarea></div>
|
||||
<div class="badge">Markdown: <code>#</code> heading · <code>**bold**</code> · <code>*italic*</code> ·
|
||||
<code>- list</code> · <code>[text](https://…)</code> · blank line = new paragraph.
|
||||
Leave empty to hide the About button entirely.</div>
|
||||
|
||||
<h2>Theme</h2>
|
||||
<div class="row"><label>accent</label><input type="color" id="accent" value="${L.accent}">
|
||||
<label style="width:auto">text</label><input type="color" id="textColor" value="${L.textColor}"></div>
|
||||
<div class="row"><label>bg dimming</label>
|
||||
<input type="range" min="0" max="1" step="0.05" id="overlay" value="${L.overlay}">
|
||||
<span class="badge" id="ovv">${L.overlay}</span></div>
|
||||
|
||||
<h2>Disclaimer</h2>
|
||||
<div class="row" style="align-items:flex-start"><label>text</label>
|
||||
<textarea id="disclaimer" style="min-height:80px">${esc(L.disclaimer)}</textarea></div>
|
||||
<div class="badge">Always shown on both screens — wording is yours to edit, but it can't be removed.
|
||||
If left blank it reverts to the default wording.</div>
|
||||
|
||||
<button id="save" class="primary" style="width:100%;padding:11px;margin:16px 0 6px">Save landing page</button>
|
||||
<div id="status" class="badge"></div>
|
||||
</div>
|
||||
|
||||
<div id="preview">
|
||||
<div class="tabs">
|
||||
<button data-tab="start" class="${tab === 'start' ? 'primary' : ''}">Landing</button>
|
||||
<button data-tab="details" class="${tab === 'details' ? 'primary' : ''}">Details</button>
|
||||
</div>
|
||||
<div class="phone">${tab === 'start' ? startPreview() : detailsPreview()}</div>
|
||||
<div class="badge" style="text-align:center;margin-top:8px">Live preview · approximate phone size</div>
|
||||
</div>
|
||||
</div>`;
|
||||
wire();
|
||||
}
|
||||
|
||||
function esc(s) {
|
||||
return String(s == null ? '' : s).replace(/&/g, '&').replace(/</g, '<')
|
||||
.replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
function startPreview() {
|
||||
return `<div class="scr" style="background-image:${L.backgroundUrl ? `url('${L.backgroundUrl}')` : 'none'};color:${L.textColor}">
|
||||
<style>.phone .scr::before{opacity:${L.overlay}}</style>
|
||||
${L.logoUrl ? `<img class="logo" src="${L.logoUrl}">` : `<h1>${esc(L.title)}</h1>`}
|
||||
<p class="sub">${esc(L.subtitle)}</p>
|
||||
<button class="b1" style="background:linear-gradient(135deg,${L.accent},#529eff);color:#04121a">${esc(L.startButton)}</button>
|
||||
${L.detailsMarkdown ? `<button class="b2" style="color:${L.textColor}">${esc(L.detailsButton)}</button>` : ''}
|
||||
<div class="disc">${esc(L.disclaimer)}</div>
|
||||
</div>`;
|
||||
}
|
||||
function detailsPreview() {
|
||||
return `<div class="dscr" style="color:${L.textColor}">
|
||||
<h1 style="color:${L.accent}">${esc(L.detailsTitle)}</h1>
|
||||
${renderMarkdown(L.detailsMarkdown)}
|
||||
<div class="disc" style="position:static;margin-top:24px;font-size:.6rem;opacity:.55">${esc(L.disclaimer)}</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function wire() {
|
||||
const live = (id, key) => {
|
||||
const el = document.getElementById(id);
|
||||
if (!el) return;
|
||||
el.oninput = () => {
|
||||
L[key] = el.type === 'range' ? parseFloat(el.value) : el.value;
|
||||
if (id === 'overlay') document.getElementById('ovv').textContent = L.overlay;
|
||||
// repaint preview only — keeps focus/caret in the field being edited
|
||||
document.querySelector('.phone').innerHTML = tab === 'start' ? startPreview() : detailsPreview();
|
||||
};
|
||||
el.onchange = () => { if (['logoUrl', 'backgroundUrl'].includes(id)) render(); };
|
||||
};
|
||||
['title', 'subtitle', 'startButton', 'detailsButton', 'detailsTitle', 'detailsMarkdown',
|
||||
'disclaimer', 'accent', 'textColor', 'overlay', 'logoUrl', 'backgroundUrl'].forEach(k => live(k, k));
|
||||
|
||||
document.querySelectorAll('[data-tab]').forEach(b => b.onclick = () => { tab = b.dataset.tab; render(); });
|
||||
|
||||
document.getElementById('file').onchange = async e => {
|
||||
for (const f of [...e.target.files]) {
|
||||
const dataBase64 = await new Promise((res, rej) => {
|
||||
const r = new FileReader();
|
||||
r.onload = () => res(String(r.result).split(',')[1]);
|
||||
r.onerror = () => rej(new Error('read failed'));
|
||||
r.readAsDataURL(f);
|
||||
});
|
||||
try { await api('/api/assets', 'POST', { name: f.name, kind: 'texture', dataBase64 }); }
|
||||
catch (err) { alert(`${f.name}: ${err.message}`); }
|
||||
}
|
||||
assets = await api('/api/assets');
|
||||
render();
|
||||
};
|
||||
|
||||
document.getElementById('save').onclick = async () => {
|
||||
const st = document.getElementById('status');
|
||||
try { L = await api('/api/landing', 'PUT', L); render();
|
||||
document.getElementById('status').textContent = 'Saved ' + new Date().toLocaleTimeString() +
|
||||
' — reload the viewer to see it.';
|
||||
} catch (e) { st.textContent = 'Save failed: ' + e.message; }
|
||||
};
|
||||
}
|
||||
render();
|
||||
</script>
|
||||
</body></html>
|
||||
@@ -0,0 +1,611 @@
|
||||
<!doctype html>
|
||||
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Layout Editor (3D) — Newbury Exhibit</title></head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="importmap">
|
||||
{ "imports": {
|
||||
"three": "https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js",
|
||||
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.160.0/examples/jsm/"
|
||||
} }
|
||||
</script>
|
||||
<script type="module">
|
||||
import * as THREE from 'three';
|
||||
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
|
||||
import { TransformControls } from 'three/addons/controls/TransformControls.js';
|
||||
import { anchorWorldMatrix } from '/js/ar/pose.js';
|
||||
import { SET_FOOTPRINTS, footprintLabel, footprintToBuilding } from '/js/set-footprints.js';
|
||||
import { api, requireAuth, styles, nav } from './admin.js';
|
||||
|
||||
document.head.insertAdjacentHTML('beforeend', `<style>${styles}
|
||||
#wrap { display:grid; grid-template-columns: 1fr 300px; height: calc(100vh - 88px); }
|
||||
#view { position:relative; }
|
||||
#gl { width:100%; height:100%; display:block; touch-action:none; }
|
||||
#side { background:#111730; padding:12px; overflow:auto; }
|
||||
#side h2 { font-size:.9rem; margin:14px 0 6px; color:#8fb8ff; }
|
||||
.row { display:flex; gap:6px; align-items:center; margin:4px 0; }
|
||||
.row label { width:70px; }
|
||||
.row input, .row select { flex:1; min-width:0; }
|
||||
.tools { display:flex; gap:6px; padding:8px 12px; background:#0d1226; flex-wrap:wrap; align-items:center; }
|
||||
.badge { font-size:.7rem; opacity:.6; }
|
||||
#legend { position:absolute; left:10px; bottom:10px; font-size:.72rem; background:rgba(6,8,15,.6);
|
||||
padding:6px 10px; border-radius:8px; line-height:1.5; pointer-events:none; }
|
||||
.modal { position:fixed; inset:0; background:rgba(4,6,12,.7); display:none; z-index:50;
|
||||
align-items:center; justify-content:center; }
|
||||
.modal.open { display:flex; }
|
||||
.modalCard { background:#111730; border:1px solid #2a3a6e; border-radius:10px; padding:16px;
|
||||
width:min(420px,92vw); max-height:80vh; display:flex; flex-direction:column; }
|
||||
.modalCard h2 { margin:0 0 4px; font-size:1rem; color:#8fb8ff; }
|
||||
.modalCard .hint { font-size:.72rem; opacity:.6; margin:0 0 10px; }
|
||||
.modalList { overflow:auto; display:flex; flex-direction:column; gap:4px; }
|
||||
#setList button { text-align:left; background:#141a33; }
|
||||
#setList button:hover { background:#1c2650; }
|
||||
.modalCard .close { margin-top:12px; align-self:flex-end; }
|
||||
.layoutRow { display:flex; gap:6px; align-items:center; background:#141a33; border-radius:6px; padding:4px 6px; }
|
||||
.layoutRow .nm { flex:1; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||
.layoutRow .active { color:#51eaf1; font-size:.7rem; }
|
||||
.layoutRow button { padding:4px 10px; }
|
||||
#layoutSaveRow { display:flex; gap:6px; margin:10px 0 4px; }
|
||||
#layoutSaveRow input { flex:1; min-width:0; }
|
||||
</style>`);
|
||||
|
||||
await requireAuth();
|
||||
const app = document.getElementById('app');
|
||||
app.innerHTML = `${nav('layout')}
|
||||
<div class="tools">
|
||||
<button data-add="anchor">+ Anchor</button>
|
||||
<button data-add="building">+ Building</button>
|
||||
<button id="importSet">Import set…</button>
|
||||
<button data-add="spawn">+ Spawn</button>
|
||||
<button data-add="path">+ Path</button>
|
||||
<button id="tableBtn">Table…</button>
|
||||
<button id="layoutsBtn">Layouts…</button>
|
||||
<button id="topView">Top view</button>
|
||||
<button onclick="location.href='/admin/plan.html'">Print plan ↗</button>
|
||||
<span style="flex:1"></span>
|
||||
<button id="reset" class="danger">Reset to seed</button>
|
||||
<button id="save" class="primary">Save layout</button>
|
||||
<span id="status" class="badge"></span>
|
||||
</div>
|
||||
<div id="wrap">
|
||||
<div id="view">
|
||||
<canvas id="gl"></canvas>
|
||||
<div id="legend"><b>N = +Z (back of table)</b> · grid 25 cm · drag gizmo to move (0.5 cm snap)<br>
|
||||
flat crest arrow = top edge of print · wall crest arrow = direction the print faces<br>
|
||||
purple = ghost paths: drag the spheres, cone shows walk direction</div>
|
||||
</div>
|
||||
<div id="side"></div>
|
||||
</div>
|
||||
<div id="setModal" class="modal"><div class="modalCard">
|
||||
<h2>Import Hidden Side set</h2>
|
||||
<p class="hint">Adds a to-scale footprint (a building box) at the view centre. Drag it into place,
|
||||
then set its height/yaw in the panel. Footprints in cm; not affiliated with the LEGO Group.</p>
|
||||
<div id="setList" class="modalList"></div>
|
||||
<button class="close">Close</button>
|
||||
</div></div>
|
||||
<div id="layoutsModal" class="modal"><div class="modalCard">
|
||||
<h2>Layouts</h2>
|
||||
<p class="hint">Each layout stores this scene <b>and</b> its playlist. Save the current arrangement
|
||||
under a name, then load any saved layout to switch the live exhibit (all viewers update).
|
||||
Save your current edits first — loading replaces the live scene.</p>
|
||||
<div id="layoutSaveRow">
|
||||
<input id="layoutName" placeholder="Layout name (e.g. Small demo)">
|
||||
<button id="layoutSave" class="primary">Save current</button>
|
||||
</div>
|
||||
<div id="layoutList" class="modalList"></div>
|
||||
<button class="close">Close</button>
|
||||
</div></div>`;
|
||||
|
||||
let scene = await api('/api/scene');
|
||||
scene.anchors ||= []; scene.buildings ||= []; scene.spawns ||= []; scene.paths ||= [];
|
||||
scene.table = Object.assign({ width: 120, depth: 100, offsetX: 0, offsetZ: 0, show: true }, scene.table || {});
|
||||
|
||||
// Active layout slug drives spawn/path ID prefixes (e.g. small-demo -> "sd-sp1").
|
||||
// Fetched here and refreshed whenever a layout is saved or loaded.
|
||||
let activeLayoutSlug = null;
|
||||
try { activeLayoutSlug = (await api('/api/layouts')).activeSlug || null; } catch { activeLayoutSlug = null; }
|
||||
|
||||
// Short prefix from a slug: initials of its words. "small-demo" -> "sd",
|
||||
// "full-build" -> "fb". Null slug (unsaved scene) -> null (use legacy names).
|
||||
function layoutPrefix() {
|
||||
if (!activeLayoutSlug) return null;
|
||||
const initials = activeLayoutSlug.split('-').filter(Boolean).map(w => w[0]).join('');
|
||||
return initials || activeLayoutSlug.slice(0, 3);
|
||||
}
|
||||
|
||||
// Next free "<prefix>-sp<n>" (or "spawn-<n>" when no layout is active),
|
||||
// scanning existing spawn IDs so numbers don't collide after deletes.
|
||||
function nextSpawnId() {
|
||||
const p = layoutPrefix();
|
||||
const base = p ? `${p}-sp` : 'spawn-';
|
||||
const used = new Set(scene.spawns.map(s => s.id));
|
||||
let n = 1; while (used.has(base + n)) n++;
|
||||
return base + n;
|
||||
}
|
||||
|
||||
// ---------- three.js setup ----------
|
||||
const cvs = document.getElementById('gl');
|
||||
const renderer = new THREE.WebGLRenderer({ canvas: cvs, antialias: true });
|
||||
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
|
||||
const scn = new THREE.Scene();
|
||||
scn.background = new THREE.Color(0x0d1226);
|
||||
const cam = new THREE.PerspectiveCamera(55, 1, 1, 20000);
|
||||
cam.position.set(90, 120, -110);
|
||||
scn.add(new THREE.AmbientLight(0xffffff, 0.9));
|
||||
const dl = new THREE.DirectionalLight(0xffffff, 0.9); dl.position.set(100, 250, -100); scn.add(dl);
|
||||
|
||||
const orbit = new OrbitControls(cam, cvs);
|
||||
orbit.target.set(55, 0, 55);
|
||||
|
||||
const gizmo = new TransformControls(cam, cvs);
|
||||
gizmo.setTranslationSnap(0.5); // 0.5 cm
|
||||
gizmo.setSize(0.8);
|
||||
gizmo.addEventListener('dragging-changed', e => orbit.enabled = !e.value);
|
||||
gizmo.addEventListener('objectChange', onGizmoMove);
|
||||
scn.add(gizmo);
|
||||
|
||||
// grid + axes + compass
|
||||
scn.add(new THREE.GridHelper(400, 16, 0x2a3a6e, 0x1a2244)); // 400 cm, 25 cm cells
|
||||
function textSprite(txt, color = '#e8ecff', size = 26) {
|
||||
const c = document.createElement('canvas'); c.width = 128; c.height = 64;
|
||||
const x = c.getContext('2d'); x.font = `bold ${size * 2}px system-ui`; x.fillStyle = color;
|
||||
x.textAlign = 'center'; x.textBaseline = 'middle'; x.fillText(txt, 64, 32);
|
||||
const sp = new THREE.Sprite(new THREE.SpriteMaterial({ map: new THREE.CanvasTexture(c), transparent: true, depthTest: false }));
|
||||
sp.scale.set(24, 12, 1); return sp;
|
||||
}
|
||||
const compass = new THREE.Group();
|
||||
compass.add(new THREE.ArrowHelper(new THREE.Vector3(0, 0, 1), new THREE.Vector3(0, 0.5, 0), 40, 0x51eaf1, 10, 6));
|
||||
const nL = textSprite('N', '#51eaf1'); nL.position.set(0, 4, 52); compass.add(nL);
|
||||
const sL = textSprite('S', '#8899bb'); sL.position.set(0, 4, -52); compass.add(sL);
|
||||
const eL = textSprite('E', '#8899bb'); eL.position.set(52, 4, 0); compass.add(eL);
|
||||
const wL = textSprite('W', '#8899bb'); wL.position.set(-52, 4, 0); compass.add(wL);
|
||||
scn.add(compass);
|
||||
|
||||
// ---------- object meshes ----------
|
||||
const objRoot = new THREE.Group(); scn.add(objRoot);
|
||||
let sel = null; // { kind, index, obj3d }
|
||||
|
||||
const matAnchorFlat = new THREE.MeshStandardMaterial({ color: 0x3bc9a7, side: THREE.DoubleSide });
|
||||
const matAnchorWall = new THREE.MeshStandardMaterial({ color: 0xf65151, side: THREE.DoubleSide });
|
||||
const matBuilding = new THREE.MeshStandardMaterial({ color: 0x529eff, transparent: true, opacity: 0.35 });
|
||||
const matSpawn = new THREE.MeshStandardMaterial({ color: 0xb57f0b });
|
||||
const matPath = new THREE.LineBasicMaterial({ color: 0xc77dff });
|
||||
const matPathPt = new THREE.MeshStandardMaterial({ color: 0xc77dff });
|
||||
|
||||
const tableGroup = new THREE.Group(); scn.add(tableGroup);
|
||||
function buildTable() {
|
||||
tableGroup.clear();
|
||||
const t = scene.table;
|
||||
if (!t || t.show === false) return;
|
||||
const x0 = t.offsetX || 0, z0 = t.offsetZ || 0, w = t.width, d = t.depth;
|
||||
// surface tint
|
||||
const top = new THREE.Mesh(new THREE.PlaneGeometry(w, d),
|
||||
new THREE.MeshBasicMaterial({ color: 0xffb84d, transparent: true, opacity: 0.07, side: THREE.DoubleSide, depthWrite: false }));
|
||||
top.rotation.x = -Math.PI / 2;
|
||||
top.position.set(x0 + w / 2, -0.2, z0 + d / 2);
|
||||
tableGroup.add(top);
|
||||
// edge outline
|
||||
const pts = [[x0, z0], [x0 + w, z0], [x0 + w, z0 + d], [x0, z0 + d], [x0, z0]]
|
||||
.map(([x, z]) => new THREE.Vector3(x, 0.3, z));
|
||||
tableGroup.add(new THREE.Line(new THREE.BufferGeometry().setFromPoints(pts),
|
||||
new THREE.LineBasicMaterial({ color: 0xffb84d })));
|
||||
// corner posts
|
||||
for (const [x, z] of [[x0, z0], [x0 + w, z0], [x0 + w, z0 + d], [x0, z0 + d]]) {
|
||||
const c = new THREE.Mesh(new THREE.CylinderGeometry(1, 1, 3, 8),
|
||||
new THREE.MeshBasicMaterial({ color: 0xffb84d }));
|
||||
c.position.set(x, 1.5, z);
|
||||
tableGroup.add(c);
|
||||
}
|
||||
// dimension labels: width along the front (S) edge, depth along the left (W) edge
|
||||
const wl = textSprite(`${w} cm`, '#ffb84d'); wl.position.set(x0 + w / 2, 5, z0 - 7); tableGroup.add(wl);
|
||||
const dl2 = textSprite(`${d} cm`, '#ffb84d'); dl2.position.set(x0 - 9, 5, z0 + d / 2); tableGroup.add(dl2);
|
||||
}
|
||||
|
||||
function buildAll() {
|
||||
gizmo.detach();
|
||||
buildTable();
|
||||
objRoot.clear();
|
||||
scene.anchors.forEach((a, i) => {
|
||||
const size = (a.sizeMM || 60) / 10; // cm
|
||||
const g = new THREE.Group();
|
||||
const plane = new THREE.Mesh(new THREE.PlaneGeometry(size, size),
|
||||
(a.mount === 'wall' ? matAnchorWall : matAnchorFlat).clone());
|
||||
plane.material.opacity = a.enabled === false ? 0.25 : 1;
|
||||
plane.material.transparent = a.enabled === false;
|
||||
g.add(plane);
|
||||
// orientation arrow: marker local +Y = top edge of the print
|
||||
g.add(new THREE.ArrowHelper(new THREE.Vector3(0, 1, 0), new THREE.Vector3(0, 0, 0.2), size * 1.1, 0xffffff, size * 0.35, size * 0.2));
|
||||
const idL = textSprite('#' + a.markerId, '#a6fff0', 20); idL.position.set(0, 0, 3); idL.scale.set(14, 7, 1); g.add(idL);
|
||||
// pose from the SAME transform the AR pipeline uses
|
||||
anchorWorldMatrix(a).decompose(g.position, g.quaternion, g.scale);
|
||||
g.userData = { kind: 'anchor', index: i };
|
||||
objRoot.add(g);
|
||||
});
|
||||
scene.buildings.forEach((b, i) => {
|
||||
const m = new THREE.Mesh(new THREE.BoxGeometry(...b.size), matBuilding.clone());
|
||||
m.position.set(b.position[0], b.position[1] + b.size[1] / 2, b.position[2]);
|
||||
m.rotation.y = THREE.MathUtils.degToRad(b.yawDeg || 0);
|
||||
m.userData = { kind: 'building', index: i };
|
||||
objRoot.add(m);
|
||||
});
|
||||
scene.spawns.forEach((s, i) => {
|
||||
const m = new THREE.Mesh(new THREE.SphereGeometry(4, 16, 12), matSpawn.clone());
|
||||
if (s.enabled === false) { m.material.transparent = true; m.material.opacity = 0.3; }
|
||||
m.position.set(...s.position);
|
||||
m.userData = { kind: 'spawn', index: i };
|
||||
// drop line to the ground so height is readable
|
||||
const line = new THREE.Line(
|
||||
new THREE.BufferGeometry().setFromPoints([new THREE.Vector3(0, 0, 0), new THREE.Vector3(0, -s.position[1], 0)]),
|
||||
new THREE.LineDashedMaterial({ color: 0xb57f0b, dashSize: 2, gapSize: 2 }));
|
||||
line.computeLineDistances(); m.add(line);
|
||||
objRoot.add(m);
|
||||
});
|
||||
scene.paths.forEach((pth, i) => {
|
||||
const pts = (pth.points || []).map(q => new THREE.Vector3(...q));
|
||||
if (pts.length >= 2) {
|
||||
const lp = pth.mode === 'loop' ? [...pts, pts[0]] : pts;
|
||||
const line = new THREE.Line(new THREE.BufferGeometry().setFromPoints(lp), matPath.clone());
|
||||
if (pth.enabled === false) { line.material.transparent = true; line.material.opacity = 0.3; }
|
||||
objRoot.add(line);
|
||||
const dir = lp[1].clone().sub(lp[0]);
|
||||
if (dir.lengthSq() > 1e-4) {
|
||||
const cone = new THREE.Mesh(new THREE.ConeGeometry(2.2, 6, 10), matPathPt);
|
||||
cone.position.copy(lp[0]).addScaledVector(dir, 0.5);
|
||||
cone.quaternion.setFromUnitVectors(new THREE.Vector3(0, 1, 0), dir.clone().normalize());
|
||||
objRoot.add(cone);
|
||||
}
|
||||
}
|
||||
pts.forEach((q, wi) => {
|
||||
const h = new THREE.Mesh(new THREE.SphereGeometry(3, 14, 10), matPathPt.clone());
|
||||
h.position.copy(q);
|
||||
h.userData = { kind: 'pathpoint', index: i, sub: wi };
|
||||
const drop = new THREE.Line(
|
||||
new THREE.BufferGeometry().setFromPoints([new THREE.Vector3(0, 0, 0), new THREE.Vector3(0, -q.y, 0)]),
|
||||
new THREE.LineDashedMaterial({ color: 0xc77dff, dashSize: 2, gapSize: 2 }));
|
||||
drop.computeLineDistances(); h.add(drop);
|
||||
objRoot.add(h);
|
||||
});
|
||||
});
|
||||
reselect();
|
||||
}
|
||||
|
||||
function dataOf(s) {
|
||||
if (s.kind === 'table') return scene.table;
|
||||
if (s.kind === 'pathpoint') return scene.paths[s.index];
|
||||
return ({ anchor: scene.anchors, building: scene.buildings, spawn: scene.spawns })[s.kind][s.index];
|
||||
}
|
||||
|
||||
function reselect() {
|
||||
if (!sel) return;
|
||||
if (sel.kind === 'table') return;
|
||||
const o = objRoot.children.find(c => c.userData.kind === sel.kind && c.userData.index === sel.index && (sel.sub === undefined || c.userData.sub === sel.sub));
|
||||
if (o) { sel.obj3d = o; gizmo.attach(o); } else { sel = null; gizmo.detach(); }
|
||||
}
|
||||
|
||||
// ---------- selection ----------
|
||||
const ray = new THREE.Raycaster(); const ptr = new THREE.Vector2();
|
||||
let downAt = null;
|
||||
cvs.addEventListener('pointerdown', e => downAt = [e.clientX, e.clientY]);
|
||||
cvs.addEventListener('pointerup', e => {
|
||||
if (!downAt || Math.hypot(e.clientX - downAt[0], e.clientY - downAt[1]) > 6) return; // it was a drag
|
||||
if (gizmo.dragging) return;
|
||||
const r = cvs.getBoundingClientRect();
|
||||
ptr.set(((e.clientX - r.left) / r.width) * 2 - 1, -((e.clientY - r.top) / r.height) * 2 + 1);
|
||||
ray.setFromCamera(ptr, cam);
|
||||
const hits = ray.intersectObjects(objRoot.children, true);
|
||||
let top = hits.find(h => { let o = h.object; while (o && !o.userData.kind) o = o.parent; return o && o.userData.kind; });
|
||||
if (top) {
|
||||
let o = top.object; while (!o.userData.kind) o = o.parent;
|
||||
sel = { kind: o.userData.kind, index: o.userData.index, sub: o.userData.sub, obj3d: o };
|
||||
gizmo.attach(o);
|
||||
} else { sel = null; gizmo.detach(); }
|
||||
panel();
|
||||
});
|
||||
|
||||
function onGizmoMove() {
|
||||
if (!sel) return;
|
||||
const d = dataOf(sel);
|
||||
const p = sel.obj3d.position;
|
||||
if (sel.kind === 'pathpoint') d.points[sel.sub] = [+p.x.toFixed(1), +p.y.toFixed(1), +p.z.toFixed(1)];
|
||||
else if (sel.kind === 'building') d.position = [+p.x.toFixed(1), +(p.y - d.size[1] / 2).toFixed(1), +p.z.toFixed(1)];
|
||||
else d.position = [+p.x.toFixed(1), +p.y.toFixed(1), +p.z.toFixed(1)];
|
||||
panel(false);
|
||||
}
|
||||
// path lines follow their points only after the drag ends (cheap + smooth)
|
||||
gizmo.addEventListener('mouseUp', () => { if (sel && sel.kind === 'pathpoint') buildAll(); });
|
||||
|
||||
// ---------- add / delete ----------
|
||||
document.querySelectorAll('[data-add]').forEach(b => b.onclick = () => {
|
||||
const t = orbit.target;
|
||||
if (b.dataset.add === 'anchor') {
|
||||
const used = new Set(scene.anchors.map(a => a.markerId));
|
||||
let id = 0; while (used.has(id)) id++;
|
||||
scene.anchors.push({ markerId: id, position: [t.x, 0, t.z], mount: 'flat', yawDeg: 0, pitchDeg: 0, rollDeg: 0, sizeMM: 60, enabled: true });
|
||||
sel = { kind: 'anchor', index: scene.anchors.length - 1 };
|
||||
} else if (b.dataset.add === 'building') {
|
||||
scene.buildings.push({ name: 'building', position: [t.x, 0, t.z], size: [40, 30, 30], yawDeg: 0 });
|
||||
sel = { kind: 'building', index: scene.buildings.length - 1 };
|
||||
} else if (b.dataset.add === 'path') {
|
||||
scene.paths.push({ id: 'path-' + (scene.paths.length + 1), mode: 'loop', enabled: true,
|
||||
points: [[t.x - 25, 25, t.z], [t.x + 25, 25, t.z], [t.x, 25, t.z + 30]] });
|
||||
sel = { kind: 'pathpoint', index: scene.paths.length - 1, sub: 0 };
|
||||
} else {
|
||||
scene.spawns.push({ id: nextSpawnId(), position: [t.x, 25, t.z], enabled: true });
|
||||
sel = { kind: 'spawn', index: scene.spawns.length - 1 };
|
||||
}
|
||||
buildAll(); panel();
|
||||
});
|
||||
|
||||
// ---------- import set footprint ----------
|
||||
const setModal = document.getElementById('setModal');
|
||||
const setList = document.getElementById('setList');
|
||||
setList.innerHTML = SET_FOOTPRINTS
|
||||
.map((fp, i) => `<button data-fp="${i}">${footprintLabel(fp)}</button>`).join('');
|
||||
document.getElementById('importSet').onclick = () => setModal.classList.add('open');
|
||||
setModal.querySelector('.close').onclick = () => setModal.classList.remove('open');
|
||||
setModal.onclick = e => { if (e.target === setModal) setModal.classList.remove('open'); };
|
||||
setList.querySelectorAll('[data-fp]').forEach(b => b.onclick = () => {
|
||||
const fp = SET_FOOTPRINTS[+b.dataset.fp];
|
||||
const t = orbit.target;
|
||||
scene.buildings.push(footprintToBuilding(fp, [t.x, 0, t.z]));
|
||||
sel = { kind: 'building', index: scene.buildings.length - 1 };
|
||||
setModal.classList.remove('open');
|
||||
buildAll(); panel();
|
||||
});
|
||||
|
||||
// ---------- layouts (save / load / delete) ----------
|
||||
const layoutsModal = document.getElementById('layoutsModal');
|
||||
const layoutList = document.getElementById('layoutList');
|
||||
async function refreshLayouts() {
|
||||
let data;
|
||||
try { data = await api('/api/layouts'); }
|
||||
catch { layoutList.innerHTML = '<em style="opacity:.6">Could not load layouts.</em>'; return; }
|
||||
const { layouts, activeSlug } = data;
|
||||
if (!layouts.length) { layoutList.innerHTML = '<em style="opacity:.6">No saved layouts yet. Name one above and Save current.</em>'; return; }
|
||||
layoutList.innerHTML = layouts.map(l => `<div class="layoutRow" data-slug="${l.slug}">
|
||||
<span class="nm">${l.name}${l.slug === activeSlug ? ' <span class="active">● active</span>' : ''}</span>
|
||||
<button data-act="load">Load</button>
|
||||
<button data-act="del" class="danger">✕</button>
|
||||
</div>`).join('');
|
||||
layoutList.querySelectorAll('.layoutRow').forEach(row => {
|
||||
const slug = row.dataset.slug;
|
||||
row.querySelector('[data-act="load"]').onclick = async () => {
|
||||
if (!confirm('Load this layout? The current live scene will be replaced (save it first if unsaved).')) return;
|
||||
try {
|
||||
const r = await api('/api/layouts/' + slug + '/load', 'POST');
|
||||
scene = r.scene;
|
||||
scene.anchors ||= []; scene.buildings ||= []; scene.spawns ||= []; scene.paths ||= [];
|
||||
scene.table = Object.assign({ width: 120, depth: 100, offsetX: 0, offsetZ: 0, show: true }, scene.table || {});
|
||||
activeLayoutSlug = (r.layouts && r.layouts.activeSlug) || slug;
|
||||
sel = null; gizmo.detach(); buildAll(); panel();
|
||||
status.textContent = 'Loaded layout';
|
||||
refreshLayouts();
|
||||
} catch (e) { alert('Load failed: ' + e.message); }
|
||||
};
|
||||
row.querySelector('[data-act="del"]').onclick = async () => {
|
||||
if (!confirm('Delete this saved layout? (Does not affect the live scene.)')) return;
|
||||
try { await api('/api/layouts/' + slug, 'DELETE'); refreshLayouts(); }
|
||||
catch (e) { alert('Delete failed: ' + e.message); }
|
||||
};
|
||||
});
|
||||
}
|
||||
document.getElementById('layoutsBtn').onclick = () => { layoutsModal.classList.add('open'); refreshLayouts(); };
|
||||
layoutsModal.querySelector('.close').onclick = () => layoutsModal.classList.remove('open');
|
||||
layoutsModal.onclick = e => { if (e.target === layoutsModal) layoutsModal.classList.remove('open'); };
|
||||
document.getElementById('layoutSave').onclick = async () => {
|
||||
const name = document.getElementById('layoutName').value.trim();
|
||||
if (!name) return alert('Give the layout a name first.');
|
||||
try {
|
||||
// persist current edits into the live scene, then snapshot as a layout
|
||||
scene = await api('/api/scene', 'PUT', scene);
|
||||
const saved = await api('/api/layouts', 'POST', { name });
|
||||
activeLayoutSlug = saved.activeSlug || activeLayoutSlug;
|
||||
document.getElementById('layoutName').value = '';
|
||||
status.textContent = 'Saved layout "' + name + '"';
|
||||
refreshLayouts();
|
||||
} catch (e) { alert('Save failed: ' + e.message); }
|
||||
};
|
||||
|
||||
// ---------- spawn ID rename (keeps playlist references in sync) ----------
|
||||
// Given a { oldId: newId } map, rewrite scene.spawns, then load the playlist
|
||||
// config, remap track.spawnPoint (bare spawn refs only) and resident.spawnId,
|
||||
// and save both. Persists immediately so live viewers stay consistent.
|
||||
async function applySpawnRename(map) {
|
||||
const changed = Object.keys(map).filter(k => map[k] && map[k] !== k);
|
||||
if (!changed.length) return;
|
||||
for (const s of scene.spawns) if (map[s.id]) s.id = map[s.id];
|
||||
scene = await api('/api/scene', 'PUT', scene);
|
||||
try {
|
||||
const cfg = await api('/api/playlist');
|
||||
let touched = false;
|
||||
for (const t of cfg.tracks || []) {
|
||||
// spawnPoint is 'random', a bare spawn id, or 'path:<id>' — only remap bare spawn ids
|
||||
if (t.spawnPoint && !String(t.spawnPoint).startsWith('path:') && map[t.spawnPoint]) {
|
||||
t.spawnPoint = map[t.spawnPoint]; touched = true;
|
||||
}
|
||||
}
|
||||
for (const r of cfg.residents || []) {
|
||||
if (r.spawnId && map[r.spawnId]) { r.spawnId = map[r.spawnId]; touched = true; }
|
||||
}
|
||||
if (touched) await api('/api/playlist', 'PUT', cfg);
|
||||
} catch (e) { console.warn('playlist remap skipped:', e.message); }
|
||||
buildAll();
|
||||
}
|
||||
|
||||
// Renumber every spawn to the active layout's prefix: sp-prefix-1, -2, … in
|
||||
// current array order. Rewrites playlist refs via applySpawnRename.
|
||||
async function renumberSpawns() {
|
||||
if (!scene.spawns.length) return alert('No spawns to renumber.');
|
||||
const p = layoutPrefix();
|
||||
const base = p ? `${p}-sp` : 'spawn-';
|
||||
const preview = scene.spawns.map((s, i) => `${s.id} → ${base}${i + 1}`).join('\n');
|
||||
if (!confirm(`Renumber ${scene.spawns.length} spawn(s) to "${base}N"?\n\n${preview}\n\nPlaylist references will be updated too.`)) return;
|
||||
const map = {}; scene.spawns.forEach((s, i) => { map[s.id] = `${base}${i + 1}`; });
|
||||
try { await applySpawnRename(map); status.textContent = 'Renumbered spawns'; panel(); }
|
||||
catch (e) { alert('Renumber failed: ' + e.message); }
|
||||
}
|
||||
|
||||
// ---------- property panel ----------
|
||||
const side = document.getElementById('side');
|
||||
function field(label, val, oncommit, opts) {
|
||||
const id = 'f' + Math.random().toString(36).slice(2, 8);
|
||||
setTimeout(() => {
|
||||
const el = document.getElementById(id);
|
||||
el.onchange = () => { oncommit(opts?.select ? el.value : parseFloat(el.value)); buildAll(); panel(false); };
|
||||
});
|
||||
if (opts?.select)
|
||||
return `<div class="row"><label>${label}</label><select id="${id}">${opts.select.map(o => `<option ${o === val ? 'selected' : ''}>${o}</option>`).join('')}</select></div>`;
|
||||
return `<div class="row"><label>${label}</label><input id="${id}" type="number" step="${opts?.step ?? 0.5}" value="${val}"></div>`;
|
||||
}
|
||||
function textField(label, val, oncommit) {
|
||||
const id = 'f' + Math.random().toString(36).slice(2, 8);
|
||||
setTimeout(() => { const el = document.getElementById(id); el.onchange = () => { oncommit(el.value); buildAll(); panel(false); }; });
|
||||
return `<div class="row"><label>${label}</label><input id="${id}" value="${val}"></div>`;
|
||||
}
|
||||
const bearing = a => a.mount === 'wall'
|
||||
? `${((a.yawDeg % 360) + 360) % 360}°`
|
||||
: `${(((180 + a.yawDeg) % 360) + 360) % 360}°`;
|
||||
function panel(rebuild = true) {
|
||||
if (!rebuild) return;
|
||||
const o = sel && dataOf(sel);
|
||||
if (!o) { side.innerHTML = '<em style="opacity:.6">Tap an object to select it, then drag the gizmo arrows (X red, Y green = height, Z blue). All values in cm.</em>'; return; }
|
||||
if (sel.kind === 'table') {
|
||||
let h = `<h2>table & scale</h2>`;
|
||||
h += field('width (cm)', o.width, v => o.width = v, { step: 1 });
|
||||
h += field('depth (cm)', o.depth, v => o.depth = v, { step: 1 });
|
||||
h += field('offset x', o.offsetX || 0, v => o.offsetX = v, { step: 1 });
|
||||
h += field('offset z', o.offsetZ || 0, v => o.offsetZ = v, { step: 1 });
|
||||
h += field('show', o.show === false ? 'no' : 'yes', v => o.show = v === 'yes', { select: ['yes', 'no'] });
|
||||
h += field('ghost height (cm)', scene.ghostHeightCm ?? 4, v => scene.ghostHeightCm = v, { step: 0.5 });
|
||||
h += `<p class="badge">Ghost height scales every ghost (wisp or OBJ model) to that many cm tall —
|
||||
minifigure ≈ 4 cm. Models auto-normalize by bounding box, so imported OBJs land at this size
|
||||
regardless of their source units; per-model fine-tune lives in the models manifest. Applies
|
||||
live to all viewers on Save.</p>`;
|
||||
h += `<p class="badge">Orange outline = your physical table, measured from the world origin
|
||||
(front-left corner, N = +Z = back). Width runs W→E, depth runs S→N. Offsets shift the
|
||||
table if your origin isn't at its corner. Saved with the layout; also printed on the
|
||||
placement plan so you can sanity-check everything fits before taping down.</p>`;
|
||||
side.innerHTML = h;
|
||||
return;
|
||||
}
|
||||
let h = `<h2>${sel.kind} ${sel.index}</h2>`;
|
||||
if (sel.kind === 'anchor') {
|
||||
h += field('marker ID', o.markerId, v => o.markerId = v | 0, { step: 1 });
|
||||
h += field('x (cm)', o.position[0], v => o.position[0] = v);
|
||||
h += field('y (cm)', o.position[1], v => o.position[1] = v);
|
||||
h += field('z (cm)', o.position[2], v => o.position[2] = v);
|
||||
h += field('mount', o.mount, v => o.mount = v, { select: ['flat', 'wall', 'custom'] });
|
||||
h += field('yaw °', o.yawDeg, v => o.yawDeg = v, { step: 1 });
|
||||
h += field('pitch °', o.pitchDeg, v => o.pitchDeg = v, { step: 1 });
|
||||
h += field('roll °', o.rollDeg, v => o.rollDeg = v, { step: 1 });
|
||||
h += field('size mm', o.sizeMM, v => o.sizeMM = v, { step: 1 });
|
||||
h += field('enabled', o.enabled ? 'yes' : 'no', v => o.enabled = v === 'yes', { select: ['yes', 'no'] });
|
||||
h += `<p class="badge">Compass: ${o.mount === 'wall' ? 'print faces' : 'top edge of print points'} <b>${bearing(o)}</b>
|
||||
(0° = N = +Z back). The white arrow in the 3D view and on the print sheet show the same direction.</p>`;
|
||||
} else if (sel.kind === 'building') {
|
||||
h += textField('name', o.name || '', v => o.name = v);
|
||||
h += field('x (cm)', o.position[0], v => o.position[0] = v);
|
||||
h += field('y (cm)', o.position[1], v => o.position[1] = v);
|
||||
h += field('z (cm)', o.position[2], v => o.position[2] = v);
|
||||
h += field('width', o.size[0], v => o.size[0] = v);
|
||||
h += field('height', o.size[1], v => o.size[1] = v);
|
||||
h += field('depth', o.size[2], v => o.size[2] = v);
|
||||
h += field('yaw °', o.yawDeg || 0, v => o.yawDeg = v, { step: 1 });
|
||||
if (o.setNumber) h += `<p class="badge">Imported footprint · Hidden Side set <b>${o.setNumber}</b>.
|
||||
Size is the published build; adjust height/yaw freely.</p>`;
|
||||
} else if (sel.kind === 'pathpoint') {
|
||||
h = `<h2>path ${sel.index} · point ${sel.sub + 1}/${o.points.length}</h2>`;
|
||||
h += textField('path id', o.id, v => o.id = v);
|
||||
h += field('mode', o.mode || 'loop', v => o.mode = v, { select: ['loop', 'pingpong'] });
|
||||
h += field('enabled', o.enabled === false ? 'no' : 'yes', v => o.enabled = v === 'yes', { select: ['yes', 'no'] });
|
||||
const pt = o.points[sel.sub];
|
||||
h += field('x (cm)', pt[0], v => pt[0] = v);
|
||||
h += field('y (cm)', pt[1], v => pt[1] = v);
|
||||
h += field('z (cm)', pt[2], v => pt[2] = v);
|
||||
h += `<div class="row"><button id="addPt">+ point after</button><button id="delPt" class="danger">✕ point</button></div>`;
|
||||
h += `<p class="badge">Ghosts walk point→point at path speed, turning to face each new direction.
|
||||
loop = circles forever · pingpong = there and back. Tap other spheres to edit them;
|
||||
the cone marks walk direction. Delete below removes the whole path.</p>`;
|
||||
} else {
|
||||
h += `<div class="row"><label>id</label><input id="spawnId" value="${o.id}"><button id="spawnRename">Rename</button></div>`;
|
||||
h += field('x (cm)', o.position[0], v => o.position[0] = v);
|
||||
h += field('y (cm)', o.position[1], v => o.position[1] = v);
|
||||
h += field('z (cm)', o.position[2], v => o.position[2] = v);
|
||||
h += field('enabled', o.enabled === false ? 'no' : 'yes', v => o.enabled = v === 'yes', { select: ['yes', 'no'] });
|
||||
h += `<div class="row"><button id="spawnRenumber">Renumber all spawns</button></div>`;
|
||||
h += `<p class="badge">Editing the id is a failsafe — hit Rename to change it safely; any playlist
|
||||
references (track spawn points, pinned residents) are updated to match. Renumber all spawns retags
|
||||
every spawn to this layout's prefix (${layoutPrefix() ? layoutPrefix() + '-sp1, -sp2…' : 'spawn-1, -2…'}).
|
||||
Drag the green (Y) arrow to set spawn height — ghosts wander around this point in 3D.</p>`;
|
||||
}
|
||||
h += `<div class="row" style="margin-top:12px"><button id="del" class="danger">Delete</button></div>`;
|
||||
side.innerHTML = h;
|
||||
document.getElementById('del').onclick = () => {
|
||||
if (sel.kind === 'pathpoint') scene.paths.splice(sel.index, 1);
|
||||
else ({ anchor: scene.anchors, building: scene.buildings, spawn: scene.spawns })[sel.kind].splice(sel.index, 1);
|
||||
sel = null; gizmo.detach(); buildAll(); panel();
|
||||
};
|
||||
wirePathExtras();
|
||||
wireSpawnExtras();
|
||||
}
|
||||
|
||||
function wireSpawnExtras() {
|
||||
const renameBtn = document.getElementById('spawnRename');
|
||||
const renumBtn = document.getElementById('spawnRenumber');
|
||||
if (renameBtn) renameBtn.onclick = async () => {
|
||||
const o = dataOf(sel);
|
||||
const newId = document.getElementById('spawnId').value.trim();
|
||||
if (!newId || newId === o.id) return;
|
||||
if (scene.spawns.some(s => s !== o && s.id === newId)) return alert('That id is already used by another spawn.');
|
||||
try { await applySpawnRename({ [o.id]: newId }); status.textContent = 'Renamed spawn'; panel(); }
|
||||
catch (e) { alert('Rename failed: ' + e.message); }
|
||||
};
|
||||
if (renumBtn) renumBtn.onclick = renumberSpawns;
|
||||
}
|
||||
|
||||
function wirePathExtras() {
|
||||
const addBtn = document.getElementById('addPt'), delBtn = document.getElementById('delPt');
|
||||
if (addBtn) addBtn.onclick = () => {
|
||||
const pth = dataOf(sel);
|
||||
const a = pth.points[sel.sub], b = pth.points[(sel.sub + 1) % pth.points.length];
|
||||
pth.points.splice(sel.sub + 1, 0, [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2, (a[2] + b[2]) / 2]);
|
||||
sel.sub += 1; buildAll(); panel();
|
||||
};
|
||||
if (delBtn) delBtn.onclick = () => {
|
||||
const pth = dataOf(sel);
|
||||
if (pth.points.length <= 2) return alert('A path needs at least 2 points — delete the whole path instead.');
|
||||
pth.points.splice(sel.sub, 1);
|
||||
sel.sub = Math.max(0, sel.sub - 1); buildAll(); panel();
|
||||
};
|
||||
}
|
||||
|
||||
// ---------- toolbar ----------
|
||||
document.getElementById('tableBtn').onclick = () => {
|
||||
sel = { kind: 'table' }; gizmo.detach(); panel();
|
||||
};
|
||||
document.getElementById('topView').onclick = () => {
|
||||
cam.position.set(orbit.target.x, 320, orbit.target.z + 0.01);
|
||||
cam.lookAt(orbit.target);
|
||||
};
|
||||
const status = document.getElementById('status');
|
||||
document.getElementById('save').onclick = async () => {
|
||||
try { scene = await api('/api/scene', 'PUT', scene); buildAll(); status.textContent = 'Saved ' + new Date().toLocaleTimeString(); }
|
||||
catch (e) { status.textContent = 'Save failed: ' + e.message; }
|
||||
};
|
||||
document.getElementById('reset').onclick = async () => {
|
||||
if (!confirm('Reset layout to committed seed? Live edits will be lost.')) return;
|
||||
scene = await api('/api/scene/reset', 'POST');
|
||||
sel = null; gizmo.detach(); buildAll(); panel();
|
||||
};
|
||||
|
||||
// ---------- render loop ----------
|
||||
function fit() {
|
||||
const w = cvs.clientWidth, h = cvs.clientHeight;
|
||||
renderer.setSize(w, h, false);
|
||||
cam.aspect = w / h; cam.updateProjectionMatrix();
|
||||
}
|
||||
new ResizeObserver(fit).observe(cvs);
|
||||
fit(); buildAll(); panel();
|
||||
(function loop() { requestAnimationFrame(loop); orbit.update(); renderer.render(scn, cam); })();
|
||||
</script>
|
||||
</body></html>
|
||||
@@ -0,0 +1,24 @@
|
||||
<!doctype html>
|
||||
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Admin Login — Newbury Exhibit</title></head>
|
||||
<body>
|
||||
<div style="max-width:340px;margin:18vh auto;text-align:center">
|
||||
<h1 style="font-size:1.3rem">Exhibit Admin</h1>
|
||||
<input id="pw" type="password" placeholder="Admin password" style="width:100%;padding:10px;margin:12px 0;font-size:1rem">
|
||||
<button id="go" class="primary" style="width:100%;padding:10px;font-size:1rem">Sign in</button>
|
||||
<div id="err" style="color:#ff8b8b;margin-top:10px;font-size:.85rem"></div>
|
||||
</div>
|
||||
<script type="module">
|
||||
import { styles } from './admin.js';
|
||||
document.head.insertAdjacentHTML('beforeend', `<style>${styles}</style>`);
|
||||
const go = async () => {
|
||||
const res = await fetch('/api/login', { method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ password: document.getElementById('pw').value }) });
|
||||
if (!res.ok) { document.getElementById('err').textContent = 'Wrong password.'; return; }
|
||||
localStorage.setItem('exhibitToken', (await res.json()).token);
|
||||
location.href = new URLSearchParams(location.search).get('next') || '/admin/layout.html';
|
||||
};
|
||||
document.getElementById('go').onclick = go;
|
||||
document.getElementById('pw').onkeydown = (e) => e.key === 'Enter' && go();
|
||||
</script>
|
||||
</body></html>
|
||||
@@ -0,0 +1,254 @@
|
||||
<!doctype html>
|
||||
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Placement Plan — Newbury Exhibit</title>
|
||||
<style>
|
||||
body { font-family: system-ui, sans-serif; margin: 0; background: #666; }
|
||||
#controls { padding: 10px 16px; display: flex; gap: 14px; flex-wrap: wrap; align-items: center; background: #0d1226; color: #e8ecff; }
|
||||
.page { background: #fff; color: #000; margin: 10px auto; box-shadow: 0 2px 10px rgba(0,0,0,.5); position: relative; overflow: hidden; }
|
||||
.page svg { display: block; }
|
||||
.pgHead { position: absolute; top: 2mm; left: 5mm; right: 5mm; font-size: 8pt; display: flex; justify-content: space-between; }
|
||||
@media print {
|
||||
body { background: #fff; }
|
||||
#hdr, #controls { display: none !important; }
|
||||
.page { margin: 0; box-shadow: none; page-break-after: always; }
|
||||
}
|
||||
</style></head>
|
||||
<body>
|
||||
<div id="hdr"></div>
|
||||
<div id="controls"></div>
|
||||
<div id="pages"></div>
|
||||
|
||||
<script src="/vendor/cv.js"></script>
|
||||
<script src="/vendor/svd.js"></script>
|
||||
<script src="/vendor/posit1.js"></script>
|
||||
<script src="/vendor/aruco.js"></script>
|
||||
<script src="/vendor/dictionaries/aruco_4x4_1000.js"></script>
|
||||
<script type="module">
|
||||
import { requireAuth, styles, nav } from './admin.js';
|
||||
document.getElementById('hdr').innerHTML = nav('plan');
|
||||
document.head.insertAdjacentHTML('beforeend', `<style>@media screen {${styles}}</style>`);
|
||||
await requireAuth();
|
||||
|
||||
const scene = await (await fetch('/api/scene')).json();
|
||||
const dic = new AR.Dictionary('ARUCO_4X4_1000');
|
||||
|
||||
const PAPER = { A4: [210, 297], A3: [297, 420] };
|
||||
const MARGIN = 10; // mm printable margin
|
||||
const OVERLAP = 10; // mm tile overlap for joining
|
||||
|
||||
const opts = { scale: '1', paper: 'A4', table: true, anchors: true, buildings: true, spawns: true, paths: true };
|
||||
|
||||
document.getElementById('controls').innerHTML = `
|
||||
<label>Scale <select id="scale">
|
||||
<option value="1">1:1 (true size — tape it down)</option>
|
||||
<option value="2">1:2</option>
|
||||
<option value="5">1:5</option>
|
||||
<option value="fit">Fit one page</option>
|
||||
</select></label>
|
||||
<label>Paper <select id="paper"><option>A4</option><option>A3</option></select></label>
|
||||
${['table','anchors','buildings','spawns','paths'].map(k =>
|
||||
`<label><input type="checkbox" id="ly-${k}" checked> ${k}</label>`).join('')}
|
||||
<span style="flex:1"></span>
|
||||
<button class="primary" onclick="print()">Print…</button>
|
||||
<span id="pgCount" style="opacity:.7;font-size:.85rem"></span>`;
|
||||
['scale','paper'].forEach(id => document.getElementById(id).onchange = e => { opts[id] = e.target.value; build(); });
|
||||
['table','anchors','buildings','spawns','paths'].forEach(k =>
|
||||
document.getElementById('ly-' + k).onchange = e => { opts[k] = e.target.checked; build(); });
|
||||
|
||||
// ---------- geometry ----------
|
||||
function extent() {
|
||||
let minX = Infinity, minZ = Infinity, maxX = -Infinity, maxZ = -Infinity;
|
||||
const eat = (x, z, pad = 10) => {
|
||||
minX = Math.min(minX, x - pad); maxX = Math.max(maxX, x + pad);
|
||||
minZ = Math.min(minZ, z - pad); maxZ = Math.max(maxZ, z + pad);
|
||||
};
|
||||
const t = scene.table;
|
||||
if (t && t.show !== false) { eat(t.offsetX || 0, t.offsetZ || 0, 6); eat((t.offsetX || 0) + t.width, (t.offsetZ || 0) + t.depth, 6); }
|
||||
for (const a of scene.anchors || []) eat(a.position[0], a.position[2]);
|
||||
for (const b of scene.buildings || []) eat(b.position[0], b.position[2], Math.max(b.size[0], b.size[2]) / 2 + 5);
|
||||
for (const s of scene.spawns || []) eat(s.position[0], s.position[2]);
|
||||
for (const p of scene.paths || []) for (const q of p.points || []) eat(q[0], q[2]);
|
||||
// nothing to draw → fall back to a small default page area
|
||||
if (!isFinite(minX)) { minX = 0; minZ = 0; maxX = 100; maxZ = 100; }
|
||||
return { minX, minZ, maxX, maxZ, w: maxX - minX, h: maxZ - minZ };
|
||||
}
|
||||
|
||||
const brgOf = a => a.mount === 'wall'
|
||||
? ((a.yawDeg % 360) + 360) % 360
|
||||
: (((180 + (a.yawDeg || 0)) % 360) + 360) % 360;
|
||||
|
||||
/* Build the plan as SVG inner markup in mm units at scale k (world cm * 10 / k).
|
||||
* Page mapping: +X east = right, N (+Z) = UP the page. */
|
||||
function planSVG(ex, k) {
|
||||
const S = 10 / k; // mm per world-cm
|
||||
const X = x => (x - ex.minX) * S;
|
||||
const Y = z => (ex.maxZ - z) * S; // N up
|
||||
const px = v => +v.toFixed(2);
|
||||
let g = '';
|
||||
|
||||
// grid: 10 cm light, 50 cm dark, labels every 50 cm
|
||||
for (let x = Math.ceil(ex.minX / 10) * 10; x <= ex.maxX; x += 10) {
|
||||
const major = x % 50 === 0;
|
||||
g += `<line x1="${px(X(x))}" y1="0" x2="${px(X(x))}" y2="${px(Y(ex.minZ))}" stroke="${major ? '#999' : '#ddd'}" stroke-width="${major ? 0.3 : 0.15}"/>`;
|
||||
if (major) g += `<text x="${px(X(x))}" y="${px(Y(ex.minZ)) - 1}" font-size="3" text-anchor="middle" fill="#666">${x}</text>`;
|
||||
}
|
||||
for (let z = Math.ceil(ex.minZ / 10) * 10; z <= ex.maxZ; z += 10) {
|
||||
const major = z % 50 === 0;
|
||||
g += `<line x1="0" y1="${px(Y(z))}" x2="${px(X(ex.maxX))}" y2="${px(Y(z))}" stroke="${major ? '#999' : '#ddd'}" stroke-width="${major ? 0.3 : 0.15}"/>`;
|
||||
if (major) g += `<text x="1" y="${px(Y(z)) - 0.8}" font-size="3" fill="#666">${z}</text>`;
|
||||
}
|
||||
|
||||
// compass rose (top-left)
|
||||
g += `<g transform="translate(12,14)">
|
||||
<circle r="8" fill="none" stroke="#000" stroke-width="0.4"/>
|
||||
<path d="M0,2 L0,-6 M-1.6,-3.6 L0,-6 L1.6,-3.6" stroke="#000" stroke-width="0.7" fill="none"/>
|
||||
<text y="-9.5" font-size="4" text-anchor="middle" font-weight="bold">N (+Z, back)</text>
|
||||
<text y="13" font-size="3" text-anchor="middle" fill="#444">grid 10 cm</text></g>`;
|
||||
|
||||
if (opts.table && scene.table && scene.table.show !== false) {
|
||||
const t = scene.table;
|
||||
const x0 = t.offsetX || 0, z0 = t.offsetZ || 0;
|
||||
const tx = X(x0), ty = Y(z0 + t.depth), tw = t.width * S, th = t.depth * S;
|
||||
g += `<rect x="${px(tx)}" y="${px(ty)}" width="${px(tw)}" height="${px(th)}"
|
||||
fill="none" stroke="#e67e00" stroke-width="0.7"/>`;
|
||||
// dimension labels with arrows: width along the front (bottom) edge, depth along the left edge
|
||||
g += `<text x="${px(tx + tw / 2)}" y="${px(ty + th + 5)}" font-size="4" text-anchor="middle" fill="#e67e00" font-weight="bold">← ${t.width} cm →</text>`;
|
||||
g += `<text x="${px(tx - 3)}" y="${px(ty + th / 2)}" font-size="4" text-anchor="middle" fill="#e67e00" font-weight="bold"
|
||||
transform="rotate(-90 ${px(tx - 3)} ${px(ty + th / 2)})">← ${t.depth} cm →</text>`;
|
||||
g += `<text x="${px(tx + tw / 2)}" y="${px(ty - 2)}" font-size="3" text-anchor="middle" fill="#e67e00">TABLE ${t.width} × ${t.depth} cm — back edge (N)</text>`;
|
||||
}
|
||||
|
||||
if (opts.paths) for (const p of scene.paths || []) {
|
||||
const pts = (p.points || []);
|
||||
if (pts.length < 2) continue;
|
||||
const lp = p.mode === 'loop' ? [...pts, pts[0]] : pts;
|
||||
g += `<polyline points="${lp.map(q => `${px(X(q[0]))},${px(Y(q[2]))}`).join(' ')}" fill="none" stroke="#8a2be2" stroke-width="0.5" stroke-dasharray="2 1.2"/>`;
|
||||
for (let i = 0; i < lp.length - 1; i++) { // direction arrow per segment midpoint
|
||||
const mx = (X(lp[i][0]) + X(lp[i + 1][0])) / 2, my = (Y(lp[i][2]) + Y(lp[i + 1][2])) / 2;
|
||||
const ang = Math.atan2(Y(lp[i + 1][2]) - Y(lp[i][2]), X(lp[i + 1][0]) - X(lp[i][0])) * 180 / Math.PI;
|
||||
g += `<path d="M-1.8,-1.2 L1.8,0 L-1.8,1.2 Z" transform="translate(${px(mx)},${px(my)}) rotate(${px(ang)})" fill="#8a2be2"/>`;
|
||||
}
|
||||
pts.forEach((q, i) => {
|
||||
g += `<circle cx="${px(X(q[0]))}" cy="${px(Y(q[2]))}" r="1.4" fill="#fff" stroke="#8a2be2" stroke-width="0.5"/>
|
||||
<text x="${px(X(q[0]) + 2)}" y="${px(Y(q[2]) - 1.5)}" font-size="2.6" fill="#8a2be2">${p.id}·${i + 1} (y=${q[1]})</text>`;
|
||||
});
|
||||
}
|
||||
|
||||
if (opts.buildings) for (const b of scene.buildings || []) {
|
||||
const w = b.size[0] * S, d = b.size[2] * S;
|
||||
g += `<g transform="translate(${px(X(b.position[0]))},${px(Y(b.position[2]))}) rotate(${px(-(b.yawDeg || 0))})">
|
||||
<rect x="${px(-w / 2)}" y="${px(-d / 2)}" width="${px(w)}" height="${px(d)}" fill="#dbe8ff" fill-opacity="0.6" stroke="#4a7fd0" stroke-width="0.4"/>
|
||||
<text font-size="3.2" text-anchor="middle" fill="#2a4f90">${b.name || 'building'}</text>
|
||||
<text y="4" font-size="2.6" text-anchor="middle" fill="#2a4f90">h=${b.size[1]} cm</text></g>`;
|
||||
}
|
||||
|
||||
if (opts.spawns) for (const s of scene.spawns || []) {
|
||||
g += `<circle cx="${px(X(s.position[0]))}" cy="${px(Y(s.position[2]))}" r="2.2" fill="#ffd166" fill-opacity="0.7" stroke="#b57f0b" stroke-width="0.4"/>
|
||||
<text x="${px(X(s.position[0]) + 3)}" y="${px(Y(s.position[2]) + 1)}" font-size="2.8" fill="#7a5500">${s.id} (y=${s.position[1]})</text>`;
|
||||
}
|
||||
|
||||
if (opts.anchors) for (const a of scene.anchors || []) {
|
||||
const cx = X(a.position[0]), cy = Y(a.position[2]);
|
||||
const brg = brgOf(a);
|
||||
if (a.mount === 'wall') {
|
||||
// vertical marker: footprint line perpendicular to facing, arrow shows facing
|
||||
const half = (a.sizeMM / 10) * S / 2;
|
||||
g += `<g transform="translate(${px(cx)},${px(cy)}) rotate(${px(brg)})">
|
||||
<line x1="${px(-half)}" y1="0" x2="${px(half)}" y2="0" stroke="#c0392b" stroke-width="1"/>
|
||||
<path d="M0,0 L0,${px(-half)} M-1.5,${px(-half + 2)} L0,${px(-half)} L1.5,${px(-half + 2)}" stroke="#c0392b" stroke-width="0.5" fill="none"/></g>
|
||||
<text x="${px(cx + half + 1)}" y="${px(cy)}" font-size="3" fill="#c0392b">#${a.markerId} WALL faces ${brg}° · y=${a.position[1]}</text>`;
|
||||
continue;
|
||||
}
|
||||
// flat marker: true-size crest pattern rotated so its top edge faces brg (clockwise from N=up)
|
||||
const sz = (a.sizeMM / 10) * S; // mm on page
|
||||
const cell = sz / 6;
|
||||
const code = dic.codeList[a.markerId] || '';
|
||||
let cells = `<rect x="${px(-sz / 2)}" y="${px(-sz / 2)}" width="${px(sz)}" height="${px(sz)}" fill="#000"/>`;
|
||||
for (let yy = 0; yy < 4; yy++) for (let xx = 0; xx < 4; xx++)
|
||||
if (code[yy * 4 + xx] === '1')
|
||||
cells += `<rect x="${px(-sz / 2 + (xx + 1) * cell)}" y="${px(-sz / 2 + (yy + 1) * cell)}" width="${px(cell)}" height="${px(cell)}" fill="#fff"/>`;
|
||||
g += `<g transform="translate(${px(cx)},${px(cy)}) rotate(${px(brg)})">${cells}
|
||||
<path d="M0,${px(-sz / 2 - 1)} L0,${px(-sz / 2 - 5)} M-1.5,${px(-sz / 2 - 3.2)} L0,${px(-sz / 2 - 5)} L1.5,${px(-sz / 2 - 3.2)}" stroke="#0a8f6b" stroke-width="0.6" fill="none"/></g>
|
||||
<text x="${px(cx + sz / 2 + 1.5)}" y="${px(cy + 1)}" font-size="3" fill="#0a8f6b" font-weight="bold">#${a.markerId}</text>
|
||||
<text x="${px(cx + sz / 2 + 1.5)}" y="${px(cy + 4.5)}" font-size="2.4" fill="#0a8f6b">top→${brg}°</text>`;
|
||||
}
|
||||
return g;
|
||||
}
|
||||
|
||||
// ---------- pages ----------
|
||||
function build() {
|
||||
const ex = extent();
|
||||
const [pw, ph] = PAPER[opts.paper];
|
||||
const availW = pw - 2 * MARGIN, availH = ph - 2 * MARGIN;
|
||||
|
||||
let k;
|
||||
if (opts.scale === 'fit') k = Math.max((ex.w * 10) / availW, (ex.h * 10) / availH, 1);
|
||||
else k = parseFloat(opts.scale);
|
||||
|
||||
const S = 10 / k;
|
||||
const totW = ex.w * S, totH = ex.h * S; // mm
|
||||
const inner = planSVG(ex, k);
|
||||
|
||||
const stepW = availW - OVERLAP, stepH = availH - OVERLAP;
|
||||
const cols = Math.max(1, Math.ceil((totW - OVERLAP) / stepW));
|
||||
const rows = Math.max(1, Math.ceil((totH - OVERLAP) / stepH));
|
||||
|
||||
const pages = document.getElementById('pages');
|
||||
pages.innerHTML = '';
|
||||
const scaleLabel = opts.scale === 'fit' ? `1:${k.toFixed(1)}` : `1:${k}` + (k === 1 ? ' — TRUE SIZE: crest patterns are usable, tape sheet down' : '');
|
||||
|
||||
// overview page (always first): whole plan fit on one page with tile grid
|
||||
const fitK = Math.max((ex.w * 10) / availW, (ex.h * 10) / availH, 1);
|
||||
const ovW = ex.w * 10 / fitK, ovH = ex.h * 10 / fitK;
|
||||
let tileGrid = '';
|
||||
if (cols * rows > 1) {
|
||||
const rr = (10 / fitK) / S; // convert tile mm to overview mm
|
||||
for (let r = 0; r < rows; r++) for (let c = 0; c < cols; c++) {
|
||||
const tx = c * stepW * rr, ty = r * stepH * rr;
|
||||
tileGrid += `<rect x="${tx.toFixed(1)}" y="${ty.toFixed(1)}" width="${(availW * rr).toFixed(1)}" height="${(availH * rr).toFixed(1)}"
|
||||
fill="none" stroke="#e74c3c" stroke-width="0.4" stroke-dasharray="2 1"/>
|
||||
<text x="${(tx + 2).toFixed(1)}" y="${(ty + 5).toFixed(1)}" font-size="5" fill="#e74c3c" font-weight="bold">${String.fromCharCode(65 + r)}${c + 1}</text>`;
|
||||
}
|
||||
}
|
||||
pages.insertAdjacentHTML('beforeend', `
|
||||
<div class="page" style="width:${pw}mm;height:${ph}mm">
|
||||
<div class="pgHead"><b>Newbury Exhibit — placement plan (overview, 1:${fitK.toFixed(1)})</b>
|
||||
<span>${cols * rows > 1 ? `${cols * rows} tile pages follow at ${scaleLabel} — red boxes show tiles` : `scale ${scaleLabel}`}</span></div>
|
||||
<svg width="${ovW}mm" height="${ovH}mm" viewBox="0 0 ${ex.w * 10 / fitK} ${ex.h * 10 / fitK}"
|
||||
style="margin:${MARGIN + 4}mm ${MARGIN}mm">
|
||||
<g transform="scale(${(10 / fitK) / S})">${inner}</g>${tileGrid}
|
||||
</svg>
|
||||
</div>`);
|
||||
|
||||
// tile pages
|
||||
if (!(opts.scale === 'fit')) {
|
||||
for (let r = 0; r < rows; r++) for (let c = 0; c < cols; c++) {
|
||||
const vx = c * stepW, vy = r * stepH;
|
||||
const label = `${String.fromCharCode(65 + r)}${c + 1}`;
|
||||
const joins = [
|
||||
c + 1 < cols ? `right joins ${String.fromCharCode(65 + r)}${c + 2}` : '',
|
||||
r + 1 < rows ? `bottom joins ${String.fromCharCode(66 + r)}${c + 1}` : '',
|
||||
].filter(Boolean).join(' · ');
|
||||
// crop marks at the overlap boundary
|
||||
const cm = `<g stroke="#e74c3c" stroke-width="0.3">
|
||||
${[[OVERLAP / 2, 0], [availW - OVERLAP / 2, 0]].map(([x]) =>
|
||||
`<line x1="${vx + x}" y1="${vy}" x2="${vx + x}" y2="${vy + 4}"/><line x1="${vx + x}" y1="${vy + availH - 4}" x2="${vx + x}" y2="${vy + availH}"/>`).join('')}
|
||||
${[[0, OVERLAP / 2], [0, availH - OVERLAP / 2]].map(([, y]) =>
|
||||
`<line x1="${vx}" y1="${vy + y}" x2="${vx + 4}" y2="${vy + y}"/><line x1="${vx + availW - 4}" y1="${vy + y}" x2="${vx + availW}" y2="${vy + y}"/>`).join('')}
|
||||
</g>`;
|
||||
pages.insertAdjacentHTML('beforeend', `
|
||||
<div class="page" style="width:${pw}mm;height:${ph}mm">
|
||||
<div class="pgHead"><b>Tile ${label}</b><span>${scaleLabel}${joins ? ' · ' + joins : ''} · overlap ${OVERLAP} mm</span></div>
|
||||
<svg width="${availW}mm" height="${availH}mm" viewBox="${vx} ${vy} ${availW} ${availH}"
|
||||
style="margin:${MARGIN + 4}mm ${MARGIN}mm ${MARGIN - 4}mm">
|
||||
${inner}${cm}
|
||||
</svg>
|
||||
</div>`);
|
||||
}
|
||||
}
|
||||
document.getElementById('pgCount').textContent =
|
||||
`${pages.children.length} page${pages.children.length === 1 ? '' : 's'} · plan ${ex.w}×${ex.h} cm`;
|
||||
}
|
||||
build();
|
||||
</script>
|
||||
</body></html>
|
||||
@@ -0,0 +1,289 @@
|
||||
<!doctype html>
|
||||
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Playlist — Newbury Exhibit</title></head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module">
|
||||
import { api, requireAuth, styles, nav } from './admin.js';
|
||||
document.head.insertAdjacentHTML('beforeend', `<style>${styles}
|
||||
main { max-width:1080px; margin:18px auto; padding:0 16px; }
|
||||
.card { background:#111730; border-radius:10px; padding:14px 16px; margin-bottom:14px; }
|
||||
.card h2 { margin:0 0 10px; font-size:.95rem; }
|
||||
.row { display:flex; gap:10px; align-items:center; margin:8px 0; }
|
||||
.row label { width:170px; }
|
||||
.trk { background:#0d1226; border-radius:10px; padding:12px 14px; margin-bottom:10px; border-left:3px solid #2a3a6e; }
|
||||
.trk.sched { border-left-color:#51eaf1; }
|
||||
.trk.off { opacity:.45; }
|
||||
.thead { display:flex; gap:10px; align-items:center; margin-bottom:8px; }
|
||||
.thead input.nm { flex:1; font-weight:600; }
|
||||
.grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(150px,1fr)); gap:8px 12px; }
|
||||
.f label { display:block; font-size:.72rem; opacity:.7; margin-bottom:2px; }
|
||||
.f input, .f select { width:100%; box-sizing:border-box; }
|
||||
.rt { margin-top:8px; font-size:.8rem; color:#51eaf1; }
|
||||
.rt .warn { color:#ffd166; }
|
||||
.setline { font-size:.75rem; opacity:.7; margin-top:6px; }
|
||||
.chips { display:flex; flex-wrap:wrap; gap:5px; margin-top:6px; max-height:150px; overflow:auto; }
|
||||
.chip { padding:3px 9px; border-radius:999px; background:#1a2244; cursor:pointer; font-size:.75rem; }
|
||||
.chip.on { background:#2a6ea0; }
|
||||
table { width:100%; font-size:.85rem; border-collapse:collapse; }
|
||||
td { padding:3px 8px 3px 0; }
|
||||
</style>`);
|
||||
await requireAuth();
|
||||
|
||||
const app = document.getElementById('app');
|
||||
let cfg = await api('/api/playlist');
|
||||
const { ghosts } = await api('/api/ghosts');
|
||||
const scene = await api('/api/scene');
|
||||
const rarities = ['Common', 'Rare', 'Epic', 'Legendary'];
|
||||
const colors = ['Red', 'Yellow', 'Blue'];
|
||||
let expanded = null; // trackId whose ghost-picker is open
|
||||
|
||||
const fmt = (s) => s >= 3600 ? `${(s / 3600).toFixed(1)} hr` : s >= 60 ? `~${Math.round(s / 60)} min` : `${Math.round(s)} s`;
|
||||
const locOptions = (sel) => ['random']
|
||||
.concat((scene.spawns || []).map(s => s.id))
|
||||
.concat((scene.paths || []).map(p => 'path:' + p.id))
|
||||
.map(v => `<option value="${v}" ${v === sel ? 'selected' : ''}>${v}</option>`).join('');
|
||||
|
||||
function setSize(t) {
|
||||
const resIds = new Set((cfg.residents || []).map(r => r.id));
|
||||
const all = ghosts.filter(g => !resIds.has(g.id));
|
||||
if (!t.set || t.set === 'all') return all.length;
|
||||
if (t.set.ids?.length) return t.set.ids.length;
|
||||
return all.filter(g => (!t.set.colors?.length || t.set.colors.includes(g.color)) &&
|
||||
(!t.set.rarities?.length || t.set.rarities.includes(g.rarity))).length;
|
||||
}
|
||||
function approx(t) {
|
||||
const n = setSize(t);
|
||||
return (t.spawnTime === 'random' || t.spawnTime == null)
|
||||
? n * (t.runEach || 30)
|
||||
: (Number(t.spawnTime) || 0) + (t.runEach || 30);
|
||||
}
|
||||
function setLabel(t) {
|
||||
if (!t.set || t.set === 'all') return 'All ghosts';
|
||||
if (t.set.ids?.length) {
|
||||
const names = t.set.ids.map(id => ghosts.find(g => g.id === id)?.name || id);
|
||||
return names.length <= 3 ? names.join(' and ') : `${names.length} ghosts`;
|
||||
}
|
||||
const bits = [...(t.set.colors || []), ...(t.set.rarities || [])];
|
||||
return bits.length ? bits.join(' / ') : 'All ghosts';
|
||||
}
|
||||
|
||||
function trackCard(t, i) {
|
||||
const timed = !(t.spawnTime === 'random' || t.spawnTime == null);
|
||||
const n = setSize(t);
|
||||
const rt = approx(t);
|
||||
const tooMany = t.concurrent > n;
|
||||
return `<div class="trk ${timed ? 'sched' : ''} ${t.enabled === false ? 'off' : ''}" data-i="${i}">
|
||||
<div class="thead">
|
||||
<input class="nm" data-k="name" value="${t.name || t.id}">
|
||||
<label><input type="checkbox" data-k="enabled" ${t.enabled !== false ? 'checked' : ''}> enabled</label>
|
||||
<button data-up="${i}" ${i === 0 ? 'disabled' : ''}>↑</button>
|
||||
<button data-down="${i}">↓</button>
|
||||
<button data-del="${i}" class="danger">✕</button>
|
||||
</div>
|
||||
<div class="grid">
|
||||
<div class="f"><label>Ghosts</label>
|
||||
<button data-pick="${t.id}" style="width:100%;text-align:left">${setLabel(t)} (${n})</button></div>
|
||||
<div class="f"><label>SpawnTime</label>
|
||||
<select data-k="spawnMode">
|
||||
<option value="random" ${!timed ? 'selected' : ''}>random (continuous)</option>
|
||||
<option value="timed" ${timed ? 'selected' : ''}>every…</option>
|
||||
</select></div>
|
||||
<div class="f"><label>${timed ? 'interval (minutes)' : '—'}</label>
|
||||
<input type="number" step="0.5" min="0" data-k="spawnTime" value="${timed ? (Number(t.spawnTime) / 60) : ''}" ${timed ? '' : 'disabled'}></div>
|
||||
<div class="f"><label>SpawnPoint</label>
|
||||
<select data-k="spawnPoint">${locOptions(t.spawnPoint || 'random')}</select></div>
|
||||
<div class="f"><label>RunEach (seconds)</label>
|
||||
<input type="number" step="1" min="1" data-k="runEach" value="${t.runEach || 30}"></div>
|
||||
<div class="f"><label>ConcurrentSpawns</label>
|
||||
<input type="number" step="1" min="1" data-k="concurrent" value="${t.concurrent || 1}"></div>
|
||||
<div class="f"><label>Priority</label>
|
||||
<input type="number" step="1" data-k="priority" value="${t.priority ?? (timed ? 10 : 0)}"></div>
|
||||
</div>
|
||||
<div class="rt"><b>ApproxRuntime: ${fmt(rt)}</b>
|
||||
<span style="opacity:.7">— ${timed
|
||||
? `one cycle: waits ${fmt(Number(t.spawnTime) || 0)}, then runs ${fmt(t.runEach)}`
|
||||
: `one full pass through ${n} ghost${n === 1 ? '' : 's'} at ${t.runEach}s each`}</span>
|
||||
${tooMany ? `<div class="warn">⚠ ConcurrentSpawns (${t.concurrent}) exceeds the ${n}-ghost set — only ${n} can show at once.</div>` : ''}
|
||||
${timed && t.runEach > Number(t.spawnTime) ? `<div class="warn">⚠ RunEach is longer than the interval — this track is effectively always on screen.</div>` : ''}
|
||||
</div>
|
||||
${expanded === t.id ? ghostPicker(t) : ''}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function ghostPicker(t) {
|
||||
const ids = (t.set && t.set.ids) ? t.set.ids : [];
|
||||
const cols = (t.set && t.set.colors) ? t.set.colors : [];
|
||||
const rars = (t.set && t.set.rarities) ? t.set.rarities : [];
|
||||
return `<div class="setline" style="margin-top:10px">
|
||||
<b>Set:</b> pick individual ghosts, or filter by colour/rarity. Nothing selected = all ghosts.
|
||||
<button data-clear="${t.id}" style="margin-left:8px">clear</button>
|
||||
<div style="margin-top:6px">Colours: ${colors.map(c =>
|
||||
`<span class="chip ${cols.includes(c) ? 'on' : ''}" data-col="${t.id}|${c}">${c}</span>`).join(' ')}
|
||||
Rarities: ${rarities.map(r =>
|
||||
`<span class="chip ${rars.includes(r) ? 'on' : ''}" data-rar="${t.id}|${r}">${r}</span>`).join(' ')}</div>
|
||||
<div class="chips">${ghosts.map(g =>
|
||||
`<span class="chip ${ids.includes(g.id) ? 'on' : ''}" data-gid="${t.id}|${g.id}">${g.name}</span>`).join('')}</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function residentRow(r, i) {
|
||||
const gOpts = ghosts.map(g => `<option value="${g.id}" ${g.id === r.id ? 'selected' : ''}>${g.name} (${g.rarity} ${g.color})</option>`).join('');
|
||||
const locSel = r.pathId ? 'path:' + r.pathId : (r.spawnId || '');
|
||||
const locOpts = ['<option value="">auto</option>']
|
||||
.concat((scene.spawns || []).map(sp => `<option value="${sp.id}" ${locSel === sp.id ? 'selected' : ''}>spawn ${sp.id}</option>`))
|
||||
.concat((scene.paths || []).map(pt => `<option value="path:${pt.id}" ${locSel === 'path:' + pt.id ? 'selected' : ''}>path ${pt.id}</option>`)).join('');
|
||||
const behOpts = ['static', 'wander', 'path'].map(b => `<option ${(r.behavior || 'static') === b ? 'selected' : ''}>${b}</option>`).join('');
|
||||
return `<div class="row" data-res="${i}">
|
||||
<select data-rk="id" style="flex:2;min-width:0">${gOpts}</select>
|
||||
<select data-rk="loc" style="flex:1;min-width:0">${locOpts}</select>
|
||||
<select data-rk="behavior" style="width:90px">${behOpts}</select>
|
||||
<button data-resdel="${i}" class="danger">✕</button></div>`;
|
||||
}
|
||||
|
||||
function render() {
|
||||
const tracks = cfg.tracks || [];
|
||||
const longest = tracks.filter(t => t.enabled !== false).map(approx);
|
||||
app.innerHTML = `${nav('playlist')}<main>
|
||||
<div class="card">
|
||||
<h2>Playlist tracks <span style="opacity:.6;font-weight:400;font-size:.8rem">— run simultaneously; scheduled tracks (cyan) outrank ambient ones and will take a spawn point from them</span></h2>
|
||||
<div id="tracks">${tracks.map(trackCard).join('') || '<em style="opacity:.6">No tracks yet</em>'}</div>
|
||||
<button id="addTrack" style="margin-top:6px">+ Add track</button>
|
||||
${longest.length ? `<div class="rt" style="margin-top:10px">Full show loops roughly every <b>${fmt(Math.max(...longest))}</b>
|
||||
(longest enabled track) · ${tracks.filter(t => t.enabled !== false).length} track(s) active</div>` : ''}
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Residents <span style="opacity:.6;font-weight:400;font-size:.8rem">— permanent, never rotate out, outrank every track</span></h2>
|
||||
<div id="residents">${(cfg.residents || []).map(residentRow).join('') || '<em style="opacity:.6">No residents</em>'}</div>
|
||||
<button id="addRes" style="margin-top:8px">+ Add resident</button>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Movement & rarity</h2>
|
||||
<div class="grid">
|
||||
<div class="f"><label>Static chance (0–1)</label><input id="staticChance" type="number" step="0.05" min="0" max="1" value="${cfg.behaviors.staticChance}"></div>
|
||||
<div class="f"><label>Wander radius (cm)</label><input id="wanderRadius" type="number" step="1" value="${cfg.behaviors.wanderRadius}"></div>
|
||||
<div class="f"><label>Wander speed</label><input id="wanderSpeed" type="number" step="0.05" value="${cfg.behaviors.wanderSpeed}"></div>
|
||||
<div class="f"><label>Vertical wander (cm)</label><input id="wanderVertical" type="number" step="1" value="${cfg.behaviors.wanderVertical ?? 10}"></div>
|
||||
<div class="f"><label>Path speed (cm/s)</label><input id="pathSpeed" type="number" step="0.5" value="${cfg.behaviors.pathSpeed ?? 8}"></div>
|
||||
<div class="f"><label>Path chance (0–1)</label><input id="pathChance" type="number" step="0.05" min="0" max="1" value="${cfg.behaviors.pathChance ?? 0.4}"></div>
|
||||
<div class="f"><label>Crossfade (seconds)</label><input id="fade" type="number" step="0.5" min="0" value="${cfg.crossfadeSeconds}"></div>
|
||||
</div>
|
||||
<table style="margin-top:10px">
|
||||
<tr style="opacity:.6"><td></td><td>appearance weight</td><td>movement scale</td></tr>
|
||||
${rarities.map(r => `<tr><td>${r}</td>
|
||||
<td><input data-rw="${r}" type="number" step="0.05" min="0" style="width:90px" value="${cfg.rarity?.weights?.[r] ?? 1}"></td>
|
||||
<td><input data-rm="${r}" type="number" step="0.05" min="0" style="width:90px" value="${cfg.rarity?.movementScale?.[r] ?? 1}"></td></tr>`).join('')}
|
||||
</table>
|
||||
<p style="opacity:.6;font-size:.78rem">Weights bias which ghost an <b>ambient</b> track picks next; movement scale shrinks wander/path speed for rarer ghosts.</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Time windows <span style="opacity:.6;font-weight:400;font-size:.8rem">— whole exhibit sleeps outside these; empty = always on</span></h2>
|
||||
<div id="windows">${(cfg.timeWindows || []).map((w, i) =>
|
||||
`<div class="row"><input type="time" value="${w.start}" data-i="${i}" data-k="start"> →
|
||||
<input type="time" value="${w.end}" data-i="${i}" data-k="end">
|
||||
<button data-wdel="${i}" class="danger">✕</button></div>`).join('') || '<em style="opacity:.6">Always on</em>'}</div>
|
||||
<button id="addWin" style="margin-top:8px">+ Add window</button>
|
||||
</div>
|
||||
|
||||
<button id="save" class="primary" style="width:100%;padding:12px;font-size:1rem">Apply playlist (restarts all tracks)</button>
|
||||
<div id="status" style="margin:10px 0;opacity:.75;font-size:.85rem"></div>
|
||||
</main>`;
|
||||
wire();
|
||||
}
|
||||
|
||||
function wire() {
|
||||
// per-track fields
|
||||
document.querySelectorAll('#tracks .trk').forEach(el => {
|
||||
const i = +el.dataset.i, t = cfg.tracks[i];
|
||||
el.querySelectorAll('[data-k]').forEach(inp => inp.onchange = () => {
|
||||
const k = inp.dataset.k;
|
||||
if (k === 'enabled') t.enabled = inp.checked;
|
||||
else if (k === 'name') t.name = inp.value;
|
||||
else if (k === 'spawnMode') t.spawnTime = inp.value === 'random' ? 'random' : 60;
|
||||
else if (k === 'spawnTime') t.spawnTime = Math.max(0, (parseFloat(inp.value) || 0) * 60);
|
||||
else if (k === 'spawnPoint') t.spawnPoint = inp.value;
|
||||
else t[k] = parseFloat(inp.value) || 0;
|
||||
render();
|
||||
});
|
||||
});
|
||||
document.querySelectorAll('[data-pick]').forEach(b => b.onclick = () => {
|
||||
expanded = expanded === b.dataset.pick ? null : b.dataset.pick; render();
|
||||
});
|
||||
const trackById = id => cfg.tracks.find(t => t.id === id);
|
||||
const ensureSet = t => { if (!t.set || t.set === 'all') t.set = { ids: [], colors: [], rarities: [] }; return t.set; };
|
||||
document.querySelectorAll('[data-gid]').forEach(c => c.onclick = () => {
|
||||
const [tid, gid] = c.dataset.gid.split('|'); const s = ensureSet(trackById(tid));
|
||||
s.ids ||= []; const k = s.ids.indexOf(gid); k >= 0 ? s.ids.splice(k, 1) : s.ids.push(gid); render();
|
||||
});
|
||||
document.querySelectorAll('[data-col]').forEach(c => c.onclick = () => {
|
||||
const [tid, v] = c.dataset.col.split('|'); const s = ensureSet(trackById(tid));
|
||||
s.colors ||= []; const k = s.colors.indexOf(v); k >= 0 ? s.colors.splice(k, 1) : s.colors.push(v); render();
|
||||
});
|
||||
document.querySelectorAll('[data-rar]').forEach(c => c.onclick = () => {
|
||||
const [tid, v] = c.dataset.rar.split('|'); const s = ensureSet(trackById(tid));
|
||||
s.rarities ||= []; const k = s.rarities.indexOf(v); k >= 0 ? s.rarities.splice(k, 1) : s.rarities.push(v); render();
|
||||
});
|
||||
document.querySelectorAll('[data-clear]').forEach(b => b.onclick = () => { trackById(b.dataset.clear).set = 'all'; render(); });
|
||||
|
||||
document.querySelectorAll('[data-del]').forEach(b => b.onclick = () => { cfg.tracks.splice(+b.dataset.del, 1); render(); });
|
||||
document.querySelectorAll('[data-up]').forEach(b => b.onclick = () => {
|
||||
const i = +b.dataset.up; [cfg.tracks[i - 1], cfg.tracks[i]] = [cfg.tracks[i], cfg.tracks[i - 1]]; render();
|
||||
});
|
||||
document.querySelectorAll('[data-down]').forEach(b => b.onclick = () => {
|
||||
const i = +b.dataset.down; if (i + 1 >= cfg.tracks.length) return;
|
||||
[cfg.tracks[i + 1], cfg.tracks[i]] = [cfg.tracks[i], cfg.tracks[i + 1]]; render();
|
||||
});
|
||||
document.getElementById('addTrack').onclick = () => {
|
||||
(cfg.tracks ||= []).push({ id: 'track-' + Date.now().toString(36), name: 'New track', set: 'all',
|
||||
spawnTime: 600, spawnPoint: 'random', runEach: 120, concurrent: 1, priority: 10, enabled: true });
|
||||
render();
|
||||
};
|
||||
|
||||
// residents
|
||||
document.getElementById('addRes').onclick = () => { (cfg.residents ||= []).push({ id: ghosts[0].id, behavior: 'static' }); render(); };
|
||||
document.querySelectorAll('[data-resdel]').forEach(b => b.onclick = () => { cfg.residents.splice(+b.dataset.resdel, 1); render(); });
|
||||
document.querySelectorAll('#residents [data-rk]').forEach(el => el.onchange = () => {
|
||||
const i = +el.closest('[data-res]').dataset.res, r = cfg.residents[i];
|
||||
if (el.dataset.rk === 'id') r.id = el.value;
|
||||
else if (el.dataset.rk === 'behavior') r.behavior = el.value;
|
||||
else {
|
||||
delete r.spawnId; delete r.pathId;
|
||||
if (el.value.startsWith('path:')) { r.pathId = el.value.slice(5); r.behavior = 'path'; }
|
||||
else if (el.value) r.spawnId = el.value;
|
||||
}
|
||||
render();
|
||||
});
|
||||
|
||||
// windows
|
||||
document.getElementById('addWin').onclick = () => { (cfg.timeWindows ||= []).push({ start: '09:00', end: '17:00' }); render(); };
|
||||
document.querySelectorAll('[data-wdel]').forEach(b => b.onclick = () => { cfg.timeWindows.splice(+b.dataset.wdel, 1); render(); });
|
||||
document.querySelectorAll('#windows input').forEach(inp => inp.onchange = () => cfg.timeWindows[+inp.dataset.i][inp.dataset.k] = inp.value);
|
||||
|
||||
// rarity table
|
||||
document.querySelectorAll('[data-rw],[data-rm]').forEach(el => el.onchange = () => {
|
||||
cfg.rarity ||= { weights: {}, movementScale: {} };
|
||||
if (el.dataset.rw) (cfg.rarity.weights ||= {})[el.dataset.rw] = +el.value;
|
||||
if (el.dataset.rm) (cfg.rarity.movementScale ||= {})[el.dataset.rm] = +el.value;
|
||||
});
|
||||
|
||||
document.getElementById('save').onclick = async () => {
|
||||
cfg.crossfadeSeconds = +document.getElementById('fade').value;
|
||||
cfg.behaviors = {
|
||||
staticChance: +document.getElementById('staticChance').value,
|
||||
wanderRadius: +document.getElementById('wanderRadius').value,
|
||||
wanderSpeed: +document.getElementById('wanderSpeed').value,
|
||||
wanderVertical: +document.getElementById('wanderVertical').value,
|
||||
pathSpeed: +document.getElementById('pathSpeed').value,
|
||||
pathChance: +document.getElementById('pathChance').value,
|
||||
};
|
||||
try { cfg = await api('/api/playlist', 'PUT', cfg); render();
|
||||
document.getElementById('status').textContent = 'Applied ' + new Date().toLocaleTimeString();
|
||||
} catch (e) { document.getElementById('status').textContent = 'Failed: ' + e.message; }
|
||||
};
|
||||
}
|
||||
render();
|
||||
</script>
|
||||
</body></html>
|
||||
@@ -0,0 +1,259 @@
|
||||
<!doctype html>
|
||||
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Virtual Viewport — Newbury Exhibit</title></head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="importmap">
|
||||
{ "imports": {
|
||||
"three": "https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js",
|
||||
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.160.0/examples/jsm/"
|
||||
} }
|
||||
</script>
|
||||
<script type="module">
|
||||
import * as THREE from 'three';
|
||||
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
|
||||
import { anchorWorldMatrix } from '/js/ar/pose.js';
|
||||
import { buildGhost } from '/js/ghosts/loader.js';
|
||||
import { ghostTransform } from '/js/ghosts/behavior.js';
|
||||
import { ExhibitNet } from '/js/net.js';
|
||||
import { requireAuth, styles, nav } from './admin.js';
|
||||
|
||||
document.head.insertAdjacentHTML('beforeend', `<style>${styles}
|
||||
#wrap { display:grid; grid-template-columns: 1fr 260px; height: calc(100vh - 44px); }
|
||||
#view { position:relative; }
|
||||
#gl { width:100%; height:100%; display:block; touch-action:none; }
|
||||
#side { background:#111730; padding:12px; overflow:auto; }
|
||||
#side h2 { font-size:.9rem; margin:0 0 8px; color:#8fb8ff; }
|
||||
.g { background:#0d1226; border-radius:8px; padding:8px 10px; margin-bottom:8px; font-size:.8rem; cursor:pointer; }
|
||||
.g:hover { background:#1a2244; }
|
||||
.g .nm { font-weight:600; }
|
||||
.g .meta { opacity:.65; margin-top:2px; }
|
||||
.dot { display:inline-block; width:9px; height:9px; border-radius:50%; margin-right:6px; }
|
||||
#legend { position:absolute; left:10px; bottom:10px; font-size:.72rem; background:rgba(6,8,15,.6);
|
||||
padding:6px 10px; border-radius:8px; pointer-events:none; line-height:1.5; }
|
||||
</style>`);
|
||||
await requireAuth();
|
||||
|
||||
// Surface client-side errors on screen — a silent throw here used to leave the
|
||||
// panel stuck on "Waiting for server…" with no clue why.
|
||||
function showError(msg) {
|
||||
let el = document.getElementById('perr');
|
||||
if (!el) {
|
||||
el = document.createElement('div');
|
||||
el.id = 'perr';
|
||||
el.style.cssText = 'position:fixed;bottom:0;left:0;right:0;max-height:35vh;overflow:auto;' +
|
||||
'background:rgba(120,0,0,.9);color:#fff;font:12px monospace;padding:8px;z-index:9999;white-space:pre-wrap';
|
||||
document.body.appendChild(el);
|
||||
}
|
||||
el.textContent += msg + '\n';
|
||||
}
|
||||
window.addEventListener('error', e => showError('[error] ' + e.message + (e.filename ? ` (${e.filename}:${e.lineno})` : '')));
|
||||
window.addEventListener('unhandledrejection', e => showError('[rejection] ' + (e.reason?.message || e.reason)));
|
||||
|
||||
const COLORS = { Red: '#ff2678', Yellow: '#fff35d', Blue: '#51eaf1' };
|
||||
const app = document.getElementById('app');
|
||||
app.innerHTML = `${nav('preview')}
|
||||
<div id="wrap">
|
||||
<div id="view">
|
||||
<canvas id="gl"></canvas>
|
||||
<div id="legend"><b>Virtual viewport</b> — live playlist, same deterministic motion the phones compute.<br>
|
||||
Drag to orbit · scroll to zoom · click a ghost in the list to follow it</div>
|
||||
</div>
|
||||
<div id="side"><h2>Active ghosts</h2><div id="list"><em style="opacity:.6">Waiting for server…</em></div></div>
|
||||
</div>`;
|
||||
|
||||
// ---------- data ----------
|
||||
const g = await (await fetch('/api/ghosts')).json();
|
||||
const gradients = g.gradients.gradients || g.gradients;
|
||||
const modelManifest = await (await fetch('/api/models')).json();
|
||||
let characters = await (await fetch('/api/characters')).json();
|
||||
|
||||
// ---------- three.js ----------
|
||||
const cvs = document.getElementById('gl');
|
||||
const renderer = new THREE.WebGLRenderer({ canvas: cvs, antialias: true });
|
||||
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
|
||||
const scn = new THREE.Scene();
|
||||
scn.background = new THREE.Color(0x0a0e1c);
|
||||
scn.fog = new THREE.Fog(0x0a0e1c, 400, 1200);
|
||||
const cam = new THREE.PerspectiveCamera(55, 1, 1, 5000);
|
||||
cam.position.set(160, 110, -80);
|
||||
scn.add(new THREE.AmbientLight(0xffffff, 1.1));
|
||||
const dl = new THREE.DirectionalLight(0x9db8ff, 0.8); dl.position.set(100, 250, -100); scn.add(dl);
|
||||
|
||||
const orbit = new OrbitControls(cam, cvs);
|
||||
orbit.target.set(55, 15, 55);
|
||||
orbit.enableDamping = true;
|
||||
|
||||
function textSprite(txt, color = '#e8ecff', px = 44) {
|
||||
const c = document.createElement('canvas'); c.width = 512; c.height = 128;
|
||||
const x = c.getContext('2d'); x.font = `bold ${px}px system-ui`; x.fillStyle = color;
|
||||
x.shadowColor = '#000'; x.shadowBlur = 8;
|
||||
x.textAlign = 'center'; x.textBaseline = 'middle'; x.fillText(txt, 256, 64);
|
||||
const sp = new THREE.Sprite(new THREE.SpriteMaterial({ map: new THREE.CanvasTexture(c), transparent: true, depthTest: false }));
|
||||
sp.scale.set(40, 10, 1); return sp;
|
||||
}
|
||||
|
||||
// ---------- static scene ----------
|
||||
const statics = new THREE.Group(); scn.add(statics);
|
||||
function buildStatics(scene) {
|
||||
statics.clear();
|
||||
statics.add(new THREE.GridHelper(400, 16, 0x2a3a6e, 0x151b36));
|
||||
|
||||
const t = scene.table;
|
||||
if (t && t.show !== false) {
|
||||
const x0 = t.offsetX || 0, z0 = t.offsetZ || 0;
|
||||
const slab = new THREE.Mesh(new THREE.BoxGeometry(t.width, 2, t.depth),
|
||||
new THREE.MeshStandardMaterial({ color: 0x5a4630, roughness: 0.9 }));
|
||||
slab.position.set(x0 + t.width / 2, -1.2, z0 + t.depth / 2);
|
||||
statics.add(slab);
|
||||
const nLbl = textSprite('N ↑ back', '#51eaf1'); nLbl.position.set(x0 + t.width / 2, 4, z0 + t.depth + 8); statics.add(nLbl);
|
||||
}
|
||||
for (const b of scene.buildings || []) {
|
||||
const m = new THREE.Mesh(new THREE.BoxGeometry(...b.size),
|
||||
new THREE.MeshStandardMaterial({ color: 0x3a4f80, transparent: true, opacity: 0.85, roughness: 0.7 }));
|
||||
m.position.set(b.position[0], b.position[1] + b.size[1] / 2, b.position[2]);
|
||||
m.rotation.y = THREE.MathUtils.degToRad(b.yawDeg || 0);
|
||||
statics.add(m);
|
||||
const e = new THREE.LineSegments(new THREE.EdgesGeometry(m.geometry),
|
||||
new THREE.LineBasicMaterial({ color: 0x7fa4e8 }));
|
||||
e.position.copy(m.position); e.rotation.copy(m.rotation);
|
||||
statics.add(e);
|
||||
}
|
||||
for (const a of scene.anchors || []) {
|
||||
if (a.enabled === false) continue;
|
||||
const size = (a.sizeMM || 60) / 10;
|
||||
const p = new THREE.Mesh(new THREE.PlaneGeometry(size, size),
|
||||
new THREE.MeshBasicMaterial({ color: a.mount === 'wall' ? 0xf65151 : 0x3bc9a7, side: THREE.DoubleSide }));
|
||||
anchorWorldMatrix(a).decompose(p.position, p.quaternion, p.scale);
|
||||
statics.add(p);
|
||||
}
|
||||
for (const pth of scene.paths || []) {
|
||||
if (pth.enabled === false || (pth.points || []).length < 2) continue;
|
||||
const pts = pth.points.map(q => new THREE.Vector3(...q));
|
||||
const lp = pth.mode === 'loop' ? [...pts, pts[0]] : pts;
|
||||
statics.add(new THREE.Line(new THREE.BufferGeometry().setFromPoints(lp),
|
||||
new THREE.LineBasicMaterial({ color: 0xc77dff, transparent: true, opacity: 0.5 })));
|
||||
}
|
||||
for (const s of scene.spawns || []) {
|
||||
if (s.enabled === false) continue;
|
||||
const m = new THREE.Mesh(new THREE.SphereGeometry(1.5, 10, 8),
|
||||
new THREE.MeshBasicMaterial({ color: 0xb57f0b, transparent: true, opacity: 0.35 }));
|
||||
m.position.set(...s.position);
|
||||
statics.add(m);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- live ghosts ----------
|
||||
const active = new Map(); // uid -> { rec, group, label }
|
||||
let followUid = null;
|
||||
let ghostHeight = 4; // cm, from scene.ghostHeightCm
|
||||
const clockSamples = []; let clockOffset = 0;
|
||||
|
||||
async function addGhost(rec) {
|
||||
if (active.has(rec.uid)) return;
|
||||
let group;
|
||||
try {
|
||||
group = await buildGhost(rec, gradients, modelManifest, characters);
|
||||
} catch (e) {
|
||||
showError(`ghost "${rec.name}" failed to build: ${e.message}`);
|
||||
return;
|
||||
}
|
||||
group.userData.setHeight(ghostHeight);
|
||||
const label = textSprite(rec.name, COLORS[rec.color] || '#fff');
|
||||
label.scale.set(20, 5, 1);
|
||||
scn.add(group); scn.add(label); // label stays world-scale, outside the scaled group
|
||||
active.set(rec.uid, { rec, group, label });
|
||||
renderList();
|
||||
}
|
||||
function removeGhost(uid) {
|
||||
const e = active.get(uid);
|
||||
if (!e) return;
|
||||
scn.remove(e.group); scn.remove(e.label);
|
||||
active.delete(uid);
|
||||
if (followUid === uid) followUid = null;
|
||||
renderList();
|
||||
}
|
||||
|
||||
const net = new ExhibitNet();
|
||||
net.on('scene', m => {
|
||||
buildStatics(m.scene);
|
||||
ghostHeight = m.scene.ghostHeightCm || 4;
|
||||
for (const e of active.values()) e.group.userData.setHeight(ghostHeight);
|
||||
})
|
||||
.on('characters', async m => {
|
||||
characters = m.characters || characters;
|
||||
const recs = [...active.values()].map(e => e.rec);
|
||||
for (const uid of [...active.keys()]) removeGhost(uid);
|
||||
for (const rec of recs) await addGhost(rec);
|
||||
})
|
||||
.on('active', async m => {
|
||||
for (const uid of [...active.keys()]) removeGhost(uid);
|
||||
for (const rec of m.ghosts) await addGhost(rec);
|
||||
})
|
||||
.on('spawn', m => addGhost(m.ghost))
|
||||
.on('despawn', m => {
|
||||
const e = active.get(m.uid);
|
||||
const until = e?.rec.until ?? Date.now() + clockOffset;
|
||||
const wait = Math.max(0, until - (Date.now() + clockOffset)) + ((e?.rec.crossfade || 3) * 1000);
|
||||
setTimeout(() => removeGhost(m.uid), Math.min(wait, 8000));
|
||||
})
|
||||
.on('pong', m => {
|
||||
const rtt = performance.now() - m.t;
|
||||
clockSamples.push(m.server + rtt / 2 - Date.now());
|
||||
if (clockSamples.length > 5) clockSamples.shift();
|
||||
clockOffset = clockSamples.reduce((a, b) => a + b, 0) / clockSamples.length;
|
||||
});
|
||||
net.connect();
|
||||
setInterval(() => { try { net.ws.send(JSON.stringify({ type: 'ping', t: performance.now() })); } catch {} }, 5000);
|
||||
|
||||
// ---------- side list ----------
|
||||
const list = document.getElementById('list');
|
||||
function renderList() {
|
||||
if (!active.size) { list.innerHTML = '<em style="opacity:.6">No ghosts active</em>'; return; }
|
||||
list.innerHTML = [...active.values()].map(({ rec }) => `
|
||||
<div class="g" data-uid="${rec.uid}">
|
||||
<div class="nm"><span class="dot" style="background:${COLORS[rec.color] || '#fff'}"></span>${rec.name}</div>
|
||||
<div class="meta">${rec.rarity} · ${rec.behavior.type}${rec.pathId ? ' on ' + rec.pathId : rec.spawnId ? ' @ ' + rec.spawnId : ''}
|
||||
· <span class="ttl" data-uid="${rec.uid}"></span>${followUid === rec.uid ? ' · <b>following</b>' : ''}</div>
|
||||
</div>`).join('');
|
||||
list.querySelectorAll('.g').forEach(el => el.onclick = () => {
|
||||
followUid = followUid === el.dataset.uid ? null : el.dataset.uid;
|
||||
renderList();
|
||||
});
|
||||
}
|
||||
setInterval(() => {
|
||||
const now = Date.now() + clockOffset;
|
||||
document.querySelectorAll('.ttl').forEach(el => {
|
||||
const e = active.get(el.dataset.uid);
|
||||
if (!e) return;
|
||||
el.textContent = e.rec.until == null ? 'resident ∞' : Math.max(0, Math.ceil((e.rec.until - now) / 1000)) + 's left';
|
||||
});
|
||||
}, 1000);
|
||||
|
||||
// ---------- render loop ----------
|
||||
const _tmp = { position: new THREE.Vector3(), rotationY: 0, opacity: 1 };
|
||||
function fit() {
|
||||
renderer.setSize(cvs.clientWidth, cvs.clientHeight, false);
|
||||
cam.aspect = cvs.clientWidth / cvs.clientHeight; cam.updateProjectionMatrix();
|
||||
}
|
||||
new ResizeObserver(fit).observe(cvs);
|
||||
fit();
|
||||
|
||||
(function loop(t) {
|
||||
requestAnimationFrame(loop);
|
||||
const now = Date.now() + clockOffset;
|
||||
for (const { rec, group, label } of active.values()) {
|
||||
ghostTransform(rec, now, _tmp);
|
||||
group.position.copy(_tmp.position);
|
||||
group.rotation.y = _tmp.rotationY;
|
||||
group.userData.setOpacity(_tmp.opacity * 0.92);
|
||||
group.userData.tick(t / 1000);
|
||||
label.position.set(_tmp.position.x, _tmp.position.y + ghostHeight + 3, _tmp.position.z);
|
||||
label.material.opacity = _tmp.opacity;
|
||||
}
|
||||
if (followUid && active.has(followUid)) orbit.target.lerp(active.get(followUid).group.position, 0.08);
|
||||
orbit.update();
|
||||
renderer.render(scn, cam);
|
||||
})(0);
|
||||
</script>
|
||||
</body></html>
|
||||
@@ -0,0 +1,88 @@
|
||||
<!doctype html>
|
||||
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Print Crests — Newbury Exhibit</title>
|
||||
<style>
|
||||
body { font-family:system-ui,sans-serif; margin:0; }
|
||||
.sheet { display:flex; flex-wrap:wrap; gap:8mm; padding:10mm; }
|
||||
.crest { text-align:center; page-break-inside:avoid; }
|
||||
.crest canvas { image-rendering:pixelated; border:0.5mm solid #000; }
|
||||
.crest .cap { font-size:9pt; margin-top:2mm; }
|
||||
@media print { header { display:none } }
|
||||
</style></head>
|
||||
<body>
|
||||
<div id="hdr"></div>
|
||||
<div id="rose" style="padding:8mm 10mm 0"></div>
|
||||
<div class="sheet" id="sheet"></div>
|
||||
|
||||
<script src="/vendor/cv.js"></script>
|
||||
<script src="/vendor/svd.js"></script>
|
||||
<script src="/vendor/posit1.js"></script>
|
||||
<script src="/vendor/aruco.js"></script>
|
||||
<script src="/vendor/dictionaries/aruco_4x4_1000.js"></script>
|
||||
<script type="module">
|
||||
import { requireAuth, styles, nav } from './admin.js';
|
||||
document.getElementById('hdr').innerHTML = nav('print');
|
||||
document.head.insertAdjacentHTML('beforeend', `<style>@media screen {${styles}}</style>`);
|
||||
await requireAuth();
|
||||
|
||||
const scene = await (await fetch('/api/scene')).json();
|
||||
const dic = new AR.Dictionary('ARUCO_4X4_1000');
|
||||
const MM2PX = 96 / 25.4; // CSS px per mm
|
||||
|
||||
const sheet = document.getElementById('sheet');
|
||||
for (const a of scene.anchors) {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'crest';
|
||||
const cv = document.createElement('canvas');
|
||||
const bits = 4 + 2; // 4x4 payload + 1-cell black border each side
|
||||
const px = Math.round(a.sizeMM * MM2PX);
|
||||
cv.width = cv.height = bits * 10;
|
||||
cv.style.width = cv.style.height = px + 'px';
|
||||
const ctx = cv.getContext('2d');
|
||||
ctx.fillStyle = '#000'; ctx.fillRect(0, 0, cv.width, cv.height);
|
||||
const code = dic.codeList[a.markerId]; // 16-char '0'/'1' string, row-major
|
||||
ctx.fillStyle = '#fff';
|
||||
for (let y = 0; y < 4; y++) for (let x = 0; x < 4; x++) {
|
||||
if (code[y * 4 + x] === '1') ctx.fillRect((x + 1) * 10, (y + 1) * 10, 10, 10);
|
||||
}
|
||||
div.appendChild(cv);
|
||||
|
||||
// Compass: bearing the print's TOP edge (flat) or FACE (wall) must point.
|
||||
// 0° = N = +Z (back of table), 90° = E = +X, 180° = S (front), 270° = W.
|
||||
const brg = a.mount === 'wall'
|
||||
? ((a.yawDeg % 360) + 360) % 360
|
||||
: (((180 + a.yawDeg) % 360) + 360) % 360;
|
||||
const dirName = ['N','NE','E','SE','S','SW','W','NW'][Math.round(brg / 45) % 8];
|
||||
const rose = document.createElement('canvas');
|
||||
rose.width = rose.height = 120;
|
||||
rose.style.width = rose.style.height = '18mm';
|
||||
const rc = rose.getContext('2d');
|
||||
rc.translate(60, 60);
|
||||
rc.strokeStyle = '#000'; rc.lineWidth = 2;
|
||||
rc.beginPath(); rc.arc(0, 0, 52, 0, 7); rc.stroke();
|
||||
rc.font = 'bold 16px system-ui'; rc.textAlign = 'center'; rc.textBaseline = 'middle'; rc.fillStyle = '#000';
|
||||
rc.fillText('N', 0, -40); rc.font = '12px system-ui';
|
||||
rc.fillText('E', 40, 0); rc.fillText('S', 0, 40); rc.fillText('W', -40, 0);
|
||||
rc.save(); rc.rotate(brg * Math.PI / 180); // needle at target bearing
|
||||
rc.beginPath(); rc.moveTo(0, 10); rc.lineTo(0, -46);
|
||||
rc.lineTo(-6, -34); rc.moveTo(0, -46); rc.lineTo(6, -34);
|
||||
rc.lineWidth = 3; rc.stroke(); rc.restore();
|
||||
rose.style.verticalAlign = 'middle'; rose.style.marginLeft = '3mm';
|
||||
div.appendChild(rose);
|
||||
|
||||
div.insertAdjacentHTML('beforeend',
|
||||
`<div class="cap"><b>Crest #${a.markerId}</b> · ${a.sizeMM} mm · ${a.mount}<br>` +
|
||||
(a.mount === 'wall'
|
||||
? `mount vertically, this way up · print <b>faces ${brg}° ${dirName}</b>`
|
||||
: `lay flat · <b>top edge of print → ${brg}° ${dirName}</b>`) +
|
||||
`<br>pos ${a.position.map(v => v.toFixed(1)).join(', ')} cm</div>`);
|
||||
sheet.appendChild(div);
|
||||
}
|
||||
document.getElementById('rose').innerHTML =
|
||||
`<b>Table compass:</b> N (0°) = +Z, the <u>back</u> of the table · E (90°) = +X, the right ·
|
||||
S (180°) = front · W (270°) = left. Each crest below shows the bearing its arrow must point —
|
||||
align the needle with the table compass and the AR maths will line up exactly.`;
|
||||
|
||||
if (!scene.anchors.length) sheet.innerHTML = '<p style="padding:20px">No anchors in the scene yet — add some in the Layout editor.</p>';
|
||||
</script>
|
||||
</body></html>
|
||||
Reference in New Issue
Block a user