mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-25 10:17:02 -06:00
M3.68: groups, layers and type have their own fills, strokes and effects, with Contents and Characters rows
A group's or layer's own fills and strokes paint every member's geometry, below or above the members as the new contents slot (Appearance.contents_index) says; its geometry effects reshape the members as one piece and its raster effects apply to the composite (one combined shadow), respecting knockout groups. Type's own fills and strokes paint under or over its characters. Render, SVG/PDF (via bake) and Expand Appearance evaluate them alike. Commands: appearance.* / effect.* take target: "object"|"contents" and layer ids; appearance.moveItem moves the Contents slot (from: "contents") or an item across it (contents); appearance.targetContents selects a group's members. Graphic styles applied to a group now style the group itself. The Appearance panel shows Contents/Characters rows that drag, and double-click to target the members or edit the characters' paint. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
25c8f32fdd
commit
8a837fd4b9
+31
-6
@@ -15,7 +15,10 @@
|
||||
//!
|
||||
//! The same applies to one fill or stroke's own raster effects (around that item alone) and to
|
||||
//! type, images, symbol instances and live objects ([`Renderer::draw_object_fx`]): their art is
|
||||
//! drawn offscreen in the filter layers, reshaped first by their geometry effects.
|
||||
//! drawn offscreen in the filter layers, reshaped first by their geometry effects. Groups and
|
||||
//! layers with an appearance of their own take the same path with their evaluated art
|
||||
//! ([`effects::evaluate_container`]: their fills and strokes painting the members, their geometry
|
||||
//! effects reshaping them), so their raster effects apply to the composite (one combined shadow).
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -45,9 +48,15 @@ pub(crate) fn has_fx(n: &Node) -> bool {
|
||||
}
|
||||
|
||||
/// Does `n` carry visible object-level effects that only apply through its art (type, images,
|
||||
/// symbol instances, live objects; see [`Renderer::draw_object_fx`])?
|
||||
/// symbol instances, live objects), or is it a group or layer with an appearance of its own (see
|
||||
/// [`Renderer::draw_object_fx`])?
|
||||
pub(crate) fn has_object_fx(n: &Node) -> bool {
|
||||
effects::needs_outline(n) && visible(&n.appearance.effects)
|
||||
(effects::needs_outline(n) && visible(&n.appearance.effects)) || effects::has_container_appearance(n)
|
||||
}
|
||||
|
||||
/// Is `n` a group or layer (whose evaluated art keeps its knockout setting)?
|
||||
fn is_container(n: &Node) -> bool {
|
||||
matches!(n.kind, NodeKind::Group { .. } | NodeKind::Layer { .. })
|
||||
}
|
||||
|
||||
/// What [`Renderer::raster_fx`] paints effects around.
|
||||
@@ -127,6 +136,18 @@ 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 {
|
||||
// The evaluated art (its fills, strokes and geometry effects) and the raster effects' reach.
|
||||
_ if effects::has_container_appearance(n) => {
|
||||
let art = effects::evaluate_container(n);
|
||||
let art = art.as_ref().unwrap_or(n);
|
||||
let b = if art.clips() {
|
||||
art.visual_bounds()
|
||||
} else {
|
||||
art.children()?.iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, cull_bounds(c)))
|
||||
}?;
|
||||
let o = effects::outset(&n.appearance.effects);
|
||||
Some(b.inflate(o, o))
|
||||
}
|
||||
NodeKind::Layer { children, clip: false, .. } | NodeKind::Group { children, clip: false } => {
|
||||
children.iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, cull_bounds(c)))
|
||||
}
|
||||
@@ -375,7 +396,8 @@ impl Renderer {
|
||||
|
||||
/// The art [`Self::draw_object_fx`] draws for `a` (cached by `Arc` identity when `cache`):
|
||||
/// reshaped by its geometry effects, else type and images as themselves and the others as
|
||||
/// their evaluated art; without the object's transparency and effects.
|
||||
/// their evaluated art (groups and layers: [`effects::evaluate_container`], keeping their
|
||||
/// knockout setting); without the object's transparency and effects.
|
||||
fn fx_art(&mut self, doc: &vectorcraft_doc::Document, a: &Arc<Node>, cache: bool) -> Arc<Node> {
|
||||
let key = Arc::as_ptr(a) as usize;
|
||||
if cache
|
||||
@@ -390,8 +412,10 @@ impl Renderer {
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
let container = is_container(a);
|
||||
let art = match effects::reshape(a, symbol.as_ref()) {
|
||||
Some(r) => r,
|
||||
None if container => effects::evaluate_container(a).unwrap_or_else(|| (**a).clone()),
|
||||
None if matches!(a.kind, NodeKind::Text(_) | NodeKind::Image(_)) => (**a).clone(),
|
||||
None => effects::outline_art(a, symbol.as_ref()).unwrap_or_else(|| Node::group(a.id, vec![])),
|
||||
};
|
||||
@@ -399,8 +423,9 @@ impl Renderer {
|
||||
opacity: 1.0,
|
||||
blend: Default::default(),
|
||||
isolate: false,
|
||||
// One element: its pieces never knock each other out.
|
||||
knockout: vectorcraft_doc::Knockout::Off,
|
||||
// One element: its pieces never knock each other out (a group's members still do as
|
||||
// its knockout setting says).
|
||||
knockout: if container { a.knockout } else { vectorcraft_doc::Knockout::Off },
|
||||
mask: None,
|
||||
appearance: vectorcraft_doc::Appearance { effects: vec![], ..art.appearance },
|
||||
..art
|
||||
|
||||
+40
-25
@@ -456,7 +456,7 @@ impl Renderer {
|
||||
return e.bounds;
|
||||
}
|
||||
let b = match &a.kind {
|
||||
NodeKind::Layer { children, clip: false, .. } | NodeKind::Group { children, clip: false } if !fx::has_fx(a) => {
|
||||
NodeKind::Layer { children, clip: false, .. } | NodeKind::Group { children, clip: false } if !fx::has_object_fx(a) => {
|
||||
let mut acc: Option<Rect> = None;
|
||||
for c in children {
|
||||
if c.visible {
|
||||
@@ -920,7 +920,17 @@ impl Renderer {
|
||||
ctx.set_blend_mode(if on { blend_mode(vectorcraft_color::BlendMode::Multiply) } else { BlendMode::default() });
|
||||
}
|
||||
};
|
||||
// Object-level appearance fills/strokes apply on top of character fills (like Illustrator).
|
||||
// The object's own fills and strokes paint the whole outline: those below the Characters
|
||||
// row under the characters' own fill and stroke, the others over them.
|
||||
let (below, above) = n.appearance.split_contents();
|
||||
let all = (!n.appearance.items.is_empty()).then(|| {
|
||||
let mut all = g.all.clone();
|
||||
all.apply_affine(t.xf);
|
||||
all
|
||||
});
|
||||
if let Some(all) = &all {
|
||||
self.draw_text_items(ctx, f, below, all, tb);
|
||||
}
|
||||
for (i, run) in t.runs.iter().enumerate() {
|
||||
let Some(path) = g.runs.get(i) else { continue };
|
||||
if path.elements().is_empty() {
|
||||
@@ -943,31 +953,34 @@ impl Renderer {
|
||||
}
|
||||
}
|
||||
overprint(ctx, false);
|
||||
if !n.appearance.items.is_empty() {
|
||||
let mut all = g.all.clone();
|
||||
all.apply_affine(t.xf);
|
||||
for item in n.appearance.items.iter().filter(|i| i.visible() && !i.paint().is_none()) {
|
||||
// Each item composites with its own opacity and blend mode.
|
||||
let layered = item.opacity() < 1.0 || item.blend() != vectorcraft_color::BlendMode::Normal;
|
||||
if layered {
|
||||
ctx.set_transform(Affine::IDENTITY);
|
||||
ctx.push_layer(None, Some(blend_mode(item.blend())), Some(item.opacity()), None, None);
|
||||
if let Some(all) = &all {
|
||||
self.draw_text_items(ctx, f, above, all, tb);
|
||||
}
|
||||
}
|
||||
|
||||
/// Paint a type object's own fills and strokes `items` on its glyph outlines `all` (document
|
||||
/// space; `tb` their bounds), each with its own opacity and blend mode.
|
||||
fn draw_text_items(&mut self, ctx: &mut RenderContext, f: &Frame, items: &[AppearanceItem], all: &BezPath, tb: Rect) {
|
||||
for item in items.iter().filter(|i| i.visible() && !i.paint().is_none()) {
|
||||
let layered = item.opacity() < 1.0 || item.blend() != vectorcraft_color::BlendMode::Normal;
|
||||
if layered {
|
||||
ctx.set_transform(Affine::IDENTITY);
|
||||
ctx.push_layer(None, Some(blend_mode(item.blend())), Some(item.opacity()), None, None);
|
||||
}
|
||||
ctx.set_transform(f.view);
|
||||
match item {
|
||||
AppearanceItem::Fill(fl) if paint::set_paint(ctx, &fl.paint, tb, f.doc) => {
|
||||
ctx.set_fill_rule(peniko::Fill::NonZero);
|
||||
ctx.fill_path(all);
|
||||
}
|
||||
ctx.set_transform(f.view);
|
||||
match item {
|
||||
AppearanceItem::Fill(fl) if paint::set_paint(ctx, &fl.paint, tb, f.doc) => {
|
||||
ctx.set_fill_rule(peniko::Fill::NonZero);
|
||||
ctx.fill_path(&all);
|
||||
}
|
||||
AppearanceItem::Stroke(st) if st.width > 0.0 && paint::set_paint(ctx, &st.paint, st.paint_bounds(tb), f.doc) => {
|
||||
ctx.set_stroke(kurbo::Stroke::new(st.width));
|
||||
ctx.stroke_path(&all);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if layered {
|
||||
ctx.pop_layer();
|
||||
AppearanceItem::Stroke(st) if st.width > 0.0 && paint::set_paint(ctx, &st.paint, st.paint_bounds(tb), f.doc) => {
|
||||
ctx.set_stroke(kurbo::Stroke::new(st.width));
|
||||
ctx.stroke_path(all);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if layered {
|
||||
ctx.pop_layer();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1141,6 +1154,8 @@ mod tests_blend;
|
||||
#[cfg(test)]
|
||||
mod tests_clip;
|
||||
#[cfg(test)]
|
||||
mod tests_container;
|
||||
#[cfg(test)]
|
||||
mod tests_isolation;
|
||||
#[cfg(test)]
|
||||
mod tests_knockout;
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
//! The appearance of groups, layers and type: a container's own fills and strokes paint its
|
||||
//! members' geometry below or above them (the contents slot), its effects apply to the composite
|
||||
//! (one combined shadow) and reshape its members as one piece; type's own fills paint under or
|
||||
//! over its characters.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_doc::text::{CharStyle, TextObject};
|
||||
use vectorcraft_doc::{Appearance, AppearanceItem, Effect, FillLayer, Knockout, Node, NodeKind, StrokeLayer};
|
||||
use vectorcraft_geom::{Point, shapes};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn fx(id: &str, params: Value) -> Effect {
|
||||
Effect { id: id.into(), params, visible: true }
|
||||
}
|
||||
|
||||
fn solid(r: f32, g: f32, b: f32) -> Paint {
|
||||
Paint::solid(Color::rgb(r, g, b))
|
||||
}
|
||||
|
||||
/// A rectangle filled with `paint` and no stroke.
|
||||
fn rect(d: &mut Document, r: Rect, paint: Paint) -> Arc<Node> {
|
||||
Arc::new(Node::path(d.alloc_id(), shapes::rectangle(r), Appearance::basic(paint, Paint::None, 0.0)))
|
||||
}
|
||||
|
||||
/// A 100×100 document holding a group of a red square (10..40) and a blue one (50..80), on a row
|
||||
/// from y 10 to 40, given its own appearance by `style`.
|
||||
fn group_doc(style: impl FnOnce(&mut Node)) -> Document {
|
||||
let mut d = Document::new(100.0, 100.0);
|
||||
let a = rect(&mut d, Rect::new(10.0, 10.0, 40.0, 40.0), solid(1.0, 0.0, 0.0));
|
||||
let b = rect(&mut d, Rect::new(50.0, 10.0, 80.0, 40.0), solid(0.0, 0.0, 1.0));
|
||||
let mut g = Node::group(d.alloc_id(), vec![a, b]);
|
||||
style(&mut g);
|
||||
let l = d.layers[0].id;
|
||||
d.insert(Some(l), 0, g).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
fn render_threads(d: &Document, threads: u16) -> Rendered {
|
||||
let mut r = Renderer::new();
|
||||
r.threads = threads;
|
||||
r.render(d, 100, 100, Affine::IDENTITY, &RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() })
|
||||
}
|
||||
|
||||
fn render(d: &Document) -> Rendered {
|
||||
render_threads(d, 0)
|
||||
}
|
||||
|
||||
const GREEN: [u8; 4] = [0, 255, 0, 255];
|
||||
const RED: [u8; 4] = [255, 0, 0, 255];
|
||||
const BLUE: [u8; 4] = [0, 0, 255, 255];
|
||||
const WHITE: [u8; 4] = [255, 255, 255, 255];
|
||||
|
||||
fn green_fill(g: &mut Node) {
|
||||
g.appearance.items.push(AppearanceItem::Fill(FillLayer::new(solid(0.0, 1.0, 0.0))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_group_fill_paints_every_member_above_or_below_them() {
|
||||
// Above the contents (the default): both members turn green; the gap between them stays white.
|
||||
let img = render(&group_doc(green_fill));
|
||||
assert_eq!((img.pixel(25, 25), img.pixel(65, 25), img.pixel(45, 25)), (GREEN, GREEN, WHITE));
|
||||
// Below the contents the members' own fills cover it.
|
||||
let img = render(&group_doc(|g| {
|
||||
green_fill(g);
|
||||
g.appearance.set_contents_at(1);
|
||||
}));
|
||||
assert_eq!((img.pixel(25, 25), img.pixel(65, 25)), (RED, BLUE));
|
||||
// A group stroke strokes each member's outline.
|
||||
let img = render(&group_doc(|g| {
|
||||
g.appearance.items.push(AppearanceItem::Stroke(StrokeLayer::new(solid(0.0, 1.0, 0.0), 4.0)));
|
||||
}));
|
||||
assert_eq!((img.pixel(10, 25), img.pixel(80, 25), img.pixel(25, 25)), (GREEN, GREEN, RED));
|
||||
// Hidden fills paint nothing.
|
||||
let img = render(&group_doc(|g| {
|
||||
green_fill(g);
|
||||
if let AppearanceItem::Fill(f) = &mut g.appearance.items[0] {
|
||||
f.visible = false;
|
||||
}
|
||||
}));
|
||||
assert_eq!(img.pixel(25, 25), RED);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_group_fill_with_opacity_composites_as_one_layer() {
|
||||
// Two overlapping members under a 50 % group fill: the overlap is as light as the rest.
|
||||
let mut d = Document::new(100.0, 100.0);
|
||||
let a = rect(&mut d, Rect::new(10.0, 10.0, 60.0, 40.0), Paint::None);
|
||||
let b = rect(&mut d, Rect::new(40.0, 10.0, 90.0, 40.0), Paint::None);
|
||||
let mut g = Node::group(d.alloc_id(), vec![a, b]);
|
||||
let mut f = FillLayer::new(Paint::solid(Color::BLACK));
|
||||
f.opacity = 0.5;
|
||||
g.appearance.items.push(AppearanceItem::Fill(f));
|
||||
let l = d.layers[0].id;
|
||||
d.insert(Some(l), 0, g).unwrap();
|
||||
let img = render(&d);
|
||||
let (single, both) = (img.pixel(20, 25), img.pixel(50, 25));
|
||||
assert!(single[0].abs_diff(128) <= 2 && single == both, "{single:?} vs {both:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_group_shadow_is_one_combined_shadow() {
|
||||
// The members' shadows (offset 30 pt right) overlap at x 65 but not at x 80; one combined
|
||||
// shadow is equally dark at both, two separate ones would double up at 65.
|
||||
let mut d = Document::new(100.0, 100.0);
|
||||
let a = rect(&mut d, Rect::new(10.0, 10.0, 40.0, 40.0), Paint::solid(Color::BLACK));
|
||||
let b = rect(&mut d, Rect::new(30.0, 10.0, 60.0, 40.0), Paint::solid(Color::BLACK));
|
||||
let mut g = Node::group(d.alloc_id(), vec![a, b]);
|
||||
g.appearance.effects.push(fx("stylize.dropShadow", json!({"x": 30, "y": 0, "blur": 0, "opacity": 50, "mode": "normal"})));
|
||||
let l = d.layers[0].id;
|
||||
d.insert(Some(l), 0, g).unwrap();
|
||||
for threads in [0, 3] {
|
||||
let img = render_threads(&d, threads);
|
||||
let (overlap, single) = (img.pixel(65, 25), img.pixel(80, 25));
|
||||
assert!(single[0] < 160 && single[0] > 100, "{single:?}");
|
||||
assert!(overlap[0].abs_diff(single[0]) <= 2, "one shadow, not two: {overlap:?} vs {single:?}");
|
||||
assert_eq!(img.pixel(95, 25), WHITE);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_geometry_effects_move_the_members_as_one_piece() {
|
||||
let img = render(&group_doc(|g| g.appearance.effects.push(fx("distort.transform", json!({"moveV": 50})))));
|
||||
assert_eq!((img.pixel(25, 25), img.pixel(25, 75), img.pixel(65, 75)), (WHITE, RED, BLUE));
|
||||
// The group's own fill paints the moved members, and the shadow follows them.
|
||||
let img = render(&group_doc(|g| {
|
||||
g.appearance.effects.push(fx("distort.transform", json!({"moveV": 50})));
|
||||
green_fill(g);
|
||||
}));
|
||||
assert_eq!((img.pixel(25, 75), img.pixel(65, 75)), (GREEN, GREEN));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_layer_paints_its_own_fill_and_a_knockout_group_keeps_knocking_out() {
|
||||
let mut d = group_doc(|_| {});
|
||||
let l = Arc::make_mut(&mut d.layers[0]);
|
||||
l.appearance.items.push(AppearanceItem::Fill(FillLayer::new(solid(0.0, 1.0, 0.0))));
|
||||
let img = render(&d);
|
||||
assert_eq!((img.pixel(25, 25), img.pixel(65, 25)), (GREEN, GREEN));
|
||||
|
||||
// A knockout group with a drop shadow: its half-transparent members still knock each other
|
||||
// out (the overlap shows the top one over the backdrop), and it casts its shadow.
|
||||
let mut d = Document::new(100.0, 100.0);
|
||||
let half = |d: &mut Document, r: Rect, p: Paint| {
|
||||
let mut n = Node::path(d.alloc_id(), shapes::rectangle(r), Appearance::basic(p, Paint::None, 0.0));
|
||||
n.opacity = 0.5;
|
||||
Arc::new(n)
|
||||
};
|
||||
let a = half(&mut d, Rect::new(10.0, 10.0, 50.0, 40.0), solid(1.0, 0.0, 0.0));
|
||||
let b = half(&mut d, Rect::new(30.0, 10.0, 70.0, 40.0), solid(0.0, 0.0, 1.0));
|
||||
let mut g = Node::group(d.alloc_id(), vec![a, b]);
|
||||
g.knockout = Knockout::On;
|
||||
g.appearance.effects.push(fx("stylize.dropShadow", json!({"x": 0, "y": 40, "blur": 0, "opacity": 100, "mode": "normal"})));
|
||||
let l = d.layers[0].id;
|
||||
d.insert(Some(l), 0, g).unwrap();
|
||||
let img = render(&d);
|
||||
let overlap = img.pixel(40, 25);
|
||||
assert!(overlap[0] > 120 && overlap[2] > 250, "the blue knocks the red out: {overlap:?}");
|
||||
// The shadow of the composite: as opaque as the knocked-out art (half), the overlap too.
|
||||
assert!(img.pixel(40, 65)[0].abs_diff(127) <= 2 && img.pixel(20, 65) == img.pixel(40, 65), "the shadow: {:?}", img.pixel(40, 65));
|
||||
}
|
||||
|
||||
/// "IIII" in black at 40 pt with its own red fill; `below`: under the characters.
|
||||
fn type_doc(below: bool) -> Document {
|
||||
let mut d = Document::new(100.0, 100.0);
|
||||
let style = CharStyle { size: 40.0, fill: Paint::solid(Color::BLACK), ..Default::default() };
|
||||
let mut n = Node::new(d.alloc_id(), NodeKind::Text(Box::new(TextObject::point(Point::new(5.0, 60.0), "IIII", style))));
|
||||
n.appearance.items.push(AppearanceItem::Fill(FillLayer::new(solid(1.0, 0.0, 0.0))));
|
||||
if below {
|
||||
n.appearance.set_contents_at(1);
|
||||
}
|
||||
let l = d.layers[0].id;
|
||||
d.insert(Some(l), 0, n).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
fn red_pixels(img: &Rendered) -> usize {
|
||||
(0..100).flat_map(|y| (0..100).map(move |x| (x, y))).filter(|&(x, y)| matches!(img.pixel(x, y), [r, g, _, _] if r > 200 && g < 60)).count()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_fill_below_the_characters_is_hidden_by_the_glyphs() {
|
||||
assert!(red_pixels(&render(&type_doc(false))) > 50, "above: the red fill covers the glyphs");
|
||||
assert_eq!(red_pixels(&render(&type_doc(true))), 0, "below: the black glyphs cover it");
|
||||
}
|
||||
Reference in New Issue
Block a user