diff --git a/crates/kernel/src/offset.rs b/crates/kernel/src/offset.rs index 8e5113a5..ae15a750 100644 --- a/crates/kernel/src/offset.rs +++ b/crates/kernel/src/offset.rs @@ -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 { let faces: Vec = 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, Vec, Vec)> { - 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> = HashMap::new(); let mut efaces: HashMap> = 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 = faces.iter().enumerate().map(|(i, f)| chosen.contains(&i) || f.vertex_iter().any(|v| moves(&v.id()))).collect(); + let mut old: Vec> = 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, Vec, Vec)> { + 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 = 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> = 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) { diff --git a/crates/kernel/src/tests.rs b/crates/kernel/src/tests.rs index 7d6186ee..ce8a67ed 100644 --- a/crates/kernel/src/tests.rs +++ b/crates/kernel/src/tests.rs @@ -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]