From 722bd007c81a312bf959a9f0236939212819fb3f Mon Sep 17 00:00:00 2001 From: Irakli Kourtis Date: Mon, 12 Oct 2026 20:31:32 -0400 Subject: [PATCH] Join keeps exact face attachments on faces it leaves unchanged (#420) (#453) A Union dropped every construction certificate, so after any join no face could take a durable sketch: sketch.create failed with "this face has no exact planar source" even on a face the join never touched (#420, and the "CREATE SKETCH: operation failed" reports #344, #195). - kernel: map planar sources through a Union from both operands, with the same exact-support rule Cut uses. Coplanar supports of the two operands carry distinct identities, so a face merged from both stays without one. - doc: Join extrude and Join box tools are certified like new bodies; a cap moved off its designed height (into the target, or past a curved To face) is not certified. Join producers are accepted when certifying. Tests: both operands' faces attach and follow an upstream edit and a reopen; a merged coplanar face is refused and fixed:true works; a face a join covers whole is lost without moving to the new roof. Tests that used a joined face as their "uncertified" example now use a merged face; the trimmed-face case in a_sketch_on_a_face_stays_on_that_face now stays attached to the surviving piece. Claude-Session: https://claude.ai/code/session_01YDDPmNdRoBgkHcLeUDKVfb Co-authored-by: Claude Opus 5.5 --- crates/doc/src/config_shell_tests.rs | 10 ++- crates/doc/src/config_styles.rs | 12 +-- crates/doc/src/eval.rs | 20 ++++- crates/engine/src/cmd/face_tests.rs | 94 +++++++++++++++++++++- crates/engine/src/cmd/sketch_face_tests.rs | 3 +- crates/engine/src/tests.rs | 14 ++-- crates/kernel/src/ops.rs | 6 +- crates/kernel/src/planar_sources.rs | 81 +++++++++++++++---- 8 files changed, 199 insertions(+), 41 deletions(-) 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> { }; } r = r.map(|result| result.map(|body| body.inherit_regularization_protection(a).inherit_regularization_protection(b))); - if matches!(op, BoolOp::Cut | BoolOp::Union) { - r = r.and_then(|result| result.map(|body| body.with_preserved_boolean_planar_sources(a, b, op == BoolOp::Cut)).transpose()); + match op { + BoolOp::Cut => r = r.and_then(|result| result.map(|body| body.with_preserved_boolean_planar_sources(a, b, true)).transpose()), + BoolOp::Union => r = r.and_then(|result| result.map(|body| body.with_preserved_union_planar_sources(a, b)).transpose()), + BoolOp::Intersect => {} } if r.is_ok() { merge(diagnostics); diff --git a/crates/kernel/src/planar_sources.rs b/crates/kernel/src/planar_sources.rs index f51352b6..3ab4b3fa 100644 --- a/crates/kernel/src/planar_sources.rs +++ b/crates/kernel/src/planar_sources.rs @@ -102,13 +102,13 @@ pub(super) fn descriptor_cost(source: &FaceSource) -> Option { impl Body { /// Preserve uniquely certified unchanged planar construction supports. pub fn with_preserved_planar_sources(self, input: &Body) -> Result { - self.map_planar_sources(input, None, None, &[], false) + self.map_planar_sources(&[input], None, None, &[], false) } /// Generate distinct outer/inner roles from the exact original supports of /// an inside shell. Generated rims and curved walls are intentionally absent. pub(crate) fn with_shell_planar_sources(self, input: &Body, thickness: f64, open: &[Vec3]) -> Result { - self.map_planar_sources(input, Some(thickness), None, open, false) + self.map_planar_sources(&[input], Some(thickness), None, open, false) } /// Retain exact shell roles, and construction roles for Cut, only on unchanged @@ -118,19 +118,38 @@ impl Body { if !input.face_sources().is_some_and(|sources| sources.iter().flatten().any(|s| boolean_source(s, constructed))) { return Ok(self_); } - self_.map_planar_sources(input, None, Some(tool), &[], constructed) + self_.map_planar_sources(&[input], None, Some(tool), &[], constructed) } - fn map_planar_sources(mut self, input: &Body, shell: Option, blocker: Option<&Body>, open: &[Vec3], constructed: bool) -> Result { - self.regularization_protected |= input.regularization_protected; - let Some(sources) = input.face_sources() else { return Ok(self) }; - if input.solid.face_iter().count() > 16_384 + /// Retain exact construction and shell roles through a Union from both operands, only on + /// unchanged supports. Coplanar supports of the two operands carry distinct identities, so + /// a face merged from both stays without a role. + pub(crate) fn with_preserved_union_planar_sources(self, a: &Body, b: &Body) -> Result { + let self_ = self.inherit_regularization_protection(a).inherit_regularization_protection(b); + if ![a, b].iter().any(|input| input.face_sources().is_some_and(|sources| sources.iter().flatten().any(|s| boolean_source(s, true)))) { + return Ok(self_); + } + self_.map_planar_sources(&[a, b], None, None, &[], true) + } + + fn map_planar_sources(mut self, inputs: &[&Body], shell: Option, blocker: Option<&Body>, open: &[Vec3], constructed: bool) -> Result { + // Every boolean (a Cut's tool, or a Union's second operand) leaves the result with + // faces whose support no input role describes. + let boolean = blocker.is_some() || inputs.len() > 1; + for input in inputs { + self.regularization_protected |= input.regularization_protected; + } + if inputs.iter().all(|input| input.face_sources().is_none()) { + return Ok(self); + } + if inputs.len() > 2 + || inputs.iter().any(|input| input.solid.face_iter().count() > 16_384) || self.solid.face_iter().count() > 16_384 || blocker.is_some_and(|b| b.solid.face_iter().count() > 16_384) { return Err(KernelError::Invalid("planar source face budget exceeded".into())); } - if sources.len() != input.solid.face_iter().count() { + if inputs.iter().any(|input| input.face_sources().is_some_and(|sources| sources.len() != input.solid.face_iter().count())) { return Err(KernelError::Invalid("planar source alignment mismatch".into())); } if shell.is_some_and(|t| !t.is_finite() || t <= 0.) { @@ -139,20 +158,22 @@ impl Body { if open.len() > 256 { return Ok(self); } - let opening_tolerance = if shell.is_some() { input.size() * 1e-4 + 1e-6 } else { 0. }; + let opening_tolerance = if shell.is_some() { inputs.iter().map(|input| input.size()).fold(0., f64::max) * 1e-4 + 1e-6 } else { 0. }; let mapped = guard("exact planar support lineage", || { let competing: Vec<_> = blocker.into_iter().flat_map(|b| b.solid.face_iter()).filter_map(actual_plane).collect(); let mut candidates = Vec::new(); let mut unknown_supports = Vec::new(); - for (face, source) in input.solid.face_iter().zip(sources) { + let faces = inputs.iter().flat_map(|input| { + let sources = input.face_sources().unwrap_or_default(); + input.solid.face_iter().enumerate().map(move |(index, face)| (face, sources.get(index).and_then(Option::as_ref))) + }); + for (face, source) in faces { let Some(actual) = actual_plane(face) else { continue }; - let Some(source) = source.as_ref() else { + let Some(source) = source else { unknown_supports.push(actual); continue; }; - if (blocker.is_some() && !boolean_source(source, constructed)) - || (shell.is_some() && matches!(source, FaceSource::ShellPlanar { .. })) - { + if (boolean && !boolean_source(source, constructed)) || (shell.is_some() && matches!(source, FaceSource::ShellPlanar { .. })) { unknown_supports.push(actual); continue; } @@ -207,7 +228,7 @@ impl Body { .face_sources() .and_then(|sources| sources.get(index)) .and_then(|s| s.as_ref()) - .filter(|s| (blocker.is_some() || self.face_was_coalesced(index)) && !boolean_source(s, constructed)); + .filter(|s| (boolean || self.face_was_coalesced(index)) && !boolean_source(s, constructed)); let keep = existing.and_then(|s| { descriptor_cost(s).and_then(|cost| { metadata.checked_sub(cost).map(|remaining| { @@ -251,7 +272,7 @@ impl Body { }); } } - if found.is_none() && (blocker.is_some() || self.face_was_coalesced(index)) { + if found.is_none() && (boolean || self.face_was_coalesced(index)) { found = self .face_sources() .and_then(|sources| sources.get(index)) @@ -428,6 +449,34 @@ mod tests { assert!((copy.tessellate(0.01).unwrap().measure().volume - mesh.measure().volume).abs() < 1e-6); } #[test] + fn union_keeps_unchanged_supports_of_both_operands_and_drops_shared_ones() { + use crate::{BoolOp, boolean}; + let a = box_solid(Vec3::ZERO, Vec3::new(20., 20., 10.)).unwrap().with_box_certificate(1).unwrap(); + // A taller box overlapping one side: the bottoms share z = 0, the tops do not. + let b = box_solid(Vec3::new(10., 5., 0.), Vec3::new(30., 15., 15.)).unwrap().with_box_certificate(2).unwrap(); + let joined = boolean(&a, &b, BoolOp::Union).unwrap().unwrap(); + let sources = joined.face_sources().unwrap(); + let mut seen = Vec::new(); + for (i, face) in joined.solid.face_iter().enumerate() { + let plane = actual_plane(face).unwrap(); + let source = sources[i].as_ref(); + let height = plane.origin.z; + if plane.normal().z > 0.999 && (height - 10.).abs() < 1e-9 { + assert!(matches!(source, Some(FaceSource::Box { producer: Some(1), axis: 2, high: true, .. })), "{source:?}"); + seen.push("a top"); + } else if plane.normal().z > 0.999 && (height - 15.).abs() < 1e-9 { + assert!(matches!(source, Some(FaceSource::Box { producer: Some(2), axis: 2, high: true, .. })), "{source:?}"); + seen.push("b top"); + } else if plane.normal().z < -0.999 { + assert!(source.is_none(), "a bottom merged from both operands has no single identity: {source:?}"); + seen.push("bottom"); + } + } + for want in ["a top", "b top", "bottom"] { + assert!(seen.contains(&want), "{want} missing from {seen:?}"); + } + } + #[test] fn coincident_competing_source_planes_and_tool_planes_stay_unknown() { let a = box_solid(Vec3::ZERO, Vec3::new(10., 10., 10.)).unwrap().with_box_certificate(1).unwrap(); let b = box_solid(Vec3::new(20., 0., 0.), Vec3::new(30., 10., 10.)).unwrap().with_box_certificate(2).unwrap();