From 4c8f080691ad7fb7736647df5cabc5d779205956 Mon Sep 17 00:00:00 2001 From: bherbruck Date: Fri, 9 Oct 2026 21:31:16 +0000 Subject: [PATCH] Faces split on purpose stay split: the body remembers its Split Face lines, export and healing keep the pieces apart Split Face records the new edges as polylines on the body (Body::split_lines); the document carries them through later features. STEP export no longer joins pieces of one surface across such a line, and healing (fillets, chamfers, booleans, shell, draft) no longer merges coplanar pieces there, while incidental pieces (boolean leftovers, seam halves) still merge as before. Tests: plane and cylinder splits keep their face count through a STEP round trip (and join without the record), a second split keeps both lines, and an engine test exports a split box after a fillet with 8 faces. Fixes #11 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RjGw4gA5ZvKrU8MQP6sBgT --- crates/doc/src/eval.rs | 6 ++++ crates/engine/src/split_face_tests.rs | 18 ++++++++++ crates/kernel/src/blend.rs | 4 +-- crates/kernel/src/blend_corner.rs | 2 +- crates/kernel/src/body.rs | 21 +++++++++++- crates/kernel/src/heal.rs | 32 ++++++++++++++++-- crates/kernel/src/meshbody.rs | 4 +-- crates/kernel/src/ops.rs | 3 +- crates/kernel/src/polyhedron.rs | 6 ++-- crates/kernel/src/splitface.rs | 48 ++++++++++++++++++++++++++- crates/kernel/src/step_out.rs | 15 +++++++-- crates/kernel/src/surfaces.rs | 4 +-- 12 files changed, 144 insertions(+), 19 deletions(-) diff --git a/crates/doc/src/eval.rs b/crates/doc/src/eval.rs index 86158b33..6756277d 100644 --- a/crates/doc/src/eval.rs +++ b/crates/doc/src/eval.rs @@ -283,6 +283,12 @@ impl ModelState { (key, &b.name).hash(&mut h); b.key = h.finish(); b.names = crate::naming::NameCell::new(Some(origin.clone())); + // Faces split on purpose stay split through later features (for export). + if b.body.split_lines().is_none() + && let Some(lines) = before.bodies.iter().find(|x| x.name == b.name).and_then(|x| x.body.split_lines()) + { + b.body = b.body.clone().with_split_lines(Some(lines.clone())); + } // An imported look outlives the operations on the body. if b.body.paint().is_none() && b.body.color().is_none() diff --git a/crates/engine/src/split_face_tests.rs b/crates/engine/src/split_face_tests.rs index 721ad4c5..0566c792 100644 --- a/crates/engine/src/split_face_tests.rs +++ b/crates/engine/src/split_face_tests.rs @@ -62,3 +62,21 @@ fn a_tool_that_misses_is_refused() { assert!(s.execute("solid.split_face", &json!({"faces": [[10, 15, 20]], "plane": {"origin": [100, 0, 0], "normal": [1, 0, 0]}})).is_err()); assert_eq!(s.doc.features.len(), n); } + +#[test] +fn export_keeps_split_faces_through_later_features() { + let mut s = boxed(); + run(&mut s, "solid.split_face", json!({"faces": [[10, 15, 20]], "plane": {"origin": [20, 0, 0], "normal": [1, 0, 0]}})); + // A later feature rebuilds the body; the split stays a split in the file. + run(&mut s, "solid.fillet", json!({"edges": [[20, 0, 0]], "radius": 2})); + let (f, _) = faces_volume(&mut s, "B"); + assert_eq!(f, 8); + let dir = std::env::temp_dir().join(format!("solvecraft-split-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let step = dir.join("split.step"); + run(&mut s, "file.export", json!({"path": step.to_string_lossy()})); + let text = std::fs::read_to_string(&step).unwrap(); + let _ = std::fs::remove_dir_all(&dir); + // Six box faces, the top's second piece and the fillet. + assert_eq!(text.matches("ADVANCED_FACE(").count(), 8, "{text}"); +} diff --git a/crates/kernel/src/blend.rs b/crates/kernel/src/blend.rs index 55bc455c..8472bd09 100644 --- a/crates/kernel/src/blend.rs +++ b/crates/kernel/src/blend.rs @@ -285,7 +285,7 @@ fn blend(body: &Body, edges: &[Vec3], r: f64, shape: Shape, what: &str) -> Resul return Err(KernelError::Invalid("too many edges".into())); } // Coplanar neighbours as one face (as after a join), so corners look as they should. - let mut cur = Body::new(crate::heal::heal(body.deep_copy(), body.size()))?; + let mut cur = Body::new(crate::heal::heal_keep(body.deep_copy(), body.size(), body.split_keep()))?; // Every edge of a convex planar body: rounded as a whole (sphere corners). if shape == Shape::Round && edges.len() >= 6 { let solid = cur.deep_copy(); @@ -700,7 +700,7 @@ pub fn chamfer(body: &Body, edges: &[Vec3], distance: f64) -> Result { } } // Coplanar neighbours as one face (as after a join), so corners look as they should. - let mut cur = Body::new(crate::heal::heal(body.deep_copy(), body.size()))?; + let mut cur = Body::new(crate::heal::heal_keep(body.deep_copy(), body.size(), body.split_keep()))?; if let Some(groups) = loop_groups(&cur, edges)? { for g in groups { let size = cur.size(); diff --git a/crates/kernel/src/blend_corner.rs b/crates/kernel/src/blend_corner.rs index 05e5d36f..00db94fc 100644 --- a/crates/kernel/src/blend_corner.rs +++ b/crates/kernel/src/blend_corner.rs @@ -33,7 +33,7 @@ pub fn fillet_radii(body: &Body, edges: &[(Vec3, f64)]) -> Result { if edges.iter().any(|(p, r)| !(p.is_finite() && r.is_finite() && *r > 1e-6 && *r < 1e6)) { return Err(KernelError::Invalid("fillet size must be positive".into())); } - let mut cur = Body::new(crate::heal::heal(body.deep_copy(), body.size()))?; + let mut cur = Body::new(crate::heal::heal_keep(body.deep_copy(), body.size(), body.split_keep()))?; let mut sel = edges.to_vec(); let mut first: Option = None; // Earlier rounds at the edges' ends come off and are blended again with the new edges. diff --git a/crates/kernel/src/body.rs b/crates/kernel/src/body.rs index 2c45fc4a..40dfbc00 100644 --- a/crates/kernel/src/body.rs +++ b/crates/kernel/src/body.rs @@ -19,6 +19,9 @@ pub struct Body { pub(crate) color: Option<[f32; 3]>, /// Opacity and face colours for export (set by the document; operations drop it). pub(crate) paint: Option>, + /// Lines the user split faces along (Split Face), as polylines: export keeps the faces on + /// either side apart where it would otherwise join pieces of one surface. + pub(crate) splits: Option>>>, } /// How a body looks beyond its colour, for export: its opacity and its faces' own colours. @@ -278,6 +281,7 @@ impl Body { } && self.color == other.color && self.paint == other.paint + && self.splits == other.splits } pub(crate) fn new(solid: Solid) -> Result { @@ -302,7 +306,7 @@ impl Body { if v < 0.0 { solid.not(); } - Ok(Body { solid: std::sync::Arc::new(solid), mesh: None, color: None, paint: None }) + Ok(Body { solid: std::sync::Arc::new(solid), mesh: None, color: None, paint: None, splits: None }) } /// A deep copy of the solid that can be mutated without affecting other bodies. @@ -335,6 +339,21 @@ impl Body { self } + /// Lines faces were split along on purpose (Split Face), as polylines. + pub fn split_lines(&self) -> Option<&std::sync::Arc>>> { + self.splits.as_ref() + } + + pub(crate) fn split_keep(&self) -> &[Vec] { + self.splits.as_ref().map(|l| l.as_slice()).unwrap_or(&[]) + } + + /// The same body with the lines its faces were split along on purpose. + pub fn with_split_lines(mut self, s: Option>>>) -> Body { + self.splits = s.filter(|l| !l.is_empty()); + self + } + /// Opacity and face colours for export, if any. pub fn paint(&self) -> Option<&Paint> { self.paint.as_deref() diff --git a/crates/kernel/src/heal.rs b/crates/kernel/src/heal.rs index 02e053ca..d7cc2f20 100644 --- a/crates/kernel/src/heal.rs +++ b/crates/kernel/src/heal.rs @@ -79,7 +79,26 @@ fn inside(p: Vec3, edges: &[mt::Edge], n: Vec3) -> bool { /// Merge each group of edge-connected coplanar faces into one face. Returns None when nothing /// changed or the result would not be a valid solid. -fn merge_coplanar(shell: &mt::Shell, tol: f64) -> Option { +/// Do the points lie along one of the lines faces were split along on purpose (polylines; +/// chords of a curved line sag by under a tenth of their length)? +pub(crate) fn on_split_line(pts: &[Vec3], keep: &[Vec]) -> bool { + !pts.is_empty() + && keep.iter().any(|line| { + let seg = line.windows(2).filter_map(|w| Some(w.first()?.dist(*w.get(1)?))).fold(0.0, f64::max); + let tol = 1e-6 * (1.0 + seg) + if line.len() > 2 { seg * 0.1 } else { 0.0 }; + pts.iter().all(|q| line.windows(2).any(|w| w.first().zip(w.get(1)).is_some_and(|(a, b)| q.dist_to_segment(*a, *b) <= tol))) + }) +} + +/// Points along an edge (ends and three inside). +fn edge_points(e: &mt::Edge) -> Vec { + use mt::{BoundedCurve, ParametricCurve}; + let c = e.curve(); + let (t0, t1) = c.range_tuple(); + (0..=4).map(|k| from_p3(c.subs(t0 + (t1 - t0) * k as f64 / 4.0))).collect() +} + +fn merge_coplanar(shell: &mt::Shell, tol: f64, keep: &[Vec]) -> Option { let faces: Vec = shell.face_iter().cloned().collect(); let planes: Vec> = faces.iter().map(plane_of).collect(); let mut parent: Vec = (0..faces.len()).collect(); @@ -90,9 +109,11 @@ fn merge_coplanar(shell: &mt::Shell, tol: f64) -> Option { } } let mut merged_any = false; - for fs in by_edge.values() { + let edges: HashMap = shell.edge_iter().map(|e| (e.id(), e)).collect(); + for (id, fs) in &by_edge { if let [a, b] = fs[..] && a != b + && (keep.is_empty() || !edges.get(id).is_some_and(|e| on_split_line(&edge_points(e), keep))) && let (Some(Some((na, da))), Some(Some((nb, db)))) = (planes.get(a), planes.get(b)) && na.dot(*nb) > 1.0 - 1e-9 && (da - db).abs() < tol @@ -378,6 +399,11 @@ fn planar_surfaces(shell: &mt::Shell, tol: f64) -> Option { /// Heal a solid; returns the input unchanged when nothing applies or healing fails. pub fn heal(solid: Solid, size: f64) -> Solid { + heal_keep(solid, size, &[]) +} + +/// [`heal`], keeping faces apart along lines they were split along on purpose. +pub(crate) fn heal_keep(solid: Solid, size: f64, keep: &[Vec]) -> Solid { let tol = (size * 1e-7).max(1e-9); let mut shells = Vec::new(); let mut changed = false; @@ -391,7 +417,7 @@ pub fn heal(solid: Solid, size: f64) -> Solid { cur = m; changed = true; } - if let Some(m) = merge_coplanar(&cur, tol) { + if let Some(m) = merge_coplanar(&cur, tol, keep) { cur = m; changed = true; } diff --git a/crates/kernel/src/meshbody.rs b/crates/kernel/src/meshbody.rs index b275adb6..babf4a83 100644 --- a/crates/kernel/src/meshbody.rs +++ b/crates/kernel/src/meshbody.rs @@ -101,7 +101,7 @@ pub fn mesh_body(positions: &[Vec3], triangles: &[[u32; 3]]) -> Result { t.swap(1, 2); } } - Ok(Body { solid: Arc::new(crate::body::Solid::new_unchecked(Vec::new())), mesh: Some(Arc::new(out)), color: None, paint: None }) + Ok(Body { solid: Arc::new(crate::body::Solid::new_unchecked(Vec::new())), mesh: Some(Arc::new(out)), color: None, paint: None, splits: None }) } /// Wind every connected piece one way: a triangle that runs a side in the same direction as @@ -185,6 +185,6 @@ impl Body { t.swap(1, 2); } } - Some(Body { solid: self.solid.clone(), mesh: Some(Arc::new(out)), color: self.color, paint: self.paint.clone() }) + Some(Body { solid: self.solid.clone(), mesh: Some(Arc::new(out)), color: self.color, paint: self.paint.clone(), splits: self.splits.clone() }) } } diff --git a/crates/kernel/src/ops.rs b/crates/kernel/src/ops.rs index 2f97db93..98ca7f8d 100644 --- a/crates/kernel/src/ops.rs +++ b/crates/kernel/src/ops.rs @@ -241,6 +241,7 @@ pub fn boolean(a: &Body, b: &Body, op: BoolOp) -> Result> { } } let mut empty_votes = 0; + let keep: Vec> = a.split_keep().iter().chain(b.split_keep()).cloned().collect(); for (attempt, j) in JITTER.iter().enumerate() { let shift = mt::Vector3::new(j[0], j[1], j[2]) * (size * 0.01); for k in [2e-3, 5e-4] { @@ -267,7 +268,7 @@ pub fn boolean(a: &Body, b: &Body, op: BoolOp) -> Result> { empty_votes += 1; last = "empty result".into(); } - Ok(s) => match Body::new(crate::heal::heal(s, size)) { + Ok(s) => match Body::new(crate::heal::heal_keep(s, size, &keep)) { Ok(body) => { let (ok, v) = check(&body); if ok { diff --git a/crates/kernel/src/polyhedron.rs b/crates/kernel/src/polyhedron.rs index 8bfe9803..ab473516 100644 --- a/crates/kernel/src/polyhedron.rs +++ b/crates/kernel/src/polyhedron.rs @@ -192,7 +192,7 @@ pub fn draft(b: &Body, faces: &[Vec3], neutral: &Plane, pull: Vec3, angle: f64) /// Draft of a non-convex body: walls (planes, cylinders) next to caps square to `pull`. fn draft_walls(b: &Body, at: &[Vec3], neutral: &Plane, pull: Vec3, angle: f64) -> Result { - let healed = Body::new(crate::heal::heal(b.deep_copy(), b.size()))?; + let healed = Body::new(crate::heal::heal_keep(b.deep_copy(), b.size(), b.split_keep()))?; let size = healed.size(); let mesh = healed.tessellate((size * 1e-3).max(1e-3))?; let mut chosen: Vec = Vec::new(); @@ -338,7 +338,7 @@ pub fn round_all_edges(b: &Body, r: f64) -> Result { /// by the thickness (open faces outward, past the body) keeping the body's topology, which /// gives the cavity; the shell is the body minus the cavity. fn shell_planar(b: &Body, open: &[Vec3], thickness: f64) -> Result { - let healed = Body::new(crate::heal::heal(b.deep_copy(), b.size()))?; + let healed = Body::new(crate::heal::heal_keep(b.deep_copy(), b.size(), b.split_keep()))?; let size = healed.size(); let mesh = healed.tessellate((size * 1e-3).max(1e-3))?; // Faces to open: the face of the triangle nearest each point. @@ -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(b.deep_copy(), b.size()))?; + let healed = Body::new(crate::heal::heal_keep(b.deep_copy(), b.size(), b.split_keep()))?; let size = healed.size(); let mesh = healed.tessellate((size * 1e-3).max(1e-3))?; let mut chosen: Vec = Vec::new(); diff --git a/crates/kernel/src/splitface.rs b/crates/kernel/src/splitface.rs index 85e77595..aab9675f 100644 --- a/crates/kernel/src/splitface.rs +++ b/crates/kernel/src/splitface.rs @@ -845,6 +845,16 @@ pub fn split_faces(body: &Body, faces: &[usize], tool: &SplitTool) -> Result Result<(Body, Vec)> { let (b, imp) = split_any(body, faces, tool)?; + // The new edges, remembered so that export keeps the pieces apart. + let mut lines: Vec> = body.splits.as_ref().map(|l| l.to_vec()).unwrap_or_default(); + lines.extend(imp.new_edges.iter().filter_map(|e| { + let c = e.curve(); + let (t0, t1) = c.range_tuple(); + let n = if matches!(c, mt::Curve::Line(_)) { 1 } else { 64 }; + let pts: Vec = (0..=n).map(|k| from_p3(c.subs(t0 + (t1 - t0) * k as f64 / n as f64))).collect(); + pts.iter().all(|p| p.is_finite()).then_some(pts) + })); + let b = b.with_split_lines(Some(std::sync::Arc::new(lines))); if let Some(missed) = faces.iter().find(|f| imp.made.get(f).copied().unwrap_or(0) == 0) { return Err(KernelError::Invalid(format!("the tool does not cross face {missed}"))); } @@ -867,7 +877,7 @@ pub(crate) fn split_any(body: &Body, faces: &[usize], tool: &SplitTool) -> Resul } let shells = std::mem::take(&mut imp.shells); let solid = mt::Solid::new_unchecked(shells.into_iter().map(mt::Shell::from).collect()); - Ok((Body { solid: std::sync::Arc::new(solid), mesh: None, color: body.color, paint: None }, imp)) + Ok((Body { solid: std::sync::Arc::new(solid), mesh: None, color: body.color, paint: None, splits: body.splits.clone() }, imp)) }) } @@ -1156,6 +1166,42 @@ mod tests { assert!(rel(v, 6000.0 - std::f64::consts::PI * 4.0 * 30.0) < 1e-3, "{v}"); } + /// Faces after a STEP round trip. + fn round_trip_faces(b: &Body) -> usize { + let ex = crate::ExportBody { name: "B".into(), body: b, color: None }; + let text = crate::step_export_bodies(&[ex], &crate::StepHeader::default()).unwrap(); + let imp = crate::step_import(&text).unwrap(); + imp.bodies.iter().map(|x| x.body.face_count()).sum() + } + + #[test] + fn export_keeps_faces_split_on_purpose() { + // A box top split by a plane, and a cylinder's side split by a plane across its axis. + let b = box_solid(Vec3::ZERO, Vec3::new(10.0, 20.0, 30.0)).unwrap(); + let pl = Plane::new(Vec3::new(4.0, 0.0, 0.0), Vec3::Y, Vec3::Z).unwrap(); + let s = split_faces(&b, &[top(&b, 30.0)], &SplitTool::Plane(pl)).unwrap(); + assert_eq!(s.face_count(), 7); + assert_eq!(round_trip_faces(&s), 7); + let c = cylinder(Vec3::ZERO, Vec3::Z, 10.0, 20.0).unwrap(); + let side = c.faces(0.01).unwrap().iter().position(|f| f.plane_normal.is_none()).unwrap(); + let mid = Plane::new(Vec3::new(0.0, 0.0, 5.0), Vec3::X, Vec3::Y).unwrap(); + let cs = split_faces(&c, &[side], &SplitTool::Plane(mid)).unwrap(); + let n = cs.face_count(); + assert!(n > c.face_count()); + // The pieces stay apart; the cylinder's incidental seam pieces still join as before. + assert_eq!(round_trip_faces(&cs), round_trip_faces(&c) + 1); + // Without the record the export joins the top's pieces again (as it does for pieces a + // boolean left). + assert_eq!(round_trip_faces(&s.clone().with_split_lines(None)), 6); + // A split after a split keeps both lines. + let pl2 = Plane::new(Vec3::new(0.0, 12.0, 0.0), Vec3::X, Vec3::Z).unwrap(); + let tops: Vec = + s.faces(0.01).unwrap().iter().enumerate().filter(|(_, f)| (f.centroid.z - 30.0).abs() < 1e-6).map(|(i, _)| i).collect(); + let s2 = split_faces(&s, &tops, &SplitTool::Plane(pl2)).unwrap(); + assert_eq!(s2.face_count(), 9); + assert_eq!(round_trip_faces(&s2), 9); + } + #[test] fn sketch_curve_splits_a_cylinder_side_and_a_circle_splits_a_top() { let c = cylinder(Vec3::ZERO, Vec3::Z, 10.0, 20.0).unwrap(); diff --git a/crates/kernel/src/step_out.rs b/crates/kernel/src/step_out.rs index 73915115..7c147826 100644 --- a/crates/kernel/src/step_out.rs +++ b/crates/kernel/src/step_out.rs @@ -11,6 +11,8 @@ use std::collections::HashMap; use std::fmt::Write as _; +use solvecraft_geom::Vec3; + use crate::body::Body; use crate::step_in::p21::{self, Param}; use crate::{KernelError, Result}; @@ -832,7 +834,13 @@ fn face_edges(ex: &p21::Exchange, face: &p21::Entity) -> Option) -> HashMap { +fn merge_split_faces(ex: &mut p21::Exchange, paint: &mut HashMap, keep: &[Vec]) -> HashMap { + // An edge on a line the user split along: every sample within a hair of the polyline. + let on_split = |ex: &p21::Exchange, e: u64| -> bool { + let Some(pts) = edge_samples(ex, e) else { return false }; + let pts: Vec = pts.iter().map(|q| Vec3::new(q[0], q[1], q[2])).collect(); + crate::heal::on_split_line(&pts, keep) + }; let mut merged_surfaces = std::collections::HashSet::new(); let mut next = ex.entities.keys().max().copied().unwrap_or(0) + 1; let shells: Vec = ex.entities.iter().filter(|(_, e)| matches!(e.name(), "CLOSED_SHELL" | "OPEN_SHELL")).map(|(i, _)| *i).collect(); @@ -923,7 +931,7 @@ fn merge_split_faces(ex: &mut p21::Exchange, paint: &mut HashMap) let all: Vec<(u64, bool)> = ea.into_iter().chain(eb).flatten().collect(); let shared: std::collections::HashSet = all.iter().filter(|(e, d)| all.iter().any(|(e2, d2)| e2 == e && d2 != d)).map(|(e, _)| *e).collect(); - if shared.is_empty() { + if shared.is_empty() || shared.iter().any(|e| on_split(ex, *e)) { continue; } // Chain oriented edges into loops by vertices (None: they do not close). @@ -1200,7 +1208,8 @@ fn brep_exchange(b: &Body, merge: bool) -> Result<(p21::Exchange, HashMap) -> Body { let shell = mt::Shell::from(faces); - Body { solid: std::sync::Arc::new(mt::Solid::new_unchecked(vec![shell])), mesh: None, color: None, paint: None } + Body { solid: std::sync::Arc::new(mt::Solid::new_unchecked(vec![shell])), mesh: None, color: None, paint: None, splits: None } } /// A body from faces: a solid when they close, else a surface. @@ -31,7 +31,7 @@ fn body_of(faces: Vec) -> Result { if shell.shell_condition() == mt::ShellCondition::Closed { Body::new(mt::Solid::new_unchecked(vec![shell])) } else { - Ok(Body { solid: std::sync::Arc::new(mt::Solid::new_unchecked(vec![shell])), mesh: None, color: None, paint: None }) + Ok(Body { solid: std::sync::Arc::new(mt::Solid::new_unchecked(vec![shell])), mesh: None, color: None, paint: None, splits: None }) } }