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//! A `Polyline2` segment is exact too: one straight vertex per polyline
12//! vertex it passes (see `lower_polyline`).
13//!
14//! Anything else is REFUSED rather than sampled. Tessellating an ellipse or a
15//! spline into short chords would produce a solid that looks right, passes
16//! every closure check, and silently is not the requested shape.
17//!
18//! # Closure at `Tolerance::ZERO` (#250)
19//!
20//! A ring is a cycle of vertices, each carrying the bulge of the edge that
21//! leaves it, so it is closed by construction: the vertex at a joint is ONE
22//! value, the end of the edge entering it and the start of the edge leaving
23//! it. It is the leaving segment's start -- a line's stored origin bit for
24//! bit, an arc's circle at its first parameter -- and nothing is averaged
25//! or moved onto it.
26//!
27//! The segments themselves cannot meet exactly in floating point, however
28//! carefully the producer shares its points (the section router computes
29//! every corner and tangent point once and hands that one value to both
30//! segments). A line stored as `origin + t direction` ends at
31//! `origin + direction`, which rounds, and its stored direction is already
32//! the rounded difference of the two points; an arc ends at the `cos` and
33//! `sin` of a transcendental sweep. No choice of `Line2` or `Circle2` makes
34//! both ends of every segment bit-exact. So the joint check compares the
35//! entering segment's evaluated end with the leaving segment's start
36//! against the larger of the caller's tolerance and the rounding of those
37//! two evaluations: eight machine epsilons of the magnitudes they are
38//! computed from, a few ulps of the coordinates. That is what lets a
39//! section with non-dyadic sizes (IPE 300 left 2.6e-18 at the flange tip)
40//! lower at `Tolerance::ZERO`, while a contour open by more than its own
41//! rounding is still refused there. The check only decides WHETHER a ring
42//! is built, never what it is: a ring that lowered before is bit-identical,
43//! except at the vertices of an arc that land on a quarter turn (#379), below.
44//!
45//! # Quarter turns evaluate exactly (#379)
46//!
47//! At a quadrant parameter an arc's `cos` or `sin` is the rounding of the
48//! parameter, not zero: for every form a producer writes (`FRAC_PI_2`,
49//! `-PI / 2`, `1.5 * PI`, `PI + FRAC_PI_2`, `TAU - FRAC_PI_2`, ...)
50//! `|cos t|` is `0.18 * t * EPSILON` at the quadrants of a turn or two and up
51//! to `0.55 * t * EPSILON` (at `20.5 * PI`). That is the evaluation's own
52//! error, but it decides whether an arc's end lies on a revolution axis
53//! through its circle's centre, which the exact revolution tests with an
54//! exact sign. A parameter within `QUADRANT_ROUNDING * max(1, |t|)` of a
55//! quarter turn therefore evaluates `sin` and `cos` as exactly 0 and +-1.
56//! This moves a point by less than the error of the point itself, and
57//! names no tolerance: a parameter further from the quadrant, by 1e-12
58//! say, is its own angle. It is a rounding gate like the joint rule above,
59//! never reported. The vertices it moves are the arc's ends and any split
60//! vertex (a sweep of a half turn or more is split) that lands on a quadrant;
61//! the joint rule reads the arc's radius times `max(1, |t|)` in its scale
62//! (`endpoint_scale`), since the snap and an unsnapped `sin_cos` of a large
63//! parameter differ by that much.
64
65use axiolid_contracts::{GeomError, GeomResult, Operation};
66use axiolid_core::{Point2, Scalar, Tolerance};
67use axiolid_curve::{Circle2, Curve2, Polyline2};
68use axiolid_overlay::{ArcRing, ArcVertex};
69use axiolid_profile::{Contour, ProfileSegment};
70
71/// Convert one closed contour into an arc ring.
72///
73/// Returns the ring in contour order, one vertex per joint. Segment
74/// endpoints must already meet, within `tolerance` or within the rounding of
75/// their own evaluation, whichever is larger (see the module notes on
76/// `Tolerance::ZERO`); gaps are reported rather than closed, because a
77/// silently bridged gap changes the profile the caller asked for.
78pub fn contour_to_arc_ring(contour: &Contour, tolerance: Tolerance) -> GeomResult<ArcRing> {
79    lower_contour(contour, tolerance)
80        .map(|lowered| lowered.ring)
81        .map_err(ContourFault::into_geom_error)
82}
83
84/// A contour lowered to its arc ring, with what a validator needs to map the
85/// ring back to the contour (#352).
86#[derive(Debug, Clone)]
87pub(crate) struct LoweredContour {
88    pub(crate) ring: ArcRing,
89    /// The contour segment each ring edge came from: a segment sweeping half
90    /// a turn or more contributes several consecutive edges.
91    pub(crate) segment_of_edge: Vec<usize>,
92    /// The largest joint gap the lowering closed, closing joint included.
93    pub(crate) max_gap: Scalar,
94}
95
96/// Why a contour does not lower, with the segment at fault.
97#[derive(Debug, Clone, PartialEq)]
98pub(crate) enum ContourFault {
99    /// A ring needs two vertices; `count` segments lowered to `vertices`.
100    TooFewSegments {
101        count: usize,
102        vertices: usize,
103    },
104    UnsupportedSegment {
105        segment: usize,
106        kind: &'static str,
107    },
108    DegenerateSegment {
109        segment: usize,
110        reason: &'static str,
111    },
112    /// The end of `segment` and the start of the next are `gap` apart, beyond
113    /// the slack. `closing` is the joint from the last segment to the first.
114    Gap {
115        segment: usize,
116        gap: Scalar,
117        at: Point2,
118        closing: bool,
119    },
120}
121
122impl ContourFault {
123    /// The error `contour_to_arc_ring` has always returned for this fault.
124    fn into_geom_error(self) -> GeomError {
125        match self {
126            Self::TooFewSegments { count, vertices } => GeomError::InvalidInput(format!(
127                "a closed contour needs at least two ring vertices, its {count} segment(s) give {vertices}"
128            )),
129            Self::UnsupportedSegment { kind, .. } => GeomError::UnsupportedInput {
130                backend: crate::BACKEND_ID,
131                operation: Operation::Sweep,
132                input: kind,
133            },
134            Self::DegenerateSegment { reason, .. } => GeomError::Degenerate(reason.to_owned()),
135            Self::Gap {
136                gap,
137                at,
138                closing: false,
139                ..
140            } => {
141                GeomError::InvalidInput(format!("contour segments leave a gap of {gap} at {at:?}"))
142            }
143            Self::Gap {
144                gap, closing: true, ..
145            } => GeomError::InvalidInput(format!(
146                "contour does not close: {gap} from last segment end to start"
147            )),
148        }
149    }
150}
151
152/// Lower one closed contour, stopping at the first fault.
153pub(crate) fn lower_contour(
154    contour: &Contour,
155    tolerance: Tolerance,
156) -> Result<LoweredContour, ContourFault> {
157    if contour.segments.is_empty() {
158        return Err(ContourFault::TooFewSegments {
159            count: 0,
160            vertices: 0,
161        });
162    }
163
164    let mut vertices = Vec::with_capacity(contour.segments.len());
165    let mut segment_of_edge = Vec::with_capacity(contour.segments.len());
166    let mut previous_end: Option<(Point2, Scalar)> = None;
167    let mut first_scale = 0.0;
168    let mut max_gap: Scalar = 0.0;
169
170    for (index, segment) in contour.segments.iter().enumerate() {
171        let lowered = lower_segment(segment, index)?;
172        let start = lowered.vertices[0].point;
173        if let Some((previous, previous_scale)) = previous_end {
174            let gap = (start - previous).length();
175            if gap > joint_slack(tolerance, previous_scale, lowered.start_scale) {
176                return Err(ContourFault::Gap {
177                    segment: index - 1,
178                    gap,
179                    at: start,
180                    closing: false,
181                });
182            }
183            max_gap = max_gap.max(gap);
184        } else {
185            first_scale = lowered.start_scale;
186        }
187        segment_of_edge.extend(std::iter::repeat_n(index, lowered.vertices.len()));
188        vertices.extend(lowered.vertices);
189        previous_end = Some((lowered.end, lowered.end_scale));
190    }
191
192    // A ring is a cycle of at least two vertices. Segments are not counted:
193    // one closed polyline or one full circle is a whole ring by itself.
194    if vertices.len() < 2 {
195        return Err(ContourFault::TooFewSegments {
196            count: contour.segments.len(),
197            vertices: vertices.len(),
198        });
199    }
200
201    // The contour must close back onto its own first vertex.
202    if let (Some((last, last_scale)), Some(first)) = (previous_end, vertices.first()) {
203        let gap = (first.point - last).length();
204        if gap > joint_slack(tolerance, last_scale, first_scale) {
205            return Err(ContourFault::Gap {
206                segment: contour.segments.len() - 1,
207                gap,
208                at: first.point,
209                closing: true,
210            });
211        }
212        max_gap = max_gap.max(gap);
213    }
214
215    Ok(LoweredContour {
216        ring: ArcRing::new(vertices),
217        segment_of_edge,
218        max_gap,
219    })
220}
221
222/// Rounding of one evaluated segment endpoint, per unit of the magnitudes
223/// it is computed from.
224///
225/// A line's end `origin + direction t` rounds twice (product and sum), and
226/// its stored direction is already the rounded difference of the two points
227/// the producer shared. An arc's end `centre + x r cos + y r sin` adds the
228/// `sin`/`cos` error and frame axes that are unit only to an ulp or two
229/// after normalisation. Each step is at most half an ulp of what it
230/// touches, so eight machine epsilons (~1.8e-15) of the operand magnitudes
231/// bound one side of a joint with room; a contour placed by an affine map
232/// adds the map's own few roundings and stays inside it. A joint closer
233/// than that is the representation's rounding, not a gap anyone drew.
234const JOINT_ROUNDING: Scalar = 8.0 * Scalar::EPSILON;
235
236/// The largest gap a joint may show: the caller's tolerance, or the
237/// rounding of the two endpoint evaluations that meet there when that is
238/// larger (at `Tolerance::ZERO`, or a tolerance below an ulp of the
239/// coordinates).
240pub(crate) fn joint_slack(tolerance: Tolerance, entering: Scalar, leaving: Scalar) -> Scalar {
241    tolerance
242        .linear()
243        .max(JOINT_ROUNDING * (entering + leaving))
244}
245
246/// The magnitudes one segment's oriented end (`at_end`) or start is evaluated
247/// from, which bound its rounding (see `JOINT_ROUNDING`); `point` is the
248/// flattened endpoint, the fallback for a family evaluated otherwise.
249pub(crate) fn endpoint_scale(segment: &ProfileSegment, at_end: bool, point: Point2) -> Scalar {
250    let t = if at_end == segment.same_sense {
251        segment.domain.end
252    } else {
253        segment.domain.start
254    };
255    match &segment.curve {
256        Curve2::Line(line) => line.origin.length() + line.direction.length() * t.abs(),
257        // The parameter's own rounding moves `sin` and `cos` by an epsilon of
258        // `t`, which the radius multiplies.
259        Curve2::Circle(circle) => {
260            circle.frame.origin.length() + circle.radius.abs() * t.abs().max(1.0)
261        }
262        _ => point.x.abs() + point.y.abs(),
263    }
264}
265
266/// One segment lowered: the ring vertices it contributes (each with the
267/// bulge of the edge leaving it), its evaluated end point, and the
268/// magnitudes its start and end are evaluated from, which bound their
269/// rounding (see `JOINT_ROUNDING`).
270struct LoweredSegment {
271    vertices: Vec<ArcVertex>,
272    end: Point2,
273    start_scale: Scalar,
274    end_scale: Scalar,
275}
276
277/// One segment as the ring vertices it contributes and its end point.
278fn lower_segment(segment: &ProfileSegment, index: usize) -> Result<LoweredSegment, ContourFault> {
279    let (from, to) = if segment.same_sense {
280        (segment.domain.start, segment.domain.end)
281    } else {
282        (segment.domain.end, segment.domain.start)
283    };
284
285    match &segment.curve {
286        Curve2::Line(line) => {
287            let start = line.origin + line.direction * from;
288            let end = line.origin + line.direction * to;
289            let origin = line.origin.length();
290            let direction = line.direction.length();
291            Ok(LoweredSegment {
292                vertices: vec![ArcVertex {
293                    point: start,
294                    bulge: 0.0,
295                }],
296                end,
297                start_scale: origin + direction * from.abs(),
298                end_scale: origin + direction * to.abs(),
299            })
300        }
301        Curve2::Circle(circle) => {
302            let (vertices, end) = lower_arc(circle, from, to, index)?;
303            // The parameter's own rounding moves `sin` and `cos` by an epsilon
304            // of `t`, which the radius multiplies (see `endpoint_scale`).
305            let scale =
306                |t: Scalar| circle.frame.origin.length() + circle.radius.abs() * t.abs().max(1.0);
307            Ok(LoweredSegment {
308                vertices,
309                end,
310                start_scale: scale(from),
311                end_scale: scale(to),
312            })
313        }
314        Curve2::Polyline(polyline) => lower_polyline(polyline, from, to, index),
315        other => Err(ContourFault::UnsupportedSegment {
316            segment: index,
317            kind: unsupported_curve_name(other),
318        }),
319    }
320}
321
322/// A polyline segment as one straight ring vertex per polyline vertex it
323/// passes, and its end point.
324///
325/// The parameter is one unit per polyline segment, as in the evaluator: the
326/// domain is `[0, segments]` with `segments` the point count (closed) or one
327/// less (open). At an integer parameter the stored point is taken bit for
328/// bit, so a ring built from a polyline carries exactly its points; between
329/// two integers the point is interpolated. The segment emits its start, then
330/// every integer parameter strictly between `from` and `to` in traversal
331/// order, all with bulge zero. A domain outside the points is refused, not
332/// clamped.
333fn lower_polyline(
334    polyline: &Polyline2,
335    from: Scalar,
336    to: Scalar,
337    index: usize,
338) -> Result<LoweredSegment, ContourFault> {
339    let degenerate = |reason: &'static str| ContourFault::DegenerateSegment {
340        segment: index,
341        reason,
342    };
343    let points = &polyline.points;
344    let count = points.len();
345    if count < 2 || points.iter().any(|p| !p.x.is_finite() || !p.y.is_finite()) {
346        return Err(degenerate(
347            "polyline profile segment has fewer than two finite points",
348        ));
349    }
350    if from == to {
351        return Err(degenerate(
352            "polyline profile segment has an empty parameter range",
353        ));
354    }
355    let segments = if polyline.closed { count } else { count - 1 } as Scalar;
356    let inside = |t: Scalar| (0.0..=segments).contains(&t);
357    if !inside(from) || !inside(to) {
358        return Err(degenerate(
359            "polyline profile segment's domain lies outside its points",
360        ));
361    }
362
363    let evaluate = |t: Scalar| {
364        let i = t.floor();
365        if i == t {
366            return points[i as usize % count];
367        }
368        let (a, b) = (points[i as usize], points[(i as usize + 1) % count]);
369        a + (b - a) * (t - i)
370    };
371    let start = evaluate(from);
372    let end = evaluate(to);
373
374    let mut vertices = vec![ArcVertex {
375        point: start,
376        bulge: 0.0,
377    }];
378    let mut push = |t: Scalar| {
379        let point = evaluate(t);
380        let last = vertices[vertices.len() - 1].point;
381        if point.x.to_bits() != last.x.to_bits() || point.y.to_bits() != last.y.to_bits() {
382            vertices.push(ArcVertex { point, bulge: 0.0 });
383        }
384    };
385    if from < to {
386        let mut k = from.floor() + 1.0;
387        while k < to {
388            push(k);
389            k += 1.0;
390        }
391    } else {
392        let mut k = from.ceil() - 1.0;
393        while k > to {
394            push(k);
395            k -= 1.0;
396        }
397    }
398    // A closed polyline may repeat its first point; the segment's end then
399    // equals its last interior vertex, and the next segment supplies it.
400    if vertices.len() > 1 {
401        let last = vertices[vertices.len() - 1].point;
402        if last.x.to_bits() == end.x.to_bits() && last.y.to_bits() == end.y.to_bits() {
403            vertices.pop();
404        }
405    }
406
407    Ok(LoweredSegment {
408        vertices,
409        end,
410        start_scale: start.x.abs() + start.y.abs(),
411        end_scale: end.x.abs() + end.y.abs(),
412    })
413}
414
415/// A circular segment as ring vertices with the bulges the extruder
416/// expects, and its end point.
417///
418/// `bulge` is `tan(sweep / 4)` for the SIGNED sweep measured in world
419/// orientation. The frame's handedness matters: a left-handed frame runs the
420/// parameter backwards relative to the plane, so the world sweep is the
421/// negation of the parameter sweep. Ignoring that flips the arc onto the
422/// wrong side of its chord.
423///
424/// A bulge determines its arc only below half a turn, so a segment sweeping
425/// half a turn or more is split into `k` equal sub-arcs, each below half a
426/// turn: an IFC arch is commonly one semicircle (ADR 0053, amended by
427/// #228). Each split vertex is the circle evaluated at its parameter,
428/// exactly as the segment's own ends are, so every sub-arc carries the same
429/// circle to the same rounding an unsplit arc does (a few ulps of the
430/// circle's coordinates); nothing is fitted. A segment sweeping more than a
431/// whole turn overlaps itself and is refused.
432fn lower_arc(
433    circle: &Circle2,
434    from: Scalar,
435    to: Scalar,
436    index: usize,
437) -> Result<(Vec<ArcVertex>, Point2), ContourFault> {
438    let evaluate = |t: Scalar| {
439        let (sin, cos) = quadrant_sin_cos(t);
440        circle.frame.origin
441            + circle.frame.x * (circle.radius * cos)
442            + circle.frame.y * (circle.radius * sin)
443    };
444    let start = evaluate(from);
445    let end = evaluate(to);
446
447    let handedness = circle.frame.x.perp_dot(circle.frame.y);
448    if handedness == 0.0 {
449        return Err(ContourFault::DegenerateSegment {
450            segment: index,
451            reason: "circular profile segment has a degenerate frame",
452        });
453    }
454    let sweep = (to - from) * handedness.signum();
455    if sweep == 0.0 {
456        return Err(ContourFault::DegenerateSegment {
457            segment: index,
458            reason: "circular profile segment has an empty parameter range",
459        });
460    }
461    if sweep.abs() > core::f64::consts::TAU {
462        return Err(ContourFault::UnsupportedSegment {
463            segment: index,
464            kind: "circular profile segment sweeping more than a whole turn",
465        });
466    }
467    // Equal sub-arcs, each below half a turn.
468    let pieces = (sweep.abs() / core::f64::consts::PI).floor() as usize + 1;
469    let bulge = (sweep / (4.0 * pieces as Scalar)).tan();
470    let vertices = (0..pieces)
471        .map(|k| ArcVertex {
472            point: if k == 0 {
473                start
474            } else {
475                evaluate(from + (to - from) * k as Scalar / pieces as Scalar)
476            },
477            bulge,
478        })
479        .collect();
480    Ok((vertices, end))
481}
482
483/// The rounding of an arc parameter's `sin` and `cos` at a quarter turn, in
484/// machine epsilons of `max(1, |t|)`: measured `0.18` to `0.55` for every
485/// form tried (`20.5 * PI` is the worst), so `2` leaves a margin of three.
486pub(crate) const QUADRANT_ROUNDING: Scalar = 2.0 * Scalar::EPSILON;
487
488/// `(sin t, cos t)`, with the one that rounds to zero at a quarter turn
489/// taken as exactly zero and the other as exactly `+-1` (#379).
490pub(crate) fn quadrant_sin_cos(t: Scalar) -> (Scalar, Scalar) {
491    let (sin, cos) = t.sin_cos();
492    let slack = QUADRANT_ROUNDING * t.abs().max(1.0);
493    if cos.abs() <= slack {
494        (sin.signum(), 0.0)
495    } else if sin.abs() <= slack {
496        (0.0, cos.signum())
497    } else {
498        (sin, cos)
499    }
500}
501
502fn unsupported_curve_name(curve: &Curve2) -> &'static str {
503    match curve {
504        Curve2::Ellipse(_) => "elliptical contour segment",
505        Curve2::BSpline(_) => "B-spline contour segment",
506        Curve2::Intrinsic(_) => "intrinsic contour segment",
507        Curve2::Chain(_) => "arc-length chain contour segment",
508        _ => "contour segment of an unsupported curve kind",
509    }
510}
511
512/// Signed area of a bulge-encoded ring; positive when counter-clockwise.
513///
514/// The polygon shoelace over the vertices plus each arc's circular-segment
515/// term, so a ring whose shape is decided by its arcs is classified by its
516/// actual geometry rather than by its chords. A crescent thin enough that its
517/// chord polygon winds the other way would otherwise be misread.
518pub fn arc_ring_signed_area(ring: &ArcRing) -> Scalar {
519    let count = ring.vertices.len();
520    let mut total = 0.0;
521    for index in 0..count {
522        let here = ring.vertices[index];
523        let next = ring.vertices[(index + 1) % count];
524        total += here.point.perp_dot(next.point);
525        if here.bulge != 0.0 {
526            let sweep = 4.0 * here.bulge.atan();
527            let chord = (next.point - here.point).length();
528            let half = (sweep.abs() / 2.0).sin();
529            if half > 0.0 {
530                let radius = chord / (2.0 * half);
531                total += radius * radius * (sweep - sweep.sin());
532            }
533        }
534    }
535    total / 2.0
536}
537
538/// Reverse an arc ring in place, preserving its arcs.
539///
540/// Reversing the vertex order alone is NOT enough: a bulge belongs to the
541/// edge LEAVING its vertex, so after reversal each vertex must take the
542/// negated bulge of what was previously its predecessor. Getting this wrong
543/// flips every arc to the wrong side while the ring still closes.
544pub fn reverse_arc_ring(ring: &ArcRing) -> ArcRing {
545    let count = ring.vertices.len();
546    let mut vertices = Vec::with_capacity(count);
547    for index in (0..count).rev() {
548        let previous = (index + count - 1) % count;
549        vertices.push(ArcVertex {
550            point: ring.vertices[index].point,
551            bulge: -ring.vertices[previous].bulge,
552        });
553    }
554    ArcRing { vertices }
555}
556
557/// Force a ring to the winding a boundary role requires.
558///
559/// Outer boundaries run counter-clockwise and holes run clockwise, so that a
560/// ring's wall normals point out of the material in both cases.
561pub fn orient_arc_ring(ring: &ArcRing, counter_clockwise: bool) -> GeomResult<ArcRing> {
562    let area = arc_ring_signed_area(ring);
563    if area == 0.0 {
564        return Err(GeomError::Degenerate(
565            "contour ring encloses no area".to_owned(),
566        ));
567    }
568    if (area > 0.0) == counter_clockwise {
569        Ok(ring.clone())
570    } else {
571        Ok(reverse_arc_ring(ring))
572    }
573}
574
575#[cfg(test)]
576mod tests {
577    use super::*;
578    use axiolid_core::{Frame2, Interval, Vec2};
579    use axiolid_curve::Line2;
580
581    fn arc(from: Scalar, to: Scalar) -> ProfileSegment {
582        ProfileSegment {
583            curve: Curve2::Circle(Circle2 {
584                frame: Frame2 {
585                    origin: Point2::new(0.0, 0.0),
586                    x: Vec2::X,
587                    y: Vec2::Y,
588                },
589                radius: 1.0,
590            }),
591            domain: Interval::new(from, to),
592            same_sense: true,
593        }
594    }
595
596    #[test]
597    fn the_sub_edges_of_a_split_arc_map_to_their_segment() {
598        let chord = ProfileSegment {
599            curve: Curve2::Line(Line2 {
600                origin: Point2::new(0.0, -1.0),
601                direction: Point2::new(1.0, 1.0) - Point2::new(0.0, 0.0),
602            }),
603            domain: Interval::UNIT,
604            same_sense: true,
605        };
606        // Three quarters of a turn is two sub-arcs; the line closes it.
607        let contour = Contour::new(vec![arc(0.0, 1.5 * core::f64::consts::PI), chord]);
608        let lowered = lower_contour(&contour, Tolerance::METRE).expect("lowers");
609        assert_eq!(lowered.segment_of_edge, vec![0, 0, 1]);
610        assert_eq!(lowered.ring.vertices.len(), 3);
611    }
612
613    #[test]
614    fn a_half_turn_between_quadrants_lowers_with_exact_ends() {
615        use core::f64::consts::FRAC_PI_2;
616        let axis = ProfileSegment {
617            curve: Curve2::Line(Line2 {
618                origin: Point2::new(0.0, 1.0),
619                direction: Point2::new(0.0, -2.0),
620            }),
621            domain: Interval::UNIT,
622            same_sense: true,
623        };
624        let contour = Contour::new(vec![arc(-FRAC_PI_2, FRAC_PI_2), axis]);
625        let lowered = lower_contour(&contour, Tolerance::ZERO).expect("lowers");
626        let points: Vec<_> = lowered.ring.vertices.iter().map(|v| v.point).collect();
627        assert_eq!(points[0], Point2::new(0.0, -1.0));
628        assert_eq!(points[points.len() - 1], Point2::new(0.0, 1.0));
629    }
630
631    #[test]
632    fn every_form_of_a_quarter_turn_snaps() {
633        use core::f64::consts::{FRAC_PI_2, PI, TAU};
634        let quarters = [
635            (FRAC_PI_2, (1.0, 0.0)),
636            (-FRAC_PI_2, (-1.0, 0.0)),
637            (-PI / 2.0, (-1.0, 0.0)),
638            (1.5 * PI, (-1.0, 0.0)),
639            (3.0 * FRAC_PI_2, (-1.0, 0.0)),
640            (PI + FRAC_PI_2, (-1.0, 0.0)),
641            (TAU - FRAC_PI_2, (-1.0, 0.0)),
642            (2.5 * PI, (1.0, 0.0)),
643            (-1.5 * PI, (1.0, 0.0)),
644            (20.5 * PI, (1.0, 0.0)),
645            (PI, (0.0, -1.0)),
646            (TAU, (0.0, 1.0)),
647            (0.0, (0.0, 1.0)),
648        ];
649        for (t, exact) in quarters {
650            assert_eq!(quadrant_sin_cos(t), exact, "{t}");
651        }
652    }
653
654    #[test]
655    fn a_large_arc_closed_by_a_line_between_its_raw_ends_lowers_at_zero() {
656        // The snap moves the arc's ends by the rounding of the parameter,
657        // which the joint check at `Tolerance::ZERO` must read.
658        use core::f64::consts::PI;
659        for t in [19.5, 64.5, 200.5] {
660            let (from, to) = (t * PI, (t + 1.0) * PI);
661            let (a, b) = (from.sin_cos(), to.sin_cos());
662            let close = ProfileSegment {
663                curve: Curve2::Line(Line2 {
664                    origin: Point2::new(b.1, b.0),
665                    direction: Point2::new(a.1, a.0) - Point2::new(b.1, b.0),
666                }),
667                domain: Interval::UNIT,
668                same_sense: true,
669            };
670            let contour = Contour::new(vec![arc(from, to), close]);
671            lower_contour(&contour, Tolerance::ZERO).unwrap_or_else(|e| panic!("{t}: {e:?}"));
672        }
673    }
674
675    #[test]
676    fn a_parameter_a_hair_off_a_quarter_turn_keeps_its_angle() {
677        use core::f64::consts::FRAC_PI_2;
678        let t = FRAC_PI_2 + 1e-12;
679        assert_eq!(quadrant_sin_cos(t), t.sin_cos());
680        assert_ne!(quadrant_sin_cos(t).1, 0.0);
681    }
682}