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 color.
39    pub color: [f32; 4],
40    /// Line width in pixels.
41    pub line_width: f32,
42    /// RGBA color for the drag handles. When set (non-zero alpha), overrides the default LUT coloring.
43    pub handle_color: [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            color: [1.0, 0.6, 0.1, 1.0],
59            line_width: 2.0,
60            handle_color: [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 { WidgetResult::Updated } else { WidgetResult::None }
123    }
124
125    /// Build the `PolylineItem` for the line segment between the two endpoints.
126    ///
127    /// `id` is used as the pick ID for the line body (0 = not pickable).
128    pub fn polyline_item(&self, id: u64) -> PolylineItem {
129        PolylineItem {
130            positions: vec![self.start.to_array(), self.end.to_array()],
131            strip_lengths: vec![2],
132            default_color: self.color,
133            line_width: self.line_width,
134            id,
135            ..PolylineItem::default()
136        }
137    }
138
139    /// Build a `GlyphItem` containing sphere handles at both endpoints.
140    ///
141    /// Handle size is constant in screen space (approximately 10 px radius).
142    /// `id_base` is the pick ID for the start handle; the end handle uses `id_base + 1`.
143    ///
144    /// Color is driven by the colormap (viridis by default). The scalar for each
145    /// handle is `0.0` when idle and `1.0` when hovered or active, so the two
146    /// states map to distinct colormap colors.
147    pub fn handle_glyphs(&self, id_base: u64, ctx: &WidgetContext) -> GlyphItem {
148        let r0 = handle_world_radius(self.start, &ctx.camera, ctx.viewport_size.y, 10.0);
149        let r1 = handle_world_radius(self.end, &ctx.camera, ctx.viewport_size.y, 10.0);
150
151        let s0 = if self.hovered_endpoint == Some(0) || self.active_endpoint == Some(0) {
152            1.0_f32
153        } else {
154            0.0
155        };
156        let s1 = if self.hovered_endpoint == Some(1) || self.active_endpoint == Some(1) {
157            1.0_f32
158        } else {
159            0.0
160        };
161
162        GlyphItem {
163            positions: vec![self.start.to_array(), self.end.to_array()],
164            vectors: vec![[r0, 0.0, 0.0], [r1, 0.0, 0.0]],
165            scale: 1.0,
166            scale_by_magnitude: true,
167            scalars: vec![s0, s1],
168            scalar_range: Some((0.0, 1.0)),
169            glyph_type: GlyphType::Sphere,
170            id: id_base,
171            default_color: self.handle_color,
172            use_default_color: self.handle_color[3] > 0.0,
173            ..GlyphItem::default()
174        }
175    }
176
177    // -----------------------------------------------------------------------
178    // Internal
179    // -----------------------------------------------------------------------
180
181    fn endpoint_pos(&self, ep: usize) -> glam::Vec3 {
182        if ep == 0 { self.start } else { self.end }
183    }
184
185    fn set_endpoint(&mut self, ep: usize, pos: glam::Vec3) {
186        if ep == 0 { self.start = pos; } else { self.end = pos; }
187    }
188
189    fn hit_test(
190        &self,
191        ray_origin: glam::Vec3,
192        ray_dir: glam::Vec3,
193        ctx: &WidgetContext,
194    ) -> Option<usize> {
195        let r0 = handle_world_radius(self.start, &ctx.camera, ctx.viewport_size.y, 10.0);
196        let r1 = handle_world_radius(self.end, &ctx.camera, ctx.viewport_size.y, 10.0);
197
198        let d0 = ray_point_dist(ray_origin, ray_dir, self.start);
199        let d1 = ray_point_dist(ray_origin, ray_dir, self.end);
200
201        let h0 = d0 < r0;
202        let h1 = d1 < r1;
203
204        match (h0, h1) {
205            (true, true) => {
206                // Prefer the endpoint closer along the ray.
207                let t0 = (self.start - ray_origin).dot(ray_dir);
208                let t1 = (self.end - ray_origin).dot(ray_dir);
209                Some(if t0 <= t1 { 0 } else { 1 })
210            }
211            (true, false) => Some(0),
212            (false, true) => Some(1),
213            (false, false) => None,
214        }
215    }
216}