diff --git a/crates/doc/src/config_shell_tests.rs b/crates/doc/src/config_shell_tests.rs
index 65499c17..de07c82b 100644
--- a/crates/doc/src/config_shell_tests.rs
+++ b/crates/doc/src/config_shell_tests.rs
@@ -529,7 +529,15 @@ fn saved_stepped_tab_cut_piece_projection_is_lost_without_expanding_material() {
let stem = crate::naming::strip_piece(&former);
let current_names = crate::naming::face_names(current);
let merged = current_names.iter().position(|name| name == stem).unwrap();
- assert!(current.body.face_was_coalesced(merged), "new exact generated-wall construction supplies the loss-only witness");
+ // The Join leaves Case's -x wall on its own support, so the wall keeps its exact box
+ // identity through the Join and the Cut: one face again, no longer the saved piece.
+ assert!(
+ matches!(
+ current.body.face_sources().and_then(|sources| sources.get(merged)).cloned().flatten(),
+ Some(solidcraft_kernel::FaceSource::Box { axis: 0, high: false, .. })
+ ),
+ "the merged wall carries its exact construction identity"
+ );
assert!(!current_names.contains(&former));
let mut sketch = Sketch::new();
sketch.add_link(LinkKind::Project, LinkSource::Face { body: "Case".into(), at }, &resolved.geom).unwrap();
diff --git a/crates/doc/src/config_styles.rs b/crates/doc/src/config_styles.rs
index 63d921fe..2c6c9cde 100644
--- a/crates/doc/src/config_styles.rs
+++ b/crates/doc/src/config_styles.rs
@@ -108,9 +108,9 @@ impl Document {
use super::FaceCertificate;
let Some(feature) = self.feature(producer) else { return false };
match (certificate, &feature.kind) {
- (FaceCertificate::Box { .. }, FeatureKind::Box { operation: crate::Operation::NewBody, .. }) => true,
- (FaceCertificate::ExtrudeCap { sketch, .. }, FeatureKind::Extrude { sketch: current, extent, operation: crate::Operation::NewBody, .. }) => sketch == current && extent.taper.is_none(),
- (FaceCertificate::ExtrudeSide { sketch, curve }, FeatureKind::Extrude { sketch: current, extent, operation: crate::Operation::NewBody, .. }) => {
+ (FaceCertificate::Box { .. }, FeatureKind::Box { operation: crate::Operation::NewBody | crate::Operation::Join, .. }) => true,
+ (FaceCertificate::ExtrudeCap { sketch, .. }, FeatureKind::Extrude { sketch: current, extent, operation: crate::Operation::NewBody | crate::Operation::Join, .. }) => sketch == current && extent.taper.is_none(),
+ (FaceCertificate::ExtrudeSide { sketch, curve }, FeatureKind::Extrude { sketch: current, extent, operation: crate::Operation::NewBody | crate::Operation::Join, .. }) => {
sketch == current && extent.taper.is_none()
&& self.feature(*sketch).is_some_and(|feature| matches!(&feature.kind, FeatureKind::Sketch { sketch, .. } if sketch.curves.iter().any(|candidate| candidate.id == *curve && !candidate.construction && matches!(candidate.kind, solidcraft_sketch::CurveKind::Line { .. } | solidcraft_sketch::CurveKind::Circle { .. } | solidcraft_sketch::CurveKind::Arc { .. }))))
}
@@ -129,14 +129,14 @@ impl Document {
let feature = self.feature(target.origin_feature).ok_or_else(|| DocError::Unknown("face origin feature is gone".into()))?;
if let Some(certificate) = &target.certificate {
let valid = match (certificate, &feature.kind) {
- (super::FaceCertificate::Box { axis, high }, FeatureKind::Box { operation: crate::Operation::NewBody, .. }) => {
+ (super::FaceCertificate::Box { axis, high }, FeatureKind::Box { operation: crate::Operation::NewBody | crate::Operation::Join, .. }) => {
["x", "y", "z"].get(*axis as usize).is_some_and(|axis| role == format!("{}{axis}", if *high { "+" } else { "-" }))
}
- (super::FaceCertificate::ExtrudeCap { sketch, high, outer, holes }, FeatureKind::Extrude { sketch: producer_sketch, extent, operation: crate::Operation::NewBody, .. }) => {
+ (super::FaceCertificate::ExtrudeCap { sketch, high, outer, holes }, FeatureKind::Extrude { sketch: producer_sketch, extent, operation: crate::Operation::NewBody | crate::Operation::Join, .. }) => {
sketch == producer_sketch && extent.taper.is_none()
&& role == format!("cap:{}:{}", if *high { "high" } else { "low" }, super::cap_key(outer, holes))
}
- (super::FaceCertificate::ExtrudeSide { sketch, curve }, FeatureKind::Extrude { sketch: producer_sketch, extent, operation: crate::Operation::NewBody, .. }) => {
+ (super::FaceCertificate::ExtrudeSide { sketch, curve }, FeatureKind::Extrude { sketch: producer_sketch, extent, operation: crate::Operation::NewBody | crate::Operation::Join, .. }) => {
sketch == producer_sketch && extent.taper.is_none() && role == format!("side:{curve}")
&& self.feature(*sketch).is_some_and(|feature| matches!(&feature.kind, FeatureKind::Sketch { sketch, .. } if sketch.curves.iter().any(|candidate| candidate.id == *curve && !candidate.construction && matches!(candidate.kind, solidcraft_sketch::CurveKind::Line { .. } | solidcraft_sketch::CurveKind::Circle { .. } | solidcraft_sketch::CurveKind::Arc { .. }))))
}
diff --git a/crates/doc/src/eval.rs b/crates/doc/src/eval.rs
index 37c57195..c5ae6388 100644
--- a/crates/doc/src/eval.rs
+++ b/crates/doc/src/eval.rs
@@ -2108,7 +2108,9 @@ fn feature_tools_with_constructor(
if untrimmed {
*constructor = Some(ExtrudeConstructor { plane: ss.plane, interval: (lo + off, hi + off) });
}
- if *operation == Operation::NewBody && extent.taper.is_none() {
+ // A Join tool's faces outlive the boolean where they stay on the result, so they are
+ // certified as a new body's are (see `Body::with_preserved_union_planar_sources`).
+ if matches!(operation, Operation::NewBody | Operation::Join) && extent.taper.is_none() {
let bodies: Vec
= tools.iter().map(|t| t.body.clone()).collect();
let cap_bindings = crate::config::extrude_cap_bindings(&ss, &bodies, 10_000_000);
let source_curves = cap_bindings.as_ref().map(|_| crate::config::ExactSketchCurves::new(&ss));
@@ -2127,8 +2129,20 @@ fn feature_tools_with_constructor(
_ => None,
})
.collect();
- let caps =
+ let mut caps =
cap_bindings.as_ref().and_then(|bindings| bindings.get(body_index)).cloned().unwrap_or_else(|| vec![None; curves.len()]);
+ // A cap moved off its designed height (into a Join target, or past a
+ // curved To face) is not the feature's start or end face.
+ let nominal = |plane: &solidcraft_geom::Plane| {
+ let height = (plane.origin - ss.plane.origin).dot(n);
+ let tolerance = 1e-9 * (1.0 + height.abs() + (lo + off).abs() + (hi + off).abs());
+ (height - (lo + off)).abs() <= tolerance || (height - (hi + off)).abs() <= tolerance
+ };
+ for (cap, source) in caps.iter_mut().zip(body.face_sources().into_iter().flatten()) {
+ if matches!(source, Some(kernel::FaceSource::ExtrudeCap { plane, .. }) if !nominal(plane)) {
+ *cap = None;
+ }
+ }
// Certificates set face sources only: the faces, and their lineage, stay.
let body = body.with_extrude_certificates(f.id, *sketch, &curves)?.with_extrude_cap_certificates(f.id, *sketch, &caps)?;
Ok(Traced { body, lineage })
@@ -3624,7 +3638,7 @@ fn eval_feature(doc: &Document, vals: &BTreeMap, f: &Feature, st:
let s = Vec3::new(val(vals, length, Kind::Length)?, val(vals, width, Kind::Length)?, val(vals, height, Kind::Length)?);
let corner = point(vals, corner)?;
let b = kernel::box_solid(corner, corner + s)?;
- let b = if *operation == Operation::NewBody { b.with_box_certificate(f.id)? } else { b };
+ let b = if matches!(operation, Operation::NewBody | Operation::Join) { b.with_box_certificate(f.id)? } else { b };
apply_traced(st, f, vec![crate::lineage::box_tool(b)], *operation, &[])
}
FeatureKind::Cylinder { base, axis, radius, height, operation } => {
diff --git a/crates/engine/src/cmd/face_tests.rs b/crates/engine/src/cmd/face_tests.rs
index f58ea971..2c7c001f 100644
--- a/crates/engine/src/cmd/face_tests.rs
+++ b/crates/engine/src/cmd/face_tests.rs
@@ -142,7 +142,8 @@ fn legacy_internal_capture_refuses_an_uncaptured_source_in_another_component() {
let component = run(&mut s, "component.create", json!({"name":"Source"}));
let occurrence = component["occurrence"].as_u64().unwrap();
run(&mut s, "solid.box", json!({"length":40,"width":30,"height":20,"body_name":"Stock"}));
- run(&mut s, "solid.press_pull", json!({"face":[5,5,20],"distance":5}));
+ // A top merged from two joined boxes has no single certified source: the legacy path.
+ run(&mut s, "solid.box", json!({"length":40,"width":30,"height":20,"corner":[40,0,0],"operation":"join"}));
run(&mut s, "component.activate", json!({"component":0}));
run(&mut s, "occurrence.move", json!({"occurrence":occurrence,"translate":[30,0,0],"capture":false}));
let state = s.model.state();
@@ -153,12 +154,12 @@ fn legacy_internal_capture_refuses_an_uncaptured_source_in_another_component() {
.faces(1e-4)
.unwrap()
.iter()
- .find(|face| face.plane_normal.is_some_and(|normal| normal.z > 0.999) && (face.centroid.z - 25.0).abs() < 1e-6)
+ .find(|face| face.plane_normal.is_some_and(|normal| normal.z > 0.999) && (face.centroid.z - 20.0).abs() < 1e-6)
.unwrap()
.index;
let before = s.doc.to_json();
let error =
- super::super::sketch::face::capture_internal(&s, &json!({"face":[35,5,25],"body":"Stock","index":index,"path":[occurrence]}), "internal")
+ super::super::sketch::face::capture_internal(&s, &json!({"face":[35,5,20],"body":"Stock","index":index,"path":[occurrence]}), "internal")
.unwrap_err();
assert!(error.to_string().contains("capture the source occurrence position"), "{error}");
assert_eq!(s.doc.to_json(), before);
@@ -229,3 +230,90 @@ fn blind_cut_inside_a_cylinder_keeps_only_planes_and_cylinders() {
assert_eq!(body["face_types"], json!({"cylinder": 2, "plane": 12}), "{m}");
assert_eq!(body["faces"], 14, "{m}");
}
+
+fn face_anchor(s: &Session, sketch: u64) -> solidcraft_doc::FaceAnchor {
+ let FeatureKind::Sketch { plane: PlaneRef::Face { anchor: Some(anchor), .. }, .. } = &s.doc.feature(sketch).unwrap().kind else {
+ panic!("sketch {sketch} has no face anchor")
+ };
+ (**anchor).clone()
+}
+
+#[test]
+fn join_keeps_exact_face_attachments_from_both_operands() {
+ let mut s = Session::default();
+ run(&mut s, "sketch.create", json!({"plane":"XY"}));
+ run(&mut s, "sketch.rectangle.two_point", json!({"p0":[-10,-10],"p1":[10,10]}));
+ run(&mut s, "sketch.finish", json!({}));
+ let base = run(&mut s, "solid.extrude", json!({"distance":40,"name":"Base"}))["feature"].as_u64().unwrap();
+ // A cross bar joined through the base (the shape of #420) and a boss joined on its top.
+ run(&mut s, "sketch.create", json!({"plane":"YZ"}));
+ run(&mut s, "sketch.circle.center", json!({"center":[0,20],"radius":5}));
+ run(&mut s, "sketch.finish", json!({}));
+ run(&mut s, "solid.extrude", json!({"distance":20,"symmetric":true,"operation":"join"}));
+ run(&mut s, "sketch.create", json!({"plane":{"face":[0,0,40]}}));
+ run(&mut s, "sketch.circle.center", json!({"center":[0,0],"radius":4}));
+ run(&mut s, "sketch.finish", json!({}));
+ let boss = run(&mut s, "solid.extrude", json!({"distance":10,"operation":"join","name":"Boss"}))["feature"].as_u64().unwrap();
+ assert_eq!(s.model.state().bodies.len(), 1);
+
+ // The base's top around the boss, and the boss's own top (the Union's second operand).
+ let on_base = run(&mut s, "sketch.create", json!({"plane":{"face":[8,8,40]}}))["sketch"].as_u64().unwrap();
+ run(&mut s, "sketch.finish", json!({}));
+ let on_boss = run(&mut s, "sketch.create", json!({"plane":{"face":[0,0,50]}}))["sketch"].as_u64().unwrap();
+ run(&mut s, "sketch.finish", json!({}));
+ assert_eq!(face_anchor(&s, on_base).target.origin_feature, base);
+ assert_eq!(face_anchor(&s, on_boss).target.origin_feature, boss);
+
+ // The attachments follow their faces through an upstream edit and a reopen.
+ run(&mut s, "timeline.edit", json!({"feature":"Base","set":{"extent":{"distance":"50"}}}));
+ s.refresh();
+ assert!(s.model.results.iter().all(|result| result.error.is_none()), "{:?}", s.model.results);
+ let state = s.model.state();
+ assert!((state.sketch(on_base).unwrap().plane.origin.z - 50.0).abs() < 1e-6);
+ assert!((state.sketch(on_boss).unwrap().plane.origin.z - 60.0).abs() < 1e-6);
+ let mut reopened = Session::new(Document::from_json(&s.doc.to_json()).unwrap());
+ reopened.refresh();
+ assert!(reopened.model.results.iter().all(|result| result.error.is_none()), "{:?}", reopened.model.results);
+ assert!((reopened.model.state().sketch(on_boss).unwrap().plane.origin.z - 60.0).abs() < 1e-6);
+}
+
+#[test]
+fn join_leaves_a_face_merged_from_both_operands_without_an_attachment() {
+ let mut s = Session::default();
+ run(&mut s, "solid.box", json!({"length":20,"width":20,"height":10}));
+ run(&mut s, "solid.box", json!({"length":20,"width":20,"height":10,"corner":[20,0,0],"operation":"join"}));
+ // Both boxes' tops share one support: no single construction identity owns it.
+ let error = s.execute("sketch.create", &json!({"plane":{"face":[10,10,10]}})).unwrap_err().to_string();
+ assert!(error.contains("fixed:true"), "{error}");
+ run(&mut s, "sketch.create", json!({"plane":{"face":[10,10,10],"fixed":true}}));
+ // An unshared support keeps its box role.
+ run(&mut s, "sketch.finish", json!({}));
+ let side = run(&mut s, "sketch.create", json!({"plane":{"face":[0,10,5]}}))["sketch"].as_u64().unwrap();
+ assert!(matches!(face_anchor(&s, side).target.certificate, Some(solidcraft_doc::config::FaceCertificate::Box { axis: 0, high: false })));
+}
+
+#[test]
+fn join_covering_a_whole_attached_face_loses_the_attachment_without_moving_it() {
+ let mut s = Session::default();
+ run(&mut s, "sketch.create", json!({"plane":"XY"}));
+ run(&mut s, "sketch.rectangle.two_point", json!({"p0":[0,0],"p1":[40,30]}));
+ run(&mut s, "sketch.finish", json!({}));
+ run(&mut s, "solid.extrude", json!({"distance":10,"name":"Block"}));
+ let top = run(&mut s, "sketch.create", json!({"plane":{"face":[30,15,10]},"name":"Top"}))["sketch"].as_u64().unwrap();
+ run(&mut s, "sketch.circle.center", json!({"center":[30,15],"radius":3}));
+ run(&mut s, "sketch.finish", json!({}));
+ run(&mut s, "solid.extrude", json!({"sketch":"Top","distance":5,"operation":"join","name":"Peg"}));
+ // An earlier join whose roof replaces Block's whole top.
+ run(&mut s, "timeline.roll_to", json!({"feature":"Block"}));
+ run(&mut s, "sketch.create", json!({"plane":"XY","name":"B"}));
+ run(&mut s, "sketch.rectangle.two_point", json!({"p0":[-5,-5],"p1":[45,35]}));
+ run(&mut s, "sketch.finish", json!({}));
+ run(&mut s, "solid.extrude", json!({"sketch":"B","distance":20,"operation":"join","name":"Boss"}));
+ run(&mut s, "timeline.roll_to", json!({}));
+ let failed = |s: &Session, id: u64| s.model.results.iter().find(|r| r.id == id).unwrap().error.is_some();
+ assert!(failed(&s, top), "the attachment never follows the substitute roof");
+ assert!(s.model.state().sketch(top).is_none());
+ run(&mut s, "timeline.suppress", json!({"feature":"Boss","suppressed":true}));
+ assert!(!failed(&s, top));
+ assert!((s.model.state().sketch(top).unwrap().plane.origin.z - 10.).abs() < 1e-9);
+}
diff --git a/crates/engine/src/cmd/sketch_face_tests.rs b/crates/engine/src/cmd/sketch_face_tests.rs
index 4d9b276e..6eda25f0 100644
--- a/crates/engine/src/cmd/sketch_face_tests.rs
+++ b/crates/engine/src/cmd/sketch_face_tests.rs
@@ -76,7 +76,8 @@ fn explicit_rebind_preserves_geometry_dimensions_context_and_one_undo() {
fn unsupported_face_has_explicit_fixed_alternative_and_no_live_projection() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length":10,"width":10,"height":10,"body_name":"Base"}));
- run(&mut s, "solid.box", json!({"length":2,"width":2,"height":2,"corner":[4,4,10],"operation":"join"}));
+ // The tops of two joined boxes merge into one face that no single construction owns.
+ run(&mut s, "solid.box", json!({"length":10,"width":10,"height":10,"corner":[10,0,0],"operation":"join"}));
let mut pick = top_pick(&s, "Base");
let before = s.doc.to_json();
let error = s.execute("sketch.create", &json!({"plane":pick})).unwrap_err();
diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs
index 6c150300..394685fa 100644
--- a/crates/engine/src/tests.rs
+++ b/crates/engine/src/tests.rs
@@ -1914,7 +1914,8 @@ mod naming {
let mut legacy = Session::new(solidcraft_doc::Document::from_json(&old.to_json()).unwrap());
assert_eq!(attached(&legacy), Some(10.));
assert!((top(&mut legacy) - 15.).abs() < 1e-6);
- // The upstream join consumes the certified body; new attachments cannot follow its substitute roof.
+ // An upstream join that trims the certified face: the attachment stays on what is left
+ // of it and never follows the substitute roof (see face_tests for a face covered whole).
for target in [&mut s, &mut legacy] {
run(target, "timeline.roll_to", json!({"feature": "Block"}));
run(target, "sketch.create", json!({"plane": "XY", "name": "B"}));
@@ -1935,19 +1936,14 @@ mod naming {
};
assert!(anchor.is_none(), "opening old face references must not infer a strict attachment");
let failed = |s: &Session, name: &str| s.model.results.iter().find(|r| r.id == s.doc.find_feature(name).unwrap().id).unwrap().error.is_some();
- assert!(failed(&s, "Top"));
- assert!(failed(&s, "Peg"), "lost sketch cannot supply downstream geometry");
- assert_eq!(attached(&s), None);
+ assert!(!failed(&s, "Top"));
+ assert!(!failed(&s, "Peg"));
+ assert_eq!(attached(&s), Some(10.), "the strip of Block's top left beside the boss is still that face");
assert!((top(&mut s) - 20.0).abs() < 1e-6, "{}", top(&mut s));
run(&mut s, "timeline.suppress", json!({"feature":"Boss","suppressed":true}));
assert!(!failed(&s, "Top"));
- assert!(!failed(&s, "Peg"));
assert_eq!(attached(&s), Some(10.));
assert!((top(&mut s) - 15.).abs() < 1e-6);
- run(&mut s, "edit.undo", json!({}));
- assert!(failed(&s, "Top"));
- assert!(failed(&s, "Peg"));
- assert_eq!(attached(&s), None);
}
#[test]
diff --git a/crates/kernel/src/ops.rs b/crates/kernel/src/ops.rs
index a0444b0a..f9561f70 100644
--- a/crates/kernel/src/ops.rs
+++ b/crates/kernel/src/ops.rs
@@ -248,8 +248,10 @@ pub fn boolean(a: &Body, b: &Body, op: BoolOp) -> Result