Skip to content

Collision checking: keep-out shapes, enablement gates, MoveIt-style invalidation#24

Merged
Jepson2k merged 38 commits into
mainfrom
feat/collision
Jul 9, 2026
Merged

Collision checking: keep-out shapes, enablement gates, MoveIt-style invalidation#24
Jepson2k merged 38 commits into
mainfrom
feat/collision

Conversation

@Jepson2k

@Jepson2k Jepson2k commented Jul 8, 2026

Copy link
Copy Markdown
Collaborator

Implements the waldoctl collision contract end-to-end (35 commits, 5 review rounds):

  • Self + world collision via pinokin CollisionChecker: status reporting, clearance config, jog speed-buffer, directional enablement (joint/cartesian button greying via the ik-worker gates)
  • Keep-out shapes: acked SET_SHAPES SystemCommand with explicit planner sync, canonical waldoctl Shape in-process (codec confined to the wire), installation layer + program layer (last-write-wins), controller readback, per-shape margin plumbed to pinokin
  • Escape semantics: when already inside a keep-out, motion that increases clearance and adds no new colliding pairs is allowed (jog, JogL, both ik-worker gates, collision_blocked)
  • MoveIt-style in-flight invalidation: SegmentPlayer re-guards streaming trajectory rows on shapes_version change and guards every TrajectorySegment at activation
  • Tool geometry in the pair vocabulary as tool:{key}:{role}
  • CI: pre-install ruckig from fixed upstream SHA (PyPI 0.17.3 sdist breaks under scikit-build-core 1.0); ty fix for numba 0.66 stubs

Depends on waldoctl v0.5.0 + pinokin v0.1.7 (pins to be bumped on this branch before merge).
Replaces #17 (same work, branch renamed to feat/collision so cross-repo CI branch-matching finds the waldoctl/pinokin feature branches).

🤖 Generated with Claude Code

https://claude.ai/code/session_01M7tEqGhaTqJV6xCXgMpFU

Jepson2k and others added 30 commits May 14, 2026 00:13
Pre-flight self/world collision validation is applied before any motion
command leaves the host. Failure raises MotionError(SYS_SELF_COLLISION)
for offline planners (MoveJ, MoveJPose, MoveL, blended chains) and
mirrors the IK-failure graceful-stop pathway for streaming JogL.

Changes
- parol6/PAROL6_ROBOT.py: module-level collision: CollisionChecker | None
  singleton alongside the existing robot singleton. Lazy init via
  _init_collision_checker() called at the end of parol6/config.py once
  the COLLISION_* knobs exist. Geometry-load failures degrade to a
  warning + None so existing scripts keep running.
- parol6/PAROL6_ROBOT.py: _resolved_urdf_for_collision() rewrites
  package://parol6/meshes/... URIs to absolute file:// paths in a
  temp URDF. The bundled URDF was authored for a ROS package layout
  (meshes at parol6/meshes/) but the Python package places them at
  parol6/urdf_model/meshes/; rewriting keeps the source URDF untouched.
- parol6/config.py: COLLISION_CHECK_ENABLED, COLLISION_PATH_SAMPLES
  (default 16, needs benchmarking once scene/world geometry is added),
  COLLISION_SRDF_PATH (defaults to bundled PAROL6.srdf).
- parol6/urdf_model/srdf/PAROL6.srdf: disables 5 non-adjacent pairs
  (base<->L4/L5/L6, L1<->L5/L6) that are physically unreachable.
  Reduces enabled pair count from 15 to 10.
- parol6/utils/error_codes.py + error_catalog.py: SYS_SELF_COLLISION
  (code 54) with title/cause/effect/remedy template.
- parol6/commands/_collision_guard.py: shared guard_joint_path helper
  subsamples per COLLISION_PATH_SAMPLES, calls check_path, raises on
  first hit. guard_config for single-point checks.
- parol6/commands/joint_commands.py: guard MoveJ/MoveJPose in
  do_setup after JointPath.interpolate and do_setup_with_blend after
  build_composite_joint_path.
- parol6/commands/cartesian_commands.py: guard MoveL in
  _precompute_trajectory after JointPath.from_poses and in
  do_setup_with_blend. For JogL, check ik_result.q per tick before
  _track_and_send; on collision predict, call cse.stop() and set
  _ik_stopping=True exactly like IK failure does (graceful
  deceleration; no exception mid-jog).
- parol6/robot.py: public Robot.in_collision, check_trajectory,
  colliding_pairs, min_distance methods delegate to the singleton.
  All return safe defaults when checker is None.
