diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 7f178153..c3819c14 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -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}; diff --git a/crates/doc/src/node.rs b/crates/doc/src/node.rs index f19f70a9..ed139d85 100644 --- a/crates/doc/src/node.rs +++ b/crates/doc/src/node.rs @@ -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>, 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, + /// 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)) { diff --git a/crates/engine/src/cmd/menucmds.rs b/crates/engine/src/cmd/menucmds.rs index e85e8626..e8fc3eef 100644 --- a/crates/engine/src/cmd/menucmds.rs +++ b/crates/engine/src/cmd/menucmds.rs @@ -19,6 +19,10 @@ pub struct MenuState { pub saved_selections: Vec<(String, String, Vec)>, /// 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 { diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 680fc566..9994ae4d 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -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()); diff --git a/crates/engine/src/cmd/opacitymask.rs b/crates/engine/src/cmd/opacitymask.rs new file mode 100644 index 00000000..06ceabf3 --- /dev/null +++ b/crates/engine/src/cmd/opacitymask.rs @@ -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 { + 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> { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + let st = s.doc()?; + let v: Vec = 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); + } +} diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index 48ff3c2f..ffee4341 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -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 { diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index cd72010f..61c1c880 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -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")); +} diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 22ed5eab..249c9ab8 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -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 { - 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 { 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) { // 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() }) } diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index 9936add7..2056625b 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -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"); +} diff --git a/crates/svg/src/export.rs b/crates/svg/src/export.rs index b2af533e..89cf40fb 100644 --- a/crates/svg/src/export.rs +++ b/crates/svg/src/export.rs @@ -543,10 +543,54 @@ impl Writer<'_> { } } + /// An object with an opacity mask: `` around the unmasked object. The mask + /// art goes in ``; 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!("")); + self.def(2, ""); + self.def(1, ""); + fid + }); + self.def(1, &format!("")); + if let Some(fid) = &inv { + self.def(1, &format!("")); + } + if !m.clip || m.invert { + let c = if m.clip { "black" } else { "white" }; + self.def(1, &format!("")); + } + self.defs.push_str(&art); + if inv.is_some() { + self.def(1, ""); + } + self.def(1, ""); + self.line(&format!("")); + self.depth += 1; + let mut bare = n.clone(); + bare.mask = None; + self.node(&bare); + self.depth -= 1; + self.line(""); + } + 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 } => { diff --git a/crates/svg/src/import.rs b/crates/svg/src/import.rs index 020d0f02..07b88510 100644 --- a/crates/svg/src/import.rs +++ b/crates/svg/src/import.rs @@ -166,9 +166,7 @@ impl Importer { fn group(&mut self, g: &usvg::Group, acc: Affine) -> Option { 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) } + /// `` → opacity mask (luminance; alpha masks are approximated by their luminance). + fn opacity_mask(&mut self, m: &usvg::Mask, ts: Affine, label: &str) -> Option> { + 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 { fn collect(g: &usvg::Group, m: Affine, out: &mut Vec<(BezPath, FillRule)>) { for c in g.children() { diff --git a/crates/svg/tests/roundtrip.rs b/crates/svg/tests/roundtrip.rs index 5c85944d..306fd846 100644 --- a/crates/svg/tests/roundtrip.rs +++ b/crates/svg/tests/roundtrip.rs @@ -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(" 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(" = 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(" { + // 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::(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 = 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); + } +}