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]>,
203}
204
205const MAX_REFINEMENTS: usize = 16;
207
208fn bounded_stations(
235 rings: &Rings,
236 budget: Scalar,
237 mut frames_at: impl FnMut(Scalar) -> GeomResult<(Vec<Frame>, bool)>,
238) -> GeomResult<Vec<Station>> {
239 if !(budget.is_finite() && budget > 0.0) {
240 return Err(GeomError::InvalidInput(format!(
241 "sweep chord budget must be positive and finite, got {budget}"
242 )));
243 }
244 let reach = rings
245 .outer
246 .iter()
247 .chain(rings.holes.iter().flatten())
248 .map(|p| p.x.hypot(p.y))
249 .fold(0.0, Scalar::max);
250 let mut b = budget;
251 for _ in 0..MAX_REFINEMENTS {
252 let (frames, smooth) = frames_at(b)?;
253 let stations: Vec<Station> = frames
254 .iter()
255 .map(|f| loft::place(rings, |p| loft::at(f, p)))
256 .collect();
257 if !smooth {
258 return Ok(stations);
259 }
260 let worst = frames
261 .windows(2)
262 .zip(stations.windows(2))
263 .map(|(f, s)| span_bound(&f[0], &f[1], b, reach) + loft::span_deviation(&s[0], &s[1]))
264 .fold(0.0, Scalar::max);
265 if worst <= budget {
266 return Ok(stations);
267 }
268 b *= 0.5;
269 }
270 Err(GeomError::BudgetExceeded {
271 resource: "sweep directrix refinement",
272 })
273}
274
275fn span_bound(from: &Frame, to: &Frame, budget: Scalar, reach: Scalar) -> Scalar {
277 let (t0, t1) = (from.x.cross(from.y), to.x.cross(to.y));
278 let turn = t0.dot(t1).clamp(-1.0, 1.0).acos();
279 let chord = (to.origin - from.origin).length();
280 let directrix = budget.min(0.5 * chord * (0.25 * turn).tan());
281 let trace = from.x.dot(to.x) + from.y.dot(to.y) + t0.dot(t1);
282 let rotation = (0.5 * (trace - 1.0)).clamp(-1.0, 1.0).acos();
283 directrix + reach * (1.0 - (0.5 * rotation).cos())
284}
285
286pub fn fixed_reference_sweep_within(
295 rings: &Rings,
296 mut sample: impl FnMut(Scalar) -> GeomResult<SampledPath>,
297 reference: Vec3,
298 budget: Scalar,
299) -> GeomResult<TriMesh> {
300 let stations = bounded_stations(rings, budget, |b| {
301 let path = sample(b)?;
302 let frames = frames_along(&path.points, path.end_tangents, |_| reference)?;
303 Ok((frames, path.end_tangents.is_some()))
304 })?;
305 loft::loft(rings, &stations, false)
306}
307
308pub fn surface_curve_sweep_within(
315 rings: &Rings,
316 mut sample: impl FnMut(Scalar) -> GeomResult<(SampledPath, Vec<Vec3>)>,
317 budget: Scalar,
318) -> GeomResult<TriMesh> {
319 let stations = bounded_stations(rings, budget, |b| {
320 let (path, normals) = sample(b)?;
321 if normals.len() != path.points.len() {
322 return Err(GeomError::InvalidInput(
323 "a surface curve sweep needs one surface normal per directrix point".to_owned(),
324 ));
325 }
326 let frames = frames_along(&path.points, path.end_tangents, |i| normals[i])?;
327 Ok((frames, path.end_tangents.is_some()))
328 })?;
329 loft::loft(rings, &stations, false)
330}
331
332fn frames_along(
339 path: &[Point3],
340 ends: Option<[Vec3; 2]>,
341 up: impl Fn(usize) -> Vec3,
342) -> GeomResult<Vec<Frame>> {
343 let tangents = tangents_along(path, ends)?;
344 let mut frames = Vec::with_capacity(path.len());
345 for (i, (point, tangent)) in path.iter().zip(tangents).enumerate() {
346 frames.push(Frame::from_reference(*point, tangent, up(i))?);
347 }
348 Ok(frames)
349}
350
351pub fn linear_extrusion_normals(path: &[Point3], direction: Vec3) -> GeomResult<Vec<Vec3>> {
352 if path.len() < 2 {
353 return Err(GeomError::InvalidInput(
354 "a linear-extrusion surface needs at least two directrix points".into(),
355 ));
356 }
357 let direction = direction.normalize_or_zero();
358 if direction == Vec3::ZERO {
359 return Err(GeomError::InvalidInput(
360 "linear-extrusion direction must be finite and non-zero".into(),
361 ));
362 }
363 let mut normals = Vec::with_capacity(path.len());
364 for i in 0..path.len() {
365 let tangent = if i == 0 {
366 path[1] - path[0]
367 } else if i + 1 == path.len() {
368 path[i] - path[i - 1]
369 } else {
370 (path[i] - path[i - 1]).normalize_or_zero()
371 + (path[i + 1] - path[i]).normalize_or_zero()
372 };
373 let normal = tangent.cross(direction).normalize_or_zero();
374 if normal == Vec3::ZERO {
375 return Err(GeomError::Degenerate(
376 "directrix tangent is parallel to linear-extrusion direction".into(),
377 ));
378 }
379 normals.push(normal);
380 }
381 Ok(normals)
382}
383
384pub fn surface_curve_sweep(
391 rings: &Rings,
392 path: &[Point3],
393 normals: &[Vec3],
394) -> GeomResult<TriMesh> {
395 if normals.len() != path.len() {
396 return Err(GeomError::InvalidInput(
397 "a surface curve sweep needs one surface normal per directrix point".to_owned(),
398 ));
399 }
400 let frames = frames_along(path, None, |i| normals[i])?;
401 let stations: Vec<Station> = frames
402 .iter()
403 .map(|f| loft::place(rings, |p| loft::at(f, p)))
404 .collect();
405 loft::loft(rings, &stations, false)
406}
407
408pub fn swept_disk(
415 path: &[Point3],
416 radius: Scalar,
417 inner_radius: Option<Scalar>,
418 fillet_radius: Option<Scalar>,
419 tolerance: Tolerance,
420) -> GeomResult<TriMesh> {
421 check_disk(radius, inner_radius, fillet_radius)?;
422 let rings = disk_rings(radius, inner_radius, tolerance.linear())?;
432 let frames = rotation_minimising_frames(path, None)?;
433 let stations: Vec<Station> = frames
434 .iter()
435 .map(|f| loft::place(&rings, |p| loft::at(f, p)))
436 .collect();
437 loft::loft(&rings, &stations, false)
438}
439
440pub fn swept_disk_within(
454 mut sample: impl FnMut(Scalar) -> GeomResult<SampledPath>,
455 radius: Scalar,
456 inner_radius: Option<Scalar>,
457 fillet_radius: Option<Scalar>,
458 chord: Scalar,
459) -> GeomResult<TriMesh> {
460 check_disk(radius, inner_radius, fillet_radius)?;
461 let rings = disk_rings(radius, inner_radius, 0.5 * chord)?;
462 let stations = bounded_stations(&rings, 0.5 * chord, |b| {
463 let path = sample(b)?;
464 let frames = rotation_minimising_frames(&path.points, path.end_tangents)?;
465 Ok((frames, path.end_tangents.is_some()))
466 })?;
467 loft::loft(&rings, &stations, false)
468}
469
470fn check_disk(
477 radius: Scalar,
478 inner_radius: Option<Scalar>,
479 fillet_radius: Option<Scalar>,
480) -> GeomResult<()> {
481 if fillet_radius.is_some() {
482 return Err(GeomError::Unsupported {
483 backend: crate::BACKEND_ID,
484 operation: axiolid_contracts::Operation::Sweep,
485 });
486 }
487 if !radius.is_finite() || radius <= 0.0 {
488 return Err(GeomError::InvalidInput(format!(
489 "swept disk radius must be positive and finite, got {radius}"
490 )));
491 }
492 if let Some(inner) = inner_radius {
493 if !inner.is_finite() || inner <= 0.0 || inner >= radius {
494 return Err(GeomError::InvalidInput(format!(
495 "swept disk inner radius must be positive and below {radius}, got {inner}"
496 )));
497 }
498 }
499 Ok(())
500}
501
502fn rotation_minimising_frames(path: &[Point3], ends: Option<[Vec3; 2]>) -> GeomResult<Vec<Frame>> {
517 let seed = seed_reference(path)?;
518 let tangents = tangents_along(path, ends)?;
519 let mut reference = seed;
520 let mut frames = Vec::with_capacity(path.len());
521 for i in 0..path.len() {
522 frames.push(Frame::from_reference(path[i], tangents[i], reference)?);
523 let Some(next) = path.get(i + 1) else { break };
524 let chord = *next - path[i];
525 let c1 = chord.dot(chord);
526 if c1 == 0.0 {
527 continue;
530 }
531 let reflected_ref = reference - chord * (2.0 / c1 * chord.dot(reference));
532 let reflected_tan = tangents[i] - chord * (2.0 / c1 * chord.dot(tangents[i]));
533 let fix = tangents[i + 1] - reflected_tan;
534 let c2 = fix.dot(fix);
535 reference = if c2 == 0.0 {
536 reflected_ref
537 } else {
538 reflected_ref - fix * (2.0 / c2 * fix.dot(reflected_ref))
539 };
540 }
541 Ok(frames)
542}
543
544fn tangents_along(path: &[Point3], ends: Option<[Vec3; 2]>) -> GeomResult<Vec<Vec3>> {
547 if path.len() < 2 {
548 return Err(GeomError::InvalidInput(format!(
549 "a sweep directrix needs at least two points, got {}",
550 path.len()
551 )));
552 }
553 (0..path.len())
554 .map(|i| {
555 let raw = if i == 0 {
556 ends.map_or(path[1] - path[0], |[start, _]| start)
557 } else if i + 1 == path.len() {
558 ends.map_or(path[i] - path[i - 1], |[_, end]| end)
559 } else {
560 (path[i] - path[i - 1]).normalize_or_zero()
561 + (path[i + 1] - path[i]).normalize_or_zero()
562 };
563 let tangent = raw.normalize_or_zero();
564 if tangent == Vec3::ZERO {
565 Err(GeomError::InvalidInput(
566 "sweep tangent must be a non-zero direction".to_owned(),
567 ))
568 } else {
569 Ok(tangent)
570 }
571 })
572 .collect()
573}
574
575fn disk_rings(radius: Scalar, inner: Option<Scalar>, chord: Scalar) -> GeomResult<Rings> {
578 let circle = |r: Scalar, reverse: bool| -> GeomResult<Vec<Point2>> {
579 let n = crate::revolve::steps(r, core::f64::consts::TAU, chord)?;
580 let mut pts: Vec<Point2> = (0..n)
581 .map(|k| {
582 let a = core::f64::consts::TAU * (k as Scalar) / (n as Scalar);
583 Point2::new(r * a.cos(), r * a.sin())
584 })
585 .collect();
586 if reverse {
587 pts.reverse();
588 }
589 Ok(pts)
590 };
591 Ok(Rings {
594 outer: circle(radius, false)?,
595 holes: match inner {
596 Some(r) => vec![circle(r, true)?],
597 None => Vec::new(),
598 },
599 })
600}
601
602fn seed_reference(path: &[Point3]) -> GeomResult<Vec3> {
608 if path.len() < 2 {
609 return Err(GeomError::InvalidInput(
610 "a swept disk directrix needs at least two points".to_owned(),
611 ));
612 }
613 let t = (path[1] - path[0]).normalize_or_zero();
614 if t == Vec3::ZERO {
615 return Err(GeomError::InvalidInput(
616 "a swept disk directrix must not start with a zero-length segment".to_owned(),
617 ));
618 }
619 let candidate = if t.x.abs() < 0.9 { Vec3::X } else { Vec3::Y };
620 Ok(candidate - t * t.dot(candidate))
621}
622
623pub fn sectioned_spine(sections: &[(Rings, Vec<Point3>)]) -> GeomResult<TriMesh> {
630 if sections.len() < 2 {
631 return Err(GeomError::InvalidInput(format!(
632 "a sectioned spine needs at least two sections, got {}",
633 sections.len()
634 )));
635 }
636 let stations: Vec<Station> = sections
637 .iter()
638 .map(|(rings, placed)| {
639 let mut it = placed.iter().copied();
640 let mut loops = Vec::with_capacity(1 + rings.holes.len());
641 loops.push((0..rings.outer.len()).filter_map(|_| it.next()).collect());
642 for hole in &rings.holes {
643 loops.push((0..hole.len()).filter_map(|_| it.next()).collect());
644 }
645 Station { loops }
646 })
647 .collect();
648 loft::loft(§ions[0].0, &stations, false)
649}