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_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
90fn right_handed_about_z(frame: Frame, tol: Tolerances) -> OgeomResult<Frame> {
95 if frame.handedness() == ogeom_math::Handedness::Right {
96 return Ok(frame);
97 }
98 Frame::new(frame.origin(), frame.z(), frame.x(), tol)
99}
100
101fn exchanged_xy(frame: Frame, tol: Tolerances) -> OgeomResult<Option<Frame>> {
106 if frame.handedness() == ogeom_math::Handedness::Right {
107 return Ok(None);
108 }
109 Ok(Some(Frame::from_axes(
110 frame.origin(),
111 frame.y(),
112 frame.x(),
113 frame.z(),
114 tol,
115 )?))
116}
117
118pub fn make_box(
126 model: &mut Model,
127 frame: Frame,
128 size: (f64, f64, f64),
129 tol: Tolerances,
130) -> OgeomResult<Built> {
131 let (dx, dy, dz) = size;
132 for (name, value) in [("x", dx), ("y", dy), ("z", dz)] {
133 if !value.is_finite() || value <= tol.confusion() {
134 ogeom_bail!(
135 Construction,
136 "box {name} size {value} must be finite and positive"
137 );
138 }
139 }
140 if let Some(exchanged) = exchanged_xy(frame, tol)? {
141 let corner_points: Vec<Point> = CORNERS
142 .iter()
143 .map(|&(i, j, k)| {
144 #[allow(clippy::cast_precision_loss)]
145 let local = Point::new(i as f64 * dy, j as f64 * dx, k as f64 * dz);
146 exchanged.to_world(local)
147 })
148 .collect();
149 model.begin_operation();
150 return box_like_named(model, &corner_points, true, tol);
151 }
152 model.begin_operation();
153
154 let extent = [dx, dy, dz];
155 let corner_points: Vec<Point> = CORNERS
156 .iter()
157 .map(|&(i, j, k)| {
158 #[allow(clippy::cast_precision_loss)]
159 let local = [
160 i as f64 * extent[0],
161 j as f64 * extent[1],
162 k as f64 * extent[2],
163 ];
164 frame.to_world(Point::new(local[0], local[1], local[2]))
165 })
166 .collect();
167
168 box_like(model, &corner_points, tol)
169}
170
171pub fn make_parallelepiped(
183 model: &mut Model,
184 origin: Point,
185 edges: [ogeom_math::Vector; 3],
186 tol: Tolerances,
187) -> OgeomResult<Built> {
188 let volume = edges[0].cross(edges[1]).dot(edges[2]);
189 if !volume.is_finite() || volume.abs() <= tol.confusion() {
190 ogeom_bail!(
191 Construction,
192 "a parallelepiped needs three edges that span a volume"
193 );
194 }
195 let [a, b, c] = if volume > 0.0 {
196 edges
197 } else {
198 [edges[1], edges[0], edges[2]]
199 };
200 model.begin_operation();
201 let corner_points: Vec<Point> = CORNERS
202 .iter()
203 .map(|&(i, j, k)| {
204 #[allow(clippy::cast_precision_loss)]
205 let at = origin + a * (i as f64) + b * (j as f64) + c * (k as f64);
206 at
207 })
208 .collect();
209 box_like(model, &corner_points, tol)
210}
211
212pub fn make_hexahedron(
229 model: &mut Model,
230 corners: [Point; 8],
231 tol: Tolerances,
232) -> OgeomResult<Built> {
233 for (face, _) in FACES {
234 let [a, b, c, d] = [
235 corners[face[0]],
236 corners[face[1]],
237 corners[face[2]],
238 corners[face[3]],
239 ];
240 let n = (b - a).cross(c - a);
241 let m = n.magnitude();
242 if !m.is_finite() || m <= tol.confusion() {
243 ogeom_bail!(Construction, "a hexahedron's face has no area");
244 }
245 let off = ((d - a).dot(n) / m).abs();
246 if off > tol.confusion() * 10.0 {
247 ogeom_bail!(
248 Construction,
249 "a hexahedron's face is not planar; its fourth corner sits {off} off"
250 );
251 }
252 }
253 let volume = (corners[1] - corners[0])
254 .cross(corners[3] - corners[0])
255 .dot(corners[4] - corners[0]);
256 if !volume.is_finite() || volume.abs() <= tol.confusion() {
257 ogeom_bail!(Construction, "a hexahedron's corners span no volume");
258 }
259 let ordered: Vec<Point> = if volume > 0.0 {
262 corners.to_vec()
263 } else {
264 CORNERS
265 .iter()
266 .map(|&(i, j, k)| {
267 let at = CORNERS.iter().position(|&c| c == (j, i, k)).unwrap_or(0);
268 corners[at]
269 })
270 .collect()
271 };
272 model.begin_operation();
273 box_like(model, &ordered, tol)
274}
275
276pub fn make_polyhedron(
294 model: &mut Model,
295 points: &[Point],
296 rings: &[Vec<usize>],
297 tol: Tolerances,
298) -> OgeomResult<Built> {
299 if points.len() < 4 || rings.len() < 4 {
300 ogeom_bail!(
301 Construction,
302 "a polyhedron has at least four points and four faces"
303 );
304 }
305 if points
306 .iter()
307 .any(|p| !p.to_vector().magnitude().is_finite())
308 {
309 ogeom_bail!(Construction, "a polyhedron's point is not finite");
310 }
311 let mut centroid = ogeom_math::Vector::ZERO;
312 for p in points {
313 centroid += p.to_vector();
314 }
315 let centroid =
316 Point::ORIGIN + centroid / f64::from(u32::try_from(points.len()).unwrap_or(u32::MAX));
317 let mut wound: Vec<Vec<usize>> = Vec::with_capacity(rings.len());
318 let mut uses: ogeom_core::FastMap<(usize, usize), usize> = ogeom_core::FastMap::default();
319 for ring in rings {
320 if ring.len() < 3 {
321 ogeom_bail!(
322 Construction,
323 "a polyhedron's face has fewer than three corners"
324 );
325 }
326 if ring.iter().any(|&i| i >= points.len()) {
327 ogeom_bail!(
328 Construction,
329 "a polyhedron's face names a point it does not have"
330 );
331 }
332 let a = points[ring[0]];
333 let n = ring_normal(points, ring);
334 let m = n.magnitude();
335 let perimeter: f64 = (0..ring.len())
336 .map(|i| points[ring[i]].distance(points[ring[(i + 1) % ring.len()]]))
337 .sum();
338 if !m.is_finite() || m <= tol.confusion() * perimeter {
339 ogeom_bail!(Construction, "a polyhedron's face has no area");
340 }
341 let n = n / m;
342 let mut mid = ogeom_math::Vector::ZERO;
343 for &i in ring {
344 let off = (points[i] - a).dot(n).abs();
345 if off > tol.confusion() * 10.0 {
346 ogeom_bail!(
347 Construction,
348 "a polyhedron's face is not planar; a corner sits {off} off"
349 );
350 }
351 mid += points[i].to_vector();
352 }
353 let mid = Point::ORIGIN + mid / f64::from(u32::try_from(ring.len()).unwrap_or(u32::MAX));
354 let outward = n.dot(mid - centroid) > 0.0;
355 let ring: Vec<usize> = if outward {
358 ring.clone()
359 } else {
360 std::iter::once(ring[0])
361 .chain(ring[1..].iter().rev().copied())
362 .collect()
363 };
364 for step in 0..ring.len() {
365 let (from, to) = (ring[step], ring[(step + 1) % ring.len()]);
366 if from == to {
367 ogeom_bail!(Construction, "a polyhedron's face repeats a corner");
368 }
369 *uses.entry((from.min(to), from.max(to))).or_insert(0) += 1;
370 }
371 wound.push(ring);
372 }
373 if let Some((edge, count)) = uses.iter().find(|(_, count)| **count != 2) {
374 ogeom_bail!(
375 Construction,
376 "a polyhedron's edge {edge:?} is used by {count} faces, not two; the shell would not close"
377 );
378 }
379 let mut volume = 0.0;
382 for ring in &wound {
383 let a = points[ring[0]];
384 for step in 1..ring.len() - 1 {
385 let (b, c) = (points[ring[step]], points[ring[step + 1]]);
386 volume += (a - centroid).dot((b - centroid).cross(c - centroid));
387 }
388 }
389 if volume.is_nan() || volume / 6.0 <= tol.confusion() {
390 ogeom_bail!(Construction, "a polyhedron's faces span no volume");
391 }
392 let borrowed: Vec<&[usize]> = wound.iter().map(Vec::as_slice).collect();
393 model.begin_operation();
394 faceted_solid(model, points, &borrowed, tol)
395}
396
397fn ring_normal(points: &[Point], ring: &[usize]) -> ogeom_math::Vector {
401 let mut n = ogeom_math::Vector::ZERO;
402 let a = points[ring[0]];
403 for step in 1..ring.len() - 1 {
404 n += (points[ring[step]] - a).cross(points[ring[step + 1]] - a);
405 }
406 n
407}
408
409fn box_like(model: &mut Model, corner_points: &[Point], tol: Tolerances) -> OgeomResult<Built> {
417 box_like_named(model, corner_points, false, tol)
418}
419
420fn box_like_named(
424 model: &mut Model,
425 corner_points: &[Point],
426 swap_xy: bool,
427 tol: Tolerances,
428) -> OgeomResult<Built> {
429 let vertices: Vec<Shape> = corner_points
430 .iter()
431 .map(|p| model.add_vertex(ogeom_topo::VertexData::new(*p)))
432 .collect();
433
434 let mut edges = Vec::with_capacity(EDGES.len());
438 for &(from, to) in &EDGES {
439 let curve: Curve = LineCurve::segment(corner_points[from], corner_points[to], tol)?.into();
440 let length = corner_points[from].distance(corner_points[to]);
441 edges.push(
442 make_edge_between(
443 model,
444 curve,
445 (0.0, length),
446 &vertices[from],
447 &vertices[to],
448 tol,
449 )?
450 .shape,
451 );
452 }
453
454 let mut faces = Vec::with_capacity(FACES.len());
455 for (corners, role) in FACES {
456 let plane = face_plane(corner_points, corners, tol)?;
457 let surface = model
461 .geometry_mut()
462 .add_surface(PlaneSurface::new(plane).into());
463 let mut ring = Vec::with_capacity(4);
464 for step in 0..4 {
465 let (from, to) = (corners[step], corners[(step + 1) % 4]);
466 let (index, forward) = find_edge(from, to)?;
467 ring.push(if forward {
468 edges[index].clone()
469 } else {
470 edges[index].reversed()
471 });
472
473 let (canonical_from, canonical_to) = EDGES[index];
478 attach_plane_pcurve(
479 model,
480 &edges[index],
481 &plane,
482 surface,
483 corner_points[canonical_from],
484 corner_points[canonical_to],
485 tol,
486 )?;
487 }
488
489 let wire = make_wire(model, &ring, tol)?.shape;
490 let face = make_face_on(model, surface, std::slice::from_ref(&wire), tol)?.shape;
491 let role = if swap_xy {
492 match role {
493 r if r == roles::FACE_MIN_X => roles::FACE_MIN_Y,
494 r if r == roles::FACE_MAX_X => roles::FACE_MAX_Y,
495 r if r == roles::FACE_MIN_Y => roles::FACE_MIN_X,
496 r if r == roles::FACE_MAX_Y => roles::FACE_MAX_X,
497 r => r,
498 }
499 } else {
500 role
501 };
502 model.set_derived(&face, &[], role)?;
503 faces.push(face);
504 }
505
506 let shell = make_shell(model, &faces)?.shape;
507 let solid = outward_solid(model, &shell)?;
508 Ok(Built::from_nothing(solid))
509}
510
511fn faceted_solid(
518 model: &mut Model,
519 points: &[Point],
520 rings: &[&[usize]],
521 tol: Tolerances,
522) -> OgeomResult<Built> {
523 let loops: Vec<Vec<&[usize]>> = rings.iter().map(|ring| vec![*ring]).collect();
524 let faces = faceted_faces(model, points, &loops, &[], false, tol)?;
525 let shell = make_shell(model, &faces)?.shape;
526 let solid = outward_solid(model, &shell)?;
527 Ok(Built::from_nothing(solid))
528}
529
530pub fn make_faceted_solid(
550 model: &mut Model,
551 points: &[Point],
552 rings: &[Vec<usize>],
553 planes: &[Option<Plane>],
554 tol: Tolerances,
555) -> OgeomResult<(Built, Vec<Shape>)> {
556 let loops: Vec<Vec<&[usize]>> = rings.iter().map(|ring| vec![ring.as_slice()]).collect();
557 faceted_solid_from_loops(model, points, &loops, planes, tol)
558}
559
560pub fn make_faceted_solid_with_holes(
570 model: &mut Model,
571 points: &[Point],
572 faces: &[Vec<Vec<usize>>],
573 planes: &[Option<Plane>],
574 tol: Tolerances,
575) -> OgeomResult<(Built, Vec<Shape>)> {
576 let loops: Vec<Vec<&[usize]>> = faces
577 .iter()
578 .map(|face| face.iter().map(Vec::as_slice).collect())
579 .collect();
580 faceted_solid_from_loops(model, points, &loops, planes, tol)
581}
582
583fn faceted_solid_from_loops(
585 model: &mut Model,
586 points: &[Point],
587 faces: &[Vec<&[usize]>],
588 planes: &[Option<Plane>],
589 tol: Tolerances,
590) -> OgeomResult<(Built, Vec<Shape>)> {
591 if faces.len() < 4
592 || faces.iter().any(Vec::is_empty)
593 || points
594 .iter()
595 .any(|p| !p.to_vector().magnitude().is_finite())
596 {
597 ogeom_bail!(
598 Construction,
599 "a faceted solid has at least four faces over finite points"
600 );
601 }
602 let mut walked: ogeom_core::FastMap<(usize, usize), usize> = ogeom_core::FastMap::default();
603 let mut volume = 0.0;
604 let origin = points[faces[0][0][0]];
605 for face in faces {
606 for ring in face {
607 if ring.len() < 3 || ring.iter().any(|&i| i >= points.len()) {
608 ogeom_bail!(
609 Construction,
610 "a faceted solid's face names fewer than three points or one it does not have"
611 );
612 }
613 }
614 let outer = face[0];
615 let n = ring_normal(points, outer);
616 let m = n.magnitude();
617 let perimeter: f64 = (0..outer.len())
618 .map(|i| points[outer[i]].distance(points[outer[(i + 1) % outer.len()]]))
619 .sum();
620 if !m.is_finite() || m <= tol.confusion() * perimeter {
621 ogeom_bail!(Construction, "a faceted solid's face has no area");
622 }
623 let n = n / m;
624 let a = points[outer[0]];
625 for ring in face {
626 for step in 0..ring.len() {
627 let (from, to) = (ring[step], ring[(step + 1) % ring.len()]);
628 if from == to {
629 ogeom_bail!(Construction, "a faceted solid's face repeats a corner");
630 }
631 let off = (points[from] - a).dot(n).abs();
632 if off > tol.confusion() * 10.0 {
633 ogeom_bail!(
634 Construction,
635 "a faceted solid's face is not planar; a corner sits {off} off"
636 );
637 }
638 *walked.entry((from, to)).or_insert(0) += 1;
639 }
640 let r0 = points[ring[0]];
641 for step in 1..ring.len() - 1 {
642 let (b, c) = (points[ring[step]], points[ring[step + 1]]);
643 volume += (r0 - origin).dot((b - origin).cross(c - origin));
644 }
645 }
646 }
647 if let Some(((from, to), _)) = walked
648 .iter()
649 .find(|((from, to), count)| **count != 1 || walked.get(&(*to, *from)) != Some(&1))
650 {
651 ogeom_bail!(
652 Construction,
653 "a faceted solid's edge from point {from} to {to} is not walked once each way; \
654 the shell would not close or its faces disagree in winding"
655 );
656 }
657 if volume.is_nan() || volume / 6.0 <= tol.confusion() {
658 ogeom_bail!(Construction, "a faceted solid's faces enclose no volume");
659 }
660 model.begin_operation();
661 let faces = faceted_faces(model, points, faces, planes, true, tol)?;
662 let shell = make_shell(model, &faces)?.shape;
663 let solid = outward_solid(model, &shell)?;
664 Ok((Built::from_nothing(solid), faces))
665}
666
667fn faceted_faces(
671 model: &mut Model,
672 points: &[Point],
673 faces_rings: &[Vec<&[usize]>],
674 planes: &[Option<Plane>],
675 open_lines: bool,
676 tol: Tolerances,
677) -> OgeomResult<Vec<Shape>> {
678 let vertices: Vec<Shape> = points
679 .iter()
680 .map(|p| model.add_vertex(ogeom_topo::VertexData::new(*p)))
681 .collect();
682 let mut edge_of: ogeom_core::FastMap<(usize, usize), Shape> = ogeom_core::FastMap::default();
683 let mut faces = Vec::with_capacity(faces_rings.len());
684 for (index, loops) in faces_rings.iter().enumerate() {
685 let ring_corners = loops[0];
686 let origin = points[ring_corners[0]];
687 let normal = Direction::new(ring_normal(points, ring_corners), tol)?;
688 let given = planes
689 .get(index)
690 .copied()
691 .flatten()
692 .filter(|p| p.frame().z().vector().dot(normal.vector()) > 0.0);
693 let plane = match given {
694 Some(plane) => plane,
695 None => {
696 let x = Direction::new(points[ring_corners[1]] - origin, tol)?;
697 Plane::new(Frame::new(origin, normal, x, tol)?)
698 }
699 };
700 let surface = model
701 .geometry_mut()
702 .add_surface(PlaneSurface::new(plane).into());
703 let mut wires = Vec::with_capacity(loops.len());
704 for ring_corners in loops {
705 let mut ring = Vec::with_capacity(ring_corners.len());
706 for step in 0..ring_corners.len() {
707 let (from, to) = (
708 ring_corners[step],
709 ring_corners[(step + 1) % ring_corners.len()],
710 );
711 let key = (from.min(to), from.max(to));
712 let edge = match edge_of.get(&key) {
713 Some(edge) => edge.clone(),
714 None => {
715 let curve: Curve = if open_lines {
716 let axis = ogeom_math::Axis::new(
717 points[key.0],
718 Direction::new(points[key.1] - points[key.0], tol)?,
719 );
720 LineCurve::new(axis).into()
721 } else {
722 LineCurve::segment(points[key.0], points[key.1], tol)?.into()
723 };
724 let length = points[key.0].distance(points[key.1]);
725 let edge = make_edge_between(
726 model,
727 curve,
728 (0.0, length),
729 &vertices[key.0],
730 &vertices[key.1],
731 tol,
732 )?
733 .shape;
734 edge_of.insert(key, edge.clone());
735 edge
736 }
737 };
738 attach_plane_pcurve(
741 model,
742 &edge,
743 &plane,
744 surface,
745 points[key.0],
746 points[key.1],
747 tol,
748 )?;
749 ring.push(if from == key.0 { edge } else { edge.reversed() });
750 }
751 wires.push(make_wire(model, &ring, tol)?.shape);
752 }
753 faces.push(make_face_on(model, surface, &wires, tol)?.shape);
754 }
755 Ok(faces)
756}
757
758fn outward_solid(model: &mut Model, shell: &Shape) -> OgeomResult<Shape> {
765 model.add_solid(std::slice::from_ref(shell))
766}
767
768fn face_plane(points: &[Point], corners: [usize; 4], tol: Tolerances) -> OgeomResult<Plane> {
770 let origin = points[corners[0]];
771 let normal = Direction::from_cross(
774 points[corners[1]] - origin,
775 points[corners[2]] - points[corners[1]],
776 tol,
777 )?;
778 let x = Direction::new(points[corners[1]] - origin, tol)?;
779 Ok(Plane::new(Frame::new(origin, normal, x, tol)?))
780}
781
782fn attach_plane_pcurve(
785 model: &mut Model,
786 edge: &Shape,
787 plane: &Plane,
788 surface: ogeom_topo::SurfaceId,
789 from: Point,
790 to: Point,
791 tol: Tolerances,
792) -> OgeomResult<()> {
793 let local = |p: Point| {
794 let l = plane.frame().to_local(p);
795 Point2::new(l.x, l.y)
796 };
797 let (a, b) = (local(from), local(to));
798 let pcurve: PlanarCurve = ogeom_geom::Line2d::segment(a, b, tol)?.into();
799 crate::build::attach_pcurve(
800 model,
801 edge,
802 pcurve,
803 surface,
804 ogeom_topo::Location::identity(),
805 (0.0, a.distance(b)),
806 )
807}
808
809fn find_edge(from: usize, to: usize) -> OgeomResult<(usize, bool)> {
811 for (index, &(a, b)) in EDGES.iter().enumerate() {
812 if a == from && b == to {
813 return Ok((index, true));
814 }
815 if a == to && b == from {
816 return Ok((index, false));
817 }
818 }
819 ogeom_bail!(
820 Construction,
821 "corners {from} and {to} are not joined by a box edge"
822 )
823}
824
825pub fn make_cylinder(
832 model: &mut Model,
833 frame: Frame,
834 radius: f64,
835 height: f64,
836 tol: Tolerances,
837) -> OgeomResult<Built> {
838 check_size("cylinder radius", radius, tol)?;
839 check_size("cylinder height", height, tol)?;
840 let frame = right_handed_about_z(frame, tol)?;
841 model.begin_operation();
842
843 let top_frame = raised(frame, height, tol)?;
844 let bottom_circle = Circle::new(frame, radius, tol)?;
845 let top_circle = Circle::new(top_frame, radius, tol)?;
846
847 let low = model.add_vertex(VertexData::new(rim_point(bottom_circle)));
851 let high = model.add_vertex(VertexData::new(rim_point(top_circle)));
852
853 let bottom_edge = full_circle_edge(model, bottom_circle, &low, tol)?;
854 let top_edge = full_circle_edge(model, top_circle, &high, tol)?;
855 let seam = make_edge_between(
856 model,
857 LineCurve::segment(rim_point(bottom_circle), rim_point(top_circle), tol)?.into(),
858 (0.0, height),
859 &low,
860 &high,
861 tol,
862 )?
863 .shape;
864
865 let lateral_id = model.geometry_mut().add_surface(
866 ogeom_geom::CylinderSurface::new(Cylinder::new(frame, radius, tol)?, (0.0, height))?.into(),
867 );
868 let lateral = rectangle_face(
869 model,
870 lateral_id,
871 (TAU, height),
872 [&bottom_edge, &top_edge, &seam],
873 tol,
874 )?;
875 model.set_derived(&lateral, &[], roles::FACE_LATERAL)?;
876
877 let bottom = cap_face(model, frame, false, &bottom_edge, bottom_circle, tol)?;
878 model.set_derived(&bottom, &[], roles::FACE_MIN_Z)?;
879 let top = cap_face(model, top_frame, true, &top_edge, top_circle, tol)?;
880 model.set_derived(&top, &[], roles::FACE_MAX_Z)?;
881
882 let shell = make_shell(model, &[lateral, bottom, top])?.shape;
883 let solid = outward_solid(model, &shell)?;
884 Ok(Built::from_nothing(solid))
885}
886
887pub fn make_sphere(
894 model: &mut Model,
895 frame: Frame,
896 radius: f64,
897 tol: Tolerances,
898) -> OgeomResult<Built> {
899 check_size("sphere radius", radius, tol)?;
900 let frame = right_handed_about_z(frame, tol)?;
901 model.begin_operation();
902
903 let centre = frame.origin();
904 let south = model.add_vertex(VertexData::new(centre - frame.z().vector() * radius));
905 let north = model.add_vertex(VertexData::new(centre + frame.z().vector() * radius));
906
907 let meridian = Circle::new(Frame::new(centre, -frame.y(), frame.x(), tol)?, radius, tol)?;
912 let seam = make_edge_between(
913 model,
914 CircleCurve::new(meridian).into(),
915 (-FRAC_PI_2, FRAC_PI_2),
916 &south,
917 &north,
918 tol,
919 )?
920 .shape;
921
922 let bottom_edge = degenerate_edge(model, &south, tol)?;
926 let top_edge = degenerate_edge(model, &north, tol)?;
927
928 let surface = model
929 .geometry_mut()
930 .add_surface(SphereSurface::new(Sphere::new(frame, radius, tol)?).into());
931 let face = rectangle_face(
932 model,
933 surface,
934 (TAU, PI),
935 [&bottom_edge, &top_edge, &seam],
936 tol,
937 )?;
938 model.set_derived(&face, &[], roles::FACE_LATERAL)?;
941
942 let shell = make_shell(model, std::slice::from_ref(&face))?.shape;
943 let solid = outward_solid(model, &shell)?;
944 Ok(Built::from_nothing(solid))
945}
946
947fn rim_point(circle: Circle) -> Point {
950 circle.centre() + circle.frame().x().vector() * circle.radius()
951}
952
953fn raised(frame: Frame, distance: f64, tol: Tolerances) -> OgeomResult<Frame> {
955 Frame::new(
956 frame.to_world(Point::new(0.0, 0.0, distance)),
957 frame.z(),
958 frame.x(),
959 tol,
960 )
961}
962
963fn check_size(what: &str, value: f64, tol: Tolerances) -> OgeomResult<()> {
965 if !value.is_finite() || value <= tol.confusion() {
966 ogeom_bail!(Construction, "{what} {value} must be finite and positive");
967 }
968 Ok(())
969}
970
971fn full_circle_edge(
973 model: &mut Model,
974 circle: Circle,
975 at: &Shape,
976 tol: Tolerances,
977) -> OgeomResult<Shape> {
978 Ok(make_edge_between(
979 model,
980 CircleCurve::new(circle).into(),
981 (0.0, TAU),
982 at,
983 at,
984 tol,
985 )?
986 .shape)
987}
988
989fn degenerate_edge(model: &mut Model, at: &Shape, tol: Tolerances) -> OgeomResult<Shape> {
996 let _ = tol;
997 let mut data = EdgeData::new();
998 data.degenerate = true;
999 model.add_edge(data, &[at.clone(), at.clone()])
1000}
1001
1002fn rectangle_face(
1013 model: &mut Model,
1014 surface: ogeom_topo::SurfaceId,
1015 extent: (f64, f64),
1016 edges: [&Shape; 3],
1017 tol: Tolerances,
1018) -> OgeomResult<Shape> {
1019 let [bottom, top, seam] = edges;
1020 let Some(geometry) = model.geometry().surface(surface) else {
1021 ogeom_bail!(Dangling, "surface is not in this model");
1022 };
1023 let ((ua, _), (va, _)) = geometry.domain();
1024 let (du, dv) = extent;
1025 let (ub, vb) = (ua + du, va + dv);
1026
1027 line_pcurve(
1028 model,
1029 bottom,
1030 surface,
1031 Point2::new(ua, va),
1032 Point2::new(ub, va),
1033 tol,
1034 )?;
1035 line_pcurve(
1036 model,
1037 top,
1038 surface,
1039 Point2::new(ua, vb),
1040 Point2::new(ub, vb),
1041 tol,
1042 )?;
1043 seam_pcurves(
1044 model,
1045 seam,
1046 surface,
1047 (Point2::new(ub, va), Point2::new(ub, vb)),
1048 (Point2::new(ua, va), Point2::new(ua, vb)),
1049 tol,
1050 )?;
1051
1052 let ring = [
1055 bottom.clone(),
1056 seam.clone(),
1057 top.reversed(),
1058 seam.reversed(),
1059 ];
1060 let wire = make_wire(model, &ring, tol)?.shape;
1061 Ok(make_face_on(model, surface, std::slice::from_ref(&wire), tol)?.shape)
1062}
1063
1064fn cap_face(
1070 model: &mut Model,
1071 frame: Frame,
1072 outward: bool,
1073 rim: &Shape,
1074 circle: Circle,
1075 tol: Tolerances,
1076) -> OgeomResult<Shape> {
1077 let normal = if outward { frame.z() } else { -frame.z() };
1078 let plane = Plane::new(Frame::new(frame.origin(), normal, frame.x(), tol)?);
1079 let surface = model
1080 .geometry_mut()
1081 .add_surface(PlaneSurface::new(plane).into());
1082
1083 circle_pcurve_on_plane(model, rim, surface, circle, plane, tol)?;
1084
1085 let edge = if outward { rim.clone() } else { rim.reversed() };
1088 let wire = make_wire(model, std::slice::from_ref(&edge), tol)?.shape;
1089 Ok(make_face_on(model, surface, std::slice::from_ref(&wire), tol)?.shape)
1090}
1091
1092fn line_pcurve(
1094 model: &mut Model,
1095 edge: &Shape,
1096 surface: ogeom_topo::SurfaceId,
1097 from: Point2,
1098 to: Point2,
1099 tol: Tolerances,
1100) -> OgeomResult<()> {
1101 let pcurve: PlanarCurve = ogeom_geom::Line2d::segment(from, to, tol)?.into();
1102 crate::build::attach_pcurve(
1103 model,
1104 edge,
1105 pcurve,
1106 surface,
1107 ogeom_topo::Location::identity(),
1108 (0.0, from.distance(to)),
1109 )
1110}
1111
1112fn seam_pcurves(
1115 model: &mut Model,
1116 edge: &Shape,
1117 surface: ogeom_topo::SurfaceId,
1118 forward: (Point2, Point2),
1119 reversed: (Point2, Point2),
1120 tol: Tolerances,
1121) -> OgeomResult<()> {
1122 let length = forward.0.distance(forward.1);
1123 let first = model
1124 .geometry_mut()
1125 .add_pcurve(ogeom_geom::Line2d::segment(forward.0, forward.1, tol)?.into());
1126 let second = model
1127 .geometry_mut()
1128 .add_pcurve(ogeom_geom::Line2d::segment(reversed.0, reversed.1, tol)?.into());
1129
1130 let Some(node) = model.node_mut(edge) else {
1131 ogeom_bail!(Dangling, "edge is not in this model");
1132 };
1133 let ogeom_topo::NodeData::Edge(data) = node.data_mut() else {
1134 ogeom_bail!(Construction, "edge node holds no edge data");
1135 };
1136 data.add(ogeom_topo::EdgeRepr::Seam {
1137 forward: first,
1138 reversed: second,
1139 surface,
1140 location: ogeom_topo::Location::identity(),
1141 range: (0.0, length),
1142 });
1143 Ok(())
1144}
1145
1146fn circle_pcurve_on_plane(
1153 model: &mut Model,
1154 edge: &Shape,
1155 surface: ogeom_topo::SurfaceId,
1156 circle: Circle,
1157 plane: Plane,
1158 tol: Tolerances,
1159) -> OgeomResult<()> {
1160 let frame = plane.frame();
1161 let flat = |p: Point| {
1162 let local = frame.to_local(p);
1163 Point2::new(local.x, local.y)
1164 };
1165 let flat_direction = |d: Direction| -> OgeomResult<Direction2> {
1168 let local = frame.vector_to_local(d.vector());
1169 Direction2::new(ogeom_math::Vector2::new(local.x, local.y), tol)
1170 };
1171
1172 let frame2 = ogeom_math::Frame2::from_axes(
1173 flat(circle.centre()),
1174 flat_direction(circle.frame().x())?,
1175 flat_direction(circle.frame().y())?,
1176 tol,
1177 )?;
1178 let pcurve: PlanarCurve =
1179 ogeom_geom::Circle2d::new(ogeom_math::Circle2::new(frame2, circle.radius(), tol)?).into();
1180 crate::build::attach_pcurve(
1181 model,
1182 edge,
1183 pcurve,
1184 surface,
1185 ogeom_topo::Location::identity(),
1186 (0.0, TAU),
1187 )
1188}
1189
1190pub fn make_cone(
1204 model: &mut Model,
1205 frame: Frame,
1206 base_radius: f64,
1207 top_radius: f64,
1208 height: f64,
1209 tol: Tolerances,
1210) -> OgeomResult<Built> {
1211 check_size("cone height", height, tol)?;
1212 let frame = right_handed_about_z(frame, tol)?;
1213 for (what, r) in [("base radius", base_radius), ("top radius", top_radius)] {
1214 if !r.is_finite() || r < 0.0 {
1215 ogeom_bail!(
1216 Construction,
1217 "cone {what} {r} must be finite and non-negative"
1218 );
1219 }
1220 }
1221 if (base_radius - top_radius).abs() <= tol.confusion() {
1222 ogeom_bail!(
1223 Construction,
1224 "a cone with equal radii is a cylinder; use make_cylinder"
1225 );
1226 }
1227 if base_radius <= tol.confusion() && top_radius <= tol.confusion() {
1228 ogeom_bail!(Construction, "a cone needs one end with a radius");
1229 }
1230 model.begin_operation();
1231
1232 let widening = top_radius > base_radius;
1237 let (surface_frame, near_radius, far_radius) = if widening {
1238 (frame, base_radius, top_radius)
1239 } else {
1240 (flipped(frame, height, tol)?, top_radius, base_radius)
1241 };
1242 let half_angle = ((far_radius - near_radius) / height).atan();
1243 let cone = Cone::new(surface_frame, near_radius, half_angle, tol)?;
1244
1245 let near_circle = circle_at(surface_frame, near_radius, 0.0, tol);
1246 let far_frame = raised(surface_frame, height, tol)?;
1247 let Some(far) = circle_at(far_frame, far_radius, 0.0, tol) else {
1250 ogeom_bail!(Construction, "the wide end of a cone must have a radius");
1251 };
1252
1253 let near_vertex = model.add_vertex(VertexData::new(match near_circle {
1254 Some(c) => rim_point(c),
1255 None => surface_frame.origin(),
1256 }));
1257 let far_vertex = model.add_vertex(VertexData::new(rim_point(far)));
1258
1259 let near_edge = match near_circle {
1262 Some(c) => full_circle_edge(model, c, &near_vertex, tol)?,
1263 None => degenerate_edge(model, &near_vertex, tol)?,
1264 };
1265 let far_edge = full_circle_edge(model, far, &far_vertex, tol)?;
1266
1267 let seam_start = near_circle.map_or_else(|| surface_frame.origin(), rim_point);
1268 let seam_end = rim_point(far);
1269 let seam = make_edge_between(
1270 model,
1271 LineCurve::segment(seam_start, seam_end, tol)?.into(),
1272 (0.0, slant(near_radius, far_radius, height)),
1273 &near_vertex,
1274 &far_vertex,
1275 tol,
1276 )?
1277 .shape;
1278
1279 let lateral_id = model
1280 .geometry_mut()
1281 .add_surface(ogeom_geom::ConeSurface::new(cone, (0.0, height))?.into());
1282 let lateral = rectangle_face(
1283 model,
1284 lateral_id,
1285 (TAU, height),
1286 [&near_edge, &far_edge, &seam],
1287 tol,
1288 )?;
1289 model.set_derived(&lateral, &[], roles::FACE_LATERAL)?;
1290
1291 let mut faces = vec![lateral];
1292 if let Some(c) = near_circle {
1294 let cap = cap_face(model, surface_frame, false, &near_edge, c, tol)?;
1295 model.set_derived(
1296 &cap,
1297 &[],
1298 if widening {
1299 roles::FACE_MIN_Z
1300 } else {
1301 roles::FACE_MAX_Z
1302 },
1303 )?;
1304 faces.push(cap);
1305 }
1306 let far_cap = cap_face(model, far_frame, true, &far_edge, far, tol)?;
1307 model.set_derived(
1308 &far_cap,
1309 &[],
1310 if widening {
1311 roles::FACE_MAX_Z
1312 } else {
1313 roles::FACE_MIN_Z
1314 },
1315 )?;
1316 faces.push(far_cap);
1317
1318 let shell = make_shell(model, &faces)?.shape;
1319 let solid = outward_solid(model, &shell)?;
1320 Ok(Built::from_nothing(solid))
1321}
1322
1323pub fn make_torus(
1331 model: &mut Model,
1332 frame: Frame,
1333 major: f64,
1334 minor: f64,
1335 tol: Tolerances,
1336) -> OgeomResult<Built> {
1337 check_size("torus major radius", major, tol)?;
1338 check_size("torus minor radius", minor, tol)?;
1339 let frame = right_handed_about_z(frame, tol)?;
1340 model.begin_operation();
1341
1342 let start = frame.to_world(Point::new(major + minor, 0.0, 0.0));
1347 let corner = model.add_vertex(VertexData::new(start));
1348
1349 let equator = Circle::new(frame, major + minor, tol)?;
1351 let along_u = full_circle_edge(model, equator, &corner, tol)?;
1352
1353 let tube_frame = Frame::new(
1356 frame.to_world(Point::new(major, 0.0, 0.0)),
1357 -frame.y(),
1358 frame.x(),
1359 tol,
1360 )?;
1361 let along_v = full_circle_edge(model, Circle::new(tube_frame, minor, tol)?, &corner, tol)?;
1362
1363 let surface = model
1364 .geometry_mut()
1365 .add_surface(ogeom_geom::TorusSurface::new(Torus::new(frame, major, minor, tol)?).into());
1366 let face = doubly_seamed_face(model, surface, &along_u, &along_v, 0.0, tol)?;
1367 model.set_derived(&face, &[], roles::FACE_LATERAL)?;
1368
1369 let shell = make_shell(model, std::slice::from_ref(&face))?.shape;
1370 let solid = outward_solid(model, &shell)?;
1371 Ok(Built::from_nothing(solid))
1372}
1373
1374pub fn torus_face_seamed_at(
1385 model: &mut Model,
1386 frame: Frame,
1387 major: f64,
1388 minor: f64,
1389 v0: f64,
1390 tol: Tolerances,
1391) -> OgeomResult<Shape> {
1392 check_size("torus major radius", major, tol)?;
1393 check_size("torus minor radius", minor, tol)?;
1394 let frame = right_handed_about_z(frame, tol)?;
1395 let (sin, cos) = v0.sin_cos();
1396 let reach = minor.mul_add(cos, major);
1397 let corner = model.add_vertex(VertexData::new(frame.to_world(Point::new(
1398 reach,
1399 0.0,
1400 minor * sin,
1401 ))));
1402 let parallel_frame = Frame::new(
1405 frame.to_world(Point::new(0.0, 0.0, minor * sin)),
1406 frame.z(),
1407 frame.x(),
1408 tol,
1409 )?;
1410 let along_u = full_circle_edge(
1411 model,
1412 Circle::new(parallel_frame, reach, tol)?,
1413 &corner,
1414 tol,
1415 )?;
1416 let start = Direction::new(frame.x().vector() * cos + frame.z().vector() * sin, tol)?;
1418 let tube_frame = Frame::new(
1419 frame.to_world(Point::new(major, 0.0, 0.0)),
1420 -frame.y(),
1421 start,
1422 tol,
1423 )?;
1424 let along_v = full_circle_edge(model, Circle::new(tube_frame, minor, tol)?, &corner, tol)?;
1425 let surface = model
1426 .geometry_mut()
1427 .add_surface(ogeom_geom::TorusSurface::new(Torus::new(frame, major, minor, tol)?).into());
1428 doubly_seamed_face(model, surface, &along_u, &along_v, v0, tol)
1429}
1430
1431pub fn make_wedge(
1445 model: &mut Model,
1446 frame: Frame,
1447 size: (f64, f64, f64),
1448 top: (f64, f64),
1449 tol: Tolerances,
1450) -> OgeomResult<Built> {
1451 let (dx, dy, dz) = size;
1452 for (name, value) in [("x", dx), ("y", dy), ("z", dz)] {
1453 check_size(&format!("wedge {name} size"), value, tol)?;
1454 }
1455 for (name, value) in [("x", top.0), ("y", top.1)] {
1456 if !value.is_finite() || value < 0.0 {
1457 ogeom_bail!(
1458 Construction,
1459 "wedge top {name} extent {value} must be finite and non-negative"
1460 );
1461 }
1462 }
1463 if let Some(exchanged) = exchanged_xy(frame, tol)? {
1464 return wedge_in(model, exchanged, (dy, dx, dz), (top.1, top.0), true, tol);
1465 }
1466 wedge_in(model, frame, size, top, false, tol)
1467}
1468
1469fn wedge_in(
1473 model: &mut Model,
1474 frame: Frame,
1475 size: (f64, f64, f64),
1476 top: (f64, f64),
1477 swap_xy: bool,
1478 tol: Tolerances,
1479) -> OgeomResult<Built> {
1480 let (dx, dy, dz) = size;
1481 model.begin_operation();
1482
1483 let (dx_, dy_, dz_) = size;
1487 let collapsed = (top.0 <= tol.confusion(), top.1 <= tol.confusion());
1488 match collapsed {
1489 (true, true) => {
1490 let local = [
1491 Point::new(0.0, 0.0, 0.0),
1492 Point::new(dx_, 0.0, 0.0),
1493 Point::new(dx_, dy_, 0.0),
1494 Point::new(0.0, dy_, 0.0),
1495 Point::new(0.0, 0.0, dz_),
1496 ];
1497 let points: Vec<Point> = local.iter().map(|p| frame.to_world(*p)).collect();
1498 let rings: [&[usize]; 5] = [
1499 &[0, 3, 2, 1],
1500 &[0, 1, 4],
1501 &[1, 2, 4],
1502 &[2, 3, 4],
1503 &[3, 0, 4],
1504 ];
1505 return faceted_solid(model, &points, &rings, tol);
1506 }
1507 (false, true) => {
1508 let local = [
1509 Point::new(0.0, 0.0, 0.0),
1510 Point::new(dx_, 0.0, 0.0),
1511 Point::new(dx_, dy_, 0.0),
1512 Point::new(0.0, dy_, 0.0),
1513 Point::new(0.0, 0.0, dz_),
1514 Point::new(top.0, 0.0, dz_),
1515 ];
1516 let points: Vec<Point> = local.iter().map(|p| frame.to_world(*p)).collect();
1517 let rings: [&[usize]; 5] = [
1518 &[0, 3, 2, 1],
1519 &[0, 1, 5, 4],
1520 &[1, 2, 5],
1521 &[2, 3, 4, 5],
1522 &[3, 0, 4],
1523 ];
1524 return faceted_solid(model, &points, &rings, tol);
1525 }
1526 (true, false) => {
1527 let local = [
1528 Point::new(0.0, 0.0, 0.0),
1529 Point::new(dx_, 0.0, 0.0),
1530 Point::new(dx_, dy_, 0.0),
1531 Point::new(0.0, dy_, 0.0),
1532 Point::new(0.0, 0.0, dz_),
1533 Point::new(0.0, top.1, dz_),
1534 ];
1535 let points: Vec<Point> = local.iter().map(|p| frame.to_world(*p)).collect();
1536 let rings: [&[usize]; 5] = [
1537 &[0, 3, 2, 1],
1538 &[0, 4, 5, 3],
1539 &[0, 1, 4],
1540 &[1, 2, 5, 4],
1541 &[2, 3, 5],
1542 ];
1543 return faceted_solid(model, &points, &rings, tol);
1544 }
1545 (false, false) => {}
1546 }
1547
1548 let corners: Vec<Point> = CORNERS
1553 .iter()
1554 .map(|&(i, j, k)| {
1555 #[allow(clippy::cast_precision_loss)]
1556 let (fi, fj) = (i as f64, j as f64);
1557 let (ex, ey) = if k == 0 { (dx, dy) } else { (top.0, top.1) };
1558 #[allow(clippy::cast_precision_loss)]
1559 frame.to_world(Point::new(fi * ex, fj * ey, k as f64 * dz))
1560 })
1561 .collect();
1562 box_like_named(model, &corners, swap_xy, tol)
1563}
1564
1565pub fn make_half_space(
1591 model: &mut Model,
1592 face: &Shape,
1593 inside: Point,
1594 tol: Tolerances,
1595) -> OgeomResult<Built> {
1596 if model.kind_of(face)? != ShapeType::Face {
1597 ogeom_bail!(Construction, "a half space is bounded by a face");
1598 }
1599 let (at, normal) = nearest_normal(model, face, inside, tol)?;
1603 let towards = inside - at;
1604 let reach = towards.magnitude();
1605 if reach <= tol.confusion() {
1606 ogeom_bail!(
1607 Construction,
1608 "the point naming the solid side lies on the face itself, so it \
1609 names no side"
1610 );
1611 }
1612 let along = normal.dot(towards) / reach;
1613 if along.abs() <= tol.angular() {
1614 ogeom_bail!(
1615 Construction,
1616 "the point naming the solid side lies in the face's own surface, so \
1617 it names no side"
1618 );
1619 }
1620 model.begin_operation();
1621
1622 let boundary = if along > 0.0 {
1625 face.reversed()
1626 } else {
1627 face.clone()
1628 };
1629 let shell = make_shell(model, std::slice::from_ref(&boundary))?.shape;
1630 let solid = model.add_solid(std::slice::from_ref(&shell))?;
1633 model.set_derived(&solid, std::slice::from_ref(face), roles::FACE_LATERAL)?;
1634
1635 let mut history = crate::history::History::new();
1636 history.generate(face, shell);
1637 history.generate(face, solid.clone());
1638 Ok(Built::new(solid, history))
1639}
1640
1641fn nearest_normal(
1644 model: &Model,
1645 face: &Shape,
1646 target: Point,
1647 tol: Tolerances,
1648) -> OgeomResult<(Point, ogeom_math::Vector)> {
1649 let Some(data) = model.node(face).and_then(|n| n.data().as_face()) else {
1650 ogeom_bail!(Construction, "face node holds no face data");
1651 };
1652 let Some(surface) = model.geometry().surface(data.surface) else {
1653 ogeom_bail!(Dangling, "face refers to a surface not in this model");
1654 };
1655 let placement = face.transform(model.datums())?;
1656 let local = placement.inverse()?.apply(target);
1657 let foot = crate::measure::project_on_surface(surface, local, 32, tol)?;
1658 let (u, v) = foot.parameters;
1659 let normal = match surface.normal_at(u, v, tol) {
1662 Ok(n) => n,
1663 Err(_) => {
1664 let ((ua, ub), (va, vb)) = surface.domain();
1665 let (mu, mv) = (f64::midpoint(ua, ub), f64::midpoint(va, vb));
1666 let nudge = |x: f64, mid: f64| x + (mid - x).signum() * 1e-6 * (1.0 + x.abs());
1667 surface.normal_at(nudge(u, mu), nudge(v, mv), tol)?
1668 }
1669 };
1670 let normal = placement.apply_vector(normal.vector());
1671 let normal = if face.orientation() == ogeom_topo::Orientation::Reversed {
1672 -normal
1673 } else {
1674 normal
1675 };
1676 Ok((placement.apply(foot.point), normal))
1677}
1678
1679fn flipped(frame: Frame, height: f64, tol: Tolerances) -> OgeomResult<Frame> {
1682 Frame::new(
1683 frame.to_world(Point::new(0.0, 0.0, height)),
1684 -frame.z(),
1685 frame.x(),
1686 tol,
1687 )
1688}
1689
1690fn circle_at(frame: Frame, radius: f64, _at: f64, tol: Tolerances) -> Option<Circle> {
1692 if radius <= tol.confusion() {
1693 return None;
1694 }
1695 Circle::new(frame, radius, tol).ok()
1696}
1697
1698fn slant(near: f64, far: f64, height: f64) -> f64 {
1700 (far - near).hypot(height)
1701}
1702
1703fn doubly_seamed_face(
1709 model: &mut Model,
1710 surface: ogeom_topo::SurfaceId,
1711 along_u: &Shape,
1712 along_v: &Shape,
1713 v0: f64,
1714 tol: Tolerances,
1715) -> OgeomResult<Shape> {
1716 let (o, e) = (0.0, TAU);
1717 let (vo, ve) = (v0, v0 + TAU);
1718 seam_pcurves(
1721 model,
1722 along_u,
1723 surface,
1724 (Point2::new(o, vo), Point2::new(e, vo)),
1725 (Point2::new(o, ve), Point2::new(e, ve)),
1726 tol,
1727 )?;
1728 seam_pcurves(
1729 model,
1730 along_v,
1731 surface,
1732 (Point2::new(e, vo), Point2::new(e, ve)),
1733 (Point2::new(o, vo), Point2::new(o, ve)),
1734 tol,
1735 )?;
1736
1737 let ring = [
1738 along_u.clone(),
1739 along_v.clone(),
1740 along_u.reversed(),
1741 along_v.reversed(),
1742 ];
1743 let wire = make_wire(model, &ring, tol)?.shape;
1744 Ok(make_face_on(model, surface, std::slice::from_ref(&wire), tol)?.shape)
1745}
1746
1747#[cfg(test)]
1748#[allow(clippy::unwrap_used)]
1749mod tests {
1750 use super::*;
1751 use crate::build::is_shell_closed;
1752 use ogeom_geom::Surface;
1753 use ogeom_topo::{ShapeType, explore_unique};
1754
1755 const T: Tolerances = Tolerances::millimetres();
1756
1757 #[test]
1761 fn a_faceted_solid_keeps_its_faces_holes() {
1762 let mut model = Model::new();
1763 let square = |lo: f64, hi: f64, z: f64| {
1764 [(lo, lo), (hi, lo), (hi, hi), (lo, hi)].map(|(x, y)| Point::new(x, y, z))
1765 };
1766 let mut points: Vec<Point> = Vec::new();
1767 for (lo, hi) in [(0.0, 4.0), (1.0, 3.0)] {
1768 points.extend(square(lo, hi, 0.0));
1769 points.extend(square(lo, hi, 1.0));
1770 }
1771 let mut faces: Vec<Vec<Vec<usize>>> = vec![
1772 vec![vec![3, 2, 1, 0], vec![8, 9, 10, 11]],
1773 vec![vec![4, 5, 6, 7], vec![15, 14, 13, 12]],
1774 ];
1775 for i in 0..4 {
1776 let j = (i + 1) % 4;
1777 faces.push(vec![vec![i, j, 4 + j, 4 + i]]);
1778 faces.push(vec![vec![8 + j, 8 + i, 12 + i, 12 + j]]);
1779 }
1780 let (built, made) =
1781 make_faceted_solid_with_holes(&mut model, &points, &faces, &[], T).unwrap();
1782 assert_eq!(made.len(), 10);
1783 let rings: Vec<usize> = made
1784 .iter()
1785 .map(|f| model.children_of(f).unwrap().len())
1786 .collect();
1787 assert_eq!(&rings[..2], &[2, 2]);
1788 assert!(crate::check(&model, &built.shape, T).unwrap().is_valid());
1789 let mass =
1790 crate::volume_properties(&model, &built.shape, ogeom_mesh::Deflection::default(), T)
1791 .unwrap()
1792 .mass;
1793 assert!((mass - 12.0).abs() < 1e-9, "volume {mass}");
1794 }
1795
1796 #[test]
1797 fn a_box_has_the_topology_a_box_should_have() {
1798 let mut model = Model::new();
1799 let built = make_box(&mut model, Frame::WORLD, (2.0, 3.0, 4.0), T).unwrap();
1800 let solid = &built.shape;
1801
1802 assert_eq!(model.kind_of(solid).unwrap(), ShapeType::Solid);
1803 assert_eq!(
1804 explore_unique(&model, solid, ShapeType::Shell)
1805 .unwrap()
1806 .len(),
1807 1
1808 );
1809 assert_eq!(
1810 explore_unique(&model, solid, ShapeType::Face)
1811 .unwrap()
1812 .len(),
1813 6
1814 );
1815 assert_eq!(
1816 explore_unique(&model, solid, ShapeType::Wire)
1817 .unwrap()
1818 .len(),
1819 6
1820 );
1821 assert_eq!(
1822 explore_unique(&model, solid, ShapeType::Edge)
1823 .unwrap()
1824 .len(),
1825 12,
1826 "edges are shared between adjacent faces, not duplicated per face"
1827 );
1828 assert_eq!(
1829 explore_unique(&model, solid, ShapeType::Vertex)
1830 .unwrap()
1831 .len(),
1832 8
1833 );
1834 }
1835
1836 #[test]
1837 fn a_boxs_shell_is_closed() {
1838 let mut model = Model::new();
1841 let built = make_box(&mut model, Frame::WORLD, (1.0, 1.0, 1.0), T).unwrap();
1842 let shell = explore_unique(&model, &built.shape, ShapeType::Shell).unwrap();
1843 assert!(is_shell_closed(&model, &shell[0]).unwrap());
1844 }
1845
1846 #[test]
1847 fn every_face_normal_points_out_of_the_box() {
1848 let mut model = Model::new();
1852 let size = (2.0, 3.0, 4.0);
1853 let built = make_box(&mut model, Frame::WORLD, size, T).unwrap();
1854 let centre = Point::new(size.0 / 2.0, size.1 / 2.0, size.2 / 2.0);
1855
1856 let faces = explore_unique(&model, &built.shape, ShapeType::Face).unwrap();
1857 assert_eq!(faces.len(), 6);
1858 for face in &faces {
1859 let node = model.node(face).unwrap();
1860 let data = node.data().as_face().unwrap();
1861 let surface = model.geometry().surface(data.surface).unwrap();
1862 let ((ua, ub), (va, vb)) = surface.domain();
1863 let point = surface
1864 .point_at((ua + ub) / 2.0, (va + vb) / 2.0, T)
1865 .unwrap();
1866 let normal = surface
1867 .normal_at((ua + ub) / 2.0, (va + vb) / 2.0, T)
1868 .unwrap();
1869
1870 let outward = surface.point_at(0.0, 0.0, T).unwrap() - centre;
1873 assert!(
1874 normal.dot_vector(outward) > 0.0,
1875 "a face normal points inward: at {point:?}, normal {normal:?}"
1876 );
1877 }
1878 }
1879
1880 #[test]
1881 fn the_six_faces_carry_distinct_roles() {
1882 let mut model = Model::new();
1886 let built = make_box(&mut model, Frame::WORLD, (1.0, 2.0, 3.0), T).unwrap();
1887 let faces = explore_unique(&model, &built.shape, ShapeType::Face).unwrap();
1888
1889 let mut roles: Vec<Role> = faces
1890 .iter()
1891 .map(|f| match model.provenance_of(f).unwrap() {
1892 ogeom_core::Provenance::Derived { role, .. } => *role,
1893 other => panic!("expected a derived face, got {other:?}"),
1894 })
1895 .collect();
1896 roles.sort_unstable();
1897 roles.dedup();
1898 assert_eq!(roles.len(), 6, "each face is identifiable on its own");
1899 }
1900
1901 #[test]
1902 fn rebuilding_at_a_different_size_gives_the_faces_the_same_roles() {
1903 let roles_of = |size| {
1907 let mut model = Model::new();
1908 let built = make_box(&mut model, Frame::WORLD, size, T).unwrap();
1909 let mut roles: Vec<Role> = explore_unique(&model, &built.shape, ShapeType::Face)
1910 .unwrap()
1911 .iter()
1912 .map(|f| match model.provenance_of(f).unwrap() {
1913 ogeom_core::Provenance::Derived { role, .. } => *role,
1914 other => panic!("expected a derived face, got {other:?}"),
1915 })
1916 .collect();
1917 roles.sort_unstable();
1918 roles
1919 };
1920 assert_eq!(roles_of((1.0, 1.0, 1.0)), roles_of((10.0, 0.5, 7.0)));
1921 }
1922
1923 #[test]
1924 fn every_edge_carries_a_pcurve_for_each_face_it_bounds() {
1925 let mut model = Model::new();
1929 let built = make_box(&mut model, Frame::WORLD, (1.0, 2.0, 3.0), T).unwrap();
1930 for edge in explore_unique(&model, &built.shape, ShapeType::Edge).unwrap() {
1931 let data = model.node(&edge).unwrap().data().as_edge().unwrap();
1932 assert_eq!(
1933 data.parametric_surfaces().len(),
1934 2,
1935 "a box edge borders exactly two faces"
1936 );
1937 assert!(data.curve3d().is_some());
1938 }
1939 }
1940
1941 #[test]
1946 fn primitives_in_a_mirrored_frame_are_sound() {
1947 let twin = Frame::new(
1948 Point::new(3.0, -2.0, 5.0),
1949 ogeom_math::Direction::from_coords(1.0, 2.0, 2.0, T).unwrap(),
1950 ogeom_math::Direction::from_coords(2.0, -1.0, 0.0, T).unwrap(),
1951 T,
1952 )
1953 .unwrap();
1954 let mirrored = twin.mirrored();
1955 type Make = fn(&mut Model, Frame) -> OgeomResult<Built>;
1956 let round: [(&str, Make); 6] = [
1957 ("cylinder", |m, f| make_cylinder(m, f, 2.0, 5.0, T)),
1958 ("widening cone", |m, f| make_cone(m, f, 1.0, 3.0, 4.0, T)),
1959 ("narrowing cone", |m, f| make_cone(m, f, 3.0, 1.0, 4.0, T)),
1960 ("pointed cone", |m, f| make_cone(m, f, 3.0, 0.0, 4.0, T)),
1961 ("sphere", |m, f| make_sphere(m, f, 2.5, T)),
1962 ("torus", |m, f| make_torus(m, f, 4.0, 1.0, T)),
1963 ];
1964 let boxy: [(&str, Make); 3] = [
1965 ("box", |m, f| make_box(m, f, (4.0, 2.0, 3.0), T)),
1966 ("wedge", |m, f| {
1967 make_wedge(m, f, (4.0, 2.0, 3.0), (1.5, 0.5), T)
1968 }),
1969 ("ridge wedge", |m, f| {
1970 make_wedge(m, f, (4.0, 2.0, 3.0), (0.0, 1.0), T)
1971 }),
1972 ];
1973 let fine = ogeom_mesh::Deflection::with_chord(1e-3).unwrap();
1974 let measure = |make: Make, frame: Frame, name: &str| {
1975 let mut model = Model::new();
1976 let solid = make(&mut model, frame)
1977 .unwrap_or_else(|e| panic!("{name}: {e}"))
1978 .shape;
1979 let diagnosis = crate::check(&model, &solid, T).unwrap();
1980 assert!(diagnosis.is_valid(), "{name}: {diagnosis}");
1981 let props = crate::volume_properties(&model, &solid, fine, T)
1982 .unwrap_or_else(|e| panic!("{name}: {e}"));
1983 (props.mass, props.centre)
1984 };
1985 for (name, make) in round {
1986 let (v, c) = measure(make, twin, name);
1987 let (vm, cm) = measure(make, mirrored, name);
1988 assert!((v - vm).abs() < 1e-6 * v, "{name}: {vm} against {v}");
1989 assert!(c.distance(cm) < 1e-6, "{name}: {cm:?} against {c:?}");
1990 }
1991 for (name, make) in boxy {
1992 let (v, c) = measure(make, twin, name);
1993 let (vm, cm) = measure(make, mirrored, name);
1994 assert!((v - vm).abs() < 1e-6 * v, "{name}: {vm} against {v}");
1995 let local = twin.to_local(c);
1996 let reflected = twin.to_world(Point::new(local.x, -local.y, local.z));
1997 assert!(
1998 cm.distance(reflected) < 1e-6,
1999 "{name}: {cm:?} against {reflected:?}"
2000 );
2001 }
2002 }
2003
2004 #[test]
2005 fn a_parallelepiped_spans_its_triple_product_in_either_handedness() {
2006 use ogeom_math::Vector;
2007 let edges = [
2008 Vector::new(10.0, 0.0, 0.0),
2009 Vector::new(3.0, 10.0, 0.0),
2010 Vector::new(2.0, 1.0, 10.0),
2011 ];
2012 for order in [[0, 1, 2], [1, 0, 2]] {
2013 let mut model = Model::new();
2014 let spanned = [edges[order[0]], edges[order[1]], edges[order[2]]];
2015 let solid = make_parallelepiped(&mut model, Point::new(1.0, 2.0, 3.0), spanned, T)
2016 .unwrap()
2017 .shape;
2018 assert_eq!(model.kind_of(&solid).unwrap(), ShapeType::Solid);
2019 let faces = explore_unique(&model, &solid, ShapeType::Face).unwrap();
2020 assert_eq!(faces.len(), 6);
2021 assert_eq!(
2022 explore_unique(&model, &solid, ShapeType::Edge)
2023 .unwrap()
2024 .len(),
2025 12
2026 );
2027 assert_eq!(
2028 explore_unique(&model, &solid, ShapeType::Vertex)
2029 .unwrap()
2030 .len(),
2031 8
2032 );
2033 let shell = explore_unique(&model, &solid, ShapeType::Shell)
2034 .unwrap()
2035 .remove(0);
2036 assert!(crate::is_shell_closed(&model, &shell).unwrap());
2037 let centre = Point::new(1.0, 2.0, 3.0) + (edges[0] + edges[1] + edges[2]) * 0.5;
2040 for face in &faces {
2041 let corners: Vec<Point> = explore_unique(&model, face, ShapeType::Vertex)
2042 .unwrap()
2043 .iter()
2044 .map(|v| model.node(v).unwrap().data().as_vertex().unwrap().point)
2045 .collect();
2046 let mut mid = ogeom_math::Vector::ZERO;
2047 for c in &corners {
2048 mid += c.to_vector();
2049 }
2050 let mid = Point::ORIGIN + mid * 0.25;
2051 let data = model.node(face).unwrap().data().as_face().unwrap().clone();
2052 let Some(ogeom_geom::SurfaceGeometry::Plane(plane)) =
2053 model.geometry().surface(data.surface)
2054 else {
2055 panic!("a parallelepiped's faces are planes");
2056 };
2057 let mut normal = plane.plane().normal().vector();
2058 if face.orientation() == ogeom_topo::Orientation::Reversed {
2059 normal = -normal;
2060 }
2061 assert!(normal.dot(mid - centre) > 0.0, "a face looks outward");
2062 }
2063 }
2064 let mut model = Model::new();
2065 assert!(
2066 make_parallelepiped(
2067 &mut model,
2068 Point::ORIGIN,
2069 [edges[0], edges[1], edges[0] + edges[1]],
2070 T
2071 )
2072 .is_err(),
2073 "coplanar edges span no volume"
2074 );
2075 }
2076
2077 #[test]
2080 fn a_hexahedron_is_a_frustum_when_its_corners_say_so() {
2081 let mut model = Model::new();
2082 let (h, a, b) = (3.0, 2.0, 1.0);
2083 let corners = [
2084 Point::new(-a, -a, 0.0),
2085 Point::new(a, -a, 0.0),
2086 Point::new(a, a, 0.0),
2087 Point::new(-a, a, 0.0),
2088 Point::new(-b, -b, h),
2089 Point::new(b, -b, h),
2090 Point::new(b, b, h),
2091 Point::new(-b, b, h),
2092 ];
2093 let solid = make_hexahedron(&mut model, corners, T).unwrap().shape;
2094 assert_eq!(model.kind_of(&solid).unwrap(), ShapeType::Solid);
2095 assert_eq!(
2096 explore_unique(&model, &solid, ShapeType::Face)
2097 .unwrap()
2098 .len(),
2099 6
2100 );
2101 let shell = explore_unique(&model, &solid, ShapeType::Shell)
2102 .unwrap()
2103 .remove(0);
2104 assert!(crate::is_shell_closed(&model, &shell).unwrap());
2105 let (bottom, top) = (4.0 * a * a, 4.0 * b * b);
2106 let expected = h / 3.0 * (bottom + top + (bottom * top).sqrt());
2107 let measured =
2108 crate::volume_properties(&model, &solid, ogeom_mesh::Deflection::default(), T)
2109 .unwrap()
2110 .mass;
2111 assert!(
2112 (measured - expected).abs() < expected * 1e-6,
2113 "frustum volume {measured} against {expected}"
2114 );
2115 let mut skewed = corners;
2117 skewed[6] = Point::new(b, b + 0.5, h);
2118 assert!(make_hexahedron(&mut model, skewed, T).is_err());
2119 }
2120
2121 #[test]
2126 fn a_polyhedron_winds_its_rings_outward_and_closes() {
2127 let mut model = Model::new();
2128 let points = [
2129 Point::new(0.0, 0.0, 0.0),
2130 Point::new(4.0, 0.0, 0.0),
2131 Point::new(0.0, 3.0, 0.0),
2132 Point::new(0.0, 0.0, 5.0),
2133 Point::new(4.0, 0.0, 5.0),
2134 Point::new(0.0, 3.0, 5.0),
2135 ];
2136 let rings = vec![
2137 vec![0, 1, 2], vec![3, 4, 5], vec![0, 1, 4, 3], vec![1, 2, 5, 4], vec![0, 3, 5, 2], ];
2143 let solid = make_polyhedron(&mut model, &points, &rings, T)
2144 .unwrap()
2145 .shape;
2146 assert_eq!(model.kind_of(&solid).unwrap(), ShapeType::Solid);
2147 assert_eq!(
2148 explore_unique(&model, &solid, ShapeType::Edge)
2149 .unwrap()
2150 .len(),
2151 9
2152 );
2153 let shell = explore_unique(&model, &solid, ShapeType::Shell)
2154 .unwrap()
2155 .remove(0);
2156 assert!(crate::is_shell_closed(&model, &shell).unwrap());
2157 let expected = 0.5 * 4.0 * 3.0 * 5.0;
2158 let measured =
2159 crate::volume_properties(&model, &solid, ogeom_mesh::Deflection::default(), T)
2160 .unwrap()
2161 .mass;
2162 assert!(
2163 (measured - expected).abs() < expected * 1e-6,
2164 "prism volume {measured} against {expected}"
2165 );
2166 let open = make_polyhedron(&mut model, &points, &rings[..4], T);
2168 assert!(open.is_err());
2169 let mut bent = points;
2171 bent[4] = Point::new(4.0, 0.5, 5.0);
2172 assert!(make_polyhedron(&mut model, &bent, &rings, T).is_err());
2173 }
2174
2175 #[test]
2179 fn a_polyhedron_face_may_start_on_a_straight_run() {
2180 let mut model = Model::new();
2181 let points = [
2182 Point::new(0.0, 0.0, 0.0),
2183 Point::new(1.0, 0.0, 0.0),
2184 Point::new(2.0, 0.0, 0.0),
2185 Point::new(2.0, 2.0, 0.0),
2186 Point::new(0.0, 2.0, 0.0),
2187 Point::new(1.0, 1.0, 3.0),
2188 ];
2189 let rings = vec![
2190 vec![0, 1, 2, 3, 4],
2191 vec![0, 1, 2, 5],
2192 vec![2, 3, 5],
2193 vec![3, 4, 5],
2194 vec![4, 0, 5],
2195 ];
2196 let solid = make_polyhedron(&mut model, &points, &rings, T)
2197 .unwrap()
2198 .shape;
2199 assert!(crate::check(&model, &solid, T).unwrap().is_valid());
2200 let expected = 4.0 * 3.0 / 3.0;
2201 let measured =
2202 crate::volume_properties(&model, &solid, ogeom_mesh::Deflection::default(), T)
2203 .unwrap()
2204 .mass;
2205 assert!(
2206 (measured - expected).abs() < expected * 1e-9,
2207 "pyramid volume {measured} against {expected}"
2208 );
2209 }
2210
2211 #[test]
2212 fn a_box_in_a_tilted_frame_is_still_a_box() {
2213 let frame = Frame::new(
2214 Point::new(5.0, -2.0, 1.0),
2215 Direction::from_coords(1.0, 1.0, 1.0, T).unwrap(),
2216 Direction::from_coords(1.0, -1.0, 0.0, T).unwrap(),
2217 T,
2218 )
2219 .unwrap();
2220 let mut model = Model::new();
2221 let built = make_box(&mut model, frame, (2.0, 2.0, 2.0), T).unwrap();
2222
2223 assert_eq!(
2224 explore_unique(&model, &built.shape, ShapeType::Face)
2225 .unwrap()
2226 .len(),
2227 6
2228 );
2229 let shell = explore_unique(&model, &built.shape, ShapeType::Shell).unwrap();
2230 assert!(is_shell_closed(&model, &shell[0]).unwrap());
2231
2232 let vertices = explore_unique(&model, &built.shape, ShapeType::Vertex).unwrap();
2234 let origin_corner = frame.to_world(Point::ORIGIN);
2235 assert!(
2236 vertices.iter().any(|v| {
2237 model
2238 .node(v)
2239 .unwrap()
2240 .data()
2241 .as_vertex()
2242 .unwrap()
2243 .point
2244 .is_equal(origin_corner, T)
2245 }),
2246 "no vertex at the frame origin"
2247 );
2248 }
2249
2250 #[test]
2251 fn degenerate_dimensions_are_refused() {
2252 let mut model = Model::new();
2253 for size in [
2254 (0.0, 1.0, 1.0),
2255 (1.0, -1.0, 1.0),
2256 (1.0, 1.0, f64::NAN),
2257 (f64::INFINITY, 1.0, 1.0),
2258 ] {
2259 assert!(
2260 make_box(&mut model, Frame::WORLD, size, T).is_err(),
2261 "accepted {size:?}"
2262 );
2263 }
2264 assert!(make_box(&mut model, Frame::WORLD, (1.0, 1.0, 1.0), T).is_ok());
2265 }
2266
2267 #[test]
2268 fn a_primitive_reports_no_history_because_it_consumed_nothing() {
2269 let mut model = Model::new();
2270 let built = make_box(&mut model, Frame::WORLD, (1.0, 1.0, 1.0), T).unwrap();
2271 assert!(
2272 built.history.is_empty(),
2273 "built from numbers, so there are no inputs to report on"
2274 );
2275 }
2276
2277 #[test]
2278 fn every_corner_pair_of_a_face_is_a_real_edge() {
2279 for (corners, _) in FACES {
2282 for step in 0..4 {
2283 let (from, to) = (corners[step], corners[(step + 1) % 4]);
2284 assert!(
2285 find_edge(from, to).is_ok(),
2286 "face corners {from} and {to} are not joined"
2287 );
2288 }
2289 }
2290 assert!(find_edge(0, 6).is_err(), "opposite corners share no edge");
2291 }
2292
2293 #[test]
2294 fn every_edge_is_used_by_exactly_two_faces_in_the_tables() {
2295 let mut uses = [0_usize; EDGES.len()];
2299 for (corners, _) in FACES {
2300 for step in 0..4 {
2301 let (from, to) = (corners[step], corners[(step + 1) % 4]);
2302 let (index, _) = find_edge(from, to).unwrap();
2303 uses[index] += 1;
2304 }
2305 }
2306 assert!(uses.iter().all(|&n| n == 2), "edge use counts: {uses:?}");
2307 }
2308}
2309
2310#[cfg(test)]
2311#[allow(clippy::unwrap_used)]
2312mod revolution_tests {
2313 use super::*;
2314 use crate::build::is_shell_closed;
2315 use crate::mass::{surface_properties, volume_properties};
2316 use approx::assert_relative_eq;
2317 use ogeom_mesh::{Deflection, triangulate};
2318 use ogeom_topo::{ShapeType, explore_unique};
2319
2320 const T: Tolerances = Tolerances::millimetres();
2321
2322 fn deflection(chord: f64) -> Deflection {
2323 Deflection {
2324 chord,
2325 ..Deflection::default()
2326 }
2327 }
2328
2329 #[test]
2330 fn a_cylinder_has_the_topology_a_cylinder_has() {
2331 let mut model = Model::new();
2332 let built = make_cylinder(&mut model, Frame::WORLD, 2.0, 5.0, T).unwrap();
2333
2334 let counts = |kind| explore_unique(&model, &built.shape, kind).unwrap().len();
2335 assert_eq!(counts(ShapeType::Face), 3, "a side and two caps");
2336 assert_eq!(counts(ShapeType::Edge), 3, "two rims and one seam");
2337 assert_eq!(counts(ShapeType::Vertex), 2, "one on each rim");
2338
2339 let shell = explore_unique(&model, &built.shape, ShapeType::Shell).unwrap()[0].clone();
2340 assert!(
2341 is_shell_closed(&model, &shell).unwrap(),
2342 "every edge should be used an even number of times"
2343 );
2344 }
2345
2346 #[test]
2347 fn a_cylinder_tessellates_into_a_closed_mesh_of_the_right_size() {
2348 let (radius, height) = (2.0_f64, 5.0);
2352 let exact = PI * radius * radius * height;
2353 let mut model = Model::new();
2354 let built = make_cylinder(&mut model, Frame::WORLD, radius, height, T).unwrap();
2355
2356 let mut previous = 0.0;
2362 for chord in [0.1_f64, 0.02, 0.005] {
2363 let mesh = triangulate(&model, &built.shape, deflection(chord), T).unwrap();
2364 assert!(mesh.is_closed(), "the mesh has a hole at chord {chord}");
2365 assert!(
2366 mesh.volume() < exact,
2367 "an inscribed volume cannot exceed it"
2368 );
2369 assert!(mesh.volume() >= previous, "refining lost volume");
2370 previous = mesh.volume();
2371 }
2372 assert!(previous > exact * 0.995, "{previous} against {exact}");
2373 let props = volume_properties(&model, &built.shape, deflection(0.005), T).unwrap();
2374 assert_relative_eq!(props.mass, exact, epsilon = 1e-9);
2375 assert_eq!(props.deflection, 0.0, "measured on the exact surface");
2376 }
2377
2378 #[test]
2379 fn a_cylinders_caps_face_outward() {
2380 let mut model = Model::new();
2384 let built = make_cylinder(&mut model, Frame::WORLD, 1.0, 3.0, T).unwrap();
2385 let props = volume_properties(&model, &built.shape, deflection(0.005), T).unwrap();
2386
2387 assert!(
2388 props.centre.distance(Point::new(0.0, 0.0, 1.5)) < 1e-3,
2389 "the centre of a cylinder is halfway up its axis, got {:?}",
2390 props.centre
2391 );
2392 }
2393
2394 #[test]
2395 fn a_sphere_has_the_topology_a_sphere_has() {
2396 let mut model = Model::new();
2397 let built = make_sphere(&mut model, Frame::WORLD, 3.0, T).unwrap();
2398
2399 let counts = |kind| explore_unique(&model, &built.shape, kind).unwrap().len();
2400 assert_eq!(counts(ShapeType::Face), 1, "one surface covers a sphere");
2401 assert_eq!(counts(ShapeType::Edge), 3, "a seam and two poles");
2402 assert_eq!(counts(ShapeType::Vertex), 2, "the two poles");
2403
2404 let degenerate = explore_unique(&model, &built.shape, ShapeType::Edge)
2407 .unwrap()
2408 .into_iter()
2409 .filter(|e| {
2410 model
2411 .node(e)
2412 .and_then(|n| n.data().as_edge())
2413 .is_some_and(|d| d.degenerate)
2414 })
2415 .count();
2416 assert_eq!(degenerate, 2);
2417 }
2418
2419 #[test]
2420 fn a_sphere_converges_on_the_volume_and_area_a_sphere_has() {
2421 let radius = 4.0_f64;
2422 let volume = 4.0 / 3.0 * PI * radius.powi(3);
2423 let area = 4.0 * PI * radius * radius;
2424 let mut model = Model::new();
2425 let built = make_sphere(&mut model, Frame::WORLD, radius, T).unwrap();
2426
2427 let props = volume_properties(&model, &built.shape, deflection(0.01), T).unwrap();
2428 assert_relative_eq!(props.mass, volume, epsilon = 1e-9);
2429 assert_eq!(props.deflection, 0.0, "measured on the exact surface");
2430 assert!(
2431 props.centre.distance(Point::ORIGIN) < 1e-9,
2432 "got {:?}",
2433 props.centre
2434 );
2435
2436 let surface = surface_properties(&model, &built.shape, deflection(0.01), T).unwrap();
2437 assert_relative_eq!(surface.mass, area, epsilon = 1e-9);
2438 assert_eq!(surface.deflection, 0.0, "measured on the exact surface");
2439 }
2440
2441 #[test]
2442 fn a_placed_primitive_lands_where_it_was_placed() {
2443 let frame = Frame::new(Point::new(10.0, -5.0, 2.0), Direction::X, Direction::Y, T).unwrap();
2444 let mut model = Model::new();
2445 let built = make_cylinder(&mut model, frame, 1.0, 4.0, T).unwrap();
2446 let props = volume_properties(&model, &built.shape, deflection(0.005), T).unwrap();
2447
2448 assert!(
2450 props.centre.distance(Point::new(12.0, -5.0, 2.0)) < 1e-3,
2451 "got {:?}",
2452 props.centre
2453 );
2454 assert_relative_eq!(props.mass, PI * 4.0, max_relative = 0.01);
2456 }
2457
2458 #[test]
2459 fn dimensions_that_describe_no_solid_are_refused() {
2460 let mut model = Model::new();
2461 for (r, h) in [(0.0, 1.0), (1.0, 0.0), (-1.0, 1.0), (f64::NAN, 1.0)] {
2462 assert!(make_cylinder(&mut model, Frame::WORLD, r, h, T).is_err());
2463 }
2464 for r in [0.0, -1.0, f64::INFINITY] {
2465 assert!(make_sphere(&mut model, Frame::WORLD, r, T).is_err());
2466 }
2467 }
2468}
2469
2470#[cfg(test)]
2471#[allow(clippy::unwrap_used)]
2472mod more_primitive_tests {
2473 use super::*;
2474 use crate::build::is_shell_closed;
2475 use crate::mass::volume_properties;
2476 use approx::assert_relative_eq;
2477 use ogeom_mesh::{Deflection, triangulate};
2478 use ogeom_topo::{ShapeType, explore_unique};
2479
2480 const T: Tolerances = Tolerances::millimetres();
2481
2482 fn deflection(chord: f64) -> Deflection {
2483 Deflection {
2484 chord,
2485 ..Deflection::default()
2486 }
2487 }
2488
2489 fn closed(model: &Model, solid: &Shape) -> bool {
2490 let shell = explore_unique(model, solid, ShapeType::Shell).unwrap()[0].clone();
2491 is_shell_closed(model, &shell).unwrap()
2492 }
2493
2494 #[test]
2495 fn a_truncated_cone_has_the_volume_a_frustum_has() {
2496 let (r0, r1, h) = (3.0_f64, 1.0_f64, 4.0_f64);
2497 let exact = PI * h / 3.0 * r1.mul_add(r1, r0.mul_add(r0, r0 * r1));
2498 let mut model = Model::new();
2499 let built = make_cone(&mut model, Frame::WORLD, r0, r1, h, T).unwrap();
2500
2501 assert!(closed(&model, &built.shape));
2502 let props = volume_properties(&model, &built.shape, deflection(0.005), T).unwrap();
2503 assert!(
2505 (props.mass - exact).abs() <= exact * 1e-12,
2506 "{} against {exact}",
2507 props.mass
2508 );
2509 }
2510
2511 #[test]
2512 fn a_true_cone_ends_in_an_apex_and_has_no_top_cap() {
2513 let (radius, height) = (2.0_f64, 5.0);
2517 let exact = PI * radius * radius * height / 3.0;
2518 let mut model = Model::new();
2519 let built = make_cone(&mut model, Frame::WORLD, radius, 0.0, height, T).unwrap();
2520
2521 assert_eq!(
2522 explore_unique(&model, &built.shape, ShapeType::Face)
2523 .unwrap()
2524 .len(),
2525 2,
2526 "a flank and one cap"
2527 );
2528 assert!(closed(&model, &built.shape));
2529 let props = volume_properties(&model, &built.shape, deflection(0.005), T).unwrap();
2530 assert!(props.mass < exact);
2531 assert!(props.mass > exact * 0.99, "{} against {exact}", props.mass);
2532 }
2533
2534 #[test]
2535 fn a_cone_widening_upward_is_built_the_same_way_round() {
2536 let mut model = Model::new();
2540 let up = make_cone(&mut model, Frame::WORLD, 1.0, 3.0, 4.0, T).unwrap();
2541 let down = make_cone(&mut model, Frame::WORLD, 3.0, 1.0, 4.0, T).unwrap();
2542
2543 let a = volume_properties(&model, &up.shape, deflection(0.005), T).unwrap();
2544 let b = volume_properties(&model, &down.shape, deflection(0.005), T).unwrap();
2545 assert_relative_eq!(a.mass, b.mass, max_relative = 1e-9);
2546 assert_relative_eq!(a.centre.z, 4.0 - b.centre.z, epsilon = 1e-9);
2548 }
2549
2550 #[test]
2551 fn a_torus_has_two_seams_one_vertex_and_the_volume_a_torus_has() {
2552 let (major, minor) = (5.0_f64, 2.0);
2555 let exact = 2.0 * PI * PI * major * minor * minor;
2556 let mut model = Model::new();
2557 let built = make_torus(&mut model, Frame::WORLD, major, minor, T).unwrap();
2558
2559 let counts = |kind| explore_unique(&model, &built.shape, kind).unwrap().len();
2560 assert_eq!(counts(ShapeType::Face), 1);
2561 assert_eq!(counts(ShapeType::Edge), 2, "one seam each way");
2562 assert_eq!(counts(ShapeType::Vertex), 1, "where the two seams cross");
2563 assert!(closed(&model, &built.shape));
2564
2565 let mesh = triangulate(&model, &built.shape, deflection(0.02), T).unwrap();
2566 assert!(mesh.is_closed(), "the mesh has a hole");
2567
2568 let props = volume_properties(&model, &built.shape, deflection(0.02), T).unwrap();
2569 assert_relative_eq!(props.mass, exact, epsilon = 1e-9);
2570 assert_eq!(props.deflection, 0.0, "measured on the exact surface");
2571 assert!(props.centre.distance(Point::ORIGIN) < 1e-9);
2572 }
2573
2574 #[test]
2577 fn a_torus_seamed_on_any_parallel_is_the_whole_torus() {
2578 let (major, minor) = (5.0_f64, 2.0);
2579 let volume = 2.0 * PI * PI * major * minor * minor;
2580 let area = 4.0 * PI * PI * major * minor;
2581 for v0 in [0.0, 1.0, FRAC_PI_2, PI, 4.0, -2.5] {
2582 let mut model = Model::new();
2583 let face = torus_face_seamed_at(&mut model, Frame::WORLD, major, minor, v0, T).unwrap();
2584 let shell = make_shell(&mut model, std::slice::from_ref(&face))
2585 .unwrap()
2586 .shape;
2587 let solid = crate::make_solid(&mut model, std::slice::from_ref(&shell))
2588 .unwrap()
2589 .shape;
2590 let diagnosis = crate::check(&model, &solid, T).unwrap();
2591 assert!(diagnosis.is_valid(), "seamed at {v0}: {diagnosis}");
2592 let mesh = triangulate(&model, &solid, deflection(0.02), T).unwrap();
2593 assert!(mesh.is_closed(), "seamed at {v0}: the mesh has a hole");
2594 let fine = deflection(1e-3);
2595 let props = volume_properties(&model, &solid, fine, T).unwrap();
2596 assert_relative_eq!(props.mass, volume, max_relative = 1e-9);
2597 let skin = crate::surface_properties(&model, &solid, fine, T).unwrap();
2598 assert_relative_eq!(skin.mass, area, max_relative = 1e-6);
2599 }
2600 }
2601
2602 #[test]
2603 fn a_wedge_with_equal_extents_is_a_box() {
2604 let mut model = Model::new();
2605 let wedge = make_wedge(&mut model, Frame::WORLD, (2.0, 3.0, 4.0), (2.0, 3.0), T).unwrap();
2606 let props = volume_properties(&model, &wedge.shape, deflection(0.01), T).unwrap();
2607 assert_relative_eq!(props.mass, 24.0, epsilon = 1e-9);
2608 assert!(closed(&model, &wedge.shape));
2609 }
2610
2611 #[test]
2612 fn a_tapered_wedge_has_the_volume_a_frustum_of_a_pyramid_has() {
2613 let mut model = Model::new();
2615 let wedge = make_wedge(&mut model, Frame::WORLD, (4.0, 4.0, 6.0), (2.0, 2.0), T).unwrap();
2616 let props = volume_properties(&model, &wedge.shape, deflection(0.01), T).unwrap();
2617 assert_relative_eq!(
2618 props.mass,
2619 6.0 / 6.0 * 4.0_f64.mul_add(9.0, 16.0 + 4.0),
2620 epsilon = 1e-9
2621 );
2622 assert!(closed(&model, &wedge.shape));
2623 }
2624
2625 #[test]
2626 fn a_wedge_collapsing_to_a_ridge_is_five_faces_and_a_prismatoid_volume() {
2627 let mut model = Model::new();
2631 let wedge = make_wedge(&mut model, Frame::WORLD, (4.0, 3.0, 6.0), (2.0, 0.0), T).unwrap();
2632 let faces = ogeom_topo::explore_unique(&model, &wedge.shape, ShapeType::Face)
2633 .unwrap()
2634 .len();
2635 assert_eq!(faces, 5, "a ridge wedge has five faces, none of them empty");
2636 let props = volume_properties(&model, &wedge.shape, deflection(0.01), T).unwrap();
2637 assert_relative_eq!(props.mass, 6.0 * 3.0 * (2.0 - 2.0 / 6.0), epsilon = 1e-9);
2638 assert!(closed(&model, &wedge.shape));
2639
2640 let other = make_wedge(&mut model, Frame::WORLD, (3.0, 4.0, 6.0), (0.0, 2.0), T).unwrap();
2642 let props = volume_properties(&model, &other.shape, deflection(0.01), T).unwrap();
2643 assert_relative_eq!(props.mass, 6.0 * 3.0 * (2.0 - 2.0 / 6.0), epsilon = 1e-9);
2644 assert!(closed(&model, &other.shape));
2645 }
2646
2647 #[test]
2648 fn a_wedge_collapsing_to_a_point_is_a_pyramid() {
2649 let mut model = Model::new();
2650 let wedge = make_wedge(&mut model, Frame::WORLD, (4.0, 3.0, 6.0), (0.0, 0.0), T).unwrap();
2651 let faces = ogeom_topo::explore_unique(&model, &wedge.shape, ShapeType::Face)
2652 .unwrap()
2653 .len();
2654 assert_eq!(faces, 5, "a base and four triangles");
2655 let props = volume_properties(&model, &wedge.shape, deflection(0.01), T).unwrap();
2656 assert_relative_eq!(props.mass, 4.0 * 3.0 * 6.0 / 3.0, epsilon = 1e-9);
2657 assert!(closed(&model, &wedge.shape));
2658 }
2659
2660 #[test]
2661 fn dimensions_that_describe_no_solid_are_refused() {
2662 let mut model = Model::new();
2663 assert!(make_cone(&mut model, Frame::WORLD, 2.0, 2.0, 1.0, T).is_err());
2665 assert!(make_cone(&mut model, Frame::WORLD, 0.0, 0.0, 1.0, T).is_err());
2666 assert!(make_cone(&mut model, Frame::WORLD, 1.0, 2.0, 0.0, T).is_err());
2667 assert!(make_cone(&mut model, Frame::WORLD, -1.0, 2.0, 1.0, T).is_err());
2668
2669 assert!(make_torus(&mut model, Frame::WORLD, 0.0, 1.0, T).is_err());
2670 assert!(make_torus(&mut model, Frame::WORLD, 1.0, f64::NAN, T).is_err());
2671
2672 assert!(make_wedge(&mut model, Frame::WORLD, (0.0, 1.0, 1.0), (1.0, 1.0), T).is_err());
2673 assert!(make_wedge(&mut model, Frame::WORLD, (1.0, 1.0, 1.0), (-1.0, 1.0), T).is_err());
2674 }
2675}
2676
2677#[cfg(test)]
2678#[allow(clippy::unwrap_used)]
2679mod half_space_tests {
2680 use super::*;
2681 use crate::classify::Containment;
2682 use crate::{classify_in_solid, make_natural_face};
2683 use ogeom_geom::PlaneSurface;
2684 use ogeom_math::Direction;
2685 use ogeom_mesh::Deflection;
2686 use ogeom_topo::explore_unique;
2687
2688 const T: Tolerances = Tolerances::millimetres();
2689
2690 fn coarse() -> Deflection {
2691 Deflection {
2692 chord: 1.0,
2693 ..Deflection::default()
2694 }
2695 }
2696
2697 fn ground(model: &mut Model) -> Shape {
2699 make_natural_face(model, PlaneSurface::new(Plane::new(Frame::WORLD)).into())
2700 .unwrap()
2701 .shape
2702 }
2703
2704 #[test]
2705 fn the_face_is_oriented_away_from_the_side_that_is_solid() {
2706 let mut model = Model::new();
2710 let face = ground(&mut model);
2711 let above = make_half_space(&mut model, &face, Point::new(0.0, 0.0, 5.0), T).unwrap();
2712 let below = make_half_space(&mut model, &face, Point::new(0.0, 0.0, -5.0), T).unwrap();
2713
2714 let boundary = |built: &crate::Built| {
2715 explore_unique(&model, &built.shape, ShapeType::Face).unwrap()[0].clone()
2716 };
2717 let (a, b) = (boundary(&above), boundary(&below));
2718 assert!(a.is_partner(&b), "the same face, both times");
2719 assert_ne!(
2720 a.orientation(),
2721 b.orientation(),
2722 "naming the other side should turn the boundary round"
2723 );
2724 assert_eq!(model.kind_of(&above.shape).unwrap(), ShapeType::Solid);
2725 assert_eq!(
2726 explore_unique(&model, &above.shape, ShapeType::Face)
2727 .unwrap()
2728 .len(),
2729 1,
2730 "one face bounds a half space"
2731 );
2732 }
2733
2734 #[test]
2735 fn nothing_can_yet_be_asked_about_the_inside_of_one() {
2736 let mut model = Model::new();
2742 let face = ground(&mut model);
2743 let built = make_half_space(&mut model, &face, Point::new(0.0, 0.0, 5.0), T).unwrap();
2744
2745 let shell = explore_unique(&model, &built.shape, ShapeType::Shell).unwrap()[0].clone();
2746 assert!(!crate::is_shell_closed(&model, &shell).unwrap());
2747
2748 let err = classify_in_solid(&model, &built.shape, Point::new(0.0, 0.0, 5.0), coarse(), T)
2749 .unwrap_err();
2750 assert!(
2751 err.to_string().contains("not closed"),
2752 "unexpected message: {err}"
2753 );
2754 let _ = Containment::In;
2755 }
2756
2757 #[test]
2758 fn a_point_on_the_face_names_no_side() {
2759 let mut model = Model::new();
2760 let face = ground(&mut model);
2761 let err = make_half_space(&mut model, &face, Point::ORIGIN, T).unwrap_err();
2762 assert!(err.to_string().contains("names no side"), "got {err}");
2763
2764 let err = make_half_space(&mut model, &face, Point::new(3.0, 4.0, 0.0), T).unwrap_err();
2766 assert!(err.to_string().contains("names no side"), "got {err}");
2767 }
2768
2769 #[test]
2770 fn a_half_space_is_bounded_by_a_face_and_nothing_else() {
2771 let mut model = Model::new();
2772 let solid = make_box(&mut model, Frame::WORLD, (1.0, 1.0, 1.0), T)
2773 .unwrap()
2774 .shape;
2775 assert!(make_half_space(&mut model, &solid, Point::ORIGIN, T).is_err());
2776
2777 let vertex = model.add_point(Point::ORIGIN);
2778 assert!(make_half_space(&mut model, &vertex, Point::ORIGIN, T).is_err());
2779 }
2780
2781 #[test]
2782 fn it_reaches_only_as_far_as_its_surface_says() {
2783 let mut model = Model::new();
2788 let face = ground(&mut model);
2789 let built = make_half_space(&mut model, &face, Point::new(0.0, 0.0, 1.0), T).unwrap();
2790
2791 let bounds = crate::shape_bounds(&model, &built.shape, T).unwrap();
2796 assert!(
2797 bounds.is_empty(),
2798 "an unbounded plane should decline to bound itself, got {bounds:?}"
2799 );
2800 let _ = Direction::Z;
2801 }
2802}