Skip to content

feat(hangar_sim): refine localization in place, with a calibrated fit-to-map gate - #933

Draft
bkanator wants to merge 21 commits into
mainfrom
fm/refine-localization-port
Draft

bkanator wants to merge 21 commits into
mainfrom
fm/refine-localization-port

Conversation

@bkanator

@bkanator bkanator commented Sep 11, 2026

Copy link
Copy Markdown

Ports meta_ws's refine-localization-in-place capability to hangar_sim, plus the manual-localization step it sits on top of. Tracks PickNikRobotics/moveit_pro#22542 (that repo has issues disabled, so the work lands here).

DRAFT, pushed on request so the diff is visible now. The validation pipeline completed intent, rebase and review (11 fix rounds); test, document, lint and CI have not run. Marked draft pending those.

The problem

After an operator sets the robot's pose on the map, the estimate stays exactly as good as that input until someone drives the base — AMCL will not update a filter whose base has not moved past update_min_d / update_min_a.

Measured result, against MuJoCo ground truth

Seeded deliberately off by dx=0.35 m, dy=-0.25 m, dyaw=8° (xy_std_dev 0.5):

position heading
before refinement 45.0 cm 5.39°
after 20 forced no-motion updates, base stationary 6.5 cm 0.06°

~7× better in position, ~90× in heading — in line with meta_ws's 4–5 cm / <1°. /request_nomotion_update was confirmed present and responding on beluga_amcl, closing an open question in the meta_ws report.

The cold-boot observation that overturned earlier reasoning

An earlier round of this branch asserted, from strings and symbols in the beluga binary, that beluga global-initialises on map receipt and broadcasts map→odom from an unconverged cloud. Running it showed the opposite. With set_initial_pose: false, beluga reports lifecycle ACTIVE, consumes 356 /scan_merged messages, and publishes zero /amcl_pose while map→odom raises ConnectivityException. Seeding makes the edge appear at once.

The "distributed across the map" initialisation string does exist in the binary, but that path is not taken with this configuration. Reading the strings gave the opposite answer to running it.

Consequences, all confirmed rather than argued: the bootstrap deadlock is real, so relabelling the pose into the map frame is necessary rather than merely defensible; the integration fixture waits on can_transform because with no edge before the seed, the edge appearing is evidence the seed was consumed; and an unseeded filter genuinely splits the TF tree, so every point-cloud Objective depends on this, not just navigation.

Gate calibrated on hangar_mapmin_inlier_fraction 0.60

Measured with the shipped scoring code against the grid map_server actually publishes (1007×1231 @ 0.05 m, origin −25.1/−2.9):

score
true pose 87.0%
0.10 m / 1° off (must accept) 69.6%
0.25 m / 3° off (must reject) 52.2%
strongest alias anywhere on the map 47.8%
honest band 52.2% – 69.6%

The squeeze: the gate must accept a 0.10 m near-miss at 69.6% and reject a 0.25 m wrong pose at 52.2% — a 17-point window, which 0.60 splits. Higher starts rejecting refinements the loop legitimately returns; lower starts admitting poses it must reject. That window is narrow on hangar_sim, narrower than meta_ws's.

A structural blind spot worth knowing: a 0.10 m pure translation with correct yaw scores 87.0% — identical to the true pose — because it is inside the 0.15 m inlier_distance. The gate cannot detect an error smaller than inlier_distance, and the refinement returns ~6.5 cm, inside that blind spot. What makes those centimetres trustworthy is the seed's density, not the gate.

The numbers moved twice, and a reader should see the correction: the first sweep used alias_keepout_m = 2.0 m and reported the strongest alias at 34.8% with a 52.2-point separation — but it skipped every candidate closer than 2.0 m. Dropping the keepout to the 1.2 m drift limit found a stronger alias at 47.8%, sitting 1.28 m from truth inside the annulus the first sweep skipped. Folding in offset rings inside the accept region then showed the real constraint is not the alias at all, but the 0.25 m must-reject ring. Both revisions made the margin smaller and more honest.

