From 8817e0701364d4bf409dccfe9596df6b109dbe9b Mon Sep 17 00:00:00 2001 From: aiongg Date: Tue, 13 Oct 2026 14:19:56 +0000 Subject: [PATCH] fix(paths): path edits refuse a path on a locked layer or in a locked group (#735) --- crates/engine/src/cmd/anchors.rs | 5 +++++ crates/engine/src/cmd/object.rs | 34 ++++++++++++++++++++++++++------ crates/engine/src/cmd/paths.rs | 1 + 3 files changed, 34 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/cmd/anchors.rs b/crates/engine/src/cmd/anchors.rs index c5015c7c4..ad18b8225 100644 --- a/crates/engine/src/cmd/anchors.rs +++ b/crates/engine/src/cmd/anchors.rs @@ -5,6 +5,7 @@ use designcraft_doc::{Document, ItemId, Shape}; use designcraft_geom::{Affine, Anchor, AnchorKind, Point}; use serde_json::{Value, json}; +use super::object::path_editable; use super::{CommandSpec, bad, cmd, has_doc, point_param}; use crate::{Result, Session}; @@ -69,6 +70,7 @@ fn erase_or_smooth(s: &mut Session, p: &Value, smooth: bool) -> Result { .map(|a| a.iter().filter_map(|v| Some(Point::new(v.get(0)?.as_f64()?, v.get(1)?.as_f64()?))).collect()) .unwrap_or_default(); s.edit(|d, _| { + path_editable(d, id, cmd)?; let inv = to_inner(d, id).ok_or(designcraft_doc::DocError::NoItem(id))?; let scale = inv.determinant().abs().sqrt().max(1e-9); let it = d.item_mut(id).ok_or(designcraft_doc::DocError::NoItem(id))?; @@ -135,6 +137,7 @@ fn add_anchor(s: &mut Session, p: &Value) -> Result { let at = point_param(p, "at").ok_or_else(|| bad("path.addAnchor", "missing at"))?; let tol = p.get("tolerance").and_then(Value::as_f64).unwrap_or(4.0); s.edit(|d, _| { + path_editable(d, id, "path.addAnchor")?; let inv = to_inner(d, id).ok_or(designcraft_doc::DocError::NoItem(id))?; let it = d.item_mut(id).ok_or(designcraft_doc::DocError::NoItem(id))?; let q = inv * at; @@ -154,6 +157,7 @@ fn add_anchor(s: &mut Session, p: &Value) -> Result { fn delete_anchor(s: &mut Session, p: &Value) -> Result { let (id, si, ai) = anchor_param(p, "path.deleteAnchor")?; s.edit(|d, sel| { + path_editable(d, id, "path.deleteAnchor")?; let it = d.item_mut(id).ok_or(designcraft_doc::DocError::NoItem(id))?; let sp = it.path.subpaths.get_mut(si).ok_or_else(|| bad("path.deleteAnchor", "no such subpath"))?; if ai >= sp.anchors.len() { @@ -181,6 +185,7 @@ fn convert_anchor(s: &mut Session, p: &Value) -> Result { let to = point_param(p, "to"); let corner = p.get("corner").and_then(Value::as_bool); s.edit(|d, _| { + path_editable(d, id, "path.convertAnchor")?; let inv = to_inner(d, id).ok_or(designcraft_doc::DocError::NoItem(id))?; let it = d.item_mut(id).ok_or(designcraft_doc::DocError::NoItem(id))?; let sp = it.path.subpaths.get_mut(si).ok_or_else(|| bad("path.convertAnchor", "no such subpath"))?; diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index aa5a2cc01..a03e055ae 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -1489,6 +1489,20 @@ fn unlocked(d: &Document, it: &Item) -> bool { !it.locked && d.layer(it.layer).is_none_or(|l| !l.locked) } +/// Refuses a path edit on `id` when it, a group it sits in, or its layer is locked. +pub(super) fn path_editable(d: &Document, id: ItemId, cmd: &str) -> Result<()> { + let loc = d.find(id).ok_or(designcraft_doc::DocError::NoItem(id))?; + let mut items = d.spread(loc.spread).map(|sp| &sp.items[..]).unwrap_or_default(); + for &i in &loc.path { + let Some(it) = items.get(i) else { break }; + if !unlocked(d, it) { + return Err(bad(cmd, "the path is locked")); + } + items = it.children(); + } + Ok(()) +} + fn has_editable_selection(s: &Session) -> std::result::Result<(), String> { has_selection(s)?; let st = s.active().ok_or_else(|| super::NOTHING_SELECTED.to_string())?; @@ -2075,6 +2089,7 @@ fn path_append(s: &mut Session, p: &Value) -> Result { let a = p.get("anchor").and_then(anchor_from).ok_or_else(|| bad("path.appendAnchor", "missing anchor"))?; let subpath = p.get("subpath").and_then(Value::as_u64); s.edit(|d, _| { + path_editable(d, id, "path.appendAnchor")?; let loc = d.find(id).ok_or(designcraft_doc::DocError::NoItem(id))?; let inv = (d.parent_xf(&loc) * d.item_at(&loc).map(|i| i.xf).unwrap_or_default()).inverse(); let it = d.item_mut(id).ok_or(designcraft_doc::DocError::NoItem(id))?; @@ -2110,12 +2125,10 @@ fn path_set_anchor(s: &mut Session, p: &Value) -> Result { let subpath = p.get("subpath").and_then(Value::as_u64); let which = p.get("anchor").cloned().unwrap_or(Value::Null); s.edit(|d, _| { + path_editable(d, id, ID)?; let loc = d.find(id).ok_or(designcraft_doc::DocError::NoItem(id))?; let inv = (d.parent_xf(&loc) * d.item_at(&loc).map(|i| i.xf).unwrap_or_default()).inverse(); let it = d.item_mut(id).ok_or(designcraft_doc::DocError::NoItem(id))?; - if it.locked { - return Err(bad(ID, "the path is locked")); - } let sp = match subpath { Some(i) => usize::try_from(i).ok().and_then(|i| it.path.subpaths.get_mut(i)), None => it.path.subpaths.last_mut(), @@ -2157,12 +2170,19 @@ fn path_reverse(s: &mut Session, p: &Value) -> Result { { return Err(bad("path.reverse", "no such subpath")); } - let ids = if only.is_some() { ids.into_iter().take(1).collect() } else { ids }; + let ids: Vec = if only.is_some() { ids.into_iter().take(1).collect() } else { ids }; + let doc = &s.doc()?.doc; + let editable: Vec = ids.iter().copied().filter(|id| path_editable(doc, *id, "path.reverse").is_ok()).collect(); + if editable.is_empty() + && let Some(id) = ids.first() + { + path_editable(doc, *id, "path.reverse")?; + } s.edit(|d, _| { let mut reversed = 0; - for id in &ids { + for id in &editable { let Some(it) = d.item_mut(*id) else { continue }; - if it.locked || it.is_group() { + if it.is_group() { continue; } for (i, sp) in it.path.subpaths.iter_mut().enumerate() { @@ -2184,6 +2204,7 @@ fn path_close(s: &mut Session, p: &Value) -> Result { let id = super::id_param(p, "id").ok_or_else(|| bad("path.close", "missing id"))?; let only = p.get("subpath").and_then(Value::as_u64).map(|i| usize::try_from(i).unwrap_or(usize::MAX)); s.edit(|d, _| { + path_editable(d, id, "path.close")?; let it = d.item_mut(id).ok_or(designcraft_doc::DocError::NoItem(id))?; if only.is_some_and(|i| i >= it.path.subpaths.len()) { return Err(bad("path.close", "no such subpath")); @@ -2206,6 +2227,7 @@ fn path_move_anchors(s: &mut Session, p: &Value) -> Result { let d0 = Vec2::new(f64_or(p, "dx", 0.0), f64_or(p, "dy", 0.0)); let handle = str_param(p, "handle").map(str::to_string); s.edit(|d, _| { + path_editable(d, id, "path.moveAnchors")?; let loc = d.find(id).ok_or(designcraft_doc::DocError::NoItem(id))?; let m = d.parent_xf(&loc) * d.item_at(&loc).map(|i| i.xf).unwrap_or_default(); // Spread-space delta → inner-space delta (linear part only). diff --git a/crates/engine/src/cmd/paths.rs b/crates/engine/src/cmd/paths.rs index b9d479067..14609d18f 100644 --- a/crates/engine/src/cmd/paths.rs +++ b/crates/engine/src/cmd/paths.rs @@ -312,6 +312,7 @@ fn split_path(s: &mut Session, p: &Value) -> Result { let id = super::id_param(p, "id").ok_or_else(|| bad("path.split", "missing id"))?; let at = point_param(p, "at").ok_or_else(|| bad("path.split", "missing at"))?; s.edit(|d, sel| { + super::object::path_editable(d, id, "path.split")?; let (sr, xf) = spread_xf(d, id).ok_or(designcraft_doc::DocError::NoItem(id))?; let it = d.item(id).cloned().ok_or(designcraft_doc::DocError::NoItem(id))?; if !matches!(it.content, Content::Unassigned) {