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