Skip to main content

ogeom_intersect/
march.rs

1//! The general surface/surface intersector: seed, then walk.
2//!
3//! Where two surfaces meet has no closed form in general: the curve is
4//! transcendental, and `docs/DATA_MODEL.md` ยง9 is blunt about the consequence:
5//! there is no exact answer to be exact about, which is why the topology carries
6//! tolerances. What there is instead is a curve that can be *followed*, one
7//! corrected step at a time, to a stated accuracy.
8//!
9//! # Two problems, kept apart on purpose
10//!
11//! **Finding a branch** and **following one** fail in completely different ways,
12//! and lumping them together is how an intersector comes to look better than it
13//! is. A tracer that follows one branch beautifully while never noticing the
14//! second reports a smooth, accurate, *wrong* answer, and the obvious accuracy
15//! measure, "is every point on both surfaces", scores it perfectly.
16//!
17//! So [`seeds`] and [`trace`] are separate, separately testable, and separately
18//! measured. Seeding is polyhedral: both surfaces are sampled into triangles and
19//! the triangle pairs that cross give starting points. It finds a branch if the
20//! sampling resolves it, and *misses one thinner than the grid*, which is a
21//! real limitation with a knob attached rather than a mystery.
22//!
23//! # Following the curve
24//!
25//! At a point on both surfaces the intersection runs along the cross product of
26//! the two normals: the one direction that stays in both tangent planes. Step
27//! along it and you leave both surfaces slightly; a Newton correction brings you
28//! back.
29//!
30//! The correction has four unknowns (two parameters on each surface) and three
31//! equations, `A(u1,v1) = B(u2,v2)`. That is deliberately one short, because the
32//! solution set *is* the curve and pinning it to a point needs one more
33//! condition. The fourth is a plane across the direction of travel: it says how
34//! far along to land, and it is what turns "somewhere on the curve" into "the
35//! next point".
36//!
37//! # What it reports about itself
38//!
39//! Whether the curve closed, and whether it ran out of steps. A polyline that
40//! stopped because it hit a limit is not the same answer as one that stopped
41//! because the curve ended, and a caller that cannot tell them apart will treat
42//! a truncated branch as a complete one.
43
44use ogeom_core::{OgeomResult, Tolerances, ogeom_bail};
45use ogeom_geom::{Surface, SurfaceGeometry};
46use ogeom_math::{Point, Vector, solve};
47
48/// How hard to look, and how closely to follow.
49#[derive(Debug, Clone, Copy, PartialEq)]
50pub struct Marching {
51    /// How far the polyline may sit from the true curve, in space.
52    pub chord: f64,
53    /// How finely each surface is sampled when looking for branches.
54    ///
55    /// The limitation with a knob on it: a branch narrower than one cell can be
56    /// stepped over entirely. Raising this costs time quadratically and is the
57    /// only thing that makes a thin branch findable.
58    pub grid: usize,
59    /// A ceiling on the points in one branch, so a curve that will not close
60    /// cannot run forever.
61    pub max_points: usize,
62}
63
64impl Default for Marching {
65    fn default() -> Self {
66        Self {
67            chord: 1e-4,
68            grid: 24,
69            max_points: 20_000,
70        }
71    }
72}
73
74impl Marching {
75    /// Check the settings are usable.
76    ///
77    /// # Errors
78    ///
79    /// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if the chord is
80    /// not positive, the grid is too coarse to hold a triangle, or no points are
81    /// allowed.
82    pub fn validate(&self) -> OgeomResult<()> {
83        if !self.chord.is_finite() || self.chord <= 0.0 {
84            ogeom_bail!(Construction, "a chord of {} is not a distance", self.chord);
85        }
86        if self.grid < 2 {
87            ogeom_bail!(Construction, "a sampling grid needs at least two steps");
88        }
89        if self.max_points < 2 {
90            ogeom_bail!(Construction, "a branch needs at least two points");
91        }
92        Ok(())
93    }
94}
95
96/// A point that lies on both surfaces, with where it is on each.
97#[derive(Debug, Clone, Copy, PartialEq)]
98pub struct Contact {
99    /// Parameters on the first surface.
100    pub on_a: (f64, f64),
101    /// Parameters on the second.
102    pub on_b: (f64, f64),
103    /// Where that is in space.
104    pub point: Point,
105}
106
107/// Why a traced branch stopped.
108#[derive(Debug, Clone, Copy, PartialEq, Eq)]
109pub enum Stopped {
110    /// It came back to where it started.
111    Closed,
112    /// It reached the edge of one surface's domain.
113    LeftTheDomain,
114    /// The correction stopped converging: a tangency or a singular point.
115    ///
116    /// Reported rather than pushed through. Marching past a point where the two
117    /// normals are parallel is how a tracer jumps onto the wrong branch, and a
118    /// wrong branch is a plausible answer to a different question.
119    Stalled,
120    /// It hit [`Marching::max_points`].
121    ///
122    /// Distinct from every other reason, because this one means the answer is
123    /// *incomplete* rather than finished.
124    RanOut,
125}
126
127/// One traced branch.
128#[derive(Debug, Clone, PartialEq)]
129pub struct Traced {
130    /// The points along it, in order.
131    pub points: Vec<Point>,
132    /// Where each point is on the first surface.
133    pub on_a: Vec<(f64, f64)>,
134    /// Where each point is on the second.
135    pub on_b: Vec<(f64, f64)>,
136    /// Why it stopped.
137    pub stopped: Stopped,
138}
139
140impl Traced {
141    /// Whether the branch is finished rather than truncated.
142    #[must_use]
143    pub const fn complete(&self) -> bool {
144        !matches!(self.stopped, Stopped::RanOut)
145    }
146
147    /// Whether it closed on itself.
148    #[must_use]
149    pub const fn closed(&self) -> bool {
150        matches!(self.stopped, Stopped::Closed)
151    }
152}
153
154/// Starting points on the intersection, one per branch found.
155///
156/// Polyhedral: both surfaces are sampled into triangles, the pairs that cross
157/// give approximate points, and each is corrected onto both surfaces exactly.
158/// Points that land on the same spot are merged, so a branch crossing many
159/// cells yields one seed rather than dozens.
160///
161/// # Errors
162///
163/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if the settings are
164/// unusable.
165pub fn seeds(
166    a: &SurfaceGeometry,
167    b: &SurfaceGeometry,
168    options: Marching,
169    tol: Tolerances,
170) -> OgeomResult<Vec<Contact>> {
171    options.validate()?;
172    let (mesh_a, mesh_b) = (sample(a, options.grid, tol), sample(b, options.grid, tol));
173
174    let mut found: Vec<Contact> = Vec::new();
175    for cell_a in &mesh_a {
176        for cell_b in &mesh_b {
177            // Cheap rejection first: most pairs are nowhere near each other,
178            // and the segment test below is far from free.
179            if !overlap(cell_a, cell_b, options.chord) {
180                continue;
181            }
182            let Some(guess) = triangles_cross(cell_a, cell_b) else {
183                continue;
184            };
185            let start = [cell_a.at.0, cell_a.at.1, cell_b.at.0, cell_b.at.1];
186            let Some(contact) = correct(a, b, start, guess, None, tol) else {
187                continue;
188            };
189            // One seed per branch, not one per cell it passes through. The
190            // spacing is the *finer* surface's grid: two distinct branches
191            // closer than that were never going to be told apart by this
192            // sampling anyway, while the coarser surface's cells say nothing
193            // about how far apart branches can be: a plane's clamped domain
194            // spans a million units, and its cell would merge every branch
195            // through a blend into one.
196            let apart = span(a).min(span(b)) / f64::from(u32::try_from(options.grid).unwrap_or(1));
197            if found
198                .iter()
199                .any(|c| c.point.distance(contact.point) <= apart)
200            {
201                continue;
202            }
203            found.push(contact);
204        }
205    }
206    Ok(found)
207}
208
209/// Every branch of the intersection: seed, trace each, and keep the distinct
210/// ones.
211///
212/// A branch crossing many sampling cells produces many seeds, and tracing from
213/// any of them gives the same curve. So a seed already lying on something
214/// traced is dropped rather than followed again, which is what makes the
215/// *number* of branches returned meaningful, and it is the number a boolean
216/// will act on.
217///
218/// # Errors
219///
220/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if the settings are
221/// unusable.
222pub fn branches(
223    a: &SurfaceGeometry,
224    b: &SurfaceGeometry,
225    options: Marching,
226    tol: Tolerances,
227) -> OgeomResult<Vec<Traced>> {
228    let found = seeds(a, b, options, tol)?;
229    let mut out: Vec<Traced> = Vec::new();
230    for seed in found {
231        // Already on something we have followed.
232        let reach = options.chord.max(tol.confusion()) * 8.0;
233        if out
234            .iter()
235            .any(|branch| passes_near(branch, seed.point, reach))
236        {
237            continue;
238        }
239        // A seed that will not trace (a tangency) is reported by being
240        // absent rather than by an error, since the other branches are still
241        // real answers.
242        if let Ok(branch) = trace(a, b, seed, options, tol)
243            && branch.points.len() >= 2
244            && !is_fragment(&branch, options)
245        {
246            out.push(branch);
247        }
248    }
249    Ok(stitch_stalled(out, a, b, options, tol))
250}
251
252/// Below this sine the surfaces count as tangent at a point: the
253/// branch-point certificate a stall end must carry to participate in
254/// stitching.
255const BRANCH_POINT_SINE: f64 = 0.05;
256
257/// The sine of the normal angle at a contact: the transversality measure.
258fn crossing_sine(
259    a: &SurfaceGeometry,
260    b: &SurfaceGeometry,
261    on_a: (f64, f64),
262    on_b: (f64, f64),
263    tol: Tolerances,
264) -> f64 {
265    let Ok(na) = a.normal_at(on_a.0, on_a.1, tol) else {
266        return 0.0;
267    };
268    let Ok(nb) = b.normal_at(on_b.0, on_b.1, tol) else {
269        return 0.0;
270    };
271    na.vector().cross(nb.vector()).magnitude()
272}
273
274/// Whether a stalled trace is a fragment rather than a curve.
275///
276/// Coincident or near-coincident surfaces defeat the tangency check at a seed:
277/// rounding in the corrected parameters leaves the two normals a whisker apart,
278/// the walk takes a couple of steps, and then stalls where the arithmetic gives
279/// out. What comes back lies on both surfaces perfectly and describes nothing:
280/// identical spheres yielded six such fragments, each a few points long.
281///
282/// A stalled branch shorter than a handful of chords carries no information the
283/// seed did not, so it is noise from a degenerate configuration and dropped. A
284/// *real* stalled branch (one that ran into a genuine tangency) has length
285/// behind it and is kept, because a truncated real answer is still an answer.
286///
287/// The marcher is deliberately not a coincidence detector: for the pairs with
288/// closed forms, [`surface_surface`](crate::surface_surface) answers
289/// [`Same`](crate::Meeting::Same), and that check belongs before this one.
290fn is_fragment(branch: &Traced, options: Marching) -> bool {
291    if branch.stopped != Stopped::Stalled {
292        return false;
293    }
294    let length: f64 = branch
295        .points
296        .windows(2)
297        .map(|pair| pair[0].distance(pair[1]))
298        .sum();
299    length < options.chord * 10.0
300}
301
302/// Whether a traced branch passes within a distance of a point.
303///
304/// Measured against the polyline's *segments*, not its vertices. The vertices
305/// are a marching step apart (far more than the chord tolerance), so a seed
306/// sitting neatly between two of them looks distant from both, and comparing to
307/// vertices alone reported one circle nine times.
308fn passes_near(branch: &Traced, p: Point, reach: f64) -> bool {
309    branch
310        .points
311        .windows(2)
312        .any(|pair| distance_to_segment(p, pair[0], pair[1]) <= reach)
313}
314
315/// Distance from a point to a segment.
316fn distance_to_segment(p: Point, a: Point, b: Point) -> f64 {
317    let along = b - a;
318    let length = along.square_magnitude();
319    if length <= f64::MIN_POSITIVE {
320        return p.distance(a);
321    }
322    let t = ((p - a).dot(along) / length).clamp(0.0, 1.0);
323    p.distance(a + along * t)
324}
325
326/// Follow the intersection from a starting point, in both directions.
327///
328/// # Errors
329///
330/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if the settings are
331/// unusable; [`OgeomError::NotDone`](ogeom_core::OgeomError::NotDone) if the two surfaces
332/// are tangent at the seed, where there is no single direction to follow.
333pub fn trace(
334    a: &SurfaceGeometry,
335    b: &SurfaceGeometry,
336    from: Contact,
337    options: Marching,
338    tol: Tolerances,
339) -> OgeomResult<Traced> {
340    options.validate()?;
341    if tangent_at(a, b, from, tol).is_none() {
342        ogeom_bail!(
343            NotDone,
344            "the surfaces are tangent here, so the intersection has no single \
345             direction to follow; that is a branch point and needs the seed \
346             moved off it"
347        );
348    }
349
350    // Forwards first. If it closes, that is the whole branch and there is
351    // nothing behind us.
352    let ahead = walk(a, b, from, 1.0, options, tol)?;
353    if ahead.stopped == Stopped::Closed {
354        return Ok(ahead);
355    }
356    let behind = walk(a, b, from, -1.0, options, tol)?;
357    let last_step = |walked: &[Point]| -> f64 {
358        walked
359            .windows(2)
360            .last()
361            .map_or(0.0, |w| w[0].distance(w[1]))
362    };
363    let steps = last_step(&ahead.points).max(last_step(&behind.points));
364
365    // Join them, the backward half reversed and its shared first point dropped.
366    let mut points = behind.points;
367    let mut on_a = behind.on_a;
368    let mut on_b = behind.on_b;
369    points.reverse();
370    on_a.reverse();
371    on_b.reverse();
372    points.pop();
373    on_a.pop();
374    on_b.pop();
375    points.extend(ahead.points);
376    on_a.extend(ahead.on_a);
377    on_b.extend(ahead.on_b);
378
379    // The worse of the two reasons: a branch truncated at either end is
380    // truncated.
381    let mut stopped = if ahead.stopped == Stopped::RanOut || behind.stopped == Stopped::RanOut {
382        Stopped::RanOut
383    } else if ahead.stopped == Stopped::Stalled || behind.stopped == Stopped::Stalled {
384        Stopped::Stalled
385    } else {
386        Stopped::LeftTheDomain
387    };
388    // A loop cut at a seam. A closed patch is clamped, not periodic: a
389    // section that runs round it (a rim's circle on a converted drum)
390    // is walked from the seed to the seam one way and to the seam the
391    // other, each walk stopping a fraction of a step short of it, and the
392    // two ends meet where the surface closes on itself. That is the whole
393    // loop, and it is closed: left open, the arrangement downstream held a
394    // circle with two ends at one point and found no face piece to keep.
395    // The ends are within a couple of the walks' own last steps of each
396    // other, and the loop is closed exactly on its first point.
397    if stopped == Stopped::LeftTheDomain && points.len() > 3 {
398        let gap = points[0].distance(points[points.len() - 1]);
399        if gap <= (steps * 2.0).max(tol.confusion() * 10.0) {
400            points.push(points[0]);
401            on_a.push(on_a[0]);
402            on_b.push(on_b[0]);
403            stopped = Stopped::Closed;
404        }
405    }
406    Ok(Traced {
407        points,
408        on_a,
409        on_b,
410        stopped,
411    })
412}
413
414/// Two surfaces, as a condition for the walker: four unknowns and three
415/// equations saying the two points coincide.
416///
417/// The intersector's own walk goes through [`crate::walk`] like everything
418/// else, and what is *not* generic lives here: the domain clamps a surface
419/// pair needs, and the tangent, which the intersector computes from the two
420/// normals rather than from the null space so that it can refuse a crossing
421/// too shallow to be more than the correction's own noise.
422struct SurfacePair<'s> {
423    a: &'s SurfaceGeometry,
424    b: &'s SurfaceGeometry,
425}
426
427impl crate::walk::Condition for SurfacePair<'_> {
428    fn unknowns(&self) -> usize {
429        4
430    }
431
432    fn position(&self, x: &[f64], tol: Tolerances) -> Option<Point> {
433        self.a.point_at(x[0], x[1], tol).ok()
434    }
435
436    fn position_gradient(&self, x: &[f64], tol: Tolerances) -> Option<Vec<Vector>> {
437        let (au, av) = self.a.d1_at(x[0], x[1], tol).ok()?;
438        // The point is taken from the first surface, so it does not move with
439        // the second's parameters at all.
440        Some(vec![au, av, Vector::ZERO, Vector::ZERO])
441    }
442
443    fn system(&self, x: &[f64], tol: Tolerances) -> Option<(Vec<f64>, Vec<Vec<f64>>)> {
444        let pa = self.a.point_at(x[0], x[1], tol).ok()?;
445        let pb = self.b.point_at(x[2], x[3], tol).ok()?;
446        let (au, av) = self.a.d1_at(x[0], x[1], tol).ok()?;
447        let (bu, bv) = self.b.d1_at(x[2], x[3], tol).ok()?;
448        let gap = pa - pb;
449        Some((
450            vec![gap.x, gap.y, gap.z],
451            vec![
452                vec![au.x, av.x, -bu.x, -bv.x],
453                vec![au.y, av.y, -bu.y, -bv.y],
454                vec![au.z, av.z, -bu.z, -bv.z],
455            ],
456        ))
457    }
458
459    fn clamp(&self, x: &mut [f64]) {
460        let (ua, va) = clamp(self.a, x[0], x[1]);
461        let (ub, vb) = clamp(self.b, x[2], x[3]);
462        x[0] = ua;
463        x[1] = va;
464        x[2] = ub;
465        x[3] = vb;
466    }
467
468    fn outside(&self, x: &[f64], tol: Tolerances) -> bool {
469        outside(self.a, (x[0], x[1]), tol) || outside(self.b, (x[2], x[3]), tol)
470    }
471
472    fn near_edge(&self, x: &[f64]) -> bool {
473        near_edge(self.a, (x[0], x[1])) || near_edge(self.b, (x[2], x[3]))
474    }
475
476    fn extent(&self) -> f64 {
477        span(self.a).max(span(self.b))
478    }
479
480    fn tangent_is_oriented(&self) -> bool {
481        // The cross product of the two normals, whose sign is the surfaces'
482        // own and whose flip at a tangency is what stops the march.
483        true
484    }
485
486    fn tangent(&self, x: &[f64], tol: Tolerances) -> Option<Vector> {
487        tangent_at(
488            self.a,
489            self.b,
490            Contact {
491                on_a: (x[0], x[1]),
492                on_b: (x[2], x[3]),
493                point: Point::ORIGIN,
494            },
495            tol,
496        )
497    }
498}
499
500/// Walk one way from a seed.
501fn walk(
502    a: &SurfaceGeometry,
503    b: &SurfaceGeometry,
504    from: Contact,
505    sense: f64,
506    options: Marching,
507    tol: Tolerances,
508) -> OgeomResult<Traced> {
509    let pair = SurfacePair { a, b };
510    let start = [from.on_a.0, from.on_a.1, from.on_b.0, from.on_b.1];
511    let walked = crate::walk::walk_one_way(&pair, &start, sense, options, tol)?;
512    Ok(Traced {
513        on_a: walked.states.iter().map(|x| (x[0], x[1])).collect(),
514        on_b: walked.states.iter().map(|x| (x[2], x[3])).collect(),
515        points: walked.points,
516        stopped: walked.stopped,
517    })
518}
519
520/// The sine of the shallowest crossing angle the marcher will follow.
521///
522/// One microradian, and the number is set by the *correction*, not by taste.
523/// `correct` accepts a residual up to the confusion tolerance, so the two
524/// parameter points of a contact can disagree by that much in space, and on
525/// coincident or near-coincident surfaces, that disagreement shows up as a
526/// spurious angle between the two computed normals of about the residual over
527/// the local feature size. A gate below that floor reads the correction's own
528/// noise as a direction and marches along it: identical spheres came back as
529/// six confident little curves that existed nowhere but in rounding.
530///
531/// So below this angle the marcher cannot tell an ultra-shallow crossing from
532/// coincidence, and refuses both rather than guessing. A genuine crossing
533/// shallower than a microradian is also one the Newton correction cannot
534/// reliably follow (its travel constraint becomes numerically dependent on
535/// the surface-gap rows at exactly the same rate), so the gate refuses what
536/// could not have been followed anyway.
537const SHALLOWEST: f64 = 1e-6;
538
539/// The direction the intersection runs at a contact.
540///
541/// The cross product of the two normals: the one direction lying in both
542/// tangent planes. `None` where the normals are parallel to within
543/// [`SHALLOWEST`]: the surfaces are tangent or coincident there, and the
544/// intersection has no direction the marcher can trust.
545fn tangent_at(
546    a: &SurfaceGeometry,
547    b: &SurfaceGeometry,
548    at: Contact,
549    tol: Tolerances,
550) -> Option<Vector> {
551    let na = normal_at(a, at.on_a, tol)?;
552    let nb = normal_at(b, at.on_b, tol)?;
553    let cross = na.cross(nb);
554    let length = cross.magnitude();
555    // The decision is made through intervals rather than a bare compare:
556    // each normal component carries the correction's stated residual as an
557    // uncertainty, the squared cross magnitude is computed as an enclosure,
558    // and only a crossing *certainly* above the floor is followed. A sine
559    // inside the enclosure's undecided band is exactly the case the floor
560    // exists for (the correction's own noise masquerading as an angle),
561    // and it is refused with a certificate instead of a guess.
562    let floor = tol.angular().max(SHALLOWEST);
563    let widen = |value: f64| ogeom_math::Interval::about(value, tol.confusion());
564    let (ax, ay, az) = (widen(na.x), widen(na.y), widen(na.z));
565    let (bx, by, bz) = (widen(nb.x), widen(nb.y), widen(nb.z));
566    let cx = ay.mul(&bz).sub(&az.mul(&by));
567    let cy = az.mul(&bx).sub(&ax.mul(&bz));
568    let cz = ax.mul(&by).sub(&ay.mul(&bx));
569    let magnitude2 = cx.square().add(&cy.square()).add(&cz.square());
570    let above = magnitude2.sub(&ogeom_math::Interval::point(floor * floor));
571    if above.certain_sign() != Some(ogeom_core::Sign::Positive) || length <= f64::MIN_POSITIVE {
572        return None;
573    }
574    Some(cross * (1.0 / length))
575}
576
577/// A surface's unit normal at a parameter.
578fn normal_at(surface: &SurfaceGeometry, at: (f64, f64), tol: Tolerances) -> Option<Vector> {
579    let (du, dv) = surface.d1_at(at.0, at.1, tol).ok()?;
580    let cross = du.cross(dv);
581    let length = cross.magnitude();
582    if length <= tol.confusion() {
583        return None;
584    }
585    Some(cross * (1.0 / length))
586}
587
588/// Bring a parameter guess onto both surfaces.
589///
590/// Three equations say the two surface points coincide; the fourth says how far
591/// along the direction of travel to land. Without that fourth the system is
592/// underdetermined (its solution set *is* the curve), and Newton would wander
593/// along it instead of converging to a point.
594///
595/// `constraint` is `(anchor, direction, distance)`. Without one, the guess
596/// itself is used as the anchor and the direction is the intersection tangent,
597/// which is what seeding wants: land anywhere on the curve near here.
598fn correct(
599    a: &SurfaceGeometry,
600    b: &SurfaceGeometry,
601    start: [f64; 4],
602    guess: Point,
603    constraint: Option<(Point, Vector, f64)>,
604    tol: Tolerances,
605) -> Option<Contact> {
606    let (anchor, along, reach) = match constraint {
607        Some(given) => given,
608        None => {
609            // No direction to travel: hold the guess still along whichever way
610            // the curve runs, so the solve slides onto the curve rather than
611            // along it.
612            let at = Contact {
613                on_a: (start[0], start[1]),
614                on_b: (start[2], start[3]),
615                point: guess,
616            };
617            (guess, tangent_at(a, b, at, tol).unwrap_or(Vector::X), 0.0)
618        }
619    };
620
621    let system = |x: &[f64]| {
622        let (ua, va) = clamp(a, x[0], x[1]);
623        let (ub, vb) = clamp(b, x[2], x[3]);
624        let pa = a.point_at(ua, va, tol).unwrap_or(Point::ORIGIN);
625        let pb = b.point_at(ub, vb, tol).unwrap_or(Point::ORIGIN);
626        let (au, av) = a.d1_at(ua, va, tol).unwrap_or((Vector::ZERO, Vector::ZERO));
627        let (bu, bv) = b.d1_at(ub, vb, tol).unwrap_or((Vector::ZERO, Vector::ZERO));
628
629        let gap = pa - pb;
630        let residual = vec![gap.x, gap.y, gap.z, (pa - anchor).dot(along) - reach];
631        let jacobian = vec![
632            vec![au.x, av.x, -bu.x, -bv.x],
633            vec![au.y, av.y, -bu.y, -bv.y],
634            vec![au.z, av.z, -bu.z, -bv.z],
635            vec![au.dot(along), av.dot(along), 0.0, 0.0],
636        ];
637        (residual, jacobian)
638    };
639
640    let criteria = solve::Criteria {
641        residual: tol.confusion() * 0.01,
642        step: tol.parametric(),
643        max_iterations: 40,
644    };
645    let found = solve::newton_system(system, &start, criteria).ok()?;
646    if found.residual > tol.confusion() {
647        return None;
648    }
649    let (ua, va) = clamp(a, found.value[0], found.value[1]);
650    let (ub, vb) = clamp(b, found.value[2], found.value[3]);
651    Some(Contact {
652        on_a: (ua, va),
653        on_b: (ub, vb),
654        point: a.point_at(ua, va, tol).ok()?,
655    })
656}
657
658/// Hold a parameter inside a surface's domain.
659///
660/// A periodic direction wraps instead, so a curve crossing a cylinder's seam
661/// keeps going rather than stopping at a boundary that is not one.
662fn clamp(surface: &SurfaceGeometry, u: f64, v: f64) -> (f64, f64) {
663    let ((ua, ub), (va, vb)) = surface.domain();
664    let fold = |x: f64, lo: f64, hi: f64, periodic: bool| {
665        if !periodic {
666            return x.clamp(lo, hi);
667        }
668        let span = hi - lo;
669        if span <= 0.0 {
670            return x;
671        }
672        lo + (x - lo).rem_euclid(span)
673    };
674    (
675        fold(u, ua, ub, surface.is_periodic_u()),
676        fold(v, va, vb, surface.is_periodic_v()),
677    )
678}
679
680/// Whether a parameter sits close enough to a non-periodic edge that a stalled
681/// walk there means the edge rather than a singularity.
682///
683/// The band is a fraction of the domain's own span: a walk stalls within a
684/// step of the boundary, and the step is far larger than the strict band
685/// [`outside`] uses to decide a point has actually crossed.
686fn near_edge(surface: &SurfaceGeometry, at: (f64, f64)) -> bool {
687    let ((ua, ub), (va, vb)) = surface.domain();
688    let close = |x: f64, lo: f64, hi: f64, periodic: bool| {
689        !periodic && {
690            let band = (hi - lo).abs() * 1e-4;
691            x <= lo + band || x >= hi - band
692        }
693    };
694    close(at.0, ua, ub, surface.is_periodic_u()) || close(at.1, va, vb, surface.is_periodic_v())
695}
696
697/// Whether a parameter has left a surface's domain, in a direction that has one.
698fn outside(surface: &SurfaceGeometry, at: (f64, f64), tol: Tolerances) -> bool {
699    let ((ua, ub), (va, vb)) = surface.domain();
700    let past = |x: f64, lo: f64, hi: f64, periodic: bool| {
701        !periodic && (x <= lo + tol.parametric() || x >= hi - tol.parametric())
702    };
703    past(at.0, ua, ub, surface.is_periodic_u()) || past(at.1, va, vb, surface.is_periodic_v())
704}
705
706/// A surface's rough size, for spacing seeds.
707fn span(surface: &SurfaceGeometry) -> f64 {
708    let ((ua, ub), (va, vb)) = surface.domain();
709    let tol = Tolerances::millimetres();
710    let corners = [(ua, va), (ub, va), (ua, vb), (ub, vb)];
711    let mut low = Point::new(f64::MAX, f64::MAX, f64::MAX);
712    let mut high = Point::new(f64::MIN, f64::MIN, f64::MIN);
713    for (u, v) in corners {
714        if let Ok(p) = surface.point_at(u, v, tol) {
715            low = Point::new(low.x.min(p.x), low.y.min(p.y), low.z.min(p.z));
716            high = Point::new(high.x.max(p.x), high.y.max(p.y), high.z.max(p.z));
717        }
718    }
719    let size = (high - low).magnitude();
720    if size.is_finite() && size > 0.0 {
721        size
722    } else {
723        1.0
724    }
725}
726
727/// One sampled triangle of a surface, with the parameters it came from.
728pub(crate) struct Cell {
729    pub(crate) corners: [Point; 3],
730    pub(crate) at: (f64, f64),
731    pub(crate) low: Point,
732    pub(crate) high: Point,
733    /// How far the surface bows from the flat cell: its middle's distance
734    /// from the middle of the diagonal the two triangles share.
735    pub(crate) sag: f64,
736    /// The parameters of the three corners, in `corners` order.
737    pub(crate) params: [(f64, f64); 3],
738}
739
740/// Sample a surface into triangles.
741pub(crate) fn sample(surface: &SurfaceGeometry, grid: usize, tol: Tolerances) -> Vec<Cell> {
742    sample_by(surface, (grid, grid), tol)
743}
744
745/// Sample a surface into triangles, `counts` cells along `u` and along `v`.
746pub(crate) fn sample_by(
747    surface: &SurfaceGeometry,
748    counts: (usize, usize),
749    tol: Tolerances,
750) -> Vec<Cell> {
751    let ((ua, ub), (va, vb)) = surface.domain();
752    // An unbounded domain would put the samples a billion units apart and find
753    // nothing. Clamped to something a real model lives inside.
754    let limit = 1.0e6;
755    let (ua, ub) = (ua.max(-limit), ub.min(limit));
756    let (va, vb) = (va.max(-limit), vb.min(limit));
757
758    let mut out = Vec::new();
759    #[allow(clippy::cast_precision_loss)]
760    let (nu, nv) = (counts.0 as f64, counts.1 as f64);
761    for i in 0..counts.0 {
762        for j in 0..counts.1 {
763            #[allow(clippy::cast_precision_loss)]
764            let (s0, s1) = (i as f64 / nu, (i + 1) as f64 / nu);
765            #[allow(clippy::cast_precision_loss)]
766            let (t0, t1) = (j as f64 / nv, (j + 1) as f64 / nv);
767            let at = |s: f64, t: f64| {
768                let (u, v) = (ua + (ub - ua) * s, va + (vb - va) * t);
769                surface.point_at(u, v, tol).map(|p| ((u, v), p))
770            };
771            let (Ok((p00, a00)), Ok((p10, a10)), Ok((p01, a01)), Ok((p11, a11))) =
772                (at(s0, t0), at(s1, t0), at(s0, t1), at(s1, t1))
773            else {
774                continue;
775            };
776            let sag = at(f64::midpoint(s0, s1), f64::midpoint(t0, t1))
777                .map_or(0.0, |(_, middle)| middle.distance(a00.midpoint(a11)));
778            for (corners, params) in [
779                ([a00, a10, a11], [p00, p10, p11]),
780                ([a00, a11, a01], [p00, p11, p01]),
781            ] {
782                let low = Point::new(
783                    corners.iter().map(|p| p.x).fold(f64::MAX, f64::min),
784                    corners.iter().map(|p| p.y).fold(f64::MAX, f64::min),
785                    corners.iter().map(|p| p.z).fold(f64::MAX, f64::min),
786                );
787                let high = Point::new(
788                    corners.iter().map(|p| p.x).fold(f64::MIN, f64::max),
789                    corners.iter().map(|p| p.y).fold(f64::MIN, f64::max),
790                    corners.iter().map(|p| p.z).fold(f64::MIN, f64::max),
791                );
792                out.push(Cell {
793                    corners,
794                    at: p00,
795                    low,
796                    high,
797                    sag,
798                    params,
799                });
800            }
801        }
802    }
803    out
804}
805
806/// Whether two cells' boxes come within a margin of each other.
807fn overlap(a: &Cell, b: &Cell, margin: f64) -> bool {
808    a.low.x <= b.high.x + margin
809        && b.low.x <= a.high.x + margin
810        && a.low.y <= b.high.y + margin
811        && b.low.y <= a.high.y + margin
812        && a.low.z <= b.high.z + margin
813        && b.low.z <= a.high.z + margin
814}
815
816/// A point where two triangles cross, if they do.
817///
818/// Each triangle's edges are tested against the other's plane and then against
819/// the triangle itself. Only an approximate answer is needed: it is a seed, and
820/// the Newton correction that follows is what makes it a point on the curve.
821fn triangles_cross(a: &Cell, b: &Cell) -> Option<Point> {
822    for (edges, target) in [(a, b), (b, a)] {
823        for k in 0..3 {
824            let (from, to) = (edges.corners[k], edges.corners[(k + 1) % 3]);
825            if let Some(hit) = segment_meets_triangle(from, to, target.corners) {
826                return Some(hit);
827            }
828        }
829    }
830    None
831}
832
833/// Where a segment crosses a triangle.
834pub(crate) fn segment_meets_triangle(from: Point, to: Point, t: [Point; 3]) -> Option<Point> {
835    let direction = to - from;
836    let (e1, e2) = (t[1] - t[0], t[2] - t[0]);
837    let h = direction.cross(e2);
838    let determinant = e1.dot(h);
839    if determinant.abs() <= f64::MIN_POSITIVE {
840        return None;
841    }
842    let inverse = 1.0 / determinant;
843    let s = from - t[0];
844    let u = inverse * s.dot(h);
845    if !(0.0..=1.0).contains(&u) {
846        return None;
847    }
848    let q = s.cross(e1);
849    let v = inverse * direction.dot(q);
850    if v < 0.0 || u + v > 1.0 {
851        return None;
852    }
853    let along = inverse * e2.dot(q);
854    if !(0.0..=1.0).contains(&along) {
855        return None;
856    }
857    Some(from + direction * along)
858}
859
860/// One arc between branch points, cut from a stalled fragment.
861struct Arc {
862    points: Vec<Point>,
863    on_a: Vec<(f64, f64)>,
864    on_b: Vec<(f64, f64)>,
865    /// The branch-point cluster each end attaches to, if any.
866    head_bp: Option<usize>,
867    tail_bp: Option<usize>,
868}
869
870impl Arc {
871    fn length(&self) -> f64 {
872        self.points
873            .windows(2)
874            .map(|pair| pair[0].distance(pair[1]))
875            .sum()
876    }
877
878    /// The direction the arc leaves its end, read across a window deep
879    /// enough to stand clear of any residual wander.
880    fn outgoing(&self, tail: bool) -> Option<Vector> {
881        let n = self.points.len();
882        if n < 2 {
883            return None;
884        }
885        let window = (n - 1).min(24);
886        let (at, back) = if tail {
887            (n - 1, n - 1 - window)
888        } else {
889            (0, window)
890        };
891        let out = self.points[at] - self.points[back];
892        let m = out.magnitude();
893        (m > f64::MIN_POSITIVE).then(|| out / m)
894    }
895}
896
897/// Whether a stalled branch is transversal *somewhere* in its interior:
898/// the certificate that it is a curve passing branch points rather than
899/// tangential-contact debris. A plane resting on a torus produces
900/// fragments tangent along their whole length; a real curve through a
901/// branch point is tangent only in passing.
902fn interior_is_transversal(
903    branch: &Traced,
904    a: &SurfaceGeometry,
905    b: &SurfaceGeometry,
906    tol: Tolerances,
907) -> bool {
908    let n = branch.points.len();
909    if n < 5 {
910        return false;
911    }
912    [n / 4, n / 2, 3 * n / 4]
913        .into_iter()
914        .any(|i| crossing_sine(a, b, branch.on_a[i], branch.on_b[i], tol) > BRANCH_POINT_SINE)
915}
916
917/// Join stalled fragments that meet at branch points into the curves they
918/// belong to: the stitching an earlier plan owed, now delivered.
919///
920/// Where the two normals become parallel the intersection has no single
921/// direction: the walk stalls there, wanders in place while the correction
922/// gives out, and a curve that passes *through* the singularity comes back
923/// as fragments with parked ends. The reassembly is geometric, not
924/// bookkeeping: cluster the near-tangent stall ends into branch points;
925/// cut every fragment at its branch-point visits, which trims the wander
926/// and separates the arcs a walk-through glued together; drop debris and
927/// duplicate coverage; then, at each branch point, pair arc ends whose
928/// tangents continue one another (a smooth curve crosses the singularity
929/// collinearly, and the crossing curve turns through the crossing angle)
930/// and chain the pairs into whole curves, closing the loops that close.
931///
932/// Only fragments transversal somewhere in their interior participate:
933/// tangential contact along a whole curve is a different phenomenon and
934/// keeps its honest fragments.
935fn stitch_stalled(
936    found: Vec<Traced>,
937    a: &SurfaceGeometry,
938    b: &SurfaceGeometry,
939    options: Marching,
940    tol: Tolerances,
941) -> Vec<Traced> {
942    let reach = options.chord.max(tol.confusion()) * 60.0;
943    const CONTINUES: f64 = 0.5;
944
945    let (candidates, mut out): (Vec<Traced>, Vec<Traced>) = found.into_iter().partition(|branch| {
946        branch.stopped == Stopped::Stalled && interior_is_transversal(branch, a, b, tol)
947    });
948    if candidates.is_empty() {
949        return out;
950    }
951
952    // Branch points: the near-tangent stall ends, clustered.
953    let mut bps: Vec<Point> = Vec::new();
954    for branch in &candidates {
955        let n = branch.points.len();
956        for at in [0, n - 1] {
957            if crossing_sine(a, b, branch.on_a[at], branch.on_b[at], tol) < BRANCH_POINT_SINE {
958                let p = branch.points[at];
959                if !bps.iter().any(|held| held.distance(p) <= reach) {
960                    bps.push(p);
961                }
962            }
963        }
964    }
965    if bps.is_empty() {
966        out.extend(candidates);
967        return out;
968    }
969    let bp_of =
970        |p: Point| -> Option<usize> { bps.iter().position(|held| held.distance(p) <= reach) };
971
972    // Cut each fragment at its branch-point visits: maximal runs of points
973    // clear of every branch point become arcs, attached to the branch
974    // points beside them.
975    let mut arcs: Vec<Arc> = Vec::new();
976    for branch in &candidates {
977        let n = branch.points.len();
978        let mut run_start: Option<usize> = None;
979        for i in 0..=n {
980            let near = i < n && bp_of(branch.points[i]).is_some();
981            match (run_start, near, i == n) {
982                (None, false, false) => run_start = Some(i),
983                (Some(s), true, _) | (Some(s), _, true) => {
984                    let e = i;
985                    if e > s + 1 {
986                        let head_bp = if s > 0 {
987                            bp_of(branch.points[s - 1])
988                        } else {
989                            None
990                        };
991                        let tail_bp = if e < n { bp_of(branch.points[e]) } else { None };
992                        arcs.push(Arc {
993                            points: branch.points[s..e].to_vec(),
994                            on_a: branch.on_a[s..e].to_vec(),
995                            on_b: branch.on_b[s..e].to_vec(),
996                            head_bp,
997                            tail_bp,
998                        });
999                    }
1000                    run_start = None;
1001                }
1002                _ => {}
1003            }
1004        }
1005    }
1006
1007    // Debris and duplicate coverage out; longest first so the fuller
1008    // tracing of a doubly-walked arc is the one kept.
1009    arcs.retain(|arc| arc.length() > options.chord * 10.0 && arc.points.len() >= 4);
1010    arcs.sort_by(|x, y| {
1011        y.length()
1012            .partial_cmp(&x.length())
1013            .unwrap_or(core::cmp::Ordering::Equal)
1014    });
1015    let mut kept: Vec<Arc> = Vec::new();
1016    'candidate: for arc in arcs {
1017        let n = arc.points.len();
1018        for probe in [n / 4, n / 2, 3 * n / 4] {
1019            let p = arc.points[probe];
1020            if kept.iter().any(|held| {
1021                held.points
1022                    .windows(2)
1023                    .any(|pair| distance_to_segment(p, pair[0], pair[1]) <= reach)
1024            }) {
1025                continue 'candidate;
1026            }
1027        }
1028        kept.push(arc);
1029    }
1030
1031    // Pair arc ends at each branch point by tangent continuation: the
1032    // smooth curve runs straight through, the crossing one turns.
1033    let ends: Vec<(usize, bool, usize, Vector)> = kept
1034        .iter()
1035        .enumerate()
1036        .flat_map(|(i, arc)| {
1037            [(false, arc.head_bp), (true, arc.tail_bp)]
1038                .into_iter()
1039                .filter_map(move |(tail, bp)| Some((i, tail, bp?, arc.outgoing(tail)?)))
1040        })
1041        .collect();
1042    let mut partner: Vec<Option<usize>> = vec![None; ends.len()];
1043    for bp in 0..bps.len() {
1044        loop {
1045            let mut best: Option<(usize, usize, f64)> = None;
1046            for x in 0..ends.len() {
1047                if partner[x].is_some() || ends[x].2 != bp {
1048                    continue;
1049                }
1050                for y in (x + 1)..ends.len() {
1051                    if partner[y].is_some() || ends[y].2 != bp {
1052                        continue;
1053                    }
1054                    let score = -ends[x].3.dot(ends[y].3);
1055                    if score > CONTINUES && best.is_none_or(|(_, _, held)| score > held) {
1056                        best = Some((x, y, score));
1057                    }
1058                }
1059            }
1060            let Some((x, y, _)) = best else { break };
1061            partner[x] = Some(y);
1062            partner[y] = Some(x);
1063        }
1064    }
1065
1066    // Chain the arcs through the pairings into whole curves.
1067    let end_index = |arc: usize, tail: bool| -> Option<usize> {
1068        ends.iter().position(|e| e.0 == arc && e.1 == tail)
1069    };
1070    let mut used = vec![false; kept.len()];
1071    for start in 0..kept.len() {
1072        if used[start] {
1073            continue;
1074        }
1075        // Walk backwards first to a free entry, unless the chain loops.
1076        let mut first = start;
1077        let mut first_reversed = false;
1078        let mut seen_back = vec![false; kept.len()];
1079        loop {
1080            seen_back[first] = true;
1081            // Forward traversal enters an arc at its head, reversed at its
1082            // tail, so the entry end is named by the orientation flag.
1083            let Some(entry) = end_index(first, first_reversed) else {
1084                break;
1085            };
1086            let Some(p) = partner[entry] else { break };
1087            let (prev, prev_tail, _, _) = ends[p];
1088            if seen_back[prev] {
1089                break; // the chain is a loop; any start serves
1090            }
1091            first = prev;
1092            // The previous arc *leaves* through the paired end: leaving at
1093            // its tail means it runs forward.
1094            first_reversed = !prev_tail;
1095        }
1096
1097        // Now walk forwards from `first`, consuming arcs.
1098        let mut points: Vec<Point> = Vec::new();
1099        let mut on_a: Vec<(f64, f64)> = Vec::new();
1100        let mut on_b: Vec<(f64, f64)> = Vec::new();
1101        let mut current = first;
1102        let mut reversed = first_reversed;
1103        let mut closed = false;
1104        loop {
1105            used[current] = true;
1106            let arc = &kept[current];
1107            type Run = (Vec<Point>, Vec<(f64, f64)>, Vec<(f64, f64)>);
1108            let (pts, pa, pb): Run = if reversed {
1109                (
1110                    arc.points.iter().rev().copied().collect(),
1111                    arc.on_a.iter().rev().copied().collect(),
1112                    arc.on_b.iter().rev().copied().collect(),
1113                )
1114            } else {
1115                (arc.points.clone(), arc.on_a.clone(), arc.on_b.clone())
1116            };
1117            // Insert the branch point itself at the junction.
1118            if !points.is_empty() {
1119                let joint_bp = if reversed { arc.tail_bp } else { arc.head_bp };
1120                if let Some(bp) = joint_bp {
1121                    points.push(bps[bp]);
1122                    on_a.push(pa[0]);
1123                    on_b.push(pb[0]);
1124                }
1125            }
1126            points.extend(pts);
1127            on_a.extend(pa);
1128            on_b.extend(pb);
1129
1130            let leaving = end_index(current, !reversed);
1131            let Some(l) = leaving else { break };
1132            let Some(p) = partner[l] else { break };
1133            let (next, next_tail, _, _) = ends[p];
1134            if used[next] {
1135                closed = next == first;
1136                break;
1137            }
1138            current = next;
1139            reversed = next_tail;
1140        }
1141        if closed && points.len() > 3 {
1142            let bridge = points[0];
1143            let ba = on_a[0];
1144            let bb = on_b[0];
1145            points.push(bridge);
1146            on_a.push(ba);
1147            on_b.push(bb);
1148        }
1149        out.push(Traced {
1150            points,
1151            on_a,
1152            on_b,
1153            stopped: if closed {
1154                Stopped::Closed
1155            } else {
1156                Stopped::Stalled
1157            },
1158        });
1159    }
1160    out
1161}
1162
1163/// Newton projection of a point onto a surface, warm-started: the local
1164/// tool the tangential walker corrects with.
1165fn nearest_on(
1166    surface: &SurfaceGeometry,
1167    seed: (f64, f64),
1168    target: Point,
1169    tol: Tolerances,
1170) -> Option<((f64, f64), Point)> {
1171    let (mut u, mut v) = seed;
1172    for _ in 0..16 {
1173        let (u_ok, v_ok) = surface.normalize_parameters(u, v, tol).ok()?;
1174        u = u_ok;
1175        v = v_ok;
1176        let p = surface.point_at(u, v, tol).ok()?;
1177        let (su, sv) = surface.d1_at(u, v, tol).ok()?;
1178        let r = p - target;
1179        let (a11, a12, a22) = (su.dot(su), su.dot(sv), sv.dot(sv));
1180        let det = a11.mul_add(a22, -(a12 * a12));
1181        if det.abs() <= f64::MIN_POSITIVE {
1182            break;
1183        }
1184        let (b1, b2) = (-su.dot(r), -sv.dot(r));
1185        let du = b1.mul_add(a22, -(b2 * a12)) / det;
1186        let dv = a11.mul_add(b2, -(a12 * b1)) / det;
1187        u += du;
1188        v += dv;
1189        if du.hypot(dv) < 1e-14 {
1190            break;
1191        }
1192    }
1193    let (u, v) = surface.normalize_parameters(u, v, tol).ok()?;
1194    Some(((u, v), surface.point_at(u, v, tol).ok()?))
1195}
1196
1197/// Trace tangential contact along a curve: the walker an earlier plan
1198/// owed, following the valley of the gap function rather than a crossing.
1199///
1200/// Where two surfaces touch along a whole curve there is no transversal
1201/// direction to march: the crossing angle is zero along the entire
1202/// contact, and the crossing walker honestly stalls. But the contact is
1203/// still a curve, and it is the locus where the *gap* between the surfaces
1204/// stays zero. This walker steps along the contact and corrects each step
1205/// transversally: project the candidate onto the first surface, project
1206/// that onto the second, and slide on the first surface to close the gap:
1207/// a minimization, not a root-find, because at tangency the gap touches
1208/// zero without crossing it.
1209///
1210/// The seed must be a genuine contact: on both surfaces within tolerance
1211/// and near-tangent there. A transversal crossing is refused; the
1212/// ordinary walker owns those.
1213///
1214/// # Errors
1215///
1216/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if the
1217/// settings are unusable or the seed is not a tangential contact.
1218pub fn trace_tangential(
1219    a: &SurfaceGeometry,
1220    b: &SurfaceGeometry,
1221    from: Contact,
1222    options: Marching,
1223    tol: Tolerances,
1224) -> OgeomResult<Traced> {
1225    options.validate()?;
1226    let accept = tol.confusion() * 100.0;
1227    let sine = crossing_sine(a, b, from.on_a, from.on_b, tol);
1228    if sine > BRANCH_POINT_SINE {
1229        ogeom_bail!(
1230            Construction,
1231            "the surfaces cross here at sine {sine}; tangential tracing wants a contact"
1232        );
1233    }
1234    let reach = span(a).max(span(b));
1235    let step = (options.chord * reach)
1236        .sqrt()
1237        .clamp(tol.confusion(), reach / 16.0);
1238
1239    type Walked = (Vec<Point>, Vec<(f64, f64)>, Vec<(f64, f64)>, Stopped);
1240    let walk_one = |sense: f64| -> OgeomResult<Walked> {
1241        let mut points = vec![from.point];
1242        let mut on_a = vec![from.on_a];
1243        let mut on_b = vec![from.on_b];
1244        let mut at = from;
1245        let mut previous: Option<Vector> = None;
1246        let mut stopped = Stopped::RanOut;
1247        while points.len() < options.max_points {
1248            ogeom_core::progress::checkpoint()?;
1249            // The contact direction: in the common tangent plane. With the
1250            // normals parallel, one surface's normal serves for both; the
1251            // step direction is the previous one projected back into the
1252            // tangent plane, or any tangent direction to begin with.
1253            let Some(normal) = normal_at(a, at.on_a, tol) else {
1254                stopped = Stopped::Stalled;
1255                break;
1256            };
1257            let direction = match previous {
1258                Some(d) => {
1259                    let flat = d - normal * d.dot(normal);
1260                    let m = flat.magnitude();
1261                    if m <= f64::MIN_POSITIVE {
1262                        stopped = Stopped::Stalled;
1263                        break;
1264                    }
1265                    flat / m
1266                }
1267                None => {
1268                    // First step: the tangent direction along which the gap
1269                    // grows least, found by sampling the tangent circle.
1270                    let (su, _) = a.d1_at(at.on_a.0, at.on_a.1, tol).map_err(|_| {
1271                        ogeom_core::ogeom_err!(Construction, "the seed cannot be evaluated")
1272                    })?;
1273                    let t1 = {
1274                        let flat = su - normal * su.dot(normal);
1275                        let m = flat.magnitude();
1276                        if m <= f64::MIN_POSITIVE {
1277                            stopped = Stopped::Stalled;
1278                            break;
1279                        }
1280                        flat / m
1281                    };
1282                    let t2 = normal.cross(t1);
1283                    let mut best = (f64::INFINITY, t1);
1284                    for k in 0..16 {
1285                        let angle = core::f64::consts::TAU * f64::from(k) / 16.0;
1286                        let dir = t1 * angle.cos() + t2 * angle.sin();
1287                        let probe = at.point + dir * step;
1288                        let Some((_, qa)) = nearest_on(a, at.on_a, probe, tol) else {
1289                            continue;
1290                        };
1291                        let Some((_, qb)) = nearest_on(b, at.on_b, qa, tol) else {
1292                            continue;
1293                        };
1294                        let gap = qa.distance(qb);
1295                        if gap < best.0 {
1296                            best = (gap, dir);
1297                        }
1298                    }
1299                    best.1 * sense
1300                }
1301            };
1302
1303            // Step and correct: onto a, gap closed against b by sliding on
1304            // a a few times.
1305            let mut candidate = at.point + direction * step;
1306            let mut pa = at.on_a;
1307            let mut pb = at.on_b;
1308            let mut gap = f64::INFINITY;
1309            for _ in 0..8 {
1310                let Some((ua, qa)) = nearest_on(a, pa, candidate, tol) else {
1311                    break;
1312                };
1313                let Some((ub, qb)) = nearest_on(b, pb, qa, tol) else {
1314                    break;
1315                };
1316                pa = ua;
1317                pb = ub;
1318                gap = qa.distance(qb);
1319                if gap <= tol.confusion() {
1320                    candidate = qa;
1321                    break;
1322                }
1323                // Slide the working point toward the midpoint of the gap.
1324                candidate = qa + (qb - qa) * 0.5;
1325            }
1326            if gap > accept {
1327                stopped = Stopped::Stalled;
1328                break;
1329            }
1330            let next = Contact {
1331                on_a: pa,
1332                on_b: pb,
1333                point: candidate,
1334            };
1335            if points.len() > 3 && next.point.distance(from.point) <= step {
1336                points.push(from.point);
1337                on_a.push(from.on_a);
1338                on_b.push(from.on_b);
1339                stopped = Stopped::Closed;
1340                break;
1341            }
1342            if next.point.distance(at.point) <= step * 1e-3 {
1343                stopped = Stopped::Stalled;
1344                break;
1345            }
1346            previous = Some(next.point - at.point);
1347            points.push(next.point);
1348            on_a.push(next.on_a);
1349            on_b.push(next.on_b);
1350            at = next;
1351        }
1352        Ok((points, on_a, on_b, stopped))
1353    };
1354
1355    let (points, on_a, on_b, stopped) = walk_one(1.0)?;
1356    if stopped == Stopped::Closed {
1357        return Ok(Traced {
1358            points,
1359            on_a,
1360            on_b,
1361            stopped,
1362        });
1363    }
1364    let (mut back_points, mut back_a, mut back_b, back_stopped) = walk_one(-1.0)?;
1365    back_points.reverse();
1366    back_a.reverse();
1367    back_b.reverse();
1368    back_points.pop();
1369    back_a.pop();
1370    back_b.pop();
1371    back_points.extend(points);
1372    back_a.extend(on_a);
1373    back_b.extend(on_b);
1374    let stopped = if stopped == Stopped::RanOut || back_stopped == Stopped::RanOut {
1375        Stopped::RanOut
1376    } else if stopped == Stopped::Stalled || back_stopped == Stopped::Stalled {
1377        Stopped::Stalled
1378    } else {
1379        Stopped::LeftTheDomain
1380    };
1381    Ok(Traced {
1382        points: back_points,
1383        on_a: back_a,
1384        on_b: back_b,
1385        stopped,
1386    })
1387}
1388
1389#[cfg(test)]
1390#[allow(clippy::unwrap_used, clippy::print_stdout)]
1391mod tests {
1392    use super::*;
1393    use ogeom_geom::{CylinderSurface, PlaneSurface, SphereSurface};
1394    use ogeom_math::{Cylinder, Direction, Frame, Plane, Sphere};
1395
1396    const T: Tolerances = Tolerances::millimetres();
1397
1398    fn cylinder(origin: Point, axis: Vector, radius: f64, height: (f64, f64)) -> SurfaceGeometry {
1399        let frame = Frame::new(
1400            origin,
1401            Direction::new(axis, T).unwrap(),
1402            Direction::from_cross(axis, Vector::new(0.3, 0.5, 0.9), T).unwrap(),
1403            T,
1404        )
1405        .unwrap();
1406        CylinderSurface::new(Cylinder::new(frame, radius, T).unwrap(), height)
1407            .unwrap()
1408            .into()
1409    }
1410
1411    fn sphere(centre: Point, radius: f64) -> SurfaceGeometry {
1412        SphereSurface::new(Sphere::centred(centre, radius, T).unwrap()).into()
1413    }
1414
1415    fn plane(origin: Point, normal: Vector) -> SurfaceGeometry {
1416        PlaneSurface::over(
1417            Plane::through(origin, Direction::new(normal, T).unwrap()),
1418            (-8.0, 8.0),
1419            (-8.0, 8.0),
1420        )
1421        .unwrap()
1422        .into()
1423    }
1424
1425    /// How far a point is from a quadric, in closed form.
1426    fn off(surface: &SurfaceGeometry, p: Point) -> f64 {
1427        match surface {
1428            SurfaceGeometry::Plane(x) => x.plane().distance_to(p),
1429            SurfaceGeometry::Sphere(x) => x.sphere().distance_to(p),
1430            SurfaceGeometry::Cylinder(x) => x.cylinder().distance_to(p),
1431            _ => 0.0,
1432        }
1433    }
1434
1435    /// The worst distance from a traced branch to either surface.
1436    fn deviation(a: &SurfaceGeometry, b: &SurfaceGeometry, traced: &Traced) -> f64 {
1437        traced
1438            .points
1439            .iter()
1440            .map(|p| off(a, *p).abs().max(off(b, *p).abs()))
1441            .fold(0.0_f64, f64::max)
1442    }
1443
1444    #[test]
1445    fn a_plane_through_a_bent_strip_seeds_both_branches() {
1446        // A cubic strip bent into an arch crosses a level plane twice. The
1447        // plane's domain is the unbounded one a face carries, and its
1448        // sampling cells span a million units; merging seeds at *its*
1449        // spacing would call the two crossings one branch and trace only
1450        // one of them. Seeds merge at the finer surface's spacing instead.
1451        use ogeom_geom::BSplineSurface;
1452        use ogeom_math::{ControlGrid, KnotVector};
1453        let mut points = Vec::new();
1454        for i in 0..7 {
1455            let a = core::f64::consts::PI * f64::from(i) / 6.0;
1456            for j in 0..2 {
1457                points.push(Point::new(2.0 * a.cos(), f64::from(j), 2.0 * a.sin()));
1458            }
1459        }
1460        let grid = ControlGrid::new(points, 7, 2).unwrap();
1461        let strip: SurfaceGeometry = BSplineSurface::new(
1462            KnotVector::clamped_uniform(3, 7).unwrap(),
1463            KnotVector::clamped_uniform(1, 2).unwrap(),
1464            &grid,
1465            T,
1466        )
1467        .unwrap()
1468        .into();
1469        let level: SurfaceGeometry = PlaneSurface::over(
1470            Plane::through(Point::new(0.0, 0.0, 1.0), Direction::Z),
1471            (-1.0e9, 1.0e9),
1472            (-1.0e9, 1.0e9),
1473        )
1474        .unwrap()
1475        .into();
1476        let options = Marching {
1477            chord: 1e-5,
1478            ..Marching::default()
1479        };
1480        let found = branches(&strip, &level, options, T).unwrap();
1481        assert_eq!(
1482            found.len(),
1483            2,
1484            "the arch crosses the level twice: {}",
1485            found.len()
1486        );
1487        for branch in &found {
1488            assert!(!branch.closed());
1489            for p in &branch.points {
1490                assert!((p.z - 1.0).abs() < 1e-4, "on the level: {p:?}");
1491            }
1492        }
1493    }
1494
1495    #[test]
1496    fn two_crossed_cylinders_are_traced_onto_both_of_them() {
1497        // The case with no closed form, and the one the analytic module
1498        // explicitly refuses: two cylinders on perpendicular axes meet in a
1499        // quartic space curve. Every point of the trace must be on both.
1500        let a = cylinder(Point::ORIGIN, Vector::Z, 1.0, (-4.0, 4.0));
1501        let b = cylinder(Point::ORIGIN, Vector::X, 1.0, (-4.0, 4.0));
1502        let options = Marching {
1503            chord: 1e-5,
1504            ..Marching::default()
1505        };
1506
1507        let found = branches(&a, &b, options, T).unwrap();
1508        assert_eq!(
1509            found.len(),
1510            2,
1511            "two equal cylinders crossing at right angles meet in two closed \
1512             curves: the Steinmetz solid's seams"
1513        );
1514
1515        let mut worst = 0.0_f64;
1516        for branch in &found {
1517            assert!(branch.closed(), "each seam is a closed loop");
1518            assert!(
1519                branch.points.len() > 100,
1520                "a branch of only {} points",
1521                branch.points.len()
1522            );
1523            worst = worst.max(deviation(&a, &b, branch));
1524        }
1525        println!(
1526            "crossed cylinders: {} branches, worst deviation {worst:e}",
1527            found.len()
1528        );
1529        assert!(worst < 1e-7, "traced off the surfaces by {worst:e}");
1530    }
1531
1532    #[test]
1533    fn unequal_crossed_cylinders_meet_in_two_curves_as_well() {
1534        // The non-degenerate cousin. Equal radii put the two curves through
1535        // each other at the tangency points, so getting the count right there
1536        // says less than getting it right here, where there is no singularity
1537        // for a tracer to be lucky about.
1538        let a = cylinder(Point::ORIGIN, Vector::Z, 1.0, (-4.0, 4.0));
1539        let b = cylinder(Point::ORIGIN, Vector::X, 1.6, (-4.0, 4.0));
1540        let options = Marching {
1541            chord: 1e-5,
1542            ..Marching::default()
1543        };
1544
1545        let found = branches(&a, &b, options, T).unwrap();
1546        assert_eq!(found.len(), 2);
1547        for branch in &found {
1548            assert!(branch.closed());
1549            assert!(deviation(&a, &b, branch) < 1e-7);
1550        }
1551    }
1552
1553    #[test]
1554    fn a_traced_circle_agrees_with_the_circle_it_should_be() {
1555        // A sphere cut by a plane through its centre is a circle of known
1556        // radius, and the marcher does not know that. Tracing it and checking
1557        // against the closed form is the strongest single check there is: it
1558        // tests the tracer against an answer derived independently of it.
1559        let s = sphere(Point::ORIGIN, 3.0);
1560        let cut = plane(Point::ORIGIN, Vector::Z);
1561        let options = Marching {
1562            chord: 1e-6,
1563            ..Marching::default()
1564        };
1565
1566        let found = seeds(&s, &cut, options, T).unwrap();
1567        assert!(!found.is_empty());
1568        let branch = trace(&s, &cut, found[0], options, T).unwrap();
1569
1570        assert!(
1571            branch.closed(),
1572            "a plane through a sphere gives a closed loop"
1573        );
1574        for p in &branch.points {
1575            let radius = (p.x * p.x + p.y * p.y).sqrt();
1576            assert!(
1577                (radius - 3.0).abs() < 1e-7,
1578                "a point at radius {radius} on a circle of 3"
1579            );
1580            assert!(p.z.abs() < 1e-7, "off the cutting plane by {}", p.z);
1581        }
1582    }
1583
1584    #[test]
1585    fn a_branch_that_leaves_the_surface_says_so_rather_than_stopping_quietly() {
1586        // A truncated branch and a finished one are different answers, and a
1587        // caller that cannot tell them apart treats the first as the second.
1588        let s = sphere(Point::ORIGIN, 3.0);
1589        let cut = plane(Point::new(0.0, 0.0, 0.0), Vector::Z);
1590        let options = Marching {
1591            chord: 1e-4,
1592            max_points: 8,
1593            ..Marching::default()
1594        };
1595        let found = seeds(&s, &cut, options, T).unwrap();
1596        let branch = trace(&s, &cut, found[0], options, T).unwrap();
1597        assert_eq!(branch.stopped, Stopped::RanOut);
1598        assert!(!branch.complete(), "a truncated branch is not complete");
1599    }
1600
1601    #[test]
1602    fn tangent_surfaces_are_refused_rather_than_followed_onto_a_guess() {
1603        // Where the normals are parallel the intersection has no single
1604        // direction, and marching through such a point is how a tracer changes
1605        // branch without noticing. A sphere resting on a plane is the case.
1606        let s = sphere(Point::new(0.0, 0.0, 3.0), 3.0);
1607        let ground = plane(Point::ORIGIN, Vector::Z);
1608        let touch = Contact {
1609            on_a: (0.0, -core::f64::consts::FRAC_PI_2),
1610            on_b: (0.0, 0.0),
1611            point: Point::ORIGIN,
1612        };
1613        let err = trace(&s, &ground, touch, Marching::default(), T).unwrap_err();
1614        assert!(err.to_string().contains("tangent"), "unexpected: {err}");
1615    }
1616
1617    #[test]
1618    fn the_number_of_branches_is_the_number_there_are() {
1619        // The failure the accuracy measure cannot see. Every point of one
1620        // circle is on both surfaces, so returning one of two scores perfectly;
1621        // the count is the only thing that catches it.
1622        let options = Marching {
1623            chord: 1e-5,
1624            ..Marching::default()
1625        };
1626
1627        // A sphere cut by a plane off its centre: one circle.
1628        let one = branches(
1629            &sphere(Point::ORIGIN, 3.0),
1630            &plane(Point::new(0.0, 0.0, 1.0), Vector::Z),
1631            options,
1632            T,
1633        )
1634        .unwrap();
1635        assert_eq!(one.len(), 1, "one plane through a sphere cuts one circle");
1636        assert!(one[0].closed());
1637
1638        // A sphere and a coaxial cylinder narrower than it: two circles, one
1639        // above and one below.
1640        let two = branches(
1641            &sphere(Point::ORIGIN, 3.0),
1642            &cylinder(Point::ORIGIN, Vector::Z, 1.5, (-4.0, 4.0)),
1643            options,
1644            T,
1645        )
1646        .unwrap();
1647        assert_eq!(two.len(), 2, "a coaxial cylinder cuts a sphere twice");
1648        for branch in &two {
1649            assert!(branch.closed(), "each is a closed circle");
1650        }
1651        // And they are on opposite sides, rather than the same one twice.
1652        let heights: Vec<f64> = two.iter().map(|b| b.points[0].z).collect();
1653        assert!(
1654            heights[0] * heights[1] < 0.0,
1655            "both branches came back on the same side: {heights:?}"
1656        );
1657    }
1658
1659    #[test]
1660    fn a_branch_thinner_than_the_sampling_is_missed_and_the_knob_finds_it() {
1661        // The stated limitation of polyhedral seeding, pinned so it is a known
1662        // boundary rather than a surprise. Two spheres barely overlapping meet
1663        // in a small circle; a coarse grid steps over it entirely.
1664        let a = sphere(Point::ORIGIN, 3.0);
1665        let b = sphere(Point::new(5.98, 0.0, 0.0), 3.0);
1666
1667        let coarse = seeds(
1668            &a,
1669            &b,
1670            Marching {
1671                grid: 6,
1672                ..Marching::default()
1673            },
1674            T,
1675        )
1676        .unwrap();
1677        let fine = seeds(
1678            &a,
1679            &b,
1680            Marching {
1681                grid: 120,
1682                ..Marching::default()
1683            },
1684            T,
1685        )
1686        .unwrap();
1687        assert!(
1688            coarse.len() < fine.len(),
1689            "a finer grid should find what a coarse one steps over: {} against {}",
1690            coarse.len(),
1691            fine.len()
1692        );
1693        assert!(!fine.is_empty(), "the branch is there to be found");
1694    }
1695
1696    #[test]
1697    fn settings_that_could_not_work_are_refused() {
1698        let a = sphere(Point::ORIGIN, 1.0);
1699        let b = plane(Point::ORIGIN, Vector::Z);
1700        for options in [
1701            Marching {
1702                chord: 0.0,
1703                ..Marching::default()
1704            },
1705            Marching {
1706                grid: 1,
1707                ..Marching::default()
1708            },
1709            Marching {
1710                max_points: 1,
1711                ..Marching::default()
1712            },
1713        ] {
1714            assert!(seeds(&a, &b, options, T).is_err());
1715        }
1716    }
1717}