Skip to main content

brep_kernel/blending/
law.rs

1//! Composable radius laws over a chain's cumulative arc-length abscissa —
2//! the OCCT `Law_Composite` / `Law_Constant` / `Law_S` / `Law_Interpol` model
3//! (occt-filleting-system-study.md §1 Stage 4, §6 lesson 6) for our
4//! variable-radius fillet lane.
5//!
6//! A [`RadiusLaw`] is built from user segments (constant, linear, interpolated
7//! point sets) laid end to end on the abscissa.  Wherever two adjacent
8//! segments meet with a value or slope mismatch, the builder inserts a smooth
9//! S-transition: a quintic Hermite bridge that matches the neighbouring
10//! segments' value and first derivative at the window boundaries and carries
11//! ZERO second derivative there.  Constant and linear segments also have zero
12//! second derivative, so the composite is C2 at every constructed joint (the
13//! C1 contract with headroom); interpolated segments are C1 monotone
14//! (Fritsch–Carlson PCHIP), so joints against them are C1.
15//!
16//! Transition-window sizing is derived from the adjoining segment GEOMETRY,
17//! not a tolerance: each junction claims exactly HALF of each adjoining
18//! segment.  Half is the largest window that can never collide with the
19//! neighbouring junction's window — two windows meeting inside one segment
20//! meet exactly at its midpoint, where both evaluate the underlying segment
21//! itself, so coverage stays contiguous and C1 by construction.
22//!
23//! Endpoint radii are met EXACTLY: no window ever reaches past a segment's
24//! midpoint, so the law's first and last values are the untouched user values.
25//! Evaluation is a binary search plus one Horner evaluation of a degree ≤ 5
26//! polynomial — cheap enough to call per march station.
27
28/// One user segment of a composite radius law, spanning `length` of abscissa.
29#[derive(Clone, Debug)]
30pub enum LawSegment {
31    /// Constant radius over `length` (OCCT `Law_Constant`).
32    Constant { length: f64, radius: f64 },
33    /// Linear ramp from `start_radius` to `end_radius` over `length`
34    /// (OCCT `Law_Linear`).
35    Linear {
36        length: f64,
37        start_radius: f64,
38        end_radius: f64,
39    },
40    /// Monotone C1 interpolation through `(abscissa_offset, radius)` points
41    /// (OCCT `Law_Interpol`).  Offsets are measured from the segment start;
42    /// the first must be exactly `0.0`, offsets strictly increase, and the
43    /// last offset is the segment length.  Interpolation is shape-preserving
44    /// (Fritsch–Carlson PCHIP): the law never overshoots the point radii, so
45    /// positive inputs stay positive.
46    Interpolated { points: Vec<(f64, f64)> },
47}
48
49/// One polynomial piece of the composite: `radius(s)` for `s ∈ [s0, s1]`,
50/// evaluated as a degree ≤ 5 polynomial in the normalized `u = (s−s0)/(s1−s0)`.
51#[derive(Clone, Debug)]
52struct Piece {
53    s0: f64,
54    s1: f64,
55    /// Power-basis coefficients `c0 + c1·u + … + c5·u⁵`.
56    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/// A composable radius law over cumulative abscissa `[0, total_length]`.
71///
72/// Construction validates every input (refuse-or-exact: non-positive radii,
73/// non-positive lengths, and non-monotone interpolation abscissas are named
74/// errors); a constructed law is deterministic and cheap to evaluate.
75#[derive(Clone, Debug)]
76pub struct RadiusLaw {
77    pieces: Vec<Piece>,
78    total: f64,
79}
80
81impl RadiusLaw {
82    /// A constant law: `radius` everywhere on `[0, length]`.
83    pub fn constant(length: f64, radius: f64) -> Result<Self, String> {
84        Self::from_segments(&[LawSegment::Constant { length, radius }])
85    }
86
87    /// The per-vertex chain model: radius `vertex_radii[i]` at chain vertex
88    /// `i`, smoothly interpolated along the chain.  `edge_lengths[k]` is the
89    /// arc length of chain edge `k`, so the abscissa breakpoints sit at the
90    /// chain's edge junctions; `vertex_radii` needs exactly one entry per
91    /// vertex (`edge_lengths.len() + 1`).  Interpolation is monotone C1
92    /// (PCHIP): every vertex radius is met exactly and the law never
93    /// overshoots the given radii.
94    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    /// Compose a law from consecutive segments with smooth junction
123    /// transitions (see the module docs for the transition model).
124    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        // 1. Validate and normalize each segment into an evaluator with an
129        //    absolute abscissa span.
130        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        // 2. Decide which junctions need a smooth transition: exact value AND
140        //    slope agreement passes through untouched (already C1); any
141        //    mismatch gets the quintic bridge.
142        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        // 3. Emit pieces: each segment keeps its span minus the half-segment
151        //    windows claimed by transitions at its junctions; each transition
152        //    spans from the left segment's midpoint boundary to the right
153        //    segment's midpoint boundary (window per side = half the adjoining
154        //    segment — the maximal size that can never overlap the next
155        //    junction's window).
156        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    /// Total abscissa length of the law's domain.
181    pub fn total_length(&self) -> f64 {
182        self.total
183    }
184
185    /// Evaluate the radius at abscissa `s` (clamped into `[0, total_length]`).
186    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        // First piece whose end reaches s.
190        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    /// Evaluate at normalized abscissa `fraction ∈ [0, 1]` of the domain.
198    pub fn radius_at_fraction(&self, fraction: f64) -> f64 {
199        self.radius_at(fraction * self.total)
200    }
201
202    /// Upper bound of `|d²radius/ds²|` over `[s_a, s_b]`, used by callers to
203    /// derive a sampling density from the law's curvature (piecewise-linear
204    /// interpolation error of a C1, piecewise-C2 function over step `h` is at
205    /// most `h²·max|r''|/8`).  Per piece the bound is exact-family: the second
206    /// derivative of a degree ≤ 5 piece is a cubic, and the maximum absolute
207    /// value of a polynomial is bounded by the maximum absolute Bernstein
208    /// coefficient (convex-hull property).  Pieces partially overlapping the
209    /// query span use their whole-piece bound (conservative).
210    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            // d²/du²: degree ≤ 3 in u.
219            let c = &piece.coeffs;
220            let dd = [2.0 * c[2], 6.0 * c[3], 12.0 * c[4], 20.0 * c[5]];
221            // Power → Bernstein (degree 3): b_j = Σ_{k≤j} a_k·C(j,k)/C(3,k).
222            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
236/// Validated per-segment evaluator on an absolute abscissa span.
237enum 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        /// Absolute abscissas of the interpolation points.
252        xs: Vec<f64>,
253        ys: Vec<f64>,
254        /// PCHIP slopes at the points (radius per abscissa).
255        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    /// Value and first derivative (radius per abscissa) of the ORIGINAL
350    /// segment at `s` — junction windows take their boundary conditions from
351    /// here, so consecutive windows meeting at a segment midpoint agree
352    /// exactly.
353    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    /// Append this segment's polynomial pieces restricted to `[from, to]`.
377    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
413/// The quintic Hermite S-bridge over `[a_s, b_s]`: matches value and first
414/// derivative of the neighbouring segments at the window boundaries and
415/// carries zero second derivative there (so joints against constant/linear
416/// segments are C2).  With flat sides (`da = db = 0`) it degenerates to the
417/// classic monotone smoothstep `10u³ − 15u⁴ + 6u⁵` scaled between the two
418/// values — the OCCT `Law_S` shape with no overshoot.
419fn 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    // End conditions in normalized u: q(0)=a, q'(0)=A1, q''(0)=0,
422    // q(1)=b, q'(1)=B1, q''(1)=0, with slopes scaled by the window width.
423    let a1 = da * w;
424    let b1 = db * w;
425    let d = b - a - a1;
426    let e = b1 - a1;
427    // Solving the three remaining equations for c3..c5 gives:
428    //   c3 = 10D − 4E,  c4 = 7E − 15D,  c5 = 6D − 3E.
429    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
443/// Horner evaluation of a degree ≤ 5 power-basis polynomial.
444fn 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
452/// Coefficients of the derivative (in `u`) of a degree ≤ 5 polynomial.
453fn 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
461/// Coefficients of `p(x0 + (x1 − x0)·t)` for `t ∈ [0, 1]` — restricting a
462/// normalized polynomial piece to a sub-interval of its own domain.
463fn poly_restrict(coeffs: &[f64; 6], x0: f64, x1: f64) -> [f64; 6] {
464    let h = x1 - x0;
465    let mut result = [0.0; 6];
466    // power = (x0 + h·t)^k, maintained iteratively.
467    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            // power ← power · (x0 + h·t)
475            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
488/// Cubic Hermite power coefficients on `u ∈ [0, 1]` from end values and end
489/// derivatives already scaled by the interval width.
490fn 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
502/// Index of the interpolation interval containing `s` (clamped to the ends).
503fn 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
509/// Shape-preserving (Fritsch–Carlson PCHIP) slopes: C1, monotone on monotone
510/// data, never overshooting the input values.  Deterministic.
511fn 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    // Interior: weighted harmonic mean where the secants agree in sign
524    // (Fritsch–Carlson), zero at local extrema — this is what prevents
525    // overshoot.
526    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    // Ends: the standard shape-preserving three-point estimate, clamped so
535    // the end interval stays monotone.
536    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
541/// One-sided three-point end-slope estimate with the Fritsch–Carlson
542/// monotonicity clamps.
543fn 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    /// Central finite-difference derivative of the law at `s`.
558    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        // Endpoint exactness (bitwise).
577        assert_eq!(law.radius_at(0.0), 2.0);
578        assert_eq!(law.radius_at(4.0), 4.0);
579        // The kept halves are untouched constants.
580        assert_eq!(law.radius_at(0.5), 2.0);
581        assert_eq!(law.radius_at(3.5), 4.0);
582        // The transition midpoint is the exact average: smoothstep(1/2) = 1/2
583        // in dyadic floats.
584        assert_eq!(law.radius_at(2.0), 3.0);
585        // Monotone S with no overshoot beyond the two segment radii.
586        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        // Joints: kept-piece/transition boundaries at the segment midpoints
617        // and the junctions themselves.
618        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            // Value continuity, tighter.
627            assert!(
628                (law.radius_at(s - eps) - law.radius_at(s + eps)).abs() < 1e-5,
629                "value jump at s={s}"
630            );
631        }
632        // Endpoints still exact.
633        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        // Monotone, inside the data range, C1 at the junction.
645        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        // Equidistant linear data reproduces the linear law exactly at the
658        // quarter points.
659        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        // Linear ramp up to 4, then constant 4: a concave kink; the bridge
666        // must stay within [value-at-window-start, 4].
667        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        // Constant halves: zero curvature.
783        assert_eq!(law.max_second_derivative(0.0, 0.9), 0.0);
784        // Transition span: bound must dominate finite-difference curvature.
785        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}