This commit is contained in:
Can Narin
2026-08-18 22:47:07 +03:00
113 changed files with 64781 additions and 27 deletions
@@ -0,0 +1,88 @@
# Handoff: runtime FLAT hand pose in ariki-game (2026-08-17)
> **STATUS 2026-08-17 (session "flatpose"): DONE — all 3 acceptance criteria verified.**
> Implementation (uncommitted, in ariki-game): `src/Animation/HandPoseLayer.cs`
> (SkeletonModifier3D, per-hand 0..1 slerp blend, Mako/MixamoSkin excluded, missing bones
> skipped), PlayerController wiring (`SetFlatHands`, attached to `AnimatedSkeleton`),
> DanceTeam/DancerRig plumbing, bed hotkeys **H** (toggle, active team) + **J** (hand cam),
> HUD `hands=FLAT` flag, pose copied to `assets/quaternius/hand-poses/pose_flat.json`.
> Verified in the dance bed (agent API screenshots): exp01 body fingers visibly flatten
> mid-dance with no spikes; shipped mitt body = silent no-op, zero console errors; nothing
> committed. Test body copy `derived-bodies/lena_leafbikini_quatskin_fingers_glb_exp01.glb`
> is untracked/test-only — do not ship it from here. One Vulkan device-lost crash occurred
> during testing (RX 5700 XT TDR) — unrelated to this code, relaunch cured it.
**Goal:** the female player character can hold a FLAT hand (fingers straight, together)
at runtime, applied as a layer on top of any playing animation. Flat only — fist and
grip are blocked on a weight repair that is running in a parallel lane (see "Scope
fence" below).
## Why this works at all
- ~70100% of finger tracks in the shipped dance clips are frozen at rest, so a
per-frame finger override loses nothing from the animations.
- Hand poses were harvested from the Kevin packs into `animation/hand-poses/`:
`pose_flat.json`, `pose_relaxed.json`, `pose_fist.json`, `pose_grip.json`.
- Format: `{"bones": {"<bone_name>": [x, y, z, w], ...}}` — glTF node-local
quaternions on the **canonical Quaternius skeleton**, which is exactly Godot
bone-pose space for these bodies. Apply directly:
`skeleton.SetBonePoseRotation(skeleton.FindBone(name), new Quaternion(x, y, z, w))`.
No rest-relative correction on canonical rigs (that hack is only for Mako, out of
scope here).
- 40 bones per pose file, including `*_04_leaf_*` tip bones. Some bodies lack the
leaf bones — **skip bones that FindBone returns -1 for**, never error.
## Body situation (the trap that makes testing confusing)
- The SHIPPED female body `assets/quaternius/derived-bodies/Ariki_Female_QuatSkin.glb`
has **zero finger weights** (the converter deliberately folds fingers into the hand
bone — "rigid mitt"). Applying the pose to her is correct code but shows NOTHING.
- A finger-weighted candidate exists and the flat pose is validated on it
(max displacement 1.8 cm, clean QA renders in
`animation/characters/work/lena_leafbikini/v02/review/`):
`animation/characters/work/lena_leafbikini/v02/lena_leafbikini_quatskin_fingers_glb_exp01.glb`
Use it as the TEST body.
- Mako (`Ariki_Male_Mako.glb`) has corrupt cross-hand finger weights — do NOT enable
the pose layer on him; a finger curl throws verts metres. Female/canonical only.
## Implementation pointers
- Pattern to copy: `ariki-game/src/Animation/PlayerIKRig.cs` — post-animation bone
modification. The hand-pose layer is the same idea: after the AnimationTree/Player
updates, write the pose quats onto the finger bones each frame while the layer is
active. Keep an on/off (and ideally a blend weight 0..1 slerping from the animated
pose) per hand.
- Test bed: `ariki-game/src/Testing/Dance/DanceTestBed.cs`. `DANCE_BODY_GLB` env var
swaps the bed's body — point it at the exp01 GLB above (see comment near line 82).
Run with `MOCK_ONLY=1`.
- ENGINE TRAP: the S3 engine is v1.0.0 (Godot 4.6.2) and the repo is on SDK 4.7.
If you locally downgrade `csproj`/`project.godot` to launch, **NEVER commit those
lines**.
## Verification tools (animation repo)
- `tools/handpose_bake_preview.py body.glb pose.json out.glb` — bakes a pose into a
GLB's rest rotations (what the runtime layer should reproduce).
- `tools/skin_displacement_check.py posed.glb original.glb` — Godot-exact LBS math;
flat on exp01 reads max 1.8 cm / median 0.27 cm. Meter-scale numbers = broken.
- `tools/handpose_skin_to_obj.py posed.glb l|r out.obj` +
`tools/handpose_render_objs.py` (blender --background) — the only honest VISUAL
check. **Do not judge by importing a baked-pose GLB into Blender and rendering:
the importer ignores the rest-vs-bind rewrite and draws false shards.**
## Scope fence
- Do not swap or re-export any shipped body (`Ariki_Female_QuatSkin.glb`,
`characters/female/lena_leafbikini_base_v01/` is frozen). The finger-weighted body
ships from the parallel weight-repair lane, not from this task.
- Do not touch `ariki-game/tools/make_lena_fullres_quatskin.py`; its
`LENA_RIGID_FINGERS` default must stay `"1"`.
- Flat pose only. Fist/grip activation waits for the repaired weights.
## Acceptance
1. In the dance test bed with `DANCE_BODY_GLB` = exp01, toggling the layer while a
dance plays visibly straightens/flattens the fingers, no vertex spikes, and the
rest of the animation is unaffected.
2. On the shipped mitt-handed body the layer is a silent no-op (no errors).
3. Nothing committed in either repo changes any shipped asset or engine version.
@@ -0,0 +1,220 @@
# Handover: Lena hand MORPH track (blend-shape lane) — 2026-08-18
You own the **morph lane**. A parallel agent owns the **bone lane** (finger weights +
`HandPoseLayer`). Read the scope fence before touching anything — the two lanes share a
git branch and one serialized test bed.
## Goal
Lena can hold **fist** and **grip** in-game, on the **shipped** body. Flat already works
via the bone lane, so flat is not your problem — the two poses that never made it are
fist and grip, and this lane is the one that can carry them, because it does not depend
on finger weights at all.
The blocking bug is known and localized: **coincident (unwelded) duplicate vertices in
the scan mesh are invisible to the solver's convergence gate**, so the right hand's
fist/grip still tear. Fix that, re-solve, get a bed visual, then make the ship-body call.
## Why this lane exists (do not re-litigate)
Lena's mesh is a Tripo scan with ~2.6k inter-digit bridge edges (web remnants). Bone
weights only choose *which bone drags a shared vertex* — when adjacent digits curl apart
in a fist, those bridges MUST tear. Five weight-rebake iterations (exp01exp05) never
converged; the verdict from that lane is that weights alone cannot clear the bar on this
mesh. A morph target IS the final vertex positions, so tearing is impossible by
construction and the residual web stretch becomes one smoothable, *converging* geometric
problem. Morphs also work on the shipped **rigid-mitt** bodies (zero finger weights) and
on Mako, with no rest-space compatibility hacks and no body denylist.
## What already works (verified, not aspirational)
- **Runtime driver committed** in ariki-game: `src/Animation/HandMorphLayer.cs`
(commit `40b995e21`). Discovers `hand_<pose>_<l|r>` blend shapes on the body's meshes,
per-hand 0..1 blend, `CyclePose()`, logs once and goes inert on bodies without shapes.
`PoseOrder = { flat, relaxed, fist, grip }`.
- **Shape preservation**: `src/Character/BodyMeshShaper.Deform()` carries the shapes
through muscle/fat rebuilds.
- **Solver + verifier** (animation repo, **untracked** — committing them is your job):
`tools/handshape_solve.py` (723 lines), `tools/handshape_verify.py` (109 lines),
plus `hand-shapes/README.md`.
- **In-engine verification happened once** (2026-08-17): fist and grip read as real
fists/grips mid-dance at hand-cam range on the demo body, no fins/shards/spikes
(agent-API screenshots 181806/181808).
- **Two beds can drive it**: `dance_test_bed` (hotkey **K** cycles poses, **J** hand cam,
**1** frames the team) and the newer `anim_hand_test_bed` (commit `d1e6fb0a5`, Lena +
Mako side by side as game-model rigs, button panels for both hand lanes + hand-follow
cams). Prefer `anim_hand_test_bed` — it is the exact colonist build path the game uses.
## Solver pipeline (so you can navigate 723 lines fast)
`tools/handshape_solve.py`, all offline pure numpy over **raw GLB bytes** — Blender is
only the interpreter host (numpy), no `bpy`, no scene import. The
"Blender-importer-draws-false-shards" trap therefore does not apply to the solver, but
see the render warning below.
1. parse GLB, rest skeleton (node globals × IBM = skinning space) — `read_glb`, `Skeleton`
2. hand ROI: verts within 1.8 cm of finger/hand bone segments, grown 3 edge rings — `build_roi:188`
3. ROI graph (CSR adjacency + unique edge list) — `roi_graph:208`
4. per-bone geodesic fields: multi-source Dijkstra over the ROI subgraph — `dijkstra_multi:226`
5. weights: gaussian kernels on geodesic distance, top-4, renormalized, smoothed — `solve_weights:250`
6. pose: parametric curl/spread/thumb-opposition per joint, pivoted at each joint head,
LBS with the solver's OWN weights (the GLB's rigid-mitt weights are irrelevant and
that is fine) — `pose_globals:336`, `lbs:398`; pose parameters live in `DEFAULT_PARAMS`
7. relax: stretch-gated Laplacian diffusion of the DELTA field until the bars pass — `relax:448`
8. gates: `stretch_stats:434``handmorph_report.json` + per-pose OBJ dumps
9. emit: splice morph accessors into the GLB (deltas added to base — exactly Godot's
`w = target + base`) with `extras.targetNames = hand_<pose>_<l|r>``emit_morph_glb:552`.
POSITION **and** NORMAL deltas are emitted (position-only morphs leave lighting on the
rest shape and read as "torn texture").
`--selftest-bump` splices one synthetic 3 cm palm bump with no solve — use it to prove the
import + drive path end-to-end on any new body before trusting solver output.
## THE BUG — weld before solve
`stretch_stats` (line 434) and the `relax` gate (line 448, `s[lr < 0.001] = 1.0`) both
apply a **1 mm rest-length floor**. The reasoning was sound for decimation slivers, but
this mesh is unwelded chart soup: it carries *coincident duplicate* verts whose rest edge
length is ~0. Those edges are excluded from the stats AND from the convergence gate, so
**the relaxation never even tries to fix them**. Measured 2026-08-18: a population of
sub-mm edges stretches to **420 cm** in fist/grip on BOTH hands (~2560 per hand over 5×)
— hairline needles, sub-pixel in the screenshots that "passed", but really there.
`grep -niE "weld|coincid|dedup" tools/handshape_solve.py` returns **zero hits** — no weld
pass exists.
**The fix, and the shape it has to take.** Weld at the *graph* level, not by rewriting the
mesh: build a representative map over coincident positions (hash/round positions to ~1e-6,
or a KD-tree at ~10 µm), solve on the welded ROI, then **scatter each welded vertex's delta
back to every duplicate in its weld group** before `full[idx] = relaxed`. Morph deltas must
stay indexed by *original* vertex id (the GLB's own attribute order) or the splice breaks.
Because all members of a group then receive an identical delta, coincident edges keep
length exactly and the entire failure class dies by construction rather than by tuning.
Insert the weld between `build_roi` (called in `main`, ~line 228) and `roi_graph`, and make
sure the Dijkstra/adjacency also runs on the welded graph — otherwise geodesic distances
still leak across seams and the weight field stays fragmented.
**Hypothesis worth testing while you are in there** (state it as a hypothesis, do not
assume): the right hand's tracked ≥1 mm failures may be the same disease. If duplicates
split the delta field across a chart seam, welding should collapse a good share of those
too. Measure before and after; report both.
## Current numbers — the baseline you must beat
Body: `Ariki_Female_QuatSkin_LowPoly_40.glb` (restored `lena_leafbikini_base_v01` mesh, so
no yellow-material defect). ROI = **5,548 verts**. From
`characters/work/lena_leafbikini/handmorph/handmorph_report.json` (2026-08-18 08:49),
post-relax, ≥1 mm edges only:
| shape | max | p99.9 | n>2× | n>5× | iters |
|---|---|---|---|---|---|
| flat_l | 1.94 | 1.60 | 0 | 0 | 11 |
| relaxed_l | 3.60 | 1.49 | 3 | 0 | 60 |
| fist_l | 4.23 | 1.99 | 15 | 0 | 60 |
| grip_l | 4.38 | 1.97 | 13 | 0 | 60 |
| flat_r | 2.41 | 1.59 | 1 | 0 | 8 |
| relaxed_r | 4.08 | 3.22 | 81 | 0 | 60 |
| **fist_r** | **29.78** | **18.94** | 214 | **37** | 60 (hit cap) |
| **grip_r** | **20.38** | **12.10** | 372 | **35** | 60 (hit cap) |
Left hand is fully clean — *better* than the earlier LENA_rig_v1 body. Right fist/grip are
the failures, and both burn all 60 relax iterations without converging.
Fingertip travel is healthy and should stay so: fist_l tips 9.010.3 cm (thumb 15.3),
relaxed_l 1.72.0 cm (thumb 6.1).
**Gate bars** (per shape, ≥1 mm edges — and after your fix, the sliver population too):
edge stretch p99.9 ≤ 1.6×, **zero** edges > 5×; fingertip travel fist ≥ 2.5 cm/finger,
grip ~2 cm, relaxed 0.52 cm; cross-hand independence 0 cm on the other hand's verts.
## How to run
Solve (Blender is just the numpy host):
```
"C:/Program Files/Blender Foundation/Blender 5.1/blender.exe" --background \
--factory-startup --python tools/handshape_solve.py -- \
--body C:/Users/Jeremy/tinqs/ariki-game/assets/quaternius/derived-bodies/Ariki_Female_QuatSkin_LowPoly_40.glb \
--poses flat,relaxed,fist,grip \
--out C:/Users/Jeremy/tinqs/ariki-game/scratchpad/lowpoly40_handmorph.glb \
--workdir characters/work/lena_leafbikini/handmorph/
```
Verify in-engine — **the only honest gate**:
```
HANDMORPH_BODY_F=res://scratchpad/lowpoly40_handmorph.glb \
SCENE=anim_hand_test_bed MOCK_ONLY=1 AGENT_OWNED=1 WAIT=1 bash tools/game.sh spawn
```
(`HANDMORPH_BODY` applies to both rigs, `_F`/`_M` per rig — see `DancerRig.cs:40`.
In `dance_test_bed` instead: **1** frames the team, **J** hand cam, **K** cycles
None→flat→relaxed→fist→grip→None.)
## Two traps that have already cost time
- **Do not judge the solver's OBJ dumps by clay render.** The un-welded chart soup renders
as black-gap confetti even at REST (flipped per-chart normals). It is dishonest in both
directions. Judge morphs **in-engine only**. The numeric report + the bed are the gates.
- **`handshape_verify.py` reports `own-delta = -1` sentinels on this body** — a mesh/bone
space mismatch in the verifier, not in the solve. Don't trust it here; either fix the
verifier or ignore it and rely on the report + bed.
## Open decisions you own
1. **Ship-body decision** — nothing shipped carries the shapes yet, so the layer is a
silent no-op on the real Lena. `LowPoly_40` costs ~**+41 MB** for 8 shapes; full-res
~**+98 MB**, not shippable as-is. Options: fewer shapes (drop `flat`/`relaxed` — the
bone lane already does flat on finger-weighted bodies, but the *shipped* Lena is a
rigid mitt, so think it through), hand-region remesh, LOD1-only, or quantized deltas.
Bring Jeremy the numbers and a recommendation; do not ship a 128 MB body silently.
2. **Mako male + full-res female** via the same one-command solve — untried.
3. **Pose authoring** is currently "edit `DEFAULT_PARAMS`". Could become JSON + a tuning
scene. Low priority — only if pose tuning becomes the bottleneck.
4. **Commit the tooling.** `tools/handshape_solve.py`, `tools/handshape_verify.py`,
`hand-shapes/README.md` and the `handmorph/` workdir are all untracked in the animation
repo. Commit the tools and README; keep the demo GLB out of git (128 MB, `scratchpad/`).
## Scope fence — the parallel bone agent
**Yours** (edit freely): `tools/handshape_solve.py`, `tools/handshape_verify.py`,
`hand-shapes/`, `characters/work/lena_leafbikini/handmorph/`, ariki-game
`src/Animation/HandMorphLayer.cs`, `scratchpad/lowpoly40_handmorph.glb`.
**Not yours** (the bone agent is actively editing these): `tools/handpose_*.py`,
`tools/edge_stretch.py`, `tools/fin_bones.py`, `hand-poses/`,
`characters/work/lena_leafbikini/08_finger_weights.py` and the `v02/` weight-experiment
bakes, ariki-game `src/Animation/HandPoseLayer.cs`.
**Shared, coordinate before touching:**
- `src/Testing/AnimHandTestBed.cs` and `src/Testing/Dance/DanceTestBed.cs` — both lanes'
controls live in these files. Announce edits.
- **Only ONE `--agent-api` game instance may run at a time.** The bed is a serialized
resource; check whether the other agent is mid-run before you spawn.
- ariki-game branch **`handpose-flat-runtime`**, currently 4 local commits ahead and
**not pushed** (`40b995e21`, `518b72ac6`, `fa5e8e8de`, `d1e6fb0a5`). Both agents commit
here. Pull/rebase before committing, and never squash across the other lane's work.
**Hard rules (unchanged):**
- Do not re-export or swap any shipped body outside the registry process;
`characters/female/lena_leafbikini_base_v01/` is frozen.
- Do not touch `ariki-game/tools/make_lena_fullres_quatskin.py`; `LENA_RIGID_FINGERS`
default stays `"1"`.
- **Never commit `csproj` / `project.godot` engine-version lines**, and don't sweep the
repo-wide `.import` churn into your commits — the working tree has hundreds of modified
`.import` sidecars that are not yours. Commit explicit paths only.
- Do not enable the **bone** lane on Mako (corrupt cross-hand finger weights, verts fly
metres). The morph lane on Mako is safe in principle — but prove it with
`--selftest-bump` first.
## Acceptance for this track
1. Weld pass lands in `handshape_solve.py`; a fresh solve reports **zero** edges > 5× on
all 8 shapes with the sliver floor removed (report the sliver population before/after).
2. Fingertip travel bars still met (fist ≥ 2.5 cm/finger), cross-hand independence 0 cm.
3. `anim_hand_test_bed` screenshots at hand-cam range: fist and grip read as a real fist
and grip on Lena mid-dance, both hands, no needles/shards/spikes.
4. A written ship-body recommendation with MB costs, for Jeremy's decision.
5. Tools + README committed in the animation repo; no shipped asset or engine-version
line changed.
+11
View File
@@ -2,6 +2,7 @@
__pycache__/
*.pyc
.DS_Store
.env
# Working-lane scratch — see .agents/rules/working-files.md
# Regenerable by re-running the lane's NN_*.py recipe from its pinned master.
@@ -14,6 +15,16 @@ __pycache__/
**/dbg_*/
**/probe_*/
# Lane-specific scratch, same tier: regenerable A/B pose bakes and solver dumps.
# mako/handfix: 12 x 18 MB broken/repaired bake GLBs + render PNGs, all rebuilt by the
# handfix recipes; the masks, pose json and review evidence are the keepers.
# lena handmorph: per-pose OBJ dumps, rebuilt in ~1 min by tools/handshape_solve.py
# (and clay renders of them lie — hand-shapes/README.md); the report json is tracked.
characters/work/mako/handfix/*.glb
characters/work/mako/handfix/hand_out/
characters/work/mako/handfix/hand2_out/
characters/work/lena_leafbikini/handmorph/*.obj
# Per-stage lane output dirs — the SCRATCH tier of working-files.md, in bulk.
# characters/work/lena/v02/ alone is 1,019 MB: 15 .blend snapshots of the same
# ~883k-vertex mesh (48_headsafe, seamed, reatlased, 52_transplant, 55_fix1/2/3,
+20
View File
@@ -251,6 +251,7 @@ 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** |
| `work/lena_leafbikini/v02/lena_leafbikini_crotchfill_sculpt_glb_v02.glb` | `de7b713e03fc9b8611b83cafdb6d24e1ad71eedc7045fea51e54eb166d632951` | 2026-08-14 | `work/lena_leafbikini/04`+`07` | the briefs leaves **melted into a smooth Barbie-doll crotch**; bust holes still open |
### `lena_leafbikini_leafcut_sculpt_glb_v01` — what it is
@@ -273,3 +274,22 @@ that produced the ship moved the mesh 0.000000 mm, so `lena_leafbikini_base_v01`
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.
### `lena_leafbikini_crotchfill_sculpt_glb_v02` — what it is
The same original with the briefs-band leaves **melted into a smooth featureless
"Barbie doll" crotch** (bi-harmonic membrane over the leaf shell's own topology — the rims
supply position, the collar supplies slope) with a pure-Laplacian flow finish. The bust
holes remain open from v01. Requested target: Barbie doll anatomy — no cleft, no features,
belly flowing into thighs; the geometry now does exactly that.
Known consequences, documented in the lane README (not defects to chase):
- **two mild shelf bulges at the hip sides** where the vine crossed the inguinal crease —
there was never skin under the vine, and every rim-anchored fill spans its anchors by
construction; removing them means sculpting the crease, a separate decision;
- **the fill is one flat donor texel** and leaf paint survives on skin just outside the
melt — the albedo re-author is its own later stage (the atlas is Tripo chart soup).
Unrigged and vertex-compatible with the ship for the same reason as v01. The recipe
(`07_fill_crotch.py`) carries the ten-run autopsy of every approach that failed first —
worth reading before attempting any similar repair.
Binary file not shown.

After

Width:  |  Height:  |  Size: 713 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 415 KiB

+35
View File
@@ -0,0 +1,35 @@
# LENA_rig_v1 — GLM's articulated-finger Lena (pulled from the game 2026-08-18)
The "hands attempt" rig model: straight-leg / flat-foot / articulated-finger Lena,
authored by GLM in `LENA_rig_v1.blend` and shipped into ariki-game on 2026-08-15 as
three commits (`440e36230`, `b82b46437`, `51066d64e`), replacing BOTH shipped female
bodies outside the registry process. Jeremy pulled it from the game on 2026-08-18
("keep the model in the animation repo, not in game") — ariki-game's
`Ariki_Female_QuatSkin.glb` and `Ariki_Female_QuatSkin_LowPoly_40.glb` were reverted
to the pre-swap `lena_leafbikini_base_v01` ship (`440e36230^`, byte-identical to the
in-game `_archive_lena/*_splayed-mitts_preswap_2026-08-15.glb` copies).
**The blend does not survive.** `LENA_rig_v1.blend` exists nowhere on disk (C: and A:
checkouts searched 2026-08-18) — these two GLB exports are the only artifacts of the
model. If the rig is ever wanted again, it must be rebuilt or re-exported from
whatever GLM environment produced it.
| file | what it is |
|---|---|
| `lena_rig_v1_quatskin.glb` | full-res export (= game history `51066d64e`). Materials are CORRECT (separate normal / basecolor / rm images). |
| `lena_rig_v1_lowpoly40_yellowbake.glb` | LOD1 bake (= game history `51066d64e`). **Materially broken — the yellow body.** |
## Why it rendered yellow (the LOD1 defect, so nobody re-ships it blind)
The LOD1 bake embeds ONE image (`Lena_rig_v1_LowPoly_40_Baked` — a normal-looking
skin/leaf atlas) but declares three texture entries **all pointing at that one image**
(`textures: [{source:0},{source:0},{source:0}]`), wired to normalTexture,
baseColorTexture AND metallicRoughnessTexture. glTF reads metallic from the blue
channel and roughness from green, so skin pixels (B≈0.45) render half-metallic and
glossy — the body picks up sky/ground bounce and goes shiny yellow-orange in-game.
The skin atlas doubling as a normal map adds the lumpy shading on top. The full-res
export does NOT have this bug; only the LOD1 bake does.
Fix if reusing: re-bake LOD1 with real normal + rm images, or strip
`normalTexture`/`metallicRoughnessTexture` and set `metallicFactor: 0`,
`roughnessFactor: ~0.9` on `MI_Body_Lena`.
Binary file not shown.
@@ -0,0 +1,820 @@
# lena_leafbikini lane, stage 07: BARBIE-FILL the crotch — melt the briefs leaves into a
# smooth featureless surface. The bust holes stay cut open (stage 05 behaviour).
#
# blender --background --factory-startup --python 07_fill_crotch.py -- \
# <pristine.glb> <leaf_mask.npz> <out.glb> [--z-split 0.615] [--free-rings 2]
# [--puff-mm 0.0] [--blend <out.blend>]
#
# TARGET. "Barbie doll anatomy": a completely smooth, undifferentiated pelvic surface — no
# cleft, no features, a taut convex continuation of belly into inner thighs. That is exactly
# what a bi-harmonic membrane produces: solve L²x = 0 with the surrounding skin held fixed.
# The rim supplies POSITION (the "distance between the two sides"), the collar behind it
# supplies SLOPE through the second Laplacian application (the "angle"), and a bi-harmonic
# surface cannot invent detail — no crease, no cleft, by construction.
#
# WHY MELT, NOT FILL. This stage was first written as hole-filling on the stage-05 cut, and it
# failed twice, instructively:
#
# attempt 1 — triangle_fill each rim + densify + membrane. The briefs rim is ONE ~2,500-vert
# loop snaking front -> between the legs -> back; beauty triangulation of a loop that long
# and that non-convex connects the WRONG BANKS at every bend. The membrane then faithfully
# smooths garbage into rippled sheets. Bonus failure: the rim itself still carried leaf-root
# remnants, and a membrane anchors position AND slope to its rim, so it reproduced the
# crumple (the mesh-repair playbook's "collar on CLEAN skin" lesson, re-learned).
# attempt 2 — rim erosion (3 rings) + interleaved Delaunay flips + double solve. Better rims,
# same disease: flips are local, the mis-bridging is global. Long sliver strands shot off
# the hips where chords bridged front rim to back rim, and triangle_fill did not even close
# the pinched loop (2,177 faces where ~2,471 were needed; 819 boundary edges left).
#
# The fix is to stop inventing topology. THE LEAF SHELL IS the disk that spans the hole — the
# scan's own manifold surface, connected to the true rim at every point, no bank ever bridged
# wrongly. So in the crotch band the leaves are not deleted at all: their vertices are FREED,
# a --free-rings collar of surrounding skin is freed with them (this erases the under-leaf rim
# crease, the same move as the nude lane's fair_rim_band), and the membrane collapses the
# whole shell onto the smooth spanning surface. Folded flaps famously resist melting by
# ITERATIVE flow (the 06*-era lesson) — but L²x = 0 is linear with a unique solution, and PCG
# run to convergence lands on it regardless of where the folds start.
#
# THE REMNANT SWEEP. Near the melt zone the colour key is re-run without the stage-04 mask's
# blind spots (min-comp speckle filter, value gate): any fixed vertex within 6 rings of the
# primary free set that is greenish (hue 46..200, no gates) or blown-white (val >= 0.78,
# sat <= 0.25 — nothing on her actual skin is that colour) joins the melt, grown 2 rings.
# Freeing a few honest skin verts by accident is harmless — the membrane returns them almost
# in place; a pinned leaf fragment is not.
#
# THE BALLOON EXCISION. The melt's first run still left half a dozen smooth raised nubs, and a
# debug bake proved they were FREE verts — melted, converged, and still bulging. That is not a
# solver bug, it is what L²x = 0 does to a PENDANT BALLOON: a fully-masked leaf is a closed
# shell attached along its root line, its excess surface area has nowhere to go, and the
# bi-harmonic solution — smooth in GRAPH terms, with no maximum principle — parks it as a
# rounded mound. Two remedies failed before this one worked:
# * proudness detection (60-sweep, then 400-sweep Taubin reference): a smoothing-built
# reference partially FOLLOWS any bump wider than its radius, so visibly 4 mm nubs measured
# 1.7 mm and thresholds caught only their tips;
# * Laplacian deflation of what it did catch: flattened tips, kept the wave.
# What a wad cannot hide is its AREA: several layers of surface over one spot put several times
# the vertices of honest membrane into the same cell of a 4 mm grid. So: detect wads by vertex
# density, EXCISE every face touching one, and refill the scars with triangle_fill + densify +
# a small membrane solve — which is exactly the right tool at this scale (round holes a
# centimetre or two across; its failure mode was only ever the giant winding channel).
# CAVEAT the run exposed: the whole melted shell lies ~4 layers deep (cell median 37), so
# density above median finds the FLAT piles — worth excising, they would z-fight — but the
# visible INFLATED caps sit at ~2 layers, BELOW median. Density cannot see them either.
#
# THE FLOW FINISH — the step that actually guarantees a smooth result, with no detector at
# all: damped pure-Laplacian flow over the entire changed region, fixed skin held, weight
# ramping 0 -> 1 over the first 8 rings so the membrane's C1 rim blend survives. The maximum
# principle does what every detector could not promise: a raised cap has strictly nowhere to
# go but down, while the broad pubic web barely moves (flow erases features at ~1/size², and
# the web is 5-10x wider than any cap). A short Taubin polish follows to undo the slight
# overall shrink. This ordering — bi-harmonic for shape, flow for guarantees — is the recipe.
#
# TEXTURE. Melted faces keep leaf texels, so every face whose vertices are all masked gets ONE
# donor texel — an old thigh-band vertex whose albedo is closest to the band's median skin tone
# and whose normal-map texel is nearest neutral. Interpolating UVs instead is meaningless here:
# the atlas is Tripo chart soup (the sibling Lena mesh had 5,870 charts). Flat is correct —
# Barbie plastic has no albedo detail either. The baked leaf contact shadows still darken the
# surviving skin just outside the melt; that is an albedo problem for a later stage.
#
# TOPOLOGY GROUND RULES (as stages 04/05): the glTF importer splits every UV seam, so the mesh
# is welded (exact duplicates, 1e-5) before boundaries or adjacency mean anything — UVs live
# per face corner and survive the weld. Custom split normals do not survive the bmesh round
# trip; on the WELDED mesh "clear + shade smooth" is seamless, which is the second reason the
# weld comes first.
import bpy, bmesh, sys, os, time, argparse
from collections import deque
import numpy as np
def interior_edges(faces):
"""Edges whose every adjacent face is a patch face — the only ones safe to subdivide."""
return list({e for f in faces for e in f.edges
if all(lf in faces for lf in e.link_faces)})
def refresh(faces, *rets):
"""Re-collect live patch faces after a bmesh op invalidated / created some."""
out = {f for f in faces if f.is_valid}
for ret in rets:
for key in ("geom", "geom_inner", "faces"):
for g in ret.get(key, ()):
if isinstance(g, bmesh.types.BMFace) and g.is_valid:
out.add(g)
return out
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("--z-split", type=float, default=0.615,
help="fraction of body height separating briefs (melted) from bust (cut open); "
"briefs mask tops out at 0.594, bust starts at 0.631")
ap.add_argument("--free-rings", type=int, default=2,
help="rings of surrounding skin freed with the leaves, to erase the rim crease")
ap.add_argument("--puff-mm", type=float, default=0.0,
help="optional outward dome on top of the membrane, peak amplitude in mm")
ap.add_argument("--blend", default="")
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"[melt {time.time()-t0:6.1f}s] {m}", flush=True)
def smoothstep(x):
x = np.clip(x, 0.0, 1.0)
return x * x * (3.0 - 2.0 * x)
# =============================================================================================
# load original + mask, split the mask at the waist
# =============================================================================================
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
bpy.context.view_layer.objects.active = body
body.select_set(True)
n0 = len(me.vertices)
log(f"in : '{body.name}' {n0}v {len(me.polygons)}f")
z = np.load(MASK)
inv, mk = z["inv"].astype(np.int64), z["mask"]
if len(inv) != n0:
raise SystemExit(f"[melt] FATAL: mask was built for {len(inv)} verts, this GLB has {n0}")
vm = mk[inv] # per raw vertex: is it leaf?
co = np.empty(n0 * 3); me.vertices.foreach_get("co", co); P0 = co.reshape(-1, 3)
Z0, H = float(P0[:, 2].min()), float(P0[:, 2].max() - P0[:, 2].min())
MM = 1000.0 * 1.777 / H
zf0 = (P0[:, 2] - Z0) / H
crotch = vm & (zf0 <= A.z_split)
bust = vm & (zf0 > A.z_split)
log(f"mask: {vm.sum()} leaf verts -> {crotch.sum()} briefs (melt), {bust.sum()} bust (cut)")
# per-vertex albedo HSV for the remnant sweep — sampled NOW, on the raw import, because UV
# indexing goes stale the moment bmesh touches the topology
base_img = 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_img = nd.image
if base_img is None:
raise SystemExit("[melt] FATAL: no base-colour image")
nl0 = len(me.loops)
lv0 = np.empty(nl0, dtype=np.int32); me.loops.foreach_get("vertex_index", lv0)
uv0 = np.empty(nl0 * 2); me.uv_layers.active.data.foreach_get("uv", uv0); uv0 = uv0.reshape(-1, 2)
vuv0 = np.zeros((n0, 2)); vuv0[lv0[::-1]] = uv0[::-1]
w_, h_ = base_img.size
buf = np.empty(w_ * h_ * 4, dtype=np.float32); base_img.pixels.foreach_get(buf)
px = buf.reshape(h_, w_, 4)[:, :, :3]; del buf
xi = np.clip((vuv0[:, 0] * (w_ - 1)).astype(np.int64), 0, w_ - 1)
yi = np.clip((vuv0[:, 1] * (h_ - 1)).astype(np.int64), 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); dd = mx - mn
hue = np.zeros(n0)
nz = dd > 1e-6
im = np.argmax(S, axis=1)
sel = nz & (im == 0); hue[sel] = 60 * (((G[sel] - B[sel]) / dd[sel]) % 6)
sel = nz & (im == 1); hue[sel] = 60 * ((B[sel] - R[sel]) / dd[sel] + 2)
sel = nz & (im == 2); hue[sel] = 60 * ((R[sel] - G[sel]) / dd[sel] + 4)
sat = np.where(mx > 1e-6, dd / np.maximum(mx, 1e-6), 0.0)
# ride the mask + HSV through the weld as attributes (per-vertex custom data survives
# remove_doubles on the surviving vertex of each duplicate cluster)
for name, arr in (("melt_m", crotch), ("bust_m", bust)):
at = me.attributes.new(name=name, type='INT', domain='POINT')
at.data.foreach_set("value", arr.astype(np.int32))
for name, arr in (("hsv_h", hue), ("hsv_s", sat), ("hsv_v", mx)):
at = me.attributes.new(name=name, type='FLOAT', domain='POINT')
at.data.foreach_set("value", arr.astype(np.float32))
# =============================================================================================
# weld, cut the bust open, free the briefs
# =============================================================================================
bm = bmesh.new()
bm.from_mesh(me)
bmesh.ops.remove_doubles(bm, verts=list(bm.verts), dist=1e-5)
bm.verts.ensure_lookup_table()
lm = bm.verts.layers.int["melt_m"]
lb = bm.verts.layers.int["bust_m"]
log(f"welded: {len(bm.verts)}v")
# bust: delete fully-masked faces (stage 05 cut rule), then despike the new rim
kill = [f for f in bm.faces if all(v[lb] for v in f.verts)]
bmesh.ops.delete(bm, geom=kill, context='FACES')
log(f"bust cut: -{len(kill)} faces")
for it in range(4):
spikes = [f for f in bm.faces
if sum(1 for e in f.edges if len(e.link_faces) == 1) >= 2
and (sum(v.co.z for v in f.verts) / len(f.verts) - Z0) / H > A.z_split]
if not spikes:
break
bmesh.ops.delete(bm, geom=spikes, context='FACES')
log(f"bust despike pass {it+1}: -{len(spikes)} dangling faces")
loose = [v for v in bm.verts if not v.link_faces]
if loose:
bmesh.ops.delete(bm, geom=loose, context='VERTS')
# briefs: free = leaf verts + a skin collar, grown over true (welded) adjacency
free_set = {v for v in bm.verts if v[lm]}
for _ in range(A.free_rings):
free_set |= {o for v in free_set for e in v.link_edges for o in e.verts}
log(f"melt set: {len(free_set)} free verts (leaves + {A.free_rings}-ring skin collar)")
# the remnant sweep (see header): re-key the fixed verts near the melt without the mask's
# speckle filter or value gate, so missed leaf fragments melt too instead of pinning welts
lh = bm.verts.layers.float["hsv_h"]
lsat = bm.verts.layers.float["hsv_s"]
lval = bm.verts.layers.float["hsv_v"]
near = set(free_set)
for _ in range(6):
near |= {o for v in near for e in v.link_edges for o in e.verts}
adds = {v for v in near - free_set
if (v.co.z - Z0) / H <= A.z_split + 0.01
and ((46.0 <= v[lh] <= 200.0) or (v[lval] >= 0.78 and v[lsat] <= 0.25))}
grown = set(adds)
for _ in range(2):
grown |= {o for v in grown for e in v.link_edges for o in e.verts}
free_set |= grown
log(f"remnant sweep: +{len(adds)} keyed (+{len(grown - adds)} ring growth) "
f"-> {len(free_set)} free verts")
# mark free verts and melted-face texels via flags that survive to_mesh
for v in bm.verts:
v.select_set(False)
for v in free_set:
v.select_set(True)
texel = {v for v in bm.verts if v[lm]} | grown # leaf faces + swept remnants, not the collar
for f in bm.faces:
f.select_set(all(v in texel for v in f.verts))
bm.to_mesh(me)
bm.free()
me.update()
n = len(me.vertices)
log(f"topology done: {n}v {len(me.polygons)}f")
# =============================================================================================
# the melt: matrix-free PCG on L²x = 0, everything but the briefs held fixed
# =============================================================================================
co = np.empty(n * 3); me.vertices.foreach_get("co", co); P = co.reshape(-1, 3).copy()
P_orig = P.copy() # for the fixed-verts gate at the end
vsel = np.empty(n, dtype=bool); me.vertices.foreach_get("select", vsel)
free = vsel.copy()
log(f"free verts: {free.sum()}")
ev = np.empty(len(me.edges) * 2, dtype=np.int32); me.edges.foreach_get("vertices", ev)
ea, eb = ev[0::2].astype(np.int64), ev[1::2].astype(np.int64)
zfw = (P[:, 2] - Z0) / H # fixed verts never move, so this stays valid
def grow_np(mask, rings):
out = mask.copy()
for _ in range(rings):
hit = np.zeros(n, dtype=bool)
m = out[ea] | out[eb]
hit[ea[m]] = True
hit[eb[m]] = True
out |= hit
return out
# full-mesh adjacency in CSR form, built once — the solver restricts it per pass
fsrc = np.concatenate([ea, eb]); fdst = np.concatenate([eb, ea])
fo = np.argsort(fsrc, kind='stable'); fsrc, fdst = fsrc[fo], fdst[fo]
fdeg = np.bincount(fsrc, minlength=n).astype(np.float64)
fptr = np.concatenate([[0], np.cumsum(fdeg)]).astype(np.int64)
def solve_membrane(free_mask, tag, warm_harmonic):
"""Bi-harmonic solve for the free verts; reads and writes me's positions in place.
Returns (ridx, Fl, deg, ptr, dst) so the puff step can reuse the last pass's graph."""
co_ = np.empty(n * 3); me.vertices.foreach_get("co", co_); Pv = co_.reshape(-1, 3).copy()
region = grow_np(free_mask, 3) # ring1 enters L, ring2 enters L², ring3 margin
ridx = np.nonzero(region)[0]
loc = np.full(n, -1, dtype=np.int64)
loc[ridx] = np.arange(len(ridx))
m = region[ea] & region[eb]
ra, rb = loc[ea[m]], loc[eb[m]]
src = np.concatenate([ra, rb]); dst = np.concatenate([rb, ra])
o = np.argsort(src, kind='stable'); src, dst = src[o], dst[o]
deg = np.bincount(src, minlength=len(ridx)).astype(np.float64)
ptr = np.concatenate([[0], np.cumsum(deg)]).astype(np.int64)
Fl = free_mask[ridx]
log(f"solve[{tag}]: {len(ridx)} region verts ({Fl.sum()} free), {len(src)//2} edges")
def Lap(Xv):
s = np.add.reduceat(Xv[dst], ptr[:-1], axis=0)
s[deg == 0] = 0.0
return s - deg[:, None] * Xv
X = Pv[ridx].copy()
def T_free(XF):
Y = np.zeros_like(X)
Y[Fl] = XF
return Lap(Lap(Y))[Fl]
if warm_harmonic:
# Lx = 0 converges in ~diameter sweeps and lands within a crease of the bi-harmonic
# answer, cutting the expensive solve's iterations roughly in half. Only worth it when
# starting from the raw leaf shell — a re-solve already sits near the answer.
Y = X.copy()
for it in range(3000):
d = Lap(Y)
Y[Fl] += 0.9 / np.maximum(deg[Fl], 1.0)[:, None] * d[Fl]
if it % 500 == 499 and float(np.abs(d[Fl]).max()) * MM < 1e-4:
break
X[Fl] = Y[Fl]
log(f" harmonic warm start: {it+1} sweeps")
r = -Lap(Lap(X))[Fl]
Mjac = (deg[Fl] ** 2 + deg[Fl])[:, None] # diag(L²) = deg² + deg
zv = r / Mjac
p = zv.copy()
rz = float((r * zv).sum())
b0 = float(np.linalg.norm(-Lap(Lap(np.where(Fl[:, None], 0.0, X)))[Fl])) + 1e-30
xF = X[Fl].copy()
rn = float(np.linalg.norm(r))
for it in range(20000):
Ap = T_free(p)
alpha = rz / (float((p * Ap).sum()) + 1e-300)
xF += alpha * p
r -= alpha * Ap
rn = float(np.linalg.norm(r))
if rn / b0 < 3e-7:
break
zv = r / Mjac
rz2 = float((r * zv).sum())
p = zv + (rz2 / rz) * p
rz = rz2
if it % 2000 == 1999:
log(f" PCG iter {it+1}: residual {rn/b0:.2e}")
X[Fl] = xF
log(f"solve[{tag}]: {it+1} iters (residual {rn/b0:.2e}), "
f"max move {np.linalg.norm(X[Fl]-Pv[ridx][Fl],axis=1).max()*MM:.1f} mm")
Pv[ridx] = X
me.vertices.foreach_set("co", Pv.ravel())
me.update()
return ridx, Fl, deg, ptr, dst
ridx, Fl, deg, ptr, dst = solve_membrane(free, "melt", warm_harmonic=True)
# THE SOLID-WELT PASS. Some leaf roots are not shells at all — they are SOLID ridges sculpted
# into the body surface and painted in skin tones, which is why every shell-hunting detector
# (density, occlusion, fin normals) returned almost nothing while four caps sat in plain view.
# A solid bump is honest single surface, so the ORIGINAL remedy is the right one: free it and
# let the membrane pull it down — no excess area, no pendant balloon. Detector exactly as the
# debug probe validated it: 400-sweep Taubin reference over the WHOLE band (everything moves,
# so there is no anchored-strip chord and no frozen-zone blindness), proud along the normal
# > 0.8 mm, fixed verts only. At that reference the smooth melt web reads ~0.2 mm (p95) and
# the caps read 1-2.4 mm.
def full_nbmean(Xv):
s = np.add.reduceat(Xv[fdst], fptr[:-1], axis=0)
s[fdeg == 0] = Xv[fdeg == 0]
return s / np.maximum(fdeg, 1.0)[:, None]
mvW = (zfw >= 0.40) & (zfw <= A.z_split + 0.02)
for wpass in range(2): # re-reference and re-detect: the first fix
cow = np.empty(n * 3) # exposes whatever its 3-ring growth missed
me.vertices.foreach_get("co", cow)
Pw = cow.reshape(-1, 3).copy()
Qw = Pw.copy()
for _ in range(400):
Qw[mvW] += 0.50 * (full_nbmean(Qw) - Qw)[mvW]
Qw[mvW] += -0.53 * (full_nbmean(Qw) - Qw)[mvW]
nrw = np.empty(n * 3); me.vertices.foreach_get("normal", nrw)
proudW = np.einsum('ij,ij->i', Pw - Qw, nrw.reshape(-1, 3)) * MM
# 0.5 mm, not 0.8: the caps are ~1.8 mm PLATEAUS with sharp edges (the hard shadows in
# clay renders oversell their height), and 0.8 clipped 220 crown verts while the body of
# each plateau survived. Honest skin reads p95 +0.13 mm against this reference — 0.5 mm
# is still 4x above the noise floor.
weltS = ~free & mvW & (proudW > 0.5)
log(f"solid-welt pass {wpass+1}: {weltS.sum()} proud fixed verts "
f"(band fixed p95 {np.percentile(proudW[~free & mvW], 95):+.2f} mm, "
f"max {proudW[~free & mvW].max():+.2f} mm)")
if not weltS.sum():
break
weltG = grow_np(weltS, 3) & ~free
free |= weltG
solve_membrane(weltG, f"weltfix{wpass+1}", warm_harmonic=False)
# their paint is leaf-root shadow, not skin: hand their faces to the donor texel too
fselW = np.empty(len(me.polygons), dtype=bool); me.polygons.foreach_get("select", fselW)
lvW = np.empty(len(me.loops), dtype=np.int32); me.loops.foreach_get("vertex_index", lvW)
lsW = np.empty(len(me.polygons), dtype=np.int32); me.polygons.foreach_get("loop_start", lsW)
ltW = np.empty(len(me.polygons), dtype=np.int32); me.polygons.foreach_get("loop_total", ltW)
allin = np.add.reduceat(weltG[lvW].astype(np.int32), lsW.astype(np.int64)) == ltW
me.polygons.foreach_set("select", fselW | allin)
log(f"solid-welt pass {wpass+1}: freed {weltG.sum()} verts, "
f"{int(allin.sum())} faces to donor texel")
# THE BALLOON DEFLATION (see header). Wide reference: 400 Taubin sweeps over the melt zone —
# diffusion radius ~sqrt(400) = 20 rings (~26 mm here), wide enough that a centimetre nub reads
# as fully proud instead of being absorbed into its own reference.
def full_nbmean(Xv):
s = np.add.reduceat(Xv[fdst], fptr[:-1], axis=0)
s[fdeg == 0] = Xv[fdeg == 0]
return s / np.maximum(fdeg, 1.0)[:, None]
co2 = np.empty(n * 3); me.vertices.foreach_get("co", co2); Pm = co2.reshape(-1, 3).copy()
# the reference zone must extend PAST anything the detectors are asked to judge: outside `mv`
# the smoothed copy equals the input and proudness is identically zero by construction — a
# 4-ring halo silently blinded the welt detector to caps sitting 5+ rings out
mv = grow_np(free, 30) & (zfw <= A.z_split + 0.03)
# Detection is by DENSITY, not proudness: a smoothing-built reference partially follows any
# bump wider than its radius (a 400-sweep probe read the visibly 4 mm nubs at 1.7 mm), but a
# wad cannot hide its area — multiple layers over one spot of surface put several times the
# verts of honest membrane into the same cell of a 3D grid.
CELL = 4.0 / MM
key3 = np.floor(Pm[free] / CELL).astype(np.int64)
_, cinv, ccnt = np.unique(key3, axis=0, return_inverse=True, return_counts=True)
per_vert_cnt = ccnt[cinv]
med_cnt = float(np.median(per_vert_cnt))
hot_thr = max(3.0 * med_cnt, 18.0)
balloon = np.zeros(n, dtype=bool)
balloon[np.nonzero(free)[0][per_vert_cnt > hot_thr]] = True
balloon = grow_np(balloon, 1)
log(f"balloon pass: cell median {med_cnt:.0f} verts, threshold {hot_thr:.0f} -> "
f"{balloon.sum()} wad verts to excise")
# THE FOLD DETECTOR — for the rim-attached flaps density cannot see (~2 layers, BELOW the
# piled median) and the flow finish cannot reach (they live in the rim-damped zone). A folded
# flap betrays itself by its NORMALS: its flanks and underside disagree with the smoothed
# reference field by 80-180 degrees, which honest skin never does — even the walls of a deep
# concave crease stay within ~70 degrees of a 150-sweep reference. Restricted to FREE verts,
# so fixed anatomy can never be excised no matter how it folds.
ltF = np.empty(len(me.polygons), dtype=np.int32); me.polygons.foreach_get("loop_total", ltF)
if (ltF == 3).all():
lvF = np.empty(len(me.loops), dtype=np.int32); me.loops.foreach_get("vertex_index", lvF)
lsF = np.empty(len(me.polygons), dtype=np.int32); me.polygons.foreach_get("loop_start", lsF)
def vnormals(Pts):
va, vb, vc = lvF[lsF], lvF[lsF + 1], lvF[lsF + 2]
fn = np.cross(Pts[vb] - Pts[va], Pts[vc] - Pts[va])
acc = np.zeros_like(Pts)
for idx in (va, vb, vc):
np.add.at(acc, idx, fn)
return acc / np.maximum(np.linalg.norm(acc, axis=1, keepdims=True), 1e-12)
Qf = Pm.copy()
for _ in range(400):
Qf[mv] += 0.50 * (full_nbmean(Qf) - Qf)[mv]
Qf[mv] += -0.53 * (full_nbmean(Qf) - Qf)[mv]
ncur = vnormals(Pm)
dotn = np.einsum('ij,ij->i', ncur, vnormals(Qf))
fold = free & (dotn < 0.15)
log(f"fold pass: {fold.sum()} inverted-normal verts "
f"(free dot p05 {np.percentile(dotn[free], 5):+.2f})")
balloon |= grow_np(fold, 1)
# THE FIXED-WELT PASS — by RAY-CAST OCCLUSION, the playbook's own move, after every
# reference-surface detector failed for a structural reason worth recording:
# * proudness vs a Taubin reference: Taubin is shape-PRESERVING — a 1.5 cm cap sits
# inside its passband, so the reference reproduces the cap and P-Q reads ~0 forever;
# * proudness at all: a FIN's wall normals are perpendicular to its height, the dot
# is ~0 no matter how far it sticks out;
# * diffusion references: run wide they chord across convex anatomy (+1.9 mm on honest
# hips), run narrow they cannot see the cap tops standing 15+ rings out.
# A flap needs no reference: it stands OVER surface, so a short ray cast INWARD from it
# hits geometry within millimetres — its own opposite wall (fins are 1-2 mm thick) or the
# web below — while honest skin's inward ray travels centimetres of flesh before exiting.
# The gluteal crease is safe by construction: its walls' inward rays point into the flesh,
# AWAY from each other; only a +n ray could cross the crease gap, and none is cast.
import mathutils
deps = bpy.context.evaluated_depsgraph_get()
bvh = mathutils.bvhtree.BVHTree.FromObject(body, deps)
nearF = grow_np(free, 20) & ~free & (zfw >= 0.40) & (zfw <= A.z_split + 0.01)
EPS, DMAX = 0.4 / MM, 4.5 / MM
widx = []
for vi in np.nonzero(nearF)[0]:
p, nv = Pm[vi], ncur[vi]
o = mathutils.Vector((p[0] - nv[0] * EPS, p[1] - nv[1] * EPS, p[2] - nv[2] * EPS))
if bvh.ray_cast(o, mathutils.Vector((-nv[0], -nv[1], -nv[2])), DMAX)[0] is not None:
widx.append(int(vi))
welt = np.zeros(n, dtype=bool)
welt[widx] = True
log(f"fixed-welt pass: {welt.sum()} occluded (flap) verts of {nearF.sum()} candidates")
balloon |= grow_np(welt, 1)
else:
log("fold pass: SKIPPED (non-triangle faces present)")
if balloon.sum():
# excise every face touching a wad vert (kills whole balloons, leaves no orphan shells),
# tidy the scar, and refill: at this scale — round holes a centimetre or two across —
# triangle_fill is exactly the right tool; its failure mode was the giant winding channel
bm = bmesh.new(); bm.from_mesh(me)
bm.verts.ensure_lookup_table()
bidx = set(np.nonzero(balloon)[0].tolist())
kill = [f for f in bm.faces if any(v.index in bidx for v in f.verts)]
bmesh.ops.delete(bm, geom=kill, context='FACES')
for it in range(4):
spikes = [f for f in bm.faces
if sum(1 for e in f.edges if len(e.link_faces) == 1) >= 2
and (sum(v.co.z for v in f.verts) / len(f.verts) - Z0) / H <= A.z_split]
if not spikes:
break
bmesh.ops.delete(bm, geom=spikes, context='FACES')
loose = [v for v in bm.verts if not v.link_faces]
if loose:
bmesh.ops.delete(bm, geom=loose, context='VERTS')
log(f"excision: -{len(kill)} wad faces (+{len(loose)} loose verts)")
# zip the pre-existing Tripo slits where they meet the scars (nude lane, close_rim_slits):
# a scar boundary that runs into a slit is a RIBBON, not a closed loop, and both fill
# operators refuse it — these were the 10 holes that survived three sweeps untouched
sl = [v for v in bm.verts
if Z0 + 0.40 * H <= v.co.z <= Z0 + (A.z_split + 0.01) * H
and any(len(e.link_faces) == 1 for e in v.link_edges)]
v0 = len(bm.verts)
bmesh.ops.remove_doubles(bm, verts=sl, dist=0.8 / MM)
bm.verts.ensure_lookup_table()
log(f"slit weld: {len(sl)} band boundary verts, {v0 - len(bm.verts)} merged at 0.8 mm")
# the weld leaves zero-area faces and zero-length edges; feeding those to triangle_fill
# took Blender down with an access violation, not an exception — clean them first
dg = [e for e in bm.edges
if Z0 + 0.38 * H <= (e.verts[0].co.z + e.verts[1].co.z) / 2 <= Z0 + 0.64 * H]
bmesh.ops.dissolve_degenerate(bm, dist=1e-5, edges=dg)
bm.verts.ensure_lookup_table()
log(f"degenerate dissolve: {len(bm.verts)}v {len(bm.faces)}f")
pre2 = {(round(v.co.x, 6), round(v.co.y, 6), round(v.co.z, 6)) for v in bm.verts}
patch_faces = set()
for sweep in range(3): # re-detect after filling: one triangle_fill
bedges2 = [e for e in bm.edges # failing silently must not leave a pinhole
if len(e.link_faces) == 1
and Z0 + 0.42 * H <= (e.verts[0].co.z + e.verts[1].co.z) / 2
<= Z0 + A.z_split * H]
v2b2 = {}
for e in bedges2:
for v in e.verts:
v2b2.setdefault(v, []).append(e)
seen2, scars = set(), []
for e0 in bedges2:
if e0 in seen2:
continue
comp, q = [], deque([e0])
seen2.add(e0)
while q:
e = q.popleft()
comp.append(e)
for v in e.verts:
for e2 in v2b2[v]:
if e2 not in seen2:
seen2.add(e2)
q.append(e2)
if len(comp) >= 3:
scars.append(comp)
if not scars:
break
log(f"scar sweep {sweep+1}: {len(scars)} holes "
f"(sizes {sorted(len(c) for c in scars)[::-1][:10]})")
sweep_faces = set() # densify THIS sweep's fills only — letting a
for comp in scars: # later sweep's sliver scars re-densify earlier
if len(comp) > 900: # patches to their microscopic target once
log(f" REFUSING {len(comp)}-edge boundary tangle (fill would crash/garble)")
continue
live = [e for e in comp if e.is_valid]
try: # (3.3M-face / 17 h lesson)
ret = bmesh.ops.triangle_fill(bm, use_beauty=True, use_dissolve=False,
edges=live)
newf = [g for g in ret["geom"] if isinstance(g, bmesh.types.BMFace)]
except Exception:
newf = []
if not newf:
try:
ret = bmesh.ops.holes_fill(bm, edges=live, sides=0)
newf = list(ret["faces"])
except Exception:
newf = []
sweep_faces.update(newf)
tgt = max(1.5 * float(np.median([e.calc_length() for c in scars for e in c])),
2.4 / MM) # floored at 2.4 mm: sliver rims must not set it
for it in range(5):
sweep_faces = {f for f in sweep_faces if f.is_valid}
if len(sweep_faces) > 120000:
log(f" densify CAPPED at {len(sweep_faces)} faces")
break
longe = [e for e in interior_edges(sweep_faces) if e.calc_length() > 1.45 * tgt]
if not longe:
break
r1 = bmesh.ops.subdivide_edges(bm, edges=longe, cuts=1, use_grid_fill=True)
sweep_faces = refresh(sweep_faces, r1)
r2 = bmesh.ops.triangulate(bm, faces=list(sweep_faces))
sweep_faces = refresh(set(), r2)
r3 = bmesh.ops.beautify_fill(bm, faces=list(sweep_faces),
edges=interior_edges(sweep_faces))
sweep_faces = refresh(sweep_faces, r3)
patch_faces = {f for f in patch_faces if f.is_valid} | sweep_faces
log(f"scar fill: {len(patch_faces)} patch faces")
for f in patch_faces: # patches join the donor-texel set
if f.is_valid:
f.select_set(True)
bm.to_mesh(me)
bm.free()
me.update()
# topology changed: rebuild the globals the solver reads, then relax the patches
n = len(me.vertices)
ev = np.empty(len(me.edges) * 2, dtype=np.int32); me.edges.foreach_get("vertices", ev)
ea, eb = ev[0::2].astype(np.int64), ev[1::2].astype(np.int64)
co2 = np.empty(n * 3); me.vertices.foreach_get("co", co2)
Pn = co2.reshape(-1, 3)
patch_free = np.array([tuple(k) not in pre2 for k in np.round(Pn, 6)], dtype=bool)
log(f"patch verts: {patch_free.sum()}")
if patch_free.sum():
solve_membrane(patch_free, "patch", warm_harmonic=False)
co3 = np.empty(n * 3); me.vertices.foreach_get("co", co3); Pout = co3.reshape(-1, 3).copy()
# every vertex is now either at a pristine post-cut position or it is part of the melt/patch;
# `changed` is the union of moved and newly created — the gate + donor selection key off it
orig_keys = {tuple(k) for k in np.round(P_orig, 6)}
changed = np.array([tuple(k) not in orig_keys for k in np.round(Pout, 6)], dtype=bool)
free = changed
# THE FLOW FINISH. Whatever pendant caps survived every detector above die here, and nothing
# has to find them first: damped pure-Laplacian flow over the ENTIRE changed region, fixed skin
# held. Laplacian flow obeys the maximum principle — no point can move outside the hull of its
# neighbours — so a raised cap has strictly nowhere to go but down, while the broad pubic web
# barely moves (flow erases features at a rate ~1/size², and the web is 5-10x wider than any
# cap). The weight ramps from 0 at the fixed rim to 1 by ring 8, so the bi-harmonic C1 blend
# earned by the membrane is untouched where it matters.
fsrc = np.concatenate([ea, eb]); fdst = np.concatenate([eb, ea])
fo = np.argsort(fsrc, kind='stable'); fsrc, fdst = fsrc[fo], fdst[fo]
fdeg = np.bincount(fsrc, minlength=n).astype(np.float64)
fptr = np.concatenate([[0], np.cumsum(fdeg)]).astype(np.int64)
def nbmean2(Xv):
s = np.add.reduceat(Xv[fdst], fptr[:-1], axis=0)
s[fdeg == 0] = Xv[fdeg == 0]
return s / np.maximum(fdeg, 1.0)[:, None]
depth = np.zeros(n)
reach = ~free
d = 0
while not reach.all() and d < 200:
d += 1
nxt = reach.copy()
hit = np.zeros(n, dtype=bool)
m2 = reach[fsrc]
hit[fdst[m2]] = True
nxt |= hit
ring = nxt & ~reach
if not ring.any():
break
depth[ring] = d
reach = nxt
# ramp over 3 rings, NOT 8. A pixel-ray probe finally identified the last "caps" as the melt
# web itself bridging taut over the inguinal hollow — and the hip-vine channel is only
# ~10-20 rings wide, so an 8-ring ramp kept essentially the whole strip in the damped zone
# and the flow never engaged exactly where the bridge needed pulling down. Three rings still
# protects the immediate C1 blend; everything past it flows.
w = smoothstep(depth / 3.0)[:, None]
log(f"flow finish: max depth {int(depth.max())} rings")
for _ in range(300):
Pout[free] += (0.55 * w[free]) * (nbmean2(Pout) - Pout)[free]
for _ in range(8): # Taubin polish: undo the slight flow shrink
Pout[free] += (0.55 * w[free]) * (nbmean2(Pout) - Pout)[free]
Pout[free] += (-0.58 * w[free]) * (nbmean2(Pout) - Pout)[free]
moved_fin = np.linalg.norm(Pout[free] - co3.reshape(-1, 3)[free], axis=1)
log(f"flow finish: moved p50 {np.percentile(moved_fin,50)*MM:.2f} mm, "
f"max {moved_fin.max()*MM:.1f} mm")
me.vertices.foreach_set("co", Pout.ravel())
me.update()
# optional Barbie dome: outward along the membrane normal, smoothstep of rim distance,
# zero value AND zero slope at the rim so the C1 blend survives
if A.puff_mm > 0:
dist = np.zeros(len(ridx))
unv = set(np.nonzero(Fl)[0].tolist())
cur = set(np.nonzero(~Fl)[0].tolist())
d = 0
while unv and cur:
d += 1
nxt = set()
for c in cur:
for j in range(int(ptr[c]), int(ptr[c + 1])):
nb = int(dst[j])
if nb in unv:
unv.discard(nb)
dist[nb] = d
nxt.add(nb)
cur = nxt
t = dist / max(dist.max(), 1.0)
nrm = np.empty(n * 3); me.vertices.foreach_get("normal", nrm); nrm = nrm.reshape(-1, 3)
Pout[ridx] += nrm[ridx] * (smoothstep(t) * (A.puff_mm / MM))[:, None] * Fl[:, None]
me.vertices.foreach_set("co", Pout.ravel())
me.update()
log(f"puff: +{A.puff_mm} mm dome over {int(dist.max())} rings")
# =============================================================================================
# texture the melt: one clean donor texel on the leaf-texel faces
# =============================================================================================
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:
for out_ in nd.outputs:
for lnk in out_.links:
if lnk.to_socket.name == 'Base Color':
imgs['base'] = nd.image
if 'normal' in nd.image.name.lower():
imgs['normal'] = nd.image
def sample(img, uvs):
w, h = img.size
buf = np.empty(w * h * 4, dtype=np.float32)
img.pixels.foreach_get(buf)
px = buf.reshape(h, w, 4)[:, :, :3]
xi = np.clip((uvs[:, 0] * (w - 1)).astype(np.int64), 0, w - 1)
yi = np.clip((uvs[:, 1] * (h - 1)).astype(np.int64), 0, h - 1)
out = px[yi, xi].copy()
del buf, px
return out
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]
zf = (Pout[:, 2] - Z0) / H
cand = np.nonzero(~free & (zf > 0.30) & (zf < 0.42))[0][::37] # thigh band, thinned
cb = sample(imgs['base'], vuv[cand])
med = np.median(cb, axis=0)
score = np.linalg.norm(cb - med, axis=1)
if 'normal' in imgs:
cn = sample(imgs['normal'], vuv[cand])
score += 2.0 * np.linalg.norm(cn - np.array([0.5, 0.5, 1.0]), axis=1)
best = int(np.argmin(score))
donor = cand[best]
log(f"donor texel: vert {donor} zf={zf[donor]:.3f} albedo={np.round(cb[best],3)} "
f"(band median {np.round(med,3)})")
fsel = np.empty(len(me.polygons), dtype=bool); me.polygons.foreach_get("select", fsel)
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)
duv = vuv[donor]
touched = 0
for fi in np.nonzero(fsel)[0]:
for li in range(ls[fi], ls[fi] + lt[fi]):
uvb[li] = duv
touched += 1
me.uv_layers.active.data.foreach_set("uv", uvb.ravel())
log(f"UVs: {touched} loops on {int(fsel.sum())} melted faces -> donor texel")
# =============================================================================================
# normals, gates, export
# =============================================================================================
if me.has_custom_normals:
bpy.ops.mesh.customdata_custom_splitnormals_clear()
me.polygons.foreach_set("use_smooth", np.ones(len(me.polygons), dtype=bool))
me.update()
# the excision/refill renumbers vertices, so "fixed didn't move" is asserted by position:
# every vertex is either bit-identical to a pristine post-cut position, or it is changed —
# and everything changed must live inside the crotch band
bm = bmesh.new(); bm.from_mesh(me)
band_open = sum(1 for e in bm.edges if len(e.link_faces) == 1
and (0.5 * (e.verts[0].co.z + e.verts[1].co.z) - Z0) / H <= A.z_split)
bm.free()
mz = zf[changed]
# +0.04, not +0.01: the solid-welt pass detects up to z_split+0.02 and grows 3 rings, so its
# legitimate reach is a little above the split — the gate must allow what the recipe declares
in_band_ok = bool((mz.min() >= 0.42) and (mz.max() <= A.z_split + 0.04))
log(f"gate changed geometry confined to band: z {mz.min():.3f}..{mz.max():.3f} "
f"({'PASS' if in_band_ok else 'FAIL'})")
log(f"gate crotch band boundary edges: {band_open} (pre-existing Tripo slits only)")
if not in_band_ok:
raise SystemExit("[melt] FATAL: geometry changed outside the crotch band")
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,501 @@
"""08_finger_weights.py — re-solve finger skin weights on a quatskin candidate body.
Why: the AccuRig hand weights survive the quatskin conversion (LENA_RIGID_FINGERS=0)
but were grafted nearest-surface from a 20:1 decimated carrier, so adjacent fingers
bleed into each other. Invisible at rest and in the FLAT pose; a full curl (fist/grip)
tears the fingers into ribbons (QA renders in v02/review/, 2026-08-17).
Method: cross-finger bleed is impossible by construction here
1. label every hand-region vert to ONE finger (or palm) by multi-source Dijkstra
over the mesh's own edges (welded across the glTF importer's UV-seam splits),
seeded by proximity to each finger's bone axis with a margin test;
exp05: label propagation is spatially GATED (a digit's label can never reach a
vert CROSS_GATE closer to another digit's axis) and edges crossing the
inter-digit equidistance valley are cost-penalized, so the digit boundary
settles in the fused inter-finger valley instead of wandering onto a
neighbor's flank (exp04's middle_02<->ring_02 / pinky<->ring fin stacks);
2. rebuild finger weights procedurally along the labeled finger's bone chain:
arc-length projection, linear blend zones at each joint, base blends into hand;
exp05: the arc-length param s is clamped by GEODESIC distance from the digit's
own base frontier euclidean projection could snap a base-region vert to a
distal segment, yielding hand + phalanx-2 weight with zero phalanx-1 (exp04's
hand<->thumb_02 / hand<->index_02 fins); then weights are smoothed over the
mesh graph restricted to same-digit + palm neighbors (NEVER across the
inter-finger gap), and a chain-continuity repair guarantees graded
hand->_01->_02->_03 falloff;
3. palm-labeled verts lose their finger weights into the hand bone.
Everything outside the finger-weighted region (+1.2 cm collar) is untouched, and no
vertex position changes anywhere this is a weights-only edit.
usage: blender --background --factory-startup --python 08_finger_weights.py -- in.glb out.glb
"""
import bpy, sys, math, heapq, struct
from mathutils import Vector, kdtree
argv = sys.argv[sys.argv.index("--") + 1:]
SRC, OUT = argv[0], argv[1]
FING = ("thumb", "index", "middle", "ring", "pinky")
SEED_AXIS_R = 0.007 # finger seed: within 7 mm of its bone axis...
SEED_AXIS_R_MAX = 0.016 # ...grown per finger until it has enough seeds (the thumb is
# a fat digit — after the hand fit's 1.56x right-thumb stretch
# its whole surface sits >7 mm off-axis and 7 mm finds ~20 verts)
SEED_MARGIN = 0.002 # ...and 2 mm closer to it than to any other finger
PALM_AXIS_D = 0.016 # palm seed: >16 mm from every finger axis (12 mm let the
# fat right thumb's pad seed as palm -> hand<->thumb_02 fins)
COLLAR_R = 0.012 # spatial collar added around the finger-weighted region
JOINT_BLEND = 0.006 # half-width of the linear blend zone at each joint (m)
CROSS_GATE = 0.0025 # a digit's label may never reach a vert this much closer
# to another digit's axis (spatial nearest-bone gate)
VALLEY_PENALTY = 4.0 # Dijkstra cost multiplier for edges crossing the
# inter-digit equidistance valley (mild bias only: a heavy
# toll starved the fused valley floor of digit labels and
# palm claimed it -> hand=1 fin stacks between fingers)
VALLEY_SURCHARGE = 0.001 # flat cost per crossing edge
PALM_NEAR_D = 0.010 # palm label pays to enter the near-axis zone (<10 mm)...
PALM_CLIMB_PENALTY = 8.0 # ...this multiplier (digit surfaces belong to digits)
CAPTURE_D = 0.009 # palm/unreached verts closer than this to a digit axis are
# force-relabeled to the spatially nearest digit
CAPTURE_REGION_D = 0.015 # ...and originally finger-weighted ones out to this radius
# (fused valley floors and beyond-tip caps sit 10-13 mm off
# axis; folding them to hand leaves them behind in a fist)
BASE_RAMP = 0.008 # geodesic ramp length: digit weight fraction is 0 at the
# palm frontier and 1 this far (geodesic) into the digit
S_SLACK = 0.004 # geodesic clamp slack on the arc-length param (m)
SMOOTH_ITERS = 6 # weight-smoothing iterations (same-digit + palm only)
SMOOTH_ALPHA = 0.5 # neighbor-average blend factor per iteration
WEB_BLEND_R0 = 0.35 # exp06: cross-digit web blending. exp01-exp05 partitioned the
# hand HARD (one digit per vert, "cross-finger bleed impossible
# by construction"), which guarantees the fused inter-digit
# bridges tear by the FULL finger separation: web verts on the
# middle side move rigidly with middle, the ring side with ring,
# and the one edge ring between them absorbs the whole gap
# (measured on exp05 fist: ~811 middle<->ring / pinky<->ring
# edges >5x, up to 45x). The cure is not "no bleed" but GRADED
# bleed: a vert's blend fraction toward its nearest other digit
# ramps from 0 at r=WEB_BLEND_R0 to 0.5 at the equidistance
# valley (r = d_own / (d_own + d_other), so r=0.5 IS the valley).
# Both sides of the boundary reach exactly 0.5 there, so the
# weight field is CONTINUOUS across it and the separation is
# spread over the web's whole edge span instead of one ring.
# Set to 0.5 to disable (= exp05 behaviour).
WEB_BLEND_SKIP = ("thumb",) # exp07: digits excluded from cross-digit blending. The four
# fingers are near-parallel, so mixing a valley vert between two
# of them is well posed. The thumb is not: its transform is
# opposition, not curl, and its arc-length frame does not
# correspond to a finger's, so projecting an index-side or palm
# vert into the thumb chain hands it weight from a bone that
# moves somewhere else entirely. exp06 (thumb included) cut fist
# /grip needles by 54-67% but REGRESSED the shipped flat pose on
# the right hand from 0 to 9 visible needles, and fin_bones put
# all 44 of its torn edges on hand_r<->thumb_0x. Fingers only.
bpy.ops.wm.read_factory_settings(use_empty=True)
bpy.ops.import_scene.gltf(filepath=SRC)
arm = next(o for o in bpy.data.objects if o.type == "ARMATURE")
body = max((o for o in bpy.data.objects if o.type == "MESH"),
key=lambda o: len(o.data.vertices))
bpy.context.view_layer.update()
MW = body.matrix_world
AW = arm.matrix_world
nv = len(body.data.vertices)
print(f"[fw] body {body.name}: {nv} verts, {len(body.vertex_groups)} groups")
pos = [MW @ v.co for v in body.data.vertices]
def bone_head(name):
return AW @ arm.data.bones[name].head_local if name in arm.data.bones else None
def seg_dist(p, a, b):
ab = b - a
t = max(0.0, min(1.0, (p - a).dot(ab) / max(ab.length_squared, 1e-12)))
return (p - (a + ab * t)).length
gname = {g.index: g.name for g in body.vertex_groups}
gidx = {g.name: g.index for g in body.vertex_groups}
changed_total = 0
for S in ("l", "r"):
fgroups = {f"{F}_0{i}_{S}" for F in FING for i in (1, 2, 3)} & set(gidx)
fg_idx = {gidx[n] for n in fgroups}
hand_i = gidx[f"hand_{S}"]
# bone chains: [head01, head02, head03, tip]
chains = {}
for F in FING:
pts = [bone_head(f"{F}_0{i}_{S}") for i in (1, 2, 3)]
if any(p is None for p in pts):
raise RuntimeError(f"missing chain bones for {F}_{S}")
tip = bone_head(f"{F}_04_leaf_{S}")
if tip is None:
tip = pts[2] + (pts[2] - pts[1])
chains[F] = pts + [tip]
# region: verts carrying any finger weight on this side
region = set()
for v in body.data.vertices:
for gr in v.groups:
if gr.group in fg_idx and gr.weight > 1e-6:
region.add(v.index); break
print(f"[fw] side {S}: {len(region)} finger-weighted verts")
# + spatial collar (label graph needs the surrounding palm to compete)
kd = kdtree.KDTree(len(region))
for vi in region: kd.insert(pos[vi], vi)
kd.balance()
region2 = set(region)
for v in body.data.vertices:
if v.index in region2: continue
hit = kd.find(pos[v.index])
if hit[0] is not None and hit[2] <= COLLAR_R:
region2.add(v.index)
print(f"[fw] side {S}: region with collar = {len(region2)}")
# adjacency: real mesh edges inside region2 + zero-cost weld edges across UV-seam dupes
adj = {vi: [] for vi in region2}
for e in body.data.edges:
a, b = e.vertices
if a in region2 and b in region2:
d = (pos[a] - pos[b]).length
adj[a].append((b, d)); adj[b].append((a, d))
kd2 = kdtree.KDTree(len(region2))
for vi in region2: kd2.insert(pos[vi], vi)
kd2.balance()
welds = 0
for vi in region2:
for (_, oi, d) in kd2.find_range(pos[vi], 1e-6):
if oi != vi:
adj[vi].append((oi, 0.0)); welds += 1
print(f"[fw] side {S}: {sum(len(a) for a in adj.values())//2} edges ({welds//2} weld pairs)")
# seeds
def axis_dists(p):
out = {}
for F, pts in chains.items():
out[F] = min(seg_dist(p, pts[k], pts[k+1]) for k in range(3))
return out
INF = float("inf")
dist = {vi: INF for vi in region2}
label = {}
pq = []
ds_all = {vi: axis_dists(pos[vi]) for vi in region2}
ds_min = {vi: min(ds_all[vi].values()) for vi in region2}
nearest_digit = {vi: min(ds_all[vi], key=ds_all[vi].get) for vi in region2}
seeds = {}
radii = {}
for F in FING:
r = SEED_AXIS_R
while True:
picked = [vi for vi in region2
if ds_all[vi][F] < r
and min((d for G, d in ds_all[vi].items() if G != F),
default=INF) - ds_all[vi][F] > SEED_MARGIN]
if len(picked) >= 100 or r >= SEED_AXIS_R_MAX:
break
r += 0.001
seeds[F] = len(picked); radii[F] = r
for vi in picked:
dist[vi] = 0.0; label[vi] = F
heapq.heappush(pq, (0.0, vi, F))
palm_seeds = 0
for vi in region2:
if vi in label: continue
if min(ds_all[vi].values()) > PALM_AXIS_D:
v = body.data.vertices[vi]
tw = sum(gr.weight for gr in v.groups)
hw = sum(gr.weight for gr in v.groups if gr.group == hand_i)
if tw > 0 and hw / tw >= 0.6:
dist[vi] = 0.0; label[vi] = "palm"
heapq.heappush(pq, (0.0, vi, "palm")); palm_seeds += 1
print(f"[fw] side {S}: seeds {seeds} palm={palm_seeds} "
f"(radii {[f'{F}:{radii[F]*1000:.0f}mm' for F in FING]})")
# sanity gate, not a quality bar: the seed loop stops growing the radius at
# SEED_AXIS_R_MAX, so a digit whose whole surface sits off-axis (left pinky on this
# mesh tops out at 88 seeds / 16 mm) can never reach 100 no matter how healthy the
# labeling is — asserting 100 made the two constants mutually unsatisfiable. This
# catches an actually broken seeding (a handful of verts), which is what it is for.
for F, n in seeds.items():
assert n >= 60, f"side {S}: only {n} seeds for {F} — seed radii wrong for this mesh"
assert palm_seeds >= 100, f"side {S}: only {palm_seeds} palm seeds"
while pq:
d, vi, lab = heapq.heappop(pq)
if d > dist[vi] or label.get(vi, lab) != lab: continue
for oi, w in adj[vi]:
if lab != "palm":
# inter-digit exclusivity: a digit's label may never reach a vert
# that sits CROSS_GATE closer to another digit's axis — the Tripo
# mesh fuses adjacent fingers, so topology alone lets a label leak
# across the gap onto the neighbor digit's flank
if ds_all[oi][lab] - ds_min[oi] > CROSS_GATE:
continue
# crossing the equidistance valley between two digits is heavily
# penalized so the label boundary settles IN the fused valley
if nearest_digit[oi] != nearest_digit[vi]:
w = w * VALLEY_PENALTY + VALLEY_SURCHARGE
else:
# symmetric toll: palm expansion pays to climb onto a digit's
# surface (exp05 rev1: palm walked toll-free up the fingers and
# left a weight cliff mid-phalanx -> hand<->hand fin stacks)
if ds_min[oi] < PALM_NEAR_D:
w = w * PALM_CLIMB_PENALTY + 0.002
nd = d + w
if nd < dist[oi]:
dist[oi] = nd; label[oi] = lab
heapq.heappush(pq, (nd, oi, lab))
# capture pass: no vert this close to a digit axis may stay palm/unreached —
# fused finger-to-palm contacts and gate-orphaned islands otherwise fold to
# hand=1 mid-finger and shear off their curling neighbors (exp05 rev1's
# hand<->index_02 / ring_03<->ring_03 fins)
captured = 0
for vi in region2:
if label.get(vi) not in FING and \
(ds_min[vi] < CAPTURE_D or (vi in region and ds_min[vi] < CAPTURE_REGION_D)):
label[vi] = nearest_digit[vi]; captured += 1
print(f"[fw] side {S}: captured {captured} near-axis palm/unreached verts to digits")
counts = {F: 0 for F in FING}; counts["palm"] = 0; counts["unreached"] = 0
for vi in region2:
counts[label.get(vi, "unreached")] = counts.get(label.get(vi, "unreached"), 0) + 1
print(f"[fw] side {S}: labels {counts}")
# residual cross-digit mesh edges (real fused-gap bridges; these are the
# accepted sub-mm baseline, not fixable by weights)
xdig = sum(1 for e in body.data.edges
if label.get(e.vertices[0]) in FING and label.get(e.vertices[1]) in FING
and label.get(e.vertices[0]) != label.get(e.vertices[1]))
print(f"[fw] side {S}: residual cross-digit mesh edges: {xdig}")
# rebuild weights
grp = {n: body.vertex_groups[n] for n in
list(fgroups) + [f"hand_{S}"]}
hand_key = f"hand_{S}"
arcs = {}
for F in FING:
pts = chains[F]; L = [0.0]
for k in range(3):
L.append(L[-1] + (pts[k+1] - pts[k]).length)
arcs[F] = L
def chain_s(F, p):
pts = chains[F]; L = arcs[F]
best_s, best_d = 0.0, INF
for k in range(3):
a, b = pts[k], pts[k+1]
ab = b - a
t = max(0.0, min(1.0, (p - a).dot(ab) / max(ab.length_squared, 1e-12)))
d = (p - (a + ab * t)).length
if d < best_d:
best_d = d; best_s = L[k] + t * (L[k+1] - L[k])
return best_s
def weights_from_s(F, s, ramp=1.0):
L = arcs[F]; bz = JOINT_BLEND
# digit fraction: arc-length blend, capped by the geodesic base ramp so it
# is exactly 0 at the palm frontier (a one-sided taper leaves a cliff)
t_base = min(max(0.0, min(1.0, (s + bz) / (2 * bz))), ramp)
t1 = max(0.0, min(1.0, (s - (L[1] - bz)) / (2 * bz)))
t2 = max(0.0, min(1.0, (s - (L[2] - bz)) / (2 * bz)))
return {hand_key: 1 - t_base,
f"{F}_01_{S}": t_base * (1 - t1),
f"{F}_02_{S}": t_base * t1 * (1 - t2),
f"{F}_03_{S}": t_base * t1 * t2}
# graded hand->_01->_02->_03 continuity: euclidean chain projection can snap
# a base-region vert to a distal segment (hand + phalanx-2 weight with zero
# phalanx-1). Clamp each vert's arc position s by its GEODESIC distance from
# the digit's own base frontier so s grows monotonically along the surface.
s_final = {}
rampv = {}
for F in FING:
dverts = [vi for vi in region2 if label.get(vi) == F]
sp_raw = {vi: chain_s(F, pos[vi]) for vi in dverts}
gd = {vi: INF for vi in dverts} # seeded with s_proj: absolute s clamp
gdb = {vi: INF for vi in dverts} # seeded with 0: base-ramp distance
pq2 = []
for vi in dverts:
# base frontier: touches palm/unlabeled AND projects into phalanx 1
# (mid-digit verts fused to the palm must not seed a false base)
if sp_raw[vi] <= arcs[F][1] and \
any(label.get(oi) not in FING for oi, _ in adj[vi]):
gd[vi] = max(0.0, sp_raw[vi])
gdb[vi] = 0.0
heapq.heappush(pq2, (gd[vi], vi))
while pq2:
d, vi = heapq.heappop(pq2)
if d > gd[vi]: continue
for oi, w in adj[vi]:
if label.get(oi) != F: continue
nd2 = d + w
if nd2 < gd[oi]:
gd[oi] = nd2
heapq.heappush(pq2, (nd2, oi))
pq3 = [(0.0, vi) for vi in dverts if gdb[vi] == 0.0]
heapq.heapify(pq3)
while pq3:
d, vi = heapq.heappop(pq3)
if d > gdb[vi]: continue
for oi, w in adj[vi]:
if label.get(oi) != F: continue
nd2 = d + w
if nd2 < gdb[oi]:
gdb[oi] = nd2
heapq.heappush(pq3, (nd2, oi))
clamped = 0
for vi in dverts:
s = sp_raw[vi]
if gd[vi] < INF and s > gd[vi] + S_SLACK:
s = gd[vi] + S_SLACK; clamped += 1
s_final[vi] = s
rampv[vi] = min(1.0, gdb[vi] / BASE_RAMP) if gdb[vi] < INF else 1.0
print(f"[fw] side {S}: {F} geodesic s-clamp moved {clamped}/{len(dverts)} verts")
fverts = [vi for vi in region2 if label.get(vi) in FING]
wcur = {vi: weights_from_s(label[vi], s_final[vi], rampv[vi]) for vi in fverts}
# topology-aware smoothing for graded falloff: average ONLY with same-digit
# neighbors (NEVER across the inter-finger gap) and with palm/hand neighbors
# (contributing pure hand weight) so digit bases taper into the palm.
for _ in range(SMOOTH_ITERS):
wnew = {}
for vi in fverts:
F = label[vi]
accum = {}; n = 0
for oi, _w in adj[vi]:
lo = label.get(oi)
if lo == F:
vec = wcur[oi]
elif lo in FING:
continue # other digit: hard wall
else:
vec = {hand_key: 1.0}
for k, x in vec.items():
accum[k] = accum.get(k, 0.0) + x
n += 1
if n == 0:
wnew[vi] = wcur[vi]; continue
mix = {}
for k in set(accum) | set(wcur[vi]):
mix[k] = ((1 - SMOOTH_ALPHA) * wcur[vi].get(k, 0.0)
+ SMOOTH_ALPHA * accum.get(k, 0.0) / n)
tot = sum(mix.values())
wnew[vi] = {k: x / tot for k, x in mix.items()}
wcur = wnew
# exp06 cross-digit web blend: make the weight field continuous ACROSS the digit
# boundary instead of walling it off. r = d_own / (d_own + d_nearest_other) is 0 on
# the digit's own axis and 0.5 in the fused equidistance valley; beta ramps 0 -> 0.5
# over [WEB_BLEND_R0, 0.5], so a valley vert is an even mix of the two digits and
# lands on the midpoint of their motion. The mirror vert across the boundary computes
# the same r and the same 50/50 mix, which is what removes the cliff. Applied AFTER
# smoothing (the smoother's hard wall would erode beta at the boundary, exactly where
# it must survive) and evaluated in the neighbour digit's own arc-length frame, capped
# by this vert's base ramp so the palm frontier stays graded.
blended = 0
beta_max = 0.0
for vi in fverts:
F = label[vi]
if F in WEB_BLEND_SKIP:
continue
cands = [g for g in FING if g != F and g not in WEB_BLEND_SKIP]
if not cands:
continue
dF = ds_all[vi][F]
G = min(cands, key=lambda g: ds_all[vi][g])
dG = ds_all[vi][G]
r = dF / max(dF + dG, 1e-9)
if r <= WEB_BLEND_R0:
continue
beta = 0.5 * min(1.0, (r - WEB_BLEND_R0) / max(0.5 - WEB_BLEND_R0, 1e-9))
if beta <= 1e-3:
continue
wG = weights_from_s(G, chain_s(G, pos[vi]), rampv.get(vi, 1.0))
mix = {}
for k in set(wcur[vi]) | set(wG):
mix[k] = (1 - beta) * wcur[vi].get(k, 0.0) + beta * wG.get(k, 0.0)
tot = sum(mix.values())
wcur[vi] = {k: x / tot for k, x in mix.items() if x / tot > 1e-4}
blended += 1
beta_max = max(beta_max, beta)
print(f"[fw] side {S}: web-blended {blended}/{len(fverts)} verts "
f"(max beta {beta_max:.3f}, r0={WEB_BLEND_R0})")
# chain-continuity repair: no vert may carry hand + phalanx>=2 weight while
# phalanx-1 is starved
repaired = 0
for vi in fverts:
F = label[vi]
w = wcur[vi]
wh = w.get(hand_key, 0.0)
k1, k2 = f"{F}_01_{S}", f"{F}_02_{S}"
w1 = w.get(k1, 0.0)
w23 = w.get(k2, 0.0) + w.get(f"{F}_03_{S}", 0.0)
need = 0.5 * min(wh, w23)
if need > 0.01 and w1 < need:
deficit = need - w1
for k, avail in ((hand_key, wh), (k2, w.get(k2, 0.0))):
take = min(deficit / 2, avail)
w[k] = w.get(k, 0.0) - take
w1 += take
w[k1] = w1
tot = sum(w.values())
wcur[vi] = {k: x / tot for k, x in w.items()}
repaired += 1
print(f"[fw] side {S}: chain-continuity repaired {repaired} verts")
changed = 0
for vi in region2:
lab = label.get(vi)
v = body.data.vertices[vi]
if lab in FING:
for g in body.vertex_groups:
g.remove([vi])
for n, x in wcur[vi].items():
if x > 1e-4: grp[n].add([vi], x, "REPLACE")
changed += 1
else: # palm / unreached: strip finger weights into hand
fsum = sum(gr.weight for gr in v.groups if gr.group in fg_idx)
if fsum > 1e-6:
for n in fgroups:
body.vertex_groups[n].remove([vi])
grp[f"hand_{S}"].add([vi], fsum, "ADD")
changed += 1
changed_total += changed
print(f"[fw] side {S}: rewrote weights on {changed} verts")
# weight-sum gate on everything we touched (glTF needs sum==1; exporter normalizes
# top-4 but a bad sum here means the logic is wrong, not a rounding issue)
bad = 0
for v in body.data.vertices:
tw = sum(gr.weight for gr in v.groups)
if abs(tw - 1.0) > 0.01: bad += 1
print(f"[fw] verts with weight sum off by >1%: {bad}")
assert bad == 0, "weight sums broken"
# names must match the canonical body (same reason as the converter)
body.name = "Lena_Female"; body.data.name = "Lena_Female"
for m in body.data.materials:
if m: m.name = "MI_Body_Lena"
arm.name = "Armature.001"
if arm.data: arm.data.name = "Armature.001"
bpy.ops.object.select_all(action="DESELECT")
arm.select_set(True); body.select_set(True)
bpy.ops.export_scene.gltf(filepath=OUT, use_selection=True, export_format="GLB",
export_skins=True, export_animations=False, export_yup=True)
print(f"[fw] EXPORTED {OUT} ({changed_total} verts rewritten)")
# alphaMode BLEND -> OPAQUE patch (same as the converter's post-export step)
with open(OUT, "rb") as f: d = f.read()
jl = struct.unpack_from("<I", d, 12)[0]
js = d[20:20+jl].decode("utf-8")
j2 = js.replace('"alphaMode":"BLEND"', '"alphaMode":"OPAQUE"').replace('"alphaMode": "BLEND"', '"alphaMode": "OPAQUE"')
if j2 != js:
b = j2.encode("utf-8"); b += b" " * ((4 - len(b) % 4) % 4)
o = d[:12] + struct.pack("<I", len(b)) + d[16:20] + b + d[20+jl:]
o = o[:8] + struct.pack("<I", len(o)) + o[12:]
with open(OUT, "wb") as f: f.write(o)
print("[fw] alphaMode patched OPAQUE")
print("[fw] DONE")
+93 -13
View File
@@ -28,6 +28,8 @@ of a 1.777 m body). Read-only, and re-hashed by every stage that opens it.
| `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 |
| `06_open_in_blender.py` | opens a lane mesh in the Blender GUI, framed on her front, Material Preview |
| `07_fill_crotch.py` | **v02**: Barbie-fills the crotch — melts the briefs leaves into a smooth featureless surface, bust holes stay open. See below |
## What the leaves turned out to be
@@ -109,18 +111,96 @@ Stage 05's gates, all reported and the first two fatal:
| surviving shells | 1 |
| removed geometry's extent | z 0.4550.755 of height (bust + briefs bands only) |
## v02 — the Barbie crotch fill (stage 07)
Target: "Barbie doll anatomy" — a completely smooth, undifferentiated pelvic surface, no
cleft, no features, belly flowing into thighs. Jeremy's "take the distance and angle
between the two sides" is precisely a bi-harmonic membrane: the rim supplies POSITION, the
collar behind it supplies SLOPE through the second Laplacian application, and L²x = 0
cannot invent detail. Bust holes stay cut open (v01 behaviour); only the briefs band gets
the treatment.
Getting there burned through roughly ten runs; the recipe header carries the full autopsy,
the short version is:
| attempt | verdict |
|---|---|
| triangle_fill the v01 holes + membrane | the briefs rim is ONE ~2,500-vert loop snaking front → between the legs → back; beauty triangulation bridges the WRONG BANKS at the bends, and the rim itself still carried leaf-root crumple the membrane faithfully anchored to |
| + rim erosion + Delaunay flips + double solve | better rims, same disease — flips are local, the mis-bridging is global; sliver strands shot off the hips |
| **melt instead of fill** (the pivot) | the leaf shell IS the disk that spans the hole — the scan's own manifold surface. Free its verts + a skin collar, solve; wrong-bank bridging becomes impossible. This produced the first genuinely smooth pelvis |
| proudness detectors + deflation for the leftovers | fully-masked leaves are PENDANT BALLOONS — closed shells whose excess area the (no-maximum-principle) bi-harmonic parks as smooth raised caps. Smoothing-built references partially follow any bump wider than their radius, and **Taubin references are worse than useless here: shape-preserving by design, a 1.5 cm cap sits inside the passband and reads ~0** |
| density excision + scar refill | catches the FLAT wads (the melted shell lies ~4 layers deep, and piles would z-fight) but not inflated caps, which sit BELOW the piled median. Scar refill needs: per-sweep densify scope with a floored target (a sliver scar once re-densified everything to 3.3M faces / 17 h), a slit weld first (scars meeting Tripo slits are ribbons, not loops — fill ops refuse them, and degenerate output crashed Blender once), and a >900-edge refusal |
| occlusion / fin / diffusion detectors for the last four "caps" | all near-zero. A pixel-ray probe finally explained why: **the last bumps were not leaf debris at all — they are the melt web itself bridging taut over the inguinal hollow.** There was never skin under the vine, and every rim-anchored method spans its anchors by construction |
| **+ the flow finish** (the guarantee) | damped pure-Laplacian flow over the whole changed region, no detector at all: the maximum principle sinks any pendant cap while the 5-10× wider pubic web barely moves. Ramp weight 0→1 over **3** rings — at 8 rings the hip-vine channel (1020 rings wide) sat entirely in the damped zone |
| **+ the solid-welt pass** | some leaf roots are SOLID skin-painted ridges, not shells — free them (0.5 mm proudness against a whole-band 400-sweep reference; honest skin reads p95 +0.13 mm) and re-solve locally; converges in 2 passes (5,375 verts, then 1) |
Pipeline as shipped in `07_fill_crotch.py`: weld → cut bust open (+despike) → free briefs
leaves + collar → remnant sweep (relaxed hue/blown-white key near the melt, no speckle
filter) → bi-harmonic melt (matrix-free Jacobi-PCG, harmonic warm start) → solid-welt pass
×2 → density excision of flat wads + occlusion pass → slit weld + degenerate dissolve →
three scar-fill sweeps + patch solve → flow finish. Melted faces get one donor texel
(thigh-band vertex nearest the band's median tone with the most neutral normal texel) —
the atlas is Tripo chart soup, so interpolating UVs across the fill would sample garbage.
```
"$B" --background --factory-startup --python $L/07_fill_crotch.py -- \
characters/originals/female/female_lena_leafbikini_tripo.glb $L/leaf_mask.npz \
$L/v02/lena_leafbikini_crotchfill_sculpt_glb_v02.glb --free-rings 3
```
## 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.
- **The hip-side shelf bulges are a known consequence, not a bug to chase further.** Where
the vine crossed the inguinal crease there was never skin underneath, and "take the
distance and angle between the two sides" — the membrane — spans its anchors by
construction. Any method anchored at the rims produces the same taut bridge (harmonic,
bi-harmonic, and flow all agree; the maximum principle *protects* a two-sided span).
Killing them means SCULPTING the crease through the strip — invented anatomy, its own
decision, its own stage. Ten runs of detector archaeology confirmed there is nothing
foreign left there to remove.
- **The bust holes are not filled, by request** (v01 deliverable; v02 fills the crotch
only). Whatever fills the bust has to rebuild breast anatomy, not just span it — see
`tools/make_lena_nude_body.py` for how the nude lane sculpted hers procedurally.
- The base-colour map still carries the leaves and their baked contact shadows. Around the
v01 holes and the v02 fill alike, the surviving skin reads darker than her surrounding
tone — an albedo re-author job for a later stage (the nude lane's harmonic refill is the
template).
- **Both artifacts are unrigged**, because they were authored on the pristine original.
They transfer to the shipped body's frame 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 either mesh. Re-running 05/07 against the shipped
GLB is a first-argument change — but the result is a new ship folder, never an edit to
the frozen one.
## Finger-weight re-solve (`08_finger_weights.py`) — how to run it
**The input is `v02/lena_leafbikini_quatskin_fingers_glb_exp03.glb`, not exp01.** This is
the one thing to get right; guessing it cost three wasted bakes on 2026-08-18.
```
"C:/Program Files/Blender Foundation/Blender 5.1/blender.exe" --background \
--factory-startup --python characters/work/lena_leafbikini/08_finger_weights.py -- \
characters/work/lena_leafbikini/v02/lena_leafbikini_quatskin_fingers_glb_exp03.glb \
characters/work/lena_leafbikini/v02/lena_leafbikini_quatskin_fingers_glb_<tag>.glb
```
exp01 and exp03 carry the SAME weight groups (identical vertex sets — `index_l` is 6,985
verts in both) but **different meshes**: 91,445 verts differ, by up to 12.5 cm. exp01 is
the pre-hand-fit body, so on it those groups land on the wrist/palm, overlapping the real
index finger by only 1.6 cm. Solving from exp01 therefore produces a body where the
arc-length param `s` never clears the phalanx-1 threshold and **every `_02`/`_03` finger
bone gets exactly zero weight** — rigid stick fingers hinging at the knuckle, and a torn
flat pose. It fails silently: weight sums are 1.0, the asserts pass, the export succeeds.
Confirm a good run by the label counts — from exp03, side l is index 4,000 / middle 4,718
(these match exp05's groups) and `chain-continuity repaired` is ~400 per side, not 0.
Verify a bake with `tools/handpose_bake_preview.py` + `tools/handpose_skin_to_obj.py` +
edge-stretch; judge on **real** tears (>=1mm rest length) and **visible** needles (>=1cm
posed), not raw ratios. Flat must stay at 0-1 real tears per hand — that pose ships.
Measurement tools for this lane: `tools/edge_stretch_cmp.py` (stretch with the real-tear /
visible-needle split, several builds side by side), `tools/skin_bone_territory.py` (how many
verts each finger bone actually owns — catches a starved chain), `tools/fin_bones.py`
(classifies torn edges by bone pair, which is what tells you *which* defect you are looking
at), `tools/handpose_trim_hand_obj.py` (trim an arm-sized skin dump to the hand, or a
bbox-framing renderer puts the hand in a corner).
@@ -0,0 +1,360 @@
{
"body": "C:/Users/Jeremy/tinqs/ariki-game/assets/quaternius/derived-bodies/Ariki_Female_QuatSkin_LowPoly_40.glb",
"roi_verts": 19159,
"weld": {
"enabled": true,
"tol_m": 1e-05,
"nodes": 17480,
"merged_groups": 1605,
"max_group": 4,
"rim": 749
},
"stitch_edges": 980,
"seam_edges": 1613,
"poses": {
"flat_l": {
"raw": {
"max": 2.5649668819269853,
"p999": 1.4997998775715617,
"n_gt2": 6,
"n_gt5": 0,
"slv_n": 2125,
"slv_gt5x": 0,
"slv_grow_gt1mm": 0,
"slv_max_grow_mm": 0.92,
"needles": 0,
"max_grow_mm": 2.14
},
"relaxed": {
"max": 1.35,
"p999": 1.3499999999999406,
"n_gt2": 0,
"n_gt5": 0,
"slv_n": 2125,
"slv_gt5x": 0,
"slv_grow_gt1mm": 0,
"slv_max_grow_mm": 0.34,
"needles": 0,
"max_grow_mm": 1.44
},
"tip_bone_cm": {
"thumb": 0.63,
"index": 0.15,
"middle": 0.15,
"ring": 0.15,
"pinky": 0.14
},
"tip_mesh_cm": {
"index": 0.11,
"middle": 0.11,
"ring": 0.11,
"pinky": 0.11
},
"relax_iters": 236,
"viol_edges_left": 0,
"viol_seam_left": 0,
"seam_max_mm": 1.23
},
"relaxed_l": {
"raw": {
"max": 12.689021649045726,
"p999": 8.077834064456647,
"n_gt2": 1020,
"n_gt5": 248,
"slv_n": 2125,
"slv_gt5x": 43,
"slv_grow_gt1mm": 96,
"slv_max_grow_mm": 9.97,
"needles": 291,
"max_grow_mm": 17.03
},
"relaxed": {
"max": 1.3589166079445092,
"p999": 1.3501469074679688,
"n_gt2": 0,
"n_gt5": 0,
"slv_n": 2125,
"slv_gt5x": 0,
"slv_grow_gt1mm": 0,
"slv_max_grow_mm": 0.35,
"needles": 0,
"max_grow_mm": 3.77
},
"tip_bone_cm": {
"thumb": 3.86,
"index": 1.42,
"middle": 1.39,
"ring": 1.33,
"pinky": 1.3
},
"tip_mesh_cm": {
"index": 0.96,
"middle": 0.99,
"ring": 0.99,
"pinky": 0.96
},
"relax_iters": 1500,
"viol_edges_left": 304,
"viol_seam_left": 12,
"seam_max_mm": 1.99
},
"fist_l": {
"raw": {
"max": 52.899800203384736,
"p999": 34.724530961525744,
"n_gt2": 2213,
"n_gt5": 1455,
"slv_n": 2125,
"slv_gt5x": 103,
"slv_grow_gt1mm": 158,
"slv_max_grow_mm": 51.07,
"needles": 1558,
"max_grow_mm": 65.54
},
"relaxed": {
"max": 1.634544698690311,
"p999": 1.3844521052494323,
"n_gt2": 0,
"n_gt5": 0,
"slv_n": 2125,
"slv_gt5x": 0,
"slv_grow_gt1mm": 0,
"slv_max_grow_mm": 0.44,
"needles": 0,
"max_grow_mm": 4.11
},
"tip_bone_cm": {
"thumb": 9.73,
"index": 7.19,
"middle": 7.1,
"ring": 6.82,
"pinky": 6.75
},
"tip_mesh_cm": {
"index": 4.91,
"middle": 4.9,
"ring": 4.76,
"pinky": 4.62
},
"relax_iters": 1500,
"viol_edges_left": 1939,
"viol_seam_left": 48,
"seam_max_mm": 1.32
},
"grip_l": {
"raw": {
"max": 49.79406421161323,
"p999": 30.545310726816965,
"n_gt2": 2150,
"n_gt5": 1353,
"slv_n": 2125,
"slv_gt5x": 101,
"slv_grow_gt1mm": 140,
"slv_max_grow_mm": 40.91,
"needles": 1454,
"max_grow_mm": 73.2
},
"relaxed": {
"max": 1.8239868532277623,
"p999": 1.4628749431140193,
"n_gt2": 0,
"n_gt5": 0,
"slv_n": 2125,
"slv_gt5x": 0,
"slv_grow_gt1mm": 0,
"slv_max_grow_mm": 0.57,
"needles": 0,
"max_grow_mm": 3.73
},
"tip_bone_cm": {
"thumb": 10.03,
"index": 5.47,
"middle": 5.39,
"ring": 5.18,
"pinky": 5.09
},
"tip_mesh_cm": {
"index": 3.71,
"middle": 3.72,
"ring": 3.63,
"pinky": 3.52
},
"relax_iters": 1500,
"viol_edges_left": 1984,
"viol_seam_left": 53,
"seam_max_mm": 1.32
},
"flat_r": {
"raw": {
"max": 7.3444863267240175,
"p999": 3.087924956802205,
"n_gt2": 103,
"n_gt5": 6,
"slv_n": 2125,
"slv_gt5x": 2,
"slv_grow_gt1mm": 4,
"slv_max_grow_mm": 5.93,
"needles": 8,
"max_grow_mm": 6.94
},
"relaxed": {
"max": 1.3508407237549374,
"p999": 1.3500148699201415,
"n_gt2": 0,
"n_gt5": 0,
"slv_n": 2125,
"slv_gt5x": 0,
"slv_grow_gt1mm": 0,
"slv_max_grow_mm": 0.35,
"needles": 0,
"max_grow_mm": 2.11
},
"tip_bone_cm": {
"thumb": 0.77,
"index": 0.15,
"middle": 0.16,
"ring": 0.15,
"pinky": 0.15
},
"tip_mesh_cm": {
"index": 0.13,
"middle": 0.13,
"ring": 0.13,
"pinky": 0.12
},
"relax_iters": 1500,
"viol_edges_left": 141,
"viol_seam_left": 17,
"seam_max_mm": 1.47
},
"relaxed_r": {
"raw": {
"max": 45.958092227798915,
"p999": 19.548705590243003,
"n_gt2": 736,
"n_gt5": 315,
"slv_n": 2125,
"slv_gt5x": 24,
"slv_grow_gt1mm": 60,
"slv_max_grow_mm": 40.74,
"needles": 339,
"max_grow_mm": 50.09
},
"relaxed": {
"max": 1.6604900471831592,
"p999": 1.4451486205473596,
"n_gt2": 0,
"n_gt5": 0,
"slv_n": 2125,
"slv_gt5x": 0,
"slv_grow_gt1mm": 0,
"slv_max_grow_mm": 0.68,
"needles": 0,
"max_grow_mm": 2.78
},
"tip_bone_cm": {
"thumb": 4.59,
"index": 1.42,
"middle": 1.42,
"ring": 1.36,
"pinky": 1.32
},
"tip_mesh_cm": {
"index": 1.13,
"middle": 1.15,
"ring": 1.13,
"pinky": 1.11
},
"relax_iters": 1500,
"viol_edges_left": 686,
"viol_seam_left": 60,
"seam_max_mm": 2.63
},
"fist_r": {
"raw": {
"max": 118.26346663296857,
"p999": 58.38873513669817,
"n_gt2": 3820,
"n_gt5": 1139,
"slv_n": 2125,
"slv_gt5x": 106,
"slv_grow_gt1mm": 299,
"slv_max_grow_mm": 116.16,
"needles": 1245,
"max_grow_mm": 132.62
},
"relaxed": {
"max": 1.7741351039193665,
"p999": 1.535993990998224,
"n_gt2": 0,
"n_gt5": 0,
"slv_n": 2125,
"slv_gt5x": 0,
"slv_grow_gt1mm": 0,
"slv_max_grow_mm": 0.63,
"needles": 0,
"max_grow_mm": 4.39
},
"tip_bone_cm": {
"thumb": 12.24,
"index": 7.22,
"middle": 7.28,
"ring": 6.97,
"pinky": 6.87
},
"tip_mesh_cm": {
"index": 6.01,
"middle": 6.24,
"ring": 6.2,
"pinky": 6.16
},
"relax_iters": 1500,
"viol_edges_left": 6109,
"viol_seam_left": 247,
"seam_max_mm": 3.23
},
"grip_r": {
"raw": {
"max": 134.1470205961321,
"p999": 64.71684078507103,
"n_gt2": 3189,
"n_gt5": 1040,
"slv_n": 2125,
"slv_gt5x": 89,
"slv_grow_gt1mm": 253,
"slv_max_grow_mm": 129.98,
"needles": 1129,
"max_grow_mm": 150.49
},
"relaxed": {
"max": 2.226320726002906,
"p999": 1.5799820313724093,
"n_gt2": 1,
"n_gt5": 0,
"slv_n": 2125,
"slv_gt5x": 0,
"slv_grow_gt1mm": 0,
"slv_max_grow_mm": 0.75,
"needles": 0,
"max_grow_mm": 4.04
},
"tip_bone_cm": {
"thumb": 13.09,
"index": 5.5,
"middle": 5.53,
"ring": 5.29,
"pinky": 5.18
},
"tip_mesh_cm": {
"index": 4.5,
"middle": 4.66,
"ring": 4.6,
"pinky": 4.54
},
"relax_iters": 1500,
"viol_edges_left": 5005,
"viol_seam_left": 197,
"seam_max_mm": 3.07
}
}
}
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 356 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 402 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 330 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 378 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 367 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 260 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 362 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 385 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 322 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 336 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 363 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 231 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 328 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 372 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 301 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,246 @@
{
"_comment": "Finger rotations Idle_Loop (UAL1) pins on every body \u2014 extracted 2026-08-18 to show what the clips force on Mako's hand. NOT a library pose; a diagnostic.",
"source": "UAL1.glb Idle_Loop frame 0",
"bones": {
"index_01_l": [
0.44519037,
0.54936898,
-0.44519415,
0.54936463
],
"index_02_l": [
0.62334186,
6e-08,
2.2e-07,
0.78194946
],
"index_03_l": [
0.62334144,
-2e-07,
6e-08,
0.78194976
],
"index_04_leaf_l": [
0.0,
1.0,
-1e-08,
3.66e-06
],
"middle_01_l": [
0.43208984,
0.53869981,
-0.45804641,
0.55972713
],
"middle_02_l": [
0.62334126,
0.00094348,
0.00118364,
0.78194851
],
"middle_03_l": [
0.62334108,
-0.00070432,
-0.00088335,
0.78194928
],
"middle_04_leaf_l": [
0.0,
1.0,
1e-08,
3.64e-06
],
"pinky_01_l": [
0.50405717,
0.51154137,
-0.43515161,
0.5430423
],
"pinky_02_l": [
0.62316287,
-0.02006147,
0.01492548,
0.78169227
],
"pinky_03_l": [
0.6233415,
0.00037983,
0.00047662,
0.78194952
],
"pinky_04_leaf_l": [
2e-08,
1.0,
-0.0,
3.64e-06
],
"ring_01_l": [
0.43533966,
0.54135603,
-0.45490378,
0.55720335
],
"ring_02_l": [
0.62334186,
7.39e-06,
9.58e-06,
0.78194946
],
"ring_03_l": [
0.62334114,
-0.00066514,
-0.0008345,
0.78194928
],
"ring_04_leaf_l": [
0.0,
1.0,
0.0,
3.66e-06
],
"thumb_01_l": [
0.2908383,
0.94964916,
0.11458342,
0.02121698
],
"thumb_02_l": [
0.21938996,
0.02380256,
-0.00530472,
0.97533244
],
"thumb_03_l": [
0.68738812,
-0.00030907,
-0.00044466,
0.72629011
],
"thumb_04_leaf_l": [
0.0,
0.5061779,
2.8e-07,
0.86242908
],
"index_01_r": [
0.44519085,
-0.5493688,
0.44519404,
0.54936451
],
"index_02_r": [
0.62334144,
2.2e-07,
6e-08,
0.78194976
],
"index_03_r": [
0.62334156,
1.5e-07,
-2.1e-07,
0.7819497
],
"index_04_leaf_r": [
1e-08,
-1.0,
-0.0,
3.65e-06
],
"middle_01_r": [
0.4320901,
-0.53869969,
0.45804659,
0.55972695
],
"middle_02_r": [
0.62334079,
-0.00094351,
-0.0011834,
0.78194886
],
"middle_03_r": [
0.62334144,
0.00070417,
0.00088315,
0.78194898
],
"middle_04_leaf_r": [
0.0,
-1.0,
-0.0,
3.64e-06
],
"pinky_01_r": [
0.50405687,
-0.51154143,
0.4351517,
0.54304248
],
"pinky_02_r": [
0.6231631,
0.0200611,
-0.01492533,
0.78169209
],
"pinky_03_r": [
0.6233418,
-0.00038015,
-0.00047653,
0.78194928
],
"pinky_04_leaf_r": [
2e-08,
-1.0,
-1e-08,
3.65e-06
],
"ring_01_r": [
0.4353399,
-0.54135597,
0.45490402,
0.55720317
],
"ring_02_r": [
0.62334114,
-7.55e-06,
-9.28e-06,
0.78195
],
"ring_03_r": [
0.62334162,
0.00066492,
0.00083433,
0.78194892
],
"ring_04_leaf_r": [
0.0,
-1.0,
1e-08,
3.65e-06
],
"thumb_01_r": [
0.29083842,
-0.94964916,
-0.11458353,
0.02121704
],
"thumb_02_r": [
0.21939011,
-0.0238026,
0.00530485,
0.97533244
],
"thumb_03_r": [
0.68738812,
0.00030923,
0.00044454,
0.72629011
],
"thumb_04_leaf_r": [
3e-08,
-0.50617778,
-3e-07,
0.86242914
]
}
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 143 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 267 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 173 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 897 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 924 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 734 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 911 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 959 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 761 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+61 -14
View File
@@ -8,25 +8,55 @@ byte-identical — the runtime `HandPoseLayer` reads the game copy).
`{"bones": {"<bone_name>": [x, y, z, w], ...}}` — glTF node-local quaternions on the
**canonical Quaternius skeleton**, which is exactly Godot bone-pose space for these
bodies. Apply directly:
```csharp
skeleton.SetBonePoseRotation(skeleton.FindBone(name), new Quaternion(x, y, z, w));
```
bodies.
40 bones per pose, including `*_04_leaf_*` tip bones. Some bodies lack the leaf
bones — **skip bones `FindBone` returns -1 for**, never error. On non-canonical rigs
(Mako) poses must be applied rest-relative; the runtime layer simply excludes those
bodies instead.
bones — **skip bones `FindBone` returns -1 for**, never error.
**Apply REST-RELATIVE, not directly** (runtime does this as of 2026-08-18):
```csharp
// delta = canonicalRest^-1 * pose; target = thisBodysRest * delta
var target = boneRest * (canonRest.Inverse() * pose);
skeleton.SetBonePoseRotation(idx, target);
```
`canonical_rest.json` (regenerate with `tools/make_canonical_rest.py`) holds the canonical
finger rests this correction needs. On a rig whose finger rest matches canonical the
correction is algebraically a no-op, so nothing changes for the Quaternius/QuatSkin
bodies. On one that deviates it is the only correct form — Mako's `*_01` knuckles sit
**11.5°** off canonical, and writing poses straight in wrenched them away from his own
rest and tore the palm/wrist boundary.
## Poses
| File | Source (Kevin packs) | Runtime status |
|---|---|---|
| `pose_flat.json` | harvested flat hand | **SHIPPED to layer 2026-08-17** — verified in the dance bed on exp01 |
| `pose_relaxed.json` | `HandWave01` f0 | staged |
| `pose_fist.json` | `AttackPunch01_R/L` f7, merged | blocked on Lena finger-weight repair (fist/grip shred on exp01exp04) |
| `pose_grip.json` | `CombatIdle1H01` f0, both hands | blocked, same repair |
| `pose_flat.json` | **= canonical REST** (see below) | **SHIPPED** — verified in-engine on Mako 2026-08-18 |
| `pose_relaxed.json` | `HandWave01` f0 (~10° off rest) | staged |
| `pose_fist.json` | `AttackPunch01_R/L` f7, merged (~61° off rest) | blocked on finger-weight repair (shreds on exp01exp05 and on Mako) |
| `pose_grip.json` | `CombatIdle1H01` f0, both hands (~44° off rest) | blocked, same repair |
### `pose_flat` is the REST pose — and that is why it is useful
Measured 2026-08-18: `pose_flat.json` matches `kevin_female_combat.glb`'s finger rests to
**0.04°** — it is the canonical rest pose, not a separately harvested "flat hand". Do not
expect it to straighten a hand that is already at rest.
It is still the load-bearing pose, because **the shipped clips do not hold fingers at
rest** — they pin them in a permanent curl (frozen tracks, spread 0.0°). `UAL1 Idle_Loop`
holds the fingers **53.9° off rest on average, up to 86.8°** (`thumb_03_r`) — near a
clench. So the FLAT layer's real job is to undo that baked-in curl.
On Mako that curl is what shreds his hands. It does not fling verts (max displacement only
23.3 cm, i.e. legitimate fingertip travel) — it **tears**: 2,258 edges stretched >5×, max
198×, which rips his fused hand open into sheets. Turning FLAT on removes the bulk of it.
Evidence (in-engine, the only honest judge here):
`characters/work/mako/handfix/review/ingame_handcam_flat_{OFF,ON}.png` and the
`ingame_mako_{left,right}_hand_flat_ON_zoom.png` crops. **Residual:** at close range his
RIGHT hand still shows a torn patch with FLAT on — his right hand owns far less finger
geometry than his left (4,283 verts vs 10,532; `middle_02_r` owns just 157), so it is not
fully fixed. FLAT only holds the FINGER bones at rest; `hand_l/r` still follow the clip.
## Verification (before shipping a pose)
@@ -34,5 +64,22 @@ bodies instead.
- `tools/skin_displacement_check.py posed.glb original.glb` — Godot-exact LBS travel;
cm-scale = sane, m-scale = broken. **Blind to fin tearing** — pair it with the
edge-stretch check (`tools/edge_stretch.py`).
- `tools/handpose_skin_to_obj.py` + `tools/handpose_render_objs.py`the only honest
visual check. Never judge by importing a baked-pose GLB into Blender (false shards).
- `tools/handpose_skin_to_obj.py` + `tools/handpose_render_objs.py`clay render. Never
judge by importing a baked-pose GLB into Blender (false shards). Pass an ABSOLUTE outdir
(a relative one silently writes nothing), name inputs `<pose>_hand_<l|r>.obj` (the glob
requires it), and crop to the hand with `tools/obj_crop.py` or the arm dominates the frame.
- `tools/skin_lever_audit.py body.glb` — finds bindings whose joint is implausibly far away
in rest, and verts bound across the midline to the opposite hand. **Invisible at rest**,
so nothing else catches them: this is what found Mako's 444 cross-hand verts.
- `tools/skin_crosshand_repair.py in.glb out.glb` — repairs those by inpainting from the
mesh's own healthy neighbours (`--diagnose` to preview).
- `tools/hand_bone_ownership.py body.glb [l|r]` — which finger bones actually own geometry.
Run it before trusting a pose on a new body: Mako is a **two-finger rig**, so only his
thumb and middle chains own verts and index/ring/pinky own nothing.
- `tools/rest_deviation.py canonical.glb other.glb` — per-bone finger rest deviation, i.e.
whether a body needs the rest-relative correction.
**Ratio alone is not the tearing gate.** `edge_stretch.py` reports huge ratios on this
mesh's sub-millimetre sliver edges (unwelded duplicates) — judge the **absolute posed
length**. On Mako, flat's worst stretched edge reaches ~12.4 cm (benign, sub-pixel in
engine) while fist/grip reach 1015 cm (real, visible needles).
+246
View File
@@ -0,0 +1,246 @@
{
"_comment": "Canonical Quaternius finger-bone REST rotations, read from kevin_female_combat.glb. The runtime applies a pose rest-relative: delta = canonical_rest^-1 * pose, target = body_rest * delta. This makes poses correct on rigs whose finger rest differs from canonical (Mako's *_01 knuckles sit 11.5 deg off). Generated by tools/make_canonical_rest.py.",
"source": "kevin_female_combat.glb",
"bones": {
"index_04_leaf_l": [
-4e-08,
0.99982297,
0.0,
0.01881603
],
"index_03_l": [
1.99e-06,
-7.07e-06,
-0.00018567,
1
],
"index_02_l": [
4.8e-07,
4.949e-05,
-0.00053302,
0.99999988
],
"index_01_l": [
0.00850501,
0.70681149,
-0.0185241,
0.7071082
],
"middle_04_leaf_l": [
-0.0,
0.99982554,
0.0,
0.0186798
],
"middle_03_l": [
-3.6e-07,
-5.432e-05,
-0.00141977,
0.99999905
],
"middle_02_l": [
5.9e-07,
7.837e-05,
0.00205278,
0.99999791
],
"middle_01_l": [
0.00506735,
0.70676285,
-0.02195816,
0.70709157
],
"pinky_04_leaf_l": [
0.0,
0.99982917,
-1e-08,
0.01848373
],
"pinky_03_l": [
3.26e-06,
-0.00013735,
0.00046609,
0.99999988
],
"pinky_02_l": [
4.4e-07,
9.531e-05,
-0.0015675,
0.99999881
],
"pinky_01_l": [
0.00260105,
0.70668215,
-0.0244147,
0.70710504
],
"ring_04_leaf_l": [
-0.0,
0.99981856,
4e-08,
0.01904976
],
"ring_03_l": [
3.61e-06,
-0.00025298,
-0.00338758,
0.99999422
],
"ring_02_l": [
-4.8e-06,
0.00021854,
0.00239975,
0.99999708
],
"ring_01_l": [
0.01532381,
0.70691711,
-0.01171326,
0.70703346
],
"thumb_04_leaf_l": [
-1e-08,
0.38270876,
1e-08,
0.92386907
],
"thumb_03_l": [
-1.3e-07,
-2.972e-05,
1.65e-06,
1
],
"thumb_02_l": [
7.4e-07,
4.63e-06,
-3.99e-06,
1
],
"thumb_01_l": [
-0.24446329,
-0.94356763,
-0.21606149,
0.05688012
],
"index_04_leaf_r": [
0.0,
-0.99982297,
-0.0,
0.01881603
],
"index_03_r": [
1.98e-06,
7.07e-06,
0.00018565,
1
],
"index_02_r": [
4.1e-07,
-4.94e-05,
0.00053306,
0.99999988
],
"index_01_r": [
0.00850504,
-0.70681167,
0.01852416,
0.70710802
],
"middle_04_leaf_r": [
-6e-08,
-0.99982554,
-0.0,
0.01867968
],
"middle_03_r": [
-4.3e-07,
5.437e-05,
0.00141985,
0.99999899
],
"middle_02_r": [
5.9e-07,
-7.843e-05,
-0.00205283,
0.99999791
],
"middle_01_r": [
0.00506735,
-0.70676279,
0.02195819,
0.70709163
],
"pinky_04_leaf_r": [
-0.0,
-0.99982917,
0.0,
0.01848373
],
"pinky_03_r": [
3.21e-06,
0.00013741,
-0.000466,
0.99999988
],
"pinky_02_r": [
4.2e-07,
-9.54e-05,
0.00156743,
0.99999875
],
"pinky_01_r": [
0.0026011,
-0.70668221,
0.02441467,
0.70710492
],
"ring_04_leaf_r": [
0.0,
-0.99981856,
-0.0,
0.01904976
],
"ring_03_r": [
3.69e-06,
0.00025282,
0.00338756,
0.99999422
],
"ring_02_r": [
-4.9e-06,
-0.0002185,
-0.00239973,
0.99999714
],
"ring_01_r": [
0.01532385,
-0.70691711,
0.01171329,
0.7070334
],
"thumb_04_leaf_r": [
-5e-08,
-0.38270876,
2e-08,
0.92386901
],
"thumb_03_r": [
-2e-07,
2.964e-05,
-2.08e-06,
1
],
"thumb_02_r": [
6.8e-07,
-4.78e-06,
3.18e-06,
1
],
"thumb_01_r": [
-0.24446253,
0.94356787,
0.21606137,
0.0568799
]
}
}
+244
View File
@@ -0,0 +1,244 @@
{
"bones": {
"index_01_r": [
0.351009,
-0.560774,
0.497857,
0.56077
],
"index_02_r": [
0.695232,
1e-06,
0.211656,
0.686916
],
"index_03_r": [
0.700051,
-1e-06,
0.205494,
0.683886
],
"index_04_leaf_r": [
2.422049449890551e-09,
-0.9998229742050171,
-1.465072729800454e-09,
0.01881602592766285
],
"middle_01_r": [
0.368445,
-0.522544,
0.543295,
0.544083
],
"middle_02_r": [
0.625715,
-0.000948,
0.176605,
0.759796
],
"middle_03_r": [
0.636196,
0.000719,
0.144964,
0.757786
],
"middle_04_leaf_r": [
-7.969595827717058e-08,
-0.9998255372047424,
-1.2168405838508534e-08,
0.018679669126868248
],
"pinky_01_r": [
0.45704,
-0.469857,
0.56239,
0.50405
],
"pinky_02_r": [
0.797564,
0.000666,
0.130697,
0.588905
],
"pinky_03_r": [
0.800258,
-0.000488,
0.105028,
0.590386
],
"pinky_04_leaf_r": [
-1.2306506924630867e-08,
-0.9998291730880737,
2.6629458638183223e-09,
0.018483733758330345
],
"ring_01_r": [
0.439332,
-0.489788,
0.556349,
0.507514
],
"ring_02_r": [
0.716635,
-7e-06,
0.101299,
0.690053
],
"ring_03_r": [
0.725824,
0.000775,
0.019064,
0.687615
],
"ring_04_leaf_r": [
-1.1020341972312053e-08,
-0.9998185634613037,
-4.304683276501464e-09,
0.0190497525036335
],
"thumb_01_r": [
0.3767375349998474,
-0.924089252948761,
-0.06302371621131897,
0.01248654630035162
],
"thumb_02_r": [
0.41932806372642517,
0.04982568323612213,
0.0259289238601923,
0.9060955047607422
],
"thumb_03_r": [
0.41881823539733887,
0.04550888016819954,
0.03892602026462555,
0.9060932397842407
],
"thumb_04_leaf_r": [
-4.8268116614735845e-08,
-0.38270869851112366,
2.2432569579677875e-08,
0.9238690137863159
],
"index_01_l": [
0.39218,
0.555922,
-0.477598,
0.555918
],
"index_02_l": [
0.724569,
-0.0,
-0.139418,
0.674954
],
"index_03_l": [
0.727499,
-0.0,
-0.131666,
0.673357
],
"index_04_leaf_l": [
-3.7380786466201243e-08,
0.9998229742050171,
1.2021164064179857e-09,
0.018816031515598297
],
"middle_01_l": [
0.415844,
0.518752,
-0.515325,
0.54075
],
"middle_02_l": [
0.64627,
0.000979,
-0.123229,
0.753093
],
"middle_03_l": [
0.652853,
-0.000737,
-0.090181,
0.752097
],
"middle_04_leaf_l": [
-9.942080492209016e-10,
0.9998255372047424,
4.936169251124056e-09,
0.018679805099964142
],
"pinky_01_l": [
0.510799,
0.470059,
-0.513511,
0.504415
],
"pinky_02_l": [
0.811413,
-0.000677,
-0.021826,
0.584065
],
"pinky_03_l": [
0.811635,
0.000494,
0.003368,
0.584156
],
"pinky_04_leaf_l": [
-2.0175272563704993e-09,
0.9998291730880737,
-1.9009007701242808e-08,
0.018483726307749748
],
"ring_01_l": [
0.491088,
0.489266,
-0.512127,
0.507126
],
"ring_02_l": [
0.726699,
8e-06,
-0.026298,
0.686453
],
"ring_03_l": [
0.724342,
-0.000773,
0.056651,
0.687109
],
"ring_04_leaf_l": [
1.3828260758685929e-10,
0.9998185634613037,
3.419970084905799e-08,
0.019049758091568947
],
"thumb_01_l": [
0.37334099411964417,
0.924976646900177,
0.06633511930704117,
0.02518703043460846
],
"thumb_02_l": [
0.42000612616539,
-0.049613188952207565,
-0.011237763799726963,
0.9060944318771362
],
"thumb_03_l": [
0.4200849235057831,
-0.044214341789484024,
-0.023830465972423553,
0.9060937762260437
],
"thumb_04_leaf_l": [
-1.3077848803888514e-09,
0.38270875811576843,
4.056841529376243e-09,
0.9238690137863159
]
}
}
+244
View File
@@ -0,0 +1,244 @@
{
"bones": {
"index_01_l": [
0.372089,
0.575212,
-0.44701,
0.575207
],
"index_02_l": [
0.536175,
-0.0,
-0.075882,
0.840689
],
"index_03_l": [
0.537411,
-0.0,
-0.074724,
0.840004
],
"index_04_leaf_l": [
-4.2130491095804246e-08,
0.9998229742050171,
6.104919680893772e-09,
0.018816012889146805
],
"middle_01_l": [
0.362081,
0.571521,
-0.440008,
0.590469
],
"middle_02_l": [
0.530509,
0.000803,
-0.091032,
0.842777
],
"middle_03_l": [
0.535123,
-0.000604,
-0.066811,
0.842128
],
"middle_04_leaf_l": [
1.4026132255651191e-08,
0.9998255372047424,
-1.303601337987459e-09,
0.01867981255054474
],
"pinky_01_l": [
0.379526,
0.578646,
-0.394442,
0.604603
],
"pinky_02_l": [
0.534097,
-0.000446,
-0.015743,
0.845276
],
"pinky_03_l": [
0.534231,
0.000325,
0.006553,
0.845313
],
"pinky_04_leaf_l": [
1.7368666505035435e-08,
0.9998291730880737,
-2.2143675337815694e-08,
0.01848371885716915
],
"ring_01_l": [
0.382671,
0.580734,
-0.403254,
0.594725
],
"ring_02_l": [
0.553724,
6e-06,
-0.018994,
0.832484
],
"ring_03_l": [
0.552551,
-0.00059,
0.03354,
0.832804
],
"ring_04_leaf_l": [
-5.756760401709471e-09,
0.9998185634613037,
2.5820394711217887e-08,
0.01904975064098835
],
"thumb_01_l": [
0.36847078800201416,
0.8819453120231628,
0.2921511232852936,
0.03239520639181137
],
"thumb_02_l": [
0.24808266758918762,
-0.029302185401320457,
-0.0066396272741258144,
0.968272864818573
],
"thumb_03_l": [
0.24812805652618408,
-0.026128530502319336,
-0.014074699021875858,
0.9682725071907043
],
"thumb_04_leaf_l": [
-4.946755449708462e-09,
0.38270875811576843,
1.279980677004744e-09,
0.9238690137863159
],
"index_01_r": [
0.33652,
-0.579358,
0.464165,
0.579353
],
"index_02_r": [
0.522149,
1e-06,
0.108755,
0.845891
],
"index_03_r": [
0.524414,
-1e-06,
0.108105,
0.844573
],
"index_04_leaf_r": [
4.50209008961977e-10,
-0.9998229742050171,
-7.24976256805121e-09,
0.0188160240650177
],
"middle_01_r": [
0.329666,
-0.574074,
0.458768,
0.592699
],
"middle_02_r": [
0.517316,
-0.000784,
0.123773,
0.846796
],
"middle_03_r": [
0.524415,
0.000592,
0.1003,
0.845534
],
"middle_04_leaf_r": [
-4.130962949489003e-08,
-0.9998255372047424,
-1.7077526059949832e-09,
0.018679669126868248
],
"pinky_01_r": [
0.354361,
-0.578543,
0.417584,
0.604433
],
"pinky_02_r": [
0.529705,
0.000442,
0.049638,
0.846728
],
"pinky_03_r": [
0.530597,
-0.000323,
0.027208,
0.847188
],
"pinky_04_leaf_r": [
2.6093180949260386e-09,
-0.9998291730880737,
3.3186802195217524e-09,
0.018483715131878853
],
"ring_01_r": [
0.355867,
-0.581001,
0.426447,
0.594928
],
"ring_02_r": [
0.54862,
-5e-06,
0.055744,
0.834212
],
"ring_03_r": [
0.553196,
0.000591,
0.003472,
0.833044
],
"ring_04_leaf_r": [
-2.2238539898467025e-08,
-0.9998185634613037,
-6.419670128821053e-09,
0.019049761816859245
],
"thumb_01_r": [
0.37758150696754456,
-0.8796287178993225,
-0.288614422082901,
0.019680041819810867
],
"thumb_02_r": [
0.24768105149269104,
0.029427889734506607,
0.015316151082515717,
0.9682735204696655
],
"thumb_03_r": [
0.2473800927400589,
0.02689296193420887,
0.02299121953547001,
0.96827232837677
],
"thumb_04_leaf_r": [
-4.4832216161694305e-08,
-0.38270875811576843,
2.3225833700735166e-08,
0.9238690137863159
]
}
}
+156
View File
@@ -0,0 +1,156 @@
# Hand shapes — morph-target hand poses for ariki-game (the "shape" lane)
Where `../hand-poses/` stores poses as **bone rotations** (blocked on a finger-weight
repair that stalled at exp05), this lane bakes poses as **surface deformation** — glTF
morph targets spliced directly into a body GLB. The pose library ships *inside the mesh*.
## Why this exists (the one-paragraph case)
The scan mesh carries ~2.6k inter-digit bridge edges. Weights only choose *which bone
drags a shared vertex* — when adjacent fingers curl apart in a fist, those bridges must
tear, which is exactly the fin-stack failure of exp01exp05 (torn-edge counts 8003300
per hand, five iterations, no convergence; the mesh topology is the problem, not the
weights). A morph target IS the final vertex positions: tearing is impossible by
construction, and the web stretch becomes one geometric fix.
## What this mesh actually is (measured 2026-08-18, `LowPoly_40`)
Every one of these was silently breaking the solve. Read before tuning anything.
| Fact | Number | Consequence |
|---|---|---|
| Coincident duplicate verts on chart seams | 356 groups / 732 verts | solved twice, deltas disagreed by up to 2.55cm → seam cracks |
| Hand is built from separate overlapping sheets | 6 components, gaps 1.110mm | Dijkstra cannot cross a gap: each bone's field covers only the sheet its seeds landed on |
| Skeleton finger chain overshoots the flesh | mesh ends 14.0cm from the wrist, `_03` joints sit at 19.8cm | `_02`/`_03` carried almost no weight, so `curl_02`/`curl_03` did nothing; the 4mm seed radius found no verts for 10 of 15 bones |
| Detached fragment near the right wrist | 134 verts | took `index_02_r` through a seed leak and flew **31cm** on fist_r |
| Verts owned by any single phalanx | **zero** of 17,480 above 0.85 | 8mm kernels are wider than the gap to the next phalanx; a 3-way blend averages the curl away |
## Pipeline (`tools/handshape_solve.py`, pure numpy on raw GLB bytes)
1. **refit** the finger chain into the flesh (`refit_fingers`) — solver-local scaffolding
only; the shipped skeleton is never touched, because every clip pins all 65 bone
positions. A morph is just final vertex positions, so the pose only needs pivots that
lie inside the flesh they bend.
2. **ROI** = union of spheres about the wrist + refit joints (`build_roi`). NOT a tube
about bone segments — a tube leaves the ROI riddled with interior chart holes, so its
"rim" is a fractal inside the hand rather than a wrist band.
3. **weld** coincident verts into single graph nodes (`weld_roi`); deltas scatter back to
every duplicate, so seams cannot crack by construction.
4. **stitch** separate sheets within 12mm (`stitch_components`) — cross-component pairs
only, so a stitch can never fake a shortcut inside a sheet.
5. **weights** as a partition of unity along each digit's chain arc (`solve_weights`):
narrow handover ramps at each joint (1.0 mid-phalanx, 0.5 at the joint), times digit
ownership from lateral distance *relative to the nearest chain*.
6. **pose** parametric curl/spread/thumb-opposition, LBS with the solver's own weights
(`pose_globals`, `lbs`); parameters in `DEFAULT_PARAMS`.
7. **relax** by strain-only edge projection (`relax`) plus ROI-border seam constraints.
8. **emit** POSITION *and* NORMAL deltas as morph accessors (`emit_morph_glb`), names in
`extras.targetNames` as `hand_<pose>_<l|r>`.
`--selftest-bump` splices one synthetic 3cm palm bump with no solve — proves the
import + drive path on a new body. `--no-weld` / `--no-stitch` / `--no-refit` reproduce
the older behaviour for comparison.
### The trap that invalidated every earlier gate
The previous relaxation was **gated Laplacian diffusion of the delta field**. Diffusion
has a null space — constants — and edge stretch is blind to every member of it: a rigid
translation stretches no edge, and neither does a collapse to zero. So the diffusion
always found one of those two exits, and reported perfect bars on the way out. Measured
on this body: the left hand decayed to **0.3cm** of fingertip travel (max 1.93x,
p99.9 1.60x, zero torn edges — a flawless report for a morph that does nothing), and the
right hand converged to a near-constant **6.5cm delta at every arc position from wrist to
fingertip** — the whole hand translated sideways with its shape intact (max 2.43x,
p99.9 1.46x, also "passing"). Strain-only projection has no such exit: a conforming edge
contributes no correction, so the pose survives wherever it does not tear.
Corollary: **edge stretch alone can never gate this lane.** Always read mesh fingertip
travel (`tip_mesh_cm`) and `seam_max_mm` beside it. `tip_bone_cm` is scaffolding — it
read 10cm/finger while the `_03` joints floated 5cm outside the mesh.
## Bake a body
```
"C:/Program Files/Blender Foundation/Blender 5.1/blender.exe" --background \
--factory-startup --python tools/handshape_solve.py -- \
--body <body.glb> --poses flat,relaxed,fist,grip --out <out.glb> \
--workdir characters/work/lena_leafbikini/handmorph/
```
Blender is only the numpy host — no bpy, no scene import (so the
importer-draws-false-shards trap does not apply to the solver). ~1 min for 8 shapes.
Numbers land in `handmorph/handmorph_report.json`, OBJ dumps beside it.
## Gate bars (per shape)
- edge stretch over **all** edges, no rest-length floor: p99.9 <= 1.6x, zero > 5x, zero
"needles" (>5x *and* >1mm of real growth). The old 1mm floor hid a population of sub-mm
seam edges that grew to 34cm — hairline spikes, sub-pixel in screenshots.
- **mesh** fingertip travel: fist >= 2.5 cm/finger, grip ~2 cm, relaxed 0.52 cm
- ROI-border seam: <= ~5mm
- cross-hand independence: 0 cm on the other hand's verts
Current (2026-08-18, `Ariki_Female_QuatSkin_LowPoly_40.glb`, ROI 19,159 → 17,480 nodes):
| shape | max | p99.9 | n>5x | needles | seam | mesh tip cm |
|---|---|---|---|---|---|---|
| flat_l | 1.35 | 1.35 | 0 | 0 | 1.2mm | 0.1 |
| relaxed_l | 1.36 | 1.35 | 0 | 0 | 2.0mm | 1.0 |
| fist_l | 1.63 | 1.38 | 0 | 0 | 1.3mm | 4.64.9 |
| grip_l | 1.82 | 1.46 | 0 | 0 | 1.3mm | 3.53.7 |
| flat_r | 1.35 | 1.35 | 0 | 0 | 1.5mm | 0.1 |
| relaxed_r | 1.66 | 1.45 | 0 | 0 | 2.6mm | 1.11.2 |
| fist_r | 1.77 | 1.54 | 0 | 0 | 3.2mm | 6.06.2 |
| grip_r | 2.23 | 1.58 | 0 | 0 | 3.1mm | 4.54.7 |
Cross-hand independence is exactly 0.0000 cm on all 8 shapes. `viol_edges_left` in the
report counts edges still above the *soft* 1.35x projection target (~6k on fist/grip after
1500 iterations) — not a bar, but the reason `max` sits near 1.8x rather than 1.35x.
## Verify in-engine (the only honest gate)
```
HANDMORPH_BODY_F=res://scratchpad/lowpoly40_handmorph.glb \
SCENE=anim_hand_test_bed MOCK_ONLY=1 AGENT_OWNED=1 WAIT=1 bash tools/game.sh spawn
# then: game.sh click 'fist' / 'grip' / 'flat' / 'morph OFF' / 'Cam: Lena hands'
```
Verified 2026-08-18: `[HandMorphLayer] found 4 hand pose(s)`; fist and grip both read as
a real curl on both hands, static and mid-`dance_soul`, at hand-cam range — no fins,
shards, needles or stray geometry. It reads as a **loose fist / cupped hand**, not a
clenched one: her fingers are only ~45cm long past the knuckles, so ~6cm of tip travel
is most of the range available.
Two environment notes: this demo body carries the **yellow LowPoly-bake defect** (yellow
with the morph on *and* off — it is the body, not the lane), and 8 dense morph targets on
a 430k-vert mesh **crashed the GPU driver** (`Vulkan device was lost`, TDR) after ~12
minutes of bed time. Take screenshots promptly, and treat runtime cost as an open risk.
## Relationship to the bone lane (`../hand-poses/`)
Independent and composable: the bone lane overrides finger-bone rotations (needs
finger-weighted bodies); this lane deforms the surface (works on ANY body carrying the
shapes, including the shipped rigid-mitt bodies). Hotkeys **H** bone lane, **K** shape
lane in the dance bed; button panels in `anim_hand_test_bed`.
## Open items
- **Ship decision.** Nothing shipped carries the shapes, so the layer is a silent no-op
on the real Lena. Dense float32 POSITION+NORMAL deltas over all 430,551 verts cost
**~9.9 MB per shape**: 43.8 MB → 122.6 MB for 8 (**+78.8 MB**, not the +41 MB the
handover estimated). Only 5.5k9.7k verts per shape are non-zero (2.2%), so glTF
**sparse accessors** are a ~30x lever (~2.5 MB for all 8) — but the engine's glTF
module appears to *write* sparse accessors without reading them, so test one shape
before betting on it. Fallbacks: drop `flat` (max delta 0.330.66cm — nearly a no-op)
and `relaxed`, keeping fist+grip = 4 shapes at ~+39 MB; or LOD1-only; or a hand-region
remesh. The TDR crash above says runtime cost needs measuring too, not just bytes.
- **Thumb chain refit is unreliable.** Its reach is measured along a wrist→tip axis that
passes through the palm, so palm/wrist flesh gets claimed by the thumb (a vertex 5cm
from the wrist came out `thumb_01_r`=0.88 and swung 4.8cm). The right hand still shows
1.4cm of wrist motion on fist; the left shows none. Needs a thumb-specific axis.
- Projection does not fully converge to 1.35x within 1500 iterations (see above).
- `tools/handshape_verify.py` reports `own-delta = -1` sentinels on this body — a
mesh/bone space mismatch in the verifier, not in the solve. Superseded in practice by
the report plus the bed; fix it or retire it.
- Mako male and full-res female via the same one-command solve — untried.
- Pose authoring is editing `DEFAULT_PARAMS` — could become JSON plus a tuning scene.
+174
View File
@@ -0,0 +1,174 @@
"""Diagnose cross-midline finger bindings: for every offending ref, compare the lever arm
to the WRONG joint against the lever to its MIRRORED counterpart, so we can tell whether a
straight _r <-> _l joint remap is geometrically correct (small mirrored lever) or would tear
(vert nowhere near the mirrored bone either).
Also reports each offending vert's full influence list, and the nearest correct finger joint.
usage: crosshand_diagnose.py body.glb
"""
import json, struct, sys, math
from pathlib import Path
from collections import Counter, defaultdict
FING = ("thumb", "index", "middle", "ring", "pinky")
def read_glb(p):
d = Path(p).read_bytes()
length = struct.unpack_from("<I", d, 8)[0]
off = 12
g = b_ = None
while off < length:
clen, ct = struct.unpack_from("<II", d, off)
off += 8
if ct == 0x4E4F534A:
g = json.loads(d[off:off + clen])
else:
b_ = d[off:off + clen]
off += clen
return g, b_
def acc(g, b, i):
a = g["accessors"][i]
bv = g["bufferViews"][a["bufferView"]]
nc = {"SCALAR": 1, "VEC2": 2, "VEC3": 3, "VEC4": 4, "MAT4": 16}[a["type"]]
fmt = {5121: "B", 5123: "H", 5125: "I", 5126: "f"}[a["componentType"]]
size = struct.calcsize(fmt) * nc
stride = bv.get("byteStride") or size
off = bv.get("byteOffset", 0) + a.get("byteOffset", 0)
return [struct.unpack_from("<%d%s" % (nc, fmt), b, off + k * stride)
for k in range(a["count"])], a["componentType"]
def quat_mat(q):
x, y, z, w = q
return [[1 - 2 * (y * y + z * z), 2 * (x * y - z * w), 2 * (x * z + y * w)],
[2 * (x * y + z * w), 1 - 2 * (x * x + z * z), 2 * (y * z - x * w)],
[2 * (x * z - y * w), 2 * (y * z + x * w), 1 - 2 * (x * x + y * y)]]
def node_local(nd):
t = nd.get("translation", [0, 0, 0])
r = nd.get("rotation", [0, 0, 0, 1])
s = nd.get("scale", [1, 1, 1])
R = quat_mat(r)
return [[R[i][j] * s[j] for j in range(3)] + [t[i]] for i in range(3)] + [[0, 0, 0, 1]]
def matmul(A, B):
return [[sum(A[i][k] * B[k][j] for k in range(4)) for j in range(4)] for i in range(4)]
def global_mats(g):
loc = [node_local(nd) for nd in g["nodes"]]
parent = {}
for i, nd in enumerate(g["nodes"]):
for c in nd.get("children", []):
parent[c] = i
memo = {}
def gm(i):
if i in memo:
return memo[i]
m = loc[i]
p = parent.get(i)
if p is not None:
m = matmul(gm(p), m)
memo[i] = m
return m
return [gm(i) for i in range(len(g["nodes"]))]
def mirror_name(n):
if n.endswith("_r"):
return n[:-2] + "_l"
if n.endswith("_l"):
return n[:-2] + "_r"
return None
path = sys.argv[1]
g, b = read_glb(path)
names = [nd.get("name", "") for nd in g["nodes"]]
GM = global_mats(g)
mesh = g["meshes"][0]
prim = mesh["primitives"][0]
att = prim["attributes"]
skin_idx = next(nd.get("skin") for nd in g["nodes"] if nd.get("mesh") == 0 and "skin" in nd)
joints = g["skins"][skin_idx]["joints"]
jname = [names[j] for j in joints]
jpos = {jname[k]: (GM[j][0][3], GM[j][1][3], GM[j][2][3]) for k, j in enumerate(joints)}
P, _ = acc(g, b, att["POSITION"])
J, _ = acc(g, b, att["JOINTS_0"])
W, wt = acc(g, b, att["WEIGHTS_0"])
wsc = 1.0 if wt == 5126 else (1 / 255 if wt == 5121 else 1 / 65535)
# hand-bone anchors, to describe where verts sit
print(f"== {Path(path).name} ==")
for hb in ("hand_l", "hand_r", "middle_01_l", "middle_01_r", "middle_03_l", "middle_03_r"):
if hb in jpos:
p = jpos[hb]
print(f" {hb:14s} rest pos = ({p[0]*100:7.1f}, {p[1]*100:7.1f}, {p[2]*100:7.1f}) cm")
bad = []
for vi, (p, jrow, wrow) in enumerate(zip(P, J, W)):
for j, w in zip(jrow, wrow):
w *= wsc
if w <= 0.001:
continue
n = jname[j]
nl = n.lower()
if not any(t in nl for t in FING):
continue
if (nl.endswith("_r") and p[0] > 0.02) or (nl.endswith("_l") and p[0] < -0.02):
bad.append((vi, n, w, p))
print(f"\n cross-midline finger refs: {len(bad)}")
vids = sorted({v for v, _, _, _ in bad})
print(f" distinct verts affected : {len(vids)} (index range {min(vids)}..{max(vids)})")
# lever comparison: wrong joint vs mirrored joint vs nearest correct-side finger joint
print(f"\n {'joint':16s} {'n':>5s} {'lever_wrong':>12s} {'lever_mirror':>13s} {'nearest_correct'}")
groups = defaultdict(list)
for vi, n, w, p in bad:
groups[n].append((vi, w, p))
for n in sorted(groups):
rows = groups[n]
mn = mirror_name(n)
lw = [math.dist(p, jpos[n]) * 100 for _, _, p in rows]
lm = [math.dist(p, jpos[mn]) * 100 for _, _, p in rows] if mn in jpos else [float("nan")]
# nearest correct-side finger joint for a sample vert
side = "_l" if rows[0][2][0] > 0 else "_r"
cand = [(math.dist(rows[0][2], jpos[k]) * 100, k) for k in jpos
if any(t in k.lower() for t in FING) and k.endswith(side)]
cand.sort()
print(f" {n:16s} {len(rows):5d} {sum(lw)/len(lw):9.1f}cm {sum(lm)/len(lm):10.1f}cm "
f" {cand[0][1]} @ {cand[0][0]:.1f}cm")
# full influence list for a few offenders
print("\n sample offending verts (full influence list):")
for vi in vids[:6]:
p = P[vi]
infl = []
for j, w in zip(J[vi], W[vi]):
w *= wsc
if w > 0.001:
infl.append(f"{jname[j]}={w:.3f}")
print(f" v{vi} pos=({p[0]*100:6.1f},{p[1]*100:6.1f},{p[2]*100:6.1f})cm {' '.join(infl)}")
# how many offending verts are FULLY (>0.99) bound to a wrong joint
full = sum(1 for vi, n, w, p in bad if w > 0.99)
print(f"\n refs at weight > 0.99 (rigid, no blend to soften): {full}")
# what fraction of total left-hand-region verts are affected
hl = jpos.get("hand_l")
if hl:
near = [vi for vi, p in enumerate(P) if math.dist(p, hl) < 0.20]
aff = set(vids) & set(near)
print(f" verts within 20cm of hand_l: {len(near)}; of those affected: {len(aff)}")
+53
View File
@@ -0,0 +1,53 @@
"""Edge-stretch fin detector (pure python): posed OBJ edge lengths vs rest OBJ."""
import sys, os, math
def load_obj(path):
vs, faces = [], []
with open(path) as f:
for line in f:
if line.startswith('v '):
p = line.split()
vs.append((float(p[1]), float(p[2]), float(p[3])))
elif line.startswith('f '):
idx = [int(tok.split('/')[0]) - 1 for tok in line.split()[1:]]
for i in range(1, len(idx) - 1):
faces.append((idx[0], idx[i], idx[i + 1]))
return vs, faces
def edge_set(faces):
es = set()
for a, b, c in faces:
for u, v in ((a, b), (b, c), (c, a)):
es.add((u, v) if u < v else (v, u))
return sorted(es)
def dist(p, q):
return math.sqrt((p[0]-q[0])**2 + (p[1]-q[1])**2 + (p[2]-q[2])**2)
d = sys.argv[1]
for hand in ('l', 'r'):
rest_v, rest_f = load_obj(os.path.join(d, f'rest_hand_{hand}.obj'))
edges = edge_set(rest_f)
rest_len = [dist(rest_v[a], rest_v[b]) for a, b in edges]
for pose in ('flat', 'fist', 'grip'):
v, _ = load_obj(os.path.join(d, f'{pose}_hand_{hand}.obj'))
if len(v) != len(rest_v):
print(f'{pose}_hand_{hand}: VERTEX COUNT MISMATCH {len(v)} vs {len(rest_v)}')
continue
ratios = []
for (a, b), rl in zip(edges, rest_len):
if rl <= 1e-9:
continue
ratios.append((dist(v[a], v[b]) / rl, a, b))
ratios.sort(key=lambda t: t[0])
n = len(ratios)
mx = ratios[-1][0]
p999 = ratios[int(n * 0.999)][0]
n2 = sum(1 for r, _, _ in ratios if r > 2)
n3 = sum(1 for r, _, _ in ratios if r > 3)
n5 = sum(1 for r, _, _ in ratios if r > 5)
print(f'{pose}_hand_{hand}: edges={n} max={mx:.2f}x p99.9={p999:.2f}x >2x={n2} >3x={n3} >5x={n5}')
for r, a, b in ratios[-min(max(n3, 3), 8):][::-1]:
pa = [c * 100 for c in v[a]]
rl = dist(rest_v[a], rest_v[b]) * 100
print(f' {r:7.1f}x rest {rl:5.2f}cm -> {r*rl:7.1f}cm at posed ({pa[0]:.1f}, {pa[1]:.1f}, {pa[2]:.1f}) cm')
+77
View File
@@ -0,0 +1,77 @@
"""Edge-stretch comparison across OBJ dirs, with a rest-length split.
usage: stretch_cmp.py <rest_dir> <label>=<dir> [<label>=<dir> ...]
Rest OBJs (rest_hand_l/r.obj) come from <rest_dir>; every compared dir must share the
body's vertex order. Reports, per pose and hand: max / p99.9 / >2x / >5x over ALL edges,
then the subset that is real geometry (>=1mm rest length) and the subset that is
VISIBLE (posed length >=1cm) the count that decides whether a render shows a needle.
"""
import math
import os
import sys
def load_obj(path):
vs, faces = [], []
with open(path) as f:
for line in f:
if line.startswith("v "):
p = line.split()
vs.append((float(p[1]), float(p[2]), float(p[3])))
elif line.startswith("f "):
idx = [int(t.split("/")[0]) - 1 for t in line.split()[1:]]
for i in range(1, len(idx) - 1):
faces.append((idx[0], idx[i], idx[i + 1]))
return vs, faces
def edge_set(faces):
es = set()
for a, b, c in faces:
for u, v in ((a, b), (b, c), (c, a)):
es.add((u, v) if u < v else (v, u))
return sorted(es)
def dist(p, q):
return math.sqrt(sum((p[i] - q[i]) ** 2 for i in range(3)))
rest_dir = sys.argv[1]
cols = [a.split("=", 1) for a in sys.argv[2:]]
for hand in ("l", "r"):
rest_v, rest_f = load_obj(os.path.join(rest_dir, f"rest_hand_{hand}.obj"))
edges = edge_set(rest_f)
rest_len = [dist(rest_v[a], rest_v[b]) for a, b in edges]
print(f"\n=== hand_{hand} ({len(rest_v)} verts, {len(edges)} edges) ===")
print(f"{'pose / build':22s} {'max':>8s} {'p99.9':>7s} {'>2x':>6s} {'>5x':>6s}"
f" {'>5x real':>9s} {'>=1cm':>7s}")
for pose in ("flat", "fist", "grip"):
for label, d in cols:
f = os.path.join(d, f"{pose}_hand_{hand}.obj")
if not os.path.exists(f):
continue
v, _ = load_obj(f)
if len(v) != len(rest_v):
print(f"{pose+' '+label:22s} VERT COUNT MISMATCH {len(v)} vs {len(rest_v)}")
continue
rs = []
gt5 = gt2 = real5 = vis = 0
for (a, b), rl in zip(edges, rest_len):
if rl <= 1e-9:
continue
lq = dist(v[a], v[b])
r = lq / rl
rs.append(r)
if r > 2:
gt2 += 1
if r > 5:
gt5 += 1
if rl >= 0.001:
real5 += 1
if lq >= 0.01:
vis += 1
rs.sort()
print(f"{pose+' '+label:22s} {rs[-1]:7.1f}x {rs[int(len(rs)*0.999)]:6.2f}x"
f" {gt2:6d} {gt5:6d} {real5:9d} {vis:7d}")
+109
View File
@@ -0,0 +1,109 @@
"""Classify torn edges (posed stretch >5x) by the dominant joint of each endpoint.
usage: fin_bones.py posed.glb"""
import json, struct, sys, math
from pathlib import Path
from collections import Counter
def read_glb(path):
d = Path(path).read_bytes()
length = struct.unpack_from("<I", d, 8)[0]
off = 12; g = None; b = None
while off < length:
clen, ct = struct.unpack_from("<II", d, off); off += 8
if ct == 0x4E4F534A: g = json.loads(d[off:off+clen])
else: b = d[off:off+clen]
off += clen
return g, b
def acc(g, b, i):
a = g["accessors"][i]; bv = g["bufferViews"][a["bufferView"]]
nc = {"SCALAR":1,"VEC2":2,"VEC3":3,"VEC4":4,"MAT4":16}[a["type"]]
fmt = {5121:"B",5123:"H",5125:"I",5126:"f"}[a["componentType"]]
size = struct.calcsize(fmt)*nc; stride = bv.get("byteStride") or size
off = bv.get("byteOffset",0)+a.get("byteOffset",0)
return [struct.unpack_from("<%d%s"%(nc,fmt), b, off+i*stride) for i in range(a["count"])], a["componentType"]
def quat_mat(q):
x,y,z,w = q
return [[1-2*(y*y+z*z),2*(x*y-z*w),2*(x*z+y*w)],
[2*(x*y+z*w),1-2*(x*x+z*z),2*(y*z-x*w)],
[2*(x*z-y*w),2*(y*z+x*w),1-2*(x*x+y*y)]]
def node_local(nd):
t = nd.get("translation",[0,0,0]); r = nd.get("rotation",[0,0,0,1]); s = nd.get("scale",[1,1,1])
R = quat_mat(r)
M = [[R[i][j]*s[j] for j in range(3)]+[t[i]] for i in range(3)]
return M+[[0,0,0,1]]
def matmul(A,B):
return [[sum(A[i][k]*B[k][j] for k in range(4)) for j in range(4)] for i in range(4)]
g, b = read_glb(sys.argv[1])
names = [nd.get("name","") for nd in g["nodes"]]
loc = [node_local(nd) for nd in g["nodes"]]
parent = {}
for i,nd in enumerate(g["nodes"]):
for c in nd.get("children",[]): parent[c] = i
memo = {}
def gm(i):
if i in memo: return memo[i]
m = loc[i] if i not in parent else matmul(gm(parent[i]), loc[i])
memo[i] = m; return m
G = [gm(i) for i in range(len(g["nodes"]))]
skin = g["skins"][0]; joints = skin["joints"]
ibm,_ = acc(g,b,skin["inverseBindMatrices"])
def m16(row): return [[row[c*4+r] for c in range(4)] for r in range(4)]
JM = [matmul(G[joints[j]], m16(ibm[j])) for j in range(len(joints))]
prim = g["meshes"][0]["primitives"][0]
P,_ = acc(g,b,prim["attributes"]["POSITION"])
J,_ = acc(g,b,prim["attributes"]["JOINTS_0"])
W,wt = acc(g,b,prim["attributes"]["WEIGHTS_0"])
wsc = 1.0 if wt==5126 else (1/255 if wt==5121 else 1/65535)
IDX,_ = acc(g,b,prim["indices"])
idx = [i[0] for i in IDX]
def skin_pos(vi):
p = P[vi]; x=y=z=0.0
for j,w in zip(J[vi],W[vi]):
w*=wsc
if w<=0: continue
M=JM[j]
x+=w*(M[0][0]*p[0]+M[0][1]*p[1]+M[0][2]*p[2]+M[0][3])
y+=w*(M[1][0]*p[0]+M[1][1]*p[1]+M[1][2]*p[2]+M[1][3])
z+=w*(M[2][0]*p[0]+M[2][1]*p[1]+M[2][2]*p[2]+M[2][3])
return (x,y,z)
def dom(vi):
best, bw = None, 0
for j,w in zip(J[vi],W[vi]):
w*=wsc
if w>bw: bw, best = w, j
return names[joints[best]] if best is not None else "?"
edges = set()
for t in range(0, len(idx), 3):
a_,b_,c_ = idx[t], idx[t+1], idx[t+2]
for u,v in ((a_,b_),(b_,c_),(c_,a_)):
edges.add((u,v) if u<v else (v,u))
pos_cache = {}
def sp(vi):
if vi not in pos_cache: pos_cache[vi] = skin_pos(vi)
return pos_cache[vi]
pairs = Counter(); n_bad = 0; maxr = 0
for u,v in edges:
rl = math.dist(P[u], P[v])
if rl <= 1e-9: continue
# cheap prefilter: only edges where an endpoint is finger/hand weighted
dn_u, dn_v = dom(u), dom(v)
lu, lv = dn_u.lower(), dn_v.lower()
keys = ("thumb","index","middle","ring","pinky","hand","lower_arm","wrist")
if not any(k in lu or k in lv for k in keys): continue
r = math.dist(sp(u), sp(v)) / rl
if r > 5:
n_bad += 1; maxr = max(maxr, r)
pairs[tuple(sorted((dn_u, dn_v)))] += 1
print(f"edges>5x: {n_bad} max stretch {maxr:.0f}x")
for (a_,b_), n in pairs.most_common(20):
print(f" {n:5d} {a_} <-> {b_}")
+61
View File
@@ -0,0 +1,61 @@
"""Cluster the worst-stretched edges by REST position to name the digit region."""
import sys, os, math
def load_obj(path):
vs, faces = [], []
with open(path) as f:
for line in f:
if line.startswith('v '):
p = line.split()
vs.append((float(p[1]), float(p[2]), float(p[3])))
elif line.startswith('f '):
idx = [int(tok.split('/')[0]) - 1 for tok in line.split()[1:]]
for i in range(1, len(idx) - 1):
faces.append((idx[0], idx[i], idx[i + 1]))
return vs, faces
def edge_set(faces):
es = set()
for a, b, c in faces:
for u, v in ((a, b), (b, c), (c, a)):
es.add((u, v) if u < v else (v, u))
return sorted(es)
def dist(p, q):
return math.sqrt(sum((p[i]-q[i])**2 for i in range(3)))
d = sys.argv[1]
for hand in ('l', 'r'):
rest_v, rest_f = load_obj(os.path.join(d, f'rest_hand_{hand}.obj'))
edges = edge_set(rest_f)
xs = sorted(v[0] for v in rest_v)
print(f'hand_{hand}: rest x range {xs[0]*100:.1f}..{xs[-1]*100:.1f} cm, '
f'y range {min(v[1] for v in rest_v)*100:.1f}..{max(v[1] for v in rest_v)*100:.1f}, '
f'z range {min(v[2] for v in rest_v)*100:.1f}..{max(v[2] for v in rest_v)*100:.1f}')
for pose in ('fist', 'grip'):
v, _ = load_obj(os.path.join(d, f'{pose}_hand_{hand}.obj'))
bad = []
for a, b in edges:
rl = dist(rest_v[a], rest_v[b])
if rl <= 1e-9:
continue
r = dist(v[a], v[b]) / rl
if r > 5:
bad.append((r, a))
# bounding box of bad verts in rest space
pts = [rest_v[a] for _, a in bad]
if not pts:
print(f' {pose}: no >5x edges')
continue
bx = (min(p[0] for p in pts)*100, max(p[0] for p in pts)*100)
by = (min(p[1] for p in pts)*100, max(p[1] for p in pts)*100)
bz = (min(p[2] for p in pts)*100, max(p[2] for p in pts)*100)
cx = sum(p[0] for p in pts)/len(pts)*100
cy = sum(p[1] for p in pts)/len(pts)*100
cz = sum(p[2] for p in pts)/len(pts)*100
print(f' {pose}: {len(bad)} edges>5x rest-bbox x[{bx[0]:.1f},{bx[1]:.1f}] '
f'y[{by[0]:.1f},{by[1]:.1f}] z[{bz[0]:.1f},{bz[1]:.1f}] centroid ({cx:.1f},{cy:.1f},{cz:.1f}) cm')
# z-histogram (palm axis?) to see if it's one digit or spread
zs = sorted(p[2]*100 for p in pts)
q = lambda f: zs[int(f*(len(zs)-1))]
print(f' rest z quartiles: {q(0):.1f} / {q(0.25):.1f} / {q(0.5):.1f} / {q(0.75):.1f} / {q(1):.1f}')
+84
View File
@@ -0,0 +1,84 @@
"""For each finger bone, report how much geometry it actually OWNS (verts where it is the
dominant influence) and where that geometry sits. On a two-finger hand rig the five-finger
bone set is present but several chains own nothing, or several chains share one fused mass.
usage: hand_bone_ownership.py body.glb [side l|r]
"""
import json, struct, sys, math
from pathlib import Path
from collections import defaultdict
FING = ("thumb", "index", "middle", "ring", "pinky")
side = sys.argv[2] if len(sys.argv) > 2 else None
def read_glb(p):
d = Path(p).read_bytes()
length = struct.unpack_from("<I", d, 8)[0]
off = 12
g = b_ = None
while off < length:
clen, ct = struct.unpack_from("<II", d, off)
off += 8
if ct == 0x4E4F534A:
g = json.loads(d[off:off + clen])
else:
b_ = d[off:off + clen]
off += clen
return g, b_
def acc(g, b, i):
a = g["accessors"][i]
bv = g["bufferViews"][a["bufferView"]]
nc = {"SCALAR": 1, "VEC2": 2, "VEC3": 3, "VEC4": 4, "MAT4": 16}[a["type"]]
fmt = {5121: "B", 5123: "H", 5125: "I", 5126: "f"}[a["componentType"]]
size = struct.calcsize(fmt) * nc
stride = bv.get("byteStride") or size
off = bv.get("byteOffset", 0) + a.get("byteOffset", 0)
return [struct.unpack_from("<%d%s" % (nc, fmt), b, off + k * stride)
for k in range(a["count"])], a["componentType"]
g, b = read_glb(sys.argv[1])
names = [nd.get("name", "") for nd in g["nodes"]]
prim = g["meshes"][0]["primitives"][0]
att = prim["attributes"]
skin = next(nd["skin"] for nd in g["nodes"] if nd.get("mesh") == 0 and "skin" in nd)
joints = g["skins"][skin]["joints"]
jname = [names[j] for j in joints]
P, _ = acc(g, b, att["POSITION"])
J, _ = acc(g, b, att["JOINTS_0"])
W, wt = acc(g, b, att["WEIGHTS_0"])
wsc = 1.0 if wt == 5126 else (1 / 255 if wt == 5121 else 1 / 65535)
own = defaultdict(list)
for vi, (p, jrow, wrow) in enumerate(zip(P, J, W)):
best = (0.0, None)
for j, w in zip(jrow, wrow):
w *= wsc
if w > best[0]:
best = (w, jname[j])
if best[1] and any(t in best[1].lower() for t in FING):
if side and not best[1].lower().endswith("_" + side):
continue
own[best[1]].append(p)
print(f"{Path(sys.argv[1]).name} dominant-owner geometry per finger bone"
f"{' (side ' + side + ')' if side else ''}\n")
print(f" {'bone':20s} {'verts':>7s} {'z-centre':>9s} {'z-span':>8s} {'x-centre':>9s}")
for fam in FING:
rows = [(n, v) for n, v in own.items() if fam in n.lower()]
if not rows:
print(f" {fam:20s} {'0':>7s} -- owns no geometry --")
continue
for n in sorted(r[0] for r in rows):
ps = own[n]
zc = sum(p[2] for p in ps) / len(ps) * 100
zs = (max(p[2] for p in ps) - min(p[2] for p in ps)) * 100
xc = sum(p[0] for p in ps) / len(ps) * 100
print(f" {n:20s} {len(ps):7d} {zc:8.1f}cm {zs:7.1f}cm {xc:8.1f}cm")
print()
tot = sum(len(v) for v in own.values())
print(f" total finger-owned verts: {tot}")
+61
View File
@@ -0,0 +1,61 @@
"""Bake a hand-pose JSON into a body GLB by rewriting finger node rest rotations.
IBMs untouched -> mesh deforms to the pose. usage: bake_preview.py body.glb pose.json out.glb"""
import json, struct, sys
from pathlib import Path
body, posef, out = sys.argv[1:4]
data = Path(body).read_bytes()
length = struct.unpack_from("<I", data, 8)[0]
off = 12
chunks = []
gltf = None
while off < length:
clen, ctype = struct.unpack_from("<II", data, off)
off += 8
if ctype == 0x4E4F534A:
gltf = json.loads(data[off:off+clen].decode("utf-8"))
chunks.append([ctype, None])
else:
chunks.append([ctype, data[off:off+clen]])
off += clen
pose = json.loads(Path(posef).read_text())["bones"]
byname = {nd.get("name"): nd for nd in gltf["nodes"]}
canon = None
if len(sys.argv) > 4: # canonical-rest GLB: apply pose as rest-relative delta
cdata = Path(sys.argv[4]).read_bytes()
clen2 = struct.unpack_from("<I", cdata, 12)[0]
cg = json.loads(cdata[20:20+clen2].decode("utf-8"))
canon = {nd.get("name"): nd.get("rotation", [0, 0, 0, 1]) for nd in cg["nodes"]}
def qmul(a, b):
ax, ay, az, aw = a; bx, by, bz, bw = b
return [aw*bx + ax*bw + ay*bz - az*by,
aw*by - ax*bz + ay*bw + az*bx,
aw*bz + ax*by - ay*bx + az*bw,
aw*bw - ax*bx - ay*by - az*bz]
n = 0
for bone, quat in pose.items():
if bone in byname:
if canon is not None:
cr = canon.get(bone, [0, 0, 0, 1])
delta = qmul([-cr[0], -cr[1], -cr[2], cr[3]], quat) # canon_rest^-1 * pose
body_rest = byname[bone].get("rotation", [0, 0, 0, 1])
quat = qmul(body_rest, delta)
byname[bone]["rotation"] = quat
n += 1
# strip animations so nothing overrides the pose
gltf.pop("animations", None)
js = json.dumps(gltf, separators=(",", ":")).encode("utf-8")
js += b" " * ((4 - len(js) % 4) % 4)
body_out = b""
for ctype, payload in chunks:
if ctype == 0x4E4F534A:
payload = js
body_out += struct.pack("<II", len(payload), ctype) + payload
hdr = struct.pack("<III", 0x46546C67, 2, 12 + len(body_out))
Path(out).write_bytes(hdr + body_out)
print(f"baked {n}/{len(pose)} bones -> {out}")
+71
View File
@@ -0,0 +1,71 @@
"""Strip axial roll (twist about the bone axis) from a hand-pose JSON, keeping the curl.
The pose delta vs the body's rest is swing-twist decomposed per bone; the twist factor is
dropped and the pose rebuilt as rest*swing. Thumb chains are left untouched (their roll is
functional opposition). usage: handpose_detwist.py body.glb pose_in.json pose_out.json"""
import json, math, struct, sys
from pathlib import Path
body, pose_in, pose_out = sys.argv[1:4]
data = Path(body).read_bytes()
jlen = struct.unpack_from("<I", data, 12)[0]
gltf = json.loads(data[20:20 + jlen].decode("utf-8"))
nodes = gltf["nodes"]
byname = {n.get("name"): i for i, n in enumerate(nodes)}
def qmul(a, b):
ax, ay, az, aw = a
bx, by, bz, bw = b
return [aw * bx + ax * bw + ay * bz - az * by,
aw * by - ax * bz + ay * bw + az * bx,
aw * bz + ax * by - ay * bx + az * bw,
aw * bw - ax * bx - ay * by - az * bz]
def qinv(q):
return [-q[0], -q[1], -q[2], q[3]]
def qnorm(q):
m = math.sqrt(sum(v * v for v in q))
return [v / m for v in q]
def bone_axis(i):
for c in nodes[i].get("children", []):
t = nodes[c].get("translation")
if t:
m = math.sqrt(sum(v * v for v in t))
if m > 1e-8:
return [v / m for v in t]
return None
pose = json.loads(Path(pose_in).read_text())["bones"]
out = {}
report = []
for name, p in pose.items():
i = byname.get(name)
if i is None or name.startswith("thumb"):
out[name] = p
continue
a = bone_axis(i)
if a is None: # leaf tips: twist is invisible, keep as-is
out[name] = p
continue
r = nodes[i].get("rotation", [0, 0, 0, 1])
d = qmul(qinv(r), p) # delta in the bone's rest-local frame
dot = d[0] * a[0] + d[1] * a[1] + d[2] * a[2]
twist = qnorm([dot * a[0], dot * a[1], dot * a[2], d[3]])
swing = qmul(d, qinv(twist))
out[name] = [round(v, 6) for v in qnorm(qmul(r, swing))]
deg = 2 * math.degrees(math.atan2(abs(dot), abs(d[3])))
if deg > 1.0:
report.append((deg, name))
Path(pose_out).write_text(json.dumps({"bones": out}, indent=1))
report.sort(reverse=True)
print("wrote %s (%d bones, thumbs untouched)" % (pose_out, len(out)))
for deg, name in report[:6]:
print(" stripped %5.1f deg %s" % (deg, name))
+99
View File
@@ -0,0 +1,99 @@
"""Extract a hand pose (40 finger-bone quaternions) from a GLB clip at a chosen frame,
report per-bone curl (deviation from skeleton rest), optionally dump JSON.
usage: python extract_pose.py <glb> <animName> [--frame N | --max-curl] [--dump out.json]
python extract_pose.py <glb> --rest --dump out.json (rest pose itself)
"""
import json, struct, sys, math
from pathlib import Path
FINGER_TOKENS = ("thumb", "index", "middle", "ring", "pinky")
def read_glb(path):
data = Path(path).read_bytes()
magic, ver, length = struct.unpack_from("<III", data, 0)
off = 12
gltf = None; binc = None
while off < length:
clen, ctype = struct.unpack_from("<II", data, off)
off += 8
chunk = data[off:off+clen]
if ctype == 0x4E4F534A: gltf = json.loads(chunk.decode("utf-8"))
elif ctype == 0x004E4942: binc = chunk
off += clen
return gltf, binc
def acc_data(gltf, binc, idx):
acc = gltf["accessors"][idx]
bv = gltf["bufferViews"][acc["bufferView"]]
n = {"SCALAR":1, "VEC3":3, "VEC4":4}[acc["type"]]
off = bv.get("byteOffset", 0) + acc.get("byteOffset", 0)
vals = struct.unpack_from("<%d%s" % (acc["count"]*n, "f"), binc, off)
return [vals[i*n:(i+1)*n] for i in range(acc["count"])]
def qangle(a, b):
d = min(1.0, abs(sum(x*y for x, y in zip(a, b))))
return 2*math.degrees(math.acos(d))
def main():
glb = sys.argv[1]
gltf, binc = read_glb(glb)
nodes = gltf["nodes"]
names = [nd.get("name", f"n{i}") for i, nd in enumerate(nodes)]
finger_idx = {i: names[i] for i, nd in enumerate(nodes)
if any(t in names[i].lower() for t in FINGER_TOKENS)}
rest = {i: tuple(nodes[i].get("rotation", [0, 0, 0, 1])) for i in finger_idx}
dump = None
if "--dump" in sys.argv:
dump = sys.argv[sys.argv.index("--dump")+1]
if "--rest" in sys.argv:
pose = {names[i]: list(rest[i]) for i in finger_idx}
label = "REST"
else:
aname = sys.argv[2]
anim = next(a for a in gltf["animations"] if a.get("name") == aname)
# collect finger rotation samplers
tracks = {}
times_ref = None
for ch in anim["channels"]:
t = ch["target"]
if t.get("path") != "rotation" or t["node"] not in finger_idx: continue
samp = anim["samplers"][ch["sampler"]]
quats = acc_data(gltf, binc, samp["output"])
tracks[t["node"]] = quats
times_ref = acc_data(gltf, binc, samp["input"])
nframes = min(len(q) for q in tracks.values())
if "--max-curl" in sys.argv:
best, bestf = -1, 0
for f in range(nframes):
curl = sum(qangle(tracks[i][f], rest[i]) for i in tracks)
if curl > best: best, bestf = curl, f
frame = bestf
elif "--frame" in sys.argv:
frame = int(sys.argv[sys.argv.index("--frame")+1])
else:
frame = 0
t = times_ref[min(frame, len(times_ref)-1)][0] if times_ref else 0
pose = {names[i]: list(tracks[i][frame]) for i in tracks}
# fill missing finger bones from rest
for i in finger_idx:
pose.setdefault(names[i], list(rest[i]))
label = f"{aname} frame {frame} (t={t:.2f}s)"
# curl report per finger chain (sum of deviations from rest), L hand only for brevity
print(f"pose: {label} ({len(pose)} bones)")
for hand in ("_l", "_r"):
parts = []
for fing in ("thumb", "index", "middle", "ring", "pinky"):
tot = sum(qangle(pose[n], rest[i]) for i, n in finger_idx.items()
if n.startswith(fing) and n.endswith(hand))
parts.append(f"{fing} {tot:.0f}")
print(f" {hand}: curl-vs-rest deg " + " ".join(parts))
if dump:
Path(dump).write_text(json.dumps({"source": f"{Path(glb).name}:{label}",
"bones": pose}, indent=1))
print("dumped ->", dump)
main()
+59
View File
@@ -0,0 +1,59 @@
"""Clay-render each OBJ in a directory, 3 angles, framed on its bbox.
Only files matching *_hand_*.obj are picked up, and outdir MUST be absolute a relative
one makes Blender write outside the tree and silently produce nothing.
usage: blender --background --factory-startup --python render_objs.py -- objdir outdir [res] [dist]
res square render resolution in px (default 900)
dist camera distance as a multiple of the mesh radius (default 2.6; lower = tighter)"""
import bpy, sys, math, glob, os
from mathutils import Vector
argv = sys.argv[sys.argv.index("--") + 1:]
objdir, outdir = argv[0], argv[1]
RES = int(argv[2]) if len(argv) > 2 else 900
DIST = float(argv[3]) if len(argv) > 3 else 2.6
bpy.ops.wm.read_factory_settings(use_empty=True)
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
mat = bpy.data.materials.new("Clay")
mat.use_nodes = True
bsdf = mat.node_tree.nodes["Principled BSDF"]
bsdf.inputs["Base Color"].default_value = (0.72, 0.55, 0.45, 1.0)
bsdf.inputs["Roughness"].default_value = 0.65
for rot, energy in (((50, 0, 30), 3.0), ((-40, 0, -140), 1.2), ((10, 0, 180), 0.8)):
sun = bpy.data.objects.new("Sun", bpy.data.lights.new("Sun", 'SUN'))
sun.data.energy = energy
sun.rotation_euler = tuple(math.radians(a) for a in rot)
scn.collection.objects.link(sun)
cam = bpy.data.objects.new("Cam", bpy.data.cameras.new("Cam"))
cam.data.lens = 60
scn.collection.objects.link(cam)
scn.camera = cam
for path in sorted(glob.glob(os.path.join(objdir, "*_hand_*.obj"))):
bpy.ops.wm.obj_import(filepath=path)
obj = bpy.context.selected_objects[0]
obj.data.materials.clear()
obj.data.materials.append(mat)
for p in obj.data.polygons: p.use_smooth = True
bb = [obj.matrix_world @ Vector(c) for c in obj.bound_box]
ctr = sum(bb, Vector()) / 8
rad = max((v - ctr).length for v in bb)
tag = os.path.splitext(os.path.basename(path))[0]
# OBJ import is -Z forward +Y up by default: gltf Y-up mesh arrives Z-up in Blender
for label, direction in (("palm", Vector((0, -1, -0.25))),
("back", Vector((0, 1, 0.35))),
("side", Vector((-1, -0.3, 0.1)))):
d = direction.normalized()
cam.location = ctr - d * (rad * DIST)
cam.rotation_euler = d.to_track_quat('-Z', 'Y').to_euler()
scn.render.filepath = os.path.join(outdir, f"{tag}_{label}.png")
bpy.ops.render.render(write_still=True)
print("[objr] wrote", scn.render.filepath)
bpy.data.objects.remove(obj, do_unlink=True)
print("[objr] DONE")
+82
View File
@@ -0,0 +1,82 @@
"""Scan GLBs: list finger joints and which animations have live (non-frozen) finger rotation tracks."""
import json, struct, sys, math
from pathlib import Path
FINGER_TOKENS = ("thumb", "index", "middle", "ring", "pinky", "finger")
def read_glb(path):
data = Path(path).read_bytes()
magic, ver, length = struct.unpack_from("<III", data, 0)
assert magic == 0x46546C67, "not glb"
off = 12
gltf = None
bin_chunk = None
while off < length:
clen, ctype = struct.unpack_from("<II", data, off)
off += 8
chunk = data[off:off+clen]
if ctype == 0x4E4F534A:
gltf = json.loads(chunk.decode("utf-8"))
elif ctype == 0x004E4942:
bin_chunk = chunk
off += clen
return gltf, bin_chunk
def accessor_data(gltf, binc, idx):
acc = gltf["accessors"][idx]
bv = gltf["bufferViews"][acc["bufferView"]]
comp = {5126: ("f", 4)}[acc["componentType"]]
n = {"SCALAR":1, "VEC3":3, "VEC4":4}[acc["type"]]
off = bv.get("byteOffset", 0) + acc.get("byteOffset", 0)
count = acc["count"]
vals = struct.unpack_from("<%d%s" % (count*n, comp[0]), binc, off)
return [vals[i*n:(i+1)*n] for i in range(count)]
def scan(path, verbose_joints=False):
gltf, binc = read_glb(path)
nodes = gltf.get("nodes", [])
names = [nd.get("name", f"node{i}") for i, nd in enumerate(nodes)]
# joints from skins
joint_set = set()
for skin in gltf.get("skins", []):
joint_set.update(skin.get("joints", []))
fingers = sorted(n for i in joint_set for n in [names[i]] if any(t in n.lower() for t in FINGER_TOKENS))
print(f"\n== {Path(path).name} ==")
print(f"joints: {len(joint_set)}, finger joints: {len(fingers)}")
if verbose_joints:
for n in sorted(names[i] for i in joint_set):
print(" ", n)
elif fingers:
print(" finger joints:", ", ".join(fingers))
for anim in gltf.get("animations", []):
aname = anim.get("name", "?")
live, frozen = [], []
for ch in anim.get("channels", []):
tgt = ch["target"]
if tgt.get("path") != "rotation":
continue
nname = names[tgt["node"]]
if not any(t in nname.lower() for t in FINGER_TOKENS):
continue
samp = anim["samplers"][ch["sampler"]]
quats = accessor_data(gltf, binc, samp["output"])
# measure max angular deviation from first frame
q0 = quats[0]
maxdot = 1.0
for q in quats[1:]:
d = abs(sum(a*b for a, b in zip(q0, q)))
maxdot = min(maxdot, min(d, 1.0))
ang = 2*math.degrees(math.acos(maxdot))
(live if ang > 2.0 else frozen).append((nname, ang))
total = len(live) + len(frozen)
if total:
print(f" anim '{aname}': {total} finger rot tracks, {len(live)} live (>2deg), {len(frozen)} frozen")
if live:
top = sorted(live, key=lambda x: -x[1])[:4]
print(" top movers:", ", ".join(f"{n} {a:.0f}deg" for n, a in top))
else:
print(f" anim '{aname}': NO finger tracks")
if __name__ == "__main__":
for p in sys.argv[1:]:
scan(p, verbose_joints="--joints" in sys.argv)

Some files were not shown because too many files have changed in this diff Show More