M3.6: opacity-mask editing mode — click the mask thumbnail to edit the mask art on a temporary isolated layer with any tool; the mask updates live after every edit; the object thumbnail (or transparency.stopEditingOpacityMask) leaves the mode

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Brandon ThomasandClaude Opus 5.5 committed 2026-10-02 08:40:18 -04:00
1 parent 5026dad446
commit dcc5a006a2
7 files changed
+208 -9

No files matched your search

+2 -2
View File
@@ -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 `<mask>` 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 `<mask>` 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 |
+11
View File
@@ -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<PatternEdit>,
/// Opacity-mask editing mode, while active.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mask_edit: Option<MaskEdit>,
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(),
};
+161
View File
@@ -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<CommandSpec> {
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<Value> {
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<Value> {
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<Vec<Arc<Node>>> = 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());
}
}
+2
View File
@@ -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());
+4
View File
@@ -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));
+3 -1
View File
@@ -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);
}
}
+25 -6
View File
@@ -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 => {