Skip to main content

axiolid_construct/
swept_disk_exact.rs

1//! Exact swept disks along a straight segment and along a circular arc
2//! (#223).
3//!
4//! # What a swept disk is here
5//!
6//! A disk of radius `r` (optionally with a concentric bore of radius
7//! `r_i`) moved along a directrix, held perpendicular to it. Along a
8//! straight segment that is a circular cylinder capped by two discs: an
9//! exact extrusion of the disk. Along an arc of radius `R` about an axis it
10//! is a torus wedge capped by two discs, or a whole torus for a full
11//! circle: an exact revolution of the disk about the arc's axis. Both are
12//! built in a local frame by the exact extrusion and revolution, then
13//! placed with [`ExactBRep::transformed`], so the walls are genuine
14//! cylinders and tori and the caps genuine planes -- never a tessellation.
15//!
16//! # What is refused
17//!
18//! A disk whose radius reaches the arc's axis (`r >= R`) would sweep
19//! through itself; a bore not strictly inside the disk is not a ring; an
20//! arc beyond a full turn sweeps through itself. All are refused by name
21//! or as invalid input. A directrix with corners, or a curved directrix
22//! other than an arc, is the compiler's to refuse: these functions take a
23//! single segment or arc.
24
25use std::f64::consts::TAU;
26
27use axiolid_brep::{ExactBRep, TransformError};
28use axiolid_contracts::{GeomError, GeomResult, Operation};
29use axiolid_core::{Interval, Mat3, Point3, Scalar, Tolerance, Transform3, Vec3};
30use axiolid_curve::Circle3;
31use axiolid_profile::{CircleProfile, Profile};
32
33use crate::extrude_exact::extrude_profile_exact;
34use crate::revolve_exact::revolve_profile_exact;
35use crate::BACKEND_ID;
36
37fn unsupported(input: &'static str) -> GeomError {
38    GeomError::UnsupportedInput {
39        backend: BACKEND_ID,
40        operation: Operation::Sweep,
41        input,
42    }
43}
44
45/// The disk as a profile in its local plane, centred at the origin.
46fn disk(radius: Scalar, inner_radius: Option<Scalar>) -> GeomResult<Profile> {
47    if !radius.is_finite() || radius <= 0.0 {
48        return Err(GeomError::InvalidInput(format!(
49            "swept disk radius must be positive and finite, got {radius}"
50        )));
51    }
52    match inner_radius {
53        // A bore of radius zero is no bore.
54        None | Some(0.0) => Ok(Profile::Circle(CircleProfile {
55            radius,
56            thickness: None,
57        })),
58        Some(inner) if inner.is_finite() && inner > 0.0 && inner < radius => {
59            Ok(Profile::Circle(CircleProfile {
60                radius,
61                thickness: Some(radius - inner),
62            }))
63        }
64        Some(inner) => Err(GeomError::InvalidInput(format!(
65            "swept disk inner radius must lie in [0, {radius}), got {inner}"
66        ))),
67    }
68}
69
70/// Place a B-rep built in a local frame; the frames here are orthonormal by
71/// construction, so a refusal names the support that could not move.
72pub(crate) fn place(solid: &ExactBRep, transform: &Transform3) -> GeomResult<ExactBRep> {
73    solid.transformed(transform).map_err(|error| match error {
74        TransformError::Unsupported(what) => unsupported(what),
75        other => GeomError::InvalidInput(format!("swept disk placement: {other}")),
76    })
77}
78
79/// A unit vector perpendicular to the unit `d`, chosen from the coordinate
80/// axis least aligned with it so the choice is deterministic and well
81/// conditioned.
82fn perpendicular(d: Vec3) -> Vec3 {
83    let a = d.abs();
84    let helper = if a.x <= a.y && a.x <= a.z {
85        Vec3::X
86    } else if a.y <= a.z {
87        Vec3::Y
88    } else {
89        Vec3::Z
90    };
91    (helper - d * helper.dot(d)).normalize()
92}
93
94/// A disk of `radius`, with an optional bore of `inner_radius`, swept along
95/// the straight segment from `start` to `end`: an exact capped cylinder
96/// (#223).
97///
98/// # Errors
99///
100/// Invalid input for a non-positive radius, a bore not strictly inside the
101/// disk, and a segment shorter than the tolerance or not finite.
102pub fn swept_disk_along_line_exact(
103    start: Point3,
104    end: Point3,
105    radius: Scalar,
106    inner_radius: Option<Scalar>,
107    tolerance: Tolerance,
108) -> GeomResult<ExactBRep> {
109    let profile = disk(radius, inner_radius)?;
110    let along = end - start;
111    let length = along.length();
112    if !length.is_finite() || length <= tolerance.linear() {
113        return Err(GeomError::InvalidInput(format!(
114            "swept disk segment must be finite and longer than the tolerance, got {length}"
115        )));
116    }
117    let z = along / length;
118    let x = perpendicular(z);
119    let y = z.cross(x);
120    // A hollow disk extrudes through the composite path, which builds the
121    // bore as an inner wall; the dedicated circle path takes a full disk.
122    let profile = match profile {
123        hollow @ Profile::Circle(CircleProfile {
124            thickness: Some(_), ..
125        }) => Profile::Composite(vec![hollow]),
126        full => full,
127    };
128    let local = extrude_profile_exact(&profile, Vec3::Z, length, tolerance)?;
129    place(
130        &local,
131        &Transform3::from_mat3_translation(Mat3::from_cols(x, y, z), start),
132    )
133}
134
135/// A disk of `radius`, with an optional bore of `inner_radius`, swept along
136/// `arc` over the angle span `span` (radians, start to end, in `arc`'s own
137/// parameter): an exact torus wedge capped by two discs, or a whole torus
138/// for a span of a full turn (#223).
139///
140/// The disk is perpendicular to the arc at every point; the arc's frame
141/// must be orthonormal.
142///
143/// # Errors
144///
145/// Invalid input for a non-positive radius, a bore not strictly inside the
146/// disk, a zero or non-finite span, and a non-orthonormal arc frame;
147/// refused by name for a span beyond a full turn and a disk reaching the
148/// arc's axis.
149pub fn swept_disk_along_arc_exact(
150    arc: &Circle3,
151    span: Interval,
152    radius: Scalar,
153    inner_radius: Option<Scalar>,
154    tolerance: Tolerance,
155) -> GeomResult<ExactBRep> {
156    let profile = disk(radius, inner_radius)?;
157    let bend = arc.radius;
158    if !bend.is_finite() || bend <= 0.0 {
159        return Err(GeomError::InvalidInput(format!(
160            "swept disk arc radius must be positive and finite, got {bend}"
161        )));
162    }
163    let angle = span.end - span.start;
164    if !angle.is_finite() || angle == 0.0 {
165        return Err(GeomError::InvalidInput(format!(
166            "swept disk arc span must be finite and non-zero, got {angle}"
167        )));
168    }
169    if angle.abs() > TAU + tolerance.linear() {
170        return Err(unsupported("swept disk along an arc beyond a full turn"));
171    }
172    if radius >= bend - tolerance.linear() {
173        return Err(unsupported("swept disk reaching its arc's axis"));
174    }
175    let frame = &arc.frame;
176    let axes = [frame.x, frame.y, frame.z];
177    let orthonormal = axes.iter().all(|a| a.is_finite())
178        && frame.origin.is_finite()
179        && (0..3).all(|i| {
180            (i..3).all(|j| {
181                let want = if i == j { 1.0 } else { 0.0 };
182                (axes[i].dot(axes[j]) - want).abs() <= 1e-12
183            })
184        })
185        && (frame.x.cross(frame.y) - frame.z).length() <= 1e-12;
186    if !orthonormal {
187        return Err(GeomError::InvalidInput(
188            "swept disk arc frame must be a right-handed orthonormal frame".to_owned(),
189        ));
190    }
191
192    // Local frame: the disk in the plane z = 0 centred at the origin, the
193    // axis the line x = -R along +y. A turn by `phi` about +y carries the
194    // centre to `A + R (cos phi, 0, -sin phi)`.
195    let local = revolve_profile_exact(
196        &profile,
197        Point3::new(-bend, 0.0, 0.0),
198        Vec3::Y,
199        angle,
200        tolerance,
201    )?;
202    // World: local x to the radial direction at the start, local y to the
203    // arc's normal, local z against the arc's tangent there, so a turn by
204    // `phi` about the normal follows the arc from `span.start`.
205    let (sin, cos) = span.start.sin_cos();
206    let radial = frame.x * cos + frame.y * sin;
207    let tangent = frame.z.cross(radial);
208    let rotation = Mat3::from_cols(radial, frame.z, -tangent);
209    let translation = frame.origin + radial * bend;
210    place(
211        &local,
212        &Transform3::from_mat3_translation(rotation, translation),
213    )
214}