1use ogeom_core::{OgeomResult, Tolerances, ogeom_bail};
11use ogeom_geom::{Curve, Curve3d as _};
12
13#[derive(Debug, Clone, PartialEq)]
16pub enum RadiusLaw {
17 Linear {
19 start: f64,
21 end: f64,
23 },
24 Points(Vec<(f64, f64)>),
29}
30
31#[derive(Debug, Clone)]
33pub(crate) struct Profile {
34 at: Vec<f64>,
36 radius: Vec<f64>,
38 slope: Vec<f64>,
40 closed: bool,
43}
44
45impl Profile {
46 pub(crate) fn new(law: &RadiusLaw, closed: bool, tol: Tolerances) -> OgeomResult<Self> {
49 let usable = |r: f64| r.is_finite() && r > tol.confusion();
50 let mut points: Vec<(f64, f64)> = match law {
51 RadiusLaw::Linear { start, end } => {
52 if closed && (start - end).abs() > tol.confusion() {
53 ogeom_bail!(
54 Construction,
55 "a linear law from {start} to {end} jumps where a closed \
56 chain meets its start; give its radii as points"
57 );
58 }
59 vec![(0.0, *start), (1.0, *end)]
60 }
61 RadiusLaw::Points(points) => points.clone(),
62 };
63 if points.is_empty() {
64 ogeom_bail!(Construction, "a radius law of no points states no radius");
65 }
66 for &(s, r) in &points {
67 if !s.is_finite() || !(0.0..=1.0).contains(&s) {
68 ogeom_bail!(
69 Construction,
70 "a radius law's fraction {s} lies off its chain (0 to 1)"
71 );
72 }
73 if !usable(r) {
74 ogeom_bail!(Construction, "a fillet of radius {r} rounds nothing");
75 }
76 }
77 if points.windows(2).any(|w| w[1].0 <= w[0].0) {
78 ogeom_bail!(
79 Construction,
80 "a radius law's fractions must increase along the chain"
81 );
82 }
83 if closed && points.len() > 1 {
86 let (first, last) = (points[0], points[points.len() - 1]);
87 if first.0 <= tol.parametric() && last.0 >= 1.0 - tol.parametric() {
88 if (first.1 - last.1).abs() > tol.confusion() {
89 ogeom_bail!(
90 Construction,
91 "a closed chain's law gives {} at its start and {} at \
92 its end, which are one point",
93 first.1,
94 last.1
95 );
96 }
97 points.pop();
98 }
99 }
100 let at: Vec<f64> = points.iter().map(|p| p.0).collect();
101 let radius: Vec<f64> = points.iter().map(|p| p.1).collect();
102 let n = at.len();
103 let mut slope = vec![0.0; n];
104 if n > 1 {
105 let span = |i: usize| -> f64 {
108 if i + 1 < n {
109 at[i + 1] - at[i]
110 } else {
111 at[0] + 1.0 - at[i]
112 }
113 };
114 let secant = |i: usize| -> f64 { (radius[(i + 1) % n] - radius[i]) / span(i) };
115 for (i, m) in slope.iter_mut().enumerate() {
116 let inner = closed || (i > 0 && i + 1 < n);
117 if inner {
118 let before = if i == 0 { n - 1 } else { i - 1 };
119 let (h0, h1) = (span(before), span(i));
120 let (d0, d1) = (secant(before), secant(i));
121 *m = if d0 * d1 > 0.0 {
122 let (w0, w1) = (2.0f64.mul_add(h1, h0), 2.0f64.mul_add(h0, h1));
123 (w0 + w1) / (w0 / d0 + w1 / d1)
124 } else {
125 0.0
126 };
127 } else {
128 let (end, d) = if i == 0 {
132 (at[0] <= tol.parametric(), secant(0))
133 } else {
134 (at[i] >= 1.0 - tol.parametric(), secant(i - 1))
135 };
136 *m = if end { d } else { 0.0 };
137 }
138 }
139 }
140 Ok(Self {
141 at,
142 radius,
143 slope,
144 closed,
145 })
146 }
147
148 pub(crate) fn at(&self, s: f64) -> (f64, f64) {
150 let n = self.at.len();
151 if n == 1 {
152 return (self.radius[0], 0.0);
153 }
154 let hermite = |i: usize, j: usize, x0: f64, h: f64, s: f64| -> (f64, f64) {
155 let t = (s - x0) / h;
156 let (y0, y1) = (self.radius[i], self.radius[j]);
157 let (m0, m1) = (self.slope[i] * h, self.slope[j] * h);
158 let t2 = t * t;
159 let t3 = t2 * t;
160 let value = (2.0 * t3 - 3.0 * t2 + 1.0) * y0
161 + (t3 - 2.0 * t2 + t) * m0
162 + (3.0 * t2 - 2.0 * t3) * y1
163 + (t3 - t2) * m1;
164 let rate = (6.0 * t2 - 6.0 * t) * (y0 - y1)
165 + (3.0 * t2 - 4.0 * t + 1.0) * m0
166 + (3.0 * t2 - 2.0 * t) * m1;
167 (value, rate / h)
168 };
169 let s = if self.closed { s.rem_euclid(1.0) } else { s };
170 if self.closed {
171 let (first, last) = (self.at[0], self.at[n - 1]);
172 if s < first || s >= last {
173 let x0 = last;
174 let h = first + 1.0 - last;
175 let s = if s < first { s + 1.0 } else { s };
176 return hermite(n - 1, 0, x0, h, s);
177 }
178 } else {
179 if s < self.at[0] {
180 return (self.radius[0], 0.0);
181 }
182 if s > self.at[n - 1] {
183 return (self.radius[n - 1], 0.0);
184 }
185 }
186 let i = self
187 .at
188 .partition_point(|&x| x <= s)
189 .saturating_sub(1)
190 .min(n - 2);
191 hermite(i, i + 1, self.at[i], self.at[i + 1] - self.at[i], s)
192 }
193
194 pub(crate) fn window(&self, from: f64, to: f64) -> Self {
201 let span = to - from;
202 let (lo, hi) = (from.min(to), from.max(to));
203 let mut nodes: Vec<(f64, f64, f64)> = Vec::new();
204 let mut push = |s: f64| {
205 let (r, m) = self.at(s);
206 nodes.push(((s - from) / span, r, m * span));
207 };
208 push(from);
209 push(to);
210 for &s in &self.at {
211 if s > lo && s < hi {
212 push(s);
213 }
214 }
215 nodes.sort_by(|a, b| a.0.total_cmp(&b.0));
216 Self {
217 at: nodes.iter().map(|n| n.0).collect(),
218 radius: nodes.iter().map(|n| n.1).collect(),
219 slope: nodes.iter().map(|n| n.2).collect(),
220 closed: false,
221 }
222 }
223
224 pub(crate) fn stretched(&self, before: f64, after: f64) -> Self {
228 let whole = 1.0 + before + after;
229 Self {
230 at: self.at.iter().map(|&s| (s + before) / whole).collect(),
231 radius: self.radius.clone(),
232 slope: self.slope.iter().map(|&m| m * whole).collect(),
233 closed: false,
234 }
235 }
236
237 pub(crate) fn largest(&self) -> f64 {
240 self.radius
241 .iter()
242 .copied()
243 .fold(f64::NEG_INFINITY, f64::max)
244 }
245
246 pub(crate) fn least(&self) -> f64 {
248 self.radius.iter().copied().fold(f64::INFINITY, f64::min)
249 }
250
251 pub(crate) const fn closed(&self) -> bool {
253 self.closed
254 }
255
256 pub(crate) fn level(&self, tol: Tolerances) -> bool {
258 self.largest() - self.least() <= tol.confusion()
259 }
260
261 pub(crate) fn pieces(&self, tol: Tolerances) -> Vec<(f64, f64, [f64; 4])> {
266 let mut pieces: Vec<(f64, f64, [f64; 4])> = Vec::new();
267 let n = self.at.len();
268 let level = |x0: f64, x1: f64, r: f64| (x0, x1, [r, r, r, r]);
269 if self.at[0] > tol.parametric() {
270 pieces.push(level(0.0, self.at[0], self.radius[0]));
271 }
272 for i in 0..n.saturating_sub(1) {
273 let (x0, x1) = (self.at[i], self.at[i + 1]);
274 let h = x1 - x0;
275 let (y0, y1) = (self.radius[i], self.radius[i + 1]);
276 pieces.push((
277 x0,
278 x1,
279 [
280 y0,
281 self.slope[i].mul_add(h / 3.0, y0),
282 (-self.slope[i + 1]).mul_add(h / 3.0, y1),
283 y1,
284 ],
285 ));
286 }
287 if self.at[n - 1] < 1.0 - tol.parametric() {
288 pieces.push(level(self.at[n - 1], 1.0, self.radius[n - 1]));
289 }
290 if pieces.is_empty() {
291 pieces.push(level(0.0, 1.0, self.radius[0]));
292 }
293 let last = pieces.len() - 1;
295 pieces[0].0 = 0.0;
296 pieces[last].1 = 1.0;
297 pieces
298 }
299
300 pub(crate) fn spline(&self, tol: Tolerances) -> (Vec<f64>, Vec<f64>) {
306 let pieces = self.pieces(tol);
307 let last = pieces.len() - 1;
308 let mut knots = vec![0.0; 4];
309 let mut values = vec![pieces[0].2[0]];
310 for (k, &(x0, x1, bezier)) in pieces.iter().enumerate() {
311 values.push(bezier[1]);
312 values.push(bezier[2]);
313 if k == last {
314 values.push(bezier[3]);
315 knots.extend([1.0; 4]);
316 continue;
317 }
318 let (y0, y1, next) = (x0, x1, pieces[k + 1]);
322 let before = (bezier[3] - bezier[2]) / (y1 - y0);
323 let after = (next.2[1] - next.2[0]) / (next.1 - next.0);
324 if (before - after).abs() > tol.parametric() {
325 values.push(bezier[3]);
326 knots.extend([x1; 3]);
327 } else {
328 knots.extend([x1, x1]);
329 }
330 }
331 (knots, values)
332 }
333}
334
335const RULER_PIECES: u32 = 256;
337
338const GAUSS: [(f64, f64); 5] = [
342 (0.0, 0.568_888_888_888_888_9),
343 (-0.538_469_310_105_683_1, 0.478_628_670_499_366_5),
344 (0.538_469_310_105_683_1, 0.478_628_670_499_366_5),
345 (-0.906_179_845_938_664, 0.236_926_885_056_189_1),
346 (0.906_179_845_938_664, 0.236_926_885_056_189_1),
347];
348
349#[derive(Debug, Clone)]
352pub(crate) struct Ruler {
353 curve: Curve,
354 range: (f64, f64),
355 cumulative: Vec<f64>,
357 closed: bool,
358}
359
360impl Ruler {
361 pub(crate) fn new(
362 curve: Curve,
363 range: (f64, f64),
364 closed: bool,
365 tol: Tolerances,
366 ) -> OgeomResult<Self> {
367 let mut cumulative: Vec<f64> = Vec::with_capacity(RULER_PIECES as usize + 1);
368 cumulative.push(0.0);
369 let step = (range.1 - range.0) / f64::from(RULER_PIECES);
370 let mut whole = 0.0;
371 for k in 0..RULER_PIECES {
372 let a = f64::from(k).mul_add(step, range.0);
373 whole += speed_integral(&curve, a, a + step, tol)?;
374 cumulative.push(whole);
375 }
376 if whole <= tol.confusion() {
377 ogeom_bail!(Construction, "a chain of no length carries no radius law");
378 }
379 Ok(Self {
380 curve,
381 range,
382 cumulative,
383 closed,
384 })
385 }
386
387 pub(crate) fn length(&self) -> f64 {
389 self.cumulative[self.cumulative.len() - 1]
390 }
391
392 pub(crate) fn fraction(&self, t: f64, tol: Tolerances) -> OgeomResult<(f64, f64)> {
395 let (lo, hi) = self.range;
396 let span = hi - lo;
397 let (t, turns) = if self.closed {
398 let turns = ((t - lo) / span).floor();
399 (turns.mul_add(-span, t), turns)
400 } else {
401 (t.clamp(lo, hi), 0.0)
402 };
403 let step = span / f64::from(RULER_PIECES);
404 #[allow(
405 clippy::cast_possible_truncation,
406 clippy::cast_sign_loss,
407 reason = "a clamped piece index"
408 )]
409 let k = (((t - lo) / step).floor().max(0.0) as u32).min(RULER_PIECES - 1);
410 let a = f64::from(k).mul_add(step, lo);
411 let length = self.cumulative[k as usize] + speed_integral(&self.curve, a, t, tol)?;
412 let speed = self.curve.d1_at(t, tol)?.magnitude();
413 let whole = self.length();
414 Ok((length / whole + turns, speed / whole))
415 }
416}
417
418fn speed_integral(curve: &Curve, a: f64, b: f64, tol: Tolerances) -> OgeomResult<f64> {
420 let (mid, half) = (f64::midpoint(a, b), (b - a) / 2.0);
421 let mut sum = 0.0;
422 for (x, w) in GAUSS {
423 sum += w * curve.d1_at(half.mul_add(x, mid), tol)?.magnitude();
424 }
425 Ok(sum * half)
426}
427
428#[derive(Debug, Clone)]
432pub(crate) struct RadiusAlong {
433 profile: Profile,
434 ruler: Ruler,
435 origin: f64,
437 reversed: bool,
439}
440
441impl RadiusAlong {
442 pub(crate) const fn new(profile: Profile, ruler: Ruler, origin: f64, reversed: bool) -> Self {
443 Self {
444 profile,
445 ruler,
446 origin,
447 reversed,
448 }
449 }
450
451 pub(crate) fn at(&self, t: f64, tol: Tolerances) -> OgeomResult<(f64, f64)> {
453 let (s, ds) = self.ruler.fraction(t, tol)?;
454 let s = s - self.origin;
455 let (s, ds) = if self.reversed { (-s, -ds) } else { (s, ds) };
456 let (r, dr) = self.profile.at(s);
457 Ok((r, dr * ds))
458 }
459
460 pub(crate) fn least(&self) -> f64 {
462 self.profile.least()
463 }
464
465 pub(crate) fn largest(&self) -> f64 {
466 self.profile.largest()
467 }
468}
469
470#[cfg(test)]
471mod tests {
472 #![allow(clippy::unwrap_used, reason = "test code")]
473 use super::{Profile, RadiusLaw};
474 use ogeom_core::Tolerances;
475
476 #[test]
480 fn a_window_is_the_law_over_its_stretch() {
481 let tol = Tolerances::millimetres();
482 let law = RadiusLaw::Points(vec![(0.1, 0.5), (0.4, 1.5), (0.7, 0.8)]);
483 let closed = Profile::new(&law, true, tol).unwrap();
484 for (from, to) in [(0.05, 0.55), (0.9, 0.3), (0.65, 1.0), (0.2, 0.25)] {
485 let window = closed.window(from, to);
486 for i in 0..=100 {
487 let u = f64::from(i) / 100.0;
488 let (r, m) = window.at(u);
489 let (want, rate) = closed.at(from + (to - from) * u);
490 assert!(
491 (r - want).abs() < 1e-12,
492 "{from}..{to} at {u}: {r} against {want}"
493 );
494 assert!(
495 (m - rate * (to - from)).abs() < 1e-9,
496 "{from}..{to} at {u}: slope {m} against {}",
497 rate * (to - from)
498 );
499 }
500 }
501 }
502}