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.  See `docs/developer/kernel-plans/occt-offset-algorithms.md`
12//! §4.4.
13//!
14//! # The parametrisation contract
15//!
16//! [`BrepSolid::validate`]'s coedge/edge coincidence check
17//! (`brep/topology/validate.rs`'s `coedge_sample`) compares the two
18//! representations at MATCHED FRACTIONS: fraction `f` of the pcurve's own
19//! domain against fraction `f` of the edge's `[t0, t1]`.  Every tier here
20//! therefore returns the parameter pair `(t0, t1)` for which
21//!
22//! ```text
23//!     curve(t0 + (t1 - t0)·f)  ≈  surface(pcurve(q0 + (q1 - q0)·f))
24//! ```
25//!
26//! for all `f ∈ [0, 1]`, where `[q0, q1]` is the pcurve's domain.  `t0 > t1`
27//! is legal and means the extracted curve runs against the pcurve — callers
28//! that need an increasing edge range reverse the curve and reflect the
29//! parameters, exactly as the existing iso branches do.
30//!
31//! # The tiers, and what each validates
32//!
33//! 1. [`ImageCurveTier::Affine`] — an affine (degree 1 × 1, equal-weight,
34//!    parallelogram) surface.  An affine map commutes with the rational
35//!    evaluation, so mapping the pcurve's HOMOGENEOUS control points through
36//!    the frame is exact for ANY rational pcurve: a rational circle in `(u,v)`
37//!    becomes the exact 3D circle, sharing degree, knots and weights.
38//! 2. [`ImageCurveTier::Iso`] — the pcurve holds one coordinate constant.  The
39//!    image is the surface's iso-curve in the other direction, whose own
40//!    parameter IS that coordinate, so `(t0, t1)` are simply the varying
41//!    coordinate's endpoint values.  This is exact only when the pcurve is also
42//!    AFFINE in its parameter; a constant-`u` pcurve with a non-linear `v(t)`
43//!    passes a constant-coordinate test yet breaks the fraction contract.  The
44//!    tier therefore measures itself and falls through when it misses.
45//! 3. [`ImageCurveTier::Approximated`] — the general case.  The composed curve
46//!    is sampled (seeded at the pcurve's own knots, where its smoothness
47//!    breaks), interpolated at those same parameters so the contract holds by
48//!    construction at the nodes, then measured strictly BETWEEN the nodes —
49//!    interpolation is exact at its own nodes, so an on-node check would
50//!    always pass and prove nothing.  Intervals that miss are bisected and the
51//!    fit repeated.  If the loop cannot reach the bar the tier REFUSES, with
52//!    the measured deviation in the message: a wrong curve is worse than a
53//!    refusal.
54//!
55//! Tiers 1 and 2 verify with the same off-node sweep tier 3 uses, and fall
56//! THROUGH to the next tier on failure rather than refusing, so a shortcut that
57//! does not apply costs accuracy nowhere.
58
59use crate::curve::{NurbsCurve, Vec4};
60use crate::fit::interpolate_curve;
61use crate::surface::NurbsSurface;
62use crate::Vec3;
63
64/// Which lane produced an [`ImageCurve`].
65#[derive(Clone, Copy, Debug, Eq, PartialEq)]
66pub enum ImageCurveTier {
67    /// Exact homogeneous mapping through an affine surface's frame.
68    Affine,
69    /// Verified iso-curve extraction.
70    Iso,
71    /// Sampled-and-fitted approximation of the composed curve-on-surface.
72    Approximated,
73}
74
75/// The 3D image of a pcurve on a surface, plus the parameter range that
76/// matches the pcurve's own domain fraction for fraction.
77#[derive(Clone, Debug)]
78pub struct ImageCurve {
79    pub curve: NurbsCurve,
80    /// Curve parameter at the pcurve's domain START. May exceed `t1`.
81    pub t0: f64,
82    /// Curve parameter at the pcurve's domain END.
83    pub t1: f64,
84    pub tier: ImageCurveTier,
85    /// Measured `max ‖curve(t) − surface(pcurve(q))‖` over the off-node sweep.
86    pub deviation: f64,
87}
88
89/// Number of off-node samples used to verify an exact/iso tier, matching
90/// OCCT's `THE_ISOLINE_CHECK_SEGMENTS` (`GeomLib.cxx:131`) density floor.
91const VERIFY_SAMPLES: usize = 24;
92
93/// Off-node probes per interval in the approximation loop.  Five interior
94/// fractions at `k/6` never coincide with an interval end or midpoint, so a
95/// bisection always lands on a previously unprobed parameter.
96const PROBES_PER_INTERVAL: usize = 5;
97
98/// Refinement rounds before the approximation tier gives up.
99const MAX_ROUNDS: usize = 14;
100
101/// Sample ceiling for the approximation tier.
102const MAX_SAMPLES: usize = 4096;
103
104/// Exact 3D image of a pcurve on an AFFINE sheet: an affine map applied to
105/// the homogeneous control points commutes with the rational evaluation, so
106/// the image shares the pcurve's degree / knots / weights and is
107/// parametrized identically — a rational circle pcurve maps to the exact 3D
108/// circle.
109pub fn affine_image_curve(
110    sheet: &NurbsSurface,
111    pcurve: &NurbsCurve,
112) -> Result<NurbsCurve, String> {
113    let [u0, _] = sheet.domain_u()?;
114    let [v0, _] = sheet.domain_v()?;
115    let frame = sheet.derivatives(u0, v0, 1)?;
116    let origin = frame[0][0];
117    let du = frame[1][0];
118    let dv = frame[0][1];
119    let control_points = pcurve
120        .control_points
121        .iter()
122        .map(|control| {
123            let position = origin
124                .scale(control.w)
125                .add(du.scale(control.x - control.w * u0))
126                .add(dv.scale(control.y - control.w * v0));
127            Vec4 {
128                x: position.x,
129                y: position.y,
130                z: position.z,
131                w: control.w,
132            }
133        })
134        .collect();
135    NurbsCurve::new(pcurve.degree, pcurve.knots.clone(), control_points)
136}
137
138/// The composed curve-on-surface `q ↦ S(p(q))`.
139///
140/// `evaluate_extended` matches what the validator's `coedge_sample` does: a
141/// pcurve that grazes or straddles a periodic seam carries parameters just past
142/// the domain, and the surface WRAPS there.  Clamping instead would read as a
143/// gross deviation and turn a legitimate seam-straddling trim into a refusal.
144fn compose(surface: &NurbsSurface, pcurve: &NurbsCurve, q: f64) -> Result<Vec3, String> {
145    let uv = pcurve.evaluate(q)?;
146    surface.evaluate_extended(uv.x, uv.y)
147}
148
149/// Worst `‖curve(t0 + (t1−t0)·f) − S(p(q0 + (q1−q0)·f))‖` over `count`
150/// interior fractions, none of which is an endpoint.
151fn sweep_deviation(
152    surface: &NurbsSurface,
153    pcurve: &NurbsCurve,
154    curve: &NurbsCurve,
155    t0: f64,
156    t1: f64,
157    count: usize,
158) -> Result<f64, String> {
159    let [q0, q1] = pcurve.domain()?;
160    let mut worst = 0.0f64;
161    for index in 0..=count {
162        let fraction = index as f64 / count as f64;
163        let target = compose(surface, pcurve, q0 + (q1 - q0) * fraction)?;
164        let value = curve.evaluate(t0 + (t1 - t0) * fraction)?;
165        worst = worst.max(value.sub(target).length());
166    }
167    Ok(worst)
168}
169
170/// Is `pcurve` a constant-`u` or constant-`v` line whose varying coordinate is
171/// AFFINE in the curve parameter?  Returns `(constant is u, varying value at
172/// q0, varying value at q1)`.
173///
174/// Both halves matter.  Constancy alone picks the iso-curve; affinity is what
175/// makes the iso-curve's own parameter a fraction-for-fraction stand-in for the
176/// pcurve parameter.  The check is structural first (degree 1, two poles, equal
177/// weights — exactly linear, the shape a boolean's straight trim and a seam
178/// both take) and sampled second, so a degree-elevated but geometrically linear
179/// pcurve is still recognised.
180fn iso_line(pcurve: &NurbsCurve, eps_u: f64, eps_v: f64) -> Result<Option<(bool, f64, f64)>, String> {
181    let [q0, q1] = pcurve.domain()?;
182    let first = pcurve.evaluate(q0)?;
183    let last = pcurve.evaluate(q1)?;
184    let constant_u = (first.x - last.x).abs() <= eps_u;
185    let constant_v = (first.y - last.y).abs() <= eps_v;
186    // A pcurve constant in BOTH directions is a point; there is no iso-curve to
187    // extract and no direction to trim along.
188    if constant_u == constant_v {
189        return Ok(None);
190    }
191    let span = q1 - q0;
192    for index in 1..8 {
193        let fraction = index as f64 / 8.0;
194        let uv = pcurve.evaluate(q0 + span * fraction)?;
195        let (held, varying, expected, eps_held, eps_vary) = if constant_u {
196            (
197                uv.x - first.x,
198                uv.y,
199                first.y + (last.y - first.y) * fraction,
200                eps_u,
201                eps_v,
202            )
203        } else {
204            (
205                uv.y - first.y,
206                uv.x,
207                first.x + (last.x - first.x) * fraction,
208                eps_v,
209                eps_u,
210            )
211        };
212        if held.abs() > eps_held || (varying - expected).abs() > eps_vary {
213            return Ok(None);
214        }
215    }
216    Ok(Some(if constant_u {
217        (true, first.y, last.y)
218    } else {
219        (false, first.x, last.x)
220    }))
221}
222
223/// The constant coordinate an iso extraction should be taken at.
224fn iso_constant(pcurve: &NurbsCurve, constant_u: bool) -> Result<f64, String> {
225    let [q0, q1] = pcurve.domain()?;
226    let first = pcurve.evaluate(q0)?;
227    let last = pcurve.evaluate(q1)?;
228    Ok(if constant_u {
229        0.5 * (first.x + last.x)
230    } else {
231        0.5 * (first.y + last.y)
232    })
233}
234
235/// Parametric detection bands for [`iso_line`], derived from the surface's own
236/// domain so a `[0,1]²` patch and a `[0,2π]²` torus are judged alike.
237fn iso_epsilons(surface: &NurbsSurface) -> Result<(f64, f64), String> {
238    let [u0, u1] = surface.domain_u()?;
239    let [v0, v1] = surface.domain_v()?;
240    Ok((1e-7 * (u1 - u0).abs(), 1e-7 * (v1 - v0).abs()))
241}
242
243/// Seed parameters for the approximation tier: the pcurve's ends, its distinct
244/// interior knots (where the composed curve's smoothness breaks — the cheap
245/// analogue of OCCT's C2/C3 discontinuity split at `GeomLib.cxx:1035-1042`) and
246/// a uniform floor so a single-span pcurve still starts with real structure.
247fn seed_parameters(pcurve: &NurbsCurve) -> Result<Vec<f64>, String> {
248    let [q0, q1] = pcurve.domain()?;
249    let span = q1 - q0;
250    let mut parameters = vec![q0, q1];
251    for knot in &pcurve.knots {
252        if *knot > q0 && *knot < q1 {
253            parameters.push(*knot);
254        }
255    }
256    for index in 1..8 {
257        parameters.push(q0 + span * index as f64 / 8.0);
258    }
259    parameters.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
260    parameters.dedup_by(|a, b| (*a - *b).abs() <= span.abs() * 1e-9);
261    Ok(parameters)
262}
263
264/// Fit the composed curve at `parameters`, then measure it strictly BETWEEN
265/// them.  Returns the fitted curve, the worst off-node deviation, and the
266/// per-interval worst deviations (one shorter than `parameters`).
267fn fit_and_measure(
268    surfaces: &[&NurbsSurface],
269    pcurve: &NurbsCurve,
270    parameters: &[f64],
271    which: usize,
272) -> Result<(NurbsCurve, f64, Vec<f64>), String> {
273    let surface = surfaces[which];
274    let points = parameters
275        .iter()
276        .map(|q| compose(surface, pcurve, *q))
277        .collect::<Result<Vec<_>, String>>()?;
278    let degree = 3.min(points.len() - 1);
279    let curve = interpolate_curve(&points, degree, parameters)?;
280    let mut worst = 0.0f64;
281    let mut per_interval = Vec::with_capacity(parameters.len() - 1);
282    for window in parameters.windows(2) {
283        let (a, b) = (window[0], window[1]);
284        let mut local = 0.0f64;
285        for probe in 1..=PROBES_PER_INTERVAL {
286            let q = a + (b - a) * probe as f64 / (PROBES_PER_INTERVAL + 1) as f64;
287            let target = compose(surface, pcurve, q)?;
288            local = local.max(curve.evaluate(q)?.sub(target).length());
289        }
290        worst = worst.max(local);
291        per_interval.push(local);
292    }
293    Ok((curve, worst, per_interval))
294}
295
296/// The approximation tier, shared by the single- and paired-surface entry
297/// points.  Every surface in `surfaces` is fitted over ONE parameter set, so
298/// the results share degree, knots and (unit) weights — the basis identity
299/// `thicken`'s `ruled_wall` requires of a bottom/top image pair.
300fn approximate(
301    surfaces: &[&NurbsSurface],
302    pcurve: &NurbsCurve,
303    tolerance: f64,
304    site: &str,
305) -> Result<Vec<ImageCurve>, String> {
306    let [q0, q1] = pcurve.domain()?;
307    let mut parameters = seed_parameters(pcurve)?;
308    let mut best = f64::INFINITY;
309    for _ in 0..MAX_ROUNDS {
310        let mut fits = Vec::with_capacity(surfaces.len());
311        let mut worst = 0.0f64;
312        let mut per_interval = vec![0.0f64; parameters.len() - 1];
313        for which in 0..surfaces.len() {
314            let (curve, sheet_worst, sheet_intervals) =
315                fit_and_measure(surfaces, pcurve, &parameters, which)?;
316            worst = worst.max(sheet_worst);
317            for (slot, value) in per_interval.iter_mut().zip(&sheet_intervals) {
318                *slot = slot.max(*value);
319            }
320            fits.push(curve);
321        }
322        best = best.min(worst);
323        if worst <= tolerance {
324            return Ok(fits
325                .into_iter()
326                .map(|curve| ImageCurve {
327                    curve,
328                    t0: q0,
329                    t1: q1,
330                    tier: ImageCurveTier::Approximated,
331                    deviation: worst,
332                })
333                .collect());
334        }
335        // Bisect every interval that missed the bar. Refining only the single
336        // worst interval converges linearly in the number of fits; refining all
337        // failing intervals halves the whole failing region per round.
338        let mut refined = Vec::with_capacity(parameters.len() * 2);
339        for (index, window) in parameters.windows(2).enumerate() {
340            refined.push(window[0]);
341            if per_interval[index] > tolerance {
342                refined.push(0.5 * (window[0] + window[1]));
343            }
344        }
345        refined.push(parameters[parameters.len() - 1]);
346        if refined.len() == parameters.len() || refined.len() > MAX_SAMPLES {
347            break;
348        }
349        parameters = refined;
350    }
351    Err(format!(
352        "{site}: the 3D image of a general pcurve could not be fitted to tolerance \
353         (worst off-node deviation {best:.3e} > {tolerance:.3e} after {} samples) — refusing",
354        parameters.len()
355    ))
356}
357
358/// Build the 3D image of an ARBITRARY pcurve on `surface`.
359///
360/// See the module docs for the tier ladder and the parametrisation contract.
361/// `tolerance` is the caller's own spatial accuracy bar for a committed edge;
362/// it is the acceptance criterion for every tier, and the approximation tier
363/// refuses rather than returning a fit that misses it.  `site` names the caller
364/// in the refusal message.
365pub fn image_curve(
366    surface: &NurbsSurface,
367    pcurve: &NurbsCurve,
368    tolerance: f64,
369    site: &str,
370) -> Result<ImageCurve, String> {
371    let [q0, q1] = pcurve.domain()?;
372    if !(q1 - q0).is_finite() || (q1 - q0).abs() <= 0.0 {
373        return Err(format!("{site}: pcurve has an empty parameter domain"));
374    }
375
376    if surface.is_affine()? {
377        let curve = affine_image_curve(surface, pcurve)?;
378        let deviation = sweep_deviation(surface, pcurve, &curve, q0, q1, VERIFY_SAMPLES)?;
379        if deviation <= tolerance {
380            return Ok(ImageCurve {
381                curve,
382                t0: q0,
383                t1: q1,
384                tier: ImageCurveTier::Affine,
385                deviation,
386            });
387        }
388    }
389
390    let (eps_u, eps_v) = iso_epsilons(surface)?;
391    if let Some((constant_u, start, end)) = iso_line(pcurve, eps_u, eps_v)? {
392        let constant = iso_constant(pcurve, constant_u)?;
393        let curve = if constant_u {
394            surface.iso_curve_u(constant)?
395        } else {
396            surface.iso_curve_v(constant)?
397        };
398        let deviation = sweep_deviation(surface, pcurve, &curve, start, end, VERIFY_SAMPLES)?;
399        if deviation <= tolerance {
400            return Ok(ImageCurve {
401                curve,
402                t0: start,
403                t1: end,
404                tier: ImageCurveTier::Iso,
405                deviation,
406            });
407        }
408    }
409
410    Ok(approximate(&[surface], pcurve, tolerance, site)?
411        .pop()
412        .expect("one surface in, one image out"))
413}
414
415/// The images of ONE pcurve on TWO surfaces, guaranteed to share a basis.
416///
417/// `thicken`'s side wall is `ruled_wall(bottom, top)`, which refuses unless the
418/// two boundary curves agree in degree, knots and weights.  Fitting the two
419/// sheets independently would let their adaptive sample sets diverge, so the
420/// approximation tier fits both over ONE parameter set refined against the
421/// worse of the two.  The tier is chosen once and applies to both.
422pub fn image_curve_pair(
423    first: &NurbsSurface,
424    second: &NurbsSurface,
425    pcurve: &NurbsCurve,
426    tolerance: f64,
427    site: &str,
428) -> Result<(ImageCurve, ImageCurve), String> {
429    let [q0, q1] = pcurve.domain()?;
430    if !(q1 - q0).is_finite() || (q1 - q0).abs() <= 0.0 {
431        return Err(format!("{site}: pcurve has an empty parameter domain"));
432    }
433
434    if first.is_affine()? && second.is_affine()? {
435        let a = affine_image_curve(first, pcurve)?;
436        let b = affine_image_curve(second, pcurve)?;
437        let deviation = sweep_deviation(first, pcurve, &a, q0, q1, VERIFY_SAMPLES)?
438            .max(sweep_deviation(second, pcurve, &b, q0, q1, VERIFY_SAMPLES)?);
439        if deviation <= tolerance {
440            return Ok((
441                ImageCurve {
442                    curve: a,
443                    t0: q0,
444                    t1: q1,
445                    tier: ImageCurveTier::Affine,
446                    deviation,
447                },
448                ImageCurve {
449                    curve: b,
450                    t0: q0,
451                    t1: q1,
452                    tier: ImageCurveTier::Affine,
453                    deviation,
454                },
455            ));
456        }
457    }
458
459    let (eps_u, eps_v) = iso_epsilons(first)?;
460    if let Some((constant_u, start, end)) = iso_line(pcurve, eps_u, eps_v)? {
461        let constant = iso_constant(pcurve, constant_u)?;
462        let (a, b) = if constant_u {
463            (first.iso_curve_u(constant)?, second.iso_curve_u(constant)?)
464        } else {
465            (first.iso_curve_v(constant)?, second.iso_curve_v(constant)?)
466        };
467        let deviation = sweep_deviation(first, pcurve, &a, start, end, VERIFY_SAMPLES)?
468            .max(sweep_deviation(second, pcurve, &b, start, end, VERIFY_SAMPLES)?);
469        if deviation <= tolerance {
470            return Ok((
471                ImageCurve {
472                    curve: a,
473                    t0: start,
474                    t1: end,
475                    tier: ImageCurveTier::Iso,
476                    deviation,
477                },
478                ImageCurve {
479                    curve: b,
480                    t0: start,
481                    t1: end,
482                    tier: ImageCurveTier::Iso,
483                    deviation,
484                },
485            ));
486        }
487    }
488
489    let mut images = approximate(&[first, second], pcurve, tolerance, site)?;
490    let second_image = images.pop().expect("two surfaces in, two images out");
491    let first_image = images.pop().expect("two surfaces in, two images out");
492    Ok((first_image, second_image))
493}
494
495#[cfg(test)]
496mod tests {
497    use super::*;
498    use crate::curve::make_line;
499    use crate::surface::{make_cylinder_surface, make_plane, make_sphere_surface};
500
501    fn z() -> Vec3 {
502        Vec3::new(0.0, 0.0, 1.0)
503    }
504
505    /// A pcurve running diagonally across the parameter rectangle — the shape
506    /// every one of the four sites used to refuse.
507    fn diagonal(u0: f64, v0: f64, u1: f64, v1: f64) -> NurbsCurve {
508        make_line(Vec3::new(u0, v0, 0.0), Vec3::new(u1, v1, 0.0)).unwrap()
509    }
510
511    #[test]
512    fn affine_tier_is_exact_for_a_general_pcurve() {
513        let plane = make_plane(
514            Vec3::new(1.0, 2.0, 3.0),
515            Vec3::new(1.0, 0.0, 0.0),
516            Vec3::new(0.0, 1.0, 0.0),
517            4.0,
518            5.0,
519        )
520        .unwrap();
521        let image = image_curve(&plane, &diagonal(0.1, 0.2, 0.8, 0.9), 1e-9, "test").unwrap();
522        assert_eq!(image.tier, ImageCurveTier::Affine);
523        assert!(image.deviation <= 1e-12, "{}", image.deviation);
524    }
525
526    #[test]
527    fn iso_tier_is_taken_for_a_constant_u_line() {
528        let cylinder =
529            make_cylinder_surface(Vec3::new(0.0, 0.0, 0.0), Vec3::new(0.0, 0.0, 1.0), 3.0, 10.0)
530                .unwrap();
531        let [u0, u1] = cylinder.domain_u().unwrap();
532        let [v0, v1] = cylinder.domain_v().unwrap();
533        let u = 0.5 * (u0 + u1);
534        let pcurve = diagonal(u, v0 + 0.1 * (v1 - v0), u, v0 + 0.7 * (v1 - v0));
535        let image = image_curve(&cylinder, &pcurve, 1e-9, "test").unwrap();
536        assert_eq!(image.tier, ImageCurveTier::Iso);
537        assert!(image.deviation <= 1e-9, "{}", image.deviation);
538    }
539
540    /// A constant-`u` pcurve whose `v` is NOT affine in the curve parameter:
541    /// the iso-curve is still the right locus, but the iso tier's fraction
542    /// contract breaks.  The self-verification must catch it and fall through.
543    #[test]
544    fn a_nonlinearly_parametrised_iso_pcurve_falls_through_to_the_general_tier() {
545        let cylinder =
546            make_cylinder_surface(Vec3::new(0.0, 0.0, 0.0), Vec3::new(0.0, 0.0, 1.0), 3.0, 10.0)
547                .unwrap();
548        let [u0, u1] = cylinder.domain_u().unwrap();
549        let [v0, v1] = cylinder.domain_v().unwrap();
550        let u = 0.5 * (u0 + u1);
551        // Three collinear poles with a lopsided middle: same locus, quadratic
552        // parametrisation.
553        let pcurve = NurbsCurve::new(
554            2,
555            vec![0.0, 0.0, 0.0, 1.0, 1.0, 1.0],
556            vec![
557                Vec4::from_point(Vec3::new(u, v0, 0.0), 1.0),
558                Vec4::from_point(Vec3::new(u, v0 + 0.9 * (v1 - v0), 0.0), 1.0),
559                Vec4::from_point(Vec3::new(u, v1, 0.0), 1.0),
560            ],
561        )
562        .unwrap();
563        let image = image_curve(&cylinder, &pcurve, 1e-6, "test").unwrap();
564        assert_eq!(image.tier, ImageCurveTier::Approximated);
565        assert!(image.deviation <= 1e-6, "{}", image.deviation);
566    }
567
568    #[test]
569    fn general_tier_fits_a_diagonal_pcurve_on_a_cylinder() {
570        let cylinder =
571            make_cylinder_surface(Vec3::new(0.0, 0.0, 0.0), Vec3::new(0.0, 0.0, 1.0), 3.0, 10.0)
572                .unwrap();
573        let [u0, u1] = cylinder.domain_u().unwrap();
574        let [v0, v1] = cylinder.domain_v().unwrap();
575        let pcurve = diagonal(
576            u0 + 0.1 * (u1 - u0),
577            v0 + 0.1 * (v1 - v0),
578            u0 + 0.8 * (u1 - u0),
579            v0 + 0.9 * (v1 - v0),
580        );
581        let image = image_curve(&cylinder, &pcurve, 1e-6, "test").unwrap();
582        assert_eq!(image.tier, ImageCurveTier::Approximated);
583        assert!(image.deviation <= 1e-6, "{}", image.deviation);
584        // The contract, checked independently of the tier's own sweep.
585        let [q0, q1] = pcurve.domain().unwrap();
586        for index in 0..=37 {
587            let fraction = index as f64 / 37.0;
588            let uv = pcurve.evaluate(q0 + (q1 - q0) * fraction).unwrap();
589            let target = cylinder.evaluate(uv.x, uv.y).unwrap();
590            let value = image
591                .curve
592                .evaluate(image.t0 + (image.t1 - image.t0) * fraction)
593                .unwrap();
594            assert!(value.sub(target).length() <= 1e-6);
595        }
596    }
597
598    /// The torus carrier in its own right: a diagonal pcurve on an EXACT
599    /// rational torus is doubly curved and rationally parametrised, the hardest
600    /// composed curve the four sites can hand the ladder.
601    #[test]
602    fn general_tier_fits_a_diagonal_pcurve_on_an_exact_torus() {
603        let torus =
604            crate::surface::make_torus_surface(Vec3::new(0.0, 0.0, 0.0), z(), 6.0, 2.0).unwrap();
605        let [u0, u1] = torus.domain_u().unwrap();
606        let [v0, v1] = torus.domain_v().unwrap();
607        let pcurve = diagonal(
608            u0 + 0.15 * (u1 - u0),
609            v0 + 0.10 * (v1 - v0),
610            u0 + 0.85 * (u1 - u0),
611            v0 + 0.80 * (v1 - v0),
612        );
613        let image = image_curve(&torus, &pcurve, 1e-6, "test").unwrap();
614        assert_eq!(image.tier, ImageCurveTier::Approximated);
615        assert!(image.deviation <= 1e-6, "{}", image.deviation);
616        // Every point of the image must sit on the exact torus: distance from
617        // the tube centre circle equals the minor radius.
618        let [t0, t1] = image.curve.domain().unwrap();
619        for index in 0..=53 {
620            let point = image
621                .curve
622                .evaluate(t0 + (t1 - t0) * index as f64 / 53.0)
623                .unwrap();
624            let radial = (point.x * point.x + point.y * point.y).sqrt();
625            let tube = ((radial - 6.0).powi(2) + point.z * point.z).sqrt();
626            assert!((tube - 2.0).abs() <= 1e-6, "off the torus by {}", tube - 2.0);
627        }
628    }
629
630    #[test]
631    fn general_tier_refuses_with_the_measured_deviation_when_the_bar_is_unreachable() {
632        let sphere = make_sphere_surface(Vec3::new(0.0, 0.0, 0.0), 5.0, Vec3::new(0.0, 0.0, 1.0)).unwrap();
633        let [u0, u1] = sphere.domain_u().unwrap();
634        let [v0, v1] = sphere.domain_v().unwrap();
635        let pcurve = diagonal(
636            u0 + 0.05 * (u1 - u0),
637            v0 + 0.05 * (v1 - v0),
638            u0 + 0.95 * (u1 - u0),
639            v0 + 0.95 * (v1 - v0),
640        );
641        // A bar below double precision on a 5 mm sphere cannot be met.
642        let error = image_curve(&sphere, &pcurve, 1e-18, "unit").unwrap_err();
643        assert!(error.starts_with("unit: "), "{error}");
644        assert!(error.contains("worst off-node deviation"), "{error}");
645        assert!(error.contains("refusing"), "{error}");
646    }
647
648    #[test]
649    fn a_paired_image_shares_one_basis() {
650        let inner =
651            make_cylinder_surface(Vec3::new(0.0, 0.0, 0.0), Vec3::new(0.0, 0.0, 1.0), 3.0, 10.0)
652                .unwrap();
653        let outer =
654            make_cylinder_surface(Vec3::new(0.0, 0.0, 0.0), Vec3::new(0.0, 0.0, 1.0), 3.5, 10.0)
655                .unwrap();
656        let [u0, u1] = inner.domain_u().unwrap();
657        let [v0, v1] = inner.domain_v().unwrap();
658        let pcurve = diagonal(
659            u0 + 0.1 * (u1 - u0),
660            v0 + 0.1 * (v1 - v0),
661            u0 + 0.8 * (u1 - u0),
662            v0 + 0.9 * (v1 - v0),
663        );
664        let (a, b) = image_curve_pair(&inner, &outer, &pcurve, 1e-6, "test").unwrap();
665        assert_eq!(a.tier, ImageCurveTier::Approximated);
666        assert_eq!(a.curve.degree, b.curve.degree);
667        assert_eq!(a.curve.knots, b.curve.knots);
668        assert_eq!(
669            a.curve.control_points.len(),
670            b.curve.control_points.len()
671        );
672        for (first, second) in a.curve.control_points.iter().zip(&b.curve.control_points) {
673            assert!((first.w - second.w).abs() <= 1e-12);
674        }
675    }
676}