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//!
81//! # Vertices near the axis (#367)
82//!
83//! A vertex that touches the axis need not be on it to the bit: the joint of
84//! two arcs is trigonometric (x of about 6e-17 on the Y axis), and almost no
85//! vertex is on a tilted axis. For the exact in-plane axis a vertex within
86//! `tolerance.linear()` of the axis line, measured to the line, is treated
87//! as on it: it is projected onto the line and welded as above, and the
88//! pinch refusal applies to it in a full turn. Moving a ring vertex by `d`
89//! moves the swept surface by at most `d`, so the largest projection is
90//! taken from the chord budget the steps must meet (`BudgetExceeded` when it
91//! leaves none), and the bound stays the budget. The distance is a float
92//! evaluation with a rounding bound; a distance within that bound of the
93//! tolerance is undecidable and `UnsupportedInput`, never welded. Under z
94//! noise nothing is projected, as nothing is welded. A ring left with no area
95//! by the projection (every vertex in the band) is `UnsupportedInput`: it
96//! would vanish from the solid or leave it empty.
97
98use axiolid_contracts::{GeomError, GeomResult, Operation};
99use axiolid_core::{Point2, Point3, Scalar, Tolerance, Vec3};
100use axiolid_guarantees::Sign;
101use axiolid_mesh::TriMesh;
102use axiolid_predicates::arithmetic::{expansion_sign, expansion_sum};
103use axiolid_predicates::two_product;
104
105use crate::profile::Rings;
106
107/// The most steps a revolution or a circle takes before it refuses the
108/// budget, rather than meet it with a coarser mesh that breaks the bound
109/// the caller asked for.
110pub(crate) const MAX_STEPS: usize = 4096;
111
112/// Rotation of `p` about the axis through `origin` along unit `dir`.
113///
114/// Rodrigues' formula. Written out rather than pulled from a matrix type
115/// so the axis stays arbitrary: a revolution is not a Z-up operation.
116pub(crate) fn rotate(p: Point3, origin: Point3, dir: Vec3, angle: Scalar) -> Point3 {
117    let v = p - origin;
118    let (s, c) = angle.sin_cos();
119    origin + v * c + dir.cross(v) * s + dir * (dir.dot(v) * (1.0 - c))
120}
121
122/// Angular steps so the swept arc meets the chord budget.
123///
124/// The widest point of the profile governs: a ring point at distance `r`
125/// from the axis traces a circle of that radius, and its sagitta is
126/// `r(1 - cos(dtheta/2))`. Using the maximum radius means every other
127/// point is sampled at least as finely. A budget that needs more than
128/// [`MAX_STEPS`] steps is refused, never met by fewer.
129pub(crate) fn steps(max_radius: Scalar, angle: Scalar, tol: Scalar) -> GeomResult<usize> {
130    if !(tol.is_finite() && tol > 0.0) {
131        return Err(GeomError::InvalidInput(format!(
132            "chord budget must be positive and finite, got {tol}"
133        )));
134    }
135    if !(max_radius.is_finite() && max_radius > 0.0) {
136        return Ok(8);
137    }
138    let ratio = (1.0 - (tol / max_radius).min(1.0)).clamp(-1.0, 1.0);
139    let per = 2.0 * ratio.acos();
140    let n = (angle.abs() / per).ceil();
141    // NaN (a zero step) counts as too many.
142    if n.is_nan() || n > MAX_STEPS as Scalar {
143        return Err(too_many_steps());
144    }
145    Ok((n as usize).max(2))
146}
147
148/// The refusal for a budget that needs more than [`MAX_STEPS`] steps.
149pub(crate) fn too_many_steps() -> GeomError {
150    GeomError::BudgetExceeded {
151        resource: "revolution angular steps",
152    }
153}
154
155/// The next step count to try when `n` misses the budget: a quarter more,
156/// so a near miss costs little and a far one converges in a few rounds.
157pub(crate) fn grow(n: usize) -> GeomResult<usize> {
158    let next = n + n / 4 + 1;
159    if next > MAX_STEPS {
160        return Err(too_many_steps());
161    }
162    Ok(next)
163}
164
165/// Steps for a revolution of `rings` through `angle` whose walls stay
166/// within `budget` of the surface the rings sweep (see the module notes).
167fn bounded_steps(
168    rings: &Rings,
169    axis_origin: Point3,
170    dir: Vec3,
171    angle: Scalar,
172    max_r: Scalar,
173    budget: Scalar,
174) -> GeomResult<usize> {
175    let at = |t: Scalar| {
176        crate::loft::place(rings, |p| {
177            rotate(Point3::new(p.x, p.y, 0.0), axis_origin, dir, t)
178        })
179    };
180    let mut n = steps(max_r, angle, budget)?;
181    loop {
182        let h = angle / n as Scalar;
183        let sagitta = max_r * (1.0 - (0.5 * h).cos());
184        // The loft walks the stations from the far end back, so each span
185        // runs from angle `t + h` to `t`; every span is this one rotated.
186        let twist = crate::loft::span_deviation(&at(h), &at(0.0));
187        if sagitta + twist <= budget {
188            return Ok(n);
189        }
190        n = grow(n)?;
191    }
192}
193
194/// Exact sign of `d x (p - a)`, the side of `p` of the line through `a`
195/// along `d`: `d.x*p.y - d.x*a.y - d.y*p.x + d.y*a.x`, summed as expansions.
196///
197/// Not `orient2d(a, a + d, p)`: `a + d` rounds, which tilts the line it
198/// decides against.
199pub(crate) fn side_of_axis(a: Point2, d: Vec3, p: Point2) -> Sign {
200    let mut sum = expansion_sum(&term(d.x, p.y, false), &term(d.x, a.y, true));
201    sum = expansion_sum(&sum, &term(d.y, p.x, true));
202    sum = expansion_sum(&sum, &term(d.y, a.x, false));
203    expansion_sign(&sum)
204}
205
206/// Exact sign of `d . (to - from)`, how far `to` lies along the axis
207/// direction `d` from `from`: `d.x*to.x - d.x*from.x + d.y*to.y - d.y*from.y`,
208/// summed as expansions. Zero exactly when the segment is perpendicular to
209/// the axis (#450).
210pub(crate) fn along_axis(d: Vec3, from: Point2, to: Point2) -> Sign {
211    let mut sum = expansion_sum(&term(d.x, to.x, false), &term(d.x, from.x, true));
212    sum = expansion_sum(&sum, &term(d.y, to.y, false));
213    sum = expansion_sum(&sum, &term(d.y, from.y, true));
214    expansion_sign(&sum)
215}
216
217/// `x * y` (or its negation) as the two-term expansion of its exact value.
218fn term(x: Scalar, y: Scalar, negate: bool) -> [Scalar; 2] {
219    let (product, error) = two_product(x, y);
220    if negate {
221        [-error, -product]
222    } else {
223        [error, product]
224    }
225}
226
227/// The exact side of every ring vertex of the line through `a` along the
228/// `x`, `y` of `d`, in flat ring order (outer ring, then each hole).
229fn ring_sides(rings: &Rings, a: Point2, d: Vec3) -> GeomResult<Vec<Sign>> {
230    let mut sides = Vec::new();
231    for p in rings.outer.iter().chain(rings.holes.iter().flatten()) {
232        if !(p.x.is_finite() && p.y.is_finite()) {
233            return Err(GeomError::InvalidInput(
234                "revolved profile must have finite vertices".to_owned(),
235            ));
236        }
237        sides.push(side_of_axis(a, d, *p));
238    }
239    Ok(sides)
240}
241
242/// Judge an axis leaning out of the profile plane (`axis_direction.z`
243/// beyond the tolerance) (#362).
244///
245/// A point `p` of the plane has the invariants `h`, its position along the
246/// axis, and `rho`, its distance from it, and two points sweep the same
247/// orbit exactly when they agree in both. `h` is linear in the plane, so
248/// each level set is a line perpendicular to the axis's projection `N` into
249/// the plane; along it `rho^2 = s^2 + c` is even about `N`. Two points
250/// share an orbit exactly when they are mirror images across `N`, so the
251/// sweep is free of self-intersection exactly when no two points of the
252/// profile are, and for a region that holds no mirror pair, that is when it
253/// lies on one closed side of `N`. The exact side test of the in-plane axis,
254/// applied to `N` (the line through the origin's `x`, `y` along the
255/// direction's), decides that, with no tolerance band: rings on both
256/// strict sides are `InvalidInput`. This subsumes an axis piercing the
257/// interior of the profile, which puts rings on both sides.
258///
259/// The axis meets the plane at one point `X` on `N`. A profile touching `N`
260/// is fine, its points have `rho > 0`, unless `X` is on the profile: that
261/// point is fixed under the rotation and the solid pinches there. `X` is not
262/// rational in the inputs without division, so it is decided with a margin:
263/// the profile's contact with `N` (its vertices on `N`, and the edges
264/// between two of them) must keep clear of `X` by more than
265/// `tolerance.linear()` plus a rounding allowance, measured along `N` (the
266/// distance between the two points of the plane, not their orbit radius
267/// about the axis), and is otherwise `UnsupportedInput`, a case that is not
268/// certified. A profile strictly to one side of `N` has no contact and needs
269/// no margin. An axis exactly perpendicular to the plane has no projection:
270/// every copy of the profile lies in the plane and they coincide,
271/// `InvalidInput`.
272///
273/// A tapered revolution blends `start` into `end` linearly, so every
274/// station is judged: a vertex on one side of `N` at one end and on the
275/// other at the other end crosses `N` between them, which the pooled side
276/// test refuses; a vertex on `N` at both ends slides along it, and its
277/// whole closed span of offsets, and an edge on `N` at both ends the span of
278/// its four, must keep clear of `X`. A vertex or edge on `N` at one end only
279/// touches it there alone. A plain revolution passes its rings as both.
280///
281/// No vertex is welded: a vertex on `N` is off the axis. A partial turn is
282/// judged alike, as an in-plane axis is.
283fn check_leaning_axis(
284    start: &Rings,
285    end: &Rings,
286    axis_origin: Point3,
287    axis_direction: Vec3,
288    tolerance: Tolerance,
289) -> GeomResult<()> {
290    if axis_direction.x == 0.0 && axis_direction.y == 0.0 {
291        return Err(GeomError::InvalidInput(
292            "revolved profile lies in a plane perpendicular to the axis: its copies coincide"
293                .to_owned(),
294        ));
295    }
296    let structure = |r: &Rings| -> Vec<usize> {
297        core::iter::once(r.outer.len())
298            .chain(r.holes.iter().map(Vec::len))
299            .collect()
300    };
301    let shape = structure(start);
302    if shape != structure(end) {
303        return Err(GeomError::InvalidInput(
304            "the end rings of a revolution must share their ring structure".to_owned(),
305        ));
306    }
307    let a = Point2::new(axis_origin.x, axis_origin.y);
308    let sides_start = ring_sides(start, a, axis_direction)?;
309    let sides_end = ring_sides(end, a, axis_direction)?;
310    let both = |sign: Sign| sides_start.contains(&sign) || sides_end.contains(&sign);
311    if both(Sign::Positive) && both(Sign::Negative) {
312        return Err(GeomError::InvalidInput(
313            "revolved profile crosses the axis: its rings lie on both sides of the axis's \
314             projection into the profile plane"
315                .to_owned(),
316        ));
317    }
318    if !both(Sign::Zero) {
319        return Ok(());
320    }
321    // Positions along N measured from where the axis meets the plane.
322    let along = axis_direction.x.hypot(axis_direction.y);
323    let t = -axis_origin.z / axis_direction.z;
324    let uncertified = || GeomError::UnsupportedInput {
325        backend: crate::BACKEND_ID,
326        operation: Operation::Sweep,
327        input: "revolution about an axis leaning out of the profile plane that may meet the \
328                profile",
329    };
330    if !t.is_finite() || !along.is_finite() {
331        return Err(uncertified());
332    }
333    let unit = (axis_direction.x / along, axis_direction.y / along);
334    let reach = t * along;
335    let offset_of = |p: Point2| (p.x - a.x) * unit.0 + (p.y - a.y) * unit.1 - reach;
336    let slack = |p: Point2| {
337        let scale =
338            p.x.abs()
339                .max(p.y.abs())
340                .max(a.x.abs())
341                .max(a.y.abs())
342                .max(reach.abs());
343        tolerance.linear() + 64.0 * Scalar::EPSILON * scale
344    };
345    let flat = |r: &Rings| -> Vec<Point2> {
346        r.outer
347            .iter()
348            .chain(r.holes.iter().flatten())
349            .copied()
350            .collect()
351    };
352    let (from, to) = (flat(start), flat(end));
353    // The offset along N of ring point `i` at one end, if it is on N.
354    let at = |sides: &[Sign], points: &[Point2], i: usize| {
355        (sides[i] == Sign::Zero).then(|| offset_of(points[i]))
356    };
357    let mut first = 0;
358    for len in shape {
359        for k in 0..len {
360            let (f, g) = (first + k, first + (k + 1) % len);
361            let (s0, s1) = (at(&sides_start, &from, f), at(&sides_start, &from, g));
362            let (e0, e1) = (at(&sides_end, &to, f), at(&sides_end, &to, g));
363            let margin = slack(from[f]).max(slack(to[f]));
364            // The closed spans of offsets the vertex and the edge cover: at
365            // each end alone, and sliding along N between the two.
366            for group in [
367                vec![s0],
368                vec![e0],
369                vec![s0, e0],
370                vec![s0, s1],
371                vec![e0, e1],
372                vec![s0, s1, e0, e1],
373            ] {
374                // A group counts only when every member is on N.
375                if group.iter().any(Option::is_none) {
376                    continue;
377                }
378                let offsets: Vec<Scalar> = group.into_iter().flatten().collect();
379                let low = offsets.iter().copied().fold(Scalar::INFINITY, Scalar::min);
380                let high = offsets
381                    .iter()
382                    .copied()
383                    .fold(Scalar::NEG_INFINITY, Scalar::max);
384                if !(low.is_finite() && high.is_finite())
385                    || (low - margin <= 0.0 && 0.0 <= high + margin)
386                {
387                    return Err(uncertified());
388                }
389            }
390        }
391        first += len;
392    }
393    Ok(())
394}
395
396/// [`check_axis_side`] for a tapered revolution from `start` to `end` (#364,
397/// #362): the flags are over `[start.outer, end.outer, start.holes..,
398/// end.holes..]`, the order its welding reads.
399///
400/// An axis leaning out of the plane is judged over the whole blend (see
401/// [`check_leaning_axis`]) and flags nothing. Otherwise the rings are judged
402/// together, each as a ring of its own, so a section that starts on one
403/// side of the axis and ends on the other is refused (#351).
404pub(crate) fn check_blend_axis_side(
405    start: &Rings,
406    end: &Rings,
407    axis_origin: Point3,
408    axis_direction: Vec3,
409    tolerance: Tolerance,
410    full: bool,
411) -> GeomResult<BlendContact> {
412    let ends = Rings {
413        outer: start.outer.clone(),
414        holes: core::iter::once(&end.outer)
415            .chain(&start.holes)
416            .chain(&end.holes)
417            .cloned()
418            .collect(),
419    };
420    if axis_direction.z.abs() > tolerance.linear() * axis_direction.length() {
421        check_leaning_axis(start, end, axis_origin, axis_direction, tolerance)?;
422        let count = ends.outer.len() + ends.holes.iter().map(Vec::len).sum::<usize>();
423        return Ok(BlendContact {
424            flags: vec![false; count],
425            start: start.clone(),
426            end: end.clone(),
427            projection: 0.0,
428        });
429    }
430    let contact = check_axis_side(&ends, axis_origin, axis_direction, tolerance, full)?;
431    let mut holes = contact.rings.holes.into_iter();
432    let end_outer = holes.next().unwrap_or_default();
433    let start_holes: Vec<Vec<Point2>> = holes.by_ref().take(start.holes.len()).collect();
434    Ok(BlendContact {
435        flags: contact.on_axis,
436        start: Rings {
437            outer: contact.rings.outer,
438            holes: start_holes,
439        },
440        end: Rings {
441            outer: end_outer,
442            holes: holes.collect(),
443        },
444        projection: contact.projection,
445    })
446}
447
448/// What [`check_axis_side`] found: the flags of the vertices on the axis, the
449/// rings with those vertices projected onto it, and the farthest a vertex
450/// moved (#367).
451pub(crate) struct AxisContact {
452    pub(crate) on_axis: Vec<bool>,
453    pub(crate) rings: Rings,
454    pub(crate) projection: Scalar,
455}
456
457/// [`AxisContact`] of a tapered revolution: the flags over `[start.outer,
458/// end.outer, start.holes.., end.holes..]` and the two profiles projected.
459pub(crate) struct BlendContact {
460    pub(crate) flags: Vec<bool>,
461    pub(crate) start: Rings,
462    pub(crate) end: Rings,
463    pub(crate) projection: Scalar,
464}
465
466/// The chord budget left once the walls have moved by `projection` (#367):
467/// moving a ring vertex by `d` moves the swept surface by at most `d`, so
468/// the projection adds to the deviation bound. A projection that uses the
469/// whole budget leaves none, which is `BudgetExceeded`.
470pub(crate) fn budget_after_projection(
471    tolerance: Tolerance,
472    projection: Scalar,
473) -> GeomResult<Scalar> {
474    let left = tolerance.linear() - projection;
475    if left > 0.0 {
476        Ok(left)
477    } else {
478        Err(too_many_steps())
479    }
480}
481
482/// Whether `p` is within `tolerance.linear()` of the in-plane axis line
483/// through `a` along `d` (#367): `Some(distance bound)` when it is, `None`
484/// when it is not. The distance is a float evaluation of
485/// `|d x (p - a)| / |d|`, whose rounding error is bounded by eight machine
486/// epsilons of the operand magnitudes; a distance within that error of the
487/// tolerance cannot be decided and is `UnsupportedInput`.
488fn near_axis(a: Point2, d: Vec3, p: Point2, tolerance: Tolerance) -> GeomResult<Option<Scalar>> {
489    let tol = tolerance.linear();
490    if tol <= 0.0 {
491        return Ok(None);
492    }
493    let norm = d.x.hypot(d.y);
494    let cross = d.x * (p.y - a.y) - d.y * (p.x - a.x);
495    let distance = cross.abs() / norm;
496    let error = 8.0
497        * Scalar::EPSILON
498        * (d.x.abs() * (p.y.abs() + a.y.abs()) + d.y.abs() * (p.x.abs() + a.x.abs()))
499        / norm;
500    if distance + error <= tol {
501        Ok(Some(distance + error))
502    } else if distance - error > tol {
503        Ok(None)
504    } else {
505        Err(touching_unsupported())
506    }
507}
508
509/// How far apart `p` and `q`, both on the in-plane axis, are along it:
510/// `Some(distance)` when it is at most `tolerance.linear()` with room for the
511/// rounding of the two evaluations, else `None` (a slide, or undecidable
512/// within rounding, both refused by the caller) (#367).
513pub(crate) fn slide_along_axis(
514    axis_origin: Point3,
515    d: Vec3,
516    p: Point2,
517    q: Point2,
518    tolerance: Tolerance,
519) -> Option<Scalar> {
520    if p == q {
521        return Some(0.0);
522    }
523    let norm = d.x.hypot(d.y);
524    let along = |r: Point2| (d.x * (r.x - axis_origin.x) + d.y * (r.y - axis_origin.y)) / norm;
525    let error = |r: Point2| {
526        8.0 * Scalar::EPSILON
527            * (d.x.abs() * (r.x.abs() + axis_origin.x.abs())
528                + d.y.abs() * (r.y.abs() + axis_origin.y.abs()))
529            / norm
530    };
531    let shift = (along(p) - along(q)).abs() + error(p) + error(q);
532    (shift <= tolerance.linear()).then_some(shift)
533}
534
535/// `p` projected onto the in-plane axis line through `a` along `d`.
536fn project_onto_axis(a: Point2, d: Vec3, p: Point2) -> Point2 {
537    let t = (d.x * (p.x - a.x) + d.y * (p.y - a.y)) / (d.x * d.x + d.y * d.y);
538    Point2::new(a.x + d.x * t, a.y + d.y * t)
539}
540
541/// Refuse rings that lie on both sides of the axis, and report which touch
542/// it (#351, #363).
543///
544/// Decided when the axis lies in the profile's plane, where "side" is
545/// meaningful: each vertex is classified by the exact sign of its cross
546/// product with the axis direction, with no tolerance band. Vertices
547/// strictly on both sides are a crossing (the swept solid passes through
548/// itself) and `InvalidInput`. A vertex on the axis that ends an edge lying
549/// on it is not an error: the result holds `true` at its index in the flat
550/// ring order (outer ring, then each hole) and [`revolve`] meshes it once. A
551/// vertex on the axis with both ring neighbours off it pinches the solid to
552/// a point and, in a `full` turn, is `UnsupportedInput`; a partial turn's
553/// caps close it into a sector point. An edge cannot meet the axis between
554/// two vertices without one of those cases. A vertex on the projected axis
555/// is `UnsupportedInput` too when the axis origin or direction has any
556/// nonzero z: the vertex is then off the real axis by the noise, and welding
557/// it would collapse a real orbit. For the exact axis a vertex within
558/// `tolerance.linear()` of the line counts as on it and is projected onto it
559/// (#367, see the module notes); the result carries the moved rings.
560///
561/// "In the plane" follows the exact path: the axis origin and direction
562/// leave it only beyond `tolerance.linear()` in z, so placement noise does
563/// not switch the check off. An axis parallel to the plane but offset from
564/// it is checked for crossing only: no ring point reaches the axis, since
565/// its radius stays at least the offset, so nothing is reported. An axis
566/// leaning out of the plane is judged by [`check_leaning_axis`] (#362).
567pub(crate) fn check_axis_side(
568    rings: &Rings,
569    axis_origin: Point3,
570    axis_direction: Vec3,
571    tolerance: Tolerance,
572    full: bool,
573) -> GeomResult<AxisContact> {
574    let count = rings.outer.len() + rings.holes.iter().map(Vec::len).sum::<usize>();
575    if axis_direction.z.abs() > tolerance.linear() * axis_direction.length() {
576        check_leaning_axis(rings, rings, axis_origin, axis_direction, tolerance)?;
577        return Ok(AxisContact {
578            on_axis: vec![false; count],
579            rings: rings.clone(),
580            projection: 0.0,
581        });
582    }
583    let offset = axis_origin.z.abs() > tolerance.linear();
584    let exact = axis_origin.z == 0.0 && axis_direction.z == 0.0;
585    let a = Point2::new(axis_origin.x, axis_origin.y);
586    let (mut positive, mut negative, mut noisy_touch) = (false, false, false);
587    let mut on_axis = Vec::with_capacity(count);
588    let mut snapped = Vec::with_capacity(count);
589    let mut moved = Vec::with_capacity(count);
590    let mut projection: Scalar = 0.0;
591    let points = rings.outer.iter().chain(rings.holes.iter().flatten());
592    for (side, p) in ring_sides(rings, a, axis_direction)?
593        .into_iter()
594        .zip(points)
595    {
596        let (side, p, near) = if side != Sign::Zero && exact && !offset {
597            match near_axis(a, axis_direction, *p, tolerance)? {
598                Some(distance) => {
599                    projection = projection.max(distance);
600                    (Sign::Zero, project_onto_axis(a, axis_direction, *p), true)
601                }
602                None => (side, *p, false),
603            }
604        } else {
605            (side, *p, false)
606        };
607        snapped.push(p);
608        moved.push(near);
609        match side {
610            Sign::Positive => positive = true,
611            Sign::Negative => negative = true,
612            _ => {}
613        }
614        noisy_touch |= side == Sign::Zero && !offset && !exact;
615        on_axis.push(side == Sign::Zero && !offset);
616    }
617    if positive && negative {
618        return Err(GeomError::InvalidInput(
619            "revolved profile crosses the axis: its rings lie on both sides".to_owned(),
620        ));
621    }
622    if noisy_touch {
623        return Err(touching_unsupported());
624    }
625    // In a full turn a vertex on the axis whose ring neighbours are both off
626    // it is a pinch: two nappes of the swept surface meet at one point, so
627    // the boundary is not a two-manifold there (the horn torus of #248 is
628    // refused alike). A partial turn's caps close it into a sector point.
629    let rings = {
630        let mut flat = snapped.into_iter();
631        let mut take =
632            |ring: &Vec<Point2>| -> Vec<Point2> { flat.by_ref().take(ring.len()).collect() };
633        Rings {
634            outer: take(&rings.outer),
635            holes: rings.holes.iter().map(&mut take).collect(),
636        }
637    };
638    // A ring that the projection flattens onto the axis sweeps nothing: it
639    // would vanish from the solid (a hole) or leave it empty (an outer ring).
640    let mut first = 0;
641    for ring in core::iter::once(&rings.outer).chain(&rings.holes) {
642        let end = first + ring.len();
643        if moved[first..end].contains(&true) {
644            let area: Scalar = (0..ring.len())
645                .map(|k| {
646                    let (p, q) = (ring[k], ring[(k + 1) % ring.len()]);
647                    p.x * q.y - q.x * p.y
648                })
649                .sum();
650            if on_axis[first..end].iter().all(|&on| on) || area == 0.0 {
651                return Err(flattened_unsupported());
652            }
653        }
654        first = end;
655    }
656    let contact = |on_axis| AxisContact {
657        on_axis,
658        rings: rings.clone(),
659        projection,
660    };
661    if !full {
662        return Ok(contact(on_axis));
663    }
664    let mut start = 0;
665    for len in core::iter::once(rings.outer.len()).chain(rings.holes.iter().map(Vec::len)) {
666        let ring = &on_axis[start..start + len];
667        for k in 0..len {
668            if ring[k] && !ring[(k + len - 1) % len] && !ring[(k + 1) % len] {
669                return Err(GeomError::UnsupportedInput {
670                    backend: crate::BACKEND_ID,
671                    operation: Operation::Sweep,
672                    input: "revolution of a profile that touches the axis at an isolated \
673                            point: the solid pinches there",
674                });
675            }
676        }
677        start += len;
678    }
679    Ok(contact(on_axis))
680}
681
682/// The refusal for a ring that lies within the tolerance of the axis (#367).
683fn flattened_unsupported() -> GeomError {
684    GeomError::UnsupportedInput {
685        backend: crate::BACKEND_ID,
686        operation: Operation::Sweep,
687        input: "revolution of a ring within the tolerance of the axis: it sweeps no area",
688    }
689}
690
691/// The refusal for a family that cannot yet mesh a profile touching the
692/// axis (#363).
693pub(crate) fn touching_unsupported() -> GeomError {
694    GeomError::UnsupportedInput {
695        backend: crate::BACKEND_ID,
696        operation: Operation::Sweep,
697        input: "revolution of a profile touching the axis",
698    }
699}
700
701/// Revolve a profile about an axis into a closed solid.
702///
703/// A full turn wraps its rings by index, so the seam shares vertices by
704/// construction rather than by two samplings agreeing numerically. That is
705/// the same structure `tessellate_primitive` uses for a cylinder, and it
706/// avoids the trim-based path recorded as broken in issue #2.
707///
708/// Every point of the surface the rings sweep lies within
709/// `tolerance.linear()` of the mesh (see the module notes for the proof);
710/// the rings' own distance from a curved profile is the caller's to add.
711/// A budget that needs more than 4096 steps round the axis is refused with
712/// `BudgetExceeded`. A profile with vertices on both sides of an in-plane
713/// axis is `InvalidInput`. A vertex on the axis is meshed once, the point
714/// being fixed under the rotation (see the module notes).
715pub fn revolve(
716    rings: &Rings,
717    axis_origin: Point3,
718    axis_direction: Vec3,
719    angle: Scalar,
720    tolerance: Tolerance,
721) -> GeomResult<TriMesh> {
722    if !angle.is_finite() || angle == 0.0 {
723        return Err(GeomError::InvalidInput(format!(
724            "revolution angle must be finite and non-zero, got {angle}"
725        )));
726    }
727    let len = axis_direction.length();
728    if !len.is_finite() || len <= 0.0 {
729        return Err(GeomError::InvalidInput(
730            "revolution axis must be a finite non-zero direction".to_owned(),
731        ));
732    }
733    let full = (angle.abs() - core::f64::consts::TAU).abs() <= 1e-9;
734    let contact = check_axis_side(rings, axis_origin, axis_direction, tolerance, full)?;
735    let (on_axis, rings, projection) = (contact.on_axis, &contact.rings, contact.projection);
736    let dir = axis_direction / len;
737    // Widest distance from the axis governs the angular step: a point at
738    // radius r traces a circle of that radius.
739    let mut max_r: Scalar = 0.0;
740    for p in rings.outer.iter().chain(rings.holes.iter().flatten()) {
741        let v = Point3::new(p.x, p.y, 0.0) - axis_origin;
742        max_r = max_r.max((v - dir * dir.dot(v)).length());
743    }
744    let budget = budget_after_projection(tolerance, projection)?;
745    let n = bounded_steps(rings, axis_origin, dir, angle, max_r, budget)?;
746    // A full turn emits n stations and wraps onto the first; a partial turn
747    // emits n + 1 so both ends exist to be capped.
748    let count = if full { n } else { n + 1 };
749    let stations: Vec<crate::loft::Station> = (0..count)
750        .rev()
751        .map(|s| {
752            let t = angle * (s as Scalar) / (n as Scalar);
753            crate::loft::place(rings, |p| {
754                rotate(Point3::new(p.x, p.y, 0.0), axis_origin, dir, t)
755            })
756        })
757        .collect();
758    let mut mesh = crate::loft::loft_welded(rings, &stations, full, &on_axis)?;
759    // The station order above (end of the sweep first) winds the walls
760    // outward for a positive angle about `dir`. A negative angle sweeps the
761    // other way round, so the same order winds them inward instead (#221):
762    // `angle` and `dir` only matter through their product (the axis-angle
763    // rotation vector), and `(dir, angle)` and `(-dir, -angle)` describe the
764    // identical rotation, so the fix cannot key off `angle`'s stored sign
765    // alone without also depending on `dir`'s. Settling it from the built
766    // mesh's own orientation sidesteps that: whichever combination produced
767    // it, a closed solid's sign is unambiguous.
768    if matches!(crate::extrude::outward_orientation(&mesh), Some(false)) {
769        for triangle in mesh.indices.as_chunks_mut::<3>().0 {
770            triangle.swap(1, 2);
771        }
772    }
773    Ok(mesh)
774}