1use axiolid_contracts::{GeomError, GeomResult};
77use axiolid_core::{Point3, Scalar, Vec3};
78use axiolid_mesh::TriMesh;
79
80use crate::loft::{self, Frame, Station};
81use crate::revolve::rotate;
82use crate::sweep;
83
84#[derive(Debug, Clone, Copy, PartialEq)]
86#[non_exhaustive]
87pub enum PathPiece {
88 Segment {
90 start: Point3,
92 end: Point3,
94 },
95 Arc {
100 centre: Point3,
102 axis: Vec3,
104 start: Point3,
106 angle: Scalar,
108 },
109}
110
111impl PathPiece {
112 #[must_use]
114 pub fn start_point(&self) -> Point3 {
115 match *self {
116 Self::Segment { start, .. } | Self::Arc { start, .. } => start,
117 }
118 }
119
120 #[must_use]
122 pub fn end_point(&self) -> Point3 {
123 self.point(1.0)
124 }
125
126 #[must_use]
128 pub fn point(&self, s: Scalar) -> Point3 {
129 match *self {
130 Self::Segment { start, end } => start + (end - start) * s,
131 Self::Arc {
132 centre,
133 axis,
134 start,
135 angle,
136 } => rotate(start, centre, axis, angle * s),
137 }
138 }
139
140 fn tangent_at(&self, point: Point3) -> Vec3 {
143 match *self {
144 Self::Segment { start, end } => (end - start).normalize_or_zero(),
145 Self::Arc {
146 centre,
147 axis,
148 angle,
149 ..
150 } => (axis.cross(point - centre) * angle.signum()).normalize_or_zero(),
151 }
152 }
153
154 #[must_use]
156 pub fn start_tangent(&self) -> Vec3 {
157 self.tangent_at(self.start_point())
158 }
159
160 #[must_use]
162 pub fn end_tangent(&self) -> Vec3 {
163 self.tangent_at(self.end_point())
164 }
165
166 #[must_use]
168 pub fn length(&self) -> Scalar {
169 match *self {
170 Self::Segment { start, end } => (end - start).length(),
171 Self::Arc {
172 centre,
173 start,
174 angle,
175 ..
176 } => (start - centre).length() * angle.abs(),
177 }
178 }
179
180 #[must_use]
182 pub fn reversed(&self) -> Self {
183 match *self {
184 Self::Segment { start, end } => Self::Segment {
185 start: end,
186 end: start,
187 },
188 Self::Arc {
189 centre,
190 axis,
191 angle,
192 ..
193 } => Self::Arc {
194 centre,
195 axis,
196 start: self.end_point(),
197 angle: -angle,
198 },
199 }
200 }
201
202 #[must_use]
205 pub fn between(&self, from: Scalar, to: Scalar) -> Self {
206 match *self {
207 Self::Segment { .. } => Self::Segment {
208 start: self.point(from),
209 end: self.point(to),
210 },
211 Self::Arc {
212 centre,
213 axis,
214 angle,
215 ..
216 } => Self::Arc {
217 centre,
218 axis,
219 start: self.point(from),
220 angle: angle * (to - from),
221 },
222 }
223 }
224
225 fn check(&self, radius: Scalar) -> GeomResult<()> {
227 let finite = |p: Point3| p.is_finite();
228 match *self {
229 Self::Segment { start, end } => {
230 if !(finite(start) && finite(end)) {
231 return Err(GeomError::InvalidInput(
232 "swept disk directrix segment must be finite".to_owned(),
233 ));
234 }
235 if start == end {
236 return Err(GeomError::Degenerate(
237 "swept disk directrix segment has no length".to_owned(),
238 ));
239 }
240 }
241 Self::Arc {
242 centre,
243 axis,
244 start,
245 angle,
246 } => {
247 if !(finite(centre) && finite(axis) && finite(start) && angle.is_finite()) {
248 return Err(GeomError::InvalidInput(
249 "swept disk directrix arc must be finite".to_owned(),
250 ));
251 }
252 let bend = (start - centre).length();
253 if (axis.length() - 1.0).abs() > 1e-9
254 || bend == 0.0
255 || (start - centre).dot(axis).abs() > 1e-9 * bend
256 {
257 return Err(GeomError::InvalidInput(
258 "swept disk directrix arc needs a unit axis normal to a non-zero \
259 radius"
260 .to_owned(),
261 ));
262 }
263 if angle == 0.0 || angle.abs() > core::f64::consts::TAU * (1.0 + 1e-12) {
264 return Err(GeomError::InvalidInput(format!(
265 "swept disk directrix arc must turn by a non-zero angle of at most \
266 a full turn, got {angle}"
267 )));
268 }
269 if bend <= radius {
270 return Err(GeomError::InvalidInput(format!(
271 "swept disk radius {radius} is not below the bend radius {bend}: \
272 the tube would fold through the bend's axis"
273 )));
274 }
275 }
276 }
277 Ok(())
278 }
279
280 fn sample(&self, budget: Scalar) -> GeomResult<(Vec<Point3>, Vec<Vec3>)> {
284 let n = match *self {
285 Self::Segment { .. } => 1,
286 Self::Arc {
287 centre,
288 start,
289 angle,
290 ..
291 } => crate::revolve::steps((start - centre).length(), angle, budget).map_err(|_| {
292 GeomError::BudgetExceeded {
293 resource: "swept disk bend steps",
294 }
295 })?,
296 };
297 let points: Vec<Point3> = (0..=n)
298 .map(|k| self.point(k as Scalar / n as Scalar))
299 .collect();
300 let tangents = points.iter().map(|p| self.tangent_at(*p)).collect();
301 Ok((points, tangents))
302 }
303}
304
305#[must_use]
309pub fn joint_tolerance(chord: Scalar, radius: Scalar) -> Scalar {
310 2.0 * (chord / (8.0 * radius)).min(1.0).asin()
311}
312
313fn turn(a: Vec3, b: Vec3) -> Scalar {
315 a.cross(b).length().atan2(a.dot(b))
316}
317
318pub fn swept_disk_along_pieces(
326 pieces: &[PathPiece],
327 radius: Scalar,
328 inner_radius: Option<Scalar>,
329 fillet_radius: Option<Scalar>,
330 chord: Scalar,
331) -> GeomResult<TriMesh> {
332 sweep::check_radii(radius, inner_radius)?;
333 if !(chord.is_finite() && chord > 0.0) {
334 return Err(GeomError::InvalidInput(format!(
335 "swept disk chord budget must be positive and finite, got {chord}"
336 )));
337 }
338 if let Some(f) = fillet_radius {
339 if !(f.is_finite() && f > 0.0) {
340 return Err(GeomError::InvalidInput(format!(
341 "swept disk fillet radius must be positive and finite, got {f}"
342 )));
343 }
344 }
345 if pieces.is_empty() {
346 return Err(GeomError::InvalidInput(
347 "a swept disk directrix needs at least one piece".to_owned(),
348 ));
349 }
350 let tolerance = joint_tolerance(chord, radius);
351 let path = match fillet_radius {
352 Some(f) => fillet(pieces, f, radius, tolerance)?,
353 None => pieces.to_vec(),
354 };
355 for piece in &path {
356 piece.check(radius)?;
357 }
358 for (k, pair) in path.windows(2).enumerate() {
359 let theta = turn(pair[0].end_tangent(), pair[1].start_tangent());
360 if theta > tolerance {
361 let advice = if fillet_radius.is_some() {
362 "only corners between two straight segments are filleted"
363 } else {
364 "a swept disk along it is undefined; give a fillet radius or a \
365 tangent-continuous directrix"
366 };
367 return Err(GeomError::InvalidInput(format!(
368 "swept disk directrix turns a corner of {theta} rad at joint {k}, above \
369 the {tolerance} rad a tangent-continuous joint may turn at this chord \
370 budget: {advice}"
371 )));
372 }
373 let gap = (pair[1].start_point() - pair[0].end_point()).length();
374 if gap > 0.25 * chord {
375 return Err(GeomError::InvalidInput(format!(
376 "swept disk directrix pieces leave a {gap} gap at joint {k}, more than a \
377 quarter of the {chord} chord budget"
378 )));
379 }
380 }
381 let rings = sweep::disk_rings(radius, inner_radius, 0.5 * chord)?;
382 let share = 0.5 * chord;
383 let mut stations: Vec<Station> = Vec::new();
384 let mut last: Option<Frame> = None;
385 for piece in &path {
386 let tangent = piece.start_tangent();
387 let seed = match &last {
390 None => seed_reference(tangent),
391 Some(frame) => least_rotation(frame.x, frame.x.cross(frame.y), tangent),
392 };
393 let joint = stations.last();
394 let (frames, placed) = bounded_piece(piece, &rings, seed, radius, share, joint)?;
395 let skip = usize::from(joint.is_some());
396 stations.extend(placed.into_iter().skip(skip));
397 last = frames.into_iter().last();
398 }
399 let mut mesh = loft::loft(&rings, &stations, false)?;
400 if matches!(crate::extrude::outward_orientation(&mesh), Some(false)) {
403 for triangle in mesh.indices.chunks_exact_mut(3) {
404 triangle.swap(1, 2);
405 }
406 }
407 Ok(mesh)
408}
409
410const MAX_REFINEMENTS: usize = 16;
412
413fn bounded_piece(
417 piece: &PathPiece,
418 rings: &crate::profile::Rings,
419 seed: Vec3,
420 reach: Scalar,
421 share: Scalar,
422 joint: Option<&Station>,
423) -> GeomResult<(Vec<Frame>, Vec<Station>)> {
424 let mut b = share;
425 for _ in 0..MAX_REFINEMENTS {
426 let (points, tangents) = piece.sample(b)?;
427 let frames = sweep::frames_carried(&points, &tangents, seed)?;
428 let placed: Vec<Station> = frames
429 .iter()
430 .map(|f| loft::place(rings, |p| loft::at(f, p)))
431 .collect();
432 let shift = joint.map_or(0.0, |j| displacement(j, &placed[0]));
433 let worst = frames
434 .windows(2)
435 .zip(placed.windows(2))
436 .enumerate()
437 .map(|(k, (f, s))| {
438 let moved = if k == 0 { shift } else { 0.0 };
439 sweep::span_bound(&f[0], &f[1], b, reach)
440 + loft::span_deviation(&s[0], &s[1])
441 + moved
442 })
443 .fold(0.0, Scalar::max);
444 if worst <= share {
445 return Ok((frames, placed));
446 }
447 b *= 0.5;
448 }
449 Err(GeomError::BudgetExceeded {
450 resource: "swept disk directrix refinement",
451 })
452}
453
454fn displacement(a: &Station, b: &Station) -> Scalar {
456 a.loops
457 .iter()
458 .zip(&b.loops)
459 .flat_map(|(p, q)| p.iter().zip(q).map(|(u, v)| (*u - *v).length()))
460 .fold(0.0, Scalar::max)
461}
462
463fn least_rotation(v: Vec3, from: Vec3, to: Vec3) -> Vec3 {
465 let axis = from.cross(to);
466 let sin = axis.length();
467 if sin == 0.0 {
468 return v;
469 }
470 rotate(v, Point3::ZERO, axis / sin, turn(from, to))
471}
472
473fn seed_reference(t: Vec3) -> Vec3 {
475 let candidate = if t.x.abs() < 0.9 { Vec3::X } else { Vec3::Y };
476 candidate - t * t.dot(candidate)
477}
478
479fn fillet(
482 pieces: &[PathPiece],
483 fillet: Scalar,
484 radius: Scalar,
485 tolerance: Scalar,
486) -> GeomResult<Vec<PathPiece>> {
487 struct Leg {
490 piece: PathPiece,
491 dir: Vec3,
492 trim_start: Scalar,
493 trim_end: Scalar,
494 }
495 let mut legs: Vec<Leg> = Vec::with_capacity(pieces.len());
496 let mut arcs: Vec<Option<PathPiece>> = Vec::with_capacity(pieces.len());
497 for piece in pieces {
498 piece.check(radius)?;
499 legs.push(Leg {
500 piece: *piece,
501 dir: piece.start_tangent(),
502 trim_start: 0.0,
503 trim_end: 0.0,
504 });
505 }
506 for k in 1..legs.len() {
507 let (PathPiece::Segment { .. }, PathPiece::Segment { start: corner, .. }) =
508 (legs[k - 1].piece, legs[k].piece)
509 else {
510 arcs.push(None);
511 continue;
512 };
513 let (u, v) = (legs[k - 1].dir, legs[k].dir);
514 let theta = turn(u, v);
515 if theta <= tolerance {
516 arcs.push(None);
517 continue;
518 }
519 if radius >= fillet {
520 return Err(GeomError::InvalidInput(format!(
521 "swept disk radius {radius} is not below the fillet radius {fillet}: the \
522 tube would fold through the bend's axis"
523 )));
524 }
525 let normal = u.cross(v);
526 if normal.length() <= 1e-12 {
527 return Err(GeomError::InvalidInput(format!(
528 "swept disk directrix reverses at corner {k}; no fillet rounds it"
529 )));
530 }
531 let cut = fillet * (0.5 * theta).tan();
532 legs[k - 1].trim_end = cut;
533 legs[k].trim_start = cut;
534 let tangent_point = corner - u * cut;
535 let inward = (v - u * u.dot(v)).normalize();
536 arcs.push(Some(PathPiece::Arc {
537 centre: tangent_point + inward * fillet,
538 axis: normal.normalize(),
539 start: tangent_point,
540 angle: theta,
541 }));
542 }
543 let mut out = Vec::with_capacity(2 * legs.len());
544 for (k, leg) in legs.iter().enumerate() {
545 if k > 0 {
546 if let Some(arc) = arcs[k - 1] {
547 out.push(arc);
548 }
549 }
550 let length = leg.piece.length();
551 let needed = leg.trim_start + leg.trim_end;
552 if needed == 0.0 {
553 out.push(leg.piece);
554 continue;
555 }
556 let slack = 1e-9 * length;
559 if needed > length + slack {
560 return Err(GeomError::InvalidInput(format!(
561 "swept disk fillet radius {fillet} does not fit: segment {k} is {length} \
562 long but its fillets need {needed} of it"
563 )));
564 }
565 if needed < length - slack {
566 out.push(
567 leg.piece
568 .between(leg.trim_start / length, 1.0 - leg.trim_end / length),
569 );
570 }
571 }
572 Ok(out)
573}
574
575#[cfg(test)]
576mod tests {
577 use super::*;
578
579 #[test]
580 fn a_fillet_is_tangent_to_both_segments() {
581 let corner = Point3::new(1.0, 0.0, 0.0);
582 let pieces = [
583 PathPiece::Segment {
584 start: Point3::ZERO,
585 end: corner,
586 },
587 PathPiece::Segment {
588 start: corner,
589 end: Point3::new(1.0, 1.0, 1.0),
590 },
591 ];
592 let path = fillet(&pieces, 0.2, 0.05, 1e-6).unwrap();
593 assert_eq!(path.len(), 3);
594 for pair in path.windows(2) {
595 assert!((pair[0].end_point() - pair[1].start_point()).length() < 1e-12);
596 assert!(turn(pair[0].end_tangent(), pair[1].start_tangent()) < 1e-12);
597 }
598 }
599
600 #[test]
601 fn an_arc_walked_backwards_retraces_itself() {
602 let arc = PathPiece::Arc {
603 centre: Point3::new(0.0, 1.0, 0.0),
604 axis: Vec3::Z,
605 start: Point3::ZERO,
606 angle: 1.0,
607 };
608 let back = arc.reversed();
609 assert!((back.end_point() - arc.start_point()).length() < 1e-12);
610 assert!((back.start_tangent() + arc.end_tangent()).length() < 1e-12);
611 let part = arc.between(0.25, 0.75);
612 assert!((part.length() - 0.5).abs() < 1e-12);
613 assert!((part.start_point() - arc.point(0.25)).length() < 1e-12);
614 }
615}