- tests/unit/test_collision_integration.py: covers singleton init,
  SRDF effect on pair count, home-is-clear, the four public Robot
  methods, and guard raise/no-op behavior.

Opt-out via PAROL6_COLLISION_CHECK=0. Failure-to-init logs a warning
and disables checking; existing motion behavior is unchanged.

https://claude.ai/code/session_01TiEkni9M9ZJC88LmvJwUyf
Released pinokin (v0.1.6) doesn't ship CollisionChecker, so the singleton
stays None on those installs. Mark the integration assertions skip-on-None
rather than fail; the universal guard tests still exercise the PAROL6
wiring against a fake checker.
…ion)

CI's test matrix detects when a pinokin branch with the same name as the
PAROL6 PR branch exists. If so, conda is set up with libpinocchio +
libcoal so pinokin can be built from source from that branch, then
overrides the pinned v0.1.6 wheel via --force-reinstall --no-deps. This
exercises the actual cross-repo collision integration before pinokin
v0.1.7 ships.

If no matching branch exists, the existing plain-pip flow runs and any
PAROL6 code that requires unreleased pinokin features fails loudly
(intended - that's the contract).

Reverts the prior graceful-degradation experiment in
test_collision_integration.py: the integration tests now hard-assert on
collision being available rather than silently skipping when it isn't.
Closes the gaps from the parol6-vision (python-fcl) comparison so the
runtime safety net covers gripper-vs-environment and surfaces readable
diagnostics, while staying within the servo budget.

- URDF + tool registry switched to bundled *_simplified.stl twins
  (parol6/urdf_model/urdf/PAROL6.urdf collision blocks; every
  MeshSpec.file in parol6/tools.py). Visual rendering is unaffected
  (URDF visual blocks untouched; WC's urdf_scene already prefers the
  same _simplified meshes via _stl_to_url). Collision queries on
  simplified BVH are dramatically faster (p99 ~38 us on Pi, ~0.4% of
  a 10 ms servo tick).

- Robot.colliding_pairs and the underlying CollisionChecker call now
  return list[tuple[str, str]] of geometry-object names rather than
  opaque indices. _collision_guard.py renders pairs as
  "ssg48_body_simplified.stl vs L4_0" in the SYS_SELF_COLLISION error
  cause, so operators see what hit what without a separate name lookup.

- PAROL6_ROBOT.apply_tool now refreshes the singleton collision
  scene with the active tool's BODY/JAW meshes (attached to the L6
  frame). Tracks attached names in _active_tool_geom_names so the
  next tool change cleans up before the new one is attached. Variant
  resolution mirrors WC's swap_tool_mesh semantics. The hook lives
  only at apply_tool (NOT Robot.set_active_tool, which mutates a
  per-instance pinokin and never touches the global scene - note
  added to its docstring).

- SRDF (parol6/urdf_model/srdf/PAROL6.srdf) expanded to the
  defensive union of the original inspection set and the measured
  simplified-mesh overlap set: base_link <-> L4/L5/L6,
  L1 <-> L4/L5/L6, L2 <-> L4.

- New test_no_spurious_self_overlap_at_home_or_joint_limits audits
  the bundled simplified STLs across home + every single-axis joint
  limit + all 64 corners of the limit hypercube + 20 seeded random
  configs. Fails loud with the named pair when anything new slips
  through; that is the answer to "which pairs overlap on these
  meshes" - measurement, not inspection.

- New test_collision_check_speed_diagnostic prints per-call
  percentiles for in_collision and colliding_pairs, so the
  trajectory-build pre-flight and the JogLCommand mid-motion check
  can be evaluated against the 100 Hz tick budget. Measured here:
  p99 ~38 us for both, well inside budget.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
"Planned configuration would self-collide at sample 7/50: pairs=ssg48_body_simplified.stl vs L4_0"
reads awkwardly because "pairs=" is dropped into the middle of the
sentence. Phrase as
"Planned configuration would self-collide at sample 7 of 50: ssg48_body_simplified.stl vs L4_0"
instead. {pairs} is now substituted directly without a key= prefix.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Safety coverage:
- JogJ stops before streaming a self-colliding configuration (only fires in the
  collision case, so normal jogging is unaffected; an abrupt stop beats driving
  the arm into itself — JogJ has no CSE smoother for a graceful stop).
- JogL release-deceleration skips streaming a colliding config and lets the CSE
  finish stopping (was sending unchecked IK configs after timer expiry).

