forked from 1b-agent/opencadstudio-upstream
feat(layers): enforce layer lock (select/modify/fade + cursor badge)
Locked-layer objects were fully editable. Now they stay visible and snappable but are otherwise protected: - Not selectable: the five interactive pick paths (box, lasso, single click, cycling, box-release) plus SELECTALL and QSELECT skip locked entities. - Not modifiable (defense-in-depth even if a handle slips through): transform_entities, erase_entities, apply_grip and the Properties commit all skip locked entities. - Faded: entities on a locked layer render dimmed toward the background (render_style + wire tessellation); toggling lock re-tessellates. - Snap still works: locked entities are intentionally kept in the wire set (visibility_ok untouched), so object snap keeps finding them. - Feedback: a small padlock is drawn by the crosshair when hovering a locked object, and clicking one prints "Object is on locked layer …". New Scene::is_layer_locked / locked_layer_name helpers centralise the check (mirroring layer_hidden). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
1 parent
c71950fa7c
commit
56e1c03ac2
11 files changed
+149
-2
No files matched your search
@@ -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 {
|
||||
|
||||
@@ -1153,6 +1153,10 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task<Message> {
|
||||
}
|
||||
} 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)
|
||||
{
|
||||
|
||||
@@ -2133,6 +2133,8 @@ pub(super) fn on_tick(&mut self, t: Instant) -> Task<Message> {
|
||||
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<Message> {
|
||||
));
|
||||
// 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<Message> {
|
||||
.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<Message> {
|
||||
// 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<Message> {
|
||||
));
|
||||
// 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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
};
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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<String> {
|
||||
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.
|
||||
|
||||
@@ -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<Handle> =
|
||||
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).
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -190,6 +190,7 @@ pub fn selection_overlay<'a>(
|
||||
pane_drop_rect: Option<iced::Rectangle>,
|
||||
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<Message> 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
|
||||
|
||||
|
||||
Reference in new issue
Block a user