diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index df9bc62c..872f8643 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -21,7 +21,7 @@ pub static COMMANDS: &[CommandSpec] = &[ .at("SOLID", "MODIFY") .icon("delete") .key("Delete") - .params("features: [id or name] (a sketch takes its dependent features with it)"), + .params("features: [id or name] (a sketch takes its dependent features with it) → deleted: [ids]; failing?: [{feature, error}] (later features that worked and now fail)"), CommandSpec::new("timeline.rename", "Rename", rename).params("feature: id|name, name"), CommandSpec::new("timeline.rollback", "Move Timeline Marker", rollback).params("position: number of features to keep active (omit = end)"), CommandSpec::new("timeline.roll_to", "Roll History Marker", rollback) @@ -120,6 +120,9 @@ fn delete(s: &mut Session, p: &Value) -> Result { if keys.is_empty() { return Err(bad(cmd, "`features` must list features")); } + // Features that work now; any of them failing after the delete is reported (a later + // feature that names a deleted body, #267). + let worked: std::collections::BTreeSet = s.model.results.iter().filter(|r| r.error.is_none() && !r.skipped).map(|r| r.id).collect(); let mut gone = Vec::new(); for k in keys { let Some(id) = s.doc.find_feature(&k).map(|f| f.id) else { @@ -134,7 +137,18 @@ fn delete(s: &mut Session, p: &Value) -> Result { s.active_sketch = None; } s.selection.clear(); - Ok(json!({"deleted": gone})) + s.refresh(); + let failing: Vec = s + .model + .results + .iter() + .filter(|r| worked.contains(&r.id)) + .filter_map(|r| r.error.as_ref().map(|e| json!({"feature": r.name, "error": e}))) + .collect(); + if failing.is_empty() { + return Ok(json!({"deleted": gone})); + } + Ok(json!({"deleted": gone, "failing": failing})) } fn rename(s: &mut Session, p: &Value) -> Result { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index ceafd9bb..c45b0dab 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -2489,6 +2489,27 @@ fn special_files_are_refused_by_every_file_command() { let _ = std::fs::remove_dir_all(&dir); } +/// 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] +fn deleting_a_sketch_leaves_no_silently_failing_feature() { + let mut s = Session::default(); + run(&mut s, "sketch.create", json!({"plane": "XY", "name": "S"})); + run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [20, 20]})); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "solid.extrude", json!({"sketch": "S", "distance": "10", "body_name": "P"})); + run(&mut s, "solid.box", json!({"length": 5, "width": 5, "height": 5, "corner": [18, 5, 0], "operation": "new_body", "body_name": "Q"})); + run(&mut s, "solid.combine", json!({"target": "P", "tools": ["Q"], "name": "JoinPQ"})); + let reply = run(&mut s, "timeline.delete", json!({"features": ["S"]})); + if let Ok(join) = s.execute("timeline.inspect", &json!({"feature": "JoinPQ"})) { + assert!( + join["error"].is_null() || reply.to_string().contains("JoinPQ"), + "JoinPQ now fails ({}) but the reply only says {reply}", + join["error"] + ); + } +} + /// Every body a multi-profile `new` extrude creates carries its `body_name`, not a generic BodyN (#266). #[test] fn multi_profile_extrude_names_every_body_after_body_name() {