Skip to main content

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