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 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 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
381fn 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
422fn 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
439pub 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 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 assert!(reference_clearance(&arc(PI), r) < 1e-12);
587 assert!((reference_clearance(&arc(FRAC_PI_4), r) - FRAC_PI_4).abs() < 1e-12);
589 assert!(reference_clearance(&arc(FRAC_PI_2), Vec3::X) < 1e-12);
591 assert!(reference_clearance(&arc(-PI), Vec3::new(1.0, -1.0, 0.0)) < 1e-12);
593 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 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 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 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 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 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 let (largest, _) = arc_twist(&arc(TAU), r);
670 assert!((largest - 2.0 * lean).abs() < 1e-8, "{largest}");
671 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 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 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 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 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 let message = refusal(plan(&[arc(PI + 0.5)], Vec3::new(1.0, 1.0, 0.0)));
747 assert!(message.contains("piece 0"), "{message}");
748 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 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 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 let message = refusal(at(-1.0));
877 assert!(
878 message.contains("fold through the bend's axis"),
879 "{message}"
880 );
881 refusal(at(-1.0 + 0.25 * CHORD));
883 assert!(at(-0.9).is_ok());
884 }
885}