Files
vectorcraft/crates/engine/tests/it/dimensions.rs
T
dotCooCoo ed17e8fc17 Dimension tool: linear dimensions measure horizontally, vertically or aligned
A linear dimension between two clicked points, or dragged out from a
segment, always measured the straight distance between its points and
drew its line parallel to them, so two points at different heights gave
only a slanted dimension. The linear dimensions help describes aligned,
horizontal and vertical linear dimensions: after the second click,
clicking again gives an aligned one and moving the pointer gives a
horizontal or vertical one.
https://helpx.adobe.com/illustrator/desktop/measure-and-align/plot-and-measure/plot-linear-dimensions-of-objects.html

While a linear dimension is being placed, the pointer's position sets
whether it is aligned, horizontal or vertical. The rule is VectorCraft's
own. With the pointer beside the two points (the nearest point of the
line through them lies between them) or on that line, the dimension is
aligned. Past either point, the line through the points and the
perpendicular through that point bound a quarter on each side of the
line. The quarter that holds the direction straight up or down gives
the horizontal distance, and the quarter that holds the direction
straight left or right gives the vertical distance. A click within
reach of the second point, or of where the pointer was for the second
click, places it aligned. Points on one horizontal or vertical line
give an aligned dimension. The dimension stays linked to the anchors it
measures.

- Dimension recipes carry an orientation (aligned by default; files
  without it open as before). The line runs along the axis, and each
  extension line runs from its point to it.
- dimension.create and dimension.update take orientation: aligned,
  horizontal or vertical, for linear dimensions only. Any other value
  is an error. A new orientation keeps the line on the side of the
  points it was on, and an update that changes nothing records no
  undo step.
- A horizontal or vertical dimension keeps its orientation when it or
  the art it measures turns, and shows 0 when its points come into line
  across its axis. A line that lay outside the points stays outside
  them after a turn.
- The hint bar names the two-click placement.

Docs: docs/dimensions.md, the Evidence list in docs/target-app-parity.md.
2026-11-10 16:54:35 -08:00