Caveat, prominently: every sample came from one stationary pose, so "the true pose" is the only true pose measured. A driven multi-pose campaign is owed, and dump_localization_calibration_data.py must be run stationary or given stamp synchronisation first — a scan stale relative to the recorded truth depresses the true-pose score and biases the threshold downward. Only 23 of 60 selected beams survive the range filters on this robot's merged scan, so the fraction moves in ~4.3-point steps: the gate is coarse here in a way it was not on meta_ws's denser scan.

The marker

Localize Robot uses AdjustPoseWithIMarker, not a click. GetPoseFromUser returns the ray hit for position and, with is_normal, the clicked face normal for orientation — which on a floor is not a heading. Heading cannot be recovered afterwards: a forced no-motion update selects among the particles the seed scattered, so a seed pointing the wrong way stays pointing the wrong way; and widening yaw_std_dev is how a filter converges facing the wrong way.

Not a contradiction of meta_ws's navigate_to_imarker_pose.xml. That Objective converts the marker pose to map with TransformPoseFrame and is correct to: it sets a navigation goal, where the robot is localized and map→odom exists. This one seeds, at cold boot, where that edge does not exist — so it relabels with ReinterpretPoseFrame. Same conversion, opposite precondition.

Known limitations and risks

  • The restore can replace a good estimate. Measured: a good estimate at 9.4 cm error, given a seed 1.0 m off, went to 93.6 cm; the refinement recovered it to 4.4 cm; re-seeding that same bad pose — what the restore branch does on gate rejection — left the filter at 95.6 cm. This contradicts the inherited meta_ws assumption that the worst case equals click-only localization. True at cold boot; false when re-running over an already-good estimate. Named as follow-up.
  • The cold-boot fallback cannot tell two cases apart. SetInitialPose / GetLatestTransform fail on any unavailable map→ridgeback_base_link, so a never-seeded filter and a transiently unavailable estimate look identical. The second routes an already-localized robot into re-localization, reaching the hazard above from another direction. Messages state the symptom and warn about replacement; distinguishing the cases is follow-up.
  • Relabel bound is sim-only. Driving 1.69 m and rotating 81.5° moved |map→odom| from 1.2 cm / 0.92° to 6.7 cm / 0.02°. hangar_sim's odometry is MuJoCo ground truth via the virtual-rail joints, so the correction barely grows here. This is not a hardware bound — on dead-reckoned odometry it grows much larger.
  • Cancellation is deferred up to ~10 s. ScanMatchResidual is a SyncActionNode blocking up to timeout for /map and again for the scan (5 s each), and SeedLocalizationAtPose up to 5 s for a subscriber. The objective server holds change_tree_state_mutex_ across tickOnce, so a cancel cannot land until the tick returns — the same window that can leave a partially refined, ungated pose.
  • Duplication, deliberately not resolved here. The ~70-line cold-boot Fallback is identical in both navigation Objectives, with a near-copy in refine_localization_in_place.xml. That contradicts this branch's own argument for extracting the refinement into one subtree referenced twice. Extracting it in a final round would trade a reviewed shape for an unknown one — follow-up.
  • Calibration re-measurement needs the tool restored from cffa2b176eb73db7587f0c64607202d20ad0335e — see above.
  • Stale gate constants. kYawDriftLimit is read by nothing; kMinInlierFraction only by a unit test. The thresholds the gate runs on live in the subtree's input-port defaults, so a re-calibration that changes the XML leaves the header constants stale — follow-up.

Not verified

  • The 6-DOF marker's operator-facing rendering for a BASE pose. AdjustPose.srv carries only prompts and initial_poses — no gripper, mesh or end-effector field — so the Objective cannot influence what is drawn; it is entirely UI-side, and the UI is the separate Desktop App. The evidence it is usable for a base pose is that meta_ws ships this same Behavior for a base goal on mike/WIP.
  • The cold-boot fallback branch has not been executed. It terminates in the marker prompt and no Desktop App was driven. What was measured is the condition that triggers it.
  • The restore path has not been exercised end to end in a live Objective run, because both runnable Objectives need an operator at the marker. The gate demonstrably rejects a bad pose (0.25 m/3° scores 52.2%, strongest alias 47.8%, both below 0.60), but that is a scoring measurement, not a run of the restore branch.

A defect class worth reporting

Review found the same defect in three kinds of location over successive rounds: a failure path confidently naming a cause it has not established. An audit of every operator-facing failure message this branch added found 8 of 12 wrong — the review had caught 2 of those 8. The same false claim then turned up in the nav2_params.yaml narrative comment, and finally in the BT node names, which show in the UI tree view and logs. Fixing the message is not the same as fixing the claim.

