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