diff --git a/crates/engine/src/split_face_tests.rs b/crates/engine/src/split_face_tests.rs index 0566c792..051d4228 100644 --- a/crates/engine/src/split_face_tests.rs +++ b/crates/engine/src/split_face_tests.rs @@ -80,3 +80,20 @@ fn export_keeps_split_faces_through_later_features() { // Six box faces, the top's second piece and the fillet. assert_eq!(text.matches("ADVANCED_FACE(").count(), 8, "{text}"); } + +#[test] +fn split_pieces_count_and_move_apart() { + let mut s = boxed(); + run(&mut s, "solid.split_face", json!({"faces": [[10, 15, 20]], "plane": {"origin": [20, 0, 0], "normal": [1, 0, 0]}})); + // Measure counts the two halves of the top as two faces. + let m = run(&mut s, "inspect.measure", json!({ "bodies": ["B"] }))["bodies"][0].clone(); + assert_eq!(m["faces"].as_u64(), Some(7), "{m}"); + // Offset Face on one half moves that half only (Fusion): a 20 × 30 step 5 high. + run(&mut s, "solid.offset_face", json!({"faces": [[10, 15, 20]], "distance": 5})); + let (_, v) = faces_volume(&mut s, "B"); + assert!((v - 27000.0).abs() < 1e-6 * 27000.0, "{v}"); + // And the other half down 3: a 20 × 30 pocket. + run(&mut s, "solid.offset_face", json!({"faces": [[30, 15, 20]], "distance": -3})); + let (_, v) = faces_volume(&mut s, "B"); + assert!((v - 25200.0).abs() < 1e-6 * 25200.0, "{v}"); +} diff --git a/crates/kernel/src/heal.rs b/crates/kernel/src/heal.rs index d7cc2f20..fcd17878 100644 --- a/crates/kernel/src/heal.rs +++ b/crates/kernel/src/heal.rs @@ -91,7 +91,7 @@ pub(crate) fn on_split_line(pts: &[Vec3], keep: &[Vec]) -> bool { } /// Points along an edge (ends and three inside). -fn edge_points(e: &mt::Edge) -> Vec { +pub(crate) fn edge_points(e: &mt::Edge) -> Vec { use mt::{BoundedCurve, ParametricCurve}; let c = e.curve(); let (t0, t1) = c.range_tuple(); diff --git a/crates/kernel/src/offset.rs b/crates/kernel/src/offset.rs index c4c79c00..8d2968d9 100644 --- a/crates/kernel/src/offset.rs +++ b/crates/kernel/src/offset.rs @@ -746,7 +746,7 @@ pub(crate) fn draft_walls(b: &Body, chosen: &[usize], neutral: &solvecraft_geom: } /// The chosen faces and every face on the same surface joined to them (the halves of a hole's -/// wall), as face indices. +/// wall), as face indices. Pieces of a face split on purpose (Split Face) stay apart. pub fn with_same_surface(b: &Body, chosen: &[usize]) -> Vec { let faces: Vec = b.solid.face_iter().cloned().collect(); let size = b.size(); @@ -766,6 +766,7 @@ pub fn with_same_surface(b: &Body, chosen: &[usize]) -> Vec { } _ => false, }; + let keep = b.split_keep(); let mut out: Vec = chosen.to_vec(); let mut grew = true; while grew { @@ -777,7 +778,12 @@ pub fn with_same_surface(b: &Body, chosen: &[usize]) -> Vec { let Some(Some(si)) = surfs.get(i) else { continue }; let joins = out.iter().any(|j| { surfs.get(*j).and_then(|x| x.as_ref()).is_some_and(|sj| same(si, sj)) - && faces.get(*j).is_some_and(|g| g.edge_iter().any(|e| f.edge_iter().any(|x| x.id() == e.id()))) + && faces.get(*j).is_some_and(|g| { + g.edge_iter().any(|e| { + f.edge_iter().any(|x| x.id() == e.id()) + && (keep.is_empty() || !crate::heal::on_split_line(&crate::heal::edge_points(&e), keep)) + }) + }) }); if joins { out.push(i); diff --git a/crates/kernel/src/polyhedron.rs b/crates/kernel/src/polyhedron.rs index ab473516..5e8f146d 100644 --- a/crates/kernel/src/polyhedron.rs +++ b/crates/kernel/src/polyhedron.rs @@ -422,7 +422,7 @@ pub fn offset_faces(b: &Body, at: &[Vec3], distance: f64) -> Result { if !distance.is_finite() || distance.abs() > 1e6 { return Err(KernelError::Invalid("offset distance".into())); } - let healed = Body::new(crate::heal::heal_keep(b.deep_copy(), b.size(), b.split_keep()))?; + let healed = Body::new(crate::heal::heal_keep(b.deep_copy(), b.size(), b.split_keep()))?.with_split_lines(b.splits.clone()); let size = healed.size(); let mesh = healed.tessellate((size * 1e-3).max(1e-3))?; let mut chosen: Vec = Vec::new(); @@ -443,5 +443,24 @@ pub fn offset_faces(b: &Body, at: &[Vec3], distance: f64) -> Result { } } let chosen = crate::offset::with_same_surface(&healed, &chosen); - offset_planar(&healed, |fi, _| if chosen.contains(&fi) { distance } else { 0.0 }) + match offset_planar(&healed, |fi, _| if chosen.contains(&fi) { distance } else { 0.0 }) { + Ok(r) => Ok(r), + // A piece of a split face (its neighbour stays): the piece swept along its normal + // joins on (outward) or cuts in, with walls along the split lines. + Err(e) if !healed.split_keep().is_empty() => press_pieces(&healed, &chosen, distance).map_err(|_| e), + Err(e) => Err(e), + } +} + +fn press_pieces(b: &Body, chosen: &[usize], distance: f64) -> Result { + let faces: Vec = b.solid.face_iter().cloned().collect(); + let mut cur = b.clone(); + for fi in chosen { + let f = faces.get(*fi).ok_or_else(|| KernelError::Failed("offset: face".into()))?; + let n = crate::blend::plane_normal(f).ok_or_else(|| KernelError::Failed("offset: only planar pieces".into()))?; + let tool = guard("offset face", || Body::new(builder::tsweep(f, crate::body::v3(n * distance))))?; + let op = if distance > 0.0 { crate::BoolOp::Union } else { crate::BoolOp::Cut }; + cur = crate::ops::boolean(&cur, &tool, op)?.ok_or_else(|| KernelError::Failed("offset: the piece moved off the body".into()))?; + } + Ok(cur.with_split_lines(b.splits.clone())) } diff --git a/crates/kernel/src/topo.rs b/crates/kernel/src/topo.rs index 97e9ceea..50a3ea36 100644 --- a/crates/kernel/src/topo.rs +++ b/crates/kernel/src/topo.rs @@ -447,10 +447,14 @@ pub fn merged_topology(b: &Body, mesh: &Mesh) -> Result { }); smooth && seen > 0 }; + // Pieces of a face split on purpose count apart. + let b_keep = b.split_keep(); + let keep_split = !b_keep.is_empty(); let mut fp: Vec = (0..nf).collect(); for (ei, faces) in &edge_faces { let other = |f: usize| matches!(surfs.get(f), Some(Surf::Other(_))); if let [a, b] = faces[..] + && !(keep_split && poly(*ei).is_some_and(|pl| crate::heal::on_split_line(pl, b_keep))) && (same_surface(&surfs, &verts, poly(*ei), a, b, tol * 10.0) || (other(a) && other(b) && exact_smooth(a, b, poly(*ei)) && { let (Some(va), Some(vb)) = (verts.get(a), verts.get(b)) else { return Ok(TopoCounts::default()) };