Phase 0+1: unblock the bench, then make it dress and move

Phase 0 — the fixes that were blocking every tier:
· /api/blender had no allowed() guard, unlike every other path-taking endpoint. Under the
  documented WG_HOST=0.0.0.0 that was an unauthenticated arbitrary-file -> Blender import +
  library-write for any tailnet peer. Now gated on allowed() + isfile.
· scan() listed garments as models only, but /api/hangable writes .png there — so every
  cut-out the generator produced was invisible in its own UI and the whole 2D paper-doll
  route dead-ended. garments now lists models AND images.
· mb_download() wrote json.dumps(data) whenever the farm response wasn't bytes, and mb_req
  parses any JSON-200 into a dict — so a farm error body got written *into* the target
  .png/.glb and the job was still marked done. Now raises instead.
· MB_TOKEN is read off disk from backnforth/.env, so the farm tiers (RMBG-2.0, image-edit
  try-on, trellis) stop silently dying when a launch forgets to export it.
· glb_of() keyed its cache on the basename alone, so two sources that slug the same served
  each other's geometry; the key now carries a hash of the full path.
· JOBS grew forever and glb_of minted an orphan job on every cache miss inside GET /glb.
  run_blender() now tolerates jid=None and finished jobs get reaped.

Phase 1 — a dressed, animated character on screen:
· clip transport: the mixer used to blind-play animations[0] with no way to reach the rest.
  Clip dropdown + play/pause + scrub + speed, with the scrub head following playback.
