1use ogeom_algo::Built;
14use ogeom_core::{OgeomResult, Tolerances, ogeom_bail};
15use ogeom_math::{Direction, Frame, Point, Vector};
16use ogeom_topo::{Model, Shape, ShapeType};
17
18pub fn round_vertex(
61 model: &mut Model,
62 solid: &Shape,
63 vertex: &Shape,
64 radius: f64,
65 tol: Tolerances,
66) -> OgeomResult<Built> {
67 round_vertex_with(model, solid, vertex, radius, None, tol)
68}
69
70pub(crate) fn round_vertex_with(
74 model: &mut Model,
75 solid: &Shape,
76 vertex: &Shape,
77 radius: f64,
78 forced: Option<usize>,
79 tol: Tolerances,
80) -> OgeomResult<Built> {
81 if model.kind_of(vertex)? != ShapeType::Vertex {
82 ogeom_bail!(Construction, "round_vertex rounds a vertex");
83 }
84 if radius <= tol.confusion() {
85 ogeom_bail!(Construction, "a blend radius must be a positive distance");
86 }
87 let Some(raw) = model
88 .node(vertex)
89 .and_then(|n| n.data().as_vertex().map(|d| d.point))
90 else {
91 ogeom_bail!(Construction, "the vertex holds no point");
92 };
93 let corner = vertex.transform(model.datums())?.apply(raw);
97
98 let mut m: Vec<Vector> = Vec::new();
108 for face in ogeom_topo::explore_unique(model, solid, ShapeType::Face)? {
109 let Some(data) = model.node(&face).and_then(|n| n.data().as_face()) else {
110 continue;
111 };
112 let Some(surface) = model.geometry().surface(data.surface) else {
113 continue;
114 };
115 if !matches!(surface, ogeom_geom::SurfaceGeometry::Plane(_)) {
116 continue;
117 }
118 let (origin, outward) = ogeom_algo::face_normal(model, &face, tol)?;
119 if (corner - origin).dot(outward).abs() > tol.confusion() * 100.0 {
120 continue;
121 }
122 if m.iter()
124 .any(|n| n.cross(outward).magnitude() < tol.angular() * 10.0)
125 {
126 continue;
127 }
128 m.push(-outward);
129 }
130 let n = m.len();
131 if n < 3 {
132 return crate::corner_curved::curved_corner(model, solid, vertex, corner, radius, tol);
135 }
136 let centre_for = |m: &[Vector]| -> Option<(Point, f64)> {
142 let mut a = [[0.0_f64; 3]; 3];
149 let mut b = [0.0_f64; 3];
150 if m.len() == 3 {
151 for (i, mk) in m.iter().enumerate() {
152 a[i] = [mk.x, mk.y, mk.z];
153 b[i] = radius;
154 }
155 } else {
156 for mk in m {
157 let v = [mk.x, mk.y, mk.z];
158 for i in 0..3 {
159 b[i] += radius * v[i];
160 for j in 0..3 {
161 a[i][j] += v[i] * v[j];
162 }
163 }
164 }
165 }
166 let det3 = |a: &[[f64; 3]; 3]| -> f64 {
167 a[0][0].mul_add(
168 a[1][1].mul_add(a[2][2], -(a[1][2] * a[2][1])),
169 -a[0][1].mul_add(
170 a[1][0].mul_add(a[2][2], -(a[1][2] * a[2][0])),
171 -(a[0][2] * a[1][0].mul_add(a[2][1], -(a[1][1] * a[2][0]))),
172 ),
173 )
174 };
175 let det = det3(&a);
176 if !det.is_finite() || det.abs() <= 1e-12 {
177 return None;
178 }
179 let mut x = [0.0_f64; 3];
180 for (k, xk) in x.iter_mut().enumerate() {
181 let mut ak = a;
182 for (row, bi) in ak.iter_mut().zip(b) {
183 row[k] = bi;
184 }
185 *xk = det3(&ak) / det;
186 }
187 let centre = corner + Vector::new(x[0], x[1], x[2]);
188 let residual = m
189 .iter()
190 .map(|mk| (mk.dot(centre - corner) - radius).abs())
191 .fold(0.0, f64::max);
192 Some((centre, residual))
193 };
194 let Some((far, residual)) = centre_for(&m) else {
195 ogeom_bail!(
196 Construction,
197 "the {n} planes through this vertex do not span a corner"
198 );
199 };
200 if residual > tol.confusion() * 10.0 {
201 return setback_corner(model, solid, vertex, corner, &m, radius, tol);
206 }
207 let boundary = ogeom_algo::SolidBoundary::of(model, solid, tol.confusion() * 1e4, tol)?;
211 if boundary.holds(model, far, tol)? != ogeom_algo::Containment::In {
212 ogeom_bail!(
213 Construction,
214 "no material a radius in from every face at this vertex; a \
215 concave vertex gains a ball instead of shedding one, and this \
216 tool cannot round it"
217 );
218 }
219 let mut edges: Vec<(usize, usize, Direction)> = Vec::new();
224 for i in 0..n {
225 for j in i + 1..n {
226 let cross = m[i].cross(m[j]);
227 let length = cross.magnitude();
228 if length <= tol.angular() * 10.0 {
229 continue;
230 }
231 for sign in [1.0, -1.0] {
232 let d = cross * (sign / length);
233 let inside = (0..n)
234 .filter(|&k| k != i && k != j)
235 .all(|k| m[k].dot(d) >= -tol.angular() * 10.0);
236 if inside {
237 edges.push((i, j, Direction::new(d, tol)?));
238 break;
239 }
240 }
241 }
242 }
243 if edges.len() != n {
244 ogeom_bail!(
245 Construction,
246 "the {n} planes through this vertex meet along {} edges, not {n}; \
247 the corner is not the simple convex one this tool speaks",
248 edges.len()
249 );
250 }
251 let mut ring: Vec<usize> = vec![0];
252 let mut last_edge: Option<usize> = None;
253 loop {
254 let here = *ring.last().unwrap_or(&0);
255 let Some((e, other)) = edges.iter().enumerate().find_map(|(e, &(i, j, _))| {
256 if Some(e) == last_edge {
257 None
258 } else if i == here {
259 Some((e, j))
260 } else if j == here {
261 Some((e, i))
262 } else {
263 None
264 }
265 }) else {
266 ogeom_bail!(
267 Construction,
268 "the planes through this vertex do not chain into a ring round it"
269 );
270 };
271 last_edge = Some(e);
272 if other == 0 {
273 break;
274 }
275 if ring.contains(&other) || ring.len() >= n {
276 ogeom_bail!(
277 Construction,
278 "the planes through this vertex do not chain into a ring round it"
279 );
280 }
281 ring.push(other);
282 }
283 if ring.len() != n {
284 ogeom_bail!(
285 Construction,
286 "the planes through this vertex do not chain into a ring round it"
287 );
288 }
289 let d: Vec<Direction> = (0..n)
291 .map(|k| {
292 let (p, q) = (ring[k], ring[(k + 1) % n]);
293 edges
294 .iter()
295 .find(|&&(i, j, _)| (i == p && j == q) || (i == q && j == p))
296 .map(|&(_, _, dir)| dir)
297 })
298 .collect::<Option<_>>()
299 .ok_or_else(|| {
300 ogeom_core::ogeom_err!(Construction, "the corner's edges lost their ring")
301 })?;
302 let inward_of = |k: usize| m[ring[k % n]];
303
304 let attempt = |model: &mut Model, start: usize, reverse: bool| -> OgeomResult<Built> {
329 let edge_at = |t: usize| -> usize {
331 if reverse {
332 (start + n - t % n) % n
333 } else {
334 (start + t) % n
335 }
336 };
337 let dl: Vec<Direction> = (0..n).map(|t| d[edge_at(t)]).collect();
338 let ml: Vec<Vector> = (0..n)
339 .map(|t| {
340 if reverse {
343 inward_of((start + n - t % n) % n)
344 } else {
345 inward_of(edge_at(t) + 1)
346 }
347 })
348 .collect();
349 let along =
350 |t: usize| -> Point { corner + dl[t].vector() * (far - corner).dot(dl[t].vector()) };
351 let touch = |t: usize| -> Point { far - ml[t] * radius };
352 if std::env::var_os("OGEOM_DEBUG_CORNER").is_some() {
353 eprintln!(
354 "CORNER attempt start {start} reverse {reverse} far {far:?} d {:?} m {ml:?}",
355 dl.iter().map(|x| x.vector()).collect::<Vec<_>>()
356 );
357 }
358 model.begin_operation();
359 let mut points: Vec<Point> = vec![corner];
360 points.extend((0..n).map(along));
361 points.extend((0..n).map(touch));
362 points.push(far);
363 let mut rings: Vec<Vec<usize>> = Vec::with_capacity(2 * n);
364 for t in 0..n {
365 rings.push(vec![0, 1 + t, 1 + n + t, 1 + (t + 1) % n]);
368 rings.push(vec![1 + t, 1 + n + (t + n - 1) % n, 1 + 2 * n, 1 + n + t]);
371 }
372 let block = ogeom_algo::make_polyhedron(model, &points, &rings, tol)?.shape;
373 let ball_frame = Frame::new(far, Direction::new(ml[0], tol)?, dl[0], tol)?;
374 let ball = ogeom_algo::make_sphere(model, ball_frame, radius, tol)?.shape;
375 let tool = ogeom_bool::cut(model, &block, &ball, tol)?;
376 if std::env::var_os("OGEOM_DEBUG_CORNER").is_some() {
377 let faces = ogeom_topo::explore_unique(model, &tool.shape, ShapeType::Face)?;
378 let kinds: Vec<String> = faces
379 .iter()
380 .map(|f| {
381 let kind = model
382 .node(f)
383 .and_then(|n| n.data().as_face())
384 .and_then(|d| model.geometry().surface(d.surface))
385 .map_or("?", |sg| match sg {
386 ogeom_geom::SurfaceGeometry::Plane(_) => "plane",
387 ogeom_geom::SurfaceGeometry::Sphere(_) => "sphere",
388 _ => "other",
389 });
390 let edges = ogeom_topo::explore_unique(model, f, ShapeType::Edge)
391 .map_or(0, |e| e.len());
392 format!("{kind}/{edges}")
393 })
394 .collect();
395 eprintln!(
396 "CORNER tool for start {start} reverse {reverse}: {} faces {kinds:?}",
397 faces.len()
398 );
399 }
400 let rounded = ogeom_bool::cut(model, solid, &tool.shape, tol)?;
401 Ok(Built {
402 shape: rounded.shape,
403 history: tool.history.then(&rounded.history),
404 })
405 };
406 let mut outcome: Option<Built> = None;
407 let mut last: Option<ogeom_core::OgeomError> = None;
408 let labellings = (0..2 * n).map(|index| (index % n, index >= n));
409 for (index, (start, reverse)) in labellings.enumerate() {
410 if forced.is_some_and(|f| f != index) {
411 continue;
412 }
413 match attempt(model, start, reverse) {
414 Ok(built) => {
415 outcome = Some(built);
416 break;
417 }
418 Err(err) => last = Some(err),
419 }
420 }
421 let Some(rounded) = outcome else {
422 ogeom_bail!(
423 NotDone,
424 "the corner tool's cut closed on none of the corner's {} \
425 labellings; the last said: {}",
426 2 * n,
427 last.map_or_else(String::new, |e| e.to_string())
428 );
429 };
430 let mut built = rounded;
431 built.history.modify(vertex, built.shape.clone());
432 Ok(built)
433}
434
435struct TipVertex {
439 centre: Point,
440 planes: Vec<usize>,
441}
442
443struct Ridge {
447 from: usize,
448 to: usize,
449 planes: [usize; 2],
450}
451
452fn setback_corner(
476 model: &mut Model,
477 solid: &Shape,
478 vertex: &Shape,
479 corner: Point,
480 m: &[Vector],
481 radius: f64,
482 tol: Tolerances,
483) -> OgeomResult<Built> {
484 let n = m.len();
485 let slack = tol.confusion() * 10.0;
486 let mut tips: Vec<TipVertex> = Vec::new();
489 for i in 0..n {
490 for j in i + 1..n {
491 for k in j + 1..n {
492 let Some(centre) = ball_centre(corner, &[m[i], m[j], m[k]], radius) else {
493 continue;
494 };
495 let feasible = m.iter().all(|mk| mk.dot(centre - corner) >= radius - slack);
496 if !feasible {
497 continue;
498 }
499 if tips.iter().any(|t| t.centre.distance(centre) <= slack) {
500 continue;
501 }
502 let planes: Vec<usize> = (0..n)
503 .filter(|&l| (m[l].dot(centre - corner) - radius).abs() <= slack)
504 .collect();
505 tips.push(TipVertex { centre, planes });
506 }
507 }
508 }
509 if std::env::var_os("OGEOM_DEBUG_CORNER").is_some() {
510 for tip in &tips {
511 eprintln!("TIP {:?} planes {:?}", tip.centre, tip.planes);
512 }
513 }
514 if tips.is_empty() {
515 ogeom_bail!(
516 Construction,
517 "the {n} planes through this vertex hold no ball a radius in from \
518 all of them"
519 );
520 }
521 let mut ridges: Vec<Ridge> = Vec::new();
528 let mut rays: Vec<Vec<(Vector, [usize; 2])>> = vec![Vec::new(); tips.len()];
529 for (index, tip) in tips.iter().enumerate() {
530 for (a, b) in tip
531 .planes
532 .iter()
533 .flat_map(|&a| tip.planes.iter().map(move |&b| (a, b)))
534 {
535 if a >= b {
536 continue;
537 }
538 let cross = m[a].cross(m[b]);
539 let length = cross.magnitude();
540 if length <= tol.angular() * 10.0 {
541 continue;
542 }
543 let Some(direction) = [1.0, -1.0]
544 .into_iter()
545 .map(|sign| cross * (sign / length))
546 .find(|d| {
547 tip.planes
548 .iter()
549 .filter(|&&l| l != a && l != b)
550 .all(|&l| m[l].dot(*d) >= -tol.angular() * 10.0)
551 })
552 else {
553 continue;
554 };
555 let mut nearest: Option<(f64, usize)> = None;
557 for (l, ml) in m.iter().enumerate() {
558 if tip.planes.contains(&l) {
559 continue;
560 }
561 let rate = ml.dot(direction);
562 if rate >= -tol.angular() {
563 continue;
564 }
565 let t = (radius - ml.dot(tip.centre - corner)) / rate;
566 if t > slack && nearest.is_none_or(|(held, _)| t < held) {
567 nearest = Some((t, l));
568 }
569 }
570 match nearest {
571 Some((t, _)) => {
572 let end = tip.centre + direction * t;
573 let Some(to) = tips.iter().position(|o| o.centre.distance(end) <= slack) else {
574 ogeom_bail!(
575 Construction,
576 "the ball rolling between two of this vertex's faces meets a \
577 third where no tip vertex stands"
578 );
579 };
580 if index < to {
581 ridges.push(Ridge {
582 from: index,
583 to,
584 planes: [a, b],
585 });
586 }
587 }
588 None => rays[index].push((direction, [a, b])),
589 }
590 }
591 }
592 let concave = {
593 let boundary = ogeom_algo::SolidBoundary::of(model, solid, tol.confusion() * 1e4, tol)?;
594 let mut concave = false;
595 for tip in &tips {
596 if boundary.holds(model, tip.centre, tol)? != ogeom_algo::Containment::In {
597 concave = true;
598 }
599 }
600 concave
601 };
602 if concave {
603 ogeom_bail!(
604 Construction,
605 "no material a radius in from every face at this vertex; a \
606 concave vertex gains a ball instead of shedding one, and this \
607 tool cannot round it"
608 );
609 }
610
611 model.begin_operation();
612 let mut rounded = solid.clone();
613 let mut history = ogeom_algo::History::new();
614 for (index, tip) in tips.iter().enumerate() {
619 let mut edges: Vec<(Vector, [usize; 2])> = rays[index].clone();
622 for ridge in &ridges {
623 let other = if ridge.from == index {
624 ridge.to
625 } else if ridge.to == index {
626 ridge.from
627 } else {
628 continue;
629 };
630 let direction = (tips[other].centre - tip.centre).normalized(tol)?;
631 edges.push((direction, ridge.planes));
632 }
633 let k = tip.planes.len();
634 if edges.len() != k {
635 ogeom_bail!(
636 Construction,
637 "a tip vertex of this corner touches {k} planes along {} edges, not \
638 {k}; the corner is not the simple convex one this tool speaks",
639 edges.len()
640 );
641 }
642 let shared = |x: &[usize; 2], y: &[usize; 2]| -> Option<usize> {
646 x.iter().copied().find(|p| y.contains(p))
647 };
648 let mut ring: Vec<(Vector, [usize; 2])> = vec![edges[0]];
649 let mut used = vec![false; k];
650 used[0] = true;
651 while ring.len() < k {
652 let last = ring[ring.len() - 1];
653 let Some(next) = (0..k).find(|&i| {
654 !used[i]
655 && shared(&edges[i].1, &last.1).is_some_and(|p| {
656 ring.len() < 2 || shared(&ring[ring.len() - 2].1, &last.1) != Some(p)
659 })
660 }) else {
661 ogeom_bail!(Construction, "the tip vertex's edges do not chain")
662 };
663 used[next] = true;
664 ring.push(edges[next]);
665 }
666 let hosts: Vec<Vector> = (0..k)
667 .map(|t| {
668 shared(&ring[t].1, &ring[(t + 1) % k].1)
669 .map(|p| m[p])
670 .ok_or_else(|| {
671 ogeom_core::ogeom_err!(Construction, "the tip vertex's edges do not chain")
672 })
673 })
674 .collect::<OgeomResult<_>>()?;
675 let directions: Vec<Vector> = ring.iter().map(|(d, _)| *d).collect();
676 let is_ridge: Vec<bool> = ring
681 .iter()
682 .map(|(d, _)| !rays[index].iter().any(|(r, _)| r.dot(*d) > 1.0 - 1e-9))
683 .collect();
684 let mut walls: Vec<(Vector, Point)> = tip.planes.iter().map(|&p| (m[p], corner)).collect();
685 for direction in &directions {
686 walls.push((-*direction, tip.centre));
687 }
688 let tool = ball_block(
689 model,
690 &walls,
691 corner,
692 tip.centre,
693 &hosts,
694 &directions,
695 &is_ridge,
696 radius,
697 tol,
698 )?;
699 let cut = ogeom_bool::cut(model, &rounded, &tool.shape, tol).map_err(|e| {
700 ogeom_core::ogeom_err!(
701 NotDone,
702 "the cut by the ball's block at tip vertex {index} failed: {e}"
703 )
704 })?;
705 history = history.then(&tool.history).then(&cut.history);
706 rounded = cut.shape;
707 }
708 for ridge in &ridges {
715 let (v1, v2) = (tips[ridge.from].centre, tips[ridge.to].centre);
716 let along = (v2 - v1).normalized(tol)?;
717 let [a, c] = ridge.planes;
718 let on_crease = |v: Point| corner + along * (v - corner).dot(along);
719 let face_on = |model: &Model, inward: Vector| -> OgeomResult<Shape> {
720 for face in ogeom_topo::explore_unique(model, &rounded, ShapeType::Face)? {
721 let Some(data) = model.node(&face).and_then(|n| n.data().as_face()) else {
722 continue;
723 };
724 if !matches!(
725 model.geometry().surface(data.surface),
726 Some(ogeom_geom::SurfaceGeometry::Plane(_))
727 ) {
728 continue;
729 }
730 let (origin, outward) = ogeom_algo::face_normal(model, &face, tol)?;
731 if (corner - origin).dot(outward).abs() <= tol.confusion() * 100.0
732 && outward.cross(inward).magnitude() < tol.angular() * 10.0
733 && outward.dot(inward) < 0.0
734 {
735 return Ok(face);
736 }
737 }
738 ogeom_bail!(
739 Construction,
740 "the ridge's host plane is no longer a face of the solid"
741 )
742 };
743 let faces = [face_on(model, m[a])?, face_on(model, m[c])?];
744 let seat = crate::support::Seat {
745 start: on_crease(v1),
746 end: on_crease(v2),
747 along,
748 normals: [-m[a], -m[c]],
749 faces,
750 convex: true,
751 };
752 let cut = crate::fillet::seated_fillet(model, &rounded, &seat, radius, None, tol)
753 .map_err(|e| ogeom_core::ogeom_err!(NotDone, "the ridge's flush fillet failed: {e}"))?;
754 history = history.then(&cut.history);
755 rounded = cut.shape;
756 }
757 history.modify(vertex, rounded.clone());
758 Ok(Built {
759 shape: rounded,
760 history,
761 })
762}
763
764fn convex_block(
773 model: &mut Model,
774 walls: &[(Vector, Point)],
775 tol: Tolerances,
776) -> OgeomResult<Shape> {
777 let slack = tol.confusion() * 100.0;
778 let n = walls.len();
779 let mut points: Vec<Point> = Vec::new();
780 for i in 0..n {
781 for j in i + 1..n {
782 for k in j + 1..n {
783 let Some(p) = planes_meet(&walls[i], &walls[j], &walls[k]) else {
784 continue;
785 };
786 if walls
787 .iter()
788 .any(|(normal, on)| normal.dot(p - *on) < -slack)
789 {
790 continue;
791 }
792 if points.iter().any(|q| q.distance(p) <= slack) {
793 continue;
794 }
795 points.push(p);
796 }
797 }
798 }
799 let mut rings: Vec<Vec<usize>> = Vec::new();
800 for (normal, on) in walls {
801 let mine: Vec<usize> = (0..points.len())
802 .filter(|&i| normal.dot(points[i] - *on).abs() <= slack)
803 .collect();
804 if mine.len() < 3 {
805 continue;
806 }
807 let centroid = mine
808 .iter()
809 .fold(Vector::ZERO, |acc, &i| acc + (points[i] - Point::ORIGIN))
810 * (1.0 / f64::from(u32::try_from(mine.len()).unwrap_or(u32::MAX)));
811 let centroid = Point::ORIGIN + centroid;
812 let axis = normal.normalized(tol)?;
813 let first = (points[mine[0]] - centroid).normalized(tol)?;
814 let second = axis.cross(first);
815 let mut ordered: Vec<(f64, usize)> = mine
816 .iter()
817 .map(|&i| {
818 let v = points[i] - centroid;
819 (v.dot(second).atan2(v.dot(first)), i)
820 })
821 .collect();
822 ordered.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(core::cmp::Ordering::Equal));
823 rings.push(ordered.into_iter().map(|(_, i)| i).collect());
824 }
825 Ok(ogeom_algo::make_polyhedron(model, &points, &rings, tol)?.shape)
826}
827
828fn planes_meet(a: &(Vector, Point), b: &(Vector, Point), c: &(Vector, Point)) -> Option<Point> {
830 let m = [a.0, b.0, c.0];
831 let rhs = [
832 a.0.dot(a.1 - Point::ORIGIN),
833 b.0.dot(b.1 - Point::ORIGIN),
834 c.0.dot(c.1 - Point::ORIGIN),
835 ];
836 let x = solve3(&m, rhs)?;
837 Some(Point::ORIGIN + x)
838}
839
840fn solve3(m: &[Vector; 3], rhs: [f64; 3]) -> Option<Vector> {
842 let a: [[f64; 3]; 3] = std::array::from_fn(|i| [m[i].x, m[i].y, m[i].z]);
843 let det3 = |a: &[[f64; 3]; 3]| -> f64 {
844 a[0][0].mul_add(
845 a[1][1].mul_add(a[2][2], -(a[1][2] * a[2][1])),
846 -a[0][1].mul_add(
847 a[1][0].mul_add(a[2][2], -(a[1][2] * a[2][0])),
848 -(a[0][2] * a[1][0].mul_add(a[2][1], -(a[1][1] * a[2][0]))),
849 ),
850 )
851 };
852 let det = det3(&a);
853 if !det.is_finite() || det.abs() <= 1e-12 {
854 return None;
855 }
856 let mut x = [0.0_f64; 3];
857 for (k, xk) in x.iter_mut().enumerate() {
858 let mut ak = a;
859 for (row, r) in ak.iter_mut().zip(rhs) {
860 row[k] = r;
861 }
862 *xk = det3(&ak) / det;
863 }
864 Some(Vector::new(x[0], x[1], x[2]))
865}
866
867fn ball_centre(corner: Point, m: &[Vector; 3], radius: f64) -> Option<Point> {
870 solve3(m, [radius; 3]).map(|x| corner + x)
871}
872
873#[allow(clippy::too_many_arguments, reason = "one construction, all its data")]
884fn ball_block(
885 model: &mut Model,
886 walls: &[(Vector, Point)],
887 corner: Point,
888 far: Point,
889 hosts: &[Vector],
890 directions: &[Vector],
891 is_ridge: &[bool],
892 radius: f64,
893 tol: Tolerances,
894) -> OgeomResult<Built> {
895 let n = directions.len();
896 let mut last: Option<ogeom_core::OgeomError> = None;
897 let touches: Vec<Vector> = hosts.iter().map(|m| -*m).collect();
913 let inside_patch = |direction: Vector| -> bool {
914 directions
915 .iter()
916 .all(|e| e.dot(direction) <= tol.angular() * 10.0)
917 };
918 let mut scored: Vec<(f64, Vector, Vector)> = Vec::new();
919 for t in 0..n {
920 if !is_ridge[t] {
921 continue;
922 }
923 for pole in [directions[t], -directions[t]] {
924 if inside_patch(pole) || inside_patch(-pole) {
925 continue;
926 }
927 let flat = |v: Vector| v - pole * v.dot(pole);
928 let mean = touches.iter().fold(Vector::ZERO, |acc, t| acc + *t);
929 for seam in [flat(-mean), flat(far - corner)] {
930 if seam.magnitude() <= tol.angular() * 10.0 {
931 continue;
932 }
933 let x = seam / seam.magnitude();
934 let y = pole.cross(x);
935 let clearance = touches
936 .iter()
937 .map(|t| t.dot(y).atan2(t.dot(x)).abs())
938 .fold(f64::INFINITY, f64::min);
939 scored.push((clearance, pole, seam));
940 }
941 }
942 }
943 scored.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(core::cmp::Ordering::Equal));
944 if std::env::var_os("OGEOM_DEBUG_CORNER").is_some() {
945 for (clearance, pole, seam) in &scored {
946 eprintln!("FRAME clearance {clearance:.3} pole {pole:?} seam {seam:?}");
947 }
948 }
949 let mut frames: Vec<(Vector, Vector)> = scored.into_iter().map(|(_, p, s)| (p, s)).collect();
950 let pole_host = |index: usize| -> usize {
953 let (start, reverse) = (index % n, index >= n);
954 if reverse {
955 (start + 2 * n - 1) % n
956 } else {
957 start
958 }
959 };
960 let clean = |index: usize| -> bool {
961 let h = pole_host(index);
962 !is_ridge[h] && !is_ridge[(h + 1) % n]
963 };
964 let mut order: Vec<usize> = (0..2 * n).collect();
965 order.sort_by_key(|&index| !clean(index));
966 frames.extend(
967 order
968 .iter()
969 .map(|&index| (hosts[pole_host(index)], directions[index % n])),
970 );
971 for (index, (pole, seam)) in frames.into_iter().enumerate() {
972 model.begin_operation();
973 let attempt = (|| -> OgeomResult<Built> {
974 let block = convex_block(model, walls, tol)?;
975 let block_bound = ogeom_algo::shape_bounds(model, &block, tol)?;
976 let ball_frame = Frame::new(
977 far,
978 Direction::new(pole, tol)?,
979 Direction::new(seam, tol)?,
980 tol,
981 )?;
982 let ball = ogeom_algo::make_sphere(model, ball_frame, radius, tol)?.shape;
983 let tool = ogeom_bool::cut(model, &block, &ball, tol)?;
984 let tool_bound = ogeom_algo::shape_bounds(model, &tool.shape, tol)?;
985 let reach = tol.confusion() * 1e3;
986 let (Some(block_lo), Some(block_hi), Some(tool_lo), Some(tool_hi)) = (
987 block_bound.low(),
988 block_bound.high(),
989 tool_bound.low(),
990 tool_bound.high(),
991 ) else {
992 ogeom_bail!(NotDone, "the ball's cut left no tool");
993 };
994 if tool_lo.x < block_lo.x - reach
995 || tool_lo.y < block_lo.y - reach
996 || tool_lo.z < block_lo.z - reach
997 || tool_hi.x > block_hi.x + reach
998 || tool_hi.y > block_hi.y + reach
999 || tool_hi.z > block_hi.z + reach
1000 {
1001 ogeom_bail!(
1002 NotDone,
1003 "the ball's cut left a tool reaching past its own block"
1004 );
1005 }
1006 Ok(tool)
1007 })();
1008 match attempt {
1009 Ok(tool) => {
1010 if std::env::var_os("OGEOM_DEBUG_CORNER").is_some() {
1011 eprintln!("BALL frame {index} closed");
1012 }
1013 return Ok(tool);
1014 }
1015 Err(err) => {
1016 if std::env::var_os("OGEOM_DEBUG_CORNER").is_some() {
1017 eprintln!("BALL frame {index} pole {pole:?} seam {seam:?}: {err}");
1018 }
1019 last = Some(err);
1020 }
1021 }
1022 }
1023 ogeom_bail!(
1024 NotDone,
1025 "the corner tool's block closed on none of its {} frames; the last said: {}",
1026 2 * n,
1027 last.map_or_else(String::new, |e| e.to_string())
1028 )
1029}
1030
1031#[cfg(test)]
1032mod tests {
1033 #![allow(clippy::unwrap_used, reason = "test code")]
1034 use super::round_vertex_with;
1035 use ogeom_core::Tolerances;
1036 use ogeom_math::{Point, Vector};
1037 use ogeom_topo::{Model, Shape, ShapeType};
1038
1039 const T: Tolerances = Tolerances::millimetres();
1040
1041 fn vertex_at(model: &Model, shape: &Shape, at: Point) -> Shape {
1042 ogeom_topo::explore_unique(model, shape, ShapeType::Vertex)
1043 .unwrap()
1044 .into_iter()
1045 .find(|v| {
1046 model
1047 .node(v)
1048 .and_then(|n| n.data().as_vertex())
1049 .is_some_and(|d| d.point.distance(at) < 1e-9)
1050 })
1051 .unwrap()
1052 }
1053
1054 fn volume(model: &Model, shape: &Shape) -> f64 {
1055 ogeom_algo::volume_properties(model, shape, ogeom_mesh::Deflection::default(), T)
1056 .unwrap()
1057 .mass
1058 }
1059
1060 fn every_labelling_agrees(model: &mut Model, solid: &Shape, vertex: &Shape, count: usize) {
1063 let mut volumes = Vec::with_capacity(count);
1064 for index in 0..count {
1065 let rounded = round_vertex_with(model, solid, vertex, 2.0, Some(index), T)
1066 .unwrap_or_else(|e| panic!("labelling {index} did not close: {e}"));
1067 let diagnosis = ogeom_algo::check(model, &rounded.shape, T).unwrap();
1068 assert!(
1069 diagnosis.is_valid(),
1070 "labelling {index}: {:?}",
1071 diagnosis.problems
1072 );
1073 volumes.push(volume(model, &rounded.shape));
1074 }
1075 for (index, v) in volumes.iter().enumerate() {
1080 assert!(
1081 (v - volumes[0]).abs() < 1e-7 * volumes[0],
1082 "labelling {index} builds {v}, labelling 0 {}",
1083 volumes[0]
1084 );
1085 }
1086 }
1087
1088 #[test]
1089 fn an_oblique_corner_rounds_the_same_under_every_labelling() {
1090 let mut model = Model::new();
1091 let (a, b, c) = (
1092 Vector::new(20.0, 0.0, 0.0),
1093 Vector::new(6.0, 20.0, 0.0),
1094 Vector::new(3.6, 6.0, 20.0),
1095 );
1096 let block = ogeom_algo::make_parallelepiped(&mut model, Point::ORIGIN, [a, b, c], T)
1097 .unwrap()
1098 .shape;
1099 let vertex = vertex_at(&model, &block, Point::ORIGIN);
1100 every_labelling_agrees(&mut model, &block, &vertex, 6);
1101 }
1102
1103 #[test]
1104 fn a_square_pyramid_apex_rounds_the_same_under_every_labelling() {
1105 let mut model = Model::new();
1106 let apex = Point::new(0.0, 0.0, 15.0);
1107 let base = [
1108 Point::new(-10.0, -10.0, 0.0),
1109 Point::new(10.0, -10.0, 0.0),
1110 Point::new(10.0, 10.0, 0.0),
1111 Point::new(-10.0, 10.0, 0.0),
1112 ];
1113 let mut faces = Vec::new();
1115 for k in 0..4 {
1116 let (p, q) = (base[k], base[(k + 1) % 4]);
1117 let outward = (q - p).cross(apex - p);
1118 faces.push(crate::support::planar_face(&mut model, &[p, q, apex], outward, T).unwrap());
1119 }
1120 let reversed: Vec<Point> = base.iter().rev().copied().collect();
1121 faces.push(crate::support::planar_face(&mut model, &reversed, -Vector::Z, T).unwrap());
1122 let sewn = ogeom_algo::sew(&mut model, &faces, T).unwrap();
1123 let pyramid = ogeom_algo::make_solid(&mut model, &sewn.shells[..1])
1124 .unwrap()
1125 .shape;
1126 let vertex = vertex_at(&model, &pyramid, apex);
1127 every_labelling_agrees(&mut model, &pyramid, &vertex, 8);
1128 }
1129}