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 <noreply@anthropic.com>
This commit is contained in:
bflatastic 2026-10-04 23:07:01 +07:00
parent 6e0af835c6
commit c95098d15a
14 changed files with 98 additions and 24 deletions

View File

@ -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
}

View File

@ -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 {

View File

@ -130,6 +130,7 @@ fn bake_leaf(d: &mut Document, n: &Node) -> Option<Node> {
blend: Default::default(),
isolate: false,
knockout: Default::default(),
knockout_shape: false,
mask: None,
trace: None,
wrap: None,

View File

@ -131,7 +131,7 @@ pub fn specs() -> Vec<CommandSpec> {
"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<Value> {
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(())
})?;

View File

@ -108,7 +108,7 @@ pub fn specs() -> Vec<CommandSpec> {
"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<BlendMode>,
pub isolate: Option<bool>,
pub knockout: Option<Knockout>,
pub knockout_shape: Option<bool>,
}
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<Value> {
"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),
}))
}

View File

@ -54,7 +54,7 @@ pub fn specs() -> Vec<CommandSpec> {
"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<Value> {
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;

View File

@ -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}");

View File

@ -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();

View File

@ -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);

View File

@ -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<Node>]) {
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 };

View File

@ -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…

View File

@ -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;

View File

@ -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"));
}

View File

@ -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