mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-28 10:11:58 +01:00
* 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>
669 lines
33 KiB
Rust
669 lines
33 KiB
Rust
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");
|
||
}
|
||
}
|
||
}
|