Merge pull request #49 from storytold/pr/m3-mask-view-clip-paint
M3: view only the mask, lossless SVG mask options, per-document transparency grid, paint on clipping paths (M3.86–M3.89)
This commit is contained in:
commit
4757dd265c
|
|
@ -478,7 +478,13 @@ impl Node {
|
|||
pub fn visual_bounds(&self) -> Option<Rect> {
|
||||
match &self.kind {
|
||||
NodeKind::Group { children, clip: true } | NodeKind::Layer { children, clip: true, .. } => {
|
||||
children.first().and_then(|c| c.geometric_bounds())
|
||||
// A clipping path that paints (see `clip_paint`) strokes over the clip edge.
|
||||
let clip = children.first()?;
|
||||
if matches!(clip.kind, NodeKind::Path { .. } | NodeKind::Compound { .. } | NodeKind::Text(_)) {
|
||||
clip.visual_bounds()
|
||||
} else {
|
||||
clip.geometric_bounds()
|
||||
}
|
||||
}
|
||||
NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } => {
|
||||
// The container's own strokes paint around its members.
|
||||
|
|
@ -705,6 +711,54 @@ impl Node {
|
|||
_ => Some((unite(&shapes), FillRule::NonZero)),
|
||||
}
|
||||
}
|
||||
|
||||
/// What this object paints as the clipping path of a clip group, shared by the renderer and
|
||||
/// the SVG and PDF writers: its fills, painted behind the clipped art, and its strokes, painted
|
||||
/// over it (unclipped). Each part is this object keeping only those appearance items (type:
|
||||
/// also only its characters' fills or strokes); `None` where it paints nothing, as right after
|
||||
/// Make Clipping Mask. Only paths, compound paths and type paint as clipping paths.
|
||||
pub fn clip_paint(&self) -> ClipPaint {
|
||||
use crate::appearance::AppearanceItem;
|
||||
let runs = match &self.kind {
|
||||
NodeKind::Path { guide: false, .. } | NodeKind::Compound { .. } => None,
|
||||
NodeKind::Text(t) => Some(&t.runs),
|
||||
_ => return ClipPaint::default(),
|
||||
};
|
||||
let paints = |fill: bool| {
|
||||
let item = |i: &AppearanceItem| match i {
|
||||
AppearanceItem::Fill(f) => fill && f.visible && !f.paint.is_none(),
|
||||
AppearanceItem::Stroke(s) => !fill && s.visible && !s.paint.is_none() && s.width > 0.0,
|
||||
};
|
||||
let run = |r: &crate::text::TextRun| if fill { !r.style.fill.is_none() } else { !r.style.stroke.is_none() && r.style.stroke_width > 0.0 };
|
||||
self.appearance.items.iter().any(item) || runs.is_some_and(|rs| rs.iter().any(run))
|
||||
};
|
||||
let part = |fill: bool| {
|
||||
paints(fill).then(|| {
|
||||
let mut n = self.clone();
|
||||
for i in (0..n.appearance.items.len()).rev() {
|
||||
if matches!(n.appearance.items[i], AppearanceItem::Fill(_)) != fill {
|
||||
n.appearance.remove_item(i);
|
||||
}
|
||||
}
|
||||
if let NodeKind::Text(t) = &mut n.kind {
|
||||
for r in &mut t.runs {
|
||||
*(if fill { &mut r.style.stroke } else { &mut r.style.fill }) = vectorcraft_color::Paint::None;
|
||||
}
|
||||
}
|
||||
n
|
||||
})
|
||||
};
|
||||
ClipPaint { fill: part(true), stroke: part(false) }
|
||||
}
|
||||
}
|
||||
|
||||
/// What a clipping path paints ([`Node::clip_paint`]).
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct ClipPaint {
|
||||
/// The clipping path with only its fills: painted behind the clipped art (inside the clip).
|
||||
pub fill: Option<Node>,
|
||||
/// The clipping path with only its strokes: painted over the clipped art, not clipped.
|
||||
pub stroke: Option<Node>,
|
||||
}
|
||||
|
||||
/// Knockout Group state of a container (the Transparency panel's three-state checkbox). In a
|
||||
|
|
|
|||
|
|
@ -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 => {}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -93,7 +93,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
"Make",
|
||||
["Object", "Clipping Mask"],
|
||||
Some("Cmd+7"),
|
||||
"{} the topmost selected object (a path, compound path or text, which loses its paint) clips the others: compound holes, even-odd fills and glyph outlines clip as drawn → {id} of the clip group",
|
||||
"{} the topmost selected object (a path, compound path or text, which loses its paint) clips the others: compound holes, even-odd fills and glyph outlines clip as drawn. Paint given to the clipping path later (paint commands with its id) shows: its fill behind the clipped art, its stroke over it → {id} of the clip group",
|
||||
has_multi,
|
||||
clip_make
|
||||
),
|
||||
|
|
@ -102,7 +102,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
"Release",
|
||||
["Object", "Clipping Mask"],
|
||||
Some("Cmd+Alt+7"),
|
||||
"{} the selected clip groups become plain groups; their clipping path (path, compound path or text) stays, unpainted",
|
||||
"{} the selected clip groups become plain groups; their clipping path (path, compound path or text) stays, with the paint it has (none unless painted after Make)",
|
||||
has_selection,
|
||||
clip_release
|
||||
),
|
||||
|
|
@ -383,11 +383,12 @@ fn ungroup(s: &mut Session, _: &Value) -> Result<Value> {
|
|||
d.remove(*id)?;
|
||||
for (k, c) in children.into_iter().enumerate() {
|
||||
let mut c = (*c).clone();
|
||||
if clip && k == 0 {
|
||||
if let NodeKind::Path { clipping, .. } = &mut c.kind {
|
||||
*clipping = false;
|
||||
}
|
||||
c.appearance = Appearance::default();
|
||||
// The clipping path keeps its paint (none, unless painted after Make).
|
||||
if clip
|
||||
&& k == 0
|
||||
&& let NodeKind::Path { clipping, .. } = &mut c.kind
|
||||
{
|
||||
*clipping = false;
|
||||
}
|
||||
c.opacity *= opacity;
|
||||
new_sel.push(c.id);
|
||||
|
|
@ -552,7 +553,7 @@ pub(super) fn make_clipping_path(d: &mut Document, top: NodeId) -> Result<()> {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// Stop clip group or clipped layer `id` clipping; its clipping path stays, unpainted.
|
||||
/// Stop clip group or clipped layer `id` clipping; its clipping path stays, with its paint.
|
||||
pub(super) fn release_clip(d: &mut Document, id: NodeId) {
|
||||
let Some(n) = d.node_mut(id).filter(|n| n.clips()) else { return };
|
||||
n.set_clips(false);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
//! View → New View… / Edit Views…: saved views stored in the document.
|
||||
//! View → New View… / Edit Views…: saved views stored in the document; per-document view
|
||||
//! toggles (Show Transparency Grid).
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::SavedView;
|
||||
|
|
@ -30,9 +31,24 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
edit_views
|
||||
),
|
||||
cmd!(query "view.saved.list", "Saved Views", [], None, "{} → {views: [{name, center, zoom, rotation}]}", has_doc, list_views),
|
||||
cmd!(
|
||||
query "view.transparencyGrid",
|
||||
"Show Transparency Grid",
|
||||
["View"],
|
||||
Some("Cmd+Shift+D"),
|
||||
"{on?: bool (default: toggle)} show the transparency grid behind the active document's artboards (each document has its own setting) → {on}",
|
||||
has_doc,
|
||||
transparency_grid
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
fn transparency_grid(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let st = s.doc_mut()?;
|
||||
st.transparency_grid = p.get("on").and_then(Value::as_bool).unwrap_or(!st.transparency_grid);
|
||||
Ok(json!({ "on": st.transparency_grid }))
|
||||
}
|
||||
|
||||
fn list_views(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
Ok(json!({ "views": s.doc()?.doc.views }))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -101,6 +101,11 @@ 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>,
|
||||
/// View → Show Transparency Grid, per document (view state: not saved, not undoable).
|
||||
pub transparency_grid: bool,
|
||||
}
|
||||
|
||||
static NEXT_DOC_UID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
|
||||
|
|
@ -122,6 +127,8 @@ impl DocState {
|
|||
last_transform: None,
|
||||
last_selection_cmd: None,
|
||||
uid: NEXT_DOC_UID.fetch_add(1, std::sync::atomic::Ordering::Relaxed),
|
||||
mask_view: None,
|
||||
transparency_grid: false,
|
||||
}
|
||||
}
|
||||
/// Unsaved changes: the document differs from the saved one (selection changes don't count).
|
||||
|
|
@ -154,6 +161,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.
|
||||
|
|
@ -783,6 +795,8 @@ mod tests_charstroke;
|
|||
#[cfg(test)]
|
||||
mod tests_clip;
|
||||
#[cfg(test)]
|
||||
mod tests_clippaint;
|
||||
#[cfg(test)]
|
||||
mod tests_cmdsplit;
|
||||
#[cfg(test)]
|
||||
mod tests_colorguide;
|
||||
|
|
@ -827,6 +841,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;
|
||||
|
|
@ -873,6 +889,8 @@ mod tests_tileedge;
|
|||
#[cfg(test)]
|
||||
mod tests_tints;
|
||||
#[cfg(test)]
|
||||
mod tests_transparencygrid;
|
||||
#[cfg(test)]
|
||||
mod tests_widthprofiles;
|
||||
#[cfg(test)]
|
||||
mod tests_xform;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,102 @@
|
|||
//! Fill and stroke on a clipping path (M3.89): Make Clipping Mask leaves the clipping path
|
||||
//! unpainted, paint given to it later shows (its fill behind the clipped art, its stroke over it
|
||||
//! and unclipped) on the canvas, in SVG and in PDF, and Release and Ungroup keep 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": 50, "width": w, "height": 100})).unwrap()["id"].as_u64().unwrap());
|
||||
s.execute("paint.setFill", &json!({"color": fill, "ids": [id.0]})).unwrap();
|
||||
id
|
||||
}
|
||||
|
||||
/// Red art (40..100) clipped by a rectangle (50..150) painted blue with a 10 pt green stroke.
|
||||
fn painted_clip() -> (Session, NodeId, NodeId) {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap();
|
||||
rect(&mut s, 40.0, 60.0, "#ff0000");
|
||||
let clip = rect(&mut s, 50.0, 100.0, "#ffffff");
|
||||
s.execute("select.all", &json!({})).unwrap();
|
||||
let group = NodeId(s.execute("object.clippingMask.make", &json!({})).unwrap()["id"].as_u64().unwrap());
|
||||
let n = s.doc().unwrap().doc.node(clip).unwrap().clone();
|
||||
assert!(n.clip_paint().fill.is_none() && n.clip_paint().stroke.is_none(), "Make leaves the clipping path unpainted");
|
||||
s.execute("paint.setFill", &json!({"color": "#0000ff", "ids": [clip.0]})).unwrap();
|
||||
s.execute("paint.setStroke", &json!({"color": "#00ff00", "ids": [clip.0]})).unwrap();
|
||||
s.execute("stroke.set", &json!({"weight": 10, "ids": [clip.0]})).unwrap();
|
||||
(s, group, clip)
|
||||
}
|
||||
|
||||
/// RGB at (x, 100) of `doc` rendered on white.
|
||||
fn rgb(doc: &Document, x: u32) -> [u8; 3] {
|
||||
let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
|
||||
let p = Renderer::new().render(doc, 200, 200, Affine::IDENTITY, &opts).pixel(x, 100);
|
||||
[p[0], p[1], p[2]]
|
||||
}
|
||||
|
||||
/// Behind the clipped art, its fill; over it (also outside the clip), its stroke.
|
||||
fn assert_painted(doc: &Document, what: &str) {
|
||||
assert_eq!(rgb(doc, 70), [255, 0, 0], "{what}: the clipped art over the fill");
|
||||
assert_eq!(rgb(doc, 120), [0, 0, 255], "{what}: the fill inside the clip");
|
||||
assert_eq!(rgb(doc, 45), [0, 255, 0], "{what}: the stroke's outer half, unclipped (the art below it is clipped away)");
|
||||
assert_eq!(rgb(doc, 52), [0, 255, 0], "{what}: the stroke over the clipped art");
|
||||
assert_eq!(rgb(doc, 147), [0, 255, 0], "{what}: the stroke over the fill");
|
||||
assert_eq!(rgb(doc, 30), [255, 255, 255], "{what}: outside the clip and its stroke");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_clipping_paths_fill_and_stroke_show_in_render_svg_and_pdf() {
|
||||
let (s, group, clip) = painted_clip();
|
||||
let doc = s.doc().unwrap().doc.clone();
|
||||
assert_painted(&doc, "canvas");
|
||||
// The clip group's bounds take in the stroke.
|
||||
let b = doc.node(group).unwrap().visual_bounds().unwrap();
|
||||
assert!((b.x0 - 45.0).abs() < 1e-6 && (b.x1 - 155.0).abs() < 1e-6, "{b:?}");
|
||||
// SVG: the fill inside the clip, the stroke after it, one element keeping the clipping path's id.
|
||||
let svg = vectorcraft_svg::export(&doc, &Default::default());
|
||||
assert_painted(&vectorcraft_svg::import(&svg).unwrap(), "SVG");
|
||||
// PDF.
|
||||
let pdf = vectorcraft_pdf::export(&doc, &Default::default()).unwrap();
|
||||
assert_painted(&vectorcraft_pdf::import(&pdf).unwrap(), "PDF");
|
||||
// Unpainted clipping paths write nothing extra.
|
||||
let mut bare = s;
|
||||
bare.execute("paint.setFill", &json!({"none": true, "ids": [clip.0]})).unwrap();
|
||||
bare.execute("paint.setStroke", &json!({"none": true, "ids": [clip.0]})).unwrap();
|
||||
let doc = bare.doc().unwrap().doc.clone();
|
||||
assert_eq!(rgb(&doc, 120), [255, 255, 255]);
|
||||
assert_eq!(rgb(&doc, 45), [255, 255, 255]);
|
||||
let svg = vectorcraft_svg::export(&doc, &Default::default());
|
||||
assert_eq!(svg.matches("<path").count(), 2, "the clip region and the red art: {svg}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn release_and_ungroup_keep_the_clipping_paths_paint() {
|
||||
for cmd in ["object.clippingMask.release", "object.ungroup"] {
|
||||
let (mut s, group, clip) = painted_clip();
|
||||
s.execute("select.set", &json!({"ids": [group.0]})).unwrap();
|
||||
s.execute(cmd, &json!({})).unwrap();
|
||||
let n = s.doc().unwrap().doc.node(clip).unwrap().clone();
|
||||
assert_eq!(n.appearance.fill_paint(), vectorcraft_color::Paint::solid(vectorcraft_color::Color::from_hex("#0000ff").unwrap()), "{cmd}");
|
||||
assert_eq!(n.appearance.stroke().map(|st| st.width), Some(10.0), "{cmd}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn type_as_a_clipping_path_paints_its_characters() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap();
|
||||
rect(&mut s, 0.0, 200.0, "#ff0000");
|
||||
let t = NodeId(s.execute("text.create", &json!({"x": 10, "y": 120, "text": "Clip", "size": 72})).unwrap()["id"].as_u64().unwrap());
|
||||
s.execute("select.all", &json!({})).unwrap();
|
||||
s.execute("object.clippingMask.make", &json!({})).unwrap();
|
||||
let paint = |s: &Session| s.doc().unwrap().doc.node(t).unwrap().clip_paint();
|
||||
assert!(paint(&s).fill.is_none() && paint(&s).stroke.is_none());
|
||||
s.execute("paint.setFill", &json!({"color": "#0000ff", "ids": [t.0]})).unwrap();
|
||||
let p = paint(&s);
|
||||
assert!(p.stroke.is_none());
|
||||
let NodeKind::Text(fill) = &p.fill.expect("the characters' fill paints").kind else { panic!("type") };
|
||||
assert!(fill.runs.iter().all(|r| r.style.fill.color().is_some() && r.style.stroke.is_none()));
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
//! View → Show Transparency Grid is engine view state of each open document (M3.88).
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn transparency_grid_is_per_document_view_state() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let grid = |s: &Session| s.doc().unwrap().transparency_grid;
|
||||
assert!(!grid(&s));
|
||||
assert_eq!(s.execute("view.transparencyGrid", &json!({})).unwrap(), json!({"on": true}));
|
||||
assert!(grid(&s));
|
||||
// Not an edit: nothing to undo, the document stays clean, the journal skips it.
|
||||
let st = s.doc().unwrap();
|
||||
assert!(st.history.undo.is_empty() && !st.is_dirty());
|
||||
// Another document has its own setting.
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
assert!(!grid(&s));
|
||||
assert_eq!(s.execute("view.transparencyGrid", &json!({"on": false})).unwrap(), json!({"on": false}));
|
||||
s.execute("document.activate", &json!({"index": 0})).unwrap();
|
||||
assert!(grid(&s));
|
||||
assert_eq!(s.execute("view.transparencyGrid", &json!({})).unwrap(), json!({"on": false}));
|
||||
assert_eq!(s.execute("view.transparencyGrid", &json!({"on": true})).unwrap(), json!({"on": true}));
|
||||
s.execute("document.activate", &json!({"index": 1})).unwrap();
|
||||
assert!(!grid(&s));
|
||||
}
|
||||
|
|
@ -527,13 +527,21 @@ impl Exporter<'_> {
|
|||
match &n.kind {
|
||||
NodeKind::Layer { children, clip: false, .. } | NodeKind::Group { children, clip: false } => self.children(s, children, page),
|
||||
NodeKind::Group { children, clip: true } | NodeKind::Layer { children, clip: true, .. } => {
|
||||
// The region every output clips to; nothing to clip by hides the clipped art.
|
||||
// The region every output clips to; nothing to clip by hides the clipped art. The
|
||||
// clipping path's fill paints behind the clipped art and its stroke over it, unclipped.
|
||||
if let Some((clip, rest)) = children.split_first()
|
||||
&& let Some((p, r)) = vectorcraft_effects::clip_outline(clip).and_then(|(bp, r)| to_path(&bp).map(|p| (p, r)))
|
||||
{
|
||||
let paint = clip.clip_paint();
|
||||
s.push_clip_path(&p, &rule(r));
|
||||
if let Some(fill) = &paint.fill {
|
||||
self.node(s, fill, page, false);
|
||||
}
|
||||
self.children(s, rest, page);
|
||||
s.pop();
|
||||
if let Some(stroke) = &paint.stroke {
|
||||
self.node(s, stroke, page, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
NodeKind::Path { path, rule, guide, .. } => {
|
||||
|
|
|
|||
|
|
@ -82,6 +82,9 @@ pub struct RenderOptions {
|
|||
/// Pattern editing mode's tile edge and swatch bounds colour (Object → Pattern → Tile Edge
|
||||
/// Color), RGB.
|
||||
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 {
|
||||
|
|
@ -97,6 +100,7 @@ impl Default for RenderOptions {
|
|||
trim: false,
|
||||
skip_templates: false,
|
||||
tile_edge: vectorcraft_doc::LAYER_COLORS[0].1,
|
||||
mask_view: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -208,6 +212,8 @@ struct GeomEntry {
|
|||
|
||||
/// A clipping path (kept alive so its address can't be reused) and the region it clips to.
|
||||
type ClipEntry = (Arc<Node>, Option<Arc<(BezPath, FillRule)>>);
|
||||
/// A clipping path (kept alive) and what it paints: its fills and its strokes ([`Node::clip_paint`]).
|
||||
type ClipPaintEntry = (Arc<Node>, Option<Arc<Node>>, Option<Arc<Node>>);
|
||||
|
||||
/// Reusable renderer (keeps the render context, decoded images and glyph caches between frames).
|
||||
pub struct Renderer {
|
||||
|
|
@ -256,6 +262,8 @@ pub struct Renderer {
|
|||
/// Clip paths pushed on the current context (path, rule, transform): starting the context
|
||||
/// again from a picture of it pushes them again (see [`Self::group`]).
|
||||
clip_paths: Vec<(BezPath, FillRule, Affine)>,
|
||||
/// What clipping paths paint, per clipping path (see [`Self::clip_paint_of`]).
|
||||
clip_paints: PtrMap<usize, ClipPaintEntry>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
|
|
@ -310,6 +318,7 @@ impl Renderer {
|
|||
backdrop: None,
|
||||
blends: PtrMap::default(),
|
||||
clip_paths: vec![],
|
||||
clip_paints: PtrMap::default(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -323,6 +332,18 @@ impl Renderer {
|
|||
// Raster filters (drop shadow, glows, blur) are rendered offscreen on this thread when the
|
||||
// main context is multithreaded (see `fx::with_filters`).
|
||||
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 mut ctx = match slot {
|
||||
Some(mut c) if c.width() == w && c.height() == h => {
|
||||
|
|
@ -331,12 +352,6 @@ impl Renderer {
|
|||
}
|
||||
_ => 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 {
|
||||
ctx.set_transform(Affine::IDENTITY);
|
||||
ctx.set_paint(peniko::Color::from_rgba8(bg[0], bg[1], bg[2], bg[3]));
|
||||
|
|
@ -493,6 +508,24 @@ impl Renderer {
|
|||
region
|
||||
}
|
||||
|
||||
/// Cached fills and strokes of clipping path `a` ([`Node::clip_paint`]): the parts keep their
|
||||
/// allocations between frames, so their own geometry stays cached.
|
||||
fn clip_paint_of(&mut self, a: &Arc<Node>) -> (Option<Arc<Node>>, Option<Arc<Node>>) {
|
||||
let key = Arc::as_ptr(a) as usize;
|
||||
if let Some((node, fill, stroke)) = self.clip_paints.get(&key)
|
||||
&& Arc::ptr_eq(node, a)
|
||||
{
|
||||
return (fill.clone(), stroke.clone());
|
||||
}
|
||||
let paint = a.clip_paint();
|
||||
let (fill, stroke) = (paint.fill.map(Arc::new), paint.stroke.map(Arc::new));
|
||||
if self.clip_paints.len() > 1024 {
|
||||
self.clip_paints.clear();
|
||||
}
|
||||
self.clip_paints.insert(key, (a.clone(), fill.clone(), stroke.clone()));
|
||||
(fill, stroke)
|
||||
}
|
||||
|
||||
/// Cached BezPath of a path node.
|
||||
fn path_of(&mut self, a: &Arc<Node>) -> Option<Arc<BezPath>> {
|
||||
let key = Arc::as_ptr(a) as usize;
|
||||
|
|
@ -548,6 +581,12 @@ impl Renderer {
|
|||
|
||||
/// 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 {
|
||||
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);
|
||||
// Mask art is a picture of its own: it takes no part in a knockout group around the object.
|
||||
let outer =
|
||||
|
|
@ -557,8 +596,7 @@ impl Renderer {
|
|||
mctx.flush();
|
||||
let mut pm = Pixmap::new(w, h);
|
||||
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();
|
||||
vello_cpu::Mask::from_parts(data, w, h)
|
||||
pm.data().iter().map(|p| mask_value(p.r, p.g, p.b, p.a, m.clip, m.invert)).collect()
|
||||
}
|
||||
|
||||
/// Everything [`Self::draw_arc`] does after culling.
|
||||
|
|
@ -657,14 +695,24 @@ impl Renderer {
|
|||
}
|
||||
NodeKind::Group { children, clip: true } | NodeKind::Layer { children, clip: true, .. } => {
|
||||
// Nothing to clip by hides the clipped art (as in the SVG and PDF output). A clip
|
||||
// group doesn't isolate: blending inside it reaches the art below.
|
||||
// group doesn't isolate: blending inside it reaches the art below. The clipping
|
||||
// path's fill paints behind the clipped art and its stroke over it, unclipped.
|
||||
if let Some((clip, rest)) = children.split_first()
|
||||
&& let Some(region) = self.clip_of(clip)
|
||||
{
|
||||
let (fill, stroke) = self.clip_paint_of(clip);
|
||||
let blends = self.blends_through(n);
|
||||
let bounds = if blends { Some(region.0.bounding_box()) } else { None };
|
||||
let comp = Composite { clip: Some((®ion.0, region.1)), blends, bounds, ..Default::default() };
|
||||
self.group(ctx, f, comp, &mut |r, c, fr| r.draw_children(c, fr, rest, knockout));
|
||||
self.group(ctx, f, comp, &mut |r, c, fr| {
|
||||
if let Some(fill) = &fill {
|
||||
r.draw_arc(c, fr, fill);
|
||||
}
|
||||
r.draw_children(c, fr, rest, knockout)
|
||||
});
|
||||
if let Some(stroke) = &stroke {
|
||||
self.draw_arc(ctx, f, stroke);
|
||||
}
|
||||
}
|
||||
}
|
||||
NodeKind::Path { path, rule, guide, .. } => {
|
||||
|
|
|
|||
|
|
@ -15,6 +15,11 @@ fn rect(d: &mut Document, r: Rect, c: [f32; 3]) -> Node {
|
|||
Node::path(d.alloc_id(), shapes::rectangle(r), Appearance::basic(Paint::solid(Color::rgb(c[0], c[1], c[2])), Paint::None, 0.0))
|
||||
}
|
||||
|
||||
/// An unpainted clipping path, as Make Clipping Mask leaves it (a painted one paints too).
|
||||
fn clip_path(d: &mut Document, r: Rect) -> Node {
|
||||
Node::path(d.alloc_id(), shapes::rectangle(r), Appearance::basic(Paint::None, Paint::None, 0.0))
|
||||
}
|
||||
|
||||
/// A Multiply rectangle at 20..80 (the group's content).
|
||||
fn multiply(d: &mut Document) -> Node {
|
||||
let mut n = rect(d, Rect::new(20.0, 20.0, 80.0, 80.0), S);
|
||||
|
|
@ -98,7 +103,7 @@ fn a_group_blend_mode_applies_again_to_the_non_isolated_result() {
|
|||
#[test]
|
||||
fn a_clip_group_does_not_isolate_its_children() {
|
||||
let d = doc(|d| {
|
||||
let clip = rect(d, Rect::new(30.0, 30.0, 70.0, 70.0), [0.0; 3]);
|
||||
let clip = clip_path(d, Rect::new(30.0, 30.0, 70.0, 70.0));
|
||||
let m = multiply(d);
|
||||
Node::new(d.alloc_id(), NodeKind::Group { children: vec![Arc::new(clip), Arc::new(m)], clip: true })
|
||||
});
|
||||
|
|
@ -112,7 +117,7 @@ fn a_non_isolated_group_inside_a_clip_group_stays_clipped() {
|
|||
// The half-opaque group copies its backdrop while the clip is in force: the clip still applies
|
||||
// to it and to what follows.
|
||||
let d = doc(|d| {
|
||||
let clip = rect(d, Rect::new(30.0, 30.0, 70.0, 70.0), [0.0; 3]);
|
||||
let clip = clip_path(d, Rect::new(30.0, 30.0, 70.0, 70.0));
|
||||
let m = multiply(d);
|
||||
let g = group(d, vec![m], 0.5, Blend::Normal, false);
|
||||
let after = rect(d, Rect::new(0.0, 60.0, 100.0, 100.0), S);
|
||||
|
|
|
|||
|
|
@ -137,6 +137,9 @@ const BIG: &str = "x=\"-100000\" y=\"-100000\" width=\"200000\" height=\"200000\
|
|||
/// Attributes of every `<mask>`: user-space units, and luminance taken from the sRGB values (as the
|
||||
/// canvas and PDF take it) rather than from linearised ones.
|
||||
const MASK: &str = "maskUnits=\"userSpaceOnUse\" color-interpolation=\"sRGB\"";
|
||||
/// Attribute of an exported opacity mask listing its options that differ from the defaults
|
||||
/// (clipping, not inverted): `noclip`, `invert`.
|
||||
pub(crate) const MASK_FLAGS: &str = "data-vectorcraft-mask";
|
||||
|
||||
pub(crate) fn blend_css(b: BlendMode) -> &'static str {
|
||||
match b {
|
||||
|
|
@ -662,7 +665,10 @@ impl Writer<'_> {
|
|||
self.def(1, "</filter>");
|
||||
fid
|
||||
});
|
||||
self.def(1, &format!("<mask id=\"{mid}\" {MASK} {BIG}>"));
|
||||
// The mask's options, so import restores them (and its art without backdrop and filter).
|
||||
let flags: Vec<&str> = [(!m.clip).then_some("noclip"), m.invert.then_some("invert")].into_iter().flatten().collect();
|
||||
let data = if flags.is_empty() { String::new() } else { format!(" {MASK_FLAGS}=\"{}\"", flags.join(" ")) };
|
||||
self.def(1, &format!("<mask id=\"{mid}\" {MASK} {BIG}{data}>"));
|
||||
if let Some(fid) = &inv {
|
||||
self.def(1, &format!("<g filter=\"url(#{fid})\">"));
|
||||
}
|
||||
|
|
@ -800,11 +806,32 @@ impl Writer<'_> {
|
|||
self.def(1, "</clipPath>");
|
||||
let id = self.id_attr(n);
|
||||
let a = self.attrs(&self.group_props(n));
|
||||
self.line(&format!("<g{id} clip-path=\"url(#{cid})\"{a}>"));
|
||||
// The clipping path's fill paints behind the clipped art and its stroke over it,
|
||||
// outside the clip (the group then wraps both).
|
||||
let paint = clip.clip_paint();
|
||||
if paint.stroke.is_some() {
|
||||
self.line(&format!("<g{id}{a}>"));
|
||||
self.depth += 1;
|
||||
self.line(&format!("<g clip-path=\"url(#{cid})\">"));
|
||||
} else {
|
||||
self.line(&format!("<g{id} clip-path=\"url(#{cid})\"{a}>"));
|
||||
}
|
||||
self.depth += 1;
|
||||
if let Some(fill) = &paint.fill {
|
||||
self.node(fill);
|
||||
}
|
||||
self.group_children(n, rest);
|
||||
self.depth -= 1;
|
||||
self.line("</g>");
|
||||
if let Some(stroke) = &paint.stroke {
|
||||
// One element keeps the clipping path's id.
|
||||
let anonymous = self.anonymous;
|
||||
self.anonymous |= paint.fill.is_some();
|
||||
self.node(stroke);
|
||||
self.anonymous = anonymous;
|
||||
self.depth -= 1;
|
||||
self.line("</g>");
|
||||
}
|
||||
}
|
||||
NodeKind::Path { guide: true, .. } => {}
|
||||
NodeKind::Path { path, rule, .. } => {
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ pub(crate) fn import(svg: &str) -> Result<(Document, Vec<String>), SvgError> {
|
|||
// absolute units is that physical size (usvg resolves it to 96 px per inch, i.e. 4/3 px per pt).
|
||||
let (kx, ky) = physical_scale(svg);
|
||||
let doc = Document::new(size.width() as f64 * kx, size.height() as f64 * ky);
|
||||
let mut im = Importer { doc, warnings: Vec::new() };
|
||||
let mut im = Importer { doc, warnings: Vec::new(), mask_flags: mask_flags(svg) };
|
||||
|
||||
// usvg wraps everything in an id-less group carrying the viewBox transform when needed.
|
||||
let mut top = tree.root();
|
||||
|
|
@ -85,6 +85,25 @@ fn physical_scale(svg: &str) -> (f64, f64) {
|
|||
struct Importer {
|
||||
doc: Document,
|
||||
warnings: Vec<String>,
|
||||
/// Opacity-mask options our export wrote, by mask id (see [`mask_flags`]).
|
||||
mask_flags: HashMap<String, (bool, bool)>,
|
||||
}
|
||||
|
||||
/// The options [`crate::export::MASK_FLAGS`] records on exported `<mask>` elements, by mask id:
|
||||
/// (clip, invert).
|
||||
fn mask_flags(svg: &str) -> HashMap<String, (bool, bool)> {
|
||||
let attr = crate::export::MASK_FLAGS;
|
||||
if !svg.contains(attr) {
|
||||
return HashMap::new();
|
||||
}
|
||||
let Ok(xml) = roxmltree::Document::parse_with_options(svg, roxmltree::ParsingOptions { allow_dtd: true, ..Default::default() }) else {
|
||||
return HashMap::new();
|
||||
};
|
||||
let flags = |n: roxmltree::Node| {
|
||||
let has = |f: &str| n.attribute(attr).is_some_and(|v| v.split_whitespace().any(|x| x == f));
|
||||
Some((n.attribute("id")?.to_string(), (!has("noclip"), has("invert"))))
|
||||
};
|
||||
xml.descendants().filter(|n| n.tag_name().name() == "mask" && n.has_attribute(attr)).filter_map(flags).collect()
|
||||
}
|
||||
|
||||
fn aff(t: usvg::Transform) -> Affine {
|
||||
|
|
@ -223,7 +242,9 @@ impl Importer {
|
|||
Some(n)
|
||||
}
|
||||
|
||||
/// `<mask>` → opacity mask (luminance; alpha masks are approximated by their luminance).
|
||||
/// `<mask>` → opacity mask (luminance; alpha masks are approximated by their luminance). A
|
||||
/// mask our export wrote gets its options back ([`mask_flags`]) and its art without what
|
||||
/// stands for them: the inverting filter group and the backdrop rectangle.
|
||||
fn opacity_mask(&mut self, m: &usvg::Mask, ts: Affine, label: &str) -> Option<Box<vectorcraft_doc::OpacityMask>> {
|
||||
if m.kind() == usvg::MaskType::Alpha {
|
||||
self.warn(format!("alpha mask on {label} imported as a luminance opacity mask"));
|
||||
|
|
@ -231,12 +252,28 @@ impl Importer {
|
|||
if m.mask().is_some() {
|
||||
self.warn(format!("nested mask on {label} ignored"));
|
||||
}
|
||||
let children = self.children(m.root(), ts);
|
||||
if children.is_empty() {
|
||||
return None;
|
||||
let (clip, invert) = self.mask_flags.get(m.id()).copied().unwrap_or((true, false));
|
||||
let (mut nodes, mut ts) = (m.root().children(), ts);
|
||||
if invert
|
||||
&& let [usvg::Node::Group(g)] = nodes
|
||||
&& !g.filters().is_empty()
|
||||
{
|
||||
(nodes, ts) = (g.children(), ts * aff(g.transform()));
|
||||
}
|
||||
let art = self.named("", NodeKind::Group { children, clip: false });
|
||||
Some(Box::new(vectorcraft_doc::OpacityMask::new(art, true)))
|
||||
if (!clip || invert)
|
||||
&& let [usvg::Node::Path(_), rest @ ..] = nodes
|
||||
{
|
||||
nodes = rest;
|
||||
}
|
||||
let mut children: Vec<Arc<Node>> = nodes.iter().filter_map(|c| self.node(c, ts)).map(Arc::new).collect();
|
||||
let art = match children.len() {
|
||||
0 => return None,
|
||||
1 => Arc::unwrap_or_clone(children.pop()?),
|
||||
_ => self.named("", NodeKind::Group { children, clip: false }),
|
||||
};
|
||||
let mut mask = vectorcraft_doc::OpacityMask::new(art, clip);
|
||||
mask.invert = invert;
|
||||
Some(Box::new(mask))
|
||||
}
|
||||
|
||||
fn clip_node(&mut self, cp: &usvg::ClipPath, ts: Affine) -> Option<Node> {
|
||||
|
|
|
|||
|
|
@ -27,7 +27,10 @@
|
|||
//! * One CSS pixel (user unit) is one point, as on export; a root `width`/`height` in absolute units
|
||||
//! (`in`, `cm`, `mm`, `pt`, `pc`) keeps its physical size (72 pt per inch), so a 210 mm SVG opens on
|
||||
//! a 210 mm artboard.
|
||||
//! * Masks and filters are ignored (reported as warnings); nested clip paths use the outer clip only.
|
||||
//! * `<mask>` imports as a luminance opacity mask; a mask we exported keeps its options and art
|
||||
//! (its `data-vectorcraft-mask="noclip invert"` lists the options that differ from clipping and
|
||||
//! not inverted). Filters are ignored (reported as warnings); nested clip paths use the outer
|
||||
//! clip only.
|
||||
//! * usvg only keeps `<text>` when fonts are loaded; we don't load a font database (too expensive and
|
||||
//! unavailable on wasm), so `<text>` elements are read directly from the XML as live point-type
|
||||
//! [`TextObject`]s (type on a path for a `<textPath>`) and placed on top of their layer.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,67 @@
|
|||
//! Opacity-mask options survive SVG (M3.87): exported masks list the options that differ from the
|
||||
//! defaults in `data-vectorcraft-mask`, and import restores them and the original mask art (without
|
||||
//! the backdrop rectangle and inverting filter that draw the options in SVG).
|
||||
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_doc::{Appearance, Document, Node, OpacityMask};
|
||||
use vectorcraft_geom::{Rect, shapes};
|
||||
use vectorcraft_svg::{ExportOptions, export, import_with_report};
|
||||
|
||||
/// A red square masked by white art over its left half.
|
||||
fn masked(clip: bool, invert: bool) -> Document {
|
||||
let mut d = Document::new(200.0, 200.0);
|
||||
let red = Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0);
|
||||
let white = Appearance::basic(Paint::solid(Color::WHITE), Paint::None, 0.0);
|
||||
let mut n = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(10.0, 10.0, 90.0, 90.0)), red);
|
||||
let art = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(10.0, 10.0, 50.0, 90.0)), white);
|
||||
let mut m = OpacityMask::new(art, clip);
|
||||
m.invert = invert;
|
||||
n.mask = Some(Box::new(m));
|
||||
let l = d.layers[0].id;
|
||||
d.insert(Some(l), 0, n).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
fn masks(d: &Document) -> Vec<OpacityMask> {
|
||||
let mut v = vec![];
|
||||
d.walk(|n| v.extend(n.mask.as_deref().cloned()));
|
||||
v
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_inverted_unclipped_mask_round_trips() {
|
||||
for (clip, invert, attr) in
|
||||
[(false, true, Some("noclip invert")), (true, true, Some("invert")), (false, false, Some("noclip")), (true, false, None)]
|
||||
{
|
||||
let s = export(&masked(clip, invert), &ExportOptions::default());
|
||||
match attr {
|
||||
Some(a) => assert!(s.contains(&format!("data-vectorcraft-mask=\"{a}\"")), "{s}"),
|
||||
None => assert!(!s.contains("data-vectorcraft-mask"), "{s}"),
|
||||
}
|
||||
let (back, warnings) = import_with_report(&s).unwrap();
|
||||
assert!(warnings.is_empty(), "{warnings:?}");
|
||||
let [m] = &masks(&back)[..] else { panic!("one masked object: {s}") };
|
||||
assert_eq!((m.clip, m.invert), (clip, invert), "{s}");
|
||||
// The original art: one white rectangle, no backdrop and no filter group around it.
|
||||
assert!(m.art.path_data().is_some(), "{:?}", m.art.kind);
|
||||
let b = m.art.geometric_bounds().unwrap();
|
||||
assert!((b.x0 - 10.0).abs() < 1e-6 && (b.x1 - 50.0).abs() < 1e-6 && (b.y0 - 10.0).abs() < 1e-6 && (b.y1 - 90.0).abs() < 1e-6, "{b:?}");
|
||||
assert_eq!(m.art.appearance.fill_paint(), Paint::solid(Color::WHITE));
|
||||
// Exporting the import again writes the same mask.
|
||||
let again = export(&back, &ExportOptions::default());
|
||||
let mask_def = |s: &str| s.lines().skip_while(|l| !l.contains("<mask id=")).take_while(|l| !l.contains("</mask>")).count();
|
||||
assert_eq!(mask_def(&again), mask_def(&s), "{again}\n{s}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn foreign_masks_keep_their_art() {
|
||||
// Without the attribute a white backdrop is part of the mask art (another app's file).
|
||||
let svg = r#"<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100">
|
||||
<mask id="m" maskUnits="userSpaceOnUse" x="0" y="0" width="100" height="100"><rect width="100" height="100" fill="white"/><rect width="50" height="100" fill="black"/></mask>
|
||||
<g mask="url(#m)"><rect width="100" height="100" fill="red"/></g></svg>"#;
|
||||
let (d, _) = import_with_report(svg).unwrap();
|
||||
let [m] = &masks(&d)[..] else { panic!("one mask") };
|
||||
assert!(m.clip && !m.invert);
|
||||
assert_eq!(m.art.children().map(|c| c.len()), Some(2));
|
||||
}
|
||||
|
|
@ -145,6 +145,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
|||
let painter = ui.painter_at(rect);
|
||||
let Some(st) = app.session.active() else { return };
|
||||
let doc = st.doc.clone();
|
||||
let mask_view = st.shown_mask();
|
||||
|
||||
// Pasteboard, artboard shadows and paper.
|
||||
painter.rect_filled(rect, 0.0, t.pasteboard);
|
||||
|
|
@ -156,7 +157,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
|||
let shift = |d: f32| q.iter().map(|p| *p + vec2(d, d)).collect::<Vec<_>>();
|
||||
painter.add(Shape::convex_polygon(shift(2.0), Color32::from_black_alpha(26), Stroke::NONE));
|
||||
painter.add(Shape::convex_polygon(shift(1.0), Color32::from_black_alpha(52), Stroke::NONE));
|
||||
if app.ui.view.transparency_grid && xf.rot == 0.0 {
|
||||
if st.transparency_grid && xf.rot == 0.0 {
|
||||
checker(&painter, r);
|
||||
} else {
|
||||
painter.add(Shape::convex_polygon(q, Color32::WHITE, Stroke::NONE));
|
||||
|
|
@ -217,6 +218,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
|||
let [r, g, b, _] = c.to_rgba8(1.0);
|
||||
[r, g, b]
|
||||
}),
|
||||
mask_view,
|
||||
..opts
|
||||
};
|
||||
// Light documents render synchronously (no lag vs overlays); heavy ones go to the worker.
|
||||
|
|
|
|||
|
|
@ -38,6 +38,8 @@ mod tests_labels;
|
|||
mod tests_overprint;
|
||||
#[cfg(test)]
|
||||
mod tests_paintchips;
|
||||
#[cfg(test)]
|
||||
mod tests_transparencygrid;
|
||||
|
||||
use std::sync::mpsc::{Receiver, Sender};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
|
|
|||
|
|
@ -125,7 +125,6 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
|
|||
("view.artboards", "Hide Artboards", "Cmd+Shift+H", "{}"),
|
||||
("view.rulers", "Show Rulers", "Cmd+R", "{}"),
|
||||
("view.boundingBox", "Hide Bounding Box", "Cmd+Shift+B", "{}"),
|
||||
("view.transparencyGrid", "Show Transparency Grid", "Cmd+Shift+D", "{}"),
|
||||
("view.guides", "Hide Guides", "Cmd+;", "{}"),
|
||||
("view.smartGuides", "Smart Guides", "Cmd+U", "{}"),
|
||||
("view.grid", "Show Grid", "Cmd+'", "{}"),
|
||||
|
|
@ -470,7 +469,6 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<R
|
|||
"view.artboards" => flag(&mut app.ui.view.artboards),
|
||||
"view.rulers" => flag(&mut app.ui.view.rulers),
|
||||
"view.boundingBox" => flag(&mut app.ui.view.bounding_box),
|
||||
"view.transparencyGrid" => flag(&mut app.ui.view.transparency_grid),
|
||||
"view.guides" => flag(&mut app.ui.view.guides),
|
||||
"view.smartGuides" => flag(&mut app.ui.view.smart_guides),
|
||||
"view.grid" => flag(&mut app.ui.view.grid),
|
||||
|
|
@ -756,7 +754,9 @@ pub fn dynamic_label(app: &VectorcraftApp, id: &str, label: &str) -> String {
|
|||
"view.artboards" => if v.artboards { "Hide Artboards" } else { "Show Artboards" }.into(),
|
||||
"view.rulers" => if v.rulers { "Hide Rulers" } else { "Show Rulers" }.into(),
|
||||
"view.boundingBox" => if v.bounding_box { "Hide Bounding Box" } else { "Show Bounding Box" }.into(),
|
||||
"view.transparencyGrid" => if v.transparency_grid { "Hide Transparency Grid" } else { "Show Transparency Grid" }.into(),
|
||||
"view.transparencyGrid" => {
|
||||
if app.session.active().is_some_and(|d| d.transparency_grid) { "Hide Transparency Grid" } else { "Show Transparency Grid" }.into()
|
||||
}
|
||||
"view.guides" => if v.guides { "Hide Guides" } else { "Show Guides" }.into(),
|
||||
"view.grid" => if v.grid { "Hide Grid" } else { "Show Grid" }.into(),
|
||||
"view.guides.lock" => if app.session.guides_locked() { "Unlock Guides" } else { "Lock Guides" }.into(),
|
||||
|
|
|
|||
|
|
@ -439,6 +439,8 @@ mod tests_effectedit;
|
|||
#[cfg(test)]
|
||||
mod tests_freeform;
|
||||
#[cfg(test)]
|
||||
mod tests_maskview;
|
||||
#[cfg(test)]
|
||||
mod tests_stroke;
|
||||
#[cfg(test)]
|
||||
mod tests_strokedepth;
|
||||
|
|
|
|||
|
|
@ -154,7 +154,8 @@ fn document_sections(app: &mut VectorcraftApp, ui: &mut Ui) {
|
|||
if widgets::icon_button(ui, "grid-3x3", "Show Grid (⌘')", v.grid, 26.0).clicked() {
|
||||
app.run("view.grid", json!({})).ok();
|
||||
}
|
||||
if widgets::icon_button(ui, "square-dashed", "Show Transparency Grid", v.transparency_grid, 26.0).clicked() {
|
||||
let transparency_grid = app.session.active().is_some_and(|d| d.transparency_grid);
|
||||
if widgets::icon_button(ui, "square-dashed", "Show Transparency Grid", transparency_grid, 26.0).clicked() {
|
||||
app.run("view.transparencyGrid", json!({})).ok();
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
//! 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
|
||||
//! Knockout Shape; the menu's Page Isolated Blending and Page Knockout Group.
|
||||
//! opacity-mask controls (make/release, link, clip, invert; Shift-click the mask to disable it,
|
||||
//! Alt-click it to view only the mask), Isolate Blending, the three-state Knockout Group (on →
|
||||
//! 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 serde_json::json;
|
||||
|
|
@ -14,6 +15,9 @@ use crate::theme::Tokens;
|
|||
use crate::widgets::{self, TransparencyEdit, menu_item};
|
||||
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 selection, or the object whose mask is being edited), recomputed only when the document
|
||||
/// 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() {
|
||||
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] };
|
||||
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(mask_w, if editing.is_some() { t.border } else { t.input_border }),
|
||||
StrokeKind::Inside,
|
||||
);
|
||||
// Viewing the mask alone (Alt-click) highlights its thumbnail.
|
||||
let viewing = app.session.active().is_some_and(|d| d.shown_mask().is_some());
|
||||
let border = match (viewing, editing.is_some()) {
|
||||
(true, _) => Stroke::new(2.0, t.accent),
|
||||
(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 {
|
||||
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("Click to edit the mask; Shift-click to disable or enable it").clicked() {
|
||||
let (shift, alt) = ui.input(|i| (i.modifiers.shift, i.modifiers.alt));
|
||||
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 {
|
||||
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() {
|
||||
app.run("transparency.editOpacityMask", json!({"id": id.0})).ok();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -115,7 +115,6 @@ pub struct ViewFlags {
|
|||
pub snap_to_grid: bool,
|
||||
pub snap_to_point: bool,
|
||||
pub artboards: bool,
|
||||
pub transparency_grid: bool,
|
||||
pub edges: bool,
|
||||
/// View → Trim View: hide everything outside the artboards.
|
||||
pub trim_view: bool,
|
||||
|
|
@ -144,7 +143,6 @@ impl Default for ViewFlags {
|
|||
snap_to_grid: false,
|
||||
snap_to_point: true,
|
||||
artboards: true,
|
||||
transparency_grid: false,
|
||||
edges: true,
|
||||
trim_view: false,
|
||||
corner_widgets: true,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,41 @@
|
|||
//! The canvas draws the transparency grid behind the artboards of the documents that show it
|
||||
//! (M3.88), and the View menu item follows the active document.
|
||||
|
||||
use serde_json::json;
|
||||
use vectorcraft_engine::Session;
|
||||
|
||||
use crate::{VectorcraftApp, canvas, menus};
|
||||
|
||||
/// Whether one headless canvas frame paints the grid's grey squares.
|
||||
fn draws_grid(app: &mut VectorcraftApp) -> bool {
|
||||
fn grey(s: &egui::Shape) -> bool {
|
||||
match s {
|
||||
egui::Shape::Rect(r) => r.fill == egui::Color32::from_gray(204),
|
||||
egui::Shape::Vec(v) => v.iter().any(grey),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
let ctx = egui::Context::default();
|
||||
let raw = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(800.0, 600.0))), ..Default::default() };
|
||||
let mut out = ctx.run_ui(raw, |ui| canvas::show(app, ui));
|
||||
out.textures_delta.clear();
|
||||
out.shapes.iter().any(|c| grey(&c.shape))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_canvas_shows_the_grid_only_for_documents_that_turn_it_on() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 200, "height": 200})).unwrap();
|
||||
app.run("file.new", json!({"width": 200, "height": 200})).unwrap();
|
||||
assert!(!draws_grid(&mut app));
|
||||
assert_eq!(menus::dynamic_label(&app, "view.transparencyGrid", ""), "Show Transparency Grid");
|
||||
// The menu's id reaches the engine command.
|
||||
assert_eq!(app.run("view.transparencyGrid", json!({})).unwrap(), json!({"on": true}));
|
||||
assert!(draws_grid(&mut app));
|
||||
assert_eq!(menus::dynamic_label(&app, "view.transparencyGrid", ""), "Hide Transparency Grid");
|
||||
app.run("document.activate", json!({"index": 0})).unwrap();
|
||||
assert!(!draws_grid(&mut app), "the other document keeps its own setting");
|
||||
assert_eq!(menus::dynamic_label(&app, "view.transparencyGrid", ""), "Show Transparency Grid");
|
||||
app.run("document.activate", json!({"index": 1})).unwrap();
|
||||
assert!(draws_grid(&mut app));
|
||||
}
|
||||
14
docs/mcp.md
14
docs/mcp.md
|
|
@ -225,12 +225,24 @@ 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
|
||||
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.
|
||||
|
||||
`view.transparencyGrid {on?}` (View → Show Transparency Grid; default: toggle) is view state of each open document,
|
||||
like the mask view: the grid shows behind the artboards of the documents that turn it on, on the canvas and in
|
||||
`ui.screenshot`, never in exports. It returns `{on}`, isn't undoable and doesn't mark the document changed.
|
||||
|
||||
## Clipping masks
|
||||
|
||||
`object.clippingMask.make` clips the selected objects by the topmost one, which may be a path, a compound path or a
|
||||
text object (it loses its paint). Compound holes, even-odd fills, glyph outlines and the union of a group's members
|
||||
clip alike on screen, in raster export and in SVG and PDF. `object.clippingMask.release` turns the clip group into a
|
||||
plain group and keeps the clipping path, unpainted.
|
||||
plain group and keeps the clipping path with whatever paint it has.
|
||||
|
||||
A clipping path can be painted after Make (`paint.setFill`, `paint.setStroke`, `stroke.set` with its `ids`): its fill
|
||||
paints behind the clipped art and its stroke over it, not clipped, on screen and in SVG and PDF.
|
||||
|
||||
```json
|
||||
{"name":"run_command","arguments":{"command":"select.set","params":{"ids":[12,15]}}}
|
||||
|
|
|
|||
Loading…
Reference in New Issue