Skip to content

Contact IK stage pipeline: unify frame ordering, hoist constraints, terminal limb IK - #380

Open
arkavo-com wants to merge 70 commits into
mainfrom
fix/stagger-collision
Open

Contact IK stage pipeline: unify frame ordering, hoist constraints, terminal limb IK#380
arkavo-com wants to merge 70 commits into
mainfrom
fix/stagger-collision

Conversation

@arkavo-com

Copy link
Copy Markdown
Contributor

Replaces the two competing animation frame orderings — AnimationLayerCompositor layer priorities and CrowdFrameStepper's hand-rolled phase list — with one named stage pipeline, hoists VRM node-constraint solving onto the final pose, and makes limb IK terminal so it re-solves against a finalised scene root.

Design: docs/superpowers/specs/2026-08-04-contact-ik-pipeline-design.md
Plan: docs/superpowers/plans/2026-08-04-contact-ik-pipeline.md

The pipeline

S0 sample     clip sampling, root motion
S2a place     scripted placement (absolute root write)
S1 compose    postural yield
S2b displace  stagger shove (additive) → exits with root/hips final, transforms refreshed
S3 limbSolve  limb IK → capture step → arm counterbalance
S4 constrain  ConstraintSolver on the final pose

CrowdFrameStepper becomes the scheduler; ordering truth lives in PoseStage. Single-avatar is the n=1 case.

S2 runs in two beats, deliberately. Placement must precede S1 because the postural lean measures its own trunk endpoints in world space; the shove must follow S1 because its penetration signal is the lean-relieved one. One writer, two beats — collapsing them changes the depth signal.

Behaviour changes

  • C2 — constraints now resolve on the posed skeleton rather than the raw animation, so twist bones follow the IK-modified wrist. AnimationPlayer.solvesConstraints defaults true, preserving the contract for the ~10 direct callers (validators, benchmarks, isolation suites); the pipeline sets it false.
  • C3 — limb IK is terminal. IKLayer leaves the compositor and runs in S3.
  • C4 — one redundant propagation removed. This is a structural cleanup, not a perf win: idle crowd members pay one extra hierarchy walk per frame (table in the commit body). The design's "five calls down to two" target was unreachable; the honest floor is seven, each with a verified downstream reader.

Bugfix found along the way (1a05396)

IKLayer.solveIKForLeg assigned TwoBoneIKSolver.solve's world-frame rotation directly as a bone-local rotation. VRM legs rest at roughly −Y, so the aim hit aimAt's antiparallel branch and produced a ~174.5° misrotation even for a trivial identity target.

All 24 existing IKLayerTests stayed green through it, because none applied solve output to a rig — they test TwoBoneIKSolver on synthetic input and IKLayer's metadata. Now pinned by IKLayerRoundTripTests (four cases asserting ankle world position, including a beyond-reach clamp). TwoBoneIKSolver's documentation, which called its output "local rotation for root joint", was the proximate cause and is corrected.

This is a pre-existing defect independent of the refactor and may deserve its own release note.

How the crowd path is gated

Baseline pose sequences were captured from unmodified code and committed before any source change (b160e57 touches zero Sources/ files; 1068da2 touches only Sources/). Every later commit compares against that committed fixture. Serialization is String(Float.bitPattern); regeneration sits behind PIPELINE_BASELINE_GENERATE=1 + XCTSkip, so a normal run cannot overwrite its own oracle. The fixture was never regenerated — it is still last touched by b160e57.

Matrix: {stagger-off, stagger-on-pre-contact, stagger-on-post-contact} × {single, crowd}, 60-frame sequences.

Invariants

Enforced by signature or assertion: cross-avatar reads come only from a FrozenSnapshot of the partner's last-committed frame; root and hips are written only in S2 (debug guard); S0 asserts it never silently samples root motion.

Documented but unenforced: every direct-apply bone has a guaranteed every-frame upstream writer; nothing before S4 reads constraint output.

Known gaps

  • The test fixture authors zero VRM node constraints, so the byte-identity gate is structurally incapable of detecting a C2 regression. C2 rests on the synthetic-constraint tests in ConstraintHoistTests. A rig with authored VRMC_node_constraint twist bones would give real end-to-end coverage.
  • AnimationLayerCompositor.addIKLayer is deprecated: IKLayer now returns absolute bone-local rotations, which the compositor's basePose * delta composition would double-apply on rigs with non-identity leg bind rotations.
  • CrowdFrameStepper permanently sets solvesConstraints = false on caller-owned players and avatarsForCamera hands them back. Documented ownership transfer, not detectable at runtime.

Test status

Full suite: 1944 tests, 185 skipped, 2 failures. Both failures are assertions in SpringBoneStressPosePenetrationTests.testLookUp_augmented_noForeheadPenetration, which fails on main independent of this branch (augmented head penetration 0.0209 m vs coarse 0.0065 m). Nothing here can reach the spring-bone augmentor. Worth investigating separately — it is the invariant the #309/#321 augmentation work exists to protect.

Also unrelated and worth its own look: BalanceModelTests.testEvaluate_isReadOnlyAndDeterministic flakes with ~1e-9 differences. "Float noise" does not explain it — a pure function on an unmutated model should be bit-identical, so something is mutating the rig between calls.

🤖 Generated with Claude Code

https://claude.ai/code/session_016mm7Pvu34Ks8qdUZKDBSUu

Comment thread Sources/VRMMetalKit/Crowd/CrowdFrameStepper.swift Outdated
@arkavo-com

Copy link
Copy Markdown
Contributor Author

Folded in: the two red tests

1. testLookUp_augmented_noForeheadPenetration — fixed (e96467d, a89b929)

Bisected to PR #378, then isolated to a single function by disabling each new collider group one at a time — only SpringBoneBreastCollider.computeShoulderColliders mattered.

