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:
+75
-2
@@ -139,7 +139,7 @@ names without touching the immutable delivered files.
|
||||
|---|---|---|---|
|
||||
| `lena` | female | `female_lena_tripo` | `lena_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".
|
||||
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) |
|
||||
|---|---|---|---|---|---|
|
||||
| `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` |
|
||||
| `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
|
||||
|
||||
@@ -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,
|
||||
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.
|
||||
|
||||
### `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 20–40°, leaf 60–80° — 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.69–0.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
|
||||
```
|
||||
BIN
Binary file not shown.
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 |
BIN
Binary file not shown.
+429
@@ -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": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
BIN
Binary file not shown.
@@ -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
|
||||
`.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
|
||||
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
|
||||
@@ -18,7 +26,7 @@ throwaway.
|
||||
| file | tris | notes |
|
||||
|---|---|---|
|
||||
| `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` |
|
||||
| `*_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
|
||||
|
||||
- Baits are geometry-only (~3.5 MB — a GLB's packed textures don't survive the FBX
|
||||
embed), so AccuRig shows a grey model. That's fine for rigging, but it means the
|
||||
July "Dummy001 export" check can't use textures: **verify AccuRig's output by mesh
|
||||
name + tri count instead.**
|
||||
- AccuRig exports come back to this folder as `<character>_tripo_accurig.fbx`.
|
||||
- Baits are geometry-only (~3.5 MB), so AccuRig shows a grey model. That's fine for
|
||||
rigging, but it means the July "Dummy001 export" check can't use textures: **verify
|
||||
AccuRig's output by mesh name + tri count instead.**
|
||||
*Correction 2026-08-13:* the parenthetical used to read "a GLB's packed textures don't
|
||||
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
|
||||
needed a `.gdignore` to stop Godot importing them. Nothing here is a game asset;
|
||||
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": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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 02–05 |
|
||||
| `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.05–0.09 and any threshold catching the leaf also catches shadow noise on skin.
|
||||
Hue separates them outright — skin 20–40°, leaf 60–80° — 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.631–0.755 of height)
|
||||
and briefs (z 0.455–0.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.455–0.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.
Binary file not shown.
Reference in New Issue
Block a user