Skip to main content

axiolid_construct/
revolve.rs

1//! Revolution of a profile about an axis.
2//!
3//! The profile is flattened to rings in its own plane, then each ring point
4//! is rotated about the axis in steps chosen by the chord budget. A full
5//! turn closes on itself; a partial turn is capped by the profile at each
6//! end.
7//!
8//! Pappus gives the oracle: a full revolution has volume `2*pi*R*A`, where
9//! `R` is the centroid's distance from the axis and `A` the profile area.
10//!
11//! # How far the walls sit from the surface (#231)
12//!
13//! [`revolve`] bounds the distance from every point of the surface swept by
14//! the GIVEN rings to its triangles by `tolerance.linear()`. Rings that
15//! chord a curved profile add their own deviation on top: a caller that
16//! wants the exact surface of revolution within a budget `c` flattens the
17//! profile to `c_p` and revolves to `c - c_p`. The reference compiler takes
18//! half each, which minimises the triangle count for a fixed sum: the steps
19//! round the profile and round the axis each grow as the inverse square
20//! root of their share, so their product is smallest at an even split. The
21//! two shares add because a rotation is an isometry: a point of the exact
22//! surface is the rotation of a profile point within `c_p` of the ring
23//! polygon, so it is within `c_p` of the same rotation of the polygon.
24//!
25//! Each ring segment `q_a -> q_b` sweeps, between the stations at angles
26//! `t` and `t + h`, the surface `R(s) q(x)` with `q(x) = (1-x) q_a + x q_b`.
27//! For fixed `x` the point `R(s) q(x)` runs along an arc of radius
28//! `rho(x)`, its distance from the axis, and the chord between the arc's
29//! ends is the row at `x` of the bilinear patch over the quad
30//! `R(t) q_a, R(t) q_b, R(t+h) q_a, R(t+h) q_b` (a rotation is linear in
31//! the point). An arc of angle `h` stays within its sagitta
32//! `rho (1 - cos(h/2))` of its chord, and `rho(x)` is the norm of an affine
33//! function, so convex in `x` and largest at a ring vertex: every point of
34//! the swept surface is within `rho_max (1 - cos(h/2))` of the patch, with
35//! `rho_max` the largest vertex distance from the axis over every ring --
36//! the outer equator, for a torus, never the tube centre. The patch is in
37//! turn within `loft::quad_deviation` of the two triangles the
38//! loft cuts it into. When the axis lies in the profile's plane every quad
39//! is a planar trapezoid (its two chords are images of parallel radial
40//! vectors under the same `R(t+h) - R(t)`), convex while the segment stays
41//! on one side of the axis, and that term is zero: the bound is the sagitta
42//! alone, whatever the sign of the Gaussian curvature (the inner side of a
43//! torus is saddle-shaped, but its trapezoids are still flat). An axis off
44//! the profile's plane sweeps hyperboloidal quads; their measured twist is
45//! added, and the step shrinks until the sum fits.
46//!
47//! Every span of a revolution is the first span rotated, so the twist is
48//! measured once.
49//!
50//! # Vertices on the axis (#363)
51//!
52//! A ring vertex exactly on an in-plane axis (the exact `side_of_axis` sign
53//! is zero) is fixed under the rotation, so all its stations coincide. It
54//! gets one mesh vertex, shared by every station and by both caps of a
55//! partial turn. Its quads collapse: the one with a single axis end keeps the
56//! triangle that is not degenerate, and the one with both ends on the axis (a
57//! profile edge lying along it) sweeps nothing and emits nothing; an axis
58//! edge of a partial turn is then an edge between the two caps. The bound
59//! above is untouched. A point at radius 0 traces no circle, so the step
60//! count, which the largest radius governs, is unchanged. A collapsed quad is
61//! the triangle `(a, b, d)` (for `b = d`) or `(a, d, c)` (for `a = c`), which
62//! the patch bounds of `loft::quad_deviation` cover exactly: `|c - a|` is
63//! zero for `a = c`, and `2|d - b|` is zero for `b = d`, where the patch
64//! degenerates into the triangle `(a, d, c)`. The merge changes neither. Merging moves no
65//! vertex: the shared one is the profile point itself, which every station's
66//! rotated copy equals up to rounding.
67//!
68//! In a full turn only a vertex that ends a ring edge lying along the axis is
69//! accepted (sphere, cylinder and cone profiles). One whose both ring
70//! neighbours are off the axis is refused (`UnsupportedInput`): two nappes of
71//! the surface meet at that point, the boundary is not a two-manifold there,
72//! and its mesh would be non-manifold at the vertex whatever the edge audit
73//! says, as for the horn torus of #248. In a partial turn the two caps close
74//! the point into a sector point, as in the exact path, and it is meshed.
75//!
76//! The weld is exact: it needs the axis origin and direction to have z
77//! exactly zero. With z noise within the tolerance, which the in-plane check
78//! forgives, a 2D-on-axis vertex is off the real axis and its orbit is a real
79//! circle, so a touching profile is refused (`UnsupportedInput`) instead.
80
81use axiolid_contracts::{GeomError, GeomResult, Operation};
82use axiolid_core::{Point2, Point3, Scalar, Tolerance, Vec3};
83use axiolid_guarantees::Sign;
84use axiolid_mesh::TriMesh;
85use axiolid_predicates::arithmetic::{expansion_sign, expansion_sum};
86use axiolid_predicates::two_product;
87
88use crate::profile::Rings;
89
90/// The most steps a revolution or a circle takes before it refuses the
91/// budget, rather than meet it with a coarser mesh that breaks the bound
92/// the caller asked for.
93pub(crate) const MAX_STEPS: usize = 4096;
94
95/// Rotation of `p` about the axis through `origin` along unit `dir`.
96///
97/// Rodrigues' formula. Written out rather than pulled from a matrix type
98/// so the axis stays arbitrary: a revolution is not a Z-up operation.
99pub(crate) fn rotate(p: Point3, origin: Point3, dir: Vec3, angle: Scalar) -> Point3 {
100    let v = p - origin;
101    let (s, c) = angle.sin_cos();
102    origin + v * c + dir.cross(v) * s + dir * (dir.dot(v) * (1.0 - c))
103}
104
105/// Angular steps so the swept arc meets the chord budget.
106///
107/// The widest point of the profile governs: a ring point at distance `r`
108/// from the axis traces a circle of that radius, and its sagitta is
109/// `r(1 - cos(dtheta/2))`. Using the maximum radius means every other
110/// point is sampled at least as finely. A budget that needs more than
111/// [`MAX_STEPS`] steps is refused, never met by fewer.
112pub(crate) fn steps(max_radius: Scalar, angle: Scalar, tol: Scalar) -> GeomResult<usize> {
113    if !(tol.is_finite() && tol > 0.0) {
114        return Err(GeomError::InvalidInput(format!(
115            "chord budget must be positive and finite, got {tol}"
116        )));
117    }
118    if !(max_radius.is_finite() && max_radius > 0.0) {
119        return Ok(8);
120    }
121    let ratio = (1.0 - (tol / max_radius).min(1.0)).clamp(-1.0, 1.0);
122    let per = 2.0 * ratio.acos();
123    let n = (angle.abs() / per).ceil();
124    // NaN (a zero step) counts as too many.
125    if n.is_nan() || n > MAX_STEPS as Scalar {
126        return Err(too_many_steps());
127    }
128    Ok((n as usize).max(2))
129}
130
131/// The refusal for a budget that needs more than [`MAX_STEPS`] steps.
132pub(crate) fn too_many_steps() -> GeomError {
133    GeomError::BudgetExceeded {
134        resource: "revolution angular steps",
135    }
136}
137
138/// The next step count to try when `n` misses the budget: a quarter more,
139/// so a near miss costs little and a far one converges in a few rounds.
140pub(crate) fn grow(n: usize) -> GeomResult<usize> {
141    let next = n + n / 4 + 1;
142    if next > MAX_STEPS {
143        return Err(too_many_steps());
144    }
145    Ok(next)
146}
147
148/// Steps for a revolution of `rings` through `angle` whose walls stay
149/// within `budget` of the surface the rings sweep (see the module notes).
150fn bounded_steps(
151    rings: &Rings,
152    axis_origin: Point3,
153    dir: Vec3,
154    angle: Scalar,
155    max_r: Scalar,
156    budget: Scalar,
157) -> GeomResult<usize> {
158    let at = |t: Scalar| {
159        crate::loft::place(rings, |p| {
160            rotate(Point3::new(p.x, p.y, 0.0), axis_origin, dir, t)
161        })
162    };
163    let mut n = steps(max_r, angle, budget)?;
164    loop {
165        let h = angle / n as Scalar;
166        let sagitta = max_r * (1.0 - (0.5 * h).cos());
167        // The loft walks the stations from the far end back, so each span
168        // runs from angle `t + h` to `t`; every span is this one rotated.
169        let twist = crate::loft::span_deviation(&at(h), &at(0.0));
170        if sagitta + twist <= budget {
171            return Ok(n);
172        }
173        n = grow(n)?;
174    }
175}
176
177/// Exact sign of `d x (p - a)`, the side of `p` of the line through `a`
178/// along `d`: `d.x*p.y - d.x*a.y - d.y*p.x + d.y*a.x`, summed as expansions.
179///
180/// Not `orient2d(a, a + d, p)`: `a + d` rounds, which tilts the line it
181/// decides against.
182fn side_of_axis(a: Point2, d: Vec3, p: Point2) -> Sign {
183    let term = |x: Scalar, y: Scalar, negate: bool| {
184        let (product, error) = two_product(x, y);
185        if negate {
186            [-error, -product]
187        } else {
188            [error, product]
189        }
190    };
191    let mut sum = expansion_sum(&term(d.x, p.y, false), &term(d.x, a.y, true));
192    sum = expansion_sum(&sum, &term(d.y, p.x, true));
193    sum = expansion_sum(&sum, &term(d.y, a.x, false));
194    expansion_sign(&sum)
195}
196
197/// Refuse rings that lie on both sides of the axis, and report which touch
198/// it (#351, #363).
199///
200/// Decided when the axis lies in the profile's plane, where "side" is
201/// meaningful: each vertex is classified by the exact sign of its cross
202/// product with the axis direction, with no tolerance band. Vertices
203/// strictly on both sides are a crossing (the swept solid passes through
204/// itself) and `InvalidInput`. A vertex on the axis that ends an edge lying
205/// on it is not an error: the result holds `true` at its index in the flat
206/// ring order (outer ring, then each hole) and [`revolve`] meshes it once. A
207/// vertex on the axis with both ring neighbours off it pinches the solid to
208/// a point and, in a `full` turn, is `UnsupportedInput`; a partial turn's
209/// caps close it into a sector point. An edge cannot meet the axis between
210/// two vertices without one of those cases. A vertex on the projected axis
211/// is `UnsupportedInput` too when the axis origin or direction has any
212/// nonzero z: the vertex is then off the real axis by the noise, and welding
213/// it would collapse a real orbit.
214///
215/// "In the plane" follows the exact path: the axis origin and direction
216/// leave it only beyond `tolerance.linear()` in z, so placement noise does
217/// not switch the check off. An axis parallel to the plane but offset from
218/// it is checked for crossing only: no ring point reaches the axis, since
219/// its radius stays at least the offset, so nothing is reported. An axis
220/// leaning out of the plane is unchecked and tracked in #362.
221pub(crate) fn check_axis_side(
222    rings: &Rings,
223    axis_origin: Point3,
224    axis_direction: Vec3,
225    tolerance: Tolerance,
226    full: bool,
227) -> GeomResult<Vec<bool>> {
228    let count = rings.outer.len() + rings.holes.iter().map(Vec::len).sum::<usize>();
229    // TODO(#362): an axis leaning out of the profile plane is not judged.
230    if axis_direction.z.abs() > tolerance.linear() * axis_direction.length() {
231        return Ok(vec![false; count]);
232    }
233    let offset = axis_origin.z.abs() > tolerance.linear();
234    let exact = axis_origin.z == 0.0 && axis_direction.z == 0.0;
235    let a = Point2::new(axis_origin.x, axis_origin.y);
236    let (mut positive, mut negative, mut noisy_touch) = (false, false, false);
237    let mut on_axis = Vec::with_capacity(count);
238    for p in rings.outer.iter().chain(rings.holes.iter().flatten()) {
239        if !(p.x.is_finite() && p.y.is_finite()) {
240            return Err(GeomError::InvalidInput(
241                "revolved profile must have finite vertices".to_owned(),
242            ));
243        }
244        let side = side_of_axis(a, axis_direction, *p);
245        match side {
246            Sign::Positive => positive = true,
247            Sign::Negative => negative = true,
248            _ => {}
249        }
250        noisy_touch |= side == Sign::Zero && !offset && !exact;
251        on_axis.push(side == Sign::Zero && !offset);
252    }
253    if positive && negative {
254        return Err(GeomError::InvalidInput(
255            "revolved profile crosses the axis: its rings lie on both sides".to_owned(),
256        ));
257    }
258    if noisy_touch {
259        return Err(touching_unsupported());
260    }
261    // In a full turn a vertex on the axis whose ring neighbours are both off
262    // it is a pinch: two nappes of the swept surface meet at one point, so
263    // the boundary is not a two-manifold there (the horn torus of #248 is
264    // refused alike). A partial turn's caps close it into a sector point.
265    if !full {
266        return Ok(on_axis);
267    }
268    let mut start = 0;
269    for len in core::iter::once(rings.outer.len()).chain(rings.holes.iter().map(Vec::len)) {
270        let ring = &on_axis[start..start + len];
271        for k in 0..len {
272            if ring[k] && !ring[(k + len - 1) % len] && !ring[(k + 1) % len] {
273                return Err(GeomError::UnsupportedInput {
274                    backend: crate::BACKEND_ID,
275                    operation: Operation::Sweep,
276                    input: "revolution of a profile that touches the axis at an isolated \
277                            point: the solid pinches there",
278                });
279            }
280        }
281        start += len;
282    }
283    Ok(on_axis)
284}
285
286/// The refusal for a family that cannot yet mesh a profile touching the
287/// axis (#363).
288pub(crate) fn touching_unsupported() -> GeomError {
289    GeomError::UnsupportedInput {
290        backend: crate::BACKEND_ID,
291        operation: Operation::Sweep,
292        input: "revolution of a profile touching the axis",
293    }
294}
295
296/// Revolve a profile about an axis into a closed solid.
297///
298/// A full turn wraps its rings by index, so the seam shares vertices by
299/// construction rather than by two samplings agreeing numerically. That is
300/// the same structure `tessellate_primitive` uses for a cylinder, and it
301/// avoids the trim-based path recorded as broken in issue #2.
302///
303/// Every point of the surface the rings sweep lies within
304/// `tolerance.linear()` of the mesh (see the module notes for the proof);
305/// the rings' own distance from a curved profile is the caller's to add.
306/// A budget that needs more than 4096 steps round the axis is refused with
307/// `BudgetExceeded`. A profile with vertices on both sides of an in-plane
308/// axis is `InvalidInput`. A vertex on the axis is meshed once, the point
309/// being fixed under the rotation (see the module notes).
310pub fn revolve(
311    rings: &Rings,
312    axis_origin: Point3,
313    axis_direction: Vec3,
314    angle: Scalar,
315    tolerance: Tolerance,
316) -> GeomResult<TriMesh> {
317    if !angle.is_finite() || angle == 0.0 {
318        return Err(GeomError::InvalidInput(format!(
319            "revolution angle must be finite and non-zero, got {angle}"
320        )));
321    }
322    let len = axis_direction.length();
323    if !len.is_finite() || len <= 0.0 {
324        return Err(GeomError::InvalidInput(
325            "revolution axis must be a finite non-zero direction".to_owned(),
326        ));
327    }
328    let full = (angle.abs() - core::f64::consts::TAU).abs() <= 1e-9;
329    let on_axis = check_axis_side(rings, axis_origin, axis_direction, tolerance, full)?;
330    let dir = axis_direction / len;
331    // Widest distance from the axis governs the angular step: a point at
332    // radius r traces a circle of that radius.
333    let mut max_r: Scalar = 0.0;
334    for p in rings.outer.iter().chain(rings.holes.iter().flatten()) {
335        let v = Point3::new(p.x, p.y, 0.0) - axis_origin;
336        max_r = max_r.max((v - dir * dir.dot(v)).length());
337    }
338    let n = bounded_steps(rings, axis_origin, dir, angle, max_r, tolerance.linear())?;
339    // A full turn emits n stations and wraps onto the first; a partial turn
340    // emits n + 1 so both ends exist to be capped.
341    let count = if full { n } else { n + 1 };
342    let stations: Vec<crate::loft::Station> = (0..count)
343        .rev()
344        .map(|s| {
345            let t = angle * (s as Scalar) / (n as Scalar);
346            crate::loft::place(rings, |p| {
347                rotate(Point3::new(p.x, p.y, 0.0), axis_origin, dir, t)
348            })
349        })
350        .collect();
351    let mut mesh = crate::loft::loft_welded(rings, &stations, full, &on_axis)?;
352    // The station order above (end of the sweep first) winds the walls
353    // outward for a positive angle about `dir`. A negative angle sweeps the
354    // other way round, so the same order winds them inward instead (#221):
355    // `angle` and `dir` only matter through their product (the axis-angle
356    // rotation vector), and `(dir, angle)` and `(-dir, -angle)` describe the
357    // identical rotation, so the fix cannot key off `angle`'s stored sign
358    // alone without also depending on `dir`'s. Settling it from the built
359    // mesh's own orientation sidesteps that: whichever combination produced
360    // it, a closed solid's sign is unambiguous.
361    if matches!(crate::extrude::outward_orientation(&mesh), Some(false)) {
362        for triangle in mesh.indices.as_chunks_mut::<3>().0 {
363            triangle.swap(1, 2);
364        }
365    }
366    Ok(mesh)
367}