Skip to main content

brep_kernel/geometry/
image_curve.rs

1//! The 3D IMAGE of a parameter-space curve on a surface: `t ↦ S(p(t))`.
2//!
3//! Four offset/push sites used to refuse any boundary whose pcurve was not an
4//! iso-parameter line, because an iso-curve extraction was the only way they
5//! knew to rematerialize a boundary in 3D.  OCCT has no such restriction: the
6//! iso extraction is an *optimisation with a verified fallback*, and the
7//! general case is a sampled approximation of the composed curve-on-surface
8//! (`GeomLib::BuildCurve3d`, `GeomLib.cxx:960-1071`; the self-verifying iso
9//! shortcut is `GeomLib::buildC3dOnIsoLine`, `:2878-3018`).  This module is
10//! that ladder, written for our representation and gated on our own measured
11//! deviation.  All four refusals were the same missing capability — the 3D
12//! image of a general pcurve on a NURBS surface — and not a restriction
13//! inherent to offsetting: OCCT installs the original pcurve on the offset face
14//! verbatim, leaving only the 3D curve to be approximated.
15//!
16//! # The parametrisation contract
17//!
18//! [`BrepSolid::validate`]'s coedge/edge coincidence check
19//! (`brep/topology/validate.rs`'s `coedge_sample`) compares the two
20//! representations at MATCHED FRACTIONS: fraction `f` of the pcurve's own
21//! domain against fraction `f` of the edge's `[t0, t1]`.  Every tier here
22//! therefore returns the parameter pair `(t0, t1)` for which
23//!
24//! ```text
25//!     curve(t0 + (t1 - t0)·f)  ≈  surface(pcurve(q0 + (q1 - q0)·f))
26//! ```
27//!
28//! for all `f ∈ [0, 1]`, where `[q0, q1]` is the pcurve's domain.  `t0 > t1`
29//! is legal and means the extracted curve runs against the pcurve — callers
30//! that need an increasing edge range reverse the curve and reflect the
31//! parameters, exactly as the existing iso branches do.
32//!
33//! # The tiers, and what each validates
34//!
35//! 1. [`ImageCurveTier::Affine`] — an affine (degree 1 × 1, equal-weight,
36//!    parallelogram) surface.  An affine map commutes with the rational
37//!    evaluation, so mapping the pcurve's HOMOGENEOUS control points through
38//!    the frame is exact for ANY rational pcurve: a rational circle in `(u,v)`
39//!    becomes the exact 3D circle, sharing degree, knots and weights.
40//! 2. [`ImageCurveTier::Iso`] — the pcurve holds one coordinate constant.  The
41//!    image is the surface's iso-curve in the other direction, whose own
42//!    parameter IS that coordinate, so `(t0, t1)` are simply the varying
43//!    coordinate's endpoint values.  This is exact only when the pcurve is also
44//!    AFFINE in its parameter; a constant-`u` pcurve with a non-linear `v(t)`
45//!    passes a constant-coordinate test yet breaks the fraction contract.  The
46//!    tier therefore measures itself and falls through when it misses.
47//! 3. [`ImageCurveTier::Approximated`] — the general case.  The composed curve
48//!    is sampled (seeded at the pcurve's own knots, where its smoothness
49//!    breaks), interpolated at those same parameters so the contract holds by
50//!    construction at the nodes, then measured strictly BETWEEN the nodes —
51//!    interpolation is exact at its own nodes, so an on-node check would
52//!    always pass and prove nothing.  Intervals that miss are bisected and the
53//!    fit repeated.  If the loop cannot reach the bar the tier REFUSES, with
54//!    the measured deviation in the message: a wrong curve is worse than a
55//!    refusal.
56//!
57//! Tiers 1 and 2 verify with the same off-node sweep tier 3 uses, and fall
58//! THROUGH to the next tier on failure rather than refusing, so a shortcut that
59//! does not apply costs accuracy nowhere.
60
61use crate::curve::{NurbsCurve, Vec4};
62use crate::fit::interpolate_curve;
63use crate::surface::NurbsSurface;
64use crate::Vec3;
65
66/// Which lane produced an [`ImageCurve`].
67#[derive(Clone, Copy, Debug, Eq, PartialEq)]
68pub enum ImageCurveTier {
69    /// Exact homogeneous mapping through an affine surface's frame.
70    Affine,
71    /// Verified iso-curve extraction.
72    Iso,
73    /// Sampled-and-fitted approximation of the composed curve-on-surface.
74    Approximated,
75}
76
77/// The 3D image of a pcurve on a surface, plus the parameter range that
78/// matches the pcurve's own domain fraction for fraction.
79#[derive(Clone, Debug)]
80pub struct ImageCurve {
81    pub curve: NurbsCurve,
82    /// Curve parameter at the pcurve's domain START. May exceed `t1`.
83    pub t0: f64,
84    /// Curve parameter at the pcurve's domain END.
85    pub t1: f64,
86    pub tier: ImageCurveTier,
87    /// Measured `max ‖curve(t) − surface(pcurve(q))‖` over the off-node sweep.
88    pub deviation: f64,
89}
90
91/// Number of off-node samples used to verify an exact/iso tier, matching
92/// OCCT's `THE_ISOLINE_CHECK_SEGMENTS` (`GeomLib.cxx:131`) density floor.
93const VERIFY_SAMPLES: usize = 24;
94
95/// Off-node probes per interval in the approximation loop.  Five interior
96/// fractions at `k/6` never coincide with an interval end or midpoint, so a
97/// bisection always lands on a previously unprobed parameter.
98const PROBES_PER_INTERVAL: usize = 5;
99
100/// Refinement rounds before the approximation tier gives up.
101const MAX_ROUNDS: usize = 14;
102
103/// Sample ceiling for the approximation tier.
104const MAX_SAMPLES: usize = 4096;
105
106/// Exact 3D image of a pcurve on an AFFINE sheet: an affine map applied to
107/// the homogeneous control points commutes with the rational evaluation, so
108/// the image shares the pcurve's degree / knots / weights and is
109/// parametrized identically — a rational circle pcurve maps to the exact 3D
110/// circle.
111pub fn affine_image_curve(
112    sheet: &NurbsSurface,
113    pcurve: &NurbsCurve,
114) -> Result<NurbsCurve, String> {
115    let [u0, _] = sheet.domain_u()?;
116    let [v0, _] = sheet.domain_v()?;
117    let frame = sheet.derivatives(u0, v0, 1)?;
118    let origin = frame[0][0];
119    let du = frame[1][0];
120    let dv = frame[0][1];
121    let control_points = pcurve
122        .control_points
123        .iter()
124        .map(|control| {
125            let position = origin
126                .scale(control.w)
127                .add(du.scale(control.x - control.w * u0))
128                .add(dv.scale(control.y - control.w * v0));
129            Vec4 {
130                x: position.x,
131                y: position.y,
132                z: position.z,
133                w: control.w,
134            }
135        })
136        .collect();
137    NurbsCurve::new(pcurve.degree, pcurve.knots.clone(), control_points)
138}
139
140/// The composed curve-on-surface `q ↦ S(p(q))`.
141///
142/// `evaluate_extended` matches what the validator's `coedge_sample` does: a
143/// pcurve that grazes or straddles a periodic seam carries parameters just past
144/// the domain, and the surface WRAPS there.  Clamping instead would read as a
145/// gross deviation and turn a legitimate seam-straddling trim into a refusal.
146fn compose(surface: &NurbsSurface, pcurve: &NurbsCurve, q: f64) -> Result<Vec3, String> {
147    let uv = pcurve.evaluate(q)?;
148    surface.evaluate_extended(uv.x, uv.y)
149}
150
151/// Worst `‖curve(t0 + (t1−t0)·f) − S(p(q0 + (q1−q0)·f))‖` over `count`
152/// interior fractions, none of which is an endpoint.
153fn sweep_deviation(
154    surface: &NurbsSurface,
155    pcurve: &NurbsCurve,
156    curve: &NurbsCurve,
157    t0: f64,
158    t1: f64,
159    count: usize,
160) -> Result<f64, String> {
161    let [q0, q1] = pcurve.domain()?;
162    let mut worst = 0.0f64;
163    for index in 0..=count {
164        let fraction = index as f64 / count as f64;
165        let target = compose(surface, pcurve, q0 + (q1 - q0) * fraction)?;
166        let value = curve.evaluate(t0 + (t1 - t0) * fraction)?;
167        worst = worst.max(value.sub(target).length());
168    }
169    Ok(worst)
170}
171
172/// Is `pcurve` a constant-`u` or constant-`v` line whose varying coordinate is
173/// AFFINE in the curve parameter?  Returns `(constant is u, varying value at
174/// q0, varying value at q1)`.
175///
176/// Both halves matter.  Constancy alone picks the iso-curve; affinity is what
177/// makes the iso-curve's own parameter a fraction-for-fraction stand-in for the
178/// pcurve parameter.  The check is structural first (degree 1, two poles, equal
179/// weights — exactly linear, the shape a boolean's straight trim and a seam
180/// both take) and sampled second, so a degree-elevated but geometrically linear
181/// pcurve is still recognised.
182fn iso_line(pcurve: &NurbsCurve, eps_u: f64, eps_v: f64) -> Result<Option<(bool, f64, f64)>, String> {
183    let [q0, q1] = pcurve.domain()?;
184    let first = pcurve.evaluate(q0)?;
185    let last = pcurve.evaluate(q1)?;
186    let constant_u = (first.x - last.x).abs() <= eps_u;
187    let constant_v = (first.y - last.y).abs() <= eps_v;
188    // A pcurve constant in BOTH directions is a point; there is no iso-curve to
189    // extract and no direction to trim along.
190    if constant_u == constant_v {
191        return Ok(None);
192    }
193    let span = q1 - q0;
194    for index in 1..8 {
195        let fraction = index as f64 / 8.0;
196        let uv = pcurve.evaluate(q0 + span * fraction)?;
197        let (held, varying, expected, eps_held, eps_vary) = if constant_u {
198            (
199                uv.x - first.x,
200                uv.y,
201                first.y + (last.y - first.y) * fraction,
202                eps_u,
203                eps_v,
204            )
205        } else {
206            (
207                uv.y - first.y,
208                uv.x,
209                first.x + (last.x - first.x) * fraction,
210                eps_v,
211                eps_u,
212            )
213        };
214        if held.abs() > eps_held || (varying - expected).abs() > eps_vary {
215            return Ok(None);
216        }
217    }
218    Ok(Some(if constant_u {
219        (true, first.y, last.y)
220    } else {
221        (false, first.x, last.x)
222    }))
223}
224
225/// The constant coordinate an iso extraction should be taken at.
226fn iso_constant(pcurve: &NurbsCurve, constant_u: bool) -> Result<f64, String> {
227    let [q0, q1] = pcurve.domain()?;
228    let first = pcurve.evaluate(q0)?;
229    let last = pcurve.evaluate(q1)?;
230    Ok(if constant_u {
231        0.5 * (first.x + last.x)
232    } else {
233        0.5 * (first.y + last.y)
234    })
235}
236
237/// Parametric detection bands for [`iso_line`], derived from the surface's own
238/// domain so a `[0,1]²` patch and a `[0,2π]²` torus are judged alike.
239fn iso_epsilons(surface: &NurbsSurface) -> Result<(f64, f64), String> {
240    let [u0, u1] = surface.domain_u()?;
241    let [v0, v1] = surface.domain_v()?;
242    Ok((1e-7 * (u1 - u0).abs(), 1e-7 * (v1 - v0).abs()))
243}
244
245/// Seed parameters for the approximation tier: the pcurve's ends, its distinct
246/// interior knots (where the composed curve's smoothness breaks — the cheap
247/// analogue of OCCT's C2/C3 discontinuity split at `GeomLib.cxx:1035-1042`) and
248/// a uniform floor so a single-span pcurve still starts with real structure.
249fn seed_parameters(pcurve: &NurbsCurve) -> Result<Vec<f64>, String> {
250    let [q0, q1] = pcurve.domain()?;
251    let span = q1 - q0;
252    let mut parameters = vec![q0, q1];
253    for knot in &pcurve.knots {
254        if *knot > q0 && *knot < q1 {
255            parameters.push(*knot);
256        }
257    }
258    for index in 1..8 {
259        parameters.push(q0 + span * index as f64 / 8.0);
260    }
261    parameters.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
262    parameters.dedup_by(|a, b| (*a - *b).abs() <= span.abs() * 1e-9);
263    Ok(parameters)
264}
265
266/// Fit the composed curve at `parameters`, then measure it strictly BETWEEN
267/// them.  Returns the fitted curve, the worst off-node deviation, and the
268/// per-interval worst deviations (one shorter than `parameters`).
269fn fit_and_measure(
270    surfaces: &[&NurbsSurface],
271    pcurve: &NurbsCurve,
272    parameters: &[f64],
273    which: usize,
274) -> Result<(NurbsCurve, f64, Vec<f64>), String> {
275    let surface = surfaces[which];
276    let points = parameters
277        .iter()
278        .map(|q| compose(surface, pcurve, *q))
279        .collect::<Result<Vec<_>, String>>()?;
280    let degree = 3.min(points.len() - 1);
281    let curve = interpolate_curve(&points, degree, parameters)?;
282    let mut worst = 0.0f64;
283    let mut per_interval = Vec::with_capacity(parameters.len() - 1);
284    for window in parameters.windows(2) {
285        let (a, b) = (window[0], window[1]);
286        let mut local = 0.0f64;
287        for probe in 1..=PROBES_PER_INTERVAL {
288            let q = a + (b - a) * probe as f64 / (PROBES_PER_INTERVAL + 1) as f64;
289            let target = compose(surface, pcurve, q)?;
290            local = local.max(curve.evaluate(q)?.sub(target).length());
291        }
292        worst = worst.max(local);
293        per_interval.push(local);
294    }
295    Ok((curve, worst, per_interval))
296}
297
298/// The approximation tier, shared by the single- and paired-surface entry
299/// points.  Every surface in `surfaces` is fitted over ONE parameter set, so
300/// the results share degree, knots and (unit) weights — the basis identity
301/// `thicken`'s `ruled_wall` requires of a bottom/top image pair.
302fn approximate(
303    surfaces: &[&NurbsSurface],
304    pcurve: &NurbsCurve,
305    tolerance: f64,
306    site: &str,
307) -> Result<Vec<ImageCurve>, String> {
308    let [q0, q1] = pcurve.domain()?;
309    let mut parameters = seed_parameters(pcurve)?;
310    let mut best = f64::INFINITY;
311    for _ in 0..MAX_ROUNDS {
312        let mut fits = Vec::with_capacity(surfaces.len());
313        let mut worst = 0.0f64;
314        let mut per_interval = vec![0.0f64; parameters.len() - 1];
315        for which in 0..surfaces.len() {
316            let (curve, sheet_worst, sheet_intervals) =
317                fit_and_measure(surfaces, pcurve, &parameters, which)?;
318            worst = worst.max(sheet_worst);
319            for (slot, value) in per_interval.iter_mut().zip(&sheet_intervals) {
320                *slot = slot.max(*value);
321            }
322            fits.push(curve);
323        }
324        best = best.min(worst);
325        if worst <= tolerance {
326            return Ok(fits
327                .into_iter()
328                .map(|curve| ImageCurve {
329                    curve,
330                    t0: q0,
331                    t1: q1,
332                    tier: ImageCurveTier::Approximated,
333                    deviation: worst,
334                })
335                .collect());
336        }
337        // Bisect every interval that missed the bar. Refining only the single
338        // worst interval converges linearly in the number of fits; refining all
339        // failing intervals halves the whole failing region per round.
340        let mut refined = Vec::with_capacity(parameters.len() * 2);
341        for (index, window) in parameters.windows(2).enumerate() {
342            refined.push(window[0]);
343            if per_interval[index] > tolerance {
344                refined.push(0.5 * (window[0] + window[1]));
345            }
346        }
347        refined.push(parameters[parameters.len() - 1]);
348        if refined.len() == parameters.len() || refined.len() > MAX_SAMPLES {
349            break;
350        }
351        parameters = refined;
352    }
353    Err(format!(
354        "{site}: the 3D image of a general pcurve could not be fitted to tolerance \
355         (worst off-node deviation {best:.3e} > {tolerance:.3e} after {} samples) — refusing",
356        parameters.len()
357    ))
358}
359
360/// Build the 3D image of an ARBITRARY pcurve on `surface`.
361///
362/// See the module docs for the tier ladder and the parametrisation contract.
363/// `tolerance` is the caller's own spatial accuracy bar for a committed edge;
364/// it is the acceptance criterion for every tier, and the approximation tier
365/// refuses rather than returning a fit that misses it.  `site` names the caller
366/// in the refusal message.
367pub fn image_curve(
368    surface: &NurbsSurface,
369    pcurve: &NurbsCurve,
370    tolerance: f64,
371    site: &str,
372) -> Result<ImageCurve, String> {
373    let [q0, q1] = pcurve.domain()?;
374    if !(q1 - q0).is_finite() || (q1 - q0).abs() <= 0.0 {
375        return Err(format!("{site}: pcurve has an empty parameter domain"));
376    }
377
378    if surface.is_affine()? {
379        let curve = affine_image_curve(surface, pcurve)?;
380        let deviation = sweep_deviation(surface, pcurve, &curve, q0, q1, VERIFY_SAMPLES)?;
381        if deviation <= tolerance {
382            return Ok(ImageCurve {
383                curve,
384                t0: q0,
385                t1: q1,
386                tier: ImageCurveTier::Affine,
387                deviation,
388            });
389        }
390    }
391
392    let (eps_u, eps_v) = iso_epsilons(surface)?;
393    if let Some((constant_u, start, end)) = iso_line(pcurve, eps_u, eps_v)? {
394        let constant = iso_constant(pcurve, constant_u)?;
395        let curve = if constant_u {
396            surface.iso_curve_u(constant)?
397        } else {
398            surface.iso_curve_v(constant)?
399        };
400        let deviation = sweep_deviation(surface, pcurve, &curve, start, end, VERIFY_SAMPLES)?;
401        if deviation <= tolerance {
402            return Ok(ImageCurve {
403                curve,
404                t0: start,
405                t1: end,
406                tier: ImageCurveTier::Iso,
407                deviation,
408            });
409        }
410    }
411
412    Ok(approximate(&[surface], pcurve, tolerance, site)?
413        .pop()
414        .expect("one surface in, one image out"))
415}
416
417/// The images of ONE pcurve on TWO surfaces, guaranteed to share a basis.
418///
419/// `thicken`'s side wall is `ruled_wall(bottom, top)`, which refuses unless the
420/// two boundary curves agree in degree, knots and weights.  Fitting the two
421/// sheets independently would let their adaptive sample sets diverge, so the
422/// approximation tier fits both over ONE parameter set refined against the
423/// worse of the two.  The tier is chosen once and applies to both.
424pub fn image_curve_pair(
425    first: &NurbsSurface,
426    second: &NurbsSurface,
427    pcurve: &NurbsCurve,
428    tolerance: f64,
429    site: &str,
430) -> Result<(ImageCurve, ImageCurve), String> {
431    let [q0, q1] = pcurve.domain()?;
432    if !(q1 - q0).is_finite() || (q1 - q0).abs() <= 0.0 {
433        return Err(format!("{site}: pcurve has an empty parameter domain"));
434    }
435
436    if first.is_affine()? && second.is_affine()? {
437        let a = affine_image_curve(first, pcurve)?;
438        let b = affine_image_curve(second, pcurve)?;
439        let deviation = sweep_deviation(first, pcurve, &a, q0, q1, VERIFY_SAMPLES)?
440            .max(sweep_deviation(second, pcurve, &b, q0, q1, VERIFY_SAMPLES)?);
441        if deviation <= tolerance {
442            return Ok((
443                ImageCurve {
444                    curve: a,
445                    t0: q0,
446                    t1: q1,
447                    tier: ImageCurveTier::Affine,
448                    deviation,
449                },
450                ImageCurve {
451                    curve: b,
452                    t0: q0,
453                    t1: q1,
454                    tier: ImageCurveTier::Affine,
455                    deviation,
456                },
457            ));
458        }
459    }
460
461    let (eps_u, eps_v) = iso_epsilons(first)?;
462    if let Some((constant_u, start, end)) = iso_line(pcurve, eps_u, eps_v)? {
463        let constant = iso_constant(pcurve, constant_u)?;
464        let (a, b) = if constant_u {
465            (first.iso_curve_u(constant)?, second.iso_curve_u(constant)?)
466        } else {
467            (first.iso_curve_v(constant)?, second.iso_curve_v(constant)?)
468        };
469        let deviation = sweep_deviation(first, pcurve, &a, start, end, VERIFY_SAMPLES)?
470            .max(sweep_deviation(second, pcurve, &b, start, end, VERIFY_SAMPLES)?);
471        if deviation <= tolerance {
472            return Ok((
473                ImageCurve {
474                    curve: a,
475                    t0: start,
476                    t1: end,
477                    tier: ImageCurveTier::Iso,
478                    deviation,
479                },
480                ImageCurve {
481                    curve: b,
482                    t0: start,
483                    t1: end,
484                    tier: ImageCurveTier::Iso,
485                    deviation,
486                },
487            ));
488        }
489    }
490
491    let mut images = approximate(&[first, second], pcurve, tolerance, site)?;
492    let second_image = images.pop().expect("two surfaces in, two images out");
493    let first_image = images.pop().expect("two surfaces in, two images out");
494    Ok((first_image, second_image))
495}
496
497// BREP private tests: 2d2c706c699b9ed2