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 ends just meet and the
258/// chain is open, nothing refused.
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 loops = closed
303        || (last.end_point().distance(first.start_point()) <= closing_gap
304            && turn(last.end_tangent(), first.start_tangent()) <= allowed);
305    let joints = if loops { m } else { m - 1 };
306    for k in 0..joints {
307        let (a, b) = (&pieces[k], &pieces[(k + 1) % m]);
308        let gap = a.end_point().distance(b.start_point());
309        if gap > 0.25 * chord {
310            return Err(refuse(format!(
311                "joint {k} leaves a gap of {gap}, above a quarter of the chord budget {chord}"
312            )));
313        }
314        let theta = turn(a.end_tangent(), b.start_tangent());
315        if theta > allowed {
316            return Err(refuse(format!(
317                "the directrix turns a corner of {theta} rad at joint {k}, above {allowed}: the \
318                 directrix must be tangent continuous"
319            )));
320        }
321    }
322    for (i, piece) in pieces.iter().enumerate() {
323        let clearance = reference_clearance(piece, r);
324        if clearance <= angular {
325            return Err(refuse(format!(
326                "the reference direction comes within {clearance} rad of the directrix tangent \
327                 on piece {i}"
328            )));
329        }
330    }
331    let mut twist: Scalar = 0.0;
332    let mut ends = vec![0.0; m];
333    for (i, piece) in pieces.iter().enumerate() {
334        let (largest, end) = arc_twist(piece, r);
335        let term = twist_term(reach, largest);
336        if term > TWIST_SHARE * chord {
337            return Ok(FixedReferencePlan::Twisting { piece: i });
338        }
339        twist = twist.max(term);
340        ends[i] = twist_term(reach, end);
341    }
342    for k in 0..joints {
343        let (a, b) = (&pieces[k], &pieces[(k + 1) % m]);
344        let (ta, tb) = (a.end_tangent(), b.start_tangent());
345        let xa = Frame::from_reference(a.end_point(), ta, r)?.x;
346        let xb = Frame::from_reference(b.start_point(), tb, r)?.x;
347        let kink = turn(ta, tb);
348        let reseed = turn(super::least_rotation(xa, ta, tb), xb);
349        let shift = reach * (kink + reseed) + ends[k];
350        if shift > JOINT_SHARE * chord {
351            return Err(refuse(format!(
352                "joint {k} turns the profile by {reseed} rad for this reference (a kink of \
353                 {kink} rad), moving it {shift}: a corner for this reference, above a quarter \
354                 of the chord budget {chord}"
355            )));
356        }
357    }
358    let walls = if matches!(pieces, [PathPiece::Segment { .. }]) {
359        0.0
360    } else {
361        0.5 * chord
362    };
363    Ok(FixedReferencePlan::Untwisted(FixedReferenceChain {
364        pieces: pieces.to_vec(),
365        reference: r,
366        closed: loops,
367        chord,
368        reach,
369        walls,
370        twist,
371    }))
372}
373
374/// Refuse a piece with no direction or a malformed arc.
375fn check_shape(i: usize, piece: &PathPiece) -> GeomResult<()> {
376    match *piece {
377        PathPiece::Segment { start, end } => {
378            if !(start.is_finite() && end.is_finite()) {
379                return Err(refuse(format!("piece {i} must be finite")));
380            }
381            if start == end {
382                return Err(GeomError::Degenerate(format!(
383                    "fixed-reference sweep: segment {i} has no length"
384                )));
385            }
386        }
387        PathPiece::Arc {
388            centre,
389            axis,
390            start,
391            angle,
392        } => {
393            if !(centre.is_finite() && axis.is_finite() && start.is_finite() && angle.is_finite()) {
394                return Err(refuse(format!("piece {i} must be finite")));
395            }
396            let bend = (start - centre).length();
397            if (axis.length() - 1.0).abs() > 1e-9
398                || bend == 0.0
399                || (start - centre).dot(axis).abs() > 1e-9 * bend
400            {
401                return Err(refuse(format!(
402                    "arc {i} needs a unit axis normal to a non-zero radius"
403                )));
404            }
405            if angle == 0.0 || angle.abs() > core::f64::consts::TAU * (1.0 + 1e-12) {
406                return Err(refuse(format!(
407                    "arc {i} must turn by a non-zero angle of at most a full turn, got {angle}"
408                )));
409            }
410        }
411    }
412    Ok(())
413}
414
415/// The budget errors a piece's refinement raises, worded for this sweep.
416fn named(error: GeomError) -> GeomError {
417    match error {
418        GeomError::BudgetExceeded {
419            resource: "swept disk directrix refinement",
420        } => GeomError::BudgetExceeded {
421            resource: "fixed-reference sweep directrix refinement",
422        },
423        GeomError::BudgetExceeded {
424            resource: "swept disk bend steps",
425        } => GeomError::BudgetExceeded {
426            resource: "fixed-reference sweep bend steps",
427        },
428        other => other,
429    }
430}
431
432/// Sweep `rings` along a planned chain so that every point of the exact
433/// surface lies within `chain.walls()` of the mesh (see the module notes).
434///
435/// Each piece starts from the fixed-reference frame at its own start, never
436/// carried across a joint; consecutive pieces share one station, and a loop
437/// closes on its first station without caps.
438pub fn fixed_reference_along_pieces(
439    rings: &Rings,
440    chain: &FixedReferenceChain,
441) -> GeomResult<TriMesh> {
442    let reference = chain.reference();
443    let share = 0.5 * chain.chord() - chain.twist();
444    for (i, piece) in chain.pieces().iter().enumerate() {
445        if let PathPiece::Arc { centre, start, .. } = *piece {
446            let frame = Frame::from_reference(start, piece.start_tangent(), reference)?;
447            let bend = (start - centre).length();
448            let inward = (centre - start).normalize_or_zero();
449            let depth = rings
450                .outer
451                .iter()
452                .chain(rings.holes.iter().flatten())
453                .map(|p| (frame.x * p.x + frame.y * p.y).dot(inward))
454                .fold(Scalar::NEG_INFINITY, Scalar::max)
455                + share;
456            if depth >= bend {
457                return Err(refuse(format!(
458                    "the profile reaches {depth} toward the axis of arc {i}, not below its bend \
459                     radius {bend}: the solid would fold through the bend's axis"
460                )));
461            }
462        }
463    }
464    let m = chain.pieces().len();
465    let mut stations: Vec<Station> = Vec::new();
466    for (i, piece) in chain.pieces().iter().enumerate() {
467        let seed = Frame::from_reference(piece.start_point(), piece.start_tangent(), reference)?.x;
468        let closing = chain.closed() && i + 1 == m;
469        let close = match (closing, stations.first()) {
470            (false, _) => Close::Open,
471            (true, Some(first)) => Close::To(first),
472            (true, None) => Close::Own,
473        };
474        let sample = |budget: Scalar| -> GeomResult<Sampled> {
475            let n = piece.steps(budget)?;
476            let points: Vec<_> = (0..=n)
477                .map(|k| piece.point(k as Scalar / n as Scalar))
478                .collect();
479            let tangents = points.iter().map(|p| piece.tangent_at(*p)).collect();
480            Ok(Sampled {
481                twists: vec![0.0; points.len()],
482                points,
483                tangents,
484            })
485        };
486        let (_, placed) = bounded_piece(
487            sample,
488            rings,
489            seed,
490            chain.reach(),
491            share,
492            stations.last(),
493            [None, None],
494            close,
495        )
496        .map_err(named)?;
497        let skip = usize::from(!stations.is_empty());
498        let keep = placed.len() - usize::from(closing);
499        stations.extend(placed.into_iter().take(keep).skip(skip));
500    }
501    let mut mesh = loft::loft(rings, &stations, chain.closed())?;
502    if matches!(crate::extrude::outward_orientation(&mesh), Some(false)) {
503        for triangle in mesh.indices.as_chunks_mut::<3>().0 {
504            triangle.swap(1, 2);
505        }
506    }
507    Ok(mesh)
508}
509
510#[cfg(test)]
511mod tests {
512    use core::f64::consts::{FRAC_PI_2, FRAC_PI_4, PI, TAU};
513
514    use axiolid_core::{Point2, Point3};
515    use axiolid_measure::volume_properties;
516
517    use super::*;
518
519    const CHORD: Scalar = 1e-3;
520
521    fn tol() -> Tolerance {
522        Tolerance::new(1e-6, 1e-2).unwrap()
523    }
524
525    fn square(lo: Scalar, hi: Scalar) -> Rings {
526        Rings {
527            outer: vec![
528                Point2::new(lo, lo),
529                Point2::new(hi, lo),
530                Point2::new(hi, hi),
531                Point2::new(lo, hi),
532            ],
533            holes: Vec::new(),
534        }
535    }
536
537    fn segment(from: [Scalar; 3], to: [Scalar; 3]) -> PathPiece {
538        PathPiece::Segment {
539            start: Point3::new(from[0], from[1], from[2]),
540            end: Point3::new(to[0], to[1], to[2]),
541        }
542    }
543
544    /// A unit arc about Z from (1, 0, 0), counter-clockwise for a positive
545    /// `angle`.
546    fn arc(angle: Scalar) -> PathPiece {
547        PathPiece::Arc {
548            centre: Point3::ZERO,
549            axis: Vec3::Z,
550            start: Point3::new(1.0, 0.0, 0.0),
551            angle,
552        }
553    }
554
555    fn plan(pieces: &[PathPiece], reference: Vec3) -> GeomResult<FixedReferencePlan> {
556        fixed_reference_plan(pieces, false, reference, 0.07, CHORD, tol())
557    }
558
559    fn refusal(result: GeomResult<impl core::fmt::Debug>) -> String {
560        let message = result.unwrap_err().to_string();
561        assert!(message.contains("fixed-reference sweep"), "{message}");
562        message
563    }
564
565    #[test]
566    fn a_segment_is_cleared_by_its_angle_to_the_reference_line() {
567        let s = segment([0.0; 3], [2.0, 0.0, 0.0]);
568        let r = Vec3::new(1.0, 1.0, 0.0);
569        assert!((reference_clearance(&s, r) - FRAC_PI_4).abs() < 1e-12);
570        assert!((reference_clearance(&s.reversed(), r) - FRAC_PI_4).abs() < 1e-12);
571        assert!((reference_clearance(&s, -r) - FRAC_PI_4).abs() < 1e-12);
572        assert!(reference_clearance(&s, Vec3::X) < 1e-12);
573    }
574
575    #[test]
576    fn an_in_plane_arc_is_cleared_by_where_its_tangent_comes_closest() {
577        let r = Vec3::new(1.0, 1.0, 0.0);
578        // Crossing: the tangent is parallel to the reference at 3 pi / 4.
579        assert!(reference_clearance(&arc(PI), r) < 1e-12);
580        // Not crossing: the start is nearest.
581        assert!((reference_clearance(&arc(FRAC_PI_4), r) - FRAC_PI_4).abs() < 1e-12);
582        // Ending at it: the tangent at pi / 2 is -x.
583        assert!(reference_clearance(&arc(FRAC_PI_2), Vec3::X) < 1e-12);
584        // Walked the other way, the same set of tangents.
585        assert!(reference_clearance(&arc(-PI), Vec3::new(1.0, -1.0, 0.0)) < 1e-12);
586        // Leaning out of the plane: the angle to the in-plane part.
587        let lean = Vec3::new(0.0, 1.0, 1.0);
588        assert!((reference_clearance(&arc(1.0), lean) - FRAC_PI_4).abs() < 1e-12);
589        // Normal to the plane: square to every tangent.
590        assert!((reference_clearance(&arc(1.0), Vec3::Z) - FRAC_PI_2).abs() < 1e-12);
591    }
592
593    #[test]
594    fn an_arc_pays_for_the_twist_of_its_lean() {
595        let (reach, exact) = (0.07, 0.5 * CHORD);
596        let walls = |pieces: &[PathPiece], reference: Vec3| match fixed_reference_plan(
597            pieces,
598            false,
599            reference,
600            reach,
601            CHORD,
602            tol(),
603        )
604        .unwrap()
605        {
606            FixedReferencePlan::Untwisted(chain) => Some((chain.walls(), chain.twist())),
607            FixedReferencePlan::Twisting { .. } => None,
608        };
609        let pieces = [arc(1.0)];
610        assert_eq!(walls(&pieces, Vec3::Z), Some((exact, 0.0)));
611        for lean in [
612            |l: Scalar| Vec3::new(0.0, l.sin(), l.cos()),
613            |l: Scalar| Vec3::new(l.sin(), 0.0, l.cos()),
614        ] {
615            // A small lean is paid for out of the walls' half: at most the
616            // rim's chord at twice the lean, and more than nothing.
617            let small = 1e-4;
618            let (w, twist) = walls(&pieces, lean(small)).expect("untwisted");
619            assert_eq!(w, exact);
620            assert!(
621                twist > 0.0 && twist <= reach * 2.0 * small * 1.01,
622                "{twist}"
623            );
624            // A large one is left to the sampled sweep.
625            assert_eq!(walls(&pieces, lean(1e-2)), None);
626        }
627        let twisting = plan(&pieces, Vec3::new(1.0, 1.0, 1.0)).unwrap();
628        assert_eq!(twisting, FixedReferencePlan::Twisting { piece: 0 });
629    }
630
631    #[test]
632    fn a_twist_peaking_inside_the_arc_is_paid_for() {
633        // Over a full turn, or a turn and a bit, the exact frame is off by
634        // up to twice the lean inside the arc, while at the end alone it is
635        // not: the end alone would pay too little.
636        let lean: Scalar = 1e-4;
637        let r = Vec3::new(lean.sin(), 0.0, lean.cos());
638        for angle in [TAU, PI + 0.5] {
639            let FixedReferencePlan::Untwisted(chain) = plan(&[arc(angle)], r).unwrap() else {
640                panic!("twisting")
641            };
642            let (largest, end) = arc_twist(&arc(angle), r);
643            assert!(end < 0.95 * largest, "{angle}: {end} {largest}");
644            assert!(chain.twist() >= twist_term(0.07, largest) * 0.999);
645            assert!(
646                chain.twist() > 1.5 * twist_term(0.07, lean),
647                "{}",
648                chain.twist()
649            );
650        }
651    }
652
653    #[test]
654    fn the_twist_is_the_measured_turn_of_the_exact_frame() {
655        // A reference leaning from the normal by `lean`: over a quarter turn
656        // the exact frame turns off the rotation-minimising one by the lean.
657        let lean: Scalar = 9e-4;
658        let r = Vec3::new(lean.sin(), 0.0, lean.cos());
659        let (largest, end) = arc_twist(&arc(FRAC_PI_2), r);
660        assert!((largest - lean).abs() < 1e-9 && (end - lean).abs() < 1e-9);
661        // Over a full turn it peaks at twice the lean, where q changes sign.
662        let (largest, _) = arc_twist(&arc(TAU), r);
663        assert!((largest - 2.0 * lean).abs() < 1e-8, "{largest}");
664        // Exactly normal: none.
665        assert_eq!(arc_twist(&arc(1.0), Vec3::Z), (0.0, 0.0));
666    }
667
668    #[test]
669    fn a_plan_refuses_by_name() {
670        let bad_inputs = [
671            (plan(&[], Vec3::Z), "at least one piece"),
672            (plan(&[arc(1.0)], Vec3::ZERO), "non-zero"),
673            (
674                plan(&[arc(1.0)], Vec3::new(Scalar::NAN, 0.0, 1.0)),
675                "finite",
676            ),
677            (
678                fixed_reference_plan(&[arc(1.0)], false, Vec3::Z, 0.07, 0.0, tol()),
679                "chord budget",
680            ),
681            (
682                fixed_reference_plan(&[arc(1.0)], false, Vec3::Z, Scalar::NAN, CHORD, tol()),
683                "profile reach",
684            ),
685            (plan(&[segment([0.0; 3], [0.0; 3])], Vec3::Z), "no length"),
686            (plan(&[arc(0.0)], Vec3::Z), "arc 0"),
687            (plan(&[arc(7.0)], Vec3::Z), "full turn"),
688        ];
689        for (result, needle) in bad_inputs {
690            let message = refusal(result);
691            assert!(message.contains(needle), "{needle}: {message}");
692        }
693        // A corner, by joint.
694        let corner = [
695            segment([0.0; 3], [1.0, 0.0, 0.0]),
696            segment([1.0, 0.0, 0.0], [1.0, 1.0, 0.0]),
697        ];
698        let message = refusal(plan(&corner, Vec3::Z));
699        assert!(
700            message.contains("joint 0") && message.contains("tangent continuous"),
701            "{message}"
702        );
703        // The corner a loop closes with.
704        let triangle = [
705            segment([0.0; 3], [1.0, 0.0, 0.0]),
706            segment([1.0, 0.0, 0.0], [1.0, 1.0, 0.0]),
707            segment([1.0, 1.0, 0.0], [0.0; 3]),
708        ];
709        let message = refusal(fixed_reference_plan(
710            &triangle,
711            true,
712            Vec3::Z,
713            0.07,
714            CHORD,
715            tol(),
716        ));
717        assert!(message.contains("joint 0"), "{message}");
718        // A gap.
719        let gap = [
720            segment([0.0; 3], [1.0, 0.0, 0.0]),
721            segment([1.5, 0.0, 0.0], [2.0, 0.0, 0.0]),
722        ];
723        let message = refusal(plan(&gap, Vec3::Z));
724        assert!(
725            message.contains("gap") && message.contains("joint 0"),
726            "{message}"
727        );
728        // A reference parallel to a segment.
729        let line = [
730            segment([0.0; 3], [1.0, 0.0, 0.0]),
731            segment([1.0, 0.0, 0.0], [2.0, 0.0, 0.0]),
732        ];
733        let message = refusal(plan(&line, Vec3::X));
734        assert!(
735            message.contains("piece 0") && message.contains("tangent"),
736            "{message}"
737        );
738        // An in-plane arc whose tangent reaches the reference.
739        let message = refusal(plan(&[arc(PI + 0.5)], Vec3::new(1.0, 1.0, 0.0)));
740        assert!(message.contains("piece 0"), "{message}");
741        // A kink within the joint tolerance that turns the re-seeded profile
742        // far: the reference nearly along the tangent.
743        let kinked = [
744            segment([0.0; 3], [1.0, 0.0, 0.0]),
745            segment([1.0, 0.0, 0.0], [2.0, 0.003, 0.0]),
746        ];
747        let flat = Vec3::new(0.05f64.cos(), 0.0, 0.05f64.sin());
748        let message = refusal(plan(&kinked, flat));
749        assert!(
750            message.contains("joint 0") && message.contains("corner for this reference"),
751            "{message}"
752        );
753        assert!(plan(&kinked, Vec3::Z).is_ok());
754    }
755
756    #[test]
757    fn walls_are_free_only_along_one_segment() {
758        let one = segment([0.0; 3], [1.0, 0.0, 0.0]);
759        let two = segment([1.0, 0.0, 0.0], [2.0, 0.0, 0.0]);
760        let walls = |pieces: &[PathPiece]| match plan(pieces, Vec3::Z).unwrap() {
761            FixedReferencePlan::Untwisted(chain) => chain.walls(),
762            other => panic!("{other:?}"),
763        };
764        assert_eq!(walls(&[one]), 0.0);
765        assert_eq!(walls(&[one, two]), 0.5 * CHORD);
766        assert_eq!(walls(&[arc(1.0)]), 0.5 * CHORD);
767    }
768
769    fn untwisted(pieces: &[PathPiece], closed: bool, reference: Vec3) -> FixedReferenceChain {
770        let reach = ring_reach(&square(-0.05, 0.05));
771        match fixed_reference_plan(pieces, closed, reference, reach, CHORD, tol()).unwrap() {
772            FixedReferencePlan::Untwisted(chain) => chain,
773            other => panic!("{other:?}"),
774        }
775    }
776
777    fn volume(mesh: &TriMesh) -> Scalar {
778        volume_properties(mesh, Tolerance::new(1e-9, 1e-9).unwrap())
779            .expect("a closed two-manifold")
780            .signed_volume
781    }
782
783    #[test]
784    fn a_full_circle_with_a_normal_reference_is_a_closed_torus() {
785        let chain = untwisted(&[arc(TAU)], false, Vec3::Z);
786        assert!(chain.closed());
787        let mesh = fixed_reference_along_pieces(&square(-0.05, 0.05), &chain).unwrap();
788        // Area times the centreline length (a centred profile).
789        let got = volume(&mesh);
790        assert!((got - 0.01 * TAU).abs() < 0.01 * TAU * 1e-2, "{got}");
791    }
792
793    #[test]
794    fn a_stadium_loop_closes_without_caps() {
795        let stadium = [
796            segment([0.0, -1.0, 0.0], [2.0, -1.0, 0.0]),
797            PathPiece::Arc {
798                centre: Point3::new(2.0, 0.0, 0.0),
799                axis: Vec3::Z,
800                start: Point3::new(2.0, -1.0, 0.0),
801                angle: PI,
802            },
803            segment([2.0, 1.0, 0.0], [0.0, 1.0, 0.0]),
804            PathPiece::Arc {
805                centre: Point3::ZERO,
806                axis: Vec3::Z,
807                start: Point3::new(0.0, 1.0, 0.0),
808                angle: PI,
809            },
810        ];
811        let chain = untwisted(&stadium, true, Vec3::Z);
812        let mesh = fixed_reference_along_pieces(&square(-0.05, 0.05), &chain).unwrap();
813        let length = 4.0 + 2.0 * PI;
814        let got = volume(&mesh);
815        assert!((got - 0.01 * length).abs() < 0.01 * length * 1e-2, "{got}");
816    }
817
818    #[test]
819    fn the_twist_comes_out_of_the_fold_slack() {
820        // The spans get the walls' half less the twist, and so does the fold
821        // slack: a profile whose depth plus that is just below the bend
822        // radius is swept, one at the full half would be refused.
823        let lean: Scalar = 5e-5;
824        let r = Vec3::new(lean.sin(), 0.0, lean.cos());
825        let at = |lowest: Scalar| {
826            let rings = Rings {
827                outer: vec![
828                    Point2::new(-1e-4, lowest),
829                    Point2::new(1e-4, lowest),
830                    Point2::new(1e-4, 0.0),
831                    Point2::new(-1e-4, 0.0),
832                ],
833                holes: Vec::new(),
834            };
835            let plan =
836                fixed_reference_plan(&[arc(1.0)], false, r, ring_reach(&rings), CHORD, tol());
837            let Ok(FixedReferencePlan::Untwisted(chain)) = plan else {
838                panic!("{plan:?}")
839            };
840            (chain.twist(), fixed_reference_along_pieces(&rings, &chain))
841        };
842        let (twist, _) = at(-0.9);
843        assert!(twist > 0.0);
844        let lowest = -(1.0 - 0.5 * CHORD + 0.5 * twist);
845        let (_, mesh) = at(lowest);
846        assert!(mesh.is_ok(), "{mesh:?}");
847    }
848
849    #[test]
850    fn a_profile_reaching_a_bends_axis_is_refused() {
851        let at = |lowest: Scalar| {
852            let rings = Rings {
853                outer: vec![
854                    Point2::new(-0.05, lowest),
855                    Point2::new(0.05, lowest),
856                    Point2::new(0.05, 0.05),
857                    Point2::new(-0.05, 0.05),
858                ],
859                holes: Vec::new(),
860            };
861            let reach = ring_reach(&rings);
862            let plan = fixed_reference_plan(&[arc(1.0)], false, Vec3::Z, reach, CHORD, tol());
863            let Ok(FixedReferencePlan::Untwisted(chain)) = plan else {
864                panic!("{plan:?}")
865            };
866            fixed_reference_along_pieces(&rings, &chain)
867        };
868        // In the normal-reference frame the profile's -y points at the axis.
869        let message = refusal(at(-1.0));
870        assert!(
871            message.contains("fold through the bend's axis"),
872            "{message}"
873        );
874        // Within the half-budget slack of the axis.
875        refusal(at(-1.0 + 0.25 * CHORD));
876        assert!(at(-0.9).is_ok());
877    }
878}