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