M3: opacity masks — model (linked transform), luminance mask rendering, transparency.* commands, SVG <mask> export/import, PDF luminosity soft masks, Transparency panel controls

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Brandon ThomasandClaude Opus 5.5 committed 2026-10-01 00:30:38 -04:00
1 parent c06fad7b2b
commit 5e15994012
13 files changed
+793 -31

No files matched your search

+1 -1
View File
@@ -21,7 +21,7 @@ pub use drawcraft_color as color;
pub use drawcraft_geom as geom;
pub use hit::{Hit, HitKind};
pub use live::{BlendOrientation, BlendSpacing, BlendSpec, EnvelopeKind, GradientMesh, MeshPoint};
pub use node::{ImageObject, LAYER_COLORS, LayerColor, LiveShape, Node, NodeId, NodeKind};
pub use node::{ImageObject, LAYER_COLORS, LayerColor, LiveShape, Node, NodeId, NodeKind, OpacityMask};
pub use pattern::{Overlap, PatternDef, PatternEdit, RepeatKind, RepeatSpec, TileType};
pub use selection::{AnchorRef, Selection};
pub use text::{CharStyle, Justify, ParaStyle, TextKind, TextObject, TextRun};
+34
View File
@@ -257,9 +257,37 @@ pub struct Node {
pub knockout: bool,
#[serde(default)]
pub appearance: Appearance,
/// Opacity mask (Transparency panel). Its art lives here, outside the layer tree.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mask: Option<Box<OpacityMask>>,
pub kind: NodeKind,
}
/// Opacity mask: the luminance of the mask art sets the object's opacity (white = opaque).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct OpacityMask {
/// Mask art in document coordinates.
pub art: Arc<Node>,
/// Clip: outside the mask art is hidden. Off: outside the mask art stays visible.
#[serde(default = "yes")]
pub clip: bool,
/// Invert the mask's luminance.
#[serde(default)]
pub invert: bool,
/// Disabled masks are kept but not applied.
#[serde(default)]
pub disabled: bool,
/// Linked masks move with the object.
#[serde(default = "yes")]
pub linked: bool,
}
impl OpacityMask {
pub fn new(art: Node, clip: bool) -> Self {
Self { art: Arc::new(art), clip, invert: false, disabled: false, linked: true }
}
}
impl Node {
pub fn new(id: NodeId, kind: NodeKind) -> Self {
Self {
@@ -272,6 +300,7 @@ impl Node {
isolate: false,
knockout: false,
appearance: Appearance::default(),
mask: None,
kind,
}
}
@@ -454,6 +483,11 @@ impl Node {
NodeKind::Mesh(m) => m.transform(a),
NodeKind::Repeat(r) => r.transform(a, scale_strokes),
}
if let Some(m) = &mut self.mask
&& m.linked
{
Arc::make_mut(&mut m.art).transform(a, scale_strokes);
}
}
/// Visit this node and all descendants depth first (paint order).
pub fn walk<'a>(&'a self, f: &mut impl FnMut(&'a Node)) {
+4
View File
@@ -19,6 +19,10 @@ pub struct MenuState {
pub saved_selections: Vec<(String, String, Vec<NodeId>)>,
/// View → Guides → Lock Guides.
pub guides_locked: bool,
/// Transparency panel menu: "New Opacity Masks Are Clipping" turned off.
pub new_masks_unclipped: bool,
/// Transparency panel menu: "New Opacity Masks Are Inverted".
pub new_masks_inverted: bool,
}
pub fn specs() -> Vec<CommandSpec> {
+2
View File
@@ -15,6 +15,7 @@ mod layer;
mod live;
pub(crate) mod menucmds;
mod object;
mod opacitymask;
mod paint;
mod panelcmds;
mod path;
@@ -132,6 +133,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
v.extend(path::specs());
v.extend(select::specs());
v.extend(paint::specs());
v.extend(opacitymask::specs());
v.extend(layer::specs());
v.extend(draw2::specs());
v.extend(xform::specs());
+313
View File
@@ -0,0 +1,313 @@
//! Opacity masks (Transparency panel): make, release, enable/disable, link/unlink, clip, invert.
//!
//! The mask art is stored on the masked object ([`drawcraft_doc::OpacityMask`]), outside the
//! layer tree, so it is never hit-tested or selected. Its luminance sets the object's opacity.
use drawcraft_doc::{Node, NodeId, OpacityMask};
use serde_json::{Value, json};
use super::edit::selected_roots;
use super::*;
use crate::EngineError;
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
"transparency.makeOpacityMask",
"Make Opacity Mask",
["Window", "Transparency"],
None,
"{clip?, invert?} the topmost selected object becomes the mask of the others (grouped if several) → {id}",
has_multi,
make
),
cmd!(
"transparency.releaseOpacityMask",
"Release Opacity Mask",
["Window", "Transparency"],
None,
"{} put the mask art back above each masked object",
has_mask,
release
),
cmd!("transparency.disableOpacityMask", "Disable Opacity Mask", ["Window", "Transparency"], None, "{}", has_mask, |s, _| set_flags(
s,
"Disable Opacity Mask",
&json!({ "disabled": true })
)),
cmd!("transparency.enableOpacityMask", "Enable Opacity Mask", ["Window", "Transparency"], None, "{}", has_mask, |s, _| set_flags(
s,
"Enable Opacity Mask",
&json!({ "disabled": false })
)),
cmd!(
"transparency.unlinkOpacityMask",
"Unlink Opacity Mask",
["Window", "Transparency"],
None,
"{} the object moves without its mask",
has_mask,
|s, _| set_flags(s, "Unlink Opacity Mask", &json!({ "linked": false }))
),
cmd!("transparency.linkOpacityMask", "Link Opacity Mask", ["Window", "Transparency"], None, "{}", has_mask, |s, _| set_flags(
s,
"Link Opacity Mask",
&json!({ "linked": true })
)),
cmd!(
"transparency.setOpacityMask",
"Opacity Mask Options",
[],
None,
"{clip?, invert?, disabled?, linked?} change the selected objects' opacity masks",
has_mask,
|s, p| set_flags(s, "Opacity Mask Options", p)
),
cmd!(
"transparency.toggleNewMasksClipping",
"New Opacity Masks Are Clipping",
["Window", "Transparency"],
None,
"{value?} → {value}",
always,
toggle_new_clip
),
cmd!(
"transparency.toggleNewMasksInverted",
"New Opacity Masks Are Inverted",
["Window", "Transparency"],
None,
"{value?} → {value}",
always,
toggle_new_invert
),
cmd!(query "transparency.opacityMaskInfo", "Opacity Mask Info", [], None, "{} → [{id, clip, invert, disabled, linked}] masks of the selected objects", has_doc, info),
]
}
fn has_mask(s: &Session) -> std::result::Result<(), String> {
has_selection(s)?;
let st = s.active().unwrap();
if st.selection.in_paint_order(&st.doc).into_iter().any(|id| st.doc.node(id).is_some_and(|n| n.mask.is_some())) {
Ok(())
} else {
Err("no selected object has an opacity mask".into())
}
}
fn masked_ids(s: &Session) -> Result<Vec<NodeId>> {
let st = s.doc()?;
Ok(selected_roots(s)?.into_iter().filter(|id| st.doc.node(*id).is_some_and(|n| n.mask.is_some())).collect())
}
fn make(s: &mut Session, p: &Value) -> Result<Value> {
let ids = selected_roots(s)?;
if ids.len() < 2 {
return Err(bad("transparency.makeOpacityMask", "select the art and, on top, the mask object"));
}
let clip = bool_or(p, "clip", !s.menu.new_masks_unclipped);
let invert = bool_or(p, "invert", s.menu.new_masks_inverted);
let (mask_id, art) = (ids[ids.len() - 1], &ids[..ids.len() - 1]);
let id = s.edit("Make Opacity Mask", |d, sel| {
let mask_art = (*d.remove(mask_id)?).clone();
// Several objects are grouped so they share one mask.
let target = if let [one] = art {
*one
} else {
let last = art[art.len() - 1];
let (par, idx, _) = d.position(last).ok_or(EngineError::NoNode(last))?;
let gid = d.alloc_id();
d.insert(par, idx + 1, Node::group(gid, vec![]))?;
for id in art {
d.move_node(*id, Some(gid), usize::MAX)?;
}
gid
};
let n = d.node_mut(target).ok_or(EngineError::NoNode(target))?;
if n.mask.is_some() {
return Err(EngineError::Other("the object already has an opacity mask".into()));
}
let mut m = OpacityMask::new(mask_art, clip);
m.invert = invert;
n.mask = Some(Box::new(m));
sel.set([target]);
Ok(target)
})?;
Ok(json!({ "id": id.0 }))
}
fn release(s: &mut Session, _: &Value) -> Result<Value> {
let ids = masked_ids(s)?;
s.edit("Release Opacity Mask", |d, sel| {
let mut out = vec![];
for id in &ids {
let Some(m) = d.node_mut(*id).and_then(|n| n.mask.take()) else { continue };
let (par, idx, _) = d.position(*id).ok_or(EngineError::NoNode(*id))?;
// Fresh ids: copies of a masked object share the ids inside their mask art.
let art = d.reid(&m.art);
out.push(*id);
out.push(d.insert(par, idx + 1, art)?);
}
sel.set(out);
Ok(())
})?;
ok()
}
fn set_flags(s: &mut Session, label: &str, p: &Value) -> Result<Value> {
let ids = masked_ids(s)?;
let get = |k: &str| p.get(k).and_then(Value::as_bool);
let (clip, invert, disabled, linked) = (get("clip"), get("invert"), get("disabled"), get("linked"));
if clip.is_none() && invert.is_none() && disabled.is_none() && linked.is_none() {
return Err(bad("transparency.setOpacityMask", "give at least one of clip, invert, disabled, linked"));
}
s.edit(label, |d, _| {
for id in &ids {
if let Some(m) = d.node_mut(*id).and_then(|n| n.mask.as_mut()) {
m.clip = clip.unwrap_or(m.clip);
m.invert = invert.unwrap_or(m.invert);
m.disabled = disabled.unwrap_or(m.disabled);
m.linked = linked.unwrap_or(m.linked);
}
}
Ok(())
})?;
ok()
}
fn toggle_new_clip(s: &mut Session, p: &Value) -> Result<Value> {
let v = bool_or(p, "value", s.menu.new_masks_unclipped);
s.menu.new_masks_unclipped = !v;
Ok(json!({ "value": v }))
}
fn toggle_new_invert(s: &mut Session, p: &Value) -> Result<Value> {
let v = bool_or(p, "value", !s.menu.new_masks_inverted);
s.menu.new_masks_inverted = v;
Ok(json!({ "value": v }))
}
impl Session {
/// Defaults for new opacity masks (clip, invert) from the Transparency panel menu.
pub fn new_mask_defaults(&self) -> (bool, bool) {
(!self.menu.new_masks_unclipped, self.menu.new_masks_inverted)
}
}
fn info(s: &mut Session, _: &Value) -> Result<Value> {
let st = s.doc()?;
let v: Vec<Value> = st
.selection
.in_paint_order(&st.doc)
.into_iter()
.filter_map(|id| st.doc.node(id))
.filter_map(|n| {
let m = n.mask.as_deref()?;
Some(json!({ "id": n.id.0, "clip": m.clip, "invert": m.invert, "disabled": m.disabled, "linked": m.linked, "art": m.art.id.0 }))
})
.collect();
Ok(json!(v))
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn session_with_two() -> (Session, u64, u64) {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap();
let a = s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 100, "height": 100})).unwrap()["id"].as_u64().unwrap();
let b = s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 50, "height": 100})).unwrap()["id"].as_u64().unwrap();
(s, a, b)
}
fn select(s: &mut Session, ids: &[u64]) {
s.execute("select.set", &json!({ "ids": ids })).unwrap();
}
#[test]
fn make_moves_top_object_into_mask() {
let (mut s, a, b) = session_with_two();
select(&mut s, &[a, b]);
let r = s.execute("transparency.makeOpacityMask", &json!({})).unwrap();
assert_eq!(r["id"].as_u64(), Some(a));
let d = &s.doc().unwrap().doc;
assert!(d.node(NodeId(b)).is_none(), "mask art left the layer tree");
let m = d.node(NodeId(a)).unwrap().mask.as_deref().unwrap();
assert!(m.clip && m.linked && !m.invert && !m.disabled);
assert_eq!(m.art.id, NodeId(b));
}
#[test]
fn make_groups_several_objects() {
let (mut s, a, b) = session_with_two();
let c = s.execute("shape.ellipse", &json!({"x": 0, "y": 0, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap();
select(&mut s, &[a, b, c]);
let g = s.execute("transparency.makeOpacityMask", &json!({"clip": false})).unwrap()["id"].as_u64().unwrap();
let d = &s.doc().unwrap().doc;
let n = d.node(NodeId(g)).unwrap();
assert_eq!(n.children().unwrap().len(), 2);
assert!(!n.mask.as_ref().unwrap().clip);
}
#[test]
fn release_restores_mask_art_and_undo_restores_mask() {
let (mut s, a, b) = session_with_two();
select(&mut s, &[a, b]);
s.execute("transparency.makeOpacityMask", &json!({})).unwrap();
s.execute("transparency.releaseOpacityMask", &json!({})).unwrap();
let st = s.doc().unwrap();
assert!(st.doc.node(NodeId(a)).unwrap().mask.is_none());
assert_eq!(st.selection.len(), 2);
assert_eq!(st.doc.layers[0].children().unwrap().len(), 2);
s.execute("edit.undo", &json!({})).unwrap();
assert!(s.doc().unwrap().doc.node(NodeId(a)).unwrap().mask.is_some());
}
#[test]
fn flags_and_linking() {
let (mut s, a, b) = session_with_two();
select(&mut s, &[a, b]);
s.execute("transparency.makeOpacityMask", &json!({})).unwrap();
s.execute("transparency.setOpacityMask", &json!({"invert": true, "clip": false})).unwrap();
s.execute("transparency.disableOpacityMask", &json!({})).unwrap();
s.execute("transparency.unlinkOpacityMask", &json!({})).unwrap();
let info = s.execute("transparency.opacityMaskInfo", &json!({})).unwrap();
assert_eq!(info[0]["invert"], true);
assert_eq!(info[0]["clip"], false);
assert_eq!(info[0]["disabled"], true);
assert_eq!(info[0]["linked"], false);
// Unlinked: moving the object leaves the mask art where it was.
let before = s.doc().unwrap().doc.node(NodeId(a)).unwrap().mask.as_ref().unwrap().art.clone();
s.execute("object.move", &json!({"dx": 30, "dy": 0})).unwrap();
let after = s.doc().unwrap().doc.node(NodeId(a)).unwrap().mask.as_ref().unwrap().art.clone();
assert_eq!(before.geometric_bounds(), after.geometric_bounds());
s.execute("transparency.linkOpacityMask", &json!({})).unwrap();
s.execute("object.move", &json!({"dx": 30, "dy": 0})).unwrap();
let moved = s.doc().unwrap().doc.node(NodeId(a)).unwrap().mask.as_ref().unwrap().art.clone();
assert!((moved.geometric_bounds().unwrap().x0 - after.geometric_bounds().unwrap().x0 - 30.0).abs() < 1e-9);
}
#[test]
fn new_masks_clipping_toggle() {
let (mut s, a, b) = session_with_two();
assert_eq!(s.execute("transparency.toggleNewMasksClipping", &json!({})).unwrap()["value"], false);
select(&mut s, &[a, b]);
s.execute("transparency.makeOpacityMask", &json!({})).unwrap();
assert!(!s.doc().unwrap().doc.node(NodeId(a)).unwrap().mask.as_ref().unwrap().clip);
s.execute("transparency.toggleNewMasksInverted", &json!({})).unwrap();
assert_eq!(s.new_mask_defaults(), (false, true));
}
#[test]
fn masks_survive_native_round_trip() {
let (mut s, a, b) = session_with_two();
select(&mut s, &[a, b]);
s.execute("transparency.makeOpacityMask", &json!({"invert": true})).unwrap();
let d = s.doc().unwrap().doc.clone();
let bytes = drawcraft_format::save(&d, false);
let back = drawcraft_format::load(&bytes).unwrap();
assert_eq!(back.node(NodeId(a)).unwrap().mask, d.node(NodeId(a)).unwrap().mask);
}
}
+38
View File
@@ -315,6 +315,37 @@ impl Exporter<'_> {
}
}
/// Opacity mask → luminosity soft mask. Outside the art the backdrop is black (clip) or white;
/// invert is a white Difference rect on top (luminance is linear, so luma(1 − c) = 1 − luma(c)).
fn soft_mask(&mut self, s: &mut Surface, m: &drawcraft_doc::OpacityMask, page: Rect) -> krilla::mask::Mask {
let backdrop = to_path(&page.inflate(1.0, 1.0).to_path(0.1));
let white = || Fill { paint: rgb::Color::new(255, 255, 255).into(), opacity: NormalizedF32::ONE, rule: krilla::paint::FillRule::NonZero };
let mut sb = s.stream_builder();
let mut ms = sb.surface();
// An opaque backdrop when it matters: white outside the art (no clip), or black for the
// inverting Difference pass below to turn white.
if let Some(bp) = &backdrop
&& (!m.clip || m.invert)
{
let c = if m.clip { rgb::Color::new(0, 0, 0) } else { rgb::Color::new(255, 255, 255) };
ms.set_stroke(None);
ms.set_fill(Some(Fill { paint: c.into(), opacity: NormalizedF32::ONE, rule: krilla::paint::FillRule::NonZero }));
ms.draw_path(bp);
}
self.node(&mut ms, &m.art, page, true);
if let Some(bp) = &backdrop
&& m.invert
{
ms.push_blend_mode(krilla::blend::BlendMode::Difference);
ms.set_stroke(None);
ms.set_fill(Some(white()));
ms.draw_path(bp);
ms.pop();
}
ms.finish();
krilla::mask::Mask::new(sb.finish(), krilla::mask::MaskType::Luminosity)
}
fn node(&mut self, s: &mut Surface, n: &Node, page: Rect, force: bool) {
if !force && !n.visible {
return;
@@ -346,6 +377,13 @@ impl Exporter<'_> {
if n.knockout {
self.warn("knockout groups are exported as normal groups");
}
if let Some(m) = n.mask.as_deref()
&& !m.disabled
{
let mask = self.soft_mask(s, m, page);
s.push_mask(mask);
pushes += 1;
}
match &n.kind {
NodeKind::Layer { children, .. } | NodeKind::Group { children, clip: false } => {
for c in children {
+13
View File
@@ -532,3 +532,16 @@ fn creation_date_written() {
let bytes = export(&d, &PdfOptions { created: Some(1_709_210_096), compress: false, ..Default::default() }).unwrap();
assert!(String::from_utf8_lossy(&bytes).contains("D:20240229123456"));
}
#[test]
fn opacity_mask_exports_as_luminosity_soft_mask() {
let mut d = doc(100.0, 100.0);
let mut n = rect_node(Rect::new(10.0, 10.0, 90.0, 90.0), Color::rgb(1.0, 0.0, 0.0));
let art = rect_node(Rect::new(10.0, 10.0, 50.0, 90.0), Color::WHITE);
n.mask = Some(Box::new(drawcraft_doc::OpacityMask::new(art, true)));
add(&mut d, n);
let bytes = export(&d, &PdfOptions { compress: false, ..Default::default() }).unwrap();
let text = String::from_utf8_lossy(&bytes);
assert!(text.contains("/SMask"), "soft mask in an ExtGState");
assert!(text.contains("/Luminosity"));
}
+50 -1
View File
@@ -268,12 +268,21 @@ impl Renderer {
/// Render a single node (thumbnails, previews) fitted into `size`×`size` pixels.
pub fn render_thumbnail(&mut self, doc: &Document, id: NodeId, size: u32) -> Option<Rendered> {
let n = doc.node(id)?;
self.render_node_thumbnail(doc, doc.node(id)?, size, None)
}
/// Render any node (also one outside the tree, e.g. opacity-mask art) fitted into
/// `size`×`size` pixels, optionally over a solid premultiplied background.
pub fn render_node_thumbnail(&mut self, doc: &Document, n: &Node, size: u32, background: Option<[u8; 4]>) -> Option<Rendered> {
let b = brush_fx::cull_bounds(n)?;
let s = (size as f64 - 2.0) / b.width().max(b.height()).max(1e-6);
let view = Affine::translate((size as f64 / 2.0, size as f64 / 2.0)) * Affine::scale(s) * Affine::translate(-b.center().to_vec2());
let w = size.clamp(1, u16::MAX as u32) as u16;
let mut ctx = single_threaded_context(w, w);
if let Some(bg) = background {
ctx.set_paint(peniko::Color::from_rgba8(bg[0], bg[1], bg[2], bg[3]));
ctx.fill_rect(&kurbo::Rect::new(0.0, 0.0, w as f64, w as f64));
}
let frame = Frame { doc, view, visible: b.inflate(1.0, 1.0), px: 1.0 / s, opts: &RenderOptions::default() };
self.draw_node(&mut ctx, &frame, n, true);
ctx.flush();
@@ -342,6 +351,33 @@ impl Renderer {
None if !a.is_container() => return,
None => {}
}
if let Some(m) = a.mask.as_deref()
&& !m.disabled
&& !f.opts.outline
{
let mask = self.opacity_mask(f, m, ctx.width(), ctx.height());
ctx.set_transform(Affine::IDENTITY);
ctx.push_layer(None, None, None, Some(mask), None);
self.draw_arc_body(ctx, f, a);
ctx.pop_layer();
return;
}
self.draw_arc_body(ctx, f, a);
}
/// Render an opacity mask's art offscreen and turn its luminance into a coverage mask.
fn opacity_mask(&mut self, f: &Frame, m: &drawcraft_doc::OpacityMask, w: u16, h: u16) -> vello_cpu::Mask {
let mut mctx = single_threaded_context(w, h);
self.draw_node(&mut mctx, f, &m.art, true);
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)
}
/// Everything [`Self::draw_arc`] does after culling.
fn draw_arc_body(&mut self, ctx: &mut RenderContext, f: &Frame, a: &Arc<Node>) {
// Fast path for plain paths: cached geometry, opacity folded into the paint.
if let NodeKind::Path { rule, guide: false, .. } = &a.kind
&& !f.opts.outline
@@ -696,6 +732,19 @@ impl Renderer {
/// Number of visible painted fill/stroke items (opacity folding is exact only for one).
/// A render context on the calling thread. vello_cpu's `RenderContext::new` defaults to a
/// multithreaded dispatcher, which panics on filter effects (glows, shadows, blur).
/// Opacity-mask coverage of one premultiplied pixel: luminance, with the area outside the mask
/// art black (clip) or white (no clip), optionally inverted.
fn mask_value(r: u8, g: u8, b: u8, a: u8, clip: bool, invert: bool) -> u8 {
let mut l = (0.2126 * r as f32 + 0.7152 * g as f32 + 0.0722 * b as f32) / 255.0;
if !clip {
l += 1.0 - a as f32 / 255.0;
}
if invert {
l = 1.0 - l;
}
(l.clamp(0.0, 1.0) * 255.0 + 0.5) as u8
}
pub(crate) fn single_threaded_context(w: u16, h: u16) -> RenderContext {
RenderContext::new_with(w, h, vello_cpu::RenderSettings { num_threads: 0, ..Default::default() })
}
+62
View File
@@ -195,3 +195,65 @@ fn arrowheads_render() {
use drawcraft_doc::Arrowhead;
use drawcraft_geom::Point;
/// A red 10..90 square masked by a white square covering only its left half (10..50),
/// plus a mid-grey strip (50..70) — rendered both multi- and single-threaded.
fn masked_doc(clip: bool, invert: bool) -> Document {
let mut d = Document::new(100.0, 100.0);
let mut n = rect_node(&mut d, Rect::new(10.0, 10.0, 90.0, 90.0), Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0);
let white = rect_node(&mut d, Rect::new(10.0, 10.0, 50.0, 90.0), Paint::solid(Color::WHITE), Paint::None, 0.0);
let grey = rect_node(&mut d, Rect::new(50.0, 10.0, 70.0, 90.0), Paint::solid(Color::rgb(0.5, 0.5, 0.5)), Paint::None, 0.0);
let gid = d.alloc_id();
let art = Node::group(gid, vec![Arc::new(white), Arc::new(grey)]);
let mut m = drawcraft_doc::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
}
#[test]
fn opacity_mask_uses_luminance_and_clips() {
for threads in [0, 2] {
let mut r = Renderer::new();
r.threads = threads;
let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
let img = r.render(&masked_doc(true, false), 100, 100, Affine::IDENTITY, &opts);
assert_eq!(img.pixel(30, 50), [255, 0, 0, 255], "white mask → opaque ({threads} threads)");
let mid = img.pixel(60, 50);
assert!((100..160).contains(&mid[1]), "grey mask → about half opacity, got {mid:?}");
assert_eq!(img.pixel(80, 50), [255, 255, 255, 255], "clip hides outside the mask art");
}
}
#[test]
fn opacity_mask_without_clip_and_inverted() {
let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
let img = Renderer::new().render(&masked_doc(false, false), 100, 100, Affine::IDENTITY, &opts);
assert_eq!(img.pixel(80, 50), [255, 0, 0, 255], "no clip: outside the mask art stays visible");
let img = Renderer::new().render(&masked_doc(true, true), 100, 100, Affine::IDENTITY, &opts);
assert_eq!(img.pixel(30, 50), [255, 255, 255, 255], "inverted white → hidden");
assert_eq!(img.pixel(80, 50), [255, 0, 0, 255], "inverted clip background → visible");
}
#[test]
fn disabled_or_outline_mask_draws_unmasked() {
let mut d = masked_doc(true, false);
let id = d.layers[0].children().unwrap()[0].id;
let n = d.node_mut(id).unwrap();
n.mask.as_mut().unwrap().disabled = true;
let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
let img = Renderer::new().render(&d, 100, 100, Affine::IDENTITY, &opts);
assert_eq!(img.pixel(80, 50), [255, 0, 0, 255]);
}
#[test]
fn linked_mask_moves_with_object() {
let mut d = masked_doc(true, false);
let id = d.layers[0].children().unwrap()[0].id;
d.node_mut(id).unwrap().transform(Affine::translate((5.0, 0.0)), false);
let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
let img = Renderer::new().render(&d, 100, 100, Affine::IDENTITY, &opts);
assert_eq!(img.pixel(52, 50), [255, 0, 0, 255], "mask art moved by 5 too");
}
+44
View File
@@ -543,10 +543,54 @@ impl Writer<'_> {
}
}
/// An object with an opacity mask: `<g mask="url(#…)">` around the unmasked object. The mask
/// art goes in `<defs>`; no-clip adds a white backdrop and invert a colour-inverting filter.
fn masked(&mut self, n: &Node, m: &drawcraft_doc::OpacityMask) {
const BIG: &str = "x=\"-100000\" y=\"-100000\" width=\"200000\" height=\"200000\"";
let mid = self.fresh_id("mask");
let (body, depth) = (std::mem::take(&mut self.body), self.depth);
self.depth = 2;
self.node(&m.art);
let art = std::mem::replace(&mut self.body, body);
self.depth = depth;
let inv = m.invert.then(|| {
let fid = self.fresh_id("invert");
self.def(1, &format!("<filter id=\"{fid}\" filterUnits=\"userSpaceOnUse\" color-interpolation-filters=\"sRGB\" {BIG}>"));
self.def(2, "<feColorMatrix type=\"matrix\" values=\"-1 0 0 0 1 0 -1 0 0 1 0 0 -1 0 1 0 0 0 1 0\"/>");
self.def(1, "</filter>");
fid
});
self.def(1, &format!("<mask id=\"{mid}\" maskUnits=\"userSpaceOnUse\" {BIG}>"));
if let Some(fid) = &inv {
self.def(1, &format!("<g filter=\"url(#{fid})\">"));
}
if !m.clip || m.invert {
let c = if m.clip { "black" } else { "white" };
self.def(1, &format!("<rect {BIG} fill=\"{c}\"/>"));
}
self.defs.push_str(&art);
if inv.is_some() {
self.def(1, "</g>");
}
self.def(1, "</mask>");
self.line(&format!("<g mask=\"url(#{mid})\">"));
self.depth += 1;
let mut bare = n.clone();
bare.mask = None;
self.node(&bare);
self.depth -= 1;
self.line("</g>");
}
fn node(&mut self, n: &Node) {
if !n.visible {
return;
}
if let Some(m) = n.mask.as_deref()
&& !m.disabled
{
return self.masked(n, m);
}
match &n.kind {
NodeKind::Layer { template: true, .. } => {}
NodeKind::Layer { children, .. } | NodeKind::Group { children, clip: false } => {
+23 -4
View File
@@ -166,9 +166,7 @@ impl Importer {
fn group(&mut self, g: &usvg::Group, acc: Affine) -> Option<Node> {
let ts = acc * aff(g.transform());
let label = if g.id().is_empty() { "a group".to_string() } else { format!("'{}'", g.id()) };
if g.mask().is_some() {
self.warn(format!("mask on {label} ignored"));
}
let mask = g.mask().and_then(|m| self.opacity_mask(m, ts, &label));
if !g.filters().is_empty() {
self.warn(format!("filter on {label} ignored"));
}
@@ -187,7 +185,11 @@ impl Importer {
}
// An id-less wrapper around a single object (usvg adds these for opacity/transform on
// shapes): fold its opacity and blend into the object.
if g.id().is_empty() && children.len() == 1 && (g.blend_mode() == usvg::BlendMode::Normal || children[0].blend == BlendMode::Normal) {
if g.id().is_empty()
&& mask.is_none()
&& children.len() == 1
&& (g.blend_mode() == usvg::BlendMode::Normal || children[0].blend == BlendMode::Normal)
{
let mut c = Arc::unwrap_or_clone(children.into_iter().next().unwrap());
c.opacity *= g.opacity().get();
if g.blend_mode() != usvg::BlendMode::Normal {
@@ -201,9 +203,26 @@ impl Importer {
n.opacity = g.opacity().get();
n.blend = blend(g.blend_mode());
n.isolate = g.isolate();
n.mask = mask;
Some(n)
}
/// `<mask>` → opacity mask (luminance; alpha masks are approximated by their luminance).
fn opacity_mask(&mut self, m: &usvg::Mask, ts: Affine, label: &str) -> Option<Box<drawcraft_doc::OpacityMask>> {
if m.kind() == usvg::MaskType::Alpha {
self.warn(format!("alpha mask on {label} imported as a luminance opacity mask"));
}
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 art = self.named("", NodeKind::Group { children, clip: false });
Some(Box::new(drawcraft_doc::OpacityMask::new(art, true)))
}
fn clip_node(&mut self, cp: &usvg::ClipPath, ts: Affine) -> Option<Node> {
fn collect(g: &usvg::Group, m: Affine, out: &mut Vec<(BezPath, FillRule)>) {
for c in g.children() {
+47 -3
View File
@@ -309,8 +309,9 @@ fn stroke_alignment_approximations() {
let b = rect_node(&mut d, Rect::new(60.0, 0.0, 110.0, 50.0), ap);
let s = export(&doc_with(vec![a, b]), &ExportOptions::default());
assert!(s.contains("<clipPath") && s.contains("<mask") && s.contains("stroke-width=\"8\""), "{s}");
let (_, warnings) = import_with_report(&s).unwrap();
assert!(warnings.iter().any(|w| w.contains("mask")));
// The outside stroke's mask comes back as an opacity mask (same pixels, no warning).
let (r, warnings) = import_with_report(&s).unwrap();
assert!(art(&r).iter().any(|n| n.mask.is_some()), "{warnings:?}");
}
#[test]
@@ -581,7 +582,8 @@ fn import_clip_mask_and_warnings() {
let a = art(&d);
assert!(matches!(a[0].kind, NodeKind::Group { clip: true, .. }));
assert!(close_rect(a[0].geometric_bounds().unwrap(), Rect::new(25.0, 25.0, 75.0, 75.0), 0.01));
assert!(w.iter().any(|w| w.contains("mask")), "{w:?}");
assert!(a.iter().any(|n| n.mask.is_some()), "<mask> imports as an opacity mask");
assert!(!w.iter().any(|w| w.contains("mask")), "{w:?}");
assert!(w.iter().any(|w| w.contains("pattern")), "{w:?}");
}
@@ -638,3 +640,45 @@ fn full_document_roundtrip_is_stable() {
assert_eq!(strip(&s1b), strip(&s2));
let _ = PathData::default();
}
fn masked_doc(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 = rect_node(&mut d, Rect::new(10.0, 10.0, 90.0, 90.0), red);
let art = rect_node(&mut d, Rect::new(10.0, 10.0, 50.0, 90.0), white);
let mut m = drawcraft_doc::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
}
#[test]
fn opacity_mask_exports_as_svg_mask_and_imports_back() {
let d = masked_doc(true, false);
let s = export(&d, &ExportOptions::default());
assert!(s.contains("<mask id=\"mask-1\""), "{s}");
assert!(s.contains("mask=\"url(#mask-1)\""), "{s}");
let back = import(&s).unwrap();
let masked: Vec<&Node> = art(&back).into_iter().filter(|n| n.mask.is_some()).collect();
assert_eq!(masked.len(), 1, "{s}");
let m = masked[0].mask.as_deref().unwrap();
assert!(close_rect(m.art.geometric_bounds().unwrap(), Rect::new(10.0, 10.0, 50.0, 90.0), 1e-6));
assert!(close_rect(masked[0].geometric_bounds().unwrap(), Rect::new(10.0, 10.0, 90.0, 90.0), 1e-6));
}
#[test]
fn unclipped_inverted_mask_exports_backdrop_and_filter() {
let s = export(&masked_doc(false, true), &ExportOptions::default());
assert!(s.contains("<feColorMatrix"), "{s}");
assert!(s.contains("fill=\"white\""), "{s}");
let disabled = {
let mut d = masked_doc(true, false);
let id = d.layers[0].children().unwrap()[0].id;
d.node_mut(id).unwrap().mask.as_mut().unwrap().disabled = true;
export(&d, &ExportOptions::default())
};
assert!(!disabled.contains("<mask"), "disabled masks are not exported");
}
+162 -22
View File
@@ -1,5 +1,6 @@
//! Transparency panel: blend mode, opacity (field + slider popup), object/mask thumbnails with the
//! opacity-mask controls (on the roadmap), Isolate Blending and Knockout Group.
//! opacity-mask controls (make/release, link, clip, invert; Shift-click the mask to disable it),
//! Isolate Blending and Knockout Group.
use drawcraft_color::{BlendMode, Paint};
use egui::{Sense, Stroke, StrokeKind, Ui, vec2};
@@ -49,31 +50,78 @@ pub fn show(app: &mut DrawcraftApp, ui: &mut Ui) {
widgets::divider(ui);
// Thumbnails and the opacity-mask controls.
let hide_thumbs: bool = pstate(ui.ctx(), "tr-hide-thumbs");
let mask = n.as_ref().and_then(|n| n.mask.as_deref().cloned());
let (new_clip, new_invert) = app.session.new_mask_defaults();
ui.horizontal(|ui| {
if !hide_thumbs {
let (r, _) = ui.allocate_exact_size(vec2(60.0, 50.0), Sense::hover());
ui.painter().rect_filled(r, 0.0, egui::Color32::WHITE);
ui.painter().rect_stroke(r, 0.0, Stroke::new(1.5, t.border), StrokeKind::Outside);
if has {
let (f, s) = current_paints(app);
let inner = r.shrink2(vec2(6.0, 10.0));
widgets::paint_chip(ui, inner, &f);
if let Some(c) = s.color()
&& !matches!(s, Paint::None)
{
ui.painter().rect_stroke(inner, 0.0, Stroke::new(1.0, super::c32(&c)), StrokeKind::Middle);
if let Some(n) = &n {
let mut bare = n.clone();
bare.mask = None;
if !node_thumb(app, ui, "tr-obj", &bare, r.shrink(3.0), None) {
let (f, s) = current_paints(app);
let inner = r.shrink2(vec2(6.0, 10.0));
widgets::paint_chip(ui, inner, &f);
if let Some(c) = s.color()
&& !matches!(s, Paint::None)
{
ui.painter().rect_stroke(inner, 0.0, Stroke::new(1.0, super::c32(&c)), StrokeKind::Middle);
}
}
}
match &mask {
Some(m) => {
// Link toggle between the object and its mask.
let (lr, lresp) = ui.allocate_exact_size(vec2(14.0, 50.0), Sense::click());
icons::paint(
ui,
if m.linked { "link" } else { "link-2-off" },
egui::Rect::from_center_size(lr.center(), vec2(12.0, 12.0)),
t.icon,
);
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 bg = if m.clip != m.invert { [0, 0, 0, 255] } else { [255, 255, 255, 255] };
ui.painter().rect_filled(mr, 0.0, egui::Color32::from_rgb(bg[0], bg[1], bg[2]));
node_thumb(app, ui, "tr-mask", &m.art, mr.shrink(2.0), Some(bg));
ui.painter().rect_stroke(mr, 0.0, Stroke::new(1.0, t.input_border), StrokeKind::Inside);
if m.disabled {
let red = Stroke::new(2.0, egui::Color32::from_rgb(220, 40, 40));
ui.painter().line_segment([mr.left_top(), mr.right_bottom()], red);
ui.painter().line_segment([mr.right_top(), mr.left_bottom()], red);
}
let shift = ui.input(|i| i.modifiers.shift);
if mresp.on_hover_text("Shift-click to disable or enable the mask").clicked() && shift {
app.run(if m.disabled { "transparency.enableOpacityMask" } else { "transparency.disableOpacityMask" }, json!({})).ok();
}
}
None => {
let (m, _) = ui.allocate_exact_size(vec2(50.0, 50.0), Sense::hover());
ui.painter().rect_stroke(m, 0.0, Stroke::new(1.0, t.input_border), StrokeKind::Inside);
icons::paint(ui, "dc-mask-none", m.shrink(12.0), t.text_disabled);
}
}
let (m, _) = ui.allocate_exact_size(vec2(50.0, 50.0), Sense::hover());
ui.painter().rect_stroke(m, 0.0, Stroke::new(1.0, t.input_border), StrokeKind::Inside);
icons::paint(ui, "dc-mask-none", m.shrink(12.0), t.text_disabled);
}
ui.vertical(|ui| {
ui.add_enabled_ui(false, |ui| widgets::flat_button(ui, "Make Mask", 96.0))
.inner
.on_disabled_hover_text("Opacity masks are on the roadmap");
widgets::check(ui, "Clip", true, false);
widgets::check(ui, "Invert Mask", false, false);
let can_make = selection_len(app) >= 2;
let label = if mask.is_some() { "Release" } else { "Make Mask" };
let enabled = mask.is_some() || can_make;
let r = ui.add_enabled_ui(enabled, |ui| widgets::flat_button(ui, label, 96.0)).inner;
if r.on_disabled_hover_text("Select the art and, on top of it, the mask object").clicked() {
let id = if mask.is_some() { "transparency.releaseOpacityMask" } else { "transparency.makeOpacityMask" };
app.run(id, json!({})).ok();
}
let (clip, invert) = mask.as_ref().map(|m| (m.clip, m.invert)).unwrap_or((new_clip, new_invert));
if widgets::check(ui, "Clip", clip, mask.is_some()) {
app.run("transparency.setOpacityMask", json!({"clip": !clip})).ok();
}
if widgets::check(ui, "Invert Mask", invert, mask.is_some()) {
app.run("transparency.setOpacityMask", json!({"invert": !invert})).ok();
}
});
});
if !pstate::<bool>(ui.ctx(), "tr-hide-options") {
@@ -91,7 +139,7 @@ pub fn show(app: &mut DrawcraftApp, ui: &mut Ui) {
}
}
pub fn menu(_app: &mut DrawcraftApp, ui: &mut Ui) {
pub fn menu(app: &mut DrawcraftApp, ui: &mut Ui) {
let hide_thumbs: bool = pstate(ui.ctx(), "tr-hide-thumbs");
let hide_opts: bool = pstate(ui.ctx(), "tr-hide-options");
if menu_item(ui, if hide_thumbs { "Show Thumbnails" } else { "Hide Thumbnails" }, true, false) {
@@ -101,13 +149,105 @@ pub fn menu(_app: &mut DrawcraftApp, ui: &mut Ui) {
set_pstate(ui.ctx(), "tr-hide-options", !hide_opts);
}
ui.separator();
for l in ["Make Opacity Mask", "Release Opacity Mask", "Disable Opacity Mask", "Unlink Opacity Mask"] {
menu_item(ui, l, false, false);
let mask = first_selected(app).and_then(|n| n.mask.as_deref().cloned());
let can_make = selection_len(app) >= 2 && mask.is_none();
let items: [(&str, &str, bool); 4] = match &mask {
Some(m) => [
("Make Opacity Mask", "transparency.makeOpacityMask", false),
("Release Opacity Mask", "transparency.releaseOpacityMask", true),
if m.disabled {
("Enable Opacity Mask", "transparency.enableOpacityMask", true)
} else {
("Disable Opacity Mask", "transparency.disableOpacityMask", true)
},
if m.linked {
("Unlink Opacity Mask", "transparency.unlinkOpacityMask", true)
} else {
("Link Opacity Mask", "transparency.linkOpacityMask", true)
},
],
None => [
("Make Opacity Mask", "transparency.makeOpacityMask", can_make),
("Release Opacity Mask", "", false),
("Disable Opacity Mask", "", false),
("Unlink Opacity Mask", "", false),
],
};
for (label, id, enabled) in items {
if menu_item(ui, label, enabled, false) {
app.run(id, json!({})).ok();
}
}
ui.separator();
menu_item(ui, "New Opacity Masks Are Clipping", false, true);
menu_item(ui, "New Opacity Masks Are Inverted", false, false);
let (clip, invert) = app.session.new_mask_defaults();
if menu_item(ui, "New Opacity Masks Are Clipping", true, clip) {
app.run("transparency.toggleNewMasksClipping", json!({})).ok();
}
if menu_item(ui, "New Opacity Masks Are Inverted", true, invert) {
app.run("transparency.toggleNewMasksInverted", json!({})).ok();
}
ui.separator();
menu_item(ui, "Page Isolated Blending", false, false);
menu_item(ui, "Page Knockout Group", false, false);
}
/// A rendered thumbnail of `n` (which may live outside the tree, like mask art). One texture per
/// slot, re-rendered only when the document revision, object or size changes.
fn node_thumb(app: &DrawcraftApp, ui: &Ui, slot: &str, n: &drawcraft_doc::Node, r: egui::Rect, bg: Option<[u8; 4]>) -> bool {
use std::cell::RefCell;
thread_local! {
static RENDERER: RefCell<drawcraft_render::Renderer> = RefCell::new(drawcraft_render::Renderer::new());
}
let Some(st) = app.session.active() else { return false };
let px = (r.width().min(r.height()) * ui.ctx().pixels_per_point()).round().max(8.0) as u32;
let (slot_id, key) = (egui::Id::new(slot), egui::Id::new((st.uid, st.revision, n.id, bg, px)));
let cached: Option<(egui::Id, egui::TextureHandle)> = ui.ctx().data(|d| d.get_temp(slot_id));
let tex = match cached {
Some((k, tex)) if k == key => Some(tex),
_ => RENDERER.with(|rr| rr.borrow_mut().render_node_thumbnail(&st.doc, n, px, bg)).map(|img| {
let color = egui::ColorImage::from_rgba_premultiplied([img.width as usize, img.height as usize], &img.pixels);
let tex = ui.ctx().load_texture(slot, color, egui::TextureOptions::LINEAR);
ui.ctx().data_mut(|d| d.insert_temp(slot_id, (key, tex.clone())));
tex
}),
};
let Some(tex) = tex else { return false };
let side = r.width().min(r.height());
let dst = egui::Rect::from_center_size(r.center(), vec2(side, side));
ui.painter().image(tex.id(), dst, egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)), egui::Color32::WHITE);
true
}
#[cfg(test)]
mod tests {
use super::*;
use drawcraft_engine::Session;
/// Run the panel and its ≡ menu for one headless frame.
fn frame(app: &mut DrawcraftApp) {
let ctx = egui::Context::default();
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| {
show(app, ui);
menu(app, ui);
});
out.textures_delta.clear();
}
#[test]
fn panel_draws_with_and_without_a_mask() {
let mut app = DrawcraftApp::new(Session::new(), Default::default());
let r = |app: &mut DrawcraftApp, id: &str, p: serde_json::Value| app.session.execute(id, &p).unwrap();
r(&mut app, "file.new", json!({"width": 100, "height": 100}));
frame(&mut app);
let a = r(&mut app, "shape.rectangle", json!({"x": 0, "y": 0, "width": 50, "height": 50}))["id"].clone();
let b = r(&mut app, "shape.rectangle", json!({"x": 10, "y": 10, "width": 20, "height": 20}))["id"].clone();
r(&mut app, "select.set", json!({"ids": [a, b]}));
frame(&mut app);
r(&mut app, "transparency.makeOpacityMask", json!({}));
frame(&mut app);
r(&mut app, "transparency.disableOpacityMask", json!({}));
r(&mut app, "transparency.unlinkOpacityMask", json!({}));
frame(&mut app);
assert_eq!(app.session.execute("transparency.opacityMaskInfo", &json!({})).unwrap()[0]["disabled"], true);
}
}