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}