1use ogeom_core::FastMap;
28
29use core::f64::consts::TAU;
30use ogeom_core::{OgeomResult, Tolerances, ogeom_bail};
31use ogeom_geom::{Curve3d, ExtrusionSurface, Line2d, PlanarCurve, Transformable};
32use ogeom_math::{Axis, Circle, Direction, Frame, Point, Point2, Transform, Vector};
33use ogeom_topo::{EdgeRepr, Location, Model, NodeData, Orientation, Shape, ShapeType, TShapeId};
34
35use crate::build::{
36 edges_turn, make_face_on, make_shell, make_solid, make_wire, walked_back, wire_turn,
37};
38use crate::history::{Built, History};
39
40pub mod roles {
42 use ogeom_core::Role;
43
44 pub const SWEEP_BOTTOM: Role = Role::op_defined(20);
46 pub const SWEEP_TOP: Role = Role::op_defined(21);
48 pub const SWEEP_SIDE: Role = Role::op_defined(22);
50 pub const SWEEP_RAIL: Role = Role::op_defined(23);
52}
53
54type ChartMap = Box<dyn Fn((f64, f64)) -> (f64, f64)>;
56
57pub fn make_prism(
69 model: &mut Model,
70 profile: &Shape,
71 vector: Vector,
72 tol: Tolerances,
73) -> OgeomResult<Built> {
74 if !vector.is_finite() || vector.magnitude() <= tol.confusion() {
75 ogeom_bail!(
76 Construction,
77 "a prism needs a direction to travel; {vector:?} has no length"
78 );
79 }
80 model.begin_operation();
81
82 let datum = model.add_datum(Transform::translation(vector));
87 let displacement = Location::of(datum);
88
89 let rails = &mut Rails::default();
90 match model.kind_of(profile)? {
91 ShapeType::Face => {
92 crate::build::trimmed_or_fitted_where_bare(model, profile, tol)?;
93 prism_over_face(model, rails, profile, &displacement, vector, tol)
94 }
95 ShapeType::Wire => {
96 let (faces, history) =
97 prism_over_wire(model, rails, profile, &displacement, vector, tol)?;
98 let shell = make_shell(model, &faces)?.shape;
99 Ok(Built::new(shell, history))
100 }
101 ShapeType::Edge => {
102 let (face, history) =
103 prism_over_edge(model, rails, profile, &displacement, vector, tol)?;
104 Ok(Built::new(face, history))
105 }
106 other => ogeom_bail!(
107 Construction,
108 "a {other:?} cannot be swept into anything; sweep an edge, a wire or \
109 a face"
110 ),
111 }
112}
113
114pub fn make_prism_tapered(
132 model: &mut Model,
133 profile: &Shape,
134 vector: Vector,
135 taper: f64,
136 tol: Tolerances,
137) -> OgeomResult<Built> {
138 use ogeom_geom::Curve;
139
140 if !taper.is_finite() || taper.abs() <= tol.angular() {
141 return make_prism(model, profile, vector, tol);
142 }
143 if taper.abs() >= core::f64::consts::FRAC_PI_2 - tol.angular() {
144 ogeom_bail!(Construction, "a taper of {taper} flattens the prism");
145 }
146 let travel = vector.magnitude();
147 if !travel.is_finite() || travel <= tol.confusion() {
148 ogeom_bail!(Construction, "a sweep along {vector:?} goes nowhere");
149 }
150 if model.kind_of(profile)? != ShapeType::Face {
151 ogeom_bail!(
152 Construction,
153 "a tapered prism encloses volume; sweep a planar face"
154 );
155 }
156 let placement = profile.location().clone();
160 if !placement.is_identity() {
161 let local = placement
162 .composed(model.datums())?
163 .inverse()?
164 .apply_vector(vector);
165 let built = make_prism_tapered(
166 model,
167 &profile.located(Location::identity()),
168 local,
169 taper,
170 tol,
171 )?;
172 return Ok(Built::new(built.shape.moved(&placement), built.history));
173 }
174 let Some(plane) = crate::build::find_plane(model, profile, tol)? else {
175 ogeom_bail!(Construction, "a tapered prism sweeps a planar face");
176 };
177 attach_cap_pcurves(model, profile, tol)?;
180 let mut normal = plane.normal().vector();
181 if normal.cross(vector / travel).magnitude() > tol.angular().max(1e-9) {
182 ogeom_bail!(
183 Construction,
184 "an oblique tapered sweep is ambiguous about its own sections; \
185 the travel must run square to the profile"
186 );
187 }
188 if normal.dot(vector) < 0.0 {
191 normal = -normal;
192 }
193 let up = Direction::new(normal, tol)?;
194 let spread = travel * taper.tan();
195
196 let mut history = History::new();
197 let mut faces: Vec<Shape> = Vec::new();
198 let mut near_wires: Vec<Shape> = Vec::new();
199 let mut far_wires: Vec<Shape> = Vec::new();
200 for wire in model.children_of(profile)? {
201 let edges = model.ordered_children_of(&wire)?;
202 let lone_circle = edges.len() == 1 && {
205 let (curve, _) = edge_geometry(model, &edges[0], tol)?;
206 matches!(curve, Curve::Circle(_))
207 };
208 if lone_circle {
209 let (curve, range) = edge_geometry(model, &edges[0], tol)?;
210 let Curve::Circle(c) = curve else {
211 unreachable!("just matched")
212 };
213 let circle = c.circle();
214 let start = c.point_at(range.0, tol)?;
218 let radial = (start - circle.centre()) / circle.radius();
219 let sigma = away_sign(model, profile, start, radial, circle.radius(), tol)?;
220 let far_radius = sigma.mul_add(spread, circle.radius());
221 if far_radius <= tol.confusion() {
222 ogeom_bail!(
223 Construction,
224 "the taper collapses a circular loop of radius {} over \
225 this height",
226 circle.radius()
227 );
228 }
229 let near_frame = Frame::new(circle.centre(), up, circle.frame().x(), tol)?;
230 let far_frame = Frame::new(circle.centre() + vector, up, circle.frame().x(), tol)?;
231 let near_curve: Curve =
232 ogeom_geom::CircleCurve::new(Circle::new(near_frame, circle.radius(), tol)?).into();
233 let near_domain = near_curve.domain();
234 let near = crate::build::make_edge(model, near_curve, near_domain, tol)?.shape;
235 let far_curve: Curve =
236 ogeom_geom::CircleCurve::new(Circle::new(far_frame, far_radius, tol)?).into();
237 let far_domain = far_curve.domain();
238 let far = crate::build::make_edge(model, far_curve, far_domain, tol)?.shape;
239 let slope = (far_radius - circle.radius()) / travel;
244 let cone = ogeom_math::Cone::new(near_frame, circle.radius(), slope.atan(), tol)?;
245 let pad = travel * 0.1;
246 let surface: ogeom_geom::SurfaceGeometry =
247 ogeom_geom::ConeSurface::new(cone, (-pad, travel + pad))?.into();
248 let band = crate::build::make_revolution_band(model, &surface, &near, &far, tol)?;
249 let wall = if sigma > 0.0 { band } else { band.reversed() };
250 model.set_derived(&wall, std::slice::from_ref(&edges[0]), roles::SWEEP_SIDE)?;
251 history.generate(&edges[0], wall.clone());
252 faces.push(wall);
253 let (near, far) = if sigma > 0.0 {
256 (near, far)
257 } else {
258 (near.reversed(), far.reversed())
259 };
260 near_wires.push(make_wire(model, std::slice::from_ref(&near), tol)?.shape);
261 far_wires.push(make_wire(model, std::slice::from_ref(&far), tol)?.shape);
262 continue;
263 }
264
265 let mut corners_near: Vec<Point> = Vec::new();
268 let mut aways: Vec<Vector> = Vec::new();
269 let mut dirs: Vec<Vector> = Vec::new();
270 for edge in &edges {
271 let (curve, range) = edge_geometry(model, edge, tol)?;
272 let Curve::Line(_) = curve else {
273 ogeom_bail!(
274 Construction,
275 "a tapered wall over an edge that is neither straight nor \
276 a full circle needs a fitted ruling; see docs/CAPABILITIES.md, \
277 offset.sweeps"
278 );
279 };
280 let reversed = edge.orientation() == Orientation::Reversed;
281 let (t0, t1) = if reversed {
282 (range.1, range.0)
283 } else {
284 (range.0, range.1)
285 };
286 let from = curve.point_at(t0, tol)?;
287 let to = curve.point_at(t1, tol)?;
288 let dir = (to - from) / from.distance(to);
289 corners_near.push(from);
290 dirs.push(dir);
291 aways.push(dir.cross(up.vector()));
292 }
293 let count = corners_near.len();
294 if count < 3 {
295 ogeom_bail!(Construction, "a straight loop needs at least three edges");
296 }
297 let flip = {
300 let mid = corners_near[0] + dirs[0] * (corners_near[0].distance(corners_near[1]) / 2.0);
301 let scale = corners_near[0].distance(corners_near[1]);
302 away_sign(model, profile, mid, aways[0], scale, tol)?
303 };
304 if flip < 0.0 {
305 for a in &mut aways {
306 *a = -*a;
307 }
308 }
309 let mut corners_far: Vec<Point> = Vec::with_capacity(count);
312 for i in 0..count {
313 let prev = (i + count - 1) % count;
314 let (d0, d1) = (dirs[prev], dirs[i]);
315 let (a0, a1) = (aways[prev], aways[i]);
316 let p0 = corners_near[i] + a0 * spread + vector;
317 let p1 = corners_near[i] + a1 * spread + vector;
318 let cross = d0.cross(d1);
319 let m = cross.magnitude();
320 let far = if m <= tol.angular() {
321 p1
323 } else {
324 let w = p1 - p0;
326 let s = w.cross(d1).dot(cross) / (m * m);
327 p0 + d0 * s
328 };
329 corners_far.push(far);
330 }
331 for (i, far) in corners_far.iter().enumerate() {
332 let next = corners_far[(i + 1) % count];
333 let d = next - *far;
334 if d.magnitude() <= tol.confusion() || d.dot(dirs[i]) <= 0.0 {
335 ogeom_bail!(
336 Construction,
337 "the taper collapses the profile's loop over this height"
338 );
339 }
340 }
341
342 let near_vertices: Vec<Shape> = corners_near
343 .iter()
344 .map(|p| crate::build::make_vertex(model, *p).shape)
345 .collect();
346 let far_vertices: Vec<Shape> = corners_far
347 .iter()
348 .map(|p| crate::build::make_vertex(model, *p).shape)
349 .collect();
350 let segment =
351 |model: &mut Model, from: (&Shape, Point), to: (&Shape, Point)| -> OgeomResult<Shape> {
352 let line = ogeom_geom::LineCurve::segment(from.1, to.1, tol)?;
353 let curve: Curve = line.into();
354 let domain = curve.domain();
355 Ok(crate::build::make_edge_between(model, curve, domain, from.0, to.0, tol)?.shape)
356 };
357 let mut near_edges = Vec::with_capacity(count);
358 let mut far_edges = Vec::with_capacity(count);
359 let mut rails = Vec::with_capacity(count);
360 for i in 0..count {
361 let next = (i + 1) % count;
362 near_edges.push(segment(
363 model,
364 (&near_vertices[i], corners_near[i]),
365 (&near_vertices[next], corners_near[next]),
366 )?);
367 far_edges.push(segment(
368 model,
369 (&far_vertices[i], corners_far[i]),
370 (&far_vertices[next], corners_far[next]),
371 )?);
372 rails.push(segment(
373 model,
374 (&near_vertices[i], corners_near[i]),
375 (&far_vertices[i], corners_far[i]),
376 )?);
377 }
378 for (i, edge) in edges.iter().enumerate() {
379 let next = (i + 1) % count;
380 let outward = {
384 let lean = aways[i] * taper.cos() - up.vector() * taper.sin();
385 Direction::new(lean, tol)?
386 };
387 let wall_plane = ogeom_math::Plane::through(corners_near[i], outward);
388 let mut reach = travel + 1.0_f64;
389 for p in [
390 corners_near[i],
391 corners_near[next],
392 corners_far[i],
393 corners_far[next],
394 ] {
395 reach = reach.max(p.distance(corners_near[i]) * 2.0);
396 }
397 let surface: ogeom_geom::SurfaceGeometry =
398 ogeom_geom::PlaneSurface::over(wall_plane, (-reach, reach), (-reach, reach))?
399 .into();
400 let ring = if flip > 0.0 {
404 vec![
405 near_edges[i].clone(),
406 rails[next].clone(),
407 far_edges[i].reversed(),
408 rails[i].reversed(),
409 ]
410 } else {
411 vec![
412 rails[i].clone(),
413 far_edges[i].clone(),
414 rails[next].reversed(),
415 near_edges[i].reversed(),
416 ]
417 };
418 let wall = crate::build::make_face_with_pcurves(model, surface, &[ring], tol)?.shape;
419 model.set_derived(&wall, std::slice::from_ref(edge), roles::SWEEP_SIDE)?;
420 history.generate(edge, wall.clone());
421 faces.push(wall);
422 }
423 let walked = |ring: &[Shape]| -> Vec<Shape> {
426 if flip > 0.0 {
427 ring.to_vec()
428 } else {
429 ring.iter().rev().map(Shape::reversed).collect()
430 }
431 };
432 near_wires.push(make_wire(model, &walked(&near_edges), tol)?.shape);
433 far_wires.push(make_wire(model, &walked(&far_edges), tol)?.shape);
434 }
435
436 let near_surface: ogeom_geom::SurfaceGeometry = {
439 let base = ogeom_math::Plane::through(plane.origin(), up);
440 ogeom_geom::PlaneSurface::over(base, (-1e6, 1e6), (-1e6, 1e6))?.into()
441 };
442 let far_surface: ogeom_geom::SurfaceGeometry = {
443 let lifted = ogeom_math::Plane::through(plane.origin() + vector, up);
444 ogeom_geom::PlaneSurface::over(lifted, (-1e6, 1e6), (-1e6, 1e6))?.into()
445 };
446 let bottom = crate::build::make_face(model, near_surface, &near_wires, tol)?
447 .shape
448 .reversed();
449 let top = crate::build::make_face(model, far_surface, &far_wires, tol)?.shape;
450 attach_cap_pcurves(model, &bottom, tol)?;
451 attach_cap_pcurves(model, &top, tol)?;
452 model.set_derived(&bottom, std::slice::from_ref(profile), roles::SWEEP_BOTTOM)?;
453 model.set_derived(&top, std::slice::from_ref(profile), roles::SWEEP_TOP)?;
454 history.generate(profile, top.clone());
455 faces.push(bottom);
456 faces.push(top);
457
458 let sewn = crate::sew(model, &faces, tol)?;
459 if sewn.shells.len() != 1 || !crate::build::is_shell_closed(model, &sewn.shells[0])? {
460 ogeom_bail!(Construction, "the tapered prism did not close");
461 }
462 let solid = make_solid(model, std::slice::from_ref(&sewn.shells[0]))?.shape;
463 history.generate(profile, solid.clone());
464 Ok(Built::new(solid, history))
465}
466
467fn away_sign(
470 model: &Model,
471 face: &Shape,
472 at: Point,
473 candidate: Vector,
474 scale: f64,
475 tol: Tolerances,
476) -> OgeomResult<f64> {
477 for factor in [1e-3, 1e-2, 5e-2] {
478 let eps = scale * factor;
479 let deflection = ogeom_mesh::Deflection {
480 chord: eps * 0.1,
481 ..ogeom_mesh::Deflection::default()
482 };
483 for sign in [1.0_f64, -1.0] {
484 let probe = at + candidate * (sign * eps);
485 if crate::classify_on_face(model, face, probe, deflection, tol)?
486 == crate::Containment::In
487 {
488 return Ok(-sign);
490 }
491 }
492 }
493 ogeom_bail!(
494 Construction,
495 "cannot read which side of the profile the material is on"
496 )
497}
498
499fn edge_geometry(
501 model: &Model,
502 edge: &Shape,
503 tol: Tolerances,
504) -> OgeomResult<(ogeom_geom::Curve, (f64, f64))> {
505 let Some(placed) = crate::build::placed_edge_curve(model, edge, tol)? else {
506 ogeom_bail!(Construction, "an edge has no curve");
507 };
508 Ok(placed)
509}
510
511fn attach_cap_pcurves(model: &mut Model, cap: &Shape, tol: Tolerances) -> OgeomResult<()> {
513 let cap_id = {
514 let Some(node) = model.node(cap) else {
515 ogeom_bail!(Dangling, "the cap just built is not in this model");
516 };
517 let NodeData::Face(data) = node.data() else {
518 ogeom_bail!(Construction, "the cap holds no face data");
519 };
520 data.surface
521 };
522 let Some(surface) = model.geometry().surface(cap_id).cloned() else {
523 ogeom_bail!(Dangling, "the cap's surface is not in this model");
524 };
525 for edge in ogeom_topo::explore(model, cap, ogeom_topo::Filter::OfType(ShapeType::Edge))? {
526 let held = model
527 .node(&edge)
528 .and_then(|n| n.data().as_edge())
529 .is_some_and(|d| d.pcurve_for(cap_id, edge.location()).is_some());
530 if held {
531 continue;
532 }
533 let (curve, range) = crate::build::charted_edge_curve(model, &edge, tol)?;
534 let Some(pcurve) = ogeom_intersect::exact_pcurve_of(&curve, &surface, tol) else {
535 ogeom_bail!(Construction, "a cap edge has no closed-form pcurve");
536 };
537 let at = edge.location().clone();
538 crate::build::attach_pcurve(model, &edge, pcurve, cap_id, at, range)?;
539 }
540 Ok(())
541}
542
543fn prism_over_face(
545 model: &mut Model,
546 rails: &mut Rails,
547 face: &Shape,
548 displacement: &Location,
549 vector: Vector,
550 tol: Tolerances,
551) -> OgeomResult<Built> {
552 let (_, normal) = crate::measure::face_normal(model, face, tol)?;
562 let travel = vector.magnitude();
563 let along = normal.dot(vector) / travel;
564 if along.abs() <= tol.angular() {
565 ogeom_bail!(
566 Construction,
567 "the sweep runs along the profile's own surface, so it encloses no \
568 volume; a face swept within its own plane is not a solid"
569 );
570 }
571 let profile = if along < 0.0 {
572 face.reversed()
573 } else {
574 face.clone()
575 };
576
577 let mut history = History::new();
578 let mut faces = Vec::new();
579
580 let wires = model.children_of(&profile)?;
588 let turns: Vec<f64> = wires
589 .iter()
590 .map(|w| wire_turn(model, w, vector, tol))
591 .collect::<OgeomResult<_>>()?;
592 let outer = turns
593 .iter()
594 .enumerate()
595 .max_by(|a, b| {
596 a.1.abs()
597 .partial_cmp(&b.1.abs())
598 .unwrap_or(core::cmp::Ordering::Equal)
599 })
600 .map_or(0, |(i, _)| i);
601 let against: Vec<bool> = turns
605 .iter()
606 .enumerate()
607 .map(|(i, turn)| turn * if i == outer { 1.0 } else { -1.0 } < 0.0)
608 .collect();
609 let profile = if against.contains(&true) {
610 rewound(model, &profile, &against, tol)?
611 } else {
612 profile
613 };
614 let wires = model.children_of(&profile)?;
615 for wire in &wires {
616 let (sides, wire_history) = prism_over_wire(model, rails, wire, displacement, vector, tol)?;
617 history = history.then(&wire_history);
618 faces.extend(sides);
619 }
620
621 let bottom = profile.reversed();
626 let top = profile.moved(displacement);
627 model.set_derived(&bottom, std::slice::from_ref(face), roles::SWEEP_BOTTOM)?;
628 model.set_derived(&top, std::slice::from_ref(face), roles::SWEEP_TOP)?;
629 history.generate(face, top.clone());
630 faces.push(bottom);
631 faces.push(top);
632
633 let shell = make_shell(model, &faces)?.shape;
634 let solid = make_solid(model, std::slice::from_ref(&shell))?.shape;
635 history.generate(face, solid.clone());
636 Ok(Built::new(solid, history))
637}
638
639fn rewound(
642 model: &mut Model,
643 face: &Shape,
644 turned: &[bool],
645 tol: Tolerances,
646) -> OgeomResult<Shape> {
647 let Some(node) = model.node(face) else {
648 ogeom_bail!(Dangling, "the profile is not in this model");
649 };
650 let Some(data) = node.data().as_face() else {
651 ogeom_bail!(Construction, "the profile holds no face data");
652 };
653 let mut rebuilt = ogeom_topo::FaceData::new(data.surface, data.location.clone());
654 rebuilt.tolerance = data.tolerance;
655 let stored = node.children().to_vec();
656 let mut wires = Vec::with_capacity(stored.len());
657 for (wire, &turn) in stored.iter().zip(turned) {
658 wires.push(if turn {
659 walked_back(model, wire, tol)?
660 } else {
661 wire.clone()
662 });
663 }
664 let built = model.add_face(rebuilt, &wires)?;
665 Ok(built.moved(face.location()).oriented(face.orientation()))
666}
667
668fn prism_over_wire(
670 model: &mut Model,
671 rails: &mut Rails,
672 wire: &Shape,
673 displacement: &Location,
674 vector: Vector,
675 tol: Tolerances,
676) -> OgeomResult<(Vec<Shape>, History)> {
677 let mut faces = Vec::new();
678 let mut history = History::new();
679 for edge in model.ordered_children_of(wire)? {
680 let (face, edge_history) = prism_over_edge(model, rails, &edge, displacement, vector, tol)?;
681 history = history.then(&edge_history);
682 faces.push(face);
683 }
684 if faces.is_empty() {
685 ogeom_bail!(Construction, "a wire with no edges sweeps out nothing");
686 }
687 Ok((faces, history))
688}
689
690fn prism_over_edge(
697 model: &mut Model,
698 rails: &mut Rails,
699 edge: &Shape,
700 displacement: &Location,
701 vector: Vector,
702 tol: Tolerances,
703) -> OgeomResult<(Shape, History)> {
704 let Some(node) = model.node(edge) else {
705 ogeom_bail!(Dangling, "edge is not in this model");
706 };
707 let NodeData::Edge(data) = node.data() else {
708 ogeom_bail!(Construction, "edge node holds no edge data");
709 };
710 let Some(EdgeRepr::Curve3d { curve, range, .. }) = data.curve3d() else {
711 ogeom_bail!(
712 Construction,
713 "an edge with no curve in space has no shape to sweep; a degenerate \
714 edge sweeps out nothing and has to be handled by its face, not here"
715 );
716 };
717 let Some(geometry) = model.geometry().curve(*curve).cloned() else {
718 ogeom_bail!(Dangling, "curve is not in this model");
719 };
720
721 let placement = edge.transform(model.datums())?;
734 let stored = geometry.domain();
735 let geometry = geometry.transformed(&placement, tol)?;
736 let placed = geometry.domain();
737 let (lo, hi) = (
738 ogeom_geom::carried(range.0, stored, placed),
739 ogeom_geom::carried(range.1, stored, placed),
740 );
741 let travel = vector.magnitude();
742 let direction = ogeom_math::Direction::new(vector, tol)?;
743 let mut chart: ChartMap = Box::new(|p| p);
757 let mut turned = false;
760 let canonical: Option<ogeom_geom::SurfaceGeometry> = if let ogeom_geom::Curve::Line(line) =
761 &geometry
762 && let Ok(normal) =
763 ogeom_math::Direction::new(line.axis().direction.vector().cross(vector), tol)
764 {
765 let axis = line.axis();
766 let frame = ogeom_math::Frame::new(axis.location, normal, axis.direction, tol)?;
767 let plane = ogeom_math::Plane::new(frame);
768 let along = axis.direction.vector();
772 let across = frame.y().vector();
773 let (shear, rise) = (
774 direction.vector().dot(along),
775 direction.vector().dot(across),
776 );
777 if shear.abs() > tol.angular() {
778 chart = Box::new(move |(u, v): (f64, f64)| (u + v * shear, v * rise));
779 }
780 let margin = (hi - lo).abs().max(travel) * 0.1 + 1.0;
781 let (u_lo, u_hi) = (lo.min(hi), lo.max(hi));
782 let u_min = u_lo + travel * shear.min(0.0);
783 let u_max = u_hi + travel * shear.max(0.0);
784 Some(
785 ogeom_geom::PlaneSurface::over(
786 plane,
787 (u_min - margin, u_max + margin),
788 (-margin, travel * rise + margin),
789 )?
790 .into(),
791 )
792 } else if let ogeom_geom::Curve::Circle(c) = &geometry
793 && !c.is_reversed()
794 && c.circle()
795 .frame()
796 .z()
797 .vector()
798 .dot(direction.vector())
799 .abs()
800 >= 1.0 - tol.angular()
801 {
802 let circle = c.circle();
814 let right = circle.frame().handedness() == ogeom_math::Handedness::Right;
815 let spin = circle.frame().z().vector() * circle.frame().handedness().sign();
816 let frame = if right && spin.dot(direction.vector()) > 0.0 {
817 circle.frame()
818 } else if spin.dot(direction.vector()) > 0.0 {
819 ogeom_math::Frame::new(circle.centre(), direction, circle.frame().x(), tol)?
820 } else {
821 chart = Box::new(|(u, v): (f64, f64)| (core::f64::consts::TAU - u, v));
822 turned = true;
825 ogeom_math::Frame::new(circle.centre(), direction, circle.frame().x(), tol)?
826 };
827 let margin = travel * 0.1 + 1.0;
828 Some(
829 ogeom_geom::CylinderSurface::new(
830 ogeom_math::Cylinder::new(frame, circle.radius(), tol)?,
831 (-margin, travel + margin),
832 )?
833 .into(),
834 )
835 } else {
836 None
837 };
838 let surface = model.geometry_mut().add_surface(match canonical {
839 Some(exact) => exact,
840 None => ExtrusionSurface::new(geometry, direction, travel)?.into(),
841 });
842
843 let reversed = edge.orientation() == Orientation::Reversed;
848
849 let bottom = edge.oriented(Orientation::Forward);
853 let top = bottom.moved(displacement);
854 let walk_start = rail(model, rails, edge, displacement, vector, false, tol)?;
855 let walk_end = rail(model, rails, edge, displacement, vector, true, tol)?;
856 let (low_rail, high_rail) = if reversed {
857 (walk_end, walk_start)
858 } else {
859 (walk_start, walk_end)
860 };
861
862 pcurve(
863 model,
864 &bottom,
865 surface,
866 chart((lo, 0.0)),
867 chart((hi, 0.0)),
868 tol,
869 )?;
870 pcurve(
871 model,
872 &top,
873 surface,
874 chart((lo, travel)),
875 chart((hi, travel)),
876 tol,
877 )?;
878 let (up, down) = if turned { (lo, hi) } else { (hi, lo) };
883 if low_rail.is_same(&high_rail) {
884 seam_pcurves(
891 model,
892 &low_rail,
893 surface,
894 (chart((up, 0.0)), chart((up, travel))),
895 (chart((down, 0.0)), chart((down, travel))),
896 tol,
897 )?;
898 } else {
899 pcurve(
900 model,
901 &low_rail,
902 surface,
903 chart((lo, 0.0)),
904 chart((lo, travel)),
905 tol,
906 )?;
907 pcurve(
908 model,
909 &high_rail,
910 surface,
911 chart((hi, 0.0)),
912 chart((hi, travel)),
913 tol,
914 )?;
915 }
916
917 let ring = if turned {
918 [
919 low_rail.clone(),
920 top.clone(),
921 high_rail.reversed(),
922 bottom.reversed(),
923 ]
924 } else {
925 [
926 bottom.clone(),
927 high_rail.clone(),
928 top.reversed(),
929 low_rail.reversed(),
930 ]
931 };
932 let boundary = make_wire(model, &ring, tol)?.shape;
933 let built = make_face_on(model, surface, std::slice::from_ref(&boundary), tol)?.shape;
934
935 let face = if reversed != turned {
943 built.reversed()
944 } else {
945 built
946 };
947 model.set_derived(&face, std::slice::from_ref(edge), roles::SWEEP_SIDE)?;
948
949 let mut history = History::new();
950 history.generate(edge, face.clone());
954 history.generate(edge, edge.moved(displacement));
955 Ok((face, history))
956}
957
958struct Turn {
960 axis: Axis,
962 angle: f64,
964 full: bool,
967 displacement: Location,
970}
971
972pub fn make_revolution(
993 model: &mut Model,
994 profile: &Shape,
995 axis: Axis,
996 angle: f64,
997 tol: Tolerances,
998) -> OgeomResult<Built> {
999 if !angle.is_finite() || angle <= tol.angular() || angle > TAU + tol.angular() {
1000 ogeom_bail!(
1001 Construction,
1002 "a revolution turns through (0, 2pi]; {angle} does not"
1003 );
1004 }
1005 let angle = angle.min(TAU);
1006 let full = TAU - angle <= tol.angular();
1007 model.begin_operation();
1008
1009 let turn = Turn {
1010 axis,
1011 angle,
1012 full,
1013 displacement: if full {
1017 Location::identity()
1018 } else {
1019 Location::of(model.add_datum(Transform::rotation(axis, angle)))
1020 },
1021 };
1022
1023 let rails = &mut Rails::default();
1024 match model.kind_of(profile)? {
1025 ShapeType::Face => revolution_over_face(model, rails, profile, &turn, tol),
1026 ShapeType::Wire => {
1027 let (faces, history) = revolution_over_wire(model, rails, profile, &turn, tol)?;
1028 let shell = make_shell(model, &faces)?.shape;
1029 Ok(Built::new(shell, history))
1030 }
1031 ShapeType::Edge => {
1032 let (face, history) = revolution_over_edge(model, rails, profile, &turn, tol)?;
1033 let Some(face) = face else {
1034 ogeom_bail!(
1035 Construction,
1036 "an edge lying along the axis turns onto itself and sweeps \
1037 out no face"
1038 );
1039 };
1040 Ok(Built::new(face, history))
1041 }
1042 other => ogeom_bail!(
1043 Construction,
1044 "a {other:?} cannot be revolved into anything; revolve an edge, a \
1045 wire or a face"
1046 ),
1047 }
1048}
1049
1050fn revolution_over_face(
1052 model: &mut Model,
1053 rails: &mut Rails,
1054 face: &Shape,
1055 turn: &Turn,
1056 tol: Tolerances,
1057) -> OgeomResult<Built> {
1058 if turn.full {
1061 crate::build::trimmed_where_bare(model, face, tol)?;
1062 } else {
1063 crate::build::trimmed_or_fitted_where_bare(model, face, tol)?;
1064 }
1065 let (point, normal) = crate::measure::face_normal(model, face, tol)?;
1069 let tangent = turn
1070 .axis
1071 .direction
1072 .cross_with(point - turn.axis.project(point));
1073 let reach = tangent.magnitude();
1074 if reach <= tol.confusion() {
1075 ogeom_bail!(
1076 Construction,
1077 "the profile sits on the axis, so revolving it sweeps out nothing"
1078 );
1079 }
1080 let along = normal.dot(tangent) / reach;
1081 if along.abs() <= tol.angular() {
1082 ogeom_bail!(
1083 Construction,
1084 "the turn runs along the profile's own surface, so it encloses no \
1085 volume; a face revolved within its own plane is not a solid"
1086 );
1087 }
1088 let profile = if along < 0.0 {
1091 face.reversed()
1092 } else {
1093 face.clone()
1094 };
1095 let wires = model.children_of(&profile)?;
1105 if wires.is_empty() {
1106 ogeom_bail!(Construction, "the profile has no boundary to revolve");
1107 }
1108 let turns: Vec<f64> = wires
1109 .iter()
1110 .map(|w| wire_turn(model, w, tangent, tol))
1111 .collect::<OgeomResult<_>>()?;
1112 let outer = turns
1113 .iter()
1114 .enumerate()
1115 .max_by(|a, b| {
1116 a.1.abs()
1117 .partial_cmp(&b.1.abs())
1118 .unwrap_or(core::cmp::Ordering::Equal)
1119 })
1120 .map_or(0, |(i, _)| i);
1121 let against: Vec<bool> = turns
1122 .iter()
1123 .enumerate()
1124 .map(|(i, turn)| turn * if i == outer { 1.0 } else { -1.0 } < 0.0)
1125 .collect();
1126 let profile = if against.contains(&true) {
1127 rewound(model, &profile, &against, tol)?
1128 } else {
1129 profile
1130 };
1131
1132 let mut history = History::new();
1133 let mut faces = Vec::new();
1134 for wire in &model.children_of(&profile)? {
1135 let (sides, wire_history) = revolution_over_wire(model, rails, wire, turn, tol)?;
1136 history = history.then(&wire_history);
1137 faces.extend(sides);
1138 }
1139
1140 if turn.full {
1141 history.delete(face);
1147 } else {
1148 let bottom = profile.reversed();
1149 let top = profile.moved(&turn.displacement);
1150 model.set_derived(&bottom, std::slice::from_ref(face), roles::SWEEP_BOTTOM)?;
1151 model.set_derived(&top, std::slice::from_ref(face), roles::SWEEP_TOP)?;
1152 history.generate(face, top.clone());
1153 faces.push(bottom);
1154 faces.push(top);
1155 }
1156
1157 let shell = make_shell(model, &faces)?.shape;
1158 let solid = make_solid(model, std::slice::from_ref(&shell))?.shape;
1159 history.generate(face, solid.clone());
1160 Ok(Built::new(solid, history))
1161}
1162
1163fn revolution_over_wire(
1165 model: &mut Model,
1166 rails: &mut Rails,
1167 wire: &Shape,
1168 turn: &Turn,
1169 tol: Tolerances,
1170) -> OgeomResult<(Vec<Shape>, History)> {
1171 let mut faces = Vec::new();
1172 let mut history = History::new();
1173 for edge in model.ordered_children_of(wire)? {
1174 let (face, edge_history) = revolution_over_edge(model, rails, &edge, turn, tol)?;
1175 history = history.then(&edge_history);
1176 faces.extend(face);
1181 }
1182 if faces.is_empty() {
1183 ogeom_bail!(
1184 Construction,
1185 "the whole wire lies along the axis, so it revolves out nothing"
1186 );
1187 }
1188 Ok((faces, history))
1189}
1190
1191fn revolution_over_edge(
1198 model: &mut Model,
1199 rails: &mut Rails,
1200 edge: &Shape,
1201 turn: &Turn,
1202 tol: Tolerances,
1203) -> OgeomResult<(Option<Shape>, History)> {
1204 let Some(node) = model.node(edge) else {
1205 ogeom_bail!(Dangling, "edge is not in this model");
1206 };
1207 let NodeData::Edge(data) = node.data() else {
1208 ogeom_bail!(Construction, "edge node holds no edge data");
1209 };
1210 let Some(EdgeRepr::Curve3d { curve, range, .. }) = data.curve3d() else {
1211 ogeom_bail!(
1212 Construction,
1213 "an edge with no curve in space has no shape to revolve; a \
1214 degenerate edge sweeps out nothing and has to be handled by its \
1215 face, not here"
1216 );
1217 };
1218 let Some(geometry) = model.geometry().curve(*curve).cloned() else {
1219 ogeom_bail!(Dangling, "curve is not in this model");
1220 };
1221
1222 let placement = edge.transform(model.datums())?;
1225 let stored = geometry.domain();
1226 let geometry = geometry.transformed(&placement, tol)?;
1227 let placed = geometry.domain();
1228 let (lo, hi) = (
1229 ogeom_geom::carried(range.0, stored, placed),
1230 ogeom_geom::carried(range.1, stored, placed),
1231 );
1232 if axis_relation(&geometry, (lo, hi), turn.axis, tol)? == AxisRelation::On {
1233 return Ok((None, History::new()));
1234 }
1235
1236 if let ogeom_geom::Curve::Line(line) = &geometry
1242 && line
1243 .axis()
1244 .direction
1245 .vector()
1246 .dot(turn.axis.direction.vector())
1247 .abs()
1248 <= tol.angular()
1249 {
1250 return flat_revolution(model, rails, edge, &geometry, (lo, hi), turn, tol);
1251 }
1252
1253 let canonical = canonical_revolution(&geometry, (lo, hi), turn, tol)?;
1254
1255 let opposed = canonical
1260 .as_ref()
1261 .is_some_and(|(_, (chart_lo, chart_hi))| chart_hi < chart_lo);
1262 let (lo, hi) = canonical.as_ref().map_or((lo, hi), |&(_, chart)| chart);
1263 let surface = match canonical {
1264 Some((exact, _)) => model.geometry_mut().add_surface(exact),
1265 None => model.geometry_mut().add_surface(
1266 ogeom_geom::RevolutionSurface::new(geometry, turn.axis, turn.angle)?.into(),
1267 ),
1268 };
1269
1270 let reversed = edge.orientation() == Orientation::Reversed;
1271 let (v_start, v_end) = if reversed { (hi, lo) } else { (lo, hi) };
1272 let (near, far) = (0.0, turn.angle);
1273 let descending = reversed != opposed;
1276
1277 let start_rail = revolved_rail(model, rails, edge, turn, false, tol)?;
1281 let end_rail = revolved_rail(model, rails, edge, turn, true, tol)?;
1282
1283 if start_rail.is_same(&end_rail) {
1284 let (forward, backward) = if descending {
1290 (v_end, v_start)
1291 } else {
1292 (v_start, v_end)
1293 };
1294 seam_pcurves(
1295 model,
1296 &start_rail,
1297 surface,
1298 ((near, forward), (far, forward)),
1299 ((near, backward), (far, backward)),
1300 tol,
1301 )?;
1302 } else {
1303 pcurve(
1304 model,
1305 &start_rail,
1306 surface,
1307 (near, v_start),
1308 (far, v_start),
1309 tol,
1310 )?;
1311 pcurve(model, &end_rail, surface, (near, v_end), (far, v_end), tol)?;
1312 }
1313
1314 let displaced = edge.moved(&turn.displacement);
1318 if turn.full {
1319 let (forward, reversed_side) = if reversed != descending {
1325 (near, far)
1326 } else {
1327 (far, near)
1328 };
1329 seam_pcurves(
1330 model,
1331 edge,
1332 surface,
1333 ((forward, lo), (forward, hi)),
1334 ((reversed_side, lo), (reversed_side, hi)),
1335 tol,
1336 )?;
1337 } else {
1338 pcurve(model, edge, surface, (near, lo), (near, hi), tol)?;
1339 pcurve(model, &displaced, surface, (far, lo), (far, hi), tol)?;
1340 }
1341
1342 let ring = if descending {
1346 [
1347 edge.clone(),
1348 end_rail.clone(),
1349 displaced.reversed(),
1350 start_rail.reversed(),
1351 ]
1352 } else {
1353 [
1354 start_rail.clone(),
1355 displaced.clone(),
1356 end_rail.reversed(),
1357 edge.reversed(),
1358 ]
1359 };
1360 let boundary = make_wire(model, &ring, tol)?.shape;
1361 let built = make_face_on(model, surface, std::slice::from_ref(&boundary), tol)?.shape;
1362
1363 let face = if reversed != opposed {
1374 built
1375 } else {
1376 built.reversed()
1377 };
1378 model.set_derived(&face, std::slice::from_ref(edge), roles::SWEEP_SIDE)?;
1379
1380 let mut history = History::new();
1381 history.generate(edge, face.clone());
1386 if !turn.full {
1387 history.generate(edge, displaced);
1388 }
1389 Ok((Some(face), history))
1390}
1391
1392fn canonical_revolution(
1411 geometry: &ogeom_geom::Curve,
1412 (lo, hi): (f64, f64),
1413 turn: &Turn,
1414 tol: Tolerances,
1415) -> OgeomResult<Option<(ogeom_geom::SurfaceGeometry, (f64, f64))>> {
1416 let axis = turn.axis;
1417 let along = axis.direction.vector();
1418 let radius_of = |p: Point| p - axis.project(p);
1419 match geometry {
1420 ogeom_geom::Curve::Line(_) => {
1424 let (p_lo, p_hi) = (geometry.point_at(lo, tol)?, geometry.point_at(hi, tol)?);
1425 let (h_lo, h_hi) = (
1426 (p_lo - axis.location).dot(along),
1427 (p_hi - axis.location).dot(along),
1428 );
1429 let rise = h_hi - h_lo;
1430 if rise.abs() <= tol.confusion() {
1431 return Ok(None);
1432 }
1433 let (r_lo, r_hi) = (radius_of(p_lo).magnitude(), radius_of(p_hi).magnitude());
1434 let stem = if r_lo > r_hi { p_lo } else { p_hi };
1437 let Ok(radial) = Direction::new(radius_of(stem), tol) else {
1438 return Ok(None);
1439 };
1440 let slope = (r_hi - r_lo) / rise;
1441 if slope.abs() <= tol.angular() {
1442 let frame = Frame::new(axis.location, axis.direction, radial, tol)?;
1443 let margin = rise.abs() * 0.1 + 1.0;
1444 let surface = ogeom_geom::CylinderSurface::new(
1445 ogeom_math::Cylinder::new(frame, r_lo, tol)?,
1446 (h_lo.min(h_hi) - margin, h_lo.max(h_hi) + margin),
1447 )?;
1448 return Ok(Some((surface.into(), (h_lo, h_hi))));
1449 }
1450 let base = axis.project(p_lo);
1454 let frame = Frame::new(base, axis.direction, radial, tol)?;
1455 let cone = ogeom_math::Cone::new(frame, r_lo, slope.atan(), tol)?;
1456 let (v_lo, v_hi) = (0.0_f64, rise);
1457 let margin = rise.abs() * 0.1 + 1.0;
1458 let apex = -r_lo / slope;
1462 let (mut low, mut high) = (v_lo.min(v_hi) - margin, v_lo.max(v_hi) + margin);
1463 if slope > 0.0 {
1464 low = low.max(apex);
1465 } else {
1466 high = high.min(apex);
1467 }
1468 let surface = ogeom_geom::ConeSurface::new(cone, (low, high))?;
1469 Ok(Some((surface.into(), (v_lo, v_hi))))
1470 }
1471 ogeom_geom::Curve::Circle(c) => {
1475 let circle = c.circle();
1476 let (centre, normal) = (circle.frame().origin(), circle.frame().z().vector());
1477 let offset = radius_of(centre);
1478 let (major, minor) = (offset.magnitude(), circle.radius());
1479 if normal.dot(along).abs() > tol.angular()
1484 || normal.dot(offset).abs() > tol.confusion()
1485 || major <= minor + tol.confusion()
1486 {
1487 return Ok(None);
1488 }
1489 let Ok(radial) = Direction::new(offset, tol) else {
1490 return Ok(None);
1491 };
1492 let frame = Frame::new(axis.project(centre), axis.direction, radial, tol)?;
1493 let torus = ogeom_math::Torus::new(frame, major, minor, tol)?;
1494
1495 let (at, tangent) = (geometry.point_at(lo, tol)?, geometry.d1_at(lo, tol)?);
1499 let spoke = at - centre;
1500 let (a, b) = (spoke.dot(radial.vector()), spoke.dot(along));
1501 let (da, db) = (tangent.dot(radial.vector()), tangent.dot(along));
1502 let v_lo = b.atan2(a);
1503 let sense = a.mul_add(db, -(b * da));
1504 if sense.abs() <= tol.confusion() {
1505 return Ok(None);
1506 }
1507 let v_hi = (hi - lo).copysign(sense) + v_lo;
1508 Ok(Some((
1509 ogeom_geom::TorusSurface::new(torus).into(),
1510 (v_lo, v_hi),
1511 )))
1512 }
1513 _ => Ok(None),
1514 }
1515}
1516
1517fn flat_revolution(
1520 model: &mut Model,
1521 rails: &mut Rails,
1522 edge: &Shape,
1523 geometry: &ogeom_geom::Curve,
1524 range: (f64, f64),
1525 turn: &Turn,
1526 tol: Tolerances,
1527) -> OgeomResult<(Option<Shape>, History)> {
1528 use ogeom_geom::Curve3d as _;
1529 let (lo, hi) = range;
1530 let axis_dir = turn.axis.direction;
1531 let at = geometry.point_at(lo, tol)?;
1532 let height = (at - turn.axis.location).dot(axis_dir.vector());
1533 let foot = turn.axis.location + axis_dir.vector() * height;
1534 let radius_of = |p: ogeom_math::Point| (p - foot).magnitude();
1535 let far = geometry.point_at(hi, tol)?;
1536 let reach = radius_of(at).max(radius_of(far)) * 2.0 + 1.0;
1537 let plane = ogeom_math::Plane::new(ogeom_math::Frame::about(foot, axis_dir));
1538 let plane_surface: ogeom_geom::SurfaceGeometry =
1539 ogeom_geom::PlaneSurface::over(plane, (-reach, reach), (-reach, reach))?.into();
1540 let surface = model.geometry_mut().add_surface(plane_surface.clone());
1541
1542 let start_rail = revolved_rail(model, rails, edge, turn, false, tol)?;
1543 let end_rail = revolved_rail(model, rails, edge, turn, true, tol)?;
1544 let is_degenerate = |model: &Model, e: &Shape| -> bool {
1545 model
1546 .node(e)
1547 .and_then(|n| n.data().as_edge())
1548 .is_some_and(|d| d.curve3d().is_none())
1549 };
1550
1551 let attach = |model: &mut Model, occurrence: &Shape| -> OgeomResult<()> {
1554 let Some(data) = model.node(occurrence).and_then(|n| n.data().as_edge()) else {
1555 ogeom_bail!(Construction, "a flat revolution edge holds no data");
1556 };
1557 let Some(EdgeRepr::Curve3d { curve, range, .. }) = data.curve3d() else {
1558 ogeom_bail!(Construction, "a flat revolution edge has no curve");
1559 };
1560 let (curve, range) = (*curve, *range);
1561 let Some(stored) = model.geometry().curve(curve) else {
1562 ogeom_bail!(Dangling, "curve is not in this model");
1563 };
1564 let placed = stored
1565 .clone()
1566 .transformed(&occurrence.transform(model.datums())?, tol)?;
1567 let Some(pc) = ogeom_intersect::exact_pcurve_of(&placed, &plane_surface, tol) else {
1568 ogeom_bail!(
1569 Construction,
1570 "a flat revolution edge has no closed-form pcurve on its plane"
1571 );
1572 };
1573 crate::build::attach_pcurve(
1574 model,
1575 occurrence,
1576 pc,
1577 surface,
1578 occurrence.location().clone(),
1579 range,
1580 )
1581 };
1582
1583 let reversed = edge.orientation() == Orientation::Reversed;
1584 let wound = |model: &mut Model, ring: Vec<Shape>, outside: bool| -> OgeomResult<Shape> {
1587 let turning = edges_turn(model, &ring, axis_dir.vector(), tol)?;
1588 let ring = if (turning > 0.0) == outside {
1589 ring
1590 } else {
1591 ring.iter().rev().map(Shape::reversed).collect()
1592 };
1593 Ok(make_wire(model, &ring, tol)?.shape)
1594 };
1595 let built = if turn.full {
1596 let mut circles = Vec::new();
1597 for rail in [&start_rail, &end_rail] {
1598 if is_degenerate(model, rail) {
1599 continue;
1600 }
1601 attach(model, rail)?;
1602 let turning = edges_turn(model, std::slice::from_ref(rail), axis_dir.vector(), tol)?;
1603 circles.push((turning.abs(), rail.clone()));
1604 }
1605 if circles.is_empty() {
1606 ogeom_bail!(Construction, "a flat revolution swept out no boundary");
1607 }
1608 circles.sort_by(|a, b| b.0.total_cmp(&a.0));
1609 let mut wires = Vec::with_capacity(circles.len());
1610 for (i, (_, rail)) in circles.into_iter().enumerate() {
1611 wires.push(wound(model, vec![rail], i == 0)?);
1612 }
1613 make_face_on(model, surface, &wires, tol)?.shape
1614 } else {
1615 let displaced = edge.moved(&turn.displacement);
1616 let mut ring: Vec<Shape> = Vec::new();
1617 if !is_degenerate(model, &start_rail) {
1618 attach(model, &start_rail)?;
1619 ring.push(start_rail.clone());
1620 }
1621 attach(model, &displaced)?;
1622 ring.push(displaced.clone());
1623 if !is_degenerate(model, &end_rail) {
1624 attach(model, &end_rail)?;
1625 ring.push(end_rail.reversed());
1626 }
1627 attach(model, edge)?;
1628 ring.push(edge.reversed());
1629 let boundary = wound(model, ring, true)?;
1630 make_face_on(model, surface, std::slice::from_ref(&boundary), tol)?.shape
1631 };
1632
1633 let outward_sense = {
1637 let radial = if radius_of(far) >= radius_of(at) {
1638 far - foot
1639 } else {
1640 at - foot
1641 };
1642 let d = (far - at).dot(radial);
1643 d < 0.0
1644 };
1645 let face = if reversed != outward_sense {
1646 built.reversed()
1647 } else {
1648 built
1649 };
1650 model.set_derived(&face, std::slice::from_ref(edge), roles::SWEEP_SIDE)?;
1651
1652 let mut history = History::new();
1653 history.generate(edge, face.clone());
1654 if !turn.full {
1655 history.generate(edge, edge.moved(&turn.displacement));
1656 }
1657 Ok((Some(face), history))
1658}
1659
1660fn revolved_rail(
1670 model: &mut Model,
1671 rails: &mut Rails,
1672 edge: &Shape,
1673 turn: &Turn,
1674 at_end: bool,
1675 tol: Tolerances,
1676) -> OgeomResult<Shape> {
1677 let Some((start, end)) = crate::build::edge_vertices(model, edge)? else {
1678 ogeom_bail!(
1679 Construction,
1680 "an unbounded edge has no endpoints to sweep into rails"
1681 );
1682 };
1683 let base = if at_end { end } else { start };
1684 if let Some(existing) = rails.get(&base.node()) {
1685 return Ok(existing.clone());
1686 }
1687
1688 let Some(node) = model.node(&base) else {
1689 ogeom_bail!(Dangling, "vertex is not in this model");
1690 };
1691 let Some(data) = node.data().as_vertex() else {
1692 ogeom_bail!(Construction, "vertex node holds no point");
1693 };
1694 let from = base.transform(model.datums())?.apply(data.point);
1695
1696 let raised = if turn.full {
1700 base.clone()
1701 } else {
1702 base.moved(&turn.displacement)
1703 };
1704
1705 let radius = from - turn.axis.project(from);
1706 let built = if radius.magnitude() <= tol.confusion() {
1707 let mut data = ogeom_topo::EdgeData::new();
1708 data.degenerate = true;
1709 model.add_edge(data, &[base.clone(), raised])?
1710 } else {
1711 let frame = Frame::new(
1715 turn.axis.project(from),
1716 turn.axis.direction,
1717 Direction::new(radius, tol)?,
1718 tol,
1719 )?;
1720 let circle = Circle::new(frame, radius.magnitude(), tol)?;
1721 crate::build::make_edge_between(
1722 model,
1723 ogeom_geom::CircleCurve::new(circle).into(),
1724 (0.0, turn.angle),
1725 &base,
1726 &raised,
1727 tol,
1728 )?
1729 .shape
1730 };
1731
1732 model.set_derived(&built, std::slice::from_ref(&base), roles::SWEEP_RAIL)?;
1733 rails.insert(base.node(), built.clone());
1734 Ok(built)
1735}
1736
1737const AXIS_SAMPLES: usize = 32;
1739
1740#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1742enum AxisRelation {
1743 Clear,
1745 On,
1747}
1748
1749fn axis_relation(
1767 curve: &ogeom_geom::Curve,
1768 range: (f64, f64),
1769 axis: Axis,
1770 tol: Tolerances,
1771) -> OgeomResult<AxisRelation> {
1772 let mut points = Vec::with_capacity(AXIS_SAMPLES + 1);
1775 for i in 0..=AXIS_SAMPLES {
1776 #[allow(clippy::cast_precision_loss)]
1777 let t = i as f64 / AXIS_SAMPLES as f64;
1778 let at = range.0 + (range.1 - range.0) * t;
1779 points.push(curve.point_at(at, tol)?);
1780 }
1781 let on_axis = |p: &ogeom_math::Point| (*p - axis.project(*p)).magnitude() <= tol.confusion();
1782 if points.iter().all(on_axis) {
1783 return Ok(AxisRelation::On);
1784 }
1785
1786 let along = |p: &ogeom_math::Point| (*p - axis.location).dot(axis.direction.vector());
1789 let mut lo = f64::INFINITY;
1790 let mut hi = f64::NEG_INFINITY;
1791 for p in &points {
1792 lo = lo.min(along(p));
1793 hi = hi.max(along(p));
1794 }
1795 let margin = (hi - lo).max(1.0);
1796 let line: ogeom_geom::Curve =
1797 ogeom_geom::LineCurve::over(axis, lo - margin, hi + margin)?.into();
1798 let trimmed: ogeom_geom::Curve = if (range.0, range.1) == curve.domain() {
1799 curve.clone()
1800 } else {
1801 ogeom_geom::TrimmedCurve::new(curve.clone(), range.0, range.1, tol)?.into()
1802 };
1803
1804 let (start, end) = (points[0], points[AXIS_SAMPLES]);
1807 let interior = |p: ogeom_math::Point| {
1808 p.distance(start) > tol.confusion() && p.distance(end) > tol.confusion()
1809 };
1810
1811 let hits = ogeom_intersect::intersect_curves(
1812 &trimmed,
1813 &line,
1814 ogeom_intersect::CurveCurveOptions::default(),
1815 tol,
1816 )?;
1817 if !hits.overlaps.is_empty() {
1818 ogeom_bail!(
1822 Construction,
1823 "the profile touches the axis away from its ends; revolving it \
1824 would sweep a surface through itself. Split the profile where it \
1825 meets the axis"
1826 );
1827 }
1828 if hits.crossings.iter().any(|c| interior(c.point)) {
1829 ogeom_bail!(
1830 Construction,
1831 "the profile passes through the axis away from its ends; \
1832 revolving it would sweep a surface through itself. Split the \
1833 profile where it meets the axis"
1834 );
1835 }
1836
1837 let near = ogeom_intersect::extrema_curve_curve(
1840 &trimmed,
1841 &line,
1842 ogeom_intersect::ExtremaOptions::default(),
1843 tol,
1844 )?;
1845 if near
1846 .approaches
1847 .iter()
1848 .any(|a| a.distance <= tol.confusion() && interior(a.point_a))
1849 {
1850 ogeom_bail!(
1851 Construction,
1852 "the profile touches the axis away from its ends; revolving it \
1853 would sweep a surface through itself. Split the profile where it \
1854 meets the axis"
1855 );
1856 }
1857 Ok(AxisRelation::Clear)
1858}
1859
1860fn rail(
1867 model: &mut Model,
1868 rails: &mut Rails,
1869 edge: &Shape,
1870 displacement: &Location,
1871 vector: Vector,
1872 at_end: bool,
1873 tol: Tolerances,
1874) -> OgeomResult<Shape> {
1875 let Some((start, end)) = crate::build::edge_vertices(model, edge)? else {
1876 ogeom_bail!(
1877 Construction,
1878 "an unbounded edge has no endpoints to sweep into rails"
1879 );
1880 };
1881 let base = if at_end { end } else { start };
1882 let raised = base.moved(displacement);
1883
1884 if let Some(existing) = rails.get(&base.node()) {
1888 return Ok(existing.clone());
1889 }
1890
1891 let Some(node) = model.node(&base) else {
1892 ogeom_bail!(Dangling, "vertex is not in this model");
1893 };
1894 let Some(data) = node.data().as_vertex() else {
1895 ogeom_bail!(Construction, "vertex node holds no point");
1896 };
1897 let from = base.transform(model.datums())?.apply(data.point);
1898 let doubt = data.tolerance;
1902
1903 let travel = vector.magnitude();
1907 let axis = Axis::new(from, Direction::new(vector, tol)?);
1908 let line = ogeom_geom::LineCurve::over(axis, 0.0, travel)?;
1909 let built =
1910 crate::build::make_edge_between(model, line.into(), (0.0, travel), &base, &raised, tol)?;
1911 model.widen(&built.shape, doubt)?;
1912 model.set_derived(&built.shape, std::slice::from_ref(&base), roles::SWEEP_RAIL)?;
1913 rails.insert(base.node(), built.shape.clone());
1914 Ok(built.shape)
1915}
1916
1917type Rails = FastMap<TShapeId, Shape>;
1923
1924fn seam_pcurves(
1929 model: &mut Model,
1930 edge: &Shape,
1931 surface: ogeom_topo::SurfaceId,
1932 forward: ((f64, f64), (f64, f64)),
1933 reversed: ((f64, f64), (f64, f64)),
1934 tol: Tolerances,
1935) -> OgeomResult<()> {
1936 let flat = |p: (f64, f64)| Point2::new(p.0, p.1);
1937 let length = flat(forward.0).distance(flat(forward.1));
1938 let first = model
1939 .geometry_mut()
1940 .add_pcurve(Line2d::segment(flat(forward.0), flat(forward.1), tol)?.into());
1941 let second = model
1942 .geometry_mut()
1943 .add_pcurve(Line2d::segment(flat(reversed.0), flat(reversed.1), tol)?.into());
1944
1945 let Some(node) = model.node_mut(edge) else {
1946 ogeom_bail!(Dangling, "edge is not in this model");
1947 };
1948 let NodeData::Edge(data) = node.data_mut() else {
1949 ogeom_bail!(Construction, "edge node holds no edge data");
1950 };
1951 data.add(EdgeRepr::Seam {
1952 forward: first,
1953 reversed: second,
1954 surface,
1955 location: edge.location().clone(),
1956 range: (0.0, length),
1957 });
1958 Ok(())
1959}
1960
1961fn pcurve(
1963 model: &mut Model,
1964 edge: &Shape,
1965 surface: ogeom_topo::SurfaceId,
1966 from: (f64, f64),
1967 to: (f64, f64),
1968 tol: Tolerances,
1969) -> OgeomResult<()> {
1970 let (a, b) = (Point2::new(from.0, from.1), Point2::new(to.0, to.1));
1971 let curve: PlanarCurve = Line2d::segment(a, b, tol)?.into();
1972 crate::build::attach_pcurve(
1977 model,
1978 edge,
1979 curve,
1980 surface,
1981 edge.location().clone(),
1982 (0.0, a.distance(b)),
1983 )
1984}
1985
1986#[cfg(test)]
1987#[allow(clippy::unwrap_used, clippy::expect_used)]
1988mod tests {
1989 use super::*;
1990 use crate::build::is_shell_closed;
1991 use crate::mass::volume_properties;
1992 use approx::assert_relative_eq;
1993 use ogeom_geom::SurfaceKind;
1994 use ogeom_math::{Frame, Point};
1995 use ogeom_mesh::{Deflection, triangulate};
1996 use ogeom_topo::{ShapeType, explore_unique};
1997
1998 const T: Tolerances = Tolerances::millimetres();
1999
2000 fn deflection(chord: f64) -> Deflection {
2001 Deflection {
2002 chord,
2003 ..Deflection::default()
2004 }
2005 }
2006
2007 fn box_face(model: &mut Model, side: f64, role: ogeom_core::Role) -> Shape {
2009 let built = crate::make_box(model, Frame::WORLD, (side, side, side), T).unwrap();
2010 explore_unique(model, &built.shape, ShapeType::Face)
2011 .unwrap()
2012 .into_iter()
2013 .find(|f| {
2014 model
2015 .provenance_of(f)
2016 .and_then(ogeom_core::Provenance::role)
2017 == Some(role)
2018 })
2019 .expect("the box has a face with that role")
2020 }
2021
2022 fn square(model: &mut Model, side: f64) -> Shape {
2024 box_face(model, side, crate::primitive::roles::FACE_MAX_Z)
2025 }
2026
2027 fn holed_square(model: &mut Model, hole_like_outline: bool) -> Shape {
2030 let plane = ogeom_math::Plane::new(Frame::WORLD);
2031 let corners = [(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 10.0)]
2032 .map(|(x, y)| Point::new(x, y, 0.0));
2033 let outer = crate::build::make_polygon(model, &corners, true, T)
2034 .unwrap()
2035 .shape;
2036 let frame = Frame::new(
2037 Point::new(4.0, 5.0, 0.0),
2038 ogeom_math::Direction::Z,
2039 ogeom_math::Direction::X,
2040 T,
2041 )
2042 .unwrap();
2043 let curve: ogeom_geom::Curve =
2044 ogeom_geom::CircleCurve::new(Circle::new(frame, 2.0, T).unwrap()).into();
2045 let domain = curve.domain();
2046 let ring = crate::build::make_edge(model, curve, domain, T)
2047 .unwrap()
2048 .shape;
2049 let ring = if hole_like_outline {
2050 ring
2051 } else {
2052 ring.reversed()
2053 };
2054 let surface: ogeom_geom::SurfaceGeometry =
2055 ogeom_geom::PlaneSurface::over(plane, (-20.0, 20.0), (-20.0, 20.0))
2056 .unwrap()
2057 .into();
2058 let outer_edges = model.ordered_children_of(&outer).unwrap();
2059 crate::build::make_face_with_pcurves(model, surface, &[outer_edges, vec![ring]], T)
2060 .unwrap()
2061 .shape
2062 }
2063
2064 #[test]
2070 fn every_sweep_walks_each_edge_once_each_way() {
2071 use crate::check::edges_walked_one_way;
2072 let mut model = Model::new();
2073 let face = square(&mut model, 2.0);
2074 let mut solids = Vec::new();
2075 for (profile, travel) in [
2076 (face.clone(), 3.0),
2077 (face.clone(), -3.0),
2078 (face.reversed(), 3.0),
2079 ] {
2080 let vector = Vector::new(0.0, 0.0, travel);
2081 solids.push(make_prism(&mut model, &profile, vector, T).unwrap().shape);
2082 }
2083 for hole_like_outline in [false, true] {
2084 let profile = holed_square(&mut model, hole_like_outline);
2085 let up = Vector::new(0.0, 0.0, 4.0);
2086 solids.push(make_prism(&mut model, &profile, up, T).unwrap().shape);
2087 solids.push(
2088 make_prism_tapered(&mut model, &profile, up, 0.1, T)
2089 .unwrap()
2090 .shape,
2091 );
2092 }
2093 let side = box_face(&mut model, 2.0, crate::primitive::roles::FACE_MIN_Y);
2094 let clear = Axis::new(Point::new(-5.0, 0.0, 0.0), ogeom_math::Direction::Z);
2095 let on = Axis::new(Point::ORIGIN, ogeom_math::Direction::Z);
2096 for (axis, angle) in [(clear, 1.0), (clear, TAU), (on, 4.0), (on, TAU)] {
2097 solids.push(
2098 make_revolution(&mut model, &side, axis, angle, T)
2099 .unwrap()
2100 .shape,
2101 );
2102 }
2103 for (i, solid) in solids.iter().enumerate() {
2104 assert_eq!(edges_walked_one_way(&model, solid), 0, "solid {i}");
2105 }
2106 }
2107
2108 #[test]
2115 fn a_stretch_of_an_unbounded_line_sweeps_a_wall_its_pcurves_end_on() {
2116 let mut model = Model::new();
2117 let length = 4.298_734_790_813_969_5;
2118 let (a, b) = (Point::ORIGIN, Point::new(0.0, 0.0, length));
2119 let va = crate::make_vertex(&mut model, a).shape;
2120 let vb = crate::make_vertex(&mut model, b).shape;
2121 let line = ogeom_geom::LineCurve::new(ogeom_math::Axis::new(a, ogeom_math::Direction::Z));
2122 let edge = crate::make_edge_between(&mut model, line.into(), (0.0, length), &va, &vb, T)
2123 .unwrap()
2124 .shape;
2125 let wall = make_prism(&mut model, &edge, Vector::new(0.0, 2.0, 0.0), T)
2126 .unwrap()
2127 .shape;
2128 let diagnosis = crate::check(&model, &wall, T).unwrap();
2129 assert!(diagnosis.is_valid(), "{:?}", diagnosis.problems);
2130 }
2131
2132 #[test]
2133 fn a_segment_swept_obliquely_makes_a_plane_with_a_sheared_chart() {
2134 use ogeom_geom::Curve3d as _;
2138 let mut model = Model::new();
2139 let (a, b) = (Point::new(0.0, 0.0, 0.0), Point::new(10.0, 0.0, 0.0));
2140 let va = crate::make_vertex(&mut model, a).shape;
2141 let vb = crate::make_vertex(&mut model, b).shape;
2142 let edge = crate::make_edge_between(
2143 &mut model,
2144 ogeom_geom::LineCurve::segment(a, b, T).unwrap().into(),
2145 (0.0, 10.0),
2146 &va,
2147 &vb,
2148 T,
2149 )
2150 .unwrap()
2151 .shape;
2152 let lean = Vector::new(2.0, 0.0, 5.0);
2153 let face = make_prism(&mut model, &edge, lean, T).unwrap().shape;
2154 let data = model.node(&face).unwrap().data().as_face().unwrap().clone();
2155 let surface = model.geometry().surface(data.surface).unwrap().clone();
2156 assert!(
2157 matches!(surface, ogeom_geom::SurfaceGeometry::Plane(_)),
2158 "an oblique sweep of a line is a plane"
2159 );
2160 use ogeom_geom::Surface as _;
2163 for e in explore_unique(&model, &face, ShapeType::Edge).unwrap() {
2164 let ed = model.node(&e).unwrap().data().as_edge().unwrap().clone();
2165 let Some(ogeom_topo::EdgeRepr::Curve3d { curve, range, .. }) = ed.curve3d() else {
2166 panic!("an edge has a curve");
2167 };
2168 let world = model
2169 .geometry()
2170 .curve(*curve)
2171 .unwrap()
2172 .clone()
2173 .transformed(&e.transform(model.datums()).unwrap(), T)
2174 .unwrap();
2175 let Some(ogeom_topo::EdgeRepr::PCurve {
2176 curve: pc,
2177 range: prange,
2178 ..
2179 }) = ed.pcurve_for(data.surface, e.location())
2180 else {
2181 panic!("an edge has a pcurve on the face");
2182 };
2183 let planar = model.geometry().pcurve(*pc).unwrap();
2184 for k in 0..=4 {
2185 let f = f64::from(k) / 4.0;
2186 let t = range.0 + (range.1 - range.0) * f;
2187 let u = prange.0 + (prange.1 - prange.0) * f;
2188 let on_curve = world.point_at(t, T).unwrap();
2189 let q = ogeom_geom::Curve2d::point_at(planar, u, T).unwrap();
2190 let on_surface = surface.point_at(q.x, q.y, T).unwrap();
2191 assert!(
2192 on_curve.distance(on_surface) < 1e-9,
2193 "pcurve and curve agree: {on_curve:?} vs {on_surface:?}"
2194 );
2195 }
2196 }
2197 }
2198
2199 #[test]
2205 fn a_mirrored_circle_sweeps_its_cylinder_either_way() {
2206 let (r, h) = (2.0, 3.0);
2207 let frame = Frame::from_axes(
2208 Point::new(1.0, -1.0, 0.0),
2209 ogeom_math::Direction::X,
2210 ogeom_math::Direction::Y,
2211 -ogeom_math::Direction::Z,
2212 T,
2213 )
2214 .unwrap();
2215 assert_eq!(frame.handedness(), ogeom_math::Handedness::Left);
2216 for travel in [h, -h] {
2217 let mut model = Model::new();
2218 let curve: ogeom_geom::Curve =
2219 ogeom_geom::CircleCurve::new(Circle::new(frame, r, T).unwrap()).into();
2220 let domain = curve.domain();
2221 let ring = crate::build::make_edge(&mut model, curve, domain, T)
2222 .unwrap()
2223 .shape;
2224 let plane = ogeom_math::Plane::new(Frame::WORLD);
2225 let surface: ogeom_geom::SurfaceGeometry =
2226 ogeom_geom::PlaneSurface::over(plane, (-20.0, 20.0), (-20.0, 20.0))
2227 .unwrap()
2228 .into();
2229 let disc = crate::build::make_face_with_pcurves(&mut model, surface, &[vec![ring]], T)
2230 .unwrap()
2231 .shape;
2232 let built = make_prism(&mut model, &disc, Vector::new(0.0, 0.0, travel), T).unwrap();
2233 let diagnosis = crate::check(&model, &built.shape, T).unwrap();
2234 assert!(
2235 diagnosis.is_valid(),
2236 "travel {travel}: {:?}",
2237 diagnosis.problems
2238 );
2239 let kinds = surface_kinds(&model, &built.shape);
2240 assert_eq!(
2241 kinds,
2242 vec![
2243 SurfaceKind::Cylinder,
2244 SurfaceKind::Plane,
2245 SurfaceKind::Plane
2246 ],
2247 "travel {travel}"
2248 );
2249 let v = volume_properties(&model, &built.shape, deflection(1e-3), T)
2250 .unwrap()
2251 .mass;
2252 let want = core::f64::consts::PI * r * r * h;
2253 assert_relative_eq!(v, want, max_relative = 1e-12);
2254 }
2255 }
2256
2257 #[test]
2258 fn a_tapered_square_prism_is_the_frustum_the_closed_form_names() {
2259 let mut model = Model::new();
2260 let profile = square(&mut model, 10.0);
2261 let taper = 5.0_f64.to_radians();
2262 let built =
2263 crate::make_prism_tapered(&mut model, &profile, Vector::new(0.0, 0.0, 10.0), taper, T)
2264 .unwrap();
2265 let diagnosis = crate::check(&model, &built.shape, T).unwrap();
2266 assert!(diagnosis.is_valid(), "{:?}", diagnosis.problems);
2267
2268 let d = 10.0 * taper.tan();
2271 let has_far_corner = explore_unique(&model, &built.shape, ShapeType::Vertex)
2272 .unwrap()
2273 .into_iter()
2274 .any(|v| {
2275 model
2276 .node(&v)
2277 .and_then(|n| n.data().as_vertex().map(|data| data.point))
2278 .is_some_and(|p| {
2279 (p.z - 20.0).abs() < 1e-9
2280 && (p.x + d).abs() < 1e-9
2281 && (p.y + d).abs() < 1e-9
2282 })
2283 });
2284 assert!(has_far_corner, "the far ring widened by the taper");
2285
2286 let (a0, a1) = (100.0, (10.0 + 2.0 * d) * (10.0 + 2.0 * d));
2288 let expected = 10.0 / 3.0 * (a1.mul_add(1.0, a0) + (a0 * a1).sqrt());
2289 let measured = volume_properties(&model, &built.shape, Deflection::default(), T)
2290 .unwrap()
2291 .mass;
2292 assert!(
2293 (measured - expected).abs() < 1e-6,
2294 "tapered prism volume {measured} against {expected}"
2295 );
2296 assert!(!built.history.generated(&profile).is_empty());
2297 }
2298
2299 #[test]
2303 fn a_bare_or_placed_profile_tapers() {
2304 let mut model = Model::new();
2305 let corners = [
2306 Point::new(0.0, 0.0, 0.0),
2307 Point::new(10.0, 0.0, 0.0),
2308 Point::new(10.0, 10.0, 0.0),
2309 Point::new(0.0, 10.0, 0.0),
2310 ];
2311 let wire = crate::make_polygon(&mut model, &corners, true, T)
2312 .unwrap()
2313 .shape;
2314 let bare = crate::make_face(
2315 &mut model,
2316 ogeom_geom::SurfaceGeometry::Plane(ogeom_geom::PlaneSurface::new(
2317 ogeom_math::Plane::new(Frame::WORLD),
2318 )),
2319 &[wire],
2320 T,
2321 )
2322 .unwrap()
2323 .shape;
2324 let shift = Vector::new(100.0, 50.0, 7.0);
2325 let placed = bare.located(Location::of(
2326 model.add_datum(ogeom_math::Transform::translation(shift)),
2327 ));
2328 let taper = 0.1_f64;
2329 let d = 5.0 * taper.tan();
2330 let (a0, a1) = (100.0, (10.0 + 2.0 * d) * (10.0 + 2.0 * d));
2331 let expected = 5.0 / 3.0 * (a0 + a1 + (a0 * a1).sqrt());
2332 for (profile, offset) in [(bare.clone(), Vector::ZERO), (placed, shift)] {
2333 let built = crate::make_prism_tapered(
2334 &mut model,
2335 &profile,
2336 Vector::new(0.0, 0.0, 5.0),
2337 taper,
2338 T,
2339 )
2340 .unwrap();
2341 let diagnosis = crate::check(&model, &built.shape, T).unwrap();
2342 assert!(diagnosis.is_valid(), "{:?}", diagnosis.problems);
2343 let properties =
2344 volume_properties(&model, &built.shape, Deflection::default(), T).unwrap();
2345 assert!(
2346 (properties.mass - expected).abs() < 1e-9 * expected,
2347 "{} against {expected}",
2348 properties.mass
2349 );
2350 let centre = Point::new(5.0, 5.0, 0.0) + offset;
2351 assert!((properties.centre.x - centre.x).abs() < 1e-9);
2352 assert!((properties.centre.y - centre.y).abs() < 1e-9);
2353 assert!(properties.centre.z > centre.z && properties.centre.z < centre.z + 5.0);
2354 }
2355 }
2356
2357 #[test]
2358 fn a_hole_tapers_with_its_profile_into_a_cone() {
2359 let mut model = Model::new();
2360 let plane = ogeom_math::Plane::new(Frame::WORLD);
2362 let corners = [
2363 Point::new(0.0, 0.0, 0.0),
2364 Point::new(10.0, 0.0, 0.0),
2365 Point::new(10.0, 10.0, 0.0),
2366 Point::new(0.0, 10.0, 0.0),
2367 ];
2368 let outer = crate::build::make_polygon(&mut model, &corners, true, T)
2369 .unwrap()
2370 .shape;
2371 let hole_centre = Point::new(3.5, 6.0, 0.0);
2372 let hole_r = 2.0;
2373 let circle = Circle::new(
2374 Frame::new(
2375 hole_centre,
2376 ogeom_math::Direction::Z,
2377 ogeom_math::Direction::X,
2378 T,
2379 )
2380 .unwrap(),
2381 hole_r,
2382 T,
2383 )
2384 .unwrap();
2385 let curve: ogeom_geom::Curve = ogeom_geom::CircleCurve::new(circle).into();
2386 let domain = curve.domain();
2387 let ring = crate::build::make_edge(&mut model, curve, domain, T)
2388 .unwrap()
2389 .shape;
2390 let hole = make_wire(&mut model, std::slice::from_ref(&ring), T)
2391 .unwrap()
2392 .shape;
2393 let surface: ogeom_geom::SurfaceGeometry =
2394 ogeom_geom::PlaneSurface::over(plane, (-20.0, 20.0), (-20.0, 20.0))
2395 .unwrap()
2396 .into();
2397 let outer_edges = model.ordered_children_of(&outer).unwrap();
2398 let profile = crate::build::make_face_with_pcurves(
2399 &mut model,
2400 surface,
2401 &[outer_edges, vec![ring.clone()]],
2402 T,
2403 )
2404 .unwrap()
2405 .shape;
2406 let _ = hole;
2407
2408 let taper = 5.0_f64.to_radians();
2409 let built =
2410 crate::make_prism_tapered(&mut model, &profile, Vector::new(0.0, 0.0, 10.0), taper, T)
2411 .unwrap();
2412 let diagnosis = crate::check(&model, &built.shape, T).unwrap();
2413 assert!(diagnosis.is_valid(), "{:?}", diagnosis.problems);
2414
2415 let cones = explore_unique(&model, &built.shape, ShapeType::Face)
2418 .unwrap()
2419 .into_iter()
2420 .filter(|f| {
2421 model
2422 .node(f)
2423 .and_then(|n| n.data().as_face())
2424 .and_then(|d| model.geometry().surface(d.surface))
2425 .is_some_and(|s| matches!(s, ogeom_geom::SurfaceGeometry::Cone(_)))
2426 })
2427 .count();
2428 assert_eq!(cones, 1, "the hole wall is a cone");
2429
2430 let pi = core::f64::consts::PI;
2431 let d = 10.0 * taper.tan();
2432 let (a0, a1) = (100.0, (10.0 + 2.0 * d) * (10.0 + 2.0 * d));
2433 let outer_frustum = 10.0 / 3.0 * (a1.mul_add(1.0, a0) + (a0 * a1).sqrt());
2434 let r1 = hole_r - d;
2435 let hole_frustum = pi * 10.0 / 3.0 * (hole_r.mul_add(hole_r, hole_r * r1) + r1 * r1);
2436 let expected = outer_frustum - hole_frustum;
2437 let measured = volume_properties(&model, &built.shape, deflection(1e-3), T)
2438 .unwrap()
2439 .mass;
2440 assert!(
2441 (measured - expected).abs() < 5e-2,
2442 "holed tapered prism volume {measured} against {expected}"
2443 );
2444 }
2445
2446 #[test]
2447 fn a_taper_that_collapses_a_hole_is_refused_by_name() {
2448 let mut model = Model::new();
2449 let plane = ogeom_math::Plane::new(Frame::WORLD);
2450 let corners = [
2451 Point::new(0.0, 0.0, 0.0),
2452 Point::new(10.0, 0.0, 0.0),
2453 Point::new(10.0, 10.0, 0.0),
2454 Point::new(0.0, 10.0, 0.0),
2455 ];
2456 let outer = crate::build::make_polygon(&mut model, &corners, true, T)
2457 .unwrap()
2458 .shape;
2459 let circle = Circle::new(
2460 Frame::new(
2461 Point::new(5.0, 5.0, 0.0),
2462 ogeom_math::Direction::Z,
2463 ogeom_math::Direction::X,
2464 T,
2465 )
2466 .unwrap(),
2467 1.0,
2468 T,
2469 )
2470 .unwrap();
2471 let curve: ogeom_geom::Curve = ogeom_geom::CircleCurve::new(circle).into();
2472 let domain = curve.domain();
2473 let ring = crate::build::make_edge(&mut model, curve, domain, T)
2474 .unwrap()
2475 .shape;
2476 let hole = make_wire(&mut model, std::slice::from_ref(&ring), T)
2477 .unwrap()
2478 .shape;
2479 let surface: ogeom_geom::SurfaceGeometry =
2480 ogeom_geom::PlaneSurface::over(plane, (-20.0, 20.0), (-20.0, 20.0))
2481 .unwrap()
2482 .into();
2483 let outer_edges = model.ordered_children_of(&outer).unwrap();
2484 let profile = crate::build::make_face_with_pcurves(
2485 &mut model,
2486 surface,
2487 &[outer_edges, vec![ring.clone()]],
2488 T,
2489 )
2490 .unwrap()
2491 .shape;
2492 let _ = hole;
2493 let err = crate::make_prism_tapered(
2494 &mut model,
2495 &profile,
2496 Vector::new(0.0, 0.0, 10.0),
2497 8.0_f64.to_radians(),
2498 T,
2499 )
2500 .unwrap_err();
2501 assert!(err.to_string().contains("collapses"), "{err}");
2502 }
2503
2504 #[test]
2505 fn a_curved_profile_edge_is_refused_by_name() {
2506 let mut model = Model::new();
2507 let plane = ogeom_math::Plane::new(Frame::WORLD);
2508 let ellipse = ogeom_math::Ellipse::new(Frame::WORLD, 4.0, 2.0, T).unwrap();
2509 let curve: ogeom_geom::Curve = ogeom_geom::EllipseCurve::new(ellipse).into();
2510 let domain = curve.domain();
2511 let ring = crate::build::make_edge(&mut model, curve, domain, T)
2512 .unwrap()
2513 .shape;
2514 let wire = make_wire(&mut model, std::slice::from_ref(&ring), T)
2515 .unwrap()
2516 .shape;
2517 let surface: ogeom_geom::SurfaceGeometry =
2518 ogeom_geom::PlaneSurface::over(plane, (-10.0, 10.0), (-10.0, 10.0))
2519 .unwrap()
2520 .into();
2521 let profile =
2522 crate::build::make_face_with_pcurves(&mut model, surface, &[vec![ring.clone()]], T)
2523 .unwrap()
2524 .shape;
2525 let _ = wire;
2526 let err = crate::make_prism_tapered(
2527 &mut model,
2528 &profile,
2529 Vector::new(0.0, 0.0, 5.0),
2530 5.0_f64.to_radians(),
2531 T,
2532 )
2533 .unwrap_err();
2534 assert!(err.to_string().contains("fitted ruling"), "{err}");
2535 }
2536
2537 #[test]
2538 fn a_profile_facing_away_from_the_sweep_gives_the_same_solid_as_one_facing_along_it() {
2539 for (role, centre) in [
2544 (
2547 crate::primitive::roles::FACE_MAX_Z,
2548 Point::new(1.0, 1.0, 3.5),
2549 ),
2550 (
2551 crate::primitive::roles::FACE_MIN_Z,
2552 Point::new(1.0, 1.0, 1.5),
2553 ),
2554 ] {
2555 let mut model = Model::new();
2556 let face = box_face(&mut model, 2.0, role);
2557 let built = make_prism(&mut model, &face, Vector::new(0.0, 0.0, 3.0), T).unwrap();
2558
2559 let counts = |kind| explore_unique(&model, &built.shape, kind).unwrap().len();
2560 assert_eq!(counts(ShapeType::Face), 6, "{role:?}");
2561 assert_eq!(counts(ShapeType::Edge), 12, "{role:?}");
2562
2563 for face in explode(&model, &built.shape) {
2566 ogeom_mesh::triangulate_face(&model, &face, deflection(0.01), T)
2567 .unwrap_or_else(|e| panic!("{role:?}: a face would not triangulate: {e}"));
2568 }
2569
2570 let mesh = triangulate(&model, &built.shape, deflection(0.01), T).unwrap();
2571 assert!(mesh.is_closed(), "{role:?}: the mesh has a slit in it");
2572 assert_relative_eq!(mesh.volume(), 12.0, epsilon = 1e-9);
2576
2577 let props = volume_properties(&model, &built.shape, deflection(0.01), T).unwrap();
2578 assert_relative_eq!(props.mass, 12.0, epsilon = 1e-9);
2579 assert!(
2580 props.centre.distance(centre) < 1e-9,
2581 "{role:?}: got {:?}",
2582 props.centre
2583 );
2584
2585 assert!(
2586 crate::check_tessellation(&model, &built.shape, deflection(0.01), T)
2587 .unwrap()
2588 .is_valid(),
2589 "{role:?}: the mesh disagrees with the topology"
2590 );
2591 }
2592 }
2593
2594 #[test]
2595 fn every_face_of_a_box_sweeps_into_a_solid_of_the_right_volume() {
2596 use crate::primitive::roles;
2599 let roles = [
2600 (roles::FACE_MIN_X, Vector::new(-3.0, 0.0, 0.0)),
2601 (roles::FACE_MAX_X, Vector::new(3.0, 0.0, 0.0)),
2602 (roles::FACE_MIN_Y, Vector::new(0.0, -3.0, 0.0)),
2603 (roles::FACE_MAX_Y, Vector::new(0.0, 3.0, 0.0)),
2604 (roles::FACE_MIN_Z, Vector::new(0.0, 0.0, -3.0)),
2605 (roles::FACE_MAX_Z, Vector::new(0.0, 0.0, 3.0)),
2606 ];
2607 for (role, vector) in roles {
2608 let mut model = Model::new();
2609 let face = box_face(&mut model, 2.0, role);
2610 let built = make_prism(&mut model, &face, vector, T).unwrap();
2611 let mesh = triangulate(&model, &built.shape, deflection(0.01), T).unwrap();
2612 assert!(mesh.is_closed(), "{role:?}: the mesh has a slit in it");
2613 assert_relative_eq!(mesh.volume(), 12.0, epsilon = 1e-9);
2614 }
2615 }
2616
2617 fn explode(model: &Model, shape: &Shape) -> Vec<Shape> {
2619 ogeom_topo::explore(model, shape, ogeom_topo::Filter::OfType(ShapeType::Face)).unwrap()
2620 }
2621
2622 fn ring_profile(model: &mut Model, offset: f64, side: f64) -> Shape {
2630 let frame = Frame::new(
2631 Point::new(offset, 0.0, 0.0),
2632 -ogeom_math::Direction::Y,
2633 ogeom_math::Direction::X,
2634 T,
2635 )
2636 .unwrap();
2637 let corners = [
2638 Point::new(offset, 0.0, 0.0),
2639 Point::new(offset + side, 0.0, 0.0),
2640 Point::new(offset + side, 0.0, side),
2641 Point::new(offset, 0.0, side),
2642 ];
2643 let vertices: Vec<Shape> = corners
2644 .iter()
2645 .map(|p| model.add_vertex(ogeom_topo::VertexData::new(*p)))
2646 .collect();
2647 let edges: Vec<Shape> = (0..4)
2648 .map(|i| {
2649 let (a, b) = (corners[i], corners[(i + 1) % 4]);
2650 crate::build::make_edge_between(
2651 model,
2652 ogeom_geom::LineCurve::segment(a, b, T).unwrap().into(),
2653 (0.0, a.distance(b)),
2654 &vertices[i],
2655 &vertices[(i + 1) % 4],
2656 T,
2657 )
2658 .unwrap()
2659 .shape
2660 })
2661 .collect();
2662 let wire = crate::make_wire(model, &edges, T).unwrap().shape;
2663 let surface = model
2664 .geometry_mut()
2665 .add_surface(ogeom_geom::PlaneSurface::new(ogeom_math::Plane::new(frame)).into());
2666 for (i, edge) in edges.iter().enumerate() {
2667 let (a, b) = (corners[i], corners[(i + 1) % 4]);
2668 let flat = |p: ogeom_math::Point| {
2669 let l = frame.to_local(p);
2670 Point2::new(l.x, l.y)
2671 };
2672 crate::attach_pcurve(
2673 model,
2674 edge,
2675 Line2d::segment(flat(a), flat(b), T).unwrap().into(),
2676 surface,
2677 ogeom_topo::Location::identity(),
2678 (0.0, a.distance(b)),
2679 )
2680 .unwrap();
2681 }
2682 crate::make_face_on(model, surface, std::slice::from_ref(&wire), T)
2683 .unwrap()
2684 .shape
2685 }
2686
2687 #[test]
2688 fn a_square_revolved_a_full_turn_is_a_ring_that_agrees_with_itself() {
2689 let (offset, side) = (3.0_f64, 2.0_f64);
2694 let mut model = Model::new();
2695 let profile = ring_profile(&mut model, offset, side);
2696 let built = make_revolution(&mut model, &profile, Axis::Z, TAU, T).unwrap();
2697
2698 let counts = |kind| explore_unique(&model, &built.shape, kind).unwrap().len();
2699 assert_eq!(counts(ShapeType::Face), 4, "one per profile edge, no caps");
2700 assert_eq!(
2701 counts(ShapeType::Edge),
2702 6,
2703 "a rail per profile vertex, and a seam only on the cylindrical \
2704 walls; the flat annuli are plane faces bounded by their rails \
2705 alone"
2706 );
2707 assert_eq!(counts(ShapeType::Vertex), 4, "a full turn adds none");
2708
2709 let shell = explore_unique(&model, &built.shape, ShapeType::Shell).unwrap()[0].clone();
2710 assert!(is_shell_closed(&model, &shell).unwrap());
2711 assert!(
2712 crate::check(&model, &built.shape, T).unwrap().is_valid(),
2713 "{}",
2714 crate::check(&model, &built.shape, T).unwrap()
2715 );
2716
2717 for face in explode(&model, &built.shape) {
2718 ogeom_mesh::triangulate_face(&model, &face, deflection(0.01), T)
2719 .unwrap_or_else(|e| panic!("a face would not triangulate: {e}"));
2720 }
2721
2722 let exact = side * side * TAU * (offset + side / 2.0);
2725 let found = crate::check_tessellation(&model, &built.shape, deflection(0.005), T).unwrap();
2726 assert!(found.is_valid(), "the mesh came apart: {found}");
2727
2728 let mesh = triangulate(&model, &built.shape, deflection(0.005), T).unwrap();
2729 assert!(mesh.is_closed(), "the mesh has a slit in it");
2730 assert!(mesh.volume() > 0.0, "the solid is inside out");
2731 assert_relative_eq!(mesh.volume(), exact, max_relative = 1e-3);
2735 }
2736
2737 #[test]
2738 fn a_square_revolved_part_way_has_two_ends_and_the_volume_of_that_wedge() {
2739 let (offset, side) = (3.0_f64, 2.0_f64);
2740 let angle = std::f64::consts::FRAC_PI_2;
2741 let mut model = Model::new();
2742 let profile = ring_profile(&mut model, offset, side);
2743 let built = make_revolution(&mut model, &profile, Axis::Z, angle, T).unwrap();
2744
2745 let counts = |kind| explore_unique(&model, &built.shape, kind).unwrap().len();
2746 assert_eq!(counts(ShapeType::Face), 6, "four sides and two ends");
2747
2748 let shell = explore_unique(&model, &built.shape, ShapeType::Shell).unwrap()[0].clone();
2749 assert!(is_shell_closed(&model, &shell).unwrap());
2750
2751 let exact = side * side * angle * (offset + side / 2.0);
2752 let mesh = triangulate(&model, &built.shape, deflection(0.005), T).unwrap();
2753 assert!(mesh.is_closed(), "the mesh has a slit in it");
2754 assert!(mesh.volume() > 0.0, "the solid is inside out");
2755 assert_relative_eq!(mesh.volume(), exact, max_relative = 1e-3);
2756 assert!(
2757 crate::check_tessellation(&model, &built.shape, deflection(0.005), T)
2758 .unwrap()
2759 .is_valid()
2760 );
2761 }
2762
2763 #[test]
2779 fn a_narrow_face_far_from_its_charts_origin_reads_its_side_across_its_gaps() {
2780 let (offset, side, angle) = (1.4_f64, 0.036_f64, 0.44_f64);
2781 let short_by = 4e-3;
2782 let mut model = Model::new();
2783 let profile = ring_profile(&mut model, offset, side);
2784 let built = make_revolution(&mut model, &profile, Axis::Z, angle, T).unwrap();
2785 let outer = explode(&model, &built.shape)
2786 .into_iter()
2787 .find(|face| {
2788 let data = model.node(face).unwrap().data().as_face().unwrap();
2789 matches!(
2790 model.geometry().surface(data.surface),
2791 Some(ogeom_geom::SurfaceGeometry::Cylinder(c))
2792 if (c.cylinder().radius() - (offset + side)).abs() < 1e-12
2793 )
2794 })
2795 .unwrap();
2796 let surface = model
2797 .node(&outer)
2798 .unwrap()
2799 .data()
2800 .as_face()
2801 .unwrap()
2802 .surface;
2803 let mut pieces = Vec::new();
2804 for edge in explore_unique(&model, &outer, ShapeType::Edge).unwrap() {
2805 let data = model.node(&edge).unwrap().data().as_edge().unwrap();
2806 let Some(EdgeRepr::PCurve { curve, range, .. }) =
2807 data.pcurve_for(surface, edge.location()).cloned()
2808 else {
2809 panic!("the wall's edges each have one pcurve on it");
2810 };
2811 pieces.push((edge, curve, range));
2812 }
2813 let turn = ogeom_math::Transform2::translation(ogeom_math::Vector2::new(TAU, 0.0));
2814 for (_, id, _) in &pieces {
2815 let held = model.geometry_mut().pcurve_mut(*id).unwrap();
2816 *held = held.transformed(&turn, T).unwrap();
2817 }
2818 let edge = pieces
2821 .iter()
2822 .filter_map(|(edge, id, range)| {
2823 let curve = model.geometry().pcurve(*id).unwrap();
2824 use ogeom_geom::Curve2d as _;
2825 let (a, b) = (
2826 curve.point_at(range.0, T).unwrap(),
2827 curve.point_at(range.1, T).unwrap(),
2828 );
2829 ((a.x - b.x).abs() < 1e-12).then_some((edge.clone(), *id, *range, a, b))
2830 })
2831 .max_by(|p, q| p.3.x.total_cmp(&q.3.x))
2832 .map(|(edge, id, range, a, b)| {
2833 let short = a + (b - a) * (short_by / a.distance(b));
2834 let knots =
2835 ogeom_math::KnotVector::new(vec![range.0, range.0, range.1, range.1], 1)
2836 .unwrap();
2837 *model.geometry_mut().pcurve_mut(id).unwrap() =
2838 ogeom_geom::BSpline2d::new(knots, vec![short, b], T)
2839 .unwrap()
2840 .into();
2841 edge
2842 })
2843 .unwrap();
2844 let widened = ogeom_core::Tolerance::new(short_by * 1.25).unwrap();
2845 model.widen(&edge, widened).unwrap();
2846 for vertex in explore_unique(&model, &edge, ShapeType::Vertex).unwrap() {
2847 model.widen(&vertex, widened).unwrap();
2848 }
2849
2850 let flags = crate::mass::flags_agree(&model, &built.shape, T).unwrap();
2851 assert!(flags.agree, "the walks tie the faces consistently");
2852 assert!(
2853 flags.sets.is_empty(),
2854 "every face is in one set, none turned against it: {:?}",
2855 flags
2856 .sets
2857 .iter()
2858 .map(|set| set.iter().filter(|(_, against)| *against).count())
2859 .collect::<Vec<_>>()
2860 );
2861 let exact = side * side * angle * (offset + side / 2.0);
2862 let found = crate::mass::volume_as_flagged(&model, &built.shape, deflection(1e-3), None, T)
2863 .unwrap();
2864 assert_eq!(found.deflection, 0.0, "the closed form measured it");
2865 assert_relative_eq!(found.mass, exact, max_relative = 1e-9);
2866 }
2867
2868 fn profile_from(model: &mut Model, corners: &[Point]) -> Shape {
2871 let frame = Frame::new(
2872 corners[0],
2873 -ogeom_math::Direction::Y,
2874 ogeom_math::Direction::X,
2875 T,
2876 )
2877 .unwrap();
2878 let n = corners.len();
2879 let vertices: Vec<Shape> = corners
2880 .iter()
2881 .map(|p| model.add_vertex(ogeom_topo::VertexData::new(*p)))
2882 .collect();
2883 let surface = model
2884 .geometry_mut()
2885 .add_surface(ogeom_geom::PlaneSurface::new(ogeom_math::Plane::new(frame)).into());
2886 let flat = |p: ogeom_math::Point| {
2887 let l = frame.to_local(p);
2888 Point2::new(l.x, l.y)
2889 };
2890
2891 let mut edges = Vec::with_capacity(n);
2892 for i in 0..n {
2893 let (a, b) = (corners[i], corners[(i + 1) % n]);
2894 let edge = crate::build::make_edge_between(
2895 model,
2896 ogeom_geom::LineCurve::segment(a, b, T).unwrap().into(),
2897 (0.0, a.distance(b)),
2898 &vertices[i],
2899 &vertices[(i + 1) % n],
2900 T,
2901 )
2902 .unwrap()
2903 .shape;
2904 crate::attach_pcurve(
2905 model,
2906 &edge,
2907 Line2d::segment(flat(a), flat(b), T).unwrap().into(),
2908 surface,
2909 ogeom_topo::Location::identity(),
2910 (0.0, a.distance(b)),
2911 )
2912 .unwrap();
2913 edges.push(edge);
2914 }
2915 let wire = crate::make_wire(model, &edges, T).unwrap().shape;
2916 crate::make_face_on(model, surface, std::slice::from_ref(&wire), T)
2917 .unwrap()
2918 .shape
2919 }
2920
2921 #[test]
2922 fn a_rectangle_with_a_side_on_the_axis_revolves_into_a_cylinder_face_for_face() {
2923 let (radius, height) = (2.0_f64, 5.0_f64);
2932 let mut model = Model::new();
2933 let profile = profile_from(
2934 &mut model,
2935 &[
2936 Point::new(0.0, 0.0, 0.0),
2937 Point::new(radius, 0.0, 0.0),
2938 Point::new(radius, 0.0, height),
2939 Point::new(0.0, 0.0, height),
2940 ],
2941 );
2942 let revolved = make_revolution(&mut model, &profile, Axis::Z, TAU, T).unwrap();
2943 let primitive = crate::make_cylinder(&mut model, Frame::WORLD, radius, height, T).unwrap();
2944
2945 let counts = |shape: &Shape, kind| explore_unique(&model, shape, kind).unwrap().len();
2946 assert_eq!(
2947 counts(&revolved.shape, ShapeType::Face),
2948 counts(&primitive.shape, ShapeType::Face),
2949 "a side and two caps, the same as make_cylinder"
2950 );
2951 assert_eq!(counts(&revolved.shape, ShapeType::Face), 3);
2952 for kind in [ShapeType::Edge, ShapeType::Vertex] {
2953 assert_eq!(
2954 counts(&revolved.shape, kind),
2955 counts(&primitive.shape, kind),
2956 "canonical caps carry a rim circle and nothing else: {kind:?}"
2957 );
2958 }
2959
2960 let shell = explore_unique(&model, &revolved.shape, ShapeType::Shell).unwrap()[0].clone();
2961 assert!(is_shell_closed(&model, &shell).unwrap());
2962 assert!(
2963 crate::check(&model, &revolved.shape, T).unwrap().is_valid(),
2964 "{}",
2965 crate::check(&model, &revolved.shape, T).unwrap()
2966 );
2967 assert!(
2968 crate::check_tessellation(&model, &revolved.shape, deflection(0.005), T)
2969 .unwrap()
2970 .is_valid()
2971 );
2972
2973 let exact = std::f64::consts::PI * radius * radius * height;
2974 let mesh = triangulate(&model, &revolved.shape, deflection(0.005), T).unwrap();
2975 assert!(mesh.is_closed());
2976 assert!(mesh.volume() > 0.0, "the solid is inside out");
2977 assert!(
2978 mesh.volume() < exact,
2979 "an inscribed volume cannot exceed it"
2980 );
2981 let reference = triangulate(&model, &primitive.shape, deflection(0.005), T).unwrap();
2986 assert_relative_eq!(mesh.volume(), reference.volume(), max_relative = 1e-6);
2987 assert!(
2988 mesh.volume() > exact * 0.995,
2989 "{} against {exact}",
2990 mesh.volume()
2991 );
2992 }
2993
2994 #[test]
2995 fn a_wall_parallel_to_the_axis_names_the_cylinder_it_is() {
2996 let (inner, outer, height) = (3.0_f64, 5.0_f64, 4.0_f64);
3003 for flip in [false, true] {
3004 let mut model = Model::new();
3005 let mut corners = [
3006 Point::new(inner, 0.0, 0.0),
3007 Point::new(outer, 0.0, 0.0),
3008 Point::new(outer, 0.0, height),
3009 Point::new(inner, 0.0, height),
3010 ];
3011 if flip {
3012 corners.reverse();
3013 }
3014 let profile = profile_from(&mut model, &corners);
3015 let revolved = make_revolution(&mut model, &profile, Axis::Z, TAU, T).unwrap();
3016
3017 for face in explore_unique(&model, &revolved.shape, ShapeType::Face).unwrap() {
3018 let NodeData::Face(data) = model.node(&face).unwrap().data() else {
3019 panic!("face data");
3020 };
3021 let surface = model.geometry().surface(data.surface).unwrap();
3022 assert!(
3023 matches!(
3024 surface,
3025 ogeom_geom::SurfaceGeometry::Cylinder(_)
3026 | ogeom_geom::SurfaceGeometry::Plane(_)
3027 ),
3028 "flip {flip}: a ring's face is a {surface:?}, not the \
3029 cylinder or plane it is"
3030 );
3031 }
3032
3033 assert!(
3034 crate::check(&model, &revolved.shape, T).unwrap().is_valid(),
3035 "flip {flip}: {}",
3036 crate::check(&model, &revolved.shape, T).unwrap()
3037 );
3038 let exact = std::f64::consts::PI * outer.mul_add(outer, -(inner * inner)) * height;
3039 let measured = volume_properties(&model, &revolved.shape, deflection(0.005), T)
3040 .unwrap()
3041 .mass;
3042 assert!(
3043 (measured - exact).abs() < exact * 1e-3,
3044 "flip {flip}: ring volume {measured} against {exact}"
3045 );
3046 }
3047 }
3048
3049 #[test]
3050 fn a_triangle_touching_the_axis_revolves_into_a_cone() {
3051 let (radius, height) = (3.0_f64, 4.0_f64);
3056 let mut model = Model::new();
3057 let profile = profile_from(
3058 &mut model,
3059 &[
3060 Point::new(0.0, 0.0, 0.0),
3061 Point::new(radius, 0.0, 0.0),
3062 Point::new(0.0, 0.0, height),
3063 ],
3064 );
3065 let built = make_revolution(&mut model, &profile, Axis::Z, TAU, T).unwrap();
3066
3067 assert_eq!(
3068 explore_unique(&model, &built.shape, ShapeType::Face)
3069 .unwrap()
3070 .len(),
3071 2,
3072 "a flank and one cap; the side on the axis sweeps out nothing"
3073 );
3074 assert_eq!(
3075 surface_kinds(&model, &built.shape),
3076 vec![SurfaceKind::Cone, SurfaceKind::Plane],
3077 "the flank is the cone it sweeps, and the cap its plane"
3078 );
3079 let shell = explore_unique(&model, &built.shape, ShapeType::Shell).unwrap()[0].clone();
3080 assert!(is_shell_closed(&model, &shell).unwrap());
3081 assert!(
3082 crate::check_tessellation(&model, &built.shape, deflection(0.005), T)
3083 .unwrap()
3084 .is_valid()
3085 );
3086
3087 let exact = std::f64::consts::PI * radius * radius * height / 3.0;
3088 let mesh = triangulate(&model, &built.shape, deflection(0.005), T).unwrap();
3089 assert!(mesh.volume() > 0.0, "the solid is inside out");
3090 assert!(mesh.volume() < exact);
3091 assert!(
3092 mesh.volume() > exact * 0.99,
3093 "{} against {exact}",
3094 mesh.volume()
3095 );
3096 }
3097
3098 fn surface_kinds(model: &Model, shape: &Shape) -> Vec<ogeom_geom::SurfaceKind> {
3101 use ogeom_geom::Surface as _;
3102 let mut kinds: Vec<ogeom_geom::SurfaceKind> = explore_unique(model, shape, ShapeType::Face)
3103 .unwrap()
3104 .iter()
3105 .map(|face| {
3106 let NodeData::Face(data) = model.node(face).unwrap().data() else {
3107 panic!("face data");
3108 };
3109 model.geometry().surface(data.surface).unwrap().kind()
3110 })
3111 .collect();
3112 kinds.sort_by_key(|k| format!("{k:?}"));
3113 kinds
3114 }
3115
3116 #[test]
3117 fn a_frustum_profile_names_a_cone_on_each_leaning_side() {
3118 let mut model = Model::new();
3122 let profile = profile_from(
3123 &mut model,
3124 &[
3125 Point::new(3.0, 0.0, 0.0),
3126 Point::new(5.0, 0.0, 0.0),
3127 Point::new(4.0, 0.0, 4.0),
3128 Point::new(2.0, 0.0, 4.0),
3129 ],
3130 );
3131 let built = make_revolution(&mut model, &profile, Axis::Z, TAU, T).unwrap();
3132
3133 assert_eq!(
3134 surface_kinds(&model, &built.shape),
3135 vec![
3136 SurfaceKind::Cone,
3137 SurfaceKind::Cone,
3138 SurfaceKind::Plane,
3139 SurfaceKind::Plane
3140 ],
3141 "two leaning walls and two flat ends"
3142 );
3143 assert!(
3144 crate::check(&model, &built.shape, T).unwrap().is_valid(),
3145 "{}",
3146 crate::check(&model, &built.shape, T).unwrap()
3147 );
3148
3149 let frustum =
3154 |a: f64, b: f64| std::f64::consts::PI * 4.0 / 3.0 * a.mul_add(a, b.mul_add(b, a * b));
3155 let exact = frustum(5.0, 4.0) - frustum(3.0, 2.0);
3156 let measured = volume_properties(&model, &built.shape, deflection(0.005), T)
3157 .unwrap()
3158 .mass;
3159 assert!(
3160 (measured - exact).abs() < exact * 1e-3,
3161 "frustum volume {measured} against {exact}"
3162 );
3163 }
3164
3165 #[test]
3166 fn a_disc_revolved_a_full_turn_is_a_torus_seamed_both_ways() {
3167 let (major, minor) = (5.0_f64, 2.0_f64);
3174 let mut model = Model::new();
3175
3176 let frame = Frame::new(
3177 Point::new(major, 0.0, 0.0),
3178 -ogeom_math::Direction::Y,
3179 ogeom_math::Direction::X,
3180 T,
3181 )
3182 .unwrap();
3183 let circle = ogeom_math::Circle::new(frame, minor, T).unwrap();
3184 let start = model.add_vertex(ogeom_topo::VertexData::new(Point::new(
3185 major + minor,
3186 0.0,
3187 0.0,
3188 )));
3189 let edge = crate::build::make_edge_between(
3190 &mut model,
3191 ogeom_geom::CircleCurve::new(circle).into(),
3192 (0.0, TAU),
3193 &start,
3194 &start,
3195 T,
3196 )
3197 .unwrap()
3198 .shape;
3199 let surface = model
3200 .geometry_mut()
3201 .add_surface(ogeom_geom::PlaneSurface::new(ogeom_math::Plane::new(frame)).into());
3202 crate::attach_pcurve(
3203 &mut model,
3204 &edge,
3205 ogeom_geom::Circle2d::new(
3206 ogeom_math::Circle2::new(
3207 ogeom_math::Frame2::new(Point2::ORIGIN, ogeom_math::Direction2::X),
3208 minor,
3209 T,
3210 )
3211 .unwrap(),
3212 )
3213 .into(),
3214 surface,
3215 ogeom_topo::Location::identity(),
3216 (0.0, TAU),
3217 )
3218 .unwrap();
3219 let wire = crate::make_wire(&mut model, std::slice::from_ref(&edge), T)
3220 .unwrap()
3221 .shape;
3222 let profile = crate::make_face_on(&mut model, surface, std::slice::from_ref(&wire), T)
3223 .unwrap()
3224 .shape;
3225
3226 let built = make_revolution(&mut model, &profile, Axis::Z, TAU, T).unwrap();
3227 let primitive = crate::make_torus(&mut model, Frame::WORLD, major, minor, T).unwrap();
3228
3229 let counts = |shape: &Shape, kind| explore_unique(&model, shape, kind).unwrap().len();
3230 for kind in [ShapeType::Face, ShapeType::Edge, ShapeType::Vertex] {
3231 assert_eq!(
3232 counts(&built.shape, kind),
3233 counts(&primitive.shape, kind),
3234 "{kind:?} count differs from make_torus's"
3235 );
3236 }
3237 assert_eq!(
3238 counts(&built.shape, ShapeType::Edge),
3239 2,
3240 "one seam each way"
3241 );
3242 assert_eq!(
3243 surface_kinds(&model, &built.shape),
3244 surface_kinds(&model, &primitive.shape),
3245 "a revolved disc is the torus make_torus builds, and should say so"
3246 );
3247
3248 let shell = explore_unique(&model, &built.shape, ShapeType::Shell).unwrap()[0].clone();
3249 assert!(is_shell_closed(&model, &shell).unwrap());
3250 assert!(
3251 crate::check_tessellation(&model, &built.shape, deflection(0.02), T)
3252 .unwrap()
3253 .is_valid()
3254 );
3255
3256 let exact = 2.0 * std::f64::consts::PI * std::f64::consts::PI * major * minor * minor;
3257 let mesh = triangulate(&model, &built.shape, deflection(0.02), T).unwrap();
3258 assert!(mesh.is_closed(), "the mesh has a slit in it");
3259 assert!(mesh.volume() > 0.0, "the solid is inside out");
3260 assert!(
3261 mesh.volume() > exact * 0.99 && mesh.volume() < exact,
3262 "{} against {exact}",
3263 mesh.volume()
3264 );
3265 }
3266
3267 #[test]
3268 fn a_full_turn_consumes_the_profile_face_but_not_its_edges() {
3269 let mut model = Model::new();
3275 let profile = ring_profile(&mut model, 3.0, 2.0);
3276 let edge = model
3277 .children_of(&model.children_of(&profile).unwrap()[0])
3278 .unwrap()[0]
3279 .clone();
3280
3281 let built = make_revolution(&mut model, &profile, Axis::Z, TAU, T).unwrap();
3282 assert!(
3283 built.history.is_deleted(&profile),
3284 "the profile is interior"
3285 );
3286 assert!(!built.history.is_deleted(&edge), "its edges are not");
3287 assert_eq!(
3288 built.history.generated(&edge).len(),
3289 1,
3290 "the lateral face it made"
3291 );
3292
3293 let mut model = Model::new();
3296 let profile = ring_profile(&mut model, 3.0, 2.0);
3297 let partial = make_revolution(&mut model, &profile, Axis::Z, 1.0, T).unwrap();
3298 assert!(!partial.history.is_deleted(&profile));
3299 }
3300
3301 #[test]
3302 fn a_profile_crossing_the_axis_is_refused_rather_than_swept_through_itself() {
3303 let mut model = Model::new();
3306 let profile = profile_from(
3307 &mut model,
3308 &[
3309 Point::new(-1.0, 0.0, 0.0),
3310 Point::new(2.0, 0.0, 0.0),
3311 Point::new(2.0, 0.0, 1.0),
3312 Point::new(-1.0, 0.0, 1.0),
3313 ],
3314 );
3315 let err = make_revolution(&mut model, &profile, Axis::Z, TAU, T).unwrap_err();
3316 assert!(
3317 err.to_string().contains("through itself"),
3318 "unexpected message: {err}"
3319 );
3320
3321 let mut model = Model::new();
3326 let grazing = profile_from(
3327 &mut model,
3328 &[
3329 Point::new(-1.0, 0.0, 0.0),
3330 Point::new(2.0, 0.0, 0.0),
3331 Point::new(2.0, 0.0, 3.0),
3332 Point::new(-1.0, 0.0, 3.0),
3333 ],
3334 );
3335 assert!(make_revolution(&mut model, &grazing, Axis::Z, TAU, T).is_err());
3336 }
3337
3338 #[test]
3339 fn a_profile_grazing_the_axis_between_samples_is_refused_exactly() {
3340 let mut model = Model::new();
3348 let frame = Frame::new(
3349 Point::new(1.0, 0.0, 0.0),
3350 -ogeom_math::Direction::Y,
3351 ogeom_math::Direction::X,
3352 T,
3353 )
3354 .unwrap();
3355 let surface = model
3356 .geometry_mut()
3357 .add_surface(ogeom_geom::PlaneSurface::new(ogeom_math::Plane::new(frame)).into());
3358 let flat = |p: ogeom_math::Point| {
3359 let l = frame.to_local(p);
3360 Point2::new(l.x, l.y)
3361 };
3362
3363 let controls = [
3364 Point::new(1.0, 0.0, 0.0),
3365 Point::new(-2.0, 0.0, 0.5),
3366 Point::new(4.0, 0.0, 1.0),
3367 ];
3368 let corners = [
3369 controls[0],
3370 controls[2],
3371 Point::new(5.0, 0.0, 1.0),
3372 Point::new(5.0, 0.0, 0.0),
3373 ];
3374 let vertices: Vec<Shape> = corners
3375 .iter()
3376 .map(|p| model.add_vertex(ogeom_topo::VertexData::new(*p)))
3377 .collect();
3378
3379 let knots = ogeom_math::KnotVector::new(vec![0.0, 0.0, 0.0, 1.0, 1.0, 1.0], 2).unwrap();
3380 let dip: ogeom_geom::Curve =
3381 ogeom_geom::BSplineCurve::new(knots.clone(), controls.to_vec(), T)
3382 .unwrap()
3383 .into();
3384 let mut edges = vec![
3385 crate::build::make_edge_between(
3386 &mut model,
3387 dip,
3388 (0.0, 1.0),
3389 &vertices[0],
3390 &vertices[1],
3391 T,
3392 )
3393 .unwrap()
3394 .shape,
3395 ];
3396 crate::attach_pcurve(
3397 &mut model,
3398 &edges[0],
3399 ogeom_geom::BSpline2d::new(knots, controls.iter().map(|p| flat(*p)).collect(), T)
3400 .unwrap()
3401 .into(),
3402 surface,
3403 ogeom_topo::Location::identity(),
3404 (0.0, 1.0),
3405 )
3406 .unwrap();
3407 for i in 1..corners.len() {
3408 let (a, b) = (corners[i], corners[(i + 1) % corners.len()]);
3409 let edge = crate::build::make_edge_between(
3410 &mut model,
3411 ogeom_geom::LineCurve::segment(a, b, T).unwrap().into(),
3412 (0.0, a.distance(b)),
3413 &vertices[i],
3414 &vertices[(i + 1) % corners.len()],
3415 T,
3416 )
3417 .unwrap()
3418 .shape;
3419 crate::attach_pcurve(
3420 &mut model,
3421 &edge,
3422 Line2d::segment(flat(a), flat(b), T).unwrap().into(),
3423 surface,
3424 ogeom_topo::Location::identity(),
3425 (0.0, a.distance(b)),
3426 )
3427 .unwrap();
3428 edges.push(edge);
3429 }
3430 let wire = crate::build::make_wire(&mut model, &edges, T)
3431 .unwrap()
3432 .shape;
3433 let profile = crate::build::make_face_on(&mut model, surface, &[wire], T)
3434 .unwrap()
3435 .shape;
3436
3437 let err = make_revolution(&mut model, &profile, Axis::Z, TAU, T).unwrap_err();
3438 assert!(
3439 err.to_string().contains("touches the axis"),
3440 "unexpected message: {err}"
3441 );
3442 }
3443
3444 #[test]
3445 fn a_turn_that_goes_nowhere_or_too_far_is_refused() {
3446 let mut model = Model::new();
3447 let profile = ring_profile(&mut model, 3.0, 2.0);
3448 for angle in [0.0, -1.0, TAU * 1.5, f64::NAN, f64::INFINITY] {
3449 assert!(
3450 make_revolution(&mut model, &profile, Axis::Z, angle, T).is_err(),
3451 "accepted {angle}"
3452 );
3453 }
3454 }
3455
3456 #[test]
3457 fn a_square_swept_upward_is_a_box() {
3458 let mut model = Model::new();
3459 let face = square(&mut model, 2.0);
3460 let built = make_prism(&mut model, &face, Vector::new(0.0, 0.0, 3.0), T).unwrap();
3461
3462 let counts = |kind| explore_unique(&model, &built.shape, kind).unwrap().len();
3463 assert_eq!(counts(ShapeType::Face), 6);
3464 assert_eq!(counts(ShapeType::Edge), 12);
3465 assert_eq!(counts(ShapeType::Vertex), 8);
3466
3467 let shell = explore_unique(&model, &built.shape, ShapeType::Shell).unwrap()[0].clone();
3468 assert!(is_shell_closed(&model, &shell).unwrap());
3469
3470 let props = volume_properties(&model, &built.shape, deflection(0.01), T).unwrap();
3471 assert_relative_eq!(props.mass, 12.0, epsilon = 1e-9);
3472 }
3473
3474 #[test]
3475 fn the_far_end_is_the_same_topology_at_a_different_place() {
3476 let mut model = Model::new();
3480 let face = square(&mut model, 1.0);
3481 let before = model.node_count();
3482 let built = make_prism(&mut model, &face, Vector::new(0.0, 0.0, 1.0), T).unwrap();
3483
3484 let faces = explore_unique(&model, &built.shape, ShapeType::Face).unwrap();
3485 let ends: Vec<&Shape> = faces.iter().filter(|f| f.is_partner(&face)).collect();
3486 assert_eq!(ends.len(), 2, "both ends share the profile's node");
3487 assert!(
3488 !ends[0].is_same(ends[1]),
3489 "and are still distinct, because their placements differ"
3490 );
3491
3492 assert!(
3495 model.node_count() - before < 20,
3496 "sweeping copied more than it should have: {} new nodes",
3497 model.node_count() - before
3498 );
3499 }
3500
3501 #[test]
3502 fn a_swept_edge_is_reported_as_both_surviving_and_generating() {
3503 let mut model = Model::new();
3507 let face = square(&mut model, 1.0);
3508 let edge = model
3509 .children_of(&model.children_of(&face).unwrap()[0])
3510 .unwrap()[0]
3511 .clone();
3512
3513 let built = make_prism(&mut model, &face, Vector::new(0.0, 0.0, 1.0), T).unwrap();
3514 let generated = built.history.generated(&edge);
3515 assert_eq!(
3516 generated.len(),
3517 2,
3518 "the lateral face and the displaced edge, got {generated:?}"
3519 );
3520 assert!(!built.history.is_deleted(&edge), "the edge survives");
3521 }
3522
3523 #[test]
3524 fn an_arc_sweeps_into_a_cylindrical_face_not_a_flat_one() {
3525 let mut model = Model::new();
3529 let (radius, height) = (2.0_f64, 5.0);
3530 let cylinder = crate::make_cylinder(&mut model, Frame::WORLD, radius, 1.0, T).unwrap();
3531 let rim = explore_unique(&model, &cylinder.shape, ShapeType::Edge)
3532 .unwrap()
3533 .into_iter()
3534 .find(|e| {
3535 model
3536 .node(e)
3537 .and_then(|n| n.data().as_edge())
3538 .and_then(ogeom_topo::EdgeData::curve3d)
3539 .is_some_and(|r| matches!(r, EdgeRepr::Curve3d { range, .. } if range.1 > 6.0))
3540 })
3541 .expect("the cylinder has a full circular rim");
3542
3543 let built = make_prism(&mut model, &rim, Vector::new(0.0, 0.0, height), T).unwrap();
3544 assert_eq!(model.kind_of(&built.shape).unwrap(), ShapeType::Face);
3545
3546 let mesh = triangulate(&model, &built.shape, deflection(0.005), T).unwrap();
3547 let area = mesh.area();
3548 let exact = std::f64::consts::TAU * radius * height;
3549 assert!(
3550 area < exact,
3551 "an inscribed area cannot exceed the surface's"
3552 );
3553 assert!(area > exact * 0.999, "{area} against {exact}");
3554 }
3555
3556 #[test]
3557 fn a_wire_sweeps_into_an_open_shell() {
3558 let mut model = Model::new();
3559 let face = square(&mut model, 2.0);
3560 let wire = model.children_of(&face).unwrap()[0].clone();
3561
3562 let built = make_prism(&mut model, &wire, Vector::new(0.0, 0.0, 3.0), T).unwrap();
3563 assert_eq!(model.kind_of(&built.shape).unwrap(), ShapeType::Shell);
3564 assert_eq!(
3565 explore_unique(&model, &built.shape, ShapeType::Face)
3566 .unwrap()
3567 .len(),
3568 4,
3569 "one side per edge, and no ends"
3570 );
3571 }
3572
3573 #[test]
3574 fn a_sweep_that_goes_nowhere_is_refused() {
3575 let mut model = Model::new();
3576 let face = square(&mut model, 1.0);
3577 for vector in [
3578 Vector::ZERO,
3579 Vector::new(f64::NAN, 0.0, 0.0),
3580 Vector::new(0.0, 0.0, f64::INFINITY),
3581 ] {
3582 assert!(make_prism(&mut model, &face, vector, T).is_err());
3583 }
3584 }
3585
3586 #[test]
3587 fn a_profile_that_has_been_placed_sweeps_where_it_actually_sits() {
3588 let mut model = Model::new();
3592 let face = square(&mut model, 2.0);
3593 let moved = crate::transformed(
3594 &mut model,
3595 &face,
3596 Transform::translation(Vector::new(10.0, 0.0, 0.0)),
3597 )
3598 .unwrap()
3599 .shape;
3600
3601 let built = make_prism(&mut model, &moved, Vector::new(0.0, 0.0, 3.0), T).unwrap();
3602 let mesh = triangulate(&model, &built.shape, deflection(0.01), T).unwrap();
3603 assert!(mesh.is_closed(), "the mesh has a slit in it");
3604 assert_relative_eq!(mesh.volume(), 12.0, epsilon = 1e-9);
3605
3606 let props = volume_properties(&model, &built.shape, deflection(0.01), T).unwrap();
3607 assert!(
3608 props.centre.distance(Point::new(11.0, 1.0, 3.5)) < 1e-9,
3609 "got {:?}",
3610 props.centre
3611 );
3612 }
3613
3614 #[test]
3615 fn a_profile_placed_with_a_scale_sweeps_at_the_size_it_is_now() {
3616 let mut model = Model::new();
3622 let face = square(&mut model, 2.0);
3623 let scaled = crate::transformed(
3624 &mut model,
3625 &face,
3626 Transform::scaling(Point::ORIGIN, 2.0, T).unwrap(),
3627 )
3628 .unwrap()
3629 .shape;
3630
3631 let built = make_prism(&mut model, &scaled, Vector::new(0.0, 0.0, 3.0), T).unwrap();
3632 let mesh = triangulate(&model, &built.shape, deflection(0.01), T).unwrap();
3633 assert!(mesh.is_closed(), "the mesh has a slit in it");
3634 assert_relative_eq!(mesh.volume(), 48.0, epsilon = 1e-9);
3636 }
3637
3638 #[test]
3639 fn a_face_swept_within_its_own_plane_is_refused() {
3640 let mut model = Model::new();
3644 let face = square(&mut model, 1.0);
3645 let err = make_prism(&mut model, &face, Vector::new(1.0, 1.0, 0.0), T).unwrap_err();
3646 assert!(
3647 err.to_string().contains("encloses no volume"),
3648 "unexpected message: {err}"
3649 );
3650 let wire = model.children_of(&face).unwrap()[0].clone();
3653 assert!(make_prism(&mut model, &wire, Vector::new(1.0, 1.0, 0.0), T).is_ok());
3654 }
3655
3656 #[test]
3657 fn a_vertex_is_not_something_this_sweeps() {
3658 let mut model = Model::new();
3662 let vertex = model.add_point(Point::ORIGIN);
3663 assert!(make_prism(&mut model, &vertex, Vector::Z, T).is_err());
3664 }
3665
3666 #[test]
3669 fn a_hole_wound_either_way_revolves_hollow() {
3670 use core::f64::consts::{PI, TAU};
3671 let upright = Frame::new(
3672 Point::ORIGIN,
3673 -ogeom_math::Direction::Y,
3674 ogeom_math::Direction::X,
3675 T,
3676 )
3677 .unwrap();
3678 for (hole_normal, turn) in [(1.0, TAU), (-1.0, TAU), (1.0, 2.0), (-1.0, 2.0)] {
3679 let mut model = Model::new();
3680 let corners: Vec<Point> = [(5.0, 0.0), (9.0, 0.0), (9.0, 4.0), (5.0, 4.0)]
3681 .iter()
3682 .map(|&(x, y)| upright.to_world(Point::new(x, y, 0.0)))
3683 .collect();
3684 let outline = crate::build::make_polygon(&mut model, &corners, true, T)
3685 .unwrap()
3686 .shape;
3687 let seat = Frame::new(
3688 upright.to_world(Point::new(7.0, 2.0, 0.0)),
3689 ogeom_math::Direction::new(upright.z().vector() * hole_normal, T).unwrap(),
3690 upright.x(),
3691 T,
3692 )
3693 .unwrap();
3694 let circle = Circle::new(seat, 1.0, T).unwrap();
3695 let ring = crate::build::make_edge(
3696 &mut model,
3697 ogeom_geom::CircleCurve::new(circle).into(),
3698 (0.0, TAU),
3699 T,
3700 )
3701 .unwrap()
3702 .shape;
3703 let hole = make_wire(&mut model, &[ring], T).unwrap().shape;
3704 let profile = crate::build::make_face(
3705 &mut model,
3706 ogeom_geom::PlaneSurface::new(ogeom_math::Plane::new(upright)).into(),
3707 &[outline, hole],
3708 T,
3709 )
3710 .unwrap()
3711 .shape;
3712 let axis = ogeom_math::Axis::new(Point::ORIGIN, ogeom_math::Direction::Z);
3713 let built = crate::make_revolution(&mut model, &profile, axis, turn, T).unwrap();
3714 let volume = volume_properties(&model, &built.shape, Deflection::default(), T)
3715 .unwrap()
3716 .mass;
3717 let want = turn * 7.0 * (16.0 - PI);
3719 assert_relative_eq!(volume, want, max_relative = 1e-9);
3720 }
3721 }
3722}