Skip to main content

viewport_lib/interaction/widgets/
sphere.rs

1//! Sphere widget: draggable center handle and radius handle.
2
3use crate::interaction::clip_plane::ray_plane_intersection;
4use crate::renderer::{ClipObject, ClipShape, GlyphItem, GlyphType, PolylineItem};
5use parry3d::math::{Pose, Vector};
6use parry3d::query::{Ray, RayCast};
7
8use super::{WidgetContext, WidgetResult, ctx_ray, handle_world_radius};
9
10#[derive(Clone, Copy, PartialEq, Eq, Debug)]
11enum SphereHandle {
12    Center,
13    Radius,
14}
15
16/// An interactive sphere widget with a draggable center and radius handle.
17///
18/// Use `clip_object()` to get the visual fill/outline (push into
19/// `fd.effects.clip_objects` with `clip_geometry: false`), and `handle_glyphs()`
20/// for the draggable handle spheres (push into `fd.scene.glyphs`).
21///
22/// # Usage
23///
24/// ```rust,ignore
25/// let mut sphere = SphereWidget::new(glam::Vec3::ZERO, 2.0);
26///
27/// // Each frame:
28/// sphere.update(&ctx);
29/// fd.effects.clip_objects.push(sphere.clip_object());
30/// fd.scene.glyphs.push(sphere.handle_glyphs(HANDLE_ID, &ctx));
31/// ```
32pub struct SphereWidget {
33    /// World-space center of the sphere.
34    pub center: glam::Vec3,
35    /// Radius in world units.
36    pub radius: f32,
37    /// RGBA fill colour (alpha controls transparency of the fill).
38    pub colour: [f32; 4],
39    /// RGBA colour for the drag handles. When set (non-zero alpha), overrides the default LUT colouring.
40    pub handle_colour: [f32; 4],
41
42    hovered_handle: Option<SphereHandle>,
43    active_handle: Option<SphereHandle>,
44    drag_plane_normal: glam::Vec3,
45    drag_plane_d: f32,
46    drag_anchor_world: glam::Vec3,
47    drag_anchor_radius: f32,
48}
49
50impl SphereWidget {
51    /// Create a new sphere widget.
52    pub fn new(center: glam::Vec3, radius: f32) -> Self {
53        Self {
54            center,
55            radius: radius.max(0.01),
56            colour: [0.3, 0.6, 1.0, 0.25],
57            handle_colour: [0.0; 4],
58            hovered_handle: None,
59            active_handle: None,
60            drag_plane_normal: glam::Vec3::Z,
61            drag_plane_d: 0.0,
62            drag_anchor_world: glam::Vec3::ZERO,
63            drag_anchor_radius: 0.0,
64        }
65    }
66
67    /// True while a drag session is in progress.
68    pub fn is_active(&self) -> bool {
69        self.active_handle.is_some()
70    }
71
72    /// Process input for this frame. Returns `Updated` if state changed.
73    pub fn update(&mut self, ctx: &WidgetContext) -> WidgetResult {
74        let (ro, rd) = ctx_ray(ctx);
75        let mut updated = false;
76
77        if self.active_handle.is_none() {
78            let hit = self.hit_test(ro, rd, ctx);
79            // On the drag_started frame the cursor can be right at the edge and the
80            // hit test may miss by a hair. Keep the previous hover so the drag still
81            // registers if the handle was highlighted on the frame before the click.
82            if hit.is_some() || !ctx.drag_started {
83                self.hovered_handle = hit;
84            }
85        }
86
87        if ctx.drag_started {
88            if let Some(handle) = self.hovered_handle {
89                let anchor = match handle {
90                    SphereHandle::Center => self.center,
91                    SphereHandle::Radius => self.radius_handle_pos(),
92                };
93                let fwd = glam::Vec3::from(ctx.camera.forward);
94                let n = -fwd;
95                self.drag_plane_normal = n;
96                self.drag_plane_d = -n.dot(anchor);
97                self.drag_anchor_world = anchor;
98                self.drag_anchor_radius = self.radius;
99                self.active_handle = Some(handle);
100            }
101        }
102
103        if let Some(handle) = self.active_handle {
104            if ctx.released || (!ctx.dragging && !ctx.drag_started) {
105                self.active_handle = None;
106                self.hovered_handle = None;
107            } else if let Some(hit) =
108                ray_plane_intersection(ro, rd, self.drag_plane_normal, self.drag_plane_d)
109            {
110                match handle {
111                    SphereHandle::Center => {
112                        let delta = hit - self.drag_anchor_world;
113                        let new_center = self.center + delta;
114                        if (new_center - self.center).length_squared() > 1e-10 {
115                            self.center = new_center;
116                            self.drag_anchor_world = hit;
117                            updated = true;
118                        }
119                    }
120                    SphereHandle::Radius => {
121                        let new_r = (hit - self.center).length().max(0.01);
122                        if (new_r - self.radius).abs() > 1e-5 {
123                            self.radius = new_r;
124                            updated = true;
125                        }
126                    }
127                }
128            }
129        }
130
131        if updated {
132            WidgetResult::Updated
133        } else {
134            WidgetResult::None
135        }
136    }
137
138    /// Build a `ClipObject` for the sphere visual (fill + outline).
139    ///
140    /// Push into `fd.effects.clip_objects`. `clip_geometry` is set to `false` so
141    /// the sphere only renders as a visual indicator and does not clip geometry.
142    pub fn clip_object(&self) -> ClipObject {
143        let edge = [self.colour[0], self.colour[1], self.colour[2], 1.0];
144        ClipObject {
145            shape: ClipShape::Sphere {
146                center: self.center.to_array(),
147                radius: self.radius,
148            },
149            colour: Some(self.colour),
150            edge_colour: Some(edge),
151            clip_geometry: false,
152            enabled: true,
153            hovered: self.hovered_handle.is_some(),
154            active: self.active_handle.is_some(),
155            ..ClipObject::default()
156        }
157    }
158
159    /// Build a `PolylineItem` of three great circle rings (XY, XZ, YZ planes).
160    ///
161    /// Push into `fd.scene.polylines` for a scene-pass outline consistent with
162    /// how `BoxWidget::wireframe_item` is used. `id` is the pick ID (0 = not pickable).
163    pub fn wireframe_item(&self, id: u64) -> PolylineItem {
164        const STEPS: usize = 64;
165        let mut positions: Vec<[f32; 3]> = Vec::with_capacity(STEPS * 3 + 3);
166        let mut strip_lengths: Vec<u32> = Vec::with_capacity(3);
167        let c = self.center;
168        let r = self.radius;
169
170        for ring in 0..3_usize {
171            for i in 0..=STEPS {
172                let a = i as f32 * std::f32::consts::TAU / STEPS as f32;
173                let (s, co) = a.sin_cos();
174                let p = match ring {
175                    0 => glam::Vec3::new(co * r, s * r, 0.0),
176                    1 => glam::Vec3::new(co * r, 0.0, s * r),
177                    _ => glam::Vec3::new(0.0, co * r, s * r),
178                };
179                positions.push((c + p).to_array());
180            }
181            strip_lengths.push((STEPS + 1) as u32);
182        }
183
184        let line_colour = [self.colour[0], self.colour[1], self.colour[2], 1.0];
185        PolylineItem {
186            positions,
187            strip_lengths,
188            default_colour: line_colour,
189            line_width: 1.5,
190
191            settings: crate::scene::material::ItemSettings { pick_id: crate::renderer::PickId(id), ..Default::default() },
192            ..PolylineItem::default()
193        }
194    }
195
196    /// Build a `GlyphItem` with sphere handles: one at the center, one at the
197    /// radius edge (along +X from center).
198    ///
199    /// `id_base` is the pick ID for the center handle; `id_base + 1` for the radius handle.
200    pub fn handle_glyphs(&self, id_base: u64, ctx: &WidgetContext) -> GlyphItem {
201        let rp = self.radius_handle_pos();
202        let r_center = handle_world_radius(self.center, &ctx.camera, ctx.viewport_size.y, 10.0);
203        let r_rh = handle_world_radius(rp, &ctx.camera, ctx.viewport_size.y, 8.0);
204
205        let sc = if self.hovered_handle == Some(SphereHandle::Center)
206            || self.active_handle == Some(SphereHandle::Center)
207        {
208            1.0_f32
209        } else {
210            0.2
211        };
212        let sr = if self.hovered_handle == Some(SphereHandle::Radius)
213            || self.active_handle == Some(SphereHandle::Radius)
214        {
215            1.0_f32
216        } else {
217            0.2
218        };
219
220        GlyphItem {
221            positions: vec![self.center.to_array(), rp.to_array()],
222            vectors: vec![[r_center, 0.0, 0.0], [r_rh, 0.0, 0.0]],
223            scale: 1.0,
224            scale_by_magnitude: true,
225            scalars: vec![sc, sr],
226            scalar_range: Some((0.0, 1.0)),
227            glyph_type: GlyphType::Sphere,
228
229            settings: crate::scene::material::ItemSettings { pick_id: crate::renderer::PickId(id_base), ..Default::default() },
230            default_colour: self.handle_colour,
231            use_default_colour: self.handle_colour[3] > 0.0,
232            ..GlyphItem::default()
233        }
234    }
235
236    // -----------------------------------------------------------------------
237    // Internal
238    // -----------------------------------------------------------------------
239
240    fn radius_handle_pos(&self) -> glam::Vec3 {
241        self.center + glam::Vec3::X * self.radius
242    }
243
244    fn hit_test(
245        &self,
246        ray_origin: glam::Vec3,
247        ray_dir: glam::Vec3,
248        ctx: &WidgetContext,
249    ) -> Option<SphereHandle> {
250        let ray = Ray::new(
251            Vector::new(ray_origin.x, ray_origin.y, ray_origin.z),
252            Vector::new(ray_dir.x, ray_dir.y, ray_dir.z),
253        );
254
255        let rp = self.radius_handle_pos();
256        let rh_r = handle_world_radius(rp, &ctx.camera, ctx.viewport_size.y, 8.0);
257        let ch_r = handle_world_radius(self.center, &ctx.camera, ctx.viewport_size.y, 10.0);
258
259        let rh_ball = parry3d::shape::Ball::new(rh_r);
260        let ch_ball = parry3d::shape::Ball::new(ch_r);
261
262        let rh_pose = Pose::from_parts([rp.x, rp.y, rp.z].into(), glam::Quat::IDENTITY);
263        let ch_pose = Pose::from_parts(
264            [self.center.x, self.center.y, self.center.z].into(),
265            glam::Quat::IDENTITY,
266        );
267
268        let t_rh = rh_ball.cast_ray(&rh_pose, &ray, f32::MAX, true).map(|i| i);
269        let t_ch = ch_ball.cast_ray(&ch_pose, &ray, f32::MAX, true).map(|i| i);
270
271        match (t_ch, t_rh) {
272            (Some(tc), Some(tr)) => Some(if tc <= tr {
273                SphereHandle::Center
274            } else {
275                SphereHandle::Radius
276            }),
277            (Some(_), None) => Some(SphereHandle::Center),
278            (None, Some(_)) => Some(SphereHandle::Radius),
279            (None, None) => None,
280        }
281    }
282}