Efficiency / cleanup:
- _refresh_collision_tool_geometry skips the STL reload + BVH rebuild when the
  (tool, variant) is unchanged — a TCP-offset-only apply_tool no longer reloads
  geometry on the control-loop thread (placement depends only on spec.origin).
- guard_joint_path: drop the dead concatenate/endpoint subsample work and the
  no-op inner ascontiguousarray (the entry conversion stays — load-bearing);
  hoist the four function-level guard imports to module top (cycle-free).
- _init_collision_checker takes (enabled, srdf_path) instead of importing config
  back (keeps the dependency one-directional); drop the dead package_dirs arg
  (all mesh URIs are rewritten to file://); reuse _mesh_dir.
- guard raises TrajectoryPlanningError (server-side planning convention), not the
  client-side MotionError. Delete dead guard_config.

Docs/comments: honest "Lazy" comment (+ TODO to actually defer the build off
import); SRDF final-state phrasing; cost estimate uses the measured ~38us/check;
neutral jog-recovery log; Robot collision-method docstrings note the
process-global checker; speed-diagnostic docstring reflects the shipped check.

Tests: TrajectoryPlanningError assertion; merged the three Robot() collision
tests into one shared-instance test; dropped the no-op try/finally del and the
redundant manual monkeypatch restore.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…decel + attach fixes

- MoveC/S/P now call guard_joint_path (were unchecked) [S4-2]
- guard_joint_path permits an escaping move when the start is already in
  collision (no new pair, no deeper) instead of trapping the arm [S4-1]
- collision-init failure raises unless PAROL6_ALLOW_NO_COLLISION set [S4-4]
- mesh URIs via Path.as_uri() so Windows file:// is valid [S4-6]
- tool geom-key set only after all attaches succeed, with rollback [S4-5]
- CartesianJog stops re-commanding jog velocity while _ik_stopping so
  cse.stop() actually decelerates [S4-3]

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
NumPy 2.5.0 (released 2026-06) violates numba 0.6x's numpy<2.5 pin, breaking
collection on Python 3.12+ (3.11 resolves an older numpy and passes). Bound
numpy to what numba supports.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Windows: rewrite package:// mesh refs to a plain POSIX path, not a file://
  URI. coal strips the scheme naively, leaving an invalid '/D:/...' (leading
  slash before the drive letter) so assimp couldn't open the meshes. as_posix()
  yields '/abs/...' on POSIX and 'D:/abs/...' on Windows — both openable.
- Lint: scope a ty unresolved-import override to PAROL6_ROBOT.py; the lint env's
  pinokin build can predate CollisionChecker (resolves fine at runtime).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
….yml

main (post-panels) needs waldoctl CameraSpec/ToolsCollection; pin v0.4.0.
tests.yml: take main's single Install-package step (job already defaults to
bash); the separate cross-repo step was redundant with the force-reinstall.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The matching-pinokin path builds into a conda env; the job defaults to the
conda-aware login shell (bash -l). An explicit shell:bash on Install package
made pip install into base python while pytest ran in the conda env -> numba
ModuleNotFoundError. Use the job-default login shell instead.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
When a move is blocked or a continuous jog is stopped by a predicted
self-collision, capture the colliding geometry/link pairs at the predicted
colliding config and surface them on the status broadcast so the frontend
can highlight the offending parts.

- ControllerState gains collision_active/collision_pairs + clear_collision();
  reset() clears them.
- Move path: the guard attaches structured pairs to TrajectoryPlanningError;
  they ride the pickled ErrorSegment across the planner subprocess and land on
  state in the segment player.
- Jog path: JogJ/JogL capture pairs on the (rare) stop branch only — never on
  the clean per-tick path, preserving the 100 Hz no-alloc rule. Cleared on a
  fresh jog, on JogL recovery, and at command intake.
- Wire: collision_active/collision_pairs appended to the STATUS tuple
  (len-guarded decode → backward compatible); cached + dirty-checked.
- Robot gains has_collision_checking to satisfy the new waldoctl ABC flag.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A SetShapesCmd carries the generic shape wire form (kind/params/pose/collision/
margin/name, matching waldoctl Shape.to_wire). Like SELECT_TOOL it rides the
planner→inline→segment-player path, so PAROL6_ROBOT.apply_shapes runs in both
the controller and planner processes against each one's collision checker.
Collision-enabled shapes become world obstacles via the generic add_obstacle;
visual-only shapes are skipped. async client gains set_shapes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ying

- COLLISION_CLEARANCE_M (default 5mm) sets the checker's fixed clearance at
  init, so all three process checkers keep a uniform near-miss buffer.
