feat(characters): ship lena_leafbikini_base_v01 as female Ariki default

Lena with the leaf bikini sculpted into the mesh — a separate character
descending from her own Tripo original, not a lena_base_v02. Full-res
(1,029,360 v) at 1.777 m, now Ariki_Female_QuatSkin.glb + LOD1.

First nearest-surface graft in the rig lane: AccuRig had to be fed a 20:1
decimated bait, so the index-exact graft the two earlier ships used was
impossible. The bait recovers AccuRig's 9.346 mm offset in closed form, which
makes it a required input forever — copied into the ship folder, and rig-work's
"disposable" rule amended to say so. Mesh moved 0 mm, 0 unweighted verts.

lena_base_v01 goes superseded, not rolled-back: she lost the default slot but
Ariki_Female_QuatSkin_Nude.glb is untouched, so per rule 9 her folder stays put.

Also lands the leaf-cut lane (work/lena_leafbikini): hue-keyed seam detection
that removes the leaf shell and leaves both holes open by request, registered
as a lane milestone with its sha256. Corrects the rig-work trap note — baits do
carry embedded textures; it is AccuRig that strips them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-13 16:09:45 -07:00
parent d6f1587eb0
commit eae8327811
23 changed files with 2361 additions and 8 deletions
+75 -2
View File
@@ -139,7 +139,7 @@ names without touching the immutable delivered files.
|---|---|---|---| |---|---|---|---|
| `lena` | female | `female_lena_tripo` | `lena_base_v01` | | `lena` | female | `female_lena_tripo` | `lena_base_v01` |
| `mako` | male | `male_base_bald_tripo_v1` | `mako_base_v01` | | `mako` | male | `male_base_bald_tripo_v1` | `mako_base_v01` |
| `lena_leafbikini` | female | `female_lena_leafbikini_tripo` | — (none yet; ancestor registered 2026-08-13, derivatives pending) | | `lena_leafbikini` | female | `female_lena_leafbikini_tripo` | `lena_leafbikini_base_v01` (live lane: `work/lena_leafbikini`) |
Spelling history for the male: game wiki "Mako", AccuRig-era typo "moka", also "moko". Spelling history for the male: game wiki "Mako", AccuRig-era typo "moka", also "moko".
Canonical filename token is **`mako`**; the others are never used in new files. Canonical filename token is **`mako`**; the others are never used in new files.
@@ -152,8 +152,15 @@ Status vocabulary: `shipped / superseded / rolled-back`.
| folder | character | date | status | ship name in ariki-game | sha256 (repo-side body) | | folder | character | date | status | ship name in ariki-game | sha256 (repo-side body) |
|---|---|---|---|---|---| |---|---|---|---|---|---|
| `female/lena_base_v01` | `lena` | 2026-08-10 | shipped | `Ariki_Female_QuatSkin_Nude.glb`, and swapped into `Ariki_Female_QuatSkin.glb` | `0d602accef0992a6a0dfffa5b73b1f9b702400df5258ff7517f786f11050f4d0` | | `female/lena_base_v01` | `lena` | 2026-08-10 | superseded | `Ariki_Female_QuatSkin_Nude.glb`, and swapped into `Ariki_Female_QuatSkin.glb` | `0d602accef0992a6a0dfffa5b73b1f9b702400df5258ff7517f786f11050f4d0` |
| `male/mako_base_v01` | `mako` | 2026-08-12 | shipped | `Ariki_Male_Mako.glb` (male Ariki default, lineage `MALE-MAKO-FULLHEAD-V1`) | `220714a2fb6283188be3834214877204533fde78de9a6ffa03ed85e1167735d3` | | `male/mako_base_v01` | `mako` | 2026-08-12 | shipped | `Ariki_Male_Mako.glb` (male Ariki default, lineage `MALE-MAKO-FULLHEAD-V1`) | `220714a2fb6283188be3834214877204533fde78de9a6ffa03ed85e1167735d3` |
| `female/lena_leafbikini_base_v01` | `lena_leafbikini` | 2026-08-13 | shipped | `Ariki_Female_QuatSkin.glb` (female Ariki default) + `Ariki_Female_QuatSkin_LowPoly_40.glb` (LOD1) | `bbfd8ac3919ad171cab76d62bec133d1f149ee3cf3c5d27f7408d502e99d678e` |
`lena_base_v01` is marked **superseded**, not rolled-back: she was displaced from
`Ariki_Female_QuatSkin.glb` on 2026-08-13 but her own ship name
`Ariki_Female_QuatSkin_Nude.glb` is untouched and still in the game. Per rule 9 her folder
moves to `characters/archive/` only when that file goes too. Nothing about her was wrong —
the game simply changed which body is the female default.
### `lena_base_v01` — what it is ### `lena_base_v01` — what it is
@@ -200,3 +207,69 @@ Lena's head, `MALE-MAKO-D-V1`, kept on disk as comparison) as the male Ariki def
**Known decisions:** height 1.818 m is provisional (IB-lane taller-than-Lena convention, **Known decisions:** height 1.818 m is provisional (IB-lane taller-than-Lena convention,
not eye-locked); ship name avoids the `QuatSkin`/`QuatBody` tokens because not eye-locked); ship name avoids the `QuatSkin`/`QuatBody` tokens because
`PlayerController` string-gates on them; no male LOD1 yet. Details in the folder README. `PlayerController` string-gates on them; no male LOD1 yet. Details in the folder README.
### `lena_leafbikini_base_v01` — what it is
Lena wearing a leaf bikini **sculpted into the mesh** — not a garment, so there is no cloth
to fit and no OutfitCatalog entry. Full-resolution Tripo scan (999,823 v after weld) at
1.777 m, the game's female default from 2026-08-13.
She is the first ship of a **separate character**, not a `lena_base_v02`: she descends from
her own Tripo original and shares no geometry with the nude sculpt, which is why the
Characters table has carried a `lena_leafbikini` row since the ancestor was registered.
| step | what happened |
|---|---|
| bait | `tools/rigbait_decimate.py` body 24k / head 14k / hands 40k → 51,682 v, no scale, no recenter |
| rig | AccuRig 1.10.1822.1, manual, on the bait → 101 `CC_Base_*` bones |
| graft | **nearest-surface** (`work/lena_leafbikini/01_graft.py`) — 51,682 v carrier onto the 1,029,360 v pristine mesh; mesh moved 0 mm, 0 unweighted verts, rest-pose drift 0.000239 mm |
| quatskin | `ariki-game/tools/make_lena_fullres_quatskin.py` `LENA_CLEAR_SPLIT_NORMALS=1` → 65 joints, 1.777 m, rigid mitts |
**First nearest-surface graft in this lane.** The two earlier ships were index-exact because
AccuRig had been given the shipping mesh whole; a 20:1 decimated bait makes that impossible.
AccuRig's 9.346 mm offset is recovered in closed form by fitting the rigged carrier against
the **bait**, which is index-exact with it and already in the GLB's space — so the bait is a
required input to the graft, not disposable, and lives in the ship folder.
**Known decisions:** single-sided, unlike `lena_base_v01` whose `doubleSided=true` was forced
by inward-wound patches this mesh does not have; no `Ariki_*_LeafBikini.glb` companion file
(the `_Nude.glb` precedent would have cost a duplicate 79 MB full-res body in LFS and a
duplicate Godot import — the new `LENA_OUT_GLB` override writes the canonical name directly);
LOD1 is 391,948 v, far heavier than the 14,559 v body it replaced, because
`make_lena_lowpoly.py` protects the head at full resolution and this head alone is 103,225 v.
---
## Lane artifacts (not ships)
Rule 11 exempts `work/` from the folder grammar and from rule 10, so nothing below has —
or needs — a Ships row. They are listed here because they are *registered lane milestones*:
someone else must be able to find out what a heavy binary sitting in a gitignored `v0N/`
dir is, and why. Each is `git add -f`'d against `characters/work/*/v0[0-9]/`, exactly as
that .gitignore rule anticipates.
| file | sha256 | date | built by | what it is |
|---|---|---|---|---|
| `work/lena_leafbikini/v01/lena_leafbikini_leafcut_sculpt_glb_v01.glb` | `8aa3421e98e985b57bda222a5cee6fd87343b6b773376c740bd907ae08db8897` | 2026-08-13 | `work/lena_leafbikini/04`+`05` | the leaf bikini **cut away at its seam, holes left open** |
### `lena_leafbikini_leafcut_sculpt_glb_v01` — what it is
`originals/female/female_lena_leafbikini_tripo.glb` with the leaf geometry deleted along
the line where it stops being Lena: 474,224 faces and 247,658 vertices removed (23.8% /
24.1%), leaving two open holes — bust and briefs. **The holes are not filled, by request.**
It exists so the seam itself can be judged before any repair is designed.
The leaves are welded into the body — one Tripo mesh, one material, one UV map — so they
are a bulge in the body surface, not a separable object. They are also **real geometry**,
which is the opposite of the case `lena_base_v01` had to solve: there the sculpted-in
underwear *was* the skin, painted on, so deleting it opened a hole and the fix was to
re-fair a rim crease (`tools/make_lena_nude_body.py`). Here there is a shell to remove, and
the seam is found by a **hue key** — skin 2040°, leaf 6080° — repaired on the mesh over
position-welded adjacency, then grown 2 rings so the cut lands just outside the rim.
`work/lena_leafbikini/README.md` carries the full reasoning and the regeneration commands.
Unrigged, because it was authored on the pristine original. That costs nothing: the graft
that produced the ship moved the mesh 0.000000 mm, so `lena_leafbikini_base_v01` holds the
same 1,029,360 vertices in the same order and `work/lena_leafbikini/leaf_mask.npz` indexes
either mesh. Cutting the shipped body is the same stage 05 with a different input — and
per rule 2 its result is a new ship folder, never an edit to the frozen one.
@@ -0,0 +1,104 @@
# lena_leafbikini_base_v01 — SHIPPED 2026-08-13
**Frozen.** Nothing in this folder is ever edited or re-exported (REGISTRY rule 2).
Any change at all becomes `lena_leafbikini_base_v02`.
The game's female base body. Lena wearing a leaf bikini that is **sculpted into the
mesh**, not a garment — there is no cloth to fit, no OutfitCatalog entry, and no way to
take it off. Full-resolution Tripo scan (999,823 v after weld) on the 65-bone Quaternius
skeleton. She replaced the nude `lena_base_v01` body as `Ariki_Female_QuatSkin.glb`.
First ship for the `lena_leafbikini` character. She is a **separate character lineage**
from `lena`, not a `lena_base_v02` — she descends from her own Tripo original
(`originals/female/female_lena_leafbikini_tripo.glb`, registered 2026-08-13) and shares
no geometry with the nude sculpt.
## Contents
| file | what |
|---|---|
| `lena_leafbikini_base_accurig_glb_v01.glb` | the body as it left this repo — pristine mesh + 3× 4K atlas + AccuRig skeleton, 1,029,360 v / 101 joints |
| `rig/lena_leafbikini_base_accurig_fbx_v01/` | **irreplaceable.** Output of a manual AccuRig 1.10.1822.1 session — no script regenerates this. Settings JSON kept alongside as provenance |
| `rig/lena_leafbikini_base_decimated_fbx_v01.fbx` | the 51,682 v carrier fed *into* that AccuRig session. Regenerable (`tools/rigbait_decimate.py`) but kept because the graft **cannot run without it** — see "the bait is the bridge" below |
| `preview/` | renders of the shipped body; `rigbait_qa_front.png` is the pre-rig carrier for comparison |
## How she was built
| step | what happened |
|---|---|
| bait | `tools/rigbait_decimate.py`, budgets body 24k / head 14k / hands 40k → 51,682 v / 77,999 tris, no scale, no recenter |
| rig | AccuRig 1.10.1822.1, manual, on the bait → 101 `CC_Base_*` bones, 38 finger bones, both wrists |
| graft | **nearest-surface**, `work/lena_leafbikini/01_graft.py` → weights off the 51,682 v carrier onto the pristine 1,029,360 v mesh. Mesh moved 0.000000 mm, 0 unweighted verts, weight sums exactly 1.0, deformed-at-rest drift 0.000239 mm |
| quatskin | `ariki-game/tools/make_lena_fullres_quatskin.py` with `LENA_CLEAR_SPLIT_NORMALS=1` → 65 joints, 1.777 m, rigid-mitt fingers |
## The bait is the bridge
This is the one structural difference from `lena_base_v01` and `mako_base_v01`, and it is
worth understanding before touching the lane again.
Both of those grafted **index-exact**: AccuRig was handed the shipping mesh whole and gave
back the same vertices in the same order, so weights copied index-to-index. Here AccuRig was
handed a carrier decimated **20:1** from the shipping mesh, so there is no index
correspondence and proximity is the only available mechanism.
That leaves the problem of *space*: AccuRig returns the carrier moved (measured here at
**9.346 mm** in Y, scale exactly 1.000000 — the same family as the July lane's 4 cm offset
trap), so its skeleton is in AccuRig's space, not the GLB's. Nothing is guessed to fix this.
The rigged FBX is index-exact against **the bait** (51,682 v both, same order), and the bait
is in the GLB's space by construction because `rigbait_decimate.py` applies no scale and no
recenter — confirmed exactly, bait and GLB sharing height 0.9792 and half-span 0.4583 to four
decimals. So fitting rigged→bait recovers AccuRig's transform in closed form.
**This is why the bait is in this folder.** Delete it and `01_graft.py` has nothing to fit
against and cannot run at all.
## Three facts that bite
- **`LENA_CLEAR_SPLIT_NORMALS=1` is load-bearing.** A raw Tripo delivery carries custom split
normals *and* a `sharp_edge` layer; left in place they are the "v8 specks"/faceting defect.
The converter's default is OFF because its default input is the v02 head-transplant body,
which needs them kept. Both layers must go together — clearing only the normals lets Blender
re-detect sharpness by angle and the facets come straight back.
- **She is single-sided**, unlike `lena_base_v01`, whose README calls `doubleSided=true`
load-bearing. That is not a regression: the inward-wound patches that forced it were an
artifact of the nude lane's re-atlas/membrane work, and this mesh never went through any of
it. Verified against the pristine delivery: `doubleSided` unset on the original.
- **AccuRig tidied one armpit.** Rigged-vs-bait residual is p50 **0.0009 mm** — bit-exact for
the bulk — with 1.01% of verts over 1 mm, all one cluster at height fraction 0.690.75 around
x=0.23. The hands, which is where a bad proximity transfer actually hurts, came back at mean
**0.020 mm** / max 1.45 mm. The graft's carrier gate measures the *shape* of that distribution
for exactly this reason; an earlier max-only threshold rejected a graft that is fine.
## Where it went
Converted by `ariki-game/tools/make_lena_fullres_quatskin.py` and imported as:
- `Ariki_Female_QuatSkin.glb` — sha256 `b6ca465ae3623e9fb3cc969f521d7cb89ad54b74271bb743fc594374864d56e6`.
The body she replaced is kept as `Ariki_Female_QuatSkin_PRESWAP_2026-08-13.glb.bak`.
- `Ariki_Female_QuatSkin_LowPoly_40.glb` — LOD1, sha256
`e770e4dce506492a28d0dffcaf609b6adbab35a7b63681923bea9028970ce47d`, rebuilt from her by
`tools/make_lena_lowpoly.py -- 0.4`. Predecessor kept as
`Ariki_Female_QuatSkin_LowPoly_40_PRESWAP_2026-08-13.glb.bak`.
Textures are packed in the GLB; nothing ships loose.
**Known decision:** no `Ariki_Female_QuatSkin_LeafBikini.glb` companion file. `lena_base_v01`
shipped one (`_Nude.glb`) byte-identical to the canonical body, because that is simply where
its converter wrote. Repeating it here would have put a second 79 MB full-res body into LFS and
made Godot import a duplicate 1 M-vertex mesh. The converter was pointed at the canonical name
via the new `LENA_OUT_GLB` override instead, and this folder is the repo-side master.
## Rebuilding / continuing
Recipe is `characters/work/lena_leafbikini/01_graft.py`. It needs three inputs, all preserved:
the pristine original, the AccuRig return in `rig/`, and the bait beside it.
```
blender --background --factory-startup --python characters/work/lena_leafbikini/01_graft.py -- \
characters/originals/female/female_lena_leafbikini_tripo.glb \
<rigged.fbx> <bait.fbx> <out.blend> <out.glb>
Q_CACHE=<qcache.json> LENA_SRC_BLEND=<out.blend> LENA_CLEAR_SPLIT_NORMALS=1 \
LENA_OUT_GLB=<ship.glb> blender --background --factory-startup \
--python ariki-game/tools/make_lena_fullres_quatskin.py
```
Binary file not shown.

