Skip to main content

ogeom_hlr/
exact.rs

1//! The exact half of the drawing pipeline.
2//!
3//! The polygonal path draws what the *mesh* says: silhouettes are interior
4//! mesh edges whose triangles disagree about facing the eye, and visibility
5//! is occlusion sampling against the triangles. Both are as good as the
6//! chord, and no better.
7//!
8//! This is the other half. A silhouette is where the surface's own normal
9//! turns perpendicular to the view, which for the elementary surfaces is a
10//! curve with a closed form: a great circle on a sphere, a pair of rulings
11//! on a cylinder or a cone. Visibility is decided by asking the *faces*
12//! whether anything stands between a point and the eye: an exact
13//! curve/surface interference and a trim test, not a triangle count. What is
14//! still sampled is the drawing itself, because a drawing is polylines. The
15//! curves it samples and the classification it carries are exact.
16//!
17//! A surface whose silhouette has no closed form (a torus, a spline) has it
18//! marched: the silhouette is one equation on the surface's own chart, and
19//! the shared walker follows it. The curve drawn through the walk is held
20//! to the walk's chord against the silhouette itself, between the walked
21//! points as well as at them.
22
23use ogeom_core::{OgeomResult, Tolerances, ogeom_bail};
24use ogeom_geom::{CircleCurve, Curve, Curve3d as _, LineCurve, Surface as _, SurfaceGeometry};
25use ogeom_math::{Axis, Circle, Direction, Frame, Point, Point2, Transform, Vector};
26use ogeom_mesh::Deflection;
27use ogeom_topo::{EdgeRepr, Model, NodeData, Orientation, Shape, ShapeType, explore_unique};
28
29use crate::crossings::{Contours, locate};
30use crate::project::{Drawing, DrawnCurve, Source, View, Visibility};
31
32/// One silhouette curve, and the face it belongs to.
33#[derive(Debug, Clone)]
34pub struct Silhouette {
35    /// The face whose surface turns away here.
36    pub face: Shape,
37    /// The curve, in space.
38    pub curve: Curve,
39    /// The portion of it that lies within the face's trim.
40    pub range: (f64, f64),
41}
42
43/// The exact silhouettes of a shape, seen along `direction`.
44///
45/// A silhouette is the locus where the surface normal is perpendicular to
46/// the view: for a sphere the great circle whose plane the direction is
47/// normal to, for a cylinder the two rulings furthest to either side, for a
48/// cone the two rulings through its apex where the same holds. Planes have
49/// none (a plane either faces the eye or does not), and a face whose
50/// surface has no closed-form silhouette has it marched and fitted, within
51/// a hundred times the confusion distance of the true silhouette along its
52/// whole length.
53///
54/// Each curve comes back trimmed to the stretch that lies within its own
55/// face, decided by sampling the face's trim, so a silhouette on a face
56/// that was cut away is absent rather than drawn through thin air.
57///
58/// # Errors
59///
60/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if the
61/// direction has no length. A marched silhouette whose walk fails, or that
62/// no fit holds to its chord, contributes no curve.
63pub fn silhouettes(
64    model: &Model,
65    shape: &Shape,
66    direction: Vector,
67    tol: Tolerances,
68) -> OgeomResult<Vec<Silhouette>> {
69    let magnitude = direction.magnitude();
70    if magnitude <= tol.confusion() {
71        ogeom_bail!(Construction, "a silhouette needs a direction to look along");
72    }
73    let along = direction / magnitude;
74    let deflection = Deflection::default();
75
76    let faces = explore_unique(model, shape, ShapeType::Face)?;
77    let per_face = ogeom_core::parallel::map_ordered(&faces, |_, face| {
78        face_silhouettes(model, face, along, deflection, tol)
79    });
80    let mut out = Vec::new();
81    for found in per_face {
82        out.extend(found?);
83    }
84    Ok(out)
85}
86
87/// The silhouettes of one face, trimmed to it.
88fn face_silhouettes(
89    model: &Model,
90    face: &Shape,
91    along: Vector,
92    deflection: Deflection,
93    tol: Tolerances,
94) -> OgeomResult<Vec<Silhouette>> {
95    let Some(NodeData::Face(data)) = model.node(face).map(|n| n.data()) else {
96        return Ok(Vec::new());
97    };
98    let Some(surface) = model.geometry().surface(data.surface) else {
99        return Ok(Vec::new());
100    };
101    let placement = face.transform(model.datums())?;
102    let world = ogeom_geom::Transformable::transformed(surface, &placement, tol)?;
103    let candidates = match &world {
104        SurfaceGeometry::Plane(_) => Vec::new(),
105        SurfaceGeometry::Sphere(s) => {
106            // The great circle whose plane has the view as its normal:
107            // every normal on it is radial, so every one is
108            // perpendicular to the view.
109            let sphere = s.sphere();
110            let axis = Direction::new(along, tol)?;
111            let frame = Frame::new(sphere.centre(), axis, perpendicular(along, tol)?, tol)?;
112            vec![Curve::Circle(CircleCurve::new(Circle::new(
113                frame,
114                sphere.radius(),
115                tol,
116            )?))]
117        }
118        SurfaceGeometry::Cylinder(c) => {
119            // The two rulings where the radial direction is
120            // perpendicular to the view: the axis stepped sideways by
121            // the radius, either way.
122            let cylinder = c.cylinder();
123            let axis = cylinder.frame().z().vector();
124            let sideways = axis.cross(along);
125            let m = sideways.magnitude();
126            if m <= tol.angular() {
127                // Looking down the axis: the whole rim is the outline,
128                // and the face's own boundary already draws it.
129                Vec::new()
130            } else {
131                let sideways = sideways / m;
132                [1.0, -1.0]
133                    .iter()
134                    .map(|sign| {
135                        let at = cylinder.frame().origin() + sideways * (cylinder.radius() * sign);
136                        Curve::Line(LineCurve::new(Axis::new(at, cylinder.frame().z())))
137                    })
138                    .collect()
139            }
140        }
141        SurfaceGeometry::Cone(c) => {
142            // The same question on a cone: the rulings whose own normal
143            // is perpendicular to the view. The normal of a ruling at
144            // angle u is radial tilted by the half-angle, so the
145            // condition is a linear one in (cos u, sin u) and has two
146            // roots, or none, when the eye is inside the cone's own
147            // angle and nothing turns away.
148            let cone = c.cone();
149            let frame = cone.frame();
150            let (x, y, z) = (frame.x().vector(), frame.y().vector(), frame.z().vector());
151            let (sin, cos) = cone.half_angle().sin_cos();
152            // n(u) = cos(half) * (x cos u + y sin u) - sin(half) * z
153            let (a, b) = (cos * along.dot(x), cos * along.dot(y));
154            let c0 = -sin * along.dot(z);
155            let r = a.hypot(b);
156            if r <= tol.angular() || c0.abs() > r {
157                Vec::new()
158            } else {
159                let phase = b.atan2(a);
160                let spread = (-c0 / r).acos();
161                [phase + spread, phase - spread]
162                    .iter()
163                    .map(|u| {
164                        let radial = x * u.cos() + y * u.sin();
165                        let apex = cone.apex();
166                        let direction =
167                            radial * cone.half_angle().sin() + z * cone.half_angle().cos();
168                        Direction::new(direction, tol)
169                            .map(|d| Curve::Line(LineCurve::new(Axis::new(apex, d))))
170                    })
171                    .collect::<OgeomResult<Vec<Curve>>>()?
172            }
173        }
174        // No closed form: a torus, a spline. The silhouette is still
175        // one equation on the surface's own chart, and one equation in
176        // two unknowns is a curve, so it is *walked* rather than refused.
177        other => marched_silhouettes(other, along, tol)?,
178    };
179
180    if candidates.is_empty() {
181        return Ok(Vec::new());
182    }
183    let rings = ogeom_mesh::face_boundary(model, face, deflection, tol)?;
184    // Every trim station projects onto the same surface, so its seeding
185    // grid is evaluated once for the face.
186    let seeds = ogeom_algo::SurfaceSeeds::over(&world, 24, tol)?;
187    let mut out = Vec::new();
188    for curve in candidates {
189        for range in within_trim(model, face, &world, &seeds, &rings, &curve, tol)? {
190            out.push(Silhouette {
191                face: face.clone(),
192                curve: curve.clone(),
193                range,
194            });
195        }
196    }
197    Ok(out)
198}
199
200/// The reflect lines of a shape under a light: where the surface turns away
201/// from the *light* rather than from the eye.
202///
203/// The same locus as a silhouette, asked of a different direction, which is
204/// what a reflect line is, and why the two share a construction. A surface
205/// inspected this way shows its own creases: the lines move a long way for a
206/// small change in curvature.
207///
208/// # Errors
209///
210/// As [`silhouettes`].
211pub fn reflect_lines(
212    model: &Model,
213    shape: &Shape,
214    light: Vector,
215    tol: Tolerances,
216) -> OgeomResult<Vec<Silhouette>> {
217    silhouettes(model, shape, light, tol)
218}
219
220/// The isoparametric curves of a face: `u_count` at constant `u`, `v_count`
221/// at constant `v`, each trimmed to the stretches that lie on the face.
222///
223/// Evenly spaced across the face's own parameter window, excluding its
224/// edges, because an isoparametric at the window's edge is the face's
225/// boundary and the boundary is already drawn.
226///
227/// # Errors
228///
229/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if the
230/// face carries no surface.
231pub fn iso_curves(
232    model: &Model,
233    face: &Shape,
234    u_count: usize,
235    v_count: usize,
236    tol: Tolerances,
237) -> OgeomResult<Vec<Vec<Point>>> {
238    let Some(NodeData::Face(data)) = model.node(face).map(|n| n.data().clone()) else {
239        ogeom_bail!(Construction, "expected a face");
240    };
241    let Some(surface) = model.geometry().surface(data.surface).cloned() else {
242        ogeom_bail!(Dangling, "face refers to a surface not in this model");
243    };
244    let placement = face.transform(model.datums())?;
245    let world = ogeom_geom::Transformable::transformed(&surface, &placement, tol)?;
246    let ((u0, u1), (v0, v1)) = world.domain();
247    let rings = ogeom_mesh::face_boundary(model, face, Deflection::default(), tol)?;
248
249    const ALONG: usize = 64;
250    let mut out = Vec::new();
251    for (count, constant_u) in [(u_count, true), (v_count, false)] {
252        for i in 1..=count {
253            #[expect(
254                clippy::cast_precision_loss,
255                reason = "a curve index, far below the mantissa"
256            )]
257            let f = i as f64 / (count + 1) as f64;
258            let mut run: Vec<Point> = Vec::new();
259            for k in 0..=ALONG {
260                #[expect(
261                    clippy::cast_precision_loss,
262                    reason = "a station index, far below the mantissa"
263                )]
264                let g = k as f64 / ALONG as f64;
265                let (u, v) = if constant_u {
266                    ((u1 - u0).mul_add(f, u0), (v1 - v0).mul_add(g, v0))
267                } else {
268                    ((u1 - u0).mul_add(g, u0), (v1 - v0).mul_add(f, v0))
269                };
270                if inside_rings(&rings, Point2::new(u, v)) {
271                    run.push(world.point_at(u, v, tol)?);
272                } else if run.len() >= 2 {
273                    out.push(std::mem::take(&mut run));
274                } else {
275                    run.clear();
276                }
277            }
278            if run.len() >= 2 {
279                out.push(run);
280            }
281        }
282    }
283    Ok(out)
284}
285
286/// Project a shape into a drawing whose silhouettes and visibility are
287/// exact.
288///
289/// The edges and silhouettes are sampled at `deflection` to become
290/// polylines, because a drawing is polylines. What is not sampled is the
291/// *geometry* they sample (exact curves on the surfaces, not mesh edges),
292/// or the classification, which asks the faces themselves whether anything
293/// stands between a point and the eye.
294///
295/// # Errors
296///
297/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if the
298/// shape has no faces, or the direction has no length. A marched silhouette
299/// whose walk fails, or that no fit holds to its chord, contributes no
300/// curve.
301pub fn project_exact(
302    model: &Model,
303    shape: &Shape,
304    view: &View,
305    deflection: Deflection,
306    tol: Tolerances,
307) -> OgeomResult<Drawing> {
308    let faces = Blockers::new(model, shape, view, tol)?;
309    if faces.list.is_empty() {
310        ogeom_bail!(Construction, "a shape with no faces draws nothing");
311    }
312
313    // Every curve is sampled before any is classified: the projection of
314    // each is a contour the others may pass behind.
315    let edges = explore_unique(model, shape, ShapeType::Edge)?;
316    let mut traced: Vec<Traced> = ogeom_core::parallel::map_ordered(&edges, |_, edge| {
317        traced_edge(model, edge, deflection, tol)
318    })
319    .into_iter()
320    .flatten()
321    .collect();
322    for silhouette in silhouettes(model, shape, view.toward_eye(), tol)? {
323        let line = ogeom_mesh::discretize(&silhouette.curve, silhouette.range, deflection, tol)?;
324        traced.push(Traced {
325            curve: silhouette.curve,
326            placement: Transform::IDENTITY,
327            parameters: line.parameters,
328            points: line.points,
329            source: Source::Silhouette,
330        });
331    }
332    let projected: Vec<Vec<Point2>> = traced
333        .iter()
334        .map(|t| t.points.iter().map(|p| view.project(*p)).collect())
335        .collect();
336    let contours = Contours::new(
337        projected
338            .iter()
339            .flat_map(|line| line.windows(2).map(|w| (w[0], w[1])))
340            .collect(),
341    );
342
343    // Each curve is classified on its own, and the runs are gathered in
344    // curve order.
345    let classified = ogeom_core::parallel::map_ordered(&traced, |i, curve| {
346        let mut drawing = Drawing::default();
347        classify(
348            &mut drawing,
349            curve,
350            &projected[i],
351            view,
352            &faces,
353            &contours,
354            tol,
355        )
356        .map(|()| drawing)
357    });
358    let mut drawing = Drawing::default();
359    for one in classified {
360        let one = one?;
361        drawing.visible.extend(one.visible);
362        drawing.hidden.extend(one.hidden);
363    }
364    Ok(drawing)
365}
366
367/// A curve to draw: its exact geometry, and the samples its polyline is
368/// drawn through.
369struct Traced {
370    curve: Curve,
371    /// Where the curve's own coordinates stand in the world.
372    placement: Transform,
373    /// The curve parameter at each sample, in drawing order.
374    parameters: Vec<f64>,
375    /// The samples, in the world.
376    points: Vec<Point>,
377    source: Source,
378}
379
380impl Traced {
381    /// The exact point at a position along the samples (`k + f`: the
382    /// fraction `f` of the way from sample `k` to the next in parameter).
383    fn at(&self, position: f64, tol: Tolerances) -> OgeomResult<Point> {
384        let (k, f) = locate(position, self.parameters.len());
385        let (t0, t1) = (self.parameters[k], self.parameters[k + 1]);
386        Ok(self
387            .placement
388            .apply(self.curve.point_at((t1 - t0).mul_add(f, t0), tol)?))
389    }
390}
391
392/// An edge's curve and samples, in the edge's own direction; `None` for an
393/// edge with no curve to draw.
394fn traced_edge(
395    model: &Model,
396    edge: &Shape,
397    deflection: Deflection,
398    tol: Tolerances,
399) -> Option<Traced> {
400    let data = model.node(edge)?.data().as_edge()?;
401    let Some(EdgeRepr::Curve3d { curve, range, .. }) = data.curve3d() else {
402        return None;
403    };
404    let curve = model.geometry().curve(*curve)?.clone();
405    let placement = edge.transform(model.datums()).ok()?;
406    let line = ogeom_mesh::discretize(&curve, *range, deflection, tol).ok()?;
407    let mut points: Vec<Point> = line.points.iter().map(|p| placement.apply(*p)).collect();
408    let mut parameters = line.parameters;
409    if edge.orientation() == Orientation::Reversed {
410        points.reverse();
411        parameters.reverse();
412    }
413    Some(Traced {
414        curve,
415        placement,
416        parameters,
417        points,
418        source: Source::Edge(edge.clone()),
419    })
420}
421
422/// A face that can stand between a point and the eye: its surface in world
423/// space and its trim as chart rings.
424struct Blocker {
425    surface: ogeom_intersect::PreparedSurface,
426    rings: Vec<Vec<Point2>>,
427    /// The projection of the corners of the face's box: a face whose
428    /// projected box misses a point's projection cannot hide it, and is not
429    /// intersected.
430    low: Point2,
431    high: Point2,
432    /// The depth of the box corner nearest the eye: a face wholly behind a
433    /// point cannot hide it either.
434    front: f64,
435    /// For a surface with no closed-form piercing, the face's box widened
436    /// by the chord its trim rings are drawn at. The seeded intersector
437    /// samples the curve it is given and polishes from every cell of the
438    /// surface near each sampled segment, so the ray is handed over as the
439    /// stretch of it inside this box. Over a line's whole domain the
440    /// segments are millions of units long, and the one through the face
441    /// is near every cell.
442    sampled: Option<(Point, Point)>,
443}
444
445/// The faces of a shape seen from one view, binned by their projected
446/// boxes so a point is tested only against the faces whose boxes cover it.
447struct Blockers {
448    list: Vec<Blocker>,
449    /// For each cell of a square grid over the drawing, the faces whose
450    /// projected box meets it, ascending.
451    cells: Vec<Vec<u32>>,
452    low: Point2,
453    size: f64,
454    side: usize,
455}
456
457impl Blockers {
458    fn new(model: &Model, shape: &Shape, view: &View, tol: Tolerances) -> OgeomResult<Self> {
459        let faces = explore_unique(model, shape, ShapeType::Face)?;
460        let built =
461            ogeom_core::parallel::map_ordered(&faces, |_, face| blocker(model, face, view, tol));
462        let mut list = Vec::with_capacity(built.len());
463        for one in built {
464            list.extend(one?);
465        }
466
467        let (mut low, mut high) = (
468            Point2::new(f64::INFINITY, f64::INFINITY),
469            Point2::new(f64::NEG_INFINITY, f64::NEG_INFINITY),
470        );
471        for b in &list {
472            low = Point2::new(low.x.min(b.low.x), low.y.min(b.low.y));
473            high = Point2::new(high.x.max(b.high.x), high.y.max(b.high.y));
474        }
475        #[allow(
476            clippy::cast_precision_loss,
477            clippy::cast_possible_truncation,
478            clippy::cast_sign_loss
479        )]
480        let side = ((list.len() as f64).sqrt().ceil() as usize * 2).clamp(1, 256);
481        let span = (high.x - low.x).max(high.y - low.y);
482        #[allow(clippy::cast_precision_loss)]
483        let size = if span.is_finite() && span > 0.0 {
484            span / side as f64
485        } else {
486            1.0
487        };
488        if !low.x.is_finite() || !low.y.is_finite() {
489            low = Point2::new(0.0, 0.0);
490        }
491        let mut out = Self {
492            list: Vec::new(),
493            cells: vec![Vec::new(); side * side],
494            low,
495            size,
496            side,
497        };
498        for (index, b) in list.iter().enumerate() {
499            #[allow(clippy::cast_possible_truncation)]
500            let index = index as u32;
501            let (c0, c1) = (out.cell(b.low.x, low.x), out.cell(b.high.x, low.x));
502            let (r0, r1) = (out.cell(b.low.y, low.y), out.cell(b.high.y, low.y));
503            for r in r0..=r1 {
504                for c in c0..=c1 {
505                    out.cells[r * side + c].push(index);
506                }
507            }
508        }
509        out.list = list;
510        Ok(out)
511    }
512
513    /// The grid column (or row) of a coordinate, clamped to the grid; a
514    /// box's ends land in the cells bracketing every point it covers.
515    fn cell(&self, x: f64, lo: f64) -> usize {
516        #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
517        let k = ((x - lo) / self.size).floor().max(0.0) as usize;
518        k.min(self.side - 1)
519    }
520
521    /// The faces whose projected box covers `seen` and that stand at least
522    /// partly in front of `depth`, by index, in face order.
523    fn covering(&self, seen: Point2, depth: f64) -> impl Iterator<Item = usize> + '_ {
524        let cell = self.cell(seen.y, self.low.y) * self.side + self.cell(seen.x, self.low.x);
525        self.cells[cell]
526            .iter()
527            .map(|&i| i as usize)
528            .filter(move |&i| self.list[i].covers(seen, depth))
529    }
530}
531
532impl Blocker {
533    /// Whether the face's projected box covers `seen` with some of the box
534    /// in front of `depth`.
535    fn covers(&self, seen: Point2, depth: f64) -> bool {
536        self.low.x <= seen.x
537            && seen.x <= self.high.x
538            && self.low.y <= seen.y
539            && seen.y <= self.high.y
540            && self.front > depth
541    }
542}
543
544/// One face as a blocker; `None` for a face with no surface or an empty box.
545fn blocker(
546    model: &Model,
547    face: &Shape,
548    view: &View,
549    tol: Tolerances,
550) -> OgeomResult<Option<Blocker>> {
551    let Some(NodeData::Face(data)) = model.node(face).map(|n| n.data()) else {
552        return Ok(None);
553    };
554    let Some(surface) = model.geometry().surface(data.surface) else {
555        return Ok(None);
556    };
557    let placement = face.transform(model.datums())?;
558    let surface = ogeom_geom::Transformable::transformed(surface, &placement, tol)?;
559    let deflection = Deflection::default();
560    let rings = ogeom_mesh::face_boundary(model, face, deflection, tol)?;
561    let bound = ogeom_algo::shape_bounds(model, face, tol)?.expanded(tol.confusion() * 1e3);
562    let sampled = match surface {
563        SurfaceGeometry::Plane(_)
564        | SurfaceGeometry::Sphere(_)
565        | SurfaceGeometry::Cylinder(_)
566        | SurfaceGeometry::Cone(_)
567        | SurfaceGeometry::Torus(_) => None,
568        _ => {
569            let wide = bound.expanded(deflection.chord);
570            wide.low().zip(wide.high())
571        }
572    };
573    let corners = bound.corners();
574    if corners.is_empty() {
575        return Ok(None);
576    }
577    let (mut low, mut high) = (
578        Point2::new(f64::INFINITY, f64::INFINITY),
579        Point2::new(f64::NEG_INFINITY, f64::NEG_INFINITY),
580    );
581    let mut front = f64::NEG_INFINITY;
582    for c in &corners {
583        let q = view.project(*c);
584        low = Point2::new(low.x.min(q.x), low.y.min(q.y));
585        high = Point2::new(high.x.max(q.x), high.y.max(q.y));
586        front = front.max(view.depth(*c));
587    }
588    Ok(Some(Blocker {
589        surface: ogeom_intersect::PreparedSurface::new(
590            surface,
591            ogeom_intersect::CurveSurfaceOptions::default(),
592            tol,
593        ),
594        rings,
595        low,
596        high,
597        front,
598        sampled,
599    }))
600}
601
602/// Split a curve into visible and hidden runs, asking the faces.
603///
604/// The samples are cut where the projection crosses another drawn curve,
605/// and each piece is classified at its middle on the exact curve. Where two
606/// neighbouring pieces disagree, the change is bisected on the curve until
607/// its two sides are the confusion tolerance apart, so the runs meet where
608/// the curve passes behind an outline rather than at a sample.
609fn classify(
610    drawing: &mut Drawing,
611    traced: &Traced,
612    projected: &[Point2],
613    view: &View,
614    faces: &Blockers,
615    contours: &Contours,
616    tol: Tolerances,
617) -> OgeomResult<()> {
618    if traced.points.len() < 2 {
619        return Ok(());
620    }
621    // Neighbouring points along a curve are mostly hidden by the same face,
622    // so the face that hid the last one is asked first.
623    let last = std::cell::Cell::new(None);
624    let seen = |position: f64| -> OgeomResult<Visibility> {
625        Ok(
626            if occluded(traced.at(position, tol)?, view, faces, &last, tol)? {
627                Visibility::Hidden
628            } else {
629                Visibility::Visible
630            },
631        )
632    };
633    let change_between = |mut lo: f64, mut hi: f64, was: Visibility| -> OgeomResult<f64> {
634        for _ in 0..64 {
635            if traced.at(lo, tol)?.distance(traced.at(hi, tol)?) <= tol.confusion() {
636                break;
637            }
638            let mid = f64::midpoint(lo, hi);
639            if seen(mid)? == was {
640                lo = mid;
641            } else {
642                hi = mid;
643            }
644        }
645        Ok(f64::midpoint(lo, hi))
646    };
647    let drawn_at =
648        |position: f64| -> OgeomResult<Point2> { Ok(view.project(traced.at(position, tol)?)) };
649    let mut flush = |run: &mut Vec<Point2>, visibility: Visibility| {
650        if run.len() < 2 {
651            run.clear();
652            return;
653        }
654        let curve = DrawnCurve {
655            points: std::mem::take(run),
656            visibility,
657            source: traced.source.clone(),
658        };
659        if visibility == Visibility::Hidden {
660            drawing.hidden.push(curve);
661        } else {
662            drawing.visible.push(curve);
663        }
664    };
665
666    let positions = contours.split(projected, tol);
667    let middles: Vec<f64> = positions
668        .windows(2)
669        .map(|w| f64::midpoint(w[0], w[1]))
670        .collect();
671    let mut verdicts = Vec::with_capacity(middles.len());
672    for middle in &middles {
673        verdicts.push(seen(*middle)?);
674    }
675    let Some(&first) = verdicts.first() else {
676        return Ok(());
677    };
678    let mut run = vec![drawn_at(positions[0])?];
679    let mut held = first;
680    for (i, &verdict) in verdicts.iter().enumerate() {
681        if verdict != held {
682            // The run so far ends at the change and the next starts there;
683            // the piece boundary goes to whichever side it is on.
684            let boundary = positions[i];
685            let change = change_between(middles[i - 1], middles[i], held)?;
686            let at_change = drawn_at(change)?;
687            if change < boundary {
688                run.pop();
689                run.push(at_change);
690                flush(&mut run, held);
691                run = vec![at_change, drawn_at(boundary)?];
692            } else {
693                run.push(at_change);
694                flush(&mut run, held);
695                run = vec![at_change];
696            }
697            held = verdict;
698        }
699        run.push(drawn_at(positions[i + 1])?);
700    }
701    flush(&mut run, held);
702    Ok(())
703}
704
705/// Whether anything stands between `at` and the eye.
706///
707/// The answer is whether any face hides the point, so the order the faces
708/// are asked in changes only the time: the face in `last` is asked first,
709/// and the face that hides the point is left there.
710fn occluded(
711    at: Point,
712    view: &View,
713    faces: &Blockers,
714    last: &std::cell::Cell<Option<usize>>,
715    tol: Tolerances,
716) -> OgeomResult<bool> {
717    let toward = view.toward_eye();
718    let magnitude = toward.magnitude();
719    if magnitude <= tol.confusion() {
720        return Ok(false);
721    }
722    let direction = toward / magnitude;
723    // Far enough to leave any shape it started inside, and started far
724    // enough along not to strike the surface the point is on.
725    let reach = 1e6;
726    let clearance = tol.confusion() * 1e3;
727    let axis = Axis::new(at, Direction::new(direction, tol)?);
728    let ray = Curve::Line(LineCurve::new(axis));
729    let hides = |face: &Blocker| -> OgeomResult<bool> {
730        let found = match face.sampled {
731            None => face.surface.intersect(&ray)?,
732            Some((low, high)) => {
733                let Some((enter, leave)) = through_box(at, direction, low, high) else {
734                    return Ok(false);
735                };
736                let enter = enter.max(0.0);
737                if leave <= enter.max(clearance) {
738                    return Ok(false);
739                }
740                face.surface
741                    .intersect(&Curve::Line(LineCurve::over(axis, enter, leave)?))?
742            }
743        };
744        Ok(found.crossings.iter().any(|piercing| {
745            piercing.on_curve > clearance
746                && piercing.on_curve < reach
747                && inside_rings(
748                    &face.rings,
749                    Point2::new(piercing.on_surface.0, piercing.on_surface.1),
750                )
751        }))
752    };
753    let (seen, depth) = (view.project(at), view.depth(at));
754    let first = last.get().filter(|&i| faces.list[i].covers(seen, depth));
755    if let Some(i) = first
756        && hides(&faces.list[i])?
757    {
758        return Ok(true);
759    }
760    for i in faces.covering(seen, depth) {
761        if Some(i) != first && hides(&faces.list[i])? {
762            last.set(Some(i));
763            return Ok(true);
764        }
765    }
766    Ok(false)
767}
768
769/// Where the line `from + t * direction` is inside the box `low`-`high`,
770/// as the interval of `t`; `None` when it misses.
771fn through_box(from: Point, direction: Vector, low: Point, high: Point) -> Option<(f64, f64)> {
772    let (mut enter, mut leave) = (f64::NEG_INFINITY, f64::INFINITY);
773    for (o, d, lo, hi) in [
774        (from.x, direction.x, low.x, high.x),
775        (from.y, direction.y, low.y, high.y),
776        (from.z, direction.z, low.z, high.z),
777    ] {
778        if d == 0.0 {
779            if o < lo || o > hi {
780                return None;
781            }
782            continue;
783        }
784        let (a, b) = ((lo - o) / d, (hi - o) / d);
785        enter = enter.max(a.min(b));
786        leave = leave.min(a.max(b));
787    }
788    (enter <= leave).then_some((enter, leave))
789}
790
791/// The stretches of a curve that lie within a face's trim.
792fn within_trim(
793    model: &Model,
794    face: &Shape,
795    surface: &SurfaceGeometry,
796    seeds: &ogeom_algo::SurfaceSeeds,
797    rings: &[Vec<Point2>],
798    curve: &Curve,
799    tol: Tolerances,
800) -> OgeomResult<Vec<(f64, f64)>> {
801    let (t0, t1) = curve.domain();
802    // A line's domain is the whole real line as far as the type is
803    // concerned. A silhouette on one is only interesting where the face is,
804    // so an unbounded curve is walked over the face's own reach.
805    let (t0, t1) = if t0.is_finite() && t1.is_finite() && t1 - t0 < 1e6 {
806        (t0, t1)
807    } else {
808        let mut bound = ogeom_math::Aabb::EMPTY;
809        for vertex in explore_unique(model, face, ShapeType::Vertex)? {
810            if let Some(data) = model.node(&vertex).and_then(|n| n.data().as_vertex()) {
811                bound = bound.with_point(vertex.transform(model.datums())?.apply(data.point));
812            }
813        }
814        let reach = bound.diagonal().max(1.0);
815        let centre = bound.centre().unwrap_or(Point::ORIGIN);
816        let at = ogeom_algo::project_on_curve(curve, centre, 64, tol)?.parameter;
817        (at - reach, at + reach)
818    };
819
820    const STATIONS: usize = 96;
821    let held_at = |t: f64| -> OgeomResult<bool> {
822        let Ok(point) = curve.point_at(t, tol) else {
823            return Ok(false);
824        };
825        let projection = seeds.project(surface, point, tol)?;
826        let (u, v) = projection.parameters;
827        // The projection clamps to the surface's own window, so a point
828        // just past the end of a face comes back with a foot *at* the end
829        // and the overshoot as its distance. Holding that to the confusion
830        // tolerance is what stops a silhouette running off its own face.
831        Ok(projection.distance <= tol.confusion() && inside_rings(rings, Point2::new(u, v)))
832    };
833    // Where the answer changes between two stations, the edge of the face
834    // is between them. Bisecting says where to a part in a million of a
835    // station, so a silhouette ends *on* its face rather than a station
836    // past it.
837    let edge_between = |inside: f64, outside: f64| -> OgeomResult<f64> {
838        let (mut lo, mut hi) = (inside, outside);
839        for _ in 0..40 {
840            let mid = f64::midpoint(lo, hi);
841            if held_at(mid)? {
842                lo = mid;
843            } else {
844                hi = mid;
845            }
846        }
847        Ok(lo)
848    };
849
850    let mut out = Vec::new();
851    let mut open: Option<f64> = None;
852    let mut previous: Option<(f64, bool)> = None;
853    for k in 0..=STATIONS {
854        #[expect(
855            clippy::cast_precision_loss,
856            reason = "a station index, far below the mantissa"
857        )]
858        let t = (t1 - t0).mul_add(k as f64 / STATIONS as f64, t0);
859        let held = held_at(t)?;
860        match (held, open) {
861            (true, None) => {
862                open = Some(match previous {
863                    Some((was, false)) => edge_between(t, was)?,
864                    _ => t,
865                });
866            }
867            (false, Some(from)) => {
868                let to = match previous {
869                    Some((was, true)) => edge_between(was, t)?,
870                    _ => t,
871                };
872                if to - from > tol.parametric() {
873                    out.push((from, to));
874                }
875                open = None;
876            }
877            _ => {}
878        }
879        previous = Some((t, held));
880    }
881    if let Some(from) = open
882        && t1 - from > tol.parametric()
883    {
884        out.push((from, t1));
885    }
886    Ok(out)
887}
888
889/// Even-odd containment against chart rings.
890fn inside_rings(rings: &[Vec<Point2>], p: Point2) -> bool {
891    let mut inside = false;
892    for ring in rings {
893        for i in 0..ring.len() {
894            let (a, b) = (ring[i], ring[(i + 1) % ring.len()]);
895            if (a.y > p.y) != (b.y > p.y) {
896                let x = (b.x - a.x).mul_add((p.y - a.y) / (b.y - a.y), a.x);
897                if x > p.x {
898                    inside = !inside;
899                }
900            }
901        }
902    }
903    inside
904}
905
906/// Any unit vector perpendicular to `v`.
907fn perpendicular(v: Vector, tol: Tolerances) -> OgeomResult<Direction> {
908    let seed = if v.x.abs() < 0.9 {
909        Vector::X
910    } else {
911        Vector::Y
912    };
913    Direction::new(v.cross(seed), tol)
914}
915
916// --- the marched silhouette --------------------------------------------------
917
918/// A surface's silhouette, as a condition the shared walker can follow.
919///
920/// The whole content of a silhouette is one equation on the surface's own
921/// chart: the normal is square to the view,
922///
923/// > `n(u, v) · d = 0`
924///
925/// which is one equation in two unknowns, and one equation in two unknowns
926/// is a curve. So a torus's silhouette needs no machinery a surface
927/// intersection did not already need: it is the same walk, following a
928/// different condition.
929///
930/// Stated with the **unit** normal. The unnormalized `Sᵤ × Sᵥ` has the same
931/// zero set and a simpler derivative, but its residual carries the surface's
932/// own scale: a correction that drives `|Sᵤ × Sᵥ| · d` below a *length*
933/// tolerance demands the angle tighter by the surface's own size, and the
934/// walk halves its step until it crawls. A dimensionless residual is a
935/// dimensionless tolerance.
936struct SilhouetteOn<'s> {
937    surface: &'s SurfaceGeometry,
938    along: Vector,
939    /// A length scale, for the walker's step control.
940    reach: f64,
941}
942
943impl ogeom_intersect::walk::Condition for SilhouetteOn<'_> {
944    fn unknowns(&self) -> usize {
945        2
946    }
947
948    fn position(&self, x: &[f64], tol: Tolerances) -> Option<Point> {
949        self.surface.point_at(x[0], x[1], tol).ok()
950    }
951
952    fn position_gradient(&self, x: &[f64], tol: Tolerances) -> Option<Vec<Vector>> {
953        let (du, dv) = self.surface.d1_at(x[0], x[1], tol).ok()?;
954        Some(vec![du, dv])
955    }
956
957    fn system(&self, x: &[f64], tol: Tolerances) -> Option<(Vec<f64>, Vec<Vec<f64>>)> {
958        Some(self.system_at(x, tol)?.0)
959    }
960
961    fn system_at(
962        &self,
963        x: &[f64],
964        tol: Tolerances,
965    ) -> Option<((Vec<f64>, Vec<Vec<f64>>), Point, Vec<Vector>)> {
966        // One jet: the residual, the walker's point and its gradient all
967        // come from the same evaluation.
968        let jet = self.surface.jet_at(x[0], x[1], tol).ok()?;
969        let (su, sv) = (jet.du, jet.dv);
970        let (suu, suv, svv) = (jet.d2u, jet.duv, jet.d2v);
971        let cross = su.cross(sv);
972        let length = cross.magnitude();
973        if length <= tol.confusion() {
974            return None;
975        }
976        let normal = cross / length;
977        // The unit normal's own derivative: the part of the unnormalized
978        // one's across the normal, over the length. The projection is what
979        // keeps a unit vector unit.
980        let across = |d: Vector| (d - normal * d.dot(normal)) / length;
981        let du = across(suu.cross(sv) + su.cross(suv));
982        let dv = across(suv.cross(sv) + su.cross(svv));
983        Some((
984            (
985                vec![normal.dot(self.along)],
986                vec![vec![du.dot(self.along), dv.dot(self.along)]],
987            ),
988            jet.point,
989            vec![su, sv],
990        ))
991    }
992
993    fn tangent_from(
994        &self,
995        _x: &[f64],
996        jacobian: &[Vec<f64>],
997        gradient: &[Vector],
998        tol: Tolerances,
999    ) -> Option<Vector> {
1000        ogeom_intersect::walk::null_tangent(jacobian, gradient, tol)
1001    }
1002
1003    fn clamp(&self, x: &mut [f64]) {
1004        let ((ua, ub), (va, vb)) = self.surface.domain();
1005        let hold = |value: f64, lo: f64, hi: f64, periodic: bool| {
1006            if periodic && hi > lo {
1007                lo + (value - lo).rem_euclid(hi - lo)
1008            } else {
1009                value.clamp(lo, hi)
1010            }
1011        };
1012        x[0] = hold(x[0], ua, ub, self.surface.is_periodic_u());
1013        x[1] = hold(x[1], va, vb, self.surface.is_periodic_v());
1014    }
1015
1016    fn outside(&self, x: &[f64], tol: Tolerances) -> bool {
1017        let ((ua, ub), (va, vb)) = self.surface.domain();
1018        let band = tol.parametric();
1019        (!self.surface.is_periodic_u() && (x[0] < ua - band || x[0] > ub + band))
1020            || (!self.surface.is_periodic_v() && (x[1] < va - band || x[1] > vb + band))
1021    }
1022
1023    fn near_edge(&self, x: &[f64]) -> bool {
1024        let ((ua, ub), (va, vb)) = self.surface.domain();
1025        let near = |value: f64, lo: f64, hi: f64| {
1026            let reach = (hi - lo) * 1e-6;
1027            value <= lo + reach || value >= hi - reach
1028        };
1029        (!self.surface.is_periodic_u() && near(x[0], ua, ub))
1030            || (!self.surface.is_periodic_v() && near(x[1], va, vb))
1031    }
1032
1033    fn extent(&self) -> f64 {
1034        self.reach
1035    }
1036}
1037
1038/// How far a point stands from a polyline, segment by segment.
1039fn on_polyline(line: &[Point], p: Point) -> f64 {
1040    let mut best = f64::INFINITY;
1041    for pair in line.windows(2) {
1042        let (a, b) = (pair[0], pair[1]);
1043        let d = b - a;
1044        let len2 = d.dot(d);
1045        let t = if len2 > 0.0 {
1046            ((p - a).dot(d) / len2).clamp(0.0, 1.0)
1047        } else {
1048            0.0
1049        };
1050        best = best.min(p.distance(a + d * t));
1051    }
1052    best
1053}
1054
1055/// The curve a walked silhouette is drawn as: within `chord` of the true
1056/// silhouette at every point measured, or `None`.
1057///
1058/// A fit through the walked points alone is measured at those points only,
1059/// and one through a silhouette that turns hard between them (a bumpy
1060/// spline seen from the side, a fat torus at a slant) can miss by most of
1061/// the surface's own size while every point it was fitted through is on
1062/// the line. So the fit is made against the silhouette itself: at any
1063/// parameter between two walked points the true silhouette point is solved
1064/// for, on the condition and in the plane across the walk's chord there,
1065/// and the fit is measured against those points between its samples and
1066/// refined until it holds. One that cannot be held, or a stretch of the
1067/// walk with no silhouette under it to solve for, gives `None`.
1068fn fitted_silhouette(
1069    condition: &SilhouetteOn<'_>,
1070    walked: &ogeom_intersect::walk::Walked,
1071    chord: f64,
1072    tol: Tolerances,
1073) -> Option<ogeom_geom::BSplineCurve> {
1074    // Chord length along the walk, with repeated points dropped so the
1075    // parameters strictly increase.
1076    let mut ts: Vec<f64> = Vec::with_capacity(walked.points.len());
1077    let mut kept: Vec<usize> = Vec::with_capacity(walked.points.len());
1078    for (i, p) in walked.points.iter().enumerate() {
1079        match kept.last() {
1080            None => ts.push(0.0),
1081            Some(&last) => {
1082                let step = p.distance(walked.points[last]);
1083                if step <= tol.confusion() {
1084                    continue;
1085                }
1086                ts.push(ts[ts.len() - 1] + step);
1087            }
1088        }
1089        kept.push(i);
1090    }
1091    if kept.len() < 2 {
1092        return None;
1093    }
1094    let trace = |t: f64| silhouette_between(condition, walked, &kept, &ts, t, chord, tol);
1095    let fitted = ogeom_geom::fit::fit_curve_sampled(trace, &ts, false, 3, chord, tol).ok()?;
1096    fitted.met.then_some(fitted.curve)
1097}
1098
1099/// The true silhouette point at chord-length `t` along a walk: between the
1100/// walked points it brackets, the point of the silhouette in the plane
1101/// across their chord at that fraction of the way along it.
1102fn silhouette_between(
1103    condition: &SilhouetteOn<'_>,
1104    walked: &ogeom_intersect::walk::Walked,
1105    kept: &[usize],
1106    ts: &[f64],
1107    t: f64,
1108    chord: f64,
1109    tol: Tolerances,
1110) -> OgeomResult<Point> {
1111    use ogeom_intersect::walk::Condition as _;
1112    let k = ts.partition_point(|&s| s <= t).clamp(1, ts.len() - 1) - 1;
1113    let f = (t - ts[k]) / (ts[k + 1] - ts[k]);
1114    let (i, j) = (kept[k], kept[k + 1]);
1115    let (a, b) = (walked.points[i], walked.points[j]);
1116    if f <= 0.0 {
1117        return Ok(a);
1118    }
1119    if f >= 1.0 {
1120        return Ok(b);
1121    }
1122    let span = b - a;
1123    let length = span.magnitude();
1124    let across = span / length;
1125    let target = a + span * f;
1126    // Two points closer than a tenth of the chord: an arc between them that
1127    // turns by less than a reversal sags from their chord by under four
1128    // tenths of its length, far inside the chord. The walk takes such steps
1129    // where it closes on a chart's edge, and the solve there would be
1130    // pushed off the chart.
1131    if length <= chord * 0.1 {
1132        return Ok(target);
1133    }
1134    // The chart position as far between the two states, the shorter way
1135    // round a periodic direction.
1136    let ((ua, ub), (va, vb)) = condition.surface.domain();
1137    let periods = [
1138        condition.surface.is_periodic_u().then_some(ub - ua),
1139        condition.surface.is_periodic_v().then_some(vb - va),
1140    ];
1141    let mut x = [0.0_f64; 2];
1142    for (axis, period) in periods.iter().enumerate() {
1143        let (from, to) = (walked.states[i][axis], walked.states[j][axis]);
1144        let mut gap = to - from;
1145        if let Some(period) = period {
1146            gap -= period * (gap / period).round();
1147        }
1148        x[axis] = gap.mul_add(f, from);
1149    }
1150    // Newton on the silhouette condition and the plane across the chord.
1151    for _ in 0..32 {
1152        condition.clamp(&mut x);
1153        let Some(((residual, jacobian), at, gradient)) = condition.system_at(&x, tol) else {
1154            break;
1155        };
1156        let (g, (gu, gv)) = (residual[0], (jacobian[0][0], jacobian[0][1]));
1157        let (pu, pv) = (gradient[0].dot(across), gradient[1].dot(across));
1158        let h = (target - at).dot(across);
1159        let det = gu.mul_add(pv, -(gv * pu));
1160        if !det.is_normal() {
1161            break;
1162        }
1163        let du = (-g).mul_add(pv, -(gv * h)) / det;
1164        let dv = gu.mul_add(h, pu * g) / det;
1165        x[0] += du;
1166        x[1] += dv;
1167        let moved = (gradient[0] * du + gradient[1] * dv).magnitude();
1168        if moved <= tol.confusion() * 1e-3 {
1169            condition.clamp(&mut x);
1170            let Some(point) = condition.position(&x, tol) else {
1171                break;
1172            };
1173            // A solve that left the stretch between the two walked points
1174            // has found some other part of the silhouette.
1175            if point.distance(target) > length {
1176                break;
1177            }
1178            return Ok(point);
1179        }
1180    }
1181    ogeom_bail!(
1182        NotDone,
1183        "the silhouette between two walked points did not solve"
1184    )
1185}
1186
1187/// Silhouettes of a surface with no closed form, marched.
1188///
1189/// Seeded the way the intersector seeds: the chart is sampled on a grid and
1190/// every sign change of `n · d` between neighbours is a starting point,
1191/// refined onto the condition before the walk begins. A silhouette loop
1192/// smaller than one grid cell is stepped over.
1193///
1194/// Each walked polyline becomes a curve held to the walk's chord against
1195/// the silhouette itself, between the walked points as well as at them
1196/// (see [`fitted_silhouette`]), so what comes back is worth the chord
1197/// rather than exactness. A walk no curve can be held to the chord along
1198/// contributes none.
1199fn marched_silhouettes(
1200    surface: &SurfaceGeometry,
1201    along: Vector,
1202    tol: Tolerances,
1203) -> OgeomResult<Vec<Curve>> {
1204    use ogeom_intersect::walk::Condition as _;
1205    let options = ogeom_intersect::Marching {
1206        chord: tol.confusion() * 1e2,
1207        ..ogeom_intersect::Marching::default()
1208    };
1209    let ((ua, ub), (va, vb)) = surface.domain();
1210    // The step control wants a *length*, and it must be the surface's own:
1211    // a torus's face is bounded by a seam and one vertex, so its vertices'
1212    // bounding box is a point, and a step control fed that walks the whole
1213    // ring in steps of a ten-thousandth.
1214    let reach = {
1215        let mut bound = ogeom_math::Aabb::EMPTY;
1216        for i in 0..=8 {
1217            for j in 0..=8 {
1218                let u = (ub - ua).mul_add(f64::from(i) / 8.0, ua);
1219                let v = (vb - va).mul_add(f64::from(j) / 8.0, va);
1220                if let Ok(p) = surface.point_at(u, v, tol) {
1221                    bound = bound.with_point(p);
1222                }
1223            }
1224        }
1225        bound.diagonal().max(tol.confusion() * 1e3)
1226    };
1227    let condition = SilhouetteOn {
1228        surface,
1229        along,
1230        reach,
1231    };
1232    let value = |u: f64, v: f64| -> Option<f64> {
1233        let (su, sv) = surface.d1_at(u, v, tol).ok()?;
1234        Some(su.cross(sv).dot(along))
1235    };
1236
1237    // Seeds: a sign change between grid neighbours, bisected to the crossing
1238    // and then corrected onto the condition by the walker's own solve.
1239    let mut seeds: Vec<[f64; 2]> = Vec::new();
1240    let steps = options.grid;
1241    #[expect(clippy::cast_precision_loss, reason = "a grid index")]
1242    let at = |i: usize, n: usize, lo: f64, hi: f64| lo + (hi - lo) * (i as f64) / (n as f64);
1243    // Each grid point is evaluated once and read by up to three cell edges.
1244    let grid: Vec<Option<f64>> = (0..=steps)
1245        .flat_map(|i| (0..=steps).map(move |j| (i, j)))
1246        .map(|(i, j)| value(at(i, steps, ua, ub), at(j, steps, va, vb)))
1247        .collect();
1248    for i in 0..=steps {
1249        for j in 0..=steps {
1250            let (u, v) = (at(i, steps, ua, ub), at(j, steps, va, vb));
1251            let Some(here) = grid[i * (steps + 1) + j] else {
1252                continue;
1253            };
1254            for (du, dv) in [(1_usize, 0_usize), (0, 1)] {
1255                if i + du > steps || j + dv > steps {
1256                    continue;
1257                }
1258                let (u2, v2) = (at(i + du, steps, ua, ub), at(j + dv, steps, va, vb));
1259                let Some(there) = grid[(i + du) * (steps + 1) + j + dv] else {
1260                    continue;
1261                };
1262                if here.signum() == there.signum() || here == 0.0 {
1263                    continue;
1264                }
1265                // Bisect to the crossing along the cell edge.
1266                let (mut lo, mut hi) = (0.0_f64, 1.0_f64);
1267                for _ in 0..40 {
1268                    let mid = f64::midpoint(lo, hi);
1269                    let (um, vm) = (u + (u2 - u) * mid, v + (v2 - v) * mid);
1270                    let Some(m) = value(um, vm) else { break };
1271                    if m.signum() == here.signum() {
1272                        lo = mid;
1273                    } else {
1274                        hi = mid;
1275                    }
1276                }
1277                let mid = f64::midpoint(lo, hi);
1278                seeds.push([u + (u2 - u) * mid, v + (v2 - v) * mid]);
1279            }
1280        }
1281    }
1282
1283    let mut out = Vec::new();
1284    let mut walked_points: Vec<Vec<Point>> = Vec::new();
1285    for seed in seeds {
1286        let mut start = seed;
1287        condition.clamp(&mut start);
1288        // A seed already covered by a curve found earlier is the same branch
1289        // met in another cell, not a new one.
1290        let Some(here) = condition.position(&start, tol) else {
1291            continue;
1292        };
1293        // Against the polyline, not its vertices: the walk's own step is
1294        // hundreds of times the chord, so a seed landing between two points
1295        // of a curve already found would otherwise be walked all over again.
1296        // A torus seen down its axis has a seed in every grid column of both
1297        // equators.
1298        if walked_points
1299            .iter()
1300            .any(|line| on_polyline(line, here) <= options.chord * 32.0)
1301        {
1302            continue;
1303        }
1304        let Ok(walked) = ogeom_intersect::walk::follow(&condition, &start, options, tol) else {
1305            continue;
1306        };
1307        if walked.points.len() < 4 {
1308            continue;
1309        }
1310        // Held to the walk's chord between the walked points as well as at
1311        // them. One that cannot be is not drawn as though it were.
1312        let Some(curve) = fitted_silhouette(&condition, &walked, options.chord, tol) else {
1313            continue;
1314        };
1315        walked_points.push(walked.points);
1316        out.push(Curve::BSpline(curve));
1317    }
1318    Ok(out)
1319}
1320
1321#[cfg(test)]
1322#[allow(clippy::unwrap_used, clippy::expect_used, reason = "test code")]
1323mod tests {
1324    use super::*;
1325
1326    const T: Tolerances = Tolerances::millimetres();
1327
1328    /// `n · d` at a chart position, with the unit normal the surface's own.
1329    fn turned(surface: &SurfaceGeometry, along: Vector, x: [f64; 2]) -> Option<f64> {
1330        Some(surface.normal_at(x[0], x[1], T).ok()?.vector().dot(along))
1331    }
1332
1333    /// How far `p` stands from the true silhouette near chart position `x`:
1334    /// Newton on the silhouette condition and on the foot of `p` along it,
1335    /// the condition's gradient by central differences, so nothing here is
1336    /// the walker's own. `x` is left at the foot.
1337    fn off_silhouette(
1338        surface: &SurfaceGeometry,
1339        along: Vector,
1340        p: Point,
1341        x: &mut [f64; 2],
1342    ) -> Option<f64> {
1343        for _ in 0..50 {
1344            let g = turned(surface, along, *x)?;
1345            let (gu, gv) = (slope(surface, along, *x, 0)?, slope(surface, along, *x, 1)?);
1346            let (su, sv) = surface.d1_at(x[0], x[1], T).ok()?;
1347            let at = surface.point_at(x[0], x[1], T).ok()?;
1348            // Along the silhouette: the chart direction `g` does not change in.
1349            let tangent = su * -gv + sv * gu;
1350            let tangent = tangent / tangent.magnitude();
1351            let (pu, pv, r) = (su.dot(tangent), sv.dot(tangent), (p - at).dot(tangent));
1352            let det = gu * pv - gv * pu;
1353            let du = (-g * pv - gv * r) / det;
1354            let dv = (gu * r + pu * g) / det;
1355            x[0] += du;
1356            x[1] += dv;
1357            hold(surface, x);
1358            if du.abs().max(dv.abs()) < 1e-13 {
1359                break;
1360            }
1361        }
1362        if turned(surface, along, *x)?.abs() <= 1e-9 {
1363            return Some(p.distance(surface.point_at(x[0], x[1], T).ok()?));
1364        }
1365        // The foot is past the chart's edge, where a drawn line ends: onto
1366        // the silhouette along either chart direction alone, which lands on
1367        // it no nearer than its nearest point.
1368        let mut nearest: Option<f64> = None;
1369        for axis in 0..2 {
1370            let mut y = *x;
1371            for _ in 0..50 {
1372                let g = turned(surface, along, y)?;
1373                y[axis] -= g / slope(surface, along, y, axis)?;
1374                hold(surface, &mut y);
1375            }
1376            if turned(surface, along, y)?.abs() <= 1e-9 {
1377                let miss = p.distance(surface.point_at(y[0], y[1], T).ok()?);
1378                nearest = Some(nearest.map_or(miss, |n| n.min(miss)));
1379            }
1380        }
1381        nearest
1382    }
1383
1384    /// How `n · d` changes along one chart direction: a central difference,
1385    /// its ends held inside the surface's window.
1386    fn slope(surface: &SurfaceGeometry, along: Vector, x: [f64; 2], axis: usize) -> Option<f64> {
1387        let (mut ahead, mut behind) = (x, x);
1388        ahead[axis] += 1e-6;
1389        behind[axis] -= 1e-6;
1390        hold(surface, &mut ahead);
1391        hold(surface, &mut behind);
1392        let run = ahead[axis] - behind[axis];
1393        Some((turned(surface, along, ahead)? - turned(surface, along, behind)?) / run)
1394    }
1395
1396    /// A chart position held inside the surface's window along a direction
1397    /// that is not periodic; a periodic one runs on.
1398    fn hold(surface: &SurfaceGeometry, x: &mut [f64; 2]) {
1399        let ((ua, ub), (va, vb)) = surface.domain();
1400        if !surface.is_periodic_u() {
1401            x[0] = x[0].clamp(ua, ub);
1402        }
1403        if !surface.is_periodic_v() {
1404            x[1] = x[1].clamp(va, vb);
1405        }
1406    }
1407
1408    /// The marched silhouettes of `surface` along `along`, and the widest
1409    /// distance from any of them to the true silhouette at 32 points a
1410    /// span.
1411    fn widest_miss(surface: &SurfaceGeometry, along: Vector) -> (usize, f64) {
1412        let along = along.normalized(T).unwrap();
1413        let curves = marched_silhouettes(surface, along, T).unwrap();
1414        let mut widest = 0.0_f64;
1415        for curve in &curves {
1416            let Curve::BSpline(spline) = curve else {
1417                panic!("a marched silhouette is a spline");
1418            };
1419            let (lo, hi) = spline.domain();
1420            let n = (spline.knots().distinct().len() * 32).max(1024);
1421            let start = curve.point_at(lo, T).unwrap();
1422            let foot = ogeom_geom::project_on_surface(surface, start, 16, T).unwrap();
1423            let mut x = [foot.parameters.0, foot.parameters.1];
1424            for i in 0..=n {
1425                #[expect(clippy::cast_precision_loss, reason = "a sample index")]
1426                let t = (hi - lo).mul_add(i as f64 / n as f64, lo);
1427                let p = curve.point_at(t, T).unwrap();
1428                let miss = off_silhouette(surface, along, p, &mut x).unwrap_or_else(|| {
1429                    panic!("no silhouette found beside {p:?} at {t} of {lo}..{hi}, from {x:?}")
1430                });
1431                widest = widest.max(miss);
1432            }
1433        }
1434        (curves.len(), widest)
1435    }
1436
1437    /// A spline surface from a grid over the unit square.
1438    fn spline(f: impl Fn(f64, f64) -> Point, n: u32) -> SurfaceGeometry {
1439        let rows: Vec<Vec<Point>> = (0..n)
1440            .map(|j| {
1441                (0..n)
1442                    .map(|i| {
1443                        f(
1444                            f64::from(i) / f64::from(n - 1),
1445                            f64::from(j) / f64::from(n - 1),
1446                        )
1447                    })
1448                    .collect()
1449            })
1450            .collect();
1451        SurfaceGeometry::BSpline(
1452            ogeom_geom::fit::fit_surface_grid(&rows, 3, 1e-6, T)
1453                .unwrap()
1454                .curve,
1455        )
1456    }
1457
1458    /// A marched silhouette is drawn within the walk's chord of the true
1459    /// silhouette between the walked points, not only at them. A fit through
1460    /// the walked points alone draws these outlines up to 1.6 off the bumpy
1461    /// sheet's silhouette, 0.7 off the fat torus's and 0.004 off the spline
1462    /// cone's, against a chord of 1e-5, with every walked point on the
1463    /// line.
1464    #[test]
1465    fn a_marched_silhouette_keeps_to_its_chord_between_its_points() {
1466        let chord = T.confusion() * 1e2;
1467        let bumps = spline(
1468            |x, y| {
1469                let (x, y) = (10.0f64.mul_add(x, -5.0), 10.0f64.mul_add(y, -5.0));
1470                Point::new(x, y, (x * 1.3).sin() * (y * 1.1).cos() * 2.0)
1471            },
1472            25,
1473        );
1474        let fat = SurfaceGeometry::Torus(ogeom_geom::TorusSurface::new(
1475            ogeom_math::Torus::new(Frame::WORLD, 3.0, 2.9, T).unwrap(),
1476        ));
1477        let cone = spline(
1478            |x, y| {
1479                let around = x * std::f64::consts::TAU;
1480                let r = 2.0f64.mul_add(y, 0.2);
1481                Point::new(r * around.cos(), r * around.sin(), 3.0 * y)
1482            },
1483            33,
1484        );
1485        for (what, surface, along, branches) in [
1486            ("a bumpy sheet", &bumps, Vector::new(0.3, 0.5, 1.0), None),
1487            ("a bumpy sheet", &bumps, Vector::X, None),
1488            ("a fat torus", &fat, Vector::new(1.0, 1.0, 1.0), Some(2)),
1489            ("a spline cone", &cone, Vector::new(1.0, 0.0, 0.0), Some(2)),
1490        ] {
1491            let (count, widest) = widest_miss(surface, along);
1492            assert!(count > 0, "{what} seen along {along:?} has an outline");
1493            if let Some(branches) = branches {
1494                assert_eq!(count, branches, "{what} seen along {along:?}");
1495            }
1496            assert!(
1497                widest <= chord,
1498                "{what} seen along {along:?} is drawn {widest:e} off its silhouette"
1499            );
1500        }
1501    }
1502}