From c95098d15a67099538be4c5ee44f66b246516333 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 4 Oct 2026 23:07:01 +0700 Subject: [PATCH] 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