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