459 lines
24 KiB
HTML
459 lines
24 KiB
HTML
<!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; }
|
|
#setModal { position:fixed; inset:0; background:rgba(4,6,12,.7); display:none; z-index:50;
|
|
align-items:center; justify-content:center; }
|
|
#setModal.open { display:flex; }
|
|
#setCard { background:#111730; border:1px solid #2a3a6e; border-radius:10px; padding:16px;
|
|
width:min(420px,92vw); max-height:80vh; display:flex; flex-direction:column; }
|
|
#setCard h2 { margin:0 0 4px; font-size:1rem; color:#8fb8ff; }
|
|
#setCard .hint { font-size:.72rem; opacity:.6; margin:0 0 10px; }
|
|
#setList { overflow:auto; display:flex; flex-direction:column; gap:4px; }
|
|
#setList button { text-align:left; background:#141a33; }
|
|
#setList button:hover { background:#1c2650; }
|
|
#setCard .close { margin-top:12px; align-self:flex-end; }
|
|
</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="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"><div id="setCard">
|
|
<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"></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 || {});
|
|
|
|
// ---------- 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: 'spawn-' + (scene.spawns.length + 1), 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();
|
|
});
|
|
|
|
// ---------- 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 += textField('id', o.id, v => o.id = 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('enabled', o.enabled === false ? 'no' : 'yes', v => o.enabled = v === 'yes', { select: ['yes', 'no'] });
|
|
h += `<p class="badge">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();
|
|
}
|
|
|
|
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>
|