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