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>
@@ -0,0 +1,392 @@
|
||||
# Plan: Clothing-pipeline unification — one entry point, QC gates at every seam
|
||||
|
||||
**Status:** Implemented 2026-07-31 (phases 1–4 built; catalog JSON still deferred) · **Author:** Fable 5 session 2026-07-31 · **Implementer:** 5-agent parallel build + integration pass, 2026-07-31 — see §8
|
||||
|
||||
## 0. Context (you have no other context — read this fully)
|
||||
|
||||
The clothing lane turns a garment idea into an outfit part an ariki-game character wears.
|
||||
It has three halves that work but do not talk to each other:
|
||||
|
||||
1. **MD upstream** (this repo): Marvelous Designer 2024, driven over a TCP bridge
|
||||
(`tools/md_bridge.py`, port 18900). Per-garment scripts in `tools/tailor/` draft
|
||||
panels, sew, drape on the Lena avatar FBX, texture, and snapshot. Session start is a
|
||||
human click (Plugin > TinqsMDBridge); MD's UI freezes while a script runs.
|
||||
2. **Blender downstream** (this repo): `clothing/garment_pipeline.py` — seven
|
||||
config-driven headless stages (`census prepare fit reduce bake skin export`), each
|
||||
checkpointing a `.blend` + QA renders into `work/<name>/`, exporting per-slot GLBs
|
||||
onto the shared 65-bone Quaternius skeleton into
|
||||
`ariki-game/assets/quaternius/outfits/<set>/`.
|
||||
3. **Game side** (`ariki-game`, read-only for this repo's tooling by convention):
|
||||
hand-edit `src/Character/OutfitCatalog.cs` to register the item, force a Godot
|
||||
reimport, spawn `ClothingTestBed` via `tools/game.sh`, click through Idle/Walk/Dance,
|
||||
and eyeball the result.
|
||||
|
||||
The lane is PROVEN — the downloaded-dress pilot (2026-07-30) and the MD-authored kapa
|
||||
haka set (pari + piupiu, 2026-07-31) are in game. But both kapa haka pieces shipped with
|
||||
**pose-dependent defects invisible to every QA artifact the pipeline produces**: the pari
|
||||
neckline gapes open in walk/dance, and a thigh punches through the piupiu skirt. All
|
||||
pipeline QA renders are rest pose — the one pose that cannot fail. A separate agent owns
|
||||
those two weight fixes; **this document is the pipeline-level response**: an assessment,
|
||||
a design that combines the three halves into one entry point, and the QC gates that
|
||||
would have caught both bugs before the game.
|
||||
|
||||
Source of truth for how the lane works *today*: `.agents/wiki/architecture/clothing-lane.md`
|
||||
and `clothing/README.md`. This document is a proposal for how it *should* work; on
|
||||
adoption, fold the outcome into `clothing-lane.md`.
|
||||
|
||||
---
|
||||
|
||||
## 1. Assessment
|
||||
|
||||
### 1.1 What already works (keep it)
|
||||
|
||||
- **Config-driven downstream.** New garment = new JSON in `clothing/configs/`, not new
|
||||
code. The schema already expresses island→part mapping, bodyshell parts, alignment,
|
||||
fit masks, weld bands, proxies, weight modes, textures, per-part tri budgets.
|
||||
- **Checkpointed stages.** `00_census.blend … 50_skin.blend` mean any stage can be
|
||||
re-run in isolation; resume semantics exist for free.
|
||||
- **Determinism fingerprint.** `export` prints a SHA1 over all garment vertices
|
||||
(`garment_pipeline.py:984-989`).
|
||||
- **The seam is crossed.** MD-authored garments arrive at game budget, pre-fitted to
|
||||
`Ariki_Female_QuatSkin.glb` — `reduce` is only hard for downloaded meshes.
|
||||
- **All the automation hooks exist on the game side.** `game.sh spawn` (multi-instance,
|
||||
`SPAWN_BUILD=1`, `MOCK_ONLY=1`), agent API (`/health /screenshot /navigate /state
|
||||
/scene /ui /command /input /console`), `game.sh clean-import`, `tools/asset_pipeline.py
|
||||
check`.
|
||||
|
||||
### 1.2 What costs time (the streamlining targets)
|
||||
|
||||
| Cost | Evidence |
|
||||
|---|---|
|
||||
| 4× duplicated MD garment scripts | `md_pari.py`, `md_piupiu.py`, `md_tee_v1.py`, `md_skirt_v1.py` share a ~100% identical shell (NewProject → ImportFBX at `op.scale=10.0` → fabric → arrange → simulate → snapshot); only panels/seams differ |
|
||||
| Config copy-paste | `piupiu.json` vs `piupiu_sb.json`: 48 lines, differing in exactly 4 values (`name`, `body`, `weights`, `export.set`) + a note |
|
||||
| Six manual CLI invocations per garment | one `blender --background … --stage <s>` per stage, run by hand in order |
|
||||
| Hand-authored configs | read `census.json`, guess island→part mapping, trial-and-error alignment against QA renders |
|
||||
| Code-edit registration | 2 `Add()` lines + possibly a `BaseDirFor` case in `OutfitCatalog.cs`, then a C# rebuild |
|
||||
| Blunt reimport | `.bin`-only changes are invisible to Godot's `.gltf` hash; today's fix is `clean-import` (wipes ALL of `.godot/imported/`) or hand-deleting entries |
|
||||
| Eyeball sign-off | spawn test bed, click Idle/Walk/Dance, look |
|
||||
|
||||
### 1.3 What is structurally unsafe
|
||||
|
||||
1. **Rest-pose-only QA.** Every `qa_*.png` the pipeline writes is the fitting pose.
|
||||
Both shipped bugs were invisible in it. (`clothing/README.md` "QA renders only prove
|
||||
the pose that cannot fail".)
|
||||
2. **Silent catalog fallback.** `OutfitCatalog.BaseDirFor` (`OutfitCatalog.cs:88-98`)
|
||||
takes a bare-string `setId` and its `_ =>` arm silently resolves to the Fantasy pack
|
||||
folder — a typo'd set name mis-resolves without error. Missing assets are warn-only
|
||||
(`OutfitCatalog.cs:71-78`): the character just stays naked/previous.
|
||||
3. **`.bin` staleness.** `GLTF_SEPARATE` export keeps geometry/UVs in the sidecar
|
||||
`.bin`; Godot hashes only the `.gltf`, so a vertex-only re-export silently ships the
|
||||
old mesh.
|
||||
|
||||
### 1.4 Corrections to the record (verified 2026-07-31)
|
||||
|
||||
Earlier session notes contained four errors that materially change the design:
|
||||
|
||||
| Prior claim | Truth | Consequence |
|
||||
|---|---|---|
|
||||
| "FBX export from MD is manual" | `export_api.ExportFBX(path, op)` (+ `ExportOBJ/ExportZPrj/ExportZPac`) is scriptable — exact flags in `.claude/skills/marvelous-designer/SKILL.md:182-190` (`op.bExportGarment=True; op.bExportAvatar=False`); the 4 garment scripts simply never call it | The **entire MD half can run unattended** after the one session click |
|
||||
| `ExportSnapshot3D` is the mesh export | It is the viewport **PNG** (the vision-QC loop); mesh export is `ExportFBX` | Don't build on the wrong call |
|
||||
| "5 duplicated MD scripts" | 4 garment scripts; `md_recon.py` is an API-introspection tool and stays standalone | Template scope |
|
||||
| `tools/vision-compare.py` exists (referenced by `ariki-game/tools/visual-qa.sh`) | **It was never written** | The in-game gate has no judge today; VLM-vs-pixel-diff is a real decision, not wiring |
|
||||
|
||||
One permanent constraint, confirmed: **MD exposes no mesh introspection to Python**
|
||||
(`GetClothPositions()` stays empty). Every upstream drape judgement is image-based,
|
||||
by necessity, until CLO exposes more.
|
||||
|
||||
Two enabling facts: the config's `export` block already carries `gender`/`set`/`out_dir`
|
||||
and each part carries `slot`, so the catalog's exact `{Gender}_{Set}_{Slot}.gltf` path is
|
||||
derivable from the config today; and the agent API's `GET /console` surfaces
|
||||
`ClothingTestBed`'s per-slot load-failure prints, so **outfit load failure is
|
||||
machine-detectable without vision**.
|
||||
|
||||
---
|
||||
|
||||
## 2. The unified pipeline
|
||||
|
||||
### 2.1 One config, additive blocks
|
||||
|
||||
Keep `clothing/configs/<garment>.json` as the single per-garment artifact and **grow it**
|
||||
— the existing Blender schema stays byte-compatible, so `garment_pipeline.py` needs no
|
||||
migration:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"extends": "piupiu.json", // NEW: overlay inheritance
|
||||
"name": "...", "source": "...", "body": "...",
|
||||
|
||||
"md": { // NEW: upstream draft params (what the 4 scripts differ on)
|
||||
"avatar_fbx": "...", "zfab": "...", "texture": "...",
|
||||
"panels": [...], "seams": [...], "arrangements": [...],
|
||||
"sim_frames": 300, "strengthen": [...],
|
||||
"export_basename": "lena_<garment>_v<N>"
|
||||
},
|
||||
|
||||
"align": {...}, "parts": {...}, // UNCHANGED: existing Blender schema
|
||||
|
||||
"export": { "gender": "Female", "set": "Kapahaka", "out_dir": "..." }, // exists
|
||||
|
||||
"catalog": { // NEW: what Add() needs that nothing else holds
|
||||
"id": "kapahaka_legs_f", "displayName": "Piupiu",
|
||||
"charisma": 0.05, "workSpeed": 0.04, "category": "casual"
|
||||
},
|
||||
|
||||
"expect": {...} // NEW: QC thresholds (§3)
|
||||
}
|
||||
```
|
||||
|
||||
- **`extends` is the cheapest real win**: `piupiu_sb.json` becomes ~8 lines. Overlay =
|
||||
deep-merge child over parent, arrays replaced whole.
|
||||
- **`md` block + one template script** `tools/tailor/draft_garment.py` replaces the 4
|
||||
copy-paste scripts. The identical shell becomes the template; panels/seams/arrangements
|
||||
come from the config. Hard-won MD lessons (mm units, +y up in 2D, whole-edge seam
|
||||
indices, look up arrangement points by name, `SetArrangement` then
|
||||
`ResetClothArrangement`) live in ONE place instead of four.
|
||||
- **`catalog` block** carries only what the C# `Add(id, displayName, slot, slotSuffix,
|
||||
charisma, workSpeed, category, set:, gender:)` signature needs and the pipeline has
|
||||
never held; `slot`/`set`/`gender` derive from existing blocks.
|
||||
|
||||
### 2.2 One orchestrator
|
||||
|
||||
`clothing/garment.py <config> [--from <stage>] [--to <stage>] [--only <stage>]`
|
||||
over one ordered stage list spanning all three worlds:
|
||||
|
||||
```
|
||||
draft drape publish │ census prepare fit reduce bake skin export │ register import verify
|
||||
└──── MD bridge ──────┘└──────── Blender headless ────────────────┘└──── ariki-game ─────┘
|
||||
```
|
||||
|
||||
**A driver, not a rewrite.** Each stage shells out to the tool that already owns it:
|
||||
|
||||
| Stage group | Shells to | Notes |
|
||||
|---|---|---|
|
||||
| `draft/drape/publish` | `python tools/md_bridge.py --file <generated>` | Script generated from the `md` block via `draft_garment.py`. `--ping` first; if no session, fail fast printing the human instruction ("click Plugin > TinqsMDBridge"). Raise the idle timeout (~1 min per 300 sim frames). `publish` = scripted `ExportZPrj` + `ExportFBX` + `ExportZPac` (§1.4). |
|
||||
| `census … export` | `blender --background --python garment_pipeline.py -- --config X --stage Y` | **Unchanged CLI**; `--from/--to` is a loop. Standalone stage runs keep working exactly as today. |
|
||||
| `register/import/verify` | `bash ariki-game/tools/game.sh …` + agent API | §2.4 and gates G6/G7/G8. Decided: orchestrator lives in `tinqs/animation/clothing/`, cross-repo shell-out to `game.sh` is acceptable. |
|
||||
|
||||
Checkpoint numbering (`00_ … 50_`) already gives resume: `--from fit` loads
|
||||
`10_prepare.blend` exactly as today.
|
||||
|
||||
### 2.3 The automation boundary (permanent, human-only)
|
||||
|
||||
1. **Session start is a click** — Plugin > TinqsMDBridge, once per MD session (not per
|
||||
garment). The orchestrator detects (`--ping`) and instructs; it can never perform it.
|
||||
2. **MD's UI freezes during a blocking call** — no mid-drape supervision; results are
|
||||
visible only after `Simulate` returns.
|
||||
3. **No mesh introspection in MD** — upstream QC is image-based forever (§1.4).
|
||||
|
||||
Everything else — including the FBX export previously believed manual — is scriptable.
|
||||
That is the headline of this assessment.
|
||||
|
||||
### 2.4 Registration: emit, don't edit (catalog JSON deferred)
|
||||
|
||||
Decided 2026-07-31: **defer** the data-driven OutfitCatalog refactor.
|
||||
|
||||
- Cost today is 2 `Add()` lines per set (+1 `BaseDirFor` case when a new folder
|
||||
appears); the refactor is not small (`Initialize()` is all hardcoded calls;
|
||||
`ClothingItem` has 10 init-only properties to round-trip). Revisit at ~10 sets.
|
||||
- The real risk is not typing, it is the **silent** `setId` fallback (§1.3) — gate G8
|
||||
(a ~30-line lint) buys that safety without the refactor.
|
||||
- The `register` stage therefore **emits** the exact `Add(...)` line(s) and any needed
|
||||
`BaseDirFor` case to stdout + `work/<name>/register.cs.txt` for paste-in.
|
||||
Codegen-as-text, not codegen-as-edit — honest about who owns `src/`.
|
||||
|
||||
---
|
||||
|
||||
## 3. QC gates (where agents/checks slot in)
|
||||
|
||||
Ranked by value = known-pain-caught ÷ effort. Both shipped bugs were pose-dependent, so
|
||||
the ranking is driven by *how early a pose-dependent failure can be caught*. Thresholds
|
||||
live in the config's `expect` block.
|
||||
|
||||
| # | Gate | After stage | Kind |
|
||||
|---|---|---|---|
|
||||
| **G5** | **Posed penetration + gape sweep — the centrepiece** | `skin` | scriptable |
|
||||
| G6 | In-game motion QC | `import` | agent-with-vision (hard part scriptable) |
|
||||
| G3 | Rest-pose penetration | `fit` | scriptable |
|
||||
| G2 | Census sanity | `census` | scriptable |
|
||||
| G8 | Catalog ↔ asset lint | `register` | scriptable |
|
||||
| G7 | Targeted reimport verify | `import` | scriptable |
|
||||
| G4 | Reduce budget + silhouette | `reduce` | scriptable |
|
||||
| G1 | Drape placement | `drape` | hybrid |
|
||||
|
||||
**G5 — posed penetration + gape sweep.** Headless Blender loads `50_skin.blend` (it
|
||||
already contains `BODY`, `RIG`, and the skinned `GARM_*` parts). Apply real clip poses —
|
||||
the UAL animation packs (`ariki-game/assets/quaternius/anim/UAL1.glb` etc.) are authored
|
||||
on the same 65-bone skeleton — or synthetic extremes (arm raise, deep step, torso twist).
|
||||
Per sampled frame, evaluate the armature-deformed meshes and BVH-test: (i) garment verts
|
||||
inside the body, (ii) body verts exposed inside a part's declared coverage band.
|
||||
Pass/fail: `expect.max_penetrating_verts` (e.g. 0 verts >1 mm inside) and
|
||||
`expect.max_gape_verts` across **all** frames. This is the only gate that would have
|
||||
caught BOTH shipped bugs — deterministically, headless, pre-game, re-runnable. One risk
|
||||
to verify during implementation: bone-name compatibility between the UAL pack armature
|
||||
and the derived-body `RIG` (retarget or pose-copy if names drift).
|
||||
|
||||
**G6 — in-game motion QC.** `SCENE=clothing_test_bed MOCK_ONLY=1 game.sh spawn` →
|
||||
`POST /navigate {"button":"Idle"|"Walk"|"Dance"}` → `GET /screenshot` per clip → judge.
|
||||
Two signals: **hard** = `GET /console` contains no outfit load error (machine-checkable
|
||||
today); **soft** = visual verdict on poke-through/gaping/texture. Decided 2026-07-31:
|
||||
**VLM/agent judge for a new garment's first sign-off, then bless those frames as the
|
||||
baseline so re-runs become a deterministic pixel diff.** This also finally gives
|
||||
`visual-qa.sh` the comparator it references but never had (§1.4). Small `ariki-game`
|
||||
enabler needed (approved): `ClothingTestBed` hardcodes its opening set
|
||||
(`DefaultBodyF = "kapahaka_body_f"` etc., `ClothingTestBed.cs:81-84`) — add an env-var/CLI
|
||||
override so a new set is directly reachable instead of blind `Cycle Body` presses.
|
||||
|
||||
**G3 — rest-pose penetration.** The same BVH test as G5, rest pose only, run right after
|
||||
`fit` — a cheap early-fail subset that saves reduce/bake/skin time on a bad fit.
|
||||
|
||||
**G2 — census sanity.** `census.json` already records per-island verts/tris/z/x/centroid.
|
||||
Check against `expect.islands` (count, per-mapped-island vert count and z-span
|
||||
tolerance). Catches the silent killer: an MD re-export reorders islands and the config
|
||||
maps the wrong ones.
|
||||
|
||||
**G8 — catalog ↔ asset lint.** Every catalog entry's resolved
|
||||
`{baseDir}/{Gender}_{Set}_{Slot}.gltf` exists on disk, and every outfit GLB on disk has a
|
||||
catalog entry. Zero unmatched either way. ~30 lines; kills the silent `BaseDirFor`
|
||||
fallback footgun (§1.3) without the JSON refactor.
|
||||
|
||||
**G7 — targeted reimport verify.** Replace blunt `clean-import` in the garment loop:
|
||||
delete only that asset's two `.godot/imported/` entries (the README already prescribes
|
||||
exactly this by hand), run `--import`, then confirm via `/console` + refreshed
|
||||
`.gltf.import`. Closes the `.bin`-staleness hole (§1.3) without nuking the whole cache.
|
||||
|
||||
**G4 — reduce budget + silhouette.** Tris vs `parts[].tris`; pixel IoU of the HI vs
|
||||
reduced QA renders ≥ threshold. Low urgency: MD-authored garments arrive at budget (both
|
||||
kapa haka pieces never hit their ceilings) — this gate mostly guards downloaded meshes.
|
||||
|
||||
**G1 — drape placement.** The thresholds already exist as prose — `md_pari.py:4` reads
|
||||
"QC targets: band top ~1.31 m (above bust), hem ~1.05 m (waist) ±3 cm". Move them into
|
||||
`expect.bands`, upgrade `tools/tailor/qc_placement.py` to read them (instead of its
|
||||
hardcoded landmark table), and add a vision pass on the `ExportSnapshot3D` PNG. That one
|
||||
move takes drape QC from eyeballed to gated.
|
||||
|
||||
---
|
||||
|
||||
## 4. Phased adoption
|
||||
|
||||
| Phase | Content | Effort | Why this order |
|
||||
|---|---|---|---|
|
||||
| **1** | `extends` overlay + `expect` block + **G2** + **G8** + orchestrator skeleton driving *Blender stages only* | ~½ day | Zero new subsystems, no MD, no game, no human in the loop. Immediately kills the 48-line config copy and the six-call ritual. |
|
||||
| **2** | `draft_garment.py` template + `md` block + scripted `ExportZPrj/FBX/ZPac` in the same bridge call + **G1** | ~1 day | Collapses 4 scripts to 1; MD half becomes unattended after the single session click. |
|
||||
| **3** | **G5** posed sweep (+ **G3** as its rest-pose subset) | ~1 day | **The money phase** — catches the class of bug that has actually shipped, before the game. |
|
||||
| **4** | **G7** targeted reimport + ClothingTestBed set override + **G6** motion QC + baseline blessing | ~1 day | Real shaders/import/AnimationTree; only meaningful after 1–3. Needs the small approved `ariki-game` edits. |
|
||||
| **5** | Data-driven `OutfitCatalog` JSON | deferred | Revisit ~10 sets; G8 buys the safety now. |
|
||||
|
||||
G4 rides along whenever convenient (it rarely bites for MD-authored garments) rather
|
||||
than blocking any phase.
|
||||
|
||||
---
|
||||
|
||||
## 5. Resolved decisions (Jeremy, 2026-07-31)
|
||||
|
||||
1. **Orchestrator home:** `tinqs/animation/clothing/garment.py`; game stages shell out
|
||||
cross-repo to `ariki-game/tools/game.sh`.
|
||||
2. **G6 judge:** VLM/agent vision judge for first sign-off; bless those frames; re-runs
|
||||
are deterministic pixel diffs.
|
||||
3. **OutfitCatalog:** stay code-edit; pipeline emits `Add()` lines; add the G8 lint;
|
||||
JSON refactor deferred (~10 sets).
|
||||
4. **`ariki-game/src` edits:** small enablers approved (ClothingTestBed set override,
|
||||
later testbed hooks). The pari/piupiu weight-fix agent owns `garment_pipeline.py`'s
|
||||
skin stage + those two configs — coordinate before touching either.
|
||||
|
||||
## 6. Leave alone
|
||||
|
||||
- `garment_pipeline.py`'s `--stage` CLI and config schema — additive only; the
|
||||
orchestrator wraps, never rewrites.
|
||||
- The `skin` stage internals and `pari.json`/`piupiu.json` weight fields — owned by the
|
||||
weight-fix agent right now.
|
||||
- `md_recon.py` — introspection tool, not a garment script; does not fold into the template.
|
||||
- `.agents/wiki/architecture/clothing-lane.md` — cite it; fold outcomes in on adoption;
|
||||
never fork its content.
|
||||
|
||||
## 7. Appendix — file/line references
|
||||
|
||||
| Fact | Where |
|
||||
|---|---|
|
||||
| Stage implementations | `clothing/garment_pipeline.py`: census 224–248, prepare 295–505, fit 542–598, reduce 653–706, bake 712–768, skin 908–957 (weight modes 920–944, skirt_bones 836–905), export 964–989 (vertex SHA1 984–989) |
|
||||
| Config parse / work dir / CLI | `garment_pipeline.py:1006`, `:36-39`, `:994-1008` |
|
||||
| MD bridge protocol/session | `tools/md_bridge.py` (port/env `:88`, `:22`; run/ping `:58-68`), `docs/md-bridge.md:8-94` |
|
||||
| Scriptable MD exports (flags) | `.claude/skills/marvelous-designer/SKILL.md:182-190` |
|
||||
| MD API lessons | `tools/tailor/md_tee_v1.py:12-28`, `md_skirt_v1.py:6-25` |
|
||||
| Drape QC thresholds as prose | `tools/tailor/md_pari.py:4`; classifier `tools/tailor/qc_placement.py:24-34`, bands `:51-80` |
|
||||
| Config diff piupiu vs piupiu_sb | `clothing/configs/piupiu.json` / `piupiu_sb.json` — `name`, `body` (`_SkirtRig`), `parts.Piupiu.weights`, `export.set` |
|
||||
| Catalog path resolution + silent fallback | `ariki-game/src/Character/OutfitCatalog.cs:57-98` (warn-only 71–78, `BaseDirFor` `_ =>` 96–97), `Add()` pattern in `Initialize()` 116+ |
|
||||
| Test bed buttons / defaults / load-fail HUD | `ariki-game/src/Testing/ClothingTestBed.cs:449-516`, `:81-84`, `:88-89` + `:279-281` |
|
||||
| Spawn / engine / import / clean-import | `ariki-game/tools/game.sh:633-749`, `:39-70`, `:832-841` (+ asset check `:848`), `:478-482` |
|
||||
| Agent API endpoints | `game.sh:215-242` (`/console` via AgentServer) |
|
||||
| Visual QA harness (judge missing) | `ariki-game/tools/visual-qa.sh:1-77` — calls `tools/vision-compare.py`, which does not exist |
|
||||
| Reimport gotcha (hand fix prescribed) | `clothing/README.md:143-147` |
|
||||
| Reference logic in older converters | `ariki-game/tools/cc_clothing_to_quaternius.py` (skeleton-frame alignment 66–73, KDTree copy 110–127, A-pose check 198–205), `make_islander_outfits.py` (region cuts 103–149) |
|
||||
|
||||
## 8. Build outcome (2026-07-31)
|
||||
|
||||
Built in one session by five parallel agents plus an integration pass. As-built
|
||||
interfaces are documented in `clothing/PIPELINE-CONTRACT.md` (marked AS-BUILT); the
|
||||
one-command usage and gate table are in `clothing/README.md`.
|
||||
|
||||
| Lane | Delivered |
|
||||
|---|---|
|
||||
| A | `clothing/garment.py` — the orchestrator: `extends` resolution, deep-merge, path absolutization, 13 stages, gate scheduling, `register` codegen, `--selftest` |
|
||||
| B | `gates/g2_census.py` (island census sanity), `gates/g8_catalog_lint.py` (catalog↔asset lint, `--all` / `--require-registered`) |
|
||||
| C | `gates/g5_posed_sweep.py` — posed penetration + gape sweep; G3 is its `--rest` mode |
|
||||
| D | `tools/tailor/draft_garment.py` (MD script generator), `tools/tailor/qc_placement.py`, `gates/g1_drape.py` |
|
||||
| E | `ariki-game/tools/targeted_reimport.sh` (G7), `ariki-game/tools/clothing_motion_qa.sh` (G6), `ClothingTestBed.ReportLoad` |
|
||||
|
||||
### Acceptance test
|
||||
|
||||
`configs/tests/piupiu_sb_test.json` (scratch export target, set `KapahakaSBTest`) run
|
||||
end to end, `census..export`, `--no-gate-stop`:
|
||||
|
||||
| Stage | Time | Gate |
|
||||
|---|---|---|
|
||||
| census | 8.0 s | **G2 PASS** — 1 island, 3620 v / 7104 tri, matched `expect.islands` |
|
||||
| prepare | 4.7 s | — |
|
||||
| fit | 3.3 s | **G3 FAIL** — 1 vert, 8.0 mm deep, at rest |
|
||||
| reduce | 3.1 s | — |
|
||||
| skin | 3.4 s | **G5 FAIL** — see below |
|
||||
| export | 3.3 s | — |
|
||||
|
||||
**G5 reproduced the shipped piupiu bug headlessly**, in 14.9 s, from the real game
|
||||
clips (`Idle_Loop` from UAL1, `Walk_Fwd_Loop` from UAL2; 65/65 pack bones matched
|
||||
the RIG, the 16 skirt bones ride their parents):
|
||||
|
||||
```
|
||||
rest 1 penetrating vert (max 8.0 mm)
|
||||
Idle @0/12/25/38/50/62 229 217 216 231 216 216 (max 75.2 – 85.2 mm)
|
||||
Walk @0/7/13/20/27/33 169 134 61 90 128 63 (max 70.0 – 87.9 mm)
|
||||
```
|
||||
|
||||
That is the thigh punching through the skirt: ~1 vert visible in the pose the whole
|
||||
pipeline used to sign off on, and **216–231 verts up to 8.5 cm deep** the moment the
|
||||
character stands still and breathes. 13 failing frames, with front + closeup QA renders
|
||||
for six of them. The gate is worth its cost.
|
||||
|
||||
The **pari neckline gape did NOT reproduce** in the Blender checkpoint — it is not
|
||||
present in the skinned mesh G5 measures. Its origin is therefore in-game (attach,
|
||||
material, or LOD), which makes it G6 territory, not G5. Worth stating plainly because
|
||||
the pre-build assumption was that both shipped defects were the same class of bug.
|
||||
|
||||
### Real findings the gates produced
|
||||
|
||||
- **G8** found an *uncommitted* `OutfitCatalog` registration hunk in the working tree —
|
||||
a garment registered locally and never pushed, which the lint surfaced immediately.
|
||||
On the test set it correctly reports `KapahakaSBTest` has no `BaseDirFor` arm and so
|
||||
would silently resolve into the Fantasy pack folder.
|
||||
- **G1** measured the *shipped* garments against their own written QC targets and found
|
||||
both off: pari's hem sits **+3.9 cm** high, and the piupiu's bands **+8 cm / +14 cm**.
|
||||
Those targets had lived as prose in the garment scripts' headers since they were
|
||||
written; nobody had ever checked them against a render.
|
||||
- **`/console` did not surface load failures at all** until `ClothingTestBed.ReportLoad`
|
||||
was added — `GD.Print` does not feed the agent API's ring buffer. Every "the bed looks
|
||||
fine" sign-off before that fix was reading an empty channel.
|
||||
- **G6's pixel diff has a measured floor**: same outfit re-run moves ~0.002 of the frame,
|
||||
an entirely different outfit moves 0.014, and by frame 3 phase drift alone (0.0095)
|
||||
matches the signal. So G6 catches gross regressions (garment vanished, failed to load,
|
||||
swapped) and nothing subtler. **G5 owns the subtle bug class** — which is the right
|
||||
split, because G5 is headless, deterministic and 15 s.
|
||||
|
||||
### Still deferred
|
||||
|
||||
Catalog-as-JSON (§2.4) — `register` still emits `work/<name>/register.cs.txt` for a human
|
||||
to paste, by design. Nothing in the build depends on changing that.
|
||||
@@ -0,0 +1,105 @@
|
||||
# Rig-graft lane — AccuRig skeleton onto pristine Tripo GLBs (PLAN, not executed)
|
||||
|
||||
**Repo home:** this plan and its tools moved from ariki-game into the animation repo
|
||||
2026-08-06 — the lane authors characters, so `characters/REGISTRY.md` governs it (see
|
||||
`.agents/wiki/ARCHITECTURE.md` "Which repo does a non-gameplay tool belong to"). Paths
|
||||
below are animation-repo-relative unless prefixed `ariki-game/`.
|
||||
|
||||
**Status 2026-08-04: PLAN ONLY. Jeremy has explicitly held execution — no model
|
||||
files are to be created or modified until the naming convention is decided.**
|
||||
|
||||
## Goal
|
||||
|
||||
Rig Ozlem's two unrigged Tripo bodies without the FBX→GLB quality loss the male
|
||||
lane hit in July (mangled textures, bad hand skinning). The move: FBX is only a
|
||||
**disposable rig carrier** through AccuRig; the **GLB is the sole source of truth**
|
||||
for mesh + materials and only ever *gains* bones and weights.
|
||||
|
||||
## Source files (curated 2026-08-04)
|
||||
|
||||
Byte-identical copies live in both repos: `characters/originals/` here (checksummed in
|
||||
`characters/REGISTRY.md`) and `ariki-game/assets/models/characters/race-sources/`. Read
|
||||
from this repo's copies.
|
||||
|
||||
| file | verts | tris | height | feet @ Z0 | axis | textures |
|
||||
|---|---|---|---|---|---|---|
|
||||
| `female_lena_tripo.glb` | 974,478 | 1,907,931 | 0.979 m | yes (0.0) | upright, rot 0, scale 1 | 3 packed 4K (basecolor/normal/rm) |
|
||||
| `female_lena_tripo.fbx` | 953,968 | 1,907,931 | 0.979 m | — | upright | 5 external JPEGs (.fbm removed; git-recoverable) |
|
||||
| `male_base_bald_tripo_v1.glb` | 969,881 | 1,901,449 | 0.980 m | yes (0.0) | upright, rot 0, scale 1 | 3 packed 4K, clean |
|
||||
| `male_base_bald_tripo_v1.fbx` | 950,729 | 1,901,449 | 0.980 m | — | **lying down (bad axis)** | 6 packed, all mislabeled "Diffuse Texture.NNN", duplicated basecolor |
|
||||
|
||||
Census facts that shape the plan:
|
||||
|
||||
- **No bones anywhere** — all four files are truly unrigged, so no rig standard is
|
||||
imposed by the sources. The 65-bone Quaternius game standard is unaffected.
|
||||
- **Identical surfaces per pair** (tri counts match exactly); vertex counts differ
|
||||
~2% from format-specific UV-seam splitting → index-exact weight copy is off the
|
||||
table, nearest-surface transfer is the mechanism (surfaces coincide, so it is
|
||||
near-exact everywhere except close-packed fingers — see step 4).
|
||||
- The male FBX's axis + texture mangling is the July failure mode reproduced in
|
||||
data — never source visual data from FBX.
|
||||
|
||||
## Decisions (Jeremy, 2026-08-04)
|
||||
|
||||
1. **NO scaling to standard height.** Bodies stay at Tripo's native ~0.98 m.
|
||||
(Any scale handling happens later, downstream, not in this lane.)
|
||||
2. **Axis check + ground the feet** — wanted, and the probe shows both GLBs
|
||||
already pass (rot 0 / scale 1 / min-Z exactly 0.0). These become verify-only
|
||||
gates, mutating nothing unless a future source fails them.
|
||||
3. **HOLD before decimation** until the naming convention for derived files is
|
||||
agreed. Nothing below the line runs until then.
|
||||
|
||||
## The lane (each step gated on the one before)
|
||||
|
||||
1. **Verify** (read-only, DONE for the two current GLBs): upright axis, unit
|
||||
scale, feet at Z=0, centered X, packed textures present.
|
||||
2. **Rig bait** *(HELD — naming)*: from the GLB, decimate a disposable copy and
|
||||
export FBX for AccuRig. **Confirmed 2026-08-04: AccuRig refuses the raw
|
||||
1.9 M-tri FBX outright** (Jeremy tried; July hit the same wall). Use the
|
||||
`ariki-game/tools/male_mesh_decimate.py` precedent: region budgets body 24k / head 14k /
|
||||
**hands 10k as their own protected region** (global-ratio decimation webs the
|
||||
fingers — half of the historic hand-mangling happened here, pre-AccuRig).
|
||||
Apply the July male-lane traps: Dummy-export quirk, the 4 cm offset.
|
||||
No scale change on the GLB — but if AccuRig misplaces joints on a 0.98 m
|
||||
body, scale the DISPOSABLE BAIT up 2× and scale the returned skeleton back
|
||||
down in the graft; the GLB never changes size.
|
||||
3. **AccuRig** (manual, Jeremy/Ozlem): rig the bait FBX. Export rigged FBX.
|
||||
4. **Graft** (headless Blender): import rigged bait FBX + pristine GLB; snap rest
|
||||
poses; transfer skeleton + skin weights decimated→full-res by nearest-surface
|
||||
with tight max-distance; **hands get special handling** (per-finger masked
|
||||
transfer or reduced distance + limit-totals + normalize) — this is where the
|
||||
old hand mangling gets fixed. Export GLB: original mesh + original packed
|
||||
textures + new rig. FBX artifacts are discarded.
|
||||
5. **QC**: pose sweep incl. finger curls; then (if/when destined for game) the
|
||||
existing AccuRig→Quaternius conversion (`ariki-game/tools/make_male_ib_quatskin_accurig.py`
|
||||
lineage) picks it up.
|
||||
|
||||
## Naming + step 2 status (RESOLVED 2026-08-04, later same day)
|
||||
|
||||
Jeremy raised the hands budget and released the bait step. Built with
|
||||
`tools/rigbait_decimate.py` (parameterized successor to male_mesh_decimate.py —
|
||||
no scale/recenter, bbox-relative region cuts, budgets body 24k / head 14k /
|
||||
**hands 40k**):
|
||||
|
||||
- `characters/rig-work/lena_tripo_rigbait.fbx` — 77,999 tris, QA'd
|
||||
- `characters/rig-work/mako_tripo_rigbait.fbx` — 78,000 tris, QA'd
|
||||
- `characters/rig-work/` is exempt from the naming grammar (REGISTRY.md rule 11) —
|
||||
disposable carriers, no registry rows. In the game repo it had needed a `.gdignore`
|
||||
to stop Godot importing it, which was the tell that it lived in the wrong repo.
|
||||
- QA renders alongside (`*_qa_front.png`, `*_qa_hand.png`) — fingers fully
|
||||
distinct at 40k, no webbing.
|
||||
- Baits are geometry-only (~3.5 MB; the GLB's packed textures don't survive FBX
|
||||
embed) — AccuRig shows a grey model, which is fine for rigging. It does mean
|
||||
the July "Dummy001 export" check can't use textures: **verify AccuRig's output
|
||||
by mesh name + ~78k tri count instead.**
|
||||
|
||||
**AccuRig exports must be saved as** (same folder):
|
||||
- `characters/rig-work/lena_tripo_accurig.fbx`
|
||||
- `characters/rig-work/mako_tripo_accurig.fbx`
|
||||
|
||||
## Open before execution
|
||||
|
||||
- Step 3 (AccuRig) — manual, waiting on Jeremy/Ozlem.
|
||||
- Step 4 graft script — build once a rigged FBX exists to test against.
|
||||
- Whether to restore the female FBX's `.fbm` texture folder from git (only needed
|
||||
if the FBX is ever used for more than rigging; the lane says it shouldn't be).
|
||||
@@ -8,6 +8,7 @@ This repo's operator playbooks are Claude-Code-native skills and live at
|
||||
| `animation` | Operator playbook — the batch workflow, naming, loop QC, gotchas |
|
||||
| `animation-creation` | Authoring new clips on the shared Quaternius skeleton |
|
||||
| `iclone-video-mocap` | iClone 8 + Video Mocap: filming, cleanup, FBX export |
|
||||
| `marvelous-designer` | Garment authoring: MD bridge, drafting from a reference image, draping on the game body, placement QC, export |
|
||||
| `pose-estimation` | Video→pose-landmark extraction (MediaPipe) |
|
||||
| `retarget-animations` | Deprecated early retarget notes, kept for history |
|
||||
|
||||
|
||||
@@ -3,6 +3,10 @@
|
||||
Entry point to architecture law for this repo. Per-system detail lives in
|
||||
`.agents/wiki/architecture/`.
|
||||
|
||||
This file covers the **animation lane** (motion → clips). The repo also runs a
|
||||
**clothing lane** (garments → worn outfits) that targets the same skeleton —
|
||||
see `.agents/wiki/architecture/clothing-lane.md`.
|
||||
|
||||
## The one-sentence shape
|
||||
|
||||
PC-side motion capture (iClone 8 + Video Mocap) → this repo (Mac↔PC bridge,
|
||||
@@ -78,13 +82,53 @@ in `dancegen/` only.
|
||||
`tools/cc_retarget.py`, `mixamo_retarget.py`, `kevin_retarget.py`,
|
||||
`mocap_retarget.py` are **mirrors of `ariki-game/tools/`** — the game copies are
|
||||
authoritative; re-copy from ariki-game when they change (they have silently
|
||||
diverged before). `.claude/skills/` here mirror a subset of `ariki-game/.claude/skills/`
|
||||
plus `~/.claude/skills/pose-estimation` — see `README.md` Provenance section.
|
||||
diverged before; verified identical 2026-08-06). `.claude/skills/` here mirror a
|
||||
subset of `ariki-game/.claude/skills/` plus `~/.claude/skills/pose-estimation` —
|
||||
see `README.md` Provenance section.
|
||||
|
||||
The **character/body lane is authoritative HERE and is not mirrored** (moved out
|
||||
of ariki-game 2026-08-06): `tools/rigbait_decimate.py`,
|
||||
`make_lena_nude_body.py`, `_bake_nude_body_texture.py`,
|
||||
`_render_body_closeup.py`, `verify_body_variant.py`. Anything that authors a
|
||||
character mesh, rig, or body texture is born here from now on, governed by
|
||||
`characters/REGISTRY.md`. The game repo keeps only the historical committed
|
||||
generators (`make_lena_body.py`, `make_male_ib_quatskin_accurig.py`,
|
||||
`male_mesh_decimate.py`, the `_convert_lena_quat_v*` series): they are cited by
|
||||
game-side plans and some carry game-side tests.
|
||||
|
||||
### Which repo does a non-gameplay tool belong to
|
||||
|
||||
Decided by **subject, not by "is it gameplay"** — nothing in `ariki-game/tools/`
|
||||
is gameplay (no `.gd` runtime script references it at all), so that test would
|
||||
empty the folder into this one. Three questions, in order:
|
||||
|
||||
1. Does the engine, CI, or the game's own test suite run it? → **stays in
|
||||
ariki-game.** `asset_pipeline.py`, `game.sh`, `session.py`,
|
||||
`e2e_interaction.py`, `anim_qc.py`, `rig_pose_gate.py`,
|
||||
`targeted_reimport.sh`, `clothing_motion_qa.sh`, and every module a
|
||||
`tools/test_*.py` imports.
|
||||
2. Does it author characters or motion? → **here.**
|
||||
3. Neither? → it is **game-asset authoring** (trees, terrain, water, props,
|
||||
items, animals, VFX). Not gameplay, but not animation either — leave it there
|
||||
rather than making this repo a dumping ground.
|
||||
|
||||
Two things that keep tripping this up: some character tools have game-side tests
|
||||
(`brow_cover_math.py`, `generate_lena_brow_surface.py`, `test_mako_rig_math.py`),
|
||||
so they move with their tests or not at all — and **this repo has no test
|
||||
harness**, so receiving them means standing one up. And `anim_qc.py` staying is a
|
||||
deliberate split, not drift (see the two-QC-tools note above).
|
||||
|
||||
**Cross-repo paths in tools that moved here:** resolve the game checkout from
|
||||
`$ARIKI_GAME`, else guess the sibling directory and *verify it*, aborting with the
|
||||
path tried. Never let an ariki-relative default resolve silently wrong — same rule
|
||||
as `--target` above.
|
||||
|
||||
## See also
|
||||
|
||||
- `.agents/wiki/architecture/` — per-system detail (currently: this file covers
|
||||
the whole pipeline; split out if a subsystem grows its own doc).
|
||||
- `.agents/wiki/architecture/clothing-lane.md` — the clothing lane (reference
|
||||
image → Marvelous Designer → `clothing/` → worn outfit in-game).
|
||||
- `.agents/wiki/architecture/` — per-system detail (split a subsystem out when
|
||||
it grows its own doc).
|
||||
- `.agents/wiki/dances/REGISTRY.md` — naming/registry source of truth.
|
||||
- `.agents/wiki/iclone-bridge.md` — PC-lane routing stub.
|
||||
- `.agents/wiki/devops-reports/` — point-in-time audits and convergence reports.
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
# architecture/ — per-system detail
|
||||
|
||||
This repo's pipeline is small enough that `.agents/wiki/ARCHITECTURE.md` covers
|
||||
it end to end today; nothing has grown large enough yet to need its own
|
||||
per-system doc here. Split a topic out into this folder (and link it from
|
||||
`ARCHITECTURE.md`) when it does — likely candidates as the repo grows:
|
||||
| Page | Covers |
|
||||
|---|---|
|
||||
| `clothing-lane.md` | The clothing lane end to end: reference image → Marvelous Designer authoring → `clothing/` game-ification → worn outfit in ariki-game. Read before any garment work. |
|
||||
|
||||
`.agents/wiki/ARCHITECTURE.md` still covers the **animation** lane end to end.
|
||||
Split a further topic out into this folder (and link it from `ARCHITECTURE.md`)
|
||||
when it grows — likely candidates:
|
||||
|
||||
- Retarget-tool internals (per-source rig mapping, bone-name conventions).
|
||||
- Loop QC/fix algorithms (pose-gap, velocity-gap, root-drift math).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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/
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
[BoneMap]
|
||||
pelvis = Hips
|
||||
spine_01 = Spine
|
||||
spine_02 = Spine1
|
||||
spine_03 = Spine2
|
||||
clavicle_l = LeftShoulder
|
||||
upperarm_l = LeftArm
|
||||
lowerarm_l = LeftForeArm
|
||||
hand_l = LeftHand
|
||||
index_01_l = LeftHandIndex1
|
||||
index_02_l = LeftHandIndex2
|
||||
index_03_l = LeftHandIndex3
|
||||
middle_01_l = LeftHandMiddle1
|
||||
middle_02_l = LeftHandMiddle2
|
||||
middle_03_l = LeftHandMiddle3
|
||||
pinky_01_l = LeftHandPinky1
|
||||
pinky_02_l = LeftHandPinky2
|
||||
pinky_03_l = LeftHandPinky3
|
||||
ring_01_l = LeftHandRing1
|
||||
ring_02_l = LeftHandRing2
|
||||
ring_03_l = LeftHandRing3
|
||||
thumb_01_l = LeftHandThumb1
|
||||
thumb_02_l = LeftHandThumb2
|
||||
thumb_03_l = LeftHandThumb3
|
||||
lowerarm_twist_01_l = LeftForeArmRoll
|
||||
clavicle_r = RightShoulder
|
||||
upperarm_r = RightArm
|
||||
lowerarm_r = RightForeArm
|
||||
hand_r = RightHand
|
||||
index_01_r = RightHandIndex1
|
||||
index_02_r = RightHandIndex2
|
||||
index_03_r = RightHandIndex3
|
||||
middle_01_r = RightHandMiddle1
|
||||
middle_02_r = RightHandMiddle2
|
||||
middle_03_r = RightHandMiddle3
|
||||
pinky_01_r = RightHandPinky1
|
||||
pinky_02_r = RightHandPinky2
|
||||
pinky_03_r = RightHandPinky3
|
||||
ring_01_r = RightHandRing1
|
||||
ring_02_r = RightHandRing2
|
||||
ring_03_r = RightHandRing3
|
||||
thumb_01_r = RightHandThumb1
|
||||
thumb_02_r = RightHandThumb2
|
||||
thumb_03_r = RightHandThumb3
|
||||
lowerarm_twist_01_r = RightForeArmRoll
|
||||
neck_01 = Neck
|
||||
head = Head
|
||||
thigh_l = LeftUpLeg
|
||||
calf_l = LeftLeg
|
||||
calf_twist_01_l = LeftLegRoll
|
||||
foot_l = LeftFoot
|
||||
ball_l = LeftToeBase
|
||||
thigh_twist_01_l = LeftUpLegRoll
|
||||
thigh_r = RightUpLeg
|
||||
calf_r = RightLeg
|
||||
calf_twist_01_r = RightLegRoll
|
||||
foot_r = RightFoot
|
||||
ball_r = RightToeBase
|
||||
thigh_twist_01_r = RightUpLegRoll
|
||||
|
||||
|
||||
[BoneRotate]
|
||||
Armature = 0.,0.,0.,1.,
|
||||
Head = -0.07867400663,0.,0.,0.9969003966,
|
||||
Mannequin = 0.,0.,0.,1.,
|
||||
ball_l = 0.000137174795,-0.9643067668,0.2647870677,0.0004994245233,
|
||||
ball_leaf_l = -1.490116119e-08,-2.043089076e-08,0.,1.,
|
||||
ball_leaf_r = -1.490116119e-08,-2.043089076e-08,0.,1.,
|
||||
ball_r = 0.000137174795,-0.9643067668,0.2647870677,0.0004994245233,
|
||||
calf_l = 0.03658974033,-0.0001311736116,-4.79783824e-06,0.9993303626,
|
||||
calf_r = 0.03658974033,-0.0001311732359,-4.797521626e-06,0.9993303626,
|
||||
clavicle_l = -0.6040205276,-0.3451028898,-0.3567176515,0.6235508919,
|
||||
clavicle_r = -0.6040205276,0.3451028898,0.3567176515,0.6235508919,
|
||||
foot_l = -0.5290732885,-0.0003280904003,0.0003434501791,0.8485760012,
|
||||
foot_r = -0.529073238,-0.000328094934,0.000343457957,0.8485760327,
|
||||
hand_l = -0.008619738169,2.055116454e-09,2.384097373e-07,0.9999628494,
|
||||
hand_r = -0.008619738169,-2.055116454e-09,-2.384097373e-07,0.9999628494,
|
||||
index_01_l = 2.374276773e-07,0.7071042257,-2.374259611e-07,0.7071093367,
|
||||
index_01_r = 2.79574658e-07,-0.7071042257,2.795726372e-07,0.7071093367,
|
||||
index_02_l = 0.,-1.192095169e-07,0.,1.,
|
||||
index_02_r = 0.,1.192092896e-07,0.,1.,
|
||||
index_03_l = 0.,0.,0.,1.,
|
||||
index_03_r = 0.,0.,0.,1.,
|
||||
index_04_leaf_l = 0.,1.,0.,3.651776586e-06,
|
||||
index_04_leaf_r = 0.,-1.,0.,3.651776586e-06,
|
||||
lowerarm_l = 0.01718220495,-2.046332276e-05,4.431598459e-07,0.9998523748,
|
||||
lowerarm_r = 0.01718226455,2.052291489e-05,-5.037899558e-07,0.9998523738,
|
||||
middle_01_l = -0.01670215415,0.7069121606,-0.01670274404,0.706906821,
|
||||
middle_01_r = -0.01670211201,-0.7069121596,0.01670278618,0.706906822,
|
||||
middle_02_l = -1.183111765e-08,-1.192076729e-07,0.001513598491,0.9999988545,
|
||||
middle_02_r = -1.092088705e-08,1.192090663e-07,-0.001513598491,0.9999988545,
|
||||
middle_03_l = 7.981403618e-09,0.,-0.001129686941,0.9999993619,
|
||||
middle_03_r = 7.981398288e-09,0.,0.001129686941,0.9999993619,
|
||||
middle_04_leaf_l = 8.381904948e-09,1.,0.,3.651776586e-06,
|
||||
middle_04_leaf_r = 9.313226634e-09,-1.,0.,3.651776586e-06,
|
||||
neck_01 = 0.1109859382,0.,0.,0.9938219768,
|
||||
pelvis = 0.7904686183,0.,0.,0.6125025416,
|
||||
pinky_01_l = -0.02370575696,0.7067118885,-0.02370634669,0.7067066951,
|
||||
pinky_01_r = -0.02370567272,-0.7067118885,0.02370643093,0.7067066951,
|
||||
pinky_02_l = 5.844320298e-09,-1.192093468e-07,-0.00083428664,0.999999652,
|
||||
pinky_02_r = 5.574940833e-09,1.192095693e-07,0.0008342270354,0.999999652,
|
||||
pinky_03_l = -4.306290348e-09,0.,0.0006095209014,0.9999998142,
|
||||
pinky_03_r = -4.30592264e-09,0.,-0.0006094612968,0.9999998143,
|
||||
pinky_04_leaf_l = 2.235174179e-08,1.,0.,3.651776586e-06,
|
||||
pinky_04_leaf_r = 2.235174534e-08,-1.,0.,3.651776586e-06,
|
||||
ring_01_l = -0.0125884143,0.7069972761,-0.01258900426,0.7069921501,
|
||||
ring_01_r = -0.01258837216,-0.7069972754,0.0125890464,0.7069921509,
|
||||
ring_02_l = -8.65591156e-10,-1.192091509e-07,1.221615912e-05,0.9999999999,
|
||||
ring_02_r = 7.37720217e-10,1.19209174e-07,-1.221615912e-05,0.9999999999,
|
||||
ring_03_l = 7.539233332e-09,0.,-0.001067100938,0.9999994306,
|
||||
ring_03_r = 7.539231556e-09,0.,0.001067100938,0.9999994306,
|
||||
ring_04_leaf_l = 9.313225745e-10,1.,0.,3.651776586e-06,
|
||||
ring_04_leaf_r = 3.725291187e-09,-1.,0.,3.651776586e-06,
|
||||
root = 0.,0.,0.,1.,
|
||||
spine_01 = -0.06470268379,0.,0.,0.997904586,
|
||||
spine_02 = -0.07727999146,0.,0.,0.9970094297,
|
||||
spine_03 = -0.0002686381599,0.,0.,0.9999999639,
|
||||
thigh_l = 0.99248421,0.,0.,0.1223727621,
|
||||
thigh_r = 0.99248421,9.236081374e-09,-1.138763077e-09,0.1223727621,
|
||||
thumb_01_l = 0.2474131752,0.9457944096,0.203441308,0.05358441736,
|
||||
thumb_01_r = 0.247413226,-0.9457943736,-0.2034414034,0.0535844553,
|
||||
thumb_02_l = -0.0001358743711,-7.410041679e-05,4.796071313e-05,0.9999999869,
|
||||
thumb_02_r = -0.0001356769347,7.408892685e-05,-4.791442513e-05,0.9999999869,
|
||||
thumb_03_l = 0.0002479590938,8.092956012e-05,-0.0005353075979,0.9999998227,
|
||||
thumb_03_r = 0.000247953548,-8.101040989e-05,0.0005352930074,0.9999998227,
|
||||
thumb_04_leaf_l = 2.375739504e-08,0.5061779195,2.751002055e-07,0.8624290776,
|
||||
thumb_04_leaf_r = 2.159766835e-08,-0.5061777653,-2.770237225e-07,0.8624291681,
|
||||
upperarm_l = 0.1802646081,0.6838513444,-0.1798313456,0.6837490014,
|
||||
upperarm_r = 0.1802662816,-0.6838508941,0.1798330142,0.6837485718,
|
||||
[FloorContact]
|
||||
HandBottom = 2.769418716
|
||||
HandBack = 0.8673477173
|
||||
HandMiddle = 11.10624313
|
||||
HandFront = 12.55753326
|
||||
HandIn = 5.706539154
|
||||
HandOut = 5.25942421
|
||||
FootBottom = 13.2835741
|
||||
FootBack = 6.798443794
|
||||
FootMiddle = 15.87928581
|
||||
FootFront = 6.717291832
|
||||
FootIn = 5.117822647
|
||||
FootOut = 6.936565399
|
||||
|
||||
|
||||
[Property]
|
||||
AnkleHeight = 9.38811779
|
||||
AnkleSpacing = 10.03706551
|
||||
HipsForward = -15.
|
||||
AutoAnkleHeight = true
|
||||
AutoAnkleSpacing = true
|
||||
RollExtractionMode = false
|
||||
|
||||
|
||||
[RootTransform]
|
||||
Value = 1.,1.,1.,1.,0.,0.,0.,0.9999999404,0.,0.,0.,1.,0.,0.,0.,
|
||||
@@ -0,0 +1,183 @@
|
||||
[BoneMap]
|
||||
pelvis = Hips
|
||||
spine_01 = Spine
|
||||
spine_02 = Spine1
|
||||
spine_03 = Spine2
|
||||
clavicle_l = LeftShoulder
|
||||
upperarm_l = LeftArm
|
||||
lowerarm_l = LeftForeArm
|
||||
hand_l = LeftHand
|
||||
index_01_l = LeftHandIndex1
|
||||
index_02_l = LeftHandIndex2
|
||||
index_03_l = LeftHandIndex3
|
||||
middle_01_l = LeftHandMiddle1
|
||||
middle_02_l = LeftHandMiddle2
|
||||
middle_03_l = LeftHandMiddle3
|
||||
pinky_01_l = LeftHandPinky1
|
||||
pinky_02_l = LeftHandPinky2
|
||||
pinky_03_l = LeftHandPinky3
|
||||
ring_01_l = LeftHandRing1
|
||||
ring_02_l = LeftHandRing2
|
||||
ring_03_l = LeftHandRing3
|
||||
thumb_01_l = LeftHandThumb1
|
||||
thumb_02_l = LeftHandThumb2
|
||||
thumb_03_l = LeftHandThumb3
|
||||
lowerarm_twist_01_l = LeftForeArmRoll
|
||||
clavicle_r = RightShoulder
|
||||
upperarm_r = RightArm
|
||||
lowerarm_r = RightForeArm
|
||||
hand_r = RightHand
|
||||
index_01_r = RightHandIndex1
|
||||
index_02_r = RightHandIndex2
|
||||
index_03_r = RightHandIndex3
|
||||
middle_01_r = RightHandMiddle1
|
||||
middle_02_r = RightHandMiddle2
|
||||
middle_03_r = RightHandMiddle3
|
||||
pinky_01_r = RightHandPinky1
|
||||
pinky_02_r = RightHandPinky2
|
||||
pinky_03_r = RightHandPinky3
|
||||
ring_01_r = RightHandRing1
|
||||
ring_02_r = RightHandRing2
|
||||
ring_03_r = RightHandRing3
|
||||
thumb_01_r = RightHandThumb1
|
||||
thumb_02_r = RightHandThumb2
|
||||
thumb_03_r = RightHandThumb3
|
||||
lowerarm_twist_01_r = RightForeArmRoll
|
||||
neck_01 = Neck
|
||||
Head = Head
|
||||
thigh_l = LeftUpLeg
|
||||
calf_l = LeftLeg
|
||||
calf_twist_01_l = LeftLegRoll
|
||||
foot_l = LeftFoot
|
||||
ball_l = LeftToeBase
|
||||
thigh_twist_01_l = LeftUpLegRoll
|
||||
thigh_r = RightUpLeg
|
||||
calf_r = RightLeg
|
||||
calf_twist_01_r = RightLegRoll
|
||||
foot_r = RightFoot
|
||||
ball_r = RightToeBase
|
||||
thigh_twist_01_r = RightUpLegRoll
|
||||
|
||||
|
||||
[BoneRotate]
|
||||
RootNode(0) = 0.,0.,0.,1.,
|
||||
ThirdPersonCharacter_167 = 0.,0.,-0.7071066499,0.7071068883,
|
||||
CharacterMesh0 = 0.,0.,0.7071065903,0.7071069479,
|
||||
root = 0.,0.,0.,1.,
|
||||
pelvis = 0.,0.7071067691,0.,-0.7071067691,
|
||||
spine_01 = 0.,0.,-6.238857657e-002,0.9980519414,
|
||||
spine_02 = 0.,0.,0.1224197969,0.9924783707,
|
||||
spine_03 = 0.,0.,2.425260469e-002,0.9997058511,
|
||||
clavicle_l = 0.2175616771,0.6793626547,0.1070207208,0.692589283,
|
||||
upperarm_l = 4.885814339e-002,-1.803681627e-002,-0.233969152,0.9708480239,
|
||||
lowerarm_l = -7.146691531e-002,-1.314506307e-002,-1.677454822e-002,0.997215271,
|
||||
hand_l = -0.6664974093,3.67404297e-002,-4.807422683e-002,0.7430478334,
|
||||
index_01_l = 0.1349626333,-6.637491286e-002,4.533420503e-002,0.9875848889,
|
||||
index_02_l = 1.228160132e-002,-1.628758386e-003,-2.582049929e-004,0.9999231696,
|
||||
index_03_l = 1.123972051e-002,6.921002176e-003,2.099514008e-003,0.9999106526,
|
||||
middle_01_l = 3.692238033e-002,-5.181602761e-002,4.584874585e-002,0.99691993,
|
||||
middle_02_l = -1.943795197e-002,5.723292008e-003,-1.066895761e-002,0.9997378588,
|
||||
middle_03_l = -3.62048531e-003,-3.873198852e-002,7.702498697e-004,0.9992428422,
|
||||
pinky_01_l = -0.1161660478,-6.177603826e-002,4.257363081e-002,0.9903921485,
|
||||
pinky_02_l = 1.135612186e-002,-9.435054846e-003,-1.869967673e-003,0.9998892546,
|
||||
pinky_03_l = 5.186018068e-003,3.358753026e-002,5.650337413e-002,0.9978237748,
|
||||
ring_01_l = -8.321698755e-002,-6.469994783e-002,3.718987107e-002,0.9937332273,
|
||||
ring_02_l = 6.980994716e-003,8.726322092e-003,-6.09222152e-005,0.9999375343,
|
||||
ring_03_l = 1.812815899e-003,2.61958465e-002,-3.580457717e-002,0.9990138412,
|
||||
thumb_01_l = 0.5882545114,0.265966773,-0.135181129,0.7516279221,
|
||||
thumb_02_l = 2.229750156e-002,-6.388775259e-002,0.1282432675,0.98943156,
|
||||
thumb_03_l = 2.14139428e-002,-1.706168172e-003,5.907326192e-002,0.9980224967,
|
||||
RL_L_Hand01_Floor01 = 0.,0.,0.,1.,
|
||||
RL_L_Hand01_Floor02 = 0.,0.,0.,1.,
|
||||
RL_L_Hand01_Floor03 = 0.,0.,0.,1.,
|
||||
RL_L_Hand01_Floor04 = 0.,0.,0.,1.,
|
||||
RL_L_Hand01_Floor05 = 0.,0.,0.,1.,
|
||||
RL_L_Hand01_Floor06 = 0.,0.,0.,1.,
|
||||
lowerarm_twist_01_l = 0.,0.,0.,1.,
|
||||
upperarm_twist_01_l = 0.,0.,0.,1.,
|
||||
clavicle_r = -0.6798773408,0.2174819708,0.6920840144,-0.107182622,
|
||||
upperarm_r = 4.860173911e-002,-1.798832975e-002,-0.2336735427,0.9709330797,
|
||||
lowerarm_r = -7.170052826e-002,-1.280183531e-002,-1.666365191e-002,0.9972048402,
|
||||
hand_r = -0.6661629677,3.690450266e-002,-4.773123935e-002,0.7433617115,
|
||||
index_01_r = 0.1352881044,-6.594806165e-002,4.57360819e-002,0.9875506163,
|
||||
index_02_r = 1.221698243e-002,-1.745244022e-003,2.132323789e-005,0.9999238253,
|
||||
index_03_r = 1.133191865e-002,7.000599988e-003,1.665975666e-003,0.9999098778,
|
||||
middle_01_r = 3.718136624e-002,-5.157186091e-002,4.625619203e-002,0.9969043136,
|
||||
middle_02_r = -1.91414915e-002,5.435747094e-003,-1.036943309e-002,0.99974823,
|
||||
middle_03_r = -3.454518039e-003,-3.839058429e-002,7.380198804e-004,0.9992565513,
|
||||
pinky_01_r = -0.1161327064,-6.213930994e-002,4.253363237e-002,0.990375042,
|
||||
pinky_02_r = 1.132710464e-002,-9.618380107e-003,-1.636284287e-003,0.9998882413,
|
||||
pinky_03_r = 5.874346476e-003,3.336748108e-002,3.290480375e-002,0.9988840818,
|
||||
ring_01_r = -8.344670385e-002,-6.444695592e-002,3.724582493e-002,0.9937283397,
|
||||
ring_02_r = 6.980994716e-003,8.726322092e-003,-6.09222152e-005,0.9999375343,
|
||||
ring_03_r = 9.657530463e-004,2.585097915e-003,-3.577389941e-002,0.999356091,
|
||||
thumb_01_r = 0.5880680084,0.266130507,-0.1352667361,0.7517004013,
|
||||
thumb_02_r = 2.229461074e-002,-6.394065171e-002,0.1285541654,0.9893878698,
|
||||
thumb_03_r = 2.10607145e-002,-1.37078797e-003,5.934789404e-002,0.9980142117,
|
||||
RL_R_Hand01_Floor01 = 0.,0.,0.,1.,
|
||||
RL_R_Hand01_Floor02 = 0.,0.,0.,1.,
|
||||
RL_R_Hand01_Floor03 = 0.,0.,0.,1.,
|
||||
RL_R_Hand01_Floor04 = 0.,0.,0.,1.,
|
||||
RL_R_Hand01_Floor05 = 0.,0.,0.,1.,
|
||||
RL_R_Hand01_Floor06 = 0.,0.,0.,1.,
|
||||
lowerarm_twist_01_r = -0.1176272556,0.,0.,0.993057847,
|
||||
upperarm_twist_01_r = -0.1732347608,0.,0.,0.9848805666,
|
||||
neck_01 = 0.,0.,-0.2037104368,0.9790312052,
|
||||
Head = 0.,0.,0.1335420161,0.99104321,
|
||||
thigh_l = 7.410442084e-002,7.760480512e-004,1.044298802e-002,0.9971954823,
|
||||
calf_l = -4.964926094e-002,6.01673685e-003,-1.868829131e-004,0.9987486005,
|
||||
calf_twist_01_l = 2.808935009e-003,-1.933382242e-003,-7.612695452e-003,0.9999651909,
|
||||
foot_l = -3.623697907e-002,-1.086986624e-002,-1.354881749e-002,0.9991921782,
|
||||
ball_l = 8.008190343e-005,2.960354868e-005,-0.7186337113,0.6953888535,
|
||||
RL_L_Foot01_Floor01 = 0.,0.,0.,1.,
|
||||
RL_L_Foot01_Floor04 = 0.,0.,0.,1.,
|
||||
RL_L_Foot01_Floor02 = 0.,0.,0.,1.,
|
||||
RL_L_Foot01_Floor03 = 0.,0.,0.,1.,
|
||||
RL_L_Foot01_Floor05 = 0.,0.,0.,1.,
|
||||
RL_L_Foot01_Floor06 = 0.,0.,0.,1.,
|
||||
thigh_twist_01_l = -4.744359851e-002,2.148010935e-005,-4.911088618e-004,0.9988737702,
|
||||
thigh_r = -7.760486915e-004,7.410442084e-002,0.9971954823,-1.044299733e-002,
|
||||
calf_r = -4.971510917e-002,6.144450512e-003,-5.679383758e-004,0.9987443686,
|
||||
calf_twist_01_r = 2.807272831e-003,-1.933727413e-003,-7.612541318e-003,0.9999652505,
|
||||
foot_r = -3.591478616e-002,-1.086789835e-002,-1.348622516e-002,0.9992047548,
|
||||
ball_r = 8.007854194e-005,2.960378333e-005,-0.7186336517,0.6953887939,
|
||||
RL_R_Foot01_Floor01 = 0.,0.,0.,1.,
|
||||
RL_R_Foot01_Floor04 = 0.,0.,0.,1.,
|
||||
RL_R_Foot01_Floor02 = 0.,0.,0.,1.,
|
||||
RL_R_Foot01_Floor03 = 0.,0.,0.,1.,
|
||||
RL_R_Foot01_Floor05 = 0.,0.,0.,1.,
|
||||
RL_R_Foot01_Floor06 = 0.,0.,0.,1.,
|
||||
thigh_twist_01_r = -4.744526744e-002,2.189409315e-005,-4.911787109e-004,0.9988737106,
|
||||
ik_foot_root = 0.,0.,0.,1.,
|
||||
ik_foot_l = 2.053012326e-002,-0.712233305,1.172331907e-002,0.7015445828,
|
||||
ik_foot_r = -0.7015444636,1.172408182e-002,0.7122334242,2.053087763e-002,
|
||||
ik_hand_root = 0.,0.,0.,1.,
|
||||
CharacterMesh0(0) = 0.,0.,8.24325852e-008,1.,
|
||||
|
||||
|
||||
[FloorContact]
|
||||
HandBottom = 2.769418716
|
||||
HandBack = 0.8673477173
|
||||
HandMiddle = 11.10624313
|
||||
HandFront = 12.55753326
|
||||
HandIn = 5.706539154
|
||||
HandOut = 5.25942421
|
||||
FootBottom = 13.2835741
|
||||
FootBack = 6.798443794
|
||||
FootMiddle = 15.87928581
|
||||
FootFront = 6.717291832
|
||||
FootIn = 5.117822647
|
||||
FootOut = 6.936565399
|
||||
|
||||
|
||||
[Property]
|
||||
AnkleHeight = 9.38811779
|
||||
AnkleSpacing = 10.03706551
|
||||
HipsForward = -15.
|
||||
AutoAnkleHeight = true
|
||||
AutoAnkleSpacing = true
|
||||
RollExtractionMode = false
|
||||
|
||||
|
||||
[RootTransform]
|
||||
Value = 1.,1.,1.,1.,0.,0.,0.,0.9999999404,0.,0.,0.,1.,0.,0.,0.,
|
||||
|
After Width: | Height: | Size: 220 KiB |
@@ -0,0 +1,2 @@
|
||||
The female mannequin doesn’t include the animations, as duplicating them wouldn’t make sense.
|
||||
You can easily retarget them from the library files in Blender, or even better, directly in your engine, since both mannequins share the same rig and very similar proportions.
|
||||
|
After Width: | Height: | Size: 488 KiB |
@@ -0,0 +1,12 @@
|
||||
-------------------------------------------------------
|
||||
License:
|
||||
CC0 1.0 Universal (CC0 1.0)
|
||||
Public Domain Dedication
|
||||
https://creativecommons.org/publicdomain/zero/1.0/
|
||||
|
||||
------------------------------------------------------
|
||||
Models by @Quaternius
|
||||
Consider supporting me on Patreon!
|
||||
|
||||
https://www.patreon.com/quaternius
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
The Universal Animation Library comes in two files: the one ending in _RM has root motion baked into every animation, while the other has root motion disabled.
|
||||
If you ever need to re-export from Blender, just install the included Blender addon (root_motion_toggle.py), which lets you turn root motion on or off.
|
||||
|
||||
|
||||
Explore all the animations in the Animation Viewer!
|
||||
https://quaternius.com/animviewer.html
|
||||
-------------------------------------------------------
|
||||
License:
|
||||
CC0 1.0 Universal (CC0 1.0)
|
||||
Public Domain Dedication
|
||||
https://creativecommons.org/publicdomain/zero/1.0/
|
||||
|
||||
------------------------------------------------------
|
||||
Models by @Quaternius
|
||||
Consider supporting me on Patreon!
|
||||
|
||||
https://www.patreon.com/quaternius
|
||||
|
||||
-------------------------------------------------------
|
||||
Join the Discord Server:
|
||||
https://discord.gg/vJqnRUYRfT
|
||||
|
After Width: | Height: | Size: 232 KiB |
|
After Width: | Height: | Size: 145 KiB |
@@ -0,0 +1,87 @@
|
||||
bl_info = {
|
||||
"name": "Root Motion Toggle",
|
||||
"author": "Quaternius",
|
||||
"version": (1, 0, 0),
|
||||
"blender": (4, 5, 0),
|
||||
"location": "View3D > Sidebar > Root Motion",
|
||||
"description": "Enable or disable root motion muting across all animations",
|
||||
"category": "Animation",
|
||||
}
|
||||
|
||||
import bpy
|
||||
|
||||
|
||||
def set_root_motion_mute(mute: bool):
|
||||
actions_affected = 0
|
||||
|
||||
for action in bpy.data.actions:
|
||||
group = action.groups.get("root")
|
||||
if group is not None:
|
||||
group.mute = mute
|
||||
for fcurve in action.fcurves:
|
||||
if fcurve.group == group:
|
||||
fcurve.mute = mute
|
||||
actions_affected += 1
|
||||
|
||||
return actions_affected
|
||||
|
||||
|
||||
class ROOTMOTION_OT_enable_all(bpy.types.Operator):
|
||||
bl_idname = "rootmotion.enable_all"
|
||||
bl_label = "Enable All Root Motion"
|
||||
bl_description = "Unmute the root bone channel group in all animations"
|
||||
bl_options = {'REGISTER', 'UNDO'}
|
||||
|
||||
def execute(self, context):
|
||||
actions = set_root_motion_mute(False)
|
||||
self.report({'INFO'}, f"Root motion enabled across {actions} action(s)")
|
||||
return {'FINISHED'}
|
||||
|
||||
|
||||
class ROOTMOTION_OT_disable_all(bpy.types.Operator):
|
||||
bl_idname = "rootmotion.disable_all"
|
||||
bl_label = "Disable All Root Motion"
|
||||
bl_description = "Mute the root bone channel group in all animations"
|
||||
bl_options = {'REGISTER', 'UNDO'}
|
||||
|
||||
def execute(self, context):
|
||||
actions = set_root_motion_mute(True)
|
||||
self.report({'INFO'}, f"Root motion disabled across {actions} action(s)")
|
||||
return {'FINISHED'}
|
||||
|
||||
|
||||
class ROOTMOTION_PT_panel(bpy.types.Panel):
|
||||
bl_label = "Root Motion"
|
||||
bl_idname = "ROOTMOTION_PT_panel"
|
||||
bl_space_type = 'VIEW_3D'
|
||||
bl_region_type = 'UI'
|
||||
bl_category = "Root Motion"
|
||||
|
||||
def draw(self, context):
|
||||
layout = self.layout
|
||||
col = layout.column(align=True)
|
||||
col.scale_y = 1.4
|
||||
col.operator("rootmotion.enable_all", text="Enable All Root Motion", icon='PLAY')
|
||||
col.separator(factor=0.5)
|
||||
col.operator("rootmotion.disable_all", text="Disable All Root Motion", icon='PAUSE')
|
||||
|
||||
|
||||
classes = (
|
||||
ROOTMOTION_OT_enable_all,
|
||||
ROOTMOTION_OT_disable_all,
|
||||
ROOTMOTION_PT_panel,
|
||||
)
|
||||
|
||||
|
||||
def register():
|
||||
for cls in classes:
|
||||
bpy.utils.register_class(cls)
|
||||
|
||||
|
||||
def unregister():
|
||||
for cls in reversed(classes):
|
||||
bpy.utils.unregister_class(cls)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
register()
|
||||
|
After Width: | Height: | Size: 488 KiB |
@@ -0,0 +1,12 @@
|
||||
-------------------------------------------------------
|
||||
License:
|
||||
CC0 1.0 Universal (CC0 1.0)
|
||||
Public Domain Dedication
|
||||
https://creativecommons.org/publicdomain/zero/1.0/
|
||||
|
||||
------------------------------------------------------
|
||||
Models by @Quaternius
|
||||
Consider supporting me on Patreon!
|
||||
|
||||
https://www.patreon.com/quaternius
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
The Universal Animation Library comes in two files: the one ending in _RM has root motion baked into every animation, while the other has root motion disabled.
|
||||
|
||||
|
||||
Explore all the animations in the Animation Viewer!
|
||||
https://quaternius.com/animviewer.html
|
||||
-------------------------------------------------------
|
||||
License:
|
||||
CC0 1.0 Universal (CC0 1.0)
|
||||
Public Domain Dedication
|
||||
https://creativecommons.org/publicdomain/zero/1.0/
|
||||
|
||||
------------------------------------------------------
|
||||
Models by @Quaternius
|
||||
Consider supporting me on Patreon!
|
||||
|
||||
https://www.patreon.com/quaternius
|
||||
|
||||
-------------------------------------------------------
|
||||
Join the Discord Server:
|
||||
https://discord.gg/vJqnRUYRfT
|
||||
|
After Width: | Height: | Size: 232 KiB |
|
After Width: | Height: | Size: 145 KiB |
@@ -0,0 +1,132 @@
|
||||
# Character Model Registry
|
||||
|
||||
Naming law for `characters/`. **Scope: this system applies only to newly created
|
||||
variants derived from mako and lena** — every such artifact from here on, *wherever
|
||||
it is produced*. (This clause used to read "anything born in this folder", which let
|
||||
the AccuRig rig baits get built into `ariki-game/assets/models/characters/rig-work/`
|
||||
and slip the net entirely; they were moved here 2026-08-06. A character artifact
|
||||
generated in another repo is still governed by this file and still belongs under
|
||||
`characters/`.)
|
||||
Legacy models elsewhere — game-side `derived-bodies/`, old intermediates, historical
|
||||
race-sources — keep their names and are NOT registered here; they will eventually be
|
||||
replaced by files that follow this system.
|
||||
|
||||
Modeled on the dance registry (`.agents/wiki/dances/REGISTRY.md`): numbers are never
|
||||
reused, superseded files are archived, never deleted.
|
||||
|
||||
## Filename grammar
|
||||
|
||||
```
|
||||
<character>[_<variant>...]_<stage>_<format>_v<NN>.<ext>
|
||||
```
|
||||
|
||||
- **character** — registered name from the Characters table below (`lena`, `mako`).
|
||||
New characters get a row here before their first file exists.
|
||||
- **variant** — optional sculpt-level descriptors (`elder`, `muscular`). A new
|
||||
variant is a new folder and a new Characters-table row.
|
||||
- **stage** — exactly one token from the closed vocabulary below.
|
||||
- **format** — file format tag matching the extension (`fbx`, `glb`).
|
||||
- **v\<NN\>** — mandatory two-digit version, per-artifact.
|
||||
|
||||
All lowercase snake_case, `[a-z0-9_]` only. No dates, no status words, no hyphens.
|
||||
|
||||
### Stage vocabulary (closed set — extending it is an edit to this table)
|
||||
|
||||
| token | meaning |
|
||||
|---|---|
|
||||
| `sculpt` | unrigged full-res mesh (sculpt-level alteration) |
|
||||
| `decimated` | post-decimation, unrigged |
|
||||
| `tpose` | pose-prep export for a rigging tool |
|
||||
| `accurig` | AccuRig output, CC_Base_* skeleton |
|
||||
| `mixamo` | Mixamo-rigged (legacy lane) |
|
||||
| `quatskin` | 65-bone Quaternius UAL game skeleton |
|
||||
| `lod<NN>` | LOD at NN% of tris (appended after `quatskin`) |
|
||||
| `toned` | skin-tone pass (appended after `quatskin`) |
|
||||
|
||||
## The rules
|
||||
|
||||
1. **Originals are read-only; delivered names immutable** — even misspelled or
|
||||
inconsistent (`female_lena_tripo` keeps its missing `_v1`). Identity is recorded
|
||||
in this registry, never fixed on disk. Renaming would fork identity vs the
|
||||
name-synced mirror in `ariki-game/assets/models/characters/race-sources/` and
|
||||
risk breaking FBX-internal texture paths.
|
||||
2. Every new derived file matches the grammar above and starts with a registered
|
||||
character name. Canonical male spelling is **`mako`** — the AccuRig-era `moka`
|
||||
typo and `moko` are never used in new files.
|
||||
3. Exactly one stage token per filename.
|
||||
4. Version is per-artifact; **ancestry lives in this registry, never in the name**
|
||||
(a `quatskin_v01` built from `decimated_v03` does not encode the parent's version).
|
||||
5. **Status lives in this registry, never in a filename** — no `_candidate`,
|
||||
`_final`, `_locked`.
|
||||
6. New geometry identity = new character-variant folder (e.g. `female/lena_elder/`);
|
||||
re-run of the same recipe = version bump. Test: "would the game treat it as a
|
||||
different character/body?" → new variant; "a better attempt at the same thing?"
|
||||
→ bump. Numbers never reused.
|
||||
7. FBX or loose-texture models get a folder named exactly the model basename
|
||||
(e.g. `mako_accurig_fbx_v01/`); loose textures inside are `<character>_<map>.<ext>`
|
||||
(`mako_basecolor.jpg`) — no version chains in texture names. `.fbm/` folders and
|
||||
Tripo UUID internals are never renamed.
|
||||
8. **Boundary law:** snake_case = animation-repo working names; PascalCase `Ariki_*`
|
||||
= game-repo ship names. The rename happens exactly once, at import into
|
||||
`ariki-game/assets/quaternius/derived-bodies/`, and is recorded in the ship-name
|
||||
column below. Never create `Ariki_*` files in this repo.
|
||||
9. Superseded files move to `characters/archive/` — never deleted, never renamed.
|
||||
10. Every new file under `characters/` (outside `archive/`) gets a row in the
|
||||
Derived artifacts table before its first commit.
|
||||
11. **`characters/rig-work/` is exempt from the grammar and from rule 10** — it
|
||||
holds disposable AccuRig rig carriers ("baits"): decimated FBX copies whose
|
||||
only job is to carry a skeleton out of AccuRig and back. They are never
|
||||
shipped and never contribute geometry to a registered artifact (the graft
|
||||
takes geometry from the GLB parent and only the skeleton from the bait), so
|
||||
they get no row of their own — instead the grafted body's **notes** column
|
||||
names the bait it got its skeleton from. Keeping their tool-era names is
|
||||
deliberate: they must stay recognisable as throwaway. See that folder's
|
||||
`README.md`.
|
||||
|
||||
## Originals
|
||||
|
||||
Never modified, never renamed, never resaved in place. Verify with
|
||||
`Get-FileHash -Algorithm SHA256`.
|
||||
|
||||
| file | sha256 | delivered by | tool | date | identity note |
|
||||
|---|---|---|---|---|---|
|
||||
| `originals/male/male_base_bald_tripo_v1.fbx` | `f7c884bb8b8a71351b1448fa355658459a61a2537fdd48f9c72e52709693608c` | Özlem | Tripo | 2026-08-04 (copied from ariki-game race-sources) | mako generation 1, unrigged A-pose ~1.9M tris |
|
||||
| `originals/male/male_base_bald_tripo_v1.glb` | `59df0de4b097428324f6b9048e0eeb7e8e034062aa8c8dad7e1eba1937be8aca` | Özlem | Tripo | 2026-08-04 (copied from ariki-game race-sources) | mako generation 1 |
|
||||
| `originals/female/female_lena_tripo.fbx` | `c3a7cb1bf4958b27bd81b6bf73fd132c88f7a582d045c8936992d5d22bfd4e8e` | Özlem | Tripo | 2026-08-04 (copied from ariki-game race-sources) | lena generation 1 — delivered name has no `_v1`; treat as v1 |
|
||||
| `originals/female/female_lena_tripo.glb` | `de17a1e7d56cefc3e07596bfb08efc65b41d04fac709c27b76500729e4fbb4b8` | Özlem | Tripo | 2026-08-04 (copied from ariki-game race-sources) | lena generation 1 |
|
||||
|
||||
### Canonical-name aliases
|
||||
|
||||
Byte-identical copies of the originals under grammar-conforming names, so tools can
|
||||
reference canonical names without touching the immutable delivered files. Same
|
||||
read-only guard, same checksums as their sources above.
|
||||
|
||||
| file | byte-identical to | date |
|
||||
|---|---|---|
|
||||
| `originals/female/lena_sculpt_fbx_v01.fbx` | `female_lena_tripo.fbx` | 2026-08-04 |
|
||||
| `originals/female/lena_sculpt_glb_v01.glb` | `female_lena_tripo.glb` | 2026-08-04 |
|
||||
| `originals/male/mako_sculpt_fbx_v01.fbx` | `male_base_bald_tripo_v1.fbx` | 2026-08-04 |
|
||||
| `originals/male/mako_sculpt_glb_v01.glb` | `male_base_bald_tripo_v1.glb` | 2026-08-04 |
|
||||
|
||||
## Characters
|
||||
|
||||
| character | gender | parent original | description | status |
|
||||
|---|---|---|---|---|
|
||||
| `mako` | male | `male_base_bald_tripo_v1` | bald base male, Tripo sculpt. Spelling history: game wiki "Mako", AccuRig-era typo "moka" — canonical filename token is `mako` | active |
|
||||
| `lena` | female | `female_lena_tripo` | base female, Tripo sculpt | active |
|
||||
| `lena_nude` | female | `female_lena_tripo` | lena with the sculpted-in underwear removed: no bra, no briefs, breasts with volume and no nipples, featureless crotch. Separate variant rather than a version of `lena` because the game selects it as a different body (rule 6) | active |
|
||||
|
||||
## Derived artifacts
|
||||
|
||||
Status vocabulary: `wip / candidate / approved / shipped / archived / rejected`.
|
||||
|
||||
| name | character | parent file | stage | tool/script | date | status | ship name | notes |
|
||||
|---|---|---|---|---|---|---|---|---|
|
||||
| `female/lena_nude/lena_nude_quatskin_glb_v01.glb` | `lena_nude` | game-side `Ariki_Female_QuatSkin.glb` (+ `Ariki_Female_QuatSkin_ChestV1.glb` as breast-shape donor) | `quatskin` | `tools/make_lena_nude_body.py` (`--size 1.0`) | 2026-08-04 | candidate | `Ariki_Female_QuatSkin_Nude.glb` (not yet released) | sha256 `931c0948a3b0201dbfbdd4d2671c3ae41688afc592a29e332bc2bab234aa1ad3`. Texture embedded, internal image name `lena_nude_basecolor` (no ship name baked inside a WIP asset). 31,670 v / 65 joints / 1.777 m; passes `tools/verify_body_variant.py` against the canonical body. Vertex count is below stock by design — the base mesh's open slits are welded shut, since the painted underwear had been hiding them. `--size` is breast volume; a different size is a version bump, not a new variant |
|
||||
| `female/lena_nude/lena_nude_basecolor.png` | `lena_nude` | game-side `Ariki_Female_QuatSkin_Lena_Body_Toned.png` | — (texture) | `tools/_bake_nude_body_texture.py` | 2026-08-04 | candidate | `Ariki_Female_QuatSkin_Nude_Lena_Body_Toned_nude.png` (not yet released) | sha256 `576fcf614cfcb19befd5bd4fb57210b526db2754f7d35468b21252f31528c9f2`. 4096² albedo with bra and briefs inpainted out to uniform skin, no tan line. Loose copy for iteration; the shipped GLB embeds it. Unversioned per rule 7 |
|
||||
| `female/lena_nude/lena_nude_sculpt_glb_v01.glb` | `lena_nude` | `originals/female/female_lena_tripo.glb` (hires, pre-decimation) + game-side `Ariki_Male_QuatBody_D_RigCandidate.glb` (crotch donor) | `sculpt` | `female/lena_nude/hires_claude/` pipeline (00 weld → 03 sculpt → 06 crotch+membrane → 05/05b texture+grade → 08 export); reference `female/lena_nude/reference_breasts.jpg` | 2026-08-05 | candidate | — (game asset derives via decimation later) | sha256 `cb781cf376dc046b108d1c00e2368e0ed7e74fde5a783bedc6fc435818e4b2c7`. 953,966 v / 1,907,931 tris / 0.9792 units tall, unrigged. Full-density nude: garment membrane-removed, C-cup round breasts (apex projection 0.040 units, cleavage gap 7 mm), no nipples; crotch = male-GLB donor + bi-harmonic gusset heal (featureless). Basecolor + normal + rm embedded; fabric repainted by 3D-nearest skin fill, rosy chest V graded out to uniform skin. Masters: `hires_claude/06_final.blend` (geometry), `07_polished.blend` (textured) |
|
||||
| `female/lena_nude/lena_nude_sculpt_glb_v02.glb` | `lena_nude` | v01 geometry + `originals/female/female_lena_tripo.glb` textures | `sculpt` | `hires_claude/05_texture.py` in `patch` mode (garment texels only, mirror-averaged fill from her own skin) + `06f` fold melt; master `hires_claude/10_patch.blend` | 2026-08-05 | candidate | — | sha256 `4a8c811305352a5fc60a58f6146c6797de03131da56dbd4073f90580d2e59e9b`. Same nude geometry as v01 but ORIGINAL skin everywhere (rosy chest V, arm/leg tones kept); only the bra/briefs/strap/crotch texels are refilled. Jeremy's preference after v01's global tone grade changed too much of her look |
|
||||
| `female/lena_nude/lena_nude_sculpt_glb_v04.glb` | `lena_nude` | `lena_nude_sculpt_glb_v03.glb` (master `hires_claude/29b_final.blend`) | `sculpt` | `hires_claude/33_bust_variants.py` (size study) → `34_apply_bust.py --k 0.5`; master `hires_claude/34_v04.blend` | 2026-08-06 | candidate | — | sha256 `88931098f27915f5a498a1bccc014e800af7e8fff3e1d43faf8b97616c307910`. 860,389 v / 1,721,805 tris, geometry identical to v03 except the bust. **Guided by the approved concept turnaround** (`exchange/INBOX/lena-base-turnaround`, GPT Image 2, approved by Can 2026-08-06): `32_ref_measure.py` compared reference silhouette against mesh and found proportions already agree — 6.43 vs 6.08 heads, waist/hip 0.504 vs 0.505, widths within 5% — with the bust the one real gap. Breast forms rescaled to 50% of their projection off a bi-harmonic chest wall, taking bust projection +0.0210 → **+0.0155 H against the reference's +0.0156**. 68,781 verts moved (max 27.8 mm, median 5.5 mm in-region), zero movement outside the chest region, height unchanged. Texture/UVs untouched from v03. Caveats: the projection metric saturates below k≈0.5 (k=0.35 also reads +0.0154) and the reference reading may be inflated by the T-pose arm crossing the chest in the side view, so 0.5 is a floor rather than a midpoint; front renders cannot distinguish sizes (frontal key light flattens the chest) — judge from the side |
|
||||
| `female/lena_nude/lena_nude_decimated_glb_v01.glb` | `lena_nude` | `lena_nude_sculpt_glb_v03.glb` (master `hires_claude/29b_final.blend`) | `decimated` | `hires_claude/30_decimate.py` (COLLAPSE ratio **0.037728**, bisected to hit the vertex target) + `30b_fix_winding.py` | 2026-08-06 | candidate | — | sha256 `b11940bb46732ae0a60e1624e88e76fa48dcc3e3af49a6363348521bb7c52cc8`. 31,964 v / 64,957 tris in Blender (46,810 / 64,957 as exported, UV seams split verts) — 3.72% of the sculpt, matched to `lena_nude_quatskin_glb_v01.glb`'s 31,670 v at Jeremy's request. Unrigged, no skins, transforms applied; basecolor + normal + rm embedded. Height preserved to +0.12 mm, bbox unchanged. **Built as input for the re-atlas lane** (`24_seams.py`): collapse destroys Tripo's 5,870-chart UV layout, so the carried TEXCOORD_0 smears badly and is expected to be replaced, not reused. Residual: 25 backfacing tris of 64,957 (down from 61; the remainder sit in non-manifold knots where consistent-orientation oscillates), plus the sculpt's inherited 835 boundary / 1649 non-manifold edges |
|
||||
| `female/lena_nude/lena_nude_sculpt_glb_v03.glb` | `lena_nude` | v02 chain head `hires_claude/10_welded.blend` | `sculpt` | `hires_claude/` 17 line heal → 26 median despeckle + cleavage fillet → 25 flipped-face repair → 29 on-body tone levelling → 08 export; masters `27_clean.blend` (geometry), `29b_final.blend` (textured) | 2026-08-06 | candidate | — | sha256 `7a8fe4c45d3af6acb7b39c2fac5fcc2aadf910f8d61a4a7d046911125a6e8baa`. 860,389 v / 1,721,805 tris. Jeremy's three v02 notes: (1) cut lines — reduced, not gone: shading kinks >12° 9704 → ~6900, ribcage/thigh lines gone frontally, waistband + underbust still legible under raking light; (2) discolouration — the pale bra/briefs panels are gone, on-body tone gap patch-vs-skin 0.0102 → 0.0068 luma; (3) cleavage — sternum fillet radius 9.5 mm → no sharp sample, notch depth 44 → 31 mm. Also 1511 → 10 flipped faces (the black dashes). Still open: 1649 non-manifold + 830 boundary edges (every fill refused them). See the lane README for the four dead ends this version ruled out |
|
||||
| `female/lena_nude/lena_nude_decimated_glb_v02.glb` | `lena_nude` | `lena_nude_decimated_glb_v01.glb` (geometry byte-for-byte unchanged) | `decimated` | `hires_claude/24_seams.py` (anatomical seams + minimum-stretch unwrap) then `hires_claude/31_reatlas.py` (map resample) | 2026-08-06 | candidate | — | sha256 `cd3d53550470ae5a1958dc473ce11793f746b13f78ac911e6d426b6f7b9f5825`. **Re-atlas only — same 31,964 v / 64,957 tris, same shape, same material, unrigged.** Replaces Tripo's 5,870-chart atlas with 14 anatomical charts (torso, head, 2 arms, 2 legs, 2 hands ×2, 2 feet ×2): coverage 62.0→68.6%, texel density spread 1.8x→1.00x, per-triangle area-scale p95 2.55→1.77 / p99 4.25→2.27. Exported vertex count drops 46,810→42,980 because the new layout has far fewer UV seams. Maps resampled into the new layout at 4096², normal map rotated per face into its new tangent frame (p50 81°). Renders identical to v01: mean pixel \|Δ\| 0.0012, p99 0.0118 over five views (`hires_claude/33_ab.py`). Version bump, not a new variant, per rule 6 — same body, better atlas |
|
||||
@@ -0,0 +1,61 @@
|
||||
# Handoff: decimated nude Lena → retexture / re-atlas lane
|
||||
|
||||
**2026-08-06.** Jeremy asked for the healed nude Lena decimated to the game-body budget and passed
|
||||
to the retexture / re-atlas work, "to see how they do".
|
||||
|
||||
## The file
|
||||
|
||||
`characters/female/lena_nude/lena_nude_decimated_glb_v01.glb`
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| sha256 | `b11940bb46732ae0a60e1624e88e76fa48dcc3e3af49a6363348521bb7c52cc8` |
|
||||
| parent | `lena_nude_sculpt_glb_v03.glb` (master `hires_claude/29b_final.blend`) |
|
||||
| recipe | `hires_claude/30_decimate.py` then `30b_fix_winding.py` |
|
||||
| ratio | Decimate **COLLAPSE 0.037728** — bisected, not guessed |
|
||||
| density | 31,964 v / 64,957 tris in Blender (46,810 v as exported; UV seams split verts) |
|
||||
| why that density | matched to the shipped `lena_nude_quatskin_glb_v01.glb` (31,670 v), Jeremy's call |
|
||||
| rig | none — unrigged, no skins, transforms applied |
|
||||
| maps | basecolor + normal + rm embedded (3 images, wired baseColor / metallicRoughness / normal) |
|
||||
|
||||
Ready to load directly: `24_seams.py` accepts a `.glb` as its first argument and applies transforms
|
||||
itself, so this drops in with no preparation.
|
||||
|
||||
## Read this before you spend a pass on the UVs
|
||||
|
||||
**The carried `TEXCOORD_0` is not worth preserving.** Collapse decimation shreds Tripo's
|
||||
5,870-chart atlas — in the textured review render (`hires_claude/review_30/chest_tex_0.png`) the
|
||||
transplanted skin grain smears into streaks and the throat picks up a red blotch. That is expected
|
||||
and is exactly the problem the ~14-chart anatomical atlas solves. Judge this mesh from
|
||||
`review_30/full_clay_0.png` (clay, no material) and treat the UVs as scrap.
|
||||
|
||||
**Decimation improved one thing.** The waistband/underbust lines that survive on the hires sculpt
|
||||
are nearly invisible at 32k — the collapse averaged them out. So the residual line problem is a
|
||||
hires-only concern; don't go hunting for it here.
|
||||
|
||||
## Known defects being handed over
|
||||
|
||||
- **25 backfacing triangles** of 64,957. Down from 61 via `30b_fix_winding.py`, but the remainder
|
||||
sit in non-manifold knots where `normals_make_consistent` oscillates (61 → 260 → 39 → 25 → 25).
|
||||
They render black. If your pass rebuilds topology, they will likely vanish for free.
|
||||
- **835 boundary edges / 1649 non-manifold edges**, inherited from the sculpt. Every fill attempt
|
||||
refused them (`bmesh.ops.holes_fill`, edit-mode `mesh.fill_holes`, and a per-loop pass) — they
|
||||
are dangling flaps and slivers, not perforations. Documented in the lane README.
|
||||
- **The head is untouched original Tripo output.** Every geometry pass in this lane stopped below
|
||||
the chin (z < 0.90) on purpose, so lips/nostrils/eyes still have the source smearing. If the
|
||||
retexture covers the face, that is new ground, not a regression.
|
||||
- **She reads glossy** — the `rm` map was neutralised to a median over the old garment texels, so
|
||||
the whole body is shinier than skin should be. Untouched deliberately: an earlier attempt at
|
||||
correcting roughness made her plasticky (see the lane README's fourth dead end).
|
||||
|
||||
## Provenance the three fixes came from
|
||||
|
||||
v03 addressed Jeremy's three notes on v02 — cut lines, discoloured bra/crotch patches, and the
|
||||
cleavage. Measured outcomes and the four dead ends that were ruled out on the way are in
|
||||
`README.md` under "Hi-res sculpt v03". Worth skimming before re-treading the geometry.
|
||||
|
||||
## Numbering note
|
||||
|
||||
This lane had two agents working in it simultaneously on 2026-08-06. `20`/`21` exist twice under
|
||||
different filenames (`20_atlas_probe.py` + `20_cleavage.py`, `21_seam_probe.py` + `21_tone.py`).
|
||||
Nothing was overwritten, but pick a fresh number rather than assuming the next one is free.
|
||||
@@ -0,0 +1,87 @@
|
||||
# Handoff: re-atlased nude Lena — `lena_nude_decimated_glb_v02.glb`
|
||||
|
||||
**2026-08-06.** Jeremy asked for the decimated nude body re-atlased and versioned. This is a
|
||||
**UV + texture change only**: the geometry is byte-for-byte the v01 mesh.
|
||||
|
||||
## The file
|
||||
|
||||
`characters/female/lena_nude/lena_nude_decimated_glb_v02.glb`
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| sha256 | `cd3d53550470ae5a1958dc473ce11793f746b13f78ac911e6d426b6f7b9f5825` |
|
||||
| parent | `lena_nude_decimated_glb_v01.glb` |
|
||||
| recipe | `hires_claude/24_seams.py` → `hires_claude/31_reatlas.py` |
|
||||
| mesh | 31,964 v / 64,957 tris in Blender — **identical to v01** |
|
||||
| exported verts | 42,980 (v01: 46,810) — the new layout needs fewer seam splits |
|
||||
| maps | basecolor + normal + rm embedded, 4096², JPEG q95, correct colorspaces |
|
||||
| rig | none — unrigged, transforms applied |
|
||||
|
||||
Rebuild:
|
||||
|
||||
```
|
||||
blender --background --python 24_seams.py -- ../lena_nude_decimated_glb_v01.glb atlas_v02
|
||||
blender --background --python 31_reatlas.py -- atlas_v02/seamed.blend atlas_v02 \
|
||||
../lena_nude_decimated_glb_v02.glb
|
||||
```
|
||||
|
||||
## What changed
|
||||
|
||||
| | Tripo atlas (v01) | new atlas (v02) |
|
||||
|---|---|---|
|
||||
| UV islands | 5,870 | 968 |
|
||||
| charts holding 99% of the area | 84 | **14** |
|
||||
| atlas coverage | 62.0% | **68.6%** |
|
||||
| texel-density spread | 1.8× | **1.00×** |
|
||||
| per-triangle area-scale p95 / p99 | — | 1.77 / 2.27 |
|
||||
|
||||
The 14 charts are torso, head, two arms, two legs, two hands (back + palm each) and two feet
|
||||
(upper + sole each). Seams run up the back midline, the back of each arm and the inner leg, with
|
||||
rings at neck, shoulder, wrist, hip and ankle — every landmark measured off the mesh's own
|
||||
cross-section radius profiles, so the rules port to any density.
|
||||
|
||||
**It looks the same, on purpose.** Five matched views diff at mean \|Δ\| 0.0012, p99 0.0118, with
|
||||
0.19% of body pixels over 0.05 (`33_ab.py`). Her original tones are untouched — this pass bought a
|
||||
paintable, uniform-density layout, not a new look.
|
||||
|
||||
## Three traps this pass hit, so you don't
|
||||
|
||||
1. **Colorspace is not cosmetic.** `image.pixels` returns *linear* values for an sRGB image and
|
||||
*raw* values for a Non-Color one, and re-encodes the same way on save. The normal and rm maps
|
||||
are Non-Color; writing them into default (sRGB) images gamma-encoded them, so 0.5 came back as
|
||||
0.21 — every stored normal became a large false perturbation and she rendered dark and
|
||||
wet-plastic. New maps must inherit `colorspace_settings.name` from their source.
|
||||
2. **Use the minimum-stretch solver, not angle-based.** ABF is conformal: it preserves angles and
|
||||
is free to crush area. At her folds it squashed 4,502 triangles (3.7% of her surface) below one
|
||||
texel, and they came out untextured. `method='MINIMUM_STRETCH'` took per-triangle area-scale p05
|
||||
from 0.03 to 0.33 and the untextured count from 4,502 to 569 (0.56%, all genuine sub-texel
|
||||
slivers absorbed by the 16 px padding pass).
|
||||
3. **Watch the LOW tail of the distortion stat.** p95/p99 measure stretching; it was p05 = 0.03 —
|
||||
crushing — that was losing whole patches. I read past it twice.
|
||||
|
||||
Also tried and rejected: splitting the 1,649 non-manifold edges before unwrapping. It cost +3,512
|
||||
verts and made the collapsed-face count *worse* (4,502 → 5,482), because the collapse was a solver
|
||||
problem, not a topology one.
|
||||
|
||||
## Still broken, and not from this pass
|
||||
|
||||
**The ragged grey tears on the face, across the underbust, and as flecks down the arms and shins
|
||||
are in v01 already** — I rendered v01 through the same script to confirm, and they are identical in
|
||||
position and shape (`hires_claude/beauty_input_v01/` vs `beauty_atlas_v02/`). They are the 835
|
||||
boundary edges / dangling flaps the v01 handoff lists as a known defect. A re-atlas cannot fix
|
||||
geometry. They need mesh surgery before this body ships.
|
||||
|
||||
The other v01 defects carry over untouched: 25 backfacing triangles, the head still being original
|
||||
Tripo output, and the glossy `rm` map.
|
||||
|
||||
## What this does NOT do
|
||||
|
||||
Jeremy's stated goal is to **keep her original texture and replace only the breast and underwear
|
||||
region**. This pass deliberately does not touch colour — it only rehouses it. The re-authoring step
|
||||
(a gradient-domain fill pinned to her surrounding skin at the boundary, so the old bra/briefs
|
||||
outline cannot ghost) is the next stage, and it is much easier now that the charts are anatomical
|
||||
and uniform-density.
|
||||
|
||||
Note that keeping her original texture keeps its known incoherence: her feet read 0.11 luma darker
|
||||
and 50% redder than her belly, and the rosy chest V remains. Measured in
|
||||
`hires_claude/21_seam_probe.py`; see the `lena-tripo-atlas-chart-soup` note.
|
||||
@@ -0,0 +1,195 @@
|
||||
# lena_nude — nude body variant (WIP, not released)
|
||||
|
||||
Lena's body with the underwear removed: no bra, no briefs, breasts with volume and **no
|
||||
nipples**, smooth featureless crotch. Staged here until it is approved for the game.
|
||||
|
||||
| file | role |
|
||||
|---|---|
|
||||
| `lena_nude_quatskin_glb_v01.glb` | the body — 65-bone Quaternius game skeleton, texture **embedded** (self-contained) |
|
||||
| `lena_nude_basecolor.png` | the baked albedo, loose, for further iteration |
|
||||
|
||||
Ship name at release is `Ariki_Female_QuatSkin_Nude.glb` in
|
||||
`ariki-game/assets/quaternius/derived-bodies/`. Per `../../REGISTRY.md` rule 8 that rename happens
|
||||
exactly once, on import — never create `Ariki_*` files in this repo.
|
||||
|
||||
## How it was made
|
||||
|
||||
Generators live in **this** repo (moved out of ariki-game 2026-08-06 — they author a character,
|
||||
so `../../REGISTRY.md` governs them; see `.agents/wiki/ARCHITECTURE.md` "Tool provenance"). They
|
||||
still *read* the canonical rigged bodies from the game repo (`Ariki_Female_QuatSkin.glb` and the
|
||||
`_ChestV1` breast-sculpt donor), resolved via `$ARIKI_GAME` or a sibling `ariki-game/` checkout —
|
||||
if neither resolves they abort with the path they tried, rather than reading the wrong file.
|
||||
Run from `C:\Users\Jeremy\tinqs\animation`:
|
||||
|
||||
```
|
||||
blender --background --python tools/_bake_nude_body_texture.py
|
||||
# defaults to writing lena_nude_basecolor.png into THIS folder
|
||||
|
||||
blender --background --python tools/make_lena_nude_body.py -- \
|
||||
--out characters/female/lena_nude/lena_nude_quatskin_glb_v01.glb \
|
||||
--size 1.0 \
|
||||
--texture characters/female/lena_nude/lena_nude_basecolor.png
|
||||
|
||||
python tools/verify_body_variant.py \
|
||||
characters/female/lena_nude/lena_nude_quatskin_glb_v01.glb
|
||||
```
|
||||
|
||||
`--size` is breast volume as a multiple of the `_ChestV1` sculpt (0.75 / 1.0 / 1.25 were
|
||||
rendered for review; **v01 ships 1.0**). A new size is a **version bump**, not a new variant —
|
||||
same recipe, different parameter. (This README and `../../REGISTRY.md` both called the flag
|
||||
`--scale` until 2026-08-06; the script has always parsed `--size`.)
|
||||
|
||||
## Facts worth knowing before editing this
|
||||
|
||||
- **The underwear is the body surface.** A ray-crossing census finds exactly two surface crossings
|
||||
at every height — thigh, belly, bra cup, briefs alike. There is no skin underneath, so the
|
||||
garment can only be flattened into the skin, never deleted. Deleting it opens a hole, which is
|
||||
precisely the defect in the game repo's `Ariki_Female_QuatSkin_Bare.glb` (black cavity across
|
||||
the chest, visible even untextured). Do not use `_Bare` geometry for anything.
|
||||
- **The base mesh is not watertight** (3,809 boundary edges). The painted underwear was hiding
|
||||
open slits; with it gone they read as a dotted line of black triangles, so the builder welds
|
||||
them shut in the torso band. That is where this file's lower vertex count comes from.
|
||||
- **Use clay renders to judge geometry.** `tools/_render_body_closeup.py --clay` drops the
|
||||
material; it is the only reliable way to tell a mesh defect from a painted one. Several dead
|
||||
ends here came from mistaking painted bra shadow for a dent, and vice versa.
|
||||
|
||||
## Working files
|
||||
|
||||
This lane follows `.agents/rules/working-files.md`: **a version is a milestone,
|
||||
not a step.** Fixing the bra produces one saved file at the end, not one per cut.
|
||||
|
||||
`hires_claude/.lanekeep` pins the only `.blend` files that survive here — the
|
||||
masters named in the Derived-artifacts table of `../../REGISTRY.md`, plus the
|
||||
live chain head. Everything else a step script wrote is scratch: the `NN_*.py`
|
||||
recipe plus `masks.npz` regenerates it from the master above it.
|
||||
|
||||
```
|
||||
python tools/prune_lane.py characters/female/lena_nude --recursive # dry run
|
||||
```
|
||||
|
||||
Two things this lane learned the hard way:
|
||||
|
||||
- **Check for a running Blender before pruning.** A heal step takes ~7 minutes
|
||||
and writes only at the end; the lane gained a new head (`17_healed.blend`)
|
||||
during the audit that produced this section.
|
||||
- `hires_work/` is the abandoned GLM-agent attempt (see its
|
||||
`glm_run_attempt*.log`), superseded by `hires_claude/`. It has no `.lanekeep`,
|
||||
so every `.blend` in it classifies as scratch.
|
||||
|
||||
The root-level `lena_sculpt_glb_v01-nude-Zephyr.blend` is hand-authored — no
|
||||
step script rebuilds it — so the pruner reports it as UNKNOWN and never touches
|
||||
it. Decide its fate by hand.
|
||||
|
||||
## Status
|
||||
|
||||
Verified against the canonical body: 1 primitive, identical attribute set (no tangents), material
|
||||
`MI_Body_Lena`, 65 joints in identical order, height 1.777 m, inverse-bind drift 4.6e-6, weights
|
||||
normalised, texture embedded. Posed sweep under a UAL clip is clean (no spikes or tearing).
|
||||
|
||||
Known cosmetic limits: a faint tonal seam where the fill meets untouched skin at extreme zoom, and
|
||||
no skin-pore detail inside the filled area — that smoothness is also what guarantees no nipple or
|
||||
crotch detail can appear.
|
||||
|
||||
Full history and the reasoning behind each choice: `~/.claude/plans/i-have-a-lena-velvet-ripple.md`.
|
||||
|
||||
## v04 — bust matched to the approved concept turnaround (2026-08-06)
|
||||
|
||||
`lena_nude_sculpt_glb_v04.glb`, master `hires_claude/34_v04.blend`. Recipes
|
||||
`32_ref_measure.py` (measure) → `33_bust_variants.py` (size study) → `34_apply_bust.py` (k=0.5).
|
||||
|
||||
Guiding art: `exchange/INBOX/lena-base-turnaround/` — front/side/back T-pose renders, GPT Image 2,
|
||||
approved by Can 2026-08-06.
|
||||
|
||||
**The reference is the same character, not a new one.** Measured against the front/side silhouettes:
|
||||
|
||||
| landmark | reference | v03 mesh |
|
||||
|---|---|---|
|
||||
| heads tall | 6.43 | 6.08 |
|
||||
| waist / hip | 0.504 | 0.505 |
|
||||
| waist width | 0.1260 H | 0.1318 H |
|
||||
| hip width | 0.2500 H | 0.2608 H |
|
||||
| **bust projection** | **+0.0156 H** | **+0.0210 H** |
|
||||
|
||||
Proportions already agree and the widths sit inside silhouette-threshold noise for generated art, so
|
||||
only the bust was changed: the breast forms are rescaled to 50% of their projection off a
|
||||
bi-harmonic chest wall, landing at +0.0155 H. Nothing outside the chest region moves (verified 0.0000 mm)
|
||||
and height is unchanged, so every v03 heal survives.
|
||||
|
||||
Two traps for whoever tunes this next:
|
||||
|
||||
- **The projection metric saturates below k≈0.5** — k=0.35 measures +0.0154, indistinguishable from
|
||||
k=0.5. Below that the "deepest slice" row migrates and stops tracking breast volume, so the number
|
||||
cannot be pushed lower meaningfully. Treat 0.5 as a floor, not a midpoint. The reference reading may
|
||||
also be inflated by the T-pose arm crossing the chest in a side view, which would mean the real
|
||||
reference bust is smaller still.
|
||||
- **Front renders cannot tell the sizes apart.** k=0.5, 0.65 and 0.8 look identical head-on because
|
||||
the frontal key light flattens the chest. Judge from the side view only. Variants are in
|
||||
`hires_claude/review_33/k{100,080,065,050,035}/`.
|
||||
|
||||
Re-running at another k takes seconds: `_bust_wall_cache.npy` holds the 8-minute chest-wall solve.
|
||||
|
||||
Not addressed, and the largest remaining gap to the concept art: the reference is a **smooth
|
||||
mannequin** with no scan noise, while this mesh still carries speckles and dents on the hips and
|
||||
thighs, plus the untouched original head.
|
||||
|
||||
## Decimated for the retexture / re-atlas lane (2026-08-06)
|
||||
|
||||
`lena_nude_decimated_glb_v01.glb` — v03 collapsed to the game-body budget (31,964 v / 64,957 tris,
|
||||
ratio 0.037728) as input for `hires_claude/24_seams.py`. Recipe `30_decimate.py` + `30b_fix_winding.py`.
|
||||
**Read `HANDOFF_decimated_v01.md` before working on it** — in particular, its carried UVs are scrap
|
||||
(collapse shreds Tripo's 5,870-chart atlas) and it ships with 25 known backfacing triangles.
|
||||
|
||||
## Re-atlased (2026-08-06)
|
||||
|
||||
`lena_nude_decimated_glb_v02.glb` — **v01's geometry with a new UV atlas and resampled maps.**
|
||||
Same 31,964 v / 64,957 tris; Tripo's 5,870 charts replaced by 14 anatomical ones (coverage
|
||||
62.0 → 68.6%, texel-density spread 1.8× → 1.00×). Recipe `hires_claude/24_seams.py` →
|
||||
`hires_claude/31_reatlas.py`. It deliberately **looks identical** to v01 (mean pixel |Δ| 0.0012
|
||||
across five views) — the win is a paintable, uniform-density layout, not a new look.
|
||||
**Read `HANDOFF_reatlas_v02.md`**, especially the colorspace and unwrap-solver traps, and note that
|
||||
the grey tears on the face/underbust are v01's dangling-flap defect and are still there.
|
||||
|
||||
## Hi-res sculpt v03 (2026-08-06) — Jeremy's three notes on v02
|
||||
|
||||
`lena_nude_sculpt_glb_v03.glb`, masters `hires_claude/27_clean.blend` (geometry) and
|
||||
`29b_final.blend` (textured). Recipes: `17_line_heal.py` → `26_finish.py` →
|
||||
`25_speckle.py --apply` → `29_tone3d.py` → `08_export.py`.
|
||||
|
||||
| note | measured outcome |
|
||||
|---|---|
|
||||
| cut lines | shading kinks >12° 9704 → ~6900; flipped faces (the black dashes) 1511 → 10. Ribcage and thigh lines gone frontally; waistband + underbust still legible under raking light |
|
||||
| discoloured bra/crotch | on-body tone gap, patch interior vs surrounding skin: 0.0102 → 0.0068 luma. The pale panels in `beauty_v02_healed/` are gone in `beauty_v03b/` |
|
||||
| cleavage | sternum fillet radius 9.5 mm → no sharp sample at any height; notch depth 44 → 31 mm |
|
||||
|
||||
### Four dead ends this version ruled out — do not repeat them
|
||||
|
||||
1. **The lines are not cracks; do not weld.** Stage 15 assumed the panel seams were disconnected
|
||||
vertex runs. They are not: the mesh has only 830 boundary edges in 689 loops of 3–5 edges, and
|
||||
for kink verts the nearest vertex outside the 3-ring sits at 1.58× the local edge length
|
||||
(median) against 1.73× for control skin. Welding at eps 1.2 mm — about the 1.85 mm mean edge
|
||||
length — merged ordinary neighbours and tore the mesh, 830 → 24k boundary edges.
|
||||
2. **They are not in the custom normals either.** Corner-vs-vertex deviation is 0.008° mean, 15
|
||||
loops above 5°; clearing custom split normals changes nothing.
|
||||
3. **A membrane is the wrong operator, and widening it is worse.** The relief is ONE VERTEX WIDE
|
||||
(0.23 mm median at the centre, back to the 0.074 mm background by ring 2), so a collar-fixed
|
||||
membrane pins itself to the defect's own shoulders. GROW 4 and 8 were no better than GROW 2
|
||||
while moving material up to 38 mm. A **ring median** is correct: it deletes a 1-vertex outlier
|
||||
and returns anything broader unchanged, so the underbust fold, clavicles and navel survive by
|
||||
construction instead of by a tuned threshold.
|
||||
4. **Never level tone from atlas-adjacent texels.** A Poisson solve whose boundary condition was
|
||||
the mismatch against UV neighbours turned the patches into pale grey panels — UV adjacency is
|
||||
not body adjacency, so the border mismatch came from other body parts and gutter. This is the
|
||||
warning already in `05_texture.py`'s header. Sample on the body (KD over skin verts in 3D),
|
||||
smooth the correction over the mesh graph, then rasterise.
|
||||
|
||||
Two process traps worth keeping: **restore the real material before saving** — `18/20/25`
|
||||
originally rendered clay last and then saved, so the file on disk kept the clay material and lost
|
||||
its texture wiring, which is why the first tone attempt aborted with "could not find the wired
|
||||
basecolor". And **the pristine textures are not inside working blends**: Blender purges the orphan
|
||||
`.002/.003` copies on save, so read them from `00_welded.blend`.
|
||||
|
||||
Still open: 1649 non-manifold and 830 boundary edges — `bmesh.ops.holes_fill`, the edit-mode
|
||||
`mesh.fill_holes` operator, and a per-loop pass all refused every one, which is why
|
||||
`normals_make_consistent` could only reach 1198 of 1511 flipped faces before explicit per-face
|
||||
reversal finished the job. None of this is propagated to the game body
|
||||
(`lena_nude_quatskin_glb_v01.glb`), which is built independently on the 65-bone Quaternius rig.
|
||||
@@ -0,0 +1,36 @@
|
||||
# .lanekeep — .blend files pinned as MASTER in this lane.
|
||||
# Everything else here that a step script wrote is scratch: regenerable by
|
||||
# re-running the NN_*.py recipe from the pinned master above it.
|
||||
# Read by tools/prune_lane.py. One filename per line, # for comments.
|
||||
#
|
||||
# Rule: a .blend earns a line here only when a registered artifact in
|
||||
# characters/REGISTRY.md was built from it, or it is the head of the live chain.
|
||||
# Intermediate attempts (the 06b-06h melt series, the 10_healed/rimheal/seamheal
|
||||
# series) never get pinned — only the state at the end of the fix.
|
||||
|
||||
# --- phase roots ---
|
||||
00_welded.blend # raw original, slits welded shut. Root of every branch;
|
||||
# keeps a re-sculpt from needing a fresh weld+isolate pass.
|
||||
|
||||
# --- masters of registered artifacts (see characters/REGISTRY.md) ---
|
||||
06_final.blend # lena_nude_sculpt_glb_v01.glb — geometry master
|
||||
07_polished.blend # lena_nude_sculpt_glb_v01.glb — textured master
|
||||
10_patch.blend # lena_nude_sculpt_glb_v02.glb — master (original skin retained)
|
||||
|
||||
27_clean.blend # lena_nude_sculpt_glb_v03.glb — geometry master (phase boundary:
|
||||
# line heal + cleavage fillet + flipped-face repair, pre-texture)
|
||||
29b_final.blend # lena_nude_sculpt_glb_v03.glb — textured master, HEAD 2026-08-06.
|
||||
# 27_clean + on-body tone levelling (29_tone3d.py str 1.0, 25 passes)
|
||||
34_v04.blend # lena_nude_sculpt_glb_v04.glb — master, HEAD 2026-08-06. 29b_final with
|
||||
# the bust rescaled to k=0.5 per the approved concept turnaround.
|
||||
10_welded.blend # parent of the v03 chain. Kept as the fallback until v03/v04 are
|
||||
# accepted; 11-15 regenerate it from 10_patch, so unpin then.
|
||||
|
||||
# _bust_wall_cache.npy is the bi-harmonic chest wall for 29b_final (an 8-minute CG solve).
|
||||
# Keep it while bust size is still in play: 33/34 reuse it and a different k then costs
|
||||
# seconds instead of minutes. It is regenerable, and invalid for any other mesh — both
|
||||
# scripts assert its shape against the vertex count before trusting it.
|
||||
|
||||
# 17_healed / 18_smoothnrm / 22_lines / 23_cleavage / 26_geo / 29_final and the
|
||||
# rejected atlas-tone attempt were per-step snapshots and have been pruned; the
|
||||
# 17/25/26/29 recipes rebuild them from 10_welded.
|
||||
@@ -0,0 +1,159 @@
|
||||
# Probe the pre-decimated Tripo sculpt: is the sports top a SEPARABLE shell?
|
||||
# Answers, per connected component (after welding UV-seam splits):
|
||||
# size, z-range, open-boundary edge count, and what fraction of its faces are painted
|
||||
# garment (colour-keyed on lena+glb_basecolor via each face's own UV texels).
|
||||
#
|
||||
# blender --background --python 01_probe.py -- <copy.glb> <checkpoint.blend>
|
||||
import bpy, bmesh, sys, time
|
||||
import numpy as np
|
||||
|
||||
argv = sys.argv[sys.argv.index("--") + 1:]
|
||||
SRC, CKPT = argv[0], argv[1]
|
||||
t0 = time.time()
|
||||
|
||||
|
||||
def log(m):
|
||||
print(f"[probe {time.time()-t0:6.1f}s] {m}", flush=True)
|
||||
|
||||
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
bpy.ops.import_scene.gltf(filepath=SRC)
|
||||
ob = max([o for o in bpy.data.objects if o.type == 'MESH'],
|
||||
key=lambda o: len(o.data.vertices))
|
||||
me = ob.data
|
||||
log(f"imported: {len(me.vertices)}v {len(me.polygons)}f, object '{ob.name}'")
|
||||
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(me)
|
||||
bmesh.ops.remove_doubles(bm, verts=list(bm.verts), dist=1e-6)
|
||||
bm.to_mesh(me)
|
||||
bm.free()
|
||||
me.update()
|
||||
log(f"after weld 1e-6: {len(me.vertices)}v {len(me.polygons)}f")
|
||||
|
||||
# save checkpoint so later stages skip the import+weld cost
|
||||
bpy.context.preferences.filepaths.save_version = 0 # no .blend1 autosave
|
||||
bpy.ops.wm.save_as_mainfile(filepath=CKPT)
|
||||
log(f"checkpoint saved: {CKPT}")
|
||||
|
||||
n_v = len(me.vertices)
|
||||
n_f = len(me.polygons)
|
||||
|
||||
# ---- connected components over faces (edge-connected), union-find in numpy ----
|
||||
# face -> vertices
|
||||
loop_tot = np.empty(n_f, dtype=np.int32)
|
||||
me.polygons.foreach_get("loop_total", loop_tot)
|
||||
loop_start = np.empty(n_f, dtype=np.int32)
|
||||
me.polygons.foreach_get("loop_start", loop_start)
|
||||
loops_v = np.empty(len(me.loops), dtype=np.int32)
|
||||
me.loops.foreach_get("vertex_index", loops_v)
|
||||
|
||||
parent = np.arange(n_v, dtype=np.int64)
|
||||
|
||||
|
||||
def find(a):
|
||||
root = a
|
||||
while parent[root] != root:
|
||||
root = parent[root]
|
||||
while parent[a] != root:
|
||||
parent[a], a = root, parent[a]
|
||||
return root
|
||||
|
||||
|
||||
# union via edges
|
||||
n_e = len(me.edges)
|
||||
ev = np.empty(n_e * 2, dtype=np.int32)
|
||||
me.edges.foreach_get("vertices", ev)
|
||||
ev = ev.reshape(-1, 2)
|
||||
for a, b in ev:
|
||||
ra, rb = find(a), find(b)
|
||||
if ra != rb:
|
||||
parent[rb] = ra
|
||||
log("union-find done")
|
||||
|
||||
root_of = np.array([find(i) for i in range(n_v)], dtype=np.int64)
|
||||
uniq, inv, counts = np.unique(root_of, return_inverse=True, return_counts=True)
|
||||
order = np.argsort(-counts)
|
||||
log(f"components: {len(uniq)}")
|
||||
|
||||
# vertex positions
|
||||
co = np.empty(n_v * 3, dtype=np.float64)
|
||||
me.vertices.foreach_get("co", co)
|
||||
co = co.reshape(-1, 3)
|
||||
|
||||
# boundary edges per component: edge belongs to 1 face only
|
||||
# count faces per edge
|
||||
edge_face_count = np.zeros(n_e, dtype=np.int32)
|
||||
for p in me.polygons:
|
||||
for ek in p.edge_keys:
|
||||
pass # too slow; use loops instead
|
||||
# faster: build edge keys from loops via me.polygons edge indices
|
||||
# Blender exposes loop edges: me.loops[i].edge_index
|
||||
loops_e = np.empty(len(me.loops), dtype=np.int32)
|
||||
me.loops.foreach_get("edge_index", loops_e)
|
||||
np.add.at(edge_face_count, loops_e, 1)
|
||||
boundary_edge = edge_face_count == 1
|
||||
log(f"boundary edges total: {boundary_edge.sum()}")
|
||||
|
||||
# ---- colour key per vertex (first-loop UV), calibrated on this texture ----
|
||||
img = None
|
||||
for i in bpy.data.images:
|
||||
if "basecolor" in i.name.lower():
|
||||
img = i
|
||||
break
|
||||
if img is None:
|
||||
img = max(bpy.data.images, key=lambda i: i.size[0] * i.size[1])
|
||||
w, h = img.size
|
||||
log(f"texture '{img.name}' {w}x{h}")
|
||||
px = np.empty(w * h * 4, dtype=np.float32)
|
||||
img.pixels.foreach_get(px)
|
||||
rgb = px.reshape(h, w, 4)[:, :, :3]
|
||||
|
||||
uvl = me.uv_layers.active.data
|
||||
uv = np.empty(len(me.loops) * 2, dtype=np.float64)
|
||||
uvl.foreach_get("uv", uv)
|
||||
uv = uv.reshape(-1, 2)
|
||||
# first loop per vertex
|
||||
first_loop = np.full(n_v, -1, dtype=np.int64)
|
||||
for li in range(len(loops_v) - 1, -1, -1):
|
||||
first_loop[loops_v[li]] = li
|
||||
has_uv = first_loop >= 0
|
||||
vx = np.clip(uv[first_loop, 0], 0, 1) * (w - 1)
|
||||
vy = np.clip(uv[first_loop, 1], 0, 1) * (h - 1) # bpy-imported UVs: already flipped
|
||||
vcol = rgb[vy.astype(int), vx.astype(int)]
|
||||
r, g, b = vcol[:, 0], vcol[:, 1], vcol[:, 2]
|
||||
mx = vcol.max(axis=1)
|
||||
mn = vcol.min(axis=1)
|
||||
sat = np.where(mx > 1e-5, (mx - mn) / np.maximum(mx, 1e-5), 0)
|
||||
rb = r / np.maximum(b, 1e-5)
|
||||
|
||||
# calibrate: thigh skin (z 0.28-0.34) vs briefs centre (z 0.50-0.56 front)
|
||||
thigh = (co[:, 2] > 0.28) & (co[:, 2] < 0.34)
|
||||
briefs = (co[:, 2] > 0.50) & (co[:, 2] < 0.56) & (co[:, 1] < 0) & (np.abs(co[:, 0]) < 0.05)
|
||||
bra = (co[:, 2] > 0.64) & (co[:, 2] < 0.72) & (co[:, 1] < 0) & (np.abs(co[:, 0]) < 0.05)
|
||||
for nm, s in (("thigh skin", thigh), ("briefs", briefs), ("bra front", bra)):
|
||||
if s.sum():
|
||||
log(f" [{nm}] n={s.sum()} sat p50={np.median(sat[s]):.3f} rb p50={np.median(rb[s]):.3f} "
|
||||
f"rgb ({np.median(r[s]):.3f},{np.median(g[s]):.3f},{np.median(b[s]):.3f})")
|
||||
|
||||
# pick thresholds midway between garment and skin medians
|
||||
sat_thr = (np.median(sat[briefs]) + np.median(sat[thigh])) / 2 if briefs.sum() and thigh.sum() else 0.45
|
||||
rb_thr = (np.median(rb[briefs]) + np.median(rb[thigh])) / 2 if briefs.sum() and thigh.sum() else 1.9
|
||||
garment_v = (sat < sat_thr) & (rb < rb_thr)
|
||||
log(f"thresholds: sat<{sat_thr:.3f} rb<{rb_thr:.2f} -> {garment_v.sum()} garment verts")
|
||||
|
||||
# ---- per-component report (top 12 by size) ----
|
||||
# per-vertex boundary flag
|
||||
vb = np.zeros(n_v, dtype=bool)
|
||||
vb[ev[boundary_edge].ravel()] = True
|
||||
|
||||
print("\n=== COMPONENTS (top 12 by vertex count) ===")
|
||||
for ci in order[:12]:
|
||||
root = uniq[ci]
|
||||
m = root_of == root
|
||||
zc = co[m, 2]
|
||||
gfrac = garment_v[m].mean()
|
||||
bcount = vb[m].sum()
|
||||
print(f"comp root={root}: verts={m.sum():7d} z[{zc.min():.3f},{zc.max():.3f}] "
|
||||
f"garment-painted={100*gfrac:5.1f}% boundary-verts={bcount}")
|
||||
print("PROBE_DONE")
|
||||
@@ -0,0 +1,158 @@
|
||||
# Stage 2: isolate the garment on the welded hires sculpt, split it into functional zones, and
|
||||
# save the masks + adjacency for the sculpt stage.
|
||||
#
|
||||
# blender --background --python 02_isolate.py -- <00_welded.blend> <masks.npz>
|
||||
#
|
||||
# Zones (per vertex):
|
||||
# cups — bra front where the breasts live (gets wall + amplified mounds)
|
||||
# shield — sternum strip the bra bridges (gets carved cleavage)
|
||||
# toprest — rest of the top: band, straps, back panel (gets faired flat to skin)
|
||||
# briefs — briefs incl. waistband/leg rims (gets faired featureless)
|
||||
# plus 'hemband' — narrow ring around every garment/skin boundary (local crease fairing).
|
||||
#
|
||||
# The colour key was calibrated on THIS texture by 01_probe.py: garment sat~0.39/RB~1.65,
|
||||
# skin sat~0.64/RB~2.80; thresholds are the midpoints. The z-guards keep the low-saturation
|
||||
# face features (eyes, teeth, lips) out of the key's reach.
|
||||
import bpy, sys, time
|
||||
import numpy as np
|
||||
|
||||
argv = sys.argv[sys.argv.index("--") + 1:]
|
||||
BLEND, OUT = argv[0], argv[1]
|
||||
t0 = time.time()
|
||||
|
||||
SAT_THR, RB_THR = 0.518, 2.22
|
||||
Z_GARMENT_LO, Z_GARMENT_HI = 0.40, 0.85 # below the head; briefs to over-shoulder straps
|
||||
Z_TOP_SPLIT = 0.595 # briefs/top divide (waistband top 0.578 + margin)
|
||||
GROW_RINGS = 6 # ~2 cm real at this density; must swallow the 1 cm hem lips
|
||||
|
||||
# cups zone (front bra): z and |x| bounds from the measured landmarks
|
||||
CUP_Z_LO, CUP_Z_HI = 0.615, 0.745
|
||||
CUP_X_MAX = 0.085
|
||||
SHIELD_X = 0.014 # sternum strip half-width
|
||||
|
||||
|
||||
def log(m):
|
||||
print(f"[iso {time.time()-t0:6.1f}s] {m}", flush=True)
|
||||
|
||||
|
||||
bpy.ops.wm.open_mainfile(filepath=BLEND)
|
||||
ob = max([o for o in bpy.data.objects if o.type == 'MESH'],
|
||||
key=lambda o: len(o.data.vertices))
|
||||
me = ob.data
|
||||
n_v = len(me.vertices)
|
||||
log(f"loaded {n_v}v")
|
||||
|
||||
co = np.empty(n_v * 3, dtype=np.float64)
|
||||
me.vertices.foreach_get("co", co)
|
||||
co = co.reshape(-1, 3)
|
||||
|
||||
# --- per-vertex colour ---
|
||||
img = next(i for i in bpy.data.images if "basecolor" in i.name.lower())
|
||||
w, h = img.size
|
||||
px = np.empty(w * h * 4, dtype=np.float32)
|
||||
img.pixels.foreach_get(px)
|
||||
rgb = px.reshape(h, w, 4)[:, :, :3]
|
||||
|
||||
loops_v = np.empty(len(me.loops), dtype=np.int32)
|
||||
me.loops.foreach_get("vertex_index", loops_v)
|
||||
uv = np.empty(len(me.loops) * 2, dtype=np.float64)
|
||||
me.uv_layers.active.data.foreach_get("uv", uv)
|
||||
uv = uv.reshape(-1, 2)
|
||||
first_loop = np.full(n_v, len(loops_v), dtype=np.int64)
|
||||
np.minimum.at(first_loop, loops_v, np.arange(len(loops_v), dtype=np.int64))
|
||||
first_loop = np.minimum(first_loop, len(loops_v) - 1)
|
||||
vx = (np.clip(uv[first_loop, 0], 0, 1) * (w - 1)).astype(int)
|
||||
vy = (np.clip(uv[first_loop, 1], 0, 1) * (h - 1)).astype(int)
|
||||
vcol = rgb[vy, vx]
|
||||
mx = vcol.max(axis=1)
|
||||
mn = vcol.min(axis=1)
|
||||
sat = np.where(mx > 1e-5, (mx - mn) / np.maximum(mx, 1e-5), 0)
|
||||
rb = vcol[:, 0] / np.maximum(vcol[:, 2], 1e-5)
|
||||
|
||||
# above z=0.80 only the shoulder straps qualify — keep the chin/lips (low-saturation paint)
|
||||
# out by requiring lateral offset there
|
||||
zone_guard = (co[:, 2] > Z_GARMENT_LO) & (co[:, 2] < Z_GARMENT_HI) & ((co[:, 2] < 0.80) | (np.abs(co[:, 0]) > 0.025))
|
||||
key = (sat < SAT_THR) & (rb < RB_THR) & zone_guard
|
||||
# The shoulder straps' paint is much closer to skin (median sat 0.57-0.61 vs the bra body's
|
||||
# 0.39) — the main key catches only ~a third of each strap and the rest survived as raised
|
||||
# geometry. In the strap corridor a relaxed threshold seeds them; the ring grow fills the rest.
|
||||
strap_zone = (co[:, 2] > 0.72) & (co[:, 2] < 0.85) & (np.abs(co[:, 0]) > 0.03) & (np.abs(co[:, 0]) < 0.105)
|
||||
key |= strap_zone & (sat < 0.55)
|
||||
log(f"colour key (+strap corridor): {key.sum()} verts")
|
||||
|
||||
# --- adjacency (numpy CSR-ish over edges) ---
|
||||
n_e = len(me.edges)
|
||||
ev = np.empty(n_e * 2, dtype=np.int32)
|
||||
me.edges.foreach_get("vertices", ev)
|
||||
ev = ev.reshape(-1, 2)
|
||||
order = np.concatenate([ev[:, 0], ev[:, 1]])
|
||||
nbr = np.concatenate([ev[:, 1], ev[:, 0]])
|
||||
srt = np.argsort(order, kind="stable")
|
||||
order_s = order[srt]
|
||||
nbr_s = nbr[srt]
|
||||
ptr = np.searchsorted(order_s, np.arange(n_v + 1))
|
||||
log("adjacency built")
|
||||
|
||||
|
||||
def grow(mask, rings):
|
||||
out = mask.copy()
|
||||
for _ in range(rings):
|
||||
sel = np.zeros(n_v, dtype=bool)
|
||||
# mark all neighbours of current selection
|
||||
active = np.nonzero(out)[0]
|
||||
# gather neighbour slices
|
||||
for a in active:
|
||||
sel[nbr_s[ptr[a]:ptr[a + 1]]] = True
|
||||
out |= sel
|
||||
return out
|
||||
|
||||
|
||||
# component filter: drop specks (<200 verts) via BFS on the keyed set
|
||||
from collections import deque
|
||||
comp_id = np.full(n_v, -1, dtype=np.int64)
|
||||
cid = 0
|
||||
keep = np.zeros(n_v, dtype=bool)
|
||||
for s in np.nonzero(key)[0]:
|
||||
if comp_id[s] >= 0:
|
||||
continue
|
||||
q = deque([s])
|
||||
comp_id[s] = cid
|
||||
members = [s]
|
||||
while q:
|
||||
c = q.popleft()
|
||||
for nb in nbr_s[ptr[c]:ptr[c + 1]]:
|
||||
if key[nb] and comp_id[nb] < 0:
|
||||
comp_id[nb] = cid
|
||||
q.append(nb)
|
||||
members.append(nb)
|
||||
if len(members) >= 200:
|
||||
keep[members] = True
|
||||
cid += 1
|
||||
log(f"component filter: {keep.sum()} verts in {cid} raw components")
|
||||
|
||||
garment = grow(keep, GROW_RINGS)
|
||||
log(f"grown +{GROW_RINGS}: {garment.sum()}")
|
||||
|
||||
top = garment & (co[:, 2] >= Z_TOP_SPLIT)
|
||||
briefs = garment & (co[:, 2] < Z_TOP_SPLIT)
|
||||
|
||||
front = co[:, 1] < 0.0
|
||||
cups = top & front & (co[:, 2] > CUP_Z_LO) & (co[:, 2] < CUP_Z_HI) \
|
||||
& (np.abs(co[:, 0]) < CUP_X_MAX) & (np.abs(co[:, 0]) > SHIELD_X)
|
||||
shield = top & front & (co[:, 2] > CUP_Z_LO) & (co[:, 2] < CUP_Z_HI) \
|
||||
& (np.abs(co[:, 0]) <= SHIELD_X)
|
||||
toprest = top & ~cups & ~shield
|
||||
|
||||
# hem band: garment boundary vs non-garment, +/-2 rings
|
||||
edge_g = garment[ev]
|
||||
bnd_edges = ev[edge_g[:, 0] != edge_g[:, 1]]
|
||||
bmask = np.zeros(n_v, dtype=bool)
|
||||
bmask[bnd_edges.ravel()] = True
|
||||
hemband = grow(bmask, 8) # hem lips are ~3 rings wide; blend needs room beyond them
|
||||
log(f"zones: cups={cups.sum()} shield={shield.sum()} toprest={toprest.sum()} "
|
||||
f"briefs={briefs.sum()} hemband={hemband.sum()}")
|
||||
|
||||
np.savez_compressed(OUT, garment=garment, cups=cups, shield=shield, toprest=toprest,
|
||||
briefs=briefs, hemband=hemband, key_raw=keep)
|
||||
log(f"WROTE {OUT}")
|
||||
print("ISOLATE_DONE")
|
||||
@@ -0,0 +1,612 @@
|
||||
# Stage 3 (v3): take the top off and sculpt the breasts, at full 954k-vert density.
|
||||
#
|
||||
# blender --background --python 03_sculpt.py -- <00_welded.blend> <masks.npz>
|
||||
# <out.blend> [amp_target=0.034]
|
||||
#
|
||||
# METHOD — and the two failure modes v3 exists to kill:
|
||||
#
|
||||
# * v1/v2 read the replacement surface off the faired proxy with BVH find_nearest. A 19k proxy
|
||||
# is FACETED (~7 mm triangles): nearest-point positions are piecewise planar and the face
|
||||
# normals jump at every proxy edge, so the lifted surface imprinted the proxy tessellation
|
||||
# onto 954k verts — the "crust" in the renders was the proxy's facets, not fabric.
|
||||
# v3 never touches proxy faces: the complete TARGET surface (wall + zone field) is computed
|
||||
# per proxy VERTEX and lifted by inverse-distance blending over the 6 nearest proxy verts —
|
||||
# continuous by construction — followed by a short Taubin polish at full density.
|
||||
#
|
||||
# * The shoulder straps' paint is nearly skin-coloured (median sat 0.57-0.61 vs 0.39 on the bra
|
||||
# body), so no colour threshold can own them. v3 catches them GEOMETRICALLY: the proxy wall
|
||||
# is also solved under a shoulder corridor, and any full-density vert there standing
|
||||
# > 2.5 mm proud of the wall is fabric — paint is irrelevant.
|
||||
#
|
||||
# Zone targets: cups/shield -> wall + AMPLIFIED mound field (her own under-bra anatomy,
|
||||
# recovered as surface-minus-wall on the proxy where 1 mm weave cannot exist) with the cleavage
|
||||
# valley carved where the bra bridged it; rest of top + proud corridor -> wall; briefs -> wall
|
||||
# + field at 1x (keeps hip/butt anatomy, sheds weave and waistband/leg lips).
|
||||
import bpy, sys, time
|
||||
from collections import deque
|
||||
import numpy as np
|
||||
from mathutils import Vector
|
||||
from mathutils.kdtree import KDTree
|
||||
from mathutils.bvhtree import BVHTree
|
||||
|
||||
argv = sys.argv[sys.argv.index("--") + 1:]
|
||||
BLEND, MASKS, OUT = argv[0], argv[1], argv[2]
|
||||
AMP_TARGET = float(argv[3]) if len(argv) > 3 else 0.034
|
||||
|
||||
CLEAV_GAP, CLEAV_W = 0.007, 0.013 # narrow gap: reference mounds nearly touch
|
||||
PROXY_RATIO = 0.012 # ~11.5k proxy: dense wall solve stays under ~2 min; the
|
||||
# vertex-IDW lift makes coarser proxies safe (no facet imprint)
|
||||
PDN_SMOOTH = 25
|
||||
BLEND_RINGS = 12
|
||||
K_LIFT = 6
|
||||
POLISH_ITERS = 12
|
||||
PROUD_THR = 0.0025 # corridor verts standing this proud of the wall are fabric
|
||||
t0 = time.time()
|
||||
|
||||
|
||||
def log(m):
|
||||
print(f"[sculpt {time.time()-t0:6.1f}s] {m}", flush=True)
|
||||
|
||||
|
||||
bpy.ops.wm.open_mainfile(filepath=BLEND)
|
||||
ob = max([o for o in bpy.data.objects if o.type == 'MESH'],
|
||||
key=lambda o: len(o.data.vertices))
|
||||
me = ob.data
|
||||
n_v = len(me.vertices)
|
||||
M = np.load(MASKS)
|
||||
cups, shield, toprest = M["cups"], M["shield"], M["toprest"]
|
||||
briefs, garment = M["briefs"], M["garment"]
|
||||
|
||||
co = np.empty(n_v * 3, dtype=np.float64)
|
||||
me.vertices.foreach_get("co", co)
|
||||
co = co.reshape(-1, 3)
|
||||
|
||||
corridor = (co[:, 2] > 0.64) & (co[:, 2] < 0.86) \
|
||||
& (np.abs(co[:, 0]) > 0.02) & (np.abs(co[:, 0]) < 0.145)
|
||||
|
||||
core_top = (co[:, 2] > 0.600) & (co[:, 2] < 0.855) & (np.abs(co[:, 0]) < 0.14)
|
||||
core_bot = (co[:, 2] > 0.435) & (co[:, 2] <= 0.600) & (np.abs(co[:, 0]) < 0.15)
|
||||
|
||||
# ROUGHNESS, computed up-front because it feeds the PROXY fair region: fabric is wrinkled
|
||||
# where this sculpt's skin is glassy, and unkeyed fabric (the bow's shadowed folds) must be
|
||||
# faired on the proxy or the target cage carries it and replaces it with itself. Blanket-
|
||||
# fairing the whole band instead was catastrophic: it removed the membrane's interior anchors
|
||||
# (belly/waist/hip skin) and the wall collapsed into a cone spanning shoulders to thighs.
|
||||
ev_r = np.empty(len(me.edges) * 2, dtype=np.int32)
|
||||
me.edges.foreach_get("vertices", ev_r)
|
||||
ev_r = ev_r.reshape(-1, 2)
|
||||
o_r = np.concatenate([ev_r[:, 0], ev_r[:, 1]])
|
||||
n_r = np.concatenate([ev_r[:, 1], ev_r[:, 0]])
|
||||
s_r = np.argsort(o_r, kind="stable")
|
||||
o_rs = o_r[s_r]
|
||||
n_rs = n_r[s_r]
|
||||
ptr_r = np.searchsorted(o_rs, np.arange(n_v + 1))
|
||||
cnt_r = np.maximum(np.diff(ptr_r), 1)
|
||||
sm_r = co.copy()
|
||||
for _ in range(8):
|
||||
su = np.add.reduceat(sm_r[n_rs], ptr_r[:-1], axis=0)
|
||||
emp = np.diff(ptr_r) == 0
|
||||
su[emp] = sm_r[emp]
|
||||
sm_r = su / cnt_r[:, None]
|
||||
rough = np.linalg.norm(co - sm_r, axis=1)
|
||||
rough_zone = (core_top | core_bot) \
|
||||
& ~((np.abs(co[:, 0]) < 0.018) & (co[:, 2] > 0.50) & (co[:, 2] < 0.55)) # navel
|
||||
fabric_rough = rough_zone & (rough > 0.0008)
|
||||
|
||||
region = garment | corridor | fabric_rough
|
||||
log(f"loaded {n_v}v; garment={garment.sum()} corridor={corridor.sum()} "
|
||||
f"rough={fabric_rough.sum()}")
|
||||
|
||||
# ---------------- proxy ----------------
|
||||
proxy = ob.copy()
|
||||
proxy.data = ob.data.copy()
|
||||
bpy.context.collection.objects.link(proxy)
|
||||
dec = proxy.modifiers.new("dec", 'DECIMATE')
|
||||
dec.ratio = PROXY_RATIO
|
||||
bpy.context.view_layer.objects.active = proxy
|
||||
bpy.ops.object.modifier_apply(modifier="dec")
|
||||
pme = proxy.data
|
||||
np_v = len(pme.vertices)
|
||||
pco = np.empty(np_v * 3, dtype=np.float64)
|
||||
pme.vertices.foreach_get("co", pco)
|
||||
pco = pco.reshape(-1, 3)
|
||||
log(f"proxy: {np_v}v")
|
||||
|
||||
# proxy zone classification by nearest hires vert (0 none,1 cup,2 toprest,3 briefs)
|
||||
zone = np.zeros(n_v, dtype=np.int8)
|
||||
zone[toprest] = 2
|
||||
zone[briefs] = 3
|
||||
zone[cups | shield] = 1
|
||||
zone[corridor & (zone == 0)] = 4 # corridor-only: candidate fabric, decided later
|
||||
zone[fabric_rough & core_top & (zone == 0)] = 2
|
||||
zone[fabric_rough & core_bot & (zone == 0)] = 3
|
||||
|
||||
pool = np.concatenate([np.nonzero(region)[0], np.nonzero(~region)[0][::20]])
|
||||
kd_h = KDTree(len(pool))
|
||||
for j, i in enumerate(pool):
|
||||
kd_h.insert(Vector(co[i]), j)
|
||||
kd_h.balance()
|
||||
p_zone = np.zeros(np_v, dtype=np.int8)
|
||||
for i in range(np_v):
|
||||
_, j, _ = kd_h.find(Vector(pco[i]))
|
||||
p_zone[i] = zone[pool[j]]
|
||||
p_free = p_zone > 0
|
||||
log(f"proxy region: {p_free.sum()} (zones: " +
|
||||
", ".join(f"{z}:{(p_zone==z).sum()}" for z in (1, 2, 3, 4)) + ")")
|
||||
|
||||
pn_e = len(pme.edges)
|
||||
pev = np.empty(pn_e * 2, dtype=np.int32)
|
||||
pme.edges.foreach_get("vertices", pev)
|
||||
pev = pev.reshape(-1, 2)
|
||||
padj = [[] for _ in range(np_v)]
|
||||
for a, b in pev:
|
||||
padj[a].append(b)
|
||||
padj[b].append(a)
|
||||
|
||||
free = p_free.copy()
|
||||
collar = free.copy()
|
||||
for _ in range(2):
|
||||
nxt = collar.copy()
|
||||
for gi in np.nonzero(collar)[0]:
|
||||
for nb in padj[gi]:
|
||||
nxt[nb] = True
|
||||
collar = nxt
|
||||
collar &= ~free
|
||||
S = np.nonzero(free | collar)[0]
|
||||
in_S = np.zeros(np_v, dtype=bool)
|
||||
in_S[S] = True
|
||||
gl = np.full(np_v, -1, dtype=np.int64)
|
||||
gl[S] = np.arange(len(S))
|
||||
# S-local symmetric graph Laplacian (Ls = deg - adjacency), matrix-free
|
||||
se = pev[in_S[pev].all(axis=1)]
|
||||
a_l = gl[se[:, 0]]
|
||||
b_l = gl[se[:, 1]]
|
||||
deg = np.zeros(len(S))
|
||||
np.add.at(deg, a_l, 1.0)
|
||||
np.add.at(deg, b_l, 1.0)
|
||||
freeS = free[S]
|
||||
|
||||
|
||||
def Ls(X):
|
||||
out = deg[:, None] * X
|
||||
np.add.at(out, a_l, -X[b_l])
|
||||
np.add.at(out, b_l, -X[a_l])
|
||||
return out
|
||||
|
||||
|
||||
def A_op(U): # (Ls^2)_ff applied to free values
|
||||
X = np.zeros((len(S), 3))
|
||||
X[freeS] = U
|
||||
Y = Ls(Ls(X))
|
||||
return Y[freeS]
|
||||
|
||||
|
||||
Xc = np.zeros((len(S), 3))
|
||||
Xc[~freeS] = pco[S[~freeS]]
|
||||
b_rhs = -Ls(Ls(Xc))[freeS]
|
||||
# CG (SPD system); matrix-free, so region size no longer matters
|
||||
U = pco[S[freeS]].copy()
|
||||
r = b_rhs - A_op(U)
|
||||
pdir = r.copy()
|
||||
rs = (r * r).sum()
|
||||
for cg_it in range(20000):
|
||||
Ap = A_op(pdir)
|
||||
alpha = rs / max((pdir * Ap).sum(), 1e-30)
|
||||
U += alpha * pdir
|
||||
r -= alpha * Ap
|
||||
rs_new = (r * r).sum()
|
||||
if rs_new < 1e-18:
|
||||
break
|
||||
pdir = r + (rs_new / rs) * pdir
|
||||
rs = rs_new
|
||||
log(f"CG converged in {cg_it} iterations, residual {rs_new:.2e}")
|
||||
p_wall = pco.copy()
|
||||
p_wall[S[freeS]] = U
|
||||
log(f"proxy wall solved, max move {np.linalg.norm(p_wall-pco,axis=1).max():.4f}")
|
||||
|
||||
# proxy wall vertex normals (area-weighted, at wall coords)
|
||||
pl_tot = np.empty(len(pme.polygons), dtype=np.int32)
|
||||
pme.polygons.foreach_get("loop_total", pl_tot)
|
||||
pl_start = np.empty(len(pme.polygons), dtype=np.int32)
|
||||
pme.polygons.foreach_get("loop_start", pl_start)
|
||||
pl_v = np.empty(len(pme.loops), dtype=np.int32)
|
||||
pme.loops.foreach_get("vertex_index", pl_v)
|
||||
p_nrm = np.zeros((np_v, 3))
|
||||
for s, t in zip(pl_start, pl_tot):
|
||||
idxs = pl_v[s:s + t]
|
||||
fn = np.cross(p_wall[idxs[1]] - p_wall[idxs[0]], p_wall[idxs[2]] - p_wall[idxs[0]])
|
||||
for vi in idxs:
|
||||
p_nrm[vi] += fn
|
||||
p_nrm /= np.maximum(np.linalg.norm(p_nrm, axis=1, keepdims=True), 1e-12)
|
||||
|
||||
# proxy mound field: ERODE (2-ring min filter), then smooth. The sports top's decorative
|
||||
# bow-knot and the hem lips are narrow POSITIVE relief riding on the broad mound; smoothing
|
||||
# alone spreads them, and amplification then blew the bow up into a fist-sized rosette on the
|
||||
# inner cup. A min-filter deletes narrow positive relief outright while barely shrinking the
|
||||
# wide mound underneath.
|
||||
p_dn = np.einsum("ij,ij->i", pco - p_wall, p_nrm)
|
||||
for _ in range(3):
|
||||
p_min = p_dn.copy()
|
||||
np.minimum.at(p_min, pev[:, 0], p_dn[pev[:, 1]])
|
||||
np.minimum.at(p_min, pev[:, 1], p_dn[pev[:, 0]])
|
||||
p_dn = p_min
|
||||
for _ in range(PDN_SMOOTH):
|
||||
acc = np.zeros(np_v)
|
||||
cnt = np.zeros(np_v)
|
||||
np.add.at(acc, pev[:, 0], p_dn[pev[:, 1]])
|
||||
np.add.at(acc, pev[:, 1], p_dn[pev[:, 0]])
|
||||
np.add.at(cnt, pev[:, 0], 1)
|
||||
np.add.at(cnt, pev[:, 1], 1)
|
||||
sm = acc / np.maximum(cnt, 1)
|
||||
upd = free | collar
|
||||
p_dn[upd] = 0.5 * p_dn[upd] + 0.5 * sm[upd]
|
||||
|
||||
# per-proxy-vertex TARGET surface
|
||||
p_cle = np.clip((np.abs(pco[:, 0]) - CLEAV_GAP) / CLEAV_W, 0.0, 1.0)
|
||||
p_cle = p_cle * p_cle * (3 - 2 * p_cle)
|
||||
cup_dn = p_dn[(p_zone == 1)]
|
||||
k_amp = AMP_TARGET / max(np.percentile(cup_dn, 99.5), 1e-4)
|
||||
p_field = np.zeros(np_v)
|
||||
mcup = p_zone == 1
|
||||
p_field[mcup] = np.maximum(p_dn[mcup], 0.0) * k_amp * p_cle[mcup]
|
||||
mbri = p_zone == 3
|
||||
bri_fade = np.clip((0.595 - pco[:, 2]) / 0.030, 0.0, 1.0)
|
||||
bri_fade = bri_fade * bri_fade * (3 - 2 * bri_fade)
|
||||
p_field[mbri] = p_dn[mbri] * bri_fade[mbri]
|
||||
# zones 2 (toprest) and 4 (corridor) stay 0 -> target = wall
|
||||
# The cup field is NOT baked into the cage any more. Sequence proven by v3.13: first take the
|
||||
# top fully off (wall replacement + melt + measured excision -> clean flat chest), THEN sculpt
|
||||
# the breasts onto the healed surface as a separate post-pass. Entangling the mound field with
|
||||
# the fabric-removal surface made every fabric fix fight the breast shape. The briefs field
|
||||
# stays in the cage: it is her real hip/butt anatomy, not an addition.
|
||||
p_field_cup = np.where(p_zone == 1, p_field, 0.0)
|
||||
up_fade = np.clip((0.752 - pco[:, 2]) / 0.022, 0.0, 1.0)
|
||||
up_fade = up_fade * up_fade * (3 - 2 * up_fade)
|
||||
p_field_cup *= up_fade
|
||||
# The zone mask is paint-keyed and SPECKLED at its borders; a cage built from a discontinuous
|
||||
# field grows radial spikes that the delta application then amplifies into shredding. Diffuse
|
||||
# the scalar over the proxy graph until the cage is built from a smooth function.
|
||||
for _ in range(15):
|
||||
accc = np.zeros(np_v)
|
||||
cntc = np.zeros(np_v)
|
||||
np.add.at(accc, pev[:, 0], p_field_cup[pev[:, 1]])
|
||||
np.add.at(accc, pev[:, 1], p_field_cup[pev[:, 0]])
|
||||
np.add.at(cntc, pev[:, 0], 1)
|
||||
np.add.at(cntc, pev[:, 1], 1)
|
||||
p_field_cup = 0.5 * p_field_cup + 0.5 * (accc / np.maximum(cntc, 1))
|
||||
p_T = p_wall + p_nrm * (p_field - p_field_cup)[:, None]
|
||||
log(f"amplification k = {k_amp:.2f}; proxy target ready (cup field deferred)")
|
||||
|
||||
# Reconstruct SMOOTH dense targets from the coarse fields via Catmull-Clark subdivision.
|
||||
# (v3.0 lifted with inverse-distance weighting of scattered proxy points instead; IDW has
|
||||
# vanishing gradients at every data site, so each proxy vertex owned a visible flat cell —
|
||||
# the "crumpled polygon" look. A subdivided cage is the proper smooth-surface reconstruction:
|
||||
# C2 almost everywhere, facets far below visual scale.)
|
||||
def smooth_bvh(vcoords):
|
||||
dup = ob.copy()
|
||||
dup.data = pme_src.copy()
|
||||
bpy.context.collection.objects.link(dup)
|
||||
dup.data.vertices.foreach_set("co", vcoords.reshape(-1).astype(np.float64))
|
||||
dup.data.update()
|
||||
sub = dup.modifiers.new("s", 'SUBSURF')
|
||||
sub.levels = 2
|
||||
sub.render_levels = 2
|
||||
bpy.context.view_layer.objects.active = dup
|
||||
bpy.ops.object.modifier_apply(modifier="s")
|
||||
dme = dup.data
|
||||
dv = np.empty(len(dme.vertices) * 3)
|
||||
dme.vertices.foreach_get("co", dv)
|
||||
dv = dv.reshape(-1, 3)
|
||||
dl_tot = np.empty(len(dme.polygons), dtype=np.int32)
|
||||
dme.polygons.foreach_get("loop_total", dl_tot)
|
||||
dl_start = np.empty(len(dme.polygons), dtype=np.int32)
|
||||
dme.polygons.foreach_get("loop_start", dl_start)
|
||||
dl_v = np.empty(len(dme.loops), dtype=np.int32)
|
||||
dme.loops.foreach_get("vertex_index", dl_v)
|
||||
dpolys = [dl_v[st:st + tt].tolist() for st, tt in zip(dl_start, dl_tot)]
|
||||
tree = BVHTree.FromPolygons([Vector(v) for v in dv], dpolys,
|
||||
all_triangles=False, epsilon=0.0)
|
||||
bpy.data.objects.remove(dup, do_unlink=True)
|
||||
return tree
|
||||
|
||||
|
||||
pme_src = pme.copy() # keep proxy topology before the proxy object is deleted
|
||||
bvh_wall = smooth_bvh(p_wall)
|
||||
bvh_tgt = smooth_bvh(p_T)
|
||||
bpy.data.objects.remove(proxy, do_unlink=True)
|
||||
log("smooth subdivided targets ready")
|
||||
|
||||
# ---------------- decide the ACTIVE set first, then lift targets for ALL of it -------------
|
||||
# (v3.2/3.3 grew the intent mask with morphological close and a boundary push, but targets were
|
||||
# only computed for the original paint+corridor region — the bow at the sternum, strap tops
|
||||
# above the corridor and the armpit folds ended up marked active WITHOUT a target, so they kept
|
||||
# their fabric geometry untouched. Invariant now: active == lifted == replaced.)
|
||||
n_e0 = len(me.edges)
|
||||
ev0 = np.empty(n_e0 * 2, dtype=np.int32)
|
||||
me.edges.foreach_get("vertices", ev0)
|
||||
ev0 = ev0.reshape(-1, 2)
|
||||
|
||||
|
||||
def grow_edges(mask, rings, edges):
|
||||
out = mask.copy()
|
||||
for _ in range(rings):
|
||||
nxt = out.copy()
|
||||
nxt[edges[:, 0]] |= out[edges[:, 1]]
|
||||
nxt[edges[:, 1]] |= out[edges[:, 0]]
|
||||
out = nxt
|
||||
return out
|
||||
|
||||
|
||||
def proud_of_wall(vi):
|
||||
return (Vector(co[vi]) - bvh_wall.find_nearest(Vector(co[vi]))[0]).length > PROUD_THR
|
||||
|
||||
|
||||
# corridor fabric: geometry decides
|
||||
corr_idx = np.nonzero(corridor & ~garment)[0]
|
||||
fabric_extra = np.zeros(n_v, dtype=bool)
|
||||
for vi in corr_idx:
|
||||
if proud_of_wall(vi):
|
||||
fabric_extra[vi] = True
|
||||
log(f"corridor fabric catch: {fabric_extra.sum()} of {len(corr_idx)}")
|
||||
|
||||
act_set = garment | fabric_extra | fabric_rough | core_top | core_bot
|
||||
g5 = grow_edges(act_set, 3, ev0)
|
||||
inv = grow_edges(~g5, 3, ev0)
|
||||
act_set = ~inv
|
||||
log(f"active after band+close: {act_set.sum()}")
|
||||
|
||||
# bounded push: boundary must rest on skin, never on proud fabric
|
||||
allowed = (co[:, 2] > 0.38) & (co[:, 2] < 0.88) & (np.abs(co[:, 0]) < 0.17)
|
||||
proud_cache = {}
|
||||
for it in range(30):
|
||||
bnd = np.zeros(n_v, dtype=bool)
|
||||
e_mix = act_set[ev0[:, 0]] != act_set[ev0[:, 1]]
|
||||
bnd[ev0[e_mix].ravel()] = True
|
||||
bnd &= act_set
|
||||
bad = []
|
||||
for vi in np.nonzero(bnd)[0]:
|
||||
if vi not in proud_cache:
|
||||
proud_cache[vi] = proud_of_wall(vi)
|
||||
if proud_cache[vi]:
|
||||
bad.append(vi)
|
||||
if not bad:
|
||||
log(f"boundary clean after {it} grow steps")
|
||||
break
|
||||
ring = np.zeros(n_v, dtype=bool)
|
||||
ring[bad] = True
|
||||
act_set |= grow_edges(ring, 2, ev0) & allowed
|
||||
else:
|
||||
log(f"NOTE: boundary push capped at 30 steps ({len(bad)} proud verts remain, "
|
||||
f"likely at the allowed-region rim)")
|
||||
|
||||
# ---------------- lift: snap every ACTIVE vert to the smooth target surface ----------------
|
||||
ridx = np.nonzero(act_set)[0]
|
||||
active = np.ones(len(ridx), dtype=bool)
|
||||
T = np.zeros((len(ridx), 3))
|
||||
for k, i in enumerate(ridx):
|
||||
T[k] = bvh_tgt.find_nearest(Vector(co[i]))[0]
|
||||
log(f"lift done for {len(ridx)} active verts")
|
||||
|
||||
# ring-depth blend: 0 at the (now skin-resting) boundary, 1 from BLEND_RINGS inward
|
||||
order = np.concatenate([ev0[:, 0], ev0[:, 1]])
|
||||
nbr = np.concatenate([ev0[:, 1], ev0[:, 0]])
|
||||
srt = np.argsort(order, kind="stable")
|
||||
o_s = order[srt]
|
||||
n_s = nbr[srt]
|
||||
ptr = np.searchsorted(o_s, np.arange(n_v + 1))
|
||||
depth = np.zeros(n_v, dtype=np.int32)
|
||||
dq = deque()
|
||||
seen = np.zeros(n_v, dtype=bool)
|
||||
for a, b in ev0:
|
||||
if act_set[a] != act_set[b]:
|
||||
sv = b if act_set[b] else a
|
||||
if not seen[sv]:
|
||||
seen[sv] = True
|
||||
depth[sv] = 1
|
||||
dq.append(sv)
|
||||
while dq:
|
||||
c = dq.popleft()
|
||||
for nb in n_s[ptr[c]:ptr[c + 1]]:
|
||||
if act_set[nb] and not seen[nb]:
|
||||
seen[nb] = True
|
||||
depth[nb] = depth[c] + 1
|
||||
dq.append(nb)
|
||||
depth[act_set & ~seen] = BLEND_RINGS + 1
|
||||
wgt = np.clip(depth[ridx] / float(BLEND_RINGS), 0.0, 1.0)
|
||||
wgt = wgt * wgt * (3 - 2 * wgt)
|
||||
log(f"blend: {(wgt >= 1).sum()} full, {((wgt > 0) & (wgt < 1)).sum()} ramp")
|
||||
|
||||
co_new = co.copy()
|
||||
co_new[ridx] = co[ridx] + (T - co[ridx]) * wgt[:, None]
|
||||
|
||||
# short Taubin polish over the replaced area (kills residual IDW dimples)
|
||||
cnt_all = np.maximum(np.diff(ptr), 1)
|
||||
|
||||
|
||||
def nb_mean(P):
|
||||
sums = np.add.reduceat(P[n_s], ptr[:-1], axis=0)
|
||||
empty = np.diff(ptr) == 0
|
||||
sums[empty] = P[empty]
|
||||
return sums / cnt_all[:, None]
|
||||
|
||||
|
||||
pol = act_set & (depth >= BLEND_RINGS)
|
||||
pidx = np.nonzero(pol)[0]
|
||||
for _ in range(POLISH_ITERS):
|
||||
for f in (0.55, -0.58):
|
||||
d = (nb_mean(co_new) - co_new) * f
|
||||
co_new[pidx] += d[pidx]
|
||||
log(f"polish: {len(pidx)} verts, {POLISH_ITERS} Taubin pairs")
|
||||
|
||||
# MELT pass: whatever fabric decoration still shows (the bow/lacing scar defeated the colour
|
||||
# key, the roughness threshold AND the proud test), it is by definition ROUGH ON THE RESULT.
|
||||
# Detect residual roughness inside the front-chest window on co_new itself and aggressively
|
||||
# smooth just those verts into the surrounding replaced surface. Local and bounded: it cannot
|
||||
# move anything that is already smooth.
|
||||
sm2 = co_new.copy()
|
||||
for _ in range(8):
|
||||
su2 = np.add.reduceat(sm2[n_rs], ptr_r[:-1], axis=0)
|
||||
emp2 = np.diff(ptr_r) == 0
|
||||
su2[emp2] = sm2[emp2]
|
||||
sm2 = su2 / cnt_r[:, None]
|
||||
rough2 = np.linalg.norm(co_new - sm2, axis=1)
|
||||
melt_win = (co[:, 2] > 0.42) & (co[:, 2] < 0.81) & (np.abs(co[:, 0]) < 0.15) & ~((np.abs(co[:, 0]) < 0.018) & (co[:, 2] > 0.50) & (co[:, 2] < 0.55)) # navel
|
||||
melt = melt_win & (rough2 > 0.0006)
|
||||
melt = grow_edges(melt, 3, ev0)
|
||||
midx = np.nonzero(melt)[0]
|
||||
for _ in range(60):
|
||||
for f in (0.55, -0.58):
|
||||
d2 = (nb_mean(co_new) - co_new) * f
|
||||
co_new[midx] += d2[midx]
|
||||
log(f"melt: {len(midx)} rough verts smoothed hard")
|
||||
|
||||
# BOW EXCISION (v4.8 configuration, restored): bi-harmonic heal of the decorated front
|
||||
# window, run to CONVERGENCE. Wall-snap variants sampled unfaired cage patches (raw bow) back
|
||||
# onto the chest, and fairing the window on the proxy collapsed the mound source field —
|
||||
# both reverted. The converged membrane leaves a soft valley between the upper mounds, which
|
||||
# the reference image shows as natural anatomy.
|
||||
bow_win = (co[:, 1] < 0) & (np.abs(co[:, 0]) < 0.080) & (co[:, 2] > 0.630) & (co[:, 2] < 0.802)
|
||||
bow_free = grow_edges(bow_win, 2, ev0)
|
||||
bow_collar = grow_edges(bow_free, 2, ev0) & ~bow_free
|
||||
Sb = np.nonzero(bow_free | bow_collar)[0]
|
||||
in_Sb = np.zeros(n_v, dtype=bool)
|
||||
in_Sb[Sb] = True
|
||||
glb_ = np.full(n_v, -1, dtype=np.int64)
|
||||
glb_[Sb] = np.arange(len(Sb))
|
||||
seb = ev0[in_Sb[ev0].all(axis=1)]
|
||||
a_b = glb_[seb[:, 0]]
|
||||
b_b = glb_[seb[:, 1]]
|
||||
degb = np.zeros(len(Sb))
|
||||
np.add.at(degb, a_b, 1.0)
|
||||
np.add.at(degb, b_b, 1.0)
|
||||
freeB = bow_free[Sb]
|
||||
|
||||
|
||||
def Lsb(X):
|
||||
out = degb[:, None] * X
|
||||
np.add.at(out, a_b, -X[b_b])
|
||||
np.add.at(out, b_b, -X[a_b])
|
||||
return out
|
||||
|
||||
|
||||
def A_b(U):
|
||||
X = np.zeros((len(Sb), 3))
|
||||
X[freeB] = U
|
||||
return Lsb(Lsb(X))[freeB]
|
||||
|
||||
|
||||
Xcb = np.zeros((len(Sb), 3))
|
||||
Xcb[~freeB] = co_new[Sb[~freeB]]
|
||||
rhs_b = -Lsb(Lsb(Xcb))[freeB]
|
||||
Ub = co_new[Sb[freeB]].copy()
|
||||
r_b = rhs_b - A_b(Ub)
|
||||
p_b = r_b.copy()
|
||||
rs_b = (r_b * r_b).sum()
|
||||
rs0_b = rs_b
|
||||
for it_b in range(120000):
|
||||
Apb = A_b(p_b)
|
||||
al = rs_b / max((p_b * Apb).sum(), 1e-30)
|
||||
Ub += al * p_b
|
||||
r_b -= al * Apb
|
||||
rs2 = (r_b * r_b).sum()
|
||||
if rs2 < 1e-18 or rs2 < rs0_b * 1e-14:
|
||||
break
|
||||
p_b = r_b + (rs2 / rs_b) * p_b
|
||||
rs_b = rs2
|
||||
co_new[Sb[freeB]] = Ub
|
||||
log(f"bow excision: {freeB.sum()} verts healed (CG {it_b} iters, "
|
||||
f"rel residual {rs2/max(rs0_b,1e-30):.2e})")
|
||||
|
||||
# ---- STEP 2: sculpt the breasts onto the healed chest ----
|
||||
# Applied as the DELTA between two subdivided cages: (wall + cup field) minus (wall). Smooth
|
||||
# everywhere by construction and exactly zero outside the mound footprint, so it composes with
|
||||
# the healed chest without steps. (A scalar IDW lift was tried first and re-created the
|
||||
# flat-spot bubbling that killed v3.0 — same lesson, same fix: reconstruct through a
|
||||
# subdivided cage, never by scattered-point interpolation.)
|
||||
bvh_mound = smooth_bvh(p_wall + p_nrm * p_field_cup[:, None])
|
||||
chest_win = (co[:, 1] < 0.02) & (co[:, 2] > 0.585) & (co[:, 2] < 0.80) & (np.abs(co[:, 0]) < 0.115)
|
||||
widx = np.nonzero(chest_win)[0]
|
||||
applied = 0
|
||||
apex_d = 0.0
|
||||
# Sample the mound HEIGHT in the wall's own frame: nearest wall point W with its smooth normal
|
||||
# N, then ray-cast the mound cage along N. One shared frame — unlike the nearest-point delta
|
||||
# (whose two nearest points are DIFFERENT surface locations on steep slopes; their difference
|
||||
# carries wild tangential components and shredded the mounds), height-along-normal is a
|
||||
# continuous scalar field over the wall, so the applied surface inherits both cages' smoothness.
|
||||
h_arr = np.zeros(len(widx))
|
||||
N_arr = np.zeros((len(widx), 3))
|
||||
for k_, i_ in enumerate(widx):
|
||||
pos_v = Vector(co_new[i_])
|
||||
hw = bvh_wall.find_nearest(pos_v)
|
||||
Wp, Nn = hw[0], hw[1]
|
||||
N_arr[k_] = np.array(Nn)
|
||||
rc = bvh_mound.ray_cast(Wp - Nn * 0.004, Nn, 0.09)
|
||||
if rc[0] is not None:
|
||||
h_arr[k_] = max((Vector(rc[0]) - Wp).dot(Nn), 0.0)
|
||||
# The raw per-vertex heights carry sampling noise (adjacent rays graze different cage faces),
|
||||
# which rendered as hairline cracks. Smooth the SCALAR height over the window's mesh graph,
|
||||
# then renormalise to the target apex — a smooth scalar along smooth normals is artifact-free.
|
||||
in_win = np.zeros(n_v, dtype=bool)
|
||||
in_win[widx] = True
|
||||
pos_win = np.full(n_v, -1, dtype=np.int64)
|
||||
pos_win[widx] = np.arange(len(widx))
|
||||
we = ev0[in_win[ev0].all(axis=1)]
|
||||
wa = pos_win[we[:, 0]]
|
||||
wb = pos_win[we[:, 1]]
|
||||
for _ in range(15):
|
||||
acch = np.zeros(len(widx))
|
||||
cnth = np.zeros(len(widx))
|
||||
np.add.at(acch, wa, h_arr[wb])
|
||||
np.add.at(acch, wb, h_arr[wa])
|
||||
np.add.at(cnth, wa, 1)
|
||||
np.add.at(cnth, wb, 1)
|
||||
mh = acch / np.maximum(cnth, 1)
|
||||
h_arr = 0.5 * h_arr + 0.5 * mh
|
||||
# the DIRECTION field cracks too: find_nearest returns per-face normals of the subdivided
|
||||
# cage, and at 30+ mm of displacement a 2-degree jump between neighbouring faces opens a
|
||||
# millimetre crack. Smooth the normals with the heights.
|
||||
accn = np.zeros((len(widx), 3))
|
||||
np.add.at(accn, wa, N_arr[wb])
|
||||
np.add.at(accn, wb, N_arr[wa])
|
||||
mn_ = accn / np.maximum(cnth, 1)[:, None]
|
||||
N_arr = 0.5 * N_arr + 0.5 * mn_
|
||||
N_arr /= np.maximum(np.linalg.norm(N_arr, axis=1, keepdims=True), 1e-12)
|
||||
if h_arr.max() > 1e-4:
|
||||
# gamma < 1 fattens the mid-slopes: the reference mounds are near-hemispherical (full
|
||||
# shoulder), not shallow domes
|
||||
h_arr = h_arr.max() * (h_arr / h_arr.max()) ** 0.75
|
||||
h_arr *= AMP_TARGET / h_arr.max()
|
||||
co_new[widx] += N_arr * h_arr[:, None]
|
||||
log(f"breast field applied (smoothed heights): {(h_arr > 1e-4).sum()} verts, "
|
||||
f"apex {h_arr.max():.4f}")
|
||||
|
||||
# seam polish: the ramp boundaries leave faint horizontal lines at the old band edges; both
|
||||
# sides are smooth surfaces now, so a light local Taubin along the boundary rings erases the
|
||||
# lines without moving anything else
|
||||
bnd_f = np.zeros(n_v, dtype=bool)
|
||||
e_mix2 = act_set[ev0[:, 0]] != act_set[ev0[:, 1]]
|
||||
bnd_f[ev0[e_mix2].ravel()] = True
|
||||
seam_band = grow_edges(bnd_f, 4, ev0)
|
||||
sidx = np.nonzero(seam_band)[0]
|
||||
for _ in range(20):
|
||||
for f in (0.55, -0.58):
|
||||
d3 = (nb_mean(co_new) - co_new) * f
|
||||
co_new[sidx] += d3[sidx]
|
||||
log(f"seam polish: {len(sidx)} boundary-band verts")
|
||||
|
||||
me.vertices.foreach_set("co", co_new.reshape(-1))
|
||||
me.update()
|
||||
if me.has_custom_normals:
|
||||
vn = np.empty(n_v * 3, dtype=np.float32)
|
||||
me.vertices.foreach_get("normal", vn)
|
||||
me.normals_split_custom_set_from_vertices(vn.reshape(-1, 3))
|
||||
|
||||
# save active mask for the texture stage (corridor fabric needs repainting too)
|
||||
np.savez_compressed(MASKS.replace(".npz", "_active.npz"), active=act_set)
|
||||
bpy.context.preferences.filepaths.save_version = 0 # no .blend1 autosave
|
||||
bpy.ops.wm.save_as_mainfile(filepath=OUT)
|
||||
log(f"WROTE {OUT}")
|
||||
|
||||
fmax = p_field.max()
|
||||
log(f"GATE apex projection (proxy field): {fmax:.4f} (target {AMP_TARGET})")
|
||||
print("SCULPT_DONE")
|
||||
@@ -0,0 +1,99 @@
|
||||
# Stage 4: review renders + numeric profile gates for the hires sculpt.
|
||||
# blender --background --python 04_review.py -- <03_sculpted.blend> <review_dir>
|
||||
import bpy, sys, os, math, time
|
||||
import numpy as np
|
||||
from mathutils import Vector
|
||||
|
||||
argv = sys.argv[sys.argv.index("--") + 1:]
|
||||
BLEND, OUT = argv[0], argv[1]
|
||||
os.makedirs(OUT, exist_ok=True)
|
||||
t0 = time.time()
|
||||
|
||||
|
||||
def log(m):
|
||||
print(f"[review {time.time()-t0:6.1f}s] {m}", flush=True)
|
||||
|
||||
|
||||
bpy.ops.wm.open_mainfile(filepath=BLEND)
|
||||
ob = max([o for o in bpy.data.objects if o.type == 'MESH'],
|
||||
key=lambda o: len(o.data.vertices))
|
||||
me = ob.data
|
||||
n_v = len(me.vertices)
|
||||
|
||||
co = np.empty(n_v * 3, dtype=np.float64)
|
||||
me.vertices.foreach_get("co", co)
|
||||
co = co.reshape(-1, 3)
|
||||
|
||||
# ---------- numeric gates ----------
|
||||
front = co[:, 1] < 0
|
||||
print("\n=== PROFILE y(z) at sternum x=0 (front) ===")
|
||||
for z0 in np.arange(0.58, 0.78, 0.01):
|
||||
m = front & (np.abs(co[:, 0]) < 0.004) & (np.abs(co[:, 2] - z0) < 0.005)
|
||||
if m.sum():
|
||||
print(f" z={z0:.2f} y={co[m,1].min():+.4f}")
|
||||
print("=== PROFILE y(z) at apex x=0.034 ===")
|
||||
for z0 in np.arange(0.58, 0.78, 0.01):
|
||||
m = front & (np.abs(co[:, 0] - 0.034) < 0.005) & (np.abs(co[:, 2] - z0) < 0.005)
|
||||
if m.sum():
|
||||
print(f" z={z0:.2f} y={co[m,1].min():+.4f}")
|
||||
print("=== PROFILE y(x) at z=0.688 ===")
|
||||
for x0 in np.arange(-0.10, 0.101, 0.01):
|
||||
m = front & (np.abs(co[:, 0] - x0) < 0.005) & (np.abs(co[:, 2] - 0.688) < 0.006)
|
||||
if m.sum():
|
||||
print(f" x={x0:+.2f} y={co[m,1].min():+.4f}")
|
||||
|
||||
# ---------- renders ----------
|
||||
scn = bpy.context.scene
|
||||
w = bpy.data.worlds.new("W")
|
||||
w.color = (0.22, 0.22, 0.24)
|
||||
scn.world = w
|
||||
key = bpy.data.objects.new("Key", bpy.data.lights.new("Key", 'SUN'))
|
||||
key.data.energy = 3.0
|
||||
key.data.use_shadow = False
|
||||
bpy.context.collection.objects.link(key)
|
||||
fill = bpy.data.objects.new("Fill", bpy.data.lights.new("Fill", 'SUN'))
|
||||
fill.data.energy = 1.0
|
||||
fill.data.use_shadow = False
|
||||
bpy.context.collection.objects.link(fill)
|
||||
cam = bpy.data.objects.new("Cam", bpy.data.cameras.new("Cam"))
|
||||
cam.data.lens = 85
|
||||
bpy.context.collection.objects.link(cam)
|
||||
scn.camera = cam
|
||||
scn.render.engine = 'BLENDER_EEVEE' if bpy.app.version >= (4, 2) else 'BLENDER_EEVEE_NEXT'
|
||||
scn.render.resolution_x = scn.render.resolution_y = 1000
|
||||
|
||||
clay_mat = bpy.data.materials.new("Clay")
|
||||
clay_mat.use_nodes = True
|
||||
clay_mat.node_tree.nodes["Principled BSDF"].inputs["Base Color"].default_value = (0.62, 0.60, 0.58, 1)
|
||||
clay_mat.node_tree.nodes["Principled BSDF"].inputs["Roughness"].default_value = 0.45
|
||||
orig_mats = [ms.material for ms in ob.material_slots]
|
||||
|
||||
|
||||
def shoot(tag, ctr, span, yaw_deg, clay):
|
||||
if clay:
|
||||
for ms in ob.material_slots:
|
||||
ms.material = clay_mat
|
||||
else:
|
||||
for ms, m in zip(ob.material_slots, orig_mats):
|
||||
ms.material = m
|
||||
yaw = math.radians(yaw_deg)
|
||||
dist = span * 3.0
|
||||
cam.location = Vector(ctr) + Vector((math.sin(yaw) * dist, -math.cos(yaw) * dist, 0.02))
|
||||
cam.rotation_euler = (Vector(ctr) - cam.location).to_track_quat('-Z', 'Y').to_euler()
|
||||
key.rotation_euler = (math.radians(62), 0, math.radians(35 + yaw_deg))
|
||||
fill.rotation_euler = (math.radians(75), 0, math.radians(yaw_deg - 110))
|
||||
scn.render.filepath = os.path.join(OUT, f"{tag}.png")
|
||||
bpy.ops.render.render(write_still=True)
|
||||
log(f"render {tag}")
|
||||
|
||||
|
||||
CHEST = (0.0, 0.0, 0.675)
|
||||
HIP = (0.0, 0.0, 0.53)
|
||||
FULL = (0.0, 0.0, 0.50)
|
||||
for yaw in (0, 40, 90):
|
||||
shoot(f"chest_clay_{yaw}", CHEST, 0.22, yaw, True)
|
||||
shoot(f"chest_tex_{yaw}", CHEST, 0.22, yaw, False)
|
||||
shoot("hip_clay_0", HIP, 0.22, 0, True)
|
||||
shoot("full_clay_0", FULL, 0.55, 0, True)
|
||||
shoot("full_clay_40", FULL, 0.55, 40, True)
|
||||
print("REVIEW_DONE")
|
||||
@@ -0,0 +1,346 @@
|
||||
# Stage 5: repaint the garment out of the textures (basecolor + normal + roughness/metallic),
|
||||
# on the sculpted hires body.
|
||||
#
|
||||
# blender --background --python 05_texture.py -- <06_final.blend> <masks.npz>
|
||||
# <00_welded.blend> <out.blend>
|
||||
#
|
||||
# The repaint mask is rebuilt from the ORIGINAL geometry (00_welded) because fabric verts are
|
||||
# no longer rough after the sculpt: garment ∪ hemband ∪ key_raw ∪ rough strap corridor ∪ the
|
||||
# bow-excision window ∪ the crotch box — the union of every region whose geometry was
|
||||
# replaced, whose paint is fabric.
|
||||
#
|
||||
# Same principles that fixed the game-density bake, at hires scale:
|
||||
# - fill tone comes from skin NEAREST ON THE BODY (KD over skin verts in 3D), never from
|
||||
# atlas neighbourhoods — atlas-local fills gave wrong tones and island seams;
|
||||
# - grain is transplanted from real skin tiles so the fill is not a smooth decal
|
||||
# (per-channel high-pass — the channel-mixing blur bug is not repeated here);
|
||||
# - normal map goes flat (128,128,255) and rm matches median skin over the same texels,
|
||||
# so the fabric weave stops shading through after the paint is gone.
|
||||
import bpy, sys, time
|
||||
import numpy as np
|
||||
from mathutils import Vector
|
||||
from mathutils.kdtree import KDTree
|
||||
|
||||
argv = sys.argv[sys.argv.index("--") + 1:]
|
||||
BLEND, MASKS, WELDED, OUT = argv[0], argv[1], argv[2], argv[3]
|
||||
# 'patch' mode (v02 variant): repaint ONLY the garment texels, keep every other texel of the
|
||||
# original skin — no rosy-chest mask, fill sources include the rosy skin so the patches blend
|
||||
# with HER tones, and the fill is mirror-averaged so one side's blush can't splash one cup.
|
||||
PATCH_ONLY = len(argv) > 4 and argv[4] == "patch"
|
||||
t0 = time.time()
|
||||
GRAIN_T = 16
|
||||
FEATHER = 4
|
||||
|
||||
|
||||
def log(m):
|
||||
print(f"[tex {time.time()-t0:6.1f}s] {m}", flush=True)
|
||||
|
||||
|
||||
# ---- build the repaint mask from ORIGINAL geometry ----
|
||||
bpy.ops.wm.open_mainfile(filepath=WELDED)
|
||||
ob0 = max([o for o in bpy.data.objects if o.type == 'MESH'],
|
||||
key=lambda o: len(o.data.vertices))
|
||||
me0 = ob0.data
|
||||
n_v = len(me0.vertices)
|
||||
co0 = np.empty(n_v * 3, dtype=np.float64)
|
||||
me0.vertices.foreach_get("co", co0)
|
||||
co0 = co0.reshape(-1, 3)
|
||||
|
||||
ev0 = np.empty(len(me0.edges) * 2, dtype=np.int32)
|
||||
me0.edges.foreach_get("vertices", ev0)
|
||||
ev0 = ev0.reshape(-1, 2)
|
||||
o_r = np.concatenate([ev0[:, 0], ev0[:, 1]])
|
||||
n_r = np.concatenate([ev0[:, 1], ev0[:, 0]])
|
||||
s_r = np.argsort(o_r, kind="stable")
|
||||
o_rs = o_r[s_r]
|
||||
n_rs = n_r[s_r]
|
||||
ptr_r = np.searchsorted(o_rs, np.arange(n_v + 1))
|
||||
cnt_r = np.maximum(np.diff(ptr_r), 1)
|
||||
sm_r = co0.copy()
|
||||
for _ in range(8):
|
||||
su = np.add.reduceat(sm_r[n_rs], ptr_r[:-1], axis=0)
|
||||
emp = np.diff(ptr_r) == 0
|
||||
su[emp] = sm_r[emp]
|
||||
sm_r = su / cnt_r[:, None]
|
||||
rough0 = np.linalg.norm(co0 - sm_r, axis=1)
|
||||
|
||||
M = np.load(MASKS)
|
||||
corridor0 = (co0[:, 2] > 0.64) & (co0[:, 2] < 0.86) \
|
||||
& (np.abs(co0[:, 0]) > 0.02) & (np.abs(co0[:, 0]) < 0.145)
|
||||
strap_rough = corridor0 & (rough0 > 0.0005)
|
||||
bow_win = (co0[:, 1] < 0) & (np.abs(co0[:, 0]) < 0.080) \
|
||||
& (co0[:, 2] > 0.630) & (co0[:, 2] < 0.802)
|
||||
crotch_box = (np.abs(co0[:, 0]) < 0.075) & (co0[:, 2] > 0.340) & (co0[:, 2] < 0.480)
|
||||
garment = (M["garment"] | M["hemband"] | M["key_raw"]
|
||||
| strap_rough | bow_win | crotch_box)
|
||||
for _ in range(3): # grow so the rasterised fill overlaps every replaced-geometry rim
|
||||
hit = garment[ev0[:, 0]] | garment[ev0[:, 1]]
|
||||
g2 = garment.copy()
|
||||
g2[ev0[:, 0]] |= hit
|
||||
g2[ev0[:, 1]] |= hit
|
||||
garment = g2
|
||||
log(f"repaint mask: {garment.sum()} of {n_v} "
|
||||
f"(strap_rough {strap_rough.sum()}, bow {bow_win.sum()}, crotch {crotch_box.sum()})")
|
||||
|
||||
# ---- now open the FINAL sculpted body and repaint on it ----
|
||||
bpy.ops.wm.open_mainfile(filepath=BLEND)
|
||||
ob = max([o for o in bpy.data.objects if o.type == 'MESH'],
|
||||
key=lambda o: len(o.data.vertices))
|
||||
me = ob.data
|
||||
assert len(me.vertices) == n_v, "vertex count changed between welded and final"
|
||||
log(f"loaded {n_v}v, garment {garment.sum()}")
|
||||
|
||||
co = np.empty(n_v * 3, dtype=np.float64)
|
||||
me.vertices.foreach_get("co", co)
|
||||
co = co.reshape(-1, 3)
|
||||
|
||||
imgs = {}
|
||||
for i in bpy.data.images:
|
||||
nm = i.name.lower()
|
||||
if "basecolor" in nm:
|
||||
imgs["base"] = i
|
||||
elif "normal" in nm:
|
||||
imgs["normal"] = i
|
||||
elif "_rm" in nm or nm.endswith("rm.jpg"):
|
||||
imgs["rm"] = i
|
||||
log(f"images: { {k: v.name for k, v in imgs.items()} }")
|
||||
base = imgs["base"]
|
||||
w, h = base.size
|
||||
buf = np.empty(w * h * 4, dtype=np.float32)
|
||||
base.pixels.foreach_get(buf)
|
||||
rgb = buf.reshape(h, w, 4)
|
||||
|
||||
loops_v = np.empty(len(me.loops), dtype=np.int32)
|
||||
me.loops.foreach_get("vertex_index", loops_v)
|
||||
uv = np.empty(len(me.loops) * 2, dtype=np.float64)
|
||||
me.uv_layers.active.data.foreach_get("uv", uv)
|
||||
uv = uv.reshape(-1, 2)
|
||||
lx = np.clip(uv[:, 0], 0, 1) * (w - 1)
|
||||
ly = np.clip(uv[:, 1], 0, 1) * (h - 1)
|
||||
|
||||
first_loop = np.full(n_v, len(loops_v), dtype=np.int64)
|
||||
np.minimum.at(first_loop, loops_v, np.arange(len(loops_v), dtype=np.int64))
|
||||
first_loop = np.minimum(first_loop, len(loops_v) - 1)
|
||||
vcol = rgb[ly[first_loop].astype(int), lx[first_loop].astype(int), :3]
|
||||
|
||||
# ---- rosy paint: the original body has a sunburn-like blush V on the upper chest/throat.
|
||||
# It reads as a tan line on the nude (uniform-skin decision) and it poisons the 3D-nearest
|
||||
# fill (asymmetric pink cups). Detect it per-vertex, repaint it, and never sample from it.
|
||||
band = (co[:, 2] > 0.30) & (co[:, 2] < 0.905)
|
||||
rg = vcol[:, 0] - vcol[:, 1]
|
||||
# reference tone is the BELLY, not the band median — the whole upper chest is rosy, so a
|
||||
# band median is itself rosy-biased and lets the blush field through (measured: belly rg
|
||||
# 0.239, upper chest 0.30-0.33; a med+0.05 cut only caught the extreme pink core)
|
||||
belly = ~garment & (co[:, 1] < 0) & (co[:, 2] > 0.45) & (co[:, 2] < 0.60) & (np.abs(co[:, 0]) < 0.08)
|
||||
med_rg = np.median(rg[belly])
|
||||
rosy = band & (rg > med_rg + 0.045)
|
||||
if PATCH_ONLY:
|
||||
log(f"patch mode: garment texels only, mask {garment.sum()}")
|
||||
else:
|
||||
rosy_chest = rosy & (co[:, 1] < 0.01) & (co[:, 2] > 0.55)
|
||||
garment = garment | rosy_chest
|
||||
log(f"rosy: {rosy.sum()} total, chest repaint {rosy_chest.sum()} "
|
||||
f"(belly med_rg {med_rg:.3f}) -> mask {garment.sum()}")
|
||||
|
||||
# ---- per-vertex fill colour: K nearest skin verts in 3D ----
|
||||
if PATCH_ONLY:
|
||||
skin = ~garment & (co[:, 2] > 0.30) & (co[:, 2] < 0.86)
|
||||
else:
|
||||
skin = ~garment & ~rosy & (co[:, 2] > 0.30) & (co[:, 2] < 0.86)
|
||||
skin_idx = np.nonzero(skin)[0][::3] # 1-in-3 sample is plenty at this density
|
||||
kd = KDTree(len(skin_idx))
|
||||
for j, i in enumerate(skin_idx):
|
||||
kd.insert(Vector(co[i]), j)
|
||||
kd.balance()
|
||||
log(f"skin KD: {len(skin_idx)} verts")
|
||||
|
||||
gidx = np.nonzero(garment)[0]
|
||||
fill_c = vcol.copy()
|
||||
|
||||
|
||||
def idw_at(p):
|
||||
hits = kd.find_n(Vector(p), 8)
|
||||
wsum = 0.0
|
||||
acc = np.zeros(3)
|
||||
for (_, j, dist) in hits:
|
||||
wgt = 1.0 / max(dist * dist, 1e-9)
|
||||
acc += wgt * vcol[skin_idx[j]]
|
||||
wsum += wgt
|
||||
return acc / wsum
|
||||
|
||||
|
||||
for i in gidx:
|
||||
c1 = idw_at(co[i])
|
||||
if PATCH_ONLY:
|
||||
# mirror-average so asymmetric blush near one cup cannot tint only that cup
|
||||
c2 = idw_at([-co[i][0], co[i][1], co[i][2]])
|
||||
fill_c[i] = 0.5 * (c1 + c2)
|
||||
else:
|
||||
fill_c[i] = c1
|
||||
log("per-vertex fill colours done")
|
||||
|
||||
# ---- rasterise fill over garment faces ----
|
||||
n_f = len(me.polygons)
|
||||
l_tot = np.empty(n_f, dtype=np.int32)
|
||||
me.polygons.foreach_get("loop_total", l_tot)
|
||||
l_start = np.empty(n_f, dtype=np.int32)
|
||||
me.polygons.foreach_get("loop_start", l_start)
|
||||
gv = np.zeros(n_v, dtype=bool)
|
||||
gv[gidx] = True
|
||||
face_g = np.zeros(n_f, dtype=bool)
|
||||
# a face is garment if ANY corner is (covers the rim); loop over faces via numpy reduceat
|
||||
face_flag = np.add.reduceat(gv[loops_v].astype(np.int32), l_start)
|
||||
face_g = face_flag > 0
|
||||
log(f"garment faces: {face_g.sum()}")
|
||||
|
||||
mask_px = np.zeros((h, w), dtype=bool)
|
||||
out_rgb = rgb[:, :, :3].astype(np.float64)
|
||||
for fi in np.nonzero(face_g)[0]:
|
||||
s, t = l_start[fi], l_tot[fi]
|
||||
li = np.arange(s, s + t)
|
||||
P = np.stack([lx[li], ly[li]], axis=1)
|
||||
V = loops_v[li]
|
||||
x0, x1 = int(P[:, 0].min()), int(np.ceil(P[:, 0].max()))
|
||||
y0, y1 = int(P[:, 1].min()), int(np.ceil(P[:, 1].max()))
|
||||
if x1 - x0 > 256 or y1 - y0 > 256 or x1 < x0 or y1 < y0:
|
||||
continue
|
||||
if t != 3:
|
||||
P = P[:3]
|
||||
V = V[:3]
|
||||
d = ((P[1, 1] - P[2, 1]) * (P[0, 0] - P[2, 0]) +
|
||||
(P[2, 0] - P[1, 0]) * (P[0, 1] - P[2, 1]))
|
||||
if abs(d) < 1e-12:
|
||||
continue
|
||||
gx, gy = np.meshgrid(np.arange(x0, min(x1, w - 1) + 1),
|
||||
np.arange(y0, min(y1, h - 1) + 1))
|
||||
a = ((P[1, 1] - P[2, 1]) * (gx - P[2, 0]) + (P[2, 0] - P[1, 0]) * (gy - P[2, 1])) / d
|
||||
b = ((P[2, 1] - P[0, 1]) * (gx - P[2, 0]) + (P[0, 0] - P[2, 0]) * (gy - P[2, 1])) / d
|
||||
c = 1.0 - a - b
|
||||
ins = (a >= -0.03) & (b >= -0.03) & (c >= -0.03)
|
||||
if not ins.any():
|
||||
continue
|
||||
col = (a[ins, None] * fill_c[V[0]] + b[ins, None] * fill_c[V[1]]
|
||||
+ c[ins, None] * fill_c[V[2]])
|
||||
out_rgb[gy[ins], gx[ins]] = col
|
||||
mask_px[gy[ins], gx[ins]] = True
|
||||
log(f"rasterised fill: {mask_px.sum()} texels")
|
||||
|
||||
# ---- absorb mask-rim slivers (white stitch piping extends past the colour key, and UV
|
||||
# island borders leave old texels between rasterised faces): dilate 8 px, propagate fill
|
||||
# colours outward into the ring so no legacy pixel survives inside the dilated mask.
|
||||
def dil1(m):
|
||||
g = m.copy()
|
||||
g[1:, :] |= m[:-1, :]
|
||||
g[:-1, :] |= m[1:, :]
|
||||
g[:, 1:] |= m[:, :-1]
|
||||
g[:, :-1] |= m[:, 1:]
|
||||
return g
|
||||
|
||||
|
||||
dil = mask_px.copy()
|
||||
for _ in range(8):
|
||||
dil = dil1(dil)
|
||||
ring = dil & ~mask_px
|
||||
C = out_rgb.copy()
|
||||
have = mask_px.copy()
|
||||
for _ in range(10):
|
||||
if not (ring & ~have).any():
|
||||
break
|
||||
Wf = have.astype(np.float64)
|
||||
acc = np.zeros_like(C)
|
||||
wacc = np.zeros((h, w))
|
||||
for dy, dx in ((1, 0), (-1, 0), (0, 1), (0, -1)):
|
||||
acc += np.roll(C * Wf[:, :, None], (dy, dx), axis=(0, 1))
|
||||
wacc += np.roll(Wf, (dy, dx), axis=(0, 1))
|
||||
newly = ring & ~have & (wacc > 0)
|
||||
C[newly] = acc[newly] / wacc[newly, None]
|
||||
have |= newly
|
||||
out_rgb[ring & have] = C[ring & have]
|
||||
mask_px |= ring & have
|
||||
log(f"rim absorb: +{(ring & have).sum()} ring texels -> mask {mask_px.sum()}")
|
||||
|
||||
|
||||
def box_blur1(a2, r):
|
||||
def b1(x, axis):
|
||||
p = [(0, 0)] * x.ndim
|
||||
p[axis] = (r, r)
|
||||
cs = np.cumsum(np.pad(x, p, mode="edge"), axis=axis)
|
||||
return (np.take(cs, np.arange(2 * r, cs.shape[axis]), axis=axis) -
|
||||
np.take(cs, np.arange(0, cs.shape[axis] - 2 * r), axis=axis)) / (2 * r)
|
||||
return b1(b1(a2, 0), 1)
|
||||
|
||||
|
||||
# ---- grain transplant (per-channel high-pass, tile-based) ----
|
||||
src_ok = ~mask_px
|
||||
grain = np.stack([out_rgb[:, :, c] - box_blur1(out_rgb[:, :, c], 5) for c in range(3)], axis=2)
|
||||
# candidate tiles must be clean AND skin-toned (the atlas also holds eyes/lips whose
|
||||
# high-contrast grain would streak the fill)
|
||||
skin_tone = np.median(out_rgb[mask_px], axis=0) if mask_px.any() else np.array([0.6, 0.45, 0.38])
|
||||
cand = []
|
||||
for ty in range(0, h - GRAIN_T, GRAIN_T):
|
||||
for tx in range(0, w - GRAIN_T, GRAIN_T):
|
||||
if not src_ok[ty:ty + GRAIN_T, tx:tx + GRAIN_T].all():
|
||||
continue
|
||||
tmean = out_rgb[ty:ty + GRAIN_T, tx:tx + GRAIN_T].reshape(-1, 3).mean(axis=0)
|
||||
if np.abs(tmean - skin_tone).max() < 0.13:
|
||||
cand.append((ty, tx))
|
||||
rng = np.random.RandomState(77)
|
||||
covered = 0
|
||||
for ty in range(0, h - GRAIN_T + 1, GRAIN_T):
|
||||
for tx in range(0, w - GRAIN_T + 1, GRAIN_T):
|
||||
tm = mask_px[ty:ty + GRAIN_T, tx:tx + GRAIN_T]
|
||||
if not tm.any():
|
||||
continue
|
||||
sy, sx = cand[rng.randint(len(cand))]
|
||||
blk = out_rgb[ty:ty + GRAIN_T, tx:tx + GRAIN_T]
|
||||
blk[tm] += grain[sy:sy + GRAIN_T, sx:sx + GRAIN_T][tm] * 0.85
|
||||
covered += int(tm.sum())
|
||||
log(f"grain: {covered} texels from {len(cand)} source tiles")
|
||||
|
||||
# feather rim
|
||||
a_ = np.ones((h, w))
|
||||
edge = mask_px.copy()
|
||||
for k in range(FEATHER):
|
||||
grown = edge.copy()
|
||||
grown[1:-1, 1:-1] |= (edge[:-2, 1:-1] | edge[2:, 1:-1] | edge[1:-1, :-2] | edge[1:-1, 2:])
|
||||
ring = grown & ~edge
|
||||
a_[ring] = (k + 1) / (FEATHER + 1.0)
|
||||
edge = grown
|
||||
blend = np.where(mask_px, 1.0, 1.0 - a_)[:, :, None]
|
||||
orig = rgb[:, :, :3].astype(np.float64)
|
||||
final = np.clip(out_rgb * blend + orig * (1 - blend), 0, 1)
|
||||
buf4 = rgb.copy()
|
||||
buf4[:, :, :3] = final.astype(np.float32)
|
||||
base.pixels.foreach_set(buf4.reshape(-1))
|
||||
base.pack()
|
||||
log("basecolor updated + packed")
|
||||
|
||||
# ---- normal + rm: neutralise over the same texels ----
|
||||
for key_, flatval in (("normal", None), ("rm", None)):
|
||||
if key_ not in imgs:
|
||||
continue
|
||||
im = imgs[key_]
|
||||
iw, ih = im.size
|
||||
b2 = np.empty(iw * ih * 4, dtype=np.float32)
|
||||
im.pixels.foreach_get(b2)
|
||||
arr = b2.reshape(ih, iw, 4)
|
||||
if (iw, ih) != (w, h):
|
||||
log(f" {key_}: size {iw}x{ih} != base — skipping")
|
||||
continue
|
||||
if key_ == "normal":
|
||||
arr[mask_px, 0] = 0.5
|
||||
arr[mask_px, 1] = 0.5
|
||||
arr[mask_px, 2] = 1.0
|
||||
else:
|
||||
med = np.median(arr[src_ok][:, :3], axis=0)
|
||||
arr[mask_px, 0] = med[0]
|
||||
arr[mask_px, 1] = med[1]
|
||||
arr[mask_px, 2] = med[2]
|
||||
im.pixels.foreach_set(arr.reshape(-1))
|
||||
im.pack()
|
||||
log(f" {key_}: neutralised {mask_px.sum()} texels + packed")
|
||||
|
||||
bpy.context.preferences.filepaths.save_version = 0 # no .blend1 autosave
|
||||
bpy.ops.wm.save_as_mainfile(filepath=OUT)
|
||||
log(f"WROTE {OUT}")
|
||||
print("TEXTURE_DONE")
|
||||
@@ -0,0 +1,128 @@
|
||||
# Stage 5b: global rosy-tone grade. Hard vertex-mask repaints leave visible boundaries
|
||||
# (lighter V patch vs darker shoulders, pink neck above the z-cap). Instead: per-texel,
|
||||
# compute the SMOOTHED red-green excess over the belly reference and subtract it with a
|
||||
# z-tapered weight. Smooth field in, smooth field out — no boundaries; the high-frequency
|
||||
# detail (pores, grain) rides on top untouched. Face is excluded by the taper (w=0 above
|
||||
# z 0.91); lips/cheeks keep their red.
|
||||
# blender --background --python 05b_tone_grade.py -- <07_textured.blend> <out.blend>
|
||||
import bpy, sys, time
|
||||
import numpy as np
|
||||
|
||||
argv = sys.argv[sys.argv.index("--") + 1:]
|
||||
BLEND, OUT = argv[0], argv[1]
|
||||
t0 = time.time()
|
||||
|
||||
REF_RG = None # belly reference; measured from the mesh below if None
|
||||
STRENGTH = 0.90
|
||||
Z_UP0, Z_UP1 = 0.26, 0.30 # taper in above the feet
|
||||
Z_DN0, Z_DN1 = 0.88, 0.91 # taper out below the face
|
||||
BLUR_R = 12
|
||||
|
||||
|
||||
def log(m):
|
||||
print(f"[grade {time.time()-t0:6.1f}s] {m}", flush=True)
|
||||
|
||||
|
||||
bpy.ops.wm.open_mainfile(filepath=BLEND)
|
||||
ob = max([o for o in bpy.data.objects if o.type == 'MESH'],
|
||||
key=lambda o: len(o.data.vertices))
|
||||
me = ob.data
|
||||
n_v = len(me.vertices)
|
||||
co = np.empty(n_v * 3)
|
||||
me.vertices.foreach_get("co", co)
|
||||
co = co.reshape(-1, 3)
|
||||
|
||||
base = next(i for i in bpy.data.images if "basecolor" in i.name.lower())
|
||||
w, h = base.size
|
||||
buf = np.empty(w * h * 4, dtype=np.float32)
|
||||
base.pixels.foreach_get(buf)
|
||||
rgb = buf.reshape(h, w, 4)
|
||||
out_rgb = rgb[:, :, :3].astype(np.float64)
|
||||
|
||||
loops_v = np.empty(len(me.loops), dtype=np.int32)
|
||||
me.loops.foreach_get("vertex_index", loops_v)
|
||||
uv = np.empty(len(me.loops) * 2)
|
||||
me.uv_layers.active.data.foreach_get("uv", uv)
|
||||
uv = uv.reshape(-1, 2)
|
||||
lx = np.clip(uv[:, 0], 0, 1) * (w - 1)
|
||||
ly = np.clip(uv[:, 1], 0, 1) * (h - 1)
|
||||
|
||||
# belly reference from the mesh (sample texels under belly verts)
|
||||
first_loop = np.full(n_v, len(loops_v), dtype=np.int64)
|
||||
np.minimum.at(first_loop, loops_v, np.arange(len(loops_v), dtype=np.int64))
|
||||
first_loop = np.minimum(first_loop, len(loops_v) - 1)
|
||||
vcol = out_rgb[ly[first_loop].astype(int), lx[first_loop].astype(int)]
|
||||
belly = (co[:, 1] < 0) & (co[:, 2] > 0.45) & (co[:, 2] < 0.60) & (np.abs(co[:, 0]) < 0.08)
|
||||
ref = REF_RG if REF_RG is not None else float(np.median(vcol[belly, 0] - vcol[belly, 1]))
|
||||
log(f"belly ref rg: {ref:.3f}")
|
||||
|
||||
|
||||
def wz(z):
|
||||
a = np.clip((z - Z_UP0) / (Z_UP1 - Z_UP0), 0, 1)
|
||||
b = np.clip((Z_DN1 - z) / (Z_DN1 - Z_DN0), 0, 1)
|
||||
t = np.minimum(a, b)
|
||||
return t * t * (3 - 2 * t)
|
||||
|
||||
|
||||
# rasterise the per-texel weight from vertex z over ALL faces
|
||||
n_f = len(me.polygons)
|
||||
l_tot = np.empty(n_f, dtype=np.int32)
|
||||
me.polygons.foreach_get("loop_total", l_tot)
|
||||
l_start = np.empty(n_f, dtype=np.int32)
|
||||
me.polygons.foreach_get("loop_start", l_start)
|
||||
vw = wz(co[:, 2])
|
||||
W = np.zeros((h, w))
|
||||
for fi in range(n_f):
|
||||
s, t = l_start[fi], l_tot[fi]
|
||||
li = np.arange(s, s + min(t, 3))
|
||||
V = loops_v[li]
|
||||
if vw[V].max() <= 0:
|
||||
continue
|
||||
P = np.stack([lx[li], ly[li]], axis=1)
|
||||
x0, x1 = int(P[:, 0].min()), int(np.ceil(P[:, 0].max()))
|
||||
y0, y1 = int(P[:, 1].min()), int(np.ceil(P[:, 1].max()))
|
||||
if x1 - x0 > 256 or y1 - y0 > 256 or x1 < x0 or y1 < y0:
|
||||
continue
|
||||
d = ((P[1, 1] - P[2, 1]) * (P[0, 0] - P[2, 0]) +
|
||||
(P[2, 0] - P[1, 0]) * (P[0, 1] - P[2, 1]))
|
||||
if abs(d) < 1e-12:
|
||||
continue
|
||||
gx, gy = np.meshgrid(np.arange(x0, min(x1, w - 1) + 1),
|
||||
np.arange(y0, min(y1, h - 1) + 1))
|
||||
a = ((P[1, 1] - P[2, 1]) * (gx - P[2, 0]) + (P[2, 0] - P[1, 0]) * (gy - P[2, 1])) / d
|
||||
b = ((P[2, 1] - P[0, 1]) * (gx - P[2, 0]) + (P[0, 0] - P[2, 0]) * (gy - P[2, 1])) / d
|
||||
c = 1.0 - a - b
|
||||
ins = (a >= -0.03) & (b >= -0.03) & (c >= -0.03)
|
||||
if not ins.any():
|
||||
continue
|
||||
wv = a[ins] * vw[V[0]] + b[ins] * vw[V[1]] + c[ins] * vw[V[2]]
|
||||
W[gy[ins], gx[ins]] = np.maximum(W[gy[ins], gx[ins]], np.clip(wv, 0, 1))
|
||||
log(f"weight rasterised: {(W > 0).sum()} texels")
|
||||
|
||||
|
||||
def box_blur1(a2, r):
|
||||
def b1(x, axis):
|
||||
p = [(0, 0)] * x.ndim
|
||||
p[axis] = (r, r)
|
||||
cs = np.cumsum(np.pad(x, p, mode="edge"), axis=axis)
|
||||
return (np.take(cs, np.arange(2 * r, cs.shape[axis]), axis=axis) -
|
||||
np.take(cs, np.arange(0, cs.shape[axis] - 2 * r), axis=axis)) / (2 * r)
|
||||
return b1(b1(a2, 0), 1)
|
||||
|
||||
|
||||
# smoothed rg field, weighted by W so face/eye texels can't bleed into the blur across
|
||||
# island borders more than locally
|
||||
rg_s = box_blur1(out_rgb[:, :, 0], BLUR_R) - box_blur1(out_rgb[:, :, 1], BLUR_R)
|
||||
delta = np.maximum(0.0, rg_s - ref) * W * STRENGTH
|
||||
out_rgb[:, :, 0] -= delta * 0.8
|
||||
out_rgb[:, :, 1] += delta * 0.2
|
||||
log(f"graded: {(delta > 0.005).sum()} texels above 0.005, max delta {delta.max():.3f}")
|
||||
|
||||
buf4 = rgb.copy()
|
||||
buf4[:, :, :3] = np.clip(out_rgb, 0, 1).astype(np.float32)
|
||||
base.pixels.foreach_set(buf4.reshape(-1))
|
||||
base.pack()
|
||||
bpy.context.preferences.filepaths.save_version = 0 # no .blend1 autosave
|
||||
bpy.ops.wm.save_as_mainfile(filepath=OUT)
|
||||
log(f"WROTE {OUT}")
|
||||
print("GRADE_DONE")
|
||||
@@ -0,0 +1,193 @@
|
||||
# Stage 6: replace the crotch with the MALE game body's construction (Jeremy's directive —
|
||||
# the male is already the smooth doll-like build; the female must match it exactly).
|
||||
#
|
||||
# blender --background --python 06_crotch.py -- <03_final_geometry.blend> <male.glb> <out.blend>
|
||||
#
|
||||
# Method: detect the crotch saddle landmark on both bodies (lowest midline point of the torso
|
||||
# between the leg roots), scale the male crotch patch by body-height ratio, translate saddle to
|
||||
# saddle, subdivide the patch to a smooth cage, and snap the female crotch window onto it with
|
||||
# a ring-depth blend. The male GLB is in METRES; this sculpt is 0.9792 units tall.
|
||||
import bpy, sys, time
|
||||
from collections import deque
|
||||
import numpy as np
|
||||
from mathutils import Vector
|
||||
from mathutils.bvhtree import BVHTree
|
||||
|
||||
argv = sys.argv[sys.argv.index("--") + 1:]
|
||||
BLEND, MALE, OUT = argv[0], argv[1], argv[2]
|
||||
t0 = time.time()
|
||||
|
||||
WIN_R_Z = 0.042 # female window: half-height around the saddle
|
||||
WIN_R_X = 0.034 # half-width — must NOT reach the inner-thigh walls
|
||||
SNAP_MAX = 0.020 # reject snaps to far surfaces (a bad cage grabbed thighs at 60 mm)
|
||||
BLEND_RINGS = 8
|
||||
|
||||
|
||||
def log(m):
|
||||
print(f"[crotch {time.time()-t0:6.1f}s] {m}", flush=True)
|
||||
|
||||
|
||||
bpy.ops.wm.open_mainfile(filepath=BLEND)
|
||||
ob = max([o for o in bpy.data.objects if o.type == 'MESH'],
|
||||
key=lambda o: len(o.data.vertices))
|
||||
me = ob.data
|
||||
n_v = len(me.vertices)
|
||||
co = np.empty(n_v * 3)
|
||||
me.vertices.foreach_get("co", co)
|
||||
co = co.reshape(-1, 3)
|
||||
f_height = co[:, 2].max() - co[:, 2].min()
|
||||
|
||||
# female saddle: lowest midline torso point between the legs (the briefs bridge the crotch,
|
||||
# so the midline strip is continuous surface)
|
||||
mid_f = (np.abs(co[:, 0]) < 0.01) & (co[:, 2] > 0.38) & (co[:, 2] < 0.50)
|
||||
saddle_f = co[mid_f][np.argmin(co[mid_f, 2])]
|
||||
log(f"female: height {f_height:.4f}, saddle {saddle_f}")
|
||||
|
||||
# ---- male donor ----
|
||||
before = set(bpy.data.objects)
|
||||
bpy.ops.import_scene.gltf(filepath=MALE)
|
||||
new = [o for o in bpy.data.objects if o not in before]
|
||||
male = max([o for o in new if o.type == 'MESH'], key=lambda o: len(o.data.vertices))
|
||||
mme = male.data
|
||||
nm = len(mme.vertices)
|
||||
mco = np.empty(nm * 3)
|
||||
mme.vertices.foreach_get("co", mco)
|
||||
mco = mco.reshape(-1, 3)
|
||||
m_height = mco[:, 2].max() - mco[:, 2].min()
|
||||
mid_m = (np.abs(mco[:, 0]) < 0.02) & (mco[:, 2] > 0.55 * m_height) * (mco[:, 2] < 0.75 * m_height)
|
||||
if not mid_m.any():
|
||||
mid_m = (np.abs(mco[:, 0]) < 0.02)
|
||||
saddle_m = mco[mid_m][np.argmin(mco[mid_m, 2])]
|
||||
s = f_height / m_height
|
||||
log(f"male '{male.name}': {nm}v height {m_height:.3f} m, saddle {saddle_m}, scale {s:.4f}")
|
||||
|
||||
# male crotch patch: faces whose verts lie near the saddle (generous; the cage is only a target)
|
||||
sel_m = (np.abs(mco[:, 0] - saddle_m[0]) < WIN_R_X / s * 1.6) \
|
||||
& (np.abs(mco[:, 2] - saddle_m[2]) < WIN_R_Z / s * 1.6)
|
||||
log(f"male patch verts: {sel_m.sum()}")
|
||||
|
||||
# transform male verts into female space — and AUTO-DETECT front/back orientation: the male
|
||||
# body may face the opposite way; test identity and y-mirror, keep whichever cage lands closer
|
||||
# to the female window verts
|
||||
mco_t = (mco - saddle_m) * s + saddle_f
|
||||
mco_t_flip = mco_t.copy()
|
||||
mco_t_flip[:, 1] = 2 * saddle_f[1] - mco_t[:, 1]
|
||||
|
||||
# build patch mesh -> subdivide -> BVH
|
||||
ml_tot = np.empty(len(mme.polygons), dtype=np.int32)
|
||||
mme.polygons.foreach_get("loop_total", ml_tot)
|
||||
ml_start = np.empty(len(mme.polygons), dtype=np.int32)
|
||||
mme.polygons.foreach_get("loop_start", ml_start)
|
||||
ml_v = np.empty(len(mme.loops), dtype=np.int32)
|
||||
mme.loops.foreach_get("vertex_index", ml_v)
|
||||
faces = []
|
||||
for fs, ft in zip(ml_start, ml_tot):
|
||||
idxs = ml_v[fs:fs + ft]
|
||||
if sel_m[idxs].all():
|
||||
faces.append(idxs.tolist())
|
||||
log(f"male patch faces: {len(faces)}")
|
||||
used = sorted(set(i for f in faces for i in f))
|
||||
remap = {g: i for i, g in enumerate(used)}
|
||||
pm = bpy.data.meshes.new("crotch_patch")
|
||||
pm.from_pydata([Vector(mco_t[i]) for i in used], [],
|
||||
[[remap[i] for i in f] for f in faces])
|
||||
pm.update()
|
||||
po = bpy.data.objects.new("crotch_patch", pm)
|
||||
bpy.context.collection.objects.link(po)
|
||||
sub = po.modifiers.new("s", 'SUBSURF')
|
||||
sub.levels = 2
|
||||
bpy.context.view_layer.objects.active = po
|
||||
bpy.ops.object.modifier_apply(modifier="s")
|
||||
dme = po.data
|
||||
dv = np.empty(len(dme.vertices) * 3)
|
||||
dme.vertices.foreach_get("co", dv)
|
||||
dv = dv.reshape(-1, 3)
|
||||
dl_tot = np.empty(len(dme.polygons), dtype=np.int32)
|
||||
dme.polygons.foreach_get("loop_total", dl_tot)
|
||||
dl_start = np.empty(len(dme.polygons), dtype=np.int32)
|
||||
dme.polygons.foreach_get("loop_start", dl_start)
|
||||
dl_v = np.empty(len(dme.loops), dtype=np.int32)
|
||||
dme.loops.foreach_get("vertex_index", dl_v)
|
||||
polys_d = [dl_v[fs:fs + ft].tolist() for fs, ft in zip(dl_start, dl_tot)]
|
||||
bvh = BVHTree.FromPolygons([Vector(v) for v in dv], polys_d,
|
||||
all_triangles=False, epsilon=0.0)
|
||||
dv_f = dv.copy()
|
||||
dv_f[:, 1] = 2 * saddle_f[1] - dv[:, 1]
|
||||
bvh_f = BVHTree.FromPolygons([Vector(v) for v in dv_f], polys_d,
|
||||
all_triangles=False, epsilon=0.0)
|
||||
for o in new + [po]:
|
||||
bpy.data.objects.remove(o, do_unlink=True)
|
||||
log("donor cages ready (both orientations)")
|
||||
|
||||
# ---- female window snap with ring-depth blend ----
|
||||
win = (np.abs(co[:, 0] - saddle_f[0]) < WIN_R_X) \
|
||||
& (np.abs(co[:, 2] - saddle_f[2]) < WIN_R_Z)
|
||||
widx = np.nonzero(win)[0]
|
||||
log(f"female window: {len(widx)} verts")
|
||||
|
||||
n_e = len(me.edges)
|
||||
ev = np.empty(n_e * 2, dtype=np.int32)
|
||||
me.edges.foreach_get("vertices", ev)
|
||||
ev = ev.reshape(-1, 2)
|
||||
order = np.concatenate([ev[:, 0], ev[:, 1]])
|
||||
nbr = np.concatenate([ev[:, 1], ev[:, 0]])
|
||||
srt = np.argsort(order, kind="stable")
|
||||
o_s = order[srt]
|
||||
n_s = nbr[srt]
|
||||
ptr = np.searchsorted(o_s, np.arange(n_v + 1))
|
||||
depth = np.zeros(n_v, dtype=np.int32)
|
||||
dq = deque()
|
||||
seen = np.zeros(n_v, dtype=bool)
|
||||
for a, b in ev:
|
||||
if win[a] != win[b]:
|
||||
sv = a if win[a] else b
|
||||
if not seen[sv]:
|
||||
seen[sv] = True
|
||||
depth[sv] = 1
|
||||
dq.append(sv)
|
||||
while dq:
|
||||
c = dq.popleft()
|
||||
for nb in n_s[ptr[c]:ptr[c + 1]]:
|
||||
if win[nb] and not seen[nb]:
|
||||
seen[nb] = True
|
||||
depth[nb] = depth[c] + 1
|
||||
dq.append(nb)
|
||||
depth[win & ~seen] = BLEND_RINGS + 2
|
||||
wgt = np.clip(depth[widx] / float(BLEND_RINGS), 0.0, 1.0)
|
||||
wgt = wgt * wgt * (3 - 2 * wgt)
|
||||
|
||||
# orientation pick: median nearest-distance over a sample of window verts
|
||||
samp = widx[::37]
|
||||
def med_d(tree):
|
||||
ds = []
|
||||
for i in samp:
|
||||
h = tree.find_nearest(Vector(co[i]), 0.08)
|
||||
ds.append(h[3] if h[0] is not None else 0.08)
|
||||
return float(np.median(ds))
|
||||
d_id, d_fl = med_d(bvh), med_d(bvh_f)
|
||||
use = bvh if d_id <= d_fl else bvh_f
|
||||
log(f"orientation: identity {d_id:.4f} vs flipped {d_fl:.4f} -> "
|
||||
f"{'identity' if d_id <= d_fl else 'flipped'}")
|
||||
|
||||
co_new = co.copy()
|
||||
moved = 0
|
||||
for k, i in enumerate(widx):
|
||||
hit = use.find_nearest(Vector(co[i]), SNAP_MAX)
|
||||
if hit[0] is None:
|
||||
continue
|
||||
tgt = np.array(hit[0])
|
||||
co_new[i] += (tgt - co[i]) * wgt[k]
|
||||
moved += 1
|
||||
delta = np.linalg.norm(co_new - co, axis=1)
|
||||
log(f"snapped {moved} verts, max move {delta.max():.4f}")
|
||||
|
||||
me.vertices.foreach_set("co", co_new.reshape(-1))
|
||||
me.update()
|
||||
if me.has_custom_normals:
|
||||
vn = np.empty(n_v * 3, dtype=np.float32)
|
||||
me.vertices.foreach_get("normal", vn)
|
||||
me.normals_split_custom_set_from_vertices(vn.reshape(-1, 3))
|
||||
bpy.context.preferences.filepaths.save_version = 0 # no .blend1 autosave
|
||||
bpy.ops.wm.save_as_mainfile(filepath=OUT)
|
||||
log(f"WROTE {OUT}")
|
||||
print("CROTCH_DONE")
|
||||
@@ -0,0 +1,211 @@
|
||||
# Stage 6 v2: male-donor crotch transfer, full-gusset window.
|
||||
#
|
||||
# v1 post-mortem: the female briefs gusset bridges the legs ~3 cm ABOVE the male crotch
|
||||
# saddle, so with SNAP_MAX 0.020 the lower half of the gusset couldn't reach the donor
|
||||
# cage — the surface tore along the snapped/rejected boundary and the leftover panel kept
|
||||
# its gathered-cloth wrinkles. v2 widens the window to the whole gusset, raises SNAP_MAX
|
||||
# to span the real gap, melts whatever the cage still rejects, and Taubin-polishes the
|
||||
# window so the cloth gathers on the inner thighs go too.
|
||||
#
|
||||
# blender --background --python 06_crotch_v2.py -- <03_final_geometry.blend> <male.glb> <out.blend>
|
||||
import bpy, sys, time
|
||||
from collections import deque
|
||||
import numpy as np
|
||||
from mathutils import Vector
|
||||
from mathutils.bvhtree import BVHTree
|
||||
|
||||
argv = sys.argv[sys.argv.index("--") + 1:]
|
||||
BLEND, MALE, OUT = argv[0], argv[1], argv[2]
|
||||
t0 = time.time()
|
||||
|
||||
WIN_Z0, WIN_Z1 = 0.355, 0.470 # absolute: whole briefs gusset + pubic base
|
||||
WIN_R_X = 0.058 # into the inner-thigh gathers, not past the thigh walls
|
||||
SNAP_MAX = 0.035 # the gusset sits ~3 cm above the male crotch
|
||||
BLEND_RINGS = 10
|
||||
MELT_ITERS = 60 # residue verts the cage rejected
|
||||
TAUBIN_PAIRS = 10
|
||||
|
||||
|
||||
def log(m):
|
||||
print(f"[crotch2 {time.time()-t0:6.1f}s] {m}", flush=True)
|
||||
|
||||
|
||||
bpy.ops.wm.open_mainfile(filepath=BLEND)
|
||||
ob = max([o for o in bpy.data.objects if o.type == 'MESH'],
|
||||
key=lambda o: len(o.data.vertices))
|
||||
me = ob.data
|
||||
n_v = len(me.vertices)
|
||||
co = np.empty(n_v * 3)
|
||||
me.vertices.foreach_get("co", co)
|
||||
co = co.reshape(-1, 3)
|
||||
f_height = co[:, 2].max() - co[:, 2].min()
|
||||
|
||||
mid_f = (np.abs(co[:, 0]) < 0.01) & (co[:, 2] > 0.38) & (co[:, 2] < 0.50)
|
||||
saddle_f = co[mid_f][np.argmin(co[mid_f, 2])]
|
||||
log(f"female: height {f_height:.4f}, saddle {saddle_f}")
|
||||
|
||||
# ---- male donor ----
|
||||
before = set(bpy.data.objects)
|
||||
bpy.ops.import_scene.gltf(filepath=MALE)
|
||||
new = [o for o in bpy.data.objects if o not in before]
|
||||
male = max([o for o in new if o.type == 'MESH'], key=lambda o: len(o.data.vertices))
|
||||
mme = male.data
|
||||
nm = len(mme.vertices)
|
||||
mco = np.empty(nm * 3)
|
||||
mme.vertices.foreach_get("co", mco)
|
||||
mco = mco.reshape(-1, 3)
|
||||
m_height = mco[:, 2].max() - mco[:, 2].min()
|
||||
mid_m = (np.abs(mco[:, 0]) < 0.02) & (mco[:, 2] > 0.55 * m_height) & (mco[:, 2] < 0.75 * m_height)
|
||||
if not mid_m.any():
|
||||
mid_m = (np.abs(mco[:, 0]) < 0.02)
|
||||
saddle_m = mco[mid_m][np.argmin(mco[mid_m, 2])]
|
||||
s = f_height / m_height
|
||||
log(f"male '{male.name}': {nm}v height {m_height:.3f} m, saddle {saddle_m}, scale {s:.4f}")
|
||||
|
||||
# male crotch patch generous enough to cover the whole female window after transform
|
||||
half_z = max(abs(WIN_Z1 - saddle_f[2]), abs(saddle_f[2] - WIN_Z0))
|
||||
sel_m = (np.abs(mco[:, 0] - saddle_m[0]) < WIN_R_X / s * 1.8) \
|
||||
& (np.abs(mco[:, 2] - saddle_m[2]) < half_z / s * 1.8)
|
||||
log(f"male patch verts: {sel_m.sum()}")
|
||||
|
||||
mco_t = (mco - saddle_m) * s + saddle_f
|
||||
|
||||
ml_tot = np.empty(len(mme.polygons), dtype=np.int32)
|
||||
mme.polygons.foreach_get("loop_total", ml_tot)
|
||||
ml_start = np.empty(len(mme.polygons), dtype=np.int32)
|
||||
mme.polygons.foreach_get("loop_start", ml_start)
|
||||
ml_v = np.empty(len(mme.loops), dtype=np.int32)
|
||||
mme.loops.foreach_get("vertex_index", ml_v)
|
||||
faces = []
|
||||
for fs, ft in zip(ml_start, ml_tot):
|
||||
idxs = ml_v[fs:fs + ft]
|
||||
if sel_m[idxs].all():
|
||||
faces.append(idxs.tolist())
|
||||
log(f"male patch faces: {len(faces)}")
|
||||
used = sorted(set(i for f in faces for i in f))
|
||||
remap = {g: i for i, g in enumerate(used)}
|
||||
pm = bpy.data.meshes.new("crotch_patch")
|
||||
pm.from_pydata([Vector(mco_t[i]) for i in used], [],
|
||||
[[remap[i] for i in f] for f in faces])
|
||||
pm.update()
|
||||
po = bpy.data.objects.new("crotch_patch", pm)
|
||||
bpy.context.collection.objects.link(po)
|
||||
sub = po.modifiers.new("s", 'SUBSURF')
|
||||
sub.levels = 2
|
||||
bpy.context.view_layer.objects.active = po
|
||||
bpy.ops.object.modifier_apply(modifier="s")
|
||||
dme = po.data
|
||||
dv = np.empty(len(dme.vertices) * 3)
|
||||
dme.vertices.foreach_get("co", dv)
|
||||
dv = dv.reshape(-1, 3)
|
||||
dl_tot = np.empty(len(dme.polygons), dtype=np.int32)
|
||||
dme.polygons.foreach_get("loop_total", dl_tot)
|
||||
dl_start = np.empty(len(dme.polygons), dtype=np.int32)
|
||||
dme.polygons.foreach_get("loop_start", dl_start)
|
||||
dl_v = np.empty(len(dme.loops), dtype=np.int32)
|
||||
dme.loops.foreach_get("vertex_index", dl_v)
|
||||
polys_d = [dl_v[fs:fs + ft].tolist() for fs, ft in zip(dl_start, dl_tot)]
|
||||
bvh = BVHTree.FromPolygons([Vector(v) for v in dv], polys_d,
|
||||
all_triangles=False, epsilon=0.0)
|
||||
dv_f = dv.copy()
|
||||
dv_f[:, 1] = 2 * saddle_f[1] - dv[:, 1]
|
||||
bvh_f = BVHTree.FromPolygons([Vector(v) for v in dv_f], polys_d,
|
||||
all_triangles=False, epsilon=0.0)
|
||||
for o in new + [po]:
|
||||
bpy.data.objects.remove(o, do_unlink=True)
|
||||
log("donor cages ready (both orientations)")
|
||||
|
||||
# ---- female window ----
|
||||
win = (np.abs(co[:, 0] - saddle_f[0]) < WIN_R_X) \
|
||||
& (co[:, 2] > WIN_Z0) & (co[:, 2] < WIN_Z1)
|
||||
widx = np.nonzero(win)[0]
|
||||
log(f"female window: {len(widx)} verts")
|
||||
|
||||
n_e = len(me.edges)
|
||||
ev = np.empty(n_e * 2, dtype=np.int32)
|
||||
me.edges.foreach_get("vertices", ev)
|
||||
ev = ev.reshape(-1, 2)
|
||||
order = np.concatenate([ev[:, 0], ev[:, 1]])
|
||||
nbr = np.concatenate([ev[:, 1], ev[:, 0]])
|
||||
srt = np.argsort(order, kind="stable")
|
||||
o_s = order[srt]
|
||||
n_s = nbr[srt]
|
||||
ptr = np.searchsorted(o_s, np.arange(n_v + 1))
|
||||
depth = np.zeros(n_v, dtype=np.int32)
|
||||
dq = deque()
|
||||
seen = np.zeros(n_v, dtype=bool)
|
||||
for a, b in ev:
|
||||
if win[a] != win[b]:
|
||||
sv = a if win[a] else b
|
||||
if not seen[sv]:
|
||||
seen[sv] = True
|
||||
depth[sv] = 1
|
||||
dq.append(sv)
|
||||
while dq:
|
||||
c = dq.popleft()
|
||||
for nb in n_s[ptr[c]:ptr[c + 1]]:
|
||||
if win[nb] and not seen[nb]:
|
||||
seen[nb] = True
|
||||
depth[nb] = depth[c] + 1
|
||||
dq.append(nb)
|
||||
depth[win & ~seen] = BLEND_RINGS + 2
|
||||
wgt = np.clip(depth[widx] / float(BLEND_RINGS), 0.0, 1.0)
|
||||
wgt = wgt * wgt * (3 - 2 * wgt)
|
||||
|
||||
samp = widx[::37]
|
||||
def med_d(tree):
|
||||
ds = []
|
||||
for i in samp:
|
||||
h = tree.find_nearest(Vector(co[i]), 0.10)
|
||||
ds.append(h[3] if h[0] is not None else 0.10)
|
||||
return float(np.median(ds))
|
||||
d_id, d_fl = med_d(bvh), med_d(bvh_f)
|
||||
use = bvh if d_id <= d_fl else bvh_f
|
||||
log(f"orientation: identity {d_id:.4f} vs flipped {d_fl:.4f} -> "
|
||||
f"{'identity' if d_id <= d_fl else 'flipped'}")
|
||||
|
||||
co_new = co.copy()
|
||||
moved = 0
|
||||
rejected = []
|
||||
for k, i in enumerate(widx):
|
||||
hit = use.find_nearest(Vector(co[i]), SNAP_MAX)
|
||||
if hit[0] is None:
|
||||
rejected.append(i)
|
||||
continue
|
||||
tgt = np.array(hit[0])
|
||||
co_new[i] += (tgt - co[i]) * wgt[k]
|
||||
moved += 1
|
||||
delta = np.linalg.norm(co_new - co, axis=1)
|
||||
log(f"snapped {moved} verts (rejected {len(rejected)}), max move {delta.max():.4f}")
|
||||
|
||||
# ---- melt the rejects toward their neighbours so no torn seam survives ----
|
||||
def neigh_mean(Q, idx):
|
||||
out = np.empty((len(idx), 3))
|
||||
for j, i in enumerate(idx):
|
||||
nbrs = n_s[ptr[i]:ptr[i + 1]]
|
||||
out[j] = Q[nbrs].mean(axis=0) if len(nbrs) else Q[i]
|
||||
return out
|
||||
|
||||
rej = np.array(rejected, dtype=np.int32)
|
||||
if len(rej):
|
||||
for _ in range(MELT_ITERS):
|
||||
co_new[rej] = 0.5 * co_new[rej] + 0.5 * neigh_mean(co_new, rej)
|
||||
log(f"melted {len(rej)} rejected verts")
|
||||
|
||||
# ---- Taubin polish over the whole window (kills cloth gathers on the inner thighs) ----
|
||||
lam, mu = 0.5, -0.53
|
||||
for _ in range(TAUBIN_PAIRS):
|
||||
co_new[widx] += lam * (neigh_mean(co_new, widx) - co_new[widx])
|
||||
co_new[widx] += mu * (neigh_mean(co_new, widx) - co_new[widx])
|
||||
log(f"Taubin x{TAUBIN_PAIRS} on window")
|
||||
|
||||
me.vertices.foreach_set("co", co_new.reshape(-1))
|
||||
me.update()
|
||||
if me.has_custom_normals:
|
||||
vn = np.empty(n_v * 3, dtype=np.float32)
|
||||
me.vertices.foreach_get("normal", vn)
|
||||
me.normals_split_custom_set_from_vertices(vn.reshape(-1, 3))
|
||||
bpy.context.preferences.filepaths.save_version = 0 # no .blend1 autosave
|
||||
bpy.ops.wm.save_as_mainfile(filepath=OUT)
|
||||
log(f"WROTE {OUT}")
|
||||
print("CROTCH2_DONE")
|
||||
@@ -0,0 +1,55 @@
|
||||
# Local melt of the crotch underside strip (residue from the donor snap's cage edge).
|
||||
# blender --background --python 06b_strip_melt.py -- <in.blend> <out.blend>
|
||||
import bpy, sys, time
|
||||
import numpy as np
|
||||
|
||||
argv = sys.argv[sys.argv.index("--") + 1:]
|
||||
t0 = time.time()
|
||||
bpy.ops.wm.open_mainfile(filepath=argv[0])
|
||||
ob = max([o for o in bpy.data.objects if o.type == 'MESH'],
|
||||
key=lambda o: len(o.data.vertices))
|
||||
me = ob.data
|
||||
n_v = len(me.vertices)
|
||||
co = np.empty(n_v * 3)
|
||||
me.vertices.foreach_get("co", co)
|
||||
co = co.reshape(-1, 3)
|
||||
|
||||
mid = (np.abs(co[:, 0]) < 0.01) & (co[:, 2] > 0.38) & (co[:, 2] < 0.50)
|
||||
sad = co[mid][np.argmin(co[mid, 2])]
|
||||
# ABSOLUTE bounds: the donor snap moved the saddle landmark, so a saddle-relative window
|
||||
# centred 3.5 cm below the visible residue
|
||||
strip = (np.abs(co[:, 0]) < 0.048) & (co[:, 2] > 0.368) & (co[:, 2] < 0.434)
|
||||
sidx = np.nonzero(strip)[0]
|
||||
print(f"[strip] {len(sidx)} verts around saddle {sad}")
|
||||
|
||||
ev = np.empty(len(me.edges) * 2, dtype=np.int32)
|
||||
me.edges.foreach_get("vertices", ev)
|
||||
ev = ev.reshape(-1, 2)
|
||||
o_ = np.concatenate([ev[:, 0], ev[:, 1]])
|
||||
n_ = np.concatenate([ev[:, 1], ev[:, 0]])
|
||||
s_ = np.argsort(o_, kind="stable")
|
||||
o_s = o_[s_]
|
||||
n_s = n_[s_]
|
||||
ptr = np.searchsorted(o_s, np.arange(n_v + 1))
|
||||
cnt = np.maximum(np.diff(ptr), 1)
|
||||
|
||||
Q = co.copy()
|
||||
for _ in range(120):
|
||||
su = np.add.reduceat(Q[n_s], ptr[:-1], axis=0)
|
||||
emp = np.diff(ptr) == 0
|
||||
su[emp] = Q[emp]
|
||||
mean = su / cnt[:, None]
|
||||
for f in (0.55, -0.58):
|
||||
pass
|
||||
Q[sidx] = 0.45 * Q[sidx] + 0.55 * mean[sidx]
|
||||
print(f"[strip] melted, max move {np.linalg.norm(Q-co,axis=1).max():.4f} "
|
||||
f"({time.time()-t0:.1f}s)")
|
||||
me.vertices.foreach_set("co", Q.reshape(-1))
|
||||
me.update()
|
||||
if me.has_custom_normals:
|
||||
vn = np.empty(n_v * 3, dtype=np.float32)
|
||||
me.vertices.foreach_get("normal", vn)
|
||||
me.normals_split_custom_set_from_vertices(vn.reshape(-1, 3))
|
||||
bpy.context.preferences.filepaths.save_version = 0 # no .blend1 autosave
|
||||
bpy.ops.wm.save_as_mainfile(filepath=argv[1])
|
||||
print("STRIP_DONE")
|
||||
@@ -0,0 +1,119 @@
|
||||
# Stage 6c: bi-harmonic membrane heal of the briefs-gusset residue between the legs.
|
||||
#
|
||||
# History: v1 donor snap (06_crotch.py) fixed the pubic front but its 2 cm SNAP_MAX left the
|
||||
# gusset panel torn (the male crotch sits ~3 cm below the female bridge). Melting the panel
|
||||
# (06b) smoothed the interior but not the torn silhouette; widening the snap window (v2)
|
||||
# shredded the inner-thigh walls. This does what worked on the chest bow: excise the residue
|
||||
# box and solve a rim-anchored bi-harmonic membrane to convergence — no donor reach limits,
|
||||
# no orientation risk, C1-continuous with the surrounding skin by construction.
|
||||
#
|
||||
# blender --background --python 06c_gusset_membrane.py -- <06_crotched.blend> <out.blend>
|
||||
import bpy, sys, time
|
||||
from collections import deque
|
||||
import numpy as np
|
||||
|
||||
argv = sys.argv[sys.argv.index("--") + 1:]
|
||||
BLEND, OUT = argv[0], argv[1]
|
||||
t0 = time.time()
|
||||
|
||||
# residue box: the gusset bridge underside + torn seam + gathered inner-thigh cloth edges.
|
||||
# (bridge underside starts at z~0.41; tear at ~0.44; gathers reach ~0.46 and |x|~0.05)
|
||||
BOX_X = 0.055
|
||||
BOX_Z0, BOX_Z1 = 0.385, 0.462
|
||||
|
||||
|
||||
def log(m):
|
||||
print(f"[gusset {time.time()-t0:6.1f}s] {m}", flush=True)
|
||||
|
||||
|
||||
bpy.ops.wm.open_mainfile(filepath=BLEND)
|
||||
ob = max([o for o in bpy.data.objects if o.type == 'MESH'],
|
||||
key=lambda o: len(o.data.vertices))
|
||||
me = ob.data
|
||||
n_v = len(me.vertices)
|
||||
co = np.empty(n_v * 3)
|
||||
me.vertices.foreach_get("co", co)
|
||||
co = co.reshape(-1, 3)
|
||||
|
||||
n_e = len(me.edges)
|
||||
ev0 = np.empty(n_e * 2, dtype=np.int32)
|
||||
me.edges.foreach_get("vertices", ev0)
|
||||
ev0 = ev0.reshape(-1, 2)
|
||||
|
||||
|
||||
def grow_edges(mask, rings, ev):
|
||||
m = mask.copy()
|
||||
for _ in range(rings):
|
||||
hit = m[ev[:, 0]] | m[ev[:, 1]]
|
||||
m2 = m.copy()
|
||||
m2[ev[:, 0]] |= hit
|
||||
m2[ev[:, 1]] |= hit
|
||||
m = m2
|
||||
return m
|
||||
|
||||
|
||||
box = (np.abs(co[:, 0]) < BOX_X) & (co[:, 2] > BOX_Z0) & (co[:, 2] < BOX_Z1)
|
||||
free_m = grow_edges(box, 2, ev0)
|
||||
collar = grow_edges(free_m, 2, ev0) & ~free_m
|
||||
S = np.nonzero(free_m | collar)[0]
|
||||
in_S = np.zeros(n_v, dtype=bool)
|
||||
in_S[S] = True
|
||||
glb = np.full(n_v, -1, dtype=np.int64)
|
||||
glb[S] = np.arange(len(S))
|
||||
se = ev0[in_S[ev0].all(axis=1)]
|
||||
a_ = glb[se[:, 0]]
|
||||
b_ = glb[se[:, 1]]
|
||||
deg = np.zeros(len(S))
|
||||
np.add.at(deg, a_, 1.0)
|
||||
np.add.at(deg, b_, 1.0)
|
||||
free = free_m[S]
|
||||
log(f"box {box.sum()} verts -> free {free.sum()}, collar {(~free).sum()}")
|
||||
|
||||
|
||||
def Ls(X):
|
||||
out = deg[:, None] * X
|
||||
np.add.at(out, a_, -X[b_])
|
||||
np.add.at(out, b_, -X[a_])
|
||||
return out
|
||||
|
||||
|
||||
def A_op(U):
|
||||
X = np.zeros((len(S), 3))
|
||||
X[free] = U
|
||||
return Ls(Ls(X))[free]
|
||||
|
||||
|
||||
Xc = np.zeros((len(S), 3))
|
||||
Xc[~free] = co[S[~free]]
|
||||
rhs = -Ls(Ls(Xc))[free]
|
||||
U = co[S[free]].copy()
|
||||
r = rhs - A_op(U)
|
||||
p = r.copy()
|
||||
rs = (r * r).sum()
|
||||
rs0 = rs
|
||||
for it in range(120000):
|
||||
Ap = A_op(p)
|
||||
al = rs / max((p * Ap).sum(), 1e-30)
|
||||
U += al * p
|
||||
r -= al * Ap
|
||||
rs2 = (r * r).sum()
|
||||
if rs2 < 1e-18 or rs2 < rs0 * 1e-14:
|
||||
break
|
||||
p = r + (rs2 / rs) * p
|
||||
rs = rs2
|
||||
co_new = co.copy()
|
||||
co_new[S[free]] = U
|
||||
delta = np.linalg.norm(co_new - co, axis=1)
|
||||
log(f"membrane: {free.sum()} verts healed (CG {it} iters, "
|
||||
f"rel residual {rs2/max(rs0,1e-30):.2e}), max move {delta.max():.4f}")
|
||||
|
||||
me.vertices.foreach_set("co", co_new.reshape(-1))
|
||||
me.update()
|
||||
if me.has_custom_normals:
|
||||
vn = np.empty(n_v * 3, dtype=np.float32)
|
||||
me.vertices.foreach_get("normal", vn)
|
||||
me.normals_split_custom_set_from_vertices(vn.reshape(-1, 3))
|
||||
bpy.context.preferences.filepaths.save_version = 0 # no .blend1 autosave
|
||||
bpy.ops.wm.save_as_mainfile(filepath=OUT)
|
||||
log(f"WROTE {OUT}")
|
||||
print("GUSSET_DONE")
|
||||
@@ -0,0 +1,89 @@
|
||||
# Stage 6d: self-locating spot melt of remaining flaps in the thigh gap.
|
||||
# Finds high-roughness verts inside the gap box (an open-slit flap the membrane pulled),
|
||||
# grows a 2-ring collar, melts. Prints the cluster it found so the fix is auditable.
|
||||
# blender --background --python 06d_spot_melt.py -- <in.blend> <out.blend>
|
||||
import bpy, sys, time
|
||||
import numpy as np
|
||||
|
||||
argv = sys.argv[sys.argv.index("--") + 1:]
|
||||
BLEND, OUT = argv[0], argv[1]
|
||||
t0 = time.time()
|
||||
|
||||
BOX = lambda co: (np.abs(co[:, 0]) < 0.075) & (co[:, 2] > 0.345) & (co[:, 2] < 0.475)
|
||||
ROUGH_THR = 0.0012
|
||||
MELT_ITERS = 80
|
||||
|
||||
|
||||
def log(m):
|
||||
print(f"[spot {time.time()-t0:6.1f}s] {m}", flush=True)
|
||||
|
||||
|
||||
bpy.ops.wm.open_mainfile(filepath=BLEND)
|
||||
ob = max([o for o in bpy.data.objects if o.type == 'MESH'],
|
||||
key=lambda o: len(o.data.vertices))
|
||||
me = ob.data
|
||||
n_v = len(me.vertices)
|
||||
co = np.empty(n_v * 3)
|
||||
me.vertices.foreach_get("co", co)
|
||||
co = co.reshape(-1, 3)
|
||||
|
||||
n_e = len(me.edges)
|
||||
ev = np.empty(n_e * 2, dtype=np.int32)
|
||||
me.edges.foreach_get("vertices", ev)
|
||||
ev = ev.reshape(-1, 2)
|
||||
order = np.concatenate([ev[:, 0], ev[:, 1]])
|
||||
nbr = np.concatenate([ev[:, 1], ev[:, 0]])
|
||||
srt = np.argsort(order, kind="stable")
|
||||
o_s = order[srt]
|
||||
n_s = nbr[srt]
|
||||
ptr = np.searchsorted(o_s, np.arange(n_v + 1))
|
||||
cnt = np.maximum(ptr[1:] - ptr[:-1], 1)
|
||||
|
||||
|
||||
def smooth_field(Q, iters):
|
||||
X = Q.copy()
|
||||
for _ in range(iters):
|
||||
acc = np.zeros_like(X)
|
||||
np.add.at(acc, o_s, X[n_s])
|
||||
X = acc / cnt[:, None]
|
||||
return X
|
||||
|
||||
|
||||
sm = smooth_field(co, 8)
|
||||
rough = np.linalg.norm(co - sm, axis=1)
|
||||
box = BOX(co)
|
||||
hot = box & (rough > ROUGH_THR)
|
||||
log(f"box {box.sum()} verts, hot {hot.sum()} (rough>{ROUGH_THR})")
|
||||
if hot.sum():
|
||||
hc = co[hot]
|
||||
log(f"hot cluster: x [{hc[:,0].min():+.4f}..{hc[:,0].max():+.4f}] "
|
||||
f"y [{hc[:,1].min():+.4f}..{hc[:,1].max():+.4f}] "
|
||||
f"z [{hc[:,2].min():+.4f}..{hc[:,2].max():+.4f}]")
|
||||
m = hot.copy()
|
||||
for _ in range(2):
|
||||
h = m[ev[:, 0]] | m[ev[:, 1]]
|
||||
m2 = m.copy()
|
||||
m2[ev[:, 0]] |= h
|
||||
m2[ev[:, 1]] |= h
|
||||
m = m2
|
||||
sidx = np.nonzero(m)[0]
|
||||
Q = co.copy()
|
||||
for _ in range(MELT_ITERS):
|
||||
acc = np.zeros_like(Q)
|
||||
np.add.at(acc, o_s, Q[n_s])
|
||||
mean = acc / cnt[:, None]
|
||||
Q[sidx] = 0.5 * Q[sidx] + 0.5 * mean[sidx]
|
||||
d = np.linalg.norm(Q - co, axis=1)
|
||||
log(f"melted {len(sidx)} verts, max move {d.max():.4f}")
|
||||
me.vertices.foreach_set("co", Q.reshape(-1))
|
||||
me.update()
|
||||
if me.has_custom_normals:
|
||||
vn = np.empty(n_v * 3, dtype=np.float32)
|
||||
me.vertices.foreach_get("normal", vn)
|
||||
me.normals_split_custom_set_from_vertices(vn.reshape(-1, 3))
|
||||
else:
|
||||
log("nothing hot — no melt applied")
|
||||
bpy.context.preferences.filepaths.save_version = 0 # no .blend1 autosave
|
||||
bpy.ops.wm.save_as_mainfile(filepath=OUT)
|
||||
log(f"WROTE {OUT}")
|
||||
print("SPOT_DONE")
|
||||
@@ -0,0 +1,130 @@
|
||||
# Stage 6e: melt open-slit rims in the crotch box.
|
||||
# The remaining flap and the dotted briefs-edge lines both live on boundary edges (the mesh
|
||||
# is not watertight). Roughness misses them — a folded flap is locally smooth. Select verts
|
||||
# on boundary edges inside the box, grow 3 rings, melt hard; also stitch: each boundary vert
|
||||
# pairs with its nearest non-neighbour boundary vert within 2.5 mm and both move to the
|
||||
# midpoint, closing the slit gap positionally (topology untouched, masks.npz stays valid).
|
||||
# blender --background --python 06e_slit_melt.py -- <in.blend> <out.blend>
|
||||
import bpy, sys, time
|
||||
import numpy as np
|
||||
from mathutils import Vector
|
||||
from mathutils.kdtree import KDTree
|
||||
|
||||
argv = sys.argv[sys.argv.index("--") + 1:]
|
||||
BLEND, OUT = argv[0], argv[1]
|
||||
t0 = time.time()
|
||||
|
||||
BOX = lambda co: (np.abs(co[:, 0]) < 0.075) & (co[:, 2] > 0.340) & (co[:, 2] < 0.480)
|
||||
MELT_ITERS = 100
|
||||
STITCH_R = 0.0025
|
||||
|
||||
|
||||
def log(m):
|
||||
print(f"[slit {time.time()-t0:6.1f}s] {m}", flush=True)
|
||||
|
||||
|
||||
bpy.ops.wm.open_mainfile(filepath=BLEND)
|
||||
ob = max([o for o in bpy.data.objects if o.type == 'MESH'],
|
||||
key=lambda o: len(o.data.vertices))
|
||||
me = ob.data
|
||||
n_v = len(me.vertices)
|
||||
co = np.empty(n_v * 3)
|
||||
me.vertices.foreach_get("co", co)
|
||||
co = co.reshape(-1, 3)
|
||||
|
||||
n_e = len(me.edges)
|
||||
ev = np.empty(n_e * 2, dtype=np.int32)
|
||||
me.edges.foreach_get("vertices", ev)
|
||||
ev = ev.reshape(-1, 2)
|
||||
|
||||
# boundary edges: adjacent to exactly one face
|
||||
l_tot = np.empty(len(me.polygons), dtype=np.int32)
|
||||
me.polygons.foreach_get("loop_total", l_tot)
|
||||
l_start = np.empty(len(me.polygons), dtype=np.int32)
|
||||
me.polygons.foreach_get("loop_start", l_start)
|
||||
l_v = np.empty(len(me.loops), dtype=np.int32)
|
||||
me.loops.foreach_get("vertex_index", l_v)
|
||||
ecount = {}
|
||||
for fs, ft in zip(l_start, l_tot):
|
||||
idxs = l_v[fs:fs + ft]
|
||||
for k in range(ft):
|
||||
a, b = idxs[k], idxs[(k + 1) % ft]
|
||||
key = (a, b) if a < b else (b, a)
|
||||
ecount[key] = ecount.get(key, 0) + 1
|
||||
bnd_v = np.zeros(n_v, dtype=bool)
|
||||
for (a, b), c in ecount.items():
|
||||
if c == 1:
|
||||
bnd_v[a] = bnd_v[b] = True
|
||||
log(f"boundary verts total: {bnd_v.sum()}")
|
||||
|
||||
box = BOX(co)
|
||||
hot = box & bnd_v
|
||||
log(f"slit verts in box: {hot.sum()}")
|
||||
|
||||
# stitch pass: pair each hot vert with nearest hot vert that is not a mesh neighbour
|
||||
order = np.concatenate([ev[:, 0], ev[:, 1]])
|
||||
nbr = np.concatenate([ev[:, 1], ev[:, 0]])
|
||||
srt = np.argsort(order, kind="stable")
|
||||
o_s = order[srt]
|
||||
n_s = nbr[srt]
|
||||
ptr = np.searchsorted(o_s, np.arange(n_v + 1))
|
||||
cnt = np.maximum(ptr[1:] - ptr[:-1], 1)
|
||||
|
||||
Q = co.copy()
|
||||
hidx = np.nonzero(hot)[0]
|
||||
if len(hidx):
|
||||
tree = KDTree(len(hidx))
|
||||
for j, i in enumerate(hidx):
|
||||
tree.insert(Vector(Q[i]), j)
|
||||
tree.balance()
|
||||
neigh_sets = {int(i): set(int(x) for x in n_s[ptr[i]:ptr[i + 1]]) for i in hidx}
|
||||
stitched = 0
|
||||
done = set()
|
||||
for j, i in enumerate(hidx):
|
||||
if j in done:
|
||||
continue
|
||||
best = None
|
||||
for (_, k, dist) in tree.find_range(Vector(Q[i]), STITCH_R):
|
||||
if k == j or k in done:
|
||||
continue
|
||||
ik = int(hidx[k])
|
||||
if ik in neigh_sets[int(i)]:
|
||||
continue
|
||||
if best is None or dist < best[1]:
|
||||
best = (k, dist)
|
||||
if best is not None:
|
||||
ik = int(hidx[best[0]])
|
||||
mid = 0.5 * (Q[i] + Q[ik])
|
||||
Q[i] = mid
|
||||
Q[ik] = mid
|
||||
done.add(j)
|
||||
done.add(best[0])
|
||||
stitched += 1
|
||||
log(f"stitched {stitched} slit pairs")
|
||||
|
||||
m = hot.copy()
|
||||
for _ in range(3):
|
||||
h = m[ev[:, 0]] | m[ev[:, 1]]
|
||||
m2 = m.copy()
|
||||
m2[ev[:, 0]] |= h
|
||||
m2[ev[:, 1]] |= h
|
||||
m = m2
|
||||
sidx = np.nonzero(m)[0]
|
||||
for _ in range(MELT_ITERS):
|
||||
acc = np.zeros_like(Q)
|
||||
np.add.at(acc, o_s, Q[n_s])
|
||||
mean = acc / cnt[:, None]
|
||||
Q[sidx] = 0.5 * Q[sidx] + 0.5 * mean[sidx]
|
||||
d = np.linalg.norm(Q - co, axis=1)
|
||||
log(f"melted {len(sidx)} rim verts, max move {d.max():.4f}")
|
||||
|
||||
me.vertices.foreach_set("co", Q.reshape(-1))
|
||||
me.update()
|
||||
if me.has_custom_normals:
|
||||
vn = np.empty(n_v * 3, dtype=np.float32)
|
||||
me.vertices.foreach_get("normal", vn)
|
||||
me.normals_split_custom_set_from_vertices(vn.reshape(-1, 3))
|
||||
bpy.context.preferences.filepaths.save_version = 0 # no .blend1 autosave
|
||||
bpy.ops.wm.save_as_mainfile(filepath=OUT)
|
||||
log(f"WROTE {OUT}")
|
||||
print("SLIT_DONE")
|
||||
@@ -0,0 +1,98 @@
|
||||
# Stage 6f: fold melt in the crotch box, normal-deviation detector.
|
||||
# The mesh has only 3 boundary verts (00_welded closed the slits) — the remaining flap and
|
||||
# dotted seam lines are welded CREASES: positions locally smooth, normals kinked. Detect
|
||||
# verts whose normal deviates > ANG_THR from the 5-iter smoothed normal field, grow, melt.
|
||||
# blender --background --python 06f_fold_melt.py -- <in.blend> <out.blend>
|
||||
import bpy, sys, time, math
|
||||
import numpy as np
|
||||
|
||||
argv = sys.argv[sys.argv.index("--") + 1:]
|
||||
BLEND, OUT = argv[0], argv[1]
|
||||
t0 = time.time()
|
||||
|
||||
# optional box override: <bx> <z0> <z1> [ang_deg]
|
||||
if len(argv) >= 5:
|
||||
BX, BZ0, BZ1 = float(argv[2]), float(argv[3]), float(argv[4])
|
||||
ANG_THR = math.radians(float(argv[5])) if len(argv) > 5 else math.radians(15.0)
|
||||
else:
|
||||
BX, BZ0, BZ1 = 0.075, 0.340, 0.480
|
||||
ANG_THR = math.radians(15.0)
|
||||
# navel stays: it is a real feature made of exactly the kind of kink this melts
|
||||
NAVEL = lambda co: (np.abs(co[:, 0]) < 0.022) & (co[:, 2] > 0.495) & (co[:, 2] < 0.555) & (co[:, 1] < 0)
|
||||
BOX = lambda co: (np.abs(co[:, 0]) < BX) & (co[:, 2] > BZ0) & (co[:, 2] < BZ1) & ~NAVEL(co)
|
||||
MELT_ITERS = 150
|
||||
|
||||
|
||||
def log(m):
|
||||
print(f"[fold {time.time()-t0:6.1f}s] {m}", flush=True)
|
||||
|
||||
|
||||
bpy.ops.wm.open_mainfile(filepath=BLEND)
|
||||
ob = max([o for o in bpy.data.objects if o.type == 'MESH'],
|
||||
key=lambda o: len(o.data.vertices))
|
||||
me = ob.data
|
||||
n_v = len(me.vertices)
|
||||
co = np.empty(n_v * 3)
|
||||
me.vertices.foreach_get("co", co)
|
||||
co = co.reshape(-1, 3)
|
||||
nrm = np.empty(n_v * 3)
|
||||
me.vertices.foreach_get("normal", nrm)
|
||||
nrm = nrm.reshape(-1, 3)
|
||||
|
||||
n_e = len(me.edges)
|
||||
ev = np.empty(n_e * 2, dtype=np.int32)
|
||||
me.edges.foreach_get("vertices", ev)
|
||||
ev = ev.reshape(-1, 2)
|
||||
order = np.concatenate([ev[:, 0], ev[:, 1]])
|
||||
nbr = np.concatenate([ev[:, 1], ev[:, 0]])
|
||||
srt = np.argsort(order, kind="stable")
|
||||
o_s = order[srt]
|
||||
n_s = nbr[srt]
|
||||
ptr = np.searchsorted(o_s, np.arange(n_v + 1))
|
||||
cnt = np.maximum(ptr[1:] - ptr[:-1], 1)
|
||||
|
||||
N = nrm.copy()
|
||||
for _ in range(5):
|
||||
acc = np.zeros_like(N)
|
||||
np.add.at(acc, o_s, N[n_s])
|
||||
N = acc / cnt[:, None]
|
||||
N /= np.maximum(np.linalg.norm(N, axis=1, keepdims=True), 1e-12)
|
||||
dot = np.clip((nrm * N).sum(axis=1), -1.0, 1.0)
|
||||
ang = np.arccos(dot)
|
||||
|
||||
box = BOX(co)
|
||||
hot = box & (ang > ANG_THR)
|
||||
log(f"box {box.sum()}, folds {hot.sum()} (>{math.degrees(ANG_THR):.0f} deg)")
|
||||
if hot.sum():
|
||||
hc = co[hot]
|
||||
log(f"fold cluster: x [{hc[:,0].min():+.4f}..{hc[:,0].max():+.4f}] "
|
||||
f"y [{hc[:,1].min():+.4f}..{hc[:,1].max():+.4f}] "
|
||||
f"z [{hc[:,2].min():+.4f}..{hc[:,2].max():+.4f}]")
|
||||
m = hot.copy()
|
||||
for _ in range(3):
|
||||
h = m[ev[:, 0]] | m[ev[:, 1]]
|
||||
m2 = m.copy()
|
||||
m2[ev[:, 0]] |= h
|
||||
m2[ev[:, 1]] |= h
|
||||
m = m2
|
||||
sidx = np.nonzero(m)[0]
|
||||
Q = co.copy()
|
||||
for _ in range(MELT_ITERS):
|
||||
acc = np.zeros_like(Q)
|
||||
np.add.at(acc, o_s, Q[n_s])
|
||||
mean = acc / cnt[:, None]
|
||||
Q[sidx] = 0.5 * Q[sidx] + 0.5 * mean[sidx]
|
||||
d = np.linalg.norm(Q - co, axis=1)
|
||||
log(f"melted {len(sidx)} fold verts, max move {d.max():.4f}")
|
||||
me.vertices.foreach_set("co", Q.reshape(-1))
|
||||
me.update()
|
||||
if me.has_custom_normals:
|
||||
vn = np.empty(n_v * 3, dtype=np.float32)
|
||||
me.vertices.foreach_get("normal", vn)
|
||||
me.normals_split_custom_set_from_vertices(vn.reshape(-1, 3))
|
||||
else:
|
||||
log("no folds found")
|
||||
bpy.context.preferences.filepaths.save_version = 0 # no .blend1 autosave
|
||||
bpy.ops.wm.save_as_mainfile(filepath=OUT)
|
||||
log(f"WROTE {OUT}")
|
||||
print("FOLD_DONE")
|
||||
@@ -0,0 +1,103 @@
|
||||
# Stage 6g: locate the surviving inner-thigh flap by camera ray-cast, melt a sphere there.
|
||||
# The flap dodged the roughness, boundary-rim, and normal-kink detectors — so aim through
|
||||
# the diagnostic camera pixel where it is visibly rendered (dbg front_tight, ~px 565,630 of
|
||||
# 1000^2) and heal whatever the ray hits. Prints the hit so the fix is auditable.
|
||||
# blender --background --python 06g_pixel_melt.py -- <in.blend> <out.blend>
|
||||
import bpy, sys, time, math
|
||||
import numpy as np
|
||||
from mathutils import Vector, Euler
|
||||
|
||||
argv = sys.argv[sys.argv.index("--") + 1:]
|
||||
BLEND, OUT = argv[0], argv[1]
|
||||
t0 = time.time()
|
||||
|
||||
# front_tight camera from dbg_gusset.py
|
||||
CAM_LOC = Vector((0.0, -0.55, 0.41))
|
||||
CAM_ROT = Euler((math.radians(90), 0, 0))
|
||||
LENS, SENSOR = 85.0, 36.0
|
||||
# pixels (x, y from top) in the 1000^2 render where the flap shows; a few samples across it
|
||||
PIXELS = [(560, 615), (568, 628), (575, 640), (582, 652), (562, 640), (572, 618)]
|
||||
R_MELT = 0.010
|
||||
MELT_ITERS = 200
|
||||
|
||||
|
||||
def log(m):
|
||||
print(f"[pix {time.time()-t0:6.1f}s] {m}", flush=True)
|
||||
|
||||
|
||||
bpy.ops.wm.open_mainfile(filepath=BLEND)
|
||||
ob = max([o for o in bpy.data.objects if o.type == 'MESH'],
|
||||
key=lambda o: len(o.data.vertices))
|
||||
me = ob.data
|
||||
n_v = len(me.vertices)
|
||||
co = np.empty(n_v * 3)
|
||||
me.vertices.foreach_get("co", co)
|
||||
co = co.reshape(-1, 3)
|
||||
|
||||
deps = bpy.context.evaluated_depsgraph_get()
|
||||
rot = CAM_ROT.to_matrix()
|
||||
fwd = rot @ Vector((0, 0, -1))
|
||||
right = rot @ Vector((1, 0, 0))
|
||||
up = rot @ Vector((0, 1, 0))
|
||||
half = SENSOR / (2 * LENS)
|
||||
|
||||
hits = []
|
||||
for px, py in PIXELS:
|
||||
ndc_x = (px / 1000.0 - 0.5) * 2
|
||||
ndc_y = (0.5 - py / 1000.0) * 2
|
||||
d = (fwd + right * (ndc_x * half) + up * (ndc_y * half)).normalized()
|
||||
ok, loc, nrm_h, fi, obj, _ = bpy.context.scene.ray_cast(deps, CAM_LOC, d)
|
||||
if ok:
|
||||
hits.append(np.array(loc))
|
||||
log(f"px({px},{py}) -> hit {np.round(np.array(loc), 4)}")
|
||||
else:
|
||||
log(f"px({px},{py}) -> MISS")
|
||||
|
||||
if not hits:
|
||||
log("no hits; aborting without changes")
|
||||
bpy.context.preferences.filepaths.save_version = 0 # no .blend1 autosave
|
||||
bpy.ops.wm.save_as_mainfile(filepath=OUT)
|
||||
sys.exit(0)
|
||||
|
||||
hits = np.array(hits)
|
||||
ctr = hits.mean(axis=0)
|
||||
log(f"flap centre {np.round(ctr,4)}, spread {np.round(hits.std(axis=0),4)}")
|
||||
|
||||
sel = np.linalg.norm(co - ctr, axis=1) < R_MELT
|
||||
sidx = np.nonzero(sel)[0]
|
||||
log(f"melt sphere r={R_MELT}: {len(sidx)} verts")
|
||||
|
||||
n_e = len(me.edges)
|
||||
ev = np.empty(n_e * 2, dtype=np.int32)
|
||||
me.edges.foreach_get("vertices", ev)
|
||||
ev = ev.reshape(-1, 2)
|
||||
order = np.concatenate([ev[:, 0], ev[:, 1]])
|
||||
nbr = np.concatenate([ev[:, 1], ev[:, 0]])
|
||||
srt = np.argsort(order, kind="stable")
|
||||
o_s = order[srt]
|
||||
n_s = nbr[srt]
|
||||
ptr = np.searchsorted(o_s, np.arange(n_v + 1))
|
||||
cnt = np.maximum(ptr[1:] - ptr[:-1], 1)
|
||||
|
||||
# soft weight: full melt at centre, fades at rim so no new crease forms
|
||||
w = np.clip(1.0 - np.linalg.norm(co[sidx] - ctr, axis=1) / R_MELT, 0.0, 1.0)
|
||||
w = w * w * (3 - 2 * w)
|
||||
Q = co.copy()
|
||||
for _ in range(MELT_ITERS):
|
||||
acc = np.zeros_like(Q)
|
||||
np.add.at(acc, o_s, Q[n_s])
|
||||
mean = acc / cnt[:, None]
|
||||
Q[sidx] = Q[sidx] + (mean[sidx] - Q[sidx]) * (0.6 * w[:, None])
|
||||
d = np.linalg.norm(Q - co, axis=1)
|
||||
log(f"melted, max move {d.max():.4f}")
|
||||
|
||||
me.vertices.foreach_set("co", Q.reshape(-1))
|
||||
me.update()
|
||||
if me.has_custom_normals:
|
||||
vn = np.empty(n_v * 3, dtype=np.float32)
|
||||
me.vertices.foreach_get("normal", vn)
|
||||
me.normals_split_custom_set_from_vertices(vn.reshape(-1, 3))
|
||||
bpy.context.preferences.filepaths.save_version = 0 # no .blend1 autosave
|
||||
bpy.ops.wm.save_as_mainfile(filepath=OUT)
|
||||
log(f"WROTE {OUT}")
|
||||
print("PIX_DONE")
|
||||
@@ -0,0 +1,109 @@
|
||||
# Stage 6h: bi-harmonic membrane over the located flap sphere. Melting (06g) barely dented
|
||||
# the fold — layered flaps are locally smooth and resist neighbour-averaging. Replace instead:
|
||||
# excise the sphere, solve the rim-anchored membrane (same method that healed the gusset).
|
||||
# blender --background --python 06h_flap_membrane.py -- <in.blend> <out.blend> <cx> <cy> <cz> <r>
|
||||
import bpy, sys, time
|
||||
import numpy as np
|
||||
|
||||
argv = sys.argv[sys.argv.index("--") + 1:]
|
||||
BLEND, OUT = argv[0], argv[1]
|
||||
CTR = np.array([float(argv[2]), float(argv[3]), float(argv[4])])
|
||||
R = float(argv[5])
|
||||
t0 = time.time()
|
||||
|
||||
|
||||
def log(m):
|
||||
print(f"[flap {time.time()-t0:6.1f}s] {m}", flush=True)
|
||||
|
||||
|
||||
bpy.ops.wm.open_mainfile(filepath=BLEND)
|
||||
ob = max([o for o in bpy.data.objects if o.type == 'MESH'],
|
||||
key=lambda o: len(o.data.vertices))
|
||||
me = ob.data
|
||||
n_v = len(me.vertices)
|
||||
co = np.empty(n_v * 3)
|
||||
me.vertices.foreach_get("co", co)
|
||||
co = co.reshape(-1, 3)
|
||||
|
||||
n_e = len(me.edges)
|
||||
ev0 = np.empty(n_e * 2, dtype=np.int32)
|
||||
me.edges.foreach_get("vertices", ev0)
|
||||
ev0 = ev0.reshape(-1, 2)
|
||||
|
||||
|
||||
def grow_edges(mask, rings, ev):
|
||||
m = mask.copy()
|
||||
for _ in range(rings):
|
||||
hit = m[ev[:, 0]] | m[ev[:, 1]]
|
||||
m2 = m.copy()
|
||||
m2[ev[:, 0]] |= hit
|
||||
m2[ev[:, 1]] |= hit
|
||||
m = m2
|
||||
return m
|
||||
|
||||
|
||||
sph = np.linalg.norm(co - CTR, axis=1) < R
|
||||
free_m = grow_edges(sph, 1, ev0)
|
||||
collar = grow_edges(free_m, 2, ev0) & ~free_m
|
||||
S = np.nonzero(free_m | collar)[0]
|
||||
in_S = np.zeros(n_v, dtype=bool)
|
||||
in_S[S] = True
|
||||
glb = np.full(n_v, -1, dtype=np.int64)
|
||||
glb[S] = np.arange(len(S))
|
||||
se = ev0[in_S[ev0].all(axis=1)]
|
||||
a_ = glb[se[:, 0]]
|
||||
b_ = glb[se[:, 1]]
|
||||
deg = np.zeros(len(S))
|
||||
np.add.at(deg, a_, 1.0)
|
||||
np.add.at(deg, b_, 1.0)
|
||||
free = free_m[S]
|
||||
log(f"sphere {sph.sum()} -> free {free.sum()}, collar {(~free).sum()}")
|
||||
|
||||
|
||||
def Ls(X):
|
||||
out = deg[:, None] * X
|
||||
np.add.at(out, a_, -X[b_])
|
||||
np.add.at(out, b_, -X[a_])
|
||||
return out
|
||||
|
||||
|
||||
def A_op(U):
|
||||
X = np.zeros((len(S), 3))
|
||||
X[free] = U
|
||||
return Ls(Ls(X))[free]
|
||||
|
||||
|
||||
Xc = np.zeros((len(S), 3))
|
||||
Xc[~free] = co[S[~free]]
|
||||
rhs = -Ls(Ls(Xc))[free]
|
||||
U = co[S[free]].copy()
|
||||
r = rhs - A_op(U)
|
||||
p = r.copy()
|
||||
rs = (r * r).sum()
|
||||
rs0 = rs
|
||||
for it in range(120000):
|
||||
Ap = A_op(p)
|
||||
al = rs / max((p * Ap).sum(), 1e-30)
|
||||
U += al * p
|
||||
r -= al * Ap
|
||||
rs2 = (r * r).sum()
|
||||
if rs2 < 1e-18 or rs2 < rs0 * 1e-14:
|
||||
break
|
||||
p = r + (rs2 / rs) * p
|
||||
rs = rs2
|
||||
co_new = co.copy()
|
||||
co_new[S[free]] = U
|
||||
d = np.linalg.norm(co_new - co, axis=1)
|
||||
log(f"membrane: {free.sum()} verts (CG {it} iters, rel {rs2/max(rs0,1e-30):.2e}), "
|
||||
f"max move {d.max():.4f}")
|
||||
|
||||
me.vertices.foreach_set("co", co_new.reshape(-1))
|
||||
me.update()
|
||||
if me.has_custom_normals:
|
||||
vn = np.empty(n_v * 3, dtype=np.float32)
|
||||
me.vertices.foreach_get("normal", vn)
|
||||
me.normals_split_custom_set_from_vertices(vn.reshape(-1, 3))
|
||||
bpy.context.preferences.filepaths.save_version = 0 # no .blend1 autosave
|
||||
bpy.ops.wm.save_as_mainfile(filepath=OUT)
|
||||
log(f"WROTE {OUT}")
|
||||
print("FLAP_DONE")
|
||||
@@ -0,0 +1,32 @@
|
||||
# Stage 8: export the finished hires nude sculpt as a packed GLB.
|
||||
# blender --background --python 08_export.py -- <07_polished.blend> <out.glb>
|
||||
import bpy, sys, time
|
||||
import numpy as np
|
||||
|
||||
argv = sys.argv[sys.argv.index("--") + 1:]
|
||||
BLEND, OUT = argv[0], argv[1]
|
||||
t0 = time.time()
|
||||
|
||||
bpy.ops.wm.open_mainfile(filepath=BLEND)
|
||||
ob = max([o for o in bpy.data.objects if o.type == 'MESH'],
|
||||
key=lambda o: len(o.data.vertices))
|
||||
me = ob.data
|
||||
co = np.empty(len(me.vertices) * 3)
|
||||
me.vertices.foreach_get("co", co)
|
||||
co = co.reshape(-1, 3)
|
||||
print(f"[export] {ob.name}: {len(me.vertices)}v {len(me.polygons)}f "
|
||||
f"height {co[:,2].max()-co[:,2].min():.4f} units, "
|
||||
f"images {[i.name for i in bpy.data.images if i.has_data]}")
|
||||
|
||||
bpy.ops.export_scene.gltf(
|
||||
filepath=OUT,
|
||||
export_format='GLB',
|
||||
export_image_format='AUTO',
|
||||
export_yup=True,
|
||||
export_apply=False,
|
||||
export_animations=False,
|
||||
export_skins=True,
|
||||
export_morph=False,
|
||||
)
|
||||
print(f"[export] WROTE {OUT} ({time.time()-t0:.1f}s)")
|
||||
print("EXPORT_DONE")
|
||||
@@ -0,0 +1,56 @@
|
||||
# Final beauty renders (textured, original materials): full front/40deg/back, chest, hip.
|
||||
# blender --background --python 09_beauty.py -- <blend> <outdir>
|
||||
import bpy, sys, math, os
|
||||
from mathutils import Vector, Euler
|
||||
|
||||
argv = sys.argv[sys.argv.index("--") + 1:]
|
||||
BLEND, OUT = argv[0], argv[1]
|
||||
os.makedirs(OUT, exist_ok=True)
|
||||
|
||||
bpy.ops.wm.open_mainfile(filepath=BLEND)
|
||||
for o in list(bpy.data.objects):
|
||||
if o.type in ('LIGHT', 'CAMERA'):
|
||||
bpy.data.objects.remove(o, do_unlink=True)
|
||||
|
||||
sc = bpy.context.scene
|
||||
sc.render.engine = 'BLENDER_EEVEE'
|
||||
sc.render.resolution_x = sc.render.resolution_y = 1200
|
||||
wd = bpy.data.worlds.new("w")
|
||||
wd.use_nodes = True
|
||||
wd.node_tree.nodes["Background"].inputs[0].default_value = (0.22, 0.22, 0.24, 1)
|
||||
sc.world = wd
|
||||
|
||||
|
||||
def sun(rot, e):
|
||||
ld = bpy.data.lights.new("s", 'SUN')
|
||||
ld.energy = e
|
||||
ld.use_shadow = False
|
||||
lo = bpy.data.objects.new("s", ld)
|
||||
lo.rotation_euler = rot
|
||||
bpy.context.collection.objects.link(lo)
|
||||
|
||||
|
||||
sun(Euler((math.radians(55), 0, math.radians(-35))), 2.2)
|
||||
sun(Euler((math.radians(120), 0, math.radians(150))), 1.0)
|
||||
sun(Euler((math.radians(85), 0, math.radians(35))), 0.8)
|
||||
|
||||
cam = bpy.data.cameras.new("c")
|
||||
cam.lens = 70
|
||||
cob = bpy.data.objects.new("c", cam)
|
||||
bpy.context.collection.objects.link(cob)
|
||||
sc.camera = cob
|
||||
|
||||
views = [
|
||||
("full_front", Vector((0.0, -2.05, 0.50)), Euler((math.radians(90), 0, 0))),
|
||||
("full_40", Vector((-1.35, -1.55, 0.50)), Euler((math.radians(90), 0, math.radians(-41)))),
|
||||
("full_back", Vector((0.0, 2.05, 0.50)), Euler((math.radians(90), 0, math.radians(180)))),
|
||||
("chest", Vector((0.0, -0.85, 0.70)), Euler((math.radians(90), 0, 0))),
|
||||
("hip", Vector((0.0, -0.85, 0.46)), Euler((math.radians(90), 0, 0))),
|
||||
]
|
||||
for name, loc, rot in views:
|
||||
cob.location = loc
|
||||
cob.rotation_euler = rot
|
||||
sc.render.filepath = os.path.join(OUT, f"{name}.png")
|
||||
bpy.ops.render.render(write_still=True)
|
||||
print(f"rendered {name}", flush=True)
|
||||
print("BEAUTY_DONE")
|
||||
@@ -0,0 +1,142 @@
|
||||
# Stage 11: heal the garment-line creases across the stomach/waist/hips.
|
||||
# The lines are GEOMETRY (they show in clay): the briefs waistband ledge and leg-hem ridges
|
||||
# (the briefs zone's 1x field kept hip anatomy AND the hem ridges), plus dotted pinch lines
|
||||
# where the source mesh's open slits were welded. Strip = hemband mask ∪ slit-seam verts
|
||||
# (mapped from the RAW pre-weld GLB's boundary edges) ∪ normal-kink verts in the torso band.
|
||||
# Taubin (volume-preserving) on the strip: ridges round off, hips/butt keep their shape.
|
||||
# blender --background --python 11_line_heal.py -- <in.blend> <masks.npz> <raw.glb> <out.blend>
|
||||
import bpy, sys, time, math
|
||||
import numpy as np
|
||||
from mathutils import Vector
|
||||
from mathutils.kdtree import KDTree
|
||||
|
||||
argv = sys.argv[sys.argv.index("--") + 1:]
|
||||
BLEND, MASKS, RAW, OUT = argv[0], argv[1], argv[2], argv[3]
|
||||
t0 = time.time()
|
||||
|
||||
ANG_THR = math.radians(12.0)
|
||||
TAUBIN_PAIRS = 30
|
||||
Z0, Z1 = 0.30, 0.87
|
||||
|
||||
|
||||
def log(m):
|
||||
print(f"[heal {time.time()-t0:6.1f}s] {m}", flush=True)
|
||||
|
||||
|
||||
bpy.ops.wm.open_mainfile(filepath=BLEND)
|
||||
ob = max([o for o in bpy.data.objects if o.type == 'MESH'],
|
||||
key=lambda o: len(o.data.vertices))
|
||||
me = ob.data
|
||||
n_v = len(me.vertices)
|
||||
co = np.empty(n_v * 3)
|
||||
me.vertices.foreach_get("co", co)
|
||||
co = co.reshape(-1, 3)
|
||||
M = np.load(MASKS)
|
||||
hemband = M["hemband"]
|
||||
|
||||
# ---- slit-seam verts from the raw pre-weld GLB ----
|
||||
before = set(bpy.data.objects)
|
||||
bpy.ops.import_scene.gltf(filepath=RAW)
|
||||
new = [o for o in bpy.data.objects if o not in before]
|
||||
raw = max([o for o in new if o.type == 'MESH'], key=lambda o: len(o.data.vertices))
|
||||
rme = raw.data
|
||||
rn = len(rme.vertices)
|
||||
rco = np.empty(rn * 3)
|
||||
rme.vertices.foreach_get("co", rco)
|
||||
rco = rco.reshape(-1, 3)
|
||||
l_tot = np.empty(len(rme.polygons), dtype=np.int32)
|
||||
rme.polygons.foreach_get("loop_total", l_tot)
|
||||
l_start = np.empty(len(rme.polygons), dtype=np.int32)
|
||||
rme.polygons.foreach_get("loop_start", l_start)
|
||||
l_v = np.empty(len(rme.loops), dtype=np.int32)
|
||||
rme.loops.foreach_get("vertex_index", l_v)
|
||||
ecount = {}
|
||||
for fs, ft in zip(l_start, l_tot):
|
||||
idxs = l_v[fs:fs + ft]
|
||||
for k in range(ft):
|
||||
a, b = idxs[k], idxs[(k + 1) % ft]
|
||||
key = (a, b) if a < b else (b, a)
|
||||
ecount[key] = ecount.get(key, 0) + 1
|
||||
rbnd = np.zeros(rn, dtype=bool)
|
||||
for (a, b), c in ecount.items():
|
||||
if c == 1:
|
||||
rbnd[a] = rbnd[b] = True
|
||||
log(f"raw boundary verts: {rbnd.sum()}")
|
||||
for o in new:
|
||||
bpy.data.objects.remove(o, do_unlink=True)
|
||||
|
||||
kd = KDTree(n_v)
|
||||
for i in range(n_v):
|
||||
kd.insert(Vector(co[i]), i)
|
||||
kd.balance()
|
||||
seam = np.zeros(n_v, dtype=bool)
|
||||
# NB: positions have been sculpted since the weld — match generously but only in the torso
|
||||
# band, and only trust matches within 6 mm (sculpted garment areas moved far more; their
|
||||
# seams are already handled by the fills/melts there)
|
||||
for p in rco[rbnd]:
|
||||
if not (Z0 < p[2] < Z1):
|
||||
continue
|
||||
hit = kd.find(Vector(p))
|
||||
if hit[0] is not None and hit[2] < 0.006:
|
||||
seam[hit[1]] = True
|
||||
log(f"seam verts mapped: {seam.sum()}")
|
||||
|
||||
# ---- normal-kink verts (ledges/ridges) ----
|
||||
nrm = np.empty(n_v * 3)
|
||||
me.vertices.foreach_get("normal", nrm)
|
||||
nrm = nrm.reshape(-1, 3)
|
||||
n_e = len(me.edges)
|
||||
ev = np.empty(n_e * 2, dtype=np.int32)
|
||||
me.edges.foreach_get("vertices", ev)
|
||||
ev = ev.reshape(-1, 2)
|
||||
order = np.concatenate([ev[:, 0], ev[:, 1]])
|
||||
nbr = np.concatenate([ev[:, 1], ev[:, 0]])
|
||||
srt = np.argsort(order, kind="stable")
|
||||
o_s = order[srt]
|
||||
n_s = nbr[srt]
|
||||
ptr = np.searchsorted(o_s, np.arange(n_v + 1))
|
||||
cnt = np.maximum(ptr[1:] - ptr[:-1], 1)
|
||||
N = nrm.copy()
|
||||
for _ in range(5):
|
||||
acc = np.zeros_like(N)
|
||||
np.add.at(acc, o_s, N[n_s])
|
||||
N = acc / cnt[:, None]
|
||||
N /= np.maximum(np.linalg.norm(N, axis=1, keepdims=True), 1e-12)
|
||||
ang = np.arccos(np.clip((nrm * N).sum(axis=1), -1, 1))
|
||||
navel = (np.abs(co[:, 0]) < 0.022) & (co[:, 2] > 0.495) & (co[:, 2] < 0.555) & (co[:, 1] < 0)
|
||||
band = (co[:, 2] > 0.42) & (co[:, 2] < Z1) & ~navel
|
||||
kink = band & (ang > ANG_THR)
|
||||
log(f"kink verts: {kink.sum()}")
|
||||
|
||||
strip = (hemband | seam | kink) & (co[:, 2] > Z0) & (co[:, 2] < Z1) & ~navel
|
||||
for _ in range(2):
|
||||
hit = strip[ev[:, 0]] | strip[ev[:, 1]]
|
||||
s2 = strip.copy()
|
||||
s2[ev[:, 0]] |= hit
|
||||
s2[ev[:, 1]] |= hit
|
||||
strip = s2
|
||||
strip &= ~navel
|
||||
sidx = np.nonzero(strip)[0]
|
||||
log(f"strip: {len(sidx)} verts")
|
||||
|
||||
Q = co.copy()
|
||||
lam, mu = 0.5, -0.53
|
||||
for _ in range(TAUBIN_PAIRS):
|
||||
for f in (lam, mu):
|
||||
acc = np.zeros_like(Q)
|
||||
np.add.at(acc, o_s, Q[n_s])
|
||||
mean = acc / cnt[:, None]
|
||||
Q[sidx] += f * (mean[sidx] - Q[sidx])
|
||||
d = np.linalg.norm(Q - co, axis=1)
|
||||
log(f"Taubin x{TAUBIN_PAIRS}: max move {d.max():.4f}")
|
||||
|
||||
me.vertices.foreach_set("co", Q.reshape(-1))
|
||||
me.update()
|
||||
if me.has_custom_normals:
|
||||
vn = np.empty(n_v * 3, dtype=np.float32)
|
||||
me.vertices.foreach_get("normal", vn)
|
||||
me.normals_split_custom_set_from_vertices(vn.reshape(-1, 3))
|
||||
bpy.context.preferences.filepaths.save_version = 0 # no .blend1 autosave
|
||||
bpy.ops.wm.save_as_mainfile(filepath=OUT)
|
||||
log(f"WROTE {OUT}")
|
||||
print("HEAL_DONE")
|
||||
@@ -0,0 +1,177 @@
|
||||
# Stage 12: membrane-heal the garment dig-in lines (waistband ledge, leg-hem creases,
|
||||
# belly/underbust dashes). These are DENTS the briefs/bra pressed into the body — the briefs
|
||||
# zone's 1x anatomy field kept them, and Taubin/melting preserves exactly this mid-frequency
|
||||
# shape. Fix = the proven pattern: narrow bands along the crease curves (mid-freq roughness
|
||||
# detector + mapped slit seams), rim-anchored bi-harmonic membrane across them.
|
||||
# blender --background --python 12_dent_membrane.py -- <in.blend> <raw.glb> <out.blend>
|
||||
import bpy, sys, time
|
||||
import numpy as np
|
||||
from mathutils import Vector
|
||||
from mathutils.kdtree import KDTree
|
||||
|
||||
argv = sys.argv[sys.argv.index("--") + 1:]
|
||||
BLEND, RAW, OUT = argv[0], argv[1], argv[2]
|
||||
t0 = time.time()
|
||||
|
||||
ROUGH_THR = 0.0006
|
||||
Z0, Z1 = 0.42, 0.655 # waist/hip/belly lines up to under the mounds; crotch already healed
|
||||
GROW_FREE = 3
|
||||
t0 = time.time()
|
||||
|
||||
|
||||
def log(m):
|
||||
print(f"[dent {time.time()-t0:6.1f}s] {m}", flush=True)
|
||||
|
||||
|
||||
bpy.ops.wm.open_mainfile(filepath=BLEND)
|
||||
ob = max([o for o in bpy.data.objects if o.type == 'MESH'],
|
||||
key=lambda o: len(o.data.vertices))
|
||||
me = ob.data
|
||||
n_v = len(me.vertices)
|
||||
co = np.empty(n_v * 3)
|
||||
me.vertices.foreach_get("co", co)
|
||||
co = co.reshape(-1, 3)
|
||||
|
||||
n_e = len(me.edges)
|
||||
ev0 = np.empty(n_e * 2, dtype=np.int32)
|
||||
me.edges.foreach_get("vertices", ev0)
|
||||
ev0 = ev0.reshape(-1, 2)
|
||||
o_r = np.concatenate([ev0[:, 0], ev0[:, 1]])
|
||||
n_r = np.concatenate([ev0[:, 1], ev0[:, 0]])
|
||||
s_r = np.argsort(o_r, kind="stable")
|
||||
o_rs = o_r[s_r]
|
||||
n_rs = n_r[s_r]
|
||||
ptr_r = np.searchsorted(o_rs, np.arange(n_v + 1))
|
||||
cnt_r = np.maximum(np.diff(ptr_r), 1)
|
||||
|
||||
sm = co.copy()
|
||||
for _ in range(8):
|
||||
su = np.add.reduceat(sm[n_rs], ptr_r[:-1], axis=0)
|
||||
emp = np.diff(ptr_r) == 0
|
||||
su[emp] = sm[emp]
|
||||
sm = su / cnt_r[:, None]
|
||||
rough = np.linalg.norm(co - sm, axis=1)
|
||||
|
||||
# slit seams from the raw pre-weld GLB (the dotted dash lines)
|
||||
before = set(bpy.data.objects)
|
||||
bpy.ops.import_scene.gltf(filepath=RAW)
|
||||
new = [o for o in bpy.data.objects if o not in before]
|
||||
raw = max([o for o in new if o.type == 'MESH'], key=lambda o: len(o.data.vertices))
|
||||
rme = raw.data
|
||||
rn = len(rme.vertices)
|
||||
rco = np.empty(rn * 3)
|
||||
rme.vertices.foreach_get("co", rco)
|
||||
rco = rco.reshape(-1, 3)
|
||||
l_tot = np.empty(len(rme.polygons), dtype=np.int32)
|
||||
rme.polygons.foreach_get("loop_total", l_tot)
|
||||
l_start = np.empty(len(rme.polygons), dtype=np.int32)
|
||||
rme.polygons.foreach_get("loop_start", l_start)
|
||||
l_v = np.empty(len(rme.loops), dtype=np.int32)
|
||||
rme.loops.foreach_get("vertex_index", l_v)
|
||||
ecount = {}
|
||||
for fs, ft in zip(l_start, l_tot):
|
||||
idxs = l_v[fs:fs + ft]
|
||||
for k in range(ft):
|
||||
a, b = idxs[k], idxs[(k + 1) % ft]
|
||||
kk = (a, b) if a < b else (b, a)
|
||||
ecount[kk] = ecount.get(kk, 0) + 1
|
||||
rbnd = np.zeros(rn, dtype=bool)
|
||||
for (a, b), c in ecount.items():
|
||||
if c == 1:
|
||||
rbnd[a] = rbnd[b] = True
|
||||
for o in new:
|
||||
bpy.data.objects.remove(o, do_unlink=True)
|
||||
|
||||
kd = KDTree(n_v)
|
||||
for i in range(n_v):
|
||||
kd.insert(Vector(co[i]), i)
|
||||
kd.balance()
|
||||
seam = np.zeros(n_v, dtype=bool)
|
||||
for p in rco[rbnd]:
|
||||
if not (Z0 < p[2] < Z1):
|
||||
continue
|
||||
hit = kd.find(Vector(p))
|
||||
if hit[0] is not None and hit[2] < 0.006:
|
||||
seam[hit[1]] = True
|
||||
log(f"seam verts: {seam.sum()}")
|
||||
|
||||
navel = (np.abs(co[:, 0]) < 0.022) & (co[:, 2] > 0.495) & (co[:, 2] < 0.555) & (co[:, 1] < 0)
|
||||
band = (co[:, 2] > Z0) & (co[:, 2] < Z1) & ~navel
|
||||
hot = band & ((rough > ROUGH_THR) | seam)
|
||||
log(f"hot line verts: {hot.sum()}")
|
||||
|
||||
|
||||
def grow_edges(mask, rings, ev):
|
||||
m = mask.copy()
|
||||
for _ in range(rings):
|
||||
hit = m[ev[:, 0]] | m[ev[:, 1]]
|
||||
m2 = m.copy()
|
||||
m2[ev[:, 0]] |= hit
|
||||
m2[ev[:, 1]] |= hit
|
||||
m = m2
|
||||
return m
|
||||
|
||||
|
||||
free_m = grow_edges(hot, GROW_FREE, ev0) & ~navel
|
||||
collar = grow_edges(free_m, 2, ev0) & ~free_m
|
||||
S = np.nonzero(free_m | collar)[0]
|
||||
in_S = np.zeros(n_v, dtype=bool)
|
||||
in_S[S] = True
|
||||
glb = np.full(n_v, -1, dtype=np.int64)
|
||||
glb[S] = np.arange(len(S))
|
||||
se = ev0[in_S[ev0].all(axis=1)]
|
||||
a_ = glb[se[:, 0]]
|
||||
b_ = glb[se[:, 1]]
|
||||
deg = np.zeros(len(S))
|
||||
np.add.at(deg, a_, 1.0)
|
||||
np.add.at(deg, b_, 1.0)
|
||||
free = free_m[S]
|
||||
log(f"free {free.sum()}, collar {(~free).sum()}")
|
||||
|
||||
|
||||
def Ls(X):
|
||||
out = deg[:, None] * X
|
||||
np.add.at(out, a_, -X[b_])
|
||||
np.add.at(out, b_, -X[a_])
|
||||
return out
|
||||
|
||||
|
||||
def A_op(U):
|
||||
X = np.zeros((len(S), 3))
|
||||
X[free] = U
|
||||
return Ls(Ls(X))[free]
|
||||
|
||||
|
||||
Xc = np.zeros((len(S), 3))
|
||||
Xc[~free] = co[S[~free]]
|
||||
rhs = -Ls(Ls(Xc))[free]
|
||||
U = co[S[free]].copy()
|
||||
r = rhs - A_op(U)
|
||||
p = r.copy()
|
||||
rs = (r * r).sum()
|
||||
rs0 = rs
|
||||
for it in range(120000):
|
||||
Ap = A_op(p)
|
||||
al = rs / max((p * Ap).sum(), 1e-30)
|
||||
U += al * p
|
||||
r -= al * Ap
|
||||
rs2 = (r * r).sum()
|
||||
if rs2 < 1e-18 or rs2 < rs0 * 1e-14:
|
||||
break
|
||||
p = r + (rs2 / rs) * p
|
||||
rs = rs2
|
||||
co_new = co.copy()
|
||||
co_new[S[free]] = U
|
||||
d = np.linalg.norm(co_new - co, axis=1)
|
||||
log(f"membrane: {free.sum()} verts (CG {it}, rel {rs2/max(rs0,1e-30):.2e}), max move {d.max():.4f}")
|
||||
|
||||
me.vertices.foreach_set("co", co_new.reshape(-1))
|
||||
me.update()
|
||||
if me.has_custom_normals:
|
||||
vn = np.empty(n_v * 3, dtype=np.float32)
|
||||
me.vertices.foreach_get("normal", vn)
|
||||
me.normals_split_custom_set_from_vertices(vn.reshape(-1, 3))
|
||||
bpy.context.preferences.filepaths.save_version = 0 # no .blend1 autosave
|
||||
bpy.ops.wm.save_as_mainfile(filepath=OUT)
|
||||
log(f"WROTE {OUT}")
|
||||
print("DENT_DONE")
|
||||
@@ -0,0 +1,168 @@
|
||||
# Stage 13: heal the garment-BOUNDARY dig-ins. Every stomach/waist line sits where an old
|
||||
# garment mask edge was: the sculpt replaced the region INSIDE the mask, but anchored its
|
||||
# membrane on the rim — exactly where the waistband/leg-hems/bra-band dug into the body, so
|
||||
# the dig-in ring survived as the boundary condition. Free a band STRADDLING every garment
|
||||
# boundary (grow-out XOR shrink-in), plus slit seams on unmoved skin (0.5 mm mapping), and
|
||||
# solve the rim-anchored membrane across it.
|
||||
# blender --background --python 13_rim_membrane.py -- <in.blend> <masks.npz> <raw.glb> <out.blend>
|
||||
import bpy, sys, time
|
||||
import numpy as np
|
||||
from mathutils import Vector
|
||||
from mathutils.kdtree import KDTree
|
||||
|
||||
argv = sys.argv[sys.argv.index("--") + 1:]
|
||||
BLEND, MASKS, RAW, OUT = argv[0], argv[1], argv[2], argv[3]
|
||||
t0 = time.time()
|
||||
|
||||
RIM = 10 # rings each side of the garment boundary (~1 cm)
|
||||
Z0, Z1 = 0.40, 0.86
|
||||
SEAM_TOL = 0.0005 # only trust seam mapping on UNMOVED skin
|
||||
|
||||
|
||||
def log(m):
|
||||
print(f"[rim {time.time()-t0:6.1f}s] {m}", flush=True)
|
||||
|
||||
|
||||
bpy.ops.wm.open_mainfile(filepath=BLEND)
|
||||
ob = max([o for o in bpy.data.objects if o.type == 'MESH'],
|
||||
key=lambda o: len(o.data.vertices))
|
||||
me = ob.data
|
||||
n_v = len(me.vertices)
|
||||
co = np.empty(n_v * 3)
|
||||
me.vertices.foreach_get("co", co)
|
||||
co = co.reshape(-1, 3)
|
||||
M = np.load(MASKS)
|
||||
garment = M["garment"]
|
||||
|
||||
n_e = len(me.edges)
|
||||
ev0 = np.empty(n_e * 2, dtype=np.int32)
|
||||
me.edges.foreach_get("vertices", ev0)
|
||||
ev0 = ev0.reshape(-1, 2)
|
||||
|
||||
|
||||
def grow_edges(mask, rings, ev):
|
||||
m = mask.copy()
|
||||
for _ in range(rings):
|
||||
hit = m[ev[:, 0]] | m[ev[:, 1]]
|
||||
m2 = m.copy()
|
||||
m2[ev[:, 0]] |= hit
|
||||
m2[ev[:, 1]] |= hit
|
||||
m = m2
|
||||
return m
|
||||
|
||||
|
||||
def shrink_edges(mask, rings, ev):
|
||||
return ~grow_edges(~mask, rings, ev)
|
||||
|
||||
|
||||
rim_band = grow_edges(garment, RIM, ev0) & ~shrink_edges(garment, RIM, ev0)
|
||||
log(f"garment rim band: {rim_band.sum()}")
|
||||
|
||||
# slit seams on unmoved skin
|
||||
before = set(bpy.data.objects)
|
||||
bpy.ops.import_scene.gltf(filepath=RAW)
|
||||
new = [o for o in bpy.data.objects if o not in before]
|
||||
raw = max([o for o in new if o.type == 'MESH'], key=lambda o: len(o.data.vertices))
|
||||
rme = raw.data
|
||||
rn = len(rme.vertices)
|
||||
rco = np.empty(rn * 3)
|
||||
rme.vertices.foreach_get("co", rco)
|
||||
rco = rco.reshape(-1, 3)
|
||||
l_tot = np.empty(len(rme.polygons), dtype=np.int32)
|
||||
rme.polygons.foreach_get("loop_total", l_tot)
|
||||
l_start = np.empty(len(rme.polygons), dtype=np.int32)
|
||||
rme.polygons.foreach_get("loop_start", l_start)
|
||||
l_v = np.empty(len(rme.loops), dtype=np.int32)
|
||||
rme.loops.foreach_get("vertex_index", l_v)
|
||||
ecount = {}
|
||||
for fs, ft in zip(l_start, l_tot):
|
||||
idxs = l_v[fs:fs + ft]
|
||||
for k in range(ft):
|
||||
a, b = idxs[k], idxs[(k + 1) % ft]
|
||||
kk = (a, b) if a < b else (b, a)
|
||||
ecount[kk] = ecount.get(kk, 0) + 1
|
||||
rbnd = np.zeros(rn, dtype=bool)
|
||||
for (a, b), c in ecount.items():
|
||||
if c == 1:
|
||||
rbnd[a] = rbnd[b] = True
|
||||
for o in new:
|
||||
bpy.data.objects.remove(o, do_unlink=True)
|
||||
|
||||
kd = KDTree(n_v)
|
||||
for i in range(n_v):
|
||||
kd.insert(Vector(co[i]), i)
|
||||
kd.balance()
|
||||
seam = np.zeros(n_v, dtype=bool)
|
||||
for p in rco[rbnd]:
|
||||
if not (Z0 < p[2] < Z1):
|
||||
continue
|
||||
hit = kd.find(Vector(p))
|
||||
if hit[0] is not None and hit[2] < SEAM_TOL:
|
||||
seam[hit[1]] = True
|
||||
log(f"seam verts (tight map): {seam.sum()}")
|
||||
|
||||
navel = (np.abs(co[:, 0]) < 0.022) & (co[:, 2] > 0.495) & (co[:, 2] < 0.555) & (co[:, 1] < 0)
|
||||
free_m = ((rim_band | grow_edges(seam, 3, ev0)) &
|
||||
(co[:, 2] > Z0) & (co[:, 2] < Z1) & ~navel)
|
||||
collar = grow_edges(free_m, 2, ev0) & ~free_m
|
||||
S = np.nonzero(free_m | collar)[0]
|
||||
in_S = np.zeros(n_v, dtype=bool)
|
||||
in_S[S] = True
|
||||
glb = np.full(n_v, -1, dtype=np.int64)
|
||||
glb[S] = np.arange(len(S))
|
||||
se = ev0[in_S[ev0].all(axis=1)]
|
||||
a_ = glb[se[:, 0]]
|
||||
b_ = glb[se[:, 1]]
|
||||
deg = np.zeros(len(S))
|
||||
np.add.at(deg, a_, 1.0)
|
||||
np.add.at(deg, b_, 1.0)
|
||||
free = free_m[S]
|
||||
log(f"free {free.sum()}, collar {(~free).sum()}")
|
||||
|
||||
|
||||
def Ls(X):
|
||||
out = deg[:, None] * X
|
||||
np.add.at(out, a_, -X[b_])
|
||||
np.add.at(out, b_, -X[a_])
|
||||
return out
|
||||
|
||||
|
||||
def A_op(U):
|
||||
X = np.zeros((len(S), 3))
|
||||
X[free] = U
|
||||
return Ls(Ls(X))[free]
|
||||
|
||||
|
||||
Xc = np.zeros((len(S), 3))
|
||||
Xc[~free] = co[S[~free]]
|
||||
rhs = -Ls(Ls(Xc))[free]
|
||||
U = co[S[free]].copy()
|
||||
r = rhs - A_op(U)
|
||||
p = r.copy()
|
||||
rs = (r * r).sum()
|
||||
rs0 = rs
|
||||
for it in range(120000):
|
||||
Ap = A_op(p)
|
||||
al = rs / max((p * Ap).sum(), 1e-30)
|
||||
U += al * p
|
||||
r -= al * Ap
|
||||
rs2 = (r * r).sum()
|
||||
if rs2 < 1e-18 or rs2 < rs0 * 1e-14:
|
||||
break
|
||||
p = r + (rs2 / rs) * p
|
||||
rs = rs2
|
||||
co_new = co.copy()
|
||||
co_new[S[free]] = U
|
||||
d = np.linalg.norm(co_new - co, axis=1)
|
||||
log(f"membrane: {free.sum()} verts (CG {it}, rel {rs2/max(rs0,1e-30):.2e}), max move {d.max():.4f}")
|
||||
|
||||
me.vertices.foreach_set("co", co_new.reshape(-1))
|
||||
me.update()
|
||||
if me.has_custom_normals:
|
||||
vn = np.empty(n_v * 3, dtype=np.float32)
|
||||
me.vertices.foreach_get("normal", vn)
|
||||
me.normals_split_custom_set_from_vertices(vn.reshape(-1, 3))
|
||||
bpy.context.preferences.filepaths.save_version = 0 # no .blend1 autosave
|
||||
bpy.ops.wm.save_as_mainfile(filepath=OUT)
|
||||
log(f"WROTE {OUT}")
|
||||
print("RIM_DONE")
|
||||
@@ -0,0 +1,156 @@
|
||||
# Stage 14: heal the SOURCE MESH's panel-seam network. The mask viz proved the stomach/waist
|
||||
# lines are the Tripo scan-panel slits (a body-wide grid), not garment edges — the paint had
|
||||
# been camouflaging them. Map the raw mesh's boundary verts at 2 mm (welding moved verts ~1 mm,
|
||||
# which is why a 0.5 mm map only caught a quarter of the network), grow 2, membrane across.
|
||||
# blender --background --python 14_seam_membrane.py -- <in.blend> <raw.glb> <out.blend>
|
||||
import bpy, sys, time
|
||||
import numpy as np
|
||||
from mathutils import Vector
|
||||
from mathutils.kdtree import KDTree
|
||||
|
||||
argv = sys.argv[sys.argv.index("--") + 1:]
|
||||
BLEND, RAW, OUT = argv[0], argv[1], argv[2]
|
||||
t0 = time.time()
|
||||
|
||||
SEAM_TOL = 0.002
|
||||
Z0, Z1 = 0.28, 0.87 # whole body below the chin; face seams stay (framed by features)
|
||||
|
||||
|
||||
def log(m):
|
||||
print(f"[seam {time.time()-t0:6.1f}s] {m}", flush=True)
|
||||
|
||||
|
||||
bpy.ops.wm.open_mainfile(filepath=BLEND)
|
||||
ob = max([o for o in bpy.data.objects if o.type == 'MESH'],
|
||||
key=lambda o: len(o.data.vertices))
|
||||
me = ob.data
|
||||
n_v = len(me.vertices)
|
||||
co = np.empty(n_v * 3)
|
||||
me.vertices.foreach_get("co", co)
|
||||
co = co.reshape(-1, 3)
|
||||
|
||||
n_e = len(me.edges)
|
||||
ev0 = np.empty(n_e * 2, dtype=np.int32)
|
||||
me.edges.foreach_get("vertices", ev0)
|
||||
ev0 = ev0.reshape(-1, 2)
|
||||
|
||||
before = set(bpy.data.objects)
|
||||
bpy.ops.import_scene.gltf(filepath=RAW)
|
||||
new = [o for o in bpy.data.objects if o not in before]
|
||||
raw = max([o for o in new if o.type == 'MESH'], key=lambda o: len(o.data.vertices))
|
||||
rme = raw.data
|
||||
rn = len(rme.vertices)
|
||||
rco = np.empty(rn * 3)
|
||||
rme.vertices.foreach_get("co", rco)
|
||||
rco = rco.reshape(-1, 3)
|
||||
l_tot = np.empty(len(rme.polygons), dtype=np.int32)
|
||||
rme.polygons.foreach_get("loop_total", l_tot)
|
||||
l_start = np.empty(len(rme.polygons), dtype=np.int32)
|
||||
rme.polygons.foreach_get("loop_start", l_start)
|
||||
l_v = np.empty(len(rme.loops), dtype=np.int32)
|
||||
rme.loops.foreach_get("vertex_index", l_v)
|
||||
ecount = {}
|
||||
for fs, ft in zip(l_start, l_tot):
|
||||
idxs = l_v[fs:fs + ft]
|
||||
for k in range(ft):
|
||||
a, b = idxs[k], idxs[(k + 1) % ft]
|
||||
kk = (a, b) if a < b else (b, a)
|
||||
ecount[kk] = ecount.get(kk, 0) + 1
|
||||
rbnd = np.zeros(rn, dtype=bool)
|
||||
for (a, b), c in ecount.items():
|
||||
if c == 1:
|
||||
rbnd[a] = rbnd[b] = True
|
||||
for o in new:
|
||||
bpy.data.objects.remove(o, do_unlink=True)
|
||||
log(f"raw boundary verts: {rbnd.sum()}")
|
||||
|
||||
kd = KDTree(n_v)
|
||||
for i in range(n_v):
|
||||
kd.insert(Vector(co[i]), i)
|
||||
kd.balance()
|
||||
seam = np.zeros(n_v, dtype=bool)
|
||||
for p in rco[rbnd]:
|
||||
if not (Z0 < p[2] < Z1):
|
||||
continue
|
||||
hit = kd.find(Vector(p))
|
||||
if hit[0] is not None and hit[2] < SEAM_TOL:
|
||||
seam[hit[1]] = True
|
||||
log(f"seam verts mapped: {seam.sum()}")
|
||||
|
||||
navel = (np.abs(co[:, 0]) < 0.022) & (co[:, 2] > 0.495) & (co[:, 2] < 0.555) & (co[:, 1] < 0)
|
||||
|
||||
|
||||
def grow_edges(mask, rings, ev):
|
||||
m = mask.copy()
|
||||
for _ in range(rings):
|
||||
hit = m[ev[:, 0]] | m[ev[:, 1]]
|
||||
m2 = m.copy()
|
||||
m2[ev[:, 0]] |= hit
|
||||
m2[ev[:, 1]] |= hit
|
||||
m = m2
|
||||
return m
|
||||
|
||||
|
||||
free_m = grow_edges(seam, 2, ev0) & (co[:, 2] > Z0) & (co[:, 2] < Z1) & ~navel
|
||||
collar = grow_edges(free_m, 2, ev0) & ~free_m
|
||||
S = np.nonzero(free_m | collar)[0]
|
||||
in_S = np.zeros(n_v, dtype=bool)
|
||||
in_S[S] = True
|
||||
glb = np.full(n_v, -1, dtype=np.int64)
|
||||
glb[S] = np.arange(len(S))
|
||||
se = ev0[in_S[ev0].all(axis=1)]
|
||||
a_ = glb[se[:, 0]]
|
||||
b_ = glb[se[:, 1]]
|
||||
deg = np.zeros(len(S))
|
||||
np.add.at(deg, a_, 1.0)
|
||||
np.add.at(deg, b_, 1.0)
|
||||
free = free_m[S]
|
||||
log(f"free {free.sum()}, collar {(~free).sum()}")
|
||||
|
||||
|
||||
def Ls(X):
|
||||
out = deg[:, None] * X
|
||||
np.add.at(out, a_, -X[b_])
|
||||
np.add.at(out, b_, -X[a_])
|
||||
return out
|
||||
|
||||
|
||||
def A_op(U):
|
||||
X = np.zeros((len(S), 3))
|
||||
X[free] = U
|
||||
return Ls(Ls(X))[free]
|
||||
|
||||
|
||||
Xc = np.zeros((len(S), 3))
|
||||
Xc[~free] = co[S[~free]]
|
||||
rhs = -Ls(Ls(Xc))[free]
|
||||
U = co[S[free]].copy()
|
||||
r = rhs - A_op(U)
|
||||
p = r.copy()
|
||||
rs = (r * r).sum()
|
||||
rs0 = rs
|
||||
for it in range(120000):
|
||||
Ap = A_op(p)
|
||||
al = rs / max((p * Ap).sum(), 1e-30)
|
||||
U += al * p
|
||||
r -= al * Ap
|
||||
rs2 = (r * r).sum()
|
||||
if rs2 < 1e-18 or rs2 < rs0 * 1e-14:
|
||||
break
|
||||
p = r + (rs2 / rs) * p
|
||||
rs = rs2
|
||||
co_new = co.copy()
|
||||
co_new[S[free]] = U
|
||||
d = np.linalg.norm(co_new - co, axis=1)
|
||||
log(f"membrane: {free.sum()} verts (CG {it}, rel {rs2/max(rs0,1e-30):.2e}), max move {d.max():.4f}")
|
||||
|
||||
me.vertices.foreach_set("co", co_new.reshape(-1))
|
||||
me.update()
|
||||
if me.has_custom_normals:
|
||||
vn = np.empty(n_v * 3, dtype=np.float32)
|
||||
me.vertices.foreach_get("normal", vn)
|
||||
me.normals_split_custom_set_from_vertices(vn.reshape(-1, 3))
|
||||
bpy.context.preferences.filepaths.save_version = 0 # no .blend1 autosave
|
||||
bpy.ops.wm.save_as_mainfile(filepath=OUT)
|
||||
log(f"WROTE {OUT}")
|
||||
print("SEAM_DONE")
|
||||
@@ -0,0 +1,94 @@
|
||||
# Stage 15: TRUE topological weld of the panel seams. Diagnosis: membranes level both sides
|
||||
# of every seam yet the lines persist -> the two sides are disconnected vertex runs (the
|
||||
# original "weld" unified positions only). Each panel smooths and shades independently, so a
|
||||
# crack survives any vertex MOVEMENT. Fix: bmesh remove_doubles restricted to the seam verts,
|
||||
# which merges the runs into shared vertices -> shared normals -> no shading discontinuity.
|
||||
# Loops keep their UVs, so the baked texture is unaffected. Vertex count changes; this is
|
||||
# only legal at the END of the pipeline (masks.npz indices die here).
|
||||
# blender --background --python 15_true_weld.py -- <in.blend> <raw.glb> <out.blend>
|
||||
import bpy, bmesh, sys, time
|
||||
import numpy as np
|
||||
from mathutils import Vector
|
||||
from mathutils.kdtree import KDTree
|
||||
|
||||
argv = sys.argv[sys.argv.index("--") + 1:]
|
||||
BLEND, RAW, OUT = argv[0], argv[1], argv[2]
|
||||
t0 = time.time()
|
||||
|
||||
SEAM_TOL = 0.0025
|
||||
MERGE_DIST = 0.002
|
||||
|
||||
|
||||
def log(m):
|
||||
print(f"[weld {time.time()-t0:6.1f}s] {m}", flush=True)
|
||||
|
||||
|
||||
bpy.ops.wm.open_mainfile(filepath=BLEND)
|
||||
ob = max([o for o in bpy.data.objects if o.type == 'MESH'],
|
||||
key=lambda o: len(o.data.vertices))
|
||||
me = ob.data
|
||||
n_v = len(me.vertices)
|
||||
co = np.empty(n_v * 3)
|
||||
me.vertices.foreach_get("co", co)
|
||||
co = co.reshape(-1, 3)
|
||||
|
||||
before = set(bpy.data.objects)
|
||||
bpy.ops.import_scene.gltf(filepath=RAW)
|
||||
new = [o for o in bpy.data.objects if o not in before]
|
||||
raw = max([o for o in new if o.type == 'MESH'], key=lambda o: len(o.data.vertices))
|
||||
rme = raw.data
|
||||
rn = len(rme.vertices)
|
||||
log(f"working mesh {n_v}v | raw mesh {rn}v | equal: {rn == n_v}")
|
||||
rco = np.empty(rn * 3)
|
||||
rme.vertices.foreach_get("co", rco)
|
||||
rco = rco.reshape(-1, 3)
|
||||
l_tot = np.empty(len(rme.polygons), dtype=np.int32)
|
||||
rme.polygons.foreach_get("loop_total", l_tot)
|
||||
l_start = np.empty(len(rme.polygons), dtype=np.int32)
|
||||
rme.polygons.foreach_get("loop_start", l_start)
|
||||
l_v = np.empty(len(rme.loops), dtype=np.int32)
|
||||
rme.loops.foreach_get("vertex_index", l_v)
|
||||
ecount = {}
|
||||
for fs, ft in zip(l_start, l_tot):
|
||||
idxs = l_v[fs:fs + ft]
|
||||
for k in range(ft):
|
||||
a, b = idxs[k], idxs[(k + 1) % ft]
|
||||
kk = (a, b) if a < b else (b, a)
|
||||
ecount[kk] = ecount.get(kk, 0) + 1
|
||||
rbnd = np.zeros(rn, dtype=bool)
|
||||
for (a, b), c in ecount.items():
|
||||
if c == 1:
|
||||
rbnd[a] = rbnd[b] = True
|
||||
for o in new:
|
||||
bpy.data.objects.remove(o, do_unlink=True)
|
||||
log(f"raw boundary verts: {rbnd.sum()}")
|
||||
|
||||
kd = KDTree(n_v)
|
||||
for i in range(n_v):
|
||||
kd.insert(Vector(co[i]), i)
|
||||
kd.balance()
|
||||
seam = np.zeros(n_v, dtype=bool)
|
||||
for p in rco[rbnd]:
|
||||
for (_, idx, dist) in kd.find_range(Vector(p), SEAM_TOL):
|
||||
seam[idx] = True
|
||||
log(f"seam verts (range map): {seam.sum()}")
|
||||
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(me)
|
||||
bm.verts.ensure_lookup_table()
|
||||
sel = [bm.verts[i] for i in np.nonzero(seam)[0]]
|
||||
res = bmesh.ops.remove_doubles(bm, verts=sel, dist=MERGE_DIST)
|
||||
bm.to_mesh(me)
|
||||
bm.free()
|
||||
me.update()
|
||||
n_after = len(me.vertices)
|
||||
log(f"merged: {n_v} -> {n_after} verts (-{n_v - n_after})")
|
||||
|
||||
# smooth vertex normals across the now-shared seams
|
||||
vn = np.empty(n_after * 3, dtype=np.float32)
|
||||
me.vertices.foreach_get("normal", vn)
|
||||
me.normals_split_custom_set_from_vertices(vn.reshape(-1, 3))
|
||||
bpy.context.preferences.filepaths.save_version = 0 # no .blend1 autosave
|
||||
bpy.ops.wm.save_as_mainfile(filepath=OUT)
|
||||
log(f"WROTE {OUT}")
|
||||
print("WELD_DONE")
|
||||
@@ -0,0 +1,278 @@
|
||||
# Stage 16 (diagnosis only — writes nothing): measure the three defects Jeremy named, so the
|
||||
# fixes target what is actually there instead of repeating stages 11-15.
|
||||
#
|
||||
# blender --background --python 16_diagnose.py -- <in.blend> <orig.blend> [scratch_dir]
|
||||
#
|
||||
# 1. CUT LINES. Are they still topology (disconnected panel runs / holes) after stage 15's weld,
|
||||
# or are they now a purely geometric groove? Reports boundary edges, non-manifold edges,
|
||||
# degenerate faces, and — for the strongest shading-kink clusters — the groove depth in mm
|
||||
# measured perpendicular to the line. Depth tells us whether to weld harder or to fillet.
|
||||
# 2. DISCOLOURATION. Finds the repainted texels by diffing this blend's packed basecolor against
|
||||
# the ORIGINAL texture, then reports mean RGB inside the patch vs a ring of untouched skin
|
||||
# just outside it. A tone STEP at the boundary is a Poisson problem; a uniform offset over the
|
||||
# whole patch is a levelling problem. The numbers separate them.
|
||||
# 3. CLEAVAGE. Samples the medial (sternum) corridor for concavity: minimum principal-curvature
|
||||
# radius per height, so "sharp crease" vs "round fillet" is a number, not an opinion.
|
||||
import bpy, bmesh, sys, os, time, math
|
||||
import numpy as np
|
||||
|
||||
argv = sys.argv[sys.argv.index("--") + 1:]
|
||||
BLEND = argv[0]
|
||||
ORIG = argv[1] if len(argv) > 1 else ""
|
||||
SCRATCH = argv[2] if len(argv) > 2 else "."
|
||||
t0 = time.time()
|
||||
|
||||
|
||||
def log(m):
|
||||
print(f"[diag {time.time()-t0:6.1f}s] {m}", flush=True)
|
||||
|
||||
|
||||
def body_of():
|
||||
return max([o for o in bpy.data.objects if o.type == 'MESH'],
|
||||
key=lambda o: len(o.data.vertices))
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 1. TOPOLOGY + CUT LINES
|
||||
# =============================================================================
|
||||
bpy.ops.wm.open_mainfile(filepath=BLEND)
|
||||
ob = body_of()
|
||||
me = ob.data
|
||||
n_v = len(me.vertices)
|
||||
n_f = len(me.polygons)
|
||||
log(f"mesh '{ob.name}': {n_v} verts, {n_f} faces, custom_normals={me.has_custom_normals}")
|
||||
|
||||
co = np.empty(n_v * 3)
|
||||
me.vertices.foreach_get("co", co)
|
||||
co = co.reshape(-1, 3)
|
||||
print(f"BBOX z {co[:,2].min():.3f}..{co[:,2].max():.3f} "
|
||||
f"x {co[:,0].min():.3f}..{co[:,0].max():.3f} y {co[:,1].min():.3f}..{co[:,1].max():.3f}")
|
||||
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(me)
|
||||
bnd = [e for e in bm.edges if len(e.link_faces) == 1]
|
||||
nonman = [e for e in bm.edges if len(e.link_faces) > 2]
|
||||
degen = [f for f in bm.faces if f.calc_area() < 1e-12]
|
||||
loose = [v for v in bm.verts if not v.link_faces]
|
||||
print(f"TOPO boundary_edges={len(bnd)} nonmanifold_edges={len(nonman)} "
|
||||
f"degenerate_faces={len(degen)} loose_verts={len(loose)}")
|
||||
# where are the holes? cluster boundary verts by height
|
||||
if bnd:
|
||||
bz = np.array([v.co.z for e in bnd for v in e.verts])
|
||||
hist, edges = np.histogram(bz, bins=12)
|
||||
print("BOUNDARY-EDGE z histogram (holes live here):")
|
||||
for c, lo, hi in zip(hist, edges[:-1], edges[1:]):
|
||||
if c:
|
||||
print(f" z {lo:.3f}-{hi:.3f}: {c}")
|
||||
bm.free()
|
||||
|
||||
# ---- shading kinks = the visible lines ----
|
||||
nrm = np.empty(n_v * 3)
|
||||
me.vertices.foreach_get("normal", nrm)
|
||||
nrm = nrm.reshape(-1, 3)
|
||||
ev = np.empty(len(me.edges) * 2, dtype=np.int32)
|
||||
me.edges.foreach_get("vertices", ev)
|
||||
ev = ev.reshape(-1, 2)
|
||||
order = np.concatenate([ev[:, 0], ev[:, 1]])
|
||||
nbr = np.concatenate([ev[:, 1], ev[:, 0]])
|
||||
srt = np.argsort(order, kind="stable")
|
||||
o_s, n_s = order[srt], nbr[srt]
|
||||
ptr = np.searchsorted(o_s, np.arange(n_v + 1))
|
||||
cnt = np.maximum(np.diff(ptr), 1)
|
||||
|
||||
|
||||
def nbr_mean(X):
|
||||
acc = np.add.reduceat(X[n_s], ptr[:-1], axis=0)
|
||||
empty = np.diff(ptr) == 0
|
||||
acc[empty] = X[empty]
|
||||
return acc / cnt[:, None]
|
||||
|
||||
|
||||
N = nrm.copy()
|
||||
for _ in range(5):
|
||||
N = nbr_mean(N)
|
||||
N /= np.maximum(np.linalg.norm(N, axis=1, keepdims=True), 1e-12)
|
||||
ang = np.degrees(np.arccos(np.clip((nrm * N).sum(axis=1), -1, 1)))
|
||||
|
||||
# signed offset from the locally-smooth surface: negative = groove, positive = ridge
|
||||
sm = co.copy()
|
||||
for _ in range(12):
|
||||
sm = nbr_mean(sm)
|
||||
dev = ((co - sm) * N).sum(axis=1) # metres, along the smooth normal
|
||||
|
||||
torso = (co[:, 2] > 0.28) & (co[:, 2] < 0.90)
|
||||
for thr in (8.0, 12.0, 20.0):
|
||||
k = torso & (ang > thr)
|
||||
print(f"KINK >{thr:4.1f}deg : {k.sum():6d} verts "
|
||||
f"(groove depth p05={np.percentile(dev[k],5)*1000:+.3f} mm, "
|
||||
f"median={np.median(dev[k])*1000:+.3f} mm, "
|
||||
f"p95={np.percentile(dev[k],95)*1000:+.3f} mm)" if k.sum() else f"KINK >{thr}: none")
|
||||
|
||||
kink = torso & (ang > 12.0)
|
||||
if kink.sum():
|
||||
hist, edges = np.histogram(co[kink, 2], bins=16)
|
||||
print("KINK z histogram (the lines):")
|
||||
for c, lo, hi in zip(hist, edges[:-1], edges[1:]):
|
||||
if c > 20:
|
||||
print(f" z {lo:.3f}-{hi:.3f}: {c:5d}")
|
||||
|
||||
# are kink verts topologically split? count how many sit on a boundary or have a
|
||||
# near-duplicate vertex that is NOT an edge-neighbour (= two panels touching, unmerged)
|
||||
from mathutils import Vector
|
||||
from mathutils.kdtree import KDTree
|
||||
kidx = np.nonzero(kink)[0]
|
||||
sample = kidx[::max(1, len(kidx) // 4000)]
|
||||
kd = KDTree(n_v)
|
||||
for i in range(n_v):
|
||||
kd.insert(Vector(co[i]), i)
|
||||
kd.balance()
|
||||
nbrs = [set() for _ in range(0)]
|
||||
adj = {}
|
||||
for a, b in ev:
|
||||
adj.setdefault(a, set()).add(b)
|
||||
adj.setdefault(b, set()).add(a)
|
||||
split = 0
|
||||
for i in sample:
|
||||
for (_, j, d) in kd.find_range(Vector(co[i]), 0.0008):
|
||||
if j != i and j not in adj.get(i, ()):
|
||||
split += 1
|
||||
break
|
||||
print(f"SPLIT-PANEL test on {len(sample)} kink verts: {split} "
|
||||
f"({100.0*split/max(len(sample),1):.1f}%) have an unmerged twin within 0.8 mm")
|
||||
|
||||
# =============================================================================
|
||||
# 3. CLEAVAGE — concavity of the medial corridor
|
||||
# =============================================================================
|
||||
print("\n=== CLEAVAGE: medial corridor cross-sections y(x) ===")
|
||||
front = co[:, 1] < 0
|
||||
for z0 in np.arange(0.62, 0.745, 0.015):
|
||||
row = []
|
||||
for x0 in np.arange(-0.05, 0.0501, 0.005):
|
||||
m = front & (np.abs(co[:, 0] - x0) < 0.0035) & (np.abs(co[:, 2] - z0) < 0.004)
|
||||
row.append(co[m, 1].min() if m.sum() else np.nan)
|
||||
row = np.array(row)
|
||||
if np.isnan(row).all():
|
||||
continue
|
||||
# curvature of the y(x) profile at the sternum: second difference over 5 mm steps
|
||||
mid = len(row) // 2
|
||||
seg = row[max(0, mid - 3):mid + 4]
|
||||
if len(seg) >= 3 and not np.isnan(seg).any():
|
||||
d2 = (seg[:-2] - 2 * seg[1:-1] + seg[2:]) / (0.005 ** 2)
|
||||
kmax = np.nanmax(d2)
|
||||
rad = 1.0 / kmax if kmax > 1e-6 else float('inf')
|
||||
print(f" z={z0:.3f} sternum y={row[mid]:+.4f} "
|
||||
f"max concave curvature {kmax:8.1f} 1/m -> fillet radius "
|
||||
f"{rad*1000:6.1f} mm" + (" <-- SHARP" if rad < 0.012 else ""))
|
||||
|
||||
print("\n=== CLEAVAGE: depth of the notch (breast apex y vs sternum y) ===")
|
||||
for z0 in np.arange(0.62, 0.745, 0.015):
|
||||
ms = front & (np.abs(co[:, 0]) < 0.004) & (np.abs(co[:, 2] - z0) < 0.004)
|
||||
ma = front & (np.abs(np.abs(co[:, 0]) - 0.034) < 0.005) & (np.abs(co[:, 2] - z0) < 0.004)
|
||||
if ms.sum() and ma.sum():
|
||||
print(f" z={z0:.3f} sternum {co[ms,1].min():+.4f} apex {co[ma,1].min():+.4f} "
|
||||
f"notch {(co[ms,1].min()-co[ma,1].min())*1000:+6.1f} mm")
|
||||
|
||||
# =============================================================================
|
||||
# 2. DISCOLOURATION — repainted texels vs surrounding skin
|
||||
# =============================================================================
|
||||
def grab_images():
|
||||
out = {}
|
||||
for i in bpy.data.images:
|
||||
nm = i.name.lower()
|
||||
if "basecolor" in nm:
|
||||
out["base"] = i
|
||||
return out
|
||||
|
||||
|
||||
def px(img):
|
||||
w, h = img.size
|
||||
b = np.empty(w * h * 4, dtype=np.float32)
|
||||
img.pixels.foreach_get(b)
|
||||
return b.reshape(h, w, 4)[:, :, :3].astype(np.float32), w, h
|
||||
|
||||
|
||||
cur = grab_images()
|
||||
if "base" not in cur:
|
||||
print("\nDISCOLOUR: no basecolor image found; skipping")
|
||||
else:
|
||||
A, w, h = px(cur["base"])
|
||||
log(f"current basecolor {w}x{h} '{cur['base'].name}'")
|
||||
np.save(os.path.join(SCRATCH, "cur_base.npy"), A)
|
||||
if ORIG and os.path.exists(ORIG):
|
||||
bpy.ops.wm.open_mainfile(filepath=ORIG)
|
||||
og = grab_images()
|
||||
if "base" in og:
|
||||
B, w2, h2 = px(og["base"])
|
||||
log(f"original basecolor {w2}x{h2} '{og['base'].name}'")
|
||||
if (w2, h2) == (w, h):
|
||||
d = np.abs(A - B).max(axis=2)
|
||||
mask = d > 0.02
|
||||
print(f"\nDISCOLOUR: repainted texels = {int(mask.sum())} "
|
||||
f"({100.0*mask.sum()/(w*h):.2f}% of atlas)")
|
||||
|
||||
def dil(m, k):
|
||||
g = m.copy()
|
||||
for _ in range(k):
|
||||
n = g.copy()
|
||||
n[1:, :] |= g[:-1, :]
|
||||
n[:-1, :] |= g[1:, :]
|
||||
n[:, 1:] |= g[:, :-1]
|
||||
n[:, :-1] |= g[:, 1:]
|
||||
g = n
|
||||
return g
|
||||
|
||||
inner = mask & ~dil(~mask, 6) # 6 px in from the patch edge
|
||||
ring = dil(mask, 10) & ~dil(mask, 2) # untouched skin just outside
|
||||
if inner.any() and ring.any():
|
||||
mi = A[inner].mean(axis=0)
|
||||
mr = A[ring].mean(axis=0)
|
||||
print(f" patch interior mean RGB {mi[0]:.4f} {mi[1]:.4f} {mi[2]:.4f}")
|
||||
print(f" outside ring mean RGB {mr[0]:.4f} {mr[1]:.4f} {mr[2]:.4f}")
|
||||
print(f" OFFSET (patch-ring) {mi[0]-mr[0]:+.4f} {mi[1]-mr[1]:+.4f} "
|
||||
f"{mi[2]-mr[2]:+.4f} (luma {(mi.mean()-mr.mean()):+.4f})")
|
||||
print(f" patch interior stddev {A[inner].std(axis=0)}")
|
||||
print(f" ring stddev {A[ring].std(axis=0)}")
|
||||
# per-region: split the mask into connected blobs and report the big ones
|
||||
lab = np.zeros(mask.shape, dtype=np.int32)
|
||||
cur_l = 0
|
||||
ys, xs = np.nonzero(mask)
|
||||
seen = np.zeros(mask.shape, dtype=bool)
|
||||
from collections import deque
|
||||
blobs = []
|
||||
for y0, x0 in zip(ys, xs):
|
||||
if seen[y0, x0]:
|
||||
continue
|
||||
cur_l += 1
|
||||
q = deque([(y0, x0)])
|
||||
seen[y0, x0] = True
|
||||
cells = []
|
||||
while q:
|
||||
y, x = q.popleft()
|
||||
cells.append((y, x))
|
||||
for dy, dx in ((1, 0), (-1, 0), (0, 1), (0, -1)):
|
||||
yy, xx = y + dy, x + dx
|
||||
if 0 <= yy < h and 0 <= xx < w and mask[yy, xx] and not seen[yy, xx]:
|
||||
seen[yy, xx] = True
|
||||
q.append((yy, xx))
|
||||
if len(cells) > 2000:
|
||||
blobs.append(cells)
|
||||
print(f" {len(blobs)} patch blobs >2000 texels")
|
||||
for bi, cells in enumerate(sorted(blobs, key=len, reverse=True)[:8]):
|
||||
cy = np.array([c[0] for c in cells])
|
||||
cx = np.array([c[1] for c in cells])
|
||||
bm_ = np.zeros(mask.shape, dtype=bool)
|
||||
bm_[cy, cx] = True
|
||||
bin_ = bm_ & ~dil(~bm_, 5)
|
||||
br_ = dil(bm_, 10) & ~dil(bm_, 2) & ~mask
|
||||
if bin_.any() and br_.any():
|
||||
a_ = A[bin_].mean(axis=0)
|
||||
r_ = A[br_].mean(axis=0)
|
||||
print(f" blob{bi}: {len(cells):7d} px uv~({cx.mean()/w:.3f},"
|
||||
f"{cy.mean()/h:.3f}) offset {a_[0]-r_[0]:+.4f} "
|
||||
f"{a_[1]-r_[1]:+.4f} {a_[2]-r_[2]:+.4f} luma "
|
||||
f"{a_.mean()-r_.mean():+.4f}")
|
||||
np.save(os.path.join(SCRATCH, "patch_mask.npy"), mask)
|
||||
log(f"saved patch_mask.npy ({int(mask.sum())} texels)")
|
||||
else:
|
||||
print(f"DISCOLOUR: size mismatch {w}x{h} vs {w2}x{h2}")
|
||||
print("DIAG_DONE")
|
||||
@@ -0,0 +1,45 @@
|
||||
# Which image datablocks exist, and which ones the body material actually SAMPLES.
|
||||
# blender --background --python 16b_images.py -- <blend> [<blend2> ...]
|
||||
import bpy, sys
|
||||
import numpy as np
|
||||
|
||||
for BLEND in sys.argv[sys.argv.index("--") + 1:]:
|
||||
bpy.ops.wm.open_mainfile(filepath=BLEND)
|
||||
print(f"\n===== {BLEND} =====")
|
||||
ob = max([o for o in bpy.data.objects if o.type == 'MESH'],
|
||||
key=lambda o: len(o.data.vertices))
|
||||
print(f"body '{ob.name}' {len(ob.data.vertices)}v materials="
|
||||
f"{[ms.material.name if ms.material else None for ms in ob.material_slots]}")
|
||||
for i in bpy.data.images:
|
||||
b = np.empty(i.size[0] * i.size[1] * 4, dtype=np.float32)
|
||||
try:
|
||||
i.pixels.foreach_get(b)
|
||||
m = b.reshape(-1, 4)[:, :3]
|
||||
stat = f"mean {m.mean(axis=0).round(4)} std {m.std(axis=0).round(4)}"
|
||||
except Exception as e:
|
||||
stat = f"(no pixels: {e})"
|
||||
print(f" IMG '{i.name}' {i.size[0]}x{i.size[1]} packed={bool(i.packed_file)} "
|
||||
f"file='{i.filepath}' {stat}")
|
||||
for ms in ob.material_slots:
|
||||
mat = ms.material
|
||||
if not mat or not mat.node_tree:
|
||||
continue
|
||||
print(f" MAT '{mat.name}':")
|
||||
bsdf = next((n for n in mat.node_tree.nodes if n.type == 'BSDF_PRINCIPLED'), None)
|
||||
for n in mat.node_tree.nodes:
|
||||
if n.type == 'TEX_IMAGE':
|
||||
tgt = []
|
||||
for o in n.outputs:
|
||||
for lk in o.links:
|
||||
tgt.append(f"{lk.to_node.name}.{lk.to_socket.name}")
|
||||
print(f" TEX_IMAGE node '{n.name}' image='{n.image.name if n.image else None}'"
|
||||
f" -> {tgt if tgt else 'UNCONNECTED'}")
|
||||
if bsdf:
|
||||
for sock in ("Base Color", "Normal", "Roughness", "Metallic"):
|
||||
if sock in bsdf.inputs:
|
||||
lk = bsdf.inputs[sock].links
|
||||
src = lk[0].from_node.name if lk else "(unlinked)"
|
||||
if lk and lk[0].from_node.type == 'TEX_IMAGE':
|
||||
src += f" image='{lk[0].from_node.image.name if lk[0].from_node.image else None}'"
|
||||
print(f" BSDF.{sock} <- {src}")
|
||||
print("IMAGES_DONE")
|
||||
@@ -0,0 +1,214 @@
|
||||
# Stage 16c (diagnosis only): settle whether the visible lines are TOPOLOGY or GEOMETRY, and
|
||||
# inventory the real holes. Stage 15 asserted "the two sides of every seam are disconnected
|
||||
# vertex runs"; stage 17's weld attempt on that premise tore the mesh (830 -> 24k boundary
|
||||
# edges), which is itself evidence the premise is wrong.
|
||||
#
|
||||
# blender --background --python 16c_topology.py -- <blend>
|
||||
#
|
||||
# Tests
|
||||
# 1. LOCAL EDGE LENGTH — the scale everything else must be judged against. A "twin at 0.8 mm"
|
||||
# means nothing until you know the mesh spacing is ~1.2 mm; at that scale a non-adjacent
|
||||
# vertex 0.8 mm away is just a 2-ring neighbour, not a crack. This is the control the earlier
|
||||
# split-panel test lacked.
|
||||
# 2. TRUE TWIN TEST — nearest vertex that is outside the 3-ring topological neighbourhood,
|
||||
# normalised by local edge length. A real crack gives a spike at ratio << 1; ordinary mesh
|
||||
# gives a distribution centred near/above 1.
|
||||
# 3. HOLE INVENTORY — boundary edges grouped into loops, with size and location, so filling can
|
||||
# be per-loop instead of one net (which is what produced 39k non-manifold edges).
|
||||
# 4. RELIEF SCALE-SPACE — for the kink verts, small-scale vs large-scale normal offset. A scan
|
||||
# seam is thin (small-scale relief, no large-scale relief); real anatomy (underbust fold,
|
||||
# gluteal fold, clavicle) has both. This is the discriminator a heal mask must use so it
|
||||
# erases seams without erasing her.
|
||||
import bpy, bmesh, sys, time
|
||||
import numpy as np
|
||||
from mathutils import Vector
|
||||
from mathutils.kdtree import KDTree
|
||||
from collections import deque
|
||||
|
||||
argv = sys.argv[sys.argv.index("--") + 1:]
|
||||
BLEND = argv[0]
|
||||
t0 = time.time()
|
||||
UNIT_MM = 1815.0 # 1 mesh unit = 1.815 m (body is 0.979 units for 1.777 m)
|
||||
|
||||
|
||||
def log(m):
|
||||
print(f"[topo {time.time()-t0:6.1f}s] {m}", flush=True)
|
||||
|
||||
|
||||
bpy.ops.wm.open_mainfile(filepath=BLEND)
|
||||
ob = max([o for o in bpy.data.objects if o.type == 'MESH'],
|
||||
key=lambda o: len(o.data.vertices))
|
||||
me = ob.data
|
||||
n_v = len(me.vertices)
|
||||
co = np.empty(n_v * 3)
|
||||
me.vertices.foreach_get("co", co)
|
||||
co = co.reshape(-1, 3)
|
||||
nrm = np.empty(n_v * 3)
|
||||
me.vertices.foreach_get("normal", nrm)
|
||||
nrm = nrm.reshape(-1, 3)
|
||||
ev = np.empty(len(me.edges) * 2, dtype=np.int32)
|
||||
me.edges.foreach_get("vertices", ev)
|
||||
ev = ev.reshape(-1, 2)
|
||||
log(f"{n_v}v {len(me.polygons)}f {len(me.edges)}e")
|
||||
|
||||
# ---- 1. local edge length ----
|
||||
elen = np.linalg.norm(co[ev[:, 0]] - co[ev[:, 1]], axis=1)
|
||||
acc = np.zeros(n_v)
|
||||
cntv = np.zeros(n_v)
|
||||
np.add.at(acc, ev[:, 0], elen)
|
||||
np.add.at(acc, ev[:, 1], elen)
|
||||
np.add.at(cntv, ev[:, 0], 1.0)
|
||||
np.add.at(cntv, ev[:, 1], 1.0)
|
||||
L = acc / np.maximum(cntv, 1)
|
||||
print(f"EDGE LENGTH mesh units: mean {elen.mean():.6f} ({elen.mean()*UNIT_MM:.2f} real mm) "
|
||||
f"p05 {np.percentile(elen,5):.6f} p95 {np.percentile(elen,95):.6f}")
|
||||
|
||||
order = np.concatenate([ev[:, 0], ev[:, 1]])
|
||||
nbr = np.concatenate([ev[:, 1], ev[:, 0]])
|
||||
srt = np.argsort(order, kind="stable")
|
||||
o_s, n_s = order[srt], nbr[srt]
|
||||
ptr = np.searchsorted(o_s, np.arange(n_v + 1))
|
||||
cnt = np.maximum(np.diff(ptr), 1)
|
||||
|
||||
|
||||
def nbr_mean(X):
|
||||
a = np.add.reduceat(X[n_s], ptr[:-1], axis=0)
|
||||
a[np.diff(ptr) == 0] = X[np.diff(ptr) == 0]
|
||||
return a / cnt[:, None]
|
||||
|
||||
|
||||
N = nrm.copy()
|
||||
for _ in range(5):
|
||||
N = nbr_mean(N)
|
||||
N /= np.maximum(np.linalg.norm(N, axis=1, keepdims=True), 1e-12)
|
||||
ang = np.degrees(np.arccos(np.clip((nrm * N).sum(axis=1), -1, 1)))
|
||||
torso = (co[:, 2] > 0.28) & (co[:, 2] < 0.90)
|
||||
kink = torso & (ang > 12.0)
|
||||
log(f"kink>12deg in torso: {kink.sum()}")
|
||||
|
||||
# ---- 2. true twin test (outside the 3-ring) ----
|
||||
kidx = np.nonzero(kink)[0]
|
||||
sample = kidx[::max(1, len(kidx) // 3000)]
|
||||
ctrl = np.nonzero(torso & (ang < 3.0))[0]
|
||||
ctrl = ctrl[::max(1, len(ctrl) // 3000)] # control: quiet skin, same test
|
||||
kd = KDTree(n_v)
|
||||
for i in range(n_v):
|
||||
kd.insert(Vector(co[i]), i)
|
||||
kd.balance()
|
||||
adj = {}
|
||||
for a, b in ev:
|
||||
adj.setdefault(int(a), set()).add(int(b))
|
||||
adj.setdefault(int(b), set()).add(int(a))
|
||||
|
||||
|
||||
def ring3(i):
|
||||
seen = {i}
|
||||
frontier = {i}
|
||||
for _ in range(3):
|
||||
nxt = set()
|
||||
for v in frontier:
|
||||
nxt |= adj.get(v, set())
|
||||
nxt -= seen
|
||||
seen |= nxt
|
||||
frontier = nxt
|
||||
return seen
|
||||
|
||||
|
||||
def twin_ratio(idxs):
|
||||
out = []
|
||||
for i in idxs:
|
||||
excl = ring3(int(i))
|
||||
best = None
|
||||
for (_, j, d) in kd.find_range(Vector(co[i]), L[i] * 2.0):
|
||||
if int(j) in excl:
|
||||
continue
|
||||
if float(nrm[i] @ nrm[j]) < 0.0:
|
||||
continue # opposite-facing surface (thigh vs thigh) is not a seam twin
|
||||
if best is None or d < best:
|
||||
best = d
|
||||
out.append(best / L[i] if best is not None else np.nan)
|
||||
return np.array(out)
|
||||
|
||||
|
||||
tr_k = twin_ratio(sample)
|
||||
tr_c = twin_ratio(ctrl)
|
||||
log("twin test done")
|
||||
for nm, tr in (("KINK verts", tr_k), ("CONTROL quiet skin", tr_c)):
|
||||
v = tr[~np.isnan(tr)]
|
||||
print(f"TWIN-RATIO {nm}: n={len(v)}/{len(tr)} "
|
||||
f"p05={np.percentile(v,5):.2f} p25={np.percentile(v,25):.2f} "
|
||||
f"median={np.median(v):.2f} p75={np.percentile(v,75):.2f}"
|
||||
if len(v) else f"TWIN-RATIO {nm}: no hits")
|
||||
if len(v):
|
||||
print(f" fraction with a non-3-ring vertex closer than 0.35x edge length: "
|
||||
f"{100.0*(v<0.35).mean():.1f}% (<0.6x: {100.0*(v<0.6).mean():.1f}%)")
|
||||
|
||||
# ---- 3. hole inventory ----
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(me)
|
||||
open_e = [e for e in bm.edges if len(e.link_faces) == 1]
|
||||
print(f"\nHOLES: {len(open_e)} boundary edges, "
|
||||
f"{len([e for e in bm.edges if len(e.link_faces) > 2])} non-manifold edges")
|
||||
eset = set(e.index for e in open_e)
|
||||
emap = {e.index: e for e in open_e}
|
||||
seen = set()
|
||||
loops = []
|
||||
for e in open_e:
|
||||
if e.index in seen:
|
||||
continue
|
||||
q = deque([e.index])
|
||||
seen.add(e.index)
|
||||
comp = []
|
||||
while q:
|
||||
ei = q.popleft()
|
||||
cur = emap[ei]
|
||||
comp.append(cur)
|
||||
for v in cur.verts:
|
||||
for e2 in v.link_edges:
|
||||
if e2.index in eset and e2.index not in seen:
|
||||
seen.add(e2.index)
|
||||
q.append(e2.index)
|
||||
loops.append(comp)
|
||||
loops.sort(key=len, reverse=True)
|
||||
print(f"HOLES: {len(loops)} separate boundary loops")
|
||||
for i, lp in enumerate(loops[:14]):
|
||||
zs = [v.co.z for e in lp for v in e.verts]
|
||||
xs = [v.co.x for e in lp for v in e.verts]
|
||||
ys = [v.co.y for e in lp for v in e.verts]
|
||||
print(f" loop{i:2d}: {len(lp):5d} edges z {min(zs):.3f}-{max(zs):.3f} "
|
||||
f"x {min(xs):+.3f}..{max(xs):+.3f} y {min(ys):+.3f}..{max(ys):+.3f}")
|
||||
small = [lp for lp in loops if len(lp) <= 60]
|
||||
print(f"HOLES: {len(small)} loops <=60 edges (safe per-loop fills), "
|
||||
f"{len(loops)-len(small)} larger")
|
||||
bm.free()
|
||||
|
||||
# ---- 4. relief scale-space ----
|
||||
def smooth_n(X, k):
|
||||
Y = X.copy()
|
||||
for _ in range(k):
|
||||
Y = nbr_mean(Y)
|
||||
return Y
|
||||
|
||||
|
||||
sm_s = smooth_n(co, 8)
|
||||
sm_l = smooth_n(co, 60)
|
||||
dev_s = ((co - sm_s) * N).sum(axis=1)
|
||||
dev_l = ((co - sm_l) * N).sum(axis=1)
|
||||
print("\nRELIEF SCALE-SPACE (real mm along the smooth normal)")
|
||||
for nm, m in (("kink>12deg", kink),
|
||||
("quiet skin", torso & (ang < 3.0))):
|
||||
if not m.sum():
|
||||
continue
|
||||
print(f" {nm}: |small-scale| median {np.median(np.abs(dev_s[m]))*UNIT_MM:.3f} mm, "
|
||||
f"p95 {np.percentile(np.abs(dev_s[m]),95)*UNIT_MM:.3f} mm | "
|
||||
f"|large-scale| median {np.median(np.abs(dev_l[m]))*UNIT_MM:.3f} mm, "
|
||||
f"p95 {np.percentile(np.abs(dev_l[m]),95)*UNIT_MM:.3f} mm")
|
||||
band = np.abs(dev_s) * UNIT_MM
|
||||
bandpass = torso & (band > 0.35) & (np.abs(dev_l) * UNIT_MM < 1.6)
|
||||
print(f" BAND-PASS candidate mask (thin relief >0.35 mm, broad relief <1.6 mm): "
|
||||
f"{int(bandpass.sum())} verts")
|
||||
hist, edges = np.histogram(co[bandpass, 2], bins=14)
|
||||
for c, lo, hi in zip(hist, edges[:-1], edges[1:]):
|
||||
if c > 50:
|
||||
print(f" z {lo:.3f}-{hi:.3f}: {c:6d}")
|
||||
print("TOPO_DONE")
|
||||
@@ -0,0 +1,291 @@
|
||||
# Stage 17: heal the cut/scan lines — pinhole fill + thin-relief membrane. NO topology weld.
|
||||
#
|
||||
# blender --background --python 17_line_heal.py -- <in.blend> <out.blend> [rounds]
|
||||
#
|
||||
# WHAT THE MEASUREMENTS ACTUALLY SAY (16c_topology.py, and they contradict stages 15 + 17-weld)
|
||||
# * The lines are NOT cracks. For kink verts the nearest vertex outside the 3-ring sits at
|
||||
# 1.58x the local edge length (median); only 3.2% are closer than 0.35x, against 0.1% for
|
||||
# control skin. There is no disconnected-panel network to weld. Welding on that false premise
|
||||
# (eps 1.2 mm ~= the 1.85 mm mean edge length) merged ordinary neighbours and tore the mesh:
|
||||
# 830 -> 24k boundary edges. Do not try it again.
|
||||
# * The mesh is nearly closed: 830 boundary edges in 689 loops of 3-5 edges each — scattered
|
||||
# PINHOLES, which is what the dotted black dashes in clay renders are. There is no sternum
|
||||
# hole; that gash is a sharp crease (see 18_cleavage.py), not an opening.
|
||||
# * The lines are thin GEOMETRIC relief: along kink verts |offset from the locally smooth
|
||||
# surface| is 0.22 mm median / 1.55 mm p95, versus 0.05 mm on quiet skin.
|
||||
#
|
||||
# So: fill the pinholes per-loop (never as one edge net — one net is what produced 39k
|
||||
# non-manifold edges), then remove the thin relief with a collar-fixed bi-harmonic membrane.
|
||||
#
|
||||
# WHY A MEMBRANE AND NOT SMOOTHING. Taubin (stage 11) sheds high frequencies but PRESERVES low
|
||||
# ones, and a 1.5 mm ridge riding on a curved torso is not purely high-frequency — that is why
|
||||
# 30 Taubin pairs left the lines legible. A bi-harmonic membrane with two fixed collar rings
|
||||
# matches position AND slope at the band edge, so the broad shape (underbust fold, hip curve,
|
||||
# clavicle) is reproduced exactly while the thin ridge inside is replaced by the interpolant.
|
||||
#
|
||||
# WHY THE MASK IS SELF-DETECTED. Stages 11/14/15 all chose their masks by mapping the raw glb's
|
||||
# boundary verts onto the working mesh; by then the sculpt had moved those verts past the
|
||||
# tolerance, so most of the line network was never in the mask — the membranes were solving over
|
||||
# the wrong verts, which is the real reason "the lines survived every pass". Detection here runs
|
||||
# on the CURRENT geometry: very-small-scale relief (4-ring) AND a shading kink. Broad anatomy has
|
||||
# low relief at that scale by construction, so it is excluded automatically rather than by hand.
|
||||
import bpy, bmesh, sys, time
|
||||
import numpy as np
|
||||
|
||||
argv = sys.argv[sys.argv.index("--") + 1:]
|
||||
BLEND, OUT = argv[0], argv[1]
|
||||
ROUNDS = int(argv[2]) if len(argv) > 2 else 3
|
||||
t0 = time.time()
|
||||
|
||||
UNIT_MM = 1815.0 # 1 mesh unit = 1.815 m
|
||||
DEV_MM = 0.20 # thin-relief threshold, real mm
|
||||
KINK_DEG = 8.0
|
||||
GROW = 2 # band half-width, rings
|
||||
COLLAR = 2 # fixed rings outside the band (position + slope BC)
|
||||
Z_LO, Z_HI = 0.04, 0.90 # below the chin: the face keeps its own detail
|
||||
X_MAX = 0.36 # exclude hands/wrists
|
||||
PIN_MAX_EDGES = 8 # a "pinhole" — bigger openings are left for a human to look at
|
||||
CLAMP_MM = 2.5 # cap per-vertex displacement, real mm
|
||||
|
||||
# WIDTH IS SETTLED — DO NOT WIDEN THE BAND. 19_wide_heal.py measured the seam cross-section: the
|
||||
# disturbance is one vertex wide (|offset| 0.23 mm median at the centre, already at the 0.074 mm
|
||||
# background level by ring 2). Trials at GROW=4 and GROW=8 were no better than this narrow band
|
||||
# (kink>12deg 6674 and 6751 vs 6994) while moving material up to 38 mm. Narrow is correct.
|
||||
#
|
||||
# CLAMP_MM exists because the solver is free to do something dramatic wherever a band happens to
|
||||
# span a real feature rather than a seam — the unclamped run made a 17 mm excursion. Seam relief
|
||||
# tops out around 1.5 mm, so 2.5 mm is generous for the defect and still forbids reshaping her.
|
||||
|
||||
|
||||
def log(m):
|
||||
print(f"[line {time.time()-t0:6.1f}s] {m}", flush=True)
|
||||
|
||||
|
||||
bpy.ops.wm.open_mainfile(filepath=BLEND)
|
||||
ob = max([o for o in bpy.data.objects if o.type == 'MESH'],
|
||||
key=lambda o: len(o.data.vertices))
|
||||
me = ob.data
|
||||
log(f"in: {len(me.vertices)}v {len(me.polygons)}f")
|
||||
|
||||
# =============================================================================
|
||||
# 1. pinhole fill — per loop
|
||||
# =============================================================================
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(me)
|
||||
open_e = [e for e in bm.edges if len(e.link_faces) == 1]
|
||||
eset = {e.index for e in open_e}
|
||||
emap = {e.index: e for e in open_e}
|
||||
from collections import deque
|
||||
seen = set()
|
||||
loops = []
|
||||
for e in open_e:
|
||||
if e.index in seen:
|
||||
continue
|
||||
q = deque([e.index])
|
||||
seen.add(e.index)
|
||||
comp = []
|
||||
while q:
|
||||
cur = emap[q.popleft()]
|
||||
comp.append(cur)
|
||||
for v in cur.verts:
|
||||
for e2 in v.link_edges:
|
||||
if e2.index in eset and e2.index not in seen:
|
||||
seen.add(e2.index)
|
||||
q.append(e2.index)
|
||||
loops.append(comp)
|
||||
sizes = sorted((len(lp) for lp in loops), reverse=True)
|
||||
log(f"boundary loops: {len(loops)} (edges: {len(open_e)}, largest {sizes[:6]})")
|
||||
bm.free()
|
||||
|
||||
# bmesh.ops.holes_fill silently refused every one of these loops (830 -> 839 boundary edges, no
|
||||
# new faces), so use the edit-mode operator, which handles the small non-manifold fans these
|
||||
# pinholes actually are.
|
||||
bpy.context.view_layer.objects.active = ob
|
||||
ob.select_set(True)
|
||||
bpy.ops.object.mode_set(mode='EDIT')
|
||||
bpy.ops.mesh.select_all(action='DESELECT')
|
||||
bpy.ops.mesh.select_mode(type='EDGE')
|
||||
bpy.ops.mesh.select_non_manifold(extend=False, use_boundary=True, use_wire=True,
|
||||
use_multi_face=False, use_non_contiguous=False, use_verts=False)
|
||||
bpy.ops.mesh.fill_holes(sides=PIN_MAX_EDGES)
|
||||
bpy.ops.object.mode_set(mode='OBJECT')
|
||||
me.update()
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(me)
|
||||
n_open_after = len([e for e in bm.edges if len(e.link_faces) == 1])
|
||||
n_nm_after = len([e for e in bm.edges if len(e.link_faces) > 2])
|
||||
dgn = [f for f in bm.faces if f.calc_area() < 1e-12]
|
||||
if dgn:
|
||||
bmesh.ops.delete(bm, geom=dgn, context='FACES')
|
||||
bm.to_mesh(me)
|
||||
me.update()
|
||||
log(f"deleted {len(dgn)} degenerate faces")
|
||||
bm.free()
|
||||
log(f"after fill: {len(me.vertices)}v {len(me.polygons)}f "
|
||||
f"boundary_edges={n_open_after} nonmanifold={n_nm_after}")
|
||||
|
||||
# =============================================================================
|
||||
# 2. thin-relief membrane, re-detected each round
|
||||
# =============================================================================
|
||||
n_v = len(me.vertices)
|
||||
co = np.empty(n_v * 3)
|
||||
me.vertices.foreach_get("co", co)
|
||||
co = co.reshape(-1, 3)
|
||||
|
||||
ev = np.empty(len(me.edges) * 2, dtype=np.int32)
|
||||
me.edges.foreach_get("vertices", ev)
|
||||
ev = ev.reshape(-1, 2)
|
||||
order = np.concatenate([ev[:, 0], ev[:, 1]])
|
||||
nbr = np.concatenate([ev[:, 1], ev[:, 0]])
|
||||
srt = np.argsort(order, kind="stable")
|
||||
o_s, n_s = order[srt], nbr[srt]
|
||||
ptr = np.searchsorted(o_s, np.arange(n_v + 1))
|
||||
cnt = np.maximum(np.diff(ptr), 1)
|
||||
empty = np.diff(ptr) == 0
|
||||
|
||||
|
||||
def nbr_mean(X):
|
||||
a = np.add.reduceat(X[n_s], ptr[:-1], axis=0)
|
||||
a[empty] = X[empty]
|
||||
return a / cnt[:, None]
|
||||
|
||||
|
||||
def smooth_n(X, k):
|
||||
Y = X.copy()
|
||||
for _ in range(k):
|
||||
Y = nbr_mean(Y)
|
||||
return Y
|
||||
|
||||
|
||||
def grow(mask, rings):
|
||||
m = mask.copy()
|
||||
for _ in range(rings):
|
||||
hit = m[ev[:, 0]] | m[ev[:, 1]]
|
||||
m2 = m.copy()
|
||||
m2[ev[:, 0]] |= hit
|
||||
m2[ev[:, 1]] |= hit
|
||||
m = m2
|
||||
return m
|
||||
|
||||
|
||||
zone = (co[:, 2] > Z_LO) & (co[:, 2] < Z_HI) & (np.abs(co[:, 0]) < X_MAX)
|
||||
navel = (np.abs(co[:, 0]) < 0.022) & (co[:, 2] > 0.495) & (co[:, 2] < 0.555) & (co[:, 1] < 0)
|
||||
protect = navel
|
||||
log(f"zone {int(zone.sum())} verts, protected {int(protect.sum())}")
|
||||
|
||||
|
||||
def detect(P):
|
||||
"""Thin relief + shading kink, both measured on P."""
|
||||
nrm = np.empty(n_v * 3)
|
||||
me.vertices.foreach_get("normal", nrm)
|
||||
nrm = nrm.reshape(-1, 3)
|
||||
N = nrm.copy()
|
||||
for _ in range(5):
|
||||
N = nbr_mean(N)
|
||||
N /= np.maximum(np.linalg.norm(N, axis=1, keepdims=True), 1e-12)
|
||||
ang = np.degrees(np.arccos(np.clip((nrm * N).sum(axis=1), -1, 1)))
|
||||
dev = ((P - smooth_n(P, 4)) * N).sum(axis=1) * UNIT_MM # real mm, very local
|
||||
m = zone & ~protect & (np.abs(dev) > DEV_MM) & (ang > KINK_DEG)
|
||||
return m, ang, dev
|
||||
|
||||
|
||||
def bilaplacian_solve(P, free_m):
|
||||
"""Collar-fixed bi-harmonic membrane over free_m, solved with CG on the S-subgraph."""
|
||||
collar = grow(free_m, COLLAR) & ~free_m
|
||||
S = np.nonzero(free_m | collar)[0]
|
||||
in_S = np.zeros(n_v, dtype=bool)
|
||||
in_S[S] = True
|
||||
glb = np.full(n_v, -1, dtype=np.int64)
|
||||
glb[S] = np.arange(len(S))
|
||||
se = ev[in_S[ev].all(axis=1)]
|
||||
a_ = glb[se[:, 0]]
|
||||
b_ = glb[se[:, 1]]
|
||||
deg = np.zeros(len(S))
|
||||
np.add.at(deg, a_, 1.0)
|
||||
np.add.at(deg, b_, 1.0)
|
||||
free = free_m[S]
|
||||
|
||||
def Ls(X):
|
||||
out = deg[:, None] * X
|
||||
np.add.at(out, a_, -X[b_])
|
||||
np.add.at(out, b_, -X[a_])
|
||||
return out
|
||||
|
||||
def A_op(U):
|
||||
X = np.zeros((len(S), 3))
|
||||
X[free] = U
|
||||
return Ls(Ls(X))[free]
|
||||
|
||||
Xc = np.zeros((len(S), 3))
|
||||
Xc[~free] = P[S[~free]]
|
||||
rhs = -Ls(Ls(Xc))[free]
|
||||
U = P[S[free]].copy()
|
||||
r = rhs - A_op(U)
|
||||
p = r.copy()
|
||||
rs = (r * r).sum()
|
||||
rs0 = max(rs, 1e-30)
|
||||
it = 0
|
||||
for it in range(200000):
|
||||
Ap = A_op(p)
|
||||
den = (p * Ap).sum()
|
||||
if abs(den) < 1e-30:
|
||||
break
|
||||
al = rs / den
|
||||
U += al * p
|
||||
r -= al * Ap
|
||||
rs2 = (r * r).sum()
|
||||
if rs2 < 1e-20 or rs2 < rs0 * 1e-14:
|
||||
rs = rs2
|
||||
break
|
||||
p = r + (rs2 / rs) * p
|
||||
rs = rs2
|
||||
Q = P.copy()
|
||||
Q[S[free]] = U
|
||||
return Q, len(S), int(free.sum()), it, rs / rs0
|
||||
|
||||
|
||||
P = co.copy()
|
||||
for rnd in range(1, ROUNDS + 1):
|
||||
mask, ang, dev = detect(P)
|
||||
if not mask.sum():
|
||||
log(f"round {rnd}: nothing left to heal")
|
||||
break
|
||||
band = grow(mask, GROW) & zone & ~protect
|
||||
Q, ns, nf, it, rel = bilaplacian_solve(P, band)
|
||||
# clamp: a seam is <=1.5 mm of relief, so anything larger is the solver reshaping anatomy
|
||||
disp = Q - P
|
||||
dmag = np.linalg.norm(disp, axis=1) * UNIT_MM
|
||||
over = dmag > CLAMP_MM
|
||||
if over.any():
|
||||
disp[over] *= (CLAMP_MM / dmag[over])[:, None]
|
||||
Q = P + disp
|
||||
log(f"round {rnd}: clamped {int(over.sum())} verts to {CLAMP_MM} mm "
|
||||
f"(largest pre-clamp {dmag.max():.1f} mm)")
|
||||
d = np.linalg.norm(Q - P, axis=1) * UNIT_MM
|
||||
log(f"round {rnd}: seed {int(mask.sum())} -> band {nf} (S={ns}) CG it={it} rel={rel:.1e} "
|
||||
f"moved max {d.max():.3f} mm, median(band) {np.median(d[band]):.3f} mm")
|
||||
P = Q
|
||||
me.vertices.foreach_set("co", P.reshape(-1))
|
||||
me.update()
|
||||
if me.has_custom_normals:
|
||||
vn = np.empty(n_v * 3, dtype=np.float32)
|
||||
me.vertices.foreach_get("normal", vn)
|
||||
me.normals_split_custom_set_from_vertices(vn.reshape(-1, 3))
|
||||
m2, ang2, dev2 = detect(P)
|
||||
torso = (co[:, 2] > 0.28) & (co[:, 2] < 0.90)
|
||||
log(f" after: seed mask {int(m2.sum())}, kink>12deg {int((torso & (ang2 > 12)).sum())}, "
|
||||
f"kink>20deg {int((torso & (ang2 > 20)).sum())}")
|
||||
|
||||
# final normals + save
|
||||
me.vertices.foreach_set("co", P.reshape(-1))
|
||||
me.update()
|
||||
vn = np.empty(n_v * 3, dtype=np.float32)
|
||||
me.vertices.foreach_get("normal", vn)
|
||||
me.normals_split_custom_set_from_vertices(vn.reshape(-1, 3))
|
||||
d_tot = np.linalg.norm(P - co, axis=1) * UNIT_MM
|
||||
log(f"TOTAL displacement: max {d_tot.max():.3f} mm, "
|
||||
f"{int((d_tot > 0.05).sum())} verts moved >0.05 mm")
|
||||
bpy.context.preferences.filepaths.save_version = 0 # no .blend1 autosave
|
||||
bpy.ops.wm.save_as_mainfile(filepath=OUT)
|
||||
log(f"WROTE {OUT}")
|
||||
print("LINE_DONE")
|
||||
@@ -0,0 +1,272 @@
|
||||
# Stage 17: TRUE panel weld + hole closure, self-detected on the CURRENT mesh.
|
||||
#
|
||||
# blender --background --python 17_panel_weld.py -- <in.blend> <out.blend> [eps_mm_units]
|
||||
#
|
||||
# WHY THIS EXISTS (stage 15 already tried to weld and the lines survived)
|
||||
# Stage 15 chose its merge set by mapping the RAW glb's boundary verts onto the working mesh at
|
||||
# 2.5 mm. By then the sculpt + five membrane passes had moved those verts further than the
|
||||
# tolerance, so most of the panel network was never selected. Measured after stage 15
|
||||
# (16_diagnose): 58.7% of shading-kink verts STILL have a non-adjacent twin within 0.8 mm, i.e.
|
||||
# the two sides of each seam are separate vertex runs that shade independently. A crack survives
|
||||
# any amount of vertex MOVEMENT, which is why 11-14's membranes could not remove it.
|
||||
#
|
||||
# So this stage never consults the raw mesh. It finds the defect where it actually is:
|
||||
# candidates = shading-kink verts ∪ boundary verts, grown 2 rings
|
||||
# a PAIR is two candidates within eps that are NOT edge-adjacent and whose normals agree
|
||||
# (dot > 0.5). The normal test is what makes this safe: two sides of one seam face the same
|
||||
# way, whereas two surfaces that merely come close (inner thighs, armpit) face opposite ways
|
||||
# and are never paired.
|
||||
# pairs -> union-find -> bmesh.ops.weld_verts with an explicit targetmap.
|
||||
# weld_verts (not remove_doubles) because the targetmap is exact: only vertices this script has
|
||||
# validated get merged, so no collateral merge is possible inside the eps ball.
|
||||
#
|
||||
# Loops keep their own UVs through a weld, so the baked atlas is unaffected — a welded vertex
|
||||
# simply carries two UV corners, which is what every UV seam already is.
|
||||
#
|
||||
# Then holes: the body should be watertight below the chin. The largest boundary cluster is the
|
||||
# bra-bow excision at the sternum (z 0.648-0.729, 329 edges) — that hole is the black gash that
|
||||
# reads as a broken cleavage. Filled with bmesh triangle_fill and smoothed by the next stage.
|
||||
# Head openings (z > HEAD_Z: mouth, eyes, nostrils) are left alone: they are supposed to be open.
|
||||
import bpy, bmesh, sys, time, math
|
||||
import numpy as np
|
||||
from mathutils import Vector
|
||||
from mathutils.kdtree import KDTree
|
||||
|
||||
argv = sys.argv[sys.argv.index("--") + 1:]
|
||||
BLEND, OUT = argv[0], argv[1]
|
||||
EPS = float(argv[2]) if len(argv) > 2 else 0.0012 # mesh units (~2.2 mm real: 1 unit=1.815 m)
|
||||
t0 = time.time()
|
||||
|
||||
KINK_DEG = 4.0 # generous: anything that could read as a line
|
||||
DEV_MIN = 0.00008 # or a small-scale bump/groove this deep (mesh units)
|
||||
NORM_DOT = 0.5 # same-facing test that makes the weld safe
|
||||
GROW = 2
|
||||
Z_LO, Z_HI = 0.04, 0.90 # below the chin, above the soles
|
||||
X_MAX = 0.36 # excludes hands/wrists so fingers can never weld together
|
||||
HEAD_Z = 0.90 # holes above this are real openings (mouth/eyes/nostrils)
|
||||
|
||||
|
||||
def log(m):
|
||||
print(f"[weld {time.time()-t0:6.1f}s] {m}", flush=True)
|
||||
|
||||
|
||||
bpy.ops.wm.open_mainfile(filepath=BLEND)
|
||||
ob = max([o for o in bpy.data.objects if o.type == 'MESH'],
|
||||
key=lambda o: len(o.data.vertices))
|
||||
me = ob.data
|
||||
n_v = len(me.vertices)
|
||||
log(f"in: {n_v}v {len(me.polygons)}f custom_normals={me.has_custom_normals}")
|
||||
|
||||
co = np.empty(n_v * 3)
|
||||
me.vertices.foreach_get("co", co)
|
||||
co = co.reshape(-1, 3)
|
||||
nrm = np.empty(n_v * 3)
|
||||
me.vertices.foreach_get("normal", nrm)
|
||||
nrm = nrm.reshape(-1, 3)
|
||||
|
||||
ev = np.empty(len(me.edges) * 2, dtype=np.int32)
|
||||
me.edges.foreach_get("vertices", ev)
|
||||
ev = ev.reshape(-1, 2)
|
||||
order = np.concatenate([ev[:, 0], ev[:, 1]])
|
||||
nbr = np.concatenate([ev[:, 1], ev[:, 0]])
|
||||
srt = np.argsort(order, kind="stable")
|
||||
o_s, n_s = order[srt], nbr[srt]
|
||||
ptr = np.searchsorted(o_s, np.arange(n_v + 1))
|
||||
cnt = np.maximum(np.diff(ptr), 1)
|
||||
|
||||
|
||||
def nbr_mean(X):
|
||||
acc = np.add.reduceat(X[n_s], ptr[:-1], axis=0)
|
||||
acc[np.diff(ptr) == 0] = X[np.diff(ptr) == 0]
|
||||
return acc / cnt[:, None]
|
||||
|
||||
|
||||
# ---- candidates: shading kinks + small-scale relief + existing boundary ----
|
||||
N = nrm.copy()
|
||||
for _ in range(5):
|
||||
N = nbr_mean(N)
|
||||
N /= np.maximum(np.linalg.norm(N, axis=1, keepdims=True), 1e-12)
|
||||
ang = np.degrees(np.arccos(np.clip((nrm * N).sum(axis=1), -1, 1)))
|
||||
sm = co.copy()
|
||||
for _ in range(12):
|
||||
sm = nbr_mean(sm)
|
||||
dev = np.abs(((co - sm) * N).sum(axis=1))
|
||||
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(me)
|
||||
bm.verts.ensure_lookup_table()
|
||||
bnd_v = np.zeros(n_v, dtype=bool)
|
||||
for e in bm.edges:
|
||||
if len(e.link_faces) == 1:
|
||||
bnd_v[e.verts[0].index] = True
|
||||
bnd_v[e.verts[1].index] = True
|
||||
n_bnd0 = len([e for e in bm.edges if len(e.link_faces) == 1])
|
||||
n_nm0 = len([e for e in bm.edges if len(e.link_faces) > 2])
|
||||
log(f"before: boundary_edges={n_bnd0} nonmanifold_edges={n_nm0} boundary_verts={bnd_v.sum()}")
|
||||
|
||||
zone = (co[:, 2] > Z_LO) & (co[:, 2] < Z_HI) & (np.abs(co[:, 0]) < X_MAX)
|
||||
cand = zone & ((ang > KINK_DEG) | (dev > DEV_MIN) | bnd_v)
|
||||
for _ in range(GROW):
|
||||
hit = cand[ev[:, 0]] | cand[ev[:, 1]]
|
||||
c2 = cand.copy()
|
||||
c2[ev[:, 0]] |= hit
|
||||
c2[ev[:, 1]] |= hit
|
||||
cand = c2 & zone
|
||||
cidx = np.nonzero(cand)[0]
|
||||
log(f"candidates: {len(cidx)} verts (kink>{KINK_DEG}deg {(zone&(ang>KINK_DEG)).sum()}, "
|
||||
f"dev {(zone&(dev>DEV_MIN)).sum()}, boundary {(zone&bnd_v).sum()}, +{GROW} rings)")
|
||||
|
||||
# adjacency lookup restricted to candidates
|
||||
adj = {}
|
||||
sel_mask = cand
|
||||
mE = sel_mask[ev[:, 0]] & sel_mask[ev[:, 1]]
|
||||
for a, b in ev[mE]:
|
||||
adj.setdefault(int(a), set()).add(int(b))
|
||||
adj.setdefault(int(b), set()).add(int(a))
|
||||
|
||||
kd = KDTree(len(cidx))
|
||||
for j, i in enumerate(cidx):
|
||||
kd.insert(Vector(co[i]), j)
|
||||
kd.balance()
|
||||
log("KD built over candidates")
|
||||
|
||||
# ---- pair up twins ----
|
||||
pairs = []
|
||||
dists = []
|
||||
for j, i in enumerate(cidx):
|
||||
ai = adj.get(int(i), ())
|
||||
for (_, k, d) in kd.find_range(Vector(co[i]), EPS):
|
||||
o = int(cidx[k])
|
||||
if o <= int(i) or o in ai:
|
||||
continue
|
||||
if float(nrm[i] @ nrm[o]) < NORM_DOT:
|
||||
continue
|
||||
pairs.append((int(i), o))
|
||||
dists.append(d)
|
||||
log(f"pairs: {len(pairs)}")
|
||||
if dists:
|
||||
dh = np.array(dists)
|
||||
print("PAIR-DISTANCE histogram (mesh units, 1 unit = 1.815 m):")
|
||||
hist, edges = np.histogram(dh, bins=np.linspace(0, EPS, 9))
|
||||
for c, lo, hi in zip(hist, edges[:-1], edges[1:]):
|
||||
print(f" {lo*1000:5.3f}-{hi*1000:5.3f} mm-units: {c:7d} "
|
||||
f"({lo*1815:5.2f}-{hi*1815:5.2f} real mm)")
|
||||
|
||||
# ---- union-find ----
|
||||
parent = {}
|
||||
|
||||
|
||||
def find(x):
|
||||
parent.setdefault(x, x)
|
||||
while parent[x] != x:
|
||||
parent[x] = parent[parent[x]]
|
||||
x = parent[x]
|
||||
return x
|
||||
|
||||
|
||||
def union(a, b):
|
||||
ra, rb = find(a), find(b)
|
||||
if ra != rb:
|
||||
parent[min(ra, rb)] = min(ra, rb)
|
||||
parent[max(ra, rb)] = min(ra, rb)
|
||||
|
||||
|
||||
for a, b in pairs:
|
||||
union(a, b)
|
||||
clusters = {}
|
||||
for v in list(parent):
|
||||
clusters.setdefault(find(v), []).append(v)
|
||||
sizes = np.array([len(c) for c in clusters.values()])
|
||||
log(f"clusters: {len(clusters)} covering {int(sizes.sum())} verts "
|
||||
f"(max {sizes.max() if len(sizes) else 0}, mean {sizes.mean() if len(sizes) else 0:.2f})")
|
||||
|
||||
# guard: a huge cluster would mean the eps ball is chaining across a whole region
|
||||
if len(sizes) and sizes.max() > 40:
|
||||
log(f"WARNING: largest cluster {sizes.max()} verts — chaining suspected; "
|
||||
f"clusters >40 verts are SKIPPED")
|
||||
|
||||
targetmap = {}
|
||||
merged_verts = 0
|
||||
for root, members in clusters.items():
|
||||
if len(members) < 2 or len(members) > 40:
|
||||
continue
|
||||
members = sorted(members)
|
||||
keep = members[0]
|
||||
ctr = co[members].mean(axis=0)
|
||||
bm.verts[keep].co = Vector(ctr)
|
||||
for m in members[1:]:
|
||||
targetmap[bm.verts[m]] = bm.verts[keep]
|
||||
merged_verts += 1
|
||||
log(f"targetmap: merging {merged_verts} verts into {len(set(targetmap.values()))} survivors")
|
||||
|
||||
if targetmap:
|
||||
bmesh.ops.weld_verts(bm, targetmap=targetmap)
|
||||
log("weld_verts done")
|
||||
|
||||
# ---- close holes below the chin ----
|
||||
bm.edges.ensure_lookup_table()
|
||||
open_e = [e for e in bm.edges if len(e.link_faces) == 1]
|
||||
body_e = [e for e in open_e
|
||||
if max(v.co.z for v in e.verts) < HEAD_Z and min(v.co.z for v in e.verts) > Z_LO * 0.5]
|
||||
log(f"open edges after weld: {len(open_e)} total, {len(body_e)} below the chin -> filling")
|
||||
if body_e:
|
||||
res = bmesh.ops.triangle_fill(bm, use_beauty=True, use_dissolve=False, edges=body_e)
|
||||
log(f"triangle_fill created {len(res.get('geom', []))} elements")
|
||||
# anything still open: try holes_fill as a second pass
|
||||
bm.edges.ensure_lookup_table()
|
||||
still = [e for e in bm.edges if len(e.link_faces) == 1
|
||||
and max(v.co.z for v in e.verts) < HEAD_Z]
|
||||
if still:
|
||||
bmesh.ops.holes_fill(bm, edges=still, sides=0)
|
||||
log(f"holes_fill on {len(still)} remaining open edges")
|
||||
|
||||
bm.edges.ensure_lookup_table()
|
||||
n_bnd1 = len([e for e in bm.edges if len(e.link_faces) == 1])
|
||||
n_nm1 = len([e for e in bm.edges if len(e.link_faces) > 2])
|
||||
dgn = [f for f in bm.faces if f.calc_area() < 1e-12]
|
||||
if dgn:
|
||||
bmesh.ops.delete(bm, geom=dgn, context='FACES')
|
||||
log(f"deleted {len(dgn)} degenerate faces")
|
||||
bm.to_mesh(me)
|
||||
bm.free()
|
||||
me.update()
|
||||
n_after = len(me.vertices)
|
||||
log(f"after: {n_after}v (-{n_v - n_after}) boundary_edges={n_bnd1} nonmanifold_edges={n_nm1}")
|
||||
|
||||
# ---- shared vertex normals across the now-shared seams ----
|
||||
vn = np.empty(n_after * 3, dtype=np.float32)
|
||||
me.vertices.foreach_get("normal", vn)
|
||||
me.normals_split_custom_set_from_vertices(vn.reshape(-1, 3))
|
||||
log("custom split normals reset from vertex normals")
|
||||
|
||||
# ---- re-measure the lines ----
|
||||
co2 = np.empty(n_after * 3)
|
||||
me.vertices.foreach_get("co", co2)
|
||||
co2 = co2.reshape(-1, 3)
|
||||
nr2 = np.empty(n_after * 3)
|
||||
me.vertices.foreach_get("normal", nr2)
|
||||
nr2 = nr2.reshape(-1, 3)
|
||||
ev2 = np.empty(len(me.edges) * 2, dtype=np.int32)
|
||||
me.edges.foreach_get("vertices", ev2)
|
||||
ev2 = ev2.reshape(-1, 2)
|
||||
o2 = np.concatenate([ev2[:, 0], ev2[:, 1]])
|
||||
n2 = np.concatenate([ev2[:, 1], ev2[:, 0]])
|
||||
s2 = np.argsort(o2, kind="stable")
|
||||
o2s, n2s = o2[s2], n2[s2]
|
||||
p2 = np.searchsorted(o2s, np.arange(n_after + 1))
|
||||
c2 = np.maximum(np.diff(p2), 1)
|
||||
N2 = nr2.copy()
|
||||
for _ in range(5):
|
||||
acc = np.add.reduceat(N2[n2s], p2[:-1], axis=0)
|
||||
acc[np.diff(p2) == 0] = N2[np.diff(p2) == 0]
|
||||
N2 = acc / c2[:, None]
|
||||
N2 /= np.maximum(np.linalg.norm(N2, axis=1, keepdims=True), 1e-12)
|
||||
ang2 = np.degrees(np.arccos(np.clip((nr2 * N2).sum(axis=1), -1, 1)))
|
||||
torso2 = (co2[:, 2] > 0.28) & (co2[:, 2] < 0.90)
|
||||
for thr in (8.0, 12.0, 20.0):
|
||||
print(f"KINK >{thr:4.1f}deg after weld: {int((torso2 & (ang2 > thr)).sum()):6d} verts")
|
||||
|
||||
bpy.context.preferences.filepaths.save_version = 0 # no .blend1 autosave
|
||||
bpy.ops.wm.save_as_mainfile(filepath=OUT)
|
||||
log(f"WROTE {OUT}")
|
||||
print("WELD_DONE")
|
||||
@@ -0,0 +1,152 @@
|
||||
# Stage 18: is the line network SHADING (custom split normals) rather than shape?
|
||||
#
|
||||
# blender --background --python 18_normal_probe.py -- <in.blend> <out.blend> <review_dir>
|
||||
#
|
||||
# THE SUSPICION. Every stage from 11 to 17 moved vertices along the seam network and the lines
|
||||
# did not change; 17's membrane moved 12.6k verts for no visible difference, and 16c proved there
|
||||
# is no crack to weld. What survives vertex movement is SHADING: this mesh carries custom split
|
||||
# normals from the Tripo import (has_custom_normals=True). If those baked normals encode the scan
|
||||
# panel borders as creases, the lines are painted into the normals and geometry work cannot touch
|
||||
# them — which would explain the whole failure streak at once, including why the lines appear in
|
||||
# clay renders (clay swaps the MATERIAL, so no normal map is involved, but custom split normals
|
||||
# belong to the MESH and survive the swap).
|
||||
#
|
||||
# Note every prior stage ended with normals_split_custom_set_from_vertices(...), which SHOULD have
|
||||
# smoothed them. This probe measures whether that actually took effect, then clears the custom
|
||||
# normals outright and re-renders. Measurement first, then the change, so we learn which it was.
|
||||
import bpy, sys, os, math, time
|
||||
import numpy as np
|
||||
from mathutils import Vector
|
||||
|
||||
argv = sys.argv[sys.argv.index("--") + 1:]
|
||||
BLEND, OUT, REVIEW = argv[0], argv[1], argv[2]
|
||||
os.makedirs(REVIEW, exist_ok=True)
|
||||
t0 = time.time()
|
||||
|
||||
|
||||
def log(m):
|
||||
print(f"[nrm {time.time()-t0:6.1f}s] {m}", flush=True)
|
||||
|
||||
|
||||
bpy.ops.wm.open_mainfile(filepath=BLEND)
|
||||
ob = max([o for o in bpy.data.objects if o.type == 'MESH'],
|
||||
key=lambda o: len(o.data.vertices))
|
||||
me = ob.data
|
||||
n_v = len(me.vertices)
|
||||
n_l = len(me.loops)
|
||||
log(f"{ob.name}: {n_v}v {len(me.polygons)}f {n_l} loops "
|
||||
f"has_custom_normals={me.has_custom_normals}")
|
||||
|
||||
flat = np.empty(len(me.polygons), dtype=bool)
|
||||
me.polygons.foreach_get("use_smooth", flat)
|
||||
log(f"flat-shaded polygons: {int((~flat).sum())} of {len(flat)}")
|
||||
|
||||
# ---- how far do the corner normals deviate from the smooth vertex normals? ----
|
||||
vn = np.empty(n_v * 3)
|
||||
me.vertices.foreach_get("normal", vn)
|
||||
vn = vn.reshape(-1, 3)
|
||||
lv = np.empty(n_l, dtype=np.int32)
|
||||
me.loops.foreach_get("vertex_index", lv)
|
||||
cn = np.empty(n_l * 3)
|
||||
try:
|
||||
me.corner_normals.foreach_get("vector", cn)
|
||||
cn = cn.reshape(-1, 3)
|
||||
dot = np.clip((cn * vn[lv]).sum(axis=1), -1, 1)
|
||||
dev = np.degrees(np.arccos(dot))
|
||||
print(f"CORNER-vs-VERTEX normal deviation: mean {dev.mean():.3f}deg "
|
||||
f"p50 {np.percentile(dev,50):.3f} p95 {np.percentile(dev,95):.3f} "
|
||||
f"p99.9 {np.percentile(dev,99.9):.3f} max {dev.max():.3f}")
|
||||
print(f" loops deviating >5deg: {int((dev>5).sum())} ({100.0*(dev>5).mean():.3f}%)")
|
||||
print(f" loops deviating >15deg: {int((dev>15).sum())} ({100.0*(dev>15).mean():.3f}%)")
|
||||
# per-vertex spread between its own corner normals = a shading crease at that vertex
|
||||
spread = np.zeros(n_v)
|
||||
np.maximum.at(spread, lv, dev)
|
||||
torso = None
|
||||
co = np.empty(n_v * 3)
|
||||
me.vertices.foreach_get("co", co)
|
||||
co = co.reshape(-1, 3)
|
||||
torso = (co[:, 2] > 0.28) & (co[:, 2] < 0.90)
|
||||
print(f" torso verts whose corner normals deviate >10deg from smooth: "
|
||||
f"{int((torso & (spread > 10)).sum())}")
|
||||
except Exception as ex:
|
||||
log(f"corner_normals read failed: {ex}")
|
||||
|
||||
# =============================================================================
|
||||
# clear the custom split normals and force smooth shading
|
||||
# =============================================================================
|
||||
had = me.has_custom_normals
|
||||
bpy.context.view_layer.objects.active = ob
|
||||
ob.select_set(True)
|
||||
cleared = False
|
||||
try:
|
||||
bpy.ops.mesh.customdata_custom_splitnormals_clear()
|
||||
cleared = True
|
||||
except Exception as ex:
|
||||
log(f"operator clear failed: {ex}")
|
||||
if not cleared:
|
||||
for nm in ("custom_normal",):
|
||||
if nm in me.attributes:
|
||||
me.attributes.remove(me.attributes[nm])
|
||||
cleared = True
|
||||
log(f"removed attribute '{nm}'")
|
||||
sm = np.ones(len(me.polygons), dtype=bool)
|
||||
me.polygons.foreach_set("use_smooth", sm)
|
||||
me.update()
|
||||
log(f"custom normals: had={had} now has_custom_normals={me.has_custom_normals} cleared={cleared}")
|
||||
|
||||
# =============================================================================
|
||||
# clay renders, same framing as 04_review so they compare 1:1
|
||||
# =============================================================================
|
||||
scn = bpy.context.scene
|
||||
w = bpy.data.worlds.new("W")
|
||||
w.color = (0.22, 0.22, 0.24)
|
||||
scn.world = w
|
||||
key = bpy.data.objects.new("Key", bpy.data.lights.new("Key", 'SUN'))
|
||||
key.data.energy = 3.0
|
||||
key.data.use_shadow = False
|
||||
bpy.context.collection.objects.link(key)
|
||||
fill = bpy.data.objects.new("Fill", bpy.data.lights.new("Fill", 'SUN'))
|
||||
fill.data.energy = 1.0
|
||||
fill.data.use_shadow = False
|
||||
bpy.context.collection.objects.link(fill)
|
||||
cam = bpy.data.objects.new("Cam", bpy.data.cameras.new("Cam"))
|
||||
cam.data.lens = 85
|
||||
bpy.context.collection.objects.link(cam)
|
||||
scn.camera = cam
|
||||
scn.render.engine = 'BLENDER_EEVEE' if bpy.app.version >= (4, 2) else 'BLENDER_EEVEE_NEXT'
|
||||
scn.render.resolution_x = scn.render.resolution_y = 1000
|
||||
|
||||
clay = bpy.data.materials.new("Clay")
|
||||
clay.use_nodes = True
|
||||
clay.node_tree.nodes["Principled BSDF"].inputs["Base Color"].default_value = (0.62, 0.60, 0.58, 1)
|
||||
clay.node_tree.nodes["Principled BSDF"].inputs["Roughness"].default_value = 0.45
|
||||
orig = [ms.material for ms in ob.material_slots]
|
||||
|
||||
|
||||
def shoot(tag, ctr, span, yaw_deg, use_clay):
|
||||
for i, ms in enumerate(ob.material_slots):
|
||||
ms.material = clay if use_clay else orig[i]
|
||||
yaw = math.radians(yaw_deg)
|
||||
dist = span * 3.0
|
||||
cam.location = Vector(ctr) + Vector((math.sin(yaw) * dist, -math.cos(yaw) * dist, 0.02))
|
||||
cam.rotation_euler = (Vector(ctr) - cam.location).to_track_quat('-Z', 'Y').to_euler()
|
||||
key.rotation_euler = (math.radians(62), 0, math.radians(35 + yaw_deg))
|
||||
fill.rotation_euler = (math.radians(75), 0, math.radians(yaw_deg - 110))
|
||||
scn.render.filepath = os.path.abspath(os.path.join(REVIEW, f"{tag}.png"))
|
||||
bpy.ops.render.render(write_still=True)
|
||||
log(f"render {tag}")
|
||||
|
||||
|
||||
CHEST = (0.0, 0.0, 0.675)
|
||||
FULL = (0.0, 0.0, 0.50)
|
||||
HIP = (0.0, 0.0, 0.53)
|
||||
shoot("chest_clay_0", CHEST, 0.22, 0, True)
|
||||
shoot("chest_clay_40", CHEST, 0.22, 40, True)
|
||||
shoot("chest_tex_0", CHEST, 0.22, 0, False)
|
||||
shoot("hip_clay_0", HIP, 0.22, 0, True)
|
||||
shoot("full_clay_0", FULL, 0.55, 0, True)
|
||||
|
||||
bpy.context.preferences.filepaths.save_version = 0 # no .blend1 autosave
|
||||
bpy.ops.wm.save_as_mainfile(filepath=OUT)
|
||||
log(f"WROTE {OUT}")
|
||||
print("NRM_DONE")
|
||||
@@ -0,0 +1,259 @@
|
||||
# Stage 19: measure the seam's cross-section, then trial the heal at several band widths.
|
||||
#
|
||||
# blender --background --python 19_wide_heal.py -- <in.blend> <review_root> [widths]
|
||||
#
|
||||
# WHY WIDTH IS THE WHOLE QUESTION. Established so far: the lines are not cracks (16c), not
|
||||
# painted into the custom normals (18 — corner-vs-vertex deviation is 0.008 deg mean), and a
|
||||
# 5-vertex-wide collar-fixed membrane moves 12.6k verts without changing the render (17).
|
||||
# The remaining reading is that a Tripo panel border is a STEP — the reconstruction's two charts
|
||||
# meet with a sub-millimetre offset, a C0 discontinuity — rather than a ridge sitting on smooth
|
||||
# skin. A narrow band cannot fix a step, because the fixed collar lands on the step's own
|
||||
# shoulders and the interpolant faithfully reproduces the offset it is pinned to. Removing a step
|
||||
# means spreading it over a wide enough neighbourhood that the residual curvature falls below
|
||||
# visibility.
|
||||
#
|
||||
# So this stage MEASURES first: |offset from a broadly smoothed surface| as a function of ring
|
||||
# distance from the seam. That profile says how wide the disturbance really is, and therefore how
|
||||
# wide the band must be. Then it renders the heal at several widths so the choice is made from
|
||||
# pictures rather than from theory. Nothing is saved — this is an experiment; the winning width
|
||||
# gets applied in the next stage.
|
||||
import bpy, sys, os, math, time
|
||||
import numpy as np
|
||||
from mathutils import Vector
|
||||
|
||||
argv = sys.argv[sys.argv.index("--") + 1:]
|
||||
BLEND, ROOT = argv[0], argv[1]
|
||||
WIDTHS = [int(x) for x in argv[2].split(",")] if len(argv) > 2 else [4, 8, 12]
|
||||
t0 = time.time()
|
||||
|
||||
UNIT_MM = 1815.0
|
||||
KINK_DEG = 6.0
|
||||
COLLAR = 3
|
||||
Z_LO, Z_HI = 0.04, 0.90
|
||||
X_MAX = 0.36
|
||||
|
||||
|
||||
def log(m):
|
||||
print(f"[wide {time.time()-t0:6.1f}s] {m}", flush=True)
|
||||
|
||||
|
||||
bpy.ops.wm.open_mainfile(filepath=BLEND)
|
||||
ob = max([o for o in bpy.data.objects if o.type == 'MESH'],
|
||||
key=lambda o: len(o.data.vertices))
|
||||
me = ob.data
|
||||
n_v = len(me.vertices)
|
||||
co = np.empty(n_v * 3)
|
||||
me.vertices.foreach_get("co", co)
|
||||
co = co.reshape(-1, 3)
|
||||
ev = np.empty(len(me.edges) * 2, dtype=np.int32)
|
||||
me.edges.foreach_get("vertices", ev)
|
||||
ev = ev.reshape(-1, 2)
|
||||
log(f"{n_v}v {len(me.polygons)}f")
|
||||
|
||||
order = np.concatenate([ev[:, 0], ev[:, 1]])
|
||||
nbr = np.concatenate([ev[:, 1], ev[:, 0]])
|
||||
srt = np.argsort(order, kind="stable")
|
||||
o_s, n_s = order[srt], nbr[srt]
|
||||
ptr = np.searchsorted(o_s, np.arange(n_v + 1))
|
||||
cnt = np.maximum(np.diff(ptr), 1)
|
||||
empty = np.diff(ptr) == 0
|
||||
|
||||
|
||||
def nbr_mean(X):
|
||||
a = np.add.reduceat(X[n_s], ptr[:-1], axis=0)
|
||||
a[empty] = X[empty]
|
||||
return a / cnt[:, None]
|
||||
|
||||
|
||||
def smooth_n(X, k):
|
||||
Y = X.copy()
|
||||
for _ in range(k):
|
||||
Y = nbr_mean(Y)
|
||||
return Y
|
||||
|
||||
|
||||
def grow(mask, rings):
|
||||
m = mask.copy()
|
||||
for _ in range(rings):
|
||||
hit = m[ev[:, 0]] | m[ev[:, 1]]
|
||||
m2 = m.copy()
|
||||
m2[ev[:, 0]] |= hit
|
||||
m2[ev[:, 1]] |= hit
|
||||
m = m2
|
||||
return m
|
||||
|
||||
|
||||
def kink_of(P):
|
||||
nrm = np.empty(n_v * 3)
|
||||
me.vertices.foreach_get("normal", nrm)
|
||||
nrm = nrm.reshape(-1, 3)
|
||||
N = nrm.copy()
|
||||
for _ in range(5):
|
||||
N = nbr_mean(N)
|
||||
N /= np.maximum(np.linalg.norm(N, axis=1, keepdims=True), 1e-12)
|
||||
return np.degrees(np.arccos(np.clip((nrm * N).sum(axis=1), -1, 1))), N
|
||||
|
||||
|
||||
zone = (co[:, 2] > Z_LO) & (co[:, 2] < Z_HI) & (np.abs(co[:, 0]) < X_MAX)
|
||||
navel = (np.abs(co[:, 0]) < 0.022) & (co[:, 2] > 0.495) & (co[:, 2] < 0.555) & (co[:, 1] < 0)
|
||||
ang, N = kink_of(co)
|
||||
seed = zone & ~navel & (ang > KINK_DEG)
|
||||
log(f"seed (kink>{KINK_DEG}deg): {int(seed.sum())} verts")
|
||||
|
||||
# =============================================================================
|
||||
# cross-section profile: |offset from broad smooth| vs ring distance from seed
|
||||
# =============================================================================
|
||||
sm40 = smooth_n(co, 40)
|
||||
sm12 = smooth_n(co, 12)
|
||||
dev40 = ((co - sm40) * N).sum(axis=1) * UNIT_MM
|
||||
dev12 = ((co - sm12) * N).sum(axis=1) * UNIT_MM
|
||||
|
||||
ring = np.full(n_v, -1, dtype=np.int32)
|
||||
ring[seed] = 0
|
||||
cur = seed.copy()
|
||||
for r in range(1, 16):
|
||||
nxt = grow(cur, 1) & ~cur & zone
|
||||
ring[nxt & (ring < 0)] = r
|
||||
cur = cur | nxt
|
||||
print("\nSEAM CROSS-SECTION (real mm, magnitudes; ring 0 = detected seam centre)")
|
||||
print(" ring n |dev12| med p90 |dev40| med p90")
|
||||
for r in range(0, 15):
|
||||
m = ring == r
|
||||
if m.sum() < 50:
|
||||
continue
|
||||
print(f" {r:4d} {int(m.sum()):8d} {np.median(np.abs(dev12[m])):7.3f} "
|
||||
f"{np.percentile(np.abs(dev12[m]),90):7.3f} "
|
||||
f"{np.median(np.abs(dev40[m])):7.3f} {np.percentile(np.abs(dev40[m]),90):7.3f}")
|
||||
far = zone & (ring < 0)
|
||||
if far.sum() > 50:
|
||||
print(f" far {int(far.sum()):8d} {np.median(np.abs(dev12[far])):7.3f} "
|
||||
f"{np.percentile(np.abs(dev12[far]),90):7.3f} "
|
||||
f"{np.median(np.abs(dev40[far])):7.3f} {np.percentile(np.abs(dev40[far]),90):7.3f}")
|
||||
|
||||
# =============================================================================
|
||||
# solver
|
||||
# =============================================================================
|
||||
def bilaplacian(P, free_m, collar_rings=COLLAR, maxit=6000):
|
||||
collar = grow(free_m, collar_rings) & ~free_m
|
||||
S = np.nonzero(free_m | collar)[0]
|
||||
in_S = np.zeros(n_v, dtype=bool)
|
||||
in_S[S] = True
|
||||
glb = np.full(n_v, -1, dtype=np.int64)
|
||||
glb[S] = np.arange(len(S))
|
||||
se = ev[in_S[ev].all(axis=1)]
|
||||
a_ = glb[se[:, 0]]
|
||||
b_ = glb[se[:, 1]]
|
||||
deg = np.zeros(len(S))
|
||||
np.add.at(deg, a_, 1.0)
|
||||
np.add.at(deg, b_, 1.0)
|
||||
free = free_m[S]
|
||||
|
||||
def Ls(X):
|
||||
out = deg[:, None] * X
|
||||
np.add.at(out, a_, -X[b_])
|
||||
np.add.at(out, b_, -X[a_])
|
||||
return out
|
||||
|
||||
def A_op(U):
|
||||
X = np.zeros((len(S), 3))
|
||||
X[free] = U
|
||||
return Ls(Ls(X))[free]
|
||||
|
||||
Xc = np.zeros((len(S), 3))
|
||||
Xc[~free] = P[S[~free]]
|
||||
rhs = -Ls(Ls(Xc))[free]
|
||||
U = P[S[free]].copy()
|
||||
r = rhs - A_op(U)
|
||||
p = r.copy()
|
||||
rs = (r * r).sum()
|
||||
rs0 = max(rs, 1e-30)
|
||||
it = 0
|
||||
for it in range(maxit):
|
||||
Ap = A_op(p)
|
||||
den = (p * Ap).sum()
|
||||
if abs(den) < 1e-30:
|
||||
break
|
||||
al = rs / den
|
||||
U += al * p
|
||||
r -= al * Ap
|
||||
rs2 = (r * r).sum()
|
||||
if rs2 < 1e-20 or rs2 < rs0 * 1e-13:
|
||||
rs = rs2
|
||||
break
|
||||
p = r + (rs2 / rs) * p
|
||||
rs = rs2
|
||||
Q = P.copy()
|
||||
Q[S[free]] = U
|
||||
return Q, int(free.sum()), it, rs / rs0
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# render helper (same framing/lighting as 04_review)
|
||||
# =============================================================================
|
||||
scn = bpy.context.scene
|
||||
wd = bpy.data.worlds.new("W")
|
||||
wd.color = (0.22, 0.22, 0.24)
|
||||
scn.world = wd
|
||||
key = bpy.data.objects.new("Key", bpy.data.lights.new("Key", 'SUN'))
|
||||
key.data.energy = 3.0
|
||||
key.data.use_shadow = False
|
||||
bpy.context.collection.objects.link(key)
|
||||
fl = bpy.data.objects.new("Fill", bpy.data.lights.new("Fill", 'SUN'))
|
||||
fl.data.energy = 1.0
|
||||
fl.data.use_shadow = False
|
||||
bpy.context.collection.objects.link(fl)
|
||||
cam = bpy.data.objects.new("Cam", bpy.data.cameras.new("Cam"))
|
||||
cam.data.lens = 85
|
||||
bpy.context.collection.objects.link(cam)
|
||||
scn.camera = cam
|
||||
scn.render.engine = 'BLENDER_EEVEE' if bpy.app.version >= (4, 2) else 'BLENDER_EEVEE_NEXT'
|
||||
scn.render.resolution_x = scn.render.resolution_y = 1000
|
||||
clay = bpy.data.materials.new("Clay")
|
||||
clay.use_nodes = True
|
||||
clay.node_tree.nodes["Principled BSDF"].inputs["Base Color"].default_value = (0.62, 0.60, 0.58, 1)
|
||||
clay.node_tree.nodes["Principled BSDF"].inputs["Roughness"].default_value = 0.45
|
||||
orig = [ms.material for ms in ob.material_slots]
|
||||
|
||||
|
||||
def shoot(outdir, tag, ctr, span, yaw_deg, use_clay=True):
|
||||
os.makedirs(outdir, exist_ok=True)
|
||||
for i, ms in enumerate(ob.material_slots):
|
||||
ms.material = clay if use_clay else orig[i]
|
||||
yaw = math.radians(yaw_deg)
|
||||
dist = span * 3.0
|
||||
cam.location = Vector(ctr) + Vector((math.sin(yaw) * dist, -math.cos(yaw) * dist, 0.02))
|
||||
cam.rotation_euler = (Vector(ctr) - cam.location).to_track_quat('-Z', 'Y').to_euler()
|
||||
key.rotation_euler = (math.radians(62), 0, math.radians(35 + yaw_deg))
|
||||
fl.rotation_euler = (math.radians(75), 0, math.radians(yaw_deg - 110))
|
||||
scn.render.filepath = os.path.abspath(os.path.join(outdir, f"{tag}.png"))
|
||||
bpy.ops.render.render(write_still=True)
|
||||
|
||||
|
||||
CHEST = (0.0, 0.0, 0.675)
|
||||
FULL = (0.0, 0.0, 0.50)
|
||||
HIP = (0.0, 0.0, 0.53)
|
||||
|
||||
for W in WIDTHS:
|
||||
band = grow(seed, W) & zone & ~navel
|
||||
Q, nf, it, rel = bilaplacian(co, band)
|
||||
d = np.linalg.norm(Q - co, axis=1) * UNIT_MM
|
||||
me.vertices.foreach_set("co", Q.reshape(-1))
|
||||
me.update()
|
||||
if me.has_custom_normals:
|
||||
vn = np.empty(n_v * 3, dtype=np.float32)
|
||||
me.vertices.foreach_get("normal", vn)
|
||||
me.normals_split_custom_set_from_vertices(vn.reshape(-1, 3))
|
||||
ang2, _ = kink_of(Q)
|
||||
torso = (co[:, 2] > 0.28) & (co[:, 2] < 0.90)
|
||||
log(f"W={W:2d}: band {nf} verts, CG it={it} rel={rel:.1e}, moved max {d.max():.2f} mm "
|
||||
f"median(band) {np.median(d[band]):.3f} mm | kink>6 {int((torso&(ang2>6)).sum())} "
|
||||
f">12 {int((torso&(ang2>12)).sum())} >20 {int((torso&(ang2>20)).sum())}")
|
||||
out = os.path.join(ROOT, f"w{W:02d}")
|
||||
shoot(out, "chest_clay_40", CHEST, 0.22, 40)
|
||||
shoot(out, "full_clay_0", FULL, 0.55, 0)
|
||||
shoot(out, "hip_clay_0", HIP, 0.22, 0)
|
||||
log(f"W={W}: rendered -> {out}")
|
||||
me.vertices.foreach_set("co", co.reshape(-1)) # reset for the next width
|
||||
me.update()
|
||||
|
||||
print("WIDE_DONE")
|
||||
@@ -0,0 +1,233 @@
|
||||
# Stage 20 (read-only probe): characterise the CURRENT UV atlas, so "the texture looks cut up
|
||||
# and pasted together" becomes a measurement instead of an impression.
|
||||
#
|
||||
# blender --background --python 20_atlas_probe.py -- <in.blend> <out_dir>
|
||||
#
|
||||
# Reports, for the mesh's active UV layer:
|
||||
# - which image the material actually samples (bpy.data.images holds stale duplicates)
|
||||
# - UV-vertex count vs mesh-vertex count => how much the atlas is cut apart
|
||||
# - island count + size distribution => "pasted together" from how many pieces
|
||||
# - per-island texel density (px per mm) => whether pieces are at inconsistent scale
|
||||
# - atlas coverage + wasted area
|
||||
# Writes: basecolor.png (the real one), islands.png (island map), density.png (px/mm heat),
|
||||
# uvgrid.png (UV wireframe), and uv_cache.npz for later stages.
|
||||
import bpy, sys, os, time
|
||||
import numpy as np
|
||||
|
||||
argv = sys.argv[sys.argv.index("--") + 1:]
|
||||
BLEND = argv[0]
|
||||
OUTDIR = os.path.abspath(argv[1])
|
||||
os.makedirs(OUTDIR, exist_ok=True)
|
||||
t0 = time.time()
|
||||
|
||||
|
||||
def log(m):
|
||||
print(f"[atlas {time.time()-t0:6.1f}s] {m}", flush=True)
|
||||
|
||||
|
||||
bpy.ops.wm.open_mainfile(filepath=BLEND)
|
||||
ob = max([o for o in bpy.data.objects if o.type == 'MESH'], key=lambda o: len(o.data.vertices))
|
||||
me = ob.data
|
||||
n_v, n_l, n_f = len(me.vertices), len(me.loops), len(me.polygons)
|
||||
log(f"mesh '{ob.name}': {n_v}v {n_l}loops {n_f}faces uv_layers={[l.name for l in me.uv_layers]}")
|
||||
|
||||
# ---- which image does the material ACTUALLY sample? follow the node link ----
|
||||
sampled = {}
|
||||
for slot in ob.material_slots:
|
||||
mat = slot.material
|
||||
if not mat or not mat.use_nodes:
|
||||
continue
|
||||
for node in mat.node_tree.nodes:
|
||||
if node.type != 'BSDF_PRINCIPLED':
|
||||
continue
|
||||
for sock, key in (("Base Color", "base"), ("Normal", "normal"), ("Roughness", "rm")):
|
||||
if sock not in node.inputs or not node.inputs[sock].links:
|
||||
continue
|
||||
src = node.inputs[sock].links[0].from_node
|
||||
seen = set()
|
||||
while src and src.type != 'TEX_IMAGE' and id(src) not in seen:
|
||||
seen.add(id(src))
|
||||
nxt = None
|
||||
for i in src.inputs:
|
||||
if i.links:
|
||||
nxt = i.links[0].from_node
|
||||
break
|
||||
src = nxt
|
||||
if src and src.type == 'TEX_IMAGE' and src.image:
|
||||
sampled[key] = src.image
|
||||
for k, im in sampled.items():
|
||||
log(f"SAMPLED {k}: '{im.name}' {im.size[0]}x{im.size[1]} packed={bool(im.packed_file)}")
|
||||
print("ALL IMAGES IN FILE (duplicates are stale):")
|
||||
for im in bpy.data.images:
|
||||
if im.size[0]:
|
||||
print(f" '{im.name}' {im.size[0]}x{im.size[1]}")
|
||||
|
||||
base = sampled.get("base")
|
||||
if base is None:
|
||||
print("!! material samples no basecolor image"); sys.exit(1)
|
||||
W, H = base.size
|
||||
buf = np.empty(W * H * 4, dtype=np.float32)
|
||||
base.pixels.foreach_get(buf)
|
||||
tex = buf.reshape(H, W, 4)
|
||||
|
||||
# ---- UV data ----
|
||||
loops_v = np.empty(n_l, dtype=np.int32)
|
||||
me.loops.foreach_get("vertex_index", loops_v)
|
||||
uv = np.empty(n_l * 2, dtype=np.float64)
|
||||
me.uv_layers.active.data.foreach_get("uv", uv)
|
||||
uv = uv.reshape(-1, 2)
|
||||
co = np.empty(n_v * 3)
|
||||
me.vertices.foreach_get("co", co)
|
||||
co = co.reshape(-1, 3)
|
||||
|
||||
print(f"UV range u {uv[:,0].min():.4f}..{uv[:,0].max():.4f} "
|
||||
f"v {uv[:,1].min():.4f}..{uv[:,1].max():.4f}")
|
||||
|
||||
# ---- uv-vertices: a mesh vertex split across N atlas pieces becomes N uv-vertices ----
|
||||
Q = 1 << 20
|
||||
key = (loops_v.astype(np.int64) * Q * Q
|
||||
+ np.round(np.clip(uv[:, 0], 0, 1) * (Q - 1)).astype(np.int64) * Q
|
||||
+ np.round(np.clip(uv[:, 1], 0, 1) * (Q - 1)).astype(np.int64))
|
||||
_, uvv = np.unique(key, return_inverse=True)
|
||||
n_uvv = uvv.max() + 1
|
||||
splits = np.bincount(uvv, minlength=n_uvv)
|
||||
per_vert = np.bincount(loops_v, weights=np.zeros(n_l)) # placeholder
|
||||
# how many atlas copies does each mesh vertex have?
|
||||
vk = np.unique(np.stack([loops_v, uvv], axis=1), axis=0)
|
||||
copies = np.bincount(vk[:, 0], minlength=n_v)
|
||||
print(f"\n=== CUT-APART ===")
|
||||
print(f"uv-vertices {n_uvv} for {n_v} mesh vertices -> {100.0*(n_uvv-n_v)/n_v:+.1f}% duplication")
|
||||
print(f"vertices on a UV seam: {int((copies > 1).sum())} ({100.0*(copies>1).sum()/n_v:.1f}%) "
|
||||
f"max copies {int(copies.max())}")
|
||||
|
||||
# ---- islands = connected components of the uv-mesh ----
|
||||
l_start = np.empty(n_f, dtype=np.int32); me.polygons.foreach_get("loop_start", l_start)
|
||||
l_tot = np.empty(n_f, dtype=np.int32); me.polygons.foreach_get("loop_total", l_tot)
|
||||
tri = l_tot == 3
|
||||
log(f"faces: {int(tri.sum())} tris, {int((~tri).sum())} n-gons")
|
||||
li = l_start[tri]
|
||||
T = np.stack([uvv[li], uvv[li + 1], uvv[li + 2]], axis=1)
|
||||
|
||||
parent = np.arange(n_uvv, dtype=np.int64)
|
||||
|
||||
|
||||
def find(x):
|
||||
r = x
|
||||
while parent[r] != r:
|
||||
r = parent[r]
|
||||
while parent[x] != r:
|
||||
parent[x], x = r, parent[x]
|
||||
return r
|
||||
|
||||
|
||||
for a, b, c in T:
|
||||
ra, rb, rc = find(a), find(b), find(c)
|
||||
if ra != rb:
|
||||
parent[rb] = ra
|
||||
if ra != rc:
|
||||
parent[rc] = ra
|
||||
log("union-find done")
|
||||
roots = np.array([find(i) for i in range(n_uvv)])
|
||||
_, isl = np.unique(roots, return_inverse=True)
|
||||
n_isl = isl.max() + 1
|
||||
|
||||
# per-island geometry: UV area and 3D area
|
||||
Puv = np.clip(uv, 0, 1)
|
||||
tri_uv = np.stack([Puv[li], Puv[li + 1], Puv[li + 2]], axis=1) # (F,3,2)
|
||||
auv = 0.5 * np.abs((tri_uv[:, 1, 0] - tri_uv[:, 0, 0]) * (tri_uv[:, 2, 1] - tri_uv[:, 0, 1])
|
||||
- (tri_uv[:, 2, 0] - tri_uv[:, 0, 0]) * (tri_uv[:, 1, 1] - tri_uv[:, 0, 1]))
|
||||
P3 = co[np.stack([loops_v[li], loops_v[li + 1], loops_v[li + 2]], axis=1)] # (F,3,3)
|
||||
cr = np.cross(P3[:, 1] - P3[:, 0], P3[:, 2] - P3[:, 0])
|
||||
a3 = 0.5 * np.linalg.norm(cr, axis=1)
|
||||
fisl = isl[T[:, 0]]
|
||||
isl_auv = np.bincount(fisl, weights=auv, minlength=n_isl)
|
||||
isl_a3 = np.bincount(fisl, weights=a3, minlength=n_isl)
|
||||
isl_nf = np.bincount(fisl, minlength=n_isl)
|
||||
|
||||
order = np.argsort(-isl_auv)
|
||||
UNIT = 1.815 # 1 mesh unit = 1.815 m (body is 0.979 units for 1.777 m)
|
||||
print(f"\n=== PASTED TOGETHER ===")
|
||||
print(f"islands: {n_isl}")
|
||||
print(f"atlas UV area used: {isl_auv.sum()*100:.1f}% (rest is padding/waste)")
|
||||
cum = np.cumsum(isl_auv[order]) / max(isl_auv.sum(), 1e-12)
|
||||
for frac in (0.5, 0.9, 0.99):
|
||||
print(f" {int(np.searchsorted(cum, frac))+1} islands cover {frac*100:.0f}% of the used area")
|
||||
tiny = int((isl_nf < 20).sum())
|
||||
print(f" islands with <20 faces: {tiny} ({100.0*tiny/n_isl:.1f}%)")
|
||||
print("\ntop 20 islands (px/mm = texel density at 4096):")
|
||||
print(" # faces uv_area% 3D area cm2 px/mm uv centre")
|
||||
for i in order[:20]:
|
||||
if isl_a3[i] <= 0:
|
||||
continue
|
||||
dens = np.sqrt(isl_auv[i] / isl_a3[i]) * W / (UNIT * 1000.0)
|
||||
m = fisl == i
|
||||
cu = tri_uv[m].reshape(-1, 2).mean(axis=0)
|
||||
print(f" {i:6d} {isl_nf[i]:7d} {isl_auv[i]*100:8.3f} "
|
||||
f"{isl_a3[i]*UNIT*UNIT*1e4:10.1f} {dens:6.2f} ({cu[0]:.3f},{cu[1]:.3f})")
|
||||
|
||||
big = order[:max(1, int(np.searchsorted(cum, 0.99)) + 1)]
|
||||
dens_all = np.where(isl_a3 > 0, np.sqrt(np.maximum(isl_auv, 0) / np.maximum(isl_a3, 1e-12))
|
||||
* W / (UNIT * 1000.0), np.nan)
|
||||
d = dens_all[big]
|
||||
d = d[np.isfinite(d)]
|
||||
print(f"\ntexel density over the 99%-area islands: min {d.min():.2f} median {np.median(d):.2f} "
|
||||
f"max {d.max():.2f} px/mm -> {d.max()/max(d.min(),1e-9):.1f}x spread")
|
||||
|
||||
# ---- pictures ----
|
||||
def save(arr, name):
|
||||
h, w = arr.shape[:2]
|
||||
img = bpy.data.images.new(name, w, h, alpha=False, float_buffer=False)
|
||||
a = np.ones((h, w, 4), dtype=np.float32)
|
||||
a[:, :, :3] = arr.astype(np.float32)
|
||||
img.pixels.foreach_set(a.reshape(-1))
|
||||
p = os.path.join(OUTDIR, name + ".png")
|
||||
img.file_format = 'PNG'
|
||||
img.filepath_raw = p
|
||||
img.save(filepath=p)
|
||||
log(f"wrote {p} exists={os.path.exists(p)}")
|
||||
|
||||
|
||||
save(tex[:, :, :3], "basecolor")
|
||||
|
||||
R = 1024
|
||||
sc = R / float(W)
|
||||
rng = np.random.RandomState(3)
|
||||
pal = rng.rand(n_isl, 3) * 0.75 + 0.2
|
||||
IS = np.zeros((R, R, 3), dtype=np.float64)
|
||||
DN = np.zeros((R, R), dtype=np.float64)
|
||||
GR = np.zeros((R, R), dtype=np.float64)
|
||||
tri_px = tri_uv * np.array([(R - 1), (R - 1)])
|
||||
for fi in range(len(tri_px)):
|
||||
P = tri_px[fi]
|
||||
x0, x1 = int(P[:, 0].min()), int(np.ceil(P[:, 0].max()))
|
||||
y0, y1 = int(P[:, 1].min()), int(np.ceil(P[:, 1].max()))
|
||||
if x1 < x0 or y1 < y0 or x1 - x0 > 64 or y1 - y0 > 64:
|
||||
continue
|
||||
dd = ((P[1, 1] - P[2, 1]) * (P[0, 0] - P[2, 0]) + (P[2, 0] - P[1, 0]) * (P[0, 1] - P[2, 1]))
|
||||
if abs(dd) < 1e-12:
|
||||
continue
|
||||
gx, gy = np.meshgrid(np.arange(x0, min(x1, R - 1) + 1), np.arange(y0, min(y1, R - 1) + 1))
|
||||
aa = ((P[1, 1] - P[2, 1]) * (gx - P[2, 0]) + (P[2, 0] - P[1, 0]) * (gy - P[2, 1])) / dd
|
||||
bb = ((P[2, 1] - P[0, 1]) * (gx - P[2, 0]) + (P[0, 0] - P[2, 0]) * (gy - P[2, 1])) / dd
|
||||
cc = 1.0 - aa - bb
|
||||
ins = (aa >= 0) & (bb >= 0) & (cc >= 0)
|
||||
if not ins.any():
|
||||
continue
|
||||
IS[gy[ins], gx[ins]] = pal[fisl[fi]]
|
||||
DN[gy[ins], gx[ins]] = dens_all[fisl[fi]] if np.isfinite(dens_all[fisl[fi]]) else 0
|
||||
# edge pixels -> wireframe
|
||||
ed = ins & ((aa < 0.06) | (bb < 0.06) | (cc < 0.06))
|
||||
GR[gy[ed], gx[ed]] = 1.0
|
||||
log("rasterised island map")
|
||||
save(IS, "islands")
|
||||
dv = DN / max(np.percentile(DN[DN > 0], 98), 1e-9)
|
||||
save(np.stack([np.clip(dv, 0, 1), np.clip(1 - np.abs(dv - 0.5) * 2, 0, 1),
|
||||
np.clip(1 - dv, 0, 1)], axis=2), "density")
|
||||
tsm = tex[::W // R, ::W // R, :3]
|
||||
save(np.clip(tsm * (1 - GR[:, :, None] * 0.8) + GR[:, :, None] * np.array([0.0, 1.0, 0.2]), 0, 1),
|
||||
"uvgrid")
|
||||
|
||||
np.savez_compressed(os.path.join(OUTDIR, "uv_cache.npz"),
|
||||
isl=isl, uvv=uvv, fisl=fisl, isl_auv=isl_auv, isl_a3=isl_a3,
|
||||
isl_nf=isl_nf, copies=copies)
|
||||
print("ATLAS_PROBE_DONE")
|
||||
@@ -0,0 +1,226 @@
|
||||
# Stage 20: round the cleavage — fillet the sharp sternum notch and the old bra-neckline crease
|
||||
# WITHOUT deflating the breasts.
|
||||
#
|
||||
# blender --background --python 20_cleavage.py -- <in.blend> <out.blend> <review_dir>
|
||||
# [iters] [target_radius_units]
|
||||
#
|
||||
# WHAT IS WRONG, MEASURED (16_diagnose on 10_welded)
|
||||
# z=0.710 sternum fillet radius 8.2 mm-units (~15 real mm) notch depth 21 real mm
|
||||
# z=0.725 sternum fillet radius 2.5 mm-units (~4.5 real mm) notch depth 44 real mm
|
||||
# elsewhere the corridor radius is 45-550 mm-units, i.e. smooth.
|
||||
# So there is a razor-sharp, deep V at z 0.71-0.74 — the scar left by excising the bra's sternum
|
||||
# bow — plus the arcing crease of the old bra neckline over each upper breast. z 0.725 is 1.32 m
|
||||
# on a 1.777 m body: that is the sternal notch ABOVE the bust (apex sits at z 0.69), where
|
||||
# anatomy wants a shallow rounded valley, not a gash.
|
||||
#
|
||||
# WHY A FILL-ONLY CONCAVITY FILTER, NOT A MEMBRANE OR SMOOTHING
|
||||
# A membrane over the corridor would bridge the notch, but it also replaces whatever it spans —
|
||||
# aimed at the upper chest it would eat the breasts' upper poles, and the cups are the one thing
|
||||
# that must survive (stage 03 sculpted them deliberately; the whole point of v2 was that they are
|
||||
# not a bra shape). Plain smoothing has the same problem in reverse: it shrinks convex volume.
|
||||
# So: displace ONLY where the surface is concave beyond a curvature limit, and only OUTWARD
|
||||
# (valley-filling). Convex geometry has the wrong sign and is untouched by construction, so no
|
||||
# amount of iteration can flatten a breast. Sharp valleys rise until their radius passes the
|
||||
# limit, which is exactly "round and smooth as it connects with the chest".
|
||||
#
|
||||
# The limit is enforced by measurement, not by feel: after each pass the script re-runs the same
|
||||
# profile-curvature probe 16_diagnose used, and reports radius + notch depth per height so the
|
||||
# result is comparable to the numbers above.
|
||||
import bpy, sys, os, math, time
|
||||
import numpy as np
|
||||
from mathutils import Vector
|
||||
|
||||
argv = sys.argv[sys.argv.index("--") + 1:]
|
||||
BLEND, OUT, REVIEW = argv[0], argv[1], argv[2]
|
||||
ITERS = int(argv[3]) if len(argv) > 3 else 60
|
||||
R_TARGET = float(argv[4]) if len(argv) > 4 else 0.025 # mesh units (~45 real mm)
|
||||
os.makedirs(REVIEW, exist_ok=True)
|
||||
t0 = time.time()
|
||||
|
||||
UNIT_MM = 1815.0
|
||||
# region: the front of the chest, from just under the bust to the clavicles
|
||||
Z0, Z1 = 0.620, 0.800
|
||||
Z_FADE = 0.020
|
||||
X_MAX = 0.095
|
||||
X_FADE = 0.025
|
||||
Y_FRONT = 0.010 # front hemisphere only
|
||||
ALPHA = 0.55 # per-pass fraction of the concave offset that is filled
|
||||
|
||||
|
||||
def log(m):
|
||||
print(f"[clv {time.time()-t0:6.1f}s] {m}", flush=True)
|
||||
|
||||
|
||||
def smoothstep(x):
|
||||
x = np.clip(x, 0.0, 1.0)
|
||||
return x * x * (3.0 - 2.0 * x)
|
||||
|
||||
|
||||
bpy.ops.wm.open_mainfile(filepath=BLEND)
|
||||
ob = max([o for o in bpy.data.objects if o.type == 'MESH'],
|
||||
key=lambda o: len(o.data.vertices))
|
||||
me = ob.data
|
||||
n_v = len(me.vertices)
|
||||
co = np.empty(n_v * 3)
|
||||
me.vertices.foreach_get("co", co)
|
||||
co = co.reshape(-1, 3)
|
||||
ev = np.empty(len(me.edges) * 2, dtype=np.int32)
|
||||
me.edges.foreach_get("vertices", ev)
|
||||
ev = ev.reshape(-1, 2)
|
||||
log(f"in: {n_v}v {len(me.polygons)}f")
|
||||
|
||||
order = np.concatenate([ev[:, 0], ev[:, 1]])
|
||||
nbr = np.concatenate([ev[:, 1], ev[:, 0]])
|
||||
srt = np.argsort(order, kind="stable")
|
||||
o_s, n_s = order[srt], nbr[srt]
|
||||
ptr = np.searchsorted(o_s, np.arange(n_v + 1))
|
||||
cnt = np.maximum(np.diff(ptr), 1)
|
||||
empty = np.diff(ptr) == 0
|
||||
|
||||
|
||||
def nbr_mean(X):
|
||||
a = np.add.reduceat(X[n_s], ptr[:-1], axis=0)
|
||||
a[empty] = X[empty]
|
||||
return a / cnt[:, None]
|
||||
|
||||
|
||||
# ---- region weight ----
|
||||
wz = smoothstep((co[:, 2] - (Z0 - Z_FADE)) / Z_FADE) * \
|
||||
smoothstep(((Z1 + Z_FADE) - co[:, 2]) / Z_FADE)
|
||||
wx = smoothstep(((X_MAX + X_FADE) - np.abs(co[:, 0])) / X_FADE)
|
||||
wy = smoothstep((Y_FRONT - co[:, 1]) / 0.030)
|
||||
W = wz * wx * wy
|
||||
log(f"region: {int((W > 0.01).sum())} verts with weight >0.01, "
|
||||
f"{int((W > 0.5).sum())} above 0.5")
|
||||
|
||||
|
||||
# ---- the same profile probe 16_diagnose used, so numbers are comparable ----
|
||||
def probe(P, tag):
|
||||
front = P[:, 1] < 0
|
||||
print(f"\n=== CLEAVAGE PROFILE [{tag}] ===")
|
||||
print(" z sternum y concave curv 1/m fillet radius notch vs apex")
|
||||
worst = 9e9
|
||||
for z0 in np.arange(0.650, 0.7801, 0.015):
|
||||
row = []
|
||||
for x0 in np.arange(-0.05, 0.0501, 0.005):
|
||||
m = front & (np.abs(P[:, 0] - x0) < 0.0035) & (np.abs(P[:, 2] - z0) < 0.004)
|
||||
row.append(P[m, 1].min() if m.sum() else np.nan)
|
||||
row = np.array(row)
|
||||
if np.isnan(row).all():
|
||||
continue
|
||||
mid = len(row) // 2
|
||||
seg = row[max(0, mid - 3):mid + 4]
|
||||
rad = float('inf')
|
||||
kmax = np.nan
|
||||
if len(seg) >= 3 and not np.isnan(seg).any():
|
||||
d2 = (seg[:-2] - 2 * seg[1:-1] + seg[2:]) / (0.005 ** 2)
|
||||
kmax = float(np.nanmax(d2))
|
||||
rad = 1.0 / kmax if kmax > 1e-6 else float('inf')
|
||||
ma = front & (np.abs(np.abs(P[:, 0]) - 0.034) < 0.005) & (np.abs(P[:, 2] - z0) < 0.004)
|
||||
notch = ((row[mid] - P[ma, 1].min()) * UNIT_MM) if ma.sum() else np.nan
|
||||
flag = ""
|
||||
if rad < R_TARGET:
|
||||
flag = " <-- SHARP"
|
||||
worst = min(worst, rad)
|
||||
print(f" {z0:.3f} {row[mid]:+.4f} {kmax:10.1f} "
|
||||
f"{rad*UNIT_MM:8.1f} mm {notch:+7.1f} mm{flag}")
|
||||
return worst
|
||||
|
||||
|
||||
w0 = probe(co, "before")
|
||||
|
||||
# ---- fill-only concavity relaxation ----
|
||||
P = co.copy()
|
||||
active = W > 0.01
|
||||
for it in range(1, ITERS + 1):
|
||||
# fresh vertex normals from the CURRENT positions (area-weighted via the mesh)
|
||||
me.vertices.foreach_set("co", P.reshape(-1))
|
||||
me.update()
|
||||
nrm = np.empty(n_v * 3)
|
||||
me.vertices.foreach_get("normal", nrm)
|
||||
nrm = nrm.reshape(-1, 3)
|
||||
lap = nbr_mean(P) - P
|
||||
c = (lap * nrm).sum(axis=1) # >0 : neighbours are outside -> valley (concave)
|
||||
step = np.where(c > 0, c, 0.0) * ALPHA * W
|
||||
# a vertex only moves if its valley is sharper than the target radius: the uniform-Laplacian
|
||||
# offset of a circular valley of radius R over spacing h is ~h^2/(2R), so compare against that
|
||||
h2 = np.zeros(n_v)
|
||||
np.add.at(h2, ev[:, 0], np.linalg.norm(P[ev[:, 0]] - P[ev[:, 1]], axis=1) ** 2)
|
||||
np.add.at(h2, ev[:, 1], np.linalg.norm(P[ev[:, 0]] - P[ev[:, 1]], axis=1) ** 2)
|
||||
hcnt = np.zeros(n_v)
|
||||
np.add.at(hcnt, ev[:, 0], 1.0)
|
||||
np.add.at(hcnt, ev[:, 1], 1.0)
|
||||
h2 = h2 / np.maximum(hcnt, 1)
|
||||
thresh = h2 / (2.0 * R_TARGET)
|
||||
step = np.where(c > thresh, step, 0.0)
|
||||
P = P + nrm * step[:, None]
|
||||
if it % 15 == 0 or it == 1:
|
||||
moved = np.linalg.norm(P - co, axis=1) * UNIT_MM
|
||||
log(f"pass {it:3d}: {int((step>0).sum()):6d} verts filled this pass, "
|
||||
f"cumulative max {moved.max():.2f} mm, median(region) "
|
||||
f"{np.median(moved[active]):.3f} mm")
|
||||
|
||||
me.vertices.foreach_set("co", P.reshape(-1))
|
||||
me.update()
|
||||
if me.has_custom_normals:
|
||||
vn = np.empty(n_v * 3, dtype=np.float32)
|
||||
me.vertices.foreach_get("normal", vn)
|
||||
me.normals_split_custom_set_from_vertices(vn.reshape(-1, 3))
|
||||
d = np.linalg.norm(P - co, axis=1) * UNIT_MM
|
||||
log(f"TOTAL: max {d.max():.2f} mm, {int((d > 0.1).sum())} verts moved >0.1 mm "
|
||||
f"(all outward: min radial change {np.min(((P-co)*0+1)[0]):.0f})")
|
||||
w1 = probe(P, "after")
|
||||
print(f"\nSHARPEST corridor radius: before {w0*UNIT_MM:.1f} mm -> after "
|
||||
f"{(w1*UNIT_MM if w1 < 9e9 else float('inf')):.1f} mm (target {R_TARGET*UNIT_MM:.0f} mm)")
|
||||
|
||||
# ---- renders ----
|
||||
scn = bpy.context.scene
|
||||
wd = bpy.data.worlds.new("W")
|
||||
wd.color = (0.22, 0.22, 0.24)
|
||||
scn.world = wd
|
||||
key = bpy.data.objects.new("Key", bpy.data.lights.new("Key", 'SUN'))
|
||||
key.data.energy = 3.0
|
||||
key.data.use_shadow = False
|
||||
bpy.context.collection.objects.link(key)
|
||||
fl = bpy.data.objects.new("Fill", bpy.data.lights.new("Fill", 'SUN'))
|
||||
fl.data.energy = 1.0
|
||||
fl.data.use_shadow = False
|
||||
bpy.context.collection.objects.link(fl)
|
||||
cam = bpy.data.objects.new("Cam", bpy.data.cameras.new("Cam"))
|
||||
cam.data.lens = 85
|
||||
bpy.context.collection.objects.link(cam)
|
||||
scn.camera = cam
|
||||
scn.render.engine = 'BLENDER_EEVEE' if bpy.app.version >= (4, 2) else 'BLENDER_EEVEE_NEXT'
|
||||
scn.render.resolution_x = scn.render.resolution_y = 1000
|
||||
clay = bpy.data.materials.new("Clay")
|
||||
clay.use_nodes = True
|
||||
clay.node_tree.nodes["Principled BSDF"].inputs["Base Color"].default_value = (0.62, 0.60, 0.58, 1)
|
||||
clay.node_tree.nodes["Principled BSDF"].inputs["Roughness"].default_value = 0.45
|
||||
orig = [ms.material for ms in ob.material_slots]
|
||||
|
||||
|
||||
def shoot(tag, ctr, span, yaw_deg, use_clay=True):
|
||||
for i, ms in enumerate(ob.material_slots):
|
||||
ms.material = clay if use_clay else orig[i]
|
||||
yaw = math.radians(yaw_deg)
|
||||
dist = span * 3.0
|
||||
cam.location = Vector(ctr) + Vector((math.sin(yaw) * dist, -math.cos(yaw) * dist, 0.02))
|
||||
cam.rotation_euler = (Vector(ctr) - cam.location).to_track_quat('-Z', 'Y').to_euler()
|
||||
key.rotation_euler = (math.radians(62), 0, math.radians(35 + yaw_deg))
|
||||
fl.rotation_euler = (math.radians(75), 0, math.radians(yaw_deg - 110))
|
||||
scn.render.filepath = os.path.abspath(os.path.join(REVIEW, f"{tag}.png"))
|
||||
bpy.ops.render.render(write_still=True)
|
||||
log(f"render {tag}")
|
||||
|
||||
|
||||
CHEST = (0.0, 0.0, 0.675)
|
||||
FULL = (0.0, 0.0, 0.50)
|
||||
for yaw in (0, 40, 90):
|
||||
shoot(f"chest_clay_{yaw}", CHEST, 0.22, yaw, True)
|
||||
shoot("chest_tex_0", CHEST, 0.22, 0, False)
|
||||
shoot("chest_tex_40", CHEST, 0.22, 40, False)
|
||||
shoot("full_clay_0", FULL, 0.55, 0, True)
|
||||
|
||||
bpy.ops.wm.save_as_mainfile(filepath=OUT)
|
||||
log(f"WROTE {OUT}")
|
||||
print("CLV_DONE")
|
||||
@@ -0,0 +1,163 @@
|
||||
# Stage 21 (read-only): is the patchwork in the LAYOUT or in the COLOUR?
|
||||
#
|
||||
# blender --background --python 21_seam_probe.py -- <in.blend> <probe_dir>
|
||||
#
|
||||
# A mesh vertex that sits on a UV seam has one copy in each atlas chart that meets there. Those
|
||||
# copies are the SAME point on her body, so they must be the same colour. Any difference is a
|
||||
# tone step the eye reads as a pasted edge — and re-packing the UVs would carry it along.
|
||||
# Reports the distribution of that step, the worst offending chart pairs, and (for scale) the
|
||||
# same statistic on non-seam vertices, which is pure sampling noise.
|
||||
# Also reports mean tone per body region, to size the "red hands / rosy chest" complaint.
|
||||
import bpy, sys, os, time
|
||||
import numpy as np
|
||||
|
||||
argv = sys.argv[sys.argv.index("--") + 1:]
|
||||
BLEND = argv[0]
|
||||
PROBE = os.path.abspath(argv[1])
|
||||
t0 = time.time()
|
||||
|
||||
|
||||
def log(m):
|
||||
print(f"[seam {time.time()-t0:6.1f}s] {m}", flush=True)
|
||||
|
||||
|
||||
bpy.ops.wm.open_mainfile(filepath=BLEND)
|
||||
ob = max([o for o in bpy.data.objects if o.type == 'MESH'], key=lambda o: len(o.data.vertices))
|
||||
me = ob.data
|
||||
n_v, n_l, n_f = len(me.vertices), len(me.loops), len(me.polygons)
|
||||
|
||||
base = None
|
||||
for slot in ob.material_slots:
|
||||
mat = slot.material
|
||||
for node in mat.node_tree.nodes:
|
||||
if node.type == 'BSDF_PRINCIPLED' and node.inputs["Base Color"].links:
|
||||
src = node.inputs["Base Color"].links[0].from_node
|
||||
if src.type == 'TEX_IMAGE':
|
||||
base = src.image
|
||||
W, H = base.size
|
||||
buf = np.empty(W * H * 4, dtype=np.float32)
|
||||
base.pixels.foreach_get(buf)
|
||||
tex = buf.reshape(H, W, 4)[:, :, :3].astype(np.float32)
|
||||
log(f"basecolor '{base.name}' {W}x{H}")
|
||||
|
||||
loops_v = np.empty(n_l, dtype=np.int32); me.loops.foreach_get("vertex_index", loops_v)
|
||||
uv = np.empty(n_l * 2); me.uv_layers.active.data.foreach_get("uv", uv); uv = uv.reshape(-1, 2)
|
||||
co = np.empty(n_v * 3); me.vertices.foreach_get("co", co); co = co.reshape(-1, 3)
|
||||
|
||||
C = np.load(os.path.join(PROBE, "uv_cache.npz"))
|
||||
uvv, isl = C["uvv"], C["isl"]
|
||||
l_isl = isl[uvv] # island id per loop
|
||||
l_start = np.empty(n_f, dtype=np.int32); me.polygons.foreach_get("loop_start", l_start)
|
||||
|
||||
# inset each loop's UV 25% toward its face centroid so we read chart interior, not padding
|
||||
face_of_loop = np.repeat(np.arange(n_f), 3) # all-tri mesh
|
||||
cen = (uv[l_start[face_of_loop]] + uv[l_start[face_of_loop] + 1]
|
||||
+ uv[l_start[face_of_loop] + 2]) / 3.0
|
||||
uvi = uv + 0.25 * (cen - uv)
|
||||
px = np.clip(np.round(uvi[:, 0] * (W - 1)).astype(np.int32), 0, W - 1)
|
||||
py = np.clip(np.round(uvi[:, 1] * (H - 1)).astype(np.int32), 0, H - 1)
|
||||
lc = tex[py, px] # colour per loop
|
||||
log("sampled per-loop colour")
|
||||
|
||||
# ---- per (vertex, island) mean colour ----
|
||||
key = loops_v.astype(np.int64) * (isl.max() + 1) + l_isl
|
||||
uk, inv = np.unique(key, return_inverse=True)
|
||||
n_k = len(uk)
|
||||
cnt = np.bincount(inv, minlength=n_k).astype(np.float64)
|
||||
acc = np.zeros((n_k, 3))
|
||||
for c in range(3):
|
||||
acc[:, c] = np.bincount(inv, weights=lc[:, c], minlength=n_k)
|
||||
kc = acc / cnt[:, None]
|
||||
kv = (uk // (isl.max() + 1)).astype(np.int64) # vertex of each (v,island) group
|
||||
ki = (uk % (isl.max() + 1)).astype(np.int64) # island of each group
|
||||
|
||||
order = np.argsort(kv, kind="stable")
|
||||
kv_s, kc_s, ki_s = kv[order], kc[order], ki[order]
|
||||
ptr = np.searchsorted(kv_s, np.arange(n_v + 1))
|
||||
ncopy = np.diff(ptr)
|
||||
|
||||
seam_v = np.nonzero(ncopy > 1)[0]
|
||||
log(f"seam vertices: {len(seam_v)} (max copies {ncopy.max()})")
|
||||
|
||||
steps = []
|
||||
pairstep = {}
|
||||
for v in seam_v:
|
||||
s, e = ptr[v], ptr[v + 1]
|
||||
cc = kc_s[s:e]
|
||||
ii = ki_s[s:e]
|
||||
d = np.abs(cc[:, None, :] - cc[None, :, :]).max(axis=2)
|
||||
a, b = np.unravel_index(np.argmax(d), d.shape)
|
||||
steps.append(d[a, b])
|
||||
if d[a, b] > 0.02:
|
||||
kpair = (int(min(ii[a], ii[b])), int(max(ii[a], ii[b])))
|
||||
r = pairstep.setdefault(kpair, [0, 0.0])
|
||||
r[0] += 1
|
||||
r[1] += float(d[a, b])
|
||||
steps = np.array(steps)
|
||||
|
||||
# baseline: colour spread among the loops of a NON-seam vertex (sampling noise only)
|
||||
solo = np.nonzero(ncopy == 1)[0]
|
||||
sample = solo[::max(1, len(solo) // 40000)]
|
||||
noise = []
|
||||
lorder = np.argsort(loops_v, kind="stable")
|
||||
lv_s = loops_v[lorder]
|
||||
lptr = np.searchsorted(lv_s, np.arange(n_v + 1))
|
||||
for v in sample:
|
||||
li = lorder[lptr[v]:lptr[v + 1]]
|
||||
if len(li) < 2:
|
||||
continue
|
||||
noise.append(np.abs(lc[li].max(axis=0) - lc[li].min(axis=0)).max())
|
||||
noise = np.array(noise)
|
||||
|
||||
print("\n=== COLOUR STEP ACROSS CHART BORDERS ===")
|
||||
print("(max channel difference between copies of the SAME body point in different charts)")
|
||||
for p in (50, 75, 90, 95, 99):
|
||||
print(f" seam p{p:<2d} {np.percentile(steps, p):.4f}")
|
||||
print(f" seam mean {steps.mean():.4f} >0.02: {100.0*(steps>0.02).mean():.1f}% "
|
||||
f">0.05: {100.0*(steps>0.05).mean():.1f}% >0.10: {100.0*(steps>0.10).mean():.1f}%")
|
||||
print(f" NOISE floor (non-seam vertices) p50 {np.percentile(noise,50):.4f} "
|
||||
f"p95 {np.percentile(noise,95):.4f} mean {noise.mean():.4f}")
|
||||
print(f" -> seam step is {steps.mean()/max(noise.mean(),1e-9):.1f}x the noise floor")
|
||||
|
||||
print("\nworst chart pairs (count of stepped verts, mean step):")
|
||||
tops = sorted(pairstep.items(), key=lambda kv_: -kv_[1][1])[:12]
|
||||
for (a, b), (n_, s_) in tops:
|
||||
print(f" chart {a:5d} <-> {b:5d}: {n_:5d} verts, mean step {s_/n_:.4f}")
|
||||
|
||||
# ---- per-region tone (the red hands / rosy chest complaint, as numbers) ----
|
||||
vc = np.zeros((n_v, 3))
|
||||
vn = np.zeros(n_v)
|
||||
for c in range(3):
|
||||
vc[:, c] = np.bincount(loops_v, weights=lc[:, c], minlength=n_v)
|
||||
vn = np.bincount(loops_v, minlength=n_v).astype(np.float64)
|
||||
vc /= np.maximum(vn, 1)[:, None]
|
||||
z, x, y = co[:, 2], co[:, 0], co[:, 1]
|
||||
regions = {
|
||||
"head ": z > 0.905,
|
||||
"neck/upper chest": (z > 0.82) & (z <= 0.905) & (np.abs(x) < 0.09),
|
||||
"breast band ": (z > 0.60) & (z <= 0.78) & (np.abs(x) < 0.11) & (y < 0),
|
||||
"belly ": (z > 0.45) & (z <= 0.60) & (np.abs(x) < 0.09) & (y < 0),
|
||||
"hip/crotch ": (z > 0.33) & (z <= 0.45) & (np.abs(x) < 0.09),
|
||||
"upper arm ": (z > 0.70) & (np.abs(x) > 0.16) & (np.abs(x) < 0.30),
|
||||
"forearm ": (np.abs(x) > 0.30) & (np.abs(x) < 0.40),
|
||||
"hand ": np.abs(x) > 0.40,
|
||||
"thigh ": (z > 0.20) & (z <= 0.33),
|
||||
"shin ": (z > 0.06) & (z <= 0.18),
|
||||
"foot ": z <= 0.05,
|
||||
}
|
||||
print("\n=== TONE BY REGION (mean RGB, and r-g redness) ===")
|
||||
belly_rg = None
|
||||
for nm, m in regions.items():
|
||||
if m.sum() < 50:
|
||||
print(f" {nm} (empty)")
|
||||
continue
|
||||
c_ = vc[m].mean(axis=0)
|
||||
rg = c_[0] - c_[1]
|
||||
if nm.startswith("belly"):
|
||||
belly_rg = rg
|
||||
print(f" {nm} n={int(m.sum()):7d} RGB {c_[0]:.3f} {c_[1]:.3f} {c_[2]:.3f} "
|
||||
f"r-g {rg:.3f} luma {c_.mean():.3f}")
|
||||
if belly_rg is not None:
|
||||
print(f" (belly r-g = {belly_rg:.3f} is the reference 'plain skin' redness)")
|
||||
np.save(os.path.join(PROBE, "vert_colour.npy"), vc.astype(np.float32))
|
||||
print("SEAM_PROBE_DONE")
|
||||
@@ -0,0 +1,353 @@
|
||||
# Stage 21: kill the discolouration in the repainted bra/crotch patches — gradient-domain
|
||||
# levelling of basecolor + roughness, and grain in place of the flat normal.
|
||||
#
|
||||
# blender --background --python 21_tone.py -- <in.blend> <out.blend> [--no-normal-grain]
|
||||
#
|
||||
# WHY THE PATCHES READ AS DISCOLOURED
|
||||
# 05_texture.py fills each garment texel with an inverse-distance colour taken from the nearest
|
||||
# SKIN VERTS IN 3D, mirror-averaged left/right, then feathers the rim 4 px. Every one of those
|
||||
# choices is right for avoiding a wrong-body-part tone, and none of them controls the patch's
|
||||
# ABSOLUTE level: the fill is an average of skin a few centimetres away, so wherever her skin has
|
||||
# a gradient (and her chest does — there is a rosy blush the uniform-skin decision repaints), the
|
||||
# patch lands at a different tone than the skin it abuts. A feather blurs that step over 4 px; it
|
||||
# cannot remove it. The blend also flattens the normal map to (128,128,255) and sets roughness to
|
||||
# the atlas median over the same texels, so the patch is smoother AND differently-glossy than the
|
||||
# skin around it — under a key light that reads as discolouration even where the albedo matches.
|
||||
#
|
||||
# THE FIX — solve for the level instead of averaging toward it.
|
||||
# Classic gradient-domain (Poisson) levelling: keep the fill's detail, replace its level. Find a
|
||||
# correction field E over the patch that is harmonic inside and, ON THE PATCH BORDER, equals the
|
||||
# mismatch against the untouched skin next to it:
|
||||
# D(b) = mean(orig[n] : n neighbour of b, n outside the patch) - current[b]
|
||||
# laplace(E) = 0 inside, E = D on the border, new = current + E
|
||||
# At the border the corrected value becomes exactly its neighbours' value, so the seam cannot be
|
||||
# seen; inward, E decays smoothly, so a uniform offset over the whole patch is removed too. Detail
|
||||
# is untouched because E is smooth by construction — this levels the patch without blurring it.
|
||||
#
|
||||
# Solved per blob with a cascadic multigrid (coarse solve -> upsample -> refine). A flat Jacobi
|
||||
# sweep would need ~width^2 iterations to converge; that mistake is already recorded in this
|
||||
# project's history as the "pale panty ghost" (400 passes on a 600 px hole left the interior at
|
||||
# its seed tone), so it is not repeated.
|
||||
#
|
||||
# The patch mask is not guessed: it is where the wired basecolor differs from the untouched
|
||||
# original, which still sits in the file as an orphan datablock copy left by the raw-glb imports.
|
||||
import bpy, sys, os, time
|
||||
import numpy as np
|
||||
|
||||
argv = sys.argv[sys.argv.index("--") + 1:]
|
||||
BLEND, OUT = argv[0], argv[1]
|
||||
# The untouched pre-repaint textures must come from a SEPARATE blend. They used to survive inside
|
||||
# the working file as orphan ".002/.003" copies left by the raw-glb imports, but Blender purges
|
||||
# zero-user datablocks on save, so they died the moment 22_lines.blend was written. 00_welded.blend
|
||||
# is the pristine import and is the right source.
|
||||
ORIG_BLEND = argv[2] if len(argv) > 2 and not argv[2].startswith("--") else "00_welded.blend"
|
||||
DO_NORMAL_GRAIN = "--no-normal-grain" not in argv
|
||||
t0 = time.time()
|
||||
|
||||
DIFF_T = 0.02 # a texel counts as repainted if any channel moved this much
|
||||
RING = 10 # how far out to look for untouched skin
|
||||
GRAIN_T = 16
|
||||
|
||||
|
||||
def log(m):
|
||||
print(f"[tone {time.time()-t0:6.1f}s] {m}", flush=True)
|
||||
|
||||
|
||||
def getpx(img):
|
||||
w, h = img.size
|
||||
b = np.empty(w * h * 4, dtype=np.float32)
|
||||
img.pixels.foreach_get(b)
|
||||
return b.reshape(h, w, 4)
|
||||
|
||||
|
||||
def dil(m, k=1):
|
||||
g = m.copy()
|
||||
for _ in range(k):
|
||||
n = g.copy()
|
||||
n[1:, :] |= g[:-1, :]
|
||||
n[:-1, :] |= g[1:, :]
|
||||
n[:, 1:] |= g[:, :-1]
|
||||
n[:, :-1] |= g[:, 1:]
|
||||
g = n
|
||||
return g
|
||||
|
||||
|
||||
# ---- cache the untouched originals from the pristine blend, before opening the working file ----
|
||||
if not os.path.exists(ORIG_BLEND):
|
||||
raise SystemExit(f"[tone] FATAL: original blend not found: {ORIG_BLEND}")
|
||||
bpy.ops.wm.open_mainfile(filepath=ORIG_BLEND)
|
||||
ORIG_CACHE = {}
|
||||
for i in bpy.data.images:
|
||||
nm = i.name.lower()
|
||||
kind = ("base" if "basecolor" in nm else
|
||||
"rm" if "_rm" in nm else
|
||||
"normal" if "normal" in nm else None)
|
||||
if kind and kind not in ORIG_CACHE:
|
||||
ORIG_CACHE[kind] = (getpx(i)[:, :, :3].astype(np.float64), tuple(i.size), i.name)
|
||||
log(f"cached originals from {ORIG_BLEND}: "
|
||||
f"{ {k: (v[2], v[1]) for k, v in ORIG_CACHE.items()} }")
|
||||
|
||||
bpy.ops.wm.open_mainfile(filepath=BLEND)
|
||||
ob = max([o for o in bpy.data.objects if o.type == 'MESH'],
|
||||
key=lambda o: len(o.data.vertices))
|
||||
log(f"body {ob.name} {len(ob.data.vertices)}v")
|
||||
|
||||
# ---- which image is WIRED, and which orphan copy is the untouched original ----
|
||||
wired = {}
|
||||
for ms in ob.material_slots:
|
||||
mat = ms.material
|
||||
if not mat or not mat.node_tree:
|
||||
continue
|
||||
for n in mat.node_tree.nodes:
|
||||
if n.type != 'TEX_IMAGE' or not n.image:
|
||||
continue
|
||||
for o in n.outputs:
|
||||
for lk in o.links:
|
||||
tn = lk.to_node.name.lower()
|
||||
if "principled" in tn or lk.to_socket.name == "Base Color":
|
||||
wired["base"] = n.image
|
||||
elif "normal map" in tn:
|
||||
wired["normal"] = n.image
|
||||
elif "separate" in tn:
|
||||
wired["rm"] = n.image
|
||||
log(f"wired: { {k: v.name for k, v in wired.items()} }")
|
||||
if "base" not in wired:
|
||||
raise SystemExit("[tone] FATAL: could not find the wired basecolor")
|
||||
|
||||
|
||||
def find_original(kind, target):
|
||||
"""The cached pristine version of this map, checked for size and for actually differing."""
|
||||
if kind not in ORIG_CACHE:
|
||||
return None
|
||||
arr, size, nm = ORIG_CACHE[kind]
|
||||
if size != tuple(target.size):
|
||||
log(f" {kind}: size {size} != wired {tuple(target.size)} — unusable")
|
||||
return None
|
||||
changed = int((np.abs(getpx(target)[:, :, :3] - arr).max(axis=2) > DIFF_T).sum())
|
||||
log(f" {kind}: original '{nm}', {changed} texels differ from wired")
|
||||
if changed < 1000:
|
||||
return None
|
||||
return (arr, changed, nm)
|
||||
|
||||
|
||||
orig = find_original("base", wired["base"])
|
||||
if orig is None:
|
||||
raise SystemExit("[tone] FATAL: no usable untouched original basecolor")
|
||||
O, n_changed, oname = orig
|
||||
log(f"original = '{oname}' ({n_changed} texels differ)")
|
||||
|
||||
A4 = getpx(wired["base"])
|
||||
A = A4[:, :, :3].astype(np.float64)
|
||||
h, w = A.shape[:2]
|
||||
mask = np.abs(A - O).max(axis=2) > DIFF_T
|
||||
log(f"patch mask: {int(mask.sum())} texels ({100.0*mask.sum()/(w*h):.2f}% of atlas)")
|
||||
|
||||
|
||||
# ---- split into blobs so each is levelled against ITS OWN surroundings ----
|
||||
def blobs_of(m, min_px=1500):
|
||||
lab = np.zeros(m.shape, dtype=np.int32)
|
||||
cur = 0
|
||||
out = []
|
||||
ys, xs = np.nonzero(m)
|
||||
seen = np.zeros(m.shape, dtype=bool)
|
||||
from collections import deque
|
||||
for y0, x0 in zip(ys, xs):
|
||||
if seen[y0, x0]:
|
||||
continue
|
||||
cur += 1
|
||||
q = deque([(y0, x0)])
|
||||
seen[y0, x0] = True
|
||||
cells = []
|
||||
while q:
|
||||
y, x = q.popleft()
|
||||
cells.append((y, x))
|
||||
for dy, dx in ((1, 0), (-1, 0), (0, 1), (0, -1)):
|
||||
yy, xx = y + dy, x + dx
|
||||
if 0 <= yy < m.shape[0] and 0 <= xx < m.shape[1] \
|
||||
and m[yy, xx] and not seen[yy, xx]:
|
||||
seen[yy, xx] = True
|
||||
q.append((yy, xx))
|
||||
if len(cells) >= min_px:
|
||||
lab[tuple(np.array(cells).T)] = cur
|
||||
out.append((cur, len(cells)))
|
||||
return lab, out
|
||||
|
||||
|
||||
lab, blist = blobs_of(mask)
|
||||
log(f"blobs >=1500 px: {len(blist)} (covering {sum(b[1] for b in blist)} texels)")
|
||||
|
||||
|
||||
def solve_level(cur_img, orig_img, m_blob, tag):
|
||||
"""Harmonic correction field E over m_blob with border BC = local mismatch vs untouched skin.
|
||||
Returns E (same shape as the crop) and diagnostics."""
|
||||
ys, xs = np.nonzero(m_blob)
|
||||
y0, y1 = max(0, ys.min() - RING - 2), min(h, ys.max() + RING + 3)
|
||||
x0, x1 = max(0, xs.min() - RING - 2), min(w, xs.max() + RING + 3)
|
||||
M = m_blob[y0:y1, x0:x1]
|
||||
C = cur_img[y0:y1, x0:x1]
|
||||
Og = orig_img[y0:y1, x0:x1]
|
||||
allm = mask[y0:y1, x0:x1]
|
||||
|
||||
# untouched skin usable as a reference: outside EVERY patch, and plausibly skin
|
||||
lum = Og.mean(axis=2)
|
||||
usable = (~allm) & (lum > 0.12)
|
||||
# robust reject: compare to the median of the ring around this blob
|
||||
ring = dil(M, RING) & usable
|
||||
if ring.sum() < 50:
|
||||
return None, None
|
||||
med = np.median(Og[ring], axis=0)
|
||||
mad = np.median(np.abs(Og[ring] - med), axis=0) + 1e-4
|
||||
ok = (np.abs(Og - med) < (6.0 * mad)).all(axis=2) & usable
|
||||
|
||||
# border texels of the blob, and their mismatch D
|
||||
nb_sum = np.zeros_like(C)
|
||||
nb_cnt = np.zeros(M.shape)
|
||||
for dy, dx in ((1, 0), (-1, 0), (0, 1), (0, -1)):
|
||||
Sh = np.roll(Og * ok[:, :, None], (dy, dx), axis=(0, 1))
|
||||
Wh = np.roll(ok.astype(np.float64), (dy, dx), axis=(0, 1))
|
||||
nb_sum += Sh
|
||||
nb_cnt += Wh
|
||||
border = M & (nb_cnt > 0)
|
||||
if border.sum() < 20:
|
||||
return None, None
|
||||
D = np.zeros_like(C)
|
||||
D[border] = nb_sum[border] / nb_cnt[border, None] - C[border]
|
||||
|
||||
# cascadic multigrid: solve coarse, upsample, refine
|
||||
def restrict(x, msk):
|
||||
Hh, Ww = x.shape[:2]
|
||||
H2, W2 = (Hh + 1) // 2, (Ww + 1) // 2
|
||||
acc = np.zeros((H2, W2, x.shape[2]))
|
||||
cw = np.zeros((H2, W2))
|
||||
for dy in (0, 1):
|
||||
for dx in (0, 1):
|
||||
sub = x[dy::2, dx::2]
|
||||
sm = msk[dy::2, dx::2].astype(np.float64)
|
||||
acc[:sub.shape[0], :sub.shape[1]] += sub * sm[:, :, None]
|
||||
cw[:sub.shape[0], :sub.shape[1]] += sm
|
||||
out = np.zeros_like(acc)
|
||||
nz = cw > 0
|
||||
out[nz] = acc[nz] / cw[nz, None]
|
||||
return out, cw > 0
|
||||
|
||||
levels = []
|
||||
Mi, Di, Bi = M, D, border
|
||||
while min(Mi.shape[:2]) > 8 and len(levels) < 7:
|
||||
levels.append((Mi, Di, Bi))
|
||||
Dn, _ = restrict(Di, Bi)
|
||||
Mn = restrict(Mi[:, :, None].astype(np.float64), Mi)[1]
|
||||
Bn = restrict(Bi[:, :, None].astype(np.float64), Bi)[1]
|
||||
Mi, Di, Bi = Mn, Dn, Bn
|
||||
E = np.zeros(levels[-1][0].shape + (3,))
|
||||
for li in range(len(levels) - 1, -1, -1):
|
||||
Ml, Dl, Bl = levels[li]
|
||||
if E.shape[:2] != Ml.shape[:2]:
|
||||
Eu = np.repeat(np.repeat(E, 2, axis=0), 2, axis=1)
|
||||
E = Eu[:Ml.shape[0], :Ml.shape[1]]
|
||||
E[Bl] = Dl[Bl]
|
||||
interior = Ml & ~Bl
|
||||
sweeps = 400 if li >= len(levels) - 2 else 60
|
||||
for _ in range(sweeps):
|
||||
acc = np.zeros_like(E)
|
||||
cw = np.zeros(E.shape[:2])
|
||||
for dy, dx in ((1, 0), (-1, 0), (0, 1), (0, -1)):
|
||||
acc += np.roll(E * Ml[:, :, None], (dy, dx), axis=(0, 1))
|
||||
cw += np.roll(Ml.astype(np.float64), (dy, dx), axis=(0, 1))
|
||||
nz = interior & (cw > 0)
|
||||
E[nz] = acc[nz] / cw[nz, None]
|
||||
E[Bl] = Dl[Bl]
|
||||
inner = M & ~dil(~M, 5)
|
||||
diag = dict(
|
||||
n=int(M.sum()),
|
||||
border=int(border.sum()),
|
||||
pre=(float(np.mean(C[inner].mean(axis=1) - med.mean())) if inner.sum() else float('nan')),
|
||||
Emean=float(E[M].mean()),
|
||||
Emax=float(np.abs(E[M]).max()),
|
||||
)
|
||||
return (slice(y0, y1), slice(x0, x1), M, E), diag
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# apply to basecolor
|
||||
# =============================================================================
|
||||
def level_image(img, orig_np, label):
|
||||
P4 = getpx(img)
|
||||
P = P4[:, :, :3].astype(np.float64)
|
||||
total = np.zeros_like(P)
|
||||
touched = np.zeros(P.shape[:2], dtype=bool)
|
||||
for bid, npx in sorted(blist, key=lambda t: -t[1]):
|
||||
mb = lab == bid
|
||||
res, diag = solve_level(P, orig_np, mb, f"{label}#{bid}")
|
||||
if res is None:
|
||||
log(f" {label} blob{bid}: skipped (no usable surrounding skin)")
|
||||
continue
|
||||
sy, sx, M, E = res
|
||||
total[sy, sx][M] += E[M]
|
||||
touched[sy, sx] |= M
|
||||
log(f" {label} blob{bid}: {npx:7d} px border {diag['border']:6d} "
|
||||
f"interior offset vs ring {diag['pre']:+.4f} -> correction mean "
|
||||
f"{diag['Emean']:+.4f} (max |E| {diag['Emax']:.4f})")
|
||||
out = np.clip(P + total, 0.0, 1.0)
|
||||
# report the residual step across the patch border
|
||||
b_in = touched & ~dil(~touched, 2)
|
||||
b_out = dil(touched, 3) & ~touched
|
||||
if b_in.any() and b_out.any():
|
||||
log(f" {label}: border step before {abs(P[b_in].mean()-P[b_out].mean()):.4f} "
|
||||
f"-> after {abs(out[b_in].mean()-out[b_out].mean()):.4f}")
|
||||
P4[:, :, :3] = out.astype(np.float32)
|
||||
img.pixels.foreach_set(P4.reshape(-1))
|
||||
img.pack()
|
||||
log(f" {label}: written + packed ({int(touched.sum())} texels corrected)")
|
||||
return touched
|
||||
|
||||
|
||||
tch = level_image(wired["base"], O, "basecolor")
|
||||
|
||||
# roughness/metallic: same levelling, so the patch stops reading as a different material
|
||||
if "rm" in wired:
|
||||
rm_orig = find_original("rm", wired["rm"])
|
||||
if rm_orig is not None and tuple(wired["rm"].size) == (w, h):
|
||||
level_image(wired["rm"], rm_orig[0], "rm")
|
||||
else:
|
||||
log("rm: no original copy or size mismatch — skipped")
|
||||
|
||||
# normal: the patch is perfectly flat; transplant skin grain so it stops reading as a decal
|
||||
if DO_NORMAL_GRAIN and "normal" in wired and tuple(wired["normal"].size) == (w, h):
|
||||
NM4 = getpx(wired["normal"])
|
||||
NM = NM4[:, :, :3].astype(np.float64)
|
||||
src_ok = ~dil(mask, 6)
|
||||
|
||||
def box1(a, r):
|
||||
def b1(x, axis):
|
||||
p = [(0, 0)] * x.ndim
|
||||
p[axis] = (r, r)
|
||||
cs = np.cumsum(np.pad(x, p, mode="edge"), axis=axis)
|
||||
return (np.take(cs, np.arange(2 * r, cs.shape[axis]), axis=axis) -
|
||||
np.take(cs, np.arange(0, cs.shape[axis] - 2 * r), axis=axis)) / (2 * r)
|
||||
return b1(b1(a, 0), 1)
|
||||
|
||||
grain = np.stack([NM[:, :, c] - box1(NM[:, :, c], 5) for c in range(3)], axis=2)
|
||||
cand = []
|
||||
for ty in range(0, h - GRAIN_T, GRAIN_T):
|
||||
for tx in range(0, w - GRAIN_T, GRAIN_T):
|
||||
if src_ok[ty:ty + GRAIN_T, tx:tx + GRAIN_T].all():
|
||||
cand.append((ty, tx))
|
||||
rng = np.random.RandomState(1234)
|
||||
cov = 0
|
||||
for ty in range(0, h - GRAIN_T + 1, GRAIN_T):
|
||||
for tx in range(0, w - GRAIN_T + 1, GRAIN_T):
|
||||
tm = mask[ty:ty + GRAIN_T, tx:tx + GRAIN_T]
|
||||
if not tm.any() or not cand:
|
||||
continue
|
||||
sy, sx = cand[rng.randint(len(cand))]
|
||||
blk = NM[ty:ty + GRAIN_T, tx:tx + GRAIN_T]
|
||||
blk[tm] += grain[sy:sy + GRAIN_T, sx:sx + GRAIN_T][tm]
|
||||
cov += int(tm.sum())
|
||||
NM4[:, :, :3] = np.clip(NM, 0, 1).astype(np.float32)
|
||||
wired["normal"].pixels.foreach_set(NM4.reshape(-1))
|
||||
wired["normal"].pack()
|
||||
log(f"normal grain: {cov} texels from {len(cand)} clean tiles + packed")
|
||||
|
||||
bpy.ops.wm.save_as_mainfile(filepath=OUT)
|
||||
log(f"WROTE {OUT}")
|
||||
print("TONE_DONE")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||