NPCFACTORY and wardrobegod were the same product — wardrobegod had already absorbed its reskin engine, and keeping two benches means two half-libraries and two export paths. Folded, keeping the wardrobegod name and codebase (370 lines there vs ~1,100 here). · Vendored three.js r175 from NPCFACTORY, replacing the unpkg CDN importmap. This was real drift, not tidiness: a CDN import breaks offline and can't be lifted into a game build. NPCFACTORY lacked OrbitControls (it used PointerLock), so that one addon was fetched at the matching revision. Added a guarded /vendor/ static route. Verified in-browser: zero CDN requests, 5 vendor files, model still loads. · Took the 17 rigged walk-animated NPCs and 6 parts. Bodies 4 -> 20. · New `unitfix` op. Deliberately NOT scale-to-height: a 0.06m human is a UNIT error, but normalising everything to 1.72m would erase the small/medium/large/obese range the library is meant to carry. So it only corrects heights outside 0.5-3.0m — physically impossible for a human — and leaves real proportions alone as data. Verified both ways: hum_character 0.0576m -> 1.72m, tradie 1.000m left untouched. Three bugs found while building it, two of them pre-existing: · `is_helper`/`real_meshes`/`bbox_of` factored out. Material-less bone widgets (a radius-1 42-vert Icosphere, so exactly 2.0 units tall) were being measured INSTEAD of the character — every body reported 2.000m. This poisoned the `scale` op too, which has been measuring widgets all along; NPCFACTORY's render_plates.py had independently worked around the same thing by framing on the dominant mesh. · `transform_apply` under temp_override(selected_editable_objects=...) SEGFAULTS Blender 5.1.2 on rigs with parented children. A segfault can't be caught, so it's avoided rather than handled: glTF encodes node scale natively, so setting the root transform is sufficient and every downstream measurement still reads correctly. Confirmed `scale` still round-trips (1.00m -> 1.72m, re-measured). · My own bulk edit replaced only ONE of the two crash-prone call sites and reported "replaced 1" — I didn't check for a second, which is why `scale` worked while `unitfix` kept crashing. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
600 lines
34 KiB
HTML
600 lines
34 KiB
HTML
<!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="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>
|
||
<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>
|
||
|
||
<!-- 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
|
||
|
||
// ---------- 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;
|
||
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'];
|
||
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>
|