1use core::f64::consts::{FRAC_PI_2, PI, TAU};
10
11use ogeom_core::{OgeomResult, Role, Tolerances, ogeom_bail};
12use ogeom_geom::{
13 CircleCurve, Curve, LineCurve, PlanarCurve, PlaneSurface, SphereSurface, Surface,
14};
15use ogeom_math::{
16 Circle, Cone, Cylinder, Direction, Direction2, Frame, Plane, Point, Point2, Sphere, Torus,
17};
18use ogeom_topo::{Model, Shape, ShapeType};
19
20use crate::build::{make_edge_between, make_face_on, make_shell, make_solid, make_wire};
21use crate::history::Built;
22use ogeom_topo::{EdgeData, VertexData};
23
24pub mod roles {
26 use ogeom_core::Role;
27
28 pub const FACE_MIN_X: Role = Role::op_defined(10);
30 pub const FACE_MAX_X: Role = Role::op_defined(11);
32 pub const FACE_MIN_Y: Role = Role::op_defined(12);
34 pub const FACE_MAX_Y: Role = Role::op_defined(13);
36 pub const FACE_MIN_Z: Role = Role::op_defined(14);
38 pub const FACE_MAX_Z: Role = Role::op_defined(15);
40 pub const FACE_LATERAL: Role = Role::op_defined(16);
43}
44
45const CORNERS: [(usize, usize, usize); 8] = [
48 (0, 0, 0),
49 (1, 0, 0),
50 (1, 1, 0),
51 (0, 1, 0),
52 (0, 0, 1),
53 (1, 0, 1),
54 (1, 1, 1),
55 (0, 1, 1),
56];
57
58const EDGES: [(usize, usize); 12] = [
60 (0, 1),
61 (1, 2),
62 (2, 3),
63 (3, 0), (4, 5),
65 (5, 6),
66 (6, 7),
67 (7, 4), (0, 4),
69 (1, 5),
70 (2, 6),
71 (3, 7), ];
73
74const FACES: [([usize; 4], Role); 6] = [
82 ([0, 3, 2, 1], roles::FACE_MIN_Z),
83 ([4, 5, 6, 7], roles::FACE_MAX_Z),
84 ([0, 1, 5, 4], roles::FACE_MIN_Y),
85 ([2, 3, 7, 6], roles::FACE_MAX_Y),
86 ([0, 4, 7, 3], roles::FACE_MIN_X),
87 ([1, 2, 6, 5], roles::FACE_MAX_X),
88];
89
90pub fn make_box(
98 model: &mut Model,
99 frame: Frame,
100 size: (f64, f64, f64),
101 tol: Tolerances,
102) -> OgeomResult<Built> {
103 let (dx, dy, dz) = size;
104 for (name, value) in [("x", dx), ("y", dy), ("z", dz)] {
105 if !value.is_finite() || value <= tol.confusion() {
106 ogeom_bail!(
107 Construction,
108 "box {name} size {value} must be finite and positive"
109 );
110 }
111 }
112 model.begin_operation();
113
114 let extent = [dx, dy, dz];
115 let corner_points: Vec<Point> = CORNERS
116 .iter()
117 .map(|&(i, j, k)| {
118 #[allow(clippy::cast_precision_loss)]
119 let local = [
120 i as f64 * extent[0],
121 j as f64 * extent[1],
122 k as f64 * extent[2],
123 ];
124 frame.to_world(Point::new(local[0], local[1], local[2]))
125 })
126 .collect();
127
128 box_like(model, &corner_points, tol)
129}
130
131pub fn make_parallelepiped(
143 model: &mut Model,
144 origin: Point,
145 edges: [ogeom_math::Vector; 3],
146 tol: Tolerances,
147) -> OgeomResult<Built> {
148 let volume = edges[0].cross(edges[1]).dot(edges[2]);
149 if !volume.is_finite() || volume.abs() <= tol.confusion() {
150 ogeom_bail!(
151 Construction,
152 "a parallelepiped needs three edges that span a volume"
153 );
154 }
155 let [a, b, c] = if volume > 0.0 {
156 edges
157 } else {
158 [edges[1], edges[0], edges[2]]
159 };
160 model.begin_operation();
161 let corner_points: Vec<Point> = CORNERS
162 .iter()
163 .map(|&(i, j, k)| {
164 #[allow(clippy::cast_precision_loss)]
165 let at = origin + a * (i as f64) + b * (j as f64) + c * (k as f64);
166 at
167 })
168 .collect();
169 box_like(model, &corner_points, tol)
170}
171
172pub fn make_hexahedron(
189 model: &mut Model,
190 corners: [Point; 8],
191 tol: Tolerances,
192) -> OgeomResult<Built> {
193 for (face, _) in FACES {
194 let [a, b, c, d] = [
195 corners[face[0]],
196 corners[face[1]],
197 corners[face[2]],
198 corners[face[3]],
199 ];
200 let n = (b - a).cross(c - a);
201 let m = n.magnitude();
202 if !m.is_finite() || m <= tol.confusion() {
203 ogeom_bail!(Construction, "a hexahedron's face has no area");
204 }
205 let off = ((d - a).dot(n) / m).abs();
206 if off > tol.confusion() * 10.0 {
207 ogeom_bail!(
208 Construction,
209 "a hexahedron's face is not planar; its fourth corner sits {off} off"
210 );
211 }
212 }
213 let volume = (corners[1] - corners[0])
214 .cross(corners[3] - corners[0])
215 .dot(corners[4] - corners[0]);
216 if !volume.is_finite() || volume.abs() <= tol.confusion() {
217 ogeom_bail!(Construction, "a hexahedron's corners span no volume");
218 }
219 let ordered: Vec<Point> = if volume > 0.0 {
222 corners.to_vec()
223 } else {
224 CORNERS
225 .iter()
226 .map(|&(i, j, k)| {
227 let at = CORNERS.iter().position(|&c| c == (j, i, k)).unwrap_or(0);
228 corners[at]
229 })
230 .collect()
231 };
232 model.begin_operation();
233 box_like(model, &ordered, tol)
234}
235
236pub fn make_polyhedron(
254 model: &mut Model,
255 points: &[Point],
256 rings: &[Vec<usize>],
257 tol: Tolerances,
258) -> OgeomResult<Built> {
259 if points.len() < 4 || rings.len() < 4 {
260 ogeom_bail!(
261 Construction,
262 "a polyhedron has at least four points and four faces"
263 );
264 }
265 if points
266 .iter()
267 .any(|p| !p.to_vector().magnitude().is_finite())
268 {
269 ogeom_bail!(Construction, "a polyhedron's point is not finite");
270 }
271 let mut centroid = ogeom_math::Vector::ZERO;
272 for p in points {
273 centroid += p.to_vector();
274 }
275 let centroid =
276 Point::ORIGIN + centroid / f64::from(u32::try_from(points.len()).unwrap_or(u32::MAX));
277 let mut wound: Vec<Vec<usize>> = Vec::with_capacity(rings.len());
278 let mut uses: std::collections::HashMap<(usize, usize), usize> =
279 std::collections::HashMap::new();
280 for ring in rings {
281 if ring.len() < 3 {
282 ogeom_bail!(
283 Construction,
284 "a polyhedron's face has fewer than three corners"
285 );
286 }
287 if ring.iter().any(|&i| i >= points.len()) {
288 ogeom_bail!(
289 Construction,
290 "a polyhedron's face names a point it does not have"
291 );
292 }
293 let [a, b, c] = [points[ring[0]], points[ring[1]], points[ring[2]]];
294 let n = (b - a).cross(c - b);
295 let m = n.magnitude();
296 if !m.is_finite() || m <= tol.confusion() {
297 ogeom_bail!(Construction, "a polyhedron's face has no area");
298 }
299 let n = n / m;
300 let mut mid = ogeom_math::Vector::ZERO;
301 for &i in ring {
302 let off = (points[i] - a).dot(n).abs();
303 if off > tol.confusion() * 10.0 {
304 ogeom_bail!(
305 Construction,
306 "a polyhedron's face is not planar; a corner sits {off} off"
307 );
308 }
309 mid += points[i].to_vector();
310 }
311 let mid = Point::ORIGIN + mid / f64::from(u32::try_from(ring.len()).unwrap_or(u32::MAX));
312 let outward = n.dot(mid - centroid) > 0.0;
313 let ring: Vec<usize> = if outward {
316 ring.clone()
317 } else {
318 std::iter::once(ring[0])
319 .chain(ring[1..].iter().rev().copied())
320 .collect()
321 };
322 for step in 0..ring.len() {
323 let (from, to) = (ring[step], ring[(step + 1) % ring.len()]);
324 if from == to {
325 ogeom_bail!(Construction, "a polyhedron's face repeats a corner");
326 }
327 *uses.entry((from.min(to), from.max(to))).or_insert(0) += 1;
328 }
329 wound.push(ring);
330 }
331 if let Some((edge, count)) = uses.iter().find(|(_, count)| **count != 2) {
332 ogeom_bail!(
333 Construction,
334 "a polyhedron's edge {edge:?} is used by {count} faces, not two; the shell would not close"
335 );
336 }
337 let mut volume = 0.0;
340 for ring in &wound {
341 let a = points[ring[0]];
342 for step in 1..ring.len() - 1 {
343 let (b, c) = (points[ring[step]], points[ring[step + 1]]);
344 volume += (a - centroid).dot((b - centroid).cross(c - centroid));
345 }
346 }
347 if volume.is_nan() || volume / 6.0 <= tol.confusion() {
348 ogeom_bail!(Construction, "a polyhedron's faces span no volume");
349 }
350 let borrowed: Vec<&[usize]> = wound.iter().map(Vec::as_slice).collect();
351 model.begin_operation();
352 faceted_solid(model, points, &borrowed, tol)
353}
354
355fn box_like(model: &mut Model, corner_points: &[Point], tol: Tolerances) -> OgeomResult<Built> {
363 let vertices: Vec<Shape> = corner_points
364 .iter()
365 .map(|p| model.add_vertex(ogeom_topo::VertexData::new(*p)))
366 .collect();
367
368 let mut edges = Vec::with_capacity(EDGES.len());
372 for &(from, to) in &EDGES {
373 let curve: Curve = LineCurve::segment(corner_points[from], corner_points[to], tol)?.into();
374 let length = corner_points[from].distance(corner_points[to]);
375 edges.push(
376 make_edge_between(
377 model,
378 curve,
379 (0.0, length),
380 &vertices[from],
381 &vertices[to],
382 tol,
383 )?
384 .shape,
385 );
386 }
387
388 let mut faces = Vec::with_capacity(FACES.len());
389 for (corners, role) in FACES {
390 let plane = face_plane(corner_points, corners, tol)?;
391 let surface = model
395 .geometry_mut()
396 .add_surface(PlaneSurface::new(plane).into());
397 let mut ring = Vec::with_capacity(4);
398 for step in 0..4 {
399 let (from, to) = (corners[step], corners[(step + 1) % 4]);
400 let (index, forward) = find_edge(from, to)?;
401 ring.push(if forward {
402 edges[index].clone()
403 } else {
404 edges[index].reversed()
405 });
406
407 let (canonical_from, canonical_to) = EDGES[index];
412 attach_plane_pcurve(
413 model,
414 &edges[index],
415 &plane,
416 surface,
417 corner_points[canonical_from],
418 corner_points[canonical_to],
419 tol,
420 )?;
421 }
422
423 let wire = make_wire(model, &ring, tol)?.shape;
424 let face = make_face_on(model, surface, std::slice::from_ref(&wire), tol)?.shape;
425 model.set_derived(&face, &[], role)?;
426 faces.push(face);
427 }
428
429 let shell = make_shell(model, &faces)?.shape;
430 let solid = make_solid(model, std::slice::from_ref(&shell))?.shape;
431 Ok(Built::from_nothing(solid))
432}
433
434fn faceted_solid(
442 model: &mut Model,
443 points: &[Point],
444 rings: &[&[usize]],
445 tol: Tolerances,
446) -> OgeomResult<Built> {
447 let vertices: Vec<Shape> = points
448 .iter()
449 .map(|p| model.add_vertex(ogeom_topo::VertexData::new(*p)))
450 .collect();
451 let mut edge_of: std::collections::HashMap<(usize, usize), Shape> =
452 std::collections::HashMap::new();
453 let mut faces = Vec::with_capacity(rings.len());
454 for ring_corners in rings {
455 let origin = points[ring_corners[0]];
456 let normal = Direction::from_cross(
457 points[ring_corners[1]] - origin,
458 points[ring_corners[2]] - points[ring_corners[1]],
459 tol,
460 )?;
461 let x = Direction::new(points[ring_corners[1]] - origin, tol)?;
462 let plane = Plane::new(Frame::new(origin, normal, x, tol)?);
463 let surface = model
464 .geometry_mut()
465 .add_surface(PlaneSurface::new(plane).into());
466 let mut ring = Vec::with_capacity(ring_corners.len());
467 for step in 0..ring_corners.len() {
468 let (from, to) = (
469 ring_corners[step],
470 ring_corners[(step + 1) % ring_corners.len()],
471 );
472 let key = (from.min(to), from.max(to));
473 let edge = match edge_of.get(&key) {
474 Some(edge) => edge.clone(),
475 None => {
476 let curve: Curve =
477 LineCurve::segment(points[key.0], points[key.1], tol)?.into();
478 let length = points[key.0].distance(points[key.1]);
479 let edge = make_edge_between(
480 model,
481 curve,
482 (0.0, length),
483 &vertices[key.0],
484 &vertices[key.1],
485 tol,
486 )?
487 .shape;
488 edge_of.insert(key, edge.clone());
489 edge
490 }
491 };
492 attach_plane_pcurve(
495 model,
496 &edge,
497 &plane,
498 surface,
499 points[key.0],
500 points[key.1],
501 tol,
502 )?;
503 ring.push(if from == key.0 { edge } else { edge.reversed() });
504 }
505 let wire = make_wire(model, &ring, tol)?.shape;
506 faces.push(make_face_on(model, surface, std::slice::from_ref(&wire), tol)?.shape);
507 }
508 let shell = make_shell(model, &faces)?.shape;
509 let solid = make_solid(model, std::slice::from_ref(&shell))?.shape;
510 Ok(Built::from_nothing(solid))
511}
512
513fn face_plane(points: &[Point], corners: [usize; 4], tol: Tolerances) -> OgeomResult<Plane> {
515 let origin = points[corners[0]];
516 let normal = Direction::from_cross(
519 points[corners[1]] - origin,
520 points[corners[2]] - points[corners[1]],
521 tol,
522 )?;
523 let x = Direction::new(points[corners[1]] - origin, tol)?;
524 Ok(Plane::new(Frame::new(origin, normal, x, tol)?))
525}
526
527fn attach_plane_pcurve(
530 model: &mut Model,
531 edge: &Shape,
532 plane: &Plane,
533 surface: ogeom_topo::SurfaceId,
534 from: Point,
535 to: Point,
536 tol: Tolerances,
537) -> OgeomResult<()> {
538 let local = |p: Point| {
539 let l = plane.frame().to_local(p);
540 Point2::new(l.x, l.y)
541 };
542 let (a, b) = (local(from), local(to));
543 let pcurve: PlanarCurve = ogeom_geom::Line2d::segment(a, b, tol)?.into();
544 crate::build::attach_pcurve(
545 model,
546 edge,
547 pcurve,
548 surface,
549 ogeom_topo::Location::identity(),
550 (0.0, a.distance(b)),
551 )
552}
553
554fn find_edge(from: usize, to: usize) -> OgeomResult<(usize, bool)> {
556 for (index, &(a, b)) in EDGES.iter().enumerate() {
557 if a == from && b == to {
558 return Ok((index, true));
559 }
560 if a == to && b == from {
561 return Ok((index, false));
562 }
563 }
564 ogeom_bail!(
565 Construction,
566 "corners {from} and {to} are not joined by a box edge"
567 )
568}
569
570pub fn make_cylinder(
577 model: &mut Model,
578 frame: Frame,
579 radius: f64,
580 height: f64,
581 tol: Tolerances,
582) -> OgeomResult<Built> {
583 check_size("cylinder radius", radius, tol)?;
584 check_size("cylinder height", height, tol)?;
585 model.begin_operation();
586
587 let top_frame = raised(frame, height, tol)?;
588 let bottom_circle = Circle::new(frame, radius, tol)?;
589 let top_circle = Circle::new(top_frame, radius, tol)?;
590
591 let low = model.add_vertex(VertexData::new(rim_point(bottom_circle)));
595 let high = model.add_vertex(VertexData::new(rim_point(top_circle)));
596
597 let bottom_edge = full_circle_edge(model, bottom_circle, &low, tol)?;
598 let top_edge = full_circle_edge(model, top_circle, &high, tol)?;
599 let seam = make_edge_between(
600 model,
601 LineCurve::segment(rim_point(bottom_circle), rim_point(top_circle), tol)?.into(),
602 (0.0, height),
603 &low,
604 &high,
605 tol,
606 )?
607 .shape;
608
609 let lateral_id = model.geometry_mut().add_surface(
610 ogeom_geom::CylinderSurface::new(Cylinder::new(frame, radius, tol)?, (0.0, height))?.into(),
611 );
612 let lateral = rectangle_face(
613 model,
614 lateral_id,
615 (TAU, height),
616 [&bottom_edge, &top_edge, &seam],
617 tol,
618 )?;
619 model.set_derived(&lateral, &[], roles::FACE_LATERAL)?;
620
621 let bottom = cap_face(model, frame, false, &bottom_edge, bottom_circle, tol)?;
622 model.set_derived(&bottom, &[], roles::FACE_MIN_Z)?;
623 let top = cap_face(model, top_frame, true, &top_edge, top_circle, tol)?;
624 model.set_derived(&top, &[], roles::FACE_MAX_Z)?;
625
626 let shell = make_shell(model, &[lateral, bottom, top])?.shape;
627 let solid = make_solid(model, std::slice::from_ref(&shell))?.shape;
628 Ok(Built::from_nothing(solid))
629}
630
631pub fn make_sphere(
638 model: &mut Model,
639 frame: Frame,
640 radius: f64,
641 tol: Tolerances,
642) -> OgeomResult<Built> {
643 check_size("sphere radius", radius, tol)?;
644 model.begin_operation();
645
646 let centre = frame.origin();
647 let south = model.add_vertex(VertexData::new(centre - frame.z().vector() * radius));
648 let north = model.add_vertex(VertexData::new(centre + frame.z().vector() * radius));
649
650 let meridian = Circle::new(Frame::new(centre, -frame.y(), frame.x(), tol)?, radius, tol)?;
655 let seam = make_edge_between(
656 model,
657 CircleCurve::new(meridian).into(),
658 (-FRAC_PI_2, FRAC_PI_2),
659 &south,
660 &north,
661 tol,
662 )?
663 .shape;
664
665 let bottom_edge = degenerate_edge(model, &south, tol)?;
669 let top_edge = degenerate_edge(model, &north, tol)?;
670
671 let surface = model
672 .geometry_mut()
673 .add_surface(SphereSurface::new(Sphere::new(frame, radius, tol)?).into());
674 let face = rectangle_face(
675 model,
676 surface,
677 (TAU, PI),
678 [&bottom_edge, &top_edge, &seam],
679 tol,
680 )?;
681 model.set_derived(&face, &[], roles::FACE_LATERAL)?;
684
685 let shell = make_shell(model, std::slice::from_ref(&face))?.shape;
686 let solid = make_solid(model, std::slice::from_ref(&shell))?.shape;
687 Ok(Built::from_nothing(solid))
688}
689
690fn rim_point(circle: Circle) -> Point {
693 circle.centre() + circle.frame().x().vector() * circle.radius()
694}
695
696fn raised(frame: Frame, distance: f64, tol: Tolerances) -> OgeomResult<Frame> {
698 Frame::new(
699 frame.to_world(Point::new(0.0, 0.0, distance)),
700 frame.z(),
701 frame.x(),
702 tol,
703 )
704}
705
706fn check_size(what: &str, value: f64, tol: Tolerances) -> OgeomResult<()> {
708 if !value.is_finite() || value <= tol.confusion() {
709 ogeom_bail!(Construction, "{what} {value} must be finite and positive");
710 }
711 Ok(())
712}
713
714fn full_circle_edge(
716 model: &mut Model,
717 circle: Circle,
718 at: &Shape,
719 tol: Tolerances,
720) -> OgeomResult<Shape> {
721 Ok(make_edge_between(
722 model,
723 CircleCurve::new(circle).into(),
724 (0.0, TAU),
725 at,
726 at,
727 tol,
728 )?
729 .shape)
730}
731
732fn degenerate_edge(model: &mut Model, at: &Shape, tol: Tolerances) -> OgeomResult<Shape> {
739 let _ = tol;
740 let mut data = EdgeData::new();
741 data.degenerate = true;
742 model.add_edge(data, &[at.clone(), at.clone()])
743}
744
745fn rectangle_face(
756 model: &mut Model,
757 surface: ogeom_topo::SurfaceId,
758 extent: (f64, f64),
759 edges: [&Shape; 3],
760 tol: Tolerances,
761) -> OgeomResult<Shape> {
762 let [bottom, top, seam] = edges;
763 let Some(geometry) = model.geometry().surface(surface) else {
764 ogeom_bail!(Dangling, "surface is not in this model");
765 };
766 let ((ua, _), (va, _)) = geometry.domain();
767 let (du, dv) = extent;
768 let (ub, vb) = (ua + du, va + dv);
769
770 line_pcurve(
771 model,
772 bottom,
773 surface,
774 Point2::new(ua, va),
775 Point2::new(ub, va),
776 tol,
777 )?;
778 line_pcurve(
779 model,
780 top,
781 surface,
782 Point2::new(ua, vb),
783 Point2::new(ub, vb),
784 tol,
785 )?;
786 seam_pcurves(
787 model,
788 seam,
789 surface,
790 (Point2::new(ub, va), Point2::new(ub, vb)),
791 (Point2::new(ua, va), Point2::new(ua, vb)),
792 tol,
793 )?;
794
795 let ring = [
798 bottom.clone(),
799 seam.clone(),
800 top.reversed(),
801 seam.reversed(),
802 ];
803 let wire = make_wire(model, &ring, tol)?.shape;
804 Ok(make_face_on(model, surface, std::slice::from_ref(&wire), tol)?.shape)
805}
806
807fn cap_face(
813 model: &mut Model,
814 frame: Frame,
815 outward: bool,
816 rim: &Shape,
817 circle: Circle,
818 tol: Tolerances,
819) -> OgeomResult<Shape> {
820 let normal = if outward { frame.z() } else { -frame.z() };
821 let plane = Plane::new(Frame::new(frame.origin(), normal, frame.x(), tol)?);
822 let surface = model
823 .geometry_mut()
824 .add_surface(PlaneSurface::new(plane).into());
825
826 circle_pcurve_on_plane(model, rim, surface, circle, plane, tol)?;
827
828 let edge = if outward { rim.clone() } else { rim.reversed() };
831 let wire = make_wire(model, std::slice::from_ref(&edge), tol)?.shape;
832 Ok(make_face_on(model, surface, std::slice::from_ref(&wire), tol)?.shape)
833}
834
835fn line_pcurve(
837 model: &mut Model,
838 edge: &Shape,
839 surface: ogeom_topo::SurfaceId,
840 from: Point2,
841 to: Point2,
842 tol: Tolerances,
843) -> OgeomResult<()> {
844 let pcurve: PlanarCurve = ogeom_geom::Line2d::segment(from, to, tol)?.into();
845 crate::build::attach_pcurve(
846 model,
847 edge,
848 pcurve,
849 surface,
850 ogeom_topo::Location::identity(),
851 (0.0, from.distance(to)),
852 )
853}
854
855fn seam_pcurves(
858 model: &mut Model,
859 edge: &Shape,
860 surface: ogeom_topo::SurfaceId,
861 forward: (Point2, Point2),
862 reversed: (Point2, Point2),
863 tol: Tolerances,
864) -> OgeomResult<()> {
865 let length = forward.0.distance(forward.1);
866 let first = model
867 .geometry_mut()
868 .add_pcurve(ogeom_geom::Line2d::segment(forward.0, forward.1, tol)?.into());
869 let second = model
870 .geometry_mut()
871 .add_pcurve(ogeom_geom::Line2d::segment(reversed.0, reversed.1, tol)?.into());
872
873 let Some(node) = model.node_mut(edge) else {
874 ogeom_bail!(Dangling, "edge is not in this model");
875 };
876 let ogeom_topo::NodeData::Edge(data) = node.data_mut() else {
877 ogeom_bail!(Construction, "edge node holds no edge data");
878 };
879 data.add(ogeom_topo::EdgeRepr::Seam {
880 forward: first,
881 reversed: second,
882 surface,
883 location: ogeom_topo::Location::identity(),
884 range: (0.0, length),
885 });
886 Ok(())
887}
888
889fn circle_pcurve_on_plane(
896 model: &mut Model,
897 edge: &Shape,
898 surface: ogeom_topo::SurfaceId,
899 circle: Circle,
900 plane: Plane,
901 tol: Tolerances,
902) -> OgeomResult<()> {
903 let frame = plane.frame();
904 let flat = |p: Point| {
905 let local = frame.to_local(p);
906 Point2::new(local.x, local.y)
907 };
908 let flat_direction = |d: Direction| -> OgeomResult<Direction2> {
909 let tip = flat(frame.origin() + d.vector());
910 let base = flat(frame.origin());
911 Direction2::new(tip - base, tol)
912 };
913
914 let frame2 = ogeom_math::Frame2::from_axes(
915 flat(circle.centre()),
916 flat_direction(circle.frame().x())?,
917 flat_direction(circle.frame().y())?,
918 tol,
919 )?;
920 let pcurve: PlanarCurve =
921 ogeom_geom::Circle2d::new(ogeom_math::Circle2::new(frame2, circle.radius(), tol)?).into();
922 crate::build::attach_pcurve(
923 model,
924 edge,
925 pcurve,
926 surface,
927 ogeom_topo::Location::identity(),
928 (0.0, TAU),
929 )
930}
931
932pub fn make_cone(
946 model: &mut Model,
947 frame: Frame,
948 base_radius: f64,
949 top_radius: f64,
950 height: f64,
951 tol: Tolerances,
952) -> OgeomResult<Built> {
953 check_size("cone height", height, tol)?;
954 for (what, r) in [("base radius", base_radius), ("top radius", top_radius)] {
955 if !r.is_finite() || r < 0.0 {
956 ogeom_bail!(
957 Construction,
958 "cone {what} {r} must be finite and non-negative"
959 );
960 }
961 }
962 if (base_radius - top_radius).abs() <= tol.confusion() {
963 ogeom_bail!(
964 Construction,
965 "a cone with equal radii is a cylinder; use make_cylinder"
966 );
967 }
968 if base_radius <= tol.confusion() && top_radius <= tol.confusion() {
969 ogeom_bail!(Construction, "a cone needs one end with a radius");
970 }
971 model.begin_operation();
972
973 let widening = top_radius > base_radius;
978 let (surface_frame, near_radius, far_radius) = if widening {
979 (frame, base_radius, top_radius)
980 } else {
981 (flipped(frame, height, tol)?, top_radius, base_radius)
982 };
983 let half_angle = ((far_radius - near_radius) / height).atan();
984 let cone = Cone::new(surface_frame, near_radius, half_angle, tol)?;
985
986 let near_circle = circle_at(surface_frame, near_radius, 0.0, tol);
987 let far_frame = raised(surface_frame, height, tol)?;
988 let Some(far) = circle_at(far_frame, far_radius, 0.0, tol) else {
991 ogeom_bail!(Construction, "the wide end of a cone must have a radius");
992 };
993
994 let near_vertex = model.add_vertex(VertexData::new(match near_circle {
995 Some(c) => rim_point(c),
996 None => surface_frame.origin(),
997 }));
998 let far_vertex = model.add_vertex(VertexData::new(rim_point(far)));
999
1000 let near_edge = match near_circle {
1003 Some(c) => full_circle_edge(model, c, &near_vertex, tol)?,
1004 None => degenerate_edge(model, &near_vertex, tol)?,
1005 };
1006 let far_edge = full_circle_edge(model, far, &far_vertex, tol)?;
1007
1008 let seam_start = near_circle.map_or_else(|| surface_frame.origin(), rim_point);
1009 let seam_end = rim_point(far);
1010 let seam = make_edge_between(
1011 model,
1012 LineCurve::segment(seam_start, seam_end, tol)?.into(),
1013 (0.0, slant(near_radius, far_radius, height)),
1014 &near_vertex,
1015 &far_vertex,
1016 tol,
1017 )?
1018 .shape;
1019
1020 let lateral_id = model
1021 .geometry_mut()
1022 .add_surface(ogeom_geom::ConeSurface::new(cone, (0.0, height))?.into());
1023 let lateral = rectangle_face(
1024 model,
1025 lateral_id,
1026 (TAU, height),
1027 [&near_edge, &far_edge, &seam],
1028 tol,
1029 )?;
1030 model.set_derived(&lateral, &[], roles::FACE_LATERAL)?;
1031
1032 let mut faces = vec![lateral];
1033 if let Some(c) = near_circle {
1035 let cap = cap_face(model, surface_frame, false, &near_edge, c, tol)?;
1036 model.set_derived(
1037 &cap,
1038 &[],
1039 if widening {
1040 roles::FACE_MIN_Z
1041 } else {
1042 roles::FACE_MAX_Z
1043 },
1044 )?;
1045 faces.push(cap);
1046 }
1047 let far_cap = cap_face(model, far_frame, true, &far_edge, far, tol)?;
1048 model.set_derived(
1049 &far_cap,
1050 &[],
1051 if widening {
1052 roles::FACE_MAX_Z
1053 } else {
1054 roles::FACE_MIN_Z
1055 },
1056 )?;
1057 faces.push(far_cap);
1058
1059 let shell = make_shell(model, &faces)?.shape;
1060 let solid = make_solid(model, std::slice::from_ref(&shell))?.shape;
1061 Ok(Built::from_nothing(solid))
1062}
1063
1064pub fn make_torus(
1072 model: &mut Model,
1073 frame: Frame,
1074 major: f64,
1075 minor: f64,
1076 tol: Tolerances,
1077) -> OgeomResult<Built> {
1078 check_size("torus major radius", major, tol)?;
1079 check_size("torus minor radius", minor, tol)?;
1080 model.begin_operation();
1081
1082 let start = frame.to_world(Point::new(major + minor, 0.0, 0.0));
1087 let corner = model.add_vertex(VertexData::new(start));
1088
1089 let equator = Circle::new(frame, major + minor, tol)?;
1091 let along_u = full_circle_edge(model, equator, &corner, tol)?;
1092
1093 let tube_frame = Frame::new(
1096 frame.to_world(Point::new(major, 0.0, 0.0)),
1097 -frame.y(),
1098 frame.x(),
1099 tol,
1100 )?;
1101 let along_v = full_circle_edge(model, Circle::new(tube_frame, minor, tol)?, &corner, tol)?;
1102
1103 let surface = model
1104 .geometry_mut()
1105 .add_surface(ogeom_geom::TorusSurface::new(Torus::new(frame, major, minor, tol)?).into());
1106 let face = doubly_seamed_face(model, surface, &along_u, &along_v, tol)?;
1107 model.set_derived(&face, &[], roles::FACE_LATERAL)?;
1108
1109 let shell = make_shell(model, std::slice::from_ref(&face))?.shape;
1110 let solid = make_solid(model, std::slice::from_ref(&shell))?.shape;
1111 Ok(Built::from_nothing(solid))
1112}
1113
1114pub fn make_wedge(
1129 model: &mut Model,
1130 frame: Frame,
1131 size: (f64, f64, f64),
1132 top: (f64, f64),
1133 tol: Tolerances,
1134) -> OgeomResult<Built> {
1135 let (dx, dy, dz) = size;
1136 for (name, value) in [("x", dx), ("y", dy), ("z", dz)] {
1137 check_size(&format!("wedge {name} size"), value, tol)?;
1138 }
1139 for (name, value) in [("x", top.0), ("y", top.1)] {
1140 if !value.is_finite() || value < 0.0 {
1141 ogeom_bail!(
1142 Construction,
1143 "wedge top {name} extent {value} must be finite and non-negative"
1144 );
1145 }
1146 }
1147 model.begin_operation();
1148
1149 let (dx_, dy_, dz_) = size;
1153 let collapsed = (top.0 <= tol.confusion(), top.1 <= tol.confusion());
1154 match collapsed {
1155 (true, true) => {
1156 let local = [
1157 Point::new(0.0, 0.0, 0.0),
1158 Point::new(dx_, 0.0, 0.0),
1159 Point::new(dx_, dy_, 0.0),
1160 Point::new(0.0, dy_, 0.0),
1161 Point::new(0.0, 0.0, dz_),
1162 ];
1163 let points: Vec<Point> = local.iter().map(|p| frame.to_world(*p)).collect();
1164 let rings: [&[usize]; 5] = [
1165 &[0, 3, 2, 1],
1166 &[0, 1, 4],
1167 &[1, 2, 4],
1168 &[2, 3, 4],
1169 &[3, 0, 4],
1170 ];
1171 return faceted_solid(model, &points, &rings, tol);
1172 }
1173 (false, true) => {
1174 let local = [
1175 Point::new(0.0, 0.0, 0.0),
1176 Point::new(dx_, 0.0, 0.0),
1177 Point::new(dx_, dy_, 0.0),
1178 Point::new(0.0, dy_, 0.0),
1179 Point::new(0.0, 0.0, dz_),
1180 Point::new(top.0, 0.0, dz_),
1181 ];
1182 let points: Vec<Point> = local.iter().map(|p| frame.to_world(*p)).collect();
1183 let rings: [&[usize]; 5] = [
1184 &[0, 3, 2, 1],
1185 &[0, 1, 5, 4],
1186 &[1, 2, 5],
1187 &[2, 3, 4, 5],
1188 &[3, 0, 4],
1189 ];
1190 return faceted_solid(model, &points, &rings, tol);
1191 }
1192 (true, false) => {
1193 let local = [
1194 Point::new(0.0, 0.0, 0.0),
1195 Point::new(dx_, 0.0, 0.0),
1196 Point::new(dx_, dy_, 0.0),
1197 Point::new(0.0, dy_, 0.0),
1198 Point::new(0.0, 0.0, dz_),
1199 Point::new(0.0, top.1, dz_),
1200 ];
1201 let points: Vec<Point> = local.iter().map(|p| frame.to_world(*p)).collect();
1202 let rings: [&[usize]; 5] = [
1203 &[0, 3, 2, 1],
1204 &[0, 4, 5, 3],
1205 &[0, 1, 4],
1206 &[1, 2, 5, 4],
1207 &[2, 3, 5],
1208 ];
1209 return faceted_solid(model, &points, &rings, tol);
1210 }
1211 (false, false) => {}
1212 }
1213
1214 let corners: Vec<Point> = CORNERS
1219 .iter()
1220 .map(|&(i, j, k)| {
1221 #[allow(clippy::cast_precision_loss)]
1222 let (fi, fj) = (i as f64, j as f64);
1223 let (ex, ey) = if k == 0 { (dx, dy) } else { (top.0, top.1) };
1224 #[allow(clippy::cast_precision_loss)]
1225 frame.to_world(Point::new(fi * ex, fj * ey, k as f64 * dz))
1226 })
1227 .collect();
1228 box_like(model, &corners, tol)
1229}
1230
1231pub fn make_half_space(
1257 model: &mut Model,
1258 face: &Shape,
1259 inside: Point,
1260 tol: Tolerances,
1261) -> OgeomResult<Built> {
1262 if model.kind_of(face)? != ShapeType::Face {
1263 ogeom_bail!(Construction, "a half space is bounded by a face");
1264 }
1265 let (at, normal) = nearest_normal(model, face, inside, tol)?;
1269 let towards = inside - at;
1270 let reach = towards.magnitude();
1271 if reach <= tol.confusion() {
1272 ogeom_bail!(
1273 Construction,
1274 "the point naming the solid side lies on the face itself, so it \
1275 names no side"
1276 );
1277 }
1278 let along = normal.dot(towards) / reach;
1279 if along.abs() <= tol.angular() {
1280 ogeom_bail!(
1281 Construction,
1282 "the point naming the solid side lies in the face's own surface, so \
1283 it names no side"
1284 );
1285 }
1286 model.begin_operation();
1287
1288 let boundary = if along > 0.0 {
1291 face.reversed()
1292 } else {
1293 face.clone()
1294 };
1295 let shell = make_shell(model, std::slice::from_ref(&boundary))?.shape;
1296 let solid = make_solid(model, std::slice::from_ref(&shell))?.shape;
1297 model.set_derived(&solid, std::slice::from_ref(face), roles::FACE_LATERAL)?;
1298
1299 let mut history = crate::history::History::new();
1300 history.generate(face, shell);
1301 history.generate(face, solid.clone());
1302 Ok(Built::new(solid, history))
1303}
1304
1305fn nearest_normal(
1308 model: &Model,
1309 face: &Shape,
1310 target: Point,
1311 tol: Tolerances,
1312) -> OgeomResult<(Point, ogeom_math::Vector)> {
1313 let Some(data) = model.node(face).and_then(|n| n.data().as_face()) else {
1314 ogeom_bail!(Construction, "face node holds no face data");
1315 };
1316 let Some(surface) = model.geometry().surface(data.surface) else {
1317 ogeom_bail!(Dangling, "face refers to a surface not in this model");
1318 };
1319 let placement = face.transform(model.datums())?;
1320 let local = placement.inverse()?.apply(target);
1321 let foot = crate::measure::project_on_surface(surface, local, 32, tol)?;
1322 let (u, v) = foot.parameters;
1323 let normal = match surface.normal_at(u, v, tol) {
1326 Ok(n) => n,
1327 Err(_) => {
1328 let ((ua, ub), (va, vb)) = surface.domain();
1329 let (mu, mv) = (f64::midpoint(ua, ub), f64::midpoint(va, vb));
1330 let nudge = |x: f64, mid: f64| x + (mid - x).signum() * 1e-6 * (1.0 + x.abs());
1331 surface.normal_at(nudge(u, mu), nudge(v, mv), tol)?
1332 }
1333 };
1334 let normal = placement.apply_vector(normal.vector());
1335 let normal = if face.orientation() == ogeom_topo::Orientation::Reversed {
1336 -normal
1337 } else {
1338 normal
1339 };
1340 Ok((placement.apply(foot.point), normal))
1341}
1342
1343fn flipped(frame: Frame, height: f64, tol: Tolerances) -> OgeomResult<Frame> {
1346 Frame::new(
1347 frame.to_world(Point::new(0.0, 0.0, height)),
1348 -frame.z(),
1349 frame.x(),
1350 tol,
1351 )
1352}
1353
1354fn circle_at(frame: Frame, radius: f64, _at: f64, tol: Tolerances) -> Option<Circle> {
1356 if radius <= tol.confusion() {
1357 return None;
1358 }
1359 Circle::new(frame, radius, tol).ok()
1360}
1361
1362fn slant(near: f64, far: f64, height: f64) -> f64 {
1364 (far - near).hypot(height)
1365}
1366
1367fn doubly_seamed_face(
1373 model: &mut Model,
1374 surface: ogeom_topo::SurfaceId,
1375 along_u: &Shape,
1376 along_v: &Shape,
1377 tol: Tolerances,
1378) -> OgeomResult<Shape> {
1379 let (o, e) = (0.0, TAU);
1380 seam_pcurves(
1383 model,
1384 along_u,
1385 surface,
1386 (Point2::new(o, o), Point2::new(e, o)),
1387 (Point2::new(o, e), Point2::new(e, e)),
1388 tol,
1389 )?;
1390 seam_pcurves(
1391 model,
1392 along_v,
1393 surface,
1394 (Point2::new(e, o), Point2::new(e, e)),
1395 (Point2::new(o, o), Point2::new(o, e)),
1396 tol,
1397 )?;
1398
1399 let ring = [
1400 along_u.clone(),
1401 along_v.clone(),
1402 along_u.reversed(),
1403 along_v.reversed(),
1404 ];
1405 let wire = make_wire(model, &ring, tol)?.shape;
1406 Ok(make_face_on(model, surface, std::slice::from_ref(&wire), tol)?.shape)
1407}
1408
1409#[cfg(test)]
1410#[allow(clippy::unwrap_used)]
1411mod tests {
1412 use super::*;
1413 use crate::build::is_shell_closed;
1414 use ogeom_geom::Surface;
1415 use ogeom_topo::{ShapeType, explore_unique};
1416
1417 const T: Tolerances = Tolerances::millimetres();
1418
1419 #[test]
1420 fn a_box_has_the_topology_a_box_should_have() {
1421 let mut model = Model::new();
1422 let built = make_box(&mut model, Frame::WORLD, (2.0, 3.0, 4.0), T).unwrap();
1423 let solid = &built.shape;
1424
1425 assert_eq!(model.kind_of(solid).unwrap(), ShapeType::Solid);
1426 assert_eq!(
1427 explore_unique(&model, solid, ShapeType::Shell)
1428 .unwrap()
1429 .len(),
1430 1
1431 );
1432 assert_eq!(
1433 explore_unique(&model, solid, ShapeType::Face)
1434 .unwrap()
1435 .len(),
1436 6
1437 );
1438 assert_eq!(
1439 explore_unique(&model, solid, ShapeType::Wire)
1440 .unwrap()
1441 .len(),
1442 6
1443 );
1444 assert_eq!(
1445 explore_unique(&model, solid, ShapeType::Edge)
1446 .unwrap()
1447 .len(),
1448 12,
1449 "edges are shared between adjacent faces, not duplicated per face"
1450 );
1451 assert_eq!(
1452 explore_unique(&model, solid, ShapeType::Vertex)
1453 .unwrap()
1454 .len(),
1455 8
1456 );
1457 }
1458
1459 #[test]
1460 fn a_boxs_shell_is_closed() {
1461 let mut model = Model::new();
1464 let built = make_box(&mut model, Frame::WORLD, (1.0, 1.0, 1.0), T).unwrap();
1465 let shell = explore_unique(&model, &built.shape, ShapeType::Shell).unwrap();
1466 assert!(is_shell_closed(&model, &shell[0]).unwrap());
1467 }
1468
1469 #[test]
1470 fn every_face_normal_points_out_of_the_box() {
1471 let mut model = Model::new();
1475 let size = (2.0, 3.0, 4.0);
1476 let built = make_box(&mut model, Frame::WORLD, size, T).unwrap();
1477 let centre = Point::new(size.0 / 2.0, size.1 / 2.0, size.2 / 2.0);
1478
1479 let faces = explore_unique(&model, &built.shape, ShapeType::Face).unwrap();
1480 assert_eq!(faces.len(), 6);
1481 for face in &faces {
1482 let node = model.node(face).unwrap();
1483 let data = node.data().as_face().unwrap();
1484 let surface = model.geometry().surface(data.surface).unwrap();
1485 let ((ua, ub), (va, vb)) = surface.domain();
1486 let point = surface
1487 .point_at((ua + ub) / 2.0, (va + vb) / 2.0, T)
1488 .unwrap();
1489 let normal = surface
1490 .normal_at((ua + ub) / 2.0, (va + vb) / 2.0, T)
1491 .unwrap();
1492
1493 let outward = surface.point_at(0.0, 0.0, T).unwrap() - centre;
1496 assert!(
1497 normal.dot_vector(outward) > 0.0,
1498 "a face normal points inward: at {point:?}, normal {normal:?}"
1499 );
1500 }
1501 }
1502
1503 #[test]
1504 fn the_six_faces_carry_distinct_roles() {
1505 let mut model = Model::new();
1509 let built = make_box(&mut model, Frame::WORLD, (1.0, 2.0, 3.0), T).unwrap();
1510 let faces = explore_unique(&model, &built.shape, ShapeType::Face).unwrap();
1511
1512 let mut roles: Vec<Role> = faces
1513 .iter()
1514 .map(|f| match model.provenance_of(f).unwrap() {
1515 ogeom_core::Provenance::Derived { role, .. } => *role,
1516 other => panic!("expected a derived face, got {other:?}"),
1517 })
1518 .collect();
1519 roles.sort_unstable();
1520 roles.dedup();
1521 assert_eq!(roles.len(), 6, "each face is identifiable on its own");
1522 }
1523
1524 #[test]
1525 fn rebuilding_at_a_different_size_gives_the_faces_the_same_roles() {
1526 let roles_of = |size| {
1530 let mut model = Model::new();
1531 let built = make_box(&mut model, Frame::WORLD, size, T).unwrap();
1532 let mut roles: Vec<Role> = explore_unique(&model, &built.shape, ShapeType::Face)
1533 .unwrap()
1534 .iter()
1535 .map(|f| match model.provenance_of(f).unwrap() {
1536 ogeom_core::Provenance::Derived { role, .. } => *role,
1537 other => panic!("expected a derived face, got {other:?}"),
1538 })
1539 .collect();
1540 roles.sort_unstable();
1541 roles
1542 };
1543 assert_eq!(roles_of((1.0, 1.0, 1.0)), roles_of((10.0, 0.5, 7.0)));
1544 }
1545
1546 #[test]
1547 fn every_edge_carries_a_pcurve_for_each_face_it_bounds() {
1548 let mut model = Model::new();
1552 let built = make_box(&mut model, Frame::WORLD, (1.0, 2.0, 3.0), T).unwrap();
1553 for edge in explore_unique(&model, &built.shape, ShapeType::Edge).unwrap() {
1554 let data = model.node(&edge).unwrap().data().as_edge().unwrap();
1555 assert_eq!(
1556 data.parametric_surfaces().len(),
1557 2,
1558 "a box edge borders exactly two faces"
1559 );
1560 assert!(data.curve3d().is_some());
1561 }
1562 }
1563
1564 #[test]
1565 fn a_parallelepiped_spans_its_triple_product_in_either_handedness() {
1566 use ogeom_math::Vector;
1567 let edges = [
1568 Vector::new(10.0, 0.0, 0.0),
1569 Vector::new(3.0, 10.0, 0.0),
1570 Vector::new(2.0, 1.0, 10.0),
1571 ];
1572 for order in [[0, 1, 2], [1, 0, 2]] {
1573 let mut model = Model::new();
1574 let spanned = [edges[order[0]], edges[order[1]], edges[order[2]]];
1575 let solid = make_parallelepiped(&mut model, Point::new(1.0, 2.0, 3.0), spanned, T)
1576 .unwrap()
1577 .shape;
1578 assert_eq!(model.kind_of(&solid).unwrap(), ShapeType::Solid);
1579 let faces = explore_unique(&model, &solid, ShapeType::Face).unwrap();
1580 assert_eq!(faces.len(), 6);
1581 assert_eq!(
1582 explore_unique(&model, &solid, ShapeType::Edge)
1583 .unwrap()
1584 .len(),
1585 12
1586 );
1587 assert_eq!(
1588 explore_unique(&model, &solid, ShapeType::Vertex)
1589 .unwrap()
1590 .len(),
1591 8
1592 );
1593 let shell = explore_unique(&model, &solid, ShapeType::Shell)
1594 .unwrap()
1595 .remove(0);
1596 assert!(crate::is_shell_closed(&model, &shell).unwrap());
1597 let centre = Point::new(1.0, 2.0, 3.0) + (edges[0] + edges[1] + edges[2]) * 0.5;
1600 for face in &faces {
1601 let corners: Vec<Point> = explore_unique(&model, face, ShapeType::Vertex)
1602 .unwrap()
1603 .iter()
1604 .map(|v| model.node(v).unwrap().data().as_vertex().unwrap().point)
1605 .collect();
1606 let mut mid = ogeom_math::Vector::ZERO;
1607 for c in &corners {
1608 mid += c.to_vector();
1609 }
1610 let mid = Point::ORIGIN + mid * 0.25;
1611 let data = model.node(face).unwrap().data().as_face().unwrap().clone();
1612 let Some(ogeom_geom::SurfaceGeometry::Plane(plane)) =
1613 model.geometry().surface(data.surface)
1614 else {
1615 panic!("a parallelepiped's faces are planes");
1616 };
1617 let mut normal = plane.plane().normal().vector();
1618 if face.orientation() == ogeom_topo::Orientation::Reversed {
1619 normal = -normal;
1620 }
1621 assert!(normal.dot(mid - centre) > 0.0, "a face looks outward");
1622 }
1623 }
1624 let mut model = Model::new();
1625 assert!(
1626 make_parallelepiped(
1627 &mut model,
1628 Point::ORIGIN,
1629 [edges[0], edges[1], edges[0] + edges[1]],
1630 T
1631 )
1632 .is_err(),
1633 "coplanar edges span no volume"
1634 );
1635 }
1636
1637 #[test]
1640 fn a_hexahedron_is_a_frustum_when_its_corners_say_so() {
1641 let mut model = Model::new();
1642 let (h, a, b) = (3.0, 2.0, 1.0);
1643 let corners = [
1644 Point::new(-a, -a, 0.0),
1645 Point::new(a, -a, 0.0),
1646 Point::new(a, a, 0.0),
1647 Point::new(-a, a, 0.0),
1648 Point::new(-b, -b, h),
1649 Point::new(b, -b, h),
1650 Point::new(b, b, h),
1651 Point::new(-b, b, h),
1652 ];
1653 let solid = make_hexahedron(&mut model, corners, T).unwrap().shape;
1654 assert_eq!(model.kind_of(&solid).unwrap(), ShapeType::Solid);
1655 assert_eq!(
1656 explore_unique(&model, &solid, ShapeType::Face)
1657 .unwrap()
1658 .len(),
1659 6
1660 );
1661 let shell = explore_unique(&model, &solid, ShapeType::Shell)
1662 .unwrap()
1663 .remove(0);
1664 assert!(crate::is_shell_closed(&model, &shell).unwrap());
1665 let (bottom, top) = (4.0 * a * a, 4.0 * b * b);
1666 let expected = h / 3.0 * (bottom + top + (bottom * top).sqrt());
1667 let measured =
1668 crate::volume_properties(&model, &solid, ogeom_mesh::Deflection::default(), T)
1669 .unwrap()
1670 .mass;
1671 assert!(
1672 (measured - expected).abs() < expected * 1e-6,
1673 "frustum volume {measured} against {expected}"
1674 );
1675 let mut skewed = corners;
1677 skewed[6] = Point::new(b, b + 0.5, h);
1678 assert!(make_hexahedron(&mut model, skewed, T).is_err());
1679 }
1680
1681 #[test]
1686 fn a_polyhedron_winds_its_rings_outward_and_closes() {
1687 let mut model = Model::new();
1688 let points = [
1689 Point::new(0.0, 0.0, 0.0),
1690 Point::new(4.0, 0.0, 0.0),
1691 Point::new(0.0, 3.0, 0.0),
1692 Point::new(0.0, 0.0, 5.0),
1693 Point::new(4.0, 0.0, 5.0),
1694 Point::new(0.0, 3.0, 5.0),
1695 ];
1696 let rings = vec![
1697 vec![0, 1, 2], vec![3, 4, 5], vec![0, 1, 4, 3], vec![1, 2, 5, 4], vec![0, 3, 5, 2], ];
1703 let solid = make_polyhedron(&mut model, &points, &rings, T)
1704 .unwrap()
1705 .shape;
1706 assert_eq!(model.kind_of(&solid).unwrap(), ShapeType::Solid);
1707 assert_eq!(
1708 explore_unique(&model, &solid, ShapeType::Edge)
1709 .unwrap()
1710 .len(),
1711 9
1712 );
1713 let shell = explore_unique(&model, &solid, ShapeType::Shell)
1714 .unwrap()
1715 .remove(0);
1716 assert!(crate::is_shell_closed(&model, &shell).unwrap());
1717 let expected = 0.5 * 4.0 * 3.0 * 5.0;
1718 let measured =
1719 crate::volume_properties(&model, &solid, ogeom_mesh::Deflection::default(), T)
1720 .unwrap()
1721 .mass;
1722 assert!(
1723 (measured - expected).abs() < expected * 1e-6,
1724 "prism volume {measured} against {expected}"
1725 );
1726 let open = make_polyhedron(&mut model, &points, &rings[..4], T);
1728 assert!(open.is_err());
1729 let mut bent = points;
1731 bent[4] = Point::new(4.0, 0.5, 5.0);
1732 assert!(make_polyhedron(&mut model, &bent, &rings, T).is_err());
1733 }
1734
1735 #[test]
1736 fn a_box_in_a_tilted_frame_is_still_a_box() {
1737 let frame = Frame::new(
1738 Point::new(5.0, -2.0, 1.0),
1739 Direction::from_coords(1.0, 1.0, 1.0, T).unwrap(),
1740 Direction::from_coords(1.0, -1.0, 0.0, T).unwrap(),
1741 T,
1742 )
1743 .unwrap();
1744 let mut model = Model::new();
1745 let built = make_box(&mut model, frame, (2.0, 2.0, 2.0), T).unwrap();
1746
1747 assert_eq!(
1748 explore_unique(&model, &built.shape, ShapeType::Face)
1749 .unwrap()
1750 .len(),
1751 6
1752 );
1753 let shell = explore_unique(&model, &built.shape, ShapeType::Shell).unwrap();
1754 assert!(is_shell_closed(&model, &shell[0]).unwrap());
1755
1756 let vertices = explore_unique(&model, &built.shape, ShapeType::Vertex).unwrap();
1758 let origin_corner = frame.to_world(Point::ORIGIN);
1759 assert!(
1760 vertices.iter().any(|v| {
1761 model
1762 .node(v)
1763 .unwrap()
1764 .data()
1765 .as_vertex()
1766 .unwrap()
1767 .point
1768 .is_equal(origin_corner, T)
1769 }),
1770 "no vertex at the frame origin"
1771 );
1772 }
1773
1774 #[test]
1775 fn degenerate_dimensions_are_refused() {
1776 let mut model = Model::new();
1777 for size in [
1778 (0.0, 1.0, 1.0),
1779 (1.0, -1.0, 1.0),
1780 (1.0, 1.0, f64::NAN),
1781 (f64::INFINITY, 1.0, 1.0),
1782 ] {
1783 assert!(
1784 make_box(&mut model, Frame::WORLD, size, T).is_err(),
1785 "accepted {size:?}"
1786 );
1787 }
1788 assert!(make_box(&mut model, Frame::WORLD, (1.0, 1.0, 1.0), T).is_ok());
1789 }
1790
1791 #[test]
1792 fn a_primitive_reports_no_history_because_it_consumed_nothing() {
1793 let mut model = Model::new();
1794 let built = make_box(&mut model, Frame::WORLD, (1.0, 1.0, 1.0), T).unwrap();
1795 assert!(
1796 built.history.is_empty(),
1797 "built from numbers, so there are no inputs to report on"
1798 );
1799 }
1800
1801 #[test]
1802 fn every_corner_pair_of_a_face_is_a_real_edge() {
1803 for (corners, _) in FACES {
1806 for step in 0..4 {
1807 let (from, to) = (corners[step], corners[(step + 1) % 4]);
1808 assert!(
1809 find_edge(from, to).is_ok(),
1810 "face corners {from} and {to} are not joined"
1811 );
1812 }
1813 }
1814 assert!(find_edge(0, 6).is_err(), "opposite corners share no edge");
1815 }
1816
1817 #[test]
1818 fn every_edge_is_used_by_exactly_two_faces_in_the_tables() {
1819 let mut uses = [0_usize; EDGES.len()];
1823 for (corners, _) in FACES {
1824 for step in 0..4 {
1825 let (from, to) = (corners[step], corners[(step + 1) % 4]);
1826 let (index, _) = find_edge(from, to).unwrap();
1827 uses[index] += 1;
1828 }
1829 }
1830 assert!(uses.iter().all(|&n| n == 2), "edge use counts: {uses:?}");
1831 }
1832}
1833
1834#[cfg(test)]
1835#[allow(clippy::unwrap_used)]
1836mod revolution_tests {
1837 use super::*;
1838 use crate::build::is_shell_closed;
1839 use crate::mass::{surface_properties, volume_properties};
1840 use approx::assert_relative_eq;
1841 use ogeom_mesh::{Deflection, triangulate};
1842 use ogeom_topo::{ShapeType, explore_unique};
1843
1844 const T: Tolerances = Tolerances::millimetres();
1845
1846 fn deflection(chord: f64) -> Deflection {
1847 Deflection {
1848 chord,
1849 ..Deflection::default()
1850 }
1851 }
1852
1853 #[test]
1854 fn a_cylinder_has_the_topology_a_cylinder_has() {
1855 let mut model = Model::new();
1856 let built = make_cylinder(&mut model, Frame::WORLD, 2.0, 5.0, T).unwrap();
1857
1858 let counts = |kind| explore_unique(&model, &built.shape, kind).unwrap().len();
1859 assert_eq!(counts(ShapeType::Face), 3, "a side and two caps");
1860 assert_eq!(counts(ShapeType::Edge), 3, "two rims and one seam");
1861 assert_eq!(counts(ShapeType::Vertex), 2, "one on each rim");
1862
1863 let shell = explore_unique(&model, &built.shape, ShapeType::Shell).unwrap()[0].clone();
1864 assert!(
1865 is_shell_closed(&model, &shell).unwrap(),
1866 "every edge should be used an even number of times"
1867 );
1868 }
1869
1870 #[test]
1871 fn a_cylinder_tessellates_into_a_closed_mesh_of_the_right_size() {
1872 let (radius, height) = (2.0_f64, 5.0);
1876 let exact = PI * radius * radius * height;
1877 let mut model = Model::new();
1878 let built = make_cylinder(&mut model, Frame::WORLD, radius, height, T).unwrap();
1879
1880 let mut previous = 0.0;
1886 for chord in [0.1_f64, 0.02, 0.005] {
1887 let mesh = triangulate(&model, &built.shape, deflection(chord), T).unwrap();
1888 assert!(mesh.is_closed(), "the mesh has a hole at chord {chord}");
1889 assert!(
1890 mesh.volume() < exact,
1891 "an inscribed volume cannot exceed it"
1892 );
1893 assert!(mesh.volume() >= previous, "refining lost volume");
1894 previous = mesh.volume();
1895 }
1896 assert!(previous > exact * 0.995, "{previous} against {exact}");
1897 let props = volume_properties(&model, &built.shape, deflection(0.005), T).unwrap();
1898 assert_relative_eq!(props.mass, exact, epsilon = 1e-9);
1899 assert_eq!(props.deflection, 0.0, "measured on the exact surface");
1900 }
1901
1902 #[test]
1903 fn a_cylinders_caps_face_outward() {
1904 let mut model = Model::new();
1908 let built = make_cylinder(&mut model, Frame::WORLD, 1.0, 3.0, T).unwrap();
1909 let props = volume_properties(&model, &built.shape, deflection(0.005), T).unwrap();
1910
1911 assert!(
1912 props.centre.distance(Point::new(0.0, 0.0, 1.5)) < 1e-3,
1913 "the centre of a cylinder is halfway up its axis, got {:?}",
1914 props.centre
1915 );
1916 }
1917
1918 #[test]
1919 fn a_sphere_has_the_topology_a_sphere_has() {
1920 let mut model = Model::new();
1921 let built = make_sphere(&mut model, Frame::WORLD, 3.0, T).unwrap();
1922
1923 let counts = |kind| explore_unique(&model, &built.shape, kind).unwrap().len();
1924 assert_eq!(counts(ShapeType::Face), 1, "one surface covers a sphere");
1925 assert_eq!(counts(ShapeType::Edge), 3, "a seam and two poles");
1926 assert_eq!(counts(ShapeType::Vertex), 2, "the two poles");
1927
1928 let degenerate = explore_unique(&model, &built.shape, ShapeType::Edge)
1931 .unwrap()
1932 .into_iter()
1933 .filter(|e| {
1934 model
1935 .node(e)
1936 .and_then(|n| n.data().as_edge())
1937 .is_some_and(|d| d.degenerate)
1938 })
1939 .count();
1940 assert_eq!(degenerate, 2);
1941 }
1942
1943 #[test]
1944 fn a_sphere_converges_on_the_volume_and_area_a_sphere_has() {
1945 let radius = 4.0_f64;
1946 let volume = 4.0 / 3.0 * PI * radius.powi(3);
1947 let area = 4.0 * PI * radius * radius;
1948 let mut model = Model::new();
1949 let built = make_sphere(&mut model, Frame::WORLD, radius, T).unwrap();
1950
1951 let props = volume_properties(&model, &built.shape, deflection(0.01), T).unwrap();
1952 assert_relative_eq!(props.mass, volume, epsilon = 1e-9);
1953 assert_eq!(props.deflection, 0.0, "measured on the exact surface");
1954 assert!(
1955 props.centre.distance(Point::ORIGIN) < 1e-9,
1956 "got {:?}",
1957 props.centre
1958 );
1959
1960 let surface = surface_properties(&model, &built.shape, deflection(0.01), T).unwrap();
1961 assert_relative_eq!(surface.mass, area, epsilon = 1e-9);
1962 assert_eq!(surface.deflection, 0.0, "measured on the exact surface");
1963 }
1964
1965 #[test]
1966 fn a_placed_primitive_lands_where_it_was_placed() {
1967 let frame = Frame::new(Point::new(10.0, -5.0, 2.0), Direction::X, Direction::Y, T).unwrap();
1968 let mut model = Model::new();
1969 let built = make_cylinder(&mut model, frame, 1.0, 4.0, T).unwrap();
1970 let props = volume_properties(&model, &built.shape, deflection(0.005), T).unwrap();
1971
1972 assert!(
1974 props.centre.distance(Point::new(12.0, -5.0, 2.0)) < 1e-3,
1975 "got {:?}",
1976 props.centre
1977 );
1978 assert_relative_eq!(props.mass, PI * 4.0, max_relative = 0.01);
1980 }
1981
1982 #[test]
1983 fn dimensions_that_describe_no_solid_are_refused() {
1984 let mut model = Model::new();
1985 for (r, h) in [(0.0, 1.0), (1.0, 0.0), (-1.0, 1.0), (f64::NAN, 1.0)] {
1986 assert!(make_cylinder(&mut model, Frame::WORLD, r, h, T).is_err());
1987 }
1988 for r in [0.0, -1.0, f64::INFINITY] {
1989 assert!(make_sphere(&mut model, Frame::WORLD, r, T).is_err());
1990 }
1991 }
1992}
1993
1994#[cfg(test)]
1995#[allow(clippy::unwrap_used)]
1996mod more_primitive_tests {
1997 use super::*;
1998 use crate::build::is_shell_closed;
1999 use crate::mass::volume_properties;
2000 use approx::assert_relative_eq;
2001 use ogeom_mesh::{Deflection, triangulate};
2002 use ogeom_topo::{ShapeType, explore_unique};
2003
2004 const T: Tolerances = Tolerances::millimetres();
2005
2006 fn deflection(chord: f64) -> Deflection {
2007 Deflection {
2008 chord,
2009 ..Deflection::default()
2010 }
2011 }
2012
2013 fn closed(model: &Model, solid: &Shape) -> bool {
2014 let shell = explore_unique(model, solid, ShapeType::Shell).unwrap()[0].clone();
2015 is_shell_closed(model, &shell).unwrap()
2016 }
2017
2018 #[test]
2019 fn a_truncated_cone_has_the_volume_a_frustum_has() {
2020 let (r0, r1, h) = (3.0_f64, 1.0_f64, 4.0_f64);
2021 let exact = PI * h / 3.0 * r1.mul_add(r1, r0.mul_add(r0, r0 * r1));
2022 let mut model = Model::new();
2023 let built = make_cone(&mut model, Frame::WORLD, r0, r1, h, T).unwrap();
2024
2025 assert!(closed(&model, &built.shape));
2026 let props = volume_properties(&model, &built.shape, deflection(0.005), T).unwrap();
2027 assert!(props.mass < exact, "an inscribed volume cannot exceed it");
2028 assert!(props.mass > exact * 0.995, "{} against {exact}", props.mass);
2029 }
2030
2031 #[test]
2032 fn a_true_cone_ends_in_an_apex_and_has_no_top_cap() {
2033 let (radius, height) = (2.0_f64, 5.0);
2037 let exact = PI * radius * radius * height / 3.0;
2038 let mut model = Model::new();
2039 let built = make_cone(&mut model, Frame::WORLD, radius, 0.0, height, T).unwrap();
2040
2041 assert_eq!(
2042 explore_unique(&model, &built.shape, ShapeType::Face)
2043 .unwrap()
2044 .len(),
2045 2,
2046 "a flank and one cap"
2047 );
2048 assert!(closed(&model, &built.shape));
2049 let props = volume_properties(&model, &built.shape, deflection(0.005), T).unwrap();
2050 assert!(props.mass < exact);
2051 assert!(props.mass > exact * 0.99, "{} against {exact}", props.mass);
2052 }
2053
2054 #[test]
2055 fn a_cone_widening_upward_is_built_the_same_way_round() {
2056 let mut model = Model::new();
2060 let up = make_cone(&mut model, Frame::WORLD, 1.0, 3.0, 4.0, T).unwrap();
2061 let down = make_cone(&mut model, Frame::WORLD, 3.0, 1.0, 4.0, T).unwrap();
2062
2063 let a = volume_properties(&model, &up.shape, deflection(0.005), T).unwrap();
2064 let b = volume_properties(&model, &down.shape, deflection(0.005), T).unwrap();
2065 assert_relative_eq!(a.mass, b.mass, max_relative = 1e-9);
2066 assert_relative_eq!(a.centre.z, 4.0 - b.centre.z, epsilon = 1e-9);
2068 }
2069
2070 #[test]
2071 fn a_torus_has_two_seams_one_vertex_and_the_volume_a_torus_has() {
2072 let (major, minor) = (5.0_f64, 2.0);
2075 let exact = 2.0 * PI * PI * major * minor * minor;
2076 let mut model = Model::new();
2077 let built = make_torus(&mut model, Frame::WORLD, major, minor, T).unwrap();
2078
2079 let counts = |kind| explore_unique(&model, &built.shape, kind).unwrap().len();
2080 assert_eq!(counts(ShapeType::Face), 1);
2081 assert_eq!(counts(ShapeType::Edge), 2, "one seam each way");
2082 assert_eq!(counts(ShapeType::Vertex), 1, "where the two seams cross");
2083 assert!(closed(&model, &built.shape));
2084
2085 let mesh = triangulate(&model, &built.shape, deflection(0.02), T).unwrap();
2086 assert!(mesh.is_closed(), "the mesh has a hole");
2087
2088 let props = volume_properties(&model, &built.shape, deflection(0.02), T).unwrap();
2089 assert_relative_eq!(props.mass, exact, epsilon = 1e-9);
2090 assert_eq!(props.deflection, 0.0, "measured on the exact surface");
2091 assert!(props.centre.distance(Point::ORIGIN) < 1e-9);
2092 }
2093
2094 #[test]
2095 fn a_wedge_with_equal_extents_is_a_box() {
2096 let mut model = Model::new();
2097 let wedge = make_wedge(&mut model, Frame::WORLD, (2.0, 3.0, 4.0), (2.0, 3.0), T).unwrap();
2098 let props = volume_properties(&model, &wedge.shape, deflection(0.01), T).unwrap();
2099 assert_relative_eq!(props.mass, 24.0, epsilon = 1e-9);
2100 assert!(closed(&model, &wedge.shape));
2101 }
2102
2103 #[test]
2104 fn a_tapered_wedge_has_the_volume_a_frustum_of_a_pyramid_has() {
2105 let mut model = Model::new();
2107 let wedge = make_wedge(&mut model, Frame::WORLD, (4.0, 4.0, 6.0), (2.0, 2.0), T).unwrap();
2108 let props = volume_properties(&model, &wedge.shape, deflection(0.01), T).unwrap();
2109 assert_relative_eq!(
2110 props.mass,
2111 6.0 / 6.0 * 4.0_f64.mul_add(9.0, 16.0 + 4.0),
2112 epsilon = 1e-9
2113 );
2114 assert!(closed(&model, &wedge.shape));
2115 }
2116
2117 #[test]
2118 fn a_wedge_collapsing_to_a_ridge_is_five_faces_and_a_prismatoid_volume() {
2119 let mut model = Model::new();
2123 let wedge = make_wedge(&mut model, Frame::WORLD, (4.0, 3.0, 6.0), (2.0, 0.0), T).unwrap();
2124 let faces = ogeom_topo::explore_unique(&model, &wedge.shape, ShapeType::Face)
2125 .unwrap()
2126 .len();
2127 assert_eq!(faces, 5, "a ridge wedge has five faces, none of them empty");
2128 let props = volume_properties(&model, &wedge.shape, deflection(0.01), T).unwrap();
2129 assert_relative_eq!(props.mass, 6.0 * 3.0 * (2.0 - 2.0 / 6.0), epsilon = 1e-9);
2130 assert!(closed(&model, &wedge.shape));
2131
2132 let other = make_wedge(&mut model, Frame::WORLD, (3.0, 4.0, 6.0), (0.0, 2.0), T).unwrap();
2134 let props = volume_properties(&model, &other.shape, deflection(0.01), T).unwrap();
2135 assert_relative_eq!(props.mass, 6.0 * 3.0 * (2.0 - 2.0 / 6.0), epsilon = 1e-9);
2136 assert!(closed(&model, &other.shape));
2137 }
2138
2139 #[test]
2140 fn a_wedge_collapsing_to_a_point_is_a_pyramid() {
2141 let mut model = Model::new();
2142 let wedge = make_wedge(&mut model, Frame::WORLD, (4.0, 3.0, 6.0), (0.0, 0.0), T).unwrap();
2143 let faces = ogeom_topo::explore_unique(&model, &wedge.shape, ShapeType::Face)
2144 .unwrap()
2145 .len();
2146 assert_eq!(faces, 5, "a base and four triangles");
2147 let props = volume_properties(&model, &wedge.shape, deflection(0.01), T).unwrap();
2148 assert_relative_eq!(props.mass, 4.0 * 3.0 * 6.0 / 3.0, epsilon = 1e-9);
2149 assert!(closed(&model, &wedge.shape));
2150 }
2151
2152 #[test]
2153 fn dimensions_that_describe_no_solid_are_refused() {
2154 let mut model = Model::new();
2155 assert!(make_cone(&mut model, Frame::WORLD, 2.0, 2.0, 1.0, T).is_err());
2157 assert!(make_cone(&mut model, Frame::WORLD, 0.0, 0.0, 1.0, T).is_err());
2158 assert!(make_cone(&mut model, Frame::WORLD, 1.0, 2.0, 0.0, T).is_err());
2159 assert!(make_cone(&mut model, Frame::WORLD, -1.0, 2.0, 1.0, T).is_err());
2160
2161 assert!(make_torus(&mut model, Frame::WORLD, 0.0, 1.0, T).is_err());
2162 assert!(make_torus(&mut model, Frame::WORLD, 1.0, f64::NAN, T).is_err());
2163
2164 assert!(make_wedge(&mut model, Frame::WORLD, (0.0, 1.0, 1.0), (1.0, 1.0), T).is_err());
2165 assert!(make_wedge(&mut model, Frame::WORLD, (1.0, 1.0, 1.0), (-1.0, 1.0), T).is_err());
2166 }
2167}
2168
2169#[cfg(test)]
2170#[allow(clippy::unwrap_used)]
2171mod half_space_tests {
2172 use super::*;
2173 use crate::classify::Containment;
2174 use crate::{classify_in_solid, make_natural_face};
2175 use ogeom_geom::PlaneSurface;
2176 use ogeom_math::Direction;
2177 use ogeom_mesh::Deflection;
2178 use ogeom_topo::explore_unique;
2179
2180 const T: Tolerances = Tolerances::millimetres();
2181
2182 fn coarse() -> Deflection {
2183 Deflection {
2184 chord: 1.0,
2185 ..Deflection::default()
2186 }
2187 }
2188
2189 fn ground(model: &mut Model) -> Shape {
2191 make_natural_face(model, PlaneSurface::new(Plane::new(Frame::WORLD)).into())
2192 .unwrap()
2193 .shape
2194 }
2195
2196 #[test]
2197 fn the_face_is_oriented_away_from_the_side_that_is_solid() {
2198 let mut model = Model::new();
2202 let face = ground(&mut model);
2203 let above = make_half_space(&mut model, &face, Point::new(0.0, 0.0, 5.0), T).unwrap();
2204 let below = make_half_space(&mut model, &face, Point::new(0.0, 0.0, -5.0), T).unwrap();
2205
2206 let boundary = |built: &crate::Built| {
2207 explore_unique(&model, &built.shape, ShapeType::Face).unwrap()[0].clone()
2208 };
2209 let (a, b) = (boundary(&above), boundary(&below));
2210 assert!(a.is_partner(&b), "the same face, both times");
2211 assert_ne!(
2212 a.orientation(),
2213 b.orientation(),
2214 "naming the other side should turn the boundary round"
2215 );
2216 assert_eq!(model.kind_of(&above.shape).unwrap(), ShapeType::Solid);
2217 assert_eq!(
2218 explore_unique(&model, &above.shape, ShapeType::Face)
2219 .unwrap()
2220 .len(),
2221 1,
2222 "one face bounds a half space"
2223 );
2224 }
2225
2226 #[test]
2227 fn nothing_can_yet_be_asked_about_the_inside_of_one() {
2228 let mut model = Model::new();
2234 let face = ground(&mut model);
2235 let built = make_half_space(&mut model, &face, Point::new(0.0, 0.0, 5.0), T).unwrap();
2236
2237 let shell = explore_unique(&model, &built.shape, ShapeType::Shell).unwrap()[0].clone();
2238 assert!(!crate::is_shell_closed(&model, &shell).unwrap());
2239
2240 let err = classify_in_solid(&model, &built.shape, Point::new(0.0, 0.0, 5.0), coarse(), T)
2241 .unwrap_err();
2242 assert!(
2243 err.to_string().contains("not closed"),
2244 "unexpected message: {err}"
2245 );
2246 let _ = Containment::In;
2247 }
2248
2249 #[test]
2250 fn a_point_on_the_face_names_no_side() {
2251 let mut model = Model::new();
2252 let face = ground(&mut model);
2253 let err = make_half_space(&mut model, &face, Point::ORIGIN, T).unwrap_err();
2254 assert!(err.to_string().contains("names no side"), "got {err}");
2255
2256 let err = make_half_space(&mut model, &face, Point::new(3.0, 4.0, 0.0), T).unwrap_err();
2258 assert!(err.to_string().contains("names no side"), "got {err}");
2259 }
2260
2261 #[test]
2262 fn a_half_space_is_bounded_by_a_face_and_nothing_else() {
2263 let mut model = Model::new();
2264 let solid = make_box(&mut model, Frame::WORLD, (1.0, 1.0, 1.0), T)
2265 .unwrap()
2266 .shape;
2267 assert!(make_half_space(&mut model, &solid, Point::ORIGIN, T).is_err());
2268
2269 let vertex = model.add_point(Point::ORIGIN);
2270 assert!(make_half_space(&mut model, &vertex, Point::ORIGIN, T).is_err());
2271 }
2272
2273 #[test]
2274 fn it_reaches_only_as_far_as_its_surface_says() {
2275 let mut model = Model::new();
2280 let face = ground(&mut model);
2281 let built = make_half_space(&mut model, &face, Point::new(0.0, 0.0, 1.0), T).unwrap();
2282
2283 let bounds = crate::shape_bounds(&model, &built.shape, T).unwrap();
2288 assert!(
2289 bounds.is_empty(),
2290 "an unbounded plane should decline to bound itself, got {bounds:?}"
2291 );
2292 let _ = Direction::Z;
2293 }
2294}