1use ogeom_algo::Built;
12use ogeom_core::{OgeomError, OgeomResult, Tolerances, ogeom_bail};
13use ogeom_math::{Direction, Frame, Point, Vector};
14use ogeom_topo::{Model, Shape, ShapeType};
15
16pub fn round_vertex(
69 model: &mut Model,
70 solid: &Shape,
71 vertex: &Shape,
72 radius: f64,
73 tol: Tolerances,
74) -> OgeomResult<Built> {
75 ogeom_algo::on_own_nodes(
76 model,
77 solid,
78 std::slice::from_ref(vertex),
79 |model, solid, parts| round_vertex_own(model, solid, &parts[0], radius, tol),
80 )
81}
82
83fn round_vertex_own(
85 model: &mut Model,
86 solid: &Shape,
87 vertex: &Shape,
88 radius: f64,
89 tol: Tolerances,
90) -> OgeomResult<Built> {
91 round_vertex_with(model, solid, vertex, radius, None, tol)
92}
93
94pub(crate) fn round_vertex_with(
98 model: &mut Model,
99 solid: &Shape,
100 vertex: &Shape,
101 radius: f64,
102 forced: Option<usize>,
103 tol: Tolerances,
104) -> OgeomResult<Built> {
105 match corner_at(model, solid, vertex, radius, tol)? {
106 Corner::Curved(corner) => {
107 crate::corner_curved::curved_corner(model, solid, vertex, corner, radius, tol)
108 }
109 Corner::Setback(corner, m) => setback_corner(model, solid, vertex, corner, &m, radius, tol),
110 Corner::OneBall(one) => one_ball_corner(model, solid, vertex, &one, radius, forced, tol),
111 }
112}
113
114enum Corner {
116 Curved(Point),
119 Setback(Point, Vec<Vector>),
122 OneBall(OneBall),
124}
125
126struct OneBall {
128 corner: Point,
130 far: Point,
132 m: Vec<Vector>,
134 ring: Vec<usize>,
136 d: Vec<Direction>,
139 probes: Vec<Point>,
141}
142
143impl OneBall {
144 fn block_bounds(&self, radius: f64) -> ogeom_math::Aabb {
148 let mut points = vec![self.corner, self.far];
149 for (dk, mk) in self.d.iter().zip(&self.m) {
150 points.push(self.foot(dk.vector()));
151 points.push(self.far - *mk * radius);
152 }
153 ogeom_math::Aabb::of_points(&points)
154 }
155
156 fn foot(&self, along: Vector) -> Point {
159 self.corner + along * (self.far - self.corner).dot(along)
160 }
161}
162
163fn corner_at(
166 model: &Model,
167 solid: &Shape,
168 vertex: &Shape,
169 radius: f64,
170 tol: Tolerances,
171) -> OgeomResult<Corner> {
172 if model.kind_of(vertex)? != ShapeType::Vertex {
173 ogeom_bail!(Construction, "round_vertex rounds a vertex");
174 }
175 if radius <= tol.confusion() {
176 ogeom_bail!(Construction, "a blend radius must be a positive distance");
177 }
178 let Some(raw) = model
179 .node(vertex)
180 .and_then(|n| n.data().as_vertex().map(|d| d.point))
181 else {
182 ogeom_bail!(Construction, "the vertex holds no point");
183 };
184 let corner = vertex.transform(model.datums())?.apply(raw);
188
189 let mut m: Vec<Vector> = Vec::new();
199 for face in ogeom_topo::explore_unique(model, solid, ShapeType::Face)? {
200 let Some(data) = model.node(&face).and_then(|n| n.data().as_face()) else {
201 continue;
202 };
203 let Some(surface) = model.geometry().surface(data.surface) else {
204 continue;
205 };
206 if !matches!(surface, ogeom_geom::SurfaceGeometry::Plane(_)) {
207 continue;
208 }
209 let (origin, outward) = ogeom_algo::face_normal(model, &face, tol)?;
210 if (corner - origin).dot(outward).abs() > tol.confusion() * 100.0 {
211 continue;
212 }
213 if m.iter()
215 .any(|n| n.cross(outward).magnitude() < tol.angular() * 10.0)
216 {
217 continue;
218 }
219 m.push(-outward);
220 }
221 let n = m.len();
222 if n < 3 {
223 return Ok(Corner::Curved(corner));
226 }
227 let centre_for = |m: &[Vector]| -> Option<(Point, f64)> {
233 let mut a = [[0.0_f64; 3]; 3];
239 let mut b = [0.0_f64; 3];
240 if m.len() == 3 {
241 for (i, mk) in m.iter().enumerate() {
242 a[i] = [mk.x, mk.y, mk.z];
243 b[i] = radius;
244 }
245 } else {
246 for mk in m {
247 let v = [mk.x, mk.y, mk.z];
248 for i in 0..3 {
249 b[i] += radius * v[i];
250 for j in 0..3 {
251 a[i][j] += v[i] * v[j];
252 }
253 }
254 }
255 }
256 let det3 = |a: &[[f64; 3]; 3]| -> f64 {
257 a[0][0].mul_add(
258 a[1][1].mul_add(a[2][2], -(a[1][2] * a[2][1])),
259 -a[0][1].mul_add(
260 a[1][0].mul_add(a[2][2], -(a[1][2] * a[2][0])),
261 -(a[0][2] * a[1][0].mul_add(a[2][1], -(a[1][1] * a[2][0]))),
262 ),
263 )
264 };
265 let det = det3(&a);
266 if !det.is_finite() || det.abs() <= 1e-12 {
267 return None;
268 }
269 let mut x = [0.0_f64; 3];
270 for (k, xk) in x.iter_mut().enumerate() {
271 let mut ak = a;
272 for (row, bi) in ak.iter_mut().zip(b) {
273 row[k] = bi;
274 }
275 *xk = det3(&ak) / det;
276 }
277 let centre = corner + Vector::new(x[0], x[1], x[2]);
278 let residual = m
279 .iter()
280 .map(|mk| (mk.dot(centre - corner) - radius).abs())
281 .fold(0.0, f64::max);
282 Some((centre, residual))
283 };
284 let Some((far, residual)) = centre_for(&m) else {
285 ogeom_bail!(
286 Construction,
287 "the {n} planes through this vertex do not span a corner"
288 );
289 };
290 if residual > tol.confusion() * 10.0 {
291 return Ok(Corner::Setback(corner, m));
296 }
297 let boundary = ogeom_algo::SolidBoundary::of(model, solid, tol.confusion() * 1e4, tol)?;
301 let mut probes = vec![far];
302 if boundary.holds(model, far, tol)? != ogeom_algo::Containment::In {
303 ogeom_bail!(
304 Construction,
305 "no material a radius in from every face at this vertex; a \
306 concave vertex gains a ball instead of shedding one, and this \
307 tool cannot round it"
308 );
309 }
310 let mut edges: Vec<(usize, usize, Direction)> = Vec::new();
315 for i in 0..n {
316 for j in i + 1..n {
317 let cross = m[i].cross(m[j]);
318 let length = cross.magnitude();
319 if length <= tol.angular() * 10.0 {
320 continue;
321 }
322 for sign in [1.0, -1.0] {
323 let d = cross * (sign / length);
324 let inside = (0..n)
325 .filter(|&k| k != i && k != j)
326 .all(|k| m[k].dot(d) >= -tol.angular() * 10.0);
327 if inside {
328 edges.push((i, j, Direction::new(d, tol)?));
329 break;
330 }
331 }
332 }
333 }
334 if edges.len() != n {
335 ogeom_bail!(
336 Construction,
337 "the {n} planes through this vertex meet along {} edges, not {n}; \
338 the corner is not the simple convex one this tool speaks",
339 edges.len()
340 );
341 }
342 {
349 let boundary = ogeom_algo::SolidBoundary::of(model, solid, tol.confusion() * 1e4, tol)?;
350 let step = (radius * 1e-2).max(tol.confusion() * 1e3);
351 for &(i, j, d) in &edges {
352 for (k, other) in [(i, j), (j, i)] {
353 let off = corner + (d.vector() - m[k] + m[other]) * step;
354 probes.push(off);
355 if boundary.holds(model, off, tol)? == ogeom_algo::Containment::In {
356 ogeom_bail!(
357 Construction,
358 "material stands outside a plane through this vertex beside \
359 one of its edges; the corner is re-entrant, not the simple \
360 convex one this tool speaks"
361 );
362 }
363 }
364 }
365 }
366 let mut ring: Vec<usize> = vec![0];
367 let mut last_edge: Option<usize> = None;
368 loop {
369 let here = *ring.last().unwrap_or(&0);
370 let Some((e, other)) = edges.iter().enumerate().find_map(|(e, &(i, j, _))| {
371 if Some(e) == last_edge {
372 None
373 } else if i == here {
374 Some((e, j))
375 } else if j == here {
376 Some((e, i))
377 } else {
378 None
379 }
380 }) else {
381 ogeom_bail!(
382 Construction,
383 "the planes through this vertex do not chain into a ring round it"
384 );
385 };
386 last_edge = Some(e);
387 if other == 0 {
388 break;
389 }
390 if ring.contains(&other) || ring.len() >= n {
391 ogeom_bail!(
392 Construction,
393 "the planes through this vertex do not chain into a ring round it"
394 );
395 }
396 ring.push(other);
397 }
398 if ring.len() != n {
399 ogeom_bail!(
400 Construction,
401 "the planes through this vertex do not chain into a ring round it"
402 );
403 }
404 let d: Vec<Direction> = (0..n)
406 .map(|k| {
407 let (p, q) = (ring[k], ring[(k + 1) % n]);
408 edges
409 .iter()
410 .find(|&&(i, j, _)| (i == p && j == q) || (i == q && j == p))
411 .map(|&(_, _, dir)| dir)
412 })
413 .collect::<Option<_>>()
414 .ok_or_else(|| {
415 ogeom_core::ogeom_err!(Construction, "the corner's edges lost their ring")
416 })?;
417 Ok(Corner::OneBall(OneBall {
418 corner,
419 far,
420 m,
421 ring,
422 d,
423 probes,
424 }))
425}
426
427pub(crate) struct CornerFrame {
433 pub corner: Point,
434 pub far: Point,
435 pub m: Vec<Vector>,
436 pub ring: Vec<usize>,
437 pub d: Vec<Direction>,
438}
439
440pub(crate) fn corner_frame(
443 model: &Model,
444 solid: &Shape,
445 vertex: &Shape,
446 radius: f64,
447 tol: Tolerances,
448) -> OgeomResult<CornerFrame> {
449 match corner_at(model, solid, vertex, radius, tol)? {
450 Corner::OneBall(one) => Ok(CornerFrame {
451 corner: one.corner,
452 far: one.far,
453 m: one.m,
454 ring: one.ring,
455 d: one.d,
456 }),
457 Corner::Curved(_) => ogeom_bail!(
458 Construction,
459 "a setback corner where a curved face meets the vertex is still \
460 owed; see docs/CAPABILITIES.md, fillet.setback-corners"
461 ),
462 Corner::Setback(..) => ogeom_bail!(
463 Construction,
464 "a setback corner whose planes share no tangent ball is still \
465 owed; see docs/CAPABILITIES.md, fillet.setback-corners"
466 ),
467 }
468}
469
470fn one_ball_corner(
472 model: &mut Model,
473 solid: &Shape,
474 vertex: &Shape,
475 one: &OneBall,
476 radius: f64,
477 forced: Option<usize>,
478 tol: Tolerances,
479) -> OgeomResult<Built> {
480 let OneBall {
481 corner,
482 far,
483 ref m,
484 ref ring,
485 ref d,
486 ..
487 } = *one;
488 let n = m.len();
489
490 let carry = |model: &mut Model, from_vertex: bool| -> OgeomResult<Built> {
496 let mut carried = Built::from_nothing(solid.clone());
497 for k in 0..n {
498 let planes = [m[ring[k]], m[ring[(k + 1) % n]]];
499 if let Some(band) = band_gap(
500 model,
501 &carried.shape,
502 corner,
503 far,
504 d,
505 k,
506 planes,
507 radius,
508 from_vertex,
509 tol,
510 )? {
511 carried = Built {
512 shape: band.shape,
513 history: carried.history.then(&band.history),
514 };
515 }
516 }
517 Ok(carried)
518 };
519
520 let attempt = |model: &mut Model, held: &Built, start: usize, reverse| -> OgeomResult<Built> {
527 let tool = one_ball_tool(model, one, radius, start, reverse, tol)?;
528 let rounded = ogeom_bool::cut(model, &held.shape, &tool.shape, tol)?;
529 Ok(Built {
530 shape: rounded.shape,
531 history: held.history.then(&tool.history).then(&rounded.history),
532 })
533 };
534 let mut outcome: Option<Built> = None;
535 let mut last: Option<ogeom_core::OgeomError> = None;
536 let mut tried: Vec<Shape> = Vec::new();
537 'carries: for from_vertex in [Some(true), Some(false), None] {
538 let carried = match from_vertex {
539 Some(from_vertex) => match carry(model, from_vertex) {
540 Ok(carried) => carried,
541 Err(err @ (OgeomError::NotDone(_) | OgeomError::Construction(_))) => {
542 last = Some(err);
543 continue;
544 }
545 Err(err) => return Err(err),
546 },
547 None => Built::from_nothing(solid.clone()),
548 };
549 if tried.iter().any(|held| held.is_same(&carried.shape)) {
551 continue;
552 }
553 tried.push(carried.shape.clone());
554 let labellings = (0..2 * n).map(|index| (index % n, index >= n));
555 for (index, (start, reverse)) in labellings.enumerate() {
556 if forced.is_some_and(|f| f != index) {
557 continue;
558 }
559 match attempt(model, &carried, start, reverse) {
560 Ok(built) => {
561 outcome = Some(built);
562 break 'carries;
563 }
564 Err(err) => last = Some(err),
565 }
566 }
567 }
568 let Some(rounded) = outcome else {
569 ogeom_bail!(
570 NotDone,
571 "the corner tool's cut closed on none of the corner's {} \
572 labellings; the last said: {}",
573 2 * n,
574 last.map_or_else(String::new, |e| e.to_string())
575 );
576 };
577 let mut built = rounded;
578 built.history.modify(vertex, built.shape.clone());
579 Ok(built)
580}
581
582pub(crate) fn round_vertices_together(
616 model: &mut Model,
617 solid: &Shape,
618 vertices: &[Shape],
619 radius: f64,
620 tol: Tolerances,
621) -> OgeomResult<Option<Built>> {
622 let mark = model.note_widened();
623 match vertices_together(model, solid, vertices, radius, tol) {
624 Ok(Some(built)) => {
625 model.keep_widened(mark);
626 Ok(Some(built))
627 }
628 Err(OgeomError::Cancelled) => {
629 model.keep_widened(mark);
630 Err(OgeomError::Cancelled)
631 }
632 Ok(None) | Err(_) => {
633 model.undo_widened(mark);
634 Ok(None)
635 }
636 }
637}
638
639fn vertices_together(
642 model: &mut Model,
643 solid: &Shape,
644 vertices: &[Shape],
645 radius: f64,
646 tol: Tolerances,
647) -> OgeomResult<Option<Built>> {
648 let margin = tol.confusion() * 1e4;
649 let faces = ogeom_topo::explore_unique(model, solid, ShapeType::Face)?;
650 let planar = |face: &Shape| {
651 model
652 .node(face)
653 .and_then(|n| n.data().as_face())
654 .is_some_and(|data| {
655 matches!(
656 model.geometry().surface(data.surface),
657 Some(ogeom_geom::SurfaceGeometry::Plane(_))
658 )
659 })
660 };
661 if !faces.iter().all(planar) {
662 return Ok(None);
663 }
664 let mut corners: Vec<OneBall> = Vec::with_capacity(vertices.len());
665 for vertex in vertices {
666 let Corner::OneBall(one) = corner_at(model, solid, vertex, radius, tol)? else {
667 return Ok(None);
668 };
669 let mut at_vertex: Vec<Vector> = Vec::new();
671 for face in &faces {
672 let holds = ogeom_topo::explore_unique(model, face, ShapeType::Vertex)?
673 .iter()
674 .any(|v| v.is_same(vertex));
675 if holds {
676 at_vertex.push(ogeom_algo::face_normal(model, face, tol)?.1);
677 }
678 }
679 let held = one.m.iter().all(|mk| {
680 at_vertex
681 .iter()
682 .any(|n| n.cross(*mk).magnitude() < tol.angular() * 10.0)
683 });
684 if !held {
685 return Ok(None);
686 }
687 corners.push(one);
688 }
689 let bounds: Vec<ogeom_math::Aabb> = corners.iter().map(|c| c.block_bounds(radius)).collect();
690 for (i, one) in corners.iter().enumerate() {
691 for (j, other) in corners.iter().enumerate() {
692 if i == j {
693 continue;
694 }
695 let apart = one.far.distance(other.far) - radius * 2.0;
696 let clear = apart > radius * ogeom_bool::POLE_TOUCHING + margin
697 && bounds[i].distance_to_box(&bounds[j]) > margin
698 && other
699 .probes
700 .iter()
701 .all(|p| bounds[i].distance_to(*p) > margin)
702 && one.d.iter().all(|dk| {
703 let along = dk.vector();
704 (other.corner - one.foot(along)).dot(along).abs() > margin
705 });
706 if !clear {
707 return Ok(None);
708 }
709 }
710 }
711 let mut tools = Vec::with_capacity(corners.len());
712 let mut history = ogeom_algo::History::new();
713 for one in &corners {
714 let n = one.m.len();
715 let tool = (0..2 * n).find_map(|index| {
716 match one_ball_tool(model, one, radius, index % n, index >= n, tol) {
717 Ok(tool) => Some(Ok(tool)),
718 Err(OgeomError::Cancelled) => Some(Err(OgeomError::Cancelled)),
719 Err(_) => None,
720 }
721 });
722 let Some(tool) = tool.transpose()? else {
723 return Ok(None);
724 };
725 history = history.then(&tool.history);
726 tools.push(tool.shape);
727 }
728 let compound = model.add_compound(&tools)?;
729 let rounded = ogeom_bool::cut(model, solid, &compound, tol)?;
730 let mut history = history.then(&rounded.history);
731 for vertex in vertices {
732 history.modify(vertex, rounded.shape.clone());
733 }
734 Ok(Some(Built {
735 shape: rounded.shape,
736 history,
737 }))
738}
739
740fn one_ball_tool(
761 model: &mut Model,
762 one: &OneBall,
763 radius: f64,
764 start: usize,
765 reverse: bool,
766 tol: Tolerances,
767) -> OgeomResult<Built> {
768 let OneBall {
769 corner,
770 far,
771 ref m,
772 ref ring,
773 ref d,
774 ..
775 } = *one;
776 let n = m.len();
777 let inward_of = |k: usize| m[ring[k % n]];
778 let edge_at = |t: usize| -> usize {
780 if reverse {
781 (start + n - t % n) % n
782 } else {
783 (start + t) % n
784 }
785 };
786 let dl: Vec<Direction> = (0..n).map(|t| d[edge_at(t)]).collect();
787 let ml: Vec<Vector> = (0..n)
788 .map(|t| {
789 if reverse {
792 inward_of((start + n - t % n) % n)
793 } else {
794 inward_of(edge_at(t) + 1)
795 }
796 })
797 .collect();
798 let along = |t: usize| -> Point { one.foot(dl[t].vector()) };
799 let touch = |t: usize| -> Point { far - ml[t] * radius };
800 if std::env::var_os("OGEOM_DEBUG_CORNER").is_some() {
801 eprintln!(
802 "CORNER attempt start {start} reverse {reverse} far {far:?} d {:?} m {ml:?}",
803 dl.iter().map(|x| x.vector()).collect::<Vec<_>>()
804 );
805 }
806 model.begin_operation();
807 let mut points: Vec<Point> = vec![corner];
808 points.extend((0..n).map(along));
809 points.extend((0..n).map(touch));
810 points.push(far);
811 let mut rings: Vec<Vec<usize>> = Vec::with_capacity(2 * n);
812 for t in 0..n {
813 rings.push(vec![0, 1 + t, 1 + n + t, 1 + (t + 1) % n]);
816 rings.push(vec![1 + t, 1 + n + (t + n - 1) % n, 1 + 2 * n, 1 + n + t]);
819 }
820 let block = ogeom_algo::make_polyhedron(model, &points, &rings, tol)?.shape;
821 let ball_frame = Frame::new(far, Direction::new(ml[0], tol)?, dl[0], tol)?;
822 let ball = ogeom_algo::make_sphere(model, ball_frame, radius, tol)?.shape;
823 let tool = ogeom_bool::cut(model, &block, &ball, tol)?;
824 if std::env::var_os("OGEOM_DEBUG_CORNER").is_some() {
825 let faces = ogeom_topo::explore_unique(model, &tool.shape, ShapeType::Face)?;
826 let kinds: Vec<String> = faces
827 .iter()
828 .map(|f| {
829 let kind = model
830 .node(f)
831 .and_then(|n| n.data().as_face())
832 .and_then(|d| model.geometry().surface(d.surface))
833 .map_or("?", |sg| match sg {
834 ogeom_geom::SurfaceGeometry::Plane(_) => "plane",
835 ogeom_geom::SurfaceGeometry::Sphere(_) => "sphere",
836 _ => "other",
837 });
838 let edges =
839 ogeom_topo::explore_unique(model, f, ShapeType::Edge).map_or(0, |e| e.len());
840 format!("{kind}/{edges}")
841 })
842 .collect();
843 eprintln!(
844 "CORNER tool for start {start} reverse {reverse}: {} faces {kinds:?}",
845 faces.len()
846 );
847 }
848 Ok(tool)
849}
850
851#[allow(clippy::too_many_arguments, reason = "one question, all its data")]
874fn band_gap(
875 model: &mut Model,
876 solid: &Shape,
877 corner: Point,
878 far: Point,
879 axes: &[Direction],
880 k: usize,
881 planes: [Vector; 2],
882 radius: f64,
883 from_vertex: bool,
884 tol: Tolerances,
885) -> OgeomResult<Option<Built>> {
886 let along = axes[k].vector();
887 let on_line = tol.confusion() * 100.0;
888 let point_of = |model: &Model, v: &Shape| -> OgeomResult<Option<Point>> {
889 let Some(p) = model
890 .node(v)
891 .and_then(|n| n.data().as_vertex().map(|d| d.point))
892 else {
893 return Ok(None);
894 };
895 Ok(Some(v.transform(model.datums())?.apply(p)))
896 };
897 let mut ends: Vec<Point> = Vec::new();
900 for face in ogeom_topo::explore_unique(model, solid, ShapeType::Face)? {
901 if !on_band(model, &face, far, along, radius, tol) {
902 continue;
903 }
904 for v in ogeom_topo::explore_unique(model, &face, ShapeType::Vertex)? {
905 if let Some(p) = point_of(model, &v)? {
906 ends.push(p);
907 }
908 }
909 }
910 let Some(start) = ends
911 .iter()
912 .map(|p| (*p - far).dot(along))
913 .min_by(f64::total_cmp)
914 else {
915 return Ok(None);
916 };
917 if start <= tol.confusion() {
918 return Ok(None);
919 }
920 let from = (corner - far).dot(along);
924 let gap = |p: Point, axis: Vector| {
925 let w = p - far;
926 ((w - axis * w.dot(axis)).magnitude() - radius).abs()
927 };
928 let crease_end = corner + along * (start - from);
929 if !axes
930 .iter()
931 .enumerate()
932 .any(|(j, axis)| j != k && gap(crease_end, axis.vector()) <= on_line)
933 {
934 return Ok(None);
935 }
936 for edge in ogeom_topo::explore_unique(model, solid, ShapeType::Edge)? {
946 let Some((a, b)) = ogeom_algo::edge_vertices(model, &edge)? else {
947 continue;
948 };
949 let (Some(pa), Some(pb)) = (point_of(model, &a)?, point_of(model, &b)?) else {
950 continue;
951 };
952 let crease = |p: Point| {
953 let w = p - corner;
954 (w - along * w.dot(along)).magnitude() <= on_line
955 };
956 if !crease(pa) || !crease(pb) {
957 continue;
958 }
959 let reach = |v: &Shape| {
960 model
961 .node(v)
962 .and_then(|n| n.data().as_vertex())
963 .map_or(0.0, |d| d.tolerance.get())
964 };
965 let (reach_a, reach_b) = (reach(&a), reach(&b));
966 let on_band_seam = axes.iter().enumerate().any(|(j, axis)| {
967 j != k && gap(pa, axis.vector()) <= reach_a && gap(pb, axis.vector()) <= reach_b
968 });
969 if on_band_seam {
970 continue;
971 }
972 let (ta, tb) = ((pa - far).dot(along), (pb - far).dot(along));
973 if ta.max(tb) > from + on_line && ta.min(tb) < start - on_line {
974 return Ok(None);
975 }
976 }
977 let face_on = |model: &Model, inward: Vector| -> OgeomResult<Option<Shape>> {
978 for face in ogeom_topo::explore_unique(model, solid, ShapeType::Face)? {
979 let Some(data) = model.node(&face).and_then(|n| n.data().as_face()) else {
980 continue;
981 };
982 if !matches!(
983 model.geometry().surface(data.surface),
984 Some(ogeom_geom::SurfaceGeometry::Plane(_))
985 ) {
986 continue;
987 }
988 let (origin, outward) = ogeom_algo::face_normal(model, &face, tol)?;
989 if (corner - origin).dot(outward).abs() <= on_line
990 && outward.cross(inward).magnitude() < tol.angular() * 10.0
991 && outward.dot(inward) < 0.0
992 {
993 return Ok(Some(face));
994 }
995 }
996 Ok(None)
997 };
998 let (Some(first), Some(second)) = (face_on(model, planes[0])?, face_on(model, planes[1])?)
999 else {
1000 return Ok(None);
1001 };
1002 let foot = corner + along * (far - corner).dot(along);
1003 let seat = crate::support::Seat {
1004 start: if from_vertex { corner } else { foot },
1005 end: foot + along * start,
1006 along,
1007 normals: [-planes[0], -planes[1]],
1008 faces: [first, second],
1009 convex: true,
1010 };
1011 let cut = crate::fillet::seated_fillet(model, solid, &seat, radius, None, &[], tol)?;
1012 let on_axis: Vec<Shape> = ogeom_topo::explore_unique(model, &cut.shape, ShapeType::Face)?
1015 .into_iter()
1016 .filter(|face| on_band(model, face, far, along, radius, tol))
1017 .collect();
1018 let (unified, _) = ogeom_heal::unify_same_domain_around(model, &cut.shape, &on_axis, tol)?;
1019 Ok(Some(Built {
1020 shape: unified.shape,
1021 history: cut.history.then(&unified.history),
1022 }))
1023}
1024
1025fn on_band(
1028 model: &Model,
1029 face: &Shape,
1030 far: Point,
1031 along: Vector,
1032 radius: f64,
1033 tol: Tolerances,
1034) -> bool {
1035 let Some(data) = model.node(face).and_then(|n| n.data().as_face()) else {
1036 return false;
1037 };
1038 let Some(ogeom_geom::SurfaceGeometry::Cylinder(c)) = model.geometry().surface(data.surface)
1039 else {
1040 return false;
1041 };
1042 let frame = c.cylinder().frame();
1043 let axis = frame.z().vector();
1044 let off = far - frame.origin();
1045 (c.cylinder().radius() - radius).abs() <= tol.confusion()
1046 && axis.cross(along).magnitude() <= tol.angular()
1047 && (off - axis * off.dot(axis)).magnitude() <= tol.confusion()
1048}
1049
1050struct TipVertex {
1054 centre: Point,
1055 planes: Vec<usize>,
1056}
1057
1058struct Ridge {
1062 from: usize,
1063 to: usize,
1064 planes: [usize; 2],
1065}
1066
1067fn setback_corner(
1091 model: &mut Model,
1092 solid: &Shape,
1093 vertex: &Shape,
1094 corner: Point,
1095 m: &[Vector],
1096 radius: f64,
1097 tol: Tolerances,
1098) -> OgeomResult<Built> {
1099 let n = m.len();
1100 let slack = tol.confusion() * 10.0;
1101 let mut tips: Vec<TipVertex> = Vec::new();
1104 for i in 0..n {
1105 for j in i + 1..n {
1106 for k in j + 1..n {
1107 let Some(centre) = ball_centre(corner, &[m[i], m[j], m[k]], radius) else {
1108 continue;
1109 };
1110 let feasible = m.iter().all(|mk| mk.dot(centre - corner) >= radius - slack);
1111 if !feasible {
1112 continue;
1113 }
1114 if tips.iter().any(|t| t.centre.distance(centre) <= slack) {
1115 continue;
1116 }
1117 let planes: Vec<usize> = (0..n)
1118 .filter(|&l| (m[l].dot(centre - corner) - radius).abs() <= slack)
1119 .collect();
1120 tips.push(TipVertex { centre, planes });
1121 }
1122 }
1123 }
1124 if std::env::var_os("OGEOM_DEBUG_CORNER").is_some() {
1125 for tip in &tips {
1126 eprintln!("TIP {:?} planes {:?}", tip.centre, tip.planes);
1127 }
1128 }
1129 if tips.is_empty() {
1130 ogeom_bail!(
1131 Construction,
1132 "the {n} planes through this vertex hold no ball a radius in from \
1133 all of them"
1134 );
1135 }
1136 let mut ridges: Vec<Ridge> = Vec::new();
1143 let mut rays: Vec<Vec<(Vector, [usize; 2])>> = vec![Vec::new(); tips.len()];
1144 for (index, tip) in tips.iter().enumerate() {
1145 for (a, b) in tip
1146 .planes
1147 .iter()
1148 .flat_map(|&a| tip.planes.iter().map(move |&b| (a, b)))
1149 {
1150 if a >= b {
1151 continue;
1152 }
1153 let cross = m[a].cross(m[b]);
1154 let length = cross.magnitude();
1155 if length <= tol.angular() * 10.0 {
1156 continue;
1157 }
1158 let Some(direction) = [1.0, -1.0]
1159 .into_iter()
1160 .map(|sign| cross * (sign / length))
1161 .find(|d| {
1162 tip.planes
1163 .iter()
1164 .filter(|&&l| l != a && l != b)
1165 .all(|&l| m[l].dot(*d) >= -tol.angular() * 10.0)
1166 })
1167 else {
1168 continue;
1169 };
1170 let mut nearest: Option<(f64, usize)> = None;
1172 for (l, ml) in m.iter().enumerate() {
1173 if tip.planes.contains(&l) {
1174 continue;
1175 }
1176 let rate = ml.dot(direction);
1177 if rate >= -tol.angular() {
1178 continue;
1179 }
1180 let t = (radius - ml.dot(tip.centre - corner)) / rate;
1181 if t > slack && nearest.is_none_or(|(held, _)| t < held) {
1182 nearest = Some((t, l));
1183 }
1184 }
1185 match nearest {
1186 Some((t, _)) => {
1187 let end = tip.centre + direction * t;
1188 let Some(to) = tips.iter().position(|o| o.centre.distance(end) <= slack) else {
1189 ogeom_bail!(
1190 Construction,
1191 "the ball rolling between two of this vertex's faces meets a \
1192 third where no tip vertex stands"
1193 );
1194 };
1195 if index < to {
1196 ridges.push(Ridge {
1197 from: index,
1198 to,
1199 planes: [a, b],
1200 });
1201 }
1202 }
1203 None => rays[index].push((direction, [a, b])),
1204 }
1205 }
1206 }
1207 let concave = {
1208 let boundary = ogeom_algo::SolidBoundary::of(model, solid, tol.confusion() * 1e4, tol)?;
1209 let mut concave = false;
1210 for tip in &tips {
1211 if boundary.holds(model, tip.centre, tol)? != ogeom_algo::Containment::In {
1212 concave = true;
1213 }
1214 }
1215 concave
1216 };
1217 if concave {
1218 ogeom_bail!(
1219 Construction,
1220 "no material a radius in from every face at this vertex; a \
1221 concave vertex gains a ball instead of shedding one, and this \
1222 tool cannot round it"
1223 );
1224 }
1225
1226 model.begin_operation();
1227 let mut rounded = solid.clone();
1228 let mut history = ogeom_algo::History::new();
1229 for (index, tip) in tips.iter().enumerate() {
1234 let mut edges: Vec<(Vector, [usize; 2])> = rays[index].clone();
1237 for ridge in &ridges {
1238 let other = if ridge.from == index {
1239 ridge.to
1240 } else if ridge.to == index {
1241 ridge.from
1242 } else {
1243 continue;
1244 };
1245 let direction = (tips[other].centre - tip.centre).normalized(tol)?;
1246 edges.push((direction, ridge.planes));
1247 }
1248 let k = tip.planes.len();
1249 if edges.len() != k {
1250 ogeom_bail!(
1251 Construction,
1252 "a tip vertex of this corner touches {k} planes along {} edges, not \
1253 {k}; the corner is not the simple convex one this tool speaks",
1254 edges.len()
1255 );
1256 }
1257 let shared = |x: &[usize; 2], y: &[usize; 2]| -> Option<usize> {
1261 x.iter().copied().find(|p| y.contains(p))
1262 };
1263 let mut ring: Vec<(Vector, [usize; 2])> = vec![edges[0]];
1264 let mut used = vec![false; k];
1265 used[0] = true;
1266 while ring.len() < k {
1267 let last = ring[ring.len() - 1];
1268 let Some(next) = (0..k).find(|&i| {
1269 !used[i]
1270 && shared(&edges[i].1, &last.1).is_some_and(|p| {
1271 ring.len() < 2 || shared(&ring[ring.len() - 2].1, &last.1) != Some(p)
1274 })
1275 }) else {
1276 ogeom_bail!(Construction, "the tip vertex's edges do not chain")
1277 };
1278 used[next] = true;
1279 ring.push(edges[next]);
1280 }
1281 let hosts: Vec<Vector> = (0..k)
1282 .map(|t| {
1283 shared(&ring[t].1, &ring[(t + 1) % k].1)
1284 .map(|p| m[p])
1285 .ok_or_else(|| {
1286 ogeom_core::ogeom_err!(Construction, "the tip vertex's edges do not chain")
1287 })
1288 })
1289 .collect::<OgeomResult<_>>()?;
1290 let directions: Vec<Vector> = ring.iter().map(|(d, _)| *d).collect();
1291 let is_ridge: Vec<bool> = ring
1296 .iter()
1297 .map(|(d, _)| !rays[index].iter().any(|(r, _)| r.dot(*d) > 1.0 - 1e-9))
1298 .collect();
1299 let mut walls: Vec<(Vector, Point)> = tip.planes.iter().map(|&p| (m[p], corner)).collect();
1300 for direction in &directions {
1301 walls.push((-*direction, tip.centre));
1302 }
1303 let tool = ball_block(
1304 model,
1305 &walls,
1306 corner,
1307 tip.centre,
1308 &hosts,
1309 &directions,
1310 &is_ridge,
1311 radius,
1312 tol,
1313 )?;
1314 let cut = ogeom_bool::cut(model, &rounded, &tool.shape, tol).map_err(|e| {
1315 ogeom_core::ogeom_err!(
1316 NotDone,
1317 "the cut by the ball's block at tip vertex {index} failed: {e}"
1318 )
1319 })?;
1320 history = history.then(&tool.history).then(&cut.history);
1321 rounded = cut.shape;
1322 }
1323 for ridge in &ridges {
1330 let (v1, v2) = (tips[ridge.from].centre, tips[ridge.to].centre);
1331 let along = (v2 - v1).normalized(tol)?;
1332 let [a, c] = ridge.planes;
1333 let on_crease = |v: Point| corner + along * (v - corner).dot(along);
1334 let face_on = |model: &Model, inward: Vector| -> OgeomResult<Shape> {
1335 for face in ogeom_topo::explore_unique(model, &rounded, ShapeType::Face)? {
1336 let Some(data) = model.node(&face).and_then(|n| n.data().as_face()) else {
1337 continue;
1338 };
1339 if !matches!(
1340 model.geometry().surface(data.surface),
1341 Some(ogeom_geom::SurfaceGeometry::Plane(_))
1342 ) {
1343 continue;
1344 }
1345 let (origin, outward) = ogeom_algo::face_normal(model, &face, tol)?;
1346 if (corner - origin).dot(outward).abs() <= tol.confusion() * 100.0
1347 && outward.cross(inward).magnitude() < tol.angular() * 10.0
1348 && outward.dot(inward) < 0.0
1349 {
1350 return Ok(face);
1351 }
1352 }
1353 ogeom_bail!(
1354 Construction,
1355 "the ridge's host plane is no longer a face of the solid"
1356 )
1357 };
1358 let faces = [face_on(model, m[a])?, face_on(model, m[c])?];
1359 let seat = crate::support::Seat {
1360 start: on_crease(v1),
1361 end: on_crease(v2),
1362 along,
1363 normals: [-m[a], -m[c]],
1364 faces,
1365 convex: true,
1366 };
1367 let cut = crate::fillet::seated_fillet(model, &rounded, &seat, radius, None, &[], tol)
1368 .map_err(|e| ogeom_core::ogeom_err!(NotDone, "the ridge's flush fillet failed: {e}"))?;
1369 history = history.then(&cut.history);
1370 rounded = cut.shape;
1371 }
1372 history.modify(vertex, rounded.clone());
1373 Ok(Built {
1374 shape: rounded,
1375 history,
1376 })
1377}
1378
1379pub(crate) fn convex_block(
1388 model: &mut Model,
1389 walls: &[(Vector, Point)],
1390 tol: Tolerances,
1391) -> OgeomResult<Shape> {
1392 let slack = tol.confusion() * 100.0;
1393 let n = walls.len();
1394 let mut points: Vec<Point> = Vec::new();
1395 for i in 0..n {
1396 for j in i + 1..n {
1397 for k in j + 1..n {
1398 let Some(p) = planes_meet(&walls[i], &walls[j], &walls[k]) else {
1399 continue;
1400 };
1401 if walls
1402 .iter()
1403 .any(|(normal, on)| normal.dot(p - *on) < -slack)
1404 {
1405 continue;
1406 }
1407 if points.iter().any(|q| q.distance(p) <= slack) {
1408 continue;
1409 }
1410 points.push(p);
1411 }
1412 }
1413 }
1414 let mut rings: Vec<Vec<usize>> = Vec::new();
1415 for (normal, on) in walls {
1416 let mine: Vec<usize> = (0..points.len())
1417 .filter(|&i| normal.dot(points[i] - *on).abs() <= slack)
1418 .collect();
1419 if mine.len() < 3 {
1420 continue;
1421 }
1422 let centroid = mine
1423 .iter()
1424 .fold(Vector::ZERO, |acc, &i| acc + (points[i] - Point::ORIGIN))
1425 * (1.0 / f64::from(u32::try_from(mine.len()).unwrap_or(u32::MAX)));
1426 let centroid = Point::ORIGIN + centroid;
1427 let axis = normal.normalized(tol)?;
1428 let first = (points[mine[0]] - centroid).normalized(tol)?;
1429 let second = axis.cross(first);
1430 let mut ordered: Vec<(f64, usize)> = mine
1431 .iter()
1432 .map(|&i| {
1433 let v = points[i] - centroid;
1434 (v.dot(second).atan2(v.dot(first)), i)
1435 })
1436 .collect();
1437 ordered.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(core::cmp::Ordering::Equal));
1438 rings.push(ordered.into_iter().map(|(_, i)| i).collect());
1439 }
1440 Ok(ogeom_algo::make_polyhedron(model, &points, &rings, tol)?.shape)
1441}
1442
1443pub(crate) fn planes_meet(
1445 a: &(Vector, Point),
1446 b: &(Vector, Point),
1447 c: &(Vector, Point),
1448) -> Option<Point> {
1449 let m = [a.0, b.0, c.0];
1450 let rhs = [
1451 a.0.dot(a.1 - Point::ORIGIN),
1452 b.0.dot(b.1 - Point::ORIGIN),
1453 c.0.dot(c.1 - Point::ORIGIN),
1454 ];
1455 let x = solve3(&m, rhs)?;
1456 Some(Point::ORIGIN + x)
1457}
1458
1459fn solve3(m: &[Vector; 3], rhs: [f64; 3]) -> Option<Vector> {
1461 let a: [[f64; 3]; 3] = std::array::from_fn(|i| [m[i].x, m[i].y, m[i].z]);
1462 let det3 = |a: &[[f64; 3]; 3]| -> f64 {
1463 a[0][0].mul_add(
1464 a[1][1].mul_add(a[2][2], -(a[1][2] * a[2][1])),
1465 -a[0][1].mul_add(
1466 a[1][0].mul_add(a[2][2], -(a[1][2] * a[2][0])),
1467 -(a[0][2] * a[1][0].mul_add(a[2][1], -(a[1][1] * a[2][0]))),
1468 ),
1469 )
1470 };
1471 let det = det3(&a);
1472 if !det.is_finite() || det.abs() <= 1e-12 {
1473 return None;
1474 }
1475 let mut x = [0.0_f64; 3];
1476 for (k, xk) in x.iter_mut().enumerate() {
1477 let mut ak = a;
1478 for (row, r) in ak.iter_mut().zip(rhs) {
1479 row[k] = r;
1480 }
1481 *xk = det3(&ak) / det;
1482 }
1483 Some(Vector::new(x[0], x[1], x[2]))
1484}
1485
1486fn ball_centre(corner: Point, m: &[Vector; 3], radius: f64) -> Option<Point> {
1489 solve3(m, [radius; 3]).map(|x| corner + x)
1490}
1491
1492#[allow(clippy::too_many_arguments, reason = "one construction, all its data")]
1503fn ball_block(
1504 model: &mut Model,
1505 walls: &[(Vector, Point)],
1506 corner: Point,
1507 far: Point,
1508 hosts: &[Vector],
1509 directions: &[Vector],
1510 is_ridge: &[bool],
1511 radius: f64,
1512 tol: Tolerances,
1513) -> OgeomResult<Built> {
1514 let n = directions.len();
1515 let mut last: Option<ogeom_core::OgeomError> = None;
1516 let touches: Vec<Vector> = hosts.iter().map(|m| -*m).collect();
1532 let inside_patch = |direction: Vector| -> bool {
1533 directions
1534 .iter()
1535 .all(|e| e.dot(direction) <= tol.angular() * 10.0)
1536 };
1537 let mut scored: Vec<(f64, Vector, Vector)> = Vec::new();
1538 for t in 0..n {
1539 if !is_ridge[t] {
1540 continue;
1541 }
1542 for pole in [directions[t], -directions[t]] {
1543 if inside_patch(pole) || inside_patch(-pole) {
1544 continue;
1545 }
1546 let flat = |v: Vector| v - pole * v.dot(pole);
1547 let mean = touches.iter().fold(Vector::ZERO, |acc, t| acc + *t);
1548 for seam in [flat(-mean), flat(far - corner)] {
1549 if seam.magnitude() <= tol.angular() * 10.0 {
1550 continue;
1551 }
1552 let x = seam / seam.magnitude();
1553 let y = pole.cross(x);
1554 let clearance = touches
1555 .iter()
1556 .map(|t| t.dot(y).atan2(t.dot(x)).abs())
1557 .fold(f64::INFINITY, f64::min);
1558 scored.push((clearance, pole, seam));
1559 }
1560 }
1561 }
1562 scored.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(core::cmp::Ordering::Equal));
1563 if std::env::var_os("OGEOM_DEBUG_CORNER").is_some() {
1564 for (clearance, pole, seam) in &scored {
1565 eprintln!("FRAME clearance {clearance:.3} pole {pole:?} seam {seam:?}");
1566 }
1567 }
1568 let mut frames: Vec<(Vector, Vector)> = scored.into_iter().map(|(_, p, s)| (p, s)).collect();
1569 let pole_host = |index: usize| -> usize {
1572 let (start, reverse) = (index % n, index >= n);
1573 if reverse {
1574 (start + 2 * n - 1) % n
1575 } else {
1576 start
1577 }
1578 };
1579 let clean = |index: usize| -> bool {
1580 let h = pole_host(index);
1581 !is_ridge[h] && !is_ridge[(h + 1) % n]
1582 };
1583 let mut order: Vec<usize> = (0..2 * n).collect();
1584 order.sort_by_key(|&index| !clean(index));
1585 frames.extend(
1586 order
1587 .iter()
1588 .map(|&index| (hosts[pole_host(index)], directions[index % n])),
1589 );
1590 for (index, (pole, seam)) in frames.into_iter().enumerate() {
1591 model.begin_operation();
1592 let attempt = (|| -> OgeomResult<Built> {
1593 let block = convex_block(model, walls, tol)?;
1594 let block_bound = ogeom_algo::shape_bounds(model, &block, tol)?;
1595 let ball_frame = Frame::new(
1596 far,
1597 Direction::new(pole, tol)?,
1598 Direction::new(seam, tol)?,
1599 tol,
1600 )?;
1601 let ball = ogeom_algo::make_sphere(model, ball_frame, radius, tol)?.shape;
1602 let tool = ogeom_bool::cut(model, &block, &ball, tol)?;
1603 let tool_bound = ogeom_algo::shape_bounds(model, &tool.shape, tol)?;
1604 let reach = tol.confusion() * 1e3;
1605 let (Some(block_lo), Some(block_hi), Some(tool_lo), Some(tool_hi)) = (
1606 block_bound.low(),
1607 block_bound.high(),
1608 tool_bound.low(),
1609 tool_bound.high(),
1610 ) else {
1611 ogeom_bail!(NotDone, "the ball's cut left no tool");
1612 };
1613 if tool_lo.x < block_lo.x - reach
1614 || tool_lo.y < block_lo.y - reach
1615 || tool_lo.z < block_lo.z - reach
1616 || tool_hi.x > block_hi.x + reach
1617 || tool_hi.y > block_hi.y + reach
1618 || tool_hi.z > block_hi.z + reach
1619 {
1620 ogeom_bail!(
1621 NotDone,
1622 "the ball's cut left a tool reaching past its own block"
1623 );
1624 }
1625 Ok(tool)
1626 })();
1627 match attempt {
1628 Ok(tool) => {
1629 if std::env::var_os("OGEOM_DEBUG_CORNER").is_some() {
1630 eprintln!("BALL frame {index} closed");
1631 }
1632 return Ok(tool);
1633 }
1634 Err(err) => {
1635 if std::env::var_os("OGEOM_DEBUG_CORNER").is_some() {
1636 eprintln!("BALL frame {index} pole {pole:?} seam {seam:?}: {err}");
1637 }
1638 last = Some(err);
1639 }
1640 }
1641 }
1642 ogeom_bail!(
1643 NotDone,
1644 "the corner tool's block closed on none of its {} frames; the last said: {}",
1645 2 * n,
1646 last.map_or_else(String::new, |e| e.to_string())
1647 )
1648}
1649
1650#[cfg(test)]
1651mod tests {
1652 #![allow(clippy::unwrap_used, reason = "test code")]
1653 use super::{round_vertex_with, round_vertices_together};
1654 use ogeom_core::Tolerances;
1655 use ogeom_math::{Point, Vector};
1656 use ogeom_topo::{Model, Shape, ShapeType};
1657
1658 const T: Tolerances = Tolerances::millimetres();
1659
1660 fn vertex_at(model: &Model, shape: &Shape, at: Point) -> Shape {
1661 ogeom_topo::explore_unique(model, shape, ShapeType::Vertex)
1662 .unwrap()
1663 .into_iter()
1664 .find(|v| {
1665 model
1666 .node(v)
1667 .and_then(|n| n.data().as_vertex())
1668 .is_some_and(|d| d.point.distance(at) < 1e-9)
1669 })
1670 .unwrap()
1671 }
1672
1673 fn volume(model: &Model, shape: &Shape) -> f64 {
1674 ogeom_algo::volume_properties(model, shape, ogeom_mesh::Deflection::default(), T)
1675 .unwrap()
1676 .mass
1677 }
1678
1679 fn every_labelling_agrees(model: &mut Model, solid: &Shape, vertex: &Shape, count: usize) {
1682 let mut volumes = Vec::with_capacity(count);
1683 for index in 0..count {
1684 let rounded = round_vertex_with(model, solid, vertex, 2.0, Some(index), T)
1685 .unwrap_or_else(|e| panic!("labelling {index} did not close: {e}"));
1686 let diagnosis = ogeom_algo::check(model, &rounded.shape, T).unwrap();
1687 assert!(
1688 diagnosis.is_valid(),
1689 "labelling {index}: {:?}",
1690 diagnosis.problems
1691 );
1692 volumes.push(volume(model, &rounded.shape));
1693 }
1694 for (index, v) in volumes.iter().enumerate() {
1699 assert!(
1700 (v - volumes[0]).abs() < 1e-7 * volumes[0],
1701 "labelling {index} builds {v}, labelling 0 {}",
1702 volumes[0]
1703 );
1704 }
1705 }
1706
1707 fn box_corners_in_turn(model: &mut Model, radius: f64) -> (Shape, Vec<Shape>, Shape) {
1710 let solid = ogeom_algo::make_box(model, ogeom_math::Frame::WORLD, (10.0, 10.0, 10.0), T)
1711 .unwrap()
1712 .shape;
1713 let vertices = ogeom_topo::explore_unique(model, &solid, ShapeType::Vertex).unwrap();
1714 let mut rounded = solid.clone();
1715 for vertex in &vertices {
1716 rounded = round_vertex_with(model, &rounded, vertex, radius, None, T)
1717 .unwrap()
1718 .shape;
1719 }
1720 (solid, vertices, rounded)
1721 }
1722
1723 fn counts(model: &Model, shape: &Shape) -> [usize; 3] {
1724 [ShapeType::Face, ShapeType::Edge, ShapeType::Vertex].map(|kind| {
1725 ogeom_topo::explore_unique(model, shape, kind)
1726 .unwrap()
1727 .len()
1728 })
1729 }
1730
1731 #[test]
1735 fn corners_clear_of_each_other_round_in_one_cut_as_in_turn() {
1736 for radius in [1.0, 2.0] {
1737 let mut model = Model::new();
1738 let ((solid, vertices, in_turn), one_by_one) =
1739 ogeom_bool::work::counted(|| box_corners_in_turn(&mut model, radius));
1740 let (together, at_once) = ogeom_bool::work::counted(|| {
1741 round_vertices_together(&mut model, &solid, &vertices, radius, T).unwrap()
1742 });
1743 let together = together.unwrap();
1744 assert!(
1745 ogeom_algo::check(&model, &together.shape, T)
1746 .unwrap()
1747 .is_valid()
1748 );
1749 let (want, got) = (volume(&model, &in_turn), volume(&model, &together.shape));
1750 assert!(
1751 (got - want).abs() <= want * 1e-9,
1752 "r {radius}: {got} against {want}"
1753 );
1754 assert_eq!(counts(&model, &together.shape), counts(&model, &in_turn));
1755 assert!(
1756 at_once.faces_gathered < one_by_one.faces_gathered,
1757 "r {radius}: {at_once:?} at once against {one_by_one:?} in turn"
1758 );
1759 for vertex in &vertices {
1760 assert_eq!(
1761 together.history.modified(vertex),
1762 core::slice::from_ref(&together.shape)
1763 );
1764 }
1765 }
1766 }
1767
1768 #[test]
1772 fn corners_whose_balls_come_close_round_in_turn() {
1773 let mut model = Model::new();
1774 let solid =
1775 ogeom_algo::make_box(&mut model, ogeom_math::Frame::WORLD, (10.0, 10.0, 10.0), T)
1776 .unwrap()
1777 .shape;
1778 let vertices = ogeom_topo::explore_unique(&model, &solid, ShapeType::Vertex).unwrap();
1779 let together = round_vertices_together(&mut model, &solid, &vertices, 4.9, T).unwrap();
1780 assert!(together.is_none());
1781 }
1782
1783 #[test]
1784 fn an_oblique_corner_rounds_the_same_under_every_labelling() {
1785 let mut model = Model::new();
1786 let (a, b, c) = (
1787 Vector::new(20.0, 0.0, 0.0),
1788 Vector::new(6.0, 20.0, 0.0),
1789 Vector::new(3.6, 6.0, 20.0),
1790 );
1791 let block = ogeom_algo::make_parallelepiped(&mut model, Point::ORIGIN, [a, b, c], T)
1792 .unwrap()
1793 .shape;
1794 let vertex = vertex_at(&model, &block, Point::ORIGIN);
1795 every_labelling_agrees(&mut model, &block, &vertex, 6);
1796 }
1797
1798 #[test]
1799 fn a_square_pyramid_apex_rounds_the_same_under_every_labelling() {
1800 let mut model = Model::new();
1801 let apex = Point::new(0.0, 0.0, 15.0);
1802 let base = [
1803 Point::new(-10.0, -10.0, 0.0),
1804 Point::new(10.0, -10.0, 0.0),
1805 Point::new(10.0, 10.0, 0.0),
1806 Point::new(-10.0, 10.0, 0.0),
1807 ];
1808 let mut faces = Vec::new();
1810 for k in 0..4 {
1811 let (p, q) = (base[k], base[(k + 1) % 4]);
1812 let outward = (q - p).cross(apex - p);
1813 faces.push(crate::support::planar_face(&mut model, &[p, q, apex], outward, T).unwrap());
1814 }
1815 let reversed: Vec<Point> = base.iter().rev().copied().collect();
1816 faces.push(crate::support::planar_face(&mut model, &reversed, -Vector::Z, T).unwrap());
1817 let sewn = ogeom_algo::sew(&mut model, &faces, T).unwrap();
1818 let pyramid = ogeom_algo::make_solid(&mut model, &sewn.shells[..1])
1819 .unwrap()
1820 .shape;
1821 let vertex = vertex_at(&model, &pyramid, apex);
1822 every_labelling_agrees(&mut model, &pyramid, &vertex, 8);
1823 }
1824}