diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs index 00268c3bc..f151d3738 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -1779,7 +1779,7 @@ impl OpenCADStudio { } => { let label = self.history_label_from_active_cmd(i, "SOLID"); let erase_source = erase_source.filter(|handle| { - self.tabs[i].scene.document.header.delete_objects + self.delete_objects != 0 && !self.tabs[i].scene.is_layer_locked(*handle) }); let pending = self.begin_undo( @@ -3661,7 +3661,7 @@ impl OpenCADStudio { taper_angle, color: _, } => { - let delete_sources = self.tabs[i].scene.document.header.delete_objects; + let delete_sources = self.delete_objects != 0; if handles.is_empty() || handles .iter() @@ -3948,7 +3948,7 @@ impl OpenCADStudio { self.restore_pre_cmd_tangent(); return Task::none(); } - let delete_sources = self.tabs[i].scene.document.header.delete_objects; + let delete_sources = self.delete_objects != 0; use crate::command::ExtrudeMode; use crate::modules::insert::solid3d_cmds::{ empty_revolved_surface, empty_solid3d, @@ -4082,7 +4082,9 @@ impl OpenCADStudio { use crate::command::ExtrudeMode; use crate::modules::insert::solid3d_cmds::empty_solid3d; use crate::scene::model::sweep_model; - let delete_sources = self.tabs[i].scene.document.header.delete_objects; + let delete_objects = self.delete_objects; + let delete_profiles = + crate::app::delobj_deletes_profiles(delete_objects); let path = self.tabs[i].scene.document.get_entity(path_handle).cloned(); let mut profiles = Vec::new(); let mut failed = 0usize; @@ -4120,6 +4122,14 @@ impl OpenCADStudio { failed += 1; continue; }; + let deletes_path = crate::app::delobj_deletes_auxiliary( + delete_objects, + surface, + ); + if deletes_path && self.tabs[i].scene.is_layer_locked(path_handle) { + failed += 1; + continue; + } let created = if surface { self.add_surface_model(sweep_model::swept_surface_entity(&record), body) } else { @@ -4132,9 +4142,12 @@ impl OpenCADStudio { failed += 1; } else { created_handles.push(created); - if delete_sources { + if delete_profiles { consumed.push(handle); } + if deletes_path { + consumed.push(path_handle); + } } } if !created_handles.is_empty() { @@ -4175,12 +4188,15 @@ impl OpenCADStudio { }).collect::>(); sources.sort_unstable_by_key(|handle| handle.value()); sources.dedup(); - let delete_sources = self.tabs[i].scene.document.header.delete_objects; - let locked = delete_sources && sources.iter().any(|handle| self.tabs[i].scene.is_layer_locked(*handle)); + let delete_objects = self.delete_objects; + let delete_sections = + crate::app::delobj_deletes_profiles(delete_objects); + let section_locked = delete_sections + && sources.iter().any(|handle| self.tabs[i].scene.is_layer_locked(*handle)); let available = sources.iter().chain(guides.iter()).copied().chain(path) .filter_map(|handle| self.tabs[i].scene.document.get_entity(handle) .cloned().map(|entity| (handle, entity))).collect::>(); - let result = if locked { + let result = if section_locked { Err("LOFT: a source is on a locked layer; disable source deletion or unlock it.".to_string()) } else { loft_command_model::record(§ions, &guides, path, &available, mode, options) @@ -4189,6 +4205,19 @@ impl OpenCADStudio { match result { Ok((body, record)) => { let surface = record.parameters.as_ref().is_some_and(|settings| settings.surface); + let delete_auxiliary = crate::app::delobj_deletes_auxiliary( + delete_objects, + surface, + ); + if delete_auxiliary + && guides.iter().copied().chain(path) + .any(|handle| self.tabs[i].scene.is_layer_locked(handle)) + { + self.command_line.push_error( + "LOFT: a source is on a locked layer; disable source deletion or unlock it.", + ); + return Task::none(); + } let dirty_before = self.tabs[i].dirty; let pending = self.begin_undo(i, "LOFT", 1, true); let created = if surface { @@ -4210,7 +4239,18 @@ impl OpenCADStudio { self.command_line.push_error(crate::t!("LOFT could not create a complete display. The source sections were preserved.").as_ref()); return Task::none(); } else { - if delete_sources { self.tabs[i].scene.erase_entities(&sources); } + let mut consumed = if delete_sections { + sources.clone() + } else { + Vec::new() + }; + if delete_auxiliary { + consumed.extend(guides.iter().copied()); + consumed.extend(path); + } + consumed.sort_unstable_by_key(|handle| handle.value()); + consumed.dedup(); + self.tabs[i].scene.erase_entities(&consumed); self.tabs[i].scene.deselect_all(); self.tabs[i].scene.select_entity(created, true); self.tabs[i].dirty = true; diff --git a/src/app/commands/draw.rs b/src/app/commands/draw.rs index a17d1a9cb..6807abfa1 100644 --- a/src/app/commands/draw.rs +++ b/src/app/commands/draw.rs @@ -1113,7 +1113,8 @@ impl OpenCADStudio { use acadrust::entities::Region; use acadrust::types::Vector3; let mut regions = Vec::new(); - for (_, e) in self.tabs[i].scene.selected_entities().iter() { + let mut sources = Vec::new(); + for (handle, e) in self.tabs[i].scene.selected_entities().iter() { let supported = matches!( e, acadrust::EntityType::LwPolyline(pl) @@ -1136,6 +1137,7 @@ impl OpenCADStudio { ); region.common.layer = self.tabs[i].active_layer.clone(); regions.push((region, body)); + sources.push(*handle); } } if regions.is_empty() { @@ -1154,6 +1156,10 @@ impl OpenCADStudio { } created.push(handle); } + if self.delete_objects != 0 { + self.tabs[i].scene.erase_entities(&sources); + self.refresh_properties(); + } self.tabs[i].dirty = true; self.command_line .push_output(crate::tf!("REGION: created {count} region(s).").as_ref()); @@ -1530,3 +1536,47 @@ impl OpenCADStudio { Some(self.finish_dispatch(cmd)) } } + +#[cfg(test)] +mod region_tests { + use crate::app::OpenCADStudio; + + #[test] + fn region_respects_delobj_and_preserves_unconverted_sources() { + for delete_sources in [false, true] { + let mut app = OpenCADStudio::new_for_test(); + app.automation_op(r#"{"op":"new"}"#); + app.automation_op(r#"{"op":"run","cmd":"CIRCLE 5,5 3"}"#); + app.automation_op(r#"{"op":"run","cmd":"LINE 0,0 10,10"}"#); + let i = app.active_tab; + let sources: Vec<_> = app.tabs[i].scene.document.entities() + .map(|entity| { + (entity.common().handle, matches!(entity, acadrust::EntityType::Circle(_))) + }) + .collect(); + assert_eq!(sources.len(), 2); + let handles: Vec<_> = sources.iter().map(|(handle, _)| *handle).collect(); + app.tabs[i].scene.select_entities(&handles); + app.delete_objects = i16::from(delete_sources); + + let _ = app.dispatch_command("REGION"); + + for (handle, is_circle) in &sources { + assert_eq!( + app.tabs[i].scene.document.get_entity(*handle).is_some(), + !delete_sources || !is_circle, + ); + } + let region_count = app.tabs[i].scene.document.entities() + .filter(|entity| matches!(entity, acadrust::EntityType::Region(_))) + .count(); + assert_eq!(region_count, 1); + + app.automation_op(r#"{"op":"undo"}"#); + for (handle, _) in &sources { + assert!(app.tabs[i].scene.document.get_entity(*handle).is_some()); + } + assert_eq!(app.tabs[i].scene.document.entities().count(), 2); + } + } +} diff --git a/src/app/commands/styleprops.rs b/src/app/commands/styleprops.rs index 939eb38fb..4efc41f80 100644 --- a/src/app/commands/styleprops.rs +++ b/src/app/commands/styleprops.rs @@ -1159,6 +1159,11 @@ impl OpenCADStudio { "0" | "OFF" | "FALSE" => Some(false), _ => None, }; + let current_delete_objects = self.delete_objects; + let requested_delete_objects = (name == "DELOBJ") + .then(|| value.as_deref()?.parse::().ok()) + .flatten() + .filter(|value| (0..=3).contains(value)); let outcome: Result<(String, bool), String> = { let h = &mut self.tabs[i].scene.document.header; match name.as_str() { @@ -1633,15 +1638,18 @@ impl OpenCADStudio { } }, "DELOBJ" => match &value { - Some(v) => parse_bool(v) - .map(|b| { - h.delete_objects = b; - (format!("DELOBJ = {}", b as i32), true) + Some(v) => v + .parse::() + .ok() + .filter(|value| (0..=3).contains(value)) + .map(|value| { + (format!("DELOBJ = {value}"), true) }) - .ok_or_else(|| "SETVAR: 0 or 1 required.".into()), - None => { - Ok((format!("DELOBJ = {}", h.delete_objects as i32), false)) - } + .ok_or_else(|| "SETVAR: integer from 0 to 3 required.".into()), + None => Ok(( + format!("DELOBJ = {current_delete_objects}"), + false, + )), }, "PLINEGEN" => match &value { Some(v) => parse_bool(v) @@ -2119,6 +2127,9 @@ impl OpenCADStudio { }; match outcome { Ok((msg, changed)) => { + if let Some(value) = requested_delete_objects { + self.delete_objects = value; + } if changed { if matches!( name.as_str(), @@ -2144,6 +2155,7 @@ impl OpenCADStudio { | "GRIPHOT" | "GRIPHOVER" | "GRIPOBJLIMIT" + | "DELOBJ" ) { self.persist_settings_if_changed(); } else { @@ -3120,4 +3132,17 @@ mod tests { assert!(app.tabs[app.active_tab].active_cmd.is_none()); assert_eq!(app.commandline_fade_ms, 5000); } + + #[test] + fn delobj_accepts_values_zero_through_three() { + let mut app = fresh_app(); + + for value in 0..=3 { + let _ = app.run_command_line(&format!("SETVAR DELOBJ {value}")); + assert_eq!(app.delete_objects, value); + } + + let _ = app.run_command_line("SETVAR DELOBJ 4"); + assert_eq!(app.delete_objects, 3); + } } diff --git a/src/app/mod.rs b/src/app/mod.rs index 1f34f7c8a..4a72dd08b 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -347,6 +347,52 @@ pub(crate) enum TextEntryMode { FreeText, } +pub(crate) fn delobj_deletes_profiles(value: i16) -> bool { + value != 0 +} + +pub(crate) fn delobj_deletes_auxiliary(value: i16, creates_surface: bool) -> bool { + value == 2 || (value == 3 && !creates_surface) +} + +pub(crate) fn delobj_deletes_conversion_source(value: i16) -> bool { + value >= 1 +} + +#[cfg(test)] +mod delobj_policy_tests { + use super::{ + delobj_deletes_auxiliary, delobj_deletes_conversion_source, + delobj_deletes_profiles, + }; + + #[test] + fn values_zero_through_three_select_the_expected_sources() { + assert_eq!( + (0..=3).map(delobj_deletes_profiles).collect::>(), + [false, true, true, true] + ); + assert_eq!( + (0..=3) + .map(|value| delobj_deletes_auxiliary(value, true)) + .collect::>(), + [false, false, true, false] + ); + assert_eq!( + (0..=3) + .map(|value| delobj_deletes_auxiliary(value, false)) + .collect::>(), + [false, false, true, true] + ); + assert_eq!( + (0..=3) + .map(delobj_deletes_conversion_source) + .collect::>(), + [false, true, true, true] + ); + } +} + pub(super) struct OpenCADStudio { start: Instant, control: control::State, @@ -566,6 +612,8 @@ pub(super) struct OpenCADStudio { /// `MIRRTEXT`) — the next command-line entry is its new value, empty keeps /// the current one. pending_setvar: Option, + /// DELOBJ system variable (0–3), shared by every open drawing. + delete_objects: i16, /// Cursor is hovering over the UCS icon body — drives the hover highlight. ucs_icon_hover: bool, /// UCS icon is selected (clicked): its grips are shown and draggable. @@ -3424,6 +3472,7 @@ impl OpenCADStudio { block_usage_last_persist: None, awaiting_vports: false, pending_setvar: None, + delete_objects: 1, ucs_icon_hover: false, ucs_icon_selected: false, ucs_grip_drag: None, diff --git a/src/app/model_ops.rs b/src/app/model_ops.rs index d767d9005..915dd0eac 100644 --- a/src/app/model_ops.rs +++ b/src/app/model_ops.rs @@ -2293,7 +2293,9 @@ impl super::OpenCADStudio { surfaces.push(surf); } self.push_undo_snapshot(i, "CONVTOSURFACE"); - self.tabs[i].scene.erase_entities(&handles); + if crate::app::delobj_deletes_conversion_source(self.delete_objects) { + self.tabs[i].scene.erase_entities(&handles); + } let n = surfaces.len(); for surf in surfaces { self.tabs[i].scene.add_entity(EntityType::Surface(surf)); diff --git a/src/app/settings.rs b/src/app/settings.rs index fb63859e2..bdfab8ff9 100644 --- a/src/app/settings.rs +++ b/src/app/settings.rs @@ -219,6 +219,10 @@ pub struct UserSettings { /// the current creation layer/style. Registry-style, app-level preference. #[serde(default = "default_dimension_continue_mode")] pub dimension_continue_mode: i16, + /// DELOBJ: source-geometry deletion policy (0–3). Registry-style, + /// app-level preference; first-run default is 1. + #[serde(default = "default_delete_objects")] + pub delete_objects: i16, /// TEXTFILL: fill TrueType glyphs (true) or draw them hollow (false). pub textfill: bool, /// When true, saving over an existing file first copies it to a sibling @@ -275,6 +279,10 @@ fn default_clipromptlines() -> i32 { 3 } +fn default_delete_objects() -> i16 { + 1 +} + fn default_commandline_fade_ms() -> i32 { 3000 } @@ -339,6 +347,7 @@ impl Default for UserSettings { texteditmode: false, quick_dimension_snap_priority: 0, dimension_continue_mode: 1, + delete_objects: 1, textfill: true, backup_on_save: true, file_assoc_enabled: true, diff --git a/src/app/update/file.rs b/src/app/update/file.rs index 4799d1cd9..771b2603b 100644 --- a/src/app/update/file.rs +++ b/src/app/update/file.rs @@ -392,6 +392,7 @@ impl OpenCADStudio { texteditmode: self.texteditmode, quick_dimension_snap_priority: self.quick_dimension_snap_priority, dimension_continue_mode: self.dimension_continue_mode, + delete_objects: self.delete_objects, textfill: crate::scene::text::sdf_atlas::textfill(), backup_on_save: self.backup_on_save, file_assoc_enabled: self.file_assoc_enabled, @@ -460,6 +461,7 @@ impl OpenCADStudio { self.texteditmode = s.texteditmode; self.quick_dimension_snap_priority = s.quick_dimension_snap_priority.min(1); self.dimension_continue_mode = s.dimension_continue_mode.clamp(0, 1); + self.delete_objects = s.delete_objects.clamp(0, 3); crate::scene::text::sdf_atlas::set_textfill(s.textfill); self.backup_on_save = s.backup_on_save; self.file_assoc_enabled = s.file_assoc_enabled;