axiolid_construct/
revolve_exact.rs

1//! Exact revolution for the profile families exact extrusion already covers.
2//!
3//! # What is exactly representable
4//!
5//! Revolving a rectangle a full turn about an axis it does not cross produces
6//! an annular tube: two cylinders (inner and outer radius) capped by two
7//! annular planes. Every one of those is an elementary surface the kernel
8//! already carries, so the result is exact -- no tessellation, no sampled
9//! approximation.
10//!
11//! # What is refused, and why that is the honest answer
12//!
13//! A PARTIAL turn is not this shape. It has two extra planar walls at the
14//! start and end angles, and its cap loops are not closed circles but
15//! circular arcs joined by radial segments. That is a different topology, not
16//! a parameter change, so it is refused rather than approximated.
17//!
18//! A profile crossing the axis degenerates: the inner cylinder collapses to
19//! the axis line and the caps stop being annuli. Refused for the same reason.
20//!
21//! The mesh path in `revolve.rs` handles all of these. Refusing here means a
22//! caller asking for exactness gets a typed refusal naming the gap instead of
23//! a silently tessellated substitute.
24
25use std::f64::consts::TAU;
26
27use axiolid_brep::{ExactBRep, ExactBRepBuilder};
28use axiolid_contracts::{GeomError, GeomResult, Operation};
29use axiolid_core::{Frame2, Interval, Point3, Scalar, Tolerance, Vec2, Vec3};
30use axiolid_curve::{Circle2, Circle3, Curve2, Curve3, Line2, Line3};
31use axiolid_overlay::ArcRing;
32use axiolid_profile::{Profile, RectangleProfile};
33use axiolid_surface::{Cylinder, Plane, Surface};
34use axiolid_topology::{
35    Edge, EdgeUse, Face, FaceBound, FaceId, Loop, Orientation, Shell, Solid, Vertex,
36};
37
38use crate::extrude_exact::extrude_profile_exact;
39use crate::BACKEND_ID;
40
41fn unsupported(input: &'static str) -> GeomError {
42    GeomError::UnsupportedInput {
43        backend: BACKEND_ID,
44        operation: Operation::Sweep,
45        input,
46    }
47}
48
49/// Revolve a supported profile into an exact, closed analytic B-rep.
50///
51/// Supported: a sharp filled rectangle, revolved a full turn about an axis
52/// parallel to the profile's local y and offset from it, so the profile does
53/// not cross the axis. The result is an annular tube of two cylinders and two
54/// annular planar caps.
55///
56/// Every other case -- partial turns, profiles crossing the axis, rounded or
57/// hollow rectangles, other profile families -- returns a typed refusal.
58pub fn revolve_profile_exact(
59    profile: &Profile,
60    axis_origin: Point3,
61    axis_direction: Vec3,
62    angle: Scalar,
63    tolerance: Tolerance,
64) -> GeomResult<ExactBRep> {
65    if !angle.is_finite() {
66        return Err(GeomError::InvalidInput(
67            "revolution angle must be finite".to_owned(),
68        ));
69    }
70    // A partial turn has two extra planar walls and arc-bounded caps: a
71    // different topology, not a different parameter.
72    if (angle.abs() - TAU).abs() > tolerance.linear() {
73        return Err(unsupported("partial-turn exact revolution"));
74    }
75
76    match profile {
77        Profile::Rectangle(rectangle) => {
78            revolve_rectangle(rectangle, axis_origin, axis_direction, tolerance)
79        }
80        // A circle lowers to four quarter arcs, each sweeping a torus
81        // quarter; a hollow circle's bore becomes a toroidal void (#111).
82        Profile::Circle(_) => revolve_via_contour(profile, axis_origin, axis_direction, tolerance),
83        Profile::Ellipse(_) => Err(unsupported("ellipse exact revolution")),
84        // Every one of these lowers to a contour, and a contour revolves.
85        // The refusals they carried described a missing module, not missing
86        // geometry, so they route through the general path rather than
87        // restating a limitation that no longer holds.
88        Profile::Section(_)
89        | Profile::Contour(_)
90        | Profile::Derived { .. }
91        | Profile::Composite(_)
92        | Profile::CenterLine(_) => {
93            revolve_via_contour(profile, axis_origin, axis_direction, tolerance)
94        }
95        _ => Err(unsupported("unknown profile exact revolution")),
96    }
97}
98
99fn revolve_rectangle(
100    rectangle: &RectangleProfile,
101    axis_origin: Point3,
102    axis_direction: Vec3,
103    tolerance: Tolerance,
104) -> GeomResult<ExactBRep> {
105    if rectangle.thickness.is_some()
106        || rectangle.outer_radius.is_some()
107        || rectangle.inner_radius.is_some()
108    {
109        // Rounded corners revolve into tori and a hollow section's opening
110        // into a void, both of which the general contour revolver builds;
111        // the dedicated path below only knows one straight-edged ring.
112        return revolve_via_contour(
113            &Profile::Rectangle(*rectangle),
114            axis_origin,
115            axis_direction,
116            tolerance,
117        );
118    }
119    if !rectangle.x.is_finite()
120        || !rectangle.y.is_finite()
121        || rectangle.x <= 0.0
122        || rectangle.y <= 0.0
123    {
124        return Err(GeomError::InvalidInput(format!(
125            "exact rectangle profile must have positive finite extents, got {} x {}",
126            rectangle.x, rectangle.y
127        )));
128    }
129
130    let length = axis_direction.length();
131    if !length.is_finite() || length <= 0.0 {
132        return Err(GeomError::InvalidInput(
133            "revolution axis must be a finite non-zero direction".to_owned(),
134        ));
135    }
136    let axis = axis_direction / length;
137
138    // The profile lives in the z = 0 plane. Only an axis parallel to local y
139    // sweeps its edges into cylinders and its horizontal edges into annuli;
140    // any other axis produces cones or general surfaces of revolution, which
141    // is a different construction.
142    if (axis.dot(Vec3::Y).abs() - 1.0).abs() > tolerance.linear() {
143        return Err(unsupported(
144            "exact revolution about an axis that is not the profile's local y",
145        ));
146    }
147    if axis_origin.z.abs() > tolerance.linear() {
148        return Err(unsupported(
149            "exact revolution about an axis off the profile plane",
150        ));
151    }
152
153    let (half_x, half_y) = (rectangle.x / 2.0, rectangle.y / 2.0);
154    // Signed offsets of the rectangle's two vertical edges from the axis. The
155    // rectangle is centred on the origin, so these are its x extents measured
156    // in the axis's frame.
157    let left = -half_x - axis_origin.x;
158    let right = half_x - axis_origin.x;
159
160    // Straddling the axis means the swept solid is not an annulus: the inner
161    // wall collapses onto the axis and the caps stop being annuli. That is a
162    // different topology, so refuse rather than emit a degenerate cylinder.
163    if left * right < 0.0 || left.abs() <= tolerance.linear() || right.abs() <= tolerance.linear() {
164        return Err(unsupported(
165            "exact revolution of a profile touching or crossing the axis",
166        ));
167    }
168    let inner = left.abs().min(right.abs());
169    let outer = left.abs().max(right.abs());
170
171    // The rectangle spans y in [-half_y, half_y] around the axis origin.
172    let bottom = axis_origin.y - half_y;
173    let top = axis_origin.y + half_y;
174    // Right-handed, with `z` along the axis: see `revolve_contour::frame_at`.
175    let frame_at = |y: Scalar| crate::revolve_contour::frame_at(axis_origin.x, y);
176    let frame2 = Frame2 {
177        origin: Vec2::ZERO,
178        x: Vec2::X,
179        y: Vec2::Y,
180    };
181
182    let mut builder = ExactBRepBuilder::default();
183    // 4 vertices, 4 circular edges, 4 loops, 4 faces.
184    builder
185        .topology_mut()
186        .try_reserve(4, 6, 6, 4, 1, 1)
187        .map_err(|_| GeomError::BudgetExceeded {
188            resource: "exact revolution topology",
189        })?;
190
191    // One circular edge per (height, radius) corner of the revolved section.
192    let circle_edge = |builder: &mut ExactBRepBuilder, y: Scalar, radius: Scalar| {
193        let position = Point3::new(axis_origin.x + radius, y, 0.0);
194        let vertex = builder.topology_mut().add_vertex(Vertex { position });
195        let curve = builder.add_curve3(Curve3::Circle(Circle3 {
196            frame: frame_at(y),
197            radius,
198        }));
199        let edge = builder.topology_mut().add_edge(Edge {
200            start: vertex,
201            end: vertex,
202            curve: Some(curve),
203        });
204        builder.set_edge_interval(edge, Interval::new(0.0, TAU));
205        (edge, vertex, position)
206    };
207
208    let (bottom_outer, bottom_outer_vertex, bottom_outer_point) =
209        circle_edge(&mut builder, bottom, outer);
210    let (bottom_inner, bottom_inner_vertex, bottom_inner_point) =
211        circle_edge(&mut builder, bottom, inner);
212    let (top_outer, top_outer_vertex, _) = circle_edge(&mut builder, top, outer);
213    let (top_inner, top_inner_vertex, _) = circle_edge(&mut builder, top, inner);
214
215    // Seam edges close the cylinder walls; see `wall_loop`.
216    let seam_edge = |builder: &mut ExactBRepBuilder, start, end, from: Point3| {
217        let curve = builder.add_curve3(Curve3::Line(Line3 {
218            origin: from,
219            direction: Vec3::new(0.0, top - bottom, 0.0),
220        }));
221        let edge = builder.topology_mut().add_edge(Edge {
222            start,
223            end,
224            curve: Some(curve),
225        });
226        builder.set_edge_interval(edge, Interval::UNIT);
227        edge
228    };
229    let outer_seam = seam_edge(
230        &mut builder,
231        bottom_outer_vertex,
232        top_outer_vertex,
233        bottom_outer_point,
234    );
235    let inner_seam = seam_edge(
236        &mut builder,
237        bottom_inner_vertex,
238        top_inner_vertex,
239        bottom_inner_point,
240    );
241
242    // Cap loops: an annulus has an outer boundary and an inner hole, so each
243    // cap face carries two loops rather than one.
244    let circle_loop = |builder: &mut ExactBRepBuilder, edge, radius, orientation| {
245        let pcurve = builder.add_curve2(Curve2::Circle(Circle2 {
246            frame: frame2,
247            radius,
248        }));
249        let loop_id = builder.topology_mut().add_loop(Loop {
250            edges: vec![EdgeUse {
251                edge,
252                orientation,
253                pcurve: Some(pcurve),
254            }],
255        });
256        // A pcurve interval runs the way its use traverses the edge
257        // (ADR 0024): a reversed use walks the circle backwards.
258        let interval = match orientation {
259            Orientation::Forward => Interval::new(0.0, TAU),
260            Orientation::Reversed => Interval::new(TAU, 0.0),
261        };
262        builder.set_pcurve_interval(loop_id, 0, interval);
263        loop_id
264    };
265
266    let bottom_outer_loop = circle_loop(&mut builder, bottom_outer, outer, Orientation::Forward);
267    let bottom_inner_loop = circle_loop(&mut builder, bottom_inner, inner, Orientation::Reversed);
268    let top_outer_loop = circle_loop(&mut builder, top_outer, outer, Orientation::Forward);
269    let top_inner_loop = circle_loop(&mut builder, top_inner, inner, Orientation::Reversed);
270
271    // A cylinder wall between two coaxial circles needs a SEAM edge. A loop
272    // must be vertex-connected -- consecutive edges share an endpoint -- and
273    // the two circles are disjoint, so a two-edge loop is an open loop and the
274    // topology audit rejects it. The seam runs up the wall and is traversed
275    // once forward and once reversed, closing the loop without adding area.
276    let wall_loop = |builder: &mut ExactBRepBuilder, lower, upper, seam, height: Scalar| {
277        let lower_pcurve = builder.add_curve2(Curve2::Line(Line2 {
278            origin: Vec2::ZERO,
279            direction: Vec2::X,
280        }));
281        let upper_pcurve = builder.add_curve2(Curve2::Line(Line2 {
282            origin: Vec2::new(0.0, height),
283            direction: Vec2::X,
284        }));
285        let up_pcurve = builder.add_curve2(Curve2::Line(Line2 {
286            origin: Vec2::new(TAU, 0.0),
287            direction: Vec2::new(0.0, height),
288        }));
289        let down_pcurve = builder.add_curve2(Curve2::Line(Line2 {
290            origin: Vec2::ZERO,
291            direction: Vec2::new(0.0, height),
292        }));
293        let loop_id = builder.topology_mut().add_loop(Loop {
294            edges: vec![
295                EdgeUse {
296                    edge: lower,
297                    orientation: Orientation::Forward,
298                    pcurve: Some(lower_pcurve),
299                },
300                EdgeUse {
301                    edge: seam,
302                    orientation: Orientation::Forward,
303                    pcurve: Some(up_pcurve),
304                },
305                EdgeUse {
306                    edge: upper,
307                    orientation: Orientation::Reversed,
308                    pcurve: Some(upper_pcurve),
309                },
310                EdgeUse {
311                    edge: seam,
312                    orientation: Orientation::Reversed,
313                    pcurve: Some(down_pcurve),
314                },
315            ],
316        });
317        builder.set_pcurve_interval(loop_id, 0, Interval::new(0.0, TAU));
318        builder.set_pcurve_interval(loop_id, 1, Interval::UNIT);
319        builder.set_pcurve_interval(loop_id, 2, Interval::new(TAU, 0.0));
320        builder.set_pcurve_interval(loop_id, 3, Interval::new(1.0, 0.0));
321        loop_id
322    };
323
324    let height = top - bottom;
325    let outer_loop = wall_loop(&mut builder, bottom_outer, top_outer, outer_seam, height);
326    let inner_loop = wall_loop(&mut builder, bottom_inner, top_inner, inner_seam, height);
327
328    let bottom_surface = builder.add_surface(Surface::Plane(Plane {
329        frame: frame_at(bottom),
330    }));
331    let top_surface = builder.add_surface(Surface::Plane(Plane {
332        frame: frame_at(top),
333    }));
334    let outer_surface = builder.add_surface(Surface::Cylinder(Cylinder {
335        frame: frame_at(bottom),
336        radius: outer,
337    }));
338    let inner_surface = builder.add_surface(Surface::Cylinder(Cylinder {
339        frame: frame_at(bottom),
340        radius: inner,
341    }));
342
343    // Each cap is an annulus: outer boundary plus an inner hole.
344    let annulus = |builder: &mut ExactBRepBuilder, surface, outer_loop, inner_loop, orientation| {
345        builder.topology_mut().add_face(Face {
346            surface: Some(surface),
347            bounds: vec![
348                FaceBound {
349                    loop_id: outer_loop,
350                    orientation: Orientation::Forward,
351                    outer: true,
352                },
353                FaceBound {
354                    loop_id: inner_loop,
355                    orientation: Orientation::Forward,
356                    outer: false,
357                },
358            ],
359            orientation,
360        })
361    };
362
363    let bottom_face = annulus(
364        &mut builder,
365        bottom_surface,
366        bottom_outer_loop,
367        bottom_inner_loop,
368        Orientation::Reversed,
369    );
370    let top_face = annulus(
371        &mut builder,
372        top_surface,
373        top_outer_loop,
374        top_inner_loop,
375        Orientation::Forward,
376    );
377
378    let wall_face = |builder: &mut ExactBRepBuilder, surface, loop_id, orientation| {
379        builder.topology_mut().add_face(Face {
380            surface: Some(surface),
381            bounds: vec![FaceBound {
382                loop_id,
383                orientation: Orientation::Forward,
384                outer: true,
385            }],
386            orientation,
387        })
388    };
389    // The inner wall faces inward, so its orientation is reversed relative to
390    // the outer wall: both must point out of the material.
391    let outer_face = wall_face(
392        &mut builder,
393        outer_surface,
394        outer_loop,
395        Orientation::Forward,
396    );
397    let inner_face = wall_face(
398        &mut builder,
399        inner_surface,
400        inner_loop,
401        Orientation::Reversed,
402    );
403
404    finish_solid(builder, vec![bottom_face, top_face, outer_face, inner_face])
405}
406
407/// Assemble the faces into a closed solid and audit it.
408///
409/// The manifold audit is the real gate: it catches a wall wound the wrong way
410/// or a cap loop that does not close, which are exactly the mistakes that
411/// produce a B-rep that looks plausible and bounds nothing.
412fn finish_solid(mut builder: ExactBRepBuilder, faces: Vec<FaceId>) -> GeomResult<ExactBRep> {
413    let shell = builder.topology_mut().add_shell(Shell {
414        faces: faces
415            .into_iter()
416            .map(|face| (face, Orientation::Forward))
417            .collect(),
418        closed: true,
419    });
420    builder.topology_mut().add_solid(Solid {
421        outer: shell,
422        voids: Vec::new(),
423    });
424    let exact = builder
425        .finish()
426        .map_err(|error| GeomError::BackendContractViolation {
427            backend: BACKEND_ID,
428            detail: format!("exact revolution assembly failed: {error}"),
429        })?;
430    let health = axiolid_topology::audit_brep(exact.topology());
431    if !health.is_closed_manifold() {
432        return Err(GeomError::BackendContractViolation {
433            backend: BACKEND_ID,
434            detail: format!("exact revolution is not a closed manifold: {health:?}"),
435        });
436    }
437    Ok(exact)
438}
439
440/// Sweep a supported profile along a straight path with a fixed reference.
441///
442/// A fixed-reference sweep along a STRAIGHT directrix keeps the profile's
443/// orientation constant, so the swept solid is exactly a linear extrusion
444/// along that segment. Rather than build a second implementation that could
445/// drift from it, this delegates to `extrude_profile_exact`, which is the
446/// same solid by construction.
447///
448/// A curved directrix is refused: the profile then rotates along the path and
449/// the walls become general swept surfaces, not planes and cylinders. That is
450/// the curved-surface work this issue explicitly puts out of scope.
451pub fn fixed_reference_sweep_exact(
452    profile: &Profile,
453    path: &[Point3],
454    reference_direction: Vec3,
455    tolerance: Tolerance,
456) -> GeomResult<ExactBRep> {
457    if path.len() < 2 {
458        return Err(GeomError::InvalidInput(
459            "a sweep path needs at least two points".to_owned(),
460        ));
461    }
462    if !path.iter().all(|p| p.is_finite()) {
463        return Err(GeomError::InvalidInput(
464            "sweep path points must be finite".to_owned(),
465        ));
466    }
467    if !reference_direction.is_finite() || reference_direction.length() <= 0.0 {
468        return Err(GeomError::InvalidInput(
469            "sweep reference direction must be finite and non-zero".to_owned(),
470        ));
471    }
472
473    // Straightness is the precondition, so it is checked rather than assumed:
474    // every interior point must lie on the segment from first to last.
475    let start = path[0];
476    let end = path[path.len() - 1];
477    let span = end - start;
478    let length = span.length();
479    if length <= tolerance.linear() {
480        return Err(GeomError::Degenerate(
481            "sweep path start and end coincide, so the path has no direction".to_owned(),
482        ));
483    }
484    let direction = span / length;
485    for point in &path[1..path.len() - 1] {
486        let offset = *point - start;
487        let perpendicular = offset - direction * direction.dot(offset);
488        if perpendicular.length() > tolerance.linear() {
489            return Err(unsupported("exact sweep along a curved directrix"));
490        }
491    }
492
493    extrude_profile_exact(profile, direction, length, tolerance)
494}
495
496/// Revolve any profile that lowers to a contour.
497///
498/// The axis must be the profile's local y and must lie in the profile plane,
499/// the same restriction `revolve_rectangle` carries: any other axis sweeps a
500/// general surface of revolution rather than the named quadrics this builds.
501fn revolve_via_contour(
502    profile: &Profile,
503    axis_origin: Point3,
504    axis_direction: Vec3,
505    tolerance: Tolerance,
506) -> GeomResult<ExactBRep> {
507    let axis = axis_direction.normalize_or_zero();
508    if (axis.dot(Vec3::Y).abs() - 1.0).abs() > tolerance.linear() {
509        return Err(unsupported(
510            "exact revolution about an axis that is not the profile's local y",
511        ));
512    }
513    if axis_origin.z.abs() > tolerance.linear() {
514        return Err(unsupported(
515            "exact revolution about an axis off the profile plane",
516        ));
517    }
518
519    // A composite is unioned exactly first; members that do not touch
520    // revolve into separate solids of one `ExactBRep` (#111).
521    if let Profile::Composite(members) = profile {
522        let regions = crate::profile_lower::composite_regions(members, tolerance)?;
523        let mut pieces = Vec::with_capacity(regions.len());
524        for region in &regions {
525            pieces.push(revolve_region(
526                &region.outer,
527                &region.holes,
528                axis_origin,
529                tolerance,
530            )?);
531        }
532        return crate::assemble::merge_solids(pieces);
533    }
534
535    let contour = crate::extrude_exact::profile_to_contour(profile, tolerance)?;
536    let outer = crate::contour_lower::contour_to_arc_ring(&contour.outer, tolerance)?;
537    let holes = contour
538        .holes
539        .iter()
540        .map(|hole| crate::contour_lower::contour_to_arc_ring(hole, tolerance))
541        .collect::<GeomResult<Vec<_>>>()?;
542    revolve_region(&outer, &holes, axis_origin, tolerance)
543}
544
545/// Revolve one section a full turn: its outer ring as the solid, each hole
546/// as a void.
547fn revolve_region(
548    outer: &ArcRing,
549    holes: &[ArcRing],
550    axis_origin: Point3,
551    tolerance: Tolerance,
552) -> GeomResult<ExactBRep> {
553    let solid = crate::revolve_contour::revolve_arc_ring(outer, axis_origin, tolerance)?;
554    if holes.is_empty() {
555        return Ok(solid);
556    }
557    // A hole swept a full turn encloses a ring-shaped cavity: it touches
558    // neither cap, so it is a second shell, not a second loop. Each hole
559    // revolves on its own and joins the solid as a void, its faces used
560    // reversed so they face into the cavity (#111).
561    let mut builder = ExactBRepBuilder::default();
562    let outer = builder.append(&solid, false);
563    let mut voids = Vec::with_capacity(holes.len());
564    for hole in holes {
565        let cavity = crate::revolve_contour::revolve_arc_ring(hole, axis_origin, tolerance)?;
566        voids.extend(builder.append(&cavity, true));
567    }
568    let [outer] = outer[..] else {
569        return Err(GeomError::Degenerate(
570            "a revolved section must have one outer shell".to_owned(),
571        ));
572    };
573    builder.topology_mut().add_solid(Solid { outer, voids });
574    builder.finish().map_err(|error| {
575        GeomError::Degenerate(format!(
576            "revolved section with holes did not assemble: {error}"
577        ))
578    })
579}