axiolid_construct/pipe/band.rs
1//! The band of a bend's tube next to a corner mitred beside an arc (#248).
2//!
3//! A corner between two straight segments shares one mitre ring that lies
4//! on both exact cylinders (#245). Beside an arc it cannot: the mitre plane
5//! cuts the bend's torus in a different curve than the tube on the other
6//! side, so the exact solid, each tube cut by the plane, has a planar face
7//! between the two section curves where one tube ends and the other does
8//! not begin. The pipe therefore meshes the end of the bend as a band of
9//! stations whose vertices walk along the torus itself:
10//!
11//! - The band starts at a square section `back` short of the corner, and
12//! each profile vertex of it turns about the bend's axis, along its own
13//! circle of the torus, toward the mitre plane. The torus continued past
14//! the arc's end is the exact tube there (the format's mitre at half
15//! angle extends the tube to the plane), so every band vertex lies on the
16//! exact surface.
17//! - `back` is the least turn at which the section short of the corner
18//! lies wholly behind the plane, and `ahead` the least past it at which
19//! the section lies wholly beyond, each by at least `MARGIN r`: each
20//! vertex's circle then crosses the plane exactly once between them (its
21//! height above the plane is `A0 + A1 cos psi + A2 sin psi`, with
22//! opposite signs at the band's two ends less than a half turn apart),
23//! and [`reach`] finds both from the closed form of the height's extremes
24//! over the section circle. A corner whose plane does not clear the tube
25//! within a quarter turn either way is refused.
26//! - Where the other side's ring is not this band's own cut, the band stops
27//! a little short of the plane (`offset`) and its last span runs, nearly
28//! in the plane, to the shared ring: that strip is the mesh of the planar
29//! face between the two section curves.
30//!
31//! No proof by construction covers the band and the planar face; the
32//! compiler certifies them against the mesh by branch and bound
33//! (`axiolid_mesh_compile`'s deviation report), which is why the band's
34//! geometry is public.
35
36use core::f64::consts::FRAC_PI_2;
37
38use axiolid_contracts::{GeomError, GeomResult};
39use axiolid_core::{Point3, Scalar, Vec3};
40
41use crate::loft::Station;
42use crate::revolve::rotate;
43
44/// The tube of a bend next to a corner mitred beside it (#248): the torus
45/// about the circle through `corner` round `centre` and `axis`, from the
46/// square section `back` short of the corner to the mitre plane.
47#[derive(Debug, Clone, Copy, PartialEq)]
48#[non_exhaustive]
49pub struct MitreBand {
50 /// Centre of the bend's circle.
51 pub centre: Point3,
52 /// Unit axis of the bend, oriented so that a positive turn about it
53 /// carries the arc toward the corner and on past it.
54 pub axis: Vec3,
55 /// The arc's end at the corner. The mitre plane passes through it.
56 pub corner: Point3,
57 /// Unit normal of the mitre plane, pointing away from the band: the
58 /// band's part of the exact solid has non-positive height along it.
59 pub normal: Vec3,
60 /// How far the band reaches back from the corner, in radians: the
61 /// section turned by `-back` about `axis` from the corner's lies wholly
62 /// behind the mitre plane.
63 pub back: Scalar,
64 /// How far past the corner the tube is continued, in radians: the
65 /// section turned by `ahead` lies wholly beyond the plane, and the
66 /// plane cuts every circle of the torus strictly between the two.
67 pub ahead: Scalar,
68}
69
70/// How far, as a fraction of the disk radius, the band's two end sections
71/// must clear the mitre plane.
72pub(crate) const MARGIN: Scalar = 0.01;
73
74/// Angles tried for [`reach`], up to a quarter turn.
75const REACH_STEPS: usize = 1024;
76
77impl MitreBand {
78 /// The radius of the bend's circle.
79 #[must_use]
80 pub fn bend_radius(&self) -> Scalar {
81 (self.corner - self.centre).length()
82 }
83
84 /// The signed height of `p` above the mitre plane, along `normal`.
85 #[must_use]
86 pub fn height(&self, p: Point3) -> Scalar {
87 (p - self.corner).dot(self.normal)
88 }
89
90 /// `p` turned by `angle` about the bend's axis.
91 #[must_use]
92 pub fn turned(&self, p: Point3, angle: Scalar) -> Point3 {
93 rotate(p, self.centre, self.axis, angle)
94 }
95
96 /// The turn in `[lo, hi]` that carries `p` along its circle to height
97 /// `level`, found by bisection; `None` unless the height is below
98 /// `level` at `lo` and above it at `hi`.
99 pub(crate) fn turn_to(
100 &self,
101 p: Point3,
102 level: Scalar,
103 lo: Scalar,
104 hi: Scalar,
105 ) -> Option<Scalar> {
106 let f = |t: Scalar| self.height(self.turned(p, t)) - level;
107 let (mut a, mut b) = (lo, hi);
108 if !(f(a) < 0.0 && f(b) > 0.0) {
109 return None;
110 }
111 for _ in 0..200 {
112 let m = 0.5 * (a + b);
113 if m <= a || m >= b {
114 break;
115 }
116 if f(m) < 0.0 {
117 a = m;
118 } else {
119 b = m;
120 }
121 }
122 Some(0.5 * (a + b))
123 }
124
125 /// The band's stations from `from` (excluded), the square section at
126 /// its far end, toward the corner, ending with `target`, the ring the
127 /// pipe shares across the mitre.
128 ///
129 /// Each vertex of `from` turns along its circle by a fraction of the
130 /// turn that takes it to the plane (`offset` = `None`) or to `offset`
131 /// short of it (at most half its own height below the plane); the turns
132 /// are split into equal steps whose sagitta at `reach_radius` is within
133 /// `tol`. With `offset` the last band station stands that close to the
134 /// plane and `target` follows it; without, `target` takes the place of
135 /// the last one, which is the cut itself.
136 pub(crate) fn stations_toward(
137 &self,
138 from: &Station,
139 target: &Station,
140 offset: Option<Scalar>,
141 reach_radius: Scalar,
142 tol: Scalar,
143 ) -> GeomResult<Vec<Station>> {
144 let mut turns: Vec<Vec<Scalar>> = Vec::with_capacity(from.loops.len());
145 let mut widest: Scalar = 0.0;
146 for ring in &from.loops {
147 let mut row = Vec::with_capacity(ring.len());
148 for &p in ring {
149 let below = -self.height(p);
150 let level = offset.map_or(0.0, |cap| -cap.min(0.5 * below));
151 let t = self
152 .turn_to(p, level, 0.0, self.back + self.ahead)
153 .ok_or_else(|| {
154 GeomError::Degenerate(
155 "swept disk mitre band: a section vertex does not cross the mitre \
156 plane within the band"
157 .to_owned(),
158 )
159 })?;
160 widest = widest.max(t);
161 row.push(t);
162 }
163 turns.push(row);
164 }
165 let steps = crate::revolve::steps(reach_radius, widest, tol).map_err(|_| {
166 GeomError::BudgetExceeded {
167 resource: "swept disk mitre band steps",
168 }
169 })?;
170 let last = if offset.is_some() { steps } else { steps - 1 };
171 let mut out = Vec::with_capacity(last + 1);
172 for j in 1..=last {
173 let s = j as Scalar / steps as Scalar;
174 out.push(Station {
175 loops: from
176 .loops
177 .iter()
178 .zip(&turns)
179 .map(|(ring, row)| {
180 ring.iter()
181 .zip(row)
182 .map(|(&p, &t)| self.turned(p, s * t))
183 .collect()
184 })
185 .collect(),
186 });
187 }
188 out.push(target.clone());
189 Ok(out)
190 }
191
192 /// The section of the band's tube by the mitre plane, at the vertices
193 /// of `square`, a section standing square to the arc at the corner:
194 /// each vertex turned along its circle onto the plane.
195 pub(crate) fn cut(&self, square: &Station) -> GeomResult<Station> {
196 let loops = square
197 .loops
198 .iter()
199 .map(|ring| {
200 ring.iter()
201 .map(|&p| {
202 self.turn_to(p, 0.0, -self.back, self.ahead)
203 .map(|t| self.turned(p, t))
204 .ok_or_else(|| {
205 GeomError::Degenerate(
206 "swept disk mitre band: a section vertex does not cross \
207 the mitre plane within the band"
208 .to_owned(),
209 )
210 })
211 })
212 .collect::<GeomResult<Vec<_>>>()
213 })
214 .collect::<GeomResult<Vec<_>>>()?;
215 Ok(Station { loops })
216 }
217}
218
219/// The least turns `(back, ahead)` (multiples of a quarter turn over
220/// [`REACH_STEPS`]) at which the section `back` short of the corner lies
221/// wholly at least `MARGIN radius` behind the plane through `corner` with
222/// unit normal `normal`, and the section `ahead` past it wholly that far
223/// beyond; `None` when either needs more than a quarter turn.
224///
225/// With `e` the unit vector from `centre` to `corner` (bend radius `R`),
226/// `t = axis x e` and a point `corner + r (cos b e + sin b axis)` of the
227/// corner's section turned by `psi`, the height above the plane is
228/// `R ((cos psi - 1) e.n + sin psi t.n) + r (cos b (cos psi e.n + sin psi
229/// t.n) + sin b axis.n)`; its extremes over `b` are the first term plus or
230/// minus `r` times the length of the second's coefficients. Any smaller
231/// radius (a bore, the centre) lies between them.
232pub(crate) fn reach(
233 centre: Point3,
234 axis: Vec3,
235 corner: Point3,
236 normal: Vec3,
237 radius: Scalar,
238) -> Option<(Scalar, Scalar)> {
239 let big = (corner - centre).length();
240 let e = (corner - centre) / big;
241 let t = axis.cross(e);
242 let (en, tn, an) = (e.dot(normal), t.dot(normal), axis.dot(normal));
243 let margin = MARGIN * radius;
244 let turns = || (1..=REACH_STEPS).map(|k| FRAC_PI_2 * k as Scalar / REACH_STEPS as Scalar);
245 let back = turns().find(|&psi| {
246 let (s, c) = psi.sin_cos();
247 big * ((c - 1.0) * en - s * tn) + radius * ((c * en - s * tn).powi(2) + an * an).sqrt()
248 <= -margin
249 })?;
250 let ahead = turns().find(|&psi| {
251 let (s, c) = psi.sin_cos();
252 big * ((c - 1.0) * en + s * tn) - radius * ((c * en + s * tn).powi(2) + an * an).sqrt()
253 >= margin
254 })?;
255 Some((back, ahead))
256}
257
258#[cfg(test)]
259mod tests {
260 use super::*;
261
262 fn band(turn: Scalar, tilt: Vec3, big: Scalar, r: Scalar) -> MitreBand {
263 // A bend in the xy plane about +z ending at (big, 0, 0) heading +y;
264 // the mitre normal bisects +y and the outgoing direction.
265 let centre = Point3::ZERO;
266 let axis = Vec3::Z;
267 let corner = Point3::new(big, 0.0, 0.0);
268 let u = Vec3::Y;
269 let v = (u * turn.cos() + tilt.normalize() * turn.sin()).normalize();
270 let normal = (u + v).normalize();
271 let (back, ahead) = reach(centre, axis, corner, normal, r).expect("a band");
272 MitreBand {
273 centre,
274 axis,
275 corner,
276 normal,
277 back,
278 ahead,
279 }
280 }
281
282 #[test]
283 fn every_circle_of_the_band_crosses_the_plane_once_inside_it() {
284 let r = 0.05;
285 for (turn, tilt) in [
286 (0.3, Vec3::X),
287 (1.2, -Vec3::X),
288 (FRAC_PI_2, Vec3::X),
289 (FRAC_PI_2, -Vec3::X),
290 (1.0, Vec3::Z),
291 (0.8, Vec3::new(1.0, 0.0, 1.0)),
292 ] {
293 let b = band(turn, tilt, 0.2, r);
294 let e = Vec3::X;
295 for k in 0..360 {
296 let beta = core::f64::consts::TAU * k as Scalar / 360.0;
297 for rho in [r, 0.5 * r, 0.0] {
298 let p = b.corner + (e * beta.cos() + Vec3::Z * beta.sin()) * rho;
299 let lo = b.height(b.turned(p, -b.back));
300 let hi = b.height(b.turned(p, b.ahead));
301 assert!(lo <= -MARGIN * r * (1.0 - 1e-9), "{turn} {tilt:?}: {lo}");
302 assert!(hi >= MARGIN * r * (1.0 - 1e-9), "{turn} {tilt:?}: {hi}");
303 // Dense: exactly one sign change.
304 let n = 400;
305 let changes = (0..n)
306 .filter(|&j| {
307 let at = |j: usize| {
308 b.height(b.turned(
309 p,
310 -b.back + (b.back + b.ahead) * j as Scalar / n as Scalar,
311 ))
312 };
313 (at(j) < 0.0) != (at(j + 1) < 0.0)
314 })
315 .count();
316 assert_eq!(changes, 1, "{turn} {tilt:?} {beta} {rho}");
317 }
318 }
319 }
320 }
321
322 #[test]
323 fn a_plane_that_wraps_round_the_bend_has_no_band() {
324 // A bend of radius 1.1 r turning a corner of nearly half a turn.
325 let r = 0.05;
326 let corner = Point3::new(1.1 * r, 0.0, 0.0);
327 let u = Vec3::Y;
328 let v = (u * 3.0f64.cos() + Vec3::X * 3.0f64.sin()).normalize();
329 let normal = (u + v).normalize();
330 assert!(reach(Point3::ZERO, Vec3::Z, corner, normal, r).is_none());
331 }
332}