Skip to main content

viewport_lib/interaction/widgets/
line_probe.rs

1//! Line probe widget: two draggable endpoint handles connected by a line segment.
2
3use crate::geometry::intersect::ray_plane_intersection;
4use crate::renderer::{GlyphItem, GlyphType, PolylineItem};
5
6use super::{WidgetContext, WidgetResult, ctx_ray, handle_world_radius, ray_point_dist};
7
8/// A two-endpoint line handle rendered in the viewport.
9///
10/// Drag either sphere handle to reposition the probe path. Read `start` and `end`
11/// each frame to get the current endpoint positions.
12///
13/// # Usage
14///
15/// ```rust,ignore
16/// // Setup (once):
17/// let mut probe = LineProbeWidget::new(
18///     glam::Vec3::new(-2.0, 0.0, 0.0),
19///     glam::Vec3::new( 2.0, 0.0, 0.0),
20/// );
21///
22/// // Each frame:
23/// let ctx = WidgetContext { camera, viewport_size, cursor_viewport,
24///                           drag_started, dragging, released };
25/// probe.update(&ctx);
26///
27/// fd.scene.polylines.push(probe.polyline_item(LINE_ID));
28/// fd.scene.glyphs.push(probe.handle_glyphs(HANDLE_ID_BASE, &ctx));
29///
30/// // Suppress orbit while dragging:
31/// if probe.is_active() { orbit.resolve(); } else { orbit.apply_to_camera(&mut camera); }
32/// ```
33pub struct LineProbeWidget {
34    /// World-space position of the first endpoint.
35    pub start: glam::Vec3,
36    /// World-space position of the second endpoint.
37    pub end: glam::Vec3,
38    /// RGBA line and handle colour.
39    pub colour: [f32; 4],
40    /// Line width in pixels.
41    pub line_width: f32,
42    /// RGBA colour for the drag handles. When set (non-zero alpha), overrides the default LUT colouring.
43    pub handle_colour: [f32; 4],
44
45    hovered_endpoint: Option<usize>,
46    active_endpoint: Option<usize>,
47    // Camera-facing drag plane captured at drag start.
48    drag_plane_normal: glam::Vec3,
49    drag_plane_d: f32,
50}
51
52impl LineProbeWidget {
53    /// Create a new probe between two world-space positions.
54    pub fn new(start: glam::Vec3, end: glam::Vec3) -> Self {
55        Self {
56            start,
57            end,
58            colour: [1.0, 0.6, 0.1, 1.0],
59            line_width: 2.0,
60            handle_colour: [0.0; 4],
61            hovered_endpoint: None,
62            active_endpoint: None,
63            drag_plane_normal: glam::Vec3::Z,
64            drag_plane_d: 0.0,
65        }
66    }
67
68    /// Index of the currently hovered endpoint (0 = start, 1 = end).
69    pub fn hovered_endpoint(&self) -> Option<usize> {
70        self.hovered_endpoint
71    }
72
73    /// True while a drag session is in progress on either endpoint.
74    pub fn is_active(&self) -> bool {
75        self.active_endpoint.is_some()
76    }
77
78    /// Process input for this frame. Returns `Updated` if either endpoint moved.
79    ///
80    /// Call once per frame before building render items.
81    pub fn update(&mut self, ctx: &WidgetContext) -> WidgetResult {
82        let (ro, rd) = ctx_ray(ctx);
83        let mut updated = false;
84
85        // Hover (only when not dragging, to avoid flicker during drag).
86        if self.active_endpoint.is_none() {
87            let hit = self.hit_test(ro, rd, ctx);
88            // On the drag_started frame the cursor can be right at the edge and the
89            // hit test may miss by a hair. Keep the previous hover so the drag still
90            // registers if the handle was highlighted on the frame before the click.
91            if hit.is_some() || !ctx.drag_started {
92                self.hovered_endpoint = hit;
93            }
94        }
95
96        if ctx.drag_started {
97            if let Some(ep) = self.hovered_endpoint {
98                let ep_world = self.endpoint_pos(ep);
99                let fwd = glam::Vec3::from(ctx.camera.forward);
100                let n = -fwd;
101                self.drag_plane_normal = n;
102                self.drag_plane_d = -n.dot(ep_world);
103                self.active_endpoint = Some(ep);
104            }
105        }
106
107        if let Some(ep) = self.active_endpoint {
108            if ctx.released || (!ctx.dragging && !ctx.drag_started) {
109                self.active_endpoint = None;
110                self.hovered_endpoint = None;
111            } else if let Some(hit) =
112                ray_plane_intersection(ro, rd, self.drag_plane_normal, self.drag_plane_d)
113            {
114                let prev = self.endpoint_pos(ep);
115                if (hit - prev).length_squared() > 1e-10 {
116                    self.set_endpoint(ep, hit);
117                    updated = true;
118                }
119            }
120        }
121
122        if updated {
123            WidgetResult::Updated
124        } else {
125            WidgetResult::None
126        }
127    }
128
129    /// Build the `PolylineItem` for the line segment between the two endpoints.
130    ///
131    /// `id` is used as the pick ID for the line body (0 = not pickable).
132    pub fn polyline_item(&self, id: u64) -> PolylineItem {
133        PolylineItem {
134            positions: vec![self.start.to_array(), self.end.to_array()],
135            strip_lengths: vec![2],
136            default_colour: self.colour,
137            line_width: self.line_width,
138
139            settings: crate::scene::material::ItemSettings {
140                pick_id: crate::renderer::PickId(id),
141                ..Default::default()
142            },
143            ..PolylineItem::default()
144        }
145    }
146
147    /// Build a `GlyphItem` containing sphere handles at both endpoints.
148    ///
149    /// Handle size is constant in screen space (approximately 10 px radius).
150    /// `id_base` is the pick ID for the start handle; the end handle uses `id_base + 1`.
151    ///
152    /// Colour is driven by the colourmap (viridis by default). The scalar for each
153    /// handle is `0.0` when idle and `1.0` when hovered or active, so the two
154    /// states map to distinct colourmap colours.
155    pub fn handle_glyphs(&self, id_base: u64, ctx: &WidgetContext) -> GlyphItem {
156        let r0 = handle_world_radius(self.start, &ctx.camera, ctx.viewport_size.y, 10.0);
157        let r1 = handle_world_radius(self.end, &ctx.camera, ctx.viewport_size.y, 10.0);
158
159        let s0 = if self.hovered_endpoint == Some(0) || self.active_endpoint == Some(0) {
160            1.0_f32
161        } else {
162            0.0
163        };
164        let s1 = if self.hovered_endpoint == Some(1) || self.active_endpoint == Some(1) {
165            1.0_f32
166        } else {
167            0.0
168        };
169
170        GlyphItem {
171            positions: vec![self.start.to_array(), self.end.to_array()],
172            vectors: vec![[r0, 0.0, 0.0], [r1, 0.0, 0.0]],
173            scale: 1.0,
174            scale_by_magnitude: true,
175            scalars: vec![s0, s1],
176            scalar_range: Some((0.0, 1.0)),
177            glyph_type: GlyphType::Sphere,
178
179            settings: crate::scene::material::ItemSettings {
180                pick_id: crate::renderer::PickId(id_base),
181                ..Default::default()
182            },
183            default_colour: self.handle_colour,
184            use_default_colour: self.handle_colour[3] > 0.0,
185            ..GlyphItem::default()
186        }
187    }
188
189    // -----------------------------------------------------------------------
190    // Internal
191    // -----------------------------------------------------------------------
192
193    fn endpoint_pos(&self, ep: usize) -> glam::Vec3 {
194        if ep == 0 { self.start } else { self.end }
195    }
196
197    fn set_endpoint(&mut self, ep: usize, pos: glam::Vec3) {
198        if ep == 0 {
199            self.start = pos;
200        } else {
201            self.end = pos;
202        }
203    }
204
205    fn hit_test(
206        &self,
207        ray_origin: glam::Vec3,
208        ray_dir: glam::Vec3,
209        ctx: &WidgetContext,
210    ) -> Option<usize> {
211        let r0 = handle_world_radius(self.start, &ctx.camera, ctx.viewport_size.y, 10.0);
212        let r1 = handle_world_radius(self.end, &ctx.camera, ctx.viewport_size.y, 10.0);
213
214        let d0 = ray_point_dist(ray_origin, ray_dir, self.start);
215        let d1 = ray_point_dist(ray_origin, ray_dir, self.end);
216
217        let h0 = d0 < r0;
218        let h1 = d1 < r1;
219
220        match (h0, h1) {
221            (true, true) => {
222                // Prefer the endpoint closer along the ray.
223                let t0 = (self.start - ray_origin).dot(ray_dir);
224                let t1 = (self.end - ray_origin).dot(ray_dir);
225                Some(if t0 <= t1 { 0 } else { 1 })
226            }
227            (true, false) => Some(0),
228            (false, true) => Some(1),
229            (false, false) => None,
230        }
231    }
232}