Files
vectorcraft/crates/engine/src/tests_expand.rs
T
bflatasticandClaude Opus 5.5 b4ba4ecf0a M3.70: Expand Appearance turns fills, strokes, effects and brushes into objects
effect.expandAppearance now expands the whole appearance: each visible fill becomes a
path of its own and each stroke its outline (a brushed stroke its brush art), grouped
in paint order under the object's id and transparency; geometry effects are baked and
raster effects become an embedded image (shadows below the art, blur/feather/inner glow
replacing it). Type with effects or its own fills is outlined, and a group's or layer's
own fills and strokes are expanded among its members. The command is enabled whenever
an appearance isn't basic. Builds on bake_leaf (effects::expand_leaf / expand_art) and
shares the raster-effect image code with PDF export.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 02:16:02 +07:00

193 lines
8.8 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.
//! Object → Expand Appearance: fills and strokes as objects, baked geometry effects, raster
//! effects as images, brushes as art, and the enable rule.
use serde_json::{Value, json};
use vectorcraft_color::BlendMode;
use vectorcraft_doc::{Node, NodeKind};
use super::*;
fn session() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap();
s
}
fn run(s: &mut Session, id: &str, p: Value) -> Value {
s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"))
}
fn node(s: &Session, id: NodeId) -> Node {
s.doc().unwrap().doc.node(id).unwrap().clone()
}
/// A selected 100×100 square at (50, 50) with a red fill and a 4 pt blue stroke.
fn square(s: &mut Session) -> NodeId {
let id = NodeId(run(s, "shape.rectangle", json!({"x": 50, "y": 50, "width": 100, "height": 100}))["id"].as_u64().unwrap());
run(s, "paint.setFill", json!({"color": "#ff0000"}));
run(s, "paint.setStroke", json!({"color": "#0000ff"}));
run(s, "stroke.set", json!({"weight": 4}));
id
}
fn render(s: &Session) -> vectorcraft_render::Rendered {
let doc = s.doc().unwrap().doc.clone();
let opts = vectorcraft_render::RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
vectorcraft_render::Renderer::new().render(&doc, 200, 200, vectorcraft_geom::Affine::IDENTITY, &opts)
}
/// Pixels (of 200×200) whose colour differs by more than `tol` in any channel.
fn differing(a: &vectorcraft_render::Rendered, b: &vectorcraft_render::Rendered, tol: u8) -> usize {
a.pixels.chunks_exact(4).zip(b.pixels.chunks_exact(4)).filter(|(p, q)| p.iter().zip(q.iter()).any(|(x, y)| x.abs_diff(*y) > tol)).count()
}
fn expandable(s: &Session) -> bool {
s.commands().iter().find(|c| c.id == "effect.expandAppearance").unwrap().enabled
}
#[test]
fn two_fills_and_an_offset_stroke_become_three_paths_that_look_the_same() {
let mut s = session();
let id = square(&mut s);
assert!(!expandable(&s), "a basic appearance has nothing to expand");
// A second fill on top (half-transparent green) and an outset stroke.
run(&mut s, "appearance.addFill", json!({}));
run(&mut s, "appearance.setItem", json!({"index": 2, "color": "#00ff00", "opacity": 50}));
run(&mut s, "effect.apply", json!({"effect": "path.offsetPath", "params": {"offset": 10}, "item": 1}));
assert!(expandable(&s));
let before = render(&s);
let r = run(&mut s, "effect.expandAppearance", json!({}));
assert_eq!(r["ids"], json!([id.0]));
let n = node(&s, id);
let ch = n.children().expect("a group of the fills and the stroke");
assert_eq!(ch.len(), 3);
assert!(ch.iter().all(|c| matches!(c.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }) && c.appearance.items.len() == 1));
// In paint order: red fill, the outlined stroke (a filled ring 10 pt out), the green fill.
assert!(ch.iter().all(|c| c.appearance.items[0].is_fill() && c.appearance.items[0].effects().is_empty()));
let ring = ch[1].geometric_bounds().unwrap();
assert!((ring.width() - 124.0).abs() < 0.5, "{ring:?}");
assert!((ch[2].opacity - 0.5).abs() < 1e-6, "the fill's opacity is the path's own");
assert!(ch[2].appearance.is_basic());
let after = render(&s);
assert!(differing(&before, &after, 8) < 200, "{} pixels differ", differing(&before, &after, 8));
assert!(!expandable(&s));
// One undo step.
run(&mut s, "edit.undo", json!({}));
assert!(matches!(node(&s, id).kind, NodeKind::Path { .. }));
assert_eq!(node(&s, id).appearance.items.len(), 3);
}
#[test]
fn a_shadow_becomes_an_image_under_the_art_and_transparency_stays_on_the_group() {
let mut s = session();
let id = square(&mut s);
run(&mut s, "effect.apply", json!({"effect": "stylize.dropShadow", "params": {"x": 8, "y": 8, "blur": 2, "opacity": 100}}));
run(&mut s, "transparency.set", json!({"opacity": 60, "blend": "multiply"}));
let before = render(&s);
run(&mut s, "effect.expandAppearance", json!({}));
let n = node(&s, id);
assert!((n.opacity - 0.6).abs() < 1e-6 && n.blend == BlendMode::Multiply);
assert!(n.appearance.effects.is_empty() && n.appearance.items.is_empty());
let ch = n.children().unwrap();
assert_eq!(ch.len(), 3, "the shadow image, the fill, the outlined stroke");
let NodeKind::Image(im) = &ch[0].kind else { panic!("the shadow is an image: {}", ch[0].kind_label()) };
assert!(s.doc().unwrap().doc.images.contains_key(&im.key));
assert!(ch[1..].iter().all(|c| c.opacity == 1.0 && c.appearance.effects.is_empty()));
let after = render(&s);
assert!(differing(&before, &after, 24) < 400, "{} pixels differ", differing(&before, &after, 24));
// The shadow is still there, below and right of the square.
let p = after.pixel(155, 155);
assert!(p[0] < 200, "{p:?}");
}
#[test]
fn a_blur_turns_the_object_into_an_image() {
let mut s = session();
let id = square(&mut s);
run(&mut s, "effect.apply", json!({"effect": "blur.gaussian", "params": {"radius": 3}}));
run(&mut s, "effect.expandAppearance", json!({}));
let n = node(&s, id);
assert!(matches!(n.kind, NodeKind::Image(_)), "{}", n.kind_label());
assert!(n.appearance.effects.is_empty());
}
#[test]
fn an_art_brush_becomes_a_group_of_its_art() {
let mut s = session();
let id = NodeId(run(&mut s, "shape.line", json!({"x1": 20, "y1": 100, "x2": 180, "y2": 100}))["id"].as_u64().unwrap());
run(&mut s, "select.set", json!({"ids": [id.0]}));
run(&mut s, "paint.setFill", json!({"none": true}));
run(&mut s, "brush.apply", json!({"name": "Charcoal"}));
assert!(expandable(&s));
let before = render(&s);
run(&mut s, "effect.expandAppearance", json!({}));
let n = node(&s, id);
assert!(matches!(n.kind, NodeKind::Group { .. }), "{}", n.kind_label());
let mut brushed = false;
n.walk(&mut |c| brushed |= vectorcraft_brush::has_brush(c));
assert!(!brushed, "no brush strokes are left");
let after = render(&s);
assert!(differing(&before, &after, 24) < 200, "{} pixels differ", differing(&before, &after, 24));
run(&mut s, "edit.undo", json!({}));
assert!(vectorcraft_brush::has_brush(&node(&s, id)));
}
#[test]
fn a_targeted_layer_with_a_fill_expands_into_art_among_its_members() {
let mut s = session();
let a = square(&mut s);
let layer = s.doc().unwrap().doc.layers[0].id;
run(&mut s, "layer.target", json!({"id": layer.0}));
run(&mut s, "appearance.addStroke", json!({}));
run(&mut s, "stroke.set", json!({"item": 0, "weight": 6}));
assert!(expandable(&s));
let before = render(&s);
run(&mut s, "effect.expandAppearance", json!({}));
let l = node(&s, layer);
assert!(l.appearance.items.is_empty());
let ch = l.children().unwrap();
assert_eq!(ch.len(), 2, "the square, then the layer's stroke above it");
assert_eq!(ch[0].id, a);
// The layer's stroke is outlined: a filled path.
assert!(ch[1].appearance.items.iter().all(|i| i.is_fill()));
let after = render(&s);
assert!(differing(&before, &after, 8) < 200, "{} pixels differ", differing(&before, &after, 8));
}
#[test]
fn type_with_a_shadow_stays_type_and_type_with_its_own_stroke_is_outlined() {
let mut s = session();
let t = NodeId(run(&mut s, "text.create", json!({"x": 20, "y": 80, "text": "Hi", "size": 48}))["id"].as_u64().unwrap());
run(&mut s, "select.set", json!({"ids": [t.0]}));
run(&mut s, "effect.apply", json!({"effect": "stylize.dropShadow"}));
run(&mut s, "effect.expandAppearance", json!({}));
let n = node(&s, t);
let ch = n.children().unwrap();
assert!(matches!(ch[0].kind, NodeKind::Image(_)) && matches!(ch[1].kind, NodeKind::Text(_)));
// A stroke of its own: outlined glyphs.
let u = NodeId(run(&mut s, "text.create", json!({"x": 20, "y": 160, "text": "Yo", "size": 48}))["id"].as_u64().unwrap());
run(&mut s, "select.set", json!({"ids": [u.0]}));
run(&mut s, "appearance.addStroke", json!({}));
run(&mut s, "effect.expandAppearance", json!({}));
let mut text = false;
node(&s, u).walk(&mut |c| text |= matches!(c.kind, NodeKind::Text(_)));
assert!(!text, "the type is outlined");
assert!(!expandable(&s));
}
#[test]
fn a_blurred_layer_keeps_its_image_as_its_only_member() {
let mut s = session();
square(&mut s);
let layer = s.doc().unwrap().doc.layers[0].id;
run(&mut s, "layer.clippingMask.toggle", json!({"id": layer.0}));
run(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 200, "height": 200}));
run(&mut s, "layer.target", json!({"id": layer.0}));
run(&mut s, "effect.apply", json!({"effect": "blur.gaussian"}));
run(&mut s, "effect.expandAppearance", json!({}));
let l = node(&s, layer);
assert!(l.is_layer() && !l.clips() && l.appearance.effects.is_empty());
let ch = l.children().unwrap();
assert!(ch.len() == 1 && matches!(ch[0].kind, NodeKind::Image(_)));
}