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
80pub fn tapered_revolve(
101 start: &Rings,
102 end: &Rings,
103 axis_origin: Point3,
104 axis_direction: Vec3,
105 angle: Scalar,
106 tolerance: Tolerance,
107) -> GeomResult<TriMesh> {
108 if !angle.is_finite() || angle == 0.0 {
109 return Err(GeomError::InvalidInput(format!(
110 "revolution angle must be finite and non-zero, got {angle}"
111 )));
112 }
113 let dir = axis_direction.normalize_or_zero();
114 if dir == Vec3::ZERO {
115 return Err(GeomError::InvalidInput(
116 "revolution axis must be a finite non-zero direction".to_owned(),
117 ));
118 }
119 let ends = Rings {
126 outer: start.outer.clone(),
127 holes: core::iter::once(&end.outer)
128 .chain(&start.holes)
129 .chain(&end.holes)
130 .cloned()
131 .collect(),
132 };
133 if crate::revolve::check_axis_side(&ends, axis_origin, axis_direction, tolerance, true)?
135 .contains(&true)
136 {
137 return Err(crate::revolve::touching_unsupported());
138 }
139 let far = blend_rings(start, end, 1.0)?;
140 let mut max_r: Scalar = 0.0;
141 let starts = start.outer.iter().chain(start.holes.iter().flatten());
142 let ends = far.outer.iter().chain(far.holes.iter().flatten());
143 let mut travel: Scalar = 0.0;
144 for (p, q) in starts.zip(ends) {
145 for p in [p, q] {
146 let v = Point3::new(p.x, p.y, 0.0) - axis_origin;
147 max_r = max_r.max((v - dir * dir.dot(v)).length());
148 }
149 travel = travel.max((*q - *p).length());
150 }
151 let budget = tolerance.linear();
152 let curvature = angle * angle * max_r + 2.0 * angle.abs() * travel;
153 let mut n = crate::revolve::steps(max_r, angle, budget)?;
154 let stations = loop {
155 let mut stations = Vec::with_capacity(n + 1);
156 for s in (0..=n).rev() {
157 let t = (s as Scalar) / (n as Scalar);
158 let ring = blend_rings(start, end, t)?;
159 let a = angle * t;
160 stations.push(loft::place(&ring, |p| {
161 crate::revolve::rotate(Point3::new(p.x, p.y, 0.0), axis_origin, dir, a)
162 }));
163 }
164 let chord = curvature / (8.0 * (n * n) as Scalar);
165 let twist = stations
166 .windows(2)
167 .map(|w| loft::span_deviation(&w[0], &w[1]))
168 .fold(0.0, Scalar::max);
169 if chord + twist <= budget {
170 break stations;
171 }
172 n = crate::revolve::grow(n)?;
173 };
174 let mut mesh = loft::loft_tapered(&far, start, &stations)?;
175 if matches!(crate::extrude::outward_orientation(&mesh), Some(false)) {
181 for triangle in mesh.indices.as_chunks_mut::<3>().0 {
182 triangle.swap(1, 2);
183 }
184 }
185 Ok(mesh)
186}
187
188pub fn fixed_reference_sweep(
194 rings: &Rings,
195 path: &[Point3],
196 reference: Vec3,
197) -> GeomResult<TriMesh> {
198 let frames = frames_along(path, None, |_| reference)?;
199 let stations: Vec<Station> = frames
200 .iter()
201 .map(|f| loft::place(rings, |p| loft::at(f, p)))
202 .collect();
203 loft::loft(rings, &stations, false)
204}
205
206#[derive(Debug, Clone, Default, PartialEq)]
209pub struct SampledPath {
210 pub points: Vec<Point3>,
212 pub end_tangents: Option<[Vec3; 2]>,
224}
225
226const MAX_REFINEMENTS: usize = 16;
228
229fn bounded_stations(
256 rings: &Rings,
257 budget: Scalar,
258 mut frames_at: impl FnMut(Scalar) -> GeomResult<(Vec<Frame>, bool)>,
259) -> GeomResult<Vec<Station>> {
260 if !(budget.is_finite() && budget > 0.0) {
261 return Err(GeomError::InvalidInput(format!(
262 "sweep chord budget must be positive and finite, got {budget}"
263 )));
264 }
265 let reach = rings
266 .outer
267 .iter()
268 .chain(rings.holes.iter().flatten())
269 .map(|p| p.x.hypot(p.y))
270 .fold(0.0, Scalar::max);
271 let mut b = budget;
272 for _ in 0..MAX_REFINEMENTS {
273 let (frames, smooth) = frames_at(b)?;
274 let stations: Vec<Station> = frames
275 .iter()
276 .map(|f| loft::place(rings, |p| loft::at(f, p)))
277 .collect();
278 if !smooth {
279 return Ok(stations);
280 }
281 let worst = frames
282 .windows(2)
283 .zip(stations.windows(2))
284 .map(|(f, s)| span_bound(&f[0], &f[1], b, reach) + loft::span_deviation(&s[0], &s[1]))
285 .fold(0.0, Scalar::max);
286 if worst <= budget {
287 return Ok(stations);
288 }
289 b *= 0.5;
290 }
291 Err(GeomError::BudgetExceeded {
292 resource: "sweep directrix refinement",
293 })
294}
295
296pub(crate) fn span_bound(from: &Frame, to: &Frame, budget: Scalar, reach: Scalar) -> Scalar {
298 let (t0, t1) = (from.x.cross(from.y), to.x.cross(to.y));
299 let turn = t0.dot(t1).clamp(-1.0, 1.0).acos();
300 let chord = (to.origin - from.origin).length();
301 let directrix = budget.min(0.5 * chord * (0.25 * turn).tan());
302 let trace = from.x.dot(to.x) + from.y.dot(to.y) + t0.dot(t1);
303 let rotation = (0.5 * (trace - 1.0)).clamp(-1.0, 1.0).acos();
304 directrix + reach * (1.0 - (0.5 * rotation).cos())
305}
306
307pub fn fixed_reference_sweep_within(
316 rings: &Rings,
317 mut sample: impl FnMut(Scalar) -> GeomResult<SampledPath>,
318 reference: Vec3,
319 budget: Scalar,
320) -> GeomResult<TriMesh> {
321 let stations = bounded_stations(rings, budget, |b| {
322 let path = sample(b)?;
323 let frames = frames_along(&path.points, path.end_tangents, |_| reference)?;
324 Ok((frames, path.end_tangents.is_some()))
325 })?;
326 loft::loft(rings, &stations, false)
327}
328
329pub fn surface_curve_sweep_within(
336 rings: &Rings,
337 mut sample: impl FnMut(Scalar) -> GeomResult<(SampledPath, Vec<Vec3>)>,
338 budget: Scalar,
339) -> GeomResult<TriMesh> {
340 let stations = bounded_stations(rings, budget, |b| {
341 let (path, normals) = sample(b)?;
342 if normals.len() != path.points.len() {
343 return Err(GeomError::InvalidInput(
344 "a surface curve sweep needs one surface normal per directrix point".to_owned(),
345 ));
346 }
347 let frames = frames_along(&path.points, path.end_tangents, |i| normals[i])?;
348 Ok((frames, path.end_tangents.is_some()))
349 })?;
350 loft::loft(rings, &stations, false)
351}
352
353fn frames_along(
360 path: &[Point3],
361 ends: Option<[Vec3; 2]>,
362 up: impl Fn(usize) -> Vec3,
363) -> GeomResult<Vec<Frame>> {
364 let tangents = tangents_along(path, ends)?;
365 let mut frames = Vec::with_capacity(path.len());
366 for (i, (point, tangent)) in path.iter().zip(tangents).enumerate() {
367 frames.push(Frame::from_reference(*point, tangent, up(i))?);
368 }
369 Ok(frames)
370}
371
372pub fn linear_extrusion_normals(path: &[Point3], direction: Vec3) -> GeomResult<Vec<Vec3>> {
373 if path.len() < 2 {
374 return Err(GeomError::InvalidInput(
375 "a linear-extrusion surface needs at least two directrix points".into(),
376 ));
377 }
378 let direction = direction.normalize_or_zero();
379 if direction == Vec3::ZERO {
380 return Err(GeomError::InvalidInput(
381 "linear-extrusion direction must be finite and non-zero".into(),
382 ));
383 }
384 let mut normals = Vec::with_capacity(path.len());
385 for i in 0..path.len() {
386 let tangent = if i == 0 {
387 path[1] - path[0]
388 } else if i + 1 == path.len() {
389 path[i] - path[i - 1]
390 } else {
391 (path[i] - path[i - 1]).normalize_or_zero()
392 + (path[i + 1] - path[i]).normalize_or_zero()
393 };
394 let normal = tangent.cross(direction).normalize_or_zero();
395 if normal == Vec3::ZERO {
396 return Err(GeomError::Degenerate(
397 "directrix tangent is parallel to linear-extrusion direction".into(),
398 ));
399 }
400 normals.push(normal);
401 }
402 Ok(normals)
403}
404
405pub fn surface_curve_sweep(
412 rings: &Rings,
413 path: &[Point3],
414 normals: &[Vec3],
415) -> GeomResult<TriMesh> {
416 if normals.len() != path.len() {
417 return Err(GeomError::InvalidInput(
418 "a surface curve sweep needs one surface normal per directrix point".to_owned(),
419 ));
420 }
421 let frames = frames_along(path, None, |i| normals[i])?;
422 let stations: Vec<Station> = frames
423 .iter()
424 .map(|f| loft::place(rings, |p| loft::at(f, p)))
425 .collect();
426 loft::loft(rings, &stations, false)
427}
428
429pub fn swept_disk(
436 path: &[Point3],
437 radius: Scalar,
438 inner_radius: Option<Scalar>,
439 fillet_radius: Option<Scalar>,
440 tolerance: Tolerance,
441) -> GeomResult<TriMesh> {
442 check_disk(radius, inner_radius, fillet_radius)?;
443 let rings = disk_rings(radius, inner_radius, tolerance.linear())?;
453 let frames = rotation_minimising_frames(path, None)?;
454 let stations: Vec<Station> = frames
455 .iter()
456 .map(|f| loft::place(&rings, |p| loft::at(f, p)))
457 .collect();
458 loft::loft(&rings, &stations, false)
459}
460
461pub fn swept_disk_within(
475 mut sample: impl FnMut(Scalar) -> GeomResult<SampledPath>,
476 radius: Scalar,
477 inner_radius: Option<Scalar>,
478 fillet_radius: Option<Scalar>,
479 chord: Scalar,
480) -> GeomResult<TriMesh> {
481 check_disk(radius, inner_radius, fillet_radius)?;
482 let rings = disk_rings(radius, inner_radius, 0.5 * chord)?;
483 let stations = bounded_stations(&rings, 0.5 * chord, |b| {
484 let path = sample(b)?;
485 let frames = rotation_minimising_frames(&path.points, path.end_tangents)?;
486 Ok((frames, path.end_tangents.is_some()))
487 })?;
488 loft::loft(&rings, &stations, false)
489}
490
491fn check_disk(
498 radius: Scalar,
499 inner_radius: Option<Scalar>,
500 fillet_radius: Option<Scalar>,
501) -> GeomResult<()> {
502 if fillet_radius.is_some() {
503 return Err(GeomError::Unsupported {
504 backend: crate::BACKEND_ID,
505 operation: axiolid_contracts::Operation::Sweep,
506 });
507 }
508 check_radii(radius, inner_radius)
509}
510
511pub(crate) fn check_radii(radius: Scalar, inner_radius: Option<Scalar>) -> GeomResult<()> {
513 if !radius.is_finite() || radius <= 0.0 {
514 return Err(GeomError::InvalidInput(format!(
515 "swept disk radius must be positive and finite, got {radius}"
516 )));
517 }
518 if let Some(inner) = inner_radius {
519 if !inner.is_finite() || inner <= 0.0 || inner >= radius {
520 return Err(GeomError::InvalidInput(format!(
521 "swept disk inner radius must be positive and below {radius}, got {inner}"
522 )));
523 }
524 }
525 Ok(())
526}
527
528fn rotation_minimising_frames(path: &[Point3], ends: Option<[Vec3; 2]>) -> GeomResult<Vec<Frame>> {
543 let seed = seed_reference(path)?;
544 let tangents = tangents_along(path, ends)?;
545 frames_carried(path, &tangents, seed)
546}
547
548pub(crate) fn frames_carried(
552 path: &[Point3],
553 tangents: &[Vec3],
554 seed: Vec3,
555) -> GeomResult<Vec<Frame>> {
556 let mut reference = seed;
557 let mut frames = Vec::with_capacity(path.len());
558 for i in 0..path.len() {
559 frames.push(Frame::from_reference(path[i], tangents[i], reference)?);
560 let Some(next) = path.get(i + 1) else { break };
561 let chord = *next - path[i];
562 let c1 = chord.dot(chord);
563 if c1 == 0.0 {
564 continue;
567 }
568 let reflected_ref = reference - chord * (2.0 / c1 * chord.dot(reference));
569 let reflected_tan = tangents[i] - chord * (2.0 / c1 * chord.dot(tangents[i]));
570 let fix = tangents[i + 1] - reflected_tan;
571 let c2 = fix.dot(fix);
572 reference = if c2 == 0.0 {
573 reflected_ref
574 } else {
575 reflected_ref - fix * (2.0 / c2 * fix.dot(reflected_ref))
576 };
577 }
578 Ok(frames)
579}
580
581fn tangents_along(path: &[Point3], ends: Option<[Vec3; 2]>) -> GeomResult<Vec<Vec3>> {
584 if path.len() < 2 {
585 return Err(GeomError::InvalidInput(format!(
586 "a sweep directrix needs at least two points, got {}",
587 path.len()
588 )));
589 }
590 (0..path.len())
591 .map(|i| {
592 let raw = if i == 0 {
593 ends.map_or(path[1] - path[0], |[start, _]| start)
594 } else if i + 1 == path.len() {
595 ends.map_or(path[i] - path[i - 1], |[_, end]| end)
596 } else {
597 (path[i] - path[i - 1]).normalize_or_zero()
598 + (path[i + 1] - path[i]).normalize_or_zero()
599 };
600 let tangent = raw.normalize_or_zero();
601 if tangent == Vec3::ZERO {
602 Err(GeomError::InvalidInput(
603 "sweep tangent must be a non-zero direction".to_owned(),
604 ))
605 } else {
606 Ok(tangent)
607 }
608 })
609 .collect()
610}
611
612pub(crate) fn disk_rings(
615 radius: Scalar,
616 inner: Option<Scalar>,
617 chord: Scalar,
618) -> GeomResult<Rings> {
619 let circle = |r: Scalar, reverse: bool| -> GeomResult<Vec<Point2>> {
620 let n = crate::revolve::steps(r, core::f64::consts::TAU, chord)?;
621 let mut pts: Vec<Point2> = (0..n)
622 .map(|k| {
623 let a = core::f64::consts::TAU * (k as Scalar) / (n as Scalar);
624 Point2::new(r * a.cos(), r * a.sin())
625 })
626 .collect();
627 if reverse {
628 pts.reverse();
629 }
630 Ok(pts)
631 };
632 Ok(Rings {
635 outer: circle(radius, false)?,
636 holes: match inner {
637 Some(r) => vec![circle(r, true)?],
638 None => Vec::new(),
639 },
640 })
641}
642
643fn seed_reference(path: &[Point3]) -> GeomResult<Vec3> {
649 if path.len() < 2 {
650 return Err(GeomError::InvalidInput(
651 "a swept disk directrix needs at least two points".to_owned(),
652 ));
653 }
654 let t = (path[1] - path[0]).normalize_or_zero();
655 if t == Vec3::ZERO {
656 return Err(GeomError::InvalidInput(
657 "a swept disk directrix must not start with a zero-length segment".to_owned(),
658 ));
659 }
660 let candidate = if t.x.abs() < 0.9 { Vec3::X } else { Vec3::Y };
661 Ok(candidate - t * t.dot(candidate))
662}
663
664pub fn sectioned_spine(sections: &[(Rings, Vec<Point3>)]) -> GeomResult<TriMesh> {
671 if sections.len() < 2 {
672 return Err(GeomError::InvalidInput(format!(
673 "a sectioned spine needs at least two sections, got {}",
674 sections.len()
675 )));
676 }
677 let stations: Vec<Station> = sections
678 .iter()
679 .map(|(rings, placed)| {
680 let mut it = placed.iter().copied();
681 let mut loops = Vec::with_capacity(1 + rings.holes.len());
682 loops.push((0..rings.outer.len()).filter_map(|_| it.next()).collect());
683 for hole in &rings.holes {
684 loops.push((0..hole.len()).filter_map(|_| it.next()).collect());
685 }
686 Station { loops }
687 })
688 .collect();
689 loft::loft(§ions[0].0, &stations, false)
690}