mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-14 19:26:56 -04:00
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 <[email protected]> Claude-Session: https://claude.ai/code/session_01VmMmc1J8XeNLxhRihWv9cc # Conflicts: # crates/engine/src/cmd/graph.rs # crates/svg/src/export.rs
207 lines
9.9 KiB
Rust
207 lines
9.9 KiB
Rust
//! A blend's spine and keys on the canvas (M8.19): spine points moved, added and deleted (the
|
|
//! straight spine becomes a path on the first edit), keys picked and edited live, Release keeping
|
|
//! the spine as a path.
|
|
|
|
use serde_json::{Value, json};
|
|
use vectorcraft_color::Paint;
|
|
use vectorcraft_doc::live::BlendSpec;
|
|
use vectorcraft_doc::{Node, NodeKind};
|
|
use vectorcraft_geom::{Point, Rect};
|
|
|
|
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 node(s: &Session, id: NodeId) -> Node {
|
|
s.doc().unwrap().doc.node(id).unwrap().clone()
|
|
}
|
|
|
|
fn spec(s: &Session, id: NodeId) -> BlendSpec {
|
|
match node(s, id).kind {
|
|
NodeKind::Blend { spec, .. } => *spec,
|
|
_ => panic!("not a blend"),
|
|
}
|
|
}
|
|
|
|
fn center(n: &Node) -> Point {
|
|
n.geometric_bounds().unwrap().center()
|
|
}
|
|
|
|
/// Two 10 pt squares centred on (5, 5) and (105, 5), blended with one step.
|
|
fn blend(s: &mut Session) -> (NodeId, NodeId, NodeId) {
|
|
let a = id_of(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap());
|
|
let b = id_of(&s.execute("shape.rectangle", &json!({"x": 100, "y": 0, "width": 10, "height": 10})).unwrap());
|
|
let g = id_of(&s.execute("object.blend.make", &json!({"ids": [a.0, b.0], "steps": 1})).unwrap());
|
|
(a, b, g)
|
|
}
|
|
|
|
fn steps(s: &Session, g: NodeId) -> Vec<Node> {
|
|
vectorcraft_doc::live::expand_live(&node(s, g))
|
|
}
|
|
|
|
#[test]
|
|
fn a_spine_point_added_and_moved_bends_the_blend() {
|
|
let mut s = session();
|
|
let (_, _, g) = blend(&mut s);
|
|
assert!(spec(&s, g).spine.is_none(), "straight until edited");
|
|
let r = s.execute("object.blend.spine.addAnchor", &json!({"x": 55, "y": 8})).unwrap();
|
|
assert_eq!(r["anchor"], json!(1));
|
|
let sp = spec(&s, g);
|
|
assert_eq!(sp.key_anchors, vec![0, 2], "the keys keep their points");
|
|
assert!(sp.spine.unwrap().subpaths[0].anchors[1].p.distance(Point::new(55.0, 5.0)) < 1e-9, "on the spine, nearest the click");
|
|
s.execute("object.blend.spine.moveAnchor", &json!({"anchor": 1, "x": 55, "y": 105})).unwrap();
|
|
let st = steps(&s, g);
|
|
assert!(center(&st[1]).distance(Point::new(55.0, 105.0)) < 1e-6, "the step follows the spine: {:?}", center(&st[1]));
|
|
assert_eq!(center(&st[0]), Point::new(5.0, 5.0), "the keys stay put");
|
|
// One undo step each.
|
|
s.execute("edit.undo", &json!({})).unwrap();
|
|
assert!(center(&steps(&s, g)[1]).distance(Point::new(55.0, 5.0)) < 1e-6);
|
|
}
|
|
|
|
#[test]
|
|
fn moving_a_key_point_moves_the_key_and_the_other_way_round() {
|
|
let mut s = session();
|
|
let (a, b, g) = blend(&mut s);
|
|
s.execute("select.set", &json!({"ids": [g.0]})).unwrap();
|
|
s.execute("object.blend.spine.moveAnchor", &json!({"anchor": 0, "x": 5, "y": 105})).unwrap();
|
|
assert_eq!(center(&node(&s, a)), Point::new(5.0, 105.0));
|
|
// A handle drag curves the spine without moving the key.
|
|
s.execute("object.blend.spine.moveAnchor", &json!({"anchor": 0, "handle": "out", "x": 5, "y": 300})).unwrap();
|
|
assert_eq!(center(&node(&s, a)), Point::new(5.0, 105.0));
|
|
assert!(center(&steps(&s, g)[1]).y > 100.0, "the step bows out");
|
|
// Editing a key on the canvas (it is selectable inside the blend) drags its point along.
|
|
s.execute("select.set", &json!({"ids": [b.0]})).unwrap();
|
|
s.execute("object.move", &json!({"dx": 0, "dy": 50})).unwrap();
|
|
let n = node(&s, g);
|
|
let NodeKind::Blend { children, spec } = &n.kind else { panic!() };
|
|
let (path, _) = vectorcraft_doc::live::blend_spine(children, spec).unwrap();
|
|
assert_eq!(path.subpaths[0].anchors[1].p, Point::new(105.0, 55.0));
|
|
assert!(s.execute("object.blend.spine.moveAnchor", &json!({"id": g.0, "anchor": 9, "x": 0, "y": 0})).is_err());
|
|
assert!(s.execute("object.blend.spine.moveAnchor", &json!({"id": g.0, "anchor": 0, "x": f64::MAX, "y": 0})).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn only_points_without_a_key_can_be_deleted() {
|
|
let mut s = session();
|
|
let (_, _, g) = blend(&mut s);
|
|
assert!(s.execute("object.blend.spine.removeAnchor", &json!({"id": g.0, "anchor": 0})).is_err());
|
|
s.execute("object.blend.spine.addAnchor", &json!({"id": g.0, "x": 30, "y": 5})).unwrap();
|
|
s.execute("object.blend.spine.removeAnchor", &json!({"id": g.0, "anchor": 1})).unwrap();
|
|
let sp = spec(&s, g);
|
|
assert_eq!((sp.spine.unwrap().subpaths[0].anchors.len(), sp.key_anchors), (2, vec![0, 1]));
|
|
}
|
|
|
|
#[test]
|
|
fn info_lists_the_spine() {
|
|
let mut s = session();
|
|
let (_, _, g) = blend(&mut s);
|
|
let info = s.execute("object.blend.info", &json!({})).unwrap();
|
|
assert_eq!(info["spine"]["explicit"], json!(false));
|
|
assert_eq!(info["spine"]["keyAnchors"], json!([0, 1]));
|
|
assert_eq!(info["spine"]["anchors"][1]["x"], json!(105.0));
|
|
let _ = g;
|
|
}
|
|
|
|
#[test]
|
|
fn release_keeps_the_spine_as_a_path() {
|
|
let mut s = session();
|
|
let (a, b, g) = blend(&mut s);
|
|
s.execute("object.blend.spine.addAnchor", &json!({"id": g.0, "x": 55, "y": 5})).unwrap();
|
|
s.execute("object.blend.spine.moveAnchor", &json!({"id": g.0, "anchor": 1, "x": 55, "y": 60})).unwrap();
|
|
let r = s.execute("object.blend.release", &json!({})).unwrap();
|
|
assert_eq!(r["ids"], json!([a.0, b.0]));
|
|
let spine = NodeId(r["spines"][0].as_u64().unwrap());
|
|
let n = node(&s, spine);
|
|
assert!(n.appearance.fill_paint().is_none() && n.appearance.stroke_paint() == Paint::None, "paints nothing");
|
|
let pts: Vec<Point> = n.path_data().unwrap().anchors().map(|(_, _, a)| a.p).collect();
|
|
assert_eq!(pts, vec![Point::new(5.0, 5.0), Point::new(55.0, 60.0), Point::new(105.0, 5.0)]);
|
|
let d = &s.doc().unwrap().doc;
|
|
let layer = d.layers[0].children().unwrap().iter().map(|c| c.id).collect::<Vec<_>>();
|
|
assert_eq!(layer, vec![spine, a, b], "the spine below the keys");
|
|
}
|
|
|
|
#[test]
|
|
fn reverse_spine_and_front_to_back_keep_an_edited_spine() {
|
|
let mut s = session();
|
|
let (a, b, g) = blend(&mut s);
|
|
s.execute("object.blend.spine.addAnchor", &json!({"id": g.0, "x": 55, "y": 5})).unwrap();
|
|
s.execute("object.blend.spine.moveAnchor", &json!({"id": g.0, "anchor": 1, "x": 55, "y": 60})).unwrap();
|
|
s.execute("select.set", &json!({"ids": [g.0]})).unwrap();
|
|
s.execute("object.blend.reverseSpine", &json!({})).unwrap();
|
|
assert_eq!((center(&node(&s, a)), center(&node(&s, b))), (Point::new(105.0, 5.0), Point::new(5.0, 5.0)), "the keys swap ends");
|
|
assert!(center(&steps(&s, g)[1]).distance(Point::new(55.0, 60.0)) < 1e-6, "the spine keeps its shape");
|
|
s.execute("object.blend.reverseFrontToBack", &json!({})).unwrap();
|
|
assert_eq!(center(&node(&s, a)), Point::new(105.0, 5.0), "stacking changes, places don't");
|
|
assert!(center(&steps(&s, g)[1]).distance(Point::new(55.0, 60.0)) < 1e-6);
|
|
}
|
|
|
|
#[test]
|
|
fn a_blend_with_an_edited_spine_takes_more_keys_along_it() {
|
|
let mut s = session();
|
|
let (a, b, g) = blend(&mut s);
|
|
s.execute("object.blend.spine.addAnchor", &json!({"id": g.0, "x": 55, "y": 5})).unwrap();
|
|
s.execute("object.blend.spine.moveAnchor", &json!({"id": g.0, "anchor": 1, "x": 55, "y": 60})).unwrap();
|
|
let c = id_of(&s.execute("shape.rectangle", &json!({"x": 200, "y": 0, "width": 10, "height": 10})).unwrap());
|
|
let g2 = id_of(&s.execute("object.blend.make", &json!({"ids": [g.0, c.0]})).unwrap());
|
|
let sp = spec(&s, g2);
|
|
assert_eq!(sp.key_anchors, vec![0, 2, 3]);
|
|
let pts: Vec<Point> = sp.spine.unwrap().subpaths[0].anchors.iter().map(|a| a.p).collect();
|
|
assert_eq!(pts[1], Point::new(55.0, 60.0), "the bend is kept");
|
|
assert_eq!(pts[3], Point::new(205.0, 5.0));
|
|
let _ = (a, b);
|
|
}
|
|
|
|
#[test]
|
|
fn direct_selection_picks_keys_and_edits_update_the_blend_live() {
|
|
let mut s = session();
|
|
let (a, _, g) = blend(&mut s);
|
|
s.execute("paint.setFill", &json!({"ids": [a.0], "color": "#000000"})).ok();
|
|
let opt = vectorcraft_doc::hit::HitOptions::default();
|
|
let h = vectorcraft_doc::hit::hit_test(&s.doc().unwrap().doc, Point::new(3.0, 3.0), opt).unwrap();
|
|
assert_eq!((h.leaf, h.top_object(None)), (a, g), "the key is hit; the Selection tool still takes the blend");
|
|
let h = vectorcraft_doc::hit::hit_test(&s.doc().unwrap().doc, Point::new(55.0, 5.0), opt).unwrap();
|
|
assert_eq!(h.leaf, g, "a step hits as the blend");
|
|
let mut r = vectorcraft_render::Renderer::new();
|
|
let before = r.render_region(&s.doc().unwrap().doc, Rect::new(0.0, 0.0, 120.0, 20.0), 1.0, true).pixel(55, 5);
|
|
s.execute("paint.setFill", &json!({"ids": [a.0], "color": "#ff0000"})).unwrap();
|
|
let after = r.render_region(&s.doc().unwrap().doc, Rect::new(0.0, 0.0, 120.0, 20.0), 1.0, true).pixel(55, 5);
|
|
assert_ne!(before, after, "the step redraws with the key's new colour");
|
|
}
|
|
|
|
#[test]
|
|
fn key_anchors_round_trip() {
|
|
let mut s = session();
|
|
let (_, _, g) = blend(&mut s);
|
|
s.execute("object.blend.spine.addAnchor", &json!({"id": g.0, "x": 55, "y": 5})).unwrap();
|
|
let d = &s.doc().unwrap().doc;
|
|
let back = vectorcraft_format::load(&vectorcraft_format::save(d, false)).unwrap();
|
|
assert_eq!(&back.layers, &d.layers);
|
|
let old: BlendSpec = serde_json::from_value(json!({"spine": {"subpaths": []}})).unwrap();
|
|
assert!(old.key_anchors.is_empty());
|
|
}
|
|
|
|
/// #1111: a straight spine's segment bends into a curve, its middle moving where it's dragged, and
|
|
/// the steps follow it; junk segments and deltas are refused.
|
|
#[test]
|
|
fn a_straight_spine_segment_bends_into_a_curve() {
|
|
let mut s = session();
|
|
let (_, _, g) = blend(&mut s);
|
|
s.execute("object.blend.spine.reshapeSegment", &json!({"segment": 0, "t": 0.5, "dx": 0, "dy": 40})).unwrap();
|
|
let spine = spec(&s, g).spine.unwrap();
|
|
let seg = spine.subpaths[0].segment(0);
|
|
use vectorcraft_geom::kurbo::ParamCurve;
|
|
assert!(seg.eval(0.5).distance(Point::new(55.0, 45.0)) < 1e-9, "{seg:?}");
|
|
assert!(center(&steps(&s, g)[1]).y > 20.0, "the step follows the curve");
|
|
for bad in [json!({"segment": 1, "dy": 10}), json!({"segment": 0, "dx": f64::MAX, "dy": f64::MAX}), json!({"segment": -1})] {
|
|
assert!(s.execute("object.blend.spine.reshapeSegment", &bad).is_err(), "{bad}");
|
|
}
|
|
}
|