1use ogeom_core::{OgeomResult, Tolerances, ogeom_bail};
22use ogeom_geom::{CircleCurve, Curve, Curve3d as _, LineCurve, Surface as _, SurfaceGeometry};
23use ogeom_math::{Axis, Circle, Direction, Frame, Point, Point2, Vector};
24use ogeom_mesh::Deflection;
25use ogeom_topo::{Model, NodeData, Shape, ShapeType, explore_unique};
26
27use crate::project::{Drawing, DrawnCurve, Source, View, Visibility};
28
29#[derive(Debug, Clone)]
31pub struct Silhouette {
32 pub face: Shape,
34 pub curve: Curve,
36 pub range: (f64, f64),
38}
39
40pub fn silhouettes(
59 model: &Model,
60 shape: &Shape,
61 direction: Vector,
62 tol: Tolerances,
63) -> OgeomResult<Vec<Silhouette>> {
64 let magnitude = direction.magnitude();
65 if magnitude <= tol.confusion() {
66 ogeom_bail!(Construction, "a silhouette needs a direction to look along");
67 }
68 let along = direction / magnitude;
69 let deflection = Deflection::default();
70
71 let mut out = Vec::new();
72 for face in explore_unique(model, shape, ShapeType::Face)? {
73 let Some(NodeData::Face(data)) = model.node(&face).map(|n| n.data().clone()) else {
74 continue;
75 };
76 let Some(surface) = model.geometry().surface(data.surface).cloned() else {
77 continue;
78 };
79 let placement = face.transform(model.datums())?;
80 let world = ogeom_geom::Transformable::transformed(&surface, &placement, tol)?;
81 let candidates = match &world {
82 SurfaceGeometry::Plane(_) => Vec::new(),
83 SurfaceGeometry::Sphere(s) => {
84 let sphere = s.sphere();
88 let axis = Direction::new(along, tol)?;
89 let frame = Frame::new(sphere.centre(), axis, perpendicular(along, tol)?, tol)?;
90 vec![Curve::Circle(CircleCurve::new(Circle::new(
91 frame,
92 sphere.radius(),
93 tol,
94 )?))]
95 }
96 SurfaceGeometry::Cylinder(c) => {
97 let cylinder = c.cylinder();
101 let axis = cylinder.frame().z().vector();
102 let sideways = axis.cross(along);
103 let m = sideways.magnitude();
104 if m <= tol.angular() {
105 Vec::new()
108 } else {
109 let sideways = sideways / m;
110 [1.0, -1.0]
111 .iter()
112 .map(|sign| {
113 let at =
114 cylinder.frame().origin() + sideways * (cylinder.radius() * sign);
115 Curve::Line(LineCurve::new(Axis::new(at, cylinder.frame().z())))
116 })
117 .collect()
118 }
119 }
120 SurfaceGeometry::Cone(c) => {
121 let cone = c.cone();
128 let frame = cone.frame();
129 let (x, y, z) = (frame.x().vector(), frame.y().vector(), frame.z().vector());
130 let (sin, cos) = cone.half_angle().sin_cos();
131 let (a, b) = (cos * along.dot(x), cos * along.dot(y));
133 let c0 = -sin * along.dot(z);
134 let r = a.hypot(b);
135 if r <= tol.angular() || c0.abs() > r {
136 Vec::new()
137 } else {
138 let phase = b.atan2(a);
139 let spread = (-c0 / r).acos();
140 [phase + spread, phase - spread]
141 .iter()
142 .map(|u| {
143 let radial = x * u.cos() + y * u.sin();
144 let apex = cone.apex();
145 let direction =
146 radial * cone.half_angle().sin() + z * cone.half_angle().cos();
147 Direction::new(direction, tol)
148 .map(|d| Curve::Line(LineCurve::new(Axis::new(apex, d))))
149 })
150 .collect::<OgeomResult<Vec<Curve>>>()?
151 }
152 }
153 other => marched_silhouettes(other, along, tol)?,
157 };
158
159 for curve in candidates {
160 for range in within_trim(model, &face, &world, &curve, deflection, tol)? {
161 out.push(Silhouette {
162 face: face.clone(),
163 curve: curve.clone(),
164 range,
165 });
166 }
167 }
168 }
169 Ok(out)
170}
171
172pub fn reflect_lines(
184 model: &Model,
185 shape: &Shape,
186 light: Vector,
187 tol: Tolerances,
188) -> OgeomResult<Vec<Silhouette>> {
189 silhouettes(model, shape, light, tol)
190}
191
192pub fn iso_curves(
204 model: &Model,
205 face: &Shape,
206 u_count: usize,
207 v_count: usize,
208 tol: Tolerances,
209) -> OgeomResult<Vec<Vec<Point>>> {
210 let Some(NodeData::Face(data)) = model.node(face).map(|n| n.data().clone()) else {
211 ogeom_bail!(Construction, "expected a face");
212 };
213 let Some(surface) = model.geometry().surface(data.surface).cloned() else {
214 ogeom_bail!(Dangling, "face refers to a surface not in this model");
215 };
216 let placement = face.transform(model.datums())?;
217 let world = ogeom_geom::Transformable::transformed(&surface, &placement, tol)?;
218 let ((u0, u1), (v0, v1)) = world.domain();
219 let rings = ogeom_mesh::face_boundary(model, face, Deflection::default(), tol)?;
220
221 const ALONG: usize = 64;
222 let mut out = Vec::new();
223 for (count, constant_u) in [(u_count, true), (v_count, false)] {
224 for i in 1..=count {
225 #[expect(
226 clippy::cast_precision_loss,
227 reason = "a curve index, far below the mantissa"
228 )]
229 let f = i as f64 / (count + 1) as f64;
230 let mut run: Vec<Point> = Vec::new();
231 for k in 0..=ALONG {
232 #[expect(
233 clippy::cast_precision_loss,
234 reason = "a station index, far below the mantissa"
235 )]
236 let g = k as f64 / ALONG as f64;
237 let (u, v) = if constant_u {
238 ((u1 - u0).mul_add(f, u0), (v1 - v0).mul_add(g, v0))
239 } else {
240 ((u1 - u0).mul_add(g, u0), (v1 - v0).mul_add(f, v0))
241 };
242 if inside_rings(&rings, Point2::new(u, v)) {
243 run.push(world.point_at(u, v, tol)?);
244 } else if run.len() >= 2 {
245 out.push(std::mem::take(&mut run));
246 } else {
247 run.clear();
248 }
249 }
250 if run.len() >= 2 {
251 out.push(run);
252 }
253 }
254 }
255 Ok(out)
256}
257
258pub fn project_exact(
272 model: &Model,
273 shape: &Shape,
274 view: &View,
275 deflection: Deflection,
276 tol: Tolerances,
277) -> OgeomResult<Drawing> {
278 let faces = blockers(model, shape, tol)?;
279 if faces.is_empty() {
280 ogeom_bail!(Construction, "a shape with no faces draws nothing");
281 }
282 let mut drawing = Drawing::default();
283
284 for edge in explore_unique(model, shape, ShapeType::Edge)? {
285 let Ok(points) = ogeom_mesh::polyline_of_edge(model, &edge, deflection, tol) else {
286 continue;
287 };
288 classify(
289 &mut drawing,
290 &points,
291 Source::Edge(edge.clone()),
292 view,
293 &faces,
294 tol,
295 )?;
296 }
297
298 for silhouette in silhouettes(model, shape, view.toward_eye(), tol)? {
299 let points = sampled(&silhouette.curve, silhouette.range, deflection, tol)?;
300 classify(&mut drawing, &points, Source::Silhouette, view, &faces, tol)?;
301 }
302 Ok(drawing)
303}
304
305struct Blocker {
308 surface: SurfaceGeometry,
309 rings: Vec<Vec<Point2>>,
310}
311
312fn blockers(model: &Model, shape: &Shape, tol: Tolerances) -> OgeomResult<Vec<Blocker>> {
313 let mut out = Vec::new();
314 for face in explore_unique(model, shape, ShapeType::Face)? {
315 let Some(NodeData::Face(data)) = model.node(&face).map(|n| n.data().clone()) else {
316 continue;
317 };
318 let Some(surface) = model.geometry().surface(data.surface).cloned() else {
319 continue;
320 };
321 let placement = face.transform(model.datums())?;
322 out.push(Blocker {
323 surface: ogeom_geom::Transformable::transformed(&surface, &placement, tol)?,
324 rings: ogeom_mesh::face_boundary(model, &face, Deflection::default(), tol)?,
325 });
326 }
327 Ok(out)
328}
329
330fn classify(
332 drawing: &mut Drawing,
333 points: &[Point],
334 source: Source,
335 view: &View,
336 faces: &[Blocker],
337 tol: Tolerances,
338) -> OgeomResult<()> {
339 let mut run: Vec<Point2> = Vec::new();
340 let mut held: Option<Visibility> = None;
341 let mut flush = |run: &mut Vec<Point2>, visibility: Option<Visibility>| {
342 if run.len() < 2 {
343 run.clear();
344 return;
345 }
346 let curve = DrawnCurve {
347 points: std::mem::take(run),
348 visibility: visibility.unwrap_or(Visibility::Visible),
349 source: source.clone(),
350 };
351 if visibility == Some(Visibility::Hidden) {
352 drawing.hidden.push(curve);
353 } else {
354 drawing.visible.push(curve);
355 }
356 };
357 for point in points {
358 let visibility = if occluded(*point, view, faces, tol)? {
359 Visibility::Hidden
360 } else {
361 Visibility::Visible
362 };
363 if held.is_some_and(|was| was != visibility) {
364 let last = run.last().copied();
367 flush(&mut run, held);
368 if let Some(last) = last {
369 run.push(last);
370 }
371 }
372 held = Some(visibility);
373 run.push(view.project(*point));
374 }
375 flush(&mut run, held);
376 Ok(())
377}
378
379fn occluded(at: Point, view: &View, faces: &[Blocker], tol: Tolerances) -> OgeomResult<bool> {
381 let toward = view.toward_eye();
382 let magnitude = toward.magnitude();
383 if magnitude <= tol.confusion() {
384 return Ok(false);
385 }
386 let direction = toward / magnitude;
387 let reach = 1e6;
390 let clearance = tol.confusion() * 1e3;
391 let ray = Curve::Line(LineCurve::new(Axis::new(
392 at,
393 Direction::new(direction, tol)?,
394 )));
395 let options = ogeom_intersect::CurveSurfaceOptions::default();
396 for face in faces {
397 let found = ogeom_intersect::intersect_curve_surface(&ray, &face.surface, options, tol)?;
398 for piercing in &found.crossings {
399 if piercing.on_curve <= clearance || piercing.on_curve >= reach {
400 continue;
401 }
402 let (u, v) = piercing.on_surface;
403 if inside_rings(&face.rings, Point2::new(u, v)) {
404 return Ok(true);
405 }
406 }
407 }
408 Ok(false)
409}
410
411fn within_trim(
413 model: &Model,
414 face: &Shape,
415 surface: &SurfaceGeometry,
416 curve: &Curve,
417 deflection: Deflection,
418 tol: Tolerances,
419) -> OgeomResult<Vec<(f64, f64)>> {
420 let rings = ogeom_mesh::face_boundary(model, face, deflection, tol)?;
421 let (t0, t1) = curve.domain();
422 let (t0, t1) = if t0.is_finite() && t1.is_finite() && t1 - t0 < 1e6 {
426 (t0, t1)
427 } else {
428 let mut bound = ogeom_math::Aabb::EMPTY;
429 for vertex in explore_unique(model, face, ShapeType::Vertex)? {
430 if let Some(data) = model.node(&vertex).and_then(|n| n.data().as_vertex()) {
431 bound = bound.with_point(vertex.transform(model.datums())?.apply(data.point));
432 }
433 }
434 let reach = bound.diagonal().max(1.0);
435 let centre = bound.centre().unwrap_or(Point::ORIGIN);
436 let at = ogeom_algo::project_on_curve(curve, centre, 64, tol)?.parameter;
437 (at - reach, at + reach)
438 };
439
440 const STATIONS: usize = 96;
441 let held_at = |t: f64| -> OgeomResult<bool> {
442 let Ok(point) = curve.point_at(t, tol) else {
443 return Ok(false);
444 };
445 let projection = ogeom_algo::project_on_surface(surface, point, 24, tol)?;
446 let (u, v) = projection.parameters;
447 Ok(projection.distance <= tol.confusion() && inside_rings(&rings, Point2::new(u, v)))
452 };
453 let edge_between = |inside: f64, outside: f64| -> OgeomResult<f64> {
458 let (mut lo, mut hi) = (inside, outside);
459 for _ in 0..40 {
460 let mid = f64::midpoint(lo, hi);
461 if held_at(mid)? {
462 lo = mid;
463 } else {
464 hi = mid;
465 }
466 }
467 Ok(lo)
468 };
469
470 let mut out = Vec::new();
471 let mut open: Option<f64> = None;
472 let mut previous: Option<(f64, bool)> = None;
473 for k in 0..=STATIONS {
474 #[expect(
475 clippy::cast_precision_loss,
476 reason = "a station index, far below the mantissa"
477 )]
478 let t = (t1 - t0).mul_add(k as f64 / STATIONS as f64, t0);
479 let held = held_at(t)?;
480 match (held, open) {
481 (true, None) => {
482 open = Some(match previous {
483 Some((was, false)) => edge_between(t, was)?,
484 _ => t,
485 });
486 }
487 (false, Some(from)) => {
488 let to = match previous {
489 Some((was, true)) => edge_between(was, t)?,
490 _ => t,
491 };
492 if to - from > tol.parametric() {
493 out.push((from, to));
494 }
495 open = None;
496 }
497 _ => {}
498 }
499 previous = Some((t, held));
500 }
501 if let Some(from) = open
502 && t1 - from > tol.parametric()
503 {
504 out.push((from, t1));
505 }
506 Ok(out)
507}
508
509fn sampled(
511 curve: &Curve,
512 range: (f64, f64),
513 deflection: Deflection,
514 tol: Tolerances,
515) -> OgeomResult<Vec<Point>> {
516 let span = curve
519 .point_at(range.0, tol)?
520 .distance(curve.point_at(range.1, tol)?);
521 #[expect(
522 clippy::cast_possible_truncation,
523 clippy::cast_sign_loss,
524 reason = "a step count, clamped into range"
525 )]
526 let steps =
527 ((span / deflection.chord.max(tol.confusion())).sqrt().ceil() as usize).clamp(8, 512);
528 let mut out = Vec::with_capacity(steps + 1);
529 for k in 0..=steps {
530 #[expect(
531 clippy::cast_precision_loss,
532 reason = "a station index, far below the mantissa"
533 )]
534 let t = (range.1 - range.0).mul_add(k as f64 / steps as f64, range.0);
535 out.push(curve.point_at(t, tol)?);
536 }
537 Ok(out)
538}
539
540fn inside_rings(rings: &[Vec<Point2>], p: Point2) -> bool {
542 let mut inside = false;
543 for ring in rings {
544 for i in 0..ring.len() {
545 let (a, b) = (ring[i], ring[(i + 1) % ring.len()]);
546 if (a.y > p.y) != (b.y > p.y) {
547 let x = (b.x - a.x).mul_add((p.y - a.y) / (b.y - a.y), a.x);
548 if x > p.x {
549 inside = !inside;
550 }
551 }
552 }
553 }
554 inside
555}
556
557fn perpendicular(v: Vector, tol: Tolerances) -> OgeomResult<Direction> {
559 let seed = if v.x.abs() < 0.9 {
560 Vector::X
561 } else {
562 Vector::Y
563 };
564 Direction::new(v.cross(seed), tol)
565}
566
567struct SilhouetteOn<'s> {
589 surface: &'s SurfaceGeometry,
590 along: Vector,
591 reach: f64,
593}
594
595impl ogeom_intersect::walk::Condition for SilhouetteOn<'_> {
596 fn unknowns(&self) -> usize {
597 2
598 }
599
600 fn position(&self, x: &[f64], tol: Tolerances) -> Option<Point> {
601 self.surface.point_at(x[0], x[1], tol).ok()
602 }
603
604 fn position_gradient(&self, x: &[f64], tol: Tolerances) -> Option<Vec<Vector>> {
605 let (du, dv) = self.surface.d1_at(x[0], x[1], tol).ok()?;
606 Some(vec![du, dv])
607 }
608
609 fn system(&self, x: &[f64], tol: Tolerances) -> Option<(Vec<f64>, Vec<Vec<f64>>)> {
610 let (su, sv) = self.surface.d1_at(x[0], x[1], tol).ok()?;
611 let (suu, suv, svv) = self.surface.d2_at(x[0], x[1], tol).ok()?;
612 let cross = su.cross(sv);
613 let length = cross.magnitude();
614 if length <= tol.confusion() {
615 return None;
616 }
617 let normal = cross / length;
618 let across = |d: Vector| (d - normal * d.dot(normal)) / length;
622 let du = across(suu.cross(sv) + su.cross(suv));
623 let dv = across(suv.cross(sv) + su.cross(svv));
624 Some((
625 vec![normal.dot(self.along)],
626 vec![vec![du.dot(self.along), dv.dot(self.along)]],
627 ))
628 }
629
630 fn clamp(&self, x: &mut [f64]) {
631 let ((ua, ub), (va, vb)) = self.surface.domain();
632 let hold = |value: f64, lo: f64, hi: f64, periodic: bool| {
633 if periodic && hi > lo {
634 lo + (value - lo).rem_euclid(hi - lo)
635 } else {
636 value.clamp(lo, hi)
637 }
638 };
639 x[0] = hold(x[0], ua, ub, self.surface.is_periodic_u());
640 x[1] = hold(x[1], va, vb, self.surface.is_periodic_v());
641 }
642
643 fn outside(&self, x: &[f64], tol: Tolerances) -> bool {
644 let ((ua, ub), (va, vb)) = self.surface.domain();
645 let band = tol.parametric();
646 (!self.surface.is_periodic_u() && (x[0] < ua - band || x[0] > ub + band))
647 || (!self.surface.is_periodic_v() && (x[1] < va - band || x[1] > vb + band))
648 }
649
650 fn near_edge(&self, x: &[f64]) -> bool {
651 let ((ua, ub), (va, vb)) = self.surface.domain();
652 let near = |value: f64, lo: f64, hi: f64| {
653 let reach = (hi - lo) * 1e-6;
654 value <= lo + reach || value >= hi - reach
655 };
656 (!self.surface.is_periodic_u() && near(x[0], ua, ub))
657 || (!self.surface.is_periodic_v() && near(x[1], va, vb))
658 }
659
660 fn extent(&self) -> f64 {
661 self.reach
662 }
663}
664
665fn on_polyline(line: &[Point], p: Point) -> f64 {
667 let mut best = f64::INFINITY;
668 for pair in line.windows(2) {
669 let (a, b) = (pair[0], pair[1]);
670 let d = b - a;
671 let len2 = d.dot(d);
672 let t = if len2 > 0.0 {
673 ((p - a).dot(d) / len2).clamp(0.0, 1.0)
674 } else {
675 0.0
676 };
677 best = best.min(p.distance(a + d * t));
678 }
679 best
680}
681
682fn marched_silhouettes(
694 surface: &SurfaceGeometry,
695 along: Vector,
696 tol: Tolerances,
697) -> OgeomResult<Vec<Curve>> {
698 use ogeom_intersect::walk::Condition as _;
699 let options = ogeom_intersect::Marching {
700 chord: tol.confusion() * 1e2,
701 ..ogeom_intersect::Marching::default()
702 };
703 let ((ua, ub), (va, vb)) = surface.domain();
704 let reach = {
709 let mut bound = ogeom_math::Aabb::EMPTY;
710 for i in 0..=8 {
711 for j in 0..=8 {
712 let u = (ub - ua).mul_add(f64::from(i) / 8.0, ua);
713 let v = (vb - va).mul_add(f64::from(j) / 8.0, va);
714 if let Ok(p) = surface.point_at(u, v, tol) {
715 bound = bound.with_point(p);
716 }
717 }
718 }
719 bound.diagonal().max(tol.confusion() * 1e3)
720 };
721 let condition = SilhouetteOn {
722 surface,
723 along,
724 reach,
725 };
726 let value = |u: f64, v: f64| -> Option<f64> {
727 let (su, sv) = surface.d1_at(u, v, tol).ok()?;
728 Some(su.cross(sv).dot(along))
729 };
730
731 let mut seeds: Vec<[f64; 2]> = Vec::new();
734 let steps = options.grid;
735 #[expect(clippy::cast_precision_loss, reason = "a grid index")]
736 let at = |i: usize, n: usize, lo: f64, hi: f64| lo + (hi - lo) * (i as f64) / (n as f64);
737 for i in 0..=steps {
738 for j in 0..=steps {
739 let (u, v) = (at(i, steps, ua, ub), at(j, steps, va, vb));
740 let Some(here) = value(u, v) else { continue };
741 for (du, dv) in [(1_usize, 0_usize), (0, 1)] {
742 if i + du > steps || j + dv > steps {
743 continue;
744 }
745 let (u2, v2) = (at(i + du, steps, ua, ub), at(j + dv, steps, va, vb));
746 let Some(there) = value(u2, v2) else { continue };
747 if here.signum() == there.signum() || here == 0.0 {
748 continue;
749 }
750 let (mut lo, mut hi) = (0.0_f64, 1.0_f64);
752 for _ in 0..40 {
753 let mid = f64::midpoint(lo, hi);
754 let (um, vm) = (u + (u2 - u) * mid, v + (v2 - v) * mid);
755 let Some(m) = value(um, vm) else { break };
756 if m.signum() == here.signum() {
757 lo = mid;
758 } else {
759 hi = mid;
760 }
761 }
762 let mid = f64::midpoint(lo, hi);
763 seeds.push([u + (u2 - u) * mid, v + (v2 - v) * mid]);
764 }
765 }
766 }
767
768 let mut out = Vec::new();
769 let mut walked_points: Vec<Vec<Point>> = Vec::new();
770 for seed in seeds {
771 let mut start = seed;
772 condition.clamp(&mut start);
773 let Some(here) = condition.position(&start, tol) else {
776 continue;
777 };
778 if walked_points
784 .iter()
785 .any(|line| on_polyline(line, here) <= options.chord * 32.0)
786 {
787 continue;
788 }
789 let Ok(walked) = ogeom_intersect::walk::follow(&condition, &start, options, tol) else {
790 continue;
791 };
792 if walked.points.len() < 4 {
793 continue;
794 }
795 let Ok(fitted) = ogeom_geom::fit::fit_points(&walked.points, 3, options.chord, tol) else {
798 continue;
799 };
800 walked_points.push(walked.points);
801 out.push(Curve::BSpline(fitted.curve));
802 }
803 Ok(out)
804}