Skip to main content

viewport_lib/interaction/widgets/
box_widget.rs

1//! Box widget: draggable center, face, and rotation-arc handles for an oriented box.
2
3use crate::interaction::clip_plane::ray_plane_intersection;
4use crate::renderer::{GlyphItem, GlyphType, PolylineItem};
5use crate::scene::aabb::Aabb;
6use parry3d::math::{Pose, Vector};
7use parry3d::query::{Ray, RayCast};
8
9use super::{WidgetContext, WidgetResult, any_perpendicular_pair, ctx_ray, handle_world_radius};
10
11/// Which handle on the box is being interacted with.
12#[derive(Clone, Copy, PartialEq, Eq, Debug)]
13enum BoxHandle {
14    /// Center: moves the whole box.
15    Center,
16    /// One of the six face handles. Index: 0=+X, 1=-X, 2=+Y, 3=-Y, 4=+Z, 5=-Z (in local box space).
17    Face(usize),
18    /// One of three rotation arc grips. Index: 0=rotate around X, 1=around Y, 2=around Z.
19    RotArc(usize),
20}
21
22/// An interactive oriented box widget with translation, resize, and rotation handles.
23///
24/// The box orientation is controlled by `rotation` (default `Quat::IDENTITY` for axis-aligned).
25/// Three rotation arc handles (one per world axis) are rendered as circle overlays.
26///
27/// Use `wireframe_item()` for the box outline, `rotation_arcs_item()` for the arc circles,
28/// and `handle_glyphs()` for all 10 draggable sphere handles.
29///
30/// # Usage
31///
32/// ```rust,ignore
33/// let mut bw = BoxWidget::new(glam::Vec3::ZERO, glam::Vec3::splat(2.0));
34///
35/// // Each frame:
36/// bw.update(&ctx);
37/// fd.scene.polylines.push(bw.wireframe_item(BOX_ID));
38/// fd.scene.polylines.push(bw.rotation_arcs_item(ARC_ID));
39/// fd.scene.glyphs.push(bw.handle_glyphs(HANDLE_ID, &ctx));
40/// ```
41pub struct BoxWidget {
42    /// World-space center of the box.
43    pub center: glam::Vec3,
44    /// Half-extents along the box's local X, Y, Z axes.
45    pub half_extents: glam::Vec3,
46    /// Orientation of the box (rotates the local axes).
47    pub rotation: glam::Quat,
48    /// RGBA colour for the wireframe outline.
49    pub colour: [f32; 4],
50    /// RGBA colour for the drag handles. When set (non-zero alpha), overrides the default LUT colouring.
51    pub handle_colour: [f32; 4],
52
53    hovered_handle: Option<BoxHandle>,
54    active_handle: Option<BoxHandle>,
55    drag_plane_normal: glam::Vec3,
56    drag_plane_d: f32,
57    drag_anchor_world: glam::Vec3,
58}
59
60impl BoxWidget {
61    /// Create a new axis-aligned box widget (rotation defaults to identity).
62    pub fn new(center: glam::Vec3, half_extents: glam::Vec3) -> Self {
63        Self {
64            center,
65            half_extents: half_extents.max(glam::Vec3::splat(0.01)),
66            rotation: glam::Quat::IDENTITY,
67            colour: [0.3, 0.8, 0.4, 1.0],
68            handle_colour: [0.0; 4],
69            hovered_handle: None,
70            active_handle: None,
71            drag_plane_normal: glam::Vec3::Z,
72            drag_plane_d: 0.0,
73            drag_anchor_world: glam::Vec3::ZERO,
74        }
75    }
76
77    /// True while a drag session is in progress.
78    pub fn is_active(&self) -> bool {
79        self.active_handle.is_some()
80    }
81
82    /// Returns `(center, half_extents, rotation)` for this oriented box.
83    pub fn obb(&self) -> (glam::Vec3, glam::Vec3, glam::Quat) {
84        (self.center, self.half_extents, self.rotation)
85    }
86
87    /// World-space AABB that conservatively bounds the oriented box.
88    ///
89    /// When `rotation` is identity this equals the exact box. Otherwise it is a
90    /// tight enclosing axis-aligned box computed from all 8 oriented corners.
91    pub fn aabb(&self) -> Aabb {
92        let mut min = glam::Vec3::splat(f32::MAX);
93        let mut max = glam::Vec3::splat(f32::MIN);
94        let h = self.half_extents;
95        for sx in [-1.0_f32, 1.0] {
96            for sy in [-1.0_f32, 1.0] {
97                for sz in [-1.0_f32, 1.0] {
98                    let p =
99                        self.center + self.rotation * glam::Vec3::new(sx * h.x, sy * h.y, sz * h.z);
100                    min = min.min(p);
101                    max = max.max(p);
102                }
103            }
104        }
105        Aabb { min, max }
106    }
107
108    /// Returns true if `point` lies inside the oriented box.
109    pub fn contains_point(&self, point: glam::Vec3) -> bool {
110        let local = self.rotation.inverse() * (point - self.center);
111        local.x.abs() <= self.half_extents.x
112            && local.y.abs() <= self.half_extents.y
113            && local.z.abs() <= self.half_extents.z
114    }
115
116    /// Process input for this frame. Returns `Updated` if state changed.
117    pub fn update(&mut self, ctx: &WidgetContext) -> WidgetResult {
118        let (ro, rd) = ctx_ray(ctx);
119        let mut updated = false;
120
121        if self.active_handle.is_none() {
122            let hit = self.hit_test(ro, rd, ctx);
123            // On the drag_started frame the cursor can be right at the edge and the
124            // hit test may miss by a hair. Keep the previous hover so the drag still
125            // registers if the handle was highlighted on the frame before the click.
126            if hit.is_some() || !ctx.drag_started {
127                self.hovered_handle = hit;
128            }
129        }
130
131        if ctx.drag_started {
132            if let Some(handle) = self.hovered_handle {
133                let anchor = self.handle_pos(handle);
134                // Camera-facing drag plane. For face handles the resize amount is
135                // derived by projecting movement onto the rotated face normal after
136                // the hit is found, not by constraining the drag plane to that axis.
137                let n = -glam::Vec3::from(ctx.camera.forward);
138                self.drag_plane_normal = n;
139                self.drag_plane_d = -n.dot(anchor);
140                self.drag_anchor_world = anchor;
141                self.active_handle = Some(handle);
142            }
143        }
144
145        if let Some(handle) = self.active_handle {
146            if ctx.released || (!ctx.dragging && !ctx.drag_started) {
147                self.active_handle = None;
148                self.hovered_handle = None;
149            } else {
150                match handle {
151                    BoxHandle::Center => {
152                        if let Some(hit) = ray_plane_intersection(
153                            ro,
154                            rd,
155                            self.drag_plane_normal,
156                            self.drag_plane_d,
157                        ) {
158                            let delta = hit - self.drag_anchor_world;
159                            if delta.length_squared() > 1e-10 {
160                                self.center += delta;
161                                self.drag_anchor_world = hit;
162                                updated = true;
163                            }
164                        }
165                    }
166                    BoxHandle::Face(i) => {
167                        if let Some(hit) = ray_plane_intersection(
168                            ro,
169                            rd,
170                            self.drag_plane_normal,
171                            self.drag_plane_d,
172                        ) {
173                            // Rotated face normal in world space.
174                            let world_normal = self.rotation * Self::local_face_normal(i);
175                            let proj = (hit - self.drag_anchor_world).dot(world_normal);
176                            if proj.abs() > 1e-5 {
177                                let axis = i / 2; // 0=X, 1=Y, 2=Z
178                                let new_he = (self.half_extents[axis] + proj).max(0.01);
179                                let he_delta = new_he - self.half_extents[axis];
180                                self.half_extents[axis] = new_he;
181                                // Keep the opposite face fixed: shift center along the rotated normal.
182                                self.center += world_normal * (he_delta * 0.5);
183                                self.drag_anchor_world = hit;
184                                updated = true;
185                            }
186                        }
187                    }
188                    BoxHandle::RotArc(axis_idx) => {
189                        // Project ray onto the plane perpendicular to the rotation axis
190                        // through the box center. Measure the angle delta from the last
191                        // drag anchor and apply it as an incremental rotation.
192                        let axis = Self::world_rotation_axis(axis_idx);
193                        let plane_d = -axis.dot(self.center);
194                        if let Some(plane_hit) = ray_plane_intersection(ro, rd, axis, plane_d) {
195                            let start_dir =
196                                (self.drag_anchor_world - self.center).normalize_or_zero();
197                            let new_dir = (plane_hit - self.center).normalize_or_zero();
198                            if start_dir.length_squared() > 0.5 && new_dir.length_squared() > 0.5 {
199                                let cos_a = start_dir.dot(new_dir).clamp(-1.0, 1.0);
200                                let cross = start_dir.cross(new_dir);
201                                let sign = cross.dot(axis).signum();
202                                let angle = cos_a.acos() * sign;
203                                if angle.abs() > 1e-5 {
204                                    let delta_rot = glam::Quat::from_axis_angle(axis, angle);
205                                    self.rotation = (delta_rot * self.rotation).normalize();
206                                    // Update anchor to the new position on the arc.
207                                    self.drag_anchor_world =
208                                        self.center + new_dir * self.arc_radius();
209                                    updated = true;
210                                }
211                            }
212                        }
213                    }
214                }
215            }
216        }
217
218        if updated {
219            WidgetResult::Updated
220        } else {
221            WidgetResult::None
222        }
223    }
224
225    /// Build a `PolylineItem` for the oriented box wireframe (12 edges).
226    ///
227    /// `id` is the pick ID (0 = not pickable).
228    pub fn wireframe_item(&self, id: u64) -> PolylineItem {
229        let c = self.center;
230        let h = self.half_extents;
231        let r = self.rotation;
232
233        let p =
234            |x: f32, y: f32, z: f32| -> [f32; 3] { (c + r * glam::Vec3::new(x, y, z)).to_array() };
235
236        PolylineItem {
237            positions: vec![
238                // Bottom face loop (local z = -h.z)
239                p(-h.x, -h.y, -h.z),
240                p(h.x, -h.y, -h.z),
241                p(h.x, h.y, -h.z),
242                p(-h.x, h.y, -h.z),
243                p(-h.x, -h.y, -h.z),
244                // Top face loop (local z = +h.z)
245                p(-h.x, -h.y, h.z),
246                p(h.x, -h.y, h.z),
247                p(h.x, h.y, h.z),
248                p(-h.x, h.y, h.z),
249                p(-h.x, -h.y, h.z),
250                // Four vertical edges
251                p(-h.x, -h.y, -h.z),
252                p(-h.x, -h.y, h.z),
253                p(h.x, -h.y, -h.z),
254                p(h.x, -h.y, h.z),
255                p(h.x, h.y, -h.z),
256                p(h.x, h.y, h.z),
257                p(-h.x, h.y, -h.z),
258                p(-h.x, h.y, h.z),
259            ],
260            strip_lengths: vec![5, 5, 2, 2, 2, 2],
261            default_colour: self.colour,
262
263            settings: crate::scene::material::ItemSettings { pick_id: crate::renderer::PickId(id), ..Default::default() },
264            ..PolylineItem::default()
265        }
266    }
267
268    /// Build a `PolylineItem` containing three rotation arcs (one per world axis).
269    ///
270    /// Each arc is a full circle at radius `arc_radius()` around the box center.
271    /// Arc colours: X = red, Y = green, Z = blue (semi-transparent).
272    pub fn rotation_arcs_item(&self, id: u64) -> PolylineItem {
273        const STEPS: usize = 48;
274        let c = self.center;
275        let r = self.arc_radius();
276        let arc_colours = [
277            [0.9_f32, 0.2, 0.2, 0.7], // X: red
278            [0.2, 0.9, 0.2, 0.7],     // Y: green
279            [0.2, 0.4, 1.0, 0.7],     // Z: blue
280        ];
281
282        // All three arcs concatenated into one PolylineItem for a single draw call.
283        // Use the overall widget colour -- the showcase can override with separate items if needed.
284        let mut positions: Vec<[f32; 3]> = Vec::with_capacity((STEPS + 1) * 3);
285        let mut strip_lengths: Vec<u32> = Vec::new();
286        let _ = arc_colours; // colour varies per strip, but PolylineItem has a single colour; we use widget colour
287
288        for axis_idx in 0..3_usize {
289            let axis = Self::world_rotation_axis(axis_idx);
290            let (u, v) = any_perpendicular_pair(axis);
291            for i in 0..=STEPS {
292                let a = i as f32 * std::f32::consts::TAU / STEPS as f32;
293                let (s, co) = a.sin_cos();
294                positions.push((c + u * (co * r) + v * (s * r)).to_array());
295            }
296            strip_lengths.push((STEPS + 1) as u32);
297        }
298
299        PolylineItem {
300            positions,
301            strip_lengths,
302            default_colour: self.colour,
303            line_width: 1.2,
304
305            settings: crate::scene::material::ItemSettings { pick_id: crate::renderer::PickId(id), ..Default::default() },
306            ..PolylineItem::default()
307        }
308    }
309
310    /// Build a `GlyphItem` with 10 sphere handles: center, 6 face handles, and 3 rotation grips.
311    ///
312    /// Pick IDs: `id_base` = center, `id_base + 1..6` = faces (+X,-X,+Y,-Y,+Z,-Z),
313    /// `id_base + 7..9` = rotation arc grips (X, Y, Z).
314    pub fn handle_glyphs(&self, id_base: u64, ctx: &WidgetContext) -> GlyphItem {
315        let all_handles = [
316            BoxHandle::Center,
317            BoxHandle::Face(0),
318            BoxHandle::Face(1),
319            BoxHandle::Face(2),
320            BoxHandle::Face(3),
321            BoxHandle::Face(4),
322            BoxHandle::Face(5),
323            BoxHandle::RotArc(0),
324            BoxHandle::RotArc(1),
325            BoxHandle::RotArc(2),
326        ];
327
328        let mut positions = Vec::with_capacity(10);
329        let mut vectors = Vec::with_capacity(10);
330        let mut scalars = Vec::with_capacity(10);
331
332        for handle in all_handles {
333            let pos = self.handle_pos(handle);
334            let target_px = if matches!(handle, BoxHandle::RotArc(_)) {
335                7.0
336            } else {
337                9.0
338            };
339            let r = handle_world_radius(pos, &ctx.camera, ctx.viewport_size.y, target_px);
340            let s = if self.hovered_handle == Some(handle) || self.active_handle == Some(handle) {
341                1.0_f32
342            } else {
343                0.2
344            };
345            positions.push(pos.to_array());
346            vectors.push([r, 0.0, 0.0]);
347            scalars.push(s);
348        }
349
350        GlyphItem {
351            positions,
352            vectors,
353            scale: 1.0,
354            scale_by_magnitude: true,
355            scalars,
356            scalar_range: Some((0.0, 1.0)),
357            glyph_type: GlyphType::Sphere,
358
359            settings: crate::scene::material::ItemSettings { pick_id: crate::renderer::PickId(id_base), ..Default::default() },
360            default_colour: self.handle_colour,
361            use_default_colour: self.handle_colour[3] > 0.0,
362            ..GlyphItem::default()
363        }
364    }
365
366    // -----------------------------------------------------------------------
367    // Internal
368    // -----------------------------------------------------------------------
369
370    /// World position of a handle.
371    fn handle_pos(&self, handle: BoxHandle) -> glam::Vec3 {
372        match handle {
373            BoxHandle::Center => self.center,
374            BoxHandle::Face(i) => {
375                self.center
376                    + self.rotation * (Self::local_face_normal(i) * self.half_extents[i / 2])
377            }
378            BoxHandle::RotArc(i) => self.center + self.arc_grip_offset(i),
379        }
380    }
381
382    /// Local-space outward unit normal for face index 0..5 (+X,-X,+Y,-Y,+Z,-Z).
383    fn local_face_normal(i: usize) -> glam::Vec3 {
384        match i {
385            0 => glam::Vec3::X,
386            1 => glam::Vec3::NEG_X,
387            2 => glam::Vec3::Y,
388            3 => glam::Vec3::NEG_Y,
389            4 => glam::Vec3::Z,
390            _ => glam::Vec3::NEG_Z,
391        }
392    }
393
394    /// World-space rotation axis for arc index 0..2 (X, Y, Z).
395    fn world_rotation_axis(i: usize) -> glam::Vec3 {
396        match i {
397            0 => glam::Vec3::X,
398            1 => glam::Vec3::Y,
399            _ => glam::Vec3::Z,
400        }
401    }
402
403    /// Radius of the rotation arc circles. Slightly larger than the box diagonal.
404    fn arc_radius(&self) -> f32 {
405        self.half_extents.length() * 1.4 + 0.1
406    }
407
408    /// World-space offset from center to the grip sphere for rotation arc `i`.
409    fn arc_grip_offset(&self, i: usize) -> glam::Vec3 {
410        let r = self.arc_radius();
411        // Each arc's grip is positioned along a world axis perpendicular to the rotation axis,
412        // so the three grips sit at distinct positions.
413        match i {
414            0 => glam::Vec3::new(0.0, r, 0.0), // X-arc grip: +Y
415            1 => glam::Vec3::new(0.0, 0.0, r), // Y-arc grip: +Z
416            _ => glam::Vec3::new(r, 0.0, 0.0), // Z-arc grip: +X
417        }
418    }
419
420    fn hit_test(
421        &self,
422        ray_origin: glam::Vec3,
423        ray_dir: glam::Vec3,
424        ctx: &WidgetContext,
425    ) -> Option<BoxHandle> {
426        let ray = Ray::new(
427            Vector::new(ray_origin.x, ray_origin.y, ray_origin.z),
428            Vector::new(ray_dir.x, ray_dir.y, ray_dir.z),
429        );
430
431        let all_handles = [
432            BoxHandle::Center,
433            BoxHandle::Face(0),
434            BoxHandle::Face(1),
435            BoxHandle::Face(2),
436            BoxHandle::Face(3),
437            BoxHandle::Face(4),
438            BoxHandle::Face(5),
439            BoxHandle::RotArc(0),
440            BoxHandle::RotArc(1),
441            BoxHandle::RotArc(2),
442        ];
443
444        let mut best: Option<(f32, BoxHandle)> = None;
445
446        for handle in all_handles {
447            let pos = self.handle_pos(handle);
448            let target_px = if matches!(handle, BoxHandle::RotArc(_)) {
449                7.0
450            } else {
451                9.0
452            };
453            let r = handle_world_radius(pos, &ctx.camera, ctx.viewport_size.y, target_px);
454            let ball = parry3d::shape::Ball::new(r);
455            let pose = Pose::from_parts([pos.x, pos.y, pos.z].into(), glam::Quat::IDENTITY);
456            if let Some(t) = ball.cast_ray(&pose, &ray, f32::MAX, true) {
457                if best.is_none() || t < best.unwrap().0 {
458                    best = Some((t, handle));
459                }
460            }
461        }
462
463        best.map(|(_, h)| h)
464    }
465}