Skip to main content

viewport_lib/interaction/
snap.rs

1//! Pure-math snap helpers for gizmo transforms.
2//!
3//! All functions are stateless — the application wraps gizmo drag results with
4//! these when snapping is enabled.
5
6/// Configuration for transform snapping.
7#[derive(Clone, Debug, Default)]
8pub struct SnapConfig {
9    /// Translation snap increment in world units (e.g. 0.25, 0.5, 1.0).
10    pub translation: Option<f32>,
11    /// Rotation snap increment in radians (e.g. `PI / 12` for 15°).
12    pub rotation: Option<f32>,
13    /// Scale snap increment as a fraction (e.g. 0.1 for 10% steps).
14    pub scale: Option<f32>,
15}
16
17/// Snap a scalar value to the nearest increment.
18///
19/// Returns `value` unchanged if `increment <= 0`.
20pub fn snap_value(value: f32, increment: f32) -> f32 {
21    if increment <= 0.0 {
22        return value;
23    }
24    (value / increment).round() * increment
25}
26
27/// Snap each component of a `Vec3` to the nearest increment.
28pub fn snap_vec3(v: glam::Vec3, increment: f32) -> glam::Vec3 {
29    glam::Vec3::new(
30        snap_value(v.x, increment),
31        snap_value(v.y, increment),
32        snap_value(v.z, increment),
33    )
34}
35
36/// Snap an angle (in radians) to the nearest increment.
37pub fn snap_angle(angle_rad: f32, increment_rad: f32) -> f32 {
38    snap_value(angle_rad, increment_rad)
39}
40
41/// Snap a scale factor to the nearest increment.
42///
43/// Operates the same as [`snap_value`]; named separately for discoverability.
44pub fn snap_scale(scale: f32, increment: f32) -> f32 {
45    snap_value(scale, increment)
46}
47
48/// Describes a visual overlay for an active constraint (data only, not rendering).
49///
50/// The application layer maps these to actual draw calls.
51#[derive(Debug, Clone)]
52#[non_exhaustive]
53pub enum ConstraintOverlay {
54    /// An infinite line through `origin` along `direction`.
55    AxisLine {
56        /// World-space point on the line.
57        origin: glam::Vec3,
58        /// Unit direction vector of the line.
59        direction: glam::Vec3,
60        /// RGBA display color.
61        color: [f32; 4],
62    },
63    /// A plane through `origin` spanned by `axis_a` and `axis_b`.
64    Plane {
65        /// World-space point on the plane.
66        origin: glam::Vec3,
67        /// First tangent axis of the plane.
68        axis_a: glam::Vec3,
69        /// Second tangent axis of the plane.
70        axis_b: glam::Vec3,
71        /// RGBA display color.
72        color: [f32; 4],
73    },
74}
75
76#[cfg(test)]
77mod tests {
78    use super::*;
79
80    #[test]
81    fn test_snap_value_rounds_to_nearest() {
82        assert!((snap_value(0.7, 0.5) - 0.5).abs() < 1e-6);
83        assert!((snap_value(0.8, 0.5) - 1.0).abs() < 1e-6);
84        assert!((snap_value(-0.3, 0.5) - -0.5).abs() < 1e-6);
85        assert!((snap_value(0.25, 0.5) - 0.5).abs() < 1e-6); // 0.25/0.5=0.5, rounds to 1, *0.5=0.5
86    }
87
88    #[test]
89    fn test_snap_vec3_per_component() {
90        let v = glam::Vec3::new(0.7, 1.3, -0.8);
91        let snapped = snap_vec3(v, 0.5);
92        assert!((snapped.x - 0.5).abs() < 1e-6);
93        assert!((snapped.y - 1.5).abs() < 1e-6);
94        assert!((snapped.z - -1.0).abs() < 1e-6);
95    }
96
97    #[test]
98    fn test_snap_angle_15_degrees() {
99        let deg15 = std::f32::consts::PI / 12.0;
100        let deg20 = 20.0_f32.to_radians();
101        let deg40 = 40.0_f32.to_radians();
102        let snapped_20 = snap_angle(deg20, deg15);
103        let snapped_40 = snap_angle(deg40, deg15);
104        // 20° -> 15° (1 × 15)
105        assert!(
106            (snapped_20 - deg15).abs() < 1e-5,
107            "20° snapped to {}, expected {}",
108            snapped_20.to_degrees(),
109            15.0
110        );
111        // 40° -> 45° (3 × 15)
112        let deg45 = 45.0_f32.to_radians();
113        assert!(
114            (snapped_40 - deg45).abs() < 1e-5,
115            "40° snapped to {}, expected {}",
116            snapped_40.to_degrees(),
117            45.0
118        );
119    }
120
121    #[test]
122    fn test_snap_scale_around_one() {
123        let snapped = snap_scale(1.37, 0.1);
124        assert!(
125            (snapped - 1.4).abs() < 1e-5,
126            "1.37 @ 0.1 -> {snapped}, expected 1.4"
127        );
128    }
129
130    #[test]
131    fn test_snap_config_none_passthrough() {
132        let config = SnapConfig::default();
133        // When increments are None, a typical usage pattern:
134        let value = 1.234;
135        let result = config
136            .translation
137            .map(|inc| snap_value(value, inc))
138            .unwrap_or(value);
139        assert!((result - value).abs() < 1e-6, "None should pass through");
140    }
141}