From 58354d9b668d335346e191f23caad8458762cf40 Mon Sep 17 00:00:00 2001 From: type-two Date: Mon, 20 Jul 2026 20:53:43 +1000 Subject: [PATCH] =?UTF-8?q?WARDROBEGOD=20v1=20=E2=80=94=20wardrobe=20gener?= =?UTF-8?q?ator=20bench=20(bodies,=20fits,=20garment=20gen=20pipeline)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- .gitignore | 6 + README.md | 48 +++++++ blender_ops.py | 166 ++++++++++++++++++++++++ server.py | 336 +++++++++++++++++++++++++++++++++++++++++++++++++ web/index.html | 315 ++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 871 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 blender_ops.py create mode 100644 server.py create mode 100644 web/index.html diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..857d141 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +.glbcache/ +library/ +out/ +jobs/ +__pycache__/ +.DS_Store diff --git a/README.md b/README.md new file mode 100644 index 0000000..7c221fe --- /dev/null +++ b/README.md @@ -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. diff --git a/blender_ops.py b/blender_ops.py new file mode 100644 index 0000000..9898137 --- /dev/null +++ b/blender_ops.py @@ -0,0 +1,166 @@ +"""WARDROBEGOD blender ops — headless Blender does the heavy lifting the browser can't. + + blender -b --python blender_ops.py -- + +ops: + convert any format Blender reads → GLB (textures packed) + scale uniform scale so bbox height = height_m, applied + decimate Decimate modifier — PRESERVES vertex weights/rig + fit [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 ... 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}') diff --git a/server.py b/server.py new file mode 100644 index 0000000..09e8a27 --- /dev/null +++ b/server.py @@ -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() diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..1d696f0 --- /dev/null +++ b/web/index.html @@ -0,0 +1,315 @@ + + +WARDROBEGOD — one skeleton, infinite fits + +
+
+

WARDROBEGOD

one skeleton · infinite fits + + + + +
+ +
+

bodies

+ +
+

body ops

+
+
+
decimate keeps skin weights — the safe local path for rigged meshes (never the farm /finish).
+
+ +
+
pick a body…
+
+ How clothing works here (3 tiers) +
    +
  1. Rigid — hats/bags/shoes: attach to a bone (picker below right). No deformation needed.
  2. +
  3. Deforming — shirts/pants: FIT 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.
  4. +
  5. Swap — socket torsos (character_kit_modular): a "shirt" that replaces the torso. Zero clipping.
  6. +
+ Generating NEW clothes ($0, MODELBEAST) +
    +
  1. flux — product-shot of the garment, flat, white bg
  2. +
  3. cutout — bg_remove
  4. +
  5. then EITHER → rigid 3D (trellis, ~5 min — hats/shoes/bags only; thin cloth dies in + reconstruction) OR → hanging texture (rack planes / paper-doll — flat clothes LOVE this)
  6. +
+
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.
+
+
+ + + + +
+ + +