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 {
124 outer: start.outer.iter().chain(&end.outer).copied().collect(),
125 holes: start.holes.iter().chain(&end.holes).cloned().collect(),
126 };
127 crate::revolve::check_axis_side(&ends, axis_origin, axis_direction, tolerance)?;
128 let far = blend_rings(start, end, 1.0)?;
129 let mut max_r: Scalar = 0.0;
130 let starts = start.outer.iter().chain(start.holes.iter().flatten());
131 let ends = far.outer.iter().chain(far.holes.iter().flatten());
132 let mut travel: Scalar = 0.0;
133 for (p, q) in starts.zip(ends) {
134 for p in [p, q] {
135 let v = Point3::new(p.x, p.y, 0.0) - axis_origin;
136 max_r = max_r.max((v - dir * dir.dot(v)).length());
137 }
138 travel = travel.max((*q - *p).length());
139 }
140 let budget = tolerance.linear();
141 let curvature = angle * angle * max_r + 2.0 * angle.abs() * travel;
142 let mut n = crate::revolve::steps(max_r, angle, budget)?;
143 let stations = loop {
144 let mut stations = Vec::with_capacity(n + 1);
145 for s in (0..=n).rev() {
146 let t = (s as Scalar) / (n as Scalar);
147 let ring = blend_rings(start, end, t)?;
148 let a = angle * t;
149 stations.push(loft::place(&ring, |p| {
150 crate::revolve::rotate(Point3::new(p.x, p.y, 0.0), axis_origin, dir, a)
151 }));
152 }
153 let chord = curvature / (8.0 * (n * n) as Scalar);
154 let twist = stations
155 .windows(2)
156 .map(|w| loft::span_deviation(&w[0], &w[1]))
157 .fold(0.0, Scalar::max);
158 if chord + twist <= budget {
159 break stations;
160 }
161 n = crate::revolve::grow(n)?;
162 };
163 let mut mesh = loft::loft_tapered(&far, start, &stations)?;
164 if matches!(crate::extrude::outward_orientation(&mesh), Some(false)) {
170 for triangle in mesh.indices.as_chunks_mut::<3>().0 {
171 triangle.swap(1, 2);
172 }
173 }
174 Ok(mesh)
175}
176
177pub fn fixed_reference_sweep(
183 rings: &Rings,
184 path: &[Point3],
185 reference: Vec3,
186) -> GeomResult<TriMesh> {
187 let frames = frames_along(path, None, |_| reference)?;
188 let stations: Vec<Station> = frames
189 .iter()
190 .map(|f| loft::place(rings, |p| loft::at(f, p)))
191 .collect();
192 loft::loft(rings, &stations, false)
193}
194
195#[derive(Debug, Clone, Default, PartialEq)]
198pub struct SampledPath {
199 pub points: Vec<Point3>,
201 pub end_tangents: Option<[Vec3; 2]>,
213}
214
215const MAX_REFINEMENTS: usize = 16;
217
218fn bounded_stations(
245 rings: &Rings,
246 budget: Scalar,
247 mut frames_at: impl FnMut(Scalar) -> GeomResult<(Vec<Frame>, bool)>,
248) -> GeomResult<Vec<Station>> {
249 if !(budget.is_finite() && budget > 0.0) {
250 return Err(GeomError::InvalidInput(format!(
251 "sweep chord budget must be positive and finite, got {budget}"
252 )));
253 }
254 let reach = rings
255 .outer
256 .iter()
257 .chain(rings.holes.iter().flatten())
258 .map(|p| p.x.hypot(p.y))
259 .fold(0.0, Scalar::max);
260 let mut b = budget;
261 for _ in 0..MAX_REFINEMENTS {
262 let (frames, smooth) = frames_at(b)?;
263 let stations: Vec<Station> = frames
264 .iter()
265 .map(|f| loft::place(rings, |p| loft::at(f, p)))
266 .collect();
267 if !smooth {
268 return Ok(stations);
269 }
270 let worst = frames
271 .windows(2)
272 .zip(stations.windows(2))
273 .map(|(f, s)| span_bound(&f[0], &f[1], b, reach) + loft::span_deviation(&s[0], &s[1]))
274 .fold(0.0, Scalar::max);
275 if worst <= budget {
276 return Ok(stations);
277 }
278 b *= 0.5;
279 }
280 Err(GeomError::BudgetExceeded {
281 resource: "sweep directrix refinement",
282 })
283}
284
285pub(crate) fn span_bound(from: &Frame, to: &Frame, budget: Scalar, reach: Scalar) -> Scalar {
287 let (t0, t1) = (from.x.cross(from.y), to.x.cross(to.y));
288 let turn = t0.dot(t1).clamp(-1.0, 1.0).acos();
289 let chord = (to.origin - from.origin).length();
290 let directrix = budget.min(0.5 * chord * (0.25 * turn).tan());
291 let trace = from.x.dot(to.x) + from.y.dot(to.y) + t0.dot(t1);
292 let rotation = (0.5 * (trace - 1.0)).clamp(-1.0, 1.0).acos();
293 directrix + reach * (1.0 - (0.5 * rotation).cos())
294}
295
296pub fn fixed_reference_sweep_within(
305 rings: &Rings,
306 mut sample: impl FnMut(Scalar) -> GeomResult<SampledPath>,
307 reference: Vec3,
308 budget: Scalar,
309) -> GeomResult<TriMesh> {
310 let stations = bounded_stations(rings, budget, |b| {
311 let path = sample(b)?;
312 let frames = frames_along(&path.points, path.end_tangents, |_| reference)?;
313 Ok((frames, path.end_tangents.is_some()))
314 })?;
315 loft::loft(rings, &stations, false)
316}
317
318pub fn surface_curve_sweep_within(
325 rings: &Rings,
326 mut sample: impl FnMut(Scalar) -> GeomResult<(SampledPath, Vec<Vec3>)>,
327 budget: Scalar,
328) -> GeomResult<TriMesh> {
329 let stations = bounded_stations(rings, budget, |b| {
330 let (path, normals) = sample(b)?;
331 if normals.len() != path.points.len() {
332 return Err(GeomError::InvalidInput(
333 "a surface curve sweep needs one surface normal per directrix point".to_owned(),
334 ));
335 }
336 let frames = frames_along(&path.points, path.end_tangents, |i| normals[i])?;
337 Ok((frames, path.end_tangents.is_some()))
338 })?;
339 loft::loft(rings, &stations, false)
340}
341
342fn frames_along(
349 path: &[Point3],
350 ends: Option<[Vec3; 2]>,
351 up: impl Fn(usize) -> Vec3,
352) -> GeomResult<Vec<Frame>> {
353 let tangents = tangents_along(path, ends)?;
354 let mut frames = Vec::with_capacity(path.len());
355 for (i, (point, tangent)) in path.iter().zip(tangents).enumerate() {
356 frames.push(Frame::from_reference(*point, tangent, up(i))?);
357 }
358 Ok(frames)
359}
360
361pub fn linear_extrusion_normals(path: &[Point3], direction: Vec3) -> GeomResult<Vec<Vec3>> {
362 if path.len() < 2 {
363 return Err(GeomError::InvalidInput(
364 "a linear-extrusion surface needs at least two directrix points".into(),
365 ));
366 }
367 let direction = direction.normalize_or_zero();
368 if direction == Vec3::ZERO {
369 return Err(GeomError::InvalidInput(
370 "linear-extrusion direction must be finite and non-zero".into(),
371 ));
372 }
373 let mut normals = Vec::with_capacity(path.len());
374 for i in 0..path.len() {
375 let tangent = if i == 0 {
376 path[1] - path[0]
377 } else if i + 1 == path.len() {
378 path[i] - path[i - 1]
379 } else {
380 (path[i] - path[i - 1]).normalize_or_zero()
381 + (path[i + 1] - path[i]).normalize_or_zero()
382 };
383 let normal = tangent.cross(direction).normalize_or_zero();
384 if normal == Vec3::ZERO {
385 return Err(GeomError::Degenerate(
386 "directrix tangent is parallel to linear-extrusion direction".into(),
387 ));
388 }
389 normals.push(normal);
390 }
391 Ok(normals)
392}
393
394pub fn surface_curve_sweep(
401 rings: &Rings,
402 path: &[Point3],
403 normals: &[Vec3],
404) -> GeomResult<TriMesh> {
405 if normals.len() != path.len() {
406 return Err(GeomError::InvalidInput(
407 "a surface curve sweep needs one surface normal per directrix point".to_owned(),
408 ));
409 }
410 let frames = frames_along(path, None, |i| normals[i])?;
411 let stations: Vec<Station> = frames
412 .iter()
413 .map(|f| loft::place(rings, |p| loft::at(f, p)))
414 .collect();
415 loft::loft(rings, &stations, false)
416}
417
418pub fn swept_disk(
425 path: &[Point3],
426 radius: Scalar,
427 inner_radius: Option<Scalar>,
428 fillet_radius: Option<Scalar>,
429 tolerance: Tolerance,
430) -> GeomResult<TriMesh> {
431 check_disk(radius, inner_radius, fillet_radius)?;
432 let rings = disk_rings(radius, inner_radius, tolerance.linear())?;
442 let frames = rotation_minimising_frames(path, None)?;
443 let stations: Vec<Station> = frames
444 .iter()
445 .map(|f| loft::place(&rings, |p| loft::at(f, p)))
446 .collect();
447 loft::loft(&rings, &stations, false)
448}
449
450pub fn swept_disk_within(
464 mut sample: impl FnMut(Scalar) -> GeomResult<SampledPath>,
465 radius: Scalar,
466 inner_radius: Option<Scalar>,
467 fillet_radius: Option<Scalar>,
468 chord: Scalar,
469) -> GeomResult<TriMesh> {
470 check_disk(radius, inner_radius, fillet_radius)?;
471 let rings = disk_rings(radius, inner_radius, 0.5 * chord)?;
472 let stations = bounded_stations(&rings, 0.5 * chord, |b| {
473 let path = sample(b)?;
474 let frames = rotation_minimising_frames(&path.points, path.end_tangents)?;
475 Ok((frames, path.end_tangents.is_some()))
476 })?;
477 loft::loft(&rings, &stations, false)
478}
479
480fn check_disk(
487 radius: Scalar,
488 inner_radius: Option<Scalar>,
489 fillet_radius: Option<Scalar>,
490) -> GeomResult<()> {
491 if fillet_radius.is_some() {
492 return Err(GeomError::Unsupported {
493 backend: crate::BACKEND_ID,
494 operation: axiolid_contracts::Operation::Sweep,
495 });
496 }
497 check_radii(radius, inner_radius)
498}
499
500pub(crate) fn check_radii(radius: Scalar, inner_radius: Option<Scalar>) -> GeomResult<()> {
502 if !radius.is_finite() || radius <= 0.0 {
503 return Err(GeomError::InvalidInput(format!(
504 "swept disk radius must be positive and finite, got {radius}"
505 )));
506 }
507 if let Some(inner) = inner_radius {
508 if !inner.is_finite() || inner <= 0.0 || inner >= radius {
509 return Err(GeomError::InvalidInput(format!(
510 "swept disk inner radius must be positive and below {radius}, got {inner}"
511 )));
512 }
513 }
514 Ok(())
515}
516
517fn rotation_minimising_frames(path: &[Point3], ends: Option<[Vec3; 2]>) -> GeomResult<Vec<Frame>> {
532 let seed = seed_reference(path)?;
533 let tangents = tangents_along(path, ends)?;
534 frames_carried(path, &tangents, seed)
535}
536
537pub(crate) fn frames_carried(
541 path: &[Point3],
542 tangents: &[Vec3],
543 seed: Vec3,
544) -> GeomResult<Vec<Frame>> {
545 let mut reference = seed;
546 let mut frames = Vec::with_capacity(path.len());
547 for i in 0..path.len() {
548 frames.push(Frame::from_reference(path[i], tangents[i], reference)?);
549 let Some(next) = path.get(i + 1) else { break };
550 let chord = *next - path[i];
551 let c1 = chord.dot(chord);
552 if c1 == 0.0 {
553 continue;
556 }
557 let reflected_ref = reference - chord * (2.0 / c1 * chord.dot(reference));
558 let reflected_tan = tangents[i] - chord * (2.0 / c1 * chord.dot(tangents[i]));
559 let fix = tangents[i + 1] - reflected_tan;
560 let c2 = fix.dot(fix);
561 reference = if c2 == 0.0 {
562 reflected_ref
563 } else {
564 reflected_ref - fix * (2.0 / c2 * fix.dot(reflected_ref))
565 };
566 }
567 Ok(frames)
568}
569
570fn tangents_along(path: &[Point3], ends: Option<[Vec3; 2]>) -> GeomResult<Vec<Vec3>> {
573 if path.len() < 2 {
574 return Err(GeomError::InvalidInput(format!(
575 "a sweep directrix needs at least two points, got {}",
576 path.len()
577 )));
578 }
579 (0..path.len())
580 .map(|i| {
581 let raw = if i == 0 {
582 ends.map_or(path[1] - path[0], |[start, _]| start)
583 } else if i + 1 == path.len() {
584 ends.map_or(path[i] - path[i - 1], |[_, end]| end)
585 } else {
586 (path[i] - path[i - 1]).normalize_or_zero()
587 + (path[i + 1] - path[i]).normalize_or_zero()
588 };
589 let tangent = raw.normalize_or_zero();
590 if tangent == Vec3::ZERO {
591 Err(GeomError::InvalidInput(
592 "sweep tangent must be a non-zero direction".to_owned(),
593 ))
594 } else {
595 Ok(tangent)
596 }
597 })
598 .collect()
599}
600
601pub(crate) fn disk_rings(
604 radius: Scalar,
605 inner: Option<Scalar>,
606 chord: Scalar,
607) -> GeomResult<Rings> {
608 let circle = |r: Scalar, reverse: bool| -> GeomResult<Vec<Point2>> {
609 let n = crate::revolve::steps(r, core::f64::consts::TAU, chord)?;
610 let mut pts: Vec<Point2> = (0..n)
611 .map(|k| {
612 let a = core::f64::consts::TAU * (k as Scalar) / (n as Scalar);
613 Point2::new(r * a.cos(), r * a.sin())
614 })
615 .collect();
616 if reverse {
617 pts.reverse();
618 }
619 Ok(pts)
620 };
621 Ok(Rings {
624 outer: circle(radius, false)?,
625 holes: match inner {
626 Some(r) => vec![circle(r, true)?],
627 None => Vec::new(),
628 },
629 })
630}
631
632fn seed_reference(path: &[Point3]) -> GeomResult<Vec3> {
638 if path.len() < 2 {
639 return Err(GeomError::InvalidInput(
640 "a swept disk directrix needs at least two points".to_owned(),
641 ));
642 }
643 let t = (path[1] - path[0]).normalize_or_zero();
644 if t == Vec3::ZERO {
645 return Err(GeomError::InvalidInput(
646 "a swept disk directrix must not start with a zero-length segment".to_owned(),
647 ));
648 }
649 let candidate = if t.x.abs() < 0.9 { Vec3::X } else { Vec3::Y };
650 Ok(candidate - t * t.dot(candidate))
651}
652
653pub fn sectioned_spine(sections: &[(Rings, Vec<Point3>)]) -> GeomResult<TriMesh> {
660 if sections.len() < 2 {
661 return Err(GeomError::InvalidInput(format!(
662 "a sectioned spine needs at least two sections, got {}",
663 sections.len()
664 )));
665 }
666 let stations: Vec<Station> = sections
667 .iter()
668 .map(|(rings, placed)| {
669 let mut it = placed.iter().copied();
670 let mut loops = Vec::with_capacity(1 + rings.holes.len());
671 loops.push((0..rings.outer.len()).filter_map(|_| it.next()).collect());
672 for hole in &rings.holes {
673 loops.push((0..hole.len()).filter_map(|_| it.next()).collect());
674 }
675 Station { loops }
676 })
677 .collect();
678 loft::loft(§ions[0].0, &stations, false)
679}