Skip to main content

axiolid_construct/
contour_lower.rs

1//! Lower an arbitrary contour profile onto the arc-ring extruder (ADR 0053).
2//!
3//! # Why this is an adapter, not new geometry
4//!
5//! [`ContourProfile`](axiolid_profile::ContourProfile) carries segments of
6//! any [`Curve2`] kind. The arc-ring
7//! extruder already builds exact walls for the two kinds that matter here --
8//! `Line2` becomes a planar wall, `Circle2` becomes a cylindrical one -- so
9//! lowering a contour to an [`ArcRing`] is a translation, not a construction.
10//!
11//! Anything else is REFUSED rather than sampled. Tessellating an ellipse or a
12//! spline into short chords would produce a solid that looks right, passes
13//! every closure check, and silently is not the requested shape.
14//!
15//! # Closure at `Tolerance::ZERO` (#250)
16//!
17//! A ring is a cycle of vertices, each carrying the bulge of the edge that
18//! leaves it, so it is closed by construction: the vertex at a joint is ONE
19//! value, the end of the edge entering it and the start of the edge leaving
20//! it. It is the leaving segment's start -- a line's stored origin bit for
21//! bit, an arc's circle at its first parameter -- and nothing is averaged
22//! or moved onto it.
23//!
24//! The segments themselves cannot meet exactly in floating point, however
25//! carefully the producer shares its points (the section router computes
26//! every corner and tangent point once and hands that one value to both
27//! segments). A line stored as `origin + t direction` ends at
28//! `origin + direction`, which rounds, and its stored direction is already
29//! the rounded difference of the two points; an arc ends at the `cos` and
30//! `sin` of a transcendental sweep. No choice of `Line2` or `Circle2` makes
31//! both ends of every segment bit-exact. So the joint check compares the
32//! entering segment's evaluated end with the leaving segment's start
33//! against the larger of the caller's tolerance and the rounding of those
34//! two evaluations: eight machine epsilons of the magnitudes they are
35//! computed from, a few ulps of the coordinates. That is what lets a
36//! section with non-dyadic sizes (IPE 300 left 2.6e-18 at the flange tip)
37//! lower at `Tolerance::ZERO`, while a contour open by more than its own
38//! rounding is still refused there. The check only decides WHETHER a ring
39//! is built, never what it is: a ring that lowered before is bit-identical.
40
41use axiolid_contracts::{GeomError, GeomResult, Operation};
42use axiolid_core::{Point2, Scalar, Tolerance};
43use axiolid_curve::{Circle2, Curve2};
44use axiolid_overlay::{ArcRing, ArcVertex};
45use axiolid_profile::{Contour, ProfileSegment};
46
47/// Convert one closed contour into an arc ring.
48///
49/// Returns the ring in contour order, one vertex per joint. Segment
50/// endpoints must already meet, within `tolerance` or within the rounding of
51/// their own evaluation, whichever is larger (see the module notes on
52/// `Tolerance::ZERO`); gaps are reported rather than closed, because a
53/// silently bridged gap changes the profile the caller asked for.
54pub fn contour_to_arc_ring(contour: &Contour, tolerance: Tolerance) -> GeomResult<ArcRing> {
55    if contour.segments.len() < 2 {
56        return Err(GeomError::InvalidInput(format!(
57            "a closed contour needs at least two segments, got {}",
58            contour.segments.len()
59        )));
60    }
61
62    let mut vertices = Vec::with_capacity(contour.segments.len());
63    let mut previous_end: Option<(Point2, Scalar)> = None;
64    let mut first_scale = 0.0;
65
66    for segment in &contour.segments {
67        let lowered = lower_segment(segment)?;
68        let start = lowered.vertices[0].point;
69        if let Some((previous, previous_scale)) = previous_end {
70            let gap = (start - previous).length();
71            if gap > joint_slack(tolerance, previous_scale, lowered.start_scale) {
72                return Err(GeomError::InvalidInput(format!(
73                    "contour segments leave a gap of {gap} at {start:?}"
74                )));
75            }
76        } else {
77            first_scale = lowered.start_scale;
78        }
79        vertices.extend(lowered.vertices);
80        previous_end = Some((lowered.end, lowered.end_scale));
81    }
82
83    // The contour must close back onto its own first vertex.
84    if let (Some((last, last_scale)), Some(first)) = (previous_end, vertices.first()) {
85        let gap = (first.point - last).length();
86        if gap > joint_slack(tolerance, last_scale, first_scale) {
87            return Err(GeomError::InvalidInput(format!(
88                "contour does not close: {gap} from last segment end to start"
89            )));
90        }
91    }
92
93    Ok(ArcRing::new(vertices))
94}
95
96/// Rounding of one evaluated segment endpoint, per unit of the magnitudes
97/// it is computed from.
98///
99/// A line's end `origin + direction t` rounds twice (product and sum), and
100/// its stored direction is already the rounded difference of the two points
101/// the producer shared. An arc's end `centre + x r cos + y r sin` adds the
102/// `sin`/`cos` error and frame axes that are unit only to an ulp or two
103/// after normalisation. Each step is at most half an ulp of what it
104/// touches, so eight machine epsilons (~1.8e-15) of the operand magnitudes
105/// bound one side of a joint with room; a contour placed by an affine map
106/// adds the map's own few roundings and stays inside it. A joint closer
107/// than that is the representation's rounding, not a gap anyone drew.
108const JOINT_ROUNDING: Scalar = 8.0 * Scalar::EPSILON;
109
110/// The largest gap a joint may show: the caller's tolerance, or the
111/// rounding of the two endpoint evaluations that meet there when that is
112/// larger (at `Tolerance::ZERO`, or a tolerance below an ulp of the
113/// coordinates).
114fn joint_slack(tolerance: Tolerance, entering: Scalar, leaving: Scalar) -> Scalar {
115    tolerance
116        .linear()
117        .max(JOINT_ROUNDING * (entering + leaving))
118}
119
120/// One segment lowered: the ring vertices it contributes (each with the
121/// bulge of the edge leaving it), its evaluated end point, and the
122/// magnitudes its start and end are evaluated from, which bound their
123/// rounding (see `JOINT_ROUNDING`).
124struct LoweredSegment {
125    vertices: Vec<ArcVertex>,
126    end: Point2,
127    start_scale: Scalar,
128    end_scale: Scalar,
129}
130
131/// One segment as the ring vertices it contributes and its end point.
132fn lower_segment(segment: &ProfileSegment) -> GeomResult<LoweredSegment> {
133    let (from, to) = if segment.same_sense {
134        (segment.domain.start, segment.domain.end)
135    } else {
136        (segment.domain.end, segment.domain.start)
137    };
138
139    match &segment.curve {
140        Curve2::Line(line) => {
141            let start = line.origin + line.direction * from;
142            let end = line.origin + line.direction * to;
143            let origin = line.origin.length();
144            let direction = line.direction.length();
145            Ok(LoweredSegment {
146                vertices: vec![ArcVertex {
147                    point: start,
148                    bulge: 0.0,
149                }],
150                end,
151                start_scale: origin + direction * from.abs(),
152                end_scale: origin + direction * to.abs(),
153            })
154        }
155        Curve2::Circle(circle) => {
156            let (vertices, end) = lower_arc(circle, from, to)?;
157            let scale = circle.frame.origin.length() + circle.radius.abs();
158            Ok(LoweredSegment {
159                vertices,
160                end,
161                start_scale: scale,
162                end_scale: scale,
163            })
164        }
165        other => Err(GeomError::UnsupportedInput {
166            backend: crate::BACKEND_ID,
167            operation: Operation::Sweep,
168            input: unsupported_curve_name(other),
169        }),
170    }
171}
172
173/// A circular segment as ring vertices with the bulges the extruder
174/// expects, and its end point.
175///
176/// `bulge` is `tan(sweep / 4)` for the SIGNED sweep measured in world
177/// orientation. The frame's handedness matters: a left-handed frame runs the
178/// parameter backwards relative to the plane, so the world sweep is the
179/// negation of the parameter sweep. Ignoring that flips the arc onto the
180/// wrong side of its chord.
181///
182/// A bulge determines its arc only below half a turn, so a segment sweeping
183/// half a turn or more is split into `k` equal sub-arcs, each below half a
184/// turn: an IFC arch is commonly one semicircle (ADR 0053, amended by
185/// #228). Each split vertex is the circle evaluated at its parameter,
186/// exactly as the segment's own ends are, so every sub-arc carries the same
187/// circle to the same rounding an unsplit arc does (a few ulps of the
188/// circle's coordinates); nothing is fitted. A segment sweeping more than a
189/// whole turn overlaps itself and is refused.
190fn lower_arc(circle: &Circle2, from: Scalar, to: Scalar) -> GeomResult<(Vec<ArcVertex>, Point2)> {
191    let evaluate = |t: Scalar| {
192        let (sin, cos) = t.sin_cos();
193        circle.frame.origin
194            + circle.frame.x * (circle.radius * cos)
195            + circle.frame.y * (circle.radius * sin)
196    };
197    let start = evaluate(from);
198    let end = evaluate(to);
199
200    let handedness = circle.frame.x.perp_dot(circle.frame.y);
201    if handedness == 0.0 {
202        return Err(GeomError::Degenerate(
203            "circular profile segment has a degenerate frame".to_owned(),
204        ));
205    }
206    let sweep = (to - from) * handedness.signum();
207    if sweep == 0.0 {
208        return Err(GeomError::Degenerate(
209            "circular profile segment has an empty parameter range".to_owned(),
210        ));
211    }
212    if sweep.abs() > core::f64::consts::TAU {
213        return Err(GeomError::UnsupportedInput {
214            backend: crate::BACKEND_ID,
215            operation: Operation::Sweep,
216            input: "circular profile segment sweeping more than a whole turn",
217        });
218    }
219    // Equal sub-arcs, each below half a turn.
220    let pieces = (sweep.abs() / core::f64::consts::PI).floor() as usize + 1;
221    let bulge = (sweep / (4.0 * pieces as Scalar)).tan();
222    let vertices = (0..pieces)
223        .map(|k| ArcVertex {
224            point: if k == 0 {
225                start
226            } else {
227                evaluate(from + (to - from) * k as Scalar / pieces as Scalar)
228            },
229            bulge,
230        })
231        .collect();
232    Ok((vertices, end))
233}
234
235fn unsupported_curve_name(curve: &Curve2) -> &'static str {
236    match curve {
237        Curve2::Ellipse(_) => "elliptical contour segment",
238        Curve2::Polyline(_) => "polyline contour segment",
239        Curve2::BSpline(_) => "B-spline contour segment",
240        Curve2::Intrinsic(_) => "intrinsic contour segment",
241        Curve2::Chain(_) => "arc-length chain contour segment",
242        _ => "contour segment of an unsupported curve kind",
243    }
244}
245
246/// Signed area of a bulge-encoded ring; positive when counter-clockwise.
247///
248/// The polygon shoelace over the vertices plus each arc's circular-segment
249/// term, so a ring whose shape is decided by its arcs is classified by its
250/// actual geometry rather than by its chords. A crescent thin enough that its
251/// chord polygon winds the other way would otherwise be misread.
252pub fn arc_ring_signed_area(ring: &ArcRing) -> Scalar {
253    let count = ring.vertices.len();
254    let mut total = 0.0;
255    for index in 0..count {
256        let here = ring.vertices[index];
257        let next = ring.vertices[(index + 1) % count];
258        total += here.point.perp_dot(next.point);
259        if here.bulge != 0.0 {
260            let sweep = 4.0 * here.bulge.atan();
261            let chord = (next.point - here.point).length();
262            let half = (sweep.abs() / 2.0).sin();
263            if half > 0.0 {
264                let radius = chord / (2.0 * half);
265                total += radius * radius * (sweep - sweep.sin());
266            }
267        }
268    }
269    total / 2.0
270}
271
272/// Reverse an arc ring in place, preserving its arcs.
273///
274/// Reversing the vertex order alone is NOT enough: a bulge belongs to the
275/// edge LEAVING its vertex, so after reversal each vertex must take the
276/// negated bulge of what was previously its predecessor. Getting this wrong
277/// flips every arc to the wrong side while the ring still closes.
278pub fn reverse_arc_ring(ring: &ArcRing) -> ArcRing {
279    let count = ring.vertices.len();
280    let mut vertices = Vec::with_capacity(count);
281    for index in (0..count).rev() {
282        let previous = (index + count - 1) % count;
283        vertices.push(ArcVertex {
284            point: ring.vertices[index].point,
285            bulge: -ring.vertices[previous].bulge,
286        });
287    }
288    ArcRing { vertices }
289}
290
291/// Force a ring to the winding a boundary role requires.
292///
293/// Outer boundaries run counter-clockwise and holes run clockwise, so that a
294/// ring's wall normals point out of the material in both cases.
295pub fn orient_arc_ring(ring: &ArcRing, counter_clockwise: bool) -> GeomResult<ArcRing> {
296    let area = arc_ring_signed_area(ring);
297    if area == 0.0 {
298        return Err(GeomError::Degenerate(
299            "contour ring encloses no area".to_owned(),
300        ));
301    }
302    if (area > 0.0) == counter_clockwise {
303        Ok(ring.clone())
304    } else {
305        Ok(reverse_arc_ring(ring))
306    }
307}