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
50use axiolid_contracts::{GeomError, GeomResult};
51use axiolid_core::{Point3, Scalar, Tolerance, Vec3};
52use axiolid_mesh::TriMesh;
53
54use crate::profile::Rings;
55
56/// The most steps a revolution or a circle takes before it refuses the
57/// budget, rather than meet it with a coarser mesh that breaks the bound
58/// the caller asked for.
59pub(crate) const MAX_STEPS: usize = 4096;
60
61/// Rotation of `p` about the axis through `origin` along unit `dir`.
62///
63/// Rodrigues' formula. Written out rather than pulled from a matrix type
64/// so the axis stays arbitrary: a revolution is not a Z-up operation.
65pub(crate) fn rotate(p: Point3, origin: Point3, dir: Vec3, angle: Scalar) -> Point3 {
66    let v = p - origin;
67    let (s, c) = angle.sin_cos();
68    origin + v * c + dir.cross(v) * s + dir * (dir.dot(v) * (1.0 - c))
69}
70
71/// Angular steps so the swept arc meets the chord budget.
72///
73/// The widest point of the profile governs: a ring point at distance `r`
74/// from the axis traces a circle of that radius, and its sagitta is
75/// `r(1 - cos(dtheta/2))`. Using the maximum radius means every other
76/// point is sampled at least as finely. A budget that needs more than
77/// [`MAX_STEPS`] steps is refused, never met by fewer.
78pub(crate) fn steps(max_radius: Scalar, angle: Scalar, tol: Scalar) -> GeomResult<usize> {
79    if !(tol.is_finite() && tol > 0.0) {
80        return Err(GeomError::InvalidInput(format!(
81            "chord budget must be positive and finite, got {tol}"
82        )));
83    }
84    if !(max_radius.is_finite() && max_radius > 0.0) {
85        return Ok(8);
86    }
87    let ratio = (1.0 - (tol / max_radius).min(1.0)).clamp(-1.0, 1.0);
88    let per = 2.0 * ratio.acos();
89    let n = (angle.abs() / per).ceil();
90    // NaN (a zero step) counts as too many.
91    if n.is_nan() || n > MAX_STEPS as Scalar {
92        return Err(too_many_steps());
93    }
94    Ok((n as usize).max(2))
95}
96
97/// The refusal for a budget that needs more than [`MAX_STEPS`] steps.
98pub(crate) fn too_many_steps() -> GeomError {
99    GeomError::BudgetExceeded {
100        resource: "revolution angular steps",
101    }
102}
103
104/// The next step count to try when `n` misses the budget: a quarter more,
105/// so a near miss costs little and a far one converges in a few rounds.
106pub(crate) fn grow(n: usize) -> GeomResult<usize> {
107    let next = n + n / 4 + 1;
108    if next > MAX_STEPS {
109        return Err(too_many_steps());
110    }
111    Ok(next)
112}
113
114/// Steps for a revolution of `rings` through `angle` whose walls stay
115/// within `budget` of the surface the rings sweep (see the module notes).
116fn bounded_steps(
117    rings: &Rings,
118    axis_origin: Point3,
119    dir: Vec3,
120    angle: Scalar,
121    max_r: Scalar,
122    budget: Scalar,
123) -> GeomResult<usize> {
124    let at = |t: Scalar| {
125        crate::loft::place(rings, |p| {
126            rotate(Point3::new(p.x, p.y, 0.0), axis_origin, dir, t)
127        })
128    };
129    let mut n = steps(max_r, angle, budget)?;
130    loop {
131        let h = angle / n as Scalar;
132        let sagitta = max_r * (1.0 - (0.5 * h).cos());
133        // The loft walks the stations from the far end back, so each span
134        // runs from angle `t + h` to `t`; every span is this one rotated.
135        let twist = crate::loft::span_deviation(&at(h), &at(0.0));
136        if sagitta + twist <= budget {
137            return Ok(n);
138        }
139        n = grow(n)?;
140    }
141}
142
143/// Revolve a profile about an axis into a closed solid.
144///
145/// A full turn wraps its rings by index, so the seam shares vertices by
146/// construction rather than by two samplings agreeing numerically. That is
147/// the same structure `tessellate_primitive` uses for a cylinder, and it
148/// avoids the trim-based path recorded as broken in issue #2.
149///
150/// Every point of the surface the rings sweep lies within
151/// `tolerance.linear()` of the mesh (see the module notes for the proof);
152/// the rings' own distance from a curved profile is the caller's to add.
153/// A budget that needs more than 4096 steps round the axis is refused with
154/// `BudgetExceeded`.
155pub fn revolve(
156    rings: &Rings,
157    axis_origin: Point3,
158    axis_direction: Vec3,
159    angle: Scalar,
160    tolerance: Tolerance,
161) -> GeomResult<TriMesh> {
162    if !angle.is_finite() || angle == 0.0 {
163        return Err(GeomError::InvalidInput(format!(
164            "revolution angle must be finite and non-zero, got {angle}"
165        )));
166    }
167    let len = axis_direction.length();
168    if !len.is_finite() || len <= 0.0 {
169        return Err(GeomError::InvalidInput(
170            "revolution axis must be a finite non-zero direction".to_owned(),
171        ));
172    }
173    let dir = axis_direction / len;
174    // Widest distance from the axis governs the angular step: a point at
175    // radius r traces a circle of that radius.
176    let mut max_r: Scalar = 0.0;
177    for p in rings.outer.iter().chain(rings.holes.iter().flatten()) {
178        let v = Point3::new(p.x, p.y, 0.0) - axis_origin;
179        max_r = max_r.max((v - dir * dir.dot(v)).length());
180    }
181    let full = (angle.abs() - core::f64::consts::TAU).abs() <= 1e-9;
182    let n = bounded_steps(rings, axis_origin, dir, angle, max_r, tolerance.linear())?;
183    // A full turn emits n stations and wraps onto the first; a partial turn
184    // emits n + 1 so both ends exist to be capped.
185    let count = if full { n } else { n + 1 };
186    let stations: Vec<crate::loft::Station> = (0..count)
187        .rev()
188        .map(|s| {
189            let t = angle * (s as Scalar) / (n as Scalar);
190            crate::loft::place(rings, |p| {
191                rotate(Point3::new(p.x, p.y, 0.0), axis_origin, dir, t)
192            })
193        })
194        .collect();
195    let mut mesh = crate::loft::loft(rings, &stations, full)?;
196    // The station order above (end of the sweep first) winds the walls
197    // outward for a positive angle about `dir`. A negative angle sweeps the
198    // other way round, so the same order winds them inward instead (#221):
199    // `angle` and `dir` only matter through their product (the axis-angle
200    // rotation vector), and `(dir, angle)` and `(-dir, -angle)` describe the
201    // identical rotation, so the fix cannot key off `angle`'s stored sign
202    // alone without also depending on `dir`'s. Settling it from the built
203    // mesh's own orientation sidesteps that: whichever combination produced
204    // it, a closed solid's sign is unambiguous.
205    if matches!(crate::extrude::outward_orientation(&mesh), Some(false)) {
206        for triangle in mesh.indices.chunks_exact_mut(3) {
207            triangle.swap(1, 2);
208        }
209    }
210    Ok(mesh)
211}