Skip to main content

axiolid_construct/
revolve_contour.rs

1//! General contour revolution (ADR 0059).
2//!
3//! `revolve_rectangle` builds one shape from four known corners. This module
4//! revolves an ARBITRARY closed section, which is what lets `Contour`,
5//! `Section`, `CenterLine`, `Derived` and `Composite` revolve: they all lower
6//! to a contour already, so the refusals they carried were about this module
7//! not existing rather than about the geometry being impossible.
8//!
9//! # Which surface each segment sweeps
10//!
11//! Measured, not assumed (`revolve.py`):
12//!
13//! | profile segment | swept surface |
14//! |---|---|
15//! | parallel to the axis | cylinder |
16//! | perpendicular to the axis | plane (annulus) |
17//! | oblique | cone |
18//! | circular arc | torus |
19//! | circular arc ending on the axis, centred on it | sphere (#379) |
20//!
21//! The cone check fitted `r` as an affine function of `z` to a residual of
22//! `2e-13`; the torus check evaluated the implicit form to `1.1e-15`.
23//!
24//! # Parameterisation
25//!
26//! Cylinder and cone both parameterise as `(angle, height)`, so a wall
27//! between two coaxial circles uses the same four-edge seam loop in both
28//! cases. That is why they share one code path here: giving the cone its own
29//! loop builder would let the two drift.
30
31use axiolid_brep::{ExactBRep, ExactBRepBuilder};
32use axiolid_contracts::{GeomError, GeomResult, Operation};
33use axiolid_core::{Frame2, Frame3, Interval, Point2, Point3, Scalar, Tolerance, Vec2, Vec3};
34use axiolid_curve::{Circle2, Circle3, Curve2, Curve3, Line2, Line3};
35use axiolid_surface::{Cone, Cylinder, Plane, Sphere, Surface, Torus};
36use axiolid_topology::{
37    Edge, EdgeId, EdgeUse, Face, FaceBound, FaceId, Loop, LoopId, Orientation, Shell, Solid,
38    Vertex, VertexId,
39};
40
41use axiolid_guarantees::Sign;
42use axiolid_overlay::{ArcRing, ArcVertex};
43
44use crate::contour_lower::arc_ring_signed_area;
45use crate::revolve::side_of_axis;
46use crate::BACKEND_ID;
47
48const TAU: Scalar = core::f64::consts::TAU;
49
50fn unsupported(input: &'static str) -> GeomError {
51    GeomError::UnsupportedInput {
52        backend: BACKEND_ID,
53        operation: Operation::Sweep,
54        input,
55    }
56}
57
58/// One revolved section vertex: its distance from the axis and its height.
59#[derive(Debug, Clone, Copy)]
60struct Station {
61    /// Signed distance from the axis, in the profile plane.
62    radius: Scalar,
63    /// Position along the axis.
64    height: Scalar,
65    /// Bulge of the segment LEAVING this station.
66    bulge: Scalar,
67    /// Exactly on the axis (#379): the exact `side_of_axis` sign is zero.
68    on_axis: bool,
69    /// The largest magnitude the coordinates above were computed from: the
70    /// radius is a difference of the vertex's `x` and the axis's, so it
71    /// carries their rounding, not its own.
72    magnitude: Scalar,
73}
74
75/// What a section edge sweeps when one of its ends may lie on the axis.
76#[derive(Debug, Clone, Copy, PartialEq, Eq)]
77enum Contact {
78    /// Both ends on the axis, straight: sweeps nothing, so no face.
79    Axis,
80    /// One end on the axis, straight and exactly perpendicular to it: a disc.
81    Cap,
82    /// An arc with one end on the axis: a sphere's pole, or a spindle or
83    /// horn torus's.
84    Arc,
85    /// A straight edge with an end on the axis that is not perpendicular to
86    /// it: a cone's slant up to its apex.
87    Cone,
88}
89
90/// Revolve a lowered section a full turn about the profile's local y axis.
91///
92/// The section may touch the axis (#379), classified with the exact
93/// `side_of_axis` sign, no tolerance: an edge lying on the axis sweeps no
94/// face, and a straight edge ending on it exactly perpendicularly (equal
95/// heights) sweeps a disc cap, so a solid cylinder and a stepped shaft
96/// revolve. A section with vertices on both sides of the axis is refused as
97/// crossing it. A vertex on the axis is admitted when one of its two edges
98/// lies on the axis and the other is another axis edge, a disc cap, a
99/// straight edge to a cone's apex or an arc to a sphere's pole. The apex or
100/// pole is a vertex of the B-rep, not a degenerate edge: the wall's loop runs
101/// `[rim circle], seam, [rim circle], seam reversed` and leaves a gap along
102/// the pole line in its parameters, which consumers close (ADR 0059). An arc
103/// with exactly one end on the axis is a sphere when its circle is centred on
104/// the axis to rounding (64 ulps of the coordinates it is computed from);
105/// otherwise it is a spindle or horn torus and is refused by name, as is an
106/// arc with both ends on the axis. A vertex on the axis with no axis edge (a
107/// pinch point) is refused by name, and so is a vertex off the axis by no
108/// more than the tolerance, as neither on it nor clear of it, or a straight
109/// edge ending on the axis within the tolerance of perpendicular to it but
110/// not exactly.
111/// Several separate contacts (a cavity along the axis) give one solid per
112/// outermost shell with the nested shells as its voids.
113pub fn revolve_arc_ring(
114    ring: &ArcRing,
115    axis_origin: Point3,
116    tolerance: Tolerance,
117) -> GeomResult<ExactBRep> {
118    revolve_ring_scaled(ring, axis_origin, 0.0, tolerance)
119}
120
121/// [`revolve_arc_ring`] for a ring whose coordinates were computed from
122/// magnitudes up to `magnitude` (a section mapped into an axis frame was a
123/// difference of larger numbers), which the rounding gates read (#450).
124pub(crate) fn revolve_ring_scaled(
125    ring: &ArcRing,
126    axis_origin: Point3,
127    magnitude: Scalar,
128    tolerance: Tolerance,
129) -> GeomResult<ExactBRep> {
130    // The axis is the line along +y through `axis_origin`: in the profile
131    // plane only when the origin is (#450).
132    if axis_origin.z != 0.0 {
133        return Err(unsupported(
134            "exact revolution about an axis out of the profile plane",
135        ));
136    }
137    let epsilon = tolerance.linear();
138    let halved = split_axis_semicircles(ring, axis_origin);
139    let ring = &halved;
140    let count = ring.vertices.len();
141    if count < 3 {
142        return Err(GeomError::Degenerate(format!(
143            "a revolved section needs at least three vertices, got {count}"
144        )));
145    }
146
147    // Counter-clockwise in the profile plane gives outward wall normals once
148    // revolved, matching the extrusion convention.
149    let area = arc_ring_signed_area(ring);
150    if area == 0.0 {
151        return Err(GeomError::Degenerate(
152            "revolved section encloses no area".to_owned(),
153        ));
154    }
155    let forward = area > 0.0;
156
157    let axis_point = Point2::new(axis_origin.x, axis_origin.y);
158    let (mut positive, mut negative) = (false, false);
159    let stations = (0..count)
160        .map(|index| {
161            let source = if forward { index } else { count - 1 - index };
162            let vertex = ring.vertices[source];
163            if !(vertex.point.x.is_finite() && vertex.point.y.is_finite()) {
164                return Err(GeomError::InvalidInput(
165                    "revolved section must have finite vertices".to_owned(),
166                ));
167            }
168            let bulge = if forward {
169                vertex.bulge
170            } else {
171                // Reversing the walk moves each bulge onto the segment that
172                // now leaves this station, and flips its side.
173                -ring.vertices[(source + count - 1) % count].bulge
174            };
175            let side = side_of_axis(axis_point, Vec3::Y, vertex.point);
176            match side {
177                Sign::Positive => positive = true,
178                Sign::Negative => negative = true,
179                _ => {}
180            }
181            Ok(Station {
182                radius: vertex.point.x - axis_origin.x,
183                height: vertex.point.y,
184                bulge,
185                on_axis: side == Sign::Zero,
186                magnitude: vertex
187                    .point
188                    .x
189                    .abs()
190                    .max(vertex.point.y.abs())
191                    .max(axis_origin.x.abs())
192                    .max(magnitude),
193            })
194        })
195        .collect::<GeomResult<Vec<Station>>>()?;
196
197    if positive && negative {
198        return Err(unsupported(
199            "exact revolution of a section crossing the axis",
200        ));
201    }
202    if stations
203        .iter()
204        .any(|station| !station.on_axis && station.radius.abs() <= epsilon)
205    {
206        return Err(unsupported(
207            "exact revolution of a section within tolerance of the axis but not on it to the bit",
208        ));
209    }
210    check_axis_contact(&stations, epsilon)?;
211    // Mirror a section on the negative side so every radius is positive; a
212    // full turn makes the two cases the same solid. `side_of_axis` is
213    // positive to the left of the upward axis, which is the negative radius.
214    // A mirror turns the walk clockwise, so it is walked backwards again:
215    // each bulge moves onto the segment that now leaves its station, and
216    // mirroring flips it back.
217    let stations: Vec<Station> = if positive {
218        (0..count)
219            .map(|index| {
220                let source = count - 1 - index;
221                Station {
222                    radius: -stations[source].radius,
223                    height: stations[source].height,
224                    bulge: stations[(source + count - 1) % count].bulge,
225                    on_axis: stations[source].on_axis,
226                    magnitude: stations[source].magnitude,
227                }
228            })
229            .collect()
230    } else {
231        stations
232    };
233
234    build(&stations, axis_origin, epsilon)
235}
236
237/// `ring`, with each semicircle (bulge of magnitude exactly 1) between two
238/// vertices exactly on the axis split at its midpoint, as `lower_arc` splits a
239/// sweep of a half turn: a bulge below 1 is a lemon (refused, both ends on
240/// the axis), but a bulge of 1 is a sphere's meridian, whose two quarter arcs
241/// each have one end on the axis (#379).
242fn split_axis_semicircles(ring: &ArcRing, axis_origin: Point3) -> ArcRing {
243    let axis_point = Point2::new(axis_origin.x, axis_origin.y);
244    let count = ring.vertices.len();
245    let mut vertices = Vec::with_capacity(count + 1);
246    for (index, vertex) in ring.vertices.iter().enumerate() {
247        vertices.push(*vertex);
248        let to = ring.vertices[(index + 1) % count].point;
249        let on_axis = |p: Point2| {
250            p.x.is_finite() && p.y.is_finite() && side_of_axis(axis_point, Vec3::Y, p) == Sign::Zero
251        };
252        if vertex.bulge.abs() == 1.0 && on_axis(vertex.point) && on_axis(to) {
253            let chord = to - vertex.point;
254            let length = chord.length();
255            if length > 0.0 && length.is_finite() {
256                // The semicircle bulges to the right of its chord for a
257                // positive bulge; the quarter arcs carry `tan(pi / 8)`.
258                let side = vertex.bulge.signum();
259                let middle = vertex.point
260                    + chord * 0.5
261                    + Vec2::new(chord.y, -chord.x) / length * (0.5 * length * side);
262                let quarter = side * (core::f64::consts::FRAC_PI_8).tan();
263                let last = vertices.len() - 1;
264                vertices[last].bulge = quarter;
265                vertices.push(ArcVertex {
266                    point: middle,
267                    bulge: quarter,
268                });
269            }
270        }
271    }
272    ArcRing::new(vertices)
273}
274
275/// What the edge leaving station `index` sweeps, judged exactly.
276fn contact(stations: &[Station], index: usize) -> Option<Contact> {
277    let here = stations[index];
278    let there = stations[(index + 1) % stations.len()];
279    match (here.on_axis, there.on_axis) {
280        (false, false) => None,
281        _ if here.bulge != 0.0 => Some(Contact::Arc),
282        (true, true) => Some(Contact::Axis),
283        _ if here.height == there.height => Some(Contact::Cap),
284        _ => Some(Contact::Cone),
285    }
286}
287
288/// Refuse every vertex on the axis that is not the end of an edge along the
289/// axis (a pinch point, a non-manifold vertex), an arc with both ends on the
290/// axis (a lemon or a spindle torus), and a cone's edge so near perpendicular to the
291/// axis that it is neither a disc nor a cone to the tolerance. Whether an arc
292/// with one end on the axis is a sphere is decided when its wall is built.
293fn check_axis_contact(stations: &[Station], epsilon: Scalar) -> GeomResult<()> {
294    let count = stations.len();
295    for (index, station) in stations.iter().enumerate() {
296        if !station.on_axis {
297            continue;
298        }
299        if station.bulge != 0.0 && stations[(index + 1) % count].on_axis {
300            return Err(unsupported(
301                "exact revolution of a section with an arc with both ends on the axis",
302            ));
303        }
304        let incoming = contact(stations, (index + count - 1) % count);
305        let outgoing = contact(stations, index);
306        match (incoming, outgoing) {
307            (Some(Contact::Axis), Some(_)) | (Some(_), Some(Contact::Axis)) => {}
308            (Some(Contact::Arc), _) | (_, Some(Contact::Arc)) => {
309                return Err(unsupported(
310                    "exact revolution of a section touching the axis at an isolated vertex where an arc ends (a pinch point or a horn torus)",
311                ));
312            }
313            _ => {
314                return Err(unsupported(
315                    "exact revolution of a section touching the axis at an isolated vertex (a pinch point)",
316                ));
317            }
318        }
319        // A cone's rise is rounding, not geometry, when it is within the
320        // tolerance: the edge is neither a disc (equal heights, exactly) nor
321        // a cone the audit can hold to the tolerance.
322        for (edge, other) in [
323            (incoming, (index + count - 1) % count),
324            (outgoing, (index + 1) % count),
325        ] {
326            if edge == Some(Contact::Cone)
327                && (station.height - stations[other].height).abs() <= epsilon
328            {
329                return Err(unsupported(
330                    "exact revolution of an edge ending on the axis within the tolerance of perpendicular to it but not exactly",
331                ));
332            }
333        }
334    }
335    Ok(())
336}
337
338/// Assemble the revolved solid: one circular edge per station, one wall face
339/// per segment.
340fn build(stations: &[Station], axis_origin: Point3, epsilon: Scalar) -> GeomResult<ExactBRep> {
341    let count = stations.len();
342    let mut builder = ExactBRepBuilder::default();
343    builder
344        .topology_mut()
345        .try_reserve(count * 2, count * 2, count * 2, count, 1, 1)
346        .map_err(|_| GeomError::BudgetExceeded {
347            resource: "exact revolution topology",
348        })?;
349
350    let axis_frame = |height: Scalar| frame_at(axis_origin.x, height);
351
352    // One circular edge per station off the axis, plus a seam so each wall
353    // loop closes. A station on the axis traces no circle and has none.
354    let mut circles = Vec::with_capacity(count);
355    let mut vertices = Vec::with_capacity(count);
356    let mut points = Vec::with_capacity(count);
357    for (index, station) in stations.iter().enumerate() {
358        let position = Point3::new(axis_origin.x + station.radius, station.height, 0.0);
359        points.push(position);
360        if station.on_axis {
361            circles.push(None);
362            // A cone's apex or a sphere's pole is a vertex its seam ends at; a
363            // station that only ends axis edges and discs has none.
364            let apex = [(index + count - 1) % count, index]
365                .into_iter()
366                .any(|edge| matches!(contact(stations, edge), Some(Contact::Cone | Contact::Arc)));
367            vertices.push(apex.then(|| builder.topology_mut().add_vertex(Vertex { position })));
368            continue;
369        }
370        let vertex = builder.topology_mut().add_vertex(Vertex { position });
371        let curve = builder.add_curve3(Curve3::Circle(Circle3 {
372            frame: axis_frame(station.height),
373            radius: station.radius,
374        }));
375        let edge = builder.topology_mut().add_edge(Edge {
376            start: vertex,
377            end: vertex,
378            curve: Some(curve),
379        });
380        builder.set_edge_interval(edge, Interval::new(0.0, TAU));
381        circles.push(Some(edge));
382        vertices.push(Some(vertex));
383    }
384
385    // Each face with the two stations of the edge it sweeps.
386    let mut faces: Vec<(FaceId, usize, usize)> = Vec::with_capacity(count);
387    for index in 0..count {
388        let next = (index + 1) % count;
389        let here = stations[index];
390        let there = stations[next];
391        let face = if here.on_axis || there.on_axis {
392            // `check_axis_contact` admitted only an axis edge, a disc, a
393            // cone's slant and an arc.
394            if here.on_axis && there.on_axis {
395                continue;
396            }
397            if here.bulge != 0.0 {
398                sphere_wall(
399                    &mut builder,
400                    axis_origin.x,
401                    &circles,
402                    &vertices,
403                    stations,
404                    index,
405                )?
406            } else if here.height == there.height {
407                cap_wall(&mut builder, axis_origin.x, &circles, stations, index)?
408            } else {
409                straight_wall(
410                    &mut builder,
411                    axis_origin.x,
412                    &circles,
413                    &vertices,
414                    &points,
415                    stations,
416                    index,
417                    true,
418                    epsilon,
419                )?
420            }
421        } else if here.bulge != 0.0 {
422            torus_wall(
423                &mut builder,
424                axis_origin.x,
425                &circles,
426                &vertices,
427                stations,
428                index,
429            )?
430        } else if (here.radius - there.radius).abs() <= epsilon {
431            // Constant radius: a cylinder. A zero-height run would be a
432            // degenerate face, so it is skipped rather than emitted.
433            if (here.height - there.height).abs() <= epsilon {
434                continue;
435            }
436            straight_wall(
437                &mut builder,
438                axis_origin.x,
439                &circles,
440                &vertices,
441                &points,
442                stations,
443                index,
444                false,
445                epsilon,
446            )?
447        } else if (here.height - there.height).abs() <= epsilon {
448            // Constant height: a planar annulus.
449            annulus_wall(&mut builder, axis_origin.x, &circles, stations, index)?
450        } else {
451            // Neither constant: a cone.
452            straight_wall(
453                &mut builder,
454                axis_origin.x,
455                &circles,
456                &vertices,
457                &points,
458                stations,
459                index,
460                true,
461                epsilon,
462            )?
463        };
464        faces.push((face, index, next));
465    }
466
467    if faces.len() < 2 {
468        return Err(GeomError::Degenerate(format!(
469            "a revolved solid needs at least two faces, got {}",
470            faces.len()
471        )));
472    }
473    finish(builder, stations, faces)
474}
475/// A cylinder or cone wall between two coaxial circles.
476///
477/// Both parameterise as `(angle, height)`, so one seam loop serves both: only
478/// the SURFACE differs. The seam is traversed forward and reversed so the
479/// loop is vertex-connected without enclosing extra area.
480///
481/// A cone with an end on the axis is framed at its apex, where its radius is
482/// exactly zero, so the apex is at `v = 0` and the loop omits the circle
483/// there (#379).
484#[allow(clippy::too_many_arguments)]
485fn straight_wall(
486    builder: &mut ExactBRepBuilder,
487    axis_x: Scalar,
488    circles: &[Option<EdgeId>],
489    vertices: &[Option<VertexId>],
490    points: &[Point3],
491    stations: &[Station],
492    index: usize,
493    cone: bool,
494    epsilon: Scalar,
495) -> GeomResult<FaceId> {
496    let count = stations.len();
497    let next = (index + 1) % count;
498    let here = stations[index];
499    let there = stations[next];
500    let rise = there.height - here.height;
501
502    let seam_curve = builder.add_curve3(Curve3::Line(Line3 {
503        origin: points[index],
504        direction: points[next] - points[index],
505    }));
506    let seam = builder.topology_mut().add_edge(Edge {
507        start: slot(vertices, index)?,
508        end: slot(vertices, next)?,
509        curve: Some(seam_curve),
510    });
511    builder.set_edge_interval(seam, Interval::UNIT);
512
513    // The station the surface is framed at: the apex when an end is on the
514    // axis, else the start, as before.
515    let basis = if there.on_axis { there } else { here };
516    let surface = if cone {
517        // `semi_angle` is measured so radius grows as `r + v * tan(angle)`,
518        // which is exactly the slope of this segment.
519        let semi_angle = ((there.radius - here.radius) / rise).atan();
520        // The angle must carry the slope: for a near-flat edge `tan` of it
521        // is off the slope by far more than the coordinates' rounding, and
522        // the cone leaves its edge (a pcurve 7e-5 off the curve for a rise of
523        // 1e-12). It may be off by the tolerance, or by eight machine
524        // epsilons of the largest coordinate, whichever is larger: the round
525        // trip through `atan` and `tan` costs a few ulps of the coordinates.
526        let scale = here
527            .radius
528            .abs()
529            .max(there.radius.abs())
530            .max(here.height.abs())
531            .max(there.height.abs());
532        let carried = rise * semi_angle.tan() - (there.radius - here.radius);
533        if carried.is_nan() || carried.abs() > epsilon.max(8.0 * Scalar::EPSILON * scale) {
534            return Err(unsupported(
535                "exact revolution of an edge so near perpendicular to the axis that a cone cannot carry its slope",
536            ));
537        }
538        builder.add_surface(Surface::Cone(Cone {
539            frame: frame_at(axis_x, basis.height),
540            radius: basis.radius,
541            semi_angle,
542        }))
543    } else {
544        builder.add_surface(Surface::Cylinder(Cylinder {
545            frame: frame_at(axis_x, here.height),
546            radius: here.radius,
547        }))
548    };
549
550    let loop_id = seam_loop_between(
551        builder,
552        circles[index],
553        circles[next],
554        seam,
555        here.height - basis.height,
556        there.height - basis.height,
557    );
558    Ok(add_face(builder, surface, loop_id, Orientation::Forward))
559}
560
561/// How far an arc's centre may lie from the axis, in machine epsilons of the
562/// largest magnitude it is computed from, for the arc to be a sphere's
563/// meridian (#379).
564///
565/// No bulge encodes a quarter arc exactly (`tan(pi / 8)` is irrational), so
566/// the centre computed from an arc that is a sphere's is a few ulps of the
567/// magnitude of its coordinates off the axis. That magnitude is the vertices'
568/// and the axis's (a radius is their difference, so a ball of radius 1e-3
569/// about `x = 12.5` carries the rounding of 12.5), and for a section mapped
570/// into an axis frame the given section's. It is a rounding gate, not a
571/// tolerance: it only chooses between a sphere and a refusal, and the sphere
572/// is placed exactly on the axis. It is not reported.
573const CENTRE_ROUNDING: Scalar = 64.0 * Scalar::EPSILON;
574
575/// A sphere wall: an arc with one end on the axis, centred on it, swept a
576/// full turn.
577///
578/// The sphere is framed on the axis at the height equidistant from both arc
579/// ends, `c = h_pole + (r^2 + d^2) / (2 d)` for the other end at radius `r`
580/// and height difference `d` from the pole, so its pole is on the axis
581/// exactly. Its `v` is the latitude, `+-pi/2` at the poles, and the seam is
582/// the meridian circle between the arc's two latitudes. The loop omits the
583/// pole's circle, as a cone's does at its apex.
584///
585/// An arc whose circle is not centred on the axis is a spindle or horn torus
586/// (a lemon, an apple, an ogive nose): refused by name, since it bounds no
587/// two-manifold solid here.
588fn sphere_wall(
589    builder: &mut ExactBRepBuilder,
590    axis_x: Scalar,
591    circles: &[Option<EdgeId>],
592    vertices: &[Option<VertexId>],
593    stations: &[Station],
594    index: usize,
595) -> GeomResult<FaceId> {
596    let next = (index + 1) % stations.len();
597    let (here, there) = (stations[index], stations[next]);
598    let arc = crate::extrude_arc::arc_geometry(
599        Point2::new(here.radius, here.height),
600        Point2::new(there.radius, there.height),
601        here.bulge,
602    )?;
603    let (pole, other) = if here.on_axis {
604        (here, there)
605    } else {
606        (there, here)
607    };
608    let rise = other.height - pole.height;
609    let not_centred = || {
610        unsupported(
611            "exact revolution of an arc ending on the axis whose circle is not centred on it (spindle or horn torus)",
612        )
613    };
614    if rise == 0.0 {
615        return Err(not_centred());
616    }
617    let centre = pole.height + (other.radius * other.radius + rise * rise) / (2.0 * rise);
618    let radius = (centre - pole.height).abs();
619    let scale = here
620        .radius
621        .abs()
622        .max(here.height.abs())
623        .max(there.radius.abs())
624        .max(there.height.abs())
625        .max(here.magnitude)
626        .max(there.magnitude)
627        + arc.radius;
628    let offset = (arc.centre - Point2::new(0.0, centre)).length();
629    if offset.is_nan() || offset > CENTRE_ROUNDING * scale {
630        return Err(not_centred());
631    }
632    let latitude = |station: Station| {
633        if station.on_axis {
634            if station.height > centre {
635                core::f64::consts::FRAC_PI_2
636            } else {
637                -core::f64::consts::FRAC_PI_2
638            }
639        } else {
640            (station.height - centre).atan2(station.radius)
641        }
642    };
643    let (start, end) = (latitude(here), latitude(there));
644    // The arc's own sweep and the latitudes agree on a meridian that stays
645    // on one side of the axis; a whole turn apart, the arc runs across it.
646    if (arc.sweep - (end - start)).abs() >= core::f64::consts::PI {
647        return Err(unsupported(
648            "exact revolution of a section crossing the axis",
649        ));
650    }
651
652    let surface = builder.add_surface(Surface::Sphere(Sphere {
653        frame: frame_at(axis_x, centre),
654        radius,
655    }));
656    // The seam is the meridian in the profile plane: a circle about the
657    // sphere's centre, from the first latitude to the second.
658    let seam_curve = builder.add_curve3(Curve3::Circle(Circle3 {
659        frame: Frame3 {
660            origin: Point3::new(axis_x, centre, 0.0),
661            x: Vec3::X,
662            y: Vec3::Y,
663            z: Vec3::Z,
664        },
665        radius,
666    }));
667    let seam = builder.topology_mut().add_edge(Edge {
668        start: slot(vertices, index)?,
669        end: slot(vertices, next)?,
670        curve: Some(seam_curve),
671    });
672    builder.set_edge_interval(seam, Interval::new(start, end));
673    let loop_id = seam_loop_between(builder, circles[index], circles[next], seam, start, end);
674    Ok(add_face(builder, surface, loop_id, Orientation::Forward))
675}
676
677/// The circle or vertex of a station, which a station on the axis has none of.
678fn slot<T: Copy>(slots: &[Option<T>], index: usize) -> GeomResult<T> {
679    slots[index]
680        .ok_or_else(|| GeomError::Degenerate("a wall references a station on the axis".to_owned()))
681}
682
683/// A planar disc: the straight edge between a station off the axis and one on
684/// it, exactly perpendicular to the axis, swept a full turn.
685///
686/// The one circle of the off-axis station bounds it; it faces the way the
687/// annulus does, outward on an edge that runs inward to the axis and the
688/// other way on one that leaves it.
689fn cap_wall(
690    builder: &mut ExactBRepBuilder,
691    axis_x: Scalar,
692    circles: &[Option<EdgeId>],
693    stations: &[Station],
694    index: usize,
695) -> GeomResult<FaceId> {
696    let next = (index + 1) % stations.len();
697    let (rim, orientation) = if stations[index].on_axis {
698        (next, Orientation::Reversed)
699    } else {
700        (index, Orientation::Forward)
701    };
702    let surface = builder.add_surface(Surface::Plane(Plane {
703        frame: frame_at(axis_x, stations[rim].height),
704    }));
705    let loop_id = circle_loop(
706        builder,
707        slot(circles, rim)?,
708        stations[rim].radius,
709        Orientation::Forward,
710    );
711    Ok(add_face(builder, surface, loop_id, orientation))
712}
713
714/// A planar annulus between two circles at the same height.
715fn annulus_wall(
716    builder: &mut ExactBRepBuilder,
717    axis_x: Scalar,
718    circles: &[Option<EdgeId>],
719    stations: &[Station],
720    index: usize,
721) -> GeomResult<FaceId> {
722    let count = stations.len();
723    let next = (index + 1) % count;
724    let here = stations[index];
725    let there = stations[next];
726    let surface = builder.add_surface(Surface::Plane(Plane {
727        frame: frame_at(axis_x, here.height),
728    }));
729
730    // The larger circle bounds the face and the smaller one is its hole, so
731    // the two loops carry opposite orientations.
732    let (outer, inner) = if here.radius > there.radius {
733        (index, next)
734    } else {
735        (next, index)
736    };
737    let outer_loop = circle_loop(
738        builder,
739        slot(circles, outer)?,
740        stations[outer].radius,
741        Orientation::Forward,
742    );
743    let inner_loop = circle_loop(
744        builder,
745        slot(circles, inner)?,
746        stations[inner].radius,
747        Orientation::Reversed,
748    );
749    let orientation = if here.radius > there.radius {
750        Orientation::Forward
751    } else {
752        Orientation::Reversed
753    };
754    Ok(builder.topology_mut().add_face(Face {
755        surface: Some(surface),
756        bounds: vec![
757            FaceBound {
758                loop_id: outer_loop,
759                orientation: Orientation::Forward,
760                outer: true,
761            },
762            FaceBound {
763                loop_id: inner_loop,
764                orientation: Orientation::Forward,
765                outer: false,
766            },
767        ],
768        orientation,
769    }))
770}
771/// A torus wall: an arc segment revolved about the axis.
772///
773/// A torus parameterises as `(angle about the axis, angle about the tube)`,
774/// so the trim region is the SAME rectangle the cylinder and cone use -- only
775/// the `v` range is the arc's own sweep instead of a height.
776fn torus_wall(
777    builder: &mut ExactBRepBuilder,
778    axis_x: Scalar,
779    circles: &[Option<EdgeId>],
780    vertices: &[Option<VertexId>],
781    stations: &[Station],
782    index: usize,
783) -> GeomResult<FaceId> {
784    let count = stations.len();
785    let next = (index + 1) % count;
786    let here = stations[index];
787    let there = stations[next];
788
789    let arc = crate::extrude_arc::arc_geometry(
790        Point2::new(here.radius, here.height),
791        Point2::new(there.radius, there.height),
792        here.bulge,
793    )?;
794    let (centre_radius, centre_height) = (arc.centre.x, arc.centre.y);
795
796    // A tube wider than its own ring distance is a SPINDLE torus: the surface
797    // passes through the axis and self-intersects, so the solid it bounds is
798    // not the one the section describes.
799    if arc.radius >= centre_radius.abs() - Scalar::EPSILON {
800        return Err(unsupported(
801            "revolved arc whose tube reaches the axis (spindle torus)",
802        ));
803    }
804    if centre_radius <= 0.0 {
805        return Err(unsupported(
806            "revolved arc centred on the far side of the axis",
807        ));
808    }
809
810    let surface = builder.add_surface(Surface::Torus(Torus {
811        frame: frame_at(axis_x, centre_height),
812        major_radius: centre_radius,
813        minor_radius: arc.radius,
814    }));
815
816    // `v` is measured from the outward radial direction in the tube plane.
817    let angle_of =
818        |station: Station| (station.height - centre_height).atan2(station.radius - centre_radius);
819    let start = angle_of(here);
820    // Walk the arc in its own direction rather than taking the shorter way
821    // round: the sweep says which half of the tube is material.
822    let end = start + arc.sweep;
823
824    // The seam runs ACROSS the tube, and on a torus that path is an arc, not
825    // a ruling: a straight chord sags below the surface by the sagitta
826    // `r*(1 - cos(sweep/2))`, which the audit reports exactly. Cylinders and
827    // cones get a straight seam because their v-direction genuinely is a
828    // straight ruling; a torus does not.
829    let seam_frame = Frame3 {
830        origin: Point3::new(axis_x + centre_radius, centre_height, 0.0),
831        x: Vec3::X,
832        y: Vec3::Y,
833        z: Vec3::Z,
834    };
835    let seam_curve = builder.add_curve3(Curve3::Circle(Circle3 {
836        frame: seam_frame,
837        radius: arc.radius,
838    }));
839    let seam = builder.topology_mut().add_edge(Edge {
840        start: slot(vertices, index)?,
841        end: slot(vertices, next)?,
842        curve: Some(seam_curve),
843    });
844    builder.set_edge_interval(seam, Interval::new(start, end));
845
846    let loop_id = seam_loop_between(
847        builder,
848        Some(slot(circles, index)?),
849        Some(slot(circles, next)?),
850        seam,
851        start,
852        end,
853    );
854    Ok(add_face(builder, surface, loop_id, Orientation::Forward))
855}
856// --- shared helpers ---------------------------------------------------------
857
858/// A surface frame on the axis at `height`, with local z along the axis.
859///
860/// `axis_x` is not optional: every circle in the solid is centred on the axis,
861/// so a frame at x = 0 disagrees with them by exactly the axis offset. The
862/// audit reports that as a pcurve error equal to `|axis_x|`.
863///
864/// The profile plane is `z = 0` and the axis is world `y`, so each frame puts
865/// its own `z` along the axis. Its `y` is world `-z`, not `+z`: `(X, Z, Y)` is
866/// LEFT-handed, and on a left-handed frame a loop running anticlockwise in
867/// `(u, v)` runs clockwise about `S_u x S_v`. Every loop here is built
868/// anticlockwise in its parameters, so a left-handed frame turned the whole
869/// solid inside out -- consistently enough that the topological and
870/// geometric audits, which only compare faces with each other, passed it.
871/// `exact_properties` measured `-2 pi R A` for every revolution.
872pub(crate) fn frame_at(axis_x: Scalar, height: Scalar) -> Frame3 {
873    Frame3 {
874        origin: Point3::new(axis_x, height, 0.0),
875        x: Vec3::X,
876        y: Vec3::NEG_Z,
877        z: Vec3::Y,
878    }
879}
880
881/// A closed loop around one circular edge, in the cap plane.
882fn circle_loop(
883    builder: &mut ExactBRepBuilder,
884    edge: EdgeId,
885    radius: Scalar,
886    orientation: Orientation,
887) -> LoopId {
888    let pcurve = builder.add_curve2(Curve2::Circle(Circle2 {
889        frame: Frame2 {
890            origin: Vec2::ZERO,
891            x: Vec2::X,
892            y: Vec2::Y,
893        },
894        radius,
895    }));
896    let loop_id = builder.topology_mut().add_loop(Loop {
897        edges: vec![EdgeUse {
898            edge,
899            orientation,
900            pcurve: Some(pcurve),
901        }],
902    });
903    // A reversed edge use walks the curve backwards, so its pcurve interval
904    // must run backwards too. Leaving it forward puts the pcurve start
905    // diametrically opposite the edge start: the audit reports the mismatch
906    // as exactly twice the radius.
907    let interval = match orientation {
908        Orientation::Forward => Interval::new(0.0, TAU),
909        Orientation::Reversed => Interval::new(TAU, 0.0),
910    };
911    builder.set_pcurve_interval(loop_id, 0, interval);
912    loop_id
913}
914
915/// The seam loop for a wall spanning `v` from `from` to `to`: the lower
916/// circle, the seam up, the upper circle back, the seam down.
917///
918/// A wall between two disjoint circles cannot close with two edges: a loop
919/// must be vertex-connected, so the seam is traversed once each way. It adds
920/// no area because the two traversals cancel. A circle that is `None` is a
921/// pole or apex, a vertex: the loop omits its use and leaves a gap along the
922/// pole line in the wall's parameters.
923fn seam_loop_between(
924    builder: &mut ExactBRepBuilder,
925    lower: Option<EdgeId>,
926    upper: Option<EdgeId>,
927    seam: EdgeId,
928    from: Scalar,
929    to: Scalar,
930) -> LoopId {
931    let rise = to - from;
932    let mut uses = Vec::with_capacity(4);
933    if let Some(lower) = lower {
934        uses.push((
935            lower,
936            Orientation::Forward,
937            Line2 {
938                origin: Vec2::new(0.0, from),
939                direction: Vec2::X,
940            },
941            Interval::new(0.0, TAU),
942        ));
943    }
944    uses.push((
945        seam,
946        Orientation::Forward,
947        Line2 {
948            origin: Vec2::new(TAU, from),
949            direction: Vec2::new(0.0, rise),
950        },
951        Interval::UNIT,
952    ));
953    if let Some(upper) = upper {
954        uses.push((
955            upper,
956            Orientation::Reversed,
957            Line2 {
958                origin: Vec2::new(0.0, to),
959                direction: Vec2::X,
960            },
961            Interval::new(TAU, 0.0),
962        ));
963    }
964    uses.push((
965        seam,
966        Orientation::Reversed,
967        Line2 {
968            origin: Vec2::new(0.0, from),
969            direction: Vec2::new(0.0, rise),
970        },
971        Interval::new(1.0, 0.0),
972    ));
973    let (edges, intervals): (Vec<_>, Vec<_>) = uses
974        .into_iter()
975        .map(|(edge, orientation, line, interval)| {
976            let pcurve = builder.add_curve2(Curve2::Line(line));
977            (
978                EdgeUse {
979                    edge,
980                    orientation,
981                    pcurve: Some(pcurve),
982                },
983                interval,
984            )
985        })
986        .unzip();
987    let loop_id = builder.topology_mut().add_loop(Loop { edges });
988    for (use_index, interval) in intervals.into_iter().enumerate() {
989        builder.set_pcurve_interval(loop_id, use_index, interval);
990    }
991    loop_id
992}
993
994fn add_face(
995    builder: &mut ExactBRepBuilder,
996    surface: axiolid_brep::SurfaceId,
997    loop_id: LoopId,
998    orientation: Orientation,
999) -> FaceId {
1000    builder.topology_mut().add_face(Face {
1001        surface: Some(surface),
1002        bounds: vec![FaceBound {
1003            loop_id,
1004            orientation: Orientation::Forward,
1005            outer: true,
1006        }],
1007        orientation,
1008    })
1009}
1010
1011/// Close the faces into shells, one per connected piece of the revolved
1012/// section (two stations are connected by an edge between them off the
1013/// axis), and the shells into solids by nesting.
1014///
1015/// A section touching the axis in several places gives several pieces: a
1016/// cavity along the axis is a piece of its own. The pieces' ranges along the
1017/// axis (the heights of every station of each of its faces, an apex's
1018/// included) decide the nesting exactly, from heights taken from the input:
1019/// one strictly inside another's range is its void. A piece nested twice (a
1020/// solid inside a cavity) is refused. The faces already face out of the
1021/// material, so a void's faces face into its cavity as they are.
1022fn finish(
1023    mut builder: ExactBRepBuilder,
1024    stations: &[Station],
1025    faces: Vec<(FaceId, usize, usize)>,
1026) -> GeomResult<ExactBRep> {
1027    struct Piece {
1028        label: usize,
1029        faces: Vec<FaceId>,
1030        low: Scalar,
1031        high: Scalar,
1032    }
1033    let count = stations.len();
1034    let mut label: Vec<usize> = (0..count).collect();
1035    for index in 0..count {
1036        let next = (index + 1) % count;
1037        if stations[index].on_axis || stations[next].on_axis {
1038            continue;
1039        }
1040        let (a, b) = (label[index], label[next]);
1041        for l in label.iter_mut() {
1042            if *l == b {
1043                *l = a;
1044            }
1045        }
1046    }
1047    let mut pieces: Vec<Piece> = Vec::new();
1048    for (index, station) in stations.iter().enumerate() {
1049        if station.on_axis {
1050            continue;
1051        }
1052        match pieces.iter_mut().find(|piece| piece.label == label[index]) {
1053            Some(piece) => {
1054                piece.low = piece.low.min(station.height);
1055                piece.high = piece.high.max(station.height);
1056            }
1057            None => pieces.push(Piece {
1058                label: label[index],
1059                faces: Vec::new(),
1060                low: station.height,
1061                high: station.height,
1062            }),
1063        }
1064    }
1065    for (face, index, next) in faces {
1066        // The edge's station off the axis names its piece.
1067        let representative = if stations[index].on_axis { next } else { index };
1068        if let Some(piece) = pieces
1069            .iter_mut()
1070            .find(|piece| piece.label == label[representative])
1071        {
1072            piece.faces.push(face);
1073            // An apex or a disc's axis end lies off the piece's off-axis
1074            // stations; without it a cavity inside a spinning top would not
1075            // nest.
1076            for station in [index, next] {
1077                piece.low = piece.low.min(stations[station].height);
1078                piece.high = piece.high.max(stations[station].height);
1079            }
1080        }
1081    }
1082    let contains = |outer: &Piece, inner: &Piece| outer.low < inner.low && inner.high < outer.high;
1083    let depth: Vec<usize> = pieces
1084        .iter()
1085        .map(|piece| pieces.iter().filter(|other| contains(other, piece)).count())
1086        .collect();
1087    if depth.iter().any(|&d| d > 1) {
1088        return Err(unsupported(
1089            "exact revolution of a section with a solid nested inside a cavity",
1090        ));
1091    }
1092    let shells: Vec<_> = pieces
1093        .iter()
1094        .map(|piece| {
1095            builder.topology_mut().add_shell(Shell {
1096                faces: piece
1097                    .faces
1098                    .iter()
1099                    .map(|&face| (face, Orientation::Forward))
1100                    .collect(),
1101                closed: true,
1102            })
1103        })
1104        .collect();
1105    for outer in (0..pieces.len()).filter(|&piece| depth[piece] == 0) {
1106        let voids = (0..pieces.len())
1107            .filter(|&inner| depth[inner] == 1 && contains(&pieces[outer], &pieces[inner]))
1108            .map(|inner| shells[inner])
1109            .collect();
1110        builder.topology_mut().add_solid(Solid {
1111            outer: shells[outer],
1112            voids,
1113        });
1114    }
1115    builder.finish().map_err(|error| {
1116        GeomError::InvalidInput(format!("revolved solid failed validation: {error:?}"))
1117    })
1118}