wardrobegod/web/index.html
type-two 2c03d6f3fd content tiers: add -swim between safe and nsfw
Swimwear/underwear is clothed (so not nsfw) but still wrong for a shopfront
NPC or a kids scene. Filename suffix like -nsfw, mutually exclusive with it,
surfaced as rating: safe|swim|nsfw on every library row. Tag button now
cycles safe -> swim -> nsfw -> safe.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-02 19:00:39 +10:00

879 lines
50 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 }
.swim { background:#1d5566; color:#cdf0ff; border-radius:4px; padding:0 4px; margin-right:5px;
font-size:10px; letter-spacing:.5px; }
.nsfw { background:#7a2233; color:#ffd9e0; border-radius:4px; padding:0 4px; margin-right:5px;
font-size:10px; letter-spacing:.5px; font-weight:700 }
/* dressing-room grid — a wardrobe you browse by looking, not by reading filenames */
.grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(76px,1fr)); gap:6px; margin:6px 0 }
.card { position:relative; border:1px solid var(--line); border-radius:8px; background:var(--panel);
cursor:pointer; overflow:hidden; aspect-ratio:1 }
.card:hover { border-color:var(--gold) } .card.on { border-color:var(--gold); background:#26210f }
.card img { width:100%; height:100%; object-fit:contain; display:block; background:#0d0b07 }
.card .cap { position:absolute; left:0; right:0; bottom:0; background:#000000c9; color:var(--ink);
font-size:9px; padding:2px 3px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis }
.card .slot { position:absolute; top:2px; left:2px; background:#0009; color:var(--gold);
font-size:8px; padding:0 3px; border-radius:3px; letter-spacing:.5px }
.card .warn { position:absolute; top:2px; right:2px; background:#7a2233; color:#ffd9e0;
font-size:8px; padding:0 3px; border-radius:3px }
.slottabs { display:flex; flex-wrap:wrap; gap:3px; margin:4px 0 }
.slottabs button { background:none; border:1px solid var(--line); color:var(--dim);
font-size:10px; padding:3px 6px; font-weight:400 }
.slottabs button.on { border-color:var(--gold); color:var(--gold) }
/* 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 class="row" style="margin:0 0 6px">
<label style="width:auto"><input type="checkbox" id="showNsfw" style="width:auto"> show bases</label>
<button id="tagBtn" class="ghost" style="margin-left:auto">tag / untag</button>
</div>
<div id="bodies"></div>
<h2>bench — prep a body</h2>
<div class="row"><button id="unitBtn" class="ghost">1 · fix units</button>
<button id="rigBtn" class="ghost">2 · rig (MIRPAMO)</button></div>
<div class="note">fix units only corrects impossible heights (&lt;0.5m or &gt;3m) — real
small/medium/large proportions are kept. MIRPAMO rigs to 22 bones, <b>no fingers</b>: fine for
locomotion, but a 65-bone donor garment will leave cuffs under-driven.</div>
<div class="row"><label>body type</label>
<select id="bodyType"><option value=""></option></select>
<button id="bodyTypeSave" class="ghost">set</button></div>
<div class="note" id="bodyTypeNote">export uses the type's height, so small/large/obese keep their
real proportions instead of all being forced to one number.</div>
<div class="row"><label>height m</label><input id="scaleH" value="1.72"><button id="scaleBtn" class="ghost">scale</button></div>
<h2>LOD</h2>
<div class="row"><label>target tris</label><input id="decT" value="18000"><button id="decBtn" class="ghost">decimate</button></div>
<div class="row" id="lodPresets"></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>
<button data-tab="kit">socket kit</button>
</div>
<div id="tab-kit" style="display:none">
<h2>socket kit &mdash; build a character from parts</h2>
<div class="note">torso picks the rig; other parts auto-fit cross-rig. genitals slot is same-rig only.</div>
<div id="kitSlots"></div>
<div class="row"><button id="kitAssemble">assemble</button>
<span class="note" id="kitStatus"></span></div>
</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 class="slottabs" id="slotTabs"></div>
<input id="garmSearch" placeholder="search name or tag…" style="margin-bottom:4px">
<div id="garments" class="grid"></div>
<div class="row"><label>slot</label>
<select id="metaSlot"><option value="">— unset —</option></select></div>
<div class="row"><label>tags</label><input id="metaTags" placeholder="knit, 90s, oversized"></div>
<div class="row"><button id="metaSave" class="ghost">save details</button>
<button id="warmBtn" class="ghost">warm thumbs</button>
<span class="note" id="metaNote"></span></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="2" placeholder="e.g. brown corduroy jacket / bucket hat / white leather sneakers"></textarea>
<div class="row"><label>template</label>
<select id="genTpl"><option value="flat3d">flat3d — product shot (→3D)</option>
<option value="doll2d">doll2d — painterly (→doll)</option>
<option value="doll2d_green">doll2d_green — plaid/busy</option></select></div>
<div class="row"><label>backend</label>
<select id="genBackend"><option value="klein">klein-4b — sharper fabric</option>
<option value="sd">juggernautXL — product/material</option>
<option value="cf">cf schnell — 2s, softer</option></select></div>
<div class="row"><label>variant</label><input id="genVariant" value="0" style="width:56px">
<button id="genReroll" class="ghost">↻ reroll</button></div>
<div class="row"><button id="genBtn">flux it</button><span class="note" id="genNote"></span></div>
<div class="note" id="genMeta"></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>
<h2>export</h2>
<div class="row"><label>target</label><select id="expTarget"></select></div>
<div class="row"><button id="expBtn">export selected</button>
<label style="width:auto"><input type="checkbox" id="expOver" style="width:auto"> overwrite</label></div>
<div class="note" id="expNote">select a garment or outfit, then a target.</div>
<div class="note">Verified against each consumer's real loader: only the <b>djsim doll</b> and the
<b>3GOD depot</b> ingest by file drop. The others load from hardcoded arrays or a manifest, so a
dropped file is a no-op until that registry is edited — the hub tells you which line.</div>
</div>
</div>
<div id="foot"><span id="job">ready.</span></div>
</div>
<!-- three.js is VENDORED (r175), not CDN. The bench has to work offline, and anything built
against a CDN import can't be lifted into a game build. Folded in from NPCFACTORY, which
already had this right; OrbitControls was the one addon it lacked (it used PointerLock). -->
<script type="importmap">
{ "imports": {
"three": "./vendor/three.module.js",
"three/addons/": "./vendor/addons/"
}}
</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
let SHOW_NSFW = false; // anatomical bases hidden by default
// ---------- 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()">` : '';
// the -nsfw suffix is the fleet-wide convention (see ~/Documents/FBX); surfacing it here is
// what makes "bases stay in the library" visible rather than a line in a README
const badge = it.nsfw ? '<span class="nsfw" title="anatomical base — blocked from game export">NSFW</span>'
: it.swim ? '<span class="swim" title="swimwear/underwear — clothed, but not for shopfront or kids scenes">SWIM</span>' : '';
return `<div class="item ${on ? 'on' : ''}" data-k="${kind}" data-p="${it.path}">
${tick}<span class="nm">${it.name.replace(/-(nsfw|swim)/i, '')}</span>
<span class="meta">${badge}${it.kb}kb · ${it.home}</span></div>`;
}
function visible(rows) {
return (rows || []).filter(r => SHOW_NSFW || !r.nsfw);
}
// ---------- dressing-room grid ----------
const SLOTS = ['head', 'torso', 'outer', 'legs', 'feet', 'hand', 'accessory', 'bag'];
let SLOTFILTER = '', GARMQ = '', TIER = 'all';
// One wardrobe, two tiers. The 392 doll layers used to live in a separate catalogue that search
// couldn't reach, so "find the coogi jumper" only ever looked at the 3D garments.
function allGarments() {
const three = (LIB.garments || []).map(g => ({ ...g, tier: g.tier || '3d' }));
const two = Object.values(LIB.doll || {}).flat();
return TIER === '3d' ? three : TIER === '2d' ? two : three.concat(two);
}
function renderGarmentGrid() {
const all = visible(allGarments());
const counts = {};
all.forEach(g => { if (g.slot) counts[g.slot] = (counts[g.slot] || 0) + 1; });
const unset = all.filter(g => !g.slot).length;
$('slotTabs').innerHTML =
['all', '3d', '2d'].map(t =>
`<button data-tier="${t}" class="${TIER === t ? 'on' : ''}" style="border-color:#5a4a20">${t}</button>`).join('') +
'<span style="width:8px"></span>' +
`<button data-slot="" class="${SLOTFILTER === '' ? 'on' : ''}">all ${all.length}</button>` +
SLOTS.filter(s => counts[s]).map(s =>
`<button data-slot="${s}" class="${SLOTFILTER === s ? 'on' : ''}">${s} ${counts[s]}</button>`).join('') +
(unset ? `<button data-slot="?" class="${SLOTFILTER === '?' ? 'on' : ''}">unset ${unset}</button>` : '');
document.querySelectorAll('#slotTabs button[data-slot]').forEach(b => b.onclick = () => {
SLOTFILTER = b.dataset.slot; renderGarmentGrid();
});
document.querySelectorAll('#slotTabs button[data-tier]').forEach(b => b.onclick = () => {
TIER = b.dataset.tier; renderGarmentGrid();
});
const q = GARMQ.trim().toLowerCase();
const rows = all.filter(g => {
if (SLOTFILTER === '?' && g.slot) return false;
if (SLOTFILTER && SLOTFILTER !== '?' && g.slot !== SLOTFILTER) return false;
if (!q) return true;
return (g.name + ' ' + (g.tags || []).join(' ') + ' ' + (g.title || '')).toLowerCase().includes(q);
});
$('garments').innerHTML = rows.map(g => {
const on = GARM && GARM.path === g.path;
// '?' marks a slot we guessed from the filename rather than one that was actually set
const slot = g.slot ? `<span class="slot">${g.slot}${g.slot_inferred ? '?' : ''}</span>` : '';
return `<div class="card ${on ? 'on' : ''}" data-p="${g.path}" title="${g.name}">
<img loading="lazy" src="thumb?p=${encodeURIComponent(g.path)}" alt="">
${slot}${g.nsfw ? '<span class="warn">NSFW</span>' : g.swim ? '<span class="swim">SWIM</span>' : ''}
<span class="cap">${(g.title || g.name).replace(/\.(glb|gltf|fbx|obj|png|jpg)$/i, '').replace(/-(nsfw|swim)/i, '')}</span>
</div>`;
}).join('') || '<div class="note">nothing matches — clear the filter or generate one →</div>';
document.querySelectorAll('#garments .card').forEach(el => el.onclick = () => {
const g = allGarments().find(x => x.path === el.dataset.p);
if (!g) return;
if (g.tier === '2d') { // a doll layer wears itself on the doll, not the 3D stage
DOLLPICK[CANON_TO_DOLL[g.slot] || g.slot] = g.stem;
toast('doll: wearing ' + (g.title || g.key));
composeDoll(); showMeta(g);
} else { previewGarment({ name: g.name, path: g.path }); showMeta(g); }
});
}
// ---------- socket kit ----------
let KIT = null;
async function loadKit() {
try { KIT = await fetch('api/kit').then(r => r.json()); } catch (e) { return; }
const box = $('kitSlots');
if (!box || !KIT.slots) return;
box.innerHTML = KIT.slots.map(sl => {
const opts = KIT.parts.filter(p => p.slot === sl)
.map(p => '<option value="' + p.id + '">' + p.id + ' (' + p.source_rig + ')</option>').join('');
const none = sl === 'torso' ? '' : '<option value="">&mdash; none &mdash;</option>';
return '<div class="row"><label>' + sl + '</label><select data-kitslot="' + sl + '">' + none + opts + '</select></div>';
}).join('');
}
async function kitAssemble() {
const parts = {};
document.querySelectorAll('[data-kitslot]').forEach(sel => {
if (sel.value) parts[sel.dataset.kitslot] = sel.value;
});
$('kitStatus').textContent = 'assembling\u2026';
const r = await fetch('api/kit/assemble', {method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({parts})}).then(r => r.json());
if (r.error) { $('kitStatus').textContent = r.error; return; }
const t = setInterval(async () => {
const j = await fetch('api/job/' + r.job).then(r => r.json());
$('kitStatus').textContent = j.status + (j.note ? ' \u00b7 ' + j.note : '');
if (j.status === 'done') {
clearInterval(t);
await refresh();
if (j.out) setBody({name: j.out.split('/').pop(), path: j.out, kb: 0,
home: 'out', nsfw: false, kind: 'model'});
}
if (j.status === 'error') {
clearInterval(t);
$('kitStatus').textContent = 'error: ' + (j.log || '').slice(-160);
}
}, 3000);
}
const _kitBtnHook = () => { const b = $('kitAssemble'); if (b) b.onclick = kitAssemble; };
if (document.readyState !== 'loading') _kitBtnHook();
else document.addEventListener('DOMContentLoaded', _kitBtnHook);
loadKit();
function showMeta(g) {
$('metaSlot').innerHTML = '<option value="">— unset —</option>' +
SLOTS.map(s => `<option value="${s}" ${g.slot === s ? 'selected' : ''}>${s}</option>`).join('');
$('metaTags').value = (g.tags || []).join(', ');
$('metaNote').textContent = g.slot_inferred ? 'slot guessed from the filename — confirm it' : '';
}
$('garmSearch').oninput = e => { GARMQ = e.target.value; renderGarmentGrid(); };
// export hub — targets and their readiness come from the server, which learned them by reading
// each consumer's actual loader
const EXPORT_TARGETS = [
['3god', '3GOD depot (thriftgod + procity read it)', true],
['djsim-doll', '90sDJsim paper doll', true],
['djsim-ped', '90sDJsim ped — needs registry', false],
['procity-ped', 'PROCITY ped — needs registry', false],
['thriftgod-prop', 'thriftgod prop — needs registry', false],
['not-tonight', 'not-tonight — no garment path', false],
];
$('expTarget').innerHTML = EXPORT_TARGETS.map(([v, l, ok]) =>
`<option value="${v}">${ok ? '✓' : '⚠'} ${l}</option>`).join('');
$('expBtn').onclick = async () => {
const it = GARM || BODY;
if (!it) return toast('select a garment or body first');
const target = $('expTarget').value;
if (target === 'djsim-doll') return $('dollExport').click();
const r = await fetch('api/export', { method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ target, path: it.path, overwrite: $('expOver').checked }) }).then(r => r.json());
if (r.ready === false) {
$('expNote').innerHTML = `<b>not a file drop.</b> ${r.why}<br>edit: <code>${r.registry || '?'}</code>`;
return toast('⚠ ' + target + ' needs a registry edit');
}
if (r.error) { $('expNote').textContent = r.error; return toast('✗ ' + r.error); }
$('expNote').textContent = `publishing as ${r.name}${r.depot}`;
const out = await pollJob(r.job, 'export');
if (out) $('expNote').textContent = 'published: ' + out;
};
async function pollJob(job, label) {
while (true) {
await new Promise(r => setTimeout(r, 1500));
const j = await fetch('api/job/' + job).then(r => r.json());
toast(`${label}: ${j.status}${j.note ? ' · ' + j.note : ''}`);
if (j.status === 'done') { refresh(); return j.out; }
if (j.status === 'error') { toast('✗ ' + (j.log || '').split('\n').pop()); return null; }
}
}
$('warmBtn').onclick = async () => {
const out = await runJob('api/thumbs/warm', {}, 'thumbs');
if (out !== null) renderGarmentGrid();
};
$('metaSave').onclick = async () => {
if (!GARM) return toast('pick a garment first');
const r = await fetch('api/meta', { method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: GARM.path, slot: $('metaSlot').value,
tags: $('metaTags').value.split(',').map(s => s.trim()).filter(Boolean) })
}).then(r => r.json());
toast(r.error ? '✗ ' + r.error : '✓ details saved');
refresh();
};
function renderLists() {
const nb = (LIB.bodies || []).filter(r => r.nsfw).length;
$('bodies').innerHTML = visible(LIB.bodies).map(i => rowHtml(i, 'bodies')).join('') || '<div class="note">drop a GLB/FBX above</div>';
if (nb && !SHOW_NSFW) $('bodies').insertAdjacentHTML('beforeend', `<div class="note">${nb} anatomical base/s hidden</div>`);
renderGarmentGrid();
$('genLib').innerHTML = visible(LIB.gen).map(i => rowHtml(i, 'gen')).join('') || '<div class="note">nothing generated yet</div>';
$('outs').innerHTML = visible(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');
$('showNsfw').onchange = e => { SHOW_NSFW = e.target.checked; renderLists(); };
// body types carry their own target height — see BODY_TYPES in server.py
const BODY_TYPES = { small: 1.58, medium: 1.72, large: 1.88, tall: 1.95, child: 1.30, obese: 1.74, stocky: 1.68 };
$('bodyType').innerHTML = '<option value="">—</option>' +
Object.entries(BODY_TYPES).map(([t, h]) => `<option value="${t}">${t} · ${h}m</option>`).join('');
$('bodyTypeSave').onclick = async () => {
if (!BODY) return toast('pick a body first');
const t = $('bodyType').value; if (!t) return toast('choose a type');
const r = await fetch('api/meta', { method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: BODY.path, body_type: t }) }).then(r => r.json());
toast(r.error ? '✗ ' + r.error : `${BODY.name} is ${t} (${BODY_TYPES[t]}m on export)`);
refresh();
};
$('tagBtn').onclick = async () => {
const it = BODY || GARM;
if (!it) return toast('select a body or garment first');
// three tiers now, so the button cycles safe -> swim -> nsfw -> safe rather
// than toggling one flag
const cur = /-nsfw/i.test(it.name) ? 'nsfw' : /-swim/i.test(it.name) ? 'swim' : 'safe';
const next = cur === 'safe' ? 'swim' : cur === 'swim' ? 'nsfw' : 'safe';
const r = await fetch('api/tag', { method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: it.path, nsfw: next === 'nsfw', swim: next === 'swim' }) }).then(r => r.json());
if (r.error) return toast('✗ ' + r.error);
toast(next === 'safe' ? 'untagged — this can now be exported to games'
: next === 'swim' ? 'tagged swim — clothed, but flagged for shopfront/kids scenes'
: 'tagged nsfw — blocked from game export');
BODY = GARM = null; refresh();
};
// ---------- 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; }
}
}
// LOD presets: the tiers the games actually budget for, so you stop typing numbers
const LODS = [['hero', 60000], ['npc', 18000], ['crowd', 6000], ['tiny', 2000]];
$('lodPresets').innerHTML = LODS.map(([n, t]) =>
`<button class="ghost" data-lod="${t}" style="flex:1;font-size:11px">${n}<br><span class="sub">${(t / 1000)}k</span></button>`).join('');
document.querySelectorAll('[data-lod]').forEach(b => b.onclick = () => {
$('decT').value = b.dataset.lod;
if (BODY) runJob('api/blender', { op: 'decimate', args: { path: BODY.path, tris: +b.dataset.lod } }, 'decimate');
else toast('pick a body first');
});
$('unitBtn').onclick = () => BODY
? runJob('api/blender', { op: 'unitfix', args: { path: BODY.path } }, 'unitfix')
: toast('pick a body first');
$('rigBtn').onclick = () => BODY
? runJob('api/rig', { path: BODY.path, tris: +$('decT').value || 0 }, 'rig (minutes)')
: toast('pick a body first');
$('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;
async function doGen() {
const p = $('genPrompt').value.trim(); if (!p) return toast('describe the garment');
const payload = { phrase: p, template: $('genTpl').value, backend: $('genBackend').value,
variant: +$('genVariant').value || 0, view: 'top' };
// show the resolved prompt + seed: the cheapest debugging affordance there is, and without it
// a bad generation gives you nothing to reason about
const pre = await fetch('api/gen', { method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload) }).then(r => r.json());
if (pre.error) return toast('✗ ' + pre.error);
$('genMeta').textContent = `seed ${pre.seed} · ${pre.prompt}`;
toast('flux…');
while (true) {
await new Promise(r => setTimeout(r, 1500));
const j = await fetch('api/job/' + pre.job).then(r => r.json());
toast(`flux: ${j.status}${j.note ? ' · ' + j.note : ''}`);
if (j.status === 'error') return toast('✗ flux: ' + (j.log || '').split('\n').pop());
if (j.status === 'done') {
GENPICK = j.out;
$('genPrev').src = 'file?p=' + encodeURIComponent(j.out) + '&t=' + Date.now();
$('genPrev').style.display = 'block'; $('rmbgBtn').disabled = false;
refresh(); return toast('✓ ' + (j.out || '').split('/').pop());
}
}
}
$('genBtn').onclick = doGen;
$('genReroll').onclick = () => { $('genVariant').value = (+$('genVariant').value || 0) + 1; doGen(); };
$('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'];
const CANON_TO_DOLL = { torso: 'top', legs: 'bottom', feet: 'shoes', head: 'hat', bag: '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>