Skip to main content

viewport_lib/interaction/widgets/
plane.rs

1//! Plane widget: a draggable infinite plane defined by a center point and normal.
2
3use crate::interaction::clip_plane::ray_plane_intersection;
4use crate::renderer::{GlyphItem, GlyphType, PolylineItem};
5use parry3d::math::{Pose, Vector};
6use parry3d::query::{Ray, RayCast};
7
8use super::{
9    WidgetContext, WidgetResult, any_perpendicular_pair, ctx_ray, handle_world_radius,
10    ray_point_dist,
11};
12
13#[derive(Clone, Copy, PartialEq, Eq, Debug)]
14enum PlaneHandle {
15    Center,
16    NormalTip,
17}
18
19/// An interactive plane widget defined by a center point and unit normal.
20///
21/// Two handles: the center (moves the plane freely in 3D) and a normal tip
22/// (orbits the normal direction around the center by dragging).
23///
24/// # Usage
25///
26/// ```rust,ignore
27/// let mut plane = PlaneWidget::new(glam::Vec3::ZERO, glam::Vec3::Z);
28///
29/// // Each frame:
30/// plane.update(&ctx);
31/// fd.scene.polylines.push(plane.plane_item(PLANE_ID));
32/// fd.scene.glyphs.push(plane.handle_glyphs(HANDLE_ID, &ctx));
33///
34/// // Suppress orbit while dragging:
35/// if plane.is_active() { orbit.resolve(); } else { orbit.apply_to_camera(&mut camera); }
36/// ```
37pub struct PlaneWidget {
38    /// World-space center of the plane.
39    pub center: glam::Vec3,
40    /// Unit normal vector of the plane (always normalized on output).
41    pub normal: glam::Vec3,
42    /// RGBA colour for the wireframe square outline and normal line.
43    pub colour: [f32; 4],
44    /// RGBA colour for the drag handles. Non-zero alpha overrides LUT colouring.
45    pub handle_colour: [f32; 4],
46    /// Half-size of the visual square in world space.
47    pub display_half_size: f32,
48    /// Distance from center to the normal-tip handle sphere.
49    pub normal_display_length: f32,
50
51    hovered_handle: Option<PlaneHandle>,
52    active_handle: Option<PlaneHandle>,
53    drag_plane_normal: glam::Vec3,
54    drag_plane_d: f32,
55    drag_anchor: glam::Vec3,
56}
57
58impl PlaneWidget {
59    /// Create a new plane widget.
60    ///
61    /// `normal` is normalized internally; if zero, defaults to `Vec3::Z`.
62    pub fn new(center: glam::Vec3, normal: glam::Vec3) -> Self {
63        let len = normal.length();
64        let normal = if len > 1e-6 {
65            normal / len
66        } else {
67            glam::Vec3::Z
68        };
69        Self {
70            center,
71            normal,
72            colour: [0.3, 0.7, 1.0, 1.0],
73            handle_colour: [0.0; 4],
74            display_half_size: 1.5,
75            normal_display_length: 2.0,
76            hovered_handle: None,
77            active_handle: None,
78            drag_plane_normal: glam::Vec3::Z,
79            drag_plane_d: 0.0,
80            drag_anchor: glam::Vec3::ZERO,
81        }
82    }
83
84    /// True while a drag is in progress on either handle.
85    pub fn is_active(&self) -> bool {
86        self.active_handle.is_some()
87    }
88
89    /// Process input for this frame. Returns `Updated` if state changed.
90    pub fn update(&mut self, ctx: &WidgetContext) -> WidgetResult {
91        let (ro, rd) = ctx_ray(ctx);
92        let mut updated = false;
93
94        if self.active_handle.is_none() {
95            let hit = self.hit_test(ro, rd, ctx);
96            if hit.is_some() || !ctx.drag_started {
97                self.hovered_handle = hit;
98            }
99        }
100
101        if ctx.drag_started {
102            if let Some(handle) = self.hovered_handle {
103                let anchor = match handle {
104                    PlaneHandle::Center => self.center,
105                    PlaneHandle::NormalTip => self.normal_tip_pos(),
106                };
107                let n = -glam::Vec3::from(ctx.camera.forward);
108                self.drag_plane_normal = n;
109                self.drag_plane_d = -n.dot(anchor);
110                self.drag_anchor = anchor;
111                self.active_handle = Some(handle);
112            }
113        }
114
115        if let Some(handle) = self.active_handle {
116            if ctx.released || (!ctx.dragging && !ctx.drag_started) {
117                self.active_handle = None;
118                self.hovered_handle = None;
119            } else if let Some(hit) =
120                ray_plane_intersection(ro, rd, self.drag_plane_normal, self.drag_plane_d)
121            {
122                match handle {
123                    PlaneHandle::Center => {
124                        let delta = hit - self.drag_anchor;
125                        if delta.length_squared() > 1e-10 {
126                            self.center += delta;
127                            self.drag_anchor = hit;
128                            updated = true;
129                        }
130                    }
131                    PlaneHandle::NormalTip => {
132                        let dir = hit - self.center;
133                        let len = dir.length();
134                        if len > 1e-3 {
135                            let new_normal = dir / len;
136                            if (new_normal - self.normal).length_squared() > 1e-8 {
137                                self.normal = new_normal;
138                                updated = true;
139                            }
140                        }
141                    }
142                }
143            }
144        }
145
146        if updated {
147            WidgetResult::Updated
148        } else {
149            WidgetResult::None
150        }
151    }
152
153    /// Build a `PolylineItem` for the wireframe square outline and normal indicator.
154    ///
155    /// The square is oriented by `normal` and centered at `center`.
156    /// A line segment from the center to the normal-tip handle is included.
157    pub fn plane_item(&self, id: u64) -> PolylineItem {
158        let (u, v) = any_perpendicular_pair(self.normal);
159        let s = self.display_half_size;
160        let c = self.center;
161        let tip = self.normal_tip_pos();
162
163        // 5 points close the square, then 2 points for the normal indicator line.
164        let positions = vec![
165            (c + u * s + v * s).to_array(),
166            (c - u * s + v * s).to_array(),
167            (c - u * s - v * s).to_array(),
168            (c + u * s - v * s).to_array(),
169            (c + u * s + v * s).to_array(),
170            c.to_array(),
171            tip.to_array(),
172        ];
173
174        PolylineItem {
175            positions,
176            strip_lengths: vec![5, 2],
177            default_colour: self.colour,
178            line_width: 1.5,
179            id,
180            ..PolylineItem::default()
181        }
182    }
183
184    /// Build a `GlyphItem` with two sphere handles: center and normal tip.
185    ///
186    /// `id_base` is the pick ID for the center handle; `id_base + 1` for the normal-tip handle.
187    pub fn handle_glyphs(&self, id_base: u64, ctx: &WidgetContext) -> GlyphItem {
188        let tip = self.normal_tip_pos();
189        let rc = handle_world_radius(self.center, &ctx.camera, ctx.viewport_size.y, 10.0);
190        let rt = handle_world_radius(tip, &ctx.camera, ctx.viewport_size.y, 8.0);
191
192        let sc = if matches!(self.hovered_handle, Some(PlaneHandle::Center))
193            || matches!(self.active_handle, Some(PlaneHandle::Center))
194        {
195            1.0_f32
196        } else {
197            0.2
198        };
199        let st = if matches!(self.hovered_handle, Some(PlaneHandle::NormalTip))
200            || matches!(self.active_handle, Some(PlaneHandle::NormalTip))
201        {
202            1.0_f32
203        } else {
204            0.2
205        };
206
207        GlyphItem {
208            positions: vec![self.center.to_array(), tip.to_array()],
209            vectors: vec![[rc, 0.0, 0.0], [rt, 0.0, 0.0]],
210            scale: 1.0,
211            scale_by_magnitude: true,
212            scalars: vec![sc, st],
213            scalar_range: Some((0.0, 1.0)),
214            glyph_type: GlyphType::Sphere,
215            id: id_base,
216            default_colour: self.handle_colour,
217            use_default_colour: self.handle_colour[3] > 0.0,
218            ..GlyphItem::default()
219        }
220    }
221
222    // -----------------------------------------------------------------------
223    // Internal
224    // -----------------------------------------------------------------------
225
226    fn normal_tip_pos(&self) -> glam::Vec3 {
227        self.center + self.normal * self.normal_display_length
228    }
229
230    fn hit_test(
231        &self,
232        ray_origin: glam::Vec3,
233        ray_dir: glam::Vec3,
234        ctx: &WidgetContext,
235    ) -> Option<PlaneHandle> {
236        let tip = self.normal_tip_pos();
237        let ray = Ray::new(
238            Vector::new(ray_origin.x, ray_origin.y, ray_origin.z),
239            Vector::new(ray_dir.x, ray_dir.y, ray_dir.z),
240        );
241
242        let rc = handle_world_radius(self.center, &ctx.camera, ctx.viewport_size.y, 10.0);
243        let rt = handle_world_radius(tip, &ctx.camera, ctx.viewport_size.y, 8.0);
244
245        let dc = ray_point_dist(ray_origin, ray_dir, self.center);
246        let dt = ray_point_dist(ray_origin, ray_dir, tip);
247
248        let center_ball = parry3d::shape::Ball::new(rc);
249        let tip_ball = parry3d::shape::Ball::new(rt);
250        let center_pose = Pose::from_parts(
251            [self.center.x, self.center.y, self.center.z].into(),
252            glam::Quat::IDENTITY,
253        );
254        let tip_pose = Pose::from_parts([tip.x, tip.y, tip.z].into(), glam::Quat::IDENTITY);
255
256        let tc = if dc < rc {
257            center_ball.cast_ray(&center_pose, &ray, f32::MAX, true)
258        } else {
259            None
260        };
261        let tt = if dt < rt {
262            tip_ball.cast_ray(&tip_pose, &ray, f32::MAX, true)
263        } else {
264            None
265        };
266
267        match (tc, tt) {
268            (Some(a), Some(b)) => Some(if a <= b {
269                PlaneHandle::Center
270            } else {
271                PlaneHandle::NormalTip
272            }),
273            (Some(_), None) => Some(PlaneHandle::Center),
274            (None, Some(_)) => Some(PlaneHandle::NormalTip),
275            (None, None) => None,
276        }
277    }
278}