360 lines
18 KiB
Rust
360 lines
18 KiB
Rust
//! Use Preview Bounds: the Transform panel values, the bounding box, Align and Distribute measure
|
||
//! visual bounds (strokes included) when it is on.
|
||
|
||
use serde_json::{Value, json};
|
||
use vectorcraft_doc::NodeId;
|
||
use vectorcraft_geom::Rect;
|
||
use vectorcraft_tools::{PointerEvent, PointerKind};
|
||
|
||
use super::*;
|
||
|
||
fn session() -> Session {
|
||
let mut s = Session::new();
|
||
s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
|
||
s
|
||
}
|
||
|
||
fn run(s: &mut Session, id: &str, p: Value) -> Value {
|
||
s.execute(id, &p).unwrap_or_else(|e| panic!("{id}: {e}"))
|
||
}
|
||
|
||
/// A rectangle with a `weight` pt stroke (0: no stroke).
|
||
fn rect(s: &mut Session, x: f64, w: f64, weight: f64) -> NodeId {
|
||
let id = NodeId(run(s, "shape.rectangle", json!({"x": x, "y": 100, "width": w, "height": 50}))["id"].as_u64().unwrap());
|
||
if weight > 0.0 {
|
||
run(s, "stroke.set", json!({"weight": weight}));
|
||
} else {
|
||
run(s, "paint.setStroke", json!({"none": true}));
|
||
}
|
||
id
|
||
}
|
||
|
||
fn geometric(s: &Session, id: NodeId) -> Rect {
|
||
s.doc().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap()
|
||
}
|
||
|
||
fn preview(s: &mut Session, on: bool) {
|
||
run(s, "prefs.set", json!({"key": "usePreviewBounds", "value": on}));
|
||
}
|
||
|
||
/// Two rectangles: one with a 10 pt stroke at x = 100, an unstroked one at x = 20; align left.
|
||
fn aligned_left(on: bool, p: Value) -> f64 {
|
||
let mut s = session();
|
||
let a = rect(&mut s, 100.0, 50.0, 10.0);
|
||
rect(&mut s, 20.0, 50.0, 0.0);
|
||
preview(&mut s, on);
|
||
run(&mut s, "select.all", json!({}));
|
||
let mut p = p;
|
||
p["horizontal"] = json!("left");
|
||
run(&mut s, "object.align", p);
|
||
geometric(&s, a).x0
|
||
}
|
||
|
||
#[test]
|
||
fn align_left_differs_by_half_the_stroke() {
|
||
assert_eq!(aligned_left(false, json!({})), 20.0);
|
||
// The 10 pt stroke's outer edge lines up with the other object: the path sits 5 pt in.
|
||
assert_eq!(aligned_left(true, json!({})), 25.0);
|
||
// `bounds` overrides the preference either way.
|
||
assert_eq!(aligned_left(true, json!({"bounds": "geometric"})), 20.0);
|
||
assert_eq!(aligned_left(false, json!({"bounds": "preview"})), 25.0);
|
||
let mut s = session();
|
||
rect(&mut s, 0.0, 10.0, 0.0);
|
||
assert!(s.execute("object.align", &json!({"horizontal": "left", "bounds": "visual"})).is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn distribute_spacing_measures_preview_bounds() {
|
||
let mut s = session();
|
||
let a = rect(&mut s, 0.0, 50.0, 10.0);
|
||
let b = rect(&mut s, 200.0, 50.0, 10.0);
|
||
preview(&mut s, true);
|
||
run(&mut s, "select.set", json!({"ids": [a.0, b.0]}));
|
||
run(&mut s, "object.distributeSpacing", json!({"axis": "horizontal", "spacing": 0}));
|
||
// The strokes touch: the paths are a stroke weight apart.
|
||
assert_eq!(geometric(&s, b).x0 - geometric(&s, a).x1, 10.0);
|
||
}
|
||
|
||
#[test]
|
||
fn the_w_field_sets_the_visual_width() {
|
||
let mut s = session();
|
||
let id = rect(&mut s, 100.0, 100.0, 10.0);
|
||
preview(&mut s, true);
|
||
assert_eq!(s.transform_bounds(&[id]).unwrap().width(), 110.0);
|
||
// Strokes scale: the whole visual box doubles, from its left edge.
|
||
run(&mut s, "object.setBounds", json!({"width": 220, "reference": 0, "proportional": true, "strokes": true}));
|
||
let v = s.transform_bounds(&[id]).unwrap();
|
||
assert!((v.width() - 220.0).abs() < 1e-9 && (v.x0 - 95.0).abs() < 1e-9, "{v:?}");
|
||
assert!((geometric(&s, id).width() - 200.0).abs() < 1e-9);
|
||
run(&mut s, "edit.undo", json!({}));
|
||
// Strokes kept: the path takes the difference.
|
||
run(&mut s, "object.setBounds", json!({"width": 220, "reference": 0, "strokes": false}));
|
||
let v = s.transform_bounds(&[id]).unwrap();
|
||
assert!((v.width() - 220.0).abs() < 1e-9 && (v.x0 - 95.0).abs() < 1e-9, "{v:?}");
|
||
assert!((geometric(&s, id).width() - 210.0).abs() < 1e-9);
|
||
// X places the visual box's reference point.
|
||
run(&mut s, "object.setBounds", json!({"x": 0, "reference": 0}));
|
||
assert!((s.transform_bounds(&[id]).unwrap().x0).abs() < 1e-9);
|
||
assert!((geometric(&s, id).x0 - 5.0).abs() < 1e-9);
|
||
}
|
||
|
||
#[test]
|
||
fn a_size_below_what_a_kept_stroke_adds_gives_the_path_no_width_or_height() {
|
||
// The 10 pt stroke adds 10 pt to the width and the height. With strokes kept, a width or height
|
||
// of 0 or more below 10 pt gives the path no width or height, and the visual box is the
|
||
// stroke's 10 pt: the path does not turn over. A size of 10 pt or more scales the path to it,
|
||
// and a negative size turns the path over.
|
||
let mut wrong = vec![];
|
||
for (key, size, path, visual) in [
|
||
("width", 5.0, 0.0, 10.0),
|
||
("width", 0.0, 0.0, 10.0),
|
||
("width", 10.0, 0.0, 10.0),
|
||
("width", 30.0, 20.0, 30.0),
|
||
("width", -10.0, 20.0, 30.0),
|
||
("height", 5.0, 0.0, 10.0),
|
||
("height", 0.0, 0.0, 10.0),
|
||
("height", 10.0, 0.0, 10.0),
|
||
("height", 30.0, 20.0, 30.0),
|
||
("height", -10.0, 20.0, 30.0),
|
||
] {
|
||
let mut s = session();
|
||
let id = rect(&mut s, 100.0, 100.0, 10.0);
|
||
preview(&mut s, true);
|
||
run(&mut s, "object.setBounds", json!({key: size, "reference": 0, "strokes": false}));
|
||
let (g, v) = (geometric(&s, id), s.transform_bounds(&[id]).unwrap());
|
||
let (g, v) = if key == "width" { (g.width(), v.width()) } else { (g.height(), v.height()) };
|
||
if (g - path).abs() > 1e-9 || (v - visual).abs() > 1e-9 {
|
||
wrong.push(format!("{key} {size}: path {g}, visual box {v}"));
|
||
}
|
||
}
|
||
assert!(wrong.is_empty(), "{wrong:?}");
|
||
}
|
||
|
||
#[test]
|
||
fn set_bounds_gives_one_path_sizes_out_of_proportion_when_strokes_scale() {
|
||
// The 100 × 50 rectangle with a 10 pt stroke: its visual box runs from (95, 95) to (205, 155).
|
||
// The stroke scales by the square root of the product of the two scales, so the width it adds
|
||
// depends on the height too. Set from the top-left corner, the visual box is 220 × 60, and
|
||
// 5 × 1000 with the path the same way round.
|
||
let mut wrong = vec![];
|
||
for (width, height) in [(220.0, 60.0), (5.0, 1000.0)] {
|
||
let mut s = session();
|
||
let id = rect(&mut s, 100.0, 100.0, 10.0);
|
||
preview(&mut s, true);
|
||
run(&mut s, "object.setBounds", json!({"width": width, "height": height, "reference": 0, "strokes": true}));
|
||
let v = s.transform_bounds(&[id]).unwrap();
|
||
let Some(vectorcraft_doc::LiveShape::Rectangle { xf, .. }) = s.doc().unwrap().doc.node(id).unwrap().live_shape() else {
|
||
panic!("the rectangle")
|
||
};
|
||
if !close(v, Rect::new(95.0, 95.0, 95.0 + width, 95.0 + height)) || xf.as_coeffs()[0] <= 0.0 {
|
||
wrong.push(format!("{width} × {height}: {v:?}, {xf:?}"));
|
||
}
|
||
}
|
||
assert!(wrong.is_empty(), "{wrong:?}");
|
||
}
|
||
|
||
fn close(a: Rect, b: Rect) -> bool {
|
||
[(a.x0, b.x0), (a.y0, b.y0), (a.x1, b.x1), (a.y1, b.y1)].iter().all(|(x, y)| (x - y).abs() < 1e-9)
|
||
}
|
||
|
||
/// Two rectangles 50 pt high at y 100, both selected with Use Preview Bounds on: one from x 0 to
|
||
/// 10 with a 2 pt stroke, which sets the left edge of the paths, and one from x 3 to 10 with a
|
||
/// 10 pt stroke, which sets every edge of the visual box, (-2, 95) to (15, 155).
|
||
fn thin_and_thick() -> (Session, [NodeId; 2]) {
|
||
let mut s = session();
|
||
let ids = [rect(&mut s, 0.0, 10.0, 2.0), rect(&mut s, 3.0, 7.0, 10.0)];
|
||
preview(&mut s, true);
|
||
run(&mut s, "select.set", json!({"ids": [ids[0].0, ids[1].0]}));
|
||
assert!(close(s.transform_bounds(&ids).unwrap(), Rect::new(-2.0, 95.0, 15.0, 155.0)));
|
||
(s, ids)
|
||
}
|
||
|
||
#[test]
|
||
fn set_bounds_measures_the_margins_on_each_side_object_by_object() {
|
||
// Set to 37 wide from the left edge with strokes kept, the paths scale by 3.1, and the left edge
|
||
// of the visual box stays at -2.
|
||
let (mut s, ids) = thin_and_thick();
|
||
run(&mut s, "object.setBounds", json!({"width": 37, "reference": 0, "strokes": false}));
|
||
let v = s.transform_bounds(&ids).unwrap();
|
||
assert!(close(v, Rect::new(-2.0, 95.0, 35.0, 155.0)), "{v:?}");
|
||
assert!(close(geometric(&s, ids[0]), Rect::new(-1.0, 100.0, 30.0, 150.0)), "{:?}", geometric(&s, ids[0]));
|
||
assert!(close(geometric(&s, ids[1]), Rect::new(8.3, 100.0, 30.0, 150.0)), "{:?}", geometric(&s, ids[1]));
|
||
}
|
||
|
||
#[test]
|
||
fn set_bounds_scales_both_ways_at_once_when_strokes_scale_and_the_sizes_are_out_of_proportion() {
|
||
// Each stroke scales by the square root of the product of the two scales, so the width it
|
||
// adds depends on the height too. The visual box is 40 × 100.
|
||
let (mut s, ids) = thin_and_thick();
|
||
run(&mut s, "object.setBounds", json!({"width": 40, "height": 100, "reference": 0, "strokes": true}));
|
||
let v = s.transform_bounds(&ids).unwrap();
|
||
assert!(close(v, Rect::new(-2.0, 95.0, 38.0, 195.0)), "{v:?}");
|
||
}
|
||
|
||
#[test]
|
||
fn set_bounds_measures_paths_turned_together_square_to_their_box() {
|
||
// Turned 30° together, the paths are measured square to their turned box: it is 37 wide, and
|
||
// its top-left corner stays where it was.
|
||
let (mut s, ids) = thin_and_thick();
|
||
run(&mut s, "object.rotate", json!({"angle": 30}));
|
||
let before = s.transform_box(&ids).unwrap();
|
||
run(&mut s, "object.setBounds", json!({"width": 37, "reference": 0, "strokes": false}));
|
||
let after = s.transform_box(&ids).unwrap();
|
||
assert!((after.angle - 30.0).abs() < 1e-9 && (after.rect.width() - 37.0).abs() < 1e-9, "{after:?}");
|
||
assert!((after.rect.height() - 60.0).abs() < 1e-9, "{after:?}");
|
||
assert!(after.reference_point(0).distance(before.reference_point(0)) < 1e-9, "{before:?} {after:?}");
|
||
}
|
||
|
||
#[test]
|
||
fn a_size_below_what_the_kept_strokes_of_several_paths_add_gives_them_no_width() {
|
||
// With no width, the paths have a visual box 10 pt wide, the width of the thick stroke. A width
|
||
// of 0 or more below 10 gives both paths no width, and the left edge of the visual box stays at
|
||
// -2. A negative width turns the paths over, as it does one path.
|
||
let mut wrong = vec![];
|
||
for (width, paths, visual) in [
|
||
(8.0, [Rect::new(3.0, 100.0, 3.0, 150.0); 2], Rect::new(-2.0, 95.0, 8.0, 155.0)),
|
||
(0.0, [Rect::new(3.0, 100.0, 3.0, 150.0); 2], Rect::new(-2.0, 95.0, 8.0, 155.0)),
|
||
(-37.0, [Rect::new(-44.0, 100.0, 0.0, 150.0), Rect::new(-44.0, 100.0, -13.2, 150.0)], Rect::new(-49.0, 95.0, 1.0, 155.0)),
|
||
] {
|
||
let (mut s, ids) = thin_and_thick();
|
||
run(&mut s, "object.setBounds", json!({"width": width, "reference": 0, "strokes": false}));
|
||
let (g, v) = (ids.map(|id| geometric(&s, id)), s.transform_bounds(&ids).unwrap());
|
||
if !close(g[0], paths[0]) || !close(g[1], paths[1]) || !close(v, visual) {
|
||
wrong.push(format!("width {width}: paths {g:?}, visual box {v:?}"));
|
||
}
|
||
}
|
||
assert!(wrong.is_empty(), "{wrong:?}");
|
||
}
|
||
|
||
#[test]
|
||
fn set_bounds_leaves_the_height_of_lines_with_no_height() {
|
||
// Horizontal lines at y 100 with square caps, from x 0 to 10 with a 2 pt stroke and from x 3 to
|
||
// 10 with a 10 pt stroke: the visual box is (-2, 95) to (15, 105). They have no height to
|
||
// scale, so the height stays 10; the width becomes 37 from the left edge.
|
||
let mut s = session();
|
||
let mut ids = vec![];
|
||
for (x, weight) in [(0.0, 2.0), (3.0, 10.0)] {
|
||
ids.push(NodeId(run(&mut s, "shape.line", json!({"x1": x, "y1": 100, "x2": 10, "y2": 100}))["id"].as_u64().unwrap()));
|
||
run(&mut s, "stroke.set", json!({"weight": weight, "cap": "square"}));
|
||
}
|
||
preview(&mut s, true);
|
||
run(&mut s, "select.set", json!({"ids": [ids[0].0, ids[1].0]}));
|
||
assert!(close(s.transform_bounds(&ids).unwrap(), Rect::new(-2.0, 95.0, 15.0, 105.0)), "{:?}", s.transform_bounds(&ids));
|
||
run(&mut s, "object.setBounds", json!({"width": 37, "height": 30, "reference": 0, "strokes": false}));
|
||
let v = s.transform_bounds(&ids).unwrap();
|
||
assert!(close(v, Rect::new(-2.0, 95.0, 35.0, 105.0)), "{v:?}");
|
||
assert!(close(geometric(&s, ids[1]), Rect::new(8.3, 100.0, 30.0, 100.0)), "{:?}", geometric(&s, ids[1]));
|
||
}
|
||
|
||
/// The rectangles of [`thin_and_thick`] in a group, the thick one in a group of its own inside it
|
||
/// when `nested`, selected with an unstroked rectangle from x 100 to 110 when `plain`. Returns the
|
||
/// selected objects.
|
||
fn grouped(nested: bool, plain: bool) -> (Session, Vec<NodeId>) {
|
||
let (mut s, [thin, thick]) = thin_and_thick();
|
||
let mut members = vec![thin.0, thick.0];
|
||
if nested {
|
||
run(&mut s, "select.set", json!({"ids": [thick.0]}));
|
||
members[1] = run(&mut s, "object.group", json!({}))["id"].as_u64().unwrap();
|
||
}
|
||
run(&mut s, "select.set", json!({"ids": members}));
|
||
let mut ids = vec![NodeId(run(&mut s, "object.group", json!({}))["id"].as_u64().unwrap())];
|
||
if plain {
|
||
ids.push(rect(&mut s, 100.0, 10.0, 0.0));
|
||
}
|
||
run(&mut s, "select.set", json!({"ids": ids.iter().map(|id| id.0).collect::<Vec<_>>()}));
|
||
(s, ids)
|
||
}
|
||
|
||
#[test]
|
||
fn set_bounds_measures_the_members_of_groups_one_by_one() {
|
||
// With the unstroked rectangle, the visual box runs from (-2, 95) to (110, 155). Set to 224 wide
|
||
// from its left edge, it is 224 wide and its left edge stays at -2, with strokes kept or
|
||
// scaling, and with the thick rectangle in a group of its own. The group alone, (-2, 95) to
|
||
// (15, 155), set to 37 wide is 37 wide.
|
||
let mut wrong = vec![];
|
||
for (nested, plain, width, strokes) in
|
||
[(false, true, 224.0, false), (false, true, 224.0, true), (true, true, 224.0, false), (false, false, 37.0, false)]
|
||
{
|
||
let (mut s, ids) = grouped(nested, plain);
|
||
run(&mut s, "object.setBounds", json!({"width": width, "reference": 0, "strokes": strokes}));
|
||
let v = s.transform_bounds(&ids).unwrap();
|
||
if !close(v, Rect::new(-2.0, 95.0, width - 2.0, 155.0)) {
|
||
wrong.push(format!("nested {nested}, plain {plain}, strokes {strokes}: {v:?}"));
|
||
}
|
||
}
|
||
// The group with a 1 pt stroke of its own, which reaches 5 pt at its miter limit of 10, around
|
||
// each member: the visual box runs from (-7, 90) to (110, 160). Set to 224 wide from its left
|
||
// edge, it is 224 wide and its left edge stays at -7, with strokes kept or scaling.
|
||
for strokes in [false, true] {
|
||
let (mut s, _) = thin_and_thick();
|
||
let group = NodeId(run(&mut s, "object.group", json!({}))["id"].as_u64().unwrap());
|
||
run(&mut s, "appearance.addStroke", json!({"ids": [group.0]}));
|
||
let ids = [group, rect(&mut s, 100.0, 10.0, 0.0)];
|
||
run(&mut s, "select.set", json!({"ids": [ids[0].0, ids[1].0]}));
|
||
assert!(close(s.transform_bounds(&ids).unwrap(), Rect::new(-7.0, 90.0, 110.0, 160.0)), "{:?}", s.transform_bounds(&ids));
|
||
run(&mut s, "object.setBounds", json!({"width": 224, "reference": 0, "strokes": strokes}));
|
||
let v = s.transform_bounds(&ids).unwrap();
|
||
if !close(v, Rect::new(-7.0, 90.0, 217.0, 160.0)) {
|
||
wrong.push(format!("the group with a stroke, strokes {strokes}: {v:?}"));
|
||
}
|
||
}
|
||
// A group of a rectangle from x 0 to 100 with a 2 pt stroke and one from x 40 to 60 with a 10 pt
|
||
// stroke: its visual box, (-1, 95) to (101, 155), set to 11 wide is 11 wide.
|
||
let mut s = session();
|
||
let ids = [rect(&mut s, 0.0, 100.0, 2.0), rect(&mut s, 40.0, 20.0, 10.0)];
|
||
preview(&mut s, true);
|
||
run(&mut s, "select.set", json!({"ids": [ids[0].0, ids[1].0]}));
|
||
let group = NodeId(run(&mut s, "object.group", json!({}))["id"].as_u64().unwrap());
|
||
run(&mut s, "object.setBounds", json!({"width": 11, "reference": 0, "strokes": false}));
|
||
let v = s.transform_bounds(&[group]).unwrap();
|
||
if !close(v, Rect::new(-1.0, 95.0, 10.0, 155.0)) {
|
||
wrong.push(format!("the 0 to 100 and 40 to 60 group: {v:?}"));
|
||
}
|
||
assert!(wrong.is_empty(), "{wrong:?}");
|
||
}
|
||
|
||
#[test]
|
||
fn set_bounds_measures_clip_groups_and_groups_with_effects_as_one_object() {
|
||
// A clip group is measured by its clipping path, a rectangle from x 0 to 10, though its content
|
||
// with a 10 pt stroke reaches past it: with the unstroked rectangle from x 100 to 110, set to
|
||
// 224 wide from the left edge, the visual box runs from x 0 to 224.
|
||
let mut s = session();
|
||
let content = rect(&mut s, 3.0, 17.0, 10.0);
|
||
let path = rect(&mut s, 0.0, 10.0, 0.0);
|
||
run(&mut s, "select.set", json!({"ids": [content.0, path.0]}));
|
||
let clip = NodeId(run(&mut s, "object.clippingMask.make", json!({}))["id"].as_u64().unwrap());
|
||
let plain = rect(&mut s, 100.0, 10.0, 0.0);
|
||
preview(&mut s, true);
|
||
run(&mut s, "select.set", json!({"ids": [clip.0, plain.0]}));
|
||
run(&mut s, "object.setBounds", json!({"width": 224, "reference": 0, "strokes": false}));
|
||
let v = s.transform_bounds(&[clip, plain]).unwrap();
|
||
assert!(close(v, Rect::new(0.0, 100.0, 224.0, 150.0)), "the clip group: {v:?}");
|
||
// A group with a drop shadow of its own is measured as one object, as before. Its 2 pt margin on
|
||
// the left is measured from the group's left edge, though the stroke of the thick rectangle at
|
||
// x 3 paints it, so the visual box is 223 wide and its left edge moves to -1.
|
||
let (mut s, ids) = grouped(false, true);
|
||
run(&mut s, "effect.apply", json!({"effect": "stylize.dropShadow", "ids": [ids[0].0]}));
|
||
run(&mut s, "select.set", json!({"ids": [ids[0].0, ids[1].0]}));
|
||
run(&mut s, "object.setBounds", json!({"width": 224, "reference": 0, "strokes": false}));
|
||
let v = s.transform_bounds(&ids).unwrap();
|
||
assert!(close(v, Rect::new(-1.0, 95.0, 222.0, 155.0)), "the group with a shadow: {v:?}");
|
||
}
|
||
|
||
#[test]
|
||
fn the_bounding_box_handles_sit_on_the_preview_bounds() {
|
||
// Drag from the visual right edge (10 pt outside the path) 100 pt to the right.
|
||
let drag = |on: bool| {
|
||
let mut s = session();
|
||
let id = rect(&mut s, 100.0, 100.0, 20.0);
|
||
preview(&mut s, on);
|
||
let v = ViewInfo::default();
|
||
s.select_tool("selection", v).unwrap();
|
||
for (k, x) in [(PointerKind::Down, 210.0), (PointerKind::Drag, 310.0), (PointerKind::Up, 310.0)] {
|
||
s.pointer(&PointerEvent::new(k, x, 125.0), v).unwrap();
|
||
}
|
||
geometric(&s, id)
|
||
};
|
||
assert!(drag(true).width() > 150.0, "a scale from the visual edge");
|
||
assert_eq!(drag(false).width(), 100.0, "off, the point is no handle");
|
||
}
|