characters editor: per-part placement panel with TransformControls gizmo + synced numeric fields, model-default/per-ghost scope toggle, rebuilt inline build-from-parts dialog (edit/delete models)
This commit is contained in:
+297
-43
@@ -12,11 +12,12 @@
|
|||||||
<script type="module">
|
<script type="module">
|
||||||
import * as THREE from 'three';
|
import * as THREE from 'three';
|
||||||
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
|
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
|
||||||
import { buildGhost } from '/js/ghosts/loader.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';
|
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: 260px 1fr 300px; height:calc(100vh - 44px); }
|
#wrap { display:grid; grid-template-columns: 240px 1fr 320px; height:calc(100vh - 44px); }
|
||||||
#list { background:#0d1226; overflow:auto; padding:8px; }
|
#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 { 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:hover { background:#1a2244; }
|
||||||
@@ -34,6 +35,8 @@ document.head.insertAdjacentHTML('beforeend', `<style>${styles}
|
|||||||
.row input, .row select { flex:1; min-width:0; }
|
.row input, .row select { flex:1; min-width:0; }
|
||||||
.row input[type=color] { padding:1px; height:26px; }
|
.row input[type=color] { padding:1px; height:26px; }
|
||||||
.row input[type=range] { padding:0; }
|
.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; }
|
.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 { 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 img { width:26px; height:26px; object-fit:cover; border-radius:3px; }
|
||||||
@@ -41,6 +44,17 @@ document.head.insertAdjacentHTML('beforeend', `<style>${styles}
|
|||||||
.badge { font-size:.7rem; opacity:.62; line-height:1.45; }
|
.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 { border:1px dashed #2a3a6e; border-radius:8px; padding:10px; text-align:center; font-size:.75rem; opacity:.8; }
|
||||||
.drop.over { border-color:#51eaf1; background:#141c3a; }
|
.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>`);
|
</style>`);
|
||||||
await requireAuth();
|
await requireAuth();
|
||||||
|
|
||||||
@@ -50,8 +64,8 @@ app.innerHTML = `${nav('characters')}
|
|||||||
<div id="wrap">
|
<div id="wrap">
|
||||||
<div id="list"><input class="search" id="search" placeholder="Search ghosts…"><div id="ghosts"></div></div>
|
<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="stage"><canvas id="gl"></canvas>
|
||||||
<div style="position:absolute;left:10px;bottom:10px;font-size:.72rem;background:rgba(6,8,15,.6);padding:6px 10px;border-radius:8px">
|
<div id="overlay">Live preview · drag to orbit · changes apply to all viewers on Save</div>
|
||||||
Live preview · drag to orbit · changes apply to all viewers on Save</div></div>
|
<div id="gizhint"></div></div>
|
||||||
<div id="side"></div>
|
<div id="side"></div>
|
||||||
</div>`;
|
</div>`;
|
||||||
|
|
||||||
@@ -60,6 +74,7 @@ const gdata = await api('/api/ghosts');
|
|||||||
const ghosts = gdata.ghosts;
|
const ghosts = gdata.ghosts;
|
||||||
const gradients = gdata.gradients.gradients || gdata.gradients;
|
const gradients = gdata.gradients.gradients || gdata.gradients;
|
||||||
let manifest = await api('/api/models');
|
let manifest = await api('/api/models');
|
||||||
|
manifest.models ||= [];
|
||||||
let assets = await api('/api/assets');
|
let assets = await api('/api/assets');
|
||||||
let chars = await api('/api/characters');
|
let chars = await api('/api/characters');
|
||||||
chars.byId ||= {}; chars.defaults ||= {};
|
chars.byId ||= {}; chars.defaults ||= {};
|
||||||
@@ -68,6 +83,10 @@ const baseHeight = scene.ghostHeightCm ?? 4;
|
|||||||
|
|
||||||
let selId = ghosts[0]?.id;
|
let selId = ghosts[0]?.id;
|
||||||
let filter = '';
|
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 ----------
|
// ---------- preview ----------
|
||||||
const cvs = document.getElementById('gl');
|
const cvs = document.getElementById('gl');
|
||||||
@@ -83,19 +102,63 @@ const orbit = new OrbitControls(cam, cvs);
|
|||||||
orbit.target.set(0, 2.2, 0);
|
orbit.target.set(0, 2.2, 0);
|
||||||
const grid = new THREE.GridHelper(20, 20, 0x2a3a6e, 0x151b36); scn.add(grid);
|
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 previewObj = null;
|
||||||
async function refreshPreview() {
|
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; }
|
if (previewObj) { scn.remove(previewObj); previewObj = null; }
|
||||||
|
partNodes = {};
|
||||||
const g = ghosts.find(x => x.id === selId);
|
const g = ghosts.find(x => x.id === selId);
|
||||||
if (!g) return;
|
if (!g) return;
|
||||||
previewObj = await buildGhost(g, gradients, manifest, chars);
|
previewObj = await buildGhost(g, gradients, manifest, chars);
|
||||||
previewObj.userData.setHeight(baseHeight);
|
previewObj.userData.setHeight(baseHeight);
|
||||||
scn.add(previewObj);
|
scn.add(previewObj);
|
||||||
|
|
||||||
|
previewObj.traverse(o => { if (o.userData && o.userData.partKey) partNodes[o.userData.partKey] = o; });
|
||||||
|
|
||||||
const h = chars.byId[selId]?.heightCm || baseHeight;
|
const h = chars.byId[selId]?.heightCm || baseHeight;
|
||||||
orbit.target.set(0, h / 2, 0);
|
orbit.target.set(0, h / 2, 0);
|
||||||
cam.position.set(0, h * 0.75, h * 3);
|
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() {
|
function fit() {
|
||||||
renderer.setSize(cvs.clientWidth, cvs.clientHeight, false);
|
renderer.setSize(cvs.clientWidth, cvs.clientHeight, false);
|
||||||
cam.aspect = cvs.clientWidth / cvs.clientHeight; cam.updateProjectionMatrix();
|
cam.aspect = cvs.clientWidth / cvs.clientHeight; cam.updateProjectionMatrix();
|
||||||
@@ -103,7 +166,8 @@ function fit() {
|
|||||||
new ResizeObserver(fit).observe(cvs);
|
new ResizeObserver(fit).observe(cvs);
|
||||||
(function loop(t) {
|
(function loop(t) {
|
||||||
requestAnimationFrame(loop);
|
requestAnimationFrame(loop);
|
||||||
if (previewObj) { previewObj.userData.tick(t / 1000); previewObj.rotation.y += 0.004; }
|
// 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);
|
orbit.update(); renderer.render(scn, cam);
|
||||||
})(0);
|
})(0);
|
||||||
|
|
||||||
@@ -116,22 +180,57 @@ function renderList() {
|
|||||||
<span class="dot" style="background:${COLORS[g.color]}"></span>${g.name}
|
<span class="dot" style="background:${COLORS[g.color]}"></span>${g.name}
|
||||||
${chars.byId[g.id] ? '<span class="cust">●</span>' : ''}</div>`).join('');
|
${chars.byId[g.id] ? '<span class="cust">●</span>' : ''}</div>`).join('');
|
||||||
document.querySelectorAll('.gh').forEach(el => el.onclick = async () => {
|
document.querySelectorAll('.gh').forEach(el => el.onclick = async () => {
|
||||||
selId = el.dataset.id; renderList(); renderSide(); await refreshPreview();
|
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(); };
|
document.getElementById('search').oninput = e => { filter = e.target.value; renderList(); };
|
||||||
|
|
||||||
// ---------- side panel ----------
|
// ---------- override helpers ----------
|
||||||
const side = document.getElementById('side');
|
const side = document.getElementById('side');
|
||||||
const ov = () => (chars.byId[selId] ||= {});
|
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); };
|
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() {
|
function renderSide() {
|
||||||
const g = ghosts.find(x => x.id === selId);
|
const g = ghosts.find(x => x.id === selId);
|
||||||
if (!g) { side.innerHTML = ''; return; }
|
if (!g) { side.innerHTML = ''; return; }
|
||||||
const grad = gradients[g.color] || gradients.Blue;
|
const grad = gradients[g.color] || gradients.Blue;
|
||||||
const models = (manifest.models || []);
|
const models = (manifest.models || []);
|
||||||
const textures = assets.assets.filter(a => a.kind === 'texture');
|
const textures = assets.assets.filter(a => a.kind === 'texture');
|
||||||
|
const model = activeModel();
|
||||||
|
const hasParts = model && model.parts && Object.keys(model.parts).length;
|
||||||
|
|
||||||
side.innerHTML = `
|
side.innerHTML = `
|
||||||
<h2>${g.name}</h2>
|
<h2>${g.name}</h2>
|
||||||
@@ -148,16 +247,19 @@ function renderSide() {
|
|||||||
<div class="row"><label>extra scale</label>
|
<div class="row"><label>extra scale</label>
|
||||||
<input type="number" step="0.05" min="0.05" id="scale" value="${cur('scale', 1)}"></div>
|
<input type="number" step="0.05" min="0.05" id="scale" value="${cur('scale', 1)}"></div>
|
||||||
|
|
||||||
|
${hasParts ? partPlacementSection(model, g) : `<div class="badge" style="margin-top:8px">
|
||||||
|
This ghost uses the ${model ? 'model’s single mesh' : 'procedural wisp'} — build a multi-part model (Assets box) to place parts individually.</div>`}
|
||||||
|
|
||||||
<h2>Appearance</h2>
|
<h2>Appearance</h2>
|
||||||
<div class="row"><label>opacity</label>
|
<div class="row"><label>opacity</label>
|
||||||
<input type="range" min="0.1" max="1" step="0.02" id="opacity" value="${cur('opacity', 0.92)}">
|
<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>
|
<span id="opv" style="width:32px;font-size:.72rem">${(+cur('opacity', 0.92)).toFixed(2)}</span></div>
|
||||||
<div class="row"><label>top colour</label>
|
<div class="row"><label>top colour</label>
|
||||||
<input type="color" id="topColor" value="${cur('topColor', grad.top)}">
|
<input type="color" id="topColor" value="${cur('topColor', grad.top)}">
|
||||||
<button id="resetTop">reset</button></div>
|
<button id="resetTop" class="minibtn">reset</button></div>
|
||||||
<div class="row"><label>bottom</label>
|
<div class="row"><label>bottom</label>
|
||||||
<input type="color" id="bottomColor" value="${cur('bottomColor', grad.bottom)}">
|
<input type="color" id="bottomColor" value="${cur('bottomColor', grad.bottom)}">
|
||||||
<button id="resetBottom">reset</button></div>
|
<button id="resetBottom" class="minibtn">reset</button></div>
|
||||||
|
|
||||||
<h2>Textures</h2>
|
<h2>Textures</h2>
|
||||||
<div class="badge">Front-projected decals — no UV mapping needed. Use PNGs with transparency.</div>
|
<div class="badge">Front-projected decals — no UV mapping needed. Use PNGs with transparency.</div>
|
||||||
@@ -178,9 +280,112 @@ function renderSide() {
|
|||||||
<button id="clearChar" class="danger">Reset this ghost</button>
|
<button id="clearChar" class="danger">Reset this ghost</button>
|
||||||
<button id="applyAll">Apply to all</button>
|
<button id="applyAll">Apply to all</button>
|
||||||
</div>
|
</div>
|
||||||
<button id="save" class="primary" style="width:100%;padding:10px;margin-top:8px">Save characters</button>
|
<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>`;
|
<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 bind = (id, key, transform = v => v) => {
|
||||||
const el = document.getElementById(id);
|
const el = document.getElementById(id);
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
@@ -189,7 +394,8 @@ function renderSide() {
|
|||||||
if (v === '' || v == null) delete ov()[key]; else ov()[key] = v;
|
if (v === '' || v == null) delete ov()[key]; else ov()[key] = v;
|
||||||
if (id === 'opacity') document.getElementById('opv').textContent = (+v).toFixed(2);
|
if (id === 'opacity') document.getElementById('opv').textContent = (+v).toFixed(2);
|
||||||
if (!Object.keys(chars.byId[selId] || {}).length) delete chars.byId[selId];
|
if (!Object.keys(chars.byId[selId] || {}).length) delete chars.byId[selId];
|
||||||
await refreshPreview(); renderList();
|
if (id === 'modelId') { activePart = null; renderSide(); }
|
||||||
|
await refreshPreview(id !== 'modelId'); renderList();
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
bind('modelId', 'modelId');
|
bind('modelId', 'modelId');
|
||||||
@@ -202,27 +408,32 @@ function renderSide() {
|
|||||||
bind('torsoTextureUrl', 'torsoTextureUrl');
|
bind('torsoTextureUrl', 'torsoTextureUrl');
|
||||||
for (const k of ['faceY', 'faceSize', 'torsoY', 'torsoSize']) bind(k, k, v => parseFloat(v));
|
for (const k of ['faceY', 'faceSize', 'torsoY', 'torsoSize']) bind(k, k, v => parseFloat(v));
|
||||||
|
|
||||||
document.getElementById('resetTop').onclick = async () => { delete ov().topColor; await refreshPreview(); renderSide(); };
|
document.getElementById('resetTop').onclick = async () => { delete ov().topColor; await refreshPreview(true); renderSide(); };
|
||||||
document.getElementById('resetBottom').onclick = async () => { delete ov().bottomColor; await refreshPreview(); renderSide(); };
|
document.getElementById('resetBottom').onclick = async () => { delete ov().bottomColor; await refreshPreview(true); renderSide(); };
|
||||||
document.getElementById('clearChar').onclick = async () => {
|
document.getElementById('clearChar').onclick = async () => {
|
||||||
delete chars.byId[selId]; await refreshPreview(); renderList(); renderSide();
|
delete chars.byId[selId]; activePart = null; await refreshPreview(); renderList(); renderSide();
|
||||||
};
|
};
|
||||||
document.getElementById('applyAll').onclick = async () => {
|
document.getElementById('applyAll').onclick = async () => {
|
||||||
const src = { ...(chars.byId[selId] || {}) };
|
const src = { ...(chars.byId[selId] || {}) };
|
||||||
delete src.faceTextureUrl; // per-ghost faces stay per-ghost
|
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;
|
if (!confirm('Apply this ghost\'s model/appearance settings as the default for every ghost?')) return;
|
||||||
chars.defaults = { ...chars.defaults, ...src };
|
chars.defaults = { ...chars.defaults, ...src };
|
||||||
renderList(); renderSide();
|
renderList(); renderSide();
|
||||||
};
|
};
|
||||||
document.getElementById('save').onclick = async () => {
|
document.getElementById('save').onclick = onSave;
|
||||||
const st = document.getElementById('status');
|
|
||||||
try { chars = await api('/api/characters', 'PUT', chars); chars.byId ||= {}; chars.defaults ||= {};
|
|
||||||
st.textContent = 'Saved ' + new Date().toLocaleTimeString(); renderList();
|
|
||||||
} catch (e) { st.textContent = 'Save failed: ' + e.message; }
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- asset manager (bottom of list column) ----------
|
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() {
|
function renderAssets() {
|
||||||
const models = assets.assets.filter(a => a.kind === 'model');
|
const models = assets.assets.filter(a => a.kind === 'model');
|
||||||
const textures = assets.assets.filter(a => a.kind === 'texture');
|
const textures = assets.assets.filter(a => a.kind === 'texture');
|
||||||
@@ -243,7 +454,7 @@ function renderAssets() {
|
|||||||
<div class="badge">Textures (${textures.length})</div>
|
<div class="badge">Textures (${textures.length})</div>
|
||||||
<div class="assets">${textures.map(a => `<span class="asset"><img src="${a.url}">${a.name}
|
<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 data-delasset="${a.id}" class="danger">✕</button></span>`).join('') || '<span class="badge">none</span>'}</div>
|
||||||
${models.length ? `<button id="buildModel" style="width:100%;margin-top:6px">Build model from parts…</button>` : ''}`;
|
<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');
|
const drop = document.getElementById('drop');
|
||||||
drop.ondragover = e => { e.preventDefault(); drop.classList.add('over'); };
|
drop.ondragover = e => { e.preventDefault(); drop.classList.add('over'); };
|
||||||
@@ -255,8 +466,7 @@ function renderAssets() {
|
|||||||
await api('/api/assets/' + b.dataset.delasset, 'DELETE');
|
await api('/api/assets/' + b.dataset.delasset, 'DELETE');
|
||||||
assets = await api('/api/assets'); renderAssets(); renderSide(); await refreshPreview();
|
assets = await api('/api/assets'); renderAssets(); renderSide(); await refreshPreview();
|
||||||
});
|
});
|
||||||
const bm = document.getElementById('buildModel');
|
document.getElementById('buildModel').onclick = () => openBuilder(activeModel()?.id || null);
|
||||||
if (bm) bm.onclick = buildModelDialog;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function upload(files) {
|
async function upload(files) {
|
||||||
@@ -276,25 +486,69 @@ async function upload(files) {
|
|||||||
renderAssets(); renderSide();
|
renderAssets(); renderSide();
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Assemble uploaded OBJ parts into a named model in the manifest. */
|
// ---------- inline model builder (replaces sequential prompts) ----------
|
||||||
async function buildModelDialog() {
|
let builderEl = null;
|
||||||
|
function openBuilder(editId = null) {
|
||||||
const objs = assets.assets.filter(a => a.kind === 'model' && a.name.toLowerCase().endsWith('.obj'));
|
const objs = assets.assets.filter(a => a.kind === 'model' && a.name.toLowerCase().endsWith('.obj'));
|
||||||
if (!objs.length) return alert('Upload some .obj parts first.');
|
if (!objs.length) return alert('Upload some .obj parts first (drop them in the Assets box).');
|
||||||
const id = prompt('Model name (e.g. minifig):', 'minifig');
|
const existing = editId ? manifest.models.find(m => m.id === editId) : null;
|
||||||
if (!id) return;
|
|
||||||
const parts = {};
|
if (builderEl) builderEl.remove();
|
||||||
for (const key of ['legs', 'torso', 'head', 'headpiece']) {
|
builderEl = document.createElement('div');
|
||||||
const list = objs.map((o, i) => `${i}: ${o.name}`).join('\n');
|
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 pick = prompt(`Which file is the ${key}? (number, or blank to skip)\n\n${list}`, '');
|
const partsInit = existing ? existing.parts : {};
|
||||||
if (pick === null) return;
|
const urlToIdx = entry => { const u = typeof entry === 'string' ? entry : entry?.url; return objs.findIndex(o => o.url === u); };
|
||||||
if (pick.trim() !== '' && objs[+pick]) parts[key] = objs[+pick].url;
|
|
||||||
|
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(parts).length) return alert('No parts chosen.');
|
});
|
||||||
manifest.models = (manifest.models || []).filter(m => m.id !== id);
|
if (!Object.keys(newParts).length) return status.textContent = 'Pick at least one part.';
|
||||||
manifest.models.push({ id, scale: 1, parts });
|
const scale = parseFloat(document.getElementById('bmScale').value) || 1;
|
||||||
manifest = await api('/api/models', 'PUT', manifest);
|
manifest.models = manifest.models.filter(m => m.id !== id);
|
||||||
renderSide(); await refreshPreview();
|
manifest.models.push({ id, scale, parts: newParts });
|
||||||
alert(`Model "${id}" created — pick it in the Model dropdown.`);
|
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();
|
renderList(); renderSide(); renderAssets(); fit(); await refreshPreview();
|
||||||
|
|||||||
Reference in New Issue
Block a user