From 7c611aec0cff3a7423c752efc3e20194b0be8bf8 Mon Sep 17 00:00:00 2001 From: bherbruck Date: Thu, 8 Oct 2026 12:44:38 +0000 Subject: [PATCH] Booleans stay in their component: a join, cut or intersect without explicit targets changes only its own component's bodies (and what it made), auto join/cut looks only at the active component; any feature takes participants: [bodies] to reach other components ("*" = every body); file format 5 gives older features in designs with components participants ["*"] so they keep their shape Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL --- crates/doc/src/document.rs | 5 +++++ crates/doc/src/eval.rs | 15 +++++++++++++-- crates/doc/src/format.rs | 26 ++++++++++++++++++++++++-- crates/engine/src/cmd/features.rs | 14 +++++++++++++- crates/engine/src/tests.rs | 29 +++++++++++++++++++++++++++++ 5 files changed, 84 insertions(+), 5 deletions(-) diff --git a/crates/doc/src/document.rs b/crates/doc/src/document.rs index 7a8138a9..f7e581b1 100644 --- a/crates/doc/src/document.rs +++ b/crates/doc/src/document.rs @@ -1045,6 +1045,10 @@ pub struct Feature { /// Persistent names of the faces at the feature's face points (`FeatureKind::face_points`). #[serde(default, skip_serializing_if = "Vec::is_empty")] pub face_names: Vec, + /// Bodies a join/cut/intersect may change (any component); empty = the feature's own + /// component's bodies. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub participants: Vec, #[serde(flatten)] pub kind: FeatureKind, } @@ -1324,6 +1328,7 @@ impl Document { edge_refs: Vec::new(), edge_names: Vec::new(), face_names: Vec::new(), + participants: Vec::new(), kind, }; match self.marker { diff --git a/crates/doc/src/eval.rs b/crates/doc/src/eval.rs index a6712bd8..5ae008d8 100644 --- a/crates/doc/src/eval.rs +++ b/crates/doc/src/eval.rs @@ -25,11 +25,13 @@ pub struct ModelBody { key: u64, /// Persistent face names (worked out on first use). pub(crate) names: crate::naming::NameCell, + /// The component the body belongs to (set after each feature). + pub component: u64, } impl ModelBody { pub fn new(name: String, body: Body, feature: u64) -> Self { - ModelBody { name, body, feature, mesh: OnceLock::new(), key: 0, names: Default::default() } + ModelBody { name, body, feature, mesh: OnceLock::new(), key: 0, names: Default::default(), component: 0 } } /// Display mesh (chord tolerance 1/1000 of the body size), computed once. pub fn mesh(&self) -> Arc { @@ -564,6 +566,9 @@ impl Model { _ => Vec::new(), }; next.stamp(&state, f, key, mats); + for b in &mut next.bodies { + b.component = doc.body_component(&b.name, b.feature); + } state = Arc::new(next); None } @@ -652,8 +657,14 @@ fn new_name(state: &mut ModelState, f: &Feature, k: usize) -> String { /// Add tool bodies to the model according to the operation. fn apply_op(state: &mut ModelState, f: &Feature, tools: Vec, op: Operation, targets: &[String]) -> Result<()> { - let target_idx: Vec = if targets.is_empty() { + // Participants: the feature's own targets, else its explicit participants, else the bodies + // of its component (and what it made itself): other components are left alone. + let targets: &[String] = if targets.is_empty() { &f.participants } else { targets }; + let all = targets.len() == 1 && targets.first().is_some_and(|t| t == "*"); + let target_idx: Vec = if all { (0..state.bodies.len()).collect() + } else if targets.is_empty() { + (0..state.bodies.len()).filter(|i| state.bodies.get(*i).is_some_and(|b| b.component == f.component || b.feature == f.id)).collect() } else { let mut v = Vec::new(); for t in targets { diff --git a/crates/doc/src/format.rs b/crates/doc/src/format.rs index 1e638d9c..abdf81c9 100644 --- a/crates/doc/src/format.rs +++ b/crates/doc/src/format.rs @@ -13,6 +13,9 @@ //! - 3: appearances per body, face and component with names and opacity //! (`"appearances": {"bodies": {name: {name, color, opacity}}, "faces": […], "components": {…}}`); //! before, `"appearances"` mapped body names to bare `[r, g, b]` colours. +//! - 5: joins, cuts and intersects change only their own component's bodies unless bodies are +//! named as `participants`; older features in designs with components get `participants: +//! ["*"]` (every body), which is what they did, so those designs keep their shape. //! - 4: joint origins mate by default (B's z against A's, as Fusion does); `flip` now means //! "don't mate". Older joints keep their placement: their `flip` is inverted. @@ -21,7 +24,7 @@ use serde_json::Value; use crate::{DocError, Document, Result}; /// The format this build writes. -pub const FORMAT: u32 = 4; +pub const FORMAT: u32 = 5; pub fn format_string(n: u32) -> String { format!("solvecraft/{n}") @@ -34,7 +37,26 @@ pub fn version_of(s: &str) -> Option { /// JSON-level upgrades: entry `i` turns format `i + 1` into `i + 2`. type JsonStep = fn(&mut Value) -> Result<()>; -const JSON_STEPS: [JsonStep; 3] = [|_| Ok(()), v2_to_v3, v3_to_v4]; +const JSON_STEPS: [JsonStep; 4] = [|_| Ok(()), v2_to_v3, v3_to_v4, v4_to_v5]; + +/// Features in designs with components keep reaching every body. +fn v4_to_v5(v: &mut Value) -> Result<()> { + let has_components = v.get("components").and_then(Value::as_array).is_some_and(|c| !c.is_empty()); + if !has_components { + return Ok(()); + } + if let Some(Value::Array(fs)) = v.get_mut("features") { + for f in fs.iter_mut() { + if let Some(o) = f.as_object_mut() + && o.get("type").and_then(Value::as_str) != Some("sketch") + && o.get("participants").is_none() + { + o.insert("participants".into(), serde_json::json!(["*"])); + } + } + } + Ok(()) +} /// Joints placed with the old convention (aligned unless flipped) keep their placement. fn v3_to_v4(v: &mut Value) -> Result<()> { diff --git a/crates/engine/src/cmd/features.rs b/crates/engine/src/cmd/features.rs index 527c02fe..c03616b0 100644 --- a/crates/engine/src/cmd/features.rs +++ b/crates/engine/src/cmd/features.rs @@ -139,7 +139,14 @@ pub fn auto_operation(s: &Session, p: &Value) -> Option<&'static str> { if st.bodies.is_empty() || !d.is_finite() { return Some("new"); } - let inside = |q: Vec3| st.bodies.iter().any(|b| b.mesh().contains(q)); + // Only the active component's bodies take part (or the ones named as participants). + let parts = string_list(p, "participants"); + let inside = |q: Vec3| { + st.bodies + .iter() + .filter(|b| if parts.is_empty() { s.doc.body_component(&b.name, b.feature) == s.active_component } else { parts.contains(&b.name) }) + .any(|b| b.mesh().contains(q)) + }; let eps = (d.abs() * 1e-3).max(1e-3); let sign = if d < 0.0 { -1.0 } else { 1.0 }; if symmetric { @@ -262,8 +269,13 @@ pub(super) fn add_feature(s: &mut Session, p: &Value, kind: FeatureKind) -> Resu let pts = kind.face_points(); if pts.is_empty() { Vec::new() } else { solvecraft_doc::naming::face_names_at(&s.model.state(), &pts) } }; + let participants = string_list(p, "participants"); + if participants.len() > 10_000 || participants.iter().any(|b| b != "*" && s.model.state().body(b).is_none()) { + return Err(EngineError::Other("`participants` must list existing bodies".into())); + } let id = s.doc_mut().add_feature(kind, name)?; if let Some(f) = s.doc_mut().feature_mut(id) { + f.participants = participants; f.edge_refs = refs; f.edge_names = names; f.face_names = face_names; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index aaaf3052..45864e88 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1387,6 +1387,8 @@ fn tapped_and_clearance_holes() { assert!(rel(v2 - volume(&mut s), PI * 2.15 * 2.15 * 10.0) < 1e-3); assert!(s.execute("FusionHoleCommand", &json!({"position": [20, 25, 10], "clearance": "M7"})).is_err()); assert!(s.execute("FusionHoleCommand", &json!({"position": [20, 25, 10]})).is_err()); +} + /// In an inch design, a bare length typed into a feature means inches (stored with the unit); /// lengths with units and angles are left alone. #[test] @@ -1427,3 +1429,30 @@ fn press_pull_on_curved_faces() { let v1 = volume(&mut s); assert!(rel(v1 - v0, PI * (25.0 - 16.0) * 20.0) < 1e-3, "{v0} {v1}"); } + +/// A cut in one component leaves other components' bodies alone unless they are named as +/// participants; auto join/cut looks at the active component only. +#[test] +fn booleans_stay_in_their_component() { + let mut s = Session::default(); + for (name, x) in [("A", 0), ("B", 20)] { + run(&mut s, "component.activate", json!({"component": "root"})); + run(&mut s, "FusionCreateNewComponentCommand", json!({"name": name})); + run(&mut s, "PrimitiveBox", json!({"length": 20, "width": 10, "height": 10, "corner": [x, 0, 0], "body_name": format!("{name}Box")})); + } + // A's box spans x 0…20, B's x 20…40 (in their own frames, both occurrences in place). + run(&mut s, "component.activate", json!({"component": "A"})); + run(&mut s, "SketchCreate", json!({"plane": "XY", "name": "Slot"})); + run(&mut s, "ShapeRectangleTwoPoint", json!({"p0": [8, 2], "p1": [28, 8]})); + run(&mut s, "SketchStop", json!({})); + let vol = |s: &mut Session, b: &str| run(s, "MeasureCommand", json!({"bodies": [b]}))["total"]["volume_mm3"].as_f64().unwrap_or(0.0); + assert_eq!(auto_operation(&s, &json!({"sketch": "Slot", "distance": 10})), Some("cut")); + run(&mut s, "Extrude", json!({"sketch": "Slot", "distance": 10, "operation": "cut", "name": "Cut"})); + assert!((vol(&mut s, "ABox") - (2000.0 - 12.0 * 6.0 * 10.0)).abs() < 1e-6, "{}", vol(&mut s, "ABox")); + assert!((vol(&mut s, "BBox") - 2000.0).abs() < 1e-6, "B untouched: {}", vol(&mut s, "BBox")); + // Named as a participant, B is cut too. + run(&mut s, "UndoCommand", json!({})); + run(&mut s, "Extrude", json!({"sketch": "Slot", "distance": 10, "operation": "cut", "participants": ["ABox", "BBox"]})); + assert!((vol(&mut s, "BBox") - (2000.0 - 8.0 * 6.0 * 10.0)).abs() < 1e-6, "{}", vol(&mut s, "BBox")); + assert!(s.execute("Extrude", &json!({"sketch": "Slot", "distance": 1, "operation": "cut", "participants": ["Nope"]})).is_err()); +}