The calibration tool was removed — and where to get it back

calibrate_scan_match_gate and the calibration_io pair it was the only consumer of are not in this branch. They were removed at the captain's request to keep the change tight; C++ in the package drops from 3115 to 2345 lines. Nothing else included calibration_io and nothing else built or ran the tool, so it came out cleanly and the package still builds with its unit tests passing.

Everything the tool produced stays. The measured hangar_map table, min_inlier_fraction 0.60, the 52.2–87.0% band, the 17-point accept/reject window, the 23-of-60 beam granularity and the one-stationary-pose caveat are findings, not tooling, and they remain in localization_gates.hpp and on the subtree's port documentation.

Restore it, do not rewrite it. The tool lives in this branch's history at

cffa2b176eb73db7587f0c64607202d20ad0335e

That sha is named in localization_gates.hpp, in dump_localization_calibration_data.py's docstring, and here — so the driven multi-pose calibration campaign starts by recovering a reviewed tool rather than writing a new one. dump_localization_calibration_data.py stays in the branch: it is Python rather than C++ volume, and it writes the two files the restored tool consumes.

C++ volume compared to meta_ws

A reviewer will ask, so: meta_ws shipped this capability in roughly 1,342 lines of C++ (fa11d47 on mike/WIP: get_amcl_pose, localization_gates, request_nomotion_update, scan_match_residual, wait_for_one_message, plus a 345-line gate test). This package is 2,345 after the strip.

Most of the difference is the frame handling their robot never needed. meta_ws seeds a filter whose map→odom already exists, so a single TransformPoseFrame does the job. We seed at cold boot, where that edge does not exist at all — measured, see above — which is why this branch carries ReinterpretPoseFrame (with its expected-input-frame guard), ProjectPoseToPlane and planar_pose.hpp, plus the three-branch conditional fallback and its IsUserAvailable gate in three Objectives. The remainder is CallEmptyService, which is a generic mirror of core's CallTriggerService and is recommended below for promotion out of this package entirely.

Where these Behaviors belong — question for the reviewer

Four of the five are correctly local: ScanMatchResidual, SeedLocalizationAtPose, ProjectPoseToPlane and ReinterpretPoseFrame encode this robot's merged scan, this map's likelihood field or a 2D filter, and their port defaults are hangar_map calibration values. CallEmptyService is different in kind — no hangar-specific content, a mechanical mirror of core's CallTriggerService differing only in service type — so it is the one that belongs in the shared library.

For the record: it does not close moveit_pro#18066. slam_toolbox/srv/SaveMap is std_msgs/String name in, uint8 result out — a filename and a result code, not std_srvs/Empty. The genuine case for an Empty caller is that beluga_amcl alone advertises two: /request_nomotion_update and /reinitialize_global_localization — the second of which must never be used inside a click-bounded refinement, because it scatters particles uniformly over the whole map.

🤖 Generated with Claude Code

bkanator and others added 20 commits September 10, 2026 15:45
Ports meta_ws's refine-localization-in-place capability to hangar_sim, plus the
manual-localization step it sits on top of. Tracked as moveit_pro#22542.

New package hangar_sim_behaviors:

- CallEmptyService: a generic std_srvs/Empty service caller, following
  CallTriggerService's shape (ServiceClientBehaviorBase, service_name port).
  Core only handles std_srvs::srv::Trigger, and the two types are unrelated, so
  no configuration of CallTriggerService reaches an Empty service. beluga_amcl
  alone advertises two: /request_nomotion_update and
  /reinitialize_global_localization.

- ScanMatchResidual: the fit-to-map gate. Reads beluga's own likelihood field one
  step before beluga turns distances into likelihoods and reports the fraction of
  the beams the filter would have scored whose endpoints land within a tolerance
  of an occupied cell. Covariance cannot be the acceptance test: it falls as the
  particle set depletes, so it shrinks whether the surviving pose is right or
  wrong.

- SeedLocalizationAtPose: seeds the filter from an arbitrary pose. Core's
  SetInitialPose seeds from TF -- from the estimate the filter already holds --
  so it can tighten a cloud but never move it, and cannot restore an operator's
  click after a rejected refinement.