- JogJ now checks a velocity-scaled lookahead (COLLISION_JOG_LOOKAHEAD_S), so
  faster jogs stop further from contact; composes with the clearance. In-place
  to keep the 100Hz path allocation-free.
- The IK worker greys a joint direction whose small step self-collides
  (_gate_joint_enable_collision, proximity-gated), feeding the existing
  can_jog_* path so the jog buttons disable toward a self-collision. Cartesian
  and tool/shape greying need the ik-worker geometry sync (a follow-up).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Move the directional self-collision gate from the ik-worker (links-only) to the
controller's status_cache _poll_ik_results, which runs against the process-global
checker carrying the tool (apply_tool) and keep-out shapes (set_shapes). So the
jog/move joint buttons now grey for tool and shape collisions, not just
link-vs-link. gate_joint_enable_collision is promoted to a shared helper.
Cartesian-direction greying needs the per-direction IK targets and is a follow-up.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A _collision_checker property folds the repeated lazy import + None check used
by in_collision / check_trajectory / colliding_pairs / min_distance. Zero
behavior change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…mment

Streamable intake (servo/jog/teleport) now clears the collision viz like the
tool-action and planner intakes — a jog-collision red no longer outlives a
subsequent clean servo. The per-jog do_setup clears are dropped (intake covers
all streamables). _pose_to_matrix documents why Rz·Ry·Rx is deliberate (matches
the frontend's reversed-Euler render order; se3_from_rpy would mis-orient
shapes).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Per the collision plan: the worker's checker is kept in sync with the
controller's geometry (SyncTool/SyncShapes over a new command queue, driven by
tool-change detection + a shapes_version on state), the joint gate moves from
status_cache into the worker loop, and _compute_cart_enable now also requires
the solved config to be collision-free (proximity-gated) — cartesian jog
buttons grey toward blocked directions. A geometry sync recomputes at the last
requested pose, so greying updates even while the arm is stationary.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
set_active_tool now refreshes the global checker's tool meshes and apply_shapes
mirrors the client's set_shapes locally, so host-side collision queries
(editing-pose highlight, dry-run preview) see the same collision world as the
server.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…allow escape

JogJ only sampled the velocity-scaled lookahead, so anything inside the first
lookahead window at jog start was tunneled through unchecked; the lookahead
also evaluated poses past the joint limits, phantom-tripping near mechanical
stops. Both jogs now use a shared collision_blocked() in _collision_guard:
blocked when the next config collides, except when the arm is ALREADY inside
(a keep-out placed over it) — then only deeper motion is blocked, mirroring
guard_joint_path's start-in-collision escape rule. JogJ clamps the lookahead
to qlim in place.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ill the worker

Inside a colliding config, the joint/cartesian gates greyed all 24 directions,
locking the UI with no way out — they now grey only directions that go deeper.
_drain_sync applies each geometry sync under its own exception guard: one bad
tool mesh or plugin import must not permanently kill the enablement worker.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…r; dup-name validation

Shapes are safety configuration, not motion: as a MotionCommand they were
rejected while disabled (e-stop) and applied controller-side via an
InlineSegment that any segment-player cancel — including every streamed jog
packet's intake — silently drained, splitting the planner and control-loop
checkers with no error. Now modeled on SELECT_PROFILE: immediate at intake,
mirrored to the planner via a SyncShapes message, re-synced at e-stop/reset
alongside sync_tool. The sync client gains the missing set_shapes wrapper, and
apply_shapes validates duplicate names before mutating (pinokin raises
mid-add, which would leave a half-applied collision world).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…cs, gate clamp

- apply_shapes fully pre-validates (kind, param count, finite 6-pose, name
  uniqueness across ALL shapes incl. visual-only) before the checker-None
  return and before removing the old world — a malformed SET_SHAPES could
  previously reply error while silently disarming every enforced barrier.
- The dry-run planner re-applies SetShapesCmd inline (only reachable there;
  the live path routes SET_SHAPES as a SystemCommand + SyncShapes), so a
  script's set_shapes() shapes its preview world instead of being swallowed.
- set_active_tool guards the collision-geometry refresh: a plugin tool whose
  meshes live outside parol6's mesh root must not break tool selection after
  the kinematic transform applied.
- JogJ joint limits come from a once-per-process module cache — robot.qlim
  allocates a fresh matrix per access and do_setup re-runs per streamed
  packet, and the per-tick row indexing allocated view objects.
- The enablement gate's ±2° probe is clamped to qlim (like JogJ's lookahead),
  so geometry just past a mechanical stop can't grey a direction the jog
  itself would permit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…llation layer, margin, display vocabulary

