1use ogeom_algo::Built;
25use ogeom_core::{OgeomResult, Tolerances, ogeom_bail};
26use ogeom_geom::Curve3d as _;
27use ogeom_geom::Surface as _;
28use ogeom_geom::{
29 CircleCurve, ConeSurface, Curve, CylinderSurface, LineCurve, PlaneSurface, SphereSurface,
30 SurfaceGeometry,
31};
32use ogeom_math::Circle;
33use ogeom_math::{Cone, Cylinder, Direction, Frame, Plane, Point, Sphere, Vector};
34use ogeom_topo::{Filter, Model, NodeData, Shape, ShapeType, explore};
35
36#[derive(Debug, Clone, PartialEq)]
38pub enum Simplified {
39 Plane {
41 worst: f64,
43 },
44 Cylinder {
46 radius: f64,
48 worst: f64,
50 },
51 Cone {
53 half_angle: f64,
55 worst: f64,
57 },
58 Sphere {
60 radius: f64,
62 worst: f64,
64 },
65}
66
67#[derive(Debug, Default)]
69pub struct CanonicalReport {
70 pub simplified: Vec<Simplified>,
72 pub untouched: usize,
75}
76
77pub fn canonical_simplify(
92 model: &mut Model,
93 shape: &Shape,
94 tolerance: f64,
95 tol: Tolerances,
96) -> OgeomResult<(Built, CanonicalReport)> {
97 if !tolerance.is_finite() || tolerance <= 0.0 {
98 ogeom_bail!(Construction, "a tolerance of {tolerance} is not a distance");
99 }
100 let mut report = CanonicalReport::default();
101 let mut history = ogeom_algo::History::new();
102 let mut replaced: Vec<(Shape, Shape)> = Vec::new();
103 let mut edge_map: std::collections::HashMap<ogeom_topo::TShapeId, Shape> =
107 std::collections::HashMap::new();
108
109 for face in explore(model, shape, Filter::OfType(ShapeType::Face))? {
110 let Some(data) = model.node(&face).and_then(|n| match n.data() {
111 NodeData::Face(d) => Some(d.clone()),
112 _ => None,
113 }) else {
114 continue;
115 };
116 let Some(surface) = model.geometry().surface(data.surface).cloned() else {
117 continue;
118 };
119 if !matches!(surface, SurfaceGeometry::BSpline(_)) {
122 report.untouched += 1;
123 continue;
124 }
125 let placement = face.transform(model.datums())?;
126 let world = {
127 use ogeom_geom::Transformable as _;
128 surface.transformed(&placement, tol)?
129 };
130 let Some(found) = recognize_surface(&world, tolerance, tol)? else {
131 report.untouched += 1;
132 continue;
133 };
134 let carrier: SurfaceGeometry = match &found {
135 Simplified::Plane { .. } => {
136 let plane = fit_plane(&world, tol)?;
137 PlaneSurface::over(plane, (-1e5, 1e5), (-1e5, 1e5))?.into()
138 }
139 Simplified::Cylinder { .. } => {
140 let cylinder = fit_cylinder(&world, tol)?;
141 CylinderSurface::new(cylinder, (-1e5, 1e5))?.into()
142 }
143 Simplified::Cone { .. } => {
144 let cone = fit_cone(&world, tol)?;
145 ConeSurface::new(cone, (-1e5, 1e5))?.into()
146 }
147 Simplified::Sphere { .. } => {
148 let sphere = fit_sphere(&world, tol)?;
149 SphereSurface::new(sphere).into()
150 }
151 };
152 let mut wires = Vec::new();
159 for wire in model.ordered_children_of(&face)? {
160 let mut edges = Vec::new();
161 for edge in model.ordered_children_of(&wire)? {
162 let mapped = simplified_edge(model, &edge, tolerance, &mut edge_map, tol)?;
163 edges.push(if edge.orientation() == ogeom_topo::Orientation::Reversed {
164 mapped.reversed()
165 } else {
166 mapped
167 });
168 }
169 wires.push(edges);
170 }
171 let rebuilt = ogeom_algo::make_face_with_pcurves(model, carrier, &wires, tol)?.shape;
172 let rebuilt = if face.orientation() == ogeom_topo::Orientation::Reversed {
173 rebuilt.reversed()
174 } else {
175 rebuilt
176 };
177 history.modify(&face, rebuilt.clone());
178 replaced.push((face, rebuilt));
179 report.simplified.push(found);
180 }
181
182 if replaced.is_empty() {
183 history.generate(shape, shape.clone());
184 return Ok((Built::new(shape.clone(), history), report));
185 }
186
187 let mut faces = Vec::new();
190 for face in explore(model, shape, Filter::OfType(ShapeType::Face))? {
191 match replaced.iter().find(|(old, _)| old.node() == face.node()) {
192 Some((_, new)) => faces.push(new.clone()),
193 None => faces.push(face),
194 }
195 }
196 let sewn = ogeom_algo::sew(model, &faces, tol)?;
197 let out = match sewn.shells.as_slice() {
198 [shell] if ogeom_algo::is_shell_closed(model, shell)? => {
199 ogeom_algo::make_solid(model, core::slice::from_ref(shell))?.shape
200 }
201 [shell] => shell.clone(),
202 _ => ogeom_algo::make_compound(model, &sewn.shells)?.shape,
203 };
204 history.modify(shape, out.clone());
205 Ok((Built::new(out, history), report))
206}
207
208fn samples(surface: &SurfaceGeometry, tol: Tolerances) -> OgeomResult<(Vec<Point>, Vec<Vector>)> {
210 const N: usize = 9;
211 let ((u0, u1), (v0, v1)) = surface.domain();
212 let mut points = Vec::with_capacity(N * N);
213 let mut normals = Vec::with_capacity(N * N);
214 for i in 0..N {
215 for j in 0..N {
216 #[allow(clippy::cast_precision_loss, reason = "a sample index")]
219 let fu = (i as f64 + 0.5) / N as f64;
220 #[allow(clippy::cast_precision_loss, reason = "a sample index")]
221 let fv = (j as f64 + 0.5) / N as f64;
222 let u = u0 + (u1 - u0) * fu;
223 let v = v0 + (v1 - v0) * fv;
224 points.push(surface.point_at(u, v, tol)?);
225 normals.push(surface.normal_at(u, v, tol)?.vector());
226 }
227 }
228 Ok((points, normals))
229}
230
231pub fn recognize_surface(
237 surface: &SurfaceGeometry,
238 tolerance: f64,
239 tol: Tolerances,
240) -> OgeomResult<Option<Simplified>> {
241 let (points, normals) = samples(surface, tol)?;
242 if let Ok(plane) = fit_plane(surface, tol) {
247 let worst = worst_of(&points, |p| plane.signed_distance_to(p).abs());
248 if worst <= tolerance {
249 return Ok(Some(Simplified::Plane { worst }));
250 }
251 }
252 if let Ok(sphere) = fit_sphere(surface, tol) {
253 let worst = worst_of(&points, |p| {
254 (p.distance(sphere.centre()) - sphere.radius()).abs()
255 });
256 if worst <= tolerance {
257 return Ok(Some(Simplified::Sphere {
258 radius: sphere.radius(),
259 worst,
260 }));
261 }
262 }
263 if let Ok(cylinder) = fit_cylinder(surface, tol) {
264 let worst = worst_of(&points, |p| cylinder.distance_to(p).abs());
265 if worst <= tolerance {
266 return Ok(Some(Simplified::Cylinder {
267 radius: cylinder.radius(),
268 worst,
269 }));
270 }
271 }
272 if let Ok(cone) = fit_cone(surface, tol) {
273 let worst = worst_of(&points, |p| cone.distance_to(p).abs());
274 if worst <= tolerance {
275 return Ok(Some(Simplified::Cone {
276 half_angle: cone.half_angle(),
277 worst,
278 }));
279 }
280 }
281 let _ = normals;
282 Ok(None)
283}
284
285fn worst_of(points: &[Point], f: impl Fn(Point) -> f64) -> f64 {
286 points.iter().map(|p| f(*p)).fold(0.0, f64::max)
287}
288
289fn simplified_edge(
292 model: &mut Model,
293 edge: &Shape,
294 tolerance: f64,
295 cache: &mut std::collections::HashMap<ogeom_topo::TShapeId, Shape>,
296 tol: Tolerances,
297) -> OgeomResult<Shape> {
298 if let Some(found) = cache.get(&edge.node()) {
299 return Ok(found.clone());
300 }
301 let Some((curve_id, range)) = model
302 .node(edge)
303 .and_then(|n| n.data().as_edge())
304 .and_then(|d| match d.curve3d()? {
305 ogeom_topo::EdgeRepr::Curve3d { curve, range, .. } => Some((*curve, *range)),
306 _ => None,
307 })
308 else {
309 return Ok(edge.clone());
310 };
311 let Some(curve) = model.geometry().curve(curve_id).cloned() else {
312 return Ok(edge.clone());
313 };
314 if !matches!(curve, Curve::BSpline(_)) {
315 return Ok(edge.clone());
316 }
317 let placement = edge.transform(model.datums())?;
318 let world = {
319 use ogeom_geom::Transformable as _;
320 curve.transformed(&placement, tol)?
321 };
322 const N: usize = 17;
323 let mut pts = Vec::with_capacity(N);
324 for i in 0..N {
325 #[allow(clippy::cast_precision_loss, reason = "a sample index")]
326 let t = range.0 + (range.1 - range.0) * i as f64 / (N - 1) as f64;
327 pts.push(world.point_at(t, tol)?);
328 }
329 let bounds = model.children_of(edge)?;
330 let (Some(va), Some(vb)) = (bounds.first().cloned(), bounds.last().cloned()) else {
331 return Ok(edge.clone());
332 };
333 let start = pts[0];
334 let end = pts[N - 1];
335 let closed = start.distance(end) <= tol.confusion();
336
337 if !closed && start.distance(end) > tol.confusion() {
339 let dir = Direction::new(end - start, tol)?;
340 let worst = worst_of(&pts, |p| (p - start).cross(dir.vector()).magnitude());
341 if worst <= tolerance {
342 let line = LineCurve::segment(start, end, tol)?;
343 let built = ogeom_algo::make_edge_between(
344 model,
345 Curve::from(line),
346 (0.0, start.distance(end)),
347 &va,
348 &vb,
349 tol,
350 )?
351 .shape;
352 cache.insert(edge.node(), built.clone());
353 return Ok(built);
354 }
355 }
356
357 if let Ok(plane) = fit_plane_points(&pts, tol) {
359 let planar = worst_of(&pts, |p| plane.signed_distance_to(p).abs());
360 if planar <= tolerance {
361 let frame = plane.frame();
362 let flat: Vec<(f64, f64)> = pts
363 .iter()
364 .map(|p| {
365 let l = frame.to_local(*p);
366 (l.x, l.y)
367 })
368 .collect();
369 if let Ok((cx, cy, r)) = fit_circle_2d(&flat) {
370 let centre = frame.origin() + frame.x().vector() * cx + frame.y().vector() * cy;
371 let round = worst_of(&pts, |p| (p.distance(centre) - r).abs());
372 if round <= tolerance {
373 let x = Direction::new(start - centre, tol)?;
377 let cframe = Frame::new(centre, frame.z(), x, tol)?;
378 let circle = Circle::new(cframe, r, tol)?;
379 let angle = |p: Point| -> f64 {
380 let l = cframe.to_local(p);
381 l.y.atan2(l.x).rem_euclid(core::f64::consts::TAU)
382 };
383 let (t0, t1) = if closed {
384 (0.0, core::f64::consts::TAU)
389 } else {
390 let a = angle(start);
391 let mut b = angle(end);
392 let m = angle(pts[N / 2]);
394 let fwd = (m - a).rem_euclid(core::f64::consts::TAU)
395 <= (b - a).rem_euclid(core::f64::consts::TAU);
396 if !fwd {
397 b -= core::f64::consts::TAU;
398 }
399 if b <= a {
400 b += core::f64::consts::TAU;
401 }
402 (a, b)
403 };
404 let built = ogeom_algo::make_edge_between(
405 model,
406 Curve::from(CircleCurve::new(circle)),
407 (t0, t1),
408 &va,
409 &vb,
410 tol,
411 )?
412 .shape;
413 cache.insert(edge.node(), built.clone());
414 return Ok(built);
415 }
416 }
417 }
418 }
419 Ok(edge.clone())
420}
421
422fn fit_plane_points(points: &[Point], tol: Tolerances) -> OgeomResult<Plane> {
424 let c = centroid(points);
425 let mut n = Vector::ZERO;
426 for w in points.windows(2) {
427 n += (w[0] - c).cross(w[1] - c);
428 }
429 Ok(Plane::through(c, Direction::new(n, tol)?))
430}
431
432fn fit_plane(surface: &SurfaceGeometry, tol: Tolerances) -> OgeomResult<Plane> {
434 let (points, normals) = samples(surface, tol)?;
435 let n = mean(&normals);
436 let centroid = centroid(&points);
437 Ok(Plane::through(centroid, Direction::new(n, tol)?))
438}
439
440fn fit_sphere(surface: &SurfaceGeometry, tol: Tolerances) -> OgeomResult<Sphere> {
443 let (points, normals) = samples(surface, tol)?;
444 let mut a = nalgebra::Matrix3::<f64>::zeros();
445 let mut b = nalgebra::Vector3::<f64>::zeros();
446 for (p, n) in points.iter().zip(&normals) {
447 let nv = nalgebra::Vector3::new(n.x, n.y, n.z);
448 let proj = nalgebra::Matrix3::identity() - nv * nv.transpose();
449 a += proj;
450 b += proj * nalgebra::Vector3::new(p.x, p.y, p.z);
451 }
452 let c = a
453 .lu()
454 .solve(&b)
455 .ok_or_else(|| ogeom_core::ogeom_err!(Construction, "normal lines meet nowhere"))?;
456 let centre = Point::new(c.x, c.y, c.z);
457 #[allow(clippy::cast_precision_loss, reason = "a sample count")]
458 let count = points.len().max(1) as f64;
459 let radius = points.iter().map(|p| p.distance(centre)).sum::<f64>() / count;
460 Sphere::centred(centre, radius, tol)
461}
462
463fn normal_axis(normals: &[Vector], tol: Tolerances) -> OgeomResult<Direction> {
467 let mut m = nalgebra::Matrix3::<f64>::zeros();
468 for n in normals {
469 let v = nalgebra::Vector3::new(n.x, n.y, n.z);
470 m += v * v.transpose();
471 }
472 let eigen = nalgebra::SymmetricEigen::new(m);
473 let mut best = 0;
474 for i in 1..3 {
475 if eigen.eigenvalues[i] < eigen.eigenvalues[best] {
476 best = i;
477 }
478 }
479 let d = eigen.eigenvectors.column(best);
480 Direction::from_coords(d.x, d.y, d.z, tol)
481}
482
483fn fit_cylinder(surface: &SurfaceGeometry, tol: Tolerances) -> OgeomResult<Cylinder> {
484 let (points, normals) = samples(surface, tol)?;
485 let axis = normal_axis(&normals, tol)?;
486 let origin = centroid(&points);
489 let frame = frame_about(origin, axis, tol)?;
490 let flat: Vec<(f64, f64)> = points
491 .iter()
492 .map(|p| {
493 let l = frame.to_local(*p);
494 (l.x, l.y)
495 })
496 .collect();
497 let (cx, cy, r) = fit_circle_2d(&flat)?;
498 let centre = frame.origin() + frame.x().vector() * cx + frame.y().vector() * cy;
499 Cylinder::new(frame_about(centre, axis, tol)?, r, tol)
500}
501
502fn fit_cone(surface: &SurfaceGeometry, tol: Tolerances) -> OgeomResult<Cone> {
503 let (points, normals) = samples(surface, tol)?;
504 let axis = normal_axis(&normals, tol)?;
505 let origin = centroid(&points);
507 let frame = frame_about(origin, axis, tol)?;
508 let hs: Vec<f64> = points.iter().map(|p| frame.to_local(*p).z).collect();
509 let (lo, hi) = hs
512 .iter()
513 .fold((f64::INFINITY, f64::NEG_INFINITY), |(a, b), h| {
514 (a.min(*h), b.max(*h))
515 });
516 if hi - lo <= tol.confusion() {
517 ogeom_bail!(Construction, "a flat band tapers to nothing");
518 }
519 let band = |keep: &dyn Fn(f64) -> bool| -> Vec<(f64, f64)> {
520 points
521 .iter()
522 .filter(|p| keep(frame.to_local(**p).z))
523 .map(|p| {
524 let l = frame.to_local(*p);
525 (l.x, l.y)
526 })
527 .collect()
528 };
529 let mid = f64::midpoint(lo, hi);
530 let lower = band(&|h| h <= mid);
531 let upper = band(&|h| h > mid);
532 let (ax, ay, r_lo) = fit_circle_2d(&lower)?;
533 let (bx, by, r_hi) = fit_circle_2d(&upper)?;
534 let h_lo = lower_mean(&points, &frame, mid, true);
535 let h_hi = lower_mean(&points, &frame, mid, false);
536 if (h_hi - h_lo).abs() <= tol.confusion() {
537 ogeom_bail!(Construction, "the bands coincide");
538 }
539 let slope = (r_hi - r_lo) / (h_hi - h_lo);
540 let half_angle = slope.atan();
541 let t = -h_lo / (h_hi - h_lo);
543 let cx = ax + (bx - ax) * t;
544 let cy = ay + (by - ay) * t;
545 let r0 = r_lo + (r_hi - r_lo) * t;
546 let centre = frame.origin() + frame.x().vector() * cx + frame.y().vector() * cy;
547 Cone::new(frame_about(centre, axis, tol)?, r0, half_angle, tol)
548}
549
550fn lower_mean(points: &[Point], frame: &Frame, mid: f64, low: bool) -> f64 {
551 let hs: Vec<f64> = points
552 .iter()
553 .map(|p| frame.to_local(*p).z)
554 .filter(|h| (*h <= mid) == low)
555 .collect();
556 #[allow(clippy::cast_precision_loss, reason = "a sample count")]
557 let n = hs.len().max(1) as f64;
558 hs.iter().sum::<f64>() / n
559}
560
561fn fit_circle_2d(points: &[(f64, f64)]) -> OgeomResult<(f64, f64, f64)> {
563 if points.len() < 3 {
564 ogeom_bail!(Construction, "a circle needs three points");
565 }
566 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);
568 let (mut bx, mut by, mut b1) = (0.0, 0.0, 0.0);
569 for (x, y) in points {
570 let rhs = x * x + y * y;
571 sxx += 2.0 * x * 2.0 * x;
572 sxy += 2.0 * x * 2.0 * y;
573 sx += 2.0 * x;
574 syy += 2.0 * y * 2.0 * y;
575 sy += 2.0 * y;
576 s1 += 1.0;
577 bx += 2.0 * x * rhs;
578 by += 2.0 * y * rhs;
579 b1 += rhs;
580 }
581 let a = nalgebra::Matrix3::new(sxx, sxy, sx, sxy, syy, sy, sx, sy, s1);
582 let sol = a
583 .lu()
584 .solve(&nalgebra::Vector3::new(bx, by, b1))
585 .ok_or_else(|| ogeom_core::ogeom_err!(Construction, "the points close no circle"))?;
586 let (cx, cy, k) = (sol.x, sol.y, sol.z);
587 let r2 = k + cx * cx + cy * cy;
588 if r2 <= 0.0 {
589 ogeom_bail!(Construction, "the points close no circle");
590 }
591 Ok((cx, cy, r2.sqrt()))
592}
593
594fn centroid(points: &[Point]) -> Point {
595 let mut sum = Vector::ZERO;
596 for p in points {
597 sum += p.to_vector();
598 }
599 #[allow(clippy::cast_precision_loss, reason = "a sample count")]
600 let n = points.len().max(1) as f64;
601 Point::ORIGIN + sum * (1.0 / n)
602}
603
604fn mean(vs: &[Vector]) -> Vector {
605 let mut sum = Vector::ZERO;
606 for v in vs {
607 sum += *v;
608 }
609 #[allow(clippy::cast_precision_loss, reason = "a sample count")]
610 let n = vs.len().max(1) as f64;
611 sum * (1.0 / n)
612}
613
614fn frame_about(origin: Point, axis: Direction, tol: Tolerances) -> OgeomResult<Frame> {
615 let seed = if axis.vector().dot(Vector::X).abs() < 0.9 {
616 Vector::X
617 } else {
618 Vector::Y
619 };
620 let x = Direction::from_cross(axis.vector(), seed, tol)?;
621 Frame::new(origin, axis, x, tol)
622}