1use ogeom_core::{OgeomResult, Tolerances, ogeom_bail};
24use ogeom_geom::{CircleCurve, Curve, Curve3d as _, LineCurve, Surface as _, SurfaceGeometry};
25use ogeom_math::{Axis, Circle, Direction, Frame, Point, Point2, Transform, Vector};
26use ogeom_mesh::Deflection;
27use ogeom_topo::{EdgeRepr, Model, NodeData, Orientation, Shape, ShapeType, explore_unique};
28
29use crate::crossings::{Contours, locate};
30use crate::project::{Drawing, DrawnCurve, Source, View, Visibility};
31
32#[derive(Debug, Clone)]
34pub struct Silhouette {
35 pub face: Shape,
37 pub curve: Curve,
39 pub range: (f64, f64),
41}
42
43pub fn silhouettes(
64 model: &Model,
65 shape: &Shape,
66 direction: Vector,
67 tol: Tolerances,
68) -> OgeomResult<Vec<Silhouette>> {
69 let magnitude = direction.magnitude();
70 if magnitude <= tol.confusion() {
71 ogeom_bail!(Construction, "a silhouette needs a direction to look along");
72 }
73 let along = direction / magnitude;
74 let deflection = Deflection::default();
75
76 let faces = explore_unique(model, shape, ShapeType::Face)?;
77 let per_face = ogeom_core::parallel::map_ordered(&faces, |_, face| {
78 face_silhouettes(model, face, along, deflection, tol)
79 });
80 let mut out = Vec::new();
81 for found in per_face {
82 out.extend(found?);
83 }
84 Ok(out)
85}
86
87fn face_silhouettes(
89 model: &Model,
90 face: &Shape,
91 along: Vector,
92 deflection: Deflection,
93 tol: Tolerances,
94) -> OgeomResult<Vec<Silhouette>> {
95 let Some(NodeData::Face(data)) = model.node(face).map(|n| n.data()) else {
96 return Ok(Vec::new());
97 };
98 let Some(surface) = model.geometry().surface(data.surface) else {
99 return Ok(Vec::new());
100 };
101 let placement = face.transform(model.datums())?;
102 let world = ogeom_geom::Transformable::transformed(surface, &placement, tol)?;
103 let candidates = match &world {
104 SurfaceGeometry::Plane(_) => Vec::new(),
105 SurfaceGeometry::Sphere(s) => {
106 let sphere = s.sphere();
110 let axis = Direction::new(along, tol)?;
111 let frame = Frame::new(sphere.centre(), axis, perpendicular(along, tol)?, tol)?;
112 vec![Curve::Circle(CircleCurve::new(Circle::new(
113 frame,
114 sphere.radius(),
115 tol,
116 )?))]
117 }
118 SurfaceGeometry::Cylinder(c) => {
119 let cylinder = c.cylinder();
123 let axis = cylinder.frame().z().vector();
124 let sideways = axis.cross(along);
125 let m = sideways.magnitude();
126 if m <= tol.angular() {
127 Vec::new()
130 } else {
131 let sideways = sideways / m;
132 [1.0, -1.0]
133 .iter()
134 .map(|sign| {
135 let at = cylinder.frame().origin() + sideways * (cylinder.radius() * sign);
136 Curve::Line(LineCurve::new(Axis::new(at, cylinder.frame().z())))
137 })
138 .collect()
139 }
140 }
141 SurfaceGeometry::Cone(c) => {
142 let cone = c.cone();
149 let frame = cone.frame();
150 let (x, y, z) = (frame.x().vector(), frame.y().vector(), frame.z().vector());
151 let (sin, cos) = cone.half_angle().sin_cos();
152 let (a, b) = (cos * along.dot(x), cos * along.dot(y));
154 let c0 = -sin * along.dot(z);
155 let r = a.hypot(b);
156 if r <= tol.angular() || c0.abs() > r {
157 Vec::new()
158 } else {
159 let phase = b.atan2(a);
160 let spread = (-c0 / r).acos();
161 [phase + spread, phase - spread]
162 .iter()
163 .map(|u| {
164 let radial = x * u.cos() + y * u.sin();
165 let apex = cone.apex();
166 let direction =
167 radial * cone.half_angle().sin() + z * cone.half_angle().cos();
168 Direction::new(direction, tol)
169 .map(|d| Curve::Line(LineCurve::new(Axis::new(apex, d))))
170 })
171 .collect::<OgeomResult<Vec<Curve>>>()?
172 }
173 }
174 other => marched_silhouettes(other, along, tol)?,
178 };
179
180 if candidates.is_empty() {
181 return Ok(Vec::new());
182 }
183 let rings = ogeom_mesh::face_boundary(model, face, deflection, tol)?;
184 let seeds = ogeom_algo::SurfaceSeeds::over(&world, 24, tol)?;
187 let mut out = Vec::new();
188 for curve in candidates {
189 for range in within_trim(model, face, &world, &seeds, &rings, &curve, tol)? {
190 out.push(Silhouette {
191 face: face.clone(),
192 curve: curve.clone(),
193 range,
194 });
195 }
196 }
197 Ok(out)
198}
199
200pub fn reflect_lines(
212 model: &Model,
213 shape: &Shape,
214 light: Vector,
215 tol: Tolerances,
216) -> OgeomResult<Vec<Silhouette>> {
217 silhouettes(model, shape, light, tol)
218}
219
220pub fn iso_curves(
232 model: &Model,
233 face: &Shape,
234 u_count: usize,
235 v_count: usize,
236 tol: Tolerances,
237) -> OgeomResult<Vec<Vec<Point>>> {
238 let Some(NodeData::Face(data)) = model.node(face).map(|n| n.data().clone()) else {
239 ogeom_bail!(Construction, "expected a face");
240 };
241 let Some(surface) = model.geometry().surface(data.surface).cloned() else {
242 ogeom_bail!(Dangling, "face refers to a surface not in this model");
243 };
244 let placement = face.transform(model.datums())?;
245 let world = ogeom_geom::Transformable::transformed(&surface, &placement, tol)?;
246 let ((u0, u1), (v0, v1)) = world.domain();
247 let rings = ogeom_mesh::face_boundary(model, face, Deflection::default(), tol)?;
248
249 const ALONG: usize = 64;
250 let mut out = Vec::new();
251 for (count, constant_u) in [(u_count, true), (v_count, false)] {
252 for i in 1..=count {
253 #[expect(
254 clippy::cast_precision_loss,
255 reason = "a curve index, far below the mantissa"
256 )]
257 let f = i as f64 / (count + 1) as f64;
258 let mut run: Vec<Point> = Vec::new();
259 for k in 0..=ALONG {
260 #[expect(
261 clippy::cast_precision_loss,
262 reason = "a station index, far below the mantissa"
263 )]
264 let g = k as f64 / ALONG as f64;
265 let (u, v) = if constant_u {
266 ((u1 - u0).mul_add(f, u0), (v1 - v0).mul_add(g, v0))
267 } else {
268 ((u1 - u0).mul_add(g, u0), (v1 - v0).mul_add(f, v0))
269 };
270 if inside_rings(&rings, Point2::new(u, v)) {
271 run.push(world.point_at(u, v, tol)?);
272 } else if run.len() >= 2 {
273 out.push(std::mem::take(&mut run));
274 } else {
275 run.clear();
276 }
277 }
278 if run.len() >= 2 {
279 out.push(run);
280 }
281 }
282 }
283 Ok(out)
284}
285
286pub fn project_exact(
302 model: &Model,
303 shape: &Shape,
304 view: &View,
305 deflection: Deflection,
306 tol: Tolerances,
307) -> OgeomResult<Drawing> {
308 let faces = Blockers::new(model, shape, view, tol)?;
309 if faces.list.is_empty() {
310 ogeom_bail!(Construction, "a shape with no faces draws nothing");
311 }
312
313 let edges = explore_unique(model, shape, ShapeType::Edge)?;
316 let mut traced: Vec<Traced> = ogeom_core::parallel::map_ordered(&edges, |_, edge| {
317 traced_edge(model, edge, deflection, tol)
318 })
319 .into_iter()
320 .flatten()
321 .collect();
322 for silhouette in silhouettes(model, shape, view.toward_eye(), tol)? {
323 let line = ogeom_mesh::discretize(&silhouette.curve, silhouette.range, deflection, tol)?;
324 traced.push(Traced {
325 curve: silhouette.curve,
326 placement: Transform::IDENTITY,
327 parameters: line.parameters,
328 points: line.points,
329 source: Source::Silhouette,
330 });
331 }
332 let projected: Vec<Vec<Point2>> = traced
333 .iter()
334 .map(|t| t.points.iter().map(|p| view.project(*p)).collect())
335 .collect();
336 let contours = Contours::new(
337 projected
338 .iter()
339 .flat_map(|line| line.windows(2).map(|w| (w[0], w[1])))
340 .collect(),
341 );
342
343 let classified = ogeom_core::parallel::map_ordered(&traced, |i, curve| {
346 let mut drawing = Drawing::default();
347 classify(
348 &mut drawing,
349 curve,
350 &projected[i],
351 view,
352 &faces,
353 &contours,
354 tol,
355 )
356 .map(|()| drawing)
357 });
358 let mut drawing = Drawing::default();
359 for one in classified {
360 let one = one?;
361 drawing.visible.extend(one.visible);
362 drawing.hidden.extend(one.hidden);
363 }
364 Ok(drawing)
365}
366
367struct Traced {
370 curve: Curve,
371 placement: Transform,
373 parameters: Vec<f64>,
375 points: Vec<Point>,
377 source: Source,
378}
379
380impl Traced {
381 fn at(&self, position: f64, tol: Tolerances) -> OgeomResult<Point> {
384 let (k, f) = locate(position, self.parameters.len());
385 let (t0, t1) = (self.parameters[k], self.parameters[k + 1]);
386 Ok(self
387 .placement
388 .apply(self.curve.point_at((t1 - t0).mul_add(f, t0), tol)?))
389 }
390}
391
392fn traced_edge(
395 model: &Model,
396 edge: &Shape,
397 deflection: Deflection,
398 tol: Tolerances,
399) -> Option<Traced> {
400 let data = model.node(edge)?.data().as_edge()?;
401 let Some(EdgeRepr::Curve3d { curve, range, .. }) = data.curve3d() else {
402 return None;
403 };
404 let curve = model.geometry().curve(*curve)?.clone();
405 let placement = edge.transform(model.datums()).ok()?;
406 let line = ogeom_mesh::discretize(&curve, *range, deflection, tol).ok()?;
407 let mut points: Vec<Point> = line.points.iter().map(|p| placement.apply(*p)).collect();
408 let mut parameters = line.parameters;
409 if edge.orientation() == Orientation::Reversed {
410 points.reverse();
411 parameters.reverse();
412 }
413 Some(Traced {
414 curve,
415 placement,
416 parameters,
417 points,
418 source: Source::Edge(edge.clone()),
419 })
420}
421
422struct Blocker {
425 surface: ogeom_intersect::PreparedSurface,
426 rings: Vec<Vec<Point2>>,
427 low: Point2,
431 high: Point2,
432 front: f64,
435 sampled: Option<(Point, Point)>,
443}
444
445struct Blockers {
448 list: Vec<Blocker>,
449 cells: Vec<Vec<u32>>,
452 low: Point2,
453 size: f64,
454 side: usize,
455}
456
457impl Blockers {
458 fn new(model: &Model, shape: &Shape, view: &View, tol: Tolerances) -> OgeomResult<Self> {
459 let faces = explore_unique(model, shape, ShapeType::Face)?;
460 let built =
461 ogeom_core::parallel::map_ordered(&faces, |_, face| blocker(model, face, view, tol));
462 let mut list = Vec::with_capacity(built.len());
463 for one in built {
464 list.extend(one?);
465 }
466
467 let (mut low, mut high) = (
468 Point2::new(f64::INFINITY, f64::INFINITY),
469 Point2::new(f64::NEG_INFINITY, f64::NEG_INFINITY),
470 );
471 for b in &list {
472 low = Point2::new(low.x.min(b.low.x), low.y.min(b.low.y));
473 high = Point2::new(high.x.max(b.high.x), high.y.max(b.high.y));
474 }
475 #[allow(
476 clippy::cast_precision_loss,
477 clippy::cast_possible_truncation,
478 clippy::cast_sign_loss
479 )]
480 let side = ((list.len() as f64).sqrt().ceil() as usize * 2).clamp(1, 256);
481 let span = (high.x - low.x).max(high.y - low.y);
482 #[allow(clippy::cast_precision_loss)]
483 let size = if span.is_finite() && span > 0.0 {
484 span / side as f64
485 } else {
486 1.0
487 };
488 if !low.x.is_finite() || !low.y.is_finite() {
489 low = Point2::new(0.0, 0.0);
490 }
491 let mut out = Self {
492 list: Vec::new(),
493 cells: vec![Vec::new(); side * side],
494 low,
495 size,
496 side,
497 };
498 for (index, b) in list.iter().enumerate() {
499 #[allow(clippy::cast_possible_truncation)]
500 let index = index as u32;
501 let (c0, c1) = (out.cell(b.low.x, low.x), out.cell(b.high.x, low.x));
502 let (r0, r1) = (out.cell(b.low.y, low.y), out.cell(b.high.y, low.y));
503 for r in r0..=r1 {
504 for c in c0..=c1 {
505 out.cells[r * side + c].push(index);
506 }
507 }
508 }
509 out.list = list;
510 Ok(out)
511 }
512
513 fn cell(&self, x: f64, lo: f64) -> usize {
516 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
517 let k = ((x - lo) / self.size).floor().max(0.0) as usize;
518 k.min(self.side - 1)
519 }
520
521 fn covering(&self, seen: Point2, depth: f64) -> impl Iterator<Item = usize> + '_ {
524 let cell = self.cell(seen.y, self.low.y) * self.side + self.cell(seen.x, self.low.x);
525 self.cells[cell]
526 .iter()
527 .map(|&i| i as usize)
528 .filter(move |&i| self.list[i].covers(seen, depth))
529 }
530}
531
532impl Blocker {
533 fn covers(&self, seen: Point2, depth: f64) -> bool {
536 self.low.x <= seen.x
537 && seen.x <= self.high.x
538 && self.low.y <= seen.y
539 && seen.y <= self.high.y
540 && self.front > depth
541 }
542}
543
544fn blocker(
546 model: &Model,
547 face: &Shape,
548 view: &View,
549 tol: Tolerances,
550) -> OgeomResult<Option<Blocker>> {
551 let Some(NodeData::Face(data)) = model.node(face).map(|n| n.data()) else {
552 return Ok(None);
553 };
554 let Some(surface) = model.geometry().surface(data.surface) else {
555 return Ok(None);
556 };
557 let placement = face.transform(model.datums())?;
558 let surface = ogeom_geom::Transformable::transformed(surface, &placement, tol)?;
559 let deflection = Deflection::default();
560 let rings = ogeom_mesh::face_boundary(model, face, deflection, tol)?;
561 let bound = ogeom_algo::shape_bounds(model, face, tol)?.expanded(tol.confusion() * 1e3);
562 let sampled = match surface {
563 SurfaceGeometry::Plane(_)
564 | SurfaceGeometry::Sphere(_)
565 | SurfaceGeometry::Cylinder(_)
566 | SurfaceGeometry::Cone(_)
567 | SurfaceGeometry::Torus(_) => None,
568 _ => {
569 let wide = bound.expanded(deflection.chord);
570 wide.low().zip(wide.high())
571 }
572 };
573 let corners = bound.corners();
574 if corners.is_empty() {
575 return Ok(None);
576 }
577 let (mut low, mut high) = (
578 Point2::new(f64::INFINITY, f64::INFINITY),
579 Point2::new(f64::NEG_INFINITY, f64::NEG_INFINITY),
580 );
581 let mut front = f64::NEG_INFINITY;
582 for c in &corners {
583 let q = view.project(*c);
584 low = Point2::new(low.x.min(q.x), low.y.min(q.y));
585 high = Point2::new(high.x.max(q.x), high.y.max(q.y));
586 front = front.max(view.depth(*c));
587 }
588 Ok(Some(Blocker {
589 surface: ogeom_intersect::PreparedSurface::new(
590 surface,
591 ogeom_intersect::CurveSurfaceOptions::default(),
592 tol,
593 ),
594 rings,
595 low,
596 high,
597 front,
598 sampled,
599 }))
600}
601
602fn classify(
610 drawing: &mut Drawing,
611 traced: &Traced,
612 projected: &[Point2],
613 view: &View,
614 faces: &Blockers,
615 contours: &Contours,
616 tol: Tolerances,
617) -> OgeomResult<()> {
618 if traced.points.len() < 2 {
619 return Ok(());
620 }
621 let last = std::cell::Cell::new(None);
624 let seen = |position: f64| -> OgeomResult<Visibility> {
625 Ok(
626 if occluded(traced.at(position, tol)?, view, faces, &last, tol)? {
627 Visibility::Hidden
628 } else {
629 Visibility::Visible
630 },
631 )
632 };
633 let change_between = |mut lo: f64, mut hi: f64, was: Visibility| -> OgeomResult<f64> {
634 for _ in 0..64 {
635 if traced.at(lo, tol)?.distance(traced.at(hi, tol)?) <= tol.confusion() {
636 break;
637 }
638 let mid = f64::midpoint(lo, hi);
639 if seen(mid)? == was {
640 lo = mid;
641 } else {
642 hi = mid;
643 }
644 }
645 Ok(f64::midpoint(lo, hi))
646 };
647 let drawn_at =
648 |position: f64| -> OgeomResult<Point2> { Ok(view.project(traced.at(position, tol)?)) };
649 let mut flush = |run: &mut Vec<Point2>, visibility: Visibility| {
650 if run.len() < 2 {
651 run.clear();
652 return;
653 }
654 let curve = DrawnCurve {
655 points: std::mem::take(run),
656 visibility,
657 source: traced.source.clone(),
658 };
659 if visibility == Visibility::Hidden {
660 drawing.hidden.push(curve);
661 } else {
662 drawing.visible.push(curve);
663 }
664 };
665
666 let positions = contours.split(projected, tol);
667 let middles: Vec<f64> = positions
668 .windows(2)
669 .map(|w| f64::midpoint(w[0], w[1]))
670 .collect();
671 let mut verdicts = Vec::with_capacity(middles.len());
672 for middle in &middles {
673 verdicts.push(seen(*middle)?);
674 }
675 let Some(&first) = verdicts.first() else {
676 return Ok(());
677 };
678 let mut run = vec![drawn_at(positions[0])?];
679 let mut held = first;
680 for (i, &verdict) in verdicts.iter().enumerate() {
681 if verdict != held {
682 let boundary = positions[i];
685 let change = change_between(middles[i - 1], middles[i], held)?;
686 let at_change = drawn_at(change)?;
687 if change < boundary {
688 run.pop();
689 run.push(at_change);
690 flush(&mut run, held);
691 run = vec![at_change, drawn_at(boundary)?];
692 } else {
693 run.push(at_change);
694 flush(&mut run, held);
695 run = vec![at_change];
696 }
697 held = verdict;
698 }
699 run.push(drawn_at(positions[i + 1])?);
700 }
701 flush(&mut run, held);
702 Ok(())
703}
704
705fn occluded(
711 at: Point,
712 view: &View,
713 faces: &Blockers,
714 last: &std::cell::Cell<Option<usize>>,
715 tol: Tolerances,
716) -> OgeomResult<bool> {
717 let toward = view.toward_eye();
718 let magnitude = toward.magnitude();
719 if magnitude <= tol.confusion() {
720 return Ok(false);
721 }
722 let direction = toward / magnitude;
723 let reach = 1e6;
726 let clearance = tol.confusion() * 1e3;
727 let axis = Axis::new(at, Direction::new(direction, tol)?);
728 let ray = Curve::Line(LineCurve::new(axis));
729 let hides = |face: &Blocker| -> OgeomResult<bool> {
730 let found = match face.sampled {
731 None => face.surface.intersect(&ray)?,
732 Some((low, high)) => {
733 let Some((enter, leave)) = through_box(at, direction, low, high) else {
734 return Ok(false);
735 };
736 let enter = enter.max(0.0);
737 if leave <= enter.max(clearance) {
738 return Ok(false);
739 }
740 face.surface
741 .intersect(&Curve::Line(LineCurve::over(axis, enter, leave)?))?
742 }
743 };
744 Ok(found.crossings.iter().any(|piercing| {
745 piercing.on_curve > clearance
746 && piercing.on_curve < reach
747 && inside_rings(
748 &face.rings,
749 Point2::new(piercing.on_surface.0, piercing.on_surface.1),
750 )
751 }))
752 };
753 let (seen, depth) = (view.project(at), view.depth(at));
754 let first = last.get().filter(|&i| faces.list[i].covers(seen, depth));
755 if let Some(i) = first
756 && hides(&faces.list[i])?
757 {
758 return Ok(true);
759 }
760 for i in faces.covering(seen, depth) {
761 if Some(i) != first && hides(&faces.list[i])? {
762 last.set(Some(i));
763 return Ok(true);
764 }
765 }
766 Ok(false)
767}
768
769fn through_box(from: Point, direction: Vector, low: Point, high: Point) -> Option<(f64, f64)> {
772 let (mut enter, mut leave) = (f64::NEG_INFINITY, f64::INFINITY);
773 for (o, d, lo, hi) in [
774 (from.x, direction.x, low.x, high.x),
775 (from.y, direction.y, low.y, high.y),
776 (from.z, direction.z, low.z, high.z),
777 ] {
778 if d == 0.0 {
779 if o < lo || o > hi {
780 return None;
781 }
782 continue;
783 }
784 let (a, b) = ((lo - o) / d, (hi - o) / d);
785 enter = enter.max(a.min(b));
786 leave = leave.min(a.max(b));
787 }
788 (enter <= leave).then_some((enter, leave))
789}
790
791fn within_trim(
793 model: &Model,
794 face: &Shape,
795 surface: &SurfaceGeometry,
796 seeds: &ogeom_algo::SurfaceSeeds,
797 rings: &[Vec<Point2>],
798 curve: &Curve,
799 tol: Tolerances,
800) -> OgeomResult<Vec<(f64, f64)>> {
801 let (t0, t1) = curve.domain();
802 let (t0, t1) = if t0.is_finite() && t1.is_finite() && t1 - t0 < 1e6 {
806 (t0, t1)
807 } else {
808 let mut bound = ogeom_math::Aabb::EMPTY;
809 for vertex in explore_unique(model, face, ShapeType::Vertex)? {
810 if let Some(data) = model.node(&vertex).and_then(|n| n.data().as_vertex()) {
811 bound = bound.with_point(vertex.transform(model.datums())?.apply(data.point));
812 }
813 }
814 let reach = bound.diagonal().max(1.0);
815 let centre = bound.centre().unwrap_or(Point::ORIGIN);
816 let at = ogeom_algo::project_on_curve(curve, centre, 64, tol)?.parameter;
817 (at - reach, at + reach)
818 };
819
820 const STATIONS: usize = 96;
821 let held_at = |t: f64| -> OgeomResult<bool> {
822 let Ok(point) = curve.point_at(t, tol) else {
823 return Ok(false);
824 };
825 let projection = seeds.project(surface, point, tol)?;
826 let (u, v) = projection.parameters;
827 Ok(projection.distance <= tol.confusion() && inside_rings(rings, Point2::new(u, v)))
832 };
833 let edge_between = |inside: f64, outside: f64| -> OgeomResult<f64> {
838 let (mut lo, mut hi) = (inside, outside);
839 for _ in 0..40 {
840 let mid = f64::midpoint(lo, hi);
841 if held_at(mid)? {
842 lo = mid;
843 } else {
844 hi = mid;
845 }
846 }
847 Ok(lo)
848 };
849
850 let mut out = Vec::new();
851 let mut open: Option<f64> = None;
852 let mut previous: Option<(f64, bool)> = None;
853 for k in 0..=STATIONS {
854 #[expect(
855 clippy::cast_precision_loss,
856 reason = "a station index, far below the mantissa"
857 )]
858 let t = (t1 - t0).mul_add(k as f64 / STATIONS as f64, t0);
859 let held = held_at(t)?;
860 match (held, open) {
861 (true, None) => {
862 open = Some(match previous {
863 Some((was, false)) => edge_between(t, was)?,
864 _ => t,
865 });
866 }
867 (false, Some(from)) => {
868 let to = match previous {
869 Some((was, true)) => edge_between(was, t)?,
870 _ => t,
871 };
872 if to - from > tol.parametric() {
873 out.push((from, to));
874 }
875 open = None;
876 }
877 _ => {}
878 }
879 previous = Some((t, held));
880 }
881 if let Some(from) = open
882 && t1 - from > tol.parametric()
883 {
884 out.push((from, t1));
885 }
886 Ok(out)
887}
888
889fn inside_rings(rings: &[Vec<Point2>], p: Point2) -> bool {
891 let mut inside = false;
892 for ring in rings {
893 for i in 0..ring.len() {
894 let (a, b) = (ring[i], ring[(i + 1) % ring.len()]);
895 if (a.y > p.y) != (b.y > p.y) {
896 let x = (b.x - a.x).mul_add((p.y - a.y) / (b.y - a.y), a.x);
897 if x > p.x {
898 inside = !inside;
899 }
900 }
901 }
902 }
903 inside
904}
905
906fn perpendicular(v: Vector, tol: Tolerances) -> OgeomResult<Direction> {
908 let seed = if v.x.abs() < 0.9 {
909 Vector::X
910 } else {
911 Vector::Y
912 };
913 Direction::new(v.cross(seed), tol)
914}
915
916struct SilhouetteOn<'s> {
937 surface: &'s SurfaceGeometry,
938 along: Vector,
939 reach: f64,
941}
942
943impl ogeom_intersect::walk::Condition for SilhouetteOn<'_> {
944 fn unknowns(&self) -> usize {
945 2
946 }
947
948 fn position(&self, x: &[f64], tol: Tolerances) -> Option<Point> {
949 self.surface.point_at(x[0], x[1], tol).ok()
950 }
951
952 fn position_gradient(&self, x: &[f64], tol: Tolerances) -> Option<Vec<Vector>> {
953 let (du, dv) = self.surface.d1_at(x[0], x[1], tol).ok()?;
954 Some(vec![du, dv])
955 }
956
957 fn system(&self, x: &[f64], tol: Tolerances) -> Option<(Vec<f64>, Vec<Vec<f64>>)> {
958 Some(self.system_at(x, tol)?.0)
959 }
960
961 fn system_at(
962 &self,
963 x: &[f64],
964 tol: Tolerances,
965 ) -> Option<((Vec<f64>, Vec<Vec<f64>>), Point, Vec<Vector>)> {
966 let jet = self.surface.jet_at(x[0], x[1], tol).ok()?;
969 let (su, sv) = (jet.du, jet.dv);
970 let (suu, suv, svv) = (jet.d2u, jet.duv, jet.d2v);
971 let cross = su.cross(sv);
972 let length = cross.magnitude();
973 if length <= tol.confusion() {
974 return None;
975 }
976 let normal = cross / length;
977 let across = |d: Vector| (d - normal * d.dot(normal)) / length;
981 let du = across(suu.cross(sv) + su.cross(suv));
982 let dv = across(suv.cross(sv) + su.cross(svv));
983 Some((
984 (
985 vec![normal.dot(self.along)],
986 vec![vec![du.dot(self.along), dv.dot(self.along)]],
987 ),
988 jet.point,
989 vec![su, sv],
990 ))
991 }
992
993 fn tangent_from(
994 &self,
995 _x: &[f64],
996 jacobian: &[Vec<f64>],
997 gradient: &[Vector],
998 tol: Tolerances,
999 ) -> Option<Vector> {
1000 ogeom_intersect::walk::null_tangent(jacobian, gradient, tol)
1001 }
1002
1003 fn clamp(&self, x: &mut [f64]) {
1004 let ((ua, ub), (va, vb)) = self.surface.domain();
1005 let hold = |value: f64, lo: f64, hi: f64, periodic: bool| {
1006 if periodic && hi > lo {
1007 lo + (value - lo).rem_euclid(hi - lo)
1008 } else {
1009 value.clamp(lo, hi)
1010 }
1011 };
1012 x[0] = hold(x[0], ua, ub, self.surface.is_periodic_u());
1013 x[1] = hold(x[1], va, vb, self.surface.is_periodic_v());
1014 }
1015
1016 fn outside(&self, x: &[f64], tol: Tolerances) -> bool {
1017 let ((ua, ub), (va, vb)) = self.surface.domain();
1018 let band = tol.parametric();
1019 (!self.surface.is_periodic_u() && (x[0] < ua - band || x[0] > ub + band))
1020 || (!self.surface.is_periodic_v() && (x[1] < va - band || x[1] > vb + band))
1021 }
1022
1023 fn near_edge(&self, x: &[f64]) -> bool {
1024 let ((ua, ub), (va, vb)) = self.surface.domain();
1025 let near = |value: f64, lo: f64, hi: f64| {
1026 let reach = (hi - lo) * 1e-6;
1027 value <= lo + reach || value >= hi - reach
1028 };
1029 (!self.surface.is_periodic_u() && near(x[0], ua, ub))
1030 || (!self.surface.is_periodic_v() && near(x[1], va, vb))
1031 }
1032
1033 fn extent(&self) -> f64 {
1034 self.reach
1035 }
1036}
1037
1038fn on_polyline(line: &[Point], p: Point) -> f64 {
1040 let mut best = f64::INFINITY;
1041 for pair in line.windows(2) {
1042 let (a, b) = (pair[0], pair[1]);
1043 let d = b - a;
1044 let len2 = d.dot(d);
1045 let t = if len2 > 0.0 {
1046 ((p - a).dot(d) / len2).clamp(0.0, 1.0)
1047 } else {
1048 0.0
1049 };
1050 best = best.min(p.distance(a + d * t));
1051 }
1052 best
1053}
1054
1055fn fitted_silhouette(
1069 condition: &SilhouetteOn<'_>,
1070 walked: &ogeom_intersect::walk::Walked,
1071 chord: f64,
1072 tol: Tolerances,
1073) -> Option<ogeom_geom::BSplineCurve> {
1074 let mut ts: Vec<f64> = Vec::with_capacity(walked.points.len());
1077 let mut kept: Vec<usize> = Vec::with_capacity(walked.points.len());
1078 for (i, p) in walked.points.iter().enumerate() {
1079 match kept.last() {
1080 None => ts.push(0.0),
1081 Some(&last) => {
1082 let step = p.distance(walked.points[last]);
1083 if step <= tol.confusion() {
1084 continue;
1085 }
1086 ts.push(ts[ts.len() - 1] + step);
1087 }
1088 }
1089 kept.push(i);
1090 }
1091 if kept.len() < 2 {
1092 return None;
1093 }
1094 let trace = |t: f64| silhouette_between(condition, walked, &kept, &ts, t, chord, tol);
1095 let fitted = ogeom_geom::fit::fit_curve_sampled(trace, &ts, false, 3, chord, tol).ok()?;
1096 fitted.met.then_some(fitted.curve)
1097}
1098
1099fn silhouette_between(
1103 condition: &SilhouetteOn<'_>,
1104 walked: &ogeom_intersect::walk::Walked,
1105 kept: &[usize],
1106 ts: &[f64],
1107 t: f64,
1108 chord: f64,
1109 tol: Tolerances,
1110) -> OgeomResult<Point> {
1111 use ogeom_intersect::walk::Condition as _;
1112 let k = ts.partition_point(|&s| s <= t).clamp(1, ts.len() - 1) - 1;
1113 let f = (t - ts[k]) / (ts[k + 1] - ts[k]);
1114 let (i, j) = (kept[k], kept[k + 1]);
1115 let (a, b) = (walked.points[i], walked.points[j]);
1116 if f <= 0.0 {
1117 return Ok(a);
1118 }
1119 if f >= 1.0 {
1120 return Ok(b);
1121 }
1122 let span = b - a;
1123 let length = span.magnitude();
1124 let across = span / length;
1125 let target = a + span * f;
1126 if length <= chord * 0.1 {
1132 return Ok(target);
1133 }
1134 let ((ua, ub), (va, vb)) = condition.surface.domain();
1137 let periods = [
1138 condition.surface.is_periodic_u().then_some(ub - ua),
1139 condition.surface.is_periodic_v().then_some(vb - va),
1140 ];
1141 let mut x = [0.0_f64; 2];
1142 for (axis, period) in periods.iter().enumerate() {
1143 let (from, to) = (walked.states[i][axis], walked.states[j][axis]);
1144 let mut gap = to - from;
1145 if let Some(period) = period {
1146 gap -= period * (gap / period).round();
1147 }
1148 x[axis] = gap.mul_add(f, from);
1149 }
1150 for _ in 0..32 {
1152 condition.clamp(&mut x);
1153 let Some(((residual, jacobian), at, gradient)) = condition.system_at(&x, tol) else {
1154 break;
1155 };
1156 let (g, (gu, gv)) = (residual[0], (jacobian[0][0], jacobian[0][1]));
1157 let (pu, pv) = (gradient[0].dot(across), gradient[1].dot(across));
1158 let h = (target - at).dot(across);
1159 let det = gu.mul_add(pv, -(gv * pu));
1160 if !det.is_normal() {
1161 break;
1162 }
1163 let du = (-g).mul_add(pv, -(gv * h)) / det;
1164 let dv = gu.mul_add(h, pu * g) / det;
1165 x[0] += du;
1166 x[1] += dv;
1167 let moved = (gradient[0] * du + gradient[1] * dv).magnitude();
1168 if moved <= tol.confusion() * 1e-3 {
1169 condition.clamp(&mut x);
1170 let Some(point) = condition.position(&x, tol) else {
1171 break;
1172 };
1173 if point.distance(target) > length {
1176 break;
1177 }
1178 return Ok(point);
1179 }
1180 }
1181 ogeom_bail!(
1182 NotDone,
1183 "the silhouette between two walked points did not solve"
1184 )
1185}
1186
1187fn marched_silhouettes(
1200 surface: &SurfaceGeometry,
1201 along: Vector,
1202 tol: Tolerances,
1203) -> OgeomResult<Vec<Curve>> {
1204 use ogeom_intersect::walk::Condition as _;
1205 let options = ogeom_intersect::Marching {
1206 chord: tol.confusion() * 1e2,
1207 ..ogeom_intersect::Marching::default()
1208 };
1209 let ((ua, ub), (va, vb)) = surface.domain();
1210 let reach = {
1215 let mut bound = ogeom_math::Aabb::EMPTY;
1216 for i in 0..=8 {
1217 for j in 0..=8 {
1218 let u = (ub - ua).mul_add(f64::from(i) / 8.0, ua);
1219 let v = (vb - va).mul_add(f64::from(j) / 8.0, va);
1220 if let Ok(p) = surface.point_at(u, v, tol) {
1221 bound = bound.with_point(p);
1222 }
1223 }
1224 }
1225 bound.diagonal().max(tol.confusion() * 1e3)
1226 };
1227 let condition = SilhouetteOn {
1228 surface,
1229 along,
1230 reach,
1231 };
1232 let value = |u: f64, v: f64| -> Option<f64> {
1233 let (su, sv) = surface.d1_at(u, v, tol).ok()?;
1234 Some(su.cross(sv).dot(along))
1235 };
1236
1237 let mut seeds: Vec<[f64; 2]> = Vec::new();
1240 let steps = options.grid;
1241 #[expect(clippy::cast_precision_loss, reason = "a grid index")]
1242 let at = |i: usize, n: usize, lo: f64, hi: f64| lo + (hi - lo) * (i as f64) / (n as f64);
1243 let grid: Vec<Option<f64>> = (0..=steps)
1245 .flat_map(|i| (0..=steps).map(move |j| (i, j)))
1246 .map(|(i, j)| value(at(i, steps, ua, ub), at(j, steps, va, vb)))
1247 .collect();
1248 for i in 0..=steps {
1249 for j in 0..=steps {
1250 let (u, v) = (at(i, steps, ua, ub), at(j, steps, va, vb));
1251 let Some(here) = grid[i * (steps + 1) + j] else {
1252 continue;
1253 };
1254 for (du, dv) in [(1_usize, 0_usize), (0, 1)] {
1255 if i + du > steps || j + dv > steps {
1256 continue;
1257 }
1258 let (u2, v2) = (at(i + du, steps, ua, ub), at(j + dv, steps, va, vb));
1259 let Some(there) = grid[(i + du) * (steps + 1) + j + dv] else {
1260 continue;
1261 };
1262 if here.signum() == there.signum() || here == 0.0 {
1263 continue;
1264 }
1265 let (mut lo, mut hi) = (0.0_f64, 1.0_f64);
1267 for _ in 0..40 {
1268 let mid = f64::midpoint(lo, hi);
1269 let (um, vm) = (u + (u2 - u) * mid, v + (v2 - v) * mid);
1270 let Some(m) = value(um, vm) else { break };
1271 if m.signum() == here.signum() {
1272 lo = mid;
1273 } else {
1274 hi = mid;
1275 }
1276 }
1277 let mid = f64::midpoint(lo, hi);
1278 seeds.push([u + (u2 - u) * mid, v + (v2 - v) * mid]);
1279 }
1280 }
1281 }
1282
1283 let mut out = Vec::new();
1284 let mut walked_points: Vec<Vec<Point>> = Vec::new();
1285 for seed in seeds {
1286 let mut start = seed;
1287 condition.clamp(&mut start);
1288 let Some(here) = condition.position(&start, tol) else {
1291 continue;
1292 };
1293 if walked_points
1299 .iter()
1300 .any(|line| on_polyline(line, here) <= options.chord * 32.0)
1301 {
1302 continue;
1303 }
1304 let Ok(walked) = ogeom_intersect::walk::follow(&condition, &start, options, tol) else {
1305 continue;
1306 };
1307 if walked.points.len() < 4 {
1308 continue;
1309 }
1310 let Some(curve) = fitted_silhouette(&condition, &walked, options.chord, tol) else {
1313 continue;
1314 };
1315 walked_points.push(walked.points);
1316 out.push(Curve::BSpline(curve));
1317 }
1318 Ok(out)
1319}
1320
1321#[cfg(test)]
1322#[allow(clippy::unwrap_used, clippy::expect_used, reason = "test code")]
1323mod tests {
1324 use super::*;
1325
1326 const T: Tolerances = Tolerances::millimetres();
1327
1328 fn turned(surface: &SurfaceGeometry, along: Vector, x: [f64; 2]) -> Option<f64> {
1330 Some(surface.normal_at(x[0], x[1], T).ok()?.vector().dot(along))
1331 }
1332
1333 fn off_silhouette(
1338 surface: &SurfaceGeometry,
1339 along: Vector,
1340 p: Point,
1341 x: &mut [f64; 2],
1342 ) -> Option<f64> {
1343 for _ in 0..50 {
1344 let g = turned(surface, along, *x)?;
1345 let (gu, gv) = (slope(surface, along, *x, 0)?, slope(surface, along, *x, 1)?);
1346 let (su, sv) = surface.d1_at(x[0], x[1], T).ok()?;
1347 let at = surface.point_at(x[0], x[1], T).ok()?;
1348 let tangent = su * -gv + sv * gu;
1350 let tangent = tangent / tangent.magnitude();
1351 let (pu, pv, r) = (su.dot(tangent), sv.dot(tangent), (p - at).dot(tangent));
1352 let det = gu * pv - gv * pu;
1353 let du = (-g * pv - gv * r) / det;
1354 let dv = (gu * r + pu * g) / det;
1355 x[0] += du;
1356 x[1] += dv;
1357 hold(surface, x);
1358 if du.abs().max(dv.abs()) < 1e-13 {
1359 break;
1360 }
1361 }
1362 if turned(surface, along, *x)?.abs() <= 1e-9 {
1363 return Some(p.distance(surface.point_at(x[0], x[1], T).ok()?));
1364 }
1365 let mut nearest: Option<f64> = None;
1369 for axis in 0..2 {
1370 let mut y = *x;
1371 for _ in 0..50 {
1372 let g = turned(surface, along, y)?;
1373 y[axis] -= g / slope(surface, along, y, axis)?;
1374 hold(surface, &mut y);
1375 }
1376 if turned(surface, along, y)?.abs() <= 1e-9 {
1377 let miss = p.distance(surface.point_at(y[0], y[1], T).ok()?);
1378 nearest = Some(nearest.map_or(miss, |n| n.min(miss)));
1379 }
1380 }
1381 nearest
1382 }
1383
1384 fn slope(surface: &SurfaceGeometry, along: Vector, x: [f64; 2], axis: usize) -> Option<f64> {
1387 let (mut ahead, mut behind) = (x, x);
1388 ahead[axis] += 1e-6;
1389 behind[axis] -= 1e-6;
1390 hold(surface, &mut ahead);
1391 hold(surface, &mut behind);
1392 let run = ahead[axis] - behind[axis];
1393 Some((turned(surface, along, ahead)? - turned(surface, along, behind)?) / run)
1394 }
1395
1396 fn hold(surface: &SurfaceGeometry, x: &mut [f64; 2]) {
1399 let ((ua, ub), (va, vb)) = surface.domain();
1400 if !surface.is_periodic_u() {
1401 x[0] = x[0].clamp(ua, ub);
1402 }
1403 if !surface.is_periodic_v() {
1404 x[1] = x[1].clamp(va, vb);
1405 }
1406 }
1407
1408 fn widest_miss(surface: &SurfaceGeometry, along: Vector) -> (usize, f64) {
1412 let along = along.normalized(T).unwrap();
1413 let curves = marched_silhouettes(surface, along, T).unwrap();
1414 let mut widest = 0.0_f64;
1415 for curve in &curves {
1416 let Curve::BSpline(spline) = curve else {
1417 panic!("a marched silhouette is a spline");
1418 };
1419 let (lo, hi) = spline.domain();
1420 let n = (spline.knots().distinct().len() * 32).max(1024);
1421 let start = curve.point_at(lo, T).unwrap();
1422 let foot = ogeom_geom::project_on_surface(surface, start, 16, T).unwrap();
1423 let mut x = [foot.parameters.0, foot.parameters.1];
1424 for i in 0..=n {
1425 #[expect(clippy::cast_precision_loss, reason = "a sample index")]
1426 let t = (hi - lo).mul_add(i as f64 / n as f64, lo);
1427 let p = curve.point_at(t, T).unwrap();
1428 let miss = off_silhouette(surface, along, p, &mut x).unwrap_or_else(|| {
1429 panic!("no silhouette found beside {p:?} at {t} of {lo}..{hi}, from {x:?}")
1430 });
1431 widest = widest.max(miss);
1432 }
1433 }
1434 (curves.len(), widest)
1435 }
1436
1437 fn spline(f: impl Fn(f64, f64) -> Point, n: u32) -> SurfaceGeometry {
1439 let rows: Vec<Vec<Point>> = (0..n)
1440 .map(|j| {
1441 (0..n)
1442 .map(|i| {
1443 f(
1444 f64::from(i) / f64::from(n - 1),
1445 f64::from(j) / f64::from(n - 1),
1446 )
1447 })
1448 .collect()
1449 })
1450 .collect();
1451 SurfaceGeometry::BSpline(
1452 ogeom_geom::fit::fit_surface_grid(&rows, 3, 1e-6, T)
1453 .unwrap()
1454 .curve,
1455 )
1456 }
1457
1458 #[test]
1465 fn a_marched_silhouette_keeps_to_its_chord_between_its_points() {
1466 let chord = T.confusion() * 1e2;
1467 let bumps = spline(
1468 |x, y| {
1469 let (x, y) = (10.0f64.mul_add(x, -5.0), 10.0f64.mul_add(y, -5.0));
1470 Point::new(x, y, (x * 1.3).sin() * (y * 1.1).cos() * 2.0)
1471 },
1472 25,
1473 );
1474 let fat = SurfaceGeometry::Torus(ogeom_geom::TorusSurface::new(
1475 ogeom_math::Torus::new(Frame::WORLD, 3.0, 2.9, T).unwrap(),
1476 ));
1477 let cone = spline(
1478 |x, y| {
1479 let around = x * std::f64::consts::TAU;
1480 let r = 2.0f64.mul_add(y, 0.2);
1481 Point::new(r * around.cos(), r * around.sin(), 3.0 * y)
1482 },
1483 33,
1484 );
1485 for (what, surface, along, branches) in [
1486 ("a bumpy sheet", &bumps, Vector::new(0.3, 0.5, 1.0), None),
1487 ("a bumpy sheet", &bumps, Vector::X, None),
1488 ("a fat torus", &fat, Vector::new(1.0, 1.0, 1.0), Some(2)),
1489 ("a spline cone", &cone, Vector::new(1.0, 0.0, 0.0), Some(2)),
1490 ] {
1491 let (count, widest) = widest_miss(surface, along);
1492 assert!(count > 0, "{what} seen along {along:?} has an outline");
1493 if let Some(branches) = branches {
1494 assert_eq!(count, branches, "{what} seen along {along:?}");
1495 }
1496 assert!(
1497 widest <= chord,
1498 "{what} seen along {along:?} is drawn {widest:e} off its silhouette"
1499 );
1500 }
1501 }
1502}