Mechanism, and it is not what the test name says. The worst penetrator was J_Sec_Hair4_03_end at (0.020, 1.294, 0.052) — a front hair strand sunk 21 mm into the décolletage, not the forehead. The two mesh-fitted shoulder spheres sat at x = ±0.117 with radius 0.081, so their medial edges reached x = ±0.036, leaving a ~7 cm gap across the front midline. Front hair was funnelled medially into that gap, where no collider exists.

The proximity filter |wp - upperOrigin| < armLen * 0.5 is a radius filter with no directional component, so collarbone-side vertices were included and dragged the fit inboard. The fix excludes vertices medial of the socket along the shoulder→upperArm axis before fitting.

worst 0.020938784 m → 0.0 m. #377's own suites stay green (27/27), so the shoulder coverage it added is intact. Baseline deltas are confined to the J_Sec_Hair4_* chain — the exact strand that was penetrating — max 23–39 mm per tier, mean 3–4 mm, 8–10 of 46 joints over 5 mm. Comparable to #377's ~22 mm precedent.

An earlier attempt to add coverage instead was built and reverted (archived on tag neck-attempt-archive). The pocket sits 80.6 mm from the neck axis, so a neck-anchored collider needed a 108 mm radius to reach it — against an authored neck collider of 43.1 mm and a mesh-measured neck of p95 47.2 mm. Every test passed and it visibly splayed the avatar's side hair horizontally. Removing the funnel beats chasing it with coverage.

2. BalanceModelTests.testEvaluate_isReadOnlyAndDeterministic — not reproducible, no fix

8 runs, zero failures: 5 isolated, plus 3 full parallel suites in which it executed every time. Not fixing what I cannot reproduce. The observation that "float noise" cannot explain nondeterminism in a pure function over an unmutated model still stands as worth investigating if it recurs — a captured diff would settle it.

Also added: a sizing guard (38f615e)

The regression was an instance of a pattern, not an incident. SpringBoneBoneGeometry.limbCapsule computes max(authoredHint, length * radiusFraction) — the rig's authored radius is a floor and never a ceiling, so any fraction constant that overshoots wins silently on every rig. Measured against authored colliders on two fixtures:

collider A U
handSphere 3.80× 3.02×
mesh-fitted shoulder (before this fix) 1.88× 1.76×
mesh-fitted shoulder (after) 1.66× 1.34×
augmentor shoulder sphere 1.53× 1.20×
lowerArm capsule 1.50× 1.28×

The families that do track the rig derive their size from an authored radius — headSkullSphere (1.0 × rHead), headBrowCapsule (0.5 × rHead), breastTwinSphere (a copy) — landing at exactly 1.00× / 0.50× / 1.00× on both bodies. That is the grounded pattern the others should follow.

ColliderDimensionAudit asserts synthetic ≤ 1.25× authored on the anchor bone, with known offenders pinned in a documented table so the debt cannot grow. An entry that drops below the ceiling on every rig fails as stale, so fixes retire their own exemptions. Verified non-vacuous.

The hand sphere at 3.0–3.8× is untouched and shipping since #321 — worth its own issue.

Test status

Full suite: 1945 tests, 185 skipped, 0 failures.

arkavo-com added a commit that referenced this pull request Aug 5, 2026
CrowdFrameStepper disabled `AnimationPlayer.solvesConstraints` on
caller-supplied players and never restored it. Since `avatarsForCamera`
hands those same instances back, a host that later drove one directly
silently lost twist/aim constraint solving with no runtime signal.

It now records each player's prior value and restores it in `deinit`, so
the flag is borrowed for the stepper's lifetime rather than taken. Restore
returns the value found, so a player that already had solving disabled
stays disabled.

Raised three times before it was fixed — twice in task review, then by
Gitar on PR #380 — each time answered with documentation rather than a
restore. Documentation does not help a host that never reads it.

PlayerOwnershipTests covers both directions; verified non-vacuous by
disabling the restore, which fails the deinit test.

Full suite: 1947 tests, 189 skipped, 0 failures.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@arkavo-com

Copy link
Copy Markdown
Contributor Author

Addressed the inline review comment on CrowdFrameStepper.swift:105 in 830867a.

The stepper now records each caller-supplied player's prior solvesConstraints and restores it in deinit, so the flag is borrowed for the stepper's lifetime rather than taken permanently. Restore returns the value found, so a player that already had solving disabled stays disabled — it does not force true.

PlayerOwnershipTests covers both directions. Verified non-vacuous: disabling the restore fails the deinit test.

Worth recording that this exact issue was raised three times — twice during task review, then here — and each time I answered it with a doc comment instead of a restore. Documentation doesn't help a host that never reads it. Good catch.

CI is green (Static checks, Compile, Gitar, both Xcode Cloud test actions) and the full local suite is 1947 tests / 0 failures.

@arkavo-com

Copy link
Copy Markdown
Contributor Author

Pushed: two further sub-projects (830867af1e9b7c, 34 commits)

This PR now also carries the #381 work built on the pipeline:

Skin-mesh coverage oracle (test-only)

Penetration measured against the actual skinned body mesh instead of hand-authored capsules: closest-point-on-triangle with angle-weighted pseudonormal classification (Bærentzen–Aanæs), spatial grid with exhaustive-fallback (no distance cutoff at any layer), SKIN-only body predicate with pinned per-fixture inventories. Fixtures AvatarSample_U and AvatarSample_M bundled — licence fields verified from the files (allowRedistribution=true).

Opt-in cloth-collision fidelity — VRMLoadingOptions.fitClothCollisionToMesh (default false)

Two halves, both required: (A) each spring joint's collision radius floored at the measured half-extent of the mesh skinned to it (VRoid authors hitRadius at median 3.7 mm for cards centimetres wide; M's hair median rises to 18.6 mm; authored values never mutated — effectiveHitRadius sits beside them), and (B) segment collision: three new Metal kernels collide the parent→child span, parent endpoint from a per-substep immutable snapshot, corrections single-writer to the child, t-scaled on interior spans and full-vector on anchor spans. The shipped collide kernels are textually untouched.

