Skip to main content

brep_kernel/blending/
law.rs

1//! Composable radius laws over a chain's cumulative arc-length abscissa for
2//! our variable-radius fillet lane — the OCCT `Law_Composite` /
3//! `Law_Constant` / `Law_S` / `Law_Interpol` model, in which the marcher
4//! evaluates `ray = Law(t)` at each station so that ONE chain carries
5//! per-edge/per-vertex radii and mixed constant/variable segments joined by
6//! smooth transitions.
7//!
8//! A [`RadiusLaw`] is built from user segments (constant, linear, interpolated
9//! point sets) laid end to end on the abscissa.  Wherever two adjacent
10//! segments meet with a value or slope mismatch, the builder inserts a smooth
11//! S-transition: a quintic Hermite bridge that matches the neighbouring
12//! segments' value and first derivative at the window boundaries and carries
13//! ZERO second derivative there.  Constant and linear segments also have zero
14//! second derivative, so the composite is C2 at every constructed joint (the
15//! C1 contract with headroom); interpolated segments are C1 monotone
16//! (Fritsch–Carlson PCHIP), so joints against them are C1.
17//!
18//! Transition-window sizing is derived from the adjoining segment GEOMETRY,
19//! not a tolerance: each junction claims exactly HALF of each adjoining
20//! segment.  Half is the largest window that can never collide with the
21//! neighbouring junction's window — two windows meeting inside one segment
22//! meet exactly at its midpoint, where both evaluate the underlying segment
23//! itself, so coverage stays contiguous and C1 by construction.
24//!
25//! Endpoint radii are met EXACTLY: no window ever reaches past a segment's
26//! midpoint, so the law's first and last values are the untouched user values.
27//! Evaluation is a binary search plus one Horner evaluation of a degree ≤ 5
28//! polynomial — cheap enough to call per march station.
29
30use crate::{KernelRefusal, KernelStage};
31
32/// One user segment of a composite radius law, spanning `length` of abscissa.
33#[derive(Clone, Debug)]
34pub enum LawSegment {
35    /// Constant radius over `length` (OCCT `Law_Constant`).
36    Constant { length: f64, radius: f64 },
37    /// Linear ramp from `start_radius` to `end_radius` over `length`
38    /// (OCCT `Law_Linear`).
39    Linear {
40        length: f64,
41        start_radius: f64,
42        end_radius: f64,
43    },
44    /// Monotone C1 interpolation through `(abscissa_offset, radius)` points
45    /// (OCCT `Law_Interpol`).  Offsets are measured from the segment start;
46    /// the first must be exactly `0.0`, offsets strictly increase, and the
47    /// last offset is the segment length.  Interpolation is shape-preserving
48    /// (Fritsch–Carlson PCHIP): the law never overshoots the point radii, so
49    /// positive inputs stay positive.
50    Interpolated { points: Vec<(f64, f64)> },
51}
52
53/// One polynomial piece of the composite: `radius(s)` for `s ∈ [s0, s1]`,
54/// evaluated as a degree ≤ 5 polynomial in the normalized `u = (s−s0)/(s1−s0)`.
55#[derive(Clone, Debug)]
56struct Piece {
57    s0: f64,
58    s1: f64,
59    /// Power-basis coefficients `c0 + c1·u + … + c5·u⁵`.
60    coeffs: [f64; 6],
61}
62
63impl Piece {
64    fn width(&self) -> f64 {
65        self.s1 - self.s0
66    }
67
68    fn evaluate(&self, s: f64) -> f64 {
69        let u = ((s - self.s0) / self.width()).clamp(0.0, 1.0);
70        poly_eval(&self.coeffs, u)
71    }
72}
73
74/// A composable radius law over cumulative abscissa `[0, total_length]`.
75///
76/// Construction validates every input (refuse-or-exact: non-positive radii,
77/// non-positive lengths, and non-monotone interpolation abscissas are named
78/// errors); a constructed law is deterministic and cheap to evaluate.
79#[derive(Clone, Debug)]
80pub struct RadiusLaw {
81    pieces: Vec<Piece>,
82    total: f64,
83}
84
85impl RadiusLaw {
86    /// A constant law: `radius` everywhere on `[0, length]`.
87    pub fn constant(length: f64, radius: f64) -> Result<Self, KernelRefusal> {
88        Self::from_segments(&[LawSegment::Constant { length, radius }])
89    }
90
91    /// The per-vertex chain model: radius `vertex_radii[i]` at chain vertex
92    /// `i`, smoothly interpolated along the chain.  `edge_lengths[k]` is the
93    /// arc length of chain edge `k`, so the abscissa breakpoints sit at the
94    /// chain's edge junctions; `vertex_radii` needs exactly one entry per
95    /// vertex (`edge_lengths.len() + 1`).  Interpolation is monotone C1
96    /// (PCHIP): every vertex radius is met exactly and the law never
97    /// overshoots the given radii.
98    pub fn from_vertex_radii(edge_lengths: &[f64], vertex_radii: &[f64]) -> Result<Self, KernelRefusal> {
99        if edge_lengths.is_empty() {
100            return Err(KernelRefusal::input(
101                KernelStage::Collect,
102                "chain_edges",
103                "radius_law: at least one chain edge length is required",
104            ));
105        }
106        if vertex_radii.len() != edge_lengths.len() + 1 {
107            return Err(KernelRefusal::input(
108                KernelStage::Collect,
109                "vertex_radii_count",
110                format!(
111                    "radius_law: need exactly one radius per chain vertex (edges + 1): \
112                     {} edges need {} radii, got {}",
113                    edge_lengths.len(),
114                    edge_lengths.len() + 1,
115                    vertex_radii.len()
116                ),
117            ));
118        }
119        for length in edge_lengths {
120            if !(length.is_finite() && *length > 0.0) {
121                return Err(segment_length_refusal());
122            }
123        }
124        let mut points = Vec::with_capacity(vertex_radii.len());
125        let mut abscissa = 0.0;
126        points.push((0.0, vertex_radii[0]));
127        for (length, radius) in edge_lengths.iter().zip(vertex_radii[1..].iter()) {
128            abscissa += length;
129            points.push((abscissa, *radius));
130        }
131        Self::from_segments(&[LawSegment::Interpolated { points }])
132    }
133
134    /// Compose a law from consecutive segments with smooth junction
135    /// transitions (see the module docs for the transition model).
136    pub fn from_segments(segments: &[LawSegment]) -> Result<Self, KernelRefusal> {
137        if segments.is_empty() {
138            return Err(KernelRefusal::input(
139                KernelStage::Collect,
140                "segments",
141                "radius_law: at least one segment is required",
142            ));
143        }
144        // 1. Validate and normalize each segment into an evaluator with an
145        //    absolute abscissa span.
146        let mut evals: Vec<SegmentEval> = Vec::with_capacity(segments.len());
147        let mut cursor = 0.0_f64;
148        for segment in segments {
149            let eval = SegmentEval::build(segment, cursor)?;
150            cursor = eval.s_end();
151            evals.push(eval);
152        }
153        let total = cursor;
154
155        // 2. Decide which junctions need a smooth transition: exact value AND
156        //    slope agreement passes through untouched (already C1); any
157        //    mismatch gets the quintic bridge.
158        let mut needs_transition = vec![false; evals.len().saturating_sub(1)];
159        for i in 0..needs_transition.len() {
160            let s_j = evals[i].s_end();
161            let (vl, dl) = evals[i].value_slope(s_j);
162            let (vr, dr) = evals[i + 1].value_slope(s_j);
163            needs_transition[i] = vl != vr || dl != dr;
164        }
165
166        // 3. Emit pieces: each segment keeps its span minus the half-segment
167        //    windows claimed by transitions at its junctions; each transition
168        //    spans from the left segment's midpoint boundary to the right
169        //    segment's midpoint boundary (window per side = half the adjoining
170        //    segment — the maximal size that can never overlap the next
171        //    junction's window).
172        let mut pieces: Vec<Piece> = Vec::new();
173        for i in 0..evals.len() {
174            let left_trim = i > 0 && needs_transition[i - 1];
175            let right_trim = i < needs_transition.len() && needs_transition[i];
176            let mid = evals[i].s_start() + evals[i].length() * 0.5;
177            let keep_from = if left_trim { mid } else { evals[i].s_start() };
178            let keep_to = if right_trim { mid } else { evals[i].s_end() };
179            if keep_to > keep_from {
180                evals[i].emit_pieces(keep_from, keep_to, &mut pieces);
181            }
182            if right_trim {
183                let a_s = evals[i].s_start() + evals[i].length() * 0.5;
184                let b_s = evals[i + 1].s_start() + evals[i + 1].length() * 0.5;
185                let (a, da) = evals[i].value_slope(a_s);
186                let (b, db) = evals[i + 1].value_slope(b_s);
187                pieces.push(quintic_bridge(a_s, b_s, a, da, b, db));
188            }
189        }
190        debug_assert!(pieces
191            .windows(2)
192            .all(|pair| (pair[0].s1 - pair[1].s0).abs() == 0.0));
193        Ok(Self { pieces, total })
194    }
195
196    /// Total abscissa length of the law's domain.
197    pub fn total_length(&self) -> f64 {
198        self.total
199    }
200
201    /// Evaluate the radius at abscissa `s` (clamped into `[0, total_length]`).
202    pub fn radius_at(&self, s: f64) -> f64 {
203        debug_assert!(s.is_finite(), "radius_law: abscissa must be finite");
204        let s = s.clamp(0.0, self.total);
205        // First piece whose end reaches s.
206        let index = self
207            .pieces
208            .partition_point(|piece| piece.s1 < s)
209            .min(self.pieces.len() - 1);
210        self.pieces[index].evaluate(s)
211    }
212
213    /// Evaluate at normalized abscissa `fraction ∈ [0, 1]` of the domain.
214    pub fn radius_at_fraction(&self, fraction: f64) -> f64 {
215        self.radius_at(fraction * self.total)
216    }
217
218    /// Upper bound of `|d²radius/ds²|` over `[s_a, s_b]`, used by callers to
219    /// derive a sampling density from the law's curvature (piecewise-linear
220    /// interpolation error of a C1, piecewise-C2 function over step `h` is at
221    /// most `h²·max|r''|/8`).  Per piece the bound is exact-family: the second
222    /// derivative of a degree ≤ 5 piece is a cubic, and the maximum absolute
223    /// value of a polynomial is bounded by the maximum absolute Bernstein
224    /// coefficient (convex-hull property).  Pieces partially overlapping the
225    /// query span use their whole-piece bound (conservative).
226    pub fn max_second_derivative(&self, s_a: f64, s_b: f64) -> f64 {
227        let lo = s_a.min(s_b).clamp(0.0, self.total);
228        let hi = s_a.max(s_b).clamp(0.0, self.total);
229        let mut bound = 0.0_f64;
230        for piece in &self.pieces {
231            if piece.s1 < lo || piece.s0 > hi {
232                continue;
233            }
234            // d²/du²: degree ≤ 3 in u.
235            let c = &piece.coeffs;
236            let dd = [2.0 * c[2], 6.0 * c[3], 12.0 * c[4], 20.0 * c[5]];
237            // Power → Bernstein (degree 3): b_j = Σ_{k≤j} a_k·C(j,k)/C(3,k).
238            let b0 = dd[0];
239            let b1 = dd[0] + dd[1] / 3.0;
240            let b2 = dd[0] + 2.0 * dd[1] / 3.0 + dd[2] / 3.0;
241            let b3 = dd[0] + dd[1] + dd[2] + dd[3];
242            let max_u = b0.abs().max(b1.abs()).max(b2.abs()).max(b3.abs());
243            let width = piece.width();
244            if width > 0.0 {
245                bound = bound.max(max_u / (width * width));
246            }
247        }
248        bound
249    }
250}
251
252/// Validated per-segment evaluator on an absolute abscissa span.
253enum SegmentEval {
254    Constant {
255        s0: f64,
256        length: f64,
257        radius: f64,
258    },
259    Linear {
260        s0: f64,
261        length: f64,
262        r0: f64,
263        r1: f64,
264    },
265    Interpolated {
266        s0: f64,
267        /// Absolute abscissas of the interpolation points.
268        xs: Vec<f64>,
269        ys: Vec<f64>,
270        /// PCHIP slopes at the points (radius per abscissa).
271        ds: Vec<f64>,
272    },
273}
274
275fn check_radius(radius: f64) -> Result<(), KernelRefusal> {
276    if !(radius.is_finite() && radius > 0.0) {
277        return Err(KernelRefusal::input(
278            KernelStage::Collect,
279            "radius",
280            "radius_law: every radius must be positive and finite",
281        ));
282    }
283    Ok(())
284}
285
286/// The one refusal every segment kind shares: a span that is not a positive
287/// finite length.  Minted once so the three sites carry one class and slug.
288fn segment_length_refusal() -> KernelRefusal {
289    KernelRefusal::input(
290        KernelStage::Collect,
291        "segment_length",
292        "radius_law: segment length must be positive and finite",
293    )
294}
295
296impl SegmentEval {
297    fn build(segment: &LawSegment, s0: f64) -> Result<Self, KernelRefusal> {
298        match segment {
299            LawSegment::Constant { length, radius } => {
300                if !(length.is_finite() && *length > 0.0) {
301                    return Err(segment_length_refusal());
302                }
303                check_radius(*radius)?;
304                Ok(Self::Constant {
305                    s0,
306                    length: *length,
307                    radius: *radius,
308                })
309            }
310            LawSegment::Linear {
311                length,
312                start_radius,
313                end_radius,
314            } => {
315                if !(length.is_finite() && *length > 0.0) {
316                    return Err(segment_length_refusal());
317                }
318                check_radius(*start_radius)?;
319                check_radius(*end_radius)?;
320                Ok(Self::Linear {
321                    s0,
322                    length: *length,
323                    r0: *start_radius,
324                    r1: *end_radius,
325                })
326            }
327            LawSegment::Interpolated { points } => {
328                if points.len() < 2 {
329                    return Err(KernelRefusal::input(
330                        KernelStage::Collect,
331                        "interpolation_points",
332                        "radius_law: an interpolated segment needs at least two points",
333                    ));
334                }
335                if points[0].0 != 0.0 {
336                    return Err(KernelRefusal::input(
337                        KernelStage::Collect,
338                        "interpolation_start",
339                        "radius_law: interpolation abscissas must start at exactly 0",
340                    ));
341                }
342                for pair in points.windows(2) {
343                    if !(pair[1].0.is_finite() && pair[1].0 > pair[0].0) {
344                        return Err(KernelRefusal::input(
345                            KernelStage::Collect,
346                            "interpolation_monotone",
347                            "radius_law: interpolation abscissas must be strictly increasing",
348                        ));
349                    }
350                }
351                for (_, radius) in points {
352                    check_radius(*radius)?;
353                }
354                let xs: Vec<f64> = points.iter().map(|(x, _)| s0 + x).collect();
355                let ys: Vec<f64> = points.iter().map(|(_, y)| *y).collect();
356                let ds = pchip_slopes(&xs, &ys);
357                Ok(Self::Interpolated { s0, xs, ys, ds })
358            }
359        }
360    }
361
362    fn s_start(&self) -> f64 {
363        match self {
364            Self::Constant { s0, .. } | Self::Linear { s0, .. } | Self::Interpolated { s0, .. } => {
365                *s0
366            }
367        }
368    }
369
370    fn length(&self) -> f64 {
371        match self {
372            Self::Constant { length, .. } | Self::Linear { length, .. } => *length,
373            Self::Interpolated { s0, xs, .. } => xs[xs.len() - 1] - s0,
374        }
375    }
376
377    fn s_end(&self) -> f64 {
378        match self {
379            Self::Constant { s0, length, .. } | Self::Linear { s0, length, .. } => s0 + length,
380            Self::Interpolated { xs, .. } => xs[xs.len() - 1],
381        }
382    }
383
384    /// Value and first derivative (radius per abscissa) of the ORIGINAL
385    /// segment at `s` — junction windows take their boundary conditions from
386    /// here, so consecutive windows meeting at a segment midpoint agree
387    /// exactly.
388    fn value_slope(&self, s: f64) -> (f64, f64) {
389        match self {
390            Self::Constant { radius, .. } => (*radius, 0.0),
391            Self::Linear {
392                s0,
393                length,
394                r0,
395                r1,
396            } => {
397                let slope = (r1 - r0) / length;
398                (r0 + slope * (s - s0), slope)
399            }
400            Self::Interpolated { xs, ys, ds, .. } => {
401                let i = interval_index(xs, s);
402                let h = xs[i + 1] - xs[i];
403                let coeffs = hermite_cubic(ys[i], ds[i] * h, ys[i + 1], ds[i + 1] * h);
404                let u = ((s - xs[i]) / h).clamp(0.0, 1.0);
405                let deriv = poly_eval(&poly_derivative(&coeffs), u) / h;
406                (poly_eval(&coeffs, u), deriv)
407            }
408        }
409    }
410
411    /// Append this segment's polynomial pieces restricted to `[from, to]`.
412    fn emit_pieces(&self, from: f64, to: f64, out: &mut Vec<Piece>) {
413        match self {
414            Self::Constant { radius, .. } => out.push(Piece {
415                s0: from,
416                s1: to,
417                coeffs: [*radius, 0.0, 0.0, 0.0, 0.0, 0.0],
418            }),
419            Self::Linear { .. } => {
420                let (va, slope) = self.value_slope(from);
421                out.push(Piece {
422                    s0: from,
423                    s1: to,
424                    coeffs: [va, slope * (to - from), 0.0, 0.0, 0.0, 0.0],
425                });
426            }
427            Self::Interpolated { xs, ys, ds, .. } => {
428                for i in 0..xs.len() - 1 {
429                    let (x0, x1) = (xs[i], xs[i + 1]);
430                    let (a, b) = (x0.max(from), x1.min(to));
431                    if b <= a {
432                        continue;
433                    }
434                    let h = x1 - x0;
435                    let cubic = hermite_cubic(ys[i], ds[i] * h, ys[i + 1], ds[i + 1] * h);
436                    let coeffs = poly_restrict(&cubic, (a - x0) / h, (b - x0) / h);
437                    out.push(Piece {
438                        s0: a,
439                        s1: b,
440                        coeffs,
441                    });
442                }
443            }
444        }
445    }
446}
447
448/// The quintic Hermite S-bridge over `[a_s, b_s]`: matches value and first
449/// derivative of the neighbouring segments at the window boundaries and
450/// carries zero second derivative there (so joints against constant/linear
451/// segments are C2).  With flat sides (`da = db = 0`) it degenerates to the
452/// classic monotone smoothstep `10u³ − 15u⁴ + 6u⁵` scaled between the two
453/// values — the OCCT `Law_S` shape with no overshoot.
454fn quintic_bridge(a_s: f64, b_s: f64, a: f64, da: f64, b: f64, db: f64) -> Piece {
455    let w = b_s - a_s;
456    // End conditions in normalized u: q(0)=a, q'(0)=A1, q''(0)=0,
457    // q(1)=b, q'(1)=B1, q''(1)=0, with slopes scaled by the window width.
458    let a1 = da * w;
459    let b1 = db * w;
460    let d = b - a - a1;
461    let e = b1 - a1;
462    // Solving the three remaining equations for c3..c5 gives:
463    //   c3 = 10D − 4E,  c4 = 7E − 15D,  c5 = 6D − 3E.
464    Piece {
465        s0: a_s,
466        s1: b_s,
467        coeffs: [
468            a,
469            a1,
470            0.0,
471            10.0 * d - 4.0 * e,
472            7.0 * e - 15.0 * d,
473            6.0 * d - 3.0 * e,
474        ],
475    }
476}
477
478/// Horner evaluation of a degree ≤ 5 power-basis polynomial.
479fn poly_eval(coeffs: &[f64; 6], u: f64) -> f64 {
480    let mut value = coeffs[5];
481    for k in (0..5).rev() {
482        value = value * u + coeffs[k];
483    }
484    value
485}
486
487/// Coefficients of the derivative (in `u`) of a degree ≤ 5 polynomial.
488fn poly_derivative(coeffs: &[f64; 6]) -> [f64; 6] {
489    let mut out = [0.0; 6];
490    for k in 1..6 {
491        out[k - 1] = coeffs[k] * k as f64;
492    }
493    out
494}
495
496/// Coefficients of `p(x0 + (x1 − x0)·t)` for `t ∈ [0, 1]` — restricting a
497/// normalized polynomial piece to a sub-interval of its own domain.
498fn poly_restrict(coeffs: &[f64; 6], x0: f64, x1: f64) -> [f64; 6] {
499    let h = x1 - x0;
500    let mut result = [0.0; 6];
501    // power = (x0 + h·t)^k, maintained iteratively.
502    let mut power = [0.0; 6];
503    power[0] = 1.0;
504    for k in 0..6 {
505        for j in 0..6 {
506            result[j] += coeffs[k] * power[j];
507        }
508        if k < 5 {
509            // power ← power · (x0 + h·t)
510            let mut next = [0.0; 6];
511            for j in 0..6 {
512                next[j] += power[j] * x0;
513                if j + 1 < 6 {
514                    next[j + 1] += power[j] * h;
515                }
516            }
517            power = next;
518        }
519    }
520    result
521}
522
523/// Cubic Hermite power coefficients on `u ∈ [0, 1]` from end values and end
524/// derivatives already scaled by the interval width.
525fn hermite_cubic(y0: f64, d0: f64, y1: f64, d1: f64) -> [f64; 6] {
526    let delta = y1 - y0;
527    [
528        y0,
529        d0,
530        3.0 * delta - 2.0 * d0 - d1,
531        -2.0 * delta + d0 + d1,
532        0.0,
533        0.0,
534    ]
535}
536
537/// Index of the interpolation interval containing `s` (clamped to the ends).
538fn interval_index(xs: &[f64], s: f64) -> usize {
539    let mut i = xs.partition_point(|x| *x <= s);
540    i = i.clamp(1, xs.len() - 1);
541    i - 1
542}
543
544/// Shape-preserving (Fritsch–Carlson PCHIP) slopes: C1, monotone on monotone
545/// data, never overshooting the input values.  Deterministic.
546fn pchip_slopes(xs: &[f64], ys: &[f64]) -> Vec<f64> {
547    let n = xs.len();
548    debug_assert!(n >= 2);
549    let m = n - 1;
550    let h: Vec<f64> = (0..m).map(|i| xs[i + 1] - xs[i]).collect();
551    let secant: Vec<f64> = (0..m).map(|i| (ys[i + 1] - ys[i]) / h[i]).collect();
552    let mut d = vec![0.0_f64; n];
553    if n == 2 {
554        d[0] = secant[0];
555        d[1] = secant[0];
556        return d;
557    }
558    // Interior: weighted harmonic mean where the secants agree in sign
559    // (Fritsch–Carlson), zero at local extrema — this is what prevents
560    // overshoot.
561    for i in 1..m {
562        let (s0, s1) = (secant[i - 1], secant[i]);
563        if s0 * s1 > 0.0 {
564            let w1 = 2.0 * h[i] + h[i - 1];
565            let w2 = h[i] + 2.0 * h[i - 1];
566            d[i] = (w1 + w2) / (w1 / s0 + w2 / s1);
567        }
568    }
569    // Ends: the standard shape-preserving three-point estimate, clamped so
570    // the end interval stays monotone.
571    d[0] = end_slope(h[0], h[1], secant[0], secant[1]);
572    d[n - 1] = end_slope(h[m - 1], h[m - 2], secant[m - 1], secant[m - 2]);
573    d
574}
575
576/// One-sided three-point end-slope estimate with the Fritsch–Carlson
577/// monotonicity clamps.
578fn end_slope(h0: f64, h1: f64, s0: f64, s1: f64) -> f64 {
579    let mut d = ((2.0 * h0 + h1) * s0 - h0 * s1) / (h0 + h1);
580    if d * s0 <= 0.0 {
581        d = 0.0;
582    } else if s0 * s1 < 0.0 && d.abs() > 3.0 * s0.abs() {
583        d = 3.0 * s0;
584    }
585    d
586}
587