Skip to main content

brepkit_operations/
projection.rs

1//! Edge projection with hidden-line removal (HLR).
2//!
3//! Projects a solid's edges onto a view plane and splits each edge into visible
4//! and hidden polylines. Occlusion is an **exact** point-in-solid test — no
5//! tessellation of faces: a boundary point is hidden when stepping it toward the
6//! camera enters the solid (a face is in front of it). Edges themselves are
7//! sampled to polylines because a projected drawing is inherently polygonal.
8
9use brepkit_math::vec::{Point2, Point3, Vec3};
10use brepkit_topology::Topology;
11use brepkit_topology::solid::SolidId;
12
13use crate::classify::{PointClassification, classify_point};
14
15/// Projected edges, split into visible and hidden 2D polylines (in the view
16/// plane's `(x, y)` coordinates).
17#[derive(Debug, Clone, Default)]
18pub struct ProjectedEdges {
19    /// Polylines that are not occluded by the solid.
20    pub visible: Vec<Vec<Point2>>,
21    /// Polylines hidden behind the solid (empty when `hidden_lines` is false).
22    pub hidden: Vec<Vec<Point2>>,
23}
24
25/// Project a solid's edges onto the view plane through `origin` with in-plane
26/// x-axis `x_axis`, viewed along `direction` (orthographic), classifying each
27/// segment as visible or hidden.
28///
29/// `direction` points from the camera into the scene. `x_axis` is the horizontal
30/// view direction (re-orthonormalized against `direction`). `deflection` controls
31/// edge-sampling density (the point-classification tolerance is fixed). When
32/// `hidden_lines` is false, hidden segments are dropped and `hidden` is left empty.
33///
34/// # Errors
35///
36/// Returns [`crate::OperationsError::InvalidInput`] if `direction` or `x_axis`
37/// is degenerate, and propagates topology, sampling, and point-classification
38/// errors.
39pub fn project_edges(
40    topo: &Topology,
41    solid: SolidId,
42    origin: Point3,
43    direction: Vec3,
44    x_axis: Vec3,
45    hidden_lines: bool,
46    deflection: f64,
47) -> Result<ProjectedEdges, crate::OperationsError> {
48    let view = direction
49        .normalize()
50        .map_err(|_| crate::OperationsError::InvalidInput {
51            reason: "projection direction must be non-zero".into(),
52        })?;
53    // In-plane orthonormal basis: x re-orthonormalized against the view, y = x × view.
54    let x = (x_axis - view * view.dot(x_axis))
55        .normalize()
56        .map_err(|_| crate::OperationsError::InvalidInput {
57            reason: "projection x_axis is parallel to the direction".into(),
58        })?;
59    let y = x.cross(view);
60
61    let project = |p: Point3| -> Point2 {
62        let v = p - origin;
63        Point2::new(x.dot(v), y.dot(v))
64    };
65
66    // Step length for the occlusion probe — far enough to clear the boundary
67    // tolerance, small relative to the model. Keyed to the model extent and
68    // capped so a large-coordinate model can't push the probe through thin
69    // features.
70    let bbox = crate::measure::solid_bounding_box(topo, solid)?;
71    let diag = (bbox.max - bbox.min).length();
72    let eps = (diag * 1e-4).clamp(1e-6, 1e-2);
73
74    // A boundary point is hidden when stepping toward the camera (−view) lands
75    // inside the solid, i.e. a face is between it and the camera. A
76    // classification error is propagated rather than silently read as
77    // "visible", so a degenerate solid surfaces instead of yielding wrong HLR.
78    let is_hidden = |p: Point3| -> Result<bool, crate::OperationsError> {
79        Ok(
80            classify_point(topo, solid, p - view * eps, deflection, 1e-7)?
81                == PointClassification::Inside,
82        )
83    };
84
85    let lines = crate::tessellate::sample_solid_edges(topo, solid, deflection)?;
86    let n_edges = lines.offsets.len();
87    let mut result = ProjectedEdges::default();
88
89    for i in 0..n_edges {
90        let start = lines.offsets[i];
91        let end = if i + 1 < n_edges {
92            lines.offsets[i + 1]
93        } else {
94            lines.positions.len()
95        };
96        let pts = &lines.positions[start..end];
97        if pts.len() < 2 {
98            continue;
99        }
100
101        // Classify each segment by its midpoint, then merge consecutive
102        // same-visibility segments into polylines (adjacent runs share the
103        // boundary vertex, so the drawing stays connected).
104        let seg_hidden: Vec<bool> = (0..pts.len() - 1)
105            .map(|j| {
106                let mid = Point3::new(
107                    0.5 * (pts[j].x() + pts[j + 1].x()),
108                    0.5 * (pts[j].y() + pts[j + 1].y()),
109                    0.5 * (pts[j].z() + pts[j + 1].z()),
110                );
111                is_hidden(mid)
112            })
113            .collect::<Result<Vec<bool>, _>>()?;
114
115        let mut j = 0;
116        while j < seg_hidden.len() {
117            let hidden = seg_hidden[j];
118            let run_start = j;
119            while j < seg_hidden.len() && seg_hidden[j] == hidden {
120                j += 1;
121            }
122            // Run covers segments [run_start, j), i.e. points [run_start, j].
123            if hidden && !hidden_lines {
124                continue;
125            }
126            let poly: Vec<Point2> = (run_start..=j).map(|k| project(pts[k])).collect();
127            if hidden {
128                result.hidden.push(poly);
129            } else {
130                result.visible.push(poly);
131            }
132        }
133    }
134
135    Ok(result)
136}
137
138#[cfg(test)]
139mod tests {
140    #![allow(clippy::unwrap_used)]
141
142    use super::*;
143
144    // An oblique view along (1,1,1): the three edges meeting at the far corner
145    // (10,10,10) are unambiguously occluded by the three near faces.
146    fn oblique() -> (Point3, Vec3, Vec3) {
147        (
148            Point3::new(-100.0, -100.0, -100.0),
149            Vec3::new(1.0, 1.0, 1.0),
150            Vec3::new(1.0, -1.0, 0.0),
151        )
152    }
153
154    #[test]
155    fn project_box_oblique_view_has_visible_and_hidden_edges() {
156        let mut topo = Topology::new();
157        let solid = crate::primitives::make_box(&mut topo, 10.0, 10.0, 10.0).unwrap();
158        let (o, d, x) = oblique();
159        let result = project_edges(&topo, solid, o, d, x, true, 0.1).unwrap();
160        assert!(
161            !result.visible.is_empty(),
162            "oblique view must have visible edges"
163        );
164        assert!(
165            !result.hidden.is_empty(),
166            "the far corner's edges must be hidden behind the box"
167        );
168    }
169
170    #[test]
171    fn project_box_without_hidden_lines_drops_hidden() {
172        let mut topo = Topology::new();
173        let solid = crate::primitives::make_box(&mut topo, 10.0, 10.0, 10.0).unwrap();
174        let (o, d, x) = oblique();
175        let result = project_edges(&topo, solid, o, d, x, false, 0.1).unwrap();
176        assert!(!result.visible.is_empty());
177        assert!(
178            result.hidden.is_empty(),
179            "hidden lines disabled → no hidden polylines"
180        );
181    }
182}