Merge branch 'main' of https://tinqs.com/tinqs/animation
@@ -0,0 +1,392 @@
|
||||
# Plan: Clothing-pipeline unification — one entry point, QC gates at every seam
|
||||
|
||||
**Status:** Implemented 2026-07-31 (phases 1–4 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 1–3. 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 224–248, prepare 295–505, fit 542–598, reduce 653–706, bake 712–768, skin 908–957 (weight modes 920–944, skirt_bones 836–905), export 964–989 (vertex SHA1 984–989) |
|
||||
| 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 71–78, `BaseDirFor` `_ =>` 96–97), `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 66–73, KDTree copy 110–127, A-pose check 198–205), `make_islander_outfits.py` (region cuts 103–149) |
|
||||
|
||||
## 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 **216–231 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.
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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 |
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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 1–4k 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.2k–3.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.
|
||||
@@ -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 4–14 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.2k–3.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 4–14 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 2–5 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.90–0.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.9–14 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 | +15–20 mm | pari: 1100 vs 1083 bust |
|
||||
| Regular fitted top | +90–100 mm | tee: 1180 vs 1083 bust |
|
||||
| Loose / drapey | +150 mm and up | (untested above ~150) |
|
||||
| Bottoms waist edge | **negative**: 0.90–0.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 8–14 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 **8–14 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.
|
||||
@@ -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
|
||||
|
||||
@@ -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/
|
||||
|
||||
@@ -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.
|
||||
@@ -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.2k–4.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/<set>/
|
||||
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>4–14 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.2k–4.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
|
@@ -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 —
|
||||
|
||||
@@ -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.,
|
||||
@@ -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.,
|
||||
|
After Width: | Height: | Size: 220 KiB |
@@ -0,0 +1,2 @@
|
||||
The female mannequin doesn’t include the animations, as duplicating them wouldn’t 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.
|
||||
|
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
|
||||
|
After Width: | Height: | Size: 232 KiB |
|
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()
|
||||
|
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
|
||||
|
After Width: | Height: | Size: 232 KiB |
|
After Width: | Height: | Size: 145 KiB |
@@ -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.
|
||||
@@ -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 3–5 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.
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||