feat: clothing lane, character sources, and DCC bridges

Bulk import of the working lanes that were living untracked on the PC.

Content:
- characters/  Lena/male body lanes, bakes, texture work, run logs
- clothing/    garment pipeline, configs, gates, contract docs
- garments/    MD-authored garment sources (.zprj/.zpac)
- UAL-Lib/     Universal Animation Library 2 source (.blend/.fbx/.glb)
- tools/       blender_bridge, iclone_bridge, md_bridge, tailor, glm_agent
- docs/, plans/, dev/, .agents/plans/

Repo hygiene:
- .gitattributes: LFS now covers .blend, .zprj, .zpac, .obj, .npy and the
  Reallusion .iAvatar/.ccAvatar/.ccRestore containers. Without this the
  ~3.8 GB in this commit would land as raw blobs. .png/.jpg are left out
  on purpose — ~250 are already tracked raw and converting them would
  rewrite every one without shrinking history.
- .gitignore: exclude /accurig/ (~1 GB AccuRig program files, redistributable
  from Reallusion, nothing authored here) and /dev/null/ (git-lfs hook copies
  dropped by a `>/dev/null` redirect on Windows).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-06 15:55:43 -07:00
parent 3363209cac
commit 3ba86b2ea8
558 changed files with 68622 additions and 8 deletions
@@ -0,0 +1,392 @@
# Plan: Clothing-pipeline unification — one entry point, QC gates at every seam
**Status:** Implemented 2026-07-31 (phases 14 built; catalog JSON still deferred) · **Author:** Fable 5 session 2026-07-31 · **Implementer:** 5-agent parallel build + integration pass, 2026-07-31 — see §8
## 0. Context (you have no other context — read this fully)
The clothing lane turns a garment idea into an outfit part an ariki-game character wears.
It has three halves that work but do not talk to each other:
1. **MD upstream** (this repo): Marvelous Designer 2024, driven over a TCP bridge
(`tools/md_bridge.py`, port 18900). Per-garment scripts in `tools/tailor/` draft
panels, sew, drape on the Lena avatar FBX, texture, and snapshot. Session start is a
human click (Plugin > TinqsMDBridge); MD's UI freezes while a script runs.
2. **Blender downstream** (this repo): `clothing/garment_pipeline.py` — seven
config-driven headless stages (`census prepare fit reduce bake skin export`), each
checkpointing a `.blend` + QA renders into `work/<name>/`, exporting per-slot GLBs
onto the shared 65-bone Quaternius skeleton into
`ariki-game/assets/quaternius/outfits/<set>/`.
3. **Game side** (`ariki-game`, read-only for this repo's tooling by convention):
hand-edit `src/Character/OutfitCatalog.cs` to register the item, force a Godot
reimport, spawn `ClothingTestBed` via `tools/game.sh`, click through Idle/Walk/Dance,
and eyeball the result.
The lane is PROVEN — the downloaded-dress pilot (2026-07-30) and the MD-authored kapa
haka set (pari + piupiu, 2026-07-31) are in game. But both kapa haka pieces shipped with
**pose-dependent defects invisible to every QA artifact the pipeline produces**: the pari
neckline gapes open in walk/dance, and a thigh punches through the piupiu skirt. All
pipeline QA renders are rest pose — the one pose that cannot fail. A separate agent owns
those two weight fixes; **this document is the pipeline-level response**: an assessment,
a design that combines the three halves into one entry point, and the QC gates that
would have caught both bugs before the game.
Source of truth for how the lane works *today*: `.agents/wiki/architecture/clothing-lane.md`
and `clothing/README.md`. This document is a proposal for how it *should* work; on
adoption, fold the outcome into `clothing-lane.md`.
---
## 1. Assessment
### 1.1 What already works (keep it)
- **Config-driven downstream.** New garment = new JSON in `clothing/configs/`, not new
code. The schema already expresses island→part mapping, bodyshell parts, alignment,
fit masks, weld bands, proxies, weight modes, textures, per-part tri budgets.
- **Checkpointed stages.** `00_census.blend … 50_skin.blend` mean any stage can be
re-run in isolation; resume semantics exist for free.
- **Determinism fingerprint.** `export` prints a SHA1 over all garment vertices
(`garment_pipeline.py:984-989`).
- **The seam is crossed.** MD-authored garments arrive at game budget, pre-fitted to
`Ariki_Female_QuatSkin.glb``reduce` is only hard for downloaded meshes.
- **All the automation hooks exist on the game side.** `game.sh spawn` (multi-instance,
`SPAWN_BUILD=1`, `MOCK_ONLY=1`), agent API (`/health /screenshot /navigate /state
/scene /ui /command /input /console`), `game.sh clean-import`, `tools/asset_pipeline.py
check`.
### 1.2 What costs time (the streamlining targets)
| Cost | Evidence |
|---|---|
| 4× duplicated MD garment scripts | `md_pari.py`, `md_piupiu.py`, `md_tee_v1.py`, `md_skirt_v1.py` share a ~100% identical shell (NewProject → ImportFBX at `op.scale=10.0` → fabric → arrange → simulate → snapshot); only panels/seams differ |
| Config copy-paste | `piupiu.json` vs `piupiu_sb.json`: 48 lines, differing in exactly 4 values (`name`, `body`, `weights`, `export.set`) + a note |
| Six manual CLI invocations per garment | one `blender --background … --stage <s>` per stage, run by hand in order |
| Hand-authored configs | read `census.json`, guess island→part mapping, trial-and-error alignment against QA renders |
| Code-edit registration | 2 `Add()` lines + possibly a `BaseDirFor` case in `OutfitCatalog.cs`, then a C# rebuild |
| Blunt reimport | `.bin`-only changes are invisible to Godot's `.gltf` hash; today's fix is `clean-import` (wipes ALL of `.godot/imported/`) or hand-deleting entries |
| Eyeball sign-off | spawn test bed, click Idle/Walk/Dance, look |
### 1.3 What is structurally unsafe
1. **Rest-pose-only QA.** Every `qa_*.png` the pipeline writes is the fitting pose.
Both shipped bugs were invisible in it. (`clothing/README.md` "QA renders only prove
the pose that cannot fail".)
2. **Silent catalog fallback.** `OutfitCatalog.BaseDirFor` (`OutfitCatalog.cs:88-98`)
takes a bare-string `setId` and its `_ =>` arm silently resolves to the Fantasy pack
folder — a typo'd set name mis-resolves without error. Missing assets are warn-only
(`OutfitCatalog.cs:71-78`): the character just stays naked/previous.
3. **`.bin` staleness.** `GLTF_SEPARATE` export keeps geometry/UVs in the sidecar
`.bin`; Godot hashes only the `.gltf`, so a vertex-only re-export silently ships the
old mesh.
### 1.4 Corrections to the record (verified 2026-07-31)
Earlier session notes contained four errors that materially change the design:
| Prior claim | Truth | Consequence |
|---|---|---|
| "FBX export from MD is manual" | `export_api.ExportFBX(path, op)` (+ `ExportOBJ/ExportZPrj/ExportZPac`) is scriptable — exact flags in `.claude/skills/marvelous-designer/SKILL.md:182-190` (`op.bExportGarment=True; op.bExportAvatar=False`); the 4 garment scripts simply never call it | The **entire MD half can run unattended** after the one session click |
| `ExportSnapshot3D` is the mesh export | It is the viewport **PNG** (the vision-QC loop); mesh export is `ExportFBX` | Don't build on the wrong call |
| "5 duplicated MD scripts" | 4 garment scripts; `md_recon.py` is an API-introspection tool and stays standalone | Template scope |
| `tools/vision-compare.py` exists (referenced by `ariki-game/tools/visual-qa.sh`) | **It was never written** | The in-game gate has no judge today; VLM-vs-pixel-diff is a real decision, not wiring |
One permanent constraint, confirmed: **MD exposes no mesh introspection to Python**
(`GetClothPositions()` stays empty). Every upstream drape judgement is image-based,
by necessity, until CLO exposes more.
Two enabling facts: the config's `export` block already carries `gender`/`set`/`out_dir`
and each part carries `slot`, so the catalog's exact `{Gender}_{Set}_{Slot}.gltf` path is
derivable from the config today; and the agent API's `GET /console` surfaces
`ClothingTestBed`'s per-slot load-failure prints, so **outfit load failure is
machine-detectable without vision**.
---
## 2. The unified pipeline
### 2.1 One config, additive blocks
Keep `clothing/configs/<garment>.json` as the single per-garment artifact and **grow it**
— the existing Blender schema stays byte-compatible, so `garment_pipeline.py` needs no
migration:
```jsonc
{
"extends": "piupiu.json", // NEW: overlay inheritance
"name": "...", "source": "...", "body": "...",
"md": { // NEW: upstream draft params (what the 4 scripts differ on)
"avatar_fbx": "...", "zfab": "...", "texture": "...",
"panels": [...], "seams": [...], "arrangements": [...],
"sim_frames": 300, "strengthen": [...],
"export_basename": "lena_<garment>_v<N>"
},
"align": {...}, "parts": {...}, // UNCHANGED: existing Blender schema
"export": { "gender": "Female", "set": "Kapahaka", "out_dir": "..." }, // exists
"catalog": { // NEW: what Add() needs that nothing else holds
"id": "kapahaka_legs_f", "displayName": "Piupiu",
"charisma": 0.05, "workSpeed": 0.04, "category": "casual"
},
"expect": {...} // NEW: QC thresholds (§3)
}
```
- **`extends` is the cheapest real win**: `piupiu_sb.json` becomes ~8 lines. Overlay =
deep-merge child over parent, arrays replaced whole.
- **`md` block + one template script** `tools/tailor/draft_garment.py` replaces the 4
copy-paste scripts. The identical shell becomes the template; panels/seams/arrangements
come from the config. Hard-won MD lessons (mm units, +y up in 2D, whole-edge seam
indices, look up arrangement points by name, `SetArrangement` then
`ResetClothArrangement`) live in ONE place instead of four.
- **`catalog` block** carries only what the C# `Add(id, displayName, slot, slotSuffix,
charisma, workSpeed, category, set:, gender:)` signature needs and the pipeline has
never held; `slot`/`set`/`gender` derive from existing blocks.
### 2.2 One orchestrator
`clothing/garment.py <config> [--from <stage>] [--to <stage>] [--only <stage>]`
over one ordered stage list spanning all three worlds:
```
draft drape publish │ census prepare fit reduce bake skin export │ register import verify
└──── MD bridge ──────┘└──────── Blender headless ────────────────┘└──── ariki-game ─────┘
```
**A driver, not a rewrite.** Each stage shells out to the tool that already owns it:
| Stage group | Shells to | Notes |
|---|---|---|
| `draft/drape/publish` | `python tools/md_bridge.py --file <generated>` | Script generated from the `md` block via `draft_garment.py`. `--ping` first; if no session, fail fast printing the human instruction ("click Plugin > TinqsMDBridge"). Raise the idle timeout (~1 min per 300 sim frames). `publish` = scripted `ExportZPrj` + `ExportFBX` + `ExportZPac` (§1.4). |
| `census … export` | `blender --background --python garment_pipeline.py -- --config X --stage Y` | **Unchanged CLI**; `--from/--to` is a loop. Standalone stage runs keep working exactly as today. |
| `register/import/verify` | `bash ariki-game/tools/game.sh …` + agent API | §2.4 and gates G6/G7/G8. Decided: orchestrator lives in `tinqs/animation/clothing/`, cross-repo shell-out to `game.sh` is acceptable. |
Checkpoint numbering (`00_ … 50_`) already gives resume: `--from fit` loads
`10_prepare.blend` exactly as today.
### 2.3 The automation boundary (permanent, human-only)
1. **Session start is a click** — Plugin > TinqsMDBridge, once per MD session (not per
garment). The orchestrator detects (`--ping`) and instructs; it can never perform it.
2. **MD's UI freezes during a blocking call** — no mid-drape supervision; results are
visible only after `Simulate` returns.
3. **No mesh introspection in MD** — upstream QC is image-based forever (§1.4).
Everything else — including the FBX export previously believed manual — is scriptable.
That is the headline of this assessment.
### 2.4 Registration: emit, don't edit (catalog JSON deferred)
Decided 2026-07-31: **defer** the data-driven OutfitCatalog refactor.
- Cost today is 2 `Add()` lines per set (+1 `BaseDirFor` case when a new folder
appears); the refactor is not small (`Initialize()` is all hardcoded calls;
`ClothingItem` has 10 init-only properties to round-trip). Revisit at ~10 sets.
- The real risk is not typing, it is the **silent** `setId` fallback (§1.3) — gate G8
(a ~30-line lint) buys that safety without the refactor.
- The `register` stage therefore **emits** the exact `Add(...)` line(s) and any needed
`BaseDirFor` case to stdout + `work/<name>/register.cs.txt` for paste-in.
Codegen-as-text, not codegen-as-edit — honest about who owns `src/`.
---
## 3. QC gates (where agents/checks slot in)
Ranked by value = known-pain-caught ÷ effort. Both shipped bugs were pose-dependent, so
the ranking is driven by *how early a pose-dependent failure can be caught*. Thresholds
live in the config's `expect` block.
| # | Gate | After stage | Kind |
|---|---|---|---|
| **G5** | **Posed penetration + gape sweep — the centrepiece** | `skin` | scriptable |
| G6 | In-game motion QC | `import` | agent-with-vision (hard part scriptable) |
| G3 | Rest-pose penetration | `fit` | scriptable |
| G2 | Census sanity | `census` | scriptable |
| G8 | Catalog ↔ asset lint | `register` | scriptable |
| G7 | Targeted reimport verify | `import` | scriptable |
| G4 | Reduce budget + silhouette | `reduce` | scriptable |
| G1 | Drape placement | `drape` | hybrid |
**G5 — posed penetration + gape sweep.** Headless Blender loads `50_skin.blend` (it
already contains `BODY`, `RIG`, and the skinned `GARM_*` parts). Apply real clip poses —
the UAL animation packs (`ariki-game/assets/quaternius/anim/UAL1.glb` etc.) are authored
on the same 65-bone skeleton — or synthetic extremes (arm raise, deep step, torso twist).
Per sampled frame, evaluate the armature-deformed meshes and BVH-test: (i) garment verts
inside the body, (ii) body verts exposed inside a part's declared coverage band.
Pass/fail: `expect.max_penetrating_verts` (e.g. 0 verts >1 mm inside) and
`expect.max_gape_verts` across **all** frames. This is the only gate that would have
caught BOTH shipped bugs — deterministically, headless, pre-game, re-runnable. One risk
to verify during implementation: bone-name compatibility between the UAL pack armature
and the derived-body `RIG` (retarget or pose-copy if names drift).
**G6 — in-game motion QC.** `SCENE=clothing_test_bed MOCK_ONLY=1 game.sh spawn` →
`POST /navigate {"button":"Idle"|"Walk"|"Dance"}` → `GET /screenshot` per clip → judge.
Two signals: **hard** = `GET /console` contains no outfit load error (machine-checkable
today); **soft** = visual verdict on poke-through/gaping/texture. Decided 2026-07-31:
**VLM/agent judge for a new garment's first sign-off, then bless those frames as the
baseline so re-runs become a deterministic pixel diff.** This also finally gives
`visual-qa.sh` the comparator it references but never had (§1.4). Small `ariki-game`
enabler needed (approved): `ClothingTestBed` hardcodes its opening set
(`DefaultBodyF = "kapahaka_body_f"` etc., `ClothingTestBed.cs:81-84`) — add an env-var/CLI
override so a new set is directly reachable instead of blind `Cycle Body` presses.
**G3 — rest-pose penetration.** The same BVH test as G5, rest pose only, run right after
`fit` — a cheap early-fail subset that saves reduce/bake/skin time on a bad fit.
**G2 — census sanity.** `census.json` already records per-island verts/tris/z/x/centroid.
Check against `expect.islands` (count, per-mapped-island vert count and z-span
tolerance). Catches the silent killer: an MD re-export reorders islands and the config
maps the wrong ones.
**G8 — catalog ↔ asset lint.** Every catalog entry's resolved
`{baseDir}/{Gender}_{Set}_{Slot}.gltf` exists on disk, and every outfit GLB on disk has a
catalog entry. Zero unmatched either way. ~30 lines; kills the silent `BaseDirFor`
fallback footgun (§1.3) without the JSON refactor.
**G7 — targeted reimport verify.** Replace blunt `clean-import` in the garment loop:
delete only that asset's two `.godot/imported/` entries (the README already prescribes
exactly this by hand), run `--import`, then confirm via `/console` + refreshed
`.gltf.import`. Closes the `.bin`-staleness hole (§1.3) without nuking the whole cache.
**G4 — reduce budget + silhouette.** Tris vs `parts[].tris`; pixel IoU of the HI vs
reduced QA renders ≥ threshold. Low urgency: MD-authored garments arrive at budget (both
kapa haka pieces never hit their ceilings) — this gate mostly guards downloaded meshes.
**G1 — drape placement.** The thresholds already exist as prose — `md_pari.py:4` reads
"QC targets: band top ~1.31 m (above bust), hem ~1.05 m (waist) ±3 cm". Move them into
`expect.bands`, upgrade `tools/tailor/qc_placement.py` to read them (instead of its
hardcoded landmark table), and add a vision pass on the `ExportSnapshot3D` PNG. That one
move takes drape QC from eyeballed to gated.
---
## 4. Phased adoption
| Phase | Content | Effort | Why this order |
|---|---|---|---|
| **1** | `extends` overlay + `expect` block + **G2** + **G8** + orchestrator skeleton driving *Blender stages only* | ~½ day | Zero new subsystems, no MD, no game, no human in the loop. Immediately kills the 48-line config copy and the six-call ritual. |
| **2** | `draft_garment.py` template + `md` block + scripted `ExportZPrj/FBX/ZPac` in the same bridge call + **G1** | ~1 day | Collapses 4 scripts to 1; MD half becomes unattended after the single session click. |
| **3** | **G5** posed sweep (+ **G3** as its rest-pose subset) | ~1 day | **The money phase** — catches the class of bug that has actually shipped, before the game. |
| **4** | **G7** targeted reimport + ClothingTestBed set override + **G6** motion QC + baseline blessing | ~1 day | Real shaders/import/AnimationTree; only meaningful after 13. Needs the small approved `ariki-game` edits. |
| **5** | Data-driven `OutfitCatalog` JSON | deferred | Revisit ~10 sets; G8 buys the safety now. |
G4 rides along whenever convenient (it rarely bites for MD-authored garments) rather
than blocking any phase.
---
## 5. Resolved decisions (Jeremy, 2026-07-31)
1. **Orchestrator home:** `tinqs/animation/clothing/garment.py`; game stages shell out
cross-repo to `ariki-game/tools/game.sh`.
2. **G6 judge:** VLM/agent vision judge for first sign-off; bless those frames; re-runs
are deterministic pixel diffs.
3. **OutfitCatalog:** stay code-edit; pipeline emits `Add()` lines; add the G8 lint;
JSON refactor deferred (~10 sets).
4. **`ariki-game/src` edits:** small enablers approved (ClothingTestBed set override,
later testbed hooks). The pari/piupiu weight-fix agent owns `garment_pipeline.py`'s
skin stage + those two configs — coordinate before touching either.
## 6. Leave alone
- `garment_pipeline.py`'s `--stage` CLI and config schema — additive only; the
orchestrator wraps, never rewrites.
- The `skin` stage internals and `pari.json`/`piupiu.json` weight fields — owned by the
weight-fix agent right now.
- `md_recon.py` — introspection tool, not a garment script; does not fold into the template.
- `.agents/wiki/architecture/clothing-lane.md` — cite it; fold outcomes in on adoption;
never fork its content.
## 7. Appendix — file/line references
| Fact | Where |
|---|---|
| Stage implementations | `clothing/garment_pipeline.py`: census 224248, prepare 295505, fit 542598, reduce 653706, bake 712768, skin 908957 (weight modes 920944, skirt_bones 836905), export 964989 (vertex SHA1 984989) |
| Config parse / work dir / CLI | `garment_pipeline.py:1006`, `:36-39`, `:994-1008` |
| MD bridge protocol/session | `tools/md_bridge.py` (port/env `:88`, `:22`; run/ping `:58-68`), `docs/md-bridge.md:8-94` |
| Scriptable MD exports (flags) | `.claude/skills/marvelous-designer/SKILL.md:182-190` |
| MD API lessons | `tools/tailor/md_tee_v1.py:12-28`, `md_skirt_v1.py:6-25` |
| Drape QC thresholds as prose | `tools/tailor/md_pari.py:4`; classifier `tools/tailor/qc_placement.py:24-34`, bands `:51-80` |
| Config diff piupiu vs piupiu_sb | `clothing/configs/piupiu.json` / `piupiu_sb.json` — `name`, `body` (`_SkirtRig`), `parts.Piupiu.weights`, `export.set` |
| Catalog path resolution + silent fallback | `ariki-game/src/Character/OutfitCatalog.cs:57-98` (warn-only 7178, `BaseDirFor` `_ =>` 9697), `Add()` pattern in `Initialize()` 116+ |
| Test bed buttons / defaults / load-fail HUD | `ariki-game/src/Testing/ClothingTestBed.cs:449-516`, `:81-84`, `:88-89` + `:279-281` |
| Spawn / engine / import / clean-import | `ariki-game/tools/game.sh:633-749`, `:39-70`, `:832-841` (+ asset check `:848`), `:478-482` |
| Agent API endpoints | `game.sh:215-242` (`/console` via AgentServer) |
| Visual QA harness (judge missing) | `ariki-game/tools/visual-qa.sh:1-77` — calls `tools/vision-compare.py`, which does not exist |
| Reimport gotcha (hand fix prescribed) | `clothing/README.md:143-147` |
| Reference logic in older converters | `ariki-game/tools/cc_clothing_to_quaternius.py` (skeleton-frame alignment 6673, KDTree copy 110127, A-pose check 198205), `make_islander_outfits.py` (region cuts 103149) |
## 8. Build outcome (2026-07-31)
Built in one session by five parallel agents plus an integration pass. As-built
interfaces are documented in `clothing/PIPELINE-CONTRACT.md` (marked AS-BUILT); the
one-command usage and gate table are in `clothing/README.md`.
| Lane | Delivered |
|---|---|
| A | `clothing/garment.py` — the orchestrator: `extends` resolution, deep-merge, path absolutization, 13 stages, gate scheduling, `register` codegen, `--selftest` |
| B | `gates/g2_census.py` (island census sanity), `gates/g8_catalog_lint.py` (catalog↔asset lint, `--all` / `--require-registered`) |
| C | `gates/g5_posed_sweep.py` — posed penetration + gape sweep; G3 is its `--rest` mode |
| D | `tools/tailor/draft_garment.py` (MD script generator), `tools/tailor/qc_placement.py`, `gates/g1_drape.py` |
| E | `ariki-game/tools/targeted_reimport.sh` (G7), `ariki-game/tools/clothing_motion_qa.sh` (G6), `ClothingTestBed.ReportLoad` |
### Acceptance test
`configs/tests/piupiu_sb_test.json` (scratch export target, set `KapahakaSBTest`) run
end to end, `census..export`, `--no-gate-stop`:
| Stage | Time | Gate |
|---|---|---|
| census | 8.0 s | **G2 PASS** — 1 island, 3620 v / 7104 tri, matched `expect.islands` |
| prepare | 4.7 s | — |
| fit | 3.3 s | **G3 FAIL** — 1 vert, 8.0 mm deep, at rest |
| reduce | 3.1 s | — |
| skin | 3.4 s | **G5 FAIL** — see below |
| export | 3.3 s | — |
**G5 reproduced the shipped piupiu bug headlessly**, in 14.9 s, from the real game
clips (`Idle_Loop` from UAL1, `Walk_Fwd_Loop` from UAL2; 65/65 pack bones matched
the RIG, the 16 skirt bones ride their parents):
```
rest 1 penetrating vert (max 8.0 mm)
Idle @0/12/25/38/50/62 229 217 216 231 216 216 (max 75.2 85.2 mm)
Walk @0/7/13/20/27/33 169 134 61 90 128 63 (max 70.0 87.9 mm)
```
That is the thigh punching through the skirt: ~1 vert visible in the pose the whole
pipeline used to sign off on, and **216231 verts up to 8.5 cm deep** the moment the
character stands still and breathes. 13 failing frames, with front + closeup QA renders
for six of them. The gate is worth its cost.
The **pari neckline gape did NOT reproduce** in the Blender checkpoint — it is not
present in the skinned mesh G5 measures. Its origin is therefore in-game (attach,
material, or LOD), which makes it G6 territory, not G5. Worth stating plainly because
the pre-build assumption was that both shipped defects were the same class of bug.
### Real findings the gates produced
- **G8** found an *uncommitted* `OutfitCatalog` registration hunk in the working tree —
a garment registered locally and never pushed, which the lint surfaced immediately.
On the test set it correctly reports `KapahakaSBTest` has no `BaseDirFor` arm and so
would silently resolve into the Fantasy pack folder.
- **G1** measured the *shipped* garments against their own written QC targets and found
both off: pari's hem sits **+3.9 cm** high, and the piupiu's bands **+8 cm / +14 cm**.
Those targets had lived as prose in the garment scripts' headers since they were
written; nobody had ever checked them against a render.
- **`/console` did not surface load failures at all** until `ClothingTestBed.ReportLoad`
was added — `GD.Print` does not feed the agent API's ring buffer. Every "the bed looks
fine" sign-off before that fix was reading an empty channel.
- **G6's pixel diff has a measured floor**: same outfit re-run moves ~0.002 of the frame,
an entirely different outfit moves 0.014, and by frame 3 phase drift alone (0.0095)
matches the signal. So G6 catches gross regressions (garment vanished, failed to load,
swapped) and nothing subtler. **G5 owns the subtle bug class** — which is the right
split, because G5 is headless, deterministic and 15 s.
### Still deferred
Catalog-as-JSON (§2.4) — `register` still emits `work/<name>/register.cs.txt` for a human
to paste, by design. Nothing in the build depends on changing that.
+105
View File
@@ -0,0 +1,105 @@
# Rig-graft lane — AccuRig skeleton onto pristine Tripo GLBs (PLAN, not executed)
**Repo home:** this plan and its tools moved from ariki-game into the animation repo
2026-08-06 — the lane authors characters, so `characters/REGISTRY.md` governs it (see
`.agents/wiki/ARCHITECTURE.md` "Which repo does a non-gameplay tool belong to"). Paths
below are animation-repo-relative unless prefixed `ariki-game/`.
**Status 2026-08-04: PLAN ONLY. Jeremy has explicitly held execution — no model
files are to be created or modified until the naming convention is decided.**
## Goal
Rig Ozlem's two unrigged Tripo bodies without the FBX→GLB quality loss the male
lane hit in July (mangled textures, bad hand skinning). The move: FBX is only a
**disposable rig carrier** through AccuRig; the **GLB is the sole source of truth**
for mesh + materials and only ever *gains* bones and weights.
## Source files (curated 2026-08-04)
Byte-identical copies live in both repos: `characters/originals/` here (checksummed in
`characters/REGISTRY.md`) and `ariki-game/assets/models/characters/race-sources/`. Read
from this repo's copies.
| file | verts | tris | height | feet @ Z0 | axis | textures |
|---|---|---|---|---|---|---|
| `female_lena_tripo.glb` | 974,478 | 1,907,931 | 0.979 m | yes (0.0) | upright, rot 0, scale 1 | 3 packed 4K (basecolor/normal/rm) |
| `female_lena_tripo.fbx` | 953,968 | 1,907,931 | 0.979 m | — | upright | 5 external JPEGs (.fbm removed; git-recoverable) |
| `male_base_bald_tripo_v1.glb` | 969,881 | 1,901,449 | 0.980 m | yes (0.0) | upright, rot 0, scale 1 | 3 packed 4K, clean |
| `male_base_bald_tripo_v1.fbx` | 950,729 | 1,901,449 | 0.980 m | — | **lying down (bad axis)** | 6 packed, all mislabeled "Diffuse Texture.NNN", duplicated basecolor |
Census facts that shape the plan:
- **No bones anywhere** — all four files are truly unrigged, so no rig standard is
imposed by the sources. The 65-bone Quaternius game standard is unaffected.
- **Identical surfaces per pair** (tri counts match exactly); vertex counts differ
~2% from format-specific UV-seam splitting → index-exact weight copy is off the
table, nearest-surface transfer is the mechanism (surfaces coincide, so it is
near-exact everywhere except close-packed fingers — see step 4).
- The male FBX's axis + texture mangling is the July failure mode reproduced in
data — never source visual data from FBX.
## Decisions (Jeremy, 2026-08-04)
1. **NO scaling to standard height.** Bodies stay at Tripo's native ~0.98 m.
(Any scale handling happens later, downstream, not in this lane.)
2. **Axis check + ground the feet** — wanted, and the probe shows both GLBs
already pass (rot 0 / scale 1 / min-Z exactly 0.0). These become verify-only
gates, mutating nothing unless a future source fails them.
3. **HOLD before decimation** until the naming convention for derived files is
agreed. Nothing below the line runs until then.
## The lane (each step gated on the one before)
1. **Verify** (read-only, DONE for the two current GLBs): upright axis, unit
scale, feet at Z=0, centered X, packed textures present.
2. **Rig bait** *(HELD — naming)*: from the GLB, decimate a disposable copy and
export FBX for AccuRig. **Confirmed 2026-08-04: AccuRig refuses the raw
1.9 M-tri FBX outright** (Jeremy tried; July hit the same wall). Use the
`ariki-game/tools/male_mesh_decimate.py` precedent: region budgets body 24k / head 14k /
**hands 10k as their own protected region** (global-ratio decimation webs the
fingers — half of the historic hand-mangling happened here, pre-AccuRig).
Apply the July male-lane traps: Dummy-export quirk, the 4 cm offset.
No scale change on the GLB — but if AccuRig misplaces joints on a 0.98 m
body, scale the DISPOSABLE BAIT up 2× and scale the returned skeleton back
down in the graft; the GLB never changes size.
3. **AccuRig** (manual, Jeremy/Ozlem): rig the bait FBX. Export rigged FBX.
4. **Graft** (headless Blender): import rigged bait FBX + pristine GLB; snap rest
poses; transfer skeleton + skin weights decimated→full-res by nearest-surface
with tight max-distance; **hands get special handling** (per-finger masked
transfer or reduced distance + limit-totals + normalize) — this is where the
old hand mangling gets fixed. Export GLB: original mesh + original packed
textures + new rig. FBX artifacts are discarded.
5. **QC**: pose sweep incl. finger curls; then (if/when destined for game) the
existing AccuRig→Quaternius conversion (`ariki-game/tools/make_male_ib_quatskin_accurig.py`
lineage) picks it up.
## Naming + step 2 status (RESOLVED 2026-08-04, later same day)
Jeremy raised the hands budget and released the bait step. Built with
`tools/rigbait_decimate.py` (parameterized successor to male_mesh_decimate.py —
no scale/recenter, bbox-relative region cuts, budgets body 24k / head 14k /
**hands 40k**):
- `characters/rig-work/lena_tripo_rigbait.fbx` — 77,999 tris, QA'd
- `characters/rig-work/mako_tripo_rigbait.fbx` — 78,000 tris, QA'd
- `characters/rig-work/` is exempt from the naming grammar (REGISTRY.md rule 11) —
disposable carriers, no registry rows. In the game repo it had needed a `.gdignore`
to stop Godot importing it, which was the tell that it lived in the wrong repo.
- QA renders alongside (`*_qa_front.png`, `*_qa_hand.png`) — fingers fully
distinct at 40k, no webbing.
- Baits are geometry-only (~3.5 MB; the GLB's packed textures don't survive FBX
embed) — AccuRig shows a grey model, which is fine for rigging. It does mean
the July "Dummy001 export" check can't use textures: **verify AccuRig's output
by mesh name + ~78k tri count instead.**
**AccuRig exports must be saved as** (same folder):
- `characters/rig-work/lena_tripo_accurig.fbx`
- `characters/rig-work/mako_tripo_accurig.fbx`
## Open before execution
- Step 3 (AccuRig) — manual, waiting on Jeremy/Ozlem.
- Step 4 graft script — build once a rigged FBX exists to test against.
- Whether to restore the female FBX's `.fbm` texture folder from git (only needed
if the FBX is ever used for more than rigging; the lane says it shouldn't be).
+1
View File
@@ -8,6 +8,7 @@ This repo's operator playbooks are Claude-Code-native skills and live at
| `animation` | Operator playbook — the batch workflow, naming, loop QC, gotchas |
| `animation-creation` | Authoring new clips on the shared Quaternius skeleton |
| `iclone-video-mocap` | iClone 8 + Video Mocap: filming, cleanup, FBX export |
| `marvelous-designer` | Garment authoring: MD bridge, drafting from a reference image, draping on the game body, placement QC, export |
| `pose-estimation` | Video→pose-landmark extraction (MediaPipe) |
| `retarget-animations` | Deprecated early retarget notes, kept for history |
+48 -4
View File
@@ -3,6 +3,10 @@
Entry point to architecture law for this repo. Per-system detail lives in
`.agents/wiki/architecture/`.
This file covers the **animation lane** (motion → clips). The repo also runs a
**clothing lane** (garments → worn outfits) that targets the same skeleton —
see `.agents/wiki/architecture/clothing-lane.md`.
## The one-sentence shape
PC-side motion capture (iClone 8 + Video Mocap) → this repo (Mac↔PC bridge,
@@ -78,13 +82,53 @@ in `dancegen/` only.
`tools/cc_retarget.py`, `mixamo_retarget.py`, `kevin_retarget.py`,
`mocap_retarget.py` are **mirrors of `ariki-game/tools/`** — the game copies are
authoritative; re-copy from ariki-game when they change (they have silently
diverged before). `.claude/skills/` here mirror a subset of `ariki-game/.claude/skills/`
plus `~/.claude/skills/pose-estimation` see `README.md` Provenance section.
diverged before; verified identical 2026-08-06). `.claude/skills/` here mirror a
subset of `ariki-game/.claude/skills/` plus `~/.claude/skills/pose-estimation`
see `README.md` Provenance section.
The **character/body lane is authoritative HERE and is not mirrored** (moved out
of ariki-game 2026-08-06): `tools/rigbait_decimate.py`,
`make_lena_nude_body.py`, `_bake_nude_body_texture.py`,
`_render_body_closeup.py`, `verify_body_variant.py`. Anything that authors a
character mesh, rig, or body texture is born here from now on, governed by
`characters/REGISTRY.md`. The game repo keeps only the historical committed
generators (`make_lena_body.py`, `make_male_ib_quatskin_accurig.py`,
`male_mesh_decimate.py`, the `_convert_lena_quat_v*` series): they are cited by
game-side plans and some carry game-side tests.
### Which repo does a non-gameplay tool belong to
Decided by **subject, not by "is it gameplay"** — nothing in `ariki-game/tools/`
is gameplay (no `.gd` runtime script references it at all), so that test would
empty the folder into this one. Three questions, in order:
1. Does the engine, CI, or the game's own test suite run it? → **stays in
ariki-game.** `asset_pipeline.py`, `game.sh`, `session.py`,
`e2e_interaction.py`, `anim_qc.py`, `rig_pose_gate.py`,
`targeted_reimport.sh`, `clothing_motion_qa.sh`, and every module a
`tools/test_*.py` imports.
2. Does it author characters or motion? → **here.**
3. Neither? → it is **game-asset authoring** (trees, terrain, water, props,
items, animals, VFX). Not gameplay, but not animation either — leave it there
rather than making this repo a dumping ground.
Two things that keep tripping this up: some character tools have game-side tests
(`brow_cover_math.py`, `generate_lena_brow_surface.py`, `test_mako_rig_math.py`),
so they move with their tests or not at all — and **this repo has no test
harness**, so receiving them means standing one up. And `anim_qc.py` staying is a
deliberate split, not drift (see the two-QC-tools note above).
**Cross-repo paths in tools that moved here:** resolve the game checkout from
`$ARIKI_GAME`, else guess the sibling directory and *verify it*, aborting with the
path tried. Never let an ariki-relative default resolve silently wrong — same rule
as `--target` above.
## See also
- `.agents/wiki/architecture/` — per-system detail (currently: this file covers
the whole pipeline; split out if a subsystem grows its own doc).
- `.agents/wiki/architecture/clothing-lane.md` — the clothing lane (reference
image → Marvelous Designer → `clothing/` → worn outfit in-game).
- `.agents/wiki/architecture/` — per-system detail (split a subsystem out when
it grows its own doc).
- `.agents/wiki/dances/REGISTRY.md` — naming/registry source of truth.
- `.agents/wiki/iclone-bridge.md` — PC-lane routing stub.
- `.agents/wiki/devops-reports/` — point-in-time audits and convergence reports.
+7 -4
View File
@@ -1,9 +1,12 @@
# architecture/ — per-system detail
This repo's pipeline is small enough that `.agents/wiki/ARCHITECTURE.md` covers
it end to end today; nothing has grown large enough yet to need its own
per-system doc here. Split a topic out into this folder (and link it from
`ARCHITECTURE.md`) when it does — likely candidates as the repo grows:
| Page | Covers |
|---|---|
| `clothing-lane.md` | The clothing lane end to end: reference image → Marvelous Designer authoring → `clothing/` game-ification → worn outfit in ariki-game. Read before any garment work. |
`.agents/wiki/ARCHITECTURE.md` still covers the **animation** lane end to end.
Split a further topic out into this folder (and link it from `ARCHITECTURE.md`)
when it grows — likely candidates:
- Retarget-tool internals (per-source rig mapping, bone-name conventions).
- Loop QC/fix algorithms (pose-gap, velocity-gap, root-drift math).
+22
View File
@@ -3,3 +3,25 @@
*.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
# 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
+8
View File
@@ -13,3 +13,11 @@ __pycache__/
**/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/
+155
View File
@@ -0,0 +1,155 @@
[BoneMap]
pelvis = Hips
spine_01 = Spine
spine_02 = Spine1
spine_03 = Spine2
clavicle_l = LeftShoulder
upperarm_l = LeftArm
lowerarm_l = LeftForeArm
hand_l = LeftHand
index_01_l = LeftHandIndex1
index_02_l = LeftHandIndex2
index_03_l = LeftHandIndex3
middle_01_l = LeftHandMiddle1
middle_02_l = LeftHandMiddle2
middle_03_l = LeftHandMiddle3
pinky_01_l = LeftHandPinky1
pinky_02_l = LeftHandPinky2
pinky_03_l = LeftHandPinky3
ring_01_l = LeftHandRing1
ring_02_l = LeftHandRing2
ring_03_l = LeftHandRing3
thumb_01_l = LeftHandThumb1
thumb_02_l = LeftHandThumb2
thumb_03_l = LeftHandThumb3
lowerarm_twist_01_l = LeftForeArmRoll
clavicle_r = RightShoulder
upperarm_r = RightArm
lowerarm_r = RightForeArm
hand_r = RightHand
index_01_r = RightHandIndex1
index_02_r = RightHandIndex2
index_03_r = RightHandIndex3
middle_01_r = RightHandMiddle1
middle_02_r = RightHandMiddle2
middle_03_r = RightHandMiddle3
pinky_01_r = RightHandPinky1
pinky_02_r = RightHandPinky2
pinky_03_r = RightHandPinky3
ring_01_r = RightHandRing1
ring_02_r = RightHandRing2
ring_03_r = RightHandRing3
thumb_01_r = RightHandThumb1
thumb_02_r = RightHandThumb2
thumb_03_r = RightHandThumb3
lowerarm_twist_01_r = RightForeArmRoll
neck_01 = Neck
head = Head
thigh_l = LeftUpLeg
calf_l = LeftLeg
calf_twist_01_l = LeftLegRoll
foot_l = LeftFoot
ball_l = LeftToeBase
thigh_twist_01_l = LeftUpLegRoll
thigh_r = RightUpLeg
calf_r = RightLeg
calf_twist_01_r = RightLegRoll
foot_r = RightFoot
ball_r = RightToeBase
thigh_twist_01_r = RightUpLegRoll
[BoneRotate]
Armature = 0.,0.,0.,1.,
Head = -0.07867400663,0.,0.,0.9969003966,
Mannequin = 0.,0.,0.,1.,
ball_l = 0.000137174795,-0.9643067668,0.2647870677,0.0004994245233,
ball_leaf_l = -1.490116119e-08,-2.043089076e-08,0.,1.,
ball_leaf_r = -1.490116119e-08,-2.043089076e-08,0.,1.,
ball_r = 0.000137174795,-0.9643067668,0.2647870677,0.0004994245233,
calf_l = 0.03658974033,-0.0001311736116,-4.79783824e-06,0.9993303626,
calf_r = 0.03658974033,-0.0001311732359,-4.797521626e-06,0.9993303626,
clavicle_l = -0.6040205276,-0.3451028898,-0.3567176515,0.6235508919,
clavicle_r = -0.6040205276,0.3451028898,0.3567176515,0.6235508919,
foot_l = -0.5290732885,-0.0003280904003,0.0003434501791,0.8485760012,
foot_r = -0.529073238,-0.000328094934,0.000343457957,0.8485760327,
hand_l = -0.008619738169,2.055116454e-09,2.384097373e-07,0.9999628494,
hand_r = -0.008619738169,-2.055116454e-09,-2.384097373e-07,0.9999628494,
index_01_l = 2.374276773e-07,0.7071042257,-2.374259611e-07,0.7071093367,
index_01_r = 2.79574658e-07,-0.7071042257,2.795726372e-07,0.7071093367,
index_02_l = 0.,-1.192095169e-07,0.,1.,
index_02_r = 0.,1.192092896e-07,0.,1.,
index_03_l = 0.,0.,0.,1.,
index_03_r = 0.,0.,0.,1.,
index_04_leaf_l = 0.,1.,0.,3.651776586e-06,
index_04_leaf_r = 0.,-1.,0.,3.651776586e-06,
lowerarm_l = 0.01718220495,-2.046332276e-05,4.431598459e-07,0.9998523748,
lowerarm_r = 0.01718226455,2.052291489e-05,-5.037899558e-07,0.9998523738,
middle_01_l = -0.01670215415,0.7069121606,-0.01670274404,0.706906821,
middle_01_r = -0.01670211201,-0.7069121596,0.01670278618,0.706906822,
middle_02_l = -1.183111765e-08,-1.192076729e-07,0.001513598491,0.9999988545,
middle_02_r = -1.092088705e-08,1.192090663e-07,-0.001513598491,0.9999988545,
middle_03_l = 7.981403618e-09,0.,-0.001129686941,0.9999993619,
middle_03_r = 7.981398288e-09,0.,0.001129686941,0.9999993619,
middle_04_leaf_l = 8.381904948e-09,1.,0.,3.651776586e-06,
middle_04_leaf_r = 9.313226634e-09,-1.,0.,3.651776586e-06,
neck_01 = 0.1109859382,0.,0.,0.9938219768,
pelvis = 0.7904686183,0.,0.,0.6125025416,
pinky_01_l = -0.02370575696,0.7067118885,-0.02370634669,0.7067066951,
pinky_01_r = -0.02370567272,-0.7067118885,0.02370643093,0.7067066951,
pinky_02_l = 5.844320298e-09,-1.192093468e-07,-0.00083428664,0.999999652,
pinky_02_r = 5.574940833e-09,1.192095693e-07,0.0008342270354,0.999999652,
pinky_03_l = -4.306290348e-09,0.,0.0006095209014,0.9999998142,
pinky_03_r = -4.30592264e-09,0.,-0.0006094612968,0.9999998143,
pinky_04_leaf_l = 2.235174179e-08,1.,0.,3.651776586e-06,
pinky_04_leaf_r = 2.235174534e-08,-1.,0.,3.651776586e-06,
ring_01_l = -0.0125884143,0.7069972761,-0.01258900426,0.7069921501,
ring_01_r = -0.01258837216,-0.7069972754,0.0125890464,0.7069921509,
ring_02_l = -8.65591156e-10,-1.192091509e-07,1.221615912e-05,0.9999999999,
ring_02_r = 7.37720217e-10,1.19209174e-07,-1.221615912e-05,0.9999999999,
ring_03_l = 7.539233332e-09,0.,-0.001067100938,0.9999994306,
ring_03_r = 7.539231556e-09,0.,0.001067100938,0.9999994306,
ring_04_leaf_l = 9.313225745e-10,1.,0.,3.651776586e-06,
ring_04_leaf_r = 3.725291187e-09,-1.,0.,3.651776586e-06,
root = 0.,0.,0.,1.,
spine_01 = -0.06470268379,0.,0.,0.997904586,
spine_02 = -0.07727999146,0.,0.,0.9970094297,
spine_03 = -0.0002686381599,0.,0.,0.9999999639,
thigh_l = 0.99248421,0.,0.,0.1223727621,
thigh_r = 0.99248421,9.236081374e-09,-1.138763077e-09,0.1223727621,
thumb_01_l = 0.2474131752,0.9457944096,0.203441308,0.05358441736,
thumb_01_r = 0.247413226,-0.9457943736,-0.2034414034,0.0535844553,
thumb_02_l = -0.0001358743711,-7.410041679e-05,4.796071313e-05,0.9999999869,
thumb_02_r = -0.0001356769347,7.408892685e-05,-4.791442513e-05,0.9999999869,
thumb_03_l = 0.0002479590938,8.092956012e-05,-0.0005353075979,0.9999998227,
thumb_03_r = 0.000247953548,-8.101040989e-05,0.0005352930074,0.9999998227,
thumb_04_leaf_l = 2.375739504e-08,0.5061779195,2.751002055e-07,0.8624290776,
thumb_04_leaf_r = 2.159766835e-08,-0.5061777653,-2.770237225e-07,0.8624291681,
upperarm_l = 0.1802646081,0.6838513444,-0.1798313456,0.6837490014,
upperarm_r = 0.1802662816,-0.6838508941,0.1798330142,0.6837485718,
[FloorContact]
HandBottom = 2.769418716
HandBack = 0.8673477173
HandMiddle = 11.10624313
HandFront = 12.55753326
HandIn = 5.706539154
HandOut = 5.25942421
FootBottom = 13.2835741
FootBack = 6.798443794
FootMiddle = 15.87928581
FootFront = 6.717291832
FootIn = 5.117822647
FootOut = 6.936565399
[Property]
AnkleHeight = 9.38811779
AnkleSpacing = 10.03706551
HipsForward = -15.
AutoAnkleHeight = true
AutoAnkleSpacing = true
RollExtractionMode = false
[RootTransform]
Value = 1.,1.,1.,1.,0.,0.,0.,0.9999999404,0.,0.,0.,1.,0.,0.,0.,
+183
View File
@@ -0,0 +1,183 @@
[BoneMap]
pelvis = Hips
spine_01 = Spine
spine_02 = Spine1
spine_03 = Spine2
clavicle_l = LeftShoulder
upperarm_l = LeftArm
lowerarm_l = LeftForeArm
hand_l = LeftHand
index_01_l = LeftHandIndex1
index_02_l = LeftHandIndex2
index_03_l = LeftHandIndex3
middle_01_l = LeftHandMiddle1
middle_02_l = LeftHandMiddle2
middle_03_l = LeftHandMiddle3
pinky_01_l = LeftHandPinky1
pinky_02_l = LeftHandPinky2
pinky_03_l = LeftHandPinky3
ring_01_l = LeftHandRing1
ring_02_l = LeftHandRing2
ring_03_l = LeftHandRing3
thumb_01_l = LeftHandThumb1
thumb_02_l = LeftHandThumb2
thumb_03_l = LeftHandThumb3
lowerarm_twist_01_l = LeftForeArmRoll
clavicle_r = RightShoulder
upperarm_r = RightArm
lowerarm_r = RightForeArm
hand_r = RightHand
index_01_r = RightHandIndex1
index_02_r = RightHandIndex2
index_03_r = RightHandIndex3
middle_01_r = RightHandMiddle1
middle_02_r = RightHandMiddle2
middle_03_r = RightHandMiddle3
pinky_01_r = RightHandPinky1
pinky_02_r = RightHandPinky2
pinky_03_r = RightHandPinky3
ring_01_r = RightHandRing1
ring_02_r = RightHandRing2
ring_03_r = RightHandRing3
thumb_01_r = RightHandThumb1
thumb_02_r = RightHandThumb2
thumb_03_r = RightHandThumb3
lowerarm_twist_01_r = RightForeArmRoll
neck_01 = Neck
Head = Head
thigh_l = LeftUpLeg
calf_l = LeftLeg
calf_twist_01_l = LeftLegRoll
foot_l = LeftFoot
ball_l = LeftToeBase
thigh_twist_01_l = LeftUpLegRoll
thigh_r = RightUpLeg
calf_r = RightLeg
calf_twist_01_r = RightLegRoll
foot_r = RightFoot
ball_r = RightToeBase
thigh_twist_01_r = RightUpLegRoll
[BoneRotate]
RootNode(0) = 0.,0.,0.,1.,
ThirdPersonCharacter_167 = 0.,0.,-0.7071066499,0.7071068883,
CharacterMesh0 = 0.,0.,0.7071065903,0.7071069479,
root = 0.,0.,0.,1.,
pelvis = 0.,0.7071067691,0.,-0.7071067691,
spine_01 = 0.,0.,-6.238857657e-002,0.9980519414,
spine_02 = 0.,0.,0.1224197969,0.9924783707,
spine_03 = 0.,0.,2.425260469e-002,0.9997058511,
clavicle_l = 0.2175616771,0.6793626547,0.1070207208,0.692589283,
upperarm_l = 4.885814339e-002,-1.803681627e-002,-0.233969152,0.9708480239,
lowerarm_l = -7.146691531e-002,-1.314506307e-002,-1.677454822e-002,0.997215271,
hand_l = -0.6664974093,3.67404297e-002,-4.807422683e-002,0.7430478334,
index_01_l = 0.1349626333,-6.637491286e-002,4.533420503e-002,0.9875848889,
index_02_l = 1.228160132e-002,-1.628758386e-003,-2.582049929e-004,0.9999231696,
index_03_l = 1.123972051e-002,6.921002176e-003,2.099514008e-003,0.9999106526,
middle_01_l = 3.692238033e-002,-5.181602761e-002,4.584874585e-002,0.99691993,
middle_02_l = -1.943795197e-002,5.723292008e-003,-1.066895761e-002,0.9997378588,
middle_03_l = -3.62048531e-003,-3.873198852e-002,7.702498697e-004,0.9992428422,
pinky_01_l = -0.1161660478,-6.177603826e-002,4.257363081e-002,0.9903921485,
pinky_02_l = 1.135612186e-002,-9.435054846e-003,-1.869967673e-003,0.9998892546,
pinky_03_l = 5.186018068e-003,3.358753026e-002,5.650337413e-002,0.9978237748,
ring_01_l = -8.321698755e-002,-6.469994783e-002,3.718987107e-002,0.9937332273,
ring_02_l = 6.980994716e-003,8.726322092e-003,-6.09222152e-005,0.9999375343,
ring_03_l = 1.812815899e-003,2.61958465e-002,-3.580457717e-002,0.9990138412,
thumb_01_l = 0.5882545114,0.265966773,-0.135181129,0.7516279221,
thumb_02_l = 2.229750156e-002,-6.388775259e-002,0.1282432675,0.98943156,
thumb_03_l = 2.14139428e-002,-1.706168172e-003,5.907326192e-002,0.9980224967,
RL_L_Hand01_Floor01 = 0.,0.,0.,1.,
RL_L_Hand01_Floor02 = 0.,0.,0.,1.,
RL_L_Hand01_Floor03 = 0.,0.,0.,1.,
RL_L_Hand01_Floor04 = 0.,0.,0.,1.,
RL_L_Hand01_Floor05 = 0.,0.,0.,1.,
RL_L_Hand01_Floor06 = 0.,0.,0.,1.,
lowerarm_twist_01_l = 0.,0.,0.,1.,
upperarm_twist_01_l = 0.,0.,0.,1.,
clavicle_r = -0.6798773408,0.2174819708,0.6920840144,-0.107182622,
upperarm_r = 4.860173911e-002,-1.798832975e-002,-0.2336735427,0.9709330797,
lowerarm_r = -7.170052826e-002,-1.280183531e-002,-1.666365191e-002,0.9972048402,
hand_r = -0.6661629677,3.690450266e-002,-4.773123935e-002,0.7433617115,
index_01_r = 0.1352881044,-6.594806165e-002,4.57360819e-002,0.9875506163,
index_02_r = 1.221698243e-002,-1.745244022e-003,2.132323789e-005,0.9999238253,
index_03_r = 1.133191865e-002,7.000599988e-003,1.665975666e-003,0.9999098778,
middle_01_r = 3.718136624e-002,-5.157186091e-002,4.625619203e-002,0.9969043136,
middle_02_r = -1.91414915e-002,5.435747094e-003,-1.036943309e-002,0.99974823,
middle_03_r = -3.454518039e-003,-3.839058429e-002,7.380198804e-004,0.9992565513,
pinky_01_r = -0.1161327064,-6.213930994e-002,4.253363237e-002,0.990375042,
pinky_02_r = 1.132710464e-002,-9.618380107e-003,-1.636284287e-003,0.9998882413,
pinky_03_r = 5.874346476e-003,3.336748108e-002,3.290480375e-002,0.9988840818,
ring_01_r = -8.344670385e-002,-6.444695592e-002,3.724582493e-002,0.9937283397,
ring_02_r = 6.980994716e-003,8.726322092e-003,-6.09222152e-005,0.9999375343,
ring_03_r = 9.657530463e-004,2.585097915e-003,-3.577389941e-002,0.999356091,
thumb_01_r = 0.5880680084,0.266130507,-0.1352667361,0.7517004013,
thumb_02_r = 2.229461074e-002,-6.394065171e-002,0.1285541654,0.9893878698,
thumb_03_r = 2.10607145e-002,-1.37078797e-003,5.934789404e-002,0.9980142117,
RL_R_Hand01_Floor01 = 0.,0.,0.,1.,
RL_R_Hand01_Floor02 = 0.,0.,0.,1.,
RL_R_Hand01_Floor03 = 0.,0.,0.,1.,
RL_R_Hand01_Floor04 = 0.,0.,0.,1.,
RL_R_Hand01_Floor05 = 0.,0.,0.,1.,
RL_R_Hand01_Floor06 = 0.,0.,0.,1.,
lowerarm_twist_01_r = -0.1176272556,0.,0.,0.993057847,
upperarm_twist_01_r = -0.1732347608,0.,0.,0.9848805666,
neck_01 = 0.,0.,-0.2037104368,0.9790312052,
Head = 0.,0.,0.1335420161,0.99104321,
thigh_l = 7.410442084e-002,7.760480512e-004,1.044298802e-002,0.9971954823,
calf_l = -4.964926094e-002,6.01673685e-003,-1.868829131e-004,0.9987486005,
calf_twist_01_l = 2.808935009e-003,-1.933382242e-003,-7.612695452e-003,0.9999651909,
foot_l = -3.623697907e-002,-1.086986624e-002,-1.354881749e-002,0.9991921782,
ball_l = 8.008190343e-005,2.960354868e-005,-0.7186337113,0.6953888535,
RL_L_Foot01_Floor01 = 0.,0.,0.,1.,
RL_L_Foot01_Floor04 = 0.,0.,0.,1.,
RL_L_Foot01_Floor02 = 0.,0.,0.,1.,
RL_L_Foot01_Floor03 = 0.,0.,0.,1.,
RL_L_Foot01_Floor05 = 0.,0.,0.,1.,
RL_L_Foot01_Floor06 = 0.,0.,0.,1.,
thigh_twist_01_l = -4.744359851e-002,2.148010935e-005,-4.911088618e-004,0.9988737702,
thigh_r = -7.760486915e-004,7.410442084e-002,0.9971954823,-1.044299733e-002,
calf_r = -4.971510917e-002,6.144450512e-003,-5.679383758e-004,0.9987443686,
calf_twist_01_r = 2.807272831e-003,-1.933727413e-003,-7.612541318e-003,0.9999652505,
foot_r = -3.591478616e-002,-1.086789835e-002,-1.348622516e-002,0.9992047548,
ball_r = 8.007854194e-005,2.960378333e-005,-0.7186336517,0.6953887939,
RL_R_Foot01_Floor01 = 0.,0.,0.,1.,
RL_R_Foot01_Floor04 = 0.,0.,0.,1.,
RL_R_Foot01_Floor02 = 0.,0.,0.,1.,
RL_R_Foot01_Floor03 = 0.,0.,0.,1.,
RL_R_Foot01_Floor05 = 0.,0.,0.,1.,
RL_R_Foot01_Floor06 = 0.,0.,0.,1.,
thigh_twist_01_r = -4.744526744e-002,2.189409315e-005,-4.911787109e-004,0.9988737106,
ik_foot_root = 0.,0.,0.,1.,
ik_foot_l = 2.053012326e-002,-0.712233305,1.172331907e-002,0.7015445828,
ik_foot_r = -0.7015444636,1.172408182e-002,0.7122334242,2.053087763e-002,
ik_hand_root = 0.,0.,0.,1.,
CharacterMesh0(0) = 0.,0.,8.24325852e-008,1.,
[FloorContact]
HandBottom = 2.769418716
HandBack = 0.8673477173
HandMiddle = 11.10624313
HandFront = 12.55753326
HandIn = 5.706539154
HandOut = 5.25942421
FootBottom = 13.2835741
FootBack = 6.798443794
FootMiddle = 15.87928581
FootFront = 6.717291832
FootIn = 5.117822647
FootOut = 6.936565399
[Property]
AnkleHeight = 9.38811779
AnkleSpacing = 10.03706551
HipsForward = -15.
AutoAnkleHeight = true
AutoAnkleSpacing = true
RollExtractionMode = false
[RootTransform]
Value = 1.,1.,1.,1.,0.,0.,0.,0.9999999404,0.,0.,0.,1.,0.,0.,0.,
Binary file not shown.

After

Width:  |  Height:  |  Size: 220 KiB

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

After

Width:  |  Height:  |  Size: 488 KiB

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

After

Width:  |  Height:  |  Size: 232 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 145 KiB

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

After

Width:  |  Height:  |  Size: 488 KiB

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

After

Width:  |  Height:  |  Size: 232 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 145 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+132
View File
@@ -0,0 +1,132 @@
# Character Model Registry
Naming law for `characters/`. **Scope: this system applies only to newly created
variants derived from mako and lena** — every such artifact from here on, *wherever
it is produced*. (This clause used to read "anything born in this folder", which let
the AccuRig rig baits get built into `ariki-game/assets/models/characters/rig-work/`
and slip the net entirely; they were moved here 2026-08-06. A character artifact
generated in another repo is still governed by this file and still belongs under
`characters/`.)
Legacy models elsewhere — game-side `derived-bodies/`, old intermediates, historical
race-sources — keep their names and are NOT registered here; they will eventually be
replaced by files that follow this system.
Modeled on the dance registry (`.agents/wiki/dances/REGISTRY.md`): numbers are never
reused, superseded files are archived, never deleted.
## Filename grammar
```
<character>[_<variant>...]_<stage>_<format>_v<NN>.<ext>
```
- **character** — registered name from the Characters table below (`lena`, `mako`).
New characters get a row here before their first file exists.
- **variant** — optional sculpt-level descriptors (`elder`, `muscular`). A new
variant is a new folder and a new Characters-table row.
- **stage** — exactly one token from the closed vocabulary below.
- **format** — file format tag matching the extension (`fbx`, `glb`).
- **v\<NN\>** — mandatory two-digit version, per-artifact.
All lowercase snake_case, `[a-z0-9_]` only. No dates, no status words, no hyphens.
### Stage vocabulary (closed set — extending it is an edit to this table)
| token | meaning |
|---|---|
| `sculpt` | unrigged full-res mesh (sculpt-level alteration) |
| `decimated` | post-decimation, unrigged |
| `tpose` | pose-prep export for a rigging tool |
| `accurig` | AccuRig output, CC_Base_* skeleton |
| `mixamo` | Mixamo-rigged (legacy lane) |
| `quatskin` | 65-bone Quaternius UAL game skeleton |
| `lod<NN>` | LOD at NN% of tris (appended after `quatskin`) |
| `toned` | skin-tone pass (appended after `quatskin`) |
## The rules
1. **Originals are read-only; delivered names immutable** — even misspelled or
inconsistent (`female_lena_tripo` keeps its missing `_v1`). Identity is recorded
in this registry, never fixed on disk. Renaming would fork identity vs the
name-synced mirror in `ariki-game/assets/models/characters/race-sources/` and
risk breaking FBX-internal texture paths.
2. Every new derived file matches the grammar above and starts with a registered
character name. Canonical male spelling is **`mako`** — the AccuRig-era `moka`
typo and `moko` are never used in new files.
3. Exactly one stage token per filename.
4. Version is per-artifact; **ancestry lives in this registry, never in the name**
(a `quatskin_v01` built from `decimated_v03` does not encode the parent's version).
5. **Status lives in this registry, never in a filename** — no `_candidate`,
`_final`, `_locked`.
6. New geometry identity = new character-variant folder (e.g. `female/lena_elder/`);
re-run of the same recipe = version bump. Test: "would the game treat it as a
different character/body?" → new variant; "a better attempt at the same thing?"
→ bump. Numbers never reused.
7. FBX or loose-texture models get a folder named exactly the model basename
(e.g. `mako_accurig_fbx_v01/`); loose textures inside are `<character>_<map>.<ext>`
(`mako_basecolor.jpg`) — no version chains in texture names. `.fbm/` folders and
Tripo UUID internals are never renamed.
8. **Boundary law:** snake_case = animation-repo working names; PascalCase `Ariki_*`
= game-repo ship names. The rename happens exactly once, at import into
`ariki-game/assets/quaternius/derived-bodies/`, and is recorded in the ship-name
column below. Never create `Ariki_*` files in this repo.
9. Superseded files move to `characters/archive/` — never deleted, never renamed.
10. Every new file under `characters/` (outside `archive/`) gets a row in the
Derived artifacts table before its first commit.
11. **`characters/rig-work/` is exempt from the grammar and from rule 10** — it
holds disposable AccuRig rig carriers ("baits"): decimated FBX copies whose
only job is to carry a skeleton out of AccuRig and back. They are never
shipped and never contribute geometry to a registered artifact (the graft
takes geometry from the GLB parent and only the skeleton from the bait), so
they get no row of their own — instead the grafted body's **notes** column
names the bait it got its skeleton from. Keeping their tool-era names is
deliberate: they must stay recognisable as throwaway. See that folder's
`README.md`.
## Originals
Never modified, never renamed, never resaved in place. Verify with
`Get-FileHash -Algorithm SHA256`.
| file | sha256 | delivered by | tool | date | identity note |
|---|---|---|---|---|---|
| `originals/male/male_base_bald_tripo_v1.fbx` | `f7c884bb8b8a71351b1448fa355658459a61a2537fdd48f9c72e52709693608c` | Özlem | Tripo | 2026-08-04 (copied from ariki-game race-sources) | mako generation 1, unrigged A-pose ~1.9M tris |
| `originals/male/male_base_bald_tripo_v1.glb` | `59df0de4b097428324f6b9048e0eeb7e8e034062aa8c8dad7e1eba1937be8aca` | Özlem | Tripo | 2026-08-04 (copied from ariki-game race-sources) | mako generation 1 |
| `originals/female/female_lena_tripo.fbx` | `c3a7cb1bf4958b27bd81b6bf73fd132c88f7a582d045c8936992d5d22bfd4e8e` | Özlem | Tripo | 2026-08-04 (copied from ariki-game race-sources) | lena generation 1 — delivered name has no `_v1`; treat as v1 |
| `originals/female/female_lena_tripo.glb` | `de17a1e7d56cefc3e07596bfb08efc65b41d04fac709c27b76500729e4fbb4b8` | Özlem | Tripo | 2026-08-04 (copied from ariki-game race-sources) | lena generation 1 |
### Canonical-name aliases
Byte-identical copies of the originals under grammar-conforming names, so tools can
reference canonical names without touching the immutable delivered files. Same
read-only guard, same checksums as their sources above.
| file | byte-identical to | date |
|---|---|---|
| `originals/female/lena_sculpt_fbx_v01.fbx` | `female_lena_tripo.fbx` | 2026-08-04 |
| `originals/female/lena_sculpt_glb_v01.glb` | `female_lena_tripo.glb` | 2026-08-04 |
| `originals/male/mako_sculpt_fbx_v01.fbx` | `male_base_bald_tripo_v1.fbx` | 2026-08-04 |
| `originals/male/mako_sculpt_glb_v01.glb` | `male_base_bald_tripo_v1.glb` | 2026-08-04 |
## Characters
| character | gender | parent original | description | status |
|---|---|---|---|---|
| `mako` | male | `male_base_bald_tripo_v1` | bald base male, Tripo sculpt. Spelling history: game wiki "Mako", AccuRig-era typo "moka" — canonical filename token is `mako` | active |
| `lena` | female | `female_lena_tripo` | base female, Tripo sculpt | active |
| `lena_nude` | female | `female_lena_tripo` | lena with the sculpted-in underwear removed: no bra, no briefs, breasts with volume and no nipples, featureless crotch. Separate variant rather than a version of `lena` because the game selects it as a different body (rule 6) | active |
## Derived artifacts
Status vocabulary: `wip / candidate / approved / shipped / archived / rejected`.
| name | character | parent file | stage | tool/script | date | status | ship name | notes |
|---|---|---|---|---|---|---|---|---|
| `female/lena_nude/lena_nude_quatskin_glb_v01.glb` | `lena_nude` | game-side `Ariki_Female_QuatSkin.glb` (+ `Ariki_Female_QuatSkin_ChestV1.glb` as breast-shape donor) | `quatskin` | `tools/make_lena_nude_body.py` (`--size 1.0`) | 2026-08-04 | candidate | `Ariki_Female_QuatSkin_Nude.glb` (not yet released) | sha256 `931c0948a3b0201dbfbdd4d2671c3ae41688afc592a29e332bc2bab234aa1ad3`. Texture embedded, internal image name `lena_nude_basecolor` (no ship name baked inside a WIP asset). 31,670 v / 65 joints / 1.777 m; passes `tools/verify_body_variant.py` against the canonical body. Vertex count is below stock by design — the base mesh's open slits are welded shut, since the painted underwear had been hiding them. `--size` is breast volume; a different size is a version bump, not a new variant |
| `female/lena_nude/lena_nude_basecolor.png` | `lena_nude` | game-side `Ariki_Female_QuatSkin_Lena_Body_Toned.png` | — (texture) | `tools/_bake_nude_body_texture.py` | 2026-08-04 | candidate | `Ariki_Female_QuatSkin_Nude_Lena_Body_Toned_nude.png` (not yet released) | sha256 `576fcf614cfcb19befd5bd4fb57210b526db2754f7d35468b21252f31528c9f2`. 4096² albedo with bra and briefs inpainted out to uniform skin, no tan line. Loose copy for iteration; the shipped GLB embeds it. Unversioned per rule 7 |
| `female/lena_nude/lena_nude_sculpt_glb_v01.glb` | `lena_nude` | `originals/female/female_lena_tripo.glb` (hires, pre-decimation) + game-side `Ariki_Male_QuatBody_D_RigCandidate.glb` (crotch donor) | `sculpt` | `female/lena_nude/hires_claude/` pipeline (00 weld → 03 sculpt → 06 crotch+membrane → 05/05b texture+grade → 08 export); reference `female/lena_nude/reference_breasts.jpg` | 2026-08-05 | candidate | — (game asset derives via decimation later) | sha256 `cb781cf376dc046b108d1c00e2368e0ed7e74fde5a783bedc6fc435818e4b2c7`. 953,966 v / 1,907,931 tris / 0.9792 units tall, unrigged. Full-density nude: garment membrane-removed, C-cup round breasts (apex projection 0.040 units, cleavage gap 7 mm), no nipples; crotch = male-GLB donor + bi-harmonic gusset heal (featureless). Basecolor + normal + rm embedded; fabric repainted by 3D-nearest skin fill, rosy chest V graded out to uniform skin. Masters: `hires_claude/06_final.blend` (geometry), `07_polished.blend` (textured) |
| `female/lena_nude/lena_nude_sculpt_glb_v02.glb` | `lena_nude` | v01 geometry + `originals/female/female_lena_tripo.glb` textures | `sculpt` | `hires_claude/05_texture.py` in `patch` mode (garment texels only, mirror-averaged fill from her own skin) + `06f` fold melt; master `hires_claude/10_patch.blend` | 2026-08-05 | candidate | — | sha256 `4a8c811305352a5fc60a58f6146c6797de03131da56dbd4073f90580d2e59e9b`. Same nude geometry as v01 but ORIGINAL skin everywhere (rosy chest V, arm/leg tones kept); only the bra/briefs/strap/crotch texels are refilled. Jeremy's preference after v01's global tone grade changed too much of her look |
| `female/lena_nude/lena_nude_sculpt_glb_v04.glb` | `lena_nude` | `lena_nude_sculpt_glb_v03.glb` (master `hires_claude/29b_final.blend`) | `sculpt` | `hires_claude/33_bust_variants.py` (size study) → `34_apply_bust.py --k 0.5`; master `hires_claude/34_v04.blend` | 2026-08-06 | candidate | — | sha256 `88931098f27915f5a498a1bccc014e800af7e8fff3e1d43faf8b97616c307910`. 860,389 v / 1,721,805 tris, geometry identical to v03 except the bust. **Guided by the approved concept turnaround** (`exchange/INBOX/lena-base-turnaround`, GPT Image 2, approved by Can 2026-08-06): `32_ref_measure.py` compared reference silhouette against mesh and found proportions already agree — 6.43 vs 6.08 heads, waist/hip 0.504 vs 0.505, widths within 5% — with the bust the one real gap. Breast forms rescaled to 50% of their projection off a bi-harmonic chest wall, taking bust projection +0.0210 → **+0.0155 H against the reference's +0.0156**. 68,781 verts moved (max 27.8 mm, median 5.5 mm in-region), zero movement outside the chest region, height unchanged. Texture/UVs untouched from v03. Caveats: the projection metric saturates below k≈0.5 (k=0.35 also reads +0.0154) and the reference reading may be inflated by the T-pose arm crossing the chest in the side view, so 0.5 is a floor rather than a midpoint; front renders cannot distinguish sizes (frontal key light flattens the chest) — judge from the side |
| `female/lena_nude/lena_nude_decimated_glb_v01.glb` | `lena_nude` | `lena_nude_sculpt_glb_v03.glb` (master `hires_claude/29b_final.blend`) | `decimated` | `hires_claude/30_decimate.py` (COLLAPSE ratio **0.037728**, bisected to hit the vertex target) + `30b_fix_winding.py` | 2026-08-06 | candidate | — | sha256 `b11940bb46732ae0a60e1624e88e76fa48dcc3e3af49a6363348521bb7c52cc8`. 31,964 v / 64,957 tris in Blender (46,810 / 64,957 as exported, UV seams split verts) — 3.72% of the sculpt, matched to `lena_nude_quatskin_glb_v01.glb`'s 31,670 v at Jeremy's request. Unrigged, no skins, transforms applied; basecolor + normal + rm embedded. Height preserved to +0.12 mm, bbox unchanged. **Built as input for the re-atlas lane** (`24_seams.py`): collapse destroys Tripo's 5,870-chart UV layout, so the carried TEXCOORD_0 smears badly and is expected to be replaced, not reused. Residual: 25 backfacing tris of 64,957 (down from 61; the remainder sit in non-manifold knots where consistent-orientation oscillates), plus the sculpt's inherited 835 boundary / 1649 non-manifold edges |
| `female/lena_nude/lena_nude_sculpt_glb_v03.glb` | `lena_nude` | v02 chain head `hires_claude/10_welded.blend` | `sculpt` | `hires_claude/` 17 line heal → 26 median despeckle + cleavage fillet → 25 flipped-face repair → 29 on-body tone levelling → 08 export; masters `27_clean.blend` (geometry), `29b_final.blend` (textured) | 2026-08-06 | candidate | — | sha256 `7a8fe4c45d3af6acb7b39c2fac5fcc2aadf910f8d61a4a7d046911125a6e8baa`. 860,389 v / 1,721,805 tris. Jeremy's three v02 notes: (1) cut lines — reduced, not gone: shading kinks >12° 9704 → ~6900, ribcage/thigh lines gone frontally, waistband + underbust still legible under raking light; (2) discolouration — the pale bra/briefs panels are gone, on-body tone gap patch-vs-skin 0.0102 → 0.0068 luma; (3) cleavage — sternum fillet radius 9.5 mm → no sharp sample, notch depth 44 → 31 mm. Also 1511 → 10 flipped faces (the black dashes). Still open: 1649 non-manifold + 830 boundary edges (every fill refused them). See the lane README for the four dead ends this version ruled out |
| `female/lena_nude/lena_nude_decimated_glb_v02.glb` | `lena_nude` | `lena_nude_decimated_glb_v01.glb` (geometry byte-for-byte unchanged) | `decimated` | `hires_claude/24_seams.py` (anatomical seams + minimum-stretch unwrap) then `hires_claude/31_reatlas.py` (map resample) | 2026-08-06 | candidate | — | sha256 `cd3d53550470ae5a1958dc473ce11793f746b13f78ac911e6d426b6f7b9f5825`. **Re-atlas only — same 31,964 v / 64,957 tris, same shape, same material, unrigged.** Replaces Tripo's 5,870-chart atlas with 14 anatomical charts (torso, head, 2 arms, 2 legs, 2 hands ×2, 2 feet ×2): coverage 62.0→68.6%, texel density spread 1.8x→1.00x, per-triangle area-scale p95 2.55→1.77 / p99 4.25→2.27. Exported vertex count drops 46,810→42,980 because the new layout has far fewer UV seams. Maps resampled into the new layout at 4096², normal map rotated per face into its new tangent frame (p50 81°). Renders identical to v01: mean pixel \|Δ\| 0.0012, p99 0.0118 over five views (`hires_claude/33_ab.py`). Version bump, not a new variant, per rule 6 — same body, better atlas |
@@ -0,0 +1,61 @@
# Handoff: decimated nude Lena → retexture / re-atlas lane
**2026-08-06.** Jeremy asked for the healed nude Lena decimated to the game-body budget and passed
to the retexture / re-atlas work, "to see how they do".
## The file
`characters/female/lena_nude/lena_nude_decimated_glb_v01.glb`
| | |
|---|---|
| sha256 | `b11940bb46732ae0a60e1624e88e76fa48dcc3e3af49a6363348521bb7c52cc8` |
| parent | `lena_nude_sculpt_glb_v03.glb` (master `hires_claude/29b_final.blend`) |
| recipe | `hires_claude/30_decimate.py` then `30b_fix_winding.py` |
| ratio | Decimate **COLLAPSE 0.037728** — bisected, not guessed |
| density | 31,964 v / 64,957 tris in Blender (46,810 v as exported; UV seams split verts) |
| why that density | matched to the shipped `lena_nude_quatskin_glb_v01.glb` (31,670 v), Jeremy's call |
| rig | none — unrigged, no skins, transforms applied |
| maps | basecolor + normal + rm embedded (3 images, wired baseColor / metallicRoughness / normal) |
Ready to load directly: `24_seams.py` accepts a `.glb` as its first argument and applies transforms
itself, so this drops in with no preparation.
## Read this before you spend a pass on the UVs
**The carried `TEXCOORD_0` is not worth preserving.** Collapse decimation shreds Tripo's
5,870-chart atlas — in the textured review render (`hires_claude/review_30/chest_tex_0.png`) the
transplanted skin grain smears into streaks and the throat picks up a red blotch. That is expected
and is exactly the problem the ~14-chart anatomical atlas solves. Judge this mesh from
`review_30/full_clay_0.png` (clay, no material) and treat the UVs as scrap.
**Decimation improved one thing.** The waistband/underbust lines that survive on the hires sculpt
are nearly invisible at 32k — the collapse averaged them out. So the residual line problem is a
hires-only concern; don't go hunting for it here.
## Known defects being handed over
- **25 backfacing triangles** of 64,957. Down from 61 via `30b_fix_winding.py`, but the remainder
sit in non-manifold knots where `normals_make_consistent` oscillates (61 → 260 → 39 → 25 → 25).
They render black. If your pass rebuilds topology, they will likely vanish for free.
- **835 boundary edges / 1649 non-manifold edges**, inherited from the sculpt. Every fill attempt
refused them (`bmesh.ops.holes_fill`, edit-mode `mesh.fill_holes`, and a per-loop pass) — they
are dangling flaps and slivers, not perforations. Documented in the lane README.
- **The head is untouched original Tripo output.** Every geometry pass in this lane stopped below
the chin (z < 0.90) on purpose, so lips/nostrils/eyes still have the source smearing. If the
retexture covers the face, that is new ground, not a regression.
- **She reads glossy** — the `rm` map was neutralised to a median over the old garment texels, so
the whole body is shinier than skin should be. Untouched deliberately: an earlier attempt at
correcting roughness made her plasticky (see the lane README's fourth dead end).
## Provenance the three fixes came from
v03 addressed Jeremy's three notes on v02 — cut lines, discoloured bra/crotch patches, and the
cleavage. Measured outcomes and the four dead ends that were ruled out on the way are in
`README.md` under "Hi-res sculpt v03". Worth skimming before re-treading the geometry.
## Numbering note
This lane had two agents working in it simultaneously on 2026-08-06. `20`/`21` exist twice under
different filenames (`20_atlas_probe.py` + `20_cleavage.py`, `21_seam_probe.py` + `21_tone.py`).
Nothing was overwritten, but pick a fresh number rather than assuming the next one is free.
@@ -0,0 +1,87 @@
# Handoff: re-atlased nude Lena — `lena_nude_decimated_glb_v02.glb`
**2026-08-06.** Jeremy asked for the decimated nude body re-atlased and versioned. This is a
**UV + texture change only**: the geometry is byte-for-byte the v01 mesh.
## The file
`characters/female/lena_nude/lena_nude_decimated_glb_v02.glb`
| | |
|---|---|
| sha256 | `cd3d53550470ae5a1958dc473ce11793f746b13f78ac911e6d426b6f7b9f5825` |
| parent | `lena_nude_decimated_glb_v01.glb` |
| recipe | `hires_claude/24_seams.py``hires_claude/31_reatlas.py` |
| mesh | 31,964 v / 64,957 tris in Blender — **identical to v01** |
| exported verts | 42,980 (v01: 46,810) — the new layout needs fewer seam splits |
| maps | basecolor + normal + rm embedded, 4096², JPEG q95, correct colorspaces |
| rig | none — unrigged, transforms applied |
Rebuild:
```
blender --background --python 24_seams.py -- ../lena_nude_decimated_glb_v01.glb atlas_v02
blender --background --python 31_reatlas.py -- atlas_v02/seamed.blend atlas_v02 \
../lena_nude_decimated_glb_v02.glb
```
## What changed
| | Tripo atlas (v01) | new atlas (v02) |
|---|---|---|
| UV islands | 5,870 | 968 |
| charts holding 99% of the area | 84 | **14** |
| atlas coverage | 62.0% | **68.6%** |
| texel-density spread | 1.8× | **1.00×** |
| per-triangle area-scale p95 / p99 | — | 1.77 / 2.27 |
The 14 charts are torso, head, two arms, two legs, two hands (back + palm each) and two feet
(upper + sole each). Seams run up the back midline, the back of each arm and the inner leg, with
rings at neck, shoulder, wrist, hip and ankle — every landmark measured off the mesh's own
cross-section radius profiles, so the rules port to any density.
**It looks the same, on purpose.** Five matched views diff at mean \|Δ\| 0.0012, p99 0.0118, with
0.19% of body pixels over 0.05 (`33_ab.py`). Her original tones are untouched — this pass bought a
paintable, uniform-density layout, not a new look.
## Three traps this pass hit, so you don't
1. **Colorspace is not cosmetic.** `image.pixels` returns *linear* values for an sRGB image and
*raw* values for a Non-Color one, and re-encodes the same way on save. The normal and rm maps
are Non-Color; writing them into default (sRGB) images gamma-encoded them, so 0.5 came back as
0.21 — every stored normal became a large false perturbation and she rendered dark and
wet-plastic. New maps must inherit `colorspace_settings.name` from their source.
2. **Use the minimum-stretch solver, not angle-based.** ABF is conformal: it preserves angles and
is free to crush area. At her folds it squashed 4,502 triangles (3.7% of her surface) below one
texel, and they came out untextured. `method='MINIMUM_STRETCH'` took per-triangle area-scale p05
from 0.03 to 0.33 and the untextured count from 4,502 to 569 (0.56%, all genuine sub-texel
slivers absorbed by the 16 px padding pass).
3. **Watch the LOW tail of the distortion stat.** p95/p99 measure stretching; it was p05 = 0.03 —
crushing — that was losing whole patches. I read past it twice.
Also tried and rejected: splitting the 1,649 non-manifold edges before unwrapping. It cost +3,512
verts and made the collapsed-face count *worse* (4,502 → 5,482), because the collapse was a solver
problem, not a topology one.
## Still broken, and not from this pass
**The ragged grey tears on the face, across the underbust, and as flecks down the arms and shins
are in v01 already** — I rendered v01 through the same script to confirm, and they are identical in
position and shape (`hires_claude/beauty_input_v01/` vs `beauty_atlas_v02/`). They are the 835
boundary edges / dangling flaps the v01 handoff lists as a known defect. A re-atlas cannot fix
geometry. They need mesh surgery before this body ships.
The other v01 defects carry over untouched: 25 backfacing triangles, the head still being original
Tripo output, and the glossy `rm` map.
## What this does NOT do
Jeremy's stated goal is to **keep her original texture and replace only the breast and underwear
region**. This pass deliberately does not touch colour — it only rehouses it. The re-authoring step
(a gradient-domain fill pinned to her surrounding skin at the boundary, so the old bra/briefs
outline cannot ghost) is the next stage, and it is much easier now that the charts are anatomical
and uniform-density.
Note that keeping her original texture keeps its known incoherence: her feet read 0.11 luma darker
and 50% redder than her belly, and the rosy chest V remains. Measured in
`hires_claude/21_seam_probe.py`; see the `lena-tripo-atlas-chart-soup` note.
+195
View File
@@ -0,0 +1,195 @@
# lena_nude — nude body variant (WIP, not released)
Lena's body with the underwear removed: no bra, no briefs, breasts with volume and **no
nipples**, smooth featureless crotch. Staged here until it is approved for the game.
| file | role |
|---|---|
| `lena_nude_quatskin_glb_v01.glb` | the body — 65-bone Quaternius game skeleton, texture **embedded** (self-contained) |
| `lena_nude_basecolor.png` | the baked albedo, loose, for further iteration |
Ship name at release is `Ariki_Female_QuatSkin_Nude.glb` in
`ariki-game/assets/quaternius/derived-bodies/`. Per `../../REGISTRY.md` rule 8 that rename happens
exactly once, on import — never create `Ariki_*` files in this repo.
## How it was made
Generators live in **this** repo (moved out of ariki-game 2026-08-06 — they author a character,
so `../../REGISTRY.md` governs them; see `.agents/wiki/ARCHITECTURE.md` "Tool provenance"). They
still *read* the canonical rigged bodies from the game repo (`Ariki_Female_QuatSkin.glb` and the
`_ChestV1` breast-sculpt donor), resolved via `$ARIKI_GAME` or a sibling `ariki-game/` checkout —
if neither resolves they abort with the path they tried, rather than reading the wrong file.
Run from `C:\Users\Jeremy\tinqs\animation`:
```
blender --background --python tools/_bake_nude_body_texture.py
# defaults to writing lena_nude_basecolor.png into THIS folder
blender --background --python tools/make_lena_nude_body.py -- \
--out characters/female/lena_nude/lena_nude_quatskin_glb_v01.glb \
--size 1.0 \
--texture characters/female/lena_nude/lena_nude_basecolor.png
python tools/verify_body_variant.py \
characters/female/lena_nude/lena_nude_quatskin_glb_v01.glb
```
`--size` is breast volume as a multiple of the `_ChestV1` sculpt (0.75 / 1.0 / 1.25 were
rendered for review; **v01 ships 1.0**). A new size is a **version bump**, not a new variant —
same recipe, different parameter. (This README and `../../REGISTRY.md` both called the flag
`--scale` until 2026-08-06; the script has always parsed `--size`.)
## Facts worth knowing before editing this
- **The underwear is the body surface.** A ray-crossing census finds exactly two surface crossings
at every height — thigh, belly, bra cup, briefs alike. There is no skin underneath, so the
garment can only be flattened into the skin, never deleted. Deleting it opens a hole, which is
precisely the defect in the game repo's `Ariki_Female_QuatSkin_Bare.glb` (black cavity across
the chest, visible even untextured). Do not use `_Bare` geometry for anything.
- **The base mesh is not watertight** (3,809 boundary edges). The painted underwear was hiding
open slits; with it gone they read as a dotted line of black triangles, so the builder welds
them shut in the torso band. That is where this file's lower vertex count comes from.
- **Use clay renders to judge geometry.** `tools/_render_body_closeup.py --clay` drops the
material; it is the only reliable way to tell a mesh defect from a painted one. Several dead
ends here came from mistaking painted bra shadow for a dent, and vice versa.
## Working files
This lane follows `.agents/rules/working-files.md`: **a version is a milestone,
not a step.** Fixing the bra produces one saved file at the end, not one per cut.
`hires_claude/.lanekeep` pins the only `.blend` files that survive here — the
masters named in the Derived-artifacts table of `../../REGISTRY.md`, plus the
live chain head. Everything else a step script wrote is scratch: the `NN_*.py`
recipe plus `masks.npz` regenerates it from the master above it.
```
python tools/prune_lane.py characters/female/lena_nude --recursive # dry run
```
Two things this lane learned the hard way:
- **Check for a running Blender before pruning.** A heal step takes ~7 minutes
and writes only at the end; the lane gained a new head (`17_healed.blend`)
during the audit that produced this section.
- `hires_work/` is the abandoned GLM-agent attempt (see its
`glm_run_attempt*.log`), superseded by `hires_claude/`. It has no `.lanekeep`,
so every `.blend` in it classifies as scratch.
The root-level `lena_sculpt_glb_v01-nude-Zephyr.blend` is hand-authored — no
step script rebuilds it — so the pruner reports it as UNKNOWN and never touches
it. Decide its fate by hand.
## Status
Verified against the canonical body: 1 primitive, identical attribute set (no tangents), material
`MI_Body_Lena`, 65 joints in identical order, height 1.777 m, inverse-bind drift 4.6e-6, weights
normalised, texture embedded. Posed sweep under a UAL clip is clean (no spikes or tearing).
Known cosmetic limits: a faint tonal seam where the fill meets untouched skin at extreme zoom, and
no skin-pore detail inside the filled area — that smoothness is also what guarantees no nipple or
crotch detail can appear.
Full history and the reasoning behind each choice: `~/.claude/plans/i-have-a-lena-velvet-ripple.md`.
## v04 — bust matched to the approved concept turnaround (2026-08-06)
`lena_nude_sculpt_glb_v04.glb`, master `hires_claude/34_v04.blend`. Recipes
`32_ref_measure.py` (measure) → `33_bust_variants.py` (size study) → `34_apply_bust.py` (k=0.5).
Guiding art: `exchange/INBOX/lena-base-turnaround/` — front/side/back T-pose renders, GPT Image 2,
approved by Can 2026-08-06.
**The reference is the same character, not a new one.** Measured against the front/side silhouettes:
| landmark | reference | v03 mesh |
|---|---|---|
| heads tall | 6.43 | 6.08 |
| waist / hip | 0.504 | 0.505 |
| waist width | 0.1260 H | 0.1318 H |
| hip width | 0.2500 H | 0.2608 H |
| **bust projection** | **+0.0156 H** | **+0.0210 H** |
Proportions already agree and the widths sit inside silhouette-threshold noise for generated art, so
only the bust was changed: the breast forms are rescaled to 50% of their projection off a
bi-harmonic chest wall, landing at +0.0155 H. Nothing outside the chest region moves (verified 0.0000 mm)
and height is unchanged, so every v03 heal survives.
Two traps for whoever tunes this next:
- **The projection metric saturates below k≈0.5** — k=0.35 measures +0.0154, indistinguishable from
k=0.5. Below that the "deepest slice" row migrates and stops tracking breast volume, so the number
cannot be pushed lower meaningfully. Treat 0.5 as a floor, not a midpoint. The reference reading may
also be inflated by the T-pose arm crossing the chest in a side view, which would mean the real
reference bust is smaller still.
- **Front renders cannot tell the sizes apart.** k=0.5, 0.65 and 0.8 look identical head-on because
the frontal key light flattens the chest. Judge from the side view only. Variants are in
`hires_claude/review_33/k{100,080,065,050,035}/`.
Re-running at another k takes seconds: `_bust_wall_cache.npy` holds the 8-minute chest-wall solve.
Not addressed, and the largest remaining gap to the concept art: the reference is a **smooth
mannequin** with no scan noise, while this mesh still carries speckles and dents on the hips and
thighs, plus the untouched original head.
## Decimated for the retexture / re-atlas lane (2026-08-06)
`lena_nude_decimated_glb_v01.glb` — v03 collapsed to the game-body budget (31,964 v / 64,957 tris,
ratio 0.037728) as input for `hires_claude/24_seams.py`. Recipe `30_decimate.py` + `30b_fix_winding.py`.
**Read `HANDOFF_decimated_v01.md` before working on it** — in particular, its carried UVs are scrap
(collapse shreds Tripo's 5,870-chart atlas) and it ships with 25 known backfacing triangles.
## Re-atlased (2026-08-06)
`lena_nude_decimated_glb_v02.glb`**v01's geometry with a new UV atlas and resampled maps.**
Same 31,964 v / 64,957 tris; Tripo's 5,870 charts replaced by 14 anatomical ones (coverage
62.0 → 68.6%, texel-density spread 1.8× → 1.00×). Recipe `hires_claude/24_seams.py`
`hires_claude/31_reatlas.py`. It deliberately **looks identical** to v01 (mean pixel |Δ| 0.0012
across five views) — the win is a paintable, uniform-density layout, not a new look.
**Read `HANDOFF_reatlas_v02.md`**, especially the colorspace and unwrap-solver traps, and note that
the grey tears on the face/underbust are v01's dangling-flap defect and are still there.
## Hi-res sculpt v03 (2026-08-06) — Jeremy's three notes on v02
`lena_nude_sculpt_glb_v03.glb`, masters `hires_claude/27_clean.blend` (geometry) and
`29b_final.blend` (textured). Recipes: `17_line_heal.py``26_finish.py`
`25_speckle.py --apply``29_tone3d.py``08_export.py`.
| note | measured outcome |
|---|---|
| cut lines | shading kinks >12° 9704 → ~6900; flipped faces (the black dashes) 1511 → 10. Ribcage and thigh lines gone frontally; waistband + underbust still legible under raking light |
| discoloured bra/crotch | on-body tone gap, patch interior vs surrounding skin: 0.0102 → 0.0068 luma. The pale panels in `beauty_v02_healed/` are gone in `beauty_v03b/` |
| cleavage | sternum fillet radius 9.5 mm → no sharp sample at any height; notch depth 44 → 31 mm |
### Four dead ends this version ruled out — do not repeat them
1. **The lines are not cracks; do not weld.** Stage 15 assumed the panel seams were disconnected
vertex runs. They are not: the mesh has only 830 boundary edges in 689 loops of 35 edges, and
for kink verts the nearest vertex outside the 3-ring sits at 1.58× the local edge length
(median) against 1.73× for control skin. Welding at eps 1.2 mm — about the 1.85 mm mean edge
length — merged ordinary neighbours and tore the mesh, 830 → 24k boundary edges.
2. **They are not in the custom normals either.** Corner-vs-vertex deviation is 0.008° mean, 15
loops above 5°; clearing custom split normals changes nothing.
3. **A membrane is the wrong operator, and widening it is worse.** The relief is ONE VERTEX WIDE
(0.23 mm median at the centre, back to the 0.074 mm background by ring 2), so a collar-fixed
membrane pins itself to the defect's own shoulders. GROW 4 and 8 were no better than GROW 2
while moving material up to 38 mm. A **ring median** is correct: it deletes a 1-vertex outlier
and returns anything broader unchanged, so the underbust fold, clavicles and navel survive by
construction instead of by a tuned threshold.
4. **Never level tone from atlas-adjacent texels.** A Poisson solve whose boundary condition was
the mismatch against UV neighbours turned the patches into pale grey panels — UV adjacency is
not body adjacency, so the border mismatch came from other body parts and gutter. This is the
warning already in `05_texture.py`'s header. Sample on the body (KD over skin verts in 3D),
smooth the correction over the mesh graph, then rasterise.
Two process traps worth keeping: **restore the real material before saving**`18/20/25`
originally rendered clay last and then saved, so the file on disk kept the clay material and lost
its texture wiring, which is why the first tone attempt aborted with "could not find the wired
basecolor". And **the pristine textures are not inside working blends**: Blender purges the orphan
`.002/.003` copies on save, so read them from `00_welded.blend`.
Still open: 1649 non-manifold and 830 boundary edges — `bmesh.ops.holes_fill`, the edit-mode
`mesh.fill_holes` operator, and a per-loop pass all refused every one, which is why
`normals_make_consistent` could only reach 1198 of 1511 flipped faces before explicit per-face
reversal finished the job. None of this is propagated to the game body
(`lena_nude_quatskin_glb_v01.glb`), which is built independently on the 65-bone Quaternius rig.
@@ -0,0 +1,36 @@
# .lanekeep — .blend files pinned as MASTER in this lane.
# Everything else here that a step script wrote is scratch: regenerable by
# re-running the NN_*.py recipe from the pinned master above it.
# Read by tools/prune_lane.py. One filename per line, # for comments.
#
# Rule: a .blend earns a line here only when a registered artifact in
# characters/REGISTRY.md was built from it, or it is the head of the live chain.
# Intermediate attempts (the 06b-06h melt series, the 10_healed/rimheal/seamheal
# series) never get pinned — only the state at the end of the fix.
# --- phase roots ---
00_welded.blend # raw original, slits welded shut. Root of every branch;
# keeps a re-sculpt from needing a fresh weld+isolate pass.
# --- masters of registered artifacts (see characters/REGISTRY.md) ---
06_final.blend # lena_nude_sculpt_glb_v01.glb — geometry master
07_polished.blend # lena_nude_sculpt_glb_v01.glb — textured master
10_patch.blend # lena_nude_sculpt_glb_v02.glb — master (original skin retained)
27_clean.blend # lena_nude_sculpt_glb_v03.glb — geometry master (phase boundary:
# line heal + cleavage fillet + flipped-face repair, pre-texture)
29b_final.blend # lena_nude_sculpt_glb_v03.glb — textured master, HEAD 2026-08-06.
# 27_clean + on-body tone levelling (29_tone3d.py str 1.0, 25 passes)
34_v04.blend # lena_nude_sculpt_glb_v04.glb — master, HEAD 2026-08-06. 29b_final with
# the bust rescaled to k=0.5 per the approved concept turnaround.
10_welded.blend # parent of the v03 chain. Kept as the fallback until v03/v04 are
# accepted; 11-15 regenerate it from 10_patch, so unpin then.
# _bust_wall_cache.npy is the bi-harmonic chest wall for 29b_final (an 8-minute CG solve).
# Keep it while bust size is still in play: 33/34 reuse it and a different k then costs
# seconds instead of minutes. It is regenerable, and invalid for any other mesh — both
# scripts assert its shape against the vertex count before trusting it.
# 17_healed / 18_smoothnrm / 22_lines / 23_cleavage / 26_geo / 29_final and the
# rejected atlas-tone attempt were per-step snapshots and have been pruned; the
# 17/25/26/29 recipes rebuild them from 10_welded.
Binary file not shown.
@@ -0,0 +1,159 @@
# Probe the pre-decimated Tripo sculpt: is the sports top a SEPARABLE shell?
# Answers, per connected component (after welding UV-seam splits):
# size, z-range, open-boundary edge count, and what fraction of its faces are painted
# garment (colour-keyed on lena+glb_basecolor via each face's own UV texels).
#
# blender --background --python 01_probe.py -- <copy.glb> <checkpoint.blend>
import bpy, bmesh, sys, time
import numpy as np
argv = sys.argv[sys.argv.index("--") + 1:]
SRC, CKPT = argv[0], argv[1]
t0 = time.time()
def log(m):
print(f"[probe {time.time()-t0:6.1f}s] {m}", flush=True)
bpy.ops.wm.read_factory_settings(use_empty=True)
bpy.ops.import_scene.gltf(filepath=SRC)
ob = max([o for o in bpy.data.objects if o.type == 'MESH'],
key=lambda o: len(o.data.vertices))
me = ob.data
log(f"imported: {len(me.vertices)}v {len(me.polygons)}f, object '{ob.name}'")
bm = bmesh.new()
bm.from_mesh(me)
bmesh.ops.remove_doubles(bm, verts=list(bm.verts), dist=1e-6)
bm.to_mesh(me)
bm.free()
me.update()
log(f"after weld 1e-6: {len(me.vertices)}v {len(me.polygons)}f")
# save checkpoint so later stages skip the import+weld cost
bpy.context.preferences.filepaths.save_version = 0 # no .blend1 autosave
bpy.ops.wm.save_as_mainfile(filepath=CKPT)
log(f"checkpoint saved: {CKPT}")
n_v = len(me.vertices)
n_f = len(me.polygons)
# ---- connected components over faces (edge-connected), union-find in numpy ----
# face -> vertices
loop_tot = np.empty(n_f, dtype=np.int32)
me.polygons.foreach_get("loop_total", loop_tot)
loop_start = np.empty(n_f, dtype=np.int32)
me.polygons.foreach_get("loop_start", loop_start)
loops_v = np.empty(len(me.loops), dtype=np.int32)
me.loops.foreach_get("vertex_index", loops_v)
parent = np.arange(n_v, dtype=np.int64)
def find(a):
root = a
while parent[root] != root:
root = parent[root]
while parent[a] != root:
parent[a], a = root, parent[a]
return root
# union via edges
n_e = len(me.edges)
ev = np.empty(n_e * 2, dtype=np.int32)
me.edges.foreach_get("vertices", ev)
ev = ev.reshape(-1, 2)
for a, b in ev:
ra, rb = find(a), find(b)
if ra != rb:
parent[rb] = ra
log("union-find done")
root_of = np.array([find(i) for i in range(n_v)], dtype=np.int64)
uniq, inv, counts = np.unique(root_of, return_inverse=True, return_counts=True)
order = np.argsort(-counts)
log(f"components: {len(uniq)}")
# vertex positions
co = np.empty(n_v * 3, dtype=np.float64)
me.vertices.foreach_get("co", co)
co = co.reshape(-1, 3)
# boundary edges per component: edge belongs to 1 face only
# count faces per edge
edge_face_count = np.zeros(n_e, dtype=np.int32)
for p in me.polygons:
for ek in p.edge_keys:
pass # too slow; use loops instead
# faster: build edge keys from loops via me.polygons edge indices
# Blender exposes loop edges: me.loops[i].edge_index
loops_e = np.empty(len(me.loops), dtype=np.int32)
me.loops.foreach_get("edge_index", loops_e)
np.add.at(edge_face_count, loops_e, 1)
boundary_edge = edge_face_count == 1
log(f"boundary edges total: {boundary_edge.sum()}")
# ---- colour key per vertex (first-loop UV), calibrated on this texture ----
img = None
for i in bpy.data.images:
if "basecolor" in i.name.lower():
img = i
break
if img is None:
img = max(bpy.data.images, key=lambda i: i.size[0] * i.size[1])
w, h = img.size
log(f"texture '{img.name}' {w}x{h}")
px = np.empty(w * h * 4, dtype=np.float32)
img.pixels.foreach_get(px)
rgb = px.reshape(h, w, 4)[:, :, :3]
uvl = me.uv_layers.active.data
uv = np.empty(len(me.loops) * 2, dtype=np.float64)
uvl.foreach_get("uv", uv)
uv = uv.reshape(-1, 2)
# first loop per vertex
first_loop = np.full(n_v, -1, dtype=np.int64)
for li in range(len(loops_v) - 1, -1, -1):
first_loop[loops_v[li]] = li
has_uv = first_loop >= 0
vx = np.clip(uv[first_loop, 0], 0, 1) * (w - 1)
vy = np.clip(uv[first_loop, 1], 0, 1) * (h - 1) # bpy-imported UVs: already flipped
vcol = rgb[vy.astype(int), vx.astype(int)]
r, g, b = vcol[:, 0], vcol[:, 1], vcol[:, 2]
mx = vcol.max(axis=1)
mn = vcol.min(axis=1)
sat = np.where(mx > 1e-5, (mx - mn) / np.maximum(mx, 1e-5), 0)
rb = r / np.maximum(b, 1e-5)
# calibrate: thigh skin (z 0.28-0.34) vs briefs centre (z 0.50-0.56 front)
thigh = (co[:, 2] > 0.28) & (co[:, 2] < 0.34)
briefs = (co[:, 2] > 0.50) & (co[:, 2] < 0.56) & (co[:, 1] < 0) & (np.abs(co[:, 0]) < 0.05)
bra = (co[:, 2] > 0.64) & (co[:, 2] < 0.72) & (co[:, 1] < 0) & (np.abs(co[:, 0]) < 0.05)
for nm, s in (("thigh skin", thigh), ("briefs", briefs), ("bra front", bra)):
if s.sum():
log(f" [{nm}] n={s.sum()} sat p50={np.median(sat[s]):.3f} rb p50={np.median(rb[s]):.3f} "
f"rgb ({np.median(r[s]):.3f},{np.median(g[s]):.3f},{np.median(b[s]):.3f})")
# pick thresholds midway between garment and skin medians
sat_thr = (np.median(sat[briefs]) + np.median(sat[thigh])) / 2 if briefs.sum() and thigh.sum() else 0.45
rb_thr = (np.median(rb[briefs]) + np.median(rb[thigh])) / 2 if briefs.sum() and thigh.sum() else 1.9
garment_v = (sat < sat_thr) & (rb < rb_thr)
log(f"thresholds: sat<{sat_thr:.3f} rb<{rb_thr:.2f} -> {garment_v.sum()} garment verts")
# ---- per-component report (top 12 by size) ----
# per-vertex boundary flag
vb = np.zeros(n_v, dtype=bool)
vb[ev[boundary_edge].ravel()] = True
print("\n=== COMPONENTS (top 12 by vertex count) ===")
for ci in order[:12]:
root = uniq[ci]
m = root_of == root
zc = co[m, 2]
gfrac = garment_v[m].mean()
bcount = vb[m].sum()
print(f"comp root={root}: verts={m.sum():7d} z[{zc.min():.3f},{zc.max():.3f}] "
f"garment-painted={100*gfrac:5.1f}% boundary-verts={bcount}")
print("PROBE_DONE")
@@ -0,0 +1,158 @@
# Stage 2: isolate the garment on the welded hires sculpt, split it into functional zones, and
# save the masks + adjacency for the sculpt stage.
#
# blender --background --python 02_isolate.py -- <00_welded.blend> <masks.npz>
#
# Zones (per vertex):
# cups — bra front where the breasts live (gets wall + amplified mounds)
# shield — sternum strip the bra bridges (gets carved cleavage)
# toprest — rest of the top: band, straps, back panel (gets faired flat to skin)
# briefs — briefs incl. waistband/leg rims (gets faired featureless)
# plus 'hemband' — narrow ring around every garment/skin boundary (local crease fairing).
#
# The colour key was calibrated on THIS texture by 01_probe.py: garment sat~0.39/RB~1.65,
# skin sat~0.64/RB~2.80; thresholds are the midpoints. The z-guards keep the low-saturation
# face features (eyes, teeth, lips) out of the key's reach.
import bpy, sys, time
import numpy as np
argv = sys.argv[sys.argv.index("--") + 1:]
BLEND, OUT = argv[0], argv[1]
t0 = time.time()
SAT_THR, RB_THR = 0.518, 2.22
Z_GARMENT_LO, Z_GARMENT_HI = 0.40, 0.85 # below the head; briefs to over-shoulder straps
Z_TOP_SPLIT = 0.595 # briefs/top divide (waistband top 0.578 + margin)
GROW_RINGS = 6 # ~2 cm real at this density; must swallow the 1 cm hem lips
# cups zone (front bra): z and |x| bounds from the measured landmarks
CUP_Z_LO, CUP_Z_HI = 0.615, 0.745
CUP_X_MAX = 0.085
SHIELD_X = 0.014 # sternum strip half-width
def log(m):
print(f"[iso {time.time()-t0:6.1f}s] {m}", flush=True)
bpy.ops.wm.open_mainfile(filepath=BLEND)
ob = max([o for o in bpy.data.objects if o.type == 'MESH'],
key=lambda o: len(o.data.vertices))
me = ob.data
n_v = len(me.vertices)
log(f"loaded {n_v}v")
co = np.empty(n_v * 3, dtype=np.float64)
me.vertices.foreach_get("co", co)
co = co.reshape(-1, 3)
# --- per-vertex colour ---
img = next(i for i in bpy.data.images if "basecolor" in i.name.lower())
w, h = img.size
px = np.empty(w * h * 4, dtype=np.float32)
img.pixels.foreach_get(px)
rgb = px.reshape(h, w, 4)[:, :, :3]
loops_v = np.empty(len(me.loops), dtype=np.int32)
me.loops.foreach_get("vertex_index", loops_v)
uv = np.empty(len(me.loops) * 2, dtype=np.float64)
me.uv_layers.active.data.foreach_get("uv", uv)
uv = uv.reshape(-1, 2)
first_loop = np.full(n_v, len(loops_v), dtype=np.int64)
np.minimum.at(first_loop, loops_v, np.arange(len(loops_v), dtype=np.int64))
first_loop = np.minimum(first_loop, len(loops_v) - 1)
vx = (np.clip(uv[first_loop, 0], 0, 1) * (w - 1)).astype(int)
vy = (np.clip(uv[first_loop, 1], 0, 1) * (h - 1)).astype(int)
vcol = rgb[vy, vx]
mx = vcol.max(axis=1)
mn = vcol.min(axis=1)
sat = np.where(mx > 1e-5, (mx - mn) / np.maximum(mx, 1e-5), 0)
rb = vcol[:, 0] / np.maximum(vcol[:, 2], 1e-5)
# above z=0.80 only the shoulder straps qualify — keep the chin/lips (low-saturation paint)
# out by requiring lateral offset there
zone_guard = (co[:, 2] > Z_GARMENT_LO) & (co[:, 2] < Z_GARMENT_HI) & ((co[:, 2] < 0.80) | (np.abs(co[:, 0]) > 0.025))
key = (sat < SAT_THR) & (rb < RB_THR) & zone_guard
# The shoulder straps' paint is much closer to skin (median sat 0.57-0.61 vs the bra body's
# 0.39) — the main key catches only ~a third of each strap and the rest survived as raised
# geometry. In the strap corridor a relaxed threshold seeds them; the ring grow fills the rest.
strap_zone = (co[:, 2] > 0.72) & (co[:, 2] < 0.85) & (np.abs(co[:, 0]) > 0.03) & (np.abs(co[:, 0]) < 0.105)
key |= strap_zone & (sat < 0.55)
log(f"colour key (+strap corridor): {key.sum()} verts")
# --- adjacency (numpy CSR-ish over edges) ---
n_e = len(me.edges)
ev = np.empty(n_e * 2, dtype=np.int32)
me.edges.foreach_get("vertices", ev)
ev = ev.reshape(-1, 2)
order = np.concatenate([ev[:, 0], ev[:, 1]])
nbr = np.concatenate([ev[:, 1], ev[:, 0]])
srt = np.argsort(order, kind="stable")
order_s = order[srt]
nbr_s = nbr[srt]
ptr = np.searchsorted(order_s, np.arange(n_v + 1))
log("adjacency built")
def grow(mask, rings):
out = mask.copy()
for _ in range(rings):
sel = np.zeros(n_v, dtype=bool)
# mark all neighbours of current selection
active = np.nonzero(out)[0]
# gather neighbour slices
for a in active:
sel[nbr_s[ptr[a]:ptr[a + 1]]] = True
out |= sel
return out
# component filter: drop specks (<200 verts) via BFS on the keyed set
from collections import deque
comp_id = np.full(n_v, -1, dtype=np.int64)
cid = 0
keep = np.zeros(n_v, dtype=bool)
for s in np.nonzero(key)[0]:
if comp_id[s] >= 0:
continue
q = deque([s])
comp_id[s] = cid
members = [s]
while q:
c = q.popleft()
for nb in nbr_s[ptr[c]:ptr[c + 1]]:
if key[nb] and comp_id[nb] < 0:
comp_id[nb] = cid
q.append(nb)
members.append(nb)
if len(members) >= 200:
keep[members] = True
cid += 1
log(f"component filter: {keep.sum()} verts in {cid} raw components")
garment = grow(keep, GROW_RINGS)
log(f"grown +{GROW_RINGS}: {garment.sum()}")
top = garment & (co[:, 2] >= Z_TOP_SPLIT)
briefs = garment & (co[:, 2] < Z_TOP_SPLIT)
front = co[:, 1] < 0.0
cups = top & front & (co[:, 2] > CUP_Z_LO) & (co[:, 2] < CUP_Z_HI) \
& (np.abs(co[:, 0]) < CUP_X_MAX) & (np.abs(co[:, 0]) > SHIELD_X)
shield = top & front & (co[:, 2] > CUP_Z_LO) & (co[:, 2] < CUP_Z_HI) \
& (np.abs(co[:, 0]) <= SHIELD_X)
toprest = top & ~cups & ~shield
# hem band: garment boundary vs non-garment, +/-2 rings
edge_g = garment[ev]
bnd_edges = ev[edge_g[:, 0] != edge_g[:, 1]]
bmask = np.zeros(n_v, dtype=bool)
bmask[bnd_edges.ravel()] = True
hemband = grow(bmask, 8) # hem lips are ~3 rings wide; blend needs room beyond them
log(f"zones: cups={cups.sum()} shield={shield.sum()} toprest={toprest.sum()} "
f"briefs={briefs.sum()} hemband={hemband.sum()}")
np.savez_compressed(OUT, garment=garment, cups=cups, shield=shield, toprest=toprest,
briefs=briefs, hemband=hemband, key_raw=keep)
log(f"WROTE {OUT}")
print("ISOLATE_DONE")
@@ -0,0 +1,612 @@
# Stage 3 (v3): take the top off and sculpt the breasts, at full 954k-vert density.
#
# blender --background --python 03_sculpt.py -- <00_welded.blend> <masks.npz>
# <out.blend> [amp_target=0.034]
#
# METHOD — and the two failure modes v3 exists to kill:
#
# * v1/v2 read the replacement surface off the faired proxy with BVH find_nearest. A 19k proxy
# is FACETED (~7 mm triangles): nearest-point positions are piecewise planar and the face
# normals jump at every proxy edge, so the lifted surface imprinted the proxy tessellation
# onto 954k verts — the "crust" in the renders was the proxy's facets, not fabric.
# v3 never touches proxy faces: the complete TARGET surface (wall + zone field) is computed
# per proxy VERTEX and lifted by inverse-distance blending over the 6 nearest proxy verts —
# continuous by construction — followed by a short Taubin polish at full density.
#
# * The shoulder straps' paint is nearly skin-coloured (median sat 0.57-0.61 vs 0.39 on the bra
# body), so no colour threshold can own them. v3 catches them GEOMETRICALLY: the proxy wall
# is also solved under a shoulder corridor, and any full-density vert there standing
# > 2.5 mm proud of the wall is fabric — paint is irrelevant.
#
# Zone targets: cups/shield -> wall + AMPLIFIED mound field (her own under-bra anatomy,
# recovered as surface-minus-wall on the proxy where 1 mm weave cannot exist) with the cleavage
# valley carved where the bra bridged it; rest of top + proud corridor -> wall; briefs -> wall
# + field at 1x (keeps hip/butt anatomy, sheds weave and waistband/leg lips).
import bpy, sys, time
from collections import deque
import numpy as np
from mathutils import Vector
from mathutils.kdtree import KDTree
from mathutils.bvhtree import BVHTree
argv = sys.argv[sys.argv.index("--") + 1:]
BLEND, MASKS, OUT = argv[0], argv[1], argv[2]
AMP_TARGET = float(argv[3]) if len(argv) > 3 else 0.034
CLEAV_GAP, CLEAV_W = 0.007, 0.013 # narrow gap: reference mounds nearly touch
PROXY_RATIO = 0.012 # ~11.5k proxy: dense wall solve stays under ~2 min; the
# vertex-IDW lift makes coarser proxies safe (no facet imprint)
PDN_SMOOTH = 25
BLEND_RINGS = 12
K_LIFT = 6
POLISH_ITERS = 12
PROUD_THR = 0.0025 # corridor verts standing this proud of the wall are fabric
t0 = time.time()
def log(m):
print(f"[sculpt {time.time()-t0:6.1f}s] {m}", flush=True)
bpy.ops.wm.open_mainfile(filepath=BLEND)
ob = max([o for o in bpy.data.objects if o.type == 'MESH'],
key=lambda o: len(o.data.vertices))
me = ob.data
n_v = len(me.vertices)
M = np.load(MASKS)
cups, shield, toprest = M["cups"], M["shield"], M["toprest"]
briefs, garment = M["briefs"], M["garment"]
co = np.empty(n_v * 3, dtype=np.float64)
me.vertices.foreach_get("co", co)
co = co.reshape(-1, 3)
corridor = (co[:, 2] > 0.64) & (co[:, 2] < 0.86) \
& (np.abs(co[:, 0]) > 0.02) & (np.abs(co[:, 0]) < 0.145)
core_top = (co[:, 2] > 0.600) & (co[:, 2] < 0.855) & (np.abs(co[:, 0]) < 0.14)
core_bot = (co[:, 2] > 0.435) & (co[:, 2] <= 0.600) & (np.abs(co[:, 0]) < 0.15)
# ROUGHNESS, computed up-front because it feeds the PROXY fair region: fabric is wrinkled
# where this sculpt's skin is glassy, and unkeyed fabric (the bow's shadowed folds) must be
# faired on the proxy or the target cage carries it and replaces it with itself. Blanket-
# fairing the whole band instead was catastrophic: it removed the membrane's interior anchors
# (belly/waist/hip skin) and the wall collapsed into a cone spanning shoulders to thighs.
ev_r = np.empty(len(me.edges) * 2, dtype=np.int32)
me.edges.foreach_get("vertices", ev_r)
ev_r = ev_r.reshape(-1, 2)
o_r = np.concatenate([ev_r[:, 0], ev_r[:, 1]])
n_r = np.concatenate([ev_r[:, 1], ev_r[:, 0]])
s_r = np.argsort(o_r, kind="stable")
o_rs = o_r[s_r]
n_rs = n_r[s_r]
ptr_r = np.searchsorted(o_rs, np.arange(n_v + 1))
cnt_r = np.maximum(np.diff(ptr_r), 1)
sm_r = co.copy()
for _ in range(8):
su = np.add.reduceat(sm_r[n_rs], ptr_r[:-1], axis=0)
emp = np.diff(ptr_r) == 0
su[emp] = sm_r[emp]
sm_r = su / cnt_r[:, None]
rough = np.linalg.norm(co - sm_r, axis=1)
rough_zone = (core_top | core_bot) \
& ~((np.abs(co[:, 0]) < 0.018) & (co[:, 2] > 0.50) & (co[:, 2] < 0.55)) # navel
fabric_rough = rough_zone & (rough > 0.0008)
region = garment | corridor | fabric_rough
log(f"loaded {n_v}v; garment={garment.sum()} corridor={corridor.sum()} "
f"rough={fabric_rough.sum()}")
# ---------------- proxy ----------------
proxy = ob.copy()
proxy.data = ob.data.copy()
bpy.context.collection.objects.link(proxy)
dec = proxy.modifiers.new("dec", 'DECIMATE')
dec.ratio = PROXY_RATIO
bpy.context.view_layer.objects.active = proxy
bpy.ops.object.modifier_apply(modifier="dec")
pme = proxy.data
np_v = len(pme.vertices)
pco = np.empty(np_v * 3, dtype=np.float64)
pme.vertices.foreach_get("co", pco)
pco = pco.reshape(-1, 3)
log(f"proxy: {np_v}v")
# proxy zone classification by nearest hires vert (0 none,1 cup,2 toprest,3 briefs)
zone = np.zeros(n_v, dtype=np.int8)
zone[toprest] = 2
zone[briefs] = 3
zone[cups | shield] = 1
zone[corridor & (zone == 0)] = 4 # corridor-only: candidate fabric, decided later
zone[fabric_rough & core_top & (zone == 0)] = 2
zone[fabric_rough & core_bot & (zone == 0)] = 3
pool = np.concatenate([np.nonzero(region)[0], np.nonzero(~region)[0][::20]])
kd_h = KDTree(len(pool))
for j, i in enumerate(pool):
kd_h.insert(Vector(co[i]), j)
kd_h.balance()
p_zone = np.zeros(np_v, dtype=np.int8)
for i in range(np_v):
_, j, _ = kd_h.find(Vector(pco[i]))
p_zone[i] = zone[pool[j]]
p_free = p_zone > 0
log(f"proxy region: {p_free.sum()} (zones: " +
", ".join(f"{z}:{(p_zone==z).sum()}" for z in (1, 2, 3, 4)) + ")")
pn_e = len(pme.edges)
pev = np.empty(pn_e * 2, dtype=np.int32)
pme.edges.foreach_get("vertices", pev)
pev = pev.reshape(-1, 2)
padj = [[] for _ in range(np_v)]
for a, b in pev:
padj[a].append(b)
padj[b].append(a)
free = p_free.copy()
collar = free.copy()
for _ in range(2):
nxt = collar.copy()
for gi in np.nonzero(collar)[0]:
for nb in padj[gi]:
nxt[nb] = True
collar = nxt
collar &= ~free
S = np.nonzero(free | collar)[0]
in_S = np.zeros(np_v, dtype=bool)
in_S[S] = True
gl = np.full(np_v, -1, dtype=np.int64)
gl[S] = np.arange(len(S))
# S-local symmetric graph Laplacian (Ls = deg - adjacency), matrix-free
se = pev[in_S[pev].all(axis=1)]
a_l = gl[se[:, 0]]
b_l = gl[se[:, 1]]
deg = np.zeros(len(S))
np.add.at(deg, a_l, 1.0)
np.add.at(deg, b_l, 1.0)
freeS = free[S]
def Ls(X):
out = deg[:, None] * X
np.add.at(out, a_l, -X[b_l])
np.add.at(out, b_l, -X[a_l])
return out
def A_op(U): # (Ls^2)_ff applied to free values
X = np.zeros((len(S), 3))
X[freeS] = U
Y = Ls(Ls(X))
return Y[freeS]
Xc = np.zeros((len(S), 3))
Xc[~freeS] = pco[S[~freeS]]
b_rhs = -Ls(Ls(Xc))[freeS]
# CG (SPD system); matrix-free, so region size no longer matters
U = pco[S[freeS]].copy()
r = b_rhs - A_op(U)
pdir = r.copy()
rs = (r * r).sum()
for cg_it in range(20000):
Ap = A_op(pdir)
alpha = rs / max((pdir * Ap).sum(), 1e-30)
U += alpha * pdir
r -= alpha * Ap
rs_new = (r * r).sum()
if rs_new < 1e-18:
break
pdir = r + (rs_new / rs) * pdir
rs = rs_new
log(f"CG converged in {cg_it} iterations, residual {rs_new:.2e}")
p_wall = pco.copy()
p_wall[S[freeS]] = U
log(f"proxy wall solved, max move {np.linalg.norm(p_wall-pco,axis=1).max():.4f}")
# proxy wall vertex normals (area-weighted, at wall coords)
pl_tot = np.empty(len(pme.polygons), dtype=np.int32)
pme.polygons.foreach_get("loop_total", pl_tot)
pl_start = np.empty(len(pme.polygons), dtype=np.int32)
pme.polygons.foreach_get("loop_start", pl_start)
pl_v = np.empty(len(pme.loops), dtype=np.int32)
pme.loops.foreach_get("vertex_index", pl_v)
p_nrm = np.zeros((np_v, 3))
for s, t in zip(pl_start, pl_tot):
idxs = pl_v[s:s + t]
fn = np.cross(p_wall[idxs[1]] - p_wall[idxs[0]], p_wall[idxs[2]] - p_wall[idxs[0]])
for vi in idxs:
p_nrm[vi] += fn
p_nrm /= np.maximum(np.linalg.norm(p_nrm, axis=1, keepdims=True), 1e-12)
# proxy mound field: ERODE (2-ring min filter), then smooth. The sports top's decorative
# bow-knot and the hem lips are narrow POSITIVE relief riding on the broad mound; smoothing
# alone spreads them, and amplification then blew the bow up into a fist-sized rosette on the
# inner cup. A min-filter deletes narrow positive relief outright while barely shrinking the
# wide mound underneath.
p_dn = np.einsum("ij,ij->i", pco - p_wall, p_nrm)
for _ in range(3):
p_min = p_dn.copy()
np.minimum.at(p_min, pev[:, 0], p_dn[pev[:, 1]])
np.minimum.at(p_min, pev[:, 1], p_dn[pev[:, 0]])
p_dn = p_min
for _ in range(PDN_SMOOTH):
acc = np.zeros(np_v)
cnt = np.zeros(np_v)
np.add.at(acc, pev[:, 0], p_dn[pev[:, 1]])
np.add.at(acc, pev[:, 1], p_dn[pev[:, 0]])
np.add.at(cnt, pev[:, 0], 1)
np.add.at(cnt, pev[:, 1], 1)
sm = acc / np.maximum(cnt, 1)
upd = free | collar
p_dn[upd] = 0.5 * p_dn[upd] + 0.5 * sm[upd]
# per-proxy-vertex TARGET surface
p_cle = np.clip((np.abs(pco[:, 0]) - CLEAV_GAP) / CLEAV_W, 0.0, 1.0)
p_cle = p_cle * p_cle * (3 - 2 * p_cle)
cup_dn = p_dn[(p_zone == 1)]
k_amp = AMP_TARGET / max(np.percentile(cup_dn, 99.5), 1e-4)
p_field = np.zeros(np_v)
mcup = p_zone == 1
p_field[mcup] = np.maximum(p_dn[mcup], 0.0) * k_amp * p_cle[mcup]
mbri = p_zone == 3
bri_fade = np.clip((0.595 - pco[:, 2]) / 0.030, 0.0, 1.0)
bri_fade = bri_fade * bri_fade * (3 - 2 * bri_fade)
p_field[mbri] = p_dn[mbri] * bri_fade[mbri]
# zones 2 (toprest) and 4 (corridor) stay 0 -> target = wall
# The cup field is NOT baked into the cage any more. Sequence proven by v3.13: first take the
# top fully off (wall replacement + melt + measured excision -> clean flat chest), THEN sculpt
# the breasts onto the healed surface as a separate post-pass. Entangling the mound field with
# the fabric-removal surface made every fabric fix fight the breast shape. The briefs field
# stays in the cage: it is her real hip/butt anatomy, not an addition.
p_field_cup = np.where(p_zone == 1, p_field, 0.0)
up_fade = np.clip((0.752 - pco[:, 2]) / 0.022, 0.0, 1.0)
up_fade = up_fade * up_fade * (3 - 2 * up_fade)
p_field_cup *= up_fade
# The zone mask is paint-keyed and SPECKLED at its borders; a cage built from a discontinuous
# field grows radial spikes that the delta application then amplifies into shredding. Diffuse
# the scalar over the proxy graph until the cage is built from a smooth function.
for _ in range(15):
accc = np.zeros(np_v)
cntc = np.zeros(np_v)
np.add.at(accc, pev[:, 0], p_field_cup[pev[:, 1]])
np.add.at(accc, pev[:, 1], p_field_cup[pev[:, 0]])
np.add.at(cntc, pev[:, 0], 1)
np.add.at(cntc, pev[:, 1], 1)
p_field_cup = 0.5 * p_field_cup + 0.5 * (accc / np.maximum(cntc, 1))
p_T = p_wall + p_nrm * (p_field - p_field_cup)[:, None]
log(f"amplification k = {k_amp:.2f}; proxy target ready (cup field deferred)")
# Reconstruct SMOOTH dense targets from the coarse fields via Catmull-Clark subdivision.
# (v3.0 lifted with inverse-distance weighting of scattered proxy points instead; IDW has
# vanishing gradients at every data site, so each proxy vertex owned a visible flat cell —
# the "crumpled polygon" look. A subdivided cage is the proper smooth-surface reconstruction:
# C2 almost everywhere, facets far below visual scale.)
def smooth_bvh(vcoords):
dup = ob.copy()
dup.data = pme_src.copy()
bpy.context.collection.objects.link(dup)
dup.data.vertices.foreach_set("co", vcoords.reshape(-1).astype(np.float64))
dup.data.update()
sub = dup.modifiers.new("s", 'SUBSURF')
sub.levels = 2
sub.render_levels = 2
bpy.context.view_layer.objects.active = dup
bpy.ops.object.modifier_apply(modifier="s")
dme = dup.data
dv = np.empty(len(dme.vertices) * 3)
dme.vertices.foreach_get("co", dv)
dv = dv.reshape(-1, 3)
dl_tot = np.empty(len(dme.polygons), dtype=np.int32)
dme.polygons.foreach_get("loop_total", dl_tot)
dl_start = np.empty(len(dme.polygons), dtype=np.int32)
dme.polygons.foreach_get("loop_start", dl_start)
dl_v = np.empty(len(dme.loops), dtype=np.int32)
dme.loops.foreach_get("vertex_index", dl_v)
dpolys = [dl_v[st:st + tt].tolist() for st, tt in zip(dl_start, dl_tot)]
tree = BVHTree.FromPolygons([Vector(v) for v in dv], dpolys,
all_triangles=False, epsilon=0.0)
bpy.data.objects.remove(dup, do_unlink=True)
return tree
pme_src = pme.copy() # keep proxy topology before the proxy object is deleted
bvh_wall = smooth_bvh(p_wall)
bvh_tgt = smooth_bvh(p_T)
bpy.data.objects.remove(proxy, do_unlink=True)
log("smooth subdivided targets ready")
# ---------------- decide the ACTIVE set first, then lift targets for ALL of it -------------
# (v3.2/3.3 grew the intent mask with morphological close and a boundary push, but targets were
# only computed for the original paint+corridor region — the bow at the sternum, strap tops
# above the corridor and the armpit folds ended up marked active WITHOUT a target, so they kept
# their fabric geometry untouched. Invariant now: active == lifted == replaced.)
n_e0 = len(me.edges)
ev0 = np.empty(n_e0 * 2, dtype=np.int32)
me.edges.foreach_get("vertices", ev0)
ev0 = ev0.reshape(-1, 2)
def grow_edges(mask, rings, edges):
out = mask.copy()
for _ in range(rings):
nxt = out.copy()
nxt[edges[:, 0]] |= out[edges[:, 1]]
nxt[edges[:, 1]] |= out[edges[:, 0]]
out = nxt
return out
def proud_of_wall(vi):
return (Vector(co[vi]) - bvh_wall.find_nearest(Vector(co[vi]))[0]).length > PROUD_THR
# corridor fabric: geometry decides
corr_idx = np.nonzero(corridor & ~garment)[0]
fabric_extra = np.zeros(n_v, dtype=bool)
for vi in corr_idx:
if proud_of_wall(vi):
fabric_extra[vi] = True
log(f"corridor fabric catch: {fabric_extra.sum()} of {len(corr_idx)}")
act_set = garment | fabric_extra | fabric_rough | core_top | core_bot
g5 = grow_edges(act_set, 3, ev0)
inv = grow_edges(~g5, 3, ev0)
act_set = ~inv
log(f"active after band+close: {act_set.sum()}")
# bounded push: boundary must rest on skin, never on proud fabric
allowed = (co[:, 2] > 0.38) & (co[:, 2] < 0.88) & (np.abs(co[:, 0]) < 0.17)
proud_cache = {}
for it in range(30):
bnd = np.zeros(n_v, dtype=bool)
e_mix = act_set[ev0[:, 0]] != act_set[ev0[:, 1]]
bnd[ev0[e_mix].ravel()] = True
bnd &= act_set
bad = []
for vi in np.nonzero(bnd)[0]:
if vi not in proud_cache:
proud_cache[vi] = proud_of_wall(vi)
if proud_cache[vi]:
bad.append(vi)
if not bad:
log(f"boundary clean after {it} grow steps")
break
ring = np.zeros(n_v, dtype=bool)
ring[bad] = True
act_set |= grow_edges(ring, 2, ev0) & allowed
else:
log(f"NOTE: boundary push capped at 30 steps ({len(bad)} proud verts remain, "
f"likely at the allowed-region rim)")
# ---------------- lift: snap every ACTIVE vert to the smooth target surface ----------------
ridx = np.nonzero(act_set)[0]
active = np.ones(len(ridx), dtype=bool)
T = np.zeros((len(ridx), 3))
for k, i in enumerate(ridx):
T[k] = bvh_tgt.find_nearest(Vector(co[i]))[0]
log(f"lift done for {len(ridx)} active verts")
# ring-depth blend: 0 at the (now skin-resting) boundary, 1 from BLEND_RINGS inward
order = np.concatenate([ev0[:, 0], ev0[:, 1]])
nbr = np.concatenate([ev0[:, 1], ev0[:, 0]])
srt = np.argsort(order, kind="stable")
o_s = order[srt]
n_s = nbr[srt]
ptr = np.searchsorted(o_s, np.arange(n_v + 1))
depth = np.zeros(n_v, dtype=np.int32)
dq = deque()
seen = np.zeros(n_v, dtype=bool)
for a, b in ev0:
if act_set[a] != act_set[b]:
sv = b if act_set[b] else a
if not seen[sv]:
seen[sv] = True
depth[sv] = 1
dq.append(sv)
while dq:
c = dq.popleft()
for nb in n_s[ptr[c]:ptr[c + 1]]:
if act_set[nb] and not seen[nb]:
seen[nb] = True
depth[nb] = depth[c] + 1
dq.append(nb)
depth[act_set & ~seen] = BLEND_RINGS + 1
wgt = np.clip(depth[ridx] / float(BLEND_RINGS), 0.0, 1.0)
wgt = wgt * wgt * (3 - 2 * wgt)
log(f"blend: {(wgt >= 1).sum()} full, {((wgt > 0) & (wgt < 1)).sum()} ramp")
co_new = co.copy()
co_new[ridx] = co[ridx] + (T - co[ridx]) * wgt[:, None]
# short Taubin polish over the replaced area (kills residual IDW dimples)
cnt_all = np.maximum(np.diff(ptr), 1)
def nb_mean(P):
sums = np.add.reduceat(P[n_s], ptr[:-1], axis=0)
empty = np.diff(ptr) == 0
sums[empty] = P[empty]
return sums / cnt_all[:, None]
pol = act_set & (depth >= BLEND_RINGS)
pidx = np.nonzero(pol)[0]
for _ in range(POLISH_ITERS):
for f in (0.55, -0.58):
d = (nb_mean(co_new) - co_new) * f
co_new[pidx] += d[pidx]
log(f"polish: {len(pidx)} verts, {POLISH_ITERS} Taubin pairs")
# MELT pass: whatever fabric decoration still shows (the bow/lacing scar defeated the colour
# key, the roughness threshold AND the proud test), it is by definition ROUGH ON THE RESULT.
# Detect residual roughness inside the front-chest window on co_new itself and aggressively
# smooth just those verts into the surrounding replaced surface. Local and bounded: it cannot
# move anything that is already smooth.
sm2 = co_new.copy()
for _ in range(8):
su2 = np.add.reduceat(sm2[n_rs], ptr_r[:-1], axis=0)
emp2 = np.diff(ptr_r) == 0
su2[emp2] = sm2[emp2]
sm2 = su2 / cnt_r[:, None]
rough2 = np.linalg.norm(co_new - sm2, axis=1)
melt_win = (co[:, 2] > 0.42) & (co[:, 2] < 0.81) & (np.abs(co[:, 0]) < 0.15) & ~((np.abs(co[:, 0]) < 0.018) & (co[:, 2] > 0.50) & (co[:, 2] < 0.55)) # navel
melt = melt_win & (rough2 > 0.0006)
melt = grow_edges(melt, 3, ev0)
midx = np.nonzero(melt)[0]
for _ in range(60):
for f in (0.55, -0.58):
d2 = (nb_mean(co_new) - co_new) * f
co_new[midx] += d2[midx]
log(f"melt: {len(midx)} rough verts smoothed hard")
# BOW EXCISION (v4.8 configuration, restored): bi-harmonic heal of the decorated front
# window, run to CONVERGENCE. Wall-snap variants sampled unfaired cage patches (raw bow) back
# onto the chest, and fairing the window on the proxy collapsed the mound source field —
# both reverted. The converged membrane leaves a soft valley between the upper mounds, which
# the reference image shows as natural anatomy.
bow_win = (co[:, 1] < 0) & (np.abs(co[:, 0]) < 0.080) & (co[:, 2] > 0.630) & (co[:, 2] < 0.802)
bow_free = grow_edges(bow_win, 2, ev0)
bow_collar = grow_edges(bow_free, 2, ev0) & ~bow_free
Sb = np.nonzero(bow_free | bow_collar)[0]
in_Sb = np.zeros(n_v, dtype=bool)
in_Sb[Sb] = True
glb_ = np.full(n_v, -1, dtype=np.int64)
glb_[Sb] = np.arange(len(Sb))
seb = ev0[in_Sb[ev0].all(axis=1)]
a_b = glb_[seb[:, 0]]
b_b = glb_[seb[:, 1]]
degb = np.zeros(len(Sb))
np.add.at(degb, a_b, 1.0)
np.add.at(degb, b_b, 1.0)
freeB = bow_free[Sb]
def Lsb(X):
out = degb[:, None] * X
np.add.at(out, a_b, -X[b_b])
np.add.at(out, b_b, -X[a_b])
return out
def A_b(U):
X = np.zeros((len(Sb), 3))
X[freeB] = U
return Lsb(Lsb(X))[freeB]
Xcb = np.zeros((len(Sb), 3))
Xcb[~freeB] = co_new[Sb[~freeB]]
rhs_b = -Lsb(Lsb(Xcb))[freeB]
Ub = co_new[Sb[freeB]].copy()
r_b = rhs_b - A_b(Ub)
p_b = r_b.copy()
rs_b = (r_b * r_b).sum()
rs0_b = rs_b
for it_b in range(120000):
Apb = A_b(p_b)
al = rs_b / max((p_b * Apb).sum(), 1e-30)
Ub += al * p_b
r_b -= al * Apb
rs2 = (r_b * r_b).sum()
if rs2 < 1e-18 or rs2 < rs0_b * 1e-14:
break
p_b = r_b + (rs2 / rs_b) * p_b
rs_b = rs2
co_new[Sb[freeB]] = Ub
log(f"bow excision: {freeB.sum()} verts healed (CG {it_b} iters, "
f"rel residual {rs2/max(rs0_b,1e-30):.2e})")
# ---- STEP 2: sculpt the breasts onto the healed chest ----
# Applied as the DELTA between two subdivided cages: (wall + cup field) minus (wall). Smooth
# everywhere by construction and exactly zero outside the mound footprint, so it composes with
# the healed chest without steps. (A scalar IDW lift was tried first and re-created the
# flat-spot bubbling that killed v3.0 — same lesson, same fix: reconstruct through a
# subdivided cage, never by scattered-point interpolation.)
bvh_mound = smooth_bvh(p_wall + p_nrm * p_field_cup[:, None])
chest_win = (co[:, 1] < 0.02) & (co[:, 2] > 0.585) & (co[:, 2] < 0.80) & (np.abs(co[:, 0]) < 0.115)
widx = np.nonzero(chest_win)[0]
applied = 0
apex_d = 0.0
# Sample the mound HEIGHT in the wall's own frame: nearest wall point W with its smooth normal
# N, then ray-cast the mound cage along N. One shared frame — unlike the nearest-point delta
# (whose two nearest points are DIFFERENT surface locations on steep slopes; their difference
# carries wild tangential components and shredded the mounds), height-along-normal is a
# continuous scalar field over the wall, so the applied surface inherits both cages' smoothness.
h_arr = np.zeros(len(widx))
N_arr = np.zeros((len(widx), 3))
for k_, i_ in enumerate(widx):
pos_v = Vector(co_new[i_])
hw = bvh_wall.find_nearest(pos_v)
Wp, Nn = hw[0], hw[1]
N_arr[k_] = np.array(Nn)
rc = bvh_mound.ray_cast(Wp - Nn * 0.004, Nn, 0.09)
if rc[0] is not None:
h_arr[k_] = max((Vector(rc[0]) - Wp).dot(Nn), 0.0)
# The raw per-vertex heights carry sampling noise (adjacent rays graze different cage faces),
# which rendered as hairline cracks. Smooth the SCALAR height over the window's mesh graph,
# then renormalise to the target apex — a smooth scalar along smooth normals is artifact-free.
in_win = np.zeros(n_v, dtype=bool)
in_win[widx] = True
pos_win = np.full(n_v, -1, dtype=np.int64)
pos_win[widx] = np.arange(len(widx))
we = ev0[in_win[ev0].all(axis=1)]
wa = pos_win[we[:, 0]]
wb = pos_win[we[:, 1]]
for _ in range(15):
acch = np.zeros(len(widx))
cnth = np.zeros(len(widx))
np.add.at(acch, wa, h_arr[wb])
np.add.at(acch, wb, h_arr[wa])
np.add.at(cnth, wa, 1)
np.add.at(cnth, wb, 1)
mh = acch / np.maximum(cnth, 1)
h_arr = 0.5 * h_arr + 0.5 * mh
# the DIRECTION field cracks too: find_nearest returns per-face normals of the subdivided
# cage, and at 30+ mm of displacement a 2-degree jump between neighbouring faces opens a
# millimetre crack. Smooth the normals with the heights.
accn = np.zeros((len(widx), 3))
np.add.at(accn, wa, N_arr[wb])
np.add.at(accn, wb, N_arr[wa])
mn_ = accn / np.maximum(cnth, 1)[:, None]
N_arr = 0.5 * N_arr + 0.5 * mn_
N_arr /= np.maximum(np.linalg.norm(N_arr, axis=1, keepdims=True), 1e-12)
if h_arr.max() > 1e-4:
# gamma < 1 fattens the mid-slopes: the reference mounds are near-hemispherical (full
# shoulder), not shallow domes
h_arr = h_arr.max() * (h_arr / h_arr.max()) ** 0.75
h_arr *= AMP_TARGET / h_arr.max()
co_new[widx] += N_arr * h_arr[:, None]
log(f"breast field applied (smoothed heights): {(h_arr > 1e-4).sum()} verts, "
f"apex {h_arr.max():.4f}")
# seam polish: the ramp boundaries leave faint horizontal lines at the old band edges; both
# sides are smooth surfaces now, so a light local Taubin along the boundary rings erases the
# lines without moving anything else
bnd_f = np.zeros(n_v, dtype=bool)
e_mix2 = act_set[ev0[:, 0]] != act_set[ev0[:, 1]]
bnd_f[ev0[e_mix2].ravel()] = True
seam_band = grow_edges(bnd_f, 4, ev0)
sidx = np.nonzero(seam_band)[0]
for _ in range(20):
for f in (0.55, -0.58):
d3 = (nb_mean(co_new) - co_new) * f
co_new[sidx] += d3[sidx]
log(f"seam polish: {len(sidx)} boundary-band verts")
me.vertices.foreach_set("co", co_new.reshape(-1))
me.update()
if me.has_custom_normals:
vn = np.empty(n_v * 3, dtype=np.float32)
me.vertices.foreach_get("normal", vn)
me.normals_split_custom_set_from_vertices(vn.reshape(-1, 3))
# save active mask for the texture stage (corridor fabric needs repainting too)
np.savez_compressed(MASKS.replace(".npz", "_active.npz"), active=act_set)
bpy.context.preferences.filepaths.save_version = 0 # no .blend1 autosave
bpy.ops.wm.save_as_mainfile(filepath=OUT)
log(f"WROTE {OUT}")
fmax = p_field.max()
log(f"GATE apex projection (proxy field): {fmax:.4f} (target {AMP_TARGET})")
print("SCULPT_DONE")
Binary file not shown.
@@ -0,0 +1,99 @@
# Stage 4: review renders + numeric profile gates for the hires sculpt.
# blender --background --python 04_review.py -- <03_sculpted.blend> <review_dir>
import bpy, sys, os, math, time
import numpy as np
from mathutils import Vector
argv = sys.argv[sys.argv.index("--") + 1:]
BLEND, OUT = argv[0], argv[1]
os.makedirs(OUT, exist_ok=True)
t0 = time.time()
def log(m):
print(f"[review {time.time()-t0:6.1f}s] {m}", flush=True)
bpy.ops.wm.open_mainfile(filepath=BLEND)
ob = max([o for o in bpy.data.objects if o.type == 'MESH'],
key=lambda o: len(o.data.vertices))
me = ob.data
n_v = len(me.vertices)
co = np.empty(n_v * 3, dtype=np.float64)
me.vertices.foreach_get("co", co)
co = co.reshape(-1, 3)
# ---------- numeric gates ----------
front = co[:, 1] < 0
print("\n=== PROFILE y(z) at sternum x=0 (front) ===")
for z0 in np.arange(0.58, 0.78, 0.01):
m = front & (np.abs(co[:, 0]) < 0.004) & (np.abs(co[:, 2] - z0) < 0.005)
if m.sum():
print(f" z={z0:.2f} y={co[m,1].min():+.4f}")
print("=== PROFILE y(z) at apex x=0.034 ===")
for z0 in np.arange(0.58, 0.78, 0.01):
m = front & (np.abs(co[:, 0] - 0.034) < 0.005) & (np.abs(co[:, 2] - z0) < 0.005)
if m.sum():
print(f" z={z0:.2f} y={co[m,1].min():+.4f}")
print("=== PROFILE y(x) at z=0.688 ===")
for x0 in np.arange(-0.10, 0.101, 0.01):
m = front & (np.abs(co[:, 0] - x0) < 0.005) & (np.abs(co[:, 2] - 0.688) < 0.006)
if m.sum():
print(f" x={x0:+.2f} y={co[m,1].min():+.4f}")
# ---------- renders ----------
scn = bpy.context.scene
w = bpy.data.worlds.new("W")
w.color = (0.22, 0.22, 0.24)
scn.world = w
key = bpy.data.objects.new("Key", bpy.data.lights.new("Key", 'SUN'))
key.data.energy = 3.0
key.data.use_shadow = False
bpy.context.collection.objects.link(key)
fill = bpy.data.objects.new("Fill", bpy.data.lights.new("Fill", 'SUN'))
fill.data.energy = 1.0
fill.data.use_shadow = False
bpy.context.collection.objects.link(fill)
cam = bpy.data.objects.new("Cam", bpy.data.cameras.new("Cam"))
cam.data.lens = 85
bpy.context.collection.objects.link(cam)
scn.camera = cam
scn.render.engine = 'BLENDER_EEVEE' if bpy.app.version >= (4, 2) else 'BLENDER_EEVEE_NEXT'
scn.render.resolution_x = scn.render.resolution_y = 1000
clay_mat = bpy.data.materials.new("Clay")
clay_mat.use_nodes = True
clay_mat.node_tree.nodes["Principled BSDF"].inputs["Base Color"].default_value = (0.62, 0.60, 0.58, 1)
clay_mat.node_tree.nodes["Principled BSDF"].inputs["Roughness"].default_value = 0.45
orig_mats = [ms.material for ms in ob.material_slots]
def shoot(tag, ctr, span, yaw_deg, clay):
if clay:
for ms in ob.material_slots:
ms.material = clay_mat
else:
for ms, m in zip(ob.material_slots, orig_mats):
ms.material = m
yaw = math.radians(yaw_deg)
dist = span * 3.0
cam.location = Vector(ctr) + Vector((math.sin(yaw) * dist, -math.cos(yaw) * dist, 0.02))
cam.rotation_euler = (Vector(ctr) - cam.location).to_track_quat('-Z', 'Y').to_euler()
key.rotation_euler = (math.radians(62), 0, math.radians(35 + yaw_deg))
fill.rotation_euler = (math.radians(75), 0, math.radians(yaw_deg - 110))
scn.render.filepath = os.path.join(OUT, f"{tag}.png")
bpy.ops.render.render(write_still=True)
log(f"render {tag}")
CHEST = (0.0, 0.0, 0.675)
HIP = (0.0, 0.0, 0.53)
FULL = (0.0, 0.0, 0.50)
for yaw in (0, 40, 90):
shoot(f"chest_clay_{yaw}", CHEST, 0.22, yaw, True)
shoot(f"chest_tex_{yaw}", CHEST, 0.22, yaw, False)
shoot("hip_clay_0", HIP, 0.22, 0, True)
shoot("full_clay_0", FULL, 0.55, 0, True)
shoot("full_clay_40", FULL, 0.55, 40, True)
print("REVIEW_DONE")
@@ -0,0 +1,346 @@
# Stage 5: repaint the garment out of the textures (basecolor + normal + roughness/metallic),
# on the sculpted hires body.
#
# blender --background --python 05_texture.py -- <06_final.blend> <masks.npz>
# <00_welded.blend> <out.blend>
#
# The repaint mask is rebuilt from the ORIGINAL geometry (00_welded) because fabric verts are
# no longer rough after the sculpt: garment hemband key_raw rough strap corridor the
# bow-excision window the crotch box — the union of every region whose geometry was
# replaced, whose paint is fabric.
#
# Same principles that fixed the game-density bake, at hires scale:
# - fill tone comes from skin NEAREST ON THE BODY (KD over skin verts in 3D), never from
# atlas neighbourhoods — atlas-local fills gave wrong tones and island seams;
# - grain is transplanted from real skin tiles so the fill is not a smooth decal
# (per-channel high-pass — the channel-mixing blur bug is not repeated here);
# - normal map goes flat (128,128,255) and rm matches median skin over the same texels,
# so the fabric weave stops shading through after the paint is gone.
import bpy, sys, time
import numpy as np
from mathutils import Vector
from mathutils.kdtree import KDTree
argv = sys.argv[sys.argv.index("--") + 1:]
BLEND, MASKS, WELDED, OUT = argv[0], argv[1], argv[2], argv[3]
# 'patch' mode (v02 variant): repaint ONLY the garment texels, keep every other texel of the
# original skin — no rosy-chest mask, fill sources include the rosy skin so the patches blend
# with HER tones, and the fill is mirror-averaged so one side's blush can't splash one cup.
PATCH_ONLY = len(argv) > 4 and argv[4] == "patch"
t0 = time.time()
GRAIN_T = 16
FEATHER = 4
def log(m):
print(f"[tex {time.time()-t0:6.1f}s] {m}", flush=True)
# ---- build the repaint mask from ORIGINAL geometry ----
bpy.ops.wm.open_mainfile(filepath=WELDED)
ob0 = max([o for o in bpy.data.objects if o.type == 'MESH'],
key=lambda o: len(o.data.vertices))
me0 = ob0.data
n_v = len(me0.vertices)
co0 = np.empty(n_v * 3, dtype=np.float64)
me0.vertices.foreach_get("co", co0)
co0 = co0.reshape(-1, 3)
ev0 = np.empty(len(me0.edges) * 2, dtype=np.int32)
me0.edges.foreach_get("vertices", ev0)
ev0 = ev0.reshape(-1, 2)
o_r = np.concatenate([ev0[:, 0], ev0[:, 1]])
n_r = np.concatenate([ev0[:, 1], ev0[:, 0]])
s_r = np.argsort(o_r, kind="stable")
o_rs = o_r[s_r]
n_rs = n_r[s_r]
ptr_r = np.searchsorted(o_rs, np.arange(n_v + 1))
cnt_r = np.maximum(np.diff(ptr_r), 1)
sm_r = co0.copy()
for _ in range(8):
su = np.add.reduceat(sm_r[n_rs], ptr_r[:-1], axis=0)
emp = np.diff(ptr_r) == 0
su[emp] = sm_r[emp]
sm_r = su / cnt_r[:, None]
rough0 = np.linalg.norm(co0 - sm_r, axis=1)
M = np.load(MASKS)
corridor0 = (co0[:, 2] > 0.64) & (co0[:, 2] < 0.86) \
& (np.abs(co0[:, 0]) > 0.02) & (np.abs(co0[:, 0]) < 0.145)
strap_rough = corridor0 & (rough0 > 0.0005)
bow_win = (co0[:, 1] < 0) & (np.abs(co0[:, 0]) < 0.080) \
& (co0[:, 2] > 0.630) & (co0[:, 2] < 0.802)
crotch_box = (np.abs(co0[:, 0]) < 0.075) & (co0[:, 2] > 0.340) & (co0[:, 2] < 0.480)
garment = (M["garment"] | M["hemband"] | M["key_raw"]
| strap_rough | bow_win | crotch_box)
for _ in range(3): # grow so the rasterised fill overlaps every replaced-geometry rim
hit = garment[ev0[:, 0]] | garment[ev0[:, 1]]
g2 = garment.copy()
g2[ev0[:, 0]] |= hit
g2[ev0[:, 1]] |= hit
garment = g2
log(f"repaint mask: {garment.sum()} of {n_v} "
f"(strap_rough {strap_rough.sum()}, bow {bow_win.sum()}, crotch {crotch_box.sum()})")
# ---- now open the FINAL sculpted body and repaint on it ----
bpy.ops.wm.open_mainfile(filepath=BLEND)
ob = max([o for o in bpy.data.objects if o.type == 'MESH'],
key=lambda o: len(o.data.vertices))
me = ob.data
assert len(me.vertices) == n_v, "vertex count changed between welded and final"
log(f"loaded {n_v}v, garment {garment.sum()}")
co = np.empty(n_v * 3, dtype=np.float64)
me.vertices.foreach_get("co", co)
co = co.reshape(-1, 3)
imgs = {}
for i in bpy.data.images:
nm = i.name.lower()
if "basecolor" in nm:
imgs["base"] = i
elif "normal" in nm:
imgs["normal"] = i
elif "_rm" in nm or nm.endswith("rm.jpg"):
imgs["rm"] = i
log(f"images: { {k: v.name for k, v in imgs.items()} }")
base = imgs["base"]
w, h = base.size
buf = np.empty(w * h * 4, dtype=np.float32)
base.pixels.foreach_get(buf)
rgb = buf.reshape(h, w, 4)
loops_v = np.empty(len(me.loops), dtype=np.int32)
me.loops.foreach_get("vertex_index", loops_v)
uv = np.empty(len(me.loops) * 2, dtype=np.float64)
me.uv_layers.active.data.foreach_get("uv", uv)
uv = uv.reshape(-1, 2)
lx = np.clip(uv[:, 0], 0, 1) * (w - 1)
ly = np.clip(uv[:, 1], 0, 1) * (h - 1)
first_loop = np.full(n_v, len(loops_v), dtype=np.int64)
np.minimum.at(first_loop, loops_v, np.arange(len(loops_v), dtype=np.int64))
first_loop = np.minimum(first_loop, len(loops_v) - 1)
vcol = rgb[ly[first_loop].astype(int), lx[first_loop].astype(int), :3]
# ---- rosy paint: the original body has a sunburn-like blush V on the upper chest/throat.
# It reads as a tan line on the nude (uniform-skin decision) and it poisons the 3D-nearest
# fill (asymmetric pink cups). Detect it per-vertex, repaint it, and never sample from it.
band = (co[:, 2] > 0.30) & (co[:, 2] < 0.905)
rg = vcol[:, 0] - vcol[:, 1]
# reference tone is the BELLY, not the band median — the whole upper chest is rosy, so a
# band median is itself rosy-biased and lets the blush field through (measured: belly rg
# 0.239, upper chest 0.30-0.33; a med+0.05 cut only caught the extreme pink core)
belly = ~garment & (co[:, 1] < 0) & (co[:, 2] > 0.45) & (co[:, 2] < 0.60) & (np.abs(co[:, 0]) < 0.08)
med_rg = np.median(rg[belly])
rosy = band & (rg > med_rg + 0.045)
if PATCH_ONLY:
log(f"patch mode: garment texels only, mask {garment.sum()}")
else:
rosy_chest = rosy & (co[:, 1] < 0.01) & (co[:, 2] > 0.55)
garment = garment | rosy_chest
log(f"rosy: {rosy.sum()} total, chest repaint {rosy_chest.sum()} "
f"(belly med_rg {med_rg:.3f}) -> mask {garment.sum()}")
# ---- per-vertex fill colour: K nearest skin verts in 3D ----
if PATCH_ONLY:
skin = ~garment & (co[:, 2] > 0.30) & (co[:, 2] < 0.86)
else:
skin = ~garment & ~rosy & (co[:, 2] > 0.30) & (co[:, 2] < 0.86)
skin_idx = np.nonzero(skin)[0][::3] # 1-in-3 sample is plenty at this density
kd = KDTree(len(skin_idx))
for j, i in enumerate(skin_idx):
kd.insert(Vector(co[i]), j)
kd.balance()
log(f"skin KD: {len(skin_idx)} verts")
gidx = np.nonzero(garment)[0]
fill_c = vcol.copy()
def idw_at(p):
hits = kd.find_n(Vector(p), 8)
wsum = 0.0
acc = np.zeros(3)
for (_, j, dist) in hits:
wgt = 1.0 / max(dist * dist, 1e-9)
acc += wgt * vcol[skin_idx[j]]
wsum += wgt
return acc / wsum
for i in gidx:
c1 = idw_at(co[i])
if PATCH_ONLY:
# mirror-average so asymmetric blush near one cup cannot tint only that cup
c2 = idw_at([-co[i][0], co[i][1], co[i][2]])
fill_c[i] = 0.5 * (c1 + c2)
else:
fill_c[i] = c1
log("per-vertex fill colours done")
# ---- rasterise fill over garment faces ----
n_f = len(me.polygons)
l_tot = np.empty(n_f, dtype=np.int32)
me.polygons.foreach_get("loop_total", l_tot)
l_start = np.empty(n_f, dtype=np.int32)
me.polygons.foreach_get("loop_start", l_start)
gv = np.zeros(n_v, dtype=bool)
gv[gidx] = True
face_g = np.zeros(n_f, dtype=bool)
# a face is garment if ANY corner is (covers the rim); loop over faces via numpy reduceat
face_flag = np.add.reduceat(gv[loops_v].astype(np.int32), l_start)
face_g = face_flag > 0
log(f"garment faces: {face_g.sum()}")
mask_px = np.zeros((h, w), dtype=bool)
out_rgb = rgb[:, :, :3].astype(np.float64)
for fi in np.nonzero(face_g)[0]:
s, t = l_start[fi], l_tot[fi]
li = np.arange(s, s + t)
P = np.stack([lx[li], ly[li]], axis=1)
V = loops_v[li]
x0, x1 = int(P[:, 0].min()), int(np.ceil(P[:, 0].max()))
y0, y1 = int(P[:, 1].min()), int(np.ceil(P[:, 1].max()))
if x1 - x0 > 256 or y1 - y0 > 256 or x1 < x0 or y1 < y0:
continue
if t != 3:
P = P[:3]
V = V[:3]
d = ((P[1, 1] - P[2, 1]) * (P[0, 0] - P[2, 0]) +
(P[2, 0] - P[1, 0]) * (P[0, 1] - P[2, 1]))
if abs(d) < 1e-12:
continue
gx, gy = np.meshgrid(np.arange(x0, min(x1, w - 1) + 1),
np.arange(y0, min(y1, h - 1) + 1))
a = ((P[1, 1] - P[2, 1]) * (gx - P[2, 0]) + (P[2, 0] - P[1, 0]) * (gy - P[2, 1])) / d
b = ((P[2, 1] - P[0, 1]) * (gx - P[2, 0]) + (P[0, 0] - P[2, 0]) * (gy - P[2, 1])) / d
c = 1.0 - a - b
ins = (a >= -0.03) & (b >= -0.03) & (c >= -0.03)
if not ins.any():
continue
col = (a[ins, None] * fill_c[V[0]] + b[ins, None] * fill_c[V[1]]
+ c[ins, None] * fill_c[V[2]])
out_rgb[gy[ins], gx[ins]] = col
mask_px[gy[ins], gx[ins]] = True
log(f"rasterised fill: {mask_px.sum()} texels")
# ---- absorb mask-rim slivers (white stitch piping extends past the colour key, and UV
# island borders leave old texels between rasterised faces): dilate 8 px, propagate fill
# colours outward into the ring so no legacy pixel survives inside the dilated mask.
def dil1(m):
g = m.copy()
g[1:, :] |= m[:-1, :]
g[:-1, :] |= m[1:, :]
g[:, 1:] |= m[:, :-1]
g[:, :-1] |= m[:, 1:]
return g
dil = mask_px.copy()
for _ in range(8):
dil = dil1(dil)
ring = dil & ~mask_px
C = out_rgb.copy()
have = mask_px.copy()
for _ in range(10):
if not (ring & ~have).any():
break
Wf = have.astype(np.float64)
acc = np.zeros_like(C)
wacc = np.zeros((h, w))
for dy, dx in ((1, 0), (-1, 0), (0, 1), (0, -1)):
acc += np.roll(C * Wf[:, :, None], (dy, dx), axis=(0, 1))
wacc += np.roll(Wf, (dy, dx), axis=(0, 1))
newly = ring & ~have & (wacc > 0)
C[newly] = acc[newly] / wacc[newly, None]
have |= newly
out_rgb[ring & have] = C[ring & have]
mask_px |= ring & have
log(f"rim absorb: +{(ring & have).sum()} ring texels -> mask {mask_px.sum()}")
def box_blur1(a2, r):
def b1(x, axis):
p = [(0, 0)] * x.ndim
p[axis] = (r, r)
cs = np.cumsum(np.pad(x, p, mode="edge"), axis=axis)
return (np.take(cs, np.arange(2 * r, cs.shape[axis]), axis=axis) -
np.take(cs, np.arange(0, cs.shape[axis] - 2 * r), axis=axis)) / (2 * r)
return b1(b1(a2, 0), 1)
# ---- grain transplant (per-channel high-pass, tile-based) ----
src_ok = ~mask_px
grain = np.stack([out_rgb[:, :, c] - box_blur1(out_rgb[:, :, c], 5) for c in range(3)], axis=2)
# candidate tiles must be clean AND skin-toned (the atlas also holds eyes/lips whose
# high-contrast grain would streak the fill)
skin_tone = np.median(out_rgb[mask_px], axis=0) if mask_px.any() else np.array([0.6, 0.45, 0.38])
cand = []
for ty in range(0, h - GRAIN_T, GRAIN_T):
for tx in range(0, w - GRAIN_T, GRAIN_T):
if not src_ok[ty:ty + GRAIN_T, tx:tx + GRAIN_T].all():
continue
tmean = out_rgb[ty:ty + GRAIN_T, tx:tx + GRAIN_T].reshape(-1, 3).mean(axis=0)
if np.abs(tmean - skin_tone).max() < 0.13:
cand.append((ty, tx))
rng = np.random.RandomState(77)
covered = 0
for ty in range(0, h - GRAIN_T + 1, GRAIN_T):
for tx in range(0, w - GRAIN_T + 1, GRAIN_T):
tm = mask_px[ty:ty + GRAIN_T, tx:tx + GRAIN_T]
if not tm.any():
continue
sy, sx = cand[rng.randint(len(cand))]
blk = out_rgb[ty:ty + GRAIN_T, tx:tx + GRAIN_T]
blk[tm] += grain[sy:sy + GRAIN_T, sx:sx + GRAIN_T][tm] * 0.85
covered += int(tm.sum())
log(f"grain: {covered} texels from {len(cand)} source tiles")
# feather rim
a_ = np.ones((h, w))
edge = mask_px.copy()
for k in range(FEATHER):
grown = edge.copy()
grown[1:-1, 1:-1] |= (edge[:-2, 1:-1] | edge[2:, 1:-1] | edge[1:-1, :-2] | edge[1:-1, 2:])
ring = grown & ~edge
a_[ring] = (k + 1) / (FEATHER + 1.0)
edge = grown
blend = np.where(mask_px, 1.0, 1.0 - a_)[:, :, None]
orig = rgb[:, :, :3].astype(np.float64)
final = np.clip(out_rgb * blend + orig * (1 - blend), 0, 1)
buf4 = rgb.copy()
buf4[:, :, :3] = final.astype(np.float32)
base.pixels.foreach_set(buf4.reshape(-1))
base.pack()
log("basecolor updated + packed")
# ---- normal + rm: neutralise over the same texels ----
for key_, flatval in (("normal", None), ("rm", None)):
if key_ not in imgs:
continue
im = imgs[key_]
iw, ih = im.size
b2 = np.empty(iw * ih * 4, dtype=np.float32)
im.pixels.foreach_get(b2)
arr = b2.reshape(ih, iw, 4)
if (iw, ih) != (w, h):
log(f" {key_}: size {iw}x{ih} != base — skipping")
continue
if key_ == "normal":
arr[mask_px, 0] = 0.5
arr[mask_px, 1] = 0.5
arr[mask_px, 2] = 1.0
else:
med = np.median(arr[src_ok][:, :3], axis=0)
arr[mask_px, 0] = med[0]
arr[mask_px, 1] = med[1]
arr[mask_px, 2] = med[2]
im.pixels.foreach_set(arr.reshape(-1))
im.pack()
log(f" {key_}: neutralised {mask_px.sum()} texels + packed")
bpy.context.preferences.filepaths.save_version = 0 # no .blend1 autosave
bpy.ops.wm.save_as_mainfile(filepath=OUT)
log(f"WROTE {OUT}")
print("TEXTURE_DONE")
@@ -0,0 +1,128 @@
# Stage 5b: global rosy-tone grade. Hard vertex-mask repaints leave visible boundaries
# (lighter V patch vs darker shoulders, pink neck above the z-cap). Instead: per-texel,
# compute the SMOOTHED red-green excess over the belly reference and subtract it with a
# z-tapered weight. Smooth field in, smooth field out — no boundaries; the high-frequency
# detail (pores, grain) rides on top untouched. Face is excluded by the taper (w=0 above
# z 0.91); lips/cheeks keep their red.
# blender --background --python 05b_tone_grade.py -- <07_textured.blend> <out.blend>
import bpy, sys, time
import numpy as np
argv = sys.argv[sys.argv.index("--") + 1:]
BLEND, OUT = argv[0], argv[1]
t0 = time.time()
REF_RG = None # belly reference; measured from the mesh below if None
STRENGTH = 0.90
Z_UP0, Z_UP1 = 0.26, 0.30 # taper in above the feet
Z_DN0, Z_DN1 = 0.88, 0.91 # taper out below the face
BLUR_R = 12
def log(m):
print(f"[grade {time.time()-t0:6.1f}s] {m}", flush=True)
bpy.ops.wm.open_mainfile(filepath=BLEND)
ob = max([o for o in bpy.data.objects if o.type == 'MESH'],
key=lambda o: len(o.data.vertices))
me = ob.data
n_v = len(me.vertices)
co = np.empty(n_v * 3)
me.vertices.foreach_get("co", co)
co = co.reshape(-1, 3)
base = next(i for i in bpy.data.images if "basecolor" in i.name.lower())
w, h = base.size
buf = np.empty(w * h * 4, dtype=np.float32)
base.pixels.foreach_get(buf)
rgb = buf.reshape(h, w, 4)
out_rgb = rgb[:, :, :3].astype(np.float64)
loops_v = np.empty(len(me.loops), dtype=np.int32)
me.loops.foreach_get("vertex_index", loops_v)
uv = np.empty(len(me.loops) * 2)
me.uv_layers.active.data.foreach_get("uv", uv)
uv = uv.reshape(-1, 2)
lx = np.clip(uv[:, 0], 0, 1) * (w - 1)
ly = np.clip(uv[:, 1], 0, 1) * (h - 1)
# belly reference from the mesh (sample texels under belly verts)
first_loop = np.full(n_v, len(loops_v), dtype=np.int64)
np.minimum.at(first_loop, loops_v, np.arange(len(loops_v), dtype=np.int64))
first_loop = np.minimum(first_loop, len(loops_v) - 1)
vcol = out_rgb[ly[first_loop].astype(int), lx[first_loop].astype(int)]
belly = (co[:, 1] < 0) & (co[:, 2] > 0.45) & (co[:, 2] < 0.60) & (np.abs(co[:, 0]) < 0.08)
ref = REF_RG if REF_RG is not None else float(np.median(vcol[belly, 0] - vcol[belly, 1]))
log(f"belly ref rg: {ref:.3f}")
def wz(z):
a = np.clip((z - Z_UP0) / (Z_UP1 - Z_UP0), 0, 1)
b = np.clip((Z_DN1 - z) / (Z_DN1 - Z_DN0), 0, 1)
t = np.minimum(a, b)
return t * t * (3 - 2 * t)
# rasterise the per-texel weight from vertex z over ALL faces
n_f = len(me.polygons)
l_tot = np.empty(n_f, dtype=np.int32)
me.polygons.foreach_get("loop_total", l_tot)
l_start = np.empty(n_f, dtype=np.int32)
me.polygons.foreach_get("loop_start", l_start)
vw = wz(co[:, 2])
W = np.zeros((h, w))
for fi in range(n_f):
s, t = l_start[fi], l_tot[fi]
li = np.arange(s, s + min(t, 3))
V = loops_v[li]
if vw[V].max() <= 0:
continue
P = np.stack([lx[li], ly[li]], axis=1)
x0, x1 = int(P[:, 0].min()), int(np.ceil(P[:, 0].max()))
y0, y1 = int(P[:, 1].min()), int(np.ceil(P[:, 1].max()))
if x1 - x0 > 256 or y1 - y0 > 256 or x1 < x0 or y1 < y0:
continue
d = ((P[1, 1] - P[2, 1]) * (P[0, 0] - P[2, 0]) +
(P[2, 0] - P[1, 0]) * (P[0, 1] - P[2, 1]))
if abs(d) < 1e-12:
continue
gx, gy = np.meshgrid(np.arange(x0, min(x1, w - 1) + 1),
np.arange(y0, min(y1, h - 1) + 1))
a = ((P[1, 1] - P[2, 1]) * (gx - P[2, 0]) + (P[2, 0] - P[1, 0]) * (gy - P[2, 1])) / d
b = ((P[2, 1] - P[0, 1]) * (gx - P[2, 0]) + (P[0, 0] - P[2, 0]) * (gy - P[2, 1])) / d
c = 1.0 - a - b
ins = (a >= -0.03) & (b >= -0.03) & (c >= -0.03)
if not ins.any():
continue
wv = a[ins] * vw[V[0]] + b[ins] * vw[V[1]] + c[ins] * vw[V[2]]
W[gy[ins], gx[ins]] = np.maximum(W[gy[ins], gx[ins]], np.clip(wv, 0, 1))
log(f"weight rasterised: {(W > 0).sum()} texels")
def box_blur1(a2, r):
def b1(x, axis):
p = [(0, 0)] * x.ndim
p[axis] = (r, r)
cs = np.cumsum(np.pad(x, p, mode="edge"), axis=axis)
return (np.take(cs, np.arange(2 * r, cs.shape[axis]), axis=axis) -
np.take(cs, np.arange(0, cs.shape[axis] - 2 * r), axis=axis)) / (2 * r)
return b1(b1(a2, 0), 1)
# smoothed rg field, weighted by W so face/eye texels can't bleed into the blur across
# island borders more than locally
rg_s = box_blur1(out_rgb[:, :, 0], BLUR_R) - box_blur1(out_rgb[:, :, 1], BLUR_R)
delta = np.maximum(0.0, rg_s - ref) * W * STRENGTH
out_rgb[:, :, 0] -= delta * 0.8
out_rgb[:, :, 1] += delta * 0.2
log(f"graded: {(delta > 0.005).sum()} texels above 0.005, max delta {delta.max():.3f}")
buf4 = rgb.copy()
buf4[:, :, :3] = np.clip(out_rgb, 0, 1).astype(np.float32)
base.pixels.foreach_set(buf4.reshape(-1))
base.pack()
bpy.context.preferences.filepaths.save_version = 0 # no .blend1 autosave
bpy.ops.wm.save_as_mainfile(filepath=OUT)
log(f"WROTE {OUT}")
print("GRADE_DONE")
@@ -0,0 +1,193 @@
# Stage 6: replace the crotch with the MALE game body's construction (Jeremy's directive —
# the male is already the smooth doll-like build; the female must match it exactly).
#
# blender --background --python 06_crotch.py -- <03_final_geometry.blend> <male.glb> <out.blend>
#
# Method: detect the crotch saddle landmark on both bodies (lowest midline point of the torso
# between the leg roots), scale the male crotch patch by body-height ratio, translate saddle to
# saddle, subdivide the patch to a smooth cage, and snap the female crotch window onto it with
# a ring-depth blend. The male GLB is in METRES; this sculpt is 0.9792 units tall.
import bpy, sys, time
from collections import deque
import numpy as np
from mathutils import Vector
from mathutils.bvhtree import BVHTree
argv = sys.argv[sys.argv.index("--") + 1:]
BLEND, MALE, OUT = argv[0], argv[1], argv[2]
t0 = time.time()
WIN_R_Z = 0.042 # female window: half-height around the saddle
WIN_R_X = 0.034 # half-width — must NOT reach the inner-thigh walls
SNAP_MAX = 0.020 # reject snaps to far surfaces (a bad cage grabbed thighs at 60 mm)
BLEND_RINGS = 8
def log(m):
print(f"[crotch {time.time()-t0:6.1f}s] {m}", flush=True)
bpy.ops.wm.open_mainfile(filepath=BLEND)
ob = max([o for o in bpy.data.objects if o.type == 'MESH'],
key=lambda o: len(o.data.vertices))
me = ob.data
n_v = len(me.vertices)
co = np.empty(n_v * 3)
me.vertices.foreach_get("co", co)
co = co.reshape(-1, 3)
f_height = co[:, 2].max() - co[:, 2].min()
# female saddle: lowest midline torso point between the legs (the briefs bridge the crotch,
# so the midline strip is continuous surface)
mid_f = (np.abs(co[:, 0]) < 0.01) & (co[:, 2] > 0.38) & (co[:, 2] < 0.50)
saddle_f = co[mid_f][np.argmin(co[mid_f, 2])]
log(f"female: height {f_height:.4f}, saddle {saddle_f}")
# ---- male donor ----
before = set(bpy.data.objects)
bpy.ops.import_scene.gltf(filepath=MALE)
new = [o for o in bpy.data.objects if o not in before]
male = max([o for o in new if o.type == 'MESH'], key=lambda o: len(o.data.vertices))
mme = male.data
nm = len(mme.vertices)
mco = np.empty(nm * 3)
mme.vertices.foreach_get("co", mco)
mco = mco.reshape(-1, 3)
m_height = mco[:, 2].max() - mco[:, 2].min()
mid_m = (np.abs(mco[:, 0]) < 0.02) & (mco[:, 2] > 0.55 * m_height) * (mco[:, 2] < 0.75 * m_height)
if not mid_m.any():
mid_m = (np.abs(mco[:, 0]) < 0.02)
saddle_m = mco[mid_m][np.argmin(mco[mid_m, 2])]
s = f_height / m_height
log(f"male '{male.name}': {nm}v height {m_height:.3f} m, saddle {saddle_m}, scale {s:.4f}")
# male crotch patch: faces whose verts lie near the saddle (generous; the cage is only a target)
sel_m = (np.abs(mco[:, 0] - saddle_m[0]) < WIN_R_X / s * 1.6) \
& (np.abs(mco[:, 2] - saddle_m[2]) < WIN_R_Z / s * 1.6)
log(f"male patch verts: {sel_m.sum()}")
# transform male verts into female space — and AUTO-DETECT front/back orientation: the male
# body may face the opposite way; test identity and y-mirror, keep whichever cage lands closer
# to the female window verts
mco_t = (mco - saddle_m) * s + saddle_f
mco_t_flip = mco_t.copy()
mco_t_flip[:, 1] = 2 * saddle_f[1] - mco_t[:, 1]
# build patch mesh -> subdivide -> BVH
ml_tot = np.empty(len(mme.polygons), dtype=np.int32)
mme.polygons.foreach_get("loop_total", ml_tot)
ml_start = np.empty(len(mme.polygons), dtype=np.int32)
mme.polygons.foreach_get("loop_start", ml_start)
ml_v = np.empty(len(mme.loops), dtype=np.int32)
mme.loops.foreach_get("vertex_index", ml_v)
faces = []
for fs, ft in zip(ml_start, ml_tot):
idxs = ml_v[fs:fs + ft]
if sel_m[idxs].all():
faces.append(idxs.tolist())
log(f"male patch faces: {len(faces)}")
used = sorted(set(i for f in faces for i in f))
remap = {g: i for i, g in enumerate(used)}
pm = bpy.data.meshes.new("crotch_patch")
pm.from_pydata([Vector(mco_t[i]) for i in used], [],
[[remap[i] for i in f] for f in faces])
pm.update()
po = bpy.data.objects.new("crotch_patch", pm)
bpy.context.collection.objects.link(po)
sub = po.modifiers.new("s", 'SUBSURF')
sub.levels = 2
bpy.context.view_layer.objects.active = po
bpy.ops.object.modifier_apply(modifier="s")
dme = po.data
dv = np.empty(len(dme.vertices) * 3)
dme.vertices.foreach_get("co", dv)
dv = dv.reshape(-1, 3)
dl_tot = np.empty(len(dme.polygons), dtype=np.int32)
dme.polygons.foreach_get("loop_total", dl_tot)
dl_start = np.empty(len(dme.polygons), dtype=np.int32)
dme.polygons.foreach_get("loop_start", dl_start)
dl_v = np.empty(len(dme.loops), dtype=np.int32)
dme.loops.foreach_get("vertex_index", dl_v)
polys_d = [dl_v[fs:fs + ft].tolist() for fs, ft in zip(dl_start, dl_tot)]
bvh = BVHTree.FromPolygons([Vector(v) for v in dv], polys_d,
all_triangles=False, epsilon=0.0)
dv_f = dv.copy()
dv_f[:, 1] = 2 * saddle_f[1] - dv[:, 1]
bvh_f = BVHTree.FromPolygons([Vector(v) for v in dv_f], polys_d,
all_triangles=False, epsilon=0.0)
for o in new + [po]:
bpy.data.objects.remove(o, do_unlink=True)
log("donor cages ready (both orientations)")
# ---- female window snap with ring-depth blend ----
win = (np.abs(co[:, 0] - saddle_f[0]) < WIN_R_X) \
& (np.abs(co[:, 2] - saddle_f[2]) < WIN_R_Z)
widx = np.nonzero(win)[0]
log(f"female window: {len(widx)} verts")
n_e = len(me.edges)
ev = np.empty(n_e * 2, dtype=np.int32)
me.edges.foreach_get("vertices", ev)
ev = ev.reshape(-1, 2)
order = np.concatenate([ev[:, 0], ev[:, 1]])
nbr = np.concatenate([ev[:, 1], ev[:, 0]])
srt = np.argsort(order, kind="stable")
o_s = order[srt]
n_s = nbr[srt]
ptr = np.searchsorted(o_s, np.arange(n_v + 1))
depth = np.zeros(n_v, dtype=np.int32)
dq = deque()
seen = np.zeros(n_v, dtype=bool)
for a, b in ev:
if win[a] != win[b]:
sv = a if win[a] else b
if not seen[sv]:
seen[sv] = True
depth[sv] = 1
dq.append(sv)
while dq:
c = dq.popleft()
for nb in n_s[ptr[c]:ptr[c + 1]]:
if win[nb] and not seen[nb]:
seen[nb] = True
depth[nb] = depth[c] + 1
dq.append(nb)
depth[win & ~seen] = BLEND_RINGS + 2
wgt = np.clip(depth[widx] / float(BLEND_RINGS), 0.0, 1.0)
wgt = wgt * wgt * (3 - 2 * wgt)
# orientation pick: median nearest-distance over a sample of window verts
samp = widx[::37]
def med_d(tree):
ds = []
for i in samp:
h = tree.find_nearest(Vector(co[i]), 0.08)
ds.append(h[3] if h[0] is not None else 0.08)
return float(np.median(ds))
d_id, d_fl = med_d(bvh), med_d(bvh_f)
use = bvh if d_id <= d_fl else bvh_f
log(f"orientation: identity {d_id:.4f} vs flipped {d_fl:.4f} -> "
f"{'identity' if d_id <= d_fl else 'flipped'}")
co_new = co.copy()
moved = 0
for k, i in enumerate(widx):
hit = use.find_nearest(Vector(co[i]), SNAP_MAX)
if hit[0] is None:
continue
tgt = np.array(hit[0])
co_new[i] += (tgt - co[i]) * wgt[k]
moved += 1
delta = np.linalg.norm(co_new - co, axis=1)
log(f"snapped {moved} verts, max move {delta.max():.4f}")
me.vertices.foreach_set("co", co_new.reshape(-1))
me.update()
if me.has_custom_normals:
vn = np.empty(n_v * 3, dtype=np.float32)
me.vertices.foreach_get("normal", vn)
me.normals_split_custom_set_from_vertices(vn.reshape(-1, 3))
bpy.context.preferences.filepaths.save_version = 0 # no .blend1 autosave
bpy.ops.wm.save_as_mainfile(filepath=OUT)
log(f"WROTE {OUT}")
print("CROTCH_DONE")
@@ -0,0 +1,211 @@
# Stage 6 v2: male-donor crotch transfer, full-gusset window.
#
# v1 post-mortem: the female briefs gusset bridges the legs ~3 cm ABOVE the male crotch
# saddle, so with SNAP_MAX 0.020 the lower half of the gusset couldn't reach the donor
# cage — the surface tore along the snapped/rejected boundary and the leftover panel kept
# its gathered-cloth wrinkles. v2 widens the window to the whole gusset, raises SNAP_MAX
# to span the real gap, melts whatever the cage still rejects, and Taubin-polishes the
# window so the cloth gathers on the inner thighs go too.
#
# blender --background --python 06_crotch_v2.py -- <03_final_geometry.blend> <male.glb> <out.blend>
import bpy, sys, time
from collections import deque
import numpy as np
from mathutils import Vector
from mathutils.bvhtree import BVHTree
argv = sys.argv[sys.argv.index("--") + 1:]
BLEND, MALE, OUT = argv[0], argv[1], argv[2]
t0 = time.time()
WIN_Z0, WIN_Z1 = 0.355, 0.470 # absolute: whole briefs gusset + pubic base
WIN_R_X = 0.058 # into the inner-thigh gathers, not past the thigh walls
SNAP_MAX = 0.035 # the gusset sits ~3 cm above the male crotch
BLEND_RINGS = 10
MELT_ITERS = 60 # residue verts the cage rejected
TAUBIN_PAIRS = 10
def log(m):
print(f"[crotch2 {time.time()-t0:6.1f}s] {m}", flush=True)
bpy.ops.wm.open_mainfile(filepath=BLEND)
ob = max([o for o in bpy.data.objects if o.type == 'MESH'],
key=lambda o: len(o.data.vertices))
me = ob.data
n_v = len(me.vertices)
co = np.empty(n_v * 3)
me.vertices.foreach_get("co", co)
co = co.reshape(-1, 3)
f_height = co[:, 2].max() - co[:, 2].min()
mid_f = (np.abs(co[:, 0]) < 0.01) & (co[:, 2] > 0.38) & (co[:, 2] < 0.50)
saddle_f = co[mid_f][np.argmin(co[mid_f, 2])]
log(f"female: height {f_height:.4f}, saddle {saddle_f}")
# ---- male donor ----
before = set(bpy.data.objects)
bpy.ops.import_scene.gltf(filepath=MALE)
new = [o for o in bpy.data.objects if o not in before]
male = max([o for o in new if o.type == 'MESH'], key=lambda o: len(o.data.vertices))
mme = male.data
nm = len(mme.vertices)
mco = np.empty(nm * 3)
mme.vertices.foreach_get("co", mco)
mco = mco.reshape(-1, 3)
m_height = mco[:, 2].max() - mco[:, 2].min()
mid_m = (np.abs(mco[:, 0]) < 0.02) & (mco[:, 2] > 0.55 * m_height) & (mco[:, 2] < 0.75 * m_height)
if not mid_m.any():
mid_m = (np.abs(mco[:, 0]) < 0.02)
saddle_m = mco[mid_m][np.argmin(mco[mid_m, 2])]
s = f_height / m_height
log(f"male '{male.name}': {nm}v height {m_height:.3f} m, saddle {saddle_m}, scale {s:.4f}")
# male crotch patch generous enough to cover the whole female window after transform
half_z = max(abs(WIN_Z1 - saddle_f[2]), abs(saddle_f[2] - WIN_Z0))
sel_m = (np.abs(mco[:, 0] - saddle_m[0]) < WIN_R_X / s * 1.8) \
& (np.abs(mco[:, 2] - saddle_m[2]) < half_z / s * 1.8)
log(f"male patch verts: {sel_m.sum()}")
mco_t = (mco - saddle_m) * s + saddle_f
ml_tot = np.empty(len(mme.polygons), dtype=np.int32)
mme.polygons.foreach_get("loop_total", ml_tot)
ml_start = np.empty(len(mme.polygons), dtype=np.int32)
mme.polygons.foreach_get("loop_start", ml_start)
ml_v = np.empty(len(mme.loops), dtype=np.int32)
mme.loops.foreach_get("vertex_index", ml_v)
faces = []
for fs, ft in zip(ml_start, ml_tot):
idxs = ml_v[fs:fs + ft]
if sel_m[idxs].all():
faces.append(idxs.tolist())
log(f"male patch faces: {len(faces)}")
used = sorted(set(i for f in faces for i in f))
remap = {g: i for i, g in enumerate(used)}
pm = bpy.data.meshes.new("crotch_patch")
pm.from_pydata([Vector(mco_t[i]) for i in used], [],
[[remap[i] for i in f] for f in faces])
pm.update()
po = bpy.data.objects.new("crotch_patch", pm)
bpy.context.collection.objects.link(po)
sub = po.modifiers.new("s", 'SUBSURF')
sub.levels = 2
bpy.context.view_layer.objects.active = po
bpy.ops.object.modifier_apply(modifier="s")
dme = po.data
dv = np.empty(len(dme.vertices) * 3)
dme.vertices.foreach_get("co", dv)
dv = dv.reshape(-1, 3)
dl_tot = np.empty(len(dme.polygons), dtype=np.int32)
dme.polygons.foreach_get("loop_total", dl_tot)
dl_start = np.empty(len(dme.polygons), dtype=np.int32)
dme.polygons.foreach_get("loop_start", dl_start)
dl_v = np.empty(len(dme.loops), dtype=np.int32)
dme.loops.foreach_get("vertex_index", dl_v)
polys_d = [dl_v[fs:fs + ft].tolist() for fs, ft in zip(dl_start, dl_tot)]
bvh = BVHTree.FromPolygons([Vector(v) for v in dv], polys_d,
all_triangles=False, epsilon=0.0)
dv_f = dv.copy()
dv_f[:, 1] = 2 * saddle_f[1] - dv[:, 1]
bvh_f = BVHTree.FromPolygons([Vector(v) for v in dv_f], polys_d,
all_triangles=False, epsilon=0.0)
for o in new + [po]:
bpy.data.objects.remove(o, do_unlink=True)
log("donor cages ready (both orientations)")
# ---- female window ----
win = (np.abs(co[:, 0] - saddle_f[0]) < WIN_R_X) \
& (co[:, 2] > WIN_Z0) & (co[:, 2] < WIN_Z1)
widx = np.nonzero(win)[0]
log(f"female window: {len(widx)} verts")
n_e = len(me.edges)
ev = np.empty(n_e * 2, dtype=np.int32)
me.edges.foreach_get("vertices", ev)
ev = ev.reshape(-1, 2)
order = np.concatenate([ev[:, 0], ev[:, 1]])
nbr = np.concatenate([ev[:, 1], ev[:, 0]])
srt = np.argsort(order, kind="stable")
o_s = order[srt]
n_s = nbr[srt]
ptr = np.searchsorted(o_s, np.arange(n_v + 1))
depth = np.zeros(n_v, dtype=np.int32)
dq = deque()
seen = np.zeros(n_v, dtype=bool)
for a, b in ev:
if win[a] != win[b]:
sv = a if win[a] else b
if not seen[sv]:
seen[sv] = True
depth[sv] = 1
dq.append(sv)
while dq:
c = dq.popleft()
for nb in n_s[ptr[c]:ptr[c + 1]]:
if win[nb] and not seen[nb]:
seen[nb] = True
depth[nb] = depth[c] + 1
dq.append(nb)
depth[win & ~seen] = BLEND_RINGS + 2
wgt = np.clip(depth[widx] / float(BLEND_RINGS), 0.0, 1.0)
wgt = wgt * wgt * (3 - 2 * wgt)
samp = widx[::37]
def med_d(tree):
ds = []
for i in samp:
h = tree.find_nearest(Vector(co[i]), 0.10)
ds.append(h[3] if h[0] is not None else 0.10)
return float(np.median(ds))
d_id, d_fl = med_d(bvh), med_d(bvh_f)
use = bvh if d_id <= d_fl else bvh_f
log(f"orientation: identity {d_id:.4f} vs flipped {d_fl:.4f} -> "
f"{'identity' if d_id <= d_fl else 'flipped'}")
co_new = co.copy()
moved = 0
rejected = []
for k, i in enumerate(widx):
hit = use.find_nearest(Vector(co[i]), SNAP_MAX)
if hit[0] is None:
rejected.append(i)
continue
tgt = np.array(hit[0])
co_new[i] += (tgt - co[i]) * wgt[k]
moved += 1
delta = np.linalg.norm(co_new - co, axis=1)
log(f"snapped {moved} verts (rejected {len(rejected)}), max move {delta.max():.4f}")
# ---- melt the rejects toward their neighbours so no torn seam survives ----
def neigh_mean(Q, idx):
out = np.empty((len(idx), 3))
for j, i in enumerate(idx):
nbrs = n_s[ptr[i]:ptr[i + 1]]
out[j] = Q[nbrs].mean(axis=0) if len(nbrs) else Q[i]
return out
rej = np.array(rejected, dtype=np.int32)
if len(rej):
for _ in range(MELT_ITERS):
co_new[rej] = 0.5 * co_new[rej] + 0.5 * neigh_mean(co_new, rej)
log(f"melted {len(rej)} rejected verts")
# ---- Taubin polish over the whole window (kills cloth gathers on the inner thighs) ----
lam, mu = 0.5, -0.53
for _ in range(TAUBIN_PAIRS):
co_new[widx] += lam * (neigh_mean(co_new, widx) - co_new[widx])
co_new[widx] += mu * (neigh_mean(co_new, widx) - co_new[widx])
log(f"Taubin x{TAUBIN_PAIRS} on window")
me.vertices.foreach_set("co", co_new.reshape(-1))
me.update()
if me.has_custom_normals:
vn = np.empty(n_v * 3, dtype=np.float32)
me.vertices.foreach_get("normal", vn)
me.normals_split_custom_set_from_vertices(vn.reshape(-1, 3))
bpy.context.preferences.filepaths.save_version = 0 # no .blend1 autosave
bpy.ops.wm.save_as_mainfile(filepath=OUT)
log(f"WROTE {OUT}")
print("CROTCH2_DONE")
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,55 @@
# Local melt of the crotch underside strip (residue from the donor snap's cage edge).
# blender --background --python 06b_strip_melt.py -- <in.blend> <out.blend>
import bpy, sys, time
import numpy as np
argv = sys.argv[sys.argv.index("--") + 1:]
t0 = time.time()
bpy.ops.wm.open_mainfile(filepath=argv[0])
ob = max([o for o in bpy.data.objects if o.type == 'MESH'],
key=lambda o: len(o.data.vertices))
me = ob.data
n_v = len(me.vertices)
co = np.empty(n_v * 3)
me.vertices.foreach_get("co", co)
co = co.reshape(-1, 3)
mid = (np.abs(co[:, 0]) < 0.01) & (co[:, 2] > 0.38) & (co[:, 2] < 0.50)
sad = co[mid][np.argmin(co[mid, 2])]
# ABSOLUTE bounds: the donor snap moved the saddle landmark, so a saddle-relative window
# centred 3.5 cm below the visible residue
strip = (np.abs(co[:, 0]) < 0.048) & (co[:, 2] > 0.368) & (co[:, 2] < 0.434)
sidx = np.nonzero(strip)[0]
print(f"[strip] {len(sidx)} verts around saddle {sad}")
ev = np.empty(len(me.edges) * 2, dtype=np.int32)
me.edges.foreach_get("vertices", ev)
ev = ev.reshape(-1, 2)
o_ = np.concatenate([ev[:, 0], ev[:, 1]])
n_ = np.concatenate([ev[:, 1], ev[:, 0]])
s_ = np.argsort(o_, kind="stable")
o_s = o_[s_]
n_s = n_[s_]
ptr = np.searchsorted(o_s, np.arange(n_v + 1))
cnt = np.maximum(np.diff(ptr), 1)
Q = co.copy()
for _ in range(120):
su = np.add.reduceat(Q[n_s], ptr[:-1], axis=0)
emp = np.diff(ptr) == 0
su[emp] = Q[emp]
mean = su / cnt[:, None]
for f in (0.55, -0.58):
pass
Q[sidx] = 0.45 * Q[sidx] + 0.55 * mean[sidx]
print(f"[strip] melted, max move {np.linalg.norm(Q-co,axis=1).max():.4f} "
f"({time.time()-t0:.1f}s)")
me.vertices.foreach_set("co", Q.reshape(-1))
me.update()
if me.has_custom_normals:
vn = np.empty(n_v * 3, dtype=np.float32)
me.vertices.foreach_get("normal", vn)
me.normals_split_custom_set_from_vertices(vn.reshape(-1, 3))
bpy.context.preferences.filepaths.save_version = 0 # no .blend1 autosave
bpy.ops.wm.save_as_mainfile(filepath=argv[1])
print("STRIP_DONE")
@@ -0,0 +1,119 @@
# Stage 6c: bi-harmonic membrane heal of the briefs-gusset residue between the legs.
#
# History: v1 donor snap (06_crotch.py) fixed the pubic front but its 2 cm SNAP_MAX left the
# gusset panel torn (the male crotch sits ~3 cm below the female bridge). Melting the panel
# (06b) smoothed the interior but not the torn silhouette; widening the snap window (v2)
# shredded the inner-thigh walls. This does what worked on the chest bow: excise the residue
# box and solve a rim-anchored bi-harmonic membrane to convergence — no donor reach limits,
# no orientation risk, C1-continuous with the surrounding skin by construction.
#
# blender --background --python 06c_gusset_membrane.py -- <06_crotched.blend> <out.blend>
import bpy, sys, time
from collections import deque
import numpy as np
argv = sys.argv[sys.argv.index("--") + 1:]
BLEND, OUT = argv[0], argv[1]
t0 = time.time()
# residue box: the gusset bridge underside + torn seam + gathered inner-thigh cloth edges.
# (bridge underside starts at z~0.41; tear at ~0.44; gathers reach ~0.46 and |x|~0.05)
BOX_X = 0.055
BOX_Z0, BOX_Z1 = 0.385, 0.462
def log(m):
print(f"[gusset {time.time()-t0:6.1f}s] {m}", flush=True)
bpy.ops.wm.open_mainfile(filepath=BLEND)
ob = max([o for o in bpy.data.objects if o.type == 'MESH'],
key=lambda o: len(o.data.vertices))
me = ob.data
n_v = len(me.vertices)
co = np.empty(n_v * 3)
me.vertices.foreach_get("co", co)
co = co.reshape(-1, 3)
n_e = len(me.edges)
ev0 = np.empty(n_e * 2, dtype=np.int32)
me.edges.foreach_get("vertices", ev0)
ev0 = ev0.reshape(-1, 2)
def grow_edges(mask, rings, ev):
m = mask.copy()
for _ in range(rings):
hit = m[ev[:, 0]] | m[ev[:, 1]]
m2 = m.copy()
m2[ev[:, 0]] |= hit
m2[ev[:, 1]] |= hit
m = m2
return m
box = (np.abs(co[:, 0]) < BOX_X) & (co[:, 2] > BOX_Z0) & (co[:, 2] < BOX_Z1)
free_m = grow_edges(box, 2, ev0)
collar = grow_edges(free_m, 2, ev0) & ~free_m
S = np.nonzero(free_m | collar)[0]
in_S = np.zeros(n_v, dtype=bool)
in_S[S] = True
glb = np.full(n_v, -1, dtype=np.int64)
glb[S] = np.arange(len(S))
se = ev0[in_S[ev0].all(axis=1)]
a_ = glb[se[:, 0]]
b_ = glb[se[:, 1]]
deg = np.zeros(len(S))
np.add.at(deg, a_, 1.0)
np.add.at(deg, b_, 1.0)
free = free_m[S]
log(f"box {box.sum()} verts -> free {free.sum()}, collar {(~free).sum()}")
def Ls(X):
out = deg[:, None] * X
np.add.at(out, a_, -X[b_])
np.add.at(out, b_, -X[a_])
return out
def A_op(U):
X = np.zeros((len(S), 3))
X[free] = U
return Ls(Ls(X))[free]
Xc = np.zeros((len(S), 3))
Xc[~free] = co[S[~free]]
rhs = -Ls(Ls(Xc))[free]
U = co[S[free]].copy()
r = rhs - A_op(U)
p = r.copy()
rs = (r * r).sum()
rs0 = rs
for it in range(120000):
Ap = A_op(p)
al = rs / max((p * Ap).sum(), 1e-30)
U += al * p
r -= al * Ap
rs2 = (r * r).sum()
if rs2 < 1e-18 or rs2 < rs0 * 1e-14:
break
p = r + (rs2 / rs) * p
rs = rs2
co_new = co.copy()
co_new[S[free]] = U
delta = np.linalg.norm(co_new - co, axis=1)
log(f"membrane: {free.sum()} verts healed (CG {it} iters, "
f"rel residual {rs2/max(rs0,1e-30):.2e}), max move {delta.max():.4f}")
me.vertices.foreach_set("co", co_new.reshape(-1))
me.update()
if me.has_custom_normals:
vn = np.empty(n_v * 3, dtype=np.float32)
me.vertices.foreach_get("normal", vn)
me.normals_split_custom_set_from_vertices(vn.reshape(-1, 3))
bpy.context.preferences.filepaths.save_version = 0 # no .blend1 autosave
bpy.ops.wm.save_as_mainfile(filepath=OUT)
log(f"WROTE {OUT}")
print("GUSSET_DONE")
@@ -0,0 +1,89 @@
# Stage 6d: self-locating spot melt of remaining flaps in the thigh gap.
# Finds high-roughness verts inside the gap box (an open-slit flap the membrane pulled),
# grows a 2-ring collar, melts. Prints the cluster it found so the fix is auditable.
# blender --background --python 06d_spot_melt.py -- <in.blend> <out.blend>
import bpy, sys, time
import numpy as np
argv = sys.argv[sys.argv.index("--") + 1:]
BLEND, OUT = argv[0], argv[1]
t0 = time.time()
BOX = lambda co: (np.abs(co[:, 0]) < 0.075) & (co[:, 2] > 0.345) & (co[:, 2] < 0.475)
ROUGH_THR = 0.0012
MELT_ITERS = 80
def log(m):
print(f"[spot {time.time()-t0:6.1f}s] {m}", flush=True)
bpy.ops.wm.open_mainfile(filepath=BLEND)
ob = max([o for o in bpy.data.objects if o.type == 'MESH'],
key=lambda o: len(o.data.vertices))
me = ob.data
n_v = len(me.vertices)
co = np.empty(n_v * 3)
me.vertices.foreach_get("co", co)
co = co.reshape(-1, 3)
n_e = len(me.edges)
ev = np.empty(n_e * 2, dtype=np.int32)
me.edges.foreach_get("vertices", ev)
ev = ev.reshape(-1, 2)
order = np.concatenate([ev[:, 0], ev[:, 1]])
nbr = np.concatenate([ev[:, 1], ev[:, 0]])
srt = np.argsort(order, kind="stable")
o_s = order[srt]
n_s = nbr[srt]
ptr = np.searchsorted(o_s, np.arange(n_v + 1))
cnt = np.maximum(ptr[1:] - ptr[:-1], 1)
def smooth_field(Q, iters):
X = Q.copy()
for _ in range(iters):
acc = np.zeros_like(X)
np.add.at(acc, o_s, X[n_s])
X = acc / cnt[:, None]
return X
sm = smooth_field(co, 8)
rough = np.linalg.norm(co - sm, axis=1)
box = BOX(co)
hot = box & (rough > ROUGH_THR)
log(f"box {box.sum()} verts, hot {hot.sum()} (rough>{ROUGH_THR})")
if hot.sum():
hc = co[hot]
log(f"hot cluster: x [{hc[:,0].min():+.4f}..{hc[:,0].max():+.4f}] "
f"y [{hc[:,1].min():+.4f}..{hc[:,1].max():+.4f}] "
f"z [{hc[:,2].min():+.4f}..{hc[:,2].max():+.4f}]")
m = hot.copy()
for _ in range(2):
h = m[ev[:, 0]] | m[ev[:, 1]]
m2 = m.copy()
m2[ev[:, 0]] |= h
m2[ev[:, 1]] |= h
m = m2
sidx = np.nonzero(m)[0]
Q = co.copy()
for _ in range(MELT_ITERS):
acc = np.zeros_like(Q)
np.add.at(acc, o_s, Q[n_s])
mean = acc / cnt[:, None]
Q[sidx] = 0.5 * Q[sidx] + 0.5 * mean[sidx]
d = np.linalg.norm(Q - co, axis=1)
log(f"melted {len(sidx)} verts, max move {d.max():.4f}")
me.vertices.foreach_set("co", Q.reshape(-1))
me.update()
if me.has_custom_normals:
vn = np.empty(n_v * 3, dtype=np.float32)
me.vertices.foreach_get("normal", vn)
me.normals_split_custom_set_from_vertices(vn.reshape(-1, 3))
else:
log("nothing hot — no melt applied")
bpy.context.preferences.filepaths.save_version = 0 # no .blend1 autosave
bpy.ops.wm.save_as_mainfile(filepath=OUT)
log(f"WROTE {OUT}")
print("SPOT_DONE")
@@ -0,0 +1,130 @@
# Stage 6e: melt open-slit rims in the crotch box.
# The remaining flap and the dotted briefs-edge lines both live on boundary edges (the mesh
# is not watertight). Roughness misses them — a folded flap is locally smooth. Select verts
# on boundary edges inside the box, grow 3 rings, melt hard; also stitch: each boundary vert
# pairs with its nearest non-neighbour boundary vert within 2.5 mm and both move to the
# midpoint, closing the slit gap positionally (topology untouched, masks.npz stays valid).
# blender --background --python 06e_slit_melt.py -- <in.blend> <out.blend>
import bpy, sys, time
import numpy as np
from mathutils import Vector
from mathutils.kdtree import KDTree
argv = sys.argv[sys.argv.index("--") + 1:]
BLEND, OUT = argv[0], argv[1]
t0 = time.time()
BOX = lambda co: (np.abs(co[:, 0]) < 0.075) & (co[:, 2] > 0.340) & (co[:, 2] < 0.480)
MELT_ITERS = 100
STITCH_R = 0.0025
def log(m):
print(f"[slit {time.time()-t0:6.1f}s] {m}", flush=True)
bpy.ops.wm.open_mainfile(filepath=BLEND)
ob = max([o for o in bpy.data.objects if o.type == 'MESH'],
key=lambda o: len(o.data.vertices))
me = ob.data
n_v = len(me.vertices)
co = np.empty(n_v * 3)
me.vertices.foreach_get("co", co)
co = co.reshape(-1, 3)
n_e = len(me.edges)
ev = np.empty(n_e * 2, dtype=np.int32)
me.edges.foreach_get("vertices", ev)
ev = ev.reshape(-1, 2)
# boundary edges: adjacent to exactly one face
l_tot = np.empty(len(me.polygons), dtype=np.int32)
me.polygons.foreach_get("loop_total", l_tot)
l_start = np.empty(len(me.polygons), dtype=np.int32)
me.polygons.foreach_get("loop_start", l_start)
l_v = np.empty(len(me.loops), dtype=np.int32)
me.loops.foreach_get("vertex_index", l_v)
ecount = {}
for fs, ft in zip(l_start, l_tot):
idxs = l_v[fs:fs + ft]
for k in range(ft):
a, b = idxs[k], idxs[(k + 1) % ft]
key = (a, b) if a < b else (b, a)
ecount[key] = ecount.get(key, 0) + 1
bnd_v = np.zeros(n_v, dtype=bool)
for (a, b), c in ecount.items():
if c == 1:
bnd_v[a] = bnd_v[b] = True
log(f"boundary verts total: {bnd_v.sum()}")
box = BOX(co)
hot = box & bnd_v
log(f"slit verts in box: {hot.sum()}")
# stitch pass: pair each hot vert with nearest hot vert that is not a mesh neighbour
order = np.concatenate([ev[:, 0], ev[:, 1]])
nbr = np.concatenate([ev[:, 1], ev[:, 0]])
srt = np.argsort(order, kind="stable")
o_s = order[srt]
n_s = nbr[srt]
ptr = np.searchsorted(o_s, np.arange(n_v + 1))
cnt = np.maximum(ptr[1:] - ptr[:-1], 1)
Q = co.copy()
hidx = np.nonzero(hot)[0]
if len(hidx):
tree = KDTree(len(hidx))
for j, i in enumerate(hidx):
tree.insert(Vector(Q[i]), j)
tree.balance()
neigh_sets = {int(i): set(int(x) for x in n_s[ptr[i]:ptr[i + 1]]) for i in hidx}
stitched = 0
done = set()
for j, i in enumerate(hidx):
if j in done:
continue
best = None
for (_, k, dist) in tree.find_range(Vector(Q[i]), STITCH_R):
if k == j or k in done:
continue
ik = int(hidx[k])
if ik in neigh_sets[int(i)]:
continue
if best is None or dist < best[1]:
best = (k, dist)
if best is not None:
ik = int(hidx[best[0]])
mid = 0.5 * (Q[i] + Q[ik])
Q[i] = mid
Q[ik] = mid
done.add(j)
done.add(best[0])
stitched += 1
log(f"stitched {stitched} slit pairs")
m = hot.copy()
for _ in range(3):
h = m[ev[:, 0]] | m[ev[:, 1]]
m2 = m.copy()
m2[ev[:, 0]] |= h
m2[ev[:, 1]] |= h
m = m2
sidx = np.nonzero(m)[0]
for _ in range(MELT_ITERS):
acc = np.zeros_like(Q)
np.add.at(acc, o_s, Q[n_s])
mean = acc / cnt[:, None]
Q[sidx] = 0.5 * Q[sidx] + 0.5 * mean[sidx]
d = np.linalg.norm(Q - co, axis=1)
log(f"melted {len(sidx)} rim verts, max move {d.max():.4f}")
me.vertices.foreach_set("co", Q.reshape(-1))
me.update()
if me.has_custom_normals:
vn = np.empty(n_v * 3, dtype=np.float32)
me.vertices.foreach_get("normal", vn)
me.normals_split_custom_set_from_vertices(vn.reshape(-1, 3))
bpy.context.preferences.filepaths.save_version = 0 # no .blend1 autosave
bpy.ops.wm.save_as_mainfile(filepath=OUT)
log(f"WROTE {OUT}")
print("SLIT_DONE")
@@ -0,0 +1,98 @@
# Stage 6f: fold melt in the crotch box, normal-deviation detector.
# The mesh has only 3 boundary verts (00_welded closed the slits) — the remaining flap and
# dotted seam lines are welded CREASES: positions locally smooth, normals kinked. Detect
# verts whose normal deviates > ANG_THR from the 5-iter smoothed normal field, grow, melt.
# blender --background --python 06f_fold_melt.py -- <in.blend> <out.blend>
import bpy, sys, time, math
import numpy as np
argv = sys.argv[sys.argv.index("--") + 1:]
BLEND, OUT = argv[0], argv[1]
t0 = time.time()
# optional box override: <bx> <z0> <z1> [ang_deg]
if len(argv) >= 5:
BX, BZ0, BZ1 = float(argv[2]), float(argv[3]), float(argv[4])
ANG_THR = math.radians(float(argv[5])) if len(argv) > 5 else math.radians(15.0)
else:
BX, BZ0, BZ1 = 0.075, 0.340, 0.480
ANG_THR = math.radians(15.0)
# navel stays: it is a real feature made of exactly the kind of kink this melts
NAVEL = lambda co: (np.abs(co[:, 0]) < 0.022) & (co[:, 2] > 0.495) & (co[:, 2] < 0.555) & (co[:, 1] < 0)
BOX = lambda co: (np.abs(co[:, 0]) < BX) & (co[:, 2] > BZ0) & (co[:, 2] < BZ1) & ~NAVEL(co)
MELT_ITERS = 150
def log(m):
print(f"[fold {time.time()-t0:6.1f}s] {m}", flush=True)
bpy.ops.wm.open_mainfile(filepath=BLEND)
ob = max([o for o in bpy.data.objects if o.type == 'MESH'],
key=lambda o: len(o.data.vertices))
me = ob.data
n_v = len(me.vertices)
co = np.empty(n_v * 3)
me.vertices.foreach_get("co", co)
co = co.reshape(-1, 3)
nrm = np.empty(n_v * 3)
me.vertices.foreach_get("normal", nrm)
nrm = nrm.reshape(-1, 3)
n_e = len(me.edges)
ev = np.empty(n_e * 2, dtype=np.int32)
me.edges.foreach_get("vertices", ev)
ev = ev.reshape(-1, 2)
order = np.concatenate([ev[:, 0], ev[:, 1]])
nbr = np.concatenate([ev[:, 1], ev[:, 0]])
srt = np.argsort(order, kind="stable")
o_s = order[srt]
n_s = nbr[srt]
ptr = np.searchsorted(o_s, np.arange(n_v + 1))
cnt = np.maximum(ptr[1:] - ptr[:-1], 1)
N = nrm.copy()
for _ in range(5):
acc = np.zeros_like(N)
np.add.at(acc, o_s, N[n_s])
N = acc / cnt[:, None]
N /= np.maximum(np.linalg.norm(N, axis=1, keepdims=True), 1e-12)
dot = np.clip((nrm * N).sum(axis=1), -1.0, 1.0)
ang = np.arccos(dot)
box = BOX(co)
hot = box & (ang > ANG_THR)
log(f"box {box.sum()}, folds {hot.sum()} (>{math.degrees(ANG_THR):.0f} deg)")
if hot.sum():
hc = co[hot]
log(f"fold cluster: x [{hc[:,0].min():+.4f}..{hc[:,0].max():+.4f}] "
f"y [{hc[:,1].min():+.4f}..{hc[:,1].max():+.4f}] "
f"z [{hc[:,2].min():+.4f}..{hc[:,2].max():+.4f}]")
m = hot.copy()
for _ in range(3):
h = m[ev[:, 0]] | m[ev[:, 1]]
m2 = m.copy()
m2[ev[:, 0]] |= h
m2[ev[:, 1]] |= h
m = m2
sidx = np.nonzero(m)[0]
Q = co.copy()
for _ in range(MELT_ITERS):
acc = np.zeros_like(Q)
np.add.at(acc, o_s, Q[n_s])
mean = acc / cnt[:, None]
Q[sidx] = 0.5 * Q[sidx] + 0.5 * mean[sidx]
d = np.linalg.norm(Q - co, axis=1)
log(f"melted {len(sidx)} fold verts, max move {d.max():.4f}")
me.vertices.foreach_set("co", Q.reshape(-1))
me.update()
if me.has_custom_normals:
vn = np.empty(n_v * 3, dtype=np.float32)
me.vertices.foreach_get("normal", vn)
me.normals_split_custom_set_from_vertices(vn.reshape(-1, 3))
else:
log("no folds found")
bpy.context.preferences.filepaths.save_version = 0 # no .blend1 autosave
bpy.ops.wm.save_as_mainfile(filepath=OUT)
log(f"WROTE {OUT}")
print("FOLD_DONE")
@@ -0,0 +1,103 @@
# Stage 6g: locate the surviving inner-thigh flap by camera ray-cast, melt a sphere there.
# The flap dodged the roughness, boundary-rim, and normal-kink detectors — so aim through
# the diagnostic camera pixel where it is visibly rendered (dbg front_tight, ~px 565,630 of
# 1000^2) and heal whatever the ray hits. Prints the hit so the fix is auditable.
# blender --background --python 06g_pixel_melt.py -- <in.blend> <out.blend>
import bpy, sys, time, math
import numpy as np
from mathutils import Vector, Euler
argv = sys.argv[sys.argv.index("--") + 1:]
BLEND, OUT = argv[0], argv[1]
t0 = time.time()
# front_tight camera from dbg_gusset.py
CAM_LOC = Vector((0.0, -0.55, 0.41))
CAM_ROT = Euler((math.radians(90), 0, 0))
LENS, SENSOR = 85.0, 36.0
# pixels (x, y from top) in the 1000^2 render where the flap shows; a few samples across it
PIXELS = [(560, 615), (568, 628), (575, 640), (582, 652), (562, 640), (572, 618)]
R_MELT = 0.010
MELT_ITERS = 200
def log(m):
print(f"[pix {time.time()-t0:6.1f}s] {m}", flush=True)
bpy.ops.wm.open_mainfile(filepath=BLEND)
ob = max([o for o in bpy.data.objects if o.type == 'MESH'],
key=lambda o: len(o.data.vertices))
me = ob.data
n_v = len(me.vertices)
co = np.empty(n_v * 3)
me.vertices.foreach_get("co", co)
co = co.reshape(-1, 3)
deps = bpy.context.evaluated_depsgraph_get()
rot = CAM_ROT.to_matrix()
fwd = rot @ Vector((0, 0, -1))
right = rot @ Vector((1, 0, 0))
up = rot @ Vector((0, 1, 0))
half = SENSOR / (2 * LENS)
hits = []
for px, py in PIXELS:
ndc_x = (px / 1000.0 - 0.5) * 2
ndc_y = (0.5 - py / 1000.0) * 2
d = (fwd + right * (ndc_x * half) + up * (ndc_y * half)).normalized()
ok, loc, nrm_h, fi, obj, _ = bpy.context.scene.ray_cast(deps, CAM_LOC, d)
if ok:
hits.append(np.array(loc))
log(f"px({px},{py}) -> hit {np.round(np.array(loc), 4)}")
else:
log(f"px({px},{py}) -> MISS")
if not hits:
log("no hits; aborting without changes")
bpy.context.preferences.filepaths.save_version = 0 # no .blend1 autosave
bpy.ops.wm.save_as_mainfile(filepath=OUT)
sys.exit(0)
hits = np.array(hits)
ctr = hits.mean(axis=0)
log(f"flap centre {np.round(ctr,4)}, spread {np.round(hits.std(axis=0),4)}")
sel = np.linalg.norm(co - ctr, axis=1) < R_MELT
sidx = np.nonzero(sel)[0]
log(f"melt sphere r={R_MELT}: {len(sidx)} verts")
n_e = len(me.edges)
ev = np.empty(n_e * 2, dtype=np.int32)
me.edges.foreach_get("vertices", ev)
ev = ev.reshape(-1, 2)
order = np.concatenate([ev[:, 0], ev[:, 1]])
nbr = np.concatenate([ev[:, 1], ev[:, 0]])
srt = np.argsort(order, kind="stable")
o_s = order[srt]
n_s = nbr[srt]
ptr = np.searchsorted(o_s, np.arange(n_v + 1))
cnt = np.maximum(ptr[1:] - ptr[:-1], 1)
# soft weight: full melt at centre, fades at rim so no new crease forms
w = np.clip(1.0 - np.linalg.norm(co[sidx] - ctr, axis=1) / R_MELT, 0.0, 1.0)
w = w * w * (3 - 2 * w)
Q = co.copy()
for _ in range(MELT_ITERS):
acc = np.zeros_like(Q)
np.add.at(acc, o_s, Q[n_s])
mean = acc / cnt[:, None]
Q[sidx] = Q[sidx] + (mean[sidx] - Q[sidx]) * (0.6 * w[:, None])
d = np.linalg.norm(Q - co, axis=1)
log(f"melted, max move {d.max():.4f}")
me.vertices.foreach_set("co", Q.reshape(-1))
me.update()
if me.has_custom_normals:
vn = np.empty(n_v * 3, dtype=np.float32)
me.vertices.foreach_get("normal", vn)
me.normals_split_custom_set_from_vertices(vn.reshape(-1, 3))
bpy.context.preferences.filepaths.save_version = 0 # no .blend1 autosave
bpy.ops.wm.save_as_mainfile(filepath=OUT)
log(f"WROTE {OUT}")
print("PIX_DONE")
@@ -0,0 +1,109 @@
# Stage 6h: bi-harmonic membrane over the located flap sphere. Melting (06g) barely dented
# the fold — layered flaps are locally smooth and resist neighbour-averaging. Replace instead:
# excise the sphere, solve the rim-anchored membrane (same method that healed the gusset).
# blender --background --python 06h_flap_membrane.py -- <in.blend> <out.blend> <cx> <cy> <cz> <r>
import bpy, sys, time
import numpy as np
argv = sys.argv[sys.argv.index("--") + 1:]
BLEND, OUT = argv[0], argv[1]
CTR = np.array([float(argv[2]), float(argv[3]), float(argv[4])])
R = float(argv[5])
t0 = time.time()
def log(m):
print(f"[flap {time.time()-t0:6.1f}s] {m}", flush=True)
bpy.ops.wm.open_mainfile(filepath=BLEND)
ob = max([o for o in bpy.data.objects if o.type == 'MESH'],
key=lambda o: len(o.data.vertices))
me = ob.data
n_v = len(me.vertices)
co = np.empty(n_v * 3)
me.vertices.foreach_get("co", co)
co = co.reshape(-1, 3)
n_e = len(me.edges)
ev0 = np.empty(n_e * 2, dtype=np.int32)
me.edges.foreach_get("vertices", ev0)
ev0 = ev0.reshape(-1, 2)
def grow_edges(mask, rings, ev):
m = mask.copy()
for _ in range(rings):
hit = m[ev[:, 0]] | m[ev[:, 1]]
m2 = m.copy()
m2[ev[:, 0]] |= hit
m2[ev[:, 1]] |= hit
m = m2
return m
sph = np.linalg.norm(co - CTR, axis=1) < R
free_m = grow_edges(sph, 1, ev0)
collar = grow_edges(free_m, 2, ev0) & ~free_m
S = np.nonzero(free_m | collar)[0]
in_S = np.zeros(n_v, dtype=bool)
in_S[S] = True
glb = np.full(n_v, -1, dtype=np.int64)
glb[S] = np.arange(len(S))
se = ev0[in_S[ev0].all(axis=1)]
a_ = glb[se[:, 0]]
b_ = glb[se[:, 1]]
deg = np.zeros(len(S))
np.add.at(deg, a_, 1.0)
np.add.at(deg, b_, 1.0)
free = free_m[S]
log(f"sphere {sph.sum()} -> free {free.sum()}, collar {(~free).sum()}")
def Ls(X):
out = deg[:, None] * X
np.add.at(out, a_, -X[b_])
np.add.at(out, b_, -X[a_])
return out
def A_op(U):
X = np.zeros((len(S), 3))
X[free] = U
return Ls(Ls(X))[free]
Xc = np.zeros((len(S), 3))
Xc[~free] = co[S[~free]]
rhs = -Ls(Ls(Xc))[free]
U = co[S[free]].copy()
r = rhs - A_op(U)
p = r.copy()
rs = (r * r).sum()
rs0 = rs
for it in range(120000):
Ap = A_op(p)
al = rs / max((p * Ap).sum(), 1e-30)
U += al * p
r -= al * Ap
rs2 = (r * r).sum()
if rs2 < 1e-18 or rs2 < rs0 * 1e-14:
break
p = r + (rs2 / rs) * p
rs = rs2
co_new = co.copy()
co_new[S[free]] = U
d = np.linalg.norm(co_new - co, axis=1)
log(f"membrane: {free.sum()} verts (CG {it} iters, rel {rs2/max(rs0,1e-30):.2e}), "
f"max move {d.max():.4f}")
me.vertices.foreach_set("co", co_new.reshape(-1))
me.update()
if me.has_custom_normals:
vn = np.empty(n_v * 3, dtype=np.float32)
me.vertices.foreach_get("normal", vn)
me.normals_split_custom_set_from_vertices(vn.reshape(-1, 3))
bpy.context.preferences.filepaths.save_version = 0 # no .blend1 autosave
bpy.ops.wm.save_as_mainfile(filepath=OUT)
log(f"WROTE {OUT}")
print("FLAP_DONE")
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,32 @@
# Stage 8: export the finished hires nude sculpt as a packed GLB.
# blender --background --python 08_export.py -- <07_polished.blend> <out.glb>
import bpy, sys, time
import numpy as np
argv = sys.argv[sys.argv.index("--") + 1:]
BLEND, OUT = argv[0], argv[1]
t0 = time.time()
bpy.ops.wm.open_mainfile(filepath=BLEND)
ob = max([o for o in bpy.data.objects if o.type == 'MESH'],
key=lambda o: len(o.data.vertices))
me = ob.data
co = np.empty(len(me.vertices) * 3)
me.vertices.foreach_get("co", co)
co = co.reshape(-1, 3)
print(f"[export] {ob.name}: {len(me.vertices)}v {len(me.polygons)}f "
f"height {co[:,2].max()-co[:,2].min():.4f} units, "
f"images {[i.name for i in bpy.data.images if i.has_data]}")
bpy.ops.export_scene.gltf(
filepath=OUT,
export_format='GLB',
export_image_format='AUTO',
export_yup=True,
export_apply=False,
export_animations=False,
export_skins=True,
export_morph=False,
)
print(f"[export] WROTE {OUT} ({time.time()-t0:.1f}s)")
print("EXPORT_DONE")
@@ -0,0 +1,56 @@
# Final beauty renders (textured, original materials): full front/40deg/back, chest, hip.
# blender --background --python 09_beauty.py -- <blend> <outdir>
import bpy, sys, math, os
from mathutils import Vector, Euler
argv = sys.argv[sys.argv.index("--") + 1:]
BLEND, OUT = argv[0], argv[1]
os.makedirs(OUT, exist_ok=True)
bpy.ops.wm.open_mainfile(filepath=BLEND)
for o in list(bpy.data.objects):
if o.type in ('LIGHT', 'CAMERA'):
bpy.data.objects.remove(o, do_unlink=True)
sc = bpy.context.scene
sc.render.engine = 'BLENDER_EEVEE'
sc.render.resolution_x = sc.render.resolution_y = 1200
wd = bpy.data.worlds.new("w")
wd.use_nodes = True
wd.node_tree.nodes["Background"].inputs[0].default_value = (0.22, 0.22, 0.24, 1)
sc.world = wd
def sun(rot, e):
ld = bpy.data.lights.new("s", 'SUN')
ld.energy = e
ld.use_shadow = False
lo = bpy.data.objects.new("s", ld)
lo.rotation_euler = rot
bpy.context.collection.objects.link(lo)
sun(Euler((math.radians(55), 0, math.radians(-35))), 2.2)
sun(Euler((math.radians(120), 0, math.radians(150))), 1.0)
sun(Euler((math.radians(85), 0, math.radians(35))), 0.8)
cam = bpy.data.cameras.new("c")
cam.lens = 70
cob = bpy.data.objects.new("c", cam)
bpy.context.collection.objects.link(cob)
sc.camera = cob
views = [
("full_front", Vector((0.0, -2.05, 0.50)), Euler((math.radians(90), 0, 0))),
("full_40", Vector((-1.35, -1.55, 0.50)), Euler((math.radians(90), 0, math.radians(-41)))),
("full_back", Vector((0.0, 2.05, 0.50)), Euler((math.radians(90), 0, math.radians(180)))),
("chest", Vector((0.0, -0.85, 0.70)), Euler((math.radians(90), 0, 0))),
("hip", Vector((0.0, -0.85, 0.46)), Euler((math.radians(90), 0, 0))),
]
for name, loc, rot in views:
cob.location = loc
cob.rotation_euler = rot
sc.render.filepath = os.path.join(OUT, f"{name}.png")
bpy.ops.render.render(write_still=True)
print(f"rendered {name}", flush=True)
print("BEAUTY_DONE")
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,142 @@
# Stage 11: heal the garment-line creases across the stomach/waist/hips.
# The lines are GEOMETRY (they show in clay): the briefs waistband ledge and leg-hem ridges
# (the briefs zone's 1x field kept hip anatomy AND the hem ridges), plus dotted pinch lines
# where the source mesh's open slits were welded. Strip = hemband mask slit-seam verts
# (mapped from the RAW pre-weld GLB's boundary edges) normal-kink verts in the torso band.
# Taubin (volume-preserving) on the strip: ridges round off, hips/butt keep their shape.
# blender --background --python 11_line_heal.py -- <in.blend> <masks.npz> <raw.glb> <out.blend>
import bpy, sys, time, math
import numpy as np
from mathutils import Vector
from mathutils.kdtree import KDTree
argv = sys.argv[sys.argv.index("--") + 1:]
BLEND, MASKS, RAW, OUT = argv[0], argv[1], argv[2], argv[3]
t0 = time.time()
ANG_THR = math.radians(12.0)
TAUBIN_PAIRS = 30
Z0, Z1 = 0.30, 0.87
def log(m):
print(f"[heal {time.time()-t0:6.1f}s] {m}", flush=True)
bpy.ops.wm.open_mainfile(filepath=BLEND)
ob = max([o for o in bpy.data.objects if o.type == 'MESH'],
key=lambda o: len(o.data.vertices))
me = ob.data
n_v = len(me.vertices)
co = np.empty(n_v * 3)
me.vertices.foreach_get("co", co)
co = co.reshape(-1, 3)
M = np.load(MASKS)
hemband = M["hemband"]
# ---- slit-seam verts from the raw pre-weld GLB ----
before = set(bpy.data.objects)
bpy.ops.import_scene.gltf(filepath=RAW)
new = [o for o in bpy.data.objects if o not in before]
raw = max([o for o in new if o.type == 'MESH'], key=lambda o: len(o.data.vertices))
rme = raw.data
rn = len(rme.vertices)
rco = np.empty(rn * 3)
rme.vertices.foreach_get("co", rco)
rco = rco.reshape(-1, 3)
l_tot = np.empty(len(rme.polygons), dtype=np.int32)
rme.polygons.foreach_get("loop_total", l_tot)
l_start = np.empty(len(rme.polygons), dtype=np.int32)
rme.polygons.foreach_get("loop_start", l_start)
l_v = np.empty(len(rme.loops), dtype=np.int32)
rme.loops.foreach_get("vertex_index", l_v)
ecount = {}
for fs, ft in zip(l_start, l_tot):
idxs = l_v[fs:fs + ft]
for k in range(ft):
a, b = idxs[k], idxs[(k + 1) % ft]
key = (a, b) if a < b else (b, a)
ecount[key] = ecount.get(key, 0) + 1
rbnd = np.zeros(rn, dtype=bool)
for (a, b), c in ecount.items():
if c == 1:
rbnd[a] = rbnd[b] = True
log(f"raw boundary verts: {rbnd.sum()}")
for o in new:
bpy.data.objects.remove(o, do_unlink=True)
kd = KDTree(n_v)
for i in range(n_v):
kd.insert(Vector(co[i]), i)
kd.balance()
seam = np.zeros(n_v, dtype=bool)
# NB: positions have been sculpted since the weld — match generously but only in the torso
# band, and only trust matches within 6 mm (sculpted garment areas moved far more; their
# seams are already handled by the fills/melts there)
for p in rco[rbnd]:
if not (Z0 < p[2] < Z1):
continue
hit = kd.find(Vector(p))
if hit[0] is not None and hit[2] < 0.006:
seam[hit[1]] = True
log(f"seam verts mapped: {seam.sum()}")
# ---- normal-kink verts (ledges/ridges) ----
nrm = np.empty(n_v * 3)
me.vertices.foreach_get("normal", nrm)
nrm = nrm.reshape(-1, 3)
n_e = len(me.edges)
ev = np.empty(n_e * 2, dtype=np.int32)
me.edges.foreach_get("vertices", ev)
ev = ev.reshape(-1, 2)
order = np.concatenate([ev[:, 0], ev[:, 1]])
nbr = np.concatenate([ev[:, 1], ev[:, 0]])
srt = np.argsort(order, kind="stable")
o_s = order[srt]
n_s = nbr[srt]
ptr = np.searchsorted(o_s, np.arange(n_v + 1))
cnt = np.maximum(ptr[1:] - ptr[:-1], 1)
N = nrm.copy()
for _ in range(5):
acc = np.zeros_like(N)
np.add.at(acc, o_s, N[n_s])
N = acc / cnt[:, None]
N /= np.maximum(np.linalg.norm(N, axis=1, keepdims=True), 1e-12)
ang = np.arccos(np.clip((nrm * N).sum(axis=1), -1, 1))
navel = (np.abs(co[:, 0]) < 0.022) & (co[:, 2] > 0.495) & (co[:, 2] < 0.555) & (co[:, 1] < 0)
band = (co[:, 2] > 0.42) & (co[:, 2] < Z1) & ~navel
kink = band & (ang > ANG_THR)
log(f"kink verts: {kink.sum()}")
strip = (hemband | seam | kink) & (co[:, 2] > Z0) & (co[:, 2] < Z1) & ~navel
for _ in range(2):
hit = strip[ev[:, 0]] | strip[ev[:, 1]]
s2 = strip.copy()
s2[ev[:, 0]] |= hit
s2[ev[:, 1]] |= hit
strip = s2
strip &= ~navel
sidx = np.nonzero(strip)[0]
log(f"strip: {len(sidx)} verts")
Q = co.copy()
lam, mu = 0.5, -0.53
for _ in range(TAUBIN_PAIRS):
for f in (lam, mu):
acc = np.zeros_like(Q)
np.add.at(acc, o_s, Q[n_s])
mean = acc / cnt[:, None]
Q[sidx] += f * (mean[sidx] - Q[sidx])
d = np.linalg.norm(Q - co, axis=1)
log(f"Taubin x{TAUBIN_PAIRS}: max move {d.max():.4f}")
me.vertices.foreach_set("co", Q.reshape(-1))
me.update()
if me.has_custom_normals:
vn = np.empty(n_v * 3, dtype=np.float32)
me.vertices.foreach_get("normal", vn)
me.normals_split_custom_set_from_vertices(vn.reshape(-1, 3))
bpy.context.preferences.filepaths.save_version = 0 # no .blend1 autosave
bpy.ops.wm.save_as_mainfile(filepath=OUT)
log(f"WROTE {OUT}")
print("HEAL_DONE")
@@ -0,0 +1,177 @@
# Stage 12: membrane-heal the garment dig-in lines (waistband ledge, leg-hem creases,
# belly/underbust dashes). These are DENTS the briefs/bra pressed into the body — the briefs
# zone's 1x anatomy field kept them, and Taubin/melting preserves exactly this mid-frequency
# shape. Fix = the proven pattern: narrow bands along the crease curves (mid-freq roughness
# detector + mapped slit seams), rim-anchored bi-harmonic membrane across them.
# blender --background --python 12_dent_membrane.py -- <in.blend> <raw.glb> <out.blend>
import bpy, sys, time
import numpy as np
from mathutils import Vector
from mathutils.kdtree import KDTree
argv = sys.argv[sys.argv.index("--") + 1:]
BLEND, RAW, OUT = argv[0], argv[1], argv[2]
t0 = time.time()
ROUGH_THR = 0.0006
Z0, Z1 = 0.42, 0.655 # waist/hip/belly lines up to under the mounds; crotch already healed
GROW_FREE = 3
t0 = time.time()
def log(m):
print(f"[dent {time.time()-t0:6.1f}s] {m}", flush=True)
bpy.ops.wm.open_mainfile(filepath=BLEND)
ob = max([o for o in bpy.data.objects if o.type == 'MESH'],
key=lambda o: len(o.data.vertices))
me = ob.data
n_v = len(me.vertices)
co = np.empty(n_v * 3)
me.vertices.foreach_get("co", co)
co = co.reshape(-1, 3)
n_e = len(me.edges)
ev0 = np.empty(n_e * 2, dtype=np.int32)
me.edges.foreach_get("vertices", ev0)
ev0 = ev0.reshape(-1, 2)
o_r = np.concatenate([ev0[:, 0], ev0[:, 1]])
n_r = np.concatenate([ev0[:, 1], ev0[:, 0]])
s_r = np.argsort(o_r, kind="stable")
o_rs = o_r[s_r]
n_rs = n_r[s_r]
ptr_r = np.searchsorted(o_rs, np.arange(n_v + 1))
cnt_r = np.maximum(np.diff(ptr_r), 1)
sm = co.copy()
for _ in range(8):
su = np.add.reduceat(sm[n_rs], ptr_r[:-1], axis=0)
emp = np.diff(ptr_r) == 0
su[emp] = sm[emp]
sm = su / cnt_r[:, None]
rough = np.linalg.norm(co - sm, axis=1)
# slit seams from the raw pre-weld GLB (the dotted dash lines)
before = set(bpy.data.objects)
bpy.ops.import_scene.gltf(filepath=RAW)
new = [o for o in bpy.data.objects if o not in before]
raw = max([o for o in new if o.type == 'MESH'], key=lambda o: len(o.data.vertices))
rme = raw.data
rn = len(rme.vertices)
rco = np.empty(rn * 3)
rme.vertices.foreach_get("co", rco)
rco = rco.reshape(-1, 3)
l_tot = np.empty(len(rme.polygons), dtype=np.int32)
rme.polygons.foreach_get("loop_total", l_tot)
l_start = np.empty(len(rme.polygons), dtype=np.int32)
rme.polygons.foreach_get("loop_start", l_start)
l_v = np.empty(len(rme.loops), dtype=np.int32)
rme.loops.foreach_get("vertex_index", l_v)
ecount = {}
for fs, ft in zip(l_start, l_tot):
idxs = l_v[fs:fs + ft]
for k in range(ft):
a, b = idxs[k], idxs[(k + 1) % ft]
kk = (a, b) if a < b else (b, a)
ecount[kk] = ecount.get(kk, 0) + 1
rbnd = np.zeros(rn, dtype=bool)
for (a, b), c in ecount.items():
if c == 1:
rbnd[a] = rbnd[b] = True
for o in new:
bpy.data.objects.remove(o, do_unlink=True)
kd = KDTree(n_v)
for i in range(n_v):
kd.insert(Vector(co[i]), i)
kd.balance()
seam = np.zeros(n_v, dtype=bool)
for p in rco[rbnd]:
if not (Z0 < p[2] < Z1):
continue
hit = kd.find(Vector(p))
if hit[0] is not None and hit[2] < 0.006:
seam[hit[1]] = True
log(f"seam verts: {seam.sum()}")
navel = (np.abs(co[:, 0]) < 0.022) & (co[:, 2] > 0.495) & (co[:, 2] < 0.555) & (co[:, 1] < 0)
band = (co[:, 2] > Z0) & (co[:, 2] < Z1) & ~navel
hot = band & ((rough > ROUGH_THR) | seam)
log(f"hot line verts: {hot.sum()}")
def grow_edges(mask, rings, ev):
m = mask.copy()
for _ in range(rings):
hit = m[ev[:, 0]] | m[ev[:, 1]]
m2 = m.copy()
m2[ev[:, 0]] |= hit
m2[ev[:, 1]] |= hit
m = m2
return m
free_m = grow_edges(hot, GROW_FREE, ev0) & ~navel
collar = grow_edges(free_m, 2, ev0) & ~free_m
S = np.nonzero(free_m | collar)[0]
in_S = np.zeros(n_v, dtype=bool)
in_S[S] = True
glb = np.full(n_v, -1, dtype=np.int64)
glb[S] = np.arange(len(S))
se = ev0[in_S[ev0].all(axis=1)]
a_ = glb[se[:, 0]]
b_ = glb[se[:, 1]]
deg = np.zeros(len(S))
np.add.at(deg, a_, 1.0)
np.add.at(deg, b_, 1.0)
free = free_m[S]
log(f"free {free.sum()}, collar {(~free).sum()}")
def Ls(X):
out = deg[:, None] * X
np.add.at(out, a_, -X[b_])
np.add.at(out, b_, -X[a_])
return out
def A_op(U):
X = np.zeros((len(S), 3))
X[free] = U
return Ls(Ls(X))[free]
Xc = np.zeros((len(S), 3))
Xc[~free] = co[S[~free]]
rhs = -Ls(Ls(Xc))[free]
U = co[S[free]].copy()
r = rhs - A_op(U)
p = r.copy()
rs = (r * r).sum()
rs0 = rs
for it in range(120000):
Ap = A_op(p)
al = rs / max((p * Ap).sum(), 1e-30)
U += al * p
r -= al * Ap
rs2 = (r * r).sum()
if rs2 < 1e-18 or rs2 < rs0 * 1e-14:
break
p = r + (rs2 / rs) * p
rs = rs2
co_new = co.copy()
co_new[S[free]] = U
d = np.linalg.norm(co_new - co, axis=1)
log(f"membrane: {free.sum()} verts (CG {it}, rel {rs2/max(rs0,1e-30):.2e}), max move {d.max():.4f}")
me.vertices.foreach_set("co", co_new.reshape(-1))
me.update()
if me.has_custom_normals:
vn = np.empty(n_v * 3, dtype=np.float32)
me.vertices.foreach_get("normal", vn)
me.normals_split_custom_set_from_vertices(vn.reshape(-1, 3))
bpy.context.preferences.filepaths.save_version = 0 # no .blend1 autosave
bpy.ops.wm.save_as_mainfile(filepath=OUT)
log(f"WROTE {OUT}")
print("DENT_DONE")
@@ -0,0 +1,168 @@
# Stage 13: heal the garment-BOUNDARY dig-ins. Every stomach/waist line sits where an old
# garment mask edge was: the sculpt replaced the region INSIDE the mask, but anchored its
# membrane on the rim — exactly where the waistband/leg-hems/bra-band dug into the body, so
# the dig-in ring survived as the boundary condition. Free a band STRADDLING every garment
# boundary (grow-out XOR shrink-in), plus slit seams on unmoved skin (0.5 mm mapping), and
# solve the rim-anchored membrane across it.
# blender --background --python 13_rim_membrane.py -- <in.blend> <masks.npz> <raw.glb> <out.blend>
import bpy, sys, time
import numpy as np
from mathutils import Vector
from mathutils.kdtree import KDTree
argv = sys.argv[sys.argv.index("--") + 1:]
BLEND, MASKS, RAW, OUT = argv[0], argv[1], argv[2], argv[3]
t0 = time.time()
RIM = 10 # rings each side of the garment boundary (~1 cm)
Z0, Z1 = 0.40, 0.86
SEAM_TOL = 0.0005 # only trust seam mapping on UNMOVED skin
def log(m):
print(f"[rim {time.time()-t0:6.1f}s] {m}", flush=True)
bpy.ops.wm.open_mainfile(filepath=BLEND)
ob = max([o for o in bpy.data.objects if o.type == 'MESH'],
key=lambda o: len(o.data.vertices))
me = ob.data
n_v = len(me.vertices)
co = np.empty(n_v * 3)
me.vertices.foreach_get("co", co)
co = co.reshape(-1, 3)
M = np.load(MASKS)
garment = M["garment"]
n_e = len(me.edges)
ev0 = np.empty(n_e * 2, dtype=np.int32)
me.edges.foreach_get("vertices", ev0)
ev0 = ev0.reshape(-1, 2)
def grow_edges(mask, rings, ev):
m = mask.copy()
for _ in range(rings):
hit = m[ev[:, 0]] | m[ev[:, 1]]
m2 = m.copy()
m2[ev[:, 0]] |= hit
m2[ev[:, 1]] |= hit
m = m2
return m
def shrink_edges(mask, rings, ev):
return ~grow_edges(~mask, rings, ev)
rim_band = grow_edges(garment, RIM, ev0) & ~shrink_edges(garment, RIM, ev0)
log(f"garment rim band: {rim_band.sum()}")
# slit seams on unmoved skin
before = set(bpy.data.objects)
bpy.ops.import_scene.gltf(filepath=RAW)
new = [o for o in bpy.data.objects if o not in before]
raw = max([o for o in new if o.type == 'MESH'], key=lambda o: len(o.data.vertices))
rme = raw.data
rn = len(rme.vertices)
rco = np.empty(rn * 3)
rme.vertices.foreach_get("co", rco)
rco = rco.reshape(-1, 3)
l_tot = np.empty(len(rme.polygons), dtype=np.int32)
rme.polygons.foreach_get("loop_total", l_tot)
l_start = np.empty(len(rme.polygons), dtype=np.int32)
rme.polygons.foreach_get("loop_start", l_start)
l_v = np.empty(len(rme.loops), dtype=np.int32)
rme.loops.foreach_get("vertex_index", l_v)
ecount = {}
for fs, ft in zip(l_start, l_tot):
idxs = l_v[fs:fs + ft]
for k in range(ft):
a, b = idxs[k], idxs[(k + 1) % ft]
kk = (a, b) if a < b else (b, a)
ecount[kk] = ecount.get(kk, 0) + 1
rbnd = np.zeros(rn, dtype=bool)
for (a, b), c in ecount.items():
if c == 1:
rbnd[a] = rbnd[b] = True
for o in new:
bpy.data.objects.remove(o, do_unlink=True)
kd = KDTree(n_v)
for i in range(n_v):
kd.insert(Vector(co[i]), i)
kd.balance()
seam = np.zeros(n_v, dtype=bool)
for p in rco[rbnd]:
if not (Z0 < p[2] < Z1):
continue
hit = kd.find(Vector(p))
if hit[0] is not None and hit[2] < SEAM_TOL:
seam[hit[1]] = True
log(f"seam verts (tight map): {seam.sum()}")
navel = (np.abs(co[:, 0]) < 0.022) & (co[:, 2] > 0.495) & (co[:, 2] < 0.555) & (co[:, 1] < 0)
free_m = ((rim_band | grow_edges(seam, 3, ev0)) &
(co[:, 2] > Z0) & (co[:, 2] < Z1) & ~navel)
collar = grow_edges(free_m, 2, ev0) & ~free_m
S = np.nonzero(free_m | collar)[0]
in_S = np.zeros(n_v, dtype=bool)
in_S[S] = True
glb = np.full(n_v, -1, dtype=np.int64)
glb[S] = np.arange(len(S))
se = ev0[in_S[ev0].all(axis=1)]
a_ = glb[se[:, 0]]
b_ = glb[se[:, 1]]
deg = np.zeros(len(S))
np.add.at(deg, a_, 1.0)
np.add.at(deg, b_, 1.0)
free = free_m[S]
log(f"free {free.sum()}, collar {(~free).sum()}")
def Ls(X):
out = deg[:, None] * X
np.add.at(out, a_, -X[b_])
np.add.at(out, b_, -X[a_])
return out
def A_op(U):
X = np.zeros((len(S), 3))
X[free] = U
return Ls(Ls(X))[free]
Xc = np.zeros((len(S), 3))
Xc[~free] = co[S[~free]]
rhs = -Ls(Ls(Xc))[free]
U = co[S[free]].copy()
r = rhs - A_op(U)
p = r.copy()
rs = (r * r).sum()
rs0 = rs
for it in range(120000):
Ap = A_op(p)
al = rs / max((p * Ap).sum(), 1e-30)
U += al * p
r -= al * Ap
rs2 = (r * r).sum()
if rs2 < 1e-18 or rs2 < rs0 * 1e-14:
break
p = r + (rs2 / rs) * p
rs = rs2
co_new = co.copy()
co_new[S[free]] = U
d = np.linalg.norm(co_new - co, axis=1)
log(f"membrane: {free.sum()} verts (CG {it}, rel {rs2/max(rs0,1e-30):.2e}), max move {d.max():.4f}")
me.vertices.foreach_set("co", co_new.reshape(-1))
me.update()
if me.has_custom_normals:
vn = np.empty(n_v * 3, dtype=np.float32)
me.vertices.foreach_get("normal", vn)
me.normals_split_custom_set_from_vertices(vn.reshape(-1, 3))
bpy.context.preferences.filepaths.save_version = 0 # no .blend1 autosave
bpy.ops.wm.save_as_mainfile(filepath=OUT)
log(f"WROTE {OUT}")
print("RIM_DONE")
@@ -0,0 +1,156 @@
# Stage 14: heal the SOURCE MESH's panel-seam network. The mask viz proved the stomach/waist
# lines are the Tripo scan-panel slits (a body-wide grid), not garment edges — the paint had
# been camouflaging them. Map the raw mesh's boundary verts at 2 mm (welding moved verts ~1 mm,
# which is why a 0.5 mm map only caught a quarter of the network), grow 2, membrane across.
# blender --background --python 14_seam_membrane.py -- <in.blend> <raw.glb> <out.blend>
import bpy, sys, time
import numpy as np
from mathutils import Vector
from mathutils.kdtree import KDTree
argv = sys.argv[sys.argv.index("--") + 1:]
BLEND, RAW, OUT = argv[0], argv[1], argv[2]
t0 = time.time()
SEAM_TOL = 0.002
Z0, Z1 = 0.28, 0.87 # whole body below the chin; face seams stay (framed by features)
def log(m):
print(f"[seam {time.time()-t0:6.1f}s] {m}", flush=True)
bpy.ops.wm.open_mainfile(filepath=BLEND)
ob = max([o for o in bpy.data.objects if o.type == 'MESH'],
key=lambda o: len(o.data.vertices))
me = ob.data
n_v = len(me.vertices)
co = np.empty(n_v * 3)
me.vertices.foreach_get("co", co)
co = co.reshape(-1, 3)
n_e = len(me.edges)
ev0 = np.empty(n_e * 2, dtype=np.int32)
me.edges.foreach_get("vertices", ev0)
ev0 = ev0.reshape(-1, 2)
before = set(bpy.data.objects)
bpy.ops.import_scene.gltf(filepath=RAW)
new = [o for o in bpy.data.objects if o not in before]
raw = max([o for o in new if o.type == 'MESH'], key=lambda o: len(o.data.vertices))
rme = raw.data
rn = len(rme.vertices)
rco = np.empty(rn * 3)
rme.vertices.foreach_get("co", rco)
rco = rco.reshape(-1, 3)
l_tot = np.empty(len(rme.polygons), dtype=np.int32)
rme.polygons.foreach_get("loop_total", l_tot)
l_start = np.empty(len(rme.polygons), dtype=np.int32)
rme.polygons.foreach_get("loop_start", l_start)
l_v = np.empty(len(rme.loops), dtype=np.int32)
rme.loops.foreach_get("vertex_index", l_v)
ecount = {}
for fs, ft in zip(l_start, l_tot):
idxs = l_v[fs:fs + ft]
for k in range(ft):
a, b = idxs[k], idxs[(k + 1) % ft]
kk = (a, b) if a < b else (b, a)
ecount[kk] = ecount.get(kk, 0) + 1
rbnd = np.zeros(rn, dtype=bool)
for (a, b), c in ecount.items():
if c == 1:
rbnd[a] = rbnd[b] = True
for o in new:
bpy.data.objects.remove(o, do_unlink=True)
log(f"raw boundary verts: {rbnd.sum()}")
kd = KDTree(n_v)
for i in range(n_v):
kd.insert(Vector(co[i]), i)
kd.balance()
seam = np.zeros(n_v, dtype=bool)
for p in rco[rbnd]:
if not (Z0 < p[2] < Z1):
continue
hit = kd.find(Vector(p))
if hit[0] is not None and hit[2] < SEAM_TOL:
seam[hit[1]] = True
log(f"seam verts mapped: {seam.sum()}")
navel = (np.abs(co[:, 0]) < 0.022) & (co[:, 2] > 0.495) & (co[:, 2] < 0.555) & (co[:, 1] < 0)
def grow_edges(mask, rings, ev):
m = mask.copy()
for _ in range(rings):
hit = m[ev[:, 0]] | m[ev[:, 1]]
m2 = m.copy()
m2[ev[:, 0]] |= hit
m2[ev[:, 1]] |= hit
m = m2
return m
free_m = grow_edges(seam, 2, ev0) & (co[:, 2] > Z0) & (co[:, 2] < Z1) & ~navel
collar = grow_edges(free_m, 2, ev0) & ~free_m
S = np.nonzero(free_m | collar)[0]
in_S = np.zeros(n_v, dtype=bool)
in_S[S] = True
glb = np.full(n_v, -1, dtype=np.int64)
glb[S] = np.arange(len(S))
se = ev0[in_S[ev0].all(axis=1)]
a_ = glb[se[:, 0]]
b_ = glb[se[:, 1]]
deg = np.zeros(len(S))
np.add.at(deg, a_, 1.0)
np.add.at(deg, b_, 1.0)
free = free_m[S]
log(f"free {free.sum()}, collar {(~free).sum()}")
def Ls(X):
out = deg[:, None] * X
np.add.at(out, a_, -X[b_])
np.add.at(out, b_, -X[a_])
return out
def A_op(U):
X = np.zeros((len(S), 3))
X[free] = U
return Ls(Ls(X))[free]
Xc = np.zeros((len(S), 3))
Xc[~free] = co[S[~free]]
rhs = -Ls(Ls(Xc))[free]
U = co[S[free]].copy()
r = rhs - A_op(U)
p = r.copy()
rs = (r * r).sum()
rs0 = rs
for it in range(120000):
Ap = A_op(p)
al = rs / max((p * Ap).sum(), 1e-30)
U += al * p
r -= al * Ap
rs2 = (r * r).sum()
if rs2 < 1e-18 or rs2 < rs0 * 1e-14:
break
p = r + (rs2 / rs) * p
rs = rs2
co_new = co.copy()
co_new[S[free]] = U
d = np.linalg.norm(co_new - co, axis=1)
log(f"membrane: {free.sum()} verts (CG {it}, rel {rs2/max(rs0,1e-30):.2e}), max move {d.max():.4f}")
me.vertices.foreach_set("co", co_new.reshape(-1))
me.update()
if me.has_custom_normals:
vn = np.empty(n_v * 3, dtype=np.float32)
me.vertices.foreach_get("normal", vn)
me.normals_split_custom_set_from_vertices(vn.reshape(-1, 3))
bpy.context.preferences.filepaths.save_version = 0 # no .blend1 autosave
bpy.ops.wm.save_as_mainfile(filepath=OUT)
log(f"WROTE {OUT}")
print("SEAM_DONE")
@@ -0,0 +1,94 @@
# Stage 15: TRUE topological weld of the panel seams. Diagnosis: membranes level both sides
# of every seam yet the lines persist -> the two sides are disconnected vertex runs (the
# original "weld" unified positions only). Each panel smooths and shades independently, so a
# crack survives any vertex MOVEMENT. Fix: bmesh remove_doubles restricted to the seam verts,
# which merges the runs into shared vertices -> shared normals -> no shading discontinuity.
# Loops keep their UVs, so the baked texture is unaffected. Vertex count changes; this is
# only legal at the END of the pipeline (masks.npz indices die here).
# blender --background --python 15_true_weld.py -- <in.blend> <raw.glb> <out.blend>
import bpy, bmesh, sys, time
import numpy as np
from mathutils import Vector
from mathutils.kdtree import KDTree
argv = sys.argv[sys.argv.index("--") + 1:]
BLEND, RAW, OUT = argv[0], argv[1], argv[2]
t0 = time.time()
SEAM_TOL = 0.0025
MERGE_DIST = 0.002
def log(m):
print(f"[weld {time.time()-t0:6.1f}s] {m}", flush=True)
bpy.ops.wm.open_mainfile(filepath=BLEND)
ob = max([o for o in bpy.data.objects if o.type == 'MESH'],
key=lambda o: len(o.data.vertices))
me = ob.data
n_v = len(me.vertices)
co = np.empty(n_v * 3)
me.vertices.foreach_get("co", co)
co = co.reshape(-1, 3)
before = set(bpy.data.objects)
bpy.ops.import_scene.gltf(filepath=RAW)
new = [o for o in bpy.data.objects if o not in before]
raw = max([o for o in new if o.type == 'MESH'], key=lambda o: len(o.data.vertices))
rme = raw.data
rn = len(rme.vertices)
log(f"working mesh {n_v}v | raw mesh {rn}v | equal: {rn == n_v}")
rco = np.empty(rn * 3)
rme.vertices.foreach_get("co", rco)
rco = rco.reshape(-1, 3)
l_tot = np.empty(len(rme.polygons), dtype=np.int32)
rme.polygons.foreach_get("loop_total", l_tot)
l_start = np.empty(len(rme.polygons), dtype=np.int32)
rme.polygons.foreach_get("loop_start", l_start)
l_v = np.empty(len(rme.loops), dtype=np.int32)
rme.loops.foreach_get("vertex_index", l_v)
ecount = {}
for fs, ft in zip(l_start, l_tot):
idxs = l_v[fs:fs + ft]
for k in range(ft):
a, b = idxs[k], idxs[(k + 1) % ft]
kk = (a, b) if a < b else (b, a)
ecount[kk] = ecount.get(kk, 0) + 1
rbnd = np.zeros(rn, dtype=bool)
for (a, b), c in ecount.items():
if c == 1:
rbnd[a] = rbnd[b] = True
for o in new:
bpy.data.objects.remove(o, do_unlink=True)
log(f"raw boundary verts: {rbnd.sum()}")
kd = KDTree(n_v)
for i in range(n_v):
kd.insert(Vector(co[i]), i)
kd.balance()
seam = np.zeros(n_v, dtype=bool)
for p in rco[rbnd]:
for (_, idx, dist) in kd.find_range(Vector(p), SEAM_TOL):
seam[idx] = True
log(f"seam verts (range map): {seam.sum()}")
bm = bmesh.new()
bm.from_mesh(me)
bm.verts.ensure_lookup_table()
sel = [bm.verts[i] for i in np.nonzero(seam)[0]]
res = bmesh.ops.remove_doubles(bm, verts=sel, dist=MERGE_DIST)
bm.to_mesh(me)
bm.free()
me.update()
n_after = len(me.vertices)
log(f"merged: {n_v} -> {n_after} verts (-{n_v - n_after})")
# smooth vertex normals across the now-shared seams
vn = np.empty(n_after * 3, dtype=np.float32)
me.vertices.foreach_get("normal", vn)
me.normals_split_custom_set_from_vertices(vn.reshape(-1, 3))
bpy.context.preferences.filepaths.save_version = 0 # no .blend1 autosave
bpy.ops.wm.save_as_mainfile(filepath=OUT)
log(f"WROTE {OUT}")
print("WELD_DONE")
@@ -0,0 +1,278 @@
# Stage 16 (diagnosis only — writes nothing): measure the three defects Jeremy named, so the
# fixes target what is actually there instead of repeating stages 11-15.
#
# blender --background --python 16_diagnose.py -- <in.blend> <orig.blend> [scratch_dir]
#
# 1. CUT LINES. Are they still topology (disconnected panel runs / holes) after stage 15's weld,
# or are they now a purely geometric groove? Reports boundary edges, non-manifold edges,
# degenerate faces, and — for the strongest shading-kink clusters — the groove depth in mm
# measured perpendicular to the line. Depth tells us whether to weld harder or to fillet.
# 2. DISCOLOURATION. Finds the repainted texels by diffing this blend's packed basecolor against
# the ORIGINAL texture, then reports mean RGB inside the patch vs a ring of untouched skin
# just outside it. A tone STEP at the boundary is a Poisson problem; a uniform offset over the
# whole patch is a levelling problem. The numbers separate them.
# 3. CLEAVAGE. Samples the medial (sternum) corridor for concavity: minimum principal-curvature
# radius per height, so "sharp crease" vs "round fillet" is a number, not an opinion.
import bpy, bmesh, sys, os, time, math
import numpy as np
argv = sys.argv[sys.argv.index("--") + 1:]
BLEND = argv[0]
ORIG = argv[1] if len(argv) > 1 else ""
SCRATCH = argv[2] if len(argv) > 2 else "."
t0 = time.time()
def log(m):
print(f"[diag {time.time()-t0:6.1f}s] {m}", flush=True)
def body_of():
return max([o for o in bpy.data.objects if o.type == 'MESH'],
key=lambda o: len(o.data.vertices))
# =============================================================================
# 1. TOPOLOGY + CUT LINES
# =============================================================================
bpy.ops.wm.open_mainfile(filepath=BLEND)
ob = body_of()
me = ob.data
n_v = len(me.vertices)
n_f = len(me.polygons)
log(f"mesh '{ob.name}': {n_v} verts, {n_f} faces, custom_normals={me.has_custom_normals}")
co = np.empty(n_v * 3)
me.vertices.foreach_get("co", co)
co = co.reshape(-1, 3)
print(f"BBOX z {co[:,2].min():.3f}..{co[:,2].max():.3f} "
f"x {co[:,0].min():.3f}..{co[:,0].max():.3f} y {co[:,1].min():.3f}..{co[:,1].max():.3f}")
bm = bmesh.new()
bm.from_mesh(me)
bnd = [e for e in bm.edges if len(e.link_faces) == 1]
nonman = [e for e in bm.edges if len(e.link_faces) > 2]
degen = [f for f in bm.faces if f.calc_area() < 1e-12]
loose = [v for v in bm.verts if not v.link_faces]
print(f"TOPO boundary_edges={len(bnd)} nonmanifold_edges={len(nonman)} "
f"degenerate_faces={len(degen)} loose_verts={len(loose)}")
# where are the holes? cluster boundary verts by height
if bnd:
bz = np.array([v.co.z for e in bnd for v in e.verts])
hist, edges = np.histogram(bz, bins=12)
print("BOUNDARY-EDGE z histogram (holes live here):")
for c, lo, hi in zip(hist, edges[:-1], edges[1:]):
if c:
print(f" z {lo:.3f}-{hi:.3f}: {c}")
bm.free()
# ---- shading kinks = the visible lines ----
nrm = np.empty(n_v * 3)
me.vertices.foreach_get("normal", nrm)
nrm = nrm.reshape(-1, 3)
ev = np.empty(len(me.edges) * 2, dtype=np.int32)
me.edges.foreach_get("vertices", ev)
ev = ev.reshape(-1, 2)
order = np.concatenate([ev[:, 0], ev[:, 1]])
nbr = np.concatenate([ev[:, 1], ev[:, 0]])
srt = np.argsort(order, kind="stable")
o_s, n_s = order[srt], nbr[srt]
ptr = np.searchsorted(o_s, np.arange(n_v + 1))
cnt = np.maximum(np.diff(ptr), 1)
def nbr_mean(X):
acc = np.add.reduceat(X[n_s], ptr[:-1], axis=0)
empty = np.diff(ptr) == 0
acc[empty] = X[empty]
return acc / cnt[:, None]
N = nrm.copy()
for _ in range(5):
N = nbr_mean(N)
N /= np.maximum(np.linalg.norm(N, axis=1, keepdims=True), 1e-12)
ang = np.degrees(np.arccos(np.clip((nrm * N).sum(axis=1), -1, 1)))
# signed offset from the locally-smooth surface: negative = groove, positive = ridge
sm = co.copy()
for _ in range(12):
sm = nbr_mean(sm)
dev = ((co - sm) * N).sum(axis=1) # metres, along the smooth normal
torso = (co[:, 2] > 0.28) & (co[:, 2] < 0.90)
for thr in (8.0, 12.0, 20.0):
k = torso & (ang > thr)
print(f"KINK >{thr:4.1f}deg : {k.sum():6d} verts "
f"(groove depth p05={np.percentile(dev[k],5)*1000:+.3f} mm, "
f"median={np.median(dev[k])*1000:+.3f} mm, "
f"p95={np.percentile(dev[k],95)*1000:+.3f} mm)" if k.sum() else f"KINK >{thr}: none")
kink = torso & (ang > 12.0)
if kink.sum():
hist, edges = np.histogram(co[kink, 2], bins=16)
print("KINK z histogram (the lines):")
for c, lo, hi in zip(hist, edges[:-1], edges[1:]):
if c > 20:
print(f" z {lo:.3f}-{hi:.3f}: {c:5d}")
# are kink verts topologically split? count how many sit on a boundary or have a
# near-duplicate vertex that is NOT an edge-neighbour (= two panels touching, unmerged)
from mathutils import Vector
from mathutils.kdtree import KDTree
kidx = np.nonzero(kink)[0]
sample = kidx[::max(1, len(kidx) // 4000)]
kd = KDTree(n_v)
for i in range(n_v):
kd.insert(Vector(co[i]), i)
kd.balance()
nbrs = [set() for _ in range(0)]
adj = {}
for a, b in ev:
adj.setdefault(a, set()).add(b)
adj.setdefault(b, set()).add(a)
split = 0
for i in sample:
for (_, j, d) in kd.find_range(Vector(co[i]), 0.0008):
if j != i and j not in adj.get(i, ()):
split += 1
break
print(f"SPLIT-PANEL test on {len(sample)} kink verts: {split} "
f"({100.0*split/max(len(sample),1):.1f}%) have an unmerged twin within 0.8 mm")
# =============================================================================
# 3. CLEAVAGE — concavity of the medial corridor
# =============================================================================
print("\n=== CLEAVAGE: medial corridor cross-sections y(x) ===")
front = co[:, 1] < 0
for z0 in np.arange(0.62, 0.745, 0.015):
row = []
for x0 in np.arange(-0.05, 0.0501, 0.005):
m = front & (np.abs(co[:, 0] - x0) < 0.0035) & (np.abs(co[:, 2] - z0) < 0.004)
row.append(co[m, 1].min() if m.sum() else np.nan)
row = np.array(row)
if np.isnan(row).all():
continue
# curvature of the y(x) profile at the sternum: second difference over 5 mm steps
mid = len(row) // 2
seg = row[max(0, mid - 3):mid + 4]
if len(seg) >= 3 and not np.isnan(seg).any():
d2 = (seg[:-2] - 2 * seg[1:-1] + seg[2:]) / (0.005 ** 2)
kmax = np.nanmax(d2)
rad = 1.0 / kmax if kmax > 1e-6 else float('inf')
print(f" z={z0:.3f} sternum y={row[mid]:+.4f} "
f"max concave curvature {kmax:8.1f} 1/m -> fillet radius "
f"{rad*1000:6.1f} mm" + (" <-- SHARP" if rad < 0.012 else ""))
print("\n=== CLEAVAGE: depth of the notch (breast apex y vs sternum y) ===")
for z0 in np.arange(0.62, 0.745, 0.015):
ms = front & (np.abs(co[:, 0]) < 0.004) & (np.abs(co[:, 2] - z0) < 0.004)
ma = front & (np.abs(np.abs(co[:, 0]) - 0.034) < 0.005) & (np.abs(co[:, 2] - z0) < 0.004)
if ms.sum() and ma.sum():
print(f" z={z0:.3f} sternum {co[ms,1].min():+.4f} apex {co[ma,1].min():+.4f} "
f"notch {(co[ms,1].min()-co[ma,1].min())*1000:+6.1f} mm")
# =============================================================================
# 2. DISCOLOURATION — repainted texels vs surrounding skin
# =============================================================================
def grab_images():
out = {}
for i in bpy.data.images:
nm = i.name.lower()
if "basecolor" in nm:
out["base"] = i
return out
def px(img):
w, h = img.size
b = np.empty(w * h * 4, dtype=np.float32)
img.pixels.foreach_get(b)
return b.reshape(h, w, 4)[:, :, :3].astype(np.float32), w, h
cur = grab_images()
if "base" not in cur:
print("\nDISCOLOUR: no basecolor image found; skipping")
else:
A, w, h = px(cur["base"])
log(f"current basecolor {w}x{h} '{cur['base'].name}'")
np.save(os.path.join(SCRATCH, "cur_base.npy"), A)
if ORIG and os.path.exists(ORIG):
bpy.ops.wm.open_mainfile(filepath=ORIG)
og = grab_images()
if "base" in og:
B, w2, h2 = px(og["base"])
log(f"original basecolor {w2}x{h2} '{og['base'].name}'")
if (w2, h2) == (w, h):
d = np.abs(A - B).max(axis=2)
mask = d > 0.02
print(f"\nDISCOLOUR: repainted texels = {int(mask.sum())} "
f"({100.0*mask.sum()/(w*h):.2f}% of atlas)")
def dil(m, k):
g = m.copy()
for _ in range(k):
n = g.copy()
n[1:, :] |= g[:-1, :]
n[:-1, :] |= g[1:, :]
n[:, 1:] |= g[:, :-1]
n[:, :-1] |= g[:, 1:]
g = n
return g
inner = mask & ~dil(~mask, 6) # 6 px in from the patch edge
ring = dil(mask, 10) & ~dil(mask, 2) # untouched skin just outside
if inner.any() and ring.any():
mi = A[inner].mean(axis=0)
mr = A[ring].mean(axis=0)
print(f" patch interior mean RGB {mi[0]:.4f} {mi[1]:.4f} {mi[2]:.4f}")
print(f" outside ring mean RGB {mr[0]:.4f} {mr[1]:.4f} {mr[2]:.4f}")
print(f" OFFSET (patch-ring) {mi[0]-mr[0]:+.4f} {mi[1]-mr[1]:+.4f} "
f"{mi[2]-mr[2]:+.4f} (luma {(mi.mean()-mr.mean()):+.4f})")
print(f" patch interior stddev {A[inner].std(axis=0)}")
print(f" ring stddev {A[ring].std(axis=0)}")
# per-region: split the mask into connected blobs and report the big ones
lab = np.zeros(mask.shape, dtype=np.int32)
cur_l = 0
ys, xs = np.nonzero(mask)
seen = np.zeros(mask.shape, dtype=bool)
from collections import deque
blobs = []
for y0, x0 in zip(ys, xs):
if seen[y0, x0]:
continue
cur_l += 1
q = deque([(y0, x0)])
seen[y0, x0] = True
cells = []
while q:
y, x = q.popleft()
cells.append((y, x))
for dy, dx in ((1, 0), (-1, 0), (0, 1), (0, -1)):
yy, xx = y + dy, x + dx
if 0 <= yy < h and 0 <= xx < w and mask[yy, xx] and not seen[yy, xx]:
seen[yy, xx] = True
q.append((yy, xx))
if len(cells) > 2000:
blobs.append(cells)
print(f" {len(blobs)} patch blobs >2000 texels")
for bi, cells in enumerate(sorted(blobs, key=len, reverse=True)[:8]):
cy = np.array([c[0] for c in cells])
cx = np.array([c[1] for c in cells])
bm_ = np.zeros(mask.shape, dtype=bool)
bm_[cy, cx] = True
bin_ = bm_ & ~dil(~bm_, 5)
br_ = dil(bm_, 10) & ~dil(bm_, 2) & ~mask
if bin_.any() and br_.any():
a_ = A[bin_].mean(axis=0)
r_ = A[br_].mean(axis=0)
print(f" blob{bi}: {len(cells):7d} px uv~({cx.mean()/w:.3f},"
f"{cy.mean()/h:.3f}) offset {a_[0]-r_[0]:+.4f} "
f"{a_[1]-r_[1]:+.4f} {a_[2]-r_[2]:+.4f} luma "
f"{a_.mean()-r_.mean():+.4f}")
np.save(os.path.join(SCRATCH, "patch_mask.npy"), mask)
log(f"saved patch_mask.npy ({int(mask.sum())} texels)")
else:
print(f"DISCOLOUR: size mismatch {w}x{h} vs {w2}x{h2}")
print("DIAG_DONE")
@@ -0,0 +1,45 @@
# Which image datablocks exist, and which ones the body material actually SAMPLES.
# blender --background --python 16b_images.py -- <blend> [<blend2> ...]
import bpy, sys
import numpy as np
for BLEND in sys.argv[sys.argv.index("--") + 1:]:
bpy.ops.wm.open_mainfile(filepath=BLEND)
print(f"\n===== {BLEND} =====")
ob = max([o for o in bpy.data.objects if o.type == 'MESH'],
key=lambda o: len(o.data.vertices))
print(f"body '{ob.name}' {len(ob.data.vertices)}v materials="
f"{[ms.material.name if ms.material else None for ms in ob.material_slots]}")
for i in bpy.data.images:
b = np.empty(i.size[0] * i.size[1] * 4, dtype=np.float32)
try:
i.pixels.foreach_get(b)
m = b.reshape(-1, 4)[:, :3]
stat = f"mean {m.mean(axis=0).round(4)} std {m.std(axis=0).round(4)}"
except Exception as e:
stat = f"(no pixels: {e})"
print(f" IMG '{i.name}' {i.size[0]}x{i.size[1]} packed={bool(i.packed_file)} "
f"file='{i.filepath}' {stat}")
for ms in ob.material_slots:
mat = ms.material
if not mat or not mat.node_tree:
continue
print(f" MAT '{mat.name}':")
bsdf = next((n for n in mat.node_tree.nodes if n.type == 'BSDF_PRINCIPLED'), None)
for n in mat.node_tree.nodes:
if n.type == 'TEX_IMAGE':
tgt = []
for o in n.outputs:
for lk in o.links:
tgt.append(f"{lk.to_node.name}.{lk.to_socket.name}")
print(f" TEX_IMAGE node '{n.name}' image='{n.image.name if n.image else None}'"
f" -> {tgt if tgt else 'UNCONNECTED'}")
if bsdf:
for sock in ("Base Color", "Normal", "Roughness", "Metallic"):
if sock in bsdf.inputs:
lk = bsdf.inputs[sock].links
src = lk[0].from_node.name if lk else "(unlinked)"
if lk and lk[0].from_node.type == 'TEX_IMAGE':
src += f" image='{lk[0].from_node.image.name if lk[0].from_node.image else None}'"
print(f" BSDF.{sock} <- {src}")
print("IMAGES_DONE")
@@ -0,0 +1,214 @@
# Stage 16c (diagnosis only): settle whether the visible lines are TOPOLOGY or GEOMETRY, and
# inventory the real holes. Stage 15 asserted "the two sides of every seam are disconnected
# vertex runs"; stage 17's weld attempt on that premise tore the mesh (830 -> 24k boundary
# edges), which is itself evidence the premise is wrong.
#
# blender --background --python 16c_topology.py -- <blend>
#
# Tests
# 1. LOCAL EDGE LENGTH — the scale everything else must be judged against. A "twin at 0.8 mm"
# means nothing until you know the mesh spacing is ~1.2 mm; at that scale a non-adjacent
# vertex 0.8 mm away is just a 2-ring neighbour, not a crack. This is the control the earlier
# split-panel test lacked.
# 2. TRUE TWIN TEST — nearest vertex that is outside the 3-ring topological neighbourhood,
# normalised by local edge length. A real crack gives a spike at ratio << 1; ordinary mesh
# gives a distribution centred near/above 1.
# 3. HOLE INVENTORY — boundary edges grouped into loops, with size and location, so filling can
# be per-loop instead of one net (which is what produced 39k non-manifold edges).
# 4. RELIEF SCALE-SPACE — for the kink verts, small-scale vs large-scale normal offset. A scan
# seam is thin (small-scale relief, no large-scale relief); real anatomy (underbust fold,
# gluteal fold, clavicle) has both. This is the discriminator a heal mask must use so it
# erases seams without erasing her.
import bpy, bmesh, sys, time
import numpy as np
from mathutils import Vector
from mathutils.kdtree import KDTree
from collections import deque
argv = sys.argv[sys.argv.index("--") + 1:]
BLEND = argv[0]
t0 = time.time()
UNIT_MM = 1815.0 # 1 mesh unit = 1.815 m (body is 0.979 units for 1.777 m)
def log(m):
print(f"[topo {time.time()-t0:6.1f}s] {m}", flush=True)
bpy.ops.wm.open_mainfile(filepath=BLEND)
ob = max([o for o in bpy.data.objects if o.type == 'MESH'],
key=lambda o: len(o.data.vertices))
me = ob.data
n_v = len(me.vertices)
co = np.empty(n_v * 3)
me.vertices.foreach_get("co", co)
co = co.reshape(-1, 3)
nrm = np.empty(n_v * 3)
me.vertices.foreach_get("normal", nrm)
nrm = nrm.reshape(-1, 3)
ev = np.empty(len(me.edges) * 2, dtype=np.int32)
me.edges.foreach_get("vertices", ev)
ev = ev.reshape(-1, 2)
log(f"{n_v}v {len(me.polygons)}f {len(me.edges)}e")
# ---- 1. local edge length ----
elen = np.linalg.norm(co[ev[:, 0]] - co[ev[:, 1]], axis=1)
acc = np.zeros(n_v)
cntv = np.zeros(n_v)
np.add.at(acc, ev[:, 0], elen)
np.add.at(acc, ev[:, 1], elen)
np.add.at(cntv, ev[:, 0], 1.0)
np.add.at(cntv, ev[:, 1], 1.0)
L = acc / np.maximum(cntv, 1)
print(f"EDGE LENGTH mesh units: mean {elen.mean():.6f} ({elen.mean()*UNIT_MM:.2f} real mm) "
f"p05 {np.percentile(elen,5):.6f} p95 {np.percentile(elen,95):.6f}")
order = np.concatenate([ev[:, 0], ev[:, 1]])
nbr = np.concatenate([ev[:, 1], ev[:, 0]])
srt = np.argsort(order, kind="stable")
o_s, n_s = order[srt], nbr[srt]
ptr = np.searchsorted(o_s, np.arange(n_v + 1))
cnt = np.maximum(np.diff(ptr), 1)
def nbr_mean(X):
a = np.add.reduceat(X[n_s], ptr[:-1], axis=0)
a[np.diff(ptr) == 0] = X[np.diff(ptr) == 0]
return a / cnt[:, None]
N = nrm.copy()
for _ in range(5):
N = nbr_mean(N)
N /= np.maximum(np.linalg.norm(N, axis=1, keepdims=True), 1e-12)
ang = np.degrees(np.arccos(np.clip((nrm * N).sum(axis=1), -1, 1)))
torso = (co[:, 2] > 0.28) & (co[:, 2] < 0.90)
kink = torso & (ang > 12.0)
log(f"kink>12deg in torso: {kink.sum()}")
# ---- 2. true twin test (outside the 3-ring) ----
kidx = np.nonzero(kink)[0]
sample = kidx[::max(1, len(kidx) // 3000)]
ctrl = np.nonzero(torso & (ang < 3.0))[0]
ctrl = ctrl[::max(1, len(ctrl) // 3000)] # control: quiet skin, same test
kd = KDTree(n_v)
for i in range(n_v):
kd.insert(Vector(co[i]), i)
kd.balance()
adj = {}
for a, b in ev:
adj.setdefault(int(a), set()).add(int(b))
adj.setdefault(int(b), set()).add(int(a))
def ring3(i):
seen = {i}
frontier = {i}
for _ in range(3):
nxt = set()
for v in frontier:
nxt |= adj.get(v, set())
nxt -= seen
seen |= nxt
frontier = nxt
return seen
def twin_ratio(idxs):
out = []
for i in idxs:
excl = ring3(int(i))
best = None
for (_, j, d) in kd.find_range(Vector(co[i]), L[i] * 2.0):
if int(j) in excl:
continue
if float(nrm[i] @ nrm[j]) < 0.0:
continue # opposite-facing surface (thigh vs thigh) is not a seam twin
if best is None or d < best:
best = d
out.append(best / L[i] if best is not None else np.nan)
return np.array(out)
tr_k = twin_ratio(sample)
tr_c = twin_ratio(ctrl)
log("twin test done")
for nm, tr in (("KINK verts", tr_k), ("CONTROL quiet skin", tr_c)):
v = tr[~np.isnan(tr)]
print(f"TWIN-RATIO {nm}: n={len(v)}/{len(tr)} "
f"p05={np.percentile(v,5):.2f} p25={np.percentile(v,25):.2f} "
f"median={np.median(v):.2f} p75={np.percentile(v,75):.2f}"
if len(v) else f"TWIN-RATIO {nm}: no hits")
if len(v):
print(f" fraction with a non-3-ring vertex closer than 0.35x edge length: "
f"{100.0*(v<0.35).mean():.1f}% (<0.6x: {100.0*(v<0.6).mean():.1f}%)")
# ---- 3. hole inventory ----
bm = bmesh.new()
bm.from_mesh(me)
open_e = [e for e in bm.edges if len(e.link_faces) == 1]
print(f"\nHOLES: {len(open_e)} boundary edges, "
f"{len([e for e in bm.edges if len(e.link_faces) > 2])} non-manifold edges")
eset = set(e.index for e in open_e)
emap = {e.index: e for e in open_e}
seen = set()
loops = []
for e in open_e:
if e.index in seen:
continue
q = deque([e.index])
seen.add(e.index)
comp = []
while q:
ei = q.popleft()
cur = emap[ei]
comp.append(cur)
for v in cur.verts:
for e2 in v.link_edges:
if e2.index in eset and e2.index not in seen:
seen.add(e2.index)
q.append(e2.index)
loops.append(comp)
loops.sort(key=len, reverse=True)
print(f"HOLES: {len(loops)} separate boundary loops")
for i, lp in enumerate(loops[:14]):
zs = [v.co.z for e in lp for v in e.verts]
xs = [v.co.x for e in lp for v in e.verts]
ys = [v.co.y for e in lp for v in e.verts]
print(f" loop{i:2d}: {len(lp):5d} edges z {min(zs):.3f}-{max(zs):.3f} "
f"x {min(xs):+.3f}..{max(xs):+.3f} y {min(ys):+.3f}..{max(ys):+.3f}")
small = [lp for lp in loops if len(lp) <= 60]
print(f"HOLES: {len(small)} loops <=60 edges (safe per-loop fills), "
f"{len(loops)-len(small)} larger")
bm.free()
# ---- 4. relief scale-space ----
def smooth_n(X, k):
Y = X.copy()
for _ in range(k):
Y = nbr_mean(Y)
return Y
sm_s = smooth_n(co, 8)
sm_l = smooth_n(co, 60)
dev_s = ((co - sm_s) * N).sum(axis=1)
dev_l = ((co - sm_l) * N).sum(axis=1)
print("\nRELIEF SCALE-SPACE (real mm along the smooth normal)")
for nm, m in (("kink>12deg", kink),
("quiet skin", torso & (ang < 3.0))):
if not m.sum():
continue
print(f" {nm}: |small-scale| median {np.median(np.abs(dev_s[m]))*UNIT_MM:.3f} mm, "
f"p95 {np.percentile(np.abs(dev_s[m]),95)*UNIT_MM:.3f} mm | "
f"|large-scale| median {np.median(np.abs(dev_l[m]))*UNIT_MM:.3f} mm, "
f"p95 {np.percentile(np.abs(dev_l[m]),95)*UNIT_MM:.3f} mm")
band = np.abs(dev_s) * UNIT_MM
bandpass = torso & (band > 0.35) & (np.abs(dev_l) * UNIT_MM < 1.6)
print(f" BAND-PASS candidate mask (thin relief >0.35 mm, broad relief <1.6 mm): "
f"{int(bandpass.sum())} verts")
hist, edges = np.histogram(co[bandpass, 2], bins=14)
for c, lo, hi in zip(hist, edges[:-1], edges[1:]):
if c > 50:
print(f" z {lo:.3f}-{hi:.3f}: {c:6d}")
print("TOPO_DONE")
@@ -0,0 +1,291 @@
# Stage 17: heal the cut/scan lines — pinhole fill + thin-relief membrane. NO topology weld.
#
# blender --background --python 17_line_heal.py -- <in.blend> <out.blend> [rounds]
#
# WHAT THE MEASUREMENTS ACTUALLY SAY (16c_topology.py, and they contradict stages 15 + 17-weld)
# * The lines are NOT cracks. For kink verts the nearest vertex outside the 3-ring sits at
# 1.58x the local edge length (median); only 3.2% are closer than 0.35x, against 0.1% for
# control skin. There is no disconnected-panel network to weld. Welding on that false premise
# (eps 1.2 mm ~= the 1.85 mm mean edge length) merged ordinary neighbours and tore the mesh:
# 830 -> 24k boundary edges. Do not try it again.
# * The mesh is nearly closed: 830 boundary edges in 689 loops of 3-5 edges each — scattered
# PINHOLES, which is what the dotted black dashes in clay renders are. There is no sternum
# hole; that gash is a sharp crease (see 18_cleavage.py), not an opening.
# * The lines are thin GEOMETRIC relief: along kink verts |offset from the locally smooth
# surface| is 0.22 mm median / 1.55 mm p95, versus 0.05 mm on quiet skin.
#
# So: fill the pinholes per-loop (never as one edge net — one net is what produced 39k
# non-manifold edges), then remove the thin relief with a collar-fixed bi-harmonic membrane.
#
# WHY A MEMBRANE AND NOT SMOOTHING. Taubin (stage 11) sheds high frequencies but PRESERVES low
# ones, and a 1.5 mm ridge riding on a curved torso is not purely high-frequency — that is why
# 30 Taubin pairs left the lines legible. A bi-harmonic membrane with two fixed collar rings
# matches position AND slope at the band edge, so the broad shape (underbust fold, hip curve,
# clavicle) is reproduced exactly while the thin ridge inside is replaced by the interpolant.
#
# WHY THE MASK IS SELF-DETECTED. Stages 11/14/15 all chose their masks by mapping the raw glb's
# boundary verts onto the working mesh; by then the sculpt had moved those verts past the
# tolerance, so most of the line network was never in the mask — the membranes were solving over
# the wrong verts, which is the real reason "the lines survived every pass". Detection here runs
# on the CURRENT geometry: very-small-scale relief (4-ring) AND a shading kink. Broad anatomy has
# low relief at that scale by construction, so it is excluded automatically rather than by hand.
import bpy, bmesh, sys, time
import numpy as np
argv = sys.argv[sys.argv.index("--") + 1:]
BLEND, OUT = argv[0], argv[1]
ROUNDS = int(argv[2]) if len(argv) > 2 else 3
t0 = time.time()
UNIT_MM = 1815.0 # 1 mesh unit = 1.815 m
DEV_MM = 0.20 # thin-relief threshold, real mm
KINK_DEG = 8.0
GROW = 2 # band half-width, rings
COLLAR = 2 # fixed rings outside the band (position + slope BC)
Z_LO, Z_HI = 0.04, 0.90 # below the chin: the face keeps its own detail
X_MAX = 0.36 # exclude hands/wrists
PIN_MAX_EDGES = 8 # a "pinhole" — bigger openings are left for a human to look at
CLAMP_MM = 2.5 # cap per-vertex displacement, real mm
# WIDTH IS SETTLED — DO NOT WIDEN THE BAND. 19_wide_heal.py measured the seam cross-section: the
# disturbance is one vertex wide (|offset| 0.23 mm median at the centre, already at the 0.074 mm
# background level by ring 2). Trials at GROW=4 and GROW=8 were no better than this narrow band
# (kink>12deg 6674 and 6751 vs 6994) while moving material up to 38 mm. Narrow is correct.
#
# CLAMP_MM exists because the solver is free to do something dramatic wherever a band happens to
# span a real feature rather than a seam — the unclamped run made a 17 mm excursion. Seam relief
# tops out around 1.5 mm, so 2.5 mm is generous for the defect and still forbids reshaping her.
def log(m):
print(f"[line {time.time()-t0:6.1f}s] {m}", flush=True)
bpy.ops.wm.open_mainfile(filepath=BLEND)
ob = max([o for o in bpy.data.objects if o.type == 'MESH'],
key=lambda o: len(o.data.vertices))
me = ob.data
log(f"in: {len(me.vertices)}v {len(me.polygons)}f")
# =============================================================================
# 1. pinhole fill — per loop
# =============================================================================
bm = bmesh.new()
bm.from_mesh(me)
open_e = [e for e in bm.edges if len(e.link_faces) == 1]
eset = {e.index for e in open_e}
emap = {e.index: e for e in open_e}
from collections import deque
seen = set()
loops = []
for e in open_e:
if e.index in seen:
continue
q = deque([e.index])
seen.add(e.index)
comp = []
while q:
cur = emap[q.popleft()]
comp.append(cur)
for v in cur.verts:
for e2 in v.link_edges:
if e2.index in eset and e2.index not in seen:
seen.add(e2.index)
q.append(e2.index)
loops.append(comp)
sizes = sorted((len(lp) for lp in loops), reverse=True)
log(f"boundary loops: {len(loops)} (edges: {len(open_e)}, largest {sizes[:6]})")
bm.free()
# bmesh.ops.holes_fill silently refused every one of these loops (830 -> 839 boundary edges, no
# new faces), so use the edit-mode operator, which handles the small non-manifold fans these
# pinholes actually are.
bpy.context.view_layer.objects.active = ob
ob.select_set(True)
bpy.ops.object.mode_set(mode='EDIT')
bpy.ops.mesh.select_all(action='DESELECT')
bpy.ops.mesh.select_mode(type='EDGE')
bpy.ops.mesh.select_non_manifold(extend=False, use_boundary=True, use_wire=True,
use_multi_face=False, use_non_contiguous=False, use_verts=False)
bpy.ops.mesh.fill_holes(sides=PIN_MAX_EDGES)
bpy.ops.object.mode_set(mode='OBJECT')
me.update()
bm = bmesh.new()
bm.from_mesh(me)
n_open_after = len([e for e in bm.edges if len(e.link_faces) == 1])
n_nm_after = len([e for e in bm.edges if len(e.link_faces) > 2])
dgn = [f for f in bm.faces if f.calc_area() < 1e-12]
if dgn:
bmesh.ops.delete(bm, geom=dgn, context='FACES')
bm.to_mesh(me)
me.update()
log(f"deleted {len(dgn)} degenerate faces")
bm.free()
log(f"after fill: {len(me.vertices)}v {len(me.polygons)}f "
f"boundary_edges={n_open_after} nonmanifold={n_nm_after}")
# =============================================================================
# 2. thin-relief membrane, re-detected each round
# =============================================================================
n_v = len(me.vertices)
co = np.empty(n_v * 3)
me.vertices.foreach_get("co", co)
co = co.reshape(-1, 3)
ev = np.empty(len(me.edges) * 2, dtype=np.int32)
me.edges.foreach_get("vertices", ev)
ev = ev.reshape(-1, 2)
order = np.concatenate([ev[:, 0], ev[:, 1]])
nbr = np.concatenate([ev[:, 1], ev[:, 0]])
srt = np.argsort(order, kind="stable")
o_s, n_s = order[srt], nbr[srt]
ptr = np.searchsorted(o_s, np.arange(n_v + 1))
cnt = np.maximum(np.diff(ptr), 1)
empty = np.diff(ptr) == 0
def nbr_mean(X):
a = np.add.reduceat(X[n_s], ptr[:-1], axis=0)
a[empty] = X[empty]
return a / cnt[:, None]
def smooth_n(X, k):
Y = X.copy()
for _ in range(k):
Y = nbr_mean(Y)
return Y
def grow(mask, rings):
m = mask.copy()
for _ in range(rings):
hit = m[ev[:, 0]] | m[ev[:, 1]]
m2 = m.copy()
m2[ev[:, 0]] |= hit
m2[ev[:, 1]] |= hit
m = m2
return m
zone = (co[:, 2] > Z_LO) & (co[:, 2] < Z_HI) & (np.abs(co[:, 0]) < X_MAX)
navel = (np.abs(co[:, 0]) < 0.022) & (co[:, 2] > 0.495) & (co[:, 2] < 0.555) & (co[:, 1] < 0)
protect = navel
log(f"zone {int(zone.sum())} verts, protected {int(protect.sum())}")
def detect(P):
"""Thin relief + shading kink, both measured on P."""
nrm = np.empty(n_v * 3)
me.vertices.foreach_get("normal", nrm)
nrm = nrm.reshape(-1, 3)
N = nrm.copy()
for _ in range(5):
N = nbr_mean(N)
N /= np.maximum(np.linalg.norm(N, axis=1, keepdims=True), 1e-12)
ang = np.degrees(np.arccos(np.clip((nrm * N).sum(axis=1), -1, 1)))
dev = ((P - smooth_n(P, 4)) * N).sum(axis=1) * UNIT_MM # real mm, very local
m = zone & ~protect & (np.abs(dev) > DEV_MM) & (ang > KINK_DEG)
return m, ang, dev
def bilaplacian_solve(P, free_m):
"""Collar-fixed bi-harmonic membrane over free_m, solved with CG on the S-subgraph."""
collar = grow(free_m, COLLAR) & ~free_m
S = np.nonzero(free_m | collar)[0]
in_S = np.zeros(n_v, dtype=bool)
in_S[S] = True
glb = np.full(n_v, -1, dtype=np.int64)
glb[S] = np.arange(len(S))
se = ev[in_S[ev].all(axis=1)]
a_ = glb[se[:, 0]]
b_ = glb[se[:, 1]]
deg = np.zeros(len(S))
np.add.at(deg, a_, 1.0)
np.add.at(deg, b_, 1.0)
free = free_m[S]
def Ls(X):
out = deg[:, None] * X
np.add.at(out, a_, -X[b_])
np.add.at(out, b_, -X[a_])
return out
def A_op(U):
X = np.zeros((len(S), 3))
X[free] = U
return Ls(Ls(X))[free]
Xc = np.zeros((len(S), 3))
Xc[~free] = P[S[~free]]
rhs = -Ls(Ls(Xc))[free]
U = P[S[free]].copy()
r = rhs - A_op(U)
p = r.copy()
rs = (r * r).sum()
rs0 = max(rs, 1e-30)
it = 0
for it in range(200000):
Ap = A_op(p)
den = (p * Ap).sum()
if abs(den) < 1e-30:
break
al = rs / den
U += al * p
r -= al * Ap
rs2 = (r * r).sum()
if rs2 < 1e-20 or rs2 < rs0 * 1e-14:
rs = rs2
break
p = r + (rs2 / rs) * p
rs = rs2
Q = P.copy()
Q[S[free]] = U
return Q, len(S), int(free.sum()), it, rs / rs0
P = co.copy()
for rnd in range(1, ROUNDS + 1):
mask, ang, dev = detect(P)
if not mask.sum():
log(f"round {rnd}: nothing left to heal")
break
band = grow(mask, GROW) & zone & ~protect
Q, ns, nf, it, rel = bilaplacian_solve(P, band)
# clamp: a seam is <=1.5 mm of relief, so anything larger is the solver reshaping anatomy
disp = Q - P
dmag = np.linalg.norm(disp, axis=1) * UNIT_MM
over = dmag > CLAMP_MM
if over.any():
disp[over] *= (CLAMP_MM / dmag[over])[:, None]
Q = P + disp
log(f"round {rnd}: clamped {int(over.sum())} verts to {CLAMP_MM} mm "
f"(largest pre-clamp {dmag.max():.1f} mm)")
d = np.linalg.norm(Q - P, axis=1) * UNIT_MM
log(f"round {rnd}: seed {int(mask.sum())} -> band {nf} (S={ns}) CG it={it} rel={rel:.1e} "
f"moved max {d.max():.3f} mm, median(band) {np.median(d[band]):.3f} mm")
P = Q
me.vertices.foreach_set("co", P.reshape(-1))
me.update()
if me.has_custom_normals:
vn = np.empty(n_v * 3, dtype=np.float32)
me.vertices.foreach_get("normal", vn)
me.normals_split_custom_set_from_vertices(vn.reshape(-1, 3))
m2, ang2, dev2 = detect(P)
torso = (co[:, 2] > 0.28) & (co[:, 2] < 0.90)
log(f" after: seed mask {int(m2.sum())}, kink>12deg {int((torso & (ang2 > 12)).sum())}, "
f"kink>20deg {int((torso & (ang2 > 20)).sum())}")
# final normals + save
me.vertices.foreach_set("co", P.reshape(-1))
me.update()
vn = np.empty(n_v * 3, dtype=np.float32)
me.vertices.foreach_get("normal", vn)
me.normals_split_custom_set_from_vertices(vn.reshape(-1, 3))
d_tot = np.linalg.norm(P - co, axis=1) * UNIT_MM
log(f"TOTAL displacement: max {d_tot.max():.3f} mm, "
f"{int((d_tot > 0.05).sum())} verts moved >0.05 mm")
bpy.context.preferences.filepaths.save_version = 0 # no .blend1 autosave
bpy.ops.wm.save_as_mainfile(filepath=OUT)
log(f"WROTE {OUT}")
print("LINE_DONE")
@@ -0,0 +1,272 @@
# Stage 17: TRUE panel weld + hole closure, self-detected on the CURRENT mesh.
#
# blender --background --python 17_panel_weld.py -- <in.blend> <out.blend> [eps_mm_units]
#
# WHY THIS EXISTS (stage 15 already tried to weld and the lines survived)
# Stage 15 chose its merge set by mapping the RAW glb's boundary verts onto the working mesh at
# 2.5 mm. By then the sculpt + five membrane passes had moved those verts further than the
# tolerance, so most of the panel network was never selected. Measured after stage 15
# (16_diagnose): 58.7% of shading-kink verts STILL have a non-adjacent twin within 0.8 mm, i.e.
# the two sides of each seam are separate vertex runs that shade independently. A crack survives
# any amount of vertex MOVEMENT, which is why 11-14's membranes could not remove it.
#
# So this stage never consults the raw mesh. It finds the defect where it actually is:
# candidates = shading-kink verts boundary verts, grown 2 rings
# a PAIR is two candidates within eps that are NOT edge-adjacent and whose normals agree
# (dot > 0.5). The normal test is what makes this safe: two sides of one seam face the same
# way, whereas two surfaces that merely come close (inner thighs, armpit) face opposite ways
# and are never paired.
# pairs -> union-find -> bmesh.ops.weld_verts with an explicit targetmap.
# weld_verts (not remove_doubles) because the targetmap is exact: only vertices this script has
# validated get merged, so no collateral merge is possible inside the eps ball.
#
# Loops keep their own UVs through a weld, so the baked atlas is unaffected — a welded vertex
# simply carries two UV corners, which is what every UV seam already is.
#
# Then holes: the body should be watertight below the chin. The largest boundary cluster is the
# bra-bow excision at the sternum (z 0.648-0.729, 329 edges) — that hole is the black gash that
# reads as a broken cleavage. Filled with bmesh triangle_fill and smoothed by the next stage.
# Head openings (z > HEAD_Z: mouth, eyes, nostrils) are left alone: they are supposed to be open.
import bpy, bmesh, sys, time, math
import numpy as np
from mathutils import Vector
from mathutils.kdtree import KDTree
argv = sys.argv[sys.argv.index("--") + 1:]
BLEND, OUT = argv[0], argv[1]
EPS = float(argv[2]) if len(argv) > 2 else 0.0012 # mesh units (~2.2 mm real: 1 unit=1.815 m)
t0 = time.time()
KINK_DEG = 4.0 # generous: anything that could read as a line
DEV_MIN = 0.00008 # or a small-scale bump/groove this deep (mesh units)
NORM_DOT = 0.5 # same-facing test that makes the weld safe
GROW = 2
Z_LO, Z_HI = 0.04, 0.90 # below the chin, above the soles
X_MAX = 0.36 # excludes hands/wrists so fingers can never weld together
HEAD_Z = 0.90 # holes above this are real openings (mouth/eyes/nostrils)
def log(m):
print(f"[weld {time.time()-t0:6.1f}s] {m}", flush=True)
bpy.ops.wm.open_mainfile(filepath=BLEND)
ob = max([o for o in bpy.data.objects if o.type == 'MESH'],
key=lambda o: len(o.data.vertices))
me = ob.data
n_v = len(me.vertices)
log(f"in: {n_v}v {len(me.polygons)}f custom_normals={me.has_custom_normals}")
co = np.empty(n_v * 3)
me.vertices.foreach_get("co", co)
co = co.reshape(-1, 3)
nrm = np.empty(n_v * 3)
me.vertices.foreach_get("normal", nrm)
nrm = nrm.reshape(-1, 3)
ev = np.empty(len(me.edges) * 2, dtype=np.int32)
me.edges.foreach_get("vertices", ev)
ev = ev.reshape(-1, 2)
order = np.concatenate([ev[:, 0], ev[:, 1]])
nbr = np.concatenate([ev[:, 1], ev[:, 0]])
srt = np.argsort(order, kind="stable")
o_s, n_s = order[srt], nbr[srt]
ptr = np.searchsorted(o_s, np.arange(n_v + 1))
cnt = np.maximum(np.diff(ptr), 1)
def nbr_mean(X):
acc = np.add.reduceat(X[n_s], ptr[:-1], axis=0)
acc[np.diff(ptr) == 0] = X[np.diff(ptr) == 0]
return acc / cnt[:, None]
# ---- candidates: shading kinks + small-scale relief + existing boundary ----
N = nrm.copy()
for _ in range(5):
N = nbr_mean(N)
N /= np.maximum(np.linalg.norm(N, axis=1, keepdims=True), 1e-12)
ang = np.degrees(np.arccos(np.clip((nrm * N).sum(axis=1), -1, 1)))
sm = co.copy()
for _ in range(12):
sm = nbr_mean(sm)
dev = np.abs(((co - sm) * N).sum(axis=1))
bm = bmesh.new()
bm.from_mesh(me)
bm.verts.ensure_lookup_table()
bnd_v = np.zeros(n_v, dtype=bool)
for e in bm.edges:
if len(e.link_faces) == 1:
bnd_v[e.verts[0].index] = True
bnd_v[e.verts[1].index] = True
n_bnd0 = len([e for e in bm.edges if len(e.link_faces) == 1])
n_nm0 = len([e for e in bm.edges if len(e.link_faces) > 2])
log(f"before: boundary_edges={n_bnd0} nonmanifold_edges={n_nm0} boundary_verts={bnd_v.sum()}")
zone = (co[:, 2] > Z_LO) & (co[:, 2] < Z_HI) & (np.abs(co[:, 0]) < X_MAX)
cand = zone & ((ang > KINK_DEG) | (dev > DEV_MIN) | bnd_v)
for _ in range(GROW):
hit = cand[ev[:, 0]] | cand[ev[:, 1]]
c2 = cand.copy()
c2[ev[:, 0]] |= hit
c2[ev[:, 1]] |= hit
cand = c2 & zone
cidx = np.nonzero(cand)[0]
log(f"candidates: {len(cidx)} verts (kink>{KINK_DEG}deg {(zone&(ang>KINK_DEG)).sum()}, "
f"dev {(zone&(dev>DEV_MIN)).sum()}, boundary {(zone&bnd_v).sum()}, +{GROW} rings)")
# adjacency lookup restricted to candidates
adj = {}
sel_mask = cand
mE = sel_mask[ev[:, 0]] & sel_mask[ev[:, 1]]
for a, b in ev[mE]:
adj.setdefault(int(a), set()).add(int(b))
adj.setdefault(int(b), set()).add(int(a))
kd = KDTree(len(cidx))
for j, i in enumerate(cidx):
kd.insert(Vector(co[i]), j)
kd.balance()
log("KD built over candidates")
# ---- pair up twins ----
pairs = []
dists = []
for j, i in enumerate(cidx):
ai = adj.get(int(i), ())
for (_, k, d) in kd.find_range(Vector(co[i]), EPS):
o = int(cidx[k])
if o <= int(i) or o in ai:
continue
if float(nrm[i] @ nrm[o]) < NORM_DOT:
continue
pairs.append((int(i), o))
dists.append(d)
log(f"pairs: {len(pairs)}")
if dists:
dh = np.array(dists)
print("PAIR-DISTANCE histogram (mesh units, 1 unit = 1.815 m):")
hist, edges = np.histogram(dh, bins=np.linspace(0, EPS, 9))
for c, lo, hi in zip(hist, edges[:-1], edges[1:]):
print(f" {lo*1000:5.3f}-{hi*1000:5.3f} mm-units: {c:7d} "
f"({lo*1815:5.2f}-{hi*1815:5.2f} real mm)")
# ---- union-find ----
parent = {}
def find(x):
parent.setdefault(x, x)
while parent[x] != x:
parent[x] = parent[parent[x]]
x = parent[x]
return x
def union(a, b):
ra, rb = find(a), find(b)
if ra != rb:
parent[min(ra, rb)] = min(ra, rb)
parent[max(ra, rb)] = min(ra, rb)
for a, b in pairs:
union(a, b)
clusters = {}
for v in list(parent):
clusters.setdefault(find(v), []).append(v)
sizes = np.array([len(c) for c in clusters.values()])
log(f"clusters: {len(clusters)} covering {int(sizes.sum())} verts "
f"(max {sizes.max() if len(sizes) else 0}, mean {sizes.mean() if len(sizes) else 0:.2f})")
# guard: a huge cluster would mean the eps ball is chaining across a whole region
if len(sizes) and sizes.max() > 40:
log(f"WARNING: largest cluster {sizes.max()} verts — chaining suspected; "
f"clusters >40 verts are SKIPPED")
targetmap = {}
merged_verts = 0
for root, members in clusters.items():
if len(members) < 2 or len(members) > 40:
continue
members = sorted(members)
keep = members[0]
ctr = co[members].mean(axis=0)
bm.verts[keep].co = Vector(ctr)
for m in members[1:]:
targetmap[bm.verts[m]] = bm.verts[keep]
merged_verts += 1
log(f"targetmap: merging {merged_verts} verts into {len(set(targetmap.values()))} survivors")
if targetmap:
bmesh.ops.weld_verts(bm, targetmap=targetmap)
log("weld_verts done")
# ---- close holes below the chin ----
bm.edges.ensure_lookup_table()
open_e = [e for e in bm.edges if len(e.link_faces) == 1]
body_e = [e for e in open_e
if max(v.co.z for v in e.verts) < HEAD_Z and min(v.co.z for v in e.verts) > Z_LO * 0.5]
log(f"open edges after weld: {len(open_e)} total, {len(body_e)} below the chin -> filling")
if body_e:
res = bmesh.ops.triangle_fill(bm, use_beauty=True, use_dissolve=False, edges=body_e)
log(f"triangle_fill created {len(res.get('geom', []))} elements")
# anything still open: try holes_fill as a second pass
bm.edges.ensure_lookup_table()
still = [e for e in bm.edges if len(e.link_faces) == 1
and max(v.co.z for v in e.verts) < HEAD_Z]
if still:
bmesh.ops.holes_fill(bm, edges=still, sides=0)
log(f"holes_fill on {len(still)} remaining open edges")
bm.edges.ensure_lookup_table()
n_bnd1 = len([e for e in bm.edges if len(e.link_faces) == 1])
n_nm1 = len([e for e in bm.edges if len(e.link_faces) > 2])
dgn = [f for f in bm.faces if f.calc_area() < 1e-12]
if dgn:
bmesh.ops.delete(bm, geom=dgn, context='FACES')
log(f"deleted {len(dgn)} degenerate faces")
bm.to_mesh(me)
bm.free()
me.update()
n_after = len(me.vertices)
log(f"after: {n_after}v (-{n_v - n_after}) boundary_edges={n_bnd1} nonmanifold_edges={n_nm1}")
# ---- shared vertex normals across the now-shared seams ----
vn = np.empty(n_after * 3, dtype=np.float32)
me.vertices.foreach_get("normal", vn)
me.normals_split_custom_set_from_vertices(vn.reshape(-1, 3))
log("custom split normals reset from vertex normals")
# ---- re-measure the lines ----
co2 = np.empty(n_after * 3)
me.vertices.foreach_get("co", co2)
co2 = co2.reshape(-1, 3)
nr2 = np.empty(n_after * 3)
me.vertices.foreach_get("normal", nr2)
nr2 = nr2.reshape(-1, 3)
ev2 = np.empty(len(me.edges) * 2, dtype=np.int32)
me.edges.foreach_get("vertices", ev2)
ev2 = ev2.reshape(-1, 2)
o2 = np.concatenate([ev2[:, 0], ev2[:, 1]])
n2 = np.concatenate([ev2[:, 1], ev2[:, 0]])
s2 = np.argsort(o2, kind="stable")
o2s, n2s = o2[s2], n2[s2]
p2 = np.searchsorted(o2s, np.arange(n_after + 1))
c2 = np.maximum(np.diff(p2), 1)
N2 = nr2.copy()
for _ in range(5):
acc = np.add.reduceat(N2[n2s], p2[:-1], axis=0)
acc[np.diff(p2) == 0] = N2[np.diff(p2) == 0]
N2 = acc / c2[:, None]
N2 /= np.maximum(np.linalg.norm(N2, axis=1, keepdims=True), 1e-12)
ang2 = np.degrees(np.arccos(np.clip((nr2 * N2).sum(axis=1), -1, 1)))
torso2 = (co2[:, 2] > 0.28) & (co2[:, 2] < 0.90)
for thr in (8.0, 12.0, 20.0):
print(f"KINK >{thr:4.1f}deg after weld: {int((torso2 & (ang2 > thr)).sum()):6d} verts")
bpy.context.preferences.filepaths.save_version = 0 # no .blend1 autosave
bpy.ops.wm.save_as_mainfile(filepath=OUT)
log(f"WROTE {OUT}")
print("WELD_DONE")
@@ -0,0 +1,152 @@
# Stage 18: is the line network SHADING (custom split normals) rather than shape?
#
# blender --background --python 18_normal_probe.py -- <in.blend> <out.blend> <review_dir>
#
# THE SUSPICION. Every stage from 11 to 17 moved vertices along the seam network and the lines
# did not change; 17's membrane moved 12.6k verts for no visible difference, and 16c proved there
# is no crack to weld. What survives vertex movement is SHADING: this mesh carries custom split
# normals from the Tripo import (has_custom_normals=True). If those baked normals encode the scan
# panel borders as creases, the lines are painted into the normals and geometry work cannot touch
# them — which would explain the whole failure streak at once, including why the lines appear in
# clay renders (clay swaps the MATERIAL, so no normal map is involved, but custom split normals
# belong to the MESH and survive the swap).
#
# Note every prior stage ended with normals_split_custom_set_from_vertices(...), which SHOULD have
# smoothed them. This probe measures whether that actually took effect, then clears the custom
# normals outright and re-renders. Measurement first, then the change, so we learn which it was.
import bpy, sys, os, math, time
import numpy as np
from mathutils import Vector
argv = sys.argv[sys.argv.index("--") + 1:]
BLEND, OUT, REVIEW = argv[0], argv[1], argv[2]
os.makedirs(REVIEW, exist_ok=True)
t0 = time.time()
def log(m):
print(f"[nrm {time.time()-t0:6.1f}s] {m}", flush=True)
bpy.ops.wm.open_mainfile(filepath=BLEND)
ob = max([o for o in bpy.data.objects if o.type == 'MESH'],
key=lambda o: len(o.data.vertices))
me = ob.data
n_v = len(me.vertices)
n_l = len(me.loops)
log(f"{ob.name}: {n_v}v {len(me.polygons)}f {n_l} loops "
f"has_custom_normals={me.has_custom_normals}")
flat = np.empty(len(me.polygons), dtype=bool)
me.polygons.foreach_get("use_smooth", flat)
log(f"flat-shaded polygons: {int((~flat).sum())} of {len(flat)}")
# ---- how far do the corner normals deviate from the smooth vertex normals? ----
vn = np.empty(n_v * 3)
me.vertices.foreach_get("normal", vn)
vn = vn.reshape(-1, 3)
lv = np.empty(n_l, dtype=np.int32)
me.loops.foreach_get("vertex_index", lv)
cn = np.empty(n_l * 3)
try:
me.corner_normals.foreach_get("vector", cn)
cn = cn.reshape(-1, 3)
dot = np.clip((cn * vn[lv]).sum(axis=1), -1, 1)
dev = np.degrees(np.arccos(dot))
print(f"CORNER-vs-VERTEX normal deviation: mean {dev.mean():.3f}deg "
f"p50 {np.percentile(dev,50):.3f} p95 {np.percentile(dev,95):.3f} "
f"p99.9 {np.percentile(dev,99.9):.3f} max {dev.max():.3f}")
print(f" loops deviating >5deg: {int((dev>5).sum())} ({100.0*(dev>5).mean():.3f}%)")
print(f" loops deviating >15deg: {int((dev>15).sum())} ({100.0*(dev>15).mean():.3f}%)")
# per-vertex spread between its own corner normals = a shading crease at that vertex
spread = np.zeros(n_v)
np.maximum.at(spread, lv, dev)
torso = None
co = np.empty(n_v * 3)
me.vertices.foreach_get("co", co)
co = co.reshape(-1, 3)
torso = (co[:, 2] > 0.28) & (co[:, 2] < 0.90)
print(f" torso verts whose corner normals deviate >10deg from smooth: "
f"{int((torso & (spread > 10)).sum())}")
except Exception as ex:
log(f"corner_normals read failed: {ex}")
# =============================================================================
# clear the custom split normals and force smooth shading
# =============================================================================
had = me.has_custom_normals
bpy.context.view_layer.objects.active = ob
ob.select_set(True)
cleared = False
try:
bpy.ops.mesh.customdata_custom_splitnormals_clear()
cleared = True
except Exception as ex:
log(f"operator clear failed: {ex}")
if not cleared:
for nm in ("custom_normal",):
if nm in me.attributes:
me.attributes.remove(me.attributes[nm])
cleared = True
log(f"removed attribute '{nm}'")
sm = np.ones(len(me.polygons), dtype=bool)
me.polygons.foreach_set("use_smooth", sm)
me.update()
log(f"custom normals: had={had} now has_custom_normals={me.has_custom_normals} cleared={cleared}")
# =============================================================================
# clay renders, same framing as 04_review so they compare 1:1
# =============================================================================
scn = bpy.context.scene
w = bpy.data.worlds.new("W")
w.color = (0.22, 0.22, 0.24)
scn.world = w
key = bpy.data.objects.new("Key", bpy.data.lights.new("Key", 'SUN'))
key.data.energy = 3.0
key.data.use_shadow = False
bpy.context.collection.objects.link(key)
fill = bpy.data.objects.new("Fill", bpy.data.lights.new("Fill", 'SUN'))
fill.data.energy = 1.0
fill.data.use_shadow = False
bpy.context.collection.objects.link(fill)
cam = bpy.data.objects.new("Cam", bpy.data.cameras.new("Cam"))
cam.data.lens = 85
bpy.context.collection.objects.link(cam)
scn.camera = cam
scn.render.engine = 'BLENDER_EEVEE' if bpy.app.version >= (4, 2) else 'BLENDER_EEVEE_NEXT'
scn.render.resolution_x = scn.render.resolution_y = 1000
clay = bpy.data.materials.new("Clay")
clay.use_nodes = True
clay.node_tree.nodes["Principled BSDF"].inputs["Base Color"].default_value = (0.62, 0.60, 0.58, 1)
clay.node_tree.nodes["Principled BSDF"].inputs["Roughness"].default_value = 0.45
orig = [ms.material for ms in ob.material_slots]
def shoot(tag, ctr, span, yaw_deg, use_clay):
for i, ms in enumerate(ob.material_slots):
ms.material = clay if use_clay else orig[i]
yaw = math.radians(yaw_deg)
dist = span * 3.0
cam.location = Vector(ctr) + Vector((math.sin(yaw) * dist, -math.cos(yaw) * dist, 0.02))
cam.rotation_euler = (Vector(ctr) - cam.location).to_track_quat('-Z', 'Y').to_euler()
key.rotation_euler = (math.radians(62), 0, math.radians(35 + yaw_deg))
fill.rotation_euler = (math.radians(75), 0, math.radians(yaw_deg - 110))
scn.render.filepath = os.path.abspath(os.path.join(REVIEW, f"{tag}.png"))
bpy.ops.render.render(write_still=True)
log(f"render {tag}")
CHEST = (0.0, 0.0, 0.675)
FULL = (0.0, 0.0, 0.50)
HIP = (0.0, 0.0, 0.53)
shoot("chest_clay_0", CHEST, 0.22, 0, True)
shoot("chest_clay_40", CHEST, 0.22, 40, True)
shoot("chest_tex_0", CHEST, 0.22, 0, False)
shoot("hip_clay_0", HIP, 0.22, 0, True)
shoot("full_clay_0", FULL, 0.55, 0, True)
bpy.context.preferences.filepaths.save_version = 0 # no .blend1 autosave
bpy.ops.wm.save_as_mainfile(filepath=OUT)
log(f"WROTE {OUT}")
print("NRM_DONE")
@@ -0,0 +1,259 @@
# Stage 19: measure the seam's cross-section, then trial the heal at several band widths.
#
# blender --background --python 19_wide_heal.py -- <in.blend> <review_root> [widths]
#
# WHY WIDTH IS THE WHOLE QUESTION. Established so far: the lines are not cracks (16c), not
# painted into the custom normals (18 — corner-vs-vertex deviation is 0.008 deg mean), and a
# 5-vertex-wide collar-fixed membrane moves 12.6k verts without changing the render (17).
# The remaining reading is that a Tripo panel border is a STEP — the reconstruction's two charts
# meet with a sub-millimetre offset, a C0 discontinuity — rather than a ridge sitting on smooth
# skin. A narrow band cannot fix a step, because the fixed collar lands on the step's own
# shoulders and the interpolant faithfully reproduces the offset it is pinned to. Removing a step
# means spreading it over a wide enough neighbourhood that the residual curvature falls below
# visibility.
#
# So this stage MEASURES first: |offset from a broadly smoothed surface| as a function of ring
# distance from the seam. That profile says how wide the disturbance really is, and therefore how
# wide the band must be. Then it renders the heal at several widths so the choice is made from
# pictures rather than from theory. Nothing is saved — this is an experiment; the winning width
# gets applied in the next stage.
import bpy, sys, os, math, time
import numpy as np
from mathutils import Vector
argv = sys.argv[sys.argv.index("--") + 1:]
BLEND, ROOT = argv[0], argv[1]
WIDTHS = [int(x) for x in argv[2].split(",")] if len(argv) > 2 else [4, 8, 12]
t0 = time.time()
UNIT_MM = 1815.0
KINK_DEG = 6.0
COLLAR = 3
Z_LO, Z_HI = 0.04, 0.90
X_MAX = 0.36
def log(m):
print(f"[wide {time.time()-t0:6.1f}s] {m}", flush=True)
bpy.ops.wm.open_mainfile(filepath=BLEND)
ob = max([o for o in bpy.data.objects if o.type == 'MESH'],
key=lambda o: len(o.data.vertices))
me = ob.data
n_v = len(me.vertices)
co = np.empty(n_v * 3)
me.vertices.foreach_get("co", co)
co = co.reshape(-1, 3)
ev = np.empty(len(me.edges) * 2, dtype=np.int32)
me.edges.foreach_get("vertices", ev)
ev = ev.reshape(-1, 2)
log(f"{n_v}v {len(me.polygons)}f")
order = np.concatenate([ev[:, 0], ev[:, 1]])
nbr = np.concatenate([ev[:, 1], ev[:, 0]])
srt = np.argsort(order, kind="stable")
o_s, n_s = order[srt], nbr[srt]
ptr = np.searchsorted(o_s, np.arange(n_v + 1))
cnt = np.maximum(np.diff(ptr), 1)
empty = np.diff(ptr) == 0
def nbr_mean(X):
a = np.add.reduceat(X[n_s], ptr[:-1], axis=0)
a[empty] = X[empty]
return a / cnt[:, None]
def smooth_n(X, k):
Y = X.copy()
for _ in range(k):
Y = nbr_mean(Y)
return Y
def grow(mask, rings):
m = mask.copy()
for _ in range(rings):
hit = m[ev[:, 0]] | m[ev[:, 1]]
m2 = m.copy()
m2[ev[:, 0]] |= hit
m2[ev[:, 1]] |= hit
m = m2
return m
def kink_of(P):
nrm = np.empty(n_v * 3)
me.vertices.foreach_get("normal", nrm)
nrm = nrm.reshape(-1, 3)
N = nrm.copy()
for _ in range(5):
N = nbr_mean(N)
N /= np.maximum(np.linalg.norm(N, axis=1, keepdims=True), 1e-12)
return np.degrees(np.arccos(np.clip((nrm * N).sum(axis=1), -1, 1))), N
zone = (co[:, 2] > Z_LO) & (co[:, 2] < Z_HI) & (np.abs(co[:, 0]) < X_MAX)
navel = (np.abs(co[:, 0]) < 0.022) & (co[:, 2] > 0.495) & (co[:, 2] < 0.555) & (co[:, 1] < 0)
ang, N = kink_of(co)
seed = zone & ~navel & (ang > KINK_DEG)
log(f"seed (kink>{KINK_DEG}deg): {int(seed.sum())} verts")
# =============================================================================
# cross-section profile: |offset from broad smooth| vs ring distance from seed
# =============================================================================
sm40 = smooth_n(co, 40)
sm12 = smooth_n(co, 12)
dev40 = ((co - sm40) * N).sum(axis=1) * UNIT_MM
dev12 = ((co - sm12) * N).sum(axis=1) * UNIT_MM
ring = np.full(n_v, -1, dtype=np.int32)
ring[seed] = 0
cur = seed.copy()
for r in range(1, 16):
nxt = grow(cur, 1) & ~cur & zone
ring[nxt & (ring < 0)] = r
cur = cur | nxt
print("\nSEAM CROSS-SECTION (real mm, magnitudes; ring 0 = detected seam centre)")
print(" ring n |dev12| med p90 |dev40| med p90")
for r in range(0, 15):
m = ring == r
if m.sum() < 50:
continue
print(f" {r:4d} {int(m.sum()):8d} {np.median(np.abs(dev12[m])):7.3f} "
f"{np.percentile(np.abs(dev12[m]),90):7.3f} "
f"{np.median(np.abs(dev40[m])):7.3f} {np.percentile(np.abs(dev40[m]),90):7.3f}")
far = zone & (ring < 0)
if far.sum() > 50:
print(f" far {int(far.sum()):8d} {np.median(np.abs(dev12[far])):7.3f} "
f"{np.percentile(np.abs(dev12[far]),90):7.3f} "
f"{np.median(np.abs(dev40[far])):7.3f} {np.percentile(np.abs(dev40[far]),90):7.3f}")
# =============================================================================
# solver
# =============================================================================
def bilaplacian(P, free_m, collar_rings=COLLAR, maxit=6000):
collar = grow(free_m, collar_rings) & ~free_m
S = np.nonzero(free_m | collar)[0]
in_S = np.zeros(n_v, dtype=bool)
in_S[S] = True
glb = np.full(n_v, -1, dtype=np.int64)
glb[S] = np.arange(len(S))
se = ev[in_S[ev].all(axis=1)]
a_ = glb[se[:, 0]]
b_ = glb[se[:, 1]]
deg = np.zeros(len(S))
np.add.at(deg, a_, 1.0)
np.add.at(deg, b_, 1.0)
free = free_m[S]
def Ls(X):
out = deg[:, None] * X
np.add.at(out, a_, -X[b_])
np.add.at(out, b_, -X[a_])
return out
def A_op(U):
X = np.zeros((len(S), 3))
X[free] = U
return Ls(Ls(X))[free]
Xc = np.zeros((len(S), 3))
Xc[~free] = P[S[~free]]
rhs = -Ls(Ls(Xc))[free]
U = P[S[free]].copy()
r = rhs - A_op(U)
p = r.copy()
rs = (r * r).sum()
rs0 = max(rs, 1e-30)
it = 0
for it in range(maxit):
Ap = A_op(p)
den = (p * Ap).sum()
if abs(den) < 1e-30:
break
al = rs / den
U += al * p
r -= al * Ap
rs2 = (r * r).sum()
if rs2 < 1e-20 or rs2 < rs0 * 1e-13:
rs = rs2
break
p = r + (rs2 / rs) * p
rs = rs2
Q = P.copy()
Q[S[free]] = U
return Q, int(free.sum()), it, rs / rs0
# =============================================================================
# render helper (same framing/lighting as 04_review)
# =============================================================================
scn = bpy.context.scene
wd = bpy.data.worlds.new("W")
wd.color = (0.22, 0.22, 0.24)
scn.world = wd
key = bpy.data.objects.new("Key", bpy.data.lights.new("Key", 'SUN'))
key.data.energy = 3.0
key.data.use_shadow = False
bpy.context.collection.objects.link(key)
fl = bpy.data.objects.new("Fill", bpy.data.lights.new("Fill", 'SUN'))
fl.data.energy = 1.0
fl.data.use_shadow = False
bpy.context.collection.objects.link(fl)
cam = bpy.data.objects.new("Cam", bpy.data.cameras.new("Cam"))
cam.data.lens = 85
bpy.context.collection.objects.link(cam)
scn.camera = cam
scn.render.engine = 'BLENDER_EEVEE' if bpy.app.version >= (4, 2) else 'BLENDER_EEVEE_NEXT'
scn.render.resolution_x = scn.render.resolution_y = 1000
clay = bpy.data.materials.new("Clay")
clay.use_nodes = True
clay.node_tree.nodes["Principled BSDF"].inputs["Base Color"].default_value = (0.62, 0.60, 0.58, 1)
clay.node_tree.nodes["Principled BSDF"].inputs["Roughness"].default_value = 0.45
orig = [ms.material for ms in ob.material_slots]
def shoot(outdir, tag, ctr, span, yaw_deg, use_clay=True):
os.makedirs(outdir, exist_ok=True)
for i, ms in enumerate(ob.material_slots):
ms.material = clay if use_clay else orig[i]
yaw = math.radians(yaw_deg)
dist = span * 3.0
cam.location = Vector(ctr) + Vector((math.sin(yaw) * dist, -math.cos(yaw) * dist, 0.02))
cam.rotation_euler = (Vector(ctr) - cam.location).to_track_quat('-Z', 'Y').to_euler()
key.rotation_euler = (math.radians(62), 0, math.radians(35 + yaw_deg))
fl.rotation_euler = (math.radians(75), 0, math.radians(yaw_deg - 110))
scn.render.filepath = os.path.abspath(os.path.join(outdir, f"{tag}.png"))
bpy.ops.render.render(write_still=True)
CHEST = (0.0, 0.0, 0.675)
FULL = (0.0, 0.0, 0.50)
HIP = (0.0, 0.0, 0.53)
for W in WIDTHS:
band = grow(seed, W) & zone & ~navel
Q, nf, it, rel = bilaplacian(co, band)
d = np.linalg.norm(Q - co, axis=1) * UNIT_MM
me.vertices.foreach_set("co", Q.reshape(-1))
me.update()
if me.has_custom_normals:
vn = np.empty(n_v * 3, dtype=np.float32)
me.vertices.foreach_get("normal", vn)
me.normals_split_custom_set_from_vertices(vn.reshape(-1, 3))
ang2, _ = kink_of(Q)
torso = (co[:, 2] > 0.28) & (co[:, 2] < 0.90)
log(f"W={W:2d}: band {nf} verts, CG it={it} rel={rel:.1e}, moved max {d.max():.2f} mm "
f"median(band) {np.median(d[band]):.3f} mm | kink>6 {int((torso&(ang2>6)).sum())} "
f">12 {int((torso&(ang2>12)).sum())} >20 {int((torso&(ang2>20)).sum())}")
out = os.path.join(ROOT, f"w{W:02d}")
shoot(out, "chest_clay_40", CHEST, 0.22, 40)
shoot(out, "full_clay_0", FULL, 0.55, 0)
shoot(out, "hip_clay_0", HIP, 0.22, 0)
log(f"W={W}: rendered -> {out}")
me.vertices.foreach_set("co", co.reshape(-1)) # reset for the next width
me.update()
print("WIDE_DONE")
@@ -0,0 +1,233 @@
# Stage 20 (read-only probe): characterise the CURRENT UV atlas, so "the texture looks cut up
# and pasted together" becomes a measurement instead of an impression.
#
# blender --background --python 20_atlas_probe.py -- <in.blend> <out_dir>
#
# Reports, for the mesh's active UV layer:
# - which image the material actually samples (bpy.data.images holds stale duplicates)
# - UV-vertex count vs mesh-vertex count => how much the atlas is cut apart
# - island count + size distribution => "pasted together" from how many pieces
# - per-island texel density (px per mm) => whether pieces are at inconsistent scale
# - atlas coverage + wasted area
# Writes: basecolor.png (the real one), islands.png (island map), density.png (px/mm heat),
# uvgrid.png (UV wireframe), and uv_cache.npz for later stages.
import bpy, sys, os, time
import numpy as np
argv = sys.argv[sys.argv.index("--") + 1:]
BLEND = argv[0]
OUTDIR = os.path.abspath(argv[1])
os.makedirs(OUTDIR, exist_ok=True)
t0 = time.time()
def log(m):
print(f"[atlas {time.time()-t0:6.1f}s] {m}", flush=True)
bpy.ops.wm.open_mainfile(filepath=BLEND)
ob = max([o for o in bpy.data.objects if o.type == 'MESH'], key=lambda o: len(o.data.vertices))
me = ob.data
n_v, n_l, n_f = len(me.vertices), len(me.loops), len(me.polygons)
log(f"mesh '{ob.name}': {n_v}v {n_l}loops {n_f}faces uv_layers={[l.name for l in me.uv_layers]}")
# ---- which image does the material ACTUALLY sample? follow the node link ----
sampled = {}
for slot in ob.material_slots:
mat = slot.material
if not mat or not mat.use_nodes:
continue
for node in mat.node_tree.nodes:
if node.type != 'BSDF_PRINCIPLED':
continue
for sock, key in (("Base Color", "base"), ("Normal", "normal"), ("Roughness", "rm")):
if sock not in node.inputs or not node.inputs[sock].links:
continue
src = node.inputs[sock].links[0].from_node
seen = set()
while src and src.type != 'TEX_IMAGE' and id(src) not in seen:
seen.add(id(src))
nxt = None
for i in src.inputs:
if i.links:
nxt = i.links[0].from_node
break
src = nxt
if src and src.type == 'TEX_IMAGE' and src.image:
sampled[key] = src.image
for k, im in sampled.items():
log(f"SAMPLED {k}: '{im.name}' {im.size[0]}x{im.size[1]} packed={bool(im.packed_file)}")
print("ALL IMAGES IN FILE (duplicates are stale):")
for im in bpy.data.images:
if im.size[0]:
print(f" '{im.name}' {im.size[0]}x{im.size[1]}")
base = sampled.get("base")
if base is None:
print("!! material samples no basecolor image"); sys.exit(1)
W, H = base.size
buf = np.empty(W * H * 4, dtype=np.float32)
base.pixels.foreach_get(buf)
tex = buf.reshape(H, W, 4)
# ---- UV data ----
loops_v = np.empty(n_l, dtype=np.int32)
me.loops.foreach_get("vertex_index", loops_v)
uv = np.empty(n_l * 2, dtype=np.float64)
me.uv_layers.active.data.foreach_get("uv", uv)
uv = uv.reshape(-1, 2)
co = np.empty(n_v * 3)
me.vertices.foreach_get("co", co)
co = co.reshape(-1, 3)
print(f"UV range u {uv[:,0].min():.4f}..{uv[:,0].max():.4f} "
f"v {uv[:,1].min():.4f}..{uv[:,1].max():.4f}")
# ---- uv-vertices: a mesh vertex split across N atlas pieces becomes N uv-vertices ----
Q = 1 << 20
key = (loops_v.astype(np.int64) * Q * Q
+ np.round(np.clip(uv[:, 0], 0, 1) * (Q - 1)).astype(np.int64) * Q
+ np.round(np.clip(uv[:, 1], 0, 1) * (Q - 1)).astype(np.int64))
_, uvv = np.unique(key, return_inverse=True)
n_uvv = uvv.max() + 1
splits = np.bincount(uvv, minlength=n_uvv)
per_vert = np.bincount(loops_v, weights=np.zeros(n_l)) # placeholder
# how many atlas copies does each mesh vertex have?
vk = np.unique(np.stack([loops_v, uvv], axis=1), axis=0)
copies = np.bincount(vk[:, 0], minlength=n_v)
print(f"\n=== CUT-APART ===")
print(f"uv-vertices {n_uvv} for {n_v} mesh vertices -> {100.0*(n_uvv-n_v)/n_v:+.1f}% duplication")
print(f"vertices on a UV seam: {int((copies > 1).sum())} ({100.0*(copies>1).sum()/n_v:.1f}%) "
f"max copies {int(copies.max())}")
# ---- islands = connected components of the uv-mesh ----
l_start = np.empty(n_f, dtype=np.int32); me.polygons.foreach_get("loop_start", l_start)
l_tot = np.empty(n_f, dtype=np.int32); me.polygons.foreach_get("loop_total", l_tot)
tri = l_tot == 3
log(f"faces: {int(tri.sum())} tris, {int((~tri).sum())} n-gons")
li = l_start[tri]
T = np.stack([uvv[li], uvv[li + 1], uvv[li + 2]], axis=1)
parent = np.arange(n_uvv, dtype=np.int64)
def find(x):
r = x
while parent[r] != r:
r = parent[r]
while parent[x] != r:
parent[x], x = r, parent[x]
return r
for a, b, c in T:
ra, rb, rc = find(a), find(b), find(c)
if ra != rb:
parent[rb] = ra
if ra != rc:
parent[rc] = ra
log("union-find done")
roots = np.array([find(i) for i in range(n_uvv)])
_, isl = np.unique(roots, return_inverse=True)
n_isl = isl.max() + 1
# per-island geometry: UV area and 3D area
Puv = np.clip(uv, 0, 1)
tri_uv = np.stack([Puv[li], Puv[li + 1], Puv[li + 2]], axis=1) # (F,3,2)
auv = 0.5 * np.abs((tri_uv[:, 1, 0] - tri_uv[:, 0, 0]) * (tri_uv[:, 2, 1] - tri_uv[:, 0, 1])
- (tri_uv[:, 2, 0] - tri_uv[:, 0, 0]) * (tri_uv[:, 1, 1] - tri_uv[:, 0, 1]))
P3 = co[np.stack([loops_v[li], loops_v[li + 1], loops_v[li + 2]], axis=1)] # (F,3,3)
cr = np.cross(P3[:, 1] - P3[:, 0], P3[:, 2] - P3[:, 0])
a3 = 0.5 * np.linalg.norm(cr, axis=1)
fisl = isl[T[:, 0]]
isl_auv = np.bincount(fisl, weights=auv, minlength=n_isl)
isl_a3 = np.bincount(fisl, weights=a3, minlength=n_isl)
isl_nf = np.bincount(fisl, minlength=n_isl)
order = np.argsort(-isl_auv)
UNIT = 1.815 # 1 mesh unit = 1.815 m (body is 0.979 units for 1.777 m)
print(f"\n=== PASTED TOGETHER ===")
print(f"islands: {n_isl}")
print(f"atlas UV area used: {isl_auv.sum()*100:.1f}% (rest is padding/waste)")
cum = np.cumsum(isl_auv[order]) / max(isl_auv.sum(), 1e-12)
for frac in (0.5, 0.9, 0.99):
print(f" {int(np.searchsorted(cum, frac))+1} islands cover {frac*100:.0f}% of the used area")
tiny = int((isl_nf < 20).sum())
print(f" islands with <20 faces: {tiny} ({100.0*tiny/n_isl:.1f}%)")
print("\ntop 20 islands (px/mm = texel density at 4096):")
print(" # faces uv_area% 3D area cm2 px/mm uv centre")
for i in order[:20]:
if isl_a3[i] <= 0:
continue
dens = np.sqrt(isl_auv[i] / isl_a3[i]) * W / (UNIT * 1000.0)
m = fisl == i
cu = tri_uv[m].reshape(-1, 2).mean(axis=0)
print(f" {i:6d} {isl_nf[i]:7d} {isl_auv[i]*100:8.3f} "
f"{isl_a3[i]*UNIT*UNIT*1e4:10.1f} {dens:6.2f} ({cu[0]:.3f},{cu[1]:.3f})")
big = order[:max(1, int(np.searchsorted(cum, 0.99)) + 1)]
dens_all = np.where(isl_a3 > 0, np.sqrt(np.maximum(isl_auv, 0) / np.maximum(isl_a3, 1e-12))
* W / (UNIT * 1000.0), np.nan)
d = dens_all[big]
d = d[np.isfinite(d)]
print(f"\ntexel density over the 99%-area islands: min {d.min():.2f} median {np.median(d):.2f} "
f"max {d.max():.2f} px/mm -> {d.max()/max(d.min(),1e-9):.1f}x spread")
# ---- pictures ----
def save(arr, name):
h, w = arr.shape[:2]
img = bpy.data.images.new(name, w, h, alpha=False, float_buffer=False)
a = np.ones((h, w, 4), dtype=np.float32)
a[:, :, :3] = arr.astype(np.float32)
img.pixels.foreach_set(a.reshape(-1))
p = os.path.join(OUTDIR, name + ".png")
img.file_format = 'PNG'
img.filepath_raw = p
img.save(filepath=p)
log(f"wrote {p} exists={os.path.exists(p)}")
save(tex[:, :, :3], "basecolor")
R = 1024
sc = R / float(W)
rng = np.random.RandomState(3)
pal = rng.rand(n_isl, 3) * 0.75 + 0.2
IS = np.zeros((R, R, 3), dtype=np.float64)
DN = np.zeros((R, R), dtype=np.float64)
GR = np.zeros((R, R), dtype=np.float64)
tri_px = tri_uv * np.array([(R - 1), (R - 1)])
for fi in range(len(tri_px)):
P = tri_px[fi]
x0, x1 = int(P[:, 0].min()), int(np.ceil(P[:, 0].max()))
y0, y1 = int(P[:, 1].min()), int(np.ceil(P[:, 1].max()))
if x1 < x0 or y1 < y0 or x1 - x0 > 64 or y1 - y0 > 64:
continue
dd = ((P[1, 1] - P[2, 1]) * (P[0, 0] - P[2, 0]) + (P[2, 0] - P[1, 0]) * (P[0, 1] - P[2, 1]))
if abs(dd) < 1e-12:
continue
gx, gy = np.meshgrid(np.arange(x0, min(x1, R - 1) + 1), np.arange(y0, min(y1, R - 1) + 1))
aa = ((P[1, 1] - P[2, 1]) * (gx - P[2, 0]) + (P[2, 0] - P[1, 0]) * (gy - P[2, 1])) / dd
bb = ((P[2, 1] - P[0, 1]) * (gx - P[2, 0]) + (P[0, 0] - P[2, 0]) * (gy - P[2, 1])) / dd
cc = 1.0 - aa - bb
ins = (aa >= 0) & (bb >= 0) & (cc >= 0)
if not ins.any():
continue
IS[gy[ins], gx[ins]] = pal[fisl[fi]]
DN[gy[ins], gx[ins]] = dens_all[fisl[fi]] if np.isfinite(dens_all[fisl[fi]]) else 0
# edge pixels -> wireframe
ed = ins & ((aa < 0.06) | (bb < 0.06) | (cc < 0.06))
GR[gy[ed], gx[ed]] = 1.0
log("rasterised island map")
save(IS, "islands")
dv = DN / max(np.percentile(DN[DN > 0], 98), 1e-9)
save(np.stack([np.clip(dv, 0, 1), np.clip(1 - np.abs(dv - 0.5) * 2, 0, 1),
np.clip(1 - dv, 0, 1)], axis=2), "density")
tsm = tex[::W // R, ::W // R, :3]
save(np.clip(tsm * (1 - GR[:, :, None] * 0.8) + GR[:, :, None] * np.array([0.0, 1.0, 0.2]), 0, 1),
"uvgrid")
np.savez_compressed(os.path.join(OUTDIR, "uv_cache.npz"),
isl=isl, uvv=uvv, fisl=fisl, isl_auv=isl_auv, isl_a3=isl_a3,
isl_nf=isl_nf, copies=copies)
print("ATLAS_PROBE_DONE")
@@ -0,0 +1,226 @@
# Stage 20: round the cleavage — fillet the sharp sternum notch and the old bra-neckline crease
# WITHOUT deflating the breasts.
#
# blender --background --python 20_cleavage.py -- <in.blend> <out.blend> <review_dir>
# [iters] [target_radius_units]
#
# WHAT IS WRONG, MEASURED (16_diagnose on 10_welded)
# z=0.710 sternum fillet radius 8.2 mm-units (~15 real mm) notch depth 21 real mm
# z=0.725 sternum fillet radius 2.5 mm-units (~4.5 real mm) notch depth 44 real mm
# elsewhere the corridor radius is 45-550 mm-units, i.e. smooth.
# So there is a razor-sharp, deep V at z 0.71-0.74 — the scar left by excising the bra's sternum
# bow — plus the arcing crease of the old bra neckline over each upper breast. z 0.725 is 1.32 m
# on a 1.777 m body: that is the sternal notch ABOVE the bust (apex sits at z 0.69), where
# anatomy wants a shallow rounded valley, not a gash.
#
# WHY A FILL-ONLY CONCAVITY FILTER, NOT A MEMBRANE OR SMOOTHING
# A membrane over the corridor would bridge the notch, but it also replaces whatever it spans —
# aimed at the upper chest it would eat the breasts' upper poles, and the cups are the one thing
# that must survive (stage 03 sculpted them deliberately; the whole point of v2 was that they are
# not a bra shape). Plain smoothing has the same problem in reverse: it shrinks convex volume.
# So: displace ONLY where the surface is concave beyond a curvature limit, and only OUTWARD
# (valley-filling). Convex geometry has the wrong sign and is untouched by construction, so no
# amount of iteration can flatten a breast. Sharp valleys rise until their radius passes the
# limit, which is exactly "round and smooth as it connects with the chest".
#
# The limit is enforced by measurement, not by feel: after each pass the script re-runs the same
# profile-curvature probe 16_diagnose used, and reports radius + notch depth per height so the
# result is comparable to the numbers above.
import bpy, sys, os, math, time
import numpy as np
from mathutils import Vector
argv = sys.argv[sys.argv.index("--") + 1:]
BLEND, OUT, REVIEW = argv[0], argv[1], argv[2]
ITERS = int(argv[3]) if len(argv) > 3 else 60
R_TARGET = float(argv[4]) if len(argv) > 4 else 0.025 # mesh units (~45 real mm)
os.makedirs(REVIEW, exist_ok=True)
t0 = time.time()
UNIT_MM = 1815.0
# region: the front of the chest, from just under the bust to the clavicles
Z0, Z1 = 0.620, 0.800
Z_FADE = 0.020
X_MAX = 0.095
X_FADE = 0.025
Y_FRONT = 0.010 # front hemisphere only
ALPHA = 0.55 # per-pass fraction of the concave offset that is filled
def log(m):
print(f"[clv {time.time()-t0:6.1f}s] {m}", flush=True)
def smoothstep(x):
x = np.clip(x, 0.0, 1.0)
return x * x * (3.0 - 2.0 * x)
bpy.ops.wm.open_mainfile(filepath=BLEND)
ob = max([o for o in bpy.data.objects if o.type == 'MESH'],
key=lambda o: len(o.data.vertices))
me = ob.data
n_v = len(me.vertices)
co = np.empty(n_v * 3)
me.vertices.foreach_get("co", co)
co = co.reshape(-1, 3)
ev = np.empty(len(me.edges) * 2, dtype=np.int32)
me.edges.foreach_get("vertices", ev)
ev = ev.reshape(-1, 2)
log(f"in: {n_v}v {len(me.polygons)}f")
order = np.concatenate([ev[:, 0], ev[:, 1]])
nbr = np.concatenate([ev[:, 1], ev[:, 0]])
srt = np.argsort(order, kind="stable")
o_s, n_s = order[srt], nbr[srt]
ptr = np.searchsorted(o_s, np.arange(n_v + 1))
cnt = np.maximum(np.diff(ptr), 1)
empty = np.diff(ptr) == 0
def nbr_mean(X):
a = np.add.reduceat(X[n_s], ptr[:-1], axis=0)
a[empty] = X[empty]
return a / cnt[:, None]
# ---- region weight ----
wz = smoothstep((co[:, 2] - (Z0 - Z_FADE)) / Z_FADE) * \
smoothstep(((Z1 + Z_FADE) - co[:, 2]) / Z_FADE)
wx = smoothstep(((X_MAX + X_FADE) - np.abs(co[:, 0])) / X_FADE)
wy = smoothstep((Y_FRONT - co[:, 1]) / 0.030)
W = wz * wx * wy
log(f"region: {int((W > 0.01).sum())} verts with weight >0.01, "
f"{int((W > 0.5).sum())} above 0.5")
# ---- the same profile probe 16_diagnose used, so numbers are comparable ----
def probe(P, tag):
front = P[:, 1] < 0
print(f"\n=== CLEAVAGE PROFILE [{tag}] ===")
print(" z sternum y concave curv 1/m fillet radius notch vs apex")
worst = 9e9
for z0 in np.arange(0.650, 0.7801, 0.015):
row = []
for x0 in np.arange(-0.05, 0.0501, 0.005):
m = front & (np.abs(P[:, 0] - x0) < 0.0035) & (np.abs(P[:, 2] - z0) < 0.004)
row.append(P[m, 1].min() if m.sum() else np.nan)
row = np.array(row)
if np.isnan(row).all():
continue
mid = len(row) // 2
seg = row[max(0, mid - 3):mid + 4]
rad = float('inf')
kmax = np.nan
if len(seg) >= 3 and not np.isnan(seg).any():
d2 = (seg[:-2] - 2 * seg[1:-1] + seg[2:]) / (0.005 ** 2)
kmax = float(np.nanmax(d2))
rad = 1.0 / kmax if kmax > 1e-6 else float('inf')
ma = front & (np.abs(np.abs(P[:, 0]) - 0.034) < 0.005) & (np.abs(P[:, 2] - z0) < 0.004)
notch = ((row[mid] - P[ma, 1].min()) * UNIT_MM) if ma.sum() else np.nan
flag = ""
if rad < R_TARGET:
flag = " <-- SHARP"
worst = min(worst, rad)
print(f" {z0:.3f} {row[mid]:+.4f} {kmax:10.1f} "
f"{rad*UNIT_MM:8.1f} mm {notch:+7.1f} mm{flag}")
return worst
w0 = probe(co, "before")
# ---- fill-only concavity relaxation ----
P = co.copy()
active = W > 0.01
for it in range(1, ITERS + 1):
# fresh vertex normals from the CURRENT positions (area-weighted via the mesh)
me.vertices.foreach_set("co", P.reshape(-1))
me.update()
nrm = np.empty(n_v * 3)
me.vertices.foreach_get("normal", nrm)
nrm = nrm.reshape(-1, 3)
lap = nbr_mean(P) - P
c = (lap * nrm).sum(axis=1) # >0 : neighbours are outside -> valley (concave)
step = np.where(c > 0, c, 0.0) * ALPHA * W
# a vertex only moves if its valley is sharper than the target radius: the uniform-Laplacian
# offset of a circular valley of radius R over spacing h is ~h^2/(2R), so compare against that
h2 = np.zeros(n_v)
np.add.at(h2, ev[:, 0], np.linalg.norm(P[ev[:, 0]] - P[ev[:, 1]], axis=1) ** 2)
np.add.at(h2, ev[:, 1], np.linalg.norm(P[ev[:, 0]] - P[ev[:, 1]], axis=1) ** 2)
hcnt = np.zeros(n_v)
np.add.at(hcnt, ev[:, 0], 1.0)
np.add.at(hcnt, ev[:, 1], 1.0)
h2 = h2 / np.maximum(hcnt, 1)
thresh = h2 / (2.0 * R_TARGET)
step = np.where(c > thresh, step, 0.0)
P = P + nrm * step[:, None]
if it % 15 == 0 or it == 1:
moved = np.linalg.norm(P - co, axis=1) * UNIT_MM
log(f"pass {it:3d}: {int((step>0).sum()):6d} verts filled this pass, "
f"cumulative max {moved.max():.2f} mm, median(region) "
f"{np.median(moved[active]):.3f} mm")
me.vertices.foreach_set("co", P.reshape(-1))
me.update()
if me.has_custom_normals:
vn = np.empty(n_v * 3, dtype=np.float32)
me.vertices.foreach_get("normal", vn)
me.normals_split_custom_set_from_vertices(vn.reshape(-1, 3))
d = np.linalg.norm(P - co, axis=1) * UNIT_MM
log(f"TOTAL: max {d.max():.2f} mm, {int((d > 0.1).sum())} verts moved >0.1 mm "
f"(all outward: min radial change {np.min(((P-co)*0+1)[0]):.0f})")
w1 = probe(P, "after")
print(f"\nSHARPEST corridor radius: before {w0*UNIT_MM:.1f} mm -> after "
f"{(w1*UNIT_MM if w1 < 9e9 else float('inf')):.1f} mm (target {R_TARGET*UNIT_MM:.0f} mm)")
# ---- renders ----
scn = bpy.context.scene
wd = bpy.data.worlds.new("W")
wd.color = (0.22, 0.22, 0.24)
scn.world = wd
key = bpy.data.objects.new("Key", bpy.data.lights.new("Key", 'SUN'))
key.data.energy = 3.0
key.data.use_shadow = False
bpy.context.collection.objects.link(key)
fl = bpy.data.objects.new("Fill", bpy.data.lights.new("Fill", 'SUN'))
fl.data.energy = 1.0
fl.data.use_shadow = False
bpy.context.collection.objects.link(fl)
cam = bpy.data.objects.new("Cam", bpy.data.cameras.new("Cam"))
cam.data.lens = 85
bpy.context.collection.objects.link(cam)
scn.camera = cam
scn.render.engine = 'BLENDER_EEVEE' if bpy.app.version >= (4, 2) else 'BLENDER_EEVEE_NEXT'
scn.render.resolution_x = scn.render.resolution_y = 1000
clay = bpy.data.materials.new("Clay")
clay.use_nodes = True
clay.node_tree.nodes["Principled BSDF"].inputs["Base Color"].default_value = (0.62, 0.60, 0.58, 1)
clay.node_tree.nodes["Principled BSDF"].inputs["Roughness"].default_value = 0.45
orig = [ms.material for ms in ob.material_slots]
def shoot(tag, ctr, span, yaw_deg, use_clay=True):
for i, ms in enumerate(ob.material_slots):
ms.material = clay if use_clay else orig[i]
yaw = math.radians(yaw_deg)
dist = span * 3.0
cam.location = Vector(ctr) + Vector((math.sin(yaw) * dist, -math.cos(yaw) * dist, 0.02))
cam.rotation_euler = (Vector(ctr) - cam.location).to_track_quat('-Z', 'Y').to_euler()
key.rotation_euler = (math.radians(62), 0, math.radians(35 + yaw_deg))
fl.rotation_euler = (math.radians(75), 0, math.radians(yaw_deg - 110))
scn.render.filepath = os.path.abspath(os.path.join(REVIEW, f"{tag}.png"))
bpy.ops.render.render(write_still=True)
log(f"render {tag}")
CHEST = (0.0, 0.0, 0.675)
FULL = (0.0, 0.0, 0.50)
for yaw in (0, 40, 90):
shoot(f"chest_clay_{yaw}", CHEST, 0.22, yaw, True)
shoot("chest_tex_0", CHEST, 0.22, 0, False)
shoot("chest_tex_40", CHEST, 0.22, 40, False)
shoot("full_clay_0", FULL, 0.55, 0, True)
bpy.ops.wm.save_as_mainfile(filepath=OUT)
log(f"WROTE {OUT}")
print("CLV_DONE")
@@ -0,0 +1,163 @@
# Stage 21 (read-only): is the patchwork in the LAYOUT or in the COLOUR?
#
# blender --background --python 21_seam_probe.py -- <in.blend> <probe_dir>
#
# A mesh vertex that sits on a UV seam has one copy in each atlas chart that meets there. Those
# copies are the SAME point on her body, so they must be the same colour. Any difference is a
# tone step the eye reads as a pasted edge — and re-packing the UVs would carry it along.
# Reports the distribution of that step, the worst offending chart pairs, and (for scale) the
# same statistic on non-seam vertices, which is pure sampling noise.
# Also reports mean tone per body region, to size the "red hands / rosy chest" complaint.
import bpy, sys, os, time
import numpy as np
argv = sys.argv[sys.argv.index("--") + 1:]
BLEND = argv[0]
PROBE = os.path.abspath(argv[1])
t0 = time.time()
def log(m):
print(f"[seam {time.time()-t0:6.1f}s] {m}", flush=True)
bpy.ops.wm.open_mainfile(filepath=BLEND)
ob = max([o for o in bpy.data.objects if o.type == 'MESH'], key=lambda o: len(o.data.vertices))
me = ob.data
n_v, n_l, n_f = len(me.vertices), len(me.loops), len(me.polygons)
base = None
for slot in ob.material_slots:
mat = slot.material
for node in mat.node_tree.nodes:
if node.type == 'BSDF_PRINCIPLED' and node.inputs["Base Color"].links:
src = node.inputs["Base Color"].links[0].from_node
if src.type == 'TEX_IMAGE':
base = src.image
W, H = base.size
buf = np.empty(W * H * 4, dtype=np.float32)
base.pixels.foreach_get(buf)
tex = buf.reshape(H, W, 4)[:, :, :3].astype(np.float32)
log(f"basecolor '{base.name}' {W}x{H}")
loops_v = np.empty(n_l, dtype=np.int32); me.loops.foreach_get("vertex_index", loops_v)
uv = np.empty(n_l * 2); me.uv_layers.active.data.foreach_get("uv", uv); uv = uv.reshape(-1, 2)
co = np.empty(n_v * 3); me.vertices.foreach_get("co", co); co = co.reshape(-1, 3)
C = np.load(os.path.join(PROBE, "uv_cache.npz"))
uvv, isl = C["uvv"], C["isl"]
l_isl = isl[uvv] # island id per loop
l_start = np.empty(n_f, dtype=np.int32); me.polygons.foreach_get("loop_start", l_start)
# inset each loop's UV 25% toward its face centroid so we read chart interior, not padding
face_of_loop = np.repeat(np.arange(n_f), 3) # all-tri mesh
cen = (uv[l_start[face_of_loop]] + uv[l_start[face_of_loop] + 1]
+ uv[l_start[face_of_loop] + 2]) / 3.0
uvi = uv + 0.25 * (cen - uv)
px = np.clip(np.round(uvi[:, 0] * (W - 1)).astype(np.int32), 0, W - 1)
py = np.clip(np.round(uvi[:, 1] * (H - 1)).astype(np.int32), 0, H - 1)
lc = tex[py, px] # colour per loop
log("sampled per-loop colour")
# ---- per (vertex, island) mean colour ----
key = loops_v.astype(np.int64) * (isl.max() + 1) + l_isl
uk, inv = np.unique(key, return_inverse=True)
n_k = len(uk)
cnt = np.bincount(inv, minlength=n_k).astype(np.float64)
acc = np.zeros((n_k, 3))
for c in range(3):
acc[:, c] = np.bincount(inv, weights=lc[:, c], minlength=n_k)
kc = acc / cnt[:, None]
kv = (uk // (isl.max() + 1)).astype(np.int64) # vertex of each (v,island) group
ki = (uk % (isl.max() + 1)).astype(np.int64) # island of each group
order = np.argsort(kv, kind="stable")
kv_s, kc_s, ki_s = kv[order], kc[order], ki[order]
ptr = np.searchsorted(kv_s, np.arange(n_v + 1))
ncopy = np.diff(ptr)
seam_v = np.nonzero(ncopy > 1)[0]
log(f"seam vertices: {len(seam_v)} (max copies {ncopy.max()})")
steps = []
pairstep = {}
for v in seam_v:
s, e = ptr[v], ptr[v + 1]
cc = kc_s[s:e]
ii = ki_s[s:e]
d = np.abs(cc[:, None, :] - cc[None, :, :]).max(axis=2)
a, b = np.unravel_index(np.argmax(d), d.shape)
steps.append(d[a, b])
if d[a, b] > 0.02:
kpair = (int(min(ii[a], ii[b])), int(max(ii[a], ii[b])))
r = pairstep.setdefault(kpair, [0, 0.0])
r[0] += 1
r[1] += float(d[a, b])
steps = np.array(steps)
# baseline: colour spread among the loops of a NON-seam vertex (sampling noise only)
solo = np.nonzero(ncopy == 1)[0]
sample = solo[::max(1, len(solo) // 40000)]
noise = []
lorder = np.argsort(loops_v, kind="stable")
lv_s = loops_v[lorder]
lptr = np.searchsorted(lv_s, np.arange(n_v + 1))
for v in sample:
li = lorder[lptr[v]:lptr[v + 1]]
if len(li) < 2:
continue
noise.append(np.abs(lc[li].max(axis=0) - lc[li].min(axis=0)).max())
noise = np.array(noise)
print("\n=== COLOUR STEP ACROSS CHART BORDERS ===")
print("(max channel difference between copies of the SAME body point in different charts)")
for p in (50, 75, 90, 95, 99):
print(f" seam p{p:<2d} {np.percentile(steps, p):.4f}")
print(f" seam mean {steps.mean():.4f} >0.02: {100.0*(steps>0.02).mean():.1f}% "
f">0.05: {100.0*(steps>0.05).mean():.1f}% >0.10: {100.0*(steps>0.10).mean():.1f}%")
print(f" NOISE floor (non-seam vertices) p50 {np.percentile(noise,50):.4f} "
f"p95 {np.percentile(noise,95):.4f} mean {noise.mean():.4f}")
print(f" -> seam step is {steps.mean()/max(noise.mean(),1e-9):.1f}x the noise floor")
print("\nworst chart pairs (count of stepped verts, mean step):")
tops = sorted(pairstep.items(), key=lambda kv_: -kv_[1][1])[:12]
for (a, b), (n_, s_) in tops:
print(f" chart {a:5d} <-> {b:5d}: {n_:5d} verts, mean step {s_/n_:.4f}")
# ---- per-region tone (the red hands / rosy chest complaint, as numbers) ----
vc = np.zeros((n_v, 3))
vn = np.zeros(n_v)
for c in range(3):
vc[:, c] = np.bincount(loops_v, weights=lc[:, c], minlength=n_v)
vn = np.bincount(loops_v, minlength=n_v).astype(np.float64)
vc /= np.maximum(vn, 1)[:, None]
z, x, y = co[:, 2], co[:, 0], co[:, 1]
regions = {
"head ": z > 0.905,
"neck/upper chest": (z > 0.82) & (z <= 0.905) & (np.abs(x) < 0.09),
"breast band ": (z > 0.60) & (z <= 0.78) & (np.abs(x) < 0.11) & (y < 0),
"belly ": (z > 0.45) & (z <= 0.60) & (np.abs(x) < 0.09) & (y < 0),
"hip/crotch ": (z > 0.33) & (z <= 0.45) & (np.abs(x) < 0.09),
"upper arm ": (z > 0.70) & (np.abs(x) > 0.16) & (np.abs(x) < 0.30),
"forearm ": (np.abs(x) > 0.30) & (np.abs(x) < 0.40),
"hand ": np.abs(x) > 0.40,
"thigh ": (z > 0.20) & (z <= 0.33),
"shin ": (z > 0.06) & (z <= 0.18),
"foot ": z <= 0.05,
}
print("\n=== TONE BY REGION (mean RGB, and r-g redness) ===")
belly_rg = None
for nm, m in regions.items():
if m.sum() < 50:
print(f" {nm} (empty)")
continue
c_ = vc[m].mean(axis=0)
rg = c_[0] - c_[1]
if nm.startswith("belly"):
belly_rg = rg
print(f" {nm} n={int(m.sum()):7d} RGB {c_[0]:.3f} {c_[1]:.3f} {c_[2]:.3f} "
f"r-g {rg:.3f} luma {c_.mean():.3f}")
if belly_rg is not None:
print(f" (belly r-g = {belly_rg:.3f} is the reference 'plain skin' redness)")
np.save(os.path.join(PROBE, "vert_colour.npy"), vc.astype(np.float32))
print("SEAM_PROBE_DONE")
@@ -0,0 +1,353 @@
# Stage 21: kill the discolouration in the repainted bra/crotch patches — gradient-domain
# levelling of basecolor + roughness, and grain in place of the flat normal.
#
# blender --background --python 21_tone.py -- <in.blend> <out.blend> [--no-normal-grain]
#
# WHY THE PATCHES READ AS DISCOLOURED
# 05_texture.py fills each garment texel with an inverse-distance colour taken from the nearest
# SKIN VERTS IN 3D, mirror-averaged left/right, then feathers the rim 4 px. Every one of those
# choices is right for avoiding a wrong-body-part tone, and none of them controls the patch's
# ABSOLUTE level: the fill is an average of skin a few centimetres away, so wherever her skin has
# a gradient (and her chest does — there is a rosy blush the uniform-skin decision repaints), the
# patch lands at a different tone than the skin it abuts. A feather blurs that step over 4 px; it
# cannot remove it. The blend also flattens the normal map to (128,128,255) and sets roughness to
# the atlas median over the same texels, so the patch is smoother AND differently-glossy than the
# skin around it — under a key light that reads as discolouration even where the albedo matches.
#
# THE FIX — solve for the level instead of averaging toward it.
# Classic gradient-domain (Poisson) levelling: keep the fill's detail, replace its level. Find a
# correction field E over the patch that is harmonic inside and, ON THE PATCH BORDER, equals the
# mismatch against the untouched skin next to it:
# D(b) = mean(orig[n] : n neighbour of b, n outside the patch) - current[b]
# laplace(E) = 0 inside, E = D on the border, new = current + E
# At the border the corrected value becomes exactly its neighbours' value, so the seam cannot be
# seen; inward, E decays smoothly, so a uniform offset over the whole patch is removed too. Detail
# is untouched because E is smooth by construction — this levels the patch without blurring it.
#
# Solved per blob with a cascadic multigrid (coarse solve -> upsample -> refine). A flat Jacobi
# sweep would need ~width^2 iterations to converge; that mistake is already recorded in this
# project's history as the "pale panty ghost" (400 passes on a 600 px hole left the interior at
# its seed tone), so it is not repeated.
#
# The patch mask is not guessed: it is where the wired basecolor differs from the untouched
# original, which still sits in the file as an orphan datablock copy left by the raw-glb imports.
import bpy, sys, os, time
import numpy as np
argv = sys.argv[sys.argv.index("--") + 1:]
BLEND, OUT = argv[0], argv[1]
# The untouched pre-repaint textures must come from a SEPARATE blend. They used to survive inside
# the working file as orphan ".002/.003" copies left by the raw-glb imports, but Blender purges
# zero-user datablocks on save, so they died the moment 22_lines.blend was written. 00_welded.blend
# is the pristine import and is the right source.
ORIG_BLEND = argv[2] if len(argv) > 2 and not argv[2].startswith("--") else "00_welded.blend"
DO_NORMAL_GRAIN = "--no-normal-grain" not in argv
t0 = time.time()
DIFF_T = 0.02 # a texel counts as repainted if any channel moved this much
RING = 10 # how far out to look for untouched skin
GRAIN_T = 16
def log(m):
print(f"[tone {time.time()-t0:6.1f}s] {m}", flush=True)
def getpx(img):
w, h = img.size
b = np.empty(w * h * 4, dtype=np.float32)
img.pixels.foreach_get(b)
return b.reshape(h, w, 4)
def dil(m, k=1):
g = m.copy()
for _ in range(k):
n = g.copy()
n[1:, :] |= g[:-1, :]
n[:-1, :] |= g[1:, :]
n[:, 1:] |= g[:, :-1]
n[:, :-1] |= g[:, 1:]
g = n
return g
# ---- cache the untouched originals from the pristine blend, before opening the working file ----
if not os.path.exists(ORIG_BLEND):
raise SystemExit(f"[tone] FATAL: original blend not found: {ORIG_BLEND}")
bpy.ops.wm.open_mainfile(filepath=ORIG_BLEND)
ORIG_CACHE = {}
for i in bpy.data.images:
nm = i.name.lower()
kind = ("base" if "basecolor" in nm else
"rm" if "_rm" in nm else
"normal" if "normal" in nm else None)
if kind and kind not in ORIG_CACHE:
ORIG_CACHE[kind] = (getpx(i)[:, :, :3].astype(np.float64), tuple(i.size), i.name)
log(f"cached originals from {ORIG_BLEND}: "
f"{ {k: (v[2], v[1]) for k, v in ORIG_CACHE.items()} }")
bpy.ops.wm.open_mainfile(filepath=BLEND)
ob = max([o for o in bpy.data.objects if o.type == 'MESH'],
key=lambda o: len(o.data.vertices))
log(f"body {ob.name} {len(ob.data.vertices)}v")
# ---- which image is WIRED, and which orphan copy is the untouched original ----
wired = {}
for ms in ob.material_slots:
mat = ms.material
if not mat or not mat.node_tree:
continue
for n in mat.node_tree.nodes:
if n.type != 'TEX_IMAGE' or not n.image:
continue
for o in n.outputs:
for lk in o.links:
tn = lk.to_node.name.lower()
if "principled" in tn or lk.to_socket.name == "Base Color":
wired["base"] = n.image
elif "normal map" in tn:
wired["normal"] = n.image
elif "separate" in tn:
wired["rm"] = n.image
log(f"wired: { {k: v.name for k, v in wired.items()} }")
if "base" not in wired:
raise SystemExit("[tone] FATAL: could not find the wired basecolor")
def find_original(kind, target):
"""The cached pristine version of this map, checked for size and for actually differing."""
if kind not in ORIG_CACHE:
return None
arr, size, nm = ORIG_CACHE[kind]
if size != tuple(target.size):
log(f" {kind}: size {size} != wired {tuple(target.size)} — unusable")
return None
changed = int((np.abs(getpx(target)[:, :, :3] - arr).max(axis=2) > DIFF_T).sum())
log(f" {kind}: original '{nm}', {changed} texels differ from wired")
if changed < 1000:
return None
return (arr, changed, nm)
orig = find_original("base", wired["base"])
if orig is None:
raise SystemExit("[tone] FATAL: no usable untouched original basecolor")
O, n_changed, oname = orig
log(f"original = '{oname}' ({n_changed} texels differ)")
A4 = getpx(wired["base"])
A = A4[:, :, :3].astype(np.float64)
h, w = A.shape[:2]
mask = np.abs(A - O).max(axis=2) > DIFF_T
log(f"patch mask: {int(mask.sum())} texels ({100.0*mask.sum()/(w*h):.2f}% of atlas)")
# ---- split into blobs so each is levelled against ITS OWN surroundings ----
def blobs_of(m, min_px=1500):
lab = np.zeros(m.shape, dtype=np.int32)
cur = 0
out = []
ys, xs = np.nonzero(m)
seen = np.zeros(m.shape, dtype=bool)
from collections import deque
for y0, x0 in zip(ys, xs):
if seen[y0, x0]:
continue
cur += 1
q = deque([(y0, x0)])
seen[y0, x0] = True
cells = []
while q:
y, x = q.popleft()
cells.append((y, x))
for dy, dx in ((1, 0), (-1, 0), (0, 1), (0, -1)):
yy, xx = y + dy, x + dx
if 0 <= yy < m.shape[0] and 0 <= xx < m.shape[1] \
and m[yy, xx] and not seen[yy, xx]:
seen[yy, xx] = True
q.append((yy, xx))
if len(cells) >= min_px:
lab[tuple(np.array(cells).T)] = cur
out.append((cur, len(cells)))
return lab, out
lab, blist = blobs_of(mask)
log(f"blobs >=1500 px: {len(blist)} (covering {sum(b[1] for b in blist)} texels)")
def solve_level(cur_img, orig_img, m_blob, tag):
"""Harmonic correction field E over m_blob with border BC = local mismatch vs untouched skin.
Returns E (same shape as the crop) and diagnostics."""
ys, xs = np.nonzero(m_blob)
y0, y1 = max(0, ys.min() - RING - 2), min(h, ys.max() + RING + 3)
x0, x1 = max(0, xs.min() - RING - 2), min(w, xs.max() + RING + 3)
M = m_blob[y0:y1, x0:x1]
C = cur_img[y0:y1, x0:x1]
Og = orig_img[y0:y1, x0:x1]
allm = mask[y0:y1, x0:x1]
# untouched skin usable as a reference: outside EVERY patch, and plausibly skin
lum = Og.mean(axis=2)
usable = (~allm) & (lum > 0.12)
# robust reject: compare to the median of the ring around this blob
ring = dil(M, RING) & usable
if ring.sum() < 50:
return None, None
med = np.median(Og[ring], axis=0)
mad = np.median(np.abs(Og[ring] - med), axis=0) + 1e-4
ok = (np.abs(Og - med) < (6.0 * mad)).all(axis=2) & usable
# border texels of the blob, and their mismatch D
nb_sum = np.zeros_like(C)
nb_cnt = np.zeros(M.shape)
for dy, dx in ((1, 0), (-1, 0), (0, 1), (0, -1)):
Sh = np.roll(Og * ok[:, :, None], (dy, dx), axis=(0, 1))
Wh = np.roll(ok.astype(np.float64), (dy, dx), axis=(0, 1))
nb_sum += Sh
nb_cnt += Wh
border = M & (nb_cnt > 0)
if border.sum() < 20:
return None, None
D = np.zeros_like(C)
D[border] = nb_sum[border] / nb_cnt[border, None] - C[border]
# cascadic multigrid: solve coarse, upsample, refine
def restrict(x, msk):
Hh, Ww = x.shape[:2]
H2, W2 = (Hh + 1) // 2, (Ww + 1) // 2
acc = np.zeros((H2, W2, x.shape[2]))
cw = np.zeros((H2, W2))
for dy in (0, 1):
for dx in (0, 1):
sub = x[dy::2, dx::2]
sm = msk[dy::2, dx::2].astype(np.float64)
acc[:sub.shape[0], :sub.shape[1]] += sub * sm[:, :, None]
cw[:sub.shape[0], :sub.shape[1]] += sm
out = np.zeros_like(acc)
nz = cw > 0
out[nz] = acc[nz] / cw[nz, None]
return out, cw > 0
levels = []
Mi, Di, Bi = M, D, border
while min(Mi.shape[:2]) > 8 and len(levels) < 7:
levels.append((Mi, Di, Bi))
Dn, _ = restrict(Di, Bi)
Mn = restrict(Mi[:, :, None].astype(np.float64), Mi)[1]
Bn = restrict(Bi[:, :, None].astype(np.float64), Bi)[1]
Mi, Di, Bi = Mn, Dn, Bn
E = np.zeros(levels[-1][0].shape + (3,))
for li in range(len(levels) - 1, -1, -1):
Ml, Dl, Bl = levels[li]
if E.shape[:2] != Ml.shape[:2]:
Eu = np.repeat(np.repeat(E, 2, axis=0), 2, axis=1)
E = Eu[:Ml.shape[0], :Ml.shape[1]]
E[Bl] = Dl[Bl]
interior = Ml & ~Bl
sweeps = 400 if li >= len(levels) - 2 else 60
for _ in range(sweeps):
acc = np.zeros_like(E)
cw = np.zeros(E.shape[:2])
for dy, dx in ((1, 0), (-1, 0), (0, 1), (0, -1)):
acc += np.roll(E * Ml[:, :, None], (dy, dx), axis=(0, 1))
cw += np.roll(Ml.astype(np.float64), (dy, dx), axis=(0, 1))
nz = interior & (cw > 0)
E[nz] = acc[nz] / cw[nz, None]
E[Bl] = Dl[Bl]
inner = M & ~dil(~M, 5)
diag = dict(
n=int(M.sum()),
border=int(border.sum()),
pre=(float(np.mean(C[inner].mean(axis=1) - med.mean())) if inner.sum() else float('nan')),
Emean=float(E[M].mean()),
Emax=float(np.abs(E[M]).max()),
)
return (slice(y0, y1), slice(x0, x1), M, E), diag
# =============================================================================
# apply to basecolor
# =============================================================================
def level_image(img, orig_np, label):
P4 = getpx(img)
P = P4[:, :, :3].astype(np.float64)
total = np.zeros_like(P)
touched = np.zeros(P.shape[:2], dtype=bool)
for bid, npx in sorted(blist, key=lambda t: -t[1]):
mb = lab == bid
res, diag = solve_level(P, orig_np, mb, f"{label}#{bid}")
if res is None:
log(f" {label} blob{bid}: skipped (no usable surrounding skin)")
continue
sy, sx, M, E = res
total[sy, sx][M] += E[M]
touched[sy, sx] |= M
log(f" {label} blob{bid}: {npx:7d} px border {diag['border']:6d} "
f"interior offset vs ring {diag['pre']:+.4f} -> correction mean "
f"{diag['Emean']:+.4f} (max |E| {diag['Emax']:.4f})")
out = np.clip(P + total, 0.0, 1.0)
# report the residual step across the patch border
b_in = touched & ~dil(~touched, 2)
b_out = dil(touched, 3) & ~touched
if b_in.any() and b_out.any():
log(f" {label}: border step before {abs(P[b_in].mean()-P[b_out].mean()):.4f} "
f"-> after {abs(out[b_in].mean()-out[b_out].mean()):.4f}")
P4[:, :, :3] = out.astype(np.float32)
img.pixels.foreach_set(P4.reshape(-1))
img.pack()
log(f" {label}: written + packed ({int(touched.sum())} texels corrected)")
return touched
tch = level_image(wired["base"], O, "basecolor")
# roughness/metallic: same levelling, so the patch stops reading as a different material
if "rm" in wired:
rm_orig = find_original("rm", wired["rm"])
if rm_orig is not None and tuple(wired["rm"].size) == (w, h):
level_image(wired["rm"], rm_orig[0], "rm")
else:
log("rm: no original copy or size mismatch — skipped")
# normal: the patch is perfectly flat; transplant skin grain so it stops reading as a decal
if DO_NORMAL_GRAIN and "normal" in wired and tuple(wired["normal"].size) == (w, h):
NM4 = getpx(wired["normal"])
NM = NM4[:, :, :3].astype(np.float64)
src_ok = ~dil(mask, 6)
def box1(a, r):
def b1(x, axis):
p = [(0, 0)] * x.ndim
p[axis] = (r, r)
cs = np.cumsum(np.pad(x, p, mode="edge"), axis=axis)
return (np.take(cs, np.arange(2 * r, cs.shape[axis]), axis=axis) -
np.take(cs, np.arange(0, cs.shape[axis] - 2 * r), axis=axis)) / (2 * r)
return b1(b1(a, 0), 1)
grain = np.stack([NM[:, :, c] - box1(NM[:, :, c], 5) for c in range(3)], axis=2)
cand = []
for ty in range(0, h - GRAIN_T, GRAIN_T):
for tx in range(0, w - GRAIN_T, GRAIN_T):
if src_ok[ty:ty + GRAIN_T, tx:tx + GRAIN_T].all():
cand.append((ty, tx))
rng = np.random.RandomState(1234)
cov = 0
for ty in range(0, h - GRAIN_T + 1, GRAIN_T):
for tx in range(0, w - GRAIN_T + 1, GRAIN_T):
tm = mask[ty:ty + GRAIN_T, tx:tx + GRAIN_T]
if not tm.any() or not cand:
continue
sy, sx = cand[rng.randint(len(cand))]
blk = NM[ty:ty + GRAIN_T, tx:tx + GRAIN_T]
blk[tm] += grain[sy:sy + GRAIN_T, sx:sx + GRAIN_T][tm]
cov += int(tm.sum())
NM4[:, :, :3] = np.clip(NM, 0, 1).astype(np.float32)
wired["normal"].pixels.foreach_set(NM4.reshape(-1))
wired["normal"].pack()
log(f"normal grain: {cov} texels from {len(cand)} clean tiles + packed")
bpy.ops.wm.save_as_mainfile(filepath=OUT)
log(f"WROTE {OUT}")
print("TONE_DONE")
@@ -0,0 +1,247 @@
# Stage 22 (feasibility test, writes only into its out dir): can we replace Tripo's 5,870-chart
# soup with a proper human atlas — a dozen anatomical charts, seams hidden where a character
# artist would put them (back midline, inner arm, inner leg, wrist/ankle/neck rings)?
#
# blender --background --python 22_unwrap_test.py -- <body.glb|blend> <out_dir> [decimate_ratio]
#
# Reports the same metrics as 20_atlas_probe so old and new atlas are directly comparable:
# island count, atlas coverage, texel-density spread, and per-chart stretch.
import bpy, bmesh, sys, os, time
import numpy as np
argv = sys.argv[sys.argv.index("--") + 1:]
SRC = argv[0]
OUTDIR = os.path.abspath(argv[1])
RATIO = float(argv[2]) if len(argv) > 2 else 0.0
os.makedirs(OUTDIR, exist_ok=True)
t0 = time.time()
def log(m):
print(f"[uw {time.time()-t0:6.1f}s] {m}", flush=True)
if SRC.lower().endswith(".glb"):
bpy.ops.wm.read_homefile(use_empty=True)
bpy.ops.import_scene.gltf(filepath=SRC)
else:
bpy.ops.wm.open_mainfile(filepath=SRC)
ob = max([o for o in bpy.data.objects if o.type == 'MESH'], key=lambda o: len(o.data.vertices))
bpy.context.view_layer.objects.active = ob
for o in bpy.data.objects:
o.select_set(o is ob)
log(f"body '{ob.name}' {len(ob.data.vertices)}v {len(ob.data.polygons)}f")
# glTF import leaves the object rotated/parented; work in world space
bpy.ops.object.transform_apply(location=True, rotation=True, scale=True)
me = ob.data
co = np.empty(len(me.vertices) * 3); me.vertices.foreach_get("co", co); co = co.reshape(-1, 3)
lo, hi = co.min(axis=0), co.max(axis=0)
print(f"BBOX x {lo[0]:.3f}..{hi[0]:.3f} y {lo[1]:.3f}..{hi[1]:.3f} z {lo[2]:.3f}..{hi[2]:.3f}")
span = hi - lo
UP = int(np.argmax(span))
LR = int(np.argmax(np.where(np.arange(3) == UP, -1, span)))
FB = 3 - UP - LR
print(f"axes: up={'xyz'[UP]} span {span[UP]:.3f} | left-right={'xyz'[LR]} span {span[LR]:.3f} "
f"| front-back={'xyz'[FB]} span {span[FB]:.3f}")
if RATIO > 0 and RATIO < 1:
m = ob.modifiers.new("dec", 'DECIMATE')
m.ratio = RATIO
bpy.ops.object.modifier_apply(modifier=m.name)
log(f"decimated -> {len(ob.data.vertices)}v {len(ob.data.polygons)}f")
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)
# normalise to a body frame: u = up in 0..1, s = signed left-right, d = signed front-back
u = (co[:, UP] - lo[UP]) / span[UP]
s = co[:, LR] - 0.5 * (lo[LR] + hi[LR])
d = co[:, FB] - 0.5 * (lo[FB] + hi[FB])
HALF = 0.5 * span[LR]
# where the limbs are, as fractions of the left-right span
print(f"|s| percentiles: " + " ".join(f"p{p}={np.percentile(np.abs(s),p)/HALF:.2f}"
for p in (50, 80, 90, 95, 99)))
print(f"u percentiles: " + " ".join(f"p{p}={np.percentile(u,p):.2f}" for p in (5, 25, 50, 75, 95)))
# ---- seam rules, all in body-frame fractions so they port between densities ----
ARM_IN = 0.34 * HALF # shoulder: |s| beyond this is arm
WRIST = 0.80 * HALF
NECK_U = 0.855 # above this is head
ANKLE_U = 0.055
CROTCH_U = 0.46
bm = bmesh.new()
bm.from_mesh(me)
bm.verts.ensure_lookup_table()
V = np.array([v.co for v in bm.verts])
uu = (V[:, UP] - lo[UP]) / span[UP]
ss = V[:, LR] - 0.5 * (lo[LR] + hi[LR])
dd = V[:, FB] - 0.5 * (lo[FB] + hi[FB])
is_arm = np.abs(ss) > ARM_IN
is_head = uu > NECK_U
is_leg = uu < CROTCH_U
for e in bm.edges:
e.seam = False
n_seam = 0
for e in bm.edges:
a, b = e.verts[0].index, e.verts[1].index
# 1. rings: neck, wrists, ankles, crotch band -> separate head / hands / feet charts
if (uu[a] > NECK_U) != (uu[b] > NECK_U):
e.seam = True; n_seam += 1; continue
if (np.abs(ss[a]) > WRIST) != (np.abs(ss[b]) > WRIST):
e.seam = True; n_seam += 1; continue
if (uu[a] > ANKLE_U) != (uu[b] > ANKLE_U):
e.seam = True; n_seam += 1; continue
# 2. shoulder ring: torso | arm
if (np.abs(ss[a]) > ARM_IN) != (np.abs(ss[b]) > ARM_IN):
e.seam = True; n_seam += 1; continue
# 3. lengthwise cut so each tube can open flat:
# torso/head -> back midline; arms -> underside; legs -> inner side
if is_arm[a] and is_arm[b]:
if (dd[a] > 0) != (dd[b] > 0): # back of the arm
e.seam = True; n_seam += 1; continue
elif is_leg[a] and is_leg[b]:
sgn = 1.0 if ss[a] + ss[b] >= 0 else -1.0
if ((ss[a] * sgn) < (ss[b] * sgn)) and dd[a] > 0 and dd[b] > 0:
pass # handled by the midline test below
if (dd[a] > 0) != (dd[b] > 0) and np.abs(ss[a]) < 0.30 * HALF:
e.seam = True; n_seam += 1; continue
else:
if dd[a] > 0 and dd[b] > 0 and (ss[a] > 0) != (ss[b] > 0):
e.seam = True; n_seam += 1; continue
log(f"marked {n_seam} seam edges")
bm.to_mesh(me)
bm.free()
bpy.ops.object.mode_set(mode='EDIT')
bpy.ops.mesh.select_all(action='SELECT')
bpy.ops.uv.unwrap(method='ANGLE_BASED', margin=0.002)
log("unwrapped")
try:
bpy.ops.uv.pack_islands(rotate=True, margin=0.004, scale=True)
except TypeError:
bpy.ops.uv.pack_islands(margin=0.004)
log("packed")
bpy.ops.object.mode_set(mode='OBJECT')
# ---- metrics (same definitions as 20_atlas_probe) ----
me = ob.data
n_l = len(me.loops)
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)
n_f = len(me.polygons)
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
li = l_start[tri]
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
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
_, isl = np.unique(np.array([find(i) for i in range(n_uvv)]), return_inverse=True)
n_isl = isl.max() + 1
Puv = np.clip(uv, 0, 1)
tuv = np.stack([Puv[li], Puv[li + 1], Puv[li + 2]], axis=1)
auv = 0.5 * np.abs((tuv[:, 1, 0] - tuv[:, 0, 0]) * (tuv[:, 2, 1] - tuv[:, 0, 1])
- (tuv[:, 2, 0] - tuv[:, 0, 0]) * (tuv[:, 1, 1] - tuv[:, 0, 1]))
co = np.empty(n_v * 3); me.vertices.foreach_get("co", co); co = co.reshape(-1, 3)
P3 = co[np.stack([loops_v[li], loops_v[li + 1], loops_v[li + 2]], axis=1)]
a3 = 0.5 * np.linalg.norm(np.cross(P3[:, 1] - P3[:, 0], P3[:, 2] - P3[:, 0]), 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)
# real-world scale: body height in metres / up-span in mesh units
HEIGHT_M = 1.777
UNITM = HEIGHT_M / span[UP]
W = 4096
print(f"\n=== NEW ATLAS ===")
print(f"islands: {n_isl} (Tripo atlas: 5870)")
print(f"atlas UV area used: {isl_auv.sum()*100:.1f}% (Tripo: 62.0%)")
print(f"uv-vertices {n_uvv} for {n_v} verts -> {100.0*(n_uvv-n_v)/n_v:+.1f}% duplication")
o = np.argsort(-isl_auv)
cum = np.cumsum(isl_auv[o]) / max(isl_auv.sum(), 1e-12)
print(f" {int(np.searchsorted(cum,0.99))+1} islands cover 99% of the used area (Tripo: 84)")
print(f" islands with <20 faces: {int((isl_nf<20).sum())} (Tripo: 5763)")
dens = np.where(isl_a3 > 0, np.sqrt(np.maximum(isl_auv, 0) / np.maximum(isl_a3, 1e-12))
* W / (UNITM * 1000.0), np.nan)
big = o[:int(np.searchsorted(cum, 0.99)) + 1]
db = dens[big]; db = db[np.isfinite(db)]
print(f"texel density over 99%-area islands: min {db.min():.2f} median {np.median(db):.2f} "
f"max {db.max():.2f} px/mm -> {db.max()/max(db.min(),1e-9):.1f}x spread (Tripo: 1.8x)")
print("\ntop 16 islands: # faces uv_area% 3D cm2 px/mm uv centre")
for i in o[:16]:
m = fisl == i
cu = tuv[m].reshape(-1, 2).mean(axis=0)
print(f" {i:5d} {isl_nf[i]:7d} {isl_auv[i]*100:8.3f} {isl_a3[i]*UNITM*UNITM*1e4:9.1f} "
f"{dens[i]:6.2f} ({cu[0]:.3f},{cu[1]:.3f})")
# per-triangle stretch: how anisotropic is the mapping (1.0 = conformal)
ok = (a3 > 1e-12) & (auv > 1e-14)
sc = np.sqrt(auv[ok] / a3[ok])
sc /= np.median(sc)
print(f"\narea-scale ratio vs median, per triangle: p05 {np.percentile(sc,5):.2f} "
f"p50 {np.percentile(sc,50):.2f} p95 {np.percentile(sc,95):.2f} "
f"p99 {np.percentile(sc,99):.2f}")
# ---- picture of the new layout ----
R = 1024
rng = np.random.RandomState(3)
pal = rng.rand(n_isl, 3) * 0.75 + 0.2
IS = np.zeros((R, R, 3))
tp = tuv * (R - 1)
for fi in range(len(tp)):
P = tp[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 ins.any():
IS[gy[ins], gx[ins]] = pal[fisl[fi]]
img = bpy.data.images.new("newislands", R, R, alpha=False)
a = np.ones((R, R, 4), dtype=np.float32)
a[:, :, :3] = IS
img.pixels.foreach_set(a.reshape(-1))
p = os.path.join(OUTDIR, "new_islands.png")
img.file_format = 'PNG'
img.filepath_raw = p
img.save(filepath=p)
log(f"wrote {p}")
bpy.context.preferences.filepaths.save_version = 0 # no .blend1 autosave
bpy.ops.wm.save_as_mainfile(filepath=os.path.join(OUTDIR, "unwrapped.blend"))
print("UNWRAP_TEST_DONE")
@@ -0,0 +1,81 @@
# Stage 23 (read-only): how many CONNECTED COMPONENTS does the mesh have?
# An atlas can never have fewer charts than the mesh has shells. If Tripo's mesh is a soup of
# disconnected shells, the chart soup is a symptom, not the disease — and a new atlas has to
# start by stitching the mesh.
#
# blender --background --python 23_shells.py -- <mesh.glb|blend>
import bpy, sys, time
import numpy as np
argv = sys.argv[sys.argv.index("--") + 1:]
SRC = argv[0]
t0 = time.time()
if SRC.lower().endswith(".glb"):
bpy.ops.wm.read_homefile(use_empty=True)
bpy.ops.import_scene.gltf(filepath=SRC)
else:
bpy.ops.wm.open_mainfile(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
n_v, n_f = len(me.vertices), len(me.polygons)
print(f"mesh '{ob.name}': {n_v}v {n_f}f")
ev = np.empty(len(me.edges) * 2, dtype=np.int32); me.edges.foreach_get("vertices", ev)
ev = ev.reshape(-1, 2)
parent = np.arange(n_v, 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 in ev:
ra, rb = find(a), find(b)
if ra != rb:
parent[rb] = ra
roots = np.array([find(i) for i in range(n_v)])
_, comp, sizes = np.unique(roots, return_inverse=True, return_counts=True)
print(f"\nTOPOLOGICAL SHELLS (edge-connected): {len(sizes)}")
o = np.argsort(-sizes)
print(f" largest {sizes[o[0]]} verts ({100.0*sizes[o[0]]/n_v:.1f}% of the mesh)")
print(f" shells >1000 verts: {int((sizes>1000).sum())} "
f">100: {int((sizes>100).sum())} >10: {int((sizes>10).sum())} "
f"<=10: {int((sizes<=10).sum())}")
print(f" top 15 shell sizes: {sizes[o[:15]].tolist()}")
print(f" verts outside the largest shell: {n_v - sizes[o[0]]} "
f"({100.0*(n_v-sizes[o[0]])/n_v:.2f}%)")
# how far apart are the shells really? if a small shell sits flush against the big one,
# a merge-by-distance would stitch it — report the gap.
if len(sizes) > 1:
from mathutils import Vector
from mathutils.kdtree import KDTree
co = np.empty(n_v * 3); me.vertices.foreach_get("co", co); co = co.reshape(-1, 3)
span = co.max(axis=0) - co.min(axis=0)
UNITM = 1.777 / span.max()
big = comp == comp[np.argmax(np.bincount(comp))]
bidx = np.nonzero(big)[0][::7]
kd = KDTree(len(bidx))
for j, i in enumerate(bidx):
kd.insert(Vector(co[i]), j)
kd.balance()
gaps = []
others = np.nonzero(~big)[0]
for i in others[::max(1, len(others) // 3000)]:
_, _, dist = kd.find(Vector(co[i]))
gaps.append(dist * UNITM * 1000.0)
gaps = np.array(gaps)
if len(gaps):
print(f"\n gap from off-shell verts to the main shell (real mm, sampled {len(gaps)}):")
for p in (50, 75, 90, 99):
print(f" p{p:<2d} {np.percentile(gaps,p):.3f} mm")
for t in (0.05, 0.2, 0.5, 1.0, 2.0):
print(f" within {t:4.2f} mm: {100.0*(gaps<t).mean():5.1f}%")
print("SHELLS_DONE")
@@ -0,0 +1,428 @@
# Stage 24: a proper human UV atlas — ~10 anatomical charts instead of Tripo's 5,870 blobs.
#
# blender --background --python 24_seams.py -- <mesh.glb|blend> <out_dir> [decimate_ratio]
#
# Every seam is placed off a MEASURED landmark, not a guessed threshold, so the same rules port
# between the hires sculpt and the decimated game body:
# neck / wrist / ankle = local minimum of cross-section radius (the narrow part)
# shoulder = smallest |s| whose slice is short in u (arm, not torso)
# hip = lowest slice that still has vertices on the midline (the crotch)
# Lengthwise cuts open each tube flat, hidden where nobody looks:
# torso + head -> back midline; arms -> back of the arm; legs -> inner side.
# Hands / feet / head need no lengthwise cut: a tube with one closed end is already a disc.
import bpy, bmesh, sys, os, time
import numpy as np
argv = sys.argv[sys.argv.index("--") + 1:]
SRC = argv[0]
OUTDIR = os.path.abspath(argv[1])
RATIO = float(argv[2]) if len(argv) > 2 else 0.0
os.makedirs(OUTDIR, exist_ok=True)
t0 = time.time()
def log(m):
print(f"[seam {time.time()-t0:6.1f}s] {m}", flush=True)
if SRC.lower().endswith(".glb"):
bpy.ops.wm.read_homefile(use_empty=True)
bpy.ops.import_scene.gltf(filepath=SRC)
else:
bpy.ops.wm.open_mainfile(filepath=SRC)
ob = max([o for o in bpy.data.objects if o.type == 'MESH'], key=lambda o: len(o.data.vertices))
bpy.context.view_layer.objects.active = ob
for o in bpy.data.objects:
o.select_set(o is ob)
bpy.ops.object.transform_apply(location=True, rotation=True, scale=True)
log(f"body '{ob.name}' {len(ob.data.vertices)}v {len(ob.data.polygons)}f")
if 0 < RATIO < 1:
m = ob.modifiers.new("dec", 'DECIMATE')
m.ratio = RATIO
bpy.ops.object.modifier_apply(modifier=m.name)
log(f"decimated -> {len(ob.data.vertices)}v {len(ob.data.polygons)}f")
# The source mesh is non-manifold (1,649 edges with >2 faces) and decimation turns that into a
# scatter of degenerate/duplicate triangles. Each one becomes its own UV island — that is where
# the confetti comes from, not the seam rules. Clean it here, before any of it reaches the unwrap.
def clean_mesh(tag):
bpy.ops.object.mode_set(mode='EDIT')
bpy.ops.mesh.select_all(action='SELECT')
bpy.ops.mesh.remove_doubles(threshold=1e-5)
bpy.ops.mesh.dissolve_degenerate(threshold=1e-6)
bpy.ops.mesh.delete_loose(use_verts=True, use_edges=True, use_faces=False)
# (Splitting the 1,649 non-manifold edges was tried here and rejected: it cost +3,512 verts
# and made the collapsed-face count worse, because the collapse is a solver problem, not a
# topology one — see the unwrap method below.)
bpy.ops.mesh.normals_make_consistent(inside=False)
bpy.ops.object.mode_set(mode='OBJECT')
bm_ = bmesh.new(); bm_.from_mesh(ob.data)
nm = sum(1 for e in bm_.edges if len(e.link_faces) > 2)
dg = sum(1 for f in bm_.faces if f.calc_area() < 1e-12)
bm_.free()
log(f"clean[{tag}]: {len(ob.data.vertices)}v {len(ob.data.polygons)}f "
f"nonmanifold_edges={nm} degenerate_faces={dg}")
clean_mesh("after decimate")
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)
lo, hi = co.min(axis=0), co.max(axis=0)
span = hi - lo
UP = int(np.argmax(span))
LR = int(np.argmax(np.where(np.arange(3) == UP, -1, span)))
FB = 3 - UP - LR
u = (co[:, UP] - lo[UP]) / span[UP]
s = co[:, LR] - 0.5 * (lo[LR] + hi[LR])
d = co[:, FB] - 0.5 * (lo[FB] + hi[FB])
HALF = 0.5 * span[LR]
sn = s / HALF # left-right, normalised to +-1
print(f"axes up={'xyz'[UP]} lr={'xyz'[LR]} fb={'xyz'[FB]} height {span[UP]:.4f} units")
# ---------------------------------------------------------------- landmarks
def radius_profile(mask, coord, lo_c, hi_c, nb):
"""mean in-slice radius vs coord, over `nb` bins — the narrow parts are the joints."""
ed = np.linspace(lo_c, hi_c, nb + 1)
mid = 0.5 * (ed[:-1] + ed[1:])
out = np.full(nb, np.nan)
for i in range(nb):
m = mask & (coord >= ed[i]) & (coord < ed[i + 1])
if m.sum() < 30:
continue
A = np.stack([s[m], d[m], u[m]], axis=1)
A = np.delete(A, 0 if coord is sn else 2, axis=1) if False else A
# radius measured in the two axes perpendicular to `coord`
if coord is u:
P = np.stack([s[m], d[m]], axis=1)
else:
P = np.stack([d[m], (u[m] - u[m].mean()) * span[UP]], axis=1)
out[i] = np.linalg.norm(P - P.mean(axis=0), axis=1).mean()
return mid, out
def local_min(mid, prof, lo_c, hi_c):
m = (mid >= lo_c) & (mid <= hi_c) & np.isfinite(prof)
if not m.any():
return 0.5 * (lo_c + hi_c)
return float(mid[m][np.argmin(prof[m])])
def taper_end(mid, prof, lo_c, hi_c, from_high, tol=0.08):
"""Where the limb stops tapering, taken from the EXTREMITY side.
The global minimum of the radius profile is not the joint: on the leg it sits up the shin,
well above where the foot ends. The joint is the end of the taper nearest the extremity —
the first bin (walking in from that side) that reaches within `tol` of the minimum.
"""
m = (mid >= lo_c) & (mid <= hi_c) & np.isfinite(prof)
if not m.any():
return 0.5 * (lo_c + hi_c)
mm, pp = mid[m], prof[m]
close = np.nonzero(pp <= pp.min() * (1.0 + tol))[0]
return float(mm[close[-1] if from_high else close[0]])
torso_side = np.abs(sn) < 0.30
mid_u, prof_u = radius_profile(torso_side, u, 0.0, 1.0, 60)
NECK_U = local_min(mid_u, prof_u, 0.80, 0.93)
# The ankle must be measured on ONE leg. Over both, the "radius" is really the gap between
# them, which falls monotonically from the feet up and has no minimum to find.
one_leg = (sn > 0.05) & (u < 0.35)
mid_l, prof_l = radius_profile(one_leg, u, 0.0, 0.35, 35)
print(" single-leg radius profile (u -> radius), the ankle is the narrow point:")
print(" " + " ".join(f"{m:.2f}:{r*1000:.0f}" for m, r in zip(mid_l, prof_l)
if np.isfinite(r)))
ANKLE_U = taper_end(mid_l, prof_l, 0.02, 0.14, from_high=False)
# shoulder: the smallest |s| whose slice is SHORT in u (an arm), scanning outward
absn = np.abs(sn)
ARM_IN = 0.35
for cut in np.arange(0.15, 0.60, 0.01):
m = (absn >= cut) & (absn < cut + 0.03)
if m.sum() < 30:
continue
if (u[m].max() - u[m].min()) < 0.13:
ARM_IN = float(cut)
break
mid_a, prof_a = radius_profile(absn > ARM_IN, absn, ARM_IN, 1.0, 40)
print(" arm radius profile (|s| -> radius), the wrist is the narrow point before the hand:")
print(" " + " ".join(f"{m:.2f}:{r*1000:.0f}" for m, r in zip(mid_a, prof_a)
if np.isfinite(r)))
# The profile reads: radius falls to the wrist, bulges again over the palm, then tapers down
# the fingers. Search below the palm bulge or the "wrist" lands among the fingers.
WRIST = taper_end(mid_a, prof_a, 0.60, 0.80, from_high=True)
# hip: lowest slice that still has vertices on the midline -> that is the crotch
HIP_U = 0.45
for lv in np.arange(0.60, 0.20, -0.005):
m = (u >= lv) & (u < lv + 0.01) & (absn < 0.025)
if m.sum() < 3:
HIP_U = float(lv + 0.01)
break
print(f"LANDMARKS neck_u {NECK_U:.3f} hip_u {HIP_U:.3f} ankle_u {ANKLE_U:.3f} "
f"arm_in {ARM_IN:.3f} wrist {WRIST:.3f} (fractions of height / half-span)")
is_arm = absn > ARM_IN
is_hand = absn > WRIST
is_head = u > NECK_U
is_leg = (u < HIP_U) & ~is_arm
is_foot = u < ANKLE_U
# centre lines for the lengthwise cuts
# A constant centre works only for a perfectly axis-aligned limb. Hers droop, so a level set of
# u wanders off the arm and cuts it twice. Use a measured centre LINE: median u per |s| bin for
# the arms, median |s| per u bin for each leg, linearly interpolated.
def centre_line(mask, along, of, nb, lo_a, hi_a):
ed = np.linspace(lo_a, hi_a, nb + 1)
mid = 0.5 * (ed[:-1] + ed[1:])
val = np.full(nb, np.nan)
for i in range(nb):
m = mask & (along >= ed[i]) & (along < ed[i + 1])
if m.sum() >= 20:
val[i] = np.median(of[m])
ok = np.isfinite(val)
if ok.sum() < 2:
return lambda q: np.full_like(q, np.nanmedian(of[mask]) if mask.any() else 0.0)
mid_o, val_o = mid[ok], val[ok]
return lambda q: np.interp(np.asarray(q), mid_o, val_o)
arm_u_of = centre_line(is_arm & ~is_hand, absn, u, 24, ARM_IN, WRIST)
leg_s_of = {}
for sg in (-1, 1):
m = is_leg & ~is_foot & (np.sign(sn) == sg)
leg_s_of[sg] = centre_line(m, u, absn, 20, ANKLE_U, HIP_U)
# A foot cut at the ankle is an L (ankle-heel-toes) and a hand is a flat paddle; neither
# flattens as one chart. Split each along its silhouette, exactly where an artist would:
# the foot into upper/sole at its mid-height, the hand into back/palm at its mid-thickness.
U_SOLE = float(np.median(u[is_foot])) if is_foot.sum() else ANKLE_U * 0.5
D_PALM = float(np.median(d[is_hand])) if is_hand.sum() else 0.0
print(f" foot split at u {U_SOLE:.3f} (upper|sole) hand split at d {D_PALM:+.4f} (back|palm)")
qa = np.linspace(ARM_IN, WRIST, 5)
print(f" arm centre line u at |s|={np.round(qa,2).tolist()}: "
f"{np.round(arm_u_of(qa), 3).tolist()}")
ql = np.linspace(ANKLE_U, HIP_U, 5)
print(f" leg(+) centre line |s| at u={np.round(ql,2).tolist()}: "
f"{np.round(leg_s_of[1](ql), 3).tolist()}")
# The torso is a tube with FOUR holes (neck, two armholes, hip). The back midline joins neck to
# hip; two more cuts are needed or the chart stays multiply-connected and the unwrap stretches it
# badly. Run a relief cut across the BACK at armpit height, from each armhole in to the midline.
# upper body only — at |s| = ARM_IN the feet also splay out that far, and they win a p5
m_pit = (np.abs(absn - ARM_IN) < 0.03) & (u > 0.5)
U_PIT = float(np.percentile(u[m_pit], 5)) if m_pit.sum() > 30 else 0.62
print(f" armpit u {U_PIT:.3f} -> relief cut across the back at that height")
# ---------------------------------------------------------------- mark seams
bm = bmesh.new()
bm.from_mesh(me)
tally = {}
def hit(k):
tally[k] = tally.get(k, 0) + 1
return True
for e in bm.edges:
e.seam = False
for e in bm.edges:
a, b = e.verts[0].index, e.verts[1].index
# rings, in order of priority
if is_head[a] != is_head[b]:
e.seam = hit("neck ring"); continue
if is_hand[a] != is_hand[b]:
e.seam = hit("wrist rings"); continue
if is_foot[a] != is_foot[b]:
e.seam = hit("ankle rings"); continue
if is_arm[a] != is_arm[b]:
e.seam = hit("shoulder rings"); continue
if (u[a] < HIP_U) != (u[b] < HIP_U):
e.seam = hit("hip ring"); continue
# lengthwise cuts
if is_foot[a] and is_foot[b]:
if (u[a] > U_SOLE) != (u[b] > U_SOLE):
e.seam = hit("foot sole line"); continue
elif is_hand[a] and is_hand[b]:
if (d[a] > D_PALM) != (d[b] > D_PALM):
e.seam = hit("hand palm line"); continue
elif is_arm[a] and is_arm[b] and not (is_hand[a] or is_hand[b]):
ca, cb = arm_u_of(absn[a]), arm_u_of(absn[b])
if d[a] > 0 and d[b] > 0 and (u[a] > ca) != (u[b] > cb):
e.seam = hit("arm back line"); continue
elif is_leg[a] and is_leg[b] and not (is_foot[a] or is_foot[b]):
sg = int(1 if sn[a] + sn[b] >= 0 else -1)
if absn[a] < leg_s_of[sg](u[a]) and absn[b] < leg_s_of[sg](u[b]) \
and (d[a] > 0) != (d[b] > 0):
e.seam = hit("leg inner line"); continue
elif not (is_arm[a] or is_arm[b] or is_foot[a] or is_foot[b]):
# torso and head share one continuous back midline
if d[a] > 0 and d[b] > 0 and (sn[a] > 0) != (sn[b] > 0):
e.seam = hit("back midline"); continue
# armpit relief: back only, from each armhole inward to the midline
if d[a] > 0 and d[b] > 0 and not is_head[a] and not is_head[b] \
and (u[a] > U_PIT) != (u[b] > U_PIT):
e.seam = hit("armpit relief"); continue
for k in sorted(tally):
print(f" seam '{k}': {tally[k]} edges")
bm.to_mesh(me)
bm.free()
log(f"marked {sum(tally.values())} seam edges")
# ---------------------------------------------------------------- unwrap
UNWRAP_METHOD = os.environ.get("UNWRAP_METHOD", "MINIMUM_STRETCH")
def do_unwrap(pack):
bpy.ops.object.mode_set(mode='EDIT')
bpy.ops.mesh.select_all(action='SELECT')
# Angle-based (ABF) is conformal: it preserves angles and is free to crush area. At her folds
# and flaps that means whole patches land under one texel and come out untextured. The
# minimum-stretch (SLIM) solver optimises area distortion instead, which is what a texture
# transfer actually needs.
try:
bpy.ops.uv.unwrap(method=UNWRAP_METHOD, margin=0.0)
except TypeError:
log(f"unwrap method {UNWRAP_METHOD} unavailable — falling back to ANGLE_BASED")
bpy.ops.uv.unwrap(method='ANGLE_BASED', margin=0.0)
bpy.ops.uv.average_islands_scale()
if pack:
try:
bpy.ops.uv.pack_islands(rotate=True, margin=0.003, scale=True)
except TypeError:
bpy.ops.uv.pack_islands(margin=0.003)
bpy.ops.object.mode_set(mode='OBJECT')
def get_islands():
m_ = ob.data
n_l_, n_f_ = len(m_.loops), len(m_.polygons)
lv = np.empty(n_l_, dtype=np.int32); m_.loops.foreach_get("vertex_index", lv)
uv_ = np.empty(n_l_ * 2); m_.uv_layers.active.data.foreach_get("uv", uv_)
uv_ = uv_.reshape(-1, 2)
ls = np.empty(n_f_, dtype=np.int32); m_.polygons.foreach_get("loop_start", ls)
lt = np.empty(n_f_, dtype=np.int32); m_.polygons.foreach_get("loop_total", lt)
li_ = ls[lt == 3]
Q = 1 << 20
k = (lv.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(k, return_inverse=True)
n_uvv_ = uvv_.max() + 1
T_ = np.stack([uvv_[li_], uvv_[li_ + 1], uvv_[li_ + 2]], axis=1)
par = np.arange(n_uvv_, dtype=np.int64)
def find(x):
r = x
while par[r] != r:
r = par[r]
while par[x] != r:
par[x], x = r, par[x]
return r
for a_, b_, c_ in T_:
ra, rb, rc = find(a_), find(b_), find(c_)
if ra != rb:
par[rb] = ra
if ra != rc:
par[rc] = ra
_, isl_ = np.unique(np.array([find(i) for i in range(n_uvv_)]), return_inverse=True)
return isl_, isl_[T_[:, 0]], li_, lv, uv_, isl_.max() + 1
# Keep the source UVs on a second layer. Stage 31 needs them to resample the existing maps into
# the new layout — unwrapping over them in place would throw the textures away.
src_layer = ob.data.uv_layers.active
src_layer.name = "UVMap_tripo"
new_layer = ob.data.uv_layers.new(name="UVMap_atlas", do_init=True)
ob.data.uv_layers.active = new_layer
for i, l in enumerate(ob.data.uv_layers):
if l is new_layer:
ob.data.uv_layers.active_index = i
log(f"uv layers: {[l.name for l in ob.data.uv_layers]} active="
f"{ob.data.uv_layers.active.name}")
do_unwrap(pack=True)
isl, fisl, li, loops_v, uv, n_isl = get_islands()
log("unwrapped + packed")
tuv = np.stack([np.clip(uv, 0, 1)[li], np.clip(uv, 0, 1)[li + 1], np.clip(uv, 0, 1)[li + 2]], 1)
auv = 0.5 * np.abs((tuv[:, 1, 0] - tuv[:, 0, 0]) * (tuv[:, 2, 1] - tuv[:, 0, 1])
- (tuv[:, 2, 0] - tuv[:, 0, 0]) * (tuv[:, 1, 1] - tuv[:, 0, 1]))
me = ob.data
co = np.empty(len(me.vertices) * 3); me.vertices.foreach_get("co", co); co = co.reshape(-1, 3)
P3 = co[np.stack([loops_v[li], loops_v[li + 1], loops_v[li + 2]], axis=1)]
a3 = 0.5 * np.linalg.norm(np.cross(P3[:, 1] - P3[:, 0], P3[:, 2] - P3[:, 0]), axis=1)
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)
UNITM = 1.777 / span[UP]
W = 4096
dens = np.where(isl_a3 > 0, np.sqrt(np.maximum(isl_auv, 0) / np.maximum(isl_a3, 1e-12))
* W / (UNITM * 1000.0), np.nan)
o = np.argsort(-isl_auv)
cum = np.cumsum(isl_auv[o]) / max(isl_auv.sum(), 1e-12)
n99 = int(np.searchsorted(cum, 0.99)) + 1
print(f"\n=== NEW ATLAS (Tripo baseline in brackets) ===")
print(f"islands {n_isl} [5870] 99%-of-area islands {n99} [84]")
print(f"coverage {isl_auv.sum()*100:.1f}% [62.0] confetti <20 faces {int((isl_nf<20).sum())} [5763]")
db = dens[o[:n99]]; db = db[np.isfinite(db)]
print(f"texel density {db.min():.2f}..{db.max():.2f} px/mm -> {db.max()/max(db.min(),1e-9):.2f}x "
f"spread [1.8x]")
# distortion, measured only over the real charts — the confetti islands are degenerate
# triangles whose ratios are meaningless and would own the tail
real = np.isin(fisl, o[:n99])
ok = real & (a3 > 1e-12) & (auv > 1e-14)
sc = np.sqrt(auv[ok] / a3[ok]); sc /= np.median(sc)
print(f"per-triangle area-scale vs median, real charts only: p05 {np.percentile(sc,5):.2f} "
f"p95 {np.percentile(sc,95):.2f} p99 {np.percentile(sc,99):.2f} (1.0 = undistorted)")
print("\nthe charts: # faces uv_area% 3D cm2 stretch_p95 where")
for i in o[:16]:
if isl_auv[i] * 100 < 0.05:
break
m = fisl == i
mo = m & (a3 > 1e-12) & (auv > 1e-14)
st = np.sqrt(auv[mo] / a3[mo])
st = st / np.median(st) if len(st) else np.array([1.0])
vs = np.unique(np.stack([loops_v[li], loops_v[li + 1], loops_v[li + 2]], 1)[m].ravel())
lab = []
for nm, msk in (("head", is_head), ("hand", is_hand), ("foot", is_foot),
("arm", is_arm & ~is_hand), ("leg", is_leg & ~is_foot)):
if msk[vs].mean() > 0.6:
lab.append(nm)
side = "L" if sn[vs].mean() > 0.15 else ("R" if sn[vs].mean() < -0.15 else "mid")
print(f" {i:6d} {isl_nf[i]:8d} {isl_auv[i]*100:9.3f} {isl_a3[i]*UNITM*UNITM*1e4:9.1f} "
f"{np.percentile(st,95):11.2f} {'+'.join(lab) or 'torso'} {side}")
# picture
R = 1024
rng = np.random.RandomState(3)
pal = rng.rand(n_isl, 3) * 0.7 + 0.25
IS = np.zeros((R, R, 3))
tp = tuv * (R - 1)
for fi in range(len(tp)):
P = tp[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
dt = ((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(dt) < 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])) / dt
bb = ((P[2, 1] - P[0, 1]) * (gx - P[2, 0]) + (P[0, 0] - P[2, 0]) * (gy - P[2, 1])) / dt
ins = (aa >= 0) & (bb >= 0) & (1 - aa - bb >= 0)
if ins.any():
IS[gy[ins], gx[ins]] = pal[fisl[fi]]
img = bpy.data.images.new("isl", R, R, alpha=False)
A = np.ones((R, R, 4), dtype=np.float32); A[:, :, :3] = IS
img.pixels.foreach_set(A.reshape(-1))
p = os.path.join(OUTDIR, "charts.png")
img.file_format = 'PNG'; img.filepath_raw = p; img.save(filepath=p)
bpy.ops.wm.save_as_mainfile(filepath=os.path.join(OUTDIR, "seamed.blend"))
log(f"wrote {p} + seamed.blend")
print("SEAMS_DONE")
@@ -0,0 +1,305 @@
# Stage 25: the black dashes/speckles. Diagnose FIRST, then fix only what the numbers show.
#
# blender --background --python 25_speckle.py -- <in.blend> <out.blend> <review_dir> [--apply]
#
# WHY NOT "FILL THE HOLES" (that has now failed twice)
# The mesh has 830 boundary edges in 689 loops of 3-5 edges, plus 1649 non-manifold edges. Both
# bmesh.ops.holes_fill (per loop) and the edit-mode mesh.fill_holes operator left the count at
# exactly 830 — they refused every one. A 3-edge run of boundary that cannot be filled is not a
# closed triangular hole; it is an OPEN CHAIN, i.e. these are dangling flaps and slivers hanging
# off the surface, not perforations. Roughly 2 non-manifold edges per boundary edge fits that
# reading. So filling is the wrong verb; the candidates are flipped winding (a backfacing triangle
# renders black in EEVEE, which is exactly what a "black dash" looks like) and tiny stray shards.
#
# Tests, in order, all reported before anything is modified:
# 1. FLIPPED FACES — face normal against the locally smoothed vertex normal. >90 deg apart
# means the triangle faces inward and will render black.
# 2. STRAY SHARDS — connected components of the face graph. The body is one component; small
# components are debris and can be deleted outright.
# 3. SLIVERS — near-zero-area and extreme-aspect triangles, which shade unpredictably.
# --apply then: recalculate consistent winding, delete shards under SHARD_MAX faces, dissolve
# degenerate slivers. Nothing here moves a vertex, so the sculpt and the heals are untouched.
import bpy, bmesh, 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]
APPLY = "--apply" in argv
os.makedirs(REVIEW, exist_ok=True)
t0 = time.time()
SHARD_MAX = 200 # faces; the body itself is ~1.7M so this is unambiguous debris
UNIT_MM = 1815.0
def log(m):
print(f"[spk {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_f = len(me.polygons)
log(f"in: {n_v}v {n_f}f custom_normals={me.has_custom_normals}")
co = np.empty(n_v * 3)
me.vertices.foreach_get("co", co)
co = co.reshape(-1, 3)
vn = np.empty(n_v * 3)
me.vertices.foreach_get("normal", vn)
vn = vn.reshape(-1, 3)
fn = np.empty(n_f * 3)
me.polygons.foreach_get("normal", fn)
fn = fn.reshape(-1, 3)
fc = np.empty(n_f * 3)
me.polygons.foreach_get("center", fc)
fc = fc.reshape(-1, 3)
ev = np.empty(len(me.edges) * 2, dtype=np.int32)
me.edges.foreach_get("vertices", ev)
ev = ev.reshape(-1, 2)
# smoothed vertex normal field, as the reference for "which way is out"
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
N = vn.copy()
for _ in range(8):
a = np.add.reduceat(N[n_s], ptr[:-1], axis=0)
a[empty] = N[empty]
N = a / cnt[:, None]
N /= np.maximum(np.linalg.norm(N, axis=1, keepdims=True), 1e-12)
lv = np.empty(len(me.loops), dtype=np.int32)
me.loops.foreach_get("vertex_index", lv)
ls = np.empty(n_f, dtype=np.int32)
me.polygons.foreach_get("loop_start", ls)
lt = np.empty(n_f, dtype=np.int32)
me.polygons.foreach_get("loop_total", lt)
# reference normal per face = mean of its verts' smoothed normals
acc = np.zeros((n_f, 3))
for k in range(int(lt.max())):
sel = lt > k
acc[sel] += N[lv[ls[sel] + k]]
acc /= np.maximum(np.linalg.norm(acc, axis=1, keepdims=True), 1e-12)
fdot = (fn * acc).sum(axis=1)
flipped = fdot < 0.0
print(f"FLIPPED FACES: {int(flipped.sum())} of {n_f} "
f"({100.0*flipped.mean():.4f}%) [dot<0 vs smoothed field]")
print(f" nearly-perpendicular (|dot|<0.2): {int((np.abs(fdot)<0.2).sum())}")
if flipped.sum():
zs = fc[flipped, 2]
hist, edges = np.histogram(zs, bins=12)
print(" flipped-face z histogram:")
for c, lo, hi in zip(hist, edges[:-1], edges[1:]):
if c:
print(f" z {lo:.3f}-{hi:.3f}: {c}")
# ---- stray shards ----
bm = bmesh.new()
bm.from_mesh(me)
bm.faces.ensure_lookup_table()
seen = np.zeros(len(bm.faces), dtype=bool)
comps = []
from collections import deque
for f0 in bm.faces:
if seen[f0.index]:
continue
q = deque([f0])
seen[f0.index] = True
size = 0
members = []
while q:
f = q.popleft()
size += 1
members.append(f)
for e in f.edges:
for g in e.link_faces:
if not seen[g.index]:
seen[g.index] = True
q.append(g)
comps.append((size, members))
comps.sort(key=lambda t: -t[0])
print(f"FACE COMPONENTS: {len(comps)} (largest {[c[0] for c in comps[:5]]})")
shards = [c for c in comps if c[0] <= SHARD_MAX]
print(f" shards <= {SHARD_MAX} faces: {len(shards)} components, "
f"{sum(c[0] for c in shards)} faces total")
for size, mem in shards[:10]:
ctr = Vector((0, 0, 0))
for f in mem:
ctr += f.calc_center_median()
ctr /= len(mem)
print(f" shard {size:4d} faces at ({ctr.x:+.3f},{ctr.y:+.3f},{ctr.z:+.3f})")
areas = np.array([f.calc_area() for f in bm.faces])
tiny = areas < (1e-5 ** 2)
print(f"SLIVERS: {int(tiny.sum())} faces with area < (0.01 mm-unit)^2; "
f"min area {areas.min():.3e}, p01 {np.percentile(areas,1):.3e}")
if APPLY:
# delete shards
if shards:
geom = [f for _, mem in shards for f in mem]
bmesh.ops.delete(bm, geom=geom, context='FACES')
log(f"deleted {len(geom)} shard faces")
# drop degenerate slivers
bm.faces.ensure_lookup_table()
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")
bmesh.ops.delete(bm, geom=[v for v in bm.verts if not v.link_faces], context='VERTS')
bm.to_mesh(me)
bm.free()
me.update()
# consistent winding — this is what actually removes backfacing black dashes
bpy.context.view_layer.objects.active = ob
ob.select_set(True)
bpy.ops.object.mode_set(mode='EDIT')
bpy.ops.mesh.select_all(action='SELECT')
bpy.ops.mesh.normals_make_consistent(inside=False)
bpy.ops.object.mode_set(mode='OBJECT')
me.update()
log(f"recalculated winding; now {len(me.vertices)}v {len(me.polygons)}f")
# re-measure
n_f2 = len(me.polygons)
fn2 = np.empty(n_f2 * 3)
me.polygons.foreach_get("normal", fn2)
fn2 = fn2.reshape(-1, 3)
lv2 = np.empty(len(me.loops), dtype=np.int32)
me.loops.foreach_get("vertex_index", lv2)
ls2 = np.empty(n_f2, dtype=np.int32)
me.polygons.foreach_get("loop_start", ls2)
lt2 = np.empty(n_f2, dtype=np.int32)
me.polygons.foreach_get("loop_total", lt2)
n_v2 = len(me.vertices)
vn2 = np.empty(n_v2 * 3)
me.vertices.foreach_get("normal", vn2)
vn2 = vn2.reshape(-1, 3)
acc2 = np.zeros((n_f2, 3))
for k in range(int(lt2.max())):
sel = lt2 > k
acc2[sel] += vn2[lv2[ls2[sel] + k]]
acc2 /= np.maximum(np.linalg.norm(acc2, axis=1, keepdims=True), 1e-12)
fl2 = ((fn2 * acc2).sum(axis=1) < 0)
print(f"FLIPPED FACES after make_consistent: {int(fl2.sum())} of {n_f2}")
# make_consistent propagates orientation across shared edges, so the 1649 non-manifold edges
# block it — it only got 1511 down to 1198. The remainder are individually wrong relative to
# their own neighbourhood, so reverse exactly those: it moves no vertex and each reversal
# brings that triangle into agreement with the faces around it.
for rnd in range(3):
n_f3 = len(me.polygons)
fn3 = np.empty(n_f3 * 3)
me.polygons.foreach_get("normal", fn3)
fn3 = fn3.reshape(-1, 3)
lv3 = np.empty(len(me.loops), dtype=np.int32)
me.loops.foreach_get("vertex_index", lv3)
ls3 = np.empty(n_f3, dtype=np.int32)
me.polygons.foreach_get("loop_start", ls3)
lt3 = np.empty(n_f3, dtype=np.int32)
me.polygons.foreach_get("loop_total", lt3)
nv3 = len(me.vertices)
vv3 = np.empty(nv3 * 3)
me.vertices.foreach_get("normal", vv3)
vv3 = vv3.reshape(-1, 3)
ac3 = np.zeros((n_f3, 3))
for k in range(int(lt3.max())):
sel = lt3 > k
ac3[sel] += vv3[lv3[ls3[sel] + k]]
ac3 /= np.maximum(np.linalg.norm(ac3, axis=1, keepdims=True), 1e-12)
bad = np.nonzero((fn3 * ac3).sum(axis=1) < 0)[0]
if len(bad) == 0:
log(f"reversal round {rnd}: none left")
break
bm2 = bmesh.new()
bm2.from_mesh(me)
bm2.faces.ensure_lookup_table()
bmesh.ops.reverse_faces(bm2, faces=[bm2.faces[int(i)] for i in bad])
bm2.to_mesh(me)
bm2.free()
me.update()
log(f"reversal round {rnd}: reversed {len(bad)} faces")
n_f4 = len(me.polygons)
fn4 = np.empty(n_f4 * 3)
me.polygons.foreach_get("normal", fn4)
fn4 = fn4.reshape(-1, 3)
lv4 = np.empty(len(me.loops), dtype=np.int32)
me.loops.foreach_get("vertex_index", lv4)
ls4 = np.empty(n_f4, dtype=np.int32)
me.polygons.foreach_get("loop_start", ls4)
lt4 = np.empty(n_f4, dtype=np.int32)
me.polygons.foreach_get("loop_total", lt4)
vv4 = np.empty(len(me.vertices) * 3)
me.vertices.foreach_get("normal", vv4)
vv4 = vv4.reshape(-1, 3)
ac4 = np.zeros((n_f4, 3))
for k in range(int(lt4.max())):
sel = lt4 > k
ac4[sel] += vv4[lv4[ls4[sel] + k]]
ac4 /= np.maximum(np.linalg.norm(ac4, axis=1, keepdims=True), 1e-12)
print(f"FLIPPED FACES final: {int(((fn4*ac4).sum(axis=1) < 0).sum())} of {n_f4}")
n_v2 = len(me.vertices)
vnn = np.empty(n_v2 * 3, dtype=np.float32)
me.vertices.foreach_get("normal", vnn)
me.normals_split_custom_set_from_vertices(vnn.reshape(-1, 3))
# SAVE BEFORE RENDERING. The render helper swaps clay into the material slots, and 18/20/25
# originally saved afterwards — which is how 23_cleavage.blend lost its texture wiring and
# made 21_tone.py abort. Saving first means the file on disk always keeps the real material.
bpy.ops.wm.save_as_mainfile(filepath=OUT)
log(f"WROTE {OUT}")
else:
bm.free()
# ---- 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}")
shoot("chest_clay_0", (0.0, 0.0, 0.675), 0.22, 0)
shoot("chest_clay_40", (0.0, 0.0, 0.675), 0.22, 40)
shoot("chest_tex_0", (0.0, 0.0, 0.675), 0.22, 0, False)
shoot("hip_clay_0", (0.0, 0.0, 0.53), 0.22, 0)
shoot("full_clay_0", (0.0, 0.0, 0.50), 0.55, 0)
shoot("full_tex_0", (0.0, 0.0, 0.50), 0.55, 0, False)
print("SPK_DONE")
@@ -0,0 +1,289 @@
# Stage 26: the right operator for the lines (ring-median despeckle) + a real cleavage fillet.
#
# blender --background --python 26_finish.py -- <in.blend> <out.blend> [iters] [alpha]
#
# PART A — LINES, with a median filter instead of a membrane.
# 19_wide_heal measured the seam cross-section: the relief is ONE VERTEX WIDE (|offset| 0.23 mm
# median at the centre and already back to the 0.074 mm background by ring 2). That measurement
# picks the operator, and it is not a membrane:
# * a collar-fixed membrane needs clean ground to stand on. With a 1-vertex defect the collar
# lands ON the defect's shoulders, so the interpolant faithfully reproduces what it is pinned
# to — which is why stages 11/14/17 moved 12k+ verts for almost no visible change, and why
# widening the band (GROW 4, 8) made it worse rather than better.
# * a MEDIAN over the 1-ring is exact for this defect class. Take the scalar offset of each
# vertex from the locally smooth surface and replace it with the median of its neighbourhood:
# an isolated 1-vertex ridge or groove is a rank outlier and is deleted completely, while a
# feature many vertices wide has offset ~= its own median and is returned UNCHANGED. So the
# underbust fold, the clavicles, the navel and the gluteal fold survive by construction
# rather than by a hand-tuned threshold — which is what every earlier stage got wrong.
# Only the normal component is filtered, so no vertex slides tangentially and the UV atlas stays
# valid. Displacement is clamped, as in stage 17, so nothing can reshape her.
#
# PART B — CLEAVAGE, with a blended membrane in the medial corridor.
# 20_cleavage's fill-only curvature filter took the sternum radius from 9.5 mm to only 15.8 mm and
# left the notch depth at 44 mm, because a uniform-Laplacian curvature test measures sharpness at
# the ~2 mm vertex scale and the defect is a 44 mm-deep macro V. Fixing macro shape needs an
# operator with macro reach: a bi-harmonic membrane across the corridor, which interpolates the
# cups' own slopes into a smooth valley. It is applied at a fraction alpha so the cleavage is
# rounded rather than erased — alpha=1 would bridge the cups into a web.
#
# NOTE ON A BUG THIS FIXES: 20_cleavage.py (and 18/25) render clay LAST and then save, so the
# saved .blend keeps the clay material in the slots and loses the texture wiring — that is why
# 21_tone.py aborted with "could not find the wired basecolor" on 23_cleavage.blend. Here the
# original materials are restored and the file is saved BEFORE any render.
import bpy, sys, time
import numpy as np
argv = sys.argv[sys.argv.index("--") + 1:]
BLEND, OUT = argv[0], argv[1]
ITERS = int(argv[2]) if len(argv) > 2 else 3
ALPHA = float(argv[3]) if len(argv) > 3 else 0.65
t0 = time.time()
UNIT_MM = 1815.0
SMOOTH_K = 6 # scale the offset is measured against
CLAMP_MM = 2.0 # lines are <=1.5 mm of relief
Z_LO, Z_HI = 0.04, 0.90
X_MAX = 0.36
# cleavage corridor
CZ0, CZ1 = 0.690, 0.782
CX = 0.028
COLLAR = 3
CLV_CLAMP_MM = 26.0 # the notch is 44 mm deep; allow a real fillet but not a bridge
def log(m):
print(f"[fin {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)
orig_mats = [ms.material for ms in ob.material_slots]
log(f"in: {n_v}v {len(me.polygons)}f mats={[m.name if m else None for m in orig_mats]}")
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
MAXV = min(int(np.diff(ptr).max()), 24)
log(f"valence: mean {np.diff(ptr).mean():.2f}, max {np.diff(ptr).max()} (using {MAXV})")
# padded neighbour index table for the ring median
cols = np.arange(MAXV)[None, :]
base = ptr[:-1][:, None]
lim = ptr[1:][:, None]
idx = np.minimum(base + cols, max(len(n_s) - 1, 0))
valid = (base + cols) < lim
nb_tab = n_s[idx]
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 ring_median(d):
"""Median of each vertex's 1-ring plus itself. Invalid slots -> nan, nanmedian ignores them."""
T = np.where(valid, d[nb_tab], np.nan)
T = np.concatenate([T, d[:, None]], axis=1)
return np.nanmedian(T, axis=1)
def vnormals():
nrm = np.empty(n_v * 3)
me.vertices.foreach_get("normal", nrm)
return nrm.reshape(-1, 3)
zone = (co[:, 2] > Z_LO) & (co[:, 2] < Z_HI) & (np.abs(co[:, 0]) < X_MAX)
log(f"zone: {int(zone.sum())} verts")
def kink_stats(P):
nrm = vnormals()
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)))
t = (P[:, 2] > 0.28) & (P[:, 2] < 0.90)
return [int((t & (ang > k)).sum()) for k in (6, 12, 20)]
log(f"kink [>6,>12,>20] before: {kink_stats(co)}")
# =============================================================================
# PART A — ring-median despeckle
# =============================================================================
P = co.copy()
for it in range(1, ITERS + 1):
me.vertices.foreach_set("co", P.reshape(-1))
me.update()
N = vnormals()
S = smooth_n(P, SMOOTH_K)
d = ((P - S) * N).sum(axis=1)
dm = ring_median(d)
delta = np.where(zone, dm - d, 0.0)
dmm = np.abs(delta) * UNIT_MM
over = dmm > CLAMP_MM
if over.any():
delta[over] = np.sign(delta[over]) * (CLAMP_MM / UNIT_MM)
Q = P + N * delta[:, None]
mv = np.abs(delta) * UNIT_MM
log(f"median pass {it}: {int((mv>0.01).sum())} verts adjusted, "
f"max {mv.max():.3f} mm, median(adjusted) "
f"{np.median(mv[mv>0.01]) if (mv>0.01).any() else 0:.3f} mm, clamped {int(over.sum())}")
P = Q
me.vertices.foreach_set("co", P.reshape(-1))
me.update()
log(f"kink [>6,>12,>20] after median: {kink_stats(P)}")
dA = np.linalg.norm(P - co, axis=1) * UNIT_MM
log(f"part A displacement: max {dA.max():.3f} mm, p99 {np.percentile(dA,99):.3f} mm")
# =============================================================================
# PART B — cleavage fillet
# =============================================================================
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 bilaplacian(X, free_m, collar_rings=COLLAR, maxit=8000):
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(Y):
out = deg[:, None] * Y
np.add.at(out, a_, -Y[b_])
np.add.at(out, b_, -Y[a_])
return out
def A_op(U):
Y = np.zeros((len(S), 3))
Y[free] = U
return Ls(Ls(Y))[free]
Xc = np.zeros((len(S), 3))
Xc[~free] = X[S[~free]]
rhs = -Ls(Ls(Xc))[free]
U = X[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
Y = X.copy()
Y[S[free]] = U
return Y, int(free.sum()), it, rs / rs0
def probe(X, tag):
front = X[:, 1] < 0
print(f"\n=== CLEAVAGE PROFILE [{tag}] ===")
print(" z sternum y fillet radius notch vs apex")
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(X[:, 0] - x0) < 0.0035) & (np.abs(X[:, 2] - z0) < 0.004)
row.append(X[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')
if len(seg) >= 3 and not np.isnan(seg).any():
d2 = (seg[:-2] - 2 * seg[1:-1] + seg[2:]) / (0.005 ** 2)
k = float(np.nanmax(d2))
rad = 1.0 / k if k > 1e-6 else float('inf')
ma = front & (np.abs(np.abs(X[:, 0]) - 0.034) < 0.005) & (np.abs(X[:, 2] - z0) < 0.004)
notch = ((row[mid] - X[ma, 1].min()) * UNIT_MM) if ma.sum() else np.nan
print(f" {z0:.3f} {row[mid]:+.4f} {rad*UNIT_MM:8.1f} mm {notch:+7.1f} mm"
+ (" <-- SHARP" if rad * UNIT_MM < 30 else ""))
probe(P, "before fillet")
corridor = (P[:, 1] < 0) & (np.abs(P[:, 0]) < CX) & (P[:, 2] > CZ0) & (P[:, 2] < CZ1)
log(f"corridor: {int(corridor.sum())} verts")
Q, nf, it, rel = bilaplacian(P, corridor)
disp = (Q - P) * ALPHA
dmag = np.linalg.norm(disp, axis=1) * UNIT_MM
ov = dmag > CLV_CLAMP_MM
if ov.any():
disp[ov] *= (CLV_CLAMP_MM / dmag[ov])[:, None]
P2 = P + disp
d = np.linalg.norm(P2 - P, axis=1) * UNIT_MM
log(f"fillet: {nf} verts, CG it={it} rel={rel:.1e}, alpha={ALPHA}, "
f"moved max {d.max():.2f} mm, median(corridor) {np.median(d[corridor]):.2f} mm, "
f"clamped {int(ov.sum())}")
me.vertices.foreach_set("co", P2.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))
probe(P2, "after fillet")
log(f"kink [>6,>12,>20] final: {kink_stats(P2)}")
dT = np.linalg.norm(P2 - co, axis=1) * UNIT_MM
log(f"TOTAL displacement: max {dT.max():.2f} mm, p99 {np.percentile(dT,99):.3f} mm, "
f"{int((dT>0.05).sum())} verts moved >0.05 mm")
# restore materials BEFORE saving (see header note) and save
for i, ms in enumerate(ob.material_slots):
ms.material = orig_mats[i]
bpy.ops.wm.save_as_mainfile(filepath=OUT)
log(f"WROTE {OUT} with materials {[m.name if m else None for m in orig_mats]}")
print("FIN_DONE")
Binary file not shown.
@@ -0,0 +1,256 @@
# Stage 29: level the repainted patches' tone using ON-BODY sampling (the only kind that works).
#
# blender --background --python 29_tone3d.py -- <in.blend> <out.blend> [orig.blend] [strength]
#
# WHY STAGE 21 FAILED AND WAS REJECTED (REJECTED_28_atlas_tone.blend)
# Stage 21 solved a Poisson correction whose boundary condition was the mismatch against texels
# ADJACENT IN THE ATLAS. That is the one mistake 05_texture.py's header explicitly warns about:
# "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". UV adjacency is not body
# adjacency: a patch border texel's atlas neighbours are frequently a different body part or empty
# gutter, so the boundary mismatch was garbage and the harmonic solve spread it over the whole
# patch. Result: the bra and briefs read as pale grey panels, far worse than the faint tone step we
# started from. The roughness pass compounded it (corrections up to +0.19 turned her plasticky).
#
# THIS STAGE DOES IT ON THE MESH.
# * a vertex is "garment" if its texel was repainted (mask = current vs pristine basecolor);
# * for each garment vertex, the target tone is an inverse-distance average of the 8 nearest
# SKIN vertices in 3D — real neighbours on the body, across UV seams, never a gutter;
# * correction = target - current, per vertex, then SMOOTHED over the mesh graph so only the
# low-frequency level is carried and the transplanted grain survives untouched;
# * the smoothed correction is rasterised over garment faces and added.
# Because the correction is smooth and vanishes where current tone already equals nearby skin, a
# well-matched region is left alone and only a genuine offset is removed.
#
# STRENGTH is deliberately conservative (default 0.75): the baseline is already close, and the
# failure mode of this whole family of fixes is overshoot.
import bpy, sys, os, 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]
ORIG_BLEND = argv[2] if len(argv) > 2 else "00_welded.blend"
STRENGTH = float(argv[3]) if len(argv) > 3 else 0.75
SMOOTH_ARG = int(argv[4]) if len(argv) > 4 else None
t0 = time.time()
DIFF_T = 0.02
SMOOTH_CORR = 120 # graph-smoothing passes on the correction field (low-frequency only)
KNN = 8
Z_LO, Z_HI = 0.25, 0.895 # sample skin on the body, never the face/lips/eyes
FEATHER = 3
def log(m):
print(f"[t3d {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)
# ---- pristine originals ----
bpy.ops.wm.open_mainfile(filepath=ORIG_BLEND)
CACHE = {}
for i in bpy.data.images:
nm = i.name.lower()
k = "base" if "basecolor" in nm else ("rm" if "_rm" in nm else None)
if k and k not in CACHE:
CACHE[k] = (getpx(i)[:, :, :3].astype(np.float32), tuple(i.size))
log(f"cached pristine: { {k: v[1] for k, v in 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))
me = ob.data
n_v = len(me.vertices)
wired = {}
for ms in ob.material_slots:
if not ms.material or not ms.material.node_tree:
continue
for n in ms.material.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:
wired["base"] = n.image
elif "separate" in tn:
wired["rm"] = n.image
log(f"body {n_v}v, wired { {k: v.name for k, v in wired.items()} }")
if "base" not in wired:
raise SystemExit("[t3d] FATAL: no wired basecolor")
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)
base = wired["base"]
B4 = getpx(base)
B = B4[:, :, :3].astype(np.float64)
h, w = B.shape[:2]
Orig = CACHE["base"][0]
mask = np.abs(B - Orig).max(axis=2) > DIFF_T
log(f"repainted texels: {int(mask.sum())} ({100.0*mask.sum()/(w*h):.2f}%)")
# ---- per-vertex UV -> texel, colour, and garment flag ----
lv = np.empty(len(me.loops), dtype=np.int32)
me.loops.foreach_get("vertex_index", lv)
uv = np.empty(len(me.loops) * 2)
me.uv_layers.active.data.foreach_get("uv", uv)
uv = uv.reshape(-1, 2)
px = np.clip(uv[:, 0], 0, 1) * (w - 1)
py = np.clip(uv[:, 1], 0, 1) * (h - 1)
pxi = px.astype(np.int32)
pyi = py.astype(np.int32)
first = np.full(n_v, -1, dtype=np.int64)
np.maximum.at(first, lv, np.arange(len(lv), dtype=np.int64))
has = first >= 0
vcol = np.zeros((n_v, 3))
vcol[has] = B[pyi[first[has]], pxi[first[has]]]
# a vertex is garment if ANY of its loops lands on a repainted texel
gv = np.zeros(n_v, dtype=bool)
np.logical_or.at(gv, lv, mask[pyi, pxi])
log(f"garment verts: {int(gv.sum())} of {n_v}")
zone = (co[:, 2] > Z_LO) & (co[:, 2] < Z_HI)
skin = (~gv) & zone & has
skin_idx = np.nonzero(skin)[0][::4]
log(f"skin sample for KD: {len(skin_idx)}")
kd = KDTree(len(skin_idx))
for j, i in enumerate(skin_idx):
kd.insert(Vector(co[i]), j)
kd.balance()
gidx = np.nonzero(gv & zone & has)[0]
target = np.zeros((n_v, 3))
for i in gidx:
hits = kd.find_n(Vector(co[i]), KNN)
wsum = 0.0
acc = np.zeros(3)
for (_, j, d) in hits:
wt = 1.0 / max(d * d, 1e-9)
acc += wt * vcol[skin_idx[j]]
wsum += wt
target[i] = acc / wsum
log("on-body target tones computed")
corr = np.zeros((n_v, 3))
corr[gidx] = target[gidx] - vcol[gidx]
pre = np.abs(corr[gidx]).mean(axis=0)
log(f"raw correction magnitude per channel: {pre.round(4)}")
# ---- smooth the correction over the mesh graph: keep only the level, not the detail ----
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
if SMOOTH_ARG is not None:
SMOOTH_CORR = SMOOTH_ARG
for _ in range(SMOOTH_CORR):
a = np.add.reduceat(corr[n_s], ptr[:-1], axis=0)
a[empty] = corr[empty]
corr = a / cnt[:, None]
corr *= STRENGTH
log(f"smoothed x{SMOOTH_CORR}, strength {STRENGTH}: "
f"mean |corr| on garment {np.abs(corr[gidx]).mean(axis=0).round(4)}, "
f"max {np.abs(corr[gidx]).max():.4f}")
# ---- on-body verification metric: garment interior tone vs the skin ring around it, in 3D ----
def grow(m, k):
x = m.copy()
for _ in range(k):
hit = x[ev[:, 0]] | x[ev[:, 1]]
y = x.copy()
y[ev[:, 0]] |= hit
y[ev[:, 1]] |= hit
x = y
return x
inner_v = gv & ~grow(~gv, 6)
ring_v = grow(gv, 8) & ~gv & zone
if inner_v.sum() and ring_v.sum():
a0 = vcol[inner_v].mean(axis=0)
r0 = vcol[ring_v].mean(axis=0)
a1 = (vcol[inner_v] + corr[inner_v]).mean(axis=0)
log(f"ON-BODY tone gap (garment interior - surrounding skin):")
log(f" before {(a0-r0).round(4)} |gap| luma {abs(a0.mean()-r0.mean()):.4f}")
log(f" after {(a1-r0).round(4)} |gap| luma {abs(a1.mean()-r0.mean()):.4f}")
# ---- rasterise the correction over garment faces ----
n_f = len(me.polygons)
ls = np.empty(n_f, dtype=np.int32)
me.polygons.foreach_get("loop_start", ls)
lt = np.empty(n_f, dtype=np.int32)
me.polygons.foreach_get("loop_total", lt)
face_g = np.add.reduceat(gv[lv].astype(np.int32), ls) > 0
log(f"garment faces: {int(face_g.sum())}")
out = B.copy()
painted = np.zeros((h, w), dtype=bool)
for fi in np.nonzero(face_g)[0]:
s, t = int(ls[fi]), int(lt[fi])
li = np.arange(s, min(s + t, s + 3))
P = np.stack([px[li], py[li]], axis=1)
V = lv[li]
if len(P) < 3:
continue
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 > 128 or y1 - y0 > 128 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.02) & (b >= -0.02) & (c >= -0.02)
if not ins.any():
continue
e = (a[ins, None] * corr[V[0]] + b[ins, None] * corr[V[1]] + c[ins, None] * corr[V[2]])
out[gy[ins], gx[ins]] += e
painted[gy[ins], gx[ins]] = True
log(f"rasterised correction over {int(painted.sum())} texels")
# only correct where the atlas was actually repainted, feathered at the rim
apply_m = painted & mask
alpha = apply_m.astype(np.float64)
edge = apply_m.copy()
for k in range(FEATHER):
g = edge.copy()
g[1:, :] |= edge[:-1, :]
g[:-1, :] |= edge[1:, :]
g[:, 1:] |= edge[:, :-1]
g[:, :-1] |= edge[:, 1:]
ring = g & ~edge
alpha[ring] = 1.0 - (k + 1) / (FEATHER + 1.0)
edge = g
final = np.clip(B * (1 - alpha[:, :, None]) + out * alpha[:, :, None], 0, 1)
log(f"applied to {int(apply_m.sum())} texels; "
f"mean shift {np.abs(final-B)[apply_m].mean():.5f}, max {np.abs(final-B).max():.4f}")
B4[:, :, :3] = final.astype(np.float32)
base.pixels.foreach_set(B4.reshape(-1))
base.pack()
log("basecolor written + packed")
bpy.ops.wm.save_as_mainfile(filepath=OUT)
log(f"WROTE {OUT}")
print("T3D_DONE")
Binary file not shown.
@@ -0,0 +1,223 @@
# Stage 30: decimate the v03 sculpt to the game-body vertex budget and export a clean GLB for the
# retexture / re-atlas lane.
#
# blender --background --python 30_decimate.py -- <in.blend> <out.glb> [target_verts] [review_dir]
#
# TARGET. Jeremy picked the shipped game body's density: `lena_nude_quatskin_glb_v01.glb` is
# 31,670 v, so that is the number to hit — not a round ratio.
#
# WHY A SEARCH INSTEAD OF A RATIO. Blender's Decimate COLLAPSE ratio is a fraction of FACES, and
# the vertex count that falls out of it depends on the mesh's genus and boundary, so
# verts != faces/2 exactly. Rather than assume, this evaluates the modifier through the depsgraph
# (no destructive apply) and bisects the ratio until the vertex count lands inside tolerance. The
# modifier is applied exactly once, at the end, with the ratio the search settled on.
#
# WHAT IT DELIBERATELY DOES NOT DO. It does not try to protect the UV atlas. The consumer is the
# re-atlas work in this lane (`24_seams.py`), which throws Tripo's 5,870-chart soup away and builds
# ~14 anatomical charts from scratch, so spending decimation quality on preserving the old UVs
# would be wasted. The basecolor/normal/rm are still carried into the GLB so the mesh arrives
# textured and can be eyeballed on its own.
#
# Transforms are applied and the object is left alone in the scene, because that is what
# `24_seams.py` expects of its input.
import bpy, bmesh, 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]
TARGET_V = int(argv[2]) if len(argv) > 2 else 31670
REVIEW = argv[3] if len(argv) > 3 else ""
t0 = time.time()
TOL = 0.01 # accept within 1% of the target vertex count
UNIT_MM = 1815.0
def log(m):
print(f"[dec {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))
bpy.context.view_layer.objects.active = ob
for o in bpy.data.objects:
o.select_set(o is ob)
bpy.ops.object.transform_apply(location=True, rotation=True, scale=True)
me = ob.data
v0, f0 = len(me.vertices), len(me.polygons)
co0 = np.empty(v0 * 3)
me.vertices.foreach_get("co", co0)
co0 = co0.reshape(-1, 3)
h0 = co0[:, 2].max() - co0[:, 2].min()
log(f"in: {v0}v {f0}f height {h0:.4f} units ({h0*UNIT_MM/1000:.3f} m equiv)")
log(f"target: {TARGET_V} v (+/-{TOL*100:.0f}%)")
# ---- bisect the collapse ratio against the evaluated mesh ----
mod = ob.modifiers.new("dec", 'DECIMATE')
mod.decimate_type = 'COLLAPSE'
mod.use_collapse_triangulate = False
def verts_at(ratio):
mod.ratio = ratio
ob.update_tag()
dg = bpy.context.evaluated_depsgraph_get()
ev = ob.evaluated_get(dg)
m = ev.to_mesh()
n = len(m.vertices)
ev.to_mesh_clear()
return n
lo, hi = 0.001, 1.0
best = None
# seed from the naive verts~faces/2 relation, then bisect
guess = min(1.0, max(0.001, (TARGET_V * 2.0) / f0))
n = verts_at(guess)
log(f" seed ratio {guess:.5f} -> {n} v")
if abs(n - TARGET_V) / TARGET_V <= TOL:
best = (guess, n)
else:
if n > TARGET_V:
hi = guess
else:
lo = guess
for it in range(24):
mid = 0.5 * (lo + hi)
n = verts_at(mid)
log(f" it{it:02d} ratio {mid:.6f} -> {n} v")
if abs(n - TARGET_V) / TARGET_V <= TOL:
best = (mid, n)
break
if n > TARGET_V:
hi = mid
else:
lo = mid
if best is None:
best = (0.5 * (lo + hi), verts_at(0.5 * (lo + hi)))
log(f" search exhausted; using ratio {best[0]:.6f} -> {best[1]} v")
ratio, got = best
mod.ratio = ratio
log(f"SETTLED ratio {ratio:.6f} -> {got} v (target {TARGET_V})")
bpy.ops.object.modifier_apply(modifier=mod.name)
me = ob.data
v1, f1 = len(me.vertices), len(me.polygons)
log(f"applied: {v0}v/{f0}f -> {v1}v/{f1}f "
f"({100.0*v1/v0:.2f}% of verts, {100.0*f1/f0:.2f}% of faces)")
# ---- shape + health checks ----
co1 = np.empty(v1 * 3)
me.vertices.foreach_get("co", co1)
co1 = co1.reshape(-1, 3)
h1 = co1[:, 2].max() - co1[:, 2].min()
print(f"HEIGHT {h0:.5f} -> {h1:.5f} units (delta {(h1-h0)*UNIT_MM:+.2f} real mm)")
for ax, nm in ((0, "x"), (1, "y"), (2, "z")):
print(f" bbox {nm}: {co0[:,ax].min():+.4f}..{co0[:,ax].max():+.4f} -> "
f"{co1[:,ax].min():+.4f}..{co1[:,ax].max():+.4f}")
# smooth normals over the new topology, matching the rest of the pipeline
vn = np.empty(v1 * 3, dtype=np.float32)
me.vertices.foreach_get("normal", vn)
if me.has_custom_normals:
me.normals_split_custom_set_from_vertices(vn.reshape(-1, 3))
bm = bmesh.new()
bm.from_mesh(me)
bnd = len([e for e in bm.edges if len(e.link_faces) == 1])
nm_ = len([e for e in bm.edges if len(e.link_faces) > 2])
tri = len([f for f in bm.faces if len(f.verts) == 3])
quad = len([f for f in bm.faces if len(f.verts) == 4])
ngon = len([f for f in bm.faces if len(f.verts) > 4])
bm.free()
print(f"TOPO boundary_edges={bnd} nonmanifold_edges={nm_} tris={tri} quads={quad} ngons={ngon}")
# flipped faces, same test stage 25 used
fn = np.empty(f1 * 3)
me.polygons.foreach_get("normal", fn)
fn = fn.reshape(-1, 3)
lv = np.empty(len(me.loops), dtype=np.int32)
me.loops.foreach_get("vertex_index", lv)
ls = np.empty(f1, dtype=np.int32)
me.polygons.foreach_get("loop_start", ls)
lt = np.empty(f1, dtype=np.int32)
me.polygons.foreach_get("loop_total", lt)
vn2 = vn.reshape(-1, 3).astype(np.float64)
acc = np.zeros((f1, 3))
for k in range(int(lt.max())):
sel = lt > k
acc[sel] += vn2[lv[ls[sel] + k]]
acc /= np.maximum(np.linalg.norm(acc, axis=1, keepdims=True), 1e-12)
print(f"FLIPPED FACES: {int(((fn*acc).sum(axis=1) < 0).sum())} of {f1}")
uvl = me.uv_layers.active
print(f"UV layer: {uvl.name if uvl else None} "
f"(carried through decimation; the re-atlas rebuilds it)")
imgs = [i.name for i in bpy.data.images if i.has_data]
print(f"IMAGES carried: {imgs}")
print(f"MATERIALS: {[ms.material.name if ms.material else None for ms in ob.material_slots]}")
print(f"MODIFIERS left: {[m.type for m in ob.modifiers]}")
# ---- export ----
os.makedirs(os.path.dirname(os.path.abspath(OUT)), exist_ok=True)
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=False,
export_morph=False,
)
log(f"WROTE {OUT} ({os.path.getsize(OUT)/1e6:.2f} MB)")
# ---- optional review renders ----
if REVIEW:
os.makedirs(REVIEW, exist_ok=True)
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
nt = clay.node_tree.nodes["Principled BSDF"]
nt.inputs["Base Color"].default_value = (0.62, 0.60, 0.58, 1)
nt.inputs["Roughness"].default_value = 0.45
orig = [ms.material for ms in ob.material_slots]
def shoot(tag, ctr, span, yaw, use_clay):
for i, ms in enumerate(ob.material_slots):
ms.material = clay if use_clay else orig[i]
y = math.radians(yaw)
d = span * 3.0
cam.location = Vector(ctr) + Vector((math.sin(y) * d, -math.cos(y) * d, 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))
fl.rotation_euler = (math.radians(75), 0, math.radians(yaw - 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}")
shoot("full_clay_0", (0.0, 0.0, 0.50), 0.55, 0, True)
shoot("full_tex_0", (0.0, 0.0, 0.50), 0.55, 0, False)
shoot("chest_clay_0", (0.0, 0.0, 0.675), 0.22, 0, True)
shoot("chest_tex_0", (0.0, 0.0, 0.675), 0.22, 0, False)
shoot("hip_clay_0", (0.0, 0.0, 0.53), 0.22, 0, True)
print("DEC_DONE")
@@ -0,0 +1,89 @@
# Stage 30b: repair face winding on the decimated GLB, in place.
#
# blender --background --python 30b_fix_winding.py -- <in.glb> <out.glb>
#
# Decimation re-triangulates, and it reintroduced 61 inward-facing triangles on the 32k mesh —
# the same defect stage 25 cleared from the hires sculpt (1511 -> 10). A backfacing triangle
# renders black, so handing the retexture/re-atlas lane a mesh speckled with them would waste
# their pass. Same two-step remedy: propagate consistent orientation, then reverse whatever the
# non-manifold edges blocked (make_consistent cannot cross them). No vertex moves.
import bpy, bmesh, sys, os, time
import numpy as np
argv = sys.argv[sys.argv.index("--") + 1:]
SRC, OUT = argv[0], argv[1]
t0 = time.time()
def log(m):
print(f"[wind {time.time()-t0:6.1f}s] {m}", flush=True)
bpy.ops.wm.read_homefile(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
bpy.context.view_layer.objects.active = ob
for o in bpy.data.objects:
o.select_set(o is ob)
log(f"in: {len(me.vertices)}v {len(me.polygons)}f")
def flipped_mask():
n_f = len(me.polygons)
fn = np.empty(n_f * 3)
me.polygons.foreach_get("normal", fn)
fn = fn.reshape(-1, 3)
n_v = len(me.vertices)
vv = np.empty(n_v * 3)
me.vertices.foreach_get("normal", vv)
vv = vv.reshape(-1, 3)
lv = np.empty(len(me.loops), dtype=np.int32)
me.loops.foreach_get("vertex_index", lv)
ls = np.empty(n_f, dtype=np.int32)
me.polygons.foreach_get("loop_start", ls)
lt = np.empty(n_f, dtype=np.int32)
me.polygons.foreach_get("loop_total", lt)
acc = np.zeros((n_f, 3))
for k in range(int(lt.max())):
sel = lt > k
acc[sel] += vv[lv[ls[sel] + k]]
acc /= np.maximum(np.linalg.norm(acc, axis=1, keepdims=True), 1e-12)
return (fn * acc).sum(axis=1) < 0
print(f"FLIPPED before: {int(flipped_mask().sum())} of {len(me.polygons)}")
bpy.ops.object.mode_set(mode='EDIT')
bpy.ops.mesh.select_all(action='SELECT')
bpy.ops.mesh.normals_make_consistent(inside=False)
bpy.ops.object.mode_set(mode='OBJECT')
me.update()
print(f"FLIPPED after make_consistent: {int(flipped_mask().sum())}")
for rnd in range(4):
bad = np.nonzero(flipped_mask())[0]
if len(bad) == 0:
log(f"round {rnd}: none left")
break
bm = bmesh.new()
bm.from_mesh(me)
bm.faces.ensure_lookup_table()
bmesh.ops.reverse_faces(bm, faces=[bm.faces[int(i)] for i in bad])
bm.to_mesh(me)
bm.free()
me.update()
log(f"round {rnd}: reversed {len(bad)}")
print(f"FLIPPED final: {int(flipped_mask().sum())} of {len(me.polygons)}")
vn = np.empty(len(me.vertices) * 3, dtype=np.float32)
me.vertices.foreach_get("normal", vn)
if me.has_custom_normals:
me.normals_split_custom_set_from_vertices(vn.reshape(-1, 3))
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=False, export_morph=False)
log(f"WROTE {OUT} ({os.path.getsize(OUT)/1e6:.2f} MB)")
print("WIND_DONE")

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