Skip to main content

axiolid_construct/
pipe.rs

1//! Swept disks along chains of straight segments and circular arcs (#232,
2//! #245).
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; a
101//! reversal (`theta = pi`, where the mitre plane holds both legs); and a
102//! corner beside an arc, because the mitre plane cuts the torus round an
103//! arc in a different curve than the tube on its other side, so no ring
104//! lies on both exact surfaces and the bound above does not carry over.
105//!
106//! # Fillets
107//!
108//! A fillet radius `f` rounds every corner between two straight segments
109//! (the swept disk's `fillet_radius`): a corner turning by
110//! `theta` is replaced by the arc of radius `f` tangent to both segments,
111//! which cuts `f tan(theta/2)` off each. Segments shortened to nothing are
112//! dropped (two fillets then meet tangentially). Refused by name: a fillet
113//! that needs more of a segment than it has, a corner beside an arc (only
114//! corners between straight segments are rounded), a disk radius above the
115//! fillet radius, or any bend radius, whose tube folds through the bend's
116//! axis on its inside, and a reversal. A disk radius equal to the fillet
117//! (or bend) radius is refused by name too, although the format rule
118//! permits it (fillet radius at least the disk radius): the bend is then a
119//! horn torus whose inner wall pinches to one point on the bend's axis,
120//! where every section touches. That boundary is not a two-manifold; a
121//! closed mesh within the budget would have to either meet itself there
122//! (degenerate triangles) or stand off the pinch and misstate it.
123
124use axiolid_contracts::{GeomError, GeomResult};
125use axiolid_core::{Point3, Scalar, Vec3};
126use axiolid_mesh::TriMesh;
127
128use crate::loft::{self, Frame, Station};
129use crate::revolve::rotate;
130use crate::sweep;
131
132/// One smooth piece of a swept disk's directrix.
133#[derive(Debug, Clone, Copy, PartialEq)]
134#[non_exhaustive]
135pub enum PathPiece {
136    /// A straight segment from `start` to `end`.
137    Segment {
138        /// First point, in the direction of travel.
139        start: Point3,
140        /// Last point.
141        end: Point3,
142    },
143    /// A circular arc: `start` turned by `angle` radians, right-handed,
144    /// about the line through `centre` along the unit `axis`. `start -
145    /// centre` must be perpendicular to `axis`; a negative `angle` turns
146    /// the other way.
147    Arc {
148        /// Centre of the arc's circle.
149        centre: Point3,
150        /// Unit normal of the arc's plane.
151        axis: Vec3,
152        /// First point, in the direction of travel.
153        start: Point3,
154        /// Signed turn, at most a full turn either way.
155        angle: Scalar,
156    },
157}
158
159impl PathPiece {
160    /// The first point, in the direction of travel.
161    #[must_use]
162    pub fn start_point(&self) -> Point3 {
163        match *self {
164            Self::Segment { start, .. } | Self::Arc { start, .. } => start,
165        }
166    }
167
168    /// The last point, in the direction of travel.
169    #[must_use]
170    pub fn end_point(&self) -> Point3 {
171        self.point(1.0)
172    }
173
174    /// The point a fraction `s` of the way along, by length.
175    #[must_use]
176    pub fn point(&self, s: Scalar) -> Point3 {
177        match *self {
178            Self::Segment { start, end } => start + (end - start) * s,
179            Self::Arc {
180                centre,
181                axis,
182                start,
183                angle,
184            } => rotate(start, centre, axis, angle * s),
185        }
186    }
187
188    /// The unit tangent, in the direction of travel, at `point` on the
189    /// piece; zero for a degenerate piece.
190    fn tangent_at(&self, point: Point3) -> Vec3 {
191        match *self {
192            Self::Segment { start, end } => (end - start).normalize_or_zero(),
193            Self::Arc {
194                centre,
195                axis,
196                angle,
197                ..
198            } => (axis.cross(point - centre) * angle.signum()).normalize_or_zero(),
199        }
200    }
201
202    /// The unit tangent at the start, in the direction of travel.
203    #[must_use]
204    pub fn start_tangent(&self) -> Vec3 {
205        self.tangent_at(self.start_point())
206    }
207
208    /// The unit tangent at the end, in the direction of travel.
209    #[must_use]
210    pub fn end_tangent(&self) -> Vec3 {
211        self.tangent_at(self.end_point())
212    }
213
214    /// The length along the piece.
215    #[must_use]
216    pub fn length(&self) -> Scalar {
217        match *self {
218            Self::Segment { start, end } => (end - start).length(),
219            Self::Arc {
220                centre,
221                start,
222                angle,
223                ..
224            } => (start - centre).length() * angle.abs(),
225        }
226    }
227
228    /// The same piece walked the other way.
229    #[must_use]
230    pub fn reversed(&self) -> Self {
231        match *self {
232            Self::Segment { start, end } => Self::Segment {
233                start: end,
234                end: start,
235            },
236            Self::Arc {
237                centre,
238                axis,
239                angle,
240                ..
241            } => Self::Arc {
242                centre,
243                axis,
244                start: self.end_point(),
245                angle: -angle,
246            },
247        }
248    }
249
250    /// The part between the fractions `from` and `to` of its length, in
251    /// that order (`to < from` walks it backwards).
252    #[must_use]
253    pub fn between(&self, from: Scalar, to: Scalar) -> Self {
254        match *self {
255            Self::Segment { .. } => Self::Segment {
256                start: self.point(from),
257                end: self.point(to),
258            },
259            Self::Arc {
260                centre,
261                axis,
262                angle,
263                ..
264            } => Self::Arc {
265                centre,
266                axis,
267                start: self.point(from),
268                angle: angle * (to - from),
269            },
270        }
271    }
272
273    /// Refuse a piece with no direction or a malformed arc.
274    fn check(&self, radius: Scalar) -> GeomResult<()> {
275        let finite = |p: Point3| p.is_finite();
276        match *self {
277            Self::Segment { start, end } => {
278                if !(finite(start) && finite(end)) {
279                    return Err(GeomError::InvalidInput(
280                        "swept disk directrix segment must be finite".to_owned(),
281                    ));
282                }
283                if start == end {
284                    return Err(GeomError::Degenerate(
285                        "swept disk directrix segment has no length".to_owned(),
286                    ));
287                }
288            }
289            Self::Arc {
290                centre,
291                axis,
292                start,
293                angle,
294            } => {
295                if !(finite(centre) && finite(axis) && finite(start) && angle.is_finite()) {
296                    return Err(GeomError::InvalidInput(
297                        "swept disk directrix arc must be finite".to_owned(),
298                    ));
299                }
300                let bend = (start - centre).length();
301                if (axis.length() - 1.0).abs() > 1e-9
302                    || bend == 0.0
303                    || (start - centre).dot(axis).abs() > 1e-9 * bend
304                {
305                    return Err(GeomError::InvalidInput(
306                        "swept disk directrix arc needs a unit axis normal to a non-zero \
307                         radius"
308                            .to_owned(),
309                    ));
310                }
311                if angle == 0.0 || angle.abs() > core::f64::consts::TAU * (1.0 + 1e-12) {
312                    return Err(GeomError::InvalidInput(format!(
313                        "swept disk directrix arc must turn by a non-zero angle of at most \
314                         a full turn, got {angle}"
315                    )));
316                }
317                if bend < radius {
318                    return Err(GeomError::InvalidInput(format!(
319                        "swept disk radius {radius} is not below the bend radius {bend}: \
320                         the tube would fold through the bend's axis"
321                    )));
322                }
323                if bend == radius {
324                    return Err(horn_torus("bend", radius));
325                }
326            }
327        }
328        Ok(())
329    }
330
331    /// Samples to chord budget `budget`, and the exact unit tangent at
332    /// each: a segment's two ends, or an arc in equal steps whose sagitta
333    /// is within the budget.
334    fn sample(&self, budget: Scalar) -> GeomResult<(Vec<Point3>, Vec<Vec3>)> {
335        let n = match *self {
336            Self::Segment { .. } => 1,
337            Self::Arc {
338                centre,
339                start,
340                angle,
341                ..
342            } => crate::revolve::steps((start - centre).length(), angle, budget).map_err(|_| {
343                GeomError::BudgetExceeded {
344                    resource: "swept disk bend steps",
345                }
346            })?,
347        };
348        let points: Vec<Point3> = (0..=n)
349            .map(|k| self.point(k as Scalar / n as Scalar))
350            .collect();
351        let tangents = points.iter().map(|p| self.tangent_at(*p)).collect();
352        Ok((points, tangents))
353    }
354}
355
356/// The largest turn a joint may make and still be tangent continuous at
357/// chord budget `chord` for a disk of radius `radius`: `2 asin(c / (8 r))`
358/// (see the module notes).
359#[must_use]
360pub fn joint_tolerance(chord: Scalar, radius: Scalar) -> Scalar {
361    2.0 * (chord / (8.0 * radius)).min(1.0).asin()
362}
363
364/// The angle between two unit vectors, accurate when it is small.
365fn turn(a: Vec3, b: Vec3) -> Scalar {
366    a.cross(b).length().atan2(a.dot(b))
367}
368
369/// Sweep a disk, optionally hollow, along a chain of segments and arcs so
370/// that every point of the exact tube lies within `chord` of the mesh.
371///
372/// `fillet_radius` rounds each corner between two segments; without it,
373/// each such corner is mitred at half angle (#245). See the module notes
374/// for the bound, the joint rule, the mitre, and what is refused. The
375/// pieces run in the direction of travel, each starting where the last
376/// ended.
377pub fn swept_disk_along_pieces(
378    pieces: &[PathPiece],
379    radius: Scalar,
380    inner_radius: Option<Scalar>,
381    fillet_radius: Option<Scalar>,
382    chord: Scalar,
383) -> GeomResult<TriMesh> {
384    sweep::check_radii(radius, inner_radius)?;
385    if !(chord.is_finite() && chord > 0.0) {
386        return Err(GeomError::InvalidInput(format!(
387            "swept disk chord budget must be positive and finite, got {chord}"
388        )));
389    }
390    if let Some(f) = fillet_radius {
391        if !(f.is_finite() && f > 0.0) {
392            return Err(GeomError::InvalidInput(format!(
393                "swept disk fillet radius must be positive and finite, got {f}"
394            )));
395        }
396    }
397    if pieces.is_empty() {
398        return Err(GeomError::InvalidInput(
399            "a swept disk directrix needs at least one piece".to_owned(),
400        ));
401    }
402    let tolerance = joint_tolerance(chord, radius);
403    let path = match fillet_radius {
404        Some(f) => fillet(pieces, f, radius, tolerance)?,
405        None => pieces.to_vec(),
406    };
407    for piece in &path {
408        piece.check(radius)?;
409    }
410    // The mitre plane's unit normal at each joint, `None` where the joint
411    // is tangent continuous at this budget.
412    let mut mitres: Vec<Option<Vec3>> = Vec::with_capacity(path.len());
413    let mut sharpest: Scalar = 0.0;
414    for (k, pair) in path.windows(2).enumerate() {
415        let gap = (pair[1].start_point() - pair[0].end_point()).length();
416        if gap > 0.25 * chord {
417            return Err(GeomError::InvalidInput(format!(
418                "swept disk directrix pieces leave a {gap} gap at joint {k}, more than a \
419                 quarter of the {chord} chord budget"
420            )));
421        }
422        let (u, v) = (pair[0].end_tangent(), pair[1].start_tangent());
423        let theta = turn(u, v);
424        if theta <= tolerance {
425            mitres.push(None);
426            continue;
427        }
428        if !matches!(
429            (pair[0], pair[1]),
430            (PathPiece::Segment { .. }, PathPiece::Segment { .. })
431        ) {
432            let advice = if fillet_radius.is_some() {
433                "only corners between two straight segments are filleted"
434            } else {
435                "only corners between two straight segments are mitred; the mitre plane \
436                 cuts the tube round an arc in a different curve than the tube on its \
437                 other side"
438            };
439            return Err(GeomError::InvalidInput(format!(
440                "swept disk directrix turns a corner of {theta} rad beside an arc at joint \
441                 {k}, above the {tolerance} rad a tangent-continuous joint may turn at this \
442                 chord budget: {advice}"
443            )));
444        }
445        let bisector = u + v;
446        if theta >= core::f64::consts::PI - 1e-9 || bisector.length() <= 1e-9 {
447            return Err(GeomError::InvalidInput(format!(
448                "swept disk directrix reverses at joint {k}: a mitre at half angle would \
449                 hold both legs"
450            )));
451        }
452        sharpest = sharpest.max(theta);
453        mitres.push(Some(bisector.normalize()));
454    }
455    // A segment's two end planes must not meet inside the tube.
456    for (i, piece) in path.iter().enumerate() {
457        let PathPiece::Segment { start, end } = *piece else {
458            continue;
459        };
460        let ends = mitre_ends(&mitres, i);
461        if ends == [None, None] {
462            continue;
463        }
464        let u = (end - start).normalize();
465        let lean = |n: Option<Vec3>| n.map_or(u, |n| n / u.dot(n));
466        let g = lean(ends[1]) - lean(ends[0]);
467        let reach = radius * (g - u * u.dot(g)).length();
468        let length = (end - start).length();
469        if reach >= length {
470            return Err(GeomError::InvalidInput(format!(
471                "swept disk directrix segment {i} is {length} long, but the mitres at its \
472                 ends reach {reach} along it at the disk radius {radius}: the tube would cut \
473                 through itself"
474            )));
475        }
476    }
477    let rings = sweep::disk_rings(radius, inner_radius, 0.5 * chord * (0.5 * sharpest).cos())?;
478    let share = 0.5 * chord;
479    let mut stations: Vec<Station> = Vec::new();
480    let mut last: Option<Frame> = None;
481    for (i, piece) in path.iter().enumerate() {
482        let tangent = piece.start_tangent();
483        // The outgoing piece's own start frame: the incoming end frame
484        // turned by the least rotation onto this tangent.
485        let seed = match &last {
486            None => seed_reference(tangent),
487            Some(frame) => least_rotation(frame.x, frame.x.cross(frame.y), tangent),
488        };
489        let joint = stations.last();
490        let ends = mitre_ends(&mitres, i);
491        let (frames, placed) = bounded_piece(piece, &rings, seed, radius, share, joint, ends)?;
492        let skip = usize::from(joint.is_some());
493        stations.extend(placed.into_iter().skip(skip));
494        last = frames.into_iter().last();
495    }
496    let mut mesh = loft::loft(&rings, &stations, false)?;
497    // As `revolve::revolve` (#221): settle the winding from the built
498    // mesh rather than from the frames' handedness.
499    if matches!(crate::extrude::outward_orientation(&mesh), Some(false)) {
500        for triangle in mesh.indices.chunks_exact_mut(3) {
501            triangle.swap(1, 2);
502        }
503    }
504    Ok(mesh)
505}
506
507/// Rounds of halving a piece's directrix budget before it is refused.
508const MAX_REFINEMENTS: usize = 16;
509
510/// The mitre normals at the start and the end of piece `i`.
511fn mitre_ends(mitres: &[Option<Vec3>], i: usize) -> [Option<Vec3>; 2] {
512    [
513        i.checked_sub(1).and_then(|k| mitres[k]),
514        mitres.get(i).copied().flatten(),
515    ]
516}
517
518/// A piece's frames and stations, its directrix sampled until each span's
519/// bound fits `share`; the first span also carries the measured distance
520/// from the shared `joint` station to the piece's own first station. An
521/// end with a mitre normal in `ends` (start, end) has its station
522/// projected along the piece's tangent onto that mitre plane.
523fn bounded_piece(
524    piece: &PathPiece,
525    rings: &crate::profile::Rings,
526    seed: Vec3,
527    reach: Scalar,
528    share: Scalar,
529    joint: Option<&Station>,
530    ends: [Option<Vec3>; 2],
531) -> GeomResult<(Vec<Frame>, Vec<Station>)> {
532    let mut b = share;
533    for _ in 0..MAX_REFINEMENTS {
534        let (points, tangents) = piece.sample(b)?;
535        let frames = sweep::frames_carried(&points, &tangents, seed)?;
536        let mut placed: Vec<Station> = frames
537            .iter()
538            .map(|f| loft::place(rings, |p| loft::at(f, p)))
539            .collect();
540        let last = placed.len() - 1;
541        for (k, normal) in [(0, ends[0]), (last, ends[1])] {
542            if let Some(n) = normal {
543                placed[k] = mitred(&placed[k], &frames[k], n);
544            }
545        }
546        let shift = joint.map_or(0.0, |j| displacement(j, &placed[0]));
547        let worst = frames
548            .windows(2)
549            .zip(placed.windows(2))
550            .enumerate()
551            .map(|(k, (f, s))| {
552                let moved = if k == 0 { shift } else { 0.0 };
553                sweep::span_bound(&f[0], &f[1], b, reach)
554                    + loft::span_deviation(&s[0], &s[1])
555                    + moved
556            })
557            .fold(0.0, Scalar::max);
558        if worst <= share {
559            return Ok((frames, placed));
560        }
561        b *= 0.5;
562    }
563    Err(GeomError::BudgetExceeded {
564        resource: "swept disk directrix refinement",
565    })
566}
567
568/// `station`, square to `frame`'s tangent at its origin, projected along
569/// that tangent onto the mitre plane through the origin with unit normal
570/// `normal` (see the module notes).
571fn mitred(station: &Station, frame: &Frame, normal: Vec3) -> Station {
572    let tangent = frame.x.cross(frame.y);
573    let along = tangent.dot(normal);
574    Station {
575        loops: station
576            .loops
577            .iter()
578            .map(|ring| {
579                ring.iter()
580                    .map(|q| *q - tangent * ((*q - frame.origin).dot(normal) / along))
581                    .collect()
582            })
583            .collect(),
584    }
585}
586
587/// The refusal of a disk radius equal to a bend's or fillet's radius
588/// (see the module notes).
589fn horn_torus(what: &str, radius: Scalar) -> GeomError {
590    GeomError::InvalidInput(format!(
591        "swept disk radius {radius} equals the {what} radius: the format rule permits a \
592         fillet radius equal to the disk radius, but the bend is then a horn torus whose \
593         inner wall degenerates to one point on the bend's axis, a boundary that is not a \
594         two-manifold and that no closed mesh bounds without meeting itself there"
595    ))
596}
597
598/// The largest distance between corresponding vertices of two stations.
599fn displacement(a: &Station, b: &Station) -> Scalar {
600    a.loops
601        .iter()
602        .zip(&b.loops)
603        .flat_map(|(p, q)| p.iter().zip(q).map(|(u, v)| (*u - *v).length()))
604        .fold(0.0, Scalar::max)
605}
606
607/// `v` turned by the least rotation that takes unit `from` to unit `to`.
608fn least_rotation(v: Vec3, from: Vec3, to: Vec3) -> Vec3 {
609    let axis = from.cross(to);
610    let sin = axis.length();
611    if sin == 0.0 {
612        return v;
613    }
614    rotate(v, Point3::ZERO, axis / sin, turn(from, to))
615}
616
617/// Any direction perpendicular to unit `t`.
618fn seed_reference(t: Vec3) -> Vec3 {
619    let candidate = if t.x.abs() < 0.9 { Vec3::X } else { Vec3::Y };
620    candidate - t * t.dot(candidate)
621}
622
623/// Round every corner between two segments that turns by more than
624/// `tolerance` with an arc of radius `fillet` (see the module notes).
625fn fillet(
626    pieces: &[PathPiece],
627    fillet: Scalar,
628    radius: Scalar,
629    tolerance: Scalar,
630) -> GeomResult<Vec<PathPiece>> {
631    // Each segment keeps its direction even when the fillets either side
632    // use it up, so the corner after it is still measured against it.
633    struct Leg {
634        piece: PathPiece,
635        dir: Vec3,
636        trim_start: Scalar,
637        trim_end: Scalar,
638    }
639    let mut legs: Vec<Leg> = Vec::with_capacity(pieces.len());
640    let mut arcs: Vec<Option<PathPiece>> = Vec::with_capacity(pieces.len());
641    for piece in pieces {
642        piece.check(radius)?;
643        legs.push(Leg {
644            piece: *piece,
645            dir: piece.start_tangent(),
646            trim_start: 0.0,
647            trim_end: 0.0,
648        });
649    }
650    for k in 1..legs.len() {
651        let (PathPiece::Segment { .. }, PathPiece::Segment { start: corner, .. }) =
652            (legs[k - 1].piece, legs[k].piece)
653        else {
654            arcs.push(None);
655            continue;
656        };
657        let (u, v) = (legs[k - 1].dir, legs[k].dir);
658        let theta = turn(u, v);
659        if theta <= tolerance {
660            arcs.push(None);
661            continue;
662        }
663        if radius > fillet {
664            return Err(GeomError::InvalidInput(format!(
665                "swept disk radius {radius} is above the fillet radius {fillet}: the \
666                 tube would fold through the bend's axis"
667            )));
668        }
669        if radius == fillet {
670            return Err(horn_torus("fillet", radius));
671        }
672        let normal = u.cross(v);
673        if normal.length() <= 1e-12 {
674            return Err(GeomError::InvalidInput(format!(
675                "swept disk directrix reverses at corner {k}; no fillet rounds it"
676            )));
677        }
678        let cut = fillet * (0.5 * theta).tan();
679        legs[k - 1].trim_end = cut;
680        legs[k].trim_start = cut;
681        let tangent_point = corner - u * cut;
682        let inward = (v - u * u.dot(v)).normalize();
683        arcs.push(Some(PathPiece::Arc {
684            centre: tangent_point + inward * fillet,
685            axis: normal.normalize(),
686            start: tangent_point,
687            angle: theta,
688        }));
689    }
690    let mut out = Vec::with_capacity(2 * legs.len());
691    for (k, leg) in legs.iter().enumerate() {
692        if k > 0 {
693            if let Some(arc) = arcs[k - 1] {
694                out.push(arc);
695            }
696        }
697        let length = leg.piece.length();
698        let needed = leg.trim_start + leg.trim_end;
699        if needed == 0.0 {
700            out.push(leg.piece);
701            continue;
702        }
703        // A fillet using up exactly its segment meets the next one
704        // tangentially; rounding in `tan` may overshoot by an ulp or two.
705        let slack = 1e-9 * length;
706        if needed > length + slack {
707            return Err(GeomError::InvalidInput(format!(
708                "swept disk fillet radius {fillet} does not fit: segment {k} is {length} \
709                 long but its fillets need {needed} of it"
710            )));
711        }
712        if needed < length - slack {
713            out.push(
714                leg.piece
715                    .between(leg.trim_start / length, 1.0 - leg.trim_end / length),
716            );
717        }
718    }
719    Ok(out)
720}
721
722#[cfg(test)]
723mod tests {
724    use super::*;
725
726    #[test]
727    fn a_fillet_is_tangent_to_both_segments() {
728        let corner = Point3::new(1.0, 0.0, 0.0);
729        let pieces = [
730            PathPiece::Segment {
731                start: Point3::ZERO,
732                end: corner,
733            },
734            PathPiece::Segment {
735                start: corner,
736                end: Point3::new(1.0, 1.0, 1.0),
737            },
738        ];
739        let path = fillet(&pieces, 0.2, 0.05, 1e-6).unwrap();
740        assert_eq!(path.len(), 3);
741        for pair in path.windows(2) {
742            assert!((pair[0].end_point() - pair[1].start_point()).length() < 1e-12);
743            assert!(turn(pair[0].end_tangent(), pair[1].start_tangent()) < 1e-12);
744        }
745    }
746
747    #[test]
748    fn a_mitre_ring_lies_on_both_cylinders_and_on_the_bisector_plane() {
749        let corner = Point3::new(1.0, 0.2, -0.3);
750        let (u, v) = (
751            Vec3::new(1.0, 0.0, 0.0),
752            Vec3::new(0.2, 0.7, -0.4).normalize(),
753        );
754        let pieces = [
755            PathPiece::Segment {
756                start: corner - u,
757                end: corner,
758            },
759            PathPiece::Segment {
760                start: corner,
761                end: corner + v,
762            },
763        ];
764        let (r, inner) = (0.05, 0.03);
765        let mesh = swept_disk_along_pieces(&pieces, r, Some(inner), None, 1e-3).unwrap();
766        // One station per vertex: start, mitre, end.
767        let per = mesh.positions.len() / 3;
768        let n = (u + v).normalize();
769        let off = |p: Point3, axis: Vec3| {
770            let d = p - corner;
771            (d - axis * d.dot(axis)).length()
772        };
773        for p in &mesh.positions[per..2 * per] {
774            assert!((*p - corner).dot(n).abs() < 1e-12);
775            let (a, b) = (off(*p, u), off(*p, v));
776            assert!((a - b).abs() < 1e-12, "{a} {b}");
777            assert!((a - r).abs() < 1e-12 || (a - inner).abs() < 1e-12, "{a}");
778        }
779    }
780
781    #[test]
782    fn an_arc_walked_backwards_retraces_itself() {
783        let arc = PathPiece::Arc {
784            centre: Point3::new(0.0, 1.0, 0.0),
785            axis: Vec3::Z,
786            start: Point3::ZERO,
787            angle: 1.0,
788        };
789        let back = arc.reversed();
790        assert!((back.end_point() - arc.start_point()).length() < 1e-12);
791        assert!((back.start_tangent() + arc.end_tangent()).length() < 1e-12);
792        let part = arc.between(0.25, 0.75);
793        assert!((part.length() - 0.5).abs() < 1e-12);
794        assert!((part.start_point() - arc.point(0.25)).length() < 1e-12);
795    }
796}