Flag-off is certified unchanged at three strengths: exact effective-value identity, a bit-exact 90-frame GPU baseline that survived the metallib recompile with zero divergence, and the per-tier regression CSVs (≤1 mm envelope).

Measured outcome on AvatarSample_M (skin-mesh oracle): worst Hair penetration 21.3 → 8.4 mm (armsCrossed), 26.6 → 8.4 mm (armsAtSides); each half alone is insufficient (11.1 / 16.0 mm), gated by an absolute <10 mm bound both halves are load-bearing for. Skirt was already clean and is guarded clean-stays-clean. GPU-timed spring-compute cost: 1.23–1.60× across M/U × ultra/extreme, against a ≤3× budget. Render evidence reviewed frame-by-frame (hair deflects around the raised forearm; hands rest on, not in, the skirt).

Final whole-branch review ran across both sub-projects; all findings fixed or batched into #383. Incidental discoveries filed: #382 (pre-existing phantom-velocity coast in applyVelocityCorrection, both copies noted).

Full suite on the pushed tree: 1990 tests, 190 skipped, 0 failures.

Comment thread Sources/VRMMetalKit/SpringBoneJointRadiusMeasure.swift Outdated
Comment thread Sources/VRMMetalKit/SpringBoneJointRadiusMeasure.swift
arkavo-com added a commit that referenced this pull request Aug 6, 2026
- CrowdFrameStepper: correct stale "flag is never restored" doc
  (r3721137996) — 830867a already added the borrow/restore, but the
  adjacent doc comment wasn't updated to match; also documents that
  PipelineAvatar's permanent mutation is the direct-construction path,
  with CrowdFrameStepper's borrow/restore as the managed alternative.
- SpringBoneJointRadiusMeasure: guard inverseBindMatrices indexing with
  an identity fallback for malformed skins (r3729089819), matching the
  loader's own absent-accessor behavior; no behavior change for
  well-formed skins.
- SpringBoneJointRadiusMeasure: document the single-instance assumption
  in the first-node mesh lookup (r3729089919) — acceptable-by-design for
  VRM avatars, multi-instance support intentionally out of scope.

Tests: ClothJointRadiusAuditTests, SpringBoneBitBaselineTests,
PlayerOwnershipTests — 9 tests, 0 failures, bit baseline unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@gitar-bot

gitar-bot Bot commented Aug 6, 2026

Copy link
Copy Markdown
Code Review ✅ Approved 3 resolved / 3 findings

Contact IK stage pipeline unifies frame ordering, hoists constraint solving onto final poses, and makes limb IK terminal. Addresses solvesConstraints configuration and inverseBindMatrices indexing safety, with no open findings.

✅ 3 resolved
Edge Case: solvesConstraints permanently disabled on caller-owned players

📄 Sources/VRMMetalKit/Crowd/CrowdFrameStepper.swift:98-105 📄 Sources/VRMMetalKit/Crowd/CrowdFrameStepper.swift:157 📄 Sources/VRMMetalKit/Animation/Pipeline/PipelineAvatar.swift:59-73
Both CrowdFrameStepper.init (line 157) and PipelineAvatar.init (line 87) set player.solvesConstraints = false and never restore it. Because avatarsForCamera hands the same AnimationPlayer instances back to the host, any caller that later drives one of those players directly (outside the pipeline) will silently get a constraint-unsolved pose with no runtime signal. This is documented as an intentional ownership transfer, but it mutates externally-owned reference objects irreversibly; consider snapshotting/restoring the prior value on teardown or exposing an explicit re-enable path so reused players don't silently lose twist/aim constraint solving.

Edge Case: Unguarded inverseBindMatrices indexing can crash on malformed skins

📄 Sources/VRMMetalKit/SpringBoneJointRadiusMeasure.swift:66
In SpringBoneJointRadiusMeasure.measure, skin.joints.indices.map { skin.joints[$0].worldMatrix * skin.inverseBindMatrices[$0] } indexes inverseBindMatrices by joint index. The loader fills identity matrices with count==joints.count when the glTF accessor is absent or errors, so normal files are safe, but a malformed accessor that decodes fewer matrices than joints would cause an out-of-bounds crash at load time (only when fitClothCollisionToMesh is on). RenderSafetyTests already guards this exact case defensively (jointIndex < skin.inverseBindMatrices.count); mirror that here. Note this matches an existing unguarded pattern in SpringBoneBreastCollider.swift.

Edge Case: Mesh-to-node lookup ignores instanced meshes

📄 Sources/VRMMetalKit/SpringBoneJointRadiusMeasure.swift:62-69
model.nodes.first(where: { $0.mesh == mi }) resolves only the first node referencing a mesh, then uses that node's skin/palette for every vertex of the mesh. If the same mesh is instanced by multiple nodes with different skins, the measurement attributes all vertices to the first instance's skin, producing wrong world positions and radii. VRM rarely instances skinned meshes so impact is low, but the assumption is silent. Consider iterating nodes (not meshes) or asserting single-node ownership.

Was this helpful? React with 👍 / 👎 | Gitar

arkavo-com and others added 16 commits August 23, 2026 18:34
Carries the two scratch diagnostics from the #378 branch onto the
stagger + collision work. Both self-skip off-machine: the body-mesh
diagnostic locates hair penetration into the skinned skin mesh grouped
by dominant bone; the host-avatar one measures hair-vs-shoulder against
the synthetic shoulder spheres using local GoM fixtures.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Promotes CrowdFrameStepper's discovered phase ordering to the single
S0-S6 stage pipeline: constraint solve hoists to S4 (final pose), limb
IK leaves the compositor and becomes terminal at S3, S2 becomes sole
writer of root/hips. Single-avatar becomes the n=1 case.

