Skip to content

Holding things: grasp, place, release, handover, swap (an#502) - #535

Merged
thorwhalen merged 3 commits into
mainfrom
grasp-actions
Oct 25, 2026
Merged

thorwhalen merged 3 commits into
mainfrom
grasp-actions

Conversation

@thorwhalen

Copy link
Copy Markdown
Collaborator

Picking up, putting down, handing over and swapping a held object, as declarative actions that look handled rather than teleported. The body-frame half of #502 (its owner comment: "a frame can be a node of a rig ... reframe with a duration ... the floor is one of the frames ... mirroring"); the world-frame half (P4b, a bottle leaving a car) stays open there.

What a scene says

# yaml shot
solve: {ground: 502}                        # the floor line (stage y)
# yaml entities: the item is its own prop
- {kind: prop, id: musket, store: props, ref: weapon-musket-os, stage: {at: [-640, 480], scale: 0.706, after: stu}, overrides: {held_by: ground}}
# yaml actions
- {kind: grasp, by: stu@hand_r, item: musket, start: 2.0, reach: 0.55}
- {kind: place, item: musket, onto: nap/arm_r/weapon, start: 7.0}
- {kind: swap, holder: nap/arm_r/weapon, take: musket, give: sword, by: stu@hand_r, start: 10.4}
- {kind: release, item: musket, at: 13.4, onto: ground@-420, arc: 60}
- {kind: handover, item: cup, to: jo@hand_l, meet: 0.5, start: 4.0}

Grammar and defaults: .claude/skills/an/references/holding-things.md.

How it works (an/stage/hold.py, inside the solve pass)

  • An item is never a solver body. At every frame it is in one holder's frame (a body's point, the ground, a stage point, a flight); a change of holder keeps its world pose at the contact frame, so there is no slide and no jump.
  • A gesture adds one reach row to the puppet: a pull of the hand's point whose goal is a per-frame track (an arc about the leaning shoulder from where the shot has the hand to the goal and back, angle and distance minimum-jerk blended, the distance kept in the ring the chain can reach so it is never refused), and frees the chain's authored channels for its span.
  • Out of reach: the chain's root (a torso) turns about its pivot (the hip) by the least angle that brings the goal in reach, closed form (_solve_lean), capped by lean (60 degrees), a fixed point among gestures that bend one root; what rides on it (head, other arm) turns with it via Puppet.lean targets; legs stay planted.
  • Planned from a solve without gestures, solved, re-planned from that solve, solved again (HOLD_PLAN_PASSES = 2).
  • A carried item follows its holder's solved transform (mirror and squash included) and over the retract turns to how the hand holds things (align, as a socket would); a place blends it to the socket's frame or to its rest angle on the ground; contact with the floor is the lowest point of the texture's alpha hull (a carved part's transparent margin does not float); a release is a parabola under the shot's or pack's gravity (else 980), or a toss solved to land at ground@x.
  • The item's channels (x, y, rotation, scale_x, scale_y) join its __solve__ clip; meta.extensions.solve.holds reports each gesture (contact, gap, lean), each item's holders, floating items.
  • Checks: hold_conflict (error; the compile raises HoldError too), unreachable_hold (error, with which way and how far to move the carrier), floating_item (warning).
  • EntityKind.landmarks (genre API level 8): named points a rig has no bone for. cutan registers a character's hand_l/hand_r (companion PR in reeleehq/cutan).

