fix: a cut takes only its sketch, a join adds only it (#1310)

Thanks @NOWN-DEV.
This commit is contained in:
NOWN-DEV authored and GitHub committed 2026-10-17 08:42:17 -04:00
1 parent 0a66697da8
commit 8a2d142333
5 files changed
+569 -128

No files matched your search

+377 -124
View File
@@ -1838,144 +1838,266 @@ fn fill_void_holes(st: &ModelState, r: &Region2, op: Operation, targets: &[Strin
Region2 { outer: r.outer.clone(), holes }
}
/// Grow profile edges that lie on a face of a target body away from the profile, by a small
/// amount, when the space they grow into is already material (join) or empty (cut). `probe`
/// maps a sketch point to the world points where the tool would be (along the extrude, around
/// the revolve). Loops of lines and arcs are adjusted: a line moves out, an arc keeps its centre
/// and changes its radius (a rounded corner of a face cut whole, #1275). Arcs move only where
/// both of their ends meet tangent neighbours that move with them; otherwise the loop is left
/// as it is.
fn grow_profile_for_coplanar(st: &ModelState, r: &Region2, op: Operation, targets: &[String], probe: &dyn Fn(Vec2) -> Vec<Vec3>) -> Region2 {
/// The bodies a cut or join works on (its targets, else every body), asked what its tool would
/// take or add if it reached past its sketch.
struct Surroundings<'a> {
/// Each body with its display mesh and, once something near it is asked, its faces.
bodies: Vec<(&'a ModelBody, Arc<Mesh>, std::cell::OnceCell<Option<kernel::Clearance<'a>>>)>,
op: Operation,
/// The largest body's size.
size: f64,
/// Faces this close to a plane lie in it (the kernel's measure for coincident faces).
skin: f64,
/// How far off a round face a space must stay to be asked about: the face's triangles may
/// stand off it by twice the chord they are made with, and the check pads them as far again.
round: f64,
}
impl<'a> Surroundings<'a> {
/// The chord of the meshes the bodies' faces are asked on, as a share of each one's size.
const CHORD: f64 = 2e-4;
/// `None` for an operation other than a cut or a join, or without a body to work on.
fn of(st: &'a ModelState, op: Operation, targets: &[String]) -> Option<Surroundings<'a>> {
if !matches!(op, Operation::Cut | Operation::Join) {
return None;
}
let bodies: Vec<_> =
st.bodies.iter().filter(|b| targets.is_empty() || targets.contains(&b.name)).map(|b| (b, b.mesh(), std::cell::OnceCell::new())).collect();
let size = bodies.iter().map(|(b, _, _)| b.body.size()).fold(0.0, f64::max);
(!bodies.is_empty()).then(|| Surroundings { bodies, op, size, skin: (size * 1e-6).max(1e-8), round: 4.0 * Self::CHORD * size })
}
/// Does the space the triangles `pieces` of `plane` sweep along its normal, from the height
/// `lo` to `hi`, hold just what the tool may gain without changing the result: nothing of
/// the bodies (a cut), or one body's material (a join)? Decided on the bodies' faces (see
/// [`kernel::Clearance`]), so a wall, a rib, a gap or a hole anywhere in it counts.
/// `margin`: how far the region reaches past the triangles sideways (negative: their sides
/// are drawn in, so that faces lying against them don't count).
fn harmless(&self, plane: &Plane, pieces: &[[Vec2; 3]], margin: f64, lo: f64, hi: f64) -> bool {
let n = plane.normal();
let faces: Vec<([Vec3; 3], f64)> = pieces.iter().map(|t| (t.map(|p| plane.to_world(p)), margin)).collect();
let mut space = solidcraft_geom::Aabb3::EMPTY;
for p in faces.iter().flat_map(|(t, _)| t) {
space.add(*p + n * lo);
space.add(*p + n * hi);
}
let mut full = false;
for (body, mesh, faces_of) in &self.bodies {
// A body well away from that space holds none of it (its faces lie within the
// display mesh's chord tolerance of the mesh).
let (b, pad) = (mesh.bounds(), margin.abs() + 2.0 * body.display_tol());
let away = |lo: Vec3, hi: Vec3| lo.x > hi.x + pad || lo.y > hi.y + pad || lo.z > hi.z + pad;
if !b.is_empty() && (away(b.min, space.max) || away(space.min, b.max)) {
continue;
}
let Some(clearance) = faces_of.get_or_init(|| kernel::Clearance::new(&body.body, body.body.size() * Self::CHORD)) else { return false };
match clearance.swept(&faces, n, lo, hi) {
kernel::Occupancy::Mixed => return false,
kernel::Occupancy::Full => full = true,
kernel::Occupancy::Empty => {}
}
}
full == (self.op == Operation::Join)
}
}
/// The outward normal (away from the profile) at parameter `t` of a segment of a
/// counterclockwise loop: the tangent turned clockwise.
fn outward(s: &solidcraft_geom::Seg2, t: f64) -> Option<Vec2> {
use solidcraft_geom::Seg2;
if !matches!(op, Operation::Cut | Operation::Join) {
return r.clone();
match *s {
Seg2::Line { a, b } => (b - a).normalized().map(|d| Vec2::new(d.y, -d.x)),
Seg2::Arc { start, sweep, .. } => Some(Vec2::from_angle(start + sweep * t) * sweep.signum()),
_ => None,
}
let bodies: Vec<&ModelBody> = st.bodies.iter().filter(|b| targets.is_empty() || targets.contains(&b.name)).collect();
if bodies.is_empty() {
return r.clone();
}
/// A counterclockwise loop of lines and arcs, each moved out by `by` where `grow` says so: a
/// line moves out and meets its neighbours again, an arc keeps its centre and changes its
/// radius. `None` when an edge would turn round (too short for the move), edges in one line
/// would part, a segment would leave a tangent neighbour behind, or an arc would move at a
/// sharp corner.
fn offset_segs(segs: &[solidcraft_geom::Seg2], grow: &[bool], by: f64) -> Option<Vec<solidcraft_geom::Seg2>> {
use solidcraft_geom::Seg2;
let n = segs.len();
let tangent = |a: &Seg2, b: &Seg2| a.end_tangent().cross(b.start_tangent()).abs() < 1e-6 && a.end_tangent().dot(b.start_tangent()) > 0.0;
let step = |g: bool| if g { by } else { 0.0 };
// Where segment `i` starts once moved: the corner of it and the one before.
let mut starts = Vec::with_capacity(n);
for i in 0..n {
let (prev, cur, g0, g1) = (segs.get((i + n - 1) % n)?, segs.get(i)?, *grow.get((i + n - 1) % n)?, *grow.get(i)?);
let corner = cur.start();
if !g0 && !g1 {
starts.push(corner);
} else if tangent(prev, cur) {
// Moved together, a tangent joint moves along the common normal and stays tangent.
if g0 != g1 {
return None;
}
starts.push(corner + outward(cur, 0.0)? * by);
} else {
let (Seg2::Line { a: a0, b: b0 }, Seg2::Line { a: a1, b: b1 }) = (*prev, *cur) else { return None };
let (d0, d1) = ((b0 - a0).normalized()?, (b1 - a1).normalized()?);
let (p0, p1) = (a0 + Vec2::new(d0.y, -d0.x) * step(g0), a1 + Vec2::new(d1.y, -d1.x) * step(g1));
let den = d0.cross(d1);
if den.abs() >= 1e-12 {
starts.push(p0 + d0 * ((p1 - p0).cross(d1) / den));
} else if g0 == g1 {
starts.push(p1);
} else {
return None;
}
}
}
let meshes: Vec<Arc<Mesh>> = bodies.iter().map(|b| b.mesh()).collect();
let size = bodies.iter().map(|b| b.body.size()).fold(0.0, f64::max);
let delta = (size * 0.005).max(1e-3);
let mut out = Vec::with_capacity(n);
for (i, seg) in segs.iter().enumerate() {
let (a, b, g) = (*starts.get(i)?, *starts.get((i + 1) % n)?, *grow.get(i)?);
out.push(match *seg {
Seg2::Line { a: a0, b: b0 } if (b - a).dot(b0 - a0) > 0.0 => Seg2::Line { a, b },
Seg2::Arc { center, radius, start, sweep } if radius + step(g) * sweep.signum() > 0.0 => {
Seg2::Arc { center, radius: radius + step(g) * sweep.signum(), start, sweep }
}
_ => return None,
});
}
Some(out)
}
/// Grow profile edges that lie on a face of a body away from the profile, by a small amount,
/// so that the boolean meets no coincident face there. `probe` maps a sketch point to the
/// world points where the tool would be (along the extrude, around the revolve): an edge with
/// material on one side and room on the other at three points along it may grow. It grows
/// only where `gains` finds the whole strip it would add (triangles in the sketch) harmless,
/// asked a quarter wider than it grows, so that the moved side comes near nothing either:
/// three points miss the walls at the edge's ends, a neighbour across a gap, or a wall thinner
/// than the step. Elsewhere the edge stays where it was drawn. Loops of lines and arcs are
/// adjusted: a line moves out, an arc keeps its centre and changes its radius (a rounded corner
/// of a face cut whole, #1275); an arc's strip, and a line's where it meets one, is asked from
/// a little off the round face the arc lies on (see [`Surroundings`]'s `round`). Edges in one
/// line move together or not at all; arcs move only where both of their ends meet tangent
/// neighbours that move with them, otherwise the loop is left as it is.
fn grow_profile_for_coplanar(sur: &Surroundings, r: &Region2, probe: &dyn Fn(Vec2) -> Vec<Vec3>, gains: &dyn Fn(&[[Vec2; 3]]) -> bool) -> Region2 {
use solidcraft_geom::Seg2;
let delta = (sur.size * 0.005).max(1e-3);
let lp = r.outer.ccw();
let segs = &lp.segs;
if segs.is_empty() || !segs.iter().all(|s| matches!(s, Seg2::Line { .. } | Seg2::Arc { .. })) {
return r.clone();
}
// The outward normal (away from the profile) at parameter `t` of a segment of the
// counterclockwise loop: the tangent turned clockwise.
let out_at = |s: &Seg2, t: f64| -> Option<Vec2> {
match *s {
Seg2::Line { a, b } => (b - a).normalized().map(|d| Vec2::new(d.y, -d.x)),
Seg2::Arc { start, sweep, .. } => Some(Vec2::from_angle(start + sweep * t) * sweep.signum()),
let n = segs.len();
// Many probes on one mesh each: indexed, the same answers as `Mesh::contains`.
let inside: Vec<_> = sur.bodies.iter().map(|(_, m, _)| m.inside_index()).collect();
let inside_any = |p: Vec3| inside.iter().any(|m| m.contains(p));
let want_outer = sur.op == Operation::Join;
let mut grow: Vec<bool> = segs
.iter()
.map(|seg| {
if let Seg2::Arc { radius, sweep, .. } = *seg
&& sweep < 0.0
&& radius <= 2.0 * delta
{
// A concave arc too small to shrink by the step.
return false;
}
// Is the edge on a body face? Material on exactly one side of it.
let mids: Vec<(Vec2, Vec2)> = [0.25, 0.5, 0.75].iter().filter_map(|t| Some((seg.point_at(*t), outward(seg, *t)?))).collect();
if mids.len() < 3 {
return false;
}
let side = |k: f64| mids.iter().flat_map(|(m, out)| probe(*m + *out * (delta * k))).map(&inside_any).collect::<Vec<bool>>();
let (outer, inner) = (side(0.5), side(-0.5));
let on_face = outer.iter().all(|x| *x == want_outer) && inner.iter().any(|x| *x != want_outer);
on_face && side(1.0).iter().all(|x| *x == want_outer)
})
.collect();
let in_line = |i: usize| match (segs.get(i % n), segs.get((i + 1) % n)) {
(Some(Seg2::Line { a: a0, b: b0 }), Some(Seg2::Line { a: a1, b: b1 })) => {
matches!(((*b0 - *a0).normalized(), (*b1 - *a1).normalized()), (Some(d0), Some(d1)) if d0.cross(d1).abs() < 1e-12)
}
_ => false,
};
let round = |k: usize| matches!(segs.get(k % n), Some(Seg2::Arc { .. }));
// What segment `i` gains moving out to `moved`, as triangles in the sketch; `None` when
// nothing of it can be asked.
let strip = |i: usize, seg: &Seg2, moved: &Seg2| -> Option<Vec<[Vec2; 3]>> {
match (*seg, *moved) {
(Seg2::Line { a, b }, Seg2::Line { a: a2, b: b2 }) => {
// Its ends drawn back from the round faces of arcs it runs into.
let d = (b - a).normalized()?;
let s0 = if round(i + n - 1) { sur.round } else { 0.0 };
let s1 = if round(i + 1) { sur.round } else { 0.0 };
if (b - a).len() <= s0 + s1 {
return None;
}
let (a, a2, b, b2) = (a + d * s0, a2 + d * s0, b - d * s1, b2 - d * s1);
Some(vec![[a, b, b2], [a, b2, a2]])
}
(Seg2::Arc { center, radius, start, sweep }, Seg2::Arc { radius: far, .. }) => {
// From `round` off the face out to the moved arc, in pieces whose chords stand off
// the circles by less than `round`: on circles drawn round those circles' chords
// where the arc is convex (the chords then stay off the face and reach past the
// moved arc), on the circles themselves where it is concave.
let near = radius + sur.round * sweep.signum();
let steps = ((sweep.abs() / (8.0 * sur.round / radius.max(far)).sqrt()).ceil() as usize).clamp(1, 256);
let scale = if sweep > 0.0 { 1.0 / (sweep.abs() / steps as f64 / 2.0).cos() } else { 1.0 };
let at = |k: usize, rad: f64| center + Vec2::from_angle(start + sweep * k as f64 / steps as f64) * (rad * scale);
Some((0..steps).flat_map(|k| [[at(k, near), at(k + 1, near), at(k + 1, far)], [at(k, near), at(k + 1, far), at(k, far)]]).collect())
}
_ => None,
}
};
// Many probes on one mesh each: indexed, the same answers as `Mesh::contains`.
let inside: Vec<_> = meshes.iter().map(|m| m.inside_index()).collect();
let inside_any = |p: Vec3| inside.iter().any(|m| m.contains(p));
let mut grow = vec![0.0; segs.len()];
let mut any = false;
for (i, seg) in segs.iter().enumerate() {
if let Seg2::Arc { radius, sweep, .. } = *seg
&& sweep < 0.0
&& radius <= 2.0 * delta
{
// A concave arc too small to shrink by the step.
continue;
for _ in 0..=n {
// Edges in one line move together or not at all.
while let Some(i) = (0..n).find(|&i| grow.get(i) != grow.get((i + 1) % n) && in_line(i)) {
grow.iter_mut().enumerate().filter(|(k, _)| *k == i || *k == (i + 1) % n).for_each(|(_, g)| *g = false);
}
// Is the edge on a body face? Material on exactly one side of it.
let mids: Vec<(Vec2, Vec2)> = [0.25, 0.5, 0.75].iter().filter_map(|t| Some((seg.point_at(*t), out_at(seg, *t)?))).collect();
if mids.len() < 3 {
continue;
}
let side = |k: f64| mids.iter().flat_map(|(m, out)| probe(*m + *out * (delta * k))).map(inside_any).collect::<Vec<bool>>();
let (outer, inner) = (side(0.5), side(-0.5));
let want_outer = op == Operation::Join;
let on_face = outer.iter().all(|x| *x == want_outer) && inner.iter().any(|x| *x != want_outer);
let room = side(1.0).iter().all(|x| *x == want_outer);
if on_face
&& room
&& let Some(g) = grow.get_mut(i)
{
*g = delta;
any = true;
}
}
if !any {
return r.clone();
}
let n = segs.len();
let tangent = |a: &Seg2, b: &Seg2| a.end_tangent().cross(b.start_tangent()).abs() < 1e-6 && a.end_tangent().dot(b.start_tangent()) > 0.0;
// Where segment `i` starts once grown: the corner of it and the one before.
let mut starts = Vec::with_capacity(n);
for i in 0..n {
let (Some(prev), Some(cur), Some(&g0), Some(&g1)) = (segs.get((i + n - 1) % n), segs.get(i), grow.get((i + n - 1) % n), grow.get(i)) else {
if !grow.contains(&true) {
return r.clone();
};
let corner = cur.start();
if g0 == 0.0 && g1 == 0.0 {
starts.push(corner);
continue;
}
if tangent(prev, cur) {
// Moved together, a tangent joint moves along the common normal and stays tangent.
if g0 != g1 {
return r.clone();
let Some(reach) = offset_segs(segs, &grow, delta * 1.25) else { return r.clone() };
let mut dropped = false;
for (i, (seg, moved)) in segs.iter().zip(&reach).enumerate() {
if grow.get(i) == Some(&true)
&& !strip(i, seg, moved).is_some_and(|pieces| gains(&pieces))
&& let Some(g) = grow.get_mut(i)
{
*g = false;
dropped = true;
}
let Some(out) = out_at(cur, 0.0) else { return r.clone() };
starts.push(corner + out * g1);
continue;
}
let (Seg2::Line { a: a0, b: b0 }, Seg2::Line { a: a1, b: b1 }) = (*prev, *cur) else { return r.clone() };
let (Some(d0), Some(d1)) = ((b0 - a0).normalized(), (b1 - a1).normalized()) else { return r.clone() };
let (p0, p1) = (a0 + Vec2::new(d0.y, -d0.x) * g0, a1 + Vec2::new(d1.y, -d1.x) * g1);
let den = d0.cross(d1);
if den.abs() < 1e-12 {
starts.push(p1);
continue;
if !dropped {
return match offset_segs(segs, &grow, delta) {
Some(segs) => Region2 { outer: solidcraft_geom::Loop2 { segs }, holes: r.holes.clone() },
None => r.clone(),
};
}
let t = (p1 - p0).cross(d1) / den;
starts.push(p0 + d0 * t);
}
let mut out = Vec::with_capacity(n);
for (i, seg) in segs.iter().enumerate() {
let (Some(&a), Some(&b), Some(&g)) = (starts.get(i), starts.get((i + 1) % n), grow.get(i)) else { return r.clone() };
out.push(match *seg {
Seg2::Line { .. } => Seg2::Line { a, b },
Seg2::Arc { center, radius, start, sweep } => Seg2::Arc { center, radius: radius + g * sweep.signum(), start, sweep },
_ => return r.clone(),
});
}
Region2 { outer: solidcraft_geom::Loop2 { segs: out }, holes: r.holes.clone() }
r.clone()
}
/// Booleans struggle with coincident faces (a cut starting on the face it cuts, a boss
/// starting on the face it joins). Extending the tool past such an end changes nothing when the
/// space beyond is empty (cut) or already material (join), so do exactly that. Only an end
/// that lies on a target face parallel to it moves, and by less than half the way to the next
/// such face level: the samples can miss material beyond the end (a thin floor between a recess
/// above and a ring pocket below), which a longer tool would cut through.
fn extend_for_coplanar(st: &ModelState, plane: &Plane, r: &Region2, lo: f64, hi: f64, op: Operation, targets: &[String]) -> (f64, f64) {
if !matches!(op, Operation::Cut | Operation::Join) || st.bodies.is_empty() {
return (lo, hi);
}
let bodies: Vec<&ModelBody> = st.bodies.iter().filter(|b| targets.is_empty() || targets.contains(&b.name)).collect();
if bodies.is_empty() {
return (lo, hi);
}
let size = bodies.iter().map(|b| b.body.size()).fold(0.0, f64::max);
/// that lies on a body face parallel to it moves, by less than half the way to the next such
/// face level, and only when the whole slab it gains (asked a quarter further than it moves)
/// is harmless on the bodies' faces (see [`Surroundings::harmless`]): sample points miss a rib
/// beyond the end, a thin floor between a recess above and a ring pocket below, or a hole or a
/// gap behind it.
fn extend_for_coplanar(sur: &Surroundings, plane: &Plane, r: &Region2, lo: f64, hi: f64) -> (f64, f64) {
let size = sur.size;
let delta = (size * 0.01).max(1e-3).min((hi - lo).abs().max(1e-3));
let meshes: Vec<Arc<Mesh>> = bodies.iter().map(|b| b.mesh()).collect();
let samples = region_samples(r);
let n = plane.normal();
// Many probes on one mesh each: indexed, the same answers as `Mesh::contains`.
let inside: Vec<_> = meshes.iter().map(|m| m.inside_index()).collect();
let inside: Vec<_> = sur.bodies.iter().map(|(_, m, _)| m.inside_index()).collect();
let inside_any = |p: Vec3| inside.iter().any(|m| m.contains(p));
// Heights of the targets' faces parallel to the profile plane.
// Heights of the bodies' faces parallel to the profile plane.
let tol = (size * 1e-7).max(1e-9);
let levels: Vec<f64> = meshes
let levels: Vec<f64> = sur
.bodies
.iter()
.map(|(_, m, _)| m)
.flat_map(|m| {
m.triangles.iter().filter_map(|t| {
let [a, b, c] = m.tri(t)?;
@@ -1993,15 +2115,25 @@ fn extend_for_coplanar(st: &ModelState, plane: &Plane, r: &Region2, lo: f64, hi:
let gap = levels.iter().map(|z| (z - h) * dir).filter(|g| *g > tol).fold(f64::INFINITY, f64::min);
delta.min(gap * 0.5)
};
// The profile's triangles; curved sides stand off their chords by up to `chord`.
let chord = (size * 2e-4).max(1e-6);
let straight = std::iter::once(&r.outer).chain(&r.holes).flat_map(|l| &l.segs).all(|s| matches!(s, solidcraft_geom::Seg2::Line { .. }));
let ok_beyond = |h: f64, dir: f64, step: f64| {
samples.iter().all(|s| {
let sampled = samples.iter().all(|s| {
let base = plane.to_world(*s);
[0.5, 1.0].iter().all(|k| {
let p = base + n * (h + dir * step * k);
let inside = inside_any(p);
if op == Operation::Cut { !inside } else { inside }
})
})
[0.5, 1.0].iter().all(|k| inside_any(base + n * (h + dir * step * k)) == (sur.op == Operation::Join))
});
if !sampled {
return false;
}
let pieces = r.triangulate(chord);
// Triangles that leave part of a straight profile uncovered leave it unasked.
let covered: f64 = pieces.iter().map(|[a, b, c]| (*b - *a).cross(*c - *a).abs() * 0.5).sum();
if pieces.is_empty() || (straight && (covered - r.area()).abs() > 1e-7 * r.area()) {
return false;
}
let (h0, h1) = if dir > 0.0 { (h + sur.skin, h + step * 1.25) } else { (h - step * 1.25, h - sur.skin) };
sur.harmless(plane, &pieces, if straight { -sur.skin } else { 2.0 * chord }, h0, h1)
};
let (down, up) = (room(lo, -1.0), room(hi, 1.0));
let lo2 = if down > tol && ok_beyond(lo, -1.0, down) { lo - down } else { lo };
@@ -2932,7 +3064,9 @@ fn feature_tools_with_constructor(
// How far a tool grows past its profile at either end, at most.
let mut growth: f64 = 0.0;
// A face of the targets in the profile's plane (the same for every profile).
let on_face = matches!(operation, Operation::Join | Operation::Cut) && face_level_at(st, &ss.plane, from, &crate::plain(targets));
let plain_targets: Vec<String> = crate::plain(targets);
let on_face = matches!(operation, Operation::Join | Operation::Cut) && face_level_at(st, &ss.plane, from, &plain_targets);
let surroundings = Surroundings::of(st, *operation, &plain_targets);
let tools: Vec<Traced> = regions
.iter()
.map(|r| {
@@ -2956,14 +3090,14 @@ fn feature_tools_with_constructor(
return Ok(roles.tool(exact, None));
}
let (lo, hi) = if sign < 0.0 { (from - len, from) } else { (from, from + len) };
let (l2, h2) = extend_for_coplanar(st, &ss.plane, r, lo, hi, *operation, &crate::plain(targets));
let (l2, h2) = surroundings.as_ref().map_or((lo, hi), |sur| extend_for_coplanar(sur, &ss.plane, r, lo, hi));
let (mut a, b) = if sign < 0.0 { (from - h2, from - l2) } else { (l2 - from, h2 - from) };
// A taper that narrows the tool away from the face widens it as it runs on into it, past
// the profile (where the profile is the whole face, out through the body's sides, #1534):
// then it starts on the face instead.
if a < 0.0 && a * taper.tan() > 0.0 {
let dir = n * sign;
if !taper_run_on_fits(st, &start_plane, dir, r, -a, a * taper.tan(), *operation, &crate::plain(targets)) {
if !taper_run_on_fits(st, &start_plane, dir, r, -a, a * taper.tan(), *operation, &plain_targets) {
a = 0.0;
}
}
@@ -3027,6 +3161,8 @@ fn feature_tools_with_constructor(
let mut footprints = Vec::with_capacity(regions.len());
// Each tool's extent along the normal (they differ where one is run on into a face).
let mut extents: Vec<(f64, f64)> = Vec::with_capacity(regions.len());
let plain_targets: Vec<String> = crate::plain(targets);
let surroundings = Surroundings::of(st, *operation, &plain_targets);
for r in &regions {
if *operation == Operation::Join {
let exact = centered_extrude(&ss.plane, r, lo + off, hi + off, Some(&ss))?;
@@ -3044,14 +3180,32 @@ fn feature_tools_with_constructor(
continue;
}
}
let (l2, h2) = extend_for_coplanar(st, &ss.plane, r, lo + off, hi + off, *operation, &crate::plain(targets));
let (l2, h2) = surroundings.as_ref().map_or((lo + off, hi + off), |sur| extend_for_coplanar(sur, &ss.plane, r, lo + off, hi + off));
let probe = |p: Vec2| (1..4).map(|k| ss.plane.to_world(p) + n * (lo + off + (hi - lo) * k as f64 / 4.0)).collect::<Vec<_>>();
let original = r;
let plain_targets = crate::plain(targets);
let r = grow_profile_for_coplanar(st, r, *operation, &plain_targets, &probe);
let r = fill_void_holes(st, &r, *operation, &plain_targets, &ss.plane, (l2, h2));
let grown = |gains: &dyn Fn(&[[Vec2; 3]]) -> bool| match &surroundings {
Some(sur) => grow_profile_for_coplanar(sur, r, &probe, gains),
None => r.clone(),
};
// A hole of the profile over empty space (a cut) or material (a join) is taken in.
let filled = |p: Region2| fill_void_holes(st, &p, *operation, &plain_targets, &ss.plane, (l2, h2));
// A side's strip runs the tool's height, drawn back by a hair from its ends.
let mut r = filled(grown(&|pieces| {
surroundings.as_ref().is_some_and(|sur| sur.harmless(&ss.plane, pieces, -sur.skin, l2 + sur.skin, h2 - sur.skin))
}));
let mut built = centered_extrude(&ss.plane, &r, l2, h2, Some(&ss));
if built.is_err() {
// A profile the kernel cannot build as drawn (a square some hundredths of a
// micrometre across, away from the origin, defeats truck's plane fit) grows
// wherever the samples find room, as it used to.
let loose = filled(grown(&|_| true));
if loose != r {
built = centered_extrude(&ss.plane, &loose, l2, h2, Some(&ss));
r = loose;
}
}
untrimmed &= l2 == lo + off && h2 == hi + off && &r == original;
tools.extend(centered_extrude(&ss.plane, &r, l2, h2, Some(&ss))?);
tools.extend(built?);
footprints.push(r);
extents.push((l2, h2));
}
@@ -3180,6 +3334,7 @@ fn feature_tools_with_constructor(
let (operation, targets) = feature_op(f);
let axis_w = ss.plane.dir_to_world(d);
let origin_w = ss.plane.to_world(o);
let surroundings = Surroundings::of(st, operation, &targets);
let regions: Vec<Region2> = regions
.iter()
.zip(&region_extents)
@@ -3199,7 +3354,11 @@ fn feature_tools_with_constructor(
})
.collect::<Vec<_>>()
};
Ok(grow_profile_for_coplanar(st, r, operation, &targets, &probe))
// Around the axis the strips are rings, which the faces are not asked about yet.
Ok(match &surroundings {
Some(sur) => grow_profile_for_coplanar(sur, r, &probe, &|_| true),
None => r.clone(),
})
})
.collect::<Result<Vec<_>>>()?;
// To a curved face: turn past the whole target body, then cut back to its surface
@@ -5554,6 +5713,100 @@ mod body_name_tests {
}
}
#[cfg(test)]
mod coplanar_growth_tests {
use super::*;
fn state(bodies: Vec<Body>) -> ModelState {
ModelState { bodies: bodies.into_iter().enumerate().map(|(i, b)| ModelBody::new(format!("Body{i}"), b, 0)).collect(), ..Default::default() }
}
fn rect(x0: f64, y0: f64, x1: f64, y1: f64) -> Region2 {
let p = Vec2::new;
Region2 { outer: solidcraft_geom::Loop2::polygon(&[p(x0, y0), p(x1, y0), p(x1, y1), p(x0, y1)]), holes: Vec::new() }
}
/// The rectangle `x0` … `x1` by `y0` … `y1` with its corners rounded to `rad`, counterclockwise.
fn rounded(x0: f64, y0: f64, x1: f64, y1: f64, rad: f64) -> Region2 {
use solidcraft_geom::Seg2;
let p = Vec2::new;
let quarter = std::f64::consts::FRAC_PI_2;
let arc = |cx: f64, cy: f64, k: f64| Seg2::Arc { center: p(cx, cy), radius: rad, start: k * quarter, sweep: quarter };
let segs = vec![
Seg2::Line { a: p(x0 + rad, y0), b: p(x1 - rad, y0) },
arc(x1 - rad, y0 + rad, 3.0),
Seg2::Line { a: p(x1, y0 + rad), b: p(x1, y1 - rad) },
arc(x1 - rad, y1 - rad, 0.0),
Seg2::Line { a: p(x1 - rad, y1), b: p(x0 + rad, y1) },
arc(x0 + rad, y1 - rad, 1.0),
Seg2::Line { a: p(x0, y1 - rad), b: p(x0, y0 + rad) },
arc(x0 + rad, y0 + rad, 2.0),
];
Region2 { outer: solidcraft_geom::Loop2 { segs }, holes: Vec::new() }
}
/// The extrude's growth of `r` (a cut from the plane z = `z`, `lo` … `hi` along +z), as
/// `feature_tools_with_constructor` asks for it.
fn grown(st: &ModelState, z: f64, r: &Region2, lo: f64, hi: f64) -> (Region2, f64, f64) {
let sur = Surroundings::of(st, Operation::Cut, &[]).unwrap();
let plane = Plane { origin: Vec3::new(0.0, 0.0, z), ..Plane::XY };
let (l2, h2) = extend_for_coplanar(&sur, &plane, r, lo, hi);
let probe = |p: Vec2| (1..4).map(|k| plane.to_world(p) + Vec3::Z * (lo + (hi - lo) * k as f64 / 4.0)).collect::<Vec<_>>();
let gains = |pieces: &[[Vec2; 3]]| sur.harmless(&plane, pieces, -sur.skin, l2 + sur.skin, h2 - sur.skin);
(grow_profile_for_coplanar(&sur, r, &probe, &gains), l2, h2)
}
/// A cut's tool still grows past the faces it lies on where all it gains is empty space: a
/// notch 10 deep into the corner of a 100 × 60 × 30 block, flush with two of its sides,
/// grows out of those sides by 0.5 % of the block's size and up out of its top by 1 %.
/// Cut into the end wall of a tray of that size, flush with the wall's inner face, the
/// strip its side would gain holds the start of the long walls: there it stays as drawn.
#[test]
fn the_tool_grows_only_where_it_gains_nothing() {
let block = kernel::box_solid(Vec3::ZERO, Vec3::new(100.0, 60.0, 30.0)).unwrap();
let st = state(vec![block.clone()]);
let notch = rect(0.0, 0.0, 20.0, 20.0);
let (r, lo, hi) = grown(&st, 30.0, &notch, -10.0, 0.0);
let step = block.size() * 0.005;
for p in [Vec2::new(-0.9 * step, 10.0), Vec2::new(10.0, -0.9 * step), Vec2::new(-0.9 * step, -0.9 * step)] {
assert!(r.contains(p) && !notch.contains(p), "{p:?}: {r:?}");
}
assert!(!r.contains(Vec2::new(20.0 + 0.1 * step, 10.0)) && !r.contains(Vec2::new(10.0, 20.0 + 0.1 * step)), "{r:?}");
assert_eq!(lo, -10.0);
assert!((hi - block.size() * 0.01).abs() < 1e-9, "{hi}");
let inner = kernel::box_solid(Vec3::new(2.0, 2.0, 2.0), Vec3::new(98.0, 58.0, 40.0)).unwrap();
let tray = kernel::boolean(&block, &inner, BoolOp::Cut).unwrap().unwrap();
let st = state(vec![tray]);
let end_wall = rect(-1.0, -1.0, 2.0, 70.0);
assert_eq!(grown(&st, 2.0, &end_wall, 0.0, 40.0), (end_wall, 0.0, 40.0));
}
/// Arcs grow too (#1275), on the same terms. A cut of the whole top face of a 40 × 40 × 20
/// block with corners rounded to 8 grows out of every side, its corners' arcs by the same step
/// as its edges. A post standing 0.1 off one corner's arc, between the points the samples
/// look at, would lose a sliver to the grown arc: there the loop stays as drawn.
#[test]
fn arcs_grow_only_where_they_gain_nothing() {
let face = rounded(0.0, 0.0, 40.0, 40.0, 8.0);
let block = kernel::extrude(&Plane::XY, std::slice::from_ref(&face), 0.0, 20.0).unwrap().remove(0);
let step = block.size() * 0.005;
let st = state(vec![block.clone()]);
let (r, _, _) = grown(&st, 20.0, &face, -5.0, 0.0);
let corner = |k: f64| Vec2::new(8.0, 8.0) + Vec2::from_angle(1.25 * std::f64::consts::PI) * (8.0 + k * step);
for q in [Vec2::new(-0.9 * step, 20.0), Vec2::new(20.0, 40.0 + 0.9 * step), corner(0.9)] {
assert!(r.contains(q) && !face.contains(q), "{q:?}: {r:?}");
}
assert!(!r.contains(corner(1.1)), "{r:?}");
// The post's corner nearest the arc's centre (8, 8) lies 8.1 from it, at 190 degrees; the
// samples look at 202.5, 225 and 247.5 degrees.
let post = kernel::box_solid(Vec3::new(-2.0, 5.0, 0.0), Vec3::new(8.0 - 7.977, 8.0 - 1.407, 20.0)).unwrap();
let st = state(vec![block, post]);
assert_eq!(grown(&st, 20.0, &face, -5.0, 0.0).0, face);
}
}
/// The parts of a body split by a plane, on its positive side first (only the non-empty ones).
/// Each part's cut face is named `section` after the half-space box that cut it, so it takes an
/// attachment that follows the plane (#565); the box's other faces never reach the part.