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