localization_gates.{hpp,cpp} holds the ROS-free geometry, mirroring beluga rather
than nav2_amcl in the three places the two differ and it matters: beam decimation
is take_evenly, not an integer index step; cell lookup floors in the grid frame
rather than rounding to the nearest centre (a systematic half-cell, 0.025 m here);
and a cell is an obstacle at exactly 100, not at >= occupied_thresh. Each
difference is pinned by a unit test against the nav2 rule it is not.

test_localization_gates.cpp also holds one failing-by-design case: a perfectly
periodic corridor, where a pose one bay along scores exactly as well as the true
one. That is a real limit of any fit-to-map test and the reason the refinement is
click-seeded and drift-bounded rather than a search.

calibrate_scan_match_gate + dump_localization_calibration_data.py re-measure the
threshold against a specific map. The dump captures the grid map_server actually
published rather than re-deriving it from the .pgm with a second loader, which
removes the cell-for-cell equivalence question instead of answering it.

The threshold in this commit is still meta_ws's 0.80 and has NOT yet been
measured against hangar_map. Calibration and the against-ground-truth
verification both need exclusive sim access, which is not available yet.

Objectives (shape is provisional -- see below):

- Localize Robot: click, transform the click out of the planning frame into map,
  seed. Ported from meta_ws's localize_robot.xml.
- Refine Localization In Place: a non-runnable subtree that seeds, runs a bounded
  loop of forced no-motion updates, gates the result on drift and on fit to the
  map, and either keeps the refined pose or restores the click.
- Localize Robot and Refine: the two composed, for an operator.

amcl now runs with set_initial_pose: false, because a robot does not power on at
a known pose. That has a consequence specific to this workspace: AMCL's map ->
odom is the only link between the MuJoCo scene frames and MoveIt's planning
frame, so with the filter unseeded every point-cloud Objective fails its
transform, not just the navigation ones. The integration test therefore seeds the
filter once in a fixture, from the spawn pose recorded in nav2_params.yaml.

Not verified in this commit: the package builds except for one quaternion
construction fixed after the last successful compile, and no test has been run
since. Builds are on hold while a timing-sensitive measurement has the host.
Applies the captain's decision on objective shape. The refinement is a reusable
subtree; both entry points reference it rather than copying it.

- refine_localization_in_place_subtree.xml: the tree itself, runnable="false".
- Localize Robot: click, transform the click into map, then call the subtree.
  Seeding and refining are one operator action, so a careless click is caught
  before the robot moves rather than after.
- Refine Localization In Place: the bumped-robot case. Seeds from the estimate
  the filter already holds (map -> ridgeback_base_link) instead of from a click,
  so it needs no operator input. That also makes it the one runnable Objective in
  this feature the headless integration suite CAN execute, which is why it is not
  in skip_objectives: it exercises the forced-update loop and both gates against
  the seed the localized_robot fixture applied.

Replaces localize_robot_and_refine.xml, which was a second copy of the same
composition.

Favorites: hangar_sim was at 7 of the 8 the favorites check allows, so exactly one
of these can be favorited. Localize Robot is the one an operator reaches for.
…ction claim

Two fixes found by reading, both in the refinement subtree.

1. The comment on the fit-to-map gate contained a literal "--", which XML
   forbids inside a comment. The file did not parse. This is the class of defect
   meta_ws warned about: a subtree that is never instantiated is not verified,
   and reading the XML does not catch it. All three Objective files now parse.

2. The drift-limit comment claimed hangar_sim's non-zero recovery_alpha_slow/fast
   mean beluga "MAY inject particles drawn uniformly over the whole map during
   the loop", and that the seed's ~4 sigma bound was therefore not structural
   here. Traced through beluga, that overstates it.

   What is true: beluga's injected states come from
   MultivariateUniformDistribution<SE2d, OccupancyGrid>, which draws a translation
   uniformly from grid.free_cells() and a heading from SO2d::sampleUniform -- the
   whole map, any orientation. If it fired inside the loop, the bound would go.

   What is also true, and is why it does not: amcl_core applies `normalize`
   immediately before the recovery estimator, and normalize divides by the total
   weight, so the estimator's input is exactly 1/N on every update. It measures
   how the particle COUNT moves, not how well the scan fits. A converging in-place
   refinement only depletes the set: N falls, 1/N rises, the fast average leads the
   slow one, and 1 - fast/slow clamps to zero. Injection requires N to grow.

   The gate stays, because a transient N increase right after a bad seed can still
   inject. The comment now says which of those two things it is.

