Merge branch 'main' of https://tinqs.com/tinqs/animation
@@ -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
|
||||||
|
|
||||||
|
- ~70–100% 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 (exp01–exp05) 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 **4–20 cm** in fist/grip on BOTH hands (~25–60 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.0–10.3 cm (thumb 15.3),
|
||||||
|
relaxed_l 1.7–2.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.5–2 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.
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
__pycache__/
|
__pycache__/
|
||||||
*.pyc
|
*.pyc
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
.env
|
||||||
|
|
||||||
# Working-lane scratch — see .agents/rules/working-files.md
|
# Working-lane scratch — see .agents/rules/working-files.md
|
||||||
# Regenerable by re-running the lane's NN_*.py recipe from its pinned master.
|
# Regenerable by re-running the lane's NN_*.py recipe from its pinned master.
|
||||||
@@ -14,6 +15,16 @@ __pycache__/
|
|||||||
**/dbg_*/
|
**/dbg_*/
|
||||||
**/probe_*/
|
**/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.
|
# 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
|
# 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,
|
# ~883k-vertex mesh (48_headsafe, seamed, reatlased, 52_transplant, 55_fix1/2/3,
|
||||||
|
|||||||
@@ -251,6 +251,7 @@ that .gitignore rule anticipates.
|
|||||||
| file | sha256 | date | built by | what it is |
|
| 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/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
|
### `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
|
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
|
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.
|
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.
|
||||||
|
|||||||
|
After Width: | Height: | Size: 713 KiB |
|
After Width: | Height: | Size: 415 KiB |
@@ -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`.
|
||||||
@@ -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")
|
||||||
@@ -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 |
|
| `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 |
|
| `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 |
|
| `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
|
## 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 |
|
| surviving shells | 1 |
|
||||||
| removed geometry's extent | z 0.455–0.755 of height (bust + briefs bands only) |
|
| removed geometry's extent | z 0.455–0.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 (10–20 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
|
## Open
|
||||||
|
|
||||||
- **The holes are not filled, by request.** Filling is a separate and harder decision:
|
- **The hip-side shelf bulges are a known consequence, not a bug to chase further.** Where
|
||||||
the crotch/gusset history in `work/lena/06*.py` is what happens when a membrane spans a
|
the vine crossed the inguinal crease there was never skin underneath, and "take the
|
||||||
wide footprint — it flattens the anatomy it spans, which is why the nude lane only ever
|
distance and angle between the two sides" — the membrane — spans its anchors by
|
||||||
faired a narrow rim band. Whatever fills these has to rebuild bust and crotch anatomy,
|
construction. Any method anchored at the rims produces the same taut bridge (harmonic,
|
||||||
not just span them.
|
bi-harmonic, and flow all agree; the maximum principle *protects* a two-sided span).
|
||||||
- The base-colour map still carries the leaves and their baked contact shadows. The rim of
|
Killing them means SCULPTING the crease through the strip — invented anatomy, its own
|
||||||
each hole is skin painted with leaf shadow, so it reads darker than her surrounding tone.
|
decision, its own stage. Ten runs of detector archaeology confirmed there is nothing
|
||||||
- **The cut is unrigged**, because it was authored on the pristine original. It transfers
|
foreign left there to remove.
|
||||||
to the shipped body for free: `01_graft.py` moved the mesh 0.000000 mm, so
|
- **The bust holes are not filled, by request** (v01 deliverable; v02 fills the crotch
|
||||||
`lena_leafbikini_base_v01` carries the *same* 1,029,360 vertices in the same order and
|
only). Whatever fills the bust has to rebuild breast anatomy, not just span it — see
|
||||||
`leaf_mask.npz` indexes it directly. Cutting the shipped GLB is running stage 05 with a
|
`tools/make_lena_nude_body.py` for how the nude lane sculpted hers procedurally.
|
||||||
different first argument — but the result is a new ship folder, never an edit to the
|
- The base-colour map still carries the leaves and their baked contact shadows. Around the
|
||||||
frozen one.
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 356 KiB |
|
After Width: | Height: | Size: 402 KiB |
|
After Width: | Height: | Size: 330 KiB |
|
After Width: | Height: | Size: 378 KiB |
|
After Width: | Height: | Size: 367 KiB |
|
After Width: | Height: | Size: 260 KiB |
|
After Width: | Height: | Size: 362 KiB |
|
After Width: | Height: | Size: 385 KiB |
|
After Width: | Height: | Size: 322 KiB |
|
After Width: | Height: | Size: 336 KiB |
|
After Width: | Height: | Size: 363 KiB |
|
After Width: | Height: | Size: 231 KiB |
|
After Width: | Height: | Size: 328 KiB |
|
After Width: | Height: | Size: 372 KiB |
|
After Width: | Height: | Size: 301 KiB |
@@ -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
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 143 KiB |
|
After Width: | Height: | Size: 267 KiB |
|
After Width: | Height: | Size: 173 KiB |
|
After Width: | Height: | Size: 897 KiB |
|
After Width: | Height: | Size: 924 KiB |
|
After Width: | Height: | Size: 734 KiB |
|
After Width: | Height: | Size: 911 KiB |
|
After Width: | Height: | Size: 959 KiB |
|
After Width: | Height: | Size: 761 KiB |
@@ -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
|
`{"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
|
**canonical Quaternius skeleton**, which is exactly Godot bone-pose space for these
|
||||||
bodies. Apply directly:
|
bodies.
|
||||||
|
|
||||||
```csharp
|
|
||||||
skeleton.SetBonePoseRotation(skeleton.FindBone(name), new Quaternion(x, y, z, w));
|
|
||||||
```
|
|
||||||
|
|
||||||
40 bones per pose, including `*_04_leaf_*` tip bones. Some bodies lack the leaf
|
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
|
bones — **skip bones `FindBone` returns -1 for**, never error.
|
||||||
(Mako) poses must be applied rest-relative; the runtime layer simply excludes those
|
|
||||||
bodies instead.
|
**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
|
## Poses
|
||||||
|
|
||||||
| File | Source (Kevin packs) | Runtime status |
|
| 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_flat.json` | **= canonical REST** (see below) | **SHIPPED** — verified in-engine on Mako 2026-08-18 |
|
||||||
| `pose_relaxed.json` | `HandWave01` f0 | staged |
|
| `pose_relaxed.json` | `HandWave01` f0 (~10° off rest) | staged |
|
||||||
| `pose_fist.json` | `AttackPunch01_R/L` f7, merged | blocked on Lena finger-weight repair (fist/grip shred on exp01–exp04) |
|
| `pose_fist.json` | `AttackPunch01_R/L` f7, merged (~61° off rest) | blocked on finger-weight repair (shreds on exp01–exp05 and on Mako) |
|
||||||
| `pose_grip.json` | `CombatIdle1H01` f0, both hands | blocked, same repair |
|
| `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)
|
## Verification (before shipping a pose)
|
||||||
|
|
||||||
@@ -34,5 +64,22 @@ bodies instead.
|
|||||||
- `tools/skin_displacement_check.py posed.glb original.glb` — Godot-exact LBS travel;
|
- `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
|
cm-scale = sane, m-scale = broken. **Blind to fin tearing** — pair it with the
|
||||||
edge-stretch check (`tools/edge_stretch.py`).
|
edge-stretch check (`tools/edge_stretch.py`).
|
||||||
- `tools/handpose_skin_to_obj.py` + `tools/handpose_render_objs.py` — the only honest
|
- `tools/handpose_skin_to_obj.py` + `tools/handpose_render_objs.py` — clay render. Never
|
||||||
visual check. Never judge by importing a baked-pose GLB into Blender (false shards).
|
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 ~1–2.4 cm (benign, sub-pixel in
|
||||||
|
engine) while fist/grip reach 10–15 cm (real, visible needles).
|
||||||
|
|||||||
@@ -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
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 exp01–exp05 (torn-edge counts 800–3300
|
||||||
|
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.1–10mm | 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 3–4cm — hairline spikes, sub-pixel in screenshots.
|
||||||
|
- **mesh** fingertip travel: fist >= 2.5 cm/finger, grip ~2 cm, relaxed 0.5–2 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.6–4.9 |
|
||||||
|
| grip_l | 1.82 | 1.46 | 0 | 0 | 1.3mm | 3.5–3.7 |
|
||||||
|
| flat_r | 1.35 | 1.35 | 0 | 0 | 1.5mm | 0.1 |
|
||||||
|
| relaxed_r | 1.66 | 1.45 | 0 | 0 | 2.6mm | 1.1–1.2 |
|
||||||
|
| fist_r | 1.77 | 1.54 | 0 | 0 | 3.2mm | 6.0–6.2 |
|
||||||
|
| grip_r | 2.23 | 1.58 | 0 | 0 | 3.1mm | 4.5–4.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 ~4–5cm 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 ~1–2
|
||||||
|
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.5k–9.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.33–0.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.
|
||||||
@@ -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)}")
|
||||||
@@ -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')
|
||||||
@@ -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}")
|
||||||
@@ -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_}")
|
||||||
@@ -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}')
|
||||||
@@ -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}")
|
||||||
@@ -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}")
|
||||||
@@ -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))
|
||||||
@@ -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()
|
||||||
@@ -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")
|
||||||
@@ -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)
|
||||||