Files
vectorcraft/crates/engine/src/tests_scalestrokes.rs
T
bflatasticandClaude Opus 5.5 5afc78a100 M3.66: Scale Strokes & Effects scales dashes and effect distances; Scale Corners works
Scaling with Scale Strokes & Effects on now scales stroke weights, dash
lengths and dash offsets and every distance parameter of object, fill and
stroke effects (groups and layers too), from new length metadata in the
effects catalogue. Off, nothing painted changes size and type keeps its
character strokes' weight. Scale Corners off keeps live corner radii.
object.scale/transform/transformEach take strokes?/corners? (default: the
preferences) and journal the values they used. The Scale dialog and a new
Transform Each dialog get both checkboxes, kept as the preferences on OK;
the panels' toggles go through prefs.set.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 03:59:37 +07:00

190 lines
8.5 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).kind {
NodeKind::Path { live: 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);
}
#[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);
}