wardrobegod/web/index.html
type-two 294af7cf1d Phase 3: the 2D paper-doll tier — 392 garments you can actually change
The flat cut-outs were the cheapest content on the board and the only tier with a shipping
consumer, but wardrobegod couldn't show one: scan() filtered PNGs out of garments/ (fixed in
Phase 0) and there was no compositor, no slots, no preview. Now there is a working dress-up.

· library/doll/ seeded with 90sDJsim's 441 shipped sprites (165 top, 110 bottom, 60 shoes,
  57 hat, base body) — an instant starter wardrobe rather than a cold start.
· tools/doll.py wraps djsim's compositor. It deliberately does NOT import doll_composite:
  that module pulls numpy at import time for a keyer and an HSV recolour we never use (the
  farm's RMBG-2.0 does our keying), and numpy isn't installable here under PEP 668. Instead
  the measured anchors are parsed out of its source with ast, so there is still exactly one
  source of truth and they cannot drift; place() is a pure-PIL port.
· slugify() reproduces djsim's item_art() _art_slug character for character, verified against
  it. That equality IS the drop-in contract — a near-miss silently falls back to generic slot
  art instead of erroring, so it's the kind of bug you'd ship without noticing.
· /api/doll/gen runs the proven pipeline: flux_local on SOLID GREEN -> farm RMBG-2.0 ->
  staged to 704x1408. Green is not a preference: grey and checkerboard backgrounds eat
  garments in the key, and flux can't spell, so no text ever goes in the prompt.
· /api/doll/compose composes server-side with PIL, so the browser preview and the exported
  PNG are the same bytes rather than two renderers that drift.
· /api/doll/export writes into a LIVE game directory, so it defaults to a dry run and refuses
  any stem without _i_ — overwriting a generic slot fallback would restyle every unarted item
  in a shipping game.
· UI: a doll 2D tab with per-slot pickers, randomise, strip, and export.

Verified: composed a dressed doll from real sprites (correct base->bottom->shoes->top->hat
order), catalogue reports 392 layers, export dry-run and the generic-art guard both behave.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 16:07:13 +10:00

529 lines
30 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>
<meta charset="utf-8">
<title>WARDROBEGOD — one skeleton, infinite fits</title>
<style>
:root { --bg:#14120e; --panel:#1c1913; --line:#37311f; --gold:#e8c257; --ink:#d8d2c2; --dim:#8a8270; }
* { box-sizing:border-box } html,body { margin:0; height:100%; background:var(--bg); color:var(--ink);
font:14px/1.45 -apple-system, system-ui, sans-serif }
#app { display:grid; grid-template-columns:270px 1fr 340px; grid-template-rows:46px 1fr 26px; height:100% }
header { grid-column:1/4; display:flex; align-items:center; gap:14px; padding:0 14px; border-bottom:1px solid var(--line) }
header h1 { font-size:17px; letter-spacing:2px; color:var(--gold); margin:0 } header .sub{color:var(--dim);font-size:12px}
header .right { margin-left:auto; display:flex; gap:8px; align-items:center }
.col { border-right:1px solid var(--line); overflow:auto; padding:10px }
.col:last-child { border-right:none; border-left:1px solid var(--line) }
h2 { font-size:11px; letter-spacing:2px; color:var(--gold); margin:12px 0 6px; text-transform:uppercase }
.item { padding:6px 8px; border:1px solid var(--line); border-radius:8px; margin:4px 0; cursor:pointer;
display:flex; justify-content:space-between; gap:6px; background:var(--panel) }
.item:hover { border-color:var(--gold) } .item.on { border-color:var(--gold); background:#26210f }
.item .meta { color:var(--dim); font-size:11px; white-space:nowrap }
.item .nm { overflow:hidden; text-overflow:ellipsis; white-space:nowrap }
#stage { position:relative; min-width:0; overflow:hidden } canvas { display:block; max-width:100% }
#app > .col { min-width:0 }
@media (max-width: 1100px) { #app { grid-template-columns:220px 1fr 300px } }
#hud { position:absolute; left:10px; top:10px; background:#0009; padding:6px 10px; border-radius:8px; font-size:12px; color:var(--ink); max-width:60% }
#foot { grid-column:1/4; display:flex; align-items:center; gap:14px; padding:0 12px; border-top:1px solid var(--line); font-size:12px; color:var(--dim); overflow:hidden; white-space:nowrap }
button { background:var(--gold); color:#211a04; border:none; border-radius:8px; padding:6px 10px; font-weight:700; cursor:pointer; font-size:12px }
button.ghost { background:none; color:var(--ink); border:1px solid var(--line) }
button:disabled { opacity:.4; cursor:default }
input, select, textarea { background:#12100b; color:var(--ink); border:1px solid var(--line); border-radius:8px; padding:6px 8px; font:13px system-ui; width:100% }
.row { display:flex; gap:6px; margin:5px 0; align-items:center } .row label { width:74px; color:var(--dim); font-size:12px }
.tabs { display:flex; gap:4px; margin-bottom:8px } .tabs button { background:none; border:1px solid var(--line); color:var(--dim) }
.tabs button.on { border-color:var(--gold); color:var(--gold) }
#genPrev { width:100%; border-radius:8px; border:1px solid var(--line); display:none }
.note { color:var(--dim); font-size:12px; margin:6px 0 }
#how { position:absolute; right:12px; top:12px; width:330px; background:#0e0c08ee; border:1px solid var(--line);
border-radius:10px; padding:12px; display:none; z-index:5; font-size:12.5px }
#how b { color:var(--gold) } #how ol { margin:6px 0 6px 18px; padding:0 }
/* transport: clip picker + scrub, pinned to the bottom of the stage */
#transport { position:absolute; left:10px; right:10px; bottom:10px; display:none; gap:8px; align-items:center;
background:#0e0c08e6; border:1px solid var(--line); border-radius:10px; padding:7px 10px; z-index:4 }
#transport.on { display:flex }
#transport select { width:auto; min-width:150px; max-width:34% }
#transport #scrub { flex:1; min-width:60px; padding:0 }
#transport #speed { width:88px; padding:0 } #transport .sub { color:var(--dim); font-size:11px; white-space:nowrap }
#transport button { min-width:34px }
/* flat (2D cut-out) garment preview — paper-doll tier has no 3D to show */
#flatPrev { position:absolute; right:12px; bottom:64px; width:190px; border:1px solid var(--line);
border-radius:10px; background:#0e0c08cc; display:none; z-index:4 }
.swatches { display:flex; gap:5px; flex-wrap:wrap; margin:5px 0 }
.sw { width:22px; height:22px; border-radius:6px; border:1px solid var(--line); cursor:pointer; padding:0 }
.sw.on { border-color:var(--gold); box-shadow:0 0 0 2px #e8c25744 }
.fitchip { margin:3px 4px 0 0; font-weight:400 }
/* 2D paper-doll stage — takes over the viewport while the doll tab is open */
#dollStage { position:absolute; inset:0; display:none; align-items:center; justify-content:center;
background:#14120e; z-index:3 }
#dollStage.on { display:flex }
#dollPrev { max-height:96%; max-width:60%; image-rendering:auto }
.slotrow { display:flex; align-items:center; gap:6px; margin:5px 0 }
.slotrow label { width:56px; color:var(--dim); font-size:12px; text-transform:uppercase; letter-spacing:1px }
.slotrow select { flex:1; min-width:0 }
</style>
<div id="app">
<header>
<h1>WARDROBEGOD</h1><span class="sub">one skeleton · infinite fits</span>
<span class="right">
<span id="farm" class="sub"></span>
<button class="ghost" id="howBtn">how does clothing work?</button>
</span>
</header>
<div class="col" id="left">
<h2>bodies</h2>
<input type="file" id="upBody" accept=".glb,.gltf,.fbx,.obj" style="margin-bottom:6px">
<div id="bodies"></div>
<h2>body ops</h2>
<div class="row"><label>height m</label><input id="scaleH" value="1.72"><button id="scaleBtn" class="ghost">scale</button></div>
<div class="row"><label>target tris</label><input id="decT" value="18000"><button id="decBtn" class="ghost">decimate</button></div>
<div class="note">decimate keeps skin weights — the safe local path for rigged meshes (never the farm /finish).</div>
</div>
<div id="stage">
<div id="hud">pick a body…</div>
<div id="transport">
<button id="playBtn" class="ghost">❚❚</button>
<select id="clipSel"><option>— no clips —</option></select>
<input id="scrub" type="range" min="0" max="1000" value="0" step="1">
<span id="clipTime" class="sub">0.0s</span>
<label class="sub">speed</label><input id="speed" type="range" min="0" max="2" step="0.05" value="1">
</div>
<img id="flatPrev" alt="">
<div id="dollStage"><img id="dollPrev" alt="paper doll"></div>
<div id="how">
<b>How clothing works here (3 tiers)</b>
<ol>
<li><b>Rigid</b> — hats/bags/shoes: attach to a bone (picker below right). No deformation needed.</li>
<li><b>Deforming</b> — shirts/pants: <b>FIT</b> copies the body's skin weights onto the garment
(Blender, nearest-face) so one skeleton drives body + clothes. Garment becomes a wardrobe
item that fits every animation.</li>
<li><b>Swap</b> — socket torsos (character_kit_modular): a "shirt" that replaces the torso. Zero clipping.</li>
</ol>
<b>Generating NEW clothes ($0, MODELBEAST)</b>
<ol>
<li><b>flux</b> — product-shot of the garment, flat, white bg</li>
<li><b>cutout</b> — bg_remove</li>
<li>then EITHER <b>→ rigid 3D</b> (trellis, ~5 min — hats/shoes/bags only; thin cloth dies in
reconstruction) OR <b>→ hanging texture</b> (rack planes / paper-doll — flat clothes LOVE this)</li>
</ol>
<div class="note">Deforming clothes are authored/retopo'd against the base body then FIT — never raw-genned.
Bases stay in the library; only dressed outfits ship into games.</div>
</div>
</div>
<div class="col" id="right">
<div class="tabs">
<button class="on" data-tab="wardrobe">wardrobe</button>
<button data-tab="doll">doll 2D</button>
<button data-tab="generate">generate</button>
<button data-tab="out">outfits</button>
</div>
<div id="tab-doll" style="display:none">
<h2>paper doll — change clothes</h2>
<div class="note" id="dollCount"></div>
<div id="dollSlots"></div>
<div class="row">
<button id="dollRand" class="ghost">randomise</button>
<button id="dollStrip" class="ghost">strip</button>
<button id="dollExport">export → djsim</button>
</div>
<h2>generate a new layer</h2>
<div class="row"><label>slot</label>
<select id="dollGenSlot"><option>top</option><option>bottom</option><option>shoes</option><option>hat</option><option>bag</option></select></div>
<textarea id="dollGenPhrase" rows="2" placeholder="e.g. red and black checked flannelette shirt"></textarea>
<div class="row"><button id="dollGenBtn">generate layer</button></div>
<div class="note">flux on <b>solid green</b> → farm RMBG-2.0 → staged to 704×1408.
Green is deliberate: grey/checkerboard backgrounds eat garments in the key. No text — flux can't spell.</div>
</div>
<div id="tab-wardrobe">
<h2>garments</h2>
<input type="file" id="upGarm" accept=".glb,.gltf,.fbx,.obj,.png,.jpg" style="margin-bottom:6px">
<div id="garments"></div>
<h2>rigid attach (preview)</h2>
<div class="row"><label>bone</label><select id="boneSel"><option></option></select></div>
<div class="row"><label>scale</label><input id="atScale" type="range" min="0.05" max="2" step="0.01" value="0.25"></div>
<div class="row"><label>up/down</label><input id="atY" type="range" min="-0.5" max="0.5" step="0.005" value="0.08"></div>
<div class="row"><label>fwd/back</label><input id="atZ" type="range" min="-0.5" max="0.5" step="0.005" value="0"></div>
<div class="row"><button id="attachBtn" class="ghost">attach selected</button><button id="clearAtBtn" class="ghost">clear</button></div>
<div class="swatches" id="tintSw"></div>
<div class="note">swatch retints the last attached item (free colourways — no re-gen).</div>
<div class="row"><input id="fitName" placeholder="save dress-up as…"><button id="saveFitBtn" class="ghost">save</button></div>
<div id="savedFits" class="note"></div>
<h2>deforming fit (blender)</h2>
<div class="row"><label>inflate mm</label><input id="fitInf" value="3"></div>
<div class="row">
<button id="fitBtn">FIT → wardrobe item</button>
<button id="fitMergeBtn" class="ghost">fit → dressed glb</button>
</div>
<div class="note">FIT needs a <b>rigged</b> body (armature + weights). Unrigged base? Rig it first (MIRPAMO / Mixamo).</div>
<h2>assemble outfit</h2>
<div id="fitList" class="note">fitted items you tick in the garments list join the outfit…</div>
<div class="row"><input id="outfitName" placeholder="outfit name e.g. trackie-bloke"><button id="asmBtn">ASSEMBLE</button></div>
</div>
<div id="tab-generate" style="display:none">
<h2>new garment — step 1: image</h2>
<textarea id="genPrompt" rows="3" placeholder="e.g. brown corduroy jacket / bucket hat / white leather sneakers"></textarea>
<div class="row"><button id="genBtn">flux it</button><span class="note" id="genNote"></span></div>
<img id="genPrev">
<h2>step 2: cutout</h2>
<div class="row"><button id="rmbgBtn" class="ghost" disabled>bg_remove</button></div>
<h2>step 3: make it an item</h2>
<div class="row"><button id="to3dBtn" class="ghost" disabled>→ rigid 3D (hats/shoes/bags)</button></div>
<div class="row"><button id="hangBtn" class="ghost" disabled>→ hanging texture (racks/doll)</button></div>
<div class="note">rigid = trellis on the farm (~5 min). Thin/floppy cloth should go the texture route or be
modelled against the base and FIT instead — reconstruction eats thin geometry (traps ledger).</div>
<h2>generated</h2><div id="genLib"></div>
</div>
<div id="tab-out" style="display:none">
<h2>finished outfits</h2><div id="outs"></div>
<div class="note">these GLBs drop straight into thriftgod / djsim / procity — or publish to 3GOD.</div>
</div>
</div>
<div id="foot"><span id="job">ready.</span></div>
</div>
<script type="importmap">
{ "imports": {
"three": "https://unpkg.com/three@0.175.0/build/three.module.js",
"three/addons/": "https://unpkg.com/three@0.175.0/examples/jsm/"
}}
</script>
<script type="module">
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
const $ = id => document.getElementById(id);
let LIB = {}, BODY = null, GARM = null, bodyRoot = null, garmRoot = null, mixer = null, clock = new THREE.Clock();
let bones = [], attached = [], picked = new Set(); // picked = fitted garments ticked for assemble
let clips = [], action = null, playing = true, scrubbing = false; // clip bank + current action
// ---------- stage ----------
const stage = $('stage');
const renderer = new THREE.WebGLRenderer({ antialias: true });
stage.appendChild(renderer.domElement);
const scene = new THREE.Scene(); scene.background = new THREE.Color(0x191610);
const camera = new THREE.PerspectiveCamera(45, 1, 0.01, 100); camera.position.set(1.6, 1.5, 2.4);
const controls = new OrbitControls(camera, renderer.domElement); controls.target.set(0, 0.9, 0);
scene.add(new THREE.HemisphereLight(0xfff4e0, 0x33291a, 1.3));
const sun = new THREE.DirectionalLight(0xffffff, 2.2); sun.position.set(2, 4, 3); scene.add(sun);
const grid = new THREE.GridHelper(4, 16, 0x554a2a, 0x2a2517); scene.add(grid);
function resize() {
const w = stage.clientWidth, h = stage.clientHeight;
renderer.setSize(w, h); camera.aspect = w / h; camera.updateProjectionMatrix();
}
new ResizeObserver(resize).observe(stage);
(function tick() {
requestAnimationFrame(tick);
const dt = clock.getDelta();
if (mixer && playing && !scrubbing) mixer.update(dt * (+$('speed').value));
if (action && !scrubbing) { // keep the scrub head following playback
const d = action.getClip().duration || 1;
$('scrub').value = Math.round((action.time % d) / d * 1000);
$('clipTime').textContent = (action.time % d).toFixed(1) + 's / ' + d.toFixed(1) + 's';
}
controls.update(); renderer.render(scene, camera);
})();
const loader = new GLTFLoader();
const loadGlb = p => new Promise((res, rej) => loader.load('/glb?p=' + encodeURIComponent(p), res, undefined, rej));
function frameObject(root) {
const box = new THREE.Box3().setFromObject(root);
const c = box.getCenter(new THREE.Vector3()), s = box.getSize(new THREE.Vector3()).length() || 1;
controls.target.copy(c); camera.position.set(c.x + s * 0.7, c.y + s * 0.35, c.z + s * 0.9);
}
async function setBody(item) {
BODY = item; renderLists();
if (bodyRoot) scene.remove(bodyRoot);
attached.forEach(a => a.parent && a.parent.remove(a)); attached = []; mixer = null;
$('hud').textContent = 'loading ' + item.name + '…';
try {
const g = await loadGlb(item.path);
bodyRoot = g.scene; scene.add(bodyRoot);
bones = []; bodyRoot.traverse(o => { if (o.isBone) bones.push(o); });
let skinned = 0, tris = 0;
bodyRoot.traverse(o => { if (o.isSkinnedMesh) skinned++; if (o.isMesh && o.geometry.index) tris += o.geometry.index.count / 3; });
$('boneSel').innerHTML = '<option>—</option>' + bones.map(b => `<option>${b.name}</option>`).join('');
// preselect the fun ones
for (const want of ['Head', 'Spine2', 'Hips']) {
const b = bones.find(b => b.name.toLowerCase().includes(want.toLowerCase()));
if (b) { $('boneSel').value = b.name; break; }
}
// clip bank: the mixer used to blind-play animations[0] with no way to pick another.
clips = g.animations || []; action = null; mixer = null;
$('clipSel').innerHTML = clips.length
? clips.map((c, i) => `<option value="${i}">${c.name || 'clip ' + (i + 1)}</option>`).join('')
: '<option>— no clips —</option>';
$('transport').classList.toggle('on', clips.length > 0);
if (clips.length) { mixer = new THREE.AnimationMixer(bodyRoot); playClip(0); }
const box = new THREE.Box3().setFromObject(bodyRoot);
const h = (box.max.y - box.min.y).toFixed(2);
$('hud').innerHTML = `<b>${item.name}</b> · ${Math.round(tris).toLocaleString()} tris · ${h}m tall · ` +
(bones.length ? `RIGGED (${bones.length} bones${g.animations.length ? ', ' + g.animations.length + ' clip' : ''})` : '<span style="color:#e88">unrigged — rigid attach & scale only, FIT needs a rig</span>');
frameObject(bodyRoot);
} catch (e) { $('hud').textContent = 'load failed: ' + (e.message || e); }
}
// ---------- clip transport ----------
function playClip(i) {
if (!mixer || !clips[i]) return;
if (action) action.fadeOut(0.2);
action = mixer.clipAction(clips[i]);
action.reset().fadeIn(0.2).play();
playing = true; $('playBtn').textContent = '❚❚';
$('clipSel').value = String(i);
}
$('clipSel').onchange = e => playClip(+e.target.value);
$('playBtn').onclick = () => { playing = !playing; $('playBtn').textContent = playing ? '❚❚' : '▶'; };
$('scrub').oninput = e => { // scrub = drive the mixer by hand
if (!action) return;
scrubbing = true;
const d = action.getClip().duration || 1;
action.time = (+e.target.value / 1000) * d;
mixer.update(0);
$('clipTime').textContent = action.time.toFixed(1) + 's / ' + d.toFixed(1) + 's';
};
$('scrub').onchange = () => { scrubbing = false; };
async function previewGarment(item) {
GARM = item; renderLists();
if (garmRoot) { scene.remove(garmRoot); garmRoot = null; }
if (!item.path.match(/\.(glb|gltf|fbx|obj)$/i)) { // flat cut-out: show the 2D tier
$('flatPrev').src = '/file?p=' + encodeURIComponent(item.path);
$('flatPrev').style.display = 'block';
return;
}
$('flatPrev').style.display = 'none';
try {
const g = await loadGlb(item.path);
garmRoot = g.scene; scene.add(garmRoot);
garmRoot.position.x = 1.2; // beside the body until fitted/attached
} catch (e) { $('hud').textContent = 'garment load failed: ' + (e.message || e); }
}
// rigid attach preview: clone garment scene onto the chosen bone
$('attachBtn').onclick = () => {
if (!garmRoot || !bones.length) return toast('need a rigged body + a 3D garment selected');
const bone = bones.find(b => b.name === $('boneSel').value);
if (!bone) return toast('pick a bone');
const inst = garmRoot.clone(true);
const s = +$('atScale').value;
inst.scale.setScalar(s); inst.position.set(0, +$('atY').value, +$('atZ').value);
// clone materials so a tint on this item doesn't bleed into every other instance
inst.traverse(o => { if (o.isMesh && o.material) o.material = o.material.clone(); });
inst.userData.wg = { path: GARM.path, name: GARM.name, bone: bone.name,
scale: s, y: +$('atY').value, z: +$('atZ').value };
bone.add(inst); attached.push(inst);
toast(`attached ${GARM.name}${bone.name} · save the dress-up to keep it`);
};
$('clearAtBtn').onclick = () => { attached.forEach(a => a.parent && a.parent.remove(a)); attached = []; };
// ---------- colourways: retint the last attached item (free variants, no re-gen) ----------
const SWATCHES = ['#ffffff', '#c8452e', '#2f5d8a', '#3f7a4a', '#d8a33a', '#6c4a86', '#2b2b2b', '#b8895f'];
$('tintSw').innerHTML = SWATCHES.map(c => `<button class="sw" data-c="${c}" style="background:${c}"></button>`).join('');
$('tintSw').onclick = e => {
const b = e.target.closest('.sw'); if (!b) return;
const tgt = attached[attached.length - 1];
if (!tgt) return toast('attach something first, then pick a colour');
document.querySelectorAll('.sw').forEach(s => s.classList.toggle('on', s === b));
tgt.traverse(o => { if (o.isMesh && o.material && o.material.color) o.material.color.set(b.dataset.c); });
tgt.userData.wg.tint = b.dataset.c;
toast('tinted ' + (tgt.userData.wg.name || '') + ' → ' + b.dataset.c);
};
// ---------- save / load a dress-up ----------
$('saveFitBtn').onclick = async () => {
if (!BODY) return toast('pick a body first');
const attach = {};
attached.forEach((a, i) => { const w = a.userData.wg || {}; attach[(w.name || 'item') + '#' + i] = w; });
const r = await fetch('/api/outfit', { method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: $('fitName').value || 'dressup', body: BODY.path,
anim: clips[+$('clipSel').value]?.name, attach }) }).then(r => r.json());
toast(r.error ? '✗ ' + r.error : `✓ saved dress-up "${r.name}" (${Object.keys(attach).length} item/s)`);
refresh();
};
async function loadFit(name) {
const f = await fetch('/api/outfit/' + encodeURIComponent(name)).then(r => r.json());
if (f.error) return toast('✗ ' + f.error);
const bodyItem = (LIB.bodies || []).find(b => b.path === f.body) || { name: f.body.split('/').pop(), path: f.body };
await setBody(bodyItem);
for (const w of Object.values(f.attach || {})) { // re-hang every saved attachment
const bone = bones.find(b => b.name === w.bone); if (!bone) continue;
try {
const g = await loadGlb(w.path);
const inst = g.scene;
inst.scale.setScalar(w.scale || 1); inst.position.set(0, w.y || 0, w.z || 0);
inst.traverse(o => { if (o.isMesh && o.material) { o.material = o.material.clone();
if (w.tint && o.material.color) o.material.color.set(w.tint); } });
inst.userData.wg = w; bone.add(inst); attached.push(inst);
} catch (e) { /* missing attachment file — skip it, the rest of the fit still loads */ }
}
if (f.anim) { const i = clips.findIndex(c => c.name === f.anim); if (i >= 0) playClip(i); }
toast(`✓ loaded "${name}" — ${attached.length} item/s`);
}
// ---------- library ----------
async function refresh() {
const r = await fetch('/api/lib').then(r => r.json());
LIB = r.lib;
LIB.doll = r.doll || {}; // doll catalogue rides alongside lib, not inside it
$('farm').textContent = (r.blender ? 'blender ✓' : 'blender ✗') +
' · flux ' + (r.cf ? 'cf ✓' : r.mb ? 'farm ✓' : '✗') + ' · farm3D ' + (r.mb ? '✓' : 'token missing');
renderLists();
}
function rowHtml(it, kind) {
const on = (kind === 'bodies' && BODY && BODY.path === it.path) || (kind !== 'bodies' && GARM && GARM.path === it.path);
const tick = kind === 'garments' && it.name.endsWith('-fitted.glb')
? `<input type="checkbox" data-pick="${it.path}" ${picked.has(it.path) ? 'checked' : ''} onclick="event.stopPropagation()">` : '';
return `<div class="item ${on ? 'on' : ''}" data-k="${kind}" data-p="${it.path}">
${tick}<span class="nm">${it.name}</span><span class="meta">${it.kb}kb · ${it.home}</span></div>`;
}
function renderLists() {
$('bodies').innerHTML = (LIB.bodies || []).map(i => rowHtml(i, 'bodies')).join('') || '<div class="note">drop a GLB/FBX above</div>';
$('garments').innerHTML = (LIB.garments || []).map(i => rowHtml(i, 'garments')).join('') || '<div class="note">none yet — generate one →</div>';
$('genLib').innerHTML = (LIB.gen || []).map(i => rowHtml(i, 'gen')).join('') || '<div class="note">nothing generated yet</div>';
$('outs').innerHTML = (LIB.out || []).map(i => rowHtml(i, 'out')).join('') || '<div class="note">no outfits assembled yet</div>';
// NB: deliberately NOT class="item" — the generic .item handler below would clobber this one
$('savedFits').innerHTML = (LIB.outfits || []).length
? (LIB.outfits || []).map(n => `<button class="ghost fitchip" data-fit="${n}">${n}</button>`).join('')
: 'no saved dress-ups yet';
document.querySelectorAll('.item').forEach(el => el.onclick = () => {
const it = { name: el.querySelector('.nm').textContent, path: el.dataset.p };
if (el.dataset.k === 'bodies') setBody(it);
else if (el.dataset.k === 'gen') { GENPICK = it.path; $('genPrev').src = '/file?p=' + encodeURIComponent(it.path); $('genPrev').style.display = 'block'; $('rmbgBtn').disabled = false; $('to3dBtn').disabled = $('hangBtn').disabled = !it.path.endsWith('-cut.png'); }
else previewGarment(it);
});
document.querySelectorAll('.fitchip').forEach(el => el.onclick = () => loadFit(el.dataset.fit));
document.querySelectorAll('[data-pick]').forEach(cb => cb.onchange = () => {
cb.checked ? picked.add(cb.dataset.pick) : picked.delete(cb.dataset.pick);
$('fitList').textContent = picked.size ? [...picked].map(p => p.split('/').pop()).join(' + ') : 'tick fitted items in the garments list…';
});
}
// uploads
function wireUpload(inputId, to) {
$(inputId).onchange = async e => {
const f = e.target.files[0]; if (!f) return;
await fetch(`/api/upload?to=${to}&name=${encodeURIComponent(f.name)}`, { method: 'POST', body: await f.arrayBuffer() });
toast('uploaded ' + f.name); refresh();
};
}
wireUpload('upBody', 'bodies'); wireUpload('upGarm', 'garments');
// ---------- jobs ----------
function toast(t) { $('job').textContent = t; }
async function runJob(url, payload, label) {
toast(label + '…');
const r = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }).then(r => r.json());
if (r.error) { toast('✗ ' + r.error); return null; }
while (true) {
await new Promise(res => setTimeout(res, 1500));
const j = await fetch('/api/job/' + r.job).then(r => r.json());
toast(`${label}: ${j.status}${j.note ? ' · ' + j.note : ''}`);
if (j.status === 'done') { toast(`${label}${(j.out || '').split('/').pop()}`); refresh(); return j.out; }
if (j.status === 'error') { toast('✗ ' + label + ': ' + (j.log || '').split('\n').pop()); return null; }
}
}
$('scaleBtn').onclick = () => BODY && runJob('/api/blender', { op: 'scale', args: { path: BODY.path, height: +$('scaleH').value } }, 'scale');
$('decBtn').onclick = () => BODY && runJob('/api/blender', { op: 'decimate', args: { path: BODY.path, tris: +$('decT').value } }, 'decimate');
$('fitBtn').onclick = () => (BODY && GARM) ? runJob('/api/blender', { op: 'fit', args: { body: BODY.path, garment: GARM.path, mode: 'garment', inflate: +$('fitInf').value } }, 'fit') : toast('pick a body AND a garment');
$('fitMergeBtn').onclick = () => (BODY && GARM) ? runJob('/api/blender', { op: 'fit', args: { body: BODY.path, garment: GARM.path, mode: 'merge', inflate: +$('fitInf').value } }, 'fit+merge') : toast('pick a body AND a garment');
$('asmBtn').onclick = () => (BODY && picked.size) ? runJob('/api/blender', { op: 'assemble', args: { body: BODY.path, garments: [...picked], name: $('outfitName').value } }, 'assemble') : toast('pick a body + tick fitted garments');
// generator chain
let GENPICK = null;
$('genBtn').onclick = async () => {
const p = $('genPrompt').value.trim(); if (!p) return;
const out = await runJob('/api/gen', { prompt: p }, 'flux');
if (out) { GENPICK = out; $('genPrev').src = '/file?p=' + encodeURIComponent(out) + '&t=' + Date.now(); $('genPrev').style.display = 'block'; $('rmbgBtn').disabled = false; }
};
$('rmbgBtn').onclick = async () => {
if (!GENPICK) return;
const out = await runJob('/api/rmbg', { path: GENPICK }, 'cutout');
if (out) { GENPICK = out; $('genPrev').src = '/file?p=' + encodeURIComponent(out) + '&t=' + Date.now(); $('to3dBtn').disabled = $('hangBtn').disabled = false; }
};
$('to3dBtn').onclick = () => GENPICK && runJob('/api/to3d', { path: GENPICK }, 'trellis');
$('hangBtn').onclick = async () => {
if (!GENPICK) return;
const r = await fetch('/api/hangable', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ path: GENPICK }) }).then(r => r.json());
toast(r.note || r.error); refresh();
};
// ---------- 2D paper-doll tier ----------
// The flat cut-outs are a first-class product, not a fallback: djsim already ships 441 of
// them and its loader self-heals on any dropped PNG, so a layer generated here wears itself
// in the game with no code change. Composition happens server-side (PIL) so the DOM preview
// and the exported PNG are guaranteed to be the same image.
const DOLL_SLOTS = ['hat', 'top', 'bottom', 'shoes', 'bag'];
let DOLLPICK = {};
function renderDoll() {
const cat = LIB.doll || {};
const total = DOLL_SLOTS.reduce((n, s) => n + (cat[s] || []).length, 0);
$('dollCount').textContent = total ? `${total} layers · ${DOLL_SLOTS.map(s => `${(cat[s] || []).length} ${s}`).join(' · ')}` : 'no doll layers yet';
$('dollSlots').innerHTML = DOLL_SLOTS.map(s => {
const items = cat[s] || [];
return `<div class="slotrow"><label>${s}</label><select data-slot="${s}" ${items.length ? '' : 'disabled'}>
<option value="">— none —</option>
${items.map(i => `<option value="${i.stem}" ${DOLLPICK[s] === i.stem ? 'selected' : ''}>${i.key}</option>`).join('')}
</select></div>`;
}).join('');
document.querySelectorAll('#dollSlots select').forEach(sel => sel.onchange = () => {
const v = sel.value; if (v) DOLLPICK[sel.dataset.slot] = v; else delete DOLLPICK[sel.dataset.slot];
composeDoll();
});
}
async function composeDoll() {
const stems = Object.values(DOLLPICK);
const r = await fetch('/api/doll/compose', { method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ stems, name: 'preview' }) }).then(r => r.json());
if (r.error) return toast('✗ ' + r.error);
$('dollPrev').src = '/file?p=' + encodeURIComponent(r.path) + '&t=' + Date.now();
toast(`doll: ${stems.length} layer/s`);
}
$('dollRand').onclick = () => {
const cat = LIB.doll || {};
DOLLPICK = {};
for (const s of ['top', 'bottom', 'shoes', 'hat']) {
const items = cat[s] || [];
if (items.length) DOLLPICK[s] = items[Math.floor(Math.random() * items.length)].stem;
}
renderDoll(); composeDoll();
};
$('dollStrip').onclick = () => { DOLLPICK = {}; renderDoll(); composeDoll(); };
$('dollGenBtn').onclick = async () => {
const phrase = $('dollGenPhrase').value.trim(); if (!phrase) return toast('describe the garment');
const slot = $('dollGenSlot').value;
const out = await runJob('/api/doll/gen', { slot, phrase, key: phrase }, `doll ${slot}`);
if (out) { await refresh(); renderDoll(); }
};
$('dollExport').onclick = async () => {
const stems = Object.values(DOLLPICK);
if (!stems.length) return toast('dress the doll first');
const r = await fetch('/api/doll/export', { method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ stems, dry: true }) }).then(r => r.json());
if (r.error) return toast('✗ ' + r.error);
toast(r.note || 'exported');
};
// tabs + how
document.querySelectorAll('.tabs button').forEach(b => b.onclick = () => {
document.querySelectorAll('.tabs button').forEach(x => x.classList.remove('on')); b.classList.add('on');
for (const t of ['wardrobe', 'doll', 'generate', 'out']) $('tab-' + t).style.display = b.dataset.tab === t ? 'block' : 'none';
const onDoll = b.dataset.tab === 'doll';
$('dollStage').classList.toggle('on', onDoll);
$('transport').classList.toggle('on', !onDoll && clips.length > 0);
if (onDoll) { renderDoll(); if (!$('dollPrev').src) composeDoll(); }
});
$('howBtn').onclick = () => $('how').style.display = $('how').style.display === 'block' ? 'none' : 'block';
refresh(); resize();
</script>