From 141f3acc854fc61774d37055d5d329b0a15d38b1 Mon Sep 17 00:00:00 2001 From: bherbruck Date: Fri, 9 Oct 2026 21:42:37 +0000 Subject: [PATCH] Split pieces stay separate faces: Measure counts them apart and Offset Face moves only the picked piece MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The merged face count no longer joins faces across a Split Face line, and Offset Face's same-surface grouping stops there. A piece whose move the body's topology cannot follow (its neighbour stays put) is swept along its normal and joined on or cut in, giving walls along the split line as in Fusion. Engine test: a split box top counts 7 faces; one half up 5 (27000 mm³), the other down 3 (25200 mm³). Fixes #12 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RjGw4gA5ZvKrU8MQP6sBgT --- crates/engine/src/split_face_tests.rs | 17 +++++++++++++++++ crates/kernel/src/heal.rs | 2 +- crates/kernel/src/offset.rs | 10 ++++++++-- crates/kernel/src/polyhedron.rs | 23 +++++++++++++++++++++-- crates/kernel/src/topo.rs | 4 ++++ 5 files changed, 51 insertions(+), 5 deletions(-) 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()) };