Skip to main content

axiolid_construct/
pipe.rs

1//! Swept disks along chains of straight segments and circular arcs (#232,
2//! #245, #248).
3//!
4//! A pipe run, a rebar or a cable tray edge is a disk swept along legs
5//! joined by bends or corners: authoring formats write it as a disk swept
6//! along a composite of lines and arcs, or along a polyline whose corners
7//! are mitred or rounded to a fillet radius.
8//! [`swept_disk_along_pieces`] meshes all of them so that every point of
9//! the exact tube lies within the chord budget of the triangles.
10//!
11//! # The bound
12//!
13//! Each piece is bounded on its own, with the split [`crate::sweep`] uses
14//! for one arc (#231): half the budget `c` chords the disk, so a ring of
15//! `n` points inscribed in the circle of radius `r` lies within
16//! `r (1 - cos(pi/n)) <= c/2` of it, and every point of the exact tube is
17//! within `c/2` of the tube the ring polygon traces when it is carried
18//! rigidly along the exact centreline by the piece's own frames (each
19//! exact section circle and its polygon lie in the same plane). The
20//! other half bounds that polygon tube against the triangles:
21//!
22//! - Along a segment the frames do not turn, so the polygon tube is a
23//!   prism whose walls are the mesh's own planar rectangles: zero.
24//! - Along an arc of radius `R` the frames are rotation-minimising, which
25//!   along a circle means they turn with it: consecutive stations are
26//!   rotations of one ring about the arc's axis, and the ring's plane
27//!   contains that axis. The polygon tube between two stations is then the
28//!   revolution [`crate::revolve`] bounds, and its triangles are within
29//!   `(R + r)(1 - cos(phi/2))` of it for a step `phi`: the sagitta of the
30//!   outer side of the bend, not of the centreline. Its wall quads are
31//!   planar trapezoids, so their twist adds nothing; the span bound
32//!   [`crate::sweep`] computes, directrix sagitta plus the section's reach
33//!   plus [`crate::loft`]'s quad bound, is exactly that sum here. The step
34//!   halves until every span fits `c/2`; more than 4096 steps on a bend
35//!   is refused with `BudgetExceeded`.
36//!
37//! # Joints
38//!
39//! Consecutive pieces share one station, so the walls meet watertight and
40//! wound one way: the incoming piece's end section. The outgoing piece's
41//! own start frame is that frame turned by the least rotation that takes
42//! the incoming tangent to the outgoing one, moved to its start point, and
43//! its stations follow from it. Where the pieces are tangent continuous
44//! and meet, the two frames coincide. Where they turn by `theta` or leave
45//! a gap `g`, every vertex of the shared station lies at most
46//! `delta <= g + 2 r sin(theta/2)` from the station the outgoing piece's
47//! bound was proved for; moving a triangle's vertices by at most `delta`
48//! moves each of its points by at most `delta` (same barycentric weights),
49//! so the first span of the outgoing piece adds the measured `delta` to its
50//! bound and is refined until the sum fits.
51//!
52//! A joint turning by at most
53//!
54//! `theta_max = 2 asin(c / (8 r))` (pi once `c >= 8 r`)
55//!
56//! is tangent continuous at the requested accuracy: its shared section
57//! leans off the outgoing piece's own by at most a quarter of the budget,
58//! which that piece's first span pays as above. A gap above `c/4` is
59//! refused.
60//!
61//! # Mitres
62//!
63//! A sharper joint between two straight segments is a corner, and a disk
64//! swept round a corner is mitred at half angle (#245): both tubes are cut
65//! by the plane through the corner whose normal `n = (u + v) / |u + v|`
66//! bisects the incoming and outgoing tangents `u` and `v`. Reflection in
67//! that plane maps `u` to `-v`, so it swaps the two axis lines and with
68//! them the two cylinders of radius `r` (and the two bores); it fixes the
69//! plane, so both cylinders cut it in the same ellipse. The outgoing frame
70//! is the incoming one turned by the least rotation about `u x v`, which
71//! agrees with that reflection on the plane square to `u`. So profile point
72//! `k` of the incoming end section and of the outgoing start section, each
73//! projected along its own axis onto the mitre plane, land on one point:
74//! the two pieces share that mitre ring, and every vertex of it lies on
75//! both exact cylinders. Each wall quad of a segment joins two generator
76//! lines of the prism over the ring polygon, so it is a planar trapezoid,
77//! exactly a face of that prism cut by the end planes: the walls add
78//! nothing, and the span bound measures them as zero.
79//!
80//! The section pays instead. A point `w + s u` of the exact cylinder (`w`
81//! square to `u`, `|w| = r`; the mitre plane cuts its generator at
82//! `s_cut(w) = -w.n / u.n`, at most `r tan(theta/2)`) lies within
83//! `(1 - l) r <= e = r (1 - cos(pi/m))` of the prism point `w' + s u` at
84//! its azimuth, `w' = l w` on the ring polygon of `m` points. When that
85//! point lies past the plane, the plane crosses the prism's generator at
86//! `l s_cut(w)`, which moves the point by at most
87//! `(1 - l) s_cut(w) <= (1 - l) r tan(theta/2)` along `u`, square to the
88//! first move: the distance is at most `e / cos(theta/2)`, attained where
89//! the ellipse's major axis ends. The rings are therefore chorded to
90//! `(c/2) cos(theta_m/2)` for the sharpest mitre `theta_m` (only finer
91//! elsewhere), and a mitred pipe keeps every point of the exact tube
92//! within `c` of its triangles, as a smooth one does. A hollow disk's bore
93//! is mitred by the same plane and bounded the same way.
94//!
95//! Along a segment of length `L` the tube runs, on the generator at `w`,
96//! from its start plane to its end plane; both cuts are linear in `w`, so
97//! the shortest generator is `L - r |g_perp|` with
98//! `g = n1 / u.n1 - n0 / u.n0` (`n0 = u` at a square end), and it must be
99//! positive (one mitre: `L > r tan(theta/2)`). Refused by name: a segment
100//! whose mitres meet inside the tube, which would cut through itself, and a
101//! reversal (`theta = pi`, where the mitre plane holds both legs).
102//!
103//! # Mitres beside an arc
104//!
105//! A corner beside an arc is mitred at half angle too (#248), by the same
106//! bisector plane, but the plane cuts the bend's torus in a different curve
107//! than the tube on its other side: no ring lies on both exact surfaces,
108//! and the exact solid (each tube continued to the plane and cut by it) has
109//! a planar face between the two section curves. The end of the bend is
110//! meshed as a [`MitreBand`] (see its module): from a square section
111//! `back` short of the corner, each profile vertex walks along its own
112//! circle of the torus to the plane, so the band's vertices lie on the
113//! exact torus, and where the shared ring is not the band's own cut, the
114//! band stops just short of the plane and a strip, nearly in the plane,
115//! joins it to that ring: the mesh of the planar face. The shared ring is
116//! the outgoing piece's: a segment's mitre ring as above (so a segment
117//! beside an arc keeps the proof of the previous section, its ring on its
118//! own cylinder), or, between two arcs, the outgoing band's cut. Nothing
119//! proves the band and the strip within the budget by construction; the
120//! compiler certifies them against the mesh and reports that bound beside
121//! the proven one ([`pipe_plan`] gives it the bands). Refused by name: a
122//! corner whose plane does not clear the bend's tube within a quarter turn
123//! of it, and bands that need more of an arc than it turns.
124//!
125//! # Fillets
126//!
127//! A fillet radius `f` rounds every corner between two straight segments
128//! (the swept disk's `fillet_radius`): a corner turning by
129//! `theta` is replaced by the arc of radius `f` tangent to both segments,
130//! which cuts `f tan(theta/2)` off each. Segments shortened to nothing are
131//! dropped (two fillets then meet tangentially). Refused by name: a fillet
132//! that needs more of a segment than it has, a corner beside an arc (only
133//! corners between straight segments are rounded), a disk radius above the
134//! fillet radius, or any bend radius, whose tube folds through the bend's
135//! axis on its inside, and a reversal.
136//!
137//! A disk radius equal to the fillet (or bend) radius stays refused by name
138//! (#248), although the format rule permits it (fillet radius at least the
139//! disk radius): the bend is then a horn torus whose inner wall pinches to
140//! one point on the bend's axis, where every section touches. That boundary
141//! is not a two-manifold; a closed mesh within the budget would have to
142//! either meet itself there (a non-manifold vertex, degenerate triangles
143//! along the pinch) or stand off the pinch and misstate it.
144//!
145//! # Closed directrices
146//!
147//! [`swept_disk_along_loop`] sweeps a loop: the last piece ends where the
148//! first starts, and that joint is mitred, filleted or tangent like any
149//! other (#248). The walls close on the first station, so there are no
150//! caps. A frame carried round a loop that leaves its plane does not come
151//! back to itself: least rotations at the joints and rotation-minimising
152//! frames along the bends return it turned about the first tangent by the
153//! loop's holonomy `tau`, in `(-pi, pi]` ([`PipePlan::twist`], the
154//! measure reported). The pipe turns its frames back by `tau`, distributed
155//! uniformly over the loop's clear length: every segment's length outside
156//! the mitre zones at its ends (`r tan(theta/2)` from each mitred corner,
157//! where the plane reaches along it), and every bend's outside its mitre
158//! bands. In the zones and bands the frame is held, so a mitre still shares
159//! one ring on both cylinders and its walls stay planar. The twist is part
160//! of the proven bound: [`crate::sweep`]'s span bound measures the turn
161//! between two frames, twist included, and a twisted segment gets stations
162//! until each span's twist fits (more than 4096 is refused with
163//! `BudgetExceeded`). The station closing the loop is the first one; the
164//! last span pays its measured distance from the station it was proved for.
165//! Refused by name: a loop whose frame comes back turned when no part of it
166//! is clear to take the twist.
167//!
168//! # Profiles with a fixed reference
169//!
170//! The same per-piece proof bounds an arbitrary profile swept with a fixed
171//! reference direction along a tangent-continuous chain (#122; see the
172//! `fixed_reference` module): where the reference is normal to the plane of
173//! every arc or lies in it, the fixed-reference frame is the
174//! rotation-minimising one, each arc is an exact revolution of the placed
175//! profile and each segment a prism, and the profile's rings stand in for
176//! the disk's. [`fixed_reference_plan`] refuses corners, a reference
177//! parallel to the tangent and a profile that folds through a bend's axis
178//! by name, and reports an arc oblique to the reference as twisting, which
179//! nothing here bounds.
180
181use axiolid_contracts::{GeomError, GeomResult};
182use axiolid_core::{Point3, Scalar, Vec3};
183use axiolid_mesh::TriMesh;
184
185use crate::loft::{self, Frame, Station};
186use crate::profile::Rings;
187use crate::revolve::rotate;
188use crate::sweep;
189
190mod band;
191mod fixed_reference;
192pub use band::MitreBand;
193pub use fixed_reference::{
194    fixed_reference_along_pieces, fixed_reference_plan, reference_clearance, ring_reach,
195    FixedReferenceChain, FixedReferencePlan,
196};
197
198/// One smooth piece of a swept disk's directrix.
199#[derive(Debug, Clone, Copy, PartialEq)]
200#[non_exhaustive]
201pub enum PathPiece {
202    /// A straight segment from `start` to `end`.
203    Segment {
204        /// First point, in the direction of travel.
205        start: Point3,
206        /// Last point.
207        end: Point3,
208    },
209    /// A circular arc: `start` turned by `angle` radians, right-handed,
210    /// about the line through `centre` along the unit `axis`. `start -
211    /// centre` must be perpendicular to `axis`; a negative `angle` turns
212    /// the other way.
213    Arc {
214        /// Centre of the arc's circle.
215        centre: Point3,
216        /// Unit normal of the arc's plane.
217        axis: Vec3,
218        /// First point, in the direction of travel.
219        start: Point3,
220        /// Signed turn, at most a full turn either way.
221        angle: Scalar,
222    },
223}
224
225impl PathPiece {
226    /// The first point, in the direction of travel.
227    #[must_use]
228    pub fn start_point(&self) -> Point3 {
229        match *self {
230            Self::Segment { start, .. } | Self::Arc { start, .. } => start,
231        }
232    }
233
234    /// The last point, in the direction of travel.
235    #[must_use]
236    pub fn end_point(&self) -> Point3 {
237        self.point(1.0)
238    }
239
240    /// The point a fraction `s` of the way along, by length.
241    #[must_use]
242    pub fn point(&self, s: Scalar) -> Point3 {
243        match *self {
244            Self::Segment { start, end } => start + (end - start) * s,
245            Self::Arc {
246                centre,
247                axis,
248                start,
249                angle,
250            } => rotate(start, centre, axis, angle * s),
251        }
252    }
253
254    /// The unit tangent, in the direction of travel, at `point` on the
255    /// piece; zero for a degenerate piece.
256    fn tangent_at(&self, point: Point3) -> Vec3 {
257        match *self {
258            Self::Segment { start, end } => (end - start).normalize_or_zero(),
259            Self::Arc {
260                centre,
261                axis,
262                angle,
263                ..
264            } => (axis.cross(point - centre) * angle.signum()).normalize_or_zero(),
265        }
266    }
267
268    /// The unit tangent at the start, in the direction of travel.
269    #[must_use]
270    pub fn start_tangent(&self) -> Vec3 {
271        self.tangent_at(self.start_point())
272    }
273
274    /// The unit tangent at the end, in the direction of travel.
275    #[must_use]
276    pub fn end_tangent(&self) -> Vec3 {
277        self.tangent_at(self.end_point())
278    }
279
280    /// The length along the piece.
281    #[must_use]
282    pub fn length(&self) -> Scalar {
283        match *self {
284            Self::Segment { start, end } => (end - start).length(),
285            Self::Arc {
286                centre,
287                start,
288                angle,
289                ..
290            } => (start - centre).length() * angle.abs(),
291        }
292    }
293
294    /// The same piece walked the other way.
295    #[must_use]
296    pub fn reversed(&self) -> Self {
297        match *self {
298            Self::Segment { start, end } => Self::Segment {
299                start: end,
300                end: start,
301            },
302            Self::Arc {
303                centre,
304                axis,
305                angle,
306                ..
307            } => Self::Arc {
308                centre,
309                axis,
310                start: self.end_point(),
311                angle: -angle,
312            },
313        }
314    }
315
316    /// The part between the fractions `from` and `to` of its length, in
317    /// that order (`to < from` walks it backwards).
318    #[must_use]
319    pub fn between(&self, from: Scalar, to: Scalar) -> Self {
320        match *self {
321            Self::Segment { .. } => Self::Segment {
322                start: self.point(from),
323                end: self.point(to),
324            },
325            Self::Arc {
326                centre,
327                axis,
328                angle,
329                ..
330            } => Self::Arc {
331                centre,
332                axis,
333                start: self.point(from),
334                angle: angle * (to - from),
335            },
336        }
337    }
338
339    /// Refuse a piece with no direction or a malformed arc.
340    fn check(&self, radius: Scalar) -> GeomResult<()> {
341        let finite = |p: Point3| p.is_finite();
342        match *self {
343            Self::Segment { start, end } => {
344                if !(finite(start) && finite(end)) {
345                    return Err(GeomError::InvalidInput(
346                        "swept disk directrix segment must be finite".to_owned(),
347                    ));
348                }
349                if start == end {
350                    return Err(GeomError::Degenerate(
351                        "swept disk directrix segment has no length".to_owned(),
352                    ));
353                }
354            }
355            Self::Arc {
356                centre,
357                axis,
358                start,
359                angle,
360            } => {
361                if !(finite(centre) && finite(axis) && finite(start) && angle.is_finite()) {
362                    return Err(GeomError::InvalidInput(
363                        "swept disk directrix arc must be finite".to_owned(),
364                    ));
365                }
366                let bend = (start - centre).length();
367                if (axis.length() - 1.0).abs() > 1e-9
368                    || bend == 0.0
369                    || (start - centre).dot(axis).abs() > 1e-9 * bend
370                {
371                    return Err(GeomError::InvalidInput(
372                        "swept disk directrix arc needs a unit axis normal to a non-zero \
373                         radius"
374                            .to_owned(),
375                    ));
376                }
377                if angle == 0.0 || angle.abs() > core::f64::consts::TAU * (1.0 + 1e-12) {
378                    return Err(GeomError::InvalidInput(format!(
379                        "swept disk directrix arc must turn by a non-zero angle of at most \
380                         a full turn, got {angle}"
381                    )));
382                }
383                if bend < radius {
384                    return Err(GeomError::InvalidInput(format!(
385                        "swept disk radius {radius} is not below the bend radius {bend}: \
386                         the tube would fold through the bend's axis"
387                    )));
388                }
389                if bend == radius {
390                    return Err(horn_torus("bend", radius));
391                }
392            }
393        }
394        Ok(())
395    }
396
397    /// Equal steps to chord budget `budget`: one for a segment, for an arc
398    /// as many as keep each step's sagitta within the budget.
399    fn steps(&self, budget: Scalar) -> GeomResult<usize> {
400        match *self {
401            Self::Segment { .. } => Ok(1),
402            Self::Arc {
403                centre,
404                start,
405                angle,
406                ..
407            } => crate::revolve::steps((start - centre).length(), angle, budget).map_err(|_| {
408                GeomError::BudgetExceeded {
409                    resource: "swept disk bend steps",
410                }
411            }),
412        }
413    }
414}
415
416/// Refuse a disk of `radius` swept along `piece` alone when it is malformed
417/// or the tube would fold through a bend's axis: the checks
418/// [`swept_disk_along_pieces`] makes of each piece, for the one arc the
419/// conic sweep takes (#122).
420pub fn check_disk_along(piece: &PathPiece, radius: Scalar) -> GeomResult<()> {
421    piece.check(radius)
422}
423
424/// The largest turn a joint may make and still be tangent continuous at
425/// chord budget `chord` for a disk of radius `radius`: `2 asin(c / (8 r))`
426/// (see the module notes).
427#[must_use]
428pub fn joint_tolerance(chord: Scalar, radius: Scalar) -> Scalar {
429    2.0 * (chord / (8.0 * radius)).min(1.0).asin()
430}
431
432/// The angle between two unit vectors, accurate when it is small.
433fn turn(a: Vec3, b: Vec3) -> Scalar {
434    a.cross(b).length().atan2(a.dot(b))
435}
436
437/// One side of a corner mitred beside an arc (#248).
438#[derive(Debug, Clone, Copy, PartialEq)]
439#[non_exhaustive]
440pub enum MitreSide {
441    /// A straight segment: near the corner its tube is the cylinder of the
442    /// disk radius about the line through `corner` along `direction`.
443    Segment {
444        /// The segment's end at the corner.
445        corner: Point3,
446        /// Its unit direction of travel.
447        direction: Vec3,
448    },
449    /// An arc: near the corner its tube is the band.
450    Arc(MitreBand),
451}
452
453/// A corner mitred beside an arc (#248): the mitre plane and the tubes on
454/// its two sides.
455#[derive(Debug, Clone, Copy, PartialEq)]
456#[non_exhaustive]
457pub struct ArcMitre {
458    /// The joint, between piece `joint` of [`PipePlan::pieces`] and the
459    /// next one (the first, for the joint that closes a loop).
460    pub joint: usize,
461    /// Unit normal of the mitre plane: the bisector of the incoming and the
462    /// outgoing tangent.
463    pub normal: Vec3,
464    /// The incoming side, then the outgoing one.
465    pub sides: [MitreSide; 2],
466}
467
468/// How [`swept_disk_along_pieces`] and [`swept_disk_along_loop`] sweep a
469/// directrix (#248): what a caller needs to bound the parts of the mesh no
470/// construction proof covers.
471#[derive(Debug, Clone, PartialEq)]
472#[non_exhaustive]
473pub struct PipePlan {
474    /// The pieces swept, fillets included.
475    pub pieces: Vec<PathPiece>,
476    /// Whether the last piece joins the first.
477    pub closed: bool,
478    /// Every corner mitred beside an arc, in joint order.
479    pub arc_mitres: Vec<ArcMitre>,
480    /// For a loop, the angle in `(-pi, pi]` about the first piece's start
481    /// tangent by which a frame carried once round the loop comes back
482    /// turned (its holonomy); the pipe turns its frames back by it,
483    /// uniformly over the loop's clear length (see the module notes). Zero
484    /// for an open directrix.
485    pub twist: Scalar,
486}
487
488/// Sweep a disk, optionally hollow, along a chain of segments and arcs so
489/// that every point of the exact tube lies within `chord` of the mesh.
490///
491/// `fillet_radius` rounds each corner between two segments; without it,
492/// each corner is mitred at half angle (#245, beside an arc #248). See the
493/// module notes for the bound, the joint rule, the mitres, and what is
494/// refused; a corner beside an arc is not proven but certified by the
495/// caller ([`pipe_plan`]). The pieces run in the direction of travel, each
496/// starting where the last ended.
497pub fn swept_disk_along_pieces(
498    pieces: &[PathPiece],
499    radius: Scalar,
500    inner_radius: Option<Scalar>,
501    fillet_radius: Option<Scalar>,
502    chord: Scalar,
503) -> GeomResult<TriMesh> {
504    Pipe::new(pieces, false, radius, inner_radius, fillet_radius, chord)?.mesh()
505}
506
507/// [`swept_disk_along_pieces`] round a loop (#248): the last piece ends
508/// where the first starts, that joint is mitred, filleted or tangent like
509/// any other, and the walls close on the first station without caps. A
510/// frame that comes back turned is twisted back uniformly over the loop's
511/// clear length (see the module notes and [`PipePlan::twist`]).
512pub fn swept_disk_along_loop(
513    pieces: &[PathPiece],
514    radius: Scalar,
515    inner_radius: Option<Scalar>,
516    fillet_radius: Option<Scalar>,
517    chord: Scalar,
518) -> GeomResult<TriMesh> {
519    Pipe::new(pieces, true, radius, inner_radius, fillet_radius, chord)?.mesh()
520}
521
522/// How [`swept_disk_along_pieces`] (`closed` false) or
523/// [`swept_disk_along_loop`] (`closed` true) sweeps these pieces at this
524/// budget, refused exactly as they refuse it.
525pub fn pipe_plan(
526    pieces: &[PathPiece],
527    closed: bool,
528    radius: Scalar,
529    inner_radius: Option<Scalar>,
530    fillet_radius: Option<Scalar>,
531    chord: Scalar,
532) -> GeomResult<PipePlan> {
533    Ok(Pipe::new(pieces, closed, radius, inner_radius, fillet_radius, chord)?.plan())
534}
535
536/// Rounds of halving a piece's directrix budget before it is refused.
537const MAX_REFINEMENTS: usize = 16;
538
539/// Most stations the twist of one piece may need.
540const MAX_TWIST_STEPS: usize = 4096;
541
542/// A closing twist whose turn at the disk's rim is below this fraction of
543/// the chord budget is rounding: the last span pays it as a measured
544/// shift instead of the loop being twisted.
545const ROUNDING_TWIST: Scalar = 1e-3;
546
547/// How far short of the mitre plane a band stops before its strip, as a
548/// fraction of the chord budget.
549const STRIP_OFFSET: Scalar = 1.0 / 16.0;
550
551/// A directrix checked and planned for sweeping.
552struct Pipe {
553    path: Vec<PathPiece>,
554    closed: bool,
555    radius: Scalar,
556    inner: Option<Scalar>,
557    chord: Scalar,
558    /// The mitre plane's unit normal at each joint, `None` where the joint
559    /// is tangent continuous at this budget. Joint `k` is between pieces
560    /// `k` and `k + 1` (the first, closing a loop).
561    mitres: Vec<Option<Vec3>>,
562    /// The sharpest mitre's turn.
563    sharpest: Scalar,
564    /// Each piece's mitre bands at its start and its end.
565    bands: Vec<[Option<MitreBand>; 2]>,
566    /// The closing twist, and the rate it is turned back at per unit of
567    /// clear length (zero where it is rounding).
568    twist: Scalar,
569    rate: Scalar,
570}
571
572/// A piece's stations, the untwisted frame at its end, and the twist
573/// reached there.
574struct Built {
575    stations: Vec<Station>,
576    end: Frame,
577    turned: Scalar,
578}
579
580/// Where a piece's last station must land.
581#[derive(Clone, Copy)]
582enum Close<'a> {
583    /// Anywhere: an open end, or a joint the next piece pays for.
584    Open,
585    /// On this station, the first of the loop.
586    To(&'a Station),
587    /// On the piece's own first station: a loop of one piece.
588    Own,
589}
590
591/// A piece sampled for a budget: points, the exact unit tangent at each,
592/// and the twist about it.
593struct Sampled {
594    points: Vec<Point3>,
595    tangents: Vec<Vec3>,
596    twists: Vec<Scalar>,
597}
598
599impl Pipe {
600    fn new(
601        pieces: &[PathPiece],
602        closed: bool,
603        radius: Scalar,
604        inner: Option<Scalar>,
605        fillet_radius: Option<Scalar>,
606        chord: Scalar,
607    ) -> GeomResult<Self> {
608        sweep::check_radii(radius, inner)?;
609        if !(chord.is_finite() && chord > 0.0) {
610            return Err(GeomError::InvalidInput(format!(
611                "swept disk chord budget must be positive and finite, got {chord}"
612            )));
613        }
614        if let Some(f) = fillet_radius {
615            if !(f.is_finite() && f > 0.0) {
616                return Err(GeomError::InvalidInput(format!(
617                    "swept disk fillet radius must be positive and finite, got {f}"
618                )));
619            }
620        }
621        if pieces.is_empty() {
622            return Err(GeomError::InvalidInput(
623                "a swept disk directrix needs at least one piece".to_owned(),
624            ));
625        }
626        let tolerance = joint_tolerance(chord, radius);
627        let path = match fillet_radius {
628            Some(f) => fillet(pieces, f, radius, tolerance, closed)?,
629            None => pieces.to_vec(),
630        };
631        for piece in &path {
632            piece.check(radius)?;
633        }
634        let m = path.len();
635        let joints = if closed { m } else { m - 1 };
636        let mut mitres: Vec<Option<Vec3>> = Vec::with_capacity(joints);
637        let mut sharpest: Scalar = 0.0;
638        for k in 0..joints {
639            let (a, b) = (path[k], path[(k + 1) % m]);
640            let gap = (b.start_point() - a.end_point()).length();
641            if gap > 0.25 * chord {
642                return Err(GeomError::InvalidInput(format!(
643                    "swept disk directrix pieces leave a {gap} gap at joint {k}, more than a \
644                     quarter of the {chord} chord budget"
645                )));
646            }
647            let (u, v) = (a.end_tangent(), b.start_tangent());
648            let theta = turn(u, v);
649            if theta <= tolerance {
650                mitres.push(None);
651                continue;
652            }
653            let straight = matches!(
654                (a, b),
655                (PathPiece::Segment { .. }, PathPiece::Segment { .. })
656            );
657            if fillet_radius.is_some() && !straight {
658                return Err(GeomError::InvalidInput(format!(
659                    "swept disk directrix turns a corner of {theta} rad beside an arc at joint \
660                     {k}, above the {tolerance} rad a tangent-continuous joint may turn at this \
661                     chord budget: only corners between two straight segments are filleted"
662                )));
663            }
664            let bisector = u + v;
665            if theta >= core::f64::consts::PI - 1e-9 || bisector.length() <= 1e-9 {
666                return Err(GeomError::InvalidInput(format!(
667                    "swept disk directrix reverses at joint {k}: a mitre at half angle would \
668                     hold both legs"
669                )));
670            }
671            sharpest = sharpest.max(theta);
672            mitres.push(Some(bisector.normalize()));
673        }
674        let mut bands: Vec<[Option<MitreBand>; 2]> = vec![[None, None]; m];
675        for (k, normal) in mitres.iter().enumerate() {
676            let Some(n) = *normal else { continue };
677            for (i, end) in [(k, 1), ((k + 1) % m, 0)] {
678                let PathPiece::Arc {
679                    centre,
680                    axis,
681                    angle,
682                    ..
683                } = path[i]
684                else {
685                    continue;
686                };
687                // Toward the corner: along the travel at the arc's end,
688                // against it at its start.
689                let (toward, corner, normal) = if end == 1 {
690                    (axis * angle.signum(), path[i].end_point(), n)
691                } else {
692                    (-axis * angle.signum(), path[i].start_point(), -n)
693                };
694                let (back, ahead) = band::reach(centre, toward, corner, normal, radius)
695                    .ok_or_else(|| {
696                        GeomError::InvalidInput(format!(
697                            "swept disk directrix mitres a corner beside an arc at joint {k}, but \
698                         the mitre plane does not clear the bend's tube within a quarter turn \
699                         of the corner: the bend is too tight for a corner this sharp"
700                        ))
701                    })?;
702                bands[i][end] = Some(MitreBand {
703                    centre,
704                    axis: toward,
705                    corner,
706                    normal,
707                    back,
708                    ahead,
709                });
710            }
711        }
712        for (i, piece) in path.iter().enumerate() {
713            let PathPiece::Arc { angle, .. } = *piece else {
714                continue;
715            };
716            let need: Scalar = bands[i].iter().flatten().map(|b| b.back).sum();
717            if need > angle.abs() {
718                return Err(GeomError::InvalidInput(format!(
719                    "swept disk directrix arc {i} turns {} rad, but the mitre bands beside it \
720                     need {need} rad of it: the mitres reach past the arc",
721                    angle.abs()
722                )));
723            }
724        }
725        let mut pipe = Self {
726            path,
727            closed,
728            radius,
729            inner,
730            chord,
731            mitres,
732            sharpest,
733            bands,
734            twist: 0.0,
735            rate: 0.0,
736        };
737        pipe.check_segment_reach()?;
738        if closed {
739            pipe.twist = pipe.closing_twist();
740            if pipe.twist.abs() * radius > ROUNDING_TWIST * chord {
741                let clear: Scalar = (0..m).map(|i| pipe.clear(i).1).sum();
742                if clear <= 0.0 {
743                    return Err(GeomError::InvalidInput(format!(
744                        "swept disk along a closed directrix: a frame carried round the loop \
745                         comes back turned by {} rad about the tangent, and no part of the \
746                         loop is clear of its mitres to take that twist back",
747                        pipe.twist
748                    )));
749                }
750                pipe.rate = pipe.twist / clear;
751            }
752        }
753        Ok(pipe)
754    }
755
756    /// The joint before piece `i`, if any.
757    fn before(&self, i: usize) -> Option<usize> {
758        match i.checked_sub(1) {
759            Some(k) => Some(k),
760            None => self.closed.then(|| self.path.len() - 1),
761        }
762    }
763
764    /// The joint after piece `i`, if any.
765    fn after(&self, i: usize) -> Option<usize> {
766        (self.closed || i + 1 < self.path.len()).then_some(i)
767    }
768
769    /// The mitre normals at the start and the end of piece `i`.
770    fn mitre_ends(&self, i: usize) -> [Option<Vec3>; 2] {
771        [
772            self.before(i).and_then(|k| self.mitres[k]),
773            self.after(i).and_then(|k| self.mitres[k]),
774        ]
775    }
776
777    /// A segment's two end planes must not meet inside the tube.
778    fn check_segment_reach(&self) -> GeomResult<()> {
779        let radius = self.radius;
780        for (i, piece) in self.path.iter().enumerate() {
781            let PathPiece::Segment { start, end } = *piece else {
782                continue;
783            };
784            let ends = self.mitre_ends(i);
785            if ends == [None, None] {
786                continue;
787            }
788            let u = (end - start).normalize();
789            let lean = |n: Option<Vec3>| n.map_or(u, |n| n / u.dot(n));
790            let g = lean(ends[1]) - lean(ends[0]);
791            let reach = radius * (g - u * u.dot(g)).length();
792            let length = (end - start).length();
793            if reach >= length {
794                return Err(GeomError::InvalidInput(format!(
795                    "swept disk directrix segment {i} is {length} long, but the mitres at its \
796                     ends reach {reach} along it at the disk radius {radius}: the tube would cut \
797                     through itself"
798                )));
799            }
800        }
801        Ok(())
802    }
803
804    /// How far a mitre plane with unit normal `n` through a segment's end
805    /// reaches along its direction `u` at the disk radius:
806    /// `r tan(theta/2)`.
807    fn hold(&self, u: Vec3, n: Vec3) -> Scalar {
808        let c = u.dot(n).abs();
809        self.radius * (1.0 - c * c).max(0.0).sqrt() / c
810    }
811
812    /// Piece `i`'s clear part, where a loop's twist is turned back: a
813    /// segment's length outside the mitre zones at its ends, as the length
814    /// held before it and its own length (both zero when the zones meet);
815    /// a bend's length outside its bands, with nothing held before it.
816    fn clear(&self, i: usize) -> (Scalar, Scalar) {
817        match self.path[i] {
818            PathPiece::Segment { start, end } => {
819                let u = (end - start).normalize();
820                let [z0, z1] = self
821                    .mitre_ends(i)
822                    .map(|n| n.map_or(0.0, |n| self.hold(u, n)));
823                let clear = (end - start).length() - z0 - z1;
824                if clear > 0.0 {
825                    (z0, clear)
826                } else {
827                    (0.0, 0.0)
828                }
829            }
830            PathPiece::Arc {
831                centre,
832                start,
833                angle,
834                ..
835            } => {
836                let banded: Scalar = self.bands[i].iter().flatten().map(|b| b.back).sum();
837                (
838                    0.0,
839                    (start - centre).length() * (angle.abs() - banded).max(0.0),
840                )
841            }
842        }
843    }
844
845    /// The holonomy of the loop: a frame carried round it (turning with
846    /// each bend, by the least rotation at each joint) comes back to the
847    /// first piece's start turned by this angle about its tangent, in
848    /// `(-pi, pi]`; turning the returned frame on by it gives the first.
849    fn closing_twist(&self) -> Scalar {
850        let m = self.path.len();
851        let t0 = self.path[0].start_tangent();
852        let x0 = seed_reference(t0).normalize();
853        let mut x = x0;
854        for (i, piece) in self.path.iter().enumerate() {
855            if let PathPiece::Arc { axis, angle, .. } = *piece {
856                x = rotate(x, Point3::ZERO, axis, angle);
857            }
858            x = least_rotation(
859                x,
860                piece.end_tangent(),
861                self.path[(i + 1) % m].start_tangent(),
862            );
863        }
864        let x = (x - t0 * t0.dot(x)).normalize();
865        x.cross(x0).dot(t0).atan2(x.dot(x0))
866    }
867
868    fn plan(&self) -> PipePlan {
869        let m = self.path.len();
870        let side = |i: usize, end: usize| -> Option<MitreSide> {
871            let piece = &self.path[i];
872            match piece {
873                PathPiece::Segment { .. } => Some(MitreSide::Segment {
874                    corner: if end == 1 {
875                        piece.end_point()
876                    } else {
877                        piece.start_point()
878                    },
879                    direction: piece.start_tangent(),
880                }),
881                PathPiece::Arc { .. } => self.bands[i][end].map(MitreSide::Arc),
882            }
883        };
884        let arc_mitres = self
885            .mitres
886            .iter()
887            .enumerate()
888            .filter_map(|(k, normal)| {
889                let normal = (*normal)?;
890                let sides = [side(k, 1)?, side((k + 1) % m, 0)?];
891                matches!(sides, [MitreSide::Arc(_), _] | [_, MitreSide::Arc(_)]).then_some(
892                    ArcMitre {
893                        joint: k,
894                        normal,
895                        sides,
896                    },
897                )
898            })
899            .collect();
900        PipePlan {
901            pieces: self.path.clone(),
902            closed: self.closed,
903            arc_mitres,
904            twist: self.twist,
905        }
906    }
907
908    fn mesh(&self) -> GeomResult<TriMesh> {
909        let rings = sweep::disk_rings(
910            self.radius,
911            self.inner,
912            0.5 * self.chord * (0.5 * self.sharpest).cos(),
913        )?;
914        let m = self.path.len();
915        let mut stations: Vec<Station> = Vec::new();
916        let mut carried: Option<Frame> = None;
917        let mut turned: Scalar = 0.0;
918        for i in 0..m {
919            let start = self.start_frame(i, carried.as_ref())?;
920            let closing = self.closed && i + 1 == m;
921            let close = match (closing, stations.first()) {
922                (false, _) => Close::Open,
923                (true, Some(first)) => Close::To(first),
924                (true, None) => Close::Own,
925            };
926            let built = self.piece_stations(i, &rings, &start, stations.last(), close, turned)?;
927            // Consecutive pieces share one station; a loop's last one is
928            // its first.
929            let skip = usize::from(!stations.is_empty());
930            let keep = built.stations.len() - usize::from(closing);
931            stations.extend(built.stations.into_iter().take(keep).skip(skip));
932            carried = Some(built.end);
933            turned = built.turned;
934        }
935        let mut mesh = loft::loft(&rings, &stations, self.closed)?;
936        // As `revolve::revolve` (#221): settle the winding from the built
937        // mesh rather than from the frames' handedness.
938        if matches!(crate::extrude::outward_orientation(&mesh), Some(false)) {
939            for triangle in mesh.indices.as_chunks_mut::<3>().0 {
940                triangle.swap(1, 2);
941            }
942        }
943        Ok(mesh)
944    }
945
946    /// Piece `i`'s own start frame: the incoming end frame turned by the
947    /// least rotation onto its tangent.
948    fn start_frame(&self, i: usize, carried: Option<&Frame>) -> GeomResult<Frame> {
949        let piece = &self.path[i];
950        let tangent = piece.start_tangent();
951        let seed = carried.map_or_else(
952            || seed_reference(tangent),
953            |f| least_rotation(f.x, f.x.cross(f.y), tangent),
954        );
955        Frame::from_reference(piece.start_point(), tangent, seed)
956    }
957
958    fn piece_stations(
959        &self,
960        i: usize,
961        rings: &Rings,
962        start: &Frame,
963        joint: Option<&Station>,
964        close: Close<'_>,
965        turned: Scalar,
966    ) -> GeomResult<Built> {
967        match self.path[i] {
968            PathPiece::Segment { .. } => {
969                self.segment_stations(i, rings, start, joint, close, turned)
970            }
971            PathPiece::Arc { .. } => self.arc_stations(i, rings, start, joint, close, turned),
972        }
973    }
974
975    /// A segment's stations: its two ends (mitred where a corner is), and
976    /// in a twisted loop the ends of its mitre zones and enough stations
977    /// between them for the twist.
978    fn segment_stations(
979        &self,
980        i: usize,
981        rings: &Rings,
982        start: &Frame,
983        joint: Option<&Station>,
984        close: Close<'_>,
985        turned: Scalar,
986    ) -> GeomResult<Built> {
987        let piece = self.path[i];
988        let length = piece.length();
989        let tangent = piece.start_tangent();
990        let (held, clear) = self.clear(i);
991        let tail = if clear > 0.0 {
992            length - held - clear
993        } else {
994            0.0
995        };
996        let twist = self.rate * clear;
997        let sample = |budget: Scalar| -> GeomResult<Sampled> {
998            let mut at = vec![0.0];
999            if twist != 0.0 {
1000                let n = twist_steps(twist, budget, self.radius)?;
1001                if held > 0.0 {
1002                    at.push(held);
1003                }
1004                at.extend((1..n).map(|k| held + clear * k as Scalar / n as Scalar));
1005                if tail > 0.0 {
1006                    at.push(length - tail);
1007                }
1008            }
1009            at.push(length);
1010            Ok(Sampled {
1011                points: at.iter().map(|s| piece.point(s / length)).collect(),
1012                tangents: vec![tangent; at.len()],
1013                twists: at
1014                    .iter()
1015                    .map(|s| turned + self.rate * (s - held).clamp(0.0, clear))
1016                    .collect(),
1017            })
1018        };
1019        let (frames, placed) = bounded_piece(
1020            sample,
1021            rings,
1022            start.x,
1023            self.radius,
1024            0.5 * self.chord,
1025            joint,
1026            self.mitre_ends(i),
1027            close,
1028        )?;
1029        Ok(Built {
1030            stations: placed,
1031            end: last_frame(frames)?,
1032            turned: turned + twist,
1033        })
1034    }
1035
1036    /// An arc's stations: a mitre band at each end beside a mitred corner,
1037    /// and the part between proven like any bend.
1038    fn arc_stations(
1039        &self,
1040        i: usize,
1041        rings: &Rings,
1042        start: &Frame,
1043        joint: Option<&Station>,
1044        close: Close<'_>,
1045        turned: Scalar,
1046    ) -> GeomResult<Built> {
1047        let piece = self.path[i];
1048        let PathPiece::Arc {
1049            centre,
1050            start: from,
1051            angle,
1052            axis,
1053        } = piece
1054        else {
1055            return Err(not_an_arc());
1056        };
1057        let total = angle.abs();
1058        let along = axis * angle.signum();
1059        let reach_radius = (from - centre).length() + self.radius;
1060        let share = 0.5 * self.chord;
1061        let band_tol = 0.5 * share;
1062        let offset = STRIP_OFFSET * self.chord;
1063        let [first, last] = self.bands[i];
1064        let (head, tail) = (first.map_or(0.0, |b| b.back), last.map_or(0.0, |b| b.back));
1065        let place = |frame: &Frame| loft::place(rings, |p| loft::at(&twisted(frame, turned), p));
1066        let carried = |frame: &Frame, by: Scalar| Frame {
1067            origin: rotate(frame.origin, centre, along, by),
1068            x: rotate(frame.x, Point3::ZERO, along, by),
1069            y: rotate(frame.y, Point3::ZERO, along, by),
1070        };
1071        let mut out: Vec<Station> = Vec::new();
1072        let mut frame = start.clone();
1073        if let Some(band) = first {
1074            let k = self.before(i).ok_or_else(not_an_arc)?;
1075            let shared = self.shared_ring(k, &twisted(start, turned), rings)?;
1076            frame = carried(start, head);
1077            let far = place(&frame);
1078            // The shared ring is this band's own cut unless a segment
1079            // comes in.
1080            let strip = matches!(self.path[k], PathPiece::Segment { .. });
1081            let mut toward = band.stations_toward(
1082                &far,
1083                &shared,
1084                strip.then_some(offset),
1085                reach_radius,
1086                band_tol,
1087            )?;
1088            toward.reverse();
1089            out.extend(toward);
1090            out.push(far);
1091        }
1092        let middle = total - head - tail;
1093        let mut twist = 0.0;
1094        if middle > 0.0 {
1095            let part = piece.between(head / total, 1.0 - tail / total);
1096            twist = self.rate * part.length();
1097            let sample = |budget: Scalar| -> GeomResult<Sampled> {
1098                let n = part
1099                    .steps(budget)?
1100                    .max(twist_steps(twist, budget, self.radius)?);
1101                let points: Vec<Point3> = (0..=n)
1102                    .map(|k| part.point(k as Scalar / n as Scalar))
1103                    .collect();
1104                let tangents = points.iter().map(|p| part.tangent_at(*p)).collect();
1105                let twists = (0..=n)
1106                    .map(|k| turned + twist * k as Scalar / n as Scalar)
1107                    .collect();
1108                Ok(Sampled {
1109                    points,
1110                    tangents,
1111                    twists,
1112                })
1113            };
1114            let (joint, close) = (
1115                if first.is_some() { out.last() } else { joint },
1116                if last.is_some() { Close::Open } else { close },
1117            );
1118            let (frames, placed) = bounded_piece(
1119                sample,
1120                rings,
1121                frame.x,
1122                self.radius,
1123                share,
1124                joint,
1125                [None, None],
1126                close,
1127            )?;
1128            let skip = usize::from(first.is_some());
1129            out.extend(placed.into_iter().skip(skip));
1130            frame = last_frame(frames)?;
1131        } else if out.is_empty() {
1132            out.push(place(start));
1133        }
1134        let turned_end = turned + twist;
1135        if let Some(band) = last {
1136            let k = self.after(i).ok_or_else(not_an_arc)?;
1137            let end = carried(&frame, tail);
1138            let target = match close {
1139                Close::To(first) => first.clone(),
1140                Close::Own => out[0].clone(),
1141                Close::Open => {
1142                    let next = self.start_frame((k + 1) % self.path.len(), Some(&end))?;
1143                    self.shared_ring(k, &twisted(&next, turned_end), rings)?
1144                }
1145            };
1146            let far = out.last().cloned().ok_or_else(not_an_arc)?;
1147            out.extend(band.stations_toward(
1148                &far,
1149                &target,
1150                Some(offset),
1151                reach_radius,
1152                band_tol,
1153            )?);
1154            frame = end;
1155        }
1156        Ok(Built {
1157            stations: out,
1158            end: frame,
1159            turned: turned_end,
1160        })
1161    }
1162
1163    /// The ring shared across mitred joint `k`, from the outgoing piece's
1164    /// start frame `frame` (twist included): a segment's mitre ring, or
1165    /// after a segment an arc's section projected the same way (the
1166    /// incoming segment's own ring, #245); between two arcs the outgoing
1167    /// band's cut.
1168    fn shared_ring(&self, k: usize, frame: &Frame, rings: &Rings) -> GeomResult<Station> {
1169        let next = (k + 1) % self.path.len();
1170        let normal = self.mitres[k].ok_or_else(not_an_arc)?;
1171        let square = loft::place(rings, |p| loft::at(frame, p));
1172        match (self.path[k], self.path[next]) {
1173            (PathPiece::Arc { .. }, PathPiece::Arc { .. }) => {
1174                self.bands[next][0].ok_or_else(not_an_arc)?.cut(&square)
1175            }
1176            _ => Ok(mitred(&square, frame, normal)),
1177        }
1178    }
1179}
1180
1181/// The refusal for a plan that does not hold together; never reached.
1182fn not_an_arc() -> GeomError {
1183    GeomError::Degenerate("swept disk: a mitre band without its arc or corner".to_owned())
1184}
1185
1186fn last_frame(frames: Vec<Frame>) -> GeomResult<Frame> {
1187    frames
1188        .into_iter()
1189        .last()
1190        .ok_or_else(|| GeomError::Degenerate("swept disk: a piece without a station".to_owned()))
1191}
1192
1193/// `frame` turned by `angle` about its own tangent.
1194fn twisted(frame: &Frame, angle: Scalar) -> Frame {
1195    if angle == 0.0 {
1196        return frame.clone();
1197    }
1198    let (s, c) = angle.sin_cos();
1199    Frame {
1200        origin: frame.origin,
1201        x: frame.x * c + frame.y * s,
1202        y: frame.y * c - frame.x * s,
1203    }
1204}
1205
1206/// Spans a twist of `twist` needs for the turn between two frames to move
1207/// the disk's rim by at most `budget`: `r (1 - cos(phi/2)) <= budget`.
1208fn twist_steps(twist: Scalar, budget: Scalar, radius: Scalar) -> GeomResult<usize> {
1209    if twist == 0.0 {
1210        return Ok(1);
1211    }
1212    let per = 2.0 * (1.0 - (budget / radius).min(1.0)).acos();
1213    let n = (twist.abs() / per).ceil();
1214    if n.is_nan() || n > MAX_TWIST_STEPS as Scalar {
1215        return Err(GeomError::BudgetExceeded {
1216            resource: "swept disk twist steps",
1217        });
1218    }
1219    Ok((n as usize).max(1))
1220}
1221
1222/// A piece's frames (untwisted) and stations, its directrix sampled until
1223/// each span's bound fits `share`; the first span also carries the
1224/// measured distance from the shared `joint` station to the piece's own
1225/// first station, and the last the distance from its last station to the
1226/// one it `close`s on. An end with a mitre normal in `ends` (start, end)
1227/// has its station projected along the piece's tangent onto that mitre
1228/// plane.
1229#[allow(clippy::too_many_arguments)]
1230fn bounded_piece(
1231    sample: impl Fn(Scalar) -> GeomResult<Sampled>,
1232    rings: &Rings,
1233    seed: Vec3,
1234    reach: Scalar,
1235    share: Scalar,
1236    joint: Option<&Station>,
1237    ends: [Option<Vec3>; 2],
1238    close: Close<'_>,
1239) -> GeomResult<(Vec<Frame>, Vec<Station>)> {
1240    let mut b = share;
1241    for _ in 0..MAX_REFINEMENTS {
1242        let Sampled {
1243            points,
1244            tangents,
1245            twists,
1246        } = sample(b)?;
1247        let frames = sweep::frames_carried(&points, &tangents, seed)?;
1248        let placing: Vec<Frame> = frames
1249            .iter()
1250            .zip(&twists)
1251            .map(|(f, a)| twisted(f, *a))
1252            .collect();
1253        let mut placed: Vec<Station> = placing
1254            .iter()
1255            .map(|f| loft::place(rings, |p| loft::at(f, p)))
1256            .collect();
1257        let last = placed.len() - 1;
1258        for (k, normal) in [(0, ends[0]), (last, ends[1])] {
1259            if let Some(n) = normal {
1260                placed[k] = mitred(&placed[k], &placing[k], n);
1261            }
1262        }
1263        let shift = joint.map_or(0.0, |j| displacement(j, &placed[0]));
1264        let closing = match close {
1265            Close::Open => 0.0,
1266            Close::To(station) => displacement(station, &placed[last]),
1267            Close::Own => displacement(&placed[0], &placed[last]),
1268        };
1269        let worst = placing
1270            .windows(2)
1271            .zip(placed.windows(2))
1272            .enumerate()
1273            .map(|(k, (f, s))| {
1274                let moved = if k == 0 { shift } else { 0.0 };
1275                let closed = if k + 1 == last { closing } else { 0.0 };
1276                sweep::span_bound(&f[0], &f[1], b, reach)
1277                    + loft::span_deviation(&s[0], &s[1])
1278                    + moved
1279                    + closed
1280            })
1281            .fold(0.0, Scalar::max);
1282        if worst <= share {
1283            return Ok((frames, placed));
1284        }
1285        b *= 0.5;
1286    }
1287    Err(GeomError::BudgetExceeded {
1288        resource: "swept disk directrix refinement",
1289    })
1290}
1291
1292/// `station`, square to `frame`'s tangent at its origin, projected along
1293/// that tangent onto the mitre plane through the origin with unit normal
1294/// `normal` (see the module notes).
1295fn mitred(station: &Station, frame: &Frame, normal: Vec3) -> Station {
1296    let tangent = frame.x.cross(frame.y);
1297    let along = tangent.dot(normal);
1298    Station {
1299        loops: station
1300            .loops
1301            .iter()
1302            .map(|ring| {
1303                ring.iter()
1304                    .map(|q| *q - tangent * ((*q - frame.origin).dot(normal) / along))
1305                    .collect()
1306            })
1307            .collect(),
1308    }
1309}
1310
1311/// The refusal of a disk radius equal to a bend's or fillet's radius
1312/// (see the module notes).
1313fn horn_torus(what: &str, radius: Scalar) -> GeomError {
1314    GeomError::InvalidInput(format!(
1315        "swept disk radius {radius} equals the {what} radius: the format rule permits a \
1316         fillet radius equal to the disk radius, but the bend is then a horn torus whose \
1317         inner wall degenerates to one point on the bend's axis, a boundary that is not a \
1318         two-manifold and that no closed mesh bounds without meeting itself there"
1319    ))
1320}
1321
1322/// The largest distance between corresponding vertices of two stations.
1323fn displacement(a: &Station, b: &Station) -> Scalar {
1324    a.loops
1325        .iter()
1326        .zip(&b.loops)
1327        .flat_map(|(p, q)| p.iter().zip(q).map(|(u, v)| (*u - *v).length()))
1328        .fold(0.0, Scalar::max)
1329}
1330
1331/// `v` turned by the least rotation that takes unit `from` to unit `to`.
1332fn least_rotation(v: Vec3, from: Vec3, to: Vec3) -> Vec3 {
1333    let axis = from.cross(to);
1334    let sin = axis.length();
1335    if sin == 0.0 {
1336        return v;
1337    }
1338    rotate(v, Point3::ZERO, axis / sin, turn(from, to))
1339}
1340
1341/// Any direction perpendicular to unit `t`.
1342fn seed_reference(t: Vec3) -> Vec3 {
1343    let candidate = if t.x.abs() < 0.9 { Vec3::X } else { Vec3::Y };
1344    candidate - t * t.dot(candidate)
1345}
1346
1347/// Round every corner between two segments that turns by more than
1348/// `tolerance` with an arc of radius `fillet` (see the module notes); in a
1349/// loop, the corner where it closes too.
1350fn fillet(
1351    pieces: &[PathPiece],
1352    fillet: Scalar,
1353    radius: Scalar,
1354    tolerance: Scalar,
1355    closed: bool,
1356) -> GeomResult<Vec<PathPiece>> {
1357    // Each segment keeps its direction even when the fillets either side
1358    // use it up, so the corner after it is still measured against it.
1359    struct Leg {
1360        piece: PathPiece,
1361        dir: Vec3,
1362        trim_start: Scalar,
1363        trim_end: Scalar,
1364    }
1365    let mut legs: Vec<Leg> = Vec::with_capacity(pieces.len());
1366    for piece in pieces {
1367        piece.check(radius)?;
1368        legs.push(Leg {
1369            piece: *piece,
1370            dir: piece.start_tangent(),
1371            trim_start: 0.0,
1372            trim_end: 0.0,
1373        });
1374    }
1375    let m = legs.len();
1376    // The fillet at the corner where leg `k` starts.
1377    let mut arcs: Vec<Option<PathPiece>> = vec![None; m];
1378    let first = if closed { 0 } else { 1 };
1379    for k in first..m {
1380        let prev = (k + m - 1) % m;
1381        if prev == k {
1382            continue;
1383        }
1384        let (PathPiece::Segment { .. }, PathPiece::Segment { start: corner, .. }) =
1385            (legs[prev].piece, legs[k].piece)
1386        else {
1387            continue;
1388        };
1389        let (u, v) = (legs[prev].dir, legs[k].dir);
1390        let theta = turn(u, v);
1391        if theta <= tolerance {
1392            continue;
1393        }
1394        if radius > fillet {
1395            return Err(GeomError::InvalidInput(format!(
1396                "swept disk radius {radius} is above the fillet radius {fillet}: the \
1397                 tube would fold through the bend's axis"
1398            )));
1399        }
1400        if radius == fillet {
1401            return Err(horn_torus("fillet", radius));
1402        }
1403        let normal = u.cross(v);
1404        if normal.length() <= 1e-12 {
1405            return Err(GeomError::InvalidInput(format!(
1406                "swept disk directrix reverses at corner {k}; no fillet rounds it"
1407            )));
1408        }
1409        let cut = fillet * (0.5 * theta).tan();
1410        legs[prev].trim_end = cut;
1411        legs[k].trim_start = cut;
1412        let tangent_point = corner - u * cut;
1413        let inward = (v - u * u.dot(v)).normalize();
1414        arcs[k] = Some(PathPiece::Arc {
1415            centre: tangent_point + inward * fillet,
1416            axis: normal.normalize(),
1417            start: tangent_point,
1418            angle: theta,
1419        });
1420    }
1421    let mut out = Vec::with_capacity(2 * m);
1422    for (k, leg) in legs.iter().enumerate() {
1423        if k > 0 {
1424            if let Some(arc) = arcs[k] {
1425                out.push(arc);
1426            }
1427        }
1428        let length = leg.piece.length();
1429        let needed = leg.trim_start + leg.trim_end;
1430        if needed == 0.0 {
1431            out.push(leg.piece);
1432            continue;
1433        }
1434        // A fillet using up exactly its segment meets the next one
1435        // tangentially; rounding in `tan` may overshoot by an ulp or two.
1436        let slack = 1e-9 * length;
1437        if needed > length + slack {
1438            return Err(GeomError::InvalidInput(format!(
1439                "swept disk fillet radius {fillet} does not fit: segment {k} is {length} \
1440                 long but its fillets need {needed} of it"
1441            )));
1442        }
1443        if needed < length - slack {
1444            out.push(
1445                leg.piece
1446                    .between(leg.trim_start / length, 1.0 - leg.trim_end / length),
1447            );
1448        }
1449    }
1450    if closed {
1451        if let Some(arc) = arcs[0] {
1452            out.push(arc);
1453        }
1454    }
1455    Ok(out)
1456}
1457
1458#[cfg(test)]
1459mod tests {
1460    use super::*;
1461
1462    #[test]
1463    fn a_fillet_is_tangent_to_both_segments() {
1464        let corner = Point3::new(1.0, 0.0, 0.0);
1465        let pieces = [
1466            PathPiece::Segment {
1467                start: Point3::ZERO,
1468                end: corner,
1469            },
1470            PathPiece::Segment {
1471                start: corner,
1472                end: Point3::new(1.0, 1.0, 1.0),
1473            },
1474        ];
1475        let path = fillet(&pieces, 0.2, 0.05, 1e-6, false).unwrap();
1476        assert_eq!(path.len(), 3);
1477        for pair in path.windows(2) {
1478            assert!((pair[0].end_point() - pair[1].start_point()).length() < 1e-12);
1479            assert!(turn(pair[0].end_tangent(), pair[1].start_tangent()) < 1e-12);
1480        }
1481    }
1482
1483    #[test]
1484    fn a_mitre_ring_lies_on_both_cylinders_and_on_the_bisector_plane() {
1485        let corner = Point3::new(1.0, 0.2, -0.3);
1486        let (u, v) = (
1487            Vec3::new(1.0, 0.0, 0.0),
1488            Vec3::new(0.2, 0.7, -0.4).normalize(),
1489        );
1490        let pieces = [
1491            PathPiece::Segment {
1492                start: corner - u,
1493                end: corner,
1494            },
1495            PathPiece::Segment {
1496                start: corner,
1497                end: corner + v,
1498            },
1499        ];
1500        let (r, inner) = (0.05, 0.03);
1501        let mesh = swept_disk_along_pieces(&pieces, r, Some(inner), None, 1e-3).unwrap();
1502        // One station per vertex: start, mitre, end.
1503        let per = mesh.positions.len() / 3;
1504        let n = (u + v).normalize();
1505        let off = |p: Point3, axis: Vec3| {
1506            let d = p - corner;
1507            (d - axis * d.dot(axis)).length()
1508        };
1509        for p in &mesh.positions[per..2 * per] {
1510            assert!((*p - corner).dot(n).abs() < 1e-12);
1511            let (a, b) = (off(*p, u), off(*p, v));
1512            assert!((a - b).abs() < 1e-12, "{a} {b}");
1513            assert!((a - r).abs() < 1e-12 || (a - inner).abs() < 1e-12, "{a}");
1514        }
1515    }
1516
1517    #[test]
1518    fn an_arc_walked_backwards_retraces_itself() {
1519        let arc = PathPiece::Arc {
1520            centre: Point3::new(0.0, 1.0, 0.0),
1521            axis: Vec3::Z,
1522            start: Point3::ZERO,
1523            angle: 1.0,
1524        };
1525        let back = arc.reversed();
1526        assert!((back.end_point() - arc.start_point()).length() < 1e-12);
1527        assert!((back.start_tangent() + arc.end_tangent()).length() < 1e-12);
1528        let part = arc.between(0.25, 0.75);
1529        assert!((part.length() - 0.5).abs() < 1e-12);
1530        assert!((part.start_point() - arc.point(0.25)).length() < 1e-12);
1531    }
1532}