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 far = blend_rings(start, end, 1.0)?;
120 let mut max_r: Scalar = 0.0;
121 let starts = start.outer.iter().chain(start.holes.iter().flatten());
122 let ends = far.outer.iter().chain(far.holes.iter().flatten());
123 let mut travel: Scalar = 0.0;
124 for (p, q) in starts.zip(ends) {
125 for p in [p, q] {
126 let v = Point3::new(p.x, p.y, 0.0) - axis_origin;
127 max_r = max_r.max((v - dir * dir.dot(v)).length());
128 }
129 travel = travel.max((*q - *p).length());
130 }
131 let budget = tolerance.linear();
132 let curvature = angle * angle * max_r + 2.0 * angle.abs() * travel;
133 let mut n = crate::revolve::steps(max_r, angle, budget)?;
134 let stations = loop {
135 let mut stations = Vec::with_capacity(n + 1);
136 for s in (0..=n).rev() {
137 let t = (s as Scalar) / (n as Scalar);
138 let ring = blend_rings(start, end, t)?;
139 let a = angle * t;
140 stations.push(loft::place(&ring, |p| {
141 crate::revolve::rotate(Point3::new(p.x, p.y, 0.0), axis_origin, dir, a)
142 }));
143 }
144 let chord = curvature / (8.0 * (n * n) as Scalar);
145 let twist = stations
146 .windows(2)
147 .map(|w| loft::span_deviation(&w[0], &w[1]))
148 .fold(0.0, Scalar::max);
149 if chord + twist <= budget {
150 break stations;
151 }
152 n = crate::revolve::grow(n)?;
153 };
154 let mut mesh = loft::loft_tapered(&far, start, &stations)?;
155 if matches!(crate::extrude::outward_orientation(&mesh), Some(false)) {
161 for triangle in mesh.indices.chunks_exact_mut(3) {
162 triangle.swap(1, 2);
163 }
164 }
165 Ok(mesh)
166}
167
168pub fn fixed_reference_sweep(
174 rings: &Rings,
175 path: &[Point3],
176 reference: Vec3,
177) -> GeomResult<TriMesh> {
178 let frames = frames_along(path, None, |_| reference)?;
179 let stations: Vec<Station> = frames
180 .iter()
181 .map(|f| loft::place(rings, |p| loft::at(f, p)))
182 .collect();
183 loft::loft(rings, &stations, false)
184}
185
186#[derive(Debug, Clone, Default, PartialEq)]
189pub struct SampledPath {
190 pub points: Vec<Point3>,
192 pub end_tangents: Option<[Vec3; 2]>,
204}
205
206const MAX_REFINEMENTS: usize = 16;
208
209fn bounded_stations(
236 rings: &Rings,
237 budget: Scalar,
238 mut frames_at: impl FnMut(Scalar) -> GeomResult<(Vec<Frame>, bool)>,
239) -> GeomResult<Vec<Station>> {
240 if !(budget.is_finite() && budget > 0.0) {
241 return Err(GeomError::InvalidInput(format!(
242 "sweep chord budget must be positive and finite, got {budget}"
243 )));
244 }
245 let reach = rings
246 .outer
247 .iter()
248 .chain(rings.holes.iter().flatten())
249 .map(|p| p.x.hypot(p.y))
250 .fold(0.0, Scalar::max);
251 let mut b = budget;
252 for _ in 0..MAX_REFINEMENTS {
253 let (frames, smooth) = frames_at(b)?;
254 let stations: Vec<Station> = frames
255 .iter()
256 .map(|f| loft::place(rings, |p| loft::at(f, p)))
257 .collect();
258 if !smooth {
259 return Ok(stations);
260 }
261 let worst = frames
262 .windows(2)
263 .zip(stations.windows(2))
264 .map(|(f, s)| span_bound(&f[0], &f[1], b, reach) + loft::span_deviation(&s[0], &s[1]))
265 .fold(0.0, Scalar::max);
266 if worst <= budget {
267 return Ok(stations);
268 }
269 b *= 0.5;
270 }
271 Err(GeomError::BudgetExceeded {
272 resource: "sweep directrix refinement",
273 })
274}
275
276pub(crate) fn span_bound(from: &Frame, to: &Frame, budget: Scalar, reach: Scalar) -> Scalar {
278 let (t0, t1) = (from.x.cross(from.y), to.x.cross(to.y));
279 let turn = t0.dot(t1).clamp(-1.0, 1.0).acos();
280 let chord = (to.origin - from.origin).length();
281 let directrix = budget.min(0.5 * chord * (0.25 * turn).tan());
282 let trace = from.x.dot(to.x) + from.y.dot(to.y) + t0.dot(t1);
283 let rotation = (0.5 * (trace - 1.0)).clamp(-1.0, 1.0).acos();
284 directrix + reach * (1.0 - (0.5 * rotation).cos())
285}
286
287pub fn fixed_reference_sweep_within(
296 rings: &Rings,
297 mut sample: impl FnMut(Scalar) -> GeomResult<SampledPath>,
298 reference: Vec3,
299 budget: Scalar,
300) -> GeomResult<TriMesh> {
301 let stations = bounded_stations(rings, budget, |b| {
302 let path = sample(b)?;
303 let frames = frames_along(&path.points, path.end_tangents, |_| reference)?;
304 Ok((frames, path.end_tangents.is_some()))
305 })?;
306 loft::loft(rings, &stations, false)
307}
308
309pub fn surface_curve_sweep_within(
316 rings: &Rings,
317 mut sample: impl FnMut(Scalar) -> GeomResult<(SampledPath, Vec<Vec3>)>,
318 budget: Scalar,
319) -> GeomResult<TriMesh> {
320 let stations = bounded_stations(rings, budget, |b| {
321 let (path, normals) = sample(b)?;
322 if normals.len() != path.points.len() {
323 return Err(GeomError::InvalidInput(
324 "a surface curve sweep needs one surface normal per directrix point".to_owned(),
325 ));
326 }
327 let frames = frames_along(&path.points, path.end_tangents, |i| normals[i])?;
328 Ok((frames, path.end_tangents.is_some()))
329 })?;
330 loft::loft(rings, &stations, false)
331}
332
333fn frames_along(
340 path: &[Point3],
341 ends: Option<[Vec3; 2]>,
342 up: impl Fn(usize) -> Vec3,
343) -> GeomResult<Vec<Frame>> {
344 let tangents = tangents_along(path, ends)?;
345 let mut frames = Vec::with_capacity(path.len());
346 for (i, (point, tangent)) in path.iter().zip(tangents).enumerate() {
347 frames.push(Frame::from_reference(*point, tangent, up(i))?);
348 }
349 Ok(frames)
350}
351
352pub fn linear_extrusion_normals(path: &[Point3], direction: Vec3) -> GeomResult<Vec<Vec3>> {
353 if path.len() < 2 {
354 return Err(GeomError::InvalidInput(
355 "a linear-extrusion surface needs at least two directrix points".into(),
356 ));
357 }
358 let direction = direction.normalize_or_zero();
359 if direction == Vec3::ZERO {
360 return Err(GeomError::InvalidInput(
361 "linear-extrusion direction must be finite and non-zero".into(),
362 ));
363 }
364 let mut normals = Vec::with_capacity(path.len());
365 for i in 0..path.len() {
366 let tangent = if i == 0 {
367 path[1] - path[0]
368 } else if i + 1 == path.len() {
369 path[i] - path[i - 1]
370 } else {
371 (path[i] - path[i - 1]).normalize_or_zero()
372 + (path[i + 1] - path[i]).normalize_or_zero()
373 };
374 let normal = tangent.cross(direction).normalize_or_zero();
375 if normal == Vec3::ZERO {
376 return Err(GeomError::Degenerate(
377 "directrix tangent is parallel to linear-extrusion direction".into(),
378 ));
379 }
380 normals.push(normal);
381 }
382 Ok(normals)
383}
384
385pub fn surface_curve_sweep(
392 rings: &Rings,
393 path: &[Point3],
394 normals: &[Vec3],
395) -> GeomResult<TriMesh> {
396 if normals.len() != path.len() {
397 return Err(GeomError::InvalidInput(
398 "a surface curve sweep needs one surface normal per directrix point".to_owned(),
399 ));
400 }
401 let frames = frames_along(path, None, |i| normals[i])?;
402 let stations: Vec<Station> = frames
403 .iter()
404 .map(|f| loft::place(rings, |p| loft::at(f, p)))
405 .collect();
406 loft::loft(rings, &stations, false)
407}
408
409pub fn swept_disk(
416 path: &[Point3],
417 radius: Scalar,
418 inner_radius: Option<Scalar>,
419 fillet_radius: Option<Scalar>,
420 tolerance: Tolerance,
421) -> GeomResult<TriMesh> {
422 check_disk(radius, inner_radius, fillet_radius)?;
423 let rings = disk_rings(radius, inner_radius, tolerance.linear())?;
433 let frames = rotation_minimising_frames(path, None)?;
434 let stations: Vec<Station> = frames
435 .iter()
436 .map(|f| loft::place(&rings, |p| loft::at(f, p)))
437 .collect();
438 loft::loft(&rings, &stations, false)
439}
440
441pub fn swept_disk_within(
455 mut sample: impl FnMut(Scalar) -> GeomResult<SampledPath>,
456 radius: Scalar,
457 inner_radius: Option<Scalar>,
458 fillet_radius: Option<Scalar>,
459 chord: Scalar,
460) -> GeomResult<TriMesh> {
461 check_disk(radius, inner_radius, fillet_radius)?;
462 let rings = disk_rings(radius, inner_radius, 0.5 * chord)?;
463 let stations = bounded_stations(&rings, 0.5 * chord, |b| {
464 let path = sample(b)?;
465 let frames = rotation_minimising_frames(&path.points, path.end_tangents)?;
466 Ok((frames, path.end_tangents.is_some()))
467 })?;
468 loft::loft(&rings, &stations, false)
469}
470
471fn check_disk(
478 radius: Scalar,
479 inner_radius: Option<Scalar>,
480 fillet_radius: Option<Scalar>,
481) -> GeomResult<()> {
482 if fillet_radius.is_some() {
483 return Err(GeomError::Unsupported {
484 backend: crate::BACKEND_ID,
485 operation: axiolid_contracts::Operation::Sweep,
486 });
487 }
488 check_radii(radius, inner_radius)
489}
490
491pub(crate) fn check_radii(radius: Scalar, inner_radius: Option<Scalar>) -> GeomResult<()> {
493 if !radius.is_finite() || radius <= 0.0 {
494 return Err(GeomError::InvalidInput(format!(
495 "swept disk radius must be positive and finite, got {radius}"
496 )));
497 }
498 if let Some(inner) = inner_radius {
499 if !inner.is_finite() || inner <= 0.0 || inner >= radius {
500 return Err(GeomError::InvalidInput(format!(
501 "swept disk inner radius must be positive and below {radius}, got {inner}"
502 )));
503 }
504 }
505 Ok(())
506}
507
508fn rotation_minimising_frames(path: &[Point3], ends: Option<[Vec3; 2]>) -> GeomResult<Vec<Frame>> {
523 let seed = seed_reference(path)?;
524 let tangents = tangents_along(path, ends)?;
525 frames_carried(path, &tangents, seed)
526}
527
528pub(crate) fn frames_carried(
532 path: &[Point3],
533 tangents: &[Vec3],
534 seed: Vec3,
535) -> GeomResult<Vec<Frame>> {
536 let mut reference = seed;
537 let mut frames = Vec::with_capacity(path.len());
538 for i in 0..path.len() {
539 frames.push(Frame::from_reference(path[i], tangents[i], reference)?);
540 let Some(next) = path.get(i + 1) else { break };
541 let chord = *next - path[i];
542 let c1 = chord.dot(chord);
543 if c1 == 0.0 {
544 continue;
547 }
548 let reflected_ref = reference - chord * (2.0 / c1 * chord.dot(reference));
549 let reflected_tan = tangents[i] - chord * (2.0 / c1 * chord.dot(tangents[i]));
550 let fix = tangents[i + 1] - reflected_tan;
551 let c2 = fix.dot(fix);
552 reference = if c2 == 0.0 {
553 reflected_ref
554 } else {
555 reflected_ref - fix * (2.0 / c2 * fix.dot(reflected_ref))
556 };
557 }
558 Ok(frames)
559}
560
561fn tangents_along(path: &[Point3], ends: Option<[Vec3; 2]>) -> GeomResult<Vec<Vec3>> {
564 if path.len() < 2 {
565 return Err(GeomError::InvalidInput(format!(
566 "a sweep directrix needs at least two points, got {}",
567 path.len()
568 )));
569 }
570 (0..path.len())
571 .map(|i| {
572 let raw = if i == 0 {
573 ends.map_or(path[1] - path[0], |[start, _]| start)
574 } else if i + 1 == path.len() {
575 ends.map_or(path[i] - path[i - 1], |[_, end]| end)
576 } else {
577 (path[i] - path[i - 1]).normalize_or_zero()
578 + (path[i + 1] - path[i]).normalize_or_zero()
579 };
580 let tangent = raw.normalize_or_zero();
581 if tangent == Vec3::ZERO {
582 Err(GeomError::InvalidInput(
583 "sweep tangent must be a non-zero direction".to_owned(),
584 ))
585 } else {
586 Ok(tangent)
587 }
588 })
589 .collect()
590}
591
592pub(crate) fn disk_rings(
595 radius: Scalar,
596 inner: Option<Scalar>,
597 chord: Scalar,
598) -> GeomResult<Rings> {
599 let circle = |r: Scalar, reverse: bool| -> GeomResult<Vec<Point2>> {
600 let n = crate::revolve::steps(r, core::f64::consts::TAU, chord)?;
601 let mut pts: Vec<Point2> = (0..n)
602 .map(|k| {
603 let a = core::f64::consts::TAU * (k as Scalar) / (n as Scalar);
604 Point2::new(r * a.cos(), r * a.sin())
605 })
606 .collect();
607 if reverse {
608 pts.reverse();
609 }
610 Ok(pts)
611 };
612 Ok(Rings {
615 outer: circle(radius, false)?,
616 holes: match inner {
617 Some(r) => vec![circle(r, true)?],
618 None => Vec::new(),
619 },
620 })
621}
622
623fn seed_reference(path: &[Point3]) -> GeomResult<Vec3> {
629 if path.len() < 2 {
630 return Err(GeomError::InvalidInput(
631 "a swept disk directrix needs at least two points".to_owned(),
632 ));
633 }
634 let t = (path[1] - path[0]).normalize_or_zero();
635 if t == Vec3::ZERO {
636 return Err(GeomError::InvalidInput(
637 "a swept disk directrix must not start with a zero-length segment".to_owned(),
638 ));
639 }
640 let candidate = if t.x.abs() < 0.9 { Vec3::X } else { Vec3::Y };
641 Ok(candidate - t * t.dot(candidate))
642}
643
644pub fn sectioned_spine(sections: &[(Rings, Vec<Point3>)]) -> GeomResult<TriMesh> {
651 if sections.len() < 2 {
652 return Err(GeomError::InvalidInput(format!(
653 "a sectioned spine needs at least two sections, got {}",
654 sections.len()
655 )));
656 }
657 let stations: Vec<Station> = sections
658 .iter()
659 .map(|(rings, placed)| {
660 let mut it = placed.iter().copied();
661 let mut loops = Vec::with_capacity(1 + rings.holes.len());
662 loops.push((0..rings.outer.len()).filter_map(|_| it.next()).collect());
663 for hole in &rings.holes {
664 loops.push((0..hole.len()).filter_map(|_| it.next()).collect());
665 }
666 Station { loops }
667 })
668 .collect();
669 loft::loft(§ions[0].0, &stations, false)
670}