Skip to content

Joints in the IR, the pin/pull/join/unjoin verbs, bodies from any rig, and the solve compile pass (P2, an#512) - #533

Merged
thorwhalen merged 3 commits into
mainfrom
joints-p2
Oct 24, 2026
Merged

thorwhalen merged 3 commits into
mainfrom
joints-p2

Conversation

@thorwhalen

@thorwhalen thorwhalen commented Oct 24, 2026 •

Copy link
Copy Markdown
Collaborator

Closes #512 (P2 of #497; ADR 0008). Depends on P1 (#528, merged).

What

  • IR (additive, omitted when unset; no migration: nothing changes meaning): JointSpec (pin, hinge with limits in degrees about the rest pose, weld, distance / string / rod, slide, contact, give, name) on Shot.joints, on an entity (AssetRef.joints, a relative to it) and in a descriptor (joints:); Bone.limits on the rig; Shot.solve (SolveSettings: mode, gravity, damping, iterations, substeps) over the style pack's gravity / damping. A body reference is path[@u,v | @bone] (a bone with no slot is a landmark, e.g. a grip) or world@x,y.
  • Verbs (core action kinds, an.ir.joints, with scene.md forms and builders): pin (exactly there for a span; the moved side is freed, so a pinned cup goes to the hand; refused out of reach, an error), pull (toward a point or along a path: exact where reachable, given up for the frame where not, so the chain reaches toward it; a positive give makes it a soft string; never a violation), join / unjoin.
  • Bodies and joints from any rig (an.stage.rig.rig_structure): every slot's node is a body unless it rides a slot on its own bone; Bone.parent is a hinge at the child bone's position; slotless bones are skipped over and kept as named points.
  • solve pass (an.stage.solve, order 525, between face and camera_follow): evaluates the partial document at every frame (the step_hz grid when set, stepped keys), holds a rig's roots and every authored channel (a nested part's authored rotation becomes a weld at that angle), solves with an.kinematics.solve warm-started frame to frame (dynamic when gravity or a string acts, else pose), and emits one quantised key per frame in a __solve__<entity> clip placed last in the entity's track (x, y, rotation for roots and flat parts; rotation alone in a nesting: bones chain). A channel the solve leaves where the document has it is not emitted. meta.extensions.solve records mode, solver version, effort, an inputs digest (inputs_digest, per P1's note), the joints, refused joints (a refused pin with its reach and a place in reach), rejoin snaps and the authored spans a solved channel overrides.
  • Checks on the Constraint interface (an.verify.joints, order 250, after the catalogue's lints): unknown_body (error; the compile raises a typed UnknownBodyError too), unreachable_pin (error, with the chain's reach and where to put the target), joint_violation (warning: a joined joint apart or bent past its range in the compiled output, a rejoin over 2 px; overridden spans as info). Each has term = the solver residual (joint_residuals).
  • Cache key: render_knobs["solve"] (version, mode, effort, a digest of the declarations) only for a shot that declares joints; every other shot's key is unchanged.
  • Fixture misc/bench/corpus/joint_pin (compile only, not in CORE_FIXTURES, no goldens); docs: architecture row and module map, scene.md contract, the an and an-spec skills, changelog.

Tests (tests/test_solve.py)

The acceptance cases, in flat and bones nesting: the hand on the grip at every frame (< 1e-3 px) with the elbow inside its range while the torso sways; a string under gravity: down settles in dynamic mode and never stretches; unreachable_pin with the reach; joint_violation when authored motion breaks a join; unknown_body for a body, a point and a joint; a pinned prop goes to the hand without pulling the arm; a rejoin across a gap is a snap; the step grid; determinism; the scene.md round trip; every core-corpus fixture compiles byte-identically with and without the pass.

Decisions (not blessed elsewhere)

  1. A rig's root parts are held at their staged and animated pose unless the entity says overrides: {free: true | [parts]}; overrides: {fixed: true} holds every part. Without this a hand pinned to a wheel drags the whole figure.
  2. The moved side of a pin / pull is its point: a body target is solved without the verb first, then the verb pins to where it went (two solves per frame, only for such verbs).
  3. A pin's default point is the body's pivot for a and its extent's centre for b.
  4. A pull does not make a shot dynamic; a declared string joint (or gravity) does.
  5. Rejoin under 2 px snaps (no blend yet); fit_channel compaction is not built. Both are follow-ups.
  6. scene.md gains joints: / solve: in the shot block. Shot.welds (The held_item lint: welds (catalogue A4, #519 rank 10) #527) is still JSON-only; not touched here.

Adversarial review (one round)

A fresh reviewer found and reproduced: a nesting: bones child drawn before its parent read its parent's unsolved pose (bodies are now ordered parents first); scale and mirroring frozen at rest (now read per frame: extents, mirrored points, hinge rests and ranges); pull reported as a violation and tearing the rig when unreachable (now a rigid goal the solver gives up first, excluded from joint_violation, and not a reason for dynamic mode); unreachable_pin saying "out of reach" for a pin held off by an authored channel (now says so), and overridden-span info for channels the solve held; a malformed descriptor joints: raising a raw pydantic error (now an unknown_body finding and a typed compile error); the pass's gate reading non-rig store entries (a voice), which moved such shots' cache keys (now rig descriptors only); validate placing verbs without the plays' natural lengths (the compile now records spans and join events in meta.extensions.solve, and validate reads them). Each has a regression test.

Not fixed here (follow-ups): the other compile-based lints (an.verify.constraints.compiled_shot, _placements in validate) compile without the style pack, so under a gravity style they see a solve without gravity, and validate solves twice; one frame where a refused pull becomes reachable can under-converge (P1's slow-convergence path, reported honestly by joint_violation); fit_channel compaction; a blend for rejoins under 2 px.

Labelled run-browser-tests: a compiled document changes when joints are declared. Nothing here is verified in a browser on a developer machine.

https://claude.ai/code/session_01JZRAaW59TnBKD9A6YKPPaR

@thorwhalen thorwhalen added the run-browser-tests Run the rendering (browser) lane on this PR — see misc/docs/adr_ci_verification_perimeter.md label Oct 24, 2026
…ror, pull as a rigid goal, reach wording, override spans, malformed descriptor joints, gate reads only rigs, verb spans recorded for validate)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

run-browser-tests Run the rendering (browser) lane on this PR — see misc/docs/adr_ci_verification_perimeter.md

Projects

None yet

1 participant