Skip to main content

axiolid_construct/pipe/
fixed_reference.rs

1//! A profile swept with a fixed reference along a tangent-continuous chain
2//! of segments and arcs (#122).
3//!
4//! A fixed-reference sweep places the profile's x axis along the reference
5//! direction projected square to the directrix tangent, z along the tangent
6//! and y = z x x. Along a chain of segments and circular arcs that frame is
7//! the rotation-minimising frame [`super`] carries for a swept disk
8//! whenever, on every arc, the reference is normal to the arc's plane or
9//! lies in it:
10//!
11//! - Normal to the plane, the projected reference is the plane's normal
12//!   itself, which stays put while the tangent turns about it.
13//! - In the plane, the projected reference is a direction in the plane
14//!   that turns with the tangent, and the plane's normal is the profile's y.
15//!
16//! In both cases consecutive stations of an arc are rotations of one ring
17//! about the arc's axis, so each arc is an exact revolution of the placed
18//! profile, and each segment is an exact prism. [`super`]'s per-piece proof
19//! (the span bound with the profile's reach, joint stations shared between
20//! pieces, the measured joint shift) then carries over with the profile's
21//! rings in place of the disk's: every point of the exact swept surface lies
22//! within `chord / 2` of the triangles, a lone segment within nothing. The
23//! caller adds the rings' own distance from a curved profile.
24//!
25//! # The twist term
26//!
27//! A reference that only nearly is normal to the plane or in it is no
28//! exception to be waved through: the exact frame then turns about the
29//! tangent off the rotation-minimising stations by an angle `delta` that
30//! grows along the arc. Both frames lie in the plane of the arc's axis `n`
31//! and the tangent's in-plane normal `nu`; the rotation-minimising one at
32//! fixed coordinates `(a, q(0))`, the exact one at `(a, q(phi))`, with `a`
33//! the reference's component along `n` and `q` its component along `nu`.
34//! The angle between them is monotone in `q`, so its largest value is read
35//! at the arc's ends and where `q` has an extremum (`arc_twist`). A profile
36//! vertex at `reach` from the origin moves by at most `2 reach sin(delta/2)`
37//! between the two, and that comes out of the walls' half of the budget, which stays `chord / 2`. A
38//! term above an eighth of the budget leaves the arc to the sampled sweep
39//! ([`FixedReferencePlan::Twisting`], reported unbounded).
40//!
41//! Each piece starts from the exact frame at its own start, not carried over
42//! a joint. At a kink `theta`, within the joint tolerance, the re-seeded
43//! frame turns about the tangent by about `theta cot(beta)` against the
44//! previous piece's end, `beta` being the reference's angle to the tangent.
45//! That and the previous arc's twist at its end are paid by the first span
46//! of the next piece; above a quarter of the budget the joint is refused as
47//! a corner for this reference.
48//!
49//! # Refusals
50//!
51//! The IFC rule is that the directrix of a fixed-reference sweep shall be
52//! tangent continuous, so a corner (a joint turning by more than
53//! [`joint_tolerance`] at the reach of the profile) is refused by name, as
54//! is a gap between pieces above a quarter of the budget. The reference may
55//! never come within the angular tolerance of the tangent on a piece, or
56//! the frame degenerates there: a segment is checked whole, an arc over its
57//! turn ([`reference_clearance`]). A profile that reaches the axis of a bend
58//! is refused: the solid would fold through it, and the revolution would
59//! pinch.
60//!
61//! An arc oblique to the reference twists the frame about the tangent by
62//! more than the term allows: [`fixed_reference_plan`] reports it as
63//! [`FixedReferencePlan::Twisting`] and the caller sweeps it as sampled and
64//! reports it unbounded; nothing here vouches for it.
65
66use axiolid_contracts::{GeomError, GeomResult};
67use axiolid_core::{Scalar, Tolerance, Vec3};
68use axiolid_mesh::TriMesh;
69
70use super::{bounded_piece, joint_tolerance, turn, Close, PathPiece, Sampled, Station};
71use crate::loft::{self, Frame};
72use crate::profile::Rings;
73
74/// How [`fixed_reference_plan`] reads a chain.
75#[derive(Debug, Clone, PartialEq)]
76#[non_exhaustive]
77pub enum FixedReferencePlan {
78    /// No arc twists the frame: swept by [`fixed_reference_along_pieces`],
79    /// the bound proven.
80    Untwisted(FixedReferenceChain),
81    /// Piece `piece` is an arc oblique to the reference: the frame twists
82    /// there; not bounded here.
83    Twisting {
84        /// Index of the first such arc.
85        piece: usize,
86    },
87}
88
89/// A chain checked for a fixed-reference sweep; only [`fixed_reference_plan`]
90/// makes one, so a chain in hand has passed its checks.
91#[derive(Debug, Clone, PartialEq)]
92#[non_exhaustive]
93pub struct FixedReferenceChain {
94    pieces: Vec<PathPiece>,
95    reference: Vec3,
96    closed: bool,
97    chord: Scalar,
98    reach: Scalar,
99    walls: Scalar,
100    twist: Scalar,
101}
102
103impl FixedReferenceChain {
104    /// The pieces, in the direction of travel.
105    #[must_use]
106    pub fn pieces(&self) -> &[PathPiece] {
107        &self.pieces
108    }
109
110    /// The unit reference direction.
111    #[must_use]
112    pub const fn reference(&self) -> Vec3 {
113        self.reference
114    }
115
116    /// Whether the last piece joins the first as a loop (see
117    /// [`fixed_reference_plan`]).
118    #[must_use]
119    pub const fn closed(&self) -> bool {
120        self.closed
121    }
122
123    /// The chord budget of the walls.
124    #[must_use]
125    pub const fn chord(&self) -> Scalar {
126        self.chord
127    }
128
129    /// The profile's furthest ring vertex from its origin.
130    #[must_use]
131    pub const fn reach(&self) -> Scalar {
132        self.reach
133    }
134
135    /// The proven distance of the walls from the exact surface: zero for
136    /// exactly one segment, else half the chord budget, of which the arcs'
137    /// largest twist term ([`Self::twist`]) is paid first and the spans get
138    /// the rest.
139    #[must_use]
140    pub const fn walls(&self) -> Scalar {
141        self.walls
142    }
143
144    /// The largest distance a profile vertex moves between the exact frame
145    /// and the rotation-minimising stations on any arc (see the module
146    /// notes); within an eighth of the chord budget.
147    #[must_use]
148    pub const fn twist(&self) -> Scalar {
149        self.twist
150    }
151}
152
153/// The furthest ring vertex from the profile's origin.
154#[must_use]
155pub fn ring_reach(rings: &Rings) -> Scalar {
156    rings
157        .outer
158        .iter()
159        .chain(rings.holes.iter().flatten())
160        .map(|p| p.x.hypot(p.y))
161        .fold(0.0, Scalar::max)
162}
163
164fn refuse(what: String) -> GeomError {
165    GeomError::InvalidInput(format!("fixed-reference sweep: {what}"))
166}
167
168/// The largest share of the chord budget the exact frame's twist off the
169/// rotation-minimising one may take before an arc is left to the sampled
170/// sweep, and the share of it a joint's re-seeding may take together with
171/// its kink.
172const TWIST_SHARE: Scalar = 0.125;
173const JOINT_SHARE: Scalar = 0.25;
174
175/// The turn about the tangent between the exact fixed-reference frame and
176/// the rotation-minimising frame carried from the arc's start, largest over
177/// the arc and at its end.
178///
179/// With `n` the axis, `a` the reference's component along it and `q(phi)`
180/// the reference's component along the in-plane normal `nu(phi)` of the
181/// tangent, both frames lie in the plane of `n` and `nu`: the rotation-
182/// minimising one at fixed coordinates `(a, q(0))`, the exact one at
183/// `(a, q(phi))`, `q(phi) = rho sin(alpha - phi)` for the in-plane part
184/// `rho`. The angle is monotone in `q`, so its largest value is at an end of
185/// the arc or where `q` has an extremum.
186fn arc_twist(piece: &PathPiece, r: Vec3) -> (Scalar, Scalar) {
187    let PathPiece::Arc { axis, angle, .. } = *piece else {
188        return (0.0, 0.0);
189    };
190    let t0 = piece.start_tangent();
191    let e2 = (axis * angle.signum()).cross(t0);
192    let p = r - axis * r.dot(axis);
193    let a = r.dot(axis).abs();
194    let (qc, qs) = (p.dot(e2), p.dot(t0));
195    let q = |phi: Scalar| qc * phi.cos() - qs * phi.sin();
196    let theta = |phi: Scalar| q(phi).atan2(a);
197    let turned = angle.abs();
198    let alpha = qc.atan2(qs);
199    let first = (alpha - core::f64::consts::FRAC_PI_2).rem_euclid(core::f64::consts::PI);
200    let start = theta(0.0);
201    let end = (theta(turned) - start).abs();
202    let largest = (0..3)
203        .map(|j| first + j as Scalar * core::f64::consts::PI)
204        .filter(|phi| *phi <= turned)
205        .map(|phi| (theta(phi) - start).abs())
206        .fold(end, Scalar::max);
207    let safe = 1.0 + 1e-9;
208    (largest * safe, end * safe)
209}
210
211/// How far a profile vertex at `reach` moves when turned by `delta` about
212/// the tangent.
213fn twist_term(reach: Scalar, delta: Scalar) -> Scalar {
214    2.0 * reach * (0.5 * delta).min(core::f64::consts::FRAC_PI_2).sin()
215}
216
217/// The smallest angle between `reference` and the line of the tangent over
218/// the piece, in `[0, pi/2]`. Zero where the frame degenerates.
219#[must_use]
220pub fn reference_clearance(piece: &PathPiece, reference: Vec3) -> Scalar {
221    let r = reference.normalize_or_zero();
222    let between = |t: Vec3| r.cross(t).length().atan2(r.dot(t).abs());
223    match *piece {
224        PathPiece::Segment { .. } => between(piece.start_tangent()),
225        PathPiece::Arc { axis, angle, .. } => {
226            let t0 = piece.start_tangent();
227            let k = axis * angle.signum();
228            let e2 = k.cross(t0);
229            // The tangent at turn `phi` is `t0 cos(phi) + e2 sin(phi)`: it
230            // is parallel to the reference's in-plane part `p` where `phi`
231            // is `alpha` plus a multiple of pi.
232            let p = r - axis * r.dot(axis);
233            let alpha = p.dot(e2).atan2(p.dot(t0));
234            let turned = angle.abs();
235            if alpha.rem_euclid(core::f64::consts::PI) <= turned {
236                return r.dot(axis).abs().atan2(p.length());
237            }
238            let at = |phi: Scalar| t0 * phi.cos() + e2 * phi.sin();
239            let end = if (turned - alpha).cos().abs() > alpha.cos().abs() {
240                turned
241            } else {
242                0.0
243            };
244            between(at(end))
245        }
246    }
247}
248
249/// Check a chain of pieces for a fixed-reference sweep of a profile of
250/// `reach` at wall budget `chord` and decide whether the bound is proven.
251///
252/// `closed` is whether the path declares itself a loop (a closed polyline):
253/// its closing joint is then checked like any other. An undeclared chain is
254/// a loop all the same when its last piece ends within the smaller of the
255/// linear tolerance and a quarter of `chord` of where its first starts, and
256/// the joint there is tangent continuous (a full circle, a
257/// composite closing on itself); where it is not, the chain is refused by
258/// name, as its two end caps would cross.
259///
260/// Refused by name, in this order: no pieces or a reference, chord or reach
261/// that is zero or not finite; a malformed piece; a joint with a gap above a
262/// quarter of `chord` or a corner; a piece whose tangent comes within the
263/// angular tolerance of the reference's direction. Then an arc whose exact
264/// frame twists off the rotation-minimising one by more than an eighth of
265/// `chord` at `reach` makes the plan [`FixedReferencePlan::Twisting`]; a
266/// smaller twist comes out of the walls' half of the budget. Last, a joint
267/// whose kink turns the re-seeded profile (by about the kink times the
268/// cotangent of the reference's angle to the tangent) further than a quarter
269/// of `chord` at `reach` is refused.
270pub fn fixed_reference_plan(
271    pieces: &[PathPiece],
272    closed: bool,
273    reference: Vec3,
274    reach: Scalar,
275    chord: Scalar,
276    tolerance: Tolerance,
277) -> GeomResult<FixedReferencePlan> {
278    if pieces.is_empty() {
279        return Err(refuse("the directrix needs at least one piece".to_owned()));
280    }
281    let r = reference.normalize_or_zero();
282    if !reference.is_finite() || r == Vec3::ZERO {
283        return Err(refuse(format!(
284            "the reference direction must be finite and non-zero, got {reference:?}"
285        )));
286    }
287    for (what, value) in [("chord budget", chord), ("profile reach", reach)] {
288        if !(value.is_finite() && value > 0.0) {
289            return Err(refuse(format!(
290                "the {what} must be positive and finite, got {value}"
291            )));
292        }
293    }
294    for (i, piece) in pieces.iter().enumerate() {
295        check_shape(i, piece)?;
296    }
297    let angular = tolerance.angular();
298    let m = pieces.len();
299    let allowed = joint_tolerance(chord, reach);
300    let closing_gap = tolerance.linear().min(0.25 * chord);
301    let (last, first) = (&pieces[m - 1], &pieces[0]);
302    let touches = last.end_point().distance(first.start_point()) <= closing_gap;
303    let closing_turn = turn(last.end_tangent(), first.start_tangent());
304    if !closed && touches && closing_turn > allowed {
305        // Open, its two end caps would share the point and cross.
306        return Err(refuse(format!(
307            "the chain ends at its start with a corner of {closing_turn} rad, above {allowed}: \
308             the directrix must be tangent continuous"
309        )));
310    }
311    let loops = closed || (touches && closing_turn <= allowed);
312    let joints = if loops { m } else { m - 1 };
313    for k in 0..joints {
314        let (a, b) = (&pieces[k], &pieces[(k + 1) % m]);
315        let gap = a.end_point().distance(b.start_point());
316        if gap > 0.25 * chord {
317            return Err(refuse(format!(
318                "joint {k} leaves a gap of {gap}, above a quarter of the chord budget {chord}"
319            )));
320        }
321        let theta = turn(a.end_tangent(), b.start_tangent());
322        if theta > allowed {
323            return Err(refuse(format!(
324                "the directrix turns a corner of {theta} rad at joint {k}, above {allowed}: the \
325                 directrix must be tangent continuous"
326            )));
327        }
328    }
329    for (i, piece) in pieces.iter().enumerate() {
330        let clearance = reference_clearance(piece, r);
331        if clearance <= angular {
332            return Err(refuse(format!(
333                "the reference direction comes within {clearance} rad of the directrix tangent \
334                 on piece {i}"
335            )));
336        }
337    }
338    let mut twist: Scalar = 0.0;
339    let mut ends = vec![0.0; m];
340    for (i, piece) in pieces.iter().enumerate() {
341        let (largest, end) = arc_twist(piece, r);
342        let term = twist_term(reach, largest);
343        if term > TWIST_SHARE * chord {
344            return Ok(FixedReferencePlan::Twisting { piece: i });
345        }
346        twist = twist.max(term);
347        ends[i] = twist_term(reach, end);
348    }
349    for k in 0..joints {
350        let (a, b) = (&pieces[k], &pieces[(k + 1) % m]);
351        let (ta, tb) = (a.end_tangent(), b.start_tangent());
352        let xa = Frame::from_reference(a.end_point(), ta, r)?.x;
353        let xb = Frame::from_reference(b.start_point(), tb, r)?.x;
354        let kink = turn(ta, tb);
355        let reseed = turn(super::least_rotation(xa, ta, tb), xb);
356        let shift = reach * (kink + reseed) + ends[k];
357        if shift > JOINT_SHARE * chord {
358            return Err(refuse(format!(
359                "joint {k} turns the profile by {reseed} rad for this reference (a kink of \
360                 {kink} rad), moving it {shift}: a corner for this reference, above a quarter \
361                 of the chord budget {chord}"
362            )));
363        }
364    }
365    let walls = if matches!(pieces, [PathPiece::Segment { .. }]) {
366        0.0
367    } else {
368        0.5 * chord
369    };
370    Ok(FixedReferencePlan::Untwisted(FixedReferenceChain {
371        pieces: pieces.to_vec(),
372        reference: r,
373        closed: loops,
374        chord,
375        reach,
376        walls,
377        twist,
378    }))
379}
380
381/// Refuse a piece with no direction or a malformed arc.
382fn check_shape(i: usize, piece: &PathPiece) -> GeomResult<()> {
383    match *piece {
384        PathPiece::Segment { start, end } => {
385            if !(start.is_finite() && end.is_finite()) {
386                return Err(refuse(format!("piece {i} must be finite")));
387            }
388            if start == end {
389                return Err(GeomError::Degenerate(format!(
390                    "fixed-reference sweep: segment {i} has no length"
391                )));
392            }
393        }
394        PathPiece::Arc {
395            centre,
396            axis,
397            start,
398            angle,
399        } => {
400            if !(centre.is_finite() && axis.is_finite() && start.is_finite() && angle.is_finite()) {
401                return Err(refuse(format!("piece {i} must be finite")));
402            }
403            let bend = (start - centre).length();
404            if (axis.length() - 1.0).abs() > 1e-9
405                || bend == 0.0
406                || (start - centre).dot(axis).abs() > 1e-9 * bend
407            {
408                return Err(refuse(format!(
409                    "arc {i} needs a unit axis normal to a non-zero radius"
410                )));
411            }
412            if angle == 0.0 || angle.abs() > core::f64::consts::TAU * (1.0 + 1e-12) {
413                return Err(refuse(format!(
414                    "arc {i} must turn by a non-zero angle of at most a full turn, got {angle}"
415                )));
416            }
417        }
418    }
419    Ok(())
420}
421
422/// The budget errors a piece's refinement raises, worded for this sweep.
423fn named(error: GeomError) -> GeomError {
424    match error {
425        GeomError::BudgetExceeded {
426            resource: "swept disk directrix refinement",
427        } => GeomError::BudgetExceeded {
428            resource: "fixed-reference sweep directrix refinement",
429        },
430        GeomError::BudgetExceeded {
431            resource: "swept disk bend steps",
432        } => GeomError::BudgetExceeded {
433            resource: "fixed-reference sweep bend steps",
434        },
435        other => other,
436    }
437}
438
439/// Sweep `rings` along a planned chain so that every point of the exact
440/// surface lies within `chain.walls()` of the mesh (see the module notes).
441///
442/// Each piece starts from the fixed-reference frame at its own start, never
443/// carried across a joint; consecutive pieces share one station, and a loop
444/// closes on its first station without caps.
445pub fn fixed_reference_along_pieces(
446    rings: &Rings,
447    chain: &FixedReferenceChain,
448) -> GeomResult<TriMesh> {
449    let reference = chain.reference();
450    let share = 0.5 * chain.chord() - chain.twist();
451    for (i, piece) in chain.pieces().iter().enumerate() {
452        if let PathPiece::Arc { centre, start, .. } = *piece {
453            let frame = Frame::from_reference(start, piece.start_tangent(), reference)?;
454            let bend = (start - centre).length();
455            let inward = (centre - start).normalize_or_zero();
456            let depth = rings
457                .outer
458                .iter()
459                .chain(rings.holes.iter().flatten())
460                .map(|p| (frame.x * p.x + frame.y * p.y).dot(inward))
461                .fold(Scalar::NEG_INFINITY, Scalar::max)
462                + share;
463            if depth >= bend {
464                return Err(refuse(format!(
465                    "the profile reaches {depth} toward the axis of arc {i}, not below its bend \
466                     radius {bend}: the solid would fold through the bend's axis"
467                )));
468            }
469        }
470    }
471    let m = chain.pieces().len();
472    let mut stations: Vec<Station> = Vec::new();
473    for (i, piece) in chain.pieces().iter().enumerate() {
474        let seed = Frame::from_reference(piece.start_point(), piece.start_tangent(), reference)?.x;
475        let closing = chain.closed() && i + 1 == m;
476        let close = match (closing, stations.first()) {
477            (false, _) => Close::Open,
478            (true, Some(first)) => Close::To(first),
479            (true, None) => Close::Own,
480        };
481        let sample = |budget: Scalar| -> GeomResult<Sampled> {
482            let n = piece.steps(budget)?;
483            let points: Vec<_> = (0..=n)
484                .map(|k| piece.point(k as Scalar / n as Scalar))
485                .collect();
486            let tangents = points.iter().map(|p| piece.tangent_at(*p)).collect();
487            Ok(Sampled {
488                twists: vec![0.0; points.len()],
489                points,
490                tangents,
491            })
492        };
493        let (_, placed) = bounded_piece(
494            sample,
495            rings,
496            seed,
497            chain.reach(),
498            share,
499            stations.last(),
500            [None, None],
501            close,
502        )
503        .map_err(named)?;
504        let skip = usize::from(!stations.is_empty());
505        let keep = placed.len() - usize::from(closing);
506        stations.extend(placed.into_iter().take(keep).skip(skip));
507    }
508    let mut mesh = loft::loft(rings, &stations, chain.closed())?;
509    if matches!(crate::extrude::outward_orientation(&mesh), Some(false)) {
510        for triangle in mesh.indices.as_chunks_mut::<3>().0 {
511            triangle.swap(1, 2);
512        }
513    }
514    Ok(mesh)
515}
516
517#[cfg(test)]
518mod tests {
519    use core::f64::consts::{FRAC_PI_2, FRAC_PI_4, PI, TAU};
520
521    use axiolid_core::{Point2, Point3};
522    use axiolid_measure::volume_properties;
523
524    use super::*;
525
526    const CHORD: Scalar = 1e-3;
527
528    fn tol() -> Tolerance {
529        Tolerance::new(1e-6, 1e-2).unwrap()
530    }
531
532    fn square(lo: Scalar, hi: Scalar) -> Rings {
533        Rings {
534            outer: vec![
535                Point2::new(lo, lo),
536                Point2::new(hi, lo),
537                Point2::new(hi, hi),
538                Point2::new(lo, hi),
539            ],
540            holes: Vec::new(),
541        }
542    }
543
544    fn segment(from: [Scalar; 3], to: [Scalar; 3]) -> PathPiece {
545        PathPiece::Segment {
546            start: Point3::new(from[0], from[1], from[2]),
547            end: Point3::new(to[0], to[1], to[2]),
548        }
549    }
550
551    /// A unit arc about Z from (1, 0, 0), counter-clockwise for a positive
552    /// `angle`.
553    fn arc(angle: Scalar) -> PathPiece {
554        PathPiece::Arc {
555            centre: Point3::ZERO,
556            axis: Vec3::Z,
557            start: Point3::new(1.0, 0.0, 0.0),
558            angle,
559        }
560    }
561
562    fn plan(pieces: &[PathPiece], reference: Vec3) -> GeomResult<FixedReferencePlan> {
563        fixed_reference_plan(pieces, false, reference, 0.07, CHORD, tol())
564    }
565
566    fn refusal(result: GeomResult<impl core::fmt::Debug>) -> String {
567        let message = result.unwrap_err().to_string();
568        assert!(message.contains("fixed-reference sweep"), "{message}");
569        message
570    }
571
572    #[test]
573    fn a_segment_is_cleared_by_its_angle_to_the_reference_line() {
574        let s = segment([0.0; 3], [2.0, 0.0, 0.0]);
575        let r = Vec3::new(1.0, 1.0, 0.0);
576        assert!((reference_clearance(&s, r) - FRAC_PI_4).abs() < 1e-12);
577        assert!((reference_clearance(&s.reversed(), r) - FRAC_PI_4).abs() < 1e-12);
578        assert!((reference_clearance(&s, -r) - FRAC_PI_4).abs() < 1e-12);
579        assert!(reference_clearance(&s, Vec3::X) < 1e-12);
580    }
581
582    #[test]
583    fn an_in_plane_arc_is_cleared_by_where_its_tangent_comes_closest() {
584        let r = Vec3::new(1.0, 1.0, 0.0);
585        // Crossing: the tangent is parallel to the reference at 3 pi / 4.
586        assert!(reference_clearance(&arc(PI), r) < 1e-12);
587        // Not crossing: the start is nearest.
588        assert!((reference_clearance(&arc(FRAC_PI_4), r) - FRAC_PI_4).abs() < 1e-12);
589        // Ending at it: the tangent at pi / 2 is -x.
590        assert!(reference_clearance(&arc(FRAC_PI_2), Vec3::X) < 1e-12);
591        // Walked the other way, the same set of tangents.
592        assert!(reference_clearance(&arc(-PI), Vec3::new(1.0, -1.0, 0.0)) < 1e-12);
593        // Leaning out of the plane: the angle to the in-plane part.
594        let lean = Vec3::new(0.0, 1.0, 1.0);
595        assert!((reference_clearance(&arc(1.0), lean) - FRAC_PI_4).abs() < 1e-12);
596        // Normal to the plane: square to every tangent.
597        assert!((reference_clearance(&arc(1.0), Vec3::Z) - FRAC_PI_2).abs() < 1e-12);
598    }
599
600    #[test]
601    fn an_arc_pays_for_the_twist_of_its_lean() {
602        let (reach, exact) = (0.07, 0.5 * CHORD);
603        let walls = |pieces: &[PathPiece], reference: Vec3| match fixed_reference_plan(
604            pieces,
605            false,
606            reference,
607            reach,
608            CHORD,
609            tol(),
610        )
611        .unwrap()
612        {
613            FixedReferencePlan::Untwisted(chain) => Some((chain.walls(), chain.twist())),
614            FixedReferencePlan::Twisting { .. } => None,
615        };
616        let pieces = [arc(1.0)];
617        assert_eq!(walls(&pieces, Vec3::Z), Some((exact, 0.0)));
618        for lean in [
619            |l: Scalar| Vec3::new(0.0, l.sin(), l.cos()),
620            |l: Scalar| Vec3::new(l.sin(), 0.0, l.cos()),
621        ] {
622            // A small lean is paid for out of the walls' half: at most the
623            // rim's chord at twice the lean, and more than nothing.
624            let small = 1e-4;
625            let (w, twist) = walls(&pieces, lean(small)).expect("untwisted");
626            assert_eq!(w, exact);
627            assert!(
628                twist > 0.0 && twist <= reach * 2.0 * small * 1.01,
629                "{twist}"
630            );
631            // A large one is left to the sampled sweep.
632            assert_eq!(walls(&pieces, lean(1e-2)), None);
633        }
634        let twisting = plan(&pieces, Vec3::new(1.0, 1.0, 1.0)).unwrap();
635        assert_eq!(twisting, FixedReferencePlan::Twisting { piece: 0 });
636    }
637
638    #[test]
639    fn a_twist_peaking_inside_the_arc_is_paid_for() {
640        // Over a full turn, or a turn and a bit, the exact frame is off by
641        // up to twice the lean inside the arc, while at the end alone it is
642        // not: the end alone would pay too little.
643        let lean: Scalar = 1e-4;
644        let r = Vec3::new(lean.sin(), 0.0, lean.cos());
645        for angle in [TAU, PI + 0.5] {
646            let FixedReferencePlan::Untwisted(chain) = plan(&[arc(angle)], r).unwrap() else {
647                panic!("twisting")
648            };
649            let (largest, end) = arc_twist(&arc(angle), r);
650            assert!(end < 0.95 * largest, "{angle}: {end} {largest}");
651            assert!(chain.twist() >= twist_term(0.07, largest) * 0.999);
652            assert!(
653                chain.twist() > 1.5 * twist_term(0.07, lean),
654                "{}",
655                chain.twist()
656            );
657        }
658    }
659
660    #[test]
661    fn the_twist_is_the_measured_turn_of_the_exact_frame() {
662        // A reference leaning from the normal by `lean`: over a quarter turn
663        // the exact frame turns off the rotation-minimising one by the lean.
664        let lean: Scalar = 9e-4;
665        let r = Vec3::new(lean.sin(), 0.0, lean.cos());
666        let (largest, end) = arc_twist(&arc(FRAC_PI_2), r);
667        assert!((largest - lean).abs() < 1e-9 && (end - lean).abs() < 1e-9);
668        // Over a full turn it peaks at twice the lean, where q changes sign.
669        let (largest, _) = arc_twist(&arc(TAU), r);
670        assert!((largest - 2.0 * lean).abs() < 1e-8, "{largest}");
671        // Exactly normal: none.
672        assert_eq!(arc_twist(&arc(1.0), Vec3::Z), (0.0, 0.0));
673    }
674
675    #[test]
676    fn a_plan_refuses_by_name() {
677        let bad_inputs = [
678            (plan(&[], Vec3::Z), "at least one piece"),
679            (plan(&[arc(1.0)], Vec3::ZERO), "non-zero"),
680            (
681                plan(&[arc(1.0)], Vec3::new(Scalar::NAN, 0.0, 1.0)),
682                "finite",
683            ),
684            (
685                fixed_reference_plan(&[arc(1.0)], false, Vec3::Z, 0.07, 0.0, tol()),
686                "chord budget",
687            ),
688            (
689                fixed_reference_plan(&[arc(1.0)], false, Vec3::Z, Scalar::NAN, CHORD, tol()),
690                "profile reach",
691            ),
692            (plan(&[segment([0.0; 3], [0.0; 3])], Vec3::Z), "no length"),
693            (plan(&[arc(0.0)], Vec3::Z), "arc 0"),
694            (plan(&[arc(7.0)], Vec3::Z), "full turn"),
695        ];
696        for (result, needle) in bad_inputs {
697            let message = refusal(result);
698            assert!(message.contains(needle), "{needle}: {message}");
699        }
700        // A corner, by joint.
701        let corner = [
702            segment([0.0; 3], [1.0, 0.0, 0.0]),
703            segment([1.0, 0.0, 0.0], [1.0, 1.0, 0.0]),
704        ];
705        let message = refusal(plan(&corner, Vec3::Z));
706        assert!(
707            message.contains("joint 0") && message.contains("tangent continuous"),
708            "{message}"
709        );
710        // The corner a loop closes with.
711        let triangle = [
712            segment([0.0; 3], [1.0, 0.0, 0.0]),
713            segment([1.0, 0.0, 0.0], [1.0, 1.0, 0.0]),
714            segment([1.0, 1.0, 0.0], [0.0; 3]),
715        ];
716        let message = refusal(fixed_reference_plan(
717            &triangle,
718            true,
719            Vec3::Z,
720            0.07,
721            CHORD,
722            tol(),
723        ));
724        assert!(message.contains("joint 0"), "{message}");
725        // A gap.
726        let gap = [
727            segment([0.0; 3], [1.0, 0.0, 0.0]),
728            segment([1.5, 0.0, 0.0], [2.0, 0.0, 0.0]),
729        ];
730        let message = refusal(plan(&gap, Vec3::Z));
731        assert!(
732            message.contains("gap") && message.contains("joint 0"),
733            "{message}"
734        );
735        // A reference parallel to a segment.
736        let line = [
737            segment([0.0; 3], [1.0, 0.0, 0.0]),
738            segment([1.0, 0.0, 0.0], [2.0, 0.0, 0.0]),
739        ];
740        let message = refusal(plan(&line, Vec3::X));
741        assert!(
742            message.contains("piece 0") && message.contains("tangent"),
743            "{message}"
744        );
745        // An in-plane arc whose tangent reaches the reference.
746        let message = refusal(plan(&[arc(PI + 0.5)], Vec3::new(1.0, 1.0, 0.0)));
747        assert!(message.contains("piece 0"), "{message}");
748        // A kink within the joint tolerance that turns the re-seeded profile
749        // far: the reference nearly along the tangent.
750        let kinked = [
751            segment([0.0; 3], [1.0, 0.0, 0.0]),
752            segment([1.0, 0.0, 0.0], [2.0, 0.003, 0.0]),
753        ];
754        let flat = Vec3::new(0.05f64.cos(), 0.0, 0.05f64.sin());
755        let message = refusal(plan(&kinked, flat));
756        assert!(
757            message.contains("joint 0") && message.contains("corner for this reference"),
758            "{message}"
759        );
760        assert!(plan(&kinked, Vec3::Z).is_ok());
761    }
762
763    #[test]
764    fn walls_are_free_only_along_one_segment() {
765        let one = segment([0.0; 3], [1.0, 0.0, 0.0]);
766        let two = segment([1.0, 0.0, 0.0], [2.0, 0.0, 0.0]);
767        let walls = |pieces: &[PathPiece]| match plan(pieces, Vec3::Z).unwrap() {
768            FixedReferencePlan::Untwisted(chain) => chain.walls(),
769            other => panic!("{other:?}"),
770        };
771        assert_eq!(walls(&[one]), 0.0);
772        assert_eq!(walls(&[one, two]), 0.5 * CHORD);
773        assert_eq!(walls(&[arc(1.0)]), 0.5 * CHORD);
774    }
775
776    fn untwisted(pieces: &[PathPiece], closed: bool, reference: Vec3) -> FixedReferenceChain {
777        let reach = ring_reach(&square(-0.05, 0.05));
778        match fixed_reference_plan(pieces, closed, reference, reach, CHORD, tol()).unwrap() {
779            FixedReferencePlan::Untwisted(chain) => chain,
780            other => panic!("{other:?}"),
781        }
782    }
783
784    fn volume(mesh: &TriMesh) -> Scalar {
785        volume_properties(mesh, Tolerance::new(1e-9, 1e-9).unwrap())
786            .expect("a closed two-manifold")
787            .signed_volume
788    }
789
790    #[test]
791    fn a_full_circle_with_a_normal_reference_is_a_closed_torus() {
792        let chain = untwisted(&[arc(TAU)], false, Vec3::Z);
793        assert!(chain.closed());
794        let mesh = fixed_reference_along_pieces(&square(-0.05, 0.05), &chain).unwrap();
795        // Area times the centreline length (a centred profile).
796        let got = volume(&mesh);
797        assert!((got - 0.01 * TAU).abs() < 0.01 * TAU * 1e-2, "{got}");
798    }
799
800    #[test]
801    fn a_stadium_loop_closes_without_caps() {
802        let stadium = [
803            segment([0.0, -1.0, 0.0], [2.0, -1.0, 0.0]),
804            PathPiece::Arc {
805                centre: Point3::new(2.0, 0.0, 0.0),
806                axis: Vec3::Z,
807                start: Point3::new(2.0, -1.0, 0.0),
808                angle: PI,
809            },
810            segment([2.0, 1.0, 0.0], [0.0, 1.0, 0.0]),
811            PathPiece::Arc {
812                centre: Point3::ZERO,
813                axis: Vec3::Z,
814                start: Point3::new(0.0, 1.0, 0.0),
815                angle: PI,
816            },
817        ];
818        let chain = untwisted(&stadium, true, Vec3::Z);
819        let mesh = fixed_reference_along_pieces(&square(-0.05, 0.05), &chain).unwrap();
820        let length = 4.0 + 2.0 * PI;
821        let got = volume(&mesh);
822        assert!((got - 0.01 * length).abs() < 0.01 * length * 1e-2, "{got}");
823    }
824
825    #[test]
826    fn the_twist_comes_out_of_the_fold_slack() {
827        // The spans get the walls' half less the twist, and so does the fold
828        // slack: a profile whose depth plus that is just below the bend
829        // radius is swept, one at the full half would be refused.
830        let lean: Scalar = 5e-5;
831        let r = Vec3::new(lean.sin(), 0.0, lean.cos());
832        let at = |lowest: Scalar| {
833            let rings = Rings {
834                outer: vec![
835                    Point2::new(-1e-4, lowest),
836                    Point2::new(1e-4, lowest),
837                    Point2::new(1e-4, 0.0),
838                    Point2::new(-1e-4, 0.0),
839                ],
840                holes: Vec::new(),
841            };
842            let plan =
843                fixed_reference_plan(&[arc(1.0)], false, r, ring_reach(&rings), CHORD, tol());
844            let Ok(FixedReferencePlan::Untwisted(chain)) = plan else {
845                panic!("{plan:?}")
846            };
847            (chain.twist(), fixed_reference_along_pieces(&rings, &chain))
848        };
849        let (twist, _) = at(-0.9);
850        assert!(twist > 0.0);
851        let lowest = -(1.0 - 0.5 * CHORD + 0.5 * twist);
852        let (_, mesh) = at(lowest);
853        assert!(mesh.is_ok(), "{mesh:?}");
854    }
855
856    #[test]
857    fn a_profile_reaching_a_bends_axis_is_refused() {
858        let at = |lowest: Scalar| {
859            let rings = Rings {
860                outer: vec![
861                    Point2::new(-0.05, lowest),
862                    Point2::new(0.05, lowest),
863                    Point2::new(0.05, 0.05),
864                    Point2::new(-0.05, 0.05),
865                ],
866                holes: Vec::new(),
867            };
868            let reach = ring_reach(&rings);
869            let plan = fixed_reference_plan(&[arc(1.0)], false, Vec3::Z, reach, CHORD, tol());
870            let Ok(FixedReferencePlan::Untwisted(chain)) = plan else {
871                panic!("{plan:?}")
872            };
873            fixed_reference_along_pieces(&rings, &chain)
874        };
875        // In the normal-reference frame the profile's -y points at the axis.
876        let message = refusal(at(-1.0));
877        assert!(
878            message.contains("fold through the bend's axis"),
879            "{message}"
880        );
881        // Within the half-budget slack of the axis.
882        refusal(at(-1.0 + 0.25 * CHORD));
883        assert!(at(-0.9).is_ok());
884    }
885}