1use axiolid_contracts::{GeomError, GeomResult};
9use axiolid_core::{Point2, Point3, Scalar, Tolerance, Vec3};
10use axiolid_mesh::TriMesh;
11
12use crate::loft::{self, Frame, Station};
13use crate::profile::Rings;
14
15fn blend_rings(a: &Rings, b: &Rings, t: Scalar) -> GeomResult<Rings> {
21 if a.outer.len() != b.outer.len() || a.holes.len() != b.holes.len() {
22 return Err(GeomError::InvalidInput(
23 "tapered sweep profiles must share their ring structure".to_owned(),
24 ));
25 }
26 let mut holes = Vec::with_capacity(a.holes.len());
27 for (ha, hb) in a.holes.iter().zip(&b.holes) {
28 if ha.len() != hb.len() {
29 return Err(GeomError::InvalidInput(
30 "tapered sweep holes must share their point count".to_owned(),
31 ));
32 }
33 holes.push(
34 ha.iter()
35 .zip(hb)
36 .map(|(p, q)| loft::blend(*p, *q, t))
37 .collect(),
38 );
39 }
40 Ok(Rings {
41 outer: a
42 .outer
43 .iter()
44 .zip(&b.outer)
45 .map(|(p, q)| loft::blend(*p, *q, t))
46 .collect(),
47 holes,
48 })
49}
50
51pub fn tapered_extrude(
56 start: &Rings,
57 end: &Rings,
58 direction: Vec3,
59 depth: Scalar,
60) -> GeomResult<TriMesh> {
61 if !depth.is_finite() || depth <= 0.0 {
62 return Err(GeomError::InvalidInput(format!(
63 "extrusion depth must be positive and finite, got {depth}"
64 )));
65 }
66 let d = direction.normalize_or_zero();
67 if d == Vec3::ZERO {
68 return Err(GeomError::InvalidInput(
69 "extrusion direction must be a non-zero vector".to_owned(),
70 ));
71 }
72 let far = blend_rings(start, end, 1.0)?;
75 let s0 = loft::place(start, |p| Point3::new(p.x, p.y, 0.0));
76 let s1 = loft::place(&far, |p| Point3::new(p.x, p.y, 0.0) + d * depth);
77 loft::loft_tapered(start, &far, &[s0, s1])
78}
79
80fn tapered_welds(
95 start: &Rings,
96 end: &Rings,
97 flags: &[bool],
98 axis_origin: Point3,
99 axis_direction: Vec3,
100 tolerance: Tolerance,
101) -> GeomResult<(Vec<bool>, Scalar)> {
102 let outer = start.outer.len();
103 let holes: usize = start.holes.iter().map(Vec::len).sum();
104 let at_start = flags[..outer]
105 .iter()
106 .chain(&flags[2 * outer..2 * outer + holes]);
107 let at_end = flags[outer..2 * outer]
108 .iter()
109 .chain(&flags[2 * outer + holes..]);
110 let points = |rings: &Rings| -> Vec<Point2> {
111 rings
112 .outer
113 .iter()
114 .chain(rings.holes.iter().flatten())
115 .copied()
116 .collect()
117 };
118 let (from, to) = (points(start), points(end));
119 let refuse = |input| GeomError::UnsupportedInput {
120 backend: crate::BACKEND_ID,
121 operation: axiolid_contracts::Operation::Sweep,
122 input,
123 };
124 let mut welded = Vec::with_capacity(outer + holes);
125 let mut slide: Scalar = 0.0;
126 for (f, (&here, &there)) in at_start.zip(at_end).enumerate() {
127 if here != there {
128 return Err(refuse(
129 "tapered revolution of a profile touching the axis at one end only",
130 ));
131 }
132 if here {
133 let shift = crate::revolve::slide_along_axis(
137 axis_origin,
138 axis_direction,
139 from[f],
140 to[f],
141 tolerance,
142 );
143 match shift {
144 Some(shift) => slide = slide.max(shift),
145 None => {
146 return Err(refuse(
147 "tapered revolution of a profile whose vertex on the axis slides along it",
148 ))
149 }
150 }
151 }
152 welded.push(here);
153 }
154 Ok((welded, slide))
155}
156
157pub fn tapered_revolve(
181 start: &Rings,
182 end: &Rings,
183 axis_origin: Point3,
184 axis_direction: Vec3,
185 angle: Scalar,
186 tolerance: Tolerance,
187) -> GeomResult<TriMesh> {
188 if !angle.is_finite() || angle == 0.0 {
189 return Err(GeomError::InvalidInput(format!(
190 "revolution angle must be finite and non-zero, got {angle}"
191 )));
192 }
193 let dir = axis_direction.normalize_or_zero();
194 if dir == Vec3::ZERO {
195 return Err(GeomError::InvalidInput(
196 "revolution axis must be a finite non-zero direction".to_owned(),
197 ));
198 }
199 blend_rings(start, end, 1.0)?;
201 let full = (angle.abs() - core::f64::consts::TAU).abs() <= 1e-9;
211 let contact = crate::revolve::check_blend_axis_side(
212 start,
213 end,
214 axis_origin,
215 axis_direction,
216 tolerance,
217 full,
218 )?;
219 let (start, end) = (&contact.start, &contact.end);
222 let far = blend_rings(start, end, 1.0)?;
223 let (welded, slide) = tapered_welds(
224 start,
225 end,
226 &contact.flags,
227 axis_origin,
228 axis_direction,
229 tolerance,
230 )?;
231 let mut max_r: Scalar = 0.0;
232 let starts = start.outer.iter().chain(start.holes.iter().flatten());
233 let ends = far.outer.iter().chain(far.holes.iter().flatten());
234 let mut travel: Scalar = 0.0;
235 for (p, q) in starts.zip(ends) {
236 for p in [p, q] {
237 let v = Point3::new(p.x, p.y, 0.0) - axis_origin;
238 max_r = max_r.max((v - dir * dir.dot(v)).length());
239 }
240 travel = travel.max((*q - *p).length());
241 }
242 let budget = crate::revolve::budget_after_projection(tolerance, contact.projection + slide)?;
243 let curvature = angle * angle * max_r + 2.0 * angle.abs() * travel;
244 let mut n = crate::revolve::steps(max_r, angle, budget)?;
245 let stations = loop {
246 let mut stations = Vec::with_capacity(n + 1);
247 for s in (0..=n).rev() {
248 let t = (s as Scalar) / (n as Scalar);
249 let ring = blend_rings(start, end, t)?;
250 let a = angle * t;
251 stations.push(loft::place(&ring, |p| {
252 crate::revolve::rotate(Point3::new(p.x, p.y, 0.0), axis_origin, dir, a)
253 }));
254 }
255 let chord = curvature / (8.0 * (n * n) as Scalar);
256 let twist = stations
257 .windows(2)
258 .map(|w| loft::span_deviation(&w[0], &w[1]))
259 .fold(0.0, Scalar::max);
260 if chord + twist <= budget {
261 break stations;
262 }
263 n = crate::revolve::grow(n)?;
264 };
265 let mut mesh = loft::loft_tapered_welded(&far, start, &stations, &welded)?;
266 if matches!(crate::extrude::outward_orientation(&mesh), Some(false)) {
272 for triangle in mesh.indices.as_chunks_mut::<3>().0 {
273 triangle.swap(1, 2);
274 }
275 }
276 Ok(mesh)
277}
278
279pub fn fixed_reference_sweep(
285 rings: &Rings,
286 path: &[Point3],
287 reference: Vec3,
288) -> GeomResult<TriMesh> {
289 let frames = frames_along(path, None, |_| reference)?;
290 let stations: Vec<Station> = frames
291 .iter()
292 .map(|f| loft::place(rings, |p| loft::at(f, p)))
293 .collect();
294 loft::loft(rings, &stations, false)
295}
296
297#[derive(Debug, Clone, Default, PartialEq)]
300pub struct SampledPath {
301 pub points: Vec<Point3>,
303 pub end_tangents: Option<[Vec3; 2]>,
315}
316
317const MAX_REFINEMENTS: usize = 16;
319
320fn bounded_stations(
347 rings: &Rings,
348 budget: Scalar,
349 mut frames_at: impl FnMut(Scalar) -> GeomResult<(Vec<Frame>, bool)>,
350) -> GeomResult<Vec<Station>> {
351 if !(budget.is_finite() && budget > 0.0) {
352 return Err(GeomError::InvalidInput(format!(
353 "sweep chord budget must be positive and finite, got {budget}"
354 )));
355 }
356 let reach = rings
357 .outer
358 .iter()
359 .chain(rings.holes.iter().flatten())
360 .map(|p| p.x.hypot(p.y))
361 .fold(0.0, Scalar::max);
362 let mut b = budget;
363 for _ in 0..MAX_REFINEMENTS {
364 let (frames, smooth) = frames_at(b)?;
365 let stations: Vec<Station> = frames
366 .iter()
367 .map(|f| loft::place(rings, |p| loft::at(f, p)))
368 .collect();
369 if !smooth {
370 return Ok(stations);
371 }
372 let worst = frames
373 .windows(2)
374 .zip(stations.windows(2))
375 .map(|(f, s)| span_bound(&f[0], &f[1], b, reach) + loft::span_deviation(&s[0], &s[1]))
376 .fold(0.0, Scalar::max);
377 if worst <= budget {
378 return Ok(stations);
379 }
380 b *= 0.5;
381 }
382 Err(GeomError::BudgetExceeded {
383 resource: "sweep directrix refinement",
384 })
385}
386
387pub(crate) fn span_bound(from: &Frame, to: &Frame, budget: Scalar, reach: Scalar) -> Scalar {
389 let (t0, t1) = (from.x.cross(from.y), to.x.cross(to.y));
390 let turn = t0.dot(t1).clamp(-1.0, 1.0).acos();
391 let chord = (to.origin - from.origin).length();
392 let directrix = budget.min(0.5 * chord * (0.25 * turn).tan());
393 let trace = from.x.dot(to.x) + from.y.dot(to.y) + t0.dot(t1);
394 let rotation = (0.5 * (trace - 1.0)).clamp(-1.0, 1.0).acos();
395 directrix + reach * (1.0 - (0.5 * rotation).cos())
396}
397
398pub fn fixed_reference_sweep_within(
407 rings: &Rings,
408 mut sample: impl FnMut(Scalar) -> GeomResult<SampledPath>,
409 reference: Vec3,
410 budget: Scalar,
411) -> GeomResult<TriMesh> {
412 let stations = bounded_stations(rings, budget, |b| {
413 let path = sample(b)?;
414 let frames = frames_along(&path.points, path.end_tangents, |_| reference)?;
415 Ok((frames, path.end_tangents.is_some()))
416 })?;
417 loft::loft(rings, &stations, false)
418}
419
420pub fn surface_curve_sweep_within(
427 rings: &Rings,
428 mut sample: impl FnMut(Scalar) -> GeomResult<(SampledPath, Vec<Vec3>)>,
429 budget: Scalar,
430) -> GeomResult<TriMesh> {
431 let stations = bounded_stations(rings, budget, |b| {
432 let (path, normals) = sample(b)?;
433 if normals.len() != path.points.len() {
434 return Err(GeomError::InvalidInput(
435 "a surface curve sweep needs one surface normal per directrix point".to_owned(),
436 ));
437 }
438 let frames = frames_along(&path.points, path.end_tangents, |i| normals[i])?;
439 Ok((frames, path.end_tangents.is_some()))
440 })?;
441 loft::loft(rings, &stations, false)
442}
443
444fn frames_along(
451 path: &[Point3],
452 ends: Option<[Vec3; 2]>,
453 up: impl Fn(usize) -> Vec3,
454) -> GeomResult<Vec<Frame>> {
455 let tangents = tangents_along(path, ends)?;
456 let mut frames = Vec::with_capacity(path.len());
457 for (i, (point, tangent)) in path.iter().zip(tangents).enumerate() {
458 frames.push(Frame::from_reference(*point, tangent, up(i))?);
459 }
460 Ok(frames)
461}
462
463pub fn linear_extrusion_normals(path: &[Point3], direction: Vec3) -> GeomResult<Vec<Vec3>> {
464 if path.len() < 2 {
465 return Err(GeomError::InvalidInput(
466 "a linear-extrusion surface needs at least two directrix points".into(),
467 ));
468 }
469 let direction = direction.normalize_or_zero();
470 if direction == Vec3::ZERO {
471 return Err(GeomError::InvalidInput(
472 "linear-extrusion direction must be finite and non-zero".into(),
473 ));
474 }
475 let mut normals = Vec::with_capacity(path.len());
476 for i in 0..path.len() {
477 let tangent = if i == 0 {
478 path[1] - path[0]
479 } else if i + 1 == path.len() {
480 path[i] - path[i - 1]
481 } else {
482 (path[i] - path[i - 1]).normalize_or_zero()
483 + (path[i + 1] - path[i]).normalize_or_zero()
484 };
485 let normal = tangent.cross(direction).normalize_or_zero();
486 if normal == Vec3::ZERO {
487 return Err(GeomError::Degenerate(
488 "directrix tangent is parallel to linear-extrusion direction".into(),
489 ));
490 }
491 normals.push(normal);
492 }
493 Ok(normals)
494}
495
496pub fn surface_curve_sweep(
503 rings: &Rings,
504 path: &[Point3],
505 normals: &[Vec3],
506) -> GeomResult<TriMesh> {
507 if normals.len() != path.len() {
508 return Err(GeomError::InvalidInput(
509 "a surface curve sweep needs one surface normal per directrix point".to_owned(),
510 ));
511 }
512 let frames = frames_along(path, None, |i| normals[i])?;
513 let stations: Vec<Station> = frames
514 .iter()
515 .map(|f| loft::place(rings, |p| loft::at(f, p)))
516 .collect();
517 loft::loft(rings, &stations, false)
518}
519
520pub fn swept_disk(
527 path: &[Point3],
528 radius: Scalar,
529 inner_radius: Option<Scalar>,
530 fillet_radius: Option<Scalar>,
531 tolerance: Tolerance,
532) -> GeomResult<TriMesh> {
533 check_disk(radius, inner_radius, fillet_radius)?;
534 let rings = disk_rings(radius, inner_radius, tolerance.linear())?;
544 let frames = rotation_minimising_frames(path, None)?;
545 let stations: Vec<Station> = frames
546 .iter()
547 .map(|f| loft::place(&rings, |p| loft::at(f, p)))
548 .collect();
549 loft::loft(&rings, &stations, false)
550}
551
552pub fn swept_disk_within(
566 mut sample: impl FnMut(Scalar) -> GeomResult<SampledPath>,
567 radius: Scalar,
568 inner_radius: Option<Scalar>,
569 fillet_radius: Option<Scalar>,
570 chord: Scalar,
571) -> GeomResult<TriMesh> {
572 check_disk(radius, inner_radius, fillet_radius)?;
573 let rings = disk_rings(radius, inner_radius, 0.5 * chord)?;
574 let stations = bounded_stations(&rings, 0.5 * chord, |b| {
575 let path = sample(b)?;
576 let frames = rotation_minimising_frames(&path.points, path.end_tangents)?;
577 Ok((frames, path.end_tangents.is_some()))
578 })?;
579 loft::loft(&rings, &stations, false)
580}
581
582fn check_disk(
589 radius: Scalar,
590 inner_radius: Option<Scalar>,
591 fillet_radius: Option<Scalar>,
592) -> GeomResult<()> {
593 if fillet_radius.is_some() {
594 return Err(GeomError::Unsupported {
595 backend: crate::BACKEND_ID,
596 operation: axiolid_contracts::Operation::Sweep,
597 });
598 }
599 check_radii(radius, inner_radius)
600}
601
602pub(crate) fn check_radii(radius: Scalar, inner_radius: Option<Scalar>) -> GeomResult<()> {
604 if !radius.is_finite() || radius <= 0.0 {
605 return Err(GeomError::InvalidInput(format!(
606 "swept disk radius must be positive and finite, got {radius}"
607 )));
608 }
609 if let Some(inner) = inner_radius {
610 if !inner.is_finite() || inner <= 0.0 || inner >= radius {
611 return Err(GeomError::InvalidInput(format!(
612 "swept disk inner radius must be positive and below {radius}, got {inner}"
613 )));
614 }
615 }
616 Ok(())
617}
618
619fn rotation_minimising_frames(path: &[Point3], ends: Option<[Vec3; 2]>) -> GeomResult<Vec<Frame>> {
634 let seed = seed_reference(path)?;
635 let tangents = tangents_along(path, ends)?;
636 frames_carried(path, &tangents, seed)
637}
638
639pub(crate) fn frames_carried(
643 path: &[Point3],
644 tangents: &[Vec3],
645 seed: Vec3,
646) -> GeomResult<Vec<Frame>> {
647 let mut reference = seed;
648 let mut frames = Vec::with_capacity(path.len());
649 for i in 0..path.len() {
650 frames.push(Frame::from_reference(path[i], tangents[i], reference)?);
651 let Some(next) = path.get(i + 1) else { break };
652 let chord = *next - path[i];
653 let c1 = chord.dot(chord);
654 if c1 == 0.0 {
655 continue;
658 }
659 let reflected_ref = reference - chord * (2.0 / c1 * chord.dot(reference));
660 let reflected_tan = tangents[i] - chord * (2.0 / c1 * chord.dot(tangents[i]));
661 let fix = tangents[i + 1] - reflected_tan;
662 let c2 = fix.dot(fix);
663 reference = if c2 == 0.0 {
664 reflected_ref
665 } else {
666 reflected_ref - fix * (2.0 / c2 * fix.dot(reflected_ref))
667 };
668 }
669 Ok(frames)
670}
671
672fn tangents_along(path: &[Point3], ends: Option<[Vec3; 2]>) -> GeomResult<Vec<Vec3>> {
675 if path.len() < 2 {
676 return Err(GeomError::InvalidInput(format!(
677 "a sweep directrix needs at least two points, got {}",
678 path.len()
679 )));
680 }
681 (0..path.len())
682 .map(|i| {
683 let raw = if i == 0 {
684 ends.map_or(path[1] - path[0], |[start, _]| start)
685 } else if i + 1 == path.len() {
686 ends.map_or(path[i] - path[i - 1], |[_, end]| end)
687 } else {
688 (path[i] - path[i - 1]).normalize_or_zero()
689 + (path[i + 1] - path[i]).normalize_or_zero()
690 };
691 let tangent = raw.normalize_or_zero();
692 if tangent == Vec3::ZERO {
693 Err(GeomError::InvalidInput(
694 "sweep tangent must be a non-zero direction".to_owned(),
695 ))
696 } else {
697 Ok(tangent)
698 }
699 })
700 .collect()
701}
702
703pub(crate) fn disk_rings(
706 radius: Scalar,
707 inner: Option<Scalar>,
708 chord: Scalar,
709) -> GeomResult<Rings> {
710 let circle = |r: Scalar, reverse: bool| -> GeomResult<Vec<Point2>> {
711 let n = crate::revolve::steps(r, core::f64::consts::TAU, chord)?;
712 let mut pts: Vec<Point2> = (0..n)
713 .map(|k| {
714 let a = core::f64::consts::TAU * (k as Scalar) / (n as Scalar);
715 Point2::new(r * a.cos(), r * a.sin())
716 })
717 .collect();
718 if reverse {
719 pts.reverse();
720 }
721 Ok(pts)
722 };
723 Ok(Rings {
726 outer: circle(radius, false)?,
727 holes: match inner {
728 Some(r) => vec![circle(r, true)?],
729 None => Vec::new(),
730 },
731 })
732}
733
734fn seed_reference(path: &[Point3]) -> GeomResult<Vec3> {
740 if path.len() < 2 {
741 return Err(GeomError::InvalidInput(
742 "a swept disk directrix needs at least two points".to_owned(),
743 ));
744 }
745 let t = (path[1] - path[0]).normalize_or_zero();
746 if t == Vec3::ZERO {
747 return Err(GeomError::InvalidInput(
748 "a swept disk directrix must not start with a zero-length segment".to_owned(),
749 ));
750 }
751 let candidate = if t.x.abs() < 0.9 { Vec3::X } else { Vec3::Y };
752 Ok(candidate - t * t.dot(candidate))
753}
754
755pub fn sectioned_spine(sections: &[(Rings, Vec<Point3>)]) -> GeomResult<TriMesh> {
762 if sections.len() < 2 {
763 return Err(GeomError::InvalidInput(format!(
764 "a sectioned spine needs at least two sections, got {}",
765 sections.len()
766 )));
767 }
768 let stations: Vec<Station> = sections
769 .iter()
770 .map(|(rings, placed)| {
771 let mut it = placed.iter().copied();
772 let mut loops = Vec::with_capacity(1 + rings.holes.len());
773 loops.push((0..rings.outer.len()).filter_map(|_| it.next()).collect());
774 for hole in &rings.holes {
775 loops.push((0..hole.len()).filter_map(|_| it.next()).collect());
776 }
777 Station { loops }
778 })
779 .collect();
780 loft::loft(§ions[0].0, &stations, false)
781}