Skip to main content

viewport_lib/interaction/widgets/
disk.rs

1//! Disk widget: a bounded circular plane with center, normal, and radius handles.
2
3use crate::geometry::intersect::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, any_perpendicular_pair, ctx_ray,
10    handle_world_radius, ray_point_dist,
11};
12
13#[derive(Clone, Copy, PartialEq, Eq, Debug)]
14enum DiskHandle {
15    Center,
16    NormalTip,
17    Radius,
18}
19
20/// An interactive disk widget: a bounded circular plane with center, normal, and radius.
21///
22/// Three handles: center (moves freely), normal tip (orbits the normal), and a radius
23/// handle on the disk rim (scales the radius).
24///
25/// # Usage
26///
27/// ```rust,ignore
28/// let mut disk = DiskWidget::new(glam::Vec3::ZERO, glam::Vec3::Z, 2.0);
29///
30/// // Each frame:
31/// disk.update(&ctx);
32/// fd.scene.polylines.push(disk.wireframe_item(DISK_ID));
33/// fd.scene.glyphs.push(disk.handle_glyphs(HANDLE_ID, &ctx));
34/// ```
35pub struct DiskWidget {
36    /// World-space center of the disk.
37    pub center: glam::Vec3,
38    /// Unit normal vector of the disk plane.
39    pub normal: glam::Vec3,
40    /// Radius in world units.
41    pub radius: f32,
42    /// RGBA colour for the wireframe circle 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    /// Distance from center to the normal-tip handle sphere.
47    pub normal_display_length: f32,
48
49    hovered_handle: Option<DiskHandle>,
50    active_handle: Option<DiskHandle>,
51    drag_plane_normal: glam::Vec3,
52    drag_plane_d: f32,
53    drag_anchor: glam::Vec3,
54}
55
56impl DiskWidget {
57    /// Create a new disk widget.
58    ///
59    /// `normal` is normalized internally; if zero, defaults to `Vec3::Z`.
60    pub fn new(center: glam::Vec3, normal: glam::Vec3, radius: f32) -> Self {
61        let len = normal.length();
62        let normal = if len > 1e-6 {
63            normal / len
64        } else {
65            glam::Vec3::Z
66        };
67        Self {
68            center,
69            normal,
70            radius: radius.max(0.01),
71            colour: [0.9, 0.6, 0.1, 1.0],
72            handle_colour: [0.0; 4],
73            normal_display_length: 2.0,
74            hovered_handle: None,
75            active_handle: None,
76            drag_plane_normal: glam::Vec3::Z,
77            drag_plane_d: 0.0,
78            drag_anchor: glam::Vec3::ZERO,
79        }
80    }
81
82    /// True while a drag is in progress on any handle.
83    pub fn is_active(&self) -> bool {
84        self.active_handle.is_some()
85    }
86
87    /// Process input for this frame. Returns `Updated` if state changed.
88    pub fn update(&mut self, ctx: &WidgetContext) -> WidgetResult {
89        let (ro, rd) = ctx_ray(ctx);
90        let mut updated = false;
91
92        if self.active_handle.is_none() {
93            let hit = self.hit_test(ro, rd, ctx);
94            if hit.is_some() || !ctx.drag_started {
95                self.hovered_handle = hit;
96            }
97        }
98
99        if ctx.drag_started {
100            if let Some(handle) = self.hovered_handle {
101                let anchor = match handle {
102                    DiskHandle::Center => self.center,
103                    DiskHandle::NormalTip => self.normal_tip_pos(),
104                    DiskHandle::Radius => self.radius_handle_pos(),
105                };
106                let n = -glam::Vec3::from(ctx.camera.forward);
107                self.drag_plane_normal = n;
108                self.drag_plane_d = -n.dot(anchor);
109                self.drag_anchor = anchor;
110                self.active_handle = Some(handle);
111            }
112        }
113
114        if let Some(handle) = self.active_handle {
115            if ctx.released || (!ctx.dragging && !ctx.drag_started) {
116                self.active_handle = None;
117                self.hovered_handle = None;
118            } else if let Some(hit) =
119                ray_plane_intersection(ro, rd, self.drag_plane_normal, self.drag_plane_d)
120            {
121                match handle {
122                    DiskHandle::Center => {
123                        let delta = hit - self.drag_anchor;
124                        if delta.length_squared() > 1e-10 {
125                            self.center += delta;
126                            self.drag_anchor = hit;
127                            updated = true;
128                        }
129                    }
130                    DiskHandle::NormalTip => {
131                        let dir = hit - self.center;
132                        let len = dir.length();
133                        if len > 1e-3 {
134                            let new_normal = dir / len;
135                            if (new_normal - self.normal).length_squared() > 1e-8 {
136                                self.normal = new_normal;
137                                updated = true;
138                            }
139                        }
140                    }
141                    DiskHandle::Radius => {
142                        let new_r = (hit - self.center).length().max(0.01);
143                        if (new_r - self.radius).abs() > 1e-5 {
144                            self.radius = new_r;
145                            updated = true;
146                        }
147                    }
148                }
149            }
150        }
151
152        if updated {
153            WidgetResult::Updated
154        } else {
155            WidgetResult::None
156        }
157    }
158
159    /// Build a `PolylineItem` for the wireframe circle and normal indicator.
160    ///
161    /// The circle uses 32 segments. A line from center to the normal tip is also included.
162    pub fn wireframe_item(&self, id: u64) -> PolylineItem {
163        const STEPS: usize = 32;
164        let (u, v) = any_perpendicular_pair(self.normal);
165        let c = self.center;
166        let r = self.radius;
167
168        let mut positions: Vec<[f32; 3]> = Vec::with_capacity(STEPS + 1 + 2);
169        crate::geometry::polyline::push_circle_loop(&mut positions, c, u, v, r, STEPS);
170        positions.push(c.to_array());
171        positions.push(self.normal_tip_pos().to_array());
172
173        PolylineItem {
174            positions,
175            strip_lengths: vec![(STEPS + 1) as u32, 2],
176            default_colour: self.colour,
177            line_width: 1.5,
178
179            settings: crate::scene::material::ItemSettings {
180                pick_id: crate::renderer::PickId(id),
181                ..Default::default()
182            },
183            ..PolylineItem::default()
184        }
185    }
186
187    /// Build a `GlyphItem` with three sphere handles: center, normal tip, and radius edge.
188    ///
189    /// `id_base` = center, `id_base + 1` = normal tip, `id_base + 2` = radius handle.
190    pub fn handle_glyphs(&self, id_base: u64, ctx: &WidgetContext) -> GlyphItem {
191        let tip = self.normal_tip_pos();
192        let rh = self.radius_handle_pos();
193
194        let rc = handle_world_radius(self.center, &ctx.camera, ctx.viewport_size.y, 10.0);
195        let rt = handle_world_radius(tip, &ctx.camera, ctx.viewport_size.y, 8.0);
196        let rr = handle_world_radius(rh, &ctx.camera, ctx.viewport_size.y, 8.0);
197
198        let scalar = |h: DiskHandle| {
199            if self.hovered_handle == Some(h) || self.active_handle == Some(h) {
200                1.0_f32
201            } else {
202                0.2
203            }
204        };
205
206        GlyphItem {
207            positions: vec![self.center.to_array(), tip.to_array(), rh.to_array()],
208            vectors: vec![[rc, 0.0, 0.0], [rt, 0.0, 0.0], [rr, 0.0, 0.0]],
209            scale: 1.0,
210            scale_by_magnitude: true,
211            scalars: vec![
212                scalar(DiskHandle::Center),
213                scalar(DiskHandle::NormalTip),
214                scalar(DiskHandle::Radius),
215            ],
216            scalar_range: Some((0.0, 1.0)),
217            glyph_type: GlyphType::Sphere,
218
219            settings: crate::scene::material::ItemSettings {
220                pick_id: crate::renderer::PickId(id_base),
221                ..Default::default()
222            },
223            default_colour: self.handle_colour,
224            use_default_colour: self.handle_colour[3] > 0.0,
225            ..GlyphItem::default()
226        }
227    }
228
229    // -----------------------------------------------------------------------
230    // Internal
231    // -----------------------------------------------------------------------
232
233    fn normal_tip_pos(&self) -> glam::Vec3 {
234        self.center + self.normal * self.normal_display_length
235    }
236
237    fn radius_handle_pos(&self) -> glam::Vec3 {
238        // Radius handle sits on the disk rim along any perpendicular direction.
239        self.center + any_perpendicular(self.normal) * self.radius
240    }
241
242    fn hit_test(
243        &self,
244        ray_origin: glam::Vec3,
245        ray_dir: glam::Vec3,
246        ctx: &WidgetContext,
247    ) -> Option<DiskHandle> {
248        let tip = self.normal_tip_pos();
249        let rh = self.radius_handle_pos();
250
251        let ray = Ray::new(
252            Vector::new(ray_origin.x, ray_origin.y, ray_origin.z),
253            Vector::new(ray_dir.x, ray_dir.y, ray_dir.z),
254        );
255
256        let rc = handle_world_radius(self.center, &ctx.camera, ctx.viewport_size.y, 10.0);
257        let rt = handle_world_radius(tip, &ctx.camera, ctx.viewport_size.y, 8.0);
258        let rr = handle_world_radius(rh, &ctx.camera, ctx.viewport_size.y, 8.0);
259
260        let handles = [
261            (DiskHandle::Center, self.center, rc),
262            (DiskHandle::NormalTip, tip, rt),
263            (DiskHandle::Radius, rh, rr),
264        ];
265
266        let mut best: Option<(f32, DiskHandle)> = None;
267        for (handle, pos, radius) in handles {
268            let d = ray_point_dist(ray_origin, ray_dir, pos);
269            if d < radius {
270                let ball = parry3d::shape::Ball::new(radius);
271                let pose = Pose::from_parts([pos.x, pos.y, pos.z].into(), glam::Quat::IDENTITY);
272                if let Some(t) = ball.cast_ray(&pose, &ray, f32::MAX, true) {
273                    if best.is_none() || t < best.unwrap().0 {
274                        best = Some((t, handle));
275                    }
276                }
277            }
278        }
279
280        best.map(|(_, h)| h)
281    }
282}