1use std::collections::BTreeMap;
74
75use crate::feature::edge::{Convexity, EdgeGeom, resolve_edge};
76use crate::feature::geom::CsgLeaves;
77use crate::feature::{EdgeFeatureData, EdgeFeatureKind, LeafPath};
78use crate::prelude_::*;
79
80const EPS_FRAC: f64 = 1.0e-3;
82const MAX_EXTENT_FRAC: f64 = 0.45;
84const MASK_SEGMENTS: usize = 64;
87
88#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
90pub struct LowerReport {
91 pub applied: usize,
92 pub skipped: usize,
93}
94
95#[derive(Clone, Debug, Default)]
98pub struct LoweredMasks {
99 pub sub: Vec<DynamicObject>,
100 pub add: Vec<DynamicObject>,
101}
102
103enum Mask {
105 Sub(DynamicObject),
106 Add(DynamicObject),
107}
108
109pub fn lower(data: &EdgeFeatureData, child: &DynamicObject) -> (DynamicObject, LowerReport) {
112 let (masks, report) = lower_masks(data, child);
113 let mut out = child.clone();
114 if !masks.sub.is_empty() {
115 out = DynamicObject::Difference {
116 children: vec![
117 out,
118 DynamicObject::Union {
119 children: masks.sub,
120 },
121 ],
122 };
123 }
124 if !masks.add.is_empty() {
125 let children = core::iter::once(out).chain(masks.add).collect();
126 out = DynamicObject::Union { children };
127 }
128 (out, report)
129}
130
131pub fn lower_masks(data: &EdgeFeatureData, child: &DynamicObject) -> (LoweredMasks, LowerReport) {
141 let csg = CsgLeaves::from_dynamic_unfeatured(child, DAffine3::IDENTITY, None);
142 let index: BTreeMap<&LeafPath, usize> =
143 csg.paths.iter().enumerate().map(|(i, p)| (p, i)).collect();
144 let mut masks = LoweredMasks::default();
145 let mut report = LowerReport::default();
146 for sel in &data.edges {
147 let resolved = index
148 .get(&sel.a.path)
149 .zip(index.get(&sel.b.path))
150 .ok_or(crate::feature::edge::EdgeReject::Unsupported)
151 .and_then(|(&la, &lb)| resolve_edge(&csg, (la, sel.a.face), (lb, sel.b.face)));
152 let mask = match resolved {
153 Ok((geom, convexity)) => edge_mask(data, &geom, convexity),
154 Err(_) => None,
155 };
156 match mask {
157 Some(Mask::Sub(m)) => {
158 masks.sub.push(m);
159 report.applied += 1;
160 }
161 Some(Mask::Add(m)) => {
162 masks.add.push(m);
163 report.applied += 1;
164 }
165 None => report.skipped += 1,
166 }
167 }
168 (masks, report)
169}
170
171fn edge_mask(data: &EdgeFeatureData, geom: &EdgeGeom, convexity: Convexity) -> Option<Mask> {
175 match (data.kind, convexity, geom) {
176 (EdgeFeatureKind::Chamfer, Convexity::Convex, EdgeGeom::Straight(e)) => {
177 let s = clamp_size(data.size, e.extent_a, e.extent_b)?;
178 Some(Mask::Sub(straight_chamfer_mask(e, s)))
179 }
180 (EdgeFeatureKind::Chamfer, Convexity::Convex, EdgeGeom::Circular(c)) => {
181 let s = clamp_size(data.size, c.extent_a, c.extent_b)?;
182 circular_chamfer_mask(c, s).map(Mask::Sub)
183 }
184 (EdgeFeatureKind::Fillet, Convexity::Convex, EdgeGeom::Straight(e)) => {
185 straight_fillet_mask(e, data.size, data.segments).map(Mask::Sub)
186 }
187 (EdgeFeatureKind::Fillet, convexity, EdgeGeom::Circular(c)) => {
188 circular_fillet_mask(c, data.size, data.segments, convexity).map(match convexity {
189 Convexity::Convex => Mask::Sub,
190 Convexity::Concave => Mask::Add,
191 })
192 }
193 _ => None,
194 }
195}
196
197fn clamp_size(size: f64, extent_a: f64, extent_b: f64) -> Option<f64> {
200 let cap = MAX_EXTENT_FRAC * extent_a.min(extent_b);
201 let s = size.min(cap);
202 (s > 1.0e-9).then_some(s)
203}
204
205fn straight_chamfer_mask(e: &crate::feature::edge::StraightEdge, s: f64) -> DynamicObject {
209 let eps = EPS_FRAC * s;
210 let u_hat = e.dir();
211 let len = (e.end - e.start).length();
212 let pa = e.start + s * e.da;
213 let pb = e.start + s * e.db;
214
215 let mut y_hat = (pb - pa).normalize();
218 let mut z_hat = u_hat.cross(y_hat);
219 if z_hat.dot(e.na + e.nb) < 0.0 {
220 y_hat = -y_hat;
221 z_hat = -z_hat;
222 }
223 let (from, width) = if y_hat.dot(pb - pa) >= 0.0 {
224 (pa, (pb - pa).length())
225 } else {
226 (pb, (pb - pa).length())
227 };
228 let depth = (e.start - pa).dot(z_hat);
229
230 let corner = from - eps * u_hat - eps * y_hat;
231 oriented_cube(
232 corner,
233 DMat3::from_cols(u_hat, y_hat, z_hat),
234 DVec3::new(len + 2.0 * eps, width + 2.0 * eps, depth + eps),
235 )
236}
237
238fn oriented_cube(corner: DVec3, r: DMat3, size: DVec3) -> DynamicObject {
241 DynamicObject::Translate {
242 offset: corner.to_array(),
243 child: Box::new(DynamicObject::Rotate {
244 angles: euler_zyx_degrees(r),
245 child: Box::new(DynamicObject::Cube {
246 size: size.to_array(),
247 }),
248 }),
249 }
250}
251
252fn euler_zyx_degrees(r: DMat3) -> [f64; 3] {
255 let (z, y, x) = DQuat::from_mat3(&r).to_euler(EulerRot::ZYX);
256 [x.to_degrees(), y.to_degrees(), z.to_degrees()]
257}
258
259fn circular_chamfer_mask(c: &crate::feature::edge::CircularEdge, s: f64) -> Option<DynamicObject> {
268 let eps = EPS_FRAC * s;
269 let pa = DVec2::new(c.radius, 0.0) + s * c.da2;
271 let pb = DVec2::new(c.radius, 0.0) + s * c.db2;
272 if pa.x <= eps || pb.x <= eps {
273 return None; }
275
276 let removed_inside = c.da2.x > 0.0;
279 let (y_lo, y_hi) = (pa.y.min(pb.y), pa.y.max(pb.y));
280 let slab_r = pa.x.max(pb.x).max(c.radius) + eps;
281 let slab = axial_cylinder(c, slab_r, y_lo, y_hi + eps);
284 let cut = cut_cone(c, pa, pb, y_lo, y_hi + eps)?;
285
286 let children = vec![slab, cut];
287 let mask = if removed_inside {
288 DynamicObject::Intersection { children }
289 } else {
290 DynamicObject::Difference { children }
291 };
292 Some(wrap_frame(&c.frame, mask))
293}
294
295fn rim_segments(c: &crate::feature::edge::CircularEdge) -> usize {
299 c.leaf_segments.unwrap_or(MASK_SEGMENTS).max(3)
300}
301
302fn place_on_rim(
306 c: &crate::feature::edge::CircularEdge,
307 y_offset: f64,
308 up_sign: f64,
309 obj: DynamicObject,
310) -> DynamicObject {
311 let y = up_sign * c.axis;
312 let x = c.ref_dir;
313 let r = DMat3::from_cols(x, y, x.cross(y));
314 DynamicObject::Translate {
315 offset: (c.center + y_offset * c.axis).to_array(),
316 child: Box::new(DynamicObject::Rotate {
317 angles: euler_zyx_degrees(r),
318 child: Box::new(obj),
319 }),
320 }
321}
322
323fn cut_cone(
327 c: &crate::feature::edge::CircularEdge,
328 pa: DVec2,
329 pb: DVec2,
330 y_lo: f64,
331 y_hi: f64,
332) -> Option<DynamicObject> {
333 let d = pb - pa;
334 if d.x.abs() < 1.0e-12 {
335 let pad = y_hi - y_lo;
337 return Some(axial_cylinder(c, pa.x, y_lo - pad, y_hi + pad));
338 }
339 if d.y.abs() < 1.0e-12 {
340 return None; }
342 let slope = d.y / d.x; let y_apex = pa.y - pa.x * slope;
344 let sigma = if y_apex >= y_hi {
348 1.0 } else if y_apex <= y_lo {
350 -1.0 } else {
352 return None; };
354 let apex_t = sigma * y_apex;
356 let far_t = sigma * if sigma > 0.0 { y_lo } else { y_hi };
357 let base_t = far_t - (y_hi - y_lo).max(1.0e-9);
358 let height = apex_t - base_t;
359 if height <= 0.0 {
360 return None;
361 }
362 let radius = pa.x * height / (apex_t - sigma * pa.y);
364 if !radius.is_finite() || radius <= 0.0 {
365 return None;
366 }
367 Some(place_on_rim(
369 c,
370 sigma * base_t,
371 sigma,
372 DynamicObject::Cone {
373 radius,
374 height,
375 segments: rim_segments(c),
376 },
377 ))
378}
379
380fn axial_cylinder(
382 c: &crate::feature::edge::CircularEdge,
383 radius: f64,
384 y0: f64,
385 y1: f64,
386) -> DynamicObject {
387 place_on_rim(
388 c,
389 y0,
390 1.0,
391 DynamicObject::Cylinder {
392 radius,
393 height: (y1 - y0).max(1.0e-9),
394 segments: rim_segments(c),
395 },
396 )
397}
398
399fn fillet_frame(
406 size: f64,
407 cos_gamma: f64,
408 extent_a: f64,
409 extent_b: f64,
410) -> Option<(f64, f64, f64)> {
411 let sin_half = ((1.0 - cos_gamma) / 2.0).max(0.0).sqrt();
412 let cos_half = ((1.0 + cos_gamma) / 2.0).max(0.0).sqrt();
413 if sin_half < 1.0e-6 || cos_half < 1.0e-6 {
414 return None;
415 }
416 let setback = clamp_size(size * cos_half / sin_half, extent_a, extent_b)?;
417 let radius = setback * sin_half / cos_half;
418 Some((radius, setback, radius / sin_half))
419}
420
421fn straight_fillet_mask(
425 e: &crate::feature::edge::StraightEdge,
426 size: f64,
427 segments: usize,
428) -> Option<DynamicObject> {
429 let (r, setback, h) = fillet_frame(size, e.da.dot(e.db), e.extent_a, e.extent_b)?;
430 let cut_box = straight_chamfer_mask(e, setback);
431 let eps = EPS_FRAC * setback;
432 let u_hat = e.dir();
433 let len = (e.end - e.start).length();
434 let axis = e.start + h * (e.da + e.db).normalize();
437 let round = place_on_axis(
438 axis - 2.0 * eps * u_hat,
439 u_hat,
440 DynamicObject::Cylinder {
441 radius: r,
442 height: len + 4.0 * eps,
443 segments: segments.max(3),
444 },
445 );
446 Some(DynamicObject::Difference {
447 children: vec![cut_box, round],
448 })
449}
450
451fn circular_fillet_mask(
456 c: &crate::feature::edge::CircularEdge,
457 size: f64,
458 segments: usize,
459 convexity: Convexity,
460) -> Option<DynamicObject> {
461 let (r, setback, h) = fillet_frame(size, c.da2.dot(c.db2), c.extent_a, c.extent_b)?;
462 let eps = EPS_FRAC * setback;
463 let e2 = DVec2::new(c.radius, 0.0);
464 let pa = e2 + setback * c.da2;
465 let pb = e2 + setback * c.db2;
466 if pa.x <= eps || pb.x <= eps {
467 return None; }
469 let center = e2 + h * (c.da2 + c.db2).normalize();
470
471 let out = match convexity {
475 Convexity::Convex => 1.0,
476 Convexity::Concave => -1.0,
477 };
478 let na = out * DVec2::Y;
479 let nb = out * c.nb2;
480
481 let mut points = vec![pa + eps * na, e2 + eps * (na + nb), pb + eps * nb, pb];
482 let a0 = (pb - center).to_angle();
484 let mut sweep = (pa - center).to_angle() - a0;
485 if sweep > core::f64::consts::PI {
486 sweep -= core::f64::consts::TAU;
487 } else if sweep < -core::f64::consts::PI {
488 sweep += core::f64::consts::TAU;
489 }
490 let chords = segments.max(3);
491 points.extend(
492 (1..chords).map(|i| center + r * DVec2::from_angle(a0 + sweep * i as f64 / chords as f64)),
493 );
494 points.push(pa);
495 ensure_ccw(&mut points);
496
497 Some(wrap_frame(
498 &c.frame,
499 place_on_rim(
500 c,
501 0.0,
502 1.0,
503 DynamicObject::Revolve {
504 angle_degrees: 360.0,
505 segments: rim_segments(c),
506 child: Box::new(DynamicObject::Polygon {
507 points: points.iter().map(|p| p.to_array()).collect(),
508 }),
509 },
510 ),
511 ))
512}
513
514fn ensure_ccw(points: &mut [DVec2]) {
516 let area2: f64 = (0..points.len())
517 .map(|i| {
518 let p = points[i];
519 let q = points[(i + 1) % points.len()];
520 p.x * q.y - q.x * p.y
521 })
522 .sum();
523 if area2 < 0.0 {
524 points.reverse();
525 }
526}
527
528fn place_on_axis(origin: DVec3, up: DVec3, obj: DynamicObject) -> DynamicObject {
530 let rot = DQuat::from_rotation_arc(DVec3::Y, up);
531 let (z, y, x) = rot.to_euler(EulerRot::ZYX);
532 DynamicObject::Translate {
533 offset: origin.to_array(),
534 child: Box::new(DynamicObject::Rotate {
535 angles: [x.to_degrees(), y.to_degrees(), z.to_degrees()],
536 child: Box::new(obj),
537 }),
538 }
539}
540
541fn wrap_frame(frame: &DAffine3, obj: DynamicObject) -> DynamicObject {
546 if *frame == DAffine3::IDENTITY {
547 return obj;
548 }
549 let m = frame.matrix3;
550 let (q, lam) = jacobi_eigen(m.transpose() * m);
551 let sigma = DVec3::new(
552 lam.x.max(0.0).sqrt(),
553 lam.y.max(0.0).sqrt(),
554 lam.z.max(0.0).sqrt(),
555 );
556 if sigma.min_element() < 1.0e-12 * sigma.max_element().max(1.0e-300) {
557 return obj; }
559 let n = m * q * DMat3::from_diagonal(sigma.recip());
561 let (r1, sigma) = if n.determinant() < 0.0 {
562 let flip = DVec3::new(1.0, 1.0, -1.0);
563 (n * DMat3::from_diagonal(flip), sigma * flip)
564 } else {
565 (n, sigma)
566 };
567 let inner = DynamicObject::Rotate {
568 angles: euler_zyx_degrees(q.transpose()),
569 child: Box::new(obj),
570 };
571 let scaled = DynamicObject::Scale {
572 factors: sigma.to_array(),
573 child: Box::new(inner),
574 };
575 let rotated = DynamicObject::Rotate {
576 angles: euler_zyx_degrees(r1),
577 child: Box::new(scaled),
578 };
579 DynamicObject::Translate {
580 offset: frame.translation.to_array(),
581 child: Box::new(rotated),
582 }
583}
584
585fn jacobi_eigen(m: DMat3) -> (DMat3, DVec3) {
588 let mut a = [
590 [m.x_axis.x, m.y_axis.x, m.z_axis.x],
591 [m.x_axis.y, m.y_axis.y, m.z_axis.y],
592 [m.x_axis.z, m.y_axis.z, m.z_axis.z],
593 ];
594 let mut q = [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]];
595 for _sweep in 0..12 {
596 let off = a[0][1].abs() + a[0][2].abs() + a[1][2].abs();
597 let diag = a[0][0].abs() + a[1][1].abs() + a[2][2].abs();
598 if off <= 1.0e-15 * diag.max(1.0e-300) {
599 break;
600 }
601 for (p, r) in [(0usize, 1usize), (0, 2), (1, 2)] {
602 let apr = a[p][r];
603 if apr.abs() <= 1.0e-30 {
604 continue;
605 }
606 let theta = 0.5 * (a[r][r] - a[p][p]) / apr;
608 let t = theta.signum() / (theta.abs() + (theta * theta + 1.0).sqrt());
609 let c = 1.0 / (t * t + 1.0).sqrt();
610 let s = t * c;
611 let rot_cols = |row: &mut [f64; 3]| {
612 let (kp, kr) = (row[p], row[r]);
613 row[p] = c * kp - s * kr;
614 row[r] = s * kp + c * kr;
615 };
616 a.iter_mut().for_each(rot_cols);
617 let (head, tail) = a.split_at_mut(r);
619 for (apk, ark) in head[p].iter_mut().zip(tail[0].iter_mut()) {
620 let (x, y) = (*apk, *ark);
621 *apk = c * x - s * y;
622 *ark = s * x + c * y;
623 }
624 q.iter_mut().for_each(rot_cols);
625 }
626 }
627 let mut qm = DMat3::from_cols(
628 DVec3::new(q[0][0], q[1][0], q[2][0]),
629 DVec3::new(q[0][1], q[1][1], q[2][1]),
630 DVec3::new(q[0][2], q[1][2], q[2][2]),
631 );
632 if qm.determinant() < 0.0 {
633 qm.z_axis = -qm.z_axis;
634 }
635 (qm, DVec3::new(a[0][0], a[1][1], a[2][2]))
636}
637
638#[cfg(test)]
639mod tests {
640 use super::*;
641 use crate::feature::{EdgeSel, FaceRef, FaceTag};
642
643 fn cube(size: [f64; 3]) -> DynamicObject {
644 DynamicObject::Cube { size }
645 }
646
647 fn cylinder(radius: f64, height: f64) -> DynamicObject {
648 DynamicObject::Cylinder {
649 radius,
650 height,
651 segments: 64,
652 }
653 }
654
655 fn translate(offset: [f64; 3], child: DynamicObject) -> DynamicObject {
656 DynamicObject::Translate {
657 offset,
658 child: Box::new(child),
659 }
660 }
661
662 fn face(path: &[u16], tag: FaceTag) -> FaceRef {
663 FaceRef {
664 path: LeafPath(path.to_vec()),
665 face: tag,
666 }
667 }
668
669 fn chamfer(size: f64, edges: Vec<EdgeSel>) -> EdgeFeatureData {
670 EdgeFeatureData {
671 kind: EdgeFeatureKind::Chamfer,
672 size,
673 segments: 16,
674 edges,
675 }
676 }
677
678 fn contains(obj: &DynamicObject, p: DVec3) -> bool {
679 CsgLeaves::from_dynamic(obj, DAffine3::IDENTITY, None).contains(p)
680 }
681
682 #[test]
683 fn straight_chamfer_cuts_the_corner_and_nothing_else() {
684 let child = cube([2.0, 2.0, 2.0]);
685 let data = chamfer(
687 0.4,
688 vec![EdgeSel::new(
689 face(&[], FaceTag::CubePosX),
690 face(&[], FaceTag::CubePosY),
691 )],
692 );
693 let (lowered, report) = lower(&data, &child);
694 assert_eq!(report.applied, 1);
695 assert_eq!(report.skipped, 0);
696
697 for z in [0.05, 1.0, 1.95] {
699 assert!(
700 !contains(&lowered, DVec3::new(1.95, 1.95, z)),
701 "corner material at z={z} must be cut"
702 );
703 }
704 assert!(contains(&lowered, DVec3::new(1.75, 1.80, 1.0)));
707 assert!(!contains(&lowered, DVec3::new(1.85, 1.85, 1.0)));
708 assert!(contains(&lowered, DVec3::new(0.05, 0.05, 1.0)));
711 assert!(contains(&lowered, DVec3::new(1.95, 0.5, 1.0)));
712 assert!(contains(&lowered, DVec3::new(0.5, 1.95, 1.0)));
713 }
714
715 #[test]
716 fn rim_chamfer_cuts_ring_without_touching_wall_below() {
717 let child = cylinder(1.0, 2.0);
718 let data = chamfer(
719 0.2,
720 vec![EdgeSel::new(
721 face(&[], FaceTag::CylSide),
722 face(&[], FaceTag::CylTop),
723 )],
724 );
725 let (lowered, report) = lower(&data, &child);
726 assert_eq!((report.applied, report.skipped), (1, 0));
727
728 for theta in [0.0f64, 1.0, 2.5, 4.0, 5.5] {
730 let (s, c) = theta.sin_cos();
731 assert!(
732 !contains(&lowered, DVec3::new(0.97 * c, 1.97, 0.97 * s)),
733 "rim corner at θ={theta} must be cut"
734 );
735 }
736 assert!(contains(&lowered, DVec3::new(0.85, 1.90, 0.0)));
738 assert!(!contains(&lowered, DVec3::new(0.93, 1.95, 0.0)));
739 assert!(contains(&lowered, DVec3::new(0.99, 1.75, 0.0)));
741 assert!(contains(&lowered, DVec3::new(0.0, 1.99, 0.0)));
742 assert!(contains(&lowered, DVec3::new(0.99, 0.5, 0.0)));
743 }
744
745 #[test]
746 fn hole_rim_countersink_clips_to_the_funnel() {
747 let child = DynamicObject::Difference {
749 children: vec![
750 cube([4.0, 1.0, 4.0]),
751 translate([2.0, -0.5, 2.0], cylinder(0.5, 2.0)),
752 ],
753 };
754 let data = chamfer(
755 0.15,
756 vec![EdgeSel::new(
757 face(&[0], FaceTag::CubePosY),
758 face(&[1], FaceTag::CylSide),
759 )],
760 );
761 let (lowered, report) = lower(&data, &child);
762 assert_eq!((report.applied, report.skipped), (1, 0));
763
764 assert!(!contains(&lowered, DVec3::new(2.55, 0.97, 2.0)));
766 assert!(contains(&lowered, DVec3::new(2.55, 0.5, 2.0)));
768 assert!(!contains(&lowered, DVec3::new(2.0, 0.5, 2.0)));
771 assert!(contains(&lowered, DVec3::new(2.7, 0.97, 2.0)));
772 }
773
774 fn fillet(size: f64, edges: Vec<EdgeSel>) -> EdgeFeatureData {
775 EdgeFeatureData {
776 kind: EdgeFeatureKind::Fillet,
777 ..chamfer(size, edges)
778 }
779 }
780
781 fn boss_on_plate() -> (DynamicObject, EdgeSel) {
782 let child = DynamicObject::Union {
783 children: vec![
784 cube([4.0, 1.0, 4.0]),
785 translate([2.0, 1.0, 2.0], cylinder(0.5, 1.0)),
786 ],
787 };
788 let sel = EdgeSel::new(face(&[0], FaceTag::CubePosY), face(&[1], FaceTag::CylSide));
789 (child, sel)
790 }
791
792 #[test]
793 fn concave_chamfer_skips() {
794 let (child, sel) = boss_on_plate();
796 let data = chamfer(0.2, vec![sel]);
797 let (lowered, report) = lower(&data, &child);
798 assert_eq!((report.applied, report.skipped), (0, 1));
799 assert_eq!(lowered, child, "nothing to lower — child passes through");
800 }
801
802 #[test]
803 fn straight_fillet_keeps_the_arc_interior() {
804 let child = cube([2.0, 2.0, 2.0]);
807 let data = fillet(
808 0.4,
809 vec![EdgeSel::new(
810 face(&[], FaceTag::CubePosX),
811 face(&[], FaceTag::CubePosY),
812 )],
813 );
814 let (lowered, report) = lower(&data, &child);
815 assert_eq!((report.applied, report.skipped), (1, 0));
816
817 for z in [0.05, 1.0, 1.95] {
819 assert!(
820 !contains(&lowered, DVec3::new(1.95, 1.95, z)),
821 "corner material at z={z} must be cut"
822 );
823 }
824 assert!(contains(&lowered, DVec3::new(1.85, 1.85, 1.0)));
828 assert!(contains(&lowered, DVec3::new(1.95, 0.5, 1.0)));
830 assert!(contains(&lowered, DVec3::new(0.5, 1.95, 1.0)));
831 }
832
833 #[test]
834 fn convex_rim_fillet_is_a_subtractive_ring() {
835 let data = fillet(
836 0.2,
837 vec![EdgeSel::new(
838 face(&[], FaceTag::CylSide),
839 face(&[], FaceTag::CylTop),
840 )],
841 );
842 let (masks, report) = lower_masks(&data, &cylinder(1.0, 2.0));
843 assert_eq!((report.applied, report.skipped), (1, 0));
844 assert_eq!((masks.sub.len(), masks.add.len()), (1, 0));
845 assert_polygon_ring(&masks.sub[0], 0.2, DVec2::new(0.8, -0.2));
847 }
848
849 #[test]
850 fn boss_base_ring_fillet_is_an_additive_ring() {
851 let (child, sel) = boss_on_plate();
852 let data = fillet(0.2, vec![sel]);
853 let (masks, report) = lower_masks(&data, &child);
854 assert_eq!((report.applied, report.skipped), (1, 0));
855 assert_eq!((masks.sub.len(), masks.add.len()), (0, 1));
856 assert_polygon_ring(&masks.add[0], 0.2, DVec2::new(0.7, 0.2));
859
860 let (lowered, _) = lower(&data, &child);
862 match &lowered {
863 DynamicObject::Union { children } => {
864 assert_eq!(children.len(), 2);
865 assert_eq!(children[0], child);
866 }
867 other => panic!("expected Union(child, ring), got {:?}", other.name()),
868 }
869 }
870
871 fn assert_polygon_ring(mask: &DynamicObject, r: f64, center: DVec2) {
874 let mut node = mask;
875 loop {
876 match node {
877 DynamicObject::Translate { child, .. }
878 | DynamicObject::Rotate { child, .. }
879 | DynamicObject::Revolve { child, .. } => node = child,
880 DynamicObject::Polygon { points } => {
881 assert!(points.iter().all(|p| p[0] > 0.0), "profile crosses axis");
882 let on_arc = points
883 .iter()
884 .filter(|p| ((DVec2::from_array(**p) - center).length() - r).abs() < 1.0e-9)
885 .count();
886 assert!(
887 on_arc >= 3,
888 "expected arc samples at radius {r} of {center}"
889 );
890 return;
891 }
892 other => panic!("unexpected ring mask node {:?}", other.name()),
893 }
894 }
895 }
896
897 #[test]
898 fn mixed_chamfer_fillet_corner_leaves_no_horn() {
899 let inner = DynamicObject::EdgeFeature {
905 data: chamfer(
906 0.3,
907 vec![EdgeSel::new(
908 face(&[], FaceTag::CubePosX),
909 face(&[], FaceTag::CubePosY),
910 )],
911 ),
912 child: Box::new(cube([2.0, 2.0, 2.0])),
913 };
914 let stacked = DynamicObject::EdgeFeature {
915 data: EdgeFeatureData {
916 kind: EdgeFeatureKind::Fillet,
917 ..chamfer(
918 0.3,
919 vec![EdgeSel::new(
920 face(&[0], FaceTag::CubePosX),
921 face(&[0], FaceTag::CubePosZ),
922 )],
923 )
924 },
925 child: Box::new(inner),
926 };
927 assert!(!contains(&stacked, DVec3::new(1.98, 1.71, 1.98)));
932 assert!(contains(&stacked, DVec3::new(1.8, 1.0, 1.8)));
934 assert!(!contains(&stacked, DVec3::new(1.9, 1.9, 1.0)));
936 assert!(contains(&stacked, DVec3::new(1.98, 0.5, 0.5)));
938 }
939
940 #[test]
941 fn rim_masks_adopt_leaf_tessellation_and_phase() {
942 let hole = translate(
946 [2.0, -0.5, 2.0],
947 DynamicObject::Cylinder {
948 radius: 0.5,
949 height: 2.0,
950 segments: 20,
951 },
952 );
953 let child = DynamicObject::Difference {
954 children: vec![cube([4.0, 1.0, 4.0]), hole],
955 };
956 let sel = || {
957 vec![EdgeSel::new(
958 face(&[0], FaceTag::CubePosY),
959 face(&[1], FaceTag::CylSide),
960 )]
961 };
962
963 let (masks, report) = lower_masks(&chamfer(0.15, sel()), &child);
964 assert_eq!((report.applied, report.skipped), (1, 0));
965 let csg = CsgLeaves::from_dynamic(&masks.sub[0], DAffine3::IDENTITY, None);
966 assert!(!csg.leaves.is_empty());
967 for leaf in &csg.leaves {
968 let segments = match leaf.prim {
969 LeafPrim::Cylinder { segments, .. } | LeafPrim::Cone { segments, .. } => segments,
970 _ => continue,
971 };
972 assert_eq!(segments, 20, "mask must match the hole tessellation");
973 let x_img = (leaf.world.matrix3 * DVec3::X).normalize();
976 assert!(
977 (x_img - DVec3::X).length() < 1e-9,
978 "mask phase must anchor at the leaf origin, got {x_img:?}"
979 );
980 }
981
982 let fillet_data = EdgeFeatureData {
984 kind: EdgeFeatureKind::Fillet,
985 ..chamfer(0.15, sel())
986 };
987 let (masks, report) = lower_masks(&fillet_data, &child);
988 assert_eq!((report.applied, report.skipped), (1, 0));
989 fn find_revolve_segments(obj: &DynamicObject) -> Option<usize> {
990 match obj {
991 DynamicObject::Revolve { segments, .. } => Some(*segments),
992 DynamicObject::Translate { child, .. }
993 | DynamicObject::Rotate { child, .. }
994 | DynamicObject::Scale { child, .. } => find_revolve_segments(child),
995 _ => None,
996 }
997 }
998 assert_eq!(find_revolve_segments(&masks.sub[0]), Some(20));
999 }
1000
1001 #[test]
1002 fn wrap_frame_reproduces_affine() {
1003 let cases = [
1004 DAffine3::from_translation(DVec3::new(1.0, 2.0, 3.0))
1006 * DAffine3::from_quat(DQuat::from_rotation_z(0.5))
1007 * DAffine3::from_scale(DVec3::new(2.0, 1.0, 0.5))
1008 * DAffine3::from_quat(DQuat::from_rotation_x(0.3)),
1009 DAffine3::from_scale(DVec3::new(-2.0, 1.0, 0.5))
1011 * DAffine3::from_quat(DQuat::from_rotation_y(0.7)),
1012 ];
1013 for f in cases {
1014 let wrapped = wrap_frame(&f, cube([1.0, 1.0, 1.0]));
1015 let csg = CsgLeaves::from_dynamic(&wrapped, DAffine3::IDENTITY, None);
1016 let got = csg.leaves[0].world;
1017 assert!(
1018 (got.matrix3.x_axis - f.matrix3.x_axis).length() < 1e-9
1019 && (got.matrix3.y_axis - f.matrix3.y_axis).length() < 1e-9
1020 && (got.matrix3.z_axis - f.matrix3.z_axis).length() < 1e-9
1021 && (got.translation - f.translation).length() < 1e-9,
1022 "wrapper chain must reproduce the frame: {got:?} vs {f:?}"
1023 );
1024 }
1025 }
1026
1027 #[test]
1028 fn elliptical_rim_chamfer_cuts_in_world() {
1029 let child = DynamicObject::Scale {
1033 factors: [2.0, 1.0, 1.0],
1034 child: Box::new(cylinder(1.0, 2.0)),
1035 };
1036 let data = chamfer(
1037 0.2,
1038 vec![EdgeSel::new(
1039 face(&[], FaceTag::CylSide),
1040 face(&[], FaceTag::CylTop),
1041 )],
1042 );
1043 let (lowered, report) = lower(&data, &child);
1044 assert_eq!((report.applied, report.skipped), (1, 0));
1045 assert!(!contains(&lowered, DVec3::new(1.94, 1.97, 0.0)));
1047 assert!(!contains(&lowered, DVec3::new(0.0, 1.97, 0.97)));
1048 assert!(contains(&lowered, DVec3::new(1.98, 1.5, 0.0)));
1050 assert!(contains(&lowered, DVec3::new(0.0, 1.5, 0.99)));
1051 assert!(contains(&lowered, DVec3::new(0.0, 1.99, 0.0)));
1052 }
1053
1054 #[test]
1055 fn stale_paths_skip_and_valid_ones_apply() {
1056 let child = cube([2.0, 2.0, 2.0]);
1057 let data = chamfer(
1058 0.2,
1059 vec![
1060 EdgeSel::new(face(&[], FaceTag::CubePosX), face(&[], FaceTag::CubePosY)),
1061 EdgeSel::new(face(&[7], FaceTag::CubePosX), face(&[7], FaceTag::CubePosY)),
1063 ],
1064 );
1065 let (_, report) = lower(&data, &child);
1066 assert_eq!((report.applied, report.skipped), (1, 1));
1067 }
1068
1069 #[test]
1070 fn nested_feature_child_paths_resolve() {
1071 let inner = DynamicObject::EdgeFeature {
1075 data: chamfer(
1076 0.2,
1077 vec![EdgeSel::new(
1078 face(&[], FaceTag::CubePosX),
1079 face(&[], FaceTag::CubePosY),
1080 )],
1081 ),
1082 child: Box::new(cube([2.0, 2.0, 2.0])),
1083 };
1084 let data = chamfer(
1085 0.2,
1086 vec![EdgeSel::new(
1087 face(&[0], FaceTag::CubeNegX),
1088 face(&[0], FaceTag::CubeNegY),
1089 )],
1090 );
1091 let (lowered, report) = lower(&data, &inner);
1092 assert_eq!((report.applied, report.skipped), (1, 0));
1093 assert!(!contains(&lowered, DVec3::new(1.95, 1.95, 1.0)));
1095 assert!(!contains(&lowered, DVec3::new(0.05, 0.05, 1.0)));
1096 }
1097
1098 #[test]
1099 fn chamfer_follows_parameter_edits() {
1100 let edges = vec![EdgeSel::new(
1101 face(&[], FaceTag::CubePosX),
1102 face(&[], FaceTag::CubePosY),
1103 )];
1104 let data = chamfer(0.3, edges);
1105 let (small, _) = lower(&data, &cube([2.0, 2.0, 2.0]));
1107 let (large, _) = lower(&data, &cube([3.0, 3.0, 3.0]));
1108 assert!(!contains(&small, DVec3::new(1.95, 1.95, 1.0)));
1109 assert!(contains(&large, DVec3::new(1.95, 1.95, 1.0)));
1110 assert!(!contains(&large, DVec3::new(2.95, 2.95, 1.0)));
1111 }
1112
1113 #[test]
1114 fn size_clamps_to_adjacent_extent() {
1115 let child = cube([2.0, 2.0, 2.0]);
1118 let data = chamfer(
1119 1000.0,
1120 vec![EdgeSel::new(
1121 face(&[], FaceTag::CubePosX),
1122 face(&[], FaceTag::CubePosY),
1123 )],
1124 );
1125 let (lowered, report) = lower(&data, &child);
1126 assert_eq!(report.applied, 1);
1127 assert!(contains(&lowered, DVec3::new(1.0, 0.2, 1.0)));
1128 assert!(
1129 contains(&lowered, DVec3::new(1.9, 1.05, 1.0)),
1130 "clamped cut leaves the face below the setback"
1131 );
1132 assert!(!contains(&lowered, DVec3::new(1.9, 1.9, 1.0)));
1133 }
1134
1135 #[test]
1136 fn transformed_leaf_masks_follow_the_leaf_frame() {
1137 let child = translate(
1139 [5.0, 0.0, 0.0],
1140 DynamicObject::Rotate {
1141 angles: [0.0, 0.0, 45.0],
1142 child: Box::new(cube([2.0, 2.0, 2.0])),
1143 },
1144 );
1145 let data = chamfer(
1146 0.4,
1147 vec![EdgeSel::new(
1148 face(&[], FaceTag::CubePosX),
1149 face(&[], FaceTag::CubePosY),
1150 )],
1151 );
1152 let (lowered, report) = lower(&data, &child);
1153 assert_eq!((report.applied, report.skipped), (1, 0));
1154 let world = DAffine3::from_translation(DVec3::new(5.0, 0.0, 0.0))
1157 * DAffine3::from_quat(DQuat::from_rotation_z(45.0_f64.to_radians()));
1158 let old_edge = world.transform_point3(DVec3::new(1.95, 1.95, 1.0));
1159 let deep = world.transform_point3(DVec3::new(1.0, 1.0, 1.0));
1160 assert!(!contains(&lowered, old_edge));
1161 assert!(contains(&lowered, deep));
1162 }
1163}