mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-14 19:26:56 -04:00
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:
1 parent
5026dad446
commit
dcc5a006a2
7 files changed
+208
-9
No files matched your search
+2
-2
@@ -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 |
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
Reference in new issue
Block a user