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
43    hovered_endpoint: Option<usize>,
44    active_endpoint: Option<usize>,
45    // Camera-facing drag plane captured at drag start.
46    drag_plane_normal: glam::Vec3,
47    drag_plane_d: f32,
48}
49
50impl LineProbeWidget {
51    /// Create a new probe between two world-space positions.
52    pub fn new(start: glam::Vec3, end: glam::Vec3) -> Self {
53        Self {
54            start,
55            end,
56            color: [1.0, 0.6, 0.1, 1.0],
57            line_width: 2.0,
58            hovered_endpoint: None,
59            active_endpoint: None,
60            drag_plane_normal: glam::Vec3::Z,
61            drag_plane_d: 0.0,
62        }
63    }
64
65    /// Index of the currently hovered endpoint (0 = start, 1 = end).
66    pub fn hovered_endpoint(&self) -> Option<usize> {
67        self.hovered_endpoint
68    }
69
70    /// True while a drag session is in progress on either endpoint.
71    pub fn is_active(&self) -> bool {
72        self.active_endpoint.is_some()
73    }
74
75    /// Process input for this frame. Returns `Updated` if either endpoint moved.
76    ///
77    /// Call once per frame before building render items.
78    pub fn update(&mut self, ctx: &WidgetContext) -> WidgetResult {
79        let (ro, rd) = ctx_ray(ctx);
80        let mut updated = false;
81
82        // Hover (only when not dragging, to avoid flicker during drag).
83        if self.active_endpoint.is_none() {
84            self.hovered_endpoint = self.hit_test(ro, rd, ctx);
85        }
86
87        if ctx.drag_started {
88            if let Some(ep) = self.hovered_endpoint {
89                let ep_world = self.endpoint_pos(ep);
90                let fwd = glam::Vec3::from(ctx.camera.forward);
91                let n = -fwd;
92                self.drag_plane_normal = n;
93                self.drag_plane_d = -n.dot(ep_world);
94                self.active_endpoint = Some(ep);
95            }
96        }
97
98        if let Some(ep) = self.active_endpoint {
99            if ctx.released || (!ctx.dragging && !ctx.drag_started) {
100                self.active_endpoint = None;
101                self.hovered_endpoint = None;
102            } else if let Some(hit) =
103                ray_plane_intersection(ro, rd, self.drag_plane_normal, self.drag_plane_d)
104            {
105                let prev = self.endpoint_pos(ep);
106                if (hit - prev).length_squared() > 1e-10 {
107                    self.set_endpoint(ep, hit);
108                    updated = true;
109                }
110            }
111        }
112
113        if updated { WidgetResult::Updated } else { WidgetResult::None }
114    }
115
116    /// Build the `PolylineItem` for the line segment between the two endpoints.
117    ///
118    /// `id` is used as the pick ID for the line body (0 = not pickable).
119    pub fn polyline_item(&self, id: u64) -> PolylineItem {
120        PolylineItem {
121            positions: vec![self.start.to_array(), self.end.to_array()],
122            strip_lengths: vec![2],
123            default_color: self.color,
124            line_width: self.line_width,
125            id,
126            ..PolylineItem::default()
127        }
128    }
129
130    /// Build a `GlyphItem` containing sphere handles at both endpoints.
131    ///
132    /// Handle size is constant in screen space (approximately 10 px radius).
133    /// `id_base` is the pick ID for the start handle; the end handle uses `id_base + 1`.
134    ///
135    /// Color is driven by the colormap (viridis by default). The scalar for each
136    /// handle is `0.0` when idle and `1.0` when hovered or active, so the two
137    /// states map to distinct colormap colors.
138    pub fn handle_glyphs(&self, id_base: u64, ctx: &WidgetContext) -> GlyphItem {
139        let r0 = handle_world_radius(self.start, &ctx.camera, ctx.viewport_size.y, 10.0);
140        let r1 = handle_world_radius(self.end, &ctx.camera, ctx.viewport_size.y, 10.0);
141
142        let s0 = if self.hovered_endpoint == Some(0) || self.active_endpoint == Some(0) {
143            1.0_f32
144        } else {
145            0.0
146        };
147        let s1 = if self.hovered_endpoint == Some(1) || self.active_endpoint == Some(1) {
148            1.0_f32
149        } else {
150            0.0
151        };
152
153        GlyphItem {
154            positions: vec![self.start.to_array(), self.end.to_array()],
155            vectors: vec![[r0, 0.0, 0.0], [r1, 0.0, 0.0]],
156            scale: 1.0,
157            scale_by_magnitude: true,
158            scalars: vec![s0, s1],
159            scalar_range: Some((0.0, 1.0)),
160            glyph_type: GlyphType::Sphere,
161            id: id_base,
162            ..GlyphItem::default()
163        }
164    }
165
166    // -----------------------------------------------------------------------
167    // Internal
168    // -----------------------------------------------------------------------
169
170    fn endpoint_pos(&self, ep: usize) -> glam::Vec3 {
171        if ep == 0 { self.start } else { self.end }
172    }
173
174    fn set_endpoint(&mut self, ep: usize, pos: glam::Vec3) {
175        if ep == 0 { self.start = pos; } else { self.end = pos; }
176    }
177
178    fn hit_test(
179        &self,
180        ray_origin: glam::Vec3,
181        ray_dir: glam::Vec3,
182        ctx: &WidgetContext,
183    ) -> Option<usize> {
184        let r0 = handle_world_radius(self.start, &ctx.camera, ctx.viewport_size.y, 12.0);
185        let r1 = handle_world_radius(self.end, &ctx.camera, ctx.viewport_size.y, 12.0);
186
187        let d0 = ray_point_dist(ray_origin, ray_dir, self.start);
188        let d1 = ray_point_dist(ray_origin, ray_dir, self.end);
189
190        let h0 = d0 < r0;
191        let h1 = d1 < r1;
192
193        match (h0, h1) {
194            (true, true) => {
195                // Prefer the endpoint closer along the ray.
196                let t0 = (self.start - ray_origin).dot(ray_dir);
197                let t1 = (self.end - ray_origin).dot(ray_dir);
198                Some(if t0 <= t1 { 0 } else { 1 })
199            }
200            (true, false) => Some(0),
201            (false, true) => Some(1),
202            (false, false) => None,
203        }
204    }
205}