· rigid attach stopped being a lie. It cloned into three.js and POSTed nothing ("preview
  only"), so every hat placement died on reload. Attachments now carry their bone/offset/
  tint and persist via /api/outfit -> library/outfits/<name>.json, reloadable from a chip.
· colourway swatches retint the last attached item (materials are cloned per instance so a
  tint doesn't bleed), giving free variants with no re-generation.
· flat cut-out garments get a 2D preview pane — the paper-doll tier had no way to be seen.

Verified end-to-end in the browser: tradie_at_wanwalk (22-bone mixamorig) walks wearing a
red-tinted felt hat on mixamorigHead, saved, page reloaded, restored from disk.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-24 15:43:02 +10:00
parent 6f32c08aa9
commit b1381d22d2
3 changed files with 594 additions and 28 deletions

377
STATE_AND_PLAN.md Normal file
View File

@ -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**, `<slot>_i_<slug>.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/<slot>_i_<slug>.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``<donor>__<material>-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/<slug>.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/<name>.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/<slot>_i_<slug>.png`; `<slug>` = `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/<outfit>.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,
`<desc>`, 1990 australia, `<view>`, 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
`<slot>_i_<slug>.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.

109
server.py
View File

@ -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):

View File

@ -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 }
</style>
<div id="app">
<header>
@ -56,6 +71,14 @@
<div id="stage">
<div id="hud">pick a body…</div>
<div id="transport">
<button id="playBtn" class="ghost">❚❚</button>
<select id="clipSel"><option>— no clips —</option></select>
<input id="scrub" type="range" min="0" max="1000" value="0" step="1">
<span id="clipTime" class="sub">0.0s</span>
<label class="sub">speed</label><input id="speed" type="range" min="0" max="2" step="0.05" value="1">
</div>
<img id="flatPrev" alt="">
<div id="how">
<b>How clothing works here (3 tiers)</b>
<ol>
@ -94,6 +117,10 @@
<div class="row"><label>up/down</label><input id="atY" type="range" min="-0.5" max="0.5" step="0.005" value="0.08"></div>
<div class="row"><label>fwd/back</label><input id="atZ" type="range" min="-0.5" max="0.5" step="0.005" value="0"></div>
<div class="row"><button id="attachBtn" class="ghost">attach selected</button><button id="clearAtBtn" class="ghost">clear</button></div>
<div class="swatches" id="tintSw"></div>
<div class="note">swatch retints the last attached item (free colourways — no re-gen).</div>
<div class="row"><input id="fitName" placeholder="save dress-up as…"><button id="saveFitBtn" class="ghost">save</button></div>
<div id="savedFits" class="note"></div>
<h2>deforming fit (blender)</h2>
<div class="row"><label>inflate mm</label><input id="fitInf" value="3"></div>
<div class="row">
@ -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) => `<option value="${i}">${c.name || 'clip ' + (i + 1)}</option>`).join('')
: '<option>— no clips —</option>';
$('transport').classList.toggle('on', clips.length > 0);
if (clips.length) { mixer = new THREE.AnimationMixer(bodyRoot); playClip(0); }
const box = new THREE.Box3().setFromObject(bodyRoot);
const h = (box.max.y - box.min.y).toFixed(2);
$('hud').innerHTML = `<b>${item.name}</b> · ${Math.round(tris).toLocaleString()} tris · ${h}m tall · ` +
@ -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 => `<button class="sw" data-c="${c}" style="background:${c}"></button>`).join('');
$('tintSw').onclick = e => {
const b = e.target.closest('.sw'); if (!b) return;
const tgt = attached[attached.length - 1];
if (!tgt) return toast('attach something first, then pick a colour');
document.querySelectorAll('.sw').forEach(s => s.classList.toggle('on', s === b));
tgt.traverse(o => { if (o.isMesh && o.material && o.material.color) o.material.color.set(b.dataset.c); });
tgt.userData.wg.tint = b.dataset.c;
toast('tinted ' + (tgt.userData.wg.name || '') + ' → ' + b.dataset.c);
};
// ---------- save / load a dress-up ----------
$('saveFitBtn').onclick = async () => {
if (!BODY) return toast('pick a body first');
const attach = {};
attached.forEach((a, i) => { const w = a.userData.wg || {}; attach[(w.name || 'item') + '#' + i] = w; });
const r = await fetch('/api/outfit', { method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: $('fitName').value || 'dressup', body: BODY.path,
anim: clips[+$('clipSel').value]?.name, attach }) }).then(r => r.json());
toast(r.error ? '✗ ' + r.error : `✓ saved dress-up "${r.name}" (${Object.keys(attach).length} item/s)`);
refresh();
};
async function loadFit(name) {
const f = await fetch('/api/outfit/' + encodeURIComponent(name)).then(r => r.json());
if (f.error) return toast('✗ ' + f.error);
const bodyItem = (LIB.bodies || []).find(b => b.path === f.body) || { name: f.body.split('/').pop(), path: f.body };
await setBody(bodyItem);
for (const w of Object.values(f.attach || {})) { // re-hang every saved attachment
const bone = bones.find(b => b.name === w.bone); if (!bone) continue;
try {
const g = await loadGlb(w.path);
const inst = g.scene;
inst.scale.setScalar(w.scale || 1); inst.position.set(0, w.y || 0, w.z || 0);
inst.traverse(o => { if (o.isMesh && o.material) { o.material = o.material.clone();
if (w.tint && o.material.color) o.material.color.set(w.tint); } });
inst.userData.wg = w; bone.add(inst); attached.push(inst);
} catch (e) { /* missing attachment file — skip it, the rest of the fit still loads */ }
}
if (f.anim) { const i = clips.findIndex(c => c.name === f.anim); if (i >= 0) playClip(i); }
toast(`✓ loaded "${name}" — ${attached.length} item/s`);
}
// ---------- library ----------
async function refresh() {
const r = await fetch('/api/lib').then(r => r.json());
@ -244,12 +359,17 @@ function renderLists() {
$('garments').innerHTML = (LIB.garments || []).map(i => rowHtml(i, 'garments')).join('') || '<div class="note">none yet — generate one →</div>';
$('genLib').innerHTML = (LIB.gen || []).map(i => rowHtml(i, 'gen')).join('') || '<div class="note">nothing generated yet</div>';
$('outs').innerHTML = (LIB.out || []).map(i => rowHtml(i, 'out')).join('') || '<div class="note">no outfits assembled yet</div>';
// NB: deliberately NOT class="item" — the generic .item handler below would clobber this one
$('savedFits').innerHTML = (LIB.outfits || []).length
? (LIB.outfits || []).map(n => `<button class="ghost fitchip" data-fit="${n}">${n}</button>`).join('')
: 'no saved dress-ups yet';
document.querySelectorAll('.item').forEach(el => el.onclick = () => {
const it = { name: el.querySelector('.nm').textContent, path: el.dataset.p };
if (el.dataset.k === 'bodies') setBody(it);
else if (el.dataset.k === 'gen') { GENPICK = it.path; $('genPrev').src = '/file?p=' + encodeURIComponent(it.path); $('genPrev').style.display = 'block'; $('rmbgBtn').disabled = false; $('to3dBtn').disabled = $('hangBtn').disabled = !it.path.endsWith('-cut.png'); }
else previewGarment(it);
});
document.querySelectorAll('.fitchip').forEach(el => el.onclick = () => loadFit(el.dataset.fit));
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…';