diff --git a/ROADMAP.md b/ROADMAP.md index 4424e5b6..eb01b354 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -59,7 +59,7 @@ _Last updated: 2026-10-02._ | M0 | Skeleton + vertical slice | ✅ done | — | | M1 | Selection, transform, layers, MCP | ✅ mostly done (rotated persistent bbox pending) | 4–6 | | M2 | Drawing tools + smart guides | ✅ mostly done (Flare, Reshape landed; Shaper, Pen modifier nuances) | 15–20 | -| M3 | Paint & appearance (swatches, color, gradient, stroke, appearance, transparency, styles) | 🟡 panels done; opacity masks done (make/release, clip, invert, disable, link; render + SVG `` in/out + PDF soft mask); mask-editing mode, freeform gradients pending | 18–27 | +| M3 | Paint & appearance (swatches, color, gradient, stroke, appearance, transparency, styles) | 🟡 panels done; opacity masks done (make/release, clip, invert, disable, link; render + SVG `` in/out + PDF soft mask); mask-editing mode (click the mask thumbnail; live update) done; freeform gradients pending | 18–27 | | M4 | Files (native, SVG, PDF, raster, Export for Screens, clipboard interop) | 🟡 Export for Screens (PNG/JPG/WebP/SVG/PDF × scales) done; headless CLI/MCP export every format; system clipboard: copy puts SVG markup on it, paste takes SVG from other apps (Ctrl/Cmd+C/X/V now also work off macOS); live effects now survive SVG/PDF/clipboard export (geometry baked, SVG filters for shadows/glows/blur/feather); PDF raster effects, PNG/PDF clipboard flavours, EPS/DXF pending | 52–73 | | M5 | Performance | 🟡 background render + caches + MT done; `vectorcraft-cli bench` and `vectorcraft-cli perf` (budget suite); file format v2 opens 3× faster (50k paths: 722 → 244 ms); raster effects (glows, shadows, blur, feather) no longer force the whole frame single-threaded (filtered offscreen per effect, verified equal to the single-threaded reference); effect-heavy demos need a clean-machine benchmark; dirty-region rendering, GPU backend spike pending | 15–25 | | M6 | Path operations (Pathfinder, Shape Builder, offset…) | ✅ mostly done (Shape Builder edge erase, large-offset bug open) | 3–6 | @@ -92,7 +92,7 @@ are counted bottom-up. | Path operations, Pathfinder, Shape Builder, Live Paint | 5 | 85% | Live Paint gap options, Shape Builder edge cases | 3–6 | | Colour, swatches, gradients, patterns, mesh, recolor | 7 | 75% | freeform gradients, swatch libraries (original), Tile Edge Color | 10–15 | | Strokes, brushes, width profiles | 5 | 65% | brush options depth, brush libraries (generated in code) | 15–25 | -| Appearance, transparency, graphic styles, masks | 5 | 80% | opacity-mask editing mode, Flatten Transparency, style libraries | 8–12 | +| Appearance, transparency, graphic styles, masks | 5 | 85% | Flatten Transparency, style libraries, mask view (Alt-click) | 6–10 | | Live vector effects | 5 | 85% | Outline Object, Pathfinder Hard/Soft Mix and Trap, SVG Filters | 6–10 | | Raster effects (Effect Gallery, Document Raster Effects Settings) | 4 | 15% | ~55 filters (Artistic, Brush Strokes, Distort, Pixelate, Sketch, Texture…), resolution setting, raster effects in PDF | 30–45 | | 3D and Materials | 4 | 0% | Extrude & Bevel, Revolve, Inflate, Rotate, lighting, materials (software renderer) | 50–80 | diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 3dba194f..12d91930 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -213,6 +213,13 @@ pub struct Artboard { pub show_cross_hairs: bool, } +/// Opacity-mask editing mode: `object`'s mask art lives on the temporary `layer` while editing. +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +pub struct MaskEdit { + pub object: NodeId, + pub layer: NodeId, +} + /// A saved view (View → New View…): zoom, centre and rotation, listed at the bottom of the View menu. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct SavedView { @@ -313,6 +320,9 @@ pub struct Document { /// Pattern editing mode, while active. #[serde(default, skip_serializing_if = "Option::is_none")] pub pattern_edit: Option, + /// Opacity-mask editing mode, while active. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub mask_edit: Option, next_id: u64, /// Foreign data preserved on round-trip. #[serde(default, skip_serializing_if = "serde_json::Map::is_empty")] @@ -357,6 +367,7 @@ impl Document { images: BTreeMap::new(), patterns: vec![], pattern_edit: None, + mask_edit: None, next_id: 1, unknown: Default::default(), }; diff --git a/crates/engine/src/cmd/maskedit.rs b/crates/engine/src/cmd/maskedit.rs new file mode 100644 index 00000000..7963c685 --- /dev/null +++ b/crates/engine/src/cmd/maskedit.rs @@ -0,0 +1,161 @@ +//! Opacity-mask editing mode (click the mask thumbnail in the Transparency panel). +//! +//! The mask art moves onto a temporary isolated layer where every tool and command can edit it. +//! After each edit [`sync`] writes the layer's art back into the object's mask, so the masked +//! object updates live; the renderer doesn't paint the editing layer (the mask is seen through its +//! effect, as in Illustrator). Leaving the mode removes the layer. + +use std::sync::Arc; + +use serde_json::{Value, json}; +use vectorcraft_doc::{Document, MaskEdit, Node, NodeId}; + +use super::*; + +/// Name of the temporary mask-editing layer. +pub const MASK_EDIT_LAYER: &str = "Opacity Mask Editing"; + +pub fn specs() -> Vec { + vec![ + cmd!( + "transparency.editOpacityMask", + "Edit Opacity Mask", + ["Window", "Transparency"], + None, + "{id?} edit the selected object's mask art in place (the mask thumbnail in the Transparency panel) → {layer}", + has_doc, + enter + ), + cmd!( + "transparency.stopEditingOpacityMask", + "Stop Editing Opacity Mask", + ["Window", "Transparency"], + None, + "{} leave mask editing (the object thumbnail) → {id}", + has_doc, + leave + ), + ] +} + +fn enter(s: &mut Session, p: &Value) -> Result { + const C: &str = "transparency.editOpacityMask"; + if s.doc()?.doc.mask_edit.is_some() { + return Err(bad(C, "already editing an opacity mask")); + } + let st = s.doc()?; + let id = id_param(p, "id") + .or_else(|| st.selection.objects.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 layer = s.edit("Edit Opacity Mask", |d, sel| { + let art = d + .node(id) + .and_then(|n| n.mask.as_ref()) + .map(|m| m.art.clone()) + .ok_or_else(|| EngineError::Other("the object has no opacity mask".into()))?; + let layer = d.add_layer(Some(MASK_EDIT_LAYER)); + // Fresh ids: copies of a masked object share the ids inside their mask art. + let copy = d.reid(&art); + let cid = copy.id; + d.insert(Some(layer), 0, copy)?; + d.mask_edit = Some(MaskEdit { object: id, layer }); + sel.set([cid]); + Ok(layer) + })?; + let st = s.doc_mut()?; + st.isolation = Some(layer); + st.active_layer = Some(layer); + st.revision += 1; + Ok(json!({ "layer": layer.0 })) +} + +fn leave(s: &mut Session, _: &Value) -> Result { + let Some(me) = s.doc()?.doc.mask_edit else { return Err(bad("transparency.stopEditingOpacityMask", "not editing an opacity mask")) }; + s.edit("Stop Editing Opacity Mask", |d, sel| { + sync(d); + d.mask_edit = None; + let _ = d.remove(me.layer); + if d.node(me.object).is_some() { + sel.set([me.object]); + } else { + sel.clear(); + } + Ok(()) + })?; + let st = s.doc_mut()?; + st.isolation = None; + st.active_layer = st.doc.default_layer(); + st.revision += 1; + Ok(json!({ "id": me.object.0 })) +} + +/// Write the editing layer's art into the object's mask (called after every edit). Leaves the +/// mode when the object or the layer is gone (e.g. deleted). +pub(crate) fn sync(d: &mut Document) { + let Some(me) = d.mask_edit else { return }; + let art: Option>> = d.node(me.layer).and_then(|l| l.children().cloned()); + let (Some(art), true) = (art, d.node(me.object).is_some_and(|n| n.mask.is_some())) else { + d.mask_edit = None; + let _ = d.remove(me.layer); + return; + }; + let new_art = match art.as_slice() { + [one] => one.clone(), + many => { + // Several objects (or none): a group, so the mask keeps one art node. + let mut g = Node::group(NodeId(u64::MAX - 1), many.to_vec()); + g.name = Some("Opacity Mask".into()); + Arc::new(g) + } + }; + if let Some(m) = d.node(me.object).and_then(|n| n.mask.as_ref()) + && Arc::ptr_eq(&m.art, &new_art) + { + return; + } + if let Some(m) = d.node_mut(me.object).and_then(|n| n.mask.as_mut()) { + m.art = new_art; + } +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use crate::{NodeId, Session}; + + #[test] + fn edit_mask_art_in_place() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 300, "height": 300})).unwrap(); + let obj = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 200, "height": 200})).unwrap()["id"].as_u64().unwrap(); + let m = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 100, "height": 200})).unwrap()["id"].as_u64().unwrap(); + s.execute("paint.setFill", &json!({"color": "#ffffff", "ids": [m]})).unwrap(); + s.execute("select.set", &json!({"ids": [obj, m]})).unwrap(); + s.execute("transparency.makeOpacityMask", &json!({})).unwrap(); + let layer = s.execute("transparency.editOpacityMask", &json!({})).unwrap()["layer"].as_u64().unwrap(); + assert_eq!(s.doc().unwrap().isolation, Some(NodeId(layer))); + // Edit the mask art with ordinary commands: widen it; the object's mask follows live. + s.execute("object.move", &json!({"dx": 50, "dy": 0})).unwrap(); + let mask_bounds = |s: &Session| s.doc().unwrap().doc.node(NodeId(obj)).unwrap().mask.as_ref().unwrap().art.geometric_bounds().unwrap(); + assert!((mask_bounds(&s).x0 - 50.0).abs() < 1e-6); + // Drawing while editing adds to the mask. + s.execute("shape.ellipse", &json!({"x": 180, "y": 150, "width": 40, "height": 40})).unwrap(); + assert!(mask_bounds(&s).x1 > 185.0); + // Rendered: the object shows through the moved mask; the mask art itself isn't painted + // (the ellipse pokes out of the object at x = 210). + let doc = s.doc().unwrap().doc.clone(); + let img = vectorcraft_render::Renderer::new().render(&doc, 300, 300, vectorcraft_geom::Affine::IDENTITY, &Default::default()); + assert!(img.pixel(100, 100)[3] > 200); + assert_eq!(img.pixel(25, 100)[3], 0); + assert_eq!(img.pixel(212, 170)[3], 0); + s.execute("transparency.stopEditingOpacityMask", &json!({})).unwrap(); + let st = s.doc().unwrap(); + assert!(st.doc.mask_edit.is_none() && st.isolation.is_none()); + assert!(!st.doc.layers.iter().any(|l| l.name.as_deref() == Some(super::MASK_EDIT_LAYER))); + assert_eq!(st.selection.objects, vec![NodeId(obj)]); + // Undo returns to editing mode's last state, then to before. + s.execute("edit.undo", &json!({})).unwrap(); + assert!(s.doc().unwrap().doc.mask_edit.is_some()); + } +} diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index f8ab4a62..8b865ea5 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -18,6 +18,7 @@ pub(crate) mod graph; pub mod help; mod layer; mod live; +pub(crate) mod maskedit; pub(crate) mod menucmds; mod object; mod opacitymask; @@ -167,6 +168,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(graph::specs()); v.extend(rasterfx::specs()); v.extend(views::specs()); + v.extend(maskedit::specs()); v.extend(docmenu::specs()); v.extend(docinfo::specs()); v.extend(panelcmds::specs()); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index df9426f1..f62d5973 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -580,6 +580,10 @@ impl Session { Ok(v) => { // Text Wrap: area type follows its wrap objects; then threads re-flow. cmd::textwrap::refresh(Arc::make_mut(&mut st.doc)); + // Opacity-mask editing: the mask follows its art on the editing layer. + if st.doc.mask_edit.is_some() { + cmd::maskedit::sync(Arc::make_mut(&mut st.doc)); + } // Threaded text re-flows when any of its frames changed. if !st.doc.text_threads.is_empty() { cmd::threads::reflow(&before, Arc::make_mut(&mut st.doc)); diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 95a4598c..c5f0e4e6 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -293,7 +293,9 @@ impl Renderer { ctx.push_clip_layer(&clip); } if !self.draw_pattern_edit(&mut ctx, &frame) { - for layer in &doc.layers { + // While editing an opacity mask its art is seen only through the mask (Illustrator). + let mask_layer = doc.mask_edit.map(|m| m.layer); + for layer in doc.layers.iter().filter(|l| Some(l.id) != mask_layer) { self.draw_arc(&mut ctx, &frame, layer); } } diff --git a/crates/ui-egui/src/panels/transparency.rs b/crates/ui-egui/src/panels/transparency.rs index 3095fd8c..63f24b6c 100644 --- a/crates/ui-egui/src/panels/transparency.rs +++ b/crates/ui-egui/src/panels/transparency.rs @@ -13,7 +13,12 @@ use crate::{VectorcraftApp, icons}; pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); - let n = first_selected(app); + // While editing a mask the panel shows the masked object (the selection is its mask art). + let editing = app.session.active().and_then(|d| d.doc.mask_edit); + let n = match editing { + Some(me) => app.session.active().and_then(|d| d.doc.node(me.object).cloned()), + None => first_selected(app), + }; let has = n.is_some(); let (blend, op, isolate, knockout) = n.as_ref().map(|n| (n.blend, n.opacity, n.isolate, n.knockout)).unwrap_or((BlendMode::Normal, 1.0, false, false)); @@ -54,9 +59,14 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let (new_clip, new_invert) = app.session.new_mask_defaults(); ui.horizontal(|ui| { if !hide_thumbs { - let (r, _) = ui.allocate_exact_size(vec2(60.0, 50.0), Sense::hover()); + let (r, oresp) = ui.allocate_exact_size(vec2(60.0, 50.0), Sense::click()); ui.painter().rect_filled(r, 0.0, egui::Color32::WHITE); - ui.painter().rect_stroke(r, 0.0, Stroke::new(1.5, t.border), StrokeKind::Outside); + // The thumbnail being edited is outlined (object normally, the mask while editing it). + let (obj_w, mask_w) = if editing.is_some() { (0.5, 1.5) } else { (1.5, 1.0) }; + ui.painter().rect_stroke(r, 0.0, Stroke::new(obj_w, t.border), StrokeKind::Outside); + if editing.is_some() && oresp.on_hover_text("Stop editing the opacity mask").clicked() { + app.run("transparency.stopEditingOpacityMask", json!({})).ok(); + } if let Some(n) = &n { let mut bare = n.clone(); bare.mask = None; @@ -88,15 +98,24 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { 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(1.0, t.input_border), StrokeKind::Inside); + ui.painter().rect_stroke( + mr, + 0.0, + Stroke::new(mask_w, if editing.is_some() { t.border } else { t.input_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("Shift-click to disable or enable the mask").clicked() && shift { - app.run(if m.disabled { "transparency.enableOpacityMask" } else { "transparency.disableOpacityMask" }, json!({})).ok(); + if mresp.on_hover_text("Click to edit the mask; Shift-click to disable or enable it").clicked() { + if shift { + app.run(if m.disabled { "transparency.enableOpacityMask" } else { "transparency.disableOpacityMask" }, json!({})).ok(); + } else if editing.is_none() { + app.run("transparency.editOpacityMask", json!({})).ok(); + } } } None => {