1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum Visibility {
29 Visible,
31 Hidden,
33}
34
35#[derive(Debug, Clone)]
37pub enum Source {
38 Edge(Shape),
40 Silhouette,
42}
43
44#[derive(Debug, Clone)]
46pub struct DrawnCurve {
47 pub points: Vec<Point2>,
49 pub visibility: Visibility,
51 pub source: Source,
53}
54
55#[derive(Debug, Clone, Default)]
57pub struct Drawing {
58 pub visible: Vec<DrawnCurve>,
60 pub hidden: Vec<DrawnCurve>,
62}
63
64impl Drawing {
65 pub fn curves(&self) -> impl Iterator<Item = &DrawnCurve> {
67 self.visible.iter().chain(self.hidden.iter())
68 }
69}
70
71#[derive(Debug, Clone, Copy)]
74pub struct View {
75 frame: Frame,
76}
77
78impl View {
79 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 #[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 #[must_use]
102 pub fn depth(&self, p: Point) -> f64 {
103 self.frame.to_local(p).z
104 }
105
106 #[must_use]
108 pub fn toward_eye(&self) -> Vector {
109 self.frame.z().vector()
110 }
111}
112
113pub 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 let clearance = deflection.chord.max(tol.confusion() * 1e3) * 4.0;
140 let occluders = Occluders::over(&mesh, view);
141
142 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 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
222type Cell = (i64, i64, i64);
224
225struct DrawnSegments {
227 cell: f64,
228 tol: Tolerances,
229 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 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 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
297struct 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 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 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
365struct 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 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 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 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
484fn 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 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 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 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 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 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 #[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 #[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 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}