From 6e0af835c6e45a3c45426787070669cb1136f923 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 4 Oct 2026 23:04:20 +0700 Subject: [PATCH 1/3] M3.46: three-state knockout groups (on, neutral, off) render, export and edit Knockout Group is now Knockout {Neutral, Off, On} (old files' true loads as On, false as Neutral). In a knockout group each child erases what the children below it drew where it paints, then adds itself, so it composites against the group's backdrop; neutral plain groups pass their children through to the enclosing knockout group. transparency.set / object.setProps take knockout "on"|"off"|"neutral"|bool; transparency.info reports the state. PDF and SVG write knockout groups as nested soft masks with the same look (PDF warns); the Transparency panel has a tri-state checkbox. Co-Authored-By: Claude Opus 5.5 --- crates/doc/src/lib.rs | 1 + crates/doc/src/node.rs | 115 ++++++++++++++++++- crates/effects/src/bake.rs | 2 +- crates/engine/src/cmd/maskedit.rs | 6 +- crates/engine/src/cmd/object.rs | 15 ++- crates/engine/src/cmd/opacitymask.rs | 8 +- crates/engine/src/cmd/paint.rs | 7 +- crates/engine/src/lib.rs | 2 + crates/engine/src/tests_appearance.rs | 2 +- crates/engine/src/tests_knockout.rs | 133 ++++++++++++++++++++++ crates/engine/src/tests_opacitymask.rs | 4 +- crates/pdf/src/export.rs | 106 ++++++++++++----- crates/render/src/lib.rs | 94 +++++++++++---- crates/render/src/live.rs | 5 +- crates/render/src/tests_knockout.rs | 75 ++++++++++++ crates/svg/src/export.rs | 119 ++++++++++++++++--- crates/ui-egui/src/panels/transparency.rs | 29 ++++- crates/ui-egui/src/widgets.rs | 23 +++- docs/mcp.md | 5 + 19 files changed, 655 insertions(+), 96 deletions(-) create mode 100644 crates/engine/src/tests_knockout.rs create mode 100644 crates/render/src/tests_knockout.rs diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index b242ae03..957a1269 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}; diff --git a/crates/doc/src/node.rs b/crates/doc/src/node.rs index 5e36727a..093408b9 100644 --- a/crates/doc/src/node.rs +++ b/crates/doc/src/node.rs @@ -253,8 +253,9 @@ 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, #[serde(default, skip_serializing_if = "crate::skip::is_default")] pub appearance: Appearance, /// Opacity mask (Transparency panel). Its art lives here, outside the layer tree. @@ -307,7 +308,7 @@ impl Node { opacity: 1.0, blend: BlendMode::Normal, isolate: false, - knockout: false, + knockout: Knockout::Neutral, appearance: Appearance::default(), mask: None, trace: None, @@ -360,7 +361,7 @@ 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.mask.is_none() } /// Kind name as the Layers panel / Properties panel shows it. pub fn kind_label(&self) -> &'static str { @@ -635,6 +636,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(); @@ -657,11 +761,12 @@ 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); 6] = [ |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.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 3b66df25..22a967b9 100644 --- a/crates/effects/src/bake.rs +++ b/crates/effects/src/bake.rs @@ -129,7 +129,7 @@ fn bake_leaf(d: &mut Document, n: &Node) -> Option { opacity: 1.0, blend: Default::default(), isolate: false, - knockout: false, + knockout: Default::default(), 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..92864f05 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)}", 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,8 @@ 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; } } Ok(()) diff --git a/crates/engine/src/cmd/opacitymask.rs b/crates/engine/src/cmd/opacitymask.rs index d86208fc..81ab2c55 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,7 +108,7 @@ 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\", 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", has_doc, info ), @@ -264,7 +264,7 @@ pub struct TransparencyInfo { pub opacity: Option, pub blend: Option, pub isolate: Option, - pub knockout: Option, + pub knockout: Option, } impl TransparencyInfo { @@ -324,7 +324,7 @@ 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), "editingMask": st.doc.mask_edit.map(|me| me.object.0), })) } diff --git a/crates/engine/src/cmd/paint.rs b/crates/engine/src/cmd/paint.rs index a7e27132..aa942b06 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), 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 ), @@ -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/lib.rs b/crates/engine/src/lib.rs index 6ea09da7..c6a894af 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..f545b92b --- /dev/null +++ b/crates/engine/src/tests_knockout.rs @@ -0,0 +1,133 @@ +//! Knockout groups (M3.46): 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"], json!("off")); + // 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_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"})); + 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), (Knockout::Off, Knockout::On)); + // Defaults are not written. + let text = String::from_utf8(vectorcraft_format::save(&session().doc().unwrap().doc, false)).unwrap(); + assert!(!text.contains("knockout"), "{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 { doc: &'a Document, warnings: Vec, images: HashMap>, + /// Whether the group being written is a knockout group (what its neutral children inherit). + knockout: bool, } fn xf(a: Affine) -> Transform { @@ -196,6 +198,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 } @@ -320,34 +334,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). + fn knockout_mask(&mut self, s: &mut Surface, c: &Node, page: Rect) -> krilla::mask::Mask { + let shape = 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; @@ -364,6 +419,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)); @@ -372,13 +430,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 { @@ -387,20 +442,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(); } } @@ -435,6 +484,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..0e5c8a63 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,6 +336,7 @@ 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); + self.knockout = false; for layer in doc.layers.iter().filter(|l| Some(l.id) != mask_layer) { self.draw_arc(&mut ctx, &frame, layer); } @@ -389,6 +397,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 +462,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 +503,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 +525,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 +543,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 +576,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 +605,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 +643,43 @@ 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), 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, true), (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 +1066,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..d5e7f014 --- /dev/null +++ b/crates/render/src/tests_knockout.rs @@ -0,0 +1,75 @@ +//! Knockout groups (Transparency panel). + +use super::*; +use vectorcraft_color::{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]; + +#[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 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)); +} diff --git a/crates/svg/src/export.rs b/crates/svg/src/export.rs index 60064d69..939646f3 100644 --- a/crates/svg/src/export.rs +++ b/crates/svg/src/export.rs @@ -44,6 +44,9 @@ pub(crate) fn export(doc: &Document, opts: &ExportOptions) -> String { depth: 1, patterns: HashMap::new(), pattern_nest: 0, + knockout: false, + anonymous: false, + knockout_filter: None, }; w.assign_name_ids(); for l in &doc.layers { @@ -101,8 +104,17 @@ 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, } +/// 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", @@ -188,6 +200,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 { @@ -533,13 +548,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!("")); @@ -569,6 +582,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). 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 = 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; @@ -585,12 +677,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(""); } @@ -607,12 +697,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(""); } @@ -665,7 +753,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..1dfd1bff 100644 --- a/crates/ui-egui/src/panels/transparency.rs +++ b/crates/ui-egui/src/panels/transparency.rs @@ -1,11 +1,11 @@ //! 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 and the three-state Knockout Group (on → neutral → off). 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 +40,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 = info.isolate.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,8 +152,15 @@ 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(); } widgets::check(ui, "Opacity & Mask Define Knockout Shape", false, false); } @@ -346,4 +353,16 @@ mod tests { frame(&mut app); assert_eq!(app.session.execute("transparency.opacityMaskInfo", &json!({})).unwrap()[0]["disabled"], true); } + + #[test] + fn knockout_states() { + 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})); + let texts = frame(&mut app); + assert!(texts.iter().any(|t| t == "Knockout Group") && texts.iter().any(|t| t == "Opacity & Mask Define Knockout Shape")); + } + } } 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 345bdde9..71783263 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -185,6 +185,11 @@ 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. 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 From c95098d15a67099538be4c5ee44f66b246516333 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 4 Oct 2026 23:07:01 +0700 Subject: [PATCH 2/3] M3.47: opacity and opacity mask can define an object's knockout shape New Node.knockout_shape (transparency.set / object.setProps knockoutShape, reported by transparency.info). In a knockout group such an object knocks out only as much as its opacity and mask let through (a 50% object knocks out 50%); otherwise its full coverage knocks out. The renderer, PDF and SVG masks honour it, and the panel's checkbox is now live. Co-Authored-By: Claude Opus 5.5 --- crates/doc/src/live.rs | 1 + crates/doc/src/node.rs | 15 ++++++++-- crates/effects/src/bake.rs | 1 + crates/engine/src/cmd/object.rs | 5 +++- crates/engine/src/cmd/opacitymask.rs | 5 +++- crates/engine/src/cmd/paint.rs | 4 +-- crates/engine/src/tests_knockout.rs | 23 ++++++++++++--- crates/engine/src/tests_opacitymask.rs | 5 +++- crates/pdf/src/export.rs | 4 +-- crates/render/src/lib.rs | 7 +++-- crates/render/src/tests_knockout.rs | 34 ++++++++++++++++++++++- crates/svg/src/export.rs | 4 +-- crates/ui-egui/src/panels/transparency.rs | 11 +++++--- docs/mcp.md | 3 +- 14 files changed, 98 insertions(+), 24 deletions(-) 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 093408b9..856804fa 100644 --- a/crates/doc/src/node.rs +++ b/crates/doc/src/node.rs @@ -256,6 +256,10 @@ pub struct Node { /// Knockout Group: whether a container's children knock each other out (see [`Knockout`]). #[serde(default, skip_serializing_if = "crate::skip::is_default")] 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. @@ -309,6 +313,7 @@ impl Node { blend: BlendMode::Normal, isolate: false, knockout: Knockout::Neutral, + knockout_shape: false, appearance: Appearance::default(), mask: None, trace: None, @@ -361,7 +366,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 == Knockout::Neutral && 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 { @@ -761,12 +771,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); 6] = [ + let tweaks: [fn(&mut Node); 7] = [ |n| n.opacity = 0.5, |n| n.blend = BlendMode::Multiply, |n| n.isolate = 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 22a967b9..6a0f361e 100644 --- a/crates/effects/src/bake.rs +++ b/crates/effects/src/bake.rs @@ -130,6 +130,7 @@ fn bake_leaf(d: &mut Document, n: &Node) -> Option { blend: Default::default(), isolate: false, knockout: Default::default(), + knockout_shape: false, mask: None, trace: None, wrap: None, diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index 92864f05..debcf0af 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -131,7 +131,7 @@ pub fn specs() -> Vec { "Object Properties", [], None, - "{ids?|id?, name?, visible?, locked?, opacity?: 0..100, blend?: \"Multiply\"…, isolate?, knockout?: \"on\"|\"off\"|\"neutral\"|bool (true = on, false = neutral)}", + "{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 ), @@ -675,6 +675,9 @@ fn set_props(s: &mut Session, p: &Value) -> Result { 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 81ab2c55..77f90c74 100644 --- a/crates/engine/src/cmd/opacitymask.rs +++ b/crates/engine/src/cmd/opacitymask.rs @@ -108,7 +108,7 @@ pub fn specs() -> Vec { "Transparency Info", [], None, - "{id?|ids?} → {ids, opacity: 0..100, blend, isolate, knockout: \"on\"|\"off\"|\"neutral\", 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} 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", has_doc, info ), @@ -265,6 +265,7 @@ pub struct TransparencyInfo { pub blend: Option, pub isolate: Option, pub knockout: Option, + pub knockout_shape: Option, } impl TransparencyInfo { @@ -280,6 +281,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, } } @@ -325,6 +327,7 @@ fn info(s: &mut Session, p: &Value) -> Result { "blend": i.blend.map(BlendMode::label), "isolate": i.isolate, "knockout": i.knockout.map(Knockout::label), + "knockoutShape": i.knockout_shape, "editingMask": st.doc.mask_edit.map(|me| me.object.0), })) } diff --git a/crates/engine/src/cmd/paint.rs b/crates/engine/src/cmd/paint.rs index aa942b06..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?: \"on\"|\"off\"|\"neutral\"|bool (true = on, false = neutral), 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; diff --git a/crates/engine/src/tests_knockout.rs b/crates/engine/src/tests_knockout.rs index f545b92b..f94eeefa 100644 --- a/crates/engine/src/tests_knockout.rs +++ b/crates/engine/src/tests_knockout.rs @@ -1,4 +1,4 @@ -//! Knockout groups (M3.46): commands, undo, files +//! Knockout groups and the knockout shape (M3.46–M3.47): commands, undo, files //! (old bools load) and the PDF and SVG output. use serde_json::{Value, json}; @@ -51,7 +51,7 @@ fn knockout_takes_three_states_names_and_bools() { } run(&mut s, "transparency.set", json!({"knockout": "off"})); let info = run(&mut s, "transparency.info", json!({})); - assert_eq!(info["knockout"], json!("off")); + 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()); @@ -65,17 +65,32 @@ fn knockout_takes_three_states_names_and_bools() { 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 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"})); + run(&mut s, "transparency.set", json!({"ids": [child], "knockout": "on", "knockoutShape": true})); 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), (Knockout::Off, Knockout::On)); + assert_eq!((bg.knockout, bc.knockout, bc.knockout_shape), (Knockout::Off, Knockout::On, true)); // Defaults are not written. let text = String::from_utf8(vectorcraft_format::save(&session().doc().unwrap().doc, false)).unwrap(); assert!(!text.contains("knockout"), "{text}"); diff --git a/crates/engine/src/tests_opacitymask.rs b/crates/engine/src/tests_opacitymask.rs index edff7157..ca25ca14 100644 --- a/crates/engine/src/tests_opacitymask.rs +++ b/crates/engine/src/tests_opacitymask.rs @@ -180,7 +180,10 @@ fn info_reports_mixed_values_as_null() { s.execute("transparency.set", &json!({"ids": [a.0, b.0], "blend": "Screen"})).unwrap(); s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap(); let info = s.execute("transparency.info", &json!({})).unwrap(); - assert_eq!(info, json!({"ids": [a.0, b.0], "opacity": null, "blend": "Screen", "isolate": null, "knockout": "neutral", "editingMask": null})); + assert_eq!( + info, + json!({"ids": [a.0, b.0], "opacity": null, "blend": "Screen", "isolate": null, "knockout": "neutral", "knockoutShape": false, "editingMask": null}) + ); let one = s.execute("transparency.info", &json!({"id": a.0})).unwrap(); assert_eq!((one["opacity"].clone(), one["isolate"].clone()), (json!(50.0), json!(true))); let i = s.transparency_info(); diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index a6910d90..69fd2129 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -383,9 +383,9 @@ impl Exporter<'_> { } /// 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). + /// 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 = Node { opacity: 1.0, mask: None, ..c.clone() }; + 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); diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 0e5c8a63..af6997f4 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -652,14 +652,15 @@ impl Renderer { /// 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), then adds itself: it composites against the - /// group's backdrop instead of over the elements below it. + /// 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, true), (Compose::Plus, false)] { + 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 }; diff --git a/crates/render/src/tests_knockout.rs b/crates/render/src/tests_knockout.rs index d5e7f014..b583b8e1 100644 --- a/crates/render/src/tests_knockout.rs +++ b/crates/render/src/tests_knockout.rs @@ -1,4 +1,4 @@ -//! Knockout groups (Transparency panel). +//! Knockout groups and the knockout shape (Transparency panel). use super::*; use vectorcraft_color::{Color, Paint}; @@ -42,6 +42,9 @@ fn close(a: [u8; 4], b: [u8; 4]) -> bool { 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() { @@ -54,6 +57,35 @@ fn knockout_group_shows_the_top_object_over_the_backdrop() { } } +#[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… diff --git a/crates/svg/src/export.rs b/crates/svg/src/export.rs index 939646f3..3062de33 100644 --- a/crates/svg/src/export.rs +++ b/crates/svg/src/export.rs @@ -634,7 +634,7 @@ impl Writer<'_> { } /// A mask that is 1 − the knockout shape of `c`: white, then `c` painted black (at full object - /// opacity without its own mask). Returns the mask id. + /// 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(), @@ -647,7 +647,7 @@ impl Writer<'_> { f } }; - let shape = Node { opacity: 1.0, mask: None, ..c.clone() }; + 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; diff --git a/crates/ui-egui/src/panels/transparency.rs b/crates/ui-egui/src/panels/transparency.rs index 1dfd1bff..995bd02a 100644 --- a/crates/ui-egui/src/panels/transparency.rs +++ b/crates/ui-egui/src/panels/transparency.rs @@ -1,6 +1,7 @@ //! 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 the three-state Knockout Group (on → neutral → off). +//! Isolate Blending, the three-state Knockout Group (on → neutral → off) and Opacity & Mask Define +//! Knockout Shape. use egui::{Sense, Stroke, StrokeKind, Ui, vec2}; use serde_json::json; @@ -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 = info.isolate.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. @@ -162,7 +163,9 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let next = info.knockout.map_or(Knockout::On, Knockout::cycle); app.run("transparency.set", json!({"knockout": next.label()})).ok(); } - widgets::check(ui, "Opacity & Mask Define Knockout Shape", false, false); + if widgets::check(ui, "Opacity & Mask Define Knockout Shape", knockout_shape, has) { + app.run("transparency.set", json!({"knockoutShape": !knockout_shape})).ok(); + } } if !has { widgets::dim_label(ui, if selection_len(app) == 0 { "No Selection" } else { "" }); @@ -360,7 +363,7 @@ mod tests { 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})); + 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")); } diff --git a/docs/mcp.md b/docs/mcp.md index 71783263..0ebe60b5 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -187,7 +187,8 @@ until `transparency.stopEditingOpacityMask`. Saves and exports never include the 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. Knockout groups render as isolated 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. 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 From 039c0cbef75611f86b142d95aafed9435020ae7b Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 4 Oct 2026 23:10:06 +0700 Subject: [PATCH 3/3] M3.48: Page Isolated Blending and Page Knockout Group New Document.page_isolate / page_knockout, set by transparency.togglePageIsolatedBlending / togglePageKnockoutGroup {value?} (one undo step each, saved with the document, reported by transparency.info) and the Transparency panel menu's check items. The page renders as an isolated group (top-level blend modes no longer blend with what lies under the page) and/or a knockout group whose elements are the layers (neutral layers pass their objects through). PDF and SVG wrap the page content in an isolated group and write page knockout as soft masks. Co-Authored-By: Claude Opus 5.5 --- crates/doc/src/lib.rs | 10 ++++++ crates/engine/src/cmd/opacitymask.rs | 40 ++++++++++++++++++++++- crates/engine/src/tests_knockout.rs | 35 ++++++++++++++++++-- crates/engine/src/tests_opacitymask.rs | 2 +- crates/pdf/src/export.rs | 13 ++++++-- crates/render/src/lib.rs | 21 ++++++++++-- crates/render/src/tests_knockout.rs | 25 ++++++++++++-- crates/svg/src/export.rs | 15 +++++++-- crates/ui-egui/src/panels/transparency.rs | 19 ++++++++--- docs/mcp.md | 4 ++- 10 files changed, 163 insertions(+), 21 deletions(-) diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 957a1269..1cfce9c6 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -334,6 +334,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 { @@ -377,6 +385,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/engine/src/cmd/opacitymask.rs b/crates/engine/src/cmd/opacitymask.rs index 77f90c74..6cec3b24 100644 --- a/crates/engine/src/cmd/opacitymask.rs +++ b/crates/engine/src/cmd/opacitymask.rs @@ -108,10 +108,28 @@ pub fn specs() -> Vec { "Transparency Info", [], None, - "{id?|ids?} → {ids, opacity: 0..100, blend, isolate, knockout: \"on\"|\"off\"|\"neutral\", knockoutShape, 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; @@ -329,6 +365,8 @@ fn info(s: &mut Session, p: &Value) -> Result { "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/tests_knockout.rs b/crates/engine/src/tests_knockout.rs index f94eeefa..26e27cc0 100644 --- a/crates/engine/src/tests_knockout.rs +++ b/crates/engine/src/tests_knockout.rs @@ -1,4 +1,4 @@ -//! Knockout groups and the knockout shape (M3.46–M3.47): commands, undo, files +//! 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}; @@ -80,6 +80,25 @@ fn knockout_shape_is_an_object_option() { 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(); @@ -87,13 +106,15 @@ fn knockout_settings_round_trip_and_old_bools_load() { 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), (Knockout::Off, Knockout::On, true)); + 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}"); + 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; @@ -145,4 +166,12 @@ fn pdf_and_svg_write_knockout_groups_as_masks() { run(&mut s, "transparency.set", json!({"ids": [g], "knockout": "neutral"})); run(&mut s, "transparency.set", json!({"ids": [outer], "knockout": "on"})); assert_eq!(svg(&s.doc().unwrap().doc).matches("mask=\"url(#knockout-").count(), 1); + + // The page group: an isolated group around the page content. + let mut page = (*plain).clone(); + page.page_isolate = true; + assert!(svg(&page).contains("isolation")); + page.page_knockout = true; + assert_eq!(svg(&page).matches("mask=\"url(#knockout-").count(), 1, "the layer is neutral: its objects knock each other out"); + assert!(String::from_utf8_lossy(&pdf(&page).bytes).contains("/Luminosity")); } diff --git a/crates/engine/src/tests_opacitymask.rs b/crates/engine/src/tests_opacitymask.rs index ca25ca14..3cfba760 100644 --- a/crates/engine/src/tests_opacitymask.rs +++ b/crates/engine/src/tests_opacitymask.rs @@ -182,7 +182,7 @@ fn info_reports_mixed_values_as_null() { let info = s.execute("transparency.info", &json!({})).unwrap(); assert_eq!( info, - json!({"ids": [a.0, b.0], "opacity": null, "blend": "Screen", "isolate": null, "knockout": "neutral", "knockoutShape": false, "editingMask": null}) + json!({"ids": [a.0, b.0], "opacity": null, "blend": "Screen", "isolate": null, "knockout": "neutral", "knockoutShape": false, "editingMask": null, "pageIsolatedBlending": false, "pageKnockoutGroup": false}) ); let one = s.execute("transparency.info", &json!({"id": a.0})).unwrap(); assert_eq!((one["opacity"].clone(), one["isolate"].clone()), (json!(50.0), json!(true))); diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index 69fd2129..ad0cff06 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -70,7 +70,7 @@ pub fn export_with_report(doc: &Document, opts: &PdfOptions) -> Result Result>()); + } 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(); diff --git a/crates/render/src/tests_knockout.rs b/crates/render/src/tests_knockout.rs index b583b8e1..a5176608 100644 --- a/crates/render/src/tests_knockout.rs +++ b/crates/render/src/tests_knockout.rs @@ -1,7 +1,7 @@ -//! Knockout groups and the knockout shape (Transparency panel). +//! Knockout groups, the knockout shape and the page group (Transparency panel). use super::*; -use vectorcraft_color::{Color, Paint}; +use vectorcraft_color::{BlendMode as Blend, Color, Paint}; use vectorcraft_doc::{Appearance, Knockout, Node}; use vectorcraft_geom::shapes; @@ -105,3 +105,24 @@ fn neutral_groups_pass_knockout_through() { 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 3062de33..8c4e0753 100644 --- a/crates/svg/src/export.rs +++ b/crates/svg/src/export.rs @@ -44,13 +44,22 @@ pub(crate) fn export(doc: &Document, opts: &ExportOptions) -> String { depth: 1, patterns: HashMap::new(), pattern_nest: 0, - knockout: false, + 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" }; diff --git a/crates/ui-egui/src/panels/transparency.rs b/crates/ui-egui/src/panels/transparency.rs index 995bd02a..52649285 100644 --- a/crates/ui-egui/src/panels/transparency.rs +++ b/crates/ui-egui/src/panels/transparency.rs @@ -1,7 +1,7 @@ //! Transparency panel: blend mode, opacity (field + slider popup), object/mask thumbnails with the //! opacity-mask controls (make/release, link, clip, invert; Shift-click the mask to disable it), //! Isolate Blending, the three-state Knockout Group (on → neutral → off) and Opacity & Mask Define -//! Knockout Shape. +//! Knockout Shape; the menu's Page Isolated Blending and Page Knockout Group. use egui::{Sense, Stroke, StrokeKind, Ui, vec2}; use serde_json::json; @@ -200,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 @@ -358,7 +364,7 @@ mod tests { } #[test] - fn knockout_states() { + 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})); @@ -367,5 +373,10 @@ mod tests { 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/docs/mcp.md b/docs/mcp.md index 0ebe60b5..e34c9051 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -188,7 +188,9 @@ until `transparency.stopEditingOpacityMask`. Saves and exports never include the 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. Knockout groups render as isolated groups; +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