diff --git a/src/app/commands/inquiry.rs b/src/app/commands/inquiry.rs index 912ecd32b..3e2534a7b 100644 --- a/src/app/commands/inquiry.rs +++ b/src/app/commands/inquiry.rs @@ -16,6 +16,8 @@ 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 { diff --git a/src/app/update/command.rs b/src/app/update/command.rs index d4411f78b..58ee4f07f 100644 --- a/src/app/update/command.rs +++ b/src/app/update/command.rs @@ -1153,6 +1153,10 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { } } else { for &handle in &handles { + // Skip objects on a locked layer. + if self.tabs[i].scene.is_layer_locked(handle) { + continue; + } if let Some(entity) = self.tabs[i].scene.document.get_entity_mut(handle) { diff --git a/src/app/update/viewport.rs b/src/app/update/viewport.rs index 217c2a0a9..db72341dd 100644 --- a/src/app/update/viewport.rs +++ b/src/app/update/viewport.rs @@ -2133,6 +2133,8 @@ pub(super) fn on_tick(&mut self, t: Instant) -> Task { handles.extend(self.tabs[i].scene.block_mesh_box_hit( a, p, crossing, view_rot, eye, bounds, )); + // 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 @@ -2193,6 +2195,8 @@ pub(super) fn on_tick(&mut self, t: Instant) -> Task { )); // 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 like the box path (issue #83): plain // lasso adds, Shift+lasso removes. An empty lasso // leaves the current selection untouched so a stray @@ -2238,6 +2242,7 @@ pub(super) fn on_tick(&mut self, t: Instant) -> Task { .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. @@ -2285,7 +2290,15 @@ pub(super) fn on_tick(&mut self, t: Instant) -> Task { // each plain click adds to the selection, // Shift+click removes the picked entity. // Esc / empty-space click clears. - if self.shift_down { + if let Some(layer) = + self.tabs[i].scene.locked_layer_name(handle) + { + // Locked layer: visible + snappable, but + // not selectable. Tell the user why. + self.command_line.push_info(&format!( + "Object is on locked layer \"{layer}\" — unlock the layer to select or edit it." + )); + } else if self.shift_down { self.tabs[i].scene.deselect_entity(handle); } else { self.tabs[i].scene.select_entity(handle, false); @@ -2341,6 +2354,8 @@ pub(super) fn on_tick(&mut self, t: Instant) -> Task { )); // 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/view/mod.rs b/src/app/view/mod.rs index 6d5a8c9a6..20466ed1f 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -389,6 +389,11 @@ impl OpenCADStudio { _ => (None, None), }; + let hover_locked = tab + .scene + .hover_highlight + .map(|h| tab.scene.is_layer_locked(h)) + .unwrap_or(false); crate::ui::overlay::selection_overlay( sel, snap_info, @@ -402,6 +407,7 @@ impl OpenCADStudio { pane_drop_rect, tab.pan_mode, self.ribbon.open_dropdown.is_some(), + hover_locked, ) }; diff --git a/src/scene/convert/tess.rs b/src/scene/convert/tess.rs index fdefc45e4..7847aa0ec 100644 --- a/src/scene/convert/tess.rs +++ b/src/scene/convert/tess.rs @@ -1,6 +1,22 @@ // Auto-split from scene/mod.rs. Pure text-move; behaviour unchanged. use super::super::*; +/// Dim an already-bg-adapted colour toward the background when the entity's +/// layer is locked, so locked objects read as non-editable (they stay visible +/// and snappable). No-op for unlocked layers. +fn fade_if_locked( + document: &acadrust::CadDocument, + e: &EntityType, + color: [f32; 4], + bg: [f32; 4], +) -> [f32; 4] { + if view::render::layer_locked(document, e) { + crate::scene::cache::block_cache::fade_toward_bg(color, bg) + } else { + color + } +} + // ── Parallel tessellation free function ────────────────────────────────────── // // Takes only the `Send + Sync` data needed for tessellation so that @@ -107,6 +123,7 @@ pub(crate) fn tessellate_entity( let (entity_color, _, _, _, aci_idx) = view::render::render_style_for(document, e); let entity_color = view::render::adapt_to_bg(entity_color, bg_color); + let entity_color = fade_if_locked(document, e, entity_color, bg_color); if is_3d_entity { // `ab` is already in the local frame // (entity_aabb subtracted world_offset @@ -190,6 +207,7 @@ pub(crate) fn tessellate_entity( let (entity_color, pattern_length, pattern, line_weight_px, aci) = view::render::render_style_for(document, e); let entity_color = view::render::adapt_to_bg(entity_color, bg_color); + let entity_color = fade_if_locked(document, e, entity_color, bg_color); let lt_scale = document.header.linetype_scale as f32 * e.common().linetype_scale as f32; let lt_name = view::render::linetype_name_for(document, e); // PSLTSCALE: scale linetype dashes by viewport anno_scale so they appear uniform in paper space. diff --git a/src/scene/group_layer.rs b/src/scene/group_layer.rs index b23c0c898..6ecf8d334 100644 --- a/src/scene/group_layer.rs +++ b/src/scene/group_layer.rs @@ -94,5 +94,7 @@ impl Scene { if let Some(layer) = self.document.layers.get_mut(name) { layer.flags.locked = !layer.flags.locked; } + // Re-tessellate so the locked-layer dim (fade) appears/clears at once. + self.bump_geometry(); } } diff --git a/src/scene/mod.rs b/src/scene/mod.rs index 532a6db54..120917245 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -2296,6 +2296,24 @@ impl Scene { .unwrap_or(false) } + /// 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. + pub fn is_layer_locked(&self, handle: Handle) -> bool { + self.document + .get_entity(handle) + .map(|e| e.common().layer.clone()) + .and_then(|name| self.document.layers.get(&name).map(|l| l.is_locked())) + .unwrap_or(false) + } + + /// The name of the locked layer `handle` sits on, if any (for messages). + pub fn locked_layer_name(&self, handle: Handle) -> Option { + let name = self.document.get_entity(handle).map(|e| e.common().layer.clone())?; + let locked = self.document.layers.get(&name).map(|l| l.is_locked()).unwrap_or(false); + locked.then_some(name) + } + /// Visibility test for a solid mesh entity, mirroring the 2D wire path: /// honour the invisible flag, the isolate/hide set, and the layer's /// off/frozen state. diff --git a/src/scene/modify.rs b/src/scene/modify.rs index 7b3adb00d..cecda157c 100644 --- a/src/scene/modify.rs +++ b/src/scene/modify.rs @@ -5,6 +5,11 @@ impl Scene { // ── Modify (transform / copy) ───────────────────────────────────────── pub fn transform_entities(&mut self, handles: &[Handle], t: &EntityTransform) { + // Never transform objects on a locked layer (defense-in-depth: the pick + // path already excludes them, but programmatic callers may not). + let handles: Vec = + handles.iter().copied().filter(|&h| !self.is_layer_locked(h)).collect(); + let handles = &handles[..]; // MIRRTEXT (header.mirror_text): when false AutoCAD positions text / // mtext / shape by the mirror but keeps the original rotation + // oblique so the text stays right-reading. Capture before the @@ -254,6 +259,10 @@ impl Scene { // ── Grip editing ────────────────────────────────────────────────────── pub fn apply_grip(&mut self, handle: Handle, grip_id: usize, apply: GripApply) { + // Objects on a locked layer can't be grip-edited. + if self.is_layer_locked(handle) { + return; + } // For Solid3D / Region / Body, record the old point_of_reference so we // can translate the pre-tessellated MeshModel by the same delta after // the grip is applied (the ACIS data itself is not modified). diff --git a/src/scene/selection.rs b/src/scene/selection.rs index 270fcc5e4..9c5ae0fc0 100644 --- a/src/scene/selection.rs +++ b/src/scene/selection.rs @@ -130,6 +130,16 @@ 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; + } if let Some(t) = type_name { if entity_type_name(e) != t { continue; @@ -316,6 +326,10 @@ impl Scene { pub fn erase_entities(&mut self, handles: &[Handle]) { for &h in handles { + // Objects on a locked layer can't be erased. + if self.is_layer_locked(h) { + continue; + } self.document.remove_entity(h); self.selected.remove(&h); self.hatches.remove(&h); diff --git a/src/scene/view/render.rs b/src/scene/view/render.rs index 8d5ea1d13..6f3381770 100644 --- a/src/scene/view/render.rs +++ b/src/scene/view/render.rs @@ -483,10 +483,28 @@ impl Scene { } else { self.paper_bg_color }; - (adapt_to_bg(color, bg), pl, pat, lw, aci) + // Objects on a locked layer are dimmed toward the background so they + // read as "not editable" (they stay visible and snappable). + let adapted = adapt_to_bg(color, bg); + let final_color = if layer_locked(&self.document, e) { + crate::scene::cache::block_cache::fade_toward_bg(adapted, bg) + } else { + adapted + }; + (final_color, pl, pat, lw, aci) } } +/// Whether an entity sits on a locked layer (via the document's layer table). +/// Document-only so it is safe from the parallel tessellation path. +pub(in crate::scene) fn layer_locked(document: &CadDocument, e: &EntityType) -> bool { + document + .layers + .get(&e.common().layer) + .map(|l| l.is_locked()) + .unwrap_or(false) +} + // ── Document-only render-style helpers (no &self, safe to call from parallel contexts) ── /// Resolves the effective linetype name for an entity, falling back to the diff --git a/src/ui/overlay.rs b/src/ui/overlay.rs index 6debb659b..7fcdf0089 100644 --- a/src/ui/overlay.rs +++ b/src/ui/overlay.rs @@ -190,6 +190,7 @@ pub fn selection_overlay<'a>( pane_drop_rect: Option, pan_mode: bool, suppressed: bool, + hover_locked: bool, ) -> Element<'a, Message> { canvas(SelectionCanvas { selection, @@ -204,6 +205,7 @@ pub fn selection_overlay<'a>( pane_drop_rect, pan_mode, suppressed, + hover_locked, }) .width(Length::Fill) .height(Length::Fill) @@ -236,6 +238,9 @@ struct SelectionCanvas { /// crosshair is not drawn and the OS cursor is shown normally so the panel /// 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. + hover_locked: bool, } impl SelectionCanvas { @@ -883,6 +888,42 @@ impl canvas::Program for SelectionCanvas { frame.stroke(&v_top, stroke.clone()); frame.stroke(&v_bot, stroke.clone()); frame.stroke(&square, stroke); + + // 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). + if self.hover_locked { + let amber = Color { r: 0.96, g: 0.76, b: 0.26, a: 0.98 }; + let dark = Color { r: 0.12, g: 0.10, b: 0.04, a: 1.0 }; + let bx = cp.x + sq + 7.0; + let by = cp.y - sq - 13.0; + // Lock body (filled). + let body = canvas::Path::rectangle( + Point::new(bx, by + 6.0), + Size::new(12.0, 9.0), + ); + frame.fill(&body, amber); + // Shackle: an inverted-U above the body (squared so the + // shape is unambiguous regardless of arc winding). + let shackle = canvas::Path::new(|b| { + b.move_to(Point::new(bx + 2.5, by + 6.0)); + b.line_to(Point::new(bx + 2.5, by + 2.5)); + b.line_to(Point::new(bx + 9.5, by + 2.5)); + b.line_to(Point::new(bx + 9.5, by + 6.0)); + }); + frame.stroke( + &shackle, + canvas::Stroke { + width: 1.8, + style: canvas::Style::Solid(amber), + line_join: canvas::LineJoin::Round, + ..Default::default() + }, + ); + // Keyhole. + let hole = canvas::Path::circle(Point::new(bx + 6.0, by + 10.5), 1.4); + frame.fill(&hole, dark); + } } } // end !over_viewcube