Separately, and NOT changed here: the same trace implies hangar_sim's recovery
alphas are probably not buying the kidnapped-robot recovery they look like they
buy, since in beluga they cannot see fit quality at all. That is a config finding
for its own ticket.

Also verified by reading, against the compiled libraries rather than the
docstrings (GetLatestTransform's docstring is stale, so the docstrings are not
trustworthy): every Behavior ID used by the three Objectives is registered by a
loader hangar_sim configures, and every port name each one passes exists.
calibrate_scan_match_gate defined both worstOverOffsetRing and bestOverOffsetRing
but only ever called the second. The workspace compiles with -Werror, so the dead
one failed the build with -Werror=unused-function.

Keeping the best-case one is the deliberate half: a threshold has to reject the
BEST-scoring wrong pose at a given offset, not merely beat the worst one, so the
worst-case figure was never the number the calibration wanted.
…tf2 path

The package had never been linked. Every earlier build died at compile
(-Werror=unused-function) before the executable link, so an undefined
tf2::fromMsg(geometry_msgs::msg::Quaternion const&, tf2::Quaternion&) sat in
libhangar_sim_behaviors.so unnoticed, and the review round that added
planar_pose.hpp and test_planar_pose.cpp never reached a build either.

tf2::getYaw resolves through tf2::fromMsg, which is an inline in
tf2_geometry_msgs.hpp rather than in tf2/utils.hpp. Whether that links depends on
include ORDER -- two-phase lookup needs the definition visible before the
template that calls it -- and this repository sorts includes, so the include that
fixes it does not stay where it is put. The failure also lands badly: object
files compile, the shared library links, and only something linking that library
fails, which is why it surfaced on calibrate_scan_match_gate.

So the package computes yaw itself. planar_pose.hpp gains yawOf(), two lines of
atan2, and scan_match_residual and seed_localization_at_pose use it. Yaw is the
only rotation a 2D localizer carries, so this is not a shortcut.

test_planar_pose reads yaw back with its own local atan2 rather than yawOf, so a
bug in yawOf cannot hide itself in the test that checks the projection.

Also disables the CLI's offer to upgrade this worktree to the stock example
workspace in the lane-private config: with no terminal on stdin the build hung on
that prompt, and answering yes would rewrite the task branch.

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

Everything here comes from running the simulator. Four defects on this branch had
already come from reasoning about it instead, and this round overturned one more.

BELUGA DOES NOT PUBLISH map -> odom UNTIL SEEDED. Measured: with
set_initial_pose false the node reports lifecycle state ACTIVE, consumes 356
/scan_merged messages, and still emits zero /amcl_pose while map -> odom raises
ConnectivityException. Publishing a seed makes the edge appear at once.

That is the opposite of what this branch claimed one round ago. beluga's binary
carries a "distributed across the map" initialisation string, which reads like it
global-initialises and broadcasts from an unconverged cloud; it does not do that
here. Both comments that asserted it are corrected, and both now say the claim was
observed rather than inferred, because the strings gave the wrong answer.

Consequences, all confirmed rather than argued:
- The bootstrap deadlock is real, so relabelling the click in the map frame is
  necessary rather than merely defensible. The relabel commit is restored.
- The integration fixture goes back to waiting on can_transform: with no edge at
  all before the seed, the edge appearing IS evidence the seed was consumed.
- An unseeded filter really does split the TF tree, so every point-cloud
  Objective, not just navigation, depends on this.

GATE CALIBRATED ON hangar_map, with the shipped scoring code against the grid
map_server actually publishes (1007 x 1231 at 0.05 m, origin -25.1/-2.9):

  true pose                     87.0%
  pose 0.10 m / 1 deg off       69.6%
  pose 0.25 m / 3 deg off       30.4%
  strongest alias on the map    34.8%   at (-2.67, 29.93)
  separation                    52.2 points   (meta_ws measured ~21 on theirs)