After

Width:  |  Height:  |  Size: 315 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 322 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 308 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 304 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 721 KiB

@@ -0,0 +1,429 @@
{
"lena_leafbikini_tripo_rigged": {
"Version": "1.10.1822.1",
"Scene": {
"Name": true,
"SupportShaderSelect": true
},
"Export Directory": "C:/Users/Jeremy/tinqs/animation/characters/rig-work",
"Object": {
"lena_leafbikini_tripo_rigged": {
"Generation": "AccuRig",
"Physics": {
"Collision Shapes": {
"RL_BoneRoot": {},
"CC_Base_Hip": {},
"CC_Base_Pelvis": {},
"CC_Base_L_Thigh": {},
"CC_Base_L_Calf": {},
"CC_Base_L_Foot": {},
"CC_Base_L_ToeBaseShareBone": {},
"CC_Base_L_ToeBase": {},
"CC_Base_L_PinkyToe1": {},
"CC_Base_L_RingToe1": {},
"CC_Base_L_MidToe1": {},
"CC_Base_L_IndexToe1": {},
"CC_Base_L_BigToe1": {},
"CC_Base_L_CalfTwist01": {},
"CC_Base_L_CalfTwist02": {},
"CC_Base_L_KneeShareBone": {},
"CC_Base_L_ThighTwist01": {},
"CC_Base_L_ThighTwist02": {},
"CC_Base_R_Thigh": {},
"CC_Base_R_ThighTwist01": {},
"CC_Base_R_ThighTwist02": {},
"CC_Base_R_Calf": {},
"CC_Base_R_Foot": {},
"CC_Base_R_ToeBase": {},
"CC_Base_R_PinkyToe1": {},
"CC_Base_R_BigToe1": {},
"CC_Base_R_IndexToe1": {},
"CC_Base_R_MidToe1": {},
"CC_Base_R_RingToe1": {},
"CC_Base_R_ToeBaseShareBone": {},
"CC_Base_R_KneeShareBone": {},
"CC_Base_R_CalfTwist01": {},
"CC_Base_R_CalfTwist02": {},
"CC_Base_Waist": {},
"CC_Base_Spine01": {},
"CC_Base_Spine02": {},
"CC_Base_NeckTwist01": {},
"CC_Base_NeckTwist02": {},
"CC_Base_Head": {},
"CC_Base_FacialBone": {},
"CC_Base_JawRoot": {},
"CC_Base_Tongue01": {},
"CC_Base_Tongue02": {},
"CC_Base_Tongue03": {},
"CC_Base_Teeth02": {},
"CC_Base_R_Eye": {},
"CC_Base_L_Eye": {},
"CC_Base_UpperJaw": {},
"CC_Base_Teeth01": {},
"CC_Base_L_Clavicle": {},
"CC_Base_L_Upperarm": {},
"CC_Base_L_Forearm": {},
"CC_Base_L_ForearmTwist01": {},
"CC_Base_L_ForearmTwist02": {},
"CC_Base_L_ElbowShareBone": {},
"CC_Base_L_Hand": {},
"CC_Base_L_Mid1": {},
"CC_Base_L_Mid2": {},
"CC_Base_L_Mid3": {},
"CC_Base_L_Index1": {},
"CC_Base_L_Index2": {},
"CC_Base_L_Index3": {},
"CC_Base_L_Ring1": {},
"CC_Base_L_Ring2": {},
"CC_Base_L_Ring3": {},
"CC_Base_L_Pinky1": {},
"CC_Base_L_Pinky2": {},
"CC_Base_L_Pinky3": {},
"CC_Base_L_Thumb1": {},
"CC_Base_L_Thumb2": {},
"CC_Base_L_Thumb3": {},
"CC_Base_L_UpperarmTwist01": {},
"CC_Base_L_UpperarmTwist02": {},
"CC_Base_R_Clavicle": {},
"CC_Base_R_Upperarm": {},
"CC_Base_R_UpperarmTwist01": {},
"CC_Base_R_UpperarmTwist02": {},
"CC_Base_R_Forearm": {},
"CC_Base_R_ForearmTwist01": {},
"CC_Base_R_ForearmTwist02": {},
"CC_Base_R_ElbowShareBone": {},
"CC_Base_R_Hand": {},
"CC_Base_R_Mid1": {},
"CC_Base_R_Mid2": {},
"CC_Base_R_Mid3": {},
"CC_Base_R_Ring1": {},
"CC_Base_R_Ring2": {},
"CC_Base_R_Ring3": {},
"CC_Base_R_Thumb1": {},
"CC_Base_R_Thumb2": {},
"CC_Base_R_Thumb3": {},
"CC_Base_R_Index1": {},
"CC_Base_R_Index2": {},
"CC_Base_R_Index3": {},
"CC_Base_R_Pinky1": {},
"CC_Base_R_Pinky2": {},
"CC_Base_R_Pinky3": {},
"CC_Base_R_RibsTwist": {},
"CC_Base_R_Breast": {},
"CC_Base_L_RibsTwist": {},
"CC_Base_L_Breast": {},
"meshes_0_": {}
}
},
"ExpressionSet": "4",
"Expression": {
"V_None": {
"Bones": {}
},
"V_Open": {
"Bones": {}
},
"V_Explosive": {
"Bones": {}
},
"V_Dental_Lip": {
"Bones": {}
},
"V_Tight_O": {
"Bones": {}
},
"V_Tight": {
"Bones": {}
},
"V_Wide": {
"Bones": {}
},
"V_Affricate": {
"Bones": {}
},
"V_Lip_Open": {
"Bones": {}
},
"V_Tongue_up": {
"Bones": {}
},
"V_Tongue_Raise": {
"Bones": {}
},
"V_Tongue_Out": {
"Bones": {}
},
"V_Tongue_Narrow": {
"Bones": {}
},
"V_Tongue_Lower": {
"Bones": {}
},
"V_Tongue_Curl_U": {
"Bones": {}
},
"V_Tongue_Curl_D": {
"Bones": {}
},
"Brow_Raise_Inner_L": {
"Bones": {}
},
"Brow_Raise_Inner_R": {
"Bones": {}
},
"Brow_Raise_Outer_L": {
"Bones": {}
},
"Brow_Raise_Outer_R": {
"Bones": {}
},
"Brow_Drop_L": {
"Bones": {}
},
"Brow_Drop_R": {
"Bones": {}
},
"Nose_Sneer_L": {
"Bones": {}
},
"Nose_Sneer_R": {
"Bones": {}
},
"Cheek_Raise_L": {
"Bones": {}
},
"Cheek_Raise_R": {
"Bones": {}
},
"Cheek_Puff_L": {
"Bones": {}
},
"Cheek_Puff_R": {
"Bones": {}
},
"Head_Turn_Up": {
"Bones": {}
},
"Head_Turn_Down": {
"Bones": {}
},
"Head_Turn_L": {
"Bones": {}
},
"Head_Turn_R": {
"Bones": {}
},
"Head_Tilt_L": {
"Bones": {}
},
"Head_Tilt_R": {
"Bones": {}
},
"Head_L": {
"Bones": {}
},
"Head_R": {
"Bones": {}
},
"Head_Forward": {
"Bones": {}
},
"Head_Backward": {
"Bones": {}
},
"Jaw_Open": {
"Bones": {}
},
"Jaw_Forward": {
"Bones": {}
},
"Jaw_L": {
"Bones": {}
},
"Jaw_R": {
"Bones": {}
},
"Tongue_Out": {
"Bones": {}
},
"Tongue_Up": {
"Bones": {}
},
"Tongue_Down": {
"Bones": {}
},
"Tongue_Tip_Up": {
"Bones": {}
},
"Tongue_Tip_Down": {
"Bones": {}
},
"Tongue_Narrow": {
"Bones": {}
},
"Tongue_Wide": {
"Bones": {}
},
"Tongue_Roll": {
"Bones": {}
},
"Tongue_L": {
"Bones": {}
},
"Tongue_R": {
"Bones": {}
},
"Tongue_Bulge_L": {
"Bones": {}
},
"Tongue_Bulge_R": {
"Bones": {}
},
"Eye_Blink_L": {
"Bones": {}
},
"Eye_Blink_R": {
"Bones": {}
},
"Eye_Squint_L": {
"Bones": {}
},
"Eye_Squint_R": {
"Bones": {}
},
"Eye_Wide_L": {
"Bones": {}
},
"Eye_Wide_R": {
"Bones": {}
},
"Eye_L_Look_L": {
"Bones": {}
},
"Eye_R_Look_L": {
"Bones": {}
},
"Eye_L_Look_R": {
"Bones": {}
},
"Eye_R_Look_R": {
"Bones": {}
},
"Eye_L_Look_Up": {
"Bones": {}
},
"Eye_R_Look_Up": {
"Bones": {}
},
"Eye_L_Look_Down": {
"Bones": {}
},
"Eye_R_Look_Down": {
"Bones": {}
},
"Mouth_Smile_L": {
"Bones": {}
},
"Mouth_Smile_R": {
"Bones": {}
},
"Mouth_Frown_L": {
"Bones": {}
},
"Mouth_Frown_R": {
"Bones": {}
},
"Mouth_Stretch_L": {
"Bones": {}
},
"Mouth_Stretch_R": {
"Bones": {}
},
"Mouth_Dimple_L": {
"Bones": {}
},
"Mouth_Dimple_R": {
"Bones": {}
},
"Mouth_Press_L": {
"Bones": {}
},
"Mouth_Press_R": {
"Bones": {}
},
"Mouth_Pucker": {
"Bones": {}
},
"Mouth_Funnel": {
"Bones": {}
},
"Mouth_Roll_In_Upper": {
"Bones": {}
},
"Mouth_Roll_In_Lower": {
"Bones": {}
},
"Mouth_L": {
"Bones": {}
},
"Mouth_R": {
"Bones": {}
},
"Mouth_Shrug_Upper": {
"Bones": {}
},
"Mouth_Shrug_Lower": {
"Bones": {}
},
"Mouth_Up_Upper_L": {
"Bones": {}
},
"Mouth_Up_Upper_R": {
"Bones": {}
},
"Mouth_Down_Lower_L": {
"Bones": {}
},
"Mouth_Down_Lower_R": {
"Bones": {}
},
"Mouth_Close": {
"Bones": {}
}
},
"Constraint": {},
"Meshes": {
"meshes_0_": {
"SubD Level": 0,
"Materials": {
"tripo_material_a8fc2a79_1dd1_4434_92b9_731655894339": {
"Material Type": "Pbr",
"MultiUV Index": 0,
"Two Side": true,
"Diffuse Color": [
204.0,
204.0,
204.0
],
"Ambient Color": [
0.0,
0.0,
0.0
],
"Specular Color": [
204.0,
204.0,
204.0
],
"Opacity": 1.0,
"Self Illumination": 0.0,
"Textures": {},
"Resource Textures": {}
}
}
}
}
}
}
}
}
+20 -6
View File
@@ -8,6 +8,14 @@ skeleton out of AccuRig and back. AccuRig refuses the raw ~1.9M-tri FBX outright
the pristine GLB and only the *skeleton* from the bait — see the pristine GLB and only the *skeleton* from the bait — see
`.agents/plans/rig-graft-lane-2026-08-04.md` for the lane. `.agents/plans/rig-graft-lane-2026-08-04.md` for the lane.
**"Disposable" now has one exception, and it is about reproducibility, not shipping.**
A bait that fed a *nearest-surface* graft is a required input to that graft forever: it
is the only thing index-exact with the AccuRig return that is also in the pristine GLB's
space, so it is what recovers AccuRig's offset in closed form. Such a bait gets **copied**
(not moved) into the ship folder alongside the AccuRig output. It is still never shipped
to the game, and the copy here stays disposable. First case:
`lena_leafbikini_base_v01` — see that folder's README, "the bait is the bridge".
Per `../REGISTRY.md` rule 11 this folder is exempt from the filename grammar and gets Per `../REGISTRY.md` rule 11 this folder is exempt from the filename grammar and gets
no Derived-artifacts rows; the grafted body's notes column names the bait its skeleton no Derived-artifacts rows; the grafted body's notes column names the bait its skeleton
came from. The tool-era names are kept on purpose so these stay recognisable as came from. The tool-era names are kept on purpose so these stay recognisable as
@@ -18,7 +26,7 @@ throwaway.
| file | tris | notes | | file | tris | notes |
|---|---|---| |---|---|---|
| `lena_tripo_rigbait.fbx` | 77,999 | from `originals/female/female_lena_tripo.glb` | | `lena_tripo_rigbait.fbx` | 77,999 | from `originals/female/female_lena_tripo.glb` |
| `lena_leafbikini_tripo_rigbait.fbx` | 77,999 | from `originals/female/female_lena_leafbikini_tripo.glb` (2026-08-13); leaf-bikini garment sculpted on — fine for AccuRig (silhouette only), fingers distinct | | `lena_leafbikini_tripo_rigbait.fbx` | 77,999 | from `originals/female/female_lena_leafbikini_tripo.glb` (2026-08-13); leaf-bikini garment sculpted on — fine for AccuRig (silhouette only), fingers distinct. **Rigged and shipped 2026-08-13** as `lena_leafbikini_base_v01`; copy preserved in that ship folder |
| `mako_tripo_rigbait.fbx` | 78,000 | from `originals/male/male_base_bald_tripo_v1.glb` | | `mako_tripo_rigbait.fbx` | 78,000 | from `originals/male/male_base_bald_tripo_v1.glb` |
| `*_qa_front.png`, `*_qa_hand.png` | — | QA renders; fingers fully distinct, no webbing | | `*_qa_front.png`, `*_qa_hand.png` | — | QA renders; fingers fully distinct, no webbing |
@@ -30,11 +38,17 @@ sibling emitted from the same decimation; when that lands, these are superseded.
## Traps ## Traps
- Baits are geometry-only (~3.5 MB — a GLB's packed textures don't survive the FBX - Baits are geometry-only (~3.5 MB), so AccuRig shows a grey model. That's fine for
embed), so AccuRig shows a grey model. That's fine for rigging, but it means the rigging, but it means the July "Dummy001 export" check can't use textures: **verify
July "Dummy001 export" check can't use textures: **verify AccuRig's output by mesh AccuRig's output by mesh name + tri count instead.**
name + tri count instead.** *Correction 2026-08-13:* the parenthetical used to read "a GLB's packed textures don't
- AccuRig exports come back to this folder as `<character>_tripo_accurig.fbx`. survive the FBX embed". They do — `rigbait_decimate.py` exports with
`embed_textures=True`, and `lena_leafbikini_tripo_rigbait.fbx` carries 4K basecolor and
normal in its `.fbm/`. What is actually true is the reverse direction: **AccuRig strips
them**, and its return has no images at all. Verify by mesh name + tri count either way.
- AccuRig exports come back to this folder as `<character>_tripo_rigged.fbx` (plus a
settings `.json` and an often-empty `.fbm/`). The older `_tripo_accurig.fbx` spelling in
the lane plan was never what the tool actually wrote.
- These files used to live in `ariki-game/assets/models/characters/rig-work/` and - These files used to live in `ariki-game/assets/models/characters/rig-work/` and
needed a `.gdignore` to stop Godot importing them. Nothing here is a game asset; needed a `.gdignore` to stop Godot importing them. Nothing here is a game asset;
that is precisely why the folder moved. that is precisely why the folder moved.
Binary file not shown.
@@ -0,0 +1,429 @@
{
"lena_leafbikini_tripo_rigged": {
"Version": "1.10.1822.1",
"Scene": {
"Name": true,
"SupportShaderSelect": true
},
"Export Directory": "C:/Users/Jeremy/tinqs/animation/characters/rig-work",
"Object": {
"lena_leafbikini_tripo_rigged": {
"Generation": "AccuRig",
"Physics": {
"Collision Shapes": {
"RL_BoneRoot": {},
"CC_Base_Hip": {},
"CC_Base_Pelvis": {},
"CC_Base_L_Thigh": {},
"CC_Base_L_Calf": {},
"CC_Base_L_Foot": {},
"CC_Base_L_ToeBaseShareBone": {},
"CC_Base_L_ToeBase": {},
"CC_Base_L_PinkyToe1": {},
"CC_Base_L_RingToe1": {},
"CC_Base_L_MidToe1": {},
"CC_Base_L_IndexToe1": {},
"CC_Base_L_BigToe1": {},
"CC_Base_L_CalfTwist01": {},
"CC_Base_L_CalfTwist02": {},
"CC_Base_L_KneeShareBone": {},
"CC_Base_L_ThighTwist01": {},
"CC_Base_L_ThighTwist02": {},
"CC_Base_R_Thigh": {},
"CC_Base_R_ThighTwist01": {},
"CC_Base_R_ThighTwist02": {},
"CC_Base_R_Calf": {},
"CC_Base_R_Foot": {},
"CC_Base_R_ToeBase": {},
"CC_Base_R_PinkyToe1": {},
"CC_Base_R_BigToe1": {},
"CC_Base_R_IndexToe1": {},
"CC_Base_R_MidToe1": {},
"CC_Base_R_RingToe1": {},
"CC_Base_R_ToeBaseShareBone": {},
"CC_Base_R_KneeShareBone": {},
"CC_Base_R_CalfTwist01": {},
"CC_Base_R_CalfTwist02": {},
"CC_Base_Waist": {},
"CC_Base_Spine01": {},
"CC_Base_Spine02": {},
"CC_Base_NeckTwist01": {},
"CC_Base_NeckTwist02": {},
"CC_Base_Head": {},
"CC_Base_FacialBone": {},
"CC_Base_JawRoot": {},
"CC_Base_Tongue01": {},
"CC_Base_Tongue02": {},
"CC_Base_Tongue03": {},
"CC_Base_Teeth02": {},
"CC_Base_R_Eye": {},
"CC_Base_L_Eye": {},
"CC_Base_UpperJaw": {},
"CC_Base_Teeth01": {},
"CC_Base_L_Clavicle": {},
"CC_Base_L_Upperarm": {},
"CC_Base_L_Forearm": {},
"CC_Base_L_ForearmTwist01": {},
"CC_Base_L_ForearmTwist02": {},
"CC_Base_L_ElbowShareBone": {},
"CC_Base_L_Hand": {},
"CC_Base_L_Mid1": {},
"CC_Base_L_Mid2": {},
"CC_Base_L_Mid3": {},
"CC_Base_L_Index1": {},
"CC_Base_L_Index2": {},
"CC_Base_L_Index3": {},
"CC_Base_L_Ring1": {},
"CC_Base_L_Ring2": {},
"CC_Base_L_Ring3": {},
"CC_Base_L_Pinky1": {},
"CC_Base_L_Pinky2": {},
"CC_Base_L_Pinky3": {},
"CC_Base_L_Thumb1": {},
"CC_Base_L_Thumb2": {},
"CC_Base_L_Thumb3": {},
"CC_Base_L_UpperarmTwist01": {},
"CC_Base_L_UpperarmTwist02": {},
"CC_Base_R_Clavicle": {},
"CC_Base_R_Upperarm": {},
"CC_Base_R_UpperarmTwist01": {},
"CC_Base_R_UpperarmTwist02": {},
"CC_Base_R_Forearm": {},
"CC_Base_R_ForearmTwist01": {},
"CC_Base_R_ForearmTwist02": {},
"CC_Base_R_ElbowShareBone": {},
"CC_Base_R_Hand": {},
"CC_Base_R_Mid1": {},
"CC_Base_R_Mid2": {},
"CC_Base_R_Mid3": {},
"CC_Base_R_Ring1": {},
"CC_Base_R_Ring2": {},
"CC_Base_R_Ring3": {},
"CC_Base_R_Thumb1": {},
"CC_Base_R_Thumb2": {},
"CC_Base_R_Thumb3": {},
"CC_Base_R_Index1": {},
"CC_Base_R_Index2": {},
"CC_Base_R_Index3": {},
"CC_Base_R_Pinky1": {},
"CC_Base_R_Pinky2": {},
"CC_Base_R_Pinky3": {},
"CC_Base_R_RibsTwist": {},
"CC_Base_R_Breast": {},
"CC_Base_L_RibsTwist": {},
"CC_Base_L_Breast": {},
"meshes_0_": {}
}
},
"ExpressionSet": "4",
"Expression": {
"V_None": {
"Bones": {}
},
"V_Open": {
"Bones": {}
},
"V_Explosive": {
"Bones": {}
},
"V_Dental_Lip": {
"Bones": {}
},
"V_Tight_O": {
"Bones": {}
},
"V_Tight": {
"Bones": {}
},
"V_Wide": {
"Bones": {}
},
"V_Affricate": {
"Bones": {}
},
"V_Lip_Open": {
"Bones": {}
},
"V_Tongue_up": {
"Bones": {}
},
"V_Tongue_Raise": {
"Bones": {}
},
"V_Tongue_Out": {
"Bones": {}
},
"V_Tongue_Narrow": {
"Bones": {}
},
"V_Tongue_Lower": {
"Bones": {}
},
"V_Tongue_Curl_U": {
"Bones": {}
},
"V_Tongue_Curl_D": {
"Bones": {}
},
"Brow_Raise_Inner_L": {
"Bones": {}
},
"Brow_Raise_Inner_R": {
"Bones": {}
},
"Brow_Raise_Outer_L": {
"Bones": {}
},
"Brow_Raise_Outer_R": {
"Bones": {}
},
"Brow_Drop_L": {
"Bones": {}
},
"Brow_Drop_R": {
"Bones": {}
},
"Nose_Sneer_L": {
"Bones": {}
},
"Nose_Sneer_R": {
"Bones": {}
},
"Cheek_Raise_L": {
"Bones": {}
},
"Cheek_Raise_R": {
"Bones": {}
},
"Cheek_Puff_L": {
"Bones": {}
},
"Cheek_Puff_R": {
"Bones": {}
},
"Head_Turn_Up": {
"Bones": {}
},
"Head_Turn_Down": {
"Bones": {}
},
"Head_Turn_L": {
"Bones": {}
},
"Head_Turn_R": {
"Bones": {}
},
"Head_Tilt_L": {
"Bones": {}
},
"Head_Tilt_R": {
"Bones": {}
},
"Head_L": {
"Bones": {}
},
"Head_R": {
"Bones": {}
},
"Head_Forward": {
"Bones": {}
},
"Head_Backward": {
"Bones": {}
},
"Jaw_Open": {
"Bones": {}
},
"Jaw_Forward": {
"Bones": {}
},
"Jaw_L": {
"Bones": {}
},
"Jaw_R": {
"Bones": {}
},
"Tongue_Out": {
"Bones": {}
},
"Tongue_Up": {
"Bones": {}
},
"Tongue_Down": {
"Bones": {}
},
"Tongue_Tip_Up": {
"Bones": {}
},
"Tongue_Tip_Down": {
"Bones": {}
},
"Tongue_Narrow": {
"Bones": {}
},
"Tongue_Wide": {
"Bones": {}
},
"Tongue_Roll": {
"Bones": {}
},
"Tongue_L": {
"Bones": {}
},
"Tongue_R": {
"Bones": {}
},
"Tongue_Bulge_L": {
"Bones": {}
},
"Tongue_Bulge_R": {
"Bones": {}
},
"Eye_Blink_L": {
"Bones": {}
},
"Eye_Blink_R": {
"Bones": {}
},
"Eye_Squint_L": {
"Bones": {}
},
"Eye_Squint_R": {
"Bones": {}
},
"Eye_Wide_L": {
"Bones": {}
},
"Eye_Wide_R": {
"Bones": {}
},
"Eye_L_Look_L": {
"Bones": {}
},
"Eye_R_Look_L": {
"Bones": {}
},
"Eye_L_Look_R": {
"Bones": {}
},
"Eye_R_Look_R": {
"Bones": {}
},
"Eye_L_Look_Up": {
"Bones": {}
},
"Eye_R_Look_Up": {
"Bones": {}
},
"Eye_L_Look_Down": {
"Bones": {}
},
"Eye_R_Look_Down": {
"Bones": {}
},
"Mouth_Smile_L": {
"Bones": {}
},
"Mouth_Smile_R": {
"Bones": {}
},
"Mouth_Frown_L": {
"Bones": {}
},
"Mouth_Frown_R": {
"Bones": {}
},
"Mouth_Stretch_L": {
"Bones": {}
},
"Mouth_Stretch_R": {
"Bones": {}
},
"Mouth_Dimple_L": {
"Bones": {}
},
"Mouth_Dimple_R": {
"Bones": {}
},
"Mouth_Press_L": {
"Bones": {}
},
"Mouth_Press_R": {
"Bones": {}
},
"Mouth_Pucker": {
"Bones": {}
},
"Mouth_Funnel": {
"Bones": {}
},
"Mouth_Roll_In_Upper": {
"Bones": {}
},
"Mouth_Roll_In_Lower": {
"Bones": {}
},
"Mouth_L": {
"Bones": {}
},
"Mouth_R": {
"Bones": {}
},
"Mouth_Shrug_Upper": {
"Bones": {}
},
"Mouth_Shrug_Lower": {
"Bones": {}
},
"Mouth_Up_Upper_L": {
"Bones": {}
},
"Mouth_Up_Upper_R": {
"Bones": {}
},
"Mouth_Down_Lower_L": {
"Bones": {}
},
"Mouth_Down_Lower_R": {
"Bones": {}
},
"Mouth_Close": {
"Bones": {}
}
},
"Constraint": {},
"Meshes": {
"meshes_0_": {
"SubD Level": 0,
"Materials": {
"tripo_material_a8fc2a79_1dd1_4434_92b9_731655894339": {
"Material Type": "Pbr",
"MultiUV Index": 0,
"Two Side": true,
"Diffuse Color": [
204.0,
204.0,
204.0
],
"Ambient Color": [
0.0,
0.0,
0.0
],
"Specular Color": [
204.0,
204.0,
204.0
],
"Opacity": 1.0,
"Self Illumination": 0.0,
"Textures": {},
"Resource Textures": {}
}
}
}
}
}
}
}
}
+320
View File
@@ -0,0 +1,320 @@
# lena_leafbikini lane, stage 01: graft the AccuRig skeleton onto the pristine leaf-bikini GLB.
#
# blender --background --factory-startup --python 01_graft.py -- <pristine.glb> <rigged.fbx> <bait.fbx> <out.blend> <out.glb>
#
# Lane rule (`.agents/plans/rig-graft-lane-2026-08-04.md`): the FBX is a disposable rig carrier, the
# GLB is the sole source of truth for mesh and materials, and it only ever GAINS bones and weights.
# Nothing here touches a vertex position, a UV or a texture — asserted at the end, not hoped for.
#
# NEAREST-SURFACE, not index-exact — and that is the difference from work/mako/02_graft.py. Mako's
# AccuRig run took the shipping mesh whole and handed back the same 122,062 verts in order, so
# weights copied index-to-index. Here AccuRig was fed a 51,682v bait decimated 20:1 from a
# 1,029,360v pristine mesh, so there is no index correspondence and proximity is the only mechanism.
# This is the case the lane was actually designed for: the bait's hands were budgeted at 40k tris
# (not the July lane's 10k) precisely to bound how crisply per-finger weights land back on the
# full-res hands. See the FINGER BLEED gate below for the check that this bought what it claims.
#
# THE BAIT IS THE BRIDGE, and it is why no scale is guessed here. AccuRig returns the carrier moved
# and rescaled (the same family as the July lane's 4 cm offset), so the returned skeleton is in some
# AccuRig space, not the GLB's. But the rigged FBX is index-exact against the BAIT (51,682v both,
# same order), and the bait is in the GLB's space BY CONSTRUCTION — rigbait_decimate.py applies no
# scale and no recenter, which the probe confirms exactly: bait and GLB share height 0.9792 and
# half-span 0.4583 to four decimals. So fitting rigged->bait index-exact recovers AccuRig's transform
# in closed form, and the residual of that fit is a gate: it must be near zero, or the carrier that
# came back is not the carrier that went in.
import bpy, sys, os, time
import numpy as np
from mathutils import Vector, Matrix
argv = sys.argv[sys.argv.index("--") + 1:]
GLB, FBX, BAIT, OUTB, OUTG = argv[0], argv[1], argv[2], os.path.abspath(argv[3]), os.path.abspath(argv[4])
t0 = time.time()
def log(m):
print(f"[graft {time.time()-t0:6.1f}s] {m}", flush=True)
def world_co(o):
n = len(o.data.vertices)
a = np.empty(n * 3)
o.data.vertices.foreach_get("co", a)
a = a.reshape(-1, 3)
M = np.array(o.matrix_world)
return a @ M[:3, :3].T + M[:3, 3]
# ── target: the pristine GLB ──────────────────────────────────────────────────
bpy.ops.wm.read_factory_settings(use_empty=True)
bpy.ops.import_scene.gltf(filepath=GLB)
for _o in bpy.data.objects:
if _o.type == "MESH":
bpy.context.view_layer.objects.active = _o
_o.select_set(True)
bpy.ops.object.transform_apply(location=True, rotation=True, scale=True)
body = max([o for o in bpy.data.objects if o.type == 'MESH'], key=lambda o: len(o.data.vertices))
me = body.data
n = len(me.vertices)
V_ref = world_co(body)
UNITM = 1.750 / (V_ref[:, 2].max() - V_ref[:, 2].min()) # rough units->mm scale for reporting
log(f"target: '{body.name}' {n}v {len(me.polygons)}f uv={[l.name for l in me.uv_layers]} "
f"mats={[m.name for m in me.materials if m]}")
# ── carriers: the AccuRig return, and the bait it was made from ───────────────
before = {o.name for o in bpy.data.objects}
bpy.ops.import_scene.fbx(filepath=FBX)
new = [o for o in bpy.data.objects if o.name not in before]
arm = next(o for o in new if o.type == 'ARMATURE')
src = max([o for o in new if o.type == 'MESH'], key=lambda o: len(o.data.vertices))
log(f"carrier: armature '{arm.name}' {len(arm.data.bones)} bones, mesh {len(src.data.vertices)}v")
before = {o.name for o in bpy.data.objects}
bpy.ops.import_scene.fbx(filepath=BAIT)
bait = max([o for o in bpy.data.objects if o.name not in before and o.type == 'MESH'],
key=lambda o: len(o.data.vertices))
log(f"bait: '{bait.name}' {len(bait.data.vertices)}v")
# AccuRig ships a "T-Pose" take and Blender's importer binds it as an ACTION. An action re-applies
# its pose on every evaluation and on every file load, so clearing pose values while it exists is
# futile — the in-session assert passes and the saved file comes back posed. Drop it first.
# (Verbatim from work/mako/02_graft.py; the failure it describes is not hypothetical.)
dropped = []
for o in (arm, src):
if o.animation_data:
dropped.append(o.name)
o.animation_data_clear()
dat = o.data
if dat and getattr(dat, "animation_data", None):
dat.animation_data_clear()
sk = getattr(dat, "shape_keys", None)
if sk and sk.animation_data:
sk.animation_data_clear()
for act in list(bpy.data.actions):
if act.users == 0:
bpy.data.actions.remove(act)
log(f"animation data cleared on {dropped}; actions left: {[a.name for a in bpy.data.actions]}")
assert len(src.data.vertices) == len(bait.data.vertices), (
f"rigged carrier {len(src.data.vertices)}v vs bait {len(bait.data.vertices)}v — AccuRig did not "
f"return the mesh it was given, so the bait cannot bridge the two spaces")
# ── recover AccuRig's transform: fit rigged -> bait, index-exact ──────────────
R = world_co(src)
B = world_co(bait)
ca, cb = B.mean(axis=0), R.mean(axis=0)
A_, B_ = B - ca, R - cb
s = float((A_ * B_).sum() / (B_ * B_).sum())
t = ca - s * cb
res = np.linalg.norm(B - (s * R + t), axis=1)
log(f"fit: scale {s:.6f} (1/{1/s:.6f}) translation {np.round(t, 6)} in target units")
log(f" carrier offset removed: {np.round(-t / s * 1000, 3)} mm in carrier space")
res_mm = res * UNITM * 1000
frac_1mm = float((res_mm > 1.0).mean())
log(f" index-exact residual vs bait: mean {res_mm.mean():.4f} mm p50 {np.percentile(res_mm,50):.4f} "
f"mm p99 {np.percentile(res_mm,99):.4f} mm max {res_mm.max():.4f} mm "
f">1mm {100*frac_1mm:.2f}%")
# What this gate is actually for: catching a carrier that came back RESAMPLED — a different surface
# wearing the same vertex count, which would poison every one of the 1M nearest-surface lookups
# downstream. It is NOT for catching AccuRig's joint-region cleanup, which is localized and harmless.
# Measured on this carrier (2026-08-13): p50 0.0009 mm — bit-exact for the bulk — with 1.01% of verts
# over 1 mm, all of them one cluster at height fraction 0.69-0.75 around x=-0.23, i.e. a single
# armpit, where AccuRig routinely tidies the arm/torso crease. The hands, which is where a bad
# transfer actually hurts, come back at mean 0.020 mm / max 1.45 mm.
# So gate on the SHAPE of the distribution, not on a lone max: a global resample moves the mean and
# the tail together, a local touch-up moves neither. An earlier max-only threshold of 1.0 mm failed
# this carrier on 523 verts out of 51,682 and would have blocked a graft that is fine.
assert res_mm.mean() < 0.5, (
f"mean carrier deviation {res_mm.mean():.3f} mm — AccuRig resampled the whole surface, and "
f"nearest-surface transfer would inherit that error everywhere")
assert frac_1mm < 0.03, (
f"{100*frac_1mm:.2f}% of carrier verts moved over 1 mm — too widespread to be joint cleanup")
assert res_mm.max() < 10.0, (
f"carrier deviates by up to {res_mm.max():.3f} mm — that is a limb moving, not a crease tidied")
M_fit = Matrix.Translation(Vector(t)) @ Matrix.Diagonal(Vector((s, s, s))).to_4x4()
# Move BOTH the skeleton and the carrier mesh into target space. Mako's graft moved only the
# armature because index-to-index weight copying never reads carrier positions; nearest-surface
# does, so the carrier surface has to physically land on the target surface.
bpy.ops.object.select_all(action='DESELECT')
src.select_set(True)
bpy.context.view_layer.objects.active = src
bpy.ops.object.parent_clear(type='CLEAR_KEEP_TRANSFORM')
for o in (arm, src):
o.matrix_world = M_fit @ o.matrix_world
bpy.ops.object.select_all(action='DESELECT')
o.select_set(True)
bpy.context.view_layer.objects.active = o
bpy.ops.object.transform_apply(location=True, rotation=True, scale=True)
log(f"armature + carrier placed; armature scale now {tuple(round(v,5) for v in arm.scale)}")
# Clear the carrier's POSE, and do it AFTER transform_apply. The mesh binds to the REST skeleton, so
# any pose riding along is pure displacement — and this FBX arrives with one. Clearing it by setting
# location/rotation/scale BEFORE the apply does not survive: transform_apply reintroduces non-identity
# rotations and the saved body deforms into the air with every edge length unchanged (rigid motion,
# which reads as "weights are fine, model is gone"). Use the pose operator, last, and assert it took.
bpy.context.view_layer.objects.active = arm
bpy.ops.object.mode_set(mode='POSE')
bpy.ops.pose.select_all(action='SELECT')
bpy.ops.pose.transforms_clear()
bpy.ops.object.mode_set(mode='OBJECT')
bpy.context.view_layer.update()
worst_pb = max(np.abs(np.array(pb.matrix_basis) - np.eye(4)).max() for pb in arm.pose.bones)
log(f"pose cleared; largest deviation from identity across {len(arm.pose.bones)} bones: {worst_pb:.2e}")
assert worst_pb < 1e-6, "pose did not clear — the bind would be displaced"
# how far the carrier surface sits from the target surface it must hand weights to
S_now = world_co(src)
gap = np.linalg.norm(S_now - B, axis=1)
log(f"carrier now sits on target space: max deviation from bait {gap.max()*UNITM*1000:.5f} mm")
# ── nearest-surface weight transfer ──────────────────────────────────────────
# The carrier still carries an Armature modifier from the FBX; data_transfer reads EVALUATED
# geometry, so leaving it in would sample a deformed surface. The pose is identity by now, but
# relying on that is a silent dependency — remove it.
for m_ in list(src.modifiers):
if m_.type == 'ARMATURE':
src.modifiers.remove(m_)
for vg in list(body.vertex_groups):
body.vertex_groups.remove(vg)
bpy.ops.object.select_all(action='DESELECT')
body.select_set(True)
src.select_set(True)
bpy.context.view_layer.objects.active = src # ACTIVE is the source, selected is the target
log(f"transferring {len(src.vertex_groups)} vertex groups onto {n} verts (POLYINTERP_NEAREST)...")
bpy.ops.object.data_transfer(
use_reverse_transfer=False,
data_type='VGROUP_WEIGHTS',
vert_mapping='POLYINTERP_NEAREST', # project onto nearest face, barycentric-interpolate
layers_select_src='ALL',
layers_select_dst='NAME',
mix_mode='REPLACE',
)
log(f"transfer done; target now has {len(body.vertex_groups)} groups")
# ── bind ─────────────────────────────────────────────────────────────────────
for m_ in list(body.modifiers):
if m_.type == 'ARMATURE':
body.modifiers.remove(m_)
mod = body.modifiers.new("Armature", 'ARMATURE')
mod.object = arm
body.parent = arm
body.matrix_parent_inverse = arm.matrix_world.inverted()
# ── the carriers are disposable: nothing visual survives from either FBX ─────
carrier_mats = [m_ for m_ in src.data.materials if m_] + [m_ for m_ in bait.data.materials if m_]
bpy.data.objects.remove(src, do_unlink=True)
bpy.data.objects.remove(bait, do_unlink=True)
for m_ in carrier_mats:
if m_.users == 0:
bpy.data.materials.remove(m_)
for im in list(bpy.data.images):
if im.users == 0 and im.name not in {"Render Result", "Viewer Node"}:
bpy.data.images.remove(im)
# ── verify ───────────────────────────────────────────────────────────────────
V2 = world_co(body)
moved = np.linalg.norm(V2 - V_ref, axis=1).max()
gidx = {g.index: g.name for g in body.vertex_groups}
tot = np.zeros(n)
nz = np.zeros(n, dtype=np.int32)
for v in me.vertices:
ssum = 0.0
c = 0
for g in v.groups:
w = g.weight
if w > 1e-6:
ssum += w
c += 1
tot[v.index] = ssum
nz[v.index] = c
print("\n=== VERIFY ===")
print(f" mesh vertices moved: {moved*UNITM*1000:.6f} mm (must be 0)")
print(f" height {V2[:,2].max()-V2[:,2].min():.5f} units, feet min-Z {V2[:,2].min():+.6f}")
print(f" weight sums: min {tot.min():.4f} mean {tot.mean():.4f} max {tot.max():.4f}")
print(f" unweighted vertices: {int((tot < 1e-6).sum())} of {n}")
print(f" influences/vert: mean {nz.mean():.2f} max {nz.max()}")
print(f" bones {len(arm.data.bones)} vertex groups {len(body.vertex_groups)}")
print(f" uv layers {[l.name for l in me.uv_layers]} materials {[m_.name for m_ in me.materials if m_]}")
print(f" images {[(i.name, i.size[0]) for i in bpy.data.images if i.size[0]]}")
assert moved < 1e-9, "the mesh moved — the graft must be additive only"
assert int((tot < 1e-6).sum()) == 0, "unweighted vertices — nearest-surface transfer left holes"
# FINGER BLEED gate. This is the failure this whole lane is shaped around: a vertex on one finger
# finding a NEIGHBOURING finger's surface nearer than its own, which mangles hands the moment a
# clip curls them. A clean transfer keeps each finger's flesh dominated by its own chain, so for
# every finger bone, measure how much of the weight it hands out lands on verts whose dominant
# influence belongs to a DIFFERENT digit. Reported per digit rather than asserted blind: the number
# is the QC evidence, and the pose sweep in 02_qc.py is what confirms it visually.
DIGITS = ("Thumb", "Index", "Mid", "Ring", "Pinky")
dom = np.zeros(n, dtype=np.int32) - 1
best = np.zeros(n)
for v in me.vertices:
for g in v.groups:
if g.weight > best[v.index]:
best[v.index] = g.weight
dom[v.index] = g.group
def digit_of(name):
for i, d in enumerate(DIGITS):
if f"_{d}" in name:
return (0 if "_L_" in name else 1) * 5 + i
return -1
dom_digit = np.full(n, -1, dtype=np.int32)
for gi, gname in gidx.items():
dd = digit_of(gname)
if dd >= 0:
dom_digit[dom == gi] = dd
print("\n=== FINGER BLEED (weight landing on a different digit) ===")
for side, S in (("L", 0), ("R", 1)):
for i, d in enumerate(DIGITS):
own = S * 5 + i
names = [gi for gi, gname in gidx.items()
if f"_{S and 'R' or 'L'}_" in gname and f"_{d}" in gname]
if not names:
continue
tot_w = 0.0
bleed_w = 0.0
for v in me.vertices:
for g in v.groups:
if g.group in names and g.weight > 1e-6:
tot_w += g.weight
if dom_digit[v.index] != own and dom_digit[v.index] >= 0:
bleed_w += g.weight
pct = 100.0 * bleed_w / tot_w if tot_w else 0.0
print(f" {side}_{d:<6} weight {tot_w:9.1f} onto other digits {pct:5.2f}%")
# The rest mesh being right proves nothing: the armature modifier is what the engine will run.
# Evaluate it and require the deformed body to sit exactly where the undeformed one does.
dg = bpy.context.evaluated_depsgraph_get()
evo = body.evaluated_get(dg)
tmp = evo.to_mesh()
Dv = np.empty(len(tmp.vertices) * 3)
tmp.vertices.foreach_get("co", Dv)
Dv = Dv.reshape(-1, 3)
evo.to_mesh_clear()
Mw = np.array(body.matrix_world)
Dv = Dv @ Mw[:3, :3].T + Mw[:3, 3]
drift = np.linalg.norm(Dv - V_ref, axis=1).max() * UNITM * 1000
print(f"\n DEFORMED at rest pose: z {Dv[:,2].min():.5f}..{Dv[:,2].max():.5f} "
f"max drift vs undeformed {drift:.6f} mm")
assert drift < 0.01, (f"the armature displaces the body by {drift:.3f} mm at rest — a leftover pose, "
f"not a weighting problem")
bpy.ops.wm.save_as_mainfile(filepath=OUTB)
log(f"SAVED {OUTB}")
bpy.ops.object.select_all(action='DESELECT')
arm.select_set(True)
body.select_set(True)
bpy.context.view_layer.objects.active = arm
bpy.ops.export_scene.gltf(filepath=OUTG, export_format='GLB', use_selection=True,
export_image_format='AUTO', export_jpeg_quality=95,
export_yup=True, export_apply=False, export_skins=True)
log(f"EXPORTED {OUTG} ({os.path.getsize(OUTG)/1e6:.2f} MB)")
print("GRAFT_DONE")
@@ -0,0 +1,168 @@
# lena_leafbikini lane, stage 02: PROBE — what exactly are the leaves?
#
# blender --background --factory-startup --python 02_probe_leaves.py -- <pristine.glb> [outdir]
#
# Before anything is cut, this answers the two questions that decide the method:
#
# 1. Are the leaves PAINTED or SCULPTED? Lena's underwear on the game body turned out to be
# painted onto the body skin with no geometry of its own (characters/work/lena/README,
# tools/make_lena_nude_body.py finding 1) — deleting it opened a hole because the garment
# WAS the skin. If the leaves are the same, "remove at the seam" means a colour-keyed face
# delete and nothing more. If they are real shells sitting proud of the body, the seam is a
# geometric crease and the colour key is only a coarse pre-filter.
# 2. Where is the seam? Reported here as the distribution of per-vertex proudness (signed
# distance from a heavily smoothed reference surface) inside vs outside the green key.
#
# Everything is numpy over foreach_get buffers: this mesh is ~1.03M verts and a per-vertex
# Python loop over it costs minutes.
import bpy, sys, os, time
import numpy as np
argv = sys.argv[sys.argv.index("--") + 1:]
GLB = os.path.abspath(argv[0])
OUT = os.path.abspath(argv[1]) if len(argv) > 1 else os.path.dirname(GLB)
os.makedirs(OUT, exist_ok=True)
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=GLB)
meshes = [o for o in bpy.data.objects if o.type == 'MESH']
log(f"objects: {[(o.name, o.type) for o in bpy.data.objects]}")
for o in meshes:
log(f" MESH '{o.name}': {len(o.data.vertices)}v {len(o.data.polygons)}f "
f"uv={[l.name for l in o.data.uv_layers]} mats={[m.name for m in o.data.materials if m]}")
body = max(meshes, key=lambda o: len(o.data.vertices))
me = body.data
n = len(me.vertices)
# ── geometry ────────────────────────────────────────────────────────────────────────────────
co = np.empty(n * 3); me.vertices.foreach_get("co", co); co = co.reshape(-1, 3)
M = np.array(body.matrix_world)
W = co @ M[:3, :3].T + M[:3, 3]
lo, hi = W.min(0), W.max(0)
log(f"bbox min={np.round(lo,4)} max={np.round(hi,4)} size={np.round(hi-lo,4)}")
log(f"height(z) {hi[2]-lo[2]:.4f} -> 1 unit = {1.777/(hi[2]-lo[2]):.4f} of a 1.777 m body")
# ── textures ────────────────────────────────────────────────────────────────────────────────
imgs = {}
for mat in [m for m in me.materials if m]:
for nd in mat.node_tree.nodes:
if nd.type == 'TEX_IMAGE' and nd.image:
tgt = [l.to_socket.name for o in nd.outputs for l in o.links]
log(f" tex '{nd.image.name}' {tuple(nd.image.size)} cs={nd.image.colorspace_settings.name} -> {tgt}")
imgs[nd.image.name] = nd.image
# base colour = the image feeding Base Color
base = None
for mat in [m for m in me.materials if m]:
bsdf = next((x for x in mat.node_tree.nodes if x.type == 'BSDF_PRINCIPLED'), None)
if not bsdf:
continue
lnk = bsdf.inputs["Base Color"].links
if lnk:
nd = lnk[0].from_node
while nd.type != 'TEX_IMAGE' and nd.inputs:
up = [i for i in nd.inputs if i.links]
if not up:
break
nd = up[0].links[0].from_node
if nd.type == 'TEX_IMAGE':
base = nd.image
if base is None:
raise SystemExit("[probe] FATAL: no base-colour image found")
log(f"base colour image: '{base.name}' {tuple(base.size)}")
# ── per-vertex UV (first loop wins), then sample the albedo ─────────────────────────────────
nl = len(me.loops)
lv = np.empty(nl, dtype=np.int32); me.loops.foreach_get("vertex_index", lv)
uv = np.empty(nl * 2); me.uv_layers.active.data.foreach_get("uv", uv); uv = uv.reshape(-1, 2)
vuv = np.zeros((n, 2))
vuv[lv[::-1]] = uv[::-1] # reversed scatter -> first loop of each vert wins
w, h = base.size
buf = np.empty(w * h * 4, dtype=np.float32); base.pixels.foreach_get(buf)
px = buf.reshape(h, w, 4)[:, :, :3]
del buf
# bpy-imported UVs are already v-flipped by the importer (see memory: gltf-uv-flip-vs-blender-images)
xi = np.clip((vuv[:, 0] * (w - 1)).astype(np.int32), 0, w - 1)
yi = np.clip((vuv[:, 1] * (h - 1)).astype(np.int32), 0, h - 1)
C = px[yi, xi] # linear RGB per vertex
del px
log(f"sampled albedo for {n} verts from {w}x{h}")
# linear -> sRGB for a colour key that matches what the eye/Tripo saw
def to_srgb(x):
return np.where(x <= 0.0031308, x * 12.92, 1.055 * np.maximum(x, 0) ** (1 / 2.4) - 0.055)
S = np.clip(to_srgb(C), 0, 1)
R, G, B = S[:, 0], S[:, 1], S[:, 2]
mx, mn = S.max(1), S.min(1)
sat = np.where(mx > 1e-5, (mx - mn) / np.maximum(mx, 1e-5), 0.0)
# green dominance: G is the max channel and beats both others
gdom = (G - np.maximum(R, B))
log(f"albedo sRGB: mean R{R.mean():.3f} G{G.mean():.3f} B{B.mean():.3f} sat mean {sat.mean():.3f}")
for thr in (0.0, 0.02, 0.05, 0.08, 0.12, 0.20):
m = gdom > thr
log(f" G-dominance > {thr:.2f}: {m.sum():7d} verts ({100*m.sum()/n:5.2f}%)"
+ (f" z {W[m,2].min():.3f}..{W[m,2].max():.3f}" if m.any() else ""))
# ── proudness: signed offset from a smoothed reference surface ─────────────────────────────
# Adjacency over POSITION-WELDED points (the importer splits every UV seam; see nude-body
# finding 5). Built as a flat CSR from the edge list so smoothing is pure numpy.
key = np.round(W, 6)
_, inv = np.unique(key, axis=0, return_inverse=True)
ng = inv.max() + 1
log(f"welded: {n} verts -> {ng} unique positions ({n-ng} seam duplicates)")
ev = np.empty(len(me.edges) * 2, dtype=np.int32); me.edges.foreach_get("vertices", ev)
ea, eb = inv[ev[0::2]], inv[ev[1::2]]
keep = ea != eb
ea, eb = ea[keep], eb[keep]
src = np.concatenate([ea, eb]); dst = np.concatenate([eb, ea])
order = np.argsort(src, kind='stable')
src, dst = src[order], dst[order]
cnt = np.bincount(src, minlength=ng)
ptr = np.concatenate([[0], np.cumsum(cnt)])
cnt_safe = np.maximum(cnt, 1)
P = np.zeros((ng, 3)); np.add.at(P, inv, W); P /= np.bincount(inv, minlength=ng)[:, None]
def nbr_mean(X):
s = np.add.reduceat(X[dst], ptr[:-1], axis=0)
s[cnt == 0] = X[cnt == 0]
return s / cnt_safe[:, None]
Q = P.copy()
for _ in range(60): # heavy Taubin: sheds the leaves, keeps the body
Q += 0.55 * (nbr_mean(Q) - Q)
Q += -0.58 * (nbr_mean(Q) - Q)
# reference normal from the smoothed surface, via the vertex-normal buffer of the ORIGINAL
vn = np.empty(n * 3); me.vertices.foreach_get("normal", vn); vn = vn.reshape(-1, 3)
N = np.zeros((ng, 3)); np.add.at(N, inv, vn)
N /= np.maximum(np.linalg.norm(N, axis=1, keepdims=True), 1e-12)
proud_g = np.einsum('ij,ij->i', P - Q, N)
proud = proud_g[inv]
mm = 1000.0 * 1.777 / (hi[2] - lo[2]) # units -> mm on a 1.777 m body
log(f"proudness (mm, body-scaled): mean {proud.mean()*mm:+.2f} p50 {np.median(proud)*mm:+.2f} "
f"p99 {np.percentile(proud,99)*mm:+.2f} max {proud.max()*mm:+.2f}")
for thr in (0.05, 0.12):
m = gdom > thr
if m.sum() < 100:
continue
log(f" green(G-dom>{thr}): proud p50 {np.median(proud[m])*mm:+.2f} mm "
f"p90 {np.percentile(proud[m],90)*mm:+.2f} mm")
log(f" skin (G-dom<=0 ): proud p50 {np.median(proud[~(gdom>0)])*mm:+.2f} mm "
f"p90 {np.percentile(proud[~(gdom>0)],90)*mm:+.2f} mm")
break
np.savez_compressed(os.path.join(OUT, "probe_leaves.npz"),
gdom=gdom.astype(np.float32), sat=sat.astype(np.float32),
proud=proud.astype(np.float32), W=W.astype(np.float32),
inv=inv.astype(np.int32))
log(f"WROTE {os.path.join(OUT, 'probe_leaves.npz')}")
@@ -0,0 +1,102 @@
# lena_leafbikini lane, stage 03: look at her. Textured AND clay, full and close.
#
# blender --background --factory-startup --python 03_render_leaves.py -- <mesh.glb|.blend> <outdir> [tag]
#
# The clay pass is the whole point (same reasoning as tools/_render_body_closeup.py --clay):
# a painted leaf and a sculpted leaf are indistinguishable in a textured render, and they need
# completely different removal methods. Whatever survives with all materials stripped is
# geometry; whatever vanishes was paint.
#
# Framing is in FRACTIONS OF BODY HEIGHT, not metres: the Tripo original is 0.979 units tall,
# not 1.777, so tools/_render_body_closeup.py's metre bands (torso = 1.00..1.52) would frame
# empty space above her head.
import bpy, sys, os, math
from mathutils import Vector
argv = sys.argv[sys.argv.index("--") + 1:]
SRC, OUT = os.path.abspath(argv[0]), os.path.abspath(argv[1])
TAG = argv[2] if len(argv) > 2 else "leaf"
os.makedirs(OUT, exist_ok=True)
# name -> (z_lo, z_hi as fraction of body height, yaw degrees)
VIEWS = [
("full_front", 0.00, 1.00, 0),
("full_back", 0.00, 1.00, 180),
("chest_front", 0.60, 0.82, 0),
("chest_34", 0.60, 0.82, 40),
("hip_front", 0.38, 0.60, 0),
("hip_34", 0.38, 0.60, 40),
]
RES = 1000
def load():
bpy.ops.wm.read_factory_settings(use_empty=True)
if SRC.lower().endswith(".blend"):
bpy.ops.wm.open_mainfile(filepath=SRC)
else:
bpy.ops.import_scene.gltf(filepath=SRC)
meshes = [o for o in bpy.data.objects if o.type == 'MESH']
body = max(meshes, key=lambda o: len(o.data.vertices))
for o in meshes:
o.hide_render = o is not body
return body
def render(body, clay):
if clay:
body.data.materials.clear()
m = bpy.data.materials.new("Clay")
m.use_nodes = True
b = m.node_tree.nodes["Principled BSDF"]
b.inputs["Base Color"].default_value = (0.62, 0.60, 0.58, 1.0)
b.inputs["Roughness"].default_value = 0.42
body.data.materials.append(m)
pts = [body.matrix_world @ v.co for v in body.data.vertices]
zmin = min(p.z for p in pts); zmax = max(p.z for p in pts)
H = zmax - zmin
w = bpy.data.worlds.new("W"); w.color = (0.20, 0.20, 0.22)
bpy.context.scene.world = w
scn = bpy.context.scene
scn.render.engine = 'BLENDER_EEVEE' if bpy.app.version >= (4, 2) else 'BLENDER_EEVEE_NEXT'
scn.render.resolution_x = scn.render.resolution_y = RES
scn.render.film_transparent = False
cam = bpy.data.objects.new("Cam", bpy.data.cameras.new("Cam"))
cam.data.lens = 85
bpy.context.collection.objects.link(cam)
scn.camera = cam
key = bpy.data.objects.new("Key", bpy.data.lights.new("Key", 'SUN'))
key.data.energy = 3.0
bpy.context.collection.objects.link(key)
fill = bpy.data.objects.new("Fill", bpy.data.lights.new("Fill", 'SUN'))
fill.data.energy = 1.2
bpy.context.collection.objects.link(fill)
for name, f_lo, f_hi, yawdeg in VIEWS:
z_lo, z_hi = zmin + f_lo * H, zmin + f_hi * H
band = [p for p in pts if z_lo <= p.z <= z_hi] or pts
xs = [p.x for p in band]
ctr = Vector((0.0, sum(p.y for p in band) / len(band), (z_lo + z_hi) / 2))
span = max(max(xs) - min(xs), z_hi - z_lo)
yaw = math.radians(yawdeg)
# closeups: ignore the T-pose arm span, frame the torso width only
if f_hi - f_lo < 0.5:
span = min(span, (z_hi - z_lo) * 1.25)
dist = span * 2.9
cam.location = ctr + Vector((math.sin(yaw) * dist, -math.cos(yaw) * dist, 0.0))
cam.rotation_euler = (ctr - cam.location).to_track_quat('-Z', 'Y').to_euler()
key.rotation_euler = (math.radians(60), 0, math.radians(35 + yawdeg))
fill.rotation_euler = (math.radians(75), 0, math.radians(yawdeg - 110))
path = os.path.join(OUT, f"{TAG}_{name}_{'clay' if clay else 'tex'}.png")
scn.render.filepath = path
bpy.ops.render.render(write_still=True)
print("RENDER_OK", path, flush=True)
b = load()
render(b, clay=False)
b = load()
render(b, clay=True)
@@ -0,0 +1,283 @@
# lena_leafbikini lane, stage 04: build the LEAF MASK and prove it before anything is cut.
#
# blender --background --factory-startup --python 04_leaf_mask.py -- <pristine.glb> <renderdir>
# [--hue-lo 52] [--hue-hi 170] [--sat-min 0.10] [--val-max 0.90] [--z-max 0.85]
# [--min-comp 200] [--close 6] [--grow 2] [--no-render]
#
# The mask lands at leaf_mask.npz beside this script, NOT in <renderdir>: renderdir is a
# review/ dir and those are gitignored scratch, while the mask is a KEEP-tier lane input
# (.agents/rules/working-files.md) that stage 05 consumes.
#
# WHAT THE LEAVES ACTUALLY ARE (stage 02 probe + stage 03 clay renders):
# * REAL GEOMETRY, not paint. The clay render shows every leaf, curled tip and hip vine with
# all materials stripped — so this is the OPPOSITE case to Lena's game-body underwear, which
# was painted onto the skin (tools/make_lena_nude_body.py, finding 1). There the fix was to
# re-fair a rim crease; here there is a solid shell to delete.
# * ONE WELDED SHELL with the body. Tripo emitted a single 1,029,360 v / 1,992,503 f mesh, so
# the leaves are not a separable object, material or UV island — they are a bulge in the body
# surface. Nothing can be selected "by object"; the seam has to be found.
# * The seam is a SHARP RIM. Where a leaf meets skin the surface folds back on itself, so the
# boundary is simultaneously a colour edge (green -> beige) and a crease.
#
# WHY THE KEY IS HUE, NOT "GREENNESS". Stage 02 keyed on G - max(R,B) and topped out at 0.20:
# Tripo painted these leaves a dark, desaturated olive (sRGB ~0.27/0.35/0.20), so the channel
# gap is only ~0.05-0.09 and any threshold that catches the leaf also catches shadow noise on
# skin. Hue separates them completely instead of marginally — skin sits at ~20-30 deg (orange),
# leaf at ~80-110 deg (green) — and hue is invariant to exactly the thing that ruins the channel
# gap here, which is how dark the pixel is.
#
# The mask is then repaired ON THE MESH, not in texture space: small components dropped (JPEG
# speckle), holes closed (leaf highlights that blow out to near-white lose their hue), and grown
# by one ring so the cut lands just outside the rim rather than just inside it. Erring outward is
# deliberate: a hole one ring too big is invisible, a leftover leaf stub is not.
import bpy, sys, os, time, argparse
import numpy as np
from collections import deque
argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
ap = argparse.ArgumentParser()
ap.add_argument("glb")
ap.add_argument("outdir")
ap.add_argument("--hue-lo", type=float, default=52.0)
ap.add_argument("--hue-hi", type=float, default=170.0)
ap.add_argument("--sat-min", type=float, default=0.10)
ap.add_argument("--val-max", type=float, default=0.90,
help="hue alone separates olive leaf from beige skin; this only rejects pixels "
"so blown out that their hue is noise")
ap.add_argument("--z-max", type=float, default=0.85,
help="fraction of body height above which the key is ignored — her irises and "
"eyebrows are green too, and they are not leaves")
ap.add_argument("--min-comp", type=int, default=200)
ap.add_argument("--close", type=int, default=6, help="ring radius of the morphological close")
ap.add_argument("--grow", type=int, default=2, help="final dilation, in rings")
ap.add_argument("--no-render", action="store_true")
A = ap.parse_args(argv)
GLB, OUT = os.path.abspath(A.glb), os.path.abspath(A.outdir)
os.makedirs(OUT, exist_ok=True)
t0 = time.time()
def log(m):
print(f"[mask {time.time()-t0:6.1f}s] {m}", flush=True)
# =============================================================================================
# load + welded adjacency
# =============================================================================================
bpy.ops.wm.read_factory_settings(use_empty=True)
bpy.ops.import_scene.gltf(filepath=GLB)
body = max([o for o in bpy.data.objects if o.type == 'MESH'], key=lambda o: len(o.data.vertices))
me = body.data
n = len(me.vertices)
log(f"'{body.name}' {n}v {len(me.polygons)}f")
co = np.empty(n * 3); me.vertices.foreach_get("co", co); co = co.reshape(-1, 3)
M = np.array(body.matrix_world)
W = co @ M[:3, :3].T + M[:3, 3]
H = W[:, 2].max() - W[:, 2].min()
MM = 1000.0 * 1.777 / H # units -> mm at final 1.777 m body scale
# The importer splits every UV seam into separate Blender vertices, so mesh adjacency is
# shattered along seams (nude-body finding 5). Every ring operation below runs on the
# POSITION-WELDED graph or it would leak holes along the seams.
_, inv = np.unique(np.round(W, 6), axis=0, return_inverse=True)
inv = inv.astype(np.int64)
ng = int(inv.max()) + 1
ev = np.empty(len(me.edges) * 2, dtype=np.int32); me.edges.foreach_get("vertices", ev)
ea, eb = inv[ev[0::2]], inv[ev[1::2]]
k = ea != eb
src = np.concatenate([ea[k], eb[k]]); dst = np.concatenate([eb[k], ea[k]])
o = np.argsort(src, kind='stable'); src, dst = src[o], dst[o]
cnt = np.bincount(src, minlength=ng)
ptr = np.concatenate([[0], np.cumsum(cnt)])
log(f"welded {n} -> {ng} points, {len(dst)//2} undirected edges")
def ring(m, k=1):
"""Dilate a boolean over welded adjacency by k rings."""
out = m.copy()
for _ in range(k):
hit = np.add.reduceat(out[dst].astype(np.int32), ptr[:-1]) > 0
hit[cnt == 0] = False
out = out | hit
return out
def shrink(m, k=1):
return ~ring(~m, k)
def components(m):
"""Connected components of a boolean over welded adjacency (exact BFS; the mask is small)."""
lab = np.full(ng, -1, dtype=np.int64)
comps = []
for s in np.nonzero(m)[0]:
if lab[s] >= 0:
continue
cid = len(comps)
q = deque([s]); lab[s] = cid; size = 0
while q:
c = q.popleft(); size += 1
for j in range(ptr[c], ptr[c + 1]):
nb = dst[j]
if m[nb] and lab[nb] < 0:
lab[nb] = cid; q.append(nb)
comps.append(size)
return lab, np.array(comps)
# =============================================================================================
# albedo -> HSV, per welded point
# =============================================================================================
base = None
for mat in [m for m in me.materials if m]:
bsdf = next((x for x in mat.node_tree.nodes if x.type == 'BSDF_PRINCIPLED'), None)
lnk = bsdf and bsdf.inputs["Base Color"].links
if lnk:
nd = lnk[0].from_node
while nd.type != 'TEX_IMAGE':
up = [i for i in nd.inputs if i.links]
if not up:
break
nd = up[0].links[0].from_node
if nd.type == 'TEX_IMAGE':
base = nd.image
if base is None:
raise SystemExit("[mask] FATAL: no base-colour image")
nl = len(me.loops)
lv = np.empty(nl, dtype=np.int32); me.loops.foreach_get("vertex_index", lv)
uvb = np.empty(nl * 2); me.uv_layers.active.data.foreach_get("uv", uvb); uvb = uvb.reshape(-1, 2)
vuv = np.zeros((n, 2))
vuv[lv[::-1]] = uvb[::-1] # first loop of each vertex wins
w, h = base.size
buf = np.empty(w * h * 4, dtype=np.float32); base.pixels.foreach_get(buf)
px = buf.reshape(h, w, 4)[:, :, :3].copy(); del buf
# bpy-imported UVs are already v-flipped by the importer (memory: gltf-uv-flip-vs-blender-images)
xi = np.clip((vuv[:, 0] * (w - 1)).astype(np.int32), 0, w - 1)
yi = np.clip((vuv[:, 1] * (h - 1)).astype(np.int32), 0, h - 1)
C = px[yi, xi].astype(np.float64); del px
S = np.clip(np.where(C <= 0.0031308, C * 12.92, 1.055 * np.maximum(C, 0) ** (1 / 2.4) - 0.055), 0, 1)
R, G, B = S[:, 0], S[:, 1], S[:, 2]
mx = S.max(1); mn = S.min(1); d = mx - mn
hue = np.zeros(n)
nz = d > 1e-6
im = np.argmax(S, axis=1)
sel = nz & (im == 0); hue[sel] = 60 * (((G[sel] - B[sel]) / d[sel]) % 6)
sel = nz & (im == 1); hue[sel] = 60 * ((B[sel] - R[sel]) / d[sel] + 2)
sel = nz & (im == 2); hue[sel] = 60 * ((R[sel] - G[sel]) / d[sel] + 4)
sat = np.where(mx > 1e-6, d / np.maximum(mx, 1e-6), 0.0)
log("hue histogram (all verts, 20 deg bins):")
hh, _ = np.histogram(hue, bins=18, range=(0, 360))
log(" " + " ".join(f"{i*20:3d}:{c*100.0/n:5.2f}%" for i, c in enumerate(hh) if c))
zf = (W[:, 2] - W[:, 2].min()) / H
raw = (hue >= A.hue_lo) & (hue <= A.hue_hi) & (sat >= A.sat_min) & (mx <= A.val_max)
log(f"hue key [{A.hue_lo},{A.hue_hi}] sat>={A.sat_min} val<={A.val_max}: {raw.sum()} verts "
f"({100*raw.sum()/n:.2f}%) z {zf[raw].min():.3f}..{zf[raw].max():.3f} of height")
head = raw & (zf > A.z_max)
raw &= ~head
log(f"head gate z<={A.z_max}: dropped {head.sum()} verts (irises/eyebrows)")
# per-welded-point: a point is leaf if ANY of its seam copies keyed (a seam copy can sample the
# far side of a texture chart boundary)
m = np.zeros(ng, dtype=bool)
np.logical_or.at(m, inv, raw)
# =============================================================================================
# repair the mask on the mesh
# =============================================================================================
lab, sizes = components(m)
if len(sizes):
log(f"components: {len(sizes)}, largest {sorted(sizes)[-8:]}")
m &= np.isin(lab, np.nonzero(sizes >= A.min_comp)[0])
log(f" after min-comp {A.min_comp}: {m.sum()} points in {(sizes>=A.min_comp).sum()} comps")
if A.close:
m = shrink(ring(m, A.close), A.close) # close: fill specular blowouts inside a leaf
log(f"after close({A.close}): {m.sum()} points")
# any hole left inside the mask is a mask hole, not a real skin island: fill enclosed holes by
# dropping small components of the COMPLEMENT that do not touch the rest of the body
hlab, hsizes = components(~m)
if len(hsizes):
big = int(np.argmax(hsizes)) # the body itself
fill = (~m) & (hlab >= 0) & (hlab != big)
small = np.isin(hlab, np.nonzero(hsizes < 5000)[0]) & fill
if small.any():
m |= small
log(f"filled {small.sum()} enclosed hole points ({(hsizes<5000).sum()-0} small comps)")
if A.grow:
m = ring(m, A.grow)
log(f"after grow({A.grow}): {m.sum()} points")
lab2, sizes2 = components(m)
log(f"FINAL mask: {m.sum()} welded points ({100*m.sum()/ng:.2f}%), {len(sizes2)} islands, "
f"sizes {sorted(sizes2)[::-1][:10]}")
vm = m[inv]
log(f" -> {vm.sum()} mesh verts; z {zf[vm].min():.3f}..{zf[vm].max():.3f} of height, "
f"|x| max {np.abs(W[vm,0]).max()/H:.3f}")
for cid in np.argsort(sizes2)[::-1][:8]:
sel = lab2 == cid
zs = zf[sel[inv]]
log(f" island {int(sizes2[cid]):7d} pts z {zs.min():.3f}..{zs.max():.3f}")
MASK_OUT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "leaf_mask.npz")
np.savez_compressed(MASK_OUT,
mask=m, inv=inv.astype(np.int32), hue=hue.astype(np.float32),
sat=sat.astype(np.float32), val=mx.astype(np.float32))
log(f"WROTE {MASK_OUT}")
# =============================================================================================
# prove it: bake the mask to vertex colour and render
# =============================================================================================
if not A.no_render:
import math
from mathutils import Vector
lay = me.color_attributes.new(name="LeafMask", type='FLOAT_COLOR', domain='POINT')
colv = np.zeros((n, 4)); colv[:, 3] = 1.0
colv[:, 0] = np.where(vm, 1.0, 0.45)
colv[:, 1] = np.where(vm, 0.05, 0.44)
colv[:, 2] = np.where(vm, 0.05, 0.42)
lay.data.foreach_set("color", colv.ravel())
me.materials.clear()
mat = bpy.data.materials.new("MaskDbg"); mat.use_nodes = True
nt = mat.node_tree
vc = nt.nodes.new("ShaderNodeVertexColor"); vc.layer_name = "LeafMask"
bsdf = next(x for x in nt.nodes if x.type == 'BSDF_PRINCIPLED')
nt.links.new(vc.outputs["Color"], bsdf.inputs["Base Color"])
bsdf.inputs["Roughness"].default_value = 0.5
me.materials.append(mat)
wl = bpy.data.worlds.new("W"); wl.color = (0.20, 0.20, 0.22)
bpy.context.scene.world = wl
scn = bpy.context.scene
scn.render.engine = 'BLENDER_EEVEE' if bpy.app.version >= (4, 2) else 'BLENDER_EEVEE_NEXT'
scn.render.resolution_x = scn.render.resolution_y = 1000
cam = bpy.data.objects.new("Cam", bpy.data.cameras.new("Cam")); cam.data.lens = 85
bpy.context.collection.objects.link(cam); scn.camera = cam
key = bpy.data.objects.new("Key", bpy.data.lights.new("Key", 'SUN')); key.data.energy = 3.0
bpy.context.collection.objects.link(key)
fill = bpy.data.objects.new("Fill", bpy.data.lights.new("Fill", 'SUN')); fill.data.energy = 1.2
bpy.context.collection.objects.link(fill)
z0 = W[:, 2].min()
for name, f_lo, f_hi, yawdeg in [("chest_front", 0.60, 0.82, 0), ("chest_34", 0.60, 0.82, 40),
("hip_front", 0.38, 0.60, 0), ("hip_34", 0.38, 0.60, 40),
("hip_back", 0.38, 0.60, 180), ("full_front", 0.0, 1.0, 0)]:
z_lo, z_hi = z0 + f_lo * H, z0 + f_hi * H
band = W[(W[:, 2] >= z_lo) & (W[:, 2] <= z_hi)]
band = band if len(band) else W
ctr = Vector((0.0, float(band[:, 1].mean()), (z_lo + z_hi) / 2))
span = max(float(band[:, 0].max() - band[:, 0].min()), z_hi - z_lo)
if f_hi - f_lo < 0.5:
span = min(span, (z_hi - z_lo) * 1.25)
yaw = math.radians(yawdeg); dist = span * 2.9
cam.location = ctr + Vector((math.sin(yaw) * dist, -math.cos(yaw) * dist, 0.0))
cam.rotation_euler = (ctr - cam.location).to_track_quat('-Z', 'Y').to_euler()
key.rotation_euler = (math.radians(60), 0, math.radians(35 + yawdeg))
fill.rotation_euler = (math.radians(75), 0, math.radians(yawdeg - 110))
scn.render.filepath = os.path.join(OUT, f"mask_{name}.png")
bpy.ops.render.render(write_still=True)
print("RENDER_OK", scn.render.filepath, flush=True)
@@ -0,0 +1,220 @@
# lena_leafbikini lane, stage 05: CUT the leaves out at their seam. Leave the holes open.
#
# blender --background --factory-startup --python 05_cut_leaves.py -- \
# <pristine.glb> <leaf_mask.npz> <out.glb> [--blend <out.blend>]
#
# Deliberately NOT healed. Jeremy asked for the model with holes so the seam itself can be
# judged before any repair is designed, and that is the whole deliverable of this stage: the
# leaf shells are gone, the skin is untouched, and the rim of each hole is exactly the line
# where leaf stopped and Lena started. Filling them is a separate decision (and a harder one —
# see the crotch/gusset history in characters/work/lena/06*.py: a membrane over a wide footprint
# flattens the anatomy it spans, which is why the nude lane only ever faired a narrow rim band).
#
# THE CUT RULE: a face dies only if ALL of its vertices are masked. Combined with the mask's
# 2-ring outward grow (stage 04), the surviving rim therefore sits ~1 ring OUTSIDE the green,
# i.e. just onto the skin. That asymmetry is on purpose in both directions:
# * "any vertex masked" would erode a ring further into her skin and leave a ragged, spiky
# boundary — single triangles hanging off the rim wherever the mask edge zig-zags;
# * a cut one ring short leaves a rim of leaf root standing proud of the skin, which is the
# one outcome that would make this file useless for judging the seam.
#
# Nothing is smoothed, welded, re-normalled or re-textured here. The material, UVs and all three
# 4096^2 maps ride through untouched, so what changes between input and output is exactly "some
# faces are missing" — asserted below on the surviving vertex positions, not assumed.
import bpy, sys, os, time, argparse
import numpy as np
argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
ap = argparse.ArgumentParser()
ap.add_argument("glb")
ap.add_argument("mask")
ap.add_argument("out")
ap.add_argument("--blend", default="")
ap.add_argument("--despike", type=int, default=6,
help="passes of dangling-face erosion at the new rims (0 = off)")
ap.add_argument("--min-island", type=int, default=400,
help="drop surviving shells smaller than this many welded points")
A = ap.parse_args(argv)
GLB, MASK, OUT = os.path.abspath(A.glb), os.path.abspath(A.mask), os.path.abspath(A.out)
os.makedirs(os.path.dirname(OUT), exist_ok=True)
t0 = time.time()
def log(m):
print(f"[cut {time.time()-t0:6.1f}s] {m}", flush=True)
def weld_ids(me):
"""Position-welded vertex ids. EVERY topology question below has to be asked on these: the
glTF importer splits each UV seam into separate Blender vertices, so on the raw mesh a face
sitting on a texture-chart border looks exactly like a face on a hole rim. Eroding "dangling"
faces off the split mesh would chew Lena open along her UV seams."""
n = len(me.vertices)
co = np.empty(n * 3); me.vertices.foreach_get("co", co)
_, inv = np.unique(np.round(co.reshape(-1, 3), 6), axis=0, return_inverse=True)
return inv.astype(np.int64), int(inv.max()) + 1
def face_edge_table(me, wid):
"""Per-loop: the welded edge it starts, and how many faces share that edge."""
nf, nl = len(me.polygons), len(me.loops)
lv = np.empty(nl, dtype=np.int32); me.loops.foreach_get("vertex_index", lv)
ls = np.empty(nf, dtype=np.int32); me.polygons.foreach_get("loop_start", ls)
lt = np.empty(nf, dtype=np.int32); me.polygons.foreach_get("loop_total", lt)
fol = np.repeat(np.arange(nf), lt)
nxt = ls[fol] + ((np.arange(nl) - ls[fol] + 1) % lt[fol])
a, b = wid[lv], wid[lv[nxt]]
key = np.stack([np.minimum(a, b), np.maximum(a, b)], axis=1)
_, ei, ec = np.unique(key, axis=0, return_inverse=True, return_counts=True)
return ls.astype(np.int64), lt, ec[ei]
def kill_faces(me, kill):
"""Delete the flagged faces plus anything left orphaned by them."""
bpy.ops.object.mode_set(mode='EDIT')
bpy.ops.mesh.select_all(action='DESELECT')
bpy.ops.object.mode_set(mode='OBJECT')
me.polygons.foreach_set("select", kill)
bpy.ops.object.mode_set(mode='EDIT')
bpy.ops.mesh.select_mode(type='FACE')
bpy.ops.mesh.delete(type='FACE')
bpy.ops.object.mode_set(mode='OBJECT')
bpy.ops.wm.read_factory_settings(use_empty=True)
bpy.ops.import_scene.gltf(filepath=GLB)
body = max([o for o in bpy.data.objects if o.type == 'MESH'], key=lambda o: len(o.data.vertices))
me = body.data
n0, f0 = len(me.vertices), len(me.polygons)
log(f"in : '{body.name}' {n0}v {f0}f mats={[m.name for m in me.materials if m]}")
co = np.empty(n0 * 3); me.vertices.foreach_get("co", co)
P0 = co.reshape(-1, 3).copy()
z = np.load(MASK)
inv, m = z["inv"].astype(np.int64), z["mask"]
if len(inv) != n0:
raise SystemExit(f"[cut] FATAL: mask was built for {len(inv)} verts, this GLB has {n0}. "
f"Re-run 04_leaf_mask.py against {os.path.basename(GLB)}.")
vm = m[inv]
log(f"mask: {vm.sum()} of {n0} verts ({100*vm.sum()/n0:.2f}%)")
# ── faces whose every vertex is masked ──────────────────────────────────────────────────────
nl = len(me.loops)
lv = np.empty(nl, dtype=np.int32); me.loops.foreach_get("vertex_index", lv)
ls = np.empty(f0, dtype=np.int32); me.polygons.foreach_get("loop_start", ls)
lt = np.empty(f0, dtype=np.int32); me.polygons.foreach_get("loop_total", lt)
hits = np.add.reduceat(vm[lv].astype(np.int32), ls.astype(np.int64))
kill = hits == lt
log(f"faces: {kill.sum()} fully masked, {(hits > 0).sum() - kill.sum()} straddle the rim (kept)")
# ── delete them ─────────────────────────────────────────────────────────────────────────────
bpy.context.view_layer.objects.active = body
body.select_set(True)
kill_faces(me, kill)
log(f"cut: {len(me.vertices)}v {len(me.polygons)}f")
# ── clean the new rims ──────────────────────────────────────────────────────────────────────
# Two passes, both of which can only ever remove things that are already not part of a smooth
# surface. Neither is cosmetic: the colour key loses the deep shadow pockets UNDER overlapping
# leaves (a leaf underside in shadow reads near-black, so its hue is noise), and what survives
# there is a leaf fragment either hanging off the rim by a single edge or floating free.
#
# A. DANGLING-FACE EROSION. A face with two or three of its edges on a real (welded) boundary is
# a spike: it is joined to the surface along at most one edge. On a closed surface no such
# face exists, so this cannot bite into her skin — it can only walk back the ragged tongues
# left where the mask edge zig-zagged. Iterated, because removing a spike can expose the next.
for i in range(A.despike):
wid, _ = weld_ids(me)
ls, lt, ecount = face_edge_table(me, wid)
nb = np.add.reduceat((ecount == 1).astype(np.int32), ls)
spike = nb >= 2
if not spike.any():
log(f"despike pass {i+1}: none left")
break
kill_faces(me, spike)
log(f"despike pass {i+1}: {int(spike.sum())} dangling faces -> {len(me.polygons)}f")
# B. DETACHED SHELLS. Whatever is no longer connected to the body is a leaf that came away whole.
if A.min_island > 0:
wid, ng = weld_ids(me)
nl = len(me.loops)
lv = np.empty(nl, dtype=np.int32); me.loops.foreach_get("vertex_index", lv)
ev = np.empty(len(me.edges) * 2, dtype=np.int32); me.edges.foreach_get("vertices", ev)
ea, eb = wid[ev[0::2]], wid[ev[1::2]]
k = ea != eb
src = np.concatenate([ea[k], eb[k]]); dst = np.concatenate([eb[k], ea[k]])
o = np.argsort(src, kind='stable'); src, dst = src[o], dst[o]
ptr = np.concatenate([[0], np.cumsum(np.bincount(src, minlength=ng))])
from collections import deque
lab = np.full(ng, -1, dtype=np.int64); sizes = []
for s in range(ng):
if lab[s] >= 0:
continue
cid = len(sizes); q = deque([s]); lab[s] = cid; sz = 0
while q:
c = q.popleft(); sz += 1
for j in range(ptr[c], ptr[c + 1]):
if lab[dst[j]] < 0:
lab[dst[j]] = cid; q.append(dst[j])
sizes.append(sz)
sizes = np.array(sizes)
log(f"shells: {len(sizes)}, sizes {sorted(sizes)[::-1][:8]}")
drop = np.isin(lab, np.nonzero(sizes < A.min_island)[0])
if drop.any():
vd = drop[wid]
ls = np.empty(len(me.polygons), dtype=np.int32); me.polygons.foreach_get("loop_start", ls)
lt = np.empty(len(me.polygons), dtype=np.int32); me.polygons.foreach_get("loop_total", lt)
hit = np.add.reduceat(vd[lv].astype(np.int32), ls.astype(np.int64)) == lt
kill_faces(me, hit)
log(f"dropped {int((sizes < A.min_island).sum())} detached shells "
f"({int(drop.sum())} points, {int(hit.sum())} faces) -> {len(me.polygons)}f")
n1, f1 = len(me.vertices), len(me.polygons)
log(f"out: {n1}v ({n0-n1} removed, {100*(n0-n1)/n0:.1f}%) "
f"{f1}f ({f0-f1} removed, {100*(f0-f1)/f0:.1f}%)")
# ── gates ───────────────────────────────────────────────────────────────────────────────────
# 1. NOTHING MOVED. Every surviving vertex position must appear in the input — this stage is a
# deletion and only a deletion, so any displacement at all is a bug, not a tolerance.
co = np.empty(n1 * 3); me.vertices.foreach_get("co", co)
P1 = co.reshape(-1, 3)
Q = np.round(np.concatenate([P0, P1]) * 1e6).astype(np.int64)
_, iq = np.unique(Q, axis=0, return_inverse=True)
i0, i1 = iq[:n0], iq[n0:]
stray = int((~np.isin(i1, i0)).sum())
log(f"gate positions: {stray} survivors not present in the input ({'PASS' if not stray else 'FAIL'})")
if stray:
raise SystemExit("[cut] FATAL: the cut moved geometry; it must only remove it")
# 2. the survivors must be one connected shell plus nothing else, and the leaf zone must now be
# open: count boundary edges before/after.
import bmesh
bm = bmesh.new(); bm.from_mesh(me)
bnd = sum(1 for e in bm.edges if len(e.link_faces) == 1)
loose = sum(1 for v in bm.verts if not v.link_faces)
log(f"gate topology: {bnd} boundary edges, {loose} loose verts")
if loose:
bmesh.ops.delete(bm, geom=[v for v in bm.verts if not v.link_faces], context='VERTS')
bm.to_mesh(me)
log(f" removed {loose} loose verts -> {len(me.vertices)}v")
bm.free()
# 3. the holes must be where the leaves were and nowhere else
me.update()
H = P0[:, 2].max() - P0[:, 2].min()
zf = (P0[vm, 2] - P0[:, 2].min()) / H
log(f"gate extent: cut geometry spanned z {zf.min():.3f}..{zf.max():.3f} of body height "
f"(bust + briefs bands only)")
# ── export ──────────────────────────────────────────────────────────────────────────────────
if A.blend:
bpy.ops.wm.save_as_mainfile(filepath=os.path.abspath(A.blend))
log(f"WROTE {A.blend}")
for o in bpy.data.objects:
o.select_set(True)
bpy.ops.export_scene.gltf(filepath=OUT, export_format='GLB', use_selection=True,
export_yup=True, export_skins=False, export_animations=False,
export_apply=False, export_image_format='AUTO',
export_tangents=False, export_normals=True)
log(f"WROTE {OUT} ({os.path.getsize(OUT)/1e6:.2f} MB)")
@@ -0,0 +1,67 @@
# lena_leafbikini lane, stage 06: open a lane mesh in the Blender GUI, framed and shaded.
#
# blender --python 06_open_in_blender.py -- <mesh.glb>
#
# Runs inside a GUI session, NOT --background, and deliberately NOT --factory-startup: the
# TinqsBlenderBridge autostarts from scripts/startup (tools/install_blender_bridge.ps1), and
# factory startup would skip it, leaving the session undriveable from the terminal afterwards.
#
# TWO THINGS THIS SCRIPT LEARNED THE HARD WAY, both consequences of `--python` running BEFORE
# Blender's window exists:
# 1. `bpy.context.screen` is None at this point, so any viewport setup here dies with
# AttributeError. All of it is deferred onto an app timer, which first fires once the UI
# is up and can therefore see the 3D View.
# 2. `bpy.ops.wm.read_homefile()` kills the bridge — the startup module has already
# registered its socket server by now, and reloading the file takes it down with no
# autostart to bring it back. The default cube/light/camera are removed by hand instead.
#
# Material Preview, not Solid: the point of looking at this file is the boundary between the
# holes and her skin, and in Solid shading the baked leaf contact shadows in the albedo are
# invisible — which is half of what makes the rim read the way it does.
import bpy, sys, os
GLB = os.path.abspath(sys.argv[sys.argv.index("--") + 1:][0])
for o in list(bpy.data.objects):
if o.name in {"Cube", "Light", "Camera"}:
bpy.data.objects.remove(o, do_unlink=True)
bpy.ops.import_scene.gltf(filepath=GLB)
body = 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 = body
for o in bpy.data.objects:
o.select_set(o is body)
print(f"[open] {os.path.basename(GLB)}: {len(body.data.vertices)}v {len(body.data.polygons)}f",
flush=True)
def setup_viewport():
"""Fires once, after the window exists."""
screen = getattr(bpy.context, "screen", None)
if screen is None:
return 0.25 # UI not up yet — ask the timer to call again
for area in screen.areas:
if area.type != 'VIEW_3D':
continue
sp = area.spaces.active
sp.shading.type = 'MATERIAL'
sp.shading.use_scene_lights = False
sp.shading.use_scene_world = False
sp.overlay.show_floor = False
sp.overlay.show_axis_x = sp.overlay.show_axis_y = False
sp.overlay.show_relationship_lines = False
sp.clip_start = 0.001
sp.clip_end = 100.0
region = next((r for r in area.regions if r.type == 'WINDOW'), None)
if region:
with bpy.context.temp_override(area=area, region=region, space_data=sp):
# BACK, not FRONT. These lane GLBs are exported with export_yup=True and come
# back in facing +Y, so Blender's FRONT view (looking from -Y) puts you behind
# her — which is the wrong side for judging a cut that is mostly on her front.
bpy.ops.view3d.view_axis(type='BACK')
bpy.ops.view3d.view_selected()
print("[open] viewport ready — Material Preview, framed on her front", flush=True)
return None # done; unregister
bpy.app.timers.register(setup_viewport, first_interval=0.5)
+126
View File
@@ -0,0 +1,126 @@
# work/lena_leafbikini — the leaf-bikini lena lane
Grammar-exempt (REGISTRY rule 11). This lane has already produced one ship —
`characters/female/lena_leafbikini_base_v01`, the game's female default since 2026-08-13,
which is **frozen** (REGISTRY rule 2). Everything still here is lane material governed by
`.agents/rules/working-files.md`; when the next body ships it graduates into
`characters/female/lena_leafbikini_base_v02/` and gets its own Ships row.
Parent original: `characters/originals/female/female_lena_leafbikini_tripo.glb`
(sha256 `977104ba…66cf29`, 1,029,360 v / 1,992,503 f, 0.9792 units tall → 1 unit = 1.815
of a 1.777 m body). Read-only, and re-hashed by every stage that opens it.
## Layout
| | |
|---|---|
| `NN_*.py` | the recipes. **These are the history** — kilobytes each, and they regenerate any state below from the original |
| `leaf_mask.npz` | the leaf mask (stage 04): per welded point, plus the vertex→welded map and the hue/sat/val the key was cut from. Regenerable, kept because stage 05 consumes it and re-deriving it costs a full import |
| `v01/` | stage output. **gitignored** (`characters/work/*/v0[0-9]/`) — registered artifacts inside it are `git add -f`'d by hand |
| `v01/review/` | QA renders. gitignored, regenerable, reused rather than re-minted |
## The stages
| stage | what it does |
|---|---|
| `01_graft.py` | grafts the AccuRig skeleton onto the pristine GLB (nearest-surface, not index-exact — the bait was decimated 20:1). This is what produced `lena_leafbikini_base_v01`. Independent of 0205 |
| `02_probe_leaves.py` | what are the leaves? Reports the albedo key candidates and per-vertex proudness |
| `03_render_leaves.py` | textured **and clay** turnaround of any mesh in the lane. The clay pass is what settled the question |
| `04_leaf_mask.py` | builds and *proves* the leaf mask: hue key → component filter → close → hole fill → grow, baked to vertex colour and rendered |
| `05_cut_leaves.py` | deletes the masked faces. Holes left open, on purpose |
## What the leaves turned out to be
The clay render (stage 03) shows every leaf, curled tip and hip vine with all materials
stripped, so:
1. **They are real geometry, not paint.** This is the opposite of Lena's game-body
underwear, where a ray-crossing census found the bra *was* the skin and deleting it
opened a hole — the whole reason `tools/make_lena_nude_body.py` re-fairs a rim band
instead of cutting. Here there is a solid shell to remove.
2. **They are welded into the body.** One Tripo mesh, one material, one UV map: the
leaves are a bulge in the body surface, not a separable object, material slot or UV
island. Nothing can be selected "by object"; the seam has to be found.
3. **The seam is both a colour edge and a crease** — where a leaf meets skin the surface
folds back on itself.
## Why the key is hue
Stage 02 first keyed on green dominance, `G - max(R, B)`, and topped out at 0.20: Tripo
painted these leaves a dark desaturated olive (sRGB ~0.27/0.35/0.20), so the channel gap
is only ~0.050.09 and any threshold catching the leaf also catches shadow noise on skin.
Hue separates them outright — skin 2040°, leaf 6080° — and is invariant to exactly the
thing that ruins the channel gap, which is how dark the pixel is.
Everything after the key is repaired **on the mesh, over position-welded adjacency**, not
in texture space:
| step | why |
|---|---|
| min-comp 200 | drops JPEG speckle |
| close 6 rings | fills specular blowouts inside a leaf, where the highlight clips to near-white and its hue becomes noise. At `--val-max 0.62` the hip vine leaves came back half-grey |
| enclosed-hole fill | a mask hole fully surrounded by leaf is a mask defect, not a skin island |
| grow 2 rings | lands the cut just outside the rim. A hole one ring too big is invisible; a leftover leaf stub is not |
| z ≤ 0.85 of height | **her irises and eyebrows are green too.** Without this the key takes her eyes (929 verts) |
Result: 239,561 welded points in exactly **two** islands — bust (z 0.6310.755 of height)
and briefs (z 0.4550.594). Two islands is itself the check that nothing else keyed.
Welded adjacency is not optional anywhere here: the glTF importer splits every UV seam
into separate Blender vertices, so on the raw mesh a face on a texture-chart border is
indistinguishable from a face on a hole rim, and ring operations would leak along the
seams (33,202 of the 1,029,360 verts are seam duplicates).
## The cut rule
A face dies only if **all** of its vertices are masked. With the 2-ring grow that puts the
surviving rim about one ring outside the green, i.e. just onto skin. Both directions of
that asymmetry are deliberate: "any vertex masked" erodes a further ring into her skin and
leaves single triangles hanging off every zig-zag in the mask edge, while a cut one ring
short leaves leaf root standing proud — the one outcome that would make the file useless
for judging the seam.
Then two passes that can only remove things that are already not part of a smooth surface:
dangling-face erosion (a face with ≥2 welded-boundary edges is joined to the surface by at
most one edge; no such face exists on a closed surface, so it cannot bite into skin) and
detached-shell removal. On this mesh they found **2 faces and 0 shells** — the colour cut
was already clean, which is the strongest evidence the mask was right.
## Regenerating `v01/lena_leafbikini_leafcut_sculpt_glb_v01.glb`
```
B="/c/Program Files/Blender Foundation/Blender 5.1/blender.exe"
L=characters/work/lena_leafbikini
"$B" --background --factory-startup --python $L/04_leaf_mask.py -- \
characters/originals/female/female_lena_leafbikini_tripo.glb $L/v01/review
"$B" --background --factory-startup --python $L/05_cut_leaves.py -- \
characters/originals/female/female_lena_leafbikini_tripo.glb $L/leaf_mask.npz \
$L/v01/lena_leafbikini_leafcut_sculpt_glb_v01.glb
"$B" --background --factory-startup --python $L/03_render_leaves.py -- \
$L/v01/lena_leafbikini_leafcut_sculpt_glb_v01.glb $L/v01/review cut
```
Stage 05's gates, all reported and the first two fatal:
| gate | result |
|---|---|
| every surviving vertex position present in the input | 0 strays — this is a deletion and only a deletion |
| loose vertices | 0 |
| surviving shells | 1 |
| removed geometry's extent | z 0.4550.755 of height (bust + briefs bands only) |
## Open
- **The holes are not filled, by request.** Filling is a separate and harder decision:
the crotch/gusset history in `work/lena/06*.py` is what happens when a membrane spans a
wide footprint — it flattens the anatomy it spans, which is why the nude lane only ever
faired a narrow rim band. Whatever fills these has to rebuild bust and crotch anatomy,
not just span them.
- The base-colour map still carries the leaves and their baked contact shadows. The rim of
each hole is skin painted with leaf shadow, so it reads darker than her surrounding tone.
- **The cut is unrigged**, because it was authored on the pristine original. It transfers
to the shipped body for free: `01_graft.py` moved the mesh 0.000000 mm, so
`lena_leafbikini_base_v01` carries the *same* 1,029,360 vertices in the same order and
`leaf_mask.npz` indexes it directly. Cutting the shipped GLB is running stage 05 with a
different first argument — but the result is a new ship folder, never an edit to the
frozen one.
Binary file not shown.