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 об’єкт не має геометрії