1#[derive(Clone, Debug)]
30pub enum LawSegment {
31 Constant { length: f64, radius: f64 },
33 Linear {
36 length: f64,
37 start_radius: f64,
38 end_radius: f64,
39 },
40 Interpolated { points: Vec<(f64, f64)> },
47}
48
49#[derive(Clone, Debug)]
52struct Piece {
53 s0: f64,
54 s1: f64,
55 coeffs: [f64; 6],
57}
58
59impl Piece {
60 fn width(&self) -> f64 {
61 self.s1 - self.s0
62 }
63
64 fn evaluate(&self, s: f64) -> f64 {
65 let u = ((s - self.s0) / self.width()).clamp(0.0, 1.0);
66 poly_eval(&self.coeffs, u)
67 }
68}
69
70#[derive(Clone, Debug)]
76pub struct RadiusLaw {
77 pieces: Vec<Piece>,
78 total: f64,
79}
80
81impl RadiusLaw {
82 pub fn constant(length: f64, radius: f64) -> Result<Self, String> {
84 Self::from_segments(&[LawSegment::Constant { length, radius }])
85 }
86
87 pub fn from_vertex_radii(edge_lengths: &[f64], vertex_radii: &[f64]) -> Result<Self, String> {
95 if edge_lengths.is_empty() {
96 return Err("radius_law: at least one chain edge length is required".into());
97 }
98 if vertex_radii.len() != edge_lengths.len() + 1 {
99 return Err(format!(
100 "radius_law: need exactly one radius per chain vertex (edges + 1): \
101 {} edges need {} radii, got {}",
102 edge_lengths.len(),
103 edge_lengths.len() + 1,
104 vertex_radii.len()
105 ));
106 }
107 for length in edge_lengths {
108 if !(length.is_finite() && *length > 0.0) {
109 return Err("radius_law: segment length must be positive and finite".into());
110 }
111 }
112 let mut points = Vec::with_capacity(vertex_radii.len());
113 let mut abscissa = 0.0;
114 points.push((0.0, vertex_radii[0]));
115 for (length, radius) in edge_lengths.iter().zip(vertex_radii[1..].iter()) {
116 abscissa += length;
117 points.push((abscissa, *radius));
118 }
119 Self::from_segments(&[LawSegment::Interpolated { points }])
120 }
121
122 pub fn from_segments(segments: &[LawSegment]) -> Result<Self, String> {
125 if segments.is_empty() {
126 return Err("radius_law: at least one segment is required".into());
127 }
128 let mut evals: Vec<SegmentEval> = Vec::with_capacity(segments.len());
131 let mut cursor = 0.0_f64;
132 for segment in segments {
133 let eval = SegmentEval::build(segment, cursor)?;
134 cursor = eval.s_end();
135 evals.push(eval);
136 }
137 let total = cursor;
138
139 let mut needs_transition = vec![false; evals.len().saturating_sub(1)];
143 for i in 0..needs_transition.len() {
144 let s_j = evals[i].s_end();
145 let (vl, dl) = evals[i].value_slope(s_j);
146 let (vr, dr) = evals[i + 1].value_slope(s_j);
147 needs_transition[i] = vl != vr || dl != dr;
148 }
149
150 let mut pieces: Vec<Piece> = Vec::new();
157 for i in 0..evals.len() {
158 let left_trim = i > 0 && needs_transition[i - 1];
159 let right_trim = i < needs_transition.len() && needs_transition[i];
160 let mid = evals[i].s_start() + evals[i].length() * 0.5;
161 let keep_from = if left_trim { mid } else { evals[i].s_start() };
162 let keep_to = if right_trim { mid } else { evals[i].s_end() };
163 if keep_to > keep_from {
164 evals[i].emit_pieces(keep_from, keep_to, &mut pieces);
165 }
166 if right_trim {
167 let a_s = evals[i].s_start() + evals[i].length() * 0.5;
168 let b_s = evals[i + 1].s_start() + evals[i + 1].length() * 0.5;
169 let (a, da) = evals[i].value_slope(a_s);
170 let (b, db) = evals[i + 1].value_slope(b_s);
171 pieces.push(quintic_bridge(a_s, b_s, a, da, b, db));
172 }
173 }
174 debug_assert!(pieces
175 .windows(2)
176 .all(|pair| (pair[0].s1 - pair[1].s0).abs() == 0.0));
177 Ok(Self { pieces, total })
178 }
179
180 pub fn total_length(&self) -> f64 {
182 self.total
183 }
184
185 pub fn radius_at(&self, s: f64) -> f64 {
187 debug_assert!(s.is_finite(), "radius_law: abscissa must be finite");
188 let s = s.clamp(0.0, self.total);
189 let index = self
191 .pieces
192 .partition_point(|piece| piece.s1 < s)
193 .min(self.pieces.len() - 1);
194 self.pieces[index].evaluate(s)
195 }
196
197 pub fn radius_at_fraction(&self, fraction: f64) -> f64 {
199 self.radius_at(fraction * self.total)
200 }
201
202 pub fn max_second_derivative(&self, s_a: f64, s_b: f64) -> f64 {
211 let lo = s_a.min(s_b).clamp(0.0, self.total);
212 let hi = s_a.max(s_b).clamp(0.0, self.total);
213 let mut bound = 0.0_f64;
214 for piece in &self.pieces {
215 if piece.s1 < lo || piece.s0 > hi {
216 continue;
217 }
218 let c = &piece.coeffs;
220 let dd = [2.0 * c[2], 6.0 * c[3], 12.0 * c[4], 20.0 * c[5]];
221 let b0 = dd[0];
223 let b1 = dd[0] + dd[1] / 3.0;
224 let b2 = dd[0] + 2.0 * dd[1] / 3.0 + dd[2] / 3.0;
225 let b3 = dd[0] + dd[1] + dd[2] + dd[3];
226 let max_u = b0.abs().max(b1.abs()).max(b2.abs()).max(b3.abs());
227 let width = piece.width();
228 if width > 0.0 {
229 bound = bound.max(max_u / (width * width));
230 }
231 }
232 bound
233 }
234}
235
236enum SegmentEval {
238 Constant {
239 s0: f64,
240 length: f64,
241 radius: f64,
242 },
243 Linear {
244 s0: f64,
245 length: f64,
246 r0: f64,
247 r1: f64,
248 },
249 Interpolated {
250 s0: f64,
251 xs: Vec<f64>,
253 ys: Vec<f64>,
254 ds: Vec<f64>,
256 },
257}
258
259fn check_radius(radius: f64) -> Result<(), String> {
260 if !(radius.is_finite() && radius > 0.0) {
261 return Err("radius_law: every radius must be positive and finite".into());
262 }
263 Ok(())
264}
265
266impl SegmentEval {
267 fn build(segment: &LawSegment, s0: f64) -> Result<Self, String> {
268 match segment {
269 LawSegment::Constant { length, radius } => {
270 if !(length.is_finite() && *length > 0.0) {
271 return Err("radius_law: segment length must be positive and finite".into());
272 }
273 check_radius(*radius)?;
274 Ok(Self::Constant {
275 s0,
276 length: *length,
277 radius: *radius,
278 })
279 }
280 LawSegment::Linear {
281 length,
282 start_radius,
283 end_radius,
284 } => {
285 if !(length.is_finite() && *length > 0.0) {
286 return Err("radius_law: segment length must be positive and finite".into());
287 }
288 check_radius(*start_radius)?;
289 check_radius(*end_radius)?;
290 Ok(Self::Linear {
291 s0,
292 length: *length,
293 r0: *start_radius,
294 r1: *end_radius,
295 })
296 }
297 LawSegment::Interpolated { points } => {
298 if points.len() < 2 {
299 return Err(
300 "radius_law: an interpolated segment needs at least two points".into()
301 );
302 }
303 if points[0].0 != 0.0 {
304 return Err(
305 "radius_law: interpolation abscissas must start at exactly 0".into()
306 );
307 }
308 for pair in points.windows(2) {
309 if !(pair[1].0.is_finite() && pair[1].0 > pair[0].0) {
310 return Err(
311 "radius_law: interpolation abscissas must be strictly increasing"
312 .into(),
313 );
314 }
315 }
316 for (_, radius) in points {
317 check_radius(*radius)?;
318 }
319 let xs: Vec<f64> = points.iter().map(|(x, _)| s0 + x).collect();
320 let ys: Vec<f64> = points.iter().map(|(_, y)| *y).collect();
321 let ds = pchip_slopes(&xs, &ys);
322 Ok(Self::Interpolated { s0, xs, ys, ds })
323 }
324 }
325 }
326
327 fn s_start(&self) -> f64 {
328 match self {
329 Self::Constant { s0, .. } | Self::Linear { s0, .. } | Self::Interpolated { s0, .. } => {
330 *s0
331 }
332 }
333 }
334
335 fn length(&self) -> f64 {
336 match self {
337 Self::Constant { length, .. } | Self::Linear { length, .. } => *length,
338 Self::Interpolated { s0, xs, .. } => xs[xs.len() - 1] - s0,
339 }
340 }
341
342 fn s_end(&self) -> f64 {
343 match self {
344 Self::Constant { s0, length, .. } | Self::Linear { s0, length, .. } => s0 + length,
345 Self::Interpolated { xs, .. } => xs[xs.len() - 1],
346 }
347 }
348
349 fn value_slope(&self, s: f64) -> (f64, f64) {
354 match self {
355 Self::Constant { radius, .. } => (*radius, 0.0),
356 Self::Linear {
357 s0,
358 length,
359 r0,
360 r1,
361 } => {
362 let slope = (r1 - r0) / length;
363 (r0 + slope * (s - s0), slope)
364 }
365 Self::Interpolated { xs, ys, ds, .. } => {
366 let i = interval_index(xs, s);
367 let h = xs[i + 1] - xs[i];
368 let coeffs = hermite_cubic(ys[i], ds[i] * h, ys[i + 1], ds[i + 1] * h);
369 let u = ((s - xs[i]) / h).clamp(0.0, 1.0);
370 let deriv = poly_eval(&poly_derivative(&coeffs), u) / h;
371 (poly_eval(&coeffs, u), deriv)
372 }
373 }
374 }
375
376 fn emit_pieces(&self, from: f64, to: f64, out: &mut Vec<Piece>) {
378 match self {
379 Self::Constant { radius, .. } => out.push(Piece {
380 s0: from,
381 s1: to,
382 coeffs: [*radius, 0.0, 0.0, 0.0, 0.0, 0.0],
383 }),
384 Self::Linear { .. } => {
385 let (va, slope) = self.value_slope(from);
386 out.push(Piece {
387 s0: from,
388 s1: to,
389 coeffs: [va, slope * (to - from), 0.0, 0.0, 0.0, 0.0],
390 });
391 }
392 Self::Interpolated { xs, ys, ds, .. } => {
393 for i in 0..xs.len() - 1 {
394 let (x0, x1) = (xs[i], xs[i + 1]);
395 let (a, b) = (x0.max(from), x1.min(to));
396 if b <= a {
397 continue;
398 }
399 let h = x1 - x0;
400 let cubic = hermite_cubic(ys[i], ds[i] * h, ys[i + 1], ds[i + 1] * h);
401 let coeffs = poly_restrict(&cubic, (a - x0) / h, (b - x0) / h);
402 out.push(Piece {
403 s0: a,
404 s1: b,
405 coeffs,
406 });
407 }
408 }
409 }
410 }
411}
412
413fn quintic_bridge(a_s: f64, b_s: f64, a: f64, da: f64, b: f64, db: f64) -> Piece {
420 let w = b_s - a_s;
421 let a1 = da * w;
424 let b1 = db * w;
425 let d = b - a - a1;
426 let e = b1 - a1;
427 Piece {
430 s0: a_s,
431 s1: b_s,
432 coeffs: [
433 a,
434 a1,
435 0.0,
436 10.0 * d - 4.0 * e,
437 7.0 * e - 15.0 * d,
438 6.0 * d - 3.0 * e,
439 ],
440 }
441}
442
443fn poly_eval(coeffs: &[f64; 6], u: f64) -> f64 {
445 let mut value = coeffs[5];
446 for k in (0..5).rev() {
447 value = value * u + coeffs[k];
448 }
449 value
450}
451
452fn poly_derivative(coeffs: &[f64; 6]) -> [f64; 6] {
454 let mut out = [0.0; 6];
455 for k in 1..6 {
456 out[k - 1] = coeffs[k] * k as f64;
457 }
458 out
459}
460
461fn poly_restrict(coeffs: &[f64; 6], x0: f64, x1: f64) -> [f64; 6] {
464 let h = x1 - x0;
465 let mut result = [0.0; 6];
466 let mut power = [0.0; 6];
468 power[0] = 1.0;
469 for k in 0..6 {
470 for j in 0..6 {
471 result[j] += coeffs[k] * power[j];
472 }
473 if k < 5 {
474 let mut next = [0.0; 6];
476 for j in 0..6 {
477 next[j] += power[j] * x0;
478 if j + 1 < 6 {
479 next[j + 1] += power[j] * h;
480 }
481 }
482 power = next;
483 }
484 }
485 result
486}
487
488fn hermite_cubic(y0: f64, d0: f64, y1: f64, d1: f64) -> [f64; 6] {
491 let delta = y1 - y0;
492 [
493 y0,
494 d0,
495 3.0 * delta - 2.0 * d0 - d1,
496 -2.0 * delta + d0 + d1,
497 0.0,
498 0.0,
499 ]
500}
501
502fn interval_index(xs: &[f64], s: f64) -> usize {
504 let mut i = xs.partition_point(|x| *x <= s);
505 i = i.clamp(1, xs.len() - 1);
506 i - 1
507}
508
509fn pchip_slopes(xs: &[f64], ys: &[f64]) -> Vec<f64> {
512 let n = xs.len();
513 debug_assert!(n >= 2);
514 let m = n - 1;
515 let h: Vec<f64> = (0..m).map(|i| xs[i + 1] - xs[i]).collect();
516 let secant: Vec<f64> = (0..m).map(|i| (ys[i + 1] - ys[i]) / h[i]).collect();
517 let mut d = vec![0.0_f64; n];
518 if n == 2 {
519 d[0] = secant[0];
520 d[1] = secant[0];
521 return d;
522 }
523 for i in 1..m {
527 let (s0, s1) = (secant[i - 1], secant[i]);
528 if s0 * s1 > 0.0 {
529 let w1 = 2.0 * h[i] + h[i - 1];
530 let w2 = h[i] + 2.0 * h[i - 1];
531 d[i] = (w1 + w2) / (w1 / s0 + w2 / s1);
532 }
533 }
534 d[0] = end_slope(h[0], h[1], secant[0], secant[1]);
537 d[n - 1] = end_slope(h[m - 1], h[m - 2], secant[m - 1], secant[m - 2]);
538 d
539}
540
541fn end_slope(h0: f64, h1: f64, s0: f64, s1: f64) -> f64 {
544 let mut d = ((2.0 * h0 + h1) * s0 - h0 * s1) / (h0 + h1);
545 if d * s0 <= 0.0 {
546 d = 0.0;
547 } else if s0 * s1 < 0.0 && d.abs() > 3.0 * s0.abs() {
548 d = 3.0 * s0;
549 }
550 d
551}
552
553#[cfg(test)]
554mod tests {
555 use super::*;
556
557 fn fd(law: &RadiusLaw, s: f64, eps: f64) -> f64 {
559 (law.radius_at(s + eps) - law.radius_at(s - eps)) / (2.0 * eps)
560 }
561
562 #[test]
563 fn constant_segments_join_with_an_exact_smoothstep() {
564 let law = RadiusLaw::from_segments(&[
565 LawSegment::Constant {
566 length: 2.0,
567 radius: 2.0,
568 },
569 LawSegment::Constant {
570 length: 2.0,
571 radius: 4.0,
572 },
573 ])
574 .unwrap();
575 assert_eq!(law.total_length(), 4.0);
576 assert_eq!(law.radius_at(0.0), 2.0);
578 assert_eq!(law.radius_at(4.0), 4.0);
579 assert_eq!(law.radius_at(0.5), 2.0);
581 assert_eq!(law.radius_at(3.5), 4.0);
582 assert_eq!(law.radius_at(2.0), 3.0);
585 let mut previous = law.radius_at(1.0);
587 for i in 1..=400 {
588 let s = 1.0 + 2.0 * i as f64 / 400.0;
589 let value = law.radius_at(s);
590 assert!(
591 (2.0..=4.0).contains(&value),
592 "overshoot at s={s}: {value}"
593 );
594 assert!(value >= previous, "non-monotone at s={s}");
595 previous = value;
596 }
597 }
598
599 #[test]
600 fn composite_is_c1_across_every_joint() {
601 let law = RadiusLaw::from_segments(&[
602 LawSegment::Constant {
603 length: 2.0,
604 radius: 2.0,
605 },
606 LawSegment::Linear {
607 length: 2.0,
608 start_radius: 3.0,
609 end_radius: 4.0,
610 },
611 LawSegment::Interpolated {
612 points: vec![(0.0, 4.0), (1.0, 3.5), (2.0, 3.0)],
613 },
614 ])
615 .unwrap();
616 let eps = 1e-6;
619 for s in [1.0, 2.0, 3.0, 4.0, 5.0] {
620 let left = (law.radius_at(s) - law.radius_at(s - eps)) / eps;
621 let right = (law.radius_at(s + eps) - law.radius_at(s)) / eps;
622 assert!(
623 (left - right).abs() < 1e-4,
624 "C1 violation at s={s}: left {left} right {right}"
625 );
626 assert!(
628 (law.radius_at(s - eps) - law.radius_at(s + eps)).abs() < 1e-5,
629 "value jump at s={s}"
630 );
631 }
632 assert_eq!(law.radius_at(0.0), 2.0);
634 assert_eq!(law.radius_at(6.0), 3.0);
635 }
636
637 #[test]
638 fn vertex_radii_law_meets_every_vertex_exactly_and_stays_monotone() {
639 let law = RadiusLaw::from_vertex_radii(&[10.0, 10.0], &[2.0, 3.0, 4.0]).unwrap();
640 assert_eq!(law.total_length(), 20.0);
641 assert_eq!(law.radius_at(0.0), 2.0);
642 assert_eq!(law.radius_at(10.0), 3.0);
643 assert_eq!(law.radius_at(20.0), 4.0);
644 let mut previous = law.radius_at(0.0);
646 for i in 1..=200 {
647 let s = 20.0 * i as f64 / 200.0;
648 let value = law.radius_at(s);
649 assert!((2.0..=4.0).contains(&value), "overshoot at s={s}: {value}");
650 assert!(value + 1e-12 >= previous, "non-monotone at s={s}");
651 previous = value;
652 }
653 let eps = 1e-6;
654 let left = (law.radius_at(10.0) - law.radius_at(10.0 - eps)) / eps;
655 let right = (law.radius_at(10.0 + eps) - law.radius_at(10.0)) / eps;
656 assert!((left - right).abs() < 1e-4, "junction slope {left} vs {right}");
657 assert!((law.radius_at(5.0) - 2.5).abs() < 1e-12);
660 assert!((law.radius_at(15.0) - 3.5).abs() < 1e-12);
661 }
662
663 #[test]
664 fn slope_mismatch_at_equal_values_is_rounded_without_overshoot() {
665 let law = RadiusLaw::from_segments(&[
668 LawSegment::Linear {
669 length: 5.0,
670 start_radius: 2.0,
671 end_radius: 4.0,
672 },
673 LawSegment::Constant {
674 length: 5.0,
675 radius: 4.0,
676 },
677 ])
678 .unwrap();
679 assert_eq!(law.radius_at(0.0), 2.0);
680 assert_eq!(law.radius_at(10.0), 4.0);
681 for i in 0..=400 {
682 let s = 10.0 * i as f64 / 400.0;
683 let value = law.radius_at(s);
684 assert!(
685 (2.0 - 1e-12..=4.0 + 1e-12).contains(&value),
686 "overshoot at s={s}: {value}"
687 );
688 }
689 let eps = 1e-6;
690 for s in [2.5, 5.0, 7.5] {
691 let left = (law.radius_at(s) - law.radius_at(s - eps)) / eps;
692 let right = (law.radius_at(s + eps) - law.radius_at(s)) / eps;
693 assert!((left - right).abs() < 1e-4, "kink at s={s}");
694 }
695 }
696
697 #[test]
698 fn law_evaluation_is_deterministic() {
699 let build = || {
700 RadiusLaw::from_segments(&[
701 LawSegment::Constant {
702 length: 1.5,
703 radius: 2.0,
704 },
705 LawSegment::Interpolated {
706 points: vec![(0.0, 3.0), (0.7, 3.6), (2.0, 3.1)],
707 },
708 LawSegment::Linear {
709 length: 1.0,
710 start_radius: 3.1,
711 end_radius: 2.5,
712 },
713 ])
714 .unwrap()
715 };
716 let (first, second) = (build(), build());
717 for i in 0..=1000 {
718 let s = first.total_length() * i as f64 / 1000.0;
719 assert_eq!(
720 first.radius_at(s).to_bits(),
721 second.radius_at(s).to_bits(),
722 "nondeterministic at s={s}"
723 );
724 }
725 }
726
727 #[test]
728 fn invalid_inputs_refuse_with_named_errors() {
729 let err = RadiusLaw::from_segments(&[]).unwrap_err();
730 assert!(err.contains("radius_law"), "{err}");
731
732 let err = RadiusLaw::constant(1.0, -2.0).unwrap_err();
733 assert!(err.contains("positive"), "{err}");
734
735 let err = RadiusLaw::constant(0.0, 2.0).unwrap_err();
736 assert!(err.contains("length"), "{err}");
737
738 let err = RadiusLaw::from_segments(&[LawSegment::Interpolated {
739 points: vec![(0.0, 2.0), (1.0, 3.0), (0.5, 2.5)],
740 }])
741 .unwrap_err();
742 assert!(err.contains("strictly increasing"), "{err}");
743
744 let err = RadiusLaw::from_segments(&[LawSegment::Interpolated {
745 points: vec![(0.1, 2.0), (1.0, 3.0)],
746 }])
747 .unwrap_err();
748 assert!(err.contains("start at exactly 0"), "{err}");
749
750 let err = RadiusLaw::from_segments(&[LawSegment::Interpolated {
751 points: vec![(0.0, 2.0)],
752 }])
753 .unwrap_err();
754 assert!(err.contains("at least two points"), "{err}");
755
756 let err = RadiusLaw::from_vertex_radii(&[10.0], &[2.0, 3.0, 4.0]).unwrap_err();
757 assert!(err.contains("one radius per chain vertex"), "{err}");
758
759 let err = RadiusLaw::from_vertex_radii(&[], &[2.0]).unwrap_err();
760 assert!(err.contains("chain edge length"), "{err}");
761
762 let err = RadiusLaw::from_vertex_radii(&[10.0, -1.0], &[2.0, 3.0, 4.0]).unwrap_err();
763 assert!(err.contains("length"), "{err}");
764
765 let err = RadiusLaw::from_vertex_radii(&[10.0], &[2.0, f64::NAN]).unwrap_err();
766 assert!(err.contains("positive"), "{err}");
767 }
768
769 #[test]
770 fn second_derivative_bound_covers_the_transition() {
771 let law = RadiusLaw::from_segments(&[
772 LawSegment::Constant {
773 length: 2.0,
774 radius: 2.0,
775 },
776 LawSegment::Constant {
777 length: 2.0,
778 radius: 4.0,
779 },
780 ])
781 .unwrap();
782 assert_eq!(law.max_second_derivative(0.0, 0.9), 0.0);
784 let bound = law.max_second_derivative(1.0, 3.0);
786 assert!(bound > 0.0);
787 let eps = 1e-4;
788 for i in 0..=100 {
789 let s = 1.0 + 2.0 * i as f64 / 100.0;
790 let dd =
791 (law.radius_at(s + eps) - 2.0 * law.radius_at(s) + law.radius_at(s - eps))
792 / (eps * eps);
793 assert!(
794 dd.abs() <= bound * (1.0 + 1e-6) + 1e-6,
795 "curvature {dd} exceeds bound {bound} at s={s}"
796 );
797 }
798 }
799}