Files
vectorcraft/crates/engine/src/tests_scalestrokes.rs
T
arhammusheer 5beac4f122 #1244: smaller objects: Node 352 -> 152 bytes, NodeKind 200 -> 80, and a cheaper undo estimate
Rarely set fields move into one optional boxed NodeExtras, and large NodeKind payloads (images,
live shapes, blends, envelopes) are boxed. Every object, and every undo step that copies it,
shrinks: a 50k-path move keeps ~24.5 MB per step instead of ~40 MB. Files stay byte-identical
(Node keeps its flat JSON through a borrowed Serialize and a from-wire Deserialize, no flatten),
and loading is slightly faster. Undo and redo reuse their step's size estimate instead of
walking the document again (8.3 -> 5.6 ms for a 50k-path move).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmMmc1J8XeNLxhRihWv9cc

# Conflicts:
#	crates/engine/src/cmd/graph.rs
#	crates/svg/src/export.rs
2026-10-12 11:02:32 +07:00

245 lines
11 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.
//! Scale Strokes & Effects and Scale Corners: what scales with the art, from the `strokes` and
//! `corners` params or the preferences, and what the journal records.
use serde_json::{Value, json};
use vectorcraft_doc::{LiveShape, Node, NodeId, NodeKind};
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}"))
}
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()
}
/// A selected 100 pt square with a 4 pt stroke dashed [6, 2] from offset 3 and a drop shadow.
fn shadowed_dashed_square(s: &mut Session) -> NodeId {
let id = id_of(&run(s, "shape.rectangle", json!({"x": 50, "y": 50, "width": 100, "height": 100})));
run(s, "stroke.set", json!({"weight": 4, "dash": [6, 2], "dashOffset": 3}));
run(s, "effect.apply", json!({"effect": "stylize.dropShadow", "params": {"x": 7, "y": 5}}));
id
}
fn shadow(n: &Node, key: &str) -> f64 {
n.appearance.effects[0].params[key].as_f64().unwrap()
}
#[test]
fn scaling_200_percent_doubles_the_stroke_dashes_and_shadow() {
let mut s = session();
let id = shadowed_dashed_square(&mut s);
run(&mut s, "object.scale", json!({"sx": 200, "strokes": true}));
let n = node(&s, id);
let st = n.appearance.stroke().unwrap();
let dash = st.dash.as_ref().unwrap();
assert_eq!((st.width, dash.pattern.clone(), dash.offset), (8.0, vec![12.0, 4.0], 6.0));
assert_eq!((shadow(&n, "x"), shadow(&n, "y")), (14.0, 10.0));
// The blur was left at its default (5 pt): it scales too.
assert_eq!(shadow(&n, "blur"), 10.0);
}
#[test]
fn off_nothing_visual_scales() {
let mut s = session();
let id = shadowed_dashed_square(&mut s);
let before = node(&s, id).appearance;
run(&mut s, "object.scale", json!({"sx": 200, "strokes": false}));
let n = node(&s, id);
assert_eq!(n.appearance.stroke(), before.stroke());
assert_eq!(n.appearance.effects, before.effects);
assert!((n.geometric_bounds().unwrap().width() - 200.0).abs() < 1e-9);
}
#[test]
fn the_preference_applies_when_the_param_is_absent() {
let mut s = session();
let id = shadowed_dashed_square(&mut s);
run(&mut s, "prefs.set", json!({"key": "scaleStrokes", "value": false}));
run(&mut s, "object.scale", json!({"sx": 50}));
assert_eq!(node(&s, id).appearance.stroke_width(), 4.0);
run(&mut s, "prefs.set", json!({"key": "scaleStrokes", "value": true}));
run(&mut s, "object.scale", json!({"sx": 50}));
assert_eq!(node(&s, id).appearance.stroke_width(), 2.0);
}
#[test]
fn relative_distances_and_fill_and_stroke_effects() {
let mut s = session();
let id = id_of(&run(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 100, "height": 100})));
// Relative Roughen is a percentage of the size: it stays; an absolute one is a distance.
run(&mut s, "effect.apply", json!({"effect": "distort.roughen", "params": {"size": 5, "relative": true}}));
run(&mut s, "effect.apply", json!({"effect": "distort.zigZag", "params": {"size": 4, "relative": false}}));
run(&mut s, "effect.apply", json!({"effect": "path.offsetPath", "params": {"offset": 3}, "item": 1}));
run(&mut s, "object.transformEach", json!({"scaleH": 300, "scaleV": 300, "strokes": true}));
let a = node(&s, id).appearance;
assert_eq!(a.effects[0].params["size"], json!(5));
assert_eq!(a.effects[1].params["size"], json!(12.0));
assert_eq!(a.items[1].effects()[0].params["offset"], json!(9.0), "the stroke's own effect");
}
#[test]
fn transform_each_without_strokes_keeps_them() {
let mut s = session();
let id = shadowed_dashed_square(&mut s);
run(&mut s, "object.transformEach", json!({"scaleH": 200, "scaleV": 200, "strokes": false}));
let n = node(&s, id);
assert_eq!((n.appearance.stroke_width(), shadow(&n, "x")), (4.0, 7.0));
assert!((n.geometric_bounds().unwrap().width() - 200.0).abs() < 1e-9);
}
#[test]
fn off_compensates_the_type_stroke_on_scales_it_with_the_type() {
let mut s = session();
let id = id_of(&run(&mut s, "text.create", json!({"x": 20, "y": 100, "text": "Scale", "size": 24})));
run(&mut s, "select.set", json!({ "ids": [id.0] }));
run(&mut s, "paint.setStroke", json!({"color": "#ff0000"}));
run(&mut s, "stroke.set", json!({"weight": 2, "dash": [4, 2], "dashOffset": 1}));
let style = |s: &Session| match &node(s, id).kind {
NodeKind::Text(t) => (t.xf.as_coeffs()[0], t.runs[0].style.clone()),
_ => panic!("not type"),
};
run(&mut s, "object.scale", json!({"sx": 200, "strokes": false}));
let (k, st) = style(&s);
// Drawn in text space (×2), the weight and dashes look as before.
assert_eq!(k, 2.0);
let dash = st.stroke_dash.unwrap();
assert_eq!((st.stroke_width * k, dash.pattern[0] * k, dash.offset * k), (2.0, 4.0, 1.0));
run(&mut s, "object.scale", json!({"sx": 200, "strokes": true}));
let (k, st) = style(&s);
assert_eq!((k, st.stroke_width * k), (4.0, 4.0), "the type's scale doubles the stroke");
}
#[test]
fn group_appearance_scales_with_its_members() {
let mut s = session();
let a = id_of(&run(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 50, "height": 50})));
let b = id_of(&run(&mut s, "shape.rectangle", json!({"x": 60, "y": 0, "width": 50, "height": 50})));
run(&mut s, "select.set", json!({ "ids": [a.0, b.0] }));
let g = id_of(&run(&mut s, "object.group", json!({})));
run(&mut s, "effect.apply", json!({"ids": [g.0], "effect": "stylize.dropShadow", "params": {"x": 4, "y": 4}}));
run(&mut s, "object.scale", json!({"sx": 50, "strokes": true}));
let n = node(&s, g);
assert_eq!(shadow(&n, "x"), 2.0);
let NodeKind::Group { children, .. } = &n.kind else { panic!("not a group") };
assert_eq!(children[0].appearance.stroke_width(), 0.5);
}
#[test]
fn scale_corners_keeps_or_scales_live_corner_radii() {
let radius = |s: &Session, id| match node(s, id).live_shape() {
Some(LiveShape::Rectangle { radii, xf, .. }) => radii[0] * xf.as_coeffs()[0],
_ => panic!("not a live rectangle"),
};
let mut s = session();
let id = id_of(&run(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 100, "height": 100, "radius": 10})));
run(&mut s, "object.scale", json!({"sx": 200, "corners": false}));
assert!((radius(&s, id) - 10.0).abs() < 1e-9, "corners keep their size");
run(&mut s, "object.scale", json!({"sx": 200, "corners": true}));
assert!((radius(&s, id) - 20.0).abs() < 1e-9);
// The path follows the live shape.
let n = node(&s, id);
assert!((n.geometric_bounds().unwrap().width() - 400.0).abs() < 1e-6);
}
/// The extent along x and y of each corner arc of an upright live rounded rectangle's path.
fn corner_spans(n: &Node) -> Vec<(f64, f64)> {
let NodeKind::Path { path, .. } = &n.kind else { panic!("not a path") };
let a = &path.subpaths[0].anchors;
assert_eq!(a.len(), 8, "a rounded rectangle");
[(1, 2), (3, 4), (5, 6), (7, 0)].iter().map(|(i, j)| ((a[*j].p.x - a[*i].p.x).abs(), (a[*j].p.y - a[*i].p.y).abs())).collect()
}
/// Every corner is a circular arc of radius `r`.
fn round_corners(n: &Node, r: f64) -> bool {
corner_spans(n).iter().all(|(x, y)| (x - r).abs() < 1e-9 && (y - r).abs() < 1e-9)
}
#[test]
fn live_corners_stay_circular_when_a_rectangle_is_scaled_unevenly() {
// #291: a rounded square widened into a long rectangle keeps round corners.
let mut s = session();
let id = id_of(&run(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 100, "height": 100, "radius": 10})));
run(&mut s, "object.scale", json!({"sx": 300, "sy": 100, "corners": false}));
let n = node(&s, id);
assert!((n.geometric_bounds().unwrap().width() - 300.0).abs() < 1e-9);
assert!(round_corners(&n, 10.0), "{:?}", corner_spans(&n));
let Some(LiveShape::Rectangle { w, h, radii, .. }) = n.live_shape() else { panic!("not live") };
assert!((w - 300.0).abs() < 1e-9 && (h - 100.0).abs() < 1e-9 && (radii[0] - 10.0).abs() < 1e-9, "the panels read document units");
// Scale Corners on: the radius scales by the mean scale (√(2 × 0.5) = 1 here, 2 below).
run(&mut s, "object.scale", json!({"sx": 200, "sy": 50, "corners": true}));
assert!(round_corners(&node(&s, id), 10.0), "{:?}", corner_spans(&node(&s, id)));
run(&mut s, "object.scale", json!({"sx": 100, "sy": 400, "corners": true}));
assert!(round_corners(&node(&s, id), 20.0), "{:?}", corner_spans(&node(&s, id)));
// Undo brings each step back.
run(&mut s, "edit.undo", json!({}));
run(&mut s, "edit.undo", json!({}));
assert!(round_corners(&node(&s, id), 10.0));
run(&mut s, "edit.undo", json!({}));
assert!(round_corners(&node(&s, id), 10.0));
assert!((node(&s, id).geometric_bounds().unwrap().width() - 100.0).abs() < 1e-9);
}
#[test]
fn a_bounding_box_drag_keeps_live_corners_circular() {
// The Selection tool's handle drag previews `object.transform` from the shape as it began.
let mut s = session();
let id = id_of(&run(&mut s, "shape.rectangle", json!({"x": 50, "y": 50, "width": 100, "height": 100, "radius": 25})));
s.begin_interaction("Scale").unwrap();
for sx in [1.5, 2.5, 4.0] {
// Dragging the right-middle handle: scaled about the left edge.
s.preview("object.transform", &json!({"matrix": [sx, 0, 0, 1, 50.0 - 50.0 * sx, 0]})).unwrap();
}
s.commit_interaction().unwrap();
let n = node(&s, id);
let b = n.geometric_bounds().unwrap();
assert!((b.x0 - 50.0).abs() < 1e-9 && (b.width() - 400.0).abs() < 1e-9 && (b.height() - 100.0).abs() < 1e-9, "{b:?}");
assert!(round_corners(&n, 25.0), "{:?}", corner_spans(&n));
}
#[test]
fn the_journal_records_the_scale_options_used() {
let mut s = session();
shadowed_dashed_square(&mut s);
run(&mut s, "prefs.set", json!({"values": {"scaleStrokes": false, "scaleCorners": true}}));
run(&mut s, "object.scale", json!({"sx": 150}));
let (cmd, p) = s.journal.last().unwrap().clone();
assert_eq!(cmd, "object.scale");
assert_eq!(p, json!({"sx": 150, "strokes": false, "corners": true}));
// An explicit value stays; moves don't scale, so they record nothing.
run(&mut s, "object.scale", json!({"sx": 150, "strokes": true}));
assert_eq!(s.journal.last().unwrap().1["strokes"], json!(true));
run(&mut s, "object.move", json!({"dx": 5, "dy": 0}));
assert_eq!(s.journal.last().unwrap().1, json!({"dx": 5, "dy": 0}));
// A drag's preview is journaled at commit with the options too.
s.begin_interaction("Scale").unwrap();
s.preview("object.transform", &json!({"matrix": [2, 0, 0, 2, 0, 0]})).unwrap();
s.commit_interaction().unwrap();
let (cmd, p) = s.journal.last().unwrap().clone();
assert_eq!((cmd.as_str(), &p["strokes"], &p["corners"]), ("object.transform", &json!(false), &json!(true)));
}
#[test]
fn replaying_the_journal_entry_scales_the_same_way() {
let mut s = session();
let id = shadowed_dashed_square(&mut s);
run(&mut s, "prefs.set", json!({"key": "scaleStrokes", "value": true}));
run(&mut s, "object.scale", json!({"sx": 200}));
let (cmd, p) = s.journal.last().unwrap().clone();
run(&mut s, "edit.undo", json!({}));
run(&mut s, "prefs.set", json!({"key": "scaleStrokes", "value": false}));
run(&mut s, &cmd, p);
assert_eq!(node(&s, id).appearance.stroke_width(), 8.0);
}