Files
OneCAD/crates/kernel/src/pinch_tests.rs
T
froglemonade2andfroglemonade2 3669d4b3d0 Cut a hole tangent inside a stepped boss (zero-thickness curved wall) (#986)
Touching sews now split 4-use straight edges on extruded walls (along their rulings), not only on planes. Faces round such an edge are ordered by the way they leave it and, where they leave it tangent, by their curvature. Sheets bound the material wedges as before, except where that would make a face touch itself (a hole touching the outline from inside); edges ending there pair the empty wedges instead, so each loop gets its own vertex. Coincident faces are still refused, plain sew still refuses vertex contact, and shell_groups is unchanged.

A Ø10 hole tangent inside a stepped Ø40/Ø20 boss now cuts exactly instead of failing with "wire is not simple". Robustness seeds 42 and 51 are recorded: their touching hole patterns in stepped bodies now cut exactly instead of by a tool nudge.

Co-authored-by: froglemonade2 <froglemonade2@users.noreply.github.com>
2026-10-14 03:55:06 -04:00

208 lines
11 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Outlines that touch themselves: a circle tangent to a side, a corner on an edge, corners
//! meeting. Joins over such a contact fuse into one solid, cuts that leave pieces meeting only
//! along a line give separate shells, and near-tangent numbers from the sketch solver (a
//! circle 1e-13 mm past the side) behave like exact ones.
use solidcraft_geom::{Loop2, Plane, Region2, Vec2, Vec3};
use crate::*;
fn prism(outer: Loop2, z0: f64, z1: f64) -> Body {
extrude(&Plane::XY, &[Region2 { outer, holes: vec![] }], z0, z1).unwrap().pop().unwrap()
}
fn poly(pts: &[[f64; 2]]) -> Loop2 {
Loop2::polygon(&pts.iter().map(|p| Vec2::new(p[0], p[1])).collect::<Vec<_>>())
}
fn rect(x0: f64, y0: f64, x1: f64, y1: f64) -> Loop2 {
poly(&[[x0, y0], [x1, y0], [x1, y1], [x0, y1]])
}
fn disc(cx: f64, cy: f64, r: f64) -> Loop2 {
Loop2::circle(Vec2::new(cx, cy), r)
}
fn assert_solid(r: &Body, volume: f64, rel: f64, shells: usize, what: &str) {
let v = r.tessellate(1e-4).unwrap().measure().volume;
assert!((v - volume).abs() <= volume * rel, "{what}: volume {v} vs {volume}");
assert_eq!(r.shell_count(), shells, "{what}: shells");
assert!(r.validity().is_empty(), "{what}: {:?}", r.validity());
}
/// The reported case: a disc on a 31 × 29 block, tangent to its back side and overhanging the
/// other three, joined exactly, whether the tangency is exact or the solver's (1.3e-13 past).
/// The solver's numbers used to fail every route: a split line traced on the disc's face ended
/// one chord's sag short of the rim and was dropped, so the contact faces never matched.
#[test]
fn disc_tangent_to_a_side_and_overhanging_joins() {
let block = prism(rect(0., 0., 31., 29.), 0., 10.);
for (cy, r) in [(4.0, 25.0), (4.392830461839724, 24.607169538160402), (4.0, 25.0 + 1e-9), (4.0, 25.0 - 1e-9)] {
let top = prism(disc(18.5, cy, r), 10., 20.);
let want = 8990.0 + std::f64::consts::PI * r * r * 10.0;
for (a, b) in [(&block, &top), (&top, &block)] {
let (res, d) = capture_boolean_diagnostics(|| boolean(a, b, BoolOp::Union));
let res = res.unwrap().unwrap();
assert_solid(&res, want, 1e-5, 1, &format!("disc at {cy}, r {r}"));
assert!(!d.events.iter().any(|e| e.strategy == BooleanStrategy::ToolNudge), "{d:?}");
}
}
}
/// A wall splitting a disc's face where the rim crosses it between two chords of the trace
/// mesh: the split happens however the mesh falls.
#[test]
fn split_line_ending_past_the_last_chord_still_splits() {
let top = prism(disc(18.5, 4.392830461839724, 24.607169538160402), 10., 20.);
let bottom =
(0..top.face_count()).find(|i| matches!(face_surface(&top, *i), Some(FaceSurface::Plane { normal, .. }) if normal.z < -0.9)).unwrap();
let wall = SplitTool::Plane(Plane { origin: Vec3::ZERO, x: Vec3::X, y: Vec3::Z });
let (split, _) = split_faces_with_map(&top, &[bottom], &wall).unwrap();
assert_eq!(split.face_count(), top.face_count() + 1);
}
/// Lines only: a triangle with its apex on the square's back edge, its base overhanging, and a
/// diamond with its corners on the square's edge midpoints, each standing on the square.
#[test]
fn corner_on_an_edge_joins() {
let square = prism(rect(0., 0., 20., 20.), 0., 10.);
let triangle = prism(poly(&[[-5., -5.], [25., -5.], [10., 20.]]), 10., 20.);
let diamond = prism(poly(&[[10., 0.], [20., 10.], [10., 20.], [0., 10.]]), 10., 20.);
for (tool, want) in [(&triangle, 4000.0 + 3750.0), (&diamond, 4000.0 + 2000.0)] {
let res = boolean(&square, tool, BoolOp::Union).unwrap().unwrap();
assert_solid(&res, want, 1e-9, 1, "corner on an edge");
}
}
/// A cut leaving pieces that meet only along a line gives one shell per piece, not one shell
/// pinched along that line: a diamond cut through a square leaves four corner posts, a disc
/// tangent to the block's back side two.
#[test]
fn cut_leaving_pieces_meeting_along_a_line_gives_separate_shells() {
let square = prism(rect(0., 0., 20., 20.), 0., 10.);
let diamond = prism(poly(&[[10., 0.], [20., 10.], [10., 20.], [0., 10.]]), -1., 11.);
let res = boolean(&square, &diamond, BoolOp::Cut).unwrap().unwrap();
assert_solid(&res, 2000.0, 1e-9, 4, "diamond through a square");
let block = prism(rect(0., 0., 31., 29.), 0., 10.);
let tool = prism(disc(18.5, 4., 25.), -1., 11.);
let res = boolean(&block, &tool, BoolOp::Cut).unwrap().unwrap();
// Material left: the block's area outside the disc, as two corner posts 10 mm high.
let n = 400_000;
let dx = 31.0 / n as f64;
let outside: f64 = (0..n)
.map(|i| {
let x = (i as f64 + 0.5) * dx - 18.5;
let h = (625.0 - x * x).max(0.0).sqrt();
(29.0 - (4.0 + h).min(29.0)).max(0.0) * dx
})
.sum();
assert_solid(&res, outside * 10.0, 1e-4, 2, "disc tangent to the back side, cut through");
}
/// A hole tangent to a side from inside is one piece (the material goes round the hole), and a
/// disc standing on a larger one, tangent to its rim from inside, joins.
#[test]
fn hole_touching_the_outline_stays_one_piece() {
let block = prism(rect(0., 0., 31., 29.), 0., 10.);
let hole = prism(disc(5., 14.5, 5.), -1., 11.);
let res = boolean(&block, &hole, BoolOp::Cut).unwrap().unwrap();
assert_solid(&res, (899.0 - std::f64::consts::PI * 25.0) * 10.0, 1e-5, 1, "hole tangent to a side");
let small = prism(disc(-2., 0., 10.), 0., 10.);
let big = prism(disc(0., 0., 12.), 10., 20.);
let res = boolean(&small, &big, BoolOp::Union).unwrap().unwrap();
assert_solid(&res, std::f64::consts::PI * (100.0 + 144.0) * 10.0, 1e-5, 1, "disc tangent inside a larger one");
}
/// A Ø40 base with a Ø20 boss on it touching its rim from inside at (20, 0), and a Ø10 hole at
/// (15, 0) touching both rims there: the material on either side of the hole meets along the
/// line x = 20, y = 0 only (a zero-thickness curved wall). The hole cut through both, the boss
/// only and the base only, all exact (no tool nudge); then the same turned 30° and mirrored.
#[test]
fn hole_tangent_inside_a_stepped_boss_cuts_exactly() {
use std::f64::consts::PI;
for (turn, flip) in [(0.0, 1.0), (PI / 6.0, 1.0), (0.0, -1.0)] {
let at = |x: f64, y: f64| Vec2::new(x * turn.cos() - flip * y * turn.sin(), x * turn.sin() + flip * y * turn.cos());
let circle = |x: f64, r: f64| Loop2::circle(at(x, 0.), r);
let base = prism(circle(0., 20.), 0., 5.);
let boss = prism(circle(10., 10.), 5., 15.);
let stepped = boolean(&base, &boss, BoolOp::Union).unwrap().unwrap();
for (z0, z1, want) in [(-1., 20., 2000. + 1000. - 25. * 15.), (6., 20., 2000. + 1000. - 25. * 9.), (-1., 4., 2000. + 1000. - 25. * 4.)] {
let what = format!("hole z {z0}…{z1}, turned {turn}, flip {flip}");
let hole = prism(circle(15., 5.), z0, z1);
let (res, d) = capture_boolean_diagnostics(|| boolean(&stepped, &hole, BoolOp::Cut));
let res = res.unwrap_or_else(|e| panic!("{what}: {e}")).unwrap();
assert_solid(&res, PI * want, 1e-5, 1, &what);
assert!(!d.events.iter().any(|e| e.strategy == BooleanStrategy::ToolNudge), "{what}: {d:?}");
}
}
}
/// A square hole in a stepped block whose side lies on the block's side: planar walls meeting
/// along a line at no angle are not a zero-thickness wall but one flat side, and a hole tangent
/// to the side of a square boss on a block cuts exactly.
#[test]
fn hole_tangent_to_a_flat_side_of_a_stepped_block_cuts_exactly() {
let block = prism(rect(0., 0., 40., 40.), 0., 5.);
let boss = prism(rect(10., 10., 40., 30.), 5., 15.);
let stepped = boolean(&block, &boss, BoolOp::Union).unwrap().unwrap();
let hole = prism(disc(35., 20., 5.), -1., 20.);
let (res, d) = capture_boolean_diagnostics(|| boolean(&stepped, &hole, BoolOp::Cut));
let res = res.unwrap().unwrap();
assert_solid(&res, 8000. + 6000. - std::f64::consts::PI * 25. * 15., 1e-5, 1, "hole tangent to a flat side");
assert!(!d.events.iter().any(|e| e.strategy == BooleanStrategy::ToolNudge), "{d:?}");
}
/// A stepped block cut by a disc tangent to its back side from inside leaves two posts meeting
/// only along the tangent line: the stepped prism route keeps them apart (its edge sheets bound
/// the posts, as in the single-height case above) rather than sewing one shell pinched along the
/// line. A diamond hole with a corner on the side of a stepped block is one piece: the material
/// goes round the hole.
#[test]
fn stepped_cuts_touching_along_a_line_keep_their_pieces() {
let block = prism(rect(0., 0., 31., 29.), 0., 10.);
let back = prism(rect(0., 20., 25., 29.), 10., 20.);
let stepped = boolean(&block, &back, BoolOp::Union).unwrap().unwrap();
let tool = prism(disc(18.5, 4., 25.), -1., 21.);
let (res, d) = capture_boolean_diagnostics(|| boolean(&stepped, &tool, BoolOp::Cut));
let res = res.unwrap().unwrap();
let n = 400_000;
let dx = 31.0 / n as f64;
let post = |x: f64| (29.0 - (4.0 + (625.0 - (x - 18.5).powi(2)).max(0.0).sqrt()).min(29.0)).max(0.0);
let volume: f64 = (0..n)
.map(|i| {
let x = (i as f64 + 0.5) * dx;
post(x) * dx * if x < 25.0 { 20.0 } else { 10.0 }
})
.sum();
let v = res.tessellate(1e-4).unwrap().measure().volume;
assert!((v - volume).abs() <= volume * 1e-4, "posts: volume {v} vs {volume}");
assert!(!d.events.iter().any(|e| e.strategy == BooleanStrategy::ToolNudge), "{d:?}");
let sewn = crate::prism::prism_boolean(&stepped, &tool, BoolOp::Cut);
assert!(!matches!(&sewn, Some(Ok(Some(b))) if b.shell_count() < 2), "posts sewn into one shell");
let block = prism(rect(0., 0., 40., 40.), 0., 5.);
let boss = prism(rect(10., 10., 40., 30.), 5., 15.);
let stepped = boolean(&block, &boss, BoolOp::Union).unwrap().unwrap();
let diamond = prism(poly(&[[30., 20.], [35., 15.], [40., 20.], [35., 25.]]), -1., 20.);
let (res, d) = capture_boolean_diagnostics(|| boolean(&stepped, &diamond, BoolOp::Cut));
let res = res.unwrap().unwrap();
assert_solid(&res, 8000. + 6000. - 50. * 15., 1e-9, 1, "diamond hole with a corner on a stepped side");
assert!(!d.events.iter().any(|e| e.strategy == BooleanStrategy::ToolNudge), "{d:?}");
}
/// A row of holes each touching the next (a 5 mm pattern of Ø5 holes) through a stepped plate:
/// the walls between them have no thickness, and the cut is exact.
#[test]
fn row_of_touching_holes_through_a_stepped_plate_cuts_exactly() {
let plate = prism(rect(0., 0., 30., 35.), 0., 7.5);
let notch = prism(rect(0., 30., 22.5, 50.), 2.5, 25.);
let mut body = boolean(&plate, &notch, BoolOp::Cut).unwrap().unwrap();
for x in [15.45, 20.45, 25.45] {
let hole = prism(disc(x, 15.7, 2.5), -5., 7.5);
let (res, d) = capture_boolean_diagnostics(|| boolean(&body, &hole, BoolOp::Cut));
body = res.unwrap().unwrap();
assert!(!d.events.iter().any(|e| e.strategy == BooleanStrategy::ToolNudge), "hole at {x}: {d:?}");
}
assert_solid(&body, 7312.5 - 3.0 * std::f64::consts::PI * 6.25 * 7.5, 1e-5, 1, "touching holes");
}