From eeb213e5a84c9c731c30bbe0bf708929dee4a142 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 11 Oct 2026 03:38:31 +0700 Subject: [PATCH] #1002: new art is refused when no layer is shown and unlocked With every layer hidden or locked, the insertion layer fell back to the top layer anyway, so drawing a shape, typing, pasting, making a graph, a flare, a grid, trim marks or a symbol put the art on a hidden or locked layer: invisible, or unselectable by Select All. Illustrator refuses to draw on a locked or hidden target layer (public forum answers, e.g. https://community.adobe.com/t5/illustrator/can-t-draw-a-new-object-in-my-document/m-p/11107182). DocState::target_parent is insertion_parent for new art, failing with "the target layer is locked or hidden" when it would be a layer that isn't editable; those creation paths use it. Another layer that is shown and unlocked still takes the art, as before. The message is in the de, es, fr, it, ja, ru and uk catalogs. File > Place is left as it is (another branch reworks it). The test hides, then locks, the only layer: an ellipse, a polar grid, text and a paste each fail with the message and leave the document unchanged; a new layer then takes a rectangle. Co-Authored-By: Claude Opus 5.5 # Conflicts: # ROADMAP.md --- ROADMAP.md | 2 +- crates/engine/src/cmd/brushsym.rs | 5 +++-- crates/engine/src/cmd/create.rs | 6 +++--- crates/engine/src/cmd/edit.rs | 2 +- crates/engine/src/cmd/graph.rs | 2 +- crates/engine/src/cmd/menucmds.rs | 2 +- crates/engine/src/lib.rs | 8 ++++++++ crates/engine/src/tests_layers.rs | 30 ++++++++++++++++++++++++++++++ crates/ui-egui/src/i18n/de.tsv | 1 + crates/ui-egui/src/i18n/es.tsv | 1 + crates/ui-egui/src/i18n/fr.tsv | 1 + crates/ui-egui/src/i18n/it.tsv | 1 + crates/ui-egui/src/i18n/ja.tsv | 1 + crates/ui-egui/src/i18n/ru.tsv | 1 + crates/ui-egui/src/i18n/uk.tsv | 1 + 15 files changed, 55 insertions(+), 9 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 2ddc66cf..0e054ab5 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -123,7 +123,7 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap | Date | What landed | |---|---| -| 2026-10-11 | Effect › Brush Strokes: Accented Edges, Angled Strokes, Crosshatch, Dark Strokes, Ink Outlines, Spatter, Sprayed Strokes, Sumi-e (G2). Command parameters from agents and files checked before anything changes (paint, place, cuts, dashes, crop, asset ids, anchors, paths, distort; #984–#999); command dialogs keep whole numbers whole, so Create Object Mosaic takes the tiles typed (#1000); stepper arrows take Shift (×10) and Ctrl/Cmd (÷10) like the arrow keys (#991). Exports and Rasterize supersample Art Optimized edges, so shapes meeting edge to edge leave no seam (#983). Character and paragraph styles take leading, kerning, mojikumi and the other attributes left out at their defaults (#1001). | +| 2026-10-11 | Effect › Brush Strokes: Accented Edges, Angled Strokes, Crosshatch, Dark Strokes, Ink Outlines, Spatter, Sprayed Strokes, Sumi-e (G2). Command parameters from agents and files checked before anything changes (paint, place, cuts, dashes, crop, asset ids, anchors, paths, distort; #984–#999); command dialogs keep whole numbers whole, so Create Object Mosaic takes the tiles typed (#1000); stepper arrows take Shift (×10) and Ctrl/Cmd (÷10) like the arrow keys (#991). Exports and Rasterize supersample Art Optimized edges, so shapes meeting edge to edge leave no seam (#983). Character and paragraph styles take leading, kerning, mojikumi and the other attributes left out at their defaults (#1001). Drawing, typing and pasting with no layer shown and unlocked is refused instead of adding art to a hidden or locked layer (#1002). | | 2026-10-10 | Initial live Revolve (Effect › 3D and Materials, #846, #605), in its own `three-d` crate. Progress docs re-measured and restructured to the craftrules standard (this page, `docs/target-app-parity.md`, `gaps.md`, `ui-parity.md`, the format, hardware, localization, effects and type checklists). Same day: v0.8.0; Effect › Distort, Pixelate and Texture filters (13); German interface; Variables (data merge); Graph Type value axes and tick marks; restart when the first frame never reaches the screen (#964) | | 2026-10-09 | Radial Blur, Smart Blur, Unsharp Mask (gap 2 begins); 340 commits, the busiest day | | 2026-10-08 | v0.5.0–v0.7.0; community contributions: Pen and shape-tool modifiers, Layers, Artboards, Japanese composition, Hebrew/Arabic type, interface languages, saved selections | diff --git a/crates/engine/src/cmd/brushsym.rs b/crates/engine/src/cmd/brushsym.rs index 429dd314..59dfef53 100644 --- a/crates/engine/src/cmd/brushsym.rs +++ b/crates/engine/src/cmd/brushsym.rs @@ -603,7 +603,7 @@ fn symbol_place(s: &mut Session, p: &Value) -> Result { let centre = d.artboards.first().map(|a| a.rect.center()).unwrap_or(Point::ZERO); let c = Point::new(f64_or(p, "x", centre.x), f64_or(p, "y", centre.y)); let size = natural_size(d, &name); - let parent = s.doc()?.insertion_parent(); + let parent = s.doc()?.target_parent()?; let id = s.edit("Place Symbol Instance", |d, sel| { let id = d.alloc_id(); d.insert(parent, usize::MAX, Node::new(id, NodeKind::SymbolInstance { symbol: name.clone(), xf: place_xf(c, size) }))?; @@ -843,7 +843,8 @@ fn symbol_spray(s: &mut Session, p: &Value) -> Result { let alt = bool_or(p, "alt", false); let size = natural_size(&st.doc, &name); let set = st.selection.objects.first().copied().filter(|id| st.selection.len() == 1 && st.doc.node(*id).is_some_and(is_symbol_set)); - let parent = st.insertion_parent(); + // Only a new Symbol Set needs a layer that takes new art. + let parent = if alt || set.is_some() { st.insertion_parent() } else { st.target_parent()? }; if alt { // Remove instances of this symbol under the brush (within the set, or anywhere). let d = &st.doc; diff --git a/crates/engine/src/cmd/create.rs b/crates/engine/src/cmd/create.rs index b4093ec0..5b4dd8c1 100644 --- a/crates/engine/src/cmd/create.rs +++ b/crates/engine/src/cmd/create.rs @@ -79,7 +79,7 @@ pub(crate) fn add_node(s: &mut Session, label: &str, kind: NodeKind, appearance: /// Insert a new object looking like `look` (drawing modes apply); select it. pub(crate) fn add_look(s: &mut Session, label: &str, kind: NodeKind, look: NewArt, name: Option) -> Result { - let parent = s.doc()?.insertion_parent(); + let parent = s.doc()?.target_parent()?; let mode = s.draw_mode; let inside = s.draw_inside; let behind_of = s.doc()?.selection.in_paint_order(&s.doc()?.doc).first().copied(); @@ -298,7 +298,7 @@ fn flare(s: &mut Session, p: &Value) -> Result { }; parts.push(("Ring", circle(at, rr), radial(at, rr, stops), 1.0)); } - let parent = s.doc()?.insertion_parent(); + let parent = s.doc()?.target_parent()?; let id = s.edit("Flare", |d, sel| { let children = parts .into_iter() @@ -352,7 +352,7 @@ fn arc(s: &mut Session, p: &Value) -> Result { } fn grid_group(s: &mut Session, label: &str, paths: Vec) -> Result { - let parent = s.doc()?.insertion_parent(); + let parent = s.doc()?.target_parent()?; let look = s.new_art_look(vectorcraft_color::Paint::None, s.paint.stroke.clone(), s.paint.stroke_width); let id = s.edit(label, |d, sel| { let children = paths diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index fb87ce30..d5e1b18a 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -346,7 +346,7 @@ fn paste_clip(s: &mut Session, p: &Value, mode: PasteMode, clip: &Clipboard, cho let off = s.prefs.paste_offset; let st = s.doc()?; let same_doc = clip.source_doc == Some(st.uid); - let parent = st.insertion_parent(); + let parent = st.target_parent()?; // A copied artboard comes back with its art: right of the last artboard, or where it was // (Paste in Place, in Front, in Back). Paste on All Artboards pastes only the art. let board = clip diff --git a/crates/engine/src/cmd/graph.rs b/crates/engine/src/cmd/graph.rs index 2077c8e5..fd2153fd 100644 --- a/crates/engine/src/cmd/graph.rs +++ b/crates/engine/src/cmd/graph.rs @@ -831,7 +831,7 @@ fn create(s: &mut Session, p: &Value) -> Result { spec.categories = vec!["A".into(), "B".into(), "C".into(), "D".into()]; spec.rows = vec![vec![3.0, 2.0], vec![5.0, 4.0], vec![4.0, 6.0], vec![7.0, 5.0]]; } - let parent = s.doc()?.insertion_parent(); + let parent = s.doc()?.target_parent()?; let id = s.edit("Graph", |d, sel| { let gid = d.alloc_id(); let mut g = Node::group(gid, vec![]); diff --git a/crates/engine/src/cmd/menucmds.rs b/crates/engine/src/cmd/menucmds.rs index 3d84bd58..b6385bed 100644 --- a/crates/engine/src/cmd/menucmds.rs +++ b/crates/engine/src/cmd/menucmds.rs @@ -702,7 +702,7 @@ fn trim_marks(s: &mut Session, p: &Value) -> Result { if rects.is_empty() { return Err(bad(C, "nothing to mark")); } - let parent = s.doc()?.insertion_parent(); + let parent = s.doc()?.target_parent()?; let ids = s.edit("Create Trim Marks", |d, sel| { let mut ids = vec![]; for r in &rects { diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index a0463b71..5c54128c 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -346,6 +346,14 @@ impl DocState { // A sublayer takes new art only while it and the layers around it are shown and unlocked. self.active_layer.filter(|l| self.doc.node(*l).is_some_and(|n| n.is_layer()) && self.doc.is_editable(*l)).or_else(|| self.doc.default_layer()) } + /// [`Self::insertion_parent`] for new art, which a locked or hidden layer never takes: an error + /// when no layer is shown and unlocked, so the art is refused rather than hidden or locked away. + pub fn target_parent(&self) -> Result> { + match self.insertion_parent() { + Some(p) if !self.doc.is_editable(p) => Err(EngineError::Other("the target layer is locked or hidden".into())), + parent => Ok(parent), + } + } /// The highlighted Layers panel rows that still exist (ids are reused after undo, so a /// remembered row must still be in the document). pub fn highlighted_rows(&self) -> Vec { diff --git a/crates/engine/src/tests_layers.rs b/crates/engine/src/tests_layers.rs index 6ad69cf3..20e73d96 100644 --- a/crates/engine/src/tests_layers.rs +++ b/crates/engine/src/tests_layers.rs @@ -78,6 +78,36 @@ fn art_in_a_sublayer_is_selected_as_objects_not_the_sublayer() { assert!(s.doc().unwrap().doc.node(a).is_none()); } +/// #1002: with no layer shown and unlocked, new art is refused (the document unchanged) rather +/// than put on a hidden or locked layer; another layer that takes art still gets it. +#[test] +fn new_art_is_refused_when_no_layer_is_shown_and_unlocked() { + for prop in ["visible", "locked"] { + let mut s = session(); + let layer = s.doc().unwrap().doc.layers[0].id; + let copied = rect(&mut s, 0.0); + run(&mut s, "select.set", json!({"ids": [copied.0]})); + run(&mut s, "edit.copy", json!({})); + run(&mut s, "select.none", json!({})); + run(&mut s, "layer.setProps", json!({"ids": [layer.0], prop: prop == "locked"})); + let before = s.doc().unwrap().doc.clone(); + for (cmd, p) in [ + ("shape.ellipse", json!({"x": 10, "y": 10, "width": 20, "height": 20})), + ("shape.polarGrid", json!({"x": 10, "y": 10, "width": 20, "height": 20})), + ("text.create", json!({"x": 10, "y": 40, "text": "A"})), + ("edit.paste", json!({})), + ] { + let e = s.execute(cmd, &p).unwrap_err(); + assert_eq!(e.to_string(), "the target layer is locked or hidden", "{prop} {cmd}"); + } + assert_eq!(s.doc().unwrap().doc.layers, before.layers, "{prop}: nothing was added"); + let other = id(&run(&mut s, "layer.new", json!({}))); + run(&mut s, "layer.setCurrent", json!({"id": layer.0})); + let n = rect(&mut s, 50.0); + assert_eq!(parent(&s, n), Some(other), "{prop}"); + } +} + #[test] fn clicking_rows_at_any_depth_highlights_them_and_sets_the_current_layer() { let mut s = session(); diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index b64e2878..79021e6c 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -3656,6 +3656,7 @@ theme Appearance Erscheinungsbild @msg the image's pixels don't match its size die Pixel des Bilds passen nicht zu seiner Größe @msg the layer has no objects to clip by die Ebene hat keine Objekte, an denen beschnitten werden könnte @msg the layer or group is locked die Ebene oder Gruppe ist gesperrt +@msg the target layer is locked or hidden die Zielebene ist gesperrt oder ausgeblendet @msg the layer to flatten into is hidden die Ebene, auf die reduziert werden soll, ist ausgeblendet @msg the object already has an opacity mask das Objekt hat bereits eine Deckkraftmaske @msg the object has no geometry das Objekt hat keine Geometrie diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index cf34a1a3..9769b110 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -3759,6 +3759,7 @@ axis Both Ambos @msg the image's pixels don't match its size los píxeles de la imagen no coinciden con su tamaño @msg the layer has no objects to clip by la capa no tiene objetos con los que recortar @msg the layer or group is locked la capa o el grupo está bloqueado +@msg the target layer is locked or hidden la capa de destino está bloqueada u oculta @msg the layer to flatten into is hidden la capa en la que acoplar está oculta @msg the object already has an opacity mask el objeto ya tiene una máscara de opacidad @msg the object has no geometry el objeto no tiene geometría diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 67993dce..74c18533 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -3756,6 +3756,7 @@ axis Both Les deux @msg the image's pixels don't match its size les pixels de l’image ne correspondent pas à sa taille @msg the layer has no objects to clip by le calque n’a aucun objet servant à l’écrêtage @msg the layer or group is locked le calque ou le groupe est verrouillé +@msg the target layer is locked or hidden le calque cible est verrouillé ou masqué @msg the layer to flatten into is hidden le calque de destination de l’aplatissement est masqué @msg the object already has an opacity mask l’objet a déjà un masque d’opacité @msg the object has no geometry l’objet n’a pas de géométrie diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 094f6e51..5d423c7b 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -3759,6 +3759,7 @@ axis Both Entrambi @msg the image's pixels don't match its size i pixel dell'immagine non corrispondono alle sue dimensioni @msg the layer has no objects to clip by il livello non ha oggetti con cui ritagliare @msg the layer or group is locked il livello o il gruppo è bloccato +@msg the target layer is locked or hidden il livello di destinazione è bloccato o nascosto @msg the layer to flatten into is hidden il livello in cui appiattire è nascosto @msg the object already has an opacity mask l'oggetto ha già una maschera di opacità @msg the object has no geometry l'oggetto non ha geometria diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index e109c1ea..ec170582 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -3497,6 +3497,7 @@ theme Appearance 外観 @msg no selection bounds 選択範囲に境界がありません @msg the layer has no objects to clip by レイヤーにクリッピングマスクとして使えるオブジェクトがありません @msg the layer or group is locked レイヤーまたはグループがロックされています +@msg the target layer is locked or hidden ターゲットレイヤーがロックされているか非表示です @msg the layer to flatten into is hidden 統合先のレイヤーが非表示になっています @msg there are no objects to release レイヤーに分配するオブジェクトがありません @msg no library `{id}` ライブラリ `{id}` が見つかりません diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 6226e177..14543a6c 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -3755,6 +3755,7 @@ axis Both Оба @msg the image's pixels don't match its size пиксели изображения не соответствуют его размеру @msg the layer has no objects to clip by в слое нет объектов для обрезки @msg the layer or group is locked слой или группа заблокированы +@msg the target layer is locked or hidden целевой слой заблокирован или скрыт @msg the layer to flatten into is hidden слой для объединения скрыт @msg the object already has an opacity mask у объекта уже есть непрозрачная маска @msg the object has no geometry у объекта нет геометрии diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index bc000368..f46baa1e 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -3742,6 +3742,7 @@ axis Both Обидві @msg the image's pixels don't match its size пікселі зображення не відповідають його розміру @msg the layer has no objects to clip by у шарі немає об’єктів для обрізки @msg the layer or group is locked шар чи група заблоковані +@msg the target layer is locked or hidden цільовий шар заблокований або прихований @msg the layer to flatten into is hidden шар для об’єднання прихований @msg the object already has an opacity mask об’єкт вже має маску непрозорості. @msg the object has no geometry об’єкт не має геометрії