Files
photocraft/crates/engine/src/vector_cmds/tests.rs
T
ab15e4ea96 Curved shapes no longer report bounds 1 px too wide (#2537) (#2641)
* Drop f32 round-off coverage in the vector rasterizer (#2537)

Each row's cell accumulator is prefix-summed in f32. A left boundary adds
coverage and the right boundary subtracts it, but for curved or off-grid
edges the two don't cancel exactly, leaving ~1e-7 coverage in the pixel
just past the shape. Shape layers wrote the fill colour there (alpha
rounding to 0 at 8/16-bit, ~1e-7 at 32-bit), so content bounds and
shape.info reported curved shapes one pixel too wide.

Snap coverage below 1e-6 to 0 when writing a row. That is under half a
16-bit step, so 8- and 16-bit coverage values are unchanged; only the
residue pixels go away. Selections, vector masks and strokes share the
rasterizer and get the same fix.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Rasterizer: NaN coverage becomes 0, not full (#2537)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Fabio Lussi <198260595+itrycodinganditsbad@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 13:41:51 -04:00

669 lines
33 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.
use serde_json::json;
use super::*;
#[test]
fn stroke_validation_rejects_hostile_values_without_editing_history() {
let mut s = session(100, 80, 8);
let id = s.execute("shape.create", json!({"rect": [20, 20, 50, 30], "stroke": {"width": 4}})).unwrap()["layer"].as_u64().unwrap();
let before = s.active().unwrap().doc.clone();
let history = s.active().unwrap().history.past_len();
let invalid = [
json!(true),
json!([]),
json!({"width": -1}),
json!({"width": 1e100}),
json!({"opacity": 101}),
json!({"cap": "invalid"}),
json!({"cap": 2}),
json!({"join": "invalid"}),
json!({"align": "invalid"}),
json!({"miterLimit": 0}),
json!({"miterLimit": 501}),
json!({"dashes": [4, "x"]}),
json!({"dashes": [-1, 2]}),
json!({"dashes": [0, 0]}),
json!({"dashes": vec![1; 33]}),
json!({"dashOffset": 1e100}),
json!({"width": "4"}),
json!({"width": 0.00001, "dashes": [0.01, 0.01]}),
];
for stroke in invalid {
assert!(s.execute("shape.edit", json!({"layer": id, "stroke": stroke})).is_err(), "{stroke}");
assert_eq!(s.active().unwrap().doc, before);
assert_eq!(s.active().unwrap().history.past_len(), history);
assert!(s.execute("shape.create", json!({"rect": [20, 20, 50, 30], "stroke": stroke})).is_err(), "{stroke}");
}
assert!(s.execute("shape.edit", json!({"layer": id, "rect": [1e100, 0, 20, 20]})).is_err());
assert_eq!(s.active().unwrap().doc, before);
}
#[test]
fn complete_stroke_edits_preserve_paint_and_geometry_and_undo_at_every_depth() {
for depth in [8, 16, 32] {
let mut s = session(100, 80, depth);
let id = s
.execute("shape.create", json!({"kind": "polygon", "sides": 3, "rect": [20, 20, 50, 30], "stroke": {"width": 4, "color": "#1122dd"}}))
.unwrap()["layer"]
.as_u64()
.unwrap();
let original = shape(&s, id);
let history = s.active().unwrap().history.past_len();
let patch = json!({"align":"outside", "cap":"square", "join":"bevel", "miterLimit":2, "opacity":35, "dashes":[4,2,1,3], "dashOffset":-0.5});
s.execute("shape.edit", json!({"layer": id, "stroke": patch})).unwrap();
let changed = shape(&s, id);
assert_eq!(changed.path, original.path);
assert_eq!(changed.stroke.as_ref().unwrap().paint, original.stroke.as_ref().unwrap().paint);
assert_eq!(changed.stroke.as_ref().unwrap().dashes, [4.0, 2.0, 1.0, 3.0]);
assert_ne!(changed.cache, original.cache);
assert_eq!(s.active().unwrap().history.past_len(), history + 1);
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(shape(&s, id), original);
s.execute("edit.redo", json!({})).unwrap();
assert_eq!(shape(&s, id), changed);
}
}
#[test]
fn stroke_edits_respect_shape_locks() {
let mut s = session(80, 60, 8);
let id = s.execute("shape.create", json!({"rect":[10,10,40,30], "stroke":{"width":3}})).unwrap()["layer"].as_u64().unwrap();
s.execute("layer.lockLayers", json!({"all":true})).unwrap();
let before = s.active().unwrap().doc.clone();
assert!(s.execute("shape.edit", json!({"layer":id,"stroke":{"dashes":[4,2]}})).is_err());
assert_eq!(s.active().unwrap().doc, before);
}
#[test]
fn adding_components_validates_the_existing_strokes_combined_workload() {
let mut s = session(80, 60, 8);
let id = s.execute("shape.create", json!({"rect":[0,0,1,1], "stroke":{"width":0.001,"dashes":[1,1]}})).unwrap()["layer"].as_u64().unwrap();
let before = s.active().unwrap().doc.clone();
let history = s.active().unwrap().history.past_len();
assert!(s.execute("shape.create", json!({"addTo":id,"rect":[20,20,100,80]})).is_err());
assert_eq!(s.active().unwrap().doc, before);
assert_eq!(s.active().unwrap().history.past_len(), history);
}
fn session(w: u32, h: u32, depth: u32) -> Session {
let mut s = Session::new();
s.execute("file.new", json!({"width": w, "height": h, "background": "white", "depth": depth})).unwrap();
s
}
fn doc(s: &Session) -> &Document {
&s.active().unwrap().doc
}
fn shape(s: &Session, id: u64) -> ShapeLayer {
match &doc(s).layer(LayerId(id)).unwrap().content {
LayerContent::Shape(sh) => sh.clone(),
other => panic!("not a shape: {}", other.kind_name()),
}
}
fn alpha_sum(sh: &ShapeLayer) -> f64 {
let c = sh.cache.as_ref().unwrap();
let r = c.content_bounds();
let fmt = c.format();
c.read_region(r).chunks_exact(fmt.channels()).map(|p| f64::from(photocraft_raster::to_rgba(&fmt, p)[3])).sum()
}
fn selection_mask(s: &Session) -> Vec<f32> {
let d = doc(s);
sel::mask_from_surface(d.selection.as_ref(), d.bounds())
}
#[test]
fn shape_create_every_kind_and_undo() {
for depth in [8, 16, 32] {
let mut s = session(200, 150, depth);
let cases = [
(json!({"kind": "rect", "rect": [10, 10, 50, 40]}), 2000.0),
(json!({"kind": "roundedRect", "rect": [10, 10, 50, 40], "radii": 10}), 2000.0 - (4.0 - std::f64::consts::PI) * 100.0),
(json!({"kind": "ellipse", "rect": [20, 20, 100, 60]}), std::f64::consts::PI * 50.0 * 30.0),
(json!({"kind": "polygon", "rect": [0, 0, 100, 100], "sides": 6}), 1.5 * 3f64.sqrt() * 2500.0),
(json!({"kind": "star", "rect": [0, 0, 100, 100], "sides": 5, "starRatio": 0.5}), 0.0),
(json!({"kind": "line", "from": [10, 100], "to": [110, 100], "weight": 4}), 400.0),
(json!({"kind": "path", "path": {"subpaths": [{"closed": true, "knots": [[0, 0], [60, 0], [0, 60]]}]}}), 1800.0),
];
let before = doc(&s).layer_count();
for (i, (p, area)) in cases.iter().enumerate() {
let r = s.execute("shape.create", p.clone()).unwrap();
let id = r["layer"].as_u64().unwrap();
let sh = shape(&s, id);
assert_eq!(sh.cache.as_ref().unwrap().format(), doc(&s).pixel_format());
let a = alpha_sum(&sh);
if *area > 0.0 {
assert!((a - area).abs() / area < 0.01, "case {i} depth {depth}: {a} vs {area}");
} else {
assert!(a > 1000.0);
}
assert_eq!(r["kind"], p["kind"], "{r}");
assert_eq!(s.active().unwrap().active_layer, Some(LayerId(id)));
}
assert_eq!(doc(&s).layer_count(), before + cases.len());
for _ in 0..cases.len() {
assert!(s.undo());
}
assert_eq!(doc(&s).layer_count(), before);
assert!(s.redo());
assert_eq!(doc(&s).layer_count(), before + 1);
}
}
#[test]
fn shape_create_fill_stroke_and_composite() {
let mut s = session(100, 100, 8);
let r = s
.execute(
"shape.create",
json!({"kind": "rect", "rect": [20, 20, 60, 60], "fill": "#ff0000", "stroke": {"width": 4, "color": "#0000ff", "align": "outside"}}),
)
.unwrap();
assert_eq!(r["fill"], "#ff0000");
assert_eq!(r["stroke"]["align"], "outside");
assert_eq!(r["bounds"], json!([16, 16, 68, 68]));
let f = photocraft_compose::flatten(doc(&s));
let px = |x: i32, y: i32| f.get(x, y);
assert!(px(50, 50)[0] > 0.99 && px(50, 50)[2] < 0.01);
assert!(px(18, 50)[2] > 0.99 && px(18, 50)[0] < 0.01);
assert!(px(10, 10)[1] > 0.99);
// No fill: only the stroke.
let r = s.execute("shape.create", json!({"kind": "ellipse", "rect": [5, 5, 40, 40], "fill": null, "stroke": {"width": 2}})).unwrap();
let sh = shape(&s, r["layer"].as_u64().unwrap());
assert!(sh.fill.is_none());
assert!((alpha_sum(&sh) - std::f64::consts::PI * 40.0 * 2.0).abs() < 3.0, "{}", alpha_sum(&sh));
assert!(s.execute("shape.create", json!({"kind": "rect"})).is_err());
assert!(s.execute("shape.create", json!({"kind": "blob", "rect": [0, 0, 1, 1]})).is_err());
}
#[test]
fn shape_edit_live_params_and_undo() {
let mut s = session(200, 200, 8);
let id = s.execute("shape.create", json!({"kind": "rect", "rect": [10, 10, 50, 50]})).unwrap()["layer"].as_u64().unwrap();
let r = s.execute("shape.edit", json!({"layer": id, "rect": [10, 10, 100, 50]})).unwrap();
assert_eq!(r["bounds"], json!([10, 10, 100, 50]));
assert!((alpha_sum(&shape(&s, id)) - 5000.0).abs() < 1.0);
// Radii turn it into a rounded rectangle.
let r = s.execute("shape.edit", json!({"layer": id, "radii": [20, 0, 20, 0]})).unwrap();
assert_eq!(r["kind"], "roundedRect");
assert_eq!(r["live"]["radii"], json!([20.0, 0.0, 20.0, 0.0]));
// Move keeps the live shape; a rotation drops it.
let r = s.execute("shape.edit", json!({"layer": id, "move": [5, 7]})).unwrap();
assert_eq!(r["live"]["rect"], json!([15.0, 17.0, 100.0, 50.0]));
let r = s.execute("shape.edit", json!({"layer": id, "transform": [0.0, 1.0, -1.0, 0.0, 150.0, 0.0]})).unwrap();
assert_eq!(r["kind"], "path");
// Fill/stroke edits merge.
s.execute("shape.edit", json!({"layer": id, "stroke": {"width": 3}})).unwrap();
let r = s
.execute(
"shape.edit",
json!({"layer": id, "stroke": {"cap": "round", "dashes": [2, 1]}, "fill": {"gradient": {"stops": [[0, "#000000"], [1, "#ffffff"]], "angle": 0}}}),
)
.unwrap();
assert_eq!(r["stroke"]["width"], 3.0);
assert_eq!(r["stroke"]["cap"], "round");
assert!(r["fill"]["gradient"].is_object());
s.execute("shape.edit", json!({"layer": id, "stroke": null, "name": "Box"})).unwrap();
assert!(shape(&s, id).stroke.is_none());
assert_eq!(doc(&s).layer(LayerId(id)).unwrap().name, "Box");
// Undo walks back one edit at a time.
s.undo();
assert!(shape(&s, id).stroke.is_some());
// Editing a non-shape layer fails.
let bg = doc(&s).layers[0].id.0;
assert!(s.execute("shape.edit", json!({"layer": bg, "fill": "#000000"})).is_err());
}
#[test]
fn shape_add_to_with_path_ops() {
let mut s = session(100, 100, 8);
let id = s.execute("shape.create", json!({"kind": "rect", "rect": [0, 0, 60, 60]})).unwrap()["layer"].as_u64().unwrap();
let r = s.execute("shape.create", json!({"kind": "rect", "rect": [30, 30, 60, 60], "addTo": id, "op": "subtract"})).unwrap();
assert_eq!(r["path"]["subpaths"][1]["op"], "subtract");
assert!((alpha_sum(&shape(&s, id)) - (3600.0 - 900.0)).abs() < 1.0);
s.execute("layer.combineShapes.intersectShapeAreas", json!({"layer": id})).unwrap();
assert!((alpha_sum(&shape(&s, id)) - 900.0).abs() < 1.0);
s.execute("layer.combineShapes.excludeOverlappingShapes", json!({"layer": id})).unwrap();
assert!((alpha_sum(&shape(&s, id)) - 5400.0).abs() < 1.0);
// Merge bakes the xor into plain outlines with the same area.
let r = s.execute("layer.combineShapes.mergeShapeComponents", json!({"layer": id})).unwrap();
let merged = shape(&s, id);
assert!(merged.path.subpaths.len() >= 2, "{r}");
assert!((alpha_sum(&merged) - 5400.0).abs() < 20.0, "{}", alpha_sum(&merged));
}
#[test]
fn shape_rasterize_keeps_pixels() {
let mut s = session(64, 64, 16);
let id = s.execute("shape.create", json!({"kind": "ellipse", "rect": [4, 4, 40, 30]})).unwrap()["layer"].as_u64().unwrap();
let before = shape(&s, id).cache.unwrap();
let flat = photocraft_compose::flatten(doc(&s));
s.execute("layer.rasterize.shape", json!({"layer": id})).unwrap();
let l = doc(&s).layer(LayerId(id)).unwrap();
assert!(matches!(l.content, LayerContent::Raster(_)));
assert_eq!(l.surface().unwrap(), &before);
assert_eq!(photocraft_compose::flatten(doc(&s)).px, flat.px);
assert!(s.execute("shape.rasterize", json!({"layer": id})).is_err());
}
#[test]
fn paths_set_rename_delete_list() {
let mut s = session(100, 100, 8);
let tri = json!({"subpaths": [{"knots": [[10, 10], [90, 10], [50, 90]]}]});
s.execute("path.set", json!({"path": tri})).unwrap();
assert!(doc(&s).work_path.is_some());
// Saving the work path (rename) moves it into the saved paths.
s.execute("path.rename", json!({"to": "Triangle"})).unwrap();
assert!(doc(&s).work_path.is_none());
assert_eq!(doc(&s).paths[0].name, "Triangle");
s.execute("path.set", json!({"name": "Triangle", "op": "subtract", "path": {"subpaths": [{"knots": [[40, 20], [60, 20], [60, 40], [40, 40]]}]}})).unwrap();
assert_eq!(doc(&s).paths[0].path.subpaths.len(), 2);
assert_eq!(doc(&s).paths[0].path.subpaths[1].op, PathOp::Subtract);
let l = s.execute("path.list", json!({})).unwrap();
assert_eq!(l["paths"][0]["knots"], 7);
let info = s.execute("path.info", json!({"name": "Triangle"})).unwrap();
assert_eq!(info["path"]["subpaths"][1]["op"], "subtract");
s.execute("path.rename", json!({"name": "Triangle", "to": "Tri"})).unwrap();
assert!(s.execute("path.delete", json!({"name": "Triangle"})).is_err());
s.execute("path.delete", json!({"name": "Tri"})).unwrap();
assert!(doc(&s).paths.is_empty());
s.undo();
assert_eq!(doc(&s).paths[0].name, "Tri");
assert!(s.execute("path.set", json!({"path": {"subpaths": [{"knots": [[0, 0], [1]]}]}})).is_err());
}
#[test]
fn path_to_selection_matches_analytic_shape() {
let mut s = session(200, 200, 8);
// Circle of radius 60 at (100, 100) as a work path.
let c = vector::shapes::ellipse(40.0, 40.0, 120.0, 120.0);
s.execute("path.set", json!({"path": path_json(&c)})).unwrap();
s.execute("path.toSelection", json!({})).unwrap();
let m = selection_mask(&s);
let area: f64 = m.iter().map(|v| f64::from(*v)).sum();
let want = std::f64::consts::PI * 3600.0;
assert!((area - want).abs() / want < 0.005, "{area} vs {want}");
// Pixel-level: centres well inside/outside are exact; edge pixels match the analytic coverage.
let at = |x: usize, y: usize| m[y * 200 + x];
assert_eq!(at(100, 100), 1.0);
assert_eq!(at(10, 10), 0.0);
for (x, y) in [(160usize, 100usize), (100, 40), (142, 142)] {
let d = ((x as f64 + 0.5 - 100.0).powi(2) + (y as f64 + 0.5 - 100.0).powi(2)).sqrt();
let analytic = (60.0 - d + 0.5).clamp(0.0, 1.0);
assert!((f64::from(at(x, y)) - analytic).abs() < 0.2, "({x},{y}): {} vs {analytic}", at(x, y));
}
// Modes: subtract a square, then intersect.
s.execute("path.set", json!({"name": "Sq", "path": {"subpaths": [{"knots": [[100, 0], [200, 0], [200, 200], [100, 200]]}]}})).unwrap();
s.execute("path.toSelection", json!({"name": "Sq", "mode": "subtract"})).unwrap();
let half: f64 = selection_mask(&s).iter().map(|v| f64::from(*v)).sum();
assert!((half - want / 2.0).abs() / want < 0.005);
// Non-anti-aliased selections are binary; feather softens.
s.execute("path.toSelection", json!({"antiAlias": false})).unwrap();
assert!(selection_mask(&s).iter().all(|v| *v == 0.0 || *v == 1.0));
s.execute("path.toSelection", json!({"feather": 8})).unwrap();
let m = selection_mask(&s);
assert!(m[100 * 200 + 160] > 0.2 && m[100 * 200 + 160] < 0.8);
}
#[test]
fn selection_to_work_path_roundtrip_iou() {
let mut s = session(240, 180, 8);
// An anti-aliased donut plus a rectangle, from a path, as the selection.
let mut p = vector::shapes::ellipse(20.0, 20.0, 140.0, 140.0);
p.subpaths.push(vector::shapes::ellipse(60.0, 60.0, 60.0, 60.0).subpaths.remove(0).with_op(PathOp::Subtract));
p.subpaths.push(vector::shapes::rect(170.0, 30.0, 50.0, 120.0).subpaths.remove(0));
s.execute("path.set", json!({"name": "src", "path": path_json(&p)})).unwrap();
s.execute("path.toSelection", json!({"name": "src"})).unwrap();
let before = selection_mask(&s);
for tol in [1.0, 2.0] {
let r = s.execute("select.toWorkPath", json!({"tolerance": tol})).unwrap();
assert_eq!(r["subpaths"], 3);
s.execute("path.toSelection", json!({})).unwrap();
let after = selection_mask(&s);
let (mut i, mut u) = (0.0f64, 0.0f64);
for (a, b) in before.iter().zip(&after) {
i += f64::from(a.min(*b));
u += f64::from(a.max(*b));
}
assert!(i / u > 0.98, "tolerance {tol}: IoU {}", i / u);
// Restore the original selection for the next tolerance.
s.execute("path.toSelection", json!({"name": "src"})).unwrap();
}
// Selection → shape layer.
let r = s.execute("select.convertToShape", json!({"fill": "#00ff00"})).unwrap();
assert_eq!(r["fill"], "#00ff00");
assert_eq!(r["path"]["subpaths"].as_array().unwrap().len(), 3);
}
#[test]
fn fill_and_stroke_path_on_pixel_layer() {
let mut s = session(100, 100, 16);
s.execute("layer.new.layer", json!({})).unwrap();
s.execute("path.set", json!({"path": {"subpaths": [{"knots": [[10, 10], [60, 10], [60, 60], [10, 60]]}]}})).unwrap();
s.execute("path.fill", json!({"color": "#ff0000", "opacity": 50})).unwrap();
let d = doc(&s);
let l = d.layer(s.active().unwrap().active_layer.unwrap()).unwrap();
let px = l.surface().unwrap().rgba(30, 30);
assert!((px[0] - 1.0).abs() < 1e-3 && (px[3] - 0.5).abs() < 0.01, "{px:?}");
assert_eq!(l.surface().unwrap().rgba(80, 80)[3], 0.0);
// Stroke with a hard 4 px pencil along the square outline.
s.execute("path.stroke", json!({"tool": "pencil", "size": 4, "color": "#0000ff", "opacity": 100})).unwrap();
let d = doc(&s);
let surf = d.layer(s.active().unwrap().active_layer.unwrap()).unwrap().surface().unwrap().clone();
assert!(surf.rgba(10, 35)[2] > 0.9);
assert!(surf.rgba(60, 35)[2] > 0.9);
assert!(surf.rgba(35, 35)[2] < 0.1);
assert!(s.execute("path.stroke", json!({"tool": "airbrush"})).is_err());
// Filling needs a pixel layer.
s.execute("shape.create", json!({"kind": "rect", "rect": [0, 0, 5, 5]})).unwrap();
assert!(s.execute("path.fill", json!({})).is_err());
}
#[test]
fn fill_path_stays_inside_the_selection() {
let alpha = |s: &Session, x, y| {
let st = s.active().unwrap();
st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap().rgba(x, y)[3]
};
for depth in [8, 16, 32] {
// A hard rectangle, an ellipse and a feathered rectangle, all over the left half.
for extra in [json!({}), json!({"ellipse": true}), json!({"feather": 4})] {
let mut s = session(100, 100, depth);
s.execute("layer.new.layer", json!({})).unwrap();
let mut rect = json!({"x": 0, "y": 0, "width": 50, "height": 100});
rect.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone());
s.execute("select.rect", rect).unwrap();
s.execute("path.set", json!({"path": {"subpaths": [{"closed": true, "knots": [[10, 10], [90, 10], [90, 90], [10, 90]]}]}})).unwrap();
s.execute("path.fill", json!({"color": "#ff0000", "opacity": 100})).unwrap();
let mask = selection_mask(&s);
assert!(alpha(&s, 30, 50) > 0.99, "{depth} {extra}: inside the selection");
assert_eq!(alpha(&s, 70, 50), 0.0, "{depth} {extra}: outside the selection");
// At every pixel the fill's alpha is at most the selection's coverage there.
for (x, y) in [(46, 50), (49, 50), (51, 50), (25, 12), (12, 25)] {
let m = mask[(y * 100 + x) as usize];
assert!(alpha(&s, x, y) <= m + 0.01, "{depth} {extra} ({x},{y}): alpha {} > selection {m}", alpha(&s, x, y));
}
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(alpha(&s, 30, 50), 0.0, "{depth} {extra}: undo restores");
}
}
// Without a selection the whole path fills, as before.
let mut s = session(100, 100, 8);
s.execute("layer.new.layer", json!({})).unwrap();
s.execute("path.set", json!({"path": {"subpaths": [{"closed": true, "knots": [[10, 10], [90, 10], [90, 90], [10, 90]]}]}})).unwrap();
s.execute("path.fill", json!({"color": "#ff0000"})).unwrap();
assert!(alpha(&s, 30, 50) > 0.99 && alpha(&s, 70, 50) > 0.99);
}
#[test]
fn clipping_path_requires_saved_path_and_tracks_rename_delete() {
let mut s = session(60, 60, 8);
assert!(s.execute("path.clippingPath.set", json!({})).is_err());
assert!(s.execute("path.clippingPath.set", json!({"name":"missing"})).is_err());
s.execute("path.set", json!({"name":"Cutout","path":{"subpaths":[{"knots":[[1,1],[20,1],[20,20]]}]}})).unwrap();
assert!(s.execute("path.clippingPath.set", json!({"name":"Cutout","flatness":-1})).is_err());
assert!(s.execute("path.clippingPath.set", json!({"name":"Cutout","flatness":"bad"})).is_err());
s.execute("path.clippingPath.set", json!({"name":"Cutout","flatness":2.5})).unwrap();
assert_eq!(doc(&s).clipping_path.as_ref().unwrap().name, "Cutout");
assert_eq!(doc(&s).clipping_path.as_ref().unwrap().flatness, 2.5);
s.execute("path.rename", json!({"name":"Cutout","to":"Final"})).unwrap();
assert_eq!(doc(&s).clipping_path.as_ref().unwrap().name, "Final");
s.execute("path.clippingPath.clear", json!({})).unwrap();
assert!(doc(&s).clipping_path.is_none());
assert!(s.undo());
assert_eq!(doc(&s).clipping_path.as_ref().unwrap().name, "Final");
s.execute("path.delete", json!({"name":"Final"})).unwrap();
assert!(doc(&s).clipping_path.is_none());
}
#[test]
fn transform_work_saved_and_shape_paths() {
let mut s = session(100, 100, 8);
let path = json!({"subpaths":[{"knots":[[4,5],[20,5],[20,20]]}]});
s.execute("path.set", json!({"path":path})).unwrap();
assert!(s.execute("path.transform", json!({})).is_err());
assert!(s.execute("path.transform", json!({"matrix":[1,0,0,1,"bad",0]})).is_err());
assert!(s.execute("path.transform", json!({"matrix":[1,0,0,1,1e30,0]})).is_err());
s.execute("path.transform", json!({"matrix":[1,0,0,1,10,7]})).unwrap();
assert_eq!(doc(&s).work_path.as_ref().unwrap().subpaths[0].knots[0].anchor, Point::new(14.0, 12.0));
s.execute("path.set", json!({"name":"Saved","path":path})).unwrap();
s.execute("path.transform", json!({"name":"Saved","matrix":[2,0,0,2,0,0]})).unwrap();
assert_eq!(doc(&s).paths[0].path.subpaths[0].knots[0].anchor, Point::new(8.0, 10.0));
s.execute("path.transform", json!({"name":"Saved","translateX":3,"translateY":-2})).unwrap();
assert_eq!(doc(&s).paths[0].path.subpaths[0].knots[0].anchor, Point::new(11.0, 8.0));
let id = s.execute("shape.create", json!({"kind":"rect","rect":[10,10,20,20]})).unwrap()["layer"].as_u64().unwrap();
s.execute("path.transform", json!({"name":"layer","layer":id,"matrix":[1,0,0,1,5,0]})).unwrap();
assert_eq!(shape(&s, id).path.subpaths[0].knots[0].anchor.x, 15.0);
assert!(shape(&s, id).cache.is_some());
}
#[test]
fn copy_and_paste_shape_fill_and_stroke() {
let mut s = session(100, 100, 8);
assert!(!s.is_enabled("path.style.copyFill"));
assert!(!s.is_enabled("path.style.pasteFill"));
assert!(s.execute("path.style.copyFill", json!({})).is_err());
assert!(s.execute("path.style.pasteFill", json!({})).is_err());
let source =
s.execute("shape.create", json!({"kind":"rect","rect":[10,10,20,20],"fill":"#ff0000","stroke":{"width":3,"color":"#0000ff"}})).unwrap()["layer"]
.as_u64()
.unwrap();
assert!(s.is_enabled("path.style.copyFill"));
assert!(s.is_enabled("path.style.copyStroke"));
s.execute("path.style.copyFill", json!({})).unwrap();
s.execute("path.style.copyStroke", json!({})).unwrap();
let target = s.execute("shape.create", json!({"kind":"rect","rect":[40,40,20,20],"fill":"#00ff00"})).unwrap()["layer"].as_u64().unwrap();
assert!(s.is_enabled("path.style.pasteFill"));
assert!(s.is_enabled("path.style.pasteStroke"));
s.execute("path.style.pasteFill", json!({})).unwrap();
s.execute("path.style.pasteStroke", json!({})).unwrap();
assert_eq!(shape(&s, source).fill, shape(&s, target).fill);
assert_eq!(shape(&s, source).stroke, shape(&s, target).stroke);
assert!(s.undo());
assert!(shape(&s, target).stroke.is_none());
}
#[test]
fn path_fill_handles_extreme_coordinates_and_feather() {
let mut s = session(48, 32, 8);
s.execute("layer.new.layer", json!({})).unwrap();
s.execute(
"path.set",
json!({
"name": "P",
"path": {"subpaths": [{
"closed": true,
"knots": [[0.0, 0.0], [3.0e9, 0.0], [3.0e9, 1.0e9]]
}]}
}),
)
.unwrap();
s.execute("path.fill", json!({"name": "P", "color": "#ff0000"})).unwrap();
s.execute("path.fill", json!({"name": "P", "color": "#ff0000", "feather": 1_073_741_824.0})).unwrap();
let layer = doc(&s).layer(s.active().unwrap().active_layer.unwrap()).unwrap();
assert_eq!(layer.surface().unwrap().tile_count(), 1);
}
#[test]
fn rasterizing_a_vector_mask_keeps_what_the_masks_show() {
// #992: the vector mask's feather (and a pixel mask's feather or density) shape the rendered
// edge, so the rasterized pixel mask must hold the mask the compositor showed.
// (vector feather, vector enabled, pixel mask (feather, density))
let cases = [
(8.0, true, None),
(8.0, true, Some((0.0, 1.0))),
(0.0, true, Some((4.0, 1.0))),
(0.0, true, Some((0.0, 0.5))),
(6.0, true, Some((3.0, 0.7))),
// Controls: hard masks were already exact; a disabled vector mask shows nothing.
(0.0, true, None),
(0.0, true, Some((0.0, 1.0))),
(8.0, false, Some((4.0, 1.0))),
];
for depth in [8, 16, 32] {
for (vf, enabled, pm) in cases {
let what = format!("{depth}-bit, vector feather {vf} enabled {enabled}, pixel mask {pm:?}");
let mut s = session(64, 64, depth);
s.execute("layer.new.layer", json!({})).unwrap();
let id = s.active().unwrap().active_layer.unwrap();
s.execute("path.set", json!({"name": "all", "path": {"subpaths": [{"knots": [[0, 0], [64, 0], [64, 64], [0, 64]]}]}})).unwrap();
s.execute("path.fill", json!({"name": "all", "color": "#000000"})).unwrap();
if let Some((feather, density)) = pm {
s.execute("select.rect", json!({"x": 0, "y": 0, "width": 40, "height": 64})).unwrap();
s.execute("layer.layerMask.revealSelection", json!({})).unwrap();
s.execute("select.deselect", json!({})).unwrap();
s.edit("mask settings", |doc, _| {
let m = doc.layer_mut(id).unwrap().mask.as_mut().unwrap();
(m.feather, m.density) = (feather, density);
Ok(())
})
.unwrap();
}
s.execute("path.set", json!({"path": {"subpaths": [{"knots": [[16, 16], [48, 16], [48, 48], [16, 48]]}]}})).unwrap();
s.execute("layer.vectorMask.currentPath", json!({})).unwrap();
s.execute("layer.vectorMask.edit", json!({"feather": vf, "enabled": enabled})).unwrap();
let before = photocraft_compose::flatten(doc(&s));
let steps = s.active().unwrap().history.past_len();
s.execute("layer.rasterize.vectorMask", json!({})).unwrap();
let l = doc(&s).layer(id).unwrap();
assert!(l.vector_mask.is_none() && l.mask.is_some(), "{what}");
// An integer mask stores the soft values rounded to its depth (a mask made from a
// selection is 8-bit at every document depth).
let tol = match l.mask.as_ref().unwrap().surface.format().sample {
photocraft_color::SampleType::U8 => 1.5 / 255.0,
_ => 1e-3,
};
assert_eq!(s.active().unwrap().history.past_len(), steps + 1, "{what}: one history step");
let after = photocraft_compose::flatten(doc(&s));
let worst = before.px.iter().zip(&after.px).flat_map(|(a, b)| (0..4).map(move |c| (a[c] - b[c]).abs())).fold(0.0f32, f32::max);
assert!(worst <= tol, "{what}: the composite changed by {worst}");
s.undo();
assert!(doc(&s).layer(id).unwrap().vector_mask.is_some(), "{what}: undo brings the vector mask back");
}
}
}
#[test]
fn vector_mask_commands_and_compositing() {
let mut s = session(100, 100, 8);
s.execute("layer.new.layer", json!({})).unwrap();
let id = s.active().unwrap().active_layer.unwrap().0;
// Fill the layer black via a full-canvas path fill (works regardless of edit.fill).
s.execute("path.set", json!({"name": "all", "path": {"subpaths": [{"knots": [[0, 0], [100, 0], [100, 100], [0, 100]]}]}})).unwrap();
s.execute("path.fill", json!({"name": "all", "color": "#000000"})).unwrap();
// Reveal All: nothing hidden.
s.execute("layer.vectorMask.revealAll", json!({})).unwrap();
let f = photocraft_compose::flatten(doc(&s));
assert!(f.get(80, 80)[0] < 0.01);
// Current path → only the left half shows the black layer.
s.execute("path.set", json!({"path": {"subpaths": [{"knots": [[0, 0], [50, 0], [50, 100], [0, 100]]}]}})).unwrap();
s.execute("layer.vectorMask.currentPath", json!({})).unwrap();
let f = photocraft_compose::flatten(doc(&s));
assert!(f.get(20, 50)[0] < 0.01 && f.get(80, 50)[0] > 0.99);
// Disable → all black again; toggling re-enables.
s.execute("layer.vectorMask.enabled", json!({})).unwrap();
assert!(photocraft_compose::flatten(doc(&s)).get(80, 50)[0] < 0.01);
s.execute("layer.vectorMask.enabled", json!({})).unwrap();
// Density 50%: the hidden half shows half grey.
let r = s.execute("layer.vectorMask.edit", json!({"density": 50})).unwrap();
assert_eq!(r["density"], 50.0);
assert!((photocraft_compose::flatten(doc(&s)).get(80, 50)[0] - 0.5).abs() < 0.01);
s.execute("layer.vectorMask.edit", json!({"density": 100})).unwrap();
// Hide All.
s.execute("layer.vectorMask.hideAll", json!({})).unwrap();
assert!(photocraft_compose::flatten(doc(&s)).get(20, 50)[0] > 0.99);
// Rasterize into the pixel mask: same composite, no vector mask left.
s.execute("layer.vectorMask.currentPath", json!({})).unwrap();
let before = photocraft_compose::flatten(doc(&s));
s.execute("layer.rasterize.vectorMask", json!({})).unwrap();
let l = doc(&s).layer(LayerId(id)).unwrap();
assert!(l.vector_mask.is_none() && l.mask.is_some());
assert_eq!(photocraft_compose::flatten(doc(&s)).px, before.px);
// Delete / undo.
s.undo();
s.execute("layer.vectorMask.delete", json!({})).unwrap();
assert!(doc(&s).layer(LayerId(id)).unwrap().vector_mask.is_none());
assert!(s.execute("layer.vectorMask.delete", json!({})).is_err());
s.undo();
assert!(doc(&s).layer(LayerId(id)).unwrap().vector_mask.is_some());
// Shape layers cannot take a separate vector mask.
s.execute("shape.create", json!({"kind": "rect", "rect": [0, 0, 10, 10]})).unwrap();
assert!(s.execute("layer.vectorMask.revealAll", json!({})).is_err());
}
#[test]
fn translate_vectors_moves_shapes_and_masks() {
let mut s = session(100, 100, 8);
let id = s.execute("shape.create", json!({"kind": "rect", "rect": [10, 10, 20, 20]})).unwrap()["layer"].as_u64().unwrap();
s.edit("Move", |d, _| {
let snap = d.clone();
let l = d.layer_mut(LayerId(id)).unwrap();
translate_vectors(&snap, l, 5.0, 0.0);
Ok(())
})
.unwrap();
let sh = shape(&s, id);
assert_eq!(sh.cache.unwrap().content_bounds(), Rect::new(15, 10, 35, 30));
assert_eq!(sh.live, Some(LiveShape::Rect { rect: [15.0, 10.0, 20.0, 20.0], radii: [0.0; 4] }));
}
#[test]
fn all_vector_commands_are_registered() {
for id in [
"shape.create",
"shape.edit",
"shape.info",
"shape.rasterize",
"path.set",
"path.delete",
"path.rename",
"path.list",
"path.info",
"path.toSelection",
"select.toWorkPath",
"path.fill",
"path.stroke",
"layer.vectorMask.add",
"layer.vectorMask.fromPath",
"layer.vectorMask.delete",
"layer.vectorMask.edit",
"layer.rasterize.vectorMask",
] {
assert!(crate::commands::find(id).is_some(), "{id}");
}
let mut ids: Vec<&str> = crate::command_specs().iter().map(|c| c.id).collect();
let n = ids.len();
ids.sort_unstable();
ids.dedup();
assert_eq!(ids.len(), n, "duplicate command ids");
}
#[test]
fn path_stroke_rejects_oversized_brush_before_rendering() {
let mut s = session(32, 32, 8);
s.execute("path.set", json!({"name": "work", "path": {"subpaths": [{"closed": false, "knots": [[2, 2], [20, 20]]}]}})).unwrap();
let result = s.execute("path.stroke", json!({"size": 1e30}));
assert!(result.is_err(), "path strokes share the bounded paint renderer");
}
#[test]
fn shape_info_bounds_of_curved_shapes_are_exact() {
// Issue #2537: an exact 600×600 ellipse reported width 601.
for depth in [8, 16, 32] {
let mut s = session(1920, 1080, depth);
for (params, want) in [
(json!({"kind": "ellipse", "rect": [0, 0, 100, 100], "fill": "#0000ff"}), json!([0, 0, 100, 100])),
(json!({"kind": "ellipse", "rect": [660, 240, 600, 600], "fill": "#0000ff"}), json!([660, 240, 600, 600])),
(json!({"kind": "ellipse", "rect": [10.5, 10, 100, 100], "fill": "#0000ff"}), json!([10, 10, 101, 100])),
(json!({"kind": "rect", "rect": [0, 0, 100, 100], "fill": "#0000ff"}), json!([0, 0, 100, 100])),
] {
let id = s.execute("shape.create", params.clone()).unwrap()["layer"].as_u64().unwrap();
let info = s.execute("shape.info", json!({"layer": id})).unwrap();
assert_eq!(info["bounds"], want, "{params} at {depth}-bit");
}
}
}