transporting-objects
The pick has happened; the gripper is holding the target. This subgraph
moves the held object above the destination container and releases it.
When to use
- After a successful grasp (
grasped end state of any grasping-* skill).
- When the destination is a container with a known OBB (cup, bin, basket).
When NOT to use
- Cluttered transport paths where the lifted object risks colliding with
other scene objects in transit — but see the
waypoint_move_carve
variant below, which routes the lift/translate through
curobo.plan_with_grasped_object against a rebuilt collision world.
Recommended subgraph state flow
Choose the flow by WHERE the object must land.
Default — a walled container (basket / bin / box / tote): 2 states
HARD RULE — pick this path whenever the destination is a container with walls
(a basket, bin, tote, box, or cup) — i.e. essentially every "put/place X in
the " task. Use the 2-state transport_move → release flow below and
do NOT use the compute_drop → move_above → release drop-pose path (that
path releases at/above the rim for a walled container and the object misses).
The drop-pose path in the next section is ONLY for placing onto a surface or
into a named sub-region.
The object must end up INSIDE the walls, so descend straight down into it. This
is the reference VAB packing recipe and needs only container_obb:
transport_move → release
transport_move — type: script,
scripts/<sg>/transport_descend_linear.py. Use the canonical script as-is
(materialized automatically; do not re-emit it). Inputs:
container_obb = Ref("in.container_obb"), place_offset = -0.06 (the TCP
descends to just above the rim so the held object clears the walls and drops
in). It lifts, moves over the container, and does an axis-locked straight-Z
descend (curobo.plan_directed_linear, fingertip-frame). Lift and transport
heights derive from the rim and the current wrist height (leave lift_z /
transport_z at their -1.0 sentinels; pin them only for a workspace you
have measured), the wrist keeps the rotation it arrived with, every cartesian
leg is checked against robot.get_ee_pose (a leg that stops >3 cm short
raises), and the lateral leg searches the container's reachable interior when
its centre is out of reach — so place_position XY may differ from the
container centre. Returns place_position.
release — type: script, scripts/<sg>/place_release.py.
Input place_position = Ref("transport_move.place_position"). Opens the
gripper with a settle so the object lands, then a linear straight-up retract
to 10 cm above the placement (hover_z left at -1.0), keeping the wrist
rotation the transport arrived with (robot.get_ee_pose).
On success → exit placed; on failure → blocked.
Emit examples/canonical_subgraph.json verbatim for this path. Do not add a
compute_drop/move_above here — the descend already places into the walls.
Placement ONTO a surface, or a described sub-region/zone, or a subpart grasp: the drop-pose path
Here the target is a surface top or a localized zone (not "inside walls"), so
compute an explicit drop pose. Either 3 states or 4 states (when a
sub-region must be localized at drop time). Do not add more.
3 states:
compute_drop → move_above → release
4 states (when the task description names a placement sub-region):
perceive_zone → compute_drop → move_above → release
For this path prefer the descend_release_linear release variant (TCP-aware
straight Cartesian line) over descend_release — the latter goes through
robot.go_to_pose, which on some backends targets the hand link and drops the
hand-to-TCP offset, a common source of vertical placement misses.
Hard rule — no re-perception of the container. Do NOT add states
named re_perceive_container, reobserve_container, re_observe_*,
re_filter_obb, or any equivalent that re-detect the container. The
container's OrientedBoundingBox and Mask flow in via
in.container_obb / in.container_mask from the upstream perception
subgraph and are reliable for the drop.
Carve-out — placement-zone perception is allowed. When the task
description names a sub-region (e.g. "the left compartment of the
caddy", "the inside of the top drawer of the cabinet", "on top of
the cabinet shelf"), insert ONE state named perceive_zone of
type: script running the canonical
scripts/<sg>/perceive_placement_zone.py. The script calls
robot.get_observation once and grounds the zone with a
two-path pipeline that mirrors perceiving-objects's picking pattern:
(A) grounding-dino.detect + vlm.query letter-pick over labeled
boxes + sam3.segment_box; (B) sam3.segment_text fallback. The VLM
only ever returns a single letter (or none) — never pixel
coordinates. Its output flows into compute_drop as
container_interior_obb and is the ONLY downstream consumer. When
the placement is unambiguously the bare container (e.g. "put X in
the basket"), omit the perceive_zone state entirely (3-state
flow). Do not add any other perception/observation states inside this
subgraph.
State details:
perceive_zone (optional — include only when the task names a
placement sub-region) — type: script, file
scripts/<sg>/perceive_placement_zone.py. Use the canonical script
as-is; do not re-emit a python: block for this
path. Returns {placement_zone_obb: OrientedBoundingBox | None}.
Hard rule on placement_description: like object_name in
perceiving-objects, this is a literal Python string — the natural
noun phrase describing where the held object should land, drawn
from this subgraph's description. It is a constant per subgraph
instance, NOT a binding. DO NOT write
Ref("in.placement_description") or any $ref; the
coordinator does not declare placement_description as a subgraph
input. Write the string directly.
The phrase should describe the placement REGION, not the named
reference object. Examples:
- Task: "Pick up the book and place it in the left compartment of
the caddy." →
"the left compartment of the caddy".
- Task: "Put the chocolate pudding to the left of the plate." →
"the area to the left of the plate".
- Task: "Put the ketchup in the top drawer of the cabinet." →
"the inside of the top drawer of the cabinet".
- Task: "Pick up the book and place it on top of the shelf." →
"on top of the cabinet shelf".
"perceive_zone": {
"type": "script",
"script": "scripts/<sg>/perceive_placement_zone.py",
"inputs": {
"placement_description": "the left compartment of the caddy",
"container_obb": Ref("in.container_obb"),
"container_mask": Ref("in.container_mask")
}
}
Both container_obb and container_mask are OPTIONAL but strongly
recommended:
container_mask filters DINO detections to those whose box center
falls inside the container (caddy/drawer/shelf), which removes
noise boxes elsewhere in the image and frees the limited
labeled-box slots for actual sub-region candidates.
container_obb lets the script reject candidate zones that drift
too far in XY from the container center (a common failure mode
when the VLM's chosen mask projects to background through bad
depth). It's also used to choose between the DINO and SAM3-text
paths — the candidate closer to the container center wins.
When the script returns None (zone not visible, low confidence,
sanity-check failure), the downstream compute_drop falls back to
the bare-container path automatically — no extra graph wiring
needed.
compute_drop — type: script, file
scripts/<sg>/compute_drop_pose.py. Use the canonical script as-is;
do not emit a python:scripts//compute_drop_pose.py
block — the bundle's canonical script is materialized into the
workflow directory automatically and re-emitting it overrides the
correct implementation with an LLM reimplementation.
Hard rule on parameter names: the canonical script's def run
signature is (ctx, container_obb, container_interior_obb=None, ee_pose_at_grasp=None, drop_clearance=0.05, approach_height=0.20, held_obb=None, wrist_to_tcp=-1.0). Bind the held object as held_obb, not
target_obb. Renaming held_obb → target_obb causes the runtime
to silently drop the value (extra kwargs are warned and discarded);
the script then falls into the no-held-geometry branch and the drop
pose is wrong by the held object's full height.
When the optional perceive_zone state is present, bind
container_interior_obb to its placement_zone_obb output so the
drop targets the named sub-region instead of the bare container
center:
"compute_drop": {
"type": "script",
"script": "scripts/<sg>/compute_drop_pose.py",
"inputs": {
"container_obb": Ref("in.container_obb"),
"held_obb": Ref("in.target_obb"),
"ee_pose_at_grasp": Ref("in.ee_pose_at_grasp"),
"container_interior_obb": Ref("perceive_zone.placement_zone_obb")
}
}
When perceive_zone is omitted (3-state flow), drop the
container_interior_obb line:
"compute_drop": {
"type": "script",
"script": "scripts/<sg>/compute_drop_pose.py",
"inputs": {
"container_obb": Ref("in.container_obb"),
"held_obb": Ref("in.target_obb"),
"ee_pose_at_grasp": Ref("in.ee_pose_at_grasp")
}
}
ee_pose_at_grasp is required for the LIBERO In(obj, region)
predicate to fire after release — the script uses it to convert the
desired held-object Z into a TCP target accounting for the hand's
wrist-to-TCP offset (read off robot.describe_arm / robot.describe_gripper
unless wrist_to_tcp pins it; the drop yaw is composed by
robot.grasp_frame). The upstream grasping-with-planner subgraph
publishes it as a cross-subgraph output (produces_outputs.ee_pose_at_grasp);
this subgraph declares ee_pose_at_grasp in its required_inputs
so the coordinator wires the binding by name.
Returns drop_position (Vec3), drop_pose (Se3Pose), approach_pose
(Se3Pose).
1a. drop_offset — type: script, file
scripts/<sg>/drop_offset_pose.py. REQUIRED whenever the
upstream grasp was on a SUBPART (e.g. frypan handle, kettle
spout, bottle neck, tool grip). Omit ONLY when the grasp was on
the object's geometric centroid (parent_obb == held_obb case).
**HOW TO TELL: the upstream perception subgraph is
`perceiving-object-parts`**, OR the task description names the grasp
location explicitly ("grasp the pan by its handle", "lift it by
the spout"). In both cases the gripper closes on a subpart and
the parent body hangs off-axis — without `drop_offset` the
`compute_drop` script puts the *grasp point* at the placement
zone, which means the *body* lands off the support. This is the
dominant failure mode for handle-grasp-then-place-ON tasks: the
goal predicate reports `pan.bottom_z << burner.top_z` (pan
centroid below the support) and `xy_coverage_over` < 0.5 even
though the workflow itself returned `placed`.
**Repair trigger.** When repairing a transport subgraph that
failed with the above signature AND the upstream is
`perceiving-object-parts`, **insert `drop_offset` even if a prior
iteration omitted it**. Do not assume the absence of `drop_offset`
in the existing workflow is intentional; it is the single most
common omission.
Inputs:
`drop_pose = Ref("compute_drop.drop_pose")`,
`ee_pose_at_grasp = Ref("in.ee_pose_at_grasp")`,
`held_obb = Ref("in.target_obb")` (the grasped subpart OBB —
same as compute_drop.held_obb),
`parent_obb = Ref("in.parent_obb")` (the full object OBB — the
coordinator must declare `parent_obb` in this subgraph's `inputs`
and wire it from the perception subgraph's parent output, e.g.
`perception_sg.parent_obb`).
Returns `drop_position`, `drop_pose`, `approach_pose` — all shifted
in XY so the parent centroid lands at the original drop XY.
Downstream `move_above` / `release` then reference
`drop_offset.drop_position` instead of `compute_drop.drop_position`.
move_above — type: script, file
scripts/<sg>/waypoint_move.py. Inputs:
drop_x = Ref("compute_drop.drop_position.x"),
drop_y = Ref("compute_drop.drop_position.y")
(or Ref("drop_offset.drop_position.x") / .y when the optional
drop_offset node is present). Lifts to a safe height at the current
XY, then moves laterally to above the drop XY. The safe height is
robot.describe_workspace's transport_z unless safe_height pins it
(pin it when a held tool hangs below the fingertips and a container rim
sits above the work surface); when the lateral leg is out of reach it
retries up to three rungs 3 cm lower — never below the surface plus
align_clearance_m — and reports flown_z / descents beside done.
Variant — collision-aware lift/translate (waypoint_move_carve).
Same inputs (drop_x, drop_y) and same return shape, but the node
rebuilds the world from a fresh observation and routes through
curobo.plan_with_grasped_object instead of the connector's
robot.go_to_pose_cartesian (which is TCP-aware but not world-aware).
Use when a known obstacle sits on the transport path between the
grasp pose and the drop XY (an oven door, a shelf above the table, a
tall bottle the lift would clip). A repair pass may flip the
transport subgraph to this variant when the transport stage
pass-rate drops below the configured threshold — its hypothesis is
"free-space transport plowed through a perceived obstacle".
release — type: script, file scripts/<sg>/descend_release.py.
Inputs: drop_position = Ref("compute_drop.drop_position")
(or Ref("drop_offset.drop_position") when drop_offset is present).
Descends, opens the gripper, retracts home (robot.go_home on the work
arm named by robot.describe_arm; the default rotation is this hand's
robot.grasp_frame). On success → exit placed; on failure → exit
blocked.
Variant — linear descent (descend_release_linear). Same
node-level contract, but the descent goes through the connector's
TCP-aware robot.go_to_pose_cartesian (cuRobo linear plan with a
plan_to_pose fallback built into the backend), so the held object
descends on a straight Cartesian line with the orientation held — the
cleanest release dynamics for subpart-grasp + place-ON tasks (frypan
handle → stove). After the open it takes a 5 cm straight-up retreat at
the same wrist rotation and robot.wait_steps before robot.go_home,
so the fingers cannot drag the just-released object.
Required end states
| End state |
Meaning |
placed |
Object released at the destination. Route to the next subgraph or to done. |
blocked |
Path blocked or motion failed; aborted before release. Coordinator routes to abort. |
See also
references/clearance_constants.md — the magic numbers.
references/design_transport.md — why no planner is needed for the
default scope.
scripts/{compute_drop_pose,drop_offset_pose,waypoint_move,waypoint_move_carve,descend_release,descend_release_linear,approach_above,lift_grasped,perceive_placement_zone}.py
— canonical scripts.
prompts/vlm_select_zone.md — VLM prompt template for the optional
perceive_zone state.
Placing an object rather than a tool centre
transport_descend_linear plans, by default, for the point between the pads:
the carry height clears the rim by a fixed amount and the interior margin is a
single symmetric inset. That is right when what is held is small next to the
container and wrong when it is not -- a thick object hangs below the tool centre
and is dragged over the dividers the thin one flies across, and a long object
put down crosswise lands on a divider rather than in a cell.
Four opt-in parameters close that gap, each defaulting to the tool-centre rule
so no existing caller moves (checked in
tests/test_promotion_is_behaviour_preserving.py):
| parameter |
what it buys |
target_obb |
the carry height clears the object's underside, and the interior margins keep the object -- not the tool centre -- inside the walls |
carry_cap_m |
where extra height stops being free, because horizontal reach falls away above a peak |
align_to_container |
turns the object's long axis onto the container's during the lift, so the turn costs no separate motion |
level_lift |
lift to the full carry height before translating, so the crossing runs level instead of climbing over the dividers |
nearest_first_fallbacks |
order the corner fallbacks by distance from the hand |
surface_inset_m tells target_obb how far below the object's top surface the
grasp put the tool centre; it must match whatever the grasp node used, and it is
what turns thickness into hang.
1---2name: transporting-objects3description: Move the currently-held object to a destination and release. The gripper enters this subgraph holding the object; on exit the object has been placed. For a walled container (basket / bin / box / tote) the default is a straight-Z descent to inside the walls (transport_descend_linear → release); for placement ONTO a surface or into a described sub-region it computes an explicit drop pose (compute_drop → move_above → release). Use after a successful grasp when the destination has a known OBB. When the destination is a sub-region described in natural language (e.g. "the left compartment of the caddy", "to the left of the plate", "the inside of the top drawer"), an optional VLM-grounded perceive_zone state localizes the zone before the drop pose is computed. Heights derive from the perceived container and the live wrist pose rather than a resting tool height, and the top-down orientation from the hand actually on the arm (robot.grasp_frame).4license: Apache-2.05---67# transporting-objects89The pick has happened; the gripper is holding the target. This subgraph10moves the held object above the destination container and releases it.1112## When to use1314- After a successful grasp (`grasped` end state of any `grasping-*` skill).15- When the destination is a container with a known OBB (cup, bin, basket).1617## When NOT to use1819- Cluttered transport paths where the lifted object risks colliding with20 other scene objects in transit — but see the `waypoint_move_carve`21 variant below, which routes the lift/translate through22 `curobo.plan_with_grasped_object` against a rebuilt collision world.2324## Recommended subgraph state flow2526Choose the flow by WHERE the object must land.2728### Default — a walled container (basket / bin / box / tote): 2 states2930**HARD RULE — pick this path whenever the destination is a container with walls**31(a basket, bin, tote, box, or cup) — i.e. essentially every "put/place X **in**32the <container>" task. Use the 2-state `transport_move → release` flow below and33do **NOT** use the `compute_drop → move_above → release` drop-pose path (that34path releases at/above the rim for a walled container and the object misses).35The drop-pose path in the next section is ONLY for placing **onto** a surface or36into a **named sub-region**.3738The object must end up INSIDE the walls, so descend straight down into it. This39is the reference VAB packing recipe and needs only `container_obb`:4041```text42transport_move → release43```4445- **`transport_move`** — `type: script`,46 `scripts/<sg>/transport_descend_linear.py`. Use the canonical script as-is47 (materialized automatically; do **not** re-emit it). Inputs:48 `container_obb = Ref("in.container_obb")`, `place_offset = -0.06` (the TCP49 descends to just above the rim so the held object clears the walls and drops50 in). It lifts, moves over the container, and does an axis-locked straight-Z51 descend (`curobo.plan_directed_linear`, fingertip-frame). Lift and transport52 heights derive from the rim and the current wrist height (leave `lift_z` /53 `transport_z` at their `-1.0` sentinels; pin them only for a workspace you54 have measured), the wrist keeps the rotation it arrived with, every cartesian55 leg is checked against `robot.get_ee_pose` (a leg that stops >3 cm short56 raises), and the lateral leg searches the container's reachable interior when57 its centre is out of reach — so `place_position` XY may differ from the58 container centre. Returns `place_position`.59- **`release`** — `type: script`, `scripts/<sg>/place_release.py`.60 Input `place_position = Ref("transport_move.place_position")`. Opens the61 gripper with a settle so the object lands, then a linear straight-up retract62 to 10 cm above the placement (`hover_z` left at `-1.0`), keeping the wrist63 rotation the transport arrived with (`robot.get_ee_pose`).64 On success → exit `placed`; on failure → `blocked`.6566Emit `examples/canonical_subgraph.json` verbatim for this path. Do **not** add a67`compute_drop`/`move_above` here — the descend already places into the walls.6869### Placement ONTO a surface, or a described sub-region/zone, or a subpart grasp: the drop-pose path7071Here the target is a surface top or a localized zone (not "inside walls"), so72compute an explicit drop pose. Either **3 states** or **4 states** (when a73sub-region must be localized at drop time). Do **not** add more.74753 states:7677```text78compute_drop → move_above → release79```80814 states (when the task description names a placement sub-region):8283```text84perceive_zone → compute_drop → move_above → release85```8687For this path prefer the `descend_release_linear` release variant (TCP-aware88straight Cartesian line) over `descend_release` — the latter goes through89`robot.go_to_pose`, which on some backends targets the hand link and drops the90hand-to-TCP offset, a common source of vertical placement misses.9192**Hard rule — no re-perception of the container.** Do NOT add states93named `re_perceive_container`, `reobserve_container`, `re_observe_*`,94`re_filter_obb`, or any equivalent that re-detect the container. The95container's `OrientedBoundingBox` and `Mask` flow in via96`in.container_obb` / `in.container_mask` from the upstream perception97subgraph and are reliable for the drop.9899**Carve-out — placement-zone perception is allowed.** When the task100description names a sub-region (e.g. *"the left compartment of the101caddy"*, *"the inside of the top drawer of the cabinet"*, *"on top of102the cabinet shelf"*), insert ONE state named `perceive_zone` of103`type: script` running the canonical104`scripts/<sg>/perceive_placement_zone.py`. The script calls105`robot.get_observation` once and grounds the zone with a106two-path pipeline that mirrors `perceiving-objects`'s picking pattern:107(A) `grounding-dino.detect` + `vlm.query` letter-pick over labeled108boxes + `sam3.segment_box`; (B) `sam3.segment_text` fallback. The VLM109only ever returns a single letter (or `none`) — never pixel110coordinates. Its output flows into `compute_drop` as111`container_interior_obb` and is the ONLY downstream consumer. When112the placement is unambiguously the bare container (e.g. *"put X in113the basket"*), omit the `perceive_zone` state entirely (3-state114flow). Do not add any other perception/observation states inside this115subgraph.116117State details:1181190. **`perceive_zone`** *(optional — include only when the task names a120 placement sub-region)* — `type: script`, file121 `scripts/<sg>/perceive_placement_zone.py`. Use the canonical script122 as-is; do **not** re-emit a ``` ```python:``` ``` block for this123 path. Returns `{placement_zone_obb: OrientedBoundingBox | None}`.124125 **Hard rule on `placement_description`:** like `object_name` in126 `perceiving-objects`, this is a literal Python string — the natural127 noun phrase describing **where the held object should land**, drawn128 from this subgraph's description. It is a constant per subgraph129 instance, NOT a binding. **DO NOT** write130 `Ref("in.placement_description")` or any `$ref`; the131 coordinator does not declare `placement_description` as a subgraph132 input. Write the string directly.133134 The phrase should describe the placement REGION, not the named135 reference object. Examples:136 - Task: "Pick up the book and place it in the **left compartment of137 the caddy**." → `"the left compartment of the caddy"`.138 - Task: "Put the chocolate pudding **to the left of the plate**." →139 `"the area to the left of the plate"`.140 - Task: "Put the ketchup **in the top drawer of the cabinet**." →141 `"the inside of the top drawer of the cabinet"`.142 - Task: "Pick up the book and place it **on top of the shelf**." →143 `"on top of the cabinet shelf"`.144145 ```json146 "perceive_zone": {147 "type": "script",148 "script": "scripts/<sg>/perceive_placement_zone.py",149 "inputs": {150 "placement_description": "the left compartment of the caddy",151 "container_obb": Ref("in.container_obb"),152 "container_mask": Ref("in.container_mask")153 }154 }155 ```156157 Both `container_obb` and `container_mask` are OPTIONAL but strongly158 recommended:159 * `container_mask` filters DINO detections to those whose box center160 falls inside the container (caddy/drawer/shelf), which removes161 noise boxes elsewhere in the image and frees the limited162 labeled-box slots for actual sub-region candidates.163 * `container_obb` lets the script reject candidate zones that drift164 too far in XY from the container center (a common failure mode165 when the VLM's chosen mask projects to background through bad166 depth). It's also used to choose between the DINO and SAM3-text167 paths — the candidate closer to the container center wins.168169 When the script returns `None` (zone not visible, low confidence,170 sanity-check failure), the downstream `compute_drop` falls back to171 the bare-container path automatically — no extra graph wiring172 needed.1731741. **`compute_drop`** — `type: script`, file175 `scripts/<sg>/compute_drop_pose.py`. Use the canonical script as-is;176 do **not** emit a ``` ```python:scripts/<sg>/compute_drop_pose.py``` ```177 block — the bundle's canonical script is materialized into the178 workflow directory automatically and re-emitting it overrides the179 correct implementation with an LLM reimplementation.180181 **Hard rule on parameter names:** the canonical script's `def run`182 signature is `(ctx, container_obb, container_interior_obb=None,183 ee_pose_at_grasp=None, drop_clearance=0.05, approach_height=0.20,184 held_obb=None, wrist_to_tcp=-1.0)`. Bind the held object as `held_obb`, **not**185 `target_obb`. Renaming `held_obb → target_obb` causes the runtime186 to silently drop the value (extra kwargs are warned and discarded);187 the script then falls into the no-held-geometry branch and the drop188 pose is wrong by the held object's full height.189190 When the optional `perceive_zone` state is present, bind191 `container_interior_obb` to its `placement_zone_obb` output so the192 drop targets the named sub-region instead of the bare container193 center:194195 ```json196 "compute_drop": {197 "type": "script",198 "script": "scripts/<sg>/compute_drop_pose.py",199 "inputs": {200 "container_obb": Ref("in.container_obb"),201 "held_obb": Ref("in.target_obb"),202 "ee_pose_at_grasp": Ref("in.ee_pose_at_grasp"),203 "container_interior_obb": Ref("perceive_zone.placement_zone_obb")204 }205 }206 ```207208 When `perceive_zone` is omitted (3-state flow), drop the209 `container_interior_obb` line:210211 ```json212 "compute_drop": {213 "type": "script",214 "script": "scripts/<sg>/compute_drop_pose.py",215 "inputs": {216 "container_obb": Ref("in.container_obb"),217 "held_obb": Ref("in.target_obb"),218 "ee_pose_at_grasp": Ref("in.ee_pose_at_grasp")219 }220 }221 ```222223 `ee_pose_at_grasp` is required for the LIBERO `In(obj, region)`224 predicate to fire after release — the script uses it to convert the225 desired held-object Z into a TCP target accounting for the hand's226 wrist-to-TCP offset (read off `robot.describe_arm` / `robot.describe_gripper`227 unless `wrist_to_tcp` pins it; the drop yaw is composed by228 `robot.grasp_frame`). The upstream `grasping-with-planner` subgraph229 publishes it as a cross-subgraph output (`produces_outputs.ee_pose_at_grasp`);230 this subgraph declares `ee_pose_at_grasp` in its `required_inputs`231 so the coordinator wires the binding by name.232233 Returns `drop_position` (Vec3), `drop_pose` (Se3Pose), `approach_pose`234 (Se3Pose).2351a. **`drop_offset`** — `type: script`, file236 `scripts/<sg>/drop_offset_pose.py`. **REQUIRED whenever the237 upstream grasp was on a SUBPART** (e.g. frypan handle, kettle238 spout, bottle neck, tool grip). Omit ONLY when the grasp was on239 the object's geometric centroid (`parent_obb == held_obb` case).240241 **HOW TO TELL: the upstream perception subgraph is242 `perceiving-object-parts`**, OR the task description names the grasp243 location explicitly ("grasp the pan by its handle", "lift it by244 the spout"). In both cases the gripper closes on a subpart and245 the parent body hangs off-axis — without `drop_offset` the246 `compute_drop` script puts the *grasp point* at the placement247 zone, which means the *body* lands off the support. This is the248 dominant failure mode for handle-grasp-then-place-ON tasks: the249 goal predicate reports `pan.bottom_z << burner.top_z` (pan250 centroid below the support) and `xy_coverage_over` < 0.5 even251 though the workflow itself returned `placed`.252253 **Repair trigger.** When repairing a transport subgraph that254 failed with the above signature AND the upstream is255 `perceiving-object-parts`, **insert `drop_offset` even if a prior256 iteration omitted it**. Do not assume the absence of `drop_offset`257 in the existing workflow is intentional; it is the single most258 common omission.259260 Inputs:261 `drop_pose = Ref("compute_drop.drop_pose")`,262 `ee_pose_at_grasp = Ref("in.ee_pose_at_grasp")`,263 `held_obb = Ref("in.target_obb")` (the grasped subpart OBB —264 same as compute_drop.held_obb),265 `parent_obb = Ref("in.parent_obb")` (the full object OBB — the266 coordinator must declare `parent_obb` in this subgraph's `inputs`267 and wire it from the perception subgraph's parent output, e.g.268 `perception_sg.parent_obb`).269 Returns `drop_position`, `drop_pose`, `approach_pose` — all shifted270 in XY so the parent centroid lands at the original drop XY.271 Downstream `move_above` / `release` then reference272 `drop_offset.drop_position` instead of `compute_drop.drop_position`.2732. **`move_above`** — `type: script`, file274 `scripts/<sg>/waypoint_move.py`. Inputs:275 `drop_x = Ref("compute_drop.drop_position.x")`,276 `drop_y = Ref("compute_drop.drop_position.y")`277 (or `Ref("drop_offset.drop_position.x")` / `.y` when the optional278 `drop_offset` node is present). Lifts to a safe height at the current279 XY, then moves laterally to above the drop XY. The safe height is280 `robot.describe_workspace`'s `transport_z` unless `safe_height` pins it281 (pin it when a held tool hangs below the fingertips and a container rim282 sits above the work surface); when the lateral leg is out of reach it283 retries up to three rungs 3 cm lower — never below the surface plus284 `align_clearance_m` — and reports `flown_z` / `descents` beside `done`.285286 **Variant — collision-aware lift/translate (`waypoint_move_carve`)**.287 Same inputs (`drop_x`, `drop_y`) and same return shape, but the node288 rebuilds the world from a fresh observation and routes through289 `curobo.plan_with_grasped_object` instead of the connector's290 `robot.go_to_pose_cartesian` (which is TCP-aware but not world-aware).291 Use when a known obstacle sits on the transport path between the292 grasp pose and the drop XY (an oven door, a shelf above the table, a293 tall bottle the lift would clip). A repair pass may flip the294 transport subgraph to this variant when the `transport` stage295 pass-rate drops below the configured threshold — its hypothesis is296 "free-space transport plowed through a perceived obstacle".2973. **`release`** — `type: script`, file `scripts/<sg>/descend_release.py`.298 Inputs: `drop_position = Ref("compute_drop.drop_position")`299 (or `Ref("drop_offset.drop_position")` when `drop_offset` is present).300 Descends, opens the gripper, retracts home (`robot.go_home` on the work301 arm named by `robot.describe_arm`; the default rotation is this hand's302 `robot.grasp_frame`). On success → exit `placed`; on failure → exit303 `blocked`.304305 **Variant — linear descent (`descend_release_linear`)**. Same306 node-level contract, but the descent goes through the connector's307 TCP-aware `robot.go_to_pose_cartesian` (cuRobo linear plan with a308 `plan_to_pose` fallback built into the backend), so the held object309 descends on a straight Cartesian line with the orientation held — the310 cleanest release dynamics for subpart-grasp + place-ON tasks (frypan311 handle → stove). After the open it takes a 5 cm straight-up retreat at312 the same wrist rotation and `robot.wait_steps` before `robot.go_home`,313 so the fingers cannot drag the just-released object.314315## Required end states316317| End state | Meaning |318|---|---|319| `placed` | Object released at the destination. Route to the next subgraph or to `done`. |320| `blocked` | Path blocked or motion failed; aborted before release. Coordinator routes to abort. |321322323## See also324325- `references/clearance_constants.md` — the magic numbers.326- `references/design_transport.md` — why no planner is needed for the327 default scope.328- `scripts/{compute_drop_pose,drop_offset_pose,waypoint_move,waypoint_move_carve,descend_release,descend_release_linear,approach_above,lift_grasped,perceive_placement_zone}.py`329 — canonical scripts.330- `prompts/vlm_select_zone.md` — VLM prompt template for the optional331 `perceive_zone` state.332333## Placing an object rather than a tool centre334335`transport_descend_linear` plans, by default, for the point between the pads:336the carry height clears the rim by a fixed amount and the interior margin is a337single symmetric inset. That is right when what is held is small next to the338container and wrong when it is not -- a thick object hangs below the tool centre339and is dragged over the dividers the thin one flies across, and a long object340put down crosswise lands on a divider rather than in a cell.341342Four opt-in parameters close that gap, each defaulting to the tool-centre rule343so no existing caller moves (checked in344`tests/test_promotion_is_behaviour_preserving.py`):345346| parameter | what it buys |347| --- | --- |348| `target_obb` | the carry height clears the object's underside, and the interior margins keep the object -- not the tool centre -- inside the walls |349| `carry_cap_m` | where extra height stops being free, because horizontal reach falls away above a peak |350| `align_to_container` | turns the object's long axis onto the container's during the lift, so the turn costs no separate motion |351| `level_lift` | lift to the full carry height before translating, so the crossing runs level instead of climbing over the dividers |352| `nearest_first_fallbacks` | order the corner fallbacks by distance from the hand |353354`surface_inset_m` tells `target_obb` how far below the object's top surface the355grasp put the tool centre; it must match whatever the grasp node used, and it is356what turns thickness into hang.