update Tue 07/14/2026 16:07:05.30
This commit is contained in:
@@ -33,11 +33,16 @@ positions and sizes, wander radius, calibration readouts. Marker print size stay
|
|||||||
color/rarity filters, optional time-of-day windows, crossfade in/out.
|
color/rarity filters, optional time-of-day windows, crossfade in/out.
|
||||||
- **Behaviors**: static float (bob + idle sway) or wander (deterministic orbit-drift —
|
- **Behaviors**: static float (bob + idle sway) or wander (deterministic orbit-drift —
|
||||||
a pure function of server spawn record + wall clock, so every phone computes the
|
a pure function of server spawn record + wall clock, so every phone computes the
|
||||||
same motion with zero position streaming).
|
same motion with zero position streaming). Wander is 3D: horizontal radius plus a
|
||||||
|
configurable vertical amplitude (`wanderVertical`, cm).
|
||||||
- **Anchors ("Newbury Crests")**: flat (horizontal) / wall (vertical) / custom mounts,
|
- **Anchors ("Newbury Crests")**: flat (horizontal) / wall (vertical) / custom mounts,
|
||||||
fully editable — position, yaw/pitch/roll trim, printed size, enable toggle.
|
fully editable — position, yaw/pitch/roll trim, printed size, enable toggle.
|
||||||
- **Layout editor** (`/admin/layout.html`): to-scale top-down canvas; drag anchors,
|
- **3D layout editor** (`/admin/layout.html`): Three.js orbit view with drag gizmos
|
||||||
occlusion buildings, and spawn points; property panel; 5 mm snap; save/reset-to-seed.
|
(5 mm snap, green arrow = height) for anchors, occlusion buildings, and spawn points —
|
||||||
|
spawn Y is fully editable so ghosts can live at any height. Anchors render through the
|
||||||
|
exact same `anchorWorldMatrix` the AR pipeline uses, each with a white orientation arrow;
|
||||||
|
compass rose on the grid (N = +Z = back of table). Top-view button, property panel,
|
||||||
|
save/reset-to-seed.
|
||||||
- **Playlist manager** (`/admin/playlist.html`), **crest print sheet** (`/admin/print.html`,
|
- **Playlist manager** (`/admin/playlist.html`), **crest print sheet** (`/admin/print.html`,
|
||||||
true-to-size), **calibration** (`/admin/calibrate.html`: live distance/angle/jitter +
|
true-to-size), **calibration** (`/admin/calibrate.html`: live distance/angle/jitter +
|
||||||
fused pose), **client error log** (`/admin/errors.html`).
|
fused pose), **client error log** (`/admin/errors.html`).
|
||||||
|
|||||||
+2
-1
@@ -11,7 +11,8 @@
|
|||||||
"behaviors": {
|
"behaviors": {
|
||||||
"staticChance": 0.5,
|
"staticChance": 0.5,
|
||||||
"wanderRadius": 60,
|
"wanderRadius": 60,
|
||||||
"wanderSpeed": 0.15
|
"wanderSpeed": 0.15,
|
||||||
|
"wanderVertical": 10
|
||||||
},
|
},
|
||||||
"timeWindows": []
|
"timeWindows": []
|
||||||
}
|
}
|
||||||
+173
-119
@@ -1,166 +1,202 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>Layout Editor — Newbury Exhibit</title></head>
|
<title>Layout Editor (3D) — Newbury Exhibit</title></head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<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">
|
<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 { api, requireAuth, styles, nav } from './admin.js';
|
import { api, requireAuth, styles, nav } from './admin.js';
|
||||||
|
|
||||||
document.head.insertAdjacentHTML('beforeend', `<style>${styles}
|
document.head.insertAdjacentHTML('beforeend', `<style>${styles}
|
||||||
#wrap { display:grid; grid-template-columns: 1fr 300px; height: calc(100vh - 44px); }
|
#wrap { display:grid; grid-template-columns: 1fr 300px; height: calc(100vh - 88px); }
|
||||||
#cv { background:#0d1226; width:100%; height:100%; display:block; touch-action:none; }
|
#view { position:relative; }
|
||||||
|
#gl { width:100%; height:100%; display:block; touch-action:none; }
|
||||||
#side { background:#111730; padding:12px; overflow:auto; }
|
#side { background:#111730; padding:12px; overflow:auto; }
|
||||||
#side h2 { font-size:.9rem; margin:14px 0 6px; color:#8fb8ff; }
|
#side h2 { font-size:.9rem; margin:14px 0 6px; color:#8fb8ff; }
|
||||||
.row { display:flex; gap:6px; align-items:center; margin:4px 0; }
|
.row { display:flex; gap:6px; align-items:center; margin:4px 0; }
|
||||||
.row label { width:70px; }
|
.row label { width:70px; }
|
||||||
.row input, .row select { flex:1; min-width:0; }
|
.row input, .row select { flex:1; min-width:0; }
|
||||||
.tools { display:flex; gap:6px; padding:8px 12px; background:#0d1226; flex-wrap:wrap; }
|
.tools { display:flex; gap:6px; padding:8px 12px; background:#0d1226; flex-wrap:wrap; align-items:center; }
|
||||||
.badge { font-size:.7rem; opacity:.6; }
|
.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; }
|
||||||
</style>`);
|
</style>`);
|
||||||
|
|
||||||
await requireAuth();
|
await requireAuth();
|
||||||
|
|
||||||
const app = document.getElementById('app');
|
const app = document.getElementById('app');
|
||||||
app.innerHTML = `${nav('layout')}
|
app.innerHTML = `${nav('layout')}
|
||||||
<div class="tools">
|
<div class="tools">
|
||||||
<button data-add="anchor">+ Anchor</button>
|
<button data-add="anchor">+ Anchor</button>
|
||||||
<button data-add="building">+ Building</button>
|
<button data-add="building">+ Building</button>
|
||||||
<button data-add="spawn">+ Spawn point</button>
|
<button data-add="spawn">+ Spawn</button>
|
||||||
|
<button id="topView">Top view</button>
|
||||||
<span style="flex:1"></span>
|
<span style="flex:1"></span>
|
||||||
<button id="reset" class="danger">Reset to seed</button>
|
<button id="reset" class="danger">Reset to seed</button>
|
||||||
<button id="save" class="primary">Save layout</button>
|
<button id="save" class="primary">Save layout</button>
|
||||||
<span id="status" class="badge"></span>
|
<span id="status" class="badge"></span>
|
||||||
</div>
|
</div>
|
||||||
<div id="wrap">
|
<div id="wrap">
|
||||||
<canvas id="cv"></canvas>
|
<div id="view">
|
||||||
<div id="side"><em style="opacity:.6">Select an item to edit it. Drag on the canvas to move. Scroll to zoom.</em></div>
|
<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</div>
|
||||||
|
</div>
|
||||||
|
<div id="side"></div>
|
||||||
</div>`;
|
</div>`;
|
||||||
|
|
||||||
let scene = await api('/api/scene');
|
let scene = await api('/api/scene');
|
||||||
scene.anchors ||= []; scene.buildings ||= []; scene.spawns ||= [];
|
scene.anchors ||= []; scene.buildings ||= []; scene.spawns ||= [];
|
||||||
|
|
||||||
const cv = document.getElementById('cv');
|
// ---------- three.js setup ----------
|
||||||
const ctx = cv.getContext('2d');
|
const cvs = document.getElementById('gl');
|
||||||
let view = { x: 0, z: 0, scale: 1.2 }; // px per cm
|
const renderer = new THREE.WebGLRenderer({ canvas: cvs, antialias: true });
|
||||||
let sel = null; // { kind, index }
|
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
|
||||||
let drag = null;
|
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);
|
||||||
|
|
||||||
function fit() {
|
const orbit = new OrbitControls(cam, cvs);
|
||||||
cv.width = cv.clientWidth * devicePixelRatio;
|
orbit.target.set(55, 0, 55);
|
||||||
cv.height = cv.clientHeight * devicePixelRatio;
|
|
||||||
draw();
|
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;
|
||||||
}
|
}
|
||||||
new ResizeObserver(fit).observe(cv);
|
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);
|
||||||
|
|
||||||
const w2s = (x, z) => [cv.width / 2 + (x - view.x) * view.scale * devicePixelRatio,
|
// ---------- object meshes ----------
|
||||||
cv.height / 2 + (z - view.z) * view.scale * devicePixelRatio];
|
const objRoot = new THREE.Group(); scn.add(objRoot);
|
||||||
const s2w = (px, py) => [(px * devicePixelRatio - cv.width / 2) / (view.scale * devicePixelRatio) + view.x,
|
let sel = null; // { kind, index, obj3d }
|
||||||
(py * devicePixelRatio - cv.height / 2) / (view.scale * devicePixelRatio) + view.z];
|
|
||||||
|
|
||||||
function draw() {
|
const matAnchorFlat = new THREE.MeshStandardMaterial({ color: 0x3bc9a7, side: THREE.DoubleSide });
|
||||||
ctx.clearRect(0, 0, cv.width, cv.height);
|
const matAnchorWall = new THREE.MeshStandardMaterial({ color: 0xf65151, side: THREE.DoubleSide });
|
||||||
// grid (25 cm)
|
const matBuilding = new THREE.MeshStandardMaterial({ color: 0x529eff, transparent: true, opacity: 0.35 });
|
||||||
ctx.strokeStyle = '#1a2244'; ctx.lineWidth = 1;
|
const matSpawn = new THREE.MeshStandardMaterial({ color: 0xb57f0b });
|
||||||
const step = 25 * view.scale * devicePixelRatio;
|
|
||||||
const ox = (cv.width / 2 - view.x * view.scale * devicePixelRatio) % step;
|
|
||||||
const oz = (cv.height / 2 - view.z * view.scale * devicePixelRatio) % step;
|
|
||||||
for (let x = ox; x < cv.width; x += step) { ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, cv.height); ctx.stroke(); }
|
|
||||||
for (let y = oz; y < cv.height; y += step) { ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(cv.width, y); ctx.stroke(); }
|
|
||||||
|
|
||||||
// buildings (occluders)
|
function buildAll() {
|
||||||
for (const [i, b] of scene.buildings.entries()) {
|
gizmo.detach();
|
||||||
const [sx, sy] = w2s(b.position[0], b.position[2]);
|
objRoot.clear();
|
||||||
const w = b.size[0] * view.scale * devicePixelRatio, d = b.size[2] * view.scale * devicePixelRatio;
|
scene.anchors.forEach((a, i) => {
|
||||||
ctx.save(); ctx.translate(sx, sy); ctx.rotate(-(b.yawDeg || 0) * Math.PI / 180);
|
const size = (a.sizeMM || 60) / 10; // cm
|
||||||
ctx.fillStyle = isSel('building', i) ? 'rgba(82,158,255,.45)' : 'rgba(82,158,255,.22)';
|
const g = new THREE.Group();
|
||||||
ctx.strokeStyle = '#529eff';
|
const plane = new THREE.Mesh(new THREE.PlaneGeometry(size, size),
|
||||||
ctx.fillRect(-w / 2, -d / 2, w, d); ctx.strokeRect(-w / 2, -d / 2, w, d);
|
(a.mount === 'wall' ? matAnchorWall : matAnchorFlat).clone());
|
||||||
ctx.restore();
|
plane.material.opacity = a.enabled === false ? 0.25 : 1;
|
||||||
label(b.name || `bldg ${i}`, sx, sy, '#9cc4ff');
|
plane.material.transparent = a.enabled === false;
|
||||||
}
|
g.add(plane);
|
||||||
// spawns
|
// orientation arrow: marker local +Y = top edge of the print
|
||||||
for (const [i, s] of scene.spawns.entries()) {
|
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 [sx, sy] = w2s(s.position[0], s.position[2]);
|
const idL = textSprite('#' + a.markerId, '#a6fff0', 20); idL.position.set(0, 0, 3); idL.scale.set(14, 7, 1); g.add(idL);
|
||||||
ctx.beginPath(); ctx.arc(sx, sy, 9 * devicePixelRatio, 0, 7);
|
// pose from the SAME transform the AR pipeline uses
|
||||||
ctx.fillStyle = isSel('spawn', i) ? '#fff35d' : (s.enabled === false ? '#665' : '#b57f0b');
|
anchorWorldMatrix(a).decompose(g.position, g.quaternion, g.scale);
|
||||||
ctx.fill();
|
g.userData = { kind: 'anchor', index: i };
|
||||||
label(s.id || `spawn ${i}`, sx, sy - 14 * devicePixelRatio, '#ffe9a3');
|
objRoot.add(g);
|
||||||
}
|
});
|
||||||
// anchors
|
scene.buildings.forEach((b, i) => {
|
||||||
for (const [i, a] of scene.anchors.entries()) {
|
const m = new THREE.Mesh(new THREE.BoxGeometry(...b.size), matBuilding.clone());
|
||||||
const [sx, sy] = w2s(a.position[0], a.position[2]);
|
m.position.set(b.position[0], b.position[1] + b.size[1] / 2, b.position[2]);
|
||||||
ctx.save(); ctx.translate(sx, sy); ctx.rotate(-(a.yawDeg || 0) * Math.PI / 180);
|
m.rotation.y = THREE.MathUtils.degToRad(b.yawDeg || 0);
|
||||||
const r = Math.max(8, (a.sizeMM / 10) * view.scale) * devicePixelRatio;
|
m.userData = { kind: 'building', index: i };
|
||||||
ctx.fillStyle = isSel('anchor', i) ? '#51eaf1' : (a.enabled === false ? '#456' : (a.mount === 'wall' ? '#f65151' : '#3bc9a7'));
|
objRoot.add(m);
|
||||||
if (a.mount === 'wall') { ctx.fillRect(-r, -2.5 * devicePixelRatio, 2 * r, 5 * devicePixelRatio); // edge-on line
|
});
|
||||||
ctx.beginPath(); ctx.moveTo(0, 0); ctx.lineTo(0, -r); ctx.strokeStyle = ctx.fillStyle; ctx.lineWidth = 2; ctx.stroke(); }
|
scene.spawns.forEach((s, i) => {
|
||||||
else ctx.fillRect(-r, -r, 2 * r, 2 * r);
|
const m = new THREE.Mesh(new THREE.SphereGeometry(4, 16, 12), matSpawn.clone());
|
||||||
ctx.restore();
|
if (s.enabled === false) { m.material.transparent = true; m.material.opacity = 0.3; }
|
||||||
label(`#${a.markerId}${a.mount === 'wall' ? ' ⊥' : ''}`, sx, sy + 16 * devicePixelRatio, '#a6fff0');
|
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);
|
||||||
|
});
|
||||||
|
reselect();
|
||||||
}
|
}
|
||||||
function label(txt, x, y, col) {
|
|
||||||
ctx.fillStyle = col; ctx.font = `${11 * devicePixelRatio}px system-ui`; ctx.textAlign = 'center';
|
|
||||||
ctx.fillText(txt, x, y - 12 * devicePixelRatio);
|
|
||||||
}
|
|
||||||
const isSel = (k, i) => sel && sel.kind === k && sel.index === i;
|
|
||||||
|
|
||||||
function hit(px, py) {
|
function dataOf(s) { return ({ anchor: scene.anchors, building: scene.buildings, spawn: scene.spawns })[s.kind][s.index]; }
|
||||||
const [wx, wz] = s2w(px, py);
|
|
||||||
const near = (x, z, r) => Math.hypot(wx - x, wz - z) < r;
|
|
||||||
for (const [i, a] of scene.anchors.entries()) if (near(a.position[0], a.position[2], 12)) return { kind: 'anchor', index: i };
|
|
||||||
for (const [i, s] of scene.spawns.entries()) if (near(s.position[0], s.position[2], 12)) return { kind: 'spawn', index: i };
|
|
||||||
for (const [i, b] of scene.buildings.entries())
|
|
||||||
if (Math.abs(wx - b.position[0]) < b.size[0] / 2 + 5 && Math.abs(wz - b.position[2]) < b.size[2] / 2 + 5)
|
|
||||||
return { kind: 'building', index: i };
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const objOf = (s) => s && ({ anchor: scene.anchors, building: scene.buildings, spawn: scene.spawns })[s.kind][s.index];
|
|
||||||
|
|
||||||
cv.addEventListener('pointerdown', (e) => {
|
function reselect() {
|
||||||
cv.setPointerCapture(e.pointerId);
|
if (!sel) return;
|
||||||
const h = hit(e.offsetX, e.offsetY);
|
const o = objRoot.children.find(c => c.userData.kind === sel.kind && c.userData.index === sel.index);
|
||||||
sel = h;
|
if (o) { sel.obj3d = o; gizmo.attach(o); } else { sel = null; gizmo.detach(); }
|
||||||
drag = h ? { kind: 'move' } : { kind: 'pan', sx: e.offsetX, sy: e.offsetY, vx: view.x, vz: view.z };
|
}
|
||||||
panel(); draw();
|
|
||||||
|
// ---------- 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, obj3d: o };
|
||||||
|
gizmo.attach(o);
|
||||||
|
} else { sel = null; gizmo.detach(); }
|
||||||
|
panel();
|
||||||
});
|
});
|
||||||
cv.addEventListener('pointermove', (e) => {
|
|
||||||
if (!drag) return;
|
function onGizmoMove() {
|
||||||
if (drag.kind === 'move' && sel) {
|
if (!sel) return;
|
||||||
const [wx, wz] = s2w(e.offsetX, e.offsetY);
|
const d = dataOf(sel);
|
||||||
const o = objOf(sel);
|
const p = sel.obj3d.position;
|
||||||
o.position[0] = Math.round(wx * 2) / 2; // 5 mm snap
|
if (sel.kind === 'building') d.position = [+p.x.toFixed(1), +(p.y - d.size[1] / 2).toFixed(1), +p.z.toFixed(1)];
|
||||||
o.position[2] = Math.round(wz * 2) / 2;
|
else d.position = [+p.x.toFixed(1), +p.y.toFixed(1), +p.z.toFixed(1)];
|
||||||
panel(false); draw();
|
panel(false);
|
||||||
} else if (drag.kind === 'pan') {
|
}
|
||||||
view.x = drag.vx - (e.offsetX - drag.sx) / view.scale;
|
|
||||||
view.z = drag.vz - (e.offsetY - drag.sy) / view.scale;
|
|
||||||
draw();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
cv.addEventListener('pointerup', () => drag = null);
|
|
||||||
cv.addEventListener('wheel', (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
view.scale = Math.min(6, Math.max(0.3, view.scale * (e.deltaY < 0 ? 1.12 : 0.89)));
|
|
||||||
draw();
|
|
||||||
}, { passive: false });
|
|
||||||
|
|
||||||
// ---------- add / delete ----------
|
// ---------- add / delete ----------
|
||||||
document.querySelectorAll('[data-add]').forEach(b => b.onclick = () => {
|
document.querySelectorAll('[data-add]').forEach(b => b.onclick = () => {
|
||||||
const [wx, wz] = [view.x, view.z];
|
const t = orbit.target;
|
||||||
if (b.dataset.add === 'anchor') {
|
if (b.dataset.add === 'anchor') {
|
||||||
const used = new Set(scene.anchors.map(a => a.markerId));
|
const used = new Set(scene.anchors.map(a => a.markerId));
|
||||||
let id = 0; while (used.has(id)) id++;
|
let id = 0; while (used.has(id)) id++;
|
||||||
scene.anchors.push({ markerId: id, position: [wx, 0, wz], mount: 'flat', yawDeg: 0, pitchDeg: 0, rollDeg: 0, sizeMM: 60, enabled: true });
|
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 };
|
sel = { kind: 'anchor', index: scene.anchors.length - 1 };
|
||||||
} else if (b.dataset.add === 'building') {
|
} else if (b.dataset.add === 'building') {
|
||||||
scene.buildings.push({ name: 'building', position: [wx, 0, wz], size: [40, 30, 30], yawDeg: 0 });
|
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 };
|
sel = { kind: 'building', index: scene.buildings.length - 1 };
|
||||||
} else {
|
} else {
|
||||||
scene.spawns.push({ id: 'spawn-' + (scene.spawns.length + 1), position: [wx, 25, wz], enabled: true });
|
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 };
|
sel = { kind: 'spawn', index: scene.spawns.length - 1 };
|
||||||
}
|
}
|
||||||
panel(); draw();
|
buildAll(); panel();
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---------- property panel ----------
|
// ---------- property panel ----------
|
||||||
@@ -169,7 +205,7 @@ function field(label, val, oncommit, opts) {
|
|||||||
const id = 'f' + Math.random().toString(36).slice(2, 8);
|
const id = 'f' + Math.random().toString(36).slice(2, 8);
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
const el = document.getElementById(id);
|
const el = document.getElementById(id);
|
||||||
el.onchange = () => { oncommit(opts?.select ? el.value : parseFloat(el.value)); panel(false); draw(); };
|
el.onchange = () => { oncommit(opts?.select ? el.value : parseFloat(el.value)); buildAll(); panel(false); };
|
||||||
});
|
});
|
||||||
if (opts?.select)
|
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><select id="${id}">${opts.select.map(o => `<option ${o === val ? 'selected' : ''}>${o}</option>`).join('')}</select></div>`;
|
||||||
@@ -177,13 +213,16 @@ function field(label, val, oncommit, opts) {
|
|||||||
}
|
}
|
||||||
function textField(label, val, oncommit) {
|
function textField(label, val, oncommit) {
|
||||||
const id = 'f' + Math.random().toString(36).slice(2, 8);
|
const id = 'f' + Math.random().toString(36).slice(2, 8);
|
||||||
setTimeout(() => { const el = document.getElementById(id); el.onchange = () => { oncommit(el.value); panel(false); draw(); }; });
|
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>`;
|
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) {
|
function panel(rebuild = true) {
|
||||||
if (!rebuild) return;
|
if (!rebuild) return;
|
||||||
const o = objOf(sel);
|
const o = sel && dataOf(sel);
|
||||||
if (!o) { side.innerHTML = '<em style="opacity:.6">Select an item to edit it.</em>'; return; }
|
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; }
|
||||||
let h = `<h2>${sel.kind} ${sel.index}</h2>`;
|
let h = `<h2>${sel.kind} ${sel.index}</h2>`;
|
||||||
if (sel.kind === 'anchor') {
|
if (sel.kind === 'anchor') {
|
||||||
h += field('marker ID', o.markerId, v => o.markerId = v | 0, { step: 1 });
|
h += field('marker ID', o.markerId, v => o.markerId = v | 0, { step: 1 });
|
||||||
@@ -196,10 +235,12 @@ function panel(rebuild = true) {
|
|||||||
h += field('roll °', o.rollDeg, v => o.rollDeg = 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('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 += field('enabled', o.enabled ? 'yes' : 'no', v => o.enabled = v === 'yes', { select: ['yes', 'no'] });
|
||||||
h += `<p class="badge">flat = face-up on a surface · wall = vertical, yaw sets facing · custom = full yaw/pitch/roll</p>`;
|
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') {
|
} else if (sel.kind === 'building') {
|
||||||
h += textField('name', o.name || '', v => o.name = v);
|
h += textField('name', o.name || '', v => o.name = v);
|
||||||
h += field('x (cm)', o.position[0], v => o.position[0] = 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('z (cm)', o.position[2], v => o.position[2] = v);
|
||||||
h += field('width', o.size[0], v => o.size[0] = 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('height', o.size[1], v => o.size[1] = v);
|
||||||
@@ -211,27 +252,40 @@ function panel(rebuild = true) {
|
|||||||
h += field('y (cm)', o.position[1], v => o.position[1] = 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('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 += 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>`;
|
h += `<div class="row" style="margin-top:12px"><button id="del" class="danger">Delete</button></div>`;
|
||||||
side.innerHTML = h;
|
side.innerHTML = h;
|
||||||
document.getElementById('del').onclick = () => {
|
document.getElementById('del').onclick = () => {
|
||||||
({ anchor: scene.anchors, building: scene.buildings, spawn: scene.spawns })[sel.kind].splice(sel.index, 1);
|
({ anchor: scene.anchors, building: scene.buildings, spawn: scene.spawns })[sel.kind].splice(sel.index, 1);
|
||||||
sel = null; panel(); draw();
|
sel = null; gizmo.detach(); buildAll(); panel();
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- save / reset ----------
|
// ---------- toolbar ----------
|
||||||
|
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');
|
const status = document.getElementById('status');
|
||||||
document.getElementById('save').onclick = async () => {
|
document.getElementById('save').onclick = async () => {
|
||||||
try { scene = await api('/api/scene', 'PUT', scene); status.textContent = 'Saved ' + new Date().toLocaleTimeString(); }
|
try { scene = await api('/api/scene', 'PUT', scene); buildAll(); status.textContent = 'Saved ' + new Date().toLocaleTimeString(); }
|
||||||
catch (e) { status.textContent = 'Save failed: ' + e.message; }
|
catch (e) { status.textContent = 'Save failed: ' + e.message; }
|
||||||
};
|
};
|
||||||
document.getElementById('reset').onclick = async () => {
|
document.getElementById('reset').onclick = async () => {
|
||||||
if (!confirm('Reset layout to committed seed? Live edits will be lost.')) return;
|
if (!confirm('Reset layout to committed seed? Live edits will be lost.')) return;
|
||||||
scene = await api('/api/scene/reset', 'POST');
|
scene = await api('/api/scene/reset', 'POST');
|
||||||
sel = null; panel(); draw();
|
sel = null; gizmo.detach(); buildAll(); panel();
|
||||||
};
|
};
|
||||||
|
|
||||||
fit(); 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>
|
</script>
|
||||||
</body></html>
|
</body></html>
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ function render() {
|
|||||||
<div class="row"><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="row"><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="row"><label>Wander radius (cm)</label><input id="wanderRadius" type="number" step="1" value="${cfg.behaviors.wanderRadius}"></div>
|
<div class="row"><label>Wander radius (cm)</label><input id="wanderRadius" type="number" step="1" value="${cfg.behaviors.wanderRadius}"></div>
|
||||||
<div class="row"><label>Wander speed</label><input id="wanderSpeed" type="number" step="0.05" value="${cfg.behaviors.wanderSpeed}"></div>
|
<div class="row"><label>Wander speed</label><input id="wanderSpeed" type="number" step="0.05" value="${cfg.behaviors.wanderSpeed}"></div>
|
||||||
|
<div class="row"><label>Vertical wander (cm)</label><input id="wanderVertical" type="number" step="1" value="${cfg.behaviors.wanderVertical ?? 10}"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2 style="margin-top:0">Time windows <span style="opacity:.6;font-size:.8rem">(ghosts only appear inside these; empty = always on)</span></h2>
|
<h2 style="margin-top:0">Time windows <span style="opacity:.6;font-size:.8rem">(ghosts only appear inside these; empty = always on)</span></h2>
|
||||||
@@ -89,6 +90,7 @@ function render() {
|
|||||||
staticChance: +document.getElementById('staticChance').value,
|
staticChance: +document.getElementById('staticChance').value,
|
||||||
wanderRadius: +document.getElementById('wanderRadius').value,
|
wanderRadius: +document.getElementById('wanderRadius').value,
|
||||||
wanderSpeed: +document.getElementById('wanderSpeed').value,
|
wanderSpeed: +document.getElementById('wanderSpeed').value,
|
||||||
|
wanderVertical: +document.getElementById('wanderVertical').value,
|
||||||
};
|
};
|
||||||
try { cfg = await api('/api/playlist', 'PUT', cfg); document.getElementById('status').textContent = 'Applied ' + new Date().toLocaleTimeString(); }
|
try { cfg = await api('/api/playlist', 'PUT', cfg); document.getElementById('status').textContent = 'Applied ' + new Date().toLocaleTimeString(); }
|
||||||
catch (e) { document.getElementById('status').textContent = 'Failed: ' + e.message; }
|
catch (e) { document.getElementById('status').textContent = 'Failed: ' + e.message; }
|
||||||
|
|||||||
+35
-3
@@ -11,6 +11,7 @@
|
|||||||
</style></head>
|
</style></head>
|
||||||
<body>
|
<body>
|
||||||
<div id="hdr"></div>
|
<div id="hdr"></div>
|
||||||
|
<div id="rose" style="padding:8mm 10mm 0"></div>
|
||||||
<div class="sheet" id="sheet"></div>
|
<div class="sheet" id="sheet"></div>
|
||||||
|
|
||||||
<script src="/vendor/cv.js"></script>
|
<script src="/vendor/cv.js"></script>
|
||||||
@@ -45,12 +46,43 @@ for (const a of scene.anchors) {
|
|||||||
if (code[y * 4 + x] === '1') ctx.fillRect((x + 1) * 10, (y + 1) * 10, 10, 10);
|
if (code[y * 4 + x] === '1') ctx.fillRect((x + 1) * 10, (y + 1) * 10, 10, 10);
|
||||||
}
|
}
|
||||||
div.appendChild(cv);
|
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.insertAdjacentHTML('beforeend',
|
||||||
`<div class="cap"><b>Crest #${a.markerId}</b> · ${a.sizeMM} mm · ${a.mount}` +
|
`<div class="cap"><b>Crest #${a.markerId}</b> · ${a.sizeMM} mm · ${a.mount}<br>` +
|
||||||
`${a.mount === 'wall' ? ` · yaw ${a.yawDeg}°` : ''}<br>` +
|
(a.mount === 'wall'
|
||||||
`pos ${a.position.map(v => v.toFixed(1)).join(', ')} cm</div>`);
|
? `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);
|
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>';
|
if (!scene.anchors.length) sheet.innerHTML = '<p style="padding:20px">No anchors in the scene yet — add some in the Layout editor.</p>';
|
||||||
</script>
|
</script>
|
||||||
</body></html>
|
</body></html>
|
||||||
|
|||||||
@@ -24,7 +24,8 @@ export function ghostTransform(rec, nowMs, out) {
|
|||||||
out.position.set(
|
out.position.set(
|
||||||
base.x + R * 0.9 * Math.sin(ph * 1.0 + hashNoise(s, 1) * 6.28) * 0.7
|
base.x + R * 0.9 * Math.sin(ph * 1.0 + hashNoise(s, 1) * 6.28) * 0.7
|
||||||
+ R * 0.4 * Math.sin(ph * 2.3 + hashNoise(s, 2) * 6.28) * 0.3,
|
+ R * 0.4 * Math.sin(ph * 2.3 + hashNoise(s, 2) * 6.28) * 0.3,
|
||||||
base.y + 6 * Math.sin(t * 1.7 + hashNoise(s, 3) * 6.28),
|
base.y + (b.vertical ?? 10) * Math.sin(t * v * 1.3 + hashNoise(s, 3) * 6.28)
|
||||||
|
+ 3 * Math.sin(t * 1.7 + hashNoise(s, 6) * 6.28),
|
||||||
base.z + R * 0.9 * Math.cos(ph * 0.8 + hashNoise(s, 4) * 6.28) * 0.7
|
base.z + R * 0.9 * Math.cos(ph * 0.8 + hashNoise(s, 4) * 6.28) * 0.7
|
||||||
+ R * 0.4 * Math.cos(ph * 1.9 + hashNoise(s, 5) * 6.28) * 0.3
|
+ R * 0.4 * Math.cos(ph * 1.9 + hashNoise(s, 5) * 6.28) * 0.3
|
||||||
);
|
);
|
||||||
|
|||||||
+3
-3
@@ -7,7 +7,7 @@
|
|||||||
* crossfadeSeconds: 3, // fade out/in overlap hint for clients
|
* crossfadeSeconds: 3, // fade out/in overlap hint for clients
|
||||||
* order: 'shuffle' | 'roster', // rotation order through the roster
|
* order: 'shuffle' | 'roster', // rotation order through the roster
|
||||||
* include: { colors:[], rarities:[], ids:[] }, // empty = all
|
* include: { colors:[], rarities:[], ids:[] }, // empty = all
|
||||||
* behaviors: { staticChance: 0.5, wanderRadius: 60, wanderSpeed: 0.15 },
|
* behaviors: { staticChance: 0.5, wanderRadius: 60, wanderSpeed: 0.15, wanderVertical: 10 },
|
||||||
* timeWindows: [] // e.g. [{ start:"09:00", end:"17:00" }] — empty = always on
|
* timeWindows: [] // e.g. [{ start:"09:00", end:"17:00" }] — empty = always on
|
||||||
* }
|
* }
|
||||||
*
|
*
|
||||||
@@ -23,7 +23,7 @@ class Playlist {
|
|||||||
this.cfg = state.getPlaylistConfig({
|
this.cfg = state.getPlaylistConfig({
|
||||||
slots: 5, dwellSeconds: 120, crossfadeSeconds: 3, order: 'shuffle',
|
slots: 5, dwellSeconds: 120, crossfadeSeconds: 3, order: 'shuffle',
|
||||||
include: { colors: [], rarities: [], ids: [] },
|
include: { colors: [], rarities: [], ids: [] },
|
||||||
behaviors: { staticChance: 0.5, wanderRadius: 60, wanderSpeed: 0.15 },
|
behaviors: { staticChance: 0.5, wanderRadius: 60, wanderSpeed: 0.15, wanderVertical: 10 },
|
||||||
timeWindows: [],
|
timeWindows: [],
|
||||||
});
|
});
|
||||||
this.active = new Map(); // uid -> ghost record
|
this.active = new Map(); // uid -> ghost record
|
||||||
@@ -105,7 +105,7 @@ class Playlist {
|
|||||||
spawnId: spawn.id, pos: spawn.position,
|
spawnId: spawn.id, pos: spawn.position,
|
||||||
behavior: isStatic
|
behavior: isStatic
|
||||||
? { type: 'static', bobAmp: 5 + Math.random() * 5, bobHz: 0.3 + Math.random() * 0.3 }
|
? { type: 'static', bobAmp: 5 + Math.random() * 5, bobHz: 0.3 + Math.random() * 0.3 }
|
||||||
: { type: 'wander', radius: b.wanderRadius ?? 60, speed: b.wanderSpeed ?? 0.15, seed: Math.floor(Math.random() * 1e6) },
|
: { type: 'wander', radius: b.wanderRadius ?? 60, speed: b.wanderSpeed ?? 0.15, vertical: b.wanderVertical ?? 10, seed: Math.floor(Math.random() * 1e6) },
|
||||||
spawnedAt: Date.now(),
|
spawnedAt: Date.now(),
|
||||||
until: Date.now() + (this.cfg.dwellSeconds || 120) * 1000,
|
until: Date.now() + (this.cfg.dwellSeconds || 120) * 1000,
|
||||||
crossfade: this.cfg.crossfadeSeconds || 3,
|
crossfade: this.cfg.crossfadeSeconds || 3,
|
||||||
|
|||||||
Reference in New Issue
Block a user