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
15use axiolid_contracts::{GeomError, GeomResult, Operation};
16use axiolid_core::{Point2, Scalar, Tolerance};
17use axiolid_curve::{Circle2, Curve2};
18use axiolid_overlay::{ArcRing, ArcVertex};
19use axiolid_profile::{Contour, ProfileSegment};
20
21/// Convert one closed contour into an arc ring.
22///
23/// Returns the ring in contour order. Segment endpoints must already meet;
24/// gaps are reported rather than closed, because a silently bridged gap
25/// changes the profile the caller asked for.
26pub fn contour_to_arc_ring(contour: &Contour, tolerance: Tolerance) -> GeomResult<ArcRing> {
27    if contour.segments.len() < 2 {
28        return Err(GeomError::InvalidInput(format!(
29            "a closed contour needs at least two segments, got {}",
30            contour.segments.len()
31        )));
32    }
33
34    let mut vertices = Vec::with_capacity(contour.segments.len());
35    let mut previous_end: Option<Point2> = None;
36
37    for segment in &contour.segments {
38        let (start, end, bulge) = lower_segment(segment)?;
39        if let Some(previous) = previous_end {
40            let gap = (start - previous).length();
41            if gap > tolerance.linear() {
42                return Err(GeomError::InvalidInput(format!(
43                    "contour segments leave a gap of {gap} at {start:?}"
44                )));
45            }
46        }
47        vertices.push(ArcVertex {
48            point: start,
49            bulge,
50        });
51        previous_end = Some(end);
52    }
53
54    // The contour must close back onto its own first vertex.
55    if let (Some(last), Some(first)) = (previous_end, vertices.first()) {
56        let gap = (first.point - last).length();
57        if gap > tolerance.linear() {
58            return Err(GeomError::InvalidInput(format!(
59                "contour does not close: {gap} from last segment end to start"
60            )));
61        }
62    }
63
64    Ok(ArcRing::new(vertices))
65}
66
67/// One segment as (start, end, bulge of the edge leaving start).
68fn lower_segment(segment: &ProfileSegment) -> GeomResult<(Point2, Point2, Scalar)> {
69    let (from, to) = if segment.same_sense {
70        (segment.domain.start, segment.domain.end)
71    } else {
72        (segment.domain.end, segment.domain.start)
73    };
74
75    match &segment.curve {
76        Curve2::Line(line) => {
77            let start = line.origin + line.direction * from;
78            let end = line.origin + line.direction * to;
79            Ok((start, end, 0.0))
80        }
81        Curve2::Circle(circle) => lower_arc(circle, from, to),
82        other => Err(GeomError::UnsupportedInput {
83            backend: crate::BACKEND_ID,
84            operation: Operation::Sweep,
85            input: unsupported_curve_name(other),
86        }),
87    }
88}
89
90/// A circular segment as start, end, and the bulge the extruder expects.
91///
92/// `bulge` is `tan(sweep / 4)` for the SIGNED sweep measured in world
93/// orientation. The frame's handedness matters: a left-handed frame runs the
94/// parameter backwards relative to the plane, so the world sweep is the
95/// negation of the parameter sweep. Ignoring that flips the arc onto the
96/// wrong side of its chord.
97fn lower_arc(circle: &Circle2, from: Scalar, to: Scalar) -> GeomResult<(Point2, Point2, Scalar)> {
98    let evaluate = |t: Scalar| {
99        let (sin, cos) = t.sin_cos();
100        circle.frame.origin
101            + circle.frame.x * (circle.radius * cos)
102            + circle.frame.y * (circle.radius * sin)
103    };
104    let start = evaluate(from);
105    let end = evaluate(to);
106
107    let handedness = circle.frame.x.perp_dot(circle.frame.y);
108    if handedness == 0.0 {
109        return Err(GeomError::Degenerate(
110            "circular profile segment has a degenerate frame".to_owned(),
111        ));
112    }
113    let sweep = (to - from) * handedness.signum();
114    if sweep == 0.0 {
115        return Err(GeomError::Degenerate(
116            "circular profile segment has an empty parameter range".to_owned(),
117        ));
118    }
119    // A single bulge cannot express a half turn or more: the tangent blows up
120    // at pi and the chord no longer determines the arc.
121    if sweep.abs() >= core::f64::consts::PI {
122        return Err(GeomError::UnsupportedInput {
123            backend: crate::BACKEND_ID,
124            operation: Operation::Sweep,
125            input: "circular profile segment sweeping half a turn or more",
126        });
127    }
128    Ok((start, end, (sweep / 4.0).tan()))
129}
130
131fn unsupported_curve_name(curve: &Curve2) -> &'static str {
132    match curve {
133        Curve2::Ellipse(_) => "elliptical contour segment",
134        Curve2::Polyline(_) => "polyline contour segment",
135        Curve2::BSpline(_) => "B-spline contour segment",
136        Curve2::Intrinsic(_) => "intrinsic contour segment",
137        _ => "contour segment of an unsupported curve kind",
138    }
139}
140
141/// Signed area of a bulge-encoded ring; positive when counter-clockwise.
142///
143/// The polygon shoelace over the vertices plus each arc's circular-segment
144/// term, so a ring whose shape is decided by its arcs is classified by its
145/// actual geometry rather than by its chords. A crescent thin enough that its
146/// chord polygon winds the other way would otherwise be misread.
147pub fn arc_ring_signed_area(ring: &ArcRing) -> Scalar {
148    let count = ring.vertices.len();
149    let mut total = 0.0;
150    for index in 0..count {
151        let here = ring.vertices[index];
152        let next = ring.vertices[(index + 1) % count];
153        total += here.point.perp_dot(next.point);
154        if here.bulge != 0.0 {
155            let sweep = 4.0 * here.bulge.atan();
156            let chord = (next.point - here.point).length();
157            let half = (sweep.abs() / 2.0).sin();
158            if half > 0.0 {
159                let radius = chord / (2.0 * half);
160                total += radius * radius * (sweep - sweep.sin());
161            }
162        }
163    }
164    total / 2.0
165}
166
167/// Reverse an arc ring in place, preserving its arcs.
168///
169/// Reversing the vertex order alone is NOT enough: a bulge belongs to the
170/// edge LEAVING its vertex, so after reversal each vertex must take the
171/// negated bulge of what was previously its predecessor. Getting this wrong
172/// flips every arc to the wrong side while the ring still closes.
173pub fn reverse_arc_ring(ring: &ArcRing) -> ArcRing {
174    let count = ring.vertices.len();
175    let mut vertices = Vec::with_capacity(count);
176    for index in (0..count).rev() {
177        let previous = (index + count - 1) % count;
178        vertices.push(ArcVertex {
179            point: ring.vertices[index].point,
180            bulge: -ring.vertices[previous].bulge,
181        });
182    }
183    ArcRing { vertices }
184}
185
186/// Force a ring to the winding a boundary role requires.
187///
188/// Outer boundaries run counter-clockwise and holes run clockwise, so that a
189/// ring's wall normals point out of the material in both cases.
190pub fn orient_arc_ring(ring: &ArcRing, counter_clockwise: bool) -> GeomResult<ArcRing> {
191    let area = arc_ring_signed_area(ring);
192    if area == 0.0 {
193        return Err(GeomError::Degenerate(
194            "contour ring encloses no area".to_owned(),
195        ));
196    }
197    if (area > 0.0) == counter_clockwise {
198        Ok(ring.clone())
199    } else {
200        Ok(reverse_arc_ring(ring))
201    }
202}