Skip to main content

ogeom_fillet/
march.rs

1//! The marching blend: a rolling ball followed by solving where it touches.
2//!
3//! The analytic seats (an edge between two planes, a plane and a cylinder in
4//! the configurations that give a torus) are built from their own closed
5//! forms elsewhere. This is the general one.
6//!
7//! # Four unknowns, four equations
8//!
9//! Intersecting the two supports' *offset* surfaces for the spine, projecting
10//! back onto each support for the tangency points and skinning the arcs
11//! between them leaves the legs' pcurves **fitted**, and a fitted pcurve on a
12//! support is exactly what the boolean cannot treat as same-domain later.
13//!
14//! So the section's two endpoints are solved for directly. The unknowns are
15//! `(u₁, v₁)` on the first support and `(u₂, v₂)` on the second: the two
16//! points where the ball touches. Three equations say the ball's centre is the
17//! same point computed from either side,
18//!
19//! > `P₁ + r·n₁ = P₂ + r·n₂`
20//!
21//! and the fourth ties the section to a **guide** (the edge being blended, or
22//! any curve running along the seat) by requiring it to lie in the plane
23//! through the guide point normal to the guide's tangent.
24//!
25//! The tangency curves then emerge **in the supports' own parameters**, so
26//! the legs' pcurves are exact by construction rather than fitted.
27//!
28//! # Marched by the shared walker
29//!
30//! The guide's parameter joins the unknowns as a fifth, which makes the system
31//! four equations in five (a curve) and that is what
32//! [`ogeom_intersect::walk`] follows. So the step control, the stall reporting
33//! and the closure test are the intersector's own, inherited rather than
34//! written a second time, and the step is set by the sag of the *tangency
35//! curve* it is walking.
36
37use ogeom_core::{OgeomResult, Tolerances, ogeom_bail};
38use ogeom_geom::{Curve, Curve3d as _, Surface as _, SurfaceGeometry};
39use ogeom_intersect::walk::Condition;
40use ogeom_intersect::{Marching, Stopped};
41use ogeom_math::{Point, Vector, solve};
42
43use crate::law::RadiusAlong;
44
45/// Which side of each support the ball rolls on.
46///
47/// A rolling ball sits at `P + s·r·n` for one sign `s` per support: outward
48/// for a convex seat, inward for a concave one, and one of each where the
49/// blend runs along a step. The pair is not guessed from the normals (
50/// normals cannot tell a step from a slot) but tried, and the combination
51/// that gives a ball genuinely touching both is the seat.
52#[derive(Debug, Clone, Copy, PartialEq, Eq)]
53pub struct Sides {
54    /// The sign on the first support's normal.
55    pub first: i8,
56    /// The sign on the second's.
57    pub second: i8,
58}
59
60impl Sides {
61    /// Every combination, for a caller that does not know the seat.
62    const ALL: [Self; 4] = [
63        Self {
64            first: 1,
65            second: 1,
66        },
67        Self {
68            first: 1,
69            second: -1,
70        },
71        Self {
72            first: -1,
73            second: 1,
74        },
75        Self {
76            first: -1,
77            second: -1,
78        },
79    ];
80}
81
82/// Why a marched blend stopped.
83///
84/// Each reason is a different thing for a caller to do about it.
85#[derive(Debug, Clone, Copy, PartialEq, Eq)]
86pub enum BlendStop {
87    /// The seat closed on itself: a blend all the way round a rim.
88    Closed,
89    /// The section reached the boundary of the first support.
90    LeftTheFirstSupport,
91    /// Of the second.
92    LeftTheSecondSupport,
93    /// Of both at once, which is a corner rather than a run-out.
94    LeftBothSupports,
95    /// The guide ran out before either support did.
96    RanPastTheGuide,
97    /// The two tangency points collapsed onto each other: the radius is too
98    /// large for the local geometry and the ball is not seated but wedged.
99    SectionCollapsed,
100    /// The correction stopped converging: a tangency or a singular point on
101    /// one of the supports.
102    Stalled,
103    /// The step ceiling, which means the answer is *incomplete* rather than
104    /// finished.
105    RanOut,
106}
107
108/// One marched blend: where the ball touched, and where its centre went.
109#[derive(Debug, Clone)]
110pub struct MarchedBlend {
111    /// The ball's centre at each station: the blend's spine.
112    pub spine: Vec<Point>,
113    /// Where it touched the first support, in that support's own parameters.
114    ///
115    /// Solved for, not projected, so a pcurve fitted through them is a
116    /// pcurve of the curve itself rather than of a projection of it.
117    pub on_first: Vec<(f64, f64)>,
118    /// And the second's.
119    pub on_second: Vec<(f64, f64)>,
120    /// The touch points on the first support, in space.
121    pub touch_first: Vec<Point>,
122    /// And on the second.
123    pub touch_second: Vec<Point>,
124    /// The guide parameter at each station: where along the seat the
125    /// section stands. An open blend's run-out is built from its ends.
126    pub along: Vec<f64>,
127    /// Which side of each support the ball rolled on.
128    pub sides: Sides,
129    /// Why it stopped.
130    pub stopped: BlendStop,
131}
132
133impl MarchedBlend {
134    /// How many stations the march produced.
135    #[must_use]
136    pub fn len(&self) -> usize {
137        self.spine.len()
138    }
139
140    /// Whether it produced none.
141    #[must_use]
142    pub fn is_empty(&self) -> bool {
143        self.spine.is_empty()
144    }
145
146    /// Whether the march finished rather than being truncated.
147    #[must_use]
148    pub const fn complete(&self) -> bool {
149        !matches!(self.stopped, BlendStop::RanOut)
150    }
151}
152
153/// Follow a rolling ball of `radius` seated between two supports, guided by
154/// `guide`.
155///
156/// The guide is the curve the sections stand square to: the edge being
157/// blended, or any curve running along the seat. It decides *where* the
158/// sections are, not what they are: the ball's own contact conditions decide
159/// that, and a guide that is merely near the seat gives the same blend as one
160/// exactly on it.
161///
162/// # Errors
163///
164/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if the
165/// radius or the settings are unusable.
166/// [`OgeomError::NotDone`](ogeom_core::OgeomError::NotDone) if no seating of
167/// the ball can be found at the guide's start, which is the honest answer for
168/// a radius the corner cannot hold, and names that rather than marching a
169/// system that is not solved.
170pub fn march_blend(
171    first: &SurfaceGeometry,
172    second: &SurfaceGeometry,
173    radius: f64,
174    guide: &Curve,
175    options: Marching,
176    tol: Tolerances,
177) -> OgeomResult<MarchedBlend> {
178    march_blend_on(
179        first,
180        second,
181        radius,
182        None,
183        guide,
184        &Sides::ALL,
185        None,
186        options,
187        tol,
188    )
189}
190
191/// As [`march_blend`], with the ball's sides named by the caller.
192///
193/// The four seatings are four different balls (a fillet's rides the
194/// material's own side of each support, a carving ball the opposite) and a
195/// caller that knows its seat should say so rather than take whichever
196/// converges first.
197///
198/// # Errors
199///
200/// As [`march_blend`].
201pub fn march_blend_sided(
202    first: &SurfaceGeometry,
203    second: &SurfaceGeometry,
204    radius: f64,
205    guide: &Curve,
206    sides: Sides,
207    options: Marching,
208    tol: Tolerances,
209) -> OgeomResult<MarchedBlend> {
210    march_blend_on(
211        first,
212        second,
213        radius,
214        None,
215        guide,
216        &[sides],
217        None,
218        options,
219        tol,
220    )
221}
222
223/// As [`march_blend_sided`], seated at the caller's own guide parameter.
224///
225/// The default seat solves at the guide domain's midpoint, which serves a
226/// guide that runs along the seat end to end. A caller whose guide is a
227/// reconstructed loop (a conic arc re-opened to its full period) knows the
228/// loop runs through territory that is not seat at all, and names a
229/// parameter that is: the walker still covers the whole loop from wherever
230/// it starts.
231///
232/// # Errors
233///
234/// As [`march_blend`].
235#[allow(clippy::too_many_arguments, reason = "one construction, all its data")]
236pub fn march_blend_seeded(
237    first: &SurfaceGeometry,
238    second: &SurfaceGeometry,
239    radius: f64,
240    guide: &Curve,
241    sides: Sides,
242    seed: f64,
243    options: Marching,
244    tol: Tolerances,
245) -> OgeomResult<MarchedBlend> {
246    march_blend_on(
247        first,
248        second,
249        radius,
250        None,
251        guide,
252        &[sides],
253        Some(seed),
254        options,
255        tol,
256    )
257}
258
259/// As [`march_blend_seeded`], the ball's radius following `law` along the
260/// guide: each section's ball is the law's at the section's guide
261/// parameter. `radius` is the law's largest, the scale a stop is read at.
262#[allow(clippy::too_many_arguments, reason = "one construction, all its data")]
263pub(crate) fn march_blend_law(
264    first: &SurfaceGeometry,
265    second: &SurfaceGeometry,
266    law: &RadiusAlong,
267    guide: &Curve,
268    sides: Sides,
269    seed: f64,
270    options: Marching,
271    tol: Tolerances,
272) -> OgeomResult<MarchedBlend> {
273    march_blend_on(
274        first,
275        second,
276        law.largest(),
277        Some(law),
278        guide,
279        &[sides],
280        Some(seed),
281        options,
282        tol,
283    )
284}
285
286#[allow(clippy::too_many_arguments, reason = "one construction, all its data")]
287fn march_blend_on(
288    first: &SurfaceGeometry,
289    second: &SurfaceGeometry,
290    radius: f64,
291    law: Option<&RadiusAlong>,
292    guide: &Curve,
293    candidates: &[Sides],
294    seed: Option<f64>,
295    options: Marching,
296    tol: Tolerances,
297) -> OgeomResult<MarchedBlend> {
298    if !radius.is_finite() || radius <= tol.confusion() {
299        ogeom_bail!(Construction, "a blend of radius {radius} rounds nothing");
300    }
301    options.validate()?;
302    let (start, sides) = seat(
303        first, second, radius, law, guide, candidates, seed, None, tol,
304    )?;
305    let guide_loops = guide.is_periodic() || {
306        let (lo, hi) = guide.domain();
307        guide
308            .point_at(lo, tol)
309            .and_then(|p| guide.point_at(hi, tol).map(|q| p.distance(q)))
310            .is_ok_and(|d| d <= tol.confusion() * 10.0)
311    };
312    let contact = BallContact {
313        first,
314        second,
315        radius,
316        law,
317        guide,
318        sides,
319        guide_loops,
320    };
321    let walked = ogeom_intersect::walk::follow(&contact, &start, options, tol)?;
322
323    let mut blend = MarchedBlend {
324        spine: Vec::with_capacity(walked.states.len()),
325        on_first: Vec::with_capacity(walked.states.len()),
326        on_second: Vec::with_capacity(walked.states.len()),
327        touch_first: Vec::with_capacity(walked.states.len()),
328        touch_second: Vec::with_capacity(walked.states.len()),
329        along: Vec::with_capacity(walked.states.len()),
330        sides,
331        stopped: BlendStop::Stalled,
332    };
333    for x in &walked.states {
334        let (Ok(p1), Ok(p2)) = (
335            first.point_at(x[0], x[1], tol),
336            second.point_at(x[2], x[3], tol),
337        ) else {
338            continue;
339        };
340        let Some(centre) = contact.centre(x, tol) else {
341            continue;
342        };
343        blend.spine.push(centre);
344        blend.on_first.push((x[0], x[1]));
345        blend.on_second.push((x[2], x[3]));
346        blend.touch_first.push(p1);
347        blend.touch_second.push(p2);
348        blend.along.push(x[4]);
349    }
350    blend.stopped = why(&contact, &walked, tol);
351    Ok(blend)
352}
353
354/// Which of the walker's reasons this is, in the blend's own vocabulary.
355fn why(
356    contact: &BallContact<'_>,
357    walked: &ogeom_intersect::walk::Walked,
358    tol: Tolerances,
359) -> BlendStop {
360    let Some(last) = walked.states.last() else {
361        return BlendStop::Stalled;
362    };
363    // A section whose two ends have met is not a section: the ball is wedged
364    // rather than seated, which is what a radius too large for the local
365    // geometry looks like from inside the march.
366    let collapsed = matches!(
367        (
368            contact.first.point_at(last[0], last[1], tol),
369            contact.second.point_at(last[2], last[3], tol),
370        ),
371        (Ok(p1), Ok(p2)) if p1.distance(p2) <= contact.radius * 1e-3
372    );
373    match walked.stopped {
374        Stopped::Closed => BlendStop::Closed,
375        Stopped::RanOut => BlendStop::RanOut,
376        Stopped::Stalled if collapsed => BlendStop::SectionCollapsed,
377        Stopped::Stalled => BlendStop::Stalled,
378        Stopped::LeftTheDomain => {
379            let left_first = at_edge(contact.first, (last[0], last[1]));
380            let left_second = at_edge(contact.second, (last[2], last[3]));
381            match (left_first, left_second) {
382                (true, true) => BlendStop::LeftBothSupports,
383                (true, false) => BlendStop::LeftTheFirstSupport,
384                (false, true) => BlendStop::LeftTheSecondSupport,
385                (false, false) => BlendStop::RanPastTheGuide,
386            }
387        }
388    }
389}
390
391/// The ball's exact section at one guide parameter: the seat solve, held at
392/// `at`. What a run-out cap stands on: the marched stations bracket the
393/// edge's own end, and the cap wants the section exactly there.
394#[allow(clippy::too_many_arguments, reason = "one construction, all its data")]
395pub(crate) fn seat_section(
396    first: &SurfaceGeometry,
397    second: &SurfaceGeometry,
398    radius: f64,
399    guide: &Curve,
400    sides: Sides,
401    at: f64,
402    near: [f64; 4],
403    tol: Tolerances,
404) -> OgeomResult<[f64; 5]> {
405    let (x, _) = seat(
406        first,
407        second,
408        radius,
409        None,
410        guide,
411        &[sides],
412        Some(at),
413        Some(near),
414        tol,
415    )?;
416    Ok(x)
417}
418
419/// Where the ball first sits, and which side of each support it sits on.
420///
421/// Tried rather than assumed: the four sign combinations are each given a
422/// Newton solve from the guide's own start, and the one that seats a ball
423/// touching two *distinct* points wins. A radius the corner cannot hold seats
424/// none of them, and that is what the refusal says.
425#[allow(clippy::too_many_arguments, reason = "one construction, all its data")]
426fn seat(
427    first: &SurfaceGeometry,
428    second: &SurfaceGeometry,
429    radius: f64,
430    law: Option<&RadiusAlong>,
431    guide: &Curve,
432    candidates: &[Sides],
433    seed: Option<f64>,
434    near: Option<[f64; 4]>,
435    tol: Tolerances,
436) -> OgeomResult<([f64; 5], Sides)> {
437    let at = seed.unwrap_or_else(|| {
438        let (lo, hi) = guide.domain();
439        f64::midpoint(lo, hi)
440    });
441    // A caller with a neighbouring station names the basin; a bare
442    // projection can land the Newton on a different seating entirely: the
443    // far side of a drum holds one too.
444    let start = match near {
445        Some(uv) => [uv[0], uv[1], uv[2], uv[3], at],
446        None => {
447            let anchor = guide.point_at(at, tol)?;
448            let near_first = ogeom_algo::project_on_surface(first, anchor, 24, tol)?;
449            let near_second = ogeom_algo::project_on_surface(second, anchor, 24, tol)?;
450            [
451                near_first.parameters.0,
452                near_first.parameters.1,
453                near_second.parameters.0,
454                near_second.parameters.1,
455                at,
456            ]
457        }
458    };
459
460    for sides in candidates.iter().copied() {
461        let contact = BallContact {
462            first,
463            second,
464            radius,
465            law,
466            guide,
467            sides,
468            // The seed holds its guide parameter; whether the guide loops
469            // never comes up here.
470            guide_loops: guide.is_periodic(),
471        };
472        // The guide parameter is held: at the seed there is nothing yet to
473        // march along, only a section to find.
474        let system = |x: &[f64; 4]| {
475            let mut full = [x[0], x[1], x[2], x[3], at];
476            contact.clamp(&mut full);
477            let mut residual = [0.0; 4];
478            let mut jacobian = [[0.0; 4]; 4];
479            if let Some((rows, matrix)) = contact.system(&full, tol) {
480                for (to, from) in residual.iter_mut().zip(&rows) {
481                    *to = *from;
482                }
483                for (to, row) in jacobian.iter_mut().zip(&matrix) {
484                    for (entry, from) in to.iter_mut().zip(row) {
485                        *entry = *from;
486                    }
487                }
488            }
489            (residual, jacobian)
490        };
491        let criteria = solve::Criteria {
492            residual: tol.confusion() * 0.01,
493            step: tol.parametric(),
494            max_iterations: 80,
495        };
496        let seed = [start[0], start[1], start[2], start[3]];
497        let Ok((found, norm, _, _)) = solve::newton_system_fixed(system, seed, criteria) else {
498            continue;
499        };
500        if norm > tol.confusion() {
501            continue;
502        }
503        let mut x = [found[0], found[1], found[2], found[3], at];
504        contact.clamp(&mut x);
505        let (Ok(p1), Ok(p2)) = (
506            first.point_at(x[0], x[1], tol),
507            second.point_at(x[2], x[3], tol),
508        ) else {
509            continue;
510        };
511        // Two distinct touches, and a ball genuinely of the stated radius.
512        if p1.distance(p2) <= radius * 1e-3 {
513            continue;
514        }
515        let Some(centre) = contact.centre(&x, tol) else {
516            continue;
517        };
518        let held = contact.radius_at(at, tol)?.0;
519        if (centre.distance(p1) - held).abs() > tol.confusion() * 10.0
520            || (centre.distance(p2) - held).abs() > tol.confusion() * 10.0
521        {
522            continue;
523        }
524        return Ok((x, sides));
525    }
526    ogeom_bail!(
527        NotDone,
528        "no ball of radius {radius} seats between these supports at the \
529         guide's start; either the corner cannot hold one or the guide does \
530         not run along the seat"
531    );
532}
533
534/// The rolling ball's contact, as a condition the walker can follow.
535struct BallContact<'s> {
536    first: &'s SurfaceGeometry,
537    second: &'s SurfaceGeometry,
538    radius: f64,
539    /// The law the radius follows along the guide, where it varies.
540    law: Option<&'s RadiusAlong>,
541    guide: &'s Curve,
542    sides: Sides,
543    /// Whether the guide comes back to its start, by parameterization or,
544    /// for a fitted seam whose curve is clamped, by geometry. A looping
545    /// guide has no end to stop at; the march wraps its parameter and lets
546    /// the points say when it is back.
547    guide_loops: bool,
548}
549
550impl BallContact<'_> {
551    /// The ball's centre, computed from the first support's side.
552    fn centre(&self, x: &[f64], tol: Tolerances) -> Option<Point> {
553        let p = self.first.point_at(x[0], x[1], tol).ok()?;
554        let n = unit_normal(self.first, x[0], x[1], tol)?;
555        let radius = match self.law {
556            None => self.radius,
557            Some(_) => self.radius_at(self.wrapped(x[4]), tol).ok()?.0,
558        };
559        Some(p + n * (f64::from(self.sides.first) * radius))
560    }
561
562    /// The ball's radius at guide parameter `t`, and its rate per unit `t`.
563    fn radius_at(&self, t: f64, tol: Tolerances) -> OgeomResult<(f64, f64)> {
564        match self.law {
565            None => Ok((self.radius, 0.0)),
566            Some(law) => law.at(t, tol),
567        }
568    }
569
570    /// A guide parameter read on a looping guide's own loop.
571    fn wrapped(&self, t: f64) -> f64 {
572        let (lo, hi) = self.guide.domain();
573        if self.guide_loops && hi > lo {
574            lo + (t - lo).rem_euclid(hi - lo)
575        } else {
576            t
577        }
578    }
579}
580
581impl Condition for BallContact<'_> {
582    fn unknowns(&self) -> usize {
583        5
584    }
585
586    fn position(&self, x: &[f64], tol: Tolerances) -> Option<Point> {
587        // The tangency point on the first support: the curve being walked is
588        // the first leg, so the step control measures *its* sag.
589        self.first.point_at(x[0], x[1], tol).ok()
590    }
591
592    fn position_gradient(&self, x: &[f64], tol: Tolerances) -> Option<Vec<Vector>> {
593        let (du, dv) = self.first.d1_at(x[0], x[1], tol).ok()?;
594        Some(vec![du, dv, Vector::ZERO, Vector::ZERO, Vector::ZERO])
595    }
596
597    fn system(&self, x: &[f64], tol: Tolerances) -> Option<(Vec<f64>, Vec<Vec<f64>>)> {
598        Some(self.system_at(x, tol)?.0)
599    }
600
601    fn system_at(
602        &self,
603        x: &[f64],
604        tol: Tolerances,
605    ) -> Option<((Vec<f64>, Vec<Vec<f64>>), Point, Vec<Vector>)> {
606        // One jet per support: the point, both tangents and the normal's
607        // derivatives all come from the same evaluation.
608        let first = self.first.jet_at(x[0], x[1], tol).ok()?;
609        let second = self.second.jet_at(x[2], x[3], tol).ok()?;
610        let (p1, a1, b1) = (first.point, first.du, first.dv);
611        let (p2, a2, b2) = (second.point, second.du, second.dv);
612        let (n1, dn1u, dn1v) = normal_and_derivatives_of(&first, tol)?;
613        let (n2, dn2u, dn2v) = normal_and_derivatives_of(&second, tol)?;
614        let w = self.wrapped(x[4]);
615        let (radius, rate) = self.radius_at(w, tol).ok()?;
616        let (s1, s2) = (
617            f64::from(self.sides.first) * radius,
618            f64::from(self.sides.second) * radius,
619        );
620        // Where the radius varies, the centre moves with the guide
621        // parameter as well: each side's ball grows along its own normal.
622        let lean = match self.law {
623            None => [0.0; 3],
624            Some(_) => {
625                let d = n1 * (f64::from(self.sides.first) * rate)
626                    - n2 * (f64::from(self.sides.second) * rate);
627                [d.x, d.y, d.z]
628            }
629        };
630
631        // Three: the ball's centre is the same point from either side.
632        let gap = (p1 + n1 * s1) - (p2 + n2 * s2);
633        let c1u = a1 + dn1u * s1;
634        let c1v = b1 + dn1v * s1;
635        let c2u = a2 + dn2u * s2;
636        let c2v = b2 + dn2v * s2;
637
638        // One more: the section stands in the plane through the guide point
639        // square to the guide. Stated with the *unnormalized* tangent, which
640        // is the same plane and a simpler derivative.
641        // A looping guide is evaluated on its loop: the walker's trial step
642        // may propose a parameter a hair past the end, and a fitted loop (
643        // closed, not periodic) refuses it, so the step fails and the march
644        // stalls at the join instead of crossing it.
645        let derivatives = self.guide.derivatives_at(w, 2, tol).ok()?;
646        let g = Point::ORIGIN + derivatives[0];
647        let (gd, gdd) = (derivatives[1], *derivatives.get(2).unwrap_or(&Vector::ZERO));
648        let square = (p1 - g).dot(gd);
649
650        Some((
651            (
652                vec![gap.x, gap.y, gap.z, square],
653                vec![
654                    vec![c1u.x, c1v.x, -c2u.x, -c2v.x, lean[0]],
655                    vec![c1u.y, c1v.y, -c2u.y, -c2v.y, lean[1]],
656                    vec![c1u.z, c1v.z, -c2u.z, -c2v.z, lean[2]],
657                    vec![
658                        a1.dot(gd),
659                        b1.dot(gd),
660                        0.0,
661                        0.0,
662                        (p1 - g).dot(gdd) - gd.dot(gd),
663                    ],
664                ],
665            ),
666            p1,
667            vec![a1, b1, Vector::ZERO, Vector::ZERO, Vector::ZERO],
668        ))
669    }
670
671    fn tangent_from(
672        &self,
673        _x: &[f64],
674        jacobian: &[Vec<f64>],
675        gradient: &[Vector],
676        tol: Tolerances,
677    ) -> Option<Vector> {
678        // No formula of its own: the null space of what the walker's
679        // correction already evaluated.
680        ogeom_intersect::walk::null_tangent(jacobian, gradient, tol)
681    }
682
683    fn clamp(&self, x: &mut [f64]) {
684        let (u1, v1) = clamp_to(self.first, x[0], x[1]);
685        let (u2, v2) = clamp_to(self.second, x[2], x[3]);
686        let (lo, hi) = self.guide.domain();
687        x[0] = u1;
688        x[1] = v1;
689        x[2] = u2;
690        x[3] = v2;
691        // A guide that closes on itself has no end to stop at: the march
692        // wraps its parameter and lets the *geometry* say when it is back
693        // where it started.
694        x[4] = if self.guide_loops && hi > lo {
695            lo + (x[4] - lo).rem_euclid(hi - lo)
696        } else {
697            x[4].clamp(lo, hi)
698        };
699    }
700
701    fn outside(&self, x: &[f64], tol: Tolerances) -> bool {
702        let (lo, hi) = self.guide.domain();
703        let band = tol.parametric();
704        beyond(self.first, (x[0], x[1]), tol)
705            || beyond(self.second, (x[2], x[3]), tol)
706            || (!self.guide_loops && (x[4] < lo - band || x[4] > hi + band))
707    }
708
709    fn near_edge(&self, x: &[f64]) -> bool {
710        let (lo, hi) = self.guide.domain();
711        let reach = (hi - lo) * 1e-6;
712        at_edge(self.first, (x[0], x[1]))
713            || at_edge(self.second, (x[2], x[3]))
714            || (!self.guide_loops && (x[4] <= lo + reach || x[4] >= hi - reach))
715    }
716
717    fn extent(&self) -> f64 {
718        // A *length*, not a parameter span: the guide's parameter may be an
719        // angle, and a step control fed an angle where it wanted a distance
720        // walks a small circle in enormous steps and a large one in tiny ones.
721        let (lo, hi) = self.guide.domain();
722        let mut length = 0.0;
723        let mut previous = None;
724        for i in 0..=16 {
725            let t = (hi - lo).mul_add(f64::from(i) / 16.0, lo);
726            let Ok(p) = self.guide.point_at(t, Tolerances::millimetres()) else {
727                continue;
728            };
729            if let Some(last) = previous {
730                length += p.distance(last);
731            }
732            previous = Some(p);
733        }
734        length.max(self.radius * 8.0)
735    }
736}
737
738/// A surface's unit normal.
739pub(crate) fn unit_normal(
740    surface: &SurfaceGeometry,
741    u: f64,
742    v: f64,
743    tol: Tolerances,
744) -> Option<Vector> {
745    let (du, dv) = surface.d1_at(u, v, tol).ok()?;
746    let cross = du.cross(dv);
747    let length = cross.magnitude();
748    if length <= tol.confusion() {
749        return None;
750    }
751    Some(cross / length)
752}
753
754/// The unit normal and how it turns with each parameter.
755///
756/// Exactly, from the surface's own second derivatives: with `c = Sᵤ × Sᵥ` the
757/// unnormalized normal, `∂n/∂u` is the part of `∂c/∂u` across `n`, over
758/// `|c|`: the projection is what keeps a unit vector unit.
759fn normal_and_derivatives_of(
760    jet: &ogeom_geom::SurfaceJet,
761    tol: Tolerances,
762) -> Option<(Vector, Vector, Vector)> {
763    let (su, sv) = (jet.du, jet.dv);
764    let (suu, suv, svv) = (jet.d2u, jet.duv, jet.d2v);
765    let cross = su.cross(sv);
766    let length = cross.magnitude();
767    if length <= tol.confusion() {
768        return None;
769    }
770    let n = cross / length;
771    let dcu = suu.cross(sv) + su.cross(suv);
772    let dcv = suv.cross(sv) + su.cross(svv);
773    let across = |d: Vector| (d - n * d.dot(n)) / length;
774    Some((n, across(dcu), across(dcv)))
775}
776
777/// Whether a chart direction comes back on itself: periodic, or closed;
778/// a converted cylinder's patch meets itself at its seam without being
779/// periodic, and a march that stopped at that seam would call a wall's
780/// own join a run-out.
781fn wraps(surface: &SurfaceGeometry) -> (bool, bool) {
782    let tol = Tolerances::millimetres();
783    (
784        surface.is_periodic_u() || surface.is_closed_u(tol),
785        surface.is_periodic_v() || surface.is_closed_v(tol),
786    )
787}
788
789/// Hold a parameter pair inside a surface's own domain.
790fn clamp_to(surface: &SurfaceGeometry, u: f64, v: f64) -> (f64, f64) {
791    let ((ua, ub), (va, vb)) = surface.domain();
792    let (wrap_u, wrap_v) = wraps(surface);
793    let hold = |value: f64, lo: f64, hi: f64, periodic: bool| {
794        if periodic {
795            let span = hi - lo;
796            if span > 0.0 {
797                return lo + (value - lo).rem_euclid(span);
798            }
799        }
800        value.clamp(lo, hi)
801    };
802    (hold(u, ua, ub, wrap_u), hold(v, va, vb, wrap_v))
803}
804
805/// Whether a parameter pair has left a surface's own domain.
806fn beyond(surface: &SurfaceGeometry, at: (f64, f64), tol: Tolerances) -> bool {
807    let ((ua, ub), (va, vb)) = surface.domain();
808    let (wrap_u, wrap_v) = wraps(surface);
809    let band = tol.parametric();
810    (!wrap_u && (at.0 < ua - band || at.0 > ub + band))
811        || (!wrap_v && (at.1 < va - band || at.1 > vb + band))
812}
813
814/// Whether it is at the edge of one, which is how a run-out is told from a
815/// singularity.
816fn at_edge(surface: &SurfaceGeometry, at: (f64, f64)) -> bool {
817    let ((ua, ub), (va, vb)) = surface.domain();
818    let (wrap_u, wrap_v) = wraps(surface);
819    let near = |value: f64, lo: f64, hi: f64| {
820        let reach = (hi - lo) * 1e-6;
821        value <= lo + reach || value >= hi - reach
822    };
823    (!wrap_u && near(at.0, ua, ub)) || (!wrap_v && near(at.1, va, vb))
824}