Records five invariants: frozen-snapshot cross-avatar reads enforced by
stage signature, the direct-apply every-frame-writer obligation, S2-only
root/hips writes, no pre-S4 reads of constraint output, and priorities
scoped to intra-S1 composition.

Four gated commits (C1 extraction, C2 hoist, C3 re-solve, C4 propagation
reduction) with golden-sequence rather than golden-frame gates, since
counterbalance decay and shove offset diverge only over frames.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Ten tasks across the four gated commits from the design. Resolves the S2
displacement conflict rule left open in the spec: one absolute request per
avatar per frame, all others additive in insertion order.

Two findings from planning changed the shape:

S2 runs in two beats and cannot be made contiguous. Placement must precede
S1 (postural lean measures world-space trunk endpoints), and the shove must
follow it (its penetration signal is documented lean-relieved). Collapsing
them changes the depth signal, so it is not code motion.

C3's crowd gate may be vacuous. The capture-step controller already runs
after the shove in the existing phase block, so the crowd fixture may pass
unchanged; IKLayer, which is the path the priority-inversion finding is
actually about, is not in the crowd path at all. Task 8 opens with a step
that determines which path carries the bug before any change lands.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The subagent workspace holds the ledger, task briefs, implementer reports and
review packages. It is coordination scratch, not source; a task-3 report file
was committed by an implementer before this ignore existed.
…raction

Captures the 3x2 matrix (stagger-off / pre-contact / post-contact x
single/crowd) over 60-frame sequences against the CURRENT, unmodified
CrowdFrameStepper implementation. The fixtures are the byte-identity
oracle the S0-S3 stage extraction must match exactly.

Replaces the brief's testMatrixIsDeterministicAcrossRuns (which only
proves the post-refactor code agrees with itself) with
testMatrixMatchesCommittedBaseline, which compares a fresh capture
against this committed pre-refactor baseline. PipelineBaselineFixture
serializes PoseSample as per-component Float bit patterns so the
round trip is exact.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016mm7Pvu34Ks8qdUZKDBSUu
…s scheduler

Introduces PipelineAvatar (per-avatar mutable pipeline state) and PoseStage
(S0 sample, S1 compose, S2 place/displace, S3 limbSolve) in
Sources/VRMMetalKit/Animation/Pipeline/. CrowdFrameStepper.step() now
builds a FrozenSnapshot per frame and drives every avatar through the
five stage calls in the same order the old inline Phase 0a-0f block ran:
sample -> place -> compose -> displace -> limbSolve.

Deletes the now-dead staggerSolvers/captureSteppers/staggerActive/
posturalLayers/armLayers dictionaries and the private
nearestPartnerTorso(of:torsos:) helper (superseded by
FrozenSnapshot.nearestPartnerTorso). Public accessors
(posturalLayer/staggerSolver/captureStepController/armCounterbalanceLayer)
now read from pipelineAvatars. FrozenSnapshot's indices are built as
avatars.map { $0.index }, matching avatars' own order so
nearestPartnerTorso's first-encountered-wins tie-break is unchanged.

Pure code motion; behaviour must be proven byte-identical against the
pre-extraction baseline fixtures (StageExtractionGateTests).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016mm7Pvu34Ks8qdUZKDBSUu
Add PoseStage helper functions and wiring to assert that only S2 (place/displace)
writes scene roots. The guard captures root translations after displace and
verifies they are unchanged after limbSolve.

- PoseStage.rootTranslations(of:) captures all scene roots' translations
- PoseStage.rootsUnchanged(_:since:) detects any root movement
- PoseStage.debugAssertRootsUnchanged(avatar:since:) fires in debug builds
- CrowdFrameStepper.step() wires the guard around limbSolve
- testRootWriteGuardDetectsMovement verifies the detector independently

Closes #377 Task 5 of contact-ik-pipeline SDD.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016mm7Pvu34Ks8qdUZKDBSUu
Wrap capture and assertion in #if DEBUG to eliminate unconditional hot-loop
allocation in release builds. Guard helper functions remain public API.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016mm7Pvu34Ks8qdUZKDBSUu
arkavo-com and others added 28 commits August 23, 2026 18:34
…racle

Scopes over materials across every mesh rather than the first mesh named
'body', which drops the Face mesh's SKIN primitives. The inventory pins the
exact included set per fixture, asserts triangle topology (strips would be
skipped in silence) and records boundary-edge counts, since a SKIN-only
surface is open at the eye sockets and under garments.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…directions

CLOTH is simulated garment surface and must never be measured as body: a
garment's own buried joint would report the garment's own surface as the
nearest triangle, at distance ~0, so region collapses to whatever the
garment is skinned to rather than the true offender. includes() is now
SKIN-only (HAIR still vetoes). The pinned inventory test now asserts both
the included AND excluded material sets, for both AvatarSample_A and
AvatarSample_U, so a rename that empties the oracle and a predicate change
that floods it with garment surfaces both fail loudly. Sabotage now runs
both directions.

build(model:) gained: an optional expressionController parameter with a
morph-weight precondition (unconditional isn't possible — VRMModel exposes
no runtime morph state; primitive.vertexBuffer is structurally rest-pose
only since morph compute writes into a private separate buffer); loud
preconditions instead of silent continues for unskinned or vertex/index-less
body-surface primitives and for vertices with all-zero skin weights;
majorityBone now prefers a hand/finger region on a genuine three-way
disagreement instead of an arbitrary first corner; and a hoisted
[ObjectIdentifier: VRMHumanoidBone] map replacing an O(nodes x bones)
per-vertex lookup.

Re-measured the SpatialGrid performance cliff after the CLOTH fix: it is
NOT gone, and is in fact worse (108.6ms/query at 1m outside vs the prior
40.8ms), and a 50m-outside query now hits the ring>4096 hard abort and
crashes outright. Root cause is the body/face mesh's own fine tessellation,
not the removed garment accessories — reported as a genuine open risk for
later tasks, not worked around here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…lback

