diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index d62a52df..a5274314 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -38,6 +38,7 @@ pub use appearance::{ pub use graph::{GraphKind, GraphSpec}; pub use hit::{Hit, HitKind}; pub use live::{BlendOrientation, BlendSpacing, BlendSpec, EnvelopeKind, GradientMesh, MeshPoint}; +pub use node::Knockout; 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}; @@ -270,7 +271,7 @@ pub struct GraphicStyle { #[serde(default, skip_serializing_if = "skip::is_default")] pub isolate: bool, #[serde(default, skip_serializing_if = "skip::is_default")] - pub knockout: bool, + pub knockout: Knockout, } fn one() -> f32 { @@ -283,7 +284,7 @@ pub const DEFAULT_GRAPHIC_STYLE: &str = "Default Graphic Style"; impl GraphicStyle { /// A style with `appearance` and default transparency (no id yet). pub fn new(name: impl Into, appearance: Appearance) -> Self { - Self { name: name.into(), appearance, id: 0, opacity: 1.0, blend: Default::default(), isolate: false, knockout: false } + Self { name: name.into(), appearance, id: 0, opacity: 1.0, blend: Default::default(), isolate: false, knockout: Knockout::Neutral } } /// A style capturing `n`'s transparency and `appearance`. pub fn of(name: impl Into, appearance: Appearance, n: &Node) -> Self { @@ -371,6 +372,14 @@ pub struct Document { /// Foreign data preserved on round-trip. #[serde(default, skip_serializing_if = "serde_json::Map::is_empty")] pub unknown: serde_json::Map, + /// Transparency panel → Page Isolated Blending: the page is an isolated transparency group, + /// so top-level blend modes don't blend with what lies under the page. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub page_isolate: bool, + /// Transparency panel → Page Knockout Group: the page's elements (its layers; neutral layers + /// pass their contents through) knock each other out. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub page_knockout: bool, } fn ppi72() -> f64 { @@ -414,6 +423,8 @@ impl Document { mask_edit: None, next_id: 1, unknown: Default::default(), + page_isolate: false, + page_knockout: false, }; let id = d.alloc_id(); d.layers.push(Arc::new(Node::layer(id, "Layer 1", LayerColor::Preset(0)))); diff --git a/crates/doc/src/live.rs b/crates/doc/src/live.rs index ca32bdac..ea8eaa76 100644 --- a/crates/doc/src/live.rs +++ b/crates/doc/src/live.rs @@ -1417,6 +1417,7 @@ pub fn expanded_group(n: &Node, outline: Outliner) -> Node { g.blend = n.blend; g.isolate = n.isolate; g.knockout = n.knockout; + g.knockout_shape = n.knockout_shape; g } diff --git a/crates/doc/src/node.rs b/crates/doc/src/node.rs index 4d637943..ac21bd3e 100644 --- a/crates/doc/src/node.rs +++ b/crates/doc/src/node.rs @@ -253,8 +253,13 @@ pub struct Node { pub blend: BlendMode, #[serde(default, skip_serializing_if = "crate::skip::is_default")] pub isolate: bool, + /// Knockout Group: whether a container's children knock each other out (see [`Knockout`]). #[serde(default, skip_serializing_if = "crate::skip::is_default")] - pub knockout: bool, + pub knockout: Knockout, + /// Opacity & Mask Define Knockout Shape: inside a knockout group, this object's opacity and + /// opacity mask scale how much of the objects below it in the group it knocks out. + #[serde(default, skip_serializing_if = "crate::skip::is_default")] + pub knockout_shape: bool, #[serde(default, skip_serializing_if = "crate::skip::is_default")] pub appearance: Appearance, /// Opacity mask (Transparency panel). Its art lives here, outside the layer tree. @@ -311,7 +316,8 @@ impl Node { opacity: 1.0, blend: BlendMode::Normal, isolate: false, - knockout: false, + knockout: Knockout::Neutral, + knockout_shape: false, appearance: Appearance::default(), mask: None, trace: None, @@ -365,7 +371,12 @@ impl Node { /// Full opacity, Normal blending, no isolation, knockout or opacity mask (the Layers panel /// fills an object's target circle otherwise). pub fn has_default_transparency(&self) -> bool { - self.opacity >= 1.0 && self.blend == BlendMode::Normal && !self.isolate && !self.knockout && self.mask.is_none() + self.opacity >= 1.0 + && self.blend == BlendMode::Normal + && !self.isolate + && self.knockout == Knockout::Neutral + && !self.knockout_shape + && self.mask.is_none() } /// Kind name as the Layers panel / Properties panel shows it. pub fn kind_label(&self) -> &'static str { @@ -640,6 +651,109 @@ impl Node { } } +/// Knockout Group state of a container (the Transparency panel's three-state checkbox). In a +/// knockout group each child composites against the group's backdrop, so it hides the children +/// below it instead of showing them through its transparency. Neutral passes the enclosing +/// group's setting through to the children. Files from before the three states stored a bool: +/// `true` loads as On, `false` as Neutral. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] +pub enum Knockout { + #[default] + Neutral, + Off, + On, +} + +impl Knockout { + pub const ALL: [Knockout; 3] = [Knockout::Neutral, Knockout::Off, Knockout::On]; + + /// Name in files and command parameters. + pub fn label(self) -> &'static str { + match self { + Knockout::Neutral => "neutral", + Knockout::Off => "off", + Knockout::On => "on", + } + } + /// A name ([`Self::label`], any case) or a bool as older files and commands wrote it. + pub fn from_value(v: &serde_json::Value) -> Option { + match v { + serde_json::Value::Bool(b) => Some(Self::from(*b)), + serde_json::Value::String(s) => Self::ALL.into_iter().find(|k| k.label().eq_ignore_ascii_case(s.trim())), + _ => None, + } + } + /// The state a click on the checkbox moves to: on → neutral → off → on. + pub fn cycle(self) -> Self { + match self { + Knockout::On => Knockout::Neutral, + Knockout::Neutral => Knockout::Off, + Knockout::Off => Knockout::On, + } + } + /// Whether children of a group with this state knock each other out, inside a group (or page) + /// where that is `enclosing`. + pub fn resolve(self, enclosing: bool) -> bool { + match self { + Knockout::Neutral => enclosing, + Knockout::Off => false, + Knockout::On => true, + } + } +} + +impl From for Knockout { + fn from(b: bool) -> Self { + if b { Knockout::On } else { Knockout::Neutral } + } +} + +impl Serialize for Knockout { + fn serialize(&self, s: S) -> Result { + s.serialize_str(self.label()) + } +} + +impl<'de> Deserialize<'de> for Knockout { + fn deserialize>(d: D) -> Result { + let v = serde_json::Value::deserialize(d)?; + Self::from_value(&v).ok_or_else(|| serde::de::Error::custom(format!("invalid knockout {v}"))) + } +} + +impl Node { + /// Whether the children of this group or layer knock each other out, inside a group (or page) + /// where that is `enclosing`. + pub fn knocks_out(&self, enclosing: bool) -> bool { + matches!(self.kind, NodeKind::Group { .. } | NodeKind::Layer { .. }) && self.knockout.resolve(enclosing) + } + + /// A plain group or layer that is neutral to knockout and has no transparency or appearance + /// of its own: inside a knockout group its children composite as the group's own children. + pub fn passes_knockout_through(&self) -> bool { + matches!(self.kind, NodeKind::Group { clip: false, .. } | NodeKind::Layer { template: false, .. }) + && self.has_default_transparency() + && self.appearance.items.is_empty() + && self.appearance.effects.is_empty() + } + + /// The visible elements of a knockout group made of `children` (bottom first): neutral plain + /// groups ([`Self::passes_knockout_through`]) contribute their children instead of themselves. + pub fn knockout_elements(children: &[Arc]) -> Vec<&Arc> { + fn push<'a>(children: &'a [Arc], out: &mut Vec<&'a Arc>) { + for c in children.iter().filter(|c| c.visible) { + match c.children() { + Some(ch) if c.passes_knockout_through() => push(ch, out), + _ => out.push(c), + } + } + } + let mut out = Vec::with_capacity(children.len()); + push(children, &mut out); + out + } +} + /// Is `a` a move plus a positive uniform scale (after which a refit gradient still matches)? fn keeps_gradient_fit(a: Affine) -> bool { let [m0, m1, m2, m3, _, _] = a.as_coeffs(); @@ -662,11 +776,13 @@ mod tests { fn default_transparency() { let p = || Node::path(NodeId(1), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art()); assert!(p().has_default_transparency()); - let tweaks: [fn(&mut Node); 5] = [ + let tweaks: [fn(&mut Node); 7] = [ |n| n.opacity = 0.5, |n| n.blend = BlendMode::Multiply, |n| n.isolate = true, - |n| n.knockout = true, + |n| n.knockout = Knockout::On, + |n| n.knockout = Knockout::Off, + |n| n.knockout_shape = true, |n| n.mask = Some(Box::new(OpacityMask::new(n.clone(), true))), ]; for tweak in tweaks { diff --git a/crates/effects/src/bake.rs b/crates/effects/src/bake.rs index 9e2423d2..3bb9d83c 100644 --- a/crates/effects/src/bake.rs +++ b/crates/effects/src/bake.rs @@ -128,7 +128,8 @@ fn bake_leaf(d: &mut Document, n: &Node) -> Option { opacity: 1.0, blend: Default::default(), isolate: false, - knockout: false, + knockout: Default::default(), + knockout_shape: false, mask: None, trace: None, wrap: None, diff --git a/crates/engine/src/cmd/maskedit.rs b/crates/engine/src/cmd/maskedit.rs index 2cf044e8..db804aee 100644 --- a/crates/engine/src/cmd/maskedit.rs +++ b/crates/engine/src/cmd/maskedit.rs @@ -55,11 +55,7 @@ pub(crate) fn mask_group(children: Vec>) -> Node { fn is_mask_group(n: &Node) -> bool { matches!(n.kind, NodeKind::Group { clip: false, .. }) && n.name.as_deref() == Some(MASK_GROUP) - && n.opacity >= 1.0 - && n.blend == vectorcraft_doc::color::BlendMode::Normal - && !n.isolate - && !n.knockout - && n.mask.is_none() + && n.has_default_transparency() && n.appearance.items.is_empty() && n.appearance.effects.is_empty() } diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index cf7280b2..debcf0af 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -5,7 +5,7 @@ use std::sync::Arc; use serde_json::{Value, json}; use vectorcraft_color::{BlendMode, Paint}; -use vectorcraft_doc::{Appearance, Document, Node, NodeId, NodeKind}; +use vectorcraft_doc::{Appearance, Document, Knockout, Node, NodeId, NodeKind}; use vectorcraft_geom::{Affine, FillRule, Point, Rect, Vec2}; use super::edit::{duplicate_in, selected_roots}; @@ -131,7 +131,7 @@ pub fn specs() -> Vec { "Object Properties", [], None, - "{ids?|id?, name?, visible?, locked?, opacity?: 0..100, blend?: \"Multiply\"…, isolate?, knockout?}", + "{ids?|id?, name?, visible?, locked?, opacity?: 0..100, blend?: \"Multiply\"…, isolate?, knockout?: \"on\"|\"off\"|\"neutral\"|bool (true = on, false = neutral), knockoutShape?: bool}", has_doc, set_props ), @@ -644,6 +644,13 @@ fn set_props(s: &mut Session, p: &Value) -> Result { Some(b) => Some(BlendMode::parse(b).ok_or_else(|| bad("object.setProps", format!("unknown blend mode `{b}`")))?), None => None, }; + let knockout = match p.get("knockout") { + Some(v) => Some( + Knockout::from_value(v) + .ok_or_else(|| bad("object.setProps", format!("knockout must be \"on\", \"off\", \"neutral\" or a bool, not {v}")))?, + ), + None => None, + }; s.edit("Object Properties", |d, _| { for id in &ids { let n = d.node_mut(*id).ok_or(EngineError::NoNode(*id))?; @@ -665,8 +672,11 @@ fn set_props(s: &mut Session, p: &Value) -> Result { if let Some(v) = p.get("isolate").and_then(Value::as_bool) { n.isolate = v; } - if let Some(v) = p.get("knockout").and_then(Value::as_bool) { - n.knockout = v; + if let Some(k) = knockout { + n.knockout = k; + } + if let Some(v) = p.get("knockoutShape").and_then(Value::as_bool) { + n.knockout_shape = v; } } Ok(()) diff --git a/crates/engine/src/cmd/opacitymask.rs b/crates/engine/src/cmd/opacitymask.rs index d86208fc..6cec3b24 100644 --- a/crates/engine/src/cmd/opacitymask.rs +++ b/crates/engine/src/cmd/opacitymask.rs @@ -10,7 +10,7 @@ use std::collections::HashSet; use serde_json::{Value, json}; use vectorcraft_color::BlendMode; -use vectorcraft_doc::{Document, Node, NodeId, OpacityMask, Selection}; +use vectorcraft_doc::{Document, Knockout, Node, NodeId, OpacityMask, Selection}; use super::edit::selected_roots; use super::maskedit::{begin, finish, mask_group, mask_parts, show_editing}; @@ -108,10 +108,28 @@ pub fn specs() -> Vec { "Transparency Info", [], None, - "{id?|ids?} → {ids, opacity: 0..100, blend, isolate, knockout, editingMask} the Transparency panel's values for `ids`, the selection, or the object whose mask is being edited. A value is null where those objects differ (or there are none); editingMask is the id of the object whose mask is being edited, or null", + "{id?|ids?} → {ids, opacity: 0..100, blend, isolate, knockout: \"on\"|\"off\"|\"neutral\", knockoutShape, editingMask, pageIsolatedBlending, pageKnockoutGroup} the Transparency panel's values for `ids`, the selection, or the object whose mask is being edited. A value is null where those objects differ (or there are none); editingMask is the id of the object whose mask is being edited, or null; the page values are the document's", has_doc, info ), + cmd!( + "transparency.togglePageIsolatedBlending", + "Page Isolated Blending", + ["Window", "Transparency"], + None, + "{value?} make the page an isolated group (default: toggle): blend modes of top-level objects don't blend with what lies under the page; saved with the document and written to PDF → {value}", + has_doc, + |s, p| toggle_page(s, p, false) + ), + cmd!( + "transparency.togglePageKnockoutGroup", + "Page Knockout Group", + ["Window", "Transparency"], + None, + "{value?} make the page a knockout group (default: toggle): its layers (the contents of neutral layers) hide what they cover instead of showing it through their transparency; saved with the document and written to PDF → {value}", + has_doc, + |s, p| toggle_page(s, p, true) + ), ] } @@ -248,6 +266,24 @@ fn toggle_new_clip(s: &mut Session, p: &Value) -> Result { Ok(json!({ "value": v })) } +/// Set (or toggle) the page's knockout group flag (`knockout`) or isolated blending flag, as one +/// undo step; setting the current value changes nothing. +fn toggle_page(s: &mut Session, p: &Value, knockout: bool) -> Result { + fn flag(d: &mut Document, knockout: bool) -> &mut bool { + if knockout { &mut d.page_knockout } else { &mut d.page_isolate } + } + let d = &s.doc()?.doc; + let cur = if knockout { d.page_knockout } else { d.page_isolate }; + let v = bool_or(p, "value", !cur); + if v != cur { + s.edit(if knockout { "Page Knockout Group" } else { "Page Isolated Blending" }, |d, _| { + *flag(d, knockout) = v; + Ok(()) + })?; + } + 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; @@ -264,7 +300,8 @@ pub struct TransparencyInfo { pub opacity: Option, pub blend: Option, pub isolate: Option, - pub knockout: Option, + pub knockout: Option, + pub knockout_shape: Option, } impl TransparencyInfo { @@ -280,6 +317,7 @@ impl TransparencyInfo { blend: same(&nodes, |n| n.blend), isolate: same(&nodes, |n| n.isolate), knockout: same(&nodes, |n| n.knockout), + knockout_shape: same(&nodes, |n| n.knockout_shape), ids, } } @@ -324,8 +362,11 @@ fn info(s: &mut Session, p: &Value) -> Result { "opacity": i.opacity, "blend": i.blend.map(BlendMode::label), "isolate": i.isolate, - "knockout": i.knockout, + "knockout": i.knockout.map(Knockout::label), + "knockoutShape": i.knockout_shape, "editingMask": st.doc.mask_edit.map(|me| me.object.0), + "pageIsolatedBlending": st.doc.page_isolate, + "pageKnockoutGroup": st.doc.page_knockout, })) } diff --git a/crates/engine/src/cmd/paint.rs b/crates/engine/src/cmd/paint.rs index a7e27132..73c5c550 100644 --- a/crates/engine/src/cmd/paint.rs +++ b/crates/engine/src/cmd/paint.rs @@ -54,7 +54,7 @@ pub fn specs() -> Vec { "Transparency", ["Window", "Transparency"], None, - "{ids?|id?, opacity?: 0..100, blend?: name, isolate?, knockout?, item?: appearance item index|null} for `ids`, the selection, or the object whose opacity mask is being edited; opacity and blend go to the targeted fill/stroke item (omitted: the Appearance panel's active item, else the objects)", + "{ids?|id?, opacity?: 0..100, blend?: name, isolate?, knockout?: \"on\"|\"off\"|\"neutral\"|bool (true = on, false = neutral), knockoutShape?: bool, item?: appearance item index|null} for `ids`, the selection, or the object whose opacity mask is being edited; opacity and blend go to the targeted fill/stroke item (omitted: the Appearance panel's active item, else the objects)", has_doc, transparency ), @@ -447,7 +447,7 @@ fn transparency(s: &mut Session, p: &Value) -> Result { let ItemTarget::Item { index, explicit } = item else { return s.execute("object.setProps", &Value::Object(q)); }; - // Opacity and blend belong to the targeted fill/stroke; isolate/knockout stay object-level. + // Opacity and blend belong to the targeted fill/stroke; isolate/knockout/knockoutShape stay object-level. let item_ids = if editing_mask { ids } else { appearance_targets(s, p)? }; if explicit { let d = &s.doc()?.doc; @@ -461,6 +461,11 @@ fn transparency(s: &mut Session, p: &Value) -> Result { { return Err(bad(C, format!("unknown blend mode {b}"))); } + if let Some(k) = q.get("knockout") + && vectorcraft_doc::Knockout::from_value(k).is_none() + { + return Err(bad(C, format!("unknown knockout state {k}"))); + } if opacity.is_some() || blend.is_some() { let ids: Vec = item_ids.iter().map(|id| id.0).collect(); s.execute("appearance.setItem", &json!({ "index": index, "ids": ids, "opacity": opacity, "blend": blend }))?; diff --git a/crates/engine/src/cmd/style.rs b/crates/engine/src/cmd/style.rs index 08adc46c..323cde94 100644 --- a/crates/engine/src/cmd/style.rs +++ b/crates/engine/src/cmd/style.rs @@ -48,7 +48,7 @@ pub fn specs() -> Vec { "List Graphic Styles", [], None, - "{} → {styles: [{id, name, fill, stroke, strokeWidth, fills, strokes, effects: [effect id…], opacity: 0..100, blend, isolate, knockout, linked: [object ids]}], selected: the style the first selected object is linked to, or null}", + "{} → {styles: [{id, name, fill, stroke, strokeWidth, fills, strokes, effects: [effect id…], opacity: 0..100, blend, isolate, knockout: \"on\"|\"off\"|\"neutral\", linked: [object ids]}], selected: the style the first selected object is linked to, or null}", has_doc, style_list ), @@ -342,7 +342,7 @@ fn style_list(s: &mut Session, _: &Value) -> Result { "opacity": (g.opacity * 100.0).round(), "blend": g.blend.label(), "isolate": g.isolate, - "knockout": g.knockout, + "knockout": g.knockout.label(), "linked": links.get(&g.id).map(|v| v.iter().map(|i| i.0).collect::>()).unwrap_or_default(), }) }) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index c9aaaca2..b83c085b 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -754,6 +754,8 @@ mod tests_file; #[cfg(test)] mod tests_gradient; #[cfg(test)] +mod tests_knockout; +#[cfg(test)] mod tests_live; #[cfg(test)] mod tests_menucmds; diff --git a/crates/engine/src/tests_appearance.rs b/crates/engine/src/tests_appearance.rs index a4825905..f5a29094 100644 --- a/crates/engine/src/tests_appearance.rs +++ b/crates/engine/src/tests_appearance.rs @@ -384,7 +384,7 @@ fn item_transparency_is_one_step_and_checks_the_blend_mode() { let before = steps(&s); run(&mut s, "transparency.set", json!({"knockout": true})); assert_eq!(steps(&s), before + 1, "isolate/knockout alone do not touch the item"); - assert!(node(&s, id).knockout); + assert_eq!(node(&s, id).knockout, vectorcraft_doc::Knockout::On); assert!(s.execute("transparency.set", &json!({"blend": "nope"})).is_err()); assert_eq!(stroke(&node(&s, id), 1).blend, BlendMode::Normal); } diff --git a/crates/engine/src/tests_knockout.rs b/crates/engine/src/tests_knockout.rs new file mode 100644 index 00000000..26e27cc0 --- /dev/null +++ b/crates/engine/src/tests_knockout.rs @@ -0,0 +1,177 @@ +//! Knockout groups, the knockout shape and the page group (M3.46–M3.48): commands, undo, files +//! (old bools load) and the PDF and SVG output. + +use serde_json::{Value, json}; +use vectorcraft_doc::Knockout; + +use super::*; + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); + s +} + +fn run(s: &mut Session, id: &str, p: Value) -> Value { + s.execute(id, &p).unwrap() +} + +fn node(s: &Session, id: u64) -> vectorcraft_doc::Node { + s.doc().unwrap().doc.node(NodeId(id)).unwrap().clone() +} + +fn steps(s: &Session) -> usize { + s.doc().unwrap().history.undo.len() +} + +/// A group of two overlapping 50% rectangles, selected. Returns the group id. +fn knockout_group(s: &mut Session) -> u64 { + let a = run(s, "shape.rectangle", json!({"x": 10, "y": 10, "width": 100, "height": 100}))["id"].clone(); + let b = run(s, "shape.rectangle", json!({"x": 60, "y": 60, "width": 100, "height": 100}))["id"].clone(); + run(s, "transparency.set", json!({"ids": [a, b], "opacity": 50})); + run(s, "select.set", json!({"ids": [a, b]})); + run(s, "object.group", json!({}))["id"].as_u64().unwrap() +} + +#[test] +fn knockout_takes_three_states_names_and_bools() { + let mut s = session(); + let g = knockout_group(&mut s); + assert_eq!(node(&s, g).knockout, Knockout::Neutral, "new groups are neutral"); + for (param, want) in [ + (json!("on"), Knockout::On), + (json!("Off"), Knockout::Off), + (json!("neutral"), Knockout::Neutral), + (json!(true), Knockout::On), + (json!(false), Knockout::Neutral), + ] { + let before = steps(&s); + run(&mut s, "transparency.set", json!({"knockout": param})); + assert_eq!((node(&s, g).knockout, steps(&s)), (want, before + 1), "{param}"); + } + run(&mut s, "transparency.set", json!({"knockout": "off"})); + let info = run(&mut s, "transparency.info", json!({})); + assert_eq!((info["knockout"].clone(), info["knockoutShape"].clone()), (json!("off"), json!(false))); + // A bad state changes nothing, also next to an opacity (which would go to an appearance item). + let before = steps(&s); + assert!(s.execute("transparency.set", &json!({"knockout": "maybe"})).is_err()); + assert!(s.execute("transparency.set", &json!({"knockout": 3, "opacity": 20})).is_err()); + assert!(s.execute("object.setProps", &json!({"knockout": "maybe"})).is_err()); + assert_eq!((node(&s, g).knockout, node(&s, g).opacity, steps(&s)), (Knockout::Off, 1.0, before)); + // Undo is one step per change. + run(&mut s, "edit.undo", json!({})); + assert_eq!(node(&s, g).knockout, Knockout::Neutral); + // The panel's click order. + assert_eq!([Knockout::On, Knockout::Neutral, Knockout::Off].map(Knockout::cycle), [Knockout::Neutral, Knockout::Off, Knockout::On]); +} + +#[test] +fn knockout_shape_is_an_object_option() { + let mut s = session(); + let g = knockout_group(&mut s); + let child = node(&s, g).children().unwrap()[1].id.0; + run(&mut s, "transparency.set", json!({"ids": [child], "knockoutShape": true})); + assert!(node(&s, child).knockout_shape && !node(&s, child).has_default_transparency()); + let info = run(&mut s, "transparency.info", json!({"id": child})); + assert_eq!(info["knockoutShape"], true); + let mixed = run(&mut s, "transparency.info", json!({"ids": [child, g]})); + assert_eq!(mixed["knockoutShape"], Value::Null); + run(&mut s, "edit.undo", json!({})); + assert!(!node(&s, child).knockout_shape); +} + +#[test] +fn page_group_toggles_are_undoable_document_settings() { + let mut s = session(); + let doc = |s: &Session| (s.doc().unwrap().doc.page_isolate, s.doc().unwrap().doc.page_knockout); + let before = steps(&s); + assert_eq!(run(&mut s, "transparency.togglePageIsolatedBlending", json!({})), json!({"value": true})); + assert_eq!(run(&mut s, "transparency.togglePageKnockoutGroup", json!({"value": true})), json!({"value": true})); + assert_eq!((doc(&s), steps(&s)), ((true, true), before + 2)); + // Setting the current value is not a change. + run(&mut s, "transparency.togglePageKnockoutGroup", json!({"value": true})); + assert_eq!(steps(&s), before + 2); + let info = run(&mut s, "transparency.info", json!({})); + assert_eq!((info["pageIsolatedBlending"].clone(), info["pageKnockoutGroup"].clone()), (json!(true), json!(true))); + run(&mut s, "edit.undo", json!({})); + assert_eq!(doc(&s), (true, false)); + run(&mut s, "transparency.togglePageIsolatedBlending", json!({})); + assert_eq!(doc(&s), (false, false)); +} + +#[test] +fn knockout_settings_round_trip_and_old_bools_load() { + let mut s = session(); + let g = knockout_group(&mut s); + let child = node(&s, g).children().unwrap()[0].id.0; + run(&mut s, "transparency.set", json!({"ids": [g], "knockout": "off"})); + run(&mut s, "transparency.set", json!({"ids": [child], "knockout": "on", "knockoutShape": true})); + run(&mut s, "transparency.togglePageIsolatedBlending", json!({})); + run(&mut s, "transparency.togglePageKnockoutGroup", json!({})); + let bytes = vectorcraft_format::save(&s.doc().unwrap().doc, false); + let back = vectorcraft_format::load(&bytes).unwrap(); + let (bg, bc) = (back.node(NodeId(g)).unwrap(), back.node(NodeId(child)).unwrap()); + assert_eq!((bg.knockout, bc.knockout, bc.knockout_shape, back.page_isolate, back.page_knockout), (Knockout::Off, Knockout::On, true, true, true)); + // Defaults are not written. + let text = String::from_utf8(vectorcraft_format::save(&session().doc().unwrap().doc, false)).unwrap(); + assert!(!text.contains("knockout") && !text.contains("page_isolate"), "{text}"); + // Files from before the three states stored a bool: true is On, false is Neutral. + let mut v: Value = serde_json::from_slice(&bytes).unwrap(); + let mut patched = 0; + fn patch(v: &mut Value, n: &mut usize) { + match v { + Value::Object(m) => { + if let Some(k) = m.get_mut("knockout") { + *k = json!(*k == json!("on")); + *n += 1; + } + m.values_mut().for_each(|c| patch(c, n)); + } + Value::Array(a) => a.iter_mut().for_each(|c| patch(c, n)), + _ => {} + } + } + patch(&mut v, &mut patched); + assert_eq!(patched, 2); + let old = vectorcraft_format::load(&serde_json::to_vec(&v).unwrap()).unwrap(); + assert_eq!((old.node(NodeId(g)).unwrap().knockout, old.node(NodeId(child)).unwrap().knockout), (Knockout::Neutral, Knockout::On)); +} + +#[test] +fn pdf_and_svg_write_knockout_groups_as_masks() { + let mut s = session(); + let g = knockout_group(&mut s); + let plain = s.doc().unwrap().doc.clone(); + run(&mut s, "transparency.set", json!({"ids": [g], "knockout": "on"})); + let ko = s.doc().unwrap().doc.clone(); + + let pdf = |d: &Document| vectorcraft_pdf::export_with_report(d, &vectorcraft_pdf::PdfOptions { compress: false, ..Default::default() }).unwrap(); + let r = pdf(&plain); + assert!(!String::from_utf8_lossy(&r.bytes).contains("/SMask") && r.warnings.is_empty()); + let r = pdf(&ko); + let text = String::from_utf8_lossy(&r.bytes); + // The lower rectangle is drawn through a luminosity mask made of the upper one's alpha. + assert!(text.contains("/Luminosity") && text.contains("/Alpha"), "soft masks"); + assert!(r.warnings.iter().any(|w| w.contains("knockout")), "{:?}", r.warnings); + + let svg = |d: &Document| vectorcraft_svg::export(d, &Default::default()); + assert!(!svg(&plain).contains("knockout")); + let out = svg(&ko); + assert_eq!(out.matches("mask=\"url(#knockout-").count(), 1, "{out}"); + assert!(out.contains(" Result Result { images: HashMap>, /// The brush library, parsed when the first brushed stroke is written. brushes: Option>, + /// Whether the group being written is a knockout group (what its neutral children inherit). + knockout: bool, } fn xf(a: Affine) -> Transform { @@ -199,6 +208,18 @@ fn color(c: &Color) -> krilla::color::Color { } } +/// Fill the page (with a margin) with an opaque grey level (0 = black, 255 = white): mask backdrops. +fn cover(s: &mut Surface, page: Rect, level: u8) { + let Some(p) = to_path(&page.inflate(1.0, 1.0).to_path(0.1)) else { return }; + s.set_stroke(None); + s.set_fill(Some(Fill { + paint: rgb::Color::new(level, level, level).into(), + opacity: NormalizedF32::ONE, + rule: krilla::paint::FillRule::NonZero, + })); + s.draw_path(&p); +} + fn rects_overlap(a: Rect, b: Rect) -> bool { a.x0 <= b.x1 && b.x0 <= a.x1 && a.y0 <= b.y1 && b.y0 <= a.y1 } @@ -323,34 +344,75 @@ 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: &vectorcraft_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); + if !m.clip || m.invert { + cover(&mut ms, page, if m.clip { 0 } else { 255 }); } + // Mask art is a picture of its own: it takes no part in a knockout group around the object. + let knockout = std::mem::take(&mut self.knockout); self.node(&mut ms, &m.art, page, true); - if let Some(bp) = &backdrop - && m.invert - { + self.knockout = knockout; + if m.invert { ms.push_blend_mode(krilla::blend::BlendMode::Difference); - ms.set_stroke(None); - ms.set_fill(Some(white())); - ms.draw_path(bp); + cover(&mut ms, page, 255); ms.pop(); } ms.finish(); krilla::mask::Mask::new(sb.finish(), krilla::mask::MaskType::Luminosity) } + /// The children of a group, as the elements of a knockout group when it is one. + fn children(&mut self, s: &mut Surface, children: &[std::sync::Arc], page: Rect) { + if !self.knockout { + for c in children { + self.node(s, c, page, false); + } + return; + } + // The PDF writer has no knockout groups: each element is drawn through a soft mask of + // where the elements above it don't paint (the same look for Normal blending). The masks + // nest, so element i sits inside the masks of elements i+1…n: open them outermost first. + self.warn("knockout groups are written as soft-masked groups (same look, but not editable as knockout groups)"); + let elements: Vec<_> = Node::knockout_elements(children) + .into_iter() + .filter(|c| c.visual_bounds().is_some_and(|b| rects_overlap(b.inflate(1.0, 1.0), page))) + .collect(); + let Some((first, rest)) = elements.split_first() else { return }; + for c in rest.iter().rev() { + let mask = self.knockout_mask(s, c, page); + s.push_mask(mask); + } + self.node(s, first, page, false); + for c in rest { + s.pop(); + self.node(s, c, page, false); + } + } + + /// A luminosity mask that is 1 − the knockout shape of `c`: white, then black through an alpha + /// mask of `c` (at full object opacity without its own mask, unless those define its shape). + fn knockout_mask(&mut self, s: &mut Surface, c: &Node, page: Rect) -> krilla::mask::Mask { + let shape = if c.knockout_shape { c.clone() } else { Node { opacity: 1.0, mask: None, ..c.clone() } }; + let mut sb = s.stream_builder(); + let mut ms = sb.surface(); + cover(&mut ms, page, 255); + let alpha = { + let mut ab = ms.stream_builder(); + let mut als = ab.surface(); + self.node(&mut als, &shape, page, false); + als.finish(); + krilla::mask::Mask::new(ab.finish(), krilla::mask::MaskType::Alpha) + }; + ms.push_mask(alpha); + cover(&mut ms, page, 0); + 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; @@ -367,6 +429,9 @@ impl Exporter<'_> { self.warn("raster effects (shadows, glows, blur, feather) are not exported to PDF yet"); } let container = n.is_container() && !matches!(n.kind, NodeKind::Compound { .. }); + // Whether this container's children knock each other out (a knockout group is isolated). + let knockout = n.knocks_out(self.knockout); + let enclosing = std::mem::replace(&mut self.knockout, knockout); let mut pushes = 0; if n.blend != BlendMode::Normal { s.push_blend_mode(blend(n.blend)); @@ -375,13 +440,10 @@ impl Exporter<'_> { if n.opacity < 1.0 { s.push_opacity(norm(n.opacity)); pushes += 1; - } else if container && (n.isolate || n.blend != BlendMode::Normal) { + } else if container && (n.isolate || n.blend != BlendMode::Normal || knockout) { s.push_isolated(); pushes += 1; } - if n.knockout { - self.warn("knockout groups are exported as normal groups"); - } if let Some(m) = n.mask.as_deref() && !m.disabled { @@ -390,20 +452,14 @@ impl Exporter<'_> { pushes += 1; } match &n.kind { - NodeKind::Layer { children, .. } | NodeKind::Group { children, clip: false } => { - for c in children { - self.node(s, c, page, false); - } - } + NodeKind::Layer { children, .. } | NodeKind::Group { children, clip: false } => self.children(s, children, page), NodeKind::Group { children, clip: true } => { // The region every output clips to; nothing to clip by hides the clipped art. if let Some((clip, rest)) = children.split_first() && let Some((p, r)) = vectorcraft_effects::clip_outline(clip).and_then(|(bp, r)| to_path(&bp).map(|p| (p, r))) { s.push_clip_path(&p, &rule(r)); - for c in rest { - self.node(s, c, page, false); - } + self.children(s, rest, page); s.pop(); } } @@ -438,6 +494,7 @@ impl Exporter<'_> { } } } + self.knockout = enclosing; for _ in 0..pushes { s.pop(); } diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index b07e703c..24a95fe4 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -230,6 +230,11 @@ pub struct Renderer { live: live::LiveCache, /// Blurred, tinted drop shadow / outer glow rasters per object and effect (see `fx`). shadows: PtrMap<(usize, usize), fx::ShadowEntry>, + /// Whether the group being drawn is a knockout group (what its neutral children inherit). + knockout: bool, + /// Address of the knockout-group element being drawn as its knockout shape: at full object + /// opacity and without its opacity mask (see [`Self::draw_knockout`]); 0 = none. + shape_of: usize, } #[derive(Clone, Copy, Debug, Default)] @@ -277,6 +282,8 @@ impl Renderer { brushes: Default::default(), shadows: PtrMap::default(), live: live::LiveCache::default(), + knockout: false, + shape_of: 0, } } @@ -329,9 +336,25 @@ impl Renderer { if !self.draw_pattern_edit(&mut ctx, &frame) { // While editing an opacity mask its art is seen only through the mask (Illustrator). let mask_layer = doc.mask_edit.map(|m| m.layer); - for layer in doc.layers.iter().filter(|l| Some(l.id) != mask_layer) { - self.draw_arc(&mut ctx, &frame, layer); + let layers = doc.layers.iter().filter(|l| Some(l.id) != mask_layer); + // Page Isolated Blending / Page Knockout Group: the page is a group of its own. + let page_group = !opts.outline && (doc.page_isolate || doc.page_knockout); + self.knockout = page_group && doc.page_knockout; + if page_group { + ctx.set_transform(Affine::IDENTITY); + ctx.push_layer(None, None, None, None, None); } + if self.knockout { + self.draw_knockout(&mut ctx, &frame, &layers.cloned().collect::>()); + } else { + for layer in layers { + self.draw_arc(&mut ctx, &frame, layer); + } + } + if page_group { + ctx.pop_layer(); + } + self.knockout = false; } if trim { ctx.pop_layer(); @@ -389,6 +412,7 @@ impl Renderer { ctx.fill_rect(&kurbo::Rect::new(0.0, 0.0, w as f64, w as f64)); } let frame = Frame { mt: false, doc, view, visible: b.inflate(1.0, 1.0), px: 1.0 / s, opts: &RenderOptions::default() }; + self.knockout = false; self.draw_node(&mut ctx, &frame, n, true); ctx.flush(); let mut pm = Pixmap::new(w, w); @@ -453,30 +477,33 @@ impl Renderer { Some(p) } - fn draw_arc(&mut self, ctx: &mut RenderContext, f: &Frame, a: &Arc) { + /// Whether `a` is left out of this frame: hidden, a skipped template, or culled. + fn skipped(&mut self, f: &Frame, a: &Arc) -> bool { let skipped_template = f.opts.skip_templates && matches!(a.kind, NodeKind::Layer { template: true, .. }); if !a.visible || skipped_template || f.opts.hidden.contains(&a.id) { - return; + return true; } match self.bounds_of(a) { Some(b) => { let pad = f.px * 2.0; - if !rects_overlap(b.inflate(pad, pad), f.visible) { - self.stats.culled += 1; - return; - } // Level of detail: leaves smaller than a quarter pixel are invisible. - if !a.is_container() && b.width() < f.px * 0.25 && b.height() < f.px * 0.25 { - self.stats.culled += 1; - return; - } + let culled = + !rects_overlap(b.inflate(pad, pad), f.visible) || (!a.is_container() && b.width() < f.px * 0.25 && b.height() < f.px * 0.25); + self.stats.culled += culled as usize; + culled } - None if !a.is_container() => return, - None => {} + None => !a.is_container(), + } + } + + fn draw_arc(&mut self, ctx: &mut RenderContext, f: &Frame, a: &Arc) { + if self.skipped(f, a) { + return; } if let Some(m) = a.mask.as_deref() && !m.disabled && !f.opts.outline + && !self.is_shape(a) { let mask = self.opacity_mask(f, m, ctx.width(), ctx.height()); ctx.set_transform(Affine::IDENTITY); @@ -491,7 +518,10 @@ impl Renderer { /// Render an opacity mask's art offscreen and turn its luminance into a coverage mask. fn opacity_mask(&mut self, f: &Frame, m: &vectorcraft_doc::OpacityMask, w: u16, h: u16) -> vello_cpu::Mask { let mut mctx = single_threaded_context(w, h); + // Mask art is a picture of its own: it takes no part in a knockout group around the object. + let knockout = std::mem::take(&mut self.knockout); self.draw_node(&mut mctx, &Frame { mt: false, ..*f }, &m.art, true); + self.knockout = knockout; mctx.flush(); let mut pm = Pixmap::new(w, h); mctx.render(&mut pm, &mut self.resources); @@ -510,10 +540,10 @@ impl Renderer { && (!blended || single_plain_fill(a)) && !a.isolate && !fx::has_fx(a) - && (a.opacity >= 1.0 || painted_items(a) == 1) + && (self.opacity_of(a) >= 1.0 || painted_items(a) == 1) && let Some(bp) = self.path_of(a) { - self.alpha = a.opacity.clamp(0.0, 1.0); + self.alpha = self.opacity_of(a).clamp(0.0, 1.0); self.cur = Some(a.clone()); if blended { ctx.set_blend_mode(blend_mode(a.blend)); @@ -528,7 +558,7 @@ impl Renderer { return; } if let NodeKind::Text(t) = &a.kind - && a.opacity >= 1.0 + && self.opacity_of(a) >= 1.0 && a.blend == vectorcraft_color::BlendMode::Normal && !fx::has_fx(a) { @@ -561,13 +591,17 @@ impl Renderer { let outline = f.opts.outline; let mut layers = 0; let template_dim = matches!(n.kind, NodeKind::Layer { template: true, .. }) && f.opts.dim_templates; - let opacity = if template_dim { n.opacity * 0.5 } else { n.opacity }; - if !outline && (opacity < 1.0 || n.blend != vectorcraft_color::BlendMode::Normal || n.isolate) { + let opacity = if template_dim { self.opacity_of(n) * 0.5 } else { self.opacity_of(n) }; + // Whether this container's children knock each other out (a knockout group is isolated). + let knockout = !outline && n.knocks_out(self.knockout); + let enclosing = std::mem::replace(&mut self.knockout, knockout); + if !outline && (opacity < 1.0 || n.blend != vectorcraft_color::BlendMode::Normal || n.isolate || knockout) { ctx.set_transform(Affine::IDENTITY); ctx.push_layer(None, Some(blend_mode(n.blend)), Some(opacity), None, None); layers += 1; } match &n.kind { + NodeKind::Layer { children, .. } | NodeKind::Group { children, clip: false } if knockout => self.draw_knockout(ctx, f, children), NodeKind::Layer { children, .. } | NodeKind::Group { children, clip: false } => { for c in children { self.draw_arc(ctx, f, c); @@ -586,8 +620,12 @@ impl Renderer { ctx.set_transform(f.view); ctx.set_fill_rule(fill_rule(region.1)); ctx.push_clip_layer(®ion.0); - for c in rest { - self.draw_arc(ctx, f, c); + if knockout { + self.draw_knockout(ctx, f, rest); + } else { + for c in rest { + self.draw_arc(ctx, f, c); + } } ctx.pop_layer(); } @@ -620,12 +658,44 @@ impl Renderer { } NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Mesh(_) | NodeKind::Repeat(_) => self.draw_live_node(ctx, f, n), } + self.knockout = enclosing; self.stats.drawn += 1; for _ in 0..layers { ctx.pop_layer(); } } + /// The children of a knockout group ([`Node::knockout_elements`]). Each element first erases + /// what the elements below it drew wherever it paints (its knockout shape: its coverage at full + /// object opacity without its opacity mask, or as drawn when its opacity and mask define the + /// knockout shape), then adds itself: it composites against the group's backdrop instead of + /// over the elements below it. + fn draw_knockout(&mut self, ctx: &mut RenderContext, f: &Frame, children: &[Arc]) { + for c in Node::knockout_elements(children) { + if self.skipped(f, c) { + continue; + } + for (compose, shape) in [(Compose::DestOut, !c.knockout_shape), (Compose::Plus, false)] { + ctx.set_transform(Affine::IDENTITY); + ctx.push_layer(None, Some(BlendMode::new(Mix::Normal, compose)), None, None, None); + self.shape_of = if shape { Arc::as_ptr(c) as usize } else { 0 }; + self.draw_arc(ctx, f, c); + self.shape_of = 0; + ctx.pop_layer(); + } + } + } + + /// Whether `n` is the knockout element being drawn as its shape (see [`Self::draw_knockout`]). + fn is_shape(&self, n: &Node) -> bool { + self.shape_of == n as *const Node as usize + } + + /// The object opacity `n` is drawn with (full while it is drawn as its knockout shape). + pub(crate) fn opacity_of(&self, n: &Node) -> f32 { + if self.is_shape(n) { 1.0 } else { n.opacity } + } + fn hairline(&mut self, ctx: &mut RenderContext, f: &Frame, bp: &BezPath, rgba: [u8; 4]) { ctx.set_transform(Affine::IDENTITY); let mut screen = bp.clone(); @@ -1012,3 +1082,5 @@ fn now() -> u64 { mod tests; #[cfg(test)] mod tests_clip; +#[cfg(test)] +mod tests_knockout; diff --git a/crates/render/src/live.rs b/crates/render/src/live.rs index 78777b8a..01569e0f 100644 --- a/crates/render/src/live.rs +++ b/crates/render/src/live.rs @@ -117,10 +117,11 @@ impl Renderer { pub(crate) fn draw_live(&mut self, ctx: &mut RenderContext, f: &Frame, a: &Arc) { let stamp = self.stamp; self.live.tick(stamp); - let layered = !f.opts.outline && (a.opacity < 1.0 || a.blend != vectorcraft_color::BlendMode::Normal || a.isolate); + let opacity = self.opacity_of(a); + let layered = !f.opts.outline && (opacity < 1.0 || a.blend != vectorcraft_color::BlendMode::Normal || a.isolate); if layered { ctx.set_transform(Affine::IDENTITY); - ctx.push_layer(None, Some(blend_mode(a.blend)), Some(a.opacity), None, None); + ctx.push_layer(None, Some(blend_mode(a.blend)), Some(opacity), None, None); } self.draw_live_body(ctx, f, a, true); if layered { diff --git a/crates/render/src/tests_knockout.rs b/crates/render/src/tests_knockout.rs new file mode 100644 index 00000000..a5176608 --- /dev/null +++ b/crates/render/src/tests_knockout.rs @@ -0,0 +1,128 @@ +//! Knockout groups, the knockout shape and the page group (Transparency panel). + +use super::*; +use vectorcraft_color::{BlendMode as Blend, Color, Paint}; +use vectorcraft_doc::{Appearance, Knockout, Node}; +use vectorcraft_geom::shapes; + +/// A 50% opaque rectangle: red at 10..60, blue at 40..90 (they overlap at 40..60). +fn half(d: &mut Document, x: f64, rgb: (f32, f32, f32)) -> Node { + let id = d.alloc_id(); + let mut n = Node::path( + id, + shapes::rectangle(Rect::new(x, 10.0, x + 50.0, 90.0)), + Appearance::basic(Paint::solid(Color::rgb(rgb.0, rgb.1, rgb.2)), Paint::None, 0.0), + ); + n.opacity = 0.5; + n +} + +/// A document whose layer holds a group (`knockout`) of a 50% red and a 50% blue rectangle, +/// after `tweak` adjusts the group. +fn doc(knockout: Knockout, tweak: impl FnOnce(&mut Document, &mut Node)) -> Document { + let mut d = Document::new(100.0, 100.0); + let red = half(&mut d, 10.0, (1.0, 0.0, 0.0)); + let blue = half(&mut d, 40.0, (0.0, 0.0, 1.0)); + let id = d.alloc_id(); + let mut g = Node::group(id, vec![Arc::new(red), Arc::new(blue)]); + g.knockout = knockout; + tweak(&mut d, &mut g); + d.insert(Some(d.layers[0].id), 0, g).unwrap(); + d +} + +fn render(d: &Document) -> Rendered { + Renderer::new().render(d, 100, 100, Affine::IDENTITY, &RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }) +} + +fn close(a: [u8; 4], b: [u8; 4]) -> bool { + a.iter().zip(b).all(|(x, y)| (*x as i32 - y as i32).abs() <= 3) +} + +const BLUE_OVER_WHITE: [u8; 4] = [128, 128, 255, 255]; +/// 50% blue over 50% red over white. +const BLUE_OVER_RED: [u8; 4] = [128, 64, 191, 255]; +/// The overlap with half of the red knocked out by an opaque blue: 0.5 · (50% red over white) + +/// 0.5 · blue (for Normal blending, the same as 50% blue over the red). +const HALF_KNOCKED: [u8; 4] = [128, 64, 191, 255]; + +#[test] +fn knockout_group_shows_the_top_object_over_the_backdrop() { + let on = render(&doc(Knockout::On, |_, _| {})); + assert!(close(on.pixel(50, 50), BLUE_OVER_WHITE), "overlap: {:?}", on.pixel(50, 50)); + assert!(close(on.pixel(20, 50), [255, 128, 128, 255]), "red alone: {:?}", on.pixel(20, 50)); + for k in [Knockout::Off, Knockout::Neutral] { + let r = render(&doc(k, |_, _| {})); + assert!(close(r.pixel(50, 50), BLUE_OVER_RED), "{k:?}: {:?}", r.pixel(50, 50)); + } +} + +#[test] +fn opacity_defines_the_knockout_shape() { + // The blue object's 50% opacity knocks out only half of the red below it. + let r = render(&doc(Knockout::On, |_, g| { + let blue = Arc::make_mut(&mut g.children_mut().unwrap()[1]); + blue.knockout_shape = true; + })); + assert!(close(r.pixel(50, 50), HALF_KNOCKED), "{:?}", r.pixel(50, 50)); + // Its opacity mask counts too when it defines the shape, and is ignored otherwise. + for shape in [false, true] { + let r = render(&doc(Knockout::On, |d, g| { + let id = d.alloc_id(); + let white = Node::path( + id, + shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), + Appearance::basic(Paint::solid(Color::rgb(0.5, 0.5, 0.5)), Paint::None, 0.0), + ); + let blue = Arc::make_mut(&mut g.children_mut().unwrap()[1]); + blue.opacity = 1.0; + blue.knockout_shape = shape; + blue.mask = Some(Box::new(vectorcraft_doc::OpacityMask::new(white, true))); + })); + let want = if shape { HALF_KNOCKED } else { BLUE_OVER_WHITE }; + // A mid-grey mask is ~50% (luminance 0.5, not gamma-corrected): allow a little more slack. + let px = r.pixel(50, 50); + assert!(px.iter().zip(want).all(|(a, b)| (*a as i32 - b as i32).abs() <= 6), "shape {shape}: {px:?}"); + } +} + +#[test] +fn neutral_groups_pass_knockout_through() { + // Red and blue in a neutral group inside a knockout group knock each other out… + let nested = |inner: Knockout| { + doc(Knockout::On, |d, g| { + let children = std::mem::take(g.children_mut().unwrap()); + let mut inner_group = Node::group(d.alloc_id(), children); + inner_group.knockout = inner; + *g.children_mut().unwrap() = vec![Arc::new(inner_group)]; + }) + }; + assert!(close(render(&nested(Knockout::Neutral)).pixel(50, 50), BLUE_OVER_WHITE)); + // …an Off group's do not… + assert!(close(render(&nested(Knockout::Off)).pixel(50, 50), BLUE_OVER_RED)); + // …and with no knockout group around it, a neutral group knocks out nothing. + let mut plain = nested(Knockout::Neutral); + Arc::make_mut(&mut Arc::make_mut(&mut plain.layers[0]).children_mut().unwrap()[0]).knockout = Knockout::Neutral; + assert!(close(render(&plain).pixel(50, 50), BLUE_OVER_RED)); +} + +#[test] +fn page_knockout_and_isolated_blending() { + // Page Knockout Group: the neutral layer passes its objects through to the page. + let mut d = doc(Knockout::Neutral, |_, _| {}); + d.page_knockout = true; + assert!(close(render(&d).pixel(50, 50), BLUE_OVER_WHITE), "{:?}", render(&d).pixel(50, 50)); + // A layer set to Off keeps its objects from knocking each other out. + Arc::make_mut(&mut d.layers[0]).knockout = Knockout::Off; + assert!(close(render(&d).pixel(50, 50), BLUE_OVER_RED)); + + // Page Isolated Blending: a Difference object no longer blends with the white under the page. + let mut d = Document::new(100.0, 100.0); + let mut red = half(&mut d, 10.0, (1.0, 0.0, 0.0)); + red.opacity = 1.0; + red.blend = Blend::Difference; + d.insert(Some(d.layers[0].id), 0, red).unwrap(); + assert!(close(render(&d).pixel(30, 50), [0, 255, 255, 255]), "inverts the white: {:?}", render(&d).pixel(30, 50)); + d.page_isolate = true; + assert!(close(render(&d).pixel(30, 50), [255, 0, 0, 255]), "isolated: {:?}", render(&d).pixel(30, 50)); +} diff --git a/crates/svg/src/export.rs b/crates/svg/src/export.rs index db75cf0d..5a47e031 100644 --- a/crates/svg/src/export.rs +++ b/crates/svg/src/export.rs @@ -46,10 +46,22 @@ pub(crate) fn export(doc: &Document, opts: &ExportOptions) -> String { patterns: HashMap::new(), pattern_nest: 0, brushes: None, + knockout: doc.page_knockout, + anonymous: false, + knockout_filter: None, }; w.assign_name_ids(); - for l in &doc.layers { - w.node(l); + // Page Isolated Blending / Page Knockout Group: the page content is one isolated group. + let page_group = doc.page_isolate || doc.page_knockout; + if page_group { + let a = w.attrs(&vec![("isolation", "isolate".into())]); + w.line(&format!("")); + w.depth += 1; + } + w.children(&doc.layers); + if page_group { + w.depth -= 1; + w.line(""); } let nl = if opts.minify { "" } else { "\n" }; @@ -103,6 +115,12 @@ struct Writer<'a> { /// `` def ids by pattern name + placement. patterns: HashMap, pattern_nest: u32, + /// Whether the group being written is a knockout group (what its neutral children inherit). + knockout: bool, + /// Writing a copy of an object (a knockout mask): no object ids, so they stay unique. + anonymous: bool, + /// The filter that paints art black keeping its alpha (knockout masks), once defined. + knockout_filter: Option, /// The brush library, parsed when the first brushed stroke is written. brushes: Option>, } @@ -114,6 +132,9 @@ enum StrokePlan { Written(Written), } +/// A region covering any artwork (mask and filter extents). +const BIG: &str = "x=\"-100000\" y=\"-100000\" width=\"200000\" height=\"200000\""; + pub(crate) fn blend_css(b: BlendMode) -> &'static str { match b { BlendMode::Normal => "normal", @@ -199,6 +220,9 @@ impl Writer<'_> { } } fn id_attr(&self, n: &Node) -> String { + if self.anonymous { + return String::new(); + } self.names.get(&n.id).map(|id| format!(" id=\"{}\"", xml_escape(id))).unwrap_or_default() } fn matrix(&self, m: Affine) -> String { @@ -619,13 +643,11 @@ 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: &vectorcraft_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; + // Mask art is a picture of its own: it takes no part in a knockout group around the object. + let knockout = std::mem::take(&mut self.knockout); + let art = self.detached(2, |w| w.node(&m.art)); + self.knockout = knockout; let inv = m.invert.then(|| { let fid = self.fresh_id("invert"); self.def(1, &format!("")); @@ -655,6 +677,85 @@ impl Writer<'_> { self.line(""); } + /// What `write` writes to the body, taken out of it (written at `depth`, for ``). + fn detached(&mut self, depth: usize, write: impl FnOnce(&mut Self)) -> String { + let (body, saved) = (std::mem::take(&mut self.body), self.depth); + self.depth = depth; + write(self); + self.depth = saved; + std::mem::replace(&mut self.body, body) + } + + /// Props of a group: a knockout group is isolated. + fn group_props(&self, n: &Node) -> Props { + let mut p = Self::node_props(n); + if !n.isolate && n.knocks_out(self.knockout) { + p.push(("isolation", "isolate".into())); + } + p + } + + /// The children of group `n`, as the elements of a knockout group when it is one. + fn group_children(&mut self, n: &Node, children: &[std::sync::Arc]) { + let knockout = n.knocks_out(self.knockout); + let enclosing = std::mem::replace(&mut self.knockout, knockout); + self.children(children); + self.knockout = enclosing; + } + + /// Children of the group being written. SVG has no knockout groups: in one, each element is + /// drawn through a mask of where the elements above it don't paint (the same look for Normal + /// blending). The masks nest, so element i sits inside the masks of elements i+1…n. + fn children(&mut self, children: &[std::sync::Arc]) { + if !self.knockout { + for c in children { + self.node(c); + } + return; + } + let elements = Node::knockout_elements(children); + let Some((first, rest)) = elements.split_first() else { return }; + for c in rest.iter().rev() { + let mid = self.knockout_mask(c); + self.line(&format!("")); + self.depth += 1; + } + self.node(first); + for c in rest { + self.depth -= 1; + self.line(""); + self.node(c); + } + } + + /// A mask that is 1 − the knockout shape of `c`: white, then `c` painted black (at full object + /// opacity without its own mask, unless those define its shape). Returns the mask id. + fn knockout_mask(&mut self, c: &Node) -> String { + let filter = match &self.knockout_filter { + Some(f) => f.clone(), + None => { + let f = self.fresh_id("knockout-shape"); + self.def(1, &format!("")); + self.def(2, ""); + self.def(1, ""); + self.knockout_filter = Some(f.clone()); + f + } + }; + let shape = if c.knockout_shape { c.clone() } else { Node { opacity: 1.0, mask: None, ..c.clone() } }; + let anonymous = std::mem::replace(&mut self.anonymous, true); + let art = self.detached(3, |w| w.node(&shape)); + self.anonymous = anonymous; + let mid = self.fresh_id("knockout"); + self.def(1, &format!("")); + self.def(2, &format!("")); + self.def(2, &format!("")); + self.defs.push_str(&art); + self.def(2, ""); + self.def(1, ""); + mid + } + fn node(&mut self, n: &Node) { if !n.visible { return; @@ -671,12 +772,10 @@ impl Writer<'_> { NodeKind::Layer { template: true, .. } => {} NodeKind::Layer { children, .. } | NodeKind::Group { children, clip: false } => { let id = self.id_attr(n); - let a = self.attrs(&Self::node_props(n)); + let a = self.attrs(&self.group_props(n)); self.line(&format!("")); self.depth += 1; - for c in children { - self.node(c); - } + self.group_children(n, children); self.depth -= 1; self.line(""); } @@ -693,12 +792,10 @@ impl Writer<'_> { } self.def(1, ""); let id = self.id_attr(n); - let a = self.attrs(&Self::node_props(n)); + let a = self.attrs(&self.group_props(n)); self.line(&format!("")); self.depth += 1; - for c in rest { - self.node(c); - } + self.group_children(n, rest); self.depth -= 1; self.line(""); } @@ -746,7 +843,10 @@ impl Writer<'_> { let a = self.attrs(&Self::node_props(n)); self.line(&format!("")); self.depth += 1; + // The symbol's art is the instance's own picture, outside any knockout around it. + let knockout = std::mem::take(&mut self.knockout); self.node(&sym.art.clone()); + self.knockout = knockout; self.depth -= 1; self.line(""); self.xf = saved; diff --git a/crates/ui-egui/src/panels/transparency.rs b/crates/ui-egui/src/panels/transparency.rs index d54d94a1..52649285 100644 --- a/crates/ui-egui/src/panels/transparency.rs +++ b/crates/ui-egui/src/panels/transparency.rs @@ -1,11 +1,12 @@ //! Transparency panel: blend mode, opacity (field + slider popup), object/mask thumbnails with the //! opacity-mask controls (make/release, link, clip, invert; Shift-click the mask to disable it), -//! Isolate Blending and Knockout Group. +//! Isolate Blending, the three-state Knockout Group (on → neutral → off) and Opacity & Mask Define +//! Knockout Shape; the menu's Page Isolated Blending and Page Knockout Group. use egui::{Sense, Stroke, StrokeKind, Ui, vec2}; use serde_json::json; use vectorcraft_color::{BlendMode, Paint}; -use vectorcraft_doc::{Node, NodeId, OpacityMask}; +use vectorcraft_doc::{Knockout, Node, NodeId, OpacityMask}; use vectorcraft_engine::cmd::TransparencyInfo; use super::{current_paints, current_transparency, live_run, pstate, selection_len, set_pstate}; @@ -40,7 +41,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let editing = app.session.active().and_then(|d| d.doc.mask_edit); let (info, n) = state(app, ui.ctx()); let has = n.is_some(); - let (isolate, knockout) = (info.isolate.unwrap_or(false), info.knockout.unwrap_or(false)); + let (isolate, knockout_shape) = (info.isolate.unwrap_or(false), info.knockout_shape.unwrap_or(false)); // Opacity and blend of the Appearance panel's active item (not while a mask is edited: the // panel then shows the masked object), else of the targets, blank where they differ; with no // target, the defaults for new art. @@ -152,10 +153,19 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { if widgets::check(ui, "Isolate Blending", isolate, has) { app.run("transparency.set", json!({"isolate": !isolate})).ok(); } - if widgets::check(ui, "Knockout Group", knockout, has) { - app.run("transparency.set", json!({"knockout": !knockout})).ok(); + // Neutral (and mixed) show a dash; a click moves on → neutral → off → on (mixed → on). + let shown = match info.knockout { + Some(Knockout::On) => Some(true), + Some(Knockout::Off) => Some(false), + _ => None, + }; + if widgets::check3(ui, "Knockout Group", shown, has) { + let next = info.knockout.map_or(Knockout::On, Knockout::cycle); + app.run("transparency.set", json!({"knockout": next.label()})).ok(); + } + if widgets::check(ui, "Opacity & Mask Define Knockout Shape", knockout_shape, has) { + app.run("transparency.set", json!({"knockoutShape": !knockout_shape})).ok(); } - widgets::check(ui, "Opacity & Mask Define Knockout Shape", false, false); } if !has { widgets::dim_label(ui, if selection_len(app) == 0 { "No Selection" } else { "" }); @@ -190,8 +200,14 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { app.run("transparency.toggleNewMasksInverted", json!({})).ok(); } ui.separator(); - menu_item(ui, "Page Isolated Blending", false, false); - menu_item(ui, "Page Knockout Group", false, false); + let page = app.session.active().map(|d| (d.doc.page_isolate, d.doc.page_knockout)); + let (isolate, knockout) = page.unwrap_or_default(); + if menu_item(ui, "Page Isolated Blending", page.is_some(), isolate) { + app.run("transparency.togglePageIsolatedBlending", json!({})).ok(); + } + if menu_item(ui, "Page Knockout Group", page.is_some(), knockout) { + app.run("transparency.togglePageKnockoutGroup", json!({})).ok(); + } } /// The menu's opacity-mask rows (label, command, enabled) for the target's `mask`; Make needs a @@ -346,4 +362,21 @@ mod tests { frame(&mut app); assert_eq!(app.session.execute("transparency.opacityMaskInfo", &json!({})).unwrap()[0]["disabled"], true); } + + #[test] + fn knockout_states_and_page_group_items() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + run(&mut app, "file.new", json!({"width": 100, "height": 100})); + run(&mut app, "shape.rectangle", json!({"x": 0, "y": 0, "width": 50, "height": 50})); + for k in ["on", "off", "neutral"] { + run(&mut app, "transparency.set", json!({"knockout": k, "knockoutShape": k == "on"})); + let texts = frame(&mut app); + assert!(texts.iter().any(|t| t == "Knockout Group") && texts.iter().any(|t| t == "Opacity & Mask Define Knockout Shape")); + } + assert!(frame(&mut app).iter().any(|t| t.ends_with(" Page Knockout Group") && !t.starts_with('✓'))); + run(&mut app, "transparency.togglePageKnockoutGroup", json!({})); + run(&mut app, "transparency.togglePageIsolatedBlending", json!({})); + let texts = frame(&mut app); + assert!(texts.iter().any(|t| t == "✓ Page Knockout Group") && texts.iter().any(|t| t == "✓ Page Isolated Blending"), "{texts:?}"); + } } diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index a065000b..fc8dfa7c 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -538,14 +538,27 @@ fn choice_row(ui: &mut Ui, label: &str, enabled: bool) -> (Rect, Response, Color /// Panel-style checkbox with a disabled state. Returns true when toggled. pub fn check(ui: &mut Ui, label: &str, value: bool, enabled: bool) -> bool { + check3(ui, label, Some(value), enabled) +} + +/// Three-state [`check`]: `None` shows a dash (a neutral or mixed state). Returns true when clicked. +pub fn check3(ui: &mut Ui, label: &str, value: Option, enabled: bool) -> bool { let t = Tokens::get(ui.ctx()); let (bx, resp, border) = choice_row(ui, label, enabled); - ui.painter().rect_filled(bx, CornerRadius::same(2), if value && enabled { t.accent_strong } else { t.input }); + let checked = value == Some(true); + ui.painter().rect_filled(bx, CornerRadius::same(2), if checked && enabled { t.accent_strong } else { t.input }); ui.painter().rect_stroke(bx, CornerRadius::same(2), Stroke::new(1.0, border), StrokeKind::Inside); - if value { - let c = if enabled { Color32::WHITE } else { t.text_disabled }; - ui.painter().line_segment([bx.left_center() + vec2(3.0, 0.0), bx.center_bottom() + vec2(-1.0, -3.5)], Stroke::new(1.6, c)); - ui.painter().line_segment([bx.center_bottom() + vec2(-1.0, -3.5), bx.right_top() + vec2(-3.0, 3.0)], Stroke::new(1.6, c)); + match value { + Some(true) => { + let c = if enabled { Color32::WHITE } else { t.text_disabled }; + ui.painter().line_segment([bx.left_center() + vec2(3.0, 0.0), bx.center_bottom() + vec2(-1.0, -3.5)], Stroke::new(1.6, c)); + ui.painter().line_segment([bx.center_bottom() + vec2(-1.0, -3.5), bx.right_top() + vec2(-3.0, 3.0)], Stroke::new(1.6, c)); + } + None => { + let c = if enabled { t.text } else { t.text_disabled }; + ui.painter().line_segment([bx.left_center() + vec2(3.0, 0.0), bx.right_center() - vec2(3.0, 0.0)], Stroke::new(1.6, c)); + } + Some(false) => {} } enabled && resp.clicked() } diff --git a/docs/mcp.md b/docs/mcp.md index b1e3f21a..3c05bb08 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -185,6 +185,14 @@ until `transparency.stopEditingOpacityMask`. Saves and exports never include the {"name":"run_command","arguments":{"command":"transparency.info","params":{"ids":[12,20]}}} ``` +Knockout Group has three states: `transparency.set {knockout: "on"|"off"|"neutral"}` (`true` = on, `false` = +neutral, the default). In a knockout group each child hides what it covers of the children below it; neutral groups +pass the enclosing group's setting to their children, off groups never knock out. `knockoutShape: true` makes the +object's opacity and opacity mask scale how much it knocks out. `transparency.togglePageKnockoutGroup` and +`transparency.togglePageIsolatedBlending` (`{value?}`) treat the whole page as a knockout or isolated group; they are +saved with the document and undoable, and `transparency.info` reports them. Knockout groups render as isolated groups; +PDF and SVG write them as soft-masked groups with the same look (the PDF export reports a warning). + ## Clipping masks `object.clippingMask.make` clips the selected objects by the topmost one, which may be a path, a compound path or a