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::interaction::clip_plane::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            id,
139            ..PolylineItem::default()
140        }
141    }
142
143    /// Build a `GlyphItem` containing sphere handles at both endpoints.
144    ///
145    /// Handle size is constant in screen space (approximately 10 px radius).
146    /// `id_base` is the pick ID for the start handle; the end handle uses `id_base + 1`.
147    ///
148    /// Colour is driven by the colourmap (viridis by default). The scalar for each
149    /// handle is `0.0` when idle and `1.0` when hovered or active, so the two
150    /// states map to distinct colourmap colours.
151    pub fn handle_glyphs(&self, id_base: u64, ctx: &WidgetContext) -> GlyphItem {
152        let r0 = handle_world_radius(self.start, &ctx.camera, ctx.viewport_size.y, 10.0);
153        let r1 = handle_world_radius(self.end, &ctx.camera, ctx.viewport_size.y, 10.0);
154
155        let s0 = if self.hovered_endpoint == Some(0) || self.active_endpoint == Some(0) {
156            1.0_f32
157        } else {
158            0.0
159        };
160        let s1 = if self.hovered_endpoint == Some(1) || self.active_endpoint == Some(1) {
161            1.0_f32
162        } else {
163            0.0
164        };
165
166        GlyphItem {
167            positions: vec![self.start.to_array(), self.end.to_array()],
168            vectors: vec![[r0, 0.0, 0.0], [r1, 0.0, 0.0]],
169            scale: 1.0,
170            scale_by_magnitude: true,
171            scalars: vec![s0, s1],
172            scalar_range: Some((0.0, 1.0)),
173            glyph_type: GlyphType::Sphere,
174            id: id_base,
175            default_colour: self.handle_colour,
176            use_default_colour: self.handle_colour[3] > 0.0,
177            ..GlyphItem::default()
178        }
179    }
180
181    // -----------------------------------------------------------------------
182    // Internal
183    // -----------------------------------------------------------------------
184
185    fn endpoint_pos(&self, ep: usize) -> glam::Vec3 {
186        if ep == 0 { self.start } else { self.end }
187    }
188
189    fn set_endpoint(&mut self, ep: usize, pos: glam::Vec3) {
190        if ep == 0 {
191            self.start = pos;
192        } else {
193            self.end = pos;
194        }
195    }
196
197    fn hit_test(
198        &self,
199        ray_origin: glam::Vec3,
200        ray_dir: glam::Vec3,
201        ctx: &WidgetContext,
202    ) -> Option<usize> {
203        let r0 = handle_world_radius(self.start, &ctx.camera, ctx.viewport_size.y, 10.0);
204        let r1 = handle_world_radius(self.end, &ctx.camera, ctx.viewport_size.y, 10.0);
205
206        let d0 = ray_point_dist(ray_origin, ray_dir, self.start);
207        let d1 = ray_point_dist(ray_origin, ray_dir, self.end);
208
209        let h0 = d0 < r0;
210        let h1 = d1 < r1;
211
212        match (h0, h1) {
213            (true, true) => {
214                // Prefer the endpoint closer along the ray.
215                let t0 = (self.start - ray_origin).dot(ray_dir);
216                let t1 = (self.end - ray_origin).dot(ray_dir);
217                Some(if t0 <= t1 { 0 } else { 1 })
218            }
219            (true, false) => Some(0),
220            (false, true) => Some(1),
221            (false, false) => None,
222        }
223    }
224}