Task 4's real-avatar test (AvatarSample_U, ~13.6k triangles, cell~0.011 from
mixing a coarse torso with a fine face) exposed the exact failure the
cell-size heuristic was flagged as vulnerable to: a query 1m outside cost
108.6ms, and a query 50m outside needed ring 4394 -- past the ring>4096
preconditionFailure added for the previous finding -- crashing the test
process. The precondition did its job (loud abort over a false CLEAN), but
a hard abort on a legitimate far query still fails the 212-joint/75-frame
gate this oracle exists to run.

SpatialGrid.nearest now falls back to a linear scan -- exactly the
equivalence test's own brute-force reference, factored into a shared
SkinMeshOracle.linearNearest(to:in:) -- whenever the required ring count
exceeds a threshold (32, chosen so the grid stays faster than a linear scan
for any query actually near the surface while bounding worst-case
ring-search cost to O(32^3) regardless of distance). Two trigger points:
up front, if the first-touch ring already exceeds it, and mid-loop, if ring
growth crosses it before termination (the diagonal Chebyshev/Euclidean gap
case). This makes the ring>4096 preconditionFailure genuinely unreachable in
normal operation rather than merely less likely, and it is kept as defense
in depth per instruction.

Re-measured on AvatarSample_U: 1m outside 108.6ms -> 27.3ms, 50m outside
crash -> 4.2ms (completes normally). Added
testGridFallsBackToLinearScanForFarOutsideQuery, a synthetic reproduction of
the same shape (fine mesh, ring count that would previously have crashed)
asserting both correctness and a bounded time. All 20 previously-existing
tests (15 SkinMeshOracleMathTests + 5 BodySurfacePredicateTests) pass
unchanged; no answer changed.

Tie-break note: fallback-routed queries now return a bit-for-bit identical
ClosestHit to brute force (same code path). Grid-routed queries keep the
pre-existing, already-documented possible tie divergence on exact ties.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The reported visual is an avatar standing with her hands at her sides, fingers
inside the dress. No existing StressPose case places the arms there. Asserted
on wrist position relative to hip and shoulder, not eyeballed.

Angle derived by measurement (node-position probe against
AvatarSample_U_1.0): -90/+90 about Z on the upper arms brings the wrists
level with the hips, not the +-78 degrees the brief's estimate assumed.

Finger half-thickness measured via SkinMeshOracle on both fixtures for
Task 6's hand-tolerance derivation (2.7mm-6.1mm; probe removed after
recording, see task-5-report.md).

StressPoseFactoryTests updated for the new case count and armsAtSides track
count.
…381)

Measures hair/skirt/hood/sleeve joints against the skinned body mesh with
AvatarSample_U posed arms-at-sides, at 1mm tolerance on hand and finger
regions (derived from Task 5's measured 2.7-6.1mm finger half-thickness).
Bust chains are never queried: they sit inside the chest by construction
and would swamp the millimetre finger signal.

The deliverable is a recorded FAILURE, pinned to the hand region so the marker
cannot be satisfied by penetration elsewhere, and carrying contact-region
baselines so later movement is attributable. When SP2/SP3 fix the defect,
XCTExpectFailure fails and forces this marker's removal.

Measured result: no hand-region penetration is found for this exact
pose/fixture/query-set combination, at either per-joint or 5-point-per-segment
density (verified independently via raw joint-to-hand-bone distance ~6cm
minimum, and a different-pose cross-check confirming the measurement
mechanism itself works). The nearest real defect here is Sleeve-chain
penetration into a non-hand region (0.0342m), consistent with the
already-tracked #309/#313 sleeve-arm finding, not #381. The marker is kept
per plan (not deleted) and the test currently fails in the suite for this
documented, verified reason — see task-6-report.md for the full investigation,
both Step 4 sabotage runs (build-guard sabotage confirms correctly; the
tolerance-widen sabotage is mechanically moot against a null baseline), and
the wall-clock cost-driver analysis (rear-hip Skirt joints hitting the spatial
grid's linear-scan fallback, ~10.5ms each, since torso skin is garment-covered
on this avatar).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…381)

Round 1 fix: the gate's querySet was an allowlist (hair/skirt/hood/sleeve)
inherited from #309's cloth work, which silently excluded CatTail, CatEar,
and TopsUpperArm from AvatarSample_U's rig. Video review of a machine-local
host avatar (modelH-final.vrm) found the actual #381 defect — a cat tail
passing through the hand and forearm — which the old allowlist could never
have measured since CatTail was never queried.

Inverted the filter to a denylist: query every spring chain except Bust
(excluded because it sits inside the chest by construction, documented
in-line so future exclusions are deliberate, not inherited).

Re-measuring AvatarSample_U with the full denylist query set still finds no
hand-region penetration: CatTail's worst penetration (0.041m) lands in
`hips`, not a hand/finger region, consistent with the coordinator's video
review showing U's tail hangs clear (unlike modelH-final.vrm, which is not
part of this repository). Per the coordinator's decision rule, dropped
XCTExpectFailure and converted the test to a passing regression guard that
asserts the currently-true state directly, documenting in its doc comment
that the #381 defect is observed on a machine-local fixture, not reproduced
here. Full suite: 1971 tests, 0 failures (was 1971/1 before this fix).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… collision

Opt-in loading mode (fitClothCollisionToMesh, default false) fixing cloth and
hair passing through body colliders, evidenced on AvatarSample_M: hair hitRadius
is authored at median 3.7mm (some zero) for cards centimetres wide, and
sphere-at-joint collision leaves 4-5-joint chains open between joints.
Controlled experiments: +50% arm colliders moved 0.03-0.23% of pixels; an 18mm
hitRadius floor moved 0.31-0.37% — ten times the lever — and still cannot close
the inter-joint gap. Both halves ship together: mesh-measured effective radii
(authored never mutated, floored never reduced, ceiling-capped) and segment
capsule collision in the XPBD kernel (child-only corrections, buffer-order
invariant preserved, CCD scoping untouched).

