From 03571affbd5b776d94de35314ddd240d7c97d451 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 5 Oct 2026 02:39:20 +0700 Subject: [PATCH 1/4] M3.86: Alt-click the mask thumbnail to view only the mask transparency.viewOpacityMask {on?, id?} shows only the opacity mask on the canvas as greyscale coverage (luminance, clip and invert applied; the rest of the art hidden) and enters mask editing; called again it shows the artwork and keeps editing. The Transparency panel's mask thumbnail runs it on Alt-click and is outlined in the accent colour while the mask is viewed. The state is DocState.mask_view (shown while that mask is edited: DocState::shown_mask) and reaches the renderer as RenderOptions.mask_view. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/maskedit.rs | 68 +++++++++++++++++--- crates/engine/src/lib.rs | 11 ++++ crates/engine/src/tests_maskview.rs | 71 +++++++++++++++++++++ crates/render/src/lib.rs | 31 ++++++--- crates/ui-egui/src/canvas.rs | 2 + crates/ui-egui/src/panels/mod.rs | 2 + crates/ui-egui/src/panels/tests_maskview.rs | 47 ++++++++++++++ crates/ui-egui/src/panels/transparency.rs | 34 ++++++---- docs/mcp.md | 5 ++ 9 files changed, 241 insertions(+), 30 deletions(-) create mode 100644 crates/engine/src/tests_maskview.rs create mode 100644 crates/ui-egui/src/panels/tests_maskview.rs diff --git a/crates/engine/src/cmd/maskedit.rs b/crates/engine/src/cmd/maskedit.rs index 2f679e7c..a94f3838 100644 --- a/crates/engine/src/cmd/maskedit.rs +++ b/crates/engine/src/cmd/maskedit.rs @@ -40,6 +40,15 @@ pub fn specs() -> Vec { has_doc, leave ), + cmd!( + "transparency.viewOpacityMask", + "View Opacity Mask", + ["Window", "Transparency"], + None, + "{on?: bool (default: toggle), id?} show only the opacity mask of `id` (default: the object whose mask is edited, else the first selected object with a mask) on the canvas, as greyscale coverage, and edit it, as Alt-clicking the mask thumbnail does; off shows the artwork again (still editing) → {on, id}", + has_doc, + view_mask + ), ] } @@ -74,12 +83,45 @@ fn enter(s: &mut Session, p: &Value) -> Result { if st.doc.mask_edit.is_some() { return Err(bad(C, "already editing an opacity mask")); } - let id = id_param(p, "id") - .or_else(|| st.selection.subjects().iter().copied().find(|i| st.doc.node(*i).is_some_and(|n| n.mask.is_some()))) - .ok_or_else(|| bad(C, "select an object with an opacity mask"))?; + let id = id_param(p, "id").or_else(|| first_masked(st)).ok_or_else(|| bad(C, "select an object with an opacity mask"))?; + let layer = start(s, id)?; + Ok(json!({ "layer": layer.0 })) +} + +/// The first selected object with an opacity mask. +fn first_masked(st: &crate::DocState) -> Option { + st.selection.subjects().iter().copied().find(|i| st.doc.node(*i).is_some_and(|n| n.mask.is_some())) +} + +/// Enter mask editing for `id` (one undo step) and isolate the editing layer. Returns the layer. +fn start(s: &mut Session, id: NodeId) -> Result { let layer = s.edit("Edit Opacity Mask", |d, sel| begin(d, sel, id))?; show_editing(s, Some(layer))?; - Ok(json!({ "layer": layer.0 })) + Ok(layer) +} + +fn view_mask(s: &mut Session, p: &Value) -> Result { + const C: &str = "transparency.viewOpacityMask"; + let st = s.doc()?; + let editing = st.doc.mask_edit.map(|m| m.object); + if !p.get("on").and_then(Value::as_bool).unwrap_or(st.shown_mask().is_none()) { + let st = s.doc_mut()?; + let id = st.mask_view.take(); + st.revision += 1; + return Ok(json!({ "on": false, "id": id.map(|i| i.0) })); + } + let id = id_param(p, "id").or(editing).or_else(|| first_masked(st)).ok_or_else(|| bad(C, "select an object with an opacity mask"))?; + match editing { + Some(e) if e != id => return Err(bad(C, "already editing another object's opacity mask")), + Some(_) => {} + None => { + start(s, id)?; + } + } + let st = s.doc_mut()?; + st.mask_view = Some(id); + st.revision += 1; + Ok(json!({ "on": true, "id": id.0 })) } /// Enter mask editing for `id` inside an edit: copy its mask art onto a new editing layer and @@ -123,29 +165,35 @@ pub(crate) fn finish(d: &mut Document, sel: &mut Selection) { } } -/// Isolate the editing layer and draw into it (`Some`), or return to the document (`None`). +/// Isolate the editing layer and draw into it (`Some`), or return to the document (`None`, which +/// also ends View Opacity Mask). pub(crate) fn show_editing(s: &mut Session, layer: Option) -> Result<()> { let st = s.doc_mut()?; st.isolation = layer; st.active_layer = layer.or_else(|| st.doc.default_layer()); + if layer.is_none() { + st.mask_view = None; + } st.revision += 1; Ok(()) } /// After undo or redo, isolation follows the restored document into or out of mask editing /// (`was` is the editing layer before, if any), so it never names a layer that is gone (whose id -/// a later object could reuse). +/// a later object could reuse). Out of mask editing, View Opacity Mask ends. pub(crate) fn follow_history(st: &mut crate::DocState, was: Option) { match st.doc.mask_edit { Some(me) => { st.isolation = Some(me.layer); st.active_layer = Some(me.layer); } - None if was.is_some() && st.isolation == was => { - st.isolation = None; - st.active_layer = st.doc.default_layer(); + None => { + st.mask_view = None; + if was.is_some() && st.isolation == was { + st.isolation = None; + st.active_layer = st.doc.default_layer(); + } } - None => {} } } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index b5f60c15..0b04c305 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -101,6 +101,9 @@ pub struct DocState { pub last_selection_cmd: Option<(String, Value)>, /// Process-unique id of this open document (tab indices shift when tabs close). pub uid: u64, + /// View Opacity Mask (Alt-click the mask thumbnail): the masked object whose mask the canvas + /// shows alone, in greyscale, while that mask is edited (see [`DocState::shown_mask`]). + pub mask_view: Option, } static NEXT_DOC_UID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); @@ -122,6 +125,7 @@ impl DocState { last_transform: None, last_selection_cmd: None, uid: NEXT_DOC_UID.fetch_add(1, std::sync::atomic::Ordering::Relaxed), + mask_view: None, } } /// Unsaved changes: the document differs from the saved one (selection changes don't count). @@ -154,6 +158,11 @@ impl DocState { } self.active_layer.filter(|l| self.doc.node(*l).is_some_and(|n| n.is_layer() && !n.locked)).or_else(|| self.doc.default_layer()) } + /// The object whose opacity mask View Opacity Mask shows ([`DocState::mask_view`]): only while + /// its mask is being edited, so leaving editing by any route (undo, deleting the object) ends it. + pub fn shown_mask(&self) -> Option { + self.mask_view.filter(|id| self.doc.mask_edit.is_some_and(|m| m.object == *id)) + } } /// Coordinates beyond this (points) are rejected: ~1,400 m, far past Illustrator's large canvas. @@ -827,6 +836,8 @@ mod tests_linked_stops; #[cfg(test)] mod tests_live; #[cfg(test)] +mod tests_maskview; +#[cfg(test)] mod tests_menucmds; #[cfg(test)] mod tests_opacitymask; diff --git a/crates/engine/src/tests_maskview.rs b/crates/engine/src/tests_maskview.rs new file mode 100644 index 00000000..9f9c58fd --- /dev/null +++ b/crates/engine/src/tests_maskview.rs @@ -0,0 +1,71 @@ +//! View Opacity Mask (M3.86): the canvas shows only the mask, as greyscale coverage, while it is +//! edited; toggling it off shows the artwork again, and leaving editing by any route ends it. + +use serde_json::json; +use vectorcraft_geom::Affine; +use vectorcraft_render::{RenderOptions, Renderer}; + +use super::*; + +fn rect(s: &mut Session, x: f64, w: f64, fill: &str) -> NodeId { + let id = NodeId(s.execute("shape.rectangle", &json!({"x": x, "y": 0, "width": w, "height": 200})).unwrap()["id"].as_u64().unwrap()); + s.execute("paint.setFill", &json!({"color": fill, "ids": [id.0]})).unwrap(); + id +} + +/// The canvas pixel at (x, 100), rendered as the canvas renders the document. +fn canvas_pixel(s: &Session, x: u32) -> [u8; 4] { + let st = s.doc().unwrap(); + let opts = RenderOptions { mask_view: st.shown_mask(), ..Default::default() }; + Renderer::new().render(&st.doc, 300, 300, Affine::IDENTITY, &opts).pixel(x, 100) +} + +#[test] +fn view_opacity_mask_shows_its_luminance_and_hides_the_artwork() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 300, "height": 300})).unwrap(); + let obj = rect(&mut s, 0.0, 200.0, "#ff0000"); + let other = rect(&mut s, 220.0, 60.0, "#0000ff"); + // Green mask art over the left half: luminance 0.7152. + let m = rect(&mut s, 0.0, 100.0, "#00ff00"); + s.execute("select.set", &json!({"ids": [obj.0, m.0]})).unwrap(); + s.execute("transparency.makeOpacityMask", &json!({})).unwrap(); + assert_eq!(canvas_pixel(&s, 250)[..3], [0, 0, 255], "the artwork, before"); + let undo = s.doc().unwrap().history.undo.len(); + // Viewing enters mask editing (one undo step) and shows only the mask. + let r = s.execute("transparency.viewOpacityMask", &json!({})).unwrap(); + assert_eq!(r, json!({"on": true, "id": obj.0})); + let st = s.doc().unwrap(); + assert_eq!((st.doc.mask_edit.map(|e| e.object), st.shown_mask()), (Some(obj), Some(obj))); + assert_eq!(st.history.undo.len(), undo + 1); + assert_eq!(canvas_pixel(&s, 50), [182, 182, 182, 255], "the mask art's luminance"); + assert_eq!(canvas_pixel(&s, 150), [0, 0, 0, 255], "clipping: black outside the mask art (the red object is hidden)"); + assert_eq!(canvas_pixel(&s, 250), [0, 0, 0, 255], "other art is hidden"); + // The view follows the mask's options and edits of its art live. + s.execute("transparency.setOpacityMask", &json!({"invert": true, "clip": false})).unwrap(); + assert_eq!(canvas_pixel(&s, 50), [73, 73, 73, 255]); + assert_eq!(canvas_pixel(&s, 250), [0, 0, 0, 255], "not clipping, inverted: white outside, inverted"); + s.execute("transparency.setOpacityMask", &json!({"invert": false, "clip": true})).unwrap(); + s.execute("object.move", &json!({"dx": 50, "dy": 0})).unwrap(); + assert_eq!(canvas_pixel(&s, 120), [182, 182, 182, 255]); + // Toggling again shows the artwork, still editing the mask. + assert_eq!(s.execute("transparency.viewOpacityMask", &json!({})).unwrap(), json!({"on": false, "id": obj.0})); + let st = s.doc().unwrap(); + assert!(st.doc.mask_edit.is_some() && st.shown_mask().is_none()); + assert_eq!(canvas_pixel(&s, 250)[..3], [0, 0, 255]); + // Leaving editing ends the view; so does undoing back out of editing. + s.execute("transparency.viewOpacityMask", &json!({"on": true})).unwrap(); + s.execute("transparency.stopEditingOpacityMask", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().mask_view, None); + s.execute("transparency.viewOpacityMask", &json!({"id": obj.0})).unwrap(); + assert!(s.doc().unwrap().shown_mask().is_some()); + s.execute("edit.undo", &json!({})).unwrap(); + let st = s.doc().unwrap(); + assert!(st.doc.mask_edit.is_none() && st.mask_view.is_none()); + assert_eq!(canvas_pixel(&s, 250)[..3], [0, 0, 255]); + // Nothing to view: an error, and the view state is untouched. + s.execute("select.set", &json!({"ids": [other.0]})).unwrap(); + assert!(s.execute("transparency.viewOpacityMask", &json!({})).is_err()); + assert!(s.execute("transparency.viewOpacityMask", &json!({"on": true, "id": other.0})).is_err()); + assert!(s.doc().unwrap().mask_view.is_none()); +} diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 07a06156..43783ff9 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -82,6 +82,9 @@ pub struct RenderOptions { /// Pattern editing mode's tile edge and swatch bounds colour (Object → Pattern → Tile Edge /// Color), RGB. pub tile_edge: [u8; 3], + /// View Opacity Mask (Alt-click the mask thumbnail): instead of the artwork, show this object's + /// opacity mask alone as its coverage in greyscale (white = opaque, black = transparent). + pub mask_view: Option, } impl Default for RenderOptions { @@ -97,6 +100,7 @@ impl Default for RenderOptions { trim: false, skip_templates: false, tile_edge: vectorcraft_doc::LAYER_COLORS[0].1, + mask_view: None, } } } @@ -323,6 +327,18 @@ impl Renderer { // Raster filters (drop shadow, glows, blur) are rendered offscreen on this thread when the // main context is multithreaded (see `fx::with_filters`). let threads = self.threads; + self.stats = FrameStats::default(); + let inv = view.inverse(); + let visible = inv.transform_rect_bbox(Rect::new(0.0, 0.0, w as f64, h as f64)); + let px = 1.0 / view.determinant().abs().sqrt().max(1e-12); + let frame = Frame { mt: threads > 0, doc, view, visible, px, opts }; + // View Opacity Mask: the mask's coverage in greyscale instead of the artwork. + if let Some(m) = opts.mask_view.and_then(|id| doc.node(id)).and_then(|n| n.mask.as_deref()) { + self.stamp += 1; + let pixels = self.mask_values(&frame, m, w, h).into_iter().flat_map(|v| [v, v, v, 255]).collect(); + self.stats.micros = now().saturating_sub(start); + return Rendered { width: w as u32, height: h as u32, pixels }; + } let slot = if threads == 0 { self.ctx_st.take() } else { self.ctx.take() }; let mut ctx = match slot { Some(mut c) if c.width() == w && c.height() == h => { @@ -331,12 +347,6 @@ impl Renderer { } _ => RenderContext::new_with(w, h, vello_cpu::RenderSettings { num_threads: threads, ..Default::default() }), }; - self.stats = FrameStats::default(); - let inv = view.inverse(); - let visible = inv.transform_rect_bbox(Rect::new(0.0, 0.0, w as f64, h as f64)); - let px = 1.0 / view.determinant().abs().sqrt().max(1e-12); - let frame = Frame { mt: threads > 0, doc, view, visible, px, opts }; - if let Some(bg) = opts.background { ctx.set_transform(Affine::IDENTITY); ctx.set_paint(peniko::Color::from_rgba8(bg[0], bg[1], bg[2], bg[3])); @@ -548,6 +558,12 @@ impl Renderer { /// Render an opacity mask's art offscreen and turn its luminance into a coverage mask. fn opacity_mask(&mut self, f: &Frame, m: &vectorcraft_doc::OpacityMask, w: u16, h: u16) -> vello_cpu::Mask { + vello_cpu::Mask::from_parts(self.mask_values(f, m, w, h), w, h) + } + + /// The coverage of opacity mask `m` per output pixel (row-major): its art rendered offscreen, + /// as luminance ([`mask_value`]). + fn mask_values(&mut self, f: &Frame, m: &vectorcraft_doc::OpacityMask, w: u16, h: u16) -> Vec { let mut mctx = single_threaded_context(w, h); // Mask art is a picture of its own: it takes no part in a knockout group around the object. let outer = @@ -557,8 +573,7 @@ impl Renderer { mctx.flush(); let mut pm = Pixmap::new(w, h); mctx.render(&mut pm, &mut self.resources); - let data = pm.data().iter().map(|p| mask_value(p.r, p.g, p.b, p.a, m.clip, m.invert)).collect(); - vello_cpu::Mask::from_parts(data, w, h) + pm.data().iter().map(|p| mask_value(p.r, p.g, p.b, p.a, m.clip, m.invert)).collect() } /// Everything [`Self::draw_arc`] does after culling. diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 779f821a..72f39d96 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -145,6 +145,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let painter = ui.painter_at(rect); let Some(st) = app.session.active() else { return }; let doc = st.doc.clone(); + let mask_view = st.shown_mask(); // Pasteboard, artboard shadows and paper. painter.rect_filled(rect, 0.0, t.pasteboard); @@ -217,6 +218,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let [r, g, b, _] = c.to_rgba8(1.0); [r, g, b] }), + mask_view, ..opts }; // Light documents render synchronously (no lag vs overlays); heavy ones go to the worker. diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index 446efe5a..cab19a31 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -439,6 +439,8 @@ mod tests_effectedit; #[cfg(test)] mod tests_freeform; #[cfg(test)] +mod tests_maskview; +#[cfg(test)] mod tests_stroke; #[cfg(test)] mod tests_strokedepth; diff --git a/crates/ui-egui/src/panels/tests_maskview.rs b/crates/ui-egui/src/panels/tests_maskview.rs new file mode 100644 index 00000000..acea8f9f --- /dev/null +++ b/crates/ui-egui/src/panels/tests_maskview.rs @@ -0,0 +1,47 @@ +//! Headless frames of the Transparency panel's mask thumbnail (M3.86): Alt-click views only the +//! mask (and edits it), Alt-click again shows the artwork. + +use serde_json::json; +use vectorcraft_engine::Session; + +use super::*; + +/// One frame of the Transparency panel with `events`, `modifiers` held. +fn frame(ctx: &egui::Context, app: &mut VectorcraftApp, events: Vec, modifiers: egui::Modifiers) { + let events = std::iter::once(egui::Event::ModifiersChanged(modifiers)).chain(events).collect(); + let mut out = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| transparency::show(app, ui)); + out.textures_delta.clear(); +} + +/// Click the mask thumbnail with `modifiers` held. +fn click_mask(ctx: &egui::Context, app: &mut VectorcraftApp, modifiers: egui::Modifiers) { + frame(ctx, app, vec![], egui::Modifiers::NONE); + let at = ctx.read_response(egui::Id::new(transparency::MASK_THUMB)).expect("the mask thumbnail is drawn").rect.center(); + let button = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers }; + frame(ctx, app, vec![egui::Event::PointerMoved(at), button(true), button(false)], modifiers); + frame(ctx, app, vec![], egui::Modifiers::NONE); +} + +#[test] +fn alt_clicking_the_mask_thumbnail_views_only_the_mask() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + let run = |app: &mut VectorcraftApp, id: &str, p: serde_json::Value| app.session.execute(id, &p).unwrap(); + run(&mut app, "file.new", json!({"width": 100, "height": 100})); + let a = run(&mut app, "shape.rectangle", json!({"x": 0, "y": 0, "width": 50, "height": 50}))["id"].as_u64().unwrap(); + let b = run(&mut app, "shape.rectangle", json!({"x": 10, "y": 10, "width": 20, "height": 20}))["id"].clone(); + run(&mut app, "select.set", json!({"ids": [a, b]})); + run(&mut app, "transparency.makeOpacityMask", json!({})); + let ctx = egui::Context::default(); + let viewed = |app: &VectorcraftApp| app.session.active().unwrap().shown_mask().map(|n| n.0); + click_mask(&ctx, &mut app, egui::Modifiers::ALT); + assert_eq!(viewed(&app), Some(a)); + assert!(app.session.active().unwrap().doc.mask_edit.is_some(), "viewing the mask edits it"); + click_mask(&ctx, &mut app, egui::Modifiers::ALT); + assert_eq!(viewed(&app), None); + assert!(app.session.active().unwrap().doc.mask_edit.is_some(), "still editing"); + // A plain click while editing does nothing; Alt views again. + click_mask(&ctx, &mut app, egui::Modifiers::NONE); + assert_eq!(viewed(&app), None); + click_mask(&ctx, &mut app, egui::Modifiers::ALT); + assert_eq!(viewed(&app), Some(a)); +} diff --git a/crates/ui-egui/src/panels/transparency.rs b/crates/ui-egui/src/panels/transparency.rs index 52649285..5d2c3c99 100644 --- a/crates/ui-egui/src/panels/transparency.rs +++ b/crates/ui-egui/src/panels/transparency.rs @@ -1,7 +1,8 @@ //! Transparency panel: blend mode, opacity (field + slider popup), object/mask thumbnails with the -//! opacity-mask controls (make/release, link, clip, invert; Shift-click the mask to disable it), -//! Isolate Blending, the three-state Knockout Group (on → neutral → off) and Opacity & Mask Define -//! Knockout Shape; the menu's Page Isolated Blending and Page Knockout Group. +//! opacity-mask controls (make/release, link, clip, invert; Shift-click the mask to disable it, +//! Alt-click it to view only the mask), Isolate Blending, the three-state Knockout Group (on → +//! neutral → off) and Opacity & Mask Define Knockout Shape; the menu's Page Isolated Blending and +//! Page Knockout Group. use egui::{Sense, Stroke, StrokeKind, Ui, vec2}; use serde_json::json; @@ -14,6 +15,9 @@ use crate::theme::Tokens; use crate::widgets::{self, TransparencyEdit, menu_item}; use crate::{VectorcraftApp, icons}; +/// Interaction id of the mask thumbnail. +pub(super) const MASK_THUMB: &str = "tr-mask-thumb"; + /// The panel's values ([`vectorcraft_engine::Session::transparency_info`]) and its first target /// (the selection, or the object whose mask is being edited), recomputed only when the document /// or the selection changes. @@ -100,25 +104,31 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { if lresp.on_hover_text(if m.linked { "Unlink the mask" } else { "Link the mask" }).clicked() { app.run("transparency.setOpacityMask", json!({"linked": !m.linked})).ok(); } - let (mr, mresp) = ui.allocate_exact_size(vec2(50.0, 50.0), Sense::click()); + let (mr, _) = ui.allocate_exact_size(vec2(50.0, 50.0), Sense::hover()); + let mresp = ui.interact(mr, egui::Id::new(MASK_THUMB), Sense::click()); let bg = if m.clip != m.invert { [0, 0, 0, 255] } else { [255, 255, 255, 255] }; ui.painter().rect_filled(mr, 0.0, egui::Color32::from_rgb(bg[0], bg[1], bg[2])); node_thumb(app, ui, "tr-mask", &m.art, mr.shrink(2.0), Some(bg)); - ui.painter().rect_stroke( - mr, - 0.0, - Stroke::new(mask_w, if editing.is_some() { t.border } else { t.input_border }), - StrokeKind::Inside, - ); + // Viewing the mask alone (Alt-click) highlights its thumbnail. + let viewing = app.session.active().is_some_and(|d| d.shown_mask().is_some()); + let border = match (viewing, editing.is_some()) { + (true, _) => Stroke::new(2.0, t.accent), + (false, true) => Stroke::new(mask_w, t.border), + (false, false) => Stroke::new(mask_w, t.input_border), + }; + ui.painter().rect_stroke(mr, 0.0, border, StrokeKind::Inside); if m.disabled { let red = Stroke::new(2.0, egui::Color32::from_rgb(220, 40, 40)); ui.painter().line_segment([mr.left_top(), mr.right_bottom()], red); ui.painter().line_segment([mr.right_top(), mr.left_bottom()], red); } - let shift = ui.input(|i| i.modifiers.shift); - if mresp.on_hover_text("Click to edit the mask; Shift-click to disable or enable it").clicked() { + let (shift, alt) = ui.input(|i| (i.modifiers.shift, i.modifiers.alt)); + let tip = "Click to edit the mask; Alt-click to view only the mask; Shift-click to disable or enable it"; + if mresp.on_hover_text(tip).clicked() { if shift { app.run(if m.disabled { "transparency.enableOpacityMask" } else { "transparency.disableOpacityMask" }, json!({})).ok(); + } else if alt { + app.run("transparency.viewOpacityMask", json!({"id": id.0})).ok(); } else if editing.is_none() { app.run("transparency.editOpacityMask", json!({"id": id.0})).ok(); } diff --git a/docs/mcp.md b/docs/mcp.md index ff7a13fd..7247d212 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -225,6 +225,11 @@ Groups are not isolated unless Isolate Blending is on: a blend mode inside a gro opacity mask, knockout or a clip reaches the art below the group, on screen and in PDF (a knockout group's elements then composite against the art below it). SVG has no non-isolated groups, so there such groups stay isolated. +`transparency.viewOpacityMask {on?, id?}` is Alt-clicking the mask thumbnail: the canvas shows only the mask of `id` +(default: the mask being edited, else the first selected masked object) as greyscale coverage (white = opaque) and the +mask is edited; called again it shows the artwork, still editing. It is view state of the open document: the canvas +(and `ui.screenshot`) shows it, exports never do, and it ends when mask editing ends. + ## Clipping masks `object.clippingMask.make` clips the selected objects by the topmost one, which may be a path, a compound path or a From 1603729ec3643eb8a4d291e705e09b606ae9bb36 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 5 Oct 2026 02:46:21 +0700 Subject: [PATCH 2/4] M3.87: SVG opacity masks keep their Clip and Invert options Exported masks carry data-vectorcraft-mask="noclip invert" (the options that differ from clipping, not inverted). Import restores the flags and the original mask art, dropping the backdrop rectangle and inverting filter group that draw the options in SVG; a single mask object comes back as itself, not wrapped in a group. Masks from other apps import as before. Co-Authored-By: Claude Opus 5.5 --- crates/svg/src/export.rs | 8 +++- crates/svg/src/import.rs | 51 ++++++++++++++++++++---- crates/svg/src/lib.rs | 5 ++- crates/svg/tests/mask_options.rs | 67 ++++++++++++++++++++++++++++++++ 4 files changed, 122 insertions(+), 9 deletions(-) create mode 100644 crates/svg/tests/mask_options.rs diff --git a/crates/svg/src/export.rs b/crates/svg/src/export.rs index fcce2df5..282a9946 100644 --- a/crates/svg/src/export.rs +++ b/crates/svg/src/export.rs @@ -137,6 +137,9 @@ const BIG: &str = "x=\"-100000\" y=\"-100000\" width=\"200000\" height=\"200000\ /// Attributes of every ``: user-space units, and luminance taken from the sRGB values (as the /// canvas and PDF take it) rather than from linearised ones. const MASK: &str = "maskUnits=\"userSpaceOnUse\" color-interpolation=\"sRGB\""; +/// Attribute of an exported opacity mask listing its options that differ from the defaults +/// (clipping, not inverted): `noclip`, `invert`. +pub(crate) const MASK_FLAGS: &str = "data-vectorcraft-mask"; pub(crate) fn blend_css(b: BlendMode) -> &'static str { match b { @@ -662,7 +665,10 @@ impl Writer<'_> { self.def(1, ""); fid }); - self.def(1, &format!("")); + // The mask's options, so import restores them (and its art without backdrop and filter). + let flags: Vec<&str> = [(!m.clip).then_some("noclip"), m.invert.then_some("invert")].into_iter().flatten().collect(); + let data = if flags.is_empty() { String::new() } else { format!(" {MASK_FLAGS}=\"{}\"", flags.join(" ")) }; + self.def(1, &format!("")); if let Some(fid) = &inv { self.def(1, &format!("")); } diff --git a/crates/svg/src/import.rs b/crates/svg/src/import.rs index dfeea2c6..3b9ee12e 100644 --- a/crates/svg/src/import.rs +++ b/crates/svg/src/import.rs @@ -22,7 +22,7 @@ pub(crate) fn import(svg: &str) -> Result<(Document, Vec), SvgError> { // absolute units is that physical size (usvg resolves it to 96 px per inch, i.e. 4/3 px per pt). let (kx, ky) = physical_scale(svg); let doc = Document::new(size.width() as f64 * kx, size.height() as f64 * ky); - let mut im = Importer { doc, warnings: Vec::new() }; + let mut im = Importer { doc, warnings: Vec::new(), mask_flags: mask_flags(svg) }; // usvg wraps everything in an id-less group carrying the viewBox transform when needed. let mut top = tree.root(); @@ -85,6 +85,25 @@ fn physical_scale(svg: &str) -> (f64, f64) { struct Importer { doc: Document, warnings: Vec, + /// Opacity-mask options our export wrote, by mask id (see [`mask_flags`]). + mask_flags: HashMap, +} + +/// The options [`crate::export::MASK_FLAGS`] records on exported `` elements, by mask id: +/// (clip, invert). +fn mask_flags(svg: &str) -> HashMap { + let attr = crate::export::MASK_FLAGS; + if !svg.contains(attr) { + return HashMap::new(); + } + let Ok(xml) = roxmltree::Document::parse_with_options(svg, roxmltree::ParsingOptions { allow_dtd: true, ..Default::default() }) else { + return HashMap::new(); + }; + let flags = |n: roxmltree::Node| { + let has = |f: &str| n.attribute(attr).is_some_and(|v| v.split_whitespace().any(|x| x == f)); + Some((n.attribute("id")?.to_string(), (!has("noclip"), has("invert")))) + }; + xml.descendants().filter(|n| n.tag_name().name() == "mask" && n.has_attribute(attr)).filter_map(flags).collect() } fn aff(t: usvg::Transform) -> Affine { @@ -223,7 +242,9 @@ impl Importer { Some(n) } - /// `` → opacity mask (luminance; alpha masks are approximated by their luminance). + /// `` → opacity mask (luminance; alpha masks are approximated by their luminance). A + /// mask our export wrote gets its options back ([`mask_flags`]) and its art without what + /// stands for them: the inverting filter group and the backdrop rectangle. fn opacity_mask(&mut self, m: &usvg::Mask, ts: Affine, label: &str) -> Option> { if m.kind() == usvg::MaskType::Alpha { self.warn(format!("alpha mask on {label} imported as a luminance opacity mask")); @@ -231,12 +252,28 @@ impl Importer { if m.mask().is_some() { self.warn(format!("nested mask on {label} ignored")); } - let children = self.children(m.root(), ts); - if children.is_empty() { - return None; + let (clip, invert) = self.mask_flags.get(m.id()).copied().unwrap_or((true, false)); + let (mut nodes, mut ts) = (m.root().children(), ts); + if invert + && let [usvg::Node::Group(g)] = nodes + && !g.filters().is_empty() + { + (nodes, ts) = (g.children(), ts * aff(g.transform())); } - let art = self.named("", NodeKind::Group { children, clip: false }); - Some(Box::new(vectorcraft_doc::OpacityMask::new(art, true))) + if (!clip || invert) + && let [usvg::Node::Path(_), rest @ ..] = nodes + { + nodes = rest; + } + let mut children: Vec> = nodes.iter().filter_map(|c| self.node(c, ts)).map(Arc::new).collect(); + let art = match children.len() { + 0 => return None, + 1 => Arc::unwrap_or_clone(children.pop()?), + _ => self.named("", NodeKind::Group { children, clip: false }), + }; + let mut mask = vectorcraft_doc::OpacityMask::new(art, clip); + mask.invert = invert; + Some(Box::new(mask)) } fn clip_node(&mut self, cp: &usvg::ClipPath, ts: Affine) -> Option { diff --git a/crates/svg/src/lib.rs b/crates/svg/src/lib.rs index 15aba18c..6aaa7623 100644 --- a/crates/svg/src/lib.rs +++ b/crates/svg/src/lib.rs @@ -27,7 +27,10 @@ //! * One CSS pixel (user unit) is one point, as on export; a root `width`/`height` in absolute units //! (`in`, `cm`, `mm`, `pt`, `pc`) keeps its physical size (72 pt per inch), so a 210 mm SVG opens on //! a 210 mm artboard. -//! * Masks and filters are ignored (reported as warnings); nested clip paths use the outer clip only. +//! * `` imports as a luminance opacity mask; a mask we exported keeps its options and art +//! (its `data-vectorcraft-mask="noclip invert"` lists the options that differ from clipping and +//! not inverted). Filters are ignored (reported as warnings); nested clip paths use the outer +//! clip only. //! * usvg only keeps `` when fonts are loaded; we don't load a font database (too expensive and //! unavailable on wasm), so `` elements are read directly from the XML as live point-type //! [`TextObject`]s (type on a path for a ``) and placed on top of their layer. diff --git a/crates/svg/tests/mask_options.rs b/crates/svg/tests/mask_options.rs new file mode 100644 index 00000000..fd310d11 --- /dev/null +++ b/crates/svg/tests/mask_options.rs @@ -0,0 +1,67 @@ +//! Opacity-mask options survive SVG (M3.87): exported masks list the options that differ from the +//! defaults in `data-vectorcraft-mask`, and import restores them and the original mask art (without +//! the backdrop rectangle and inverting filter that draw the options in SVG). + +use vectorcraft_color::{Color, Paint}; +use vectorcraft_doc::{Appearance, Document, Node, OpacityMask}; +use vectorcraft_geom::{Rect, shapes}; +use vectorcraft_svg::{ExportOptions, export, import_with_report}; + +/// A red square masked by white art over its left half. +fn masked(clip: bool, invert: bool) -> Document { + let mut d = Document::new(200.0, 200.0); + let red = Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0); + let white = Appearance::basic(Paint::solid(Color::WHITE), Paint::None, 0.0); + let mut n = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(10.0, 10.0, 90.0, 90.0)), red); + let art = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(10.0, 10.0, 50.0, 90.0)), white); + let mut m = OpacityMask::new(art, clip); + m.invert = invert; + n.mask = Some(Box::new(m)); + let l = d.layers[0].id; + d.insert(Some(l), 0, n).unwrap(); + d +} + +fn masks(d: &Document) -> Vec { + let mut v = vec![]; + d.walk(|n| v.extend(n.mask.as_deref().cloned())); + v +} + +#[test] +fn an_inverted_unclipped_mask_round_trips() { + for (clip, invert, attr) in + [(false, true, Some("noclip invert")), (true, true, Some("invert")), (false, false, Some("noclip")), (true, false, None)] + { + let s = export(&masked(clip, invert), &ExportOptions::default()); + match attr { + Some(a) => assert!(s.contains(&format!("data-vectorcraft-mask=\"{a}\"")), "{s}"), + None => assert!(!s.contains("data-vectorcraft-mask"), "{s}"), + } + let (back, warnings) = import_with_report(&s).unwrap(); + assert!(warnings.is_empty(), "{warnings:?}"); + let [m] = &masks(&back)[..] else { panic!("one masked object: {s}") }; + assert_eq!((m.clip, m.invert), (clip, invert), "{s}"); + // The original art: one white rectangle, no backdrop and no filter group around it. + assert!(m.art.path_data().is_some(), "{:?}", m.art.kind); + let b = m.art.geometric_bounds().unwrap(); + assert!((b.x0 - 10.0).abs() < 1e-6 && (b.x1 - 50.0).abs() < 1e-6 && (b.y0 - 10.0).abs() < 1e-6 && (b.y1 - 90.0).abs() < 1e-6, "{b:?}"); + assert_eq!(m.art.appearance.fill_paint(), Paint::solid(Color::WHITE)); + // Exporting the import again writes the same mask. + let again = export(&back, &ExportOptions::default()); + let mask_def = |s: &str| s.lines().skip_while(|l| !l.contains("")).count(); + assert_eq!(mask_def(&again), mask_def(&s), "{again}\n{s}"); + } +} + +#[test] +fn foreign_masks_keep_their_art() { + // Without the attribute a white backdrop is part of the mask art (another app's file). + let svg = r#" + + "#; + let (d, _) = import_with_report(svg).unwrap(); + let [m] = &masks(&d)[..] else { panic!("one mask") }; + assert!(m.clip && !m.invert); + assert_eq!(m.art.children().map(|c| c.len()), Some(2)); +} From 799b507564bfe2ee79efe54112f475b9d1a59795 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 5 Oct 2026 02:54:14 +0700 Subject: [PATCH 3/4] M3.88: Show Transparency Grid is per-document engine view state view.transparencyGrid {on?} -> {on} is now an engine command (DocState. transparency_grid; not undoable, doesn't dirty the document), so agents can turn the grid on headless and each open document keeps its own setting. The menu item, its Show/Hide label, the shortcut and the Properties panel button use the same id; the canvas draws the grid for the active document only. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/views.rs | 18 ++++++++- crates/engine/src/lib.rs | 5 +++ crates/engine/src/tests_transparencygrid.rs | 28 +++++++++++++ crates/ui-egui/src/canvas.rs | 2 +- crates/ui-egui/src/lib.rs | 2 + crates/ui-egui/src/menus.rs | 6 +-- crates/ui-egui/src/panels/properties.rs | 3 +- crates/ui-egui/src/state.rs | 2 - crates/ui-egui/src/tests_transparencygrid.rs | 41 ++++++++++++++++++++ docs/mcp.md | 4 ++ 10 files changed, 103 insertions(+), 8 deletions(-) create mode 100644 crates/engine/src/tests_transparencygrid.rs create mode 100644 crates/ui-egui/src/tests_transparencygrid.rs diff --git a/crates/engine/src/cmd/views.rs b/crates/engine/src/cmd/views.rs index 6a678bb2..65c55f93 100644 --- a/crates/engine/src/cmd/views.rs +++ b/crates/engine/src/cmd/views.rs @@ -1,4 +1,5 @@ -//! View → New View… / Edit Views…: saved views stored in the document. +//! View → New View… / Edit Views…: saved views stored in the document; per-document view +//! toggles (Show Transparency Grid). use serde_json::{Value, json}; use vectorcraft_doc::SavedView; @@ -30,9 +31,24 @@ pub fn specs() -> Vec { edit_views ), cmd!(query "view.saved.list", "Saved Views", [], None, "{} → {views: [{name, center, zoom, rotation}]}", has_doc, list_views), + cmd!( + query "view.transparencyGrid", + "Show Transparency Grid", + ["View"], + Some("Cmd+Shift+D"), + "{on?: bool (default: toggle)} show the transparency grid behind the active document's artboards (each document has its own setting) → {on}", + has_doc, + transparency_grid + ), ] } +fn transparency_grid(s: &mut Session, p: &Value) -> Result { + let st = s.doc_mut()?; + st.transparency_grid = p.get("on").and_then(Value::as_bool).unwrap_or(!st.transparency_grid); + Ok(json!({ "on": st.transparency_grid })) +} + fn list_views(s: &mut Session, _: &Value) -> Result { Ok(json!({ "views": s.doc()?.doc.views })) } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 0b04c305..8637b9dc 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -104,6 +104,8 @@ pub struct DocState { /// View Opacity Mask (Alt-click the mask thumbnail): the masked object whose mask the canvas /// shows alone, in greyscale, while that mask is edited (see [`DocState::shown_mask`]). pub mask_view: Option, + /// View → Show Transparency Grid, per document (view state: not saved, not undoable). + pub transparency_grid: bool, } static NEXT_DOC_UID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); @@ -126,6 +128,7 @@ impl DocState { last_selection_cmd: None, uid: NEXT_DOC_UID.fetch_add(1, std::sync::atomic::Ordering::Relaxed), mask_view: None, + transparency_grid: false, } } /// Unsaved changes: the document differs from the saved one (selection changes don't count). @@ -884,6 +887,8 @@ mod tests_tileedge; #[cfg(test)] mod tests_tints; #[cfg(test)] +mod tests_transparencygrid; +#[cfg(test)] mod tests_widthprofiles; #[cfg(test)] mod tests_xform; diff --git a/crates/engine/src/tests_transparencygrid.rs b/crates/engine/src/tests_transparencygrid.rs new file mode 100644 index 00000000..80e10491 --- /dev/null +++ b/crates/engine/src/tests_transparencygrid.rs @@ -0,0 +1,28 @@ +//! View → Show Transparency Grid is engine view state of each open document (M3.88). + +use serde_json::json; + +use super::*; + +#[test] +fn transparency_grid_is_per_document_view_state() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let grid = |s: &Session| s.doc().unwrap().transparency_grid; + assert!(!grid(&s)); + assert_eq!(s.execute("view.transparencyGrid", &json!({})).unwrap(), json!({"on": true})); + assert!(grid(&s)); + // Not an edit: nothing to undo, the document stays clean, the journal skips it. + let st = s.doc().unwrap(); + assert!(st.history.undo.is_empty() && !st.is_dirty()); + // Another document has its own setting. + s.execute("file.new", &json!({})).unwrap(); + assert!(!grid(&s)); + assert_eq!(s.execute("view.transparencyGrid", &json!({"on": false})).unwrap(), json!({"on": false})); + s.execute("document.activate", &json!({"index": 0})).unwrap(); + assert!(grid(&s)); + assert_eq!(s.execute("view.transparencyGrid", &json!({})).unwrap(), json!({"on": false})); + assert_eq!(s.execute("view.transparencyGrid", &json!({"on": true})).unwrap(), json!({"on": true})); + s.execute("document.activate", &json!({"index": 1})).unwrap(); + assert!(!grid(&s)); +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 72f39d96..4c257f80 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -157,7 +157,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let shift = |d: f32| q.iter().map(|p| *p + vec2(d, d)).collect::>(); painter.add(Shape::convex_polygon(shift(2.0), Color32::from_black_alpha(26), Stroke::NONE)); painter.add(Shape::convex_polygon(shift(1.0), Color32::from_black_alpha(52), Stroke::NONE)); - if app.ui.view.transparency_grid && xf.rot == 0.0 { + if st.transparency_grid && xf.rot == 0.0 { checker(&painter, r); } else { painter.add(Shape::convex_polygon(q, Color32::WHITE, Stroke::NONE)); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index d0d46a11..631b43ae 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -38,6 +38,8 @@ mod tests_labels; mod tests_overprint; #[cfg(test)] mod tests_paintchips; +#[cfg(test)] +mod tests_transparencygrid; use std::sync::mpsc::{Receiver, Sender}; use std::sync::{Arc, Mutex}; diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 3cea890b..12967fdb 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -125,7 +125,6 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ ("view.artboards", "Hide Artboards", "Cmd+Shift+H", "{}"), ("view.rulers", "Show Rulers", "Cmd+R", "{}"), ("view.boundingBox", "Hide Bounding Box", "Cmd+Shift+B", "{}"), - ("view.transparencyGrid", "Show Transparency Grid", "Cmd+Shift+D", "{}"), ("view.guides", "Hide Guides", "Cmd+;", "{}"), ("view.smartGuides", "Smart Guides", "Cmd+U", "{}"), ("view.grid", "Show Grid", "Cmd+'", "{}"), @@ -470,7 +469,6 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option flag(&mut app.ui.view.artboards), "view.rulers" => flag(&mut app.ui.view.rulers), "view.boundingBox" => flag(&mut app.ui.view.bounding_box), - "view.transparencyGrid" => flag(&mut app.ui.view.transparency_grid), "view.guides" => flag(&mut app.ui.view.guides), "view.smartGuides" => flag(&mut app.ui.view.smart_guides), "view.grid" => flag(&mut app.ui.view.grid), @@ -756,7 +754,9 @@ pub fn dynamic_label(app: &VectorcraftApp, id: &str, label: &str) -> String { "view.artboards" => if v.artboards { "Hide Artboards" } else { "Show Artboards" }.into(), "view.rulers" => if v.rulers { "Hide Rulers" } else { "Show Rulers" }.into(), "view.boundingBox" => if v.bounding_box { "Hide Bounding Box" } else { "Show Bounding Box" }.into(), - "view.transparencyGrid" => if v.transparency_grid { "Hide Transparency Grid" } else { "Show Transparency Grid" }.into(), + "view.transparencyGrid" => { + if app.session.active().is_some_and(|d| d.transparency_grid) { "Hide Transparency Grid" } else { "Show Transparency Grid" }.into() + } "view.guides" => if v.guides { "Hide Guides" } else { "Show Guides" }.into(), "view.grid" => if v.grid { "Hide Grid" } else { "Show Grid" }.into(), "view.guides.lock" => if app.session.guides_locked() { "Unlock Guides" } else { "Lock Guides" }.into(), diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 5d9b9959..f881815f 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -154,7 +154,8 @@ fn document_sections(app: &mut VectorcraftApp, ui: &mut Ui) { if widgets::icon_button(ui, "grid-3x3", "Show Grid (⌘')", v.grid, 26.0).clicked() { app.run("view.grid", json!({})).ok(); } - if widgets::icon_button(ui, "square-dashed", "Show Transparency Grid", v.transparency_grid, 26.0).clicked() { + let transparency_grid = app.session.active().is_some_and(|d| d.transparency_grid); + if widgets::icon_button(ui, "square-dashed", "Show Transparency Grid", transparency_grid, 26.0).clicked() { app.run("view.transparencyGrid", json!({})).ok(); } }); diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index a16b4111..b76b9d52 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -115,7 +115,6 @@ pub struct ViewFlags { pub snap_to_grid: bool, pub snap_to_point: bool, pub artboards: bool, - pub transparency_grid: bool, pub edges: bool, /// View → Trim View: hide everything outside the artboards. pub trim_view: bool, @@ -144,7 +143,6 @@ impl Default for ViewFlags { snap_to_grid: false, snap_to_point: true, artboards: true, - transparency_grid: false, edges: true, trim_view: false, corner_widgets: true, diff --git a/crates/ui-egui/src/tests_transparencygrid.rs b/crates/ui-egui/src/tests_transparencygrid.rs new file mode 100644 index 00000000..2307429e --- /dev/null +++ b/crates/ui-egui/src/tests_transparencygrid.rs @@ -0,0 +1,41 @@ +//! The canvas draws the transparency grid behind the artboards of the documents that show it +//! (M3.88), and the View menu item follows the active document. + +use serde_json::json; +use vectorcraft_engine::Session; + +use crate::{VectorcraftApp, canvas, menus}; + +/// Whether one headless canvas frame paints the grid's grey squares. +fn draws_grid(app: &mut VectorcraftApp) -> bool { + fn grey(s: &egui::Shape) -> bool { + match s { + egui::Shape::Rect(r) => r.fill == egui::Color32::from_gray(204), + egui::Shape::Vec(v) => v.iter().any(grey), + _ => false, + } + } + let ctx = egui::Context::default(); + let raw = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(800.0, 600.0))), ..Default::default() }; + let mut out = ctx.run_ui(raw, |ui| canvas::show(app, ui)); + out.textures_delta.clear(); + out.shapes.iter().any(|c| grey(&c.shape)) +} + +#[test] +fn the_canvas_shows_the_grid_only_for_documents_that_turn_it_on() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); + app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); + assert!(!draws_grid(&mut app)); + assert_eq!(menus::dynamic_label(&app, "view.transparencyGrid", ""), "Show Transparency Grid"); + // The menu's id reaches the engine command. + assert_eq!(app.run("view.transparencyGrid", json!({})).unwrap(), json!({"on": true})); + assert!(draws_grid(&mut app)); + assert_eq!(menus::dynamic_label(&app, "view.transparencyGrid", ""), "Hide Transparency Grid"); + app.run("document.activate", json!({"index": 0})).unwrap(); + assert!(!draws_grid(&mut app), "the other document keeps its own setting"); + assert_eq!(menus::dynamic_label(&app, "view.transparencyGrid", ""), "Show Transparency Grid"); + app.run("document.activate", json!({"index": 1})).unwrap(); + assert!(draws_grid(&mut app)); +} diff --git a/docs/mcp.md b/docs/mcp.md index 7247d212..dc9b9830 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -230,6 +230,10 @@ then composite against the art below it). SVG has no non-isolated groups, so the mask is edited; called again it shows the artwork, still editing. It is view state of the open document: the canvas (and `ui.screenshot`) shows it, exports never do, and it ends when mask editing ends. +`view.transparencyGrid {on?}` (View → Show Transparency Grid; default: toggle) is view state of each open document, +like the mask view: the grid shows behind the artboards of the documents that turn it on, on the canvas and in +`ui.screenshot`, never in exports. It returns `{on}`, isn't undoable and doesn't mark the document changed. + ## Clipping masks `object.clippingMask.make` clips the selected objects by the topmost one, which may be a path, a compound path or a From 3b2f46137af956ccfc39705315b72ed04050bb9b Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 5 Oct 2026 03:06:54 +0700 Subject: [PATCH 4/4] M3.89: a clipping path's fill paints behind the clipped art, its stroke over it Paint given to a clipping path (paint commands with its id) now shows: its fill paints inside the clip behind the clipped art and its stroke over the art, unclipped, on the canvas and in SVG and PDF. Node::clip_paint splits the clipping path into its fills and its strokes (type: its characters' too) for all three outputs; the renderer caches the parts per clipping path. Clip group visual bounds take in the stroke. Make Clipping Mask still leaves the clipping path unpainted, as the reference app does; Ungroup no longer wipes a painted clipping path's appearance (Release already kept it). Co-Authored-By: Claude Opus 5.5 --- crates/doc/src/node.rs | 56 ++++++++++++++- crates/engine/src/cmd/object.rs | 17 ++--- crates/engine/src/lib.rs | 2 + crates/engine/src/tests_clippaint.rs | 102 +++++++++++++++++++++++++++ crates/pdf/src/export.rs | 10 ++- crates/render/src/lib.rs | 37 +++++++++- crates/render/src/tests_isolation.rs | 9 ++- crates/svg/src/export.rs | 23 +++++- docs/mcp.md | 5 +- 9 files changed, 245 insertions(+), 16 deletions(-) create mode 100644 crates/engine/src/tests_clippaint.rs diff --git a/crates/doc/src/node.rs b/crates/doc/src/node.rs index 4761eecc..38c0b05c 100644 --- a/crates/doc/src/node.rs +++ b/crates/doc/src/node.rs @@ -478,7 +478,13 @@ impl Node { pub fn visual_bounds(&self) -> Option { match &self.kind { NodeKind::Group { children, clip: true } | NodeKind::Layer { children, clip: true, .. } => { - children.first().and_then(|c| c.geometric_bounds()) + // A clipping path that paints (see `clip_paint`) strokes over the clip edge. + let clip = children.first()?; + if matches!(clip.kind, NodeKind::Path { .. } | NodeKind::Compound { .. } | NodeKind::Text(_)) { + clip.visual_bounds() + } else { + clip.geometric_bounds() + } } NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } => { // The container's own strokes paint around its members. @@ -705,6 +711,54 @@ impl Node { _ => Some((unite(&shapes), FillRule::NonZero)), } } + + /// What this object paints as the clipping path of a clip group, shared by the renderer and + /// the SVG and PDF writers: its fills, painted behind the clipped art, and its strokes, painted + /// over it (unclipped). Each part is this object keeping only those appearance items (type: + /// also only its characters' fills or strokes); `None` where it paints nothing, as right after + /// Make Clipping Mask. Only paths, compound paths and type paint as clipping paths. + pub fn clip_paint(&self) -> ClipPaint { + use crate::appearance::AppearanceItem; + let runs = match &self.kind { + NodeKind::Path { guide: false, .. } | NodeKind::Compound { .. } => None, + NodeKind::Text(t) => Some(&t.runs), + _ => return ClipPaint::default(), + }; + let paints = |fill: bool| { + let item = |i: &AppearanceItem| match i { + AppearanceItem::Fill(f) => fill && f.visible && !f.paint.is_none(), + AppearanceItem::Stroke(s) => !fill && s.visible && !s.paint.is_none() && s.width > 0.0, + }; + let run = |r: &crate::text::TextRun| if fill { !r.style.fill.is_none() } else { !r.style.stroke.is_none() && r.style.stroke_width > 0.0 }; + self.appearance.items.iter().any(item) || runs.is_some_and(|rs| rs.iter().any(run)) + }; + let part = |fill: bool| { + paints(fill).then(|| { + let mut n = self.clone(); + for i in (0..n.appearance.items.len()).rev() { + if matches!(n.appearance.items[i], AppearanceItem::Fill(_)) != fill { + n.appearance.remove_item(i); + } + } + if let NodeKind::Text(t) = &mut n.kind { + for r in &mut t.runs { + *(if fill { &mut r.style.stroke } else { &mut r.style.fill }) = vectorcraft_color::Paint::None; + } + } + n + }) + }; + ClipPaint { fill: part(true), stroke: part(false) } + } +} + +/// What a clipping path paints ([`Node::clip_paint`]). +#[derive(Clone, Debug, Default)] +pub struct ClipPaint { + /// The clipping path with only its fills: painted behind the clipped art (inside the clip). + pub fill: Option, + /// The clipping path with only its strokes: painted over the clipped art, not clipped. + pub stroke: Option, } /// Knockout Group state of a container (the Transparency panel's three-state checkbox). In a diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index 64403fca..fc45480b 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -93,7 +93,7 @@ pub fn specs() -> Vec { "Make", ["Object", "Clipping Mask"], Some("Cmd+7"), - "{} the topmost selected object (a path, compound path or text, which loses its paint) clips the others: compound holes, even-odd fills and glyph outlines clip as drawn → {id} of the clip group", + "{} the topmost selected object (a path, compound path or text, which loses its paint) clips the others: compound holes, even-odd fills and glyph outlines clip as drawn. Paint given to the clipping path later (paint commands with its id) shows: its fill behind the clipped art, its stroke over it → {id} of the clip group", has_multi, clip_make ), @@ -102,7 +102,7 @@ pub fn specs() -> Vec { "Release", ["Object", "Clipping Mask"], Some("Cmd+Alt+7"), - "{} the selected clip groups become plain groups; their clipping path (path, compound path or text) stays, unpainted", + "{} the selected clip groups become plain groups; their clipping path (path, compound path or text) stays, with the paint it has (none unless painted after Make)", has_selection, clip_release ), @@ -383,11 +383,12 @@ fn ungroup(s: &mut Session, _: &Value) -> Result { d.remove(*id)?; for (k, c) in children.into_iter().enumerate() { let mut c = (*c).clone(); - if clip && k == 0 { - if let NodeKind::Path { clipping, .. } = &mut c.kind { - *clipping = false; - } - c.appearance = Appearance::default(); + // The clipping path keeps its paint (none, unless painted after Make). + if clip + && k == 0 + && let NodeKind::Path { clipping, .. } = &mut c.kind + { + *clipping = false; } c.opacity *= opacity; new_sel.push(c.id); @@ -552,7 +553,7 @@ pub(super) fn make_clipping_path(d: &mut Document, top: NodeId) -> Result<()> { Ok(()) } -/// Stop clip group or clipped layer `id` clipping; its clipping path stays, unpainted. +/// Stop clip group or clipped layer `id` clipping; its clipping path stays, with its paint. pub(super) fn release_clip(d: &mut Document, id: NodeId) { let Some(n) = d.node_mut(id).filter(|n| n.clips()) else { return }; n.set_clips(false); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 8637b9dc..efef9dd6 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -795,6 +795,8 @@ mod tests_charstroke; #[cfg(test)] mod tests_clip; #[cfg(test)] +mod tests_clippaint; +#[cfg(test)] mod tests_cmdsplit; #[cfg(test)] mod tests_colorguide; diff --git a/crates/engine/src/tests_clippaint.rs b/crates/engine/src/tests_clippaint.rs new file mode 100644 index 00000000..673466d3 --- /dev/null +++ b/crates/engine/src/tests_clippaint.rs @@ -0,0 +1,102 @@ +//! Fill and stroke on a clipping path (M3.89): Make Clipping Mask leaves the clipping path +//! unpainted, paint given to it later shows (its fill behind the clipped art, its stroke over it +//! and unclipped) on the canvas, in SVG and in PDF, and Release and Ungroup keep it. + +use serde_json::json; +use vectorcraft_geom::Affine; +use vectorcraft_render::{RenderOptions, Renderer}; + +use super::*; + +fn rect(s: &mut Session, x: f64, w: f64, fill: &str) -> NodeId { + let id = NodeId(s.execute("shape.rectangle", &json!({"x": x, "y": 50, "width": w, "height": 100})).unwrap()["id"].as_u64().unwrap()); + s.execute("paint.setFill", &json!({"color": fill, "ids": [id.0]})).unwrap(); + id +} + +/// Red art (40..100) clipped by a rectangle (50..150) painted blue with a 10 pt green stroke. +fn painted_clip() -> (Session, NodeId, NodeId) { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); + rect(&mut s, 40.0, 60.0, "#ff0000"); + let clip = rect(&mut s, 50.0, 100.0, "#ffffff"); + s.execute("select.all", &json!({})).unwrap(); + let group = NodeId(s.execute("object.clippingMask.make", &json!({})).unwrap()["id"].as_u64().unwrap()); + let n = s.doc().unwrap().doc.node(clip).unwrap().clone(); + assert!(n.clip_paint().fill.is_none() && n.clip_paint().stroke.is_none(), "Make leaves the clipping path unpainted"); + s.execute("paint.setFill", &json!({"color": "#0000ff", "ids": [clip.0]})).unwrap(); + s.execute("paint.setStroke", &json!({"color": "#00ff00", "ids": [clip.0]})).unwrap(); + s.execute("stroke.set", &json!({"weight": 10, "ids": [clip.0]})).unwrap(); + (s, group, clip) +} + +/// RGB at (x, 100) of `doc` rendered on white. +fn rgb(doc: &Document, x: u32) -> [u8; 3] { + let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }; + let p = Renderer::new().render(doc, 200, 200, Affine::IDENTITY, &opts).pixel(x, 100); + [p[0], p[1], p[2]] +} + +/// Behind the clipped art, its fill; over it (also outside the clip), its stroke. +fn assert_painted(doc: &Document, what: &str) { + assert_eq!(rgb(doc, 70), [255, 0, 0], "{what}: the clipped art over the fill"); + assert_eq!(rgb(doc, 120), [0, 0, 255], "{what}: the fill inside the clip"); + assert_eq!(rgb(doc, 45), [0, 255, 0], "{what}: the stroke's outer half, unclipped (the art below it is clipped away)"); + assert_eq!(rgb(doc, 52), [0, 255, 0], "{what}: the stroke over the clipped art"); + assert_eq!(rgb(doc, 147), [0, 255, 0], "{what}: the stroke over the fill"); + assert_eq!(rgb(doc, 30), [255, 255, 255], "{what}: outside the clip and its stroke"); +} + +#[test] +fn a_clipping_paths_fill_and_stroke_show_in_render_svg_and_pdf() { + let (s, group, clip) = painted_clip(); + let doc = s.doc().unwrap().doc.clone(); + assert_painted(&doc, "canvas"); + // The clip group's bounds take in the stroke. + let b = doc.node(group).unwrap().visual_bounds().unwrap(); + assert!((b.x0 - 45.0).abs() < 1e-6 && (b.x1 - 155.0).abs() < 1e-6, "{b:?}"); + // SVG: the fill inside the clip, the stroke after it, one element keeping the clipping path's id. + let svg = vectorcraft_svg::export(&doc, &Default::default()); + assert_painted(&vectorcraft_svg::import(&svg).unwrap(), "SVG"); + // PDF. + let pdf = vectorcraft_pdf::export(&doc, &Default::default()).unwrap(); + assert_painted(&vectorcraft_pdf::import(&pdf).unwrap(), "PDF"); + // Unpainted clipping paths write nothing extra. + let mut bare = s; + bare.execute("paint.setFill", &json!({"none": true, "ids": [clip.0]})).unwrap(); + bare.execute("paint.setStroke", &json!({"none": true, "ids": [clip.0]})).unwrap(); + let doc = bare.doc().unwrap().doc.clone(); + assert_eq!(rgb(&doc, 120), [255, 255, 255]); + assert_eq!(rgb(&doc, 45), [255, 255, 255]); + let svg = vectorcraft_svg::export(&doc, &Default::default()); + assert_eq!(svg.matches(" { match &n.kind { NodeKind::Layer { children, clip: false, .. } | NodeKind::Group { children, clip: false } => self.children(s, children, page), NodeKind::Group { children, clip: true } | NodeKind::Layer { children, clip: true, .. } => { - // The region every output clips to; nothing to clip by hides the clipped art. + // The region every output clips to; nothing to clip by hides the clipped art. The + // clipping path's fill paints behind the clipped art and its stroke over it, unclipped. if let Some((clip, rest)) = children.split_first() && let Some((p, r)) = vectorcraft_effects::clip_outline(clip).and_then(|(bp, r)| to_path(&bp).map(|p| (p, r))) { + let paint = clip.clip_paint(); s.push_clip_path(&p, &rule(r)); + if let Some(fill) = &paint.fill { + self.node(s, fill, page, false); + } self.children(s, rest, page); s.pop(); + if let Some(stroke) = &paint.stroke { + self.node(s, stroke, page, false); + } } } NodeKind::Path { path, rule, guide, .. } => { diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 43783ff9..3520eea5 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -212,6 +212,8 @@ struct GeomEntry { /// A clipping path (kept alive so its address can't be reused) and the region it clips to. type ClipEntry = (Arc, Option>); +/// A clipping path (kept alive) and what it paints: its fills and its strokes ([`Node::clip_paint`]). +type ClipPaintEntry = (Arc, Option>, Option>); /// Reusable renderer (keeps the render context, decoded images and glyph caches between frames). pub struct Renderer { @@ -260,6 +262,8 @@ pub struct Renderer { /// Clip paths pushed on the current context (path, rule, transform): starting the context /// again from a picture of it pushes them again (see [`Self::group`]). clip_paths: Vec<(BezPath, FillRule, Affine)>, + /// What clipping paths paint, per clipping path (see [`Self::clip_paint_of`]). + clip_paints: PtrMap, } #[derive(Clone, Copy, Debug, Default)] @@ -314,6 +318,7 @@ impl Renderer { backdrop: None, blends: PtrMap::default(), clip_paths: vec![], + clip_paints: PtrMap::default(), } } @@ -503,6 +508,24 @@ impl Renderer { region } + /// Cached fills and strokes of clipping path `a` ([`Node::clip_paint`]): the parts keep their + /// allocations between frames, so their own geometry stays cached. + fn clip_paint_of(&mut self, a: &Arc) -> (Option>, Option>) { + let key = Arc::as_ptr(a) as usize; + if let Some((node, fill, stroke)) = self.clip_paints.get(&key) + && Arc::ptr_eq(node, a) + { + return (fill.clone(), stroke.clone()); + } + let paint = a.clip_paint(); + let (fill, stroke) = (paint.fill.map(Arc::new), paint.stroke.map(Arc::new)); + if self.clip_paints.len() > 1024 { + self.clip_paints.clear(); + } + self.clip_paints.insert(key, (a.clone(), fill.clone(), stroke.clone())); + (fill, stroke) + } + /// Cached BezPath of a path node. fn path_of(&mut self, a: &Arc) -> Option> { let key = Arc::as_ptr(a) as usize; @@ -672,14 +695,24 @@ impl Renderer { } NodeKind::Group { children, clip: true } | NodeKind::Layer { children, clip: true, .. } => { // Nothing to clip by hides the clipped art (as in the SVG and PDF output). A clip - // group doesn't isolate: blending inside it reaches the art below. + // group doesn't isolate: blending inside it reaches the art below. The clipping + // path's fill paints behind the clipped art and its stroke over it, unclipped. if let Some((clip, rest)) = children.split_first() && let Some(region) = self.clip_of(clip) { + let (fill, stroke) = self.clip_paint_of(clip); let blends = self.blends_through(n); let bounds = if blends { Some(region.0.bounding_box()) } else { None }; let comp = Composite { clip: Some((®ion.0, region.1)), blends, bounds, ..Default::default() }; - self.group(ctx, f, comp, &mut |r, c, fr| r.draw_children(c, fr, rest, knockout)); + self.group(ctx, f, comp, &mut |r, c, fr| { + if let Some(fill) = &fill { + r.draw_arc(c, fr, fill); + } + r.draw_children(c, fr, rest, knockout) + }); + if let Some(stroke) = &stroke { + self.draw_arc(ctx, f, stroke); + } } } NodeKind::Path { path, rule, guide, .. } => { diff --git a/crates/render/src/tests_isolation.rs b/crates/render/src/tests_isolation.rs index 5494eefb..1db73de1 100644 --- a/crates/render/src/tests_isolation.rs +++ b/crates/render/src/tests_isolation.rs @@ -15,6 +15,11 @@ fn rect(d: &mut Document, r: Rect, c: [f32; 3]) -> Node { Node::path(d.alloc_id(), shapes::rectangle(r), Appearance::basic(Paint::solid(Color::rgb(c[0], c[1], c[2])), Paint::None, 0.0)) } +/// An unpainted clipping path, as Make Clipping Mask leaves it (a painted one paints too). +fn clip_path(d: &mut Document, r: Rect) -> Node { + Node::path(d.alloc_id(), shapes::rectangle(r), Appearance::basic(Paint::None, Paint::None, 0.0)) +} + /// A Multiply rectangle at 20..80 (the group's content). fn multiply(d: &mut Document) -> Node { let mut n = rect(d, Rect::new(20.0, 20.0, 80.0, 80.0), S); @@ -98,7 +103,7 @@ fn a_group_blend_mode_applies_again_to_the_non_isolated_result() { #[test] fn a_clip_group_does_not_isolate_its_children() { let d = doc(|d| { - let clip = rect(d, Rect::new(30.0, 30.0, 70.0, 70.0), [0.0; 3]); + let clip = clip_path(d, Rect::new(30.0, 30.0, 70.0, 70.0)); let m = multiply(d); Node::new(d.alloc_id(), NodeKind::Group { children: vec![Arc::new(clip), Arc::new(m)], clip: true }) }); @@ -112,7 +117,7 @@ fn a_non_isolated_group_inside_a_clip_group_stays_clipped() { // The half-opaque group copies its backdrop while the clip is in force: the clip still applies // to it and to what follows. let d = doc(|d| { - let clip = rect(d, Rect::new(30.0, 30.0, 70.0, 70.0), [0.0; 3]); + let clip = clip_path(d, Rect::new(30.0, 30.0, 70.0, 70.0)); let m = multiply(d); let g = group(d, vec![m], 0.5, Blend::Normal, false); let after = rect(d, Rect::new(0.0, 60.0, 100.0, 100.0), S); diff --git a/crates/svg/src/export.rs b/crates/svg/src/export.rs index 282a9946..38cf6320 100644 --- a/crates/svg/src/export.rs +++ b/crates/svg/src/export.rs @@ -806,11 +806,32 @@ impl Writer<'_> { self.def(1, ""); let id = self.id_attr(n); let a = self.attrs(&self.group_props(n)); - self.line(&format!("")); + // The clipping path's fill paints behind the clipped art and its stroke over it, + // outside the clip (the group then wraps both). + let paint = clip.clip_paint(); + if paint.stroke.is_some() { + self.line(&format!("")); + self.depth += 1; + self.line(&format!("")); + } else { + self.line(&format!("")); + } self.depth += 1; + if let Some(fill) = &paint.fill { + self.node(fill); + } self.group_children(n, rest); self.depth -= 1; self.line(""); + if let Some(stroke) = &paint.stroke { + // One element keeps the clipping path's id. + let anonymous = self.anonymous; + self.anonymous |= paint.fill.is_some(); + self.node(stroke); + self.anonymous = anonymous; + self.depth -= 1; + self.line(""); + } } NodeKind::Path { guide: true, .. } => {} NodeKind::Path { path, rule, .. } => { diff --git a/docs/mcp.md b/docs/mcp.md index dc9b9830..566531aa 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -239,7 +239,10 @@ like the mask view: the grid shows behind the artboards of the documents that tu `object.clippingMask.make` clips the selected objects by the topmost one, which may be a path, a compound path or a text object (it loses its paint). Compound holes, even-odd fills, glyph outlines and the union of a group's members clip alike on screen, in raster export and in SVG and PDF. `object.clippingMask.release` turns the clip group into a -plain group and keeps the clipping path, unpainted. +plain group and keeps the clipping path with whatever paint it has. + +A clipping path can be painted after Make (`paint.setFill`, `paint.setStroke`, `stroke.set` with its `ids`): its fill +paints behind the clipped art and its stroke over it, not clipped, on screen and in SVG and PDF. ```json {"name":"run_command","arguments":{"command":"select.set","params":{"ids":[12,15]}}}