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(
86 start: &Rings,
87 end: &Rings,
88 axis_origin: Point3,
89 axis_direction: Vec3,
90 angle: Scalar,
91 tolerance: Tolerance,
92) -> GeomResult<TriMesh> {
93 if !angle.is_finite() || angle == 0.0 {
94 return Err(GeomError::InvalidInput(format!(
95 "revolution angle must be finite and non-zero, got {angle}"
96 )));
97 }
98 let dir = axis_direction.normalize_or_zero();
99 if dir == Vec3::ZERO {
100 return Err(GeomError::InvalidInput(
101 "revolution axis must be a finite non-zero direction".to_owned(),
102 ));
103 }
104 let far = blend_rings(start, end, 1.0)?;
105 let mut max_r: Scalar = 0.0;
106 for p in start.outer.iter().chain(far.outer.iter()) {
107 let v = Point3::new(p.x, p.y, 0.0) - axis_origin;
108 max_r = max_r.max((v - dir * dir.dot(v)).length());
109 }
110 let n = crate::revolve::steps(max_r, angle, tolerance.linear());
111 let mut stations = Vec::with_capacity(n + 1);
112 for s in 0..=n {
113 let t = (s as Scalar) / (n as Scalar);
114 let ring = blend_rings(start, end, t)?;
115 let a = angle * t;
116 stations.push(loft::place(&ring, |p| {
117 crate::revolve::rotate(Point3::new(p.x, p.y, 0.0), axis_origin, dir, a)
118 }));
119 }
120 let stations: Vec<_> = stations.into_iter().rev().collect();
121 loft::loft_tapered(&far, start, &stations)
122}
123
124pub fn fixed_reference_sweep(
130 rings: &Rings,
131 path: &[Point3],
132 reference: Vec3,
133) -> GeomResult<TriMesh> {
134 let frames = frames_along(path, |_| reference)?;
135 let stations: Vec<Station> = frames
136 .iter()
137 .map(|f| loft::place(rings, |p| loft::at(f, p)))
138 .collect();
139 loft::loft(rings, &stations, false)
140}
141
142fn frames_along(path: &[Point3], up: impl Fn(usize) -> Vec3) -> GeomResult<Vec<Frame>> {
148 if path.len() < 2 {
149 return Err(GeomError::InvalidInput(format!(
150 "a sweep directrix needs at least two points, got {}",
151 path.len()
152 )));
153 }
154 let mut frames = Vec::with_capacity(path.len());
155 for i in 0..path.len() {
156 let tangent = if i == 0 {
157 path[1] - path[0]
158 } else if i + 1 == path.len() {
159 path[i] - path[i - 1]
160 } else {
161 (path[i] - path[i - 1]).normalize_or_zero()
162 + (path[i + 1] - path[i]).normalize_or_zero()
163 };
164 frames.push(Frame::from_reference(path[i], tangent, up(i))?);
165 }
166 Ok(frames)
167}
168
169pub fn linear_extrusion_normals(path: &[Point3], direction: Vec3) -> GeomResult<Vec<Vec3>> {
170 if path.len() < 2 {
171 return Err(GeomError::InvalidInput(
172 "a linear-extrusion surface needs at least two directrix points".into(),
173 ));
174 }
175 let direction = direction.normalize_or_zero();
176 if direction == Vec3::ZERO {
177 return Err(GeomError::InvalidInput(
178 "linear-extrusion direction must be finite and non-zero".into(),
179 ));
180 }
181 let mut normals = Vec::with_capacity(path.len());
182 for i in 0..path.len() {
183 let tangent = if i == 0 {
184 path[1] - path[0]
185 } else if i + 1 == path.len() {
186 path[i] - path[i - 1]
187 } else {
188 (path[i] - path[i - 1]).normalize_or_zero()
189 + (path[i + 1] - path[i]).normalize_or_zero()
190 };
191 let normal = tangent.cross(direction).normalize_or_zero();
192 if normal == Vec3::ZERO {
193 return Err(GeomError::Degenerate(
194 "directrix tangent is parallel to linear-extrusion direction".into(),
195 ));
196 }
197 normals.push(normal);
198 }
199 Ok(normals)
200}
201
202pub fn surface_curve_sweep(
209 rings: &Rings,
210 path: &[Point3],
211 normals: &[Vec3],
212) -> GeomResult<TriMesh> {
213 if normals.len() != path.len() {
214 return Err(GeomError::InvalidInput(
215 "a surface curve sweep needs one surface normal per directrix point".to_owned(),
216 ));
217 }
218 let frames = frames_along(path, |i| normals[i])?;
219 let stations: Vec<Station> = frames
220 .iter()
221 .map(|f| loft::place(rings, |p| loft::at(f, p)))
222 .collect();
223 loft::loft(rings, &stations, false)
224}
225
226pub fn swept_disk(
233 path: &[Point3],
234 radius: Scalar,
235 inner_radius: Option<Scalar>,
236 fillet_radius: Option<Scalar>,
237 tolerance: Tolerance,
238) -> GeomResult<TriMesh> {
239 if fillet_radius.is_some() {
240 return Err(GeomError::Unsupported {
241 backend: crate::BACKEND_ID,
242 operation: axiolid_contracts::Operation::Sweep,
243 });
244 }
245 if !radius.is_finite() || radius <= 0.0 {
246 return Err(GeomError::InvalidInput(format!(
247 "swept disk radius must be positive and finite, got {radius}"
248 )));
249 }
250 if let Some(inner) = inner_radius {
251 if !inner.is_finite() || inner <= 0.0 || inner >= radius {
252 return Err(GeomError::InvalidInput(format!(
253 "swept disk inner radius must be positive and below {radius}, got {inner}"
254 )));
255 }
256 }
257 let rings = disk_rings(radius, inner_radius, tolerance)?;
267 let frames = rotation_minimising_frames(path)?;
268 let stations: Vec<Station> = frames
269 .iter()
270 .map(|f| loft::place(&rings, |p| loft::at(f, p)))
271 .collect();
272 loft::loft(&rings, &stations, false)
273}
274
275fn rotation_minimising_frames(path: &[Point3]) -> GeomResult<Vec<Frame>> {
289 let seed = seed_reference(path)?;
290 let tangents = tangents_along(path)?;
291 let mut reference = seed;
292 let mut frames = Vec::with_capacity(path.len());
293 for i in 0..path.len() {
294 frames.push(Frame::from_reference(path[i], tangents[i], reference)?);
295 let Some(next) = path.get(i + 1) else { break };
296 let chord = *next - path[i];
297 let c1 = chord.dot(chord);
298 if c1 == 0.0 {
299 continue;
302 }
303 let reflected_ref = reference - chord * (2.0 / c1 * chord.dot(reference));
304 let reflected_tan = tangents[i] - chord * (2.0 / c1 * chord.dot(tangents[i]));
305 let fix = tangents[i + 1] - reflected_tan;
306 let c2 = fix.dot(fix);
307 reference = if c2 == 0.0 {
308 reflected_ref
309 } else {
310 reflected_ref - fix * (2.0 / c2 * fix.dot(reflected_ref))
311 };
312 }
313 Ok(frames)
314}
315
316fn tangents_along(path: &[Point3]) -> GeomResult<Vec<Vec3>> {
318 if path.len() < 2 {
319 return Err(GeomError::InvalidInput(format!(
320 "a sweep directrix needs at least two points, got {}",
321 path.len()
322 )));
323 }
324 (0..path.len())
325 .map(|i| {
326 let raw = if i == 0 {
327 path[1] - path[0]
328 } else if i + 1 == path.len() {
329 path[i] - path[i - 1]
330 } else {
331 (path[i] - path[i - 1]).normalize_or_zero()
332 + (path[i + 1] - path[i]).normalize_or_zero()
333 };
334 let tangent = raw.normalize_or_zero();
335 if tangent == Vec3::ZERO {
336 Err(GeomError::InvalidInput(
337 "sweep tangent must be a non-zero direction".to_owned(),
338 ))
339 } else {
340 Ok(tangent)
341 }
342 })
343 .collect()
344}
345
346fn disk_rings(radius: Scalar, inner: Option<Scalar>, tolerance: Tolerance) -> GeomResult<Rings> {
348 let circle = |r: Scalar, reverse: bool| -> Vec<Point2> {
349 let n = crate::revolve::steps(r, core::f64::consts::TAU, tolerance.linear());
350 let mut pts: Vec<Point2> = (0..n)
351 .map(|k| {
352 let a = core::f64::consts::TAU * (k as Scalar) / (n as Scalar);
353 Point2::new(r * a.cos(), r * a.sin())
354 })
355 .collect();
356 if reverse {
357 pts.reverse();
358 }
359 pts
360 };
361 Ok(Rings {
364 outer: circle(radius, false),
365 holes: inner.map(|r| vec![circle(r, true)]).unwrap_or_default(),
366 })
367}
368
369fn seed_reference(path: &[Point3]) -> GeomResult<Vec3> {
375 if path.len() < 2 {
376 return Err(GeomError::InvalidInput(
377 "a swept disk directrix needs at least two points".to_owned(),
378 ));
379 }
380 let t = (path[1] - path[0]).normalize_or_zero();
381 if t == Vec3::ZERO {
382 return Err(GeomError::InvalidInput(
383 "a swept disk directrix must not start with a zero-length segment".to_owned(),
384 ));
385 }
386 let candidate = if t.x.abs() < 0.9 { Vec3::X } else { Vec3::Y };
387 Ok(candidate - t * t.dot(candidate))
388}
389
390pub fn sectioned_spine(sections: &[(Rings, Vec<Point3>)]) -> GeomResult<TriMesh> {
397 if sections.len() < 2 {
398 return Err(GeomError::InvalidInput(format!(
399 "a sectioned spine needs at least two sections, got {}",
400 sections.len()
401 )));
402 }
403 let stations: Vec<Station> = sections
404 .iter()
405 .map(|(rings, placed)| {
406 let mut it = placed.iter().copied();
407 let mut loops = Vec::with_capacity(1 + rings.holes.len());
408 loops.push((0..rings.outer.len()).filter_map(|_| it.next()).collect());
409 for hole in &rings.holes {
410 loops.push((0..hole.len()).filter_map(|_| it.next()).collect());
411 }
412 Station { loops }
413 })
414 .collect();
415 loft::loft(§ions[0].0, &stations, false)
416}