Opt-in is the deliberate strict reading of the #326 lesson: VMK does not
deviate from authored physics without host consent. That keeps the default
path byte-identical — per-tier baselines untouched, no pre-release marker —
and makes all validation additive. Four gates, each with a named sabotage,
including a synthetic two-joint chain whose inter-joint gap is the
discriminating fixture for segment collision.

Bundles AvatarSample_M (licence verified) as the first redistributable
fixture with a wide dress at hand height. Filed under #381 as the cloth-side
sibling of SP2 and the enabler of SP3's per-finger work.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1. Parent endpoint reads from an immutable per-substep snapshot, never the
   live buffer — the collide kernels read and write bonePosCurr[id] per
   thread, so a live parent read would be their first unsynchronized
   cross-thread read and run-to-run nondeterministic. One snapshot after
   integration serves all three shape dispatches; staleness is chosen, not
   inherited. New 6.5 determinism gate whose sabotage is exactly the live
   read.

2. Identity claims rewritten to what the gates certify: the CSVs guarantee
   a 1mm mean-per-axis envelope (SpringBoneRegressionTests.swift:70), not
   bit-identity. A bit-exact GPU baseline, captured before any kernel
   change, now carries the recompiled-metallib claim; extreme's lack of a
   golden is acknowledged. The 6.1 sabotage is judged against the bit gate,
   with the explicit note that green CSVs under sabotage is the envelope
   working, not the sabotage passing.

3+4. Landing order made explicit: the remote is at 830867a and every SP1
   artifact this spec depends on (oracle as amended, U bundled at 28f9a5b)
   is unpushed local state; 6.3 margins are valid only against the amended
   oracle, cited by commit.

5. Child-only correction's small-t overshoot named as the scheme's weak
   point, bounded by a derived tolerance plus a settling assertion in 6.2,
   with t-scaled correction as the named fallback.

Plus: root joints measured, root-to-leaf fallback with transitive
inheritance from computed ancestors only, dominant-vertex counts in the
audit table, "buffer-index-order invariant" replaced with the real
single-writer property, CLAUDE.md CCD sentence amendment added to slice 6,
ENDSITE dropped, scalp-bulge round-cone fallback named.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Seven tasks over the spec's six slices. The load-bearing sequencing: the
bit-exact GPU baseline is generated and committed in Task 3, before Task 4
touches any kernel — and Task 4 adds three NEW segment kernels rather than
editing the shipped three, so the flag-off baseline has its best chance of
surviving the metallib recompile untouched. Parent endpoints read a
per-substep snapshot (buffer 16); the 6.5 determinism sabotage is precisely
the live-buffer read. Every gate carries an observed-failing requirement,
including the ULP-vs-mm escalation rule if the bit gate breaks on recompile.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Licence verified from the file (allowRedistribution=true, everyone,
allowModificationRedistribution). Predicate inventory pinned: two SKIN
primitives in, all garments out.
hitRadius is a geometric proxy VRoid authors near zero (median 3.7mm on
AvatarSample_M hair, for cards centimetres wide). Per joint: dominant-weight
verts, perpendicular extent about the chain axis at p65, floored at authored,
ceiling min(5cm, 0.75×span), root->leaf inheritance for sparse joints with
the dominant-vertex count recorded so sparsity is loud. Nothing consumes the
values yet; the audit test pins the invariants on A, U and M.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ed, bit baseline captured

Flag defaults false; authored hitRadius never mutated (effectiveHitRadius
sits beside it, spec §2/#326 boundary). The single consumption point reads
effective ?? authored. 6.1(i) identity pinned on A/U/M; 6.1(ii) bit-exact
GPU baseline captured and committed BEFORE any kernel change — it, not the
1mm-envelope CSVs, certifies the upcoming metallib recompile. Sabotage
(default flipped) observed failing the 1mm-envelope CSVs; the bit gate
stayed green because it always passes fit explicitly, bypassing the
default — flagged as non-discriminating per spec §6.1 escalation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…llisionToMesh default

The flipped-default sabotage tested the wrong property — the bit gate's
harness always passes fit explicitly, so a default regression is
structurally invisible to it (correctly caught instead by the 1mm-envelope
CSVs at 0.14-0.17 m). Replacement sabotage makes the flag-off write-back
unconditional; testBitBaselineMatches now fails at sample 0 as expected,
confirming the consumption point actually feeds the simulator. Adds
testFitClothCollisionDefaultsToOff to pin the default separately, and
documents the division of labor between the bit gate and the CSV envelope.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three NEW kernels collide the parent->child span against sphere/capsule/
plane colliders; the shipped endpoint kernels are textually untouched, which
is what let the flag-off bit baseline survive the metallib recompile. Parent
endpoints read an immutable per-substep snapshot (buffer 16) - the collide
kernels write bonePosCurr[id] per thread, so a live parent read would be
their first cross-thread read and nondeterministic; the 6.5 gate's sabotage
is exactly that read (not observed diverging in 10 runs on this hardware -
recorded honestly, not claimed). Corrections stay single-writer to the
child, scaled by the segment's barycentric parameter (t for spheres/planes,
s for capsules) to bound the child-only correction's small-t overshoot near
a parent-side contact (spec §4's named fallback, shipped directly rather
than discovered via a failing un-scaled reference). CCD scoping untouched
(segments are discrete-time). Metallibs regenerated and committed
atomically.

Internal test hook: SpringBoneComputeSystem.segmentCollisionEnabledForTesting.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HnCAJ2XwBTpGrPM7q1jmvE
…uild case1 honestly

Fix round 1 for segment cloth collision (previous commit a8f27aa):

