diff --git a/crates/doc/src/eval.rs b/crates/doc/src/eval.rs index 649f6b60..6f802ce6 100644 --- a/crates/doc/src/eval.rs +++ b/crates/doc/src/eval.rs @@ -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) -> 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, std::cell::OnceCell>>)>, + 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> { + 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 { 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> { + 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> = 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, 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 { - 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 = 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::>(); + 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> { + 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::>(); - 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> = 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 = meshes + let levels: Vec = 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 = 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 = 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 = 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::>(); 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 = regions .iter() .zip(®ion_extents) @@ -3199,7 +3354,11 @@ fn feature_tools_with_constructor( }) .collect::>() }; - 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::>>()?; // 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) -> 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::>(); + 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. diff --git a/crates/engine/src/exact_tool_tests.rs b/crates/engine/src/exact_tool_tests.rs new file mode 100644 index 00000000..dc29b3c4 --- /dev/null +++ b/crates/engine/src/exact_tool_tests.rs @@ -0,0 +1,176 @@ +//! A cut takes exactly the prism its sketch and extent define, and a join adds exactly that, +//! whatever stands beside the tool or beyond its ends. The tool is still grown past a face it +//! lies on (to keep coincident faces out of the boolean), but only where what it gains is empty +//! (a cut) or material (a join) on the bodies' faces, not just at a few sample points. + +use serde_json::{Value, json}; +use solidcraft_geom::Vec3; + +use crate::Session; + +fn run(s: &mut Session, id: &str, p: Value) -> Value { + match s.execute(id, &p) { + Ok(v) => v, + Err(e) => panic!("{id} {p}: {e}"), + } +} + +fn volume(s: &Session, body: &str) -> f64 { + s.model.state().body(body).map(|b| solidcraft_kernel::measure(&b.body).unwrap().volume).unwrap_or(f64::NAN) +} + +fn has(s: &Session, body: &str, at: [f64; 3]) -> bool { + s.model.state().body(body).is_some_and(|b| b.body.contains(Vec3::new(at[0], at[1], at[2]))) +} + +fn bodies(s: &Session) -> Vec { + s.model.state().bodies.iter().map(|b| b.name.clone()).collect() +} + +fn same(got: f64, want: f64) -> bool { + (got - want).abs() <= 1e-9 * want.abs().max(1.0) +} + +/// A rectangle sketched on `plane`, extruded by `distance` along its normal (the other way +/// when negative). +fn rectangle(s: &mut Session, plane: Value, p0: [f64; 2], p1: [f64; 2], distance: f64, operation: &str, targets: Value) { + run(s, "sketch.create", json!({"plane": plane, "name": "Tool"})); + run(s, "sketch.rectangle.two_point", json!({"p0": p0, "p1": p1})); + run(s, "sketch.finish", json!({})); + let direction = if distance < 0.0 { "negative" } else { "positive" }; + let mut p = json!({"sketch": "Tool", "distance": distance.abs(), "direction": direction, "operation": operation}); + if !targets.is_null() { + p["targets"] = targets; + } + run(s, "solid.extrude", p); +} + +/// The plane z = `z`. +fn at_height(z: f64) -> Value { + json!({"origin": [0, 0, z], "x_dir": [1, 0, 0], "y_dir": [0, 1, 0]}) +} + +/// An open tray, `l` × `w` × `h` outside, with a 2 mm floor and 2 mm walls. +fn tray(l: f64, w: f64, h: f64) -> Session { + let mut s = Session::default(); + run(&mut s, "solid.box", json!({"length": l, "width": w, "height": h, "body_name": "Tray"})); + run(&mut s, "solid.box", json!({"corner": [2, 2, 2], "length": l - 4.0, "width": w - 4.0, "height": h, "operation": "cut", "targets": ["Tray"]})); + assert!(same(volume(&s, "Tray"), l * w * h - (l - 4.0) * (w - 4.0) * (h - 2.0))); + s +} + +/// A cut from the tray's floor up, over x −1 … X and all of y, takes the end wall x = 0 … 2 +/// and, past x = 2, the start of the two long walls. At X = 2 its side lies in the end wall's +/// inner face, at 1.9 it leaves 0.1 mm of that wall, at 2.1 it reaches 0.1 mm past it. The +/// tray then starts at x = X and has lost min(X, 2) · W · (H − 2) + max(0, X − 2) · 2 · 2 · +/// (H − 2), whatever its size. (The tool's side used to be grown by 0.5 % of the tray's size +/// where three points along it found room: 0.6 mm of the long walls went with it on a 100 mm +/// tray.) +#[test] +fn a_cut_beside_a_wall_takes_only_its_sketch() { + for (l, w, h, x) in [(100.0, 60.0, 30.0, 1.9), (100.0, 60.0, 30.0, 2.0), (100.0, 60.0, 30.0, 2.1), (1000.0, 600.0, 300.0, 2.0)] { + let mut s = tray(l, w, h); + let before = volume(&s, "Tray"); + rectangle(&mut s, at_height(2.0), [-1.0, -1.0], [x, w + 10.0], h + 10.0, "cut", json!(["Tray"])); + let removed = x.min(2.0) * w * (h - 2.0) + (x - 2.0).max(0.0) * 2.0 * 2.0 * (h - 2.0); + let took = before - volume(&s, "Tray"); + assert!(same(took, removed), "{l} × {w} × {h} cut to x = {x}: took {took} mm³, its sketch {removed} mm³"); + // The long wall starts at the cut; what is left of the end wall and the floor stay. + assert!(has(&s, "Tray", [x + 0.05, 1.0, h / 2.0]) && !has(&s, "Tray", [x - 0.05, 1.0, h / 2.0]), "{l} × {w} × {h} cut to x = {x}"); + assert_eq!(has(&s, "Tray", [(x + 2.0) / 2.0, w / 2.0, h / 2.0]), x < 2.0, "{l} × {w} × {h} cut to x = {x}"); + assert!(has(&s, "Tray", [x / 2.0, w / 2.0, 1.0]), "{l} × {w} × {h} cut to x = {x}: the floor under the cut is gone"); + } +} + +/// What stands beside a cut whose side lies in a face stays: a 0.2 mm web between a slot and +/// the plate's edge, and a 1 mm plate standing 0.8 mm from the face (which the grown tool used +/// to cut in two). +#[test] +fn a_cut_leaves_what_stands_beside_it() { + let mut s = Session::default(); + run(&mut s, "solid.box", json!({"length": 200, "width": 100, "height": 10, "body_name": "Plate"})); + rectangle(&mut s, at_height(0.0), [0.2, 20.0], [10.0, 80.0], 10.0, "cut", Value::Null); + assert!(same(volume(&s, "Plate"), 200.0 * 100.0 * 10.0 - 9.8 * 60.0 * 10.0), "{}", volume(&s, "Plate")); + assert!(has(&s, "Plate", [0.1, 50.0, 5.0]) && !has(&s, "Plate", [0.3, 50.0, 5.0])); + + let mut s = Session::default(); + run(&mut s, "solid.box", json!({"length": 1000, "width": 600, "height": 200, "body_name": "A"})); + run(&mut s, "solid.box", json!({"corner": [1000.8, 0, 0], "length": 1, "width": 600, "height": 200, "operation": "new_body", "body_name": "B"})); + rectangle(&mut s, at_height(0.0), [900.0, 100.0], [1000.0, 500.0], 200.0, "cut", Value::Null); + assert_eq!(bodies(&s), ["A", "B"], "the plate must not be cut in two"); + assert!(same(volume(&s, "A"), 1000.0 * 600.0 * 200.0 - 100.0 * 400.0 * 200.0), "{}", volume(&s, "A")); + assert!(same(volume(&s, "B"), 600.0 * 200.0), "{}", volume(&s, "B")); +} + +/// A join adds only its sketch: a block against a wall narrower than the block adds nothing +/// beside the wall, a block 0.1 mm from a wall leaves the gap, and a pad flush with a block's +/// side does not reach a neighbour 0.8 mm from that side. +#[test] +fn a_join_adds_only_its_sketch() { + let wall = || { + let mut s = Session::default(); + run(&mut s, "solid.box", json!({"corner": [0, 10, 0], "length": 2, "width": 40, "height": 30, "body_name": "Wall"})); + s + }; + let mut s = wall(); + rectangle(&mut s, at_height(0.0), [2.0, 0.0], [20.0, 60.0], 30.0, "join", Value::Null); + assert!(same(volume(&s, "Wall"), 2.0 * 40.0 * 30.0 + 18.0 * 60.0 * 30.0), "{}", volume(&s, "Wall")); + assert!(!has(&s, "Wall", [1.9, 5.0, 15.0]) && !has(&s, "Wall", [1.9, 55.0, 15.0]) && has(&s, "Wall", [1.9, 30.0, 15.0])); + + let mut s = wall(); + rectangle(&mut s, at_height(0.0), [2.1, 10.0], [20.0, 50.0], 30.0, "join", Value::Null); + assert!(same(volume(&s, "Wall"), 2.0 * 40.0 * 30.0 + 17.9 * 40.0 * 30.0), "{}", volume(&s, "Wall")); + assert!(!has(&s, "Wall", [2.05, 30.0, 15.0]), "the 0.1 mm gap is filled"); + + let mut s = Session::default(); + run(&mut s, "solid.box", json!({"length": 300, "width": 200, "height": 100, "body_name": "A"})); + run( + &mut s, + "solid.box", + json!({"corner": [300.8, 0, 0], "length": 99.2, "width": 200, "height": 150, "operation": "new_body", "body_name": "B"}), + ); + rectangle(&mut s, at_height(100.0), [280.0, 50.0], [300.0, 150.0], 20.0, "join", Value::Null); + assert_eq!(bodies(&s), ["A", "B"], "the pad must not bridge the gap"); + assert!(same(volume(&s, "A"), 300.0 * 200.0 * 100.0 + 20.0 * 100.0 * 20.0), "{}", volume(&s, "A")); +} + +/// A window cut down through a plate ends on its underside, where a 4 mm rib hangs below +/// between the points the end used to be checked at: the rib stays whole. +#[test] +fn a_cut_ending_on_a_face_leaves_the_rib_beyond_it() { + let mut s = Session::default(); + run(&mut s, "sketch.create", json!({"plane": {"origin": [0, 0, 0], "x_dir": [1, 0, 0], "y_dir": [0, 0, 1]}, "name": "Section"})); + let tee = [[0, 20], [88, 20], [88, 0], [92, 0], [92, 20], [200, 20], [200, 30], [0, 30]]; + run(&mut s, "sketch.line", json!({"points": tee, "closed": true})); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "solid.extrude", json!({"sketch": "Section", "distance": 200, "body_name": "Tee"})); + let before = volume(&s, "Tee"); + assert!(same(before, (200.0 * 10.0 + 4.0 * 20.0) * 200.0), "{before}"); + rectangle(&mut s, at_height(30.0), [50.0, -150.0], [150.0, -50.0], -10.0, "cut", Value::Null); + assert!(same(before - volume(&s, "Tee"), 100.0 * 100.0 * 10.0), "{}", before - volume(&s, "Tee")); + assert!(has(&s, "Tee", [90.0, -100.0, 19.5]), "the rib under the window is cut"); +} + +/// A hole cut up through a ring from its flat underside (the reference case `60-pipe-flange`). +/// The ring is revolved, so that face is a line swept round the axis, not a plane, but flat +/// all the same: the tool still starts past it and the hole opens it, leaving no film of zero +/// thickness over the hole (two discs of its area more surface). +#[test] +fn a_hole_from_a_revolved_rings_flat_side_opens_it() { + let mut s = Session::default(); + run(&mut s, "sketch.create", json!({"plane": "XZ", "name": "Profile"})); + run(&mut s, "sketch.rectangle.two_point", json!({"p0": [25, 0], "p1": [75, -16]})); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "solid.revolve", json!({"sketch": "Profile", "axis": {"origin": [0, 0, 0], "dir": [0, 0, 1]}, "angle": 360, "body_names": ["Ring"]})); + run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Hole"})); + run(&mut s, "sketch.circle.center", json!({"center": [60, 0], "radius": 7})); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "solid.extrude", json!({"sketch": "Hole", "through_all": true, "direction": "positive", "operation": "cut"})); + let pi = std::f64::consts::PI; + let ring = s.model.state().body("Ring").map(|b| solidcraft_kernel::measure(&b.body).unwrap()).unwrap(); + let want = 2.0 * pi * (75.0 * 75.0 - 25.0 * 25.0 - 7.0 * 7.0) + 2.0 * pi * (75.0 + 25.0 + 7.0) * 16.0; + assert!((ring.area - want).abs() < 1e-3 * want, "area {} instead of {want}", ring.area); + let volume = pi * (75.0 * 75.0 - 25.0 * 25.0 - 7.0 * 7.0) * 16.0; + assert!((ring.volume / volume - 1.0).abs() < 1e-3, "volume {} instead of {volume}", ring.volume); + assert!(!has(&s, "Ring", [60.0, 0.0, 8.0]), "the hole is not cut"); +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index a0c33cd1..ab44c02d 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1019,6 +1019,8 @@ mod derived_units_tests; #[cfg(test)] mod display_mesh_tests; #[cfg(test)] +mod exact_tool_tests; +#[cfg(test)] mod extent_tests; #[cfg(test)] mod face_name_tests; diff --git a/crates/kernel/src/clearance.rs b/crates/kernel/src/clearance.rs index c33c9c96..3a505e3a 100644 --- a/crates/kernel/src/clearance.rs +++ b/crates/kernel/src/clearance.rs @@ -5,6 +5,10 @@ //! body (a cut), or nothing but its material (a join). A [`Clearance`] decides that on the //! other body's faces: every triangle of its mesh is clipped against that space, so a wall, a //! rib or a hole anywhere in it is found. Sample points miss what lies between them. +//! +//! A cut's or join's tool grown past the faces it lies on, to keep coincident faces out of its +//! boolean, is asked the same way (in the document's evaluation): it may gain only empty space +//! (a cut) or material (a join). use std::collections::HashMap; @@ -16,7 +20,7 @@ use crate::offset::{Surf, surf_of}; /// How a body's material sits in a region of space. #[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub(crate) enum Occupancy { +pub enum Occupancy { /// The region holds none of it. Empty, /// The region lies wholly in it. @@ -104,7 +108,7 @@ enum Reach { } /// A body's faces, for telling how its material sits in the space a face moves through. -pub(crate) struct Clearance<'a> { +pub struct Clearance<'a> { body: &'a Body, mesh: Mesh, /// How far a curved face may stand off its triangles. @@ -119,7 +123,7 @@ const MAX_PAIRS: usize = 2_000_000; impl<'a> Clearance<'a> { /// `chord`: the chord error of the mesh the body is asked on. - pub(crate) fn new(body: &'a Body, chord: f64) -> Option> { + pub fn new(body: &'a Body, chord: f64) -> Option> { let mesh = body.tessellate_with(chord, true).ok()?; (!mesh.triangles.is_empty()).then(|| Clearance { body, mesh, sag: 2.0 * chord, tol: (body.size() * 1e-7).max(1e-9), exact: false }) } @@ -140,7 +144,7 @@ impl<'a> Clearance<'a> { /// planar faces square to `along`, each with how far its face reaches past it sideways /// (negative: the triangle's sides are drawn in, so that faces lying against them don't /// count). `Mixed` as soon as a face of the body reaches into that space. - pub(crate) fn swept(&self, faces: &[([Vec3; 3], f64)], along: Vec3, lo: f64, hi: f64) -> Occupancy { + pub fn swept(&self, faces: &[([Vec3; 3], f64)], along: Vec3, lo: f64, hi: f64) -> Occupancy { let Some((first, _)) = faces.first() else { return Occupancy::Mixed }; let base = along.dot(first[0]); // The body's triangles near that space. @@ -264,6 +268,11 @@ impl<'a> Clearance<'a> { return Ok(Reach::Never); } let round = surf_of(f, self.tol * 100.0); + // A flat face stored otherwise (a revolve's end: a line swept round its axis) is + // a plane all the same: its triangles lie in it. + if matches!(round, Some(Surf::Plane { .. })) { + return Ok(Reach::Exact); + } let straight = matches!(round, Some(Surf::Cylinder { a, .. }) if a.dot(along).abs() > 1.0 - 1e-12) || crate::prism::wall_along(&surface, along, self.tol * 10.0, &mut 262_144); Ok(if straight { diff --git a/crates/kernel/src/lib.rs b/crates/kernel/src/lib.rs index 2a0673d4..0f63b8de 100644 --- a/crates/kernel/src/lib.rs +++ b/crates/kernel/src/lib.rs @@ -148,6 +148,7 @@ pub use build::{ PathSeg, box_solid, canonical_revolve_axis, cylinder, extrude, extrude_tapered, extrude_tapered_between, loft, path_polyline, revolve, revolve_whole, sphere, sweep, sweep_with, torus, }; +pub use clearance::{Clearance, Occupancy}; pub use coaxial_overlap::{CoaxialOverlap, coaxial_overlap}; pub use continuity::{ ContinuityReport, ContinuityTolerances, EdgePick, MAX_SAMPLES, MIN_SAMPLES, SeamReport, SeamVerdict, SeamWorst, pick_edges, seam_diagnostics,