From 2f26d4012b4cc00e5729370f9827a10eaa7c7eec Mon Sep 17 00:00:00 2001 From: FredApps Date: Sun, 1 Nov 2026 00:01:23 +0100 Subject: [PATCH 1/5] ci: i18n report without a rebuild; PR print runs stop saving caches - xtask ci: the i18n report selects the whole workspace as the test stage did. With `-p vectorcraft-ui-egui` alone cargo resolved features differently and recompiled for about 3.5 minutes. `cargo xtask i18n-report` on its own still builds only that crate. - print.yml: only main saves the desktop-print cache. Each PR saved its own 1.3 GB cache that only that PR can read (6.3 GB across five PRs), pushing the repository past its 10 GB limit and evicting caches main's gate restores. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/print.yml | 4 ++++ xtask/src/main.rs | 11 ++++++++++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/.github/workflows/print.yml b/.github/workflows/print.yml index 400a81944..b805725f2 100644 --- a/.github/workflows/print.yml +++ b/.github/workflows/print.yml @@ -26,9 +26,13 @@ jobs: with: toolchain: stable components: clippy + # Pull requests restore but never save: a PR's cache (about 1.3 GB) is visible only to that + # PR, and with the repository at its 10 GB limit each one evicted caches main's gate uses. + # A manual run on main seeds the cache every PR can restore. - uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3 with: key: desktop-print + save-if: ${{ github.ref == 'refs/heads/main' }} - run: cargo clippy --locked -p vectorcraft --all-targets -- -D warnings - run: cargo test --locked -p vectorcraft - name: Check pending submission and queue refusal feedback diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 0516321ab..efb80daa7 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -294,7 +294,7 @@ fn cmd_ci() -> Result<(), String> { ("assets", Box::new(|| assets::run(&root()))), ("brands", Box::new(|| brands::run(&root()))), ("cursors", Box::new(|| cursors::run(&root()))), - ("i18n-report", Box::new(cmd_i18n_report)), + ("i18n-report", Box::new(cmd_ci_i18n_report)), ("layers", Box::new(cmd_layers)), ("wasm", Box::new(cmd_wasm)), ]; @@ -349,3 +349,12 @@ fn cmd_i18n_report() -> Result<(), String> { c.args(["test", "-p", "vectorcraft-ui-egui", "--lib", "--", "i18n::tests::untranslated_report", "--exact", "--nocapture"]); run(c, "cargo test -p vectorcraft-ui-egui --lib -- i18n::tests::untranslated_report --exact --nocapture") } + +/// [`cmd_i18n_report`] in `ci`, selecting the whole workspace as the test stage did: with +/// `-p vectorcraft-ui-egui` alone cargo resolves features differently and recompiled for about +/// 3.5 minutes. Every other lib test binary runs with no test matching the filter. +fn cmd_ci_i18n_report() -> Result<(), String> { + let mut c = cargo(); + c.args(["test", "--workspace", "--lib", "--quiet", "--", "i18n::tests::untranslated_report", "--exact", "--nocapture"]); + run(c, "cargo test --workspace --lib --quiet -- i18n::tests::untranslated_report --exact --nocapture") +} From 123e770e41ec6b3d4cc061d829cd4a8e2e71afe2 Mon Sep 17 00:00:00 2001 From: FredApps Date: Sun, 1 Nov 2026 00:17:40 +0100 Subject: [PATCH 2/5] ci(xtask): i18n report selects the test run's targets too Co-Authored-By: Claude Opus 5.5 The test run builds --lib --bins --tests; a binary target's dependencies (the desktop app's eframe features) could still resolve differently under --lib alone. Match the selection exactly, and drop --quiet so a rebuild would show in the log. --- xtask/src/main.rs | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/xtask/src/main.rs b/xtask/src/main.rs index efb80daa7..de6ff5815 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -350,11 +350,12 @@ fn cmd_i18n_report() -> Result<(), String> { run(c, "cargo test -p vectorcraft-ui-egui --lib -- i18n::tests::untranslated_report --exact --nocapture") } -/// [`cmd_i18n_report`] in `ci`, selecting the whole workspace as the test stage did: with -/// `-p vectorcraft-ui-egui` alone cargo resolves features differently and recompiled for about -/// 3.5 minutes. Every other lib test binary runs with no test matching the filter. +/// [`cmd_i18n_report`] in `ci`, selecting the packages and targets the test stage did so it +/// reuses that build: with `-p vectorcraft-ui-egui --lib` alone cargo resolves features +/// differently and recompiled for about 3.5 minutes. Every other test binary runs with no test +/// matching the filter. fn cmd_ci_i18n_report() -> Result<(), String> { let mut c = cargo(); - c.args(["test", "--workspace", "--lib", "--quiet", "--", "i18n::tests::untranslated_report", "--exact", "--nocapture"]); - run(c, "cargo test --workspace --lib --quiet -- i18n::tests::untranslated_report --exact --nocapture") + c.args(["test", "--workspace", "--lib", "--bins", "--tests", "--", "i18n::tests::untranslated_report", "--exact", "--nocapture"]); + run(c, "cargo test --workspace --lib --bins --tests -- i18n::tests::untranslated_report --exact --nocapture") } From b0294122e80e8a90e4325fad09a42d6bf28b0633 Mon Sep 17 00:00:00 2001 From: FredApps Date: Sun, 1 Nov 2026 02:20:34 +0100 Subject: [PATCH 3/5] ci: leave print.yml unchanged print.yml still runs its own jobs, and CLAUDE.md (CI workflows) keeps additions out of such files. The cache churn it causes goes to craft-common-actions instead. This PR is now the xtask change only. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/print.yml | 4 ---- 1 file changed, 4 deletions(-) diff --git a/.github/workflows/print.yml b/.github/workflows/print.yml index b805725f2..400a81944 100644 --- a/.github/workflows/print.yml +++ b/.github/workflows/print.yml @@ -26,13 +26,9 @@ jobs: with: toolchain: stable components: clippy - # Pull requests restore but never save: a PR's cache (about 1.3 GB) is visible only to that - # PR, and with the repository at its 10 GB limit each one evicted caches main's gate uses. - # A manual run on main seeds the cache every PR can restore. - uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3 with: key: desktop-print - save-if: ${{ github.ref == 'refs/heads/main' }} - run: cargo clippy --locked -p vectorcraft --all-targets -- -D warnings - run: cargo test --locked -p vectorcraft - name: Check pending submission and queue refusal feedback From d0834e68349eb5e8a825e2b306d772ac672d4b7c Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 31 Oct 2026 11:01:44 -0700 Subject: [PATCH 4/5] Transparency: a layer on top gives its topmost object as the opacity mask Make Opacity Mask took the topmost target as the mask. When that target was a layer (a targeted layer above the art, or `ids` ending in a layer id), the whole layer was removed from the layer tree and became the mask art. A layer on top now stands for its visible, unlocked art, as a layer below the mask already does: its topmost object is the mask and the rest of its art is masked with the art below. A top layer with no such art is refused and the document is left unchanged. --- crates/engine/src/cmd/opacitymask.rs | 74 +++++++++++++++++++++++----- 1 file changed, 63 insertions(+), 11 deletions(-) diff --git a/crates/engine/src/cmd/opacitymask.rs b/crates/engine/src/cmd/opacitymask.rs index 8d6530860..d16eab497 100644 --- a/crates/engine/src/cmd/opacitymask.rs +++ b/crates/engine/src/cmd/opacitymask.rs @@ -24,7 +24,7 @@ pub fn specs() -> Vec { "Make Opacity Mask", ["Window", "Transparency"], None, - "{ids?, clip?, invert?} the topmost of the selected objects (or of `ids`) becomes the mask of the others (grouped if several, a layer among several by its art). One object gets an empty mask and enters mask editing: draw the mask, then transparency.stopEditingOpacityMask; one target (an object or a layer) is refused in isolation mode and in pattern editing mode → {id, editing}", + "{ids?, clip?, invert?} the topmost of the selected objects (or of `ids`) becomes the mask of the others (grouped if several; a layer among several stands for its art, so the topmost object of a layer on top is the mask). One object gets an empty mask and enters mask editing: draw the mask, then transparency.stopEditingOpacityMask; one target (an object or a layer) is refused in isolation mode and in pattern editing mode → {id, editing}", can_make, make ), @@ -185,7 +185,7 @@ fn make(s: &mut Session, p: &Value) -> Result { if d.mask_edit.is_some() { return Err(bad(C, "stop editing the opacity mask first")); } - let ids = match ids_param(p, "ids") { + let mut ids = match ids_param(p, "ids") { Some(ids) => { if let Some(id) = ids.iter().find(|id| d.node(**id).is_none()) { return Err(EngineError::NoNode(*id)); @@ -194,6 +194,25 @@ fn make(s: &mut Session, p: &Value) -> Result { } None => super::appearance::subject_roots(s)?, }; + // A layer among several targets stands for its visible, unlocked art. A hidden, locked or + // template layer gives none. + let art_of = |id: NodeId| match d.node(id) { + Some(n) if n.is_layer() && (!n.visible || n.locked || n.is_template()) => vec![], + Some(n) if n.is_layer() => n.layer_art(true), + _ => vec![id], + }; + // A layer on top: the topmost object of its art is the mask, and the rest of it is masked. + if ids.len() > 1 + && let Some(&top) = ids.last() + && d.node(top).is_some_and(Node::is_layer) + { + let top_art = super::edit::outermost(d, d.paint_order(art_of(top))); + if top_art.is_empty() { + return Err(bad(C, "select the art and, on top of it, the mask object")); + } + ids.pop(); + ids.extend(top_art); + } let Some((&top, below)) = ids.split_last() else { return Err(bad(C, "select the art and, on top of it, the mask object")) }; // One target gets an empty mask and enters mask editing, which isolation mode and pattern // editing mode do not allow. @@ -206,17 +225,10 @@ fn make(s: &mut Session, p: &Value) -> Result { let invert = bool_or(p, "invert", s.menu.new_masks_inverted); // Several objects: the top one is the mask. One: an empty mask, drawn in mask editing. let (art, mask_id) = if below.is_empty() { (&ids[..], None) } else { (below, Some(top)) }; - // Several objects go into a new group, and a layer can't: a layer among them stands for its - // visible, unlocked art, which the group takes from it. A hidden, locked or template layer - // gives none. + // Several objects go into a new group, and a layer can't: the group takes a layer's art. let contents; let art = if art.len() > 1 && art.iter().any(|id| d.node(*id).is_some_and(Node::is_layer)) { - let objects = art.iter().flat_map(|id| match d.node(*id) { - Some(n) if n.is_layer() && (!n.visible || n.locked || n.is_template()) => vec![], - Some(n) if n.is_layer() => n.layer_art(true), - _ => vec![*id], - }); - contents = super::edit::outermost(d, d.paint_order(objects)); + contents = super::edit::outermost(d, d.paint_order(art.iter().flat_map(|id| art_of(*id)))); if contents.is_empty() { return Err(bad(C, "the layers hold no art to mask")); } @@ -549,6 +561,46 @@ mod tests { assert_eq!((d.parent_of(NodeId(b)), d.parent_of(NodeId(b2))), (Some(NodeId(l2)), Some(NodeId(l2)))); } + #[test] + fn make_takes_the_mask_from_the_art_of_a_layer_on_top() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); + let rect = |s: &mut Session, x: u64| { + s.execute("shape.rectangle", &json!({"x": x, "y": 10, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap() + }; + let l1 = s.doc().unwrap().doc.layers[0].id.0; + let (a, a2) = (rect(&mut s, 0), rect(&mut s, 30)); + let l2 = s.execute("layer.new", &json!({})).unwrap()["id"].as_u64().unwrap(); + let (b, b2) = (rect(&mut s, 60), rect(&mut s, 90)); + let l3 = s.execute("layer.new", &json!({})).unwrap()["id"].as_u64().unwrap(); + let top = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 150, "height": 50})).unwrap()["id"].as_u64().unwrap(); + let before = s.doc().unwrap().doc.layers.clone(); + let layer_ids: Vec = before.iter().map(|l| l.id.0).collect(); + // The topmost object of the top layer is the mask, and the rest of that layer's art is + // masked with the art below it. No layer leaves the layer tree. + for (ids, mask, grouped) in [(vec![a, l3], top, None), (vec![l1, l2], b2, Some(vec![a, a2, b])), (vec![a, l2], b2, Some(vec![a, b]))] { + let r = s.execute("transparency.makeOpacityMask", &json!({ "ids": ids })).unwrap(); + let id = NodeId(r["id"].as_u64().unwrap()); + assert_eq!(r["editing"], false, "{ids:?}"); + let d = &s.doc().unwrap().doc; + assert_eq!(d.layers.iter().map(|l| l.id.0).collect::>(), layer_ids, "{ids:?}: every layer stays"); + assert!(d.node(NodeId(mask)).is_none(), "{ids:?}: the mask art left the layer tree"); + let n = d.node(id).unwrap(); + assert_eq!(n.extras().mask.as_ref().unwrap().art.id, NodeId(mask), "{ids:?}"); + match grouped { + Some(g) => assert_eq!(n.children().unwrap().iter().map(|c| c.id.0).collect::>(), g, "{ids:?}"), + None => assert_eq!(id, NodeId(a), "{ids:?}"), + } + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc.layers, before, "{ids:?}: undo"); + } + // A top layer without art gives no mask, and nothing changes. + s.execute("layer.setProps", &json!({"id": l3, "visible": false})).unwrap(); + let hidden = s.doc().unwrap().doc.layers.clone(); + assert!(s.execute("transparency.makeOpacityMask", &json!({ "ids": [a, l3] })).is_err()); + assert_eq!(s.doc().unwrap().doc.layers, hidden); + } + #[test] fn release_restores_mask_art_and_undo_restores_mask() { let (mut s, a, b) = session_with_two(); From 482c011233b2d1a6fbc1ca25620a249bed7972ee Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 31 Oct 2026 18:11:28 -0700 Subject: [PATCH 5/5] Transparency: a layer's art on a locked artboard or in a hidden or locked parent is not taken for an opacity mask Make Opacity Mask expands a layer among its targets to the art that can be edited: an object in a sublayer of a hidden or locked layer, or on a locked artboard, is neither the mask nor masked, so it stays where it is. --- crates/engine/src/cmd/opacitymask.rs | 39 +++++++++++++++++++++++++--- 1 file changed, 36 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/opacitymask.rs b/crates/engine/src/cmd/opacitymask.rs index d16eab497..f3e25b19e 100644 --- a/crates/engine/src/cmd/opacitymask.rs +++ b/crates/engine/src/cmd/opacitymask.rs @@ -194,11 +194,13 @@ fn make(s: &mut Session, p: &Value) -> Result { } None => super::appearance::subject_roots(s)?, }; - // A layer among several targets stands for its visible, unlocked art. A hidden, locked or - // template layer gives none. + // A layer among several targets stands for its art that can be edited: visible and unlocked, + // with every layer it is in, and not on a locked artboard. A hidden, locked or template layer + // gives none. + let editable = d.editable(); let art_of = |id: NodeId| match d.node(id) { Some(n) if n.is_layer() && (!n.visible || n.locked || n.is_template()) => vec![], - Some(n) if n.is_layer() => n.layer_art(true), + Some(n) if n.is_layer() => n.layer_art(true).into_iter().filter(|c| editable(*c)).collect(), _ => vec![id], }; // A layer on top: the topmost object of its art is the mask, and the rest of it is masked. @@ -236,6 +238,7 @@ fn make(s: &mut Session, p: &Value) -> Result { } else { art }; + drop(editable); let (id, layer) = s.edit("Make Opacity Mask", |d, sel| { let mask_art = match mask_id { Some(m) => (*d.remove(m)?).clone(), @@ -601,6 +604,36 @@ mod tests { assert_eq!(s.doc().unwrap().doc.layers, hidden); } + #[test] + fn make_leaves_out_layer_art_that_cannot_be_edited() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); + let rect = |s: &mut Session, x: u64, y: u64| { + s.execute("shape.rectangle", &json!({"x": x, "y": y, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap() + }; + let a = rect(&mut s, 300, 10); + // A visible sublayer of a hidden layer holds no art that can be edited. + let l2 = s.execute("layer.new", &json!({})).unwrap()["id"].as_u64().unwrap(); + let sub = s.execute("layer.newSublayer", &json!({"parent": l2})).unwrap()["id"].as_u64().unwrap(); + let inner = rect(&mut s, 300, 40); + assert_eq!(s.doc().unwrap().doc.parent_of(NodeId(inner)), Some(NodeId(sub))); + s.execute("layer.setProps", &json!({"id": l2, "visible": false})).unwrap(); + let before = s.doc().unwrap().doc.layers.clone(); + assert!(s.execute("transparency.makeOpacityMask", &json!({ "ids": [a, sub] })).is_err()); + assert_eq!(s.doc().unwrap().doc.layers, before, "the hidden layer's art stays"); + // Art on a locked artboard acts as locked: the topmost object off it is the mask. + let l3 = s.execute("layer.new", &json!({"top": true})).unwrap()["id"].as_u64().unwrap(); + let off = rect(&mut s, 300, 70); + let on = rect(&mut s, 10, 10); + s.execute("artboard.setProps", &json!({"index": 0, "locked": true})).unwrap(); + let r = s.execute("transparency.makeOpacityMask", &json!({ "ids": [a, l3] })).unwrap(); + let d = &s.doc().unwrap().doc; + let n = d.node(NodeId(r["id"].as_u64().unwrap())).unwrap(); + assert_eq!(n.id, NodeId(a)); + assert_eq!(n.extras().mask.as_ref().unwrap().art.id, NodeId(off)); + assert_eq!(d.parent_of(NodeId(on)), Some(NodeId(l3)), "the object on the locked artboard stays"); + } + #[test] fn release_restores_mask_art_and_undo_restores_mask() { let (mut s, a, b) = session_with_two();