mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-28 03:37:00 -06:00
Geometry effects on a group or layer, and effects on a symbol instance, blend or repeat, outline the type inside it. outline_nested_text replaced each type object with a plain group of its outlines, so the type lost its opacity, blend mode, opacity mask, knockout shape option, own effects and perspective, and hidden type was drawn, exported and made visible by Expand Appearance. outline_nested_text now replaces type with the art that as_art makes for it, which is also what reshape makes for type with geometry effects of its own. That art has the type's own geometry effects and perspective applied and keeps its name, transparency, opacity mask and other effects. as_art also keeps the object's visibility and knockout shape option, so hidden type with a geometry effect of its own is left out of exports. Type in a clip group's clipping path still becomes its plain outlines and now keeps its visibility.
279 lines
13 KiB
Rust
279 lines
13 KiB
Rust
//! 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, Document, Effect, FillLayer, ImageBlob, ImageObject, 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));
|
||
}
|
||
|
||
/// #2023: a Transform effect on a group moves and copies its images as it does its paths (the
|
||
/// image's placement takes the map, and every copy is drawn at its own place).
|
||
#[test]
|
||
fn group_geometry_effects_move_and_copy_images_too() {
|
||
let mut d = Document::new(100.0, 100.0);
|
||
let mut png = Vec::new();
|
||
image::RgbaImage::from_pixel(4, 4, image::Rgba([255, 0, 0, 255])).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
|
||
d.images.insert("red".into(), ImageBlob::new("image/png", png));
|
||
let b = rect(&mut d, Rect::new(0.0, 0.0, 20.0, 20.0), solid(0.0, 0.0, 1.0));
|
||
let im = ImageObject { key: "red".into(), width: 30, height: 30, xf: Affine::translate((10.0, 30.0)), link: None, placement: Default::default() };
|
||
let image = Arc::new(Node::new(d.alloc_id(), NodeKind::Image(Box::new(im))));
|
||
let mut g = Node::group(d.alloc_id(), vec![b, image]);
|
||
g.appearance.effects.push(fx("distort.transform", json!({"moveH": 50, "copies": 1})));
|
||
let l = d.layers[0].id;
|
||
d.insert(Some(l), 0, g).unwrap();
|
||
let img = render(&d);
|
||
assert_eq!(img.pixel(25, 45), RED, "the image itself");
|
||
assert_eq!(img.pixel(75, 45), RED, "its copy, 50 pt to the right");
|
||
assert_eq!(img.pixel(60, 10), BLUE, "the path's copy");
|
||
assert_eq!(img.pixel(5, 10), BLUE, "the path itself");
|
||
}
|
||
|
||
/// A 100×100 document holding a group of a red square (10..90) and black 40 pt type "IIII" over
|
||
/// it, the type changed by `ty` and the group changed by `group`.
|
||
fn type_group_doc(ty: impl FnOnce(&mut Node), group: 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, 90.0, 90.0), solid(1.0, 0.0, 0.0));
|
||
let style = CharStyle { size: 40.0, fill: Paint::solid(Color::BLACK), ..Default::default() };
|
||
let mut t = Node::new(d.alloc_id(), NodeKind::Text(Box::new(TextObject::point(Point::new(5.0, 60.0), "IIII", style))));
|
||
ty(&mut t);
|
||
let mut g = Node::group(d.alloc_id(), vec![a, Arc::new(t)]);
|
||
group(&mut g);
|
||
let l = d.layers[0].id;
|
||
d.insert(Some(l), 0, g).unwrap();
|
||
d
|
||
}
|
||
|
||
#[test]
|
||
fn type_in_a_group_keeps_its_transparency_and_visibility_under_the_groups_geometry_effects() {
|
||
// Offset Path 0 leaves every path as it is, so the group is drawn the same with it as without
|
||
// it, for type at 50 %, hidden type, and type at 50 % defining its knockout shape in a
|
||
// knockout group.
|
||
let half: fn(&mut Node) = |t| t.opacity = 0.5;
|
||
let hidden: fn(&mut Node) = |t| t.visible = false;
|
||
let shape: fn(&mut Node) = |t| (t.opacity, t.knockout_shape) = (0.5, true);
|
||
let plain: fn(&mut Node) = |_| {};
|
||
let knockout: fn(&mut Node) = |g| g.knockout = Knockout::On;
|
||
for (case, ty, group) in [("50 %", half, plain), ("hidden", hidden, plain), ("knockout shape", shape, knockout)] {
|
||
let without = render(&type_group_doc(ty, group));
|
||
let with = render(&type_group_doc(ty, |g| {
|
||
group(g);
|
||
g.appearance.effects.push(fx("path.offsetPath", json!({"offset": 0})));
|
||
}));
|
||
let worst = without.pixels.iter().zip(&with.pixels).map(|(a, b)| a.abs_diff(*b)).max().unwrap();
|
||
assert!(worst <= 4, "{case}: max channel difference {worst}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn type_in_a_clipping_path_clips_alike_under_the_groups_geometry_effects() {
|
||
// The red square is clipped by type with a Transform of its own, which the clip leaves out,
|
||
// then by a group of that type and a small square, with the type shown and hidden. Offset
|
||
// Path 0 on the clip group leaves every path as it is.
|
||
let doc = |effect: bool, grouped: bool, hidden: bool| {
|
||
let mut d = Document::new(100.0, 100.0);
|
||
let style = CharStyle { size: 40.0, fill: Paint::None, ..Default::default() };
|
||
let mut t = Node::new(d.alloc_id(), NodeKind::Text(Box::new(TextObject::point(Point::new(5.0, 60.0), "IIII", style))));
|
||
t.appearance.effects.push(fx("distort.transform", json!({"moveH": 30})));
|
||
t.visible = !hidden;
|
||
let mut clip = Arc::new(t);
|
||
if grouped {
|
||
let small = rect(&mut d, Rect::new(0.0, 0.0, 20.0, 20.0), Paint::None);
|
||
clip = Arc::new(Node::group(d.alloc_id(), vec![clip, small]));
|
||
}
|
||
let a = rect(&mut d, Rect::new(0.0, 0.0, 100.0, 100.0), solid(1.0, 0.0, 0.0));
|
||
let mut g = Node::new(d.alloc_id(), NodeKind::Group { children: vec![clip, a], clip: true });
|
||
if effect {
|
||
g.appearance.effects.push(fx("path.offsetPath", json!({"offset": 0})));
|
||
}
|
||
let l = d.layers[0].id;
|
||
d.insert(Some(l), 0, g).unwrap();
|
||
d
|
||
};
|
||
for (grouped, hidden) in [(false, false), (true, false), (true, true)] {
|
||
let (without, with) = (render(&doc(false, grouped, hidden)), render(&doc(true, grouped, hidden)));
|
||
let worst = without.pixels.iter().zip(&with.pixels).map(|(a, b)| a.abs_diff(*b)).max().unwrap();
|
||
assert!(worst <= 4, "grouped {grouped}, hidden {hidden}: max channel difference {worst}");
|
||
}
|
||
}
|
||
|
||
#[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");
|
||
}
|