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:
parent
6e0af835c6
commit
c95098d15a
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(())
|
||||
})?;
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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}");
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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…
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue