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 crossing the axis sweeps through itself and is
27//! refused, as is a partial turn crossing it.
28//!
29//! A FULL turn of a profile touching the axis (#379) is exact when each
30//! vertex on the axis ends an edge lying on the axis, which sweeps no face,
31//! and its other edge is another such edge, a straight edge ending on the
32//! axis exactly perpendicularly, which sweeps a disc cap, or any other
33//! straight edge, which sweeps a cone up to its apex, or an arc ending on
34//! the axis with its circle centred on it, which sweeps a sphere up to its
35//! pole: a solid cylinder, a stepped shaft, a cone, a bolt, a spinning top, a
36//! ball, a domed column. The contact is classified with the exact
37//! `side_of_axis` sign, no tolerance; whether an arc's circle is centred on
38//! the axis is decided within the rounding of its coordinates (the arc is a
39//! bulge, which cannot encode a sphere's meridian exactly). The apex or pole
40//! is a vertex of the B-rep, not a degenerate edge (ADR 0059). A vertex on
41//! the axis with no edge along it (a pinch point), an arc whose circle is not
42//! centred on the axis (a spindle or horn torus, a lemon or an apple), an arc
43//! with both ends on the axis and an arc running across it are refused by
44//! name, as is a straight edge ending on the axis within the tolerance of
45//! perpendicular to it but not exactly.
46//!
47//! # The axis (#450)
48//!
49//! An axis exactly along +y or -y builds in place. Any other axis lying
50//! exactly in the profile plane (`z` zero in its direction and origin) is
51//! built exactly as given: the section is mapped rigidly into the axis's own
52//! frame, revolved about +y there and placed back with
53//! [`ExactBRep::transformed`], so a tilt of a milliradian is a tilt of a
54//! milliradian, never snapped onto +y. A direction with a `z` component, or
55//! an origin off the plane, sweeps hyperboloids and is refused by name: there
56//! is no tolerance band to offer. Rejected: snapping the axis within the
57//! tolerance and reporting it (a tilt of 1.4 mrad over 10 m moves geometry by
58//! 14 mm, and construct has no reading channel).
59//!
60//! The mesh path in `revolve.rs` handles all of these. Refusing here means a
61//! caller asking for exactness gets a typed refusal naming the gap instead of
62//! a silently tessellated substitute.
63
64use std::f64::consts::TAU;
65
66use axiolid_brep::{ExactBRep, ExactBRepBuilder};
67use axiolid_contracts::{GeomError, GeomResult, Operation};
68use axiolid_core::{Frame2, Interval, Point2, Point3, Scalar, Tolerance, Transform3, Vec2, Vec3};
69use axiolid_curve::{Circle2, Circle3, Curve2, Curve3, Line2, Line3};
70use axiolid_guarantees::Sign;
71use axiolid_overlay::{ArcRing, ArcVertex};
72use axiolid_profile::{Profile, RectangleProfile};
73use axiolid_surface::{Cylinder, Plane, Surface};
74use axiolid_topology::{
75    Edge, EdgeUse, Face, FaceBound, FaceId, Loop, Orientation, Shell, Solid, Vertex,
76};
77
78use crate::extrude_exact::extrude_profile_exact;
79use crate::revolve::{along_axis, side_of_axis};
80use crate::BACKEND_ID;
81
82fn unsupported(input: &'static str) -> GeomError {
83    GeomError::UnsupportedInput {
84        backend: BACKEND_ID,
85        operation: Operation::Sweep,
86        input,
87    }
88}
89
90/// Revolve a supported profile into an exact, closed analytic B-rep.
91///
92/// The axis must lie in the profile plane, exactly: `z` is zero in its
93/// direction and its origin. Along y (either sense) it builds in place; any
94/// other in-plane direction builds in the axis's own frame and is placed back
95/// (#450). `angle` is in radians, by the right-hand rule about
96/// `axis_direction`.
97///
98/// A full turn (`|angle|` within tolerance of `2 pi`) revolves every profile
99/// that lowers to a contour, provided it does not cross the axis and touches
100/// it only along edges or at perpendicular edge ends (#379): a sharp
101/// rectangle becomes an annular tube of two cylinders and two annular caps,
102/// a hole becomes a void shell. A partial turn (`0 < |angle| < 2 pi`)
103/// revolves the same profiles into a solid capped by the profile at both
104/// ends, each hole a tunnel between the caps, and may touch the axis along
105/// straight segments (see [`crate::revolve_partial`]).
106///
107/// Refused by name: an ellipse, a turn beyond a full turn, a profile crossing
108/// the axis, one touching it at an isolated vertex (a pinch point), an arc
109/// whose circle reaches the axis without being centred on it (spindle or horn
110/// torus), an axis out of the profile plane. A zero or non-finite angle is
111/// invalid input.
112pub fn revolve_profile_exact(
113    profile: &Profile,
114    axis_origin: Point3,
115    axis_direction: Vec3,
116    angle: Scalar,
117    tolerance: Tolerance,
118) -> GeomResult<ExactBRep> {
119    let frame = axis_frame(axis_origin, axis_direction)?;
120    if !angle.is_finite() || angle == 0.0 {
121        return Err(GeomError::InvalidInput(format!(
122            "revolution angle must be finite and non-zero, got {angle}"
123        )));
124    }
125    // More than a full turn sweeps the solid through itself.
126    if angle.abs() > TAU + tolerance.linear() {
127        return Err(unsupported("exact revolution beyond a full turn"));
128    }
129    // A partial turn has two extra planar walls and arc-bounded caps: a
130    // different topology, not a different parameter (#172).
131    if (angle.abs() - TAU).abs() > tolerance.linear() {
132        return revolve_partial(profile, &frame, angle, tolerance);
133    }
134
135    match profile {
136        // The dedicated rectangle builder knows only the axis along y.
137        Profile::Rectangle(rectangle) if frame.placement.is_none() => {
138            revolve_rectangle(rectangle, &frame, tolerance)
139        }
140        // A circle lowers to four quarter arcs, each sweeping a torus
141        // quarter; a hollow circle's bore becomes a toroidal void (#111).
142        Profile::Circle(_) => revolve_via_contour(profile, &frame, None, tolerance),
143        Profile::Rectangle(_) => revolve_via_contour(profile, &frame, None, tolerance),
144        Profile::Ellipse(_) => Err(unsupported("ellipse exact revolution")),
145        // Every one of these lowers to a contour, and a contour revolves.
146        // The refusals they carried described a missing module, not missing
147        // geometry, so they route through the general path rather than
148        // restating a limitation that no longer holds.
149        Profile::Section(_)
150        | Profile::Contour(_)
151        | Profile::Derived { .. }
152        | Profile::Composite(_)
153        | Profile::CenterLine(_) => revolve_via_contour(profile, &frame, None, tolerance),
154        _ => Err(unsupported("unknown profile exact revolution")),
155    }
156}
157
158/// The axis of a revolution, in the profile plane (#450).
159#[derive(Debug, Clone)]
160struct AxisFrame {
161    /// A point of the axis, as given.
162    origin: Point2,
163    /// The axis direction as given, in 3D (`z` zero): the exact sign tests
164    /// run against it.
165    given: Vec3,
166    /// The unit direction in the plane, the frame's y.
167    unit: Vec2,
168    /// Where the frame sits in the profile plane: `None` for an axis exactly
169    /// along y, built in place.
170    placement: Option<Transform3>,
171}
172
173/// The axis `axis_origin` + `axis_direction`, which must lie exactly in the
174/// profile plane.
175///
176/// A direction along +y or -y (`x` exactly zero) is the identity. Any other
177/// in-plane direction `d` has the frame `(n, d, z)` with `n = (d.y, -d.x)`,
178/// right-handed, whose origin is the axis origin: a section mapped into it is
179/// revolved about +y there, and the placement maps the result back.
180fn axis_frame(axis_origin: Point3, axis_direction: Vec3) -> GeomResult<AxisFrame> {
181    if !(axis_origin.is_finite() && axis_direction.is_finite()) || axis_direction == Vec3::ZERO {
182        return Err(GeomError::InvalidInput(
183            "revolution axis must be a finite non-zero direction through a finite origin"
184                .to_owned(),
185        ));
186    }
187    // A component out of the plane sweeps hyperboloids: no exact family, and
188    // no tolerance band to offer.
189    if axis_direction.z != 0.0 || axis_origin.z != 0.0 {
190        return Err(unsupported(
191            "exact revolution about an axis out of the profile plane",
192        ));
193    }
194    let origin = Point2::new(axis_origin.x, axis_origin.y);
195    let length = axis_direction.x.hypot(axis_direction.y);
196    let unit = Vec2::new(axis_direction.x / length, axis_direction.y / length);
197    let placement = (axis_direction.x != 0.0).then(|| {
198        let normal = Vec2::new(unit.y, -unit.x);
199        Transform3::from_cols(
200            Vec3::new(normal.x, normal.y, 0.0),
201            Vec3::new(unit.x, unit.y, 0.0),
202            Vec3::Z,
203            Vec3::new(origin.x, origin.y, 0.0),
204        )
205    });
206    Ok(AxisFrame {
207        origin,
208        given: axis_direction,
209        unit,
210        placement,
211    })
212}
213
214/// `ring` in the axis's frame: `p' = ((p - o) . n, (p - o) . d)`, the bulges
215/// kept (a rigid map keeps an arc's bulge).
216///
217/// The revolution classifies a vertex on the axis with an exact sign against
218/// +y, so the map keeps the classification of the original: a vertex exactly
219/// on the axis (the exact sign against the axis as given) maps to `x' = 0`,
220/// and any other has `x'` of the sign of its side. A vertex whose `x'` rounds
221/// to zero or the wrong sign is closer to the axis than the frame can tell
222/// and is refused. An edge exactly perpendicular to the axis with one end on
223/// it keeps equal heights, which makes a disc; an edge that is not, whose
224/// heights come out equal, is refused.
225fn into_axis_frame(ring: &ArcRing, frame: &AxisFrame) -> GeomResult<ArcRing> {
226    let normal = Vec2::new(frame.unit.y, -frame.unit.x);
227    let count = ring.vertices.len();
228    let mut points = Vec::with_capacity(count);
229    let mut on_axis = Vec::with_capacity(count);
230    for vertex in &ring.vertices {
231        let p = vertex.point;
232        if !(p.x.is_finite() && p.y.is_finite()) {
233            return Err(GeomError::InvalidInput(
234                "revolved section must have finite vertices".to_owned(),
235            ));
236        }
237        let offset = p - frame.origin;
238        let side = side_of_axis(frame.origin, frame.given, p);
239        let across = offset.x * normal.x + offset.y * normal.y;
240        let along = offset.x * frame.unit.x + offset.y * frame.unit.y;
241        // Positive is the left of the axis: negative `x'`.
242        let consistent = match side {
243            Sign::Zero => true,
244            Sign::Positive => across < 0.0,
245            Sign::Negative => across > 0.0,
246            _ => false,
247        };
248        if !consistent {
249            return Err(unsupported(
250                "exact revolution of a section with a vertex closer to the axis than the rounding of the axis frame",
251            ));
252        }
253        on_axis.push(side == Sign::Zero);
254        points.push(Point2::new(
255            if side == Sign::Zero { 0.0 } else { across },
256            along,
257        ));
258    }
259    for index in 0..count {
260        let next = (index + 1) % count;
261        if on_axis[index] == on_axis[next] || ring.vertices[index].bulge != 0.0 {
262            continue;
263        }
264        let (on, off) = if on_axis[index] {
265            (index, next)
266        } else {
267            (next, index)
268        };
269        let from = ring.vertices[index].point;
270        let to = ring.vertices[next].point;
271        if along_axis(frame.given, from, to) == Sign::Zero {
272            points[on].y = points[off].y;
273        } else if points[on].y == points[off].y {
274            return Err(unsupported(
275                "exact revolution of an edge ending on the axis within the tolerance of perpendicular to it but not exactly",
276            ));
277        }
278    }
279    Ok(ArcRing::new(
280        ring.vertices
281            .iter()
282            .zip(points)
283            .map(|(vertex, point)| ArcVertex {
284                point,
285                bulge: vertex.bulge,
286            })
287            .collect(),
288    ))
289}
290
291/// `solid`, built in the axis's frame, placed where the axis is.
292fn place_in_axis_frame(solid: ExactBRep, frame: &AxisFrame) -> GeomResult<ExactBRep> {
293    match &frame.placement {
294        None => Ok(solid),
295        Some(placement) => {
296            solid
297                .transformed(placement)
298                .map_err(|error| GeomError::BackendContractViolation {
299                    backend: BACKEND_ID,
300                    detail: format!("placing a revolution in its axis frame failed: {error}"),
301                })
302        }
303    }
304}
305
306/// The dedicated rectangle builder, for an axis exactly along y.
307fn revolve_rectangle(
308    rectangle: &RectangleProfile,
309    frame: &AxisFrame,
310    tolerance: Tolerance,
311) -> GeomResult<ExactBRep> {
312    let axis_origin = Point3::new(frame.origin.x, frame.origin.y, 0.0);
313    if rectangle.thickness.is_some()
314        || rectangle.outer_radius.is_some()
315        || rectangle.inner_radius.is_some()
316    {
317        // Rounded corners revolve into tori and a hollow section's opening
318        // into a void, both of which the general contour revolver builds;
319        // the dedicated path below only knows one straight-edged ring.
320        return revolve_via_contour(&Profile::Rectangle(*rectangle), frame, None, tolerance);
321    }
322    if !rectangle.x.is_finite()
323        || !rectangle.y.is_finite()
324        || rectangle.x <= 0.0
325        || rectangle.y <= 0.0
326    {
327        return Err(GeomError::InvalidInput(format!(
328            "exact rectangle profile must have positive finite extents, got {} x {}",
329            rectangle.x, rectangle.y
330        )));
331    }
332
333    let (half_x, half_y) = (rectangle.x / 2.0, rectangle.y / 2.0);
334    // Signed offsets of the rectangle's two vertical edges from the axis. The
335    // rectangle is centred on the origin, so these are its x extents measured
336    // in the axis's frame.
337    let left = -half_x - axis_origin.x;
338    let right = half_x - axis_origin.x;
339
340    // Straddling the axis means the swept solid is not an annulus: the inner
341    // wall collapses onto the axis and the caps stop being annuli. That is a
342    // different topology, so refuse rather than emit a degenerate cylinder.
343    // An edge exactly on the axis is a solid cylinder, which the contour path
344    // builds (#379); crossing it, or lying within the tolerance of it without
345    // touching it, is refused there and here.
346    if left == 0.0 || right == 0.0 {
347        return revolve_via_contour(&Profile::Rectangle(*rectangle), frame, None, tolerance);
348    }
349    if left * right < 0.0 {
350        return Err(unsupported(
351            "exact revolution of a section crossing the axis",
352        ));
353    }
354    if left.abs() <= tolerance.linear() || right.abs() <= tolerance.linear() {
355        return Err(unsupported(
356            "exact revolution of a section within tolerance of the axis but not on it",
357        ));
358    }
359    let inner = left.abs().min(right.abs());
360    let outer = left.abs().max(right.abs());
361
362    // The rectangle spans y in [-half_y, half_y] around the axis origin.
363    let bottom = axis_origin.y - half_y;
364    let top = axis_origin.y + half_y;
365    // Right-handed, with `z` along the axis: see `revolve_contour::frame_at`.
366    let frame_at = |y: Scalar| crate::revolve_contour::frame_at(axis_origin.x, y);
367    let frame2 = Frame2 {
368        origin: Vec2::ZERO,
369        x: Vec2::X,
370        y: Vec2::Y,
371    };
372
373    let mut builder = ExactBRepBuilder::default();
374    // 4 vertices, 4 circular edges, 4 loops, 4 faces.
375    builder
376        .topology_mut()
377        .try_reserve(4, 6, 6, 4, 1, 1)
378        .map_err(|_| GeomError::BudgetExceeded {
379            resource: "exact revolution topology",
380        })?;
381
382    // One circular edge per (height, radius) corner of the revolved section.
383    let circle_edge = |builder: &mut ExactBRepBuilder, y: Scalar, radius: Scalar| {
384        let position = Point3::new(axis_origin.x + radius, y, 0.0);
385        let vertex = builder.topology_mut().add_vertex(Vertex { position });
386        let curve = builder.add_curve3(Curve3::Circle(Circle3 {
387            frame: frame_at(y),
388            radius,
389        }));
390        let edge = builder.topology_mut().add_edge(Edge {
391            start: vertex,
392            end: vertex,
393            curve: Some(curve),
394        });
395        builder.set_edge_interval(edge, Interval::new(0.0, TAU));
396        (edge, vertex, position)
397    };
398
399    let (bottom_outer, bottom_outer_vertex, bottom_outer_point) =
400        circle_edge(&mut builder, bottom, outer);
401    let (bottom_inner, bottom_inner_vertex, bottom_inner_point) =
402        circle_edge(&mut builder, bottom, inner);
403    let (top_outer, top_outer_vertex, _) = circle_edge(&mut builder, top, outer);
404    let (top_inner, top_inner_vertex, _) = circle_edge(&mut builder, top, inner);
405
406    // Seam edges close the cylinder walls; see `wall_loop`.
407    let seam_edge = |builder: &mut ExactBRepBuilder, start, end, from: Point3| {
408        let curve = builder.add_curve3(Curve3::Line(Line3 {
409            origin: from,
410            direction: Vec3::new(0.0, top - bottom, 0.0),
411        }));
412        let edge = builder.topology_mut().add_edge(Edge {
413            start,
414            end,
415            curve: Some(curve),
416        });
417        builder.set_edge_interval(edge, Interval::UNIT);
418        edge
419    };
420    let outer_seam = seam_edge(
421        &mut builder,
422        bottom_outer_vertex,
423        top_outer_vertex,
424        bottom_outer_point,
425    );
426    let inner_seam = seam_edge(
427        &mut builder,
428        bottom_inner_vertex,
429        top_inner_vertex,
430        bottom_inner_point,
431    );
432
433    // Cap loops: an annulus has an outer boundary and an inner hole, so each
434    // cap face carries two loops rather than one.
435    let circle_loop = |builder: &mut ExactBRepBuilder, edge, radius, orientation| {
436        let pcurve = builder.add_curve2(Curve2::Circle(Circle2 {
437            frame: frame2,
438            radius,
439        }));
440        let loop_id = builder.topology_mut().add_loop(Loop {
441            edges: vec![EdgeUse {
442                edge,
443                orientation,
444                pcurve: Some(pcurve),
445            }],
446        });
447        // A pcurve interval runs the way its use traverses the edge
448        // (ADR 0024): a reversed use walks the circle backwards.
449        let interval = match orientation {
450            Orientation::Forward => Interval::new(0.0, TAU),
451            Orientation::Reversed => Interval::new(TAU, 0.0),
452        };
453        builder.set_pcurve_interval(loop_id, 0, interval);
454        loop_id
455    };
456
457    let bottom_outer_loop = circle_loop(&mut builder, bottom_outer, outer, Orientation::Forward);
458    let bottom_inner_loop = circle_loop(&mut builder, bottom_inner, inner, Orientation::Reversed);
459    let top_outer_loop = circle_loop(&mut builder, top_outer, outer, Orientation::Forward);
460    let top_inner_loop = circle_loop(&mut builder, top_inner, inner, Orientation::Reversed);
461
462    // A cylinder wall between two coaxial circles needs a SEAM edge. A loop
463    // must be vertex-connected -- consecutive edges share an endpoint -- and
464    // the two circles are disjoint, so a two-edge loop is an open loop and the
465    // topology audit rejects it. The seam runs up the wall and is traversed
466    // once forward and once reversed, closing the loop without adding area.
467    let wall_loop = |builder: &mut ExactBRepBuilder, lower, upper, seam, height: Scalar| {
468        let lower_pcurve = builder.add_curve2(Curve2::Line(Line2 {
469            origin: Vec2::ZERO,
470            direction: Vec2::X,
471        }));
472        let upper_pcurve = builder.add_curve2(Curve2::Line(Line2 {
473            origin: Vec2::new(0.0, height),
474            direction: Vec2::X,
475        }));
476        let up_pcurve = builder.add_curve2(Curve2::Line(Line2 {
477            origin: Vec2::new(TAU, 0.0),
478            direction: Vec2::new(0.0, height),
479        }));
480        let down_pcurve = builder.add_curve2(Curve2::Line(Line2 {
481            origin: Vec2::ZERO,
482            direction: Vec2::new(0.0, height),
483        }));
484        let loop_id = builder.topology_mut().add_loop(Loop {
485            edges: vec![
486                EdgeUse {
487                    edge: lower,
488                    orientation: Orientation::Forward,
489                    pcurve: Some(lower_pcurve),
490                },
491                EdgeUse {
492                    edge: seam,
493                    orientation: Orientation::Forward,
494                    pcurve: Some(up_pcurve),
495                },
496                EdgeUse {
497                    edge: upper,
498                    orientation: Orientation::Reversed,
499                    pcurve: Some(upper_pcurve),
500                },
501                EdgeUse {
502                    edge: seam,
503                    orientation: Orientation::Reversed,
504                    pcurve: Some(down_pcurve),
505                },
506            ],
507        });
508        builder.set_pcurve_interval(loop_id, 0, Interval::new(0.0, TAU));
509        builder.set_pcurve_interval(loop_id, 1, Interval::UNIT);
510        builder.set_pcurve_interval(loop_id, 2, Interval::new(TAU, 0.0));
511        builder.set_pcurve_interval(loop_id, 3, Interval::new(1.0, 0.0));
512        loop_id
513    };
514
515    let height = top - bottom;
516    let outer_loop = wall_loop(&mut builder, bottom_outer, top_outer, outer_seam, height);
517    let inner_loop = wall_loop(&mut builder, bottom_inner, top_inner, inner_seam, height);
518
519    let bottom_surface = builder.add_surface(Surface::Plane(Plane {
520        frame: frame_at(bottom),
521    }));
522    let top_surface = builder.add_surface(Surface::Plane(Plane {
523        frame: frame_at(top),
524    }));
525    let outer_surface = builder.add_surface(Surface::Cylinder(Cylinder {
526        frame: frame_at(bottom),
527        radius: outer,
528    }));
529    let inner_surface = builder.add_surface(Surface::Cylinder(Cylinder {
530        frame: frame_at(bottom),
531        radius: inner,
532    }));
533
534    // Each cap is an annulus: outer boundary plus an inner hole.
535    let annulus = |builder: &mut ExactBRepBuilder, surface, outer_loop, inner_loop, orientation| {
536        builder.topology_mut().add_face(Face {
537            surface: Some(surface),
538            bounds: vec![
539                FaceBound {
540                    loop_id: outer_loop,
541                    orientation: Orientation::Forward,
542                    outer: true,
543                },
544                FaceBound {
545                    loop_id: inner_loop,
546                    orientation: Orientation::Forward,
547                    outer: false,
548                },
549            ],
550            orientation,
551        })
552    };
553
554    let bottom_face = annulus(
555        &mut builder,
556        bottom_surface,
557        bottom_outer_loop,
558        bottom_inner_loop,
559        Orientation::Reversed,
560    );
561    let top_face = annulus(
562        &mut builder,
563        top_surface,
564        top_outer_loop,
565        top_inner_loop,
566        Orientation::Forward,
567    );
568
569    let wall_face = |builder: &mut ExactBRepBuilder, surface, loop_id, orientation| {
570        builder.topology_mut().add_face(Face {
571            surface: Some(surface),
572            bounds: vec![FaceBound {
573                loop_id,
574                orientation: Orientation::Forward,
575                outer: true,
576            }],
577            orientation,
578        })
579    };
580    // The inner wall faces inward, so its orientation is reversed relative to
581    // the outer wall: both must point out of the material.
582    let outer_face = wall_face(
583        &mut builder,
584        outer_surface,
585        outer_loop,
586        Orientation::Forward,
587    );
588    let inner_face = wall_face(
589        &mut builder,
590        inner_surface,
591        inner_loop,
592        Orientation::Reversed,
593    );
594
595    finish_solid(builder, vec![bottom_face, top_face, outer_face, inner_face])
596}
597
598/// Assemble the faces into a closed solid and audit it.
599///
600/// The manifold audit is the real gate: it catches a wall wound the wrong way
601/// or a cap loop that does not close, which are exactly the mistakes that
602/// produce a B-rep that looks plausible and bounds nothing.
603fn finish_solid(mut builder: ExactBRepBuilder, faces: Vec<FaceId>) -> GeomResult<ExactBRep> {
604    let shell = builder.topology_mut().add_shell(Shell {
605        faces: faces
606            .into_iter()
607            .map(|face| (face, Orientation::Forward))
608            .collect(),
609        closed: true,
610    });
611    builder.topology_mut().add_solid(Solid {
612        outer: shell,
613        voids: Vec::new(),
614    });
615    let exact = builder
616        .finish()
617        .map_err(|error| GeomError::BackendContractViolation {
618            backend: BACKEND_ID,
619            detail: format!("exact revolution assembly failed: {error}"),
620        })?;
621    let health = axiolid_topology::audit_brep(exact.topology());
622    if !health.is_closed_manifold() {
623        return Err(GeomError::BackendContractViolation {
624            backend: BACKEND_ID,
625            detail: format!("exact revolution is not a closed manifold: {health:?}"),
626        });
627    }
628    Ok(exact)
629}
630
631/// Sweep a supported profile along a straight path with a fixed reference.
632///
633/// A fixed-reference sweep along a STRAIGHT directrix keeps the profile's
634/// orientation constant, so the swept solid is exactly a linear extrusion
635/// along that segment. Rather than build a second implementation that could
636/// drift from it, this delegates to `extrude_profile_exact`, which is the
637/// same solid by construction.
638///
639/// A curved directrix is refused: the profile then rotates along the path and
640/// the walls become general swept surfaces, not planes and cylinders. That is
641/// the curved-surface work this issue explicitly puts out of scope.
642pub fn fixed_reference_sweep_exact(
643    profile: &Profile,
644    path: &[Point3],
645    reference_direction: Vec3,
646    tolerance: Tolerance,
647) -> GeomResult<ExactBRep> {
648    if path.len() < 2 {
649        return Err(GeomError::InvalidInput(
650            "a sweep path needs at least two points".to_owned(),
651        ));
652    }
653    if !path.iter().all(|p| p.is_finite()) {
654        return Err(GeomError::InvalidInput(
655            "sweep path points must be finite".to_owned(),
656        ));
657    }
658    if !reference_direction.is_finite() || reference_direction.length() <= 0.0 {
659        return Err(GeomError::InvalidInput(
660            "sweep reference direction must be finite and non-zero".to_owned(),
661        ));
662    }
663
664    // Straightness is the precondition, so it is checked rather than assumed:
665    // every interior point must lie on the segment from first to last.
666    let start = path[0];
667    let end = path[path.len() - 1];
668    let span = end - start;
669    let length = span.length();
670    if length <= tolerance.linear() {
671        return Err(GeomError::Degenerate(
672            "sweep path start and end coincide, so the path has no direction".to_owned(),
673        ));
674    }
675    let direction = span / length;
676    for point in &path[1..path.len() - 1] {
677        let offset = *point - start;
678        let perpendicular = offset - direction * direction.dot(offset);
679        if perpendicular.length() > tolerance.linear() {
680            return Err(unsupported("exact sweep along a curved directrix"));
681        }
682    }
683
684    extrude_profile_exact(profile, direction, length, tolerance)
685}
686
687/// Revolve a profile through a partial turn: every family the contour path
688/// revolves a full turn, routed through [`crate::revolve_partial`].
689fn revolve_partial(
690    profile: &Profile,
691    frame: &AxisFrame,
692    angle: Scalar,
693    tolerance: Tolerance,
694) -> GeomResult<ExactBRep> {
695    match profile {
696        Profile::Ellipse(_) => return Err(unsupported("ellipse exact revolution")),
697        Profile::Rectangle(_)
698        | Profile::Circle(_)
699        | Profile::Section(_)
700        | Profile::Contour(_)
701        | Profile::Derived { .. }
702        | Profile::Composite(_)
703        | Profile::CenterLine(_) => {}
704        _ => return Err(unsupported("unknown profile exact revolution")),
705    }
706    revolve_via_contour(profile, frame, Some(angle), tolerance)
707}
708
709/// Revolve any profile that lowers to a contour, a full turn (`angle` is
710/// `None`) or partially.
711///
712/// About an axis exactly along y the sections are built in place, a turn
713/// about -y running the other way about +y. About any other in-plane axis
714/// each section is mapped into the axis's frame ([`into_axis_frame`]),
715/// revolved about +y there with the angle as given, and the solid placed back
716/// (#450).
717fn revolve_via_contour(
718    profile: &Profile,
719    frame: &AxisFrame,
720    angle: Option<Scalar>,
721    tolerance: Tolerance,
722) -> GeomResult<ExactBRep> {
723    let placed = frame.placement.is_some();
724    let (axis_origin, angle) = if placed {
725        (Point3::ZERO, angle)
726    } else {
727        // The sections are built about world +y; an axis pointing down turns
728        // the right-hand sweep the other way.
729        (
730            Point3::new(frame.origin.x, frame.origin.y, 0.0),
731            angle.map(|angle| if frame.given.y < 0.0 { -angle } else { angle }),
732        )
733    };
734    let section = |ring: &ArcRing| -> GeomResult<ArcRing> {
735        if placed {
736            into_axis_frame(ring, frame)
737        } else {
738            Ok(ring.clone())
739        }
740    };
741    // The mapped coordinates are differences of the given ones and the
742    // axis's: the rounding gates of the revolution read their magnitude.
743    let magnitude = |rings: &[&ArcRing]| -> Scalar {
744        if !placed {
745            return 0.0;
746        }
747        rings
748            .iter()
749            .flat_map(|ring| ring.vertices.iter())
750            .fold(frame.origin.x.abs().max(frame.origin.y.abs()), |m, v| {
751                m.max(v.point.x.abs()).max(v.point.y.abs())
752            })
753    };
754
755    // A composite is unioned exactly first; members that do not touch
756    // revolve into separate solids of one `ExactBRep` (#111).
757    if let Profile::Composite(members) = profile {
758        let regions = crate::profile_lower::composite_regions(members, tolerance)?;
759        let mut pieces = Vec::with_capacity(regions.len());
760        for region in &regions {
761            let holes = region
762                .holes
763                .iter()
764                .map(&section)
765                .collect::<GeomResult<Vec<_>>>()?;
766            let rings: Vec<&ArcRing> = core::iter::once(&region.outer)
767                .chain(region.holes.iter())
768                .collect();
769            pieces.push(revolve_region(
770                &section(&region.outer)?,
771                &holes,
772                axis_origin,
773                magnitude(&rings),
774                angle,
775                tolerance,
776            )?);
777        }
778        return place_in_axis_frame(crate::assemble::merge_solids(pieces)?, frame);
779    }
780
781    let contour = crate::extrude_exact::profile_to_contour(profile, tolerance)?;
782    let outer = crate::contour_lower::contour_to_arc_ring(&contour.outer, tolerance)?;
783    let holes = contour
784        .holes
785        .iter()
786        .map(|hole| crate::contour_lower::contour_to_arc_ring(hole, tolerance))
787        .collect::<GeomResult<Vec<_>>>()?;
788    let rings: Vec<&ArcRing> = core::iter::once(&outer).chain(holes.iter()).collect();
789    let scale = magnitude(&rings);
790    let holes = holes.iter().map(&section).collect::<GeomResult<Vec<_>>>()?;
791    let solid = revolve_region(
792        &section(&outer)?,
793        &holes,
794        axis_origin,
795        scale,
796        angle,
797        tolerance,
798    )?;
799    place_in_axis_frame(solid, frame)
800}
801
802/// Revolve one section a full turn -- its outer ring as the solid, each hole
803/// as a void -- or partially, each hole a tunnel between the caps.
804fn revolve_region(
805    outer: &ArcRing,
806    holes: &[ArcRing],
807    axis_origin: Point3,
808    magnitude: Scalar,
809    angle: Option<Scalar>,
810    tolerance: Tolerance,
811) -> GeomResult<ExactBRep> {
812    if let Some(angle) = angle {
813        return crate::revolve_partial::revolve_section_partial(
814            outer,
815            holes,
816            axis_origin,
817            angle,
818            tolerance,
819        );
820    }
821    let solid =
822        crate::revolve_contour::revolve_ring_scaled(outer, axis_origin, magnitude, tolerance)?;
823    if holes.is_empty() {
824        return Ok(solid);
825    }
826    if solid.topology().solids().len() != 1 || !solid.topology().solids()[0].voids.is_empty() {
827        return Err(unsupported(
828            "exact revolution of a section with holes and several contacts with the axis",
829        ));
830    }
831    // A hole swept a full turn encloses a ring-shaped cavity: it touches
832    // neither cap, so it is a second shell, not a second loop. Each hole
833    // revolves on its own and joins the solid as a void, its faces used
834    // reversed so they face into the cavity (#111).
835    let mut builder = ExactBRepBuilder::default();
836    let outer = builder.append(&solid, false);
837    let mut voids = Vec::with_capacity(holes.len());
838    for hole in holes {
839        let cavity =
840            crate::revolve_contour::revolve_ring_scaled(hole, axis_origin, magnitude, tolerance)?;
841        voids.extend(builder.append(&cavity, true));
842    }
843    let [outer] = outer[..] else {
844        return Err(GeomError::Degenerate(
845            "a revolved section must have one outer shell".to_owned(),
846        ));
847    };
848    builder.topology_mut().add_solid(Solid { outer, voids });
849    builder.finish().map_err(|error| {
850        GeomError::Degenerate(format!(
851            "revolved section with holes did not assemble: {error}"
852        ))
853    })
854}