From 0be9c41773511b42ee08741046cd7146154474a5 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Wed, 12 Aug 2026 12:46:41 +0300 Subject: [PATCH] fix(layers): protect locked entities Keep locked-layer objects selectable for inspection while suppressing grips, property edits, command mutations, and stale editor writes. --- src/app/command_driver.rs | 168 +++++++++++++++++++++++++++++---- src/app/commands/blocks.rs | 70 +++++++++----- src/app/commands/dim.rs | 15 ++- src/app/commands/display.rs | 44 ++++++++- src/app/commands/draw.rs | 23 ++++- src/app/commands/inquiry.rs | 19 +++- src/app/commands/layerprops.rs | 2 + src/app/commands/layers.rs | 3 + src/app/commands/styleprops.rs | 6 +- src/app/commands/view.rs | 12 +++ src/app/find_replace.rs | 15 ++- src/app/model_ops.rs | 11 +++ src/app/mtext_editor.rs | 10 ++ src/app/plugin_host.rs | 6 ++ src/app/properties.rs | 94 ++++++++++++++++-- src/app/text_inline.rs | 13 +++ src/app/update/command.rs | 21 ++++- src/app/update/mod.rs | 10 ++ src/app/update/viewport.rs | 73 +++++++------- src/app/visibility.rs | 4 + src/scene/entity.rs | 3 + src/scene/mod.rs | 4 +- src/scene/modify.rs | 2 +- src/scene/selection.rs | 10 -- src/ui/overlay.rs | 4 +- 25 files changed, 524 insertions(+), 118 deletions(-) diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs index e53c6aa2..4491f33d 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -4,6 +4,16 @@ use acadrust::Handle; use iced::Task; impl OpenCADStudio { + pub(super) fn reject_locked_edit(&mut self, i: usize, handle: Handle) -> bool { + let Some(layer) = self.tabs[i].scene.locked_layer_name(handle) else { + return false; + }; + self.command_line.push_info(crate::tf!( + "Object is on locked layer \"{layer}\" — unlock the layer to edit it." + ).as_ref()); + true + } + fn refresh_area_preview(&mut self, i: usize) { let regions = self.tabs[i] .active_cmd @@ -499,16 +509,12 @@ impl OpenCADStudio { .entity_wires() .iter() .filter_map(|w| crate::scene::Scene::handle_from_wire_name(&w.name)) - .filter(|&h| !self.tabs[i].scene.is_layer_locked(h)) .collect(), "P" | "PREVIOUS" => self.tabs[i] .prev_selection .iter() .copied() - .filter(|&h| { - self.tabs[i].scene.document.get_entity(h).is_some() - && !self.tabs[i].scene.is_layer_locked(h) - }) + .filter(|&h| self.tabs[i].scene.document.get_entity(h).is_some()) .collect(), // Highest handle among the selectable wires of the current space — // handles are handed out monotonically, so that is the most @@ -518,7 +524,6 @@ impl OpenCADStudio { .entity_wires() .iter() .filter_map(|w| crate::scene::Scene::handle_from_wire_name(&w.name)) - .filter(|&h| !self.tabs[i].scene.is_layer_locked(h)) .max_by_key(|h| h.value()) .into_iter() .collect(), @@ -713,6 +718,17 @@ impl OpenCADStudio { } } CmdResult::CommitEntity(entity) => { + let source_handle = entity.common().handle; + if !source_handle.is_null() + && self.tabs[i] + .scene + .document + .get_entity(source_handle) + .is_some() + && self.reject_locked_edit(i, source_handle) + { + return Task::none(); + } // A line/arc drawn by a repeating command advances the ARC_CONT // continuation anchor, so ending one run and launching another // keeps continuing from the last segment (mirrors the @@ -741,6 +757,17 @@ impl OpenCADStudio { } } CmdResult::CommitEntities(entities) => { + let locked_source = entities.iter().find_map(|entity| { + let handle = entity.common().handle; + (!handle.is_null() + && self.tabs[i].scene.document.get_entity(handle).is_some() + && self.tabs[i].scene.is_layer_locked(handle)) + .then_some(handle) + }); + if let Some(handle) = locked_source { + self.reject_locked_edit(i, handle); + return Task::none(); + } let label = self.history_label_from_active_cmd(i, "ENTITY"); let delta_safe = entities .iter() @@ -1000,7 +1027,12 @@ impl OpenCADStudio { self.restore_pre_cmd_tangent(); return self.on_layout_switch(layout); } - CmdResult::TransformSelected(handles, transform) => { + CmdResult::TransformSelected(mut handles, transform) => { + handles.retain(|handle| !self.tabs[i].scene.is_layer_locked(*handle)); + if handles.is_empty() { + self.tabs[i].active_cmd = None; + return Task::none(); + } let label = self.history_label_from_active_cmd(i, "MOVE"); // A move/rotate/scale/mirror mutates only the selected entities // (and their baked dimension sub-entities) through @@ -1017,7 +1049,11 @@ impl OpenCADStudio { self.commit_undo_delta(i, p); } } - CmdResult::CopySelected(handles, transform) => { + CmdResult::CopySelected(mut handles, transform) => { + handles.retain(|handle| !self.tabs[i].scene.is_layer_locked(*handle)); + if handles.is_empty() { + return Task::none(); + } let label = self.history_label_from_active_cmd(i, "COPY"); // Copying a dimension clones a *D block record, so gate delta on // the selection being dimension-free. @@ -1160,10 +1196,16 @@ impl OpenCADStudio { } } CmdResult::CreateBlock { - handles, + mut handles, name, base, } => { + handles.retain(|handle| !self.tabs[i].scene.is_layer_locked(*handle)); + if handles.is_empty() { + self.command_line + .push_info(crate::t!("No editable objects selected.").as_ref()); + return Task::none(); + } self.push_undo_snapshot(i, "BLOCK"); let ucs = self.tabs[i].ucs_xform(); let world_to_block = ucs.to_ucs_transform_at(base); @@ -1218,7 +1260,12 @@ impl OpenCADStudio { self.commit_undo_delta(i, pd); } } - CmdResult::BatchCopy(handles, transforms) => { + CmdResult::BatchCopy(mut handles, transforms) => { + handles.retain(|handle| !self.tabs[i].scene.is_layer_locked(*handle)); + if handles.is_empty() { + self.tabs[i].active_cmd = None; + return Task::none(); + } let label = self.history_label_from_active_cmd(i, "ARRAY"); let count = transforms.len(); // Same gate as COPY (dimension-free), sized by the total number @@ -1242,6 +1289,14 @@ impl OpenCADStudio { } } CmdResult::ReplaceMany(replacements, additions) => { + if let Some((handle, _)) = replacements + .iter() + .find(|(handle, _)| self.tabs[i].scene.is_layer_locked(*handle)) + { + self.reject_locked_edit(i, *handle); + self.tabs[i].active_cmd = None; + return Task::none(); + } let label = self.history_label_from_active_cmd(i, "FILLET"); let was_catchment = self.tabs[i] .active_cmd @@ -1275,6 +1330,13 @@ impl OpenCADStudio { self.refresh_properties(); } CmdResult::ReplaceManyContinue(replacements) => { + if let Some((handle, _)) = replacements + .iter() + .find(|(handle, _)| self.tabs[i].scene.is_layer_locked(*handle)) + { + self.reject_locked_edit(i, *handle); + return Task::none(); + } let label = self.history_label_from_active_cmd(i, "TRIM"); self.push_undo_snapshot(i, label); for (handle, entities) in replacements { @@ -1300,6 +1362,9 @@ impl OpenCADStudio { self.refresh_properties(); } CmdResult::ReplaceEntity(handle, new_entities) => { + if self.reject_locked_edit(i, handle) { + return Task::none(); + } // Detect SPLINEDIT sentinel: a single XLine with a magic layer name. if new_entities.len() == 1 { if let acadrust::EntityType::XLine(ref xl) = new_entities[0] { @@ -1673,7 +1738,14 @@ impl OpenCADStudio { .document .get_entity(src) .map(|e| e.common().layer.clone()); - if let Some(layer) = src_layer { + let dest: Vec<_> = dest + .into_iter() + .filter(|handle| !self.tabs[i].scene.is_layer_locked(*handle)) + .collect(); + if dest.is_empty() { + self.command_line + .push_info(crate::t!("No editable objects selected.").as_ref()); + } else if let Some(layer) = src_layer { self.push_undo_snapshot(i, "LAYMATCH"); for h in &dest { if let Some(e) = self.tabs[i].scene.document.get_entity_mut(*h) { @@ -1692,7 +1764,11 @@ impl OpenCADStudio { self.command_line.push_error(crate::t!("Source object not found.").as_ref()); } } - CmdResult::MatchProperties { dest, src } => { + CmdResult::MatchProperties { mut dest, src } => { + dest.retain(|handle| !self.tabs[i].scene.is_layer_locked(*handle)); + if dest.is_empty() { + return Task::none(); + } // The command stays active after each apply so more targets // can keep being picked; Enter / Esc ends it (#362). // Special (type-specific) properties travel like AutoCAD's @@ -1868,10 +1944,14 @@ impl OpenCADStudio { .push_info(crate::tf!("{count} object(s) pasted.").as_ref()); } } - CmdResult::CreateGroup { handles, name } => { + CmdResult::CreateGroup { mut handles, name } => { + handles.retain(|handle| !self.tabs[i].scene.is_layer_locked(*handle)); self.tabs[i].active_cmd = None; self.tabs[i].snap_result = None; self.tabs[i].scene.clear_preview_wire(); + if handles.is_empty() { + return Task::none(); + } let undo = self.begin_group_undo(i, "GROUP"); self.tabs[i].scene.create_group(name.clone(), handles); self.tabs[i].dirty = true; @@ -1879,10 +1959,14 @@ impl OpenCADStudio { self.command_line .push_info(crate::tf!("Group \"{}\" created.", name).as_ref()); } - CmdResult::DeleteGroups { handles } => { + CmdResult::DeleteGroups { mut handles } => { + handles.retain(|handle| !self.tabs[i].scene.is_layer_locked(*handle)); self.tabs[i].active_cmd = None; self.tabs[i].snap_result = None; self.tabs[i].scene.clear_preview_wire(); + if handles.is_empty() { + return Task::none(); + } let undo = self.begin_group_undo(i, "UNGROUP"); let count = self.tabs[i].scene.delete_groups_containing(&handles); self.tabs[i].dirty = true; @@ -2044,12 +2128,13 @@ impl OpenCADStudio { } } CmdResult::AlignSelected { - handles, + mut handles, src1, dst1, angle_rad, scale, } => { + handles.retain(|handle| !self.tabs[i].scene.is_layer_locked(*handle)); if handles.is_empty() { self.tabs[i].active_cmd = None; self.tabs[i].snap_result = None; @@ -2113,6 +2198,9 @@ impl OpenCADStudio { pick_pt, mode, } => { + if self.reject_locked_edit(i, handle) { + return Task::none(); + } use crate::modules::draw::modify::lengthen::lengthen_entity; let result = self.tabs[i] .scene @@ -2195,6 +2283,9 @@ impl OpenCADStudio { self.restore_pre_cmd_tangent(); } CmdResult::PeditOp { handle, op } => { + if self.reject_locked_edit(i, handle) { + return Task::none(); + } use crate::modules::draw::modify::pedit::{ apply_pedit, convert_to_polyline, PeditOp, }; @@ -2248,6 +2339,13 @@ impl OpenCADStudio { } } CmdResult::JoinEntities(handles) => { + if let Some(handle) = handles + .iter() + .find(|handle| self.tabs[i].scene.is_layer_locked(**handle)) + { + self.reject_locked_edit(i, *handle); + return Task::none(); + } use crate::modules::draw::modify::join::join_entities; let pairs: Vec<_> = handles .iter() @@ -2285,6 +2383,9 @@ impl OpenCADStudio { } } CmdResult::BreakEntity { handle, p1, p2 } => { + if self.reject_locked_edit(i, handle) { + return Task::none(); + } use crate::modules::draw::modify::break_cmd::break_entity; let replacement = self.tabs[i] .scene @@ -2403,11 +2504,16 @@ impl OpenCADStudio { self.tabs[i].active_cmd = Some(Box::new(cmd)); } CmdResult::StretchEntities { - handles, + mut handles, win_min, win_max, delta, } => { + handles.retain(|handle| !self.tabs[i].scene.is_layer_locked(*handle)); + if handles.is_empty() { + self.tabs[i].active_cmd = None; + return Task::none(); + } let structural = handles.iter().any(|handle| { matches!( self.tabs[i].scene.document.get_entity(*handle), @@ -2702,6 +2808,10 @@ impl OpenCADStudio { height, color, } => { + if self.reject_locked_edit(i, handle) { + self.tabs[i].active_cmd = None; + return Task::none(); + } use crate::modules::insert::solid3d_cmds::empty_solid3d; use crate::scene::model::{solid_model, sweep_model}; @@ -2753,6 +2863,10 @@ impl OpenCADStudio { angle_deg, color, } => { + if self.reject_locked_edit(i, handle) { + self.tabs[i].active_cmd = None; + return Task::none(); + } use crate::modules::insert::solid3d_cmds::empty_solid3d; use crate::scene::model::{solid_model, sweep_model}; @@ -2815,6 +2929,12 @@ impl OpenCADStudio { path_handle, color, } => { + if self.reject_locked_edit(i, profile_handle) + || self.reject_locked_edit(i, path_handle) + { + self.tabs[i].active_cmd = None; + return Task::none(); + } use crate::modules::insert::solid3d_cmds::empty_solid3d; use crate::scene::model::sweep_model; @@ -2869,6 +2989,15 @@ impl OpenCADStudio { // ── LOFT ────────────────────────────────────────────────────── CmdResult::LoftEntities { handles, color } => { + if let Some(handle) = handles + .iter() + .find(|handle| self.tabs[i].scene.is_layer_locked(**handle)) + .copied() + { + self.reject_locked_edit(i, handle); + self.tabs[i].active_cmd = None; + return Task::none(); + } use crate::modules::insert::solid3d_cmds::empty_solid3d; use crate::scene::model::sweep_model; @@ -2914,6 +3043,9 @@ impl OpenCADStudio { scale, angle, } => { + if self.reject_locked_edit(i, handle) { + return Task::none(); + } if let Some(mut model) = self.tabs[i].scene.hatches.get(&handle).cloned() { let layer = self.tabs[i] .scene @@ -3025,6 +3157,10 @@ impl OpenCADStudio { self.tabs[i].scene.clear_preview_wire(); } CmdResult::DdeditEntity { handle, new_text } => { + if self.reject_locked_edit(i, handle) { + self.tabs[i].active_cmd = None; + return Task::none(); + } self.push_undo_snapshot(i, "DDEDIT"); let mut updated = false; let mut is_dim = false; diff --git a/src/app/commands/blocks.rs b/src/app/commands/blocks.rs index 1888df2e..6cf2394a 100644 --- a/src/app/commands/blocks.rs +++ b/src/app/commands/blocks.rs @@ -71,6 +71,7 @@ impl OpenCADStudio { .selected_entities() .into_iter() .map(|(h, _)| h) + .filter(|handle| !self.tabs[i].scene.is_layer_locked(*handle)) .collect(); if handles.is_empty() { use crate::modules::draw::select::SelectObjectsCommand; @@ -106,6 +107,7 @@ impl OpenCADStudio { .selected_entities() .into_iter() .map(|(h, _)| h) + .filter(|handle| !self.tabs[i].scene.is_layer_locked(*handle)) .collect(); if handles.is_empty() { use crate::modules::draw::select::SelectObjectsCommand; @@ -132,6 +134,7 @@ impl OpenCADStudio { .selected_entities() .into_iter() .map(|(h, _)| h) + .filter(|handle| !self.tabs[i].scene.is_layer_locked(*handle)) .collect(); if coords.len() == 3 && !handles.is_empty() { let base = glam::DVec3::new(coords[0], coords[1], coords[2]); @@ -160,6 +163,7 @@ impl OpenCADStudio { .selected_entities() .into_iter() .map(|(h, _)| h) + .filter(|handle| !self.tabs[i].scene.is_layer_locked(*handle)) .collect(); if handles.is_empty() { use crate::modules::draw::select::SelectObjectsCommand; @@ -393,32 +397,55 @@ impl OpenCADStudio { .collect() }) .unwrap_or_default(); + let inserts: Vec<_> = self.tabs[i] + .scene + .document + .entities() + .filter_map(|entity| match entity { + acadrust::EntityType::Insert(insert) + if insert.block_name.eq_ignore_ascii_case(&block) + && !self.tabs[i] + .scene + .is_layer_locked(insert.common.handle) => + { + Some(insert.common.handle) + } + _ => None, + }) + .collect(); + if inserts.is_empty() { + self.command_line.push_error( + crate::t!("ATTSYNC: no editable block references.").as_ref(), + ); + return Some(Task::none()); + } self.push_undo_snapshot(i, "ATTSYNC"); let mut synced = 0usize; let mut changes = Vec::new(); - for e in self.tabs[i].scene.document.entities_mut() { - if let acadrust::EntityType::Insert(ins) = e { - if ins.block_name.eq_ignore_ascii_case(&block) { - ins.attributes.retain(|a| { - attdefs.iter().any(|(t, _)| t.eq_ignore_ascii_case(&a.tag)) - }); - for (tag, default) in &attdefs { - if !ins - .attributes - .iter() - .any(|a| a.tag.eq_ignore_ascii_case(tag)) - { - ins.attributes - .push(acadrust::entities::AttributeEntity::new( - tag.clone(), - default.clone(), - )); - } + for handle in inserts { + let Some(acadrust::EntityType::Insert(ins)) = + self.tabs[i].scene.document.get_entity_mut(handle) + else { + continue; + }; + ins.attributes.retain(|a| { + attdefs.iter().any(|(t, _)| t.eq_ignore_ascii_case(&a.tag)) + }); + for (tag, default) in &attdefs { + if !ins + .attributes + .iter() + .any(|a| a.tag.eq_ignore_ascii_case(tag)) + { + ins.attributes + .push(acadrust::entities::AttributeEntity::new( + tag.clone(), + default.clone(), + )); } - synced += 1; - changes.push((ins.common.handle, crate::scene::ChangeKind::Modified)); - } } + synced += 1; + changes.push((ins.common.handle, crate::scene::ChangeKind::Modified)); } self.tabs[i].scene.bump_entities(&changes); self.tabs[i].dirty = true; @@ -626,6 +653,7 @@ impl OpenCADStudio { .iter() .filter(|(_, e)| matches!(e, acadrust::EntityType::Insert(_))) .map(|(h, _)| *h) + .filter(|handle| !self.tabs[i].scene.is_layer_locked(*handle)) .collect(); if inserts.is_empty() { self.command_line diff --git a/src/app/commands/dim.rs b/src/app/commands/dim.rs index 1556d988..32ea4332 100644 --- a/src/app/commands/dim.rs +++ b/src/app/commands/dim.rs @@ -86,6 +86,7 @@ impl OpenCADStudio { .selected_entities() .iter() .map(|(h, _)| *h) + .filter(|handle| !self.tabs[i].scene.is_layer_locked(*handle)) .collect(); if handles.is_empty() { self.command_line @@ -208,6 +209,7 @@ impl OpenCADStudio { .selected_entities() .iter() .map(|(h, _)| *h) + .filter(|handle| !self.tabs[i].scene.is_layer_locked(*handle)) .collect(); if handles.is_empty() { self.command_line @@ -297,6 +299,7 @@ impl OpenCADStudio { ) }) .map(|(h, _)| *h) + .filter(|handle| !self.tabs[i].scene.is_layer_locked(*handle)) .collect(); if handles.is_empty() { self.command_line @@ -374,6 +377,7 @@ impl OpenCADStudio { .iter() .filter(|(_, e)| matches!(e, acadrust::EntityType::Text(_))) .map(|(h, _)| *h) + .filter(|handle| !self.tabs[i].scene.is_layer_locked(*handle)) .collect(); if handles.is_empty() { self.command_line @@ -469,7 +473,9 @@ impl OpenCADStudio { .selected_entities() .iter() .filter_map(|(h, e)| match e { - acadrust::EntityType::Text(t) => { + acadrust::EntityType::Text(t) + if !self.tabs[i].scene.is_layer_locked(*h) => + { Some((*h, t.insertion_point.x, t.insertion_point.y)) } _ => None, @@ -1127,7 +1133,12 @@ impl OpenCADStudio { "EXPLODE" => { use crate::modules::draw::modify::explode::explode_entity; - let entities: Vec<_> = self.tabs[i].scene.selected_entities().into_iter().collect(); + let entities: Vec<_> = self.tabs[i] + .scene + .selected_entities() + .into_iter() + .filter(|(handle, _)| !self.tabs[i].scene.is_layer_locked(*handle)) + .collect(); if entities.is_empty() { use crate::modules::draw::select::SelectObjectsCommand; let cmd = SelectObjectsCommand::new("EXPLODE"); diff --git a/src/app/commands/display.rs b/src/app/commands/display.rs index 2ad05a00..0b3a06e9 100644 --- a/src/app/commands/display.rs +++ b/src/app/commands/display.rs @@ -30,6 +30,7 @@ impl OpenCADStudio { .selected_entities() .iter() .map(|(h, _)| *h) + .filter(|handle| !self.tabs[i].scene.is_layer_locked(*handle)) .collect(); let mut found = false; for sh in &selected_handles { @@ -389,8 +390,20 @@ impl OpenCADStudio { "SET" => { let app = parts.get(1).copied().unwrap_or("OpenCADStudio"); let val = parts.get(2).copied().unwrap_or(""); + let editable: Vec<_> = selected_handles + .iter() + .copied() + .filter(|handle| !self.tabs[i].scene.is_layer_locked(*handle)) + .collect(); + if editable.is_empty() { + self.command_line.push_error( + crate::t!("XDATA: selected entities are on locked layers.") + .as_ref(), + ); + return Some(Task::none()); + } self.push_undo_snapshot(i, "XDATA SET"); - for sh in &selected_handles { + for sh in &editable { if let Some(entity) = self.tabs[i].scene.document.get_entity_mut(*sh) { @@ -402,13 +415,25 @@ impl OpenCADStudio { self.tabs[i].dirty = true; self.command_line.push_output(crate::tf!( "XDATA: set [{app}] = \"{val}\" on {} entity/entities.", - selected_handles.len() + editable.len() ).as_ref()); } "CLEAR" => { let app_filter = parts.get(1).copied(); + let editable: Vec<_> = selected_handles + .iter() + .copied() + .filter(|handle| !self.tabs[i].scene.is_layer_locked(*handle)) + .collect(); + if editable.is_empty() { + self.command_line.push_error( + crate::t!("XDATA: selected entities are on locked layers.") + .as_ref(), + ); + return Some(Task::none()); + } self.push_undo_snapshot(i, "XDATA CLEAR"); - for sh in &selected_handles { + for sh in &editable { if let Some(entity) = self.tabs[i].scene.document.get_entity_mut(*sh) { @@ -600,6 +625,7 @@ impl OpenCADStudio { .selected_entities() .iter() .map(|(h, _)| *h) + .filter(|handle| !self.tabs[i].scene.is_layer_locked(*handle)) .collect(); if handles.is_empty() { self.command_line @@ -706,6 +732,7 @@ impl OpenCADStudio { .selected_entities() .iter() .map(|(h, _)| *h) + .filter(|handle| !self.tabs[i].scene.is_layer_locked(*handle)) .collect(); if handles.is_empty() { self.command_line @@ -765,8 +792,13 @@ impl OpenCADStudio { self.command_line.push_info(crate::t!("Usage: HYPERLINK (select objects first)").as_ref()); return Some(Task::none()); } - let handles: Vec = - self.tabs[i].scene.selected_entities().iter().map(|(h, _)| *h).collect(); + let handles: Vec = self.tabs[i] + .scene + .selected_entities() + .iter() + .map(|(h, _)| *h) + .filter(|handle| !self.tabs[i].scene.is_layer_locked(*handle)) + .collect(); if handles.is_empty() { self.command_line.push_error(crate::t!("HYPERLINK: select objects first.").as_ref()); return Some(Task::none()); @@ -816,6 +848,7 @@ impl OpenCADStudio { .selected_entities() .iter() .map(|(h, _)| *h) + .filter(|handle| !self.tabs[i].scene.is_layer_locked(*handle)) .collect(); if handles.is_empty() { self.command_line @@ -938,6 +971,7 @@ impl OpenCADStudio { .selected_entities() .iter() .map(|(handle, _)| *handle) + .filter(|handle| !self.tabs[i].scene.is_layer_locked(*handle)) .collect(); if handles.is_empty() { self.command_line diff --git a/src/app/commands/draw.rs b/src/app/commands/draw.rs index 2874a70a..20a3d393 100644 --- a/src/app/commands/draw.rs +++ b/src/app/commands/draw.rs @@ -145,6 +145,9 @@ impl OpenCADStudio { let mut changed = 0usize; self.push_undo_snapshot(i, "ATTEDIT"); for sh in &selected_handles { + if self.tabs[i].scene.is_layer_locked(*sh) { + continue; + } if let Some(acadrust::EntityType::Insert(ins)) = self.tabs[i] .scene .document @@ -195,10 +198,22 @@ impl OpenCADStudio { let sub = cmd.split_whitespace().nth(1).unwrap_or("").to_uppercase(); match sub.as_str() { "ON" | "OFF" | "NORMAL" => { + let handles: Vec<_> = self.tabs[i] + .scene + .document + .entities() + .filter_map(|entity| { + matches!(entity, acadrust::EntityType::AttributeDefinition(_)) + .then_some(entity.common().handle) + }) + .filter(|handle| !self.tabs[i].scene.is_layer_locked(*handle)) + .collect(); self.push_undo_snapshot(i, "ATTDISP"); let mut count = 0usize; - for entity in self.tabs[i].scene.document.entities_mut() { - if let acadrust::EntityType::AttributeDefinition(ad) = entity { + for handle in handles { + if let Some(acadrust::EntityType::AttributeDefinition(ad)) = + self.tabs[i].scene.document.get_entity_mut(handle) + { match sub.as_str() { "ON" => { ad.flags.invisible = false; @@ -208,8 +223,6 @@ impl OpenCADStudio { ad.flags.invisible = true; count += 1; } - "NORMAL" => { /* leave existing flags — they are already the "normal" state */ - } _ => {} } } @@ -490,6 +503,7 @@ impl OpenCADStudio { .selected_entities() .into_iter() .map(|(h, _)| h) + .filter(|handle| !self.tabs[i].scene.is_layer_locked(*handle)) .collect(); if handles.is_empty() { use crate::modules::draw::select::SelectObjectsCommand; @@ -730,6 +744,7 @@ impl OpenCADStudio { .selected_entities() .into_iter() .map(|(h, _)| h) + .filter(|handle| !self.tabs[i].scene.is_layer_locked(*handle)) .collect(); if handles.is_empty() { use crate::modules::draw::select::SelectObjectsCommand; diff --git a/src/app/commands/inquiry.rs b/src/app/commands/inquiry.rs index 884a101f..905c3ec1 100644 --- a/src/app/commands/inquiry.rs +++ b/src/app/commands/inquiry.rs @@ -16,8 +16,6 @@ impl OpenCADStudio { .entity_wires() .iter() .filter_map(|w| Scene::handle_from_wire_name(&w.name)) - // Objects on a locked layer aren't selectable. - .filter(|&h| !self.tabs[i].scene.is_layer_locked(h)) .collect(); let count = handles.len(); for h in handles { @@ -362,6 +360,9 @@ impl OpenCADStudio { let handle_u64: u64 = cmd["BEDIT_BEGIN:".len()..].parse().unwrap_or(0); let insert_handle = Handle::new(handle_u64); + if self.reject_locked_edit(i, insert_handle) { + return Some(Task::none()); + } let insert = match self.tabs[i].scene.document.get_entity(insert_handle) { Some(acadrust::EntityType::Insert(ins)) => ins.clone(), @@ -637,6 +638,9 @@ impl OpenCADStudio { let handle_u64: u64 = cmd["REFEDIT_BEGIN:".len()..].parse().unwrap_or(0); let insert_handle = Handle::new(handle_u64); + if self.reject_locked_edit(i, insert_handle) { + return Some(Task::none()); + } // Get INSERT entity. let insert = match self.tabs[i].scene.document.get_entity(insert_handle) { @@ -995,9 +999,13 @@ impl OpenCADStudio { .document .entities() .map(|e| e.common().handle) + .filter(|handle| !self.tabs[i].scene.is_layer_locked(*handle)) .collect() } else { - sel.into_iter().map(|(h, _)| h).collect() + sel.into_iter() + .map(|(h, _)| h) + .filter(|handle| !self.tabs[i].scene.is_layer_locked(*handle)) + .collect() } }; if handles.is_empty() { @@ -1165,7 +1173,10 @@ impl OpenCADStudio { handles.clone() } else { handles.iter().copied().take(1).collect() - }; + } + .into_iter() + .filter(|handle| !self.tabs[i].scene.is_layer_locked(*handle)) + .collect(); if targets.is_empty() { self.command_line .push_output(crate::tf!("FIND: \"{}\" not found.", search).as_ref()); diff --git a/src/app/commands/layerprops.rs b/src/app/commands/layerprops.rs index 550c8115..73460d3f 100644 --- a/src/app/commands/layerprops.rs +++ b/src/app/commands/layerprops.rs @@ -106,6 +106,7 @@ impl OpenCADStudio { } self.push_undo_snapshot(i, "LAYER LOCK"); self.tabs[i].dirty = true; + self.refresh_properties(); self.command_line.push_output(crate::t!("LAYER: layers locked.").as_ref()); } "UNLOCK" | "UL" => { @@ -116,6 +117,7 @@ impl OpenCADStudio { } self.push_undo_snapshot(i, "LAYER UNLOCK"); self.tabs[i].dirty = true; + self.refresh_properties(); self.command_line.push_output(crate::t!("LAYER: layers unlocked.").as_ref()); } "COLOR" | "C" => { diff --git a/src/app/commands/layers.rs b/src/app/commands/layers.rs index 6afeea5c..a0220654 100644 --- a/src/app/commands/layers.rs +++ b/src/app/commands/layers.rs @@ -399,6 +399,7 @@ impl OpenCADStudio { self.tabs[i].dirty = true; self.commit_layer_undo(i, undo); self.refresh_layer_panel(); + self.refresh_properties(); self.command_line.push_info(crate::t!("Layer(s) locked.").as_ref()); } } @@ -508,6 +509,7 @@ impl OpenCADStudio { self.tabs[i].dirty = true; self.commit_layer_undo(i, undo); self.refresh_layer_panel(); + self.refresh_properties(); self.command_line.push_info(crate::t!("Layer(s) unlocked.").as_ref()); } } @@ -686,6 +688,7 @@ impl OpenCADStudio { .selected_entities() .into_iter() .map(|(h, _)| h) + .filter(|handle| !self.tabs[i].scene.is_layer_locked(*handle)) .collect(); if handles.is_empty() { use crate::modules::draw::groups::ungroup::UngroupCommand; diff --git a/src/app/commands/styleprops.rs b/src/app/commands/styleprops.rs index e18432c2..cc800f9a 100644 --- a/src/app/commands/styleprops.rs +++ b/src/app/commands/styleprops.rs @@ -607,6 +607,7 @@ impl OpenCADStudio { .selected_entities() .into_iter() .map(|(h, _)| h) + .filter(|handle| !self.tabs[i].scene.is_layer_locked(*handle)) .collect(); if handles.is_empty() { self.command_line @@ -708,6 +709,7 @@ impl OpenCADStudio { .selected_entities() .into_iter() .map(|(h, _)| h) + .filter(|handle| !self.tabs[i].scene.is_layer_locked(*handle)) .collect(); if handles.is_empty() { self.command_line @@ -756,7 +758,8 @@ impl OpenCADStudio { .document .entities() .filter(|e| { - selected.is_empty() || selected.contains(&e.common().handle.value()) + (selected.is_empty() || selected.contains(&e.common().handle.value())) + && !self.tabs[i].scene.is_layer_locked(e.common().handle) }) .map(|e| { let key = crate::entities::names::dxf_name(e).to_string(); @@ -2284,6 +2287,7 @@ impl OpenCADStudio { .selected_entities() .iter() .map(|(h, _)| *h) + .filter(|handle| !self.tabs[i].scene.is_layer_locked(*handle)) .collect(); if selected_handles.is_empty() { self.command_line diff --git a/src/app/commands/view.rs b/src/app/commands/view.rs index f32de9ca..49fb811d 100644 --- a/src/app/commands/view.rs +++ b/src/app/commands/view.rs @@ -652,6 +652,16 @@ impl OpenCADStudio { } }) .collect(); + if to_erase + .iter() + .any(|handle| self.tabs[i].scene.is_layer_locked(*handle)) + { + self.command_line.push_error( + crate::t!("VPORTS: unlock existing viewport layers first.") + .as_ref(), + ); + return Some(Task::none()); + } self.push_undo_snapshot(i, "VPORTS"); self.tabs[i].scene.erase_entities(&to_erase); // Create new viewports. @@ -814,6 +824,7 @@ impl OpenCADStudio { .selected_entities() .iter() .map(|(h, _)| *h) + .filter(|handle| !self.tabs[i].scene.is_layer_locked(*handle)) .collect(); if selected.is_empty() { self.command_line @@ -1004,6 +1015,7 @@ impl OpenCADStudio { .iter() .filter(|(_, e)| matches!(e, acadrust::EntityType::Viewport(_))) .map(|(h, _)| *h) + .filter(|handle| !self.tabs[i].scene.is_layer_locked(*handle)) .collect(); if vps.len() < 2 { self.command_line.push_error( diff --git a/src/app/find_replace.rs b/src/app/find_replace.rs index a49718bc..b684b078 100644 --- a/src/app/find_replace.rs +++ b/src/app/find_replace.rs @@ -98,6 +98,10 @@ impl OpenCADStudio { let search = self.find_replace.search.clone(); let replacement = self.find_replace.replacement.clone(); + if match_is_locked(&self.tabs[i].scene, target) { + self.find_replace.status = "The matching object is on a locked layer.".to_string(); + return; + } self.push_undo_snapshot(i, "FIND/REPLACE"); let replaced = replace_match_text( &mut self.tabs[i].scene.document, @@ -152,7 +156,11 @@ impl OpenCADStudio { let mut replaced = 0usize; let mut changed = Vec::new(); let mut changed_outside_active_space = false; - for target in matches { + let editable: Vec<_> = matches + .into_iter() + .filter(|target| !match_is_locked(&self.tabs[i].scene, *target)) + .collect(); + for target in editable { let count = replace_match_text( &mut self.tabs[i].scene.document, target, @@ -357,6 +365,11 @@ fn match_document_handle(target: FindMatchKey) -> acadrust::Handle { } } +fn match_is_locked(scene: &crate::scene::Scene, target: FindMatchKey) -> bool { + scene.is_layer_locked(match_owner_handle(target)) + || scene.is_layer_locked(match_document_handle(target)) +} + fn match_label(target: FindMatchKey) -> String { match target { FindMatchKey::Entity(handle) => format!("handle {:X}", handle.value()), diff --git a/src/app/model_ops.rs b/src/app/model_ops.rs index 53efe862..1ba66b3a 100644 --- a/src/app/model_ops.rs +++ b/src/app/model_ops.rs @@ -46,6 +46,7 @@ impl super::OpenCADStudio { .selected .iter() .copied() + .filter(|h| !self.tabs[i].scene.is_layer_locked(*h)) .filter(|h| self.tabs[i].scene.solid_models.contains_key(h)) .collect(); if handles.len() != 2 { @@ -96,6 +97,7 @@ impl super::OpenCADStudio { .selected .iter() .copied() + .filter(|h| !self.tabs[i].scene.is_layer_locked(*h)) .filter(|h| self.tabs[i].scene.solid_models.contains_key(h)) .collect(); if handles.len() != 1 { @@ -171,6 +173,7 @@ impl super::OpenCADStudio { .selected .iter() .copied() + .filter(|h| !self.tabs[i].scene.is_layer_locked(*h)) .filter(|h| self.tabs[i].scene.solid_models.contains_key(h)) .collect(); if handles.len() != 2 { @@ -210,6 +213,7 @@ impl super::OpenCADStudio { .selected .iter() .copied() + .filter(|h| !self.tabs[i].scene.is_layer_locked(*h)) .filter(|h| self.tabs[i].scene.solid_models.contains_key(h)) .collect(); if handles.len() != 1 { @@ -258,6 +262,7 @@ impl super::OpenCADStudio { .scene .selected_entities() .iter() + .filter(|(h, _)| !self.tabs[i].scene.is_layer_locked(*h)) .find_map(|(h, e)| match e { EntityType::LwPolyline(pl) => Some(( *h, @@ -343,6 +348,7 @@ impl super::OpenCADStudio { .selected .iter() .copied() + .filter(|h| !self.tabs[i].scene.is_layer_locked(*h)) .filter(|h| self.tabs[i].scene.solid_models.contains_key(h)) .collect(); if handles.len() != 1 { @@ -394,6 +400,7 @@ impl super::OpenCADStudio { .selected .iter() .copied() + .filter(|h| !self.tabs[i].scene.is_layer_locked(*h)) .filter(|h| self.tabs[i].scene.solid_models.contains_key(h)) .collect(); if handles.len() != 1 { @@ -461,6 +468,7 @@ impl super::OpenCADStudio { .selected .iter() .copied() + .filter(|h| !self.tabs[i].scene.is_layer_locked(*h)) .filter(|h| self.tabs[i].scene.solid_models.contains_key(h)) .collect(); if handles.len() != 1 { @@ -561,6 +569,7 @@ impl super::OpenCADStudio { .scene .selected_entities() .iter() + .filter(|(h, _)| !self.tabs[i].scene.is_layer_locked(*h)) .find_map(|(h, e)| match e { EntityType::LwPolyline(pl) => Some(( *h, @@ -632,6 +641,7 @@ impl super::OpenCADStudio { .selected .iter() .copied() + .filter(|h| !self.tabs[i].scene.is_layer_locked(*h)) .filter(|h| self.tabs[i].scene.solid_models.contains_key(h)) .collect(); if handles.is_empty() { @@ -672,6 +682,7 @@ impl super::OpenCADStudio { .selected .iter() .copied() + .filter(|h| !self.tabs[i].scene.is_layer_locked(*h)) .filter(|h| self.tabs[i].scene.solid_models.contains_key(h)) .collect(); if handles.is_empty() { diff --git a/src/app/mtext_editor.rs b/src/app/mtext_editor.rs index c11e873f..f1954ffc 100644 --- a/src/app/mtext_editor.rs +++ b/src/app/mtext_editor.rs @@ -606,6 +606,9 @@ impl super::OpenCADStudio { initial: &str, height: f64, ) { + if handle.is_some_and(|h| self.tabs[self.active_tab].scene.is_layer_locked(h)) { + return; + } let mut state = MTextEditorState::new(pos, initial, height, handle); if let Some(p) = self.tabs[self.active_tab].scene.selection.borrow().last_move_pos { state.screen_anchor = p; @@ -1222,6 +1225,10 @@ impl super::OpenCADStudio { return false; } if let Some(h) = ed.editing { + if self.tabs[i].scene.is_layer_locked(h) { + self.refresh_properties(); + return false; + } self.push_undo_snapshot(i, "MTEXT"); match self.tabs[i].scene.document.get_entity_mut(h) { Some(EntityType::MText(t)) => { @@ -1307,6 +1314,9 @@ impl super::OpenCADStudio { return; } if let Some(h) = editing { + if self.tabs[i].scene.is_layer_locked(h) { + return; + } self.push_undo_snapshot(i, "MTEXT"); match self.tabs[i].scene.document.get_entity_mut(h) { Some(EntityType::MText(t)) => { diff --git a/src/app/plugin_host.rs b/src/app/plugin_host.rs index a0aa433c..6d37ce43 100644 --- a/src/app/plugin_host.rs +++ b/src/app/plugin_host.rs @@ -117,6 +117,9 @@ impl<'a> HostSession<'a> { /// missing so the file stays valid for other CAD apps. Returns `false` when /// the entity does not exist. pub fn write_record(&mut self, handle: Handle, record: ExtendedDataRecord) -> bool { + if self.app.tabs[self.tab].scene.is_layer_locked(handle) { + return false; + } let app = record.application_name.clone(); self.ensure_app_id(&app); let app_handle = self.document().app_ids.get(&app).map(|a| a.handle.value()); @@ -150,6 +153,9 @@ impl<'a> HostSession<'a> { /// Remove the XDATA record for `app_name` from entity `handle`. Returns /// `true` when a record was actually removed. pub fn remove_record(&mut self, handle: Handle, app_name: &str) -> bool { + if self.app.tabs[self.tab].scene.is_layer_locked(handle) { + return false; + } let app_handle = self.document().app_ids.get(app_name).map(|a| a.handle.value()); let Some(entity) = self.document_mut().get_entity_mut(handle) else { return false; diff --git a/src/app/properties.rs b/src/app/properties.rs index 7d6ddd3c..15c1e93b 100644 --- a/src/app/properties.rs +++ b/src/app/properties.rs @@ -1445,6 +1445,26 @@ impl OpenCADStudio { panel.source_handles = new_handles; panel.prop_vertex = prop_vertex; panel.prop_vertex_indicator_active = prop_vertex_indicator_active; + let property_handles = panel.selected_handles(); + let property_handles = if property_handles.is_empty() { + &panel.source_handles + } else { + &property_handles + }; + let locked_only = !property_handles.is_empty() + && property_handles + .iter() + .all(|handle| self.tabs[i].scene.is_layer_locked(*handle)); + if locked_only { + make_sections_read_only(&mut panel.sections); + panel.edit_buf.clear(); + panel.color_picker_open = false; + panel.color_palette_open = false; + panel.bg_color_picker_open = false; + panel.open_color_field = None; + panel.hatch_pattern_picker_open = false; + panel.edit_choice_open = false; + } panel }; @@ -1563,6 +1583,15 @@ impl OpenCADStudio { /// Rebuild the cached selected_grips from the current entity selection. pub(super) fn refresh_selected_grips(&mut self) { let i = self.active_tab; + let locked_active_grip = self.tabs[i].active_grip.as_ref().is_some_and(|grip| { + grip.targets + .iter() + .any(|target| self.tabs[i].scene.is_layer_locked(target.handle)) + }); + if locked_active_grip { + self.cancel_active_grip_edit(); + return; + } let is_paper = self.tabs[i].scene.current_layout != "Model"; // Paper-space entity coordinates are NOT offset by world_offset (same rule // as wire tessellation in wires_for_block). Only subtract in model space. @@ -1574,10 +1603,15 @@ impl OpenCADStudio { let (new_handle, new_grips, new_grip_handles) = { let annotation_scale_handle = self.tabs[i].scene.displayed_annotation_scale_handle(); let selected = self.tabs[i].scene.selected_entities(); - let single_handle = (selected.len() == 1).then(|| selected[0].0); + let single_handle = (selected.len() == 1 + && !self.tabs[i].scene.is_layer_locked(selected[0].0)) + .then(|| selected[0].0); let mut grips = Vec::new(); let mut handles = Vec::new(); for (handle, entity) in selected { + if self.tabs[i].scene.is_layer_locked(handle) { + continue; + } let contextual = crate::scene::annotative::entity_for_annotation_context( &self.tabs[i].scene.document, entity, @@ -1618,12 +1652,21 @@ impl OpenCADStudio { } pub(super) fn property_target_handles(&self, i: usize) -> Vec { - let handles = self.tabs[i].properties.selected_handles(); - if !handles.is_empty() { - handles - } else { - self.tabs[i].selected_handle.into_iter().collect() + let mut handles = self.tabs[i].properties.selected_handles(); + if handles.is_empty() { + handles = self.tabs[i].properties.source_handles.clone(); } + if handles.is_empty() { + handles.extend(self.tabs[i].selected_handle); + } + handles.retain(|handle| !self.tabs[i].scene.is_layer_locked(*handle)); + handles + } + + pub(super) fn has_property_selection(&self, i: usize) -> bool { + !self.tabs[i].properties.selected_handles().is_empty() + || !self.tabs[i].properties.source_handles.is_empty() + || self.tabs[i].selected_handle.is_some() } pub(super) fn invalidate_property_targets(&mut self, i: usize, handles: &[Handle]) { @@ -1864,6 +1907,45 @@ impl OpenCADStudio { } } +fn make_sections_read_only( + sections: &mut [crate::scene::model::object::PropSection], +) { + use crate::scene::model::object::PropValue; + + for property in sections + .iter_mut() + .flat_map(|section| section.props.iter_mut()) + { + let text = match &property.value { + PropValue::ReadOnly(value) + | PropValue::EditText(value) + | PropValue::LayerChoice(value) + | PropValue::LinetypeChoice(value) + | PropValue::HatchPatternChoice(value) => value.clone(), + PropValue::Choice { selected, .. } => selected.clone(), + PropValue::EditChoice { value, .. } => value.clone(), + PropValue::ColorChoice(color) => match color { + acadrust::types::Color::None => "None".to_string(), + acadrust::types::Color::ByLayer => "ByLayer".to_string(), + acadrust::types::Color::ByBlock => "ByBlock".to_string(), + acadrust::types::Color::Index(index) => index.to_string(), + acadrust::types::Color::Rgb { r, g, b } => format!("{r},{g},{b}"), + }, + PropValue::ColorVaries | PropValue::LwVaries => VARIES_LABEL.to_string(), + PropValue::LwChoice(lineweight) => { + ui::properties::LwItem(*lineweight).to_string() + } + PropValue::BoolToggle { value, .. } => { + if *value { t!("Yes") } else { t!("No") }.into_owned() + } + PropValue::Stepper { display, .. } => display.clone(), + PropValue::AttrText { value, .. } => value.clone(), + }; + property.field = "locked_read_only"; + property.value = PropValue::ReadOnly(text); + } +} + // ── Multi-selection property aggregation ─────────────────────────────────── pub(super) fn build_selection_groups( diff --git a/src/app/text_inline.rs b/src/app/text_inline.rs index 2759aa53..c1f8b495 100644 --- a/src/app/text_inline.rs +++ b/src/app/text_inline.rs @@ -125,6 +125,9 @@ impl super::OpenCADStudio { /// the plain box (the rich editor needs no field focus). pub(super) fn begin_text_edit(&mut self, handle: Handle) -> iced::Task { let i = self.active_tab; + if self.tabs[i].scene.is_layer_locked(handle) { + return iced::Task::none(); + } // Resolve a Leader chain to the annotated entity. let mut target = handle; for _ in 0..8 { @@ -139,6 +142,9 @@ impl super::OpenCADStudio { _ => break, } } + if self.tabs[i].scene.is_layer_locked(target) { + return iced::Task::none(); + } // Snapshot what we need before borrowing `self` mutably to open. let Some(entity) = self.tabs[i].scene.document.get_entity(target) else { return iced::Task::none(); @@ -178,6 +184,9 @@ impl super::OpenCADStudio { height: f64, field: TextEntityField, ) { + if handle.is_some_and(|h| self.tabs[self.active_tab].scene.is_layer_locked(h)) { + return; + } let mut state = TextInlineState { pos, value: initial.to_string(), @@ -202,6 +211,10 @@ impl super::OpenCADStudio { return false; } if let Some(h) = ed.editing { + if self.tabs[i].scene.is_layer_locked(h) { + self.refresh_properties(); + return false; + } self.push_undo_snapshot(i, "TEXT"); if let Some(entity) = self.tabs[i].scene.document.get_entity_mut(h) { write_text_field(entity, ed.field, ed.value.clone()); diff --git a/src/app/update/command.rs b/src/app/update/command.rs index 773ec8db..72eec976 100644 --- a/src/app/update/command.rs +++ b/src/app/update/command.rs @@ -239,6 +239,11 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { // Grip-menu value prompt — consume the typed number and // route it through `apply_grip_menu_value`. if let Some(pending) = self.grip_pending.take() { + let i = self.active_tab; + if self.reject_locked_edit(i, pending.handle) { + self.cancel_active_grip_edit(); + return Task::none(); + } let raw = crate::app::expr_eval::eval_to_string(self.command_line.input.trim()); self.command_line.input.clear(); let Ok(v) = raw.parse::() else { @@ -249,7 +254,6 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { self.grip_pending = Some(pending); return self.focus_cmd_input(); }; - let i = self.active_tab; let interactive_lengthen = self.tabs[i] .active_grip .as_ref() @@ -1141,6 +1145,9 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { let Some(popup) = self.grip_popup.take() else { return Task::none(); }; + if self.reject_locked_edit(i, popup.handle) { + return Task::none(); + } self.grip_hover = None; let Some(item) = popup.items.get(idx).cloned() else { return Task::none(); @@ -1517,6 +1524,9 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { self.ribbon.close_dropdown(); let handles = self.property_target_handles(i); if handles.is_empty() { + if self.has_property_selection(i) { + return Task::none(); + } // No selection — change the creation default. Persist // into the tab's header (CLAYER) so it survives a tab // switch and rides the next save. #21. @@ -1559,6 +1569,9 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { self.ribbon.close_dropdown(); let handles = self.property_target_handles(i); if handles.is_empty() { + if self.has_property_selection(i) { + return Task::none(); + } // Persist the new default into the tab's header so it // round-trips through tab switches and writes back on // save (CECOLOR). #21. @@ -1585,6 +1598,9 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { self.ribbon.close_dropdown(); let handles = self.property_target_handles(i); if handles.is_empty() { + if self.has_property_selection(i) { + return Task::none(); + } // Persist into the tab's header (CELTYPE). Resolve to a // handle when the name matches a line_types entry so the // handle-based lookup stays in sync. #21. @@ -2361,6 +2377,9 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { /// attributes. Entry points: double-clicking such a block, or ATTEDIT. pub(crate) fn open_attribute_editor(&mut self, handle: acadrust::Handle) { let i = self.active_tab; + if self.reject_locked_edit(i, handle) { + return; + } let doc = &self.tabs[i].scene.document; // Ok((block, rows)) to open; Err(msg) to report and stay closed. The // borrow of `doc` ends with this match, before any `self` mutation. diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index f8700452..eaec47f3 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -2320,6 +2320,7 @@ impl OpenCADStudio { if locked { "locked" } else { "unlocked" } ).as_ref()); self.sync_ribbon_layers(); + self.refresh_properties(); } } Task::none() @@ -3203,6 +3204,9 @@ impl OpenCADStudio { Message::AnnoObjectScaleToggle(name) => { let i = self.active_tab; if let Some(entity) = self.anno_object_scale_target { + if self.tabs[i].scene.is_layer_locked(entity) { + return Task::none(); + } if let Some(sh) = self.tabs[i].scene.scale_handle_ensuring(&name) { self.push_undo_snapshot(i, "OBJECTSCALE"); let doc = &mut self.tabs[i].scene.document; @@ -4249,6 +4253,9 @@ impl OpenCADStudio { self.ribbon.close_dropdown(); let handles = self.property_target_handles(i); if handles.is_empty() { + if self.has_property_selection(i) { + return Task::none(); + } // Persist into the tab's header (CELWEIGHT). #21. self.tabs[i].scene.document.header.current_line_weight = lw.value(); self.tabs[i].dirty = true; @@ -4322,6 +4329,9 @@ impl OpenCADStudio { let i = self.active_tab; let handles = self.property_target_handles(i); if handles.is_empty() { + if self.has_property_selection(i) { + return Task::none(); + } self.tabs[i].scene.document.header.current_line_weight = lw.value(); self.tabs[i].dirty = true; self.ribbon.active_lineweight = lw; diff --git a/src/app/update/viewport.rs b/src/app/update/viewport.rs index 1cbfdc9e..cafb76d0 100644 --- a/src/app/update/viewport.rs +++ b/src/app/update/viewport.rs @@ -1079,6 +1079,14 @@ impl OpenCADStudio { // ── Grip drag ───────────────────────────────────────────── if let Some(grip) = self.tabs[i].active_grip.clone() { + if grip + .targets + .iter() + .any(|target| self.tabs[i].scene.is_layer_locked(target.handle)) + { + self.cancel_active_grip_edit(); + return Task::none(); + } let grip_started = Instant::now(); let (vw, vh) = vp_size; let bounds = iced::Rectangle { @@ -3632,8 +3640,6 @@ impl OpenCADStudio { bounds, candidate_handles.as_ref(), )); - // Objects on a locked layer aren't selectable. - handles.retain(|&h| !self.tabs[i].scene.is_layer_locked(h)); // Box/lasso accumulates like individual picks // (issue #83): a plain box adds to the current // selection, Shift+box removes the boxed @@ -3746,7 +3752,6 @@ impl OpenCADStudio { .into_iter() .filter_map(|s| Scene::handle_from_wire_name(s)) .filter(|&h| self.tabs[i].scene.passes_selection_filter(h)) - .filter(|&h| !self.tabs[i].scene.is_layer_locked(h)) .collect(); if cands.len() >= 2 { // Overlap: open the list box at the cursor. @@ -3806,45 +3811,33 @@ impl OpenCADStudio { // Selection filter: drop a pick whose type is excluded. let hit = hit.filter(|&h| self.tabs[i].scene.passes_selection_filter(h)); if let Some(handle) = hit { - if let Some(layer) = self.tabs[i].scene.locked_layer_name(handle) { - // Locked layer: visible + snappable but - // not selectable. Report and do nothing - // else — in particular do NOT set - // `selection_just_completed`, or a - // gather command (MOVE's "select - // objects") would wrongly finish. - self.command_line.push_info(crate::tf!( - "Object is on locked layer \"{layer}\" — unlock the layer to select or edit it." - ).as_ref()); - } else { - // Individual picks accumulate (issue #47): - // each plain click adds to the selection, - // Shift+click removes the picked entity. - // PICKADD 0 (#226): a plain click - // REPLACES the selection instead and - // Shift+click toggles membership. - if self.shift_down || self.select_remove_mode { - // Remove was asked for by name, so it only - // ever takes away — the toggle below is - // Shift's PICKADD-0 behaviour, not its. - if !self.select_remove_mode - && !selection_pick_add - && !self.tabs[i].scene.selected.contains(&handle) - { - self.tabs[i].scene.select_entity(handle, false); - self.tabs[i].scene.expand_selection_for_groups(&[handle]); - } else { - self.tabs[i].scene.deselect_entity(handle); - } - } else { - self.tabs[i] - .scene - .select_entity(handle, !selection_pick_add); + // Individual picks accumulate (issue #47): + // each plain click adds to the selection, + // Shift+click removes the picked entity. + // PICKADD 0 (#226): a plain click + // REPLACES the selection instead and + // Shift+click toggles membership. + if self.shift_down || self.select_remove_mode { + // Remove was asked for by name, so it only + // ever takes away — the toggle below is + // Shift's PICKADD-0 behaviour, not its. + if !self.select_remove_mode + && !selection_pick_add + && !self.tabs[i].scene.selected.contains(&handle) + { + self.tabs[i].scene.select_entity(handle, false); self.tabs[i].scene.expand_selection_for_groups(&[handle]); + } else { + self.tabs[i].scene.deselect_entity(handle); } - self.refresh_properties(); - selection_just_completed = true; + } else { + self.tabs[i] + .scene + .select_entity(handle, !selection_pick_add); + self.tabs[i].scene.expand_selection_for_groups(&[handle]); } + self.refresh_properties(); + selection_just_completed = true; } else { // Empty-space click only ARMS a box here; it // no longer clears the selection, so a box can @@ -4011,8 +4004,6 @@ impl OpenCADStudio { )); // Selection filter: keep only allowed types. handles.retain(|&h| self.tabs[i].scene.passes_selection_filter(h)); - // Objects on a locked layer aren't selectable. - handles.retain(|&h| !self.tabs[i].scene.is_layer_locked(h)); // Accumulate (issue #83): a plain box adds to the // current selection, Shift+box removes the boxed // entities. An empty box leaves the selection alone diff --git a/src/app/visibility.rs b/src/app/visibility.rs index 2fbf83da..8c349bb7 100644 --- a/src/app/visibility.rs +++ b/src/app/visibility.rs @@ -178,6 +178,10 @@ impl OpenCADStudio { /// anonymous-block member visible/invisible per the state, then rebuild. pub(super) fn apply_visibility_state(&mut self, insert_handle: Handle, state_idx: usize) { let i = self.active_tab; + if self.reject_locked_edit(i, insert_handle) { + self.visibility_popup = None; + return; + } // Resolve everything against an immutable borrow first. let mapping = { diff --git a/src/scene/entity.rs b/src/scene/entity.rs index 6ed432eb..130bd0f8 100644 --- a/src/scene/entity.rs +++ b/src/scene/entity.rs @@ -327,6 +327,9 @@ impl Scene { #[cfg_attr(target_arch = "wasm32", allow(dead_code))] pub fn update_entity(&mut self, mut entity: EntityType) -> bool { let handle = entity.common().handle; + if self.is_layer_locked(handle) { + return false; + } let Some(existing) = self.document.get_entity(handle) else { return false; }; diff --git a/src/scene/mod.rs b/src/scene/mod.rs index 120a10a6..58839940 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -6412,8 +6412,7 @@ impl Scene { } /// True when `handle`'s entity sits on a locked layer. Locked objects stay - /// visible and snappable but cannot be selected or modified — callers in - /// the pick / modify paths consult this to skip them. + /// visible, snappable and selectable, but mutation paths must skip them. pub fn is_layer_locked(&self, handle: Handle) -> bool { self.document .get_entity(handle) @@ -7807,7 +7806,6 @@ impl Scene { )); } handles.retain(|&h| self.passes_selection_filter(h)); - handles.retain(|&h| !self.is_layer_locked(h)); handles } diff --git a/src/scene/modify.rs b/src/scene/modify.rs index 2f004a18..ae167651 100644 --- a/src/scene/modify.rs +++ b/src/scene/modify.rs @@ -551,7 +551,7 @@ impl Scene { } pub fn copy_entities(&mut self, handles: &[Handle], t: &EntityTransform) -> Vec { - // Objects on a locked layer can't be copied (they can't be selected). + // Objects on a locked layer can be selected but not copied. let clones: Vec<(Handle, EntityType)> = handles .iter() .filter(|&&h| !self.is_layer_locked(h)) diff --git a/src/scene/selection.rs b/src/scene/selection.rs index 55728cd5..80ac65af 100644 --- a/src/scene/selection.rs +++ b/src/scene/selection.rs @@ -164,16 +164,6 @@ impl Scene { let Some(e) = self.document.get_entity(h) else { continue; }; - // Never quick-select objects on a locked layer. - if self - .document - .layers - .get(&e.common().layer) - .map(|l| l.is_locked()) - .unwrap_or(false) - { - continue; - } let type_ok = type_name.is_none_or(|t| entity_type_name(e) == t); let prop_ok = if !type_ok { true diff --git a/src/ui/overlay.rs b/src/ui/overlay.rs index a2ea3b61..91730445 100644 --- a/src/ui/overlay.rs +++ b/src/ui/overlay.rs @@ -347,7 +347,7 @@ struct SelectionCanvas { /// is usable instead of the cursor vanishing over it. (#227) suppressed: bool, /// The entity under the crosshair is on a locked layer — draw a small lock - /// badge by the cursor so the user knows it can't be selected/edited. + /// badge by the cursor so the user knows it can't be edited. hover_locked: bool, /// Background of the active drawing space. Crosshair contrast follows this /// rather than the UI theme, which may be light over a dark model viewport. @@ -1042,7 +1042,7 @@ impl canvas::Program for SelectionCanvas { // Locked-layer badge: a small padlock beside the crosshair when // the hovered object sits on a locked layer (issue: locked - // objects are visible + snappable but not selectable/editable). + // objects are visible, snappable and selectable but not editable). if self.hover_locked { let warning = theme.palette().warning.base; let amber = warning.color.scale_alpha(0.98);