diff --git a/crates/engine/src/cmd/features.rs b/crates/engine/src/cmd/features.rs index 64b6ff50..186b635e 100644 --- a/crates/engine/src/cmd/features.rs +++ b/crates/engine/src/cmd/features.rs @@ -23,10 +23,10 @@ pub static COMMANDS: &[CommandSpec] = &[ .icon("sweep") .params("sketch: profile sketch; profiles?; path_sketch: sketch; path: [curve ids in order]; operation?, targets?, name?, body_name?"), CommandSpec::new("solid.loft", "Loft", loft).at("SOLID", "CREATE").icon("loft").params("sections: [{sketch, profiles?} | {sketch, point: id} (an apex)] (2 or more, in order); operation?, targets?, name?, body_name?"), - CommandSpec::new("solid.box", "Box", prim_box).at("SOLID", "CREATE").icon("box").params("length, width, height: expr; corner?: [x,y,z] | center?: [x,y,z] (numbers in mm or length expressions; default: the origin); operation?, name?, body_name?"), - CommandSpec::new("solid.cylinder", "Cylinder", prim_cylinder).at("SOLID", "CREATE").icon("cylinder").params("radius | diameter, height: expr; base?: [x,y,z] (centre of the bottom: numbers in mm or length expressions; default: the origin); axis?: X|Y|Z|[x,y,z] (default Z); operation?, name?, body_name?"), - CommandSpec::new("solid.sphere", "Sphere", prim_sphere).at("SOLID", "CREATE").icon("sphere").params("radius | diameter: expr; center?: [x,y,z] (numbers in mm or length expressions; default: the origin); operation?, name?, body_name?"), - CommandSpec::new("solid.torus", "Torus", prim_torus).at("SOLID", "CREATE").icon("torus").params("major, minor: expr (radii); center?: [x,y,z] (numbers in mm or length expressions; default: the origin); operation?, name?, body_name?"), + CommandSpec::new("solid.box", "Box", prim_box).at("SOLID", "CREATE").icon("box").params("length, width, height: expr; corner?: [x,y,z] | center?: [x,y,z] (numbers in mm or length expressions; default: the origin); operation?, targets?: [body], name?, body_name?"), + CommandSpec::new("solid.cylinder", "Cylinder", prim_cylinder).at("SOLID", "CREATE").icon("cylinder").params("radius | diameter, height: expr; base?: [x,y,z] (centre of the bottom: numbers in mm or length expressions; default: the origin); axis?: X|Y|Z|[x,y,z] (default Z); operation?, targets?: [body], name?, body_name?"), + CommandSpec::new("solid.sphere", "Sphere", prim_sphere).at("SOLID", "CREATE").icon("sphere").params("radius | diameter: expr; center?: [x,y,z] (numbers in mm or length expressions; default: the origin); operation?, targets?: [body], name?, body_name?"), + CommandSpec::new("solid.torus", "Torus", prim_torus).at("SOLID", "CREATE").icon("torus").params("major, minor: expr (radii); center?: [x,y,z] (numbers in mm or length expressions; default: the origin); operation?, targets?: [body], name?, body_name?"), CommandSpec::new("solid.fillet", "Fillet", fillet) .at("SOLID", "MODIFY") .icon("fillet") @@ -413,9 +413,14 @@ fn add_feature_in(s: &mut Session, p: &Value, kind: FeatureKind, world: bool) -> solvecraft_doc::naming::face_names_at(&s.model.state(), &pts) } }; - let participants = string_list(p, "participants"); + // Primitives have no `targets` of their own: their `targets` are the bodies taking part (#259). + let primitive = matches!(kind, FeatureKind::Box { .. } | FeatureKind::Cylinder { .. } | FeatureKind::Sphere { .. } | FeatureKind::Torus { .. }); + let (participants, key) = match string_list(p, "participants") { + v if v.is_empty() && primitive => (string_list(p, "targets"), "targets"), + v => (v, "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())); + return Err(EngineError::Other(format!("`{key}` must list existing bodies"))); } if matches!(kind, FeatureKind::ConstructionPlane { .. }) && let Some(n) = name diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 307ba7e9..d58a6d4d 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -2500,6 +2500,23 @@ fn special_files_are_refused_by_every_file_command() { let _ = std::fs::remove_dir_all(&dir); } +/// A join with explicit `targets` merges into those bodies only; a touched non-target stays (#259). +#[test] +fn join_with_targets_leaves_other_touched_bodies_alone() { + let mut s = Session::default(); + run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "operation": "new_body", "body_name": "A"})); + run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [10, 0, 0], "operation": "new_body", "body_name": "B"})); + // Straddles the face between A and B; only A is a target. + run(&mut s, "solid.box", json!({"length": 4, "width": 4, "height": 4, "corner": [8, 3, 3], "operation": "join", "targets": ["A"]})); + let names: Vec = s.model.state().bodies.iter().map(|b| b.name.clone()).collect(); + assert_eq!(names, ["A", "B"], "B is not a target and must stay a body"); + let b = run(&mut s, "inspect.measure", json!({"bodies": ["B"]}))["total"]["volume_mm3"].as_f64().unwrap(); + assert!(rel(b, 1000.0) < 1e-6, "B is unchanged: {b}"); + // A gains the half of the tool outside it. + let a = run(&mut s, "inspect.measure", json!({"bodies": ["A"]}))["total"]["volume_mm3"].as_f64().unwrap(); + assert!(rel(a, 1032.0) < 1e-6, "A: {a}"); +} + /// Deleting a sketch takes its extrude with it; a later combine that names the extrude's body must /// not be left failing silently: it is deleted too, or the reply reports it (#267). #[test]