1use crate::feature::FaceTag;
33use crate::feature::geom::{CsgExpr, CsgLeaves, GeomLeaf, LeafPrim};
34use crate::prelude_::*;
35
36const PROBE_FRAC: f64 = 1.0e-4;
38const LINE_SAMPLES: usize = 9;
40const CIRCLE_SAMPLES: usize = 8;
42const TRIM_ITERS: usize = 12;
44const ALIGN_EPS: f64 = 1.0e-7;
46
47#[derive(Clone, Copy, Debug, PartialEq, Eq)]
52pub enum Convexity {
53 Convex,
54 Concave,
55}
56
57#[derive(Clone, Copy, Debug, PartialEq, Eq)]
60pub enum EdgeReject {
61 Unsupported,
63 UnsupportedCurve,
66 NonUniformScale,
71 NotAnEdge,
74 PartialCircle,
76 StraightConcave,
78}
79
80impl EdgeReject {
81 pub fn message(self) -> &'static str {
82 match self {
83 Self::Unsupported => "Edge not supported on this surface",
84 Self::UnsupportedCurve => "Edge curve not supported (line/circle only)",
85 Self::NonUniformScale => "Mismatched anisotropic scales not supported",
86 Self::NotAnEdge => "No edge between these faces",
87 Self::PartialCircle => "Partial circular edges not supported",
88 Self::StraightConcave => "Concave straight edges not supported",
89 }
90 }
91}
92
93#[derive(Clone, Copy, Debug)]
95pub enum EdgeGeom {
96 Straight(StraightEdge),
97 Circular(CircularEdge),
98}
99
100#[derive(Clone, Copy, Debug)]
102pub struct StraightEdge {
103 pub start: DVec3,
104 pub end: DVec3,
105 pub na: DVec3,
107 pub nb: DVec3,
108 pub da: DVec3,
110 pub db: DVec3,
111 pub extent_a: f64,
113 pub extent_b: f64,
114}
115
116impl StraightEdge {
117 pub fn dir(&self) -> DVec3 {
118 (self.end - self.start).normalize_or_zero()
119 }
120}
121
122#[derive(Clone, Copy, Debug)]
136pub struct CircularEdge {
137 pub frame: DAffine3,
139 pub center: DVec3,
140 pub axis: DVec3,
142 pub radius: f64,
143 pub ref_dir: DVec3,
147 pub leaf_segments: Option<usize>,
150 pub nb2: DVec2,
152 pub da2: DVec2,
155 pub db2: DVec2,
156 pub extent_a: f64,
158 pub extent_b: f64,
159}
160
161impl CircularEdge {
162 pub fn radial_at(&self, theta: f64) -> (DVec3, DVec3) {
165 let (u, v) = (self.ref_dir, self.ref_dir.cross(self.axis));
166 let r_hat = theta.cos() * u + theta.sin() * v;
167 (r_hat, self.center + self.radius * r_hat)
168 }
169}
170
171fn point_segment_distance(p: DVec3, a: DVec3, b: DVec3) -> f64 {
173 let ab = b - a;
174 let len_sq = ab.length_squared();
175 if len_sq < 1.0e-24 {
176 return (p - a).length();
177 }
178 let t = ((p - a).dot(ab) / len_sq).clamp(0.0, 1.0);
179 (p - (a + t * ab)).length()
180}
181
182const ELLIPSE_DISTANCE_SEGMENTS: usize = 64;
184
185impl EdgeGeom {
186 pub fn distance_to(&self, p: DVec3) -> f64 {
190 match self {
191 EdgeGeom::Straight(e) => point_segment_distance(p, e.start, e.end),
192 EdgeGeom::Circular(c) if c.frame == DAffine3::IDENTITY => {
193 let rel = p - c.center;
194 let axial = rel.dot(c.axis);
195 let radial = rel - axial * c.axis;
196 let rho = radial.length();
197 if rho < 1.0e-12 {
198 return (axial * axial + c.radius * c.radius).sqrt();
200 }
201 let nearest = c.center + radial / rho * c.radius;
202 (p - nearest).length()
203 }
204 EdgeGeom::Circular(_) => {
205 let pts = self.polyline(ELLIPSE_DISTANCE_SEGMENTS);
206 (0..pts.len())
207 .map(|i| point_segment_distance(p, pts[i], pts[(i + 1) % pts.len()]))
208 .fold(f64::INFINITY, f64::min)
209 }
210 }
211 }
212
213 pub fn polyline(&self, segments: usize) -> Vec<DVec3> {
216 match self {
217 EdgeGeom::Straight(e) => vec![e.start, e.end],
218 EdgeGeom::Circular(c) => (0..segments.max(3))
219 .map(|i| {
220 let theta = core::f64::consts::TAU * i as f64 / segments.max(3) as f64;
221 c.frame.transform_point3(c.radial_at(theta).1)
222 })
223 .collect(),
224 }
225 }
226
227 fn into_world_from(self, frame: &DAffine3) -> EdgeGeom {
233 match self {
234 EdgeGeom::Straight(e) => {
235 let m = frame.matrix3;
236 let n_it = m.inverse().transpose();
237 let na = (n_it * e.na).normalize_or_zero();
238 let nb = (n_it * e.nb).normalize_or_zero();
239 let start = frame.transform_point3(e.start);
240 let end = frame.transform_point3(e.end);
241 let u = (end - start).normalize_or_zero();
242 let redir = |n: DVec3, d_frame: DVec3| {
245 let c = u.cross(n).normalize_or_zero();
246 if c.dot(m * d_frame) >= 0.0 { c } else { -c }
247 };
248 EdgeGeom::Straight(StraightEdge {
249 start,
250 end,
251 na,
252 nb,
253 da: redir(na, e.da),
254 db: redir(nb, e.db),
255 extent_a: (m * (e.da * e.extent_a)).length(),
256 extent_b: (m * (e.db * e.extent_b)).length(),
257 })
258 }
259 EdgeGeom::Circular(c) => EdgeGeom::Circular(CircularEdge {
260 frame: *frame * c.frame,
261 ..c
262 }),
263 }
264 }
265}
266
267pub fn leaf_face_tags(prim: &LeafPrim) -> &'static [FaceTag] {
271 match prim {
272 LeafPrim::Cube { .. } => &[
273 FaceTag::CubePosX,
274 FaceTag::CubeNegX,
275 FaceTag::CubePosY,
276 FaceTag::CubeNegY,
277 FaceTag::CubePosZ,
278 FaceTag::CubeNegZ,
279 ],
280 LeafPrim::Cylinder { .. } => &[FaceTag::CylSide, FaceTag::CylTop, FaceTag::CylBottom],
281 LeafPrim::Cone { .. } => &[FaceTag::ConeSide, FaceTag::ConeBottom],
282 LeafPrim::Sphere { .. } => &[FaceTag::Sphere],
283 LeafPrim::Aabb { .. } => &[],
284 }
285}
286
287pub fn leaf_flipped(csg: &CsgLeaves) -> Vec<bool> {
291 fn walk(expr: &CsgExpr, flipped: bool, out: &mut Vec<(usize, bool)>) {
292 match expr {
293 CsgExpr::Leaf(i) => out.push((*i, flipped)),
294 CsgExpr::Union(cs) | CsgExpr::Inter(cs) => {
295 cs.iter().for_each(|c| walk(c, flipped, out));
296 }
297 CsgExpr::Diff(cs) => {
298 if let Some((first, rest)) = cs.split_first() {
299 walk(first, flipped, out);
300 rest.iter().for_each(|c| walk(c, !flipped, out));
301 }
302 }
303 }
304 }
305 let mut pairs = Vec::with_capacity(csg.leaves.len());
306 walk(&csg.expr, false, &mut pairs);
307 let mut flags = vec![false; csg.leaves.len()];
308 for (i, f) in pairs {
309 flags[i] = f;
310 }
311 flags
312}
313
314pub fn csg_aabb(csg: &CsgLeaves) -> Option<(DVec3, DVec3)> {
316 let mut bounds: Option<(DVec3, DVec3)> = None;
317 for leaf in &csg.leaves {
318 let (lo, hi) = match leaf.prim {
319 LeafPrim::Cube { size } => (DVec3::ZERO, size),
320 LeafPrim::Cylinder { radius, height, .. } | LeafPrim::Cone { radius, height, .. } => (
321 DVec3::new(-radius, 0.0, -radius),
322 DVec3::new(radius, height, radius),
323 ),
324 LeafPrim::Sphere { radius } => (DVec3::splat(-radius), DVec3::splat(radius)),
325 LeafPrim::Aabb { min, max } => (min, max),
326 };
327 for i in 0..8 {
328 let corner = DVec3::new(
329 if i & 1 == 0 { lo.x } else { hi.x },
330 if i & 2 == 0 { lo.y } else { hi.y },
331 if i & 4 == 0 { lo.z } else { hi.z },
332 );
333 let p = leaf.world.transform_point3(corner);
334 bounds = Some(match bounds {
335 None => (p, p),
336 Some((mn, mx)) => (mn.min(p), mx.max(p)),
337 });
338 }
339 }
340 bounds
341}
342
343fn axisymmetric_scale(world: &DAffine3) -> Option<(f64, f64)> {
351 let m = world.matrix3;
352 let g = m.transpose() * m;
353 let r2 = (g.x_axis.x + g.z_axis.z) / 2.0;
354 let h2 = g.y_axis.y;
355 if r2 <= 0.0 || h2 <= 0.0 {
356 return None;
357 }
358 let mix = ALIGN_EPS * (r2 * h2).sqrt();
360 let ok = (g.x_axis.x - r2).abs() < ALIGN_EPS * r2
361 && g.x_axis.z.abs() < ALIGN_EPS * r2
362 && g.x_axis.y.abs() < mix
363 && g.y_axis.z.abs() < mix;
364 ok.then(|| (r2.sqrt(), h2.sqrt()))
365}
366
367fn similarity_scale(world: &DAffine3) -> Option<f64> {
370 let (s_r, s_h) = axisymmetric_scale(world)?;
371 let (r2, h2) = (s_r * s_r, s_h * s_h);
372 ((r2 - h2).abs() < ALIGN_EPS * r2.max(h2)).then(|| ((2.0 * r2 + h2) / 3.0).sqrt())
373}
374
375#[derive(Clone, Copy, Debug)]
379enum PlaneExtent {
380 Quad { origin: DVec3, eu: DVec3, ev: DVec3 },
382 Disc { center: DVec3, radius: f64 },
384}
385
386#[derive(Clone, Copy, Debug)]
389enum Surface {
390 Plane {
391 point: DVec3,
392 normal: DVec3,
394 extent: PlaneExtent,
395 },
396 Cylinder {
397 base: DVec3,
398 axis: DVec3,
399 radius: f64,
400 height: f64,
401 },
402 Cone {
403 base: DVec3,
404 axis: DVec3,
405 radius: f64,
406 height: f64,
407 },
408 Sphere {
409 center: DVec3,
410 radius: f64,
411 },
412}
413
414impl Surface {
415 fn normal_at(&self, p: DVec3) -> DVec3 {
417 match self {
418 Surface::Plane { normal, .. } => *normal,
419 Surface::Cylinder { base, axis, .. } => {
420 let rel = p - *base;
421 (rel - rel.dot(*axis) * *axis).normalize_or_zero()
422 }
423 Surface::Cone {
424 base,
425 axis,
426 radius,
427 height,
428 } => {
429 let rel = p - *base;
430 let r_hat = (rel - rel.dot(*axis) * *axis).normalize_or_zero();
431 if r_hat == DVec3::ZERO {
432 return *axis;
433 }
434 (r_hat + radius / height * *axis).normalize()
435 }
436 Surface::Sphere { center, .. } => (p - *center).normalize_or_zero(),
437 }
438 }
439
440 fn project(&self, p: DVec3) -> DVec3 {
443 match self {
444 Surface::Plane { point, normal, .. } => p - (p - *point).dot(*normal) * *normal,
445 Surface::Cylinder {
446 base, axis, radius, ..
447 } => {
448 let rel = p - *base;
449 let y = rel.dot(*axis);
450 let radial = rel - y * *axis;
451 let r_hat = radial.normalize_or_zero();
452 *base + y * *axis + *radius * r_hat
453 }
454 Surface::Cone {
455 base,
456 axis,
457 radius,
458 height,
459 } => {
460 let rel = p - *base;
461 let y = rel.dot(*axis).clamp(0.0, *height);
462 let radial = rel - rel.dot(*axis) * *axis;
463 let r_hat = radial.normalize_or_zero();
464 let rho = radius * (1.0 - y / height);
465 *base + y * *axis + rho * r_hat
466 }
467 Surface::Sphere { center, radius } => {
468 *center + (p - *center).normalize_or_zero() * *radius
469 }
470 }
471 }
472}
473
474fn face_surface(leaf: &GeomLeaf, face: FaceTag) -> Result<Surface, EdgeReject> {
476 let w = &leaf.world;
477 let normal = |n_local: DVec3| (leaf.world_inv.matrix3.transpose() * n_local).normalize();
478 let quad = |axis: usize, positive: bool, size: DVec3| {
479 let mut corner = DVec3::ZERO;
480 corner[axis] = if positive { size[axis] } else { 0.0 };
481 let (u, v) = ((axis + 1) % 3, (axis + 2) % 3);
482 let mut eu = DVec3::ZERO;
483 eu[u] = size[u];
484 let mut ev = DVec3::ZERO;
485 ev[v] = size[v];
486 PlaneExtent::Quad {
487 origin: w.transform_point3(corner),
488 eu: w.matrix3 * eu,
489 ev: w.matrix3 * ev,
490 }
491 };
492 let similarity = || similarity_scale(w).ok_or(EdgeReject::NonUniformScale);
493 let axisym = || axisymmetric_scale(w).ok_or(EdgeReject::NonUniformScale);
494 match (&leaf.prim, face) {
495 (LeafPrim::Cube { size }, tag) => {
496 let (axis, positive) = match tag {
497 FaceTag::CubePosX => (0, true),
498 FaceTag::CubeNegX => (0, false),
499 FaceTag::CubePosY => (1, true),
500 FaceTag::CubeNegY => (1, false),
501 FaceTag::CubePosZ => (2, true),
502 FaceTag::CubeNegZ => (2, false),
503 _ => return Err(EdgeReject::Unsupported),
504 };
505 let mut n_local = DVec3::ZERO;
506 n_local[axis] = if positive { 1.0 } else { -1.0 };
507 let mut on_face = DVec3::ZERO;
508 on_face[axis] = if positive { size[axis] } else { 0.0 };
509 Ok(Surface::Plane {
510 point: w.transform_point3(on_face),
511 normal: normal(n_local),
512 extent: quad(axis, positive, *size),
513 })
514 }
515 (LeafPrim::Cylinder { radius, height, .. }, FaceTag::CylTop | FaceTag::CylBottom) => {
516 let (s_r, _) = axisym()?;
517 let y = if face == FaceTag::CylTop {
518 *height
519 } else {
520 0.0
521 };
522 let n_local = if face == FaceTag::CylTop {
523 DVec3::Y
524 } else {
525 -DVec3::Y
526 };
527 let center = w.transform_point3(DVec3::new(0.0, y, 0.0));
528 Ok(Surface::Plane {
529 point: center,
530 normal: normal(n_local),
531 extent: PlaneExtent::Disc {
532 center,
533 radius: radius * s_r,
534 },
535 })
536 }
537 (LeafPrim::Cylinder { radius, height, .. }, FaceTag::CylSide) => {
538 let (s_r, s_h) = axisym()?;
539 Ok(Surface::Cylinder {
540 base: w.transform_point3(DVec3::ZERO),
541 axis: (w.matrix3 * DVec3::Y).normalize(),
542 radius: radius * s_r,
543 height: height * s_h,
544 })
545 }
546 (LeafPrim::Cone { radius, .. }, FaceTag::ConeBottom) => {
547 let (s_r, _) = axisym()?;
548 let center = w.transform_point3(DVec3::ZERO);
549 Ok(Surface::Plane {
550 point: center,
551 normal: normal(-DVec3::Y),
552 extent: PlaneExtent::Disc {
553 center,
554 radius: radius * s_r,
555 },
556 })
557 }
558 (LeafPrim::Cone { radius, height, .. }, FaceTag::ConeSide) => {
559 let (s_r, s_h) = axisym()?;
560 Ok(Surface::Cone {
561 base: w.transform_point3(DVec3::ZERO),
562 axis: (w.matrix3 * DVec3::Y).normalize(),
563 radius: radius * s_r,
564 height: height * s_h,
565 })
566 }
567 (LeafPrim::Sphere { radius }, FaceTag::Sphere) => {
568 let s = similarity()?;
569 Ok(Surface::Sphere {
570 center: w.transform_point3(DVec3::ZERO),
571 radius: radius * s,
572 })
573 }
574 _ => Err(EdgeReject::Unsupported),
575 }
576}
577
578struct Prober<'a> {
581 csg: &'a CsgLeaves,
582 delta: f64,
584}
585
586impl<'a> Prober<'a> {
587 fn new(csg: &'a CsgLeaves) -> Self {
588 let diag = csg_aabb(csg)
589 .map(|(mn, mx)| (mx - mn).length())
590 .unwrap_or(1.0)
591 .max(1.0e-6);
592 Self {
593 csg,
594 delta: PROBE_FRAC * diag,
595 }
596 }
597
598 fn edge_exists(&self, q: DVec3, bisector: DVec3) -> bool {
601 let b = bisector.normalize_or_zero();
602 if b == DVec3::ZERO {
603 return false;
604 }
605 self.csg.contains(q - self.delta * b) && !self.csg.contains(q + self.delta * b)
606 }
607
608 fn face_survives(&self, surface: &Surface, n_final_sign: f64, q: DVec3, d: DVec3) -> bool {
611 let p = surface.project(q + self.delta * d);
612 let n = surface.normal_at(p) * n_final_sign;
613 if n == DVec3::ZERO {
614 return false;
615 }
616 let h = 0.5 * self.delta;
617 self.csg.contains(p - h * n) && !self.csg.contains(p + h * n)
618 }
619
620 fn orient(
623 &self,
624 surface: &Surface,
625 n_sign: f64,
626 q: DVec3,
627 candidate: DVec3,
628 ) -> Result<DVec3, EdgeReject> {
629 let pos = self.face_survives(surface, n_sign, q, candidate);
630 let neg = self.face_survives(surface, n_sign, q, -candidate);
631 match (pos, neg) {
632 (true, false) => Ok(candidate),
633 (false, true) => Ok(-candidate),
634 _ => Err(EdgeReject::NotAnEdge),
635 }
636 }
637}
638
639fn classify(da_dot_nb: f64, db_dot_na: f64) -> Result<Convexity, EdgeReject> {
643 let eps = 1.0e-9;
644 if da_dot_nb < -eps && db_dot_na < -eps {
645 Ok(Convexity::Convex)
646 } else if da_dot_nb > eps && db_dot_na > eps {
647 Ok(Convexity::Concave)
648 } else {
649 Err(EdgeReject::NotAnEdge) }
651}
652
653fn clip_to_extent(extent: &PlaneExtent, q0: DVec3, dir: DVec3) -> Option<(f64, f64)> {
658 match extent {
659 PlaneExtent::Quad { origin, eu, ev } => {
660 let g00 = eu.dot(*eu);
662 let g01 = eu.dot(*ev);
663 let g11 = ev.dot(*ev);
664 let det = g00 * g11 - g01 * g01;
665 if det.abs() < 1.0e-18 {
666 return None;
667 }
668 let params = |p: DVec3| {
669 let r = p - *origin;
670 let (bu, bv) = (r.dot(*eu), r.dot(*ev));
671 ((bu * g11 - bv * g01) / det, (bv * g00 - bu * g01) / det)
672 };
673 let (u0, v0) = params(q0);
674 let (u1, v1) = params(q0 + dir);
675 let (du, dv) = (u1 - u0, v1 - v0);
676 const PARAM_EPS: f64 = 1.0e-9;
680 let mut lo = f64::NEG_INFINITY;
681 let mut hi = f64::INFINITY;
682 for (start, delta) in [(u0, du), (v0, dv)] {
683 if delta.abs() < 1.0e-12 {
684 if !(-PARAM_EPS..=1.0 + PARAM_EPS).contains(&start) {
685 return None;
686 }
687 continue;
688 }
689 let (t0, t1) = ((0.0 - start) / delta, (1.0 - start) / delta);
690 lo = lo.max(t0.min(t1));
691 hi = hi.min(t0.max(t1));
692 }
693 (lo < hi).then_some((lo, hi))
694 }
695 PlaneExtent::Disc { center, radius } => {
696 let rel = q0 - *center;
697 let a = dir.dot(dir);
698 if a < 1.0e-18 {
699 return (rel.length() <= *radius).then_some((f64::NEG_INFINITY, f64::INFINITY));
700 }
701 let b = 2.0 * rel.dot(dir);
702 let c = rel.dot(rel) - radius * radius;
703 let disc = b * b - 4.0 * a * c;
704 (disc > 0.0).then(|| {
705 let sq = disc.sqrt();
706 ((-b - sq) / (2.0 * a), (-b + sq) / (2.0 * a))
707 })
708 }
709 }
710}
711
712fn extent_along(extent: &PlaneExtent, q: DVec3, d: DVec3) -> f64 {
714 clip_to_extent(extent, q, d).map_or(0.0, |(_, hi)| hi.max(0.0))
715}
716
717pub fn resolve_edge(
729 csg: &CsgLeaves,
730 a: (usize, FaceTag),
731 b: (usize, FaceTag),
732) -> Result<(EdgeGeom, Convexity), EdgeReject> {
733 let world = resolve_edge_framed(csg, a, b);
734 let Err(EdgeReject::NonUniformScale) = world else {
735 return world;
736 };
737 let mut err = EdgeReject::NonUniformScale;
738 let mut tried: Option<usize> = None;
739 for (li, tag) in [a, b] {
740 let round = matches!(
743 tag,
744 FaceTag::CylSide
745 | FaceTag::CylTop
746 | FaceTag::CylBottom
747 | FaceTag::ConeSide
748 | FaceTag::ConeBottom
749 | FaceTag::Sphere
750 );
751 if !round || tried == Some(li) {
752 continue;
753 }
754 tried = Some(li);
755 let Some(leaf) = csg.leaves.get(li) else {
756 continue;
757 };
758 let frame = leaf.world;
759 let reframed = reframe(csg, &leaf.world_inv, &frame);
760 match resolve_edge_framed(&reframed, a, b) {
761 Ok((geom, convexity)) => return Ok((geom.into_world_from(&frame), convexity)),
762 Err(e) => err = e,
763 }
764 }
765 Err(err)
766}
767
768fn reframe(csg: &CsgLeaves, f_inv: &DAffine3, f: &DAffine3) -> CsgLeaves {
771 CsgLeaves {
772 leaves: csg
773 .leaves
774 .iter()
775 .map(|l| GeomLeaf {
776 prim: l.prim,
777 world: *f_inv * l.world,
778 world_inv: l.world_inv * *f,
780 })
781 .collect(),
782 expr: csg.expr.clone(),
783 paths: csg.paths.clone(),
784 }
785}
786
787fn resolve_edge_framed(
790 csg: &CsgLeaves,
791 a: (usize, FaceTag),
792 b: (usize, FaceTag),
793) -> Result<(EdgeGeom, Convexity), EdgeReject> {
794 if a == b {
795 return Err(EdgeReject::NotAnEdge);
796 }
797 let flipped = leaf_flipped(csg);
798 let leaf_a = csg.leaves.get(a.0).ok_or(EdgeReject::Unsupported)?;
799 let leaf_b = csg.leaves.get(b.0).ok_or(EdgeReject::Unsupported)?;
800 let surf_a = face_surface(leaf_a, a.1)?;
801 let surf_b = face_surface(leaf_b, b.1)?;
802 let sign_a = if flipped[a.0] { -1.0 } else { 1.0 };
803 let sign_b = if flipped[b.0] { -1.0 } else { 1.0 };
804 let prober = Prober::new(csg);
805
806 if a.0 == b.0
809 && let Some(circle) = same_leaf_rim(leaf_a, a.1, b.1)?
810 {
811 let plane_first = matches!(
812 (a.1, b.1),
813 (
814 FaceTag::CylTop | FaceTag::CylBottom | FaceTag::ConeBottom,
815 _
816 )
817 );
818 let (plane, curved, sp, sc) = if plane_first {
819 (&surf_a, &surf_b, sign_a, sign_b)
820 } else {
821 (&surf_b, &surf_a, sign_b, sign_a)
822 };
823 return resolve_circle(&prober, circle, plane, curved, leaf_a, sp, sc, true);
824 }
825
826 match (&surf_a, &surf_b) {
827 (Surface::Plane { .. }, Surface::Plane { .. }) => {
828 resolve_line(&prober, &surf_a, &surf_b, sign_a, sign_b)
829 }
830 (Surface::Plane { .. }, _) => {
831 let circle = plane_curved_circle(&surf_a, &surf_b)?;
832 resolve_circle(
833 &prober, circle, &surf_a, &surf_b, leaf_b, sign_a, sign_b, false,
834 )
835 }
836 (_, Surface::Plane { .. }) => {
837 let circle = plane_curved_circle(&surf_b, &surf_a)?;
838 resolve_circle(
839 &prober, circle, &surf_b, &surf_a, leaf_a, sign_b, sign_a, false,
840 )
841 }
842 _ => Err(EdgeReject::UnsupportedCurve),
843 }
844}
845
846fn same_leaf_rim(
848 leaf: &GeomLeaf,
849 ta: FaceTag,
850 tb: FaceTag,
851) -> Result<Option<(DVec3, DVec3, f64)>, EdgeReject> {
852 use FaceTag as F;
853 let pair = |x: FaceTag, y: FaceTag| (ta == x && tb == y) || (ta == y && tb == x);
854 let (y_local, radius_local) = match &leaf.prim {
855 LeafPrim::Cylinder { radius, height, .. } => {
856 if pair(F::CylSide, F::CylTop) {
857 (*height, *radius)
858 } else if pair(F::CylSide, F::CylBottom) {
859 (0.0, *radius)
860 } else {
861 return if pair(F::CylTop, F::CylBottom) {
862 Err(EdgeReject::NotAnEdge)
863 } else {
864 Ok(None)
865 };
866 }
867 }
868 LeafPrim::Cone { radius, .. } if pair(F::ConeSide, F::ConeBottom) => (0.0, *radius),
869 _ => return Ok(None),
870 };
871 let (s_r, _) = axisymmetric_scale(&leaf.world).ok_or(EdgeReject::NonUniformScale)?;
872 let center = leaf.world.transform_point3(DVec3::new(0.0, y_local, 0.0));
873 let axis = (leaf.world.matrix3 * DVec3::Y).normalize();
874 Ok(Some((center, axis, radius_local * s_r)))
875}
876
877fn plane_curved_circle(
880 plane: &Surface,
881 curved: &Surface,
882) -> Result<(DVec3, DVec3, f64), EdgeReject> {
883 let Surface::Plane { point, normal, .. } = plane else {
884 return Err(EdgeReject::UnsupportedCurve);
885 };
886 match curved {
887 Surface::Cylinder {
888 base,
889 axis,
890 radius,
891 height,
892 } => {
893 if normal.dot(*axis).abs() < 1.0 - ALIGN_EPS {
894 return Err(EdgeReject::UnsupportedCurve); }
896 let y = (*point - *base).dot(*axis);
900 let tol = 1.0e-9 * height.max(1.0);
901 if !(-tol..=height + tol).contains(&y) {
902 return Err(EdgeReject::NotAnEdge);
903 }
904 Ok((*base + y.clamp(0.0, *height) * *axis, *axis, *radius))
905 }
906 Surface::Cone {
907 base,
908 axis,
909 radius,
910 height,
911 } => {
912 if normal.dot(*axis).abs() < 1.0 - ALIGN_EPS {
913 return Err(EdgeReject::UnsupportedCurve);
914 }
915 let y = (*point - *base).dot(*axis);
916 let tol = 1.0e-9 * height.max(1.0);
917 if y < -tol || y >= *height - tol {
919 return Err(EdgeReject::NotAnEdge);
920 }
921 let y = y.max(0.0);
922 Ok((*base + y * *axis, *axis, radius * (1.0 - y / height)))
923 }
924 Surface::Sphere { center, radius } => {
925 let d = (*center - *point).dot(*normal);
926 if d.abs() >= *radius {
927 return Err(EdgeReject::NotAnEdge);
928 }
929 let r = (radius * radius - d * d).sqrt();
930 if r < 1.0e-9 * radius {
931 return Err(EdgeReject::NotAnEdge); }
933 Ok((*center - d * *normal, *normal, r))
934 }
935 _ => Err(EdgeReject::UnsupportedCurve),
936 }
937}
938
939fn resolve_line(
942 prober: &Prober,
943 surf_a: &Surface,
944 surf_b: &Surface,
945 sign_a: f64,
946 sign_b: f64,
947) -> Result<(EdgeGeom, Convexity), EdgeReject> {
948 let (
949 Surface::Plane {
950 point: pa,
951 normal: na_leaf,
952 extent: ext_a,
953 },
954 Surface::Plane {
955 point: pb,
956 normal: nb_leaf,
957 extent: ext_b,
958 },
959 ) = (surf_a, surf_b)
960 else {
961 return Err(EdgeReject::UnsupportedCurve);
962 };
963 let na = sign_a * *na_leaf;
964 let nb = sign_b * *nb_leaf;
965 let u = na_leaf.cross(*nb_leaf);
966 let u_len = u.length();
967 if u_len < 1.0e-9 {
968 return Err(EdgeReject::NotAnEdge); }
970 let u_hat = u / u_len;
971 let (d1, d2) = (pa.dot(*na_leaf), pb.dot(*nb_leaf));
973 let q0 = (d1 * nb_leaf.cross(u) - d2 * na_leaf.cross(u)) / (u_len * u_len);
974 debug_assert!((q0.dot(*na_leaf) - d1).abs() < 1.0e-6);
975 debug_assert!((q0.dot(*nb_leaf) - d2).abs() < 1.0e-6);
976
977 let (a_lo, a_hi) = clip_to_extent(ext_a, q0, u_hat).ok_or(EdgeReject::NotAnEdge)?;
979 let (b_lo, b_hi) = clip_to_extent(ext_b, q0, u_hat).ok_or(EdgeReject::NotAnEdge)?;
980 let (lo, hi) = (a_lo.max(b_lo), a_hi.min(b_hi));
981 if !(hi - lo).is_finite() || hi - lo < prober.delta {
982 return Err(EdgeReject::NotAnEdge);
983 }
984
985 let bisector = na + nb;
987 if bisector.length_squared() < 1.0e-12 {
988 return Err(EdgeReject::NotAnEdge); }
990 let at = |t: f64| q0 + t * u_hat;
991 let sample_t: Vec<f64> = (0..LINE_SAMPLES)
992 .map(|i| {
993 let f = (i as f64 + 0.5) / LINE_SAMPLES as f64;
994 lo + f * (hi - lo)
995 })
996 .collect();
997 let alive: Vec<bool> = sample_t
998 .iter()
999 .map(|&t| prober.edge_exists(at(t), bisector))
1000 .collect();
1001 let (run_start, run_len) = longest_run(&alive).ok_or(EdgeReject::NotAnEdge)?;
1002
1003 let exists = |t: f64| prober.edge_exists(at(t), bisector);
1006 let t_start = if run_start == 0 {
1007 lo
1008 } else {
1009 bisect(sample_t[run_start], sample_t[run_start - 1], exists)
1010 };
1011 let run_end = run_start + run_len - 1;
1012 let t_end = if run_end + 1 == LINE_SAMPLES {
1013 hi
1014 } else {
1015 bisect(sample_t[run_end], sample_t[run_end + 1], exists)
1016 };
1017 if t_end - t_start < prober.delta {
1018 return Err(EdgeReject::NotAnEdge);
1019 }
1020
1021 let q_mid = at(0.5 * (t_start + t_end));
1023 let da = prober.orient(surf_a, sign_a, q_mid, u_hat.cross(na).normalize())?;
1024 let db = prober.orient(surf_b, sign_b, q_mid, u_hat.cross(nb).normalize())?;
1025 let convexity = classify(da.dot(nb), db.dot(na))?;
1026 if convexity == Convexity::Concave {
1027 return Err(EdgeReject::StraightConcave);
1028 }
1029
1030 let edge = StraightEdge {
1031 start: at(t_start),
1032 end: at(t_end),
1033 na,
1034 nb,
1035 da,
1036 db,
1037 extent_a: extent_along(ext_a, q_mid, da),
1038 extent_b: extent_along(ext_b, q_mid, db),
1039 };
1040 Ok((EdgeGeom::Straight(edge), convexity))
1041}
1042
1043#[allow(clippy::too_many_arguments)]
1046fn resolve_circle(
1047 prober: &Prober,
1048 (center, axis_raw, radius): (DVec3, DVec3, f64),
1049 plane: &Surface,
1050 curved: &Surface,
1051 curved_leaf: &GeomLeaf,
1052 sign_plane: f64,
1053 sign_curved: f64,
1054 same_leaf: bool,
1055) -> Result<(EdgeGeom, Convexity), EdgeReject> {
1056 let Surface::Plane {
1057 normal: np_leaf,
1058 extent: ext_p,
1059 ..
1060 } = plane
1061 else {
1062 return Err(EdgeReject::UnsupportedCurve);
1063 };
1064 let np_final = sign_plane * *np_leaf;
1066 let axis = if axis_raw.dot(np_final) >= 0.0 {
1067 axis_raw
1068 } else {
1069 -axis_raw
1070 };
1071 let ref_dir = {
1074 let mx = curved_leaf.world.matrix3 * DVec3::X;
1075 let perp = mx - mx.dot(axis) * axis;
1076 let len = perp.length();
1077 if len > 1.0e-12 {
1078 perp / len
1079 } else {
1080 plane_basis(axis).0
1081 }
1082 };
1083 let leaf_segments = match curved_leaf.prim {
1084 LeafPrim::Cylinder { segments, .. } | LeafPrim::Cone { segments, .. } => Some(segments),
1085 _ => None,
1086 };
1087 let (bu, bv) = plane_basis(axis);
1088
1089 let mut nb2_acc = DVec2::ZERO;
1092 for i in 0..CIRCLE_SAMPLES {
1093 let theta = core::f64::consts::TAU * i as f64 / CIRCLE_SAMPLES as f64;
1094 let r_hat = theta.cos() * bu + theta.sin() * bv;
1095 let q = center + radius * r_hat;
1096 if !same_leaf {
1097 let inside = match ext_p {
1098 PlaneExtent::Quad { .. } => {
1099 clip_to_extent(ext_p, q, r_hat).is_some_and(|(lo, hi)| lo <= 0.0 && hi >= 0.0)
1100 }
1101 PlaneExtent::Disc {
1102 center: c,
1103 radius: r,
1104 } => (q - *c).length() <= *r + 1.0e-9,
1105 };
1106 if !inside {
1107 return Err(EdgeReject::PartialCircle);
1108 }
1109 }
1110 let nb3 = curved.normal_at(q) * sign_curved;
1111 if !prober.edge_exists(q, np_final + nb3) {
1112 return Err(EdgeReject::PartialCircle);
1113 }
1114 nb2_acc += DVec2::new(nb3.dot(r_hat), nb3.dot(axis));
1115 }
1116 let nb2 = (nb2_acc / CIRCLE_SAMPLES as f64).normalize_or_zero();
1117 if nb2 == DVec2::ZERO {
1118 return Err(EdgeReject::NotAnEdge);
1119 }
1120
1121 let r_hat = bu;
1123 let q = center + radius * r_hat;
1124 let da3 = prober.orient(plane, sign_plane, q, r_hat)?;
1125 let da2 = DVec2::new(da3.dot(r_hat), 0.0);
1126 let tangent = match curved {
1129 Surface::Cylinder { axis: ca, .. } => *ca,
1130 Surface::Cone {
1131 axis: ca,
1132 radius,
1133 height,
1134 ..
1135 } => {
1136 let k = radius / height;
1137 (*ca - k * r_hat).normalize()
1138 }
1139 Surface::Sphere { center: c, .. } => {
1140 let n = (q - *c).normalize_or_zero();
1141 (axis - axis.dot(n) * n).normalize_or_zero()
1143 }
1144 Surface::Plane { .. } => return Err(EdgeReject::UnsupportedCurve),
1145 };
1146 if tangent == DVec3::ZERO {
1147 return Err(EdgeReject::NotAnEdge);
1148 }
1149 let db3 = prober.orient(curved, sign_curved, q, tangent)?;
1150 let db2 = DVec2::new(db3.dot(r_hat), db3.dot(axis)).normalize_or_zero();
1151
1152 let na2 = DVec2::Y;
1154 let convexity = classify(da2.dot(nb2), db2.dot(na2))?;
1155
1156 let extent_a = extent_along(ext_p, q, da3);
1158 let extent_b = match curved {
1159 Surface::Cylinder {
1160 base,
1161 axis: ca,
1162 height,
1163 ..
1164 } => {
1165 let y = (q - *base).dot(*ca);
1166 if db3.dot(*ca) >= 0.0 { height - y } else { y }
1167 }
1168 Surface::Cone { radius, height, .. } => (radius * radius + height * height).sqrt(),
1169 Surface::Sphere { radius, .. } => *radius,
1170 Surface::Plane { .. } => 0.0,
1171 };
1172
1173 let edge = CircularEdge {
1174 frame: DAffine3::IDENTITY,
1175 center,
1176 axis,
1177 radius,
1178 ref_dir,
1179 leaf_segments,
1180 nb2,
1181 da2,
1182 db2,
1183 extent_a,
1184 extent_b: extent_b.max(0.0),
1185 };
1186 Ok((EdgeGeom::Circular(edge), convexity))
1187}
1188
1189fn longest_run(flags: &[bool]) -> Option<(usize, usize)> {
1193 let mut best: Option<(usize, usize)> = None;
1194 let mut cur: Option<(usize, usize)> = None;
1195 for (i, &f) in flags.iter().enumerate() {
1196 cur = match (cur, f) {
1197 (None, true) => Some((i, 1)),
1198 (Some((s, n)), true) => Some((s, n + 1)),
1199 (_, false) => None,
1200 };
1201 if let Some((_, n)) = cur
1202 && best.is_none_or(|(_, bn)| n > bn)
1203 {
1204 best = cur;
1205 }
1206 }
1207 best
1208}
1209
1210fn bisect(mut t_ok: f64, mut t_bad: f64, exists: impl Fn(f64) -> bool) -> f64 {
1212 for _ in 0..TRIM_ITERS {
1213 let mid = 0.5 * (t_ok + t_bad);
1214 if exists(mid) {
1215 t_ok = mid;
1216 } else {
1217 t_bad = mid;
1218 }
1219 }
1220 t_ok
1221}
1222
1223pub(crate) fn plane_basis(n: DVec3) -> (DVec3, DVec3) {
1225 let helper = if n.y.abs() < 0.9 { DVec3::Y } else { DVec3::X };
1226 let u = n.cross(helper).normalize();
1227 (u, n.cross(u))
1228}
1229
1230#[cfg(test)]
1231mod tests {
1232 use super::*;
1233
1234 fn cube(size: [f64; 3]) -> DynamicObject {
1235 DynamicObject::Cube { size }
1236 }
1237
1238 fn cylinder(radius: f64, height: f64) -> DynamicObject {
1239 DynamicObject::Cylinder {
1240 radius,
1241 height,
1242 segments: 32,
1243 }
1244 }
1245
1246 fn translate(offset: [f64; 3], child: DynamicObject) -> DynamicObject {
1247 DynamicObject::Translate {
1248 offset,
1249 child: Box::new(child),
1250 }
1251 }
1252
1253 fn leaves(obj: &DynamicObject) -> CsgLeaves {
1254 CsgLeaves::from_dynamic(obj, DAffine3::IDENTITY, None)
1255 }
1256
1257 fn plate_with_hole() -> DynamicObject {
1259 DynamicObject::Difference {
1260 children: vec![
1261 cube([4.0, 1.0, 4.0]),
1262 translate([2.0, -0.5, 2.0], cylinder(0.5, 2.0)),
1263 ],
1264 }
1265 }
1266
1267 fn boss_on_plate() -> DynamicObject {
1269 DynamicObject::Union {
1270 children: vec![
1271 cube([4.0, 1.0, 4.0]),
1272 translate([2.0, 1.0, 2.0], cylinder(0.5, 1.0)),
1273 ],
1274 }
1275 }
1276
1277 #[test]
1278 fn cube_adjacent_faces_resolve_convex_lines() {
1279 let csg = leaves(&cube([1.0, 2.0, 3.0]));
1280 let adjacent = [
1281 (FaceTag::CubePosX, FaceTag::CubePosY),
1282 (FaceTag::CubePosX, FaceTag::CubeNegZ),
1283 (FaceTag::CubeNegY, FaceTag::CubeNegZ),
1284 ];
1285 for (fa, fb) in adjacent {
1286 let (geom, convexity) =
1287 resolve_edge(&csg, (0, fa), (0, fb)).expect("adjacent faces share an edge");
1288 assert_eq!(convexity, Convexity::Convex, "{fa:?}/{fb:?}");
1289 let EdgeGeom::Straight(e) = geom else {
1290 panic!("cube edge must be straight");
1291 };
1292 let expect_len = match (fa, fb) {
1294 (FaceTag::CubePosX, FaceTag::CubePosY) => 3.0,
1295 (FaceTag::CubePosX, FaceTag::CubeNegZ) => 2.0,
1296 _ => 1.0,
1297 };
1298 assert!(((e.end - e.start).length() - expect_len).abs() < 1e-9);
1299 }
1300 assert_eq!(
1302 resolve_edge(&csg, (0, FaceTag::CubePosX), (0, FaceTag::CubeNegX)).unwrap_err(),
1303 EdgeReject::NotAnEdge
1304 );
1305 }
1306
1307 #[test]
1308 fn cube_edge_directions_point_into_faces() {
1309 let csg = leaves(&cube([1.0, 1.0, 1.0]));
1310 let (geom, _) = resolve_edge(&csg, (0, FaceTag::CubePosX), (0, FaceTag::CubePosY)).unwrap();
1311 let EdgeGeom::Straight(e) = geom else {
1312 panic!("straight");
1313 };
1314 assert!((e.na - DVec3::X).length() < 1e-9);
1315 assert!((e.nb - DVec3::Y).length() < 1e-9);
1316 assert!(e.da.dot(DVec3::Y) < -0.99);
1318 assert!(e.db.dot(DVec3::X) < -0.99);
1319 assert!((e.extent_a - 1.0).abs() < 1e-9);
1320 assert!((e.extent_b - 1.0).abs() < 1e-9);
1321 }
1322
1323 #[test]
1324 fn same_leaf_cylinder_rims() {
1325 let csg = leaves(&cylinder(1.0, 2.0));
1326 let (geom, convexity) =
1327 resolve_edge(&csg, (0, FaceTag::CylSide), (0, FaceTag::CylTop)).unwrap();
1328 assert_eq!(convexity, Convexity::Convex);
1329 let EdgeGeom::Circular(c) = geom else {
1330 panic!("rim must be circular");
1331 };
1332 assert!((c.center - DVec3::new(0.0, 2.0, 0.0)).length() < 1e-9);
1333 assert!((c.axis - DVec3::Y).length() < 1e-9, "axis = cap outward");
1334 assert!((c.radius - 1.0).abs() < 1e-9);
1335 assert!(c.da2.x < -0.99);
1337 assert!(c.db2.y < -0.99);
1338
1339 let (_, convexity) =
1340 resolve_edge(&csg, (0, FaceTag::CylSide), (0, FaceTag::CylBottom)).unwrap();
1341 assert_eq!(convexity, Convexity::Convex);
1342
1343 assert_eq!(
1344 resolve_edge(&csg, (0, FaceTag::CylTop), (0, FaceTag::CylBottom)).unwrap_err(),
1345 EdgeReject::NotAnEdge
1346 );
1347 }
1348
1349 #[test]
1350 fn cone_base_rim_resolves() {
1351 let csg = leaves(&DynamicObject::Cone {
1352 radius: 1.0,
1353 height: 2.0,
1354 segments: 32,
1355 });
1356 let (geom, convexity) =
1357 resolve_edge(&csg, (0, FaceTag::ConeSide), (0, FaceTag::ConeBottom)).unwrap();
1358 assert_eq!(convexity, Convexity::Convex);
1359 let EdgeGeom::Circular(c) = geom else {
1360 panic!("circular");
1361 };
1362 assert!((c.radius - 1.0).abs() < 1e-9);
1363 assert!(
1364 (c.axis + DVec3::Y).length() < 1e-9,
1365 "axis = base outward (-Y)"
1366 );
1367 assert!(c.db2.x < 0.0 && c.db2.y < 0.0);
1370 }
1371
1372 #[test]
1373 fn hole_rim_is_convex_circle_with_outward_plane_dir() {
1374 let obj = plate_with_hole();
1375 let csg = leaves(&obj);
1376 let (geom, convexity) =
1377 resolve_edge(&csg, (0, FaceTag::CubePosY), (1, FaceTag::CylSide)).unwrap();
1378 assert_eq!(convexity, Convexity::Convex, "hole rims are chamferable");
1379 let EdgeGeom::Circular(c) = geom else {
1380 panic!("circular");
1381 };
1382 assert!((c.center - DVec3::new(2.0, 1.0, 2.0)).length() < 1e-9);
1383 assert!((c.radius - 0.5).abs() < 1e-9);
1384 assert!((c.axis - DVec3::Y).length() < 1e-9);
1385 assert!(c.da2.x > 0.99);
1387 assert!(c.db2.y < -0.99);
1388 }
1389
1390 #[test]
1391 fn boss_base_ring_is_concave() {
1392 let csg = leaves(&boss_on_plate());
1393 let (geom, convexity) =
1394 resolve_edge(&csg, (0, FaceTag::CubePosY), (1, FaceTag::CylSide)).unwrap();
1395 assert_eq!(
1396 convexity,
1397 Convexity::Concave,
1398 "boss-base ring is a fillet case"
1399 );
1400 let EdgeGeom::Circular(c) = geom else {
1401 panic!("circular");
1402 };
1403 assert!(c.da2.x > 0.99, "plate survives outside the boss");
1404 assert!(c.db2.y > 0.99, "boss wall survives upward");
1405 }
1406
1407 #[test]
1408 fn straight_concave_rejected() {
1409 let obj = DynamicObject::Union {
1411 children: vec![
1412 cube([2.0, 1.0, 2.0]),
1413 translate([0.0, 1.0, 0.0], cube([1.0, 1.0, 2.0])),
1414 ],
1415 };
1416 let csg = leaves(&obj);
1417 assert_eq!(
1418 resolve_edge(&csg, (0, FaceTag::CubePosY), (1, FaceTag::CubePosX)).unwrap_err(),
1419 EdgeReject::StraightConcave
1420 );
1421 }
1422
1423 #[test]
1424 fn oblique_and_curved_pairs_rejected() {
1425 let obj = DynamicObject::Union {
1427 children: vec![
1428 cube([4.0, 1.0, 4.0]),
1429 translate(
1430 [2.0, 0.0, 2.0],
1431 DynamicObject::Rotate {
1432 angles: [30.0, 0.0, 0.0],
1433 child: Box::new(cylinder(0.5, 3.0)),
1434 },
1435 ),
1436 ],
1437 };
1438 let csg = leaves(&obj);
1439 assert_eq!(
1440 resolve_edge(&csg, (0, FaceTag::CubePosY), (1, FaceTag::CylSide)).unwrap_err(),
1441 EdgeReject::UnsupportedCurve
1442 );
1443 let obj = DynamicObject::Union {
1445 children: vec![
1446 cylinder(1.0, 2.0),
1447 translate([1.0, 0.0, 0.0], cylinder(1.0, 2.0)),
1448 ],
1449 };
1450 let csg = leaves(&obj);
1451 assert_eq!(
1452 resolve_edge(&csg, (0, FaceTag::CylSide), (1, FaceTag::CylSide)).unwrap_err(),
1453 EdgeReject::UnsupportedCurve
1454 );
1455 }
1456
1457 #[test]
1458 fn mismatched_anisotropic_frames_reject() {
1459 let obj = DynamicObject::Union {
1463 children: vec![
1464 DynamicObject::Scale {
1465 factors: [2.0, 1.0, 1.0],
1466 child: Box::new(cylinder(1.0, 2.0)),
1467 },
1468 translate([0.0, 2.0, 0.0], cylinder(0.25, 1.0)),
1469 ],
1470 };
1471 let csg = leaves(&obj);
1472 assert_eq!(
1473 resolve_edge(&csg, (0, FaceTag::CylTop), (1, FaceTag::CylSide)).unwrap_err(),
1474 EdgeReject::NonUniformScale
1475 );
1476 let obj = DynamicObject::Scale {
1478 factors: [2.0, 1.0, 1.0],
1479 child: Box::new(cube([1.0, 1.0, 1.0])),
1480 };
1481 let csg = leaves(&obj);
1482 assert!(resolve_edge(&csg, (0, FaceTag::CubePosX), (0, FaceTag::CubePosY)).is_ok());
1483 }
1484
1485 #[test]
1486 fn elliptical_rim_resolves_in_leaf_frame() {
1487 let obj = DynamicObject::Scale {
1490 factors: [2.0, 1.0, 1.0],
1491 child: Box::new(cylinder(1.0, 2.0)),
1492 };
1493 let csg = leaves(&obj);
1494 let (geom, convexity) =
1495 resolve_edge(&csg, (0, FaceTag::CylSide), (0, FaceTag::CylTop)).unwrap();
1496 assert_eq!(convexity, Convexity::Convex);
1497 let EdgeGeom::Circular(c) = geom else {
1498 panic!("circular");
1499 };
1500 assert_ne!(c.frame, DAffine3::IDENTITY);
1501 assert!((c.radius - 1.0).abs() < 1e-9, "frame-local unit radius");
1502 for p in geom.polyline(24) {
1504 assert!((p.y - 2.0).abs() < 1e-9);
1505 assert!((p.x * p.x / 4.0 + p.z * p.z - 1.0).abs() < 1e-9);
1506 }
1507 let d = geom.distance_to(DVec3::new(2.1, 2.0, 0.0));
1509 assert!((d - 0.1).abs() < 0.01, "distance to ellipse vertex: {d}");
1510 }
1511
1512 #[test]
1513 fn spheroid_dome_rim_resolves() {
1514 let obj = DynamicObject::Union {
1517 children: vec![
1518 cube([4.0, 1.0, 4.0]),
1519 translate(
1520 [2.0, 0.5, 2.0],
1521 DynamicObject::Scale {
1522 factors: [1.0, 2.0, 1.0],
1523 child: Box::new(DynamicObject::Sphere {
1524 radius: 1.0,
1525 segments: 32,
1526 stacks: 16,
1527 }),
1528 },
1529 ),
1530 ],
1531 };
1532 let csg = leaves(&obj);
1533 let (geom, convexity) =
1534 resolve_edge(&csg, (0, FaceTag::CubePosY), (1, FaceTag::Sphere)).unwrap();
1535 assert_eq!(convexity, Convexity::Concave, "dome meets plate concavely");
1536 let EdgeGeom::Circular(c) = geom else {
1537 panic!("circular");
1538 };
1539 let expect = (1.0f64 - 0.0625).sqrt();
1542 assert!((c.radius - expect).abs() < 1e-9);
1543 for p in geom.polyline(16) {
1544 assert!((p.y - 1.0).abs() < 1e-9);
1545 let rho = DVec2::new(p.x - 2.0, p.z - 2.0).length();
1546 assert!((rho - expect).abs() < 1e-9);
1547 }
1548 }
1549
1550 #[test]
1551 fn cap_chord_straight_edge_maps_from_frame() {
1552 let obj = DynamicObject::Difference {
1556 children: vec![
1557 DynamicObject::Scale {
1558 factors: [2.0, 1.0, 1.0],
1559 child: Box::new(cylinder(1.0, 2.0)),
1560 },
1561 translate([0.0, -1.0, -3.0], cube([3.0, 4.0, 6.0])),
1562 ],
1563 };
1564 let csg = leaves(&obj);
1565 let (geom, convexity) =
1566 resolve_edge(&csg, (0, FaceTag::CylTop), (1, FaceTag::CubeNegX)).unwrap();
1567 assert_eq!(convexity, Convexity::Convex);
1568 let EdgeGeom::Straight(e) = geom else {
1569 panic!("straight chord");
1570 };
1571 assert!(e.start.x.abs() < 1e-6 && e.end.x.abs() < 1e-6);
1573 assert!((e.start.y - 2.0).abs() < 1e-6 && (e.end.y - 2.0).abs() < 1e-6);
1574 assert!(((e.end - e.start).length() - 2.0).abs() < 1e-3);
1575 assert!((e.na - DVec3::Y).length() < 1e-6, "cap normal up");
1576 assert!((e.nb - DVec3::X).length() < 1e-6, "cut wall normal +x");
1577 assert!(e.da.dot(DVec3::X) < -0.99);
1581 assert!(e.db.dot(DVec3::Y) < -0.99);
1582 assert!(e.extent_a > 0.5 && e.extent_b > 0.5);
1583 }
1584
1585 #[test]
1586 fn axisymmetric_scale_keeps_rims_circular() {
1587 let obj = DynamicObject::Scale {
1590 factors: [1.0, 3.0, 1.0],
1591 child: Box::new(cylinder(1.0, 2.0)),
1592 };
1593 let csg = leaves(&obj);
1594 let (geom, convexity) =
1595 resolve_edge(&csg, (0, FaceTag::CylSide), (0, FaceTag::CylTop)).unwrap();
1596 assert_eq!(convexity, Convexity::Convex);
1597 let EdgeGeom::Circular(c) = geom else {
1598 panic!("circular");
1599 };
1600 assert!((c.center - DVec3::new(0.0, 6.0, 0.0)).length() < 1e-9);
1601 assert!((c.radius - 1.0).abs() < 1e-9);
1602
1603 let obj = DynamicObject::Scale {
1605 factors: [2.0, 1.0, 2.0],
1606 child: Box::new(cylinder(1.0, 2.0)),
1607 };
1608 let csg = leaves(&obj);
1609 let (geom, _) = resolve_edge(&csg, (0, FaceTag::CylSide), (0, FaceTag::CylBottom)).unwrap();
1610 let EdgeGeom::Circular(c) = geom else {
1611 panic!("circular");
1612 };
1613 assert!(c.center.length() < 1e-9);
1614 assert!((c.radius - 2.0).abs() < 1e-9);
1615
1616 let obj = DynamicObject::Scale {
1619 factors: [2.0, 1.0, 2.0],
1620 child: Box::new(DynamicObject::Cone {
1621 radius: 1.0,
1622 height: 2.0,
1623 segments: 32,
1624 }),
1625 };
1626 let csg = leaves(&obj);
1627 let (geom, convexity) =
1628 resolve_edge(&csg, (0, FaceTag::ConeSide), (0, FaceTag::ConeBottom)).unwrap();
1629 assert_eq!(convexity, Convexity::Convex);
1630 let EdgeGeom::Circular(c) = geom else {
1631 panic!("circular");
1632 };
1633 assert!((c.radius - 2.0).abs() < 1e-9);
1634 }
1635
1636 #[test]
1637 fn axisymmetric_scale_composes_with_rotation() {
1638 let obj = DynamicObject::Scale {
1643 factors: [2.0, 2.0, 1.0],
1644 child: Box::new(DynamicObject::Rotate {
1645 angles: [90.0, 0.0, 0.0],
1646 child: Box::new(cylinder(1.0, 2.0)),
1647 }),
1648 };
1649 let csg = leaves(&obj);
1650 let (geom, convexity) =
1651 resolve_edge(&csg, (0, FaceTag::CylSide), (0, FaceTag::CylTop)).unwrap();
1652 assert_eq!(convexity, Convexity::Convex);
1653 let EdgeGeom::Circular(c) = geom else {
1654 panic!("circular");
1655 };
1656 assert!((c.radius - 2.0).abs() < 1e-9);
1657 assert!(c.axis.cross(DVec3::Z).length() < 1e-9);
1658 assert!((c.center - DVec3::new(0.0, 0.0, 2.0)).length() < 1e-9);
1659
1660 let obj = DynamicObject::Scale {
1664 factors: [1.0, 2.0, 1.0],
1665 child: Box::new(DynamicObject::Rotate {
1666 angles: [90.0, 0.0, 0.0],
1667 child: Box::new(cylinder(1.0, 2.0)),
1668 }),
1669 };
1670 let csg = leaves(&obj);
1671 let (geom, _) = resolve_edge(&csg, (0, FaceTag::CylSide), (0, FaceTag::CylTop)).unwrap();
1672 let EdgeGeom::Circular(c) = geom else {
1673 panic!("circular");
1674 };
1675 assert_ne!(c.frame, DAffine3::IDENTITY);
1676 for p in geom.polyline(16) {
1677 assert!((p.z - 2.0).abs() < 1e-9);
1678 assert!((p.x * p.x + p.y * p.y / 4.0 - 1.0).abs() < 1e-9);
1679 }
1680 }
1681
1682 #[test]
1683 fn scaled_hole_rim_resolves() {
1684 let obj = DynamicObject::Difference {
1687 children: vec![
1688 cube([4.0, 1.0, 4.0]),
1689 translate(
1690 [2.0, -0.5, 2.0],
1691 DynamicObject::Scale {
1692 factors: [1.0, 2.0, 1.0],
1693 child: Box::new(cylinder(0.5, 1.0)),
1694 },
1695 ),
1696 ],
1697 };
1698 let csg = leaves(&obj);
1699 let (geom, convexity) =
1700 resolve_edge(&csg, (0, FaceTag::CubePosY), (1, FaceTag::CylSide)).unwrap();
1701 assert_eq!(convexity, Convexity::Convex);
1702 let EdgeGeom::Circular(c) = geom else {
1703 panic!("circular");
1704 };
1705 assert!((c.center - DVec3::new(2.0, 1.0, 2.0)).length() < 1e-9);
1706 assert!((c.radius - 0.5).abs() < 1e-9);
1707 }
1708
1709 #[test]
1710 fn partial_circle_rejected_when_hole_leaves_plate() {
1711 let obj = DynamicObject::Difference {
1713 children: vec![
1714 cube([4.0, 1.0, 4.0]),
1715 translate([4.0, -0.5, 2.0], cylinder(0.5, 2.0)),
1716 ],
1717 };
1718 let csg = leaves(&obj);
1719 assert_eq!(
1720 resolve_edge(&csg, (0, FaceTag::CubePosY), (1, FaceTag::CylSide)).unwrap_err(),
1721 EdgeReject::PartialCircle
1722 );
1723 }
1724
1725 #[test]
1726 fn consumed_edge_rejected_and_partial_edge_trimmed() {
1727 let consumed = DynamicObject::Difference {
1729 children: vec![
1730 cube([2.0, 2.0, 2.0]),
1731 translate([-1.0, 1.5, -1.0], cube([4.0, 2.0, 4.0])),
1732 ],
1733 };
1734 let csg = leaves(&consumed);
1735 assert!(
1736 resolve_edge(&csg, (0, FaceTag::CubePosY), (0, FaceTag::CubePosX)).is_err(),
1737 "an edge shaved off entirely must not resolve"
1738 );
1739
1740 let notched = DynamicObject::Difference {
1742 children: vec![
1743 cube([2.0, 2.0, 2.0]),
1744 translate([1.0, 1.0, 1.0], cube([2.0, 2.0, 2.0])),
1745 ],
1746 };
1747 let csg = leaves(¬ched);
1748 let (geom, convexity) =
1749 resolve_edge(&csg, (0, FaceTag::CubePosX), (0, FaceTag::CubePosY)).unwrap();
1750 assert_eq!(convexity, Convexity::Convex);
1751 let EdgeGeom::Straight(e) = geom else {
1752 panic!("straight");
1753 };
1754 let len = (e.end - e.start).length();
1756 assert!(
1757 (len - 1.0).abs() < 0.05,
1758 "surviving run ≈ half the edge, got {len}"
1759 );
1760 let z_max = e.start.z.max(e.end.z);
1761 assert!(z_max < 1.05, "run must stay on the surviving side");
1762 }
1763
1764 #[test]
1765 fn sphere_plane_circle_resolves() {
1766 let obj = DynamicObject::Union {
1768 children: vec![
1769 cube([4.0, 1.0, 4.0]),
1770 translate(
1771 [2.0, 0.5, 2.0],
1772 DynamicObject::Sphere {
1773 radius: 1.0,
1774 segments: 32,
1775 stacks: 16,
1776 },
1777 ),
1778 ],
1779 };
1780 let csg = leaves(&obj);
1781 let (geom, convexity) =
1782 resolve_edge(&csg, (0, FaceTag::CubePosY), (1, FaceTag::Sphere)).unwrap();
1783 assert_eq!(convexity, Convexity::Concave, "dome meets plate concavely");
1784 let EdgeGeom::Circular(c) = geom else {
1785 panic!("circular");
1786 };
1787 assert!((c.radius - (1.0f64 - 0.25).sqrt()).abs() < 1e-9);
1789 assert!((c.center - DVec3::new(2.0, 1.0, 2.0)).length() < 1e-9);
1790 }
1791
1792 #[test]
1793 fn leaf_flipped_tracks_difference_nesting() {
1794 let obj = DynamicObject::Difference {
1796 children: vec![
1797 cube([4.0, 4.0, 4.0]),
1798 DynamicObject::Difference {
1799 children: vec![
1800 translate([1.0, 1.0, 1.0], cube([2.0, 2.0, 2.0])),
1801 translate([1.5, 1.5, 1.5], cube([1.0, 1.0, 1.0])),
1802 ],
1803 },
1804 ],
1805 };
1806 let csg = leaves(&obj);
1807 assert_eq!(leaf_flipped(&csg), vec![false, true, false]);
1808 }
1809
1810 #[test]
1811 fn distance_and_polyline_helpers() {
1812 let csg = leaves(&cylinder(1.0, 2.0));
1813 let (geom, _) = resolve_edge(&csg, (0, FaceTag::CylSide), (0, FaceTag::CylTop)).unwrap();
1814 assert!((geom.distance_to(DVec3::new(1.1, 2.0, 0.0)) - 0.1).abs() < 1e-9);
1816 let poly = geom.polyline(16);
1817 assert_eq!(poly.len(), 16);
1818 for p in poly {
1819 assert!((p.y - 2.0).abs() < 1e-9);
1820 assert!((DVec3::new(p.x, 0.0, p.z).length() - 1.0).abs() < 1e-9);
1821 }
1822 }
1823}