axiolid_construct/
center_line_exact.rs

1//! Exact centre-line offsetting (ADR 0056).
2//!
3//! `center_line.rs` offsets the FLATTENED path, which is correct for the
4//! tessellating pipeline but bakes a chord budget into the result. The exact
5//! extruder cannot accept that: flattening here would produce a solid that
6//! closes, audits clean, and is not the requested shape.
7//!
8//! # What can be offset exactly
9//!
10//! Only curve kinds whose offset is the SAME kind, measured rather than
11//! assumed:
12//!
13//! - a line offsets to a parallel line;
14//! - a circular arc offsets to a CONCENTRIC arc of radius `r -/+ d`, keeping
15//!   its centre, frame and parameter domain, so endpoints correspond at equal
16//!   parameters;
17//! - an ellipse does NOT offset to an ellipse (best-fit residual 0.074 for a
18//!   3:1 ellipse at distance 0.3), and neither do splines in general.
19//!
20//! The last group is refused. Sampling it into chords is exactly the silent
21//! approximation this path exists to avoid.
22
23use axiolid_contracts::{GeomError, GeomResult, Operation};
24use axiolid_core::{Interval, Point2, Scalar, Tolerance, Vec2};
25use axiolid_curve::{Circle2, Curve2, Line2};
26use axiolid_profile::{CenterLineProfile, Contour, ContourProfile, ProfileSegment};
27
28use crate::BACKEND_ID;
29
30fn unsupported(input: &'static str) -> GeomError {
31    GeomError::UnsupportedInput {
32        backend: BACKEND_ID,
33        operation: Operation::Sweep,
34        input,
35    }
36}
37
38/// One path segment reduced to what offsetting needs.
39#[derive(Debug, Clone, Copy)]
40enum Piece {
41    Line {
42        from: Point2,
43        to: Point2,
44    },
45    Arc {
46        centre: Point2,
47        x: Vec2,
48        y: Vec2,
49        radius: Scalar,
50        start: Scalar,
51        end: Scalar,
52        /// Signed sweep in WORLD orientation, not parameter orientation.
53        sweep: Scalar,
54    },
55}
56
57impl Piece {
58    fn start_point(&self) -> Point2 {
59        match self {
60            Piece::Line { from, .. } => *from,
61            Piece::Arc {
62                centre,
63                x,
64                y,
65                radius,
66                start,
67                ..
68            } => conic(*centre, *x, *y, *radius, *start),
69        }
70    }
71
72    fn end_point(&self) -> Point2 {
73        match self {
74            Piece::Line { to, .. } => *to,
75            Piece::Arc {
76                centre,
77                x,
78                y,
79                radius,
80                end,
81                ..
82            } => conic(*centre, *x, *y, *radius, *end),
83        }
84    }
85
86    /// Unit tangent at the start, in traversal direction.
87    fn start_tangent(&self) -> Vec2 {
88        match self {
89            Piece::Line { from, to } => (*to - *from).normalize_or_zero(),
90            Piece::Arc {
91                x, y, start, end, ..
92            } => arc_tangent(*x, *y, *start, *end > *start),
93        }
94    }
95
96    /// Unit tangent at the end, in traversal direction.
97    fn end_tangent(&self) -> Vec2 {
98        match self {
99            Piece::Line { from, to } => (*to - *from).normalize_or_zero(),
100            Piece::Arc {
101                x, y, start, end, ..
102            } => arc_tangent(*x, *y, *end, *end > *start),
103        }
104    }
105}
106
107fn conic(centre: Point2, x: Vec2, y: Vec2, radius: Scalar, t: Scalar) -> Point2 {
108    let (s, c) = t.sin_cos();
109    centre + x * (radius * c) + y * (radius * s)
110}
111
112fn arc_tangent(x: Vec2, y: Vec2, t: Scalar, increasing: bool) -> Vec2 {
113    let (s, c) = t.sin_cos();
114    let d = x * -s + y * c;
115    let d = if increasing { d } else { -d };
116    d.normalize_or_zero()
117}
118/// Read one path segment, refusing kinds with no exact offset.
119fn read_piece(segment: &ProfileSegment) -> GeomResult<Piece> {
120    let (from, to) = if segment.same_sense {
121        (segment.domain.start, segment.domain.end)
122    } else {
123        (segment.domain.end, segment.domain.start)
124    };
125    match &segment.curve {
126        Curve2::Line(line) => Ok(Piece::Line {
127            from: line.origin + line.direction * from,
128            to: line.origin + line.direction * to,
129        }),
130        Curve2::Circle(circle) => {
131            let handedness = circle.frame.x.perp_dot(circle.frame.y);
132            if handedness == 0.0 {
133                return Err(GeomError::Degenerate(
134                    "centre-line arc has a degenerate frame".to_owned(),
135                ));
136            }
137            Ok(Piece::Arc {
138                centre: circle.frame.origin,
139                x: circle.frame.x,
140                y: circle.frame.y,
141                radius: circle.radius,
142                start: from,
143                end: to,
144                // World sweep, not parameter sweep: a left-handed frame runs
145                // the parameter backwards, and the offset SIDE depends on the
146                // world turn direction.
147                sweep: (to - from) * handedness.signum(),
148            })
149        }
150        Curve2::Ellipse(_) => Err(unsupported(
151            "centre-line elliptical segment has no elliptical offset",
152        )),
153        _ => Err(unsupported(
154            "centre-line segment of a kind with no exact offset",
155        )),
156    }
157}
158
159/// Offset one piece sideways by a signed distance.
160///
161/// `distance` is positive to the LEFT of the traversal direction.
162fn offset_piece(piece: &Piece, distance: Scalar) -> GeomResult<ProfileSegment> {
163    match piece {
164        Piece::Line { from, to } => {
165            let along = (*to - *from).normalize_or_zero();
166            if along == Vec2::ZERO {
167                return Err(GeomError::Degenerate(
168                    "centre line has a zero-length segment".to_owned(),
169                ));
170            }
171            let normal = Vec2::new(-along.y, along.x);
172            let start = *from + normal * distance;
173            let end = *to + normal * distance;
174            Ok(ProfileSegment {
175                curve: Curve2::Line(Line2 {
176                    origin: start,
177                    direction: end - start,
178                }),
179                domain: Interval::UNIT,
180                same_sense: true,
181            })
182        }
183        Piece::Arc {
184            centre,
185            x,
186            y,
187            radius,
188            start,
189            end,
190            sweep,
191        } => {
192            // Measured: the left normal of a counter-clockwise arc points at
193            // its centre, so the left offset SHRINKS the radius; clockwise
194            // grows it. Centre, frame and domain are unchanged, which is what
195            // makes the offset endpoints correspond at equal parameters.
196            let offset_radius = radius - distance * sweep.signum();
197            if offset_radius <= 0.0 {
198                return Err(GeomError::Degenerate(format!(
199                    "centre-line half-width {} collapses an arc of radius {radius}",
200                    distance.abs()
201                )));
202            }
203            Ok(ProfileSegment {
204                curve: Curve2::Circle(Circle2 {
205                    frame: axiolid_core::Frame2 {
206                        origin: *centre,
207                        x: *x,
208                        y: *y,
209                    },
210                    radius: offset_radius,
211                }),
212                domain: Interval::new(*start, *end),
213                same_sense: true,
214            })
215        }
216    }
217}
218/// Resolve a centre-line profile into an exact closed contour.
219///
220/// The boundary is the left offset walked forward, a butt end cap, the right
221/// offset walked back, and a butt start cap. Butt caps are used because the
222/// source states a width and an extent, not an end treatment: a round or
223/// square cap would add material the author never declared.
224pub fn center_line_contour(
225    profile: &CenterLineProfile,
226    tolerance: Tolerance,
227) -> GeomResult<ContourProfile> {
228    // Explicit non-positive test so a NaN half-width is refused too.
229    if profile.half_width <= 0.0 || profile.half_width.is_nan() {
230        return Err(GeomError::Degenerate(format!(
231            "centre line half-width must be positive, got {}",
232            profile.half_width
233        )));
234    }
235    if profile.path.segments.is_empty() {
236        return Err(GeomError::Degenerate(
237            "centre line path has no segments".to_owned(),
238        ));
239    }
240
241    let pieces = profile
242        .path
243        .segments
244        .iter()
245        .map(read_piece)
246        .collect::<GeomResult<Vec<_>>>()?;
247
248    // The path must be connected and must NOT be closed: a closed path
249    // denotes an annulus, which needs a hole rather than a single ring, and
250    // silently treating it as open would weld the two ends together.
251    let eps = tolerance.linear();
252    for pair in pieces.windows(2) {
253        let gap = (pair[1].start_point() - pair[0].end_point()).length();
254        if gap > eps {
255            return Err(GeomError::InvalidInput(format!(
256                "centre line path is disconnected by {gap}"
257            )));
258        }
259    }
260    let first = pieces.first().expect("at least one segment");
261    let last = pieces.last().expect("at least one segment");
262    if (last.end_point() - first.start_point()).length() <= eps {
263        return Err(unsupported(
264            "closed centre-line path denotes an annulus, not a single ring",
265        ));
266    }
267
268    // A tangent reversal makes the two offsets cross, producing a ring whose
269    // enclosed area depends on where it self-intersects. Refused rather than
270    // emitting a spike.
271    for pair in pieces.windows(2) {
272        let incoming = pair[0].end_tangent();
273        let outgoing = pair[1].start_tangent();
274        if incoming.dot(outgoing) <= -1.0 + 1e-12 {
275            return Err(GeomError::Degenerate(
276                "centre line reverses on itself".to_owned(),
277            ));
278        }
279    }
280
281    let half = profile.half_width;
282    let mut segments = Vec::with_capacity(2 * pieces.len() + 2);
283
284    // RIGHT side forward, then LEFT side back. Verified by shoelace: walking
285    // the left side first traces the boundary clockwise, which builds the
286    // solid inside-out -- the volume comes out correct in magnitude and
287    // negative in sign, so only a SIGNED check catches it.
288    for piece in &pieces {
289        segments.push(offset_piece(piece, -half)?);
290    }
291    // End cap: straight across the width, right to left.
292    let end_centre = last.end_point();
293    let end_normal = left_normal(last.end_tangent());
294    segments.push(straight(
295        end_centre - end_normal * half,
296        end_centre + end_normal * half,
297    ));
298    // Left side, backward: reverse the order AND each segment's sense.
299    for piece in pieces.iter().rev() {
300        let mut offset = offset_piece(piece, half)?;
301        offset.same_sense = !offset.same_sense;
302        segments.push(offset);
303    }
304    // Start cap closes the ring, left to right.
305    let start_centre = first.start_point();
306    let start_normal = left_normal(first.start_tangent());
307    segments.push(straight(
308        start_centre + start_normal * half,
309        start_centre - start_normal * half,
310    ));
311
312    Ok(ContourProfile {
313        outer: Contour::new(segments),
314        holes: Vec::new(),
315    })
316}
317
318fn left_normal(tangent: Vec2) -> Vec2 {
319    Vec2::new(-tangent.y, tangent.x)
320}
321
322fn straight(from: Point2, to: Point2) -> ProfileSegment {
323    ProfileSegment {
324        curve: Curve2::Line(Line2 {
325            origin: from,
326            direction: to - from,
327        }),
328        domain: Interval::UNIT,
329        same_sense: true,
330    }
331}