CRITICAL: segment kernels ignored collider responseScale, so a ghosted
cross-avatar partner (setContactResponseScale(0)) would push spans while
pushing nothing else. All three segment kernels now multiply by
responseScale (spheres/capsules; planes carry no such field, matching
the untouched endpoint plane kernel). Case 1's test used to rely on
responseScale=0 to neutralize the endpoint kernel for isolation, which
meant the suite's primary gate certified the defect — rebuilt case 1
honestly: the collider sits inside the disk swept by the pendulum-like
span, at a smaller radius from the fixed parent than the span length,
giving a provable (triangle-inequality, asserted every frame) clearance
for both joints with a normal responseScale=1 collider. A dedicated test
now covers the responseScale=0 ghosting behavior explicitly.

Anchor-span coverage gap: the first simulated joint's parent is the
kinematic root, which never moves and has no span of its own — a t-scaled
correction there converges to zero with no coverage anywhere (the
scalp-hair/Hitarea_Head case). Spans whose parent is itself anchored to
the kinematic root now get the FULL, un-scaled correction; interior spans
keep the t/s-scaled one. New test asserts deflection at t=0 exactly.

New test covers small-but-nonzero interior contact (t≈0.1-0.15, 4-bone
chain) with a settling assertion, previously untested.

Determinism sabotage gate replaced: the original 3-bone scene put every
bone in one 256-wide threadgroup (lockstep SIMD), structurally unable to
observe a torn live read. New 257-bone scene pads 254 inert filler bones
before the real span so parent (255) and child (256) provably straddle a
threadgroup boundary. Live-read sabotage re-run 10x on the new scene: no
divergence observed (reported honestly, not claimed).

Cheap fixes: snapshot dispatch now gated on segmentCollision != 0 (was
unconditional); snapshot kernel's doc comment corrected (BEFORE the
endpoint kernels, not after — the load-bearing staleness contract);
planes now apply the same anchor/interior scaling policy as spheres/
capsules (previously unscaled, contradicting the prior commit message).

Bit gate + regression CSVs re-verified green post-recompile.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HnCAJ2XwBTpGrPM7q1jmvE
…etration on M

One test per pose (armsCrossed, armsAtSides) on AvatarSample_M_1.0.vrm: loads
the model flag-off and flag-on, runs the existing offscreen stress harness,
measures worst oracle penetration per chain family at the radius the sim
actually used (effectiveHitRadius ?? hitRadius), and asserts flag-on beats
flag-off by max(2mm, 20%) per family that was non-zero flag-off (clean stays
clean otherwise).

Headline numbers (worst penetration, flagOff -> flagOn):
  armsCrossed:  Hair 0.0213m -> 0.0084m   Skirt 0.0000m -> 0.0000m
  armsAtSides:  Hair 0.0266m -> 0.0084m   Skirt 0.0000m -> 0.0000m

Dual sabotage (spec 6.3) exercised both directions: measurement stubbed
(SpringBoneJointRadiusMeasure.measurementDisabledForTesting = true) still
passed at 0.0213m -> 0.0160m via segment collision alone; segment collision
disabled still passed at 0.0213m -> 0.0111m via measured-radius flooring
alone. Neither sabotage failed the gate at the current margin -- reported as
a contribute-nothing finding per spec, not tuned away. Both toggles reverted;
clean re-run confirmed identical to the numbers above.

Adds SpringBoneJointRadiusMeasure.measurementDisabledForTesting, the sabotage
hook this gate consumes (segmentCollisionEnabledForTesting already existed
from Task 4). Guard check green: SpringBoneBitBaselineTests +
SegmentCollisionGapTests, 14 tests, 0 failures.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HnCAJ2XwBTpGrPM7q1jmvE
…n-discriminating sabotage

Fix round 1 for the Task 5 oracle improvement gate: the relative margin
(max(2mm, 20%)) was cleared by either half of fitClothCollisionToMesh alone,
so disabling one mechanism didn't fail the gate. Adds an absolute combined
bound, derived from the recorded armsCrossed measurements (radii-only
0.0111m, segments-only 0.0160m, combined 0.0084m): flagOn.worstHair < 0.010m,
applied to both pose tests alongside the unchanged relative-margin
assertions.

Re-ran both sabotage directions against the tightened gate, foreground:
  measurement stubbed:  FAILS, observed 0.016043m (>= 0.010m)
  segments disabled:    FAILS, observed 0.011069m (>= 0.010m)
Both toggles reverted; clean re-run green with unchanged combined numbers
(armsCrossed 0.0084m, armsAtSides 0.0084m, both under the new bound).

Guard re-check green: SkinMeshCoverageTests + SpringBoneBitBaselineTests +
SegmentCollisionGapTests, 18 tests, 1 skipped (opt-in), 0 failures.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HnCAJ2XwBTpGrPM7q1jmvE
Sets VRMLoadingOptions.fitClothCollisionToMesh = true at model load in
VRMVideoRenderer, following the --stagger option's parsing pattern.
Task 6.4 visual+perf gate: pixel-delta 0.60-1.29% (>2x the 0.31-0.37%
insufficient-fidelity floor) across Hitarea_Head/Hitarea_Groin/Sit_Idle;
hair deflects around raised arm, hand rests on (not in) skirt, no
scalp bulging or floating-hair artifacts. Perf gate ≤3x at ultra:
M 1.23x, U 1.60x (extreme: M 1.58x, U 1.60x). No percentile
recalibration needed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HnCAJ2XwBTpGrPM7q1jmvE
- Skirt penetration was 0.0000m flag-off on both poses (nothing to
  improve), not "similar improvements" to Hair's 60-68% reduction.
  State the clean-stays-clean gate as what it actually is.
- Bit-exactness of the flag-off path is certified by ONE dedicated
  baseline (SpringBoneBitBaselineTests, A/ultra/armsCrossed, 90
  frames), not by "all existing validation gates"; the per-tier CSVs
  are a looser <=1mm mean-per-axis envelope.
