1use axiolid_contracts::{GeomError, GeomResult};
125use axiolid_core::{Point3, Scalar, Vec3};
126use axiolid_mesh::TriMesh;
127
128use crate::loft::{self, Frame, Station};
129use crate::revolve::rotate;
130use crate::sweep;
131
132#[derive(Debug, Clone, Copy, PartialEq)]
134#[non_exhaustive]
135pub enum PathPiece {
136 Segment {
138 start: Point3,
140 end: Point3,
142 },
143 Arc {
148 centre: Point3,
150 axis: Vec3,
152 start: Point3,
154 angle: Scalar,
156 },
157}
158
159impl PathPiece {
160 #[must_use]
162 pub fn start_point(&self) -> Point3 {
163 match *self {
164 Self::Segment { start, .. } | Self::Arc { start, .. } => start,
165 }
166 }
167
168 #[must_use]
170 pub fn end_point(&self) -> Point3 {
171 self.point(1.0)
172 }
173
174 #[must_use]
176 pub fn point(&self, s: Scalar) -> Point3 {
177 match *self {
178 Self::Segment { start, end } => start + (end - start) * s,
179 Self::Arc {
180 centre,
181 axis,
182 start,
183 angle,
184 } => rotate(start, centre, axis, angle * s),
185 }
186 }
187
188 fn tangent_at(&self, point: Point3) -> Vec3 {
191 match *self {
192 Self::Segment { start, end } => (end - start).normalize_or_zero(),
193 Self::Arc {
194 centre,
195 axis,
196 angle,
197 ..
198 } => (axis.cross(point - centre) * angle.signum()).normalize_or_zero(),
199 }
200 }
201
202 #[must_use]
204 pub fn start_tangent(&self) -> Vec3 {
205 self.tangent_at(self.start_point())
206 }
207
208 #[must_use]
210 pub fn end_tangent(&self) -> Vec3 {
211 self.tangent_at(self.end_point())
212 }
213
214 #[must_use]
216 pub fn length(&self) -> Scalar {
217 match *self {
218 Self::Segment { start, end } => (end - start).length(),
219 Self::Arc {
220 centre,
221 start,
222 angle,
223 ..
224 } => (start - centre).length() * angle.abs(),
225 }
226 }
227
228 #[must_use]
230 pub fn reversed(&self) -> Self {
231 match *self {
232 Self::Segment { start, end } => Self::Segment {
233 start: end,
234 end: start,
235 },
236 Self::Arc {
237 centre,
238 axis,
239 angle,
240 ..
241 } => Self::Arc {
242 centre,
243 axis,
244 start: self.end_point(),
245 angle: -angle,
246 },
247 }
248 }
249
250 #[must_use]
253 pub fn between(&self, from: Scalar, to: Scalar) -> Self {
254 match *self {
255 Self::Segment { .. } => Self::Segment {
256 start: self.point(from),
257 end: self.point(to),
258 },
259 Self::Arc {
260 centre,
261 axis,
262 angle,
263 ..
264 } => Self::Arc {
265 centre,
266 axis,
267 start: self.point(from),
268 angle: angle * (to - from),
269 },
270 }
271 }
272
273 fn check(&self, radius: Scalar) -> GeomResult<()> {
275 let finite = |p: Point3| p.is_finite();
276 match *self {
277 Self::Segment { start, end } => {
278 if !(finite(start) && finite(end)) {
279 return Err(GeomError::InvalidInput(
280 "swept disk directrix segment must be finite".to_owned(),
281 ));
282 }
283 if start == end {
284 return Err(GeomError::Degenerate(
285 "swept disk directrix segment has no length".to_owned(),
286 ));
287 }
288 }
289 Self::Arc {
290 centre,
291 axis,
292 start,
293 angle,
294 } => {
295 if !(finite(centre) && finite(axis) && finite(start) && angle.is_finite()) {
296 return Err(GeomError::InvalidInput(
297 "swept disk directrix arc must be finite".to_owned(),
298 ));
299 }
300 let bend = (start - centre).length();
301 if (axis.length() - 1.0).abs() > 1e-9
302 || bend == 0.0
303 || (start - centre).dot(axis).abs() > 1e-9 * bend
304 {
305 return Err(GeomError::InvalidInput(
306 "swept disk directrix arc needs a unit axis normal to a non-zero \
307 radius"
308 .to_owned(),
309 ));
310 }
311 if angle == 0.0 || angle.abs() > core::f64::consts::TAU * (1.0 + 1e-12) {
312 return Err(GeomError::InvalidInput(format!(
313 "swept disk directrix arc must turn by a non-zero angle of at most \
314 a full turn, got {angle}"
315 )));
316 }
317 if bend < radius {
318 return Err(GeomError::InvalidInput(format!(
319 "swept disk radius {radius} is not below the bend radius {bend}: \
320 the tube would fold through the bend's axis"
321 )));
322 }
323 if bend == radius {
324 return Err(horn_torus("bend", radius));
325 }
326 }
327 }
328 Ok(())
329 }
330
331 fn sample(&self, budget: Scalar) -> GeomResult<(Vec<Point3>, Vec<Vec3>)> {
335 let n = match *self {
336 Self::Segment { .. } => 1,
337 Self::Arc {
338 centre,
339 start,
340 angle,
341 ..
342 } => crate::revolve::steps((start - centre).length(), angle, budget).map_err(|_| {
343 GeomError::BudgetExceeded {
344 resource: "swept disk bend steps",
345 }
346 })?,
347 };
348 let points: Vec<Point3> = (0..=n)
349 .map(|k| self.point(k as Scalar / n as Scalar))
350 .collect();
351 let tangents = points.iter().map(|p| self.tangent_at(*p)).collect();
352 Ok((points, tangents))
353 }
354}
355
356#[must_use]
360pub fn joint_tolerance(chord: Scalar, radius: Scalar) -> Scalar {
361 2.0 * (chord / (8.0 * radius)).min(1.0).asin()
362}
363
364fn turn(a: Vec3, b: Vec3) -> Scalar {
366 a.cross(b).length().atan2(a.dot(b))
367}
368
369pub fn swept_disk_along_pieces(
378 pieces: &[PathPiece],
379 radius: Scalar,
380 inner_radius: Option<Scalar>,
381 fillet_radius: Option<Scalar>,
382 chord: Scalar,
383) -> GeomResult<TriMesh> {
384 sweep::check_radii(radius, inner_radius)?;
385 if !(chord.is_finite() && chord > 0.0) {
386 return Err(GeomError::InvalidInput(format!(
387 "swept disk chord budget must be positive and finite, got {chord}"
388 )));
389 }
390 if let Some(f) = fillet_radius {
391 if !(f.is_finite() && f > 0.0) {
392 return Err(GeomError::InvalidInput(format!(
393 "swept disk fillet radius must be positive and finite, got {f}"
394 )));
395 }
396 }
397 if pieces.is_empty() {
398 return Err(GeomError::InvalidInput(
399 "a swept disk directrix needs at least one piece".to_owned(),
400 ));
401 }
402 let tolerance = joint_tolerance(chord, radius);
403 let path = match fillet_radius {
404 Some(f) => fillet(pieces, f, radius, tolerance)?,
405 None => pieces.to_vec(),
406 };
407 for piece in &path {
408 piece.check(radius)?;
409 }
410 let mut mitres: Vec<Option<Vec3>> = Vec::with_capacity(path.len());
413 let mut sharpest: Scalar = 0.0;
414 for (k, pair) in path.windows(2).enumerate() {
415 let gap = (pair[1].start_point() - pair[0].end_point()).length();
416 if gap > 0.25 * chord {
417 return Err(GeomError::InvalidInput(format!(
418 "swept disk directrix pieces leave a {gap} gap at joint {k}, more than a \
419 quarter of the {chord} chord budget"
420 )));
421 }
422 let (u, v) = (pair[0].end_tangent(), pair[1].start_tangent());
423 let theta = turn(u, v);
424 if theta <= tolerance {
425 mitres.push(None);
426 continue;
427 }
428 if !matches!(
429 (pair[0], pair[1]),
430 (PathPiece::Segment { .. }, PathPiece::Segment { .. })
431 ) {
432 let advice = if fillet_radius.is_some() {
433 "only corners between two straight segments are filleted"
434 } else {
435 "only corners between two straight segments are mitred; the mitre plane \
436 cuts the tube round an arc in a different curve than the tube on its \
437 other side"
438 };
439 return Err(GeomError::InvalidInput(format!(
440 "swept disk directrix turns a corner of {theta} rad beside an arc at joint \
441 {k}, above the {tolerance} rad a tangent-continuous joint may turn at this \
442 chord budget: {advice}"
443 )));
444 }
445 let bisector = u + v;
446 if theta >= core::f64::consts::PI - 1e-9 || bisector.length() <= 1e-9 {
447 return Err(GeomError::InvalidInput(format!(
448 "swept disk directrix reverses at joint {k}: a mitre at half angle would \
449 hold both legs"
450 )));
451 }
452 sharpest = sharpest.max(theta);
453 mitres.push(Some(bisector.normalize()));
454 }
455 for (i, piece) in path.iter().enumerate() {
457 let PathPiece::Segment { start, end } = *piece else {
458 continue;
459 };
460 let ends = mitre_ends(&mitres, i);
461 if ends == [None, None] {
462 continue;
463 }
464 let u = (end - start).normalize();
465 let lean = |n: Option<Vec3>| n.map_or(u, |n| n / u.dot(n));
466 let g = lean(ends[1]) - lean(ends[0]);
467 let reach = radius * (g - u * u.dot(g)).length();
468 let length = (end - start).length();
469 if reach >= length {
470 return Err(GeomError::InvalidInput(format!(
471 "swept disk directrix segment {i} is {length} long, but the mitres at its \
472 ends reach {reach} along it at the disk radius {radius}: the tube would cut \
473 through itself"
474 )));
475 }
476 }
477 let rings = sweep::disk_rings(radius, inner_radius, 0.5 * chord * (0.5 * sharpest).cos())?;
478 let share = 0.5 * chord;
479 let mut stations: Vec<Station> = Vec::new();
480 let mut last: Option<Frame> = None;
481 for (i, piece) in path.iter().enumerate() {
482 let tangent = piece.start_tangent();
483 let seed = match &last {
486 None => seed_reference(tangent),
487 Some(frame) => least_rotation(frame.x, frame.x.cross(frame.y), tangent),
488 };
489 let joint = stations.last();
490 let ends = mitre_ends(&mitres, i);
491 let (frames, placed) = bounded_piece(piece, &rings, seed, radius, share, joint, ends)?;
492 let skip = usize::from(joint.is_some());
493 stations.extend(placed.into_iter().skip(skip));
494 last = frames.into_iter().last();
495 }
496 let mut mesh = loft::loft(&rings, &stations, false)?;
497 if matches!(crate::extrude::outward_orientation(&mesh), Some(false)) {
500 for triangle in mesh.indices.chunks_exact_mut(3) {
501 triangle.swap(1, 2);
502 }
503 }
504 Ok(mesh)
505}
506
507const MAX_REFINEMENTS: usize = 16;
509
510fn mitre_ends(mitres: &[Option<Vec3>], i: usize) -> [Option<Vec3>; 2] {
512 [
513 i.checked_sub(1).and_then(|k| mitres[k]),
514 mitres.get(i).copied().flatten(),
515 ]
516}
517
518fn bounded_piece(
524 piece: &PathPiece,
525 rings: &crate::profile::Rings,
526 seed: Vec3,
527 reach: Scalar,
528 share: Scalar,
529 joint: Option<&Station>,
530 ends: [Option<Vec3>; 2],
531) -> GeomResult<(Vec<Frame>, Vec<Station>)> {
532 let mut b = share;
533 for _ in 0..MAX_REFINEMENTS {
534 let (points, tangents) = piece.sample(b)?;
535 let frames = sweep::frames_carried(&points, &tangents, seed)?;
536 let mut placed: Vec<Station> = frames
537 .iter()
538 .map(|f| loft::place(rings, |p| loft::at(f, p)))
539 .collect();
540 let last = placed.len() - 1;
541 for (k, normal) in [(0, ends[0]), (last, ends[1])] {
542 if let Some(n) = normal {
543 placed[k] = mitred(&placed[k], &frames[k], n);
544 }
545 }
546 let shift = joint.map_or(0.0, |j| displacement(j, &placed[0]));
547 let worst = frames
548 .windows(2)
549 .zip(placed.windows(2))
550 .enumerate()
551 .map(|(k, (f, s))| {
552 let moved = if k == 0 { shift } else { 0.0 };
553 sweep::span_bound(&f[0], &f[1], b, reach)
554 + loft::span_deviation(&s[0], &s[1])
555 + moved
556 })
557 .fold(0.0, Scalar::max);
558 if worst <= share {
559 return Ok((frames, placed));
560 }
561 b *= 0.5;
562 }
563 Err(GeomError::BudgetExceeded {
564 resource: "swept disk directrix refinement",
565 })
566}
567
568fn mitred(station: &Station, frame: &Frame, normal: Vec3) -> Station {
572 let tangent = frame.x.cross(frame.y);
573 let along = tangent.dot(normal);
574 Station {
575 loops: station
576 .loops
577 .iter()
578 .map(|ring| {
579 ring.iter()
580 .map(|q| *q - tangent * ((*q - frame.origin).dot(normal) / along))
581 .collect()
582 })
583 .collect(),
584 }
585}
586
587fn horn_torus(what: &str, radius: Scalar) -> GeomError {
590 GeomError::InvalidInput(format!(
591 "swept disk radius {radius} equals the {what} radius: the format rule permits a \
592 fillet radius equal to the disk radius, but the bend is then a horn torus whose \
593 inner wall degenerates to one point on the bend's axis, a boundary that is not a \
594 two-manifold and that no closed mesh bounds without meeting itself there"
595 ))
596}
597
598fn displacement(a: &Station, b: &Station) -> Scalar {
600 a.loops
601 .iter()
602 .zip(&b.loops)
603 .flat_map(|(p, q)| p.iter().zip(q).map(|(u, v)| (*u - *v).length()))
604 .fold(0.0, Scalar::max)
605}
606
607fn least_rotation(v: Vec3, from: Vec3, to: Vec3) -> Vec3 {
609 let axis = from.cross(to);
610 let sin = axis.length();
611 if sin == 0.0 {
612 return v;
613 }
614 rotate(v, Point3::ZERO, axis / sin, turn(from, to))
615}
616
617fn seed_reference(t: Vec3) -> Vec3 {
619 let candidate = if t.x.abs() < 0.9 { Vec3::X } else { Vec3::Y };
620 candidate - t * t.dot(candidate)
621}
622
623fn fillet(
626 pieces: &[PathPiece],
627 fillet: Scalar,
628 radius: Scalar,
629 tolerance: Scalar,
630) -> GeomResult<Vec<PathPiece>> {
631 struct Leg {
634 piece: PathPiece,
635 dir: Vec3,
636 trim_start: Scalar,
637 trim_end: Scalar,
638 }
639 let mut legs: Vec<Leg> = Vec::with_capacity(pieces.len());
640 let mut arcs: Vec<Option<PathPiece>> = Vec::with_capacity(pieces.len());
641 for piece in pieces {
642 piece.check(radius)?;
643 legs.push(Leg {
644 piece: *piece,
645 dir: piece.start_tangent(),
646 trim_start: 0.0,
647 trim_end: 0.0,
648 });
649 }
650 for k in 1..legs.len() {
651 let (PathPiece::Segment { .. }, PathPiece::Segment { start: corner, .. }) =
652 (legs[k - 1].piece, legs[k].piece)
653 else {
654 arcs.push(None);
655 continue;
656 };
657 let (u, v) = (legs[k - 1].dir, legs[k].dir);
658 let theta = turn(u, v);
659 if theta <= tolerance {
660 arcs.push(None);
661 continue;
662 }
663 if radius > fillet {
664 return Err(GeomError::InvalidInput(format!(
665 "swept disk radius {radius} is above the fillet radius {fillet}: the \
666 tube would fold through the bend's axis"
667 )));
668 }
669 if radius == fillet {
670 return Err(horn_torus("fillet", radius));
671 }
672 let normal = u.cross(v);
673 if normal.length() <= 1e-12 {
674 return Err(GeomError::InvalidInput(format!(
675 "swept disk directrix reverses at corner {k}; no fillet rounds it"
676 )));
677 }
678 let cut = fillet * (0.5 * theta).tan();
679 legs[k - 1].trim_end = cut;
680 legs[k].trim_start = cut;
681 let tangent_point = corner - u * cut;
682 let inward = (v - u * u.dot(v)).normalize();
683 arcs.push(Some(PathPiece::Arc {
684 centre: tangent_point + inward * fillet,
685 axis: normal.normalize(),
686 start: tangent_point,
687 angle: theta,
688 }));
689 }
690 let mut out = Vec::with_capacity(2 * legs.len());
691 for (k, leg) in legs.iter().enumerate() {
692 if k > 0 {
693 if let Some(arc) = arcs[k - 1] {
694 out.push(arc);
695 }
696 }
697 let length = leg.piece.length();
698 let needed = leg.trim_start + leg.trim_end;
699 if needed == 0.0 {
700 out.push(leg.piece);
701 continue;
702 }
703 let slack = 1e-9 * length;
706 if needed > length + slack {
707 return Err(GeomError::InvalidInput(format!(
708 "swept disk fillet radius {fillet} does not fit: segment {k} is {length} \
709 long but its fillets need {needed} of it"
710 )));
711 }
712 if needed < length - slack {
713 out.push(
714 leg.piece
715 .between(leg.trim_start / length, 1.0 - leg.trim_end / length),
716 );
717 }
718 }
719 Ok(out)
720}
721
722#[cfg(test)]
723mod tests {
724 use super::*;
725
726 #[test]
727 fn a_fillet_is_tangent_to_both_segments() {
728 let corner = Point3::new(1.0, 0.0, 0.0);
729 let pieces = [
730 PathPiece::Segment {
731 start: Point3::ZERO,
732 end: corner,
733 },
734 PathPiece::Segment {
735 start: corner,
736 end: Point3::new(1.0, 1.0, 1.0),
737 },
738 ];
739 let path = fillet(&pieces, 0.2, 0.05, 1e-6).unwrap();
740 assert_eq!(path.len(), 3);
741 for pair in path.windows(2) {
742 assert!((pair[0].end_point() - pair[1].start_point()).length() < 1e-12);
743 assert!(turn(pair[0].end_tangent(), pair[1].start_tangent()) < 1e-12);
744 }
745 }
746
747 #[test]
748 fn a_mitre_ring_lies_on_both_cylinders_and_on_the_bisector_plane() {
749 let corner = Point3::new(1.0, 0.2, -0.3);
750 let (u, v) = (
751 Vec3::new(1.0, 0.0, 0.0),
752 Vec3::new(0.2, 0.7, -0.4).normalize(),
753 );
754 let pieces = [
755 PathPiece::Segment {
756 start: corner - u,
757 end: corner,
758 },
759 PathPiece::Segment {
760 start: corner,
761 end: corner + v,
762 },
763 ];
764 let (r, inner) = (0.05, 0.03);
765 let mesh = swept_disk_along_pieces(&pieces, r, Some(inner), None, 1e-3).unwrap();
766 let per = mesh.positions.len() / 3;
768 let n = (u + v).normalize();
769 let off = |p: Point3, axis: Vec3| {
770 let d = p - corner;
771 (d - axis * d.dot(axis)).length()
772 };
773 for p in &mesh.positions[per..2 * per] {
774 assert!((*p - corner).dot(n).abs() < 1e-12);
775 let (a, b) = (off(*p, u), off(*p, v));
776 assert!((a - b).abs() < 1e-12, "{a} {b}");
777 assert!((a - r).abs() < 1e-12 || (a - inner).abs() < 1e-12, "{a}");
778 }
779 }
780
781 #[test]
782 fn an_arc_walked_backwards_retraces_itself() {
783 let arc = PathPiece::Arc {
784 centre: Point3::new(0.0, 1.0, 0.0),
785 axis: Vec3::Z,
786 start: Point3::ZERO,
787 angle: 1.0,
788 };
789 let back = arc.reversed();
790 assert!((back.end_point() - arc.start_point()).length() < 1e-12);
791 assert!((back.start_tangent() + arc.end_tangent()).length() < 1e-12);
792 let part = arc.between(0.25, 0.75);
793 assert!((part.length() - 0.5).abs() < 1e-12);
794 assert!((part.start_point() - arc.point(0.25)).length() < 1e-12);
795 }
796}