Files
vectorcraft/crates/engine/src/tests_targeting.rs
T
arhammusheer 5beac4f122 #1244: smaller objects: Node 352 -> 152 bytes, NodeKind 200 -> 80, and a cheaper undo estimate
Rarely set fields move into one optional boxed NodeExtras, and large NodeKind payloads (images,
live shapes, blends, envelopes) are boxed. Every object, and every undo step that copies it,
shrinks: a 50k-path move keeps ~24.5 MB per step instead of ~40 MB. Files stay byte-identical
(Node keeps its flat JSON through a borrowed Serialize and a from-wire Deserialize, no flatten),
and loading is slightly faster. Undo and redo reuse their step's size estimate instead of
walking the document again (8.3 -> 5.6 ms for a 50k-path move).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmMmc1J8XeNLxhRihWv9cc

# Conflicts:
#	crates/engine/src/cmd/graph.rs
#	crates/svg/src/export.rs
2026-10-12 11:02:32 +07:00

118 lines
5.3 KiB
Rust

//! Targeting through the Layers panel's target circles (`layer.target`) and moving or copying
//! appearances between objects (`appearance.transfer`).
use serde_json::{Value, json};
use vectorcraft_color::{BlendMode, Paint};
use vectorcraft_doc::Node;
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 rect(s: &mut Session, x: f64) -> NodeId {
NodeId(run(s, "shape.rectangle", json!({"x": x, "y": 20, "width": 40, "height": 40}))["id"].as_u64().unwrap())
}
#[test]
fn a_targeted_layer_takes_transparency_appearance_and_effects() {
let mut s = session();
let a = rect(&mut s, 20.0);
let b = rect(&mut s, 100.0);
let layer = s.doc().unwrap().doc.layers[0].id;
let r = run(&mut s, "layer.target", json!({"id": layer.0}));
assert_eq!(r["selected"], json!([a.0, b.0]), "the layer's art is selected");
assert_eq!(s.doc().unwrap().selection.target, Some(layer));
run(&mut s, "transparency.set", json!({"opacity": 40, "blend": "multiply"}));
let l = node(&s, layer);
assert!((l.opacity - 0.4).abs() < 1e-6 && l.blend == BlendMode::Multiply);
assert_eq!(node(&s, a).opacity, 1.0, "the art keeps its own opacity");
assert_eq!(s.execute("transparency.info", &json!({})).unwrap()["ids"], json!([layer.0]));
run(&mut s, "appearance.addStroke", json!({}));
run(&mut s, "effect.apply", json!({"effect": "stylize.dropShadow"}));
let l = node(&s, layer);
assert_eq!(l.appearance.items.len(), 1);
assert_eq!(l.appearance.effects.len(), 1);
// The Appearance panel's active row is a row of the layer's own stack.
run(&mut s, "appearance.setActiveItem", json!({"index": 0}));
assert_eq!(s.appearance_item(), Some(0));
// Undo is one step per command, and any other selection change ends the targeting.
run(&mut s, "edit.undo", json!({}));
assert!(node(&s, layer).appearance.effects.is_empty());
run(&mut s, "select.set", json!({"ids": [a.0]}));
assert_eq!(s.doc().unwrap().selection.target, None);
assert_eq!(s.appearance_item(), None);
run(&mut s, "transparency.set", json!({"opacity": 50}));
assert!((node(&s, a).opacity - 0.5).abs() < 1e-6);
assert!((node(&s, layer).opacity - 0.4).abs() < 1e-6);
}
#[test]
fn targeting_an_object_selects_it_and_locked_layers_cannot_be_targeted() {
let mut s = session();
let a = rect(&mut s, 20.0);
run(&mut s, "select.none", json!({}));
run(&mut s, "layer.target", json!({"id": a.0}));
assert_eq!(s.doc().unwrap().selection.objects, vec![a]);
let layer = s.doc().unwrap().doc.layers[0].id;
run(&mut s, "layer.setProps", json!({"id": layer.0, "locked": true}));
assert!(s.execute("layer.target", &json!({"id": layer.0})).is_err());
assert!(s.execute("layer.target", &json!({"id": 9999})).is_err());
}
#[test]
fn a_targeted_layer_gets_an_opacity_mask() {
let mut s = session();
rect(&mut s, 20.0);
let layer = s.doc().unwrap().doc.layers[0].id;
run(&mut s, "layer.target", json!({"id": layer.0}));
assert_eq!(run(&mut s, "transparency.makeOpacityMask", json!({}))["id"], json!(layer.0));
run(&mut s, "transparency.stopEditingOpacityMask", json!({}));
assert!(node(&s, layer).extras().mask.is_some());
assert_eq!(s.doc().unwrap().selection.target, Some(layer), "the masked layer stays targeted");
}
#[test]
fn transfer_moves_or_copies_the_appearance() {
let mut s = session();
let a = rect(&mut s, 20.0);
let b = rect(&mut s, 100.0);
run(&mut s, "select.set", json!({"ids": [a.0]}));
run(&mut s, "paint.setFill", json!({"color": "#ff0000"}));
run(&mut s, "appearance.addFill", json!({}));
run(&mut s, "effect.apply", json!({"effect": "distort.twist"}));
run(&mut s, "transparency.set", json!({"opacity": 30, "blend": "screen"}));
let src = node(&s, a);
// Copy (Alt-drag): both have it.
run(&mut s, "appearance.transfer", json!({"source": a.0, "target": b.0, "copy": true}));
let (na, nb) = (node(&s, a), node(&s, b));
assert_eq!(na.appearance, src.appearance);
assert_eq!(nb.appearance, src.appearance);
assert!((nb.opacity - 0.3).abs() < 1e-6 && nb.blend == BlendMode::Screen);
// Move onto the layer: the source is left cleared; one undo step brings it back.
let layer = s.doc().unwrap().doc.layers[0].id;
run(&mut s, "appearance.transfer", json!({"source": a.0, "target": layer.0}));
let (na, nl) = (node(&s, a), node(&s, layer));
assert_eq!(nl.appearance.items.len(), src.appearance.items.len());
assert_eq!(nl.appearance.effects.len(), 1);
assert!((nl.opacity - 0.3).abs() < 1e-6);
assert!(na.appearance.effects.is_empty() && na.opacity == 1.0 && na.blend == BlendMode::Normal);
assert_eq!(na.appearance.fill_paint(), Paint::None);
run(&mut s, "edit.undo", json!({}));
assert_eq!(node(&s, a).appearance, src.appearance);
assert!(node(&s, layer).appearance.items.is_empty());
assert!(s.execute("appearance.transfer", &json!({"source": a.0, "target": a.0})).is_err());
assert!(s.execute("appearance.transfer", &json!({"source": a.0})).is_err());
}