From a7f301bde6be090ae7e2c2323578ebf799226890 Mon Sep 17 00:00:00 2001 From: bherbruck Date: Thu, 8 Oct 2026 06:22:29 +0000 Subject: [PATCH] Delete for everything: selection.delete removes sketch entities, features and sketches (with their dependents), construction planes, bodies (Remove feature), occurrences and components, and canvases in one undo step, with a dry run; Delete/Backspace and every menu's Delete go through it and ask first, listing what goes with it and what would fail; faces say Delete Face is not available yet; ui.confirm control method Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL --- crates/engine/src/cmd/delete.rs | 259 +++++++++++++++++++++++++++++ crates/engine/src/cmd/mod.rs | 2 + crates/ui-egui/src/browser.rs | 2 + crates/ui-egui/src/context_menu.rs | 15 +- crates/ui-egui/src/control.rs | 11 ++ crates/ui-egui/src/delete.rs | 136 +++++++++++++++ crates/ui-egui/src/lib.rs | 2 + crates/ui-egui/src/menu_tests.rs | 51 ++++++ crates/ui-egui/src/viewport.rs | 30 +--- docs/control-protocol.md | 1 + 10 files changed, 476 insertions(+), 33 deletions(-) create mode 100644 crates/engine/src/cmd/delete.rs create mode 100644 crates/ui-egui/src/delete.rs diff --git a/crates/engine/src/cmd/delete.rs b/crates/engine/src/cmd/delete.rs new file mode 100644 index 00000000..d1a53cba --- /dev/null +++ b/crates/engine/src/cmd/delete.rs @@ -0,0 +1,259 @@ +//! Delete for everything that can be selected, in one undo step: sketch entities (while +//! sketching), timeline features and sketches (with the features that depend on them), +//! construction planes, bodies (a Remove feature from here on in the timeline), occurrences and +//! components, and canvases. `dry_run` reports what would go and what would fail without +//! changing anything, so the UI can ask first. + +use serde_json::{Value, json}; +use solvecraft_doc::FeatureKind; + +use super::CommandSpec; +use crate::params::{bad, bool_}; +use crate::{EngineError, Result, Sel, Session}; + +pub static COMMANDS: &[CommandSpec] = &[CommandSpec::new("selection.delete", "Delete", delete).icon("delete").params( + "items?: [selection items]; components?, occurrences?, canvases?: [ids or names]; dry_run?: bool → deleted, would_fail, skipped (one undo step)", +)]; + +/// Run another command's body inside this one (so the whole delete is one undo step). +fn sub(s: &mut Session, id: &str, p: Value) -> Result { + let spec = super::find_command(id).ok_or_else(|| EngineError::UnknownCommand(id.to_string()))?; + (spec.enabled)(s).map_err(|why| EngineError::Disabled(spec.id.to_string(), why))?; + let r = (spec.run)(s, &p)?; + s.refresh(); + Ok(r) +} + +fn keys(p: &Value, k: &str) -> Vec { + match p.get(k) { + Some(Value::Array(a)) => { + a.iter().take(10_000).filter_map(|v| v.as_str().map(str::to_string).or_else(|| v.as_u64().map(|n| n.to_string()))).collect() + } + _ => Vec::new(), + } +} + +fn delete(s: &mut Session, p: &Value) -> Result { + if bool_(p, "dry_run").unwrap_or(false) { + let mut t = s.scratch(); + return apply(&mut t, p); + } + let r = apply(s, p)?; + s.selection.clear(); + Ok(r) +} + +fn apply(s: &mut Session, p: &Value) -> Result { + let cmd = "selection.delete"; + let items: Vec = match p.get("items") { + None | Some(Value::Null) => Vec::new(), + Some(Value::Array(a)) if a.len() <= 10_000 => { + a.iter().map(|v| serde_json::from_value(v.clone()).map_err(|e| bad(cmd, format!("bad item {v}: {e}")))).collect::>()? + } + Some(_) => return Err(bad(cmd, "`items` must be a list of selection items")), + }; + let before = s.model.clone(); + let features_before: Vec<(u64, String)> = s.doc.features.iter().map(|f| (f.id, f.name.clone())).collect(); + let mut skipped: Vec = Vec::new(); + let mut skip = |why: String| { + if !skipped.contains(&why) { + skipped.push(why); + } + }; + let mut entities = Vec::new(); + let mut features: Vec = Vec::new(); + let mut bodies: Vec = Vec::new(); + for x in &items { + match x { + Sel::SketchCurve { id } | Sel::SketchPoint { id } => entities.push(id.clone()), + Sel::Feature { id } => { + if s.doc.feature(*id).is_none() { + return Err(bad(cmd, format!("no feature {id}"))); + } + features.push(*id); + } + Sel::Plane { name } => match s.doc.find_feature(name) { + Some(f) if matches!(f.kind, FeatureKind::ConstructionPlane { .. }) => features.push(f.id), + _ => skip("the origin planes can't be deleted".into()), + }, + Sel::Axis { .. } => skip("the origin axes can't be deleted".into()), + Sel::Body { name } => bodies.push(name.clone()), + Sel::Face { .. } => skip("Delete Face: not available yet".into()), + Sel::Edge { .. } | Sel::Vertex { .. } => skip("edges and vertices are deleted with their body or face".into()), + Sel::Profile { .. } => skip("a profile goes with its sketch".into()), + } + } + features.dedup(); + let mut done = false; + if !entities.is_empty() { + if s.active_sketch.is_none() { + skip("sketch entities are deleted while editing their sketch".into()); + } else { + sub(s, "sketch.delete", json!({ "entities": entities }))?; + done = true; + } + } + if !features.is_empty() { + let ids: Vec = features.iter().map(u64::to_string).collect(); + sub(s, "FusionDeleteCommand", json!({ "features": ids }))?; + done = true; + } + // Bodies still there (not made by a feature deleted above) get a Remove feature. + let st = s.model.state(); + bodies.retain(|b| st.body(b).is_some()); + bodies.dedup(); + if !bodies.is_empty() { + sub(s, "SoftDeleteCommand", json!({ "bodies": bodies }))?; + done = true; + } + // An occurrence goes alone while its component has others; the last one takes the component. + let mut components: Vec = Vec::new(); + for k in keys(p, "occurrences") { + let Some(o) = s.doc.occurrences.iter().find(|o| o.id.to_string() == k || o.name == k).cloned() else { + return Err(bad(cmd, format!("no occurrence `{k}`"))); + }; + if s.doc.occurrences.iter().filter(|x| x.component == o.component).count() > 1 { + s.doc_mut().occurrences.retain(|x| x.id != o.id); + s.pending_moves.remove(&o.id); + done = true; + } else { + components.push(o.component); + } + } + for k in keys(p, "components") { + let id = s.doc.find_component(&k).ok_or_else(|| bad(cmd, format!("no component `{k}`")))?; + if id == 0 { + return Err(bad(cmd, "the design itself can't be deleted")); + } + components.push(id); + } + components.dedup(); + for c in components { + // A parent deleted first takes its subcomponents with it. + if s.doc.components.iter().any(|x| x.id == c) { + sub(s, "component.delete", json!({ "component": c }))?; + done = true; + } + } + for k in keys(p, "canvases") { + sub(s, "canvas.delete", json!({ "canvas": k }))?; + done = true; + } + if !done { + return Err(bad(cmd, skipped.first().cloned().unwrap_or_else(|| "nothing to delete".into()))); + } + s.refresh(); + let deleted: Vec<&String> = features_before.iter().filter(|(id, _)| s.doc.feature(*id).is_none()).map(|(_, n)| n).collect(); + let would_fail: Vec = s + .model + .results + .iter() + .filter(|r| r.error.is_some() && before.result(r.id).is_some_and(|b| b.error.is_none() && !b.skipped)) + .map(|r| r.name.clone()) + .collect(); + Ok(json!({ "deleted": deleted, "bodies": bodies, "would_fail": would_fail, "skipped": skipped })) +} + +#[cfg(test)] +mod tests { + use serde_json::{Value, json}; + + use crate::Session; + + fn run(s: &mut Session, id: &str, p: Value) -> Value { + match s.execute(id, &p) { + Ok(v) => v, + Err(e) => panic!("{id} {p}: {e}"), + } + } + + fn bodies(s: &Session) -> usize { + s.model.state().bodies.len() + } + + /// A sketch with a rectangle extruded into a body, a second box, and an offset plane. + fn part() -> (Session, u64) { + let mut s = Session::default(); + let sk = run(&mut s, "SketchCreate", json!({"plane": "XY"}))["sketch"].as_u64().unwrap(); + run(&mut s, "ShapeRectangleTwoPoint", json!({"p0": [0, 0], "p1": [10, 20]})); + run(&mut s, "SketchStop", json!({})); + run(&mut s, "Extrude", json!({"distance": 5})); + run(&mut s, "PrimitiveBox", json!({"length": 5, "width": 5, "height": 5, "corner": [50, 0, 0]})); + run(&mut s, "ConstructionPlaneOffsetFromPlaneCommand", json!({"base": "XY", "offset": 10})); + (s, sk) + } + + #[test] + fn a_sketch_goes_with_its_dependents_after_a_dry_run() { + let (mut s, sk) = part(); + let n = s.doc.features.len(); + let dry = run(&mut s, "selection.delete", json!({"items": [{"type": "feature", "id": sk}], "dry_run": true})); + assert_eq!(s.doc.features.len(), n, "a dry run changes nothing"); + assert!(dry["deleted"].as_array().unwrap().len() >= 2, "the extrude goes with its sketch: {dry}"); + run(&mut s, "selection.delete", json!({"items": [{"type": "feature", "id": sk}]})); + assert_eq!(bodies(&s), 1); + run(&mut s, "UndoCommand", json!({})); + assert_eq!(s.doc.features.len(), n); + assert_eq!(bodies(&s), 2); + } + + #[test] + fn mixed_selection_is_one_undo_step() { + let (mut s, _) = part(); + let body = s.model.state().bodies[1].name.clone(); + let depth = s.undo.len(); + let n = s.doc.features.len(); + let r = run( + &mut s, + "selection.delete", + json!({"items": [{"type": "body", "name": body}, {"type": "plane", "name": "Plane1"}, {"type": "plane", "name": "XY"}]}), + ); + assert_eq!(bodies(&s), 1, "{r}"); + assert!(s.doc.find_feature("Plane1").is_none()); + assert_eq!(r["skipped"][0], "the origin planes can't be deleted"); + assert_eq!(s.undo.len(), depth + 1); + run(&mut s, "UndoCommand", json!({})); + assert_eq!((bodies(&s), s.doc.features.len()), (2, n)); + } + + #[test] + fn faces_alone_are_refused_and_hostile_input_too() { + let (mut s, _) = part(); + let b = s.model.state().bodies[0].name.clone(); + let e = s.execute("selection.delete", &json!({"items": [{"type": "face", "body": b, "index": 0, "point": [0, 0, 0]}]})).unwrap_err(); + assert!(e.to_string().contains("not available yet"), "{e}"); + for p in [ + json!({}), + json!({"items": "x"}), + json!({"items": [{"type": "nope"}]}), + json!({"items": [{"type": "feature", "id": 999}]}), + json!({"components": ["0"]}), + ] { + assert!(s.execute("selection.delete", &p).is_err(), "{p}"); + } + } + + #[test] + fn occurrences_components_and_sketch_entities() { + let (mut s, _) = part(); + let b = s.model.state().bodies[1].name.clone(); + run(&mut s, "FusionCreateComponentsFromBodiesCommand", json!({"bodies": [b]})); + let c = s.doc.components[0].id; + let o1 = s.doc.occurrence_of(c).unwrap().id; + run(&mut s, "occurrence.copy", json!({"component": c, "translate": [0, 30, 0]})); + assert_eq!(s.doc.occurrences.len(), 2); + // One instance goes alone; the last one takes its component. + run(&mut s, "selection.delete", json!({"occurrences": [o1]})); + assert_eq!((s.doc.occurrences.len(), s.doc.components.len()), (1, 1)); + let o2 = s.doc.occurrences[0].id; + run(&mut s, "selection.delete", json!({"occurrences": [o2]})); + assert!(s.doc.components.is_empty()); + // Sketch entities while sketching. + run(&mut s, "SketchCreate", json!({"plane": "XY"})); + run(&mut s, "DrawPolyline", json!({"points": [[0, 0], [10, 0]]})); + let sk = s.active_sketch.unwrap(); + let id = s.model.state().sketch(sk).unwrap().sketch.curves[0].id.clone(); + run(&mut s, "selection.delete", json!({"items": [{"type": "sketch_curve", "id": id}]})); + assert!(s.model.state().sketch(sk).unwrap().sketch.curves.is_empty()); + } +} diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index c00ca4f4..ab8e4b68 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -5,6 +5,7 @@ mod browser; mod clipboard; mod component; +mod delete; mod edit; mod face; mod features; @@ -138,6 +139,7 @@ pub fn command_specs() -> Vec<&'static CommandSpec> { v.extend(component::COMMANDS.iter()); v.extend(joints::COMMANDS.iter()); v.extend(browser::COMMANDS.iter()); + v.extend(delete::COMMANDS.iter()); v.extend(edit::COMMANDS.iter()); v.extend(clipboard::COMMANDS.iter()); v.extend(parameters::COMMANDS.iter()); diff --git a/crates/ui-egui/src/browser.rs b/crates/ui-egui/src/browser.rs index e76e4d3b..eaf5b326 100644 --- a/crates/ui-egui/src/browser.rs +++ b/crates/ui-egui/src/browser.rs @@ -32,6 +32,8 @@ pub struct TreeState { pub redefine: Option, /// Components picked in the tree (they have no viewport selection). pub picked_components: Vec, + /// Canvases picked in the tree. + pub picked_canvases: Vec, /// The last row clicked (for Shift ranges). anchor: Option, /// Row keys in drawing order this frame (for Shift ranges). diff --git a/crates/ui-egui/src/context_menu.rs b/crates/ui-egui/src/context_menu.rs index 33ea06d3..5193fc81 100644 --- a/crates/ui-egui/src/context_menu.rs +++ b/crates/ui-egui/src/context_menu.rs @@ -126,6 +126,8 @@ pub struct MenuState { pending: Option<(Pos2, Target)>, /// An inline rename in progress. pub rename: Option, + /// A delete waiting for its confirmation (it takes or breaks other features). + pub confirm: Option, /// The Properties window: title and measurements. pub props: Option<(String, Value)>, } @@ -284,6 +286,7 @@ fn viewport_items(app: &SolveApp) -> Vec { v.push(act("ui.findBrowser", "Find in Browser", "").with(json!({ "body": face }))); v.push(act("ui.findTimeline", "Find in Timeline", "").with(json!({ "body": face }))); v.push(act("ui.hide", "Hide Body", "eye").with(json!({ "bodies": [face] }))); + v.push(act("ui.delete", "Delete", "delete").key("Del")); } else if has(|s| matches!(s, Sel::Edge { .. })) { v.push(cmd(app, "FusionFilletEdgesCommand", "Fillet")); v.push(cmd(app, "FusionChamferCommand", "Chamfer")); @@ -338,7 +341,10 @@ fn body_items(app: &SolveApp, bodies: &[String], browser: bool) -> Vec { .with(json!({ "bodies": names })), act("ui.isolate", "Isolate", "").with(json!({ "bodies": names })), act("ui.rename", "Rename", "").with(json!({ "body": bodies.first() })).on(one), - cmd(app, "SoftDeleteCommand", "Delete").key("Del").with(json!({ "bodies": names })).on(!locked), + act("ui.delete", "Delete", "delete") + .key("Del") + .with(json!({ "items": bodies.iter().map(|b| json!({"type": "body", "name": b})).collect::>() })) + .on(!locked), act("ui.lock", if locked { "Unlock" } else { "Lock" }, "").with(json!({ "bodies": names })), Item::sep(), act("ui.properties", "Properties", "measure").with(json!({ "bodies": names })), @@ -368,7 +374,7 @@ fn sketch_items(app: &SolveApp, id: u64) -> Vec { act("ui.sketchDims", if dims { "Hide Dimensions" } else { "Show Dimensions" }, "dimension").with(json!({ "sketch": id })), act("ui.sketchProfile", if profiles { "Hide Profile" } else { "Show Profile" }, "").with(json!({ "sketch": id })), act("ui.rename", "Rename", "").with(json!({ "feature": id })), - cmd(app, "FusionDeleteCommand", "Delete").key("Del").with(json!({ "features": [id.to_string()] })), + act("ui.delete", "Delete", "delete").key("Del").with(json!({ "items": [{"type": "feature", "id": id}] })), Item::sep(), act("ui.findTimeline", "Find in Timeline", "").with(json!({ "feature": id })), ] @@ -397,7 +403,7 @@ fn component_items(app: &SolveApp, id: u64) -> Vec { if id != 0 { let grounded = occ.is_some_and(|o| o.grounded); let oid = occ.map(|o| o.id).unwrap_or(0); - v.push(cmd(app, "component.delete", "Delete").key("Del").with(json!({ "component": id }))); + v.push(act("ui.delete", "Delete", "delete").key("Del").with(json!({ "occurrences": [occ.map(|o| o.id)] })).on(occ.is_some())); v.push(Item::sep()); v.push(act("ui.moveOccurrence", "Move/Copy", "move").with(json!({ "occurrence": oid })).on(!grounded && occ.is_some())); v.push(cmd(app, "occurrence.ground", if grounded { "Unground" } else { "Ground" }).with(json!({ "occurrence": oid, "grounded": !grounded }))); @@ -523,6 +529,7 @@ pub fn run_item(app: &mut SolveApp, item: &Item, at: Pos2) { app.start(&id); } } + "ui.delete" if item.params.is_some() => crate::delete::request(app, p), "ui.delete" => delete(app), "ui.finishSketch" => app.finish_sketch(), "ui.hide" => { @@ -649,7 +656,7 @@ pub fn run_item(app: &mut SolveApp, item: &Item, at: Pos2) { /// Delete what is selected: sketch entities, features, and bodies (a Remove feature). pub fn delete(app: &mut SolveApp) { - crate::viewport::delete_selection(app); + crate::delete::delete_selection(app); } /// Add the edges that continue the selected edges smoothly to the selection. diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 9c79421a..79e64e4e 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -21,6 +21,7 @@ //! the open menu and its items. `ui.menuPick {item}` runs an item by id or label; //! `ui.rename {text?, commit?}` finishes the rename box //! - `ui.window {action: minimize|maximize|restore|toggle|close}`: what the title bar's buttons do +//! - `ui.confirm {accept?}`: the Delete confirmation (without `accept`: what it lists) //! - `ui.resize {width, height}`, `app.quit` use std::sync::mpsc::Sender; @@ -267,6 +268,16 @@ pub fn handle(app: &mut SolveApp, ctx: &egui::Context, req: &ControlRequest) -> } ok(crate::context_menu::describe(app)) } + "ui.confirm" => match p.get("accept").and_then(Value::as_bool) { + Some(a) => { + if crate::delete::confirm(app, a) { + ok(json!({"accepted": a})) + } else { + err("nothing is waiting for a confirmation") + } + } + None => ok(json!({"pending": app.menu.confirm.as_ref().map(|c| c.report.clone())})), + }, "ui.menuPick" => { let Some(key) = s("item") else { return err("missing `item` (id or label)") }; wrap(crate::context_menu::pick(app, key)) diff --git a/crates/ui-egui/src/delete.rs b/crates/ui-egui/src/delete.rs new file mode 100644 index 00000000..818ca92c --- /dev/null +++ b/crates/ui-egui/src/delete.rs @@ -0,0 +1,136 @@ +//! Delete (the Delete and Backspace keys, and every menu's Delete item): whatever is selected in +//! the viewport or the browser goes in one undoable step through `selection.delete`. When it +//! takes other features with it or makes some fail, a confirmation lists them first. + +use egui::{RichText, vec2}; +use serde_json::{Value, json}; + +use crate::SolveApp; +use crate::theme::Tokens; + +/// A delete waiting for confirmation: its parameters and the dry run's report. +#[derive(Clone, Debug)] +pub struct Pending { + pub params: Value, + pub report: Value, +} + +/// What Delete acts on now: the selection (bodies not locked), plus the components picked in +/// the browser (their occurrences). +pub fn selection_params(app: &SolveApp) -> Value { + let items: Vec<&solvecraft_engine::Sel> = + app.session.selection.iter().filter(|s| !matches!(s, solvecraft_engine::Sel::Body { name } if app.ui.locked_bodies.contains(name))).collect(); + let occurrences: Vec = app.tree.picked_components.iter().filter_map(|c| app.session.doc.occurrence_of(*c).map(|o| o.id)).collect(); + json!({ "items": items, "occurrences": occurrences, "canvases": app.tree.picked_canvases }) +} + +fn is_empty(p: &Value) -> bool { + ["items", "occurrences", "components", "canvases"].iter().all(|k| p.get(*k).and_then(Value::as_array).is_none_or(|a| a.is_empty())) +} + +/// Delete what is selected (the Delete key). A selected sketch dimension or constraint glyph +/// goes first, as before. +pub fn delete_selection(app: &mut SolveApp) { + if crate::dim_view::delete_selected(app) || crate::sketch_tools::delete_glyph(app) { + return; + } + request(app, selection_params(app)); +} + +/// Delete `params` (as `selection.delete` takes them): at once, or after a confirmation when +/// other features go with it or would fail. +pub fn request(app: &mut SolveApp, params: Value) { + if is_empty(¶ms) { + return; + } + let mut dry = params.clone(); + dry["dry_run"] = json!(true); + let report = match app.session.execute("selection.delete", &dry) { + Ok(r) => r, + Err(e) => { + app.set_status(e.to_string().replace("selection.delete: ", ""), true); + return; + } + }; + let asked = + params.get("items").and_then(Value::as_array).map_or(0, |a| a.iter().filter(|i| i["type"] == "feature" || i["type"] == "plane").count()); + let extra = report["deleted"].as_array().map_or(0, Vec::len) > asked; + let fails = report["would_fail"].as_array().is_some_and(|a| !a.is_empty()); + if extra || fails { + app.menu.confirm = Some(Pending { params, report }); + } else { + run(app, ¶ms); + } +} + +fn run(app: &mut SolveApp, params: &Value) { + match app.run("selection.delete", params.clone()) { + Ok(r) => { + app.tree.picked_components.clear(); + app.tree.picked_canvases.clear(); + if let Some(s) = r["skipped"].as_array().and_then(|a| a.first()).and_then(Value::as_str) { + app.set_status(format!("deleted; {s}"), false); + } + } + Err(e) => app.set_status(e.replace("selection.delete: ", ""), true), + } +} + +/// Answer the confirmation (also `ui.confirm`): delete, or keep everything. +pub fn confirm(app: &mut SolveApp, accept: bool) -> bool { + let Some(p) = app.menu.confirm.take() else { return false }; + if accept { + run(app, &p.params); + } + true +} + +/// The confirmation window. +pub fn show(app: &mut SolveApp, ctx: &egui::Context) { + let Some(p) = app.menu.confirm.clone() else { return }; + let t = Tokens::get(); + let list = |k: &str| -> Vec { + p.report[k].as_array().map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect()).unwrap_or_default() + }; + let (deleted, fails) = (list("deleted"), list("would_fail")); + let mut answer: Option = None; + egui::Window::new("Delete") + .id(egui::Id::new("sc_delete_confirm")) + .collapsible(false) + .resizable(false) + .anchor(egui::Align2::CENTER_CENTER, vec2(0.0, -60.0)) + .show(ctx, |ui| { + ui.set_max_width(360.0); + if !deleted.is_empty() { + ui.label("These features will be deleted:"); + for d in deleted.iter().take(20) { + ui.label(RichText::new(format!(" • {d}")).color(t.text)); + } + if deleted.len() > 20 { + ui.label(format!(" … and {} more", deleted.len() - 20)); + } + } + if !fails.is_empty() { + ui.add_space(6.0); + ui.label(RichText::new("These features will fail:").color(t.warning)); + for f in fails.iter().take(20) { + ui.label(RichText::new(format!(" • {f}")).color(t.warning)); + } + } + ui.add_space(8.0); + ui.horizontal(|ui| { + if ui.button("Delete").clicked() { + answer = Some(true); + } + if ui.button("Cancel").clicked() { + answer = Some(false); + } + }); + if ui.input(|i| i.key_pressed(egui::Key::Escape)) { + answer = Some(false); + } + }); + if let Some(a) = answer { + confirm(app, a); + } +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 7881092f..07781cbf 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -12,6 +12,7 @@ pub mod browser; pub mod canvas; pub mod context_menu; pub mod control; +pub mod delete; pub mod dialogs; pub mod dim_view; pub mod gpu; @@ -606,6 +607,7 @@ impl SolveApp { dialogs::show(self, ui.ctx()); params_dialog::show(self, ui.ctx()); context_menu::show(self, ui.ctx()); + delete::show(self, ui.ctx()); if self.custom_titlebar { titlebar::resize_zones(ui); } diff --git a/crates/ui-egui/src/menu_tests.rs b/crates/ui-egui/src/menu_tests.rs index e34da427..f2cf5b2f 100644 --- a/crates/ui-egui/src/menu_tests.rs +++ b/crates/ui-egui/src/menu_tests.rs @@ -201,3 +201,54 @@ fn browser_folds_are_kept_in_the_preferences() { other.load_prefs(&prefs); assert!(other.tree.collapsed.contains("c0/sketches") && other.tree.expanded.contains("origin")); } + +#[test] +fn delete_key_handles_every_kind_and_asks_about_dependents() { + use solvecraft_engine::Sel; + let mut app = sample_app(); + app.run("ConstructionPlaneOffsetFromPlaneCommand", json!({"base": "XY", "offset": 10})).unwrap(); + app.run("PrimitiveBox", json!({"length": 5, "width": 5, "height": 5, "corner": [200, 0, 0]})).unwrap(); + let n = app.session.doc.features.len(); + // A body and a construction plane together: one undo step, no question. + let b = app.session.model.state().bodies.last().unwrap().name.clone(); + app.run("select.set", json!({"items": [{"type": "body", "name": b}, {"type": "plane", "name": "Plane1"}]})).unwrap(); + crate::delete::delete_selection(&mut app); + assert!(app.menu.confirm.is_none()); + assert!(app.session.model.state().body(&b).is_none()); + assert!(app.session.doc.find_feature("Plane1").is_none()); + app.run("UndoCommand", json!({})).unwrap(); + assert_eq!(app.session.doc.features.len(), n); + // A sketch with features built on it: the confirmation lists them first. + let sk = app.session.doc.features.iter().find(|f| f.name == "Base").unwrap().id; + app.run("select.set", json!({"items": [Sel::Feature { id: sk }]})).unwrap(); + crate::delete::delete_selection(&mut app); + let pending = app.menu.confirm.clone().expect("asks first"); + assert!(pending.report["deleted"].as_array().unwrap().len() > 1); + assert!(crate::delete::confirm(&mut app, false)); + assert_eq!(app.session.doc.features.len(), n, "cancel keeps everything"); + crate::delete::delete_selection(&mut app); + crate::delete::confirm(&mut app, true); + assert!(app.session.doc.features.len() < n); + app.run("UndoCommand", json!({})).unwrap(); + assert_eq!(app.session.doc.features.len(), n); + // A face alone: not available yet, nothing changes. + let b0 = app.session.model.state().bodies[0].name.clone(); + app.run("select.set", json!({"items": [{"type": "face", "body": b0, "index": 0, "point": [0, 0, 0]}]})).unwrap(); + crate::delete::delete_selection(&mut app); + assert!(app.status.as_ref().is_some_and(|s| s.0.contains("not available yet")), "{:?}", app.status); + assert_eq!(app.session.doc.features.len(), n); +} + +#[test] +fn menu_delete_items_use_the_same_path() { + let mut app = sample_app(); + let b = body(&app); + app.run("FusionCreateComponentsFromBodiesCommand", json!({"bodies": [b]})).unwrap(); + let c = app.session.doc.components[0].id; + act(&mut app, &Target::Component { id: c }, "Delete"); + assert!(app.session.doc.components.is_empty()); + // A sketch from the browser: asks, since its features go too. + let id = app.session.doc.features.iter().find(|f| f.name == "Base").unwrap().id; + act(&mut app, &Target::Sketch { id }, "Delete"); + assert!(app.menu.confirm.is_some()); +} diff --git a/crates/ui-egui/src/viewport.rs b/crates/ui-egui/src/viewport.rs index 481688dc..87490415 100644 --- a/crates/ui-egui/src/viewport.rs +++ b/crates/ui-egui/src/viewport.rs @@ -962,35 +962,7 @@ pub fn sketch_point_at(app: &SolveApp, proj: &Proj, pos: Pos2) -> Option<(Vec2, } pub fn delete_selection(app: &mut SolveApp) { - if crate::dim_view::delete_selected(app) || crate::sketch_tools::delete_glyph(app) { - return; - } - let sel = app.session.selection.clone(); - if sel.is_empty() { - return; - } - let mut sketch_ids: Vec = Vec::new(); - let mut features: Vec = Vec::new(); - let mut bodies: Vec = Vec::new(); - for x in &sel { - match x { - Sel::SketchCurve { id } | Sel::SketchPoint { id } => sketch_ids.push(id.clone()), - Sel::Feature { id } => features.push(*id), - Sel::Body { name } if !app.ui.locked_bodies.contains(name) => bodies.push(name.clone()), - _ => {} - } - } - // Deleting a body removes it from here on in the timeline (a Remove feature). - if !bodies.is_empty() && app.session.active_sketch.is_none() { - let _ = app.run("SoftDeleteCommand", json!({ "bodies": bodies })); - } - if !sketch_ids.is_empty() && app.session.active_sketch.is_some() { - let _ = app.run("sketch.delete", json!({"entities": sketch_ids})); - } - if !features.is_empty() { - let _ = app.run("FusionDeleteCommand", json!({"features": features.iter().map(|f| f.to_string()).collect::>()})); - } - let _ = app.run("select.clear", json!({})); + crate::delete::delete_selection(app); } pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) { diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 7304dc4e..80722f1b 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -39,6 +39,7 @@ Commands never open dialogs when run this way. `solvecraft-cli commands` prints | `ui.text` | `text` | typed text | | `ui.screenshot` | `path?` | PNG of the window (needs a presented frame) | | `ui.render` | `path`, `width?`, `height?` | CPU render of the model with the current camera (no window needed) | +| `ui.confirm` | `accept?`: bool | answers the Delete confirmation (shown when a delete takes other features with it or makes some fail); without `accept`, what it lists | | `ui.window` | `action`: minimize, maximize, restore, toggle, close | what the title bar's caption buttons do (on Windows and Linux the application bar is the title bar) | | `ui.resize` | `width`, `height` | | | `app.quit` | — | |