Files
vectorcraft/crates/engine/src/tests_scalestrokes.rs
T
BFlatandClaude Opus 5.5 deaec7fc71 Fix #291: live corners stay circular when a rectangle is scaled unevenly
A live rectangle kept every transform in its `xf`, so a non-uniform scale
(a bounding-box drag, Object > Transform > Scale) stretched the rounded
corners built in untransformed space into elliptical arcs. Scale Corners
off divided the radii by the mean scale, which only compensated uniform
scales.

LiveShape::transform now folds a scale along the rectangle's own sides
(any rotation or reflection, no shear) into w/h and keeps only rotation,
reflection and position in `xf`, so the radii are document lengths and the
path is regenerated with circular corners. Radii scale by the mean scale
(sqrt|det|), which keep_corners undoes for Scale Corners off. Shears and
plain moves keep composing into `xf` as before, so files saved with a
scale in `xf` keep their exact geometry until next scaled or rotated.
A corner-widget drag starts from the radius as drawn (clamped to fit).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 20:25:41 +07:00

245 lines
12 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);
}
/// 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 NodeKind::Path { live: Some(LiveShape::Rectangle { w, h, radii, .. }), .. } = &n.kind 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);
}