min_inlier_fraction is 0.60, not the 0.80 meta_ws ships. 0.80 sits only 7 points
under the true pose here AND above the 69.6% a 0.10 m error scores, while the
refinement returns about 6.5 cm -- it would reject the refinements the loop
legitimately produces. 0.60 still clears the strongest alias by 25 points.

Two properties of that measurement travel with it. Only 23 of the 60 selected
beams survive the range filters on this robot's merged scan, so the fraction moves
in steps of about 4.3 points: the gate is coarse here in a way it was not on
meta_ws's denser scan. And every sample came from one stationary pose, so "the
true pose" is the only true pose measured. A driven multi-pose campaign is owed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
"Localize Robot" asked for a click. GetPoseFromUser returns the ray hit for
position and, with is_normal, the clicked FACE NORMAL for orientation, which on a
floor is not a heading. Heading is the axis this Objective exists to pin down and
it cannot be recovered afterwards: a forced no-motion update SELECTS among the
particles the seed scattered, so a seed pointing the wrong way stays pointing the
wrong way, and the fit-to-map gate then rejects it. Widening yaw_std_dev is not
the answer either -- a cloud spread wide in yaw is how a filter converges facing
the wrong way.

So the pose comes from AdjustPoseWithIMarker, which returns exactly the pose the
operator places, heading included. Four things it does, each recorded in meta_ws's
navigate_to_imarker_pose.xml: it takes a LIST, so the seed is wrapped in a
one-element vector and read back at index 0; it handles exactly one pose; the
prompt must contain no semicolon, because the port is a string list that splits on
it; and it always returns poses in 'world'.

That last one lands in the trap this branch already measured, so this Objective
does NOT follow the reference's next step. meta_ws transforms the marker pose into
map with TransformPoseFrame, and is right to: that Objective sets a NAVIGATION
goal, by which point the robot is localized and map -> odom exists and means
something. This one SEEDS, at cold boot, where that edge does not exist at all --
so the pose is relabelled through ReinterpretPoseFrame instead. Same conversion,
opposite precondition.

What the operator sees is NOT settled. AdjustPose.srv carries only prompts and
initial_poses, with no gripper, mesh or end-effector field, so the Objective cannot
influence what is drawn and the rendering is entirely UI-side. The MoveIt Pro UI is
the separate Desktop App -- the runtime's 3200 endpoint is an API, not a web app --
so it cannot be observed headlessly. The evidence that it is usable for a base pose
is that meta_ws ships exactly this Behavior for a base goal on mike/WIP.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…verride; widen alias sweep; correct localization comments
@coderabbitai

coderabbitai Bot commented Sep 11, 2026

Copy link
Copy Markdown

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true

Comment @coderabbitai help to get the list of available commands.

Removes calibrate_scan_match_gate and the calibration_io pair it was the only
consumer of, with their CMakeLists entries. Verified before removing: nothing
else in the workspace includes calibration_io, and nothing else builds or runs
the tool. The package still builds and its unit tests still pass.

C++ in this package goes from 3115 to 2345 lines. Removed at the captain's
request to keep the change tight.

WHAT THIS DOES NOT REMOVE: everything the tool produced. The measured hangar_map
table, min_inlier_fraction 0.60, the 52.2%-87.0% band, the 17-point
accept/reject window, the 23-of-60 beam granularity and the one-stationary-pose
caveat are findings, not tooling, and they stay in localization_gates.hpp and on
the subtree's port documentation.

RECOVERABILITY. The tool is not rewritten from scratch when the driven
multi-pose calibration campaign happens -- it is restored from this branch's own
history at

  cffa2b1

That sha is named in localization_gates.hpp, in the dump script's docstring and
in the PR body, so whoever picks the campaign up finds it without having to dig
through the log.

dump_localization_calibration_data.py stays: it is Python, not part of the C++
volume being cut, and it writes the two files the restored tool consumes. Its
docstring now says where the tool went.

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

Copy link
Copy Markdown

MoveIt Pro Example WS - Objectives Integration Test Report

@github-actions

Copy link
Copy Markdown

MoveIt Pro Example WS - Objectives Integration Test Report

@bkanator

Copy link
Copy Markdown
Author

Update: need to re-arch this to put commn nav behaviors in the nav folder in moveit pro to support these new objectives.

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