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}