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//!
20//! The cone check fitted `r` as an affine function of `z` to a residual of
21//! `2e-13`; the torus check evaluated the implicit form to `1.1e-15`.
22//!
23//! # Parameterisation
24//!
25//! Cylinder and cone both parameterise as `(angle, height)`, so a wall
26//! between two coaxial circles uses the same four-edge seam loop in both
27//! cases. That is why they share one code path here: giving the cone its own
28//! loop builder would let the two drift.
29
30use axiolid_brep::{ExactBRep, ExactBRepBuilder};
31use axiolid_contracts::{GeomError, GeomResult, Operation};
32use axiolid_core::{Frame2, Frame3, Interval, Point2, Point3, Scalar, Tolerance, Vec2, Vec3};
33use axiolid_curve::{Circle2, Circle3, Curve2, Curve3, Line2, Line3};
34use axiolid_surface::{Cone, Cylinder, Plane, Surface, Torus};
35use axiolid_topology::{
36    Edge, EdgeId, EdgeUse, Face, FaceBound, FaceId, Loop, LoopId, Orientation, Shell, Solid,
37    Vertex, VertexId,
38};
39
40use axiolid_overlay::ArcRing;
41
42use crate::contour_lower::arc_ring_signed_area;
43use crate::BACKEND_ID;
44
45const TAU: Scalar = core::f64::consts::TAU;
46
47fn unsupported(input: &'static str) -> GeomError {
48    GeomError::UnsupportedInput {
49        backend: BACKEND_ID,
50        operation: Operation::Sweep,
51        input,
52    }
53}
54
55/// One revolved section vertex: its distance from the axis and its height.
56#[derive(Debug, Clone, Copy)]
57struct Station {
58    /// Signed distance from the axis, in the profile plane.
59    radius: Scalar,
60    /// Position along the axis.
61    height: Scalar,
62    /// Bulge of the segment LEAVING this station.
63    bulge: Scalar,
64}
65/// Revolve a lowered section a full turn about the profile's local y axis.
66///
67/// The section must lie entirely on one side of the axis: a section touching
68/// or crossing it produces a solid whose walls collapse onto the axis, which
69/// is a different topology rather than a degenerate case of this one.
70pub fn revolve_arc_ring(
71    ring: &ArcRing,
72    axis_origin: Point3,
73    tolerance: Tolerance,
74) -> GeomResult<ExactBRep> {
75    let epsilon = tolerance.linear();
76    let count = ring.vertices.len();
77    if count < 3 {
78        return Err(GeomError::Degenerate(format!(
79            "a revolved section needs at least three vertices, got {count}"
80        )));
81    }
82
83    // Counter-clockwise in the profile plane gives outward wall normals once
84    // revolved, matching the extrusion convention.
85    let area = arc_ring_signed_area(ring);
86    if area == 0.0 {
87        return Err(GeomError::Degenerate(
88            "revolved section encloses no area".to_owned(),
89        ));
90    }
91    let forward = area > 0.0;
92
93    let stations: Vec<Station> = (0..count)
94        .map(|index| {
95            let source = if forward { index } else { count - 1 - index };
96            let vertex = ring.vertices[source];
97            let bulge = if forward {
98                vertex.bulge
99            } else {
100                // Reversing the walk moves each bulge onto the segment that
101                // now leaves this station, and flips its side.
102                -ring.vertices[(source + count - 1) % count].bulge
103            };
104            Station {
105                radius: vertex.point.x - axis_origin.x,
106                height: vertex.point.y,
107                bulge,
108            }
109        })
110        .collect();
111
112    let min = stations
113        .iter()
114        .map(|station| station.radius)
115        .fold(Scalar::INFINITY, Scalar::min);
116    let max = stations
117        .iter()
118        .map(|station| station.radius)
119        .fold(Scalar::NEG_INFINITY, Scalar::max);
120    if min * max < 0.0 || min.abs() <= epsilon || max.abs() <= epsilon {
121        return Err(unsupported(
122            "exact revolution of a section touching or crossing the axis",
123        ));
124    }
125    // Mirror a section on the negative side so every radius is positive; a
126    // full turn makes the two cases the same solid.
127    let flip = max < 0.0;
128    let stations: Vec<Station> = stations
129        .iter()
130        .map(|station| Station {
131            radius: if flip {
132                -station.radius
133            } else {
134                station.radius
135            },
136            height: station.height,
137            bulge: if flip { -station.bulge } else { station.bulge },
138        })
139        .collect();
140
141    build(&stations, axis_origin, epsilon)
142}
143/// Assemble the revolved solid: one circular edge per station, one wall face
144/// per segment.
145fn build(stations: &[Station], axis_origin: Point3, epsilon: Scalar) -> GeomResult<ExactBRep> {
146    let count = stations.len();
147    let mut builder = ExactBRepBuilder::default();
148    builder
149        .topology_mut()
150        .try_reserve(count * 2, count * 2, count * 2, count, 1, 1)
151        .map_err(|_| GeomError::BudgetExceeded {
152            resource: "exact revolution topology",
153        })?;
154
155    let axis_frame = |height: Scalar| frame_at(axis_origin.x, height);
156
157    // One circular edge per station, plus a seam so each wall loop closes.
158    let mut circles = Vec::with_capacity(count);
159    let mut vertices = Vec::with_capacity(count);
160    let mut points = Vec::with_capacity(count);
161    for station in stations {
162        let position = Point3::new(axis_origin.x + station.radius, station.height, 0.0);
163        let vertex = builder.topology_mut().add_vertex(Vertex { position });
164        let curve = builder.add_curve3(Curve3::Circle(Circle3 {
165            frame: axis_frame(station.height),
166            radius: station.radius,
167        }));
168        let edge = builder.topology_mut().add_edge(Edge {
169            start: vertex,
170            end: vertex,
171            curve: Some(curve),
172        });
173        builder.set_edge_interval(edge, Interval::new(0.0, TAU));
174        circles.push(edge);
175        vertices.push(vertex);
176        points.push(position);
177    }
178
179    let mut faces = Vec::with_capacity(count);
180    for index in 0..count {
181        let next = (index + 1) % count;
182        let here = stations[index];
183        let there = stations[next];
184        let face = if here.bulge != 0.0 {
185            torus_wall(
186                &mut builder,
187                axis_origin.x,
188                &circles,
189                &vertices,
190                stations,
191                index,
192            )?
193        } else if (here.radius - there.radius).abs() <= epsilon {
194            // Constant radius: a cylinder. A zero-height run would be a
195            // degenerate face, so it is skipped rather than emitted.
196            if (here.height - there.height).abs() <= epsilon {
197                continue;
198            }
199            straight_wall(
200                &mut builder,
201                axis_origin.x,
202                &circles,
203                &vertices,
204                &points,
205                stations,
206                index,
207                false,
208            )?
209        } else if (here.height - there.height).abs() <= epsilon {
210            // Constant height: a planar annulus.
211            annulus_wall(&mut builder, axis_origin.x, &circles, stations, index)?
212        } else {
213            // Neither constant: a cone.
214            straight_wall(
215                &mut builder,
216                axis_origin.x,
217                &circles,
218                &vertices,
219                &points,
220                stations,
221                index,
222                true,
223            )?
224        };
225        faces.push(face);
226    }
227
228    if faces.len() < 3 {
229        return Err(GeomError::Degenerate(format!(
230            "a revolved solid needs at least three faces, got {}",
231            faces.len()
232        )));
233    }
234    finish(builder, faces)
235}
236/// A cylinder or cone wall between two coaxial circles.
237///
238/// Both parameterise as `(angle, height)`, so one seam loop serves both: only
239/// the SURFACE differs. The seam is traversed forward and reversed so the
240/// loop is vertex-connected without enclosing extra area.
241#[allow(clippy::too_many_arguments)]
242fn straight_wall(
243    builder: &mut ExactBRepBuilder,
244    axis_x: Scalar,
245    circles: &[EdgeId],
246    vertices: &[VertexId],
247    points: &[Point3],
248    stations: &[Station],
249    index: usize,
250    cone: bool,
251) -> GeomResult<FaceId> {
252    let count = stations.len();
253    let next = (index + 1) % count;
254    let here = stations[index];
255    let there = stations[next];
256    let rise = there.height - here.height;
257
258    let seam_curve = builder.add_curve3(Curve3::Line(Line3 {
259        origin: points[index],
260        direction: points[next] - points[index],
261    }));
262    let seam = builder.topology_mut().add_edge(Edge {
263        start: vertices[index],
264        end: vertices[next],
265        curve: Some(seam_curve),
266    });
267    builder.set_edge_interval(seam, Interval::UNIT);
268
269    let surface = if cone {
270        // `semi_angle` is measured so radius grows as `r + v * tan(angle)`,
271        // which is exactly the slope of this segment.
272        let semi_angle = ((there.radius - here.radius) / rise).atan();
273        builder.add_surface(Surface::Cone(Cone {
274            frame: frame_at(axis_x, here.height),
275            radius: here.radius,
276            semi_angle,
277        }))
278    } else {
279        builder.add_surface(Surface::Cylinder(Cylinder {
280            frame: frame_at(axis_x, here.height),
281            radius: here.radius,
282        }))
283    };
284
285    let loop_id = seam_loop(builder, circles[index], circles[next], seam, rise);
286    Ok(add_face(builder, surface, loop_id, Orientation::Forward))
287}
288
289/// A planar annulus between two circles at the same height.
290fn annulus_wall(
291    builder: &mut ExactBRepBuilder,
292    axis_x: Scalar,
293    circles: &[EdgeId],
294    stations: &[Station],
295    index: usize,
296) -> GeomResult<FaceId> {
297    let count = stations.len();
298    let next = (index + 1) % count;
299    let here = stations[index];
300    let there = stations[next];
301    let surface = builder.add_surface(Surface::Plane(Plane {
302        frame: frame_at(axis_x, here.height),
303    }));
304
305    // The larger circle bounds the face and the smaller one is its hole, so
306    // the two loops carry opposite orientations.
307    let (outer, inner) = if here.radius > there.radius {
308        (index, next)
309    } else {
310        (next, index)
311    };
312    let outer_loop = circle_loop(
313        builder,
314        circles[outer],
315        stations[outer].radius,
316        Orientation::Forward,
317    );
318    let inner_loop = circle_loop(
319        builder,
320        circles[inner],
321        stations[inner].radius,
322        Orientation::Reversed,
323    );
324    let orientation = if here.radius > there.radius {
325        Orientation::Forward
326    } else {
327        Orientation::Reversed
328    };
329    Ok(builder.topology_mut().add_face(Face {
330        surface: Some(surface),
331        bounds: vec![
332            FaceBound {
333                loop_id: outer_loop,
334                orientation: Orientation::Forward,
335                outer: true,
336            },
337            FaceBound {
338                loop_id: inner_loop,
339                orientation: Orientation::Forward,
340                outer: false,
341            },
342        ],
343        orientation,
344    }))
345}
346/// A torus wall: an arc segment revolved about the axis.
347///
348/// A torus parameterises as `(angle about the axis, angle about the tube)`,
349/// so the trim region is the SAME rectangle the cylinder and cone use -- only
350/// the `v` range is the arc's own sweep instead of a height.
351fn torus_wall(
352    builder: &mut ExactBRepBuilder,
353    axis_x: Scalar,
354    circles: &[EdgeId],
355    vertices: &[VertexId],
356    stations: &[Station],
357    index: usize,
358) -> GeomResult<FaceId> {
359    let count = stations.len();
360    let next = (index + 1) % count;
361    let here = stations[index];
362    let there = stations[next];
363
364    let arc = crate::extrude_arc::arc_geometry(
365        Point2::new(here.radius, here.height),
366        Point2::new(there.radius, there.height),
367        here.bulge,
368    )?;
369    let (centre_radius, centre_height) = (arc.centre.x, arc.centre.y);
370
371    // A tube wider than its own ring distance is a SPINDLE torus: the surface
372    // passes through the axis and self-intersects, so the solid it bounds is
373    // not the one the section describes.
374    if arc.radius >= centre_radius.abs() - Scalar::EPSILON {
375        return Err(unsupported(
376            "revolved arc whose tube reaches the axis (spindle torus)",
377        ));
378    }
379    if centre_radius <= 0.0 {
380        return Err(unsupported(
381            "revolved arc centred on the far side of the axis",
382        ));
383    }
384
385    let surface = builder.add_surface(Surface::Torus(Torus {
386        frame: frame_at(axis_x, centre_height),
387        major_radius: centre_radius,
388        minor_radius: arc.radius,
389    }));
390
391    // `v` is measured from the outward radial direction in the tube plane.
392    let angle_of =
393        |station: Station| (station.height - centre_height).atan2(station.radius - centre_radius);
394    let start = angle_of(here);
395    // Walk the arc in its own direction rather than taking the shorter way
396    // round: the sweep says which half of the tube is material.
397    let end = start + arc.sweep;
398
399    // The seam runs ACROSS the tube, and on a torus that path is an arc, not
400    // a ruling: a straight chord sags below the surface by the sagitta
401    // `r*(1 - cos(sweep/2))`, which the audit reports exactly. Cylinders and
402    // cones get a straight seam because their v-direction genuinely is a
403    // straight ruling; a torus does not.
404    let seam_frame = Frame3 {
405        origin: Point3::new(axis_x + centre_radius, centre_height, 0.0),
406        x: Vec3::X,
407        y: Vec3::Y,
408        z: Vec3::Z,
409    };
410    let seam_curve = builder.add_curve3(Curve3::Circle(Circle3 {
411        frame: seam_frame,
412        radius: arc.radius,
413    }));
414    let seam = builder.topology_mut().add_edge(Edge {
415        start: vertices[index],
416        end: vertices[next],
417        curve: Some(seam_curve),
418    });
419    builder.set_edge_interval(seam, Interval::new(start, end));
420
421    let loop_id = seam_loop_between(builder, circles[index], circles[next], seam, start, end);
422    Ok(add_face(builder, surface, loop_id, Orientation::Forward))
423}
424// --- shared helpers ---------------------------------------------------------
425
426/// A surface frame on the axis at `height`, with local z along the axis.
427///
428/// `axis_x` is not optional: every circle in the solid is centred on the axis,
429/// so a frame at x = 0 disagrees with them by exactly the axis offset. The
430/// audit reports that as a pcurve error equal to `|axis_x|`.
431///
432/// The profile plane is `z = 0` and the axis is world `y`, so each frame puts
433/// its own `z` along the axis. Its `y` is world `-z`, not `+z`: `(X, Z, Y)` is
434/// LEFT-handed, and on a left-handed frame a loop running anticlockwise in
435/// `(u, v)` runs clockwise about `S_u x S_v`. Every loop here is built
436/// anticlockwise in its parameters, so a left-handed frame turned the whole
437/// solid inside out -- consistently enough that the topological and
438/// geometric audits, which only compare faces with each other, passed it.
439/// `exact_properties` measured `-2 pi R A` for every revolution.
440pub(crate) fn frame_at(axis_x: Scalar, height: Scalar) -> Frame3 {
441    Frame3 {
442        origin: Point3::new(axis_x, height, 0.0),
443        x: Vec3::X,
444        y: Vec3::NEG_Z,
445        z: Vec3::Y,
446    }
447}
448
449/// A closed loop around one circular edge, in the cap plane.
450fn circle_loop(
451    builder: &mut ExactBRepBuilder,
452    edge: EdgeId,
453    radius: Scalar,
454    orientation: Orientation,
455) -> LoopId {
456    let pcurve = builder.add_curve2(Curve2::Circle(Circle2 {
457        frame: Frame2 {
458            origin: Vec2::ZERO,
459            x: Vec2::X,
460            y: Vec2::Y,
461        },
462        radius,
463    }));
464    let loop_id = builder.topology_mut().add_loop(Loop {
465        edges: vec![EdgeUse {
466            edge,
467            orientation,
468            pcurve: Some(pcurve),
469        }],
470    });
471    // A reversed edge use walks the curve backwards, so its pcurve interval
472    // must run backwards too. Leaving it forward puts the pcurve start
473    // diametrically opposite the edge start: the audit reports the mismatch
474    // as exactly twice the radius.
475    let interval = match orientation {
476        Orientation::Forward => Interval::new(0.0, TAU),
477        Orientation::Reversed => Interval::new(TAU, 0.0),
478    };
479    builder.set_pcurve_interval(loop_id, 0, interval);
480    loop_id
481}
482
483/// The four-edge seam loop for a wall spanning `v` from 0 to `rise`.
484fn seam_loop(
485    builder: &mut ExactBRepBuilder,
486    lower: EdgeId,
487    upper: EdgeId,
488    seam: EdgeId,
489    rise: Scalar,
490) -> LoopId {
491    seam_loop_between(builder, lower, upper, seam, 0.0, rise)
492}
493
494/// The four-edge seam loop for a wall spanning `v` from `from` to `to`.
495///
496/// A wall between two disjoint circles cannot close with two edges: a loop
497/// must be vertex-connected, so the seam is traversed once each way. It adds
498/// no area because the two traversals cancel.
499fn seam_loop_between(
500    builder: &mut ExactBRepBuilder,
501    lower: EdgeId,
502    upper: EdgeId,
503    seam: EdgeId,
504    from: Scalar,
505    to: Scalar,
506) -> LoopId {
507    let lower_pcurve = builder.add_curve2(Curve2::Line(Line2 {
508        origin: Vec2::new(0.0, from),
509        direction: Vec2::X,
510    }));
511    let upper_pcurve = builder.add_curve2(Curve2::Line(Line2 {
512        origin: Vec2::new(0.0, to),
513        direction: Vec2::X,
514    }));
515    let up_pcurve = builder.add_curve2(Curve2::Line(Line2 {
516        origin: Vec2::new(TAU, from),
517        direction: Vec2::new(0.0, to - from),
518    }));
519    let down_pcurve = builder.add_curve2(Curve2::Line(Line2 {
520        origin: Vec2::new(0.0, from),
521        direction: Vec2::new(0.0, to - from),
522    }));
523    let loop_id = builder.topology_mut().add_loop(Loop {
524        edges: vec![
525            EdgeUse {
526                edge: lower,
527                orientation: Orientation::Forward,
528                pcurve: Some(lower_pcurve),
529            },
530            EdgeUse {
531                edge: seam,
532                orientation: Orientation::Forward,
533                pcurve: Some(up_pcurve),
534            },
535            EdgeUse {
536                edge: upper,
537                orientation: Orientation::Reversed,
538                pcurve: Some(upper_pcurve),
539            },
540            EdgeUse {
541                edge: seam,
542                orientation: Orientation::Reversed,
543                pcurve: Some(down_pcurve),
544            },
545        ],
546    });
547    builder.set_pcurve_interval(loop_id, 0, Interval::new(0.0, TAU));
548    builder.set_pcurve_interval(loop_id, 1, Interval::UNIT);
549    builder.set_pcurve_interval(loop_id, 2, Interval::new(TAU, 0.0));
550    builder.set_pcurve_interval(loop_id, 3, Interval::new(1.0, 0.0));
551    loop_id
552}
553
554fn add_face(
555    builder: &mut ExactBRepBuilder,
556    surface: axiolid_brep::SurfaceId,
557    loop_id: LoopId,
558    orientation: Orientation,
559) -> FaceId {
560    builder.topology_mut().add_face(Face {
561        surface: Some(surface),
562        bounds: vec![FaceBound {
563            loop_id,
564            orientation: Orientation::Forward,
565            outer: true,
566        }],
567        orientation,
568    })
569}
570
571fn finish(mut builder: ExactBRepBuilder, faces: Vec<FaceId>) -> GeomResult<ExactBRep> {
572    let shell = builder.topology_mut().add_shell(Shell {
573        faces: faces
574            .into_iter()
575            .map(|f| (f, Orientation::Forward))
576            .collect(),
577        closed: true,
578    });
579    builder.topology_mut().add_solid(Solid {
580        outer: shell,
581        voids: Vec::new(),
582    });
583    builder.finish().map_err(|error| {
584        GeomError::InvalidInput(format!("revolved solid failed validation: {error:?}"))
585    })
586}