mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-27 19:27:21 -06:00
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>
190 lines
8.5 KiB
Rust
190 lines
8.5 KiB
Rust
//! 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);
|
||
}
|