Skip to main content

mecha10_behavior_runtime/actions/
set_go_to_location_target.rs

1//! Action node that turns a clicked point on the dashboard's video feed into a
2//! `go-to-location` navigation goal, expressed in the robot's own frame.
3//!
4//! This is the MVP ("no SLAM") leg of click-to-navigate: there is no map or localization, so a
5//! click can't be projected onto a world frame. Instead the clicked point is projected onto the
6//! ground in front of the robot using a simple pinhole/ground-plane approximation (configurable
7//! field of view and max range) and published as a `NavigationGoal` whose `target` is a
8//! `base_link`-relative `Pose` - i.e. "drive `forward_m` forward and `bearing_rad` to the side of
9//! wherever you are right now". `GoToLocationNode` (the companion planning node) is responsible
10//! for turning that relative goal into motion via odometry-based dead reckoning.
11//!
12//! Mirrors `set_instance_track_target.rs`'s request/relay shape, but relays a normalized click
13//! point instead of a bounding box, and publishes a `NavigationGoal`/`navigation::CANCEL`
14//! instead of a node-specific control command.
15
16use crate::{BehaviorNode, Context, NodeStatus};
17use async_trait::async_trait;
18use mecha10_core::messages::{NavigationGoal, Pose};
19use mecha10_core::topics::navigation;
20use mecha10_core::Receiver;
21use serde::{Deserialize, Serialize};
22use serde_json::Value;
23
24/// A click on the video feed, normalized to the video frame: `(0, 0)` is the top-left corner,
25/// `(1, 1)` is the bottom-right corner. `y = 1` (bottom of frame) is treated as directly in
26/// front of the robot; `y = 0` (top of frame) is treated as `max_distance_m` away.
27#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
28pub(crate) struct NormalizedPoint {
29    x: f32,
30    y: f32,
31}
32
33#[derive(Debug, Clone, Serialize, Deserialize)]
34pub(crate) struct GoToLocationTargetRequest {
35    #[serde(default)]
36    pub(crate) point: Option<NormalizedPoint>,
37}
38
39impl mecha10_core::messages::Message for GoToLocationTargetRequest {}
40
41fn default_request_topic() -> String {
42    "/go-to-location/target/request".to_string()
43}
44
45fn default_max_distance_m() -> f32 {
46    3.0
47}
48
49fn default_horizontal_fov_rad() -> f32 {
50    // ~60 degrees, a reasonable default camera horizontal field of view.
51    std::f32::consts::FRAC_PI_3
52}
53
54/// Projects a normalized click into a `(forward_m, bearing_rad)` polar offset in the robot's
55/// own frame, using a simple ground-plane approximation: `y` maps linearly onto forward
56/// distance (bottom of frame = 0m, top of frame = `max_distance_m`), and `x` maps linearly onto
57/// bearing across `horizontal_fov_rad` (left edge = `+fov/2`, right edge = `-fov/2`; positive
58/// bearing is a left turn, matching the right-handed `yaw` convention `Pose::new_2d` uses).
59pub(crate) fn project_click(point: NormalizedPoint, max_distance_m: f32, horizontal_fov_rad: f32) -> (f32, f32) {
60    let y = point.y.clamp(0.0, 1.0);
61    let x = point.x.clamp(0.0, 1.0);
62
63    let forward_m = max_distance_m * (1.0 - y);
64    let bearing_rad = (0.5 - x) * horizontal_fov_rad;
65
66    (forward_m, bearing_rad)
67}
68
69/// Builds the `base_link`-relative goal `Pose` for a projected `(forward_m, bearing_rad)`
70/// offset: the target point itself, facing along the bearing to it.
71pub(crate) fn relative_goal_pose(forward_m: f32, bearing_rad: f32) -> Pose {
72    let mut pose = Pose::new_2d(
73        forward_m * bearing_rad.cos(),
74        forward_m * bearing_rad.sin(),
75        bearing_rad,
76    );
77    pose.frame_id = Some("base_link".to_string());
78    pose
79}
80
81#[derive(Debug, Serialize, Deserialize)]
82struct SetGoToLocationTargetConfig {
83    #[serde(default = "default_request_topic")]
84    request_topic: String,
85    #[serde(default = "default_max_distance_m")]
86    max_distance_m: f32,
87    #[serde(default = "default_horizontal_fov_rad")]
88    horizontal_fov_rad: f32,
89}
90
91pub struct SetGoToLocationTargetNode {
92    request_topic: String,
93    max_distance_m: f32,
94    horizontal_fov_rad: f32,
95    receiver: Option<Receiver<Value>>,
96    active: bool,
97}
98
99impl std::fmt::Debug for SetGoToLocationTargetNode {
100    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
101        f.debug_struct("SetGoToLocationTargetNode")
102            .field("request_topic", &self.request_topic)
103            .field("max_distance_m", &self.max_distance_m)
104            .field("horizontal_fov_rad", &self.horizontal_fov_rad)
105            .field("subscribed", &self.receiver.is_some())
106            .field("active", &self.active)
107            .finish()
108    }
109}
110
111impl SetGoToLocationTargetNode {
112    /// Create a new node from JSON configuration. `null` is normalized to `{}` so this node can
113    /// be referenced with no config at all, matching `SetInstanceTrackTargetNode::from_json`.
114    pub fn from_json(config: Value) -> anyhow::Result<Self> {
115        let config = if config.is_null() {
116            serde_json::json!({})
117        } else {
118            config
119        };
120        let cfg: SetGoToLocationTargetConfig = serde_json::from_value(config)?;
121        Ok(Self {
122            request_topic: cfg.request_topic,
123            max_distance_m: cfg.max_distance_m,
124            horizontal_fov_rad: cfg.horizontal_fov_rad,
125            receiver: None,
126            active: false,
127        })
128    }
129
130    async fn publish_goal(&self, ctx: &Context, point: NormalizedPoint) -> anyhow::Result<()> {
131        let (forward_m, bearing_rad) = project_click(point, self.max_distance_m, self.horizontal_fov_rad);
132        let goal_id = format!("go-to-location-{}", mecha10_core::prelude::now_micros());
133
134        let goal = NavigationGoal {
135            timestamp: mecha10_core::prelude::now_micros(),
136            goal_id: goal_id.clone(),
137            target: relative_goal_pose(forward_m, bearing_rad),
138            max_linear_velocity: None,
139            max_angular_velocity: None,
140            position_tolerance: None,
141            orientation_tolerance: None,
142        };
143
144        ctx.publish_to(navigation::GOAL, &goal).await?;
145        tracing::debug!(
146            "SetGoToLocationTargetNode: published goal {} (forward={:.2}m, bearing={:.2}rad)",
147            goal_id,
148            forward_m,
149            bearing_rad
150        );
151        Ok(())
152    }
153
154    async fn publish_cancel(&self, ctx: &Context) -> anyhow::Result<()> {
155        ctx.publish_to(navigation::CANCEL, &true).await?;
156        tracing::debug!("SetGoToLocationTargetNode: published cancel");
157        Ok(())
158    }
159
160    /// Drains every pending request off `receiver`, returning the last one seen (if any) - only
161    /// the most recent click matters. A request with `point: null` (or no `point` key at all)
162    /// means "cancel the current goal".
163    fn poll_latest_request(receiver: &mut Receiver<Value>) -> Option<Option<NormalizedPoint>> {
164        let mut latest = None;
165        while let Ok(value) = receiver.try_recv() {
166            match serde_json::from_value::<GoToLocationTargetRequest>(value) {
167                Ok(request) => latest = Some(request.point),
168                Err(e) => tracing::warn!("SetGoToLocationTargetNode: dropping malformed target request: {}", e),
169            }
170        }
171        latest
172    }
173}
174
175#[async_trait]
176impl BehaviorNode for SetGoToLocationTargetNode {
177    async fn tick(&mut self, ctx: &Context) -> anyhow::Result<NodeStatus> {
178        if self.receiver.is_none() {
179            self.receiver = Some(ctx.subscribe_raw::<Value>(&self.request_topic).await?);
180        }
181
182        let latest = Self::poll_latest_request(self.receiver.as_mut().expect("just subscribed above"));
183        if let Some(point) = latest {
184            match point {
185                Some(point) => {
186                    self.publish_goal(ctx, point).await?;
187                    self.active = true;
188                }
189                None => {
190                    self.publish_cancel(ctx).await?;
191                    self.active = false;
192                }
193            }
194        }
195
196        Ok(if self.active {
197            NodeStatus::Running
198        } else {
199            NodeStatus::Failure
200        })
201    }
202
203    fn name(&self) -> &str {
204        "SetGoToLocationTargetNode"
205    }
206}
207
208#[cfg(test)]
209mod tests {
210    use super::*;
211    use crate::test_helpers::create_test_context;
212    use std::f32::consts::PI;
213
214    #[test]
215    fn project_click_maps_bottom_center_to_directly_ahead() {
216        let (forward_m, bearing_rad) = project_click(NormalizedPoint { x: 0.5, y: 1.0 }, 3.0, PI / 3.0);
217        assert_eq!(forward_m, 0.0);
218        assert_eq!(bearing_rad, 0.0);
219    }
220
221    #[test]
222    fn project_click_maps_top_center_to_max_distance_ahead() {
223        let (forward_m, bearing_rad) = project_click(NormalizedPoint { x: 0.5, y: 0.0 }, 3.0, PI / 3.0);
224        assert_eq!(forward_m, 3.0);
225        assert_eq!(bearing_rad, 0.0);
226    }
227
228    #[test]
229    fn project_click_maps_left_edge_to_a_positive_left_bearing() {
230        let (_, bearing_rad) = project_click(NormalizedPoint { x: 0.0, y: 0.5 }, 3.0, PI / 3.0);
231        assert!((bearing_rad - PI / 6.0).abs() < 1e-6);
232    }
233
234    #[test]
235    fn project_click_maps_right_edge_to_a_negative_right_bearing() {
236        let (_, bearing_rad) = project_click(NormalizedPoint { x: 1.0, y: 0.5 }, 3.0, PI / 3.0);
237        assert!((bearing_rad + PI / 6.0).abs() < 1e-6);
238    }
239
240    #[test]
241    fn project_click_clamps_out_of_range_coordinates() {
242        let (forward_m, _) = project_click(NormalizedPoint { x: 0.5, y: -1.0 }, 3.0, PI / 3.0);
243        assert_eq!(forward_m, 3.0);
244        let (forward_m, _) = project_click(NormalizedPoint { x: 0.5, y: 2.0 }, 3.0, PI / 3.0);
245        assert_eq!(forward_m, 0.0);
246    }
247
248    #[test]
249    fn relative_goal_pose_is_base_link_relative_and_faces_the_bearing() {
250        let pose = relative_goal_pose(2.0, PI / 2.0);
251        assert_eq!(pose.frame_id, Some("base_link".to_string()));
252        assert!((pose.position[0] - 0.0).abs() < 1e-5);
253        assert!((pose.position[1] - 2.0).abs() < 1e-5);
254    }
255
256    #[test]
257    fn from_json_defaults_topic_distance_and_fov() {
258        let node = SetGoToLocationTargetNode::from_json(serde_json::json!({})).unwrap();
259        assert_eq!(node.request_topic, "/go-to-location/target/request");
260        assert_eq!(node.max_distance_m, 3.0);
261        assert_eq!(node.horizontal_fov_rad, std::f32::consts::FRAC_PI_3);
262        assert!(!node.active);
263    }
264
265    #[test]
266    fn from_json_accepts_null_the_same_as_an_empty_object() {
267        let node = SetGoToLocationTargetNode::from_json(Value::Null).unwrap();
268        assert_eq!(node.request_topic, "/go-to-location/target/request");
269    }
270
271    #[test]
272    fn from_json_reads_overridden_fields() {
273        let node = SetGoToLocationTargetNode::from_json(serde_json::json!({
274            "request_topic": "/custom/request",
275            "max_distance_m": 5.0,
276            "horizontal_fov_rad": 1.0
277        }))
278        .unwrap();
279        assert_eq!(node.request_topic, "/custom/request");
280        assert_eq!(node.max_distance_m, 5.0);
281        assert_eq!(node.horizontal_fov_rad, 1.0);
282    }
283
284    #[test]
285    fn poll_latest_request_returns_none_when_empty() {
286        let (_tx, rx) = tokio::sync::mpsc::unbounded_channel::<Value>();
287        let mut receiver = Receiver::from(rx);
288        assert_eq!(SetGoToLocationTargetNode::poll_latest_request(&mut receiver), None);
289    }
290
291    #[test]
292    fn poll_latest_request_keeps_only_the_most_recent_click() {
293        let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<Value>();
294        let mut receiver = Receiver::from(rx);
295        tx.send(serde_json::json!({ "point": { "x": 0.1, "y": 0.1 } })).unwrap();
296        tx.send(serde_json::json!({ "point": { "x": 0.9, "y": 0.9 } })).unwrap();
297
298        assert_eq!(
299            SetGoToLocationTargetNode::poll_latest_request(&mut receiver),
300            Some(Some(NormalizedPoint { x: 0.9, y: 0.9 }))
301        );
302    }
303
304    #[test]
305    fn poll_latest_request_accepts_a_null_point_as_a_cancel_request() {
306        let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<Value>();
307        let mut receiver = Receiver::from(rx);
308        tx.send(serde_json::json!({ "point": null })).unwrap();
309
310        assert_eq!(
311            SetGoToLocationTargetNode::poll_latest_request(&mut receiver),
312            Some(None)
313        );
314    }
315
316    #[test]
317    fn poll_latest_request_drops_malformed_requests_and_keeps_polling() {
318        let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<Value>();
319        let mut receiver = Receiver::from(rx);
320        tx.send(serde_json::json!({ "point": "not-a-point" })).unwrap();
321        tx.send(serde_json::json!({ "point": { "x": 0.5, "y": 0.5 } })).unwrap();
322
323        assert_eq!(
324            SetGoToLocationTargetNode::poll_latest_request(&mut receiver),
325            Some(Some(NormalizedPoint { x: 0.5, y: 0.5 }))
326        );
327    }
328
329    #[tokio::test]
330    #[ignore = "Requires Redis infrastructure"]
331    async fn tick_returns_failure_until_a_click_is_received() {
332        let ctx = create_test_context("test_set_go_to_location_target_idle").await;
333
334        let mut node = SetGoToLocationTargetNode::from_json(serde_json::json!({})).unwrap();
335        assert_eq!(node.tick(&ctx).await.unwrap(), NodeStatus::Failure);
336    }
337
338    #[tokio::test]
339    #[ignore = "Requires Redis infrastructure"]
340    async fn tick_publishes_a_navigation_goal_for_a_relayed_click() {
341        let ctx = create_test_context("test_set_go_to_location_target_click").await;
342
343        let mut node = SetGoToLocationTargetNode::from_json(serde_json::json!({
344            "request_topic": "/go-to-location/target/request"
345        }))
346        .unwrap();
347
348        node.tick(&ctx).await.unwrap();
349
350        let mut goal_receiver = ctx.subscribe(navigation::GOAL).await.unwrap();
351        ctx.publish_raw(
352            "/go-to-location/target/request",
353            &serde_json::json!({ "point": { "x": 0.5, "y": 1.0 } }),
354        )
355        .await
356        .unwrap();
357
358        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
359        let status = node.tick(&ctx).await.unwrap();
360        assert_eq!(status, NodeStatus::Running);
361
362        let goal = tokio::time::timeout(std::time::Duration::from_millis(500), goal_receiver.recv())
363            .await
364            .expect("timed out waiting for published goal")
365            .expect("channel closed without a message");
366
367        assert_eq!(goal.target.frame_id, Some("base_link".to_string()));
368    }
369
370    #[tokio::test]
371    #[ignore = "Requires Redis infrastructure"]
372    async fn tick_publishes_cancel_for_a_cleared_click() {
373        let ctx = create_test_context("test_set_go_to_location_target_clear").await;
374
375        let mut node = SetGoToLocationTargetNode::from_json(serde_json::json!({
376            "request_topic": "/go-to-location/target/request"
377        }))
378        .unwrap();
379
380        node.tick(&ctx).await.unwrap();
381
382        let mut cancel_receiver = ctx.subscribe(navigation::CANCEL).await.unwrap();
383        ctx.publish_raw(
384            "/go-to-location/target/request",
385            &serde_json::json!({ "point": { "x": 0.5, "y": 1.0 } }),
386        )
387        .await
388        .unwrap();
389        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
390        node.tick(&ctx).await.unwrap();
391
392        ctx.publish_raw("/go-to-location/target/request", &serde_json::json!({ "point": null }))
393            .await
394            .unwrap();
395        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
396        let status = node.tick(&ctx).await.unwrap();
397        assert_eq!(status, NodeStatus::Failure);
398
399        let cancel = tokio::time::timeout(std::time::Duration::from_millis(500), cancel_receiver.recv())
400            .await
401            .expect("timed out waiting for cancel")
402            .expect("channel closed without a message");
403        assert!(cancel);
404    }
405}