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:
parent
6f32c08aa9
commit
b1381d22d2
377
STATE_AND_PLAN.md
Normal file
377
STATE_AND_PLAN.md
Normal 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
109
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):
|
||||
|
||||
136
web/index.html
136
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 }
|
||||
</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…';
|
||||
|
||||
Loading…
Reference in New Issue
Block a user