Merge pull request #22 from storytold/pr/m3-blend-isolation
M3: blend accuracy, non-isolated groups, layer clipping masks (M3.73–M3.75)
This commit is contained in:
commit
e21ff60b6a
|
|
@ -75,6 +75,11 @@ impl BlendMode {
|
|||
_ => 5,
|
||||
}
|
||||
}
|
||||
/// Whether the mode works on each colour channel separately (all but Hue, Saturation, Color
|
||||
/// and Luminosity).
|
||||
pub fn is_separable(self) -> bool {
|
||||
self.group() != 5
|
||||
}
|
||||
/// Parse a label or identifier, case/space-insensitively.
|
||||
pub fn parse(s: &str) -> Option<Self> {
|
||||
let norm = |x: &str| x.to_ascii_lowercase().replace([' ', '_', '-'], "");
|
||||
|
|
@ -83,10 +88,182 @@ impl BlendMode {
|
|||
}
|
||||
}
|
||||
|
||||
/// Luminance weights of the non-separable modes (Hue, Saturation, Color, Luminosity), as the PDF
|
||||
/// and W3C compositing specifications define them.
|
||||
pub const LUM: [f32; 3] = [0.3, 0.59, 0.11];
|
||||
|
||||
/// Luminance weights of opacity masks (Rec. 709, applied to the sRGB values as SVG luminance masks
|
||||
/// with `color-interpolation="sRGB"` apply them).
|
||||
pub const MASK_LUM: [f32; 3] = [0.2126, 0.7152, 0.0722];
|
||||
|
||||
/// The blend function B(backdrop, source) of `mode` on straight (not premultiplied) RGB in 0..=1:
|
||||
/// the colour that replaces the backdrop where an opaque source covers an opaque backdrop. Every
|
||||
/// output (screen, SVG, PDF and transparency flattening) follows these formulas.
|
||||
pub fn blend_rgb(mode: BlendMode, backdrop: [f32; 3], source: [f32; 3]) -> [f32; 3] {
|
||||
use BlendMode as B;
|
||||
let separable = |f: fn(f32, f32) -> f32| [0, 1, 2].map(|i| f(backdrop[i].clamp(0.0, 1.0), source[i].clamp(0.0, 1.0)));
|
||||
match mode {
|
||||
B::Normal => separable(|_, s| s),
|
||||
B::Multiply => separable(|b, s| b * s),
|
||||
B::Screen => separable(screen),
|
||||
B::Overlay => separable(|b, s| hard_light(s, b)),
|
||||
B::Darken => separable(f32::min),
|
||||
B::Lighten => separable(f32::max),
|
||||
B::ColorDodge => separable(|b, s| {
|
||||
if b <= 0.0 {
|
||||
0.0
|
||||
} else if s >= 1.0 {
|
||||
1.0
|
||||
} else {
|
||||
(b / (1.0 - s)).min(1.0)
|
||||
}
|
||||
}),
|
||||
B::ColorBurn => separable(|b, s| {
|
||||
if b >= 1.0 {
|
||||
1.0
|
||||
} else if s <= 0.0 {
|
||||
0.0
|
||||
} else {
|
||||
1.0 - ((1.0 - b) / s).min(1.0)
|
||||
}
|
||||
}),
|
||||
B::HardLight => separable(hard_light),
|
||||
B::SoftLight => separable(|b, s| {
|
||||
if s <= 0.5 {
|
||||
b - (1.0 - 2.0 * s) * b * (1.0 - b)
|
||||
} else {
|
||||
let d = if b <= 0.25 { ((16.0 * b - 12.0) * b + 4.0) * b } else { b.sqrt() };
|
||||
b + (2.0 * s - 1.0) * (d - b)
|
||||
}
|
||||
}),
|
||||
B::Difference => separable(|b, s| (b - s).abs()),
|
||||
B::Exclusion => separable(|b, s| b + s - 2.0 * b * s),
|
||||
B::Hue => set_lum(set_sat(source, sat(backdrop)), lum(backdrop)),
|
||||
B::Saturation => set_lum(set_sat(backdrop, sat(source)), lum(backdrop)),
|
||||
B::Color => set_lum(source, lum(backdrop)),
|
||||
B::Luminosity => set_lum(backdrop, lum(source)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Source-over compositing of `source` onto `backdrop` with `mode` (straight RGBA in 0..=1, the
|
||||
/// result straight too): the source colour is mixed with B(backdrop, source) by the backdrop's
|
||||
/// alpha, then laid over the backdrop by its own alpha.
|
||||
pub fn composite(mode: BlendMode, backdrop: [f32; 4], source: [f32; 4]) -> [f32; 4] {
|
||||
let (ab, as_) = (backdrop[3].clamp(0.0, 1.0), source[3].clamp(0.0, 1.0));
|
||||
let ao = as_ + ab * (1.0 - as_);
|
||||
if ao <= 0.0 {
|
||||
return [0.0; 4];
|
||||
}
|
||||
let mixed = blend_rgb(mode, [backdrop[0], backdrop[1], backdrop[2]], [source[0], source[1], source[2]]);
|
||||
let c = |i: usize| ((1.0 - ab) * source[i] + ab * mixed[i]) * as_ + (1.0 - as_) * ab * backdrop[i];
|
||||
[c(0) / ao, c(1) / ao, c(2) / ao, ao]
|
||||
}
|
||||
|
||||
fn screen(b: f32, s: f32) -> f32 {
|
||||
b + s - b * s
|
||||
}
|
||||
|
||||
fn hard_light(b: f32, s: f32) -> f32 {
|
||||
if s <= 0.5 { b * 2.0 * s } else { screen(b, 2.0 * s - 1.0) }
|
||||
}
|
||||
|
||||
fn lum(c: [f32; 3]) -> f32 {
|
||||
LUM[0] * c[0] + LUM[1] * c[1] + LUM[2] * c[2]
|
||||
}
|
||||
|
||||
fn sat(c: [f32; 3]) -> f32 {
|
||||
c[0].max(c[1]).max(c[2]) - c[0].min(c[1]).min(c[2])
|
||||
}
|
||||
|
||||
/// Bring a colour moved off the gamut by [`set_lum`] back in, keeping its luminance.
|
||||
fn clip_color(c: [f32; 3]) -> [f32; 3] {
|
||||
let l = lum(c);
|
||||
let (n, x) = (c[0].min(c[1]).min(c[2]), c[0].max(c[1]).max(c[2]));
|
||||
let mut c = c;
|
||||
if n < 0.0 {
|
||||
c = c.map(|v| l + (v - l) * l / (l - n));
|
||||
}
|
||||
if x > 1.0 {
|
||||
c = c.map(|v| l + (v - l) * (1.0 - l) / (x - l));
|
||||
}
|
||||
c
|
||||
}
|
||||
|
||||
fn set_lum(c: [f32; 3], l: f32) -> [f32; 3] {
|
||||
let d = l - lum(c);
|
||||
clip_color(c.map(|v| v + d))
|
||||
}
|
||||
|
||||
/// `c` with saturation `s`: its largest component becomes `s`, its smallest 0 and the middle one
|
||||
/// keeps its proportion.
|
||||
fn set_sat(c: [f32; 3], s: f32) -> [f32; 3] {
|
||||
let (n, x) = (c[0].min(c[1]).min(c[2]), c[0].max(c[1]).max(c[2]));
|
||||
if x <= n {
|
||||
return [0.0; 3];
|
||||
}
|
||||
c.map(|v| (v - n) * s / (x - n))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn near(a: [f32; 3], b: [f32; 3]) -> bool {
|
||||
a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-5)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn separable_modes_follow_the_reference_formulas() {
|
||||
let (b, s) = ([0.2, 0.5, 0.9], [0.6, 0.25, 0.5]);
|
||||
let cases: [(BlendMode, [f32; 3]); 12] = [
|
||||
(BlendMode::Normal, s),
|
||||
(BlendMode::Multiply, [0.12, 0.125, 0.45]),
|
||||
(BlendMode::Screen, [0.68, 0.625, 0.95]),
|
||||
(BlendMode::Darken, [0.2, 0.25, 0.5]),
|
||||
(BlendMode::Lighten, [0.6, 0.5, 0.9]),
|
||||
(BlendMode::Difference, [0.4, 0.25, 0.4]),
|
||||
(BlendMode::Exclusion, [0.56, 0.5, 0.5]),
|
||||
// Overlay = Hard Light with the layers swapped: b ≤ ½ multiplies, else screens.
|
||||
(BlendMode::Overlay, [0.24, 0.25, 0.9]),
|
||||
(BlendMode::HardLight, [0.36, 0.25, 0.9]),
|
||||
(BlendMode::ColorDodge, [0.5, 0.6666667, 1.0]),
|
||||
(BlendMode::ColorBurn, [0.0, 0.0, 0.8]),
|
||||
// s ≤ ½ darkens by b(1−b)(1−2s); s > ½ lightens towards D(b).
|
||||
(BlendMode::SoftLight, [0.2 + 0.2 * (((16.0 * 0.2 - 12.0) * 0.2 + 4.0) * 0.2 - 0.2), 0.375, 0.9]),
|
||||
];
|
||||
for (m, want) in cases {
|
||||
assert!(near(blend_rgb(m, b, s), want), "{m:?}: {:?} ≠ {want:?}", blend_rgb(m, b, s));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_separable_modes_keep_the_reference_luminance() {
|
||||
let (b, s) = ([0.8, 0.3, 0.2], [0.1, 0.4, 0.9]);
|
||||
// Color and Hue take the backdrop's luminance; Luminosity the source's.
|
||||
for m in [BlendMode::Hue, BlendMode::Saturation, BlendMode::Color] {
|
||||
assert!((lum(blend_rgb(m, b, s)) - lum(b)).abs() < 1e-5, "{m:?}");
|
||||
}
|
||||
assert!((lum(blend_rgb(BlendMode::Luminosity, b, s)) - lum(s)).abs() < 1e-5);
|
||||
// Saturation of grey is 0: a grey source desaturates the backdrop to its luminance.
|
||||
let l = lum(b);
|
||||
assert!(near(blend_rgb(BlendMode::Saturation, b, [0.5; 3]), [l; 3]));
|
||||
// Hue of the source with the backdrop's saturation: blue-ish stays blue-ish.
|
||||
let h = blend_rgb(BlendMode::Hue, b, s);
|
||||
assert!(h[2] > h[1] && h[1] > h[0], "{h:?}");
|
||||
assert!((sat(h) - sat(b)).abs() < 1e-5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn composite_lays_the_blend_over_the_backdrop_by_alpha() {
|
||||
let (b, s) = ([0.8, 0.4, 0.2, 1.0], [0.5, 0.5, 0.5, 0.5]);
|
||||
let o = composite(BlendMode::Multiply, b, s);
|
||||
// Half of the multiplied colour over half of the backdrop.
|
||||
assert!(near([o[0], o[1], o[2]], [0.6, 0.3, 0.15]) && o[3] == 1.0, "{o:?}");
|
||||
// Over a transparent backdrop the source shows unblended.
|
||||
let o = composite(BlendMode::Difference, [0.3, 0.3, 0.3, 0.0], s);
|
||||
assert!(near([o[0], o[1], o[2]], [0.5; 3]) && (o[3] - 0.5).abs() < 1e-6, "{o:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_labels() {
|
||||
assert_eq!(BlendMode::parse("color burn"), Some(BlendMode::ColorBurn));
|
||||
|
|
|
|||
|
|
@ -88,8 +88,8 @@ fn clip_contains(clip: &Node, p: Point) -> bool {
|
|||
|
||||
fn hit_children(parent: &Node, p: Point, opt: HitOptions, chain: &mut Vec<NodeId>) -> Option<Hit> {
|
||||
let children = parent.children()?;
|
||||
// A clip group only hits inside its clipping path.
|
||||
if let NodeKind::Group { clip: true, .. } = parent.kind
|
||||
// A clip group (or a layer with a clipping mask) only hits inside its clipping path.
|
||||
if parent.clips()
|
||||
&& let Some(clip) = children.first()
|
||||
&& !clip_contains(clip, p)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -174,6 +174,9 @@ pub enum NodeKind {
|
|||
#[serde(default = "yes")]
|
||||
printable: bool,
|
||||
children: Vec<Arc<Node>>,
|
||||
/// Layer clipping mask: the first (bottom-most) child clips the others, as in a clip group.
|
||||
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
|
||||
clip: bool,
|
||||
},
|
||||
/// A group. With `clip`, the first (bottom-most) child is the clipping path.
|
||||
Group {
|
||||
|
|
@ -336,7 +339,7 @@ impl Node {
|
|||
Self::new(id, NodeKind::Group { children, clip: false })
|
||||
}
|
||||
pub fn layer(id: NodeId, name: &str, color: LayerColor) -> Self {
|
||||
let mut n = Self::new(id, NodeKind::Layer { color, template: false, printable: true, children: vec![] });
|
||||
let mut n = Self::new(id, NodeKind::Layer { color, template: false, printable: true, children: vec![], clip: false });
|
||||
n.name = Some(name.to_string());
|
||||
n
|
||||
}
|
||||
|
|
@ -427,7 +430,9 @@ impl Node {
|
|||
pub fn geometric_bounds(&self) -> Option<Rect> {
|
||||
match &self.kind {
|
||||
NodeKind::Path { path, .. } => path.bounds(),
|
||||
NodeKind::Group { children, clip: true } => children.first().and_then(|c| c.geometric_bounds()),
|
||||
NodeKind::Group { children, clip: true } | NodeKind::Layer { children, clip: true, .. } => {
|
||||
children.first().and_then(|c| c.geometric_bounds())
|
||||
}
|
||||
NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } | NodeKind::Compound { children, .. } => children
|
||||
.iter()
|
||||
.filter(|c| c.visible || !matches!(self.kind, NodeKind::Layer { .. }))
|
||||
|
|
@ -447,7 +452,9 @@ impl Node {
|
|||
/// Visual bounds (includes stroke outset).
|
||||
pub fn visual_bounds(&self) -> Option<Rect> {
|
||||
match &self.kind {
|
||||
NodeKind::Group { children, clip: true } => children.first().and_then(|c| c.geometric_bounds()),
|
||||
NodeKind::Group { children, clip: true } | NodeKind::Layer { children, clip: true, .. } => {
|
||||
children.first().and_then(|c| c.geometric_bounds())
|
||||
}
|
||||
NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } => {
|
||||
children.iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, c.visual_bounds()))
|
||||
}
|
||||
|
|
@ -731,7 +738,7 @@ impl Node {
|
|||
/// 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, .. })
|
||||
matches!(self.kind, NodeKind::Group { clip: false, .. } | NodeKind::Layer { template: false, clip: false, .. })
|
||||
&& self.has_default_transparency()
|
||||
&& self.appearance.items.is_empty()
|
||||
&& self.appearance.effects.is_empty()
|
||||
|
|
@ -752,6 +759,38 @@ impl Node {
|
|||
push(children, &mut out);
|
||||
out
|
||||
}
|
||||
|
||||
/// Whether this is a clip group or a layer with a clipping mask: its first child clips the rest.
|
||||
pub fn clips(&self) -> bool {
|
||||
matches!(self.kind, NodeKind::Group { clip: true, .. } | NodeKind::Layer { clip: true, .. })
|
||||
}
|
||||
|
||||
/// Make this group or layer clip its children by its first child, or stop it.
|
||||
pub fn set_clips(&mut self, on: bool) {
|
||||
if let NodeKind::Group { clip, .. } | NodeKind::Layer { clip, .. } = &mut self.kind {
|
||||
*clip = on;
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether blending inside this object reaches the art below it unless the object isolates
|
||||
/// it: a blend mode other than Normal on a child (or, in a leaf, on a fill or stroke),
|
||||
/// directly or through children that don't isolate their own blending.
|
||||
pub fn blends_through(&self) -> bool {
|
||||
self.blends_through_with(&mut |c| c.blends_through())
|
||||
}
|
||||
|
||||
/// [`Self::blends_through`], with `inner` answering it for the children (e.g. from a cache).
|
||||
pub fn blends_through_with(&self, inner: &mut dyn FnMut(&Arc<Node>) -> bool) -> bool {
|
||||
match self.children() {
|
||||
Some(ch) if !matches!(self.kind, NodeKind::Compound { .. }) => Self::children_blend(ch, inner),
|
||||
_ => self.appearance.items.iter().any(|i| i.visible() && i.blend() != BlendMode::Normal),
|
||||
}
|
||||
}
|
||||
|
||||
/// [`Self::blends_through`] of a group made of `children` (`inner` answers it for each child).
|
||||
pub fn children_blend(children: &[Arc<Node>], inner: &mut dyn FnMut(&Arc<Node>) -> bool) -> bool {
|
||||
children.iter().any(|c| c.visible && (c.blend != BlendMode::Normal || (!c.isolate && inner(c))))
|
||||
}
|
||||
}
|
||||
|
||||
/// Is `a` a move plus a positive uniform scale (after which a refit gradient still matches)?
|
||||
|
|
|
|||
|
|
@ -47,9 +47,48 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
),
|
||||
cmd!("artboard.fitToArt", "Fit to Artwork Bounds", ["Object", "Artboards"], None, "{index?}", has_doc, artboard_fit_art),
|
||||
cmd!("artboard.fitToSelection", "Fit to Selected Art", ["Object", "Artboards"], None, "{index?}", has_selection, artboard_fit_sel),
|
||||
cmd!(
|
||||
"layer.clippingMask.toggle",
|
||||
"Make/Release Clipping Mask",
|
||||
["Window", "Layers"],
|
||||
None,
|
||||
"{id?: layer or group (default: the one selected group, else the current layer)} make: its top object (a path, compound path or text, which loses its paint) clips the rest and moves to the bottom (new art added on top is clipped); release: it stops clipping, the clipping path stays unpainted → {clip}",
|
||||
has_doc,
|
||||
clip_toggle
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
/// The Layers panel's clipping mask button: the top object of a layer (or group) clips the rest,
|
||||
/// or the clipping mask is released.
|
||||
fn clip_toggle(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let st = s.doc()?;
|
||||
let group = match st.selection.objects.as_slice() {
|
||||
[id] if matches!(st.doc.node(*id).map(|n| &n.kind), Some(NodeKind::Group { .. })) => Some(*id),
|
||||
_ => None,
|
||||
};
|
||||
let id = id_param(p, "id").or(group).or(st.current_layer()).ok_or_else(|| bad("layer.clippingMask.toggle", "no layer"))?;
|
||||
let clip = s.edit("Make/Release Clipping Mask", |d, _| {
|
||||
let n = d.node(id).ok_or(EngineError::NoNode(id))?;
|
||||
if !matches!(n.kind, NodeKind::Layer { .. } | NodeKind::Group { .. }) {
|
||||
return Err(bad("layer.clippingMask.toggle", "not a layer or group"));
|
||||
}
|
||||
if n.clips() {
|
||||
super::object::release_clip(d, id);
|
||||
return Ok(false);
|
||||
}
|
||||
let top =
|
||||
n.children().and_then(|c| c.last()).map(|c| c.id).ok_or_else(|| EngineError::Other("the layer has no objects to clip by".into()))?;
|
||||
super::object::make_clipping_path(d, top)?;
|
||||
d.move_node(top, Some(id), 0)?;
|
||||
if let Some(n) = d.node_mut(id) {
|
||||
n.set_clips(true);
|
||||
}
|
||||
Ok(true)
|
||||
})?;
|
||||
Ok(json!({ "clip": clip }))
|
||||
}
|
||||
|
||||
fn new_layer(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let name = str_param(p, "name").map(str::to_string);
|
||||
let st = s.doc()?;
|
||||
|
|
|
|||
|
|
@ -532,13 +532,43 @@ fn compound_release(s: &mut Session, _: &Value) -> Result<Value> {
|
|||
ok()
|
||||
}
|
||||
|
||||
/// Make `top` (a path, compound path or text object) the clipping path: it loses its paint (it
|
||||
/// stays unpainted after Release).
|
||||
pub(super) fn make_clipping_path(d: &mut Document, top: NodeId) -> Result<()> {
|
||||
let c = d.node_mut(top).ok_or(EngineError::NoNode(top))?;
|
||||
if !matches!(c.kind, NodeKind::Path { guide: false, .. } | NodeKind::Compound { .. } | NodeKind::Text(_)) {
|
||||
return Err(EngineError::Other("the top object must be a path, compound path or text object to use as a clipping mask".into()));
|
||||
}
|
||||
c.appearance = Appearance::basic(Paint::None, Paint::None, 0.0);
|
||||
match &mut c.kind {
|
||||
NodeKind::Path { clipping, .. } => *clipping = true,
|
||||
NodeKind::Text(t) => {
|
||||
for r in &mut t.runs {
|
||||
(r.style.fill, r.style.stroke) = (Paint::None, Paint::None);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Stop clip group or clipped layer `id` clipping; its clipping path stays, unpainted.
|
||||
pub(super) fn release_clip(d: &mut Document, id: NodeId) {
|
||||
let Some(n) = d.node_mut(id).filter(|n| n.clips()) else { return };
|
||||
n.set_clips(false);
|
||||
let clip = n.children().and_then(|c| c.first()).map(|c| c.id);
|
||||
if let Some(c) = clip.and_then(|c| d.node_mut(c))
|
||||
&& let NodeKind::Path { clipping, .. } = &mut c.kind
|
||||
{
|
||||
*clipping = false;
|
||||
}
|
||||
}
|
||||
|
||||
fn clip_make(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let ids = selected_roots(s)?;
|
||||
let top = *ids.last().unwrap();
|
||||
let gid = s.edit("Make Clipping Mask", |d, sel| {
|
||||
if !matches!(d.node(top).map(|n| &n.kind), Some(NodeKind::Path { guide: false, .. } | NodeKind::Compound { .. } | NodeKind::Text(_))) {
|
||||
return Err(EngineError::Other("the top object must be a path, compound path or text object to use as a clipping mask".into()));
|
||||
}
|
||||
make_clipping_path(d, top)?;
|
||||
let (par, idx, _) = d.position(top).ok_or(EngineError::NoNode(top))?;
|
||||
let gid = d.alloc_id();
|
||||
d.insert(par, idx + 1, Node::new(gid, NodeKind::Group { children: vec![], clip: true }))?;
|
||||
|
|
@ -546,19 +576,6 @@ fn clip_make(s: &mut Session, _: &Value) -> Result<Value> {
|
|||
for id in &ids[..ids.len() - 1] {
|
||||
d.move_node(*id, Some(gid), usize::MAX)?;
|
||||
}
|
||||
if let Some(c) = d.node_mut(top) {
|
||||
// The clipping path loses its paint (it stays unpainted after Release).
|
||||
c.appearance = Appearance::basic(Paint::None, Paint::None, 0.0);
|
||||
match &mut c.kind {
|
||||
NodeKind::Path { clipping, .. } => *clipping = true,
|
||||
NodeKind::Text(t) => {
|
||||
for r in &mut t.runs {
|
||||
(r.style.fill, r.style.stroke) = (Paint::None, Paint::None);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
sel.set([gid]);
|
||||
Ok(gid)
|
||||
})?;
|
||||
|
|
@ -569,18 +586,7 @@ fn clip_release(s: &mut Session, _: &Value) -> Result<Value> {
|
|||
let ids = selected_roots(s)?;
|
||||
s.edit("Release Clipping Mask", |d, _| {
|
||||
for id in &ids {
|
||||
let clip_child = match d.node(*id).map(|n| &n.kind) {
|
||||
Some(NodeKind::Group { children, clip: true }) => children.first().map(|c| c.id),
|
||||
_ => continue,
|
||||
};
|
||||
if let Some(NodeKind::Group { clip, .. }) = d.node_mut(*id).map(|n| &mut n.kind) {
|
||||
*clip = false;
|
||||
}
|
||||
if let Some(c) = clip_child.and_then(|c| d.node_mut(c))
|
||||
&& let NodeKind::Path { clipping, .. } = &mut c.kind
|
||||
{
|
||||
*clipping = false;
|
||||
}
|
||||
release_clip(d, *id);
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
|
|
|
|||
|
|
@ -770,6 +770,8 @@ mod tests_gradpanel;
|
|||
#[cfg(test)]
|
||||
mod tests_knockout;
|
||||
#[cfg(test)]
|
||||
mod tests_layerclip;
|
||||
#[cfg(test)]
|
||||
mod tests_live;
|
||||
#[cfg(test)]
|
||||
mod tests_menucmds;
|
||||
|
|
|
|||
|
|
@ -7,13 +7,13 @@ use vectorcraft_render::Rendered;
|
|||
|
||||
use super::*;
|
||||
|
||||
fn session() -> Session {
|
||||
pub(crate) fn session() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn id_of(v: &Value) -> NodeId {
|
||||
pub(crate) fn id_of(v: &Value) -> NodeId {
|
||||
NodeId(v["id"].as_u64().unwrap())
|
||||
}
|
||||
|
||||
|
|
@ -21,16 +21,16 @@ fn ellipse(s: &mut Session, x: f64, w: f64) -> NodeId {
|
|||
id_of(&s.execute("shape.ellipse", &json!({"x": x, "y": x, "width": w, "height": w})).unwrap())
|
||||
}
|
||||
|
||||
fn select(s: &mut Session, ids: &[NodeId]) {
|
||||
pub(crate) fn select(s: &mut Session, ids: &[NodeId]) {
|
||||
s.execute("select.set", &json!({"ids": ids.iter().map(|i| i.0).collect::<Vec<_>>()})).unwrap();
|
||||
}
|
||||
|
||||
/// The active document's 100×100 artboard, rendered transparent.
|
||||
fn render(s: &Session) -> Rendered {
|
||||
pub(crate) fn render(s: &Session) -> Rendered {
|
||||
vectorcraft_render::Renderer::new().render(&s.doc().unwrap().doc, 100, 100, Affine::IDENTITY, &Default::default())
|
||||
}
|
||||
|
||||
fn opaque(r: &Rendered, x: u32, y: u32) -> bool {
|
||||
pub(crate) fn opaque(r: &Rendered, x: u32, y: u32) -> bool {
|
||||
r.pixel(x, y)[3] > 128
|
||||
}
|
||||
|
||||
|
|
@ -43,7 +43,7 @@ fn reopened(s: &mut Session, format: &str) -> Rendered {
|
|||
}
|
||||
|
||||
/// The same probes are opaque on screen and in the SVG and PDF output.
|
||||
fn assert_outputs_agree(s: &mut Session, probes: &[(u32, u32)]) {
|
||||
pub(crate) fn assert_outputs_agree(s: &mut Session, probes: &[(u32, u32)]) {
|
||||
let screen = render(s);
|
||||
for format in ["svg", "pdf"] {
|
||||
let out = reopened(s, format);
|
||||
|
|
@ -54,7 +54,7 @@ fn assert_outputs_agree(s: &mut Session, probes: &[(u32, u32)]) {
|
|||
}
|
||||
|
||||
/// The leaf a click at (`x`, `y`) hits.
|
||||
fn hit(s: &Session, x: f64, y: f64) -> Option<NodeId> {
|
||||
pub(crate) fn hit(s: &Session, x: f64, y: f64) -> Option<NodeId> {
|
||||
vectorcraft_doc::hit::hit_test(&s.doc().unwrap().doc, vectorcraft_geom::Point::new(x, y), Default::default()).map(|h| h.leaf)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,122 @@
|
|||
//! Layer clipping masks (M3.75): the Layers panel button makes the top object of the current layer
|
||||
//! (or of the selected group) clip the rest, and pressing it again releases the mask.
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
use super::tests_clip::{assert_outputs_agree, hit, id_of, opaque, render, select, session};
|
||||
use super::*;
|
||||
|
||||
fn rect(s: &mut Session, x: f64, y: f64, w: f64, h: f64) -> NodeId {
|
||||
id_of(&s.execute("shape.rectangle", &json!({"x": x, "y": y, "width": w, "height": h})).unwrap())
|
||||
}
|
||||
|
||||
fn toggle(s: &mut Session) -> bool {
|
||||
s.execute("layer.clippingMask.toggle", &json!({})).unwrap()["clip"].as_bool().unwrap()
|
||||
}
|
||||
|
||||
fn layer(s: &Session) -> vectorcraft_doc::Node {
|
||||
let st = s.doc().unwrap();
|
||||
st.doc.node(st.current_layer().unwrap()).unwrap().clone()
|
||||
}
|
||||
|
||||
/// A full-page square under a 50 × 50 square at 25..75 (the clip to be), nothing selected.
|
||||
fn art_and_clip(s: &mut Session) -> (NodeId, NodeId) {
|
||||
let art = rect(s, 0.0, 0.0, 100.0, 100.0);
|
||||
let clip = rect(s, 25.0, 25.0, 50.0, 50.0);
|
||||
s.execute("select.none", &json!({})).unwrap();
|
||||
(art, clip)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_top_object_clips_the_layer_and_pressing_again_releases_it() {
|
||||
let mut s = session();
|
||||
let (art, clip) = art_and_clip(&mut s);
|
||||
assert!(toggle(&mut s));
|
||||
let l = layer(&s);
|
||||
assert!(l.clips());
|
||||
// The clipping path loses its paint and goes to the bottom, so art added on top is clipped.
|
||||
let c = &l.children().unwrap()[0];
|
||||
assert_eq!(c.id, clip);
|
||||
assert!(c.appearance.fill_paint().is_none() && matches!(c.kind, NodeKind::Path { clipping: true, .. }));
|
||||
let r = render(&s);
|
||||
assert!(opaque(&r, 50, 50) && !opaque(&r, 10, 10));
|
||||
// Clicks only reach the art inside the clip.
|
||||
assert_eq!(hit(&s, 50.0, 50.0), Some(art));
|
||||
assert_eq!(hit(&s, 10.0, 10.0), None);
|
||||
rect(&mut s, 0.0, 80.0, 100.0, 20.0);
|
||||
assert!(!opaque(&render(&s), 50, 90), "new art is clipped");
|
||||
// Again: released, the clipping path stays unpainted.
|
||||
s.execute("select.none", &json!({})).unwrap();
|
||||
assert!(!toggle(&mut s));
|
||||
let l = layer(&s);
|
||||
assert!(!l.clips());
|
||||
let c = &l.children().unwrap()[0];
|
||||
assert!(c.appearance.fill_paint().is_none() && matches!(c.kind, NodeKind::Path { clipping: false, .. }));
|
||||
let r = render(&s);
|
||||
assert!(opaque(&r, 10, 10) && opaque(&r, 50, 90));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn making_a_layer_clipping_mask_is_one_undo_step() {
|
||||
let mut s = session();
|
||||
let (_, clip) = art_and_clip(&mut s);
|
||||
let before = layer(&s);
|
||||
toggle(&mut s);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(layer(&s), before, "the clip path is back on top with its paint");
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
assert!(layer(&s).clips());
|
||||
assert_eq!(layer(&s).children().unwrap()[0].id, clip);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layer_clipping_masks_save_and_export() {
|
||||
let mut s = session();
|
||||
art_and_clip(&mut s);
|
||||
toggle(&mut s);
|
||||
let doc = &s.doc().unwrap().doc;
|
||||
let back = vectorcraft_format::load(&vectorcraft_format::save(doc, false)).unwrap();
|
||||
assert!(back.layers[0].clips());
|
||||
assert_eq!(back, **doc);
|
||||
// An unclipped layer writes no `clip` key (older files stay as they were).
|
||||
let plain = String::from_utf8(vectorcraft_format::save(&session().doc().unwrap().doc, false)).unwrap();
|
||||
assert!(!plain.contains("\"clip\""), "{plain}");
|
||||
// SVG and PDF clip the same way as the canvas.
|
||||
assert_outputs_agree(&mut s, &[(50, 50), (10, 10), (30, 30), (80, 80), (74, 26)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_selected_group_becomes_a_clip_group() {
|
||||
let mut s = session();
|
||||
let (art, clip) = art_and_clip(&mut s);
|
||||
select(&mut s, &[art, clip]);
|
||||
let g = id_of(&s.execute("object.group", &json!({})).unwrap());
|
||||
select(&mut s, &[g]);
|
||||
assert!(toggle(&mut s));
|
||||
let n = s.doc().unwrap().doc.node(g).unwrap().clone();
|
||||
assert!(matches!(n.kind, NodeKind::Group { clip: true, .. }));
|
||||
assert_eq!(n.children().unwrap()[0].id, clip);
|
||||
assert!(!layer(&s).clips(), "the layer is left alone");
|
||||
assert!(!opaque(&render(&s), 10, 10));
|
||||
assert!(!toggle(&mut s));
|
||||
assert!(!s.doc().unwrap().doc.node(g).unwrap().clips());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_top_object_must_be_able_to_clip() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 0.0, 0.0, 50.0, 50.0);
|
||||
let b = rect(&mut s, 50.0, 50.0, 50.0, 50.0);
|
||||
select(&mut s, &[a, b]);
|
||||
s.execute("object.group", &json!({})).unwrap();
|
||||
s.execute("select.none", &json!({})).unwrap();
|
||||
let before = layer(&s);
|
||||
assert!(s.execute("layer.clippingMask.toggle", &json!({})).is_err(), "a group on top");
|
||||
assert_eq!(layer(&s), before);
|
||||
// An explicit id picks the layer even with a group selected.
|
||||
let l = before.id;
|
||||
s.execute("layer.newSublayer", &json!({"parent": l.0})).unwrap();
|
||||
let sub = layer(&s).children().unwrap().last().unwrap().id;
|
||||
let err = s.execute("layer.clippingMask.toggle", &json!({"id": sub.0}));
|
||||
assert!(err.is_err(), "an empty sublayer has nothing to clip by");
|
||||
}
|
||||
|
|
@ -208,18 +208,25 @@ 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) {
|
||||
/// Fill the page (with a margin) with a grey level (0 = black, 255 = white) at `opacity`: mask
|
||||
/// backdrops.
|
||||
fn cover(s: &mut Surface, page: Rect, level: u8, opacity: f32) {
|
||||
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.set_fill(Some(Fill { paint: rgb::Color::new(level, level, level).into(), opacity: norm(opacity), rule: krilla::paint::FillRule::NonZero }));
|
||||
s.draw_path(&p);
|
||||
}
|
||||
|
||||
/// An alpha mask of constant `alpha` over the page. A group drawn through it is a transparency
|
||||
/// group of that opacity that, unlike the writer's opacity groups, is not isolated.
|
||||
fn constant_mask(s: &mut Surface, page: Rect, alpha: f32) -> krilla::mask::Mask {
|
||||
let mut sb = s.stream_builder();
|
||||
let mut ms = sb.surface();
|
||||
cover(&mut ms, page, 0, alpha);
|
||||
ms.finish();
|
||||
krilla::mask::Mask::new(sb.finish(), krilla::mask::MaskType::Alpha)
|
||||
}
|
||||
|
||||
fn rects_overlap(a: Rect, b: Rect) -> bool {
|
||||
a.x0 <= b.x1 && b.x0 <= a.x1 && a.y0 <= b.y1 && b.y0 <= a.y1
|
||||
}
|
||||
|
|
@ -356,7 +363,7 @@ impl Exporter<'_> {
|
|||
// An opaque backdrop when it matters: white outside the art (no clip), or black for the
|
||||
// inverting Difference pass below to turn white.
|
||||
if !m.clip || m.invert {
|
||||
cover(&mut ms, page, if m.clip { 0 } else { 255 });
|
||||
cover(&mut ms, page, if m.clip { 0 } else { 255 }, 1.0);
|
||||
}
|
||||
// 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);
|
||||
|
|
@ -364,7 +371,7 @@ impl Exporter<'_> {
|
|||
self.knockout = knockout;
|
||||
if m.invert {
|
||||
ms.push_blend_mode(krilla::blend::BlendMode::Difference);
|
||||
cover(&mut ms, page, 255);
|
||||
cover(&mut ms, page, 255, 1.0);
|
||||
ms.pop();
|
||||
}
|
||||
ms.finish();
|
||||
|
|
@ -405,7 +412,7 @@ impl Exporter<'_> {
|
|||
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);
|
||||
cover(&mut ms, page, 255, 1.0);
|
||||
let alpha = {
|
||||
let mut ab = ms.stream_builder();
|
||||
let mut als = ab.surface();
|
||||
|
|
@ -414,7 +421,7 @@ impl Exporter<'_> {
|
|||
krilla::mask::Mask::new(ab.finish(), krilla::mask::MaskType::Alpha)
|
||||
};
|
||||
ms.push_mask(alpha);
|
||||
cover(&mut ms, page, 0);
|
||||
cover(&mut ms, page, 0, 1.0);
|
||||
ms.pop();
|
||||
ms.finish();
|
||||
krilla::mask::Mask::new(sb.finish(), krilla::mask::MaskType::Luminosity)
|
||||
|
|
@ -434,7 +441,7 @@ impl Exporter<'_> {
|
|||
}
|
||||
self.warn_raster(&n.appearance.effects);
|
||||
let container = n.is_container() && !matches!(n.kind, NodeKind::Compound { .. });
|
||||
// Whether this container's children knock each other out (a knockout group is isolated).
|
||||
// Whether this container's children knock each other out (a knockout group is written as a group).
|
||||
let knockout = n.knocks_out(self.knockout);
|
||||
let enclosing = std::mem::replace(&mut self.knockout, knockout);
|
||||
let mut pushes = 0;
|
||||
|
|
@ -442,11 +449,16 @@ impl Exporter<'_> {
|
|||
s.push_blend_mode(blend(n.blend));
|
||||
pushes += 1;
|
||||
}
|
||||
if n.opacity < 1.0 {
|
||||
s.push_opacity(norm(n.opacity));
|
||||
pushes += 1;
|
||||
} else if container && (n.isolate || n.blend != BlendMode::Normal || knockout) {
|
||||
s.push_isolated();
|
||||
if n.opacity < 1.0 || (container && (n.isolate || n.blend != BlendMode::Normal || knockout)) {
|
||||
if !n.isolate && n.blends_through() {
|
||||
// Not isolated: blending inside reaches the art below the group.
|
||||
let alpha = constant_mask(s, page, n.opacity);
|
||||
s.push_mask(alpha);
|
||||
} else if n.opacity < 1.0 {
|
||||
s.push_opacity(norm(n.opacity));
|
||||
} else {
|
||||
s.push_isolated();
|
||||
}
|
||||
pushes += 1;
|
||||
}
|
||||
if let Some(m) = n.mask.as_deref()
|
||||
|
|
@ -457,8 +469,8 @@ impl Exporter<'_> {
|
|||
pushes += 1;
|
||||
}
|
||||
match &n.kind {
|
||||
NodeKind::Layer { children, .. } | NodeKind::Group { children, clip: false } => self.children(s, children, page),
|
||||
NodeKind::Group { children, clip: true } => {
|
||||
NodeKind::Layer { children, clip: false, .. } | NodeKind::Group { children, clip: false } => self.children(s, children, page),
|
||||
NodeKind::Group { children, clip: true } | NodeKind::Layer { 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)))
|
||||
|
|
|
|||
|
|
@ -108,6 +108,8 @@ pub enum PdfError {
|
|||
#[cfg(test)]
|
||||
mod tests;
|
||||
#[cfg(test)]
|
||||
mod tests_blend;
|
||||
#[cfg(test)]
|
||||
mod tests_dashalign;
|
||||
#[cfg(test)]
|
||||
mod tests_fx;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,99 @@
|
|||
//! Blend modes and transparency groups in PDF export.
|
||||
|
||||
use vectorcraft_color::{BlendMode, Color, Paint};
|
||||
use vectorcraft_doc::{Appearance, Document, Node, NodeId};
|
||||
use vectorcraft_geom::{Rect, shapes};
|
||||
|
||||
use crate::*;
|
||||
|
||||
fn rect(r: Rect, rgb: (f32, f32, f32)) -> Node {
|
||||
Node::path(NodeId(0), shapes::rectangle(r), Appearance::basic(Paint::solid(Color::rgb(rgb.0, rgb.1, rgb.2)), Paint::None, 0.0))
|
||||
}
|
||||
|
||||
fn add(d: &mut Document, mut n: Node) {
|
||||
n.id = d.alloc_id();
|
||||
let l = d.layers[0].id;
|
||||
d.insert(Some(l), usize::MAX, n).unwrap();
|
||||
}
|
||||
|
||||
/// The file's text with spaces removed (content streams uncompressed).
|
||||
fn text(d: &Document) -> String {
|
||||
let bytes = export(d, &PdfOptions { compress: false, ..Default::default() }).unwrap();
|
||||
String::from_utf8_lossy(&bytes).replace(' ', "")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_blend_mode_is_written_under_its_pdf_name_and_read_back() {
|
||||
for mode in BlendMode::ALL.into_iter().filter(|m| *m != BlendMode::Normal) {
|
||||
let mut d = Document::new(100.0, 100.0);
|
||||
add(&mut d, rect(Rect::new(0.0, 0.0, 100.0, 100.0), (0.2, 0.5, 0.9)));
|
||||
let mut top = rect(Rect::new(20.0, 20.0, 80.0, 80.0), (0.6, 0.25, 0.5));
|
||||
top.blend = mode;
|
||||
add(&mut d, top);
|
||||
let name = mode.label().replace(' ', "");
|
||||
assert!(text(&d).contains(&format!("/BM/{name}")), "{mode:?}");
|
||||
let back = import(&export(&d, &PdfOptions::default()).unwrap()).unwrap();
|
||||
let mut found = BlendMode::Normal;
|
||||
back.walk(|n| {
|
||||
if n.blend != BlendMode::Normal {
|
||||
found = n.blend;
|
||||
}
|
||||
});
|
||||
assert_eq!(found, mode);
|
||||
}
|
||||
}
|
||||
|
||||
/// The backdrop and a 50% group (isolated or not) holding a Multiply square.
|
||||
fn half_group(isolate: bool) -> Document {
|
||||
let mut d = Document::new(100.0, 100.0);
|
||||
add(&mut d, rect(Rect::new(0.0, 0.0, 100.0, 100.0), (0.8, 0.6, 0.2)));
|
||||
let mut m = rect(Rect::new(20.0, 20.0, 80.0, 80.0), (0.5, 0.5, 1.0));
|
||||
(m.id, m.blend) = (d.alloc_id(), BlendMode::Multiply);
|
||||
let mut g = Node::group(NodeId(0), vec![std::sync::Arc::new(m)]);
|
||||
(g.opacity, g.isolate) = (0.5, isolate);
|
||||
add(&mut d, g);
|
||||
d
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_group_is_written_non_isolated_unless_it_isolates_blending() {
|
||||
let open = text(&half_group(false));
|
||||
// A transparency group without /I, its 50% carried by a constant alpha soft mask.
|
||||
assert!(!open.contains("/Itrue"), "{open}");
|
||||
assert!(open.contains("/SMask") && open.contains("/Alpha") && open.contains("/BM/Multiply"), "{open}");
|
||||
let isolated = text(&half_group(true));
|
||||
assert!(isolated.contains("/Itrue") && isolated.contains("/ca0.5"), "{isolated}");
|
||||
assert!(!isolated.contains("/Alpha"), "{isolated}");
|
||||
// Either way the child's blend mode reads back.
|
||||
for d in [half_group(false), half_group(true)] {
|
||||
let back = import(&export(&d, &PdfOptions::default()).unwrap()).unwrap();
|
||||
let mut bm = BlendMode::Normal;
|
||||
back.walk(|n| {
|
||||
if n.blend != BlendMode::Normal {
|
||||
bm = n.blend;
|
||||
}
|
||||
});
|
||||
assert_eq!(bm, BlendMode::Multiply);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn groups_without_blending_inside_stay_isolated_groups() {
|
||||
let mut d = half_group(false);
|
||||
let g = d.layers[0].children().unwrap()[1].id;
|
||||
let m = d.node(g).unwrap().children().unwrap()[0].id;
|
||||
d.node_mut(m).unwrap().blend = BlendMode::Normal;
|
||||
let t = text(&d);
|
||||
assert!(t.contains("/Itrue") && t.contains("/ca0.5") && !t.contains("/SMask"), "{t}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_knockout_group_with_blending_elements_is_non_isolated_unless_isolated() {
|
||||
for isolate in [false, true] {
|
||||
let mut d = half_group(isolate);
|
||||
let g = d.layers[0].children().unwrap()[1].id;
|
||||
let n = d.node_mut(g).unwrap();
|
||||
(n.opacity, n.knockout) = (1.0, vectorcraft_doc::Knockout::On);
|
||||
assert_eq!(text(&d).contains("/Itrue"), isolate, "isolate {isolate}");
|
||||
}
|
||||
}
|
||||
|
|
@ -13,7 +13,7 @@ use vectorcraft_doc::{AppearanceItem, Document, Node, StrokeLayer};
|
|||
use vectorcraft_geom::{BezPath, Rect};
|
||||
use vello_cpu::RenderContext;
|
||||
|
||||
use crate::{Frame, Renderer, blend_mode};
|
||||
use crate::{Frame, Renderer};
|
||||
|
||||
struct Entry {
|
||||
path: BezPath,
|
||||
|
|
@ -98,16 +98,15 @@ impl Renderer {
|
|||
pieces
|
||||
}
|
||||
};
|
||||
let layered = st.opacity < 1.0 || st.blend != vectorcraft_color::BlendMode::Normal;
|
||||
if layered {
|
||||
ctx.set_transform(vectorcraft_geom::Affine::IDENTITY);
|
||||
ctx.push_layer(None, Some(blend_mode(st.blend)), Some(st.opacity), None, None);
|
||||
}
|
||||
for p in pieces.iter() {
|
||||
self.draw_node(ctx, f, p, true);
|
||||
}
|
||||
if layered {
|
||||
ctx.pop_layer();
|
||||
let mut draw = |r: &mut Self, c: &mut RenderContext, fr: &Frame| {
|
||||
for p in pieces.iter() {
|
||||
r.draw_node(c, fr, p, true);
|
||||
}
|
||||
};
|
||||
if st.opacity < 1.0 || st.blend != vectorcraft_color::BlendMode::Normal {
|
||||
self.group(ctx, f, crate::group::Composite { blend: st.blend, opacity: st.opacity, ..Default::default() }, &mut draw);
|
||||
} else {
|
||||
draw(self, ctx, f);
|
||||
}
|
||||
true
|
||||
}
|
||||
|
|
|
|||
|
|
@ -127,7 +127,7 @@ pub(crate) fn visual_bounds(n: &Node) -> Option<Rect> {
|
|||
/// the subtree (a shadow can reach the view while its object is outside it).
|
||||
pub(crate) fn cull_bounds(n: &Node) -> Option<Rect> {
|
||||
match &n.kind {
|
||||
NodeKind::Layer { children, .. } | NodeKind::Group { children, clip: false } => {
|
||||
NodeKind::Layer { children, clip: false, .. } | NodeKind::Group { children, clip: false } => {
|
||||
children.iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, cull_bounds(c)))
|
||||
}
|
||||
_ if has_fx(n) => visual_bounds(n),
|
||||
|
|
@ -284,7 +284,7 @@ impl Renderer {
|
|||
// Silhouette (the object's painted alpha), offset by the shadow distance.
|
||||
let mut off = crate::single_threaded_context(w, h);
|
||||
let crop = Frame { mt: false, view: Affine::translate((-x0, -y0)) * f.view, ..*f };
|
||||
paint(self, &mut off, &shifted(&crop, dx, dy));
|
||||
self.inside_layer(|r| paint(r, &mut off, &shifted(&crop, dx, dy)));
|
||||
off.flush();
|
||||
let mut pm = vello_cpu::Pixmap::new(w, h);
|
||||
off.render(&mut pm, &mut self.resources);
|
||||
|
|
@ -343,11 +343,19 @@ impl Renderer {
|
|||
/// the art standing for the object's silhouette.
|
||||
pub(crate) fn draw_object_fx(&mut self, ctx: &mut RenderContext, f: &Frame, a: &Arc<Node>, cache: bool) {
|
||||
let opacity = self.opacity_of(a);
|
||||
let layered = !f.opts.outline && (opacity < 1.0 || a.blend != vectorcraft_doc::color::BlendMode::Normal || a.isolate);
|
||||
if layered {
|
||||
ctx.set_transform(Affine::IDENTITY);
|
||||
ctx.push_layer(None, Some(blend_mode(a.blend)), Some(opacity), None, None);
|
||||
if !f.opts.outline && (opacity < 1.0 || a.blend != vectorcraft_doc::color::BlendMode::Normal || a.isolate) {
|
||||
let blends = self.blends_through(a);
|
||||
let bounds = if blends { self.bounds_of(a) } else { None };
|
||||
let comp = crate::group::Composite { blend: a.blend, opacity, isolated: a.isolate, blends, bounds, ..Default::default() };
|
||||
self.group(ctx, f, comp, &mut |r, c, fr| r.draw_object_fx_art(c, fr, a, cache));
|
||||
} else {
|
||||
self.draw_object_fx_art(ctx, f, a, cache);
|
||||
}
|
||||
self.stats.drawn += 1;
|
||||
}
|
||||
|
||||
/// What [`Self::draw_object_fx`] draws inside the object's transparency group.
|
||||
fn draw_object_fx_art(&mut self, ctx: &mut RenderContext, f: &Frame, a: &Arc<Node>, cache: bool) {
|
||||
let art = self.fx_art(f.doc, a, cache);
|
||||
let rfx = effects::raster_effects(&a.appearance.effects);
|
||||
match cull_bounds(&art) {
|
||||
|
|
@ -363,10 +371,6 @@ impl Renderer {
|
|||
}
|
||||
_ => self.draw_node(ctx, f, &art, true),
|
||||
}
|
||||
if layered {
|
||||
ctx.pop_layer();
|
||||
}
|
||||
self.stats.drawn += 1;
|
||||
}
|
||||
|
||||
/// The art [`Self::draw_object_fx`] draws for `a` (cached by `Arc` identity when `cache`):
|
||||
|
|
@ -535,7 +539,7 @@ impl Renderer {
|
|||
draw: impl FnOnce(&mut Self, &mut RenderContext, &Frame, Option<(vectorcraft_doc::color::BlendMode, f32)>),
|
||||
) {
|
||||
if !f.mt {
|
||||
return draw(self, ctx, f, composite);
|
||||
return self.inside_layer(|r| draw(r, ctx, f, composite));
|
||||
}
|
||||
// 3σ of the Gaussian (σ = blur / 2) in pixels, plus a pixel of antialiasing.
|
||||
let spread = blur.max(0.0) * 1.5 / f.px + 2.0;
|
||||
|
|
@ -549,7 +553,7 @@ impl Renderer {
|
|||
let (w, h) = (w as u16, h as u16);
|
||||
let mut off = crate::single_threaded_context(w, h);
|
||||
let shifted = Frame { mt: false, view: Affine::translate((-x0, -y0)) * f.view, ..*f };
|
||||
draw(self, &mut off, &shifted, None);
|
||||
self.inside_layer(|r| draw(r, &mut off, &shifted, None));
|
||||
off.flush();
|
||||
let mut pm = vello_cpu::Pixmap::new(w, h);
|
||||
off.render(&mut pm, &mut self.resources);
|
||||
|
|
@ -558,13 +562,10 @@ impl Renderer {
|
|||
ctx.set_transform(Affine::IDENTITY);
|
||||
ctx.push_layer(None, Some(blend_mode(m)), Some(o), None, None);
|
||||
}
|
||||
ctx.set_transform(Affine::translate((x0, y0)));
|
||||
ctx.set_paint(vello_cpu::Image { image: vello_cpu::ImageSource::Pixmap(std::sync::Arc::new(pm)), sampler: peniko::ImageSampler::default() });
|
||||
ctx.fill_rect(&Rect::new(0.0, 0.0, w as f64, h as f64));
|
||||
crate::group::draw_pixmap(ctx, std::sync::Arc::new(pm), (x0, y0));
|
||||
if layered {
|
||||
ctx.pop_layer();
|
||||
}
|
||||
ctx.set_transform(Affine::IDENTITY);
|
||||
}
|
||||
|
||||
/// Paint the fills and strokes of `n` on geometry `g`, applying per-item geometry effects and
|
||||
|
|
|
|||
|
|
@ -0,0 +1,275 @@
|
|||
//! Transparency groups: groups with opacity or a blend mode, clip groups, opacity masks, the page
|
||||
//! group. Most are vello layers, which are isolated (their content starts from transparency). A
|
||||
//! non-isolated group whose content blends is drawn offscreen over a copy of its backdrop, so its
|
||||
//! children blend with the art below it, then mixed back in (backdrop-copy compositing).
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use vectorcraft_color::BlendMode as Blend;
|
||||
use vectorcraft_doc::Node;
|
||||
use vectorcraft_geom::{Affine, BezPath, FillRule, Rect};
|
||||
use vello_cpu::peniko::{self, BlendMode, Compose, Mix};
|
||||
use vello_cpu::{Pixmap, RenderContext};
|
||||
|
||||
use crate::{Frame, Renderer, blend_mode, fill_rule, single_threaded_context};
|
||||
|
||||
/// How a group composites onto what is below it.
|
||||
pub(crate) struct Composite<'a> {
|
||||
/// Clipping path (document space).
|
||||
pub clip: Option<(&'a BezPath, FillRule)>,
|
||||
pub blend: Blend,
|
||||
pub opacity: f32,
|
||||
/// Opacity mask (the context's size).
|
||||
pub mask: Option<vello_cpu::Mask>,
|
||||
/// The content starts from transparency instead of from the art below.
|
||||
pub isolated: bool,
|
||||
/// Blending inside reaches the group's backdrop ([`Node::blends_through`]).
|
||||
pub blends: bool,
|
||||
/// Area the content covers (document space); `None` = anywhere.
|
||||
pub bounds: Option<Rect>,
|
||||
}
|
||||
|
||||
impl Default for Composite<'_> {
|
||||
fn default() -> Self {
|
||||
Self { clip: None, blend: Blend::Normal, opacity: 1.0, mask: None, isolated: false, blends: false, bounds: None }
|
||||
}
|
||||
}
|
||||
|
||||
/// Group content drawn into a context (called once, or twice for a non-isolated group with a blend
|
||||
/// mode: over the backdrop copy, then alone for its shape).
|
||||
pub(crate) type Content<'d> = dyn FnMut(&mut Renderer, &mut RenderContext, &Frame) + 'd;
|
||||
|
||||
/// A pixel region of a context: origin and size.
|
||||
#[derive(Clone, Copy)]
|
||||
struct Region {
|
||||
x: u16,
|
||||
y: u16,
|
||||
w: u16,
|
||||
h: u16,
|
||||
}
|
||||
|
||||
impl Renderer {
|
||||
/// Draw a group's content with `draw` and composite it as `c` says. Blending content is drawn
|
||||
/// offscreen: over a copy of the backdrop when the group isn't isolated (possible only with
|
||||
/// no layer open around it, else it is drawn isolated), from transparency when it is (so
|
||||
/// non-isolated groups inside can copy their backdrop).
|
||||
pub(crate) fn group(&mut self, ctx: &mut RenderContext, f: &Frame, c: Composite, draw: &mut Content) {
|
||||
if c.blends
|
||||
&& !c.isolated
|
||||
&& c.opacity >= 1.0
|
||||
&& c.blend == Blend::Normal
|
||||
&& c.mask.is_none()
|
||||
&& let Some((path, rule)) = c.clip
|
||||
{
|
||||
// A plain clip clips each drawing instead of compositing a layer: blending inside
|
||||
// reaches the art below directly.
|
||||
ctx.set_transform(f.view);
|
||||
ctx.set_fill_rule(fill_rule(rule));
|
||||
ctx.push_clip_path(path);
|
||||
self.clip_paths.push((path.clone(), rule, f.view));
|
||||
draw(self, ctx, f);
|
||||
self.clip_paths.pop();
|
||||
ctx.pop_clip_path();
|
||||
return;
|
||||
}
|
||||
if c.blends && (c.isolated || self.nested == 0) {
|
||||
return self.group_offscreen(ctx, f, c, draw);
|
||||
}
|
||||
ctx.set_transform(f.view);
|
||||
if let Some((_, rule)) = c.clip {
|
||||
ctx.set_fill_rule(fill_rule(rule));
|
||||
}
|
||||
ctx.push_layer(c.clip.map(|(p, _)| p), Some(blend_mode(c.blend)), Some(c.opacity), c.mask, None);
|
||||
self.nested += 1;
|
||||
// The layer starts from transparency: no backdrop for the content.
|
||||
let backdrop = self.backdrop.take();
|
||||
draw(self, ctx, f);
|
||||
self.backdrop = backdrop;
|
||||
self.nested -= 1;
|
||||
ctx.pop_layer();
|
||||
}
|
||||
|
||||
fn group_offscreen(&mut self, ctx: &mut RenderContext, f: &Frame, c: Composite, draw: &mut Content) {
|
||||
let Composite { clip, blend, opacity, mask, isolated, bounds, .. } = c;
|
||||
let Some(r) = region(ctx, f, bounds) else { return };
|
||||
let view = Affine::translate((-(r.x as f64), -(r.y as f64))) * f.view;
|
||||
let visible = view.inverse().transform_rect_bbox(Rect::new(0.0, 0.0, r.w as f64, r.h as f64));
|
||||
let sub = Frame { mt: false, view, visible, ..*f };
|
||||
let backdrop = (!isolated).then(|| self.flatten(ctx, r));
|
||||
let content = self.offscreen(r, &sub, backdrop.clone(), draw);
|
||||
let at = (r.x as f64, r.y as f64);
|
||||
let push = |ctx: &mut RenderContext, mode: BlendMode, mask: Option<vello_cpu::Mask>| {
|
||||
ctx.set_transform(f.view);
|
||||
if let Some((_, rule)) = clip {
|
||||
ctx.set_fill_rule(fill_rule(rule));
|
||||
}
|
||||
ctx.push_layer(clip.map(|(p, _)| p), Some(mode), Some(opacity), mask, None);
|
||||
};
|
||||
match backdrop {
|
||||
// The backdrop under the content, mixed with it by opacity, mask and clip: take that
|
||||
// share of the backdrop out, then add the same share of the content.
|
||||
Some(_) if blend == Blend::Normal => {
|
||||
push(ctx, BlendMode::new(Mix::Normal, Compose::DestOut), mask.clone());
|
||||
ctx.set_transform(Affine::IDENTITY);
|
||||
ctx.set_paint(peniko::Color::BLACK);
|
||||
ctx.fill_rect(&Rect::new(at.0, at.1, at.0 + r.w as f64, at.1 + r.h as f64));
|
||||
ctx.pop_layer();
|
||||
push(ctx, BlendMode::new(Mix::Normal, Compose::Plus), mask);
|
||||
draw_pixmap(ctx, Arc::new(content), at);
|
||||
}
|
||||
// A blend mode applies to the group as an object of its own: take the backdrop's share
|
||||
// out of the content (it needs the content's own coverage), then blend the rest.
|
||||
Some(b) => {
|
||||
let alone = self.offscreen(r, &sub, None, draw);
|
||||
push(ctx, blend_mode(blend), mask);
|
||||
draw_pixmap(ctx, Arc::new(remove_backdrop(content, &alone, &b)), at);
|
||||
}
|
||||
None => {
|
||||
push(ctx, blend_mode(blend), mask);
|
||||
draw_pixmap(ctx, Arc::new(content), at);
|
||||
}
|
||||
}
|
||||
ctx.pop_layer();
|
||||
}
|
||||
|
||||
/// Render what `ctx` holds so far, and start it again from that picture (so later drawing sees
|
||||
/// it unchanged). Returns the pixels of `r`.
|
||||
fn flatten(&mut self, ctx: &mut RenderContext, r: Region) -> Arc<Pixmap> {
|
||||
ctx.flush();
|
||||
let mut all = Pixmap::new(ctx.width(), ctx.height());
|
||||
ctx.render(&mut all, &mut self.resources);
|
||||
ctx.reset();
|
||||
let crop = if (r.x, r.y, r.w, r.h) == (0, 0, all.width(), all.height()) {
|
||||
None
|
||||
} else {
|
||||
let mut out = Pixmap::new(r.w, r.h);
|
||||
let (src_w, w) = (all.width() as usize, r.w as usize);
|
||||
for row in 0..r.h as usize {
|
||||
let from = (r.y as usize + row) * src_w + r.x as usize;
|
||||
out.data_mut()[row * w..(row + 1) * w].copy_from_slice(&all.data()[from..from + w]);
|
||||
}
|
||||
Some(Arc::new(out))
|
||||
};
|
||||
let all = Arc::new(all);
|
||||
draw_pixmap(ctx, all.clone(), (0.0, 0.0));
|
||||
for (path, rule, xf) in &self.clip_paths {
|
||||
ctx.set_transform(*xf);
|
||||
ctx.set_fill_rule(fill_rule(*rule));
|
||||
ctx.push_clip_path(path);
|
||||
}
|
||||
crop.unwrap_or(all)
|
||||
}
|
||||
|
||||
/// `draw` rendered into a new context of region `r` (frame `sub`), over `backdrop` if any.
|
||||
fn offscreen(&mut self, r: Region, sub: &Frame, backdrop: Option<Arc<Pixmap>>, draw: &mut Content) -> Pixmap {
|
||||
let mut ctx = single_threaded_context(r.w, r.h);
|
||||
if let Some(b) = &backdrop {
|
||||
draw_pixmap(&mut ctx, b.clone(), (0.0, 0.0));
|
||||
}
|
||||
let outer = (std::mem::replace(&mut self.nested, 0), std::mem::replace(&mut self.backdrop, backdrop), std::mem::take(&mut self.clip_paths));
|
||||
draw(self, &mut ctx, sub);
|
||||
(self.nested, self.backdrop, self.clip_paths) = outer;
|
||||
ctx.flush();
|
||||
let mut pm = Pixmap::new(r.w, r.h);
|
||||
ctx.render(&mut pm, &mut self.resources);
|
||||
pm
|
||||
}
|
||||
|
||||
/// Run `draw` inside layers (or into another context) it opens itself: groups drawn there can't
|
||||
/// copy their backdrop, and see none.
|
||||
pub(crate) fn inside_layer<T>(&mut self, draw: impl FnOnce(&mut Self) -> T) -> T {
|
||||
self.nested += 1;
|
||||
let backdrop = self.backdrop.take();
|
||||
let out = draw(self);
|
||||
self.backdrop = backdrop;
|
||||
self.nested -= 1;
|
||||
out
|
||||
}
|
||||
|
||||
/// Whether blending inside `n` reaches its backdrop ([`Node::blends_through`], cached per
|
||||
/// container).
|
||||
pub(crate) fn blends_through(&mut self, n: &Node) -> bool {
|
||||
n.blends_through_with(&mut |c| self.blends_of(c))
|
||||
}
|
||||
|
||||
/// [`Self::blends_through`] of the group `children` make up.
|
||||
pub(crate) fn children_blend(&mut self, children: &[Arc<Node>]) -> bool {
|
||||
Node::children_blend(children, &mut |c| self.blends_of(c))
|
||||
}
|
||||
|
||||
fn blends_of(&mut self, a: &Arc<Node>) -> bool {
|
||||
if !a.is_container() {
|
||||
return a.blends_through();
|
||||
}
|
||||
let key = Arc::as_ptr(a) as usize;
|
||||
if let Some(e) = self.blends.get_mut(&key)
|
||||
&& Arc::ptr_eq(&e.0, a)
|
||||
{
|
||||
e.2 = self.stamp;
|
||||
return e.1;
|
||||
}
|
||||
let v = a.blends_through_with(&mut |c| self.blends_of(c));
|
||||
if self.blends.len() > 1024 {
|
||||
let g = self.stamp;
|
||||
self.blends.retain(|_, e| g - e.2 <= 3);
|
||||
}
|
||||
self.blends.insert(key, (a.clone(), v, self.stamp));
|
||||
v
|
||||
}
|
||||
|
||||
/// Draw knockout element `c` in a layer composited with `compose`: as its knockout shape
|
||||
/// (`DestOut` erases what the elements below drew there, `DestIn` keeps only that part), or
|
||||
/// as itself (`Plus` adds it). In a non-isolated knockout group (`backdrop`), the element is
|
||||
/// laid over the group's backdrop and cut to its shape, so it composites against the art
|
||||
/// below the group instead of the elements below it.
|
||||
pub(crate) fn knockout_pass(&mut self, ctx: &mut RenderContext, f: &Frame, c: &Arc<Node>, compose: Compose, backdrop: Option<&Arc<Pixmap>>) {
|
||||
ctx.set_transform(Affine::IDENTITY);
|
||||
ctx.push_layer(None, Some(BlendMode::new(Mix::Normal, compose)), None, None, None);
|
||||
self.nested += 1;
|
||||
if let Some(b) = backdrop {
|
||||
draw_pixmap(ctx, b.clone(), (0.0, 0.0));
|
||||
}
|
||||
let shape = compose != Compose::Plus && !c.knockout_shape;
|
||||
self.shape_of = if shape { Arc::as_ptr(c) as usize } else { 0 };
|
||||
self.draw_arc(ctx, f, c);
|
||||
self.shape_of = 0;
|
||||
if backdrop.is_some() {
|
||||
self.knockout_pass(ctx, f, c, Compose::DestIn, None);
|
||||
}
|
||||
self.nested -= 1;
|
||||
ctx.pop_layer();
|
||||
}
|
||||
}
|
||||
|
||||
/// Draw `pm` with its top-left corner at pixel `at` (whole pixels: copied exactly).
|
||||
pub(crate) fn draw_pixmap(ctx: &mut RenderContext, pm: Arc<Pixmap>, at: (f64, f64)) {
|
||||
let (w, h) = (pm.width() as f64, pm.height() as f64);
|
||||
ctx.set_transform(Affine::translate(at));
|
||||
ctx.set_paint(vello_cpu::Image { image: vello_cpu::ImageSource::Pixmap(pm), sampler: peniko::ImageSampler::default() });
|
||||
ctx.fill_rect(&Rect::new(0.0, 0.0, w, h));
|
||||
ctx.set_transform(Affine::IDENTITY);
|
||||
}
|
||||
|
||||
/// The pixel region of `ctx` that `bounds` (document space) covers, with a margin for
|
||||
/// antialiasing; `None` when it is off the context.
|
||||
fn region(ctx: &RenderContext, f: &Frame, bounds: Option<Rect>) -> Option<Region> {
|
||||
let full = Rect::new(0.0, 0.0, ctx.width() as f64, ctx.height() as f64);
|
||||
let r = match bounds {
|
||||
Some(b) => f.view.transform_rect_bbox(b).inflate(2.0, 2.0).intersect(full),
|
||||
None => full,
|
||||
};
|
||||
let (x0, y0, x1, y1) = (r.x0.floor().max(0.0), r.y0.floor().max(0.0), r.x1.ceil().min(full.x1), r.y1.ceil().min(full.y1));
|
||||
(x1 > x0 && y1 > y0).then_some(Region { x: x0 as u16, y: y0 as u16, w: (x1 - x0) as u16, h: (y1 - y0) as u16 })
|
||||
}
|
||||
|
||||
/// The group's own colour from `content` (the group drawn over `backdrop`) and `alone` (drawn
|
||||
/// over transparency, for its coverage): content = group + (1 − group alpha) × backdrop, all
|
||||
/// premultiplied.
|
||||
fn remove_backdrop(mut content: Pixmap, alone: &Pixmap, backdrop: &Pixmap) -> Pixmap {
|
||||
for ((c, a), b) in content.data_mut().iter_mut().zip(alone.data()).zip(backdrop.data()) {
|
||||
let keep = 255 - a.a as u32;
|
||||
let sub = |v: u8, bv: u8| (v as u32).saturating_sub((keep * bv as u32 + 127) / 255).min(a.a as u32) as u8;
|
||||
(c.r, c.g, c.b, c.a) = (sub(c.r, b.r), sub(c.g, b.g), sub(c.b, b.b), a.a);
|
||||
}
|
||||
content
|
||||
}
|
||||
|
|
@ -8,6 +8,7 @@
|
|||
|
||||
mod brush_fx;
|
||||
mod fx;
|
||||
mod group;
|
||||
mod live;
|
||||
mod paint;
|
||||
mod pattern;
|
||||
|
|
@ -23,6 +24,8 @@ use vello_cpu::kurbo;
|
|||
use vello_cpu::peniko::{self, BlendMode, Compose, Mix};
|
||||
use vello_cpu::{Pixmap, RenderContext, Resources};
|
||||
|
||||
use group::Composite;
|
||||
|
||||
pub use effects::stroke::width_outline;
|
||||
pub use live::expand_live;
|
||||
pub use pattern::render_pattern_swatch;
|
||||
|
|
@ -237,6 +240,17 @@ pub struct Renderer {
|
|||
shape_of: usize,
|
||||
/// The art drawn for objects with effects that apply through it (see `fx::has_object_fx`).
|
||||
fx_arts: PtrMap<usize, (Arc<Node>, Arc<Node>)>,
|
||||
/// Layers open around the drawing point in the current context: a non-isolated group can copy
|
||||
/// its backdrop only when there are none (see [`Self::group`]).
|
||||
nested: u32,
|
||||
/// Copy of the backdrop of the non-isolated group being drawn offscreen, in its context's
|
||||
/// pixels: the elements of a non-isolated knockout group composite against it.
|
||||
backdrop: Option<Arc<Pixmap>>,
|
||||
/// [`Node::blends_through`] per container, keyed like `geom`.
|
||||
blends: PtrMap<usize, (Arc<Node>, bool, u64)>,
|
||||
/// Clip paths pushed on the current context (path, rule, transform): starting the context
|
||||
/// again from a picture of it pushes them again (see [`Self::group`]).
|
||||
clip_paths: Vec<(BezPath, FillRule, Affine)>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
|
|
@ -287,6 +301,10 @@ impl Renderer {
|
|||
knockout: false,
|
||||
shape_of: 0,
|
||||
fx_arts: PtrMap::default(),
|
||||
nested: 0,
|
||||
backdrop: None,
|
||||
blends: PtrMap::default(),
|
||||
clip_paths: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -327,40 +345,18 @@ impl Renderer {
|
|||
}
|
||||
}
|
||||
self.stamp += 1;
|
||||
let trim = opts.trim && !doc.artboards.is_empty();
|
||||
if trim {
|
||||
(self.nested, self.backdrop) = (0, None);
|
||||
self.clip_paths.clear();
|
||||
if opts.trim && !doc.artboards.is_empty() {
|
||||
let mut clip = BezPath::new();
|
||||
for ab in &doc.artboards {
|
||||
clip.extend(ab.rect.path_elements(0.1));
|
||||
}
|
||||
ctx.set_transform(view);
|
||||
ctx.push_clip_layer(&clip);
|
||||
}
|
||||
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);
|
||||
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();
|
||||
let blends = self.children_blend(&doc.layers);
|
||||
let trim = Composite { clip: Some((&clip, FillRule::NonZero)), blends, ..Default::default() };
|
||||
self.group(&mut ctx, &frame, trim, &mut |r, c, fr| r.draw_page(c, fr));
|
||||
} else {
|
||||
self.draw_page(&mut ctx, &frame);
|
||||
}
|
||||
// Drop cache entries not seen for a few frames.
|
||||
let g = self.stamp;
|
||||
|
|
@ -387,6 +383,32 @@ impl Renderer {
|
|||
Rendered { width: w as u32, height: h as u32, pixels }
|
||||
}
|
||||
|
||||
/// The page's art: the layers (or the pattern being edited), inside the page group when the
|
||||
/// page has one.
|
||||
fn draw_page(&mut self, ctx: &mut RenderContext, f: &Frame) {
|
||||
if self.draw_pattern_edit(ctx, f) {
|
||||
return;
|
||||
}
|
||||
let doc = f.doc;
|
||||
// While editing an opacity mask its art is seen only through the mask.
|
||||
let mask_layer = doc.mask_edit.map(|m| m.layer);
|
||||
let layers: Vec<Arc<Node>> = doc.layers.iter().filter(|l| Some(l.id) != mask_layer).cloned().collect();
|
||||
// Page Isolated Blending / Page Knockout Group: the page is a group of its own.
|
||||
let page_group = !f.opts.outline && (doc.page_isolate || doc.page_knockout);
|
||||
let knockout = page_group && doc.page_knockout;
|
||||
let mut draw = |r: &mut Self, c: &mut RenderContext, fr: &Frame| {
|
||||
r.knockout = knockout;
|
||||
r.draw_children(c, fr, &layers, knockout);
|
||||
r.knockout = false;
|
||||
};
|
||||
if page_group {
|
||||
let blends = self.children_blend(&layers);
|
||||
self.group(ctx, f, Composite { isolated: doc.page_isolate, blends, ..Default::default() }, &mut draw);
|
||||
} else {
|
||||
draw(self, ctx, f);
|
||||
}
|
||||
}
|
||||
|
||||
/// Render one artboard (or any document rect) at `scale` pixels per point, transparent or on white,
|
||||
/// as exported: template layers are left out.
|
||||
pub fn render_region(&mut self, doc: &Document, region: Rect, scale: f64, white: bool) -> Rendered {
|
||||
|
|
@ -415,7 +437,8 @@ 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.knockout, self.nested, self.backdrop) = (false, 0, None);
|
||||
self.clip_paths.clear();
|
||||
self.draw_node(&mut ctx, &frame, n, true);
|
||||
ctx.flush();
|
||||
let mut pm = Pixmap::new(w, w);
|
||||
|
|
@ -433,7 +456,7 @@ impl Renderer {
|
|||
return e.bounds;
|
||||
}
|
||||
let b = match &a.kind {
|
||||
NodeKind::Layer { children, .. } | NodeKind::Group { children, clip: false } if !fx::has_fx(a) => {
|
||||
NodeKind::Layer { children, clip: false, .. } | NodeKind::Group { children, clip: false } if !fx::has_fx(a) => {
|
||||
let mut acc: Option<Rect> = None;
|
||||
for c in children {
|
||||
if c.visible {
|
||||
|
|
@ -508,11 +531,11 @@ impl Renderer {
|
|||
&& !f.opts.outline
|
||||
&& !self.is_shape(a)
|
||||
{
|
||||
let mask = self.opacity_mask(f, m, ctx.width(), ctx.height());
|
||||
ctx.set_transform(Affine::IDENTITY);
|
||||
ctx.push_layer(None, None, None, Some(mask), None);
|
||||
self.draw_arc_body(ctx, f, a);
|
||||
ctx.pop_layer();
|
||||
// A masked object is a non-isolated group of its own.
|
||||
let mask = Some(self.opacity_mask(f, m, ctx.width(), ctx.height()));
|
||||
let blends = a.blend != vectorcraft_color::BlendMode::Normal || (!a.isolate && self.blends_through(a));
|
||||
let bounds = if blends { self.bounds_of(a) } else { None };
|
||||
self.group(ctx, f, Composite { mask, blends, bounds, ..Default::default() }, &mut |r, c, fr| r.draw_arc_body(c, fr, a));
|
||||
return;
|
||||
}
|
||||
self.draw_arc_body(ctx, f, a);
|
||||
|
|
@ -522,9 +545,10 @@ impl Renderer {
|
|||
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);
|
||||
let outer =
|
||||
(std::mem::take(&mut self.knockout), std::mem::take(&mut self.nested), self.backdrop.take(), std::mem::take(&mut self.clip_paths));
|
||||
self.draw_node(&mut mctx, &Frame { mt: false, ..*f }, &m.art, true);
|
||||
self.knockout = knockout;
|
||||
(self.knockout, self.nested, self.backdrop, self.clip_paths) = outer;
|
||||
mctx.flush();
|
||||
let mut pm = Pixmap::new(w, h);
|
||||
mctx.render(&mut pm, &mut self.resources);
|
||||
|
|
@ -598,45 +622,44 @@ impl Renderer {
|
|||
return self.draw_object_fx(ctx, f, &Arc::new(n.clone()), false);
|
||||
}
|
||||
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 { 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).
|
||||
// Whether this container's children knock each other out.
|
||||
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;
|
||||
let blends = self.blends_through(n);
|
||||
let bounds = if blends { self.node_bounds(n) } else { None };
|
||||
let comp = Composite { blend: n.blend, opacity, isolated: n.isolate, blends, bounds, ..Default::default() };
|
||||
self.group(ctx, f, comp, &mut |r, c, fr| r.draw_content(c, fr, n, knockout));
|
||||
} else {
|
||||
self.draw_content(ctx, f, n, knockout);
|
||||
}
|
||||
self.knockout = enclosing;
|
||||
self.stats.drawn += 1;
|
||||
}
|
||||
|
||||
/// What `n` draws inside its transparency group (`knockout`: its children knock each other out).
|
||||
fn draw_content(&mut self, ctx: &mut RenderContext, f: &Frame, n: &Node, knockout: bool) {
|
||||
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);
|
||||
}
|
||||
NodeKind::Layer { children, clip: false, .. } | NodeKind::Group { children, clip: false } => {
|
||||
self.draw_children(ctx, f, children, knockout)
|
||||
}
|
||||
NodeKind::Group { children, clip: true } if outline => {
|
||||
NodeKind::Group { children, clip: true } | NodeKind::Layer { children, clip: true, .. } if f.opts.outline => {
|
||||
for c in children {
|
||||
self.draw_node(ctx, f, c, false);
|
||||
}
|
||||
}
|
||||
NodeKind::Group { children, clip: true } => {
|
||||
// Nothing to clip by hides the clipped art (as in the SVG and PDF output).
|
||||
NodeKind::Group { children, clip: true } | NodeKind::Layer { children, clip: true, .. } => {
|
||||
// Nothing to clip by hides the clipped art (as in the SVG and PDF output). A clip
|
||||
// group doesn't isolate: blending inside it reaches the art below.
|
||||
if let Some((clip, rest)) = children.split_first()
|
||||
&& let Some(region) = self.clip_of(clip)
|
||||
{
|
||||
ctx.set_transform(f.view);
|
||||
ctx.set_fill_rule(fill_rule(region.1));
|
||||
ctx.push_clip_layer(®ion.0);
|
||||
if knockout {
|
||||
self.draw_knockout(ctx, f, rest);
|
||||
} else {
|
||||
for c in rest {
|
||||
self.draw_arc(ctx, f, c);
|
||||
}
|
||||
}
|
||||
ctx.pop_layer();
|
||||
let blends = self.blends_through(n);
|
||||
let bounds = if blends { Some(region.0.bounding_box()) } else { None };
|
||||
let comp = Composite { clip: Some((®ion.0, region.1)), blends, bounds, ..Default::default() };
|
||||
self.group(ctx, f, comp, &mut |r, c, fr| r.draw_children(c, fr, rest, knockout));
|
||||
}
|
||||
}
|
||||
NodeKind::Path { path, rule, guide, .. } => {
|
||||
|
|
@ -667,10 +690,25 @@ 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 group: as the elements of a knockout group when `knockout`.
|
||||
fn draw_children(&mut self, ctx: &mut RenderContext, f: &Frame, children: &[Arc<Node>], knockout: bool) {
|
||||
if knockout {
|
||||
self.draw_knockout(ctx, f, children);
|
||||
} else {
|
||||
for c in children {
|
||||
self.draw_arc(ctx, f, c);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Cull bounds of `n`: cached when it is a node of the tree being drawn.
|
||||
fn node_bounds(&mut self, n: &Node) -> Option<Rect> {
|
||||
match self.geom.get(&(n as *const Node as usize)) {
|
||||
// The entry keeps its node alive, so the address is that node's.
|
||||
Some(e) if std::ptr::eq(Arc::as_ptr(&e.node), n) => e.bounds,
|
||||
_ => fx::cull_bounds(n),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -678,21 +716,18 @@ impl Renderer {
|
|||
/// 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.
|
||||
/// over the elements below it (the art below the group when the group is non-isolated and
|
||||
/// drawn over a copy of it, see [`Self::group`]).
|
||||
fn draw_knockout(&mut self, ctx: &mut RenderContext, f: &Frame, children: &[Arc<Node>]) {
|
||||
let backdrop = self.backdrop.take();
|
||||
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();
|
||||
}
|
||||
self.knockout_pass(ctx, f, c, Compose::DestOut, None);
|
||||
self.knockout_pass(ctx, f, c, Compose::Plus, backdrop.as_ref());
|
||||
}
|
||||
self.backdrop = backdrop;
|
||||
}
|
||||
|
||||
/// Whether `n` is the knockout element being drawn as its shape (see [`Self::draw_knockout`]).
|
||||
|
|
@ -968,7 +1003,8 @@ impl Renderer {
|
|||
/// Opacity-mask coverage of one premultiplied pixel: luminance, with the area outside the mask
|
||||
/// art black (clip) or white (no clip), optionally inverted.
|
||||
fn mask_value(r: u8, g: u8, b: u8, a: u8, clip: bool, invert: bool) -> u8 {
|
||||
let mut l = (0.2126 * r as f32 + 0.7152 * g as f32 + 0.0722 * b as f32) / 255.0;
|
||||
let [kr, kg, kb] = vectorcraft_color::blend::MASK_LUM;
|
||||
let mut l = (kr * r as f32 + kg * g as f32 + kb * b as f32) / 255.0;
|
||||
if !clip {
|
||||
l += 1.0 - a as f32 / 255.0;
|
||||
}
|
||||
|
|
@ -1101,8 +1137,12 @@ fn now() -> u64 {
|
|||
#[cfg(test)]
|
||||
mod tests;
|
||||
#[cfg(test)]
|
||||
mod tests_blend;
|
||||
#[cfg(test)]
|
||||
mod tests_clip;
|
||||
#[cfg(test)]
|
||||
mod tests_isolation;
|
||||
#[cfg(test)]
|
||||
mod tests_knockout;
|
||||
#[cfg(test)]
|
||||
mod tests_objectfx;
|
||||
|
|
|
|||
|
|
@ -11,11 +11,11 @@ use std::sync::Arc;
|
|||
|
||||
use vectorcraft_doc::live::{self, GradientMesh, MeshQuad};
|
||||
use vectorcraft_doc::{Node, NodeKind};
|
||||
use vectorcraft_geom::{Affine, BezPath, PathData, Point};
|
||||
use vectorcraft_geom::{BezPath, PathData, Point};
|
||||
use vello_cpu::RenderContext;
|
||||
use vello_cpu::peniko;
|
||||
|
||||
use crate::{Frame, Renderer, blend_mode, text_geom};
|
||||
use crate::{Frame, Renderer, text_geom};
|
||||
|
||||
type Expanded = Arc<Vec<Arc<Node>>>;
|
||||
type MeshEntry = (Arc<Node>, Arc<Vec<MeshQuad>>, u64);
|
||||
|
|
@ -113,14 +113,13 @@ impl Renderer {
|
|||
let stamp = self.stamp;
|
||||
self.live.tick(stamp);
|
||||
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(opacity), None, None);
|
||||
}
|
||||
self.draw_live_body(ctx, f, a, true);
|
||||
if layered {
|
||||
ctx.pop_layer();
|
||||
if !f.opts.outline && (opacity < 1.0 || a.blend != vectorcraft_color::BlendMode::Normal || a.isolate) {
|
||||
let blends = self.blends_through(a);
|
||||
let bounds = if blends { self.bounds_of(a) } else { None };
|
||||
let comp = crate::group::Composite { blend: a.blend, opacity, isolated: a.isolate, blends, bounds, ..Default::default() };
|
||||
self.group(ctx, f, comp, &mut |r, c, fr| r.draw_live_body(c, fr, a, true));
|
||||
} else {
|
||||
self.draw_live_body(ctx, f, a, true);
|
||||
}
|
||||
self.stats.drawn += 1;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -183,17 +183,15 @@ impl Renderer {
|
|||
let children: Vec<Arc<Node>> = layer.children().cloned().unwrap_or_default();
|
||||
if layer.visible && !children.is_empty() {
|
||||
let dim = (def.dim_copies / 100.0).clamp(0.0, 1.0);
|
||||
ctx.set_transform(Affine::IDENTITY);
|
||||
ctx.push_layer(None, None, Some(dim), None, None);
|
||||
for o in def.preview_offsets() {
|
||||
let view = f.view * Affine::translate(o);
|
||||
let visible = f.visible - o;
|
||||
let frame = Frame { view, visible, ..*f };
|
||||
for c in &children {
|
||||
self.draw_arc(ctx, &frame, c);
|
||||
let comp = crate::group::Composite { opacity: dim, ..Default::default() };
|
||||
self.group(ctx, f, comp, &mut |r, c, fr| {
|
||||
for o in def.preview_offsets() {
|
||||
let frame = Frame { view: fr.view * Affine::translate(o), visible: fr.visible - o, ..*fr };
|
||||
for a in &children {
|
||||
r.draw_arc(c, &frame, a);
|
||||
}
|
||||
}
|
||||
}
|
||||
ctx.pop_layer();
|
||||
});
|
||||
}
|
||||
self.draw_arc(ctx, f, layer);
|
||||
if def.show_tile_edge {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,109 @@
|
|||
//! Blend-mode accuracy: every mode matches the reference formulas ([`blend_rgb`]) within 1/255
|
||||
//! (see [`tolerance`]), whether it is an object's blend mode, a fill's or a group's.
|
||||
|
||||
use super::*;
|
||||
use vectorcraft_color::blend::blend_rgb;
|
||||
use vectorcraft_color::{BlendMode as Blend, Color, Paint};
|
||||
use vectorcraft_doc::{Appearance, Node};
|
||||
use vectorcraft_geom::shapes;
|
||||
|
||||
/// Backdrop / source pairs (8-bit, so the expected values carry no input rounding) that take every
|
||||
/// branch of the formulas: dark and light backdrops, sources on both sides of ½, greys, extremes.
|
||||
const PAIRS: [([u8; 3], [u8; 3]); 6] = [
|
||||
([51, 128, 230], [153, 64, 128]),
|
||||
([230, 26, 102], [77, 204, 179]),
|
||||
([0, 255, 128], [255, 0, 127]),
|
||||
([200, 180, 40], [20, 60, 250]),
|
||||
([128, 128, 128], [240, 30, 90]),
|
||||
([10, 90, 160], [128, 128, 128]),
|
||||
];
|
||||
|
||||
fn rect(d: &mut Document, r: Rect, rgb: [u8; 3]) -> Node {
|
||||
Node::path(d.alloc_id(), shapes::rectangle(r), Appearance::basic(Paint::solid(Color::rgb8(rgb[0], rgb[1], rgb[2])), Paint::None, 0.0))
|
||||
}
|
||||
|
||||
/// Where the blend is set: on the object, on its fill, or on a group around it.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
enum Level {
|
||||
Object,
|
||||
Fill,
|
||||
Group,
|
||||
}
|
||||
|
||||
/// An opaque backdrop with the source on top of it, blended with `mode` at `level`.
|
||||
fn doc(mode: Blend, level: Level, backdrop: [u8; 3], source: [u8; 3]) -> Document {
|
||||
let mut d = Document::new(20.0, 20.0);
|
||||
let below = rect(&mut d, Rect::new(0.0, 0.0, 20.0, 20.0), backdrop);
|
||||
let mut top = rect(&mut d, Rect::new(5.0, 5.0, 15.0, 15.0), source);
|
||||
match level {
|
||||
Level::Object => top.blend = mode,
|
||||
Level::Fill => {
|
||||
if let Some(vectorcraft_doc::AppearanceItem::Fill(f)) = top.appearance.items.first_mut() {
|
||||
f.blend = mode;
|
||||
}
|
||||
}
|
||||
Level::Group => {}
|
||||
}
|
||||
let top = match level {
|
||||
Level::Group => {
|
||||
let mut g = Node::group(d.alloc_id(), vec![Arc::new(top)]);
|
||||
g.blend = mode;
|
||||
g
|
||||
}
|
||||
_ => top,
|
||||
};
|
||||
let l = d.layers[0].id;
|
||||
d.insert(Some(l), usize::MAX, below).unwrap();
|
||||
d.insert(Some(l), usize::MAX, top).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
pub(crate) fn expected(mode: Blend, backdrop: [u8; 3], source: [u8; 3]) -> [u8; 3] {
|
||||
let f = |c: [u8; 3]| c.map(|v| v as f32 / 255.0);
|
||||
blend_rgb(mode, f(backdrop), f(source)).map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u8)
|
||||
}
|
||||
|
||||
/// Whether `a` is within `tol`/255 of `b` in every channel.
|
||||
pub(crate) fn within(a: [u8; 3], b: [u8; 3], tol: i32) -> bool {
|
||||
a.iter().zip(b).all(|(x, y)| (*x as i32 - y as i32).abs() <= tol)
|
||||
}
|
||||
|
||||
/// Tolerance of `mode` on screen: 1/255, except Exclusion, which the rasteriser's 8-bit pipeline
|
||||
/// rounds twice (b + s − 2bs).
|
||||
fn tolerance(mode: Blend) -> i32 {
|
||||
if mode == Blend::Exclusion { 2 } else { 1 }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_mode_matches_the_reference_formulas() {
|
||||
let mut r = Renderer::new();
|
||||
for mode in Blend::ALL {
|
||||
for level in [Level::Object, Level::Fill, Level::Group] {
|
||||
for (b, s) in PAIRS {
|
||||
let px = r.render(&doc(mode, level, b, s), 20, 20, Affine::IDENTITY, &RenderOptions::default()).pixel(10, 10);
|
||||
let want = expected(mode, b, s);
|
||||
assert!(
|
||||
within([px[0], px[1], px[2]], want, tolerance(mode)) && px[3] == 255,
|
||||
"{mode:?} at {level:?}, {b:?} under {s:?}: {px:?}, want {want:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_translucent_source_mixes_the_blend_by_its_opacity() {
|
||||
let mut r = Renderer::new();
|
||||
for mode in Blend::ALL {
|
||||
let (b, s) = PAIRS[0];
|
||||
let mut d = doc(mode, Level::Object, b, s);
|
||||
let top = d.layers[0].children().unwrap()[1].id;
|
||||
d.node_mut(top).unwrap().opacity = 0.6;
|
||||
let px = r.render(&d, 20, 20, Affine::IDENTITY, &RenderOptions::default()).pixel(10, 10);
|
||||
let f = |c: [u8; 3], a: f32| [c[0] as f32 / 255.0, c[1] as f32 / 255.0, c[2] as f32 / 255.0, a];
|
||||
let o = vectorcraft_color::blend::composite(mode, f(b, 1.0), f(s, 0.6));
|
||||
let want = [o[0], o[1], o[2]].map(|v| (v * 255.0).round() as u8);
|
||||
// The opacity is one more 8-bit step.
|
||||
assert!(within([px[0], px[1], px[2]], want, tolerance(mode) + 1), "{mode:?}: {px:?}, want {want:?}");
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,215 @@
|
|||
//! Non-isolated transparency groups: blending inside a group with opacity or a blend mode reaches
|
||||
//! the art below the group unless Isolate Blending is on (checked against the reference formulas).
|
||||
|
||||
use super::*;
|
||||
use vectorcraft_color::blend::{blend_rgb, composite};
|
||||
use vectorcraft_color::{BlendMode as Blend, Color, Paint};
|
||||
use vectorcraft_doc::{Appearance, Knockout, Node};
|
||||
use vectorcraft_geom::shapes;
|
||||
|
||||
/// The backdrop colour (a full-page rectangle) and the blended child's colour.
|
||||
const B: [f32; 3] = [0.8, 0.6, 0.2];
|
||||
const S: [f32; 3] = [0.5, 0.5, 1.0];
|
||||
|
||||
fn rect(d: &mut Document, r: Rect, c: [f32; 3]) -> Node {
|
||||
Node::path(d.alloc_id(), shapes::rectangle(r), Appearance::basic(Paint::solid(Color::rgb(c[0], c[1], c[2])), Paint::None, 0.0))
|
||||
}
|
||||
|
||||
/// A Multiply rectangle at 20..80 (the group's content).
|
||||
fn multiply(d: &mut Document) -> Node {
|
||||
let mut n = rect(d, Rect::new(20.0, 20.0, 80.0, 80.0), S);
|
||||
n.blend = Blend::Multiply;
|
||||
n
|
||||
}
|
||||
|
||||
/// The backdrop, then `top` (built by `make`), in a 100 × 100 document.
|
||||
fn doc(make: impl FnOnce(&mut Document) -> Node) -> Document {
|
||||
let mut d = Document::new(100.0, 100.0);
|
||||
let below = rect(&mut d, Rect::new(0.0, 0.0, 100.0, 100.0), B);
|
||||
let top = make(&mut d);
|
||||
let l = d.layers[0].id;
|
||||
d.insert(Some(l), usize::MAX, below).unwrap();
|
||||
d.insert(Some(l), usize::MAX, top).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
/// A group of `children` with `opacity`, `blend` and isolation.
|
||||
fn group(d: &mut Document, children: Vec<Node>, opacity: f32, blend: Blend, isolate: bool) -> Node {
|
||||
let mut g = Node::group(d.alloc_id(), children.into_iter().map(Arc::new).collect());
|
||||
(g.opacity, g.blend, g.isolate) = (opacity, blend, isolate);
|
||||
g
|
||||
}
|
||||
|
||||
fn render_with(r: &mut Renderer, d: &Document, opts: &RenderOptions) -> Rendered {
|
||||
r.render(d, 100, 100, Affine::IDENTITY, opts)
|
||||
}
|
||||
|
||||
fn render(d: &Document) -> Rendered {
|
||||
render_with(&mut Renderer::new(), d, &RenderOptions::default())
|
||||
}
|
||||
|
||||
fn rgb(p: [u8; 4]) -> [u8; 3] {
|
||||
[p[0], p[1], p[2]]
|
||||
}
|
||||
|
||||
fn to8(c: [f32; 3]) -> [u8; 3] {
|
||||
c.map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u8)
|
||||
}
|
||||
|
||||
fn lerp(a: [f32; 3], b: [f32; 3], t: f32) -> [f32; 3] {
|
||||
[0, 1, 2].map(|i| a[i] + (b[i] - a[i]) * t)
|
||||
}
|
||||
|
||||
fn assert_near(got: [u8; 4], want: [f32; 3], what: &str) {
|
||||
let w = to8(want);
|
||||
assert!(rgb(got).iter().zip(w).all(|(a, b)| (*a as i32 - b as i32).abs() <= 2) && got[3] == 255, "{what}: {got:?}, want {w:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_multiply_child_of_a_half_opaque_group_multiplies_with_the_backdrop() {
|
||||
let multiplied = blend_rgb(Blend::Multiply, B, S);
|
||||
for isolate in [false, true] {
|
||||
let d = doc(|d| {
|
||||
let m = multiply(d);
|
||||
group(d, vec![m], 0.5, Blend::Normal, isolate)
|
||||
});
|
||||
let r = render(&d);
|
||||
// Non-isolated: the child multiplies with the backdrop, then the group is half opaque.
|
||||
// Isolated: the child multiplies with nothing, so the group shows its plain colour.
|
||||
let inside = if isolate { lerp(B, S, 0.5) } else { lerp(B, multiplied, 0.5) };
|
||||
assert_near(r.pixel(50, 50), inside, &format!("isolate {isolate}"));
|
||||
assert_near(r.pixel(10, 10), B, "outside the group");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_group_blend_mode_applies_again_to_the_non_isolated_result() {
|
||||
// The child (opaque) multiplies with the backdrop; the group then screens that result onto the
|
||||
// backdrop at 60%.
|
||||
let d = doc(|d| {
|
||||
let m = multiply(d);
|
||||
group(d, vec![m], 0.6, Blend::Screen, false)
|
||||
});
|
||||
let g = blend_rgb(Blend::Multiply, B, S);
|
||||
let o = composite(Blend::Screen, [B[0], B[1], B[2], 1.0], [g[0], g[1], g[2], 0.6]);
|
||||
assert_near(render(&d).pixel(50, 50), [o[0], o[1], o[2]], "screened multiply");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_clip_group_does_not_isolate_its_children() {
|
||||
let d = doc(|d| {
|
||||
let clip = rect(d, Rect::new(30.0, 30.0, 70.0, 70.0), [0.0; 3]);
|
||||
let m = multiply(d);
|
||||
Node::new(d.alloc_id(), NodeKind::Group { children: vec![Arc::new(clip), Arc::new(m)], clip: true })
|
||||
});
|
||||
let r = render(&d);
|
||||
assert_near(r.pixel(50, 50), blend_rgb(Blend::Multiply, B, S), "inside the clip");
|
||||
assert_near(r.pixel(25, 25), B, "clipped away");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_non_isolated_group_inside_a_clip_group_stays_clipped() {
|
||||
// The half-opaque group copies its backdrop while the clip is in force: the clip still applies
|
||||
// to it and to what follows.
|
||||
let d = doc(|d| {
|
||||
let clip = rect(d, Rect::new(30.0, 30.0, 70.0, 70.0), [0.0; 3]);
|
||||
let m = multiply(d);
|
||||
let g = group(d, vec![m], 0.5, Blend::Normal, false);
|
||||
let after = rect(d, Rect::new(0.0, 60.0, 100.0, 100.0), S);
|
||||
let children = [clip, g, after].into_iter().map(Arc::new).collect();
|
||||
Node::new(d.alloc_id(), NodeKind::Group { children, clip: true })
|
||||
});
|
||||
let r = render(&d);
|
||||
assert_near(r.pixel(50, 40), lerp(B, blend_rgb(Blend::Multiply, B, S), 0.5), "inside the clip");
|
||||
assert_near(r.pixel(25, 50), B, "group clipped away");
|
||||
assert_near(r.pixel(50, 65), S, "later art inside the clip");
|
||||
assert_near(r.pixel(50, 85), B, "later art clipped away");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_masked_object_blends_with_the_backdrop() {
|
||||
let d = doc(|d| {
|
||||
let m = multiply(d);
|
||||
let mut g = group(d, vec![m], 1.0, Blend::Normal, false);
|
||||
// A 50% grey mask over the whole group.
|
||||
let art = rect(d, Rect::new(0.0, 0.0, 100.0, 100.0), [0.5; 3]);
|
||||
g.mask = Some(Box::new(vectorcraft_doc::OpacityMask::new(art, true)));
|
||||
g
|
||||
});
|
||||
assert_near(render(&d).pixel(50, 50), lerp(B, blend_rgb(Blend::Multiply, B, S), 0.5), "masked multiply");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_non_isolated_group_inside_an_isolated_one_blends_with_its_siblings_below() {
|
||||
// An isolated group holding a green square, then a 50% group with the Multiply child: the
|
||||
// child multiplies with the green (the isolated group's own art), not with the backdrop.
|
||||
const G: [f32; 3] = [0.2, 0.9, 0.4];
|
||||
let d = doc(|d| {
|
||||
let green = rect(d, Rect::new(10.0, 10.0, 90.0, 90.0), G);
|
||||
let m = multiply(d);
|
||||
let inner = group(d, vec![m], 0.5, Blend::Normal, false);
|
||||
group(d, vec![green, inner], 1.0, Blend::Normal, true)
|
||||
});
|
||||
assert_near(render(&d).pixel(50, 50), lerp(G, blend_rgb(Blend::Multiply, G, S), 0.5), "multiplied with the sibling");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_non_isolated_knockout_group_composites_its_children_against_the_backdrop() {
|
||||
// Two Multiply squares overlapping at 40..60 in a knockout group: in the overlap the top one
|
||||
// multiplies with the backdrop only (it knocks out the one below).
|
||||
const T: [f32; 3] = [1.0, 0.4, 0.6];
|
||||
for isolate in [false, true] {
|
||||
let d = doc(|d| {
|
||||
let mut lower = rect(d, Rect::new(10.0, 10.0, 60.0, 90.0), S);
|
||||
let mut upper = rect(d, Rect::new(40.0, 10.0, 90.0, 90.0), T);
|
||||
(lower.blend, upper.blend) = (Blend::Multiply, Blend::Multiply);
|
||||
let mut g = group(d, vec![lower, upper], 1.0, Blend::Normal, isolate);
|
||||
g.knockout = Knockout::On;
|
||||
g
|
||||
});
|
||||
let r = render(&d);
|
||||
let (overlap, alone) = if isolate { (T, S) } else { (blend_rgb(Blend::Multiply, B, T), blend_rgb(Blend::Multiply, B, S)) };
|
||||
assert_near(r.pixel(50, 50), overlap, &format!("overlap, isolate {isolate}"));
|
||||
assert_near(r.pixel(20, 50), alone, &format!("lower alone, isolate {isolate}"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trim_view_page_group_and_threads_keep_the_non_isolated_look() {
|
||||
let d = doc(|d| {
|
||||
let m = multiply(d);
|
||||
group(d, vec![m], 0.5, Blend::Normal, false)
|
||||
});
|
||||
let want = lerp(B, blend_rgb(Blend::Multiply, B, S), 0.5);
|
||||
let trim = RenderOptions { trim: true, ..Default::default() };
|
||||
assert_near(render_with(&mut Renderer::new(), &d, &trim).pixel(50, 50), want, "trim view");
|
||||
let mut paged = d.clone();
|
||||
paged.page_isolate = true;
|
||||
assert_near(render(&paged).pixel(50, 50), want, "isolated page group");
|
||||
let mut threaded = Renderer::new();
|
||||
threaded.threads = 2;
|
||||
let single = render(&d);
|
||||
let multi = render_with(&mut threaded, &d, &RenderOptions::default());
|
||||
assert_eq!(single.pixels, multi.pixels, "multithreaded rendering copies the backdrop the same way");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copying_the_backdrop_changes_nothing_without_blending() {
|
||||
// A plain 50% group renders the same with or without an invisible blending group drawn first
|
||||
// (which makes the renderer start again from a picture of the page and mix it back in).
|
||||
let plain = doc(|d| {
|
||||
let c = rect(d, Rect::new(20.0, 20.0, 80.0, 80.0), S);
|
||||
group(d, vec![c], 0.5, Blend::Normal, false)
|
||||
});
|
||||
let mut flattened = plain.clone();
|
||||
let m = multiply(&mut flattened);
|
||||
let g = group(&mut flattened, vec![m], 0.5, Blend::Normal, false);
|
||||
let l = flattened.layers[0].id;
|
||||
// Behind everything but the backdrop, and fully transparent: only the copy happens.
|
||||
let mut g = g;
|
||||
g.children_mut().unwrap().iter_mut().for_each(|c| Arc::make_mut(c).opacity = 0.0);
|
||||
flattened.insert(Some(l), 1, g).unwrap();
|
||||
let (a, b) = (render(&plain).pixels, render(&flattened).pixels);
|
||||
let worst = a.iter().zip(&b).map(|(x, y)| (*x as i32 - *y as i32).abs()).max().unwrap();
|
||||
assert!(worst <= 1, "differs by {worst}/255");
|
||||
}
|
||||
|
|
@ -134,6 +134,9 @@ enum StrokePlan {
|
|||
|
||||
/// A region covering any artwork (mask and filter extents).
|
||||
const BIG: &str = "x=\"-100000\" y=\"-100000\" width=\"200000\" height=\"200000\"";
|
||||
/// Attributes of every `<mask>`: user-space units, and luminance taken from the sRGB values (as the
|
||||
/// canvas and PDF take it) rather than from linearised ones.
|
||||
const MASK: &str = "maskUnits=\"userSpaceOnUse\" color-interpolation=\"sRGB\"";
|
||||
|
||||
pub(crate) fn blend_css(b: BlendMode) -> &'static str {
|
||||
match b {
|
||||
|
|
@ -504,7 +507,7 @@ impl Writer<'_> {
|
|||
let b = self.xf.transform_rect_bbox(reach).inflate(1.0, 1.0);
|
||||
let (x, y, w, h) = (self.num(b.x0), self.num(b.y0), self.num(b.width()), self.num(b.height()));
|
||||
let fr = if rule == FillRule::EvenOdd { " fill-rule=\"evenodd\"" } else { "" };
|
||||
self.def(1, &format!("<mask id=\"{mid}\" maskUnits=\"userSpaceOnUse\" x=\"{x}\" y=\"{y}\" width=\"{w}\" height=\"{h}\">"));
|
||||
self.def(1, &format!("<mask id=\"{mid}\" {MASK} x=\"{x}\" y=\"{y}\" width=\"{w}\" height=\"{h}\">"));
|
||||
self.def(2, &format!("<rect x=\"{x}\" y=\"{y}\" width=\"{w}\" height=\"{h}\" fill=\"#fff\"/>"));
|
||||
self.def(2, &format!("<path d=\"{d}\" fill=\"#000\"{fr}/>"));
|
||||
self.def(1, "</mask>");
|
||||
|
|
@ -659,7 +662,7 @@ impl Writer<'_> {
|
|||
self.def(1, "</filter>");
|
||||
fid
|
||||
});
|
||||
self.def(1, &format!("<mask id=\"{mid}\" maskUnits=\"userSpaceOnUse\" {BIG}>"));
|
||||
self.def(1, &format!("<mask id=\"{mid}\" {MASK} {BIG}>"));
|
||||
if let Some(fid) = &inv {
|
||||
self.def(1, &format!("<g filter=\"url(#{fid})\">"));
|
||||
}
|
||||
|
|
@ -751,7 +754,7 @@ impl Writer<'_> {
|
|||
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(1, &format!("<mask id=\"{mid}\" {MASK} {BIG}>"));
|
||||
self.def(2, &format!("<rect {BIG} fill=\"white\"/>"));
|
||||
self.def(2, &format!("<g filter=\"url(#{filter})\">"));
|
||||
self.defs.push_str(&art);
|
||||
|
|
@ -774,7 +777,7 @@ impl Writer<'_> {
|
|||
}
|
||||
match &n.kind {
|
||||
NodeKind::Layer { template: true, .. } => {}
|
||||
NodeKind::Layer { children, .. } | NodeKind::Group { children, clip: false } => {
|
||||
NodeKind::Layer { children, clip: false, .. } | NodeKind::Group { children, clip: false } => {
|
||||
let id = self.id_attr(n);
|
||||
let a = self.attrs(&self.group_props(n));
|
||||
self.line(&format!("<g{id}{a}>"));
|
||||
|
|
@ -783,7 +786,7 @@ impl Writer<'_> {
|
|||
self.depth -= 1;
|
||||
self.line("</g>");
|
||||
}
|
||||
NodeKind::Group { children, clip: true } => {
|
||||
NodeKind::Group { children, clip: true } | NodeKind::Layer { children, clip: true, .. } => {
|
||||
let Some((clip, rest)) = children.split_first() else { return };
|
||||
let cid = self.fresh_id("clip-path");
|
||||
self.def(1, &format!("<clipPath id=\"{cid}\">"));
|
||||
|
|
|
|||
|
|
@ -0,0 +1,95 @@
|
|||
//! Blend modes and opacity masks in SVG export look as on the canvas: every mode follows the
|
||||
//! reference formulas when resvg draws the file, and masks take luminance the canvas's way.
|
||||
|
||||
use vectorcraft_color::blend::blend_rgb;
|
||||
use vectorcraft_color::{BlendMode, Color, Paint};
|
||||
use vectorcraft_doc::{Appearance, Document, Node, OpacityMask};
|
||||
use vectorcraft_geom::{Rect, shapes};
|
||||
use vectorcraft_svg::{ExportOptions, export};
|
||||
use vectorcraft_testkit::raster::{Image, render_artboard};
|
||||
|
||||
fn rect(d: &mut Document, r: Rect, rgb: [u8; 3]) -> Node {
|
||||
Node::path(d.alloc_id(), shapes::rectangle(r), Appearance::basic(Paint::solid(Color::rgb8(rgb[0], rgb[1], rgb[2])), Paint::None, 0.0))
|
||||
}
|
||||
|
||||
fn add(d: &mut Document, n: Node) {
|
||||
let l = d.layers[0].id;
|
||||
d.insert(Some(l), usize::MAX, n).unwrap();
|
||||
}
|
||||
|
||||
/// The exported SVG rendered by resvg on white at 1 px/pt.
|
||||
fn resvg_render(svg: &str, w: u32, h: u32) -> Image {
|
||||
let tree = resvg::usvg::Tree::from_str(svg, &resvg::usvg::Options::default()).expect("parse");
|
||||
let mut pm = resvg::tiny_skia::Pixmap::new(w, h).unwrap();
|
||||
pm.fill(resvg::tiny_skia::Color::WHITE);
|
||||
resvg::render(&tree, resvg::tiny_skia::Transform::identity(), &mut pm.as_mut());
|
||||
Image { width: w, height: h, rgba: pm.data().to_vec() }
|
||||
}
|
||||
|
||||
fn within(a: [u8; 3], b: [u8; 3], tol: i32) -> bool {
|
||||
a.iter().zip(b).all(|(x, y)| (*x as i32 - y as i32).abs() <= tol)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_blend_mode_follows_the_reference_formulas() {
|
||||
// (backdrop, source, whether a non-separable result leaves the gamut). resvg's rasteriser
|
||||
// departs from the reference formulas when Hue, Saturation, Color or Luminosity have to clip
|
||||
// their result back into the gamut, so those cases are checked on the canvas only
|
||||
// (vectorcraft-render's blend tests).
|
||||
let pairs: [([u8; 3], [u8; 3], bool); 4] = [
|
||||
([51, 128, 230], [153, 64, 128], false),
|
||||
([230, 26, 102], [77, 204, 179], true),
|
||||
([200, 180, 40], [20, 60, 250], true),
|
||||
([10, 90, 160], [128, 128, 128], false),
|
||||
];
|
||||
for mode in BlendMode::ALL {
|
||||
for (b, s, clips) in pairs {
|
||||
if clips && !mode.is_separable() {
|
||||
continue;
|
||||
}
|
||||
let mut d = Document::new(20.0, 20.0);
|
||||
let below = rect(&mut d, Rect::new(0.0, 0.0, 20.0, 20.0), b);
|
||||
let mut top = rect(&mut d, Rect::new(5.0, 5.0, 15.0, 15.0), s);
|
||||
top.blend = mode;
|
||||
add(&mut d, below);
|
||||
add(&mut d, top);
|
||||
let svg = export(&d, &ExportOptions::default());
|
||||
let px = resvg_render(&svg, 20, 20).over_white(10, 10);
|
||||
let f = |c: [u8; 3]| c.map(|v| v as f32 / 255.0);
|
||||
let want = blend_rgb(mode, f(b), f(s)).map(|v| (v * 255.0).round() as u8);
|
||||
assert!(within(px, want, 1), "{mode:?}, {b:?} under {s:?}: {px:?}, want {want:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A red square under a mask of green art (luminance 0.7152 with Rec. 709 weights, 0.59 with the
|
||||
/// blend-mode weights) that covers its left half.
|
||||
fn masked(clip: bool, invert: bool) -> Document {
|
||||
let mut d = Document::new(40.0, 20.0);
|
||||
let art = rect(&mut d, Rect::new(0.0, 0.0, 20.0, 20.0), [0, 255, 0]);
|
||||
let mut red = rect(&mut d, Rect::new(0.0, 0.0, 40.0, 20.0), [255, 0, 0]);
|
||||
let mut m = OpacityMask::new(art, clip);
|
||||
m.invert = invert;
|
||||
red.mask = Some(Box::new(m));
|
||||
add(&mut d, red);
|
||||
d
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clip_and_invert_masks_match_the_canvas() {
|
||||
for (clip, invert) in [(true, false), (false, false), (true, true), (false, true)] {
|
||||
let d = masked(clip, invert);
|
||||
let svg = export(&d, &ExportOptions::default());
|
||||
assert!(svg.contains("color-interpolation=\"sRGB\""), "{svg}");
|
||||
let canvas = render_artboard(&d);
|
||||
let written = resvg_render(&svg, 40, 20);
|
||||
for x in [10, 30] {
|
||||
let (c, w) = (canvas.over_white(x, 10), written.over_white(x, 10));
|
||||
assert!(within(c, w, 2), "clip {clip}, invert {invert}, x {x}: canvas {c:?}, svg {w:?}");
|
||||
}
|
||||
// Over the green art the red shows at the art's luminance (inverted: its complement).
|
||||
let lum: f32 = if invert { 1.0 - 0.7152 } else { 0.7152 };
|
||||
let g = (255.0 * (1.0 - lum)).round() as u8;
|
||||
assert!(within(canvas.over_white(10, 10), [255, g, g], 2), "{:?}", canvas.over_white(10, 10));
|
||||
}
|
||||
}
|
||||
|
|
@ -31,7 +31,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
|||
egui::ScrollArea::vertical().max_height(h).auto_shrink([false, false]).show(ui, |ui| {
|
||||
ui.spacing_mut().item_spacing.y = 0.0;
|
||||
for l in doc.layers.iter().rev() {
|
||||
row(ui, &doc, l, 0, &sel, current, &mut expanded, &mut actions, &t);
|
||||
row(ui, &doc, l, 0, false, &sel, current, &mut expanded, &mut actions, &t);
|
||||
}
|
||||
});
|
||||
ui.data_mut(|d| d.insert_temp(expanded_id(), expanded));
|
||||
|
|
@ -64,7 +64,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
|||
actions.push(("layer.newSublayer".into(), json!({})));
|
||||
}
|
||||
if widgets::icon_button(&mut child, "frame", "Make/Release Clipping Mask", false, 24.0).clicked() {
|
||||
actions.push(("object.clippingMask.make".into(), json!({})));
|
||||
actions.push(("layer.clippingMask.toggle".into(), json!({})));
|
||||
}
|
||||
if widgets::icon_button(&mut child, "search", "Locate Object", false, 24.0).clicked() {
|
||||
// Expand ancestors of the selection.
|
||||
|
|
@ -89,6 +89,7 @@ fn row(
|
|||
doc: &vectorcraft_doc::Document,
|
||||
n: &Node,
|
||||
depth: usize,
|
||||
clip_path: bool,
|
||||
sel: &HashSet<NodeId>,
|
||||
current: Option<NodeId>,
|
||||
expanded: &mut HashSet<u64>,
|
||||
|
|
@ -199,7 +200,12 @@ fn row(
|
|||
}
|
||||
}
|
||||
_ => {
|
||||
ui.painter().with_clip_rect(name_rect).text(egui::pos2(x, r.center().y), egui::Align2::LEFT_CENTER, name.clone(), font, t.text);
|
||||
let painter = ui.painter().with_clip_rect(name_rect);
|
||||
let text = painter.text(egui::pos2(x, r.center().y), egui::Align2::LEFT_CENTER, name.clone(), font, t.text);
|
||||
// A clipping path's name is underlined.
|
||||
if clip_path {
|
||||
painter.line_segment([text.left_bottom(), text.right_bottom()], Stroke::new(1.0, t.text));
|
||||
}
|
||||
}
|
||||
}
|
||||
// Target circle and selection square.
|
||||
|
|
@ -269,8 +275,8 @@ fn row(
|
|||
}
|
||||
|
||||
if has_children && expanded.contains(&n.id.0) {
|
||||
for c in n.children().unwrap().iter().rev() {
|
||||
row(ui, doc, c, depth + 1, sel, current, expanded, actions, t);
|
||||
for (i, c) in n.children().unwrap().iter().enumerate().rev() {
|
||||
row(ui, doc, c, depth + 1, i == 0 && n.clips(), sel, current, expanded, actions, t);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -374,4 +380,27 @@ mod tests {
|
|||
run(&mut app, "appearance.addStroke", json!({}));
|
||||
assert_eq!(filled_targets(&mut app, &ctx), 1, "two strokes");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_layer_clipping_mask_underlines_its_clipping_path() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
let run = |app: &mut VectorcraftApp, id: &str, p: serde_json::Value| app.session.execute(id, &p).unwrap();
|
||||
run(&mut app, "file.new", json!({"width": 100, "height": 100}));
|
||||
run(&mut app, "shape.rectangle", json!({"x": 0, "y": 0, "width": 100, "height": 100}));
|
||||
run(&mut app, "shape.rectangle", json!({"x": 25, "y": 25, "width": 50, "height": 50}));
|
||||
run(&mut app, "select.none", json!({}));
|
||||
let ctx = egui::Context::default();
|
||||
let text = Tokens::get(&ctx).text;
|
||||
// Line segments in the text colour: the underlines.
|
||||
let underlines = |app: &mut VectorcraftApp| {
|
||||
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| show(app, ui));
|
||||
out.textures_delta.clear();
|
||||
out.shapes.iter().filter(|c| matches!(&c.shape, egui::Shape::LineSegment { stroke, .. } if stroke.color == text)).count()
|
||||
};
|
||||
assert_eq!(underlines(&mut app), 0);
|
||||
assert_eq!(run(&mut app, "layer.clippingMask.toggle", json!({}))["clip"], true);
|
||||
assert_eq!(underlines(&mut app), 1);
|
||||
run(&mut app, "layer.clippingMask.toggle", json!({}));
|
||||
assert_eq!(underlines(&mut app), 0);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
13
docs/mcp.md
13
docs/mcp.md
|
|
@ -203,8 +203,12 @@ neutral, the default). In a knockout group each child hides what it covers of th
|
|||
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).
|
||||
saved with the document and undoable, and `transparency.info` reports them. PDF and SVG write knockout groups as
|
||||
soft-masked groups with the same look (the PDF export reports a warning).
|
||||
|
||||
Groups are not isolated unless Isolate Blending is on: a blend mode inside a group with opacity, a blend mode, an
|
||||
opacity mask, knockout or a clip reaches the art below the group, on screen and in PDF (a knockout group's elements
|
||||
then composite against the art below it). SVG has no non-isolated groups, so there such groups stay isolated.
|
||||
|
||||
## Clipping masks
|
||||
|
||||
|
|
@ -218,6 +222,11 @@ plain group and keeps the clipping path, unpainted.
|
|||
{"name":"run_command","arguments":{"command":"object.clippingMask.make","params":{}}}
|
||||
```
|
||||
|
||||
`layer.clippingMask.toggle {id?}` is the Layers panel's clipping mask button: the top object of the layer `id` (default:
|
||||
the one selected group, else the current layer) becomes its clipping path (unpainted, moved to the bottom of the layer,
|
||||
so art added later is clipped too); called again it releases the mask. It returns `{clip}`, and the Layers panel
|
||||
underlines clipping-path names.
|
||||
|
||||
## Graphic styles
|
||||
|
||||
A graphic style holds an appearance (fills, strokes, effects) plus opacity, blend mode, isolate and knockout.
|
||||
|
|
|
|||
Loading…
Reference in New Issue