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) is
18//! refused by name rather than approximated here. The polygonal path draws
19//! those, and says so.
20
21use ogeom_core::{OgeomResult, Tolerances, ogeom_bail};
22use ogeom_geom::{CircleCurve, Curve, Curve3d as _, LineCurve, Surface as _, SurfaceGeometry};
23use ogeom_math::{Axis, Circle, Direction, Frame, Point, Point2, Vector};
24use ogeom_mesh::Deflection;
25use ogeom_topo::{Model, NodeData, Shape, ShapeType, explore_unique};
26
27use crate::project::{Drawing, DrawnCurve, Source, View, Visibility};
28
29/// One silhouette curve, and the face it belongs to.
30#[derive(Debug, Clone)]
31pub struct Silhouette {
32    /// The face whose surface turns away here.
33    pub face: Shape,
34    /// The curve, in space.
35    pub curve: Curve,
36    /// The portion of it that lies within the face's trim.
37    pub range: (f64, f64),
38}
39
40/// The exact silhouettes of a shape, seen along `direction`.
41///
42/// A silhouette is the locus where the surface normal is perpendicular to
43/// the view: for a sphere the great circle whose plane the direction is
44/// normal to, for a cylinder the two rulings furthest to either side, for a
45/// cone the two rulings through its apex where the same holds. Planes have
46/// none (a plane either faces the eye or does not), and a face whose
47/// surface has no closed-form silhouette is refused by name.
48///
49/// Each curve comes back trimmed to the stretch that lies within its own
50/// face, decided by sampling the face's trim, so a silhouette on a face
51/// that was cut away is absent rather than drawn through thin air.
52///
53/// # Errors
54///
55/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if the
56/// direction has no length, or a face carries a surface whose silhouette has
57/// no closed form.
58pub fn silhouettes(
59    model: &Model,
60    shape: &Shape,
61    direction: Vector,
62    tol: Tolerances,
63) -> OgeomResult<Vec<Silhouette>> {
64    let magnitude = direction.magnitude();
65    if magnitude <= tol.confusion() {
66        ogeom_bail!(Construction, "a silhouette needs a direction to look along");
67    }
68    let along = direction / magnitude;
69    let deflection = Deflection::default();
70
71    let mut out = Vec::new();
72    for face in explore_unique(model, shape, ShapeType::Face)? {
73        let Some(NodeData::Face(data)) = model.node(&face).map(|n| n.data().clone()) else {
74            continue;
75        };
76        let Some(surface) = model.geometry().surface(data.surface).cloned() else {
77            continue;
78        };
79        let placement = face.transform(model.datums())?;
80        let world = ogeom_geom::Transformable::transformed(&surface, &placement, tol)?;
81        let candidates = match &world {
82            SurfaceGeometry::Plane(_) => Vec::new(),
83            SurfaceGeometry::Sphere(s) => {
84                // The great circle whose plane has the view as its normal:
85                // every normal on it is radial, so every one is
86                // perpendicular to the view.
87                let sphere = s.sphere();
88                let axis = Direction::new(along, tol)?;
89                let frame = Frame::new(sphere.centre(), axis, perpendicular(along, tol)?, tol)?;
90                vec![Curve::Circle(CircleCurve::new(Circle::new(
91                    frame,
92                    sphere.radius(),
93                    tol,
94                )?))]
95            }
96            SurfaceGeometry::Cylinder(c) => {
97                // The two rulings where the radial direction is
98                // perpendicular to the view: the axis stepped sideways by
99                // the radius, either way.
100                let cylinder = c.cylinder();
101                let axis = cylinder.frame().z().vector();
102                let sideways = axis.cross(along);
103                let m = sideways.magnitude();
104                if m <= tol.angular() {
105                    // Looking down the axis: the whole rim is the outline,
106                    // and the face's own boundary already draws it.
107                    Vec::new()
108                } else {
109                    let sideways = sideways / m;
110                    [1.0, -1.0]
111                        .iter()
112                        .map(|sign| {
113                            let at =
114                                cylinder.frame().origin() + sideways * (cylinder.radius() * sign);
115                            Curve::Line(LineCurve::new(Axis::new(at, cylinder.frame().z())))
116                        })
117                        .collect()
118                }
119            }
120            SurfaceGeometry::Cone(c) => {
121                // The same question on a cone: the rulings whose own normal
122                // is perpendicular to the view. The normal of a ruling at
123                // angle u is radial tilted by the half-angle, so the
124                // condition is a linear one in (cos u, sin u) and has two
125                // roots, or none, when the eye is inside the cone's own
126                // angle and nothing turns away.
127                let cone = c.cone();
128                let frame = cone.frame();
129                let (x, y, z) = (frame.x().vector(), frame.y().vector(), frame.z().vector());
130                let (sin, cos) = cone.half_angle().sin_cos();
131                // n(u) = cos(half) * (x cos u + y sin u) - sin(half) * z
132                let (a, b) = (cos * along.dot(x), cos * along.dot(y));
133                let c0 = -sin * along.dot(z);
134                let r = a.hypot(b);
135                if r <= tol.angular() || c0.abs() > r {
136                    Vec::new()
137                } else {
138                    let phase = b.atan2(a);
139                    let spread = (-c0 / r).acos();
140                    [phase + spread, phase - spread]
141                        .iter()
142                        .map(|u| {
143                            let radial = x * u.cos() + y * u.sin();
144                            let apex = cone.apex();
145                            let direction =
146                                radial * cone.half_angle().sin() + z * cone.half_angle().cos();
147                            Direction::new(direction, tol)
148                                .map(|d| Curve::Line(LineCurve::new(Axis::new(apex, d))))
149                        })
150                        .collect::<OgeomResult<Vec<Curve>>>()?
151                }
152            }
153            // No closed form: a torus, a spline. The silhouette is still
154            // one equation on the surface's own chart, and one equation in
155            // two unknowns is a curve, so it is *walked* rather than refused.
156            other => marched_silhouettes(other, along, tol)?,
157        };
158
159        for curve in candidates {
160            for range in within_trim(model, &face, &world, &curve, deflection, tol)? {
161                out.push(Silhouette {
162                    face: face.clone(),
163                    curve: curve.clone(),
164                    range,
165                });
166            }
167        }
168    }
169    Ok(out)
170}
171
172/// The reflect lines of a shape under a light: where the surface turns away
173/// from the *light* rather than from the eye.
174///
175/// The same locus as a silhouette, asked of a different direction, which is
176/// what a reflect line is, and why the two share a construction. A surface
177/// inspected this way shows its own creases: the lines move a long way for a
178/// small change in curvature.
179///
180/// # Errors
181///
182/// As [`silhouettes`].
183pub fn reflect_lines(
184    model: &Model,
185    shape: &Shape,
186    light: Vector,
187    tol: Tolerances,
188) -> OgeomResult<Vec<Silhouette>> {
189    silhouettes(model, shape, light, tol)
190}
191
192/// The isoparametric curves of a face: `u_count` at constant `u`, `v_count`
193/// at constant `v`, each trimmed to the stretches that lie on the face.
194///
195/// Evenly spaced across the face's own parameter window, excluding its
196/// edges, because an isoparametric at the window's edge is the face's
197/// boundary and the boundary is already drawn.
198///
199/// # Errors
200///
201/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if the
202/// face carries no surface.
203pub fn iso_curves(
204    model: &Model,
205    face: &Shape,
206    u_count: usize,
207    v_count: usize,
208    tol: Tolerances,
209) -> OgeomResult<Vec<Vec<Point>>> {
210    let Some(NodeData::Face(data)) = model.node(face).map(|n| n.data().clone()) else {
211        ogeom_bail!(Construction, "expected a face");
212    };
213    let Some(surface) = model.geometry().surface(data.surface).cloned() else {
214        ogeom_bail!(Dangling, "face refers to a surface not in this model");
215    };
216    let placement = face.transform(model.datums())?;
217    let world = ogeom_geom::Transformable::transformed(&surface, &placement, tol)?;
218    let ((u0, u1), (v0, v1)) = world.domain();
219    let rings = ogeom_mesh::face_boundary(model, face, Deflection::default(), tol)?;
220
221    const ALONG: usize = 64;
222    let mut out = Vec::new();
223    for (count, constant_u) in [(u_count, true), (v_count, false)] {
224        for i in 1..=count {
225            #[expect(
226                clippy::cast_precision_loss,
227                reason = "a curve index, far below the mantissa"
228            )]
229            let f = i as f64 / (count + 1) as f64;
230            let mut run: Vec<Point> = Vec::new();
231            for k in 0..=ALONG {
232                #[expect(
233                    clippy::cast_precision_loss,
234                    reason = "a station index, far below the mantissa"
235                )]
236                let g = k as f64 / ALONG as f64;
237                let (u, v) = if constant_u {
238                    ((u1 - u0).mul_add(f, u0), (v1 - v0).mul_add(g, v0))
239                } else {
240                    ((u1 - u0).mul_add(g, u0), (v1 - v0).mul_add(f, v0))
241                };
242                if inside_rings(&rings, Point2::new(u, v)) {
243                    run.push(world.point_at(u, v, tol)?);
244                } else if run.len() >= 2 {
245                    out.push(std::mem::take(&mut run));
246                } else {
247                    run.clear();
248                }
249            }
250            if run.len() >= 2 {
251                out.push(run);
252            }
253        }
254    }
255    Ok(out)
256}
257
258/// Project a shape into a drawing whose silhouettes and visibility are
259/// exact.
260///
261/// The edges and silhouettes are sampled at `deflection` to become
262/// polylines, because a drawing is polylines. What is not sampled is the
263/// *geometry* they sample (exact curves on the surfaces, not mesh edges),
264/// or the classification, which asks the faces themselves whether anything
265/// stands between a point and the eye.
266///
267/// # Errors
268///
269/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if a
270/// face's silhouette has no closed form, or the shape has no faces.
271pub fn project_exact(
272    model: &Model,
273    shape: &Shape,
274    view: &View,
275    deflection: Deflection,
276    tol: Tolerances,
277) -> OgeomResult<Drawing> {
278    let faces = blockers(model, shape, tol)?;
279    if faces.is_empty() {
280        ogeom_bail!(Construction, "a shape with no faces draws nothing");
281    }
282    let mut drawing = Drawing::default();
283
284    for edge in explore_unique(model, shape, ShapeType::Edge)? {
285        let Ok(points) = ogeom_mesh::polyline_of_edge(model, &edge, deflection, tol) else {
286            continue;
287        };
288        classify(
289            &mut drawing,
290            &points,
291            Source::Edge(edge.clone()),
292            view,
293            &faces,
294            tol,
295        )?;
296    }
297
298    for silhouette in silhouettes(model, shape, view.toward_eye(), tol)? {
299        let points = sampled(&silhouette.curve, silhouette.range, deflection, tol)?;
300        classify(&mut drawing, &points, Source::Silhouette, view, &faces, tol)?;
301    }
302    Ok(drawing)
303}
304
305/// A face that can stand between a point and the eye: its surface in world
306/// space and its trim as chart rings.
307struct Blocker {
308    surface: SurfaceGeometry,
309    rings: Vec<Vec<Point2>>,
310}
311
312fn blockers(model: &Model, shape: &Shape, tol: Tolerances) -> OgeomResult<Vec<Blocker>> {
313    let mut out = Vec::new();
314    for face in explore_unique(model, shape, ShapeType::Face)? {
315        let Some(NodeData::Face(data)) = model.node(&face).map(|n| n.data().clone()) else {
316            continue;
317        };
318        let Some(surface) = model.geometry().surface(data.surface).cloned() else {
319            continue;
320        };
321        let placement = face.transform(model.datums())?;
322        out.push(Blocker {
323            surface: ogeom_geom::Transformable::transformed(&surface, &placement, tol)?,
324            rings: ogeom_mesh::face_boundary(model, &face, Deflection::default(), tol)?,
325        });
326    }
327    Ok(out)
328}
329
330/// Split a polyline into visible and hidden runs, asking the faces.
331fn classify(
332    drawing: &mut Drawing,
333    points: &[Point],
334    source: Source,
335    view: &View,
336    faces: &[Blocker],
337    tol: Tolerances,
338) -> OgeomResult<()> {
339    let mut run: Vec<Point2> = Vec::new();
340    let mut held: Option<Visibility> = None;
341    let mut flush = |run: &mut Vec<Point2>, visibility: Option<Visibility>| {
342        if run.len() < 2 {
343            run.clear();
344            return;
345        }
346        let curve = DrawnCurve {
347            points: std::mem::take(run),
348            visibility: visibility.unwrap_or(Visibility::Visible),
349            source: source.clone(),
350        };
351        if visibility == Some(Visibility::Hidden) {
352            drawing.hidden.push(curve);
353        } else {
354            drawing.visible.push(curve);
355        }
356    };
357    for point in points {
358        let visibility = if occluded(*point, view, faces, tol)? {
359            Visibility::Hidden
360        } else {
361            Visibility::Visible
362        };
363        if held.is_some_and(|was| was != visibility) {
364            // The change happens somewhere between the two samples; the run
365            // ends at the sample that changed, so the two runs meet there.
366            let last = run.last().copied();
367            flush(&mut run, held);
368            if let Some(last) = last {
369                run.push(last);
370            }
371        }
372        held = Some(visibility);
373        run.push(view.project(*point));
374    }
375    flush(&mut run, held);
376    Ok(())
377}
378
379/// Whether anything stands between `at` and the eye.
380fn occluded(at: Point, view: &View, faces: &[Blocker], tol: Tolerances) -> OgeomResult<bool> {
381    let toward = view.toward_eye();
382    let magnitude = toward.magnitude();
383    if magnitude <= tol.confusion() {
384        return Ok(false);
385    }
386    let direction = toward / magnitude;
387    // Far enough to leave any shape it started inside, and started far
388    // enough along not to strike the surface the point is on.
389    let reach = 1e6;
390    let clearance = tol.confusion() * 1e3;
391    let ray = Curve::Line(LineCurve::new(Axis::new(
392        at,
393        Direction::new(direction, tol)?,
394    )));
395    let options = ogeom_intersect::CurveSurfaceOptions::default();
396    for face in faces {
397        let found = ogeom_intersect::intersect_curve_surface(&ray, &face.surface, options, tol)?;
398        for piercing in &found.crossings {
399            if piercing.on_curve <= clearance || piercing.on_curve >= reach {
400                continue;
401            }
402            let (u, v) = piercing.on_surface;
403            if inside_rings(&face.rings, Point2::new(u, v)) {
404                return Ok(true);
405            }
406        }
407    }
408    Ok(false)
409}
410
411/// The stretches of a curve that lie within a face's trim.
412fn within_trim(
413    model: &Model,
414    face: &Shape,
415    surface: &SurfaceGeometry,
416    curve: &Curve,
417    deflection: Deflection,
418    tol: Tolerances,
419) -> OgeomResult<Vec<(f64, f64)>> {
420    let rings = ogeom_mesh::face_boundary(model, face, deflection, tol)?;
421    let (t0, t1) = curve.domain();
422    // A line's domain is the whole real line as far as the type is
423    // concerned; a silhouette on one is only interesting where the face is,
424    // so an unbounded curve is walked over the face's own reach.
425    let (t0, t1) = if t0.is_finite() && t1.is_finite() && t1 - t0 < 1e6 {
426        (t0, t1)
427    } else {
428        let mut bound = ogeom_math::Aabb::EMPTY;
429        for vertex in explore_unique(model, face, ShapeType::Vertex)? {
430            if let Some(data) = model.node(&vertex).and_then(|n| n.data().as_vertex()) {
431                bound = bound.with_point(vertex.transform(model.datums())?.apply(data.point));
432            }
433        }
434        let reach = bound.diagonal().max(1.0);
435        let centre = bound.centre().unwrap_or(Point::ORIGIN);
436        let at = ogeom_algo::project_on_curve(curve, centre, 64, tol)?.parameter;
437        (at - reach, at + reach)
438    };
439
440    const STATIONS: usize = 96;
441    let held_at = |t: f64| -> OgeomResult<bool> {
442        let Ok(point) = curve.point_at(t, tol) else {
443            return Ok(false);
444        };
445        let projection = ogeom_algo::project_on_surface(surface, point, 24, tol)?;
446        let (u, v) = projection.parameters;
447        // The projection clamps to the surface's own window, so a point
448        // just past the end of a face comes back with a foot *at* the end
449        // and the overshoot as its distance. Holding that to the confusion
450        // tolerance is what stops a silhouette running off its own face.
451        Ok(projection.distance <= tol.confusion() && inside_rings(&rings, Point2::new(u, v)))
452    };
453    // Where the answer changes between two stations, the edge of the face
454    // is between them; bisecting says where to a part in a million of a
455    // station, so a silhouette ends *on* its face rather than a station
456    // past it.
457    let edge_between = |inside: f64, outside: f64| -> OgeomResult<f64> {
458        let (mut lo, mut hi) = (inside, outside);
459        for _ in 0..40 {
460            let mid = f64::midpoint(lo, hi);
461            if held_at(mid)? {
462                lo = mid;
463            } else {
464                hi = mid;
465            }
466        }
467        Ok(lo)
468    };
469
470    let mut out = Vec::new();
471    let mut open: Option<f64> = None;
472    let mut previous: Option<(f64, bool)> = None;
473    for k in 0..=STATIONS {
474        #[expect(
475            clippy::cast_precision_loss,
476            reason = "a station index, far below the mantissa"
477        )]
478        let t = (t1 - t0).mul_add(k as f64 / STATIONS as f64, t0);
479        let held = held_at(t)?;
480        match (held, open) {
481            (true, None) => {
482                open = Some(match previous {
483                    Some((was, false)) => edge_between(t, was)?,
484                    _ => t,
485                });
486            }
487            (false, Some(from)) => {
488                let to = match previous {
489                    Some((was, true)) => edge_between(was, t)?,
490                    _ => t,
491                };
492                if to - from > tol.parametric() {
493                    out.push((from, to));
494                }
495                open = None;
496            }
497            _ => {}
498        }
499        previous = Some((t, held));
500    }
501    if let Some(from) = open
502        && t1 - from > tol.parametric()
503    {
504        out.push((from, t1));
505    }
506    Ok(out)
507}
508
509/// A curve's polyline over a range, at the given deflection.
510fn sampled(
511    curve: &Curve,
512    range: (f64, f64),
513    deflection: Deflection,
514    tol: Tolerances,
515) -> OgeomResult<Vec<Point>> {
516    // How finely to walk: enough steps that the chord between two of them
517    // stays inside the deflection, from the curve's own length.
518    let span = curve
519        .point_at(range.0, tol)?
520        .distance(curve.point_at(range.1, tol)?);
521    #[expect(
522        clippy::cast_possible_truncation,
523        clippy::cast_sign_loss,
524        reason = "a step count, clamped into range"
525    )]
526    let steps =
527        ((span / deflection.chord.max(tol.confusion())).sqrt().ceil() as usize).clamp(8, 512);
528    let mut out = Vec::with_capacity(steps + 1);
529    for k in 0..=steps {
530        #[expect(
531            clippy::cast_precision_loss,
532            reason = "a station index, far below the mantissa"
533        )]
534        let t = (range.1 - range.0).mul_add(k as f64 / steps as f64, range.0);
535        out.push(curve.point_at(t, tol)?);
536    }
537    Ok(out)
538}
539
540/// Even-odd containment against chart rings.
541fn inside_rings(rings: &[Vec<Point2>], p: Point2) -> bool {
542    let mut inside = false;
543    for ring in rings {
544        for i in 0..ring.len() {
545            let (a, b) = (ring[i], ring[(i + 1) % ring.len()]);
546            if (a.y > p.y) != (b.y > p.y) {
547                let x = (b.x - a.x).mul_add((p.y - a.y) / (b.y - a.y), a.x);
548                if x > p.x {
549                    inside = !inside;
550                }
551            }
552        }
553    }
554    inside
555}
556
557/// Any unit vector perpendicular to `v`.
558fn perpendicular(v: Vector, tol: Tolerances) -> OgeomResult<Direction> {
559    let seed = if v.x.abs() < 0.9 {
560        Vector::X
561    } else {
562        Vector::Y
563    };
564    Direction::new(v.cross(seed), tol)
565}
566
567// --- the marched silhouette --------------------------------------------------
568
569/// A surface's silhouette, as a condition the shared walker can follow.
570///
571/// The whole content of a silhouette is one equation on the surface's own
572/// chart: the normal is square to the view,
573///
574/// > `n(u, v) · d = 0`
575///
576/// which is one equation in two unknowns, and one equation in two unknowns
577/// is a curve. So a torus's silhouette needs no machinery a surface
578/// intersection did not already need: it is the same walk, following a
579/// different condition.
580///
581/// Stated with the **unit** normal, and that is not a detail. The
582/// unnormalized `Sᵤ × Sᵥ` has the same zero set and a simpler derivative, but
583/// its residual carries the surface's own scale: on a torus of radius eight
584/// the correction had to drive `|Sᵤ × Sᵥ| · d` below a *length* tolerance,
585/// which is a demand on the angle some eight times tighter than anything
586/// asked for, and the walk answered by halving its step until it crawled.
587/// A dimensionless residual is a dimensionless tolerance.
588struct SilhouetteOn<'s> {
589    surface: &'s SurfaceGeometry,
590    along: Vector,
591    /// A length scale, for the walker's step control.
592    reach: f64,
593}
594
595impl ogeom_intersect::walk::Condition for SilhouetteOn<'_> {
596    fn unknowns(&self) -> usize {
597        2
598    }
599
600    fn position(&self, x: &[f64], tol: Tolerances) -> Option<Point> {
601        self.surface.point_at(x[0], x[1], tol).ok()
602    }
603
604    fn position_gradient(&self, x: &[f64], tol: Tolerances) -> Option<Vec<Vector>> {
605        let (du, dv) = self.surface.d1_at(x[0], x[1], tol).ok()?;
606        Some(vec![du, dv])
607    }
608
609    fn system(&self, x: &[f64], tol: Tolerances) -> Option<(Vec<f64>, Vec<Vec<f64>>)> {
610        let (su, sv) = self.surface.d1_at(x[0], x[1], tol).ok()?;
611        let (suu, suv, svv) = self.surface.d2_at(x[0], x[1], tol).ok()?;
612        let cross = su.cross(sv);
613        let length = cross.magnitude();
614        if length <= tol.confusion() {
615            return None;
616        }
617        let normal = cross / length;
618        // The unit normal's own derivative: the part of the unnormalized
619        // one's across the normal, over the length; the projection is what
620        // keeps a unit vector unit.
621        let across = |d: Vector| (d - normal * d.dot(normal)) / length;
622        let du = across(suu.cross(sv) + su.cross(suv));
623        let dv = across(suv.cross(sv) + su.cross(svv));
624        Some((
625            vec![normal.dot(self.along)],
626            vec![vec![du.dot(self.along), dv.dot(self.along)]],
627        ))
628    }
629
630    fn clamp(&self, x: &mut [f64]) {
631        let ((ua, ub), (va, vb)) = self.surface.domain();
632        let hold = |value: f64, lo: f64, hi: f64, periodic: bool| {
633            if periodic && hi > lo {
634                lo + (value - lo).rem_euclid(hi - lo)
635            } else {
636                value.clamp(lo, hi)
637            }
638        };
639        x[0] = hold(x[0], ua, ub, self.surface.is_periodic_u());
640        x[1] = hold(x[1], va, vb, self.surface.is_periodic_v());
641    }
642
643    fn outside(&self, x: &[f64], tol: Tolerances) -> bool {
644        let ((ua, ub), (va, vb)) = self.surface.domain();
645        let band = tol.parametric();
646        (!self.surface.is_periodic_u() && (x[0] < ua - band || x[0] > ub + band))
647            || (!self.surface.is_periodic_v() && (x[1] < va - band || x[1] > vb + band))
648    }
649
650    fn near_edge(&self, x: &[f64]) -> bool {
651        let ((ua, ub), (va, vb)) = self.surface.domain();
652        let near = |value: f64, lo: f64, hi: f64| {
653            let reach = (hi - lo) * 1e-6;
654            value <= lo + reach || value >= hi - reach
655        };
656        (!self.surface.is_periodic_u() && near(x[0], ua, ub))
657            || (!self.surface.is_periodic_v() && near(x[1], va, vb))
658    }
659
660    fn extent(&self) -> f64 {
661        self.reach
662    }
663}
664
665/// How far a point stands from a polyline, segment by segment.
666fn on_polyline(line: &[Point], p: Point) -> f64 {
667    let mut best = f64::INFINITY;
668    for pair in line.windows(2) {
669        let (a, b) = (pair[0], pair[1]);
670        let d = b - a;
671        let len2 = d.dot(d);
672        let t = if len2 > 0.0 {
673            ((p - a).dot(d) / len2).clamp(0.0, 1.0)
674        } else {
675            0.0
676        };
677        best = best.min(p.distance(a + d * t));
678    }
679    best
680}
681
682/// Silhouettes of a surface with no closed form, marched.
683///
684/// Seeded the way the intersector seeds: the chart is sampled on a grid and
685/// every sign change of `n · d` between neighbours is a starting point,
686/// refined onto the condition before the walk begins. That is the same
687/// limitation with the same knob on it (a silhouette loop smaller than one
688/// cell is stepped over), and it is stated rather than discovered.
689///
690/// The walked polylines are fitted to curves at `chord`, and the fit's own
691/// error is added to it, so what comes back carries a budget rather than a
692/// claim of exactness.
693fn marched_silhouettes(
694    surface: &SurfaceGeometry,
695    along: Vector,
696    tol: Tolerances,
697) -> OgeomResult<Vec<Curve>> {
698    use ogeom_intersect::walk::Condition as _;
699    let options = ogeom_intersect::Marching {
700        chord: tol.confusion() * 1e2,
701        ..ogeom_intersect::Marching::default()
702    };
703    let ((ua, ub), (va, vb)) = surface.domain();
704    // The step control wants a *length*, and the surface's own is the only
705    // honest one: a torus's face is bounded by a seam and one vertex, so its
706    // vertices' bounding box is a point and a step control fed that walks the
707    // whole ring in steps of a ten-thousandth.
708    let reach = {
709        let mut bound = ogeom_math::Aabb::EMPTY;
710        for i in 0..=8 {
711            for j in 0..=8 {
712                let u = (ub - ua).mul_add(f64::from(i) / 8.0, ua);
713                let v = (vb - va).mul_add(f64::from(j) / 8.0, va);
714                if let Ok(p) = surface.point_at(u, v, tol) {
715                    bound = bound.with_point(p);
716                }
717            }
718        }
719        bound.diagonal().max(tol.confusion() * 1e3)
720    };
721    let condition = SilhouetteOn {
722        surface,
723        along,
724        reach,
725    };
726    let value = |u: f64, v: f64| -> Option<f64> {
727        let (su, sv) = surface.d1_at(u, v, tol).ok()?;
728        Some(su.cross(sv).dot(along))
729    };
730
731    // Seeds: a sign change between grid neighbours, bisected to the crossing
732    // and then corrected onto the condition by the walker's own solve.
733    let mut seeds: Vec<[f64; 2]> = Vec::new();
734    let steps = options.grid;
735    #[expect(clippy::cast_precision_loss, reason = "a grid index")]
736    let at = |i: usize, n: usize, lo: f64, hi: f64| lo + (hi - lo) * (i as f64) / (n as f64);
737    for i in 0..=steps {
738        for j in 0..=steps {
739            let (u, v) = (at(i, steps, ua, ub), at(j, steps, va, vb));
740            let Some(here) = value(u, v) else { continue };
741            for (du, dv) in [(1_usize, 0_usize), (0, 1)] {
742                if i + du > steps || j + dv > steps {
743                    continue;
744                }
745                let (u2, v2) = (at(i + du, steps, ua, ub), at(j + dv, steps, va, vb));
746                let Some(there) = value(u2, v2) else { continue };
747                if here.signum() == there.signum() || here == 0.0 {
748                    continue;
749                }
750                // Bisect to the crossing along the cell edge.
751                let (mut lo, mut hi) = (0.0_f64, 1.0_f64);
752                for _ in 0..40 {
753                    let mid = f64::midpoint(lo, hi);
754                    let (um, vm) = (u + (u2 - u) * mid, v + (v2 - v) * mid);
755                    let Some(m) = value(um, vm) else { break };
756                    if m.signum() == here.signum() {
757                        lo = mid;
758                    } else {
759                        hi = mid;
760                    }
761                }
762                let mid = f64::midpoint(lo, hi);
763                seeds.push([u + (u2 - u) * mid, v + (v2 - v) * mid]);
764            }
765        }
766    }
767
768    let mut out = Vec::new();
769    let mut walked_points: Vec<Vec<Point>> = Vec::new();
770    for seed in seeds {
771        let mut start = seed;
772        condition.clamp(&mut start);
773        // A seed already covered by a curve found earlier is the same branch
774        // met in another cell, not a new one.
775        let Some(here) = condition.position(&start, tol) else {
776            continue;
777        };
778        // Against the polyline, not its vertices: the walk's own step is
779        // hundreds of times the chord, so a seed landing between two points
780        // of a curve already found is the same branch met in another cell and
781        // would otherwise be walked all over again. A torus seen down its
782        // axis has a seed in every grid column of both equators.
783        if walked_points
784            .iter()
785            .any(|line| on_polyline(line, here) <= options.chord * 32.0)
786        {
787            continue;
788        }
789        let Ok(walked) = ogeom_intersect::walk::follow(&condition, &start, options, tol) else {
790            continue;
791        };
792        if walked.points.len() < 4 {
793            continue;
794        }
795        // Fitted, with the fit's own error added to the walk's chord; the
796        // curve says what it is worth.
797        let Ok(fitted) = ogeom_geom::fit::fit_points(&walked.points, 3, options.chord, tol) else {
798            continue;
799        };
800        walked_points.push(walked.points);
801        out.push(Curve::BSpline(fitted.curve));
802    }
803    Ok(out)
804}