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 <noreply@anthropic.com>
This commit is contained in:
bflatastic
2026-10-05 03:11:04 +07:00
co-authored by Claude Opus 5.5
parent cbdef4ea1f
commit 03571affbd
9 changed files with 241 additions and 30 deletions
+58 -10
View File
@@ -40,6 +40,15 @@ pub fn specs() -> Vec<CommandSpec> {
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<Value> {
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<NodeId> {
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<NodeId> {
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<Value> {
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<NodeId>) -> 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<NodeId>) {
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 => {}
}
}
+11
View File
@@ -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<NodeId>,
}
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<NodeId> {
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;
+71
View File
@@ -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());
}