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