WARDROBEGOD v1 — wardrobe generator bench (bodies, fits, garment gen pipeline)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
commit
58354d9b66
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
@ -0,0 +1,6 @@
|
||||
.glbcache/
|
||||
library/
|
||||
out/
|
||||
jobs/
|
||||
__pycache__/
|
||||
.DS_Store
|
||||
48
README.md
Normal file
48
README.md
Normal file
@ -0,0 +1,48 @@
|
||||
# WARDROBEGOD — one skeleton, infinite fits
|
||||
|
||||
The wardrobe generator bench: load bodies (FBX/GLB/OBJ), build a clothes library, generate
|
||||
new garments on the farm, fit them, assemble dressed NPCs. Sibling of NPCFACTORY (the
|
||||
dress-up/clip bench) and MeshGod imagelab — this one owns *clothing*.
|
||||
|
||||
python3 server.py → http://localhost:8150
|
||||
WG_HOST=0.0.0.0 python3 server.py → expose on the tailnet
|
||||
MB_TOKEN=mbt_… python3 server.py → enables the generator tab (MODELBEAST bearer token)
|
||||
|
||||
No build step: stdlib server + three.js from CDN. Heavy lifting = local headless Blender
|
||||
(`blender_ops.py`, tested on 5.1.2; override path with WG_BLENDER).
|
||||
|
||||
## What it does
|
||||
|
||||
- **Library panes**: bodies (scans `library/bodies/` + `~/Documents/anatomy` + thriftgod's
|
||||
`web/assets/models`), garments, generated images, finished outfits. Upload via file inputs.
|
||||
- **Stage**: orbit viewer; FBX auto-converts to GLB (cached in `.glbcache/`); HUD reads
|
||||
tris / height / rig state (bones + clips); first clip auto-plays.
|
||||
- **Body ops**: scale-to-height (bases arrive 1.0m tall — normalize to ~1.72m);
|
||||
decimate-keep-weights (the SAFE local path for rigged meshes — never farm `/finish`).
|
||||
- **Rigid attach (tier 1)**: hats/bags/shoes → pick a bone, slide offsets, preview live.
|
||||
Bake permanently via the fleet `/rig` endpoint (m1ultra :8011) when happy.
|
||||
- **Deforming FIT (tier 2)**: weight-transfers the rigged body's skinning onto a garment
|
||||
mesh (Blender data_transfer, nearest-face interpolated, optional inflate-mm of air),
|
||||
parents it to the armature. `FIT → wardrobe item` = garment+skeleton GLB (reusable);
|
||||
`fit → dressed glb` = body+garment in one.
|
||||
- **Assemble**: body + ticked fitted garments → one dressed GLB in `out/` — drop straight
|
||||
into thriftgod / djsim / procity, or publish to 3GOD.
|
||||
|
||||
## Generating NEW clothing (the pipeline, $0 on MODELBEAST)
|
||||
|
||||
1. **flux** (`flux_local`, Klein) — product-shot prompt template: garment laid flat, white bg
|
||||
2. **cutout** (`bg_remove_local`) — the biggest quality lever before 3D
|
||||
3. then ONE of:
|
||||
- **→ rigid 3D** (`trellis_mac`, ~5 min): hats, shoes, bags, glasses — bone-parent these.
|
||||
NOT for floppy cloth: reconstruction eats thin geometry (fleet traps ledger).
|
||||
- **→ hanging texture**: flat garment PNG for rack planes (djsim `fittings.js
|
||||
garment({image})`) and the paper-doll wardrobe. Flat clothes love this route.
|
||||
- **deforming wearables**: model/retopo against the base body (or socket-cut a clothed
|
||||
torso, character_kit_modular style), then FIT. Never raw-gen these.
|
||||
|
||||
## House rules
|
||||
|
||||
- Bases (anatomical models) stay in the library. **Only dressed outfits ship into games.**
|
||||
- FIT requires a rigged body. Unrigged base → rig first (MIRPAMO on ultra, or Mixamo).
|
||||
- One skeleton (mixamorig) across the fleet: any garment fitted on one body fits every
|
||||
body on that skeleton, and every clip in the bank drives it.
|
||||
166
blender_ops.py
Normal file
166
blender_ops.py
Normal file
@ -0,0 +1,166 @@
|
||||
"""WARDROBEGOD blender ops — headless Blender does the heavy lifting the browser can't.
|
||||
|
||||
blender -b --python blender_ops.py -- <op> <args...>
|
||||
|
||||
ops:
|
||||
convert <in> <out.glb> any format Blender reads → GLB (textures packed)
|
||||
scale <in> <out.glb> <height_m> uniform scale so bbox height = height_m, applied
|
||||
decimate <in> <out.glb> <target_tris> Decimate modifier — PRESERVES vertex weights/rig
|
||||
fit <body> <garment> <out.glb> <mode> [inflate_mm]
|
||||
weight-transfer the body's skinning onto the garment (nearest-face interpolated),
|
||||
parent to the body's armature. mode=merge → one GLB (dressed body);
|
||||
mode=garment → garment+skeleton only (a wardrobe item, reusable in assemble)
|
||||
assemble <body> <out.glb> <garment.glb>... dressed combo: garments must have been fitted
|
||||
against the same skeleton (bone names match)
|
||||
|
||||
House rules: never fit against an unrigged body (we abort); rigged meshes are never sent to
|
||||
the farm /finish (this file is the safe local path for them).
|
||||
"""
|
||||
import bpy, sys, os
|
||||
|
||||
argv = sys.argv[sys.argv.index('--') + 1:]
|
||||
OP, ARGS = argv[0], argv[1:]
|
||||
|
||||
|
||||
def clean():
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
|
||||
|
||||
def load(path):
|
||||
"""Import path, return the set of objects it brought in."""
|
||||
before = set(bpy.data.objects)
|
||||
ext = os.path.splitext(path)[1].lower()
|
||||
if ext == '.fbx':
|
||||
bpy.ops.import_scene.fbx(filepath=path)
|
||||
elif ext in ('.glb', '.gltf'):
|
||||
bpy.ops.import_scene.gltf(filepath=path)
|
||||
elif ext == '.obj':
|
||||
bpy.ops.wm.obj_import(filepath=path)
|
||||
else:
|
||||
raise SystemExit(f'unsupported format: {ext}')
|
||||
return list(set(bpy.data.objects) - before)
|
||||
|
||||
|
||||
def export(out):
|
||||
os.makedirs(os.path.dirname(out), exist_ok=True)
|
||||
bpy.ops.export_scene.gltf(filepath=out, export_format='GLB')
|
||||
print(f'WROTE {out}')
|
||||
|
||||
|
||||
def apply_mod(obj, name):
|
||||
with bpy.context.temp_override(object=obj, active_object=obj,
|
||||
selected_editable_objects=[obj]):
|
||||
bpy.ops.object.modifier_apply(modifier=name)
|
||||
|
||||
|
||||
def meshes(objs):
|
||||
return [o for o in objs if o.type == 'MESH']
|
||||
|
||||
|
||||
def armatures(objs):
|
||||
return [o for o in objs if o.type == 'ARMATURE']
|
||||
|
||||
|
||||
def total_tris(objs):
|
||||
return sum(sum(len(p.vertices) - 2 for p in o.data.polygons) for o in meshes(objs))
|
||||
|
||||
|
||||
if OP == 'convert':
|
||||
clean(); load(ARGS[0]); export(ARGS[1])
|
||||
|
||||
elif OP == 'scale':
|
||||
clean()
|
||||
objs = load(ARGS[0]); target = float(ARGS[2])
|
||||
import mathutils
|
||||
mn = mathutils.Vector((1e9,) * 3); mx = mathutils.Vector((-1e9,) * 3)
|
||||
for o in meshes(objs):
|
||||
for c in o.bound_box:
|
||||
w = o.matrix_world @ mathutils.Vector(c)
|
||||
mn = mathutils.Vector(map(min, mn, w)); mx = mathutils.Vector(map(max, mx, w))
|
||||
h = mx.z - mn.z
|
||||
if h <= 0:
|
||||
raise SystemExit('flat object — no height to scale')
|
||||
s = target / h
|
||||
roots = [o for o in objs if not o.parent]
|
||||
for r in roots:
|
||||
r.scale = [c * s for c in r.scale]
|
||||
with bpy.context.temp_override(selected_editable_objects=roots + meshes(objs) + armatures(objs)):
|
||||
bpy.ops.object.transform_apply(location=False, rotation=False, scale=True)
|
||||
print(f'scaled ×{s:.3f} → {target}m')
|
||||
export(ARGS[1])
|
||||
|
||||
elif OP == 'decimate':
|
||||
clean()
|
||||
objs = load(ARGS[0]); target = int(ARGS[2])
|
||||
tris = total_tris(objs)
|
||||
ratio = min(1.0, target / max(tris, 1))
|
||||
for o in meshes(objs):
|
||||
m = o.modifiers.new('dec', 'DECIMATE')
|
||||
m.ratio = ratio
|
||||
# keep the Armature modifier LAST so skinning still evaluates after the cut
|
||||
while o.modifiers[0].name != 'dec':
|
||||
with bpy.context.temp_override(object=o):
|
||||
bpy.ops.object.modifier_move_up(modifier='dec')
|
||||
apply_mod(o, 'dec')
|
||||
print(f'decimated {tris:,} → {total_tris(objs):,} tris (ratio {ratio:.3f}) — weights kept')
|
||||
export(ARGS[1])
|
||||
|
||||
elif OP == 'fit':
|
||||
clean()
|
||||
body_objs = load(ARGS[0])
|
||||
arms = armatures(body_objs)
|
||||
if not arms:
|
||||
raise SystemExit('body has no armature — rig it first (MIRPAMO/Mixamo), then fit')
|
||||
arm = arms[0]
|
||||
body = max(meshes(body_objs), key=lambda o: len(o.vertex_groups), default=None)
|
||||
if body is None or not body.vertex_groups:
|
||||
raise SystemExit('body has no skin weights to copy')
|
||||
garm_objs = load(ARGS[1])
|
||||
mode = ARGS[3] if len(ARGS) > 3 else 'merge'
|
||||
inflate = float(ARGS[4]) / 1000.0 if len(ARGS) > 4 else 0.0
|
||||
for g in meshes(garm_objs):
|
||||
if inflate: # a few mm of air so the body doesn't poke through
|
||||
d = g.modifiers.new('puff', 'DISPLACE'); d.strength = inflate; d.mid_level = 0
|
||||
apply_mod(g, 'puff')
|
||||
with bpy.context.temp_override(object=body, active_object=body,
|
||||
selected_editable_objects=[g, body],
|
||||
selected_objects=[g, body]):
|
||||
bpy.ops.object.data_transfer(data_type='VGROUP_WEIGHTS', use_create=True,
|
||||
vert_mapping='POLYINTERP_NEAREST',
|
||||
layers_select_src='ALL', layers_select_dst='NAME')
|
||||
g.parent = arm
|
||||
am = g.modifiers.new('arm', 'ARMATURE'); am.object = arm
|
||||
# garment armatures that came along with the import are dupes — drop them
|
||||
for a in armatures(garm_objs):
|
||||
bpy.data.objects.remove(a, do_unlink=True)
|
||||
if mode == 'garment':
|
||||
bpy.data.objects.remove(body, do_unlink=True)
|
||||
for o in meshes(body_objs):
|
||||
if o.name in bpy.data.objects:
|
||||
bpy.data.objects.remove(o, do_unlink=True)
|
||||
print(f'fitted {len(meshes(garm_objs))} garment mesh(es), mode={mode}, inflate={inflate * 1000:.0f}mm')
|
||||
export(ARGS[2])
|
||||
|
||||
elif OP == 'assemble':
|
||||
clean()
|
||||
body_objs = load(ARGS[0])
|
||||
arms = armatures(body_objs)
|
||||
if not arms:
|
||||
raise SystemExit('body has no armature')
|
||||
arm = arms[0]
|
||||
for gpath in ARGS[2:]:
|
||||
gobjs = load(gpath)
|
||||
for g in meshes(gobjs):
|
||||
g.parent = arm
|
||||
for m in g.modifiers:
|
||||
if m.type == 'ARMATURE':
|
||||
m.object = arm
|
||||
if not any(m.type == 'ARMATURE' for m in g.modifiers):
|
||||
am = g.modifiers.new('arm', 'ARMATURE'); am.object = arm
|
||||
for a in armatures(gobjs): # bone names match by contract — drop the dupe
|
||||
bpy.data.objects.remove(a, do_unlink=True)
|
||||
print(f'assembled body + {len(ARGS) - 2} garment file(s)')
|
||||
export(ARGS[1])
|
||||
|
||||
else:
|
||||
raise SystemExit(f'unknown op {OP}')
|
||||
336
server.py
Normal file
336
server.py
Normal file
@ -0,0 +1,336 @@
|
||||
#!/usr/bin/env python3
|
||||
"""WARDROBEGOD — one skeleton, infinite fits. The wardrobe generator bench.
|
||||
|
||||
Stdlib server + three.js page (no build step, house style — sibling of NPCFACTORY/imagelab).
|
||||
|
||||
python3 server.py → http://localhost:8150
|
||||
WG_HOST=0.0.0.0 python3 server.py → expose on the tailnet
|
||||
|
||||
What it does:
|
||||
· library panes over bodies / garments / generated images / finished outfits
|
||||
· loads FBX or GLB on a 3D stage (FBX auto-converts to GLB via local headless Blender)
|
||||
· Blender jobs: convert · scale-to-height · decimate-keep-weights · FIT (weight-transfer
|
||||
a garment onto a rigged body) · ASSEMBLE (body + fitted garments → one dressed GLB)
|
||||
· the garment GENERATOR pipeline (MODELBEAST, $0): flux image → bg_remove →
|
||||
either a hanging-garment TEXTURE (racks/paper-doll) or trellis_mac → rigid 3D
|
||||
wearable (hats/shoes/bags — bone-parent those; cloth that must bend gets FIT instead)
|
||||
|
||||
Env: MB_HOST (default m3ultra :8777) · MB_TOKEN (bearer; generator disabled without it)
|
||||
WG_PORT (8150) · WG_HOST (127.0.0.1)
|
||||
"""
|
||||
import json, mimetypes, os, re, shutil, subprocess, threading, time, urllib.parse, urllib.request, uuid
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
|
||||
ROOT = os.path.dirname(os.path.abspath(__file__))
|
||||
LIB = os.path.join(ROOT, 'library')
|
||||
DIRS = {'bodies': os.path.join(LIB, 'bodies'), 'garments': os.path.join(LIB, 'garments'),
|
||||
'gen': os.path.join(LIB, 'gen'), 'out': os.path.join(ROOT, 'out')}
|
||||
EXTRA_BODIES = [os.path.expanduser('~/Documents/anatomy'),
|
||||
os.path.expanduser('~/Documents/thriftgod/web/assets/models')]
|
||||
CACHE = os.path.join(ROOT, '.glbcache')
|
||||
BLENDER = os.environ.get('WG_BLENDER', '/Applications/Blender.app/Contents/MacOS/Blender')
|
||||
OPS = os.path.join(ROOT, 'blender_ops.py')
|
||||
MB = os.environ.get('MB_HOST', 'http://100.89.131.57:8777')
|
||||
MB_TOKEN = os.environ.get('MB_TOKEN', '')
|
||||
PORT = int(os.environ.get('WG_PORT', 8150))
|
||||
HOST = os.environ.get('WG_HOST', '127.0.0.1')
|
||||
for d in list(DIRS.values()) + [CACHE]:
|
||||
os.makedirs(d, exist_ok=True)
|
||||
|
||||
JOBS = {} # id → {status, note, out, log}
|
||||
MODEL_EXT = ('.glb', '.gltf', '.fbx', '.obj')
|
||||
|
||||
|
||||
def slug(s):
|
||||
return re.sub(r'[^a-z0-9]+', '-', (s or '').lower()).strip('-')[:60] or 'x'
|
||||
|
||||
|
||||
def allowed(path):
|
||||
p = os.path.realpath(path)
|
||||
roots = list(DIRS.values()) + EXTRA_BODIES + [CACHE]
|
||||
return any(p.startswith(os.path.realpath(r) + os.sep) or p == os.path.realpath(r) for r in roots)
|
||||
|
||||
|
||||
def scan():
|
||||
out = {}
|
||||
for key, d in DIRS.items():
|
||||
exts = MODEL_EXT if key in ('bodies', 'garments', 'out') else ('.png', '.jpg', '.webp')
|
||||
rows = []
|
||||
dirs = [d] + (EXTRA_BODIES if key == 'bodies' else [])
|
||||
for dd in dirs:
|
||||
if not os.path.isdir(dd):
|
||||
continue
|
||||
for f in sorted(os.listdir(dd)):
|
||||
if f.lower().endswith(exts):
|
||||
p = os.path.join(dd, f)
|
||||
rows.append({'name': f, 'path': p, 'kb': os.path.getsize(p) // 1024,
|
||||
'home': os.path.basename(dd)})
|
||||
out[key] = rows
|
||||
return out
|
||||
|
||||
|
||||
def run_blender(args, jid):
|
||||
"""One headless Blender op; stdout tail lands in the job log."""
|
||||
cmd = [BLENDER, '-b', '--python', OPS, '--'] + [str(a) for a in args]
|
||||
r = subprocess.run(cmd, capture_output=True, text=True, timeout=1800)
|
||||
tail = '\n'.join((r.stdout + r.stderr).strip().splitlines()[-12:])
|
||||
JOBS[jid]['log'] = tail
|
||||
if r.returncode != 0 or 'Error' in r.stderr:
|
||||
raise RuntimeError(tail[-400:])
|
||||
|
||||
|
||||
def glb_of(path, jid=None):
|
||||
"""FBX/OBJ → cached GLB (stage + ops always speak GLB); GLB passes through."""
|
||||
if path.lower().endswith(('.glb', '.gltf')):
|
||||
return path
|
||||
tgt = os.path.join(CACHE, slug(os.path.basename(path)) + '.glb')
|
||||
if not os.path.exists(tgt) or os.path.getmtime(tgt) < os.path.getmtime(path):
|
||||
run_blender(['convert', path, tgt], jid or new_job('convert', 'convert ' + os.path.basename(path)))
|
||||
return tgt
|
||||
|
||||
|
||||
def new_job(kind, note):
|
||||
jid = uuid.uuid4().hex[:10]
|
||||
JOBS[jid] = {'status': 'running', 'kind': kind, 'note': note, 'out': None, 'log': '', 't': time.time()}
|
||||
return jid
|
||||
|
||||
|
||||
def job_thread(jid, fn):
|
||||
def go():
|
||||
try:
|
||||
JOBS[jid]['out'] = fn(jid)
|
||||
JOBS[jid]['status'] = 'done'
|
||||
except Exception as e:
|
||||
JOBS[jid]['status'] = 'error'
|
||||
JOBS[jid]['log'] = (JOBS[jid]['log'] + '\n' + str(e)).strip()[-1500:]
|
||||
threading.Thread(target=go, daemon=True).start()
|
||||
|
||||
|
||||
# ---------- MODELBEAST client (token stays server-side, never in the page) ----------
|
||||
def mb_req(path, data=None, raw=None, ctype='application/json'):
|
||||
req = urllib.request.Request(MB + path, data=raw if raw is not None else (json.dumps(data).encode() if data else None))
|
||||
req.add_header('Authorization', 'Bearer ' + MB_TOKEN)
|
||||
if data is not None or raw is not None:
|
||||
req.add_header('Content-Type', ctype)
|
||||
with urllib.request.urlopen(req, timeout=120) as r:
|
||||
body = r.read()
|
||||
try:
|
||||
return json.loads(body)
|
||||
except ValueError:
|
||||
return body
|
||||
|
||||
|
||||
def mb_wait(job_id, jid, timeout=900):
|
||||
t0 = time.time()
|
||||
while time.time() - t0 < timeout:
|
||||
j = mb_req(f'/api/jobs/{job_id}')
|
||||
st = j.get('status')
|
||||
JOBS[jid]['note'] = f"farm: {st}"
|
||||
if st in ('done', 'completed', 'succeeded'):
|
||||
return j
|
||||
if st in ('error', 'failed', 'cancelled'):
|
||||
raise RuntimeError('farm job failed: ' + str(j.get('error') or st))
|
||||
time.sleep(3)
|
||||
raise RuntimeError('farm job timed out')
|
||||
|
||||
|
||||
def mb_outputs(job_id):
|
||||
assets = mb_req(f'/api/assets?parent_job={job_id}')
|
||||
rows = assets.get('assets', assets) if isinstance(assets, dict) else assets
|
||||
return rows or []
|
||||
|
||||
|
||||
def mb_download(asset, tgt):
|
||||
aid = asset.get('id') or asset.get('asset_id')
|
||||
data = mb_req(f'/api/assets/{aid}/file')
|
||||
with open(tgt, 'wb') as f:
|
||||
f.write(data if isinstance(data, bytes) else json.dumps(data).encode())
|
||||
return tgt
|
||||
|
||||
|
||||
def mb_upload(path):
|
||||
import mimetypes as mt
|
||||
boundary = uuid.uuid4().hex
|
||||
name = os.path.basename(path)
|
||||
ctype = mt.guess_type(path)[0] or 'application/octet-stream'
|
||||
with open(path, 'rb') as f:
|
||||
payload = f.read()
|
||||
body = (f'--{boundary}\r\nContent-Disposition: form-data; name="file"; filename="{name}"\r\n'
|
||||
f'Content-Type: {ctype}\r\n\r\n').encode() + payload + f'\r\n--{boundary}--\r\n'.encode()
|
||||
out = mb_req('/api/assets', raw=body, ctype=f'multipart/form-data; boundary={boundary}')
|
||||
return out.get('id') or out.get('asset_id') or (out.get('asset') or {}).get('id')
|
||||
|
||||
|
||||
# ---------- HTTP ----------
|
||||
class H(BaseHTTPRequestHandler):
|
||||
def log_message(self, *a):
|
||||
pass
|
||||
|
||||
def send(self, code, body, ctype='application/json'):
|
||||
self.send_response(code)
|
||||
self.send_header('Content-Type', ctype)
|
||||
self.send_header('Content-Length', str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def j(self, obj, code=200):
|
||||
self.send(code, json.dumps(obj).encode())
|
||||
|
||||
def do_GET(self):
|
||||
u = urllib.parse.urlparse(self.path)
|
||||
q = dict(urllib.parse.parse_qsl(u.query))
|
||||
if u.path == '/':
|
||||
return self.send(200, open(os.path.join(ROOT, 'web', 'index.html'), 'rb').read(), 'text/html')
|
||||
if u.path == '/api/lib':
|
||||
return self.j({'lib': scan(), 'mb': bool(MB_TOKEN), 'blender': os.path.exists(BLENDER)})
|
||||
if u.path.startswith('/api/job/'):
|
||||
jid = u.path.rsplit('/', 1)[1]
|
||||
return self.j(JOBS.get(jid) or {'status': 'unknown'})
|
||||
if u.path == '/file':
|
||||
p = q.get('p', '')
|
||||
if not allowed(p) or not os.path.isfile(p):
|
||||
return self.j({'error': 'nope'}, 403)
|
||||
return self.send(200, open(p, 'rb').read(), mimetypes.guess_type(p)[0] or 'application/octet-stream')
|
||||
if u.path == '/glb': # stage loader: any model file → GLB (convert+cache)
|
||||
p = q.get('p', '')
|
||||
if not allowed(p) or not os.path.isfile(p):
|
||||
return self.j({'error': 'nope'}, 403)
|
||||
try:
|
||||
g = glb_of(p)
|
||||
except Exception as e:
|
||||
return self.j({'error': str(e)[-300:]}, 500)
|
||||
return self.send(200, open(g, 'rb').read(), 'model/gltf-binary')
|
||||
return self.j({'error': 'not found'}, 404)
|
||||
|
||||
def do_POST(self):
|
||||
u = urllib.parse.urlparse(self.path)
|
||||
q = dict(urllib.parse.parse_qsl(u.query))
|
||||
n = int(self.headers.get('Content-Length') or 0)
|
||||
raw = self.rfile.read(n) if n else b''
|
||||
|
||||
if u.path == '/api/upload': # file input → library dir
|
||||
to, name = q.get('to', 'bodies'), os.path.basename(q.get('name', 'upload.glb'))
|
||||
if to not in DIRS or not name.lower().endswith(MODEL_EXT + ('.png', '.jpg', '.webp')):
|
||||
return self.j({'error': 'bad target'}, 400)
|
||||
p = os.path.join(DIRS[to], name)
|
||||
with open(p, 'wb') as f:
|
||||
f.write(raw)
|
||||
return self.j({'ok': True, 'path': p})
|
||||
|
||||
body = json.loads(raw or b'{}')
|
||||
|
||||
if u.path == '/api/blender': # {op, args:{...}} → background Blender job
|
||||
op = body.get('op')
|
||||
a = body.get('args', {})
|
||||
jid = new_job(op, body.get('note', op))
|
||||
|
||||
def fx(jid):
|
||||
if op == 'scale':
|
||||
src = glb_of(a['path'], jid)
|
||||
out = os.path.join(DIRS['bodies'], slug(a.get('name') or os.path.basename(a['path']).rsplit('.', 1)[0]) + '.glb')
|
||||
run_blender(['scale', src, out, a['height']], jid)
|
||||
elif op == 'decimate':
|
||||
src = glb_of(a['path'], jid)
|
||||
out = os.path.join(DIRS['bodies'], slug(os.path.basename(a['path']).rsplit('.', 1)[0]) + f"-{int(a['tris'])//1000}k.glb")
|
||||
run_blender(['decimate', src, out, int(a['tris'])], jid)
|
||||
elif op == 'fit':
|
||||
b, g = glb_of(a['body'], jid), glb_of(a['garment'], jid)
|
||||
mode = a.get('mode', 'garment')
|
||||
base = slug(os.path.basename(a['garment']).rsplit('.', 1)[0])
|
||||
out = os.path.join(DIRS['garments'] if mode == 'garment' else DIRS['out'],
|
||||
base + ('-fitted.glb' if mode == 'garment' else '-dressed.glb'))
|
||||
run_blender(['fit', b, g, out, mode, a.get('inflate', 3)], jid)
|
||||
elif op == 'assemble':
|
||||
b = glb_of(a['body'], jid)
|
||||
gs = [glb_of(g, jid) for g in a['garments']]
|
||||
out = os.path.join(DIRS['out'], slug(a.get('name') or 'outfit') + '.glb')
|
||||
run_blender(['assemble', b, out] + gs, jid)
|
||||
elif op == 'convert':
|
||||
src = a['path']
|
||||
out = os.path.join(DIRS['bodies'], slug(os.path.basename(src).rsplit('.', 1)[0]) + '.glb')
|
||||
run_blender(['convert', src, out], jid)
|
||||
else:
|
||||
raise RuntimeError('unknown op')
|
||||
return out
|
||||
job_thread(jid, fx)
|
||||
return self.j({'job': jid})
|
||||
|
||||
if u.path == '/api/gen': # the garment pipeline, step 1: flux image
|
||||
if not MB_TOKEN:
|
||||
return self.j({'error': 'MB_TOKEN not set on this box — export MB_TOKEN=… and restart'}, 400)
|
||||
prompt = body.get('prompt', '')
|
||||
style = ('product photo of a single {} laid flat on a plain white background, '
|
||||
'front view, no mannequin, no person, no text, soft even lighting, '
|
||||
'1990s Australian op-shop garment, slightly worn').format(prompt)
|
||||
jid = new_job('gen', 'flux: ' + prompt[:40])
|
||||
|
||||
def fx(jid):
|
||||
j = mb_req('/api/jobs', {'operator': 'flux_local', 'asset_id': None,
|
||||
'params': {'prompt': style, 'model': 'flux2-klein-4b',
|
||||
'steps': 4, 'width': 768, 'height': 768,
|
||||
'seed': int(time.time()) % 99991}})
|
||||
done = mb_wait(j.get('id') or j.get('job_id'), jid)
|
||||
outs = mb_outputs(j.get('id') or j.get('job_id'))
|
||||
if not outs:
|
||||
raise RuntimeError('no image came back')
|
||||
tgt = os.path.join(DIRS['gen'], slug(prompt) + '.png')
|
||||
return mb_download(outs[0], tgt)
|
||||
job_thread(jid, fx)
|
||||
return self.j({'job': jid})
|
||||
|
||||
if u.path == '/api/rmbg': # step 2: cut it out
|
||||
if not MB_TOKEN:
|
||||
return self.j({'error': 'MB_TOKEN not set'}, 400)
|
||||
p = body.get('path', '')
|
||||
if not allowed(p):
|
||||
return self.j({'error': 'nope'}, 403)
|
||||
jid = new_job('rmbg', 'cutout ' + os.path.basename(p))
|
||||
|
||||
def fx(jid):
|
||||
aid = mb_upload(p)
|
||||
j = mb_req('/api/jobs', {'operator': 'bg_remove_local', 'asset_id': aid, 'params': {}})
|
||||
mb_wait(j.get('id') or j.get('job_id'), jid)
|
||||
outs = mb_outputs(j.get('id') or j.get('job_id'))
|
||||
if not outs:
|
||||
raise RuntimeError('no cutout came back')
|
||||
tgt = p.rsplit('.', 1)[0] + '-cut.png'
|
||||
return mb_download(outs[0], tgt)
|
||||
job_thread(jid, fx)
|
||||
return self.j({'job': jid})
|
||||
|
||||
if u.path == '/api/to3d': # step 3a: rigid wearable via TRELLIS (hats/shoes/bags)
|
||||
if not MB_TOKEN:
|
||||
return self.j({'error': 'MB_TOKEN not set'}, 400)
|
||||
p = body.get('path', '')
|
||||
if not allowed(p):
|
||||
return self.j({'error': 'nope'}, 403)
|
||||
jid = new_job('to3d', '3D: ' + os.path.basename(p) + ' (~5 min on the farm)')
|
||||
|
||||
def fx(jid):
|
||||
aid = mb_upload(p)
|
||||
j = mb_req('/api/jobs', {'operator': 'trellis_mac', 'asset_id': aid, 'params': {}})
|
||||
mb_wait(j.get('id') or j.get('job_id'), jid, timeout=1200)
|
||||
outs = mb_outputs(j.get('id') or j.get('job_id'))
|
||||
glbs = [o for o in outs if str(o.get('name', o.get('filename', ''))).lower().endswith('.glb')] or outs
|
||||
if not glbs:
|
||||
raise RuntimeError('no mesh came back')
|
||||
tgt = os.path.join(DIRS['garments'], slug(os.path.basename(p).rsplit('.', 1)[0]) + '-rigid.glb')
|
||||
return mb_download(glbs[0], tgt)
|
||||
job_thread(jid, fx)
|
||||
return self.j({'job': jid})
|
||||
|
||||
if u.path == '/api/hangable': # step 3b: cutout → hanging-garment texture pack
|
||||
p = body.get('path', '')
|
||||
if not allowed(p):
|
||||
return self.j({'error': 'nope'}, 403)
|
||||
tgt = os.path.join(DIRS['garments'], os.path.basename(p))
|
||||
shutil.copyfile(p, tgt)
|
||||
return self.j({'ok': True, 'path': tgt,
|
||||
'note': 'texture saved to garments — use on rack planes (fittings.js garment({image}))'})
|
||||
|
||||
return self.j({'error': 'not found'}, 404)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
print(f'WARDROBEGOD on http://{HOST}:{PORT} · blender={os.path.exists(BLENDER)} · farm token={"yes" if MB_TOKEN else "NO (generator off)"}')
|
||||
ThreadingHTTPServer((HOST, PORT), H).serve_forever()
|
||||
315
web/index.html
Normal file
315
web/index.html
Normal file
@ -0,0 +1,315 @@
|
||||
<!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 }
|
||||
</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="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="generate">generate</button>
|
||||
<button data-tab="out">outfits</button>
|
||||
</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>
|
||||
<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
|
||||
|
||||
// ---------- 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); if (mixer) mixer.update(clock.getDelta()); else clock.getDelta(); 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; }
|
||||
}
|
||||
if (g.animations.length) {
|
||||
mixer = new THREE.AnimationMixer(bodyRoot);
|
||||
mixer.clipAction(g.animations[0]).play();
|
||||
}
|
||||
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); }
|
||||
}
|
||||
|
||||
async function previewGarment(item) {
|
||||
GARM = item; renderLists();
|
||||
if (garmRoot) { scene.remove(garmRoot); garmRoot = null; }
|
||||
if (!item.path.match(/\.(glb|gltf|fbx|obj)$/i)) return; // textures: no 3D preview
|
||||
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();
|
||||
const s = +$('atScale').value;
|
||||
inst.scale.setScalar(s); inst.position.set(0, +$('atY').value, +$('atZ').value);
|
||||
bone.add(inst); attached.push(inst);
|
||||
toast(`attached to ${bone.name} — preview only (bake via fleet /rig for keeps)`);
|
||||
};
|
||||
$('clearAtBtn').onclick = () => { attached.forEach(a => a.parent && a.parent.remove(a)); attached = []; };
|
||||
|
||||
// ---------- library ----------
|
||||
async function refresh() {
|
||||
const r = await fetch('/api/lib').then(r => r.json());
|
||||
LIB = r.lib;
|
||||
$('farm').textContent = (r.blender ? 'blender ✓' : 'blender ✗') + ' · farm ' + (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>';
|
||||
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('[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();
|
||||
};
|
||||
|
||||
// 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', 'generate', 'out']) $('tab-' + t).style.display = b.dataset.tab === t ? 'block' : 'none';
|
||||
});
|
||||
$('howBtn').onclick = () => $('how').style.display = $('how').style.display === 'block' ? 'none' : 'block';
|
||||
|
||||
refresh(); resize();
|
||||
</script>
|
||||
Loading…
Reference in New Issue
Block a user