diff --git a/characters/REGISTRY.md b/characters/REGISTRY.md index b7410a8..ece3d04 100644 --- a/characters/REGISTRY.md +++ b/characters/REGISTRY.md @@ -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. diff --git a/characters/female/lena_leafbikini_base_v01/README.md b/characters/female/lena_leafbikini_base_v01/README.md new file mode 100644 index 0000000..087d386 --- /dev/null +++ b/characters/female/lena_leafbikini_base_v01/README.md @@ -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 \ + + +Q_CACHE= LENA_SRC_BLEND= LENA_CLEAR_SPLIT_NORMALS=1 \ +LENA_OUT_GLB= blender --background --factory-startup \ + --python ariki-game/tools/make_lena_fullres_quatskin.py +``` diff --git a/characters/female/lena_leafbikini_base_v01/lena_leafbikini_base_accurig_glb_v01.glb b/characters/female/lena_leafbikini_base_v01/lena_leafbikini_base_accurig_glb_v01.glb new file mode 100644 index 0000000..27a54f9 --- /dev/null +++ b/characters/female/lena_leafbikini_base_v01/lena_leafbikini_base_accurig_glb_v01.glb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:bbfd8ac3919ad171cab76d62bec133d1f149ee3cf3c5d27f7408d502e99d678e +size 79080032 diff --git a/characters/female/lena_leafbikini_base_v01/preview/back.png b/characters/female/lena_leafbikini_base_v01/preview/back.png new file mode 100644 index 0000000..cf28119 Binary files /dev/null and b/characters/female/lena_leafbikini_base_v01/preview/back.png differ diff --git a/characters/female/lena_leafbikini_base_v01/preview/front.png b/characters/female/lena_leafbikini_base_v01/preview/front.png new file mode 100644 index 0000000..3a7df52 Binary files /dev/null and b/characters/female/lena_leafbikini_base_v01/preview/front.png differ diff --git a/characters/female/lena_leafbikini_base_v01/preview/q34.png b/characters/female/lena_leafbikini_base_v01/preview/q34.png new file mode 100644 index 0000000..f4c6a05 Binary files /dev/null and b/characters/female/lena_leafbikini_base_v01/preview/q34.png differ diff --git a/characters/female/lena_leafbikini_base_v01/preview/rigbait_qa_front.png b/characters/female/lena_leafbikini_base_v01/preview/rigbait_qa_front.png new file mode 100644 index 0000000..4c39c57 Binary files /dev/null and b/characters/female/lena_leafbikini_base_v01/preview/rigbait_qa_front.png differ diff --git a/characters/female/lena_leafbikini_base_v01/preview/torso.png b/characters/female/lena_leafbikini_base_v01/preview/torso.png new file mode 100644 index 0000000..072c8d1 Binary files /dev/null and b/characters/female/lena_leafbikini_base_v01/preview/torso.png differ diff --git a/characters/female/lena_leafbikini_base_v01/rig/lena_leafbikini_base_accurig_fbx_v01/lena_leafbikini_base_accurig_fbx_v01.fbx b/characters/female/lena_leafbikini_base_v01/rig/lena_leafbikini_base_accurig_fbx_v01/lena_leafbikini_base_accurig_fbx_v01.fbx new file mode 100644 index 0000000..7632e6e --- /dev/null +++ b/characters/female/lena_leafbikini_base_v01/rig/lena_leafbikini_base_accurig_fbx_v01/lena_leafbikini_base_accurig_fbx_v01.fbx @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6e096e57f55060f930c597b0dec07aeaa947fc765f80396e4d5edaff77de75f4 +size 4299280 diff --git a/characters/female/lena_leafbikini_base_v01/rig/lena_leafbikini_base_accurig_fbx_v01/lena_leafbikini_base_accurig_fbx_v01.json b/characters/female/lena_leafbikini_base_v01/rig/lena_leafbikini_base_accurig_fbx_v01/lena_leafbikini_base_accurig_fbx_v01.json new file mode 100644 index 0000000..5698221 --- /dev/null +++ b/characters/female/lena_leafbikini_base_v01/rig/lena_leafbikini_base_accurig_fbx_v01/lena_leafbikini_base_accurig_fbx_v01.json @@ -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": {} + } + } + } + } + } + } + } +} \ No newline at end of file diff --git a/characters/female/lena_leafbikini_base_v01/rig/lena_leafbikini_base_decimated_fbx_v01.fbx b/characters/female/lena_leafbikini_base_v01/rig/lena_leafbikini_base_decimated_fbx_v01.fbx new file mode 100644 index 0000000..98cbae9 --- /dev/null +++ b/characters/female/lena_leafbikini_base_v01/rig/lena_leafbikini_base_decimated_fbx_v01.fbx @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:fc866dcaaad4e59006e0561bf67dd0c0e699cbc9d740e8320e2f6539386ed18f +size 3774380 diff --git a/characters/rig-work/README.md b/characters/rig-work/README.md index 7be1f58..1d4d84c 100644 --- a/characters/rig-work/README.md +++ b/characters/rig-work/README.md @@ -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 `_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 `_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. diff --git a/characters/rig-work/lena_leafbikini_tripo_rigged.fbx b/characters/rig-work/lena_leafbikini_tripo_rigged.fbx new file mode 100644 index 0000000..29bba1c --- /dev/null +++ b/characters/rig-work/lena_leafbikini_tripo_rigged.fbx @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:beb184027aa9385f7b1ceed1bff49e9f0359b0ef0cf406998d9cf4108cb5cb99 +size 4299264 diff --git a/characters/rig-work/lena_leafbikini_tripo_rigged.json b/characters/rig-work/lena_leafbikini_tripo_rigged.json new file mode 100644 index 0000000..5698221 --- /dev/null +++ b/characters/rig-work/lena_leafbikini_tripo_rigged.json @@ -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": {} + } + } + } + } + } + } + } +} \ No newline at end of file diff --git a/characters/work/lena_leafbikini/01_graft.py b/characters/work/lena_leafbikini/01_graft.py new file mode 100644 index 0000000..a454245 --- /dev/null +++ b/characters/work/lena_leafbikini/01_graft.py @@ -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 -- +# +# 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") diff --git a/characters/work/lena_leafbikini/02_probe_leaves.py b/characters/work/lena_leafbikini/02_probe_leaves.py new file mode 100644 index 0000000..f2fe26b --- /dev/null +++ b/characters/work/lena_leafbikini/02_probe_leaves.py @@ -0,0 +1,168 @@ +# lena_leafbikini lane, stage 02: PROBE — what exactly are the leaves? +# +# blender --background --factory-startup --python 02_probe_leaves.py -- [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')}") diff --git a/characters/work/lena_leafbikini/03_render_leaves.py b/characters/work/lena_leafbikini/03_render_leaves.py new file mode 100644 index 0000000..7a623d8 --- /dev/null +++ b/characters/work/lena_leafbikini/03_render_leaves.py @@ -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 -- [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) diff --git a/characters/work/lena_leafbikini/04_leaf_mask.py b/characters/work/lena_leafbikini/04_leaf_mask.py new file mode 100644 index 0000000..98b98ca --- /dev/null +++ b/characters/work/lena_leafbikini/04_leaf_mask.py @@ -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 -- +# [--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 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) diff --git a/characters/work/lena_leafbikini/05_cut_leaves.py b/characters/work/lena_leafbikini/05_cut_leaves.py new file mode 100644 index 0000000..0605533 --- /dev/null +++ b/characters/work/lena_leafbikini/05_cut_leaves.py @@ -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 -- \ +# [--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)") diff --git a/characters/work/lena_leafbikini/06_open_in_blender.py b/characters/work/lena_leafbikini/06_open_in_blender.py new file mode 100644 index 0000000..f7bedf8 --- /dev/null +++ b/characters/work/lena_leafbikini/06_open_in_blender.py @@ -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 -- +# +# 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) diff --git a/characters/work/lena_leafbikini/README.md b/characters/work/lena_leafbikini/README.md new file mode 100644 index 0000000..c59d3f3 --- /dev/null +++ b/characters/work/lena_leafbikini/README.md @@ -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. diff --git a/characters/work/lena_leafbikini/leaf_mask.npz b/characters/work/lena_leafbikini/leaf_mask.npz new file mode 100644 index 0000000..2ea3f3d --- /dev/null +++ b/characters/work/lena_leafbikini/leaf_mask.npz @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:209ecd26ad3f0b2a0c8eb6fba14cb436195f15f936ca3e4e64db0eda9550d72f +size 6436884 diff --git a/characters/work/lena_leafbikini/v01/lena_leafbikini_leafcut_sculpt_glb_v01.glb b/characters/work/lena_leafbikini/v01/lena_leafbikini_leafcut_sculpt_glb_v01.glb new file mode 100644 index 0000000..0fcab61 --- /dev/null +++ b/characters/work/lena_leafbikini/v01/lena_leafbikini_leafcut_sculpt_glb_v01.glb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8aa3421e98e985b57bda222a5cee6fd87343b6b773376c740bd907ae08db8897 +size 44832212