Files
vectorcraft/crates/engine/src/tests_blendopts.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

156 lines
7.3 KiB
Rust

//! Blend Options (defaults with nothing selected, `object.blend.info`) and the Blend tool:
//! start points from clicked anchors, adding keys to a blend instead of nesting it (M8.18).
use serde_json::{Value, json};
use vectorcraft_doc::live::{BlendOrientation, BlendSpacing, BlendSpec};
use vectorcraft_doc::{Node, NodeKind};
use vectorcraft_tools::{Mods, PointerEvent, PointerKind};
use super::*;
fn session() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap();
s
}
fn id_of(v: &Value) -> NodeId {
NodeId(v["id"].as_u64().unwrap())
}
fn rect(s: &mut Session, x: f64, y: f64, w: f64) -> NodeId {
id_of(&s.execute("shape.rectangle", &json!({"x": x, "y": y, "width": w, "height": w})).unwrap())
}
fn node(s: &Session, id: NodeId) -> Node {
s.doc().unwrap().doc.node(id).unwrap().clone()
}
fn spec(s: &Session, id: NodeId) -> (Vec<NodeId>, BlendSpec) {
match node(s, id).kind {
NodeKind::Blend { children, spec } => (children.iter().map(|c| c.id).collect(), *spec),
_ => panic!("not a blend"),
}
}
fn sel(s: &mut Session, ids: &[NodeId]) {
s.execute("select.set", &json!({"ids": ids.iter().map(|i| i.0).collect::<Vec<_>>()})).unwrap();
}
#[test]
fn options_with_nothing_selected_set_what_new_blends_start_with() {
let mut s = session();
let info = s.execute("object.blend.info", &json!({})).unwrap();
assert_eq!((info["target"].as_str(), info["spacing"].as_str()), (Some("defaults"), Some("smooth")));
let depth = s.doc().unwrap().history.undo.len();
let r = s.execute("object.blend.options", &json!({"spacing": "distance", "value": 20, "orientation": "path"})).unwrap();
assert_eq!(r["defaults"], json!(true));
assert_eq!(s.doc().unwrap().history.undo.len(), depth, "a tool setting, not an undo step");
let info = s.execute("object.blend.info", &json!({})).unwrap();
assert_eq!((info["spacing"].as_str(), info["distance"].as_f64(), info["orientation"].as_str()), (Some("distance"), Some(20.0), Some("path")));
// New blends start with them; explicit params still win.
let (a, b) = (rect(&mut s, 0.0, 0.0, 10.0), rect(&mut s, 100.0, 0.0, 10.0));
sel(&mut s, &[a, b]);
let g = id_of(&s.execute("object.blend.make", &json!({})).unwrap());
let (_, sp) = spec(&s, g);
assert_eq!((sp.spacing, sp.orientation), (BlendSpacing::Distance(20.0), BlendOrientation::AlignToPath));
s.execute("edit.undo", &json!({})).unwrap();
sel(&mut s, &[a, b]);
let g = id_of(&s.execute("object.blend.make", &json!({"steps": 2})).unwrap());
assert_eq!(spec(&s, g).1.spacing, BlendSpacing::Steps(2));
// The preference survives a round trip through the preferences file.
let back: Prefs = serde_json::from_value(s.prefs.to_json()).unwrap();
assert_eq!(back.blend_options, s.prefs.blend_options);
assert!(Prefs::default().to_json().get("blendOptions").is_none());
}
#[test]
fn info_reads_the_selected_blend() {
let mut s = session();
let (a, b) = (rect(&mut s, 0.0, 0.0, 10.0), rect(&mut s, 100.0, 0.0, 10.0));
sel(&mut s, &[a, b]);
let g = id_of(&s.execute("object.blend.make", &json!({"steps": 4})).unwrap());
let info = s.execute("object.blend.info", &json!({})).unwrap();
assert_eq!(info["target"], "blend");
assert_eq!(info["id"], json!(g.0));
assert_eq!((info["spacing"].as_str(), info["steps"].as_u64()), (Some("steps"), Some(4)));
assert_eq!(info["keys"], json!([a.0, b.0]));
}
#[test]
fn start_points_pair_the_clicked_anchors() {
let mut s = session();
let (a, b) = (rect(&mut s, 0.0, 0.0, 10.0), rect(&mut s, 100.0, 0.0, 10.0));
// a starts at its top left, b at its bottom right: corners meet their opposites.
let g = id_of(&s.execute("object.blend.make", &json!({"ids": [b.0, a.0], "starts": [2, 0], "steps": 1})).unwrap());
let (keys, sp) = spec(&s, g);
assert_eq!(keys, vec![a, b], "keys in paint order");
assert_eq!(sp.starts, vec![Some(0), Some(2)], "each start stays with its object");
let steps = vectorcraft_doc::live::expand_live(&node(&s, g));
let mid = steps[1].geometric_bounds().unwrap();
assert!(mid.height() < 1e-6, "the middle step collapses where opposite corners meet: {mid:?}");
assert!(s.execute("object.blend.make", &json!({"ids": [a.0, b.0], "starts": "x"})).is_err());
assert!(s.execute("object.blend.make", &json!({"ids": [a.0, b.0], "starts": [-1, 0]})).is_err());
// Reverse Front to Back keeps each key's start.
sel(&mut s, &[g]);
s.execute("object.blend.reverseFrontToBack", &json!({})).unwrap();
assert_eq!(spec(&s, g).1.starts, vec![Some(2), Some(0)]);
}
#[test]
fn a_blend_and_an_object_make_one_blend_with_more_keys() {
let mut s = session();
let (a, b, c) = (rect(&mut s, 0.0, 0.0, 10.0), rect(&mut s, 100.0, 0.0, 10.0), rect(&mut s, 200.0, 0.0, 10.0));
sel(&mut s, &[a, b]);
let g = id_of(&s.execute("object.blend.make", &json!({"steps": 3})).unwrap());
s.edit("name", |d, _| {
d.node_mut(g).unwrap().name = Some("Ribbon".into());
Ok(())
})
.unwrap();
let g2 = id_of(&s.execute("object.blend.make", &json!({"ids": [g.0, c.0]})).unwrap());
let n = node(&s, g2);
let (keys, sp) = spec(&s, g2);
assert_eq!(keys, vec![a, b, c], "no blend inside the blend");
assert_eq!(sp.spacing, BlendSpacing::Steps(3), "the blend's options are kept");
assert_eq!(n.name.as_deref(), Some("Ribbon"));
assert!(s.doc().unwrap().doc.node(g).is_none());
assert_eq!(vectorcraft_doc::live::expand_live(&n).len(), 3 + 2 * 3);
}
#[test]
fn the_blend_tool_blends_from_clicked_anchors_and_keeps_adding() {
let mut s = session();
let (a, b, c) = (rect(&mut s, 0.0, 0.0, 40.0), rect(&mut s, 200.0, 0.0, 40.0), rect(&mut s, 400.0, 0.0, 40.0));
s.select_tool("blend", ViewInfo::default()).unwrap();
let click = |s: &mut Session, x: f64, y: f64| s.pointer(&PointerEvent::new(PointerKind::Down, x, y), ViewInfo::default()).unwrap();
// a's top right anchor, then b's interior.
click(&mut s, 40.0, 0.0);
click(&mut s, 220.0, 20.0);
let g = s.doc().unwrap().selection.objects[0];
let (keys, sp) = spec(&s, g);
assert_eq!(keys, vec![a, b]);
assert_eq!(sp.starts, vec![Some(1), None]);
// The next object joins the same blend.
click(&mut s, 420.0, 20.0);
let g2 = s.doc().unwrap().selection.objects[0];
assert_eq!(spec(&s, g2).0, vec![a, b, c]);
// Alt-click asks for Blend Options.
let r = s.pointer(&PointerEvent::new(PointerKind::Down, 600.0, 500.0).with_mods(Mods { alt: true, ..Default::default() }), ViewInfo::default());
assert!(matches!(r.unwrap().as_slice(), [UiRequest::Dialog(k, _)] if k == "blendOptions"));
}
#[test]
fn start_points_round_trip_and_old_blends_load() {
let mut s = session();
let (a, b) = (rect(&mut s, 0.0, 0.0, 10.0), rect(&mut s, 100.0, 0.0, 10.0));
s.execute("object.blend.make", &json!({"ids": [a.0, b.0], "starts": [null, 3]})).unwrap();
let d = &s.doc().unwrap().doc;
let back = vectorcraft_format::load(&vectorcraft_format::save(d, false)).unwrap();
assert_eq!(&back.layers, &d.layers);
// A blend saved before start points has none.
let old: BlendSpec = serde_json::from_value(json!({"spacing": {"steps": 4}, "orientation": "alignToPage"})).unwrap();
assert!(old.starts.is_empty() && old.start(0).is_none());
assert!(serde_json::to_value(&old).unwrap().get("starts").is_none());
}