- Fix the adoption/how-it-works snippets to the real API:
  VRMModel.load(from:device:options:) (device: is required or the GPU
  spring system never initializes and the flag silently no-ops) and
  renderer.loadModel(_:) (renderer.load(model:) does not exist).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HnCAJ2XwBTpGrPM7q1jmvE
…ence

The live-read-vs-snapshot sabotage never reproduced a mismatch even on
the verified cross-threadgroup scene, so it does not prove the
snapshot buys determinism. Restate the snapshot's justification as
removing undefined cross-thread aliasing on the parent-endpoint read
(true regardless of observed outcomes), with the gate standing as a
regression watch rather than as proof of the original claim.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HnCAJ2XwBTpGrPM7q1jmvE
… code

SkinMeshCoverageTests: document that the clean-family branch (Skirt,
0.0 both ways on this fixture) asserts clean-stays-clean only, never
improvement; record hairAbsoluteBound's headroom risk (16% on this
machine's GPU) and require RE-DERIVATION, not nudging, if it flakes on
other hardware.

SkinMeshOracle: build(model:) no longer computes boundaryEdgeCount (never
read by build) or calls readIndices twice per primitive — inventory()
gains a computeBoundaryEdgeCount flag so build() opts out while
BodySurfacePredicateTests keeps the count by default. Guard non-finite
query positions in SpatialGrid.nearest so a NaN joint (an upstream
solver bug) returns "no penetration" instead of trapping the whole
xctest process. Delete the unreachable ring>4096 preconditionFailure
(the linear-scan fallback already engages at ring 32) and fix the
stale "~13.6k triangles" comment — it's the vertex count.

BodySurfacePredicateTests: pin total boundaryEdgeCount per fixture
(A 1630, M 1680, U 2008) so mesh-topology drift near the open body
surface (eye sockets, under-garment deletion) is loud instead of a
silent shift in oracle behaviour near rims. Align BodySurfacePredicate's
doc comment to the actual rule: contains("SKIN") && !contains("HAIR")
is a substring test, not mutual exclusivity — a hypothetical material
named with both SKIN and CLOTH would be accepted.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HnCAJ2XwBTpGrPM7q1jmvE
… pipelines

VRMSpringJoint.effectiveHitRadius: public var -> public internal(set) var.
A host application must not be able to raise simulated collision radii
with fitClothCollisionToMesh off — only the module's own measurement
path (flag on, at load time) writes this field.

SpringBoneComputeSystem: the four segment-collision pipelines (snapshot
+ three segment kernels) are now built lazily by
ensureSegmentCollisionPipelines() on first flag-on dispatch instead of
unconditionally at init, so the default flag-off load/simulate path
pays zero pipeline-compile cold-start cost. Creation failure stays
loud (preconditionFailure), matching init's existing behavior; the bit
baseline (flag-off) is unaffected since it never triggers creation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HnCAJ2XwBTpGrPM7q1jmvE
The review's reference counts covered the Body primitive alone; the pin sums
all included SKIN primitives. Values from the assertions' own printed output.
- CrowdFrameStepper: correct stale "flag is never restored" doc
  (r3721137996) — 830867a already added the borrow/restore, but the
  adjacent doc comment wasn't updated to match; also documents that
  PipelineAvatar's permanent mutation is the direct-construction path,
  with CrowdFrameStepper's borrow/restore as the managed alternative.
- SpringBoneJointRadiusMeasure: guard inverseBindMatrices indexing with
  an identity fallback for malformed skins (r3729089819), matching the
  loader's own absent-accessor behavior; no behavior change for
  well-formed skins.
- SpringBoneJointRadiusMeasure: document the single-instance assumption
  in the first-node mesh lookup (r3729089919) — acceptable-by-design for
  VRM avatars, multi-instance support intentionally out of scope.

Tests: ClothJointRadiusAuditTests, SpringBoneBitBaselineTests,
PlayerOwnershipTests — 9 tests, 0 failures, bit baseline unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The rebase took main's metallibs at every conflicting commit, so the
committed slices carried none of this branch's shader work. Rebuild
from the merged sources so SpringBoneSegmentCollision's kernels are
present in all five slices — including the xros/xrsimulator slices
added by #395, which this branch predates.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011pj1CapHF8WkgztWwcB6Ls
…ed layout

#420 (group 6b) split primitive storage: `vertexBuffer` is now position-only
(`VRMPositionVertex`) and normal/UV/colour/joints/weights moved to
`attributeBuffer` (`VRMAttributeVertex`). Three call sites on this branch
still bound `vertexBuffer` to the interleaved `VRMVertex`, so every read
past the first 16 bytes ran off the end of a smaller allocation into heap
memory — joints and weights came back garbage.

SkinMeshOracle tripped its own all-zero-weight precondition on the Face
mesh (with a vertex count that varied run to run, the signature of reading
uninitialised memory). SpringBoneJointRadiusMeasure is production code and
failed silently: measured cloth radii were derived from that garbage.

All three now go through `VRMPrimitive.interleavedVertices()`, the accessor
#420 added to reconstruct the interleaved value from the two streams.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011pj1CapHF8WkgztWwcB6Ls
The baseline was captured on 1.1.0-beta.1, before #398 (per-substep host
write segmentation) and #420 moved spring-bone trajectories on main. 1322
of 3059 samples differ, all of it main's motion, none of it this branch's.

Verified rather than assumed: captured the same 90-frame armsCrossed
sequence from a worktree at origin/main with the flag plumbing removed,
and this branch's flag-off capture matches it bit for bit, 3059/3059. The
invariant the oracle exists to protect — opt-in off changes nothing — now
holds against 1.1.0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011pj1CapHF8WkgztWwcB6Ls
@arkavo-com
arkavo-com force-pushed the fix/stagger-collision branch from be419db to 45a8d2e Compare August 23, 2026 23:36
@github-actions

Copy link
Copy Markdown

Claude Code Review ran on 45a8d2e — result: success.

Findings (if any) are posted as inline comments; none means the review found nothing to flag. Run log.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant