1use ogeom_algo::Built;
23use ogeom_core::{OgeomResult, Tolerances, ogeom_bail};
24use ogeom_geom::Curve3d as _;
25use ogeom_geom::Surface as _;
26use ogeom_geom::{
27 CircleCurve, ConeSurface, Curve, CylinderSurface, LineCurve, PlaneSurface, SphereSurface,
28 SurfaceGeometry,
29};
30use ogeom_math::Circle;
31use ogeom_math::{Cone, Cylinder, Direction, Frame, Plane, Point, Sphere, Vector};
32use ogeom_topo::{Filter, Model, NodeData, Shape, ShapeType, explore};
33
34#[derive(Debug, Clone, PartialEq)]
36pub enum Simplified {
37 Plane {
39 worst: f64,
41 },
42 Cylinder {
44 radius: f64,
46 worst: f64,
48 },
49 Cone {
51 half_angle: f64,
53 worst: f64,
55 },
56 Sphere {
58 radius: f64,
60 worst: f64,
62 },
63}
64
65#[derive(Debug, Default)]
67pub struct CanonicalReport {
68 pub simplified: Vec<Simplified>,
70 pub untouched: usize,
73}
74
75pub fn canonical_simplify(
90 model: &mut Model,
91 shape: &Shape,
92 tolerance: f64,
93 tol: Tolerances,
94) -> OgeomResult<(Built, CanonicalReport)> {
95 if !tolerance.is_finite() || tolerance <= 0.0 {
96 ogeom_bail!(Construction, "a tolerance of {tolerance} is not a distance");
97 }
98 let mut report = CanonicalReport::default();
99 let mut history = ogeom_algo::History::new();
100 let mut replaced: Vec<(Shape, Shape)> = Vec::new();
101 let mut edge_map: ogeom_core::FastMap<ogeom_topo::TShapeId, Shape> =
105 ogeom_core::FastMap::default();
106
107 for face in explore(model, shape, Filter::OfType(ShapeType::Face))? {
108 let Some(data) = model.node(&face).and_then(|n| match n.data() {
109 NodeData::Face(d) => Some(d.clone()),
110 _ => None,
111 }) else {
112 continue;
113 };
114 let Some(surface) = model.geometry().surface(data.surface).cloned() else {
115 continue;
116 };
117 if !matches!(surface, SurfaceGeometry::BSpline(_)) {
120 report.untouched += 1;
121 continue;
122 }
123 let placement = face.transform(model.datums())?;
124 let world = {
125 use ogeom_geom::Transformable as _;
126 surface.transformed(&placement, tol)?
127 };
128 let Some(found) = recognize_surface(&world, tolerance, tol)? else {
129 report.untouched += 1;
130 continue;
131 };
132 let carrier: SurfaceGeometry = match &found {
133 Simplified::Plane { .. } => {
134 let plane = fit_plane(&world, tol)?;
135 PlaneSurface::over(plane, (-1e5, 1e5), (-1e5, 1e5))?.into()
136 }
137 Simplified::Cylinder { .. } => {
138 let cylinder = fit_cylinder(&world, tol)?;
139 CylinderSurface::new(cylinder, (-1e5, 1e5))?.into()
140 }
141 Simplified::Cone { .. } => {
142 let cone = fit_cone(&world, tol)?;
143 ConeSurface::new(cone, (-1e5, 1e5))?.into()
144 }
145 Simplified::Sphere { .. } => {
146 let sphere = fit_sphere(&world, tol)?;
147 SphereSurface::new(sphere).into()
148 }
149 };
150 let flipped = ogeom_algo::normals_oppose(&world, &carrier, tol)?;
165 let mut wires = Vec::new();
166 for wire in model.ordered_children_of(&face.oriented(ogeom_topo::Orientation::Forward))? {
167 let mut edges = Vec::new();
168 for edge in model.ordered_children_of(&wire)? {
169 let stored = if edge.orientation() == ogeom_topo::Orientation::Reversed {
172 edge.reversed()
173 } else {
174 edge.clone()
175 };
176 let mapped = simplified_edge(model, &stored, tolerance, &mut edge_map, tol)?;
177 edges.push(if edge.orientation() == ogeom_topo::Orientation::Reversed {
178 mapped.reversed()
179 } else {
180 mapped
181 });
182 }
183 if flipped {
184 edges = edges.iter().rev().map(Shape::reversed).collect();
185 }
186 wires.push(edges);
187 }
188 let rebuilt = ogeom_algo::make_face_with_pcurves(model, carrier, &wires, tol)?.shape;
189 let reversed = face.orientation() == ogeom_topo::Orientation::Reversed;
190 let rebuilt = if reversed == flipped {
191 rebuilt
192 } else {
193 rebuilt.reversed()
194 };
195 let worst = match found {
199 Simplified::Plane { worst }
200 | Simplified::Cylinder { worst, .. }
201 | Simplified::Cone { worst, .. }
202 | Simplified::Sphere { worst, .. } => worst,
203 };
204 if worst > tol.confusion() {
205 model.widen(
206 &rebuilt,
207 ogeom_core::Tolerance::new(worst + tol.confusion())?,
208 )?;
209 }
210 let edges = ogeom_topo::explore_unique(model, &rebuilt, ShapeType::Edge)?;
211 ogeom_algo::state_pcurve_gaps_of(model, &edges, tol)?;
212 history.modify(&face, rebuilt.clone());
213 replaced.push((face, rebuilt));
214 report.simplified.push(found);
215 }
216
217 if replaced.is_empty() {
218 history.generate(shape, shape.clone());
219 return Ok((Built::new(shape.clone(), history), report));
220 }
221
222 let mut faces = Vec::new();
225 for face in explore(model, shape, Filter::OfType(ShapeType::Face))? {
226 match replaced.iter().find(|(old, _)| old.node() == face.node()) {
227 Some((_, new)) => faces.push(new.clone()),
228 None => faces.push(face),
229 }
230 }
231 let sewn = ogeom_algo::sew(model, &faces, tol)?;
232 let out = match sewn.shells.as_slice() {
233 [shell] if ogeom_algo::is_shell_closed(model, shell)? => {
234 ogeom_algo::make_solid(model, core::slice::from_ref(shell))?.shape
235 }
236 [shell] => shell.clone(),
237 _ => ogeom_algo::make_compound(model, &sewn.shells)?.shape,
238 };
239 history.modify(shape, out.clone());
240 Ok((Built::new(out, history), report))
241}
242
243fn samples(surface: &SurfaceGeometry, tol: Tolerances) -> OgeomResult<(Vec<Point>, Vec<Vector>)> {
245 const N: usize = 9;
246 let ((u0, u1), (v0, v1)) = surface.domain();
247 let mut points = Vec::with_capacity(N * N);
248 let mut normals = Vec::with_capacity(N * N);
249 for i in 0..N {
250 for j in 0..N {
251 #[allow(clippy::cast_precision_loss, reason = "a sample index")]
254 let fu = (i as f64 + 0.5) / N as f64;
255 #[allow(clippy::cast_precision_loss, reason = "a sample index")]
256 let fv = (j as f64 + 0.5) / N as f64;
257 let u = u0 + (u1 - u0) * fu;
258 let v = v0 + (v1 - v0) * fv;
259 points.push(surface.point_at(u, v, tol)?);
260 normals.push(surface.normal_at(u, v, tol)?.vector());
261 }
262 }
263 Ok((points, normals))
264}
265
266pub fn recognize_surface(
272 surface: &SurfaceGeometry,
273 tolerance: f64,
274 tol: Tolerances,
275) -> OgeomResult<Option<Simplified>> {
276 let (mut points, normals) = samples(surface, tol)?;
280 points.extend(verification_grid(surface, tol)?);
281 if let Ok(plane) = fit_plane(surface, tol) {
286 let worst = worst_of(&points, |p| plane.signed_distance_to(p).abs());
287 if worst <= tolerance {
288 return Ok(Some(Simplified::Plane { worst }));
289 }
290 }
291 if let Ok(sphere) = fit_sphere(surface, tol) {
292 let worst = worst_of(&points, |p| {
293 (p.distance(sphere.centre()) - sphere.radius()).abs()
294 });
295 if worst <= tolerance {
296 return Ok(Some(Simplified::Sphere {
297 radius: sphere.radius(),
298 worst,
299 }));
300 }
301 }
302 if let Ok(cylinder) = fit_cylinder(surface, tol) {
303 let worst = worst_of(&points, |p| cylinder.distance_to(p).abs());
304 if worst <= tolerance {
305 return Ok(Some(Simplified::Cylinder {
306 radius: cylinder.radius(),
307 worst,
308 }));
309 }
310 }
311 if let Ok(cone) = fit_cone(surface, tol) {
312 let worst = worst_of(&points, |p| cone.distance_to(p).abs());
313 if worst <= tolerance {
314 return Ok(Some(Simplified::Cone {
315 half_angle: cone.half_angle(),
316 worst,
317 }));
318 }
319 }
320 let _ = normals;
321 Ok(None)
322}
323
324fn verification_grid(surface: &SurfaceGeometry, tol: Tolerances) -> OgeomResult<Vec<Point>> {
328 const FEWEST: usize = 16;
329 const MOST: usize = 256;
330 let ((u0, u1), (v0, v1)) = surface.domain();
331 let direction = |range: (f64, f64), knots: Option<&ogeom_math::KnotVector>| -> Vec<f64> {
332 let breaks: Vec<f64> = knots.map_or_else(Vec::new, |k| {
333 k.distinct().into_iter().map(|(value, _)| value).collect()
334 });
335 let cut = ogeom_algo::traced::stations_between(range, &breaks, 1, FEWEST);
336 if cut.len() > MOST + 1 {
337 ogeom_algo::traced::stations_between(range, &[], 1, MOST)
338 } else {
339 cut
340 }
341 };
342 let (us, vs) = match surface {
343 SurfaceGeometry::BSpline(patch) => (
344 direction((u0, u1), Some(patch.u_knots())),
345 direction((v0, v1), Some(patch.v_knots())),
346 ),
347 _ => (direction((u0, u1), None), direction((v0, v1), None)),
348 };
349 let mut out = Vec::with_capacity(us.len() * vs.len());
350 for &u in &us {
351 for &v in &vs {
352 out.push(surface.point_at(u, v, tol)?);
353 }
354 }
355 Ok(out)
356}
357
358fn worst_of(points: &[Point], f: impl Fn(Point) -> f64) -> f64 {
359 points.iter().map(|p| f(*p)).fold(0.0, f64::max)
360}
361
362fn simplified_edge(
365 model: &mut Model,
366 edge: &Shape,
367 tolerance: f64,
368 cache: &mut ogeom_core::FastMap<ogeom_topo::TShapeId, Shape>,
369 tol: Tolerances,
370) -> OgeomResult<Shape> {
371 if let Some(found) = cache.get(&edge.node()) {
372 return Ok(found.clone());
373 }
374 let Some((curve_id, range)) = model
375 .node(edge)
376 .and_then(|n| n.data().as_edge())
377 .and_then(|d| match d.curve3d()? {
378 ogeom_topo::EdgeRepr::Curve3d { curve, range, .. } => Some((*curve, *range)),
379 _ => None,
380 })
381 else {
382 return Ok(edge.clone());
383 };
384 let Some(curve) = model.geometry().curve(curve_id).cloned() else {
385 return Ok(edge.clone());
386 };
387 if !matches!(curve, Curve::BSpline(_)) {
388 return Ok(edge.clone());
389 }
390 let placement = edge.transform(model.datums())?;
391 let world = {
392 use ogeom_geom::Transformable as _;
393 curve.transformed(&placement, tol)?
394 };
395 let pts = ogeom_algo::traced::stations(&world, range, 16)
398 .into_iter()
399 .map(|t| world.point_at(t, tol))
400 .collect::<OgeomResult<Vec<Point>>>()?;
401 let n = pts.len();
402 let stated = |model: &mut Model, built: &Shape, worst: f64| -> OgeomResult<()> {
403 if worst > tol.confusion() {
404 model.widen(built, ogeom_core::Tolerance::new(worst + tol.confusion())?)?;
405 }
406 Ok(())
407 };
408 let bounds = model.children_of(edge)?;
409 let (Some(va), Some(vb)) = (bounds.first().cloned(), bounds.last().cloned()) else {
410 return Ok(edge.clone());
411 };
412 let start = pts[0];
413 let end = pts[n - 1];
414 let closed = start.distance(end) <= tol.confusion();
415
416 if !closed && start.distance(end) > tol.confusion() {
418 let dir = Direction::new(end - start, tol)?;
419 let worst = worst_of(&pts, |p| (p - start).cross(dir.vector()).magnitude());
420 if worst <= tolerance {
421 let line = LineCurve::segment(start, end, tol)?;
422 let built = ogeom_algo::make_edge_between(
423 model,
424 Curve::from(line),
425 (0.0, start.distance(end)),
426 &va,
427 &vb,
428 tol,
429 )?
430 .shape;
431 stated(model, &built, worst)?;
432 cache.insert(edge.node(), built.clone());
433 return Ok(built);
434 }
435 }
436
437 if let Ok(plane) = fit_plane_points(&pts, tol) {
439 let planar = worst_of(&pts, |p| plane.signed_distance_to(p).abs());
440 if planar <= tolerance {
441 let frame = plane.frame();
442 let flat: Vec<(f64, f64)> = pts
443 .iter()
444 .map(|p| {
445 let l = frame.to_local(*p);
446 (l.x, l.y)
447 })
448 .collect();
449 if let Ok((cx, cy, r)) = fit_circle_2d(&flat) {
450 let centre = frame.origin() + frame.x().vector() * cx + frame.y().vector() * cy;
451 let round = worst_of(&pts, |p| (p.distance(centre) - r).abs());
452 if round <= tolerance {
453 let x = Direction::new(start - centre, tol)?;
457 let cframe = Frame::new(centre, frame.z(), x, tol)?;
458 let circle = Circle::new(cframe, r, tol)?;
459 let angle = |p: Point| -> f64 {
460 let l = cframe.to_local(p);
461 l.y.atan2(l.x).rem_euclid(core::f64::consts::TAU)
462 };
463 let (t0, t1) = if closed {
464 (0.0, core::f64::consts::TAU)
469 } else {
470 let a = angle(start);
471 let mut b = angle(end);
472 let m = angle(pts[n / 2]);
474 let fwd = (m - a).rem_euclid(core::f64::consts::TAU)
475 <= (b - a).rem_euclid(core::f64::consts::TAU);
476 if !fwd {
477 b -= core::f64::consts::TAU;
478 }
479 if b <= a {
480 b += core::f64::consts::TAU;
481 }
482 (a, b)
483 };
484 let built = ogeom_algo::make_edge_between(
485 model,
486 Curve::from(CircleCurve::new(circle)),
487 (t0, t1),
488 &va,
489 &vb,
490 tol,
491 )?
492 .shape;
493 stated(model, &built, round.max(planar))?;
494 cache.insert(edge.node(), built.clone());
495 return Ok(built);
496 }
497 }
498 }
499 }
500 Ok(edge.clone())
501}
502
503fn fit_plane_points(points: &[Point], tol: Tolerances) -> OgeomResult<Plane> {
505 let c = centroid(points);
506 let mut n = Vector::ZERO;
507 for w in points.windows(2) {
508 n += (w[0] - c).cross(w[1] - c);
509 }
510 Ok(Plane::through(c, Direction::new(n, tol)?))
511}
512
513fn fit_plane(surface: &SurfaceGeometry, tol: Tolerances) -> OgeomResult<Plane> {
515 let (points, normals) = samples(surface, tol)?;
516 let n = mean(&normals);
517 let centroid = centroid(&points);
518 Ok(Plane::through(centroid, Direction::new(n, tol)?))
519}
520
521fn fit_sphere(surface: &SurfaceGeometry, tol: Tolerances) -> OgeomResult<Sphere> {
524 let (points, normals) = samples(surface, tol)?;
525 let mut a = nalgebra::Matrix3::<f64>::zeros();
526 let mut b = nalgebra::Vector3::<f64>::zeros();
527 for (p, n) in points.iter().zip(&normals) {
528 let nv = nalgebra::Vector3::new(n.x, n.y, n.z);
529 let proj = nalgebra::Matrix3::identity() - nv * nv.transpose();
530 a += proj;
531 b += proj * nalgebra::Vector3::new(p.x, p.y, p.z);
532 }
533 let c = a
534 .lu()
535 .solve(&b)
536 .ok_or_else(|| ogeom_core::ogeom_err!(Construction, "normal lines meet nowhere"))?;
537 let centre = Point::new(c.x, c.y, c.z);
538 #[allow(clippy::cast_precision_loss, reason = "a sample count")]
539 let count = points.len().max(1) as f64;
540 let radius = points.iter().map(|p| p.distance(centre)).sum::<f64>() / count;
541 Sphere::centred(centre, radius, tol)
542}
543
544fn normal_axis(normals: &[Vector], tol: Tolerances) -> OgeomResult<Direction> {
548 let mut m = nalgebra::Matrix3::<f64>::zeros();
549 for n in normals {
550 let v = nalgebra::Vector3::new(n.x, n.y, n.z);
551 m += v * v.transpose();
552 }
553 let eigen = nalgebra::SymmetricEigen::new(m);
554 let mut best = 0;
555 for i in 1..3 {
556 if eigen.eigenvalues[i] < eigen.eigenvalues[best] {
557 best = i;
558 }
559 }
560 let d = eigen.eigenvectors.column(best);
561 Direction::from_coords(d.x, d.y, d.z, tol)
562}
563
564fn fit_cylinder(surface: &SurfaceGeometry, tol: Tolerances) -> OgeomResult<Cylinder> {
565 let (points, normals) = samples(surface, tol)?;
566 let axis = normal_axis(&normals, tol)?;
567 let origin = centroid(&points);
570 let frame = frame_about(origin, axis, tol)?;
571 let flat: Vec<(f64, f64)> = points
572 .iter()
573 .map(|p| {
574 let l = frame.to_local(*p);
575 (l.x, l.y)
576 })
577 .collect();
578 let (cx, cy, r) = fit_circle_2d(&flat)?;
579 let centre = frame.origin() + frame.x().vector() * cx + frame.y().vector() * cy;
580 Cylinder::new(frame_about(centre, axis, tol)?, r, tol)
581}
582
583fn fit_cone(surface: &SurfaceGeometry, tol: Tolerances) -> OgeomResult<Cone> {
584 let (points, normals) = samples(surface, tol)?;
585 let axis = normal_axis(&normals, tol)?;
586 let origin = centroid(&points);
588 let frame = frame_about(origin, axis, tol)?;
589 let hs: Vec<f64> = points.iter().map(|p| frame.to_local(*p).z).collect();
590 let (lo, hi) = hs
593 .iter()
594 .fold((f64::INFINITY, f64::NEG_INFINITY), |(a, b), h| {
595 (a.min(*h), b.max(*h))
596 });
597 if hi - lo <= tol.confusion() {
598 ogeom_bail!(Construction, "a flat band tapers to nothing");
599 }
600 let band = |keep: &dyn Fn(f64) -> bool| -> Vec<(f64, f64)> {
601 points
602 .iter()
603 .filter(|p| keep(frame.to_local(**p).z))
604 .map(|p| {
605 let l = frame.to_local(*p);
606 (l.x, l.y)
607 })
608 .collect()
609 };
610 let mid = f64::midpoint(lo, hi);
611 let lower = band(&|h| h <= mid);
612 let upper = band(&|h| h > mid);
613 let (ax, ay, r_lo) = fit_circle_2d(&lower)?;
614 let (bx, by, r_hi) = fit_circle_2d(&upper)?;
615 let h_lo = lower_mean(&points, &frame, mid, true);
616 let h_hi = lower_mean(&points, &frame, mid, false);
617 if (h_hi - h_lo).abs() <= tol.confusion() {
618 ogeom_bail!(Construction, "the bands coincide");
619 }
620 let slope = (r_hi - r_lo) / (h_hi - h_lo);
621 let half_angle = slope.atan();
622 let t = -h_lo / (h_hi - h_lo);
624 let cx = ax + (bx - ax) * t;
625 let cy = ay + (by - ay) * t;
626 let r0 = r_lo + (r_hi - r_lo) * t;
627 let centre = frame.origin() + frame.x().vector() * cx + frame.y().vector() * cy;
628 Cone::new(frame_about(centre, axis, tol)?, r0, half_angle, tol)
629}
630
631fn lower_mean(points: &[Point], frame: &Frame, mid: f64, low: bool) -> f64 {
632 let hs: Vec<f64> = points
633 .iter()
634 .map(|p| frame.to_local(*p).z)
635 .filter(|h| (*h <= mid) == low)
636 .collect();
637 #[allow(clippy::cast_precision_loss, reason = "a sample count")]
638 let n = hs.len().max(1) as f64;
639 hs.iter().sum::<f64>() / n
640}
641
642fn fit_circle_2d(points: &[(f64, f64)]) -> OgeomResult<(f64, f64, f64)> {
644 if points.len() < 3 {
645 ogeom_bail!(Construction, "a circle needs three points");
646 }
647 let (mut sxx, mut sxy, mut sx, mut syy, mut sy, mut s1) = (0.0, 0.0, 0.0, 0.0, 0.0, 0.0);
649 let (mut bx, mut by, mut b1) = (0.0, 0.0, 0.0);
650 for (x, y) in points {
651 let rhs = x * x + y * y;
652 sxx += 2.0 * x * 2.0 * x;
653 sxy += 2.0 * x * 2.0 * y;
654 sx += 2.0 * x;
655 syy += 2.0 * y * 2.0 * y;
656 sy += 2.0 * y;
657 s1 += 1.0;
658 bx += 2.0 * x * rhs;
659 by += 2.0 * y * rhs;
660 b1 += rhs;
661 }
662 let a = nalgebra::Matrix3::new(sxx, sxy, sx, sxy, syy, sy, sx, sy, s1);
663 let sol = a
664 .lu()
665 .solve(&nalgebra::Vector3::new(bx, by, b1))
666 .ok_or_else(|| ogeom_core::ogeom_err!(Construction, "the points close no circle"))?;
667 let (cx, cy, k) = (sol.x, sol.y, sol.z);
668 let r2 = k + cx * cx + cy * cy;
669 if r2 <= 0.0 {
670 ogeom_bail!(Construction, "the points close no circle");
671 }
672 Ok((cx, cy, r2.sqrt()))
673}
674
675fn centroid(points: &[Point]) -> Point {
676 let mut sum = Vector::ZERO;
677 for p in points {
678 sum += p.to_vector();
679 }
680 #[allow(clippy::cast_precision_loss, reason = "a sample count")]
681 let n = points.len().max(1) as f64;
682 Point::ORIGIN + sum * (1.0 / n)
683}
684
685fn mean(vs: &[Vector]) -> Vector {
686 let mut sum = Vector::ZERO;
687 for v in vs {
688 sum += *v;
689 }
690 #[allow(clippy::cast_precision_loss, reason = "a sample count")]
691 let n = vs.len().max(1) as f64;
692 sum * (1.0 / n)
693}
694
695fn frame_about(origin: Point, axis: Direction, tol: Tolerances) -> OgeomResult<Frame> {
696 let seed = if axis.vector().dot(Vector::X).abs() < 0.9 {
697 Vector::X
698 } else {
699 Vector::Y
700 };
701 let x = Direction::from_cross(axis.vector(), seed, tol)?;
702 Frame::new(origin, axis, x, tol)
703}