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}