1use 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#[derive(Debug, Clone, PartialEq)]
76#[non_exhaustive]
77pub enum FixedReferencePlan {
78 Untwisted(FixedReferenceChain),
81 Twisting {
84 piece: usize,
86 },
87}
88
89#[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 #[must_use]
106 pub fn pieces(&self) -> &[PathPiece] {
107 &self.pieces
108 }
109
110 #[must_use]
112 pub const fn reference(&self) -> Vec3 {
113 self.reference
114 }
115
116 #[must_use]
119 pub const fn closed(&self) -> bool {
120 self.closed
121 }
122
123 #[must_use]
125 pub const fn chord(&self) -> Scalar {
126 self.chord
127 }
128
129 #[must_use]
131 pub const fn reach(&self) -> Scalar {
132 self.reach
133 }
134
135 #[must_use]
140 pub const fn walls(&self) -> Scalar {
141 self.walls
142 }
143
144 #[must_use]
148 pub const fn twist(&self) -> Scalar {
149 self.twist
150 }
151}
152
153#[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
168const TWIST_SHARE: Scalar = 0.125;
173const JOINT_SHARE: Scalar = 0.25;
174
175fn 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
211fn 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#[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 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
249pub 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
374fn 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
415fn 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
432pub 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 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 assert!(reference_clearance(&arc(PI), r) < 1e-12);
580 assert!((reference_clearance(&arc(FRAC_PI_4), r) - FRAC_PI_4).abs() < 1e-12);
582 assert!(reference_clearance(&arc(FRAC_PI_2), Vec3::X) < 1e-12);
584 assert!(reference_clearance(&arc(-PI), Vec3::new(1.0, -1.0, 0.0)) < 1e-12);
586 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 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 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 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 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 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 let (largest, _) = arc_twist(&arc(TAU), r);
663 assert!((largest - 2.0 * lean).abs() < 1e-8, "{largest}");
664 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 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 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 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 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 let message = refusal(plan(&[arc(PI + 0.5)], Vec3::new(1.0, 1.0, 0.0)));
740 assert!(message.contains("piece 0"), "{message}");
741 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 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 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 let message = refusal(at(-1.0));
870 assert!(
871 message.contains("fold through the bend's axis"),
872 "{message}"
873 );
874 refusal(at(-1.0 + 0.25 * CHORD));
876 assert!(at(-0.9).is_ok());
877 }
878}