152 lines
7.4 KiB
Rust
152 lines
7.4 KiB
Rust
//! Group, layer and type appearance through commands: their own fills, strokes and effects, the
|
||
//! Contents (Characters) slot, `target: "contents"`, Target Contents and effects on layers.
|
||
|
||
use serde_json::{Value, json};
|
||
use vectorcraft_color::{Color, Paint};
|
||
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()
|
||
}
|
||
|
||
fn id_of(v: &Value) -> NodeId {
|
||
NodeId(v["id"].as_u64().unwrap())
|
||
}
|
||
|
||
/// A selected group of two 40×40 squares at (20, 20) and (100, 20) → (group, [members]).
|
||
fn group(s: &mut Session) -> (NodeId, [NodeId; 2]) {
|
||
let a = id_of(&run(s, "shape.rectangle", json!({"x": 20, "y": 20, "width": 40, "height": 40})));
|
||
let b = id_of(&run(s, "shape.rectangle", json!({"x": 100, "y": 20, "width": 40, "height": 40})));
|
||
run(s, "select.set", json!({"ids": [a.0, b.0]}));
|
||
run(s, "paint.setFill", json!({"color": "#ff0000"}));
|
||
(id_of(&run(s, "object.group", json!({}))), [a, b])
|
||
}
|
||
|
||
fn pixel(s: &Session, x: u32, y: u32) -> [u8; 4] {
|
||
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).pixel(x, y)
|
||
}
|
||
|
||
const GREEN: [u8; 4] = [0, 255, 0, 255];
|
||
const RED: [u8; 4] = [255, 0, 0, 255];
|
||
|
||
#[test]
|
||
fn a_group_fill_paints_both_members_and_the_contents_slot_moves() {
|
||
let mut s = session();
|
||
let (g, [a, _]) = group(&mut s);
|
||
run(&mut s, "appearance.addFill", json!({}));
|
||
run(&mut s, "paint.setFill", json!({"color": "#00ff00", "item": 0}));
|
||
let n = node(&s, g);
|
||
assert_eq!((n.appearance.items.len(), n.appearance.contents_at()), (1, 0));
|
||
// The members keep their own fill; the group's paints over both of them.
|
||
assert_eq!(node(&s, a).appearance.fill_paint(), Paint::solid(Color::from_hex("#ff0000").unwrap()));
|
||
assert_eq!((pixel(&s, 40, 40), pixel(&s, 120, 40), pixel(&s, 80, 40)), (GREEN, GREEN, [255; 4]));
|
||
// Contents above the fill: the members show again; one undo step brings it back.
|
||
assert_eq!(run(&mut s, "appearance.moveItem", json!({"from": "contents", "to": 1}))["contents"], 1);
|
||
assert_eq!((node(&s, g).appearance.contents_index, pixel(&s, 40, 40)), (Some(1), RED));
|
||
run(&mut s, "edit.undo", json!({}));
|
||
assert_eq!(pixel(&s, 40, 40), GREEN);
|
||
// A stroke dragged under the contents in one step with `contents`.
|
||
run(&mut s, "appearance.addStroke", json!({}));
|
||
run(&mut s, "appearance.moveItem", json!({"from": 1, "to": 0, "contents": 1}));
|
||
let ap = node(&s, g).appearance;
|
||
assert!(!ap.items[0].is_fill() && ap.contents_at() == 1, "{ap:?}");
|
||
// Paths have no Contents row.
|
||
run(&mut s, "select.set", json!({"ids": [a.0]}));
|
||
assert!(s.execute("appearance.moveItem", &json!({"from": "contents", "to": 1})).is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn appearance_commands_target_the_object_or_its_contents() {
|
||
let mut s = session();
|
||
let (g, [a, b]) = group(&mut s);
|
||
let before = node(&s, a).appearance.items.len();
|
||
run(&mut s, "appearance.addStroke", json!({"target": "contents"}));
|
||
assert_eq!((node(&s, a).appearance.items.len(), node(&s, b).appearance.items.len()), (before + 1, before + 1));
|
||
assert!(node(&s, g).appearance.items.is_empty());
|
||
run(&mut s, "effect.apply", json!({"effect": "stylize.dropShadow", "target": "contents"}));
|
||
assert_eq!((node(&s, a).appearance.effects.len(), node(&s, g).appearance.effects.len()), (1, 0));
|
||
run(&mut s, "effect.apply", json!({"effect": "stylize.dropShadow"}));
|
||
assert_eq!(node(&s, g).appearance.effects.len(), 1);
|
||
assert!(s.execute("appearance.addFill", &json!({"target": "inside"})).is_err());
|
||
// Target Contents selects the members (what double-clicking the Contents row does).
|
||
assert_eq!(run(&mut s, "appearance.targetContents", json!({}))["ids"], json!([a.0, b.0]));
|
||
assert_eq!(s.doc().unwrap().selection.objects, vec![a, b]);
|
||
assert!(s.execute("appearance.targetContents", &json!({})).is_err(), "paths have no members");
|
||
}
|
||
|
||
#[test]
|
||
fn effects_and_fills_apply_to_layers_by_id() {
|
||
let mut s = session();
|
||
let (g, _) = group(&mut s);
|
||
let layer = s.doc().unwrap().doc.layers[0].id;
|
||
run(&mut s, "select.none", json!({}));
|
||
let r = run(&mut s, "effect.apply", json!({"effect": "stylize.dropShadow", "ids": [layer.0]}));
|
||
assert_eq!(r["ids"], json!([layer.0]));
|
||
run(&mut s, "appearance.addFill", json!({"ids": [layer.0]}));
|
||
let l = node(&s, layer);
|
||
assert_eq!((l.appearance.effects.len(), l.appearance.items.len()), (1, 1));
|
||
assert!(node(&s, g).appearance.effects.is_empty());
|
||
// Two undo steps take both off again.
|
||
run(&mut s, "edit.undo", json!({}));
|
||
run(&mut s, "edit.undo", json!({}));
|
||
assert_eq!(node(&s, layer).appearance, Default::default());
|
||
// Nothing selected and no ids: an error, not a silent no-op.
|
||
assert!(s.execute("appearance.addFill", &json!({})).is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn type_fills_go_above_or_below_the_characters() {
|
||
let mut s = session();
|
||
let t = id_of(&run(&mut s, "text.create", json!({"x": 20, "y": 100, "text": "Hi"})));
|
||
run(&mut s, "select.set", json!({"ids": [t.0]}));
|
||
// A new fill on type copies its characters' fill (it has none of its own).
|
||
run(&mut s, "appearance.addFill", json!({}));
|
||
let n = node(&s, t);
|
||
let NodeKind::Text(tx) = &n.kind else { panic!("type") };
|
||
assert_eq!(n.appearance.fill_paint(), tx.runs[0].style.fill);
|
||
run(&mut s, "appearance.moveItem", json!({"from": "contents", "to": 1}));
|
||
assert_eq!(node(&s, t).appearance.contents_at(), 1);
|
||
}
|
||
|
||
#[test]
|
||
fn group_appearance_round_trips_and_expands() {
|
||
let mut s = session();
|
||
let (g, _) = group(&mut s);
|
||
run(&mut s, "appearance.addFill", json!({}));
|
||
run(&mut s, "appearance.addStroke", json!({}));
|
||
run(&mut s, "appearance.moveItem", json!({"from": "contents", "to": 1}));
|
||
run(&mut s, "effect.apply", json!({"effect": "distort.transform", "params": {"moveV": 50}}));
|
||
let doc = s.doc().unwrap().doc.clone();
|
||
let back = vectorcraft_format::load(&vectorcraft_format::save(&doc, false)).unwrap();
|
||
assert_eq!(back.node(g).unwrap().appearance, doc.node(g).unwrap().appearance);
|
||
assert_eq!(back.node(g).unwrap().appearance.contents_index, Some(1));
|
||
// Expand Appearance: the fill and stroke become paths below and above the moved members.
|
||
run(&mut s, "effect.expandAppearance", json!({}));
|
||
let n = node(&s, g);
|
||
assert!(n.appearance.items.is_empty() && n.appearance.effects.is_empty());
|
||
let ch = n.children().unwrap();
|
||
assert_eq!(ch.len(), 4, "fill, two members, stroke");
|
||
// Each is a group of one path per member (sharing the item's opacity and blend mode); the
|
||
// stroke's paths are its outlines, filled.
|
||
let first = |n: &Node| n.children().unwrap()[0].appearance.items[0].is_fill();
|
||
assert!(first(&ch[0]) && first(&ch[3]) && ch[0].children().unwrap().len() == 2);
|
||
let outline = ch[3].children().unwrap()[0].geometric_bounds().unwrap();
|
||
assert!((outline.width() - 41.0).abs() < 0.1, "{outline:?}");
|
||
// Moved down 50 pt; the outlined 1 pt stroke reaches half a point beyond.
|
||
assert!((n.geometric_bounds().unwrap().y0 - 69.5).abs() < 1e-6);
|
||
}
|