Skip to main content

brep_kernel/geometry/coons/
compat.rs

1//! Representation compatibility for the Coons constructors.
2//!
3//! Two curves can only become opposite boundaries of ONE tensor-product patch
4//! when they share a degree, a knot vector and a parameter domain.  Everything
5//! here reaches that state by EXACT algebra — affine knot re-mapping, degree
6//! elevation and knot insertion — never by sampling and refitting, so the
7//! curve that comes out is the curve that went in, bit for bit where no
8//! operation was needed at all.
9//!
10//! The three primitives:
11//!
12//! * [`normalise_domain`] re-maps the knot domain onto `[0, 1]`.  Control
13//!   points are untouched; a curve already on `[0, 1]` comes back with its
14//!   knot floats unchanged (`(k - 0.0) / 1.0 == k`).
15//! * [`elevate_degree`] raises the degree by `t`.  The curve is Bezier-
16//!   decomposed with the shared [`NurbsCurve::insert_knot`], each segment is
17//!   elevated by the closed binomial formula, the segments are rejoined, and
18//!   the surplus junction knots are then REMOVED so each interior knot lands
19//!   back at its original multiplicity `m` plus `t` — the minimal knot vector
20//!   for the elevated curve.  Removal is cleanup only: it is applied one knot
21//!   at a time and each removal is verified by re-inserting the knot and
22//!   comparing control points, so a removal that would change the geometry is
23//!   simply not taken (the knot stays, the curve is still exact).
24//! * [`merge_knots`] refines two equal-degree curves on `[0, 1]` onto the
25//!   union of their knots, then stamps ONE canonical knot array onto both so
26//!   the tensor structure is exact rather than merely equal within a band.
27//!
28//! Knot values are compared with the kernel's [`crate::KNOT_IDENTITY_TOL`]:
29//! two knots inside that band ARE the same knot, which is the same rule
30//! `insert_knot` and `split` snap by.
31
32use crate::{NurbsCurve, Vec4, KNOT_IDENTITY_TOL};
33
34/// What a compatibility pass had to do to one curve.  Reported, never
35/// inferred: a caller that promises "bit for bit" needs to see a zero here.
36#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
37pub struct CompatWork {
38    /// Degrees added by `elevate_degree`.
39    pub elevated_by: usize,
40    /// Knots added by `merge_knots`.
41    pub knots_inserted: usize,
42    /// Whether `normalise_domain` moved the knot domain.
43    pub domain_remapped: bool,
44}
45
46impl CompatWork {
47    /// No elevation, no insertion, no re-mapping: the curve is unchanged.
48    pub fn is_identity(&self) -> bool {
49        self.elevated_by == 0 && self.knots_inserted == 0 && !self.domain_remapped
50    }
51}
52
53/// Affinely re-map `curve`'s knot domain onto `[0, 1]`.
54///
55/// Exact in both directions: the control points never move, the clamped ends
56/// land on exactly `0.0` and `1.0` (`(start - start) / span == 0.0`,
57/// `(end - start) / span == 1.0`), and a curve already on `[0, 1]` keeps every
58/// knot float it had.
59pub(crate) fn normalise_domain(curve: &NurbsCurve) -> Result<(NurbsCurve, bool), String> {
60    let [start, end] = curve.domain()?;
61    let span = end - start;
62    if span <= 0.0 || !span.is_finite() {
63        return Err(format!(
64            "coons: boundary knot domain [{start}, {end}] is not a positive span"
65        ));
66    }
67    let moved = start != 0.0 || end != 1.0;
68    if !moved {
69        return Ok((curve.clone(), false));
70    }
71    let knots = curve
72        .knots
73        .iter()
74        .map(|knot| ((knot - start) / span).clamp(0.0, 1.0))
75        .collect();
76    Ok((
77        NurbsCurve::new(curve.degree, knots, curve.control_points.clone())?,
78        true,
79    ))
80}
81
82/// Binomial coefficient as an exact f64 for the small degrees a patch uses.
83fn binomial(n: usize, k: usize) -> f64 {
84    if k > n {
85        return 0.0;
86    }
87    let k = k.min(n - k);
88    let mut value = 1.0f64;
89    for step in 0..k {
90        value = value * (n - step) as f64 / (step + 1) as f64;
91    }
92    value
93}
94
95/// Degree-elevate ONE Bezier segment from degree `p` to `p + t`.
96///
97/// `Q_i = sum_j C(p,j) C(t,i-j) / C(p+t,i) * P_j` — the closed form, applied
98/// to the homogeneous control points, so a rational segment elevates exactly
99/// like a polynomial one.
100fn elevate_bezier(points: &[Vec4], t: usize) -> Vec<Vec4> {
101    let p = points.len() - 1;
102    let elevated = p + t;
103    (0..=elevated)
104        .map(|index| {
105            let mut accumulated = Vec4 {
106                x: 0.0,
107                y: 0.0,
108                z: 0.0,
109                w: 0.0,
110            };
111            for source in index.saturating_sub(t)..=index.min(p) {
112                let coefficient =
113                    binomial(p, source) * binomial(t, index - source) / binomial(elevated, index);
114                accumulated = accumulated.add(points[source].scale(coefficient));
115            }
116            accumulated
117        })
118        .collect()
119}
120
121/// Distinct interior knot values of `curve` with their multiplicities, in
122/// increasing order.  Values inside [`KNOT_IDENTITY_TOL`] of each other are
123/// ONE knot (the first occurrence names it); the clamped ends are excluded.
124pub(super) fn interior_multiplicities(curve: &NurbsCurve) -> Vec<(f64, usize)> {
125    let degree = curve.degree;
126    let start = curve.knots[degree];
127    let end = curve.knots[curve.knots.len() - 1 - degree];
128    let mut groups: Vec<(f64, usize)> = Vec::new();
129    for &knot in &curve.knots {
130        if knot <= start + KNOT_IDENTITY_TOL || knot >= end - KNOT_IDENTITY_TOL {
131            continue;
132        }
133        match groups.last_mut() {
134            Some((value, count)) if (*value - knot).abs() <= KNOT_IDENTITY_TOL => *count += 1,
135            _ => groups.push((knot, 1)),
136        }
137    }
138    groups
139}
140
141/// Try to remove ONE instance of the knot `parameter` from `curve`.
142///
143/// Removal is the inverse of insertion, so it is solved as such: the reduced
144/// control points are recovered by forward substitution through Boehm's
145/// insertion recurrence, and the candidate is then VERIFIED by re-inserting
146/// the knot and comparing every control point against the original.  The
147/// returned residual is that round-trip distance — the only evidence that the
148/// removal changed nothing.  `None` means the knot is not there to remove.
149fn try_remove_knot(curve: &NurbsCurve, parameter: f64) -> Option<(NurbsCurve, f64)> {
150    let degree = curve.degree;
151    let occurrences: Vec<usize> = curve
152        .knots
153        .iter()
154        .enumerate()
155        .filter(|(_, knot)| (**knot - parameter).abs() <= KNOT_IDENTITY_TOL)
156        .map(|(index, _)| index)
157        .collect();
158    let multiplicity = occurrences.len();
159    let last = *occurrences.last()?;
160    if multiplicity == 0 || last < degree + 1 || last + degree >= curve.knots.len() {
161        return None;
162    }
163    let mut reduced_knots = curve.knots.clone();
164    reduced_knots.remove(last);
165    // In the reduced vector the knot's last occurrence sits at `last - 1`, so
166    // that index IS the span index of `parameter`.  Boehm's insertion writes
167    // control points `last - degree ..= last - multiplicity`; everything below
168    // is copied straight through and everything above shifts by one.
169    let first_affected = last - degree;
170    let last_affected = last - multiplicity;
171    if first_affected == 0 || last_affected + 1 >= curve.control_points.len() {
172        return None;
173    }
174    let mut reduced_points: Vec<Vec4> = curve.control_points[..first_affected].to_vec();
175    let mut previous = curve.control_points[first_affected - 1];
176    for index in first_affected..=last_affected {
177        let denominator = reduced_knots[index + degree] - reduced_knots[index];
178        if denominator.abs() <= f64::MIN_POSITIVE {
179            return None;
180        }
181        let alpha = (parameter - reduced_knots[index]) / denominator;
182        if alpha.abs() <= 1e-12 {
183            return None;
184        }
185        let point = curve.control_points[index]
186            .add(previous.scale(alpha - 1.0))
187            .scale(1.0 / alpha);
188        reduced_points.push(point);
189        previous = point;
190    }
191    reduced_points.extend_from_slice(&curve.control_points[last_affected + 2..]);
192    let reduced = NurbsCurve::new(degree, reduced_knots, reduced_points).ok()?;
193    // The verification: re-inserting the knot must rebuild the original net.
194    let rebuilt = reduced.insert_knot(parameter, 1).ok()?;
195    if rebuilt.control_points.len() != curve.control_points.len() {
196        return None;
197    }
198    let residual = rebuilt
199        .control_points
200        .iter()
201        .zip(&curve.control_points)
202        .map(|(a, b)| {
203            ((a.x - b.x).powi(2) + (a.y - b.y).powi(2) + (a.z - b.z).powi(2) + (a.w - b.w).powi(2))
204                .sqrt()
205        })
206        .fold(0.0f64, f64::max);
207    Some((reduced, residual))
208}
209
210/// Relative round-off band a knot removal's re-insertion round trip must come
211/// back inside.  Sized for accumulated f64 error over a Boehm forward
212/// substitution and its inverse on a handful of control points — a removable
213/// knot round-trips at ~1e-16 relative, a non-removable one at 1e-1.  There is
214/// no tolerance to tune here: the gap between the two cases is fifteen orders
215/// of magnitude.
216const REMOVAL_ROUND_OFF: f64 = 1e-12;
217
218/// Largest control-point coordinate magnitude — the scale the round-trip
219/// residual of a knot removal is judged against.
220fn net_extent(curve: &NurbsCurve) -> f64 {
221    curve
222        .control_points
223        .iter()
224        .flat_map(|point| [point.x, point.y, point.z, point.w])
225        .fold(0.0f64, |worst, value| worst.max(value.abs()))
226}
227
228/// Raise `curve` to `target` degree exactly.
229///
230/// Bezier decomposition (knot insertion) -> per-segment binomial elevation ->
231/// rejoin -> removal of the surplus junction knots.  The result has each
232/// interior knot at its original multiplicity plus `target - degree`, which is
233/// the minimal knot vector of the elevated curve; a removal that does not
234/// verify is skipped, leaving a redundant knot rather than a wrong curve.
235pub(super) fn elevate_degree(curve: &NurbsCurve, target: usize) -> Result<NurbsCurve, String> {
236    let degree = curve.degree;
237    if target < degree {
238        return Err(format!(
239            "coons: cannot lower degree {degree} to {target} — elevation only"
240        ));
241    }
242    if target == degree {
243        return Ok(curve.clone());
244    }
245    let raise = target - degree;
246    let interior = interior_multiplicities(curve);
247    // 1. Bezier decomposition: every interior knot to multiplicity `degree`.
248    let mut decomposed = curve.clone();
249    for &(knot, multiplicity) in &interior {
250        if multiplicity > degree {
251            return Err(format!(
252                "coons: interior knot {knot} has multiplicity {multiplicity} above degree {degree}"
253            ));
254        }
255        decomposed = decomposed.insert_knot(knot, degree - multiplicity)?;
256    }
257    let segments = interior.len() + 1;
258    if decomposed.control_points.len() != segments * degree + 1 {
259        return Err(format!(
260            "coons: Bezier decomposition produced {} control points, expected {}",
261            decomposed.control_points.len(),
262            segments * degree + 1
263        ));
264    }
265    // 2. Elevate each segment; the shared junction point is contributed once.
266    let mut points: Vec<Vec4> = Vec::with_capacity(segments * target + 1);
267    for segment in 0..segments {
268        let start = segment * degree;
269        let elevated = elevate_bezier(&decomposed.control_points[start..=start + degree], raise);
270        if segment == 0 {
271            points.extend_from_slice(&elevated);
272        } else {
273            points.extend_from_slice(&elevated[1..]);
274        }
275    }
276    // 3. Rejoin at full junction multiplicity.
277    let [domain_start, domain_end] = decomposed.domain()?;
278    let mut knots: Vec<f64> = vec![domain_start; target + 1];
279    for &(knot, _) in &interior {
280        knots.extend(std::iter::repeat_n(knot, target));
281    }
282    knots.extend(std::iter::repeat_n(domain_end, target + 1));
283    let mut elevated = NurbsCurve::new(target, knots, points)?;
284    // 4. Drop the surplus: an interior knot of original multiplicity `m` needs
285    //    multiplicity `m + raise`, so `degree - m` removals.
286    //
287    //    The acceptance band is ROUND-OFF, not a tolerance: a knot that is
288    //    genuinely removable round-trips to a few ulps of the control net, and
289    //    anything above that is a knot the curve does not actually carry —
290    //    taking it would be a refit, which this module does not do.  A skipped
291    //    removal costs one redundant knot and changes no geometry.
292    let band = REMOVAL_ROUND_OFF * net_extent(&elevated).max(1.0);
293    for &(knot, multiplicity) in &interior {
294        for _ in 0..(degree - multiplicity) {
295            match try_remove_knot(&elevated, knot) {
296                Some((reduced, residual)) if residual <= band => elevated = reduced,
297                _ => break,
298            }
299        }
300    }
301    Ok(elevated)
302}
303
304/// Refine two equal-degree curves on `[0, 1]` onto the union of their knots.
305///
306/// Returns the two refined curves and the number of knots each gained.  Both
307/// come back carrying ONE canonical knot array, so the surface built from them
308/// has an exactly shared tensor structure rather than two vectors that merely
309/// agree within a band.
310pub(super) fn merge_knots(
311    first: &NurbsCurve,
312    second: &NurbsCurve,
313) -> Result<(NurbsCurve, NurbsCurve, usize, usize), String> {
314    if first.degree != second.degree {
315        return Err(format!(
316            "coons: knot merge needs one degree, got {} and {}",
317            first.degree, second.degree
318        ));
319    }
320    let degree = first.degree;
321    let mut targets: Vec<(f64, usize)> = interior_multiplicities(first);
322    for (knot, multiplicity) in interior_multiplicities(second) {
323        match targets
324            .iter_mut()
325            .find(|(value, _)| (*value - knot).abs() <= KNOT_IDENTITY_TOL)
326        {
327            Some((_, existing)) => *existing = (*existing).max(multiplicity),
328            None => targets.push((knot, multiplicity)),
329        }
330    }
331    targets.sort_by(|a, b| a.0.total_cmp(&b.0));
332    for &(knot, multiplicity) in &targets {
333        if multiplicity > degree {
334            return Err(format!(
335                "coons: merged interior knot {knot} would reach multiplicity {multiplicity}, \
336                 above degree {degree}"
337            ));
338        }
339    }
340    let refine = |curve: &NurbsCurve| -> Result<(NurbsCurve, usize), String> {
341        let mut refined = curve.clone();
342        let before = refined.knots.len();
343        for &(knot, multiplicity) in &targets {
344            let present = refined
345                .knots
346                .iter()
347                .filter(|value| (**value - knot).abs() <= KNOT_IDENTITY_TOL)
348                .count();
349            if multiplicity > present {
350                refined = refined.insert_knot(knot, multiplicity - present)?;
351            }
352        }
353        let inserted = refined.knots.len() - before;
354        Ok((refined, inserted))
355    };
356    let (first_refined, first_inserted) = refine(first)?;
357    let (second_refined, second_inserted) = refine(second)?;
358    let mut canonical: Vec<f64> = vec![0.0; degree + 1];
359    for &(knot, multiplicity) in &targets {
360        canonical.extend(std::iter::repeat_n(knot, multiplicity));
361    }
362    canonical.extend(std::iter::repeat_n(1.0, degree + 1));
363    if canonical.len() != first_refined.knots.len() || canonical.len() != second_refined.knots.len()
364    {
365        return Err(format!(
366            "coons: knot merge did not converge — canonical {} vs {} and {}",
367            canonical.len(),
368            first_refined.knots.len(),
369            second_refined.knots.len()
370        ));
371    }
372    Ok((
373        NurbsCurve::new(degree, canonical.clone(), first_refined.control_points)?,
374        NurbsCurve::new(degree, canonical, second_refined.control_points)?,
375        first_inserted,
376        second_inserted,
377    ))
378}
379
380/// Bring a whole family of curves into one degree and one knot vector.
381///
382/// The family is made compatible pairwise against a running representative:
383/// every curve is elevated to the family's highest degree, then each is
384/// refined onto the accumulated knot union.  Returns the compatible curves and
385/// the per-curve work record.
386pub(crate) fn make_family_compatible(
387    curves: &[NurbsCurve],
388    minimum_degree: usize,
389) -> Result<(Vec<NurbsCurve>, Vec<CompatWork>), String> {
390    if curves.is_empty() {
391        return Err("coons: cannot make an empty curve family compatible".into());
392    }
393    let mut work = vec![CompatWork::default(); curves.len()];
394    let mut normalised = Vec::with_capacity(curves.len());
395    for (index, curve) in curves.iter().enumerate() {
396        let (moved, remapped) = normalise_domain(curve)?;
397        work[index].domain_remapped = remapped;
398        normalised.push(moved);
399    }
400    let degree = normalised
401        .iter()
402        .map(|curve| curve.degree)
403        .max()
404        .unwrap_or(minimum_degree)
405        .max(minimum_degree);
406    let mut elevated = Vec::with_capacity(normalised.len());
407    for (index, curve) in normalised.iter().enumerate() {
408        work[index].elevated_by = degree - curve.degree;
409        elevated.push(elevate_degree(curve, degree)?);
410    }
411    // One pass accumulates the union into curve 0, a second pushes the union
412    // back out to every other curve.
413    let mut representative = elevated[0].clone();
414    for curve in elevated.iter().skip(1) {
415        let (merged, _, _, _) = merge_knots(&representative, curve)?;
416        representative = merged;
417    }
418    let mut compatible = Vec::with_capacity(elevated.len());
419    for (index, curve) in elevated.iter().enumerate() {
420        let (_, refined, _, inserted) = merge_knots(&representative, curve)?;
421        work[index].knots_inserted = inserted;
422        compatible.push(refined);
423    }
424    Ok((compatible, work))
425}
426
427/// Greville abscissae of a knot vector: `xi_i = (u_{i+1} + ... + u_{i+p}) / p`.
428///
429/// A degree-`p` B-spline basis reproduces any LINEAR function exactly when its
430/// coefficients are that function sampled at these parameters, which is how
431/// the ruled and bilinear halves of a Coons patch are expressed in the
432/// patch's own knot vector without a single fit.
433pub(super) fn greville(knots: &[f64], degree: usize) -> Vec<f64> {
434    let count = knots.len() - degree - 1;
435    (0..count)
436        .map(|index| {
437            knots[index + 1..=index + degree].iter().sum::<f64>() / degree as f64
438        })
439        .collect()
440}
441