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