diff --git a/crates/engine/src/vector_cmds/tests.rs b/crates/engine/src/vector_cmds/tests.rs index 11a4b511e0..21207d3e96 100644 --- a/crates/engine/src/vector_cmds/tests.rs +++ b/crates/engine/src/vector_cmds/tests.rs @@ -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) { diff --git a/crates/vector/src/shapes.rs b/crates/vector/src/shapes.rs index a459ba816e..b154a47325 100644 --- a/crates/vector/src/shapes.rs +++ b/crates/vector/src/shapes.rs @@ -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);