Files
vectorcraft/crates/engine/src/tests/blendspine.rs
T
dotCooCoo 40318753db Blends: Convert on a replaced spine changes only its handles
Convert to Smooth or Corner on a blend whose spine came from Replace
Spine stored key anchors with the converted spine, so the spine's
points then moved onto the key centers. It now changes only the
spine's handles: the points stay where they are and the keys stay
spread along the spine. Direct Selection's spine conversion and the
other spine edits are unchanged.
2026-11-04 15:13:18 +07:00

405 lines
21 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 crate::*;
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);
}
/// #2544: an Anchor Point drag on a blend spine must create two symmetric
/// handles, not bend the adjacent segment and leave one handle behind.
#[test]
fn spine_anchor_conversion_preserves_keys_and_undo() {
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();
let keys_before = spec(&s, g).key_anchors;
let point_before = spec(&s, g).spine.unwrap().subpaths[0].anchors[1].p;
assert!(point_before.distance(Point::new(55.0, 5.0)) < 1e-9);
s.execute("object.blend.spine.convertAnchor", &json!({"id": g.0, "anchor": 1, "to": "smooth", "x": 55, "y": 35})).unwrap();
let sp = spec(&s, g);
assert_eq!(sp.key_anchors, keys_before);
let middle = sp.spine.as_ref().unwrap().subpaths[0].anchors[1];
assert_eq!(middle.kind, vectorcraft_geom::AnchorKind::Smooth);
assert_eq!(middle.p, point_before);
assert_eq!(middle.h_in, point_before - (Point::new(55.0, 35.0) - point_before));
assert_eq!(middle.h_out, Point::new(55.0, 35.0));
assert_eq!(center(&node(&s, a)), Point::new(5.0, 5.0));
assert_eq!(center(&node(&s, b)), Point::new(105.0, 5.0));
s.execute("edit.undo", &json!({})).unwrap();
let before = spec(&s, g);
assert_eq!(before.spine.unwrap().subpaths[0].anchors[1].h_out, point_before);
s.execute("edit.redo", &json!({})).unwrap();
assert_eq!(spec(&s, g).spine.unwrap().subpaths[0].anchors[1], middle);
s.execute("object.blend.spine.convertAnchor", &json!({"id": g.0, "anchor": 1, "to": "corner"})).unwrap();
let corner = spec(&s, g).spine.unwrap().subpaths[0].anchors[1];
assert!(!corner.has_in() && !corner.has_out());
assert_eq!(corner.kind, vectorcraft_geom::AnchorKind::Corner);
}
#[test]
fn spine_conversion_rejects_invalid_arguments_without_mutation() {
let mut s = session();
let (_, _, g) = blend(&mut s);
let before = spec(&s, g);
for params in [
json!({"id": g.0, "anchor": 5, "to": "smooth", "x": 20, "y": 20}),
json!({"id": g.0, "anchor": 0, "to": "unknown"}),
json!({"id": g.0, "anchor": 0, "to": "smooth", "x": 10}),
json!({"id": g.0, "anchor": 0, "to": "smooth", "x": f64::MAX, "y": 1}),
] {
assert!(s.execute("object.blend.spine.convertAnchor", &params).is_err(), "{params}");
assert_eq!(spec(&s, g), before);
}
}
/// #4819: Convert Selected Anchor Points to Smooth or Corner (the Control bar's and the Contextual
/// Task Bar's buttons) converts the points of a selected blend's spine, as it converts every
/// anchor of a path selected whole, in one undo step, the keys staying put.
#[test]
fn converting_anchors_of_a_selected_blend_converts_its_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": 105})).unwrap();
s.execute("select.set", &json!({"ids": [g.0]})).unwrap();
let keys = spec(&s, g).key_anchors;
s.execute("path.convertAnchors", &json!({"to": "smooth"})).unwrap();
let sp = spec(&s, g);
assert_eq!(sp.key_anchors, keys);
let middle = sp.spine.as_ref().unwrap().subpaths[0].anchors[1];
assert_eq!(middle.p, Point::new(55.0, 105.0));
assert!(middle.has_in() && middle.has_out(), "{middle:?}");
assert!((middle.h_in.y - 105.0).abs() < 1e-9 && (middle.h_out.y - 105.0).abs() < 1e-9, "tangent to the chord between its neighbors: {middle:?}");
assert!(middle.h_in.x < 55.0 && middle.h_out.x > 55.0, "{middle:?}");
assert_eq!(center(&node(&s, a)), Point::new(5.0, 5.0));
assert_eq!(center(&node(&s, b)), Point::new(105.0, 5.0));
s.execute("path.convertAnchors", &json!({"to": "corner"})).unwrap();
let corner = spec(&s, g).spine.unwrap().subpaths[0].anchors.clone();
assert!(corner.iter().all(|a| !a.has_in() && !a.has_out()), "{corner:?}");
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(spec(&s, g).spine.unwrap().subpaths[0].anchors[1], middle, "one undo step");
}
/// Converting the points of a blend whose spine came from Replace Spine changes only the spine's
/// handles: its points stay where they are, the keys are not pinned to points, and the steps stay
/// spread along the spine instead of the spine's ends moving onto the keys.
#[test]
fn converting_anchors_of_a_replaced_spine_changes_only_its_handles() {
let mut s = session();
let (_, _, g) = blend(&mut s);
let spine = id_of(&s.execute("path.create", &json!({"d": "M100 300 L200 200 L300 300"})).unwrap());
s.execute("select.set", &json!({"ids": [g.0, spine.0]})).unwrap();
s.execute("object.blend.replaceSpine", &json!({})).unwrap();
s.execute("select.set", &json!({"ids": [g.0]})).unwrap();
let points = |s: &Session| spec(s, g).spine.unwrap().subpaths[0].anchors.iter().map(|a| a.p).collect::<Vec<_>>();
let before = points(&s);
let ends = |s: &Session| {
let st = steps(s, g);
(center(&st[0]), center(&st[st.len() - 1]))
};
let ends_before = ends(&s);
s.execute("path.convertAnchors", &json!({"to": "smooth"})).unwrap();
let sp = spec(&s, g);
assert!(sp.key_anchors.is_empty(), "{:?}", sp.key_anchors);
assert_eq!(points(&s), before, "the spine's points stay put");
let middle = sp.spine.as_ref().unwrap().subpaths[0].anchors[1];
assert!(middle.has_in() && middle.has_out(), "{middle:?}");
assert_eq!(ends(&s), ends_before, "the end steps stay at the spine's ends");
assert!(center(&steps(&s, g)[1]).distance(Point::new(200.0, 200.0)) < 1e-6, "the step stays on the spine");
s.execute("path.convertAnchors", &json!({"to": "corner"})).unwrap();
let sp = spec(&s, g);
assert!(sp.key_anchors.is_empty(), "{:?}", sp.key_anchors);
assert!(sp.spine.unwrap().subpaths[0].anchors.iter().all(|a| !a.has_in() && !a.has_out()));
assert_eq!(points(&s), before);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(spec(&s, g).spine.unwrap().subpaths[0].anchors[1], middle, "one undo step");
}
#[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());
}
/// Moving a spine point refuses an `independent`, and reshaping a spine segment a `perpendicular`,
/// given as text or as another value that is not true or false, and the blend stays as it was; null
/// is read as the key left out.
#[test]
fn spine_edits_refuse_a_boolean_given_as_text() {
let mut s = session();
let (_, _, g) = blend(&mut s);
s.execute("object.blend.spine.addAnchor", &json!({"id": g.0, "x": 55, "y": 5})).unwrap();
s.execute("object.blend.spine.convertAnchor", &json!({"id": g.0, "anchor": 1, "to": "smooth", "x": 75, "y": 5})).unwrap();
let before = node(&s, g);
for (cmd, key, p) in [
("object.blend.spine.moveAnchor", "independent", json!({"id": g.0, "anchor": 1, "handle": "out", "x": 75, "y": 40, "independent": "true"})),
("object.blend.spine.moveAnchor", "independent", json!({"id": g.0, "anchor": 1, "x": 55, "y": 60, "independent": 1})),
("object.blend.spine.reshapeSegment", "perpendicular", json!({"id": g.0, "segment": 0, "dx": 10, "dy": 40, "perpendicular": "true"})),
("object.blend.spine.reshapeSegment", "perpendicular", json!({"id": g.0, "segment": 0, "dy": 40, "perpendicular": [false]})),
] {
let r = s.execute(cmd, &p);
assert!(matches!(&r, Err(EngineError::BadParams { msg, .. }) if *msg == format!("`{key}` must be true or false")), "{cmd} {p}: {r:?}");
assert_eq!(node(&s, g), before, "{cmd} {p}");
}
s.execute("object.blend.spine.moveAnchor", &json!({"id": g.0, "anchor": 1, "handle": "out", "x": 75, "y": 40, "independent": null})).unwrap();
let a = spec(&s, g).spine.unwrap().subpaths[0].anchors[1];
assert_eq!((a.h_out, a.kind), (Point::new(75.0, 40.0), vectorcraft_geom::AnchorKind::Smooth), "the point stays smooth");
s.execute("object.blend.spine.reshapeSegment", &json!({"id": g.0, "segment": 0, "dy": 40, "perpendicular": null})).unwrap();
}
#[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]));
}
/// An `anchor` or `segment` that is not a whole number is an error that leaves the blend as it
/// was; a whole number written as 1.0 still names its point.
#[test]
fn spine_commands_refuse_an_index_that_is_not_a_whole_number() {
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 before = node(&s, g);
for (cmd, key, p) in [
("object.blend.spine.moveAnchor", "anchor", json!({"id": g.0, "anchor": 1.5, "x": 55, "y": 60})),
("object.blend.spine.moveAnchor", "anchor", json!({"id": g.0, "anchor": 0.5, "handle": "out", "x": 5, "y": 300})),
("object.blend.spine.reshapeSegment", "segment", json!({"id": g.0, "segment": 0.5, "dy": 40})),
("object.blend.spine.removeAnchor", "anchor", json!({"id": g.0, "anchor": 1.5})),
] {
let r = s.execute(cmd, &p);
assert!(matches!(&r, Err(EngineError::BadParams { msg, .. }) if *msg == format!("`{key}` must be a whole number")), "{cmd} {p}: {r:?}");
assert_eq!(node(&s, g), before, "{cmd} {p}");
}
s.execute("object.blend.spine.moveAnchor", &json!({"id": g.0, "anchor": 1.0, "x": 55, "y": 60})).unwrap();
assert_eq!(spec(&s, g).spine.unwrap().subpaths[0].anchors[1].p, Point::new(55.0, 60.0));
}
/// Reshape Spine Segment refuses a `t`, `dx` or `dy` given as text or as another value that is not
/// a number, instead of bending the spine by 0 or at the segment's middle, and the blend stays as
/// it was; null is read as the key left out.
#[test]
fn spine_reshape_segment_refuses_a_number_given_as_text() {
let mut s = session();
let (_, _, g) = blend(&mut s);
let before = node(&s, g);
for key in ["t", "dx", "dy"] {
for v in [json!("10"), json!([10]), json!(true)] {
let mut p = json!({"id": g.0, "segment": 0, "t": 0.5, "dx": 0, "dy": 40});
p[key] = v.clone();
let r = s.execute("object.blend.spine.reshapeSegment", &p);
assert!(matches!(&r, Err(EngineError::BadParams { msg, .. }) if *msg == format!("`{key}` must be a number, not {v}")), "{p}: {r:?}");
assert_eq!(node(&s, g), before, "{p}");
}
}
s.execute("object.blend.spine.reshapeSegment", &json!({"id": g.0, "segment": 0, "t": null, "dx": null, "dy": 40})).unwrap();
assert!(center(&steps(&s, g)[1]).y > 20.0, "the step follows the bent spine");
}
#[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}");
}
}
/// With `perpendicular` (Shift) the spine segment's handles stand at right angles to it, at one
/// length: its middle goes straight down, wherever the drag goes sideways.
#[test]
fn a_spine_segment_bends_with_perpendicular_handles() {
let mut s = session();
let (_, _, g) = blend(&mut s);
s.execute("object.blend.spine.reshapeSegment", &json!({"segment": 0, "t": 0.5, "dx": 10, "dy": 40, "perpendicular": true})).unwrap();
let sp = spec(&s, g).spine.unwrap().subpaths[0].clone();
let (a, b) = (sp.anchors[0], sp.anchors[1]);
let (ha, hb) = (a.h_out - a.p, b.h_in - b.p);
assert!(ha.x.abs() < 1e-9 && hb.x.abs() < 1e-9 && (ha.y - hb.y).abs() < 1e-9, "{sp:?}");
use vectorcraft_geom::kurbo::ParamCurve;
assert!(sp.segment(0).eval(0.5).distance(Point::new(55.0, 45.0)) < 1e-9, "{sp:?}");
}