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 <[email protected]>
Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
This commit is contained in:
bherbruckandClaude Opus 5.5 committed 2026-10-08 06:44:11 +00:00
1 parent 7f9a8f2afd
commit a7f301bde6
10 files changed
+476 -33

No files matched your search

+259
View File
@@ -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<Value> {
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<String> {
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<Value> {
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<Value> {
let cmd = "selection.delete";
let items: Vec<Sel> = 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::<Result<_>>()?
}
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<String> = Vec::new();
let mut skip = |why: String| {
if !skipped.contains(&why) {
skipped.push(why);
}
};
let mut entities = Vec::new();
let mut features: Vec<u64> = Vec::new();
let mut bodies: Vec<String> = 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<String> = 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<u64> = 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<String> = 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());
}
}
+2
View File
@@ -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());
+2
View File
@@ -32,6 +32,8 @@ pub struct TreeState {
pub redefine: Option<u64>,
/// Components picked in the tree (they have no viewport selection).
pub picked_components: Vec<u64>,
/// Canvases picked in the tree.
pub picked_canvases: Vec<u64>,
/// The last row clicked (for Shift ranges).
anchor: Option<String>,
/// Row keys in drawing order this frame (for Shift ranges).
+11 -4
View File
@@ -126,6 +126,8 @@ pub struct MenuState {
pending: Option<(Pos2, Target)>,
/// An inline rename in progress.
pub rename: Option<Rename>,
/// A delete waiting for its confirmation (it takes or breaks other features).
pub confirm: Option<crate::delete::Pending>,
/// The Properties window: title and measurements.
pub props: Option<(String, Value)>,
}
@@ -284,6 +286,7 @@ fn viewport_items(app: &SolveApp) -> Vec<Item> {
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<Item> {
.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::<Vec<_>>() }))
.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<Item> {
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<Item> {
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.
+11
View File
@@ -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))
+136
View File
@@ -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<u64> = 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(&params) {
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, &params);
}
}
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<String> {
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<bool> = 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);
}
}
+2
View File
@@ -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);
}
+51
View File
@@ -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());
}
+1 -29
View File
@@ -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<String> = Vec::new();
let mut features: Vec<u64> = Vec::new();
let mut bodies: Vec<String> = 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::<Vec<_>>()}));
}
let _ = app.run("select.clear", json!({}));
crate::delete::delete_selection(app);
}
pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
+1
View File
@@ -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` | — | |