fix: a cut takes only its sketch, a join adds only it (#1310)
Thanks @NOWN-DEV.
This commit is contained in:
1 parent
0a66697da8
commit
8a2d142333
5 files changed
+569
-128
No files matched your search
+377
-124
@@ -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 ®ions {
|
||||
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(®ion_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, ¬ch, -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.
|
||||
|
||||
Reference in new issue
Block a user