1use std::collections::HashMap;
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::{make_face_on, make_shell, make_solid, make_wire};
36use crate::history::{Built, History};
37
38pub mod roles {
40 use ogeom_core::Role;
41
42 pub const SWEEP_BOTTOM: Role = Role::op_defined(20);
44 pub const SWEEP_TOP: Role = Role::op_defined(21);
46 pub const SWEEP_SIDE: Role = Role::op_defined(22);
48 pub const SWEEP_RAIL: Role = Role::op_defined(23);
50}
51
52type ChartMap = Box<dyn Fn((f64, f64)) -> (f64, f64)>;
54
55pub fn make_prism(
67 model: &mut Model,
68 profile: &Shape,
69 vector: Vector,
70 tol: Tolerances,
71) -> OgeomResult<Built> {
72 if !vector.is_finite() || vector.magnitude() <= tol.confusion() {
73 ogeom_bail!(
74 Construction,
75 "a prism needs a direction to travel; {vector:?} has no length"
76 );
77 }
78 model.begin_operation();
79
80 let datum = model.add_datum(Transform::translation(vector));
85 let displacement = Location::of(datum);
86
87 let rails = &mut Rails::new();
88 match model.kind_of(profile)? {
89 ShapeType::Face => {
90 crate::build::trimmed_where_bare(model, profile, tol)?;
91 prism_over_face(model, rails, profile, &displacement, vector, tol)
92 }
93 ShapeType::Wire => {
94 let (faces, history) =
95 prism_over_wire(model, rails, profile, &displacement, vector, tol)?;
96 let shell = make_shell(model, &faces)?.shape;
97 Ok(Built::new(shell, history))
98 }
99 ShapeType::Edge => {
100 let (face, history) =
101 prism_over_edge(model, rails, profile, &displacement, vector, tol)?;
102 Ok(Built::new(face, history))
103 }
104 other => ogeom_bail!(
105 Construction,
106 "a {other:?} cannot be swept into anything; sweep an edge, a wire or \
107 a face"
108 ),
109 }
110}
111
112pub fn make_prism_tapered(
131 model: &mut Model,
132 profile: &Shape,
133 vector: Vector,
134 taper: f64,
135 tol: Tolerances,
136) -> OgeomResult<Built> {
137 use ogeom_geom::Curve;
138
139 if !taper.is_finite() || taper.abs() <= tol.angular() {
140 return make_prism(model, profile, vector, tol);
141 }
142 if taper.abs() >= core::f64::consts::FRAC_PI_2 - tol.angular() {
143 ogeom_bail!(Construction, "a taper of {taper} flattens the prism");
144 }
145 let travel = vector.magnitude();
146 if !travel.is_finite() || travel <= tol.confusion() {
147 ogeom_bail!(Construction, "a sweep along {vector:?} goes nowhere");
148 }
149 if model.kind_of(profile)? != ShapeType::Face {
150 ogeom_bail!(
151 Construction,
152 "a tapered prism encloses volume; sweep a planar face"
153 );
154 }
155 let Some(plane) = crate::build::find_plane(model, profile, tol)? else {
156 ogeom_bail!(Construction, "a tapered prism sweeps a planar face");
157 };
158 let mut normal = plane.normal().vector();
159 if normal.cross(vector / travel).magnitude() > tol.angular().max(1e-9) {
160 ogeom_bail!(
161 Construction,
162 "an oblique tapered sweep is ambiguous about its own sections; \
163 the travel must run square to the profile"
164 );
165 }
166 if normal.dot(vector) < 0.0 {
169 normal = -normal;
170 }
171 let up = Direction::new(normal, tol)?;
172 let spread = travel * taper.tan();
173
174 let mut history = History::new();
175 let mut faces: Vec<Shape> = Vec::new();
176 let mut near_wires: Vec<Shape> = Vec::new();
177 let mut far_wires: Vec<Shape> = Vec::new();
178 for wire in model.children_of(profile)? {
179 let edges = model.ordered_children_of(&wire)?;
180 let lone_circle = edges.len() == 1 && {
183 let (curve, _) = edge_geometry(model, &edges[0])?;
184 matches!(curve, Curve::Circle(_))
185 };
186 if lone_circle {
187 let (curve, range) = edge_geometry(model, &edges[0])?;
188 let Curve::Circle(c) = curve else {
189 unreachable!("just matched")
190 };
191 let circle = c.circle();
192 let start = c.point_at(range.0, tol)?;
196 let radial = (start - circle.centre()) / circle.radius();
197 let sigma = away_sign(model, profile, start, radial, circle.radius(), tol)?;
198 let far_radius = sigma.mul_add(spread, circle.radius());
199 if far_radius <= tol.confusion() {
200 ogeom_bail!(
201 Construction,
202 "the taper collapses a circular loop of radius {} over \
203 this height",
204 circle.radius()
205 );
206 }
207 let near_frame = Frame::new(circle.centre(), up, circle.frame().x(), tol)?;
208 let far_frame = Frame::new(circle.centre() + vector, up, circle.frame().x(), tol)?;
209 let near_curve: Curve =
210 ogeom_geom::CircleCurve::new(Circle::new(near_frame, circle.radius(), tol)?).into();
211 let near_domain = near_curve.domain();
212 let near = crate::build::make_edge(model, near_curve, near_domain, tol)?.shape;
213 let far_curve: Curve =
214 ogeom_geom::CircleCurve::new(Circle::new(far_frame, far_radius, tol)?).into();
215 let far_domain = far_curve.domain();
216 let far = crate::build::make_edge(model, far_curve, far_domain, tol)?.shape;
217 let slope = (far_radius - circle.radius()) / travel;
222 let cone = ogeom_math::Cone::new(near_frame, circle.radius(), slope.atan(), tol)?;
223 let pad = travel * 0.1;
224 let surface: ogeom_geom::SurfaceGeometry =
225 ogeom_geom::ConeSurface::new(cone, (-pad, travel + pad))?.into();
226 let band = crate::build::make_revolution_band(model, &surface, &near, &far, tol)?;
227 let wall = if sigma > 0.0 { band } else { band.reversed() };
228 model.set_derived(&wall, std::slice::from_ref(&edges[0]), roles::SWEEP_SIDE)?;
229 history.generate(&edges[0], wall.clone());
230 faces.push(wall);
231 near_wires.push(make_wire(model, std::slice::from_ref(&near), tol)?.shape);
232 far_wires.push(make_wire(model, std::slice::from_ref(&far), tol)?.shape);
233 continue;
234 }
235
236 let mut corners_near: Vec<Point> = Vec::new();
239 let mut aways: Vec<Vector> = Vec::new();
240 let mut dirs: Vec<Vector> = Vec::new();
241 for edge in &edges {
242 let (curve, range) = edge_geometry(model, edge)?;
243 let Curve::Line(_) = curve else {
244 ogeom_bail!(
245 Construction,
246 "a tapered wall over an edge that is neither straight nor \
247 a full circle needs a fitted ruling; see docs/PARITY.md, \
248 offset.sweeps"
249 );
250 };
251 let reversed = edge.orientation() == Orientation::Reversed;
252 let (t0, t1) = if reversed {
253 (range.1, range.0)
254 } else {
255 (range.0, range.1)
256 };
257 let from = curve.point_at(t0, tol)?;
258 let to = curve.point_at(t1, tol)?;
259 let dir = (to - from) / from.distance(to);
260 corners_near.push(from);
261 dirs.push(dir);
262 aways.push(dir.cross(up.vector()));
263 }
264 let count = corners_near.len();
265 if count < 3 {
266 ogeom_bail!(Construction, "a straight loop needs at least three edges");
267 }
268 let flip = {
271 let mid = corners_near[0] + dirs[0] * (corners_near[0].distance(corners_near[1]) / 2.0);
272 let scale = corners_near[0].distance(corners_near[1]);
273 away_sign(model, profile, mid, aways[0], scale, tol)?
274 };
275 if flip < 0.0 {
276 for a in &mut aways {
277 *a = -*a;
278 }
279 }
280 let mut corners_far: Vec<Point> = Vec::with_capacity(count);
283 for i in 0..count {
284 let prev = (i + count - 1) % count;
285 let (d0, d1) = (dirs[prev], dirs[i]);
286 let (a0, a1) = (aways[prev], aways[i]);
287 let p0 = corners_near[i] + a0 * spread + vector;
288 let p1 = corners_near[i] + a1 * spread + vector;
289 let cross = d0.cross(d1);
290 let m = cross.magnitude();
291 let far = if m <= tol.angular() {
292 p1
294 } else {
295 let w = p1 - p0;
297 let s = w.cross(d1).dot(cross) / (m * m);
298 p0 + d0 * s
299 };
300 corners_far.push(far);
301 }
302 for (i, far) in corners_far.iter().enumerate() {
303 let next = corners_far[(i + 1) % count];
304 let d = next - *far;
305 if d.magnitude() <= tol.confusion() || d.dot(dirs[i]) <= 0.0 {
306 ogeom_bail!(
307 Construction,
308 "the taper collapses the profile's loop over this height"
309 );
310 }
311 }
312
313 let near_vertices: Vec<Shape> = corners_near
314 .iter()
315 .map(|p| crate::build::make_vertex(model, *p).shape)
316 .collect();
317 let far_vertices: Vec<Shape> = corners_far
318 .iter()
319 .map(|p| crate::build::make_vertex(model, *p).shape)
320 .collect();
321 let segment =
322 |model: &mut Model, from: (&Shape, Point), to: (&Shape, Point)| -> OgeomResult<Shape> {
323 let line = ogeom_geom::LineCurve::segment(from.1, to.1, tol)?;
324 let curve: Curve = line.into();
325 let domain = curve.domain();
326 Ok(crate::build::make_edge_between(model, curve, domain, from.0, to.0, tol)?.shape)
327 };
328 let mut near_edges = Vec::with_capacity(count);
329 let mut far_edges = Vec::with_capacity(count);
330 let mut rails = Vec::with_capacity(count);
331 for i in 0..count {
332 let next = (i + 1) % count;
333 near_edges.push(segment(
334 model,
335 (&near_vertices[i], corners_near[i]),
336 (&near_vertices[next], corners_near[next]),
337 )?);
338 far_edges.push(segment(
339 model,
340 (&far_vertices[i], corners_far[i]),
341 (&far_vertices[next], corners_far[next]),
342 )?);
343 rails.push(segment(
344 model,
345 (&near_vertices[i], corners_near[i]),
346 (&far_vertices[i], corners_far[i]),
347 )?);
348 }
349 for (i, edge) in edges.iter().enumerate() {
350 let next = (i + 1) % count;
351 let outward = {
355 let lean = aways[i] * taper.cos() - up.vector() * taper.sin();
356 Direction::new(lean, tol)?
357 };
358 let wall_plane = ogeom_math::Plane::through(corners_near[i], outward);
359 let mut reach = travel + 1.0_f64;
360 for p in [
361 corners_near[i],
362 corners_near[next],
363 corners_far[i],
364 corners_far[next],
365 ] {
366 reach = reach.max(p.distance(corners_near[i]) * 2.0);
367 }
368 let surface: ogeom_geom::SurfaceGeometry =
369 ogeom_geom::PlaneSurface::over(wall_plane, (-reach, reach), (-reach, reach))?
370 .into();
371 let wall = crate::build::make_face_with_pcurves(
372 model,
373 surface,
374 &[vec![
375 near_edges[i].clone(),
376 rails[next].clone(),
377 far_edges[i].reversed(),
378 rails[i].reversed(),
379 ]],
380 tol,
381 )?
382 .shape;
383 model.set_derived(&wall, std::slice::from_ref(edge), roles::SWEEP_SIDE)?;
384 history.generate(edge, wall.clone());
385 faces.push(wall);
386 }
387 near_wires.push(make_wire(model, &near_edges, tol)?.shape);
388 far_wires.push(make_wire(model, &far_edges, tol)?.shape);
389 }
390
391 let near_surface: ogeom_geom::SurfaceGeometry = {
394 let base = ogeom_math::Plane::through(plane.origin(), up);
395 ogeom_geom::PlaneSurface::over(base, (-1e6, 1e6), (-1e6, 1e6))?.into()
396 };
397 let far_surface: ogeom_geom::SurfaceGeometry = {
398 let lifted = ogeom_math::Plane::through(plane.origin() + vector, up);
399 ogeom_geom::PlaneSurface::over(lifted, (-1e6, 1e6), (-1e6, 1e6))?.into()
400 };
401 let bottom = crate::build::make_face(model, near_surface, &near_wires, tol)?
402 .shape
403 .reversed();
404 let top = crate::build::make_face(model, far_surface, &far_wires, tol)?.shape;
405 attach_cap_pcurves(model, &bottom, tol)?;
406 attach_cap_pcurves(model, &top, tol)?;
407 model.set_derived(&bottom, std::slice::from_ref(profile), roles::SWEEP_BOTTOM)?;
408 model.set_derived(&top, std::slice::from_ref(profile), roles::SWEEP_TOP)?;
409 history.generate(profile, top.clone());
410 faces.push(bottom);
411 faces.push(top);
412
413 let sewn = crate::sew(model, &faces, tol)?;
414 if sewn.shells.len() != 1 || !crate::build::is_shell_closed(model, &sewn.shells[0])? {
415 ogeom_bail!(Construction, "the tapered prism did not close");
416 }
417 let solid = make_solid(model, std::slice::from_ref(&sewn.shells[0]))?.shape;
418 history.generate(profile, solid.clone());
419 Ok(Built::new(solid, history))
420}
421
422fn away_sign(
425 model: &Model,
426 face: &Shape,
427 at: Point,
428 candidate: Vector,
429 scale: f64,
430 tol: Tolerances,
431) -> OgeomResult<f64> {
432 for factor in [1e-3, 1e-2, 5e-2] {
433 let eps = scale * factor;
434 let deflection = ogeom_mesh::Deflection {
435 chord: eps * 0.1,
436 ..ogeom_mesh::Deflection::default()
437 };
438 for sign in [1.0_f64, -1.0] {
439 let probe = at + candidate * (sign * eps);
440 if crate::classify_on_face(model, face, probe, deflection, tol)?
441 == crate::Containment::In
442 {
443 return Ok(-sign);
445 }
446 }
447 }
448 ogeom_bail!(
449 Construction,
450 "cannot read which side of the profile the material is on"
451 )
452}
453
454fn edge_geometry(model: &Model, edge: &Shape) -> OgeomResult<(ogeom_geom::Curve, (f64, f64))> {
456 let Some(data) = model.node(edge).and_then(|n| n.data().as_edge()) else {
457 ogeom_bail!(Construction, "an edge holds no data");
458 };
459 let Some(EdgeRepr::Curve3d { curve, range, .. }) = data.curve3d() else {
460 ogeom_bail!(Construction, "an edge has no curve");
461 };
462 let Some(geometry) = model.geometry().curve(*curve) else {
463 ogeom_bail!(Dangling, "curve is not in this model");
464 };
465 Ok((geometry.clone(), *range))
466}
467
468fn attach_cap_pcurves(model: &mut Model, cap: &Shape, tol: Tolerances) -> OgeomResult<()> {
470 let cap_id = {
471 let Some(node) = model.node(cap) else {
472 ogeom_bail!(Dangling, "the cap just built is not in this model");
473 };
474 let NodeData::Face(data) = node.data() else {
475 ogeom_bail!(Construction, "the cap holds no face data");
476 };
477 data.surface
478 };
479 let Some(surface) = model.geometry().surface(cap_id).cloned() else {
480 ogeom_bail!(Dangling, "the cap's surface is not in this model");
481 };
482 for edge in ogeom_topo::explore(model, cap, ogeom_topo::Filter::OfType(ShapeType::Edge))? {
483 let (curve, range) = edge_geometry(model, &edge)?;
484 let Some(pcurve) = ogeom_intersect::exact_pcurve_of(&curve, &surface, tol) else {
485 ogeom_bail!(Construction, "a cap edge has no closed-form pcurve");
486 };
487 crate::build::attach_pcurve(model, &edge, pcurve, cap_id, Location::identity(), range)?;
488 }
489 Ok(())
490}
491
492fn prism_over_face(
494 model: &mut Model,
495 rails: &mut Rails,
496 face: &Shape,
497 displacement: &Location,
498 vector: Vector,
499 tol: Tolerances,
500) -> OgeomResult<Built> {
501 let (_, normal) = crate::measure::face_normal(model, face, tol)?;
511 let travel = vector.magnitude();
512 let along = normal.dot(vector) / travel;
513 if along.abs() <= tol.angular() {
514 ogeom_bail!(
515 Construction,
516 "the sweep runs along the profile's own surface, so it encloses no \
517 volume; a face swept within its own plane is not a solid"
518 );
519 }
520 let profile = if along < 0.0 {
521 face.reversed()
522 } else {
523 face.clone()
524 };
525
526 let mut history = History::new();
527 let mut faces = Vec::new();
528
529 let wires = model.children_of(&profile)?;
537 let turns: Vec<f64> = wires
538 .iter()
539 .map(|w| wire_turn(model, w, vector, tol))
540 .collect::<OgeomResult<_>>()?;
541 let outer = turns
542 .iter()
543 .enumerate()
544 .max_by(|a, b| {
545 a.1.abs()
546 .partial_cmp(&b.1.abs())
547 .unwrap_or(core::cmp::Ordering::Equal)
548 })
549 .map_or(0, |(i, _)| i);
550 for (index, wire) in wires.iter().enumerate() {
551 let (sides, wire_history) = prism_over_wire(model, rails, wire, displacement, vector, tol)?;
552 history = history.then(&wire_history);
553 let wanted = if index == outer { 1.0 } else { -1.0 };
554 if turns[index] * wanted < 0.0 {
555 faces.extend(sides.into_iter().map(|f| f.reversed()));
556 } else {
557 faces.extend(sides);
558 }
559 }
560
561 let bottom = profile.reversed();
566 let top = profile.moved(displacement);
567 model.set_derived(&bottom, std::slice::from_ref(face), roles::SWEEP_BOTTOM)?;
568 model.set_derived(&top, std::slice::from_ref(face), roles::SWEEP_TOP)?;
569 history.generate(face, top.clone());
570 faces.push(bottom);
571 faces.push(top);
572
573 let shell = make_shell(model, &faces)?.shape;
574 let solid = make_solid(model, std::slice::from_ref(&shell))?.shape;
575 history.generate(face, solid.clone());
576 Ok(Built::new(solid, history))
577}
578
579fn wire_turn(model: &Model, wire: &Shape, axis: Vector, tol: Tolerances) -> OgeomResult<f64> {
583 use ogeom_geom::Curve3d as _;
584 let mut points: Vec<ogeom_math::Point> = Vec::new();
585 for edge in model.ordered_children_of(wire)? {
586 let Some(EdgeRepr::Curve3d { curve, range, .. }) = model
587 .node(&edge)
588 .and_then(|n| n.data().as_edge())
589 .and_then(|d| d.curve3d())
590 else {
591 continue;
592 };
593 let Some(geometry) = model.geometry().curve(*curve) else {
594 continue;
595 };
596 let placement = edge.transform(model.datums())?;
597 const SAMPLES: u32 = 16;
598 for k in 0..SAMPLES {
599 let f = f64::from(k) / f64::from(SAMPLES);
600 let t = if edge.orientation() == ogeom_topo::Orientation::Reversed {
601 range.1 + (range.0 - range.1) * f
602 } else {
603 range.0 + (range.1 - range.0) * f
604 };
605 points.push(placement.apply(geometry.point_at(t, tol)?));
606 }
607 }
608 let mut newell = Vector::ZERO;
609 for i in 0..points.len() {
610 let (a, b) = (points[i], points[(i + 1) % points.len()]);
611 newell += (a - ogeom_math::Point::ORIGIN).cross(b - ogeom_math::Point::ORIGIN);
612 }
613 Ok(newell.dot(axis))
614}
615
616fn prism_over_wire(
618 model: &mut Model,
619 rails: &mut Rails,
620 wire: &Shape,
621 displacement: &Location,
622 vector: Vector,
623 tol: Tolerances,
624) -> OgeomResult<(Vec<Shape>, History)> {
625 let mut faces = Vec::new();
626 let mut history = History::new();
627 for edge in model.ordered_children_of(wire)? {
628 let (face, edge_history) = prism_over_edge(model, rails, &edge, displacement, vector, tol)?;
629 history = history.then(&edge_history);
630 faces.push(face);
631 }
632 if faces.is_empty() {
633 ogeom_bail!(Construction, "a wire with no edges sweeps out nothing");
634 }
635 Ok((faces, history))
636}
637
638fn prism_over_edge(
645 model: &mut Model,
646 rails: &mut Rails,
647 edge: &Shape,
648 displacement: &Location,
649 vector: Vector,
650 tol: Tolerances,
651) -> OgeomResult<(Shape, History)> {
652 let Some(node) = model.node(edge) else {
653 ogeom_bail!(Dangling, "edge is not in this model");
654 };
655 let NodeData::Edge(data) = node.data() else {
656 ogeom_bail!(Construction, "edge node holds no edge data");
657 };
658 let Some(EdgeRepr::Curve3d { curve, range, .. }) = data.curve3d() else {
659 ogeom_bail!(
660 Construction,
661 "an edge with no curve in space has no shape to sweep; a degenerate \
662 edge sweeps out nothing and has to be handled by its face, not here"
663 );
664 };
665 let Some(geometry) = model.geometry().curve(*curve).cloned() else {
666 ogeom_bail!(Dangling, "curve is not in this model");
667 };
668
669 let placement = edge.transform(model.datums())?;
680 let stored = geometry.domain();
681 let geometry = geometry.transformed(&placement, tol)?;
682 let placed = geometry.domain();
683 let (lo, hi) = (
684 rescale(range.0, stored, placed),
685 rescale(range.1, stored, placed),
686 );
687 let travel = vector.magnitude();
688 let direction = ogeom_math::Direction::new(vector, tol)?;
689 let mut chart: ChartMap = Box::new(|p| p);
703 let mut turned = false;
706 let canonical: Option<ogeom_geom::SurfaceGeometry> = if let ogeom_geom::Curve::Line(line) =
707 &geometry
708 && let Ok(normal) =
709 ogeom_math::Direction::new(line.axis().direction.vector().cross(vector), tol)
710 {
711 let axis = line.axis();
712 let frame = ogeom_math::Frame::new(axis.location, normal, axis.direction, tol)?;
713 let plane = ogeom_math::Plane::new(frame);
714 let along = axis.direction.vector();
718 let across = frame.y().vector();
719 let (shear, rise) = (
720 direction.vector().dot(along),
721 direction.vector().dot(across),
722 );
723 if shear.abs() > tol.angular() {
724 chart = Box::new(move |(u, v): (f64, f64)| (u + v * shear, v * rise));
725 }
726 let margin = (hi - lo).abs().max(travel) * 0.1 + 1.0;
727 let (u_lo, u_hi) = (lo.min(hi), lo.max(hi));
728 let u_min = u_lo + travel * shear.min(0.0);
729 let u_max = u_hi + travel * shear.max(0.0);
730 Some(
731 ogeom_geom::PlaneSurface::over(
732 plane,
733 (u_min - margin, u_max + margin),
734 (-margin, travel * rise + margin),
735 )?
736 .into(),
737 )
738 } else if let ogeom_geom::Curve::Circle(c) = &geometry
739 && !c.is_reversed()
740 && c.circle()
741 .frame()
742 .z()
743 .vector()
744 .dot(direction.vector())
745 .abs()
746 >= 1.0 - tol.angular()
747 {
748 let circle = c.circle();
757 let frame = if circle.frame().z().vector().dot(direction.vector()) > 0.0 {
758 circle.frame()
759 } else {
760 chart = Box::new(|(u, v): (f64, f64)| (core::f64::consts::TAU - u, v));
761 turned = true;
764 ogeom_math::Frame::new(circle.centre(), direction, circle.frame().x(), tol)?
765 };
766 let margin = travel * 0.1 + 1.0;
767 Some(
768 ogeom_geom::CylinderSurface::new(
769 ogeom_math::Cylinder::new(frame, circle.radius(), tol)?,
770 (-margin, travel + margin),
771 )?
772 .into(),
773 )
774 } else {
775 None
776 };
777 let surface = model.geometry_mut().add_surface(match canonical {
778 Some(exact) => exact,
779 None => ExtrusionSurface::new(geometry, direction, travel)?.into(),
780 });
781
782 let reversed = edge.orientation() == Orientation::Reversed;
793 let (u_start, u_end) = if reversed { (hi, lo) } else { (lo, hi) };
794
795 let bottom = edge.clone();
799 let top = edge.moved(displacement);
800 let start_rail = rail(model, rails, edge, displacement, vector, false, tol)?;
801 let end_rail = rail(model, rails, edge, displacement, vector, true, tol)?;
802
803 pcurve(
804 model,
805 &bottom,
806 surface,
807 chart((lo, 0.0)),
808 chart((hi, 0.0)),
809 tol,
810 )?;
811 pcurve(
812 model,
813 &top,
814 surface,
815 chart((lo, travel)),
816 chart((hi, travel)),
817 tol,
818 )?;
819 if start_rail.is_same(&end_rail) {
820 seam_pcurves(
828 model,
829 &start_rail,
830 surface,
831 (chart((u_end, 0.0)), chart((u_end, travel))),
832 (chart((u_start, 0.0)), chart((u_start, travel))),
833 tol,
834 )?;
835 } else {
836 pcurve(
837 model,
838 &start_rail,
839 surface,
840 chart((u_start, 0.0)),
841 chart((u_start, travel)),
842 tol,
843 )?;
844 pcurve(
845 model,
846 &end_rail,
847 surface,
848 chart((u_end, 0.0)),
849 chart((u_end, travel)),
850 tol,
851 )?;
852 }
853
854 let ring = [
857 bottom.clone(),
858 end_rail.clone(),
859 top.reversed(),
860 start_rail.reversed(),
861 ];
862 let boundary = make_wire(model, &ring, tol)?.shape;
863 let built = make_face_on(model, surface, std::slice::from_ref(&boundary), tol)?.shape;
864
865 let face = if reversed != turned {
872 built.reversed()
873 } else {
874 built
875 };
876 model.set_derived(&face, std::slice::from_ref(edge), roles::SWEEP_SIDE)?;
877
878 let mut history = History::new();
879 history.generate(edge, face.clone());
883 history.generate(edge, top);
884 Ok((face, history))
885}
886
887struct Turn {
889 axis: Axis,
891 angle: f64,
893 full: bool,
896 displacement: Location,
899}
900
901pub fn make_revolution(
922 model: &mut Model,
923 profile: &Shape,
924 axis: Axis,
925 angle: f64,
926 tol: Tolerances,
927) -> OgeomResult<Built> {
928 if !angle.is_finite() || angle <= tol.angular() || angle > TAU + tol.angular() {
929 ogeom_bail!(
930 Construction,
931 "a revolution turns through (0, 2pi]; {angle} does not"
932 );
933 }
934 let angle = angle.min(TAU);
935 let full = TAU - angle <= tol.angular();
936 model.begin_operation();
937
938 let turn = Turn {
939 axis,
940 angle,
941 full,
942 displacement: if full {
946 Location::identity()
947 } else {
948 Location::of(model.add_datum(Transform::rotation(axis, angle)))
949 },
950 };
951
952 let rails = &mut Rails::new();
953 match model.kind_of(profile)? {
954 ShapeType::Face => revolution_over_face(model, rails, profile, &turn, tol),
955 ShapeType::Wire => {
956 let (faces, history) = revolution_over_wire(model, rails, profile, &turn, tol)?;
957 let shell = make_shell(model, &faces)?.shape;
958 Ok(Built::new(shell, history))
959 }
960 ShapeType::Edge => {
961 let (face, history) = revolution_over_edge(model, rails, profile, &turn, tol)?;
962 let Some(face) = face else {
963 ogeom_bail!(
964 Construction,
965 "an edge lying along the axis turns onto itself and sweeps \
966 out no face"
967 );
968 };
969 Ok(Built::new(face, history))
970 }
971 other => ogeom_bail!(
972 Construction,
973 "a {other:?} cannot be revolved into anything; revolve an edge, a \
974 wire or a face"
975 ),
976 }
977}
978
979fn revolution_over_face(
981 model: &mut Model,
982 rails: &mut Rails,
983 face: &Shape,
984 turn: &Turn,
985 tol: Tolerances,
986) -> OgeomResult<Built> {
987 crate::build::trimmed_where_bare(model, face, tol)?;
991 let (point, normal) = crate::measure::face_normal(model, face, tol)?;
992 let tangent = turn
993 .axis
994 .direction
995 .cross_with(point - turn.axis.project(point));
996 let reach = tangent.magnitude();
997 if reach <= tol.confusion() {
998 ogeom_bail!(
999 Construction,
1000 "the profile sits on the axis, so revolving it sweeps out nothing"
1001 );
1002 }
1003 let along = normal.dot(tangent) / reach;
1004 if along.abs() <= tol.angular() {
1005 ogeom_bail!(
1006 Construction,
1007 "the turn runs along the profile's own surface, so it encloses no \
1008 volume; a face revolved within its own plane is not a solid"
1009 );
1010 }
1011 let hand = {
1018 let mut area = ogeom_math::Vector::ZERO;
1019 let wires = model.ordered_children_of(face)?;
1020 let Some(outer) = wires.first() else {
1021 ogeom_bail!(Construction, "the profile has no boundary to revolve");
1022 };
1023 let mut walk: Vec<Point> = Vec::new();
1024 for edge in model.ordered_children_of(outer)? {
1025 let Some(data) = model.node(&edge).and_then(|n| n.data().as_edge()) else {
1026 ogeom_bail!(Construction, "a profile edge holds no data");
1027 };
1028 let Some(EdgeRepr::Curve3d { curve, range, .. }) = data.curve3d() else {
1029 ogeom_bail!(Construction, "a profile edge has no curve");
1030 };
1031 let Some(stored) = model.geometry().curve(*curve) else {
1032 ogeom_bail!(Dangling, "curve is not in this model");
1033 };
1034 let placed = stored
1035 .clone()
1036 .transformed(&edge.transform(model.datums())?, tol)?;
1037 let flipped = edge.orientation() == Orientation::Reversed;
1038 for k in 0..8 {
1039 let f = f64::from(k) / 8.0;
1040 let f = if flipped { 1.0 - f } else { f };
1041 let t = (range.1 - range.0).mul_add(f, range.0);
1042 walk.push(placed.point_at(t, tol)?);
1043 }
1044 }
1045 for k in 0..walk.len() {
1046 let (a, b) = (walk[k], walk[(k + 1) % walk.len()]);
1047 area += (a - point).cross(b - point);
1048 }
1049 area.dot(tangent)
1050 };
1051 let profile = if along < 0.0 {
1056 face.reversed()
1057 } else {
1058 face.clone()
1059 };
1060 let walls_turned = (hand < 0.0) != (along < 0.0);
1061
1062 let mut history = History::new();
1063 let mut faces = Vec::new();
1064 for wire in model.children_of(&profile)? {
1065 let (sides, wire_history) = revolution_over_wire(model, rails, &wire, turn, tol)?;
1066 history = history.then(&wire_history);
1067 if walls_turned {
1068 faces.extend(sides.into_iter().map(|f| f.reversed()));
1069 } else {
1070 faces.extend(sides);
1071 }
1072 }
1073
1074 if turn.full {
1075 history.delete(face);
1081 } else {
1082 let bottom = profile.reversed();
1083 let top = profile.moved(&turn.displacement);
1084 model.set_derived(&bottom, std::slice::from_ref(face), roles::SWEEP_BOTTOM)?;
1085 model.set_derived(&top, std::slice::from_ref(face), roles::SWEEP_TOP)?;
1086 history.generate(face, top.clone());
1087 faces.push(bottom);
1088 faces.push(top);
1089 }
1090
1091 let shell = make_shell(model, &faces)?.shape;
1092 let solid = make_solid(model, std::slice::from_ref(&shell))?.shape;
1093 history.generate(face, solid.clone());
1094 Ok(Built::new(solid, history))
1095}
1096
1097fn revolution_over_wire(
1099 model: &mut Model,
1100 rails: &mut Rails,
1101 wire: &Shape,
1102 turn: &Turn,
1103 tol: Tolerances,
1104) -> OgeomResult<(Vec<Shape>, History)> {
1105 let mut faces = Vec::new();
1106 let mut history = History::new();
1107 for edge in model.ordered_children_of(wire)? {
1108 let (face, edge_history) = revolution_over_edge(model, rails, &edge, turn, tol)?;
1109 history = history.then(&edge_history);
1110 faces.extend(face);
1115 }
1116 if faces.is_empty() {
1117 ogeom_bail!(
1118 Construction,
1119 "the whole wire lies along the axis, so it revolves out nothing"
1120 );
1121 }
1122 Ok((faces, history))
1123}
1124
1125fn revolution_over_edge(
1132 model: &mut Model,
1133 rails: &mut Rails,
1134 edge: &Shape,
1135 turn: &Turn,
1136 tol: Tolerances,
1137) -> OgeomResult<(Option<Shape>, History)> {
1138 let Some(node) = model.node(edge) else {
1139 ogeom_bail!(Dangling, "edge is not in this model");
1140 };
1141 let NodeData::Edge(data) = node.data() else {
1142 ogeom_bail!(Construction, "edge node holds no edge data");
1143 };
1144 let Some(EdgeRepr::Curve3d { curve, range, .. }) = data.curve3d() else {
1145 ogeom_bail!(
1146 Construction,
1147 "an edge with no curve in space has no shape to revolve; a \
1148 degenerate edge sweeps out nothing and has to be handled by its \
1149 face, not here"
1150 );
1151 };
1152 let Some(geometry) = model.geometry().curve(*curve).cloned() else {
1153 ogeom_bail!(Dangling, "curve is not in this model");
1154 };
1155
1156 let placement = edge.transform(model.datums())?;
1159 let stored = geometry.domain();
1160 let geometry = geometry.transformed(&placement, tol)?;
1161 let placed = geometry.domain();
1162 let (lo, hi) = (
1163 rescale(range.0, stored, placed),
1164 rescale(range.1, stored, placed),
1165 );
1166 if axis_relation(&geometry, (lo, hi), turn.axis, tol)? == AxisRelation::On {
1167 return Ok((None, History::new()));
1168 }
1169
1170 if let ogeom_geom::Curve::Line(line) = &geometry
1176 && line
1177 .axis()
1178 .direction
1179 .vector()
1180 .dot(turn.axis.direction.vector())
1181 .abs()
1182 <= tol.angular()
1183 {
1184 return flat_revolution(model, rails, edge, &geometry, (lo, hi), turn, tol);
1185 }
1186
1187 let canonical = canonical_revolution(&geometry, (lo, hi), turn, tol)?;
1188
1189 let opposed = canonical
1194 .as_ref()
1195 .is_some_and(|(_, (chart_lo, chart_hi))| chart_hi < chart_lo);
1196 let (lo, hi) = canonical.as_ref().map_or((lo, hi), |&(_, chart)| chart);
1197 let surface = match canonical {
1198 Some((exact, _)) => model.geometry_mut().add_surface(exact),
1199 None => model.geometry_mut().add_surface(
1200 ogeom_geom::RevolutionSurface::new(geometry, turn.axis, turn.angle)?.into(),
1201 ),
1202 };
1203
1204 let reversed = edge.orientation() == Orientation::Reversed;
1205 let (v_start, v_end) = if reversed { (hi, lo) } else { (lo, hi) };
1206 let (near, far) = (0.0, turn.angle);
1207
1208 let start_rail = revolved_rail(model, rails, edge, turn, false, tol)?;
1212 let end_rail = revolved_rail(model, rails, edge, turn, true, tol)?;
1213
1214 if start_rail.is_same(&end_rail) {
1215 seam_pcurves(
1221 model,
1222 &start_rail,
1223 surface,
1224 ((near, v_start), (far, v_start)),
1225 ((near, v_end), (far, v_end)),
1226 tol,
1227 )?;
1228 } else {
1229 pcurve(
1230 model,
1231 &start_rail,
1232 surface,
1233 (near, v_start),
1234 (far, v_start),
1235 tol,
1236 )?;
1237 pcurve(model, &end_rail, surface, (near, v_end), (far, v_end), tol)?;
1238 }
1239
1240 let displaced = edge.moved(&turn.displacement);
1244 if turn.full {
1245 let (forward, reversed_side) = if reversed { (near, far) } else { (far, near) };
1250 seam_pcurves(
1251 model,
1252 edge,
1253 surface,
1254 ((forward, lo), (forward, hi)),
1255 ((reversed_side, lo), (reversed_side, hi)),
1256 tol,
1257 )?;
1258 } else {
1259 pcurve(model, edge, surface, (near, lo), (near, hi), tol)?;
1260 pcurve(model, &displaced, surface, (far, lo), (far, hi), tol)?;
1261 }
1262
1263 let ring = [
1266 start_rail.clone(),
1267 displaced.clone(),
1268 end_rail.reversed(),
1269 edge.reversed(),
1270 ];
1271 let boundary = make_wire(model, &ring, tol)?.shape;
1272 let built = make_face_on(model, surface, std::slice::from_ref(&boundary), tol)?.shape;
1273
1274 let face = if reversed != opposed {
1285 built
1286 } else {
1287 built.reversed()
1288 };
1289 model.set_derived(&face, std::slice::from_ref(edge), roles::SWEEP_SIDE)?;
1290
1291 let mut history = History::new();
1292 history.generate(edge, face.clone());
1297 if !turn.full {
1298 history.generate(edge, displaced);
1299 }
1300 Ok((Some(face), history))
1301}
1302
1303fn canonical_revolution(
1322 geometry: &ogeom_geom::Curve,
1323 (lo, hi): (f64, f64),
1324 turn: &Turn,
1325 tol: Tolerances,
1326) -> OgeomResult<Option<(ogeom_geom::SurfaceGeometry, (f64, f64))>> {
1327 let axis = turn.axis;
1328 let along = axis.direction.vector();
1329 let radius_of = |p: Point| p - axis.project(p);
1330 match geometry {
1331 ogeom_geom::Curve::Line(_) => {
1335 let (p_lo, p_hi) = (geometry.point_at(lo, tol)?, geometry.point_at(hi, tol)?);
1336 let (h_lo, h_hi) = (
1337 (p_lo - axis.location).dot(along),
1338 (p_hi - axis.location).dot(along),
1339 );
1340 let rise = h_hi - h_lo;
1341 if rise.abs() <= tol.confusion() {
1342 return Ok(None);
1343 }
1344 let (r_lo, r_hi) = (radius_of(p_lo).magnitude(), radius_of(p_hi).magnitude());
1345 let stem = if r_lo > r_hi { p_lo } else { p_hi };
1348 let Ok(radial) = Direction::new(radius_of(stem), tol) else {
1349 return Ok(None);
1350 };
1351 let slope = (r_hi - r_lo) / rise;
1352 if slope.abs() <= tol.angular() {
1353 let frame = Frame::new(axis.location, axis.direction, radial, tol)?;
1354 let margin = rise.abs() * 0.1 + 1.0;
1355 let surface = ogeom_geom::CylinderSurface::new(
1356 ogeom_math::Cylinder::new(frame, r_lo, tol)?,
1357 (h_lo.min(h_hi) - margin, h_lo.max(h_hi) + margin),
1358 )?;
1359 return Ok(Some((surface.into(), (h_lo, h_hi))));
1360 }
1361 let base = axis.project(p_lo);
1365 let frame = Frame::new(base, axis.direction, radial, tol)?;
1366 let cone = ogeom_math::Cone::new(frame, r_lo, slope.atan(), tol)?;
1367 let (v_lo, v_hi) = (0.0_f64, rise);
1368 let margin = rise.abs() * 0.1 + 1.0;
1369 let apex = -r_lo / slope;
1373 let (mut low, mut high) = (v_lo.min(v_hi) - margin, v_lo.max(v_hi) + margin);
1374 if slope > 0.0 {
1375 low = low.max(apex);
1376 } else {
1377 high = high.min(apex);
1378 }
1379 let surface = ogeom_geom::ConeSurface::new(cone, (low, high))?;
1380 Ok(Some((surface.into(), (v_lo, v_hi))))
1381 }
1382 ogeom_geom::Curve::Circle(c) => {
1386 let circle = c.circle();
1387 let (centre, normal) = (circle.frame().origin(), circle.frame().z().vector());
1388 let offset = radius_of(centre);
1389 let (major, minor) = (offset.magnitude(), circle.radius());
1390 if normal.dot(along).abs() > tol.angular()
1395 || normal.dot(offset).abs() > tol.confusion()
1396 || major <= minor + tol.confusion()
1397 {
1398 return Ok(None);
1399 }
1400 let Ok(radial) = Direction::new(offset, tol) else {
1401 return Ok(None);
1402 };
1403 let frame = Frame::new(axis.project(centre), axis.direction, radial, tol)?;
1404 let torus = ogeom_math::Torus::new(frame, major, minor, tol)?;
1405
1406 let (at, tangent) = (geometry.point_at(lo, tol)?, geometry.d1_at(lo, tol)?);
1410 let spoke = at - centre;
1411 let (a, b) = (spoke.dot(radial.vector()), spoke.dot(along));
1412 let (da, db) = (tangent.dot(radial.vector()), tangent.dot(along));
1413 let v_lo = b.atan2(a);
1414 let sense = a.mul_add(db, -(b * da));
1415 if sense.abs() <= tol.confusion() {
1416 return Ok(None);
1417 }
1418 let v_hi = (hi - lo).copysign(sense) + v_lo;
1419 Ok(Some((
1420 ogeom_geom::TorusSurface::new(torus).into(),
1421 (v_lo, v_hi),
1422 )))
1423 }
1424 _ => Ok(None),
1425 }
1426}
1427
1428fn flat_revolution(
1431 model: &mut Model,
1432 rails: &mut Rails,
1433 edge: &Shape,
1434 geometry: &ogeom_geom::Curve,
1435 range: (f64, f64),
1436 turn: &Turn,
1437 tol: Tolerances,
1438) -> OgeomResult<(Option<Shape>, History)> {
1439 use ogeom_geom::Curve3d as _;
1440 let (lo, hi) = range;
1441 let axis_dir = turn.axis.direction;
1442 let at = geometry.point_at(lo, tol)?;
1443 let height = (at - turn.axis.location).dot(axis_dir.vector());
1444 let foot = turn.axis.location + axis_dir.vector() * height;
1445 let radius_of = |p: ogeom_math::Point| (p - foot).magnitude();
1446 let far = geometry.point_at(hi, tol)?;
1447 let reach = radius_of(at).max(radius_of(far)) * 2.0 + 1.0;
1448 let plane = ogeom_math::Plane::new(ogeom_math::Frame::about(foot, axis_dir));
1449 let plane_surface: ogeom_geom::SurfaceGeometry =
1450 ogeom_geom::PlaneSurface::over(plane, (-reach, reach), (-reach, reach))?.into();
1451 let surface = model.geometry_mut().add_surface(plane_surface.clone());
1452
1453 let start_rail = revolved_rail(model, rails, edge, turn, false, tol)?;
1454 let end_rail = revolved_rail(model, rails, edge, turn, true, tol)?;
1455 let is_degenerate = |model: &Model, e: &Shape| -> bool {
1456 model
1457 .node(e)
1458 .and_then(|n| n.data().as_edge())
1459 .is_some_and(|d| d.curve3d().is_none())
1460 };
1461
1462 let attach = |model: &mut Model, occurrence: &Shape| -> OgeomResult<()> {
1465 let Some(data) = model.node(occurrence).and_then(|n| n.data().as_edge()) else {
1466 ogeom_bail!(Construction, "a flat revolution edge holds no data");
1467 };
1468 let Some(EdgeRepr::Curve3d { curve, range, .. }) = data.curve3d() else {
1469 ogeom_bail!(Construction, "a flat revolution edge has no curve");
1470 };
1471 let (curve, range) = (*curve, *range);
1472 let Some(stored) = model.geometry().curve(curve) else {
1473 ogeom_bail!(Dangling, "curve is not in this model");
1474 };
1475 let placed = stored
1476 .clone()
1477 .transformed(&occurrence.transform(model.datums())?, tol)?;
1478 let Some(pc) = ogeom_intersect::exact_pcurve_of(&placed, &plane_surface, tol) else {
1479 ogeom_bail!(
1480 Construction,
1481 "a flat revolution edge has no closed-form pcurve on its plane"
1482 );
1483 };
1484 crate::build::attach_pcurve(
1485 model,
1486 occurrence,
1487 pc,
1488 surface,
1489 occurrence.location().clone(),
1490 range,
1491 )
1492 };
1493
1494 let reversed = edge.orientation() == Orientation::Reversed;
1495 let built = if turn.full {
1496 let mut wires = Vec::new();
1497 for rail in [&start_rail, &end_rail] {
1498 if is_degenerate(model, rail) {
1499 continue;
1500 }
1501 attach(model, rail)?;
1502 wires.push(make_wire(model, std::slice::from_ref(rail), tol)?.shape);
1503 }
1504 if wires.is_empty() {
1505 ogeom_bail!(Construction, "a flat revolution swept out no boundary");
1506 }
1507 make_face_on(model, surface, &wires, tol)?.shape
1508 } else {
1509 let displaced = edge.moved(&turn.displacement);
1510 let mut ring: Vec<Shape> = Vec::new();
1511 if !is_degenerate(model, &start_rail) {
1512 attach(model, &start_rail)?;
1513 ring.push(start_rail.clone());
1514 }
1515 attach(model, &displaced)?;
1516 ring.push(displaced.clone());
1517 if !is_degenerate(model, &end_rail) {
1518 attach(model, &end_rail)?;
1519 ring.push(end_rail.reversed());
1520 }
1521 attach(model, edge)?;
1522 ring.push(edge.reversed());
1523 let boundary = make_wire(model, &ring, tol)?.shape;
1524 make_face_on(model, surface, std::slice::from_ref(&boundary), tol)?.shape
1525 };
1526
1527 let outward_sense = {
1531 let radial = if radius_of(far) >= radius_of(at) {
1532 far - foot
1533 } else {
1534 at - foot
1535 };
1536 let d = (far - at).dot(radial);
1537 d < 0.0
1538 };
1539 let face = if reversed != outward_sense {
1540 built.reversed()
1541 } else {
1542 built
1543 };
1544 model.set_derived(&face, std::slice::from_ref(edge), roles::SWEEP_SIDE)?;
1545
1546 let mut history = History::new();
1547 history.generate(edge, face.clone());
1548 if !turn.full {
1549 history.generate(edge, edge.moved(&turn.displacement));
1550 }
1551 Ok((Some(face), history))
1552}
1553
1554fn revolved_rail(
1564 model: &mut Model,
1565 rails: &mut Rails,
1566 edge: &Shape,
1567 turn: &Turn,
1568 at_end: bool,
1569 tol: Tolerances,
1570) -> OgeomResult<Shape> {
1571 let Some((start, end)) = crate::build::edge_vertices(model, edge)? else {
1572 ogeom_bail!(
1573 Construction,
1574 "an unbounded edge has no endpoints to sweep into rails"
1575 );
1576 };
1577 let base = if at_end { end } else { start };
1578 if let Some(existing) = rails.get(&base.node()) {
1579 return Ok(existing.clone());
1580 }
1581
1582 let Some(node) = model.node(&base) else {
1583 ogeom_bail!(Dangling, "vertex is not in this model");
1584 };
1585 let Some(data) = node.data().as_vertex() else {
1586 ogeom_bail!(Construction, "vertex node holds no point");
1587 };
1588 let from = base.transform(model.datums())?.apply(data.point);
1589
1590 let raised = if turn.full {
1594 base.clone()
1595 } else {
1596 base.moved(&turn.displacement)
1597 };
1598
1599 let radius = from - turn.axis.project(from);
1600 let built = if radius.magnitude() <= tol.confusion() {
1601 let mut data = ogeom_topo::EdgeData::new();
1602 data.degenerate = true;
1603 model.add_edge(data, &[base.clone(), raised])?
1604 } else {
1605 let frame = Frame::new(
1609 turn.axis.project(from),
1610 turn.axis.direction,
1611 Direction::new(radius, tol)?,
1612 tol,
1613 )?;
1614 let circle = Circle::new(frame, radius.magnitude(), tol)?;
1615 crate::build::make_edge_between(
1616 model,
1617 ogeom_geom::CircleCurve::new(circle).into(),
1618 (0.0, turn.angle),
1619 &base,
1620 &raised,
1621 tol,
1622 )?
1623 .shape
1624 };
1625
1626 model.set_derived(&built, std::slice::from_ref(&base), roles::SWEEP_RAIL)?;
1627 rails.insert(base.node(), built.clone());
1628 Ok(built)
1629}
1630
1631const AXIS_SAMPLES: usize = 32;
1633
1634#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1636enum AxisRelation {
1637 Clear,
1639 On,
1641}
1642
1643fn axis_relation(
1664 curve: &ogeom_geom::Curve,
1665 range: (f64, f64),
1666 axis: Axis,
1667 tol: Tolerances,
1668) -> OgeomResult<AxisRelation> {
1669 let mut points = Vec::with_capacity(AXIS_SAMPLES + 1);
1672 for i in 0..=AXIS_SAMPLES {
1673 #[allow(clippy::cast_precision_loss)]
1674 let t = i as f64 / AXIS_SAMPLES as f64;
1675 let at = range.0 + (range.1 - range.0) * t;
1676 points.push(curve.point_at(at, tol)?);
1677 }
1678 let on_axis = |p: &ogeom_math::Point| (*p - axis.project(*p)).magnitude() <= tol.confusion();
1679 if points.iter().all(on_axis) {
1680 return Ok(AxisRelation::On);
1681 }
1682
1683 let along = |p: &ogeom_math::Point| (*p - axis.location).dot(axis.direction.vector());
1686 let mut lo = f64::INFINITY;
1687 let mut hi = f64::NEG_INFINITY;
1688 for p in &points {
1689 lo = lo.min(along(p));
1690 hi = hi.max(along(p));
1691 }
1692 let margin = (hi - lo).max(1.0);
1693 let line: ogeom_geom::Curve =
1694 ogeom_geom::LineCurve::over(axis, lo - margin, hi + margin)?.into();
1695 let trimmed: ogeom_geom::Curve = if (range.0, range.1) == curve.domain() {
1696 curve.clone()
1697 } else {
1698 ogeom_geom::TrimmedCurve::new(curve.clone(), range.0, range.1, tol)?.into()
1699 };
1700
1701 let (start, end) = (points[0], points[AXIS_SAMPLES]);
1704 let interior = |p: ogeom_math::Point| {
1705 p.distance(start) > tol.confusion() && p.distance(end) > tol.confusion()
1706 };
1707
1708 let hits = ogeom_intersect::intersect_curves(
1709 &trimmed,
1710 &line,
1711 ogeom_intersect::CurveCurveOptions::default(),
1712 tol,
1713 )?;
1714 if !hits.overlaps.is_empty() {
1715 ogeom_bail!(
1719 Construction,
1720 "the profile touches the axis away from its ends; revolving it \
1721 would sweep a surface through itself. Split the profile where it \
1722 meets the axis"
1723 );
1724 }
1725 if hits.crossings.iter().any(|c| interior(c.point)) {
1726 ogeom_bail!(
1727 Construction,
1728 "the profile passes through the axis away from its ends; \
1729 revolving it would sweep a surface through itself. Split the \
1730 profile where it meets the axis"
1731 );
1732 }
1733
1734 let near = ogeom_intersect::extrema_curve_curve(
1737 &trimmed,
1738 &line,
1739 ogeom_intersect::ExtremaOptions::default(),
1740 tol,
1741 )?;
1742 if near
1743 .approaches
1744 .iter()
1745 .any(|a| a.distance <= tol.confusion() && interior(a.point_a))
1746 {
1747 ogeom_bail!(
1748 Construction,
1749 "the profile touches the axis away from its ends; revolving it \
1750 would sweep a surface through itself. Split the profile where it \
1751 meets the axis"
1752 );
1753 }
1754 Ok(AxisRelation::Clear)
1755}
1756
1757fn rescale(u: f64, from: (f64, f64), to: (f64, f64)) -> f64 {
1765 let span = from.1 - from.0;
1766 if span.abs() <= f64::MIN_POSITIVE {
1767 return to.0;
1768 }
1769 to.0 + (to.1 - to.0) * (u - from.0) / span
1770}
1771
1772fn rail(
1779 model: &mut Model,
1780 rails: &mut Rails,
1781 edge: &Shape,
1782 displacement: &Location,
1783 vector: Vector,
1784 at_end: bool,
1785 tol: Tolerances,
1786) -> OgeomResult<Shape> {
1787 let Some((start, end)) = crate::build::edge_vertices(model, edge)? else {
1788 ogeom_bail!(
1789 Construction,
1790 "an unbounded edge has no endpoints to sweep into rails"
1791 );
1792 };
1793 let base = if at_end { end } else { start };
1794 let raised = base.moved(displacement);
1795
1796 if let Some(existing) = rails.get(&base.node()) {
1800 return Ok(existing.clone());
1801 }
1802
1803 let Some(node) = model.node(&base) else {
1804 ogeom_bail!(Dangling, "vertex is not in this model");
1805 };
1806 let Some(data) = node.data().as_vertex() else {
1807 ogeom_bail!(Construction, "vertex node holds no point");
1808 };
1809 let from = base.transform(model.datums())?.apply(data.point);
1810
1811 let line = ogeom_geom::LineCurve::segment(from, from + vector, tol)?;
1812 let built = crate::build::make_edge_between(
1813 model,
1814 line.into(),
1815 (0.0, vector.magnitude()),
1816 &base,
1817 &raised,
1818 tol,
1819 )?;
1820 model.set_derived(&built.shape, std::slice::from_ref(&base), roles::SWEEP_RAIL)?;
1821 rails.insert(base.node(), built.shape.clone());
1822 Ok(built.shape)
1823}
1824
1825type Rails = HashMap<TShapeId, Shape>;
1831
1832fn seam_pcurves(
1835 model: &mut Model,
1836 edge: &Shape,
1837 surface: ogeom_topo::SurfaceId,
1838 forward: ((f64, f64), (f64, f64)),
1839 reversed: ((f64, f64), (f64, f64)),
1840 tol: Tolerances,
1841) -> OgeomResult<()> {
1842 let flat = |p: (f64, f64)| Point2::new(p.0, p.1);
1843 let length = flat(forward.0).distance(flat(forward.1));
1844 let first = model
1845 .geometry_mut()
1846 .add_pcurve(Line2d::segment(flat(forward.0), flat(forward.1), tol)?.into());
1847 let second = model
1848 .geometry_mut()
1849 .add_pcurve(Line2d::segment(flat(reversed.0), flat(reversed.1), tol)?.into());
1850
1851 let Some(node) = model.node_mut(edge) else {
1852 ogeom_bail!(Dangling, "edge is not in this model");
1853 };
1854 let NodeData::Edge(data) = node.data_mut() else {
1855 ogeom_bail!(Construction, "edge node holds no edge data");
1856 };
1857 data.add(EdgeRepr::Seam {
1858 forward: first,
1859 reversed: second,
1860 surface,
1861 location: Location::identity(),
1862 range: (0.0, length),
1863 });
1864 Ok(())
1865}
1866
1867fn pcurve(
1869 model: &mut Model,
1870 edge: &Shape,
1871 surface: ogeom_topo::SurfaceId,
1872 from: (f64, f64),
1873 to: (f64, f64),
1874 tol: Tolerances,
1875) -> OgeomResult<()> {
1876 let (a, b) = (Point2::new(from.0, from.1), Point2::new(to.0, to.1));
1877 let curve: PlanarCurve = Line2d::segment(a, b, tol)?.into();
1878 crate::build::attach_pcurve(
1883 model,
1884 edge,
1885 curve,
1886 surface,
1887 edge.location().clone(),
1888 (0.0, a.distance(b)),
1889 )
1890}
1891
1892#[cfg(test)]
1893#[allow(clippy::unwrap_used, clippy::expect_used)]
1894mod tests {
1895 use super::*;
1896 use crate::build::is_shell_closed;
1897 use crate::mass::volume_properties;
1898 use approx::assert_relative_eq;
1899 use ogeom_geom::SurfaceKind;
1900 use ogeom_math::{Frame, Point};
1901 use ogeom_mesh::{Deflection, triangulate};
1902 use ogeom_topo::{ShapeType, explore_unique};
1903
1904 const T: Tolerances = Tolerances::millimetres();
1905
1906 fn deflection(chord: f64) -> Deflection {
1907 Deflection {
1908 chord,
1909 ..Deflection::default()
1910 }
1911 }
1912
1913 fn box_face(model: &mut Model, side: f64, role: ogeom_core::Role) -> Shape {
1915 let built = crate::make_box(model, Frame::WORLD, (side, side, side), T).unwrap();
1916 explore_unique(model, &built.shape, ShapeType::Face)
1917 .unwrap()
1918 .into_iter()
1919 .find(|f| {
1920 model
1921 .provenance_of(f)
1922 .and_then(ogeom_core::Provenance::role)
1923 == Some(role)
1924 })
1925 .expect("the box has a face with that role")
1926 }
1927
1928 fn square(model: &mut Model, side: f64) -> Shape {
1930 box_face(model, side, crate::primitive::roles::FACE_MAX_Z)
1931 }
1932
1933 #[test]
1934 fn a_segment_swept_obliquely_makes_a_plane_with_a_sheared_chart() {
1935 use ogeom_geom::Curve3d as _;
1939 let mut model = Model::new();
1940 let (a, b) = (Point::new(0.0, 0.0, 0.0), Point::new(10.0, 0.0, 0.0));
1941 let va = crate::make_vertex(&mut model, a).shape;
1942 let vb = crate::make_vertex(&mut model, b).shape;
1943 let edge = crate::make_edge_between(
1944 &mut model,
1945 ogeom_geom::LineCurve::segment(a, b, T).unwrap().into(),
1946 (0.0, 10.0),
1947 &va,
1948 &vb,
1949 T,
1950 )
1951 .unwrap()
1952 .shape;
1953 let lean = Vector::new(2.0, 0.0, 5.0);
1954 let face = make_prism(&mut model, &edge, lean, T).unwrap().shape;
1955 let data = model.node(&face).unwrap().data().as_face().unwrap().clone();
1956 let surface = model.geometry().surface(data.surface).unwrap().clone();
1957 assert!(
1958 matches!(surface, ogeom_geom::SurfaceGeometry::Plane(_)),
1959 "an oblique sweep of a line is a plane"
1960 );
1961 use ogeom_geom::Surface as _;
1964 for e in explore_unique(&model, &face, ShapeType::Edge).unwrap() {
1965 let ed = model.node(&e).unwrap().data().as_edge().unwrap().clone();
1966 let Some(ogeom_topo::EdgeRepr::Curve3d { curve, range, .. }) = ed.curve3d() else {
1967 panic!("an edge has a curve");
1968 };
1969 let world = model
1970 .geometry()
1971 .curve(*curve)
1972 .unwrap()
1973 .clone()
1974 .transformed(&e.transform(model.datums()).unwrap(), T)
1975 .unwrap();
1976 let Some(ogeom_topo::EdgeRepr::PCurve {
1977 curve: pc,
1978 range: prange,
1979 ..
1980 }) = ed.pcurve_for(data.surface, e.location())
1981 else {
1982 panic!("an edge has a pcurve on the face");
1983 };
1984 let planar = model.geometry().pcurve(*pc).unwrap();
1985 for k in 0..=4 {
1986 let f = f64::from(k) / 4.0;
1987 let t = range.0 + (range.1 - range.0) * f;
1988 let u = prange.0 + (prange.1 - prange.0) * f;
1989 let on_curve = world.point_at(t, T).unwrap();
1990 let q = ogeom_geom::Curve2d::point_at(planar, u, T).unwrap();
1991 let on_surface = surface.point_at(q.x, q.y, T).unwrap();
1992 assert!(
1993 on_curve.distance(on_surface) < 1e-9,
1994 "pcurve and curve agree: {on_curve:?} vs {on_surface:?}"
1995 );
1996 }
1997 }
1998 }
1999
2000 #[test]
2001 fn a_tapered_square_prism_is_the_frustum_the_closed_form_names() {
2002 let mut model = Model::new();
2003 let profile = square(&mut model, 10.0);
2004 let taper = 5.0_f64.to_radians();
2005 let built =
2006 crate::make_prism_tapered(&mut model, &profile, Vector::new(0.0, 0.0, 10.0), taper, T)
2007 .unwrap();
2008 let diagnosis = crate::check(&model, &built.shape, T).unwrap();
2009 assert!(diagnosis.is_valid(), "{:?}", diagnosis.problems);
2010
2011 let d = 10.0 * taper.tan();
2014 let has_far_corner = explore_unique(&model, &built.shape, ShapeType::Vertex)
2015 .unwrap()
2016 .into_iter()
2017 .any(|v| {
2018 model
2019 .node(&v)
2020 .and_then(|n| n.data().as_vertex().map(|data| data.point))
2021 .is_some_and(|p| {
2022 (p.z - 20.0).abs() < 1e-9
2023 && (p.x + d).abs() < 1e-9
2024 && (p.y + d).abs() < 1e-9
2025 })
2026 });
2027 assert!(has_far_corner, "the far ring widened by the taper");
2028
2029 let (a0, a1) = (100.0, (10.0 + 2.0 * d) * (10.0 + 2.0 * d));
2031 let expected = 10.0 / 3.0 * (a1.mul_add(1.0, a0) + (a0 * a1).sqrt());
2032 let measured = volume_properties(&model, &built.shape, Deflection::default(), T)
2033 .unwrap()
2034 .mass;
2035 assert!(
2036 (measured - expected).abs() < 1e-6,
2037 "tapered prism volume {measured} against {expected}"
2038 );
2039 assert!(!built.history.generated(&profile).is_empty());
2040 }
2041
2042 #[test]
2043 fn a_hole_tapers_with_its_profile_into_a_cone() {
2044 let mut model = Model::new();
2045 let plane = ogeom_math::Plane::new(Frame::WORLD);
2047 let corners = [
2048 Point::new(0.0, 0.0, 0.0),
2049 Point::new(10.0, 0.0, 0.0),
2050 Point::new(10.0, 10.0, 0.0),
2051 Point::new(0.0, 10.0, 0.0),
2052 ];
2053 let outer = crate::build::make_polygon(&mut model, &corners, true, T)
2054 .unwrap()
2055 .shape;
2056 let hole_centre = Point::new(3.5, 6.0, 0.0);
2057 let hole_r = 2.0;
2058 let circle = Circle::new(
2059 Frame::new(
2060 hole_centre,
2061 ogeom_math::Direction::Z,
2062 ogeom_math::Direction::X,
2063 T,
2064 )
2065 .unwrap(),
2066 hole_r,
2067 T,
2068 )
2069 .unwrap();
2070 let curve: ogeom_geom::Curve = ogeom_geom::CircleCurve::new(circle).into();
2071 let domain = curve.domain();
2072 let ring = crate::build::make_edge(&mut model, curve, domain, T)
2073 .unwrap()
2074 .shape;
2075 let hole = make_wire(&mut model, std::slice::from_ref(&ring), T)
2076 .unwrap()
2077 .shape;
2078 let surface: ogeom_geom::SurfaceGeometry =
2079 ogeom_geom::PlaneSurface::over(plane, (-20.0, 20.0), (-20.0, 20.0))
2080 .unwrap()
2081 .into();
2082 let outer_edges = model.ordered_children_of(&outer).unwrap();
2083 let profile = crate::build::make_face_with_pcurves(
2084 &mut model,
2085 surface,
2086 &[outer_edges, vec![ring.clone()]],
2087 T,
2088 )
2089 .unwrap()
2090 .shape;
2091 let _ = hole;
2092
2093 let taper = 5.0_f64.to_radians();
2094 let built =
2095 crate::make_prism_tapered(&mut model, &profile, Vector::new(0.0, 0.0, 10.0), taper, T)
2096 .unwrap();
2097 let diagnosis = crate::check(&model, &built.shape, T).unwrap();
2098 assert!(diagnosis.is_valid(), "{:?}", diagnosis.problems);
2099
2100 let cones = explore_unique(&model, &built.shape, ShapeType::Face)
2103 .unwrap()
2104 .into_iter()
2105 .filter(|f| {
2106 model
2107 .node(f)
2108 .and_then(|n| n.data().as_face())
2109 .and_then(|d| model.geometry().surface(d.surface))
2110 .is_some_and(|s| matches!(s, ogeom_geom::SurfaceGeometry::Cone(_)))
2111 })
2112 .count();
2113 assert_eq!(cones, 1, "the hole wall is a cone");
2114
2115 let pi = core::f64::consts::PI;
2116 let d = 10.0 * taper.tan();
2117 let (a0, a1) = (100.0, (10.0 + 2.0 * d) * (10.0 + 2.0 * d));
2118 let outer_frustum = 10.0 / 3.0 * (a1.mul_add(1.0, a0) + (a0 * a1).sqrt());
2119 let r1 = hole_r - d;
2120 let hole_frustum = pi * 10.0 / 3.0 * (hole_r.mul_add(hole_r, hole_r * r1) + r1 * r1);
2121 let expected = outer_frustum - hole_frustum;
2122 let measured = volume_properties(&model, &built.shape, deflection(1e-3), T)
2123 .unwrap()
2124 .mass;
2125 assert!(
2126 (measured - expected).abs() < 5e-2,
2127 "holed tapered prism volume {measured} against {expected}"
2128 );
2129 }
2130
2131 #[test]
2132 fn a_taper_that_collapses_a_hole_is_refused_by_name() {
2133 let mut model = Model::new();
2134 let plane = ogeom_math::Plane::new(Frame::WORLD);
2135 let corners = [
2136 Point::new(0.0, 0.0, 0.0),
2137 Point::new(10.0, 0.0, 0.0),
2138 Point::new(10.0, 10.0, 0.0),
2139 Point::new(0.0, 10.0, 0.0),
2140 ];
2141 let outer = crate::build::make_polygon(&mut model, &corners, true, T)
2142 .unwrap()
2143 .shape;
2144 let circle = Circle::new(
2145 Frame::new(
2146 Point::new(5.0, 5.0, 0.0),
2147 ogeom_math::Direction::Z,
2148 ogeom_math::Direction::X,
2149 T,
2150 )
2151 .unwrap(),
2152 1.0,
2153 T,
2154 )
2155 .unwrap();
2156 let curve: ogeom_geom::Curve = ogeom_geom::CircleCurve::new(circle).into();
2157 let domain = curve.domain();
2158 let ring = crate::build::make_edge(&mut model, curve, domain, T)
2159 .unwrap()
2160 .shape;
2161 let hole = make_wire(&mut model, std::slice::from_ref(&ring), T)
2162 .unwrap()
2163 .shape;
2164 let surface: ogeom_geom::SurfaceGeometry =
2165 ogeom_geom::PlaneSurface::over(plane, (-20.0, 20.0), (-20.0, 20.0))
2166 .unwrap()
2167 .into();
2168 let outer_edges = model.ordered_children_of(&outer).unwrap();
2169 let profile = crate::build::make_face_with_pcurves(
2170 &mut model,
2171 surface,
2172 &[outer_edges, vec![ring.clone()]],
2173 T,
2174 )
2175 .unwrap()
2176 .shape;
2177 let _ = hole;
2178 let err = crate::make_prism_tapered(
2179 &mut model,
2180 &profile,
2181 Vector::new(0.0, 0.0, 10.0),
2182 8.0_f64.to_radians(),
2183 T,
2184 )
2185 .unwrap_err();
2186 assert!(err.to_string().contains("collapses"), "{err}");
2187 }
2188
2189 #[test]
2190 fn a_curved_profile_edge_is_refused_by_name() {
2191 let mut model = Model::new();
2192 let plane = ogeom_math::Plane::new(Frame::WORLD);
2193 let ellipse = ogeom_math::Ellipse::new(Frame::WORLD, 4.0, 2.0, T).unwrap();
2194 let curve: ogeom_geom::Curve = ogeom_geom::EllipseCurve::new(ellipse).into();
2195 let domain = curve.domain();
2196 let ring = crate::build::make_edge(&mut model, curve, domain, T)
2197 .unwrap()
2198 .shape;
2199 let wire = make_wire(&mut model, std::slice::from_ref(&ring), T)
2200 .unwrap()
2201 .shape;
2202 let surface: ogeom_geom::SurfaceGeometry =
2203 ogeom_geom::PlaneSurface::over(plane, (-10.0, 10.0), (-10.0, 10.0))
2204 .unwrap()
2205 .into();
2206 let profile =
2207 crate::build::make_face_with_pcurves(&mut model, surface, &[vec![ring.clone()]], T)
2208 .unwrap()
2209 .shape;
2210 let _ = wire;
2211 let err = crate::make_prism_tapered(
2212 &mut model,
2213 &profile,
2214 Vector::new(0.0, 0.0, 5.0),
2215 5.0_f64.to_radians(),
2216 T,
2217 )
2218 .unwrap_err();
2219 assert!(err.to_string().contains("fitted ruling"), "{err}");
2220 }
2221
2222 #[test]
2223 fn a_profile_facing_away_from_the_sweep_gives_the_same_solid_as_one_facing_along_it() {
2224 for (role, centre) in [
2229 (
2232 crate::primitive::roles::FACE_MAX_Z,
2233 Point::new(1.0, 1.0, 3.5),
2234 ),
2235 (
2236 crate::primitive::roles::FACE_MIN_Z,
2237 Point::new(1.0, 1.0, 1.5),
2238 ),
2239 ] {
2240 let mut model = Model::new();
2241 let face = box_face(&mut model, 2.0, role);
2242 let built = make_prism(&mut model, &face, Vector::new(0.0, 0.0, 3.0), T).unwrap();
2243
2244 let counts = |kind| explore_unique(&model, &built.shape, kind).unwrap().len();
2245 assert_eq!(counts(ShapeType::Face), 6, "{role:?}");
2246 assert_eq!(counts(ShapeType::Edge), 12, "{role:?}");
2247
2248 for face in explode(&model, &built.shape) {
2251 ogeom_mesh::triangulate_face(&model, &face, deflection(0.01), T)
2252 .unwrap_or_else(|e| panic!("{role:?}: a face would not triangulate: {e}"));
2253 }
2254
2255 let mesh = triangulate(&model, &built.shape, deflection(0.01), T).unwrap();
2256 assert!(mesh.is_closed(), "{role:?}: the mesh has a slit in it");
2257 assert_relative_eq!(mesh.volume(), 12.0, epsilon = 1e-9);
2261
2262 let props = volume_properties(&model, &built.shape, deflection(0.01), T).unwrap();
2263 assert_relative_eq!(props.mass, 12.0, epsilon = 1e-9);
2264 assert!(
2265 props.centre.distance(centre) < 1e-9,
2266 "{role:?}: got {:?}",
2267 props.centre
2268 );
2269
2270 assert!(
2271 crate::check_tessellation(&model, &built.shape, deflection(0.01), T)
2272 .unwrap()
2273 .is_valid(),
2274 "{role:?}: the mesh disagrees with the topology"
2275 );
2276 }
2277 }
2278
2279 #[test]
2280 fn every_face_of_a_box_sweeps_into_a_solid_of_the_right_volume() {
2281 use crate::primitive::roles;
2284 let roles = [
2285 (roles::FACE_MIN_X, Vector::new(-3.0, 0.0, 0.0)),
2286 (roles::FACE_MAX_X, Vector::new(3.0, 0.0, 0.0)),
2287 (roles::FACE_MIN_Y, Vector::new(0.0, -3.0, 0.0)),
2288 (roles::FACE_MAX_Y, Vector::new(0.0, 3.0, 0.0)),
2289 (roles::FACE_MIN_Z, Vector::new(0.0, 0.0, -3.0)),
2290 (roles::FACE_MAX_Z, Vector::new(0.0, 0.0, 3.0)),
2291 ];
2292 for (role, vector) in roles {
2293 let mut model = Model::new();
2294 let face = box_face(&mut model, 2.0, role);
2295 let built = make_prism(&mut model, &face, vector, T).unwrap();
2296 let mesh = triangulate(&model, &built.shape, deflection(0.01), T).unwrap();
2297 assert!(mesh.is_closed(), "{role:?}: the mesh has a slit in it");
2298 assert_relative_eq!(mesh.volume(), 12.0, epsilon = 1e-9);
2299 }
2300 }
2301
2302 fn explode(model: &Model, shape: &Shape) -> Vec<Shape> {
2304 ogeom_topo::explore(model, shape, ogeom_topo::Filter::OfType(ShapeType::Face)).unwrap()
2305 }
2306
2307 fn ring_profile(model: &mut Model, offset: f64, side: f64) -> Shape {
2315 let frame = Frame::new(
2316 Point::new(offset, 0.0, 0.0),
2317 -ogeom_math::Direction::Y,
2318 ogeom_math::Direction::X,
2319 T,
2320 )
2321 .unwrap();
2322 let corners = [
2323 Point::new(offset, 0.0, 0.0),
2324 Point::new(offset + side, 0.0, 0.0),
2325 Point::new(offset + side, 0.0, side),
2326 Point::new(offset, 0.0, side),
2327 ];
2328 let vertices: Vec<Shape> = corners
2329 .iter()
2330 .map(|p| model.add_vertex(ogeom_topo::VertexData::new(*p)))
2331 .collect();
2332 let edges: Vec<Shape> = (0..4)
2333 .map(|i| {
2334 let (a, b) = (corners[i], corners[(i + 1) % 4]);
2335 crate::build::make_edge_between(
2336 model,
2337 ogeom_geom::LineCurve::segment(a, b, T).unwrap().into(),
2338 (0.0, a.distance(b)),
2339 &vertices[i],
2340 &vertices[(i + 1) % 4],
2341 T,
2342 )
2343 .unwrap()
2344 .shape
2345 })
2346 .collect();
2347 let wire = crate::make_wire(model, &edges, T).unwrap().shape;
2348 let surface = model
2349 .geometry_mut()
2350 .add_surface(ogeom_geom::PlaneSurface::new(ogeom_math::Plane::new(frame)).into());
2351 for (i, edge) in edges.iter().enumerate() {
2352 let (a, b) = (corners[i], corners[(i + 1) % 4]);
2353 let flat = |p: ogeom_math::Point| {
2354 let l = frame.to_local(p);
2355 Point2::new(l.x, l.y)
2356 };
2357 crate::attach_pcurve(
2358 model,
2359 edge,
2360 Line2d::segment(flat(a), flat(b), T).unwrap().into(),
2361 surface,
2362 ogeom_topo::Location::identity(),
2363 (0.0, a.distance(b)),
2364 )
2365 .unwrap();
2366 }
2367 crate::make_face_on(model, surface, std::slice::from_ref(&wire), T)
2368 .unwrap()
2369 .shape
2370 }
2371
2372 #[test]
2373 fn a_square_revolved_a_full_turn_is_a_ring_that_agrees_with_itself() {
2374 let (offset, side) = (3.0_f64, 2.0_f64);
2379 let mut model = Model::new();
2380 let profile = ring_profile(&mut model, offset, side);
2381 let built = make_revolution(&mut model, &profile, Axis::Z, TAU, T).unwrap();
2382
2383 let counts = |kind| explore_unique(&model, &built.shape, kind).unwrap().len();
2384 assert_eq!(counts(ShapeType::Face), 4, "one per profile edge, no caps");
2385 assert_eq!(
2386 counts(ShapeType::Edge),
2387 6,
2388 "a rail per profile vertex, and a seam only on the cylindrical \
2389 walls; the flat annuli are plane faces bounded by their rails \
2390 alone"
2391 );
2392 assert_eq!(counts(ShapeType::Vertex), 4, "a full turn adds none");
2393
2394 let shell = explore_unique(&model, &built.shape, ShapeType::Shell).unwrap()[0].clone();
2395 assert!(is_shell_closed(&model, &shell).unwrap());
2396 assert!(
2397 crate::check(&model, &built.shape, T).unwrap().is_valid(),
2398 "{}",
2399 crate::check(&model, &built.shape, T).unwrap()
2400 );
2401
2402 for face in explode(&model, &built.shape) {
2403 ogeom_mesh::triangulate_face(&model, &face, deflection(0.01), T)
2404 .unwrap_or_else(|e| panic!("a face would not triangulate: {e}"));
2405 }
2406
2407 let exact = side * side * TAU * (offset + side / 2.0);
2410 let found = crate::check_tessellation(&model, &built.shape, deflection(0.005), T).unwrap();
2411 assert!(found.is_valid(), "the mesh came apart: {found}");
2412
2413 let mesh = triangulate(&model, &built.shape, deflection(0.005), T).unwrap();
2414 assert!(mesh.is_closed(), "the mesh has a slit in it");
2415 assert!(mesh.volume() > 0.0, "the solid is inside out");
2416 assert_relative_eq!(mesh.volume(), exact, max_relative = 1e-3);
2420 }
2421
2422 #[test]
2423 fn a_square_revolved_part_way_has_two_ends_and_the_volume_of_that_wedge() {
2424 let (offset, side) = (3.0_f64, 2.0_f64);
2425 let angle = std::f64::consts::FRAC_PI_2;
2426 let mut model = Model::new();
2427 let profile = ring_profile(&mut model, offset, side);
2428 let built = make_revolution(&mut model, &profile, Axis::Z, angle, T).unwrap();
2429
2430 let counts = |kind| explore_unique(&model, &built.shape, kind).unwrap().len();
2431 assert_eq!(counts(ShapeType::Face), 6, "four sides and two ends");
2432
2433 let shell = explore_unique(&model, &built.shape, ShapeType::Shell).unwrap()[0].clone();
2434 assert!(is_shell_closed(&model, &shell).unwrap());
2435
2436 let exact = side * side * angle * (offset + side / 2.0);
2437 let mesh = triangulate(&model, &built.shape, deflection(0.005), T).unwrap();
2438 assert!(mesh.is_closed(), "the mesh has a slit in it");
2439 assert!(mesh.volume() > 0.0, "the solid is inside out");
2440 assert_relative_eq!(mesh.volume(), exact, max_relative = 1e-3);
2441 assert!(
2442 crate::check_tessellation(&model, &built.shape, deflection(0.005), T)
2443 .unwrap()
2444 .is_valid()
2445 );
2446 }
2447
2448 fn profile_from(model: &mut Model, corners: &[Point]) -> Shape {
2451 let frame = Frame::new(
2452 corners[0],
2453 -ogeom_math::Direction::Y,
2454 ogeom_math::Direction::X,
2455 T,
2456 )
2457 .unwrap();
2458 let n = corners.len();
2459 let vertices: Vec<Shape> = corners
2460 .iter()
2461 .map(|p| model.add_vertex(ogeom_topo::VertexData::new(*p)))
2462 .collect();
2463 let surface = model
2464 .geometry_mut()
2465 .add_surface(ogeom_geom::PlaneSurface::new(ogeom_math::Plane::new(frame)).into());
2466 let flat = |p: ogeom_math::Point| {
2467 let l = frame.to_local(p);
2468 Point2::new(l.x, l.y)
2469 };
2470
2471 let mut edges = Vec::with_capacity(n);
2472 for i in 0..n {
2473 let (a, b) = (corners[i], corners[(i + 1) % n]);
2474 let edge = crate::build::make_edge_between(
2475 model,
2476 ogeom_geom::LineCurve::segment(a, b, T).unwrap().into(),
2477 (0.0, a.distance(b)),
2478 &vertices[i],
2479 &vertices[(i + 1) % n],
2480 T,
2481 )
2482 .unwrap()
2483 .shape;
2484 crate::attach_pcurve(
2485 model,
2486 &edge,
2487 Line2d::segment(flat(a), flat(b), T).unwrap().into(),
2488 surface,
2489 ogeom_topo::Location::identity(),
2490 (0.0, a.distance(b)),
2491 )
2492 .unwrap();
2493 edges.push(edge);
2494 }
2495 let wire = crate::make_wire(model, &edges, T).unwrap().shape;
2496 crate::make_face_on(model, surface, std::slice::from_ref(&wire), T)
2497 .unwrap()
2498 .shape
2499 }
2500
2501 #[test]
2502 fn a_rectangle_with_a_side_on_the_axis_revolves_into_a_cylinder_face_for_face() {
2503 let (radius, height) = (2.0_f64, 5.0_f64);
2512 let mut model = Model::new();
2513 let profile = profile_from(
2514 &mut model,
2515 &[
2516 Point::new(0.0, 0.0, 0.0),
2517 Point::new(radius, 0.0, 0.0),
2518 Point::new(radius, 0.0, height),
2519 Point::new(0.0, 0.0, height),
2520 ],
2521 );
2522 let revolved = make_revolution(&mut model, &profile, Axis::Z, TAU, T).unwrap();
2523 let primitive = crate::make_cylinder(&mut model, Frame::WORLD, radius, height, T).unwrap();
2524
2525 let counts = |shape: &Shape, kind| explore_unique(&model, shape, kind).unwrap().len();
2526 assert_eq!(
2527 counts(&revolved.shape, ShapeType::Face),
2528 counts(&primitive.shape, ShapeType::Face),
2529 "a side and two caps, the same as make_cylinder"
2530 );
2531 assert_eq!(counts(&revolved.shape, ShapeType::Face), 3);
2532 for kind in [ShapeType::Edge, ShapeType::Vertex] {
2533 assert_eq!(
2534 counts(&revolved.shape, kind),
2535 counts(&primitive.shape, kind),
2536 "canonical caps carry a rim circle and nothing else: {kind:?}"
2537 );
2538 }
2539
2540 let shell = explore_unique(&model, &revolved.shape, ShapeType::Shell).unwrap()[0].clone();
2541 assert!(is_shell_closed(&model, &shell).unwrap());
2542 assert!(
2543 crate::check(&model, &revolved.shape, T).unwrap().is_valid(),
2544 "{}",
2545 crate::check(&model, &revolved.shape, T).unwrap()
2546 );
2547 assert!(
2548 crate::check_tessellation(&model, &revolved.shape, deflection(0.005), T)
2549 .unwrap()
2550 .is_valid()
2551 );
2552
2553 let exact = std::f64::consts::PI * radius * radius * height;
2554 let mesh = triangulate(&model, &revolved.shape, deflection(0.005), T).unwrap();
2555 assert!(mesh.is_closed());
2556 assert!(mesh.volume() > 0.0, "the solid is inside out");
2557 assert!(
2558 mesh.volume() < exact,
2559 "an inscribed volume cannot exceed it"
2560 );
2561 let reference = triangulate(&model, &primitive.shape, deflection(0.005), T).unwrap();
2566 assert_relative_eq!(mesh.volume(), reference.volume(), max_relative = 1e-6);
2567 assert!(
2568 mesh.volume() > exact * 0.995,
2569 "{} against {exact}",
2570 mesh.volume()
2571 );
2572 }
2573
2574 #[test]
2575 fn a_wall_parallel_to_the_axis_names_the_cylinder_it_is() {
2576 let (inner, outer, height) = (3.0_f64, 5.0_f64, 4.0_f64);
2583 for flip in [false, true] {
2584 let mut model = Model::new();
2585 let mut corners = [
2586 Point::new(inner, 0.0, 0.0),
2587 Point::new(outer, 0.0, 0.0),
2588 Point::new(outer, 0.0, height),
2589 Point::new(inner, 0.0, height),
2590 ];
2591 if flip {
2592 corners.reverse();
2593 }
2594 let profile = profile_from(&mut model, &corners);
2595 let revolved = make_revolution(&mut model, &profile, Axis::Z, TAU, T).unwrap();
2596
2597 for face in explore_unique(&model, &revolved.shape, ShapeType::Face).unwrap() {
2598 let NodeData::Face(data) = model.node(&face).unwrap().data() else {
2599 panic!("face data");
2600 };
2601 let surface = model.geometry().surface(data.surface).unwrap();
2602 assert!(
2603 matches!(
2604 surface,
2605 ogeom_geom::SurfaceGeometry::Cylinder(_)
2606 | ogeom_geom::SurfaceGeometry::Plane(_)
2607 ),
2608 "flip {flip}: a ring's face is a {surface:?}, not the \
2609 cylinder or plane it is"
2610 );
2611 }
2612
2613 assert!(
2614 crate::check(&model, &revolved.shape, T).unwrap().is_valid(),
2615 "flip {flip}: {}",
2616 crate::check(&model, &revolved.shape, T).unwrap()
2617 );
2618 let exact = std::f64::consts::PI * outer.mul_add(outer, -(inner * inner)) * height;
2619 let measured = volume_properties(&model, &revolved.shape, deflection(0.005), T)
2620 .unwrap()
2621 .mass;
2622 assert!(
2623 (measured - exact).abs() < exact * 1e-3,
2624 "flip {flip}: ring volume {measured} against {exact}"
2625 );
2626 }
2627 }
2628
2629 #[test]
2630 fn a_triangle_touching_the_axis_revolves_into_a_cone() {
2631 let (radius, height) = (3.0_f64, 4.0_f64);
2636 let mut model = Model::new();
2637 let profile = profile_from(
2638 &mut model,
2639 &[
2640 Point::new(0.0, 0.0, 0.0),
2641 Point::new(radius, 0.0, 0.0),
2642 Point::new(0.0, 0.0, height),
2643 ],
2644 );
2645 let built = make_revolution(&mut model, &profile, Axis::Z, TAU, T).unwrap();
2646
2647 assert_eq!(
2648 explore_unique(&model, &built.shape, ShapeType::Face)
2649 .unwrap()
2650 .len(),
2651 2,
2652 "a flank and one cap; the side on the axis sweeps out nothing"
2653 );
2654 assert_eq!(
2655 surface_kinds(&model, &built.shape),
2656 vec![SurfaceKind::Cone, SurfaceKind::Plane],
2657 "the flank is the cone it sweeps, and the cap its plane"
2658 );
2659 let shell = explore_unique(&model, &built.shape, ShapeType::Shell).unwrap()[0].clone();
2660 assert!(is_shell_closed(&model, &shell).unwrap());
2661 assert!(
2662 crate::check_tessellation(&model, &built.shape, deflection(0.005), T)
2663 .unwrap()
2664 .is_valid()
2665 );
2666
2667 let exact = std::f64::consts::PI * radius * radius * height / 3.0;
2668 let mesh = triangulate(&model, &built.shape, deflection(0.005), T).unwrap();
2669 assert!(mesh.volume() > 0.0, "the solid is inside out");
2670 assert!(mesh.volume() < exact);
2671 assert!(
2672 mesh.volume() > exact * 0.99,
2673 "{} against {exact}",
2674 mesh.volume()
2675 );
2676 }
2677
2678 fn surface_kinds(model: &Model, shape: &Shape) -> Vec<ogeom_geom::SurfaceKind> {
2681 use ogeom_geom::Surface as _;
2682 let mut kinds: Vec<ogeom_geom::SurfaceKind> = explore_unique(model, shape, ShapeType::Face)
2683 .unwrap()
2684 .iter()
2685 .map(|face| {
2686 let NodeData::Face(data) = model.node(face).unwrap().data() else {
2687 panic!("face data");
2688 };
2689 model.geometry().surface(data.surface).unwrap().kind()
2690 })
2691 .collect();
2692 kinds.sort_by_key(|k| format!("{k:?}"));
2693 kinds
2694 }
2695
2696 #[test]
2697 fn a_frustum_profile_names_a_cone_on_each_leaning_side() {
2698 let mut model = Model::new();
2702 let profile = profile_from(
2703 &mut model,
2704 &[
2705 Point::new(3.0, 0.0, 0.0),
2706 Point::new(5.0, 0.0, 0.0),
2707 Point::new(4.0, 0.0, 4.0),
2708 Point::new(2.0, 0.0, 4.0),
2709 ],
2710 );
2711 let built = make_revolution(&mut model, &profile, Axis::Z, TAU, T).unwrap();
2712
2713 assert_eq!(
2714 surface_kinds(&model, &built.shape),
2715 vec![
2716 SurfaceKind::Cone,
2717 SurfaceKind::Cone,
2718 SurfaceKind::Plane,
2719 SurfaceKind::Plane
2720 ],
2721 "two leaning walls and two flat ends"
2722 );
2723 assert!(
2724 crate::check(&model, &built.shape, T).unwrap().is_valid(),
2725 "{}",
2726 crate::check(&model, &built.shape, T).unwrap()
2727 );
2728
2729 let frustum =
2734 |a: f64, b: f64| std::f64::consts::PI * 4.0 / 3.0 * a.mul_add(a, b.mul_add(b, a * b));
2735 let exact = frustum(5.0, 4.0) - frustum(3.0, 2.0);
2736 let measured = volume_properties(&model, &built.shape, deflection(0.005), T)
2737 .unwrap()
2738 .mass;
2739 assert!(
2740 (measured - exact).abs() < exact * 1e-3,
2741 "frustum volume {measured} against {exact}"
2742 );
2743 }
2744
2745 #[test]
2746 fn a_disc_revolved_a_full_turn_is_a_torus_seamed_both_ways() {
2747 let (major, minor) = (5.0_f64, 2.0_f64);
2754 let mut model = Model::new();
2755
2756 let frame = Frame::new(
2757 Point::new(major, 0.0, 0.0),
2758 -ogeom_math::Direction::Y,
2759 ogeom_math::Direction::X,
2760 T,
2761 )
2762 .unwrap();
2763 let circle = ogeom_math::Circle::new(frame, minor, T).unwrap();
2764 let start = model.add_vertex(ogeom_topo::VertexData::new(Point::new(
2765 major + minor,
2766 0.0,
2767 0.0,
2768 )));
2769 let edge = crate::build::make_edge_between(
2770 &mut model,
2771 ogeom_geom::CircleCurve::new(circle).into(),
2772 (0.0, TAU),
2773 &start,
2774 &start,
2775 T,
2776 )
2777 .unwrap()
2778 .shape;
2779 let surface = model
2780 .geometry_mut()
2781 .add_surface(ogeom_geom::PlaneSurface::new(ogeom_math::Plane::new(frame)).into());
2782 crate::attach_pcurve(
2783 &mut model,
2784 &edge,
2785 ogeom_geom::Circle2d::new(
2786 ogeom_math::Circle2::new(
2787 ogeom_math::Frame2::new(Point2::ORIGIN, ogeom_math::Direction2::X),
2788 minor,
2789 T,
2790 )
2791 .unwrap(),
2792 )
2793 .into(),
2794 surface,
2795 ogeom_topo::Location::identity(),
2796 (0.0, TAU),
2797 )
2798 .unwrap();
2799 let wire = crate::make_wire(&mut model, std::slice::from_ref(&edge), T)
2800 .unwrap()
2801 .shape;
2802 let profile = crate::make_face_on(&mut model, surface, std::slice::from_ref(&wire), T)
2803 .unwrap()
2804 .shape;
2805
2806 let built = make_revolution(&mut model, &profile, Axis::Z, TAU, T).unwrap();
2807 let primitive = crate::make_torus(&mut model, Frame::WORLD, major, minor, T).unwrap();
2808
2809 let counts = |shape: &Shape, kind| explore_unique(&model, shape, kind).unwrap().len();
2810 for kind in [ShapeType::Face, ShapeType::Edge, ShapeType::Vertex] {
2811 assert_eq!(
2812 counts(&built.shape, kind),
2813 counts(&primitive.shape, kind),
2814 "{kind:?} count differs from make_torus's"
2815 );
2816 }
2817 assert_eq!(
2818 counts(&built.shape, ShapeType::Edge),
2819 2,
2820 "one seam each way"
2821 );
2822 assert_eq!(
2823 surface_kinds(&model, &built.shape),
2824 surface_kinds(&model, &primitive.shape),
2825 "a revolved disc is the torus make_torus builds, and should say so"
2826 );
2827
2828 let shell = explore_unique(&model, &built.shape, ShapeType::Shell).unwrap()[0].clone();
2829 assert!(is_shell_closed(&model, &shell).unwrap());
2830 assert!(
2831 crate::check_tessellation(&model, &built.shape, deflection(0.02), T)
2832 .unwrap()
2833 .is_valid()
2834 );
2835
2836 let exact = 2.0 * std::f64::consts::PI * std::f64::consts::PI * major * minor * minor;
2837 let mesh = triangulate(&model, &built.shape, deflection(0.02), T).unwrap();
2838 assert!(mesh.is_closed(), "the mesh has a slit in it");
2839 assert!(mesh.volume() > 0.0, "the solid is inside out");
2840 assert!(
2841 mesh.volume() > exact * 0.99 && mesh.volume() < exact,
2842 "{} against {exact}",
2843 mesh.volume()
2844 );
2845 }
2846
2847 #[test]
2848 fn a_full_turn_consumes_the_profile_face_but_not_its_edges() {
2849 let mut model = Model::new();
2855 let profile = ring_profile(&mut model, 3.0, 2.0);
2856 let edge = model
2857 .children_of(&model.children_of(&profile).unwrap()[0])
2858 .unwrap()[0]
2859 .clone();
2860
2861 let built = make_revolution(&mut model, &profile, Axis::Z, TAU, T).unwrap();
2862 assert!(
2863 built.history.is_deleted(&profile),
2864 "the profile is interior"
2865 );
2866 assert!(!built.history.is_deleted(&edge), "its edges are not");
2867 assert_eq!(
2868 built.history.generated(&edge).len(),
2869 1,
2870 "the lateral face it made"
2871 );
2872
2873 let mut model = Model::new();
2876 let profile = ring_profile(&mut model, 3.0, 2.0);
2877 let partial = make_revolution(&mut model, &profile, Axis::Z, 1.0, T).unwrap();
2878 assert!(!partial.history.is_deleted(&profile));
2879 }
2880
2881 #[test]
2882 fn a_profile_crossing_the_axis_is_refused_rather_than_swept_through_itself() {
2883 let mut model = Model::new();
2886 let profile = profile_from(
2887 &mut model,
2888 &[
2889 Point::new(-1.0, 0.0, 0.0),
2890 Point::new(2.0, 0.0, 0.0),
2891 Point::new(2.0, 0.0, 1.0),
2892 Point::new(-1.0, 0.0, 1.0),
2893 ],
2894 );
2895 let err = make_revolution(&mut model, &profile, Axis::Z, TAU, T).unwrap_err();
2896 assert!(
2897 err.to_string().contains("through itself"),
2898 "unexpected message: {err}"
2899 );
2900
2901 let mut model = Model::new();
2906 let grazing = profile_from(
2907 &mut model,
2908 &[
2909 Point::new(-1.0, 0.0, 0.0),
2910 Point::new(2.0, 0.0, 0.0),
2911 Point::new(2.0, 0.0, 3.0),
2912 Point::new(-1.0, 0.0, 3.0),
2913 ],
2914 );
2915 assert!(make_revolution(&mut model, &grazing, Axis::Z, TAU, T).is_err());
2916 }
2917
2918 #[test]
2919 fn a_profile_grazing_the_axis_between_samples_is_refused_exactly() {
2920 let mut model = Model::new();
2929 let frame = Frame::new(
2930 Point::new(1.0, 0.0, 0.0),
2931 -ogeom_math::Direction::Y,
2932 ogeom_math::Direction::X,
2933 T,
2934 )
2935 .unwrap();
2936 let surface = model
2937 .geometry_mut()
2938 .add_surface(ogeom_geom::PlaneSurface::new(ogeom_math::Plane::new(frame)).into());
2939 let flat = |p: ogeom_math::Point| {
2940 let l = frame.to_local(p);
2941 Point2::new(l.x, l.y)
2942 };
2943
2944 let controls = [
2945 Point::new(1.0, 0.0, 0.0),
2946 Point::new(-2.0, 0.0, 0.5),
2947 Point::new(4.0, 0.0, 1.0),
2948 ];
2949 let corners = [
2950 controls[0],
2951 controls[2],
2952 Point::new(5.0, 0.0, 1.0),
2953 Point::new(5.0, 0.0, 0.0),
2954 ];
2955 let vertices: Vec<Shape> = corners
2956 .iter()
2957 .map(|p| model.add_vertex(ogeom_topo::VertexData::new(*p)))
2958 .collect();
2959
2960 let knots = ogeom_math::KnotVector::new(vec![0.0, 0.0, 0.0, 1.0, 1.0, 1.0], 2).unwrap();
2961 let dip: ogeom_geom::Curve =
2962 ogeom_geom::BSplineCurve::new(knots.clone(), controls.to_vec(), T)
2963 .unwrap()
2964 .into();
2965 let mut edges = vec![
2966 crate::build::make_edge_between(
2967 &mut model,
2968 dip,
2969 (0.0, 1.0),
2970 &vertices[0],
2971 &vertices[1],
2972 T,
2973 )
2974 .unwrap()
2975 .shape,
2976 ];
2977 crate::attach_pcurve(
2978 &mut model,
2979 &edges[0],
2980 ogeom_geom::BSpline2d::new(knots, controls.iter().map(|p| flat(*p)).collect(), T)
2981 .unwrap()
2982 .into(),
2983 surface,
2984 ogeom_topo::Location::identity(),
2985 (0.0, 1.0),
2986 )
2987 .unwrap();
2988 for i in 1..corners.len() {
2989 let (a, b) = (corners[i], corners[(i + 1) % corners.len()]);
2990 let edge = crate::build::make_edge_between(
2991 &mut model,
2992 ogeom_geom::LineCurve::segment(a, b, T).unwrap().into(),
2993 (0.0, a.distance(b)),
2994 &vertices[i],
2995 &vertices[(i + 1) % corners.len()],
2996 T,
2997 )
2998 .unwrap()
2999 .shape;
3000 crate::attach_pcurve(
3001 &mut model,
3002 &edge,
3003 Line2d::segment(flat(a), flat(b), T).unwrap().into(),
3004 surface,
3005 ogeom_topo::Location::identity(),
3006 (0.0, a.distance(b)),
3007 )
3008 .unwrap();
3009 edges.push(edge);
3010 }
3011 let wire = crate::build::make_wire(&mut model, &edges, T)
3012 .unwrap()
3013 .shape;
3014 let profile = crate::build::make_face_on(&mut model, surface, &[wire], T)
3015 .unwrap()
3016 .shape;
3017
3018 let err = make_revolution(&mut model, &profile, Axis::Z, TAU, T).unwrap_err();
3019 assert!(
3020 err.to_string().contains("touches the axis"),
3021 "unexpected message: {err}"
3022 );
3023 }
3024
3025 #[test]
3026 fn a_turn_that_goes_nowhere_or_too_far_is_refused() {
3027 let mut model = Model::new();
3028 let profile = ring_profile(&mut model, 3.0, 2.0);
3029 for angle in [0.0, -1.0, TAU * 1.5, f64::NAN, f64::INFINITY] {
3030 assert!(
3031 make_revolution(&mut model, &profile, Axis::Z, angle, T).is_err(),
3032 "accepted {angle}"
3033 );
3034 }
3035 }
3036
3037 #[test]
3038 fn a_square_swept_upward_is_a_box() {
3039 let mut model = Model::new();
3040 let face = square(&mut model, 2.0);
3041 let built = make_prism(&mut model, &face, Vector::new(0.0, 0.0, 3.0), T).unwrap();
3042
3043 let counts = |kind| explore_unique(&model, &built.shape, kind).unwrap().len();
3044 assert_eq!(counts(ShapeType::Face), 6);
3045 assert_eq!(counts(ShapeType::Edge), 12);
3046 assert_eq!(counts(ShapeType::Vertex), 8);
3047
3048 let shell = explore_unique(&model, &built.shape, ShapeType::Shell).unwrap()[0].clone();
3049 assert!(is_shell_closed(&model, &shell).unwrap());
3050
3051 let props = volume_properties(&model, &built.shape, deflection(0.01), T).unwrap();
3052 assert_relative_eq!(props.mass, 12.0, epsilon = 1e-9);
3053 }
3054
3055 #[test]
3056 fn the_far_end_is_the_same_topology_at_a_different_place() {
3057 let mut model = Model::new();
3061 let face = square(&mut model, 1.0);
3062 let before = model.node_count();
3063 let built = make_prism(&mut model, &face, Vector::new(0.0, 0.0, 1.0), T).unwrap();
3064
3065 let faces = explore_unique(&model, &built.shape, ShapeType::Face).unwrap();
3066 let ends: Vec<&Shape> = faces.iter().filter(|f| f.is_partner(&face)).collect();
3067 assert_eq!(ends.len(), 2, "both ends share the profile's node");
3068 assert!(
3069 !ends[0].is_same(ends[1]),
3070 "and are still distinct, because their placements differ"
3071 );
3072
3073 assert!(
3076 model.node_count() - before < 20,
3077 "sweeping copied more than it should have: {} new nodes",
3078 model.node_count() - before
3079 );
3080 }
3081
3082 #[test]
3083 fn a_swept_edge_is_reported_as_both_surviving_and_generating() {
3084 let mut model = Model::new();
3088 let face = square(&mut model, 1.0);
3089 let edge = model
3090 .children_of(&model.children_of(&face).unwrap()[0])
3091 .unwrap()[0]
3092 .clone();
3093
3094 let built = make_prism(&mut model, &face, Vector::new(0.0, 0.0, 1.0), T).unwrap();
3095 let generated = built.history.generated(&edge);
3096 assert_eq!(
3097 generated.len(),
3098 2,
3099 "the lateral face and the displaced edge, got {generated:?}"
3100 );
3101 assert!(!built.history.is_deleted(&edge), "the edge survives");
3102 }
3103
3104 #[test]
3105 fn an_arc_sweeps_into_a_cylindrical_face_not_a_flat_one() {
3106 let mut model = Model::new();
3110 let (radius, height) = (2.0_f64, 5.0);
3111 let cylinder = crate::make_cylinder(&mut model, Frame::WORLD, radius, 1.0, T).unwrap();
3112 let rim = explore_unique(&model, &cylinder.shape, ShapeType::Edge)
3113 .unwrap()
3114 .into_iter()
3115 .find(|e| {
3116 model
3117 .node(e)
3118 .and_then(|n| n.data().as_edge())
3119 .and_then(ogeom_topo::EdgeData::curve3d)
3120 .is_some_and(|r| matches!(r, EdgeRepr::Curve3d { range, .. } if range.1 > 6.0))
3121 })
3122 .expect("the cylinder has a full circular rim");
3123
3124 let built = make_prism(&mut model, &rim, Vector::new(0.0, 0.0, height), T).unwrap();
3125 assert_eq!(model.kind_of(&built.shape).unwrap(), ShapeType::Face);
3126
3127 let mesh = triangulate(&model, &built.shape, deflection(0.005), T).unwrap();
3128 let area = mesh.area();
3129 let exact = std::f64::consts::TAU * radius * height;
3130 assert!(
3131 area < exact,
3132 "an inscribed area cannot exceed the surface's"
3133 );
3134 assert!(area > exact * 0.999, "{area} against {exact}");
3135 }
3136
3137 #[test]
3138 fn a_wire_sweeps_into_an_open_shell() {
3139 let mut model = Model::new();
3140 let face = square(&mut model, 2.0);
3141 let wire = model.children_of(&face).unwrap()[0].clone();
3142
3143 let built = make_prism(&mut model, &wire, Vector::new(0.0, 0.0, 3.0), T).unwrap();
3144 assert_eq!(model.kind_of(&built.shape).unwrap(), ShapeType::Shell);
3145 assert_eq!(
3146 explore_unique(&model, &built.shape, ShapeType::Face)
3147 .unwrap()
3148 .len(),
3149 4,
3150 "one side per edge, and no ends"
3151 );
3152 }
3153
3154 #[test]
3155 fn a_sweep_that_goes_nowhere_is_refused() {
3156 let mut model = Model::new();
3157 let face = square(&mut model, 1.0);
3158 for vector in [
3159 Vector::ZERO,
3160 Vector::new(f64::NAN, 0.0, 0.0),
3161 Vector::new(0.0, 0.0, f64::INFINITY),
3162 ] {
3163 assert!(make_prism(&mut model, &face, vector, T).is_err());
3164 }
3165 }
3166
3167 #[test]
3168 fn a_profile_that_has_been_placed_sweeps_where_it_actually_sits() {
3169 let mut model = Model::new();
3173 let face = square(&mut model, 2.0);
3174 let moved = crate::transformed(
3175 &mut model,
3176 &face,
3177 Transform::translation(Vector::new(10.0, 0.0, 0.0)),
3178 )
3179 .unwrap()
3180 .shape;
3181
3182 let built = make_prism(&mut model, &moved, Vector::new(0.0, 0.0, 3.0), T).unwrap();
3183 let mesh = triangulate(&model, &built.shape, deflection(0.01), T).unwrap();
3184 assert!(mesh.is_closed(), "the mesh has a slit in it");
3185 assert_relative_eq!(mesh.volume(), 12.0, epsilon = 1e-9);
3186
3187 let props = volume_properties(&model, &built.shape, deflection(0.01), T).unwrap();
3188 assert!(
3189 props.centre.distance(Point::new(11.0, 1.0, 3.5)) < 1e-9,
3190 "got {:?}",
3191 props.centre
3192 );
3193 }
3194
3195 #[test]
3196 fn a_profile_placed_with_a_scale_sweeps_at_the_size_it_is_now() {
3197 let mut model = Model::new();
3203 let face = square(&mut model, 2.0);
3204 let scaled = crate::transformed(
3205 &mut model,
3206 &face,
3207 Transform::scaling(Point::ORIGIN, 2.0, T).unwrap(),
3208 )
3209 .unwrap()
3210 .shape;
3211
3212 let built = make_prism(&mut model, &scaled, Vector::new(0.0, 0.0, 3.0), T).unwrap();
3213 let mesh = triangulate(&model, &built.shape, deflection(0.01), T).unwrap();
3214 assert!(mesh.is_closed(), "the mesh has a slit in it");
3215 assert_relative_eq!(mesh.volume(), 48.0, epsilon = 1e-9);
3217 }
3218
3219 #[test]
3220 fn a_face_swept_within_its_own_plane_is_refused() {
3221 let mut model = Model::new();
3225 let face = square(&mut model, 1.0);
3226 let err = make_prism(&mut model, &face, Vector::new(1.0, 1.0, 0.0), T).unwrap_err();
3227 assert!(
3228 err.to_string().contains("encloses no volume"),
3229 "unexpected message: {err}"
3230 );
3231 let wire = model.children_of(&face).unwrap()[0].clone();
3234 assert!(make_prism(&mut model, &wire, Vector::new(1.0, 1.0, 0.0), T).is_ok());
3235 }
3236
3237 #[test]
3238 fn a_vertex_is_not_something_this_sweeps() {
3239 let mut model = Model::new();
3243 let vertex = model.add_point(Point::ORIGIN);
3244 assert!(make_prism(&mut model, &vertex, Vector::Z, T).is_err());
3245 }
3246}