Draft: only the faces round the drafted walls must be walls or caps (#1393)

Draft checks only the faces that share a corner with a drafted wall, so rounds and other faces elsewhere on the body no longer refuse it (#390).
This commit is contained in:
Luke Kearney authored and GitHub committed 2026-10-15 13:31:51 -04:00
1 parent d8a088f46a
commit f0c5bfcdf2
2 files changed
+86 -40

No files matched your search

+59 -40
View File
@@ -1120,9 +1120,10 @@ pub(crate) fn offset_body(b: &Body, shift: impl Fn(usize, Vec3) -> f64) -> Resul
/// Draft walls parallel to `pull` (planes, and cylinders about axes along `pull`) by `angle`
/// about their line on the neutral plane: at height h along `pull` a wall moves along its
/// outward normal by −h·tan(angle), so planes tilt and cylinders become cones. The other
/// faces must be caps square to `pull`; the topology is kept, and the result's history is over
/// `[b]` (each face and edge the one it was moved from).
/// outward normal by −h·tan(angle), so planes tilt and cylinders become cones. The faces that
/// share a corner with a drafted wall must be caps square to `pull` or walls; the rest of the
/// body (a rounded edge elsewhere, say) is left as it is. The topology is kept, and the
/// result's history is over `[b]` (each face and edge the one it was moved from).
pub(crate) fn draft_walls(b: &Body, chosen: &[usize], neutral: &solidcraft_geom::Plane, pull: Vec3, angle: f64) -> Result<Body> {
let faces: Vec<mt::Face> = b.solid.face_iter().cloned().collect();
let size = b.size();
@@ -1134,37 +1135,6 @@ pub(crate) fn draft_walls(b: &Body, chosen: &[usize], neutral: &solidcraft_geom:
}
let height = |p: Vec3| (p - neutral.origin).dot(nn) / den;
let tan = angle.tan();
let mut old = Vec::with_capacity(faces.len());
for (i, f) in faces.iter().enumerate() {
let sf = surf_of(f, tol * 100.0).ok_or_else(|| fail("draft: only bodies with planar and cylindrical faces"))?;
let wall = match sf {
Surf::Plane { n, .. } => n.dot(pull).abs() < 1e-9,
Surf::Cylinder { a, .. } => a.dot(pull).abs() > 1.0 - 1e-9,
Surf::Sphere { .. } | Surf::Cone { .. } | Surf::Torus { .. } => false,
};
let cap = matches!(sf, Surf::Plane { n, .. } if n.dot(pull).abs() > 1.0 - 1e-9);
if chosen.contains(&i) && !wall {
return Err(fail("draft: a chosen face isn't parallel to the pull direction"));
}
if !chosen.contains(&i) && !cap && !wall {
return Err(fail("draft: faces next to the drafted walls must be square to the pull direction"));
}
old.push(sf);
}
// Shifts and moved surfaces at a height.
let at = |ids: &[usize], h: f64| -> Option<(Vec<Surf>, Vec<f64>, Vec<Surf>)> {
let mut o = Vec::new();
let mut s = Vec::new();
let mut n = Vec::new();
for i in ids {
let sf = *old.get(*i)?;
let sh = if chosen.contains(i) { -h * tan } else { 0.0 };
o.push(sf);
s.push(sh);
n.push(sf.moved(sh)?);
}
Some((o, s, n))
};
let mut vfaces: HashMap<mt::VertexID, Vec<usize>> = HashMap::new();
let mut efaces: HashMap<mt::EdgeID, Vec<usize>> = HashMap::new();
for (i, f) in faces.iter().enumerate() {
@@ -1181,9 +1151,53 @@ pub(crate) fn draft_walls(b: &Body, chosen: &[usize], neutral: &solidcraft_geom:
}
}
}
// A corner moves when one of its faces is drafted; the faces round a moving corner must be
// walls or caps for the corner to follow them. Faces away from the drafted walls may be
// anything: they keep their surface and their edges.
let moves = |vid: &mt::VertexID| vfaces.get(vid).is_some_and(|fs| fs.iter().any(|i| chosen.contains(i)));
let touched: Vec<bool> = faces.iter().enumerate().map(|(i, f)| chosen.contains(&i) || f.vertex_iter().any(|v| moves(&v.id()))).collect();
let mut old: Vec<Option<Surf>> = Vec::with_capacity(faces.len());
for (i, f) in faces.iter().enumerate() {
if !touched.get(i).copied().unwrap_or(false) {
old.push(None);
continue;
}
let sf = surf_of(f, tol * 100.0).ok_or_else(|| fail("draft: the faces next to the drafted walls must be planes or cylinders"))?;
let wall = match sf {
Surf::Plane { n, .. } => n.dot(pull).abs() < 1e-9,
Surf::Cylinder { a, .. } => a.dot(pull).abs() > 1.0 - 1e-9,
Surf::Sphere { .. } | Surf::Cone { .. } | Surf::Torus { .. } => false,
};
let cap = matches!(sf, Surf::Plane { n, .. } if n.dot(pull).abs() > 1.0 - 1e-9);
if chosen.contains(&i) && !wall {
return Err(fail("draft: a chosen face isn't parallel to the pull direction"));
}
if !chosen.contains(&i) && !cap && !wall {
return Err(fail("draft: faces next to the drafted walls must be square to the pull direction"));
}
old.push(Some(sf));
}
// Shifts and moved surfaces at a height.
let at = |ids: &[usize], h: f64| -> Option<(Vec<Surf>, Vec<f64>, Vec<Surf>)> {
let mut o = Vec::new();
let mut s = Vec::new();
let mut n = Vec::new();
for i in ids {
let sf = (*old.get(*i)?)?;
let sh = if chosen.contains(i) { -h * tan } else { 0.0 };
o.push(sf);
s.push(sh);
n.push(sf.moved(sh)?);
}
Some((o, s, n))
};
let mut newpos: HashMap<mt::VertexID, Vec3> = HashMap::new();
for v in b.solid.vertex_iter() {
let p = from_p3(v.point());
if !moves(&v.id()) {
newpos.insert(v.id(), p);
continue;
}
let fs = vfaces.get(&v.id()).ok_or_else(|| fail("vertex"))?;
let (o, s, n) = at(fs, height(p)).ok_or_else(|| fail("draft: a wall shrinks to nothing"))?;
let q = move_point(p, &o, &s, &n, size * 1e-6).ok_or_else(|| fail("draft: a corner whose faces don't meet the same way after the draft"))?;
@@ -1192,10 +1206,8 @@ pub(crate) fn draft_walls(b: &Body, chosen: &[usize], neutral: &solidcraft_geom:
// New surfaces for the drafted walls: planes through their hinge line, cylinders → cones.
let mut surfaces: Vec<Option<mt::Surface>> = vec![None; faces.len()];
for (i, sf) in old.iter().enumerate() {
if !chosen.contains(&i) {
continue;
}
let surface = match *sf {
let (true, Some(sf)) = (chosen.contains(&i), *sf) else { continue };
let surface = match sf {
Surf::Plane { n, d } => {
let n2 = (n * angle.cos() + pull * angle.sin()).normalized().ok_or_else(|| fail("draft"))?;
// A point of the old plane on the neutral plane: the hinge.
@@ -1248,7 +1260,14 @@ pub(crate) fn draft_walls(b: &Body, chosen: &[usize], neutral: &solidcraft_geom:
continue;
}
let (Some(a), Some(c)) = (verts.get(&e.absolute_front().id()), verts.get(&e.absolute_back().id())) else { return Err(fail("edge")) };
let kind = arc_mid(&e, size * 1e-6).ok_or_else(|| fail("draft: edges must be lines or arcs"))?;
// An edge whose ends stay keeps its curve (it may be anything: a blend's spline).
if !moves(&e.absolute_front().id()) && !moves(&e.absolute_back().id()) {
let ne = mt::Edge::try_new(a, c, e.curve().clone()).map_err(|e| fail(&e.to_string()))?;
rec.piece(&ne, &e);
edges.insert(e.id(), ne);
continue;
}
let kind = arc_mid(&e, size * 1e-6).ok_or_else(|| fail("draft: edges next to the drafted walls must be lines or arcs"))?;
let ne = match kind {
None => builder::line(a, c),
Some(m) => {
@@ -1277,7 +1296,7 @@ pub(crate) fn draft_walls(b: &Body, chosen: &[usize], neutral: &solidcraft_geom:
Some(mut surface) => {
// Outward like the old face (checked at a boundary point).
let probe = f.boundaries().first().and_then(|w| w.vertex_iter().next()).map(|v| from_p3(v.point()));
let want = probe.and_then(|p| old.get(i).and_then(|s| s.normal(p)));
let want = probe.and_then(|p| old.get(i).copied().flatten().and_then(|s| s.normal(p)));
if let (Some(p), Some(want)) = (probe, want) {
use mt::{ParametricSurface3D, SearchNearestParameter};
if let Some((u, v)) = surface.search_nearest_parameter(p3(p), None, 100) {
+27
View File
@@ -1753,6 +1753,33 @@ fn draft_of_a_round_ended_pocket() {
assert!(rel(v, 80.0 * 60.0 * 25.0 - pocket) < 1e-4, "{v} vs {}", 80.0 * 60.0 * 25.0 - pocket);
}
/// Rounded edges elsewhere on the body don't stop a draft: only the faces round the drafted
/// walls' corners must be walls or caps, the rest keep their surfaces and edges.
#[test]
fn draft_of_a_pocket_on_a_body_with_a_far_rounded_edge() {
let block = box_solid(Vec3::ZERO, Vec3::new(80.0, 60.0, 25.0)).unwrap();
let tool = box_solid(Vec3::new(25.0, 20.0, 15.0), Vec3::new(55.0, 40.0, 30.0)).unwrap();
let b = boolean(&block, &tool, BoolOp::Cut).unwrap().unwrap();
// The bottom edge along x at y = 0: a cylinder (and two sphere-free ends) far from the pocket.
let r = 2.0;
let b = fillet(&b, &[Vec3::new(40.0, 0.0, 0.0)], r).unwrap();
let faces_before = measure(&b).unwrap().merged.faces;
let walls = [Vec3::new(25.0, 30.0, 20.0), Vec3::new(40.0, 20.0, 20.0), Vec3::new(55.0, 30.0, 20.0), Vec3::new(40.0, 40.0, 20.0)];
let a = 5f64.to_radians();
let d = draft(&b, &walls, &Plane::XY.offset(25.0), Vec3::Z, a).unwrap();
// The pocket narrows going down: at depth t its walls stand t·tan(a) further in.
let (n, mut pocket) = (2000, 0.0);
for k in 0..n {
let e = (k as f64 + 0.5) / n as f64 * 10.0 * a.tan();
pocket += (30.0 - 2.0 * e) * (20.0 - 2.0 * e) * 10.0 / n as f64;
}
let round = (r * r - PI * r * r / 4.0) * 80.0;
let want = 80.0 * 60.0 * 25.0 - round - pocket;
let m = measure(&d).unwrap();
assert!(rel(m.volume, want) < 1e-4, "{} vs {want}", m.volume);
assert_eq!(m.merged.faces, faces_before, "the round and every other face survive");
}
/// A round wall is stored in pieces (a cylinder's two halves); one pick drafts all of it (#547).
/// A hole drafted from the top narrows going down: a frustum from R 10 to 10 − 25 · tan 5°.
#[test]