Skip to main content

ogeom_hlr/
project.rs

1//! Polygonal hidden line removal: project, classify, draw.
2//!
3//! The mesh does the occlusion work. The drawing's curves come from two
4//! places: the model's own edges, discretized by the same machinery every
5//! face boundary uses, and the tessellation's silhouettes, the mesh edges
6//! where the surface turns away from the eye. Every sampled segment is
7//! split where its projection crosses a contour of the mesh (a silhouette
8//! or a free border), the only places its visibility can change, and each
9//! piece is classified by casting its midpoint toward the eye against the
10//! whole mesh: a triangle strictly in front hides it. Runs of
11//! same-classified pieces merge back into polylines, so a curve that dips
12//! behind a boss comes out as visible, hidden, visible: three curves, which
13//! is what a drawing shows.
14//!
15//! Polygonal, not exact: the classification is as fine as the tessellation
16//! and the sampling. The exact half (silhouettes in closed form, visibility
17//! asked of the faces themselves) is the `exact` module.
18
19use ogeom_core::{OgeomResult, Tolerances, ogeom_bail};
20use ogeom_math::{Direction, Frame, Point, Point2, Vector};
21use ogeom_mesh::Deflection;
22use ogeom_topo::{Filter, Model, Shape, ShapeType, Triangulation, explore};
23
24use crate::crossings::{Contours, locate};
25
26/// Which side of the pencil a curve lands on.
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum Visibility {
29    /// Nothing stands between the curve and the eye.
30    Visible,
31    /// Something does: drawn dashed, or not at all.
32    Hidden,
33}
34
35/// Where a drawn curve came from.
36#[derive(Debug, Clone)]
37pub enum Source {
38    /// A model edge, with the occurrence that produced it.
39    Edge(Shape),
40    /// A silhouette: the tessellation turning away from the eye.
41    Silhouette,
42}
43
44/// One polyline of the drawing, in view-plane coordinates.
45#[derive(Debug, Clone)]
46pub struct DrawnCurve {
47    /// The projected points, in order.
48    pub points: Vec<Point2>,
49    /// Visible or hidden.
50    pub visibility: Visibility,
51    /// What it is a picture of.
52    pub source: Source,
53}
54
55/// A 2D drawing: the classified projection of a shape.
56#[derive(Debug, Clone, Default)]
57pub struct Drawing {
58    /// Curves nothing occludes.
59    pub visible: Vec<DrawnCurve>,
60    /// Curves something does.
61    pub hidden: Vec<DrawnCurve>,
62}
63
64impl Drawing {
65    /// Every curve, visible first.
66    pub fn curves(&self) -> impl Iterator<Item = &DrawnCurve> {
67        self.visible.iter().chain(self.hidden.iter())
68    }
69}
70
71/// The view for a drawing: an orthographic camera looking along `-z` of the
72/// frame it carries, with `x` right and `y` up on the sheet.
73#[derive(Debug, Clone, Copy)]
74pub struct View {
75    frame: Frame,
76}
77
78impl View {
79    /// A view looking along `direction`, with `up` steadying the sheet.
80    ///
81    /// # Errors
82    ///
83    /// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if
84    /// `up` is parallel to the view direction.
85    pub fn looking(direction: Vector, up: Vector, tol: Tolerances) -> OgeomResult<Self> {
86        let toward_eye = Direction::new(-direction, tol)?;
87        let right = Direction::new(up.cross(toward_eye.vector()), tol)?;
88        Ok(Self {
89            frame: Frame::new(Point::ORIGIN, toward_eye, right, tol)?,
90        })
91    }
92
93    /// Sheet coordinates of a world point: `x` right, `y` up.
94    #[must_use]
95    pub fn project(&self, p: Point) -> Point2 {
96        let local = self.frame.to_local(p);
97        Point2::new(local.x, local.y)
98    }
99
100    /// Depth of a world point: greater is nearer the eye.
101    #[must_use]
102    pub fn depth(&self, p: Point) -> f64 {
103        self.frame.to_local(p).z
104    }
105
106    /// The world direction toward the eye.
107    #[must_use]
108    pub fn toward_eye(&self) -> Vector {
109        self.frame.z().vector()
110    }
111}
112
113/// Project a shape into a classified 2D drawing.
114///
115/// The model's edges and the tessellation's silhouettes, each split into
116/// visible and hidden runs by occlusion against the shape's own mesh.
117/// Segments that project to nothing (an edge running straight along the
118/// view direction) are dropped: a point is not a line in a drawing.
119///
120/// # Errors
121///
122/// As [`ogeom_mesh::triangulate()`]; and
123/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if the
124/// shape has no faces to draw.
125pub fn project(
126    model: &Model,
127    shape: &Shape,
128    view: &View,
129    deflection: Deflection,
130    tol: Tolerances,
131) -> OgeomResult<Drawing> {
132    let mesh = ogeom_mesh::triangulate(model, shape, deflection, tol)?;
133    if mesh.is_empty() {
134        ogeom_bail!(Construction, "the shape tessellates to nothing to draw");
135    }
136    // A segment on the exact surface sits up to a sagitta outside the
137    // inscribed mesh, and a sample on a front face must not be occluded by
138    // that face's own triangles: the clearance a hit must beat.
139    let clearance = deflection.chord.max(tol.confusion() * 1e3) * 4.0;
140    let occluders = Occluders::over(&mesh, view);
141
142    // The contours: interior mesh edges whose triangles disagree about
143    // facing the eye, border edges, and edges more than two triangles
144    // share. Faces are meshed apart, so the mesh is welded first: otherwise
145    // every face's border would read as one. The silhouettes drawn are the
146    // contours that turn away from the eye and the borders of front faces.
147    let welded = mesh.welded(tol);
148    let toward_eye = view.toward_eye();
149    let mut uses: ogeom_core::FastMap<(u32, u32), Vec<usize>> = ogeom_core::FastMap::default();
150    for (t, triangle) in welded.triangles.iter().enumerate() {
151        for i in 0..3 {
152            let (a, b) = (triangle[i], triangle[(i + 1) % 3]);
153            uses.entry((a.min(b), a.max(b))).or_default().push(t);
154        }
155    }
156    let facing = |t: usize| -> f64 {
157        let [a, b, c] = welded.triangles[t];
158        let (pa, pb, pc) = (
159            welded.positions[a as usize],
160            welded.positions[b as usize],
161            welded.positions[c as usize],
162        );
163        (pb - pa).cross(pc - pa).dot(toward_eye)
164    };
165    let mut edges: Vec<(&(u32, u32), &Vec<usize>)> = uses.iter().collect();
166    edges.sort_by_key(|&(&(a, b), _)| (a, b));
167    let mut contours: Vec<(Point2, Point2)> = Vec::new();
168    let mut silhouettes: Vec<[Point; 2]> = Vec::new();
169    for (&(a, b), triangles) in edges {
170        let (contour, silhouette) = match triangles.as_slice() {
171            [t] => (true, facing(*t) > 0.0),
172            [s, t] => {
173                let turns = (facing(*s) > 0.0) != (facing(*t) > 0.0);
174                (turns, turns)
175            }
176            _ => (true, false),
177        };
178        let points = [welded.positions[a as usize], welded.positions[b as usize]];
179        if contour {
180            contours.push((view.project(points[0]), view.project(points[1])));
181        }
182        if silhouette {
183            silhouettes.push(points);
184        }
185    }
186    let contours = Contours::new(contours);
187    let classifier = Classifier {
188        view,
189        occluders: &occluders,
190        contours: &contours,
191        clearance,
192        tol,
193    };
194
195    let mut drawing = Drawing::default();
196
197    // The model's own edges, their segments kept so a silhouette along one
198    // is not drawn over it.
199    let mut seen = ogeom_core::FastSet::default();
200    let mut drawn = DrawnSegments::new(tol);
201    for edge in explore(model, shape, Filter::OfType(ShapeType::Edge))? {
202        let key = (edge.node(), edge.location().clone());
203        if !seen.insert(key) {
204            continue;
205        }
206        let Ok(points) = ogeom_mesh::polyline_of_edge(model, &edge, deflection, tol) else {
207            continue;
208        };
209        drawn.add(&points);
210        classifier.classify_into(&mut drawing, &points, Source::Edge(edge.clone()));
211    }
212
213    for points in silhouettes {
214        if drawn.holds(points[0], points[1]) {
215            continue;
216        }
217        classifier.classify_into(&mut drawing, &points, Source::Silhouette);
218    }
219    Ok(drawing)
220}
221
222/// A grid cell of [`DrawnSegments`].
223type Cell = (i64, i64, i64);
224
225/// The segments of the model edges already drawn, found by their ends.
226struct DrawnSegments {
227    cell: f64,
228    tol: Tolerances,
229    /// Each polyline point by its grid cell: which polyline, and where on
230    /// it.
231    points: ogeom_core::FastMap<Cell, Vec<(usize, usize, Point)>>,
232    lines: usize,
233}
234
235impl DrawnSegments {
236    fn new(tol: Tolerances) -> Self {
237        Self {
238            cell: tol.confusion().max(f64::MIN_POSITIVE) * 10.0,
239            tol,
240            points: ogeom_core::FastMap::default(),
241            lines: 0,
242        }
243    }
244
245    fn key(&self, p: Point) -> Cell {
246        #[allow(clippy::cast_possible_truncation)]
247        (
248            (p.x / self.cell).floor() as i64,
249            (p.y / self.cell).floor() as i64,
250            (p.z / self.cell).floor() as i64,
251        )
252    }
253
254    fn add(&mut self, polyline: &[Point]) {
255        for (index, p) in polyline.iter().enumerate() {
256            let key = self.key(*p);
257            self.points
258                .entry(key)
259                .or_default()
260                .push((self.lines, index, *p));
261        }
262        self.lines += 1;
263    }
264
265    /// Where `p` stands on the drawn polylines.
266    fn at(&self, p: Point) -> Vec<(usize, usize)> {
267        let (x, y, z) = self.key(p);
268        let mut out = Vec::new();
269        for dx in -1..=1 {
270            for dy in -1..=1 {
271                for dz in -1..=1 {
272                    let Some(found) = self.points.get(&(x + dx, y + dy, z + dz)) else {
273                        continue;
274                    };
275                    out.extend(
276                        found
277                            .iter()
278                            .filter(|(_, _, q)| q.is_equal(p, self.tol))
279                            .map(|&(line, index, _)| (line, index)),
280                    );
281                }
282            }
283        }
284        out
285    }
286
287    /// Whether `a` to `b` is a segment of a drawn polyline.
288    fn holds(&self, a: Point, b: Point) -> bool {
289        let at_b = self.at(b);
290        self.at(a).into_iter().any(|(line, index)| {
291            at_b.iter()
292                .any(|&(other, j)| other == line && index.abs_diff(j) == 1)
293        })
294    }
295}
296
297/// What a polyline is classified against: the mesh that hides, and the
298/// contours where hiding can begin or end.
299struct Classifier<'a> {
300    view: &'a View,
301    occluders: &'a Occluders<'a>,
302    contours: &'a Contours,
303    clearance: f64,
304    tol: Tolerances,
305}
306
307impl Classifier<'_> {
308    /// Split a polyline into visible and hidden runs against the mesh.
309    ///
310    /// Each segment is cut where its projection crosses a contour, and each
311    /// piece is classified by one ray from its middle: between two
312    /// crossings nothing can pass in front of it.
313    fn classify_into(&self, drawing: &mut Drawing, points: &[Point], source: Source) {
314        let (view, tol) = (self.view, self.tol);
315        let mut run: Vec<Point2> = Vec::new();
316        let mut run_visibility: Option<Visibility> = None;
317        let mut flush = |run: &mut Vec<Point2>, visibility: Option<Visibility>| {
318            if run.len() < 2 {
319                run.clear();
320                return;
321            }
322            let curve = DrawnCurve {
323                points: std::mem::take(run),
324                visibility: visibility.unwrap_or(Visibility::Visible),
325                source: source.clone(),
326            };
327            match visibility {
328                Some(Visibility::Hidden) => drawing.hidden.push(curve),
329                _ => drawing.visible.push(curve),
330            }
331        };
332        let projected: Vec<Point2> = points.iter().map(|p| view.project(*p)).collect();
333        let at = |position: f64| -> Point {
334            let (k, f) = locate(position, points.len());
335            points[k] + (points[k + 1] - points[k]) * f
336        };
337        for pair in self.contours.split(&projected, tol).windows(2) {
338            let (from, to) = (pair[0], pair[1]);
339            let (k, _) = locate(from, points.len());
340            if projected[k].distance(projected[k + 1]) <= tol.confusion() {
341                // Projects to a point: not a line in a drawing.
342                flush(&mut run, run_visibility);
343                run_visibility = None;
344                continue;
345            }
346            let middle = at(f64::midpoint(from, to));
347            let visibility = if self.occluders.occlude(middle, view, self.clearance) {
348                Visibility::Hidden
349            } else {
350                Visibility::Visible
351            };
352            if run_visibility != Some(visibility) {
353                flush(&mut run, run_visibility);
354                run_visibility = Some(visibility);
355            }
356            if run.is_empty() {
357                run.push(view.project(at(from)));
358            }
359            run.push(view.project(at(to)));
360        }
361        flush(&mut run, run_visibility);
362    }
363}
364
365/// A mesh's triangles binned by where they project in the view.
366///
367/// The view is orthographic, so the ray from a point toward the eye can
368/// only hit a triangle whose projection covers the point's: a sample is
369/// tested against the triangles of its own cell of a grid over the drawing,
370/// not the whole mesh.
371struct Occluders<'m> {
372    mesh: &'m Triangulation,
373    cells: Vec<Vec<u32>>,
374    low: Point2,
375    size: f64,
376    columns: usize,
377    rows: usize,
378}
379
380impl<'m> Occluders<'m> {
381    fn over(mesh: &'m Triangulation, view: &View) -> Self {
382        let projected: Vec<Point2> = mesh.positions.iter().map(|p| view.project(*p)).collect();
383        let (mut low, mut high) = (
384            Point2::new(f64::INFINITY, f64::INFINITY),
385            Point2::new(f64::NEG_INFINITY, f64::NEG_INFINITY),
386        );
387        for p in &projected {
388            low = Point2::new(low.x.min(p.x), low.y.min(p.y));
389            high = Point2::new(high.x.max(p.x), high.y.max(p.y));
390        }
391        #[allow(
392            clippy::cast_precision_loss,
393            clippy::cast_possible_truncation,
394            clippy::cast_sign_loss
395        )]
396        let side = ((mesh.triangles.len() as f64).sqrt().ceil() as usize).clamp(1, 512);
397        let span = (high.x - low.x).max(high.y - low.y);
398        #[allow(clippy::cast_precision_loss)]
399        let size = if span.is_finite() && span > 0.0 {
400            span / side as f64
401        } else {
402            1.0
403        };
404        let (columns, rows) = (side, side);
405        let mut cells: Vec<Vec<u32>> = vec![Vec::new(); columns * rows];
406        let cell = |x: f64, lo: f64, n: usize| -> usize {
407            #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
408            let k = ((x - lo) / size).floor().max(0.0) as usize;
409            k.min(n - 1)
410        };
411        for (t, triangle) in mesh.triangles.iter().enumerate() {
412            let corners = triangle.map(|i| projected[i as usize]);
413            let (x0, x1) = (
414                corners.iter().map(|p| p.x).fold(f64::INFINITY, f64::min),
415                corners
416                    .iter()
417                    .map(|p| p.x)
418                    .fold(f64::NEG_INFINITY, f64::max),
419            );
420            let (y0, y1) = (
421                corners.iter().map(|p| p.y).fold(f64::INFINITY, f64::min),
422                corners
423                    .iter()
424                    .map(|p| p.y)
425                    .fold(f64::NEG_INFINITY, f64::max),
426            );
427            if !(x0.is_finite() && x1.is_finite() && y0.is_finite() && y1.is_finite()) {
428                continue;
429            }
430            // A cell's worth of margin each way: the test's own tolerance
431            // lets a hit land a hair outside the exact projection.
432            let (c0, c1) = (
433                cell(x0 - size, low.x, columns),
434                cell(x1 + size, low.x, columns),
435            );
436            let (r0, r1) = (cell(y0 - size, low.y, rows), cell(y1 + size, low.y, rows));
437            #[allow(clippy::cast_possible_truncation)]
438            for r in r0..=r1 {
439                for c in c0..=c1 {
440                    cells[r * columns + c].push(t as u32);
441                }
442            }
443        }
444        Self {
445            mesh,
446            cells,
447            low,
448            size,
449            columns,
450            rows,
451        }
452    }
453
454    /// Whether anything in the mesh stands between `p` and the eye.
455    fn occlude(&self, p: Point, view: &View, clearance: f64) -> bool {
456        let q = view.project(p);
457        #[allow(
458            clippy::cast_possible_truncation,
459            clippy::cast_sign_loss,
460            clippy::cast_precision_loss
461        )]
462        let at = |x: f64, lo: f64, n: usize| -> Option<usize> {
463            // A point on the drawing's outer edge falls exactly at the
464            // grid's end: within a cell of it, it belongs to the last cell.
465            let k = ((x - lo) / self.size).floor();
466            (k >= -1.0 && k <= n as f64).then(|| (k.max(0.0) as usize).min(n - 1))
467        };
468        let (Some(c), Some(r)) = (
469            at(q.x, self.low.x, self.columns),
470            at(q.y, self.low.y, self.rows),
471        ) else {
472            return false;
473        };
474        occluded(
475            self.mesh,
476            self.cells[r * self.columns + c].iter().map(|t| *t as usize),
477            p,
478            view,
479            clearance,
480        )
481    }
482}
483
484/// Whether any of the given triangles stands between the point and the eye.
485fn occluded(
486    mesh: &Triangulation,
487    candidates: impl Iterator<Item = usize>,
488    p: Point,
489    view: &View,
490    clearance: f64,
491) -> bool {
492    let toward_eye = view.toward_eye();
493    let depth = view.depth(p);
494    for t in candidates {
495        let [a, b, c] = mesh.triangles[t];
496        let (pa, pb, pc) = (
497            mesh.positions[a as usize],
498            mesh.positions[b as usize],
499            mesh.positions[c as usize],
500        );
501        // Möller-Trumbore, orthographic: the ray from p toward the eye.
502        let (e1, e2) = (pb - pa, pc - pa);
503        let h = toward_eye.cross(e2);
504        let det = e1.dot(h);
505        if det.abs() < 1e-14 {
506            continue;
507        }
508        let inv = 1.0 / det;
509        let s = p - pa;
510        let u = s.dot(h) * inv;
511        if !(0.0..=1.0).contains(&u) {
512            continue;
513        }
514        let q = s.cross(e1);
515        let v = toward_eye.dot(q) * inv;
516        if v < 0.0 || u + v > 1.0 {
517            continue;
518        }
519        let t = e2.dot(q) * inv;
520        if t <= clearance {
521            continue;
522        }
523        let hit_depth = depth + t;
524        if hit_depth > depth + clearance {
525            return true;
526        }
527    }
528    false
529}
530
531#[cfg(test)]
532#[allow(clippy::unwrap_used, clippy::expect_used)]
533mod tests {
534    use super::*;
535    use ogeom_math::Frame as MFrame;
536
537    const T: Tolerances = Tolerances::millimetres();
538
539    fn fine() -> Deflection {
540        Deflection {
541            chord: 1e-2,
542            ..Deflection::default()
543        }
544    }
545
546    fn edge_curves(drawing: &Drawing, visibility: Visibility) -> usize {
547        drawing
548            .curves()
549            .filter(|c| c.visibility == visibility && matches!(c.source, Source::Edge(_)))
550            .count()
551    }
552
553    #[test]
554    fn a_box_face_on_shows_its_front_and_hides_its_back() {
555        let mut model = Model::new();
556        let solid = ogeom_algo::make_box(&mut model, MFrame::WORLD, (10.0, 6.0, 4.0), T).unwrap();
557        // Looking down -z: the eye is above, the top face is the front.
558        let view =
559            View::looking(Vector::new(0.0, 0.0, -1.0), Vector::new(0.0, 1.0, 0.0), T).unwrap();
560        let drawing = super::project(&model, &solid.shape, &view, fine(), T).unwrap();
561
562        // Four top edges visible, four bottom edges hidden behind the top
563        // face. The four vertical edges project to points and are dropped.
564        assert_eq!(edge_curves(&drawing, Visibility::Visible), 4);
565        assert_eq!(edge_curves(&drawing, Visibility::Hidden), 4);
566    }
567
568    #[test]
569    fn a_box_in_three_quarter_view_shows_nine_and_hides_three() {
570        let mut model = Model::new();
571        let solid = ogeom_algo::make_box(&mut model, MFrame::WORLD, (10.0, 6.0, 4.0), T).unwrap();
572        // The classic drawing-class view: three faces show, nine edges
573        // visible, the three edges meeting at the far corner hidden.
574        let view =
575            View::looking(Vector::new(-1.0, -1.2, -0.9), Vector::new(0.0, 0.0, 1.0), T).unwrap();
576        let drawing = super::project(&model, &solid.shape, &view, fine(), T).unwrap();
577        assert_eq!(edge_curves(&drawing, Visibility::Visible), 9);
578        assert_eq!(edge_curves(&drawing, Visibility::Hidden), 3);
579    }
580
581    #[test]
582    fn a_cylinder_from_the_side_draws_its_silhouette() {
583        let mut model = Model::new();
584        let solid = ogeom_algo::make_cylinder(&mut model, MFrame::WORLD, 3.0, 8.0, T).unwrap();
585        let view =
586            View::looking(Vector::new(-1.0, 0.0, 0.0), Vector::new(0.0, 0.0, 1.0), T).unwrap();
587        let drawing = super::project(&model, &solid.shape, &view, fine(), T).unwrap();
588
589        // Silhouette runs exist, and the visible drawing spans the
590        // cylinder's height and diameter.
591        let silhouettes = drawing
592            .visible
593            .iter()
594            .filter(|c| matches!(c.source, Source::Silhouette))
595            .count();
596        assert!(silhouettes > 0, "a curved side draws by its silhouette");
597        let (mut min_x, mut max_x) = (f64::INFINITY, f64::NEG_INFINITY);
598        let (mut min_y, mut max_y) = (f64::INFINITY, f64::NEG_INFINITY);
599        for curve in &drawing.visible {
600            for p in &curve.points {
601                min_x = min_x.min(p.x);
602                max_x = max_x.max(p.x);
603                min_y = min_y.min(p.y);
604                max_y = max_y.max(p.y);
605            }
606        }
607        assert!(
608            (max_x - min_x - 6.0).abs() < 0.1,
609            "diameter across the sheet"
610        );
611        assert!((max_y - min_y - 8.0).abs() < 0.1, "height up the sheet");
612    }
613
614    fn silhouette_length(drawing: &Drawing, visibility: Visibility) -> f64 {
615        drawing
616            .curves()
617            .filter(|c| c.visibility == visibility && matches!(c.source, Source::Silhouette))
618            .map(|c| {
619                c.points
620                    .windows(2)
621                    .map(|w| w[0].distance(w[1]))
622                    .sum::<f64>()
623            })
624            .sum()
625    }
626
627    /// Each outline is drawn once: a box's outline is its own edges, with
628    /// no silhouette over them, and a drum's side silhouette is its two
629    /// generators, not its face borders as well.
630    #[test]
631    fn every_outline_is_drawn_once() {
632        let mut model = Model::new();
633        let solid = ogeom_algo::make_box(&mut model, MFrame::WORLD, (10.0, 6.0, 4.0), T).unwrap();
634        let view =
635            View::looking(Vector::new(-1.0, -1.2, -0.9), Vector::new(0.0, 0.0, 1.0), T).unwrap();
636        let drawing = super::project(&model, &solid.shape, &view, fine(), T).unwrap();
637        assert!(silhouette_length(&drawing, Visibility::Visible) < 1e-9);
638        assert!(silhouette_length(&drawing, Visibility::Hidden) < 1e-9);
639
640        let drum = ogeom_algo::make_cylinder(&mut model, MFrame::WORLD, 3.0, 8.0, T).unwrap();
641        let side =
642            View::looking(Vector::new(-1.0, 0.3, 0.0), Vector::new(0.0, 0.0, 1.0), T).unwrap();
643        let drawing = super::project(&model, &drum.shape, &side, fine(), T).unwrap();
644        let visible = silhouette_length(&drawing, Visibility::Visible);
645        assert!((visible - 16.0).abs() < 1e-6, "two generators: {visible}");
646    }
647
648    /// A bar under a block that covers its far end, seen from above: the
649    /// bar's top edge is one straight segment, visible up to the block's
650    /// side and hidden past it.
651    #[test]
652    fn a_straight_edge_half_under_a_block_is_split_at_the_blocks_outline() {
653        let mut model = Model::new();
654        let bar = ogeom_algo::make_box(&mut model, MFrame::WORLD, (20.0, 1.0, 1.0), T).unwrap();
655        let at = MFrame::new(Point::new(12.0, -5.0, 5.0), Direction::Z, Direction::X, T).unwrap();
656        let block = ogeom_algo::make_box(&mut model, at, (20.0, 10.0, 2.0), T).unwrap();
657        let both = ogeom_algo::build::make_compound(&mut model, &[bar.shape.clone(), block.shape])
658            .unwrap();
659        let view =
660            View::looking(Vector::new(0.0, 0.0, -1.0), Vector::new(0.0, 1.0, 0.0), T).unwrap();
661        let drawing = super::project(&model, &both.shape, &view, fine(), T).unwrap();
662
663        let top = explore(&model, &bar.shape, Filter::OfType(ShapeType::Edge))
664            .unwrap()
665            .into_iter()
666            .find(|e| {
667                let points = ogeom_mesh::polyline_of_edge(&model, e, fine(), T).unwrap();
668                points.len() == 2
669                    && points
670                        .iter()
671                        .all(|p| p.y.abs() < 1e-9 && (p.z - 1.0).abs() < 1e-9)
672            })
673            .unwrap();
674        let spans = |curves: &[DrawnCurve]| -> Vec<(f64, f64)> {
675            curves
676                .iter()
677                .filter(|c| matches!(&c.source, Source::Edge(e) if e.node() == top.node()))
678                .map(|c| {
679                    let xs = c.points.iter().map(|p| p.x);
680                    (
681                        xs.clone().fold(f64::INFINITY, f64::min),
682                        xs.fold(f64::NEG_INFINITY, f64::max),
683                    )
684                })
685                .collect()
686        };
687        let visible = spans(&drawing.visible);
688        let hidden = spans(&drawing.hidden);
689        assert_eq!(visible.len(), 1, "{visible:?} {hidden:?}");
690        assert_eq!(hidden.len(), 1, "{hidden:?}");
691        assert!(visible[0].0.abs() < 1e-9 && (visible[0].1 - 12.0).abs() < 1e-9);
692        assert!((hidden[0].0 - 12.0).abs() < 1e-9 && (hidden[0].1 - 20.0).abs() < 1e-9);
693    }
694
695    #[test]
696    fn a_small_box_behind_a_large_one_is_entirely_hidden() {
697        let mut model = Model::new();
698        let front = ogeom_algo::make_box(&mut model, MFrame::WORLD, (20.0, 20.0, 2.0), T).unwrap();
699        let behind_frame =
700            MFrame::new(Point::new(8.0, 8.0, -10.0), Direction::Z, Direction::X, T).unwrap();
701        let back = ogeom_algo::make_box(&mut model, behind_frame, (4.0, 4.0, 2.0), T).unwrap();
702        let both = ogeom_algo::build::make_compound(
703            &mut model,
704            &[front.shape.clone(), back.shape.clone()],
705        )
706        .unwrap();
707        let view =
708            View::looking(Vector::new(0.0, 0.0, -1.0), Vector::new(0.0, 1.0, 0.0), T).unwrap();
709        let drawing = super::project(&model, &both.shape, &view, fine(), T).unwrap();
710
711        // Every drawable edge of the back box is hidden by the front plate.
712        let back_edges_visible = drawing
713            .visible
714            .iter()
715            .filter_map(|c| match &c.source {
716                Source::Edge(e) => Some(e),
717                Source::Silhouette => None,
718            })
719            .filter(|e| {
720                ogeom_topo::explore(&model, &back.shape, Filter::OfType(ShapeType::Edge))
721                    .unwrap()
722                    .iter()
723                    .any(|be| be.node() == e.node())
724            })
725            .count();
726        assert_eq!(back_edges_visible, 0, "the plate hides the block");
727    }
728}