mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-14 19:26:56 -04:00
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:
1 parent
c06fad7b2b
commit
5e15994012
13 files changed
+793
-31
No files matched your search
@@ -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};
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
@@ -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() })
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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 } => {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user