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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
This commit is contained in:
bherbruckandClaude Opus 5.5 committed 2026-10-08 14:25:27 +00:00
1 parent 2886399215
commit 7c611aec0c
5 files changed
+84 -5

No files matched your search

+5
View File
@@ -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<String>,
/// 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<String>,
#[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 {
+13 -2
View File
@@ -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<Mesh> {
@@ -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<Body>, op: Operation, targets: &[String]) -> Result<()> {
let target_idx: Vec<usize> = 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<usize> = 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 {
+24 -2
View File
@@ -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<u32> {
/// 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<()> {
+13 -1
View File
@@ -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;
+29
View File
@@ -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());
}