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