wardrobegod/web/index.html
type-two cfca6708ab Phase 2: reskin engine — dress the nude TRELLIS bodies without fitting anything
Every anatomy body is an independent TRELLIS reconstruction: no shared topology, no vertex
correspondence, its own UV atlas. That kills garment portability — a fitted mesh can't move
between bodies. Reskin sidesteps the whole problem by repainting the atlas the body already
has, so it works on unstructured meshes and needs no rig.

Chain (/api/reskin), all local except one farm call:
  render_plates.py  -> bind-pose front/back ortho plates, EEVEE, 1024, transparent
  mflux_image_edit  -> "put <garment> from image 2 onto the person in image 1", per plate
  align_plate.py    -> re-fit the edited figure to the original bbox. The edit model redraws
                       at its own scale/offset, and the projection bake assumes the original
                       framing, so skipping this makes the body sample background.
  bake_skin.py      -> Cycles DIFFUSE projection onto the mesh's real UVs, front/back split
                       on normal.Y
Costume swapping in the browser is just a material.map change, so pre-baked outfits toggle
instantly with no re-bake; the bare map is retained so it's reversible.

Fixed a real defect in the inherited baker: planar front/back projection has no idea a head
is round, so side-facing skull polygons sampled whatever front-plate pixel sat at their x/z
and painted a ghost second face down the side of the head (clearly visible on phrtt2). Since
this tier repaints CLOTHES, the bake now keeps the body's own texture above a neck-height
cutoff (default 0.87 of body height, -1 to disable), sampling it through the mesh's real UVs.

Verified end-to-end on phrtt2 (1 mesh / 1 UVMap / 1 material / 4,364 tris — the reskin class):
farm try-on produced a convincing corduroy jacket holding the exact T-pose, and the bake put
it on the model. Known ceiling, unchanged and by design: the silhouette does not move, so this
sells tight and printed garments and reads wrong for a heavy coat.

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

565 lines
32 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>reskin — paint it on</h2>
<div class="row"><button id="reskinBtn">RESKIN body + garment</button></div>
<div class="row"><label>costume</label><select id="costumeSel"><option value="">— bare —</option></select></div>
<div class="note">Bakes the garment onto the body's own UV atlas, so it works on unstructured
TRELLIS meshes with no fitting. Silhouette doesn't change — great for tight/printed garments,
wrong for a big coat. Swapping the dropdown is instant (no re-bake).</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));
renderCostumes();
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();
};
// ---------- reskin: swap the body's own texture ----------
// The bake is the expensive part and it already happened; changing costume is just a map swap,
// so pre-baked outfits toggle instantly. Keeps the original map so "bare" is reversible.
const texLoader = new THREE.TextureLoader();
$('reskinBtn').onclick = () => (BODY && GARM)
? runJob('/api/reskin', { body: BODY.path, garment: GARM.path, desc: 'the ' + GARM.name.replace(/[-_]/g, ' ').replace(/\.\w+$/, '') }, 'reskin')
: toast('pick a body AND a garment first');
function renderCostumes() {
const mine = (LIB.garments || []).filter(g => g.name.endsWith('.reskin.png'));
$('costumeSel').innerHTML = '<option value="">— bare —</option>' +
mine.map(g => `<option value="${g.path}">${g.name.replace('.reskin.png', '')}</option>`).join('');
}
$('costumeSel').onchange = e => {
if (!bodyRoot) return toast('load a body first');
const p = e.target.value;
bodyRoot.traverse(o => {
if (!o.isMesh || !o.material) return;
if (o.userData.bareMap === undefined) o.userData.bareMap = o.material.map || null;
if (!p) { o.material.map = o.userData.bareMap; o.material.needsUpdate = true; return; }
texLoader.load('/file?p=' + encodeURIComponent(p) + '&t=' + Date.now(), t => {
t.flipY = false; t.colorSpace = THREE.SRGBColorSpace; // glTF UV convention
o.material.map = t; o.material.needsUpdate = true;
});
});
toast(p ? 'wearing ' + p.split('/').pop().replace('.reskin.png', '') : 'bare');
};
// ---------- 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>