- SET_SHAPES decodes to waldoctl Shapes at intake (the codec boundary);
  apply_shapes / SyncShapes / the planner all speak Shape — a preview
  script's set_shapes works end-to-end (previously TypeError)
- SET_SHAPES joins SYSTEM_CMD_TYPES: 1 confirmed / 0 timeout, raises on
  rejection; new SHAPES readback query; scene_epoch in the STATUS wire
- config INSTALLATION_SHAPES: an install: layer every program inherits,
  untouched by set_shapes, loaded per-process at import
- apply_shapes plumbs the per-shape margin into the checker; set-level
  validation only (values enforced at Shape construction)
- colliding pairs report URDF link names + shape:/install:/tool: names,
  never checker-internal identifiers; tool geometry attached as
  tool:<key>:<part>
- JogL release deceleration uses the escape-aware gate — the target no
  longer freezes when releasing while escaping a keep-out
- _pose_to_matrix documented against the waldoctl RPY contract
- CLAUDE.md: no-testing-theatre rules, no declared-but-unimplemented API

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M7tEqGhaTqJV6ZxCXgMpFU
Jepson2k and others added 4 commits July 7, 2026 18:24
…invalidation

- collision_blocked and both ik-worker escape gates now also block when the
  step introduces a colliding pair absent at the current config, matching
  guard_joint_path: the global min-distance trend alone cannot distinguish an
  improving start-collision from a new shallower one. pairs_now is hoisted
  once per gate pass; allocations stay confined to the rare in-collision branch.
- Attached tool geometry is named tool:{key}:{role} (per-role dedup suffix)
  instead of leaking raw STL basenames into error text and collision_pairs.
- SegmentPlayer re-guards committed motion against the current world: a
  shapes_version bump re-validates the streaming trajectory's remaining rows
  (zero-alloc int compare on the unchanged path), and every TrajectorySegment
  is guarded at activation — closing the planner-FIFO race where a segment
  planned against the old world arrives after SET_SHAPES applied. Violations
  halt exactly like an ErrorSegment via guard_joint_path's escape-aware,
  display-vocabulary error. Inline segments are not trajectory playback and
  are not re-guarded.

Red-first e2e coverage: mid-flight set_shapes halts the streaming move,
rejects a queued move at activation, and leaves off-path motion undisturbed;
unit coverage for the new-pair gate greying and tool-role pair vocabulary.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M7tEqGhaTqJV6ZxCXgMpFU
ruckig 0.17.3 on PyPI is sdist-only and its build config still uses
cmake.targets, which scikit-build-core 1.0 rejects as a hard error —
every CI job has died at dependency install since the 1.0 release.
Upstream fixed the rename on main (pantor/ruckig#262) but has not cut
a release, so pre-install that commit before ".[dev]" in both the lint
and test jobs; its version (0.17.3) satisfies the ruckig>=0.12.2 pin,
so the dev install leaves it in place. Drop the override once a fixed
ruckig release lands.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M7tEqGhaTqJV6ZxCXgMpFU
numba 0.66 ships a _dispatcher.pyi stub, so ty now type-checks njit
call arguments; the four module-level limit caches are ndarray | None
and need the same narrowing the solver/result caches already get.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M7tEqGhaTqJV6ZxCXgMpFU
Jepson2k and others added 4 commits July 8, 2026 02:30
Wheel URLs written from the actual v0.1.7 assets — the cp312-cp314
macOS wheels moved to macosx_26_0_arm64 in this build.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M7tEqGhaTqJV6ZxCXgMpFU
v0.1.7's cp312-314 macOS wheels were tagged macosx_26_0 (built on the
macos-latest pool mid-rollout), which pip rejects on macOS 15 runners.
v0.1.8 pins MACOSX_DEPLOYMENT_TARGET=15.0 in the wheel build, so every
macOS wheel is macosx_15_0_arm64.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M7tEqGhaTqJV6ZxCXgMpFU
waldoctl's StatusBuffer protocol declares cart_en as a plain attribute;
ty 0.0.57 enforces that a read-only property doesn't satisfy a mutable
protocol member. The dict is still built once in __post_init__, aliasing
the two enable arrays the decoder mutates in place — nothing per-tick.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M7tEqGhaTqJV6ZxCXgMpFU
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M7tEqGhaTqJV6ZxCXgMpFU
@Jepson2k Jepson2k merged commit b8cd2b6 into main Jul 9, 2026
26 checks passed
@Jepson2k Jepson2k deleted the feat/collision branch July 9, 2026 03:52
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