1use ogeom_core::{OgeomResult, Tolerances, ogeom_bail};
19use ogeom_math::{Direction, Frame, Point, Point2, Vector};
20use ogeom_mesh::Deflection;
21use ogeom_topo::{Filter, Model, Shape, ShapeType, Triangulation, explore};
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub enum Visibility {
26 Visible,
28 Hidden,
30}
31
32#[derive(Debug, Clone)]
34pub enum Source {
35 Edge(Shape),
37 Silhouette,
39}
40
41#[derive(Debug, Clone)]
43pub struct DrawnCurve {
44 pub points: Vec<Point2>,
46 pub visibility: Visibility,
48 pub source: Source,
50}
51
52#[derive(Debug, Clone, Default)]
54pub struct Drawing {
55 pub visible: Vec<DrawnCurve>,
57 pub hidden: Vec<DrawnCurve>,
59}
60
61impl Drawing {
62 pub fn curves(&self) -> impl Iterator<Item = &DrawnCurve> {
64 self.visible.iter().chain(self.hidden.iter())
65 }
66}
67
68#[derive(Debug, Clone, Copy)]
71pub struct View {
72 frame: Frame,
73}
74
75impl View {
76 pub fn looking(direction: Vector, up: Vector, tol: Tolerances) -> OgeomResult<Self> {
83 let toward_eye = Direction::new(-direction, tol)?;
84 let right = Direction::new(up.cross(toward_eye.vector()), tol)?;
85 Ok(Self {
86 frame: Frame::new(Point::ORIGIN, toward_eye, right, tol)?,
87 })
88 }
89
90 #[must_use]
92 pub fn project(&self, p: Point) -> Point2 {
93 let local = self.frame.to_local(p);
94 Point2::new(local.x, local.y)
95 }
96
97 #[must_use]
99 pub fn depth(&self, p: Point) -> f64 {
100 self.frame.to_local(p).z
101 }
102
103 #[must_use]
105 pub fn toward_eye(&self) -> Vector {
106 self.frame.z().vector()
107 }
108}
109
110pub fn project(
123 model: &Model,
124 shape: &Shape,
125 view: &View,
126 deflection: Deflection,
127 tol: Tolerances,
128) -> OgeomResult<Drawing> {
129 let mesh = ogeom_mesh::triangulate(model, shape, deflection, tol)?;
130 if mesh.is_empty() {
131 ogeom_bail!(Construction, "the shape tessellates to nothing to draw");
132 }
133 let clearance = deflection.chord.max(tol.confusion() * 1e3) * 4.0;
137
138 let mut drawing = Drawing::default();
139
140 let mut seen = std::collections::HashSet::new();
142 for edge in explore(model, shape, Filter::OfType(ShapeType::Edge))? {
143 let key = (edge.node(), edge.location().clone());
144 if !seen.insert(key) {
145 continue;
146 }
147 let Ok(points) = ogeom_mesh::polyline_of_edge(model, &edge, deflection, tol) else {
148 continue;
149 };
150 classify_into(
151 &mut drawing,
152 &points,
153 Source::Edge(edge.clone()),
154 view,
155 &mesh,
156 clearance,
157 tol,
158 );
159 }
160
161 let toward_eye = view.toward_eye();
164 let mut uses: std::collections::HashMap<(u32, u32), Vec<usize>> =
165 std::collections::HashMap::new();
166 for (t, triangle) in mesh.triangles.iter().enumerate() {
167 for i in 0..3 {
168 let (a, b) = (triangle[i], triangle[(i + 1) % 3]);
169 uses.entry((a.min(b), a.max(b))).or_default().push(t);
170 }
171 }
172 let facing = |t: usize| -> f64 {
173 let [a, b, c] = mesh.triangles[t];
174 let (pa, pb, pc) = (
175 mesh.positions[a as usize],
176 mesh.positions[b as usize],
177 mesh.positions[c as usize],
178 );
179 (pb - pa).cross(pc - pa).dot(toward_eye)
180 };
181 let mut edges: Vec<(&(u32, u32), &Vec<usize>)> = uses.iter().collect();
182 edges.sort_by_key(|&(&(a, b), _)| (a, b));
183 for (&(a, b), triangles) in edges {
184 let silhouette = match triangles.as_slice() {
185 [t] => facing(*t) > 0.0,
186 [s, t] => (facing(*s) > 0.0) != (facing(*t) > 0.0),
187 _ => false,
188 };
189 if !silhouette {
190 continue;
191 }
192 let points = [mesh.positions[a as usize], mesh.positions[b as usize]];
193 classify_into(
194 &mut drawing,
195 &points,
196 Source::Silhouette,
197 view,
198 &mesh,
199 clearance,
200 tol,
201 );
202 }
203 Ok(drawing)
204}
205
206fn classify_into(
208 drawing: &mut Drawing,
209 points: &[Point],
210 source: Source,
211 view: &View,
212 mesh: &Triangulation,
213 clearance: f64,
214 tol: Tolerances,
215) {
216 let mut run: Vec<Point2> = Vec::new();
217 let mut run_visibility: Option<Visibility> = None;
218 let mut flush = |run: &mut Vec<Point2>, visibility: Option<Visibility>| {
219 if run.len() < 2 {
220 run.clear();
221 return;
222 }
223 let curve = DrawnCurve {
224 points: std::mem::take(run),
225 visibility: visibility.unwrap_or(Visibility::Visible),
226 source: source.clone(),
227 };
228 match visibility {
229 Some(Visibility::Hidden) => drawing.hidden.push(curve),
230 _ => drawing.visible.push(curve),
231 }
232 };
233 for pair in points.windows(2) {
234 let (a, b) = (pair[0], pair[1]);
235 let (pa, pb) = (view.project(a), view.project(b));
236 if pa.distance(pb) <= tol.confusion() {
237 flush(&mut run, run_visibility);
239 run_visibility = None;
240 continue;
241 }
242 let mid = Point::new(
243 f64::midpoint(a.x, b.x),
244 f64::midpoint(a.y, b.y),
245 f64::midpoint(a.z, b.z),
246 );
247 let visibility = if occluded(mesh, mid, view, clearance) {
248 Visibility::Hidden
249 } else {
250 Visibility::Visible
251 };
252 if run_visibility != Some(visibility) {
253 flush(&mut run, run_visibility);
254 run_visibility = Some(visibility);
255 }
256 if run.is_empty() {
257 run.push(pa);
258 }
259 run.push(pb);
260 }
261 flush(&mut run, run_visibility);
262}
263
264fn occluded(mesh: &Triangulation, p: Point, view: &View, clearance: f64) -> bool {
266 let toward_eye = view.toward_eye();
267 let depth = view.depth(p);
268 for triangle in &mesh.triangles {
269 let [a, b, c] = *triangle;
270 let (pa, pb, pc) = (
271 mesh.positions[a as usize],
272 mesh.positions[b as usize],
273 mesh.positions[c as usize],
274 );
275 let (e1, e2) = (pb - pa, pc - pa);
277 let h = toward_eye.cross(e2);
278 let det = e1.dot(h);
279 if det.abs() < 1e-14 {
280 continue;
281 }
282 let inv = 1.0 / det;
283 let s = p - pa;
284 let u = s.dot(h) * inv;
285 if !(0.0..=1.0).contains(&u) {
286 continue;
287 }
288 let q = s.cross(e1);
289 let v = toward_eye.dot(q) * inv;
290 if v < 0.0 || u + v > 1.0 {
291 continue;
292 }
293 let t = e2.dot(q) * inv;
294 if t <= clearance {
295 continue;
296 }
297 let hit_depth = depth + t;
298 if hit_depth > depth + clearance {
299 return true;
300 }
301 }
302 false
303}
304
305#[cfg(test)]
306#[allow(clippy::unwrap_used, clippy::expect_used)]
307mod tests {
308 use super::*;
309 use ogeom_math::Frame as MFrame;
310
311 const T: Tolerances = Tolerances::millimetres();
312
313 fn fine() -> Deflection {
314 Deflection {
315 chord: 1e-2,
316 ..Deflection::default()
317 }
318 }
319
320 fn edge_curves(drawing: &Drawing, visibility: Visibility) -> usize {
321 drawing
322 .curves()
323 .filter(|c| c.visibility == visibility && matches!(c.source, Source::Edge(_)))
324 .count()
325 }
326
327 #[test]
328 fn a_box_face_on_shows_its_front_and_hides_its_back() {
329 let mut model = Model::new();
330 let solid = ogeom_algo::make_box(&mut model, MFrame::WORLD, (10.0, 6.0, 4.0), T).unwrap();
331 let view =
333 View::looking(Vector::new(0.0, 0.0, -1.0), Vector::new(0.0, 1.0, 0.0), T).unwrap();
334 let drawing = super::project(&model, &solid.shape, &view, fine(), T).unwrap();
335
336 assert_eq!(edge_curves(&drawing, Visibility::Visible), 4);
339 assert_eq!(edge_curves(&drawing, Visibility::Hidden), 4);
340 }
341
342 #[test]
343 fn a_box_in_three_quarter_view_shows_nine_and_hides_three() {
344 let mut model = Model::new();
345 let solid = ogeom_algo::make_box(&mut model, MFrame::WORLD, (10.0, 6.0, 4.0), T).unwrap();
346 let view =
349 View::looking(Vector::new(-1.0, -1.2, -0.9), Vector::new(0.0, 0.0, 1.0), T).unwrap();
350 let drawing = super::project(&model, &solid.shape, &view, fine(), T).unwrap();
351 assert_eq!(edge_curves(&drawing, Visibility::Visible), 9);
352 assert_eq!(edge_curves(&drawing, Visibility::Hidden), 3);
353 }
354
355 #[test]
356 fn a_cylinder_from_the_side_draws_its_silhouette() {
357 let mut model = Model::new();
358 let solid = ogeom_algo::make_cylinder(&mut model, MFrame::WORLD, 3.0, 8.0, T).unwrap();
359 let view =
360 View::looking(Vector::new(-1.0, 0.0, 0.0), Vector::new(0.0, 0.0, 1.0), T).unwrap();
361 let drawing = super::project(&model, &solid.shape, &view, fine(), T).unwrap();
362
363 let silhouettes = drawing
366 .visible
367 .iter()
368 .filter(|c| matches!(c.source, Source::Silhouette))
369 .count();
370 assert!(silhouettes > 0, "a curved side draws by its silhouette");
371 let (mut min_x, mut max_x) = (f64::INFINITY, f64::NEG_INFINITY);
372 let (mut min_y, mut max_y) = (f64::INFINITY, f64::NEG_INFINITY);
373 for curve in &drawing.visible {
374 for p in &curve.points {
375 min_x = min_x.min(p.x);
376 max_x = max_x.max(p.x);
377 min_y = min_y.min(p.y);
378 max_y = max_y.max(p.y);
379 }
380 }
381 assert!(
382 (max_x - min_x - 6.0).abs() < 0.1,
383 "diameter across the sheet"
384 );
385 assert!((max_y - min_y - 8.0).abs() < 0.1, "height up the sheet");
386 }
387
388 #[test]
389 fn a_small_box_behind_a_large_one_is_entirely_hidden() {
390 let mut model = Model::new();
391 let front = ogeom_algo::make_box(&mut model, MFrame::WORLD, (20.0, 20.0, 2.0), T).unwrap();
392 let behind_frame =
393 MFrame::new(Point::new(8.0, 8.0, -10.0), Direction::Z, Direction::X, T).unwrap();
394 let back = ogeom_algo::make_box(&mut model, behind_frame, (4.0, 4.0, 2.0), T).unwrap();
395 let both = ogeom_algo::build::make_compound(
396 &mut model,
397 &[front.shape.clone(), back.shape.clone()],
398 )
399 .unwrap();
400 let view =
401 View::looking(Vector::new(0.0, 0.0, -1.0), Vector::new(0.0, 1.0, 0.0), T).unwrap();
402 let drawing = super::project(&model, &both.shape, &view, fine(), T).unwrap();
403
404 let back_edges_visible = drawing
406 .visible
407 .iter()
408 .filter_map(|c| match &c.source {
409 Source::Edge(e) => Some(e),
410 Source::Silhouette => None,
411 })
412 .filter(|e| {
413 ogeom_topo::explore(&model, &back.shape, Filter::OfType(ShapeType::Edge))
414 .unwrap()
415 .iter()
416 .any(|be| be.node() == e.node())
417 })
418 .count();
419 assert_eq!(back_edges_visible, 0, "the plate hides the block");
420 }
421}