diff --git a/data/scene.json b/data/scene.json index 4935c9f..6878ffc 100644 --- a/data/scene.json +++ b/data/scene.json @@ -4,24 +4,179 @@ "note": "origin = front-left corner of the table; +X right, +Z toward the back, +Y up. All positions/sizes in centimetres; marker print size stays in mm (sizeMM)." }, "anchors": [ - { "markerId": 0, "position": [20.0, 0.0, 20.0], "mount": "flat", "yawDeg": 0, "pitchDeg": 0, "rollDeg": 0, "sizeMM": 60, "enabled": true }, - { "markerId": 1, "position": [90.0, 0.0, 20.0], "mount": "flat", "yawDeg": 0, "pitchDeg": 0, "rollDeg": 0, "sizeMM": 60, "enabled": true }, - { "markerId": 2, "position": [20.0, 0.0, 90.0], "mount": "flat", "yawDeg": 0, "pitchDeg": 0, "rollDeg": 0, "sizeMM": 60, "enabled": true }, - { "markerId": 3, "position": [90.0, 0.0, 90.0], "mount": "flat", "yawDeg": 0, "pitchDeg": 0, "rollDeg": 0, "sizeMM": 60, "enabled": true }, - { "markerId": 4, "position": [55.0, 12.0, 0.0], "mount": "wall", "yawDeg": 180, "pitchDeg": 0, "rollDeg": 0, "sizeMM": 60, "enabled": true } + { + "markerId": 0, + "position": [ + 20.0, + 0.0, + 20.0 + ], + "mount": "flat", + "yawDeg": 0, + "pitchDeg": 0, + "rollDeg": 0, + "sizeMM": 60, + "enabled": true + }, + { + "markerId": 1, + "position": [ + 90.0, + 0.0, + 20.0 + ], + "mount": "flat", + "yawDeg": 0, + "pitchDeg": 0, + "rollDeg": 0, + "sizeMM": 60, + "enabled": true + }, + { + "markerId": 2, + "position": [ + 20.0, + 0.0, + 90.0 + ], + "mount": "flat", + "yawDeg": 0, + "pitchDeg": 0, + "rollDeg": 0, + "sizeMM": 60, + "enabled": true + }, + { + "markerId": 3, + "position": [ + 90.0, + 0.0, + 90.0 + ], + "mount": "flat", + "yawDeg": 0, + "pitchDeg": 0, + "rollDeg": 0, + "sizeMM": 60, + "enabled": true + }, + { + "markerId": 4, + "position": [ + 55.0, + 12.0, + 0.0 + ], + "mount": "wall", + "yawDeg": 180, + "pitchDeg": 0, + "rollDeg": 0, + "sizeMM": 60, + "enabled": true + } ], "buildings": [ - { "name": "Newbury High", "position": [55.0, 0.0, 55.0], "size": [35.0, 30.0, 25.0], "yawDeg": 0 } + { + "name": "Newbury High", + "position": [ + 55.0, + 0.0, + 55.0 + ], + "size": [ + 35.0, + 30.0, + 25.0 + ], + "yawDeg": 0 + } ], "spawns": [ - { "id": "spawn-1", "position": [35.0, 30.0, 40.0], "enabled": true }, - { "id": "spawn-2", "position": [75.0, 35.0, 40.0], "enabled": true }, - { "id": "spawn-3", "position": [55.0, 45.0, 70.0], "enabled": true }, - { "id": "spawn-4", "position": [25.0, 25.0, 75.0], "enabled": true }, - { "id": "spawn-5", "position": [85.0, 28.0, 75.0], "enabled": true } + { + "id": "spawn-1", + "position": [ + 35.0, + 30.0, + 40.0 + ], + "enabled": true + }, + { + "id": "spawn-2", + "position": [ + 75.0, + 35.0, + 40.0 + ], + "enabled": true + }, + { + "id": "spawn-3", + "position": [ + 55.0, + 45.0, + 70.0 + ], + "enabled": true + }, + { + "id": "spawn-4", + "position": [ + 25.0, + 25.0, + 75.0 + ], + "enabled": true + }, + { + "id": "spawn-5", + "position": [ + 85.0, + 28.0, + 75.0 + ], + "enabled": true + } ], "paths": [ - { "id": "main-street", "mode": "loop", "enabled": true, "points": [[20, 20, 35], [90, 20, 35], [95, 25, 75], [55, 30, 95], [15, 25, 70]] } + { + "id": "main-street", + "mode": "loop", + "enabled": true, + "points": [ + [ + 20, + 20, + 35 + ], + [ + 90, + 20, + 35 + ], + [ + 95, + 25, + 75 + ], + [ + 55, + 30, + 95 + ], + [ + 15, + 25, + 70 + ] + ] + } ], - "table": { "width": 110, "depth": 110, "offsetX": 0, "offsetZ": 0, "show": true } -} + "table": { + "width": 110, + "depth": 110, + "offsetX": 0, + "offsetZ": 0, + "show": true + } +} \ No newline at end of file diff --git a/public/admin/admin.js b/public/admin/admin.js index 34e2a36..4207204 100644 --- a/public/admin/admin.js +++ b/public/admin/admin.js @@ -39,7 +39,7 @@ export const styles = ` export function nav(active) { return `

