1use axiolid_contracts::{GeomError, GeomResult};
182use axiolid_core::{Point3, Scalar, Vec3};
183use axiolid_mesh::TriMesh;
184
185use crate::loft::{self, Frame, Station};
186use crate::profile::Rings;
187use crate::revolve::rotate;
188use crate::sweep;
189
190mod band;
191mod fixed_reference;
192pub use band::MitreBand;
193pub use fixed_reference::{
194 fixed_reference_along_pieces, fixed_reference_plan, reference_clearance, ring_reach,
195 FixedReferenceChain, FixedReferencePlan,
196};
197
198#[derive(Debug, Clone, Copy, PartialEq)]
200#[non_exhaustive]
201pub enum PathPiece {
202 Segment {
204 start: Point3,
206 end: Point3,
208 },
209 Arc {
214 centre: Point3,
216 axis: Vec3,
218 start: Point3,
220 angle: Scalar,
222 },
223}
224
225impl PathPiece {
226 #[must_use]
228 pub fn start_point(&self) -> Point3 {
229 match *self {
230 Self::Segment { start, .. } | Self::Arc { start, .. } => start,
231 }
232 }
233
234 #[must_use]
236 pub fn end_point(&self) -> Point3 {
237 self.point(1.0)
238 }
239
240 #[must_use]
242 pub fn point(&self, s: Scalar) -> Point3 {
243 match *self {
244 Self::Segment { start, end } => start + (end - start) * s,
245 Self::Arc {
246 centre,
247 axis,
248 start,
249 angle,
250 } => rotate(start, centre, axis, angle * s),
251 }
252 }
253
254 fn tangent_at(&self, point: Point3) -> Vec3 {
257 match *self {
258 Self::Segment { start, end } => (end - start).normalize_or_zero(),
259 Self::Arc {
260 centre,
261 axis,
262 angle,
263 ..
264 } => (axis.cross(point - centre) * angle.signum()).normalize_or_zero(),
265 }
266 }
267
268 #[must_use]
270 pub fn start_tangent(&self) -> Vec3 {
271 self.tangent_at(self.start_point())
272 }
273
274 #[must_use]
276 pub fn end_tangent(&self) -> Vec3 {
277 self.tangent_at(self.end_point())
278 }
279
280 #[must_use]
282 pub fn length(&self) -> Scalar {
283 match *self {
284 Self::Segment { start, end } => (end - start).length(),
285 Self::Arc {
286 centre,
287 start,
288 angle,
289 ..
290 } => (start - centre).length() * angle.abs(),
291 }
292 }
293
294 #[must_use]
296 pub fn reversed(&self) -> Self {
297 match *self {
298 Self::Segment { start, end } => Self::Segment {
299 start: end,
300 end: start,
301 },
302 Self::Arc {
303 centre,
304 axis,
305 angle,
306 ..
307 } => Self::Arc {
308 centre,
309 axis,
310 start: self.end_point(),
311 angle: -angle,
312 },
313 }
314 }
315
316 #[must_use]
319 pub fn between(&self, from: Scalar, to: Scalar) -> Self {
320 match *self {
321 Self::Segment { .. } => Self::Segment {
322 start: self.point(from),
323 end: self.point(to),
324 },
325 Self::Arc {
326 centre,
327 axis,
328 angle,
329 ..
330 } => Self::Arc {
331 centre,
332 axis,
333 start: self.point(from),
334 angle: angle * (to - from),
335 },
336 }
337 }
338
339 fn check(&self, radius: Scalar) -> GeomResult<()> {
341 let finite = |p: Point3| p.is_finite();
342 match *self {
343 Self::Segment { start, end } => {
344 if !(finite(start) && finite(end)) {
345 return Err(GeomError::InvalidInput(
346 "swept disk directrix segment must be finite".to_owned(),
347 ));
348 }
349 if start == end {
350 return Err(GeomError::Degenerate(
351 "swept disk directrix segment has no length".to_owned(),
352 ));
353 }
354 }
355 Self::Arc {
356 centre,
357 axis,
358 start,
359 angle,
360 } => {
361 if !(finite(centre) && finite(axis) && finite(start) && angle.is_finite()) {
362 return Err(GeomError::InvalidInput(
363 "swept disk directrix arc must be finite".to_owned(),
364 ));
365 }
366 let bend = (start - centre).length();
367 if (axis.length() - 1.0).abs() > 1e-9
368 || bend == 0.0
369 || (start - centre).dot(axis).abs() > 1e-9 * bend
370 {
371 return Err(GeomError::InvalidInput(
372 "swept disk directrix arc needs a unit axis normal to a non-zero \
373 radius"
374 .to_owned(),
375 ));
376 }
377 if angle == 0.0 || angle.abs() > core::f64::consts::TAU * (1.0 + 1e-12) {
378 return Err(GeomError::InvalidInput(format!(
379 "swept disk directrix arc must turn by a non-zero angle of at most \
380 a full turn, got {angle}"
381 )));
382 }
383 if bend < radius {
384 return Err(GeomError::InvalidInput(format!(
385 "swept disk radius {radius} is not below the bend radius {bend}: \
386 the tube would fold through the bend's axis"
387 )));
388 }
389 if bend == radius {
390 return Err(horn_torus("bend", radius));
391 }
392 }
393 }
394 Ok(())
395 }
396
397 fn steps(&self, budget: Scalar) -> GeomResult<usize> {
400 match *self {
401 Self::Segment { .. } => Ok(1),
402 Self::Arc {
403 centre,
404 start,
405 angle,
406 ..
407 } => crate::revolve::steps((start - centre).length(), angle, budget).map_err(|_| {
408 GeomError::BudgetExceeded {
409 resource: "swept disk bend steps",
410 }
411 }),
412 }
413 }
414}
415
416pub fn check_disk_along(piece: &PathPiece, radius: Scalar) -> GeomResult<()> {
421 piece.check(radius)
422}
423
424#[must_use]
428pub fn joint_tolerance(chord: Scalar, radius: Scalar) -> Scalar {
429 2.0 * (chord / (8.0 * radius)).min(1.0).asin()
430}
431
432fn turn(a: Vec3, b: Vec3) -> Scalar {
434 a.cross(b).length().atan2(a.dot(b))
435}
436
437#[derive(Debug, Clone, Copy, PartialEq)]
439#[non_exhaustive]
440pub enum MitreSide {
441 Segment {
444 corner: Point3,
446 direction: Vec3,
448 },
449 Arc(MitreBand),
451}
452
453#[derive(Debug, Clone, Copy, PartialEq)]
456#[non_exhaustive]
457pub struct ArcMitre {
458 pub joint: usize,
461 pub normal: Vec3,
464 pub sides: [MitreSide; 2],
466}
467
468#[derive(Debug, Clone, PartialEq)]
472#[non_exhaustive]
473pub struct PipePlan {
474 pub pieces: Vec<PathPiece>,
476 pub closed: bool,
478 pub arc_mitres: Vec<ArcMitre>,
480 pub twist: Scalar,
486}
487
488pub fn swept_disk_along_pieces(
498 pieces: &[PathPiece],
499 radius: Scalar,
500 inner_radius: Option<Scalar>,
501 fillet_radius: Option<Scalar>,
502 chord: Scalar,
503) -> GeomResult<TriMesh> {
504 Pipe::new(pieces, false, radius, inner_radius, fillet_radius, chord)?.mesh()
505}
506
507pub fn swept_disk_along_loop(
513 pieces: &[PathPiece],
514 radius: Scalar,
515 inner_radius: Option<Scalar>,
516 fillet_radius: Option<Scalar>,
517 chord: Scalar,
518) -> GeomResult<TriMesh> {
519 Pipe::new(pieces, true, radius, inner_radius, fillet_radius, chord)?.mesh()
520}
521
522pub fn pipe_plan(
526 pieces: &[PathPiece],
527 closed: bool,
528 radius: Scalar,
529 inner_radius: Option<Scalar>,
530 fillet_radius: Option<Scalar>,
531 chord: Scalar,
532) -> GeomResult<PipePlan> {
533 Ok(Pipe::new(pieces, closed, radius, inner_radius, fillet_radius, chord)?.plan())
534}
535
536const MAX_REFINEMENTS: usize = 16;
538
539const MAX_TWIST_STEPS: usize = 4096;
541
542const ROUNDING_TWIST: Scalar = 1e-3;
546
547const STRIP_OFFSET: Scalar = 1.0 / 16.0;
550
551struct Pipe {
553 path: Vec<PathPiece>,
554 closed: bool,
555 radius: Scalar,
556 inner: Option<Scalar>,
557 chord: Scalar,
558 mitres: Vec<Option<Vec3>>,
562 sharpest: Scalar,
564 bands: Vec<[Option<MitreBand>; 2]>,
566 twist: Scalar,
569 rate: Scalar,
570}
571
572struct Built {
575 stations: Vec<Station>,
576 end: Frame,
577 turned: Scalar,
578}
579
580#[derive(Clone, Copy)]
582enum Close<'a> {
583 Open,
585 To(&'a Station),
587 Own,
589}
590
591struct Sampled {
594 points: Vec<Point3>,
595 tangents: Vec<Vec3>,
596 twists: Vec<Scalar>,
597}
598
599impl Pipe {
600 fn new(
601 pieces: &[PathPiece],
602 closed: bool,
603 radius: Scalar,
604 inner: Option<Scalar>,
605 fillet_radius: Option<Scalar>,
606 chord: Scalar,
607 ) -> GeomResult<Self> {
608 sweep::check_radii(radius, inner)?;
609 if !(chord.is_finite() && chord > 0.0) {
610 return Err(GeomError::InvalidInput(format!(
611 "swept disk chord budget must be positive and finite, got {chord}"
612 )));
613 }
614 if let Some(f) = fillet_radius {
615 if !(f.is_finite() && f > 0.0) {
616 return Err(GeomError::InvalidInput(format!(
617 "swept disk fillet radius must be positive and finite, got {f}"
618 )));
619 }
620 }
621 if pieces.is_empty() {
622 return Err(GeomError::InvalidInput(
623 "a swept disk directrix needs at least one piece".to_owned(),
624 ));
625 }
626 let tolerance = joint_tolerance(chord, radius);
627 let path = match fillet_radius {
628 Some(f) => fillet(pieces, f, radius, tolerance, closed)?,
629 None => pieces.to_vec(),
630 };
631 for piece in &path {
632 piece.check(radius)?;
633 }
634 let m = path.len();
635 let joints = if closed { m } else { m - 1 };
636 let mut mitres: Vec<Option<Vec3>> = Vec::with_capacity(joints);
637 let mut sharpest: Scalar = 0.0;
638 for k in 0..joints {
639 let (a, b) = (path[k], path[(k + 1) % m]);
640 let gap = (b.start_point() - a.end_point()).length();
641 if gap > 0.25 * chord {
642 return Err(GeomError::InvalidInput(format!(
643 "swept disk directrix pieces leave a {gap} gap at joint {k}, more than a \
644 quarter of the {chord} chord budget"
645 )));
646 }
647 let (u, v) = (a.end_tangent(), b.start_tangent());
648 let theta = turn(u, v);
649 if theta <= tolerance {
650 mitres.push(None);
651 continue;
652 }
653 let straight = matches!(
654 (a, b),
655 (PathPiece::Segment { .. }, PathPiece::Segment { .. })
656 );
657 if fillet_radius.is_some() && !straight {
658 return Err(GeomError::InvalidInput(format!(
659 "swept disk directrix turns a corner of {theta} rad beside an arc at joint \
660 {k}, above the {tolerance} rad a tangent-continuous joint may turn at this \
661 chord budget: only corners between two straight segments are filleted"
662 )));
663 }
664 let bisector = u + v;
665 if theta >= core::f64::consts::PI - 1e-9 || bisector.length() <= 1e-9 {
666 return Err(GeomError::InvalidInput(format!(
667 "swept disk directrix reverses at joint {k}: a mitre at half angle would \
668 hold both legs"
669 )));
670 }
671 sharpest = sharpest.max(theta);
672 mitres.push(Some(bisector.normalize()));
673 }
674 let mut bands: Vec<[Option<MitreBand>; 2]> = vec![[None, None]; m];
675 for (k, normal) in mitres.iter().enumerate() {
676 let Some(n) = *normal else { continue };
677 for (i, end) in [(k, 1), ((k + 1) % m, 0)] {
678 let PathPiece::Arc {
679 centre,
680 axis,
681 angle,
682 ..
683 } = path[i]
684 else {
685 continue;
686 };
687 let (toward, corner, normal) = if end == 1 {
690 (axis * angle.signum(), path[i].end_point(), n)
691 } else {
692 (-axis * angle.signum(), path[i].start_point(), -n)
693 };
694 let (back, ahead) = band::reach(centre, toward, corner, normal, radius)
695 .ok_or_else(|| {
696 GeomError::InvalidInput(format!(
697 "swept disk directrix mitres a corner beside an arc at joint {k}, but \
698 the mitre plane does not clear the bend's tube within a quarter turn \
699 of the corner: the bend is too tight for a corner this sharp"
700 ))
701 })?;
702 bands[i][end] = Some(MitreBand {
703 centre,
704 axis: toward,
705 corner,
706 normal,
707 back,
708 ahead,
709 });
710 }
711 }
712 for (i, piece) in path.iter().enumerate() {
713 let PathPiece::Arc { angle, .. } = *piece else {
714 continue;
715 };
716 let need: Scalar = bands[i].iter().flatten().map(|b| b.back).sum();
717 if need > angle.abs() {
718 return Err(GeomError::InvalidInput(format!(
719 "swept disk directrix arc {i} turns {} rad, but the mitre bands beside it \
720 need {need} rad of it: the mitres reach past the arc",
721 angle.abs()
722 )));
723 }
724 }
725 let mut pipe = Self {
726 path,
727 closed,
728 radius,
729 inner,
730 chord,
731 mitres,
732 sharpest,
733 bands,
734 twist: 0.0,
735 rate: 0.0,
736 };
737 pipe.check_segment_reach()?;
738 if closed {
739 pipe.twist = pipe.closing_twist();
740 if pipe.twist.abs() * radius > ROUNDING_TWIST * chord {
741 let clear: Scalar = (0..m).map(|i| pipe.clear(i).1).sum();
742 if clear <= 0.0 {
743 return Err(GeomError::InvalidInput(format!(
744 "swept disk along a closed directrix: a frame carried round the loop \
745 comes back turned by {} rad about the tangent, and no part of the \
746 loop is clear of its mitres to take that twist back",
747 pipe.twist
748 )));
749 }
750 pipe.rate = pipe.twist / clear;
751 }
752 }
753 Ok(pipe)
754 }
755
756 fn before(&self, i: usize) -> Option<usize> {
758 match i.checked_sub(1) {
759 Some(k) => Some(k),
760 None => self.closed.then(|| self.path.len() - 1),
761 }
762 }
763
764 fn after(&self, i: usize) -> Option<usize> {
766 (self.closed || i + 1 < self.path.len()).then_some(i)
767 }
768
769 fn mitre_ends(&self, i: usize) -> [Option<Vec3>; 2] {
771 [
772 self.before(i).and_then(|k| self.mitres[k]),
773 self.after(i).and_then(|k| self.mitres[k]),
774 ]
775 }
776
777 fn check_segment_reach(&self) -> GeomResult<()> {
779 let radius = self.radius;
780 for (i, piece) in self.path.iter().enumerate() {
781 let PathPiece::Segment { start, end } = *piece else {
782 continue;
783 };
784 let ends = self.mitre_ends(i);
785 if ends == [None, None] {
786 continue;
787 }
788 let u = (end - start).normalize();
789 let lean = |n: Option<Vec3>| n.map_or(u, |n| n / u.dot(n));
790 let g = lean(ends[1]) - lean(ends[0]);
791 let reach = radius * (g - u * u.dot(g)).length();
792 let length = (end - start).length();
793 if reach >= length {
794 return Err(GeomError::InvalidInput(format!(
795 "swept disk directrix segment {i} is {length} long, but the mitres at its \
796 ends reach {reach} along it at the disk radius {radius}: the tube would cut \
797 through itself"
798 )));
799 }
800 }
801 Ok(())
802 }
803
804 fn hold(&self, u: Vec3, n: Vec3) -> Scalar {
808 let c = u.dot(n).abs();
809 self.radius * (1.0 - c * c).max(0.0).sqrt() / c
810 }
811
812 fn clear(&self, i: usize) -> (Scalar, Scalar) {
817 match self.path[i] {
818 PathPiece::Segment { start, end } => {
819 let u = (end - start).normalize();
820 let [z0, z1] = self
821 .mitre_ends(i)
822 .map(|n| n.map_or(0.0, |n| self.hold(u, n)));
823 let clear = (end - start).length() - z0 - z1;
824 if clear > 0.0 {
825 (z0, clear)
826 } else {
827 (0.0, 0.0)
828 }
829 }
830 PathPiece::Arc {
831 centre,
832 start,
833 angle,
834 ..
835 } => {
836 let banded: Scalar = self.bands[i].iter().flatten().map(|b| b.back).sum();
837 (
838 0.0,
839 (start - centre).length() * (angle.abs() - banded).max(0.0),
840 )
841 }
842 }
843 }
844
845 fn closing_twist(&self) -> Scalar {
850 let m = self.path.len();
851 let t0 = self.path[0].start_tangent();
852 let x0 = seed_reference(t0).normalize();
853 let mut x = x0;
854 for (i, piece) in self.path.iter().enumerate() {
855 if let PathPiece::Arc { axis, angle, .. } = *piece {
856 x = rotate(x, Point3::ZERO, axis, angle);
857 }
858 x = least_rotation(
859 x,
860 piece.end_tangent(),
861 self.path[(i + 1) % m].start_tangent(),
862 );
863 }
864 let x = (x - t0 * t0.dot(x)).normalize();
865 x.cross(x0).dot(t0).atan2(x.dot(x0))
866 }
867
868 fn plan(&self) -> PipePlan {
869 let m = self.path.len();
870 let side = |i: usize, end: usize| -> Option<MitreSide> {
871 let piece = &self.path[i];
872 match piece {
873 PathPiece::Segment { .. } => Some(MitreSide::Segment {
874 corner: if end == 1 {
875 piece.end_point()
876 } else {
877 piece.start_point()
878 },
879 direction: piece.start_tangent(),
880 }),
881 PathPiece::Arc { .. } => self.bands[i][end].map(MitreSide::Arc),
882 }
883 };
884 let arc_mitres = self
885 .mitres
886 .iter()
887 .enumerate()
888 .filter_map(|(k, normal)| {
889 let normal = (*normal)?;
890 let sides = [side(k, 1)?, side((k + 1) % m, 0)?];
891 matches!(sides, [MitreSide::Arc(_), _] | [_, MitreSide::Arc(_)]).then_some(
892 ArcMitre {
893 joint: k,
894 normal,
895 sides,
896 },
897 )
898 })
899 .collect();
900 PipePlan {
901 pieces: self.path.clone(),
902 closed: self.closed,
903 arc_mitres,
904 twist: self.twist,
905 }
906 }
907
908 fn mesh(&self) -> GeomResult<TriMesh> {
909 let rings = sweep::disk_rings(
910 self.radius,
911 self.inner,
912 0.5 * self.chord * (0.5 * self.sharpest).cos(),
913 )?;
914 let m = self.path.len();
915 let mut stations: Vec<Station> = Vec::new();
916 let mut carried: Option<Frame> = None;
917 let mut turned: Scalar = 0.0;
918 for i in 0..m {
919 let start = self.start_frame(i, carried.as_ref())?;
920 let closing = self.closed && i + 1 == m;
921 let close = match (closing, stations.first()) {
922 (false, _) => Close::Open,
923 (true, Some(first)) => Close::To(first),
924 (true, None) => Close::Own,
925 };
926 let built = self.piece_stations(i, &rings, &start, stations.last(), close, turned)?;
927 let skip = usize::from(!stations.is_empty());
930 let keep = built.stations.len() - usize::from(closing);
931 stations.extend(built.stations.into_iter().take(keep).skip(skip));
932 carried = Some(built.end);
933 turned = built.turned;
934 }
935 let mut mesh = loft::loft(&rings, &stations, self.closed)?;
936 if matches!(crate::extrude::outward_orientation(&mesh), Some(false)) {
939 for triangle in mesh.indices.as_chunks_mut::<3>().0 {
940 triangle.swap(1, 2);
941 }
942 }
943 Ok(mesh)
944 }
945
946 fn start_frame(&self, i: usize, carried: Option<&Frame>) -> GeomResult<Frame> {
949 let piece = &self.path[i];
950 let tangent = piece.start_tangent();
951 let seed = carried.map_or_else(
952 || seed_reference(tangent),
953 |f| least_rotation(f.x, f.x.cross(f.y), tangent),
954 );
955 Frame::from_reference(piece.start_point(), tangent, seed)
956 }
957
958 fn piece_stations(
959 &self,
960 i: usize,
961 rings: &Rings,
962 start: &Frame,
963 joint: Option<&Station>,
964 close: Close<'_>,
965 turned: Scalar,
966 ) -> GeomResult<Built> {
967 match self.path[i] {
968 PathPiece::Segment { .. } => {
969 self.segment_stations(i, rings, start, joint, close, turned)
970 }
971 PathPiece::Arc { .. } => self.arc_stations(i, rings, start, joint, close, turned),
972 }
973 }
974
975 fn segment_stations(
979 &self,
980 i: usize,
981 rings: &Rings,
982 start: &Frame,
983 joint: Option<&Station>,
984 close: Close<'_>,
985 turned: Scalar,
986 ) -> GeomResult<Built> {
987 let piece = self.path[i];
988 let length = piece.length();
989 let tangent = piece.start_tangent();
990 let (held, clear) = self.clear(i);
991 let tail = if clear > 0.0 {
992 length - held - clear
993 } else {
994 0.0
995 };
996 let twist = self.rate * clear;
997 let sample = |budget: Scalar| -> GeomResult<Sampled> {
998 let mut at = vec![0.0];
999 if twist != 0.0 {
1000 let n = twist_steps(twist, budget, self.radius)?;
1001 if held > 0.0 {
1002 at.push(held);
1003 }
1004 at.extend((1..n).map(|k| held + clear * k as Scalar / n as Scalar));
1005 if tail > 0.0 {
1006 at.push(length - tail);
1007 }
1008 }
1009 at.push(length);
1010 Ok(Sampled {
1011 points: at.iter().map(|s| piece.point(s / length)).collect(),
1012 tangents: vec![tangent; at.len()],
1013 twists: at
1014 .iter()
1015 .map(|s| turned + self.rate * (s - held).clamp(0.0, clear))
1016 .collect(),
1017 })
1018 };
1019 let (frames, placed) = bounded_piece(
1020 sample,
1021 rings,
1022 start.x,
1023 self.radius,
1024 0.5 * self.chord,
1025 joint,
1026 self.mitre_ends(i),
1027 close,
1028 )?;
1029 Ok(Built {
1030 stations: placed,
1031 end: last_frame(frames)?,
1032 turned: turned + twist,
1033 })
1034 }
1035
1036 fn arc_stations(
1039 &self,
1040 i: usize,
1041 rings: &Rings,
1042 start: &Frame,
1043 joint: Option<&Station>,
1044 close: Close<'_>,
1045 turned: Scalar,
1046 ) -> GeomResult<Built> {
1047 let piece = self.path[i];
1048 let PathPiece::Arc {
1049 centre,
1050 start: from,
1051 angle,
1052 axis,
1053 } = piece
1054 else {
1055 return Err(not_an_arc());
1056 };
1057 let total = angle.abs();
1058 let along = axis * angle.signum();
1059 let reach_radius = (from - centre).length() + self.radius;
1060 let share = 0.5 * self.chord;
1061 let band_tol = 0.5 * share;
1062 let offset = STRIP_OFFSET * self.chord;
1063 let [first, last] = self.bands[i];
1064 let (head, tail) = (first.map_or(0.0, |b| b.back), last.map_or(0.0, |b| b.back));
1065 let place = |frame: &Frame| loft::place(rings, |p| loft::at(&twisted(frame, turned), p));
1066 let carried = |frame: &Frame, by: Scalar| Frame {
1067 origin: rotate(frame.origin, centre, along, by),
1068 x: rotate(frame.x, Point3::ZERO, along, by),
1069 y: rotate(frame.y, Point3::ZERO, along, by),
1070 };
1071 let mut out: Vec<Station> = Vec::new();
1072 let mut frame = start.clone();
1073 if let Some(band) = first {
1074 let k = self.before(i).ok_or_else(not_an_arc)?;
1075 let shared = self.shared_ring(k, &twisted(start, turned), rings)?;
1076 frame = carried(start, head);
1077 let far = place(&frame);
1078 let strip = matches!(self.path[k], PathPiece::Segment { .. });
1081 let mut toward = band.stations_toward(
1082 &far,
1083 &shared,
1084 strip.then_some(offset),
1085 reach_radius,
1086 band_tol,
1087 )?;
1088 toward.reverse();
1089 out.extend(toward);
1090 out.push(far);
1091 }
1092 let middle = total - head - tail;
1093 let mut twist = 0.0;
1094 if middle > 0.0 {
1095 let part = piece.between(head / total, 1.0 - tail / total);
1096 twist = self.rate * part.length();
1097 let sample = |budget: Scalar| -> GeomResult<Sampled> {
1098 let n = part
1099 .steps(budget)?
1100 .max(twist_steps(twist, budget, self.radius)?);
1101 let points: Vec<Point3> = (0..=n)
1102 .map(|k| part.point(k as Scalar / n as Scalar))
1103 .collect();
1104 let tangents = points.iter().map(|p| part.tangent_at(*p)).collect();
1105 let twists = (0..=n)
1106 .map(|k| turned + twist * k as Scalar / n as Scalar)
1107 .collect();
1108 Ok(Sampled {
1109 points,
1110 tangents,
1111 twists,
1112 })
1113 };
1114 let (joint, close) = (
1115 if first.is_some() { out.last() } else { joint },
1116 if last.is_some() { Close::Open } else { close },
1117 );
1118 let (frames, placed) = bounded_piece(
1119 sample,
1120 rings,
1121 frame.x,
1122 self.radius,
1123 share,
1124 joint,
1125 [None, None],
1126 close,
1127 )?;
1128 let skip = usize::from(first.is_some());
1129 out.extend(placed.into_iter().skip(skip));
1130 frame = last_frame(frames)?;
1131 } else if out.is_empty() {
1132 out.push(place(start));
1133 }
1134 let turned_end = turned + twist;
1135 if let Some(band) = last {
1136 let k = self.after(i).ok_or_else(not_an_arc)?;
1137 let end = carried(&frame, tail);
1138 let target = match close {
1139 Close::To(first) => first.clone(),
1140 Close::Own => out[0].clone(),
1141 Close::Open => {
1142 let next = self.start_frame((k + 1) % self.path.len(), Some(&end))?;
1143 self.shared_ring(k, &twisted(&next, turned_end), rings)?
1144 }
1145 };
1146 let far = out.last().cloned().ok_or_else(not_an_arc)?;
1147 out.extend(band.stations_toward(
1148 &far,
1149 &target,
1150 Some(offset),
1151 reach_radius,
1152 band_tol,
1153 )?);
1154 frame = end;
1155 }
1156 Ok(Built {
1157 stations: out,
1158 end: frame,
1159 turned: turned_end,
1160 })
1161 }
1162
1163 fn shared_ring(&self, k: usize, frame: &Frame, rings: &Rings) -> GeomResult<Station> {
1169 let next = (k + 1) % self.path.len();
1170 let normal = self.mitres[k].ok_or_else(not_an_arc)?;
1171 let square = loft::place(rings, |p| loft::at(frame, p));
1172 match (self.path[k], self.path[next]) {
1173 (PathPiece::Arc { .. }, PathPiece::Arc { .. }) => {
1174 self.bands[next][0].ok_or_else(not_an_arc)?.cut(&square)
1175 }
1176 _ => Ok(mitred(&square, frame, normal)),
1177 }
1178 }
1179}
1180
1181fn not_an_arc() -> GeomError {
1183 GeomError::Degenerate("swept disk: a mitre band without its arc or corner".to_owned())
1184}
1185
1186fn last_frame(frames: Vec<Frame>) -> GeomResult<Frame> {
1187 frames
1188 .into_iter()
1189 .last()
1190 .ok_or_else(|| GeomError::Degenerate("swept disk: a piece without a station".to_owned()))
1191}
1192
1193fn twisted(frame: &Frame, angle: Scalar) -> Frame {
1195 if angle == 0.0 {
1196 return frame.clone();
1197 }
1198 let (s, c) = angle.sin_cos();
1199 Frame {
1200 origin: frame.origin,
1201 x: frame.x * c + frame.y * s,
1202 y: frame.y * c - frame.x * s,
1203 }
1204}
1205
1206fn twist_steps(twist: Scalar, budget: Scalar, radius: Scalar) -> GeomResult<usize> {
1209 if twist == 0.0 {
1210 return Ok(1);
1211 }
1212 let per = 2.0 * (1.0 - (budget / radius).min(1.0)).acos();
1213 let n = (twist.abs() / per).ceil();
1214 if n.is_nan() || n > MAX_TWIST_STEPS as Scalar {
1215 return Err(GeomError::BudgetExceeded {
1216 resource: "swept disk twist steps",
1217 });
1218 }
1219 Ok((n as usize).max(1))
1220}
1221
1222#[allow(clippy::too_many_arguments)]
1230fn bounded_piece(
1231 sample: impl Fn(Scalar) -> GeomResult<Sampled>,
1232 rings: &Rings,
1233 seed: Vec3,
1234 reach: Scalar,
1235 share: Scalar,
1236 joint: Option<&Station>,
1237 ends: [Option<Vec3>; 2],
1238 close: Close<'_>,
1239) -> GeomResult<(Vec<Frame>, Vec<Station>)> {
1240 let mut b = share;
1241 for _ in 0..MAX_REFINEMENTS {
1242 let Sampled {
1243 points,
1244 tangents,
1245 twists,
1246 } = sample(b)?;
1247 let frames = sweep::frames_carried(&points, &tangents, seed)?;
1248 let placing: Vec<Frame> = frames
1249 .iter()
1250 .zip(&twists)
1251 .map(|(f, a)| twisted(f, *a))
1252 .collect();
1253 let mut placed: Vec<Station> = placing
1254 .iter()
1255 .map(|f| loft::place(rings, |p| loft::at(f, p)))
1256 .collect();
1257 let last = placed.len() - 1;
1258 for (k, normal) in [(0, ends[0]), (last, ends[1])] {
1259 if let Some(n) = normal {
1260 placed[k] = mitred(&placed[k], &placing[k], n);
1261 }
1262 }
1263 let shift = joint.map_or(0.0, |j| displacement(j, &placed[0]));
1264 let closing = match close {
1265 Close::Open => 0.0,
1266 Close::To(station) => displacement(station, &placed[last]),
1267 Close::Own => displacement(&placed[0], &placed[last]),
1268 };
1269 let worst = placing
1270 .windows(2)
1271 .zip(placed.windows(2))
1272 .enumerate()
1273 .map(|(k, (f, s))| {
1274 let moved = if k == 0 { shift } else { 0.0 };
1275 let closed = if k + 1 == last { closing } else { 0.0 };
1276 sweep::span_bound(&f[0], &f[1], b, reach)
1277 + loft::span_deviation(&s[0], &s[1])
1278 + moved
1279 + closed
1280 })
1281 .fold(0.0, Scalar::max);
1282 if worst <= share {
1283 return Ok((frames, placed));
1284 }
1285 b *= 0.5;
1286 }
1287 Err(GeomError::BudgetExceeded {
1288 resource: "swept disk directrix refinement",
1289 })
1290}
1291
1292fn mitred(station: &Station, frame: &Frame, normal: Vec3) -> Station {
1296 let tangent = frame.x.cross(frame.y);
1297 let along = tangent.dot(normal);
1298 Station {
1299 loops: station
1300 .loops
1301 .iter()
1302 .map(|ring| {
1303 ring.iter()
1304 .map(|q| *q - tangent * ((*q - frame.origin).dot(normal) / along))
1305 .collect()
1306 })
1307 .collect(),
1308 }
1309}
1310
1311fn horn_torus(what: &str, radius: Scalar) -> GeomError {
1314 GeomError::InvalidInput(format!(
1315 "swept disk radius {radius} equals the {what} radius: the format rule permits a \
1316 fillet radius equal to the disk radius, but the bend is then a horn torus whose \
1317 inner wall degenerates to one point on the bend's axis, a boundary that is not a \
1318 two-manifold and that no closed mesh bounds without meeting itself there"
1319 ))
1320}
1321
1322fn displacement(a: &Station, b: &Station) -> Scalar {
1324 a.loops
1325 .iter()
1326 .zip(&b.loops)
1327 .flat_map(|(p, q)| p.iter().zip(q).map(|(u, v)| (*u - *v).length()))
1328 .fold(0.0, Scalar::max)
1329}
1330
1331fn least_rotation(v: Vec3, from: Vec3, to: Vec3) -> Vec3 {
1333 let axis = from.cross(to);
1334 let sin = axis.length();
1335 if sin == 0.0 {
1336 return v;
1337 }
1338 rotate(v, Point3::ZERO, axis / sin, turn(from, to))
1339}
1340
1341fn seed_reference(t: Vec3) -> Vec3 {
1343 let candidate = if t.x.abs() < 0.9 { Vec3::X } else { Vec3::Y };
1344 candidate - t * t.dot(candidate)
1345}
1346
1347fn fillet(
1351 pieces: &[PathPiece],
1352 fillet: Scalar,
1353 radius: Scalar,
1354 tolerance: Scalar,
1355 closed: bool,
1356) -> GeomResult<Vec<PathPiece>> {
1357 struct Leg {
1360 piece: PathPiece,
1361 dir: Vec3,
1362 trim_start: Scalar,
1363 trim_end: Scalar,
1364 }
1365 let mut legs: Vec<Leg> = Vec::with_capacity(pieces.len());
1366 for piece in pieces {
1367 piece.check(radius)?;
1368 legs.push(Leg {
1369 piece: *piece,
1370 dir: piece.start_tangent(),
1371 trim_start: 0.0,
1372 trim_end: 0.0,
1373 });
1374 }
1375 let m = legs.len();
1376 let mut arcs: Vec<Option<PathPiece>> = vec![None; m];
1378 let first = if closed { 0 } else { 1 };
1379 for k in first..m {
1380 let prev = (k + m - 1) % m;
1381 if prev == k {
1382 continue;
1383 }
1384 let (PathPiece::Segment { .. }, PathPiece::Segment { start: corner, .. }) =
1385 (legs[prev].piece, legs[k].piece)
1386 else {
1387 continue;
1388 };
1389 let (u, v) = (legs[prev].dir, legs[k].dir);
1390 let theta = turn(u, v);
1391 if theta <= tolerance {
1392 continue;
1393 }
1394 if radius > fillet {
1395 return Err(GeomError::InvalidInput(format!(
1396 "swept disk radius {radius} is above the fillet radius {fillet}: the \
1397 tube would fold through the bend's axis"
1398 )));
1399 }
1400 if radius == fillet {
1401 return Err(horn_torus("fillet", radius));
1402 }
1403 let normal = u.cross(v);
1404 if normal.length() <= 1e-12 {
1405 return Err(GeomError::InvalidInput(format!(
1406 "swept disk directrix reverses at corner {k}; no fillet rounds it"
1407 )));
1408 }
1409 let cut = fillet * (0.5 * theta).tan();
1410 legs[prev].trim_end = cut;
1411 legs[k].trim_start = cut;
1412 let tangent_point = corner - u * cut;
1413 let inward = (v - u * u.dot(v)).normalize();
1414 arcs[k] = Some(PathPiece::Arc {
1415 centre: tangent_point + inward * fillet,
1416 axis: normal.normalize(),
1417 start: tangent_point,
1418 angle: theta,
1419 });
1420 }
1421 let mut out = Vec::with_capacity(2 * m);
1422 for (k, leg) in legs.iter().enumerate() {
1423 if k > 0 {
1424 if let Some(arc) = arcs[k] {
1425 out.push(arc);
1426 }
1427 }
1428 let length = leg.piece.length();
1429 let needed = leg.trim_start + leg.trim_end;
1430 if needed == 0.0 {
1431 out.push(leg.piece);
1432 continue;
1433 }
1434 let slack = 1e-9 * length;
1437 if needed > length + slack {
1438 return Err(GeomError::InvalidInput(format!(
1439 "swept disk fillet radius {fillet} does not fit: segment {k} is {length} \
1440 long but its fillets need {needed} of it"
1441 )));
1442 }
1443 if needed < length - slack {
1444 out.push(
1445 leg.piece
1446 .between(leg.trim_start / length, 1.0 - leg.trim_end / length),
1447 );
1448 }
1449 }
1450 if closed {
1451 if let Some(arc) = arcs[0] {
1452 out.push(arc);
1453 }
1454 }
1455 Ok(out)
1456}
1457
1458#[cfg(test)]
1459mod tests {
1460 use super::*;
1461
1462 #[test]
1463 fn a_fillet_is_tangent_to_both_segments() {
1464 let corner = Point3::new(1.0, 0.0, 0.0);
1465 let pieces = [
1466 PathPiece::Segment {
1467 start: Point3::ZERO,
1468 end: corner,
1469 },
1470 PathPiece::Segment {
1471 start: corner,
1472 end: Point3::new(1.0, 1.0, 1.0),
1473 },
1474 ];
1475 let path = fillet(&pieces, 0.2, 0.05, 1e-6, false).unwrap();
1476 assert_eq!(path.len(), 3);
1477 for pair in path.windows(2) {
1478 assert!((pair[0].end_point() - pair[1].start_point()).length() < 1e-12);
1479 assert!(turn(pair[0].end_tangent(), pair[1].start_tangent()) < 1e-12);
1480 }
1481 }
1482
1483 #[test]
1484 fn a_mitre_ring_lies_on_both_cylinders_and_on_the_bisector_plane() {
1485 let corner = Point3::new(1.0, 0.2, -0.3);
1486 let (u, v) = (
1487 Vec3::new(1.0, 0.0, 0.0),
1488 Vec3::new(0.2, 0.7, -0.4).normalize(),
1489 );
1490 let pieces = [
1491 PathPiece::Segment {
1492 start: corner - u,
1493 end: corner,
1494 },
1495 PathPiece::Segment {
1496 start: corner,
1497 end: corner + v,
1498 },
1499 ];
1500 let (r, inner) = (0.05, 0.03);
1501 let mesh = swept_disk_along_pieces(&pieces, r, Some(inner), None, 1e-3).unwrap();
1502 let per = mesh.positions.len() / 3;
1504 let n = (u + v).normalize();
1505 let off = |p: Point3, axis: Vec3| {
1506 let d = p - corner;
1507 (d - axis * d.dot(axis)).length()
1508 };
1509 for p in &mesh.positions[per..2 * per] {
1510 assert!((*p - corner).dot(n).abs() < 1e-12);
1511 let (a, b) = (off(*p, u), off(*p, v));
1512 assert!((a - b).abs() < 1e-12, "{a} {b}");
1513 assert!((a - r).abs() < 1e-12 || (a - inner).abs() < 1e-12, "{a}");
1514 }
1515 }
1516
1517 #[test]
1518 fn an_arc_walked_backwards_retraces_itself() {
1519 let arc = PathPiece::Arc {
1520 centre: Point3::new(0.0, 1.0, 0.0),
1521 axis: Vec3::Z,
1522 start: Point3::ZERO,
1523 angle: 1.0,
1524 };
1525 let back = arc.reversed();
1526 assert!((back.end_point() - arc.start_point()).length() < 1e-12);
1527 assert!((back.start_tangent() + arc.end_tangent()).length() < 1e-12);
1528 let part = arc.between(0.25, 0.75);
1529 assert!((part.length() - 0.5).abs() < 1e-12);
1530 assert!((part.start_point() - arc.point(0.25)).length() < 1e-12);
1531 }
1532}