Shapes: a rectangle with a negative size extends from its origin like the other shapes (#4203) (#4204)

rounded_rect took the absolute width/height without moving the origin, so
shape.create {"kind":"rect","rect":[50,10,-20,30]} covered x 50..70 while an
ellipse or polygon with the same rect covered 30..50. Normalise (x, w) and
(y, h) for negative sizes instead. The Shape tool drag already normalises its
rect, so interactive drawing is unchanged.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
David Mann
2026-10-12 21:17:38 -04:00
committed by GitHub
co-authored by Claude Opus 5.5
parent e4f15eecdf
commit 72c8f58e11
2 changed files with 43 additions and 2 deletions
+25
View File
@@ -796,6 +796,31 @@ fn shape_info_bounds_of_curved_shapes_are_exact() {
}
}
#[test]
fn shape_create_negative_size_rect_covers_the_ellipse_box() {
// Issue #4203: a rectangle with a negative width spanned x 50..70 while the ellipse and
// polygon with the same `rect` spanned 30..50.
for depth in [8, 16, 32] {
let mut s = session(100, 60, depth);
let ell = s.execute("shape.create", json!({"kind": "ellipse", "rect": [50, 10, -20, 30], "fill": "#0000ff"})).unwrap()["bounds"].clone();
assert_eq!(ell, json!([30, 10, 20, 30]), "{depth}-bit");
for params in [
json!({"kind": "rect", "rect": [50, 10, -20, 30], "fill": "#0000ff"}),
json!({"kind": "roundedRect", "rect": [50, 10, -20, 30], "radii": 5, "fill": "#0000ff"}),
json!({"kind": "rect", "rect": [50, 40, -20, -30], "fill": "#0000ff"}),
] {
let r = s.execute("shape.create", params.clone()).unwrap();
assert_eq!(r["bounds"], ell, "{params} at {depth}-bit");
let sh = shape(&s, r["layer"].as_u64().unwrap());
let c = sh.cache.as_ref().unwrap();
let fmt = c.format();
let alpha = |x: i32, y: i32| photocraft_raster::to_rgba(&fmt, &c.read_region(photocraft_doc::Rect::from_xywh(x, y, 1, 1)))[3];
assert!(alpha(40, 25) > 0.99, "{params} at {depth}-bit");
assert!(alpha(60, 25) < 0.01, "{params} at {depth}-bit");
}
}
}
/// Two overlapping 40×40 rectangle shape layers ("Rectangle 1" black below, "Rectangle 2" grey
/// above, overlapping by 20×20) with both selected, like the Layers panel in #2400.
fn two_selected_rects(depth: u32) -> (Session, u64, u64) {
+18 -2
View File
@@ -29,9 +29,11 @@ pub fn clamp_radii(w: f64, h: f64, radii: [f64; 4]) -> [f64; 4] {
r.map(|v| v * f)
}
/// Rectangle with corner radii `[top-left, top-right, bottom-right, bottom-left]`.
/// Rectangle with corner radii `[top-left, top-right, bottom-right, bottom-left]`. A negative
/// width or height extends from the origin to the left or up, like [`ellipse`] and [`polygon`].
pub fn rounded_rect(x: f64, y: f64, w: f64, h: f64, radii: [f64; 4]) -> Path {
let (w, h) = (w.abs(), h.abs());
let (x, w) = if w < 0.0 { (x + w, -w) } else { (x, w) };
let (y, h) = if h < 0.0 { (y + h, -h) } else { (y, h) };
let [tl, tr, br, bl] = clamp_radii(w, h, radii);
if tl <= 0.0 && tr <= 0.0 && br <= 0.0 && bl <= 0.0 {
return rect(x, y, w, h);
@@ -143,6 +145,20 @@ mod tests {
assert_eq!(clamp_radii(10.0, 10.0, [10.0; 4]), [5.0; 4]);
}
#[test]
fn negative_size_rect_extends_from_its_origin_like_the_ellipse() {
// Issue #4203: a negative width/height drew the rectangle on the far side of its origin.
let want = ellipse(50.0, 10.0, -20.0, 30.0).control_bounds().unwrap();
let want_neg_h = ellipse(50.0, 40.0, -20.0, -30.0).control_bounds().unwrap();
for radii in [[0.0; 4], [5.0; 4]] {
let b = rounded_rect(50.0, 10.0, -20.0, 30.0, radii).control_bounds().unwrap();
assert_eq!(b, want, "radii {radii:?}");
assert_eq!(b, (30.0, 10.0, 50.0, 40.0), "radii {radii:?}");
let b = rounded_rect(50.0, 40.0, -20.0, -30.0, radii).control_bounds().unwrap();
assert_eq!(b, want_neg_h, "radii {radii:?}");
}
}
#[test]
fn star_vertices() {
assert_eq!(polygon(0.0, 0.0, 10.0, 10.0, 5, 0.5).subpaths[0].knots.len(), 10);