This commit is contained in:
2026-08-11 16:16:49 -07:00
576 changed files with 70512 additions and 14 deletions
@@ -0,0 +1,392 @@
# Plan: Clothing-pipeline unification — one entry point, QC gates at every seam
**Status:** Implemented 2026-07-31 (phases 14 built; catalog JSON still deferred) · **Author:** Fable 5 session 2026-07-31 · **Implementer:** 5-agent parallel build + integration pass, 2026-07-31 — see §8
## 0. Context (you have no other context — read this fully)
The clothing lane turns a garment idea into an outfit part an ariki-game character wears.
It has three halves that work but do not talk to each other:
1. **MD upstream** (this repo): Marvelous Designer 2024, driven over a TCP bridge
(`tools/md_bridge.py`, port 18900). Per-garment scripts in `tools/tailor/` draft
panels, sew, drape on the Lena avatar FBX, texture, and snapshot. Session start is a
human click (Plugin > TinqsMDBridge); MD's UI freezes while a script runs.
2. **Blender downstream** (this repo): `clothing/garment_pipeline.py` — seven
config-driven headless stages (`census prepare fit reduce bake skin export`), each
checkpointing a `.blend` + QA renders into `work/<name>/`, exporting per-slot GLBs
onto the shared 65-bone Quaternius skeleton into
`ariki-game/assets/quaternius/outfits/<set>/`.
3. **Game side** (`ariki-game`, read-only for this repo's tooling by convention):
hand-edit `src/Character/OutfitCatalog.cs` to register the item, force a Godot
reimport, spawn `ClothingTestBed` via `tools/game.sh`, click through Idle/Walk/Dance,
and eyeball the result.
The lane is PROVEN — the downloaded-dress pilot (2026-07-30) and the MD-authored kapa
haka set (pari + piupiu, 2026-07-31) are in game. But both kapa haka pieces shipped with
**pose-dependent defects invisible to every QA artifact the pipeline produces**: the pari
neckline gapes open in walk/dance, and a thigh punches through the piupiu skirt. All
pipeline QA renders are rest pose — the one pose that cannot fail. A separate agent owns
those two weight fixes; **this document is the pipeline-level response**: an assessment,
a design that combines the three halves into one entry point, and the QC gates that
would have caught both bugs before the game.
Source of truth for how the lane works *today*: `.agents/wiki/architecture/clothing-lane.md`
and `clothing/README.md`. This document is a proposal for how it *should* work; on
adoption, fold the outcome into `clothing-lane.md`.
---
## 1. Assessment
### 1.1 What already works (keep it)
- **Config-driven downstream.** New garment = new JSON in `clothing/configs/`, not new
code. The schema already expresses island→part mapping, bodyshell parts, alignment,
fit masks, weld bands, proxies, weight modes, textures, per-part tri budgets.
- **Checkpointed stages.** `00_census.blend … 50_skin.blend` mean any stage can be
re-run in isolation; resume semantics exist for free.
- **Determinism fingerprint.** `export` prints a SHA1 over all garment vertices
(`garment_pipeline.py:984-989`).
- **The seam is crossed.** MD-authored garments arrive at game budget, pre-fitted to
`Ariki_Female_QuatSkin.glb``reduce` is only hard for downloaded meshes.
- **All the automation hooks exist on the game side.** `game.sh spawn` (multi-instance,
`SPAWN_BUILD=1`, `MOCK_ONLY=1`), agent API (`/health /screenshot /navigate /state
/scene /ui /command /input /console`), `game.sh clean-import`, `tools/asset_pipeline.py
check`.
### 1.2 What costs time (the streamlining targets)
| Cost | Evidence |
|---|---|
| 4× duplicated MD garment scripts | `md_pari.py`, `md_piupiu.py`, `md_tee_v1.py`, `md_skirt_v1.py` share a ~100% identical shell (NewProject → ImportFBX at `op.scale=10.0` → fabric → arrange → simulate → snapshot); only panels/seams differ |
| Config copy-paste | `piupiu.json` vs `piupiu_sb.json`: 48 lines, differing in exactly 4 values (`name`, `body`, `weights`, `export.set`) + a note |
| Six manual CLI invocations per garment | one `blender --background … --stage <s>` per stage, run by hand in order |
| Hand-authored configs | read `census.json`, guess island→part mapping, trial-and-error alignment against QA renders |
| Code-edit registration | 2 `Add()` lines + possibly a `BaseDirFor` case in `OutfitCatalog.cs`, then a C# rebuild |
| Blunt reimport | `.bin`-only changes are invisible to Godot's `.gltf` hash; today's fix is `clean-import` (wipes ALL of `.godot/imported/`) or hand-deleting entries |
| Eyeball sign-off | spawn test bed, click Idle/Walk/Dance, look |
### 1.3 What is structurally unsafe
1. **Rest-pose-only QA.** Every `qa_*.png` the pipeline writes is the fitting pose.
Both shipped bugs were invisible in it. (`clothing/README.md` "QA renders only prove
the pose that cannot fail".)
2. **Silent catalog fallback.** `OutfitCatalog.BaseDirFor` (`OutfitCatalog.cs:88-98`)
takes a bare-string `setId` and its `_ =>` arm silently resolves to the Fantasy pack
folder — a typo'd set name mis-resolves without error. Missing assets are warn-only
(`OutfitCatalog.cs:71-78`): the character just stays naked/previous.
3. **`.bin` staleness.** `GLTF_SEPARATE` export keeps geometry/UVs in the sidecar
`.bin`; Godot hashes only the `.gltf`, so a vertex-only re-export silently ships the
old mesh.
### 1.4 Corrections to the record (verified 2026-07-31)
Earlier session notes contained four errors that materially change the design:
| Prior claim | Truth | Consequence |
|---|---|---|
| "FBX export from MD is manual" | `export_api.ExportFBX(path, op)` (+ `ExportOBJ/ExportZPrj/ExportZPac`) is scriptable — exact flags in `.claude/skills/marvelous-designer/SKILL.md:182-190` (`op.bExportGarment=True; op.bExportAvatar=False`); the 4 garment scripts simply never call it | The **entire MD half can run unattended** after the one session click |
| `ExportSnapshot3D` is the mesh export | It is the viewport **PNG** (the vision-QC loop); mesh export is `ExportFBX` | Don't build on the wrong call |
| "5 duplicated MD scripts" | 4 garment scripts; `md_recon.py` is an API-introspection tool and stays standalone | Template scope |
| `tools/vision-compare.py` exists (referenced by `ariki-game/tools/visual-qa.sh`) | **It was never written** | The in-game gate has no judge today; VLM-vs-pixel-diff is a real decision, not wiring |
One permanent constraint, confirmed: **MD exposes no mesh introspection to Python**
(`GetClothPositions()` stays empty). Every upstream drape judgement is image-based,
by necessity, until CLO exposes more.
Two enabling facts: the config's `export` block already carries `gender`/`set`/`out_dir`
and each part carries `slot`, so the catalog's exact `{Gender}_{Set}_{Slot}.gltf` path is
derivable from the config today; and the agent API's `GET /console` surfaces
`ClothingTestBed`'s per-slot load-failure prints, so **outfit load failure is
machine-detectable without vision**.
---
## 2. The unified pipeline
### 2.1 One config, additive blocks
Keep `clothing/configs/<garment>.json` as the single per-garment artifact and **grow it**
— the existing Blender schema stays byte-compatible, so `garment_pipeline.py` needs no
migration:
```jsonc
{
"extends": "piupiu.json", // NEW: overlay inheritance
"name": "...", "source": "...", "body": "...",
"md": { // NEW: upstream draft params (what the 4 scripts differ on)
"avatar_fbx": "...", "zfab": "...", "texture": "...",
"panels": [...], "seams": [...], "arrangements": [...],
"sim_frames": 300, "strengthen": [...],
"export_basename": "lena_<garment>_v<N>"
},
"align": {...}, "parts": {...}, // UNCHANGED: existing Blender schema
"export": { "gender": "Female", "set": "Kapahaka", "out_dir": "..." }, // exists
"catalog": { // NEW: what Add() needs that nothing else holds
"id": "kapahaka_legs_f", "displayName": "Piupiu",
"charisma": 0.05, "workSpeed": 0.04, "category": "casual"
},
"expect": {...} // NEW: QC thresholds (§3)
}
```
- **`extends` is the cheapest real win**: `piupiu_sb.json` becomes ~8 lines. Overlay =
deep-merge child over parent, arrays replaced whole.
- **`md` block + one template script** `tools/tailor/draft_garment.py` replaces the 4
copy-paste scripts. The identical shell becomes the template; panels/seams/arrangements
come from the config. Hard-won MD lessons (mm units, +y up in 2D, whole-edge seam
indices, look up arrangement points by name, `SetArrangement` then
`ResetClothArrangement`) live in ONE place instead of four.
- **`catalog` block** carries only what the C# `Add(id, displayName, slot, slotSuffix,
charisma, workSpeed, category, set:, gender:)` signature needs and the pipeline has
never held; `slot`/`set`/`gender` derive from existing blocks.
### 2.2 One orchestrator
`clothing/garment.py <config> [--from <stage>] [--to <stage>] [--only <stage>]`
over one ordered stage list spanning all three worlds:
```
draft drape publish │ census prepare fit reduce bake skin export │ register import verify
└──── MD bridge ──────┘└──────── Blender headless ────────────────┘└──── ariki-game ─────┘
```
**A driver, not a rewrite.** Each stage shells out to the tool that already owns it:
| Stage group | Shells to | Notes |
|---|---|---|
| `draft/drape/publish` | `python tools/md_bridge.py --file <generated>` | Script generated from the `md` block via `draft_garment.py`. `--ping` first; if no session, fail fast printing the human instruction ("click Plugin > TinqsMDBridge"). Raise the idle timeout (~1 min per 300 sim frames). `publish` = scripted `ExportZPrj` + `ExportFBX` + `ExportZPac` (§1.4). |
| `census … export` | `blender --background --python garment_pipeline.py -- --config X --stage Y` | **Unchanged CLI**; `--from/--to` is a loop. Standalone stage runs keep working exactly as today. |
| `register/import/verify` | `bash ariki-game/tools/game.sh …` + agent API | §2.4 and gates G6/G7/G8. Decided: orchestrator lives in `tinqs/animation/clothing/`, cross-repo shell-out to `game.sh` is acceptable. |
Checkpoint numbering (`00_ … 50_`) already gives resume: `--from fit` loads
`10_prepare.blend` exactly as today.
### 2.3 The automation boundary (permanent, human-only)
1. **Session start is a click** — Plugin > TinqsMDBridge, once per MD session (not per
garment). The orchestrator detects (`--ping`) and instructs; it can never perform it.
2. **MD's UI freezes during a blocking call** — no mid-drape supervision; results are
visible only after `Simulate` returns.
3. **No mesh introspection in MD** — upstream QC is image-based forever (§1.4).
Everything else — including the FBX export previously believed manual — is scriptable.
That is the headline of this assessment.
### 2.4 Registration: emit, don't edit (catalog JSON deferred)
Decided 2026-07-31: **defer** the data-driven OutfitCatalog refactor.
- Cost today is 2 `Add()` lines per set (+1 `BaseDirFor` case when a new folder
appears); the refactor is not small (`Initialize()` is all hardcoded calls;
`ClothingItem` has 10 init-only properties to round-trip). Revisit at ~10 sets.
- The real risk is not typing, it is the **silent** `setId` fallback (§1.3) — gate G8
(a ~30-line lint) buys that safety without the refactor.
- The `register` stage therefore **emits** the exact `Add(...)` line(s) and any needed
`BaseDirFor` case to stdout + `work/<name>/register.cs.txt` for paste-in.
Codegen-as-text, not codegen-as-edit — honest about who owns `src/`.
---
## 3. QC gates (where agents/checks slot in)
Ranked by value = known-pain-caught ÷ effort. Both shipped bugs were pose-dependent, so
the ranking is driven by *how early a pose-dependent failure can be caught*. Thresholds
live in the config's `expect` block.
| # | Gate | After stage | Kind |
|---|---|---|---|
| **G5** | **Posed penetration + gape sweep — the centrepiece** | `skin` | scriptable |
| G6 | In-game motion QC | `import` | agent-with-vision (hard part scriptable) |
| G3 | Rest-pose penetration | `fit` | scriptable |
| G2 | Census sanity | `census` | scriptable |
| G8 | Catalog ↔ asset lint | `register` | scriptable |
| G7 | Targeted reimport verify | `import` | scriptable |
| G4 | Reduce budget + silhouette | `reduce` | scriptable |
| G1 | Drape placement | `drape` | hybrid |
**G5 — posed penetration + gape sweep.** Headless Blender loads `50_skin.blend` (it
already contains `BODY`, `RIG`, and the skinned `GARM_*` parts). Apply real clip poses —
the UAL animation packs (`ariki-game/assets/quaternius/anim/UAL1.glb` etc.) are authored
on the same 65-bone skeleton — or synthetic extremes (arm raise, deep step, torso twist).
Per sampled frame, evaluate the armature-deformed meshes and BVH-test: (i) garment verts
inside the body, (ii) body verts exposed inside a part's declared coverage band.
Pass/fail: `expect.max_penetrating_verts` (e.g. 0 verts >1 mm inside) and
`expect.max_gape_verts` across **all** frames. This is the only gate that would have
caught BOTH shipped bugs — deterministically, headless, pre-game, re-runnable. One risk
to verify during implementation: bone-name compatibility between the UAL pack armature
and the derived-body `RIG` (retarget or pose-copy if names drift).
**G6 — in-game motion QC.** `SCENE=clothing_test_bed MOCK_ONLY=1 game.sh spawn` →
`POST /navigate {"button":"Idle"|"Walk"|"Dance"}` → `GET /screenshot` per clip → judge.
Two signals: **hard** = `GET /console` contains no outfit load error (machine-checkable
today); **soft** = visual verdict on poke-through/gaping/texture. Decided 2026-07-31:
**VLM/agent judge for a new garment's first sign-off, then bless those frames as the
baseline so re-runs become a deterministic pixel diff.** This also finally gives
`visual-qa.sh` the comparator it references but never had (§1.4). Small `ariki-game`
enabler needed (approved): `ClothingTestBed` hardcodes its opening set
(`DefaultBodyF = "kapahaka_body_f"` etc., `ClothingTestBed.cs:81-84`) — add an env-var/CLI
override so a new set is directly reachable instead of blind `Cycle Body` presses.
**G3 — rest-pose penetration.** The same BVH test as G5, rest pose only, run right after
`fit` — a cheap early-fail subset that saves reduce/bake/skin time on a bad fit.
**G2 — census sanity.** `census.json` already records per-island verts/tris/z/x/centroid.
Check against `expect.islands` (count, per-mapped-island vert count and z-span
tolerance). Catches the silent killer: an MD re-export reorders islands and the config
maps the wrong ones.
**G8 — catalog ↔ asset lint.** Every catalog entry's resolved
`{baseDir}/{Gender}_{Set}_{Slot}.gltf` exists on disk, and every outfit GLB on disk has a
catalog entry. Zero unmatched either way. ~30 lines; kills the silent `BaseDirFor`
fallback footgun (§1.3) without the JSON refactor.
**G7 — targeted reimport verify.** Replace blunt `clean-import` in the garment loop:
delete only that asset's two `.godot/imported/` entries (the README already prescribes
exactly this by hand), run `--import`, then confirm via `/console` + refreshed
`.gltf.import`. Closes the `.bin`-staleness hole (§1.3) without nuking the whole cache.
**G4 — reduce budget + silhouette.** Tris vs `parts[].tris`; pixel IoU of the HI vs
reduced QA renders ≥ threshold. Low urgency: MD-authored garments arrive at budget (both
kapa haka pieces never hit their ceilings) — this gate mostly guards downloaded meshes.
**G1 — drape placement.** The thresholds already exist as prose — `md_pari.py:4` reads
"QC targets: band top ~1.31 m (above bust), hem ~1.05 m (waist) ±3 cm". Move them into
`expect.bands`, upgrade `tools/tailor/qc_placement.py` to read them (instead of its
hardcoded landmark table), and add a vision pass on the `ExportSnapshot3D` PNG. That one
move takes drape QC from eyeballed to gated.
---
## 4. Phased adoption
| Phase | Content | Effort | Why this order |
|---|---|---|---|
| **1** | `extends` overlay + `expect` block + **G2** + **G8** + orchestrator skeleton driving *Blender stages only* | ~½ day | Zero new subsystems, no MD, no game, no human in the loop. Immediately kills the 48-line config copy and the six-call ritual. |
| **2** | `draft_garment.py` template + `md` block + scripted `ExportZPrj/FBX/ZPac` in the same bridge call + **G1** | ~1 day | Collapses 4 scripts to 1; MD half becomes unattended after the single session click. |
| **3** | **G5** posed sweep (+ **G3** as its rest-pose subset) | ~1 day | **The money phase** — catches the class of bug that has actually shipped, before the game. |
| **4** | **G7** targeted reimport + ClothingTestBed set override + **G6** motion QC + baseline blessing | ~1 day | Real shaders/import/AnimationTree; only meaningful after 13. Needs the small approved `ariki-game` edits. |
| **5** | Data-driven `OutfitCatalog` JSON | deferred | Revisit ~10 sets; G8 buys the safety now. |
G4 rides along whenever convenient (it rarely bites for MD-authored garments) rather
than blocking any phase.
---
## 5. Resolved decisions (Jeremy, 2026-07-31)
1. **Orchestrator home:** `tinqs/animation/clothing/garment.py`; game stages shell out
cross-repo to `ariki-game/tools/game.sh`.
2. **G6 judge:** VLM/agent vision judge for first sign-off; bless those frames; re-runs
are deterministic pixel diffs.
3. **OutfitCatalog:** stay code-edit; pipeline emits `Add()` lines; add the G8 lint;
JSON refactor deferred (~10 sets).
4. **`ariki-game/src` edits:** small enablers approved (ClothingTestBed set override,
later testbed hooks). The pari/piupiu weight-fix agent owns `garment_pipeline.py`'s
skin stage + those two configs — coordinate before touching either.
## 6. Leave alone
- `garment_pipeline.py`'s `--stage` CLI and config schema — additive only; the
orchestrator wraps, never rewrites.
- The `skin` stage internals and `pari.json`/`piupiu.json` weight fields — owned by the
weight-fix agent right now.
- `md_recon.py` — introspection tool, not a garment script; does not fold into the template.
- `.agents/wiki/architecture/clothing-lane.md` — cite it; fold outcomes in on adoption;
never fork its content.
## 7. Appendix — file/line references
| Fact | Where |
|---|---|
| Stage implementations | `clothing/garment_pipeline.py`: census 224248, prepare 295505, fit 542598, reduce 653706, bake 712768, skin 908957 (weight modes 920944, skirt_bones 836905), export 964989 (vertex SHA1 984989) |
| Config parse / work dir / CLI | `garment_pipeline.py:1006`, `:36-39`, `:994-1008` |
| MD bridge protocol/session | `tools/md_bridge.py` (port/env `:88`, `:22`; run/ping `:58-68`), `docs/md-bridge.md:8-94` |
| Scriptable MD exports (flags) | `.claude/skills/marvelous-designer/SKILL.md:182-190` |
| MD API lessons | `tools/tailor/md_tee_v1.py:12-28`, `md_skirt_v1.py:6-25` |
| Drape QC thresholds as prose | `tools/tailor/md_pari.py:4`; classifier `tools/tailor/qc_placement.py:24-34`, bands `:51-80` |
| Config diff piupiu vs piupiu_sb | `clothing/configs/piupiu.json` / `piupiu_sb.json` — `name`, `body` (`_SkirtRig`), `parts.Piupiu.weights`, `export.set` |
| Catalog path resolution + silent fallback | `ariki-game/src/Character/OutfitCatalog.cs:57-98` (warn-only 7178, `BaseDirFor` `_ =>` 9697), `Add()` pattern in `Initialize()` 116+ |
| Test bed buttons / defaults / load-fail HUD | `ariki-game/src/Testing/ClothingTestBed.cs:449-516`, `:81-84`, `:88-89` + `:279-281` |
| Spawn / engine / import / clean-import | `ariki-game/tools/game.sh:633-749`, `:39-70`, `:832-841` (+ asset check `:848`), `:478-482` |
| Agent API endpoints | `game.sh:215-242` (`/console` via AgentServer) |
| Visual QA harness (judge missing) | `ariki-game/tools/visual-qa.sh:1-77` — calls `tools/vision-compare.py`, which does not exist |
| Reimport gotcha (hand fix prescribed) | `clothing/README.md:143-147` |
| Reference logic in older converters | `ariki-game/tools/cc_clothing_to_quaternius.py` (skeleton-frame alignment 6673, KDTree copy 110127, A-pose check 198205), `make_islander_outfits.py` (region cuts 103149) |
## 8. Build outcome (2026-07-31)
Built in one session by five parallel agents plus an integration pass. As-built
interfaces are documented in `clothing/PIPELINE-CONTRACT.md` (marked AS-BUILT); the
one-command usage and gate table are in `clothing/README.md`.
| Lane | Delivered |
|---|---|
| A | `clothing/garment.py` — the orchestrator: `extends` resolution, deep-merge, path absolutization, 13 stages, gate scheduling, `register` codegen, `--selftest` |
| B | `gates/g2_census.py` (island census sanity), `gates/g8_catalog_lint.py` (catalog↔asset lint, `--all` / `--require-registered`) |
| C | `gates/g5_posed_sweep.py` — posed penetration + gape sweep; G3 is its `--rest` mode |
| D | `tools/tailor/draft_garment.py` (MD script generator), `tools/tailor/qc_placement.py`, `gates/g1_drape.py` |
| E | `ariki-game/tools/targeted_reimport.sh` (G7), `ariki-game/tools/clothing_motion_qa.sh` (G6), `ClothingTestBed.ReportLoad` |
### Acceptance test
`configs/tests/piupiu_sb_test.json` (scratch export target, set `KapahakaSBTest`) run
end to end, `census..export`, `--no-gate-stop`:
| Stage | Time | Gate |
|---|---|---|
| census | 8.0 s | **G2 PASS** — 1 island, 3620 v / 7104 tri, matched `expect.islands` |
| prepare | 4.7 s | — |
| fit | 3.3 s | **G3 FAIL** — 1 vert, 8.0 mm deep, at rest |
| reduce | 3.1 s | — |
| skin | 3.4 s | **G5 FAIL** — see below |
| export | 3.3 s | — |
**G5 reproduced the shipped piupiu bug headlessly**, in 14.9 s, from the real game
clips (`Idle_Loop` from UAL1, `Walk_Fwd_Loop` from UAL2; 65/65 pack bones matched
the RIG, the 16 skirt bones ride their parents):
```
rest 1 penetrating vert (max 8.0 mm)
Idle @0/12/25/38/50/62 229 217 216 231 216 216 (max 75.2 85.2 mm)
Walk @0/7/13/20/27/33 169 134 61 90 128 63 (max 70.0 87.9 mm)
```
That is the thigh punching through the skirt: ~1 vert visible in the pose the whole
pipeline used to sign off on, and **216231 verts up to 8.5 cm deep** the moment the
character stands still and breathes. 13 failing frames, with front + closeup QA renders
for six of them. The gate is worth its cost.
The **pari neckline gape did NOT reproduce** in the Blender checkpoint — it is not
present in the skinned mesh G5 measures. Its origin is therefore in-game (attach,
material, or LOD), which makes it G6 territory, not G5. Worth stating plainly because
the pre-build assumption was that both shipped defects were the same class of bug.
### Real findings the gates produced
- **G8** found an *uncommitted* `OutfitCatalog` registration hunk in the working tree —
a garment registered locally and never pushed, which the lint surfaced immediately.
On the test set it correctly reports `KapahakaSBTest` has no `BaseDirFor` arm and so
would silently resolve into the Fantasy pack folder.
- **G1** measured the *shipped* garments against their own written QC targets and found
both off: pari's hem sits **+3.9 cm** high, and the piupiu's bands **+8 cm / +14 cm**.
Those targets had lived as prose in the garment scripts' headers since they were
written; nobody had ever checked them against a render.
- **`/console` did not surface load failures at all** until `ClothingTestBed.ReportLoad`
was added — `GD.Print` does not feed the agent API's ring buffer. Every "the bed looks
fine" sign-off before that fix was reading an empty channel.
- **G6's pixel diff has a measured floor**: same outfit re-run moves ~0.002 of the frame,
an entirely different outfit moves 0.014, and by frame 3 phase drift alone (0.0095)
matches the signal. So G6 catches gross regressions (garment vanished, failed to load,
swapped) and nothing subtler. **G5 owns the subtle bug class** — which is the right
split, because G5 is headless, deterministic and 15 s.
### Still deferred
Catalog-as-JSON (§2.4) — `register` still emits `work/<name>/register.cs.txt` for a human
to paste, by design. Nothing in the build depends on changing that.
+105
View File
@@ -0,0 +1,105 @@
# Rig-graft lane — AccuRig skeleton onto pristine Tripo GLBs (PLAN, not executed)
**Repo home:** this plan and its tools moved from ariki-game into the animation repo
2026-08-06 — the lane authors characters, so `characters/REGISTRY.md` governs it (see
`.agents/wiki/ARCHITECTURE.md` "Which repo does a non-gameplay tool belong to"). Paths
below are animation-repo-relative unless prefixed `ariki-game/`.
**Status 2026-08-04: PLAN ONLY. Jeremy has explicitly held execution — no model
files are to be created or modified until the naming convention is decided.**
## Goal
Rig Ozlem's two unrigged Tripo bodies without the FBX→GLB quality loss the male
lane hit in July (mangled textures, bad hand skinning). The move: FBX is only a
**disposable rig carrier** through AccuRig; the **GLB is the sole source of truth**
for mesh + materials and only ever *gains* bones and weights.
## Source files (curated 2026-08-04)
Byte-identical copies live in both repos: `characters/originals/` here (checksummed in
`characters/REGISTRY.md`) and `ariki-game/assets/models/characters/race-sources/`. Read
from this repo's copies.
| file | verts | tris | height | feet @ Z0 | axis | textures |
|---|---|---|---|---|---|---|
| `female_lena_tripo.glb` | 974,478 | 1,907,931 | 0.979 m | yes (0.0) | upright, rot 0, scale 1 | 3 packed 4K (basecolor/normal/rm) |
| `female_lena_tripo.fbx` | 953,968 | 1,907,931 | 0.979 m | — | upright | 5 external JPEGs (.fbm removed; git-recoverable) |
| `male_base_bald_tripo_v1.glb` | 969,881 | 1,901,449 | 0.980 m | yes (0.0) | upright, rot 0, scale 1 | 3 packed 4K, clean |
| `male_base_bald_tripo_v1.fbx` | 950,729 | 1,901,449 | 0.980 m | — | **lying down (bad axis)** | 6 packed, all mislabeled "Diffuse Texture.NNN", duplicated basecolor |
Census facts that shape the plan:
- **No bones anywhere** — all four files are truly unrigged, so no rig standard is
imposed by the sources. The 65-bone Quaternius game standard is unaffected.
- **Identical surfaces per pair** (tri counts match exactly); vertex counts differ
~2% from format-specific UV-seam splitting → index-exact weight copy is off the
table, nearest-surface transfer is the mechanism (surfaces coincide, so it is
near-exact everywhere except close-packed fingers — see step 4).
- The male FBX's axis + texture mangling is the July failure mode reproduced in
data — never source visual data from FBX.
## Decisions (Jeremy, 2026-08-04)
1. **NO scaling to standard height.** Bodies stay at Tripo's native ~0.98 m.
(Any scale handling happens later, downstream, not in this lane.)
2. **Axis check + ground the feet** — wanted, and the probe shows both GLBs
already pass (rot 0 / scale 1 / min-Z exactly 0.0). These become verify-only
gates, mutating nothing unless a future source fails them.
3. **HOLD before decimation** until the naming convention for derived files is
agreed. Nothing below the line runs until then.
## The lane (each step gated on the one before)
1. **Verify** (read-only, DONE for the two current GLBs): upright axis, unit
scale, feet at Z=0, centered X, packed textures present.
2. **Rig bait** *(HELD — naming)*: from the GLB, decimate a disposable copy and
export FBX for AccuRig. **Confirmed 2026-08-04: AccuRig refuses the raw
1.9 M-tri FBX outright** (Jeremy tried; July hit the same wall). Use the
`ariki-game/tools/male_mesh_decimate.py` precedent: region budgets body 24k / head 14k /
**hands 10k as their own protected region** (global-ratio decimation webs the
fingers — half of the historic hand-mangling happened here, pre-AccuRig).
Apply the July male-lane traps: Dummy-export quirk, the 4 cm offset.
No scale change on the GLB — but if AccuRig misplaces joints on a 0.98 m
body, scale the DISPOSABLE BAIT up 2× and scale the returned skeleton back
down in the graft; the GLB never changes size.
3. **AccuRig** (manual, Jeremy/Ozlem): rig the bait FBX. Export rigged FBX.
4. **Graft** (headless Blender): import rigged bait FBX + pristine GLB; snap rest
poses; transfer skeleton + skin weights decimated→full-res by nearest-surface
with tight max-distance; **hands get special handling** (per-finger masked
transfer or reduced distance + limit-totals + normalize) — this is where the
old hand mangling gets fixed. Export GLB: original mesh + original packed
textures + new rig. FBX artifacts are discarded.
5. **QC**: pose sweep incl. finger curls; then (if/when destined for game) the
existing AccuRig→Quaternius conversion (`ariki-game/tools/make_male_ib_quatskin_accurig.py`
lineage) picks it up.
## Naming + step 2 status (RESOLVED 2026-08-04, later same day)
Jeremy raised the hands budget and released the bait step. Built with
`tools/rigbait_decimate.py` (parameterized successor to male_mesh_decimate.py —
no scale/recenter, bbox-relative region cuts, budgets body 24k / head 14k /
**hands 40k**):
- `characters/rig-work/lena_tripo_rigbait.fbx` — 77,999 tris, QA'd
- `characters/rig-work/mako_tripo_rigbait.fbx` — 78,000 tris, QA'd
- `characters/rig-work/` is exempt from the naming grammar (REGISTRY.md rule 11) —
disposable carriers, no registry rows. In the game repo it had needed a `.gdignore`
to stop Godot importing it, which was the tell that it lived in the wrong repo.
- QA renders alongside (`*_qa_front.png`, `*_qa_hand.png`) — fingers fully
distinct at 40k, no webbing.
- Baits are geometry-only (~3.5 MB; the GLB's packed textures don't survive FBX
embed) — AccuRig shows a grey model, which is fine for rigging. It does mean
the July "Dummy001 export" check can't use textures: **verify AccuRig's output
by mesh name + ~78k tri count instead.**
**AccuRig exports must be saved as** (same folder):
- `characters/rig-work/lena_tripo_accurig.fbx`
- `characters/rig-work/mako_tripo_accurig.fbx`
## Open before execution
- Step 3 (AccuRig) — manual, waiting on Jeremy/Ozlem.
- Step 4 graft script — build once a rigged FBX exists to test against.
- Whether to restore the female FBX's `.fbm` texture folder from git (only needed
if the FBX is ever used for more than rigging; the lane says it shouldn't be).
+10 -5
View File
@@ -1,7 +1,12 @@
# rules/ — always-on conventions for this repo
No repo-specific rules beyond what's already always-on in root `AGENTS.md`
(git via `tinqs push`/`tinqs pull`, stage-by-explicit-path, naming law) and the
shared hub rules at `tinqs-ltd/docs/.agents/rules/`. Add a file here when a
convention needs to be enforced repo-wide and doesn't fit in `AGENTS.md`'s
thin-entry budget.
Beyond what's already always-on in root `AGENTS.md` (git via `tinqs push`/
`tinqs pull`, stage-by-explicit-path, naming law) and the shared hub rules at
`tinqs-ltd/docs/.agents/rules/`:
| file | rule |
|---|---|
| `working-files.md` | Keep milestones, not steps — the four-tier policy for staged working lanes (`.lanekeep`, scratch pruning, no `.blend1`) |
Add a file here when a convention needs to be enforced repo-wide and doesn't fit
in `AGENTS.md`'s thin-entry budget.
+74
View File
@@ -0,0 +1,74 @@
# Working files — keep milestones, not steps
Applies to every staged working lane under `characters/` and `clothing/work/`:
folders where a chain of `NN_*.py` scripts each open a `.blend`, change it, and
save. Left unmanaged those lanes grow ~80 MB per attempt. Lena's lane reached
**3.0 GB, of which 2.2 GB was 30 `.blend` files** — five 80 MB snapshots to land
one crotch fix, five more to land one bra fix.
## The rule
**A version is a milestone, not a step.** You get a saved copy when a change is
finished and approved — not after every cut that got you there. Fixing the bra
produces *one* file at the end, not one per attempt.
Four tiers:
| tier | what | policy |
|---|---|---|
| **KEEP** | the `NN_*.py` recipe, mask/input `.npz`/`.npy`, reference images, registered `_vNN.glb` artifacts | permanent. Governed by `characters/REGISTRY.md` (rule 9: superseded artifacts move to `archive/`, never deleted) |
| **MASTER** | the handful of `.blend` files a registered artifact was actually built from, plus the live chain head | pinned by name in the lane's `.lanekeep`. Aim for ≤6 per lane |
| **SCRATCH** | per-attempt `.blend`, `.blend1` autosaves, `review*/` and `dbg_*/` render dirs, `*_run.log` | overwritten freely, pruned any time, never committed |
| **UNKNOWN** | a `.blend` in a folder with no step scripts — hand-authored, so no recipe rebuilds it | never auto-deleted; decide by hand |
The key asymmetry: **the `.py` scripts are the history.** They are kilobytes and
they regenerate any intermediate state from the pinned master above it. Keeping
20 `.blend` files is keeping 20 copies of the same 950k-vertex mesh to avoid
re-running a script that takes a few minutes.
## How to work in a lane
- **Roll one working file.** Pass the same `work.blend` as the output of each
experimental step. Only write a new named `.blend` at a phase boundary
(geometry done, texture done) or when a step's result gets registered.
- **Name the end state, not the attempt.** `10_welded.blend` earns a name;
`10_healed``10_rimheal``10_seamheal` on the way there do not.
- **Reuse one `review/` dir.** QA renders are regenerable; minting `review29/`
because 28 exist is 8 MB for nothing.
- **No `.blend1` autosaves.** Step scripts set
`bpy.context.preferences.filepaths.save_version = 0` before saving.
- **Pin as you go.** When an artifact is registered in `characters/REGISTRY.md`,
add its master `.blend` to `.lanekeep` in the same edit that adds the registry
row. Anything not pinned is scratch by definition.
## `.lanekeep`
One filename per line, `#` for comments, inline comments allowed. A `.blend`
earns a line only when a registered artifact was built from it, or it is the
head of the live chain. Unpin the head's parent once the head is accepted.
## Pruning
```
python tools/prune_lane.py characters/female/lena_nude --recursive # dry run
python tools/prune_lane.py <lane> --apply # delete scratch
```
Dry run by default. It never deletes KEEP, MASTER or UNKNOWN, and it flags
binaries that are byte-identical to a registered original in
`characters/originals/` (the canonical filename always survives a duplicate
pair, never the ` - Copy`).
**Before `--apply`, check nothing is mid-flight.** These runs take minutes and
write their output at the end; a lane can gain a new head while you are looking
at it. Confirm no `blender` process is running and that the newest `.blend` is
pinned.
## Committing
Scratch patterns are in `.gitignore` and must never be committed. `.blend`,
`.zprj`, `.obj`, `.npy`/`.npz` and the rest of the heavy formats are LFS-tracked
in `.gitattributes`, so a pinned master can be committed safely — but note that
`.png`/`.jpg` deliberately are **not** (≈250 are already tracked raw; adding them
would rewrite every one without shrinking history). A lane's QA renders are
scratch for that reason too, not just a disk one.
+1
View File
@@ -8,6 +8,7 @@ This repo's operator playbooks are Claude-Code-native skills and live at
| `animation` | Operator playbook — the batch workflow, naming, loop QC, gotchas |
| `animation-creation` | Authoring new clips on the shared Quaternius skeleton |
| `iclone-video-mocap` | iClone 8 + Video Mocap: filming, cleanup, FBX export |
| `marvelous-designer` | Garment authoring: MD bridge, drafting from a reference image, draping on the game body, placement QC, export |
| `pose-estimation` | Video→pose-landmark extraction (MediaPipe) |
| `retarget-animations` | Deprecated early retarget notes, kept for history |
+48 -4
View File
@@ -3,6 +3,10 @@
Entry point to architecture law for this repo. Per-system detail lives in
`.agents/wiki/architecture/`.
This file covers the **animation lane** (motion → clips). The repo also runs a
**clothing lane** (garments → worn outfits) that targets the same skeleton —
see `.agents/wiki/architecture/clothing-lane.md`.
## The one-sentence shape
PC-side motion capture (iClone 8 + Video Mocap) → this repo (Mac↔PC bridge,
@@ -78,13 +82,53 @@ in `dancegen/` only.
`tools/cc_retarget.py`, `mixamo_retarget.py`, `kevin_retarget.py`,
`mocap_retarget.py` are **mirrors of `ariki-game/tools/`** — the game copies are
authoritative; re-copy from ariki-game when they change (they have silently
diverged before). `.claude/skills/` here mirror a subset of `ariki-game/.claude/skills/`
plus `~/.claude/skills/pose-estimation` see `README.md` Provenance section.
diverged before; verified identical 2026-08-06). `.claude/skills/` here mirror a
subset of `ariki-game/.claude/skills/` plus `~/.claude/skills/pose-estimation`
see `README.md` Provenance section.
The **character/body lane is authoritative HERE and is not mirrored** (moved out
of ariki-game 2026-08-06): `tools/rigbait_decimate.py`,
`make_lena_nude_body.py`, `_bake_nude_body_texture.py`,
`_render_body_closeup.py`, `verify_body_variant.py`. Anything that authors a
character mesh, rig, or body texture is born here from now on, governed by
`characters/REGISTRY.md`. The game repo keeps only the historical committed
generators (`make_lena_body.py`, `make_male_ib_quatskin_accurig.py`,
`male_mesh_decimate.py`, the `_convert_lena_quat_v*` series): they are cited by
game-side plans and some carry game-side tests.
### Which repo does a non-gameplay tool belong to
Decided by **subject, not by "is it gameplay"** — nothing in `ariki-game/tools/`
is gameplay (no `.gd` runtime script references it at all), so that test would
empty the folder into this one. Three questions, in order:
1. Does the engine, CI, or the game's own test suite run it? → **stays in
ariki-game.** `asset_pipeline.py`, `game.sh`, `session.py`,
`e2e_interaction.py`, `anim_qc.py`, `rig_pose_gate.py`,
`targeted_reimport.sh`, `clothing_motion_qa.sh`, and every module a
`tools/test_*.py` imports.
2. Does it author characters or motion? → **here.**
3. Neither? → it is **game-asset authoring** (trees, terrain, water, props,
items, animals, VFX). Not gameplay, but not animation either — leave it there
rather than making this repo a dumping ground.
Two things that keep tripping this up: some character tools have game-side tests
(`brow_cover_math.py`, `generate_lena_brow_surface.py`, `test_mako_rig_math.py`),
so they move with their tests or not at all — and **this repo has no test
harness**, so receiving them means standing one up. And `anim_qc.py` staying is a
deliberate split, not drift (see the two-QC-tools note above).
**Cross-repo paths in tools that moved here:** resolve the game checkout from
`$ARIKI_GAME`, else guess the sibling directory and *verify it*, aborting with the
path tried. Never let an ariki-relative default resolve silently wrong — same rule
as `--target` above.
## See also
- `.agents/wiki/architecture/` — per-system detail (currently: this file covers
the whole pipeline; split out if a subsystem grows its own doc).
- `.agents/wiki/architecture/clothing-lane.md` — the clothing lane (reference
image → Marvelous Designer → `clothing/` → worn outfit in-game).
- `.agents/wiki/architecture/` — per-system detail (split a subsystem out when
it grows its own doc).
- `.agents/wiki/dances/REGISTRY.md` — naming/registry source of truth.
- `.agents/wiki/iclone-bridge.md` — PC-lane routing stub.
- `.agents/wiki/devops-reports/` — point-in-time audits and convergence reports.
+7 -4
View File
@@ -1,9 +1,12 @@
# architecture/ — per-system detail
This repo's pipeline is small enough that `.agents/wiki/ARCHITECTURE.md` covers
it end to end today; nothing has grown large enough yet to need its own
per-system doc here. Split a topic out into this folder (and link it from
`ARCHITECTURE.md`) when it does — likely candidates as the repo grows:
| Page | Covers |
|---|---|
| `clothing-lane.md` | The clothing lane end to end: reference image → Marvelous Designer authoring → `clothing/` game-ification → worn outfit in ariki-game. Read before any garment work. |
`.agents/wiki/ARCHITECTURE.md` still covers the **animation** lane end to end.
Split a further topic out into this folder (and link it from `ARCHITECTURE.md`)
when it grows — likely candidates:
- Retarget-tool internals (per-source rig mapping, bone-name conventions).
- Loop QC/fix algorithms (pose-gap, velocity-gap, root-drift math).
+117
View File
@@ -0,0 +1,117 @@
# Clothing lane — reference image → garment worn in-game
The animation lane carries **motion** to ariki-game; this is the parallel lane
that carries **clothing**. Both converge on the same 65-bone Quaternius
skeleton. Built 2026-07-30 in two halves, by two sessions, that meet at a
garment mesh.
```
reference photo ──▶ [ UPSTREAM: Marvelous Designer ] ──▶ garment mesh (FBX/OBJ)
or concept author · drape on game body 14k verts, fitted
texture · QC placement + .zprj source
[ DOWNSTREAM: clothing/garment_pipeline.py ]
census · prepare · fit · reduce · (bake) · skin · export
ariki-game/assets/quaternius/outfits/<set>/
per-slot GLB on the shared skeleton
+ OutfitCatalog.Register entry
```
## The two halves
| | Upstream — authoring | Downstream — game-ification |
|---|---|---|
| Question it answers | "What is this garment, and what shape is it on *our* body?" | "How does the game wear it?" |
| Tool | Marvelous Designer 2026 + `TinqsMDBridge` socket plugin | Headless Blender 5.1, deterministic, staged |
| Lives in | `tools/md_bridge*`, `tools/tailor/` | `clothing/` |
| Playbook | `.claude/skills/marvelous-designer/SKILL.md` | `clothing/README.md` |
| Driven by | Agent over a socket; **needs a human click** per session, freezes MD's UI | Agent, fully headless, no human in the loop |
| Output | Draped garment mesh + `.zprj` + QC screenshot | Per-slot skinned GLB + catalog entry |
Read each half's own doc for operating detail — this page only owns **how they
fit together**. (Repo SoT rule: no second source of truth.)
## The handoff contract
Upstream hands downstream a **garment mesh in `tools/tailor/`**
(`lena_<garment>_v<N>_garment.fbx|.obj`) plus the `.zprj` it came from.
Downstream consumes it via a per-garment config
(`clothing/configs/<garment>.json`) naming the source file, island→part mapping,
slots, and budgets — new garment = new config, not new code.
Two properties of **MD-authored** meshes change how the downstream config should
be written, and the pilot config does not yet reflect them:
- **Already at game budget.** MD-authored garments export at **1.2k3.7k verts**.
The pilot config was written against a *downloaded* MD dress at **2.49 M verts
/ 110 MB**, where `reduce` is the hard stage. For our own garments, set a
generous tri budget and only decimate if the test bed complains.
- **Already fitted.** They were draped on `Ariki_Female_QuatSkin.glb` itself, so
alignment/scale work is largely moot; the `fit` stage still earns its keep for
the +4 mm clearance shell that prevents poke-through.
The `clothing/README.md` independently reached the same conclusion from research:
prefer re-exporting from MD over fighting reduction downstream. Our upstream
makes that the default rather than the exception.
## Why this shape
- **Why MD for authoring:** it is the only tool in the stack that turns flat
panels into cloth physically draped on *our* body. Garments come out
pre-fitted, low-poly, and reproducible from a `.zprj`.
- **Why not Character Creator:** the CC5/Reallusion clothing lane was
investigated and **rejected** — CC5 provably refuses clothing tools on AccuRig
"Humanoid" characters (verified via its Python API *and* GUI). See
`clothing/README.md`.
- **Why not MD's own EveryWear** (its auto retopo/rig/GLB toolkit, which would in
principle replace the whole downstream half): it is **GUI-only, absent from the
scripting API**, so it cannot be driven agentically. Revisit only if
Reallusion/CLO exposes it.
- **Why the halves are separate processes:** MD can't run headless and freezes
during a bridge session; Blender can and doesn't. Splitting keeps everything
after the garment mesh fully automatable.
## Body and rig facts
- Target body: `ariki-game/assets/quaternius/derived-bodies/Ariki_Female_QuatSkin.glb`
(32.7k verts, 65 bones, 1.777 m).
- MD needs it as **FBX**: `tools/tailor/avatar/Lena_QuatSkin_Avatar.fbx`
(leaf bones stripped, stray meshes dropped) — regenerate with the converter
described in the marvelous-designer skill.
- Measurements: `tools/tailor/lena_measurements.json` — she is stylized
(108-67-109 cm, 72 cm thighs, 178 cm), so standard size charts don't fit.
- The skeleton is the same hub the animation lane targets — see `../ARCHITECTURE.md`.
## Status (2026-07-31)
**Proven upstream:** image → decomposition → drafted panels → sewn → draped →
textured → QC'd → exported, end to end. Shipped: tee, A-line skirt, and a kapa
haka set (tāniko pari + piupiu) matched to a reference photo, all with repo
screenshots in `tools/tailor/screenshots/`.
**Proven downstream:** the dress pilot ran through to an in-game render
(`clothing/dress_ingame_2026-07-30.png`).
**The seam is CROSSED (2026-07-31).** The kapa haka set — MD-authored on our own
body — went through `garment_pipeline.py` (`configs/pari.json`,
`configs/piupiu.json`) to `Female_Kapahaka_{Body,Legs}.gltf`, and both render on
Lena in the game's clothing test bed with the tāniko pattern and piupiu stripes
intact. None of the feared surprises materialised: MD's material naming, island
splitting, and UV/texture carry-through all worked first time. The predictions on
MD-authored meshes above held — both garments arrived at game budget already
(4k/8k tri ceilings never bit) and needed no alignment work beyond a z-nudge.
**What the in-game pass exposed instead** was pose-dependent, not authoring:
the bodice neckline opens over the sternum and the piupiu lets a thigh through
once the skeleton leaves the rest pose. Both are invisible to the pipeline's own
QA, which only ever renders the **rest pose** — the pose the garment was fitted
in, i.e. the one pose that cannot fail. Judging cloth in motion is the test bed's
job, and until 2026-07-31 the bed could not do it either (its Idle/Walk/Dance
buttons were overridden by the AnimationTree; fixed in `ClothingTestBed.PlayClip`).
**Known gaps:** trousers/shorts don't drape (see the skill's failure catalogue);
placement QC's pixel classifier is crude; no skirt bones, so deep leg swings
compress hems; garments are flat-coloured until textures are authored.
+274
View File
@@ -0,0 +1,274 @@
---
name: marvelous-designer
description: Authoring garments in Marvelous Designer agentically — driving MD's Python API over the TinqsMDBridge socket plugin, drafting patterns from a reference image and Lena's measurements, draping/QC-ing them on the game body, and exporting meshes for the clothing pipeline. Use for any "make/adjust a garment", "match this clothing photo", or "drive Marvelous Designer" task, and read before touching tools/md_bridge* or tools/tailor/.
---
# Marvelous Designer — agentic garment authoring
The **upstream half** of the clothing lane: reference image (or a description) →
sewn, draped, textured garment mesh fitted to the game body. The downstream half
(garment mesh → game-ready skinned GLB) is `clothing/` — see
`.agents/wiki/architecture/clothing-lane.md` for how they meet.
Everything here was learned by doing it on 2026-07-30 (tee, skirt, kapa haka
pari + piupiu). The API facts are **verified against MD 2026 Personal**, not
docs — Reallusion/CLO's published API docs are thin and several signatures in
them are wrong.
## The 60-second model
- MD is driven through **`tools/md_bridge.py`** → a socket plugin running inside
MD (`tools/md_bridge/TinqsMDBridge.py`). Full protocol: `docs/md-bridge.md`.
- A garment = **flat 2D panels** (`CreatePatternWithPoints`) + **seams**
(`AddSeamlinePairGroup`) + **arrangement points** on the avatar
(`SetArrangement`), then **simulate** (`utility_api.Simulate(frames)`) to drape
cloth onto the body.
- You see results by rendering the viewport to PNG (`ExportSnapshot3D`) and
reading the image back. **This vision loop is the whole method** — draft,
drape, look, measure, adjust, repeat.
- **Which call for which job, and why:** `references/tooling.md`. Read it before any
QC or diagnostic work — it carries the verified signatures, the render/measure
toolkit, the seam-pairing decision table, and the harness patterns.
- Garment identity for traditional wear is mostly **textiles, not tailoring**.
Kapa haka / Mexica / Pacific garments are rectangles + blocks; the design lives
in generated texture maps. Model the shape simply, spend effort on the pattern.
## Session protocol (read first — MD's UI freezes)
MD's embedded Python **does not run background threads**, so the bridge owns the
main thread while it serves. Consequences:
1. **A human must click** Plugin → TinqsMDBridge to start a session. You cannot
start one yourself. Ask, then wait.
2. **MD's UI is frozen** for the whole session ("Not Responding" is normal).
3. End with `python tools/md_bridge.py --stop` — works from the terminal even
though the UI is dead. Idle timeout is 4 h (was 300 s; raised so one click
lasts a work session).
4. **Pause the session whenever Jeremy wants to look at the model.** He inspects
in MD's viewport; he can't while you hold the thread. Default to stopping when
you hand back a result, unless he said to stay in.
```bash
python tools/md_bridge.py --ping # verify + see mode/api modules
python tools/md_bridge.py --exec "result = pattern_api.GetPatternCount()"
python tools/md_bridge.py --file tools/tailor/md_pari.py --timeout 500
python tools/md_bridge.py --stop # give the UI back
```
Long `Simulate()` calls need a raised client `--timeout` (~1 min per 300 frames).
The exec namespace persists **within** a session, not across; MD api modules
(`pattern_api`, `import_api`, `export_api`, `fabric_api`, `utility_api`,
`ApiTypes`) plus a `BRIDGE` info dict are pre-seeded.
**Introspect, don't trust docs.** Full surface dump lives at
`tools/md_bridge/api_surface.json` (688 functions) and harvested docstrings at
`tools/md_bridge/api_docs.json`. Overloaded pybind11 functions break
`inspect.signature()` — read `__doc__` instead.
## API facts that cost hours to find
| Fact | Consequence |
|---|---|
| **2D pattern y+ maps to UP in 3D** | Panels drafted y-down drape **upside-down over the head** and tangle. Symptom looks like a seam bug; it isn't. Hem at y=0, neckline at high y. |
| Units are **mm**; gravity 9800 | A "500" square is 50 cm. |
| Blender-exported FBX avatars import **10× small** | Import with `op.scale = 10.0`. |
| `ImportAvatar()` is **.avt only** — returns `False` on FBX | Use **`import_api.ImportFBX(path, op)`** for the game body. |
| `op.bAddArrangementPoints = True` | Auto-generates ~98 named arrangement points on a custom avatar. Without it you have nowhere to hang cloth. |
| `SetArrangement()` only *assigns*; **`utility_api.ResetClothArrangement()` applies** it | Skipping the apply = nothing moves. (`ReDrape3DArrangement` only materializes not-yet-draped cloth.) |
| Arrangement **indices regenerate** per avatar import | Always look up by name from `GetArrangementList()`; never hardcode an index. Offsets aren't stable either — verify with a 0-frame snapshot. |
| Avatar getters live in **`export_api`** (`GetAvatarCount`, `GetAvatarNameList`) | Not `utility_api`, where you'd look. |
| `utility_api.NewProject()` **deletes the avatar** | Re-import after. |
| `SetBaseTextureMapImageGivenFilePath(path, fabricIdx)`**path is arg0** | Reversed args throw a TypeError that reads like a missing overload. |
| **PNG DPI sets a texture's physical size in MD** | 1024 px at 54.2 dpi = 480 mm of cloth. Control tiling by setting dpi in PIL, not by scaling the image. |
| `fabric_api.AddFabric()` needs a **`.zfab` file path** | A name string silently fails. Stock presets: `C:\Users\Public\Documents\MarvelousDesigner\New Assets\Fabric\`. |
| Fabric **index 0 is the shared default** | Coloring it dyes every garment in the scene. Always `AddFabric` a new one. |
| `AssignFabricToPattern()` returns False for every arg order tried | Use **`pattern_api.SetPatternPieceFabricIndex(pattern, fabric)`**. |
| `SetViewPoint()` does nothing; **`SetCamViewPoint(2)` = front view** | Call before every QC snapshot so shots are comparable. |
| **`SetCamViewPoint` has NO REAR VIEW** (0 bottom, 1/3 front ¾, 2 front, 4/6/7 sides, 5 top) | `ExportSnapshot3D` cannot show the back of a garment at all. Use `export_api.ExportTurntableImages(4)` — index 2 is the back. It ignores its path arg and writes into MD's own output folder, so the return value is the only way to find the files. This blind spot shipped four separate defects. |
| `SetArrangementPosition` takes **4 ints** | A float raises `TypeError`. The correct **x** depends on the arrangement family: `Body_*_Center_1` needs the two panels to **match** (a 50/0 split folds one shoulder); `Leg_Skirt_*` needs them to **differ** (50/50 drops the skirt on the floor). Sweep with a control before changing it. See `references/tooling.md` §5. |
| `ExportOBJ` writes **mm**; the FBX→census path reads **decimetres** | The two exporters disagree. Metres = OBJ × 0.001, FBX census × 0.1 (that's what `align.scale_z` is for). |
| `GetClothPositions()` is an out-param that stays empty from Python | No mesh introspection. Export a throwaway OBJ and parse its `v ` lines — exact, and the only option for gappy/strand garments, where `qc_placement.py`'s pixel classifier fails outright. |
| No `SaveProjectFile`; no glTF export; no EveryWear in the API | Save = `export_api.ExportZPrj`. Exits: `ExportFBX` / `ExportOBJ` / `ExportZPac`. EveryWear is GUI-only. |
## Construction doctrine
**Take the reference apart before drafting anything.** The tailor's method —
image → slot split → anchoring → placement targets → ease table → piece plan —
is `references/deconstruction.md`. Its output is a worksheet of numbers, and
every downstream value (panel dims, `expect.bands`, sim recipe) traces to a row
of it. Skipping this step is how garments shipped 414 cm off target.
**Draft from measurements, not guesses.** Lena's card:
`tools/tailor/lena_measurements.json` (regenerate for any body with
`tools/tailor/measure_body.py`, a headless-Blender mesh slicer). She is
stylized — 108-67-109 cm with 72 cm thighs on 178 cm — so real-world size charts
produce clothes that don't fit.
**Build from blocks, not freehand.** The blocks are parametric now —
`tools/tailor/blocks.py` turns worksheet numbers into a runnable config skeleton
(`python tools/tailor/blocks.py fitted_top --name x --band-top-z 1.31 --hem-z 1.05 --ease 17`),
with the seam parity, arrangement-x rules, bodice taper, and tension waists
baked in (`--selftest` proves it regenerates the shipped garments). Prefer
generate-then-edit over hand-typing point lists. The original per-garment
recipes remain as provenance:
| Block | Generator | Legacy recipe | Notes |
|---|---|---|---|
| Fitted top / tank / bodice | `blocks.fitted_top` | `md_tee_v1.py`, `md_pari.py` | Front+back panels, shoulder+side seams, neck gap and armholes cut into the outline. Straps hold height reliably. Legacy recipes predate the §3.3 seam-parity and §5 arrangement-x discoveries — the generator has the fixes. |
| A-line skirt | `blocks.aline_skirt` | `md_skirt_v1.py`, `md_piupiu.py` | 2 panels, side seams only. The most forgiving garment; start here. |
| Strand/fringe skirt | `blocks.strand_skirt` | — | Comb outline (teeth, never partial seams), symmetric half-gaps, elastic mid-settle. |
| Trousers/shorts | **unsolved** — see failure catalogue | | |
**Seams:** whole-edge only. Pair the **same line index** on **mirrored** front/back
panels with `(False, False)`*mirrored* is load-bearing. Most recipes here draft both
panels from the **identical** point list offset by `dx`, which is NOT mirrored, and
those need **`(True, True)`** on the side seams or the panel twists and turns partly
inside out. Full decision table in `references/tooling.md` §3.3; cross-pairing every
seam makes the garment slide off the shoulders. Build
neck gaps and armholes as extra points in the outline, not as partial seams.
Fingerprint line indices by length via `GetLineLength(pattern, line)`.
**Straps beat tubes.** A strapless tube slides down to the narrowest catch
(underbust). If a reference garment is strapless, add straps anyway when the
target body has baked-in underwear to cover — it fixes placement and coverage at
once. It's also what made the pari read correctly.
**Bottoms stay up by tension, not elastic.** Cut the waist *smaller* than the
hips and let fabric stretch hold it.
## Drape recipe (the sequence that works)
```python
pattern_api.SetPatternStrengthen(p, True) # stiffen so cloth wraps, not crumples
utility_api.ResetClothArrangement() # apply arrangement
utility_api.Simulate(250) # main settle, still stiff
# skirts: enable waist elastic HERE, mid-settle, then Simulate(80) more
pattern_api.SetPatternStrengthen(p, False)
utility_api.Simulate(50) # relax into natural folds
```
- **Strengthen through the whole settle**, relax only at the end. Soft fabric
from frame 0 rolls into a bunch at the waist. This is the anti-bunching fix —
**but only for garments whose sides are fully sewn.** On a bodice sewn only over
its lower half with 90 mm straps, strengthening flattens the back and **rotates the
whole garment**, giving an uneven hem and exposed skin. Where it can't be used,
remove the surplus cloth instead.
- **Elastic mid-settle, never from the start.** Elastic applied before the cloth
has wrapped cinches the garment off one hip. Drape first, cinch second — that
is what finally locked the piupiu waistband at the waist.
- **Skirts arrange on `Leg_Skirt_Front` / `Leg_Skirt_Back`**, not the body-waist
points, with `SetArrangementPosition(p, x, 92, 50)`.
- **One garment per scene.** A second garment — even frozen — grabs and inverts
the new one. Game exports are per-garment anyway. Assemble outfits only at the
end, by reloading finished pieces (below).
- **Outfit assembly:** `ImportZprj(base)` + `ImportZpac(other, op.bAdd=True)`.
Textures survive **only if you don't re-fabric or re-simulate after merging**
a merge renumbers patterns and fabrics unpredictably. Assemble, shoot, done.
## QC — the part that was missing and matters most
Early drapes were accepted because they "looked like clothing". They were
bunched around the middle. Fit is judged by **numbers against the reference**:
1. **Extract placement targets** from the reference photo — where each edge sits
relative to body landmarks, as a table with tolerances. Example (kapa haka):
pari top 1.31 m ±3 cm (above bust), pari hem / piupiu waist 1.05 m ±3 cm,
piupiu hem 0.45 m ±4 cm (below knee).
2. **Measure every drape**: `python tools/tailor/qc_placement.py <snapshot.png>`
classifies background/skin/garment pixels, calibrates px→m off Lena's known
1.777 m height, and reports each garment band's top/bottom in metres against
the nearest landmark. Iterate until inside tolerance.
3. **Snapshot with `SetCamViewPoint(2)`** so every shot is comparable.
4. **Save a screenshot into the repo** for every shipped garment —
`tools/tailor/screenshots/<garment>.png`. Required, not optional: it's how
Jeremy reviews without opening MD.
**The diagnostic that unsticks everything:** snapshot *after*
`ResetClothArrangement()` with **zero simulation frames**. That shows where the
panels actually start, before physics muddies it. Seven "seam bug" iterations
were really an upside-down garment; one pre-sim snapshot would have caught it
immediately. Reach for it the moment a drape misbehaves.
`qc_placement.py` is honest but crude: it counts the body's baked-in underwear
and floor shadow as garment. Tighten it with per-garment colour masks when
precision matters.
## Textures
Generate procedurally with PIL and set physical scale via DPI —
`tools/tailor/textures/` holds `taniko.png` (concentric woven diamonds) and
`piupiu.png` (flax strands with geometric banding), both derived from a reference
photo. Pattern-generation scripts are worth keeping when a motif will recur.
Workflow: `AddFabric(<.zfab>)``SetBaseTextureMapImageGivenFilePath(png, fab)`
`SetPatternPieceFabricIndex(pattern, fab)`. Set the PNG's dpi so the design
spans the garment exactly once (dpi = px / (mm/25.4)).
## Handing off downstream
Export three ways per garment, into `tools/tailor/`:
```python
export_api.ExportZPrj(".../lena_<garment>_v<N>.zprj") # editable source of truth
op = ApiTypes.ImportExportOption(); op.bExportGarment = True; op.bExportAvatar = False
export_api.ExportFBX(".../lena_<garment>_v<N>_garment.fbx", op) # for clothing/
export_api.ExportOBJ(".../lena_<garment>_v<N>_garment.obj", op)
export_api.ExportZPac(".../lena_<garment>_v<N>.zpac") # for outfit assembly
```
Then the garment goes through `clothing/garment_pipeline.py` (fit → reduce →
skin → export) to become a game outfit part. **Two advantages MD-authored
garments have over downloaded ones**, worth exploiting in the config:
- **They're already game budget.** These export at 1.2k3.7k verts; the
downloaded MD dress the clothing pipeline was piloted on is 2.49 M verts /
110 MB. The brutal `reduce` stage is mostly unnecessary — start with a high
tri budget and only decimate if the test bed complains.
- **They're already fitted**, having been draped on the actual game body, so
`align`/`fit` needs little more than the clearance shell.
Keep the `.zprj` — regenerating a variant beats re-authoring, and the clothing
README explicitly prefers re-exporting from MD over fighting reduction.
## Failure catalogue (don't rediscover these)
- **Shorts / trousers are unsolved.** Two-panel crotch-notch construction twists
every time — fabric can't thread between Lena's touching thighs, and all seam
parities were tried. Next approach: **4 panels arranged on the per-leg points**
(`Leg_Front_L/R`, `Leg_Back_L/R`) so cloth starts wrapped around each thigh.
- **Upside-down panels** (y-down drafting) — see the y+ = UP rule.
- **Elastic-first drapes** slide off a hip; **soft-from-frame-0** drapes bunch.
- **Co-draping** two garments inverts one.
- **A textured render hides folds and winding.** Judge shape with the texture OFF:
cloth is white on its front face, grey on its back, so a panel showing grey from
outside is inside out and a white streak is a fold. A busy motif conceals both.
- **A sweep without a control case teaches you something false.** If the control
(known-good settings) also fails, the harness is broken, not the geometry.
- **A light garment does not slide into place.** MD materialises cloth at the
arrangement point and does not simulate donning, so for anything light the
arrangement height *is* the placement.
- **Retexturing after a scene merge** silently repaints the wrong garment.
- **Threaded/background socket servers inside MD never answer** — the bridge must
own the main thread. Don't "fix" the frozen UI by re-threading it; that was
tried and the design is deliberate.
- MD 2026's **AI Image Generator / EveryWear are GUI-only** — not scriptable.
## File map
| Path | What |
|---|---|
| `tools/md_bridge.py` | Terminal client (`--ping/--exec/--file/--stop`) |
| `tools/md_bridge/TinqsMDBridge.py` | The plugin (register once via Plug-in Manager; referenced in place, so edits go live on next click) |
| `tools/md_bridge/api_surface.json`, `api_docs.json` | Introspected API truth |
| `docs/md-bridge.md` | Protocol, install, threading rationale |
| `tools/tailor/measure_body.py`, `lena_measurements.json` | Body measurement tool + Lena's card |
| `tools/tailor/blocks.py` | Parametric blocks: worksheet numbers → config skeleton (`--selftest`) |
| `tools/tailor/draft_garment.py` | Config `md` block → bridge script (the four recipes, templated) |
| `tools/tailor/md_*.py` | Per-garment recipes (each header carries its own lessons) |
| `references/deconstruction.md` | The tailor's method: taking a reference image apart into pieces |
| `tools/tailor/qc_placement.py` | Placement QC measurement |
| `tools/tailor/textures/` | Generated fabric maps |
| `tools/tailor/screenshots/` | Required per-garment review renders (front **and back** — see tooling.md §0) |
| `references/tooling.md` | Which API call for which job, verified signatures, decision tables |
| `tools/tailor/avatar/Lena_QuatSkin_Avatar.fbx` | Game body as an MD avatar (from the GLB, leaf bones stripped) |
| `tools/tailor/lena_*_v*.{zprj,fbx,obj,zpac}` | Shipped garments |
@@ -0,0 +1,214 @@
# Deconstruction — taking a reference apart like a tailor
Companion to `../SKILL.md`. That file is drafting/draping doctrine; **this file is
the step before it**: how to read a clothing image (or description) and take the
garment apart into pieces our MD lane can actually build. Skipping this step is
how garments shipped 414 cm off their own targets — the drafting was fine, the
*analysis* had never been written down.
The output is a **worksheet of numbers**, not geometry. Panel point lists come
last, and mostly from `tools/tailor/blocks.py`, not by hand.
---
## 0. The worksheet (output contract)
Fill this in full **before** asking for a bridge session. Every number the drape
and QC stages use traces back to a row here.
| # | Item | Feeds |
|---|---|---|
| 1 | Slot split — which separate garments is this outfit? | one config per garment |
| 2 | Per garment: class + block choice | `blocks.py` function |
| 3 | Anchoring — what holds it up | construction + arrangement |
| 4 | Placement targets — each edge's z ± tol vs landmarks | `expect.bands` (gate G1) |
| 5 | Edge circumference + ease table | panel widths, taper |
| 6 | Piece list + seam plan | `md.panels` / `md.seams` |
| 7 | Textile plan — what is texture, not geometry | texture generation |
| 8 | Fabric read — weight/stiffness | `.zfab` choice + sim recipe |
| 9 | Risks — unsolved/untested features | scope call before starting |
`blocks.py` turns rows 25 into a config skeleton (`md` block + `expect.bands`)
in one call. If no block fits, hand-draft under `tooling.md` §3 rules (named
landmarks, computed line indices) — never freehand a point list.
## 1. Split the outfit into garments by slot
The game wears **per-slot parts** (Body, Legs, …) and MD drapes **one garment per
scene** — a second garment, even frozen, grabs and inverts the new one. So a
"dress over leggings with a belt" reference is *three* worksheets, three configs,
three drape sessions. Decide the split first:
- One garment per clothing slot it occupies. A dress is one garment (Body slot,
or whatever slot spans it) even though it covers both regions.
- Belts, sashes, armbands: texture if flat against the host garment; separate
garment only if they hang or swing.
- Outfit photos are assembled at the end from finished `.zpac`s (SKILL.md
"Outfit assembly") — never co-draped.
## 2. Anchoring — decide what holds it up before what it looks like
Anchoring determines construction more than silhouette does. MD materialises
cloth at the arrangement point and does **not** simulate donning, so nothing
"slides into place".
| What the image shows | Anchor | Construction consequence |
|---|---|---|
| Straps, sleeves, or a shoulder line | shoulder-hung | bodice block; straps reach `shoulder_z` or the garment settles at the underbust |
| Strapless top | (unreliable) | **add straps anyway** — a tube slides to the narrowest catch; straps also cover the body's baked-in bra straps |
| Skirt/trousers at waist or hip | waist tension | cut the waist **smaller** than the hips (0.900.92 × hip circ) and let stretch hold it; elastic only mid-settle, never frame 0 |
| Light/gappy garment (strands, fringe, open weave) | arrangement itself | arrangement height **is** the placement — a light band stays exactly where arranged |
Always check what the anchor must *cover*: the target body has baked-in
underwear, and any neckline or scoop that drops below it reads as the garment
failing. Check the **back** neckline explicitly — the reference photo almost
never shows it, `ExportSnapshot3D` cannot show it (`tooling.md` §1.1), and that
blind spot has shipped defects. If the image doesn't show the back, *decide* the
back (scoop depth, coverage) and write it on the worksheet rather than letting it
default.
## 3. Read the seams, then discard most of them
A real tailor's deconstruction finds every seam. Ours finds them and then
**collapses almost all of them**, because the MD lane sews whole edges only, has
no darts, and garment identity for our targets is mostly textiles (SKILL.md).
Translation table:
| Feature in the image | Our move |
|---|---|
| Side seams, shoulder seams | keep — these are the block's real seams |
| Darts (bust, waist) | **no darts.** Taper the panel side edges + stretch ease carries the shaping |
| Princess seams, yokes | collapse into the panel; if visible, draw them in the texture |
| Waistband | merge into the panel top; separate band only for strand/fringe skirts |
| Set-in sleeves | **untested** — no sleeve block yet; treat as scope risk (row 9) |
| Collars, hoods | **untested/unsolved** — same |
| Trousers/shorts crotch | **unsolved** (failure catalogue); next approach is 4 panels on the per-leg points |
| Plackets, buttons, zips, pockets, topstitching | texture, always |
| Gathers, pleats, ruffles | texture unless the *silhouette* depends on them |
| Fringe / strands / fur edge | teeth cut into the panel outline (comb panel), never partial seams |
| Belt/sash flat against the garment | texture |
The test for "geometry or texture?": does it change the **silhouette** or the
**edge positions**? If not, it's texture.
## 4. Placement targets — numbers before points
Extract where every garment edge sits **before drafting anything**. This is the
step that was skipped when shipped garments landed 3.914 cm off targets that
existed only as prose.
Landmark card for the current body (`tools/tailor/lena_measurements.json`,
regenerate per body with `measure_body.py`; metres, floor = 0):
| Landmark | z | Landmark | z |
|---|---|---|---|
| top of head | 1.777 | hip (widest) / crotch | 0.946 |
| neck base | 1.457 | thigh | 0.865 |
| shoulder | 1.397 | knee | 0.517 |
| chest (bust) | 1.264 | ankle | 0.106 |
| waist | 1.089 | | |
Method, per garment edge (top of band, hem, waistline…):
1. Find the edge in the image relative to the two nearest **visible** landmarks
(e.g. "hem lands mid-thigh, about ⅓ of the knee→hip span above the knee").
2. Interpolate a z from the card. The body is stylized — always place against
*these* landmarks, never against real-world garment-length conventions.
3. Assign a tolerance: ±3 cm for fitted edges, ±4 cm for free-hanging hems.
4. Write the rows into `expect.bands` — gate G1 measures every drape against
them from then on.
Sanity anchor (proven): a pari band 1.31→1.05 m and piupiu 1.05→0.45 m came from
exactly this read of the kapa haka reference.
## 5. Ease — how much bigger than the body
For each **horizontal** edge, the panel width comes from the body circumference
at that z plus ease. Interpolate circumference linearly between the card's
(z, circ) pairs — neck 0.392, chest 1.083, waist 0.675, hip 1.095 — and **never
interpolate below the hip** (the legs bifurcate; a slice there measures nonsense).
| Fit read from the image | Total ease | Proven case |
|---|---|---|
| Snug / bandeau / activewear | +1520 mm | pari: 1100 vs 1083 bust |
| Regular fitted top | +90100 mm | tee: 1180 vs 1083 bust |
| Loose / drapey | +150 mm and up | (untested above ~150) |
| Bottoms waist edge | **negative**: 0.900.92 × hip circ | piupiu: 1000 vs 1095 hip |
Bottoms are the inversion to internalise: the waist edge is cut *smaller* than
the hips it must pass over, because tension is the anchor (§2).
## 6. Vertical spans are 1:1
Pattern millimetres map 1:1 to world metres — drape shrinkage is negligible for
our fabrics. So:
- panel cloth height = `(top_z hem_z) × 1000`
- a shoulder-hung garment's straps must reach the shoulder: total panel height
= `(shoulder_z hem_z) × 1000`, and the front scoop depth is what's left
between strap top and the visible band top: `scoop = panel_h (band_top_z
hem_z) × 1000`.
Proof this math is the real one: it reproduces all three shipped garments —
tee 480 (shoulder 1.397 → hip-ish hem 0.917), pari 350/scoop 105 (shoulder →
waist 1.05, band top 1.30), piupiu 600 (1.05 → 0.45).
## 7. Blocks — what we can build today
| Garment class | Block | Status |
|---|---|---|
| Tank / tee / fitted bodice / bandeau-with-straps | `blocks.fitted_top` | **proven** (tee, pari) |
| A-line / straight skirt | `blocks.aline_skirt` | **proven** (skirt, piupiu v2) |
| Strand / fringe skirt (piupiu, hula, fur trim) | `blocks.strand_skirt` | proven construction (comb outline), parameters per garment |
| Dress | `fitted_top` + skirt geometry in one panel pair | **untested** — try taper-through-waist first |
| Trousers / shorts | — | **unsolved**; next: 4 panels on `Leg_Front_L/R`, `Leg_Back_L/R` |
| Sleeves, collars, hoods | — | untested; scope risk |
| Capes / cloaks / rectangles (traditional wear) | plain panels | rectangles + blocks; identity is the textile |
Every block bakes in the expensive discoveries — seam parity for
identical-offset panels, the arrangement-x rules per point family, bodice taper,
tension waists, strengthen/relax sim recipes — so **prefer a block over a
hand-typed point list even when the block needs post-editing**. Generate, then
edit the config, not the other way round.
## 8. Textiles carry the identity
For traditional wear especially (kapa haka, Mexica, Pacific), the garment reads
as *itself* because of the textile, not the cut. Spend the effort there:
generate the pattern procedurally (PIL), set physical size via DPI
(`dpi = px / (mm / 25.4)`), and keep the generator script — motifs recur.
Model the shape as simply as the silhouette allows.
## 9. Fabric read → sim plan
From the image, judge weight and stiffness, then pick levers (`tooling.md` §4):
- **Stiffness ladder** (`.zfab` presets): `V2_Woven_Canvas_1` <
`V2_Woven_Denim_1` < `V2_Non-Fabric_Tyvek_1`. `fabric_api` has no physics
setters — the `.zfab` *is* the stiffness choice.
- Fully-sewn sides → strengthen through the whole settle, relax at the end.
Under-constrained (half-sewn sides, thin straps) → **don't** strengthen
(it rotates the garment); remove surplus cloth instead.
- Light/gappy → `SetParticleDistanceOfPattern` ~20 mm and remember §2:
arrangement height is placement.
- Skin-tight → `SetAddlThicknessCollision` a few mm before the settle.
## 10. Worked example — the kapa haka reference, as this method
What was done by trial and error in 2026-07-30/31, restated as the worksheet:
| Row | Pari (bodice) | Piupiu (skirt) |
|---|---|---|
| Slot | Body | Legs |
| Class/block | bandeau → `fitted_top` | strand skirt → `strand_skirt` (v2 shipped as `aline_skirt`) |
| Anchoring | photo shows strapless → **straps anyway** (covers bra straps, fixes height) | waist tension, 1000 vs 1095 hip; light strands → arrangement = placement |
| Targets | band top 1.31 ±0.03, hem 1.05 ±0.03 | waist 1.05 ±0.03, hem 0.45 ±0.04 (below knee) |
| Ease | snug: +17 mm over bust | waist 0.91 × hip |
| Seam plan | shoulders + sides; scoop/armholes in outline | band side edges only; strands are outline teeth |
| Textile | tāniko band → generated `taniko.png`, DPI-sized to span once | flax strands + geometric banding → `piupiu.png` |
| Fabric | default sim fabric, strengthen full settle | default, strengthen + (v3) elastic mid-settle |
| Risks | back neckline never visible in photo — decided, then verified via turntable | hem vs leg-swing envelope (skirt bones downstream) |
The lesson the example carries: **nothing in the finished configs is a guess.**
Every number is a worksheet row, and every worksheet row is checkable — by G1
against the render, or by `GetLineLength` against the drafted panel.
@@ -0,0 +1,310 @@
# MD tooling — which call to reach for, when, and why
Companion to `../SKILL.md`. That file is **doctrine** (how to author a garment).
This file is **tool selection**: for a given question, which API answers it, why that
one rather than the obvious alternative, and what it costs you when you guess.
Everything here is verified against **MD 2026 Personal** by running it. Where a
belief was disproved by experiment, the disproof is kept — those are the expensive
entries. Published Reallusion/CLO docs are thin and several signatures in them are
wrong; `tools/md_bridge/api_surface.json` (688 functions) is the name truth,
`api_docs.json` holds only ~48 harvested docstrings, so **read `__doc__` live** for
anything not listed below.
---
## 0. The diagnostic loop — the part that actually determines whether you succeed
The vision loop in SKILL.md is right but incomplete. Four passes, in this order,
because each one can only see what the previous one can't hide:
| Pass | Call | Catches |
|---|---|---|
| 1. **Pre-sim, zero frames** | `ExportSnapshot3D` right after `ResetClothArrangement()` | Upside-down panels, tangles, self-intersecting outlines, wrong start height. Physics hasn't muddied anything yet. |
| 2. **Untextured** | skip `SetBaseTextureMapImageGivenFilePath` | Folds and **winding**. See §1.3 — this is non-negotiable for shape work. |
| 3. **The back** | `ExportTurntableImages(4)`, index 2 | Anything on the rear. `ExportSnapshot3D` **cannot** show the back at all (§1.1). |
| 4. **Numbers** | export a throwaway OBJ, parse it (§2) | Placement, span, coverage. Eyeballing placement is how garments shipped 814 cm off their own written targets. |
A defect that survives to production is almost always one that pass 2 or 3 would
have caught. Every screenshot in this repo before 2026-07-31 was a **textured front
view** — pass 1 only — and a twisted seam, an inside-out panel, an asymmetric
shoulder fold and an exposed back neckline all shipped underneath that blind spot.
---
## 1. Looking at the garment
### 1.1 `SetCamViewPoint` HAS NO REAR VIEW
Verified by shooting all eight:
| n | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 |
|---|---|---|---|---|---|---|---|---|
| view | bottom | front ¾ | **front** | front ¾ | side | **top** | side | side |
There is no back. So `ExportSnapshot3D` is structurally incapable of showing the
rear of a garment, no matter how you drive it. Use:
```python
paths = export_api.ExportTurntableImages(4) # 0 front, 1 side, 2 BACK, 3 side
```
- The `(int)` overload **ignores any path you pass** and writes into MD's own output
folder (`%LOCALAPPDATA%\CLO Virtual Fashion\Marvelous Designer Personal\<n>\output*.png`).
The **return value is the only way to find the files**.
- The documented `(path, count, w, h, startIndex)` overload returned `[]` — didn't work.
- `ExportCustomViewSnapshot(folder, w, h, prefix)` also returned `[]`.
- Turntable **reuses the same `output*.png` names every call**, so if you are
sweeping variants, `shutil.copyfile` each frame out immediately or you lose it.
- `SetCamViewPoint(5)` (top-down) is the clearest angle on a **shoulder join**
nothing else shows whether front and back actually meet over the shoulder.
- `SetViewPoint()` does nothing. Don't confuse the two.
- Always set the viewpoint before a snapshot so shots are comparable.
### 1.2 Zoom in, and build contact sheets
The turntable renders 2500×2500. Crop to the region of interest and upscale with
PIL before looking, or you will miss centimetre-scale defects. When comparing
variants, tile them into **one** image with labels — one look at four labelled
tiles beats four separate looks, and it makes the winner obvious.
### 1.3 Untextured renders show face orientation — the single best shape diagnostic
With the default sim fabric and no texture map, cloth renders **WHITE on its front
face and GREY on its back face**.
- A panel showing **grey from outside is inside out**.
- A **white streak on an otherwise grey panel** is a fold exposing the true front face.
- A busy motif hides both **completely**. A tāniko print concealed a twisted side
seam through several rounds of wrong diagnosis; the untextured pass identified it
in one look.
Judge shape untextured, then re-enable the texture only for the shipping render.
---
## 2. Measuring — you cannot introspect the mesh, so export and parse
`GetClothPositions()` is an out-param that **stays empty from Python**. There is no
mesh access. So:
```python
op = ApiTypes.ImportExportOption(); op.bExportGarment = True; op.bExportAvatar = False
export_api.ExportOBJ(throwaway_path, op) # then parse the 'v ' lines yourself
```
**Units are a trap.** The two exporters disagree:
| Path | Units | Conversion to metres |
|---|---|---|
| `ExportOBJ` | **millimetres** | `× 0.001` |
| `ExportFBX` → clothing pipeline census | **decimetres** | `× 0.1` (this is what `align.scale_z: 0.1` is doing) |
Verified: an OBJ y of 1086.9 is 1.0869 m, checked against the known 1.777 m body.
Getting this wrong wastes a full sim round-trip.
**`tools/tailor/qc_placement.py` only works on solid silhouettes.** Its pixel
classifier needs a filled shape; on a strand skirt (mostly gaps) it reported a
1.71 m span, which is nonsense. For anything gappy, layered, or strand-based,
measure the exported geometry instead. Useful derived numbers: z-span (band top,
hem), fraction of verts above a bone-ring height, and per-height radius
percentiles for ease.
**`GetLineLength(pattern, line)`** is the cheap way to confirm you are about to sew
the edges you think you are. Fingerprint by expected length before every seam call
— see §3.2.
---
## 3. Constructing patterns
### 3.1 Outlines, not partial seams
`CreatePatternWithPoints` with **y+ = UP in 3D**. Drafting y-down drapes the garment
upside-down over the head; the symptom reads like a seam bug and isn't.
Cut features **into the outline** — neck gaps, armholes, and the teeth of a strand
skirt. Partial seams twist unpredictably. A 32-strand piupiu was built as two
comb-shaped panels with the strands cut into the outline and only the two band side
edges sewn; the alternative (32 partial seams) is in the failure catalogue for a
reason.
### 3.2 Line indices shift — compute them, never hardcode
Line `i` runs `pts[i] → pts[i+1]`. **Inserting a point shifts every index after it.**
Hardcoded `0/5/7/9` broke this repo's bodice recipe twice. Build the point list with
named landmarks and derive the indices:
```python
n_arm = 1 if arm else 0
side_r = 6 + n_arm + 1
idx = {"shoulder_l": 0, "shoulder_r": 5,
"side_r": side_r, "hem": side_r + 1, "side_l": side_r + 2}
```
Then verify against known lengths (`shoulders ≈ 93 mm`, `sides ≈ 208 mm`). If those
drift, your index maths is wrong — not the cloth. This check turns a silent
mis-sew into an immediate, obvious failure.
**Paired edges must be EQUAL, not close.** A strand skirt fingerprinted as
`[60.0, 60.075, 60.0, 60.075]` and that 0.075 mm was dismissed as rounding. It wasn't:
one band side edge was a true vertical and the other was **slanted**, because the
outline gave a half-gap to the leftmost tooth but not the rightmost (the loop skipped
the band-bottom step point on its first iteration, leaving that tooth flush with the
panel edge and half a gap wider than every other). Sewing a flush tooth to a
half-gapped one across a slanted seam notched the waistband and exposed the reverse
face. **Treat any inequality between paired edges as a construction bug.**
### 3.3 Seam pairing — a decision table, because the naive rule is incomplete
`AddSeamlinePairGroup(pf, lineF, pb, lineB, flagA, flagB)`. The two booleans reverse
edge traversal. SKILL.md's rule — "same line index on **mirrored** front/back panels
with `(False, False)`" — is correct but the word *mirrored* is load-bearing, and
most recipes here draft both panels from the **identical** point list offset by `dx`,
which is **not** mirrored.
| Panels | Pairing | Flags | Result |
|---|---|---|---|
| Mirrored | same index | `(False, False)` | correct (the documented case) |
| **Identical, not mirrored** | same index | **`(True, True)`** | correct — both edges reversed |
| Identical, not mirrored | same index | `(False,False)` / `(0,1)` / `(1,0)` | **twists the panel**; part turns inside out and rucks up |
| Mirrored coords, list order kept | same index | any | **garment falls off** — mirroring permutes the indices |
| Mirrored coords + **remapped** indices (`shoulder_l``shoulder_r`, `side_r``side_l`) | crossed | `(False, False)` | correct, and the only thing that fixes an inside-out back panel |
| Cross every pair (sides **and** shoulders) | crossed | any | **slides to the hips** — cross-wired straps cancel and it falls off the shoulders |
Proven by sweeping all four flag combinations at fixed geometry. Two lessons worth
internalising: **a twisted seam and surplus cloth look identical when textured**, and
mirroring is only correct if you remap the pair indices with it.
### 3.4 Shape rules that are geometry, not physics
- **Bodices must taper.** A rectangular panel cut for the bust carries its full bust
width down to a hem sitting on a much smaller waist (1083 mm bust vs 675 mm waist
here — ~400 mm of surplus) and the excess can only fold. Take it off each side
edge at the hem. Use the **same** taper on both panels: side-seam length is
`sqrt(taper² + side_y²)`, so equal tapers keep whole-edge pairing matched and
unequal ones skew it.
- **A pelvis-parented skirt must contain the whole leg-swing envelope.** At a hem
0.5 m below the hip pivot, 30° of hip flexion sweeps the leg ~25 cm forward — more
than any believable silhouette clears. Clearance alone cannot fix a knee-length
skirt; discrete strands or a runtime bone rig has to do the rest.
- **Straps exist to cover the target body's baked-in underwear.** If a neckline or
scoop drops below the body's painted-on tank, the tank shows and reads as the
garment failing to connect. Check the back neckline specifically — nothing was
looking at it.
---
## 4. Fit and physics levers
| Goal | Call | Why this one |
|---|---|---|
| Stand cloth off the skin | `pattern_api.SetAddlThicknessCollision(p, mm)` | The sim **resolves** the clearance, so it holds everywhere. Better than the downstream Blender normal-push, which self-intersects in concave regions — exactly between touching thighs. Set it **before** the settle. |
| Mesh / sim resolution | `SetParticleDistanceOfPattern(p, mm)` | Drives vert count and how many rows sit between skeleton joints. 20 mm gave ~29 rows on a 530 mm strand; 15 mm blew the vert budget. |
| Stop crumpling during the settle | `SetPatternStrengthen(p, True)` → relax at the end | **Conditional — see §6.** |
| Hold a waistband up | `SetPatternPieceElastic(p, line, bool)` + `SetPatternPieceElasticTotalLength(p, line, mm)` | Apply **mid-settle**, never from frame 0. Not needed if the band is cut smaller than the hips — the hip blocks it. |
| Fabric stiffness | **a different `.zfab`** | `fabric_api` has **no physics setters at all** — it is textures and metadata only. Stiffness ladder: `V2_Woven_Canvas_1` < `V2_Woven_Denim_1` < `V2_Non-Fabric_Tyvek_1`. Stock presets in `C:\Users\Public\Documents\MarvelousDesigner\New Assets\Fabric\`. |
Signatures verified live (all `(patternIdx, …)`, and the elastic family takes a
**line index** as its second arg):
```
SetAddlThicknessCollision(int, float) -> None GetAddlThicknessCollisionValue(int) -> float
SetParticleDistanceOfPattern(int, float) -> None SetPatternPieceSolidifyStrengthen(int, float) -> None
SetPatternPieceElastic(int, int, bool) -> None SetPatternPieceElasticTotalLength(int, int, float) -> None
SetPatternPieceElasticStrength(int, int, float) SetPatternPieceElasticSegmentLength(int, int, float)
SetSimulationSelfCollisionAvoidanceStiffness(float) SetAvatarSoftBodyStiffness(int, float)
GetLineLength(int, int) -> float ImportZprj(str, ApiTypes.ImportZPRJOption) -> bool
```
---
## 5. Arrangement — placement is a separate system from physics
| Fact | Consequence |
|---|---|
| `SetArrangement()` only **assigns**; `utility_api.ResetClothArrangement()` **applies** | Skipping the apply = nothing moves. |
| Arrangement **indices regenerate** per avatar import | Always look up by name from `GetArrangementList()`. Never hardcode. |
| `SetArrangementPosition` takes **4 ints** | A float raises `TypeError`. |
| **The x argument's correct value DEPENDS ON THE ARRANGEMENT POINT FAMILY — verified both ways by sweep** | `Body_*_Center_1` (bodice): the two panels must use the **SAME** x. Front `50` / back `0` folds one shoulder only and exposes the reverse face. `Leg_Skirt_Front`/`Back` (skirt): the two panels must use **DIFFERENT** x. `50/0` and `0/50` both drape correctly (band top 1.093 / 1.095) while `50/50` and `0/0` drop the skirt **on the floor** (0.10 / 0.14). So the split is load-bearing for skirts and a bug for bodices. **Never harmonise the two recipes** — and sweep with a control before changing this value on a new garment. |
| Skirts belong on `Leg_Skirt_Front` / `Leg_Skirt_Back`, y=92 | Using `Body_*_Waist` instead put a skirt **814 cm high**. Switching fixed placement to within 9 mm. |
| **A light garment does not slide into place** | MD materialises cloth at the arrangement point; it does not simulate donning. A heavy solid panel settles onto the hips, but a 60 mm waistband with light strands **stays exactly where it is arranged** — the first strand-skirt drape sat at the chest. For light garments, **arrangement height IS the placement**. |
| `utility_api.NewProject()` **deletes the avatar** | Re-import after. |
---
## 6. Rules in SKILL.md that need a condition attached
- **"Soft-from-frame-0 drapes bunch"** → true, and strengthening does flatten a
bunched back. But `SetPatternStrengthen` **rotates an under-constrained garment**:
a bodice whose sides are sewn only over the lower half and whose straps are 90 mm
wide came out flat *and twisted*, with an uneven hem and skin showing. Pre-sim was
clean, so it develops during the settle. **The rule applies to garments whose sides
are fully sewn.** Where it doesn't, remove the surplus instead of stiffening.
- **"Pair the same line index with `(False, False)`"** → only for **mirrored**
panels. See the table in §3.3.
---
## 7. Harness patterns for iterating
**Sweep variants in ONE bridge call.** Each `--file` round trip costs a session
turn; a loop with `NewProject()` per iteration costs one. Four 250-frame sims ≈ 4
minutes and answers a question that four guesses would not.
```python
for value in SWEEP:
utility_api.NewProject(); import_api.ImportFBX(AVATAR_FBX, op) # avatar first
...build, sew, arrange, Simulate(250)...
shots = export_api.ExportTurntableImages(4)
shutil.copyfile(shots[2], f"...{value}_back.png") # names are reused — copy now
```
**Read pass/fail from GEOMETRY, not images, where you can.** A garment on the floor
measures `band_top < 0.3 m`. Have the sweep parse its own probe OBJ and self-report
which cells even stayed on the body — then you only open images for the survivors.
**Always include a control case that reproduces the known-good result.** A sweep
whose control also fails tells you the *harness* is broken, not the geometry —
`tools/tailor/md_pari_armsweep.py` dropped the garment on the floor in all four
cells including its control, so every one of its results was void.
`md_pari_seamsweep.py` had a valid control and produced a decisive answer. Without a
control you cannot tell those two situations apart, and you will "learn" something
false.
**Guard the export.** Keep `EXPORT = False` until a snapshot looks right, so a
look-first run cannot overwrite shipped files.
**Timeouts:** roughly 1 minute per 300 simulated frames; pass `--timeout 880` for
anything with several sims.
**Session mechanics:** a human must click Plugin → TinqsMDBridge; MD's UI is frozen
for the whole session ("Not Responding" is normal); `python tools/md_bridge.py --stop`
hands it back and works even though the UI is dead. Stop when you hand back a
result — Jeremy inspects in the viewport and can't while the bridge owns the thread.
**Writing recipe files from Python:** these files contain box-drawing and em-dash
characters. Always `io.open(path, encoding='utf-8')`. A default-codec round trip
(cp1252) corrupted a recipe and the bridge then failed to read it at all.
---
## 8. Exits
`ExportZPrj` (editable source of truth), `ExportFBX`, `ExportOBJ`, `ExportZPac`
(outfit assembly), `ExportAlembic` / `ExportUSD`, `ExportAnimationVideo`.
No glTF export. No `SaveProjectFile` — saving *is* `ExportZPrj`. **EveryWear and the
AI Image Generator are GUI-only**, absent from the API, so the downstream
Blender half of the lane cannot be replaced by them.
Avatar getters live in **`export_api`** (`GetAvatarCount`, `GetAvatarNameList`), not
`utility_api` where you would look for them.
There is an **animation surface worth knowing about but unverified**:
`SetStartAnimationFrame` / `SetEndAnimationFrame` / `SetCurrentAnimationFrame` /
`RunAnimationRecording` / `GetAnimationLayerFrameRange`, plus `ExportAlembic`. If a
game clip's motion can be driven onto the avatar, this yields a per-frame cloth cache
with zero penetration by construction — the reference a skinned garment should be
scored against. **Not yet tested**: whether `ImportFBX`'s options can bring animation
in with the avatar is unknown.
+27
View File
@@ -3,3 +3,30 @@
*.FBX filter=lfs diff=lfs merge=lfs -text
*.glb filter=lfs diff=lfs merge=lfs -text
*.mp4 filter=lfs diff=lfs merge=lfs -text
# Authoring-tool project files and heavy binaries.
# NOTE: *.png / *.jpg are deliberately NOT here — ~250 are already tracked as
# raw blobs, and adding them would rewrite every one without shrinking history.
*.blend filter=lfs diff=lfs merge=lfs -text
*.zprj filter=lfs diff=lfs merge=lfs -text
*.zpac filter=lfs diff=lfs merge=lfs -text
*.obj filter=lfs diff=lfs merge=lfs -text
*.abc filter=lfs diff=lfs merge=lfs -text
*.exr filter=lfs diff=lfs merge=lfs -text
*.psd filter=lfs diff=lfs merge=lfs -text
*.tga filter=lfs diff=lfs merge=lfs -text
*.npy filter=lfs diff=lfs merge=lfs -text
*.npz filter=lfs diff=lfs merge=lfs -text
# Generated .humans/ PDFs (tools/humans_to_pdf.py). Each regeneration is a whole
# new ~2 MB blob, so LFS from the start — unlike png/jpg above, none are tracked
# raw yet, so there is no history to rewrite.
*.pdf filter=lfs diff=lfs merge=lfs -text
# Reallusion CC / iClone asset containers
*.iAvatar filter=lfs diff=lfs merge=lfs -text
*.iavatar filter=lfs diff=lfs merge=lfs -text
*.ccAvatar filter=lfs diff=lfs merge=lfs -text
*.ccProject filter=lfs diff=lfs merge=lfs -text
*.ccRestore filter=lfs diff=lfs merge=lfs -text
*.ccSeparateData filter=lfs diff=lfs merge=lfs -text
+19
View File
@@ -2,3 +2,22 @@
__pycache__/
*.pyc
.DS_Store
# Working-lane scratch — see .agents/rules/working-files.md
# Regenerable by re-running the lane's NN_*.py recipe from its pinned master.
*.blend1
*.blend2
*_run.log
**/review/
**/review_*/
**/review[0-9]*/
**/dbg_*/
**/probe_*/
# Vendor application install — AccuRig (~1 GB of Reallusion program files).
# Re-downloadable from Reallusion; nothing here is authored by us.
/accurig/
# Artifact of a `>/dev/null` redirect run from a Windows shell: git-lfs drops
# copies of its hooks in here. Not project content.
/dev/null/
+30
View File
@@ -0,0 +1,30 @@
# .humans/ — pages written for people, not agents
Explainers about how parts of this repo work. Same convention as
`ariki-game/.humans/`: an HTML page per topic, read in a browser.
The operating detail lives in `.claude/skills/` and `.agents/` and is written for
whoever is driving the tools. These pages are the **explanation** — what we built,
why it's shaped that way, and what it cost to learn. Where the two disagree, the
skill/wiki is the source of truth.
| page | about |
|---|---|
| `marvelous-designer` | How we author Ariki's garments as real sewn cloth in Marvelous Designer — the process, what's been made, the traps, what's still unsolved. Written for Özlem and Jeremy. |
## HTML is the source; the PDF is what you send
The HTML references screenshots relatively (`../tools/tailor/screenshots/…`), so it
only renders from inside a checkout — mail someone the `.html` on its own and they
get broken images. The **PDF has the images baked in** and travels as one file.
Edit the HTML, then regenerate:
```
python tools/humans_to_pdf.py --all # or a single page
```
That drives headless Chrome, so what you get is exactly what the browser shows.
Each page carries an `@media print` block controlling the paper layout — without it
the wide tables and the pipeline diagram, which merely scroll on screen, get cut
off at the page edge. Commit the HTML and the PDF together so they don't drift.
+451
View File
@@ -0,0 +1,451 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Ariki — How We Make Clothes in Marvelous Designer</title>
<style>
:root {
color-scheme: dark;
--ink: #f4efe6;
--muted: #a9a396;
--gold: #d9a66c;
--aqua: #72d5cb;
--rose: #e08585;
--line: rgba(255,255,255,.12);
--panel: rgba(18, 24, 27, .92);
}
* { box-sizing: border-box; }
body {
margin: 0;
padding: 2rem clamp(1rem, 4vw, 3rem) 5rem;
color: var(--ink);
background:
radial-gradient(circle at 90% 0%, rgba(67,139,132,.22), transparent 28rem),
#0b1113;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, sans-serif;
line-height: 1.6;
}
.eyebrow {
color: var(--gold);
font: 700 .75rem/1 ui-monospace, Menlo, monospace;
letter-spacing: .14em;
text-transform: uppercase;
}
h1 {
margin: .4rem 0 1.2rem;
font-size: clamp(2rem, 5vw, 3.6rem);
letter-spacing: -.04em;
line-height: 1.05;
max-width: 20ch;
}
h2 {
margin: 3rem 0 .8rem;
font-size: 1.25rem;
color: var(--aqua);
letter-spacing: -.01em;
}
h3 { margin: 1.8rem 0 .5rem; font-size: 1rem; color: var(--gold); }
p { max-width: 60rem; margin: 0 0 1rem; }
p.lede { color: var(--muted); font-size: 1.1rem; max-width: 56rem; margin: 0 0 1.5rem; }
ul, ol { max-width: 60rem; padding-left: 1.2rem; }
li { margin: .35rem 0; }
a { color: var(--aqua); }
code {
font: .88em/1.4 ui-monospace, Menlo, monospace;
background: rgba(255,255,255,.07);
padding: .12em .4em;
border-radius: .3rem;
}
strong { color: #fff; }
.wrap {
overflow-x: auto;
border: 1px solid var(--line);
border-radius: 1rem;
background: var(--panel);
margin: 1rem 0 1.5rem;
}
table { width: 100%; border-collapse: collapse; min-width: 640px; font-size: .92rem; }
th, td { padding: .8rem 1rem; text-align: left; vertical-align: top; border-bottom: 1px solid var(--line); }
th {
color: var(--gold);
font: 700 .7rem/1.2 ui-monospace, Menlo, monospace;
letter-spacing: .08em;
text-transform: uppercase;
background: rgba(0,0,0,.25);
}
tr:last-child td { border-bottom: 0; }
.num { font: .9rem ui-monospace, Menlo, monospace; color: var(--aqua); white-space: nowrap; }
figure { margin: 1.5rem 0; }
figure img {
max-width: 100%;
height: auto;
border: 1px solid var(--line);
border-radius: 1rem;
display: block;
background: #111;
}
figcaption { color: var(--muted); font-size: .88rem; margin-top: .6rem; max-width: 52rem; }
.shots { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 22rem), 1fr)); gap: 1.5rem; }
.shots figure { margin: 0; }
.flow {
border: 1px solid var(--line);
border-radius: 1rem;
background: var(--panel);
padding: 1.2rem 1.4rem;
overflow-x: auto;
margin: 1rem 0 1.5rem;
}
.flow pre {
margin: 0;
font: .82rem/1.7 ui-monospace, Menlo, monospace;
color: var(--ink);
white-space: pre;
}
.callout {
border-left: 3px solid var(--gold);
background: rgba(217,166,108,.08);
padding: .9rem 1.2rem;
border-radius: 0 .6rem .6rem 0;
margin: 1.2rem 0;
max-width: 60rem;
}
.callout.warn { border-left-color: var(--rose); background: rgba(224,133,133,.09); }
.callout p:last-child { margin-bottom: 0; }
.meta { color: var(--muted); font-size: .85rem; border-top: 1px solid var(--line); margin-top: 3.5rem; padding-top: 1.2rem; }
hr { border: 0; border-top: 1px solid var(--line); margin: 3rem 0 0; }
/* PDF export — `python tools/humans_to_pdf.py` drives headless Chrome.
Screen rules scroll wide content; on paper it would simply be cut off. */
@media print {
@page { size: A4; margin: 12mm; }
html, body {
background: #0b1113 !important;
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
body { padding: 0; font-size: 10pt; line-height: 1.5; }
h1 { font-size: 26pt; margin-bottom: .6rem; }
h2 { font-size: 13pt; margin: 20pt 0 6pt; }
h3 { font-size: 10.5pt; margin: 12pt 0 4pt; }
p, li { max-width: none; }
a { text-decoration: none; }
/* Wide content is scrollable on screen; on paper it must reflow. */
.wrap { overflow: visible; }
table { min-width: 0; font-size: 8.5pt; }
th, td { padding: .5rem .7rem; }
.flow { padding: .8rem 1rem; overflow: visible; }
.flow pre { font-size: 7pt; line-height: 1.5; }
/* Never split a figure, table row, callout or diagram across a page. */
figure, .callout, .flow, .wrap, tr { break-inside: avoid; page-break-inside: avoid; }
h1, h2, h3 { break-after: avoid; page-break-after: avoid; }
figure img { max-height: 16cm; width: auto; margin: 0 auto; }
figcaption { font-size: 8.5pt; }
.shots { display: block; }
.meta { margin-top: 20pt; font-size: 8pt; }
}
</style>
</head>
<body>
<p class="eyebrow">Ariki · Clothing lane · For humans</p>
<h1>How we make clothes in Marvelous Designer</h1>
<p class="lede">
We build Ariki's garments as <strong>real sewn cloth</strong> — flat pattern pieces, stitched
along seams, dropped onto Lena's actual game body and simulated until they hang. Nothing is
sculpted by hand. This page explains what we've made, how the process runs, and the things
that took us a long time to learn.
</p>
<figure>
<img src="../tools/tailor/screenshots/lena_kapahaka_outfit_v3.png"
alt="Lena in a red, black and white tāniko bodice with a flax-coloured piupiu skirt, arms out in T-pose.">
<figcaption>
The kapa haka outfit, v3 — a tāniko-patterned pari (bodice) over a piupiu (flax skirt), draped
on the game body inside Marvelous Designer. Both the tāniko diamonds and the flax strand
banding are textures we generated from reference photos, not painted by hand.
</figcaption>
</figure>
<h2>The short version</h2>
<p>
Clothing runs on a two-stage lane. Marvelous Designer is the <strong>upstream</strong> half — it
answers "what is this garment, and what shape is it on <em>our</em> body?" A headless Blender
pipeline is the <strong>downstream</strong> half — it answers "how does the game wear it?"
They meet at a garment mesh.
</p>
<div class="flow"><pre>reference photo ──▶ [ MARVELOUS DESIGNER ] ──▶ garment mesh (FBX/OBJ)
or concept draft · drape · texture 1.2k4.7k verts, already fitted
measure · screenshot + .zprj editable source
[ clothing/garment_pipeline.py — headless Blender ]
census · prepare · fit · reduce · skin · export
ariki-game/assets/quaternius/outfits/&lt;set&gt;/
per-slot GLB on the shared 65-bone skeleton</pre></div>
<p>
The upstream half needs a human to start each session. The downstream half is fully automatic.
</p>
<figure>
<img src="../clothing/kapahaka_ingame_dance_2026-07-31.png"
alt="The same tāniko bodice and piupiu skirt on an animated character in the game's clothing test bed.">
<figcaption>
The same two garments after the downstream half, worn in-game in the Clothing Test Bed and
playing an animation. Note the piupiu clipping through the left thigh — that is the kind of
defect the downstream <code>fit</code> stage's clearance shell exists to prevent, and it is
why we review in the test bed rather than trusting the MD render.
</figcaption>
</figure>
<h2>How a garment actually gets made</h2>
<p>Six steps. Every one of them produces numbers that the next one is checked against.</p>
<h3>1 · Take the reference apart</h3>
<p>
Before drafting anything, the reference image is deconstructed into a worksheet: which body slot
each piece belongs to, where it's anchored, where its edges should sit in metres, and how much
ease (slack) it needs. Every downstream number traces back to a row of that worksheet. Skipping
this step is how early garments shipped <strong>414 cm off target</strong>.
</p>
<h3>2 · Draft from Lena's measurements, not a size chart</h3>
<p>
Lena is stylized. Her card reads roughly <span class="num">108 67 109 cm</span> with
<span class="num">72 cm</span> thighs on a <span class="num">178 cm</span> frame. Real-world
size charts produce clothes that simply do not fit her. The measurements are extracted straight
off the game mesh by a script that slices it at known heights
(<code>tools/tailor/measure_body.py</code>), so they describe the body the game actually renders.
</p>
<h3>3 · Build from blocks, not freehand</h3>
<p>
We have parametric <em>blocks</em> — a fitted top, an A-line skirt, a strand/fringe skirt — that
turn worksheet numbers into a runnable pattern. You give it the band height, hem height and ease;
it emits the panel outlines, the seam pairing and the arrangement rules with the known fixes
already baked in. Hand-typing point lists is how the early mistakes happened.
</p>
<h3>4 · Drape it on the body</h3>
<p>
The flat panels get arrangement points on the avatar (shoulders, waist, skirt front/back), then
the cloth simulates onto her. The sequence matters more than the settings:
</p>
<ul>
<li><strong>Stiffen the fabric for the whole settle, relax only at the end.</strong> Soft cloth
from frame zero rolls into a bunch at the waist.</li>
<li><strong>Apply waist elastic mid-settle, never from the start.</strong> Elastic applied before
the cloth has wrapped cinches the garment off one hip.</li>
<li><strong>Bottoms stay up by tension, not elastic</strong> — cut the waist smaller than the hips
and let the fabric stretch hold it.</li>
<li><strong>One garment per scene.</strong> A second garment, even frozen, grabs and inverts the
new one. Outfits are assembled at the very end from finished pieces.</li>
</ul>
<h3>5 · Judge the fit by numbers, not by eye</h3>
<div class="callout">
<p>
This is the step that was missing at the start, and it matters most. Early drapes were accepted
because they <em>looked like clothing</em>. They were bunched around the middle.
</p>
</div>
<p>
Now every drape is measured. A script (<code>qc_placement.py</code>) reads the render, separates
background from skin from garment, calibrates pixels-to-metres off Lena's known 1.777 m height,
and reports where each garment band's top and bottom actually sit — against a target table pulled
from the reference photo. For the kapa haka outfit that table was: pari top at
<span class="num">1.31 m ±3 cm</span> (above the bust), pari hem and piupiu waist at
<span class="num">1.05 m ±3 cm</span>, piupiu hem at <span class="num">0.45 m ±4 cm</span>
(below the knee). We iterate until it's inside tolerance.
</p>
<p>
Every shipped garment also gets a screenshot committed to
<code>tools/tailor/screenshots/</code>, front <em>and</em> back. That's how the work gets reviewed
without anyone opening Marvelous Designer.
</p>
<h3>6 · Export four ways</h3>
<div class="wrap">
<table>
<thead><tr><th>File</th><th>Why we keep it</th></tr></thead>
<tbody>
<tr><td><code>.zprj</code></td><td>The editable source of truth. Opens in MD normally. Regenerating a variant from this always beats re-authoring.</td></tr>
<tr><td><code>.fbx</code></td><td>What the downstream Blender pipeline consumes.</td></tr>
<tr><td><code>.obj</code></td><td>Plain-text backup, and the only reliable way to measure the mesh (see below).</td></tr>
<tr><td><code>.zpac</code></td><td>For assembling multi-piece outfits later.</td></tr>
</tbody>
</table>
</div>
<h2>The unusual part: we drive MD with code</h2>
<p>
Marvelous Designer has an embedded Python API. We wrote a small plugin that opens a socket inside
MD (<code>TinqsMDBridge</code>), so a script on the outside can send it commands — create these
panels, sew this edge to that one, simulate 250 frames, render the viewport to a PNG.
</p>
<p>
<strong>The whole method is a vision loop</strong>: draft, drape, render, look at the image,
measure it, adjust, repeat. That's what makes it possible to iterate a garment dozens of times in
an afternoon.
</p>
<div class="callout warn">
<p>
<strong>Two things to know if you're sitting at the machine.</strong> MD's embedded Python
can't run in the background, so the bridge takes over the main thread: <strong>a human has to
click Plugin → TinqsMDBridge to start a session</strong>, and <strong>MD's window freezes for
the whole session</strong> — "Not Responding" is normal, not a crash. Ending the session with
<code>python tools/md_bridge.py --stop</code> gives the UI straight back. If you want to look at
the model yourself, we stop the session first.
</p>
</div>
<p>
None of this locks the work up. The <code>.zprj</code> files are ordinary MD projects — open them,
edit patterns, re-drape, re-export by hand any time. The bridge is just how the automation drives
the same buttons.
</p>
<h2>What we've made so far</h2>
<p>
Authored on and after 2026-07-30. Vertex counts are the exported garment mesh.
</p>
<div class="wrap">
<table>
<thead><tr><th>Garment</th><th>What it is</th><th>Versions</th><th class="num">Verts</th></tr></thead>
<tbody>
<tr><td>Tee</td><td>First test — fitted top block</td><td>v1</td><td class="num">2,335</td></tr>
<tr><td>Skirt</td><td>First test — A-line block, the most forgiving garment</td><td>v1</td><td class="num">1,988</td></tr>
<tr><td>Pari</td><td>Kapa haka bodice, tāniko pattern</td><td>v1 → v4</td><td class="num">1,406</td></tr>
<tr><td>Piupiu</td><td>Kapa haka flax skirt, strand block</td><td>v1 → v3</td><td class="num">3,022</td></tr>
<tr><td>Cape</td><td>Shoulder garment</td><td>v1, v2</td><td class="num">4,664</td></tr>
<tr><td>Kapa haka outfit</td><td>Pari + piupiu assembled together</td><td>v1 → v3</td><td class="num"></td></tr>
</tbody>
</table>
</div>
<div class="callout">
<p>
<strong>Our own garments are far cheaper than downloaded ones.</strong> These export at
<span class="num">1.2k4.7k</span> verts. The downloaded MD dress the clothing pipeline was
first piloted on was <span class="num">2.49 million</span> verts / 110 MB. So the brutal
<em>reduce</em> stage downstream is mostly unnecessary for our work — and because they were
draped on the real game body, they arrive already fitted.
</p>
</div>
<h2>Textiles do more work than tailoring</h2>
<p>
For traditional wear, garment identity lives in the <strong>pattern</strong>, not the cut. Kapa
haka, Mexica and Pacific garments are largely rectangles and simple blocks. So we model the shape
simply and spend the effort on the cloth: <code>taniko.png</code> (concentric woven diamonds) and
<code>piupiu.png</code> (flax strands with geometric banding) are both generated procedurally from
reference photos, and both are reusable.
</p>
<p>
One non-obvious control: in MD, <strong>a PNG's DPI sets its physical size on the cloth</strong>.
1024 px at 54.2 dpi is 480 mm of fabric. You tile a motif by changing the DPI, not by scaling the
image.
</p>
<h2>Things that cost us hours</h2>
<div class="wrap">
<table>
<thead><tr><th>What happens</th><th>Why</th></tr></thead>
<tbody>
<tr>
<td>The garment drapes <strong>upside-down over her head</strong> and tangles</td>
<td>In MD's 2D pattern window, <strong>y+ is UP in 3D</strong>. Panels drafted y-down come out inverted. It looks exactly like a seam bug and isn't — seven iterations were lost to this once.</td>
</tr>
<tr>
<td>A strapless top <strong>slides down to the underbust</strong></td>
<td>Cloth falls to the narrowest catch. MD doesn't simulate <em>putting a garment on</em> — it materialises the cloth where you arranged it. Add straps; it fixes placement and coverage at once.</td>
</tr>
<tr>
<td>The garment <strong>slides off the shoulders</strong> or twists partly inside out</td>
<td>Seam pairing. Panels drafted as identical copies offset sideways are <em>not</em> mirrored, and need the opposite flip setting from mirrored ones.</td>
</tr>
<tr>
<td>Everything in the scene <strong>changes colour at once</strong></td>
<td>Fabric slot 0 is the shared default. Colouring it dyes every garment. Always add a new fabric.</td>
</tr>
<tr>
<td>A defect ships because <strong>nobody saw the back</strong></td>
<td>MD's snapshot camera has no rear view at all — bottom, front, ¾, sides, top, and that's it. Four separate defects shipped through this blind spot before we started using turntable renders for the back.</td>
</tr>
<tr>
<td>A busy texture <strong>hides folds and inside-out panels</strong></td>
<td>Judge shape with the texture off: cloth renders white on its front face and grey on its back, so a grey patch seen from outside is inside out and a white streak is a fold.</td>
</tr>
</tbody>
</table>
</div>
<div class="callout">
<p>
<strong>The one diagnostic that unsticks everything:</strong> render the scene right after
arranging the panels but with <em>zero</em> simulation frames. That shows where the cloth
actually starts, before physics muddies the picture. Reach for it the moment a drape
misbehaves.
</p>
</div>
<h2>What doesn't work yet</h2>
<ul>
<li>
<strong>Trousers and shorts are unsolved.</strong> Two-panel construction with a crotch notch
twists every time — fabric can't thread between Lena's touching thighs, and every seam
configuration has been tried. The next idea is four panels arranged on the per-leg points, so
the cloth starts already wrapped around each thigh.
</li>
<li>
<strong>The placement measurement is honest but crude.</strong> It counts the body's baked-in
underwear and the floor shadow as garment. Fine for band positions; needs per-garment colour
masks when precision matters.
</li>
<li>
<strong>MD's AI Image Generator and EveryWear are GUI-only</strong> — they can't be scripted, so
they're outside this workflow entirely.
</li>
</ul>
<h2>Where everything lives</h2>
<div class="wrap">
<table>
<thead><tr><th>Path</th><th>What's there</th></tr></thead>
<tbody>
<tr><td><code>tools/tailor/</code></td><td>The garment workshop: per-garment recipes, the parametric blocks, Lena's measurement card, QC scripts, generated textures, and every shipped <code>.zprj</code>/<code>.fbx</code>/<code>.obj</code></td></tr>
<tr><td><code>tools/tailor/screenshots/</code></td><td>The review renders — start here to see what exists</td></tr>
<tr><td><code>tools/md_bridge.py</code> + <code>tools/md_bridge/</code></td><td>The socket bridge into MD, and a dump of MD's real API surface (688 functions, introspected — the published docs are thin and several signatures in them are wrong)</td></tr>
<tr><td><code>clothing/</code></td><td>The downstream half: garment mesh → game-ready skinned GLB</td></tr>
<tr><td><code>.claude/skills/marvelous-designer/</code></td><td>The full operating playbook, written for whoever (or whatever) is driving</td></tr>
<tr><td><code>.agents/wiki/architecture/clothing-lane.md</code></td><td>How the two halves fit together</td></tr>
</tbody>
</table>
</div>
<hr>
<p class="meta">
Animation repo · <code>.humans/marvelous-designer.html</code> · written 2026-08-06.<br>
Everything here was learned by doing it, and verified against Marvelous Designer 2026 Personal on
the PC. If a detail here disagrees with the playbook in
<code>.claude/skills/marvelous-designer/</code>, the playbook is the source of truth — this page
is the explanation, not the spec.
</p>
</body>
</html>
After
Binary file not shown.
+18 -1
View File
@@ -18,6 +18,9 @@ this file stays thin; the operational detail lives in `.claude/skills/` and
| Batch workflow, canonical retarget command, naming, loop QC, gotchas, repo etiquette | `.claude/skills/animation/SKILL.md` | always-on — read before any batch or clip work |
| Architecture (pipeline stages, canonical rig, tool provenance) | `.agents/wiki/ARCHITECTURE.md` | consult when touching the pipeline |
| Per-system architecture detail | `.agents/wiki/architecture/` | consult-when |
| **Clothing lane** — how a reference image becomes a worn in-game outfit (both halves, and where they meet) | `.agents/wiki/architecture/clothing-lane.md` | read before any garment/outfit work |
| Garment authoring in Marvelous Designer (bridge, drafting, draping, QC) | `.claude/skills/marvelous-designer/SKILL.md` | always-on for garment authoring |
| Garment mesh → game-ready skinned outfit GLB (headless Blender) | `clothing/README.md` | always-on for the game-ification half |
| In-flight work + recently shipped | `.agents/wiki/master-plan.md` | check before starting new work |
| Dance/clip naming registry (source of truth for names) | `.agents/wiki/dances/REGISTRY.md` | always-on when naming or shipping a clip |
| PC-lane routing stub | `.agents/wiki/iclone-bridge.md` | consult-when |
@@ -26,7 +29,9 @@ this file stays thin; the operational detail lives in `.claude/skills/` and
| Other operator playbooks (iClone/mocap, clip authoring, pose estimation) | `.claude/skills/` (index: `.agents/skills/README.md`) | consult-when |
| Loop-QC sub-agent | `.claude/agents/loop-qc.md` | consult-when |
| Repo-wide conventions | `.agents/rules/` | always-on |
| **Working files** — keep milestones, not steps: which `.blend`/render dirs survive a staged lane, and how to prune the rest | `.agents/rules/working-files.md` | read before running any staged `NN_*.py` lane |
| Exchange-folder operator guide (which folder, when files move/archive) | `exchange/GUIDE.md` | consult-when doing a batch |
| Human-facing explainer pages (HTML, for Jeremy/Özlem — not a source of truth) | `.humans/` | consult-when explaining the repo to a person |
## Layout
@@ -35,11 +40,23 @@ AGENTS.md ← you are here
.agents/ ← agent context: SOUL, wiki (architecture/master-plan/registry/
devops-reports), plans, rules, skills-pointer
.claude/ ← Claude-Code-native skills + agents (auto-discovered)
tools/ ← Blender-headless retargeters, loop tools, prop export
tools/ ← Blender-headless retargeters, loop tools, prop export,
md_bridge* (drive Marvelous Designer), tailor/ (garment
authoring: recipes, measurements, QC, screenshots)
characters/ ← character body models: originals/ (read-only Tripo sources) +
per-character working folders (male/mako, female/lena).
Naming law in characters/REGISTRY.md — applies to all new
derived models
clothing/ ← garment mesh → game-ready outfit GLB (staged Blender pipeline)
garments/ ← raw incoming garment sources + design advice notes
exchange/ ← files in flight between Mac and PC (see exchange/GUIDE.md)
archive/ ← retired takes — never deleted, never renumbered
```
Two lanes run through this repo: the **animation lane** (motion → clips) and the
**clothing lane** (garments → outfits). They converge on the same 65-bone
Quaternius skeleton.
## Git
- Branch is **`main`**. Sync via **`tinqs push`/`tinqs pull`** (not raw git —
+155
View File
@@ -0,0 +1,155 @@
[BoneMap]
pelvis = Hips
spine_01 = Spine
spine_02 = Spine1
spine_03 = Spine2
clavicle_l = LeftShoulder
upperarm_l = LeftArm
lowerarm_l = LeftForeArm
hand_l = LeftHand
index_01_l = LeftHandIndex1
index_02_l = LeftHandIndex2
index_03_l = LeftHandIndex3
middle_01_l = LeftHandMiddle1
middle_02_l = LeftHandMiddle2
middle_03_l = LeftHandMiddle3
pinky_01_l = LeftHandPinky1
pinky_02_l = LeftHandPinky2
pinky_03_l = LeftHandPinky3
ring_01_l = LeftHandRing1
ring_02_l = LeftHandRing2
ring_03_l = LeftHandRing3
thumb_01_l = LeftHandThumb1
thumb_02_l = LeftHandThumb2
thumb_03_l = LeftHandThumb3
lowerarm_twist_01_l = LeftForeArmRoll
clavicle_r = RightShoulder
upperarm_r = RightArm
lowerarm_r = RightForeArm
hand_r = RightHand
index_01_r = RightHandIndex1
index_02_r = RightHandIndex2
index_03_r = RightHandIndex3
middle_01_r = RightHandMiddle1
middle_02_r = RightHandMiddle2
middle_03_r = RightHandMiddle3
pinky_01_r = RightHandPinky1
pinky_02_r = RightHandPinky2
pinky_03_r = RightHandPinky3
ring_01_r = RightHandRing1
ring_02_r = RightHandRing2
ring_03_r = RightHandRing3
thumb_01_r = RightHandThumb1
thumb_02_r = RightHandThumb2
thumb_03_r = RightHandThumb3
lowerarm_twist_01_r = RightForeArmRoll
neck_01 = Neck
head = Head
thigh_l = LeftUpLeg
calf_l = LeftLeg
calf_twist_01_l = LeftLegRoll
foot_l = LeftFoot
ball_l = LeftToeBase
thigh_twist_01_l = LeftUpLegRoll
thigh_r = RightUpLeg
calf_r = RightLeg
calf_twist_01_r = RightLegRoll
foot_r = RightFoot
ball_r = RightToeBase
thigh_twist_01_r = RightUpLegRoll
[BoneRotate]
Armature = 0.,0.,0.,1.,
Head = -0.07867400663,0.,0.,0.9969003966,
Mannequin = 0.,0.,0.,1.,
ball_l = 0.000137174795,-0.9643067668,0.2647870677,0.0004994245233,
ball_leaf_l = -1.490116119e-08,-2.043089076e-08,0.,1.,
ball_leaf_r = -1.490116119e-08,-2.043089076e-08,0.,1.,
ball_r = 0.000137174795,-0.9643067668,0.2647870677,0.0004994245233,
calf_l = 0.03658974033,-0.0001311736116,-4.79783824e-06,0.9993303626,
calf_r = 0.03658974033,-0.0001311732359,-4.797521626e-06,0.9993303626,
clavicle_l = -0.6040205276,-0.3451028898,-0.3567176515,0.6235508919,
clavicle_r = -0.6040205276,0.3451028898,0.3567176515,0.6235508919,
foot_l = -0.5290732885,-0.0003280904003,0.0003434501791,0.8485760012,
foot_r = -0.529073238,-0.000328094934,0.000343457957,0.8485760327,
hand_l = -0.008619738169,2.055116454e-09,2.384097373e-07,0.9999628494,
hand_r = -0.008619738169,-2.055116454e-09,-2.384097373e-07,0.9999628494,
index_01_l = 2.374276773e-07,0.7071042257,-2.374259611e-07,0.7071093367,
index_01_r = 2.79574658e-07,-0.7071042257,2.795726372e-07,0.7071093367,
index_02_l = 0.,-1.192095169e-07,0.,1.,
index_02_r = 0.,1.192092896e-07,0.,1.,
index_03_l = 0.,0.,0.,1.,
index_03_r = 0.,0.,0.,1.,
index_04_leaf_l = 0.,1.,0.,3.651776586e-06,
index_04_leaf_r = 0.,-1.,0.,3.651776586e-06,
lowerarm_l = 0.01718220495,-2.046332276e-05,4.431598459e-07,0.9998523748,
lowerarm_r = 0.01718226455,2.052291489e-05,-5.037899558e-07,0.9998523738,
middle_01_l = -0.01670215415,0.7069121606,-0.01670274404,0.706906821,
middle_01_r = -0.01670211201,-0.7069121596,0.01670278618,0.706906822,
middle_02_l = -1.183111765e-08,-1.192076729e-07,0.001513598491,0.9999988545,
middle_02_r = -1.092088705e-08,1.192090663e-07,-0.001513598491,0.9999988545,
middle_03_l = 7.981403618e-09,0.,-0.001129686941,0.9999993619,
middle_03_r = 7.981398288e-09,0.,0.001129686941,0.9999993619,
middle_04_leaf_l = 8.381904948e-09,1.,0.,3.651776586e-06,
middle_04_leaf_r = 9.313226634e-09,-1.,0.,3.651776586e-06,
neck_01 = 0.1109859382,0.,0.,0.9938219768,
pelvis = 0.7904686183,0.,0.,0.6125025416,
pinky_01_l = -0.02370575696,0.7067118885,-0.02370634669,0.7067066951,
pinky_01_r = -0.02370567272,-0.7067118885,0.02370643093,0.7067066951,
pinky_02_l = 5.844320298e-09,-1.192093468e-07,-0.00083428664,0.999999652,
pinky_02_r = 5.574940833e-09,1.192095693e-07,0.0008342270354,0.999999652,
pinky_03_l = -4.306290348e-09,0.,0.0006095209014,0.9999998142,
pinky_03_r = -4.30592264e-09,0.,-0.0006094612968,0.9999998143,
pinky_04_leaf_l = 2.235174179e-08,1.,0.,3.651776586e-06,
pinky_04_leaf_r = 2.235174534e-08,-1.,0.,3.651776586e-06,
ring_01_l = -0.0125884143,0.7069972761,-0.01258900426,0.7069921501,
ring_01_r = -0.01258837216,-0.7069972754,0.0125890464,0.7069921509,
ring_02_l = -8.65591156e-10,-1.192091509e-07,1.221615912e-05,0.9999999999,
ring_02_r = 7.37720217e-10,1.19209174e-07,-1.221615912e-05,0.9999999999,
ring_03_l = 7.539233332e-09,0.,-0.001067100938,0.9999994306,
ring_03_r = 7.539231556e-09,0.,0.001067100938,0.9999994306,
ring_04_leaf_l = 9.313225745e-10,1.,0.,3.651776586e-06,
ring_04_leaf_r = 3.725291187e-09,-1.,0.,3.651776586e-06,
root = 0.,0.,0.,1.,
spine_01 = -0.06470268379,0.,0.,0.997904586,
spine_02 = -0.07727999146,0.,0.,0.9970094297,
spine_03 = -0.0002686381599,0.,0.,0.9999999639,
thigh_l = 0.99248421,0.,0.,0.1223727621,
thigh_r = 0.99248421,9.236081374e-09,-1.138763077e-09,0.1223727621,
thumb_01_l = 0.2474131752,0.9457944096,0.203441308,0.05358441736,
thumb_01_r = 0.247413226,-0.9457943736,-0.2034414034,0.0535844553,
thumb_02_l = -0.0001358743711,-7.410041679e-05,4.796071313e-05,0.9999999869,
thumb_02_r = -0.0001356769347,7.408892685e-05,-4.791442513e-05,0.9999999869,
thumb_03_l = 0.0002479590938,8.092956012e-05,-0.0005353075979,0.9999998227,
thumb_03_r = 0.000247953548,-8.101040989e-05,0.0005352930074,0.9999998227,
thumb_04_leaf_l = 2.375739504e-08,0.5061779195,2.751002055e-07,0.8624290776,
thumb_04_leaf_r = 2.159766835e-08,-0.5061777653,-2.770237225e-07,0.8624291681,
upperarm_l = 0.1802646081,0.6838513444,-0.1798313456,0.6837490014,
upperarm_r = 0.1802662816,-0.6838508941,0.1798330142,0.6837485718,
[FloorContact]
HandBottom = 2.769418716
HandBack = 0.8673477173
HandMiddle = 11.10624313
HandFront = 12.55753326
HandIn = 5.706539154
HandOut = 5.25942421
FootBottom = 13.2835741
FootBack = 6.798443794
FootMiddle = 15.87928581
FootFront = 6.717291832
FootIn = 5.117822647
FootOut = 6.936565399
[Property]
AnkleHeight = 9.38811779
AnkleSpacing = 10.03706551
HipsForward = -15.
AutoAnkleHeight = true
AutoAnkleSpacing = true
RollExtractionMode = false
[RootTransform]
Value = 1.,1.,1.,1.,0.,0.,0.,0.9999999404,0.,0.,0.,1.,0.,0.,0.,
+183
View File
@@ -0,0 +1,183 @@
[BoneMap]
pelvis = Hips
spine_01 = Spine
spine_02 = Spine1
spine_03 = Spine2
clavicle_l = LeftShoulder
upperarm_l = LeftArm
lowerarm_l = LeftForeArm
hand_l = LeftHand
index_01_l = LeftHandIndex1
index_02_l = LeftHandIndex2
index_03_l = LeftHandIndex3
middle_01_l = LeftHandMiddle1
middle_02_l = LeftHandMiddle2
middle_03_l = LeftHandMiddle3
pinky_01_l = LeftHandPinky1
pinky_02_l = LeftHandPinky2
pinky_03_l = LeftHandPinky3
ring_01_l = LeftHandRing1
ring_02_l = LeftHandRing2
ring_03_l = LeftHandRing3
thumb_01_l = LeftHandThumb1
thumb_02_l = LeftHandThumb2
thumb_03_l = LeftHandThumb3
lowerarm_twist_01_l = LeftForeArmRoll
clavicle_r = RightShoulder
upperarm_r = RightArm
lowerarm_r = RightForeArm
hand_r = RightHand
index_01_r = RightHandIndex1
index_02_r = RightHandIndex2
index_03_r = RightHandIndex3
middle_01_r = RightHandMiddle1
middle_02_r = RightHandMiddle2
middle_03_r = RightHandMiddle3
pinky_01_r = RightHandPinky1
pinky_02_r = RightHandPinky2
pinky_03_r = RightHandPinky3
ring_01_r = RightHandRing1
ring_02_r = RightHandRing2
ring_03_r = RightHandRing3
thumb_01_r = RightHandThumb1
thumb_02_r = RightHandThumb2
thumb_03_r = RightHandThumb3
lowerarm_twist_01_r = RightForeArmRoll
neck_01 = Neck
Head = Head
thigh_l = LeftUpLeg
calf_l = LeftLeg
calf_twist_01_l = LeftLegRoll
foot_l = LeftFoot
ball_l = LeftToeBase
thigh_twist_01_l = LeftUpLegRoll
thigh_r = RightUpLeg
calf_r = RightLeg
calf_twist_01_r = RightLegRoll
foot_r = RightFoot
ball_r = RightToeBase
thigh_twist_01_r = RightUpLegRoll
[BoneRotate]
RootNode(0) = 0.,0.,0.,1.,
ThirdPersonCharacter_167 = 0.,0.,-0.7071066499,0.7071068883,
CharacterMesh0 = 0.,0.,0.7071065903,0.7071069479,
root = 0.,0.,0.,1.,
pelvis = 0.,0.7071067691,0.,-0.7071067691,
spine_01 = 0.,0.,-6.238857657e-002,0.9980519414,
spine_02 = 0.,0.,0.1224197969,0.9924783707,
spine_03 = 0.,0.,2.425260469e-002,0.9997058511,
clavicle_l = 0.2175616771,0.6793626547,0.1070207208,0.692589283,
upperarm_l = 4.885814339e-002,-1.803681627e-002,-0.233969152,0.9708480239,
lowerarm_l = -7.146691531e-002,-1.314506307e-002,-1.677454822e-002,0.997215271,
hand_l = -0.6664974093,3.67404297e-002,-4.807422683e-002,0.7430478334,
index_01_l = 0.1349626333,-6.637491286e-002,4.533420503e-002,0.9875848889,
index_02_l = 1.228160132e-002,-1.628758386e-003,-2.582049929e-004,0.9999231696,
index_03_l = 1.123972051e-002,6.921002176e-003,2.099514008e-003,0.9999106526,
middle_01_l = 3.692238033e-002,-5.181602761e-002,4.584874585e-002,0.99691993,
middle_02_l = -1.943795197e-002,5.723292008e-003,-1.066895761e-002,0.9997378588,
middle_03_l = -3.62048531e-003,-3.873198852e-002,7.702498697e-004,0.9992428422,
pinky_01_l = -0.1161660478,-6.177603826e-002,4.257363081e-002,0.9903921485,
pinky_02_l = 1.135612186e-002,-9.435054846e-003,-1.869967673e-003,0.9998892546,
pinky_03_l = 5.186018068e-003,3.358753026e-002,5.650337413e-002,0.9978237748,
ring_01_l = -8.321698755e-002,-6.469994783e-002,3.718987107e-002,0.9937332273,
ring_02_l = 6.980994716e-003,8.726322092e-003,-6.09222152e-005,0.9999375343,
ring_03_l = 1.812815899e-003,2.61958465e-002,-3.580457717e-002,0.9990138412,
thumb_01_l = 0.5882545114,0.265966773,-0.135181129,0.7516279221,
thumb_02_l = 2.229750156e-002,-6.388775259e-002,0.1282432675,0.98943156,
thumb_03_l = 2.14139428e-002,-1.706168172e-003,5.907326192e-002,0.9980224967,
RL_L_Hand01_Floor01 = 0.,0.,0.,1.,
RL_L_Hand01_Floor02 = 0.,0.,0.,1.,
RL_L_Hand01_Floor03 = 0.,0.,0.,1.,
RL_L_Hand01_Floor04 = 0.,0.,0.,1.,
RL_L_Hand01_Floor05 = 0.,0.,0.,1.,
RL_L_Hand01_Floor06 = 0.,0.,0.,1.,
lowerarm_twist_01_l = 0.,0.,0.,1.,
upperarm_twist_01_l = 0.,0.,0.,1.,
clavicle_r = -0.6798773408,0.2174819708,0.6920840144,-0.107182622,
upperarm_r = 4.860173911e-002,-1.798832975e-002,-0.2336735427,0.9709330797,
lowerarm_r = -7.170052826e-002,-1.280183531e-002,-1.666365191e-002,0.9972048402,
hand_r = -0.6661629677,3.690450266e-002,-4.773123935e-002,0.7433617115,
index_01_r = 0.1352881044,-6.594806165e-002,4.57360819e-002,0.9875506163,
index_02_r = 1.221698243e-002,-1.745244022e-003,2.132323789e-005,0.9999238253,
index_03_r = 1.133191865e-002,7.000599988e-003,1.665975666e-003,0.9999098778,
middle_01_r = 3.718136624e-002,-5.157186091e-002,4.625619203e-002,0.9969043136,
middle_02_r = -1.91414915e-002,5.435747094e-003,-1.036943309e-002,0.99974823,
middle_03_r = -3.454518039e-003,-3.839058429e-002,7.380198804e-004,0.9992565513,
pinky_01_r = -0.1161327064,-6.213930994e-002,4.253363237e-002,0.990375042,
pinky_02_r = 1.132710464e-002,-9.618380107e-003,-1.636284287e-003,0.9998882413,
pinky_03_r = 5.874346476e-003,3.336748108e-002,3.290480375e-002,0.9988840818,
ring_01_r = -8.344670385e-002,-6.444695592e-002,3.724582493e-002,0.9937283397,
ring_02_r = 6.980994716e-003,8.726322092e-003,-6.09222152e-005,0.9999375343,
ring_03_r = 9.657530463e-004,2.585097915e-003,-3.577389941e-002,0.999356091,
thumb_01_r = 0.5880680084,0.266130507,-0.1352667361,0.7517004013,
thumb_02_r = 2.229461074e-002,-6.394065171e-002,0.1285541654,0.9893878698,
thumb_03_r = 2.10607145e-002,-1.37078797e-003,5.934789404e-002,0.9980142117,
RL_R_Hand01_Floor01 = 0.,0.,0.,1.,
RL_R_Hand01_Floor02 = 0.,0.,0.,1.,
RL_R_Hand01_Floor03 = 0.,0.,0.,1.,
RL_R_Hand01_Floor04 = 0.,0.,0.,1.,
RL_R_Hand01_Floor05 = 0.,0.,0.,1.,
RL_R_Hand01_Floor06 = 0.,0.,0.,1.,
lowerarm_twist_01_r = -0.1176272556,0.,0.,0.993057847,
upperarm_twist_01_r = -0.1732347608,0.,0.,0.9848805666,
neck_01 = 0.,0.,-0.2037104368,0.9790312052,
Head = 0.,0.,0.1335420161,0.99104321,
thigh_l = 7.410442084e-002,7.760480512e-004,1.044298802e-002,0.9971954823,
calf_l = -4.964926094e-002,6.01673685e-003,-1.868829131e-004,0.9987486005,
calf_twist_01_l = 2.808935009e-003,-1.933382242e-003,-7.612695452e-003,0.9999651909,
foot_l = -3.623697907e-002,-1.086986624e-002,-1.354881749e-002,0.9991921782,
ball_l = 8.008190343e-005,2.960354868e-005,-0.7186337113,0.6953888535,
RL_L_Foot01_Floor01 = 0.,0.,0.,1.,
RL_L_Foot01_Floor04 = 0.,0.,0.,1.,
RL_L_Foot01_Floor02 = 0.,0.,0.,1.,
RL_L_Foot01_Floor03 = 0.,0.,0.,1.,
RL_L_Foot01_Floor05 = 0.,0.,0.,1.,
RL_L_Foot01_Floor06 = 0.,0.,0.,1.,
thigh_twist_01_l = -4.744359851e-002,2.148010935e-005,-4.911088618e-004,0.9988737702,
thigh_r = -7.760486915e-004,7.410442084e-002,0.9971954823,-1.044299733e-002,
calf_r = -4.971510917e-002,6.144450512e-003,-5.679383758e-004,0.9987443686,
calf_twist_01_r = 2.807272831e-003,-1.933727413e-003,-7.612541318e-003,0.9999652505,
foot_r = -3.591478616e-002,-1.086789835e-002,-1.348622516e-002,0.9992047548,
ball_r = 8.007854194e-005,2.960378333e-005,-0.7186336517,0.6953887939,
RL_R_Foot01_Floor01 = 0.,0.,0.,1.,
RL_R_Foot01_Floor04 = 0.,0.,0.,1.,
RL_R_Foot01_Floor02 = 0.,0.,0.,1.,
RL_R_Foot01_Floor03 = 0.,0.,0.,1.,
RL_R_Foot01_Floor05 = 0.,0.,0.,1.,
RL_R_Foot01_Floor06 = 0.,0.,0.,1.,
thigh_twist_01_r = -4.744526744e-002,2.189409315e-005,-4.911787109e-004,0.9988737106,
ik_foot_root = 0.,0.,0.,1.,
ik_foot_l = 2.053012326e-002,-0.712233305,1.172331907e-002,0.7015445828,
ik_foot_r = -0.7015444636,1.172408182e-002,0.7122334242,2.053087763e-002,
ik_hand_root = 0.,0.,0.,1.,
CharacterMesh0(0) = 0.,0.,8.24325852e-008,1.,
[FloorContact]
HandBottom = 2.769418716
HandBack = 0.8673477173
HandMiddle = 11.10624313
HandFront = 12.55753326
HandIn = 5.706539154
HandOut = 5.25942421
FootBottom = 13.2835741
FootBack = 6.798443794
FootMiddle = 15.87928581
FootFront = 6.717291832
FootIn = 5.117822647
FootOut = 6.936565399
[Property]
AnkleHeight = 9.38811779
AnkleSpacing = 10.03706551
HipsForward = -15.
AutoAnkleHeight = true
AutoAnkleSpacing = true
RollExtractionMode = false
[RootTransform]
Value = 1.,1.,1.,1.,0.,0.,0.,0.9999999404,0.,0.,0.,1.,0.,0.,0.,
Binary file not shown.

After

Width:  |  Height:  |  Size: 220 KiB

@@ -0,0 +1,2 @@
The female mannequin doesnt include the animations, as duplicating them wouldnt make sense.
You can easily retarget them from the library files in Blender, or even better, directly in your engine, since both mannequins share the same rig and very similar proportions.
Binary file not shown.

After

Width:  |  Height:  |  Size: 488 KiB

@@ -0,0 +1,12 @@
-------------------------------------------------------
License:
CC0 1.0 Universal (CC0 1.0)
Public Domain Dedication
https://creativecommons.org/publicdomain/zero/1.0/
------------------------------------------------------
Models by @Quaternius
Consider supporting me on Patreon!
https://www.patreon.com/quaternius
@@ -0,0 +1,21 @@
The Universal Animation Library comes in two files: the one ending in _RM has root motion baked into every animation, while the other has root motion disabled.
If you ever need to re-export from Blender, just install the included Blender addon (root_motion_toggle.py), which lets you turn root motion on or off.
Explore all the animations in the Animation Viewer!
https://quaternius.com/animviewer.html
-------------------------------------------------------
License:
CC0 1.0 Universal (CC0 1.0)
Public Domain Dedication
https://creativecommons.org/publicdomain/zero/1.0/
------------------------------------------------------
Models by @Quaternius
Consider supporting me on Patreon!
https://www.patreon.com/quaternius
-------------------------------------------------------
Join the Discord Server:
https://discord.gg/vJqnRUYRfT
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 232 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 145 KiB

@@ -0,0 +1,87 @@
bl_info = {
"name": "Root Motion Toggle",
"author": "Quaternius",
"version": (1, 0, 0),
"blender": (4, 5, 0),
"location": "View3D > Sidebar > Root Motion",
"description": "Enable or disable root motion muting across all animations",
"category": "Animation",
}
import bpy
def set_root_motion_mute(mute: bool):
actions_affected = 0
for action in bpy.data.actions:
group = action.groups.get("root")
if group is not None:
group.mute = mute
for fcurve in action.fcurves:
if fcurve.group == group:
fcurve.mute = mute
actions_affected += 1
return actions_affected
class ROOTMOTION_OT_enable_all(bpy.types.Operator):
bl_idname = "rootmotion.enable_all"
bl_label = "Enable All Root Motion"
bl_description = "Unmute the root bone channel group in all animations"
bl_options = {'REGISTER', 'UNDO'}
def execute(self, context):
actions = set_root_motion_mute(False)
self.report({'INFO'}, f"Root motion enabled across {actions} action(s)")
return {'FINISHED'}
class ROOTMOTION_OT_disable_all(bpy.types.Operator):
bl_idname = "rootmotion.disable_all"
bl_label = "Disable All Root Motion"
bl_description = "Mute the root bone channel group in all animations"
bl_options = {'REGISTER', 'UNDO'}
def execute(self, context):
actions = set_root_motion_mute(True)
self.report({'INFO'}, f"Root motion disabled across {actions} action(s)")
return {'FINISHED'}
class ROOTMOTION_PT_panel(bpy.types.Panel):
bl_label = "Root Motion"
bl_idname = "ROOTMOTION_PT_panel"
bl_space_type = 'VIEW_3D'
bl_region_type = 'UI'
bl_category = "Root Motion"
def draw(self, context):
layout = self.layout
col = layout.column(align=True)
col.scale_y = 1.4
col.operator("rootmotion.enable_all", text="Enable All Root Motion", icon='PLAY')
col.separator(factor=0.5)
col.operator("rootmotion.disable_all", text="Disable All Root Motion", icon='PAUSE')
classes = (
ROOTMOTION_OT_enable_all,
ROOTMOTION_OT_disable_all,
ROOTMOTION_PT_panel,
)
def register():
for cls in classes:
bpy.utils.register_class(cls)
def unregister():
for cls in reversed(classes):
bpy.utils.unregister_class(cls)
if __name__ == "__main__":
register()
Binary file not shown.

After

Width:  |  Height:  |  Size: 488 KiB

@@ -0,0 +1,12 @@
-------------------------------------------------------
License:
CC0 1.0 Universal (CC0 1.0)
Public Domain Dedication
https://creativecommons.org/publicdomain/zero/1.0/
------------------------------------------------------
Models by @Quaternius
Consider supporting me on Patreon!
https://www.patreon.com/quaternius
@@ -0,0 +1,20 @@
The Universal Animation Library comes in two files: the one ending in _RM has root motion baked into every animation, while the other has root motion disabled.
Explore all the animations in the Animation Viewer!
https://quaternius.com/animviewer.html
-------------------------------------------------------
License:
CC0 1.0 Universal (CC0 1.0)
Public Domain Dedication
https://creativecommons.org/publicdomain/zero/1.0/
------------------------------------------------------
Models by @Quaternius
Consider supporting me on Patreon!
https://www.patreon.com/quaternius
-------------------------------------------------------
Join the Discord Server:
https://discord.gg/vJqnRUYRfT
Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 232 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 145 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+132
View File
@@ -0,0 +1,132 @@
# Character Model Registry
Naming law for `characters/`. **Scope: this system applies only to newly created
variants derived from mako and lena** — every such artifact from here on, *wherever
it is produced*. (This clause used to read "anything born in this folder", which let
the AccuRig rig baits get built into `ariki-game/assets/models/characters/rig-work/`
and slip the net entirely; they were moved here 2026-08-06. A character artifact
generated in another repo is still governed by this file and still belongs under
`characters/`.)
Legacy models elsewhere — game-side `derived-bodies/`, old intermediates, historical
race-sources — keep their names and are NOT registered here; they will eventually be
replaced by files that follow this system.
Modeled on the dance registry (`.agents/wiki/dances/REGISTRY.md`): numbers are never
reused, superseded files are archived, never deleted.
## Filename grammar
```
<character>[_<variant>...]_<stage>_<format>_v<NN>.<ext>
```
- **character** — registered name from the Characters table below (`lena`, `mako`).
New characters get a row here before their first file exists.
- **variant** — optional sculpt-level descriptors (`elder`, `muscular`). A new
variant is a new folder and a new Characters-table row.
- **stage** — exactly one token from the closed vocabulary below.
- **format** — file format tag matching the extension (`fbx`, `glb`).
- **v\<NN\>** — mandatory two-digit version, per-artifact.
All lowercase snake_case, `[a-z0-9_]` only. No dates, no status words, no hyphens.
### Stage vocabulary (closed set — extending it is an edit to this table)
| token | meaning |
|---|---|
| `sculpt` | unrigged full-res mesh (sculpt-level alteration) |
| `decimated` | post-decimation, unrigged |
| `tpose` | pose-prep export for a rigging tool |
| `accurig` | AccuRig output, CC_Base_* skeleton |
| `mixamo` | Mixamo-rigged (legacy lane) |
| `quatskin` | 65-bone Quaternius UAL game skeleton |
| `lod<NN>` | LOD at NN% of tris (appended after `quatskin`) |
| `toned` | skin-tone pass (appended after `quatskin`) |
## The rules
1. **Originals are read-only; delivered names immutable** — even misspelled or
inconsistent (`female_lena_tripo` keeps its missing `_v1`). Identity is recorded
in this registry, never fixed on disk. Renaming would fork identity vs the
name-synced mirror in `ariki-game/assets/models/characters/race-sources/` and
risk breaking FBX-internal texture paths.
2. Every new derived file matches the grammar above and starts with a registered
character name. Canonical male spelling is **`mako`** — the AccuRig-era `moka`
typo and `moko` are never used in new files.
3. Exactly one stage token per filename.
4. Version is per-artifact; **ancestry lives in this registry, never in the name**
(a `quatskin_v01` built from `decimated_v03` does not encode the parent's version).
5. **Status lives in this registry, never in a filename** — no `_candidate`,
`_final`, `_locked`.
6. New geometry identity = new character-variant folder (e.g. `female/lena_elder/`);
re-run of the same recipe = version bump. Test: "would the game treat it as a
different character/body?" → new variant; "a better attempt at the same thing?"
→ bump. Numbers never reused.
7. FBX or loose-texture models get a folder named exactly the model basename
(e.g. `mako_accurig_fbx_v01/`); loose textures inside are `<character>_<map>.<ext>`
(`mako_basecolor.jpg`) — no version chains in texture names. `.fbm/` folders and
Tripo UUID internals are never renamed.
8. **Boundary law:** snake_case = animation-repo working names; PascalCase `Ariki_*`
= game-repo ship names. The rename happens exactly once, at import into
`ariki-game/assets/quaternius/derived-bodies/`, and is recorded in the ship-name
column below. Never create `Ariki_*` files in this repo.
9. Superseded files move to `characters/archive/` — never deleted, never renamed.
10. Every new file under `characters/` (outside `archive/`) gets a row in the
Derived artifacts table before its first commit.
11. **`characters/rig-work/` is exempt from the grammar and from rule 10** — it
holds disposable AccuRig rig carriers ("baits"): decimated FBX copies whose
only job is to carry a skeleton out of AccuRig and back. They are never
shipped and never contribute geometry to a registered artifact (the graft
takes geometry from the GLB parent and only the skeleton from the bait), so
they get no row of their own — instead the grafted body's **notes** column
names the bait it got its skeleton from. Keeping their tool-era names is
deliberate: they must stay recognisable as throwaway. See that folder's
`README.md`.
## Originals
Never modified, never renamed, never resaved in place. Verify with
`Get-FileHash -Algorithm SHA256`.
| file | sha256 | delivered by | tool | date | identity note |
|---|---|---|---|---|---|
| `originals/male/male_base_bald_tripo_v1.fbx` | `f7c884bb8b8a71351b1448fa355658459a61a2537fdd48f9c72e52709693608c` | Özlem | Tripo | 2026-08-04 (copied from ariki-game race-sources) | mako generation 1, unrigged A-pose ~1.9M tris |
| `originals/male/male_base_bald_tripo_v1.glb` | `59df0de4b097428324f6b9048e0eeb7e8e034062aa8c8dad7e1eba1937be8aca` | Özlem | Tripo | 2026-08-04 (copied from ariki-game race-sources) | mako generation 1 |
| `originals/female/female_lena_tripo.fbx` | `c3a7cb1bf4958b27bd81b6bf73fd132c88f7a582d045c8936992d5d22bfd4e8e` | Özlem | Tripo | 2026-08-04 (copied from ariki-game race-sources) | lena generation 1 — delivered name has no `_v1`; treat as v1 |
| `originals/female/female_lena_tripo.glb` | `de17a1e7d56cefc3e07596bfb08efc65b41d04fac709c27b76500729e4fbb4b8` | Özlem | Tripo | 2026-08-04 (copied from ariki-game race-sources) | lena generation 1 |
### Canonical-name aliases
Byte-identical copies of the originals under grammar-conforming names, so tools can
reference canonical names without touching the immutable delivered files. Same
read-only guard, same checksums as their sources above.
| file | byte-identical to | date |
|---|---|---|
| `originals/female/lena_sculpt_fbx_v01.fbx` | `female_lena_tripo.fbx` | 2026-08-04 |
| `originals/female/lena_sculpt_glb_v01.glb` | `female_lena_tripo.glb` | 2026-08-04 |
| `originals/male/mako_sculpt_fbx_v01.fbx` | `male_base_bald_tripo_v1.fbx` | 2026-08-04 |
| `originals/male/mako_sculpt_glb_v01.glb` | `male_base_bald_tripo_v1.glb` | 2026-08-04 |
## Characters
| character | gender | parent original | description | status |
|---|---|---|---|---|
| `mako` | male | `male_base_bald_tripo_v1` | bald base male, Tripo sculpt. Spelling history: game wiki "Mako", AccuRig-era typo "moka" — canonical filename token is `mako` | active |
| `lena` | female | `female_lena_tripo` | base female, Tripo sculpt | active |
| `lena_nude` | female | `female_lena_tripo` | lena with the sculpted-in underwear removed: no bra, no briefs, breasts with volume and no nipples, featureless crotch. Separate variant rather than a version of `lena` because the game selects it as a different body (rule 6) | active |
## Derived artifacts
Status vocabulary: `wip / candidate / approved / shipped / archived / rejected`.
| name | character | parent file | stage | tool/script | date | status | ship name | notes |
|---|---|---|---|---|---|---|---|---|
| `female/lena_nude/lena_nude_quatskin_glb_v01.glb` | `lena_nude` | game-side `Ariki_Female_QuatSkin.glb` (+ `Ariki_Female_QuatSkin_ChestV1.glb` as breast-shape donor) | `quatskin` | `tools/make_lena_nude_body.py` (`--size 1.0`) | 2026-08-04 | candidate | `Ariki_Female_QuatSkin_Nude.glb` (not yet released) | sha256 `931c0948a3b0201dbfbdd4d2671c3ae41688afc592a29e332bc2bab234aa1ad3`. Texture embedded, internal image name `lena_nude_basecolor` (no ship name baked inside a WIP asset). 31,670 v / 65 joints / 1.777 m; passes `tools/verify_body_variant.py` against the canonical body. Vertex count is below stock by design — the base mesh's open slits are welded shut, since the painted underwear had been hiding them. `--size` is breast volume; a different size is a version bump, not a new variant |
| `female/lena_nude/lena_nude_basecolor.png` | `lena_nude` | game-side `Ariki_Female_QuatSkin_Lena_Body_Toned.png` | — (texture) | `tools/_bake_nude_body_texture.py` | 2026-08-04 | candidate | `Ariki_Female_QuatSkin_Nude_Lena_Body_Toned_nude.png` (not yet released) | sha256 `576fcf614cfcb19befd5bd4fb57210b526db2754f7d35468b21252f31528c9f2`. 4096² albedo with bra and briefs inpainted out to uniform skin, no tan line. Loose copy for iteration; the shipped GLB embeds it. Unversioned per rule 7 |
| `female/lena_nude/lena_nude_sculpt_glb_v01.glb` | `lena_nude` | `originals/female/female_lena_tripo.glb` (hires, pre-decimation) + game-side `Ariki_Male_QuatBody_D_RigCandidate.glb` (crotch donor) | `sculpt` | `female/lena_nude/hires_claude/` pipeline (00 weld → 03 sculpt → 06 crotch+membrane → 05/05b texture+grade → 08 export); reference `female/lena_nude/reference_breasts.jpg` | 2026-08-05 | candidate | — (game asset derives via decimation later) | sha256 `cb781cf376dc046b108d1c00e2368e0ed7e74fde5a783bedc6fc435818e4b2c7`. 953,966 v / 1,907,931 tris / 0.9792 units tall, unrigged. Full-density nude: garment membrane-removed, C-cup round breasts (apex projection 0.040 units, cleavage gap 7 mm), no nipples; crotch = male-GLB donor + bi-harmonic gusset heal (featureless). Basecolor + normal + rm embedded; fabric repainted by 3D-nearest skin fill, rosy chest V graded out to uniform skin. Masters: `hires_claude/06_final.blend` (geometry), `07_polished.blend` (textured) |
| `female/lena_nude/lena_nude_sculpt_glb_v02.glb` | `lena_nude` | v01 geometry + `originals/female/female_lena_tripo.glb` textures | `sculpt` | `hires_claude/05_texture.py` in `patch` mode (garment texels only, mirror-averaged fill from her own skin) + `06f` fold melt; master `hires_claude/10_patch.blend` | 2026-08-05 | candidate | — | sha256 `4a8c811305352a5fc60a58f6146c6797de03131da56dbd4073f90580d2e59e9b`. Same nude geometry as v01 but ORIGINAL skin everywhere (rosy chest V, arm/leg tones kept); only the bra/briefs/strap/crotch texels are refilled. Jeremy's preference after v01's global tone grade changed too much of her look |
| `female/lena_nude/lena_nude_sculpt_glb_v04.glb` | `lena_nude` | `lena_nude_sculpt_glb_v03.glb` (master `hires_claude/29b_final.blend`) | `sculpt` | `hires_claude/33_bust_variants.py` (size study) → `34_apply_bust.py --k 0.5`; master `hires_claude/34_v04.blend` | 2026-08-06 | candidate | — | sha256 `88931098f27915f5a498a1bccc014e800af7e8fff3e1d43faf8b97616c307910`. 860,389 v / 1,721,805 tris, geometry identical to v03 except the bust. **Guided by the approved concept turnaround** (`exchange/INBOX/lena-base-turnaround`, GPT Image 2, approved by Can 2026-08-06): `32_ref_measure.py` compared reference silhouette against mesh and found proportions already agree — 6.43 vs 6.08 heads, waist/hip 0.504 vs 0.505, widths within 5% — with the bust the one real gap. Breast forms rescaled to 50% of their projection off a bi-harmonic chest wall, taking bust projection +0.0210 → **+0.0155 H against the reference's +0.0156**. 68,781 verts moved (max 27.8 mm, median 5.5 mm in-region), zero movement outside the chest region, height unchanged. Texture/UVs untouched from v03. Caveats: the projection metric saturates below k≈0.5 (k=0.35 also reads +0.0154) and the reference reading may be inflated by the T-pose arm crossing the chest in the side view, so 0.5 is a floor rather than a midpoint; front renders cannot distinguish sizes (frontal key light flattens the chest) — judge from the side |
| `female/lena_nude/lena_nude_decimated_glb_v01.glb` | `lena_nude` | `lena_nude_sculpt_glb_v03.glb` (master `hires_claude/29b_final.blend`) | `decimated` | `hires_claude/30_decimate.py` (COLLAPSE ratio **0.037728**, bisected to hit the vertex target) + `30b_fix_winding.py` | 2026-08-06 | candidate | — | sha256 `b11940bb46732ae0a60e1624e88e76fa48dcc3e3af49a6363348521bb7c52cc8`. 31,964 v / 64,957 tris in Blender (46,810 / 64,957 as exported, UV seams split verts) — 3.72% of the sculpt, matched to `lena_nude_quatskin_glb_v01.glb`'s 31,670 v at Jeremy's request. Unrigged, no skins, transforms applied; basecolor + normal + rm embedded. Height preserved to +0.12 mm, bbox unchanged. **Built as input for the re-atlas lane** (`24_seams.py`): collapse destroys Tripo's 5,870-chart UV layout, so the carried TEXCOORD_0 smears badly and is expected to be replaced, not reused. Residual: 25 backfacing tris of 64,957 (down from 61; the remainder sit in non-manifold knots where consistent-orientation oscillates), plus the sculpt's inherited 835 boundary / 1649 non-manifold edges |
| `female/lena_nude/lena_nude_sculpt_glb_v03.glb` | `lena_nude` | v02 chain head `hires_claude/10_welded.blend` | `sculpt` | `hires_claude/` 17 line heal → 26 median despeckle + cleavage fillet → 25 flipped-face repair → 29 on-body tone levelling → 08 export; masters `27_clean.blend` (geometry), `29b_final.blend` (textured) | 2026-08-06 | candidate | — | sha256 `7a8fe4c45d3af6acb7b39c2fac5fcc2aadf910f8d61a4a7d046911125a6e8baa`. 860,389 v / 1,721,805 tris. Jeremy's three v02 notes: (1) cut lines — reduced, not gone: shading kinks >12° 9704 → ~6900, ribcage/thigh lines gone frontally, waistband + underbust still legible under raking light; (2) discolouration — the pale bra/briefs panels are gone, on-body tone gap patch-vs-skin 0.0102 → 0.0068 luma; (3) cleavage — sternum fillet radius 9.5 mm → no sharp sample, notch depth 44 → 31 mm. Also 1511 → 10 flipped faces (the black dashes). Still open: 1649 non-manifold + 830 boundary edges (every fill refused them). See the lane README for the four dead ends this version ruled out |
| `female/lena_nude/lena_nude_decimated_glb_v02.glb` | `lena_nude` | `lena_nude_decimated_glb_v01.glb` (geometry byte-for-byte unchanged) | `decimated` | `hires_claude/24_seams.py` (anatomical seams + minimum-stretch unwrap) then `hires_claude/31_reatlas.py` (map resample) | 2026-08-06 | candidate | — | sha256 `cd3d53550470ae5a1958dc473ce11793f746b13f78ac911e6d426b6f7b9f5825`. **Re-atlas only — same 31,964 v / 64,957 tris, same shape, same material, unrigged.** Replaces Tripo's 5,870-chart atlas with 14 anatomical charts (torso, head, 2 arms, 2 legs, 2 hands ×2, 2 feet ×2): coverage 62.0→68.6%, texel density spread 1.8x→1.00x, per-triangle area-scale p95 2.55→1.77 / p99 4.25→2.27. Exported vertex count drops 46,810→42,980 because the new layout has far fewer UV seams. Maps resampled into the new layout at 4096², normal map rotated per face into its new tangent frame (p50 81°). Renders identical to v01: mean pixel \|Δ\| 0.0012, p99 0.0118 over five views (`hires_claude/33_ab.py`). Version bump, not a new variant, per rule 6 — same body, better atlas |
@@ -0,0 +1,61 @@
# Handoff: decimated nude Lena → retexture / re-atlas lane
**2026-08-06.** Jeremy asked for the healed nude Lena decimated to the game-body budget and passed
to the retexture / re-atlas work, "to see how they do".
## The file
`characters/female/lena_nude/lena_nude_decimated_glb_v01.glb`
| | |
|---|---|
| sha256 | `b11940bb46732ae0a60e1624e88e76fa48dcc3e3af49a6363348521bb7c52cc8` |
| parent | `lena_nude_sculpt_glb_v03.glb` (master `hires_claude/29b_final.blend`) |
| recipe | `hires_claude/30_decimate.py` then `30b_fix_winding.py` |
| ratio | Decimate **COLLAPSE 0.037728** — bisected, not guessed |
| density | 31,964 v / 64,957 tris in Blender (46,810 v as exported; UV seams split verts) |
| why that density | matched to the shipped `lena_nude_quatskin_glb_v01.glb` (31,670 v), Jeremy's call |
| rig | none — unrigged, no skins, transforms applied |
| maps | basecolor + normal + rm embedded (3 images, wired baseColor / metallicRoughness / normal) |
Ready to load directly: `24_seams.py` accepts a `.glb` as its first argument and applies transforms
itself, so this drops in with no preparation.
## Read this before you spend a pass on the UVs
**The carried `TEXCOORD_0` is not worth preserving.** Collapse decimation shreds Tripo's
5,870-chart atlas — in the textured review render (`hires_claude/review_30/chest_tex_0.png`) the
transplanted skin grain smears into streaks and the throat picks up a red blotch. That is expected
and is exactly the problem the ~14-chart anatomical atlas solves. Judge this mesh from
`review_30/full_clay_0.png` (clay, no material) and treat the UVs as scrap.
**Decimation improved one thing.** The waistband/underbust lines that survive on the hires sculpt
are nearly invisible at 32k — the collapse averaged them out. So the residual line problem is a
hires-only concern; don't go hunting for it here.
## Known defects being handed over
- **25 backfacing triangles** of 64,957. Down from 61 via `30b_fix_winding.py`, but the remainder
sit in non-manifold knots where `normals_make_consistent` oscillates (61 → 260 → 39 → 25 → 25).
They render black. If your pass rebuilds topology, they will likely vanish for free.
- **835 boundary edges / 1649 non-manifold edges**, inherited from the sculpt. Every fill attempt
refused them (`bmesh.ops.holes_fill`, edit-mode `mesh.fill_holes`, and a per-loop pass) — they
are dangling flaps and slivers, not perforations. Documented in the lane README.
- **The head is untouched original Tripo output.** Every geometry pass in this lane stopped below
the chin (z < 0.90) on purpose, so lips/nostrils/eyes still have the source smearing. If the
retexture covers the face, that is new ground, not a regression.
- **She reads glossy** — the `rm` map was neutralised to a median over the old garment texels, so
the whole body is shinier than skin should be. Untouched deliberately: an earlier attempt at
correcting roughness made her plasticky (see the lane README's fourth dead end).
## Provenance the three fixes came from
v03 addressed Jeremy's three notes on v02 — cut lines, discoloured bra/crotch patches, and the
cleavage. Measured outcomes and the four dead ends that were ruled out on the way are in
`README.md` under "Hi-res sculpt v03". Worth skimming before re-treading the geometry.
## Numbering note
This lane had two agents working in it simultaneously on 2026-08-06. `20`/`21` exist twice under
different filenames (`20_atlas_probe.py` + `20_cleavage.py`, `21_seam_probe.py` + `21_tone.py`).
Nothing was overwritten, but pick a fresh number rather than assuming the next one is free.
@@ -0,0 +1,87 @@
# Handoff: re-atlased nude Lena — `lena_nude_decimated_glb_v02.glb`
**2026-08-06.** Jeremy asked for the decimated nude body re-atlased and versioned. This is a
**UV + texture change only**: the geometry is byte-for-byte the v01 mesh.
## The file
`characters/female/lena_nude/lena_nude_decimated_glb_v02.glb`
| | |
|---|---|
| sha256 | `cd3d53550470ae5a1958dc473ce11793f746b13f78ac911e6d426b6f7b9f5825` |
| parent | `lena_nude_decimated_glb_v01.glb` |
| recipe | `hires_claude/24_seams.py``hires_claude/31_reatlas.py` |
| mesh | 31,964 v / 64,957 tris in Blender — **identical to v01** |
| exported verts | 42,980 (v01: 46,810) — the new layout needs fewer seam splits |
| maps | basecolor + normal + rm embedded, 4096², JPEG q95, correct colorspaces |
| rig | none — unrigged, transforms applied |
Rebuild:
```
blender --background --python 24_seams.py -- ../lena_nude_decimated_glb_v01.glb atlas_v02
blender --background --python 31_reatlas.py -- atlas_v02/seamed.blend atlas_v02 \
../lena_nude_decimated_glb_v02.glb
```
## What changed
| | Tripo atlas (v01) | new atlas (v02) |
|---|---|---|
| UV islands | 5,870 | 968 |
| charts holding 99% of the area | 84 | **14** |
| atlas coverage | 62.0% | **68.6%** |
| texel-density spread | 1.8× | **1.00×** |
| per-triangle area-scale p95 / p99 | — | 1.77 / 2.27 |
The 14 charts are torso, head, two arms, two legs, two hands (back + palm each) and two feet
(upper + sole each). Seams run up the back midline, the back of each arm and the inner leg, with
rings at neck, shoulder, wrist, hip and ankle — every landmark measured off the mesh's own
cross-section radius profiles, so the rules port to any density.
**It looks the same, on purpose.** Five matched views diff at mean \|Δ\| 0.0012, p99 0.0118, with
0.19% of body pixels over 0.05 (`33_ab.py`). Her original tones are untouched — this pass bought a
paintable, uniform-density layout, not a new look.
## Three traps this pass hit, so you don't
1. **Colorspace is not cosmetic.** `image.pixels` returns *linear* values for an sRGB image and
*raw* values for a Non-Color one, and re-encodes the same way on save. The normal and rm maps
are Non-Color; writing them into default (sRGB) images gamma-encoded them, so 0.5 came back as
0.21 — every stored normal became a large false perturbation and she rendered dark and
wet-plastic. New maps must inherit `colorspace_settings.name` from their source.
2. **Use the minimum-stretch solver, not angle-based.** ABF is conformal: it preserves angles and
is free to crush area. At her folds it squashed 4,502 triangles (3.7% of her surface) below one
texel, and they came out untextured. `method='MINIMUM_STRETCH'` took per-triangle area-scale p05
from 0.03 to 0.33 and the untextured count from 4,502 to 569 (0.56%, all genuine sub-texel
slivers absorbed by the 16 px padding pass).
3. **Watch the LOW tail of the distortion stat.** p95/p99 measure stretching; it was p05 = 0.03 —
crushing — that was losing whole patches. I read past it twice.
Also tried and rejected: splitting the 1,649 non-manifold edges before unwrapping. It cost +3,512
verts and made the collapsed-face count *worse* (4,502 → 5,482), because the collapse was a solver
problem, not a topology one.
## Still broken, and not from this pass
**The ragged grey tears on the face, across the underbust, and as flecks down the arms and shins
are in v01 already** — I rendered v01 through the same script to confirm, and they are identical in
position and shape (`hires_claude/beauty_input_v01/` vs `beauty_atlas_v02/`). They are the 835
boundary edges / dangling flaps the v01 handoff lists as a known defect. A re-atlas cannot fix
geometry. They need mesh surgery before this body ships.
The other v01 defects carry over untouched: 25 backfacing triangles, the head still being original
Tripo output, and the glossy `rm` map.
## What this does NOT do
Jeremy's stated goal is to **keep her original texture and replace only the breast and underwear
region**. This pass deliberately does not touch colour — it only rehouses it. The re-authoring step
(a gradient-domain fill pinned to her surrounding skin at the boundary, so the old bra/briefs
outline cannot ghost) is the next stage, and it is much easier now that the charts are anatomical
and uniform-density.
Note that keeping her original texture keeps its known incoherence: her feet read 0.11 luma darker
and 50% redder than her belly, and the rosy chest V remains. Measured in
`hires_claude/21_seam_probe.py`; see the `lena-tripo-atlas-chart-soup` note.
+195
View File
@@ -0,0 +1,195 @@
# lena_nude — nude body variant (WIP, not released)
Lena's body with the underwear removed: no bra, no briefs, breasts with volume and **no
nipples**, smooth featureless crotch. Staged here until it is approved for the game.
| file | role |
|---|---|
| `lena_nude_quatskin_glb_v01.glb` | the body — 65-bone Quaternius game skeleton, texture **embedded** (self-contained) |
| `lena_nude_basecolor.png` | the baked albedo, loose, for further iteration |
Ship name at release is `Ariki_Female_QuatSkin_Nude.glb` in
`ariki-game/assets/quaternius/derived-bodies/`. Per `../../REGISTRY.md` rule 8 that rename happens
exactly once, on import — never create `Ariki_*` files in this repo.
## How it was made
Generators live in **this** repo (moved out of ariki-game 2026-08-06 — they author a character,
so `../../REGISTRY.md` governs them; see `.agents/wiki/ARCHITECTURE.md` "Tool provenance"). They
still *read* the canonical rigged bodies from the game repo (`Ariki_Female_QuatSkin.glb` and the
`_ChestV1` breast-sculpt donor), resolved via `$ARIKI_GAME` or a sibling `ariki-game/` checkout —
if neither resolves they abort with the path they tried, rather than reading the wrong file.
Run from `C:\Users\Jeremy\tinqs\animation`:
```
blender --background --python tools/_bake_nude_body_texture.py
# defaults to writing lena_nude_basecolor.png into THIS folder
blender --background --python tools/make_lena_nude_body.py -- \
--out characters/female/lena_nude/lena_nude_quatskin_glb_v01.glb \
--size 1.0 \
--texture characters/female/lena_nude/lena_nude_basecolor.png
python tools/verify_body_variant.py \
characters/female/lena_nude/lena_nude_quatskin_glb_v01.glb
```
`--size` is breast volume as a multiple of the `_ChestV1` sculpt (0.75 / 1.0 / 1.25 were
rendered for review; **v01 ships 1.0**). A new size is a **version bump**, not a new variant —
same recipe, different parameter. (This README and `../../REGISTRY.md` both called the flag
`--scale` until 2026-08-06; the script has always parsed `--size`.)
## Facts worth knowing before editing this
- **The underwear is the body surface.** A ray-crossing census finds exactly two surface crossings
at every height — thigh, belly, bra cup, briefs alike. There is no skin underneath, so the
garment can only be flattened into the skin, never deleted. Deleting it opens a hole, which is
precisely the defect in the game repo's `Ariki_Female_QuatSkin_Bare.glb` (black cavity across
the chest, visible even untextured). Do not use `_Bare` geometry for anything.
- **The base mesh is not watertight** (3,809 boundary edges). The painted underwear was hiding
open slits; with it gone they read as a dotted line of black triangles, so the builder welds
them shut in the torso band. That is where this file's lower vertex count comes from.
- **Use clay renders to judge geometry.** `tools/_render_body_closeup.py --clay` drops the
material; it is the only reliable way to tell a mesh defect from a painted one. Several dead
ends here came from mistaking painted bra shadow for a dent, and vice versa.
## Working files
This lane follows `.agents/rules/working-files.md`: **a version is a milestone,
not a step.** Fixing the bra produces one saved file at the end, not one per cut.
`hires_claude/.lanekeep` pins the only `.blend` files that survive here — the
masters named in the Derived-artifacts table of `../../REGISTRY.md`, plus the
live chain head. Everything else a step script wrote is scratch: the `NN_*.py`
recipe plus `masks.npz` regenerates it from the master above it.
```
python tools/prune_lane.py characters/female/lena_nude --recursive # dry run
```
Two things this lane learned the hard way:
- **Check for a running Blender before pruning.** A heal step takes ~7 minutes
and writes only at the end; the lane gained a new head (`17_healed.blend`)
during the audit that produced this section.
- `hires_work/` is the abandoned GLM-agent attempt (see its
`glm_run_attempt*.log`), superseded by `hires_claude/`. It has no `.lanekeep`,
so every `.blend` in it classifies as scratch.
The root-level `lena_sculpt_glb_v01-nude-Zephyr.blend` is hand-authored — no
step script rebuilds it — so the pruner reports it as UNKNOWN and never touches
it. Decide its fate by hand.
## Status
Verified against the canonical body: 1 primitive, identical attribute set (no tangents), material
`MI_Body_Lena`, 65 joints in identical order, height 1.777 m, inverse-bind drift 4.6e-6, weights
normalised, texture embedded. Posed sweep under a UAL clip is clean (no spikes or tearing).
Known cosmetic limits: a faint tonal seam where the fill meets untouched skin at extreme zoom, and
no skin-pore detail inside the filled area — that smoothness is also what guarantees no nipple or
crotch detail can appear.
Full history and the reasoning behind each choice: `~/.claude/plans/i-have-a-lena-velvet-ripple.md`.
## v04 — bust matched to the approved concept turnaround (2026-08-06)
`lena_nude_sculpt_glb_v04.glb`, master `hires_claude/34_v04.blend`. Recipes
`32_ref_measure.py` (measure) → `33_bust_variants.py` (size study) → `34_apply_bust.py` (k=0.5).
Guiding art: `exchange/INBOX/lena-base-turnaround/` — front/side/back T-pose renders, GPT Image 2,
approved by Can 2026-08-06.
**The reference is the same character, not a new one.** Measured against the front/side silhouettes:
| landmark | reference | v03 mesh |
|---|---|---|
| heads tall | 6.43 | 6.08 |
| waist / hip | 0.504 | 0.505 |
| waist width | 0.1260 H | 0.1318 H |
| hip width | 0.2500 H | 0.2608 H |
| **bust projection** | **+0.0156 H** | **+0.0210 H** |
Proportions already agree and the widths sit inside silhouette-threshold noise for generated art, so
only the bust was changed: the breast forms are rescaled to 50% of their projection off a
bi-harmonic chest wall, landing at +0.0155 H. Nothing outside the chest region moves (verified 0.0000 mm)
and height is unchanged, so every v03 heal survives.
Two traps for whoever tunes this next:
- **The projection metric saturates below k≈0.5** — k=0.35 measures +0.0154, indistinguishable from
k=0.5. Below that the "deepest slice" row migrates and stops tracking breast volume, so the number
cannot be pushed lower meaningfully. Treat 0.5 as a floor, not a midpoint. The reference reading may
also be inflated by the T-pose arm crossing the chest in a side view, which would mean the real
reference bust is smaller still.
- **Front renders cannot tell the sizes apart.** k=0.5, 0.65 and 0.8 look identical head-on because
the frontal key light flattens the chest. Judge from the side view only. Variants are in
`hires_claude/review_33/k{100,080,065,050,035}/`.
Re-running at another k takes seconds: `_bust_wall_cache.npy` holds the 8-minute chest-wall solve.
Not addressed, and the largest remaining gap to the concept art: the reference is a **smooth
mannequin** with no scan noise, while this mesh still carries speckles and dents on the hips and
thighs, plus the untouched original head.
## Decimated for the retexture / re-atlas lane (2026-08-06)
`lena_nude_decimated_glb_v01.glb` — v03 collapsed to the game-body budget (31,964 v / 64,957 tris,
ratio 0.037728) as input for `hires_claude/24_seams.py`. Recipe `30_decimate.py` + `30b_fix_winding.py`.
**Read `HANDOFF_decimated_v01.md` before working on it** — in particular, its carried UVs are scrap
(collapse shreds Tripo's 5,870-chart atlas) and it ships with 25 known backfacing triangles.
## Re-atlased (2026-08-06)
`lena_nude_decimated_glb_v02.glb`**v01's geometry with a new UV atlas and resampled maps.**
Same 31,964 v / 64,957 tris; Tripo's 5,870 charts replaced by 14 anatomical ones (coverage
62.0 → 68.6%, texel-density spread 1.8× → 1.00×). Recipe `hires_claude/24_seams.py`
`hires_claude/31_reatlas.py`. It deliberately **looks identical** to v01 (mean pixel |Δ| 0.0012
across five views) — the win is a paintable, uniform-density layout, not a new look.
**Read `HANDOFF_reatlas_v02.md`**, especially the colorspace and unwrap-solver traps, and note that
the grey tears on the face/underbust are v01's dangling-flap defect and are still there.
## Hi-res sculpt v03 (2026-08-06) — Jeremy's three notes on v02
`lena_nude_sculpt_glb_v03.glb`, masters `hires_claude/27_clean.blend` (geometry) and
`29b_final.blend` (textured). Recipes: `17_line_heal.py``26_finish.py`
`25_speckle.py --apply``29_tone3d.py``08_export.py`.
| note | measured outcome |
|---|---|
| cut lines | shading kinks >12° 9704 → ~6900; flipped faces (the black dashes) 1511 → 10. Ribcage and thigh lines gone frontally; waistband + underbust still legible under raking light |
| discoloured bra/crotch | on-body tone gap, patch interior vs surrounding skin: 0.0102 → 0.0068 luma. The pale panels in `beauty_v02_healed/` are gone in `beauty_v03b/` |
| cleavage | sternum fillet radius 9.5 mm → no sharp sample at any height; notch depth 44 → 31 mm |
### Four dead ends this version ruled out — do not repeat them
1. **The lines are not cracks; do not weld.** Stage 15 assumed the panel seams were disconnected
vertex runs. They are not: the mesh has only 830 boundary edges in 689 loops of 35 edges, and
for kink verts the nearest vertex outside the 3-ring sits at 1.58× the local edge length
(median) against 1.73× for control skin. Welding at eps 1.2 mm — about the 1.85 mm mean edge
length — merged ordinary neighbours and tore the mesh, 830 → 24k boundary edges.
2. **They are not in the custom normals either.** Corner-vs-vertex deviation is 0.008° mean, 15
loops above 5°; clearing custom split normals changes nothing.
3. **A membrane is the wrong operator, and widening it is worse.** The relief is ONE VERTEX WIDE
(0.23 mm median at the centre, back to the 0.074 mm background by ring 2), so a collar-fixed
membrane pins itself to the defect's own shoulders. GROW 4 and 8 were no better than GROW 2
while moving material up to 38 mm. A **ring median** is correct: it deletes a 1-vertex outlier
and returns anything broader unchanged, so the underbust fold, clavicles and navel survive by
construction instead of by a tuned threshold.
4. **Never level tone from atlas-adjacent texels.** A Poisson solve whose boundary condition was
the mismatch against UV neighbours turned the patches into pale grey panels — UV adjacency is
not body adjacency, so the border mismatch came from other body parts and gutter. This is the
warning already in `05_texture.py`'s header. Sample on the body (KD over skin verts in 3D),
smooth the correction over the mesh graph, then rasterise.
Two process traps worth keeping: **restore the real material before saving**`18/20/25`
originally rendered clay last and then saved, so the file on disk kept the clay material and lost
its texture wiring, which is why the first tone attempt aborted with "could not find the wired
basecolor". And **the pristine textures are not inside working blends**: Blender purges the orphan
`.002/.003` copies on save, so read them from `00_welded.blend`.
Still open: 1649 non-manifold and 830 boundary edges — `bmesh.ops.holes_fill`, the edit-mode
`mesh.fill_holes` operator, and a per-loop pass all refused every one, which is why
`normals_make_consistent` could only reach 1198 of 1511 flipped faces before explicit per-face
reversal finished the job. None of this is propagated to the game body
(`lena_nude_quatskin_glb_v01.glb`), which is built independently on the 65-bone Quaternius rig.
@@ -0,0 +1,36 @@
# .lanekeep — .blend files pinned as MASTER in this lane.
# Everything else here that a step script wrote is scratch: regenerable by
# re-running the NN_*.py recipe from the pinned master above it.
# Read by tools/prune_lane.py. One filename per line, # for comments.
#
# Rule: a .blend earns a line here only when a registered artifact in
# characters/REGISTRY.md was built from it, or it is the head of the live chain.
# Intermediate attempts (the 06b-06h melt series, the 10_healed/rimheal/seamheal
# series) never get pinned — only the state at the end of the fix.
# --- phase roots ---
00_welded.blend # raw original, slits welded shut. Root of every branch;
# keeps a re-sculpt from needing a fresh weld+isolate pass.
# --- masters of registered artifacts (see characters/REGISTRY.md) ---
06_final.blend # lena_nude_sculpt_glb_v01.glb — geometry master
07_polished.blend # lena_nude_sculpt_glb_v01.glb — textured master
10_patch.blend # lena_nude_sculpt_glb_v02.glb — master (original skin retained)
27_clean.blend # lena_nude_sculpt_glb_v03.glb — geometry master (phase boundary:
# line heal + cleavage fillet + flipped-face repair, pre-texture)
29b_final.blend # lena_nude_sculpt_glb_v03.glb — textured master, HEAD 2026-08-06.
# 27_clean + on-body tone levelling (29_tone3d.py str 1.0, 25 passes)
34_v04.blend # lena_nude_sculpt_glb_v04.glb — master, HEAD 2026-08-06. 29b_final with
# the bust rescaled to k=0.5 per the approved concept turnaround.
10_welded.blend # parent of the v03 chain. Kept as the fallback until v03/v04 are
# accepted; 11-15 regenerate it from 10_patch, so unpin then.
# _bust_wall_cache.npy is the bi-harmonic chest wall for 29b_final (an 8-minute CG solve).
# Keep it while bust size is still in play: 33/34 reuse it and a different k then costs
# seconds instead of minutes. It is regenerable, and invalid for any other mesh — both
# scripts assert its shape against the vertex count before trusting it.
# 17_healed / 18_smoothnrm / 22_lines / 23_cleavage / 26_geo / 29_final and the
# rejected atlas-tone attempt were per-step snapshots and have been pruned; the
# 17/25/26/29 recipes rebuild them from 10_welded.
Binary file not shown.
@@ -0,0 +1,159 @@
# Probe the pre-decimated Tripo sculpt: is the sports top a SEPARABLE shell?
# Answers, per connected component (after welding UV-seam splits):
# size, z-range, open-boundary edge count, and what fraction of its faces are painted
# garment (colour-keyed on lena+glb_basecolor via each face's own UV texels).
#
# blender --background --python 01_probe.py -- <copy.glb> <checkpoint.blend>
import bpy, bmesh, sys, time
import numpy as np
argv = sys.argv[sys.argv.index("--") + 1:]
SRC, CKPT = argv[0], argv[1]
t0 = time.time()
def log(m):
print(f"[probe {time.time()-t0:6.1f}s] {m}", flush=True)
bpy.ops.wm.read_factory_settings(use_empty=True)
bpy.ops.import_scene.gltf(filepath=SRC)
ob = max([o for o in bpy.data.objects if o.type == 'MESH'],
key=lambda o: len(o.data.vertices))
me = ob.data
log(f"imported: {len(me.vertices)}v {len(me.polygons)}f, object '{ob.name}'")
bm = bmesh.new()
bm.from_mesh(me)
bmesh.ops.remove_doubles(bm, verts=list(bm.verts), dist=1e-6)
bm.to_mesh(me)
bm.free()
me.update()
log(f"after weld 1e-6: {len(me.vertices)}v {len(me.polygons)}f")
# save checkpoint so later stages skip the import+weld cost
bpy.context.preferences.filepaths.save_version = 0 # no .blend1 autosave
bpy.ops.wm.save_as_mainfile(filepath=CKPT)
log(f"checkpoint saved: {CKPT}")
n_v = len(me.vertices)
n_f = len(me.polygons)
# ---- connected components over faces (edge-connected), union-find in numpy ----
# face -> vertices
loop_tot = np.empty(n_f, dtype=np.int32)
me.polygons.foreach_get("loop_total", loop_tot)
loop_start = np.empty(n_f, dtype=np.int32)
me.polygons.foreach_get("loop_start", loop_start)
loops_v = np.empty(len(me.loops), dtype=np.int32)
me.loops.foreach_get("vertex_index", loops_v)
parent = np.arange(n_v, dtype=np.int64)
def find(a):
root = a
while parent[root] != root:
root = parent[root]
while parent[a] != root:
parent[a], a = root, parent[a]
return root
# union via edges
n_e = len(me.edges)
ev = np.empty(n_e * 2, dtype=np.int32)
me.edges.foreach_get("vertices", ev)
ev = ev.reshape(-1, 2)
for a, b in ev:
ra, rb = find(a), find(b)
if ra != rb:
parent[rb] = ra
log("union-find done")
root_of = np.array([find(i) for i in range(n_v)], dtype=np.int64)
uniq, inv, counts = np.unique(root_of, return_inverse=True, return_counts=True)
order = np.argsort(-counts)
log(f"components: {len(uniq)}")
# vertex positions
co = np.empty(n_v * 3, dtype=np.float64)
me.vertices.foreach_get("co", co)
co = co.reshape(-1, 3)
# boundary edges per component: edge belongs to 1 face only
# count faces per edge
edge_face_count = np.zeros(n_e, dtype=np.int32)
for p in me.polygons:
for ek in p.edge_keys:
pass # too slow; use loops instead
# faster: build edge keys from loops via me.polygons edge indices
# Blender exposes loop edges: me.loops[i].edge_index
loops_e = np.empty(len(me.loops), dtype=np.int32)
me.loops.foreach_get("edge_index", loops_e)
np.add.at(edge_face_count, loops_e, 1)
boundary_edge = edge_face_count == 1
log(f"boundary edges total: {boundary_edge.sum()}")
# ---- colour key per vertex (first-loop UV), calibrated on this texture ----
img = None
for i in bpy.data.images:
if "basecolor" in i.name.lower():
img = i
break
if img is None:
img = max(bpy.data.images, key=lambda i: i.size[0] * i.size[1])
w, h = img.size
log(f"texture '{img.name}' {w}x{h}")
px = np.empty(w * h * 4, dtype=np.float32)
img.pixels.foreach_get(px)
rgb = px.reshape(h, w, 4)[:, :, :3]
uvl = me.uv_layers.active.data
uv = np.empty(len(me.loops) * 2, dtype=np.float64)
uvl.foreach_get("uv", uv)
uv = uv.reshape(-1, 2)
# first loop per vertex
first_loop = np.full(n_v, -1, dtype=np.int64)
for li in range(len(loops_v) - 1, -1, -1):
first_loop[loops_v[li]] = li
has_uv = first_loop >= 0
vx = np.clip(uv[first_loop, 0], 0, 1) * (w - 1)
vy = np.clip(uv[first_loop, 1], 0, 1) * (h - 1) # bpy-imported UVs: already flipped
vcol = rgb[vy.astype(int), vx.astype(int)]
r, g, b = vcol[:, 0], vcol[:, 1], vcol[:, 2]
mx = vcol.max(axis=1)
mn = vcol.min(axis=1)
sat = np.where(mx > 1e-5, (mx - mn) / np.maximum(mx, 1e-5), 0)
rb = r / np.maximum(b, 1e-5)
# calibrate: thigh skin (z 0.28-0.34) vs briefs centre (z 0.50-0.56 front)
thigh = (co[:, 2] > 0.28) & (co[:, 2] < 0.34)
briefs = (co[:, 2] > 0.50) & (co[:, 2] < 0.56) & (co[:, 1] < 0) & (np.abs(co[:, 0]) < 0.05)
bra = (co[:, 2] > 0.64) & (co[:, 2] < 0.72) & (co[:, 1] < 0) & (np.abs(co[:, 0]) < 0.05)
for nm, s in (("thigh skin", thigh), ("briefs", briefs), ("bra front", bra)):
if s.sum():
log(f" [{nm}] n={s.sum()} sat p50={np.median(sat[s]):.3f} rb p50={np.median(rb[s]):.3f} "
f"rgb ({np.median(r[s]):.3f},{np.median(g[s]):.3f},{np.median(b[s]):.3f})")
# pick thresholds midway between garment and skin medians
sat_thr = (np.median(sat[briefs]) + np.median(sat[thigh])) / 2 if briefs.sum() and thigh.sum() else 0.45
rb_thr = (np.median(rb[briefs]) + np.median(rb[thigh])) / 2 if briefs.sum() and thigh.sum() else 1.9
garment_v = (sat < sat_thr) & (rb < rb_thr)
log(f"thresholds: sat<{sat_thr:.3f} rb<{rb_thr:.2f} -> {garment_v.sum()} garment verts")
# ---- per-component report (top 12 by size) ----
# per-vertex boundary flag
vb = np.zeros(n_v, dtype=bool)
vb[ev[boundary_edge].ravel()] = True
print("\n=== COMPONENTS (top 12 by vertex count) ===")
for ci in order[:12]:
root = uniq[ci]
m = root_of == root
zc = co[m, 2]
gfrac = garment_v[m].mean()
bcount = vb[m].sum()
print(f"comp root={root}: verts={m.sum():7d} z[{zc.min():.3f},{zc.max():.3f}] "
f"garment-painted={100*gfrac:5.1f}% boundary-verts={bcount}")
print("PROBE_DONE")
@@ -0,0 +1,158 @@
# Stage 2: isolate the garment on the welded hires sculpt, split it into functional zones, and
# save the masks + adjacency for the sculpt stage.
#
# blender --background --python 02_isolate.py -- <00_welded.blend> <masks.npz>
#
# Zones (per vertex):
# cups — bra front where the breasts live (gets wall + amplified mounds)
# shield — sternum strip the bra bridges (gets carved cleavage)
# toprest — rest of the top: band, straps, back panel (gets faired flat to skin)
# briefs — briefs incl. waistband/leg rims (gets faired featureless)
# plus 'hemband' — narrow ring around every garment/skin boundary (local crease fairing).
#
# The colour key was calibrated on THIS texture by 01_probe.py: garment sat~0.39/RB~1.65,
# skin sat~0.64/RB~2.80; thresholds are the midpoints. The z-guards keep the low-saturation
# face features (eyes, teeth, lips) out of the key's reach.
import bpy, sys, time
import numpy as np
argv = sys.argv[sys.argv.index("--") + 1:]
BLEND, OUT = argv[0], argv[1]
t0 = time.time()
SAT_THR, RB_THR = 0.518, 2.22
Z_GARMENT_LO, Z_GARMENT_HI = 0.40, 0.85 # below the head; briefs to over-shoulder straps
Z_TOP_SPLIT = 0.595 # briefs/top divide (waistband top 0.578 + margin)
GROW_RINGS = 6 # ~2 cm real at this density; must swallow the 1 cm hem lips
# cups zone (front bra): z and |x| bounds from the measured landmarks
CUP_Z_LO, CUP_Z_HI = 0.615, 0.745
CUP_X_MAX = 0.085
SHIELD_X = 0.014 # sternum strip half-width
def log(m):
print(f"[iso {time.time()-t0:6.1f}s] {m}", flush=True)
bpy.ops.wm.open_mainfile(filepath=BLEND)
ob = max([o for o in bpy.data.objects if o.type == 'MESH'],
key=lambda o: len(o.data.vertices))
me = ob.data
n_v = len(me.vertices)
log(f"loaded {n_v}v")
co = np.empty(n_v * 3, dtype=np.float64)
me.vertices.foreach_get("co", co)
co = co.reshape(-1, 3)
# --- per-vertex colour ---
img = next(i for i in bpy.data.images if "basecolor" in i.name.lower())
w, h = img.size
px = np.empty(w * h * 4, dtype=np.float32)
img.pixels.foreach_get(px)
rgb = px.reshape(h, w, 4)[:, :, :3]
loops_v = np.empty(len(me.loops), dtype=np.int32)
me.loops.foreach_get("vertex_index", loops_v)
uv = np.empty(len(me.loops) * 2, dtype=np.float64)
me.uv_layers.active.data.foreach_get("uv", uv)
uv = uv.reshape(-1, 2)
first_loop = np.full(n_v, len(loops_v), dtype=np.int64)
np.minimum.at(first_loop, loops_v, np.arange(len(loops_v), dtype=np.int64))
first_loop = np.minimum(first_loop, len(loops_v) - 1)
vx = (np.clip(uv[first_loop, 0], 0, 1) * (w - 1)).astype(int)
vy = (np.clip(uv[first_loop, 1], 0, 1) * (h - 1)).astype(int)
vcol = rgb[vy, vx]
mx = vcol.max(axis=1)
mn = vcol.min(axis=1)
sat = np.where(mx > 1e-5, (mx - mn) / np.maximum(mx, 1e-5), 0)
rb = vcol[:, 0] / np.maximum(vcol[:, 2], 1e-5)
# above z=0.80 only the shoulder straps qualify — keep the chin/lips (low-saturation paint)
# out by requiring lateral offset there
zone_guard = (co[:, 2] > Z_GARMENT_LO) & (co[:, 2] < Z_GARMENT_HI) & ((co[:, 2] < 0.80) | (np.abs(co[:, 0]) > 0.025))
key = (sat < SAT_THR) & (rb < RB_THR) & zone_guard
# The shoulder straps' paint is much closer to skin (median sat 0.57-0.61 vs the bra body's
# 0.39) — the main key catches only ~a third of each strap and the rest survived as raised
# geometry. In the strap corridor a relaxed threshold seeds them; the ring grow fills the rest.
strap_zone = (co[:, 2] > 0.72) & (co[:, 2] < 0.85) & (np.abs(co[:, 0]) > 0.03) & (np.abs(co[:, 0]) < 0.105)
key |= strap_zone & (sat < 0.55)
log(f"colour key (+strap corridor): {key.sum()} verts")
# --- adjacency (numpy CSR-ish over edges) ---
n_e = len(me.edges)
ev = np.empty(n_e * 2, dtype=np.int32)
me.edges.foreach_get("vertices", ev)
ev = ev.reshape(-1, 2)
order = np.concatenate([ev[:, 0], ev[:, 1]])
nbr = np.concatenate([ev[:, 1], ev[:, 0]])
srt = np.argsort(order, kind="stable")
order_s = order[srt]
nbr_s = nbr[srt]
ptr = np.searchsorted(order_s, np.arange(n_v + 1))
log("adjacency built")
def grow(mask, rings):
out = mask.copy()
for _ in range(rings):
sel = np.zeros(n_v, dtype=bool)
# mark all neighbours of current selection
active = np.nonzero(out)[0]
# gather neighbour slices
for a in active:
sel[nbr_s[ptr[a]:ptr[a + 1]]] = True
out |= sel
return out
# component filter: drop specks (<200 verts) via BFS on the keyed set
from collections import deque
comp_id = np.full(n_v, -1, dtype=np.int64)
cid = 0
keep = np.zeros(n_v, dtype=bool)
for s in np.nonzero(key)[0]:
if comp_id[s] >= 0:
continue
q = deque([s])
comp_id[s] = cid
members = [s]
while q:
c = q.popleft()
for nb in nbr_s[ptr[c]:ptr[c + 1]]:
if key[nb] and comp_id[nb] < 0:
comp_id[nb] = cid
q.append(nb)
members.append(nb)
if len(members) >= 200:
keep[members] = True
cid += 1
log(f"component filter: {keep.sum()} verts in {cid} raw components")
garment = grow(keep, GROW_RINGS)
log(f"grown +{GROW_RINGS}: {garment.sum()}")
top = garment & (co[:, 2] >= Z_TOP_SPLIT)
briefs = garment & (co[:, 2] < Z_TOP_SPLIT)
front = co[:, 1] < 0.0
cups = top & front & (co[:, 2] > CUP_Z_LO) & (co[:, 2] < CUP_Z_HI) \
& (np.abs(co[:, 0]) < CUP_X_MAX) & (np.abs(co[:, 0]) > SHIELD_X)
shield = top & front & (co[:, 2] > CUP_Z_LO) & (co[:, 2] < CUP_Z_HI) \
& (np.abs(co[:, 0]) <= SHIELD_X)
toprest = top & ~cups & ~shield
# hem band: garment boundary vs non-garment, +/-2 rings
edge_g = garment[ev]
bnd_edges = ev[edge_g[:, 0] != edge_g[:, 1]]
bmask = np.zeros(n_v, dtype=bool)
bmask[bnd_edges.ravel()] = True
hemband = grow(bmask, 8) # hem lips are ~3 rings wide; blend needs room beyond them
log(f"zones: cups={cups.sum()} shield={shield.sum()} toprest={toprest.sum()} "
f"briefs={briefs.sum()} hemband={hemband.sum()}")
np.savez_compressed(OUT, garment=garment, cups=cups, shield=shield, toprest=toprest,
briefs=briefs, hemband=hemband, key_raw=keep)
log(f"WROTE {OUT}")
print("ISOLATE_DONE")
@@ -0,0 +1,612 @@
# Stage 3 (v3): take the top off and sculpt the breasts, at full 954k-vert density.
#
# blender --background --python 03_sculpt.py -- <00_welded.blend> <masks.npz>
# <out.blend> [amp_target=0.034]
#
# METHOD — and the two failure modes v3 exists to kill:
#
# * v1/v2 read the replacement surface off the faired proxy with BVH find_nearest. A 19k proxy
# is FACETED (~7 mm triangles): nearest-point positions are piecewise planar and the face
# normals jump at every proxy edge, so the lifted surface imprinted the proxy tessellation
# onto 954k verts — the "crust" in the renders was the proxy's facets, not fabric.
# v3 never touches proxy faces: the complete TARGET surface (wall + zone field) is computed
# per proxy VERTEX and lifted by inverse-distance blending over the 6 nearest proxy verts —
# continuous by construction — followed by a short Taubin polish at full density.
#
# * The shoulder straps' paint is nearly skin-coloured (median sat 0.57-0.61 vs 0.39 on the bra
# body), so no colour threshold can own them. v3 catches them GEOMETRICALLY: the proxy wall
# is also solved under a shoulder corridor, and any full-density vert there standing
# > 2.5 mm proud of the wall is fabric — paint is irrelevant.
#
# Zone targets: cups/shield -> wall + AMPLIFIED mound field (her own under-bra anatomy,
# recovered as surface-minus-wall on the proxy where 1 mm weave cannot exist) with the cleavage
# valley carved where the bra bridged it; rest of top + proud corridor -> wall; briefs -> wall
# + field at 1x (keeps hip/butt anatomy, sheds weave and waistband/leg lips).
import bpy, sys, time
from collections import deque
import numpy as np
from mathutils import Vector
from mathutils.kdtree import KDTree
from mathutils.bvhtree import BVHTree
argv = sys.argv[sys.argv.index("--") + 1:]
BLEND, MASKS, OUT = argv[0], argv[1], argv[2]
AMP_TARGET = float(argv[3]) if len(argv) > 3 else 0.034
CLEAV_GAP, CLEAV_W = 0.007, 0.013 # narrow gap: reference mounds nearly touch
PROXY_RATIO = 0.012 # ~11.5k proxy: dense wall solve stays under ~2 min; the
# vertex-IDW lift makes coarser proxies safe (no facet imprint)
PDN_SMOOTH = 25
BLEND_RINGS = 12
K_LIFT = 6
POLISH_ITERS = 12
PROUD_THR = 0.0025 # corridor verts standing this proud of the wall are fabric
t0 = time.time()
def log(m):
print(f"[sculpt {time.time()-t0:6.1f}s] {m}", flush=True)
bpy.ops.wm.open_mainfile(filepath=BLEND)
ob = max([o for o in bpy.data.objects if o.type == 'MESH'],
key=lambda o: len(o.data.vertices))
me = ob.data
n_v = len(me.vertices)
M = np.load(MASKS)
cups, shield, toprest = M["cups"], M["shield"], M["toprest"]
briefs, garment = M["briefs"], M["garment"]
co = np.empty(n_v * 3, dtype=np.float64)
me.vertices.foreach_get("co", co)
co = co.reshape(-1, 3)
corridor = (co[:, 2] > 0.64) & (co[:, 2] < 0.86) \
& (np.abs(co[:, 0]) > 0.02) & (np.abs(co[:, 0]) < 0.145)
core_top = (co[:, 2] > 0.600) & (co[:, 2] < 0.855) & (np.abs(co[:, 0]) < 0.14)
core_bot = (co[:, 2] > 0.435) & (co[:, 2] <= 0.600) & (np.abs(co[:, 0]) < 0.15)
# ROUGHNESS, computed up-front because it feeds the PROXY fair region: fabric is wrinkled
# where this sculpt's skin is glassy, and unkeyed fabric (the bow's shadowed folds) must be
# faired on the proxy or the target cage carries it and replaces it with itself. Blanket-
# fairing the whole band instead was catastrophic: it removed the membrane's interior anchors
# (belly/waist/hip skin) and the wall collapsed into a cone spanning shoulders to thighs.
ev_r = np.empty(len(me.edges) * 2, dtype=np.int32)
me.edges.foreach_get("vertices", ev_r)
ev_r = ev_r.reshape(-1, 2)
o_r = np.concatenate([ev_r[:, 0], ev_r[:, 1]])
n_r = np.concatenate([ev_r[:, 1], ev_r[:, 0]])
s_r = np.argsort(o_r, kind="stable")
o_rs = o_r[s_r]
n_rs = n_r[s_r]
ptr_r = np.searchsorted(o_rs, np.arange(n_v + 1))
cnt_r = np.maximum(np.diff(ptr_r), 1)
sm_r = co.copy()
for _ in range(8):
su = np.add.reduceat(sm_r[n_rs], ptr_r[:-1], axis=0)
emp = np.diff(ptr_r) == 0
su[emp] = sm_r[emp]
sm_r = su / cnt_r[:, None]
rough = np.linalg.norm(co - sm_r, axis=1)
rough_zone = (core_top | core_bot) \
& ~((np.abs(co[:, 0]) < 0.018) & (co[:, 2] > 0.50) & (co[:, 2] < 0.55)) # navel
fabric_rough = rough_zone & (rough > 0.0008)
region = garment | corridor | fabric_rough
log(f"loaded {n_v}v; garment={garment.sum()} corridor={corridor.sum()} "
f"rough={fabric_rough.sum()}")
# ---------------- proxy ----------------
proxy = ob.copy()
proxy.data = ob.data.copy()
bpy.context.collection.objects.link(proxy)
dec = proxy.modifiers.new("dec", 'DECIMATE')
dec.ratio = PROXY_RATIO
bpy.context.view_layer.objects.active = proxy
bpy.ops.object.modifier_apply(modifier="dec")
pme = proxy.data
np_v = len(pme.vertices)
pco = np.empty(np_v * 3, dtype=np.float64)
pme.vertices.foreach_get("co", pco)
pco = pco.reshape(-1, 3)
log(f"proxy: {np_v}v")
# proxy zone classification by nearest hires vert (0 none,1 cup,2 toprest,3 briefs)
zone = np.zeros(n_v, dtype=np.int8)
zone[toprest] = 2
zone[briefs] = 3
zone[cups | shield] = 1
zone[corridor & (zone == 0)] = 4 # corridor-only: candidate fabric, decided later
zone[fabric_rough & core_top & (zone == 0)] = 2
zone[fabric_rough & core_bot & (zone == 0)] = 3
pool = np.concatenate([np.nonzero(region)[0], np.nonzero(~region)[0][::20]])
kd_h = KDTree(len(pool))
for j, i in enumerate(pool):
kd_h.insert(Vector(co[i]), j)
kd_h.balance()
p_zone = np.zeros(np_v, dtype=np.int8)
for i in range(np_v):
_, j, _ = kd_h.find(Vector(pco[i]))
p_zone[i] = zone[pool[j]]
p_free = p_zone > 0
log(f"proxy region: {p_free.sum()} (zones: " +
", ".join(f"{z}:{(p_zone==z).sum()}" for z in (1, 2, 3, 4)) + ")")
pn_e = len(pme.edges)
pev = np.empty(pn_e * 2, dtype=np.int32)
pme.edges.foreach_get("vertices", pev)
pev = pev.reshape(-1, 2)
padj = [[] for _ in range(np_v)]
for a, b in pev:
padj[a].append(b)
padj[b].append(a)
free = p_free.copy()
collar = free.copy()
for _ in range(2):
nxt = collar.copy()
for gi in np.nonzero(collar)[0]:
for nb in padj[gi]:
nxt[nb] = True
collar = nxt
collar &= ~free
S = np.nonzero(free | collar)[0]
in_S = np.zeros(np_v, dtype=bool)
in_S[S] = True
gl = np.full(np_v, -1, dtype=np.int64)
gl[S] = np.arange(len(S))
# S-local symmetric graph Laplacian (Ls = deg - adjacency), matrix-free
se = pev[in_S[pev].all(axis=1)]
a_l = gl[se[:, 0]]
b_l = gl[se[:, 1]]
deg = np.zeros(len(S))
np.add.at(deg, a_l, 1.0)
np.add.at(deg, b_l, 1.0)
freeS = free[S]
def Ls(X):
out = deg[:, None] * X
np.add.at(out, a_l, -X[b_l])
np.add.at(out, b_l, -X[a_l])
return out
def A_op(U): # (Ls^2)_ff applied to free values
X = np.zeros((len(S), 3))
X[freeS] = U
Y = Ls(Ls(X))
return Y[freeS]
Xc = np.zeros((len(S), 3))
Xc[~freeS] = pco[S[~freeS]]
b_rhs = -Ls(Ls(Xc))[freeS]
# CG (SPD system); matrix-free, so region size no longer matters
U = pco[S[freeS]].copy()
r = b_rhs - A_op(U)
pdir = r.copy()
rs = (r * r).sum()
for cg_it in range(20000):
Ap = A_op(pdir)
alpha = rs / max((pdir * Ap).sum(), 1e-30)
U += alpha * pdir
r -= alpha * Ap
rs_new = (r * r).sum()
if rs_new < 1e-18:
break
pdir = r + (rs_new / rs) * pdir
rs = rs_new
log(f"CG converged in {cg_it} iterations, residual {rs_new:.2e}")
p_wall = pco.copy()
p_wall[S[freeS]] = U
log(f"proxy wall solved, max move {np.linalg.norm(p_wall-pco,axis=1).max():.4f}")
# proxy wall vertex normals (area-weighted, at wall coords)
pl_tot = np.empty(len(pme.polygons), dtype=np.int32)
pme.polygons.foreach_get("loop_total", pl_tot)
pl_start = np.empty(len(pme.polygons), dtype=np.int32)
pme.polygons.foreach_get("loop_start", pl_start)
pl_v = np.empty(len(pme.loops), dtype=np.int32)
pme.loops.foreach_get("vertex_index", pl_v)
p_nrm = np.zeros((np_v, 3))
for s, t in zip(pl_start, pl_tot):
idxs = pl_v[s:s + t]
fn = np.cross(p_wall[idxs[1]] - p_wall[idxs[0]], p_wall[idxs[2]] - p_wall[idxs[0]])
for vi in idxs:
p_nrm[vi] += fn
p_nrm /= np.maximum(np.linalg.norm(p_nrm, axis=1, keepdims=True), 1e-12)
# proxy mound field: ERODE (2-ring min filter), then smooth. The sports top's decorative
# bow-knot and the hem lips are narrow POSITIVE relief riding on the broad mound; smoothing
# alone spreads them, and amplification then blew the bow up into a fist-sized rosette on the
# inner cup. A min-filter deletes narrow positive relief outright while barely shrinking the
# wide mound underneath.
p_dn = np.einsum("ij,ij->i", pco - p_wall, p_nrm)
for _ in range(3):
p_min = p_dn.copy()
np.minimum.at(p_min, pev[:, 0], p_dn[pev[:, 1]])
np.minimum.at(p_min, pev[:, 1], p_dn[pev[:, 0]])
p_dn = p_min
for _ in range(PDN_SMOOTH):
acc = np.zeros(np_v)
cnt = np.zeros(np_v)
np.add.at(acc, pev[:, 0], p_dn[pev[:, 1]])
np.add.at(acc, pev[:, 1], p_dn[pev[:, 0]])
np.add.at(cnt, pev[:, 0], 1)
np.add.at(cnt, pev[:, 1], 1)
sm = acc / np.maximum(cnt, 1)
upd = free | collar
p_dn[upd] = 0.5 * p_dn[upd] + 0.5 * sm[upd]
# per-proxy-vertex TARGET surface
p_cle = np.clip((np.abs(pco[:, 0]) - CLEAV_GAP) / CLEAV_W, 0.0, 1.0)
p_cle = p_cle * p_cle * (3 - 2 * p_cle)
cup_dn = p_dn[(p_zone == 1)]
k_amp = AMP_TARGET / max(np.percentile(cup_dn, 99.5), 1e-4)
p_field = np.zeros(np_v)
mcup = p_zone == 1
p_field[mcup] = np.maximum(p_dn[mcup], 0.0) * k_amp * p_cle[mcup]
mbri = p_zone == 3
bri_fade = np.clip((0.595 - pco[:, 2]) / 0.030, 0.0, 1.0)
bri_fade = bri_fade * bri_fade * (3 - 2 * bri_fade)
p_field[mbri] = p_dn[mbri] * bri_fade[mbri]
# zones 2 (toprest) and 4 (corridor) stay 0 -> target = wall
# The cup field is NOT baked into the cage any more. Sequence proven by v3.13: first take the
# top fully off (wall replacement + melt + measured excision -> clean flat chest), THEN sculpt
# the breasts onto the healed surface as a separate post-pass. Entangling the mound field with
# the fabric-removal surface made every fabric fix fight the breast shape. The briefs field
# stays in the cage: it is her real hip/butt anatomy, not an addition.
p_field_cup = np.where(p_zone == 1, p_field, 0.0)
up_fade = np.clip((0.752 - pco[:, 2]) / 0.022, 0.0, 1.0)
up_fade = up_fade * up_fade * (3 - 2 * up_fade)
p_field_cup *= up_fade
# The zone mask is paint-keyed and SPECKLED at its borders; a cage built from a discontinuous
# field grows radial spikes that the delta application then amplifies into shredding. Diffuse
# the scalar over the proxy graph until the cage is built from a smooth function.
for _ in range(15):
accc = np.zeros(np_v)
cntc = np.zeros(np_v)
np.add.at(accc, pev[:, 0], p_field_cup[pev[:, 1]])
np.add.at(accc, pev[:, 1], p_field_cup[pev[:, 0]])
np.add.at(cntc, pev[:, 0], 1)
np.add.at(cntc, pev[:, 1], 1)
p_field_cup = 0.5 * p_field_cup + 0.5 * (accc / np.maximum(cntc, 1))
p_T = p_wall + p_nrm * (p_field - p_field_cup)[:, None]
log(f"amplification k = {k_amp:.2f}; proxy target ready (cup field deferred)")
# Reconstruct SMOOTH dense targets from the coarse fields via Catmull-Clark subdivision.
# (v3.0 lifted with inverse-distance weighting of scattered proxy points instead; IDW has
# vanishing gradients at every data site, so each proxy vertex owned a visible flat cell —
# the "crumpled polygon" look. A subdivided cage is the proper smooth-surface reconstruction:
# C2 almost everywhere, facets far below visual scale.)
def smooth_bvh(vcoords):
dup = ob.copy()
dup.data = pme_src.copy()
bpy.context.collection.objects.link(dup)
dup.data.vertices.foreach_set("co", vcoords.reshape(-1).astype(np.float64))
dup.data.update()
sub = dup.modifiers.new("s", 'SUBSURF')
sub.levels = 2
sub.render_levels = 2
bpy.context.view_layer.objects.active = dup
bpy.ops.object.modifier_apply(modifier="s")
dme = dup.data
dv = np.empty(len(dme.vertices) * 3)
dme.vertices.foreach_get("co", dv)
dv = dv.reshape(-1, 3)
dl_tot = np.empty(len(dme.polygons), dtype=np.int32)
dme.polygons.foreach_get("loop_total", dl_tot)
dl_start = np.empty(len(dme.polygons), dtype=np.int32)
dme.polygons.foreach_get("loop_start", dl_start)
dl_v = np.empty(len(dme.loops), dtype=np.int32)
dme.loops.foreach_get("vertex_index", dl_v)
dpolys = [dl_v[st:st + tt].tolist() for st, tt in zip(dl_start, dl_tot)]
tree = BVHTree.FromPolygons([Vector(v) for v in dv], dpolys,
all_triangles=False, epsilon=0.0)
bpy.data.objects.remove(dup, do_unlink=True)
return tree
pme_src = pme.copy() # keep proxy topology before the proxy object is deleted
bvh_wall = smooth_bvh(p_wall)
bvh_tgt = smooth_bvh(p_T)
bpy.data.objects.remove(proxy, do_unlink=True)
log("smooth subdivided targets ready")
# ---------------- decide the ACTIVE set first, then lift targets for ALL of it -------------
# (v3.2/3.3 grew the intent mask with morphological close and a boundary push, but targets were
# only computed for the original paint+corridor region — the bow at the sternum, strap tops
# above the corridor and the armpit folds ended up marked active WITHOUT a target, so they kept
# their fabric geometry untouched. Invariant now: active == lifted == replaced.)
n_e0 = len(me.edges)
ev0 = np.empty(n_e0 * 2, dtype=np.int32)
me.edges.foreach_get("vertices", ev0)
ev0 = ev0.reshape(-1, 2)
def grow_edges(mask, rings, edges):
out = mask.copy()
for _ in range(rings):
nxt = out.copy()
nxt[edges[:, 0]] |= out[edges[:, 1]]
nxt[edges[:, 1]] |= out[edges[:, 0]]
out = nxt
return out
def proud_of_wall(vi):
return (Vector(co[vi]) - bvh_wall.find_nearest(Vector(co[vi]))[0]).length > PROUD_THR
# corridor fabric: geometry decides
corr_idx = np.nonzero(corridor & ~garment)[0]
fabric_extra = np.zeros(n_v, dtype=bool)
for vi in corr_idx:
if proud_of_wall(vi):
fabric_extra[vi] = True
log(f"corridor fabric catch: {fabric_extra.sum()} of {len(corr_idx)}")
act_set = garment | fabric_extra | fabric_rough | core_top | core_bot
g5 = grow_edges(act_set, 3, ev0)
inv = grow_edges(~g5, 3, ev0)
act_set = ~inv
log(f"active after band+close: {act_set.sum()}")
# bounded push: boundary must rest on skin, never on proud fabric
allowed = (co[:, 2] > 0.38) & (co[:, 2] < 0.88) & (np.abs(co[:, 0]) < 0.17)
proud_cache = {}
for it in range(30):
bnd = np.zeros(n_v, dtype=bool)
e_mix = act_set[ev0[:, 0]] != act_set[ev0[:, 1]]
bnd[ev0[e_mix].ravel()] = True
bnd &= act_set
bad = []
for vi in np.nonzero(bnd)[0]:
if vi not in proud_cache:
proud_cache[vi] = proud_of_wall(vi)
if proud_cache[vi]:
bad.append(vi)
if not bad:
log(f"boundary clean after {it} grow steps")
break
ring = np.zeros(n_v, dtype=bool)
ring[bad] = True
act_set |= grow_edges(ring, 2, ev0) & allowed
else:
log(f"NOTE: boundary push capped at 30 steps ({len(bad)} proud verts remain, "
f"likely at the allowed-region rim)")
# ---------------- lift: snap every ACTIVE vert to the smooth target surface ----------------
ridx = np.nonzero(act_set)[0]
active = np.ones(len(ridx), dtype=bool)
T = np.zeros((len(ridx), 3))
for k, i in enumerate(ridx):
T[k] = bvh_tgt.find_nearest(Vector(co[i]))[0]
log(f"lift done for {len(ridx)} active verts")
# ring-depth blend: 0 at the (now skin-resting) boundary, 1 from BLEND_RINGS inward
order = np.concatenate([ev0[:, 0], ev0[:, 1]])
nbr = np.concatenate([ev0[:, 1], ev0[:, 0]])
srt = np.argsort(order, kind="stable")
o_s = order[srt]
n_s = nbr[srt]
ptr = np.searchsorted(o_s, np.arange(n_v + 1))
depth = np.zeros(n_v, dtype=np.int32)
dq = deque()
seen = np.zeros(n_v, dtype=bool)
for a, b in ev0:
if act_set[a] != act_set[b]:
sv = b if act_set[b] else a
if not seen[sv]:
seen[sv] = True
depth[sv] = 1
dq.append(sv)
while dq:
c = dq.popleft()
for nb in n_s[ptr[c]:ptr[c + 1]]:
if act_set[nb] and not seen[nb]:
seen[nb] = True
depth[nb] = depth[c] + 1
dq.append(nb)
depth[act_set & ~seen] = BLEND_RINGS + 1
wgt = np.clip(depth[ridx] / float(BLEND_RINGS), 0.0, 1.0)
wgt = wgt * wgt * (3 - 2 * wgt)
log(f"blend: {(wgt >= 1).sum()} full, {((wgt > 0) & (wgt < 1)).sum()} ramp")
co_new = co.copy()
co_new[ridx] = co[ridx] + (T - co[ridx]) * wgt[:, None]
# short Taubin polish over the replaced area (kills residual IDW dimples)
cnt_all = np.maximum(np.diff(ptr), 1)
def nb_mean(P):
sums = np.add.reduceat(P[n_s], ptr[:-1], axis=0)
empty = np.diff(ptr) == 0
sums[empty] = P[empty]
return sums / cnt_all[:, None]
pol = act_set & (depth >= BLEND_RINGS)
pidx = np.nonzero(pol)[0]
for _ in range(POLISH_ITERS):
for f in (0.55, -0.58):
d = (nb_mean(co_new) - co_new) * f
co_new[pidx] += d[pidx]
log(f"polish: {len(pidx)} verts, {POLISH_ITERS} Taubin pairs")
# MELT pass: whatever fabric decoration still shows (the bow/lacing scar defeated the colour
# key, the roughness threshold AND the proud test), it is by definition ROUGH ON THE RESULT.
# Detect residual roughness inside the front-chest window on co_new itself and aggressively
# smooth just those verts into the surrounding replaced surface. Local and bounded: it cannot
# move anything that is already smooth.
sm2 = co_new.copy()
for _ in range(8):
su2 = np.add.reduceat(sm2[n_rs], ptr_r[:-1], axis=0)
emp2 = np.diff(ptr_r) == 0
su2[emp2] = sm2[emp2]
sm2 = su2 / cnt_r[:, None]
rough2 = np.linalg.norm(co_new - sm2, axis=1)
melt_win = (co[:, 2] > 0.42) & (co[:, 2] < 0.81) & (np.abs(co[:, 0]) < 0.15) & ~((np.abs(co[:, 0]) < 0.018) & (co[:, 2] > 0.50) & (co[:, 2] < 0.55)) # navel
melt = melt_win & (rough2 > 0.0006)
melt = grow_edges(melt, 3, ev0)
midx = np.nonzero(melt)[0]
for _ in range(60):
for f in (0.55, -0.58):
d2 = (nb_mean(co_new) - co_new) * f
co_new[midx] += d2[midx]
log(f"melt: {len(midx)} rough verts smoothed hard")
# BOW EXCISION (v4.8 configuration, restored): bi-harmonic heal of the decorated front
# window, run to CONVERGENCE. Wall-snap variants sampled unfaired cage patches (raw bow) back
# onto the chest, and fairing the window on the proxy collapsed the mound source field —
# both reverted. The converged membrane leaves a soft valley between the upper mounds, which
# the reference image shows as natural anatomy.
bow_win = (co[:, 1] < 0) & (np.abs(co[:, 0]) < 0.080) & (co[:, 2] > 0.630) & (co[:, 2] < 0.802)
bow_free = grow_edges(bow_win, 2, ev0)
bow_collar = grow_edges(bow_free, 2, ev0) & ~bow_free
Sb = np.nonzero(bow_free | bow_collar)[0]
in_Sb = np.zeros(n_v, dtype=bool)
in_Sb[Sb] = True
glb_ = np.full(n_v, -1, dtype=np.int64)
glb_[Sb] = np.arange(len(Sb))
seb = ev0[in_Sb[ev0].all(axis=1)]
a_b = glb_[seb[:, 0]]
b_b = glb_[seb[:, 1]]
degb = np.zeros(len(Sb))
np.add.at(degb, a_b, 1.0)
np.add.at(degb, b_b, 1.0)
freeB = bow_free[Sb]
def Lsb(X):
out = degb[:, None] * X
np.add.at(out, a_b, -X[b_b])
np.add.at(out, b_b, -X[a_b])
return out
def A_b(U):
X = np.zeros((len(Sb), 3))
X[freeB] = U
return Lsb(Lsb(X))[freeB]
Xcb = np.zeros((len(Sb), 3))
Xcb[~freeB] = co_new[Sb[~freeB]]
rhs_b = -Lsb(Lsb(Xcb))[freeB]
Ub = co_new[Sb[freeB]].copy()
r_b = rhs_b - A_b(Ub)
p_b = r_b.copy()
rs_b = (r_b * r_b).sum()
rs0_b = rs_b
for it_b in range(120000):
Apb = A_b(p_b)
al = rs_b / max((p_b * Apb).sum(), 1e-30)
Ub += al * p_b
r_b -= al * Apb
rs2 = (r_b * r_b).sum()
if rs2 < 1e-18 or rs2 < rs0_b * 1e-14:
break
p_b = r_b + (rs2 / rs_b) * p_b
rs_b = rs2
co_new[Sb[freeB]] = Ub
log(f"bow excision: {freeB.sum()} verts healed (CG {it_b} iters, "
f"rel residual {rs2/max(rs0_b,1e-30):.2e})")
# ---- STEP 2: sculpt the breasts onto the healed chest ----
# Applied as the DELTA between two subdivided cages: (wall + cup field) minus (wall). Smooth
# everywhere by construction and exactly zero outside the mound footprint, so it composes with
# the healed chest without steps. (A scalar IDW lift was tried first and re-created the
# flat-spot bubbling that killed v3.0 — same lesson, same fix: reconstruct through a
# subdivided cage, never by scattered-point interpolation.)
bvh_mound = smooth_bvh(p_wall + p_nrm * p_field_cup[:, None])
chest_win = (co[:, 1] < 0.02) & (co[:, 2] > 0.585) & (co[:, 2] < 0.80) & (np.abs(co[:, 0]) < 0.115)
widx = np.nonzero(chest_win)[0]
applied = 0
apex_d = 0.0
# Sample the mound HEIGHT in the wall's own frame: nearest wall point W with its smooth normal
# N, then ray-cast the mound cage along N. One shared frame — unlike the nearest-point delta
# (whose two nearest points are DIFFERENT surface locations on steep slopes; their difference
# carries wild tangential components and shredded the mounds), height-along-normal is a
# continuous scalar field over the wall, so the applied surface inherits both cages' smoothness.
h_arr = np.zeros(len(widx))
N_arr = np.zeros((len(widx), 3))
for k_, i_ in enumerate(widx):
pos_v = Vector(co_new[i_])
hw = bvh_wall.find_nearest(pos_v)
Wp, Nn = hw[0], hw[1]
N_arr[k_] = np.array(Nn)
rc = bvh_mound.ray_cast(Wp - Nn * 0.004, Nn, 0.09)
if rc[0] is not None:
h_arr[k_] = max((Vector(rc[0]) - Wp).dot(Nn), 0.0)
# The raw per-vertex heights carry sampling noise (adjacent rays graze different cage faces),
# which rendered as hairline cracks. Smooth the SCALAR height over the window's mesh graph,
# then renormalise to the target apex — a smooth scalar along smooth normals is artifact-free.
in_win = np.zeros(n_v, dtype=bool)
in_win[widx] = True
pos_win = np.full(n_v, -1, dtype=np.int64)
pos_win[widx] = np.arange(len(widx))
we = ev0[in_win[ev0].all(axis=1)]
wa = pos_win[we[:, 0]]
wb = pos_win[we[:, 1]]
for _ in range(15):
acch = np.zeros(len(widx))
cnth = np.zeros(len(widx))
np.add.at(acch, wa, h_arr[wb])
np.add.at(acch, wb, h_arr[wa])
np.add.at(cnth, wa, 1)
np.add.at(cnth, wb, 1)
mh = acch / np.maximum(cnth, 1)
h_arr = 0.5 * h_arr + 0.5 * mh
# the DIRECTION field cracks too: find_nearest returns per-face normals of the subdivided
# cage, and at 30+ mm of displacement a 2-degree jump between neighbouring faces opens a
# millimetre crack. Smooth the normals with the heights.
accn = np.zeros((len(widx), 3))
np.add.at(accn, wa, N_arr[wb])
np.add.at(accn, wb, N_arr[wa])
mn_ = accn / np.maximum(cnth, 1)[:, None]
N_arr = 0.5 * N_arr + 0.5 * mn_
N_arr /= np.maximum(np.linalg.norm(N_arr, axis=1, keepdims=True), 1e-12)
if h_arr.max() > 1e-4:
# gamma < 1 fattens the mid-slopes: the reference mounds are near-hemispherical (full
# shoulder), not shallow domes
h_arr = h_arr.max() * (h_arr / h_arr.max()) ** 0.75
h_arr *= AMP_TARGET / h_arr.max()
co_new[widx] += N_arr * h_arr[:, None]
log(f"breast field applied (smoothed heights): {(h_arr > 1e-4).sum()} verts, "
f"apex {h_arr.max():.4f}")
# seam polish: the ramp boundaries leave faint horizontal lines at the old band edges; both
# sides are smooth surfaces now, so a light local Taubin along the boundary rings erases the
# lines without moving anything else
bnd_f = np.zeros(n_v, dtype=bool)
e_mix2 = act_set[ev0[:, 0]] != act_set[ev0[:, 1]]
bnd_f[ev0[e_mix2].ravel()] = True
seam_band = grow_edges(bnd_f, 4, ev0)
sidx = np.nonzero(seam_band)[0]
for _ in range(20):
for f in (0.55, -0.58):
d3 = (nb_mean(co_new) - co_new) * f
co_new[sidx] += d3[sidx]
log(f"seam polish: {len(sidx)} boundary-band verts")
me.vertices.foreach_set("co", co_new.reshape(-1))
me.update()
if me.has_custom_normals:
vn = np.empty(n_v * 3, dtype=np.float32)
me.vertices.foreach_get("normal", vn)
me.normals_split_custom_set_from_vertices(vn.reshape(-1, 3))
# save active mask for the texture stage (corridor fabric needs repainting too)
np.savez_compressed(MASKS.replace(".npz", "_active.npz"), active=act_set)
bpy.context.preferences.filepaths.save_version = 0 # no .blend1 autosave
bpy.ops.wm.save_as_mainfile(filepath=OUT)
log(f"WROTE {OUT}")
fmax = p_field.max()
log(f"GATE apex projection (proxy field): {fmax:.4f} (target {AMP_TARGET})")
print("SCULPT_DONE")
Binary file not shown.
@@ -0,0 +1,99 @@
# Stage 4: review renders + numeric profile gates for the hires sculpt.
# blender --background --python 04_review.py -- <03_sculpted.blend> <review_dir>
import bpy, sys, os, math, time
import numpy as np
from mathutils import Vector
argv = sys.argv[sys.argv.index("--") + 1:]
BLEND, OUT = argv[0], argv[1]
os.makedirs(OUT, exist_ok=True)
t0 = time.time()
def log(m):
print(f"[review {time.time()-t0:6.1f}s] {m}", flush=True)
bpy.ops.wm.open_mainfile(filepath=BLEND)
ob = max([o for o in bpy.data.objects if o.type == 'MESH'],
key=lambda o: len(o.data.vertices))
me = ob.data
n_v = len(me.vertices)
co = np.empty(n_v * 3, dtype=np.float64)
me.vertices.foreach_get("co", co)
co = co.reshape(-1, 3)
# ---------- numeric gates ----------
front = co[:, 1] < 0
print("\n=== PROFILE y(z) at sternum x=0 (front) ===")
for z0 in np.arange(0.58, 0.78, 0.01):
m = front & (np.abs(co[:, 0]) < 0.004) & (np.abs(co[:, 2] - z0) < 0.005)
if m.sum():
print(f" z={z0:.2f} y={co[m,1].min():+.4f}")
print("=== PROFILE y(z) at apex x=0.034 ===")
for z0 in np.arange(0.58, 0.78, 0.01):
m = front & (np.abs(co[:, 0] - 0.034) < 0.005) & (np.abs(co[:, 2] - z0) < 0.005)
if m.sum():
print(f" z={z0:.2f} y={co[m,1].min():+.4f}")
print("=== PROFILE y(x) at z=0.688 ===")
for x0 in np.arange(-0.10, 0.101, 0.01):
m = front & (np.abs(co[:, 0] - x0) < 0.005) & (np.abs(co[:, 2] - 0.688) < 0.006)
if m.sum():
print(f" x={x0:+.2f} y={co[m,1].min():+.4f}")
# ---------- renders ----------
scn = bpy.context.scene
w = bpy.data.worlds.new("W")
w.color = (0.22, 0.22, 0.24)
scn.world = w
key = bpy.data.objects.new("Key", bpy.data.lights.new("Key", 'SUN'))
key.data.energy = 3.0
key.data.use_shadow = False
bpy.context.collection.objects.link(key)
fill = bpy.data.objects.new("Fill", bpy.data.lights.new("Fill", 'SUN'))
fill.data.energy = 1.0
fill.data.use_shadow = False
bpy.context.collection.objects.link(fill)
cam = bpy.data.objects.new("Cam", bpy.data.cameras.new("Cam"))
cam.data.lens = 85
bpy.context.collection.objects.link(cam)
scn.camera = cam
scn.render.engine = 'BLENDER_EEVEE' if bpy.app.version >= (4, 2) else 'BLENDER_EEVEE_NEXT'
scn.render.resolution_x = scn.render.resolution_y = 1000
clay_mat = bpy.data.materials.new("Clay")
clay_mat.use_nodes = True
clay_mat.node_tree.nodes["Principled BSDF"].inputs["Base Color"].default_value = (0.62, 0.60, 0.58, 1)
clay_mat.node_tree.nodes["Principled BSDF"].inputs["Roughness"].default_value = 0.45
orig_mats = [ms.material for ms in ob.material_slots]
def shoot(tag, ctr, span, yaw_deg, clay):
if clay:
for ms in ob.material_slots:
ms.material = clay_mat
else:
for ms, m in zip(ob.material_slots, orig_mats):
ms.material = m
yaw = math.radians(yaw_deg)
dist = span * 3.0
cam.location = Vector(ctr) + Vector((math.sin(yaw) * dist, -math.cos(yaw) * dist, 0.02))
cam.rotation_euler = (Vector(ctr) - cam.location).to_track_quat('-Z', 'Y').to_euler()
key.rotation_euler = (math.radians(62), 0, math.radians(35 + yaw_deg))
fill.rotation_euler = (math.radians(75), 0, math.radians(yaw_deg - 110))
scn.render.filepath = os.path.join(OUT, f"{tag}.png")
bpy.ops.render.render(write_still=True)
log(f"render {tag}")
CHEST = (0.0, 0.0, 0.675)
HIP = (0.0, 0.0, 0.53)
FULL = (0.0, 0.0, 0.50)
for yaw in (0, 40, 90):
shoot(f"chest_clay_{yaw}", CHEST, 0.22, yaw, True)
shoot(f"chest_tex_{yaw}", CHEST, 0.22, yaw, False)
shoot("hip_clay_0", HIP, 0.22, 0, True)
shoot("full_clay_0", FULL, 0.55, 0, True)
shoot("full_clay_40", FULL, 0.55, 40, True)
print("REVIEW_DONE")
@@ -0,0 +1,346 @@
# Stage 5: repaint the garment out of the textures (basecolor + normal + roughness/metallic),
# on the sculpted hires body.
#
# blender --background --python 05_texture.py -- <06_final.blend> <masks.npz>
# <00_welded.blend> <out.blend>
#
# The repaint mask is rebuilt from the ORIGINAL geometry (00_welded) because fabric verts are
# no longer rough after the sculpt: garment hemband key_raw rough strap corridor the
# bow-excision window the crotch box — the union of every region whose geometry was
# replaced, whose paint is fabric.
#
# Same principles that fixed the game-density bake, at hires scale:
# - fill tone comes from skin NEAREST ON THE BODY (KD over skin verts in 3D), never from
# atlas neighbourhoods — atlas-local fills gave wrong tones and island seams;
# - grain is transplanted from real skin tiles so the fill is not a smooth decal
# (per-channel high-pass — the channel-mixing blur bug is not repeated here);
# - normal map goes flat (128,128,255) and rm matches median skin over the same texels,
# so the fabric weave stops shading through after the paint is gone.
import bpy, sys, time
import numpy as np
from mathutils import Vector
from mathutils.kdtree import KDTree
argv = sys.argv[sys.argv.index("--") + 1:]
BLEND, MASKS, WELDED, OUT = argv[0], argv[1], argv[2], argv[3]
# 'patch' mode (v02 variant): repaint ONLY the garment texels, keep every other texel of the
# original skin — no rosy-chest mask, fill sources include the rosy skin so the patches blend
# with HER tones, and the fill is mirror-averaged so one side's blush can't splash one cup.
PATCH_ONLY = len(argv) > 4 and argv[4] == "patch"
t0 = time.time()
GRAIN_T = 16
FEATHER = 4
def log(m):
print(f"[tex {time.time()-t0:6.1f}s] {m}", flush=True)
# ---- build the repaint mask from ORIGINAL geometry ----
bpy.ops.wm.open_mainfile(filepath=WELDED)
ob0 = max([o for o in bpy.data.objects if o.type == 'MESH'],
key=lambda o: len(o.data.vertices))
me0 = ob0.data
n_v = len(me0.vertices)
co0 = np.empty(n_v * 3, dtype=np.float64)
me0.vertices.foreach_get("co", co0)
co0 = co0.reshape(-1, 3)
ev0 = np.empty(len(me0.edges) * 2, dtype=np.int32)
me0.edges.foreach_get("vertices", ev0)
ev0 = ev0.reshape(-1, 2)
o_r = np.concatenate([ev0[:, 0], ev0[:, 1]])
n_r = np.concatenate([ev0[:, 1], ev0[:, 0]])
s_r = np.argsort(o_r, kind="stable")
o_rs = o_r[s_r]
n_rs = n_r[s_r]
ptr_r = np.searchsorted(o_rs, np.arange(n_v + 1))
cnt_r = np.maximum(np.diff(ptr_r), 1)
sm_r = co0.copy()
for _ in range(8):
su = np.add.reduceat(sm_r[n_rs], ptr_r[:-1], axis=0)
emp = np.diff(ptr_r) == 0
su[emp] = sm_r[emp]
sm_r = su / cnt_r[:, None]
rough0 = np.linalg.norm(co0 - sm_r, axis=1)
M = np.load(MASKS)
corridor0 = (co0[:, 2] > 0.64) & (co0[:, 2] < 0.86) \
& (np.abs(co0[:, 0]) > 0.02) & (np.abs(co0[:, 0]) < 0.145)
strap_rough = corridor0 & (rough0 > 0.0005)
bow_win = (co0[:, 1] < 0) & (np.abs(co0[:, 0]) < 0.080) \
& (co0[:, 2] > 0.630) & (co0[:, 2] < 0.802)
crotch_box = (np.abs(co0[:, 0]) < 0.075) & (co0[:, 2] > 0.340) & (co0[:, 2] < 0.480)
garment = (M["garment"] | M["hemband"] | M["key_raw"]
| strap_rough | bow_win | crotch_box)
for _ in range(3): # grow so the rasterised fill overlaps every replaced-geometry rim
hit = garment[ev0[:, 0]] | garment[ev0[:, 1]]
g2 = garment.copy()
g2[ev0[:, 0]] |= hit
g2[ev0[:, 1]] |= hit
garment = g2
log(f"repaint mask: {garment.sum()} of {n_v} "
f"(strap_rough {strap_rough.sum()}, bow {bow_win.sum()}, crotch {crotch_box.sum()})")
# ---- now open the FINAL sculpted body and repaint on it ----
bpy.ops.wm.open_mainfile(filepath=BLEND)
ob = max([o for o in bpy.data.objects if o.type == 'MESH'],
key=lambda o: len(o.data.vertices))
me = ob.data
assert len(me.vertices) == n_v, "vertex count changed between welded and final"
log(f"loaded {n_v}v, garment {garment.sum()}")
co = np.empty(n_v * 3, dtype=np.float64)
me.vertices.foreach_get("co", co)
co = co.reshape(-1, 3)
imgs = {}
for i in bpy.data.images:
nm = i.name.lower()
if "basecolor" in nm:
imgs["base"] = i
elif "normal" in nm:
imgs["normal"] = i
elif "_rm" in nm or nm.endswith("rm.jpg"):
imgs["rm"] = i
log(f"images: { {k: v.name for k, v in imgs.items()} }")
base = imgs["base"]
w, h = base.size
buf = np.empty(w * h * 4, dtype=np.float32)
base.pixels.foreach_get(buf)
rgb = buf.reshape(h, w, 4)
loops_v = np.empty(len(me.loops), dtype=np.int32)
me.loops.foreach_get("vertex_index", loops_v)
uv = np.empty(len(me.loops) * 2, dtype=np.float64)
me.uv_layers.active.data.foreach_get("uv", uv)
uv = uv.reshape(-1, 2)
lx = np.clip(uv[:, 0], 0, 1) * (w - 1)
ly = np.clip(uv[:, 1], 0, 1) * (h - 1)
first_loop = np.full(n_v, len(loops_v), dtype=np.int64)
np.minimum.at(first_loop, loops_v, np.arange(len(loops_v), dtype=np.int64))
first_loop = np.minimum(first_loop, len(loops_v) - 1)
vcol = rgb[ly[first_loop].astype(int), lx[first_loop].astype(int), :3]
# ---- rosy paint: the original body has a sunburn-like blush V on the upper chest/throat.
# It reads as a tan line on the nude (uniform-skin decision) and it poisons the 3D-nearest
# fill (asymmetric pink cups). Detect it per-vertex, repaint it, and never sample from it.
band = (co[:, 2] > 0.30) & (co[:, 2] < 0.905)
rg = vcol[:, 0] - vcol[:, 1]
# reference tone is the BELLY, not the band median — the whole upper chest is rosy, so a
# band median is itself rosy-biased and lets the blush field through (measured: belly rg
# 0.239, upper chest 0.30-0.33; a med+0.05 cut only caught the extreme pink core)
belly = ~garment & (co[:, 1] < 0) & (co[:, 2] > 0.45) & (co[:, 2] < 0.60) & (np.abs(co[:, 0]) < 0.08)
med_rg = np.median(rg[belly])
rosy = band & (rg > med_rg + 0.045)
if PATCH_ONLY:
log(f"patch mode: garment texels only, mask {garment.sum()}")
else:
rosy_chest = rosy & (co[:, 1] < 0.01) & (co[:, 2] > 0.55)
garment = garment | rosy_chest
log(f"rosy: {rosy.sum()} total, chest repaint {rosy_chest.sum()} "
f"(belly med_rg {med_rg:.3f}) -> mask {garment.sum()}")
# ---- per-vertex fill colour: K nearest skin verts in 3D ----
if PATCH_ONLY:
skin = ~garment & (co[:, 2] > 0.30) & (co[:, 2] < 0.86)
else:
skin = ~garment & ~rosy & (co[:, 2] > 0.30) & (co[:, 2] < 0.86)
skin_idx = np.nonzero(skin)[0][::3] # 1-in-3 sample is plenty at this density
kd = KDTree(len(skin_idx))
for j, i in enumerate(skin_idx):
kd.insert(Vector(co[i]), j)
kd.balance()
log(f"skin KD: {len(skin_idx)} verts")
gidx = np.nonzero(garment)[0]
fill_c = vcol.copy()
def idw_at(p):
hits = kd.find_n(Vector(p), 8)
wsum = 0.0
acc = np.zeros(3)
for (_, j, dist) in hits:
wgt = 1.0 / max(dist * dist, 1e-9)
acc += wgt * vcol[skin_idx[j]]
wsum += wgt
return acc / wsum
for i in gidx:
c1 = idw_at(co[i])
if PATCH_ONLY:
# mirror-average so asymmetric blush near one cup cannot tint only that cup
c2 = idw_at([-co[i][0], co[i][1], co[i][2]])
fill_c[i] = 0.5 * (c1 + c2)
else:
fill_c[i] = c1
log("per-vertex fill colours done")
# ---- rasterise fill over garment faces ----
n_f = len(me.polygons)
l_tot = np.empty(n_f, dtype=np.int32)
me.polygons.foreach_get("loop_total", l_tot)
l_start = np.empty(n_f, dtype=np.int32)
me.polygons.foreach_get("loop_start", l_start)
gv = np.zeros(n_v, dtype=bool)
gv[gidx] = True
face_g = np.zeros(n_f, dtype=bool)
# a face is garment if ANY corner is (covers the rim); loop over faces via numpy reduceat
face_flag = np.add.reduceat(gv[loops_v].astype(np.int32), l_start)
face_g = face_flag > 0
log(f"garment faces: {face_g.sum()}")
mask_px = np.zeros((h, w), dtype=bool)
out_rgb = rgb[:, :, :3].astype(np.float64)
for fi in np.nonzero(face_g)[0]:
s, t = l_start[fi], l_tot[fi]
li = np.arange(s, s + t)
P = np.stack([lx[li], ly[li]], axis=1)
V = loops_v[li]
x0, x1 = int(P[:, 0].min()), int(np.ceil(P[:, 0].max()))
y0, y1 = int(P[:, 1].min()), int(np.ceil(P[:, 1].max()))
if x1 - x0 > 256 or y1 - y0 > 256 or x1 < x0 or y1 < y0:
continue
if t != 3:
P = P[:3]
V = V[:3]
d = ((P[1, 1] - P[2, 1]) * (P[0, 0] - P[2, 0]) +
(P[2, 0] - P[1, 0]) * (P[0, 1] - P[2, 1]))
if abs(d) < 1e-12:
continue
gx, gy = np.meshgrid(np.arange(x0, min(x1, w - 1) + 1),
np.arange(y0, min(y1, h - 1) + 1))
a = ((P[1, 1] - P[2, 1]) * (gx - P[2, 0]) + (P[2, 0] - P[1, 0]) * (gy - P[2, 1])) / d
b = ((P[2, 1] - P[0, 1]) * (gx - P[2, 0]) + (P[0, 0] - P[2, 0]) * (gy - P[2, 1])) / d
c = 1.0 - a - b
ins = (a >= -0.03) & (b >= -0.03) & (c >= -0.03)
if not ins.any():
continue
col = (a[ins, None] * fill_c[V[0]] + b[ins, None] * fill_c[V[1]]
+ c[ins, None] * fill_c[V[2]])
out_rgb[gy[ins], gx[ins]] = col
mask_px[gy[ins], gx[ins]] = True
log(f"rasterised fill: {mask_px.sum()} texels")
# ---- absorb mask-rim slivers (white stitch piping extends past the colour key, and UV
# island borders leave old texels between rasterised faces): dilate 8 px, propagate fill
# colours outward into the ring so no legacy pixel survives inside the dilated mask.
def dil1(m):
g = m.copy()
g[1:, :] |= m[:-1, :]
g[:-1, :] |= m[1:, :]
g[:, 1:] |= m[:, :-1]
g[:, :-1] |= m[:, 1:]
return g
dil = mask_px.copy()
for _ in range(8):
dil = dil1(dil)
ring = dil & ~mask_px
C = out_rgb.copy()
have = mask_px.copy()
for _ in range(10):
if not (ring & ~have).any():
break
Wf = have.astype(np.float64)
acc = np.zeros_like(C)
wacc = np.zeros((h, w))
for dy, dx in ((1, 0), (-1, 0), (0, 1), (0, -1)):
acc += np.roll(C * Wf[:, :, None], (dy, dx), axis=(0, 1))
wacc += np.roll(Wf, (dy, dx), axis=(0, 1))
newly = ring & ~have & (wacc > 0)
C[newly] = acc[newly] / wacc[newly, None]
have |= newly
out_rgb[ring & have] = C[ring & have]
mask_px |= ring & have
log(f"rim absorb: +{(ring & have).sum()} ring texels -> mask {mask_px.sum()}")
def box_blur1(a2, r):
def b1(x, axis):
p = [(0, 0)] * x.ndim
p[axis] = (r, r)
cs = np.cumsum(np.pad(x, p, mode="edge"), axis=axis)
return (np.take(cs, np.arange(2 * r, cs.shape[axis]), axis=axis) -
np.take(cs, np.arange(0, cs.shape[axis] - 2 * r), axis=axis)) / (2 * r)
return b1(b1(a2, 0), 1)
# ---- grain transplant (per-channel high-pass, tile-based) ----
src_ok = ~mask_px
grain = np.stack([out_rgb[:, :, c] - box_blur1(out_rgb[:, :, c], 5) for c in range(3)], axis=2)
# candidate tiles must be clean AND skin-toned (the atlas also holds eyes/lips whose
# high-contrast grain would streak the fill)
skin_tone = np.median(out_rgb[mask_px], axis=0) if mask_px.any() else np.array([0.6, 0.45, 0.38])
cand = []
for ty in range(0, h - GRAIN_T, GRAIN_T):
for tx in range(0, w - GRAIN_T, GRAIN_T):
if not src_ok[ty:ty + GRAIN_T, tx:tx + GRAIN_T].all():
continue
tmean = out_rgb[ty:ty + GRAIN_T, tx:tx + GRAIN_T].reshape(-1, 3).mean(axis=0)
if np.abs(tmean - skin_tone).max() < 0.13:
cand.append((ty, tx))
rng = np.random.RandomState(77)
covered = 0
for ty in range(0, h - GRAIN_T + 1, GRAIN_T):
for tx in range(0, w - GRAIN_T + 1, GRAIN_T):
tm = mask_px[ty:ty + GRAIN_T, tx:tx + GRAIN_T]
if not tm.any():
continue
sy, sx = cand[rng.randint(len(cand))]
blk = out_rgb[ty:ty + GRAIN_T, tx:tx + GRAIN_T]
blk[tm] += grain[sy:sy + GRAIN_T, sx:sx + GRAIN_T][tm] * 0.85
covered += int(tm.sum())
log(f"grain: {covered} texels from {len(cand)} source tiles")
# feather rim
a_ = np.ones((h, w))
edge = mask_px.copy()
for k in range(FEATHER):
grown = edge.copy()
grown[1:-1, 1:-1] |= (edge[:-2, 1:-1] | edge[2:, 1:-1] | edge[1:-1, :-2] | edge[1:-1, 2:])
ring = grown & ~edge
a_[ring] = (k + 1) / (FEATHER + 1.0)
edge = grown
blend = np.where(mask_px, 1.0, 1.0 - a_)[:, :, None]
orig = rgb[:, :, :3].astype(np.float64)
final = np.clip(out_rgb * blend + orig * (1 - blend), 0, 1)
buf4 = rgb.copy()
buf4[:, :, :3] = final.astype(np.float32)
base.pixels.foreach_set(buf4.reshape(-1))
base.pack()
log("basecolor updated + packed")
# ---- normal + rm: neutralise over the same texels ----
for key_, flatval in (("normal", None), ("rm", None)):
if key_ not in imgs:
continue
im = imgs[key_]
iw, ih = im.size
b2 = np.empty(iw * ih * 4, dtype=np.float32)
im.pixels.foreach_get(b2)
arr = b2.reshape(ih, iw, 4)
if (iw, ih) != (w, h):
log(f" {key_}: size {iw}x{ih} != base — skipping")
continue
if key_ == "normal":
arr[mask_px, 0] = 0.5
arr[mask_px, 1] = 0.5
arr[mask_px, 2] = 1.0
else:
med = np.median(arr[src_ok][:, :3], axis=0)
arr[mask_px, 0] = med[0]
arr[mask_px, 1] = med[1]
arr[mask_px, 2] = med[2]
im.pixels.foreach_set(arr.reshape(-1))
im.pack()
log(f" {key_}: neutralised {mask_px.sum()} texels + packed")
bpy.context.preferences.filepaths.save_version = 0 # no .blend1 autosave
bpy.ops.wm.save_as_mainfile(filepath=OUT)
log(f"WROTE {OUT}")
print("TEXTURE_DONE")
@@ -0,0 +1,128 @@
# Stage 5b: global rosy-tone grade. Hard vertex-mask repaints leave visible boundaries
# (lighter V patch vs darker shoulders, pink neck above the z-cap). Instead: per-texel,
# compute the SMOOTHED red-green excess over the belly reference and subtract it with a
# z-tapered weight. Smooth field in, smooth field out — no boundaries; the high-frequency
# detail (pores, grain) rides on top untouched. Face is excluded by the taper (w=0 above
# z 0.91); lips/cheeks keep their red.
# blender --background --python 05b_tone_grade.py -- <07_textured.blend> <out.blend>
import bpy, sys, time
import numpy as np
argv = sys.argv[sys.argv.index("--") + 1:]
BLEND, OUT = argv[0], argv[1]
t0 = time.time()
REF_RG = None # belly reference; measured from the mesh below if None
STRENGTH = 0.90
Z_UP0, Z_UP1 = 0.26, 0.30 # taper in above the feet
Z_DN0, Z_DN1 = 0.88, 0.91 # taper out below the face
BLUR_R = 12
def log(m):
print(f"[grade {time.time()-t0:6.1f}s] {m}", flush=True)
bpy.ops.wm.open_mainfile(filepath=BLEND)
ob = max([o for o in bpy.data.objects if o.type == 'MESH'],
key=lambda o: len(o.data.vertices))
me = ob.data
n_v = len(me.vertices)
co = np.empty(n_v * 3)
me.vertices.foreach_get("co", co)
co = co.reshape(-1, 3)
base = next(i for i in bpy.data.images if "basecolor" in i.name.lower())
w, h = base.size
buf = np.empty(w * h * 4, dtype=np.float32)
base.pixels.foreach_get(buf)
rgb = buf.reshape(h, w, 4)
out_rgb = rgb[:, :, :3].astype(np.float64)
loops_v = np.empty(len(me.loops), dtype=np.int32)
me.loops.foreach_get("vertex_index", loops_v)
uv = np.empty(len(me.loops) * 2)
me.uv_layers.active.data.foreach_get("uv", uv)
uv = uv.reshape(-1, 2)
lx = np.clip(uv[:, 0], 0, 1) * (w - 1)
ly = np.clip(uv[:, 1], 0, 1) * (h - 1)
# belly reference from the mesh (sample texels under belly verts)
first_loop = np.full(n_v, len(loops_v), dtype=np.int64)
np.minimum.at(first_loop, loops_v, np.arange(len(loops_v), dtype=np.int64))
first_loop = np.minimum(first_loop, len(loops_v) - 1)
vcol = out_rgb[ly[first_loop].astype(int), lx[first_loop].astype(int)]
belly = (co[:, 1] < 0) & (co[:, 2] > 0.45) & (co[:, 2] < 0.60) & (np.abs(co[:, 0]) < 0.08)
ref = REF_RG if REF_RG is not None else float(np.median(vcol[belly, 0] - vcol[belly, 1]))
log(f"belly ref rg: {ref:.3f}")
def wz(z):
a = np.clip((z - Z_UP0) / (Z_UP1 - Z_UP0), 0, 1)
b = np.clip((Z_DN1 - z) / (Z_DN1 - Z_DN0), 0, 1)
t = np.minimum(a, b)
return t * t * (3 - 2 * t)
# rasterise the per-texel weight from vertex z over ALL faces
n_f = len(me.polygons)
l_tot = np.empty(n_f, dtype=np.int32)
me.polygons.foreach_get("loop_total", l_tot)
l_start = np.empty(n_f, dtype=np.int32)
me.polygons.foreach_get("loop_start", l_start)
vw = wz(co[:, 2])
W = np.zeros((h, w))
for fi in range(n_f):
s, t = l_start[fi], l_tot[fi]
li = np.arange(s, s + min(t, 3))
V = loops_v[li]
if vw[V].max() <= 0:
continue
P = np.stack([lx[li], ly[li]], axis=1)
x0, x1 = int(P[:, 0].min()), int(np.ceil(P[:, 0].max()))
y0, y1 = int(P[:, 1].min()), int(np.ceil(P[:, 1].max()))
if x1 - x0 > 256 or y1 - y0 > 256 or x1 < x0 or y1 < y0:
continue
d = ((P[1, 1] - P[2, 1]) * (P[0, 0] - P[2, 0]) +
(P[2, 0] - P[1, 0]) * (P[0, 1] - P[2, 1]))
if abs(d) < 1e-12:
continue
gx, gy = np.meshgrid(np.arange(x0, min(x1, w - 1) + 1),
np.arange(y0, min(y1, h - 1) + 1))
a = ((P[1, 1] - P[2, 1]) * (gx - P[2, 0]) + (P[2, 0] - P[1, 0]) * (gy - P[2, 1])) / d
b = ((P[2, 1] - P[0, 1]) * (gx - P[2, 0]) + (P[0, 0] - P[2, 0]) * (gy - P[2, 1])) / d
c = 1.0 - a - b
ins = (a >= -0.03) & (b >= -0.03) & (c >= -0.03)
if not ins.any():
continue
wv = a[ins] * vw[V[0]] + b[ins] * vw[V[1]] + c[ins] * vw[V[2]]
W[gy[ins], gx[ins]] = np.maximum(W[gy[ins], gx[ins]], np.clip(wv, 0, 1))
log(f"weight rasterised: {(W > 0).sum()} texels")
def box_blur1(a2, r):
def b1(x, axis):
p = [(0, 0)] * x.ndim
p[axis] = (r, r)
cs = np.cumsum(np.pad(x, p, mode="edge"), axis=axis)
return (np.take(cs, np.arange(2 * r, cs.shape[axis]), axis=axis) -
np.take(cs, np.arange(0, cs.shape[axis] - 2 * r), axis=axis)) / (2 * r)
return b1(b1(a2, 0), 1)
# smoothed rg field, weighted by W so face/eye texels can't bleed into the blur across
# island borders more than locally
rg_s = box_blur1(out_rgb[:, :, 0], BLUR_R) - box_blur1(out_rgb[:, :, 1], BLUR_R)
delta = np.maximum(0.0, rg_s - ref) * W * STRENGTH
out_rgb[:, :, 0] -= delta * 0.8
out_rgb[:, :, 1] += delta * 0.2
log(f"graded: {(delta > 0.005).sum()} texels above 0.005, max delta {delta.max():.3f}")
buf4 = rgb.copy()
buf4[:, :, :3] = np.clip(out_rgb, 0, 1).astype(np.float32)
base.pixels.foreach_set(buf4.reshape(-1))
base.pack()
bpy.context.preferences.filepaths.save_version = 0 # no .blend1 autosave
bpy.ops.wm.save_as_mainfile(filepath=OUT)
log(f"WROTE {OUT}")
print("GRADE_DONE")
@@ -0,0 +1,193 @@
# Stage 6: replace the crotch with the MALE game body's construction (Jeremy's directive —
# the male is already the smooth doll-like build; the female must match it exactly).
#
# blender --background --python 06_crotch.py -- <03_final_geometry.blend> <male.glb> <out.blend>
#
# Method: detect the crotch saddle landmark on both bodies (lowest midline point of the torso
# between the leg roots), scale the male crotch patch by body-height ratio, translate saddle to
# saddle, subdivide the patch to a smooth cage, and snap the female crotch window onto it with
# a ring-depth blend. The male GLB is in METRES; this sculpt is 0.9792 units tall.
import bpy, sys, time
from collections import deque
import numpy as np
from mathutils import Vector
from mathutils.bvhtree import BVHTree
argv = sys.argv[sys.argv.index("--") + 1:]
BLEND, MALE, OUT = argv[0], argv[1], argv[2]
t0 = time.time()
WIN_R_Z = 0.042 # female window: half-height around the saddle
WIN_R_X = 0.034 # half-width — must NOT reach the inner-thigh walls
SNAP_MAX = 0.020 # reject snaps to far surfaces (a bad cage grabbed thighs at 60 mm)
BLEND_RINGS = 8
def log(m):
print(f"[crotch {time.time()-t0:6.1f}s] {m}", flush=True)
bpy.ops.wm.open_mainfile(filepath=BLEND)
ob = max([o for o in bpy.data.objects if o.type == 'MESH'],
key=lambda o: len(o.data.vertices))
me = ob.data
n_v = len(me.vertices)
co = np.empty(n_v * 3)
me.vertices.foreach_get("co", co)
co = co.reshape(-1, 3)
f_height = co[:, 2].max() - co[:, 2].min()
# female saddle: lowest midline torso point between the legs (the briefs bridge the crotch,
# so the midline strip is continuous surface)
mid_f = (np.abs(co[:, 0]) < 0.01) & (co[:, 2] > 0.38) & (co[:, 2] < 0.50)
saddle_f = co[mid_f][np.argmin(co[mid_f, 2])]
log(f"female: height {f_height:.4f}, saddle {saddle_f}")
# ---- male donor ----
before = set(bpy.data.objects)
bpy.ops.import_scene.gltf(filepath=MALE)
new = [o for o in bpy.data.objects if o not in before]
male = max([o for o in new if o.type == 'MESH'], key=lambda o: len(o.data.vertices))
mme = male.data
nm = len(mme.vertices)
mco = np.empty(nm * 3)
mme.vertices.foreach_get("co", mco)
mco = mco.reshape(-1, 3)
m_height = mco[:, 2].max() - mco[:, 2].min()
mid_m = (np.abs(mco[:, 0]) < 0.02) & (mco[:, 2] > 0.55 * m_height) * (mco[:, 2] < 0.75 * m_height)
if not mid_m.any():
mid_m = (np.abs(mco[:, 0]) < 0.02)
saddle_m = mco[mid_m][np.argmin(mco[mid_m, 2])]
s = f_height / m_height
log(f"male '{male.name}': {nm}v height {m_height:.3f} m, saddle {saddle_m}, scale {s:.4f}")
# male crotch patch: faces whose verts lie near the saddle (generous; the cage is only a target)
sel_m = (np.abs(mco[:, 0] - saddle_m[0]) < WIN_R_X / s * 1.6) \
& (np.abs(mco[:, 2] - saddle_m[2]) < WIN_R_Z / s * 1.6)
log(f"male patch verts: {sel_m.sum()}")
# transform male verts into female space — and AUTO-DETECT front/back orientation: the male
# body may face the opposite way; test identity and y-mirror, keep whichever cage lands closer
# to the female window verts
mco_t = (mco - saddle_m) * s + saddle_f
mco_t_flip = mco_t.copy()
mco_t_flip[:, 1] = 2 * saddle_f[1] - mco_t[:, 1]
# build patch mesh -> subdivide -> BVH
ml_tot = np.empty(len(mme.polygons), dtype=np.int32)
mme.polygons.foreach_get("loop_total", ml_tot)
ml_start = np.empty(len(mme.polygons), dtype=np.int32)
mme.polygons.foreach_get("loop_start", ml_start)
ml_v = np.empty(len(mme.loops), dtype=np.int32)
mme.loops.foreach_get("vertex_index", ml_v)
faces = []
for fs, ft in zip(ml_start, ml_tot):
idxs = ml_v[fs:fs + ft]
if sel_m[idxs].all():
faces.append(idxs.tolist())
log(f"male patch faces: {len(faces)}")
used = sorted(set(i for f in faces for i in f))
remap = {g: i for i, g in enumerate(used)}
pm = bpy.data.meshes.new("crotch_patch")
pm.from_pydata([Vector(mco_t[i]) for i in used], [],
[[remap[i] for i in f] for f in faces])
pm.update()
po = bpy.data.objects.new("crotch_patch", pm)
bpy.context.collection.objects.link(po)
sub = po.modifiers.new("s", 'SUBSURF')
sub.levels = 2
bpy.context.view_layer.objects.active = po
bpy.ops.object.modifier_apply(modifier="s")
dme = po.data
dv = np.empty(len(dme.vertices) * 3)
dme.vertices.foreach_get("co", dv)
dv = dv.reshape(-1, 3)
dl_tot = np.empty(len(dme.polygons), dtype=np.int32)
dme.polygons.foreach_get("loop_total", dl_tot)
dl_start = np.empty(len(dme.polygons), dtype=np.int32)
dme.polygons.foreach_get("loop_start", dl_start)
dl_v = np.empty(len(dme.loops), dtype=np.int32)
dme.loops.foreach_get("vertex_index", dl_v)
polys_d = [dl_v[fs:fs + ft].tolist() for fs, ft in zip(dl_start, dl_tot)]
bvh = BVHTree.FromPolygons([Vector(v) for v in dv], polys_d,
all_triangles=False, epsilon=0.0)
dv_f = dv.copy()
dv_f[:, 1] = 2 * saddle_f[1] - dv[:, 1]
bvh_f = BVHTree.FromPolygons([Vector(v) for v in dv_f], polys_d,
all_triangles=False, epsilon=0.0)
for o in new + [po]:
bpy.data.objects.remove(o, do_unlink=True)
log("donor cages ready (both orientations)")
# ---- female window snap with ring-depth blend ----
win = (np.abs(co[:, 0] - saddle_f[0]) < WIN_R_X) \
& (np.abs(co[:, 2] - saddle_f[2]) < WIN_R_Z)
widx = np.nonzero(win)[0]
log(f"female window: {len(widx)} verts")
n_e = len(me.edges)
ev = np.empty(n_e * 2, dtype=np.int32)
me.edges.foreach_get("vertices", ev)
ev = ev.reshape(-1, 2)
order = np.concatenate([ev[:, 0], ev[:, 1]])
nbr = np.concatenate([ev[:, 1], ev[:, 0]])
srt = np.argsort(order, kind="stable")
o_s = order[srt]
n_s = nbr[srt]
ptr = np.searchsorted(o_s, np.arange(n_v + 1))
depth = np.zeros(n_v, dtype=np.int32)
dq = deque()
seen = np.zeros(n_v, dtype=bool)
for a, b in ev:
if win[a] != win[b]:
sv = a if win[a] else b
if not seen[sv]:
seen[sv] = True
depth[sv] = 1
dq.append(sv)
while dq:
c = dq.popleft()
for nb in n_s[ptr[c]:ptr[c + 1]]:
if win[nb] and not seen[nb]:
seen[nb] = True
depth[nb] = depth[c] + 1
dq.append(nb)
depth[win & ~seen] = BLEND_RINGS + 2
wgt = np.clip(depth[widx] / float(BLEND_RINGS), 0.0, 1.0)
wgt = wgt * wgt * (3 - 2 * wgt)
# orientation pick: median nearest-distance over a sample of window verts
samp = widx[::37]
def med_d(tree):
ds = []
for i in samp:
h = tree.find_nearest(Vector(co[i]), 0.08)
ds.append(h[3] if h[0] is not None else 0.08)
return float(np.median(ds))
d_id, d_fl = med_d(bvh), med_d(bvh_f)
use = bvh if d_id <= d_fl else bvh_f
log(f"orientation: identity {d_id:.4f} vs flipped {d_fl:.4f} -> "
f"{'identity' if d_id <= d_fl else 'flipped'}")
co_new = co.copy()
moved = 0
for k, i in enumerate(widx):
hit = use.find_nearest(Vector(co[i]), SNAP_MAX)
if hit[0] is None:
continue
tgt = np.array(hit[0])
co_new[i] += (tgt - co[i]) * wgt[k]
moved += 1
delta = np.linalg.norm(co_new - co, axis=1)
log(f"snapped {moved} verts, max move {delta.max():.4f}")
me.vertices.foreach_set("co", co_new.reshape(-1))
me.update()
if me.has_custom_normals:
vn = np.empty(n_v * 3, dtype=np.float32)
me.vertices.foreach_get("normal", vn)
me.normals_split_custom_set_from_vertices(vn.reshape(-1, 3))
bpy.context.preferences.filepaths.save_version = 0 # no .blend1 autosave
bpy.ops.wm.save_as_mainfile(filepath=OUT)
log(f"WROTE {OUT}")
print("CROTCH_DONE")
@@ -0,0 +1,211 @@
# Stage 6 v2: male-donor crotch transfer, full-gusset window.
#
# v1 post-mortem: the female briefs gusset bridges the legs ~3 cm ABOVE the male crotch
# saddle, so with SNAP_MAX 0.020 the lower half of the gusset couldn't reach the donor
# cage — the surface tore along the snapped/rejected boundary and the leftover panel kept
# its gathered-cloth wrinkles. v2 widens the window to the whole gusset, raises SNAP_MAX
# to span the real gap, melts whatever the cage still rejects, and Taubin-polishes the
# window so the cloth gathers on the inner thighs go too.
#
# blender --background --python 06_crotch_v2.py -- <03_final_geometry.blend> <male.glb> <out.blend>
import bpy, sys, time
from collections import deque
import numpy as np
from mathutils import Vector
from mathutils.bvhtree import BVHTree
argv = sys.argv[sys.argv.index("--") + 1:]
BLEND, MALE, OUT = argv[0], argv[1], argv[2]
t0 = time.time()
WIN_Z0, WIN_Z1 = 0.355, 0.470 # absolute: whole briefs gusset + pubic base
WIN_R_X = 0.058 # into the inner-thigh gathers, not past the thigh walls
SNAP_MAX = 0.035 # the gusset sits ~3 cm above the male crotch
BLEND_RINGS = 10
MELT_ITERS = 60 # residue verts the cage rejected
TAUBIN_PAIRS = 10
def log(m):
print(f"[crotch2 {time.time()-t0:6.1f}s] {m}", flush=True)
bpy.ops.wm.open_mainfile(filepath=BLEND)
ob = max([o for o in bpy.data.objects if o.type == 'MESH'],
key=lambda o: len(o.data.vertices))
me = ob.data
n_v = len(me.vertices)
co = np.empty(n_v * 3)
me.vertices.foreach_get("co", co)
co = co.reshape(-1, 3)
f_height = co[:, 2].max() - co[:, 2].min()
mid_f = (np.abs(co[:, 0]) < 0.01) & (co[:, 2] > 0.38) & (co[:, 2] < 0.50)
saddle_f = co[mid_f][np.argmin(co[mid_f, 2])]
log(f"female: height {f_height:.4f}, saddle {saddle_f}")
# ---- male donor ----
before = set(bpy.data.objects)
bpy.ops.import_scene.gltf(filepath=MALE)
new = [o for o in bpy.data.objects if o not in before]
male = max([o for o in new if o.type == 'MESH'], key=lambda o: len(o.data.vertices))
mme = male.data
nm = len(mme.vertices)
mco = np.empty(nm * 3)
mme.vertices.foreach_get("co", mco)
mco = mco.reshape(-1, 3)
m_height = mco[:, 2].max() - mco[:, 2].min()
mid_m = (np.abs(mco[:, 0]) < 0.02) & (mco[:, 2] > 0.55 * m_height) & (mco[:, 2] < 0.75 * m_height)
if not mid_m.any():
mid_m = (np.abs(mco[:, 0]) < 0.02)
saddle_m = mco[mid_m][np.argmin(mco[mid_m, 2])]
s = f_height / m_height
log(f"male '{male.name}': {nm}v height {m_height:.3f} m, saddle {saddle_m}, scale {s:.4f}")
# male crotch patch generous enough to cover the whole female window after transform
half_z = max(abs(WIN_Z1 - saddle_f[2]), abs(saddle_f[2] - WIN_Z0))
sel_m = (np.abs(mco[:, 0] - saddle_m[0]) < WIN_R_X / s * 1.8) \
& (np.abs(mco[:, 2] - saddle_m[2]) < half_z / s * 1.8)
log(f"male patch verts: {sel_m.sum()}")
mco_t = (mco - saddle_m) * s + saddle_f
ml_tot = np.empty(len(mme.polygons), dtype=np.int32)
mme.polygons.foreach_get("loop_total", ml_tot)
ml_start = np.empty(len(mme.polygons), dtype=np.int32)
mme.polygons.foreach_get("loop_start", ml_start)
ml_v = np.empty(len(mme.loops), dtype=np.int32)
mme.loops.foreach_get("vertex_index", ml_v)
faces = []
for fs, ft in zip(ml_start, ml_tot):
idxs = ml_v[fs:fs + ft]
if sel_m[idxs].all():
faces.append(idxs.tolist())
log(f"male patch faces: {len(faces)}")
used = sorted(set(i for f in faces for i in f))
remap = {g: i for i, g in enumerate(used)}
pm = bpy.data.meshes.new("crotch_patch")
pm.from_pydata([Vector(mco_t[i]) for i in used], [],
[[remap[i] for i in f] for f in faces])
pm.update()
po = bpy.data.objects.new("crotch_patch", pm)
bpy.context.collection.objects.link(po)
sub = po.modifiers.new("s", 'SUBSURF')
sub.levels = 2
bpy.context.view_layer.objects.active = po
bpy.ops.object.modifier_apply(modifier="s")
dme = po.data
dv = np.empty(len(dme.vertices) * 3)
dme.vertices.foreach_get("co", dv)
dv = dv.reshape(-1, 3)
dl_tot = np.empty(len(dme.polygons), dtype=np.int32)
dme.polygons.foreach_get("loop_total", dl_tot)
dl_start = np.empty(len(dme.polygons), dtype=np.int32)
dme.polygons.foreach_get("loop_start", dl_start)
dl_v = np.empty(len(dme.loops), dtype=np.int32)
dme.loops.foreach_get("vertex_index", dl_v)
polys_d = [dl_v[fs:fs + ft].tolist() for fs, ft in zip(dl_start, dl_tot)]
bvh = BVHTree.FromPolygons([Vector(v) for v in dv], polys_d,
all_triangles=False, epsilon=0.0)
dv_f = dv.copy()
dv_f[:, 1] = 2 * saddle_f[1] - dv[:, 1]
bvh_f = BVHTree.FromPolygons([Vector(v) for v in dv_f], polys_d,
all_triangles=False, epsilon=0.0)
for o in new + [po]:
bpy.data.objects.remove(o, do_unlink=True)
log("donor cages ready (both orientations)")
# ---- female window ----
win = (np.abs(co[:, 0] - saddle_f[0]) < WIN_R_X) \
& (co[:, 2] > WIN_Z0) & (co[:, 2] < WIN_Z1)
widx = np.nonzero(win)[0]
log(f"female window: {len(widx)} verts")
n_e = len(me.edges)
ev = np.empty(n_e * 2, dtype=np.int32)
me.edges.foreach_get("vertices", ev)
ev = ev.reshape(-1, 2)
order = np.concatenate([ev[:, 0], ev[:, 1]])
nbr = np.concatenate([ev[:, 1], ev[:, 0]])
srt = np.argsort(order, kind="stable")
o_s = order[srt]
n_s = nbr[srt]
ptr = np.searchsorted(o_s, np.arange(n_v + 1))
depth = np.zeros(n_v, dtype=np.int32)
dq = deque()
seen = np.zeros(n_v, dtype=bool)
for a, b in ev:
if win[a] != win[b]:
sv = a if win[a] else b
if not seen[sv]:
seen[sv] = True
depth[sv] = 1
dq.append(sv)
while dq:
c = dq.popleft()
for nb in n_s[ptr[c]:ptr[c + 1]]:
if win[nb] and not seen[nb]:
seen[nb] = True
depth[nb] = depth[c] + 1
dq.append(nb)
depth[win & ~seen] = BLEND_RINGS + 2
wgt = np.clip(depth[widx] / float(BLEND_RINGS), 0.0, 1.0)
wgt = wgt * wgt * (3 - 2 * wgt)
samp = widx[::37]
def med_d(tree):
ds = []
for i in samp:
h = tree.find_nearest(Vector(co[i]), 0.10)
ds.append(h[3] if h[0] is not None else 0.10)
return float(np.median(ds))
d_id, d_fl = med_d(bvh), med_d(bvh_f)
use = bvh if d_id <= d_fl else bvh_f
log(f"orientation: identity {d_id:.4f} vs flipped {d_fl:.4f} -> "
f"{'identity' if d_id <= d_fl else 'flipped'}")
co_new = co.copy()
moved = 0
rejected = []
for k, i in enumerate(widx):
hit = use.find_nearest(Vector(co[i]), SNAP_MAX)
if hit[0] is None:
rejected.append(i)
continue
tgt = np.array(hit[0])
co_new[i] += (tgt - co[i]) * wgt[k]
moved += 1
delta = np.linalg.norm(co_new - co, axis=1)
log(f"snapped {moved} verts (rejected {len(rejected)}), max move {delta.max():.4f}")
# ---- melt the rejects toward their neighbours so no torn seam survives ----
def neigh_mean(Q, idx):
out = np.empty((len(idx), 3))
for j, i in enumerate(idx):
nbrs = n_s[ptr[i]:ptr[i + 1]]
out[j] = Q[nbrs].mean(axis=0) if len(nbrs) else Q[i]
return out
rej = np.array(rejected, dtype=np.int32)
if len(rej):
for _ in range(MELT_ITERS):
co_new[rej] = 0.5 * co_new[rej] + 0.5 * neigh_mean(co_new, rej)
log(f"melted {len(rej)} rejected verts")
# ---- Taubin polish over the whole window (kills cloth gathers on the inner thighs) ----
lam, mu = 0.5, -0.53
for _ in range(TAUBIN_PAIRS):
co_new[widx] += lam * (neigh_mean(co_new, widx) - co_new[widx])
co_new[widx] += mu * (neigh_mean(co_new, widx) - co_new[widx])
log(f"Taubin x{TAUBIN_PAIRS} on window")
me.vertices.foreach_set("co", co_new.reshape(-1))
me.update()
if me.has_custom_normals:
vn = np.empty(n_v * 3, dtype=np.float32)
me.vertices.foreach_get("normal", vn)
me.normals_split_custom_set_from_vertices(vn.reshape(-1, 3))
bpy.context.preferences.filepaths.save_version = 0 # no .blend1 autosave
bpy.ops.wm.save_as_mainfile(filepath=OUT)
log(f"WROTE {OUT}")
print("CROTCH2_DONE")
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,55 @@
# Local melt of the crotch underside strip (residue from the donor snap's cage edge).
# blender --background --python 06b_strip_melt.py -- <in.blend> <out.blend>
import bpy, sys, time
import numpy as np
argv = sys.argv[sys.argv.index("--") + 1:]
t0 = time.time()
bpy.ops.wm.open_mainfile(filepath=argv[0])
ob = max([o for o in bpy.data.objects if o.type == 'MESH'],
key=lambda o: len(o.data.vertices))
me = ob.data
n_v = len(me.vertices)
co = np.empty(n_v * 3)
me.vertices.foreach_get("co", co)
co = co.reshape(-1, 3)
mid = (np.abs(co[:, 0]) < 0.01) & (co[:, 2] > 0.38) & (co[:, 2] < 0.50)
sad = co[mid][np.argmin(co[mid, 2])]
# ABSOLUTE bounds: the donor snap moved the saddle landmark, so a saddle-relative window
# centred 3.5 cm below the visible residue
strip = (np.abs(co[:, 0]) < 0.048) & (co[:, 2] > 0.368) & (co[:, 2] < 0.434)
sidx = np.nonzero(strip)[0]
print(f"[strip] {len(sidx)} verts around saddle {sad}")
ev = np.empty(len(me.edges) * 2, dtype=np.int32)
me.edges.foreach_get("vertices", ev)
ev = ev.reshape(-1, 2)
o_ = np.concatenate([ev[:, 0], ev[:, 1]])
n_ = np.concatenate([ev[:, 1], ev[:, 0]])
s_ = np.argsort(o_, kind="stable")
o_s = o_[s_]
n_s = n_[s_]
ptr = np.searchsorted(o_s, np.arange(n_v + 1))
cnt = np.maximum(np.diff(ptr), 1)
Q = co.copy()
for _ in range(120):
su = np.add.reduceat(Q[n_s], ptr[:-1], axis=0)
emp = np.diff(ptr) == 0
su[emp] = Q[emp]
mean = su / cnt[:, None]
for f in (0.55, -0.58):
pass
Q[sidx] = 0.45 * Q[sidx] + 0.55 * mean[sidx]
print(f"[strip] melted, max move {np.linalg.norm(Q-co,axis=1).max():.4f} "
f"({time.time()-t0:.1f}s)")
me.vertices.foreach_set("co", Q.reshape(-1))
me.update()
if me.has_custom_normals:
vn = np.empty(n_v * 3, dtype=np.float32)
me.vertices.foreach_get("normal", vn)
me.normals_split_custom_set_from_vertices(vn.reshape(-1, 3))
bpy.context.preferences.filepaths.save_version = 0 # no .blend1 autosave
bpy.ops.wm.save_as_mainfile(filepath=argv[1])
print("STRIP_DONE")
@@ -0,0 +1,119 @@
# Stage 6c: bi-harmonic membrane heal of the briefs-gusset residue between the legs.
#
# History: v1 donor snap (06_crotch.py) fixed the pubic front but its 2 cm SNAP_MAX left the
# gusset panel torn (the male crotch sits ~3 cm below the female bridge). Melting the panel
# (06b) smoothed the interior but not the torn silhouette; widening the snap window (v2)
# shredded the inner-thigh walls. This does what worked on the chest bow: excise the residue
# box and solve a rim-anchored bi-harmonic membrane to convergence — no donor reach limits,
# no orientation risk, C1-continuous with the surrounding skin by construction.
#
# blender --background --python 06c_gusset_membrane.py -- <06_crotched.blend> <out.blend>
import bpy, sys, time
from collections import deque
import numpy as np
argv = sys.argv[sys.argv.index("--") + 1:]
BLEND, OUT = argv[0], argv[1]
t0 = time.time()
# residue box: the gusset bridge underside + torn seam + gathered inner-thigh cloth edges.
# (bridge underside starts at z~0.41; tear at ~0.44; gathers reach ~0.46 and |x|~0.05)
BOX_X = 0.055
BOX_Z0, BOX_Z1 = 0.385, 0.462
def log(m):
print(f"[gusset {time.time()-t0:6.1f}s] {m}", flush=True)
bpy.ops.wm.open_mainfile(filepath=BLEND)
ob = max([o for o in bpy.data.objects if o.type == 'MESH'],
key=lambda o: len(o.data.vertices))
me = ob.data
n_v = len(me.vertices)
co = np.empty(n_v * 3)
me.vertices.foreach_get("co", co)
co = co.reshape(-1, 3)
n_e = len(me.edges)
ev0 = np.empty(n_e * 2, dtype=np.int32)
me.edges.foreach_get("vertices", ev0)
ev0 = ev0.reshape(-1, 2)
def grow_edges(mask, rings, ev):
m = mask.copy()
for _ in range(rings):
hit = m[ev[:, 0]] | m[ev[:, 1]]
m2 = m.copy()
m2[ev[:, 0]] |= hit
m2[ev[:, 1]] |= hit
m = m2
return m
box = (np.abs(co[:, 0]) < BOX_X) & (co[:, 2] > BOX_Z0) & (co[:, 2] < BOX_Z1)
free_m = grow_edges(box, 2, ev0)
collar = grow_edges(free_m, 2, ev0) & ~free_m
S = np.nonzero(free_m | collar)[0]
in_S = np.zeros(n_v, dtype=bool)
in_S[S] = True
glb = np.full(n_v, -1, dtype=np.int64)
glb[S] = np.arange(len(S))
se = ev0[in_S[ev0].all(axis=1)]
a_ = glb[se[:, 0]]
b_ = glb[se[:, 1]]
deg = np.zeros(len(S))
np.add.at(deg, a_, 1.0)
np.add.at(deg, b_, 1.0)
free = free_m[S]
log(f"box {box.sum()} verts -> free {free.sum()}, collar {(~free).sum()}")
def Ls(X):
out = deg[:, None] * X
np.add.at(out, a_, -X[b_])
np.add.at(out, b_, -X[a_])
return out
def A_op(U):
X = np.zeros((len(S), 3))
X[free] = U
return Ls(Ls(X))[free]
Xc = np.zeros((len(S), 3))
Xc[~free] = co[S[~free]]
rhs = -Ls(Ls(Xc))[free]
U = co[S[free]].copy()
r = rhs - A_op(U)
p = r.copy()
rs = (r * r).sum()
rs0 = rs
for it in range(120000):
Ap = A_op(p)
al = rs / max((p * Ap).sum(), 1e-30)
U += al * p
r -= al * Ap
rs2 = (r * r).sum()
if rs2 < 1e-18 or rs2 < rs0 * 1e-14:
break
p = r + (rs2 / rs) * p
rs = rs2
co_new = co.copy()
co_new[S[free]] = U
delta = np.linalg.norm(co_new - co, axis=1)
log(f"membrane: {free.sum()} verts healed (CG {it} iters, "
f"rel residual {rs2/max(rs0,1e-30):.2e}), max move {delta.max():.4f}")
me.vertices.foreach_set("co", co_new.reshape(-1))
me.update()
if me.has_custom_normals:
vn = np.empty(n_v * 3, dtype=np.float32)
me.vertices.foreach_get("normal", vn)
me.normals_split_custom_set_from_vertices(vn.reshape(-1, 3))
bpy.context.preferences.filepaths.save_version = 0 # no .blend1 autosave
bpy.ops.wm.save_as_mainfile(filepath=OUT)
log(f"WROTE {OUT}")
print("GUSSET_DONE")
@@ -0,0 +1,89 @@
# Stage 6d: self-locating spot melt of remaining flaps in the thigh gap.
# Finds high-roughness verts inside the gap box (an open-slit flap the membrane pulled),
# grows a 2-ring collar, melts. Prints the cluster it found so the fix is auditable.
# blender --background --python 06d_spot_melt.py -- <in.blend> <out.blend>
import bpy, sys, time
import numpy as np
argv = sys.argv[sys.argv.index("--") + 1:]
BLEND, OUT = argv[0], argv[1]
t0 = time.time()
BOX = lambda co: (np.abs(co[:, 0]) < 0.075) & (co[:, 2] > 0.345) & (co[:, 2] < 0.475)
ROUGH_THR = 0.0012
MELT_ITERS = 80
def log(m):
print(f"[spot {time.time()-t0:6.1f}s] {m}", flush=True)
bpy.ops.wm.open_mainfile(filepath=BLEND)
ob = max([o for o in bpy.data.objects if o.type == 'MESH'],
key=lambda o: len(o.data.vertices))
me = ob.data
n_v = len(me.vertices)
co = np.empty(n_v * 3)
me.vertices.foreach_get("co", co)
co = co.reshape(-1, 3)
n_e = len(me.edges)
ev = np.empty(n_e * 2, dtype=np.int32)
me.edges.foreach_get("vertices", ev)
ev = ev.reshape(-1, 2)
order = np.concatenate([ev[:, 0], ev[:, 1]])
nbr = np.concatenate([ev[:, 1], ev[:, 0]])
srt = np.argsort(order, kind="stable")
o_s = order[srt]
n_s = nbr[srt]
ptr = np.searchsorted(o_s, np.arange(n_v + 1))
cnt = np.maximum(ptr[1:] - ptr[:-1], 1)
def smooth_field(Q, iters):
X = Q.copy()
for _ in range(iters):
acc = np.zeros_like(X)
np.add.at(acc, o_s, X[n_s])
X = acc / cnt[:, None]
return X
sm = smooth_field(co, 8)
rough = np.linalg.norm(co - sm, axis=1)
box = BOX(co)
hot = box & (rough > ROUGH_THR)
log(f"box {box.sum()} verts, hot {hot.sum()} (rough>{ROUGH_THR})")
if hot.sum():
hc = co[hot]
log(f"hot cluster: x [{hc[:,0].min():+.4f}..{hc[:,0].max():+.4f}] "
f"y [{hc[:,1].min():+.4f}..{hc[:,1].max():+.4f}] "
f"z [{hc[:,2].min():+.4f}..{hc[:,2].max():+.4f}]")
m = hot.copy()
for _ in range(2):
h = m[ev[:, 0]] | m[ev[:, 1]]
m2 = m.copy()
m2[ev[:, 0]] |= h
m2[ev[:, 1]] |= h
m = m2
sidx = np.nonzero(m)[0]
Q = co.copy()
for _ in range(MELT_ITERS):
acc = np.zeros_like(Q)
np.add.at(acc, o_s, Q[n_s])
mean = acc / cnt[:, None]
Q[sidx] = 0.5 * Q[sidx] + 0.5 * mean[sidx]
d = np.linalg.norm(Q - co, axis=1)
log(f"melted {len(sidx)} verts, max move {d.max():.4f}")
me.vertices.foreach_set("co", Q.reshape(-1))
me.update()
if me.has_custom_normals:
vn = np.empty(n_v * 3, dtype=np.float32)
me.vertices.foreach_get("normal", vn)
me.normals_split_custom_set_from_vertices(vn.reshape(-1, 3))
else:
log("nothing hot — no melt applied")
bpy.context.preferences.filepaths.save_version = 0 # no .blend1 autosave
bpy.ops.wm.save_as_mainfile(filepath=OUT)
log(f"WROTE {OUT}")
print("SPOT_DONE")
@@ -0,0 +1,130 @@
# Stage 6e: melt open-slit rims in the crotch box.
# The remaining flap and the dotted briefs-edge lines both live on boundary edges (the mesh
# is not watertight). Roughness misses them — a folded flap is locally smooth. Select verts
# on boundary edges inside the box, grow 3 rings, melt hard; also stitch: each boundary vert
# pairs with its nearest non-neighbour boundary vert within 2.5 mm and both move to the
# midpoint, closing the slit gap positionally (topology untouched, masks.npz stays valid).
# blender --background --python 06e_slit_melt.py -- <in.blend> <out.blend>
import bpy, sys, time
import numpy as np
from mathutils import Vector
from mathutils.kdtree import KDTree
argv = sys.argv[sys.argv.index("--") + 1:]
BLEND, OUT = argv[0], argv[1]
t0 = time.time()
BOX = lambda co: (np.abs(co[:, 0]) < 0.075) & (co[:, 2] > 0.340) & (co[:, 2] < 0.480)
MELT_ITERS = 100
STITCH_R = 0.0025
def log(m):
print(f"[slit {time.time()-t0:6.1f}s] {m}", flush=True)
bpy.ops.wm.open_mainfile(filepath=BLEND)
ob = max([o for o in bpy.data.objects if o.type == 'MESH'],
key=lambda o: len(o.data.vertices))
me = ob.data
n_v = len(me.vertices)
co = np.empty(n_v * 3)
me.vertices.foreach_get("co", co)
co = co.reshape(-1, 3)
n_e = len(me.edges)
ev = np.empty(n_e * 2, dtype=np.int32)
me.edges.foreach_get("vertices", ev)
ev = ev.reshape(-1, 2)
# boundary edges: adjacent to exactly one face
l_tot = np.empty(len(me.polygons), dtype=np.int32)
me.polygons.foreach_get("loop_total", l_tot)
l_start = np.empty(len(me.polygons), dtype=np.int32)
me.polygons.foreach_get("loop_start", l_start)
l_v = np.empty(len(me.loops), dtype=np.int32)
me.loops.foreach_get("vertex_index", l_v)
ecount = {}
for fs, ft in zip(l_start, l_tot):
idxs = l_v[fs:fs + ft]
for k in range(ft):
a, b = idxs[k], idxs[(k + 1) % ft]
key = (a, b) if a < b else (b, a)
ecount[key] = ecount.get(key, 0) + 1
bnd_v = np.zeros(n_v, dtype=bool)
for (a, b), c in ecount.items():
if c == 1:
bnd_v[a] = bnd_v[b] = True
log(f"boundary verts total: {bnd_v.sum()}")
box = BOX(co)
hot = box & bnd_v
log(f"slit verts in box: {hot.sum()}")
# stitch pass: pair each hot vert with nearest hot vert that is not a mesh neighbour
order = np.concatenate([ev[:, 0], ev[:, 1]])
nbr = np.concatenate([ev[:, 1], ev[:, 0]])
srt = np.argsort(order, kind="stable")
o_s = order[srt]
n_s = nbr[srt]
ptr = np.searchsorted(o_s, np.arange(n_v + 1))
cnt = np.maximum(ptr[1:] - ptr[:-1], 1)
Q = co.copy()
hidx = np.nonzero(hot)[0]
if len(hidx):
tree = KDTree(len(hidx))
for j, i in enumerate(hidx):
tree.insert(Vector(Q[i]), j)
tree.balance()
neigh_sets = {int(i): set(int(x) for x in n_s[ptr[i]:ptr[i + 1]]) for i in hidx}
stitched = 0
done = set()
for j, i in enumerate(hidx):
if j in done:
continue
best = None
for (_, k, dist) in tree.find_range(Vector(Q[i]), STITCH_R):
if k == j or k in done:
continue
ik = int(hidx[k])
if ik in neigh_sets[int(i)]:
continue
if best is None or dist < best[1]:
best = (k, dist)
if best is not None:
ik = int(hidx[best[0]])
mid = 0.5 * (Q[i] + Q[ik])
Q[i] = mid
Q[ik] = mid
done.add(j)
done.add(best[0])
stitched += 1
log(f"stitched {stitched} slit pairs")
m = hot.copy()
for _ in range(3):
h = m[ev[:, 0]] | m[ev[:, 1]]
m2 = m.copy()
m2[ev[:, 0]] |= h
m2[ev[:, 1]] |= h
m = m2
sidx = np.nonzero(m)[0]
for _ in range(MELT_ITERS):
acc = np.zeros_like(Q)
np.add.at(acc, o_s, Q[n_s])
mean = acc / cnt[:, None]
Q[sidx] = 0.5 * Q[sidx] + 0.5 * mean[sidx]
d = np.linalg.norm(Q - co, axis=1)
log(f"melted {len(sidx)} rim verts, max move {d.max():.4f}")
me.vertices.foreach_set("co", Q.reshape(-1))
me.update()
if me.has_custom_normals:
vn = np.empty(n_v * 3, dtype=np.float32)
me.vertices.foreach_get("normal", vn)
me.normals_split_custom_set_from_vertices(vn.reshape(-1, 3))
bpy.context.preferences.filepaths.save_version = 0 # no .blend1 autosave
bpy.ops.wm.save_as_mainfile(filepath=OUT)
log(f"WROTE {OUT}")
print("SLIT_DONE")
@@ -0,0 +1,98 @@
# Stage 6f: fold melt in the crotch box, normal-deviation detector.
# The mesh has only 3 boundary verts (00_welded closed the slits) — the remaining flap and
# dotted seam lines are welded CREASES: positions locally smooth, normals kinked. Detect
# verts whose normal deviates > ANG_THR from the 5-iter smoothed normal field, grow, melt.
# blender --background --python 06f_fold_melt.py -- <in.blend> <out.blend>
import bpy, sys, time, math
import numpy as np
argv = sys.argv[sys.argv.index("--") + 1:]
BLEND, OUT = argv[0], argv[1]
t0 = time.time()
# optional box override: <bx> <z0> <z1> [ang_deg]
if len(argv) >= 5:
BX, BZ0, BZ1 = float(argv[2]), float(argv[3]), float(argv[4])
ANG_THR = math.radians(float(argv[5])) if len(argv) > 5 else math.radians(15.0)
else:
BX, BZ0, BZ1 = 0.075, 0.340, 0.480
ANG_THR = math.radians(15.0)
# navel stays: it is a real feature made of exactly the kind of kink this melts
NAVEL = lambda co: (np.abs(co[:, 0]) < 0.022) & (co[:, 2] > 0.495) & (co[:, 2] < 0.555) & (co[:, 1] < 0)
BOX = lambda co: (np.abs(co[:, 0]) < BX) & (co[:, 2] > BZ0) & (co[:, 2] < BZ1) & ~NAVEL(co)
MELT_ITERS = 150
def log(m):
print(f"[fold {time.time()-t0:6.1f}s] {m}", flush=True)
bpy.ops.wm.open_mainfile(filepath=BLEND)
ob = max([o for o in bpy.data.objects if o.type == 'MESH'],
key=lambda o: len(o.data.vertices))
me = ob.data
n_v = len(me.vertices)
co = np.empty(n_v * 3)
me.vertices.foreach_get("co", co)
co = co.reshape(-1, 3)
nrm = np.empty(n_v * 3)
me.vertices.foreach_get("normal", nrm)
nrm = nrm.reshape(-1, 3)
n_e = len(me.edges)
ev = np.empty(n_e * 2, dtype=np.int32)
me.edges.foreach_get("vertices", ev)
ev = ev.reshape(-1, 2)
order = np.concatenate([ev[:, 0], ev[:, 1]])
nbr = np.concatenate([ev[:, 1], ev[:, 0]])
srt = np.argsort(order, kind="stable")
o_s = order[srt]
n_s = nbr[srt]
ptr = np.searchsorted(o_s, np.arange(n_v + 1))
cnt = np.maximum(ptr[1:] - ptr[:-1], 1)
N = nrm.copy()
for _ in range(5):
acc = np.zeros_like(N)
np.add.at(acc, o_s, N[n_s])
N = acc / cnt[:, None]
N /= np.maximum(np.linalg.norm(N, axis=1, keepdims=True), 1e-12)
dot = np.clip((nrm * N).sum(axis=1), -1.0, 1.0)
ang = np.arccos(dot)
box = BOX(co)
hot = box & (ang > ANG_THR)
log(f"box {box.sum()}, folds {hot.sum()} (>{math.degrees(ANG_THR):.0f} deg)")
if hot.sum():
hc = co[hot]
log(f"fold cluster: x [{hc[:,0].min():+.4f}..{hc[:,0].max():+.4f}] "
f"y [{hc[:,1].min():+.4f}..{hc[:,1].max():+.4f}] "
f"z [{hc[:,2].min():+.4f}..{hc[:,2].max():+.4f}]")
m = hot.copy()
for _ in range(3):
h = m[ev[:, 0]] | m[ev[:, 1]]
m2 = m.copy()
m2[ev[:, 0]] |= h
m2[ev[:, 1]] |= h
m = m2
sidx = np.nonzero(m)[0]
Q = co.copy()
for _ in range(MELT_ITERS):
acc = np.zeros_like(Q)
np.add.at(acc, o_s, Q[n_s])
mean = acc / cnt[:, None]
Q[sidx] = 0.5 * Q[sidx] + 0.5 * mean[sidx]
d = np.linalg.norm(Q - co, axis=1)
log(f"melted {len(sidx)} fold verts, max move {d.max():.4f}")
me.vertices.foreach_set("co", Q.reshape(-1))
me.update()
if me.has_custom_normals:
vn = np.empty(n_v * 3, dtype=np.float32)
me.vertices.foreach_get("normal", vn)
me.normals_split_custom_set_from_vertices(vn.reshape(-1, 3))
else:
log("no folds found")
bpy.context.preferences.filepaths.save_version = 0 # no .blend1 autosave
bpy.ops.wm.save_as_mainfile(filepath=OUT)
log(f"WROTE {OUT}")
print("FOLD_DONE")
@@ -0,0 +1,103 @@
# Stage 6g: locate the surviving inner-thigh flap by camera ray-cast, melt a sphere there.
# The flap dodged the roughness, boundary-rim, and normal-kink detectors — so aim through
# the diagnostic camera pixel where it is visibly rendered (dbg front_tight, ~px 565,630 of
# 1000^2) and heal whatever the ray hits. Prints the hit so the fix is auditable.
# blender --background --python 06g_pixel_melt.py -- <in.blend> <out.blend>
import bpy, sys, time, math
import numpy as np
from mathutils import Vector, Euler
argv = sys.argv[sys.argv.index("--") + 1:]
BLEND, OUT = argv[0], argv[1]
t0 = time.time()
# front_tight camera from dbg_gusset.py
CAM_LOC = Vector((0.0, -0.55, 0.41))
CAM_ROT = Euler((math.radians(90), 0, 0))
LENS, SENSOR = 85.0, 36.0
# pixels (x, y from top) in the 1000^2 render where the flap shows; a few samples across it
PIXELS = [(560, 615), (568, 628), (575, 640), (582, 652), (562, 640), (572, 618)]
R_MELT = 0.010
MELT_ITERS = 200
def log(m):
print(f"[pix {time.time()-t0:6.1f}s] {m}", flush=True)
bpy.ops.wm.open_mainfile(filepath=BLEND)
ob = max([o for o in bpy.data.objects if o.type == 'MESH'],
key=lambda o: len(o.data.vertices))
me = ob.data
n_v = len(me.vertices)
co = np.empty(n_v * 3)
me.vertices.foreach_get("co", co)
co = co.reshape(-1, 3)
deps = bpy.context.evaluated_depsgraph_get()
rot = CAM_ROT.to_matrix()
fwd = rot @ Vector((0, 0, -1))
right = rot @ Vector((1, 0, 0))
up = rot @ Vector((0, 1, 0))
half = SENSOR / (2 * LENS)
hits = []
for px, py in PIXELS:
ndc_x = (px / 1000.0 - 0.5) * 2
ndc_y = (0.5 - py / 1000.0) * 2
d = (fwd + right * (ndc_x * half) + up * (ndc_y * half)).normalized()
ok, loc, nrm_h, fi, obj, _ = bpy.context.scene.ray_cast(deps, CAM_LOC, d)
if ok:
hits.append(np.array(loc))
log(f"px({px},{py}) -> hit {np.round(np.array(loc), 4)}")
else:
log(f"px({px},{py}) -> MISS")
if not hits:
log("no hits; aborting without changes")
bpy.context.preferences.filepaths.save_version = 0 # no .blend1 autosave
bpy.ops.wm.save_as_mainfile(filepath=OUT)
sys.exit(0)
hits = np.array(hits)
ctr = hits.mean(axis=0)
log(f"flap centre {np.round(ctr,4)}, spread {np.round(hits.std(axis=0),4)}")
sel = np.linalg.norm(co - ctr, axis=1) < R_MELT
sidx = np.nonzero(sel)[0]
log(f"melt sphere r={R_MELT}: {len(sidx)} verts")
n_e = len(me.edges)
ev = np.empty(n_e * 2, dtype=np.int32)
me.edges.foreach_get("vertices", ev)
ev = ev.reshape(-1, 2)
order = np.concatenate([ev[:, 0], ev[:, 1]])
nbr = np.concatenate([ev[:, 1], ev[:, 0]])
srt = np.argsort(order, kind="stable")
o_s = order[srt]
n_s = nbr[srt]
ptr = np.searchsorted(o_s, np.arange(n_v + 1))
cnt = np.maximum(ptr[1:] - ptr[:-1], 1)
# soft weight: full melt at centre, fades at rim so no new crease forms
w = np.clip(1.0 - np.linalg.norm(co[sidx] - ctr, axis=1) / R_MELT, 0.0, 1.0)
w = w * w * (3 - 2 * w)
Q = co.copy()
for _ in range(MELT_ITERS):
acc = np.zeros_like(Q)
np.add.at(acc, o_s, Q[n_s])
mean = acc / cnt[:, None]
Q[sidx] = Q[sidx] + (mean[sidx] - Q[sidx]) * (0.6 * w[:, None])
d = np.linalg.norm(Q - co, axis=1)
log(f"melted, max move {d.max():.4f}")
me.vertices.foreach_set("co", Q.reshape(-1))
me.update()
if me.has_custom_normals:
vn = np.empty(n_v * 3, dtype=np.float32)
me.vertices.foreach_get("normal", vn)
me.normals_split_custom_set_from_vertices(vn.reshape(-1, 3))
bpy.context.preferences.filepaths.save_version = 0 # no .blend1 autosave
bpy.ops.wm.save_as_mainfile(filepath=OUT)
log(f"WROTE {OUT}")
print("PIX_DONE")
@@ -0,0 +1,109 @@
# Stage 6h: bi-harmonic membrane over the located flap sphere. Melting (06g) barely dented
# the fold — layered flaps are locally smooth and resist neighbour-averaging. Replace instead:
# excise the sphere, solve the rim-anchored membrane (same method that healed the gusset).
# blender --background --python 06h_flap_membrane.py -- <in.blend> <out.blend> <cx> <cy> <cz> <r>
import bpy, sys, time
import numpy as np
argv = sys.argv[sys.argv.index("--") + 1:]
BLEND, OUT = argv[0], argv[1]
CTR = np.array([float(argv[2]), float(argv[3]), float(argv[4])])
R = float(argv[5])
t0 = time.time()
def log(m):
print(f"[flap {time.time()-t0:6.1f}s] {m}", flush=True)
bpy.ops.wm.open_mainfile(filepath=BLEND)
ob = max([o for o in bpy.data.objects if o.type == 'MESH'],
key=lambda o: len(o.data.vertices))
me = ob.data
n_v = len(me.vertices)
co = np.empty(n_v * 3)
me.vertices.foreach_get("co", co)
co = co.reshape(-1, 3)
n_e = len(me.edges)
ev0 = np.empty(n_e * 2, dtype=np.int32)
me.edges.foreach_get("vertices", ev0)
ev0 = ev0.reshape(-1, 2)
def grow_edges(mask, rings, ev):
m = mask.copy()
for _ in range(rings):
hit = m[ev[:, 0]] | m[ev[:, 1]]
m2 = m.copy()
m2[ev[:, 0]] |= hit
m2[ev[:, 1]] |= hit
m = m2
return m
sph = np.linalg.norm(co - CTR, axis=1) < R
free_m = grow_edges(sph, 1, ev0)
collar = grow_edges(free_m, 2, ev0) & ~free_m
S = np.nonzero(free_m | collar)[0]
in_S = np.zeros(n_v, dtype=bool)
in_S[S] = True
glb = np.full(n_v, -1, dtype=np.int64)
glb[S] = np.arange(len(S))
se = ev0[in_S[ev0].all(axis=1)]
a_ = glb[se[:, 0]]
b_ = glb[se[:, 1]]
deg = np.zeros(len(S))
np.add.at(deg, a_, 1.0)
np.add.at(deg, b_, 1.0)
free = free_m[S]
log(f"sphere {sph.sum()} -> free {free.sum()}, collar {(~free).sum()}")
def Ls(X):
out = deg[:, None] * X
np.add.at(out, a_, -X[b_])
np.add.at(out, b_, -X[a_])
return out
def A_op(U):
X = np.zeros((len(S), 3))
X[free] = U
return Ls(Ls(X))[free]
Xc = np.zeros((len(S), 3))
Xc[~free] = co[S[~free]]
rhs = -Ls(Ls(Xc))[free]
U = co[S[free]].copy()
r = rhs - A_op(U)
p = r.copy()
rs = (r * r).sum()
rs0 = rs
for it in range(120000):
Ap = A_op(p)
al = rs / max((p * Ap).sum(), 1e-30)
U += al * p
r -= al * Ap
rs2 = (r * r).sum()
if rs2 < 1e-18 or rs2 < rs0 * 1e-14:
break
p = r + (rs2 / rs) * p
rs = rs2
co_new = co.copy()
co_new[S[free]] = U
d = np.linalg.norm(co_new - co, axis=1)
log(f"membrane: {free.sum()} verts (CG {it} iters, rel {rs2/max(rs0,1e-30):.2e}), "
f"max move {d.max():.4f}")
me.vertices.foreach_set("co", co_new.reshape(-1))
me.update()
if me.has_custom_normals:
vn = np.empty(n_v * 3, dtype=np.float32)
me.vertices.foreach_get("normal", vn)
me.normals_split_custom_set_from_vertices(vn.reshape(-1, 3))
bpy.context.preferences.filepaths.save_version = 0 # no .blend1 autosave
bpy.ops.wm.save_as_mainfile(filepath=OUT)
log(f"WROTE {OUT}")
print("FLAP_DONE")
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,32 @@
# Stage 8: export the finished hires nude sculpt as a packed GLB.
# blender --background --python 08_export.py -- <07_polished.blend> <out.glb>
import bpy, sys, time
import numpy as np
argv = sys.argv[sys.argv.index("--") + 1:]
BLEND, OUT = argv[0], argv[1]
t0 = time.time()
bpy.ops.wm.open_mainfile(filepath=BLEND)
ob = max([o for o in bpy.data.objects if o.type == 'MESH'],
key=lambda o: len(o.data.vertices))
me = ob.data
co = np.empty(len(me.vertices) * 3)
me.vertices.foreach_get("co", co)
co = co.reshape(-1, 3)
print(f"[export] {ob.name}: {len(me.vertices)}v {len(me.polygons)}f "
f"height {co[:,2].max()-co[:,2].min():.4f} units, "
f"images {[i.name for i in bpy.data.images if i.has_data]}")
bpy.ops.export_scene.gltf(
filepath=OUT,
export_format='GLB',
export_image_format='AUTO',
export_yup=True,
export_apply=False,
export_animations=False,
export_skins=True,
export_morph=False,
)
print(f"[export] WROTE {OUT} ({time.time()-t0:.1f}s)")
print("EXPORT_DONE")
@@ -0,0 +1,56 @@
# Final beauty renders (textured, original materials): full front/40deg/back, chest, hip.
# blender --background --python 09_beauty.py -- <blend> <outdir>
import bpy, sys, math, os
from mathutils import Vector, Euler
argv = sys.argv[sys.argv.index("--") + 1:]
BLEND, OUT = argv[0], argv[1]
os.makedirs(OUT, exist_ok=True)
bpy.ops.wm.open_mainfile(filepath=BLEND)
for o in list(bpy.data.objects):
if o.type in ('LIGHT', 'CAMERA'):
bpy.data.objects.remove(o, do_unlink=True)
sc = bpy.context.scene
sc.render.engine = 'BLENDER_EEVEE'
sc.render.resolution_x = sc.render.resolution_y = 1200
wd = bpy.data.worlds.new("w")
wd.use_nodes = True
wd.node_tree.nodes["Background"].inputs[0].default_value = (0.22, 0.22, 0.24, 1)
sc.world = wd
def sun(rot, e):
ld = bpy.data.lights.new("s", 'SUN')
ld.energy = e
ld.use_shadow = False
lo = bpy.data.objects.new("s", ld)
lo.rotation_euler = rot
bpy.context.collection.objects.link(lo)
sun(Euler((math.radians(55), 0, math.radians(-35))), 2.2)
sun(Euler((math.radians(120), 0, math.radians(150))), 1.0)
sun(Euler((math.radians(85), 0, math.radians(35))), 0.8)
cam = bpy.data.cameras.new("c")
cam.lens = 70
cob = bpy.data.objects.new("c", cam)
bpy.context.collection.objects.link(cob)
sc.camera = cob
views = [
("full_front", Vector((0.0, -2.05, 0.50)), Euler((math.radians(90), 0, 0))),
("full_40", Vector((-1.35, -1.55, 0.50)), Euler((math.radians(90), 0, math.radians(-41)))),
("full_back", Vector((0.0, 2.05, 0.50)), Euler((math.radians(90), 0, math.radians(180)))),
("chest", Vector((0.0, -0.85, 0.70)), Euler((math.radians(90), 0, 0))),
("hip", Vector((0.0, -0.85, 0.46)), Euler((math.radians(90), 0, 0))),
]
for name, loc, rot in views:
cob.location = loc
cob.rotation_euler = rot
sc.render.filepath = os.path.join(OUT, f"{name}.png")
bpy.ops.render.render(write_still=True)
print(f"rendered {name}", flush=True)
print("BEAUTY_DONE")
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,142 @@
# Stage 11: heal the garment-line creases across the stomach/waist/hips.
# The lines are GEOMETRY (they show in clay): the briefs waistband ledge and leg-hem ridges
# (the briefs zone's 1x field kept hip anatomy AND the hem ridges), plus dotted pinch lines
# where the source mesh's open slits were welded. Strip = hemband mask slit-seam verts
# (mapped from the RAW pre-weld GLB's boundary edges) normal-kink verts in the torso band.
# Taubin (volume-preserving) on the strip: ridges round off, hips/butt keep their shape.
# blender --background --python 11_line_heal.py -- <in.blend> <masks.npz> <raw.glb> <out.blend>
import bpy, sys, time, math
import numpy as np
from mathutils import Vector
from mathutils.kdtree import KDTree
argv = sys.argv[sys.argv.index("--") + 1:]
BLEND, MASKS, RAW, OUT = argv[0], argv[1], argv[2], argv[3]
t0 = time.time()
ANG_THR = math.radians(12.0)
TAUBIN_PAIRS = 30
Z0, Z1 = 0.30, 0.87
def log(m):
print(f"[heal {time.time()-t0:6.1f}s] {m}", flush=True)
bpy.ops.wm.open_mainfile(filepath=BLEND)
ob = max([o for o in bpy.data.objects if o.type == 'MESH'],
key=lambda o: len(o.data.vertices))
me = ob.data
n_v = len(me.vertices)
co = np.empty(n_v * 3)
me.vertices.foreach_get("co", co)
co = co.reshape(-1, 3)
M = np.load(MASKS)
hemband = M["hemband"]
# ---- slit-seam verts from the raw pre-weld GLB ----
before = set(bpy.data.objects)
bpy.ops.import_scene.gltf(filepath=RAW)
new = [o for o in bpy.data.objects if o not in before]
raw = max([o for o in new if o.type == 'MESH'], key=lambda o: len(o.data.vertices))
rme = raw.data
rn = len(rme.vertices)
rco = np.empty(rn * 3)
rme.vertices.foreach_get("co", rco)
rco = rco.reshape(-1, 3)
l_tot = np.empty(len(rme.polygons), dtype=np.int32)
rme.polygons.foreach_get("loop_total", l_tot)
l_start = np.empty(len(rme.polygons), dtype=np.int32)
rme.polygons.foreach_get("loop_start", l_start)
l_v = np.empty(len(rme.loops), dtype=np.int32)
rme.loops.foreach_get("vertex_index", l_v)
ecount = {}
for fs, ft in zip(l_start, l_tot):
idxs = l_v[fs:fs + ft]
for k in range(ft):
a, b = idxs[k], idxs[(k + 1) % ft]
key = (a, b) if a < b else (b, a)
ecount[key] = ecount.get(key, 0) + 1
rbnd = np.zeros(rn, dtype=bool)
for (a, b), c in ecount.items():
if c == 1:
rbnd[a] = rbnd[b] = True
log(f"raw boundary verts: {rbnd.sum()}")
for o in new:
bpy.data.objects.remove(o, do_unlink=True)
kd = KDTree(n_v)
for i in range(n_v):
kd.insert(Vector(co[i]), i)
kd.balance()
seam = np.zeros(n_v, dtype=bool)
# NB: positions have been sculpted since the weld — match generously but only in the torso
# band, and only trust matches within 6 mm (sculpted garment areas moved far more; their
# seams are already handled by the fills/melts there)
for p in rco[rbnd]:
if not (Z0 < p[2] < Z1):
continue
hit = kd.find(Vector(p))
if hit[0] is not None and hit[2] < 0.006:
seam[hit[1]] = True
log(f"seam verts mapped: {seam.sum()}")
# ---- normal-kink verts (ledges/ridges) ----
nrm = np.empty(n_v * 3)
me.vertices.foreach_get("normal", nrm)
nrm = nrm.reshape(-1, 3)
n_e = len(me.edges)
ev = np.empty(n_e * 2, dtype=np.int32)
me.edges.foreach_get("vertices", ev)
ev = ev.reshape(-1, 2)
order = np.concatenate([ev[:, 0], ev[:, 1]])
nbr = np.concatenate([ev[:, 1], ev[:, 0]])
srt = np.argsort(order, kind="stable")
o_s = order[srt]
n_s = nbr[srt]
ptr = np.searchsorted(o_s, np.arange(n_v + 1))
cnt = np.maximum(ptr[1:] - ptr[:-1], 1)
N = nrm.copy()
for _ in range(5):
acc = np.zeros_like(N)
np.add.at(acc, o_s, N[n_s])
N = acc / cnt[:, None]
N /= np.maximum(np.linalg.norm(N, axis=1, keepdims=True), 1e-12)
ang = np.arccos(np.clip((nrm * N).sum(axis=1), -1, 1))
navel = (np.abs(co[:, 0]) < 0.022) & (co[:, 2] > 0.495) & (co[:, 2] < 0.555) & (co[:, 1] < 0)
band = (co[:, 2] > 0.42) & (co[:, 2] < Z1) & ~navel
kink = band & (ang > ANG_THR)
log(f"kink verts: {kink.sum()}")
strip = (hemband | seam | kink) & (co[:, 2] > Z0) & (co[:, 2] < Z1) & ~navel
for _ in range(2):
hit = strip[ev[:, 0]] | strip[ev[:, 1]]
s2 = strip.copy()
s2[ev[:, 0]] |= hit
s2[ev[:, 1]] |= hit
strip = s2
strip &= ~navel
sidx = np.nonzero(strip)[0]
log(f"strip: {len(sidx)} verts")
Q = co.copy()
lam, mu = 0.5, -0.53
for _ in range(TAUBIN_PAIRS):
for f in (lam, mu):
acc = np.zeros_like(Q)
np.add.at(acc, o_s, Q[n_s])
mean = acc / cnt[:, None]
Q[sidx] += f * (mean[sidx] - Q[sidx])
d = np.linalg.norm(Q - co, axis=1)
log(f"Taubin x{TAUBIN_PAIRS}: max move {d.max():.4f}")
me.vertices.foreach_set("co", Q.reshape(-1))
me.update()
if me.has_custom_normals:
vn = np.empty(n_v * 3, dtype=np.float32)
me.vertices.foreach_get("normal", vn)
me.normals_split_custom_set_from_vertices(vn.reshape(-1, 3))
bpy.context.preferences.filepaths.save_version = 0 # no .blend1 autosave
bpy.ops.wm.save_as_mainfile(filepath=OUT)
log(f"WROTE {OUT}")
print("HEAL_DONE")
@@ -0,0 +1,177 @@
# Stage 12: membrane-heal the garment dig-in lines (waistband ledge, leg-hem creases,
# belly/underbust dashes). These are DENTS the briefs/bra pressed into the body — the briefs
# zone's 1x anatomy field kept them, and Taubin/melting preserves exactly this mid-frequency
# shape. Fix = the proven pattern: narrow bands along the crease curves (mid-freq roughness
# detector + mapped slit seams), rim-anchored bi-harmonic membrane across them.
# blender --background --python 12_dent_membrane.py -- <in.blend> <raw.glb> <out.blend>
import bpy, sys, time
import numpy as np
from mathutils import Vector
from mathutils.kdtree import KDTree
argv = sys.argv[sys.argv.index("--") + 1:]
BLEND, RAW, OUT = argv[0], argv[1], argv[2]
t0 = time.time()
ROUGH_THR = 0.0006
Z0, Z1 = 0.42, 0.655 # waist/hip/belly lines up to under the mounds; crotch already healed
GROW_FREE = 3
t0 = time.time()
def log(m):
print(f"[dent {time.time()-t0:6.1f}s] {m}", flush=True)
bpy.ops.wm.open_mainfile(filepath=BLEND)
ob = max([o for o in bpy.data.objects if o.type == 'MESH'],
key=lambda o: len(o.data.vertices))
me = ob.data
n_v = len(me.vertices)
co = np.empty(n_v * 3)
me.vertices.foreach_get("co", co)
co = co.reshape(-1, 3)
n_e = len(me.edges)
ev0 = np.empty(n_e * 2, dtype=np.int32)
me.edges.foreach_get("vertices", ev0)
ev0 = ev0.reshape(-1, 2)
o_r = np.concatenate([ev0[:, 0], ev0[:, 1]])
n_r = np.concatenate([ev0[:, 1], ev0[:, 0]])
s_r = np.argsort(o_r, kind="stable")
o_rs = o_r[s_r]
n_rs = n_r[s_r]
ptr_r = np.searchsorted(o_rs, np.arange(n_v + 1))
cnt_r = np.maximum(np.diff(ptr_r), 1)
sm = co.copy()
for _ in range(8):
su = np.add.reduceat(sm[n_rs], ptr_r[:-1], axis=0)
emp = np.diff(ptr_r) == 0
su[emp] = sm[emp]
sm = su / cnt_r[:, None]
rough = np.linalg.norm(co - sm, axis=1)
# slit seams from the raw pre-weld GLB (the dotted dash lines)
before = set(bpy.data.objects)
bpy.ops.import_scene.gltf(filepath=RAW)
new = [o for o in bpy.data.objects if o not in before]
raw = max([o for o in new if o.type == 'MESH'], key=lambda o: len(o.data.vertices))
rme = raw.data
rn = len(rme.vertices)
rco = np.empty(rn * 3)
rme.vertices.foreach_get("co", rco)
rco = rco.reshape(-1, 3)
l_tot = np.empty(len(rme.polygons), dtype=np.int32)
rme.polygons.foreach_get("loop_total", l_tot)
l_start = np.empty(len(rme.polygons), dtype=np.int32)
rme.polygons.foreach_get("loop_start", l_start)
l_v = np.empty(len(rme.loops), dtype=np.int32)
rme.loops.foreach_get("vertex_index", l_v)
ecount = {}
for fs, ft in zip(l_start, l_tot):
idxs = l_v[fs:fs + ft]
for k in range(ft):
a, b = idxs[k], idxs[(k + 1) % ft]
kk = (a, b) if a < b else (b, a)
ecount[kk] = ecount.get(kk, 0) + 1
rbnd = np.zeros(rn, dtype=bool)
for (a, b), c in ecount.items():
if c == 1:
rbnd[a] = rbnd[b] = True
for o in new:
bpy.data.objects.remove(o, do_unlink=True)
kd = KDTree(n_v)
for i in range(n_v):
kd.insert(Vector(co[i]), i)
kd.balance()
seam = np.zeros(n_v, dtype=bool)
for p in rco[rbnd]:
if not (Z0 < p[2] < Z1):
continue
hit = kd.find(Vector(p))
if hit[0] is not None and hit[2] < 0.006:
seam[hit[1]] = True
log(f"seam verts: {seam.sum()}")
navel = (np.abs(co[:, 0]) < 0.022) & (co[:, 2] > 0.495) & (co[:, 2] < 0.555) & (co[:, 1] < 0)
band = (co[:, 2] > Z0) & (co[:, 2] < Z1) & ~navel
hot = band & ((rough > ROUGH_THR) | seam)
log(f"hot line verts: {hot.sum()}")
def grow_edges(mask, rings, ev):
m = mask.copy()
for _ in range(rings):
hit = m[ev[:, 0]] | m[ev[:, 1]]
m2 = m.copy()
m2[ev[:, 0]] |= hit
m2[ev[:, 1]] |= hit
m = m2
return m
free_m = grow_edges(hot, GROW_FREE, ev0) & ~navel
collar = grow_edges(free_m, 2, ev0) & ~free_m
S = np.nonzero(free_m | collar)[0]
in_S = np.zeros(n_v, dtype=bool)
in_S[S] = True
glb = np.full(n_v, -1, dtype=np.int64)
glb[S] = np.arange(len(S))
se = ev0[in_S[ev0].all(axis=1)]
a_ = glb[se[:, 0]]
b_ = glb[se[:, 1]]
deg = np.zeros(len(S))
np.add.at(deg, a_, 1.0)
np.add.at(deg, b_, 1.0)
free = free_m[S]
log(f"free {free.sum()}, collar {(~free).sum()}")
def Ls(X):
out = deg[:, None] * X
np.add.at(out, a_, -X[b_])
np.add.at(out, b_, -X[a_])
return out
def A_op(U):
X = np.zeros((len(S), 3))
X[free] = U
return Ls(Ls(X))[free]
Xc = np.zeros((len(S), 3))
Xc[~free] = co[S[~free]]
rhs = -Ls(Ls(Xc))[free]
U = co[S[free]].copy()
r = rhs - A_op(U)
p = r.copy()
rs = (r * r).sum()
rs0 = rs
for it in range(120000):
Ap = A_op(p)
al = rs / max((p * Ap).sum(), 1e-30)
U += al * p
r -= al * Ap
rs2 = (r * r).sum()
if rs2 < 1e-18 or rs2 < rs0 * 1e-14:
break
p = r + (rs2 / rs) * p
rs = rs2
co_new = co.copy()
co_new[S[free]] = U
d = np.linalg.norm(co_new - co, axis=1)
log(f"membrane: {free.sum()} verts (CG {it}, rel {rs2/max(rs0,1e-30):.2e}), max move {d.max():.4f}")
me.vertices.foreach_set("co", co_new.reshape(-1))
me.update()
if me.has_custom_normals:
vn = np.empty(n_v * 3, dtype=np.float32)
me.vertices.foreach_get("normal", vn)
me.normals_split_custom_set_from_vertices(vn.reshape(-1, 3))
bpy.context.preferences.filepaths.save_version = 0 # no .blend1 autosave
bpy.ops.wm.save_as_mainfile(filepath=OUT)
log(f"WROTE {OUT}")
print("DENT_DONE")
@@ -0,0 +1,168 @@
# Stage 13: heal the garment-BOUNDARY dig-ins. Every stomach/waist line sits where an old
# garment mask edge was: the sculpt replaced the region INSIDE the mask, but anchored its
# membrane on the rim — exactly where the waistband/leg-hems/bra-band dug into the body, so
# the dig-in ring survived as the boundary condition. Free a band STRADDLING every garment
# boundary (grow-out XOR shrink-in), plus slit seams on unmoved skin (0.5 mm mapping), and
# solve the rim-anchored membrane across it.
# blender --background --python 13_rim_membrane.py -- <in.blend> <masks.npz> <raw.glb> <out.blend>
import bpy, sys, time
import numpy as np
from mathutils import Vector
from mathutils.kdtree import KDTree
argv = sys.argv[sys.argv.index("--") + 1:]
BLEND, MASKS, RAW, OUT = argv[0], argv[1], argv[2], argv[3]
t0 = time.time()
RIM = 10 # rings each side of the garment boundary (~1 cm)
Z0, Z1 = 0.40, 0.86
SEAM_TOL = 0.0005 # only trust seam mapping on UNMOVED skin
def log(m):
print(f"[rim {time.time()-t0:6.1f}s] {m}", flush=True)
bpy.ops.wm.open_mainfile(filepath=BLEND)
ob = max([o for o in bpy.data.objects if o.type == 'MESH'],
key=lambda o: len(o.data.vertices))
me = ob.data
n_v = len(me.vertices)
co = np.empty(n_v * 3)
me.vertices.foreach_get("co", co)
co = co.reshape(-1, 3)
M = np.load(MASKS)
garment = M["garment"]
n_e = len(me.edges)
ev0 = np.empty(n_e * 2, dtype=np.int32)
me.edges.foreach_get("vertices", ev0)
ev0 = ev0.reshape(-1, 2)
def grow_edges(mask, rings, ev):
m = mask.copy()
for _ in range(rings):
hit = m[ev[:, 0]] | m[ev[:, 1]]
m2 = m.copy()
m2[ev[:, 0]] |= hit
m2[ev[:, 1]] |= hit
m = m2
return m
def shrink_edges(mask, rings, ev):
return ~grow_edges(~mask, rings, ev)
rim_band = grow_edges(garment, RIM, ev0) & ~shrink_edges(garment, RIM, ev0)
log(f"garment rim band: {rim_band.sum()}")
# slit seams on unmoved skin
before = set(bpy.data.objects)
bpy.ops.import_scene.gltf(filepath=RAW)
new = [o for o in bpy.data.objects if o not in before]
raw = max([o for o in new if o.type == 'MESH'], key=lambda o: len(o.data.vertices))
rme = raw.data
rn = len(rme.vertices)
rco = np.empty(rn * 3)
rme.vertices.foreach_get("co", rco)
rco = rco.reshape(-1, 3)
l_tot = np.empty(len(rme.polygons), dtype=np.int32)
rme.polygons.foreach_get("loop_total", l_tot)
l_start = np.empty(len(rme.polygons), dtype=np.int32)
rme.polygons.foreach_get("loop_start", l_start)
l_v = np.empty(len(rme.loops), dtype=np.int32)
rme.loops.foreach_get("vertex_index", l_v)
ecount = {}
for fs, ft in zip(l_start, l_tot):
idxs = l_v[fs:fs + ft]
for k in range(ft):
a, b = idxs[k], idxs[(k + 1) % ft]
kk = (a, b) if a < b else (b, a)
ecount[kk] = ecount.get(kk, 0) + 1
rbnd = np.zeros(rn, dtype=bool)
for (a, b), c in ecount.items():
if c == 1:
rbnd[a] = rbnd[b] = True
for o in new:
bpy.data.objects.remove(o, do_unlink=True)
kd = KDTree(n_v)
for i in range(n_v):
kd.insert(Vector(co[i]), i)
kd.balance()
seam = np.zeros(n_v, dtype=bool)
for p in rco[rbnd]:
if not (Z0 < p[2] < Z1):
continue
hit = kd.find(Vector(p))
if hit[0] is not None and hit[2] < SEAM_TOL:
seam[hit[1]] = True
log(f"seam verts (tight map): {seam.sum()}")
navel = (np.abs(co[:, 0]) < 0.022) & (co[:, 2] > 0.495) & (co[:, 2] < 0.555) & (co[:, 1] < 0)
free_m = ((rim_band | grow_edges(seam, 3, ev0)) &
(co[:, 2] > Z0) & (co[:, 2] < Z1) & ~navel)
collar = grow_edges(free_m, 2, ev0) & ~free_m
S = np.nonzero(free_m | collar)[0]
in_S = np.zeros(n_v, dtype=bool)
in_S[S] = True
glb = np.full(n_v, -1, dtype=np.int64)
glb[S] = np.arange(len(S))
se = ev0[in_S[ev0].all(axis=1)]
a_ = glb[se[:, 0]]
b_ = glb[se[:, 1]]
deg = np.zeros(len(S))
np.add.at(deg, a_, 1.0)
np.add.at(deg, b_, 1.0)
free = free_m[S]
log(f"free {free.sum()}, collar {(~free).sum()}")
def Ls(X):
out = deg[:, None] * X
np.add.at(out, a_, -X[b_])
np.add.at(out, b_, -X[a_])
return out
def A_op(U):
X = np.zeros((len(S), 3))
X[free] = U
return Ls(Ls(X))[free]
Xc = np.zeros((len(S), 3))
Xc[~free] = co[S[~free]]
rhs = -Ls(Ls(Xc))[free]
U = co[S[free]].copy()
r = rhs - A_op(U)
p = r.copy()
rs = (r * r).sum()
rs0 = rs
for it in range(120000):
Ap = A_op(p)
al = rs / max((p * Ap).sum(), 1e-30)
U += al * p
r -= al * Ap
rs2 = (r * r).sum()
if rs2 < 1e-18 or rs2 < rs0 * 1e-14:
break
p = r + (rs2 / rs) * p
rs = rs2
co_new = co.copy()
co_new[S[free]] = U
d = np.linalg.norm(co_new - co, axis=1)
log(f"membrane: {free.sum()} verts (CG {it}, rel {rs2/max(rs0,1e-30):.2e}), max move {d.max():.4f}")
me.vertices.foreach_set("co", co_new.reshape(-1))
me.update()
if me.has_custom_normals:
vn = np.empty(n_v * 3, dtype=np.float32)
me.vertices.foreach_get("normal", vn)
me.normals_split_custom_set_from_vertices(vn.reshape(-1, 3))
bpy.context.preferences.filepaths.save_version = 0 # no .blend1 autosave
bpy.ops.wm.save_as_mainfile(filepath=OUT)
log(f"WROTE {OUT}")
print("RIM_DONE")
@@ -0,0 +1,156 @@
# Stage 14: heal the SOURCE MESH's panel-seam network. The mask viz proved the stomach/waist
# lines are the Tripo scan-panel slits (a body-wide grid), not garment edges — the paint had
# been camouflaging them. Map the raw mesh's boundary verts at 2 mm (welding moved verts ~1 mm,
# which is why a 0.5 mm map only caught a quarter of the network), grow 2, membrane across.
# blender --background --python 14_seam_membrane.py -- <in.blend> <raw.glb> <out.blend>
import bpy, sys, time
import numpy as np
from mathutils import Vector
from mathutils.kdtree import KDTree
argv = sys.argv[sys.argv.index("--") + 1:]
BLEND, RAW, OUT = argv[0], argv[1], argv[2]
t0 = time.time()
SEAM_TOL = 0.002
Z0, Z1 = 0.28, 0.87 # whole body below the chin; face seams stay (framed by features)
def log(m):
print(f"[seam {time.time()-t0:6.1f}s] {m}", flush=True)
bpy.ops.wm.open_mainfile(filepath=BLEND)
ob = max([o for o in bpy.data.objects if o.type == 'MESH'],
key=lambda o: len(o.data.vertices))
me = ob.data
n_v = len(me.vertices)
co = np.empty(n_v * 3)
me.vertices.foreach_get("co", co)
co = co.reshape(-1, 3)
n_e = len(me.edges)
ev0 = np.empty(n_e * 2, dtype=np.int32)
me.edges.foreach_get("vertices", ev0)
ev0 = ev0.reshape(-1, 2)
before = set(bpy.data.objects)
bpy.ops.import_scene.gltf(filepath=RAW)
new = [o for o in bpy.data.objects if o not in before]
raw = max([o for o in new if o.type == 'MESH'], key=lambda o: len(o.data.vertices))
rme = raw.data
rn = len(rme.vertices)
rco = np.empty(rn * 3)
rme.vertices.foreach_get("co", rco)
rco = rco.reshape(-1, 3)
l_tot = np.empty(len(rme.polygons), dtype=np.int32)
rme.polygons.foreach_get("loop_total", l_tot)
l_start = np.empty(len(rme.polygons), dtype=np.int32)
rme.polygons.foreach_get("loop_start", l_start)
l_v = np.empty(len(rme.loops), dtype=np.int32)
rme.loops.foreach_get("vertex_index", l_v)
ecount = {}
for fs, ft in zip(l_start, l_tot):
idxs = l_v[fs:fs + ft]
for k in range(ft):
a, b = idxs[k], idxs[(k + 1) % ft]
kk = (a, b) if a < b else (b, a)
ecount[kk] = ecount.get(kk, 0) + 1
rbnd = np.zeros(rn, dtype=bool)
for (a, b), c in ecount.items():
if c == 1:
rbnd[a] = rbnd[b] = True
for o in new:
bpy.data.objects.remove(o, do_unlink=True)
log(f"raw boundary verts: {rbnd.sum()}")
kd = KDTree(n_v)
for i in range(n_v):
kd.insert(Vector(co[i]), i)
kd.balance()
seam = np.zeros(n_v, dtype=bool)
for p in rco[rbnd]:
if not (Z0 < p[2] < Z1):
continue
hit = kd.find(Vector(p))
if hit[0] is not None and hit[2] < SEAM_TOL:
seam[hit[1]] = True
log(f"seam verts mapped: {seam.sum()}")
navel = (np.abs(co[:, 0]) < 0.022) & (co[:, 2] > 0.495) & (co[:, 2] < 0.555) & (co[:, 1] < 0)
def grow_edges(mask, rings, ev):
m = mask.copy()
for _ in range(rings):
hit = m[ev[:, 0]] | m[ev[:, 1]]
m2 = m.copy()
m2[ev[:, 0]] |= hit
m2[ev[:, 1]] |= hit
m = m2
return m
free_m = grow_edges(seam, 2, ev0) & (co[:, 2] > Z0) & (co[:, 2] < Z1) & ~navel
collar = grow_edges(free_m, 2, ev0) & ~free_m
S = np.nonzero(free_m | collar)[0]
in_S = np.zeros(n_v, dtype=bool)
in_S[S] = True
glb = np.full(n_v, -1, dtype=np.int64)
glb[S] = np.arange(len(S))
se = ev0[in_S[ev0].all(axis=1)]
a_ = glb[se[:, 0]]
b_ = glb[se[:, 1]]
deg = np.zeros(len(S))
np.add.at(deg, a_, 1.0)
np.add.at(deg, b_, 1.0)
free = free_m[S]
log(f"free {free.sum()}, collar {(~free).sum()}")
def Ls(X):
out = deg[:, None] * X
np.add.at(out, a_, -X[b_])
np.add.at(out, b_, -X[a_])
return out
def A_op(U):
X = np.zeros((len(S), 3))
X[free] = U
return Ls(Ls(X))[free]
Xc = np.zeros((len(S), 3))
Xc[~free] = co[S[~free]]
rhs = -Ls(Ls(Xc))[free]
U = co[S[free]].copy()
r = rhs - A_op(U)
p = r.copy()
rs = (r * r).sum()
rs0 = rs
for it in range(120000):
Ap = A_op(p)
al = rs / max((p * Ap).sum(), 1e-30)
U += al * p
r -= al * Ap
rs2 = (r * r).sum()
if rs2 < 1e-18 or rs2 < rs0 * 1e-14:
break
p = r + (rs2 / rs) * p
rs = rs2
co_new = co.copy()
co_new[S[free]] = U
d = np.linalg.norm(co_new - co, axis=1)
log(f"membrane: {free.sum()} verts (CG {it}, rel {rs2/max(rs0,1e-30):.2e}), max move {d.max():.4f}")
me.vertices.foreach_set("co", co_new.reshape(-1))
me.update()
if me.has_custom_normals:
vn = np.empty(n_v * 3, dtype=np.float32)
me.vertices.foreach_get("normal", vn)
me.normals_split_custom_set_from_vertices(vn.reshape(-1, 3))
bpy.context.preferences.filepaths.save_version = 0 # no .blend1 autosave
bpy.ops.wm.save_as_mainfile(filepath=OUT)
log(f"WROTE {OUT}")
print("SEAM_DONE")
@@ -0,0 +1,94 @@
# Stage 15: TRUE topological weld of the panel seams. Diagnosis: membranes level both sides
# of every seam yet the lines persist -> the two sides are disconnected vertex runs (the
# original "weld" unified positions only). Each panel smooths and shades independently, so a
# crack survives any vertex MOVEMENT. Fix: bmesh remove_doubles restricted to the seam verts,
# which merges the runs into shared vertices -> shared normals -> no shading discontinuity.
# Loops keep their UVs, so the baked texture is unaffected. Vertex count changes; this is
# only legal at the END of the pipeline (masks.npz indices die here).
# blender --background --python 15_true_weld.py -- <in.blend> <raw.glb> <out.blend>
import bpy, bmesh, sys, time
import numpy as np
from mathutils import Vector
from mathutils.kdtree import KDTree
argv = sys.argv[sys.argv.index("--") + 1:]
BLEND, RAW, OUT = argv[0], argv[1], argv[2]
t0 = time.time()
SEAM_TOL = 0.0025
MERGE_DIST = 0.002
def log(m):
print(f"[weld {time.time()-t0:6.1f}s] {m}", flush=True)
bpy.ops.wm.open_mainfile(filepath=BLEND)
ob = max([o for o in bpy.data.objects if o.type == 'MESH'],
key=lambda o: len(o.data.vertices))
me = ob.data
n_v = len(me.vertices)
co = np.empty(n_v * 3)
me.vertices.foreach_get("co", co)
co = co.reshape(-1, 3)
before = set(bpy.data.objects)
bpy.ops.import_scene.gltf(filepath=RAW)
new = [o for o in bpy.data.objects if o not in before]
raw = max([o for o in new if o.type == 'MESH'], key=lambda o: len(o.data.vertices))
rme = raw.data
rn = len(rme.vertices)
log(f"working mesh {n_v}v | raw mesh {rn}v | equal: {rn == n_v}")
rco = np.empty(rn * 3)
rme.vertices.foreach_get("co", rco)
rco = rco.reshape(-1, 3)
l_tot = np.empty(len(rme.polygons), dtype=np.int32)
rme.polygons.foreach_get("loop_total", l_tot)
l_start = np.empty(len(rme.polygons), dtype=np.int32)
rme.polygons.foreach_get("loop_start", l_start)
l_v = np.empty(len(rme.loops), dtype=np.int32)
rme.loops.foreach_get("vertex_index", l_v)
ecount = {}
for fs, ft in zip(l_start, l_tot):
idxs = l_v[fs:fs + ft]
for k in range(ft):
a, b = idxs[k], idxs[(k + 1) % ft]
kk = (a, b) if a < b else (b, a)
ecount[kk] = ecount.get(kk, 0) + 1
rbnd = np.zeros(rn, dtype=bool)
for (a, b), c in ecount.items():
if c == 1:
rbnd[a] = rbnd[b] = True
for o in new:
bpy.data.objects.remove(o, do_unlink=True)
log(f"raw boundary verts: {rbnd.sum()}")
kd = KDTree(n_v)
for i in range(n_v):
kd.insert(Vector(co[i]), i)
kd.balance()
seam = np.zeros(n_v, dtype=bool)
for p in rco[rbnd]:
for (_, idx, dist) in kd.find_range(Vector(p), SEAM_TOL):
seam[idx] = True
log(f"seam verts (range map): {seam.sum()}")
bm = bmesh.new()
bm.from_mesh(me)
bm.verts.ensure_lookup_table()
sel = [bm.verts[i] for i in np.nonzero(seam)[0]]
res = bmesh.ops.remove_doubles(bm, verts=sel, dist=MERGE_DIST)
bm.to_mesh(me)
bm.free()
me.update()
n_after = len(me.vertices)
log(f"merged: {n_v} -> {n_after} verts (-{n_v - n_after})")
# smooth vertex normals across the now-shared seams
vn = np.empty(n_after * 3, dtype=np.float32)
me.vertices.foreach_get("normal", vn)
me.normals_split_custom_set_from_vertices(vn.reshape(-1, 3))
bpy.context.preferences.filepaths.save_version = 0 # no .blend1 autosave
bpy.ops.wm.save_as_mainfile(filepath=OUT)
log(f"WROTE {OUT}")
print("WELD_DONE")
@@ -0,0 +1,278 @@
# Stage 16 (diagnosis only — writes nothing): measure the three defects Jeremy named, so the
# fixes target what is actually there instead of repeating stages 11-15.
#
# blender --background --python 16_diagnose.py -- <in.blend> <orig.blend> [scratch_dir]
#
# 1. CUT LINES. Are they still topology (disconnected panel runs / holes) after stage 15's weld,
# or are they now a purely geometric groove? Reports boundary edges, non-manifold edges,
# degenerate faces, and — for the strongest shading-kink clusters — the groove depth in mm
# measured perpendicular to the line. Depth tells us whether to weld harder or to fillet.
# 2. DISCOLOURATION. Finds the repainted texels by diffing this blend's packed basecolor against
# the ORIGINAL texture, then reports mean RGB inside the patch vs a ring of untouched skin
# just outside it. A tone STEP at the boundary is a Poisson problem; a uniform offset over the
# whole patch is a levelling problem. The numbers separate them.
# 3. CLEAVAGE. Samples the medial (sternum) corridor for concavity: minimum principal-curvature
# radius per height, so "sharp crease" vs "round fillet" is a number, not an opinion.
import bpy, bmesh, sys, os, time, math
import numpy as np
argv = sys.argv[sys.argv.index("--") + 1:]
BLEND = argv[0]
ORIG = argv[1] if len(argv) > 1 else ""
SCRATCH = argv[2] if len(argv) > 2 else "."
t0 = time.time()
def log(m):
print(f"[diag {time.time()-t0:6.1f}s] {m}", flush=True)
def body_of():
return max([o for o in bpy.data.objects if o.type == 'MESH'],
key=lambda o: len(o.data.vertices))
# =============================================================================
# 1. TOPOLOGY + CUT LINES
# =============================================================================
bpy.ops.wm.open_mainfile(filepath=BLEND)
ob = body_of()
me = ob.data
n_v = len(me.vertices)
n_f = len(me.polygons)
log(f"mesh '{ob.name}': {n_v} verts, {n_f} faces, custom_normals={me.has_custom_normals}")
co = np.empty(n_v * 3)
me.vertices.foreach_get("co", co)
co = co.reshape(-1, 3)
print(f"BBOX z {co[:,2].min():.3f}..{co[:,2].max():.3f} "
f"x {co[:,0].min():.3f}..{co[:,0].max():.3f} y {co[:,1].min():.3f}..{co[:,1].max():.3f}")
bm = bmesh.new()
bm.from_mesh(me)
bnd = [e for e in bm.edges if len(e.link_faces) == 1]
nonman = [e for e in bm.edges if len(e.link_faces) > 2]
degen = [f for f in bm.faces if f.calc_area() < 1e-12]
loose = [v for v in bm.verts if not v.link_faces]
print(f"TOPO boundary_edges={len(bnd)} nonmanifold_edges={len(nonman)} "
f"degenerate_faces={len(degen)} loose_verts={len(loose)}")
# where are the holes? cluster boundary verts by height
if bnd:
bz = np.array([v.co.z for e in bnd for v in e.verts])
hist, edges = np.histogram(bz, bins=12)
print("BOUNDARY-EDGE z histogram (holes live here):")
for c, lo, hi in zip(hist, edges[:-1], edges[1:]):
if c:
print(f" z {lo:.3f}-{hi:.3f}: {c}")
bm.free()
# ---- shading kinks = the visible lines ----
nrm = np.empty(n_v * 3)
me.vertices.foreach_get("normal", nrm)
nrm = nrm.reshape(-1, 3)
ev = np.empty(len(me.edges) * 2, dtype=np.int32)
me.edges.foreach_get("vertices", ev)
ev = ev.reshape(-1, 2)
order = np.concatenate([ev[:, 0], ev[:, 1]])
nbr = np.concatenate([ev[:, 1], ev[:, 0]])
srt = np.argsort(order, kind="stable")
o_s, n_s = order[srt], nbr[srt]
ptr = np.searchsorted(o_s, np.arange(n_v + 1))
cnt = np.maximum(np.diff(ptr), 1)
def nbr_mean(X):
acc = np.add.reduceat(X[n_s], ptr[:-1], axis=0)
empty = np.diff(ptr) == 0
acc[empty] = X[empty]
return acc / cnt[:, None]
N = nrm.copy()
for _ in range(5):
N = nbr_mean(N)
N /= np.maximum(np.linalg.norm(N, axis=1, keepdims=True), 1e-12)
ang = np.degrees(np.arccos(np.clip((nrm * N).sum(axis=1), -1, 1)))
# signed offset from the locally-smooth surface: negative = groove, positive = ridge
sm = co.copy()
for _ in range(12):
sm = nbr_mean(sm)
dev = ((co - sm) * N).sum(axis=1) # metres, along the smooth normal
torso = (co[:, 2] > 0.28) & (co[:, 2] < 0.90)
for thr in (8.0, 12.0, 20.0):
k = torso & (ang > thr)
print(f"KINK >{thr:4.1f}deg : {k.sum():6d} verts "
f"(groove depth p05={np.percentile(dev[k],5)*1000:+.3f} mm, "
f"median={np.median(dev[k])*1000:+.3f} mm, "
f"p95={np.percentile(dev[k],95)*1000:+.3f} mm)" if k.sum() else f"KINK >{thr}: none")
kink = torso & (ang > 12.0)
if kink.sum():
hist, edges = np.histogram(co[kink, 2], bins=16)
print("KINK z histogram (the lines):")
for c, lo, hi in zip(hist, edges[:-1], edges[1:]):
if c > 20:
print(f" z {lo:.3f}-{hi:.3f}: {c:5d}")
# are kink verts topologically split? count how many sit on a boundary or have a
# near-duplicate vertex that is NOT an edge-neighbour (= two panels touching, unmerged)
from mathutils import Vector
from mathutils.kdtree import KDTree
kidx = np.nonzero(kink)[0]
sample = kidx[::max(1, len(kidx) // 4000)]
kd = KDTree(n_v)
for i in range(n_v):
kd.insert(Vector(co[i]), i)
kd.balance()
nbrs = [set() for _ in range(0)]
adj = {}
for a, b in ev:
adj.setdefault(a, set()).add(b)
adj.setdefault(b, set()).add(a)
split = 0
for i in sample:
for (_, j, d) in kd.find_range(Vector(co[i]), 0.0008):
if j != i and j not in adj.get(i, ()):
split += 1
break
print(f"SPLIT-PANEL test on {len(sample)} kink verts: {split} "
f"({100.0*split/max(len(sample),1):.1f}%) have an unmerged twin within 0.8 mm")
# =============================================================================
# 3. CLEAVAGE — concavity of the medial corridor
# =============================================================================
print("\n=== CLEAVAGE: medial corridor cross-sections y(x) ===")
front = co[:, 1] < 0
for z0 in np.arange(0.62, 0.745, 0.015):
row = []
for x0 in np.arange(-0.05, 0.0501, 0.005):
m = front & (np.abs(co[:, 0] - x0) < 0.0035) & (np.abs(co[:, 2] - z0) < 0.004)
row.append(co[m, 1].min() if m.sum() else np.nan)
row = np.array(row)
if np.isnan(row).all():
continue
# curvature of the y(x) profile at the sternum: second difference over 5 mm steps
mid = len(row) // 2
seg = row[max(0, mid - 3):mid + 4]
if len(seg) >= 3 and not np.isnan(seg).any():
d2 = (seg[:-2] - 2 * seg[1:-1] + seg[2:]) / (0.005 ** 2)
kmax = np.nanmax(d2)
rad = 1.0 / kmax if kmax > 1e-6 else float('inf')
print(f" z={z0:.3f} sternum y={row[mid]:+.4f} "
f"max concave curvature {kmax:8.1f} 1/m -> fillet radius "
f"{rad*1000:6.1f} mm" + (" <-- SHARP" if rad < 0.012 else ""))
print("\n=== CLEAVAGE: depth of the notch (breast apex y vs sternum y) ===")
for z0 in np.arange(0.62, 0.745, 0.015):
ms = front & (np.abs(co[:, 0]) < 0.004) & (np.abs(co[:, 2] - z0) < 0.004)
ma = front & (np.abs(np.abs(co[:, 0]) - 0.034) < 0.005) & (np.abs(co[:, 2] - z0) < 0.004)
if ms.sum() and ma.sum():
print(f" z={z0:.3f} sternum {co[ms,1].min():+.4f} apex {co[ma,1].min():+.4f} "
f"notch {(co[ms,1].min()-co[ma,1].min())*1000:+6.1f} mm")
# =============================================================================
# 2. DISCOLOURATION — repainted texels vs surrounding skin
# =============================================================================
def grab_images():
out = {}
for i in bpy.data.images:
nm = i.name.lower()
if "basecolor" in nm:
out["base"] = i
return out
def px(img):
w, h = img.size
b = np.empty(w * h * 4, dtype=np.float32)
img.pixels.foreach_get(b)
return b.reshape(h, w, 4)[:, :, :3].astype(np.float32), w, h
cur = grab_images()
if "base" not in cur:
print("\nDISCOLOUR: no basecolor image found; skipping")
else:
A, w, h = px(cur["base"])
log(f"current basecolor {w}x{h} '{cur['base'].name}'")
np.save(os.path.join(SCRATCH, "cur_base.npy"), A)
if ORIG and os.path.exists(ORIG):
bpy.ops.wm.open_mainfile(filepath=ORIG)
og = grab_images()
if "base" in og:
B, w2, h2 = px(og["base"])
log(f"original basecolor {w2}x{h2} '{og['base'].name}'")
if (w2, h2) == (w, h):
d = np.abs(A - B).max(axis=2)
mask = d > 0.02
print(f"\nDISCOLOUR: repainted texels = {int(mask.sum())} "
f"({100.0*mask.sum()/(w*h):.2f}% of atlas)")
def dil(m, k):
g = m.copy()
for _ in range(k):
n = g.copy()
n[1:, :] |= g[:-1, :]
n[:-1, :] |= g[1:, :]
n[:, 1:] |= g[:, :-1]
n[:, :-1] |= g[:, 1:]
g = n
return g
inner = mask & ~dil(~mask, 6) # 6 px in from the patch edge
ring = dil(mask, 10) & ~dil(mask, 2) # untouched skin just outside
if inner.any() and ring.any():
mi = A[inner].mean(axis=0)
mr = A[ring].mean(axis=0)
print(f" patch interior mean RGB {mi[0]:.4f} {mi[1]:.4f} {mi[2]:.4f}")
print(f" outside ring mean RGB {mr[0]:.4f} {mr[1]:.4f} {mr[2]:.4f}")
print(f" OFFSET (patch-ring) {mi[0]-mr[0]:+.4f} {mi[1]-mr[1]:+.4f} "
f"{mi[2]-mr[2]:+.4f} (luma {(mi.mean()-mr.mean()):+.4f})")
print(f" patch interior stddev {A[inner].std(axis=0)}")
print(f" ring stddev {A[ring].std(axis=0)}")
# per-region: split the mask into connected blobs and report the big ones
lab = np.zeros(mask.shape, dtype=np.int32)
cur_l = 0
ys, xs = np.nonzero(mask)
seen = np.zeros(mask.shape, dtype=bool)
from collections import deque
blobs = []
for y0, x0 in zip(ys, xs):
if seen[y0, x0]:
continue
cur_l += 1
q = deque([(y0, x0)])
seen[y0, x0] = True
cells = []
while q:
y, x = q.popleft()
cells.append((y, x))
for dy, dx in ((1, 0), (-1, 0), (0, 1), (0, -1)):
yy, xx = y + dy, x + dx
if 0 <= yy < h and 0 <= xx < w and mask[yy, xx] and not seen[yy, xx]:
seen[yy, xx] = True
q.append((yy, xx))
if len(cells) > 2000:
blobs.append(cells)
print(f" {len(blobs)} patch blobs >2000 texels")
for bi, cells in enumerate(sorted(blobs, key=len, reverse=True)[:8]):
cy = np.array([c[0] for c in cells])
cx = np.array([c[1] for c in cells])
bm_ = np.zeros(mask.shape, dtype=bool)
bm_[cy, cx] = True
bin_ = bm_ & ~dil(~bm_, 5)
br_ = dil(bm_, 10) & ~dil(bm_, 2) & ~mask
if bin_.any() and br_.any():
a_ = A[bin_].mean(axis=0)
r_ = A[br_].mean(axis=0)
print(f" blob{bi}: {len(cells):7d} px uv~({cx.mean()/w:.3f},"
f"{cy.mean()/h:.3f}) offset {a_[0]-r_[0]:+.4f} "
f"{a_[1]-r_[1]:+.4f} {a_[2]-r_[2]:+.4f} luma "
f"{a_.mean()-r_.mean():+.4f}")
np.save(os.path.join(SCRATCH, "patch_mask.npy"), mask)
log(f"saved patch_mask.npy ({int(mask.sum())} texels)")
else:
print(f"DISCOLOUR: size mismatch {w}x{h} vs {w2}x{h2}")
print("DIAG_DONE")
@@ -0,0 +1,45 @@
# Which image datablocks exist, and which ones the body material actually SAMPLES.
# blender --background --python 16b_images.py -- <blend> [<blend2> ...]
import bpy, sys
import numpy as np
for BLEND in sys.argv[sys.argv.index("--") + 1:]:
bpy.ops.wm.open_mainfile(filepath=BLEND)
print(f"\n===== {BLEND} =====")
ob = max([o for o in bpy.data.objects if o.type == 'MESH'],
key=lambda o: len(o.data.vertices))
print(f"body '{ob.name}' {len(ob.data.vertices)}v materials="
f"{[ms.material.name if ms.material else None for ms in ob.material_slots]}")
for i in bpy.data.images:
b = np.empty(i.size[0] * i.size[1] * 4, dtype=np.float32)
try:
i.pixels.foreach_get(b)
m = b.reshape(-1, 4)[:, :3]
stat = f"mean {m.mean(axis=0).round(4)} std {m.std(axis=0).round(4)}"
except Exception as e:
stat = f"(no pixels: {e})"
print(f" IMG '{i.name}' {i.size[0]}x{i.size[1]} packed={bool(i.packed_file)} "
f"file='{i.filepath}' {stat}")
for ms in ob.material_slots:
mat = ms.material
if not mat or not mat.node_tree:
continue
print(f" MAT '{mat.name}':")
bsdf = next((n for n in mat.node_tree.nodes if n.type == 'BSDF_PRINCIPLED'), None)
for n in mat.node_tree.nodes:
if n.type == 'TEX_IMAGE':
tgt = []
for o in n.outputs:
for lk in o.links:
tgt.append(f"{lk.to_node.name}.{lk.to_socket.name}")
print(f" TEX_IMAGE node '{n.name}' image='{n.image.name if n.image else None}'"
f" -> {tgt if tgt else 'UNCONNECTED'}")
if bsdf:
for sock in ("Base Color", "Normal", "Roughness", "Metallic"):
if sock in bsdf.inputs:
lk = bsdf.inputs[sock].links
src = lk[0].from_node.name if lk else "(unlinked)"
if lk and lk[0].from_node.type == 'TEX_IMAGE':
src += f" image='{lk[0].from_node.image.name if lk[0].from_node.image else None}'"
print(f" BSDF.{sock} <- {src}")
print("IMAGES_DONE")
@@ -0,0 +1,214 @@
# Stage 16c (diagnosis only): settle whether the visible lines are TOPOLOGY or GEOMETRY, and
# inventory the real holes. Stage 15 asserted "the two sides of every seam are disconnected
# vertex runs"; stage 17's weld attempt on that premise tore the mesh (830 -> 24k boundary
# edges), which is itself evidence the premise is wrong.
#
# blender --background --python 16c_topology.py -- <blend>
#
# Tests
# 1. LOCAL EDGE LENGTH — the scale everything else must be judged against. A "twin at 0.8 mm"
# means nothing until you know the mesh spacing is ~1.2 mm; at that scale a non-adjacent
# vertex 0.8 mm away is just a 2-ring neighbour, not a crack. This is the control the earlier
# split-panel test lacked.
# 2. TRUE TWIN TEST — nearest vertex that is outside the 3-ring topological neighbourhood,
# normalised by local edge length. A real crack gives a spike at ratio << 1; ordinary mesh
# gives a distribution centred near/above 1.
# 3. HOLE INVENTORY — boundary edges grouped into loops, with size and location, so filling can
# be per-loop instead of one net (which is what produced 39k non-manifold edges).
# 4. RELIEF SCALE-SPACE — for the kink verts, small-scale vs large-scale normal offset. A scan
# seam is thin (small-scale relief, no large-scale relief); real anatomy (underbust fold,
# gluteal fold, clavicle) has both. This is the discriminator a heal mask must use so it
# erases seams without erasing her.
import bpy, bmesh, sys, time
import numpy as np
from mathutils import Vector
from mathutils.kdtree import KDTree
from collections import deque
argv = sys.argv[sys.argv.index("--") + 1:]
BLEND = argv[0]
t0 = time.time()
UNIT_MM = 1815.0 # 1 mesh unit = 1.815 m (body is 0.979 units for 1.777 m)
def log(m):
print(f"[topo {time.time()-t0:6.1f}s] {m}", flush=True)
bpy.ops.wm.open_mainfile(filepath=BLEND)
ob = max([o for o in bpy.data.objects if o.type == 'MESH'],
key=lambda o: len(o.data.vertices))
me = ob.data
n_v = len(me.vertices)
co = np.empty(n_v * 3)
me.vertices.foreach_get("co", co)
co = co.reshape(-1, 3)
nrm = np.empty(n_v * 3)
me.vertices.foreach_get("normal", nrm)
nrm = nrm.reshape(-1, 3)
ev = np.empty(len(me.edges) * 2, dtype=np.int32)
me.edges.foreach_get("vertices", ev)
ev = ev.reshape(-1, 2)
log(f"{n_v}v {len(me.polygons)}f {len(me.edges)}e")
# ---- 1. local edge length ----
elen = np.linalg.norm(co[ev[:, 0]] - co[ev[:, 1]], axis=1)
acc = np.zeros(n_v)
cntv = np.zeros(n_v)
np.add.at(acc, ev[:, 0], elen)
np.add.at(acc, ev[:, 1], elen)
np.add.at(cntv, ev[:, 0], 1.0)
np.add.at(cntv, ev[:, 1], 1.0)
L = acc / np.maximum(cntv, 1)
print(f"EDGE LENGTH mesh units: mean {elen.mean():.6f} ({elen.mean()*UNIT_MM:.2f} real mm) "
f"p05 {np.percentile(elen,5):.6f} p95 {np.percentile(elen,95):.6f}")
order = np.concatenate([ev[:, 0], ev[:, 1]])
nbr = np.concatenate([ev[:, 1], ev[:, 0]])
srt = np.argsort(order, kind="stable")
o_s, n_s = order[srt], nbr[srt]
ptr = np.searchsorted(o_s, np.arange(n_v + 1))
cnt = np.maximum(np.diff(ptr), 1)
def nbr_mean(X):
a = np.add.reduceat(X[n_s], ptr[:-1], axis=0)
a[np.diff(ptr) == 0] = X[np.diff(ptr) == 0]
return a / cnt[:, None]
N = nrm.copy()
for _ in range(5):
N = nbr_mean(N)
N /= np.maximum(np.linalg.norm(N, axis=1, keepdims=True), 1e-12)
ang = np.degrees(np.arccos(np.clip((nrm * N).sum(axis=1), -1, 1)))
torso = (co[:, 2] > 0.28) & (co[:, 2] < 0.90)
kink = torso & (ang > 12.0)
log(f"kink>12deg in torso: {kink.sum()}")
# ---- 2. true twin test (outside the 3-ring) ----
kidx = np.nonzero(kink)[0]
sample = kidx[::max(1, len(kidx) // 3000)]
ctrl = np.nonzero(torso & (ang < 3.0))[0]
ctrl = ctrl[::max(1, len(ctrl) // 3000)] # control: quiet skin, same test
kd = KDTree(n_v)
for i in range(n_v):
kd.insert(Vector(co[i]), i)
kd.balance()
adj = {}
for a, b in ev:
adj.setdefault(int(a), set()).add(int(b))
adj.setdefault(int(b), set()).add(int(a))
def ring3(i):
seen = {i}
frontier = {i}
for _ in range(3):
nxt = set()
for v in frontier:
nxt |= adj.get(v, set())
nxt -= seen
seen |= nxt
frontier = nxt
return seen
def twin_ratio(idxs):
out = []
for i in idxs:
excl = ring3(int(i))
best = None
for (_, j, d) in kd.find_range(Vector(co[i]), L[i] * 2.0):
if int(j) in excl:
continue
if float(nrm[i] @ nrm[j]) < 0.0:
continue # opposite-facing surface (thigh vs thigh) is not a seam twin
if best is None or d < best:
best = d
out.append(best / L[i] if best is not None else np.nan)
return np.array(out)
tr_k = twin_ratio(sample)
tr_c = twin_ratio(ctrl)
log("twin test done")
for nm, tr in (("KINK verts", tr_k), ("CONTROL quiet skin", tr_c)):
v = tr[~np.isnan(tr)]
print(f"TWIN-RATIO {nm}: n={len(v)}/{len(tr)} "
f"p05={np.percentile(v,5):.2f} p25={np.percentile(v,25):.2f} "
f"median={np.median(v):.2f} p75={np.percentile(v,75):.2f}"
if len(v) else f"TWIN-RATIO {nm}: no hits")
if len(v):
print(f" fraction with a non-3-ring vertex closer than 0.35x edge length: "
f"{100.0*(v<0.35).mean():.1f}% (<0.6x: {100.0*(v<0.6).mean():.1f}%)")
# ---- 3. hole inventory ----
bm = bmesh.new()
bm.from_mesh(me)
open_e = [e for e in bm.edges if len(e.link_faces) == 1]
print(f"\nHOLES: {len(open_e)} boundary edges, "
f"{len([e for e in bm.edges if len(e.link_faces) > 2])} non-manifold edges")
eset = set(e.index for e in open_e)
emap = {e.index: e for e in open_e}
seen = set()
loops = []
for e in open_e:
if e.index in seen:
continue
q = deque([e.index])
seen.add(e.index)
comp = []
while q:
ei = q.popleft()
cur = emap[ei]
comp.append(cur)
for v in cur.verts:
for e2 in v.link_edges:
if e2.index in eset and e2.index not in seen:
seen.add(e2.index)
q.append(e2.index)
loops.append(comp)
loops.sort(key=len, reverse=True)
print(f"HOLES: {len(loops)} separate boundary loops")
for i, lp in enumerate(loops[:14]):
zs = [v.co.z for e in lp for v in e.verts]
xs = [v.co.x for e in lp for v in e.verts]
ys = [v.co.y for e in lp for v in e.verts]
print(f" loop{i:2d}: {len(lp):5d} edges z {min(zs):.3f}-{max(zs):.3f} "
f"x {min(xs):+.3f}..{max(xs):+.3f} y {min(ys):+.3f}..{max(ys):+.3f}")
small = [lp for lp in loops if len(lp) <= 60]
print(f"HOLES: {len(small)} loops <=60 edges (safe per-loop fills), "
f"{len(loops)-len(small)} larger")
bm.free()
# ---- 4. relief scale-space ----
def smooth_n(X, k):
Y = X.copy()
for _ in range(k):
Y = nbr_mean(Y)
return Y
sm_s = smooth_n(co, 8)
sm_l = smooth_n(co, 60)
dev_s = ((co - sm_s) * N).sum(axis=1)
dev_l = ((co - sm_l) * N).sum(axis=1)
print("\nRELIEF SCALE-SPACE (real mm along the smooth normal)")
for nm, m in (("kink>12deg", kink),
("quiet skin", torso & (ang < 3.0))):
if not m.sum():
continue
print(f" {nm}: |small-scale| median {np.median(np.abs(dev_s[m]))*UNIT_MM:.3f} mm, "
f"p95 {np.percentile(np.abs(dev_s[m]),95)*UNIT_MM:.3f} mm | "
f"|large-scale| median {np.median(np.abs(dev_l[m]))*UNIT_MM:.3f} mm, "
f"p95 {np.percentile(np.abs(dev_l[m]),95)*UNIT_MM:.3f} mm")
band = np.abs(dev_s) * UNIT_MM
bandpass = torso & (band > 0.35) & (np.abs(dev_l) * UNIT_MM < 1.6)
print(f" BAND-PASS candidate mask (thin relief >0.35 mm, broad relief <1.6 mm): "
f"{int(bandpass.sum())} verts")
hist, edges = np.histogram(co[bandpass, 2], bins=14)
for c, lo, hi in zip(hist, edges[:-1], edges[1:]):
if c > 50:
print(f" z {lo:.3f}-{hi:.3f}: {c:6d}")
print("TOPO_DONE")
@@ -0,0 +1,291 @@
# Stage 17: heal the cut/scan lines — pinhole fill + thin-relief membrane. NO topology weld.
#
# blender --background --python 17_line_heal.py -- <in.blend> <out.blend> [rounds]
#
# WHAT THE MEASUREMENTS ACTUALLY SAY (16c_topology.py, and they contradict stages 15 + 17-weld)
# * The lines are NOT cracks. For kink verts the nearest vertex outside the 3-ring sits at
# 1.58x the local edge length (median); only 3.2% are closer than 0.35x, against 0.1% for
# control skin. There is no disconnected-panel network to weld. Welding on that false premise
# (eps 1.2 mm ~= the 1.85 mm mean edge length) merged ordinary neighbours and tore the mesh:
# 830 -> 24k boundary edges. Do not try it again.
# * The mesh is nearly closed: 830 boundary edges in 689 loops of 3-5 edges each — scattered
# PINHOLES, which is what the dotted black dashes in clay renders are. There is no sternum
# hole; that gash is a sharp crease (see 18_cleavage.py), not an opening.
# * The lines are thin GEOMETRIC relief: along kink verts |offset from the locally smooth
# surface| is 0.22 mm median / 1.55 mm p95, versus 0.05 mm on quiet skin.
#
# So: fill the pinholes per-loop (never as one edge net — one net is what produced 39k
# non-manifold edges), then remove the thin relief with a collar-fixed bi-harmonic membrane.
#
# WHY A MEMBRANE AND NOT SMOOTHING. Taubin (stage 11) sheds high frequencies but PRESERVES low
# ones, and a 1.5 mm ridge riding on a curved torso is not purely high-frequency — that is why
# 30 Taubin pairs left the lines legible. A bi-harmonic membrane with two fixed collar rings
# matches position AND slope at the band edge, so the broad shape (underbust fold, hip curve,
# clavicle) is reproduced exactly while the thin ridge inside is replaced by the interpolant.
#
# WHY THE MASK IS SELF-DETECTED. Stages 11/14/15 all chose their masks by mapping the raw glb's
# boundary verts onto the working mesh; by then the sculpt had moved those verts past the
# tolerance, so most of the line network was never in the mask — the membranes were solving over
# the wrong verts, which is the real reason "the lines survived every pass". Detection here runs
# on the CURRENT geometry: very-small-scale relief (4-ring) AND a shading kink. Broad anatomy has
# low relief at that scale by construction, so it is excluded automatically rather than by hand.
import bpy, bmesh, sys, time
import numpy as np
argv = sys.argv[sys.argv.index("--") + 1:]
BLEND, OUT = argv[0], argv[1]
ROUNDS = int(argv[2]) if len(argv) > 2 else 3
t0 = time.time()
UNIT_MM = 1815.0 # 1 mesh unit = 1.815 m
DEV_MM = 0.20 # thin-relief threshold, real mm
KINK_DEG = 8.0
GROW = 2 # band half-width, rings
COLLAR = 2 # fixed rings outside the band (position + slope BC)
Z_LO, Z_HI = 0.04, 0.90 # below the chin: the face keeps its own detail
X_MAX = 0.36 # exclude hands/wrists
PIN_MAX_EDGES = 8 # a "pinhole" — bigger openings are left for a human to look at
CLAMP_MM = 2.5 # cap per-vertex displacement, real mm
# WIDTH IS SETTLED — DO NOT WIDEN THE BAND. 19_wide_heal.py measured the seam cross-section: the
# disturbance is one vertex wide (|offset| 0.23 mm median at the centre, already at the 0.074 mm
# background level by ring 2). Trials at GROW=4 and GROW=8 were no better than this narrow band
# (kink>12deg 6674 and 6751 vs 6994) while moving material up to 38 mm. Narrow is correct.
#
# CLAMP_MM exists because the solver is free to do something dramatic wherever a band happens to
# span a real feature rather than a seam — the unclamped run made a 17 mm excursion. Seam relief
# tops out around 1.5 mm, so 2.5 mm is generous for the defect and still forbids reshaping her.
def log(m):
print(f"[line {time.time()-t0:6.1f}s] {m}", flush=True)
bpy.ops.wm.open_mainfile(filepath=BLEND)
ob = max([o for o in bpy.data.objects if o.type == 'MESH'],
key=lambda o: len(o.data.vertices))
me = ob.data
log(f"in: {len(me.vertices)}v {len(me.polygons)}f")
# =============================================================================
# 1. pinhole fill — per loop
# =============================================================================
bm = bmesh.new()
bm.from_mesh(me)
open_e = [e for e in bm.edges if len(e.link_faces) == 1]
eset = {e.index for e in open_e}
emap = {e.index: e for e in open_e}
from collections import deque
seen = set()
loops = []
for e in open_e:
if e.index in seen:
continue
q = deque([e.index])
seen.add(e.index)
comp = []
while q:
cur = emap[q.popleft()]
comp.append(cur)
for v in cur.verts:
for e2 in v.link_edges:
if e2.index in eset and e2.index not in seen:
seen.add(e2.index)
q.append(e2.index)
loops.append(comp)
sizes = sorted((len(lp) for lp in loops), reverse=True)
log(f"boundary loops: {len(loops)} (edges: {len(open_e)}, largest {sizes[:6]})")
bm.free()
# bmesh.ops.holes_fill silently refused every one of these loops (830 -> 839 boundary edges, no
# new faces), so use the edit-mode operator, which handles the small non-manifold fans these
# pinholes actually are.
bpy.context.view_layer.objects.active = ob
ob.select_set(True)
bpy.ops.object.mode_set(mode='EDIT')
bpy.ops.mesh.select_all(action='DESELECT')
bpy.ops.mesh.select_mode(type='EDGE')
bpy.ops.mesh.select_non_manifold(extend=False, use_boundary=True, use_wire=True,
use_multi_face=False, use_non_contiguous=False, use_verts=False)
bpy.ops.mesh.fill_holes(sides=PIN_MAX_EDGES)
bpy.ops.object.mode_set(mode='OBJECT')
me.update()
bm = bmesh.new()
bm.from_mesh(me)
n_open_after = len([e for e in bm.edges if len(e.link_faces) == 1])
n_nm_after = len([e for e in bm.edges if len(e.link_faces) > 2])
dgn = [f for f in bm.faces if f.calc_area() < 1e-12]
if dgn:
bmesh.ops.delete(bm, geom=dgn, context='FACES')
bm.to_mesh(me)
me.update()
log(f"deleted {len(dgn)} degenerate faces")
bm.free()
log(f"after fill: {len(me.vertices)}v {len(me.polygons)}f "
f"boundary_edges={n_open_after} nonmanifold={n_nm_after}")
# =============================================================================
# 2. thin-relief membrane, re-detected each round
# =============================================================================
n_v = len(me.vertices)
co = np.empty(n_v * 3)
me.vertices.foreach_get("co", co)
co = co.reshape(-1, 3)
ev = np.empty(len(me.edges) * 2, dtype=np.int32)
me.edges.foreach_get("vertices", ev)
ev = ev.reshape(-1, 2)
order = np.concatenate([ev[:, 0], ev[:, 1]])
nbr = np.concatenate([ev[:, 1], ev[:, 0]])
srt = np.argsort(order, kind="stable")
o_s, n_s = order[srt], nbr[srt]
ptr = np.searchsorted(o_s, np.arange(n_v + 1))
cnt = np.maximum(np.diff(ptr), 1)
empty = np.diff(ptr) == 0
def nbr_mean(X):
a = np.add.reduceat(X[n_s], ptr[:-1], axis=0)
a[empty] = X[empty]
return a / cnt[:, None]
def smooth_n(X, k):
Y = X.copy()
for _ in range(k):
Y = nbr_mean(Y)
return Y
def grow(mask, rings):
m = mask.copy()
for _ in range(rings):
hit = m[ev[:, 0]] | m[ev[:, 1]]
m2 = m.copy()
m2[ev[:, 0]] |= hit
m2[ev[:, 1]] |= hit
m = m2
return m
zone = (co[:, 2] > Z_LO) & (co[:, 2] < Z_HI) & (np.abs(co[:, 0]) < X_MAX)
navel = (np.abs(co[:, 0]) < 0.022) & (co[:, 2] > 0.495) & (co[:, 2] < 0.555) & (co[:, 1] < 0)
protect = navel
log(f"zone {int(zone.sum())} verts, protected {int(protect.sum())}")
def detect(P):
"""Thin relief + shading kink, both measured on P."""
nrm = np.empty(n_v * 3)
me.vertices.foreach_get("normal", nrm)
nrm = nrm.reshape(-1, 3)
N = nrm.copy()
for _ in range(5):
N = nbr_mean(N)
N /= np.maximum(np.linalg.norm(N, axis=1, keepdims=True), 1e-12)
ang = np.degrees(np.arccos(np.clip((nrm * N).sum(axis=1), -1, 1)))
dev = ((P - smooth_n(P, 4)) * N).sum(axis=1) * UNIT_MM # real mm, very local
m = zone & ~protect & (np.abs(dev) > DEV_MM) & (ang > KINK_DEG)
return m, ang, dev
def bilaplacian_solve(P, free_m):
"""Collar-fixed bi-harmonic membrane over free_m, solved with CG on the S-subgraph."""
collar = grow(free_m, COLLAR) & ~free_m
S = np.nonzero(free_m | collar)[0]
in_S = np.zeros(n_v, dtype=bool)
in_S[S] = True
glb = np.full(n_v, -1, dtype=np.int64)
glb[S] = np.arange(len(S))
se = ev[in_S[ev].all(axis=1)]
a_ = glb[se[:, 0]]
b_ = glb[se[:, 1]]
deg = np.zeros(len(S))
np.add.at(deg, a_, 1.0)
np.add.at(deg, b_, 1.0)
free = free_m[S]
def Ls(X):
out = deg[:, None] * X
np.add.at(out, a_, -X[b_])
np.add.at(out, b_, -X[a_])
return out
def A_op(U):
X = np.zeros((len(S), 3))
X[free] = U
return Ls(Ls(X))[free]
Xc = np.zeros((len(S), 3))
Xc[~free] = P[S[~free]]
rhs = -Ls(Ls(Xc))[free]
U = P[S[free]].copy()
r = rhs - A_op(U)
p = r.copy()
rs = (r * r).sum()
rs0 = max(rs, 1e-30)
it = 0
for it in range(200000):
Ap = A_op(p)
den = (p * Ap).sum()
if abs(den) < 1e-30:
break
al = rs / den
U += al * p
r -= al * Ap
rs2 = (r * r).sum()
if rs2 < 1e-20 or rs2 < rs0 * 1e-14:
rs = rs2
break
p = r + (rs2 / rs) * p
rs = rs2
Q = P.copy()
Q[S[free]] = U
return Q, len(S), int(free.sum()), it, rs / rs0
P = co.copy()
for rnd in range(1, ROUNDS + 1):
mask, ang, dev = detect(P)
if not mask.sum():
log(f"round {rnd}: nothing left to heal")
break
band = grow(mask, GROW) & zone & ~protect
Q, ns, nf, it, rel = bilaplacian_solve(P, band)
# clamp: a seam is <=1.5 mm of relief, so anything larger is the solver reshaping anatomy
disp = Q - P
dmag = np.linalg.norm(disp, axis=1) * UNIT_MM
over = dmag > CLAMP_MM
if over.any():
disp[over] *= (CLAMP_MM / dmag[over])[:, None]
Q = P + disp
log(f"round {rnd}: clamped {int(over.sum())} verts to {CLAMP_MM} mm "
f"(largest pre-clamp {dmag.max():.1f} mm)")
d = np.linalg.norm(Q - P, axis=1) * UNIT_MM
log(f"round {rnd}: seed {int(mask.sum())} -> band {nf} (S={ns}) CG it={it} rel={rel:.1e} "
f"moved max {d.max():.3f} mm, median(band) {np.median(d[band]):.3f} mm")
P = Q
me.vertices.foreach_set("co", P.reshape(-1))
me.update()
if me.has_custom_normals:
vn = np.empty(n_v * 3, dtype=np.float32)
me.vertices.foreach_get("normal", vn)
me.normals_split_custom_set_from_vertices(vn.reshape(-1, 3))
m2, ang2, dev2 = detect(P)
torso = (co[:, 2] > 0.28) & (co[:, 2] < 0.90)
log(f" after: seed mask {int(m2.sum())}, kink>12deg {int((torso & (ang2 > 12)).sum())}, "
f"kink>20deg {int((torso & (ang2 > 20)).sum())}")
# final normals + save
me.vertices.foreach_set("co", P.reshape(-1))
me.update()
vn = np.empty(n_v * 3, dtype=np.float32)
me.vertices.foreach_get("normal", vn)
me.normals_split_custom_set_from_vertices(vn.reshape(-1, 3))
d_tot = np.linalg.norm(P - co, axis=1) * UNIT_MM
log(f"TOTAL displacement: max {d_tot.max():.3f} mm, "
f"{int((d_tot > 0.05).sum())} verts moved >0.05 mm")
bpy.context.preferences.filepaths.save_version = 0 # no .blend1 autosave
bpy.ops.wm.save_as_mainfile(filepath=OUT)
log(f"WROTE {OUT}")
print("LINE_DONE")
@@ -0,0 +1,272 @@
# Stage 17: TRUE panel weld + hole closure, self-detected on the CURRENT mesh.
#
# blender --background --python 17_panel_weld.py -- <in.blend> <out.blend> [eps_mm_units]
#
# WHY THIS EXISTS (stage 15 already tried to weld and the lines survived)
# Stage 15 chose its merge set by mapping the RAW glb's boundary verts onto the working mesh at
# 2.5 mm. By then the sculpt + five membrane passes had moved those verts further than the
# tolerance, so most of the panel network was never selected. Measured after stage 15
# (16_diagnose): 58.7% of shading-kink verts STILL have a non-adjacent twin within 0.8 mm, i.e.
# the two sides of each seam are separate vertex runs that shade independently. A crack survives
# any amount of vertex MOVEMENT, which is why 11-14's membranes could not remove it.
#
# So this stage never consults the raw mesh. It finds the defect where it actually is:
# candidates = shading-kink verts boundary verts, grown 2 rings
# a PAIR is two candidates within eps that are NOT edge-adjacent and whose normals agree
# (dot > 0.5). The normal test is what makes this safe: two sides of one seam face the same
# way, whereas two surfaces that merely come close (inner thighs, armpit) face opposite ways
# and are never paired.
# pairs -> union-find -> bmesh.ops.weld_verts with an explicit targetmap.
# weld_verts (not remove_doubles) because the targetmap is exact: only vertices this script has
# validated get merged, so no collateral merge is possible inside the eps ball.
#
# Loops keep their own UVs through a weld, so the baked atlas is unaffected — a welded vertex
# simply carries two UV corners, which is what every UV seam already is.
#
# Then holes: the body should be watertight below the chin. The largest boundary cluster is the
# bra-bow excision at the sternum (z 0.648-0.729, 329 edges) — that hole is the black gash that
# reads as a broken cleavage. Filled with bmesh triangle_fill and smoothed by the next stage.
# Head openings (z > HEAD_Z: mouth, eyes, nostrils) are left alone: they are supposed to be open.
import bpy, bmesh, sys, time, math
import numpy as np
from mathutils import Vector
from mathutils.kdtree import KDTree
argv = sys.argv[sys.argv.index("--") + 1:]
BLEND, OUT = argv[0], argv[1]
EPS = float(argv[2]) if len(argv) > 2 else 0.0012 # mesh units (~2.2 mm real: 1 unit=1.815 m)
t0 = time.time()
KINK_DEG = 4.0 # generous: anything that could read as a line
DEV_MIN = 0.00008 # or a small-scale bump/groove this deep (mesh units)
NORM_DOT = 0.5 # same-facing test that makes the weld safe
GROW = 2
Z_LO, Z_HI = 0.04, 0.90 # below the chin, above the soles
X_MAX = 0.36 # excludes hands/wrists so fingers can never weld together
HEAD_Z = 0.90 # holes above this are real openings (mouth/eyes/nostrils)
def log(m):
print(f"[weld {time.time()-t0:6.1f}s] {m}", flush=True)
bpy.ops.wm.open_mainfile(filepath=BLEND)
ob = max([o for o in bpy.data.objects if o.type == 'MESH'],
key=lambda o: len(o.data.vertices))
me = ob.data
n_v = len(me.vertices)
log(f"in: {n_v}v {len(me.polygons)}f custom_normals={me.has_custom_normals}")
co = np.empty(n_v * 3)
me.vertices.foreach_get("co", co)
co = co.reshape(-1, 3)
nrm = np.empty(n_v * 3)
me.vertices.foreach_get("normal", nrm)
nrm = nrm.reshape(-1, 3)
ev = np.empty(len(me.edges) * 2, dtype=np.int32)
me.edges.foreach_get("vertices", ev)
ev = ev.reshape(-1, 2)
order = np.concatenate([ev[:, 0], ev[:, 1]])
nbr = np.concatenate([ev[:, 1], ev[:, 0]])
srt = np.argsort(order, kind="stable")
o_s, n_s = order[srt], nbr[srt]
ptr = np.searchsorted(o_s, np.arange(n_v + 1))
cnt = np.maximum(np.diff(ptr), 1)
def nbr_mean(X):
acc = np.add.reduceat(X[n_s], ptr[:-1], axis=0)
acc[np.diff(ptr) == 0] = X[np.diff(ptr) == 0]
return acc / cnt[:, None]
# ---- candidates: shading kinks + small-scale relief + existing boundary ----
N = nrm.copy()
for _ in range(5):
N = nbr_mean(N)
N /= np.maximum(np.linalg.norm(N, axis=1, keepdims=True), 1e-12)
ang = np.degrees(np.arccos(np.clip((nrm * N).sum(axis=1), -1, 1)))
sm = co.copy()
for _ in range(12):
sm = nbr_mean(sm)
dev = np.abs(((co - sm) * N).sum(axis=1))
bm = bmesh.new()
bm.from_mesh(me)
bm.verts.ensure_lookup_table()
bnd_v = np.zeros(n_v, dtype=bool)
for e in bm.edges:
if len(e.link_faces) == 1:
bnd_v[e.verts[0].index] = True
bnd_v[e.verts[1].index] = True
n_bnd0 = len([e for e in bm.edges if len(e.link_faces) == 1])
n_nm0 = len([e for e in bm.edges if len(e.link_faces) > 2])
log(f"before: boundary_edges={n_bnd0} nonmanifold_edges={n_nm0} boundary_verts={bnd_v.sum()}")
zone = (co[:, 2] > Z_LO) & (co[:, 2] < Z_HI) & (np.abs(co[:, 0]) < X_MAX)
cand = zone & ((ang > KINK_DEG) | (dev > DEV_MIN) | bnd_v)
for _ in range(GROW):
hit = cand[ev[:, 0]] | cand[ev[:, 1]]
c2 = cand.copy()
c2[ev[:, 0]] |= hit
c2[ev[:, 1]] |= hit
cand = c2 & zone
cidx = np.nonzero(cand)[0]
log(f"candidates: {len(cidx)} verts (kink>{KINK_DEG}deg {(zone&(ang>KINK_DEG)).sum()}, "
f"dev {(zone&(dev>DEV_MIN)).sum()}, boundary {(zone&bnd_v).sum()}, +{GROW} rings)")
# adjacency lookup restricted to candidates
adj = {}
sel_mask = cand
mE = sel_mask[ev[:, 0]] & sel_mask[ev[:, 1]]
for a, b in ev[mE]:
adj.setdefault(int(a), set()).add(int(b))
adj.setdefault(int(b), set()).add(int(a))
kd = KDTree(len(cidx))
for j, i in enumerate(cidx):
kd.insert(Vector(co[i]), j)
kd.balance()
log("KD built over candidates")
# ---- pair up twins ----
pairs = []
dists = []
for j, i in enumerate(cidx):
ai = adj.get(int(i), ())
for (_, k, d) in kd.find_range(Vector(co[i]), EPS):
o = int(cidx[k])
if o <= int(i) or o in ai:
continue
if float(nrm[i] @ nrm[o]) < NORM_DOT:
continue
pairs.append((int(i), o))
dists.append(d)
log(f"pairs: {len(pairs)}")
if dists:
dh = np.array(dists)
print("PAIR-DISTANCE histogram (mesh units, 1 unit = 1.815 m):")
hist, edges = np.histogram(dh, bins=np.linspace(0, EPS, 9))
for c, lo, hi in zip(hist, edges[:-1], edges[1:]):
print(f" {lo*1000:5.3f}-{hi*1000:5.3f} mm-units: {c:7d} "
f"({lo*1815:5.2f}-{hi*1815:5.2f} real mm)")
# ---- union-find ----
parent = {}
def find(x):
parent.setdefault(x, x)
while parent[x] != x:
parent[x] = parent[parent[x]]
x = parent[x]
return x
def union(a, b):
ra, rb = find(a), find(b)
if ra != rb:
parent[min(ra, rb)] = min(ra, rb)
parent[max(ra, rb)] = min(ra, rb)
for a, b in pairs:
union(a, b)
clusters = {}
for v in list(parent):
clusters.setdefault(find(v), []).append(v)
sizes = np.array([len(c) for c in clusters.values()])
log(f"clusters: {len(clusters)} covering {int(sizes.sum())} verts "
f"(max {sizes.max() if len(sizes) else 0}, mean {sizes.mean() if len(sizes) else 0:.2f})")
# guard: a huge cluster would mean the eps ball is chaining across a whole region
if len(sizes) and sizes.max() > 40:
log(f"WARNING: largest cluster {sizes.max()} verts — chaining suspected; "
f"clusters >40 verts are SKIPPED")
targetmap = {}
merged_verts = 0
for root, members in clusters.items():
if len(members) < 2 or len(members) > 40:
continue
members = sorted(members)
keep = members[0]
ctr = co[members].mean(axis=0)
bm.verts[keep].co = Vector(ctr)
for m in members[1:]:
targetmap[bm.verts[m]] = bm.verts[keep]
merged_verts += 1
log(f"targetmap: merging {merged_verts} verts into {len(set(targetmap.values()))} survivors")
if targetmap:
bmesh.ops.weld_verts(bm, targetmap=targetmap)
log("weld_verts done")
# ---- close holes below the chin ----
bm.edges.ensure_lookup_table()
open_e = [e for e in bm.edges if len(e.link_faces) == 1]
body_e = [e for e in open_e
if max(v.co.z for v in e.verts) < HEAD_Z and min(v.co.z for v in e.verts) > Z_LO * 0.5]
log(f"open edges after weld: {len(open_e)} total, {len(body_e)} below the chin -> filling")
if body_e:
res = bmesh.ops.triangle_fill(bm, use_beauty=True, use_dissolve=False, edges=body_e)
log(f"triangle_fill created {len(res.get('geom', []))} elements")
# anything still open: try holes_fill as a second pass
bm.edges.ensure_lookup_table()
still = [e for e in bm.edges if len(e.link_faces) == 1
and max(v.co.z for v in e.verts) < HEAD_Z]
if still:
bmesh.ops.holes_fill(bm, edges=still, sides=0)
log(f"holes_fill on {len(still)} remaining open edges")
bm.edges.ensure_lookup_table()
n_bnd1 = len([e for e in bm.edges if len(e.link_faces) == 1])
n_nm1 = len([e for e in bm.edges if len(e.link_faces) > 2])
dgn = [f for f in bm.faces if f.calc_area() < 1e-12]
if dgn:
bmesh.ops.delete(bm, geom=dgn, context='FACES')
log(f"deleted {len(dgn)} degenerate faces")
bm.to_mesh(me)
bm.free()
me.update()
n_after = len(me.vertices)
log(f"after: {n_after}v (-{n_v - n_after}) boundary_edges={n_bnd1} nonmanifold_edges={n_nm1}")
# ---- shared vertex normals across the now-shared seams ----
vn = np.empty(n_after * 3, dtype=np.float32)
me.vertices.foreach_get("normal", vn)
me.normals_split_custom_set_from_vertices(vn.reshape(-1, 3))
log("custom split normals reset from vertex normals")
# ---- re-measure the lines ----
co2 = np.empty(n_after * 3)
me.vertices.foreach_get("co", co2)
co2 = co2.reshape(-1, 3)
nr2 = np.empty(n_after * 3)
me.vertices.foreach_get("normal", nr2)
nr2 = nr2.reshape(-1, 3)
ev2 = np.empty(len(me.edges) * 2, dtype=np.int32)
me.edges.foreach_get("vertices", ev2)
ev2 = ev2.reshape(-1, 2)
o2 = np.concatenate([ev2[:, 0], ev2[:, 1]])
n2 = np.concatenate([ev2[:, 1], ev2[:, 0]])
s2 = np.argsort(o2, kind="stable")
o2s, n2s = o2[s2], n2[s2]
p2 = np.searchsorted(o2s, np.arange(n_after + 1))
c2 = np.maximum(np.diff(p2), 1)
N2 = nr2.copy()
for _ in range(5):
acc = np.add.reduceat(N2[n2s], p2[:-1], axis=0)
acc[np.diff(p2) == 0] = N2[np.diff(p2) == 0]
N2 = acc / c2[:, None]
N2 /= np.maximum(np.linalg.norm(N2, axis=1, keepdims=True), 1e-12)
ang2 = np.degrees(np.arccos(np.clip((nr2 * N2).sum(axis=1), -1, 1)))
torso2 = (co2[:, 2] > 0.28) & (co2[:, 2] < 0.90)
for thr in (8.0, 12.0, 20.0):
print(f"KINK >{thr:4.1f}deg after weld: {int((torso2 & (ang2 > thr)).sum()):6d} verts")
bpy.context.preferences.filepaths.save_version = 0 # no .blend1 autosave
bpy.ops.wm.save_as_mainfile(filepath=OUT)
log(f"WROTE {OUT}")
print("WELD_DONE")
@@ -0,0 +1,152 @@
# Stage 18: is the line network SHADING (custom split normals) rather than shape?
#
# blender --background --python 18_normal_probe.py -- <in.blend> <out.blend> <review_dir>
#
# THE SUSPICION. Every stage from 11 to 17 moved vertices along the seam network and the lines
# did not change; 17's membrane moved 12.6k verts for no visible difference, and 16c proved there
# is no crack to weld. What survives vertex movement is SHADING: this mesh carries custom split
# normals from the Tripo import (has_custom_normals=True). If those baked normals encode the scan
# panel borders as creases, the lines are painted into the normals and geometry work cannot touch
# them — which would explain the whole failure streak at once, including why the lines appear in
# clay renders (clay swaps the MATERIAL, so no normal map is involved, but custom split normals
# belong to the MESH and survive the swap).
#
# Note every prior stage ended with normals_split_custom_set_from_vertices(...), which SHOULD have
# smoothed them. This probe measures whether that actually took effect, then clears the custom
# normals outright and re-renders. Measurement first, then the change, so we learn which it was.
import bpy, sys, os, math, time
import numpy as np
from mathutils import Vector
argv = sys.argv[sys.argv.index("--") + 1:]
BLEND, OUT, REVIEW = argv[0], argv[1], argv[2]
os.makedirs(REVIEW, exist_ok=True)
t0 = time.time()
def log(m):
print(f"[nrm {time.time()-t0:6.1f}s] {m}", flush=True)
bpy.ops.wm.open_mainfile(filepath=BLEND)
ob = max([o for o in bpy.data.objects if o.type == 'MESH'],
key=lambda o: len(o.data.vertices))
me = ob.data
n_v = len(me.vertices)
n_l = len(me.loops)
log(f"{ob.name}: {n_v}v {len(me.polygons)}f {n_l} loops "
f"has_custom_normals={me.has_custom_normals}")
flat = np.empty(len(me.polygons), dtype=bool)
me.polygons.foreach_get("use_smooth", flat)
log(f"flat-shaded polygons: {int((~flat).sum())} of {len(flat)}")
# ---- how far do the corner normals deviate from the smooth vertex normals? ----
vn = np.empty(n_v * 3)
me.vertices.foreach_get("normal", vn)
vn = vn.reshape(-1, 3)
lv = np.empty(n_l, dtype=np.int32)
me.loops.foreach_get("vertex_index", lv)
cn = np.empty(n_l * 3)
try:
me.corner_normals.foreach_get("vector", cn)
cn = cn.reshape(-1, 3)
dot = np.clip((cn * vn[lv]).sum(axis=1), -1, 1)
dev = np.degrees(np.arccos(dot))
print(f"CORNER-vs-VERTEX normal deviation: mean {dev.mean():.3f}deg "
f"p50 {np.percentile(dev,50):.3f} p95 {np.percentile(dev,95):.3f} "
f"p99.9 {np.percentile(dev,99.9):.3f} max {dev.max():.3f}")
print(f" loops deviating >5deg: {int((dev>5).sum())} ({100.0*(dev>5).mean():.3f}%)")
print(f" loops deviating >15deg: {int((dev>15).sum())} ({100.0*(dev>15).mean():.3f}%)")
# per-vertex spread between its own corner normals = a shading crease at that vertex
spread = np.zeros(n_v)
np.maximum.at(spread, lv, dev)
torso = None
co = np.empty(n_v * 3)
me.vertices.foreach_get("co", co)
co = co.reshape(-1, 3)
torso = (co[:, 2] > 0.28) & (co[:, 2] < 0.90)
print(f" torso verts whose corner normals deviate >10deg from smooth: "
f"{int((torso & (spread > 10)).sum())}")
except Exception as ex:
log(f"corner_normals read failed: {ex}")
# =============================================================================
# clear the custom split normals and force smooth shading
# =============================================================================
had = me.has_custom_normals
bpy.context.view_layer.objects.active = ob
ob.select_set(True)
cleared = False
try:
bpy.ops.mesh.customdata_custom_splitnormals_clear()
cleared = True
except Exception as ex:
log(f"operator clear failed: {ex}")
if not cleared:
for nm in ("custom_normal",):
if nm in me.attributes:
me.attributes.remove(me.attributes[nm])
cleared = True
log(f"removed attribute '{nm}'")
sm = np.ones(len(me.polygons), dtype=bool)
me.polygons.foreach_set("use_smooth", sm)
me.update()
log(f"custom normals: had={had} now has_custom_normals={me.has_custom_normals} cleared={cleared}")
# =============================================================================
# clay renders, same framing as 04_review so they compare 1:1
# =============================================================================
scn = bpy.context.scene
w = bpy.data.worlds.new("W")
w.color = (0.22, 0.22, 0.24)
scn.world = w
key = bpy.data.objects.new("Key", bpy.data.lights.new("Key", 'SUN'))
key.data.energy = 3.0
key.data.use_shadow = False
bpy.context.collection.objects.link(key)
fill = bpy.data.objects.new("Fill", bpy.data.lights.new("Fill", 'SUN'))
fill.data.energy = 1.0
fill.data.use_shadow = False
bpy.context.collection.objects.link(fill)
cam = bpy.data.objects.new("Cam", bpy.data.cameras.new("Cam"))
cam.data.lens = 85
bpy.context.collection.objects.link(cam)
scn.camera = cam
scn.render.engine = 'BLENDER_EEVEE' if bpy.app.version >= (4, 2) else 'BLENDER_EEVEE_NEXT'
scn.render.resolution_x = scn.render.resolution_y = 1000
clay = bpy.data.materials.new("Clay")
clay.use_nodes = True
clay.node_tree.nodes["Principled BSDF"].inputs["Base Color"].default_value = (0.62, 0.60, 0.58, 1)
clay.node_tree.nodes["Principled BSDF"].inputs["Roughness"].default_value = 0.45
orig = [ms.material for ms in ob.material_slots]
def shoot(tag, ctr, span, yaw_deg, use_clay):
for i, ms in enumerate(ob.material_slots):
ms.material = clay if use_clay else orig[i]
yaw = math.radians(yaw_deg)
dist = span * 3.0
cam.location = Vector(ctr) + Vector((math.sin(yaw) * dist, -math.cos(yaw) * dist, 0.02))
cam.rotation_euler = (Vector(ctr) - cam.location).to_track_quat('-Z', 'Y').to_euler()
key.rotation_euler = (math.radians(62), 0, math.radians(35 + yaw_deg))
fill.rotation_euler = (math.radians(75), 0, math.radians(yaw_deg - 110))
scn.render.filepath = os.path.abspath(os.path.join(REVIEW, f"{tag}.png"))
bpy.ops.render.render(write_still=True)
log(f"render {tag}")
CHEST = (0.0, 0.0, 0.675)
FULL = (0.0, 0.0, 0.50)
HIP = (0.0, 0.0, 0.53)
shoot("chest_clay_0", CHEST, 0.22, 0, True)
shoot("chest_clay_40", CHEST, 0.22, 40, True)
shoot("chest_tex_0", CHEST, 0.22, 0, False)
shoot("hip_clay_0", HIP, 0.22, 0, True)
shoot("full_clay_0", FULL, 0.55, 0, True)
bpy.context.preferences.filepaths.save_version = 0 # no .blend1 autosave
bpy.ops.wm.save_as_mainfile(filepath=OUT)
log(f"WROTE {OUT}")
print("NRM_DONE")
@@ -0,0 +1,259 @@
# Stage 19: measure the seam's cross-section, then trial the heal at several band widths.
#
# blender --background --python 19_wide_heal.py -- <in.blend> <review_root> [widths]
#
# WHY WIDTH IS THE WHOLE QUESTION. Established so far: the lines are not cracks (16c), not
# painted into the custom normals (18 — corner-vs-vertex deviation is 0.008 deg mean), and a
# 5-vertex-wide collar-fixed membrane moves 12.6k verts without changing the render (17).
# The remaining reading is that a Tripo panel border is a STEP — the reconstruction's two charts
# meet with a sub-millimetre offset, a C0 discontinuity — rather than a ridge sitting on smooth
# skin. A narrow band cannot fix a step, because the fixed collar lands on the step's own
# shoulders and the interpolant faithfully reproduces the offset it is pinned to. Removing a step
# means spreading it over a wide enough neighbourhood that the residual curvature falls below
# visibility.
#
# So this stage MEASURES first: |offset from a broadly smoothed surface| as a function of ring
# distance from the seam. That profile says how wide the disturbance really is, and therefore how
# wide the band must be. Then it renders the heal at several widths so the choice is made from
# pictures rather than from theory. Nothing is saved — this is an experiment; the winning width
# gets applied in the next stage.
import bpy, sys, os, math, time
import numpy as np
from mathutils import Vector
argv = sys.argv[sys.argv.index("--") + 1:]
BLEND, ROOT = argv[0], argv[1]
WIDTHS = [int(x) for x in argv[2].split(",")] if len(argv) > 2 else [4, 8, 12]
t0 = time.time()
UNIT_MM = 1815.0
KINK_DEG = 6.0
COLLAR = 3
Z_LO, Z_HI = 0.04, 0.90
X_MAX = 0.36
def log(m):
print(f"[wide {time.time()-t0:6.1f}s] {m}", flush=True)
bpy.ops.wm.open_mainfile(filepath=BLEND)
ob = max([o for o in bpy.data.objects if o.type == 'MESH'],
key=lambda o: len(o.data.vertices))
me = ob.data
n_v = len(me.vertices)
co = np.empty(n_v * 3)
me.vertices.foreach_get("co", co)
co = co.reshape(-1, 3)
ev = np.empty(len(me.edges) * 2, dtype=np.int32)
me.edges.foreach_get("vertices", ev)
ev = ev.reshape(-1, 2)
log(f"{n_v}v {len(me.polygons)}f")
order = np.concatenate([ev[:, 0], ev[:, 1]])
nbr = np.concatenate([ev[:, 1], ev[:, 0]])
srt = np.argsort(order, kind="stable")
o_s, n_s = order[srt], nbr[srt]
ptr = np.searchsorted(o_s, np.arange(n_v + 1))
cnt = np.maximum(np.diff(ptr), 1)
empty = np.diff(ptr) == 0
def nbr_mean(X):
a = np.add.reduceat(X[n_s], ptr[:-1], axis=0)
a[empty] = X[empty]
return a / cnt[:, None]
def smooth_n(X, k):
Y = X.copy()
for _ in range(k):
Y = nbr_mean(Y)
return Y
def grow(mask, rings):
m = mask.copy()
for _ in range(rings):
hit = m[ev[:, 0]] | m[ev[:, 1]]
m2 = m.copy()
m2[ev[:, 0]] |= hit
m2[ev[:, 1]] |= hit
m = m2
return m
def kink_of(P):
nrm = np.empty(n_v * 3)
me.vertices.foreach_get("normal", nrm)
nrm = nrm.reshape(-1, 3)
N = nrm.copy()
for _ in range(5):
N = nbr_mean(N)
N /= np.maximum(np.linalg.norm(N, axis=1, keepdims=True), 1e-12)
return np.degrees(np.arccos(np.clip((nrm * N).sum(axis=1), -1, 1))), N
zone = (co[:, 2] > Z_LO) & (co[:, 2] < Z_HI) & (np.abs(co[:, 0]) < X_MAX)
navel = (np.abs(co[:, 0]) < 0.022) & (co[:, 2] > 0.495) & (co[:, 2] < 0.555) & (co[:, 1] < 0)
ang, N = kink_of(co)
seed = zone & ~navel & (ang > KINK_DEG)
log(f"seed (kink>{KINK_DEG}deg): {int(seed.sum())} verts")
# =============================================================================
# cross-section profile: |offset from broad smooth| vs ring distance from seed
# =============================================================================
sm40 = smooth_n(co, 40)
sm12 = smooth_n(co, 12)
dev40 = ((co - sm40) * N).sum(axis=1) * UNIT_MM
dev12 = ((co - sm12) * N).sum(axis=1) * UNIT_MM
ring = np.full(n_v, -1, dtype=np.int32)
ring[seed] = 0
cur = seed.copy()
for r in range(1, 16):
nxt = grow(cur, 1) & ~cur & zone
ring[nxt & (ring < 0)] = r
cur = cur | nxt
print("\nSEAM CROSS-SECTION (real mm, magnitudes; ring 0 = detected seam centre)")
print(" ring n |dev12| med p90 |dev40| med p90")
for r in range(0, 15):
m = ring == r
if m.sum() < 50:
continue
print(f" {r:4d} {int(m.sum()):8d} {np.median(np.abs(dev12[m])):7.3f} "
f"{np.percentile(np.abs(dev12[m]),90):7.3f} "
f"{np.median(np.abs(dev40[m])):7.3f} {np.percentile(np.abs(dev40[m]),90):7.3f}")
far = zone & (ring < 0)
if far.sum() > 50:
print(f" far {int(far.sum()):8d} {np.median(np.abs(dev12[far])):7.3f} "
f"{np.percentile(np.abs(dev12[far]),90):7.3f} "
f"{np.median(np.abs(dev40[far])):7.3f} {np.percentile(np.abs(dev40[far]),90):7.3f}")
# =============================================================================
# solver
# =============================================================================
def bilaplacian(P, free_m, collar_rings=COLLAR, maxit=6000):
collar = grow(free_m, collar_rings) & ~free_m
S = np.nonzero(free_m | collar)[0]
in_S = np.zeros(n_v, dtype=bool)
in_S[S] = True
glb = np.full(n_v, -1, dtype=np.int64)
glb[S] = np.arange(len(S))
se = ev[in_S[ev].all(axis=1)]
a_ = glb[se[:, 0]]
b_ = glb[se[:, 1]]
deg = np.zeros(len(S))
np.add.at(deg, a_, 1.0)
np.add.at(deg, b_, 1.0)
free = free_m[S]
def Ls(X):
out = deg[:, None] * X
np.add.at(out, a_, -X[b_])
np.add.at(out, b_, -X[a_])
return out
def A_op(U):
X = np.zeros((len(S), 3))
X[free] = U
return Ls(Ls(X))[free]
Xc = np.zeros((len(S), 3))
Xc[~free] = P[S[~free]]
rhs = -Ls(Ls(Xc))[free]
U = P[S[free]].copy()
r = rhs - A_op(U)
p = r.copy()
rs = (r * r).sum()
rs0 = max(rs, 1e-30)
it = 0
for it in range(maxit):
Ap = A_op(p)
den = (p * Ap).sum()
if abs(den) < 1e-30:
break
al = rs / den
U += al * p
r -= al * Ap
rs2 = (r * r).sum()
if rs2 < 1e-20 or rs2 < rs0 * 1e-13:
rs = rs2
break
p = r + (rs2 / rs) * p
rs = rs2
Q = P.copy()
Q[S[free]] = U
return Q, int(free.sum()), it, rs / rs0
# =============================================================================
# render helper (same framing/lighting as 04_review)
# =============================================================================
scn = bpy.context.scene
wd = bpy.data.worlds.new("W")
wd.color = (0.22, 0.22, 0.24)
scn.world = wd
key = bpy.data.objects.new("Key", bpy.data.lights.new("Key", 'SUN'))
key.data.energy = 3.0
key.data.use_shadow = False
bpy.context.collection.objects.link(key)
fl = bpy.data.objects.new("Fill", bpy.data.lights.new("Fill", 'SUN'))
fl.data.energy = 1.0
fl.data.use_shadow = False
bpy.context.collection.objects.link(fl)
cam = bpy.data.objects.new("Cam", bpy.data.cameras.new("Cam"))
cam.data.lens = 85
bpy.context.collection.objects.link(cam)
scn.camera = cam
scn.render.engine = 'BLENDER_EEVEE' if bpy.app.version >= (4, 2) else 'BLENDER_EEVEE_NEXT'
scn.render.resolution_x = scn.render.resolution_y = 1000
clay = bpy.data.materials.new("Clay")
clay.use_nodes = True
clay.node_tree.nodes["Principled BSDF"].inputs["Base Color"].default_value = (0.62, 0.60, 0.58, 1)
clay.node_tree.nodes["Principled BSDF"].inputs["Roughness"].default_value = 0.45
orig = [ms.material for ms in ob.material_slots]
def shoot(outdir, tag, ctr, span, yaw_deg, use_clay=True):
os.makedirs(outdir, exist_ok=True)
for i, ms in enumerate(ob.material_slots):
ms.material = clay if use_clay else orig[i]
yaw = math.radians(yaw_deg)
dist = span * 3.0
cam.location = Vector(ctr) + Vector((math.sin(yaw) * dist, -math.cos(yaw) * dist, 0.02))
cam.rotation_euler = (Vector(ctr) - cam.location).to_track_quat('-Z', 'Y').to_euler()
key.rotation_euler = (math.radians(62), 0, math.radians(35 + yaw_deg))
fl.rotation_euler = (math.radians(75), 0, math.radians(yaw_deg - 110))
scn.render.filepath = os.path.abspath(os.path.join(outdir, f"{tag}.png"))
bpy.ops.render.render(write_still=True)
CHEST = (0.0, 0.0, 0.675)
FULL = (0.0, 0.0, 0.50)
HIP = (0.0, 0.0, 0.53)
for W in WIDTHS:
band = grow(seed, W) & zone & ~navel
Q, nf, it, rel = bilaplacian(co, band)
d = np.linalg.norm(Q - co, axis=1) * UNIT_MM
me.vertices.foreach_set("co", Q.reshape(-1))
me.update()
if me.has_custom_normals:
vn = np.empty(n_v * 3, dtype=np.float32)
me.vertices.foreach_get("normal", vn)
me.normals_split_custom_set_from_vertices(vn.reshape(-1, 3))
ang2, _ = kink_of(Q)
torso = (co[:, 2] > 0.28) & (co[:, 2] < 0.90)
log(f"W={W:2d}: band {nf} verts, CG it={it} rel={rel:.1e}, moved max {d.max():.2f} mm "
f"median(band) {np.median(d[band]):.3f} mm | kink>6 {int((torso&(ang2>6)).sum())} "
f">12 {int((torso&(ang2>12)).sum())} >20 {int((torso&(ang2>20)).sum())}")
out = os.path.join(ROOT, f"w{W:02d}")
shoot(out, "chest_clay_40", CHEST, 0.22, 40)
shoot(out, "full_clay_0", FULL, 0.55, 0)
shoot(out, "hip_clay_0", HIP, 0.22, 0)
log(f"W={W}: rendered -> {out}")
me.vertices.foreach_set("co", co.reshape(-1)) # reset for the next width
me.update()
print("WIDE_DONE")
@@ -0,0 +1,233 @@
# Stage 20 (read-only probe): characterise the CURRENT UV atlas, so "the texture looks cut up
# and pasted together" becomes a measurement instead of an impression.
#
# blender --background --python 20_atlas_probe.py -- <in.blend> <out_dir>
#
# Reports, for the mesh's active UV layer:
# - which image the material actually samples (bpy.data.images holds stale duplicates)
# - UV-vertex count vs mesh-vertex count => how much the atlas is cut apart
# - island count + size distribution => "pasted together" from how many pieces
# - per-island texel density (px per mm) => whether pieces are at inconsistent scale
# - atlas coverage + wasted area
# Writes: basecolor.png (the real one), islands.png (island map), density.png (px/mm heat),
# uvgrid.png (UV wireframe), and uv_cache.npz for later stages.
import bpy, sys, os, time
import numpy as np
argv = sys.argv[sys.argv.index("--") + 1:]
BLEND = argv[0]
OUTDIR = os.path.abspath(argv[1])
os.makedirs(OUTDIR, exist_ok=True)
t0 = time.time()
def log(m):
print(f"[atlas {time.time()-t0:6.1f}s] {m}", flush=True)
bpy.ops.wm.open_mainfile(filepath=BLEND)
ob = max([o for o in bpy.data.objects if o.type == 'MESH'], key=lambda o: len(o.data.vertices))
me = ob.data
n_v, n_l, n_f = len(me.vertices), len(me.loops), len(me.polygons)
log(f"mesh '{ob.name}': {n_v}v {n_l}loops {n_f}faces uv_layers={[l.name for l in me.uv_layers]}")
# ---- which image does the material ACTUALLY sample? follow the node link ----
sampled = {}
for slot in ob.material_slots:
mat = slot.material
if not mat or not mat.use_nodes:
continue
for node in mat.node_tree.nodes:
if node.type != 'BSDF_PRINCIPLED':
continue
for sock, key in (("Base Color", "base"), ("Normal", "normal"), ("Roughness", "rm")):
if sock not in node.inputs or not node.inputs[sock].links:
continue
src = node.inputs[sock].links[0].from_node
seen = set()
while src and src.type != 'TEX_IMAGE' and id(src) not in seen:
seen.add(id(src))
nxt = None
for i in src.inputs:
if i.links:
nxt = i.links[0].from_node
break
src = nxt
if src and src.type == 'TEX_IMAGE' and src.image:
sampled[key] = src.image
for k, im in sampled.items():
log(f"SAMPLED {k}: '{im.name}' {im.size[0]}x{im.size[1]} packed={bool(im.packed_file)}")
print("ALL IMAGES IN FILE (duplicates are stale):")
for im in bpy.data.images:
if im.size[0]:
print(f" '{im.name}' {im.size[0]}x{im.size[1]}")
base = sampled.get("base")
if base is None:
print("!! material samples no basecolor image"); sys.exit(1)
W, H = base.size
buf = np.empty(W * H * 4, dtype=np.float32)
base.pixels.foreach_get(buf)
tex = buf.reshape(H, W, 4)
# ---- UV data ----
loops_v = np.empty(n_l, dtype=np.int32)
me.loops.foreach_get("vertex_index", loops_v)
uv = np.empty(n_l * 2, dtype=np.float64)
me.uv_layers.active.data.foreach_get("uv", uv)
uv = uv.reshape(-1, 2)
co = np.empty(n_v * 3)
me.vertices.foreach_get("co", co)
co = co.reshape(-1, 3)
print(f"UV range u {uv[:,0].min():.4f}..{uv[:,0].max():.4f} "
f"v {uv[:,1].min():.4f}..{uv[:,1].max():.4f}")
# ---- uv-vertices: a mesh vertex split across N atlas pieces becomes N uv-vertices ----
Q = 1 << 20
key = (loops_v.astype(np.int64) * Q * Q
+ np.round(np.clip(uv[:, 0], 0, 1) * (Q - 1)).astype(np.int64) * Q
+ np.round(np.clip(uv[:, 1], 0, 1) * (Q - 1)).astype(np.int64))
_, uvv = np.unique(key, return_inverse=True)
n_uvv = uvv.max() + 1
splits = np.bincount(uvv, minlength=n_uvv)
per_vert = np.bincount(loops_v, weights=np.zeros(n_l)) # placeholder
# how many atlas copies does each mesh vertex have?
vk = np.unique(np.stack([loops_v, uvv], axis=1), axis=0)
copies = np.bincount(vk[:, 0], minlength=n_v)
print(f"\n=== CUT-APART ===")
print(f"uv-vertices {n_uvv} for {n_v} mesh vertices -> {100.0*(n_uvv-n_v)/n_v:+.1f}% duplication")
print(f"vertices on a UV seam: {int((copies > 1).sum())} ({100.0*(copies>1).sum()/n_v:.1f}%) "
f"max copies {int(copies.max())}")
# ---- islands = connected components of the uv-mesh ----
l_start = np.empty(n_f, dtype=np.int32); me.polygons.foreach_get("loop_start", l_start)
l_tot = np.empty(n_f, dtype=np.int32); me.polygons.foreach_get("loop_total", l_tot)
tri = l_tot == 3
log(f"faces: {int(tri.sum())} tris, {int((~tri).sum())} n-gons")
li = l_start[tri]
T = np.stack([uvv[li], uvv[li + 1], uvv[li + 2]], axis=1)
parent = np.arange(n_uvv, dtype=np.int64)
def find(x):
r = x
while parent[r] != r:
r = parent[r]
while parent[x] != r:
parent[x], x = r, parent[x]
return r
for a, b, c in T:
ra, rb, rc = find(a), find(b), find(c)
if ra != rb:
parent[rb] = ra
if ra != rc:
parent[rc] = ra
log("union-find done")
roots = np.array([find(i) for i in range(n_uvv)])
_, isl = np.unique(roots, return_inverse=True)
n_isl = isl.max() + 1
# per-island geometry: UV area and 3D area
Puv = np.clip(uv, 0, 1)
tri_uv = np.stack([Puv[li], Puv[li + 1], Puv[li + 2]], axis=1) # (F,3,2)
auv = 0.5 * np.abs((tri_uv[:, 1, 0] - tri_uv[:, 0, 0]) * (tri_uv[:, 2, 1] - tri_uv[:, 0, 1])
- (tri_uv[:, 2, 0] - tri_uv[:, 0, 0]) * (tri_uv[:, 1, 1] - tri_uv[:, 0, 1]))
P3 = co[np.stack([loops_v[li], loops_v[li + 1], loops_v[li + 2]], axis=1)] # (F,3,3)
cr = np.cross(P3[:, 1] - P3[:, 0], P3[:, 2] - P3[:, 0])
a3 = 0.5 * np.linalg.norm(cr, axis=1)
fisl = isl[T[:, 0]]
isl_auv = np.bincount(fisl, weights=auv, minlength=n_isl)
isl_a3 = np.bincount(fisl, weights=a3, minlength=n_isl)
isl_nf = np.bincount(fisl, minlength=n_isl)
order = np.argsort(-isl_auv)
UNIT = 1.815 # 1 mesh unit = 1.815 m (body is 0.979 units for 1.777 m)
print(f"\n=== PASTED TOGETHER ===")
print(f"islands: {n_isl}")
print(f"atlas UV area used: {isl_auv.sum()*100:.1f}% (rest is padding/waste)")
cum = np.cumsum(isl_auv[order]) / max(isl_auv.sum(), 1e-12)
for frac in (0.5, 0.9, 0.99):
print(f" {int(np.searchsorted(cum, frac))+1} islands cover {frac*100:.0f}% of the used area")
tiny = int((isl_nf < 20).sum())
print(f" islands with <20 faces: {tiny} ({100.0*tiny/n_isl:.1f}%)")
print("\ntop 20 islands (px/mm = texel density at 4096):")
print(" # faces uv_area% 3D area cm2 px/mm uv centre")
for i in order[:20]:
if isl_a3[i] <= 0:
continue
dens = np.sqrt(isl_auv[i] / isl_a3[i]) * W / (UNIT * 1000.0)
m = fisl == i
cu = tri_uv[m].reshape(-1, 2).mean(axis=0)
print(f" {i:6d} {isl_nf[i]:7d} {isl_auv[i]*100:8.3f} "
f"{isl_a3[i]*UNIT*UNIT*1e4:10.1f} {dens:6.2f} ({cu[0]:.3f},{cu[1]:.3f})")
big = order[:max(1, int(np.searchsorted(cum, 0.99)) + 1)]
dens_all = np.where(isl_a3 > 0, np.sqrt(np.maximum(isl_auv, 0) / np.maximum(isl_a3, 1e-12))
* W / (UNIT * 1000.0), np.nan)
d = dens_all[big]
d = d[np.isfinite(d)]
print(f"\ntexel density over the 99%-area islands: min {d.min():.2f} median {np.median(d):.2f} "
f"max {d.max():.2f} px/mm -> {d.max()/max(d.min(),1e-9):.1f}x spread")
# ---- pictures ----
def save(arr, name):
h, w = arr.shape[:2]
img = bpy.data.images.new(name, w, h, alpha=False, float_buffer=False)
a = np.ones((h, w, 4), dtype=np.float32)
a[:, :, :3] = arr.astype(np.float32)
img.pixels.foreach_set(a.reshape(-1))
p = os.path.join(OUTDIR, name + ".png")
img.file_format = 'PNG'
img.filepath_raw = p
img.save(filepath=p)
log(f"wrote {p} exists={os.path.exists(p)}")
save(tex[:, :, :3], "basecolor")
R = 1024
sc = R / float(W)
rng = np.random.RandomState(3)
pal = rng.rand(n_isl, 3) * 0.75 + 0.2
IS = np.zeros((R, R, 3), dtype=np.float64)
DN = np.zeros((R, R), dtype=np.float64)
GR = np.zeros((R, R), dtype=np.float64)
tri_px = tri_uv * np.array([(R - 1), (R - 1)])
for fi in range(len(tri_px)):
P = tri_px[fi]
x0, x1 = int(P[:, 0].min()), int(np.ceil(P[:, 0].max()))
y0, y1 = int(P[:, 1].min()), int(np.ceil(P[:, 1].max()))
if x1 < x0 or y1 < y0 or x1 - x0 > 64 or y1 - y0 > 64:
continue
dd = ((P[1, 1] - P[2, 1]) * (P[0, 0] - P[2, 0]) + (P[2, 0] - P[1, 0]) * (P[0, 1] - P[2, 1]))
if abs(dd) < 1e-12:
continue
gx, gy = np.meshgrid(np.arange(x0, min(x1, R - 1) + 1), np.arange(y0, min(y1, R - 1) + 1))
aa = ((P[1, 1] - P[2, 1]) * (gx - P[2, 0]) + (P[2, 0] - P[1, 0]) * (gy - P[2, 1])) / dd
bb = ((P[2, 1] - P[0, 1]) * (gx - P[2, 0]) + (P[0, 0] - P[2, 0]) * (gy - P[2, 1])) / dd
cc = 1.0 - aa - bb
ins = (aa >= 0) & (bb >= 0) & (cc >= 0)
if not ins.any():
continue
IS[gy[ins], gx[ins]] = pal[fisl[fi]]
DN[gy[ins], gx[ins]] = dens_all[fisl[fi]] if np.isfinite(dens_all[fisl[fi]]) else 0
# edge pixels -> wireframe
ed = ins & ((aa < 0.06) | (bb < 0.06) | (cc < 0.06))
GR[gy[ed], gx[ed]] = 1.0
log("rasterised island map")
save(IS, "islands")
dv = DN / max(np.percentile(DN[DN > 0], 98), 1e-9)
save(np.stack([np.clip(dv, 0, 1), np.clip(1 - np.abs(dv - 0.5) * 2, 0, 1),
np.clip(1 - dv, 0, 1)], axis=2), "density")
tsm = tex[::W // R, ::W // R, :3]
save(np.clip(tsm * (1 - GR[:, :, None] * 0.8) + GR[:, :, None] * np.array([0.0, 1.0, 0.2]), 0, 1),
"uvgrid")
np.savez_compressed(os.path.join(OUTDIR, "uv_cache.npz"),
isl=isl, uvv=uvv, fisl=fisl, isl_auv=isl_auv, isl_a3=isl_a3,
isl_nf=isl_nf, copies=copies)
print("ATLAS_PROBE_DONE")
@@ -0,0 +1,226 @@
# Stage 20: round the cleavage — fillet the sharp sternum notch and the old bra-neckline crease
# WITHOUT deflating the breasts.
#
# blender --background --python 20_cleavage.py -- <in.blend> <out.blend> <review_dir>
# [iters] [target_radius_units]
#
# WHAT IS WRONG, MEASURED (16_diagnose on 10_welded)
# z=0.710 sternum fillet radius 8.2 mm-units (~15 real mm) notch depth 21 real mm
# z=0.725 sternum fillet radius 2.5 mm-units (~4.5 real mm) notch depth 44 real mm
# elsewhere the corridor radius is 45-550 mm-units, i.e. smooth.
# So there is a razor-sharp, deep V at z 0.71-0.74 — the scar left by excising the bra's sternum
# bow — plus the arcing crease of the old bra neckline over each upper breast. z 0.725 is 1.32 m
# on a 1.777 m body: that is the sternal notch ABOVE the bust (apex sits at z 0.69), where
# anatomy wants a shallow rounded valley, not a gash.
#
# WHY A FILL-ONLY CONCAVITY FILTER, NOT A MEMBRANE OR SMOOTHING
# A membrane over the corridor would bridge the notch, but it also replaces whatever it spans —
# aimed at the upper chest it would eat the breasts' upper poles, and the cups are the one thing
# that must survive (stage 03 sculpted them deliberately; the whole point of v2 was that they are
# not a bra shape). Plain smoothing has the same problem in reverse: it shrinks convex volume.
# So: displace ONLY where the surface is concave beyond a curvature limit, and only OUTWARD
# (valley-filling). Convex geometry has the wrong sign and is untouched by construction, so no
# amount of iteration can flatten a breast. Sharp valleys rise until their radius passes the
# limit, which is exactly "round and smooth as it connects with the chest".
#
# The limit is enforced by measurement, not by feel: after each pass the script re-runs the same
# profile-curvature probe 16_diagnose used, and reports radius + notch depth per height so the
# result is comparable to the numbers above.
import bpy, sys, os, math, time
import numpy as np
from mathutils import Vector
argv = sys.argv[sys.argv.index("--") + 1:]
BLEND, OUT, REVIEW = argv[0], argv[1], argv[2]
ITERS = int(argv[3]) if len(argv) > 3 else 60
R_TARGET = float(argv[4]) if len(argv) > 4 else 0.025 # mesh units (~45 real mm)
os.makedirs(REVIEW, exist_ok=True)
t0 = time.time()
UNIT_MM = 1815.0
# region: the front of the chest, from just under the bust to the clavicles
Z0, Z1 = 0.620, 0.800
Z_FADE = 0.020
X_MAX = 0.095
X_FADE = 0.025
Y_FRONT = 0.010 # front hemisphere only
ALPHA = 0.55 # per-pass fraction of the concave offset that is filled
def log(m):
print(f"[clv {time.time()-t0:6.1f}s] {m}", flush=True)
def smoothstep(x):
x = np.clip(x, 0.0, 1.0)
return x * x * (3.0 - 2.0 * x)
bpy.ops.wm.open_mainfile(filepath=BLEND)
ob = max([o for o in bpy.data.objects if o.type == 'MESH'],
key=lambda o: len(o.data.vertices))
me = ob.data
n_v = len(me.vertices)
co = np.empty(n_v * 3)
me.vertices.foreach_get("co", co)
co = co.reshape(-1, 3)
ev = np.empty(len(me.edges) * 2, dtype=np.int32)
me.edges.foreach_get("vertices", ev)
ev = ev.reshape(-1, 2)
log(f"in: {n_v}v {len(me.polygons)}f")
order = np.concatenate([ev[:, 0], ev[:, 1]])
nbr = np.concatenate([ev[:, 1], ev[:, 0]])
srt = np.argsort(order, kind="stable")
o_s, n_s = order[srt], nbr[srt]
ptr = np.searchsorted(o_s, np.arange(n_v + 1))
cnt = np.maximum(np.diff(ptr), 1)
empty = np.diff(ptr) == 0
def nbr_mean(X):
a = np.add.reduceat(X[n_s], ptr[:-1], axis=0)
a[empty] = X[empty]
return a / cnt[:, None]
# ---- region weight ----
wz = smoothstep((co[:, 2] - (Z0 - Z_FADE)) / Z_FADE) * \
smoothstep(((Z1 + Z_FADE) - co[:, 2]) / Z_FADE)
wx = smoothstep(((X_MAX + X_FADE) - np.abs(co[:, 0])) / X_FADE)
wy = smoothstep((Y_FRONT - co[:, 1]) / 0.030)
W = wz * wx * wy
log(f"region: {int((W > 0.01).sum())} verts with weight >0.01, "
f"{int((W > 0.5).sum())} above 0.5")
# ---- the same profile probe 16_diagnose used, so numbers are comparable ----
def probe(P, tag):
front = P[:, 1] < 0
print(f"\n=== CLEAVAGE PROFILE [{tag}] ===")
print(" z sternum y concave curv 1/m fillet radius notch vs apex")
worst = 9e9
for z0 in np.arange(0.650, 0.7801, 0.015):
row = []
for x0 in np.arange(-0.05, 0.0501, 0.005):
m = front & (np.abs(P[:, 0] - x0) < 0.0035) & (np.abs(P[:, 2] - z0) < 0.004)
row.append(P[m, 1].min() if m.sum() else np.nan)
row = np.array(row)
if np.isnan(row).all():
continue
mid = len(row) // 2
seg = row[max(0, mid - 3):mid + 4]
rad = float('inf')
kmax = np.nan
if len(seg) >= 3 and not np.isnan(seg).any():
d2 = (seg[:-2] - 2 * seg[1:-1] + seg[2:]) / (0.005 ** 2)
kmax = float(np.nanmax(d2))
rad = 1.0 / kmax if kmax > 1e-6 else float('inf')
ma = front & (np.abs(np.abs(P[:, 0]) - 0.034) < 0.005) & (np.abs(P[:, 2] - z0) < 0.004)
notch = ((row[mid] - P[ma, 1].min()) * UNIT_MM) if ma.sum() else np.nan
flag = ""
if rad < R_TARGET:
flag = " <-- SHARP"
worst = min(worst, rad)
print(f" {z0:.3f} {row[mid]:+.4f} {kmax:10.1f} "
f"{rad*UNIT_MM:8.1f} mm {notch:+7.1f} mm{flag}")
return worst
w0 = probe(co, "before")
# ---- fill-only concavity relaxation ----
P = co.copy()
active = W > 0.01
for it in range(1, ITERS + 1):
# fresh vertex normals from the CURRENT positions (area-weighted via the mesh)
me.vertices.foreach_set("co", P.reshape(-1))
me.update()
nrm = np.empty(n_v * 3)
me.vertices.foreach_get("normal", nrm)
nrm = nrm.reshape(-1, 3)
lap = nbr_mean(P) - P
c = (lap * nrm).sum(axis=1) # >0 : neighbours are outside -> valley (concave)
step = np.where(c > 0, c, 0.0) * ALPHA * W
# a vertex only moves if its valley is sharper than the target radius: the uniform-Laplacian
# offset of a circular valley of radius R over spacing h is ~h^2/(2R), so compare against that
h2 = np.zeros(n_v)
np.add.at(h2, ev[:, 0], np.linalg.norm(P[ev[:, 0]] - P[ev[:, 1]], axis=1) ** 2)
np.add.at(h2, ev[:, 1], np.linalg.norm(P[ev[:, 0]] - P[ev[:, 1]], axis=1) ** 2)
hcnt = np.zeros(n_v)
np.add.at(hcnt, ev[:, 0], 1.0)
np.add.at(hcnt, ev[:, 1], 1.0)
h2 = h2 / np.maximum(hcnt, 1)
thresh = h2 / (2.0 * R_TARGET)
step = np.where(c > thresh, step, 0.0)
P = P + nrm * step[:, None]
if it % 15 == 0 or it == 1:
moved = np.linalg.norm(P - co, axis=1) * UNIT_MM
log(f"pass {it:3d}: {int((step>0).sum()):6d} verts filled this pass, "
f"cumulative max {moved.max():.2f} mm, median(region) "
f"{np.median(moved[active]):.3f} mm")
me.vertices.foreach_set("co", P.reshape(-1))
me.update()
if me.has_custom_normals:
vn = np.empty(n_v * 3, dtype=np.float32)
me.vertices.foreach_get("normal", vn)
me.normals_split_custom_set_from_vertices(vn.reshape(-1, 3))
d = np.linalg.norm(P - co, axis=1) * UNIT_MM
log(f"TOTAL: max {d.max():.2f} mm, {int((d > 0.1).sum())} verts moved >0.1 mm "
f"(all outward: min radial change {np.min(((P-co)*0+1)[0]):.0f})")
w1 = probe(P, "after")
print(f"\nSHARPEST corridor radius: before {w0*UNIT_MM:.1f} mm -> after "
f"{(w1*UNIT_MM if w1 < 9e9 else float('inf')):.1f} mm (target {R_TARGET*UNIT_MM:.0f} mm)")
# ---- renders ----
scn = bpy.context.scene
wd = bpy.data.worlds.new("W")
wd.color = (0.22, 0.22, 0.24)
scn.world = wd
key = bpy.data.objects.new("Key", bpy.data.lights.new("Key", 'SUN'))
key.data.energy = 3.0
key.data.use_shadow = False
bpy.context.collection.objects.link(key)
fl = bpy.data.objects.new("Fill", bpy.data.lights.new("Fill", 'SUN'))
fl.data.energy = 1.0
fl.data.use_shadow = False
bpy.context.collection.objects.link(fl)
cam = bpy.data.objects.new("Cam", bpy.data.cameras.new("Cam"))
cam.data.lens = 85
bpy.context.collection.objects.link(cam)
scn.camera = cam
scn.render.engine = 'BLENDER_EEVEE' if bpy.app.version >= (4, 2) else 'BLENDER_EEVEE_NEXT'
scn.render.resolution_x = scn.render.resolution_y = 1000
clay = bpy.data.materials.new("Clay")
clay.use_nodes = True
clay.node_tree.nodes["Principled BSDF"].inputs["Base Color"].default_value = (0.62, 0.60, 0.58, 1)
clay.node_tree.nodes["Principled BSDF"].inputs["Roughness"].default_value = 0.45
orig = [ms.material for ms in ob.material_slots]
def shoot(tag, ctr, span, yaw_deg, use_clay=True):
for i, ms in enumerate(ob.material_slots):
ms.material = clay if use_clay else orig[i]
yaw = math.radians(yaw_deg)
dist = span * 3.0
cam.location = Vector(ctr) + Vector((math.sin(yaw) * dist, -math.cos(yaw) * dist, 0.02))
cam.rotation_euler = (Vector(ctr) - cam.location).to_track_quat('-Z', 'Y').to_euler()
key.rotation_euler = (math.radians(62), 0, math.radians(35 + yaw_deg))
fl.rotation_euler = (math.radians(75), 0, math.radians(yaw_deg - 110))
scn.render.filepath = os.path.abspath(os.path.join(REVIEW, f"{tag}.png"))
bpy.ops.render.render(write_still=True)
log(f"render {tag}")
CHEST = (0.0, 0.0, 0.675)
FULL = (0.0, 0.0, 0.50)
for yaw in (0, 40, 90):
shoot(f"chest_clay_{yaw}", CHEST, 0.22, yaw, True)
shoot("chest_tex_0", CHEST, 0.22, 0, False)
shoot("chest_tex_40", CHEST, 0.22, 40, False)
shoot("full_clay_0", FULL, 0.55, 0, True)
bpy.ops.wm.save_as_mainfile(filepath=OUT)
log(f"WROTE {OUT}")
print("CLV_DONE")
@@ -0,0 +1,163 @@
# Stage 21 (read-only): is the patchwork in the LAYOUT or in the COLOUR?
#
# blender --background --python 21_seam_probe.py -- <in.blend> <probe_dir>
#
# A mesh vertex that sits on a UV seam has one copy in each atlas chart that meets there. Those
# copies are the SAME point on her body, so they must be the same colour. Any difference is a
# tone step the eye reads as a pasted edge — and re-packing the UVs would carry it along.
# Reports the distribution of that step, the worst offending chart pairs, and (for scale) the
# same statistic on non-seam vertices, which is pure sampling noise.
# Also reports mean tone per body region, to size the "red hands / rosy chest" complaint.
import bpy, sys, os, time
import numpy as np
argv = sys.argv[sys.argv.index("--") + 1:]
BLEND = argv[0]
PROBE = os.path.abspath(argv[1])
t0 = time.time()
def log(m):
print(f"[seam {time.time()-t0:6.1f}s] {m}", flush=True)
bpy.ops.wm.open_mainfile(filepath=BLEND)
ob = max([o for o in bpy.data.objects if o.type == 'MESH'], key=lambda o: len(o.data.vertices))
me = ob.data
n_v, n_l, n_f = len(me.vertices), len(me.loops), len(me.polygons)
base = None
for slot in ob.material_slots:
mat = slot.material
for node in mat.node_tree.nodes:
if node.type == 'BSDF_PRINCIPLED' and node.inputs["Base Color"].links:
src = node.inputs["Base Color"].links[0].from_node
if src.type == 'TEX_IMAGE':
base = src.image
W, H = base.size
buf = np.empty(W * H * 4, dtype=np.float32)
base.pixels.foreach_get(buf)
tex = buf.reshape(H, W, 4)[:, :, :3].astype(np.float32)
log(f"basecolor '{base.name}' {W}x{H}")
loops_v = np.empty(n_l, dtype=np.int32); me.loops.foreach_get("vertex_index", loops_v)
uv = np.empty(n_l * 2); me.uv_layers.active.data.foreach_get("uv", uv); uv = uv.reshape(-1, 2)
co = np.empty(n_v * 3); me.vertices.foreach_get("co", co); co = co.reshape(-1, 3)
C = np.load(os.path.join(PROBE, "uv_cache.npz"))
uvv, isl = C["uvv"], C["isl"]
l_isl = isl[uvv] # island id per loop
l_start = np.empty(n_f, dtype=np.int32); me.polygons.foreach_get("loop_start", l_start)
# inset each loop's UV 25% toward its face centroid so we read chart interior, not padding
face_of_loop = np.repeat(np.arange(n_f), 3) # all-tri mesh
cen = (uv[l_start[face_of_loop]] + uv[l_start[face_of_loop] + 1]
+ uv[l_start[face_of_loop] + 2]) / 3.0
uvi = uv + 0.25 * (cen - uv)
px = np.clip(np.round(uvi[:, 0] * (W - 1)).astype(np.int32), 0, W - 1)
py = np.clip(np.round(uvi[:, 1] * (H - 1)).astype(np.int32), 0, H - 1)
lc = tex[py, px] # colour per loop
log("sampled per-loop colour")
# ---- per (vertex, island) mean colour ----
key = loops_v.astype(np.int64) * (isl.max() + 1) + l_isl
uk, inv = np.unique(key, return_inverse=True)
n_k = len(uk)
cnt = np.bincount(inv, minlength=n_k).astype(np.float64)
acc = np.zeros((n_k, 3))
for c in range(3):
acc[:, c] = np.bincount(inv, weights=lc[:, c], minlength=n_k)
kc = acc / cnt[:, None]
kv = (uk // (isl.max() + 1)).astype(np.int64) # vertex of each (v,island) group
ki = (uk % (isl.max() + 1)).astype(np.int64) # island of each group
order = np.argsort(kv, kind="stable")
kv_s, kc_s, ki_s = kv[order], kc[order], ki[order]
ptr = np.searchsorted(kv_s, np.arange(n_v + 1))
ncopy = np.diff(ptr)
seam_v = np.nonzero(ncopy > 1)[0]
log(f"seam vertices: {len(seam_v)} (max copies {ncopy.max()})")
steps = []
pairstep = {}
for v in seam_v:
s, e = ptr[v], ptr[v + 1]
cc = kc_s[s:e]
ii = ki_s[s:e]
d = np.abs(cc[:, None, :] - cc[None, :, :]).max(axis=2)
a, b = np.unravel_index(np.argmax(d), d.shape)
steps.append(d[a, b])
if d[a, b] > 0.02:
kpair = (int(min(ii[a], ii[b])), int(max(ii[a], ii[b])))
r = pairstep.setdefault(kpair, [0, 0.0])
r[0] += 1
r[1] += float(d[a, b])
steps = np.array(steps)
# baseline: colour spread among the loops of a NON-seam vertex (sampling noise only)
solo = np.nonzero(ncopy == 1)[0]
sample = solo[::max(1, len(solo) // 40000)]
noise = []
lorder = np.argsort(loops_v, kind="stable")
lv_s = loops_v[lorder]
lptr = np.searchsorted(lv_s, np.arange(n_v + 1))
for v in sample:
li = lorder[lptr[v]:lptr[v + 1]]
if len(li) < 2:
continue
noise.append(np.abs(lc[li].max(axis=0) - lc[li].min(axis=0)).max())
noise = np.array(noise)
print("\n=== COLOUR STEP ACROSS CHART BORDERS ===")
print("(max channel difference between copies of the SAME body point in different charts)")
for p in (50, 75, 90, 95, 99):
print(f" seam p{p:<2d} {np.percentile(steps, p):.4f}")
print(f" seam mean {steps.mean():.4f} >0.02: {100.0*(steps>0.02).mean():.1f}% "
f">0.05: {100.0*(steps>0.05).mean():.1f}% >0.10: {100.0*(steps>0.10).mean():.1f}%")
print(f" NOISE floor (non-seam vertices) p50 {np.percentile(noise,50):.4f} "
f"p95 {np.percentile(noise,95):.4f} mean {noise.mean():.4f}")
print(f" -> seam step is {steps.mean()/max(noise.mean(),1e-9):.1f}x the noise floor")
print("\nworst chart pairs (count of stepped verts, mean step):")
tops = sorted(pairstep.items(), key=lambda kv_: -kv_[1][1])[:12]
for (a, b), (n_, s_) in tops:
print(f" chart {a:5d} <-> {b:5d}: {n_:5d} verts, mean step {s_/n_:.4f}")
# ---- per-region tone (the red hands / rosy chest complaint, as numbers) ----
vc = np.zeros((n_v, 3))
vn = np.zeros(n_v)
for c in range(3):
vc[:, c] = np.bincount(loops_v, weights=lc[:, c], minlength=n_v)
vn = np.bincount(loops_v, minlength=n_v).astype(np.float64)
vc /= np.maximum(vn, 1)[:, None]
z, x, y = co[:, 2], co[:, 0], co[:, 1]
regions = {
"head ": z > 0.905,
"neck/upper chest": (z > 0.82) & (z <= 0.905) & (np.abs(x) < 0.09),
"breast band ": (z > 0.60) & (z <= 0.78) & (np.abs(x) < 0.11) & (y < 0),
"belly ": (z > 0.45) & (z <= 0.60) & (np.abs(x) < 0.09) & (y < 0),
"hip/crotch ": (z > 0.33) & (z <= 0.45) & (np.abs(x) < 0.09),
"upper arm ": (z > 0.70) & (np.abs(x) > 0.16) & (np.abs(x) < 0.30),
"forearm ": (np.abs(x) > 0.30) & (np.abs(x) < 0.40),
"hand ": np.abs(x) > 0.40,
"thigh ": (z > 0.20) & (z <= 0.33),
"shin ": (z > 0.06) & (z <= 0.18),
"foot ": z <= 0.05,
}
print("\n=== TONE BY REGION (mean RGB, and r-g redness) ===")
belly_rg = None
for nm, m in regions.items():
if m.sum() < 50:
print(f" {nm} (empty)")
continue
c_ = vc[m].mean(axis=0)
rg = c_[0] - c_[1]
if nm.startswith("belly"):
belly_rg = rg
print(f" {nm} n={int(m.sum()):7d} RGB {c_[0]:.3f} {c_[1]:.3f} {c_[2]:.3f} "
f"r-g {rg:.3f} luma {c_.mean():.3f}")
if belly_rg is not None:
print(f" (belly r-g = {belly_rg:.3f} is the reference 'plain skin' redness)")
np.save(os.path.join(PROBE, "vert_colour.npy"), vc.astype(np.float32))
print("SEAM_PROBE_DONE")
@@ -0,0 +1,353 @@
# Stage 21: kill the discolouration in the repainted bra/crotch patches — gradient-domain
# levelling of basecolor + roughness, and grain in place of the flat normal.
#
# blender --background --python 21_tone.py -- <in.blend> <out.blend> [--no-normal-grain]
#
# WHY THE PATCHES READ AS DISCOLOURED
# 05_texture.py fills each garment texel with an inverse-distance colour taken from the nearest
# SKIN VERTS IN 3D, mirror-averaged left/right, then feathers the rim 4 px. Every one of those
# choices is right for avoiding a wrong-body-part tone, and none of them controls the patch's
# ABSOLUTE level: the fill is an average of skin a few centimetres away, so wherever her skin has
# a gradient (and her chest does — there is a rosy blush the uniform-skin decision repaints), the
# patch lands at a different tone than the skin it abuts. A feather blurs that step over 4 px; it
# cannot remove it. The blend also flattens the normal map to (128,128,255) and sets roughness to
# the atlas median over the same texels, so the patch is smoother AND differently-glossy than the
# skin around it — under a key light that reads as discolouration even where the albedo matches.
#
# THE FIX — solve for the level instead of averaging toward it.
# Classic gradient-domain (Poisson) levelling: keep the fill's detail, replace its level. Find a
# correction field E over the patch that is harmonic inside and, ON THE PATCH BORDER, equals the
# mismatch against the untouched skin next to it:
# D(b) = mean(orig[n] : n neighbour of b, n outside the patch) - current[b]
# laplace(E) = 0 inside, E = D on the border, new = current + E
# At the border the corrected value becomes exactly its neighbours' value, so the seam cannot be
# seen; inward, E decays smoothly, so a uniform offset over the whole patch is removed too. Detail
# is untouched because E is smooth by construction — this levels the patch without blurring it.
#
# Solved per blob with a cascadic multigrid (coarse solve -> upsample -> refine). A flat Jacobi
# sweep would need ~width^2 iterations to converge; that mistake is already recorded in this
# project's history as the "pale panty ghost" (400 passes on a 600 px hole left the interior at
# its seed tone), so it is not repeated.
#
# The patch mask is not guessed: it is where the wired basecolor differs from the untouched
# original, which still sits in the file as an orphan datablock copy left by the raw-glb imports.
import bpy, sys, os, time
import numpy as np
argv = sys.argv[sys.argv.index("--") + 1:]
BLEND, OUT = argv[0], argv[1]
# The untouched pre-repaint textures must come from a SEPARATE blend. They used to survive inside
# the working file as orphan ".002/.003" copies left by the raw-glb imports, but Blender purges
# zero-user datablocks on save, so they died the moment 22_lines.blend was written. 00_welded.blend
# is the pristine import and is the right source.
ORIG_BLEND = argv[2] if len(argv) > 2 and not argv[2].startswith("--") else "00_welded.blend"
DO_NORMAL_GRAIN = "--no-normal-grain" not in argv
t0 = time.time()
DIFF_T = 0.02 # a texel counts as repainted if any channel moved this much
RING = 10 # how far out to look for untouched skin
GRAIN_T = 16
def log(m):
print(f"[tone {time.time()-t0:6.1f}s] {m}", flush=True)
def getpx(img):
w, h = img.size
b = np.empty(w * h * 4, dtype=np.float32)
img.pixels.foreach_get(b)
return b.reshape(h, w, 4)
def dil(m, k=1):
g = m.copy()
for _ in range(k):
n = g.copy()
n[1:, :] |= g[:-1, :]
n[:-1, :] |= g[1:, :]
n[:, 1:] |= g[:, :-1]
n[:, :-1] |= g[:, 1:]
g = n
return g
# ---- cache the untouched originals from the pristine blend, before opening the working file ----
if not os.path.exists(ORIG_BLEND):
raise SystemExit(f"[tone] FATAL: original blend not found: {ORIG_BLEND}")
bpy.ops.wm.open_mainfile(filepath=ORIG_BLEND)
ORIG_CACHE = {}
for i in bpy.data.images:
nm = i.name.lower()
kind = ("base" if "basecolor" in nm else
"rm" if "_rm" in nm else
"normal" if "normal" in nm else None)
if kind and kind not in ORIG_CACHE:
ORIG_CACHE[kind] = (getpx(i)[:, :, :3].astype(np.float64), tuple(i.size), i.name)
log(f"cached originals from {ORIG_BLEND}: "
f"{ {k: (v[2], v[1]) for k, v in ORIG_CACHE.items()} }")
bpy.ops.wm.open_mainfile(filepath=BLEND)
ob = max([o for o in bpy.data.objects if o.type == 'MESH'],
key=lambda o: len(o.data.vertices))
log(f"body {ob.name} {len(ob.data.vertices)}v")
# ---- which image is WIRED, and which orphan copy is the untouched original ----
wired = {}
for ms in ob.material_slots:
mat = ms.material
if not mat or not mat.node_tree:
continue
for n in mat.node_tree.nodes:
if n.type != 'TEX_IMAGE' or not n.image:
continue
for o in n.outputs:
for lk in o.links:
tn = lk.to_node.name.lower()
if "principled" in tn or lk.to_socket.name == "Base Color":
wired["base"] = n.image
elif "normal map" in tn:
wired["normal"] = n.image
elif "separate" in tn:
wired["rm"] = n.image
log(f"wired: { {k: v.name for k, v in wired.items()} }")
if "base" not in wired:
raise SystemExit("[tone] FATAL: could not find the wired basecolor")
def find_original(kind, target):
"""The cached pristine version of this map, checked for size and for actually differing."""
if kind not in ORIG_CACHE:
return None
arr, size, nm = ORIG_CACHE[kind]
if size != tuple(target.size):
log(f" {kind}: size {size} != wired {tuple(target.size)} — unusable")
return None
changed = int((np.abs(getpx(target)[:, :, :3] - arr).max(axis=2) > DIFF_T).sum())
log(f" {kind}: original '{nm}', {changed} texels differ from wired")
if changed < 1000:
return None
return (arr, changed, nm)
orig = find_original("base", wired["base"])
if orig is None:
raise SystemExit("[tone] FATAL: no usable untouched original basecolor")
O, n_changed, oname = orig
log(f"original = '{oname}' ({n_changed} texels differ)")
A4 = getpx(wired["base"])
A = A4[:, :, :3].astype(np.float64)
h, w = A.shape[:2]
mask = np.abs(A - O).max(axis=2) > DIFF_T
log(f"patch mask: {int(mask.sum())} texels ({100.0*mask.sum()/(w*h):.2f}% of atlas)")
# ---- split into blobs so each is levelled against ITS OWN surroundings ----
def blobs_of(m, min_px=1500):
lab = np.zeros(m.shape, dtype=np.int32)
cur = 0
out = []
ys, xs = np.nonzero(m)
seen = np.zeros(m.shape, dtype=bool)
from collections import deque
for y0, x0 in zip(ys, xs):
if seen[y0, x0]:
continue
cur += 1
q = deque([(y0, x0)])
seen[y0, x0] = True
cells = []
while q:
y, x = q.popleft()
cells.append((y, x))
for dy, dx in ((1, 0), (-1, 0), (0, 1), (0, -1)):
yy, xx = y + dy, x + dx
if 0 <= yy < m.shape[0] and 0 <= xx < m.shape[1] \
and m[yy, xx] and not seen[yy, xx]:
seen[yy, xx] = True
q.append((yy, xx))
if len(cells) >= min_px:
lab[tuple(np.array(cells).T)] = cur
out.append((cur, len(cells)))
return lab, out
lab, blist = blobs_of(mask)
log(f"blobs >=1500 px: {len(blist)} (covering {sum(b[1] for b in blist)} texels)")
def solve_level(cur_img, orig_img, m_blob, tag):
"""Harmonic correction field E over m_blob with border BC = local mismatch vs untouched skin.
Returns E (same shape as the crop) and diagnostics."""
ys, xs = np.nonzero(m_blob)
y0, y1 = max(0, ys.min() - RING - 2), min(h, ys.max() + RING + 3)
x0, x1 = max(0, xs.min() - RING - 2), min(w, xs.max() + RING + 3)
M = m_blob[y0:y1, x0:x1]
C = cur_img[y0:y1, x0:x1]
Og = orig_img[y0:y1, x0:x1]
allm = mask[y0:y1, x0:x1]
# untouched skin usable as a reference: outside EVERY patch, and plausibly skin
lum = Og.mean(axis=2)
usable = (~allm) & (lum > 0.12)
# robust reject: compare to the median of the ring around this blob
ring = dil(M, RING) & usable
if ring.sum() < 50:
return None, None
med = np.median(Og[ring], axis=0)
mad = np.median(np.abs(Og[ring] - med), axis=0) + 1e-4
ok = (np.abs(Og - med) < (6.0 * mad)).all(axis=2) & usable
# border texels of the blob, and their mismatch D
nb_sum = np.zeros_like(C)
nb_cnt = np.zeros(M.shape)
for dy, dx in ((1, 0), (-1, 0), (0, 1), (0, -1)):
Sh = np.roll(Og * ok[:, :, None], (dy, dx), axis=(0, 1))
Wh = np.roll(ok.astype(np.float64), (dy, dx), axis=(0, 1))
nb_sum += Sh
nb_cnt += Wh
border = M & (nb_cnt > 0)
if border.sum() < 20:
return None, None
D = np.zeros_like(C)
D[border] = nb_sum[border] / nb_cnt[border, None] - C[border]
# cascadic multigrid: solve coarse, upsample, refine
def restrict(x, msk):
Hh, Ww = x.shape[:2]
H2, W2 = (Hh + 1) // 2, (Ww + 1) // 2
acc = np.zeros((H2, W2, x.shape[2]))
cw = np.zeros((H2, W2))
for dy in (0, 1):
for dx in (0, 1):
sub = x[dy::2, dx::2]
sm = msk[dy::2, dx::2].astype(np.float64)
acc[:sub.shape[0], :sub.shape[1]] += sub * sm[:, :, None]
cw[:sub.shape[0], :sub.shape[1]] += sm
out = np.zeros_like(acc)
nz = cw > 0
out[nz] = acc[nz] / cw[nz, None]
return out, cw > 0
levels = []
Mi, Di, Bi = M, D, border
while min(Mi.shape[:2]) > 8 and len(levels) < 7:
levels.append((Mi, Di, Bi))
Dn, _ = restrict(Di, Bi)
Mn = restrict(Mi[:, :, None].astype(np.float64), Mi)[1]
Bn = restrict(Bi[:, :, None].astype(np.float64), Bi)[1]
Mi, Di, Bi = Mn, Dn, Bn
E = np.zeros(levels[-1][0].shape + (3,))
for li in range(len(levels) - 1, -1, -1):
Ml, Dl, Bl = levels[li]
if E.shape[:2] != Ml.shape[:2]:
Eu = np.repeat(np.repeat(E, 2, axis=0), 2, axis=1)
E = Eu[:Ml.shape[0], :Ml.shape[1]]
E[Bl] = Dl[Bl]
interior = Ml & ~Bl
sweeps = 400 if li >= len(levels) - 2 else 60
for _ in range(sweeps):
acc = np.zeros_like(E)
cw = np.zeros(E.shape[:2])
for dy, dx in ((1, 0), (-1, 0), (0, 1), (0, -1)):
acc += np.roll(E * Ml[:, :, None], (dy, dx), axis=(0, 1))
cw += np.roll(Ml.astype(np.float64), (dy, dx), axis=(0, 1))
nz = interior & (cw > 0)
E[nz] = acc[nz] / cw[nz, None]
E[Bl] = Dl[Bl]
inner = M & ~dil(~M, 5)
diag = dict(
n=int(M.sum()),
border=int(border.sum()),
pre=(float(np.mean(C[inner].mean(axis=1) - med.mean())) if inner.sum() else float('nan')),
Emean=float(E[M].mean()),
Emax=float(np.abs(E[M]).max()),
)
return (slice(y0, y1), slice(x0, x1), M, E), diag
# =============================================================================
# apply to basecolor
# =============================================================================
def level_image(img, orig_np, label):
P4 = getpx(img)
P = P4[:, :, :3].astype(np.float64)
total = np.zeros_like(P)
touched = np.zeros(P.shape[:2], dtype=bool)
for bid, npx in sorted(blist, key=lambda t: -t[1]):
mb = lab == bid
res, diag = solve_level(P, orig_np, mb, f"{label}#{bid}")
if res is None:
log(f" {label} blob{bid}: skipped (no usable surrounding skin)")
continue
sy, sx, M, E = res
total[sy, sx][M] += E[M]
touched[sy, sx] |= M
log(f" {label} blob{bid}: {npx:7d} px border {diag['border']:6d} "
f"interior offset vs ring {diag['pre']:+.4f} -> correction mean "
f"{diag['Emean']:+.4f} (max |E| {diag['Emax']:.4f})")
out = np.clip(P + total, 0.0, 1.0)
# report the residual step across the patch border
b_in = touched & ~dil(~touched, 2)
b_out = dil(touched, 3) & ~touched
if b_in.any() and b_out.any():
log(f" {label}: border step before {abs(P[b_in].mean()-P[b_out].mean()):.4f} "
f"-> after {abs(out[b_in].mean()-out[b_out].mean()):.4f}")
P4[:, :, :3] = out.astype(np.float32)
img.pixels.foreach_set(P4.reshape(-1))
img.pack()
log(f" {label}: written + packed ({int(touched.sum())} texels corrected)")
return touched
tch = level_image(wired["base"], O, "basecolor")
# roughness/metallic: same levelling, so the patch stops reading as a different material
if "rm" in wired:
rm_orig = find_original("rm", wired["rm"])
if rm_orig is not None and tuple(wired["rm"].size) == (w, h):
level_image(wired["rm"], rm_orig[0], "rm")
else:
log("rm: no original copy or size mismatch — skipped")
# normal: the patch is perfectly flat; transplant skin grain so it stops reading as a decal
if DO_NORMAL_GRAIN and "normal" in wired and tuple(wired["normal"].size) == (w, h):
NM4 = getpx(wired["normal"])
NM = NM4[:, :, :3].astype(np.float64)
src_ok = ~dil(mask, 6)
def box1(a, r):
def b1(x, axis):
p = [(0, 0)] * x.ndim
p[axis] = (r, r)
cs = np.cumsum(np.pad(x, p, mode="edge"), axis=axis)
return (np.take(cs, np.arange(2 * r, cs.shape[axis]), axis=axis) -
np.take(cs, np.arange(0, cs.shape[axis] - 2 * r), axis=axis)) / (2 * r)
return b1(b1(a, 0), 1)
grain = np.stack([NM[:, :, c] - box1(NM[:, :, c], 5) for c in range(3)], axis=2)
cand = []
for ty in range(0, h - GRAIN_T, GRAIN_T):
for tx in range(0, w - GRAIN_T, GRAIN_T):
if src_ok[ty:ty + GRAIN_T, tx:tx + GRAIN_T].all():
cand.append((ty, tx))
rng = np.random.RandomState(1234)
cov = 0
for ty in range(0, h - GRAIN_T + 1, GRAIN_T):
for tx in range(0, w - GRAIN_T + 1, GRAIN_T):
tm = mask[ty:ty + GRAIN_T, tx:tx + GRAIN_T]
if not tm.any() or not cand:
continue
sy, sx = cand[rng.randint(len(cand))]
blk = NM[ty:ty + GRAIN_T, tx:tx + GRAIN_T]
blk[tm] += grain[sy:sy + GRAIN_T, sx:sx + GRAIN_T][tm]
cov += int(tm.sum())
NM4[:, :, :3] = np.clip(NM, 0, 1).astype(np.float32)
wired["normal"].pixels.foreach_set(NM4.reshape(-1))
wired["normal"].pack()
log(f"normal grain: {cov} texels from {len(cand)} clean tiles + packed")
bpy.ops.wm.save_as_mainfile(filepath=OUT)
log(f"WROTE {OUT}")
print("TONE_DONE")

Some files were not shown because too many files have changed in this diff Show More