diff --git a/STATE_AND_PLAN.md b/STATE_AND_PLAN.md new file mode 100644 index 0000000..596a3fb --- /dev/null +++ b/STATE_AND_PLAN.md @@ -0,0 +1,377 @@ +# WARDROBEGOD — state of play + how to improve it heaps +*Recon 2026-07-24 across JING5 / ultra / m3ultra. Every fleet claim below was spot-verified live (see §B).* + +## TL;DR +The **bench works, the wardrobe is empty, and one bug is dangerous.** The three-column three.js +dress-up shell runs, the local Blender pipeline (convert / scale / decimate-keep-weights / **fit** / +assemble) works, and the Cloudflare flux generator works — but `library/` holds **2 bodies + 1 garment +PNG (which is invisible to its own UI)**, `out/` and `jobs/` are empty, the trellis tier is dead (no +`MB_TOKEN`), and `/api/blender` is an **unauthenticated arbitrary-file → Blender** primitive. + +Meanwhile the fleet already contains *everything wardrobegod is trying to become*, several times over: +- **NPCFACTORY** (ultra) — a further-along dress-up bench: **17 finished rigged, walk-animated NPC GLBs** + + a **completed reskin engine** (paint clothes onto a body's own UV atlas). +- **90sDJsim** (ultra) — a shipped $0 2D wardrobe: **441 authored transparent-PNG garment sprites** + a + self-healing doll compositor. +- **MODELBEAST** (m3ultra :8777, **UP, 26 live operators**) — free local **virtual try-on** + (`mflux_image_edit` = "Edit Image (local, Qwen)": *"put the shirt from image 2 on the person in image 1"*), + flux, RMBG-2.0, TRELLIS.2, Hunyuan3D, SF3D, layered-RGBA cutouts, MoMask text→motion. +- **character_kit_modular** (ultra) — a socket-swap costume engine on rigged mixamorig bases. +- **MIRPAMO** (everywhere) — a local, offline Mixamo (auto-rig → 22-bone mixamorig + retarget). +- **~2,394 + 2,548 + ~163** animation clips already on disk. + +**The decision:** stop treating wardrobegod as a Blender-op bench and make it the one thing the fleet +*lacks* — the **canonical garment/outfit LIBRARY + a dressing-room authoring UI + a multi-consumer +EXPORT HUB**. The dressing *engines* exist five times over; the connective tissue exists zero times. +Graft the fastest proven engines onto that spine (reskin, try-on, the 2D doll, donor-harvest). + +--- + +## A. Where it's at — honest state of play + +### Works today +- **`server.py`** (stdlib ThreadingHTTPServer :8150): `/api/lib`, `/glb?p=` (Blender convert-cache), + `/file?p=`, `/api/upload`, `/api/blender` (op dispatch), `/api/gen` (CF flux), `/api/rmbg`, + `/api/to3d`, `/api/hangable`. No build step. +- **`blender_ops.py`** (166 lines): `convert / scale / decimate-keep-weights / fit / assemble`. The + **fit** op (data_transfer `VGROUP_WEIGHTS POLYINTERP_NEAREST` + armature parent, + `blender_ops.py:128-132`) is the proven wardrobe mechanic — wrap it, don't rewrite it. +- **`web/index.html`** (316 lines): OrbitControls + GLTFLoader stage, 3 tabs, body/garment lists, + mixer that auto-plays `animations[0]`. +- **CF flux** `/api/gen` → `@cf/black-forest-labs/flux-1-schnell` works with only the CF creds in `.env`. +- Blender 5.1.2 + PIL present on JING5 — the whole local pipeline runs with **no farm dependency**. + +### Content truth (the real problem) +``` +library/bodies/ phrtt2.glb (1.67M), droidte2-18k.glb (7.97M) +library/gen/ brown-corduroy-jacket.png, -cut.png +library/garments/ brown-corduroy-jacket-cut.png ← INVISIBLE to the UI (bug #2) +out/ jobs/ EMPTY +.env CLOUDFLARE_* only — NO MB_TOKEN +``` +**Zero garments have ever been fitted to any body.** + +### The bodies are not the consistent A-pose set the brief assumed +The 6 `~/Documents/anatomy` FBX (verified by headless render): **2 A-pose** (`dudescholng`, +`2_views_3d`), **2 T-pose** (`phrtt2`, `droidte2`), 2 arms-down/contrapposto. **5 of 6 are unrigged**; +only `droidte2` has a skeleton (41-bone reduced mixamo) **and it's already clothed** (shorts+boots), +482k-tri raw. Each is an independent TRELLIS reconstruction — **no shared topology, own UV atlas**. +→ **One fitted garment can't be reused across bodies; each needs its own per-body fit, and only +shrinkwrap/proximity works (no vertex correspondence).** + +### The real bugs (file:line) +1. **SECURITY — `/api/blender` is unguarded.** `allowed()` guards `/file /glb /api/rmbg /api/to3d + /api/hangable` but **not** `/api/blender` — `fx()` passes `a['path']/a['body']/a['garment']/ + a['garments']` straight into `glb_of()`→`run_blender()` (`server.py:238-265`). Under the documented + `WG_HOST=0.0.0.0` (`server.py:7`) that's an **unauthenticated arbitrary-file → Blender import + + library-write** for any tailnet peer. `allowed()` itself is sound (`server.py:58-61`) — it's just + not called here. **Fix before any 0.0.0.0 exposure.** +2. **`scan()` hides PNG garments.** `server.py:67` uses `MODEL_EXT` for `garments`, but `/api/hangable` + writes a **.png** there (`server.py:374`). Every hanging-texture / doll cutout is silently filtered + out — the whole 2D route dead-ends in an unshowable directory. **One-line fix, prerequisite for 2D.** +3. **`glb_of` cache-key collision.** `server.py:96` keys on `slug(basename(path))` only, single-source + mtime check (`:97`). Same-basename files in different dirs collide → serve wrong geometry. +4. **Silent corruption in `mb_download`.** `server.py:156-157` writes `json.dumps(data)` when the farm + response isn't bytes; a JSON error body gets written **into** the target `.png/.glb` and the job is + marked **done**. Affects `/api/gen /api/rmbg /api/to3d`. +5. **`JOBS` leaks unbounded** (`server.py:50`), and `glb_of` spawns a `convert` job on every cache miss + even inside synchronous GET `/glb` (`server.py:98`), orphaning records. +6. **Local rmbg keyer is crude** — iterative border flood-fill through near-white (`>232`), binary + alpha, `server.py:329-344`. On a non-white bg it never seeds → **silent no-op opaque copy**. +7. **Rigid-attach is ephemeral** — `web/index.html:215-225` clones into three.js and **POSTs nothing**; + the toast admits *"preview only."* Attach transforms live only in browser memory. +8. **`fit`**: picks body as `max(meshes, key=len(vertex_groups))` → multi-part body donates weights from + one mesh only (`blender_ops.py:115-117`); no `matrix_parent_inverse` (`:131`). +9. **`assemble` trusts an unenforced bone-name contract** (`blender_ops.py:151-161`). +10. No auth / CORS / rate-limit / delete / thumbnails / search; gen filenames overwrite silently. + +> **Not a bug (verified, don't touch):** the suspected `decimate` `modifier_move_up` infinite-loop / +> 30-min hang **does not reproduce** on Blender 5.1.2 — the operator returns `{'FINISHED'}`, loop exits +> in one iteration. Code smell only. + +### Convention drift +- three.js from **unpkg CDN** (`index.html:133-136`) — house style is **vendored** r175 (breaks offline + / un-shippable into a game build). +- README frames the generator as MODELBEAST-only; commit `6f32c08` quietly made **CF the primary** flux + backend — a cloud path outside the LOCAL-FIRST doctrine. +- "Only dressed outfits ship into games" is **one unenforced README line** (`README.md:45`) — no code gate. + +--- + +## B. What the fleet already gives us (verified live 2026-07-24) + +### MODELBEAST — `http://100.89.131.57:8777` (m3ultra), **UP, 26 live operators** ✓ +Bearer `MB_TOKEN` (on disk at `~/Documents/backnforth/.env`, len 44 — never paste). Contract: +`POST /api/assets` (multipart) → `POST /api/jobs {operator, asset_ids|asset_id, params}` → poll +`GET /api/jobs/{id}` → outputs = assets with `parent_job==id` → `GET /api/assets/{id}/file`. +`main.py:290-293` already accepts an **`asset_ids` array** → multi-image try-on is submittable today. + +Wardrobe-critical live operators (display name → slug): +| Display name | Slug | Use | +|---|---|---| +| **Edit Image (local, Qwen)** | `mflux_image_edit` | **virtual try-on** — 1-3 imgs, *"put the shirt from image 2 on the person in image 1"*. The key unused capability. | +| Qwen-Image-Layered (local) | `qwen_layered_local` | image → N editable RGBA layers = the "paper cut-out layers" rep, generated | +| FLUX (local, MLX) | `flux_local` | flux2-klein-4b, ~40s, ungated | +| Remove Background (local) | `bg_remove_local` | RMBG-2.0 matting (better than the local keyer) | +| SD1.5 + anatomy LoRAs / SD+LoRA (ComfyUI) | `sd_local` | OpenPose T-pose ControlNet + IP-Adapter identity lock | +| TRELLIS.2 (SOTA) / TRELLIS.2 MLX | `trellis*` | image → GLB (~2-5 min) — **single-image only** | +| Hunyuan3D 2.1 (local MLX) | `hunyuan3d_mlx` | image → GLB, no HF login | +| **SF3D (fast draft)** | `sf3d` | image → textured GLB, seconds — good for quick garment blockouts | +| **Motion (MoMask)** | text → BVH motion (+mp4) — text-to-animation | | +| Upscale (SeedVR2), Brush 3DGS Train | — | polish / gaussian-splat | + +Copyable stdlib CLI: `~/Documents/MODELBEAST/mb` → `./mb run flux_local -p prompt=... --wait --download out/`. +Launch pattern: `export MB_TOKEN=$(grep '^MB_TOKEN=' ~/Documents/backnforth/.env | cut -d= -f2); python3 server.py`. + +### NPCFACTORY (ultra `~/Documents/NPCFACTORY`, 410M) — the further-along sibling ✓17 banks confirmed +- Same stdlib + **vendored** three.js house style; already has orbit stage + clip dropdown + + speed/height-normalise, click-a-part bone-attach dress-up, recipe JSON, in-UI Blender bank-builder. +- **17 finished rigged, walk-animated NPC GLBs** in `library/banks/` (tradie/cop/goth/nanna/postie/ + busdriver/bizdad… 7-25MB each) — instant dressed+animated characters to load. +- **The reskin trio** (E6 COMPLETE, all 3 confirmed on disk): `tools/render_plates.py` (bind-pose + front/back EEVEE ortho plates, 1024, transparent), `tools/align_plate.py`, `tools/bake_skin.py` + (Cycles DIFFUSE projection bake onto the rig's own UVs, front/back via `Normal.Y<0`). `archetype_batch.py` + = batch factory template. +- **Constraint:** reskin only works on **single-mesh / single-UVMap / single-material** bodies — + **John's nude TRELLIS-2 figures are exactly this class.** (`droidte2` is disqualified: multi-part, clothed.) + +### 90sDJsim (ultra `~/Documents/90sDJsim`) — the 2D wardrobe, already shipped ✓441 sprites confirmed +- **`web/world/media/doll/` = 441 transparent-PNG garment layers**, `_i_.png`. Runtime + compositor `doll.js` (704×1408, ORDER base→bottom→shoes→top→hat, bag last). `doll_layout.json` nudges. +- **Self-healing loader** `server.py:167-178`: `_art_slug = re.sub(r'[^a-z0-9]+','_',name.lower()).strip('_')`. + **Drop a PNG + restart = that garment wears its own art, zero code change.** +- **$0 generator:** `tools/{gen_wardrobe,doll_composite,doll_stage,build_wardrobe_manifest}.py` — prompt + → `flux_local` on **solid green** → `bg_remove_local` → stage onto 704×1408 via measured `SLOT_DEF` + anchors. Prompt template + 321 authored items + 432 reference JPGs exist. +- **Caveat:** the full doll pipeline is **ONLY on ultra**; the JING5 checkout is stripped → export-to-djsim + must rsync to ultra. + +### character_kit + character_kit_modular (ultra canonical `~/Documents/character_kit/`) +- Rigged mixamorig bases (65-bone) + already-rigged wearable props (`rigged/{w_beanie,w_felthat}.glb`, + `props/modular/{beanie,buckethat,felthat,boombox,camera}.glb`). +- `scripts/merge_anims.py` — base rig + N anim-only FBX → **one multi-clip GLB** (NLA tracks). +- `modular/scripts/`: `cut_parts.py` (cut a mixamorig char into swappable socket parts), `assemble.py` + (`attach_part`, `fit_part` cross-rig auto-scale, `attach_attachment` bbox-fit+bone-parent), + `texture_variants.py` (numpy hue-mask recolour). `parts_library/library.json` = the garment-registry + schema to copy. + +### Animation banks +- **ultra `~/Documents/MOTIONLIB/green/mixamo_full` = 2,394 anim-only mixamorig FBX** (1.3G, shippable + w/ attribution) + `cmu_bvh` 2,548 BVH + 100style/accad/kaykit/quaternius. +- **~163 local mixamorig clips** already on JING5+ultra — enough for a showcase set now, no downloads. + +### Garment donors + bodies +- **ultra `~/Documents/3D=models/characters/people-normal/` = ~40 clothed, mixamorig-rigged FBX** + (`man_suit_01`, `man_dj_streetwear_01`, `woman_raver_01`, `man_worker_hivis_01`…). **Exclude + `actorcore-unreal/` (RED, native-only).** +- Clean multi-LOD nude base: **m3ultra `~/Documents/trellis2-bench/anatomy_{2000,8000,30000,50000,800k}.glb`**. +- ultra `3d-from-m5/AUDIT.md`: of 4278 files, **43 are rigged + game-budget (≤10k tris)**. +- **fluxgod** (JING5) — `mb.py/a1111.py/openrouter.py` multi-lane client with restart-safe queue + + autocrop. Copy wholesale rather than re-implement CF-only. + +### Two honest fleet limits (do NOT build against these) +- **TRELLIS.2 is single-image** — do NOT build a "4-view sheet → TRELLIS" bridge; multi-view is only for + reskin plates / QC. +- **No live vision model** (m4pro Ollama = text `qwen2.5:7b` + `nomic-embed` only) → **auto-tagging + garments is NOT an existing capability** (needs a `qwen2.5-vl` pull or paid OpenRouter). MODELBEAST has + a local text LLM (Qwen3-30B) but not a captioner. +- **No garment/clothing LoRA exists** — styled-garment fidelity is prompt-only until `brush_train` makes one. + +--- + +## C. The plan — phased, each ending in a demo + +**Guiding principle:** the data model (§D) is cheap stdlib JSON — it goes in Phase 1 and every phase +populates it. Fast/proven engines first (reskin, try-on, 2D doll), quality-core cloth mid (donor +harvest), UI polish last. + +### Phase 0 — Unblock (½ day) +1. **Bug #2** (`server.py:67`): add `.png` to `garments` scan exts → cutouts/doll layers become visible. +2. **Bug #1** (`server.py:238-265`): call `allowed()` on the blender-op paths before any 0.0.0.0 exposure. +3. **Export `MB_TOKEN`** at launch (unlocks try-on, RMBG-2.0, trellis) — LaunchAgent or launcher script. +4. **Bug #4** (`server.py:156-157`): check content-type/magic bytes before writing a farm response; mark + the job `error` on a JSON body. + +### Phase 1 — "It dresses and it moves" (1 day) → dressed, animated character on screen +- **Track 1 — the stage moves:** add a **clip dropdown + play/pause/scrub** (the mixer exists at + `index.html:191-194`; there's no selector). Build clips via `character_kit/scripts/merge_anims.py` + (base rig + ~10 local clips → one multi-clip GLB, NLA tracks) → rsync to JING5. +- **Track 2 — it dresses:** load a rigged mixamorig base; rsync one NPCFACTORY bank NPC (already + dressed+animated) as the guaranteed floor. Attach a rigid hat (`character_kit/rigged/w_felthat.glb`) + via the existing bone-attach, but **make it persist** (POST an `outfit.attach` record, not just an + in-browser clone). Add runtime `material.color` colourway tinting (free). +- **Demo:** a rigged character **walks on screen wearing an attached hat**, clip dropdown switches + animations, a colour swatch retints it. +- **Stretch (starts the real nude-figure track):** kick off the first reskin bake on `phrtt2` (T-pose, + single-mesh/UV — ideal reskin class): `mirpamo rig phrtt2.glb` → `render_plates` → repaint → `bake_skin`. + +### Phase 2 — Reskin engine: paint clothes onto nude bodies (2-3 days) +Copy the NPCFACTORY trio (137 lines). New `POST /api/reskin`, mirroring `run_blender`+`job_thread`: +``` +1. blender -b -P render_plates.py -- body_rigged.glb plates/ → front.png, back.png +2. MB job mflux_image_edit asset_ids=[front.png, garment_cut.png] + "put the garment from image 2 onto the person in image 1, keep exact pose+silhouette, plain bg" +3. blender -b -P align_plate.py -- edited_front.png front.png aligned_front.png +4. blender -b -P bake_skin.py -- body_rigged.glb aligned_front.png aligned_back.png costume.png +5. three.js: material.map = TextureLoader().load(costume.png); needsUpdate=true +``` +A **costume dropdown** swaps `material.map` at runtime — instant, free, reversible toggling between +pre-baked outfits. +- **Demo:** pick a nude TRELLIS body → pick/generate a garment → walking clothed character; flip a + dropdown to change costumes instantly. +- **Ceiling (say it out loud):** the nude *silhouette never changes* — sells tight/printed garments & + uniforms & mid-distance figures, but a jacket reads as a jacket-print bodysuit. Hero silhouette + garments need Phase 4. + +### Phase 3 — The 2D paper-doll product + first export contract (2-3 days) +Adopt `90sDJsim/tools/{gen_wardrobe,doll_composite,doll_stage,build_wardrobe_manifest}.py` wholesale. +Emit `media/doll/_i_.png` honoring `_art_slug` exactly. Wire `qwen_layered_local` / +`bg_remove_local` for matting-grade cutouts (upgrading the crude local keyer). Import the **441 sprites** +as a starter pack. `POST /api/export {target:"djsim", outfit}` writing over rsync to ultra. +- **Demo:** generate a garment → it appears as a doll layer → **drops into 90sDJsim with zero consumer + code change** (self-healing `DOLL_ART`). Highest-ROI, lowest-risk value on the board. + +### Phase 4 — Quality-core: real 3D cloth via donor harvest + hide-masks (8-9 days) +New `blender_ops.py` op `harvest`: import a clothed mixamorig donor → `bpy.ops.mesh.separate(type= +'MATERIAL')` (**Blender copies all vertex groups onto the separated garment automatically — the whole +trick**) → purge empty groups → `attach_part` → `__-harvest.glb`. `/api/wear` runs +`assemble.py fit_part(anchor=mixamorig:Hips)`. License-gate: exclude `actorcore-unreal/`. +Batch the ~40 donors → **~80-150 pre-weighted staple garments** at $0. +- **Graft from proxy-standard:** author `hide_torso/hide_upperarm/hide_thigh/hide_shin/hide_foot/ + hide_scalp` vertex groups once, apply an inverted `MASK` modifier per garment's `hides` list — + **replacing the 3mm DISPLACE "puff" hack** (`blender_ops.py:122-124`) — so nude skin stops poking through. +- **Demo:** a real jacket (collar/cuff volume) on a mixamorig body **bending correctly through a walk + clip**, no skin poke-through. The only route that looks like a person in actual clothes. +- **Ceiling:** garments captive to donor proportions (clip/gap on dissimilar bodies), wardrobe + donor-bounded, 65-bone donor vs 22-bone MIRPAMO leaves cuff verts under-driven. + +### Phase 5 — Dressing Room UI + full export hub (1 week) +Rebuild `web/index.html` as a real dressing room: **vendor three.js r175** (drop the CDN), thumbnail +garment grid with slot tabs + tag search + license filter, live click-to-swap composing by *reference* +(never re-bake per click), a 2D-doll preview toggle (reuse `doll.js` verbatim), save/load/randomize +outfits, and an **export dropdown** (djsim | thriftgod | procity | not-tonight | glb). Add a `render_thumb` +Blender op (EEVEE 256×256 A-pose). Gate every export on `license != "red"` — the only actual enforcement +of "only dressed/green ships." + +--- + +## D. The data model (actual JSON — stdlib files, no DB) + +`library/index.json` written on every mutation, read once by the UI. Replaces `scan()` (which returns +only `{name,path,kb,home}` and drops PNGs). + +**Slots & layers** +``` +slot ∈ head | torso | legs | feet | hand_l | hand_r | accessory | bag +layer (draw/stack int; drives 3D normal-push AND 2D doll ORDER): + skin=0 underwear=10 legs=20 feet=25 torso=30 outer=40 head=50 accessory=60 bag=70 +``` + +**Garment record** (`library/garments/.json`) +```json +{ + "slug": "coogi-multi-knit", "title": "Coogi multicolour knit", + "slot": "torso", "layer": 30, "skeleton": "mixamorig", + "hides": ["hide_torso","hide_upperarm"], + "tags": ["knit","90s","multicolour","oversized"], "brand": "coogi-parody", + "license": "green", + "colorway": { "base": "#8a2b6d", "tintable": true }, + "reps": { + "flat_png": "garments/coogi-multi-knit.png", + "doll_layer": "garments/coogi-multi-knit.doll.png", + "sprite": "garments/coogi-multi-knit.sprite.png", + "glb": "garments/coogi-multi-knit-harvest.glb", + "skin_tex": "garments/coogi-multi-knit.reskin.png", + "thumb": "garments/coogi-multi-knit.thumb.png" + }, + "source": { "prompt": "...", "backend": "flux_local", "seed": 42 }, + "provenance": { "created": "2026-07-24T...", "donor": null } +} +``` + +**Outfit record** (`library/outfits/.json`) +```json +{ + "name": "raver-fit-01", "body": "bodies/phrtt2_rigged.glb", + "slots": { "torso":"coogi-multi-knit","legs":"baggy-jnco","feet":"doc-8eye","head":"bucket-hat" }, + "layerOrder":[ "legs","feet","torso","head","bag" ], + "anim": "wan_walk", + "attach": { "bucket-hat": { "bone":"mixamorig:Head","scale":1.0,"y":0.0,"z":0.0 } } +} +``` +The `attach` block **bakes the currently-ephemeral rigid-attach state** (`index.html:215-225`) so +hats/props survive reload + export. + +**Export contracts** (verified against each consumer's real loader) +| Target | Contract | Slot map | +|---|---|---| +| **90sDJsim / thriftgod doll** | write `reps.doll_layer` → `90sDJsim/web/world/media/doll/_i_.png`; `` = `re.sub(r'[^a-z0-9]+','_',name.lower()).strip('_')`. Self-healing `DOLL_ART` = drop file + restart. Rsync to ultra. | torso→top, legs→bottom, feet→shoes, head→hat, bag→bag | +| **thriftgod / procity racks** | emit `reps.flat_png` as `fittings.js garment({image})` plane texture / PROCITY `StockSlot{kind:'garment'}` | slot → rack `kind:'garment'` | +| **not-tonight** | emit `reps.sprite` (32×48, quantized to the 14-entry `outfits.ts PALETTE`) | head→hair/accessory, torso→top, outer→outer, legs→legs, feet→shoes | +| **3D GLB (hero/games)** | `assemble(body,{torso:garment})` or `fit` → `out/.glb` → 3GOD depot | native mixamorig | + +Schema is a **superset of** `character_kit_modular/parts_library/library.json` → parts flow between +benches (anti-silo, not a new competing format). + +--- + +## E. The 2D paper-doll track (first-class, not a fallback) +John explicitly has "2D paper cut-out" garments, they're the **cheapest content**, and `GODVERSE_MAP.md` +already recommends *"clothes cutouts on a hanger card OVER 3D garment mesh."* Mostly **adoption**, not building: +- Generator exists & is $0: `90sDJsim/tools/gen_wardrobe.py` — prompt → `flux_local` on **solid green** + → `bg_remove_local` → `doll_stage.py` onto 704×1408 via measured `SLOT_DEF` anchors. +- Battle-tested prompt (`build_wardrobe_manifest.py`): *"paper doll clothing item for a dress-up game, + ``, 1990 australia, ``, floating centered on a plain pure white background, painterly + stylized 3d game illustration, full item visible, no body, no person, no mannequin, no hanger, no text, + no logos."* Lift verbatim, incl. per-slot VIEW phrasing. +- Content on hand: **441 sprites + 321 items + 432 reference JPGs.** +- Upgrade: `qwen_layered_local` → RGBA layers = the literal "layered paper cut-out" rep, cleaner than the keyer. +- **Prerequisite: fix bug #2** or none of this is ever visible. +- **Honest scope:** the djsim doll dresses **one fixed 704×1408 front-view silhouette** — it **cannot** + dress arbitrary 3D bodies. The dressing room is two products sharing one library: a 2D doll authoring + view (djsim/not-tonight) and a 3D stage (reskin/harvest/socket). Toggle honestly; never pretend a flat + sprite is dressing the 3D body. + +--- + +## F. Open questions (only John can answer) +1. **wardrobegod vs NPCFACTORY** — keep wardrobegod as the JING5 library/authoring/export hub and *borrow* + NPCFACTORY's stage+banks (plan assumes this), or physically **merge** the two? +2. **Canonical nude base + consent** — rig/reskin target: mint from `trellis2-bench/anatomy_8000.glb`, use + `phrtt2` (ideal reskin class), or the 43 rigged game-budget set? Hard `license!="red"` export gate? +3. **`MB_TOKEN` persistence** — OK to add a LaunchAgent / boot-time read from `backnforth/.env`? +4. **Where the canonical library physically lives** — JING5 (author), ultra (doll pipeline runs), or 3GOD depot? +5. **First export target** — 90sDJsim (biggest existing 2D consumer, zero-code drop-in) or 3D-in-thriftgod? +6. **Hero garments** — accept painted-on reskin for crowd/mid, donor-harvest for staples; invest in + socket-torso swap for true hero couture (bigger build, not in this plan)? +7. **Garment LoRA** — curate a set + `brush_train` for on-brand 90s Aussie op-shop garments, or prompt-only for now? + +--- + +## G. Risks & what will look bad (no sugar-coating) +- **Reskin is a painted-on illusion** — zero added geometry; unchanged nude silhouette + a jacket *print* + is a dead giveaway at close range; can't layer, can't drape; re-bake to change (runtime swap only + toggles *already-baked* costumes). Single-mesh/UV bodies only. +- **Virtual try-on is a 2D still** — `mflux_image_edit` gives a believable clothed image in ~2 min (great + for browsing) but doesn't animate and drifts identity/pose. Preview surface, not hero render. +- **Donor cloth is proportion-captive** — weights transfer perfectly, but a suit molded to `man_suit_01` + floats/clips on a slim TRELLIS figure (no cloth sim). Wardrobe is donor-bounded. 65-bone vs 22-bone + leaves cuffs under-driven. Unknown fraction of donors are single-atlas (skin+cloth fused) → won't + separate cleanly — real number unknown until triage runs. +- **3D fit on unstructured topology is per-body & clips** — no shared topology → fitted garment doesn't + transfer across bodies; expect clipping/sleeve mismatch on non-A-pose bodies; MIRPAMO can candy-wrap + joints on non-manifold soup. +- **The 2D doll can't dress the real 3D bodies** — one fixed front-view silhouette only. +- **Generation fidelity is prompt-only** (no garment LoRA) — exotic garments hit-or-miss. +- **Export writes into live game dirs** — needs dry-run + `license!="red"` gate + write only + `_i_.png` (never overwrite generic fallbacks). +- **Overlap with NPCFACTORY is real** — build this as the library/export layer NPCFACTORY lacks, not a + third isolated dress-up stage. +- **Auto-tagging is not available today** (no live vision model) — treat as manual or paid-OpenRouter. +- **Farm GPU = 1 job/node** — batch gen queues; push flux/RMBG to the 2nd MODELBEAST node on ultra. + +**One-liner for John:** by end of week you get a real "change clothes + walk" button (reskin + rigid +attach + clip playback) and a best-in-class 2D wardrobe that drops straight into 90sDJsim; good-looking +real-cloth 3D comes in Phase 4 from harvesting donors you already own; and the thing you'll use every day +— the dressing-room library + export hub — is the spine that turns every engine into value for five +existing consumers. diff --git a/server.py b/server.py index dfe93a8..a03fa02 100644 --- a/server.py +++ b/server.py @@ -18,37 +18,46 @@ What it does: 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 +import hashlib, 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')} +OUTFITS = os.path.join(LIB, 'outfits') # saved dress-ups: body + attachments + clip + tints 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') -# .env beside server.py (gitignored) — CF Workers AI creds live here, lifted from -# backnforth/.env on ultra (the proven flux.mjs contract). Never printed, never committed. -_envp = os.path.join(ROOT, '.env') -if os.path.exists(_envp): - for _l in open(_envp): - if '=' in _l and not _l.startswith('#'): - k, v = _l.split('=', 1) - os.environ.setdefault(k.strip(), v.strip()) +# Creds come off disk, never off the command line. .env beside server.py (gitignored) holds the +# CF Workers AI pair; MB_TOKEN lives in backnforth/.env — read it there rather than making every +# launch remember `export MB_TOKEN=…` (without it the farm tiers silently die: no RMBG-2.0, no +# image-edit try-on, no trellis). First file to define a key wins, so a local .env always overrides. +def _load_env(path): + if not os.path.exists(path): + return + for line in open(path): + if '=' in line and not line.lstrip().startswith('#'): + k, v = line.split('=', 1) + os.environ.setdefault(k.strip(), v.strip().strip('"').strip("'")) + + +_load_env(os.path.join(ROOT, '.env')) +_load_env(os.path.expanduser('~/Documents/backnforth/.env')) MB = os.environ.get('MB_HOST', 'http://100.89.131.57:8777') MB_TOKEN = os.environ.get('MB_TOKEN', '') CF_ACCT = os.environ.get('CLOUDFLARE_ACCOUNT_ID', '') CF_TOKEN = os.environ.get('CLOUDFLARE_API_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]: +for d in list(DIRS.values()) + [CACHE, OUTFITS]: os.makedirs(d, exist_ok=True) JOBS = {} # id → {status, note, out, log} MODEL_EXT = ('.glb', '.gltf', '.fbx', '.obj') +IMG_EXT = ('.png', '.jpg', '.jpeg', '.webp') def slug(s): @@ -64,7 +73,10 @@ def allowed(path): def scan(): out = {} for key, d in DIRS.items(): - exts = MODEL_EXT if key in ('bodies', 'garments', 'out') else ('.png', '.jpg', '.webp') + # garments hold BOTH kinds: fitted/harvested GLBs and flat cut-out PNGs (the paper-doll + # tier). Listing models only used to hide every /api/hangable output — the whole 2D route + # dead-ended in a directory the UI refused to show. + exts = MODEL_EXT + IMG_EXT if key == 'garments' else MODEL_EXT if key in ('bodies', 'out') else IMG_EXT rows = [] dirs = [d] + (EXTRA_BODIES if key == 'bodies' else []) for dd in dirs: @@ -74,37 +86,60 @@ def scan(): 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)}) + 'home': os.path.basename(dd), + 'kind': 'img' if f.lower().endswith(IMG_EXT) else 'model'}) out[key] = rows + out['outfits'] = sorted(f[:-5] for f in os.listdir(OUTFITS) if f.endswith('.json')) return out -def run_blender(args, jid): - """One headless Blender op; stdout tail lands in the job log.""" +def run_blender(args, jid=None): + """One headless Blender op; stdout tail lands in the job log (when there is a job).""" 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 jid and jid in JOBS: + 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.""" + """FBX/OBJ → cached GLB (stage + ops always speak GLB); GLB passes through. + + The cache key carries a hash of the full source path: keying on the basename alone let + two different files that slug the same (anatomy/jacket_v2.fbx vs garments/jacket-v2.fbx) + collide onto one cache entry and serve each other's geometry. + """ if path.lower().endswith(('.glb', '.gltf')): return path - tgt = os.path.join(CACHE, slug(os.path.basename(path)) + '.glb') + src = os.path.realpath(path) + key = slug(os.path.basename(path)) + '-' + hashlib.sha1(src.encode()).hexdigest()[:8] + tgt = os.path.join(CACHE, key + '.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))) + run_blender(['convert', path, tgt], jid) return tgt def new_job(kind, note): + reap() jid = uuid.uuid4().hex[:10] JOBS[jid] = {'status': 'running', 'kind': kind, 'note': note, 'out': None, 'log': '', 't': time.time()} return jid +def reap(max_age=3600, keep=200): + """Drop finished jobs the page has long stopped polling — JOBS never pruned itself.""" + now = time.time() + dead = [k for k, v in list(JOBS.items()) + if v.get('status') in ('done', 'error') and now - v.get('t', now) > max_age] + for k in dead: + JOBS.pop(k, None) + if len(JOBS) > keep: + for k, _ in sorted(JOBS.items(), key=lambda kv: kv[1].get('t', 0))[:len(JOBS) - keep]: + JOBS.pop(k, None) + + def job_thread(jid, fn): def go(): try: @@ -153,8 +188,12 @@ def mb_outputs(job_id): def mb_download(asset, tgt): aid = asset.get('id') or asset.get('asset_id') data = mb_req(f'/api/assets/{aid}/file') + if not isinstance(data, bytes): + # mb_req parses any JSON-looking 200 into a dict. Writing that dict into a .png/.glb + # used to succeed silently and mark the job done — you got a "mesh" full of error JSON. + raise RuntimeError(f'farm returned no file for asset {aid}: {str(data)[:200]}') with open(tgt, 'wb') as f: - f.write(data if isinstance(data, bytes) else json.dumps(data).encode()) + f.write(data) return tgt @@ -197,6 +236,11 @@ class H(BaseHTTPRequestHandler): if u.path.startswith('/api/job/'): jid = u.path.rsplit('/', 1)[1] return self.j(JOBS.get(jid) or {'status': 'unknown'}) + if u.path.startswith('/api/outfit/'): + f = os.path.join(OUTFITS, slug(u.path.rsplit('/', 1)[1]) + '.json') + if not os.path.isfile(f): + return self.j({'error': 'no such outfit'}, 404) + return self.j(json.load(open(f))) if u.path == '/file': p = q.get('p', '') if not allowed(p) or not os.path.isfile(p): @@ -221,7 +265,7 @@ class H(BaseHTTPRequestHandler): 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')): + if to not in DIRS or not name.lower().endswith(MODEL_EXT + IMG_EXT): return self.j({'error': 'bad target'}, 400) p = os.path.join(DIRS[to], name) with open(p, 'wb') as f: @@ -230,9 +274,34 @@ class H(BaseHTTPRequestHandler): body = json.loads(raw or b'{}') + if u.path == '/api/outfit': # save a dress-up: body + attachments + clip + tints + # The rigid-attach sliders used to live only in browser memory ("preview only") — every + # hat placement died on reload. This is the persistence half of that tier. + name = slug(body.get('name') or 'outfit') + b = body.get('body') or '' + if not allowed(b) or not os.path.isfile(b): + return self.j({'error': 'body outside library'}, 403) + rec = {'name': name, 'body': b, 'anim': body.get('anim'), + 'attach': body.get('attach') or {}, 'tint': body.get('tint') or {}, + 'saved': time.strftime('%Y-%m-%dT%H:%M:%S')} + for att in rec['attach'].values(): + if not allowed(att.get('path', '')): + return self.j({'error': 'attachment outside library'}, 403) + with open(os.path.join(OUTFITS, name + '.json'), 'w') as f: + json.dump(rec, f, indent=2) + return self.j({'ok': True, 'name': name}) + if u.path == '/api/blender': # {op, args:{...}} → background Blender job op = body.get('op') a = body.get('args', {}) + # Every other endpoint gates its path args through allowed(); this one never did, so + # any tailnet peer could hand WG_HOST=0.0.0.0 an arbitrary on-disk file to import. + ins = [a[k] for k in ('path', 'body', 'garment') if a.get(k)] + list(a.get('garments') or []) + if not ins: + return self.j({'error': 'no input path'}, 400) + for p in ins: + if not allowed(p) or not os.path.isfile(p): + return self.j({'error': 'path outside library: ' + os.path.basename(str(p))}, 403) jid = new_job(op, body.get('note', op)) def fx(jid): diff --git a/web/index.html b/web/index.html index ba3c8fc..cbaf1b3 100644 --- a/web/index.html +++ b/web/index.html @@ -34,6 +34,21 @@ #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 }
@@ -56,6 +71,14 @@
pick a body…
+
+ + + + 0.0s + +
+
How clothing works here (3 tiers)
    @@ -94,6 +117,10 @@
    +
    +
    swatch retints the last attached item (free colourways — no re-gen).
    +
    +

    deforming fit (blender)

    @@ -144,6 +171,7 @@ 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'); @@ -160,7 +188,17 @@ function resize() { 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); })(); +(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)); @@ -188,10 +226,13 @@ async function setBody(item) { 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(); - } + // 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) => ``).join('') + : ''; + $('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 = `${item.name} · ${Math.round(tris).toLocaleString()} tris · ${h}m tall · ` + @@ -200,10 +241,36 @@ async function setBody(item) { } 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)) return; // textures: no 3D preview + 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); @@ -216,14 +283,62 @@ $('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 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 to ${bone.name} — preview only (bake via fleet /rig for keeps)`); + 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 => ``).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()); @@ -244,12 +359,17 @@ function renderLists() { $('garments').innerHTML = (LIB.garments || []).map(i => rowHtml(i, 'garments')).join('') || '
    none yet — generate one →
    '; $('genLib').innerHTML = (LIB.gen || []).map(i => rowHtml(i, 'gen')).join('') || '
    nothing generated yet
    '; $('outs').innerHTML = (LIB.out || []).map(i => rowHtml(i, 'out')).join('') || '
    no outfits assembled yet
    '; + // NB: deliberately NOT class="item" — the generic .item handler below would clobber this one + $('savedFits').innerHTML = (LIB.outfits || []).length + ? (LIB.outfits || []).map(n => ``).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)); 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…';