Skip to main content

geop_ops_drawing/
silhouette.rs

1//! Silhouettes: where a curved face turns away from the eye, the outline it
2//! shows without any edge there — the sides of a cylinder seen side on.
3//!
4//! For a view along `d`, a point of a surface `Scalar(u, v)` is on its silhouette
5//! when its normal is perpendicular to `d`: `g(u, v) = (S_u x S_v) . d = 0`.
6//! That is a curve in `(u, v)`, traced here as such:
7//!
8//! 1. **Seeds.** `g`'s sign is sampled along a grid of iso lines of the
9//!    surface's domain. Between two samples of opposite sign a silhouette
10//!    crosses the line, and bisection finds where. Sampling only decides
11//!    where to look: what is traced is the exact zero set. The interior grid
12//!    lines sit at irrational fractions of the domain, a free choice made so
13//!    they do not run along the symmetric iso lines silhouettes so often are.
14//!    A closed loop crossing no grid line lies within one cell, and encloses
15//!    a critical point of `g` (an extremum): each cell where `g` could
16//!    vanish is halved into boxes, those that could hold a critical point
17//!    halved again, and through each left a line across the cell is sampled
18//!    too ([`loop_seats`]), crossing such a loop. A trace seeded on a short
19//!    line steps no further than half of it, so a small loop is traced in
20//!    steps of its own size.
21//! 2. **Tracing.** From a seed, predictor-corrector steps follow the curve:
22//!    a step along the tangent `(-g_v, g_u)`, sized to a fixed length in 3-D,
23//!    then Newton back onto `g = 0`. A trace ends where the curve leaves the
24//!    domain, landing exactly on its boundary, or where it closes on itself.
25//!    Every grid line a trace crosses marks the seed there as traced.
26//! 3. **Trimming.** The traced curve in `(u, v)` is cut where it crosses the
27//!    face's trim curves, and only the pieces inside the face are kept.
28//! 4. **Curves.** Each piece becomes a 3-D curve through exact silhouette
29//!    points along it, widened to enclose the silhouette between them too
30//!    (as a traced intersection branch is, `NurbCurve3D::fit_enclosing`):
31//!    two silhouettes that are one line on the paper — mirror images seen
32//!    along the mirror's normal — are then seen to run along each other.
33//!
34//! The curve encloses the silhouette, so a point of it is near the
35//! silhouette, within its width, not on it. Whoever needs a point exactly
36//! on it — the visibility test does: a ray grazing a face at its silhouette
37//! tells the truth only from the exact point — asks
38//! [`Silhouette::exact_point`].
39
40use std::convert::Infallible;
41
42use geop_core_geometry::{
43    intersection::curve_curve_overlaps_and_crossings,
44    nurb_curve::{NurbCurve2D, NurbCurve3D},
45    nurb_surface::NurbSurface3D,
46};
47use geop_core_math::{
48    geop_error::{GeopError, GeopResult, WithContext},
49    plain,
50    scalars::Scalar,
51    solvers::{
52        bisect::sign_changes,
53        iterate::{End, Repeat, Step, iterate},
54        newton::{Arrive, newton_1d},
55        repeat::{self, repeat},
56    },
57    vector::{Vector2, Vector3},
58};
59use geop_core_topology::{
60    CoedgeId, FaceId, Model,
61    contains::face::{PointClassification, face_contains},
62};
63
64use crate::hidden_lines::{COARSE_SUBDIVISION, cut_points};
65use geop_core_geometry::search::{MAX_NODES, min_subdivision_size};
66
67/// Interior grid lines per direction of the domain, besides its boundary.
68const GRID_LINES: usize = 12;
69/// Sample intervals along each grid line.
70const GRID_SAMPLES: usize = 32;
71/// Where the interior grid lines sit within their cells: the golden
72/// section, far from the halves and quarters symmetric curves cross at.
73const GRID_OFFSET: f64 = 0.381_966_011_250_105;
74/// How many times a box within a grid cell is halved, at most, while it
75/// could hold a critical point of `g`: how hard the search for small
76/// closed loops tries.
77const BOX_DEPTH: usize = 1;
78/// Newton steps bringing a point onto the silhouette.
79const CORRECTOR_ITERATIONS: usize = 24;
80/// Bisection steps locating a seed between two samples.
81const BISECTIONS: usize = 60;
82/// Trace steps per face, at most: how hard a trace tries.
83const MAX_TRACE_STEPS: usize = 4000;
84/// Times a trace step is halved, at most, before the trace gives up.
85const STEP_HALVINGS: usize = 6;
86/// Steps a trace takes along the face's size.
87const STEPS_PER_FACE: f64 = 24.0;
88/// Seed for the containment tests.
89const FACE_CONTAINS_SEED: u64 = 0x5114_u64;
90
91/// A piece of a face's silhouette.
92#[derive(Clone, Debug)]
93pub struct Silhouette {
94    pub face: FaceId,
95    pub curve: NurbCurve3D,
96    /// Exact silhouette points along it, as `(u, v)` on the face's surface:
97    /// where [`Silhouette::exact_point`] starts from.
98    samples: Vec<Vector2>,
99}
100
101/// The silhouette function `g = (S_u x S_v) . d` and its gradient.
102fn contour(
103    surface: &NurbSurface3D,
104    d: &Vector3,
105    u: Scalar,
106    v: Scalar,
107) -> GeopResult<(Scalar, Scalar, Scalar)> {
108    let (su, sv) = surface.derivatives(u, v)?;
109    let [suu, suv, svv] = surface.second_derivatives(u, v)?;
110    let g = su.prod_cross(&sv).prod_dot(d);
111    let gu = suu.prod_cross(&sv).add(&su.prod_cross(&suv)).prod_dot(d);
112    let gv = suv.prod_cross(&sv).add(&su.prod_cross(&svv)).prod_dot(d);
113    Ok((g, gu, gv))
114}
115
116/// `g`'s sign at `(u, v)`, from the unit normal — which, unlike `S_u x S_v`,
117/// does not vanish at a pole — or `None` where it has no definite one.
118fn sign(surface: &NurbSurface3D, d: &Vector3, u: Scalar, v: Scalar) -> Option<bool> {
119    surface.normal(u, v).ok()?.prod_dot(d).definite_sign()
120}
121
122/// `(u, v)` moved onto the silhouette by Newton steps along `g`'s gradient
123/// (the smallest step that zeroes `g` to first order), or why it does not
124/// get there. Every iterate is sharpened: each is only the seed of the
125/// next. What is returned is the step from the last seed, unsharpened: an
126/// enclosure of the silhouette there as wide as `g`'s leaves it — or, where
127/// the seeds only cycle round it, the last step as wide as it came out, if
128/// `g` could vanish over it. A start outside the domain — a sharpened foot
129/// point on its boundary can land a hair past it — is clamped into it
130/// first, as every iterate is. The answer is cut to the domain: the
131/// silhouette sought is on the patch, and an answer as wide as a noisy `g`
132/// leaves it reaches past the patch's ends, where the surface is no
133/// surface and its rows' weights are no weights.
134fn correct(
135    surface: &NurbSurface3D,
136    d: &Vector3,
137    start: (Scalar, Scalar),
138) -> GeopResult<(Scalar, Scalar)> {
139    let (u_lo, u_hi) = surface.domain_u();
140    let (v_lo, v_hi) = surface.domain_v();
141    let seed = (start.0.clamped(u_lo, u_hi), start.1.clamped(v_lo, v_hi));
142    let on_domain =
143        |(u, v): (Scalar, Scalar)| (u.intersect(u_lo.union(u_hi)), v.intersect(v_lo.union(v_hi)));
144    let mut last = None;
145    let corrected = iterate(
146        seed,
147        seed,
148        CORRECTOR_ITERATIONS,
149        Repeat::Never,
150        |&(u, v)| {
151            let (u, v) = (u.clamped(u_lo, u_hi), v.clamped(v_lo, v_hi));
152            ((u.sharpen(), v.sharpen()), (u, v))
153        },
154        |&(u, v)| -> GeopResult<Step<(Scalar, Scalar)>> {
155            let (g, gu, gv) = contour(surface, d, u, v)?;
156            if g.could_be_equal(Scalar::ZERO) {
157                return Ok(Step::Done);
158            }
159            let scale = g.div(gu.mul(gu).add(gv.mul(gv)))?;
160            last = Some((u, v, g, gu, gv));
161            Ok(Step::Next((u.sub(gu.mul(scale)), v.sub(gv.mul(scale)))))
162        },
163    )?;
164    if corrected.end == End::Done {
165        // The sharp seed is where `g` could vanish, one point of where the
166        // silhouette could be: the answer is the step from it, unsharpened,
167        // which holds the seed and is as wide as `g` there leaves where
168        // the silhouette is. Sharp, a silhouette of a padded surface ran
169        // `3.6e-12` off the face's edge it lies along, and nothing could
170        // see the two were one.
171        let (u, v) = corrected.seed;
172        let (g, gu, gv) = contour(surface, d, u, v)?;
173        let Ok(scale) = g.div(gu.mul(gu).add(gv.mul(gv))) else {
174            return Ok((u, v));
175        };
176        return Ok(on_domain((u.sub(gu.mul(scale)), v.sub(gv.mul(scale)))));
177    }
178    // No sharp seed lands where `g`, rounded, could vanish — the steps
179    // cycle in the last bits around it. The last step's answer, as wide
180    // as the step left it, may still hold the silhouette: then it is the
181    // point, as precisely as the arithmetic pins it down.
182    let (u, v) = on_domain(corrected.answer);
183    if contour(surface, d, u, v)?.0.could_be_equal(Scalar::ZERO) {
184        return Ok((u, v));
185    }
186    Err(GeopError::new(format!(
187        "the point does not converge onto the silhouette: from {start:?}, the last \
188         (u, v, g, g_u, g_v) {last:?}, in the domain {:?} x {:?}",
189        surface.domain_u(),
190        surface.domain_v()
191    )))
192}
193
194/// `(u, v)` on the domain's boundary line `fixed` moved onto the silhouette
195/// along that line, or `None` if it does not get there.
196fn correct_on_boundary(
197    surface: &NurbSurface3D,
198    d: &Vector3,
199    (u, v): (Scalar, Scalar),
200    u_fixed: bool,
201) -> Option<(Scalar, Scalar)> {
202    // The parameter that moves, the bounds it moves in, and the point at it.
203    let (moving, (lo, hi)) = if u_fixed {
204        (v, surface.domain_v())
205    } else {
206        (u, surface.domain_u())
207    };
208    let at = |t: Scalar| if u_fixed { (u, t) } else { (t, v) };
209    let corrected = newton_1d(
210        moving,
211        (lo, hi),
212        CORRECTOR_ITERATIONS,
213        Arrive::AtZero,
214        |t| -> GeopResult<_> {
215            let (u, v) = at(t);
216            let (g, gu, gv) = contour(surface, d, u, v)?;
217            Ok((g, if u_fixed { gv } else { gu }))
218        },
219    );
220    let (t, arrived) = corrected.ok()?;
221    arrived.then(|| at(t))
222}
223
224/// A piece of an iso line of the domain the seeds are found along: a side
225/// of a box (see the module docs).
226struct GridLine {
227    /// Whether `u` is fixed along it (else `v` is).
228    u_fixed: bool,
229    value: Scalar,
230    /// The other parameter at each sample.
231    params: Vec<Scalar>,
232    /// Sign changes between samples: the sample indices on either side.
233    brackets: Vec<(usize, usize)>,
234    /// Which brackets a trace has crossed.
235    traced: Vec<bool>,
236    /// How far apart its ends are in space: a trace seeded on it steps no
237    /// further than half that, so a loop small enough to have been found
238    /// only on small boxes is traced in steps its own size.
239    scale: f64,
240}
241
242impl GridLine {
243    fn point(&self, t: Scalar) -> (Scalar, Scalar) {
244        if self.u_fixed {
245            (self.value, t)
246        } else {
247            (t, self.value)
248        }
249    }
250
251    /// Marks traced the bracket a trace crossed at `t` along this line: the
252    /// one whose ends, widened by its own width and a 32nd of the line each
253    /// way, hold `t`, and the nearest of them if several do. None, if no
254    /// bracket is that near — the crossing is off this piece of the line.
255    fn mark(&mut self, t: f64) {
256        let p = |k: usize| self.params[k].to_f64();
257        let line = (p(self.params.len() - 1) - p(0)) / 32.0;
258        let near = self
259            .brackets
260            .iter()
261            .enumerate()
262            .filter(|(_, (lo, hi))| {
263                let reach = p(*hi) - p(*lo) + line;
264                p(*lo) - reach <= t && t <= p(*hi) + reach
265            })
266            .min_by(|(_, a), (_, b)| {
267                let distance = |&(lo, hi): &(usize, usize)| (0.5 * (p(lo) + p(hi)) - t).abs();
268                distance(a).total_cmp(&distance(b))
269            })
270            .map(|(i, _)| i);
271        if let Some(i) = near {
272            self.traced[i] = true;
273        }
274    }
275}
276
277/// The distinct knots of `knots` within `(lo, hi)`, and its ends, in
278/// order.
279fn breaks(knots: &[Scalar], (lo, hi): (Scalar, Scalar)) -> Vec<Scalar> {
280    let mut out = vec![lo];
281    for &k in knots {
282        if k.definitely_greater(*out.last().expect("a first break")) && k.definitely_less(hi) {
283            out.push(k);
284        }
285    }
286    out.push(hi);
287    out
288}
289
290/// The middle of `(a, b)`, sharp: where to split is a free choice.
291fn middle(a: Scalar, b: Scalar) -> Scalar {
292    Scalar::fraction(a, b, Scalar::from_f64(0.5)).sharpen()
293}
294
295/// The iso lines seeds are found on (see the module docs): the grid's,
296/// and through every box in a cell of it that could hold a critical point
297/// of `g` where `g` could vanish, a piece of a line across that cell.
298fn grid(surface: &NurbSurface3D, d: &Vector3) -> Vec<GridLine> {
299    let mut fractions = vec![0.0];
300    fractions.extend((0..GRID_LINES).map(|k| (k as f64 + GRID_OFFSET) / GRID_LINES as f64));
301    fractions.push(1.0);
302    let at = |(lo, hi): (Scalar, Scalar), f: f64| match f {
303        0.0 => lo,
304        1.0 => hi,
305        _ => Scalar::fraction(lo, hi, Scalar::from_f64(f)),
306    };
307    let (du, dv) = (surface.domain_u(), surface.domain_v());
308    let us: Vec<Scalar> = fractions.iter().map(|&f| at(du, f)).collect();
309    let vs: Vec<Scalar> = fractions.iter().map(|&f| at(dv, f)).collect();
310    let mut lines = Vec::new();
311    for &u in &us {
312        lines.push(sampled_line(surface, d, true, u, dv));
313    }
314    for &v in &vs {
315        lines.push(sampled_line(surface, d, false, v, du));
316    }
317    for u in us.windows(2) {
318        for v in vs.windows(2) {
319            for (at, size) in loop_seats(surface, d, (u[0], u[1]), (v[0], v[1])) {
320                let mut line = sampled_line(surface, d, true, at, (v[0], v[1]));
321                line.scale = line.scale.min(size);
322                lines.push(line);
323            }
324        }
325    }
326    lines
327}
328
329/// Where in the cell `cu` x `cv` of the grid a loop of the silhouette that
330/// crosses none of its lines could lie: the `u` of the middle of every box
331/// of it, halved to [`BOX_DEPTH`], that could hold a critical point of
332/// `g`, and the box's size in space — the size of a loop found there. A loop of `g = 0` encloses one (an extremum of `g`), so a line
333/// through each crosses every loop in the cell — none, if `g` cannot
334/// vanish in the cell at all. Each box is within a knot span, where the
335/// enclosures are the span's own.
336fn loop_seats(
337    surface: &NurbSurface3D,
338    d: &Vector3,
339    cu: (Scalar, Scalar),
340    cv: (Scalar, Scalar),
341) -> Vec<(Scalar, f64)> {
342    let zero = Scalar::ZERO;
343    let us = breaks(&surface.knot_vector_u, cu);
344    let vs = breaks(&surface.knot_vector_v, cv);
345    let mut stack = Vec::new();
346    let mut vanishes = false;
347    for u in us.windows(2) {
348        for v in vs.windows(2) {
349            match contour(surface, d, u[0].union(u[1]), v[0].union(v[1])) {
350                Ok((g, _, _)) if !g.could_be_equal(zero) => {}
351                _ => vanishes = true,
352            }
353            stack.push(((u[0], u[1]), (v[0], v[1]), 0));
354        }
355    }
356    let mut seats: Vec<(Scalar, f64)> = Vec::new();
357    if !vanishes {
358        return seats;
359    }
360    while let Some(((u0, u1), (v0, v1), depth)) = stack.pop() {
361        let critical = match contour(surface, d, u0.union(u1), v0.union(v1)) {
362            Ok((_, gu, gv)) => gu.could_be_equal(zero) && gv.could_be_equal(zero),
363            Err(_) => true,
364        };
365        if !critical {
366            continue;
367        }
368        let (um, vm) = (middle(u0, u1), middle(v0, v1));
369        if depth >= BOX_DEPTH {
370            if !seats.iter().any(|s| s.0.could_be_equal(um)) {
371                let corner =
372                    |u: Scalar, v: Scalar| surface.evaluate(u, v).map(|p| p.to_f64_array());
373                let size = match (corner(u0, v0), corner(u1, v1)) {
374                    (Ok(a), Ok(b)) => plain::distance(a, b),
375                    _ => f64::INFINITY,
376                };
377                seats.push((um, size));
378            }
379            continue;
380        }
381        for u in [(u0, um), (um, u1)] {
382            for v in [(v0, vm), (vm, v1)] {
383                stack.push((u, v, depth + 1));
384            }
385        }
386    }
387    seats
388}
389
390/// The piece `range` of the iso line where `u` (if `u_fixed`, else `v`) is
391/// `value`, sampled for `g`'s sign at [`GRID_SAMPLES`] intervals.
392fn sampled_line(
393    surface: &NurbSurface3D,
394    d: &Vector3,
395    u_fixed: bool,
396    value: Scalar,
397    (lo, hi): (Scalar, Scalar),
398) -> GridLine {
399    let params: Vec<Scalar> = (0..=GRID_SAMPLES)
400        .map(|k| match k {
401            0 => lo,
402            k if k == GRID_SAMPLES => hi,
403            k => Scalar::fraction(lo, hi, Scalar::from_f64(k as f64 / GRID_SAMPLES as f64)),
404        })
405        .collect();
406    let mut line = GridLine {
407        u_fixed,
408        value,
409        params,
410        brackets: Vec::new(),
411        traced: Vec::new(),
412        scale: f64::INFINITY,
413    };
414    let at = |t: Scalar| {
415        let (u, v) = line.point(t);
416        surface.evaluate(u, v).map(|p| p.to_f64_array())
417    };
418    if let (Ok(a), Ok(b)) = (at(lo), at(hi)) {
419        line.scale = plain::distance(a, b);
420    }
421    let brackets: Vec<(usize, usize)> = sign_changes(0..line.params.len(), |k| {
422        let (u, v) = line.point(line.params[k]);
423        Ok::<_, Infallible>(sign(surface, d, u, v))
424    })
425    .flatten()
426    .map(|(j, k, _)| (j, k))
427    .collect();
428    line.traced = vec![false; brackets.len()];
429    line.brackets = brackets;
430    line
431}
432
433/// The seed in `line`'s bracket `(lo, hi)`: bisected on `g`'s sign, then
434/// moved onto the silhouette.
435fn seed(
436    surface: &NurbSurface3D,
437    d: &Vector3,
438    line: &GridLine,
439    (lo, hi): (usize, usize),
440) -> Option<(Scalar, Scalar)> {
441    let sign_at = |t| -> GeopResult<_> {
442        let (u, v) = line.point(t);
443        Ok(sign(surface, d, u, v))
444    };
445    let bracket = sign_changes([line.params[lo], line.params[hi]], sign_at)
446        .bisected(BISECTIONS, false)
447        .next()?
448        .ok()?;
449    // Where the sign cannot be told, the silhouette is there.
450    let (a, b) = match bracket.undecided {
451        Some(mid) => (mid, mid),
452        None => (bracket.lo, bracket.hi),
453    };
454    let t = a.add(b).div(Scalar::TWO).ok()?.sharpen();
455    correct(surface, d, line.point(t)).ok()
456}
457
458/// Marks traced every bracket the step from `a` to `b` along the
459/// silhouette crosses or ends on.
460///
461/// Which brackets a trace has passed is only bookkeeping — it decides which
462/// seeds are left to trace — but a bracket missed is a silhouette traced a
463/// second time, a copy no search on the paper can tell from the first. Two
464/// ways a step passes a grid line without its ends lying on either side:
465///
466/// - It ends on it. A trace that leaves the domain lands on its boundary,
467///   inside the enclosure of the bound but not necessarily on the side of
468///   its midpoint the step came from: asked of the intervals.
469/// - It dips across and back. A silhouette whose extremum lies a hair past a
470///   grid line crosses the line twice between the ends of one step. So the
471///   step is taken as the cubic leaving `a` and arriving at `b` along the
472///   silhouette's tangents there (Hermite), which follows the silhouette to
473///   fourth order in the step, and its crossings are the line's.
474fn mark_crossings(
475    surface: &NurbSurface3D,
476    d: &Vector3,
477    lines: &mut [GridLine],
478    a: (Scalar, Scalar),
479    b: (Scalar, Scalar),
480) {
481    let (au, av, bu, bv) = (a.0.to_f64(), a.1.to_f64(), b.0.to_f64(), b.1.to_f64());
482    let chord = [bu - au, bv - av];
483    let length = chord[0].hypot(chord[1]);
484    // The tangent at `p`, as long as the chord and along it; the chord
485    // itself where there is none.
486    let tangent = |(u, v): (Scalar, Scalar)| -> [f64; 2] {
487        let Ok((_, gu, gv)) = contour(surface, d, u, v) else {
488            return chord;
489        };
490        let t = [-gv.to_f64(), gu.to_f64()];
491        let norm = t[0].hypot(t[1]);
492        let along = t[0] * chord[0] + t[1] * chord[1];
493        if norm == 0.0 || along == 0.0 || !norm.is_finite() {
494            return chord;
495        }
496        let scale = length / norm * along.signum();
497        [t[0] * scale, t[1] * scale]
498    };
499    let (ta, tb) = (tangent(a), tangent(b));
500    // The step's cubic in coordinate `k` (0 for `u`, 1 for `v`), as power
501    // coefficients in `s` over `[0, 1]`.
502    let cubic = |k: usize| -> [f64; 4] {
503        let (x0, x1) = if k == 0 { (au, bu) } else { (av, bv) };
504        [
505            x0,
506            ta[k],
507            3.0 * (x1 - x0) - 2.0 * ta[k] - tb[k],
508            2.0 * (x0 - x1) + ta[k] + tb[k],
509        ]
510    };
511    let at = |c: &[f64; 4], s: f64| ((c[3] * s + c[2]) * s + c[1]) * s + c[0];
512    for line in lines.iter_mut() {
513        let (k, other) = if line.u_fixed { (0, 1) } else { (1, 0) };
514        for (end, p) in [(a, (au, av)), (b, (bu, bv))] {
515            let (x, y) = if line.u_fixed {
516                (end.0, p.1)
517            } else {
518                (end.1, p.0)
519            };
520            if x.could_be_equal(line.value) {
521                line.mark(y);
522            }
523        }
524        let mut here = cubic(k);
525        here[0] -= line.value.to_f64();
526        let across = cubic(other);
527        // The cubic's turning points split `[0, 1]` into pieces it is
528        // monotone along; a piece whose ends lie on either side of the line
529        // crosses it once, bisected.
530        let (qa, qb, qc) = (3.0 * here[3], 2.0 * here[2], here[1]);
531        let mut ends = vec![0.0, 1.0];
532        if qa == 0.0 {
533            if qb != 0.0 {
534                ends.push(-qc / qb);
535            }
536        } else {
537            let disc = qb * qb - 4.0 * qa * qc;
538            if disc >= 0.0 {
539                let root = disc.sqrt();
540                ends.extend([(-qb - root) / (2.0 * qa), (-qb + root) / (2.0 * qa)]);
541            }
542        }
543        ends.retain(|s| (0.0..=1.0).contains(s));
544        ends.sort_by(f64::total_cmp);
545        // Where the cubic vanishes, it has no side.
546        let side = |s| {
547            let x = at(&here, s);
548            Ok::<_, GeopError>((x != 0.0 && !x.is_nan()).then_some(x > 0.0))
549        };
550        for bracket in sign_changes(ends, side)
551            .bisected(BISECTIONS, false)
552            .flatten()
553        {
554            let s = bracket.undecided.unwrap_or(0.5 * (bracket.lo + bracket.hi));
555            line.mark(at(&across, s));
556        }
557    }
558}
559
560/// How a march along the silhouette ended.
561enum Ending {
562    /// It came back to where it started.
563    Closed,
564    /// It left the domain, or could not go on.
565    Open,
566}
567
568/// Marches from `start` along the silhouette, the way `forward` says,
569/// pushing each point onto `points` (not `start` itself).
570#[allow(clippy::too_many_arguments)]
571fn march(
572    surface: &NurbSurface3D,
573    d: &Vector3,
574    start: (Scalar, Scalar),
575    forward: bool,
576    step: Scalar,
577    lines: &mut [GridLine],
578    points: &mut Vec<(Scalar, Scalar)>,
579) -> GeopResult<Ending> {
580    let (u_lo, u_hi) = surface.domain_u();
581    let (v_lo, v_hi) = surface.domain_v();
582    let start_point = surface.evaluate(start.0, start.1)?;
583    let mut current = start;
584    let mut travelled = Scalar::ZERO;
585    repeat(MAX_TRACE_STEPS, || -> GeopResult<_> {
586        let (u, v) = current;
587        let Ok((_, gu, gv)) = contour(surface, d, u, v) else {
588            return Ok(repeat::Step::Done(Ending::Open));
589        };
590        let (mut tu, mut tv) = (gv.neg(), gu);
591        if !forward {
592            (tu, tv) = (tu.neg(), tv.neg());
593        }
594        let (su, sv) = surface.derivatives(u, v)?;
595        let speed = su.prod_scalar(tu).add(&sv.prod_scalar(tv)).norm();
596        // A step whose corrector fails, or lands further off than twice
597        // the step, is retried at half the length: how far to step is a
598        // free choice, and a tight turn of the silhouette needs short ones.
599        let from = surface.evaluate(u, v)?;
600        let mut tried = step;
601        let mut halvings = 0;
602        let next = loop {
603            let Ok(scale) = tried.div(speed) else {
604                return Ok(repeat::Step::Done(Ending::Open));
605            };
606            let (du, dv) = (tu.mul(scale), tv.mul(scale));
607            let (pu, pv) = (u.add(du).sharpen(), v.add(dv).sharpen());
608            let leaves_u = pu.definitely_less(u_lo) || pu.definitely_greater(u_hi);
609            let leaves_v = pv.definitely_less(v_lo) || pv.definitely_greater(v_hi);
610            if leaves_u || leaves_v {
611                // The boundary the step reaches first, and the fraction of the
612                // step to it: the point there, then onto the silhouette along
613                // that boundary.
614                let to = |x: Scalar, dx: Scalar, lo: Scalar, hi: Scalar| -> (f64, Scalar) {
615                    let bound = if dx.to_f64() < 0.0 { lo } else { hi };
616                    ((bound.sub(x).to_f64() / dx.to_f64()).clamp(0.0, 1.0), bound)
617                };
618                let (fu, bound_u) = to(u, du, u_lo, u_hi);
619                let (fv, bound_v) = to(v, dv, v_lo, v_hi);
620                let u_first = leaves_u && (!leaves_v || fu <= fv);
621                // On the boundary, exactly: the step's own arithmetic lands a
622                // rounding off it — a hair inside, on no grid line — and a
623                // trace ending there does not mark the bracket the boundary
624                // line has where it ends, which then seeds the same silhouette
625                // again.
626                let landed = if u_first {
627                    let f = Scalar::from_f64(fu);
628                    (bound_u, v.add(dv.mul(f)).sharpen().clamped(v_lo, v_hi))
629                } else {
630                    let f = Scalar::from_f64(fv);
631                    (u.add(du.mul(f)).sharpen().clamped(u_lo, u_hi), bound_v)
632                };
633                if let Some(end) = correct_on_boundary(surface, d, landed, u_first) {
634                    mark_crossings(surface, d, lines, current, end);
635                    points.push(end);
636                }
637                return Ok(repeat::Step::Done(Ending::Open));
638            }
639            let landed = correct(surface, d, (pu, pv)).ok().filter(|next| {
640                surface
641                    .evaluate(next.0, next.1)
642                    .is_ok_and(|p| p.sub(&from).norm().definitely_less(tried.mul(Scalar::TWO)))
643            });
644            match landed {
645                Some(next) => break next,
646                None if halvings < STEP_HALVINGS => {
647                    tried = tried.div(Scalar::TWO)?;
648                    halvings += 1;
649                }
650                None => return Ok(repeat::Step::Done(Ending::Open)),
651            }
652        };
653        mark_crossings(surface, d, lines, current, next);
654        points.push(next);
655        travelled = travelled.add(tried);
656        let here = surface.evaluate(next.0, next.1)?;
657        if travelled.definitely_greater(step.mul(Scalar::TWO))
658            && here.sub(&start_point).norm().definitely_less(step)
659        {
660            mark_crossings(surface, d, lines, next, start);
661            points.push(start);
662            return Ok(repeat::Step::Done(Ending::Closed));
663        }
664        current = next;
665        Ok(repeat::Step::Again)
666    })?
667    .done_or(|steps| {
668        GeopError::new(format!(
669            "the silhouette trace from {start:?} did not end within {steps} steps of {step:?}"
670        ))
671    })
672}
673
674/// The silhouette through `seed`, traced both ways, as `(u, v)` points.
675fn trace(
676    surface: &NurbSurface3D,
677    d: &Vector3,
678    seed: (Scalar, Scalar),
679    step: Scalar,
680    lines: &mut [GridLine],
681) -> GeopResult<Vec<(Scalar, Scalar)>> {
682    let mut forward = Vec::new();
683    let points = if let Ending::Closed = march(surface, d, seed, true, step, lines, &mut forward)? {
684        let mut points = vec![seed];
685        points.extend(forward);
686        points
687    } else {
688        let mut backward = Vec::new();
689        march(surface, d, seed, false, step, lines, &mut backward)?;
690        backward.reverse();
691        backward.push(seed);
692        backward.extend(forward);
693        backward
694    };
695    thin(surface, points, step)
696}
697
698/// `points` without those crowding the one before: where a curve is
699/// sampled is a free choice, and samples a sliver apart — a step that
700/// reached the domain's boundary all but, then the boundary itself — make
701/// the fit through them ill posed. The ends stay.
702fn thin(
703    surface: &NurbSurface3D,
704    points: Vec<(Scalar, Scalar)>,
705    step: Scalar,
706) -> GeopResult<Vec<(Scalar, Scalar)>> {
707    let close = step.div(Scalar::from_f64(8.0))?;
708    let at = |p: &(Scalar, Scalar)| surface.evaluate(p.0, p.1);
709    let count = points.len();
710    let mut kept: Vec<(Scalar, Scalar)> = Vec::with_capacity(count);
711    for (k, p) in points.into_iter().enumerate() {
712        if let Some(last) = kept.last()
713            && at(last)?.sub(&at(&p)?).norm().definitely_less(close)
714        {
715            if k + 1 == count && kept.len() > 1 {
716                kept.pop();
717            } else {
718                continue;
719            }
720        }
721        kept.push(p);
722    }
723    Ok(kept)
724}
725
726/// The size of `surface`: the diagonal of its control points' box.
727fn extent(surface: &NurbSurface3D) -> Scalar {
728    let b = surface.aabb();
729    Scalar::from_f64(plain::diagonal(
730        b.map(|x| x.lower().to_f64()),
731        b.map(|x| x.upper().to_f64()),
732    ))
733}
734
735/// Whether `surface` is swept straight along `d` in one of its parameter
736/// directions — an extruded or turned cylinder seen end on — so that its
737/// normal is perpendicular to `d` everywhere and the whole face is seen
738/// edge on: then its outline is its edges, and it has no silhouette curve.
739///
740/// Swept along `d` to the sheet's resolution ([`COARSE_SUBDIVISION`]) is
741/// swept along it: every control point of a row, of one weight, lies on
742/// the paper within it of the row's first, so the face lies within it of
743/// the curve its first points span, which its edges draw — and so do its
744/// silhouettes. A cylinder in a part placed by a pose rounded in its last
745/// bits is seen `4e-14` off its axis: its silhouette is a generator that
746/// projects to a point, and `g`, a rounding off zero all over the face,
747/// put the corrector's answer for it anywhere round the arc, `150`
748/// wide in a domain of `1`.
749fn swept_along(surface: &NurbSurface3D, d: &Vector3) -> bool {
750    let (nu, nv) = (surface.num_u, surface.num_v);
751    let point = |i: usize, j: usize| surface.control_points[i * nv + j];
752    let cartesian = |q: geop_core_math::vector::Vector4| -> Option<Vector3> {
753        let w = q[3];
754        Some(Vector3::from_array([
755            q[0].div(w).ok()?,
756            q[1].div(w).ok()?,
757            q[2].div(w).ok()?,
758        ]))
759    };
760    let along = |a: geop_core_math::vector::Vector4, b: geop_core_math::vector::Vector4| {
761        let (Some(pa), Some(pb)) = (cartesian(a), cartesian(b)) else {
762            return false;
763        };
764        a[3].could_be_equal(b[3])
765            && !(pb.sub(&pa).prod_cross(d).norm())
766                .definitely_greater(Scalar::from_f64(COARSE_SUBDIVISION))
767    };
768    let along_v = (0..nu).all(|i| (1..nv).all(|j| along(point(i, 0), point(i, j))));
769    let along_u = (0..nv).all(|j| (1..nu).all(|i| along(point(0, j), point(i, j))));
770    along_u || along_v
771}
772
773impl Silhouette {
774    /// The point of this silhouette near `near`, exactly on it: `near`
775    /// projected onto the surface from the nearest exact sample, then moved
776    /// onto the silhouette.
777    pub fn exact_point(&self, model: &Model, d: &Vector3, near: &Vector3) -> GeopResult<Vector3> {
778        let surface = &model.get_face(self.face)?.surface;
779        let ctx = |e: GeopError| {
780            e.with_context(format!(
781                "Silhouette::exact_point(face={}, near={near:?})",
782                self.face
783            ))
784        };
785        let distance = |uv: &Vector2| -> f64 {
786            surface
787                .evaluate(uv[0], uv[1])
788                .map(|p| p.sub(near).norm_sq().to_f64())
789                .unwrap_or(f64::INFINITY)
790        };
791        let seed = self
792            .samples
793            .iter()
794            .min_by(|a, b| distance(a).total_cmp(&distance(b)))
795            .ok_or_else(|| GeopError::new("a silhouette without samples"))
796            .with_context(&ctx)?;
797        let (u, v) = surface
798            .project(*near, seed[0], seed[1], CORRECTOR_ITERATIONS)
799            .with_context(&ctx)?;
800        let (u, v) = correct(surface, d, (u.sharpen(), v.sharpen())).with_context(&ctx)?;
801        surface.evaluate(u, v).with_context(&ctx)
802    }
803}
804
805/// Samples along an edge looking for where it crosses a silhouette.
806const EDGE_SAMPLES: usize = 32;
807
808/// Where the edge `curve`, bounding the face `face_id` along the coedge
809/// `coedge_id`, crosses the face's silhouette seen along `d`: the
810/// parameters where the face's normal there turns perpendicular to `d`.
811///
812/// Projected, an edge touches the silhouette of a face it bounds
813/// tangentially where it crosses it — a contact no search on the paper can
814/// isolate, so it is found here, in space: `g`'s sign sampled along the
815/// edge, and each change bisected. Only isolated crossings: an edge along
816/// which the face is seen edge on throughout has none.
817pub fn edge_crossings(
818    model: &Model,
819    face_id: FaceId,
820    coedge_id: CoedgeId,
821    curve: &NurbCurve3D,
822    d: &Vector3,
823) -> GeopResult<Vec<Scalar>> {
824    let surface = &model.get_face(face_id)?.surface;
825    if surface.as_plane()?.is_some() || swept_along(surface, d) {
826        return Ok(Vec::new());
827    }
828    let coedge = model.get_coedge(coedge_id)?;
829    let (t0, t1) = curve.domain();
830    let (s0, s1) = coedge.pcurve.domain();
831    let edge_domain = model.get_edge(coedge.edge()?)?.curve.domain();
832    // Where on the surface the edge's point at `t` is: projected from the
833    // pcurve's point as far along it — a seed, a free choice.
834    let foot = |t: Scalar| -> Option<(Scalar, Scalar)> {
835        let f = t
836            .sub(edge_domain.0)
837            .div(edge_domain.1.sub(edge_domain.0))
838            .ok()?;
839        let f = match coedge.sense {
840            geop_core_topology::Sense::Forward => f,
841            geop_core_topology::Sense::Reversed => Scalar::ONE.sub(f),
842        };
843        let seed = coedge.pcurve.evaluate(Scalar::fraction(s0, s1, f)).ok()?;
844        let point = curve.evaluate(t).ok()?;
845        surface
846            .project(point, seed[0], seed[1], CORRECTOR_ITERATIONS)
847            .ok()
848    };
849    let sign_at = |t: Scalar| -> Option<bool> {
850        let (u, v) = foot(t)?;
851        sign(surface, d, u, v)
852    };
853    let params = (0..=EDGE_SAMPLES).map(|k| match k {
854        0 => t0,
855        k if k == EDGE_SAMPLES => t1,
856        k => Scalar::fraction(t0, t1, Scalar::from_f64(k as f64 / EDGE_SAMPLES as f64)),
857    });
858    sign_changes(params, |t| -> GeopResult<_> { Ok(sign_at(t)) })
859        .bisected(BISECTIONS, false)
860        .map(|bracket| {
861            let bracket = bracket?;
862            Ok(bracket
863                .undecided
864                .unwrap_or_else(|| bracket.lo.union(bracket.hi)))
865        })
866        .collect()
867}
868
869/// The silhouette of `face_id` seen along the unit vector `d`, as curves on
870/// the face: none for a plane, or for a face seen edge on throughout.
871pub fn face_silhouettes(
872    model: &Model,
873    face_id: FaceId,
874    d: &Vector3,
875) -> GeopResult<Vec<Silhouette>> {
876    let ctx = |e: GeopError| e.with_context(format!("face_silhouettes(face={face_id}, d={d:?})"));
877    let surface = &model.get_face(face_id).with_context(&ctx)?.surface;
878    if surface.as_plane().with_context(&ctx)?.is_some() || swept_along(surface, d) {
879        return Ok(Vec::new());
880    }
881    let step = extent(surface)
882        .div(Scalar::from_f64(STEPS_PER_FACE))
883        .with_context(&ctx)?;
884    let mut lines = grid(surface, d);
885    let mut traces = Vec::new();
886    for l in 0..lines.len() {
887        for b in 0..lines[l].brackets.len() {
888            if lines[l].traced[b] {
889                continue;
890            }
891            lines[l].traced[b] = true;
892            let Some(start) = seed(surface, d, &lines[l], lines[l].brackets[b]) else {
893                continue;
894            };
895            let half = Scalar::from_f64(lines[l].scale / 2.0);
896            let step = if half.definitely_less(step) {
897                half
898            } else {
899                step
900            };
901            let traced = trace(surface, d, start, step, &mut lines).map_err(|e| {
902                e.with_context(format!(
903                    "tracing the silhouette of {surface:?} from {start:?} in steps of {step:?}"
904                ))
905            });
906            traces.push(traced.with_context(&ctx)?);
907        }
908    }
909    let mut silhouettes = Vec::new();
910    for points in traces {
911        let trimmed = trim(model, face_id, d, &points).map_err(|e| {
912            e.with_context(format!("trimming the silhouette traced through {points:?}"))
913        });
914        silhouettes.extend(trimmed.with_context(&ctx)?);
915    }
916    Ok(silhouettes)
917}
918
919/// The pieces of the traced silhouette `points` inside the face, as 3-D
920/// curves through exact silhouette points.
921fn trim(
922    model: &Model,
923    face_id: FaceId,
924    d: &Vector3,
925    points: &[(Scalar, Scalar)],
926) -> GeopResult<Vec<Silhouette>> {
927    let surface = &model.get_face(face_id)?.surface;
928    let mut uv: Vec<Vector2> = Vec::with_capacity(points.len());
929    for &(u, v) in points {
930        let p = Vector2::from_array([u, v]);
931        if uv.last().is_none_or(|q: &Vector2| !q.could_be_equal(&p)) {
932            uv.push(p);
933        }
934    }
935    if uv.len() < 2 {
936        return Ok(Vec::new());
937    }
938    let fit = NurbCurve2D::interpolate(&uv, (uv.len() - 1).min(3))
939        .map_err(|e| e.with_context(format!("fitting the traced silhouette through {uv:?}")))?;
940    let (t0, t1) = fit.domain();
941
942    // Where the trace crosses or runs along the face's trim. A trace that
943    // left the domain ends exactly on its boundary, so the trim meets it
944    // there, at an end it already has: only a meeting definitely inside the
945    // trace is a cut. Cutting at the midpoint of the box found at an end
946    // left a sliver beyond it, which at a cone's apex lay a hair inside the
947    // face and drew a silhouette piece no longer than the rounding.
948    //
949    // A face whose boundary is its silhouette by design — a half cylinder
950    // seen from above — is seen a hair off that in a placed part turned by
951    // a pose rounded in its last bits: the trace runs `1e-14` beside the
952    // trim all along it, and the exact search could only cover that with
953    // pieces as small. Should it fail, the two are compared again to the
954    // sheet's resolution, as the lines of a view are (see
955    // `hidden_lines::meetings`): a stretch running that close is one.
956    let coarse = Scalar::from_f64(COARSE_SUBDIVISION);
957    let mut meetings = Vec::new();
958    for coedge_id in model.iterate_face_coedges(face_id) {
959        let pcurve = &model.get_coedge(coedge_id)?.pcurve;
960        let (overlaps, crossings) = curve_curve_overlaps_and_crossings(
961            &fit,
962            pcurve,
963            Scalar::ZERO,
964            MAX_NODES,
965            min_subdivision_size(),
966        )
967        .or_else(|error| {
968            curve_curve_overlaps_and_crossings(&fit, pcurve, coarse, MAX_NODES, coarse).map_err(
969                |e| {
970                    e.with_context(format!(
971                        "searched again to the sheet's resolution, after: {error}"
972                    ))
973                },
974            )
975        })
976        .map_err(|e| {
977            e.with_context(format!(
978                "where the traced silhouette {fit:?} meets the pcurve {pcurve:?} of coedge \
979                 {coedge_id}"
980            ))
981        })?;
982        meetings.extend(crossings.iter().map(|(s, _)| *s));
983        meetings.extend(overlaps.iter().flat_map(|o| [o.start.t, o.end.t]));
984    }
985    let mut cuts = vec![t0];
986    cuts.extend(cut_points(meetings, (t0, t1)));
987    cuts.push(t1);
988
989    let mut silhouettes = Vec::new();
990    for pair in cuts.windows(2) {
991        let (a, b) = (pair[0], pair[1]);
992        let mid = a.add(b).div(Scalar::TWO)?.sharpen();
993        let at = fit.evaluate(mid)?;
994        let inside = face_contains(
995            model,
996            face_id,
997            at[0],
998            at[1],
999            MAX_NODES,
1000            min_subdivision_size(),
1001            FACE_CONTAINS_SEED,
1002        )?;
1003        if inside != PointClassification::Inside {
1004            continue;
1005        }
1006        // Exact points along the piece, as many as the trace had there and
1007        // at least enough for a cubic.
1008        let share = b.sub(a).div(t1.sub(t0))?.to_f64();
1009        let count = ((share * uv.len() as f64).ceil() as usize).max(4);
1010        let mut samples = Vec::with_capacity(count + 1);
1011        for k in 0..=count {
1012            let t = match k {
1013                0 => a,
1014                k if k == count => b,
1015                k => Scalar::fraction(a, b, Scalar::from_f64(k as f64 / count as f64)),
1016            };
1017            let q = fit.evaluate(t)?;
1018            let (u, v) =
1019                correct(surface, d, (q[0].sharpen(), q[1].sharpen())).unwrap_or((q[0], q[1]));
1020            samples.push(Vector2::from_array([u, v]));
1021        }
1022        let mut stations: Vec<(Vector3, Vector2)> = Vec::with_capacity(samples.len());
1023        for s in &samples {
1024            let p = surface.evaluate(s[0], s[1])?;
1025            if stations
1026                .last()
1027                .is_none_or(|(q, _): &(Vector3, Vector2)| !q.could_be_equal(&p))
1028            {
1029                stations.push((p, *s));
1030            }
1031        }
1032        if stations.len() < 2 {
1033            continue;
1034        }
1035        // A cubic through the stations, widened to enclose the silhouette
1036        // between them too: two faces whose silhouettes are one line on
1037        // the paper — mirror images seen along the mirror's normal — are
1038        // then seen to run along each other, where two interpolants of the
1039        // one line, each drifting from it its own way, cross and touch
1040        // along all of it and no search could cut them.
1041        let locate = |(_, from): &(Vector3, Vector2),
1042                      (_, to): &(Vector3, Vector2),
1043                      frac: Scalar|
1044         -> GeopResult<(Vector3, Vector2)> {
1045            let blend = |k: usize| Scalar::fraction(from[k], to[k], frac).sharpen();
1046            let (u, v) = correct(surface, d, (blend(0), blend(1)))?;
1047            Ok((surface.evaluate(u, v)?, Vector2::from_array([u, v])))
1048        };
1049        let ctx = |e: GeopError| {
1050            e.with_context(format!(
1051                "fitting the silhouette of face {face_id} through {stations:?}"
1052            ))
1053        };
1054        let curve = NurbCurve3D::fit_enclosing(&stations, locate, &mut None, &ctx)?;
1055        silhouettes.push(Silhouette {
1056            face: face_id,
1057            curve,
1058            samples,
1059        });
1060    }
1061    Ok(silhouettes)
1062}
1063
1064#[cfg(test)]
1065mod tests {
1066    use super::*;
1067    use geop_core_math::vector::Vector4;
1068    use geop_core_topology::build::BodySpec;
1069
1070    /// A bicubic sheet over `[0, 1]²` of 60 knot spans each way, rising
1071    /// gently along `x` (`z = x / 20`), with one control point near
1072    /// `(0.48, 0.48)` raised into a bump: a sheet whose only silhouette,
1073    /// seen along `x`, is a small loop round the bump's flank, where its
1074    /// slope cancels the rise — a loop that fits between two iso lines a
1075    /// twelfth of the domain apart.
1076    fn bumpy_sheet() -> (Model, FaceId) {
1077        let spans = 60;
1078        let n = spans + 3;
1079        let s = Scalar::from_f64;
1080        let mut knots = vec![Scalar::ZERO; 4];
1081        knots.extend((1..spans).map(|k| s(k as f64 / spans as f64)));
1082        knots.extend([Scalar::ONE; 4]);
1083        let mut control_points = Vec::with_capacity(n * n);
1084        for i in 0..n {
1085            for j in 0..n {
1086                let (x, y) = (i as f64 / (n - 1) as f64, j as f64 / (n - 1) as f64);
1087                let bump = if (i, j) == (30, 30) { 0.08 } else { 0.0 };
1088                control_points.push(Vector4::from_array([
1089                    s(x),
1090                    s(y),
1091                    s(x / 20.0 + bump),
1092                    s(1.0),
1093                ]));
1094            }
1095        }
1096        let surface = NurbSurface3D::try_new(3, 3, control_points, knots.clone(), knots).unwrap();
1097        let mut model = Model::new();
1098        let built = model
1099            .build_body(BodySpec::sheet_on(surface).unwrap())
1100            .unwrap();
1101        (model, built.faces[0])
1102    }
1103
1104    /// The bump's loop is found, though it crosses no iso line a sampling
1105    /// grid would look along, and traced once.
1106    #[test]
1107    fn a_small_loop_between_grid_lines_is_found() {
1108        let (model, face) = bumpy_sheet();
1109        let d = Vector3::from_array([Scalar::ONE, Scalar::ZERO, Scalar::ZERO]);
1110        let pieces = face_silhouettes(&model, face, &d).unwrap();
1111        assert_eq!(pieces.len(), 1, "{pieces:?}");
1112        let curve = &pieces[0].curve;
1113        let (start, end) = (curve.start().unwrap(), curve.end().unwrap());
1114        assert!(start.could_be_equal(&end), "{start:?} {end:?}");
1115    }
1116}