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