Skip to main content

brepkit_math/
chord.rs

1//! Chord deviation computation for circular arc discretization.
2//!
3//! Given a circle of radius `r`, the chord deviation (sag) at the midpoint
4//! of an arc subtending angle `θ` is `r*(1 - cos(θ/2))`. This module
5//! provides the inverse: given a maximum deflection, compute the number
6//! of segments needed to discretize an arc.
7//!
8//! Two independent tolerances drive density: the linear sag `deflection`
9//! and an angular cap `angular_tol` (max tangent turn per segment). The
10//! linear criterion alone lets a small-radius arc take a near-`π` turn per
11//! segment, under-tessellating sharp rounded features; the angular cap
12//! floors the segment count independently of radius.
13
14/// Default angular deflection cap (radians) when a caller has no preference.
15///
16/// ~20° — chosen so small fillet/chamfer arcs reach reference density.
17pub const DEFAULT_ANGULAR_TOL: f64 = 0.35;
18
19/// Compute the number of segments needed to discretize a circular arc
20/// so that the chord-height deviation stays below `deflection`.
21///
22/// Equivalent to [`segments_for_chord_deviation_with_angle`] with the
23/// default angular cap, no minimum-edge-length clamp, and the curvature
24/// floor applied (the conservative default for callers that may pass a
25/// variable-curvature curve's nominal radius).
26///
27/// Returns at least 4 segments. For degenerate inputs (non-positive
28/// radius, deflection, or arc range) returns 8 as a safe default.
29#[must_use]
30pub fn segments_for_chord_deviation(radius: f64, arc_range: f64, deflection: f64) -> usize {
31    segments_for_chord_deviation_with_angle(
32        radius,
33        arc_range,
34        deflection,
35        DEFAULT_ANGULAR_TOL,
36        0.0,
37        true,
38    )
39}
40
41/// Compute the number of segments to discretize a circular arc so that both
42/// the chord-height deviation stays below `deflection` and the per-segment
43/// tangent turn stays below `angular_tol`.
44///
45/// The per-segment angle is `θ_step = max(min(θ_lin, α), θ_minsize)` where:
46/// - `θ_lin = 2*acos(clamp(1 - deflection/radius, 0, 1))` is the linear sag angle,
47/// - `α = angular_tol` is the angular cap,
48/// - `θ_minsize = min(min_len/radius, π/2)` (when `min_len > 0`) floors the
49///   step so a vanishingly small radius cannot demand sub-`min_len` edges.
50///
51/// The segment count is `ceil(arc_range / θ_step)`, kept at least 4.
52/// For degenerate inputs (non-positive radius, deflection, or arc range)
53/// returns 8 as a safe default. A non-positive `angular_tol` is treated as
54/// "no angular cap" (linear-only behaviour).
55///
56/// `apply_curvature_floor` gates the legacy curvature floor `n_min`. For a
57/// circle the chord formula `n` is already exact (constant curvature), so the
58/// floor is pure over-tessellation and callers handling circular cylinder/cone
59/// faces or `Circle` edges pass `false`. Variable-curvature curves (ellipse,
60/// NURBS) and doubly-curved surfaces (sphere, torus) pass `true`: the nominal
61/// `radius` understates the tightest curvature there, and the floor supplies
62/// the extra density.
63#[must_use]
64pub fn segments_for_chord_deviation_with_angle(
65    radius: f64,
66    arc_range: f64,
67    deflection: f64,
68    angular_tol: f64,
69    min_len: f64,
70    apply_curvature_floor: bool,
71) -> usize {
72    use std::f64::consts::FRAC_PI_2;
73
74    if radius <= 0.0 || deflection <= 0.0 || arc_range <= 0.0 {
75        return 8;
76    }
77
78    let theta_lin = 2.0 * (1.0 - deflection / radius).clamp(0.0, 1.0).acos();
79
80    let mut theta_step = if angular_tol > 0.0 {
81        theta_lin.min(angular_tol)
82    } else {
83        theta_lin
84    };
85
86    if min_len > 0.0 {
87        let theta_minsize = (min_len / radius).min(FRAC_PI_2);
88        theta_step = theta_step.max(theta_minsize);
89    }
90
91    if theta_step <= 0.0 {
92        return 8;
93    }
94
95    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
96    let n = (arc_range / theta_step).ceil() as usize;
97
98    if !apply_curvature_floor {
99        return n.max(4);
100    }
101
102    // Legacy curvature floor for variable-curvature curves and doubly-curved
103    // surfaces, where the nominal radius understates the tightest curvature.
104    // Retained as a lower bound (never reduces the count) so their existing
105    // watertight tessellations stay bit-identical.
106    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
107    let n_min = (arc_range * (radius / deflection).sqrt()).ceil() as usize;
108
109    n.max(n_min).max(4)
110}
111
112#[cfg(test)]
113mod tests {
114    #![allow(clippy::unwrap_used, clippy::expect_used)]
115
116    use super::*;
117    use std::f64::consts::TAU;
118
119    #[test]
120    fn degenerate_inputs() {
121        assert_eq!(segments_for_chord_deviation(0.0, TAU, 0.01), 8);
122        assert_eq!(segments_for_chord_deviation(1.0, 0.0, 0.01), 8);
123        assert_eq!(segments_for_chord_deviation(1.0, TAU, 0.0), 8);
124        assert_eq!(segments_for_chord_deviation(-1.0, TAU, 0.01), 8);
125    }
126
127    #[test]
128    fn minimum_four_segments() {
129        // Even for very coarse deflection, at least 4 segments.
130        let n = segments_for_chord_deviation(1.0, TAU, 10.0);
131        assert!(n >= 4, "got {n}");
132    }
133
134    #[test]
135    fn finer_deflection_more_segments() {
136        let coarse = segments_for_chord_deviation(1.0, TAU, 0.1);
137        let fine = segments_for_chord_deviation(1.0, TAU, 0.01);
138        assert!(fine > coarse, "fine={fine} should be > coarse={coarse}");
139    }
140
141    #[test]
142    fn larger_radius_more_segments_linear_only() {
143        // With the angular cap disabled the linear sag criterion dominates,
144        // so a larger radius (smaller per-segment turn) yields more segments.
145        let small = segments_for_chord_deviation_with_angle(1.0, TAU, 0.05, 0.0, 0.0, true);
146        let large = segments_for_chord_deviation_with_angle(10.0, TAU, 0.05, 0.0, 0.0, true);
147        assert!(large > small, "large={large} should be > small={small}");
148    }
149
150    #[test]
151    fn angular_cap_floors_small_radius() {
152        let n = segments_for_chord_deviation_with_angle(0.5, TAU, 0.1, 0.35, 0.0, true);
153        let floor = (TAU / 0.35).ceil() as usize;
154        assert!(n >= floor, "got {n}, expected >= {floor}");
155    }
156
157    #[test]
158    fn large_angular_cap_matches_linear_only() {
159        // alpha large => angular cap inactive => identical to linear-only.
160        for (r, d) in [(1.0, 0.05), (10.0, 0.01), (0.4, 0.02)] {
161            let capped = segments_for_chord_deviation_with_angle(r, TAU, d, 10.0, 0.0, true);
162            let linear = segments_for_chord_deviation_with_angle(r, TAU, d, 0.0, 0.0, true);
163            assert_eq!(capped, linear, "r={r} d={d}");
164        }
165    }
166
167    #[test]
168    fn curvature_floor_skipped_drops_large_radius_count() {
169        // For a circle (constant curvature) the floor is pure over-count.
170        // Skipping it must drop the count for any radius where the floor
171        // dominates (radius > ~0.4mm at typical deflections).
172        for (r, d) in [(3.25, 0.05), (5.0, 0.01), (1.0, 0.02)] {
173            let floored = segments_for_chord_deviation_with_angle(r, TAU, d, 0.0, 0.0, true);
174            let exact = segments_for_chord_deviation_with_angle(r, TAU, d, 0.0, 0.0, false);
175            assert!(
176                exact < floored,
177                "r={r} d={d}: exact={exact} should be < floored={floored}"
178            );
179        }
180    }
181
182    #[test]
183    fn angular_degenerate_inputs_return_default() {
184        assert_eq!(
185            segments_for_chord_deviation_with_angle(0.0, TAU, 0.1, 0.35, 0.0, true),
186            8
187        );
188        assert_eq!(
189            segments_for_chord_deviation_with_angle(1.0, 0.0, 0.1, 0.35, 0.0, true),
190            8
191        );
192        assert_eq!(
193            segments_for_chord_deviation_with_angle(1.0, TAU, 0.0, 0.35, 0.0, true),
194            8
195        );
196    }
197
198    #[test]
199    fn min_len_caps_blow_up_on_tiny_radius() {
200        // Tiny radius with a tight angular cap would demand huge counts;
201        // min_len floors the per-segment angle so the count stays bounded.
202        let unbounded = segments_for_chord_deviation_with_angle(1e-4, TAU, 1e-6, 0.05, 0.0, true);
203        let bounded = segments_for_chord_deviation_with_angle(1e-4, TAU, 1e-6, 0.05, 0.1, true);
204        assert!(
205            bounded < unbounded,
206            "bounded={bounded} should be < unbounded={unbounded}"
207        );
208    }
209
210    #[test]
211    fn min_size_angle_capped_at_half_pi() {
212        // min_len much larger than radius must not exceed pi/2 per segment;
213        // a full circle then needs at least 4 segments.
214        let n = segments_for_chord_deviation_with_angle(0.1, TAU, 1e-6, 0.01, 100.0, true);
215        let floor = (TAU / std::f64::consts::FRAC_PI_2).ceil() as usize;
216        assert!(n >= floor, "got {n}, expected >= {floor}");
217    }
218}