use darkly::coord::{CanvasPoint, CanvasRect};
use darkly::document::SelectionMode;
use darkly::engine::types::StrokeOp;
use darkly::engine::DarklyEngine;
use darkly::gpu::context::GpuContext;
use darkly::gpu::overlay::{OverlayPrimitive, FLAG_CANVAS_SPACE, KIND_DASHED_LINE};
use darkly::gpu::test_utils::*;
use darkly::layer::LayerId;
fn test_engine(width: u32, height: u32) -> DarklyEngine {
let (device, queue) = test_device();
let gpu = GpuContext::new_headless(device, queue);
DarklyEngine::new(gpu, width, height)
}
fn paint_feature(engine: &mut DarklyEngine, layer_id: LayerId, cx: f32, cy: f32) {
engine.begin_stroke(layer_id);
let steps = 16;
for i in 0..=steps {
let x = cx - 3.0 + 6.0 * (i as f32 / steps as f32);
engine.stroke_to(StrokeOp::BrushStroke {
x,
y: cy,
pressure: 1.0,
x_tilt: 0.0,
y_tilt: 0.0,
rotation: 0.0,
tangential_pressure: 0.0,
time_ms: i as f64 * 16.0,
cr: 1.0,
cg: 0.0,
cb: 0.0,
ca: 1.0,
});
}
engine.end_stroke();
}
fn red_centroid(px: &[u8], w: u32, h: u32) -> (f32, f32) {
let (mut sx, mut sy, mut n) = (0.0f32, 0.0f32, 0u32);
for y in 0..h {
for x in 0..w {
let i = ((y * w + x) * 4) as usize;
if px[i] > 128 && px[i + 1] < 80 && px[i + 2] < 80 {
sx += x as f32;
sy += y as f32;
n += 1;
}
}
}
assert!(n > 0, "no red pixels found");
(sx / n as f32, sy / n as f32)
}
#[test]
fn feature_centroid_scales_after_2x() {
let (w, h) = (32u32, 32u32);
let mut engine = test_engine(w, h);
let layer = engine.add_raster_layer(None);
paint_feature(&mut engine, layer, 8.0, 8.0);
let before = engine.test_readback_canvas();
let (cx0, cy0) = red_centroid(&before, w, h);
engine.rescale_image(2 * w, 2 * h);
assert_eq!(engine.canvas_dimensions(), (2 * w, 2 * h));
let after = engine.test_readback_canvas();
let (cx1, cy1) = red_centroid(&after, 2 * w, 2 * h);
assert!(
(cx1 - 2.0 * cx0).abs() < 2.0 && (cy1 - 2.0 * cy0).abs() < 2.0,
"feature centroid should scale ~2x: before ({cx0},{cy0}) after ({cx1},{cy1})"
);
}
#[test]
fn rescale_downscale_025x() {
let (w, h) = (64u32, 64u32);
let mut engine = test_engine(w, h);
let layer = engine.add_raster_layer(None);
paint_feature(&mut engine, layer, 16.0, 16.0);
let before = engine.test_readback_canvas();
let (cx0, cy0) = red_centroid(&before, w, h);
engine.rescale_image(w / 4, h / 4);
assert_eq!(engine.canvas_dimensions(), (w / 4, h / 4));
let after = engine.test_readback_canvas();
let (cx1, cy1) = red_centroid(&after, w / 4, h / 4);
assert!(
(cx1 - 0.25 * cx0).abs() < 2.0 && (cy1 - 0.25 * cy0).abs() < 2.0,
"feature centroid should scale ~0.25x: before ({cx0},{cy0}) after ({cx1},{cy1})"
);
}
#[test]
fn two_layers_undo_restores_all_pixels() {
let (w, h) = (32u32, 32u32);
let mut engine = test_engine(w, h);
let l1 = engine.add_raster_layer(None);
let l2 = engine.add_raster_layer(None);
paint_feature(&mut engine, l1, 8.0, 8.0);
paint_feature(&mut engine, l2, 20.0, 20.0);
let before1 = engine.test_readback_layer(l1);
let before2 = engine.test_readback_layer(l2);
engine.rescale_image(2 * w, 2 * h);
engine.undo();
let after1 = engine.test_readback_layer(l1);
let after2 = engine.test_readback_layer(l2);
assert_eq!(
after1, before1,
"layer 1 pixels must be restored exactly on undo"
);
assert_eq!(
after2, before2,
"layer 2 pixels must be restored exactly on undo"
);
}
#[test]
fn rescale_undo_then_redo_pixels() {
let (w, h) = (32u32, 32u32);
let mut engine = test_engine(w, h);
let layer = engine.add_raster_layer(None);
paint_feature(&mut engine, layer, 10.0, 10.0);
let before = engine.test_readback_layer(layer);
engine.rescale_image(2 * w, 2 * h);
let scaled = engine.test_readback_layer(layer);
assert_eq!(
scaled.len(),
(2 * w * 2 * h * 4) as usize,
"layer extent should double"
);
engine.undo();
let undone = engine.test_readback_layer(layer);
assert_eq!(engine.canvas_dimensions(), (w, h));
assert_eq!(
undone, before,
"undo must restore the original pixels exactly"
);
engine.redo();
let redone = engine.test_readback_layer(layer);
assert_eq!(engine.canvas_dimensions(), (2 * w, 2 * h));
assert_eq!(
redone, scaled,
"redo must restore the resampled pixels exactly"
);
}
#[test]
fn mask_scales_with_layer() {
let (w, h) = (32u32, 32u32);
let mut engine = test_engine(w, h);
let layer = engine.add_raster_layer(None);
engine.add_mask(layer);
let mask = engine.test_mask_id(layer).expect("mask filter present");
assert_eq!(
engine.test_node_pixel_bounds(mask),
Some(CanvasRect::from_xywh(0, 0, w, h))
);
engine.rescale_image(2 * w, 2 * h);
assert_eq!(
engine.test_node_pixel_bounds(mask),
Some(CanvasRect::from_xywh(0, 0, 2 * w, 2 * h)),
"mask extent must scale with its host layer"
);
assert_eq!(
engine.test_readback_layer(mask).len(),
(2 * w * 2 * h) as usize
);
}
#[test]
fn rescale_clears_active_selection() {
let (w, h) = (32u32, 32u32);
let mut engine = test_engine(w, h);
let _layer = engine.add_raster_layer(None);
engine.select_rect(8.0, 8.0, 16.0, 16.0, SelectionMode::Replace, false, 0.0);
assert!(
engine.has_selection(),
"selection should be active before rescale"
);
engine.rescale_image(2 * w, 2 * h);
assert!(
!engine.has_selection(),
"rescale should clear the active selection"
);
engine.undo();
assert!(engine.has_selection(), "undo should restore the selection");
}
#[test]
fn rescale_noop_when_unchanged() {
let (w, h) = (32u32, 32u32);
let mut engine = test_engine(w, h);
assert!(!engine.test_can_undo());
engine.rescale_image(w, h);
assert!(
!engine.test_can_undo(),
"a same-dims rescale must not push an undo step"
);
}
fn alpha_at(px: &[u8], w: u32, x: u32, y: u32) -> u8 {
px[((y * w + x) * 4 + 3) as usize]
}
fn paint_row(engine: &mut DarklyEngine, layer_id: LayerId, py: f32, x0: f32, x1: f32) {
engine.begin_stroke(layer_id);
let steps = 48;
for i in 0..=steps {
let x = x0 + (x1 - x0) * (i as f32 / steps as f32);
engine.stroke_to(StrokeOp::BrushStroke {
x,
y: py,
pressure: 1.0,
x_tilt: 0.0,
y_tilt: 0.0,
rotation: 0.0,
tangential_pressure: 0.0,
time_ms: i as f64 * 16.0,
cr: 1.0,
cg: 0.0,
cb: 0.0,
ca: 1.0,
});
}
engine.end_stroke();
}
fn ants_bbox(prims: &[OverlayPrimitive]) -> (f32, f32, f32, f32) {
let (mut minx, mut miny, mut maxx, mut maxy) = (f32::MAX, f32::MAX, f32::MIN, f32::MIN);
let mut found = false;
for p in prims {
if p.kind != KIND_DASHED_LINE || (p.flags & FLAG_CANVAS_SPACE) == 0 {
continue;
}
for &[x, y] in &[p.p0, p.p1] {
found = true;
minx = minx.min(x);
miny = miny.min(y);
maxx = maxx.max(x);
maxy = maxy.max(y);
}
}
assert!(found, "no marching-ants primitives present");
(minx, miny, maxx, maxy)
}
fn pump(engine: &mut DarklyEngine, n: u32) {
for _ in 0..n {
engine.test_flush_readbacks();
engine.render(0.0);
}
}
fn rgba_with_red_square(w: u32, h: u32, x0: u32, y0: u32, x1: u32, y1: u32) -> Vec<u8> {
let mut rgba = vec![0u8; (w * h * 4) as usize];
for y in y0..y1 {
for x in x0..x1 {
let i = ((y * w + x) * 4) as usize;
rgba[i] = 255;
rgba[i + 3] = 255;
}
}
rgba
}
fn flood_fill(
engine: &mut DarklyEngine,
layer_id: LayerId,
x: f32,
y: f32,
color: [u8; 4],
tol: u8,
) {
engine.begin_stroke(layer_id);
engine.stroke_to(StrokeOp::FloodFill {
x,
y,
r: color[0],
g: color[1],
b: color[2],
a: color[3],
tolerance: tol,
});
engine.end_stroke();
}
#[test]
fn paint_lands_at_plane_coords_after_rescale() {
let (w, h) = (32u32, 32u32);
let mut engine = test_engine(w, h);
let layer = engine.add_raster_layer(None);
engine.rescale_image(2 * w, 2 * h); paint_feature(&mut engine, layer, 40.0, 40.0);
let px = engine.test_readback_layer(layer);
let (cx, cy) = red_centroid(&px, 2 * w, 2 * h);
assert!(
(cx - 40.0).abs() < 2.0 && (cy - 40.0).abs() < 2.0,
"post-rescale paint landed at ({cx}, {cy}), expected ~(40, 40)"
);
}
#[test]
fn paint_lands_at_plane_coords_after_rescale_undo() {
let (w, h) = (64u32, 64u32);
let mut engine = test_engine(w, h);
let layer = engine.add_raster_layer(None);
engine.rescale_image(2 * w, 2 * h);
engine.undo();
assert_eq!(engine.canvas_dimensions(), (w, h));
paint_feature(&mut engine, layer, 40.0, 40.0);
let px = engine.test_readback_layer(layer);
let (cx, cy) = red_centroid(&px, w, h);
assert!(
(cx - 40.0).abs() < 2.0 && (cy - 40.0).abs() < 2.0,
"post-undo paint landed at ({cx}, {cy}), expected ~(40, 40)"
);
}
#[test]
fn marquee_masks_same_plane_pixels_after_rescale() {
let (w, h) = (64u32, 64u32);
let mut engine = test_engine(w, h);
let layer = engine.add_raster_layer(None);
engine.rescale_image(2 * w, 2 * h); engine.select_rect(
16.0,
0.0,
48.0,
(2 * h) as f32,
SelectionMode::Replace,
false,
0.0,
);
paint_row(&mut engine, layer, 40.0, 16.0, 96.0);
let px = engine.test_readback_layer(layer);
assert!(
alpha_at(&px, 2 * w, 40, 40) > 0,
"a selected plane pixel must be painted through the marquee after rescale"
);
assert_eq!(
alpha_at(&px, 2 * w, 80, 40),
0,
"an unselected plane pixel must stay transparent after rescale"
);
}
#[test]
fn marquee_masks_same_plane_pixels_after_rescale_undo() {
let (w, h) = (64u32, 64u32);
let mut engine = test_engine(w, h);
let layer = engine.add_raster_layer(None);
engine.rescale_image(2 * w, 2 * h);
engine.undo();
assert_eq!(engine.canvas_dimensions(), (w, h));
engine.select_rect(8.0, 0.0, 24.0, h as f32, SelectionMode::Replace, false, 0.0);
paint_row(&mut engine, layer, 32.0, 8.0, 56.0);
let px = engine.test_readback_layer(layer);
assert!(
alpha_at(&px, w, 20, 32) > 0,
"selected plane pixel must be painted through the marquee after undo"
);
assert_eq!(
alpha_at(&px, w, 44, 32),
0,
"unselected plane pixel must stay transparent after undo"
);
}
#[test]
fn marching_ants_track_selection_after_rescale() {
let (w, h) = (32u32, 32u32);
let mut engine = test_engine(w, h);
let _layer = engine.add_raster_layer(None);
engine.rescale_image(2 * w, 2 * h); engine.select_rect(20.0, 16.0, 12.0, 10.0, SelectionMode::Replace, false, 0.0);
let (minx, miny, maxx, maxy) = ants_bbox(&engine.test_selection_overlay());
assert!(
(minx - 20.0).abs() <= 2.0 && (miny - 16.0).abs() <= 2.0,
"ants min ({minx}, {miny}) must track plane origin (20, 16) after rescale"
);
assert!(
(maxx - 32.0).abs() <= 2.0 && (maxy - 26.0).abs() <= 2.0,
"ants max ({maxx}, {maxy}) must track plane far edge (32, 26) after rescale"
);
}
#[test]
fn marching_ants_track_selection_after_rescale_undo() {
let (w, h) = (64u32, 64u32);
let mut engine = test_engine(w, h);
let _layer = engine.add_raster_layer(None);
engine.rescale_image(2 * w, 2 * h);
engine.undo();
assert_eq!(engine.canvas_dimensions(), (w, h));
engine.select_rect(16.0, 12.0, 10.0, 8.0, SelectionMode::Replace, false, 0.0);
let (minx, miny, maxx, maxy) = ants_bbox(&engine.test_selection_overlay());
assert!(
(minx - 16.0).abs() <= 2.0 && (miny - 12.0).abs() <= 2.0,
"ants min ({minx}, {miny}) must track plane origin (16, 12) after undo"
);
assert!(
(maxx - 26.0).abs() <= 2.0 && (maxy - 20.0).abs() <= 2.0,
"ants max ({maxx}, {maxy}) must track plane far edge (26, 20) after undo"
);
}
#[test]
fn flood_fill_fills_plane_region_after_rescale() {
let (w, h) = (32u32, 32u32);
let mut engine = test_engine(w, h);
let rgba = rgba_with_red_square(w, h, 8, 8, 16, 16);
let layer = engine.paste_image(w, h, &rgba, 0, 0, None);
engine.rescale_image(2 * w, 2 * h); flood_fill(&mut engine, layer, 24.0, 24.0, [0, 255, 0, 255], 50);
pump(&mut engine, 8);
let px = engine.test_readback_layer(layer);
let i = ((24 * 2 * w + 24) * 4) as usize;
assert!(
px[i] < 80 && px[i + 1] > 150,
"fill seed inside the scaled square must recolor it green, got {:?}",
&px[i..i + 4]
);
assert_eq!(
alpha_at(&px, 2 * w, 4, 4),
0,
"flood fill must not leak outside the seeded region after rescale"
);
}
#[test]
fn flood_fill_fills_plane_region_after_rescale_undo() {
let (w, h) = (32u32, 32u32);
let mut engine = test_engine(w, h);
let rgba = rgba_with_red_square(w, h, 8, 8, 16, 16);
let layer = engine.paste_image(w, h, &rgba, 0, 0, None);
engine.rescale_image(2 * w, 2 * h);
engine.undo();
assert_eq!(engine.canvas_dimensions(), (w, h));
flood_fill(&mut engine, layer, 12.0, 12.0, [0, 255, 0, 255], 50);
pump(&mut engine, 8);
let px = engine.test_readback_layer(layer);
let i = ((12 * w + 12) * 4) as usize;
assert!(
px[i] < 80 && px[i + 1] > 150,
"post-undo fill seed inside the square must recolor it green, got {:?}",
&px[i..i + 4]
);
assert_eq!(
alpha_at(&px, w, 2, 2),
0,
"flood fill must not leak outside the seeded region after undo"
);
}
#[test]
fn magic_wand_selects_plane_region_after_rescale() {
let (w, h) = (32u32, 32u32);
let mut engine = test_engine(w, h);
let rgba = rgba_with_red_square(w, h, 8, 8, 16, 16);
let layer = engine.paste_image(w, h, &rgba, 0, 0, None);
engine.rescale_image(2 * w, 2 * h); engine.select_magic_wand(layer, CanvasPoint::new(24, 24), 50, SelectionMode::Replace);
pump(&mut engine, 8);
assert!(
engine.has_selection(),
"magic wand should produce a selection"
);
let (minx, miny, maxx, maxy) = ants_bbox(&engine.test_selection_overlay());
assert!(
(minx - 16.0).abs() <= 3.0 && (miny - 16.0).abs() <= 3.0,
"wand selection min ({minx}, {miny}) must track scaled square origin (16, 16)"
);
assert!(
(maxx - 32.0).abs() <= 3.0 && (maxy - 32.0).abs() <= 3.0,
"wand selection max ({maxx}, {maxy}) must track scaled square far edge (32, 32)"
);
}
#[test]
fn magic_wand_selects_plane_region_after_rescale_undo() {
let (w, h) = (32u32, 32u32);
let mut engine = test_engine(w, h);
let rgba = rgba_with_red_square(w, h, 8, 8, 16, 16);
let layer = engine.paste_image(w, h, &rgba, 0, 0, None);
engine.rescale_image(2 * w, 2 * h);
engine.undo();
assert_eq!(engine.canvas_dimensions(), (w, h));
engine.select_magic_wand(layer, CanvasPoint::new(12, 12), 50, SelectionMode::Replace);
pump(&mut engine, 8);
assert!(
engine.has_selection(),
"magic wand should produce a selection"
);
let (minx, miny, maxx, maxy) = ants_bbox(&engine.test_selection_overlay());
assert!(
(minx - 8.0).abs() <= 3.0 && (miny - 8.0).abs() <= 3.0,
"wand selection min ({minx}, {miny}) must track square origin (8, 8) after undo"
);
assert!(
(maxx - 16.0).abs() <= 3.0 && (maxy - 16.0).abs() <= 3.0,
"wand selection max ({maxx}, {maxy}) must track square far edge (16, 16) after undo"
);
}
#[test]
fn transform_from_selection_plane_origin_after_rescale() {
let (w, h) = (32u32, 32u32);
let mut engine = test_engine(w, h);
let layer = engine.add_raster_layer(None);
engine.rescale_image(2 * w, 2 * h); paint_feature(&mut engine, layer, 32.0, 28.0);
engine.select_rect(24.0, 20.0, 16.0, 12.0, SelectionMode::Replace, false, 0.0);
assert!(engine.begin_transform(layer), "transform should set up");
let (sox, soy, sw, sh, _m) = engine.floating_info().expect("floating active");
assert!(
(sox - 24.0).abs() <= 1.0 && (soy - 20.0).abs() <= 1.0,
"source origin ({sox}, {soy}) must be the selection's plane origin (24, 20)"
);
assert!(
(sw - 16.0).abs() <= 1.0 && (sh - 12.0).abs() <= 1.0,
"source size ({sw}, {sh}) should match the selection (16, 12)"
);
}
#[test]
fn transform_from_selection_plane_origin_after_rescale_undo() {
let (w, h) = (32u32, 32u32);
let mut engine = test_engine(w, h);
let layer = engine.add_raster_layer(None);
engine.rescale_image(2 * w, 2 * h);
engine.undo();
assert_eq!(engine.canvas_dimensions(), (w, h));
paint_feature(&mut engine, layer, 16.0, 14.0);
engine.select_rect(12.0, 10.0, 8.0, 6.0, SelectionMode::Replace, false, 0.0);
assert!(engine.begin_transform(layer), "transform should set up");
let (sox, soy, sw, sh, _m) = engine.floating_info().expect("floating active");
assert!(
(sox - 12.0).abs() <= 1.0 && (soy - 10.0).abs() <= 1.0,
"source origin ({sox}, {soy}) must be the selection's plane origin (12, 10) after undo"
);
assert!(
(sw - 8.0).abs() <= 1.0 && (sh - 6.0).abs() <= 1.0,
"source size ({sw}, {sh}) should match the selection (8, 6) after undo"
);
}
#[test]
fn color_pick_reads_plane_pixel_after_rescale() {
use darkly::engine::PickSource;
let (w, h) = (32u32, 32u32);
let mut engine = test_engine(w, h);
engine.rescale_image(2 * w, 2 * h);
let mut rgba = vec![0u8; (2 * w * 2 * h * 4) as usize];
for px in rgba.chunks_exact_mut(4) {
px.copy_from_slice(&[200, 0, 0, 255]);
}
for y in 40..44 {
for x in 40..44 {
let i = ((y * 2 * w + x) * 4) as usize;
rgba[i..i + 4].copy_from_slice(&[0, 200, 0, 255]);
}
}
let _layer = engine.paste_image(2 * w, 2 * h, &rgba, 0, 0, None);
let _ = engine.test_readback_canvas();
engine.pick_color(41.0, 41.0, PickSource::Merged);
engine.test_flush_readbacks();
let c = engine.last_picked_color();
assert!(
c[1] > 150 && c[0] < 80,
"merged pick after rescale must read the plane pixel (green); got {c:?}"
);
}
#[test]
fn color_pick_reads_plane_pixel_after_rescale_undo() {
use darkly::engine::PickSource;
let (w, h) = (32u32, 32u32);
let mut engine = test_engine(w, h);
engine.rescale_image(2 * w, 2 * h);
engine.undo();
assert_eq!(engine.canvas_dimensions(), (w, h));
let mut rgba = vec![0u8; (w * h * 4) as usize];
for px in rgba.chunks_exact_mut(4) {
px.copy_from_slice(&[200, 0, 0, 255]);
}
for y in 20..24 {
for x in 20..24 {
let i = ((y * w + x) * 4) as usize;
rgba[i..i + 4].copy_from_slice(&[0, 200, 0, 255]);
}
}
let _layer = engine.paste_image(w, h, &rgba, 0, 0, None);
let _ = engine.test_readback_canvas();
engine.pick_color(21.0, 21.0, PickSource::Merged);
engine.test_flush_readbacks();
let c = engine.last_picked_color();
assert!(
c[1] > 150 && c[0] < 80,
"merged pick after undo must read the plane pixel (green); got {c:?}"
);
}
#[test]
fn screen_to_plane_tracks_dims_through_rescale_and_undo() {
let (w, h) = (64u32, 64u32);
let mut engine = test_engine(w, h);
let _layer = engine.add_raster_layer(None);
let (sw, sh) = (200.0_f32, 200.0_f32);
engine.set_view_transform(0.0, 0.0, 1.0, 0.0, false, sw, sh);
let (cx0, cy0) = engine.screen_to_plane(sw / 2.0, sh / 2.0);
assert!(
(cx0 - 32.0).abs() < 1e-2 && (cy0 - 32.0).abs() < 1e-2,
"pre-rescale center should map to (32, 32), got ({cx0}, {cy0})"
);
engine.rescale_image(2 * w, 2 * h); let (cx1, cy1) = engine.screen_to_plane(sw / 2.0, sh / 2.0);
assert!(
(cx1 - 64.0).abs() < 1e-2 && (cy1 - 64.0).abs() < 1e-2,
"post-rescale center must map to the new canvas center (64, 64), got ({cx1}, {cy1})"
);
engine.undo();
let (cx2, cy2) = engine.screen_to_plane(sw / 2.0, sh / 2.0);
assert!(
(cx2 - 32.0).abs() < 1e-2 && (cy2 - 32.0).abs() < 1e-2,
"after undo, center must map back to (32, 32), got ({cx2}, {cy2})"
);
}