Files
newbury-exhibit-v2/public/admin/characters.html
T

557 lines
28 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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 ? 'models 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>