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:
1 parent
7f9a8f2afd
commit
a7f301bde6
10 files changed
+476
-33
No files matched your search
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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(¶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<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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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` | — | |
|
||||
|
||||
Reference in new issue
Block a user