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.
|
||||||
107
server.py
107
server.py
@ -18,37 +18,46 @@ What it does:
|
|||||||
Env: MB_HOST (default m3ultra :8777) · MB_TOKEN (bearer; generator disabled without it)
|
Env: MB_HOST (default m3ultra :8777) · MB_TOKEN (bearer; generator disabled without it)
|
||||||
WG_PORT (8150) · WG_HOST (127.0.0.1)
|
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
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
|
||||||
ROOT = os.path.dirname(os.path.abspath(__file__))
|
ROOT = os.path.dirname(os.path.abspath(__file__))
|
||||||
LIB = os.path.join(ROOT, 'library')
|
LIB = os.path.join(ROOT, 'library')
|
||||||
DIRS = {'bodies': os.path.join(LIB, 'bodies'), 'garments': os.path.join(LIB, 'garments'),
|
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')}
|
'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'),
|
EXTRA_BODIES = [os.path.expanduser('~/Documents/anatomy'),
|
||||||
os.path.expanduser('~/Documents/thriftgod/web/assets/models')]
|
os.path.expanduser('~/Documents/thriftgod/web/assets/models')]
|
||||||
CACHE = os.path.join(ROOT, '.glbcache')
|
CACHE = os.path.join(ROOT, '.glbcache')
|
||||||
BLENDER = os.environ.get('WG_BLENDER', '/Applications/Blender.app/Contents/MacOS/Blender')
|
BLENDER = os.environ.get('WG_BLENDER', '/Applications/Blender.app/Contents/MacOS/Blender')
|
||||||
OPS = os.path.join(ROOT, 'blender_ops.py')
|
OPS = os.path.join(ROOT, 'blender_ops.py')
|
||||||
# .env beside server.py (gitignored) — CF Workers AI creds live here, lifted from
|
# Creds come off disk, never off the command line. .env beside server.py (gitignored) holds the
|
||||||
# backnforth/.env on ultra (the proven flux.mjs contract). Never printed, never committed.
|
# CF Workers AI pair; MB_TOKEN lives in backnforth/.env — read it there rather than making every
|
||||||
_envp = os.path.join(ROOT, '.env')
|
# launch remember `export MB_TOKEN=…` (without it the farm tiers silently die: no RMBG-2.0, no
|
||||||
if os.path.exists(_envp):
|
# image-edit try-on, no trellis). First file to define a key wins, so a local .env always overrides.
|
||||||
for _l in open(_envp):
|
def _load_env(path):
|
||||||
if '=' in _l and not _l.startswith('#'):
|
if not os.path.exists(path):
|
||||||
k, v = _l.split('=', 1)
|
return
|
||||||
os.environ.setdefault(k.strip(), v.strip())
|
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 = os.environ.get('MB_HOST', 'http://100.89.131.57:8777')
|
||||||
MB_TOKEN = os.environ.get('MB_TOKEN', '')
|
MB_TOKEN = os.environ.get('MB_TOKEN', '')
|
||||||
CF_ACCT = os.environ.get('CLOUDFLARE_ACCOUNT_ID', '')
|
CF_ACCT = os.environ.get('CLOUDFLARE_ACCOUNT_ID', '')
|
||||||
CF_TOKEN = os.environ.get('CLOUDFLARE_API_TOKEN', '')
|
CF_TOKEN = os.environ.get('CLOUDFLARE_API_TOKEN', '')
|
||||||
PORT = int(os.environ.get('WG_PORT', 8150))
|
PORT = int(os.environ.get('WG_PORT', 8150))
|
||||||
HOST = os.environ.get('WG_HOST', '127.0.0.1')
|
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)
|
os.makedirs(d, exist_ok=True)
|
||||||
|
|
||||||
JOBS = {} # id → {status, note, out, log}
|
JOBS = {} # id → {status, note, out, log}
|
||||||
MODEL_EXT = ('.glb', '.gltf', '.fbx', '.obj')
|
MODEL_EXT = ('.glb', '.gltf', '.fbx', '.obj')
|
||||||
|
IMG_EXT = ('.png', '.jpg', '.jpeg', '.webp')
|
||||||
|
|
||||||
|
|
||||||
def slug(s):
|
def slug(s):
|
||||||
@ -64,7 +73,10 @@ def allowed(path):
|
|||||||
def scan():
|
def scan():
|
||||||
out = {}
|
out = {}
|
||||||
for key, d in DIRS.items():
|
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 = []
|
rows = []
|
||||||
dirs = [d] + (EXTRA_BODIES if key == 'bodies' else [])
|
dirs = [d] + (EXTRA_BODIES if key == 'bodies' else [])
|
||||||
for dd in dirs:
|
for dd in dirs:
|
||||||
@ -74,37 +86,60 @@ def scan():
|
|||||||
if f.lower().endswith(exts):
|
if f.lower().endswith(exts):
|
||||||
p = os.path.join(dd, f)
|
p = os.path.join(dd, f)
|
||||||
rows.append({'name': f, 'path': p, 'kb': os.path.getsize(p) // 1024,
|
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[key] = rows
|
||||||
|
out['outfits'] = sorted(f[:-5] for f in os.listdir(OUTFITS) if f.endswith('.json'))
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
def run_blender(args, jid):
|
def run_blender(args, jid=None):
|
||||||
"""One headless Blender op; stdout tail lands in the job log."""
|
"""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]
|
cmd = [BLENDER, '-b', '--python', OPS, '--'] + [str(a) for a in args]
|
||||||
r = subprocess.run(cmd, capture_output=True, text=True, timeout=1800)
|
r = subprocess.run(cmd, capture_output=True, text=True, timeout=1800)
|
||||||
tail = '\n'.join((r.stdout + r.stderr).strip().splitlines()[-12:])
|
tail = '\n'.join((r.stdout + r.stderr).strip().splitlines()[-12:])
|
||||||
|
if jid and jid in JOBS:
|
||||||
JOBS[jid]['log'] = tail
|
JOBS[jid]['log'] = tail
|
||||||
if r.returncode != 0 or 'Error' in r.stderr:
|
if r.returncode != 0 or 'Error' in r.stderr:
|
||||||
raise RuntimeError(tail[-400:])
|
raise RuntimeError(tail[-400:])
|
||||||
|
|
||||||
|
|
||||||
def glb_of(path, jid=None):
|
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')):
|
if path.lower().endswith(('.glb', '.gltf')):
|
||||||
return path
|
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):
|
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
|
return tgt
|
||||||
|
|
||||||
|
|
||||||
def new_job(kind, note):
|
def new_job(kind, note):
|
||||||
|
reap()
|
||||||
jid = uuid.uuid4().hex[:10]
|
jid = uuid.uuid4().hex[:10]
|
||||||
JOBS[jid] = {'status': 'running', 'kind': kind, 'note': note, 'out': None, 'log': '', 't': time.time()}
|
JOBS[jid] = {'status': 'running', 'kind': kind, 'note': note, 'out': None, 'log': '', 't': time.time()}
|
||||||
return jid
|
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 job_thread(jid, fn):
|
||||||
def go():
|
def go():
|
||||||
try:
|
try:
|
||||||
@ -153,8 +188,12 @@ def mb_outputs(job_id):
|
|||||||
def mb_download(asset, tgt):
|
def mb_download(asset, tgt):
|
||||||
aid = asset.get('id') or asset.get('asset_id')
|
aid = asset.get('id') or asset.get('asset_id')
|
||||||
data = mb_req(f'/api/assets/{aid}/file')
|
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:
|
with open(tgt, 'wb') as f:
|
||||||
f.write(data if isinstance(data, bytes) else json.dumps(data).encode())
|
f.write(data)
|
||||||
return tgt
|
return tgt
|
||||||
|
|
||||||
|
|
||||||
@ -197,6 +236,11 @@ class H(BaseHTTPRequestHandler):
|
|||||||
if u.path.startswith('/api/job/'):
|
if u.path.startswith('/api/job/'):
|
||||||
jid = u.path.rsplit('/', 1)[1]
|
jid = u.path.rsplit('/', 1)[1]
|
||||||
return self.j(JOBS.get(jid) or {'status': 'unknown'})
|
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':
|
if u.path == '/file':
|
||||||
p = q.get('p', '')
|
p = q.get('p', '')
|
||||||
if not allowed(p) or not os.path.isfile(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
|
if u.path == '/api/upload': # file input → library dir
|
||||||
to, name = q.get('to', 'bodies'), os.path.basename(q.get('name', 'upload.glb'))
|
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)
|
return self.j({'error': 'bad target'}, 400)
|
||||||
p = os.path.join(DIRS[to], name)
|
p = os.path.join(DIRS[to], name)
|
||||||
with open(p, 'wb') as f:
|
with open(p, 'wb') as f:
|
||||||
@ -230,9 +274,34 @@ class H(BaseHTTPRequestHandler):
|
|||||||
|
|
||||||
body = json.loads(raw or b'{}')
|
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
|
if u.path == '/api/blender': # {op, args:{...}} → background Blender job
|
||||||
op = body.get('op')
|
op = body.get('op')
|
||||||
a = body.get('args', {})
|
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))
|
jid = new_job(op, body.get('note', op))
|
||||||
|
|
||||||
def fx(jid):
|
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);
|
#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 }
|
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 }
|
#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>
|
</style>
|
||||||
<div id="app">
|
<div id="app">
|
||||||
<header>
|
<header>
|
||||||
@ -56,6 +71,14 @@
|
|||||||
|
|
||||||
<div id="stage">
|
<div id="stage">
|
||||||
<div id="hud">pick a body…</div>
|
<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">
|
<div id="how">
|
||||||
<b>How clothing works here (3 tiers)</b>
|
<b>How clothing works here (3 tiers)</b>
|
||||||
<ol>
|
<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>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"><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="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>
|
<h2>deforming fit (blender)</h2>
|
||||||
<div class="row"><label>inflate mm</label><input id="fitInf" value="3"></div>
|
<div class="row"><label>inflate mm</label><input id="fitInf" value="3"></div>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
@ -144,6 +171,7 @@ import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
|
|||||||
const $ = id => document.getElementById(id);
|
const $ = id => document.getElementById(id);
|
||||||
let LIB = {}, BODY = null, GARM = null, bodyRoot = null, garmRoot = null, mixer = null, clock = new THREE.Clock();
|
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 bones = [], attached = [], picked = new Set(); // picked = fitted garments ticked for assemble
|
||||||
|
let clips = [], action = null, playing = true, scrubbing = false; // clip bank + current action
|
||||||
|
|
||||||
// ---------- stage ----------
|
// ---------- stage ----------
|
||||||
const stage = $('stage');
|
const stage = $('stage');
|
||||||
@ -160,7 +188,17 @@ function resize() {
|
|||||||
renderer.setSize(w, h); camera.aspect = w / h; camera.updateProjectionMatrix();
|
renderer.setSize(w, h); camera.aspect = w / h; camera.updateProjectionMatrix();
|
||||||
}
|
}
|
||||||
new ResizeObserver(resize).observe(stage);
|
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 loader = new GLTFLoader();
|
||||||
const loadGlb = p => new Promise((res, rej) => loader.load('/glb?p=' + encodeURIComponent(p), res, undefined, rej));
|
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()));
|
const b = bones.find(b => b.name.toLowerCase().includes(want.toLowerCase()));
|
||||||
if (b) { $('boneSel').value = b.name; break; }
|
if (b) { $('boneSel').value = b.name; break; }
|
||||||
}
|
}
|
||||||
if (g.animations.length) {
|
// clip bank: the mixer used to blind-play animations[0] with no way to pick another.
|
||||||
mixer = new THREE.AnimationMixer(bodyRoot);
|
clips = g.animations || []; action = null; mixer = null;
|
||||||
mixer.clipAction(g.animations[0]).play();
|
$('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 box = new THREE.Box3().setFromObject(bodyRoot);
|
||||||
const h = (box.max.y - box.min.y).toFixed(2);
|
const h = (box.max.y - box.min.y).toFixed(2);
|
||||||
$('hud').innerHTML = `<b>${item.name}</b> · ${Math.round(tris).toLocaleString()} tris · ${h}m tall · ` +
|
$('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); }
|
} 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) {
|
async function previewGarment(item) {
|
||||||
GARM = item; renderLists();
|
GARM = item; renderLists();
|
||||||
if (garmRoot) { scene.remove(garmRoot); garmRoot = null; }
|
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 {
|
try {
|
||||||
const g = await loadGlb(item.path);
|
const g = await loadGlb(item.path);
|
||||||
garmRoot = g.scene; scene.add(garmRoot);
|
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');
|
if (!garmRoot || !bones.length) return toast('need a rigged body + a 3D garment selected');
|
||||||
const bone = bones.find(b => b.name === $('boneSel').value);
|
const bone = bones.find(b => b.name === $('boneSel').value);
|
||||||
if (!bone) return toast('pick a bone');
|
if (!bone) return toast('pick a bone');
|
||||||
const inst = garmRoot.clone();
|
const inst = garmRoot.clone(true);
|
||||||
const s = +$('atScale').value;
|
const s = +$('atScale').value;
|
||||||
inst.scale.setScalar(s); inst.position.set(0, +$('atY').value, +$('atZ').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);
|
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 = []; };
|
$('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 ----------
|
// ---------- library ----------
|
||||||
async function refresh() {
|
async function refresh() {
|
||||||
const r = await fetch('/api/lib').then(r => r.json());
|
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>';
|
$('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>';
|
$('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>';
|
$('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 = () => {
|
document.querySelectorAll('.item').forEach(el => el.onclick = () => {
|
||||||
const it = { name: el.querySelector('.nm').textContent, path: el.dataset.p };
|
const it = { name: el.querySelector('.nm').textContent, path: el.dataset.p };
|
||||||
if (el.dataset.k === 'bodies') setBody(it);
|
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 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);
|
else previewGarment(it);
|
||||||
});
|
});
|
||||||
|
document.querySelectorAll('.fitchip').forEach(el => el.onclick = () => loadFit(el.dataset.fit));
|
||||||
document.querySelectorAll('[data-pick]').forEach(cb => cb.onchange = () => {
|
document.querySelectorAll('[data-pick]').forEach(cb => cb.onchange = () => {
|
||||||
cb.checked ? picked.add(cb.dataset.pick) : picked.delete(cb.dataset.pick);
|
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…';
|
$('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