vectorcraft/crates/engine/src/tests_appearance.rs

412 lines
20 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.

//! Appearance stacks: the active item that paint, stroke, gradient and transparency edits target
//! (`appearance.setActiveItem`, the `item` param).
use serde_json::{Value, json};
use vectorcraft_color::{BlendMode, Color, GradientKind, Paint};
use vectorcraft_doc::{AppearanceItem, Node};
use super::*;
fn session() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
s
}
/// A selected 100×100 rectangle with the default appearance `[Fill white, Stroke black]`.
fn rect(s: &mut Session, x: f64) -> NodeId {
let r = s.execute("shape.rectangle", &json!({"x": x, "y": 100, "width": 100, "height": 100})).unwrap();
let id = NodeId(r["id"].as_u64().unwrap());
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
id
}
fn node(s: &Session, id: NodeId) -> Node {
s.doc().unwrap().doc.node(id).unwrap().clone()
}
fn run(s: &mut Session, id: &str, p: Value) -> Value {
s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"))
}
fn stroke(n: &Node, i: usize) -> &vectorcraft_doc::StrokeLayer {
n.appearance.stroke_at(Some(i)).unwrap_or_else(|| panic!("item {i} is not a stroke"))
}
fn fill_paint(n: &Node, i: usize) -> &Paint {
&n.appearance.fill_at(Some(i)).unwrap_or_else(|| panic!("item {i} is not a fill")).paint
}
#[test]
fn stroke_set_with_item_changes_only_that_stroke() {
let mut s = session();
let id = rect(&mut s, 100.0);
run(&mut s, "appearance.addStroke", json!({})); // [Fill, Stroke, Stroke]
run(&mut s, "stroke.set", json!({"item": 1, "dash": [4, 2], "weight": 6}));
let n = node(&s, id);
assert_eq!(stroke(&n, 1).dash.as_ref().unwrap().pattern, vec![4.0, 2.0]);
assert_eq!(stroke(&n, 1).width, 6.0);
assert!(stroke(&n, 2).dash.is_none());
assert_eq!(stroke(&n, 2).width, 1.0);
run(&mut s, "stroke.setAdvanced", json!({"item": 1, "arrowScale": [50, 200]}));
let n = node(&s, id);
assert_eq!((stroke(&n, 1).arrow_scale, stroke(&n, 2).arrow_scale), ((50.0, 200.0), (100.0, 100.0)));
// An explicit item must be a stroke; nothing changes when it is not.
assert!(s.execute("stroke.set", &json!({"item": 0, "weight": 9})).is_err());
assert!(s.execute("stroke.set", &json!({"item": 7, "weight": 9})).is_err());
assert!(s.execute("stroke.set", &json!({"item": "top", "weight": 9})).is_err());
assert_eq!(stroke(&node(&s, id), 2).width, 1.0);
// Without an item the top stroke changes.
run(&mut s, "stroke.set", json!({"weight": 3}));
let n = node(&s, id);
assert_eq!((stroke(&n, 1).width, stroke(&n, 2).width), (6.0, 3.0));
}
#[test]
fn edit_gradient_on_the_lower_fill() {
let mut s = session();
let id = rect(&mut s, 100.0);
run(&mut s, "appearance.addFill", json!({})); // [Fill, Stroke, Fill]
run(&mut s, "paint.editGradient", json!({"item": 0, "kind": "radial"}));
let n = node(&s, id);
let Paint::Gradient(g) = fill_paint(&n, 0) else { panic!("lower fill is not a gradient") };
assert_eq!(g.gradient.kind, GradientKind::Radial);
assert_eq!(*fill_paint(&n, 2), Paint::solid(Color::WHITE));
// The gradient vector of the lower fill, through the active item (the gradient tool's path).
run(&mut s, "appearance.setActiveItem", json!({"index": 0}));
run(&mut s, "paint.setGradientGeom", json!({"start": [100, 150], "end": [200, 150]}));
let n = node(&s, id);
let Paint::Gradient(g) = fill_paint(&n, 0) else { panic!() };
assert_eq!(g.geom.unwrap().end.x, 200.0);
assert_eq!(*fill_paint(&n, 2), Paint::solid(Color::WHITE));
// A stroke row makes the gradient vector a stroke gradient.
run(&mut s, "appearance.setActiveItem", json!({"index": 1}));
run(&mut s, "paint.setGradientGeom", json!({"start": [100, 150], "end": [100, 250]}));
assert!(matches!(stroke(&node(&s, id), 1).paint, Paint::Gradient(_)));
}
#[test]
fn active_item_targets_paint_and_proxies_and_clears_on_selection_change() {
let mut s = session();
let other = rect(&mut s, 250.0);
let id = rect(&mut s, 100.0);
run(&mut s, "appearance.addFill", json!({})); // [Fill, Stroke, Fill]
assert!(s.execute("appearance.setActiveItem", &json!({"index": 5})).is_err());
assert_eq!(run(&mut s, "appearance.setActiveItem", json!({"index": 0}))["index"], 0);
assert_eq!(s.appearance_item(), Some(0));
assert_eq!(inspect::document(&s)["paint"]["appearanceItem"], 0);
assert!(s.fill_active);
run(&mut s, "paint.setFill", json!({"color": "#ff0000"}));
let n = node(&s, id);
assert_eq!(fill_paint(&n, 0).color().unwrap().to_hex(), "#ff0000");
assert_eq!(*fill_paint(&n, 2), Paint::solid(Color::WHITE));
// The stroke edits fall back to the top stroke while a fill row is active.
run(&mut s, "paint.setStroke", json!({"color": "#00ff00"}));
assert_eq!(stroke(&node(&s, id), 1).paint.color().unwrap().to_hex(), "#00ff00");
// The proxies show the active row for its kind and the top one for the other.
let (f, st) = s.proxy_paints();
assert_eq!((f.color().unwrap().to_hex(), st.color().unwrap().to_hex()), ("#ff0000".to_string(), "#00ff00".to_string()));
// A stroke row brings the Stroke proxy forward.
run(&mut s, "appearance.setActiveItem", json!({"index": 1}));
assert!(!s.fill_active);
// Changing the selection clears it, also when the same object is selected again.
run(&mut s, "select.set", json!({"ids": [other.0]}));
assert_eq!(s.appearance_item(), None);
run(&mut s, "select.set", json!({"ids": [id.0]}));
assert_eq!(s.appearance_item(), None);
run(&mut s, "paint.setFill", json!({"color": "#0000ff"}));
let n = node(&s, id);
assert_eq!(fill_paint(&n, 2).color().unwrap().to_hex(), "#0000ff");
assert_eq!(fill_paint(&n, 0).color().unwrap().to_hex(), "#ff0000");
// Explicit ids never use the active item, and `null` forces the top one.
run(&mut s, "appearance.setActiveItem", json!({"index": 0}));
run(&mut s, "paint.setFill", json!({"color": "#222222", "ids": [id.0]}));
assert_eq!(fill_paint(&node(&s, id), 2).color().unwrap().to_hex(), "#222222");
run(&mut s, "paint.setFill", json!({"color": "#111111", "item": null}));
let n = node(&s, id);
assert_eq!(fill_paint(&n, 2).color().unwrap().to_hex(), "#111111");
assert_eq!(fill_paint(&n, 0).color().unwrap().to_hex(), "#ff0000");
run(&mut s, "appearance.setActiveItem", json!({"index": null}));
assert_eq!(s.appearance_item(), None);
}
#[test]
fn item_rows_follow_remove_and_move() {
let mut s = session();
let id = rect(&mut s, 100.0);
run(&mut s, "appearance.addFill", json!({})); // [Fill, Stroke, Fill]
run(&mut s, "appearance.setActiveItem", json!({"index": 1}));
run(&mut s, "appearance.moveItem", json!({"from": 1, "to": 2}));
assert_eq!(s.appearance_item(), Some(2));
assert!(matches!(node(&s, id).appearance.items[2], AppearanceItem::Stroke(_)));
run(&mut s, "appearance.removeItem", json!({"index": 0}));
assert_eq!(s.appearance_item(), Some(1));
run(&mut s, "appearance.removeItem", json!({"index": 1}));
assert_eq!(s.appearance_item(), None);
}
#[test]
fn transparency_goes_to_the_targeted_item() {
let mut s = session();
let id = rect(&mut s, 100.0);
run(&mut s, "transparency.set", json!({"item": 1, "opacity": 40, "blend": "multiply"}));
let n = node(&s, id);
assert_eq!((stroke(&n, 1).opacity, stroke(&n, 1).blend), (0.4, BlendMode::Multiply));
assert_eq!((n.opacity, n.blend), (1.0, BlendMode::Normal));
run(&mut s, "appearance.setActiveItem", json!({"index": 0}));
// Opacity and blend go to the active item, isolate stays on the object.
run(&mut s, "transparency.set", json!({"opacity": 1, "isolate": true}));
let n = node(&s, id);
assert!((n.appearance.items[0].opacity() - 0.01).abs() < 1e-6);
assert!(n.isolate);
assert_eq!(n.opacity, 1.0);
run(&mut s, "transparency.set", json!({"item": null, "opacity": 50}));
assert_eq!(node(&s, id).opacity, 0.5);
assert!(s.execute("transparency.set", &json!({"item": 9, "opacity": 50})).is_err());
}
#[test]
fn group_rows_are_the_groups_own_stack() {
let mut s = session();
let a = rect(&mut s, 100.0);
let b = rect(&mut s, 250.0);
run(&mut s, "select.set", json!({"ids": [a.0, b.0]}));
let g = NodeId(run(&mut s, "object.group", json!({}))["id"].as_u64().unwrap());
run(&mut s, "appearance.addFill", json!({}));
assert_eq!(node(&s, g).appearance.items.len(), 1);
assert_eq!(node(&s, a).appearance.items.len(), 2);
run(&mut s, "appearance.setActiveItem", json!({"index": 0}));
run(&mut s, "paint.setFill", json!({"color": "#ff0000"}));
assert_eq!(fill_paint(&node(&s, g), 0).color().unwrap().to_hex(), "#ff0000");
assert_eq!(*fill_paint(&node(&s, a), 0), Paint::solid(Color::WHITE));
// Without a targeted item, a group's fill edit still recolours its contents.
run(&mut s, "appearance.setActiveItem", json!({"index": null}));
run(&mut s, "paint.setFill", json!({"color": "#00ff00"}));
assert_eq!(fill_paint(&node(&s, b), 0).color().unwrap().to_hex(), "#00ff00");
}
#[test]
fn text_items_take_item_paint() {
let mut s = session();
let t = NodeId(run(&mut s, "text.create", json!({"x": 10, "y": 50, "text": "Hi"}))["id"].as_u64().unwrap());
run(&mut s, "select.set", json!({"ids": [t.0]}));
run(&mut s, "appearance.addFill", json!({}));
run(&mut s, "paint.setFill", json!({"item": 0, "color": "#ff0000"}));
let n = node(&s, t);
assert_eq!(fill_paint(&n, 0).color().unwrap().to_hex(), "#ff0000");
let vectorcraft_doc::NodeKind::Text(tx) = &n.kind else { panic!() };
assert_ne!(tx.first_style().fill, Paint::solid(Color::from_hex("#ff0000").unwrap()));
}
#[test]
fn effects_land_on_the_targeted_item() {
let mut s = session();
let id = rect(&mut s, 100.0);
let r = run(&mut s, "effect.apply", json!({"effect": "distort.roughen", "item": 1}));
assert_eq!((r["index"].clone(), r["item"].clone()), (json!(0), json!(1)));
let n = node(&s, id);
assert_eq!(stroke(&n, 1).effects.len(), 1);
assert!(n.appearance.effects.is_empty() && n.appearance.items[0].effects().is_empty());
run(&mut s, "edit.undo", json!({}));
assert!(stroke(&node(&s, id), 1).effects.is_empty());
assert!(s.execute("effect.apply", &json!({"effect": "distort.roughen", "item": 9})).is_err());
assert!(s.execute("effect.apply", &json!({"effect": "distort.roughen", "item": -1})).is_err());
// The active item takes the Effect menu's effects (and its alias).
run(&mut s, "appearance.setActiveItem", json!({"index": 0}));
run(&mut s, "effect.apply", json!({"effect": "distort.twist"}));
run(&mut s, "appearance.addEffect", json!({"effect": "stylize.roundCorners"}));
let n = node(&s, id);
assert_eq!(n.appearance.items[0].effects().iter().map(|e| e.id.as_str()).collect::<Vec<_>>(), ["distort.twist", "stylize.roundCorners"]);
let l = run(&mut s, "effect.list", json!({}));
assert_eq!(l["activeItem"], 0);
assert_eq!(l["applied"][0]["items"][0]["kind"], "fill");
assert_eq!(l["applied"][0]["items"][0]["effects"].as_array().unwrap().len(), 2);
// Sub-row eye, duplicate and delete address the item's list.
run(&mut s, "effect.setParams", json!({"index": 1, "visible": false}));
run(&mut s, "effect.duplicate", json!({"index": 0}));
run(&mut s, "effect.remove", json!({"index": 2}));
let fx = node(&s, id).appearance.items[0].effects().clone();
assert_eq!(fx.iter().map(|e| (e.id.as_str(), e.visible)).collect::<Vec<_>>(), [("distort.twist", true), ("distort.twist", true)]);
assert!(s.execute("effect.remove", &json!({"index": 5})).is_err());
// `item: null` addresses the object's own effects.
run(&mut s, "effect.apply", json!({"effect": "stylize.dropShadow", "item": null}));
assert_eq!(node(&s, id).appearance.effects.len(), 1);
}
#[test]
fn per_fill_offset_path_renders() {
let mut s = session();
let id = rect(&mut s, 100.0);
run(&mut s, "paint.setFill", json!({"color": "#ff0000"}));
run(&mut s, "paint.setStroke", json!({"none": true}));
let render = |s: &Session| {
vectorcraft_render::Renderer::new().render(&s.doc().unwrap().doc, 400, 400, vectorcraft_geom::Affine::IDENTITY, &Default::default())
};
assert_eq!(render(&s).pixel(90, 150)[3], 0);
run(&mut s, "effect.apply", json!({"effect": "path.offsetPath", "params": {"offset": 20}, "item": 0}));
assert_eq!(node(&s, id).appearance.items[0].effects().len(), 1);
let px = render(&s).pixel(90, 150);
assert!(px[0] > 200 && px[3] > 200, "{px:?}");
}
#[test]
fn text_fill_items_composite_with_their_opacity() {
let mut s = session();
let t = NodeId(run(&mut s, "text.create", json!({"x": 20, "y": 200, "text": "HIM", "size": 150}))["id"].as_u64().unwrap());
run(&mut s, "select.set", json!({"ids": [t.0]}));
run(&mut s, "appearance.addFill", json!({}));
run(&mut s, "appearance.setItem", json!({"index": 0, "color": "#ff0000"}));
let render = |s: &Session| {
vectorcraft_render::Renderer::new().render(&s.doc().unwrap().doc, 400, 400, vectorcraft_geom::Affine::IDENTITY, &Default::default())
};
let opaque = render(&s);
// A pixel the red fill covers fully (over the black character fill).
let (x, y) = (0..400u32).flat_map(|y| (0..400u32).map(move |x| (x, y))).find(|&(x, y)| opaque.pixel(x, y) == [255, 0, 0, 255]).expect("red text");
run(&mut s, "appearance.setItem", json!({"index": 0, "opacity": 50, "blend": "multiply"}));
let px = render(&s).pixel(x, y);
// Half of red multiplied over black: black.
assert!(px[0] < 20 && px[3] == 255, "{px:?}");
run(&mut s, "appearance.setItem", json!({"index": 0, "blend": "normal"}));
let px = render(&s).pixel(x, y);
assert!((110..=145).contains(&px[0]) && px[1] < 10, "{px:?}");
}
#[test]
fn add_fill_and_stroke_copy_the_top_ones() {
let mut s = session();
let id = rect(&mut s, 100.0);
run(&mut s, "paint.setFill", json!({"color": "#ff0000"}));
run(&mut s, "stroke.set", json!({"weight": 4}));
run(&mut s, "appearance.addFill", json!({}));
run(&mut s, "appearance.addStroke", json!({})); // [Fill, Stroke, Fill, Stroke]
let n = node(&s, id);
assert_eq!(n.appearance.items.iter().map(AppearanceItem::kind_name).collect::<Vec<_>>(), ["fill", "stroke", "fill", "stroke"]);
assert_eq!(fill_paint(&n, 2).color().unwrap().to_hex(), "#ff0000");
assert_eq!(stroke(&n, 3).width, 4.0);
assert!(!n.appearance.is_basic());
run(&mut s, "edit.undo", json!({}));
assert_eq!(node(&s, id).appearance.items.len(), 3);
}
#[test]
fn set_item_and_remove_item() {
let mut s = session();
let id = rect(&mut s, 100.0);
run(&mut s, "appearance.setItem", json!({"index": 1, "opacity": 50, "blend": "screen", "weight": 3, "color": "#0000ff"}));
let n = node(&s, id);
let st = stroke(&n, 1);
assert_eq!((st.opacity, st.blend, st.width), (0.5, BlendMode::Screen, 3.0));
assert_eq!(st.paint.color().unwrap().to_hex(), "#0000ff");
run(&mut s, "appearance.setItem", json!({"index": 0, "visible": false, "opacity": 25}));
let n = node(&s, id);
assert!(!n.appearance.items[0].visible() && n.appearance.items[0].opacity() == 0.25);
assert!(s.execute("appearance.setItem", &json!({})).is_err());
run(&mut s, "appearance.removeItem", json!({"index": 0}));
let n = node(&s, id);
assert_eq!(n.appearance.items.len(), 1);
assert!(n.appearance.fill().is_none() && n.appearance.stroke().is_some());
}
#[test]
fn clear_and_reduce_to_basic() {
let mut s = session();
let id = rect(&mut s, 100.0);
run(&mut s, "appearance.addStroke", json!({}));
run(&mut s, "appearance.setItem", json!({"index": 2, "visible": false, "color": "#00ff00"}));
run(&mut s, "appearance.setItem", json!({"index": 1, "opacity": 50}));
run(&mut s, "effect.apply", json!({"effect": "distort.roughen", "item": 1}));
run(&mut s, "effect.apply", json!({"effect": "stylize.dropShadow", "item": null}));
run(&mut s, "transparency.set", json!({"opacity": 40, "blend": "multiply"}));
// Reduce keeps the topmost visible fill and stroke, plain, and the object's transparency.
run(&mut s, "appearance.reduceToBasic", json!({}));
let n = node(&s, id);
assert!(n.appearance.is_basic(), "{:?}", n.appearance);
assert_eq!(stroke(&n, 1).paint, Paint::solid(Color::BLACK));
assert_eq!((n.opacity, n.blend), (0.4, BlendMode::Multiply));
// Clear leaves one None fill and stroke, and resets the object's Opacity row.
run(&mut s, "appearance.clear", json!({}));
let n = node(&s, id);
assert_eq!(n.appearance, vectorcraft_doc::Appearance::basic(Paint::None, Paint::None, 1.0));
assert_eq!((n.opacity, n.blend), (1.0, BlendMode::Normal));
run(&mut s, "edit.undo", json!({}));
assert_eq!(node(&s, id).opacity, 0.4);
}
#[test]
fn graphic_style_apply_replaces_the_stack() {
let mut s = session();
let a = rect(&mut s, 100.0);
run(&mut s, "appearance.addFill", json!({}));
run(&mut s, "effect.apply", json!({"effect": "distort.roughen"}));
let name = run(&mut s, "graphicStyle.new", json!({"name": "Rough"}))["name"].clone();
let b = rect(&mut s, 250.0);
run(&mut s, "graphicStyle.apply", json!({ "name": name }));
assert_eq!(node(&s, b).appearance, node(&s, a).appearance);
assert!(s.execute("graphicStyle.apply", &json!({"name": "nope"})).is_err());
}
#[test]
fn an_active_row_of_the_other_kind_leaves_edits_on_the_painted_leaves() {
let mut s = session();
let a = rect(&mut s, 100.0);
let b = rect(&mut s, 250.0);
run(&mut s, "select.set", json!({"ids": [a.0, b.0]}));
let g = NodeId(run(&mut s, "object.group", json!({}))["id"].as_u64().unwrap());
run(&mut s, "appearance.addFill", json!({}));
// The group's own fill row is active: stroke edits still go to the members' strokes.
run(&mut s, "appearance.setActiveItem", json!({"index": 0}));
run(&mut s, "stroke.set", json!({"weight": 5}));
run(&mut s, "paint.setStroke", json!({"color": "#00ff00"}));
assert_eq!(node(&s, g).appearance.items.len(), 1, "no stroke row added to the group");
for id in [a, b] {
assert_eq!(stroke(&node(&s, id), 1).width, 5.0);
assert_eq!(stroke(&node(&s, id), 1).paint.color().unwrap().to_hex(), "#00ff00");
}
}
#[test]
fn the_active_row_follows_only_edits_of_its_object() {
let mut s = session();
let other = rect(&mut s, 250.0);
rect(&mut s, 100.0);
run(&mut s, "appearance.setActiveItem", json!({"index": 1}));
run(&mut s, "appearance.removeItem", json!({"index": 0, "ids": [other.0]}));
run(&mut s, "appearance.duplicateItem", json!({"index": 0, "ids": [other.0]}));
assert_eq!(s.appearance_item(), Some(1));
}
#[test]
fn item_transparency_is_one_step_and_checks_the_blend_mode() {
let mut s = session();
let id = rect(&mut s, 100.0);
run(&mut s, "appearance.setActiveItem", json!({"index": 1}));
let steps = |s: &Session| s.doc().unwrap().history.undo.len();
let before = steps(&s);
run(&mut s, "transparency.set", json!({"knockout": true}));
assert_eq!(steps(&s), before + 1, "isolate/knockout alone do not touch the item");
assert_eq!(node(&s, id).knockout, vectorcraft_doc::Knockout::On);
assert!(s.execute("transparency.set", &json!({"blend": "nope"})).is_err());
assert_eq!(stroke(&node(&s, id), 1).blend, BlendMode::Normal);
}
#[test]
fn clear_and_reduce_keep_a_groups_stack_empty() {
let mut s = session();
let a = rect(&mut s, 100.0);
let b = rect(&mut s, 250.0);
let members = node(&s, a).appearance;
run(&mut s, "select.set", json!({"ids": [a.0, b.0]}));
let g = NodeId(run(&mut s, "object.group", json!({}))["id"].as_u64().unwrap());
run(&mut s, "appearance.reduceToBasic", json!({}));
assert!(node(&s, g).appearance.items.is_empty());
run(&mut s, "appearance.addStroke", json!({}));
run(&mut s, "appearance.reduceToBasic", json!({}));
assert_eq!(node(&s, g).appearance.items.iter().map(AppearanceItem::kind_name).collect::<Vec<_>>(), ["stroke"]);
run(&mut s, "transparency.set", json!({"opacity": 50}));
run(&mut s, "appearance.clear", json!({}));
let n = node(&s, g);
assert!(n.appearance.items.is_empty() && n.opacity == 1.0);
// The members keep their own appearance.
assert_eq!(node(&s, a).appearance, members);
}