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