Newbury Exhibit — Admin

- ${['preview', 'layout', 'plan', 'playlist', 'print', 'calibrate', 'errors'].map(p => + ${['preview', 'layout', 'plan', 'playlist', 'characters', 'print', 'calibrate', 'errors'].map(p => `${p}`).join('')} viewer ↗
`; diff --git a/public/admin/characters.html b/public/admin/characters.html new file mode 100644 index 0000000..78dd745 --- /dev/null +++ b/public/admin/characters.html @@ -0,0 +1,302 @@ + + +Characters — Newbury Exhibit + +
+ + + diff --git a/public/admin/preview.html b/public/admin/preview.html index fd15c53..11a1372 100644 --- a/public/admin/preview.html +++ b/public/admin/preview.html @@ -50,6 +50,7 @@ app.innerHTML = `${nav('preview')} 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'); @@ -134,7 +135,7 @@ const clockSamples = []; let clockOffset = 0; async function addGhost(rec) { if (active.has(rec.uid)) return; - const group = await buildGhost(rec, gradients, modelManifest); + const group = await buildGhost(rec, gradients, modelManifest, characters); group.userData.setHeight(ghostHeight); const label = textSprite(rec.name, COLORS[rec.color] || '#fff'); label.scale.set(20, 5, 1); @@ -157,6 +158,12 @@ net.on('scene', m => { 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); diff --git a/public/js/exhibit.js b/public/js/exhibit.js index cd0e4b6..47cd63b 100644 --- a/public/js/exhibit.js +++ b/public/js/exhibit.js @@ -15,7 +15,7 @@ const $ = (s) => document.querySelector(s); let info = { mode: 'dev' }; let scene3, camera3, renderer, video, canvas2d, ctx2d; let detector, poseEst, fuser; -let gradients = {}, modelManifest = null; +let gradients = {}, modelManifest = null, characters = { defaults: {}, byId: {} }; let ghostHeight = 4; // cm, from scene.ghostHeightCm const activeGhosts = new Map(); // uid -> { rec, group } let tracking = { markers: 0, lastSeen: 0 }; @@ -29,6 +29,7 @@ async function boot() { const g = await (await fetch('/api/ghosts')).json(); gradients = g.gradients.gradients || g.gradients; modelManifest = await (await fetch('/api/models')).json(); + characters = await (await fetch('/api/characters')).json(); $('#start').addEventListener('click', start, { once: true }); } @@ -68,6 +69,12 @@ async function start() { ghostHeight = m.scene.ghostHeightCm || 4; for (const e of activeGhosts.values()) e.group.userData.setHeight(ghostHeight); }) + .on('characters', async (m) => { + characters = m.characters || characters; + const recs = [...activeGhosts.values()].map(e => e.rec); + for (const uid of [...activeGhosts.keys()]) removeGhost(uid); + for (const rec of recs) await addGhost(rec); // rebuild with new visuals + }) .on('active', async (m) => { for (const uid of [...activeGhosts.keys()]) removeGhost(uid); for (const rec of m.ghosts) await addGhost(rec); @@ -111,7 +118,7 @@ function buildOcclusion(scene) { // ---------- ghosts ---------- async function addGhost(rec) { if (activeGhosts.has(rec.uid)) return; - const group = await buildGhost(rec, gradients, modelManifest); + const group = await buildGhost(rec, gradients, modelManifest, characters); group.userData.setHeight(ghostHeight); scene3.add(group); activeGhosts.set(rec.uid, { rec, group }); diff --git a/public/js/ghosts/loader.js b/public/js/ghosts/loader.js index 4fdca52..240a5d3 100644 --- a/public/js/ghosts/loader.js +++ b/public/js/ghosts/loader.js @@ -1,20 +1,36 @@ /* loader.js — ghost visuals. - * Multi-part OBJ ghosts: { legs|wisp, torso, head, headpiece } assembled into one Group, - * every part rendered with the recovered Hidden Side gradient shader (top->bottom tint - * by GhostColor: Red / Yellow / Blue). Until models exist, a procedural wisp fallback - * is used so the exhibit always has something to show. * - * Model manifest (data/models.json, editable via /api/models): - * { "models": [ { "id": "classic", "scale": 1.0, - * "parts": { "legs": "/models/classic/legs.obj", "torso": "...", "head": "...", "headpiece": "..." } } ] } + * Multi-part OBJ ghosts: { legs|wisp, torso, head, headpiece } assembled into one Group, + * rendered with the recovered Hidden Side gradient shader (top->bottom tint by GhostColor: + * Red / Yellow / Blue). Procedural wisp fallback until models are uploaded. + * + * Per-character overrides come from /api/characters (managed in /admin/characters.html): + * { modelId, opacity, topColor, bottomColor, heightCm, scale, + * faceTextureUrl, torsoTextureUrl } + * + * Textures: face and torso images are projected planar-front onto the mesh (UV-independent, + * so untextured OBJs still work). Each is placed by a normalized Y band with adjustable + * centre/size, blended over the gradient — a simple decal without needing UV-mapped models. */ import * as THREE from 'three'; import { OBJLoader } from 'three/addons/loaders/OBJLoader.js'; const objLoader = new OBJLoader(); const objCache = new Map(); +const texLoader = new THREE.TextureLoader(); +const texCache = new Map(); -function gradientMaterial(top, bottom, opacity = 0.92) { +function loadTexture(url) { + if (!url) return null; + if (!texCache.has(url)) { + const t = texLoader.load(url); + t.colorSpace = THREE.SRGBColorSpace; + texCache.set(url, t); + } + return texCache.get(url); +} + +function gradientMaterial(top, bottom, opacity, faceTex, torsoTex, bands) { return new THREE.ShaderMaterial({ transparent: true, depthWrite: false, @@ -25,25 +41,60 @@ function gradientMaterial(top, bottom, opacity = 0.92) { uMinY: { value: 0 }, uMaxY: { value: 1 }, uTime: { value: 0 }, + faceMap: { value: faceTex || null }, + torsoMap: { value: torsoTex || null }, + hasFace: { value: faceTex ? 1 : 0 }, + hasTorso: { value: torsoTex ? 1 : 0 }, + // [centreY, halfHeight, halfWidth] in normalized model space + faceBand: { value: new THREE.Vector3(bands.faceY, bands.faceSize, bands.faceSize) }, + torsoBand: { value: new THREE.Vector3(bands.torsoY, bands.torsoSize, bands.torsoSize) }, }, vertexShader: ` varying float vY; varying vec3 vNormal; + varying vec3 vLocal; uniform float uMinY, uMaxY; void main() { - vY = clamp((position.y - uMinY) / max(uMaxY - uMinY, 0.001), 0.0, 1.0); + float h = max(uMaxY - uMinY, 0.001); + vY = clamp((position.y - uMinY) / h, 0.0, 1.0); + vLocal = vec3(position.x / h, vY, position.z / h); vNormal = normalize(normalMatrix * normal); gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); }`, fragmentShader: ` varying float vY; varying vec3 vNormal; + varying vec3 vLocal; uniform vec3 topColor, bottomColor; uniform float opacity, uTime; + uniform sampler2D faceMap, torsoMap; + uniform int hasFace, hasTorso; + uniform vec3 faceBand, torsoBand; + + // planar-front decal: map local X/Y into the band's UV box + vec4 decal(sampler2D map, vec3 band) { + vec2 uv = vec2((vLocal.x / band.z) * 0.5 + 0.5, + ((vLocal.y - band.x) / band.y) * 0.5 + 0.5); + if (uv.x < 0.0 || uv.x > 1.0 || uv.y < 0.0 || uv.y > 1.0) return vec4(0.0); + return texture2D(map, uv); + } + void main() { vec3 c = mix(bottomColor, topColor, vY); float rim = pow(1.0 - abs(dot(vNormal, vec3(0.0, 0.0, 1.0))), 2.0); c += rim * 0.35; + + // only decal the front-facing side so images don't mirror onto the back + float front = smoothstep(0.0, 0.35, vNormal.z); + if (hasTorso == 1) { + vec4 t = decal(torsoMap, torsoBand); + c = mix(c, t.rgb, t.a * front); + } + if (hasFace == 1) { + vec4 f = decal(faceMap, faceBand); + c = mix(c, f.rgb, f.a * front); + } + float pulse = 0.92 + 0.08 * sin(uTime * 2.2); gl_FragColor = vec4(c, opacity * pulse); }`, @@ -69,12 +120,37 @@ function proceduralWisp() { return g; } -export async function buildGhost(ghost, gradients, manifest) { +/* Resolve the override chain: character-specific -> defaults -> built-in. */ +function resolveOverrides(ghost, characters) { + const d = (characters && characters.defaults) || {}; + const c = (characters && characters.byId && characters.byId[ghost.id]) || {}; + return { ...d, ...c }; +} + +function pickModel(manifest, ov) { + const models = (manifest && manifest.models) || []; + if (!models.length) return null; + if (ov.modelId) return models.find(m => m.id === ov.modelId) || models[0]; + return models[0]; +} + +export async function buildGhost(ghost, gradients, manifest, characters) { + const ov = resolveOverrides(ghost, characters); const grad = gradients[ghost.color] || gradients.Blue; - const mat = gradientMaterial(grad.top, grad.bottom); + const top = ov.topColor || grad.top; + const bottom = ov.bottomColor || grad.bottom; + const opacity = ov.opacity != null ? Number(ov.opacity) : 0.92; + const bands = { + faceY: ov.faceY != null ? Number(ov.faceY) : 0.82, + faceSize: ov.faceSize != null ? Number(ov.faceSize) : 0.16, + torsoY: ov.torsoY != null ? Number(ov.torsoY) : 0.52, + torsoSize: ov.torsoSize != null ? Number(ov.torsoSize) : 0.22, + }; + const mat = gradientMaterial(top, bottom, opacity, + loadTexture(ov.faceTextureUrl), loadTexture(ov.torsoTextureUrl), bands); let group; - const model = (manifest?.models || [])[0]; // default model; per-ghost mapping can extend later + const model = pickModel(manifest, ov); if (model && model.parts) { group = new THREE.Group(); try { @@ -92,7 +168,7 @@ export async function buildGhost(ghost, gradients, manifest) { group = proceduralWisp(); } - // apply gradient material to every mesh; compute Y-range for gradient mapping + // gradient/decal mapping needs the model's Y range const box = new THREE.Box3().setFromObject(group); group.traverse(o => { if (o.isMesh) { @@ -102,20 +178,19 @@ export async function buildGhost(ghost, gradients, manifest) { } }); - // Normalize: wrap so feet sit at the group origin, then expose setHeight(cm) that - // scales the whole ghost (any source units) to an exact world height. Minifigure ≈ 4 cm. + // Normalize: feet at origin, then setHeight(cm) scales any source units to world height. const outer = new THREE.Group(); const inner = new THREE.Group(); - inner.position.y = -box.min.y; // feet -> origin + inner.position.y = -box.min.y; inner.add(group); outer.add(inner); const rawH = Math.max(box.max.y - box.min.y, 1e-6); - const modelScale = model?.scale || 1; // per-model fine-tune multiplier from the manifest - outer.userData.setHeight = (cm) => outer.scale.setScalar((cm / rawH) * modelScale); + const modelScale = (ov.scale || model?.scale || 1); + outer.userData.setHeight = (cm) => outer.scale.setScalar(((ov.heightCm || cm) / rawH) * modelScale); outer.userData.setHeight(4); outer.userData.material = mat; - outer.userData.setOpacity = (v) => { mat.uniforms.opacity.value = v; }; + outer.userData.setOpacity = (v) => { mat.uniforms.opacity.value = v * opacity; }; outer.userData.tick = (t) => { mat.uniforms.uTime.value = t; }; return outer; } diff --git a/push-characters.bat b/push-characters.bat new file mode 100644 index 0000000..5199eed --- /dev/null +++ b/push-characters.bat @@ -0,0 +1,17 @@ +@echo off +REM push-characters.bat - commit and push the character manager changes to Gitea. +REM Run from anywhere; it always works on K:\newbury-exhibit-v2 +cd /d K:\newbury-exhibit-v2 + +if not exist .git ( + echo No git repo here - initialising and linking to Gitea... + git init -b main + git remote add origin https://gitea.hideawaygaming.com.au/jessikitty/newbury-exhibit-v2.git + git fetch origin + git reset --soft origin/main +) + +git add -A +git commit -m "Character manager: asset uploads, per-ghost model/colour/opacity overrides, face+torso texture decals" +git push origin main +pause \ No newline at end of file diff --git a/server/server.js b/server/server.js index acba871..42cce51 100644 --- a/server/server.js +++ b/server/server.js @@ -11,18 +11,40 @@ const { WebSocketServer } = require('ws'); const state = require('./state'); const auth = require('./auth'); const Playlist = require('./playlist'); +const { Assets } = require('./uploads'); const PORT = process.env.PORT || 33044; const MODE = process.env.EXHIBIT_MODE || 'dev'; const app = express(); -app.use(express.json({ limit: '2mb' })); +app.use(express.json({ limit: '20mb' })); // base64 asset uploads // ---------- public static ---------- app.use(express.static(path.join(__dirname, '..', 'public'))); app.use('/data/ghosts.json', express.static(path.join(__dirname, '..', 'data', 'ghosts.json'))); app.use('/data/gradients.json', express.static(path.join(__dirname, '..', 'data', 'gradients.json'))); +// ---------- uploaded assets (models + textures) ---------- +const assets = new Assets(state.liveDir); +app.use('/assets', express.static(path.join(state.liveDir, 'assets'), { maxAge: '1h' })); + +app.get('/api/assets', (req, res) => res.json(assets.list())); +app.post('/api/assets', guard, (req, res) => { + try { res.json(assets.save(req.body)); } + catch (e) { res.status(400).json({ error: String(e.message || e) }); } +}); +app.delete('/api/assets/:id', guard, (req, res) => { + try { res.json(assets.remove(req.params.id)); } + catch (e) { res.status(400).json({ error: String(e.message || e) }); } +}); + +// ---------- character overrides ---------- +app.get('/api/characters', (req, res) => res.json(state.getCharacters())); +app.put('/api/characters', guard, (req, res) => { + try { res.json(state.putCharacters(req.body)); } + catch (e) { res.status(400).json({ error: String(e.message || e) }); } +}); + // ---------- runtime info ---------- app.get('/api/info', (req, res) => { res.json({ mode: MODE, authRequired: MODE === 'final', version: 2 }); @@ -91,6 +113,7 @@ playlist.start(); wss.on('connection', (ws) => { ws.send(JSON.stringify({ type: 'scene', scene: state.getScene() })); + ws.send(JSON.stringify({ type: 'characters', characters: state.getCharacters() })); ws.send(JSON.stringify({ type: 'active', ghosts: playlist.activeSnapshot() })); ws.on('message', (raw) => { let m; try { m = JSON.parse(raw); } catch { return; } @@ -99,7 +122,10 @@ wss.on('connection', (ws) => { }); // push scene updates to viewers when admin saves -state.onChange(() => broadcast({ type: 'scene', scene: state.getScene() })); +state.onChange(() => { + broadcast({ type: 'scene', scene: state.getScene() }); + broadcast({ type: 'characters', characters: state.getCharacters() }); +}); server.listen(PORT, () => { console.log(`Newbury Exhibit v2 [${MODE}] on :${PORT}`); diff --git a/server/state.js b/server/state.js index defc7b6..7cde8ee 100644 --- a/server/state.js +++ b/server/state.js @@ -12,6 +12,7 @@ const LIVE = path.join(LIVE_DIR, 'scene.live.json'); const PLAYLIST_LIVE = path.join(LIVE_DIR, 'playlist.live.json'); const MODELS_LIVE = path.join(LIVE_DIR, 'models.live.json'); const MODELS_SEED = path.join(DATA, 'models.json'); +const CHARS_LIVE = path.join(LIVE_DIR, 'characters.live.json'); fs.mkdirSync(LIVE_DIR, { recursive: true }); @@ -26,6 +27,10 @@ let scene = readJSON(LIVE, null) || readJSON(SEED, { anchors: [], buildings: [], let ghosts = readJSON(path.join(DATA, 'ghosts.json'), []); let gradients = readJSON(path.join(DATA, 'gradients.json'), {}); let models = readJSON(MODELS_LIVE, null) || readJSON(MODELS_SEED, { models: [] }); +/* characters: per-ghost visual overrides keyed by ghost id, plus defaults. + * { defaults: {...}, byId: { 'mad-fenton': { modelId, opacity, topColor, bottomColor, + * faceTextureUrl, torsoTextureUrl, heightCm, scale } } } */ +let characters = readJSON(CHARS_LIVE, null) || { defaults: {}, byId: {} }; const listeners = []; @@ -71,6 +76,15 @@ module.exports = { return scene; }, getGhosts: () => ({ ghosts, gradients }), + getCharacters: () => characters, + putCharacters(c) { + if (!c || typeof c !== 'object') throw new Error('characters must be object'); + characters = { defaults: c.defaults || {}, byId: c.byId || {} }; + writeJSON(CHARS_LIVE, characters); + listeners.forEach(f => f()); + return characters; + }, + liveDir: LIVE_DIR, getModels: () => models, putModels(m) { if (!m || !Array.isArray(m.models)) throw new Error('models.models must be array');