Files
vectorcraft/crates/render/src/tests_objectfx.rs
T
bflatasticandClaude Opus 5.5 0f1565aed2 M3.43: effects render on type, placed images, symbol instances, blends, envelopes, meshes and repeats; a fill or stroke's own raster effects render and export as SVG filters on that element
Geometry effects reach objects that aren't paths through their outlines (vectorcraft_effects::
reshape: glyph outlines, evaluated live art, the symbol's art, an image clipped by its reshaped
frame), applied to the whole art as one piece; the renderer caches that art per object and
paints raster effects around it in the same filter layers paths use (shared Renderer::raster_fx,
also used per fill/stroke). Exports bake the reshaped art; SVG writes per-item filters; PDF
warns about raster effects on strokes as well as fills and objects.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 23:15:13 +07:00

153 lines
6.4 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Effects on every object kind: raster effects on type, images and symbol instances through an
//! offscreen layer of their art, geometry effects through their outlines, and one fill or
//! stroke's own raster effects.
use std::sync::Arc;
use serde_json::{Value, json};
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::text::{CharStyle, TextObject};
use vectorcraft_doc::{Appearance, Effect, ImageBlob, ImageObject, Node, NodeKind, Symbol};
use vectorcraft_geom::{Point, shapes};
use super::*;
fn fx(id: &str, params: Value) -> Effect {
Effect { id: id.into(), params, visible: true }
}
/// A 100×100 document with `n` (its id allocated) on the first layer.
fn doc_with(mut d: Document, mut n: Node) -> Document {
n.id = d.alloc_id();
let l = d.layers[0].id;
d.insert(Some(l), 0, n).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)
}
fn lum(p: [u8; 4]) -> u32 {
p[0] as u32 + p[1] as u32 + p[2] as u32
}
const WHITE: u32 = 765;
/// A 30×30 red image placed at (20, 20).
fn image_doc(effects: Vec<Effect>) -> Document {
let mut d = Document::new(100.0, 100.0);
let img = image::RgbaImage::from_pixel(30, 30, image::Rgba([255, 0, 0, 255]));
let mut png = vec![];
img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
d.images.insert("red".into(), ImageBlob { mime: "image/png".into(), bytes: Arc::new(png) });
let mut n = Node::new(
NodeId(0),
NodeKind::Image(ImageObject { key: "red".into(), width: 30, height: 30, xf: Affine::translate((20.0, 20.0)), link: None }),
);
n.appearance.effects = effects;
doc_with(d, n)
}
/// An instance at (50, 50) of a symbol whose art is a black 20×20 square centred on its origin.
fn symbol_doc(effects: Vec<Effect>) -> Document {
let mut d = Document::new(100.0, 100.0);
let art = Node::path(
d.alloc_id(),
shapes::rectangle(Rect::new(-10.0, -10.0, 10.0, 10.0)),
Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0),
);
d.symbols.push(Symbol { name: "Square".into(), art: Arc::new(art) });
let mut n = Node::new(NodeId(0), NodeKind::SymbolInstance { symbol: "Square".into(), xf: Affine::translate((50.0, 50.0)) });
n.appearance.effects = effects;
doc_with(d, n)
}
/// Black 40 pt "IIII" with its baseline at (5, 60).
fn text_doc(effects: Vec<Effect>) -> Document {
let style = CharStyle { size: 40.0, fill: Paint::solid(Color::BLACK), ..Default::default() };
let mut n = Node::new(NodeId(0), NodeKind::Text(Box::new(TextObject::point(Point::new(5.0, 60.0), "IIII", style))));
n.appearance.effects = effects;
doc_with(Document::new(100.0, 100.0), n)
}
/// Columns (x) holding any dark pixel.
fn inked_columns(img: &Rendered) -> Vec<u32> {
(0..100).filter(|x| (0..100).any(|y| lum(img.pixel(*x, y)) < 300)).collect()
}
#[test]
fn a_shadow_on_an_image_paints_offset_pixels() {
let plain = render(&image_doc(vec![]));
assert_eq!(lum(plain.pixel(55, 55)), WHITE);
let d = image_doc(vec![fx("stylize.dropShadow", json!({"x": 10, "y": 10, "blur": 0, "opacity": 100, "mode": "normal"}))]);
let img = render(&d);
assert!(lum(img.pixel(55, 55)) < 100, "shadow below-right: {:?}", img.pixel(55, 55));
assert_eq!(img.pixel(35, 35), [255, 0, 0, 255], "the image stays on top");
assert_eq!(lum(img.pixel(15, 15)), WHITE);
// The multithreaded pipeline filters offscreen and matches.
let mt = render_threads(&d, 3);
let worst = img.pixels.iter().zip(&mt.pixels).map(|(a, b)| a.abs_diff(*b)).max().unwrap();
assert!(worst <= 4, "max channel difference {worst}");
}
#[test]
fn blur_on_a_symbol_instance_softens_its_edges() {
let plain = render(&symbol_doc(vec![]));
assert_eq!(lum(plain.pixel(38, 50)), WHITE);
assert_eq!(lum(plain.pixel(50, 50)), 0);
let img = render(&symbol_doc(vec![fx("blur.gaussian", json!({"radius": 8}))]));
let edge = lum(img.pixel(38, 50));
assert!(edge > 0 && edge < WHITE, "the blur spreads past the edge: {edge}");
assert!(lum(img.pixel(50, 50)) < 200, "the centre stays dark");
}
#[test]
fn geometry_effects_reshape_type_through_its_outlines() {
let plain = inked_columns(&render(&text_doc(vec![])));
assert!(!plain.is_empty() && plain[0] < 30, "{plain:?}");
// Transform moves the outlines as one piece.
let moved = inked_columns(&render(&text_doc(vec![fx("distort.transform", json!({"moveH": 30}))])));
assert_eq!(moved.first().copied(), plain.first().map(|x| x + 30));
// Roughen redraws the glyphs as rough outlines.
let d = text_doc(vec![fx("distort.roughen", json!({"size": 4, "relative": false, "detail": 30, "points": "corner"}))]);
let rough = render(&d);
let smooth = render(&text_doc(vec![]));
let changed = rough.pixels.chunks(4).zip(smooth.pixels.chunks(4)).filter(|(a, b)| a != b).count();
assert!(changed > 100, "{changed} pixels changed");
// The reshaped art is plain paths painted with the text's fill.
let n = &d.layers[0].children().unwrap()[0];
let art = vectorcraft_effects::reshape(n, None).unwrap();
let (mut text, mut black) = (0, 0);
art.walk(&mut |m| {
text += usize::from(matches!(m.kind, NodeKind::Text(_)));
black += usize::from(m.appearance.fill_paint() == Paint::solid(Color::BLACK));
});
assert!(text == 0 && black > 0, "{art:?}");
}
#[test]
fn a_strokes_own_glow_paints_around_that_stroke_only() {
let mut d = Document::new(100.0, 100.0);
let mut n = Node::path(
NodeId(0),
shapes::rectangle(Rect::new(30.0, 30.0, 70.0, 70.0)),
Appearance::basic(Paint::solid(Color::WHITE), Paint::solid(Color::BLACK), 2.0),
);
n.appearance.items[1].effects_mut().push(fx("stylize.outerGlow", json!({"color": "#00ff00", "mode": "normal", "opacity": 100, "blur": 6})));
d = doc_with(d, n);
let img = render(&d);
let g = img.pixel(27, 50);
assert!(g[1] > g[0] + 20 && g[1] > g[2] + 20, "green glow outside the stroke: {g:?}");
assert_eq!(img.pixel(50, 50), [255, 255, 255, 255], "the fill has no glow");
let mt = render_threads(&d, 3);
let worst = img.pixels.iter().zip(&mt.pixels).map(|(a, b)| a.abs_diff(*b)).max().unwrap();
assert!(worst <= 4, "max channel difference {worst}");
}