mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-25 10:17:02 -06:00
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:
co-authored by
Claude Opus 5.5
parent
cbdef4ea1f
commit
03571affbd
@@ -40,6 +40,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||||||
has_doc,
|
has_doc,
|
||||||
leave
|
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() {
|
if st.doc.mask_edit.is_some() {
|
||||||
return Err(bad(C, "already editing an opacity mask"));
|
return Err(bad(C, "already editing an opacity mask"));
|
||||||
}
|
}
|
||||||
let id = id_param(p, "id")
|
let id = id_param(p, "id").or_else(|| first_masked(st)).ok_or_else(|| bad(C, "select an object with an opacity mask"))?;
|
||||||
.or_else(|| st.selection.subjects().iter().copied().find(|i| st.doc.node(*i).is_some_and(|n| n.mask.is_some())))
|
let layer = start(s, id)?;
|
||||||
.ok_or_else(|| bad(C, "select an object with an opacity mask"))?;
|
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))?;
|
let layer = s.edit("Edit Opacity Mask", |d, sel| begin(d, sel, id))?;
|
||||||
show_editing(s, Some(layer))?;
|
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
|
/// 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<()> {
|
pub(crate) fn show_editing(s: &mut Session, layer: Option<NodeId>) -> Result<()> {
|
||||||
let st = s.doc_mut()?;
|
let st = s.doc_mut()?;
|
||||||
st.isolation = layer;
|
st.isolation = layer;
|
||||||
st.active_layer = layer.or_else(|| st.doc.default_layer());
|
st.active_layer = layer.or_else(|| st.doc.default_layer());
|
||||||
|
if layer.is_none() {
|
||||||
|
st.mask_view = None;
|
||||||
|
}
|
||||||
st.revision += 1;
|
st.revision += 1;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// After undo or redo, isolation follows the restored document into or out of mask editing
|
/// 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
|
/// (`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>) {
|
pub(crate) fn follow_history(st: &mut crate::DocState, was: Option<NodeId>) {
|
||||||
match st.doc.mask_edit {
|
match st.doc.mask_edit {
|
||||||
Some(me) => {
|
Some(me) => {
|
||||||
st.isolation = Some(me.layer);
|
st.isolation = Some(me.layer);
|
||||||
st.active_layer = Some(me.layer);
|
st.active_layer = Some(me.layer);
|
||||||
}
|
}
|
||||||
None if was.is_some() && st.isolation == was => {
|
None => {
|
||||||
st.isolation = None;
|
st.mask_view = None;
|
||||||
st.active_layer = st.doc.default_layer();
|
if was.is_some() && st.isolation == was {
|
||||||
|
st.isolation = None;
|
||||||
|
st.active_layer = st.doc.default_layer();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
None => {}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -101,6 +101,9 @@ pub struct DocState {
|
|||||||
pub last_selection_cmd: Option<(String, Value)>,
|
pub last_selection_cmd: Option<(String, Value)>,
|
||||||
/// Process-unique id of this open document (tab indices shift when tabs close).
|
/// Process-unique id of this open document (tab indices shift when tabs close).
|
||||||
pub uid: u64,
|
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);
|
static NEXT_DOC_UID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
|
||||||
@@ -122,6 +125,7 @@ impl DocState {
|
|||||||
last_transform: None,
|
last_transform: None,
|
||||||
last_selection_cmd: None,
|
last_selection_cmd: None,
|
||||||
uid: NEXT_DOC_UID.fetch_add(1, std::sync::atomic::Ordering::Relaxed),
|
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).
|
/// 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())
|
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.
|
/// 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)]
|
#[cfg(test)]
|
||||||
mod tests_live;
|
mod tests_live;
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
mod tests_maskview;
|
||||||
|
#[cfg(test)]
|
||||||
mod tests_menucmds;
|
mod tests_menucmds;
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests_opacitymask;
|
mod tests_opacitymask;
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
@@ -82,6 +82,9 @@ pub struct RenderOptions {
|
|||||||
/// Pattern editing mode's tile edge and swatch bounds colour (Object → Pattern → Tile Edge
|
/// Pattern editing mode's tile edge and swatch bounds colour (Object → Pattern → Tile Edge
|
||||||
/// Color), RGB.
|
/// Color), RGB.
|
||||||
pub tile_edge: [u8; 3],
|
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<NodeId>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for RenderOptions {
|
impl Default for RenderOptions {
|
||||||
@@ -97,6 +100,7 @@ impl Default for RenderOptions {
|
|||||||
trim: false,
|
trim: false,
|
||||||
skip_templates: false,
|
skip_templates: false,
|
||||||
tile_edge: vectorcraft_doc::LAYER_COLORS[0].1,
|
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
|
// Raster filters (drop shadow, glows, blur) are rendered offscreen on this thread when the
|
||||||
// main context is multithreaded (see `fx::with_filters`).
|
// main context is multithreaded (see `fx::with_filters`).
|
||||||
let threads = self.threads;
|
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 slot = if threads == 0 { self.ctx_st.take() } else { self.ctx.take() };
|
||||||
let mut ctx = match slot {
|
let mut ctx = match slot {
|
||||||
Some(mut c) if c.width() == w && c.height() == h => {
|
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() }),
|
_ => 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 {
|
if let Some(bg) = opts.background {
|
||||||
ctx.set_transform(Affine::IDENTITY);
|
ctx.set_transform(Affine::IDENTITY);
|
||||||
ctx.set_paint(peniko::Color::from_rgba8(bg[0], bg[1], bg[2], bg[3]));
|
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.
|
/// 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 {
|
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<u8> {
|
||||||
let mut mctx = single_threaded_context(w, h);
|
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.
|
// Mask art is a picture of its own: it takes no part in a knockout group around the object.
|
||||||
let outer =
|
let outer =
|
||||||
@@ -557,8 +573,7 @@ impl Renderer {
|
|||||||
mctx.flush();
|
mctx.flush();
|
||||||
let mut pm = Pixmap::new(w, h);
|
let mut pm = Pixmap::new(w, h);
|
||||||
mctx.render(&mut pm, &mut self.resources);
|
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();
|
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Everything [`Self::draw_arc`] does after culling.
|
/// Everything [`Self::draw_arc`] does after culling.
|
||||||
|
|||||||
@@ -145,6 +145,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
|||||||
let painter = ui.painter_at(rect);
|
let painter = ui.painter_at(rect);
|
||||||
let Some(st) = app.session.active() else { return };
|
let Some(st) = app.session.active() else { return };
|
||||||
let doc = st.doc.clone();
|
let doc = st.doc.clone();
|
||||||
|
let mask_view = st.shown_mask();
|
||||||
|
|
||||||
// Pasteboard, artboard shadows and paper.
|
// Pasteboard, artboard shadows and paper.
|
||||||
painter.rect_filled(rect, 0.0, t.pasteboard);
|
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);
|
let [r, g, b, _] = c.to_rgba8(1.0);
|
||||||
[r, g, b]
|
[r, g, b]
|
||||||
}),
|
}),
|
||||||
|
mask_view,
|
||||||
..opts
|
..opts
|
||||||
};
|
};
|
||||||
// Light documents render synchronously (no lag vs overlays); heavy ones go to the worker.
|
// Light documents render synchronously (no lag vs overlays); heavy ones go to the worker.
|
||||||
|
|||||||
@@ -439,6 +439,8 @@ mod tests_effectedit;
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests_freeform;
|
mod tests_freeform;
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
mod tests_maskview;
|
||||||
|
#[cfg(test)]
|
||||||
mod tests_stroke;
|
mod tests_stroke;
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests_strokedepth;
|
mod tests_strokedepth;
|
||||||
|
|||||||
@@ -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<egui::Event>, 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));
|
||||||
|
}
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
//! Transparency panel: blend mode, opacity (field + slider popup), object/mask thumbnails with the
|
//! 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),
|
//! 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
|
//! Alt-click it to view only the mask), Isolate Blending, the three-state Knockout Group (on →
|
||||||
//! Knockout Shape; the menu's Page Isolated Blending and Page Knockout Group.
|
//! 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 egui::{Sense, Stroke, StrokeKind, Ui, vec2};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
@@ -14,6 +15,9 @@ use crate::theme::Tokens;
|
|||||||
use crate::widgets::{self, TransparencyEdit, menu_item};
|
use crate::widgets::{self, TransparencyEdit, menu_item};
|
||||||
use crate::{VectorcraftApp, icons};
|
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 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
|
/// (the selection, or the object whose mask is being edited), recomputed only when the document
|
||||||
/// or the selection changes.
|
/// 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() {
|
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();
|
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] };
|
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]));
|
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));
|
node_thumb(app, ui, "tr-mask", &m.art, mr.shrink(2.0), Some(bg));
|
||||||
ui.painter().rect_stroke(
|
// Viewing the mask alone (Alt-click) highlights its thumbnail.
|
||||||
mr,
|
let viewing = app.session.active().is_some_and(|d| d.shown_mask().is_some());
|
||||||
0.0,
|
let border = match (viewing, editing.is_some()) {
|
||||||
Stroke::new(mask_w, if editing.is_some() { t.border } else { t.input_border }),
|
(true, _) => Stroke::new(2.0, t.accent),
|
||||||
StrokeKind::Inside,
|
(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 {
|
if m.disabled {
|
||||||
let red = Stroke::new(2.0, egui::Color32::from_rgb(220, 40, 40));
|
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.left_top(), mr.right_bottom()], red);
|
||||||
ui.painter().line_segment([mr.right_top(), mr.left_bottom()], red);
|
ui.painter().line_segment([mr.right_top(), mr.left_bottom()], red);
|
||||||
}
|
}
|
||||||
let shift = ui.input(|i| i.modifiers.shift);
|
let (shift, alt) = ui.input(|i| (i.modifiers.shift, i.modifiers.alt));
|
||||||
if mresp.on_hover_text("Click to edit the mask; Shift-click to disable or enable it").clicked() {
|
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 {
|
if shift {
|
||||||
app.run(if m.disabled { "transparency.enableOpacityMask" } else { "transparency.disableOpacityMask" }, json!({})).ok();
|
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() {
|
} else if editing.is_none() {
|
||||||
app.run("transparency.editOpacityMask", json!({"id": id.0})).ok();
|
app.run("transparency.editOpacityMask", json!({"id": id.0})).ok();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
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.
|
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
|
## Clipping masks
|
||||||
|
|
||||||
`object.clippingMask.make` clips the selected objects by the topmost one, which may be a path, a compound path or a
|
`object.clippingMask.make` clips the selected objects by the topmost one, which may be a path, a compound path or a
|
||||||
|
|||||||
Reference in New Issue
Block a user