Two changes to the existing solve pass (behaviour fixes, found by this work)

  1. _local_box: a drawing smaller than DFLT_BODY_EXTENT on both sides (a one-pixel "empty" swap key, the transparent-part workaround) is a body of the default extent at its pivot. As a speck many widths from its pivot, the solver could not hold it: the carved Stu's face slot drifted up to 365 px off its joint during a walk.
  2. Pose mode: a passenger part keeps the angle the document gives it. A passenger is a part no joint or verb names at any time in the shot (nor any part hanging from it) and that no reach is moving at that frame. It used to swing about its joint as the joint dragged it (Stu's head turned 40 to 65 degrees while he glided). Dynamic mode is unchanged; a part a joint or a verb ever drives keeps P2's behaviour (so a pin's end does not snap it back). Breaking for P2 output (flagged): in pose mode, a part hanging from a driven or authored body that nothing names now keeps its document angle instead of swinging (measured main vs branch over 36 joint/verb scenarios on the joint_pin fixture: 27 byte-identical, 9 differ, all in the fore arm of pin_upper_tip, pull_torso, join_upper_world_then_unjoin, in each nesting mode). Corpus byte-identity (scenes with no joint or verb) holds; P2's own tests pass unchanged.

Both are guarded by tests that go red when the fix is deleted (mutation-checked locally): test_a_passenger_part_does_not_swing_when_its_body_moves, test_a_part_that_draws_a_speck_stays_on_its_joint, and test_a_part_a_joint_let_go_of_stays_where_it_was_left (tests/test_solve.py).

Seams (v1)

Seam v1 default Replacement pointed at
a holder's point when none is named the body's end effector a hand bone (reeleehq/cutan#104), a genre landmark
named points a rig has no bone for EntityKind.landmarks cutan's hands
how far a body bends to reach chain root about its pivot, cap 60 per-part hinge limits (reeleehq/cutan#104)
where the ground is solve: {ground}, else the carrier's lowest root part a plane's ground line / scene space (#503)

Not seams: the reach path shape, the flight, no bounce, the report layout.

Verified where

Compiled-document tests (tests/test_holds.py, 32 tests on a fixture figure that bends at the hip): exact contact (gap under 0.5 px), no frame-to-frame jump of the grip beyond the hand's own step, riders bend with the root, socket alignment, ink-hull floor contact, toss landing at x on the floor, drop, mirror carry, swap, handover at meet 0.5, conflicts by name, unreachable with direction, floating warning, landmarks hook, determinism. Rendered on a developer machine (not CI): the carved Stu picking a musket and a falcata off the floor, placing the musket in Napoleon's figurine hand (drawn over the figurine's own swap-slot musket at half opacity, the two coincide), swapping it for the sword, tossing the musket back onto the floor; and cutan's factory-character demo. A render can change pixels for shots that declare joints or verbs (the two solve fixes): label run-browser-tests.

Not built (follow-ups)

A draw order that changes with the holder (the item draws where it is staged), placing onto a prop's surface other than the floor (support_top), a crouch (legs that bend), bounce or friction on landing, holds across cuts.

Independent adversarial review (done; findings fixed in this PR)

  1. (High) the passenger rule snapped a part back when a pin's span ended or a joint was unjoined (101 px in one frame): passengers are now parts no joint or verb EVER names; regression test added.
  2. (High) a release and a catch at one instant left the item on the floor: a release now orders before a grasp at a tie, and events at one frame keep the schedule's order; test added.
  3. (Med) the reach path projected onto the reach ring could flip the arm through the shoulder (331 px): the path is now an arc about the shoulder, its direction fixed per phase.
  4. (Med) a gesture reaching its goal after the shot's end was squeezed silently: refused (hold_conflict).
  5. (Med) a reach starting while the body moves was reported as a join the scene never wrote: reach rows are left out of the rejoin snaps.
  6. (Med) after a grasp that fell short, a place to the ground sank the item then popped it up: the second plan now uses where the grasp really put the item, and a ground place is measured by the item against the floor.
  7. (Med-low) the speck rule moved points on small drawings: narrowed to drawings under 2 px both ways.
  8. (Low-med) two gestures bending one body opposite ways: unreachable_hold now says so; a part that moves holds one item whatever point of it is named.
  9. (Low) tests that passed for the wrong reason: the mirrored receiver and cutan's elbow case now measure what they claim.
  10. (Low) an item that holds another: refused by name.

Contract package: 1.10.0 (the five new vocabulary entries, python contract/build.py bump --level minor).

Closes nothing yet: the body-frame half of #502; the world-frame reframe (P4b) stays open there. Companion: reeleehq/cutan grasp-hands.

Core action kinds that move an item between holders (a body's point, a
socket of another prop, the ground, a flight) inside the solve pass, so a
pick-up, a put-down, a hand-over or a swap looks handled rather than
teleported:

- the hand reaches its goal on a minimum-jerk path (a pull row whose goal
  is a per-frame track, kept inside the ring the chain can reach) and
  retracts; the item changes holder at the contact frame keeping its world
  pose (no slide, no jump);
- when the goal is out of reach, the chain's root turns about its pivot by
  the least angle that brings it in (closed form, `lean`, 60 degrees by
  default), and the parts riding on it turn with it;
- a carried item follows its holder (mirror and squash included) and turns
  to the hand's frame over the retract (`align`); a place aligns it with a
  socket's frame or lays it on the ground at its rest angle, its lowest
  painted pixel (the texture's alpha hull) on the floor; a release is a
  parabola under gravity, or a toss to `ground@x` on an `arc`;
- `solve: {ground: y}` is the floor line; `overrides: {held_by: ...}` says
  what holds an item when the shot opens;
- checks: hold_conflict and unreachable_hold (errors, the latter with which
  way to move the carrier), floating_item (warning);
- `EntityKind.landmarks` (genre API level 8): named points a rig has no
  bone for (the cut-out genre's hands, `stu@hand_r`).

Two solve fixes the hold layer exposed: a part that draws a speck (a
one-pixel "empty" swap key) is a body of the default extent at its pivot
(it drifted off its joint), and in pose mode a passenger part (nothing
acts on it or below it) keeps its document angle (heads and idle arms
swung about their joints as a figure walked).
- passengers are parts no joint or verb ever names: a pin's end no longer
  snaps the part back (P2 behaviour kept for driven parts);
- a release orders before a catch at one instant; one frame's events keep
  the schedule's order;
- the reach swings in an arc about the shoulder (no flip through it);
- a gesture reaching after the shot's end is refused;
- reach rows are not reported as rejoins;
- the second plan uses where the grasp really put the item; a ground
  place is measured by the item against the floor;
- the speck rule only for drawings under 2 px both ways;
- a moving part holds one item, whatever point of it is named; opposite
  leans at once are said; an item that holds another is refused by name;
- contract package 1.10.0 (the five vocabulary entries).
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

Development

Successfully merging this pull request may close these issues.

1 participant