1226 lines
61 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Linked measurements, history, portable artwork and hostile command input.
use serde_json::{Value, json};
use vectorcraft_engine::{
Session, ViewInfo,
doc::{Document, NodeId, NodeKind},
tools::{Mods, PointerEvent, PointerKind, ToolKey},
};
fn session() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width":600,"height":500})).unwrap();
s
}
fn run(s: &mut Session, id: &str, p: Value) -> Value {
s.execute(id, &p).unwrap()
}
fn edge(s: &mut Session) -> u64 {
run(s, "shape.line", json!({"x1":100,"y1":100,"x2":172,"y2":100}))["id"].as_u64().unwrap()
}
fn dim(s: &mut Session, node: u64) -> u64 {
run(
s,
"dimension.create",
json!({"source":{"kind":"segment","edge":{"node":node,"subpath":0,"segment":0}},"options":{"units":"in","precision":2,"scale":"1:10"}}),
)["id"]
.as_u64()
.unwrap()
}
fn text(s: &Session, id: u64) -> String {
let n = s.doc().unwrap().doc.node(NodeId(id)).unwrap();
n.children().unwrap().iter().find_map(|n| if let NodeKind::Text(t) = &n.kind { Some(t.plain_text()) } else { None }).unwrap()
}
fn recipe(s: &Session, id: u64) -> vectorcraft_doc::dimension::Dimension {
(**s.doc().unwrap().doc.node(NodeId(id)).unwrap().extras().dimension.as_ref().unwrap()).clone()
}
fn ev(s: &mut Session, kind: PointerKind, x: f64, y: f64) {
s.pointer(&PointerEvent::new(kind, x, y), ViewInfo::default()).unwrap();
}
#[test]
fn dimension_length_scale_live_edit_undo_and_roundtrip() {
let mut s = session();
let line = edge(&mut s);
let id = dim(&mut s, line);
assert_eq!(text(&s, id), "10.00 in");
run(&mut s, "select.set", json!({"ids":[line]}));
run(&mut s, "object.scale", json!({"sx":200,"sy":100}));
assert_eq!(text(&s, id), "20.00 in");
run(&mut s, "edit.undo", json!({}));
assert_eq!(text(&s, id), "10.00 in");
run(&mut s, "edit.redo", json!({}));
assert_eq!(text(&s, id), "20.00 in");
let json = serde_json::to_string(&*s.doc().unwrap().doc).unwrap();
let doc: Document = serde_json::from_str(&json).unwrap();
assert!(doc.dimensions, "saved with the document");
let mut reopened = Session::new();
reopened.add_document(doc, None);
run(&mut reopened, "select.set", json!({"ids":[line]}));
run(&mut reopened, "object.scale", json!({"sx":50,"sy":100}));
assert_eq!(text(&reopened, id), "10.00 in");
run(&mut reopened, "edit.clear", json!({}));
assert!(reopened.doc().unwrap().doc.node(NodeId(id)).is_none());
run(&mut reopened, "edit.undo", json!({}));
assert_eq!(text(&reopened, id), "10.00 in");
}
/// Documents without dimensions skip the refresh, and lose the flag when the last goes.
#[test]
fn documents_without_dimensions_skip_the_refresh() {
let mut s = session();
let line = edge(&mut s);
assert!(!s.doc().unwrap().doc.dimensions);
let id = dim(&mut s, line);
assert!(s.doc().unwrap().doc.dimensions);
run(&mut s, "select.set", json!({"ids":[id]}));
run(&mut s, "edit.clear", json!({}));
run(&mut s, "select.set", json!({"ids":[line]}));
run(&mut s, "object.move", json!({"dx":1,"dy":0}));
assert!(!s.doc().unwrap().doc.dimensions);
}
#[test]
fn apply_to_all_restyles_every_dimension_in_one_undo_step() {
let mut s = session();
let line = edge(&mut s);
let first = dim(&mut s, line);
let second = run(&mut s, "dimension.create", json!({"a":[0,0],"b":[72,0],"options":{"units":"pt","precision":0}}))["id"].as_u64().unwrap();
assert_eq!(text(&s, second), "72 pt");
run(&mut s, "dimension.applyToAll", json!({"id":first}));
assert_eq!(text(&s, second), "10.00 in", "the first dimension's style, its scale too");
run(&mut s, "edit.undo", json!({}));
assert_eq!(text(&s, second), "72 pt");
assert!(s.execute("dimension.applyToAll", &json!({"id":line})).is_err(), "not a dimension");
}
#[test]
fn dimension_angles_circles_arcs_and_curves() {
let mut s = session();
let a = run(
&mut s,
"dimension.create",
json!({"mode":"angular","a":[100,0],"vertex":[0,0],"b":[0,100],"options":{"precision":1,"scale":"1:10"}}),
)["id"]
.as_u64()
.unwrap();
assert_eq!(text(&s, a), "90.0°");
let circle = run(&mut s, "shape.ellipse", json!({"x":100,"y":100,"width":144,"height":144}))["id"].as_u64().unwrap();
for (mode, want) in [("radius", "1.0 in"), ("diameter", "2.0 in")] {
let id = run(
&mut s,
"dimension.create",
json!({"mode":mode,"source":{"kind":"circle","node":circle},"options":{"units":"in","precision":1}}),
)["id"]
.as_u64()
.unwrap();
assert_eq!(text(&s, id), want);
}
// One quarter of the circle is a perfect arc.
let id = run(
&mut s,
"dimension.create",
json!({"mode":"radius","source":{"kind":"arc","edge":{"node":circle,"subpath":0,"segment":0}},"options":{"units":"in","precision":1}}),
)["id"]
.as_u64()
.unwrap();
assert_eq!(text(&s, id), "1.0 in");
// A curve that isn't an arc: no radius, and its length is measured end to end.
let curve = run(&mut s, "path.create", json!({"d":"M 0 0 C 0 100 100 100 100 0"}))["id"].as_u64().unwrap();
let arc = json!({"mode":"radius","source":{"kind":"arc","edge":{"node":curve,"subpath":0,"segment":0}}});
assert!(s.execute("dimension.create", &arc).is_err());
let id = run(
&mut s,
"dimension.create",
json!({"source":{"kind":"segment","edge":{"node":curve,"subpath":0,"segment":0}},"options":{"units":"pt","precision":0}}),
)["id"]
.as_u64()
.unwrap();
assert_eq!(text(&s, id), "100 pt");
}
#[test]
fn dimension_rejects_bad_input_without_mutation() {
let mut s = session();
let line = edge(&mut s);
let id = dim(&mut s, line);
let original = serde_json::to_value(&*s.doc().unwrap().doc).unwrap();
for p in [
json!({}),
json!({"a":[0,0],"b":[0,0]}),
json!({"a":[0,0],"b":[1e100,0]}),
json!({"a":[0,0],"b":[10,0],"options":{"precision":99999}}),
json!({"a":[0,0],"b":[10,0],"options":{"scale":"0:10"}}),
json!({"a":[0,0],"b":[10,0],"options":{"scale":"1:NaN"}}),
json!({"a":[0,0],"b":[10,0],"options":{"units":"furlongs"}}),
json!({"a":[0,0],"b":[10,0],"options":{"textPosition":"sideways"}}),
json!({"a":[0,0],"b":[10,0],"mode":"spiral"}),
json!({"source":{"kind":"segment","edge":{"node":line,"subpath":0,"segment":18446744073709551615u64}}}),
json!({"mode":"angular","source":{"kind":"segment","edge":{"node":line,"subpath":0,"segment":0}}}),
] {
assert!(s.execute("dimension.create", &p).is_err(), "{p}");
assert_eq!(serde_json::to_value(&*s.doc().unwrap().doc).unwrap(), original);
}
assert!(s.execute("dimension.update", &json!({"ids":[id],"options":{"fontSize":-1}})).is_err());
assert_eq!(serde_json::to_value(&*s.doc().unwrap().doc).unwrap(), original);
}
/// Hover a segment and drag outwards: releasing places the dimension; Escape cancels the drag.
#[test]
fn dragging_out_from_a_segment_places_its_dimension() {
let mut s = session();
edge(&mut s);
s.select_tool("dimension", ViewInfo::default()).unwrap();
let before = s.doc().unwrap().doc.node_count();
ev(&mut s, PointerKind::Move, 130.0, 100.0);
ev(&mut s, PointerKind::Down, 130.0, 100.0);
ev(&mut s, PointerKind::Drag, 130.0, 70.0);
assert!(s.in_interaction());
s.tool_key(ToolKey::Escape, Mods::default(), ViewInfo::default()).unwrap();
assert_eq!(s.doc().unwrap().doc.node_count(), before);
assert!(!s.in_interaction());
ev(&mut s, PointerKind::Up, 130.0, 70.0);
ev(&mut s, PointerKind::Down, 130.0, 100.0);
ev(&mut s, PointerKind::Drag, 130.0, 80.0);
ev(&mut s, PointerKind::Drag, 130.0, 60.0);
ev(&mut s, PointerKind::Up, 130.0, 60.0);
assert!(!s.in_interaction());
assert!(s.doc().unwrap().doc.node_count() > before);
let id = s.doc().unwrap().selection.objects[0].0;
assert_eq!(text(&s, id), "72.00 pt");
assert!((recipe(&s, id).offset.abs() - 40.0).abs() < 1e-6, "placed where it was released");
run(&mut s, "edit.undo", json!({}));
assert_eq!(s.doc().unwrap().doc.node_count(), before);
}
/// Plain clicks pick two points, and a third click places the dimension.
#[test]
fn clicking_two_points_then_placing_measures_between_them() {
let mut s = session();
s.select_tool("dimension", ViewInfo::default()).unwrap();
for (x, y) in [(300.0, 300.0), (372.0, 300.0)] {
ev(&mut s, PointerKind::Down, x, y);
ev(&mut s, PointerKind::Up, x, y);
}
ev(&mut s, PointerKind::Move, 336.0, 330.0);
assert!(s.in_interaction());
ev(&mut s, PointerKind::Down, 336.0, 330.0);
ev(&mut s, PointerKind::Up, 336.0, 330.0);
assert!(!s.in_interaction());
let id = s.doc().unwrap().selection.objects[0].0;
assert_eq!(text(&s, id), "72.00 pt");
}
/// After two clicked points, a pointer beside them (between the perpendiculars at the points)
/// gives the distance between them (aligned), as does clicking again on the second point. Past
/// either point, the quarter that holds the direction straight up or down from it gives their
/// horizontal distance, and the quarter that holds the direction straight left or right gives
/// their vertical distance. The dimension stays linked to the anchors it measures.
#[test]
fn two_points_measure_horizontally_vertically_or_aligned() {
use vectorcraft_doc::dimension::Orientation;
for ((x, y), want, orientation) in [
((210.0, 120.0), "100.00 pt", Orientation::Horizontal),
((90.0, 380.0), "100.00 pt", Orientation::Horizontal),
((280.0, 220.0), "100.00 pt", Orientation::Vertical),
((40.0, 310.0), "100.00 pt", Orientation::Vertical),
((120.0, 160.0), "141.42 pt", Orientation::Aligned),
((200.0, 300.0), "141.42 pt", Orientation::Aligned),
((200.0, 200.0), "141.42 pt", Orientation::Aligned),
] {
let mut s = session();
let line = run(&mut s, "shape.line", json!({"x1":100,"y1":300,"x2":200,"y2":200}))["id"].as_u64().unwrap();
s.select_tool("dimension", ViewInfo::default()).unwrap();
for (px, py) in [(100.0, 300.0), (200.0, 200.0), (x, y)] {
ev(&mut s, PointerKind::Move, px, py);
ev(&mut s, PointerKind::Down, px, py);
ev(&mut s, PointerKind::Up, px, py);
}
let id = s.doc().unwrap().selection.objects[0].0;
assert_eq!((text(&s, id), recipe(&s, id).orientation), (want.to_string(), orientation), "pointer at ({x}, {y})");
assert!(recipe(&s, id).source.is_some_and(|src| src.nodes() == vec![NodeId(line), NodeId(line)]));
if orientation != Orientation::Aligned {
run(&mut s, "select.set", json!({"ids":[line]}));
run(&mut s, "object.scale", json!({"sx":200,"sy":200}));
assert_eq!(text(&s, id), "200.00 pt", "follows the art");
}
}
}
/// A third click a little past the second point, still within reach of it, places an aligned
/// dimension, also when Shift moved the second point away from where the pointer was: the click
/// counts as clicking again on the second point.
#[test]
fn clicking_again_by_the_second_point_places_an_aligned_dimension() {
use vectorcraft_doc::dimension::Orientation;
let click = |s: &mut Session, x: f64, y: f64, shift: bool| {
for kind in [PointerKind::Move, PointerKind::Down, PointerKind::Up] {
let e = PointerEvent::new(kind, x, y).with_mods(Mods { shift, ..Mods::default() });
s.pointer(&e, ViewInfo::default()).unwrap();
}
};
// Free points, the third click 2.2 pt past the second.
let mut s = session();
s.select_tool("dimension", ViewInfo::default()).unwrap();
click(&mut s, 100.0, 300.0, false);
click(&mut s, 200.0, 200.0, false);
click(&mut s, 202.0, 201.0, false);
let id = s.doc().unwrap().selection.objects[0].0;
assert_eq!((text(&s, id), recipe(&s, id).orientation), ("141.42 pt".to_string(), Orientation::Aligned));
// Shift puts the second point on the 45 degree line, about 21 pt from the pointer.
let mut s = session();
s.select_tool("dimension", ViewInfo::default()).unwrap();
click(&mut s, 100.0, 300.0, false);
click(&mut s, 260.0, 170.0, true);
click(&mut s, 262.0, 168.0, false);
let id = s.doc().unwrap().selection.objects[0].0;
assert_eq!((text(&s, id), recipe(&s, id).orientation), ("206.16 pt".to_string(), Orientation::Aligned));
}
/// Dragged out from a slanted segment, the dimension follows the drag the same way, wherever on
/// the segment the press was: beside the segment it measures its length, and past either end
/// its horizontal or vertical length.
#[test]
fn a_segment_drag_measures_horizontally_vertically_or_aligned() {
use vectorcraft_doc::dimension::Orientation;
for (press, (x, y), want, orientation) in [
((150.0, 250.0), (210.0, 120.0), "100.00 pt", Orientation::Horizontal),
((110.0, 290.0), (210.0, 120.0), "100.00 pt", Orientation::Horizontal),
((150.0, 250.0), (280.0, 220.0), "100.00 pt", Orientation::Vertical),
((190.0, 210.0), (40.0, 310.0), "100.00 pt", Orientation::Vertical),
((150.0, 250.0), (120.0, 160.0), "141.42 pt", Orientation::Aligned),
((110.0, 290.0), (200.0, 300.0), "141.42 pt", Orientation::Aligned),
] {
let mut s = session();
run(&mut s, "shape.line", json!({"x1":100,"y1":300,"x2":200,"y2":200}));
s.select_tool("dimension", ViewInfo::default()).unwrap();
ev(&mut s, PointerKind::Move, press.0, press.1);
ev(&mut s, PointerKind::Down, press.0, press.1);
ev(&mut s, PointerKind::Drag, (press.0 + x) / 2.0, (press.1 + y) / 2.0);
ev(&mut s, PointerKind::Drag, x, y);
ev(&mut s, PointerKind::Up, x, y);
let id = s.doc().unwrap().selection.objects[0].0;
assert_eq!((text(&s, id), recipe(&s, id).orientation), (want.to_string(), orientation), "pressed at {press:?}, dragged to ({x}, {y})");
}
}
/// Two points on one vertical line give an aligned dimension wherever the pointer goes: their
/// vertical distance is the distance between them, and they have no horizontal distance.
#[test]
fn points_on_a_vertical_line_measure_aligned() {
use vectorcraft_doc::dimension::Orientation;
for (x, y) in [(60.0, 200.0), (160.0, 80.0), (100.0, 40.0), (40.0, 340.0), (100.0, 300.0)] {
let mut s = session();
s.select_tool("dimension", ViewInfo::default()).unwrap();
for (px, py) in [(100.0, 100.0), (100.0, 300.0), (x, y)] {
ev(&mut s, PointerKind::Move, px, py);
ev(&mut s, PointerKind::Down, px, py);
ev(&mut s, PointerKind::Up, px, py);
}
let id = s.doc().unwrap().selection.objects[0].0;
assert_eq!((text(&s, id), recipe(&s, id).orientation), ("200.00 pt".to_string(), Orientation::Aligned), "pointer at ({x}, {y})");
}
}
/// The ends of a dimension's generated paths (extension lines first, then the dimension line),
/// rounded.
fn path_ends(s: &Session, id: u64) -> Vec<Vec<(f64, f64)>> {
let round = |v: f64| (v * 1e6).round() / 1e6;
let doc = &s.doc().unwrap().doc;
let art = doc.node(NodeId(id)).unwrap().children().unwrap();
art.iter().filter_map(|n| n.path_data()).map(|p| p.subpaths[0].anchors.iter().map(|a| (round(a.p.x), round(a.p.y))).collect()).collect()
}
/// A horizontal dimension's line is horizontal and a vertical one's vertical, through the point
/// it was placed at, and each extension line runs from its point to the dimension line. Points
/// given right to left or bottom to top draw the same lines.
#[test]
fn horizontal_and_vertical_dimensions_draw_lines_along_their_axis() {
let mut s = session();
let r = run(&mut s, "dimension.create", json!({"a":[100,300],"b":[200,200],"orientation":"horizontal","offset":-150}));
assert_eq!(r["label"], "100.00 pt");
// Extension lines: 4 pt off each point, 6 pt past the line at y = 150.
let horizontal = vec![vec![(100.0, 296.0), (100.0, 144.0)], vec![(200.0, 196.0), (200.0, 144.0)], vec![(100.0, 150.0), (200.0, 150.0)]];
assert_eq!(path_ends(&s, r["id"].as_u64().unwrap()), horizontal);
let r = run(&mut s, "dimension.create", json!({"a":[200,200],"b":[100,300],"orientation":"horizontal","offset":-50}));
let swapped = vec![vec![(200.0, 196.0), (200.0, 144.0)], vec![(100.0, 296.0), (100.0, 144.0)], vec![(200.0, 150.0), (100.0, 150.0)]];
assert_eq!(path_ends(&s, r["id"].as_u64().unwrap()), swapped, "the line stays on the side the offset puts it");
// A vertical dimension 50 pt left of the first point: the line at x = 50.
let r = run(&mut s, "dimension.create", json!({"a":[100,300],"b":[200,200],"orientation":"vertical","offset":50}));
assert_eq!(r["label"], "100.00 pt");
let vertical = vec![vec![(96.0, 300.0), (44.0, 300.0)], vec![(196.0, 200.0), (44.0, 200.0)], vec![(50.0, 300.0), (50.0, 200.0)]];
assert_eq!(path_ends(&s, r["id"].as_u64().unwrap()), vertical);
}
/// A horizontal dimension stays horizontal when it or the art it measures turns: it shows the
/// new horizontal distance, and 0 when the points come one above the other. It is not removed.
#[test]
fn turned_horizontal_dimensions_stay_horizontal() {
use vectorcraft_doc::dimension::Orientation;
let mut s = session();
let id = run(&mut s, "dimension.create", json!({"a":[100,200],"b":[200,260],"orientation":"horizontal","offset":-50}))["id"].as_u64().unwrap();
run(&mut s, "select.set", json!({"ids":[id]}));
run(&mut s, "object.rotate", json!({"angle":90,"origin":[150,230]}));
assert_eq!((text(&s, id), recipe(&s, id).orientation), ("60.00 pt".to_string(), Orientation::Horizontal));
// The points are now at y = 180 and 280: the line stays 50 pt above the upper one instead of
// crossing between them.
let d = recipe(&s, id);
let mut ys = [d.a.y.round(), d.b.y.round()];
ys.sort_by(f64::total_cmp);
assert_eq!(ys, [180.0, 280.0]);
assert!(path_ends(&s, id)[2].iter().all(|(_, y)| (y - 130.0).abs() < 1e-6), "{:?}", path_ends(&s, id));
let line = run(&mut s, "shape.line", json!({"x1":100,"y1":400,"x2":200,"y2":400}))["id"].as_u64().unwrap();
let anchor = |anchor: u64| json!({"node":line,"subpath":0,"anchor":anchor});
let src = json!({"kind":"points","a":anchor(0),"b":anchor(1)});
let id = run(&mut s, "dimension.create", json!({"source":src,"orientation":"horizontal"}))["id"].as_u64().unwrap();
run(&mut s, "select.set", json!({"ids":[line]}));
run(&mut s, "object.rotate", json!({"angle":90}));
assert_eq!((text(&s, id), recipe(&s, id).orientation), ("0.00 pt".to_string(), Orientation::Horizontal));
run(&mut s, "edit.undo", json!({}));
assert_eq!(text(&s, id), "100.00 pt");
}
/// The line of a linked horizontal dimension keeps its gap from the outermost point on its side
/// when the art turns, so it stays clear of the art: after a half turn the line is still 50 pt
/// above the upper point, which is now the other anchor.
#[test]
fn a_linked_horizontal_dimension_keeps_its_gap_from_turned_art() {
let mut s = session();
let line = run(&mut s, "shape.line", json!({"x1":100,"y1":300,"x2":200,"y2":200}))["id"].as_u64().unwrap();
let anchor = |anchor: u64| json!({"node":line,"subpath":0,"anchor":anchor});
let src = json!({"kind":"points","a":anchor(0),"b":anchor(1)});
let id = run(&mut s, "dimension.create", json!({"source":src,"orientation":"horizontal","offset":-150}))["id"].as_u64().unwrap();
assert_eq!(path_ends(&s, id)[2], vec![(100.0, 150.0), (200.0, 150.0)]);
run(&mut s, "select.set", json!({"ids":[line]}));
run(&mut s, "object.rotate", json!({"angle":180,"origin":[150,250]}));
assert_eq!(text(&s, id), "100.00 pt");
assert_eq!(path_ends(&s, id)[2], vec![(200.0, 150.0), (100.0, 150.0)]);
// Scaled to twice the size about the lower left point, the upper point is at y = 100.
run(&mut s, "object.scale", json!({"sx":200,"sy":200,"origin":[100,300]}));
assert_eq!(text(&s, id), "200.00 pt");
assert_eq!(path_ends(&s, id)[2], vec![(300.0, 50.0), (100.0, 50.0)]);
run(&mut s, "edit.undo", json!({}));
run(&mut s, "edit.undo", json!({}));
assert_eq!(path_ends(&s, id)[2], vec![(100.0, 150.0), (200.0, 150.0)]);
}
/// An unlinked horizontal dimension reflected on its own has its line where the reflection puts
/// it: a line above the points is below them after a flip top to bottom.
#[test]
fn a_reflected_horizontal_dimension_keeps_its_line_on_the_reflected_side() {
let mut s = session();
let id = run(&mut s, "dimension.create", json!({"a":[100,300],"b":[200,200],"orientation":"horizontal","offset":-150}))["id"].as_u64().unwrap();
run(&mut s, "select.set", json!({"ids":[id]}));
run(&mut s, "object.reflect", json!({"axis":"horizontal","origin":[0,250]}));
let d = recipe(&s, id);
assert_eq!((d.a.y.round(), d.b.y.round()), (200.0, 300.0));
assert_eq!(path_ends(&s, id)[2], vec![(100.0, 350.0), (200.0, 350.0)]);
assert_eq!(text(&s, id), "100.00 pt");
}
/// The line of a linked horizontal dimension below both points stays below them when the art
/// turns a half turn; a line between the points keeps its distance from the first point.
#[test]
fn a_linked_horizontal_dimension_below_or_between_its_points_follows_turned_art() {
for (offset, want) in [(50.0, vec![(200.0, 350.0), (100.0, 350.0)]), (-50.0, vec![(200.0, 150.0), (100.0, 150.0)])] {
let mut s = session();
let line = run(&mut s, "shape.line", json!({"x1":100,"y1":300,"x2":200,"y2":200}))["id"].as_u64().unwrap();
let anchor = |anchor: u64| json!({"node":line,"subpath":0,"anchor":anchor});
let src = json!({"kind":"points","a":anchor(0),"b":anchor(1)});
let id = run(&mut s, "dimension.create", json!({"source":src,"orientation":"horizontal","offset":offset}))["id"].as_u64().unwrap();
run(&mut s, "select.set", json!({"ids":[line]}));
run(&mut s, "object.rotate", json!({"angle":180,"origin":[150,250]}));
assert_eq!(text(&s, id), "100.00 pt");
assert_eq!(path_ends(&s, id)[2], want, "offset {offset}");
}
}
/// A dimension recipe without an orientation reads as aligned.
#[test]
fn a_dimension_without_an_orientation_reads_as_aligned() {
use vectorcraft_doc::dimension::{Dimension, Orientation};
let mut s = session();
let id = run(&mut s, "dimension.create", json!({"a":[0,0],"b":[30,40]}))["id"].as_u64().unwrap();
let v = serde_json::to_value(recipe(&s, id)).unwrap();
assert!(v.get("orientation").is_none());
let d: Dimension = serde_json::from_value(v).unwrap();
assert_eq!(d.orientation, Orientation::Aligned);
}
/// `orientation` is checked on create and update, and only linear dimensions take one.
#[test]
fn dimension_orientation_is_checked() {
let mut s = session();
for (p, want) in [
(json!({"a":[0,0],"b":[30,40],"orientation":"horizontal"}), Some("30.00 pt")),
(json!({"a":[0,0],"b":[30,40],"orientation":"vertical"}), Some("40.00 pt")),
(json!({"a":[0,0],"b":[30,40],"orientation":"aligned"}), Some("50.00 pt")),
(json!({"a":[0,0],"b":[0,40],"orientation":"horizontal"}), Some("0.00 pt")),
(json!({"a":[0,0],"b":[30,40],"orientation":"diagonal"}), None),
(json!({"a":[0,0],"b":[30,40],"orientation":7}), None),
(json!({"a":[0,0],"b":[30,40],"orientation":null}), None),
(json!({"mode":"angular","a":[0,0],"b":[30,40],"vertex":[10,0],"orientation":"horizontal"}), None),
] {
let before = serde_json::to_value(&*s.doc().unwrap().doc).unwrap();
match (s.execute("dimension.create", &p), want) {
(Ok(r), Some(want)) => assert_eq!(r["label"], want, "{p}"),
(Err(_), None) => assert_eq!(serde_json::to_value(&*s.doc().unwrap().doc).unwrap(), before, "{p}"),
(r, _) => panic!("{p}: {r:?}"),
}
}
// An aligned dimension is written as before, without the field.
let doc = serde_json::to_string(&*s.doc().unwrap().doc).unwrap();
assert_eq!(doc.matches("\"orientation\"").count(), 3, "only the horizontal and vertical ones");
// dimension.update turns an existing linear dimension, and refuses the same bad values.
let id = run(&mut s, "dimension.create", json!({"a":[0,0],"b":[30,40]}))["id"].as_u64().unwrap();
let angle = run(&mut s, "dimension.create", json!({"mode":"angular","a":[0,0],"b":[30,40],"vertex":[10,0]}))["id"].as_u64().unwrap();
for (orientation, want) in [("horizontal", "30.00 pt"), ("vertical", "40.00 pt"), ("aligned", "50.00 pt")] {
run(&mut s, "dimension.update", json!({"ids":[id],"orientation":orientation}));
assert_eq!(text(&s, id), want);
}
run(&mut s, "dimension.update", json!({"ids":[id],"orientation":"vertical"}));
run(&mut s, "edit.undo", json!({}));
assert_eq!(text(&s, id), "50.00 pt", "one undo step");
let before = serde_json::to_value(&*s.doc().unwrap().doc).unwrap();
for p in [
json!({"ids":[id],"orientation":"diagonal"}),
json!({"ids":[id],"orientation":7}),
json!({"ids":[angle],"orientation":"vertical"}),
// A linear and an angular dimension together: neither changes.
json!({"ids":[id, angle],"orientation":"vertical"}),
] {
assert!(s.execute("dimension.update", &p).is_err(), "{p}");
assert_eq!(serde_json::to_value(&*s.doc().unwrap().doc).unwrap(), before, "{p}");
}
// The orientation it already has, or aligned for an angular one, changes nothing and records
// no undo step: the undo after it goes back to the vertical dimension of the earlier update.
for p in [json!({"ids":[id],"orientation":"aligned"}), json!({"ids":[angle],"orientation":"aligned"})] {
run(&mut s, "dimension.update", p);
assert_eq!(serde_json::to_value(&*s.doc().unwrap().doc).unwrap(), before);
}
run(&mut s, "edit.undo", json!({}));
assert_eq!(text(&s, id), "40.00 pt");
}
/// A dimension turned with `dimension.update` keeps its line on the side of the points it was
/// on: an aligned line 30 pt above a pair made right to left stays 30 pt above as horizontal,
/// and an aligned line below and right of slanted points goes 30 pt below the lower point.
#[test]
fn a_turned_dimension_keeps_its_line_on_its_side() {
let mut s = session();
let id = run(&mut s, "dimension.create", json!({"a":[200,200],"b":[100,200],"offset":30}))["id"].as_u64().unwrap();
assert_eq!(path_ends(&s, id)[2], vec![(200.0, 170.0), (100.0, 170.0)]);
run(&mut s, "dimension.update", json!({"ids":[id],"orientation":"horizontal"}));
assert_eq!(path_ends(&s, id)[2], vec![(200.0, 170.0), (100.0, 170.0)]);
// The middle of the aligned line (about 171, 271) lies between the points' heights.
let id = run(&mut s, "dimension.create", json!({"a":[100,300],"b":[200,200],"offset":30}))["id"].as_u64().unwrap();
run(&mut s, "dimension.update", json!({"ids":[id],"orientation":"horizontal"}));
assert_eq!(path_ends(&s, id)[2], vec![(100.0, 330.0), (200.0, 330.0)]);
}
/// Dragging out from a corner of two straight segments measures the angle between them.
#[test]
fn dragging_out_from_a_corner_measures_its_angle() {
let mut s = session();
run(&mut s, "shape.rectangle", json!({"x":100,"y":100,"width":100,"height":50}));
s.select_tool("dimension", ViewInfo::default()).unwrap();
s.set_tool_option_cmd(&json!({"tool":"dimension","values":{"mode":"angular"}})).unwrap();
ev(&mut s, PointerKind::Move, 100.0, 100.0);
ev(&mut s, PointerKind::Down, 100.0, 100.0);
ev(&mut s, PointerKind::Drag, 120.0, 120.0);
ev(&mut s, PointerKind::Drag, 130.0, 130.0);
ev(&mut s, PointerKind::Up, 130.0, 130.0);
let id = s.doc().unwrap().selection.objects[0].0;
assert_eq!(text(&s, id), "90.00°");
}
/// Dragging out from a circle gives its radius; dragging inwards, its diameter.
#[test]
fn dragging_from_a_circle_gives_its_radius_or_diameter() {
let mut s = session();
run(&mut s, "shape.ellipse", json!({"x":100,"y":100,"width":144,"height":144}));
s.select_tool("dimension", ViewInfo::default()).unwrap();
s.set_tool_option_cmd(&json!({"tool":"dimension","values":{"mode":"radial","style":{"units":"in","precision":0}}})).unwrap();
for ((x0, y0), (x1, y1), want) in [((244.0, 172.0), (280.0, 172.0), "1 in"), ((244.0, 172.0), (200.0, 172.0), "2 in")] {
ev(&mut s, PointerKind::Move, x0, y0);
ev(&mut s, PointerKind::Down, x0, y0);
ev(&mut s, PointerKind::Drag, (x0 + x1) / 2.0, y0);
ev(&mut s, PointerKind::Drag, x1, y1);
ev(&mut s, PointerKind::Up, x1, y1);
let id = s.doc().unwrap().selection.objects[0].0;
assert_eq!(text(&s, id), want);
}
}
#[test]
fn dimension_anchor_links_and_native_file_survive_source_moves() {
let mut s = session();
let first = edge(&mut s);
let second = edge(&mut s);
let id=run(&mut s,"dimension.create",json!({"a":[100,100],"b":[172,100],"source":{"kind":"points","a":{"node":first,"subpath":0,"anchor":0},"b":{"node":second,"subpath":0,"anchor":1},"vertex":null},"options":{"units":"pt","precision":0}}))["id"].as_u64().unwrap();
assert_eq!(text(&s, id), "72 pt");
let bytes = vectorcraft_format::save(&s.doc().unwrap().doc, false);
let doc = vectorcraft_format::load(&bytes).unwrap();
let mut s = Session::new();
s.add_document(doc, None);
run(&mut s, "select.set", json!({"ids":[second]}));
run(&mut s, "object.move", json!({"dx":28,"dy":0}));
assert_eq!(text(&s, id), "100 pt");
}
/// A linked dimension moved on its own keeps its new place when its art changes later; moved with
/// its art, it keeps its distance from it.
#[test]
fn a_moved_linked_dimension_stays_where_it_was_dropped() {
let mut s = session();
let line = edge(&mut s);
let id = dim(&mut s, line);
let offset = recipe(&s, id).offset;
run(&mut s, "select.set", json!({"ids":[id]}));
run(&mut s, "object.move", json!({"dx":0,"dy":-20}));
let moved = recipe(&s, id).offset;
assert!((moved - offset).abs() - 20.0 < 1e-6 && (moved - offset).abs() > 19.0, "{offset} → {moved}");
run(&mut s, "select.set", json!({"ids":[line]}));
run(&mut s, "object.scale", json!({"sx":200,"sy":100}));
assert!((recipe(&s, id).offset - moved).abs() < 1e-6, "no jump back");
run(&mut s, "select.set", json!({"ids":[line, id]}));
run(&mut s, "object.move", json!({"dx":0,"dy":50}));
assert!((recipe(&s, id).offset - moved).abs() < 1e-6, "moved together");
}
/// How many nodes a dimension's art holds, how many of them are type, and whether it has a recipe.
fn art(s: &Session, id: u64) -> (usize, usize, bool) {
let n = s.doc().unwrap().doc.node(NodeId(id)).unwrap();
let (mut nodes, mut labels) = (0, 0);
n.walk(&mut |c| {
nodes += 1;
labels += usize::from(matches!(c.kind, NodeKind::Text(_)));
});
(nodes, labels, n.extras().dimension.is_some())
}
fn bounds(s: &Session, id: u64) -> [f64; 4] {
let b = s.doc().unwrap().doc.node(NodeId(id)).unwrap().geometric_bounds().unwrap();
[b.x0, b.y0, b.x1, b.y1]
}
/// The art of `id`, which holds no type and no recipe, follows a move, a scale, undo and redo as
/// plain art does.
fn stays_plain_art(s: &mut Session, id: u64) {
let plain = art(s, id);
assert_eq!(plain.1, 0, "no type left: {plain:?}");
let at = bounds(s, id);
run(s, "select.set", json!({"ids":[id]}));
run(s, "object.move", json!({"dx":30,"dy":-20}));
assert_eq!(art(s, id), plain, "after a move");
let moved = bounds(s, id);
for (k, (a, b)) in at.iter().zip(moved).enumerate() {
let d = if k % 2 == 0 { 30.0 } else { -20.0 };
assert!((b - a - d).abs() < 1e-6, "{at:?} moved to {moved:?}");
}
run(s, "object.scale", json!({"sx":200,"sy":100}));
assert_eq!(art(s, id), plain, "after a scale");
let scaled = bounds(s, id);
assert!(((scaled[2] - scaled[0]) - 2.0 * (at[2] - at[0])).abs() < 1e-6, "{at:?} scaled to {scaled:?}");
run(s, "edit.undo", json!({}));
assert_eq!(art(s, id), plain, "after undoing the scale");
run(s, "edit.redo", json!({}));
assert_eq!(art(s, id), plain, "after redoing the scale");
run(s, "edit.undo", json!({}));
run(s, "edit.undo", json!({}));
assert_eq!(art(s, id), plain);
assert_eq!(bounds(s, id), at);
assert!(!plain.2, "no recipe left");
}
/// Object › Expand turns a dimension into plain art for good: it no longer follows the art it
/// measured, and one undo gives the live dimension back.
#[test]
fn an_expanded_dimension_stays_plain_art() {
let mut s = session();
let line = edge(&mut s);
let id = dim(&mut s, line);
let other = dim(&mut s, line);
let live = art(&s, id);
assert_eq!((live.1, live.2), (1, true));
run(&mut s, "select.set", json!({"ids":[id]}));
run(&mut s, "object.expand", json!({}));
stays_plain_art(&mut s, id);
let plain = art(&s, id);
let at = bounds(&s, id);
assert_eq!(art(&s, other), live, "the dimension left out of the selection stays live");
// The art it measured no longer moves it, and deleting that art leaves it.
run(&mut s, "select.set", json!({"ids":[line]}));
run(&mut s, "object.scale", json!({"sx":200,"sy":100}));
assert_eq!(text(&s, other), "20.00 in");
assert_eq!((art(&s, id), bounds(&s, id)), (plain, at));
run(&mut s, "edit.clear", json!({}));
assert_eq!((art(&s, id), bounds(&s, id)), (plain, at));
assert!(s.doc().unwrap().doc.node(NodeId(other)).is_none());
// Saved and opened again, it is still plain art.
let doc = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap();
let mut reopened = Session::new();
reopened.add_document(doc, None);
stays_plain_art(&mut reopened, id);
run(&mut s, "edit.undo", json!({}));
run(&mut s, "edit.undo", json!({}));
run(&mut s, "edit.undo", json!({}));
assert_eq!(art(&s, id), live, "one undo gives the live dimension back");
assert_eq!(text(&s, id), "10.00 in");
run(&mut s, "select.set", json!({"ids":[line]}));
run(&mut s, "object.scale", json!({"sx":200,"sy":100}));
assert_eq!(text(&s, id), "20.00 in", "linked again");
}
/// Expand's options: any one that changes a dimension's art ends its link, and an Expand that
/// changes nothing leaves it live.
#[test]
fn expand_options_that_change_a_dimension_end_its_link() {
let mut s = session();
let line = edge(&mut s);
let id = dim(&mut s, line);
let live = art(&s, id);
run(&mut s, "select.set", json!({"ids":[id]}));
assert!(s.execute("object.expand", &json!({"object":false,"stroke":false})).is_err(), "no fills to expand");
assert_eq!(art(&s, id), live);
assert!(s.doc().unwrap().doc.dimensions);
// Stroke alone leaves type as it is, so a stroked label on its own does not end the link.
let label = {
let children = s.doc().unwrap().doc.node(NodeId(id)).unwrap().children().unwrap();
children.iter().find(|c| matches!(c.kind, NodeKind::Text(_))).unwrap().id.0
};
run(&mut s, "paint.setStroke", json!({"ids":[label],"color":"#ff0000"}));
run(&mut s, "select.set", json!({"ids":[label]}));
assert!(s.execute("object.expand", &json!({"object":false,"fill":false})).is_err(), "no paths to outline");
assert!(art(&s, id).2, "still live");
run(&mut s, "edit.undo", json!({}));
assert_eq!(art(&s, id), live);
run(&mut s, "select.set", json!({"ids":[id]}));
// Stroke alone: the label stays type, in art that no longer redraws.
run(&mut s, "object.expand", json!({"object":false,"fill":false}));
let plain = art(&s, id);
assert_eq!(plain.1, 1, "{plain:?}");
run(&mut s, "select.set", json!({"ids":[id]}));
run(&mut s, "object.move", json!({"dx":30,"dy":0}));
assert_eq!(art(&s, id), plain);
assert!(!plain.2, "no recipe left");
run(&mut s, "edit.undo", json!({}));
run(&mut s, "edit.undo", json!({}));
assert_eq!(art(&s, id), live);
// Object alone: the lines stay stroked paths, in art that no longer redraws.
run(&mut s, "select.set", json!({"ids":[id]}));
run(&mut s, "object.expand", json!({"stroke":false,"fill":false}));
let plain = art(&s, id);
assert_eq!(plain.1, 0, "{plain:?}");
run(&mut s, "object.move", json!({"dx":30,"dy":0}));
assert_eq!(art(&s, id), plain);
assert!(!plain.2, "no recipe left");
}
/// An Expand that ends up changing no art fails and leaves the dimension live: Outline Stroke skips
/// guides, here the dimension's lines.
#[test]
fn an_expand_that_changes_no_art_leaves_a_dimension_live() {
let mut s = session();
let line = edge(&mut s);
let id = dim(&mut s, line);
let lines: Vec<u64> = {
let children = s.doc().unwrap().doc.node(NodeId(id)).unwrap().children().unwrap();
children.iter().filter(|c| matches!(c.kind, NodeKind::Path { .. })).map(|c| c.id.0).collect()
};
run(&mut s, "select.set", json!({"ids":lines}));
run(&mut s, "view.guides.make", json!({}));
let live = art(&s, id);
run(&mut s, "select.set", json!({"ids":[id]}));
assert!(s.execute("object.expand", &json!({"object":false,"fill":false})).is_err());
assert_eq!(art(&s, id), live);
assert!(live.2);
// A stroked path selected with it expands, and the dimension's art stays live.
let other = run(&mut s, "shape.rectangle", json!({"x":300,"y":300,"width":50,"height":50}))["id"].as_u64().unwrap();
run(&mut s, "paint.setStroke", json!({"ids":[other],"color":"#000000"}));
let moved_with = s.doc().unwrap().doc.node_count();
run(&mut s, "select.set", json!({"ids":[id, other]}));
run(&mut s, "object.expand", json!({"object":false,"fill":false}));
assert_ne!(s.doc().unwrap().doc.node_count(), moved_with, "the rectangle's stroke is outlined");
assert_eq!(art(&s, id), live);
}
/// A dimension selected with the art it measures, or held by a selected group, expands the same
/// way: outlining that art does not remove it.
#[test]
fn a_dimension_expanded_with_its_art_or_in_a_group_stays_plain_art() {
let mut s = session();
let line = edge(&mut s);
let id = dim(&mut s, line);
run(&mut s, "select.set", json!({"ids":[line, id]}));
run(&mut s, "object.expand", json!({}));
stays_plain_art(&mut s, id);
let mut s = session();
let line = edge(&mut s);
let id = dim(&mut s, line);
run(&mut s, "select.set", json!({"ids":[id]}));
run(&mut s, "object.group", json!({}));
run(&mut s, "object.expand", json!({}));
stays_plain_art(&mut s, id);
}
/// Expand replacing only the art a selected dimension measures leaves the dimension's art as it
/// is, without a link to the art that went.
#[test]
fn a_dimension_keeps_no_link_to_the_art_expand_replaced() {
for circle in [false, true] {
let mut s = session();
let shape = json!({"x":100,"y":100,"width":72,"height":72});
let art_id = run(&mut s, if circle { "shape.ellipse" } else { "shape.rectangle" }, shape)["id"].as_u64().unwrap();
run(&mut s, "paint.setFill", json!({"ids":[art_id],"gradient":{"kind":"linear"}}));
let id = if circle {
run(&mut s, "dimension.create", json!({"mode":"radius","source":{"kind":"circle","node":art_id}}))["id"].as_u64().unwrap()
} else {
dim(&mut s, art_id)
};
let (nodes, labels, _) = art(&s, id);
run(&mut s, "select.set", json!({"ids":[art_id, id]}));
run(&mut s, "object.expand", json!({"object":false,"stroke":false}));
assert_eq!(art(&s, id), (nodes, labels, false), "circle: {circle}");
run(&mut s, "select.set", json!({"ids":[id]}));
run(&mut s, "object.move", json!({"dx":30,"dy":0}));
assert_eq!(art(&s, id), (nodes, labels, false), "circle: {circle}");
}
}
/// Outline Stroke and Expand Appearance replacing the art a selected dimension measures leave the
/// dimension as a plain group, whether or not they change its own art.
#[test]
fn a_dimension_outlasts_the_art_outline_stroke_or_expand_appearance_replaced() {
for command in ["object.path.outlineStroke", "effect.expandAppearance"] {
for whole in [true, false] {
let mut s = session();
let art_id = run(&mut s, "shape.rectangle", json!({"x":100,"y":100,"width":72,"height":72}))["id"].as_u64().unwrap();
run(&mut s, "paint.setStroke", json!({"ids":[art_id],"color":"#000000"}));
run(&mut s, "select.set", json!({"ids":[art_id]}));
run(&mut s, "appearance.addStroke", json!({}));
let id = dim(&mut s, art_id);
let lines: Vec<u64> = {
let children = s.doc().unwrap().doc.node(NodeId(id)).unwrap().children().unwrap();
children.iter().filter(|c| matches!(c.kind, NodeKind::Path { .. })).map(|c| c.id.0).collect()
};
run(&mut s, "select.set", json!({"ids":lines}));
run(&mut s, "view.guides.make", json!({}));
let picked = if whole { id } else { lines[0] };
run(&mut s, "select.set", json!({"ids":[art_id, picked]}));
run(&mut s, command, json!({}));
assert!(s.doc().unwrap().doc.node(NodeId(id)).is_some(), "{command} removed the dimension, whole: {whole}");
let plain = art(&s, id);
assert!(!plain.2, "{command}: no recipe left, whole: {whole}");
run(&mut s, "select.set", json!({"ids":[id]}));
run(&mut s, "object.move", json!({"dx":30,"dy":0}));
assert_eq!(art(&s, id), plain, "{command}, whole: {whole}");
}
}
}
/// A part of a dimension expanded on its own ends the link too, so the move does not undo it.
#[test]
fn expanding_a_part_of_a_dimension_ends_its_link() {
let mut s = session();
let line = edge(&mut s);
let id = dim(&mut s, line);
let part = s.doc().unwrap().doc.node(NodeId(id)).unwrap().children().unwrap()[0].id;
run(&mut s, "select.set", json!({"ids":[part]}));
run(&mut s, "object.expand", json!({}));
let plain = art(&s, id);
assert_eq!(plain.1, 1, "{plain:?}");
run(&mut s, "select.set", json!({"ids":[id]}));
run(&mut s, "object.move", json!({"dx":30,"dy":0}));
assert_eq!(art(&s, id), plain);
assert!(!plain.2, "no recipe left");
}
/// The instance of a symbol whose art is a dimension expands to art that no longer redraws.
#[test]
fn a_dimension_in_an_expanded_symbol_instance_stays_plain_art() {
let mut s = session();
let id = run(&mut s, "dimension.create", json!({"a":[100,100],"b":[172,100],"options":{"units":"pt","precision":0}}))["id"].as_u64().unwrap();
run(&mut s, "select.set", json!({"ids":[id]}));
let instance = run(&mut s, "symbol.new", json!({"name":"Measure"}))["id"].as_u64().unwrap();
run(&mut s, "select.set", json!({"ids":[instance]}));
run(&mut s, "object.expand", json!({}));
let root = s.doc().unwrap().selection.objects[0].0;
let mut recipes = 0;
s.doc().unwrap().doc.node(NodeId(root)).unwrap().walk(&mut |c| recipes += usize::from(c.extras().dimension.is_some()));
assert_eq!(recipes, 0);
stays_plain_art(&mut s, root);
}
/// Create Outlines on a dimension, or on its label alone, ends the link in the same undo step.
#[test]
fn outlining_a_dimension_or_its_label_ends_its_link() {
for label_alone in [false, true] {
let mut s = session();
let line = edge(&mut s);
let id = dim(&mut s, line);
let live = art(&s, id);
let label = {
let children = s.doc().unwrap().doc.node(NodeId(id)).unwrap().children().unwrap();
children.iter().find(|c| matches!(c.kind, NodeKind::Text(_))).unwrap().id
};
let target = if label_alone { label.0 } else { id };
run(&mut s, "select.set", json!({"ids":[target]}));
run(&mut s, "type.createOutlines", json!({}));
stays_plain_art(&mut s, id);
run(&mut s, "edit.undo", json!({}));
assert_eq!(art(&s, id), live, "one undo gives the live dimension back");
assert_eq!(text(&s, id), "10.00 in");
}
}
/// Outline Stroke on a dimension ends its link: the label stays type and the art does not redraw.
#[test]
fn outlining_a_dimensions_strokes_ends_its_link() {
let mut s = session();
let line = edge(&mut s);
let id = dim(&mut s, line);
let live = art(&s, id);
run(&mut s, "select.set", json!({"ids":[id]}));
run(&mut s, "object.path.outlineStroke", json!({}));
let plain = art(&s, id);
assert_eq!(plain.1, 1, "{plain:?}");
run(&mut s, "object.move", json!({"dx":30,"dy":0}));
assert_eq!(art(&s, id), plain);
assert!(!plain.2, "no recipe left");
run(&mut s, "edit.undo", json!({}));
run(&mut s, "edit.undo", json!({}));
assert_eq!(art(&s, id), live, "one undo gives the live dimension back");
}
/// Expand Appearance, which the menus offer for a dimension in place of Expand, ends its link too.
#[test]
fn expanding_a_dimensions_appearance_ends_its_link() {
let mut s = session();
let line = edge(&mut s);
let id = dim(&mut s, line);
let live = art(&s, id);
run(&mut s, "select.set", json!({"ids":[id]}));
assert_eq!(run(&mut s, "object.expand.info", json!({}))["appearance"], true);
run(&mut s, "effect.expandAppearance", json!({}));
stays_plain_art(&mut s, id);
run(&mut s, "edit.undo", json!({}));
assert_eq!(art(&s, id), live, "one undo gives the live dimension back");
assert_eq!(text(&s, id), "10.00 in");
}
/// Ungroup takes the group that holds the recipe away, so the parts stay ordinary art.
#[test]
fn an_ungrouped_dimension_stays_ordinary_art() {
let mut s = session();
let line = edge(&mut s);
let id = dim(&mut s, line);
let (nodes, ..) = art(&s, id);
run(&mut s, "select.set", json!({"ids":[id]}));
run(&mut s, "object.ungroup", json!({}));
assert!(s.doc().unwrap().doc.node(NodeId(id)).is_none());
let parts = s.doc().unwrap().selection.objects.clone();
assert_eq!(parts.len(), nodes - 1);
let boxes = |s: &Session| parts.iter().map(|p| s.doc().unwrap().doc.node(*p).unwrap().geometric_bounds().unwrap()).collect::<Vec<_>>();
let at = boxes(&s);
run(&mut s, "object.move", json!({"dx":30,"dy":0}));
for (a, b) in at.iter().zip(boxes(&s)) {
assert!((b.x0 - a.x0 - 30.0).abs() < 1e-6 && (b.y0 - a.y0).abs() < 1e-6, "{a:?} moved to {b:?}");
}
run(&mut s, "select.set", json!({"ids":[line]}));
run(&mut s, "object.scale", json!({"sx":200,"sy":100}));
assert!(!s.doc().unwrap().doc.dimensions);
}
#[test]
fn dimension_options_are_persistent_and_invalid_updates_report_errors() {
let mut s = session();
s.set_tool_option_cmd(&json!({"tool":"dimension","values":{"style":{"scale":"10:1","units":"mm","precision":3},"mode":"radial"}})).unwrap();
assert_eq!(s.tool_options_of("dimension")["style"]["scale"], "10:1");
assert!(s.set_tool_option_cmd(&json!({"tool":"dimension","values":{"style":{"scale":"1:0"}}})).is_err());
let o: vectorcraft_doc::dimension::DimensionOptions = serde_json::from_value(s.tool_options_of("dimension")["style"].clone()).unwrap();
assert_eq!(o.label(72.0, false).unwrap(), "2.540 mm");
let picas: vectorcraft_doc::dimension::DimensionOptions = serde_json::from_value(json!({"units":"pc","precision":1})).unwrap();
assert_eq!(picas.label(72.0, false).unwrap(), "6.0 pc");
let prefs = serde_json::to_value(&s.prefs).unwrap();
let mut fresh = Session::new();
fresh.prefs = serde_json::from_value(prefs).unwrap();
assert_eq!(fresh.tool_options_of("dimension")["style"], s.tool_options_of("dimension")["style"]);
}
#[test]
fn dimension_unlinked_geometry_scales_and_updates_its_label() {
let mut s = session();
let id = run(&mut s, "dimension.create", json!({"a":[100,100],"b":[172,100],"options":{"units":"in","precision":0}}))["id"].as_u64().unwrap();
run(&mut s, "object.scale", json!({"sx":200,"sy":200}));
assert_eq!(text(&s, id), "2 in");
}
#[test]
fn dimension_linked_angle_and_circle_update_and_reject_non_circles() {
let mut s = session();
let first = edge(&mut s);
let second = run(&mut s, "shape.line", json!({"x1":100,"y1":100,"x2":100,"y2":172}))["id"].as_u64().unwrap();
let id=run(&mut s,"dimension.create",json!({"mode":"angular","source":{"kind":"angle","first":{"node":first,"subpath":0,"segment":0},"second":{"node":second,"subpath":0,"segment":0}},"options":{"precision":1}}))["id"].as_u64().unwrap();
assert_eq!(text(&s, id), "90.0°");
run(&mut s, "select.set", json!({"ids":[second]}));
run(&mut s, "object.rotate", json!({"angle":45,"origin":[100,100]}));
assert!(["45.0°", "135.0°"].contains(&text(&s, id).as_str()));
let circle = run(&mut s, "shape.ellipse", json!({"x":100,"y":100,"width":144,"height":144}))["id"].as_u64().unwrap();
let id = run(
&mut s,
"dimension.create",
json!({"mode":"diameter","source":{"kind":"circle","node":circle},"options":{"units":"in","precision":0}}),
)["id"]
.as_u64()
.unwrap();
run(&mut s, "select.set", json!({"ids":[circle]}));
run(&mut s, "object.scale", json!({"sx":200,"sy":200}));
assert_eq!(text(&s, id), "4 in");
run(&mut s, "object.scale", json!({"sx":200,"sy":100}));
assert!(s.doc().unwrap().doc.node(NodeId(id)).is_none());
assert!(s.execute("dimension.create", &json!({"mode":"radius","source":{"kind":"circle","node":circle}})).is_err());
}
#[test]
fn dimension_style_update_keeps_links_and_can_be_undone() {
let mut s = session();
let line = edge(&mut s);
let id = dim(&mut s, line);
run(
&mut s,
"dimension.update",
json!({"ids":[id],"offset":-42,"options":{"units":"mm","precision":2,"hideUnits":true,"textPosition":"between","textAlignment":"right","lineColor":[10,20,30]}}),
);
assert_eq!(text(&s, id), "25.40");
let d = recipe(&s, id);
assert!(d.source.is_some());
assert_eq!(d.offset, -42.0);
run(&mut s, "edit.undo", json!({}));
assert_eq!(text(&s, id), "10.00 in");
}
#[test]
fn dimension_cannot_measure_its_own_generated_art() {
let mut s = session();
let line = edge(&mut s);
let id = dim(&mut s, line);
let child = s.doc().unwrap().doc.node(NodeId(id)).unwrap().children().unwrap()[0].id;
assert!(s.execute("dimension.create", &json!({"source":{"kind":"segment","edge":{"node":child,"subpath":0,"segment":0}}})).is_err());
}
#[test]
fn unknown_dimension_options_are_refused_by_commands_but_a_newer_file_still_opens() {
let mut s = session();
let line = edge(&mut s);
let source = json!({"kind":"segment","edge":{"node":line,"subpath":0,"segment":0}});
let bad = s.execute("dimension.create", &json!({"source":source,"options":{"units":"in","color":"red"}}));
assert!(bad.unwrap_err().to_string().contains("Unknown dimension option color"));
assert!(s.set_tool_option_cmd(&json!({"tool":"dimension","values":{"style":{"precison":2}}})).is_err());
let id = dim(&mut s, line);
let saved = vectorcraft_format::save(&s.doc().unwrap().doc, false);
let mut v: Value = serde_json::from_slice(&saved).unwrap();
// A later version's dimension carries fields this one doesn't know.
fn add_unknown(v: &mut Value) {
match v {
Value::Object(m) => {
if let Some(Value::Object(d)) = m.get_mut("dimension") {
d.insert("later".into(), json!(1));
if let Some(Value::Object(o)) = d.get_mut("options") {
o.insert("later".into(), json!(true));
}
}
m.values_mut().for_each(add_unknown);
}
Value::Array(a) => a.iter_mut().for_each(add_unknown),
_ => {}
}
}
add_unknown(&mut v);
let doc = vectorcraft_format::load(&serde_json::to_vec(&v).unwrap()).unwrap();
assert!(doc.node(NodeId(id)).unwrap().extras().dimension.is_some());
}
/// With Shift down, the dashed line from the first picked point follows the 45-degree step the
/// second click will pick (#2432), not the raw pointer.
#[test]
fn shift_shows_the_constrained_line_while_picking_the_second_point() {
use vectorcraft_engine::tools::Overlay;
let mut s = session();
s.select_tool("dimension", ViewInfo::default()).unwrap();
ev(&mut s, PointerKind::Down, 300.0, 300.0);
ev(&mut s, PointerKind::Up, 300.0, 300.0);
let end = |s: &mut Session, shift: bool| {
let e = PointerEvent::new(PointerKind::Move, 372.0, 310.0).with_mods(Mods { shift, ..Mods::default() });
s.pointer(&e, ViewInfo::default()).unwrap();
s.overlays(ViewInfo::default()).into_iter().find_map(|o| if let Overlay::Line { b, .. } = o { Some(b) } else { None }).unwrap()
};
assert_eq!(end(&mut s, false).y, 310.0);
assert!((end(&mut s, true).y - 300.0).abs() < 1e-9);
}
/// A click near a line, away from its anchors, snaps to the closest point of the line (#2432).
#[test]
fn clicking_near_a_line_snaps_the_measured_point_onto_it() {
let mut s = session();
edge(&mut s);
s.select_tool("dimension", ViewInfo::default()).unwrap();
for (x, y) in [(130.0, 103.0), (150.0, 97.0), (140.0, 130.0)] {
ev(&mut s, PointerKind::Down, x, y);
ev(&mut s, PointerKind::Up, x, y);
}
let id = s.doc().unwrap().selection.objects[0].0;
assert_eq!(text(&s, id), "20.00 pt");
}
/// Both picked points snap onto anchors a few pixels away, the rubber band and a ring show the
/// snap before the click, and the dimension stays linked to the anchors (#2516).
#[test]
fn both_points_snap_onto_anchors_with_a_visible_indicator() {
use vectorcraft_engine::tools::Overlay;
let mut s = session();
let line = edge(&mut s);
s.select_tool("dimension", ViewInfo::default()).unwrap();
ev(&mut s, PointerKind::Move, 103.0, 102.0);
let ring = |s: &mut Session| {
s.overlays(ViewInfo::default())
.into_iter()
.any(|o| matches!(o, Overlay::Anchor { p, size, .. } if size > 8.0 && p.x == 100.0 && p.y == 100.0))
};
assert!(ring(&mut s), "ring on the anchor before the first click");
ev(&mut s, PointerKind::Down, 103.0, 102.0);
ev(&mut s, PointerKind::Up, 103.0, 102.0);
ev(&mut s, PointerKind::Move, 169.0, 98.0);
let end = s.overlays(ViewInfo::default()).into_iter().find_map(|o| if let Overlay::Line { b, .. } = o { Some(b) } else { None }).unwrap();
assert_eq!((end.x, end.y), (172.0, 100.0), "rubber band ends on the anchor");
ev(&mut s, PointerKind::Down, 169.0, 98.0);
ev(&mut s, PointerKind::Up, 169.0, 98.0);
ev(&mut s, PointerKind::Down, 136.0, 130.0);
ev(&mut s, PointerKind::Up, 136.0, 130.0);
let id = s.doc().unwrap().selection.objects[0].0;
assert_eq!(text(&s, id), "72.00 pt");
run(&mut s, "select.set", json!({"ids":[line]}));
run(&mut s, "object.scale", json!({"sx":200,"sy":100}));
assert_eq!(text(&s, id), "144.00 pt", "linked to both anchors");
}
/// Smart Guides for the Linear picks: hovering away from any line, the first point slides into
/// line with another object's corner and its guides show before the click; the second slides onto
/// the construction guide through the first. The dimension measures between the snapped points.
#[test]
fn linear_picks_show_and_snap_to_smart_guides() {
use vectorcraft_engine::tools::Overlay;
let mut s = session();
run(&mut s, "shape.rectangle", json!({"x":300,"y":200,"width":80,"height":60}));
run(&mut s, "select.none", json!({}));
s.select_tool("dimension", ViewInfo::default()).unwrap();
let overlays = |s: &mut Session| s.overlays(ViewInfo::default());
// In line with the rectangle's top edge, 2.5 pt off it: the pick lands on its level.
ev(&mut s, PointerKind::Move, 250.0, 202.5);
let o = overlays(&mut s);
assert!(o.iter().any(|o| matches!(o, Overlay::Anchor { p, .. } if p.x == 250.0 && p.y == 200.0)), "the pick shows on the guide: {o:?}");
assert!(o.iter().any(|o| matches!(o, Overlay::Line { .. } | Overlay::Label { .. })), "its guides show before the click: {o:?}");
ev(&mut s, PointerKind::Down, 250.0, 202.5);
ev(&mut s, PointerKind::Up, 250.0, 202.5);
// Straight below the first point, 1.5 pt off: the rubber band ends on the vertical through it.
ev(&mut s, PointerKind::Move, 251.5, 260.0);
let end = overlays(&mut s).into_iter().find_map(|o| if let Overlay::Line { b, dashed: true, .. } = o { Some(b) } else { None }).unwrap();
assert_eq!((end.x, end.y), (250.0, 260.0), "on the construction guide");
ev(&mut s, PointerKind::Down, 251.5, 260.0);
ev(&mut s, PointerKind::Up, 251.5, 260.0);
ev(&mut s, PointerKind::Move, 220.0, 230.0);
assert!(!overlays(&mut s).iter().any(|o| matches!(o, Overlay::Label { .. })), "placing: the guides are gone");
ev(&mut s, PointerKind::Down, 220.0, 230.0);
ev(&mut s, PointerKind::Up, 220.0, 230.0);
let id = s.doc().unwrap().selection.objects[0].0;
assert_eq!(text(&s, id), "60.00 pt");
}
/// The page content streams of exported PDF `v`, inflated.
fn pdf_content(v: &Value) -> String {
let bytes = vectorcraft_format::base64_decode(v["dataBase64"].as_str().unwrap()).unwrap();
let mut out = String::new();
let mut rest = &bytes[..];
while let Some(at) = rest.windows(7).position(|w| w == b"stream\n" || w == b"stream\r") {
let body = &rest[at + 7..];
let body = body.strip_prefix(b"\n").unwrap_or(body);
let end = body.windows(9).position(|w| w == b"endstream").unwrap_or(body.len());
let mut raw = Vec::new();
if std::io::Read::read_to_end(&mut flate2::read::ZlibDecoder::new(&body[..end]), &mut raw).is_ok() {
out.push_str(&String::from_utf8_lossy(&raw));
}
rest = &body[(end + 9).min(body.len())..];
}
out
}
/// #3233: in a CMYK document a dimension's black lines and label print on the black plate only
/// (`0 0 0 1 K`), not as a four-ink rich black; other colours convert to CMYK; RGB documents keep
/// RGB.
#[test]
fn dimension_colours_follow_the_document_colour_model() {
let mut s = Session::new();
run(&mut s, "file.new", json!({"width":600,"height":500,"colorMode":"cmyk"}));
let line = edge(&mut s);
run(&mut s, "object.hide", json!({"ids":[line]}));
let id = dim(&mut s, line);
let colours = |s: &Session, id: u64| {
let mut v = Vec::new();
s.doc().unwrap().doc.node(NodeId(id)).unwrap().walk(&mut |n| {
v.extend([n.appearance.fill_paint(), n.appearance.stroke_paint()].iter().filter_map(|p| p.color()));
});
v
};
let black = colours(&s, id);
assert!(!black.is_empty() && black.iter().all(|c| *c == vectorcraft_color::Color::cmyk(0.0, 0.0, 0.0, 1.0)), "{black:?}");
let pdf = pdf_content(&run(&mut s, "document.exportPdf", json!({"preset":"PDF/X-4:2010"})));
// PDF/X-4 paints in the output intent's CMYK space: `/c0 CS 0 0 0 1 SCN` strokes, `scn` fills.
let operands = |op: &str| pdf.match_indices(op).map(|(i, _)| pdf[i.saturating_sub(7)..i].to_string()).collect::<Vec<_>>();
let (strokes, fills) = (operands(" SCN"), operands(" scn"));
assert!(!strokes.is_empty() && !fills.is_empty(), "{pdf}");
assert!(strokes.iter().chain(&fills).all(|v| v == "0 0 0 1"), "K-only colours: {strokes:?} {fills:?}");
run(&mut s, "dimension.update", json!({"ids":[id],"options":{"lineColor":[255,0,0]}}));
let red = colours(&s, id);
assert!(red.iter().any(|c| matches!(c, vectorcraft_color::Color::Cmyk { m, y, .. } if *m > 0.5 && *y > 0.5)), "{red:?}");
assert!(red.iter().all(|c| matches!(c, vectorcraft_color::Color::Cmyk { .. })), "{red:?}");
// RGB documents are unchanged.
let mut s = session();
let line = edge(&mut s);
let id = dim(&mut s, line);
assert!(colours(&s, id).iter().all(|c| *c == vectorcraft_color::Color::rgb8(0, 0, 0)));
// Document Color Mode redraws dimensions in the new mode: K-only black in CMYK, RGB black back
// in RGB (not a profile conversion of the old art).
let k = vectorcraft_color::Color::cmyk(0.0, 0.0, 0.0, 1.0);
run(&mut s, "file.documentColorMode", json!({"mode":"cmyk"}));
let after = colours(&s, id);
assert!(!after.is_empty() && after.iter().all(|c| *c == k), "{after:?}");
run(&mut s, "file.documentColorMode", json!({"mode":"rgb"}));
assert!(colours(&s, id).iter().all(|c| *c == vectorcraft_color::Color::rgb8(0, 0, 0)), "{:?}", colours(&s, id));
// A CMYK file saved with RGB dimension art (before dimensions followed the colour mode) opens
// with its dimensions redrawn in CMYK.
let mut old = (*s.doc().unwrap().doc).clone();
old.color_mode = vectorcraft_doc::ColorMode::Cmyk;
s.add_document(old, None);
let opened = colours(&s, id);
assert!(!opened.is_empty() && opened.iter().all(|c| *c == k), "{opened:?}");
// Only the label's characters in RGB (their fill is saved apart from the node's): redrawn too.
let mut old = (*s.doc().unwrap().doc).clone();
let label = {
let mut found = None;
old.node(NodeId(id)).unwrap().walk(&mut |n| {
if matches!(n.kind, vectorcraft_doc::NodeKind::Text(_)) {
found = Some(n.id);
}
});
found.expect("a dimension label")
};
if let Some(vectorcraft_doc::NodeKind::Text(t)) = old.node_mut(label).map(|n| &mut n.kind) {
for r in &mut t.runs {
r.style.fill = vectorcraft_color::Paint::solid(vectorcraft_color::Color::rgb8(0, 0, 0));
}
}
s.add_document(old, None);
let runs = |s: &Session| match &s.doc().unwrap().doc.node(label).unwrap().kind {
vectorcraft_doc::NodeKind::Text(t) => t.runs.iter().filter_map(|r| r.style.fill.color()).collect::<Vec<_>>(),
_ => Vec::new(),
};
assert!(!runs(&s).is_empty() && runs(&s).iter().all(|c| *c == k), "{:?}", runs(&s));
}