Skip to main content

axiolid_construct/
pipe.rs

1//! Swept disks along chains of straight segments and circular arcs (#232).
2//!
3//! A pipe run, a rebar or a cable tray edge is a disk swept along legs
4//! joined by bends: authoring formats write it as a disk swept along a
5//! composite of lines and arcs, or along a polyline whose corners are
6//! rounded to a fillet radius.
7//! [`swept_disk_along_pieces`] meshes both so that every point of the
8//! exact tube lies within the chord budget of the triangles.
9//!
10//! # The bound
11//!
12//! Each piece is bounded on its own, with the split [`crate::sweep`] uses
13//! for one arc (#231): half the budget `c` chords the disk, so a ring of
14//! `n` points inscribed in the circle of radius `r` lies within
15//! `r (1 - cos(pi/n)) <= c/2` of it, and every point of the exact tube is
16//! within `c/2` of the tube the ring polygon traces when it is carried
17//! rigidly along the exact centreline by the piece's own frames (each
18//! exact section circle and its polygon lie in the same plane). The
19//! other half bounds that polygon tube against the triangles:
20//!
21//! - Along a segment the frames do not turn, so the polygon tube is a
22//!   prism whose walls are the mesh's own planar rectangles: zero.
23//! - Along an arc of radius `R` the frames are rotation-minimising, which
24//!   along a circle means they turn with it: consecutive stations are
25//!   rotations of one ring about the arc's axis, and the ring's plane
26//!   contains that axis. The polygon tube between two stations is then the
27//!   revolution [`crate::revolve`] bounds, and its triangles are within
28//!   `(R + r)(1 - cos(phi/2))` of it for a step `phi`: the sagitta of the
29//!   outer side of the bend, not of the centreline. Its wall quads are
30//!   planar trapezoids, so their twist adds nothing; the span bound
31//!   [`crate::sweep`] computes, directrix sagitta plus the section's reach
32//!   plus [`crate::loft`]'s quad bound, is exactly that sum here. The step
33//!   halves until every span fits `c/2`; more than 4096 steps on a bend
34//!   is refused with `BudgetExceeded`.
35//!
36//! # Joints
37//!
38//! Consecutive pieces share one station, so the walls meet watertight and
39//! wound one way: the incoming piece's end section. The outgoing piece's
40//! own start frame is that frame turned by the least rotation that takes
41//! the incoming tangent to the outgoing one, moved to its start point, and
42//! its stations follow from it. Where the pieces are tangent continuous
43//! and meet, the two frames coincide. Where they turn by `theta` or leave
44//! a gap `g`, every vertex of the shared station lies at most
45//! `delta <= g + 2 r sin(theta/2)` from the station the outgoing piece's
46//! bound was proved for; moving a triangle's vertices by at most `delta`
47//! moves each of its points by at most `delta` (same barycentric weights),
48//! so the first span of the outgoing piece adds the measured `delta` to its
49//! bound and is refined until the sum fits.
50//!
51//! A swept disk along a directrix that is not tangent continuous is
52//! undefined (the section has no single plane at a corner), so a joint
53//! turning by more than
54//!
55//! `theta_max = 2 asin(c / (8 r))` (pi once `c >= 8 r`)
56//!
57//! is a corner and is refused by name: at that turn the shared section
58//! leans off the outgoing piece's own by a quarter of the budget, and any
59//! larger turn would be a kink no chord budget can hide. A turn at or below
60//! it is tangent continuous at the requested accuracy and costs at most
61//! `c/4` of the directrix's half. A gap above `c/4` is refused too.
62//!
63//! # Fillets
64//!
65//! A fillet radius `f` rounds every corner between two straight segments
66//! (the swept disk's `fillet_radius`): a corner turning by
67//! `theta` is replaced by the arc of radius `f` tangent to both segments,
68//! which cuts `f tan(theta/2)` off each. Segments shortened to nothing are
69//! dropped (two fillets then meet tangentially). Refused by name: a fillet
70//! that needs more of a segment than it has, a corner beside an arc (only
71//! corners between straight segments are rounded), a disk radius at or
72//! above the fillet radius, or any bend radius, whose tube folds through
73//! the bend's axis on its inside (equality is a horn torus, which some
74//! formats permit but which bounds no two-manifold solid), and a reversal.
75
76use axiolid_contracts::{GeomError, GeomResult};
77use axiolid_core::{Point3, Scalar, Vec3};
78use axiolid_mesh::TriMesh;
79
80use crate::loft::{self, Frame, Station};
81use crate::revolve::rotate;
82use crate::sweep;
83
84/// One smooth piece of a swept disk's directrix.
85#[derive(Debug, Clone, Copy, PartialEq)]
86#[non_exhaustive]
87pub enum PathPiece {
88    /// A straight segment from `start` to `end`.
89    Segment {
90        /// First point, in the direction of travel.
91        start: Point3,
92        /// Last point.
93        end: Point3,
94    },
95    /// A circular arc: `start` turned by `angle` radians, right-handed,
96    /// about the line through `centre` along the unit `axis`. `start -
97    /// centre` must be perpendicular to `axis`; a negative `angle` turns
98    /// the other way.
99    Arc {
100        /// Centre of the arc's circle.
101        centre: Point3,
102        /// Unit normal of the arc's plane.
103        axis: Vec3,
104        /// First point, in the direction of travel.
105        start: Point3,
106        /// Signed turn, at most a full turn either way.
107        angle: Scalar,
108    },
109}
110
111impl PathPiece {
112    /// The first point, in the direction of travel.
113    #[must_use]
114    pub fn start_point(&self) -> Point3 {
115        match *self {
116            Self::Segment { start, .. } | Self::Arc { start, .. } => start,
117        }
118    }
119
120    /// The last point, in the direction of travel.
121    #[must_use]
122    pub fn end_point(&self) -> Point3 {
123        self.point(1.0)
124    }
125
126    /// The point a fraction `s` of the way along, by length.
127    #[must_use]
128    pub fn point(&self, s: Scalar) -> Point3 {
129        match *self {
130            Self::Segment { start, end } => start + (end - start) * s,
131            Self::Arc {
132                centre,
133                axis,
134                start,
135                angle,
136            } => rotate(start, centre, axis, angle * s),
137        }
138    }
139
140    /// The unit tangent, in the direction of travel, at `point` on the
141    /// piece; zero for a degenerate piece.
142    fn tangent_at(&self, point: Point3) -> Vec3 {
143        match *self {
144            Self::Segment { start, end } => (end - start).normalize_or_zero(),
145            Self::Arc {
146                centre,
147                axis,
148                angle,
149                ..
150            } => (axis.cross(point - centre) * angle.signum()).normalize_or_zero(),
151        }
152    }
153
154    /// The unit tangent at the start, in the direction of travel.
155    #[must_use]
156    pub fn start_tangent(&self) -> Vec3 {
157        self.tangent_at(self.start_point())
158    }
159
160    /// The unit tangent at the end, in the direction of travel.
161    #[must_use]
162    pub fn end_tangent(&self) -> Vec3 {
163        self.tangent_at(self.end_point())
164    }
165
166    /// The length along the piece.
167    #[must_use]
168    pub fn length(&self) -> Scalar {
169        match *self {
170            Self::Segment { start, end } => (end - start).length(),
171            Self::Arc {
172                centre,
173                start,
174                angle,
175                ..
176            } => (start - centre).length() * angle.abs(),
177        }
178    }
179
180    /// The same piece walked the other way.
181    #[must_use]
182    pub fn reversed(&self) -> Self {
183        match *self {
184            Self::Segment { start, end } => Self::Segment {
185                start: end,
186                end: start,
187            },
188            Self::Arc {
189                centre,
190                axis,
191                angle,
192                ..
193            } => Self::Arc {
194                centre,
195                axis,
196                start: self.end_point(),
197                angle: -angle,
198            },
199        }
200    }
201
202    /// The part between the fractions `from` and `to` of its length, in
203    /// that order (`to < from` walks it backwards).
204    #[must_use]
205    pub fn between(&self, from: Scalar, to: Scalar) -> Self {
206        match *self {
207            Self::Segment { .. } => Self::Segment {
208                start: self.point(from),
209                end: self.point(to),
210            },
211            Self::Arc {
212                centre,
213                axis,
214                angle,
215                ..
216            } => Self::Arc {
217                centre,
218                axis,
219                start: self.point(from),
220                angle: angle * (to - from),
221            },
222        }
223    }
224
225    /// Refuse a piece with no direction or a malformed arc.
226    fn check(&self, radius: Scalar) -> GeomResult<()> {
227        let finite = |p: Point3| p.is_finite();
228        match *self {
229            Self::Segment { start, end } => {
230                if !(finite(start) && finite(end)) {
231                    return Err(GeomError::InvalidInput(
232                        "swept disk directrix segment must be finite".to_owned(),
233                    ));
234                }
235                if start == end {
236                    return Err(GeomError::Degenerate(
237                        "swept disk directrix segment has no length".to_owned(),
238                    ));
239                }
240            }
241            Self::Arc {
242                centre,
243                axis,
244                start,
245                angle,
246            } => {
247                if !(finite(centre) && finite(axis) && finite(start) && angle.is_finite()) {
248                    return Err(GeomError::InvalidInput(
249                        "swept disk directrix arc must be finite".to_owned(),
250                    ));
251                }
252                let bend = (start - centre).length();
253                if (axis.length() - 1.0).abs() > 1e-9
254                    || bend == 0.0
255                    || (start - centre).dot(axis).abs() > 1e-9 * bend
256                {
257                    return Err(GeomError::InvalidInput(
258                        "swept disk directrix arc needs a unit axis normal to a non-zero \
259                         radius"
260                            .to_owned(),
261                    ));
262                }
263                if angle == 0.0 || angle.abs() > core::f64::consts::TAU * (1.0 + 1e-12) {
264                    return Err(GeomError::InvalidInput(format!(
265                        "swept disk directrix arc must turn by a non-zero angle of at most \
266                         a full turn, got {angle}"
267                    )));
268                }
269                if bend <= radius {
270                    return Err(GeomError::InvalidInput(format!(
271                        "swept disk radius {radius} is not below the bend radius {bend}: \
272                         the tube would fold through the bend's axis"
273                    )));
274                }
275            }
276        }
277        Ok(())
278    }
279
280    /// Samples to chord budget `budget`, and the exact unit tangent at
281    /// each: a segment's two ends, or an arc in equal steps whose sagitta
282    /// is within the budget.
283    fn sample(&self, budget: Scalar) -> GeomResult<(Vec<Point3>, Vec<Vec3>)> {
284        let n = match *self {
285            Self::Segment { .. } => 1,
286            Self::Arc {
287                centre,
288                start,
289                angle,
290                ..
291            } => crate::revolve::steps((start - centre).length(), angle, budget).map_err(|_| {
292                GeomError::BudgetExceeded {
293                    resource: "swept disk bend steps",
294                }
295            })?,
296        };
297        let points: Vec<Point3> = (0..=n)
298            .map(|k| self.point(k as Scalar / n as Scalar))
299            .collect();
300        let tangents = points.iter().map(|p| self.tangent_at(*p)).collect();
301        Ok((points, tangents))
302    }
303}
304
305/// The largest turn a joint may make and still be tangent continuous at
306/// chord budget `chord` for a disk of radius `radius`: `2 asin(c / (8 r))`
307/// (see the module notes).
308#[must_use]
309pub fn joint_tolerance(chord: Scalar, radius: Scalar) -> Scalar {
310    2.0 * (chord / (8.0 * radius)).min(1.0).asin()
311}
312
313/// The angle between two unit vectors, accurate when it is small.
314fn turn(a: Vec3, b: Vec3) -> Scalar {
315    a.cross(b).length().atan2(a.dot(b))
316}
317
318/// Sweep a disk, optionally hollow, along a chain of segments and arcs so
319/// that every point of the exact tube lies within `chord` of the mesh.
320///
321/// `fillet_radius` rounds each corner between two segments; see the
322/// module notes for the bound, the joint rule, and what is refused. The
323/// pieces run in the direction of travel, each starting where the last
324/// ended.
325pub fn swept_disk_along_pieces(
326    pieces: &[PathPiece],
327    radius: Scalar,
328    inner_radius: Option<Scalar>,
329    fillet_radius: Option<Scalar>,
330    chord: Scalar,
331) -> GeomResult<TriMesh> {
332    sweep::check_radii(radius, inner_radius)?;
333    if !(chord.is_finite() && chord > 0.0) {
334        return Err(GeomError::InvalidInput(format!(
335            "swept disk chord budget must be positive and finite, got {chord}"
336        )));
337    }
338    if let Some(f) = fillet_radius {
339        if !(f.is_finite() && f > 0.0) {
340            return Err(GeomError::InvalidInput(format!(
341                "swept disk fillet radius must be positive and finite, got {f}"
342            )));
343        }
344    }
345    if pieces.is_empty() {
346        return Err(GeomError::InvalidInput(
347            "a swept disk directrix needs at least one piece".to_owned(),
348        ));
349    }
350    let tolerance = joint_tolerance(chord, radius);
351    let path = match fillet_radius {
352        Some(f) => fillet(pieces, f, radius, tolerance)?,
353        None => pieces.to_vec(),
354    };
355    for piece in &path {
356        piece.check(radius)?;
357    }
358    for (k, pair) in path.windows(2).enumerate() {
359        let theta = turn(pair[0].end_tangent(), pair[1].start_tangent());
360        if theta > tolerance {
361            let advice = if fillet_radius.is_some() {
362                "only corners between two straight segments are filleted"
363            } else {
364                "a swept disk along it is undefined; give a fillet radius or a \
365                 tangent-continuous directrix"
366            };
367            return Err(GeomError::InvalidInput(format!(
368                "swept disk directrix turns a corner of {theta} rad at joint {k}, above \
369                 the {tolerance} rad a tangent-continuous joint may turn at this chord \
370                 budget: {advice}"
371            )));
372        }
373        let gap = (pair[1].start_point() - pair[0].end_point()).length();
374        if gap > 0.25 * chord {
375            return Err(GeomError::InvalidInput(format!(
376                "swept disk directrix pieces leave a {gap} gap at joint {k}, more than a \
377                 quarter of the {chord} chord budget"
378            )));
379        }
380    }
381    let rings = sweep::disk_rings(radius, inner_radius, 0.5 * chord)?;
382    let share = 0.5 * chord;
383    let mut stations: Vec<Station> = Vec::new();
384    let mut last: Option<Frame> = None;
385    for piece in &path {
386        let tangent = piece.start_tangent();
387        // The outgoing piece's own start frame: the incoming end frame
388        // turned by the least rotation onto this tangent.
389        let seed = match &last {
390            None => seed_reference(tangent),
391            Some(frame) => least_rotation(frame.x, frame.x.cross(frame.y), tangent),
392        };
393        let joint = stations.last();
394        let (frames, placed) = bounded_piece(piece, &rings, seed, radius, share, joint)?;
395        let skip = usize::from(joint.is_some());
396        stations.extend(placed.into_iter().skip(skip));
397        last = frames.into_iter().last();
398    }
399    let mut mesh = loft::loft(&rings, &stations, false)?;
400    // As `revolve::revolve` (#221): settle the winding from the built
401    // mesh rather than from the frames' handedness.
402    if matches!(crate::extrude::outward_orientation(&mesh), Some(false)) {
403        for triangle in mesh.indices.chunks_exact_mut(3) {
404            triangle.swap(1, 2);
405        }
406    }
407    Ok(mesh)
408}
409
410/// Rounds of halving a piece's directrix budget before it is refused.
411const MAX_REFINEMENTS: usize = 16;
412
413/// A piece's frames and stations, its directrix sampled until each span's
414/// bound fits `share`; the first span also carries the measured distance
415/// from the shared `joint` station to the piece's own first station.
416fn bounded_piece(
417    piece: &PathPiece,
418    rings: &crate::profile::Rings,
419    seed: Vec3,
420    reach: Scalar,
421    share: Scalar,
422    joint: Option<&Station>,
423) -> GeomResult<(Vec<Frame>, Vec<Station>)> {
424    let mut b = share;
425    for _ in 0..MAX_REFINEMENTS {
426        let (points, tangents) = piece.sample(b)?;
427        let frames = sweep::frames_carried(&points, &tangents, seed)?;
428        let placed: Vec<Station> = frames
429            .iter()
430            .map(|f| loft::place(rings, |p| loft::at(f, p)))
431            .collect();
432        let shift = joint.map_or(0.0, |j| displacement(j, &placed[0]));
433        let worst = frames
434            .windows(2)
435            .zip(placed.windows(2))
436            .enumerate()
437            .map(|(k, (f, s))| {
438                let moved = if k == 0 { shift } else { 0.0 };
439                sweep::span_bound(&f[0], &f[1], b, reach)
440                    + loft::span_deviation(&s[0], &s[1])
441                    + moved
442            })
443            .fold(0.0, Scalar::max);
444        if worst <= share {
445            return Ok((frames, placed));
446        }
447        b *= 0.5;
448    }
449    Err(GeomError::BudgetExceeded {
450        resource: "swept disk directrix refinement",
451    })
452}
453
454/// The largest distance between corresponding vertices of two stations.
455fn displacement(a: &Station, b: &Station) -> Scalar {
456    a.loops
457        .iter()
458        .zip(&b.loops)
459        .flat_map(|(p, q)| p.iter().zip(q).map(|(u, v)| (*u - *v).length()))
460        .fold(0.0, Scalar::max)
461}
462
463/// `v` turned by the least rotation that takes unit `from` to unit `to`.
464fn least_rotation(v: Vec3, from: Vec3, to: Vec3) -> Vec3 {
465    let axis = from.cross(to);
466    let sin = axis.length();
467    if sin == 0.0 {
468        return v;
469    }
470    rotate(v, Point3::ZERO, axis / sin, turn(from, to))
471}
472
473/// Any direction perpendicular to unit `t`.
474fn seed_reference(t: Vec3) -> Vec3 {
475    let candidate = if t.x.abs() < 0.9 { Vec3::X } else { Vec3::Y };
476    candidate - t * t.dot(candidate)
477}
478
479/// Round every corner between two segments that turns by more than
480/// `tolerance` with an arc of radius `fillet` (see the module notes).
481fn fillet(
482    pieces: &[PathPiece],
483    fillet: Scalar,
484    radius: Scalar,
485    tolerance: Scalar,
486) -> GeomResult<Vec<PathPiece>> {
487    // Each segment keeps its direction even when the fillets either side
488    // use it up, so the corner after it is still measured against it.
489    struct Leg {
490        piece: PathPiece,
491        dir: Vec3,
492        trim_start: Scalar,
493        trim_end: Scalar,
494    }
495    let mut legs: Vec<Leg> = Vec::with_capacity(pieces.len());
496    let mut arcs: Vec<Option<PathPiece>> = Vec::with_capacity(pieces.len());
497    for piece in pieces {
498        piece.check(radius)?;
499        legs.push(Leg {
500            piece: *piece,
501            dir: piece.start_tangent(),
502            trim_start: 0.0,
503            trim_end: 0.0,
504        });
505    }
506    for k in 1..legs.len() {
507        let (PathPiece::Segment { .. }, PathPiece::Segment { start: corner, .. }) =
508            (legs[k - 1].piece, legs[k].piece)
509        else {
510            arcs.push(None);
511            continue;
512        };
513        let (u, v) = (legs[k - 1].dir, legs[k].dir);
514        let theta = turn(u, v);
515        if theta <= tolerance {
516            arcs.push(None);
517            continue;
518        }
519        if radius >= fillet {
520            return Err(GeomError::InvalidInput(format!(
521                "swept disk radius {radius} is not below the fillet radius {fillet}: the \
522                 tube would fold through the bend's axis"
523            )));
524        }
525        let normal = u.cross(v);
526        if normal.length() <= 1e-12 {
527            return Err(GeomError::InvalidInput(format!(
528                "swept disk directrix reverses at corner {k}; no fillet rounds it"
529            )));
530        }
531        let cut = fillet * (0.5 * theta).tan();
532        legs[k - 1].trim_end = cut;
533        legs[k].trim_start = cut;
534        let tangent_point = corner - u * cut;
535        let inward = (v - u * u.dot(v)).normalize();
536        arcs.push(Some(PathPiece::Arc {
537            centre: tangent_point + inward * fillet,
538            axis: normal.normalize(),
539            start: tangent_point,
540            angle: theta,
541        }));
542    }
543    let mut out = Vec::with_capacity(2 * legs.len());
544    for (k, leg) in legs.iter().enumerate() {
545        if k > 0 {
546            if let Some(arc) = arcs[k - 1] {
547                out.push(arc);
548            }
549        }
550        let length = leg.piece.length();
551        let needed = leg.trim_start + leg.trim_end;
552        if needed == 0.0 {
553            out.push(leg.piece);
554            continue;
555        }
556        // A fillet using up exactly its segment meets the next one
557        // tangentially; rounding in `tan` may overshoot by an ulp or two.
558        let slack = 1e-9 * length;
559        if needed > length + slack {
560            return Err(GeomError::InvalidInput(format!(
561                "swept disk fillet radius {fillet} does not fit: segment {k} is {length} \
562                 long but its fillets need {needed} of it"
563            )));
564        }
565        if needed < length - slack {
566            out.push(
567                leg.piece
568                    .between(leg.trim_start / length, 1.0 - leg.trim_end / length),
569            );
570        }
571    }
572    Ok(out)
573}
574
575#[cfg(test)]
576mod tests {
577    use super::*;
578
579    #[test]
580    fn a_fillet_is_tangent_to_both_segments() {
581        let corner = Point3::new(1.0, 0.0, 0.0);
582        let pieces = [
583            PathPiece::Segment {
584                start: Point3::ZERO,
585                end: corner,
586            },
587            PathPiece::Segment {
588                start: corner,
589                end: Point3::new(1.0, 1.0, 1.0),
590            },
591        ];
592        let path = fillet(&pieces, 0.2, 0.05, 1e-6).unwrap();
593        assert_eq!(path.len(), 3);
594        for pair in path.windows(2) {
595            assert!((pair[0].end_point() - pair[1].start_point()).length() < 1e-12);
596            assert!(turn(pair[0].end_tangent(), pair[1].start_tangent()) < 1e-12);
597        }
598    }
599
600    #[test]
601    fn an_arc_walked_backwards_retraces_itself() {
602        let arc = PathPiece::Arc {
603            centre: Point3::new(0.0, 1.0, 0.0),
604            axis: Vec3::Z,
605            start: Point3::ZERO,
606            angle: 1.0,
607        };
608        let back = arc.reversed();
609        assert!((back.end_point() - arc.start_point()).length() < 1e-12);
610        assert!((back.start_tangent() + arc.end_tangent()).length() < 1e-12);
611        let part = arc.between(0.25, 0.75);
612        assert!((part.length() - 0.5).abs() < 1e-12);
613        assert!((part.start_point() - arc.point(0.25)).length() < 1e-12);
614    }
615}