use darkly::brush::nodes::pen_input;
use darkly::brush::wire::BrushWireType;
use darkly::document::SelectionMode;
use darkly::engine::types::StrokeOp;
use darkly::engine::DarklyEngine;
use darkly::gpu::context::GpuContext;
use darkly::gpu::test_utils::test_device;
use darkly::layer::LayerId;
use darkly::nodegraph::NodeInstance;
fn paint_at(engine: &mut DarklyEngine, layer_id: LayerId, x: f32, y: f32, r: f32, g: f32, b: f32) {
engine.begin_stroke(layer_id);
engine.stroke_to(StrokeOp::BrushStroke {
x,
y,
pressure: 1.0,
x_tilt: 0.0,
y_tilt: 0.0,
rotation: 0.0,
tangential_pressure: 0.0,
time_ms: 0.0,
cr: r,
cg: g,
cb: b,
ca: 1.0,
});
engine.end_stroke();
engine.render(0.0);
}
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_full_stroke(engine: &mut DarklyEngine, layer_id: LayerId, w: u32, h: u32) {
engine.begin_stroke(layer_id);
for x_step in 0..20 {
let x = x_step as f32 * (w as f32 / 20.0);
engine.stroke_to(StrokeOp::BrushStroke {
x,
y: (h / 2) as f32,
pressure: 1.0,
x_tilt: 0.0,
y_tilt: 0.0,
rotation: 0.0,
tangential_pressure: 0.0,
time_ms: x_step as f64 * 16.0,
cr: 1.0,
cg: 0.0,
cb: 0.0,
ca: 1.0,
});
}
engine.end_stroke();
}
fn alpha_at(pixels: &[u8], w: u32, x: u32, y: u32) -> u8 {
pixels[((y * w + x) * 4 + 3) as usize]
}
#[test]
fn engine_brush_stroke_respects_selection() {
let (w, h) = (128, 128);
let mut engine = test_engine(w, h);
let layer_id = engine.add_raster_layer(None);
engine.select_rect(
0.0,
0.0,
(w / 2) as f32,
h as f32,
SelectionMode::Replace,
false,
0.0,
);
paint_full_stroke(&mut engine, layer_id, w, h);
let pixels = engine.test_readback_layer(layer_id);
assert!(
alpha_at(&pixels, w, w / 4, h / 2) > 0,
"left (selected) should have paint"
);
assert_eq!(
alpha_at(&pixels, w, 3 * w / 4, h / 2),
0,
"right (unselected) should be transparent"
);
}
#[test]
fn engine_transform_bounds_are_tight() {
let (w, h) = (256, 256);
let mut engine = test_engine(w, h);
let layer_id = engine.add_raster_layer(None);
let sel_x = 17.0_f32;
let sel_y = 23.0_f32;
let sel_w = 30.0_f32;
let sel_h = 45.0_f32;
engine.select_rect(
sel_x,
sel_y,
sel_w,
sel_h,
SelectionMode::Replace,
false,
0.0,
);
paint_at(
&mut engine,
layer_id,
sel_x + sel_w / 2.0,
sel_y + sel_h / 2.0,
1.0,
0.0,
0.0,
);
let started = engine.begin_transform(layer_id);
assert!(started, "begin_transform should succeed with a selection");
let (origin_x, origin_y, float_w, float_h, _) = engine.floating_info().unwrap();
assert!(
(float_w as i32 - sel_w as i32).unsigned_abs() <= 1,
"width should be ~{}, got {float_w}",
sel_w as u32
);
assert!(
(float_h as i32 - sel_h as i32).unsigned_abs() <= 1,
"height should be ~{}, got {float_h}",
sel_h as u32
);
assert!(
(origin_x as i32 - sel_x as i32).abs() <= 1,
"origin X should be ~{sel_x}, got {origin_x}"
);
assert!(
(origin_y as i32 - sel_y as i32).abs() <= 1,
"origin Y should be ~{sel_y}, got {origin_y}"
);
}
#[test]
fn paste_floating_cancel_removes_layer() {
let (w, h) = (128, 128);
let mut engine = test_engine(w, h);
let base_layer = engine.add_raster_layer(None);
let pw: u32 = 8;
let ph: u32 = 8;
let rgba = vec![0xFFu8; (pw * ph * 4) as usize];
let pasted_id = engine.paste_image_floating(pw, ph, &rgba, 10, 10, Some(base_layer));
assert!(
engine.has_layer(pasted_id),
"auto-created paste layer should exist after paste_image_floating"
);
assert!(
engine.has_floating(),
"should be in floating mode after paste_image_floating"
);
engine.cancel_floating();
assert!(
!engine.has_floating(),
"floating should be cleared after cancel"
);
assert!(
!engine.has_layer(pasted_id),
"auto-created paste layer should be removed after cancel"
);
assert!(
engine.has_layer(base_layer),
"pre-existing layer must remain after cancel"
);
engine.undo();
assert!(
!engine.has_layer(pasted_id),
"undo after cancel must not resurrect the pasted layer"
);
}
#[test]
fn transform_on_off_canvas_layer_cancel_restores_pixels() {
let (cw, ch) = (64, 64);
let mut engine = test_engine(cw, ch);
let _base = engine.add_raster_layer(None);
let pw: u32 = 128;
let ph: u32 = 128;
let mut rgba = vec![0u8; (pw * ph * 4) as usize];
for px in rgba.chunks_exact_mut(4) {
px[0] = 255;
px[3] = 255;
}
let pasted_id = engine.paste_image(pw, ph, &rgba, -32, -32, None);
let before = engine.test_readback_layer(pasted_id);
let started = engine.begin_transform(pasted_id);
assert!(
!started,
"no-selection path should defer for content_bounds"
);
let mut floating_ready = false;
for _ in 0..16 {
engine.test_flush_readbacks();
engine.render(0.0);
if engine.has_floating() {
floating_ready = true;
break;
}
}
assert!(
floating_ready,
"begin_transform did not resolve within 16 iterations"
);
let (ox, oy, fw, fh, _) = engine.floating_info().expect("floating info");
assert_eq!(
(ox as i32, oy as i32),
(-32, -32),
"source_origin should be canvas-space (layer offset), not layer-local (0,0)"
);
assert_eq!(fw as u32, pw);
assert_eq!(fh as u32, ph);
engine.cancel_floating();
let after = engine.test_readback_layer(pasted_id);
assert_eq!(
before, after,
"layer pixels must be byte-identical after cancel"
);
}
#[test]
fn commit_floating_translate_past_canvas_preserves_pixels() {
use darkly::coord::CanvasRect;
use darkly::gpu::transform::affine_translate;
let (cw, ch) = (64, 64);
let mut engine = test_engine(cw, ch);
let _base = engine.add_raster_layer(None);
let bw: u32 = 8;
let bh: u32 = 8;
let mut rgba = vec![0u8; (bw * bh * 4) as usize];
for px in rgba.chunks_exact_mut(4) {
px[0] = 255;
px[3] = 255;
}
let red_layer = engine.paste_image(bw, bh, &rgba, 50, 30, None);
assert_eq!(
engine.layer_bounds(red_layer),
Some(CanvasRect::from_xywh(50, 30, bw, bh)),
"pasted layer should start sized to the paste"
);
engine.select_rect(
50.0,
30.0,
bw as f32,
bh as f32,
SelectionMode::Replace,
false,
0.0,
);
let started = engine.begin_transform(red_layer);
assert!(
started,
"begin_transform with a selection should be synchronous"
);
engine.update_floating_matrix(affine_translate(20.0, 0.0));
engine.commit_floating();
let bounds = engine
.layer_bounds(red_layer)
.expect("layer must still exist after commit");
assert!(
bounds.contains(CanvasRect::from_xywh(70, 30, bw, bh)),
"layer bounds must contain the off-canvas translated rect (70, 30, 8, 8); got {:?}",
bounds
);
let pixels = engine.test_readback_layer(red_layer);
let lw = bounds.width;
let lx = (70 - bounds.x0()) as u32;
let ly = (30 - bounds.y0()) as u32;
let p_idx = ((ly * lw + lx) * 4) as usize;
assert!(
pixels[p_idx] > 200,
"moved pixel at canvas (70, 30) should be red, got R={}",
pixels[p_idx]
);
assert!(
pixels[p_idx + 3] > 200,
"moved pixel at canvas (70, 30) should be opaque, got A={}",
pixels[p_idx + 3]
);
}
#[test]
fn paste_image_floating_preserves_off_canvas_extent() {
use darkly::coord::CanvasRect;
let (cw, ch) = (64, 64);
let mut engine = test_engine(cw, ch);
let _base = engine.add_raster_layer(None);
let pw: u32 = 256;
let ph: u32 = 256;
let rgba = vec![0x88u8; (pw * ph * 4) as usize];
let ox = (cw as i32 - pw as i32) / 2;
let oy = (ch as i32 - ph as i32) / 2;
let pasted_id = engine.paste_image_floating(pw, ph, &rgba, ox, oy, None);
let bounds = engine
.layer_bounds(pasted_id)
.expect("pasted layer must have bounds");
assert_eq!(
bounds,
CanvasRect::from_xywh(ox, oy, pw, ph),
"layer bounds must match the full paste extent"
);
engine.commit_floating();
let bounds = engine
.layer_bounds(pasted_id)
.expect("pasted layer still exists after commit");
assert_eq!(bounds.width, pw);
assert_eq!(bounds.height, ph);
}
#[test]
fn paste_image_direct_preserves_off_canvas_extent() {
use darkly::coord::CanvasRect;
let (cw, ch) = (64, 64);
let mut engine = test_engine(cw, ch);
let _base = engine.add_raster_layer(None);
let pw: u32 = 200;
let ph: u32 = 100;
let rgba = vec![0x44u8; (pw * ph * 4) as usize];
let pasted_id = engine.paste_image(pw, ph, &rgba, -50, 10, None);
let bounds = engine
.layer_bounds(pasted_id)
.expect("pasted layer must have bounds");
assert_eq!(
bounds,
CanvasRect::from_xywh(-50, 10, pw, ph),
"direct paste layer bounds must match the full paste extent"
);
}
#[test]
fn paste_floating_target_layer_matches_created() {
let (w, h) = (128, 128);
let mut engine = test_engine(w, h);
let base_layer = engine.add_raster_layer(None);
assert_eq!(
engine.floating_target_layer(),
None,
"no floating, no target"
);
let pw: u32 = 8;
let ph: u32 = 8;
let rgba = vec![0xFFu8; (pw * ph * 4) as usize];
let pasted_id = engine.paste_image_floating(pw, ph, &rgba, 10, 10, Some(base_layer));
assert_eq!(
engine.floating_target_layer(),
Some(pasted_id),
"floating_target_layer must match the pasted layer id"
);
engine.cancel_floating();
assert_eq!(
engine.floating_target_layer(),
None,
"no target after cancel"
);
}
#[test]
fn paste_floating_commit_is_one_undo() {
let (w, h) = (128, 128);
let mut engine = test_engine(w, h);
let base_layer = engine.add_raster_layer(None);
let pw: u32 = 8;
let ph: u32 = 8;
let rgba = vec![0xFFu8; (pw * ph * 4) as usize];
let pasted_id = engine.paste_image_floating(pw, ph, &rgba, 10, 10, Some(base_layer));
engine.commit_floating();
assert!(engine.has_layer(pasted_id), "pasted layer should remain");
assert!(!engine.has_floating(), "floating cleared after commit");
engine.undo();
assert!(
!engine.has_layer(pasted_id),
"single undo must remove the pasted layer entirely"
);
}
#[test]
fn thumbnail_readback_handles_layer_smaller_than_canvas() {
let (cw, ch) = (1920, 1080);
let mut engine = test_engine(cw, ch);
let _base = engine.add_raster_layer(None);
let pw: u32 = 256;
let ph: u32 = 256;
let mut rgba = vec![0u8; (pw * ph * 4) as usize];
for px in rgba.chunks_exact_mut(4) {
px[1] = 200;
px[3] = 255;
}
let pasted = engine.paste_image(pw, ph, &rgba, 100, 100, None);
engine.render(0.0);
for _ in 0..8 {
engine.test_flush_readbacks();
if engine.test_thumbnail_cache_peek(pasted).is_some() {
break;
}
}
let thumb = engine
.test_thumbnail_cache_peek(pasted)
.expect("thumbnail must complete after a few flushes");
let any_visible = thumb.chunks_exact(4).any(|p| p[3] > 0);
assert!(
any_visible,
"thumbnail of a non-empty paste must contain visible pixels"
);
}
#[test]
fn paste_image_floating_preview_visible_before_any_drag() {
let (cw, ch) = (64, 64);
let mut engine = test_engine(cw, ch);
let _base = engine.add_raster_layer(None);
let pw: u32 = 32;
let ph: u32 = 32;
let mut rgba = vec![0u8; (pw * ph * 4) as usize];
for px in rgba.chunks_exact_mut(4) {
px[1] = 255;
px[3] = 255;
}
let ox = (cw as i32 - pw as i32) / 2;
let oy = (ch as i32 - ph as i32) / 2;
let _pasted = engine.paste_image_floating(pw, ph, &rgba, ox, oy, None);
let canvas = engine.test_readback_canvas();
let center_x = (cw / 2) as usize;
let center_y = (ch / 2) as usize;
let p_idx = (center_y * cw as usize + center_x) * 4;
assert!(
canvas[p_idx + 1] > 200,
"center pixel must be green from the paste preview, got G={}",
canvas[p_idx + 1]
);
assert!(
canvas[p_idx + 3] > 200,
"center pixel must be opaque, got A={}",
canvas[p_idx + 3]
);
}
#[test]
fn floating_preview_visible_when_translation_extends_past_source_bbox() {
use darkly::gpu::transform::affine_translate;
let (cw, ch) = (64, 64);
let mut engine = test_engine(cw, ch);
let _base = engine.add_raster_layer(None);
let bw: u32 = 8;
let bh: u32 = 8;
let mut rgba = vec![0u8; (bw * bh * 4) as usize];
for px in rgba.chunks_exact_mut(4) {
px[0] = 255;
px[3] = 255;
}
let _pasted = engine.paste_image_floating(bw, bh, &rgba, 10, 10, None);
engine.update_floating_matrix(affine_translate(20.0, 0.0));
let canvas = engine.test_readback_canvas();
let pixel_at = |x: u32, y: u32| {
let i = ((y * cw + x) * 4) as usize;
[canvas[i], canvas[i + 1], canvas[i + 2], canvas[i + 3]]
};
let moved = pixel_at(34, 14);
assert!(
moved[0] > 200 && moved[3] > 200,
"moved pixel at canvas (34, 14) should be opaque red, got {:?}",
moved
);
let old = pixel_at(14, 14);
assert!(
old[3] < 50,
"old position (14, 14) should be transparent during transform, got A={}",
old[3]
);
}
#[test]
fn floating_preview_does_not_leave_ghost_pixels_when_dragged() {
use darkly::gpu::transform::affine_translate;
let (cw, ch) = (64, 64);
let mut engine = test_engine(cw, ch);
let _base = engine.add_raster_layer(None);
let bw: u32 = 8;
let bh: u32 = 8;
let mut rgba = vec![0u8; (bw * bh * 4) as usize];
for px in rgba.chunks_exact_mut(4) {
px[0] = 255;
px[3] = 255;
}
let _pasted = engine.paste_image_floating(bw, bh, &rgba, 10, 10, None);
engine.update_floating_matrix(affine_translate(15.0, 0.0));
let _ = engine.test_readback_canvas();
engine.update_floating_matrix(affine_translate(30.0, 0.0));
let canvas = engine.test_readback_canvas();
let pixel_at = |x: u32, y: u32| {
let i = ((y * cw + x) * 4) as usize;
canvas[i + 3]
};
assert!(
pixel_at(44, 14) > 200,
"current drag position (44, 14) must be opaque, got A={}",
pixel_at(44, 14)
);
assert!(
pixel_at(29, 14) < 50,
"previous-drag position (29, 14) must NOT retain pixels, got A={}",
pixel_at(29, 14)
);
}
#[test]
fn lasso_selection_performance_and_correctness() {
let (w, h) = (1024, 1024);
let mut engine = test_engine(w, h);
let layer_id = engine.add_raster_layer(None);
let cx = 500.0_f32;
let cy = 500.0_f32;
let r = 200.0_f32;
let n_verts = 200;
let vertices: Vec<[f32; 2]> = (0..n_verts)
.map(|i| {
let angle = 2.0 * std::f32::consts::PI * i as f32 / n_verts as f32;
[cx + r * angle.cos(), cy + r * angle.sin()]
})
.collect();
let start = std::time::Instant::now();
engine.select_lasso(&vertices, SelectionMode::Replace, true, 0.0);
let elapsed = start.elapsed();
let ms = elapsed.as_secs_f64() * 1000.0;
eprintln!("select_lasso({n_verts} verts, {w}x{h}): {ms:.1}ms");
assert!(
ms < 50.0,
"select_lasso with {n_verts} verts took {ms:.1}ms, expected <50ms"
);
assert!(engine.has_selection());
engine.begin_stroke(layer_id);
for x_step in 0..40 {
let x = x_step as f32 * (w as f32 / 40.0);
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: x_step as f64 * 16.0,
cr: 1.0,
cg: 0.0,
cb: 0.0,
ca: 1.0,
});
}
engine.end_stroke();
let pixels = engine.test_readback_layer(layer_id);
assert!(
alpha_at(&pixels, w, cx as u32, cy as u32) > 0,
"center of lasso should have paint"
);
assert_eq!(
alpha_at(&pixels, w, 50, cy as u32),
0,
"outside lasso should be transparent"
);
}
fn find_node_id(engine: &DarklyEngine, type_id: &str) -> u64 {
engine
.active_brush_graph()
.nodes()
.values()
.find(|n: &&NodeInstance<BrushWireType>| n.type_id == type_id)
.unwrap_or_else(|| panic!("no '{type_id}' node in default graph"))
.id
.0
}
fn alpha_sum(pixels: &[u8], w: u32, h: u32) -> u64 {
let mut s: u64 = 0;
for y in 0..h {
for x in 0..w {
s += alpha_at(pixels, w, x, y) as u64;
}
}
s
}
fn paint_horizontal_stroke(engine: &mut DarklyEngine, layer_id: LayerId, w: u32, h: u32) {
engine.begin_stroke(layer_id);
let samples = 40;
for i in 0..samples {
let t = i as f32 / (samples - 1) as f32;
let x = 16.0 + t * (w as f32 - 32.0);
engine.stroke_to(StrokeOp::BrushStroke {
x,
y: (h / 2) as f32,
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();
engine.render(0.0);
}
#[test]
fn pen_input_spacing_port_controls_dab_density() {
let (w, h) = (256, 256);
let mut engine = test_engine(w, h);
let layer_id = engine.add_raster_layer(None);
let pen_id = find_node_id(&engine, pen_input::TYPE_ID);
engine
.brush_graph_set_port_default(pen_id, "spacing", 0.10)
.expect("default spacing port must exist");
paint_horizontal_stroke(&mut engine, layer_id, w, h);
let dense_alpha = alpha_sum(&engine.test_readback_layer(layer_id), w, h);
let mut engine = test_engine(w, h);
let layer_id = engine.add_raster_layer(None);
let pen_id = find_node_id(&engine, pen_input::TYPE_ID);
engine
.brush_graph_set_port_default(pen_id, "spacing", 1.0)
.expect("spacing port must exist");
paint_horizontal_stroke(&mut engine, layer_id, w, h);
let sparse_alpha = alpha_sum(&engine.test_readback_layer(layer_id), w, h);
assert!(
sparse_alpha * 4 < dense_alpha * 3,
"expected 100% spacing to deposit noticeably less paint than 10%; \
got dense={dense_alpha}, sparse={sparse_alpha} (sparse/dense = {:.2})",
sparse_alpha as f64 / dense_alpha as f64
);
}
#[test]
fn small_brush_does_not_emit_subpixel_dab_spacing() {
let (w, h) = (256, 256);
let mut engine = test_engine(w, h);
let layer_id = engine.add_raster_layer(None);
let pen_id = find_node_id(&engine, pen_input::TYPE_ID);
engine
.brush_graph_set_port_default(pen_id, "spacing", 0.04)
.expect("spacing port must exist");
let x0 = 16.0_f32;
let x1 = (w as f32) - 16.0;
let stroke_length_px = (x1 - x0).abs();
engine.begin_stroke(layer_id);
let samples = 40;
for i in 0..samples {
let t = i as f32 / (samples - 1) as f32;
let x = x0 + t * (x1 - x0);
engine.stroke_to(StrokeOp::BrushStroke {
x,
y: (h / 2) as f32,
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();
engine.render(0.0);
let dabs = engine.test_stroke_total_dabs();
let max_expected = (stroke_length_px.ceil() as u64) * 4;
assert!(
dabs <= max_expected,
"tiny-brush stroke emitted {dabs} dabs across {stroke_length_px:.0}px \
of stroke (gross-regression bound {max_expected}); spacing floor \
appears to have been bypassed"
);
}
#[test]
fn brush_stroke_on_paste_extent_layer_undo_preserves_off_canvas_pixels() {
let (cw, ch) = (64, 64);
let mut engine = test_engine(cw, ch);
let pw: u32 = 200;
let ph: u32 = 200;
let rgba: Vec<u8> = (0..pw * ph).flat_map(|_| [10u8, 20, 200, 255]).collect();
let pasted_id = engine.paste_image(pw, ph, &rgba, -50, -50, None);
let pre_stroke = engine.test_readback_layer(pasted_id);
assert_eq!(pre_stroke.len(), (pw * ph * 4) as usize);
paint_at(&mut engine, pasted_id, 10.0, 10.0, 1.0, 0.0, 0.0);
let after_stroke = engine.test_readback_layer(pasted_id);
assert_ne!(
pre_stroke, after_stroke,
"stroke should have changed at least one pixel"
);
engine.undo();
engine.render(0.0);
let after_undo = engine.test_readback_layer(pasted_id);
assert_eq!(
pre_stroke, after_undo,
"undo on paste-extent layer must restore byte-identical pre-stroke pixels (including off-canvas)"
);
}
#[test]
fn brush_stroke_on_paste_extent_layer_lands_at_canvas_coords() {
let (cw, ch) = (64, 64);
let mut engine = test_engine(cw, ch);
let pw: u32 = 200;
let ph: u32 = 200;
let rgba = vec![0u8; (pw * ph * 4) as usize]; let off_x = -50;
let off_y = -50;
let pasted_id = engine.paste_image(pw, ph, &rgba, off_x, off_y, None);
paint_at(&mut engine, pasted_id, 10.0, 10.0, 1.0, 0.0, 0.0);
let pixels = engine.test_readback_layer(pasted_id);
let lx = (10 - off_x) as u32;
let ly = (10 - off_y) as u32;
let mut hit = false;
for dy in 0..6u32 {
for dx in 0..6u32 {
let px = lx.saturating_sub(3) + dx;
let py = ly.saturating_sub(3) + dy;
if alpha_at(&pixels, pw, px, py) > 0 {
hit = true;
break;
}
}
}
assert!(
hit,
"stroke must land at layer-local ({lx}, {ly}) — canvas-space coords expected"
);
let mut wrong_hit = 0u32;
for dy in 0..6u32 {
for dx in 0..6u32 {
let px = (10u32).saturating_sub(3) + dx;
let py = (10u32).saturating_sub(3) + dy;
wrong_hit = wrong_hit.max(alpha_at(&pixels, pw, px, py) as u32);
}
}
assert_eq!(
wrong_hit, 0,
"layer-local (10, 10) area should be untouched (would be wrong-place stroke)"
);
}
#[test]
fn brush_stroke_off_canvas_grows_layer() {
let (cw, ch) = (256u32, 256u32);
let mut engine = test_engine(cw, ch);
let layer_id = engine.add_raster_layer(None);
let bounds_before = engine.layer_bounds(layer_id).expect("layer exists");
assert_eq!(bounds_before.origin.x, 0);
assert_eq!(bounds_before.origin.y, 0);
assert_eq!(bounds_before.width, cw);
assert_eq!(bounds_before.height, ch);
paint_at(
&mut engine,
layer_id,
cw as f32 + 50.0,
ch as f32 / 2.0,
1.0,
0.0,
0.0,
);
let bounds_after = engine.layer_bounds(layer_id).expect("layer still exists");
assert!(
bounds_after.width > cw,
"layer width should have grown past canvas; before {}, after {}",
cw,
bounds_after.width,
);
assert_eq!(
bounds_after.origin.x, 0,
"positive-direction growth should keep origin at 0"
);
}
#[test]
fn brush_stroke_off_canvas_pixel_lands_correctly() {
let (cw, ch) = (256u32, 256u32);
let mut engine = test_engine(cw, ch);
let layer_id = engine.add_raster_layer(None);
let canvas_x: i32 = cw as i32 + 80;
let canvas_y: i32 = ch as i32 / 2;
paint_at(
&mut engine,
layer_id,
canvas_x as f32,
canvas_y as f32,
1.0,
0.0,
0.0,
);
let bounds = engine.layer_bounds(layer_id).expect("layer exists");
let pixels = engine.test_readback_layer(layer_id);
assert_eq!(
pixels.len(),
(bounds.width * bounds.height * 4) as usize,
"readback should match grown layer dimensions"
);
let lx = (canvas_x - bounds.origin.x) as u32;
let ly = (canvas_y - bounds.origin.y) as u32;
let half: u32 = 64;
let mut hit = false;
'outer: for dy in 0..(half * 2) {
for dx in 0..(half * 2) {
let px = lx.saturating_sub(half) + dx;
let py = ly.saturating_sub(half) + dy;
if px < bounds.width
&& py < bounds.height
&& alpha_at(&pixels, bounds.width, px, py) > 0
{
hit = true;
break 'outer;
}
}
}
assert!(
hit,
"off-canvas paint at canvas ({canvas_x}, {canvas_y}) should land at layer-local ({lx}, {ly})"
);
}
#[test]
fn layer_growth_negative_direction() {
let (cw, ch) = (256u32, 256u32);
let mut engine = test_engine(cw, ch);
let layer_id = engine.add_raster_layer(None);
paint_at(
&mut engine,
layer_id,
-100.0,
ch as f32 / 2.0,
0.0,
1.0,
0.0,
);
let bounds = engine.layer_bounds(layer_id).expect("layer exists");
assert!(
bounds.origin.x <= -256,
"negative-direction growth should shift offset_x by at least one chunk; got {}",
bounds.origin.x
);
assert!(
bounds.width >= cw + 256,
"width should expand to cover the new origin shift; got {}",
bounds.width
);
}
#[test]
fn layer_growth_negative_direction_y() {
let (cw, ch) = (256u32, 256u32);
let mut engine = test_engine(cw, ch);
let layer_id = engine.add_raster_layer(None);
paint_at(
&mut engine,
layer_id,
cw as f32 / 2.0,
-100.0,
0.0,
0.0,
1.0,
);
let bounds = engine.layer_bounds(layer_id).expect("layer exists");
assert!(
bounds.origin.y <= -256,
"negative-direction Y growth should shift offset_y by at least one chunk; got {}",
bounds.origin.y
);
assert!(
bounds.height >= ch + 256,
"height should expand to cover the new origin shift; got {}",
bounds.height
);
}
#[test]
fn layer_growth_chunked_to_256() {
let (cw, ch) = (256u32, 256u32);
let mut engine = test_engine(cw, ch);
let layer_id = engine.add_raster_layer(None);
paint_at(
&mut engine,
layer_id,
cw as f32 + 1.0,
ch as f32 / 2.0,
1.0,
0.0,
0.0,
);
let bounds = engine.layer_bounds(layer_id).expect("layer exists");
assert!(
bounds.width >= cw + 256,
"1-pixel overshoot should still snap to a full chunk: width={}",
bounds.width
);
assert_eq!(
bounds.width % 256,
0,
"width should be chunk-aligned: {}",
bounds.width
);
}
#[test]
fn undo_after_growth_restores_pixels_in_old_bounds() {
let (cw, ch) = (256u32, 256u32);
let mut engine = test_engine(cw, ch);
let layer_id = engine.add_raster_layer(None);
paint_at(&mut engine, layer_id, 64.0, 64.0, 1.0, 0.0, 0.0);
let pre_stroke = engine.test_readback_layer(layer_id);
let pre_bounds = engine.layer_bounds(layer_id).unwrap();
paint_at(
&mut engine,
layer_id,
cw as f32 + 80.0,
ch as f32 / 2.0,
0.0,
1.0,
0.0,
);
let grown_bounds = engine.layer_bounds(layer_id).unwrap();
assert!(
grown_bounds.width > pre_bounds.width,
"layer should have grown"
);
engine.undo();
engine.render(0.0);
let after_undo = engine.test_readback_layer(layer_id);
let after_bounds = engine.layer_bounds(layer_id).unwrap();
assert_eq!(after_bounds, grown_bounds, "undo doesn't shrink bounds");
for x in 0..pre_bounds.width {
let pre_idx = (((64) * pre_bounds.width + x) * 4) as usize;
let new_x = x as i32 + (pre_bounds.origin.x - after_bounds.origin.x);
let new_y = 64i32 + (pre_bounds.origin.y - after_bounds.origin.y);
if new_x < 0 || new_y < 0 {
continue;
}
let cur_idx = (((new_y as u32) * after_bounds.width + new_x as u32) * 4) as usize;
assert_eq!(
&pre_stroke[pre_idx..pre_idx + 4],
&after_undo[cur_idx..cur_idx + 4],
"row 64 col {x}: pre-stroke pixels in the old bounds must be restored after undo"
);
}
}
#[test]
fn layer_growth_capped_at_max() {
use darkly::gpu::compositor::MAX_LAYER_DIM;
let (cw, ch) = (256u32, 256u32);
let mut engine = test_engine(cw, ch);
let layer_id = engine.add_raster_layer(None);
paint_at(
&mut engine,
layer_id,
(MAX_LAYER_DIM as f32) + 1000.0,
ch as f32 / 2.0,
1.0,
0.0,
0.0,
);
let bounds = engine.layer_bounds(layer_id).unwrap();
assert!(
bounds.width <= MAX_LAYER_DIM,
"layer width must stay within MAX_LAYER_DIM; got {}",
bounds.width
);
assert!(
bounds.height <= MAX_LAYER_DIM,
"layer height must stay within MAX_LAYER_DIM; got {}",
bounds.height
);
}
#[test]
fn mid_stroke_growth_preserves_already_saved_region() {
let (cw, ch) = (256u32, 256u32);
let mut engine = test_engine(cw, ch);
let layer_id = engine.add_raster_layer(None);
paint_at(&mut engine, layer_id, 100.0, 100.0, 1.0, 0.0, 0.0);
let pre_stroke_bounds = engine.layer_bounds(layer_id).unwrap();
let pre_stroke = engine.test_readback_layer(layer_id);
engine.begin_stroke(layer_id);
for x_step in 0..10 {
let x = (cw as f32) * 0.4 + (x_step as f32) * 80.0;
engine.stroke_to(StrokeOp::BrushStroke {
x,
y: ch as f32 / 2.0,
pressure: 1.0,
x_tilt: 0.0,
y_tilt: 0.0,
rotation: 0.0,
tangential_pressure: 0.0,
time_ms: x_step as f64 * 16.0,
cr: 0.0,
cg: 0.0,
cb: 1.0,
ca: 1.0,
});
}
engine.end_stroke();
engine.render(0.0);
let grown_bounds = engine.layer_bounds(layer_id).unwrap();
assert!(
grown_bounds.width > pre_stroke_bounds.width,
"stroke should have grown the layer"
);
engine.undo();
engine.render(0.0);
let after_undo = engine.test_readback_layer(layer_id);
let after_bounds = engine.layer_bounds(layer_id).unwrap();
let lx = (100 - after_bounds.origin.x) as u32;
let ly = (100 - after_bounds.origin.y) as u32;
let mut found_red = false;
for dy in 0..8u32 {
for dx in 0..8u32 {
let px = lx.saturating_sub(4) + dx;
let py = ly.saturating_sub(4) + dy;
if px < after_bounds.width && py < after_bounds.height {
let idx = ((py * after_bounds.width + px) * 4) as usize;
if after_undo[idx] > 200 && after_undo[idx + 3] > 200 {
found_red = true;
break;
}
}
}
}
let _ = pre_stroke; assert!(
found_red,
"after-undo: pre-stroke red pixels at canvas (100, 100) must survive mid-stroke grow"
);
}
#[test]
fn layer_info_carries_paste_extent_bounds_through_serde() {
use darkly::coord::CanvasRect;
use darkly::engine::types::LayerInfo;
let (cw, ch) = (64, 64);
let mut engine = test_engine(cw, ch);
let _base = engine.add_raster_layer(None);
let pw: u32 = 200;
let ph: u32 = 200;
let rgba = vec![0x33u8; (pw * ph * 4) as usize];
let pasted_id = engine.paste_image(pw, ph, &rgba, -50, -50, None);
let tree = engine.layer_tree();
let mut found_bounds: Option<CanvasRect> = None;
for info in &tree {
if let LayerInfo::Raster { id, bounds, .. } = info {
if *id as u64 == pasted_id.to_ffi() {
found_bounds = Some(*bounds);
break;
}
}
}
let bounds = found_bounds.expect("pasted layer must appear in layer_tree as Raster");
assert_eq!(
bounds,
CanvasRect::from_xywh(-50, -50, pw, ph),
"LayerInfo bounds must reflect the actual paste extent"
);
let json = serde_json::to_string(&bounds).expect("bounds must serialize");
let decoded: CanvasRect =
serde_json::from_str(&json).expect("bounds must deserialize byte-identically");
assert_eq!(decoded, bounds);
let value: serde_json::Value =
serde_json::from_str(&json).expect("bounds JSON must parse as Value");
assert_eq!(value["origin"]["x"], -50);
assert_eq!(value["origin"]["y"], -50);
assert_eq!(value["width"], pw);
assert_eq!(value["height"], ph);
}
#[test]
fn paste_cancel_cycles_dont_leak_layer_textures() {
let (cw, ch) = (64, 64);
let mut engine = test_engine(cw, ch);
let _base = engine.add_raster_layer(None);
let baseline = engine.test_node_texture_count();
let pw: u32 = cw * 4;
let ph: u32 = ch * 4;
let rgba = vec![0xAAu8; (pw * ph * 4) as usize];
for _ in 0..5 {
let id = engine.paste_image_floating(pw, ph, &rgba, 0, 0, None);
assert!(engine.has_layer(id), "paste should create the target layer");
engine.cancel_floating();
assert!(!engine.has_layer(id), "cancel should detach the layer");
}
let after_cycles = engine.test_node_texture_count();
assert_eq!(
after_cycles, baseline,
"5 paste→cancel cycles should leave layer_textures count unchanged \
(baseline {baseline}, got {after_cycles})"
);
}
#[test]
fn add_remove_cycles_dont_leak_layer_textures() {
let (cw, ch) = (128, 128);
let mut engine = test_engine(cw, ch);
let _base = engine.add_raster_layer(None);
let baseline = engine.test_node_texture_count();
for _ in 0..5 {
let id = engine.add_raster_layer(None);
assert!(engine.has_layer(id));
engine.remove_layer(id).expect("remove should succeed");
assert!(!engine.has_layer(id));
}
engine.test_drain_undo_for_teardown();
let after_cycles = engine.test_node_texture_count();
assert_eq!(
after_cycles, baseline,
"5 add→remove cycles should leave layer_textures count unchanged \
after undo-stack drain (baseline {baseline}, got {after_cycles})"
);
}
#[test]
fn undo_remove_layer_restores_pixels() {
let (cw, ch) = (128u32, 128u32);
let mut engine = test_engine(cw, ch);
let _base = engine.add_raster_layer(None);
let layer_id = engine.add_raster_layer(None);
paint_at(&mut engine, layer_id, 32.0, 32.0, 1.0, 0.0, 0.0);
paint_at(&mut engine, layer_id, 96.0, 96.0, 0.0, 1.0, 0.0);
let before_remove = engine.test_readback_layer(layer_id);
let bounds_before = engine.layer_bounds(layer_id).unwrap();
assert!(
before_remove.iter().skip(3).step_by(4).any(|&a| a > 0),
"sanity: pre-remove layer must contain painted pixels",
);
engine
.remove_layer(layer_id)
.expect("remove should succeed");
assert!(!engine.has_layer(layer_id));
engine.undo();
engine.render(0.0);
assert!(engine.has_layer(layer_id), "undo must reattach the layer");
let bounds_after = engine.layer_bounds(layer_id).unwrap();
assert_eq!(
bounds_after, bounds_before,
"undo must restore the layer's bounds",
);
let after_undo = engine.test_readback_layer(layer_id);
assert_eq!(
after_undo, before_remove,
"undo of remove_layer must restore the layer's pixel content byte-for-byte",
);
}
#[test]
fn mid_stroke_growth_invalidates_mask_bind_group() {
let (cw, ch) = (256u32, 256u32);
let mut engine = test_engine(cw, ch);
let layer_id = engine.add_raster_layer(None);
engine.add_mask(layer_id);
engine.render(0.0);
paint_at(
&mut engine,
layer_id,
cw as f32 + 80.0,
ch as f32 / 2.0,
1.0,
0.0,
0.0,
);
engine.render(0.0);
let bounds = engine.layer_bounds(layer_id).unwrap();
assert!(bounds.width > cw, "layer should have grown");
}
#[test]
fn floating_transform_undo_with_rotation() {
use darkly::gpu::transform::{affine_multiply, affine_rotate, affine_translate};
let (cw, ch) = (64, 64);
let mut engine = test_engine(cw, ch);
let pw: u32 = cw;
let ph: u32 = ch;
let mut layer_rgba = vec![0u8; (pw * ph * 4) as usize];
for y in 0..ph {
for x in 0..pw {
let idx = ((y * pw + x) * 4) as usize;
if y < ph / 2 {
layer_rgba[idx] = 255; } else {
layer_rgba[idx + 2] = 255; }
layer_rgba[idx + 3] = 255;
}
}
let layer_id = engine.paste_image(pw, ph, &layer_rgba, 0, 0, None);
let cx = cw / 2;
let cy = ch / 2;
let half = 8u32;
engine.select_rect(
(cx - half) as f32,
(cy - half) as f32,
(2 * half) as f32,
(2 * half) as f32,
SelectionMode::Replace,
false,
0.0,
);
let before = engine.test_readback_layer(layer_id);
let started = engine.begin_transform(layer_id);
assert!(started, "begin_transform with selection should succeed");
let theta = std::f32::consts::FRAC_PI_2;
let matrix = affine_multiply(
&affine_translate(8.0, 8.0),
&affine_multiply(&affine_rotate(theta), &affine_translate(-8.0, -8.0)),
);
engine.update_floating_matrix(matrix);
engine.commit_floating();
engine.render(0.0);
let after_commit = engine.test_readback_layer(layer_id);
assert_ne!(
before, after_commit,
"transform commit should have modified the layer"
);
engine.undo();
engine.render(0.0);
let after_undo = engine.test_readback_layer(layer_id);
assert_eq!(
before, after_undo,
"undo of rotation transform must restore byte-identical pixels"
);
}
#[test]
fn brush_stroke_off_canvas_undo_after_grow() {
let (cw, ch) = (256u32, 256u32);
let mut engine = test_engine(cw, ch);
let layer_id = engine.add_raster_layer(None);
let before = engine.test_readback_layer(layer_id);
paint_at(
&mut engine,
layer_id,
cw as f32 + 80.0,
ch as f32 / 2.0,
1.0,
0.0,
0.0,
);
let after_paint = engine.test_readback_layer(layer_id);
assert_ne!(
before.len(),
after_paint.len(),
"stroke past edge should have grown the layer texture"
);
engine.undo();
engine.render(0.0);
let after_undo = engine.test_readback_layer(layer_id);
let n = (cw * ch * 4) as usize;
let original_region_post_undo = &after_undo[..n.min(after_undo.len())];
let any_opaque = original_region_post_undo
.chunks_exact(4)
.any(|px| px[3] > 0);
assert!(
!any_opaque,
"after undo, original-bounds region should be fully transparent"
);
}
#[test]
fn stroke_crossing_canvas_edge_keeps_early_dabs_in_place() {
let (cw, ch) = (256u32, 256u32);
let mut engine = test_engine(cw, ch);
let layer_id = engine.add_raster_layer(None);
engine.begin_stroke(layer_id);
for step in 0..20 {
let t = step as f32 / 19.0;
let x = 50.0 - t * 150.0;
engine.stroke_to(StrokeOp::BrushStroke {
x,
y: 100.0,
pressure: 1.0,
x_tilt: 0.0,
y_tilt: 0.0,
rotation: 0.0,
tangential_pressure: 0.0,
time_ms: step as f64 * 16.0,
cr: 1.0,
cg: 0.0,
cb: 0.0,
ca: 1.0,
});
}
engine.end_stroke();
engine.render(0.0);
let bounds = engine.layer_bounds(layer_id).expect("layer exists");
assert!(
bounds.origin.x <= -256,
"negative-direction grow should have shifted offset_x; got {}",
bounds.origin.x
);
let pixels = engine.test_readback_layer(layer_id);
let lw = bounds.width;
let early_lx = (50 - bounds.origin.x) as u32;
let early_ly = (100 - bounds.origin.y) as u32;
let mut hit_at_expected = false;
for dy in 0..8u32 {
for dx in 0..8u32 {
let px = early_lx.saturating_sub(4) + dx;
let py = early_ly.saturating_sub(4) + dy;
if alpha_at(&pixels, lw, px, py) > 0 {
hit_at_expected = true;
break;
}
}
}
assert!(
hit_at_expected,
"early-stroke dab at canvas (50, 100) must land at layer-local ({early_lx}, {early_ly}) after grow"
);
let mut wrong_hit = 0u8;
for dy in 0..8u32 {
for dx in 0..8u32 {
let px = (50u32).saturating_sub(4) + dx;
let py = (100u32).saturating_sub(4) + dy;
wrong_hit = wrong_hit.max(alpha_at(&pixels, lw, px, py));
}
}
assert_eq!(
wrong_hit, 0,
"no paint should land at the un-translated (50, 100) position; that area is canvas (50 + offset_x, 100) and should be empty"
);
}
#[test]
fn undo_after_grow_does_not_leave_prior_stroke_artifacts() {
let (cw, ch) = (256u32, 256u32);
let mut engine = test_engine(cw, ch);
let layer_id = engine.add_raster_layer(None);
paint_at(&mut engine, layer_id, 50.0, 50.0, 1.0, 0.0, 0.0);
paint_at(&mut engine, layer_id, -100.0, -100.0, 0.0, 1.0, 0.0);
engine.undo();
engine.render(0.0);
engine.undo();
engine.render(0.0);
let pixels = engine.test_readback_layer(layer_id);
let bounds = engine.layer_bounds(layer_id).expect("layer exists");
let (lw, lh) = (bounds.width, bounds.height);
let mut painted_count = 0u32;
for y in 0..lh {
for x in 0..lw {
if alpha_at(&pixels, lw, x, y) > 0 {
painted_count += 1;
}
}
}
assert_eq!(
painted_count, 0,
"after undoing both strokes, layer should be fully transparent; \
got {painted_count} painted pixels (artifacts from pre-grow stroke)"
);
}
#[test]
fn transform_translate_no_selection_does_not_duplicate() {
use darkly::gpu::transform::affine_translate;
let (cw, ch) = (128u32, 128u32);
let mut engine = test_engine(cw, ch);
let mut rgba = vec![0u8; (cw * ch * 4) as usize];
for y in 10..26 {
for x in 10..26 {
let idx = ((y * cw + x) * 4) as usize;
rgba[idx] = 255; rgba[idx + 3] = 255; }
}
let layer_id = engine.paste_image(cw, ch, &rgba, 0, 0, None);
let started = engine.begin_transform(layer_id);
if !started {
for _ in 0..16 {
engine.test_flush_readbacks();
engine.render(0.0);
if engine.has_floating() {
break;
}
}
}
assert!(
engine.has_floating(),
"begin_transform should have set up floating"
);
engine.update_floating_matrix(affine_translate(50.0, 50.0));
engine.commit_floating();
engine.render(0.0);
let pixels = engine.test_readback_layer(layer_id);
let bounds = engine.layer_bounds(layer_id).expect("layer exists");
let lw = bounds.width;
let ox = bounds.origin.x;
let oy = bounds.origin.y;
let alpha_canvas = |cx: i32, cy: i32| -> u8 {
let lx = cx - ox;
let ly = cy - oy;
if lx < 0 || ly < 0 || lx as u32 >= bounds.width || ly as u32 >= bounds.height {
return 0;
}
alpha_at(&pixels, lw, lx as u32, ly as u32)
};
assert!(
alpha_canvas(65, 65) > 0,
"translated source position (65, 65) must be opaque after commit; got A={}",
alpha_canvas(65, 65)
);
assert_eq!(
alpha_canvas(15, 15),
0,
"original source position (15, 15) must be transparent after \
commit — non-zero here means the un-clear left a duplicate of \
the source at its original position"
);
}
#[test]
fn transform_translate_with_selection_does_not_duplicate() {
use darkly::gpu::transform::affine_translate;
let (cw, ch) = (128u32, 128u32);
let mut engine = test_engine(cw, ch);
let mut rgba = vec![0u8; (cw * ch * 4) as usize];
for y in 10..26 {
for x in 10..26 {
let idx = ((y * cw + x) * 4) as usize;
rgba[idx] = 255; rgba[idx + 3] = 255; }
}
let layer_id = engine.paste_image(cw, ch, &rgba, 0, 0, None);
engine.select_rect(10.0, 10.0, 16.0, 16.0, SelectionMode::Replace, false, 0.0);
let started = engine.begin_transform(layer_id);
assert!(
started,
"begin_transform should set up floating synchronously with an active selection"
);
engine.update_floating_matrix(affine_translate(50.0, 50.0));
engine.commit_floating();
engine.render(0.0);
let pixels = engine.test_readback_layer(layer_id);
let bounds = engine.layer_bounds(layer_id).expect("layer exists");
let lw = bounds.width;
let ox = bounds.origin.x;
let oy = bounds.origin.y;
let alpha_canvas = |cx: i32, cy: i32| -> u8 {
let lx = cx - ox;
let ly = cy - oy;
if lx < 0 || ly < 0 || lx as u32 >= bounds.width || ly as u32 >= bounds.height {
return 0;
}
alpha_at(&pixels, lw, lx as u32, ly as u32)
};
assert!(
alpha_canvas(65, 65) > 0,
"translated source position (65, 65) must be opaque after commit; got A={}",
alpha_canvas(65, 65)
);
assert_eq!(
alpha_canvas(15, 15),
0,
"original source position (15, 15) must be transparent after commit — \
non-zero here means the selection-shaped re-clear was skipped and the \
un-cleared source pixel was preserved by the transform shader's discard"
);
}
#[test]
fn pending_undo_commit_survives_two_grows() {
let (cw, ch) = (256u32, 256u32);
let mut engine = test_engine(cw, ch);
let layer_id = engine.add_raster_layer(None);
paint_at(&mut engine, layer_id, -50.0, 50.0, 1.0, 0.0, 0.0);
paint_at(&mut engine, layer_id, 50.0, -50.0, 0.0, 1.0, 0.0);
engine.undo();
engine.render(0.0);
engine.undo();
engine.render(0.0);
let pixels = engine.test_readback_layer(layer_id);
let bounds = engine.layer_bounds(layer_id).expect("layer exists");
let (lw, lh) = (bounds.width, bounds.height);
let mut painted_count = 0u32;
for y in 0..lh {
for x in 0..lw {
if alpha_at(&pixels, lw, x, y) > 0 {
painted_count += 1;
}
}
}
assert_eq!(
painted_count, 0,
"after undoing two strokes that each grew the layer, the layer \
should be fully transparent; got {painted_count} painted pixels — \
the deferred undo commit from stroke A held a stale layer-local \
rect that survived past the second grow"
);
}
fn paint_mask_dab(engine: &mut DarklyEngine, host_id: LayerId, x: f32, y: f32, value: f32) {
let mask_id = engine
.host_mask_id(host_id)
.expect("paint_mask_dab requires the host to have a mask filter");
engine.begin_stroke(mask_id);
engine.stroke_to(StrokeOp::BrushStroke {
x,
y,
pressure: 1.0,
x_tilt: 0.0,
y_tilt: 0.0,
rotation: 0.0,
tangential_pressure: 0.0,
time_ms: 0.0,
cr: value,
cg: value,
cb: value,
ca: 1.0,
});
engine.end_stroke();
engine.render(0.0);
}
fn mask_byte_at(pixels: &[u8], w: u32, x: u32, y: u32) -> u8 {
pixels[(y * w + x) as usize]
}
#[test]
fn engine_brush_stroke_paints_on_mask() {
let (w, h) = (128, 128);
let mut engine = test_engine(w, h);
let layer_id = engine.add_raster_layer(None);
engine.add_mask(layer_id);
paint_mask_dab(&mut engine, layer_id, (w / 2) as f32, (h / 2) as f32, 0.0);
let pixels = engine.test_readback_mask(layer_id);
assert_eq!(
pixels.len(),
(w * h) as usize,
"mask is R8 — one byte/pixel"
);
let center = mask_byte_at(&pixels, w, w / 2, h / 2);
assert!(
center < 250,
"mask center should be painted (byte < 250 after a black brush dab); \
got {center} — brush stroke did not modify the mask"
);
}
#[test]
fn engine_mask_brush_unstroked_pixels_unchanged() {
let (w, h) = (128, 128);
let mut engine = test_engine(w, h);
let layer_id = engine.add_raster_layer(None);
engine.add_mask(layer_id);
paint_mask_dab(&mut engine, layer_id, 10.0, 10.0, 0.0);
let pixels = engine.test_readback_mask(layer_id);
let far = mask_byte_at(&pixels, w, 100, 100);
assert_eq!(
far, 255,
"pixel at (100,100) — well outside the dab footprint at (10,10) — \
must remain at the initial reveal-all value (255); got {far} — \
the read-side R8→RGBA8 expand or write-side RGBA8→R8 reduce \
shifted bytes"
);
}
#[test]
fn engine_mask_brush_undo_restores_mask() {
let (w, h) = (64, 64);
let mut engine = test_engine(w, h);
let layer_id = engine.add_raster_layer(None);
engine.add_mask(layer_id);
paint_mask_dab(&mut engine, layer_id, (w / 2) as f32, (h / 2) as f32, 0.0);
engine.test_flush_readbacks();
engine.render(0.0);
engine.undo();
engine.render(0.0);
let pixels = engine.test_readback_mask(layer_id);
let mut all_white = true;
for byte in &pixels {
if *byte != 255 {
all_white = false;
break;
}
}
assert!(
all_white,
"after undo of mask brush stroke, mask should return to all-white"
);
}
#[test]
fn engine_mask_brush_respects_selection() {
let (w, h) = (128, 128);
let mut engine = test_engine(w, h);
let layer_id = engine.add_raster_layer(None);
engine.add_mask(layer_id);
engine.select_rect(
0.0,
0.0,
(w / 2) as f32,
h as f32,
SelectionMode::Replace,
false,
0.0,
);
paint_mask_dab(&mut engine, layer_id, (w / 4) as f32, (h / 2) as f32, 0.0);
paint_mask_dab(
&mut engine,
layer_id,
(3 * w / 4) as f32,
(h / 2) as f32,
0.0,
);
let pixels = engine.test_readback_mask(layer_id);
let inside = mask_byte_at(&pixels, w, w / 4, h / 2);
let outside = mask_byte_at(&pixels, w, 3 * w / 4, h / 2);
assert!(
inside < 250,
"mask byte inside the selection should be painted (< 250); got {inside}"
);
assert_eq!(
outside, 255,
"mask byte outside the selection must remain all-reveal (255) — \
brush stroke on a mask must respect the active selection; got {outside}"
);
}
#[test]
fn engine_add_mask_seeds_from_active_selection() {
let (w, h) = (64, 64);
let mut engine = test_engine(w, h);
let layer_id = engine.add_raster_layer(None);
engine.select_rect(
0.0,
0.0,
(w / 2) as f32,
h as f32,
SelectionMode::Replace,
false,
0.0,
);
engine.add_mask(layer_id);
let pixels = engine.test_readback_mask(layer_id);
let inside = mask_byte_at(&pixels, w, w / 4, h / 2);
let outside = mask_byte_at(&pixels, w, 3 * w / 4, h / 2);
assert!(
inside > 200,
"mask byte inside the selection should reveal (~255); got {inside}"
);
assert!(
outside < 50,
"mask byte outside the selection should hide (~0); got {outside}"
);
}
#[test]
fn engine_add_mask_without_selection_is_all_white() {
let (w, h) = (64, 64);
let mut engine = test_engine(w, h);
let layer_id = engine.add_raster_layer(None);
engine.add_mask(layer_id);
let pixels = engine.test_readback_mask(layer_id);
assert!(
pixels.iter().all(|&b| b == 255),
"with no active selection, a freshly-added mask must be all-white (255)"
);
}
#[test]
fn engine_no_mask_brush_safe_on_layer() {
let (w, h) = (64, 64);
let mut engine = test_engine(w, h);
let layer_id = engine.add_raster_layer(None);
engine.begin_stroke(layer_id);
engine.stroke_to(StrokeOp::BrushStroke {
x: (w / 2) as f32,
y: (h / 2) as f32,
pressure: 1.0,
x_tilt: 0.0,
y_tilt: 0.0,
rotation: 0.0,
tangential_pressure: 0.0,
time_ms: 0.0,
cr: 0.0,
cg: 0.0,
cb: 0.0,
ca: 1.0,
});
engine.end_stroke();
engine.render(0.0);
}
#[test]
fn engine_mask_flood_fill() {
let (w, h) = (64, 64);
let mut engine = test_engine(w, h);
let layer_id = engine.add_raster_layer(None);
engine.add_mask(layer_id);
let mask_id = engine.host_mask_id(layer_id).unwrap();
engine.begin_stroke(mask_id);
engine.stroke_to(StrokeOp::FloodFill {
x: (w / 2) as f32,
y: (h / 2) as f32,
r: 0,
g: 0,
b: 0,
a: 255,
tolerance: 0,
});
engine.end_stroke();
engine.test_flush_readbacks();
engine.render(0.0);
let pixels = engine.test_readback_mask(layer_id);
let center = mask_byte_at(&pixels, w, w / 2, h / 2);
assert!(
center < 10,
"flood fill with black should drive mask center near 0; got {center}"
);
}
#[test]
fn engine_magic_wand_on_mask_reads_mask_not_layer() {
let (w, h) = (64, 64);
let mut engine = test_engine(w, h);
let layer_id = engine.add_raster_layer(None);
engine.select_rect(
0.0,
0.0,
(w / 2) as f32,
h as f32,
SelectionMode::Replace,
false,
0.0,
);
engine.add_mask(layer_id);
let mask_id = engine.host_mask_id(layer_id).unwrap();
engine.select_magic_wand(
mask_id,
darkly::coord::CanvasPoint::new(4, (h / 2) as i32),
0,
SelectionMode::Replace,
);
engine.test_flush_readbacks();
let cache = engine
.test_selection_cpu_cache()
.expect("magic wand must populate the selection cpu cache");
let inside = cache[((h / 2) * w + 4) as usize];
let outside = cache[((h / 2) * w + (3 * w / 4)) as usize];
assert!(
inside > 200,
"seed inside left (mask=255) half must be selected; got {inside}"
);
assert_eq!(
outside, 0,
"right (mask=0) half must NOT be selected — pre-fix the magic wand \
flood-filled the empty RGBA layer instead of the mask, producing a \
full-canvas selection; got {outside}"
);
}
#[test]
fn engine_magic_wand_on_paste_extent_layer_translates_coords() {
use darkly::coord::CanvasRect;
let (cw, ch) = (64u32, 64u32);
let mut engine = test_engine(cw, ch);
let pw: u32 = 96;
let ph: u32 = 96;
let mut rgba = vec![0u8; (pw * ph * 4) as usize];
for ty in 48..80u32 {
for tx in 48..80u32 {
let i = ((ty * pw + tx) * 4) as usize;
rgba[i] = 255;
rgba[i + 3] = 255;
}
}
let pasted = engine.paste_image(pw, ph, &rgba, -32, -32, None);
assert_eq!(
engine.layer_bounds(pasted),
Some(CanvasRect::from_xywh(-32, -32, pw, ph)),
"paste layer must sit at canvas offset (-32, -32) with the full 96×96 extent"
);
engine.select_magic_wand(
pasted,
darkly::coord::CanvasPoint::new(32, 32),
0,
SelectionMode::Replace,
);
engine.test_flush_readbacks();
let cache = engine
.test_selection_cpu_cache()
.expect("magic wand must populate the selection cpu cache");
let inside = cache[(32u32 * cw + 32u32) as usize];
assert!(
inside > 200,
"seed pixel inside the visible red block must be selected; got {inside}"
);
let outside_tl = cache[0];
assert_eq!(
outside_tl, 0,
"transparent top-left canvas pixel must NOT be selected; got {outside_tl}. \
Pre-fix the readback rect was hardcoded to the canvas extent, so the seed \
sampled the wrong texture pixel and the resulting mask got shifted onto \
the wrong canvas region."
);
let red_corner = cache[(16u32 * cw + 16u32) as usize];
assert!(
red_corner > 200,
"top-left corner of the visible red block (canvas 16,16) must be selected; \
got {red_corner}"
);
let outside_br = cache[(48u32 * cw + 48u32) as usize];
assert_eq!(
outside_br, 0,
"canvas pixel just past the bottom-right edge of the red block must NOT \
be selected; got {outside_br}"
);
}
#[test]
fn engine_flood_fill_on_paste_extent_layer_translates_coords() {
let (cw, ch) = (64u32, 64u32);
let mut engine = test_engine(cw, ch);
let pw: u32 = 96;
let ph: u32 = 96;
let mut rgba = vec![0u8; (pw * ph * 4) as usize];
for ty in 48..80u32 {
for tx in 48..80u32 {
let i = ((ty * pw + tx) * 4) as usize;
rgba[i] = 255;
rgba[i + 3] = 255;
}
}
let pasted = engine.paste_image(pw, ph, &rgba, -32, -32, None);
engine.begin_stroke(pasted);
engine.stroke_to(StrokeOp::FloodFill {
x: 32.0,
y: 32.0,
r: 0,
g: 255,
b: 0,
a: 255,
tolerance: 0,
});
engine.end_stroke();
engine.test_flush_readbacks();
engine.render(0.0);
let pixels = engine.test_readback_layer(pasted);
let center_idx = ((64u32 * pw + 64u32) * 4) as usize;
assert!(
pixels[center_idx] < 50 && pixels[center_idx + 1] > 200 && pixels[center_idx + 3] > 200,
"center of the red block (canvas 32,32 = texture 64,64) must be replaced \
with opaque green; got rgba=({}, {}, {}, {})",
pixels[center_idx],
pixels[center_idx + 1],
pixels[center_idx + 2],
pixels[center_idx + 3]
);
let stray_idx = ((40u32 * pw + 40u32) * 4) as usize;
assert_eq!(
pixels[stray_idx + 3],
0,
"texture (40,40) = canvas (8,8) was transparent and disconnected from the \
red block; it must remain transparent. Pre-fix the bucket fill leaked \
green here because the readback rect was canvas-anchored rather than \
texture-anchored. Got rgba=({}, {}, {}, {})",
pixels[stray_idx],
pixels[stray_idx + 1],
pixels[stray_idx + 2],
pixels[stray_idx + 3]
);
assert_eq!(
pixels[3], 0,
"off-canvas texture corner must stay transparent; got alpha={}",
pixels[3]
);
}
#[test]
fn floating_preview_respects_layer_mask() {
let (w, h) = (128, 128);
let mut engine = test_engine(w, h);
let layer_id = engine.add_raster_layer(None);
paint_full_stroke(&mut engine, layer_id, w, h);
engine.render(0.0);
engine.select_rect(
0.0,
0.0,
(w / 2) as f32,
h as f32,
SelectionMode::Replace,
false,
0.0,
);
engine.add_mask(layer_id);
engine.clear_selection();
engine.render(0.0);
let pre = engine.test_readback_canvas();
let pre_left = alpha_at(&pre, w, w / 4, h / 2);
let pre_right = alpha_at(&pre, w, 3 * w / 4, h / 2);
assert!(
pre_left > 0,
"test setup: left half should be revealed (mask=255); got alpha={pre_left}"
);
assert_eq!(
pre_right, 0,
"test setup: right half should be hidden (mask=0); got alpha={pre_right}"
);
engine.begin_transform(layer_id);
let mut floating_ready = false;
for _ in 0..16 {
engine.test_flush_readbacks();
engine.render(0.0);
if engine.has_floating() {
floating_ready = true;
break;
}
}
assert!(
floating_ready,
"begin_transform did not produce floating content within 16 frames"
);
engine.render(0.0);
let post = engine.test_readback_canvas();
let post_left = alpha_at(&post, w, w / 4, h / 2);
let post_right = alpha_at(&post, w, 3 * w / 4, h / 2);
assert!(
post_left > 0,
"left half should still be visible during transform preview; got alpha={post_left}"
);
assert_eq!(
post_right, 0,
"right half is masked out — the floating preview must apply the \
target layer's mask. Pre-fix the transform-blend shader skipped \
the mask entirely, so this read came back fully opaque; got \
alpha={post_right}"
);
}
#[test]
fn passthrough_sub_canvas_group_mask_samples_own_space() {
use darkly::coord::CanvasRect;
use darkly::document::MoveTarget;
let mut engine = test_engine(64, 64);
let group_id = engine.add_group(None);
engine.set_group_passthrough(group_id, true);
let child_id = engine.add_raster_layer(None);
engine.move_layer(child_id, MoveTarget::IntoGroupTop(group_id));
engine.begin_stroke(child_id);
engine.stroke_to(StrokeOp::FloodFill {
x: 1.0,
y: 1.0,
r: 255,
g: 0,
b: 0,
a: 255,
tolerance: 255,
});
engine.end_stroke();
engine.render(0.0);
engine.add_mask(group_id);
paint_mask_dab(&mut engine, group_id, 40.0, 40.0, 0.0);
engine.test_flush_readbacks();
engine.resize_canvas(CanvasRect::from_xywh(20, 16, 128, 96));
engine.render(0.0);
let canvas = engine.test_readback_canvas();
let hidden = alpha_at(&canvas, 128, 20, 24);
let shown = alpha_at(&canvas, 128, 42, 2);
assert!(
hidden < 128,
"sub-canvas group mask must hide the child at the dab's own-space \
location; got alpha={hidden}"
);
assert!(
shown > 200,
"away from the dab the child must show through the sub-canvas group \
mask; got alpha={shown}"
);
}
#[test]
fn growing_host_leaves_mask_bounds_unchanged() {
let (cw, ch) = (256u32, 256u32);
let mut engine = test_engine(cw, ch);
let layer_id = engine.add_raster_layer(None);
engine.add_mask(layer_id);
let mask_id = engine
.host_mask_id(layer_id)
.expect("just-added mask must be reachable via host_mask_id");
let host_before = engine
.layer_bounds(layer_id)
.expect("raster layer has bounds");
let mask_before = engine
.node_pixel_bounds(mask_id)
.expect("mask filter has bounds");
assert_eq!(
mask_before, host_before,
"fresh mask defaults to the canvas (== a fresh canvas-sized host)"
);
paint_at(
&mut engine,
layer_id,
cw as f32 + 50.0,
ch as f32 / 2.0,
1.0,
0.0,
0.0,
);
let host_after = engine.layer_bounds(layer_id).expect("layer still exists");
let mask_after = engine
.node_pixel_bounds(mask_id)
.expect("mask still attached");
assert!(
host_after.width > host_before.width,
"host should have grown past canvas; before {} after {}",
host_before.width,
host_after.width,
);
assert_eq!(
mask_after, mask_before,
"the mask must NOT follow the host's growth — it owns its own bounds. \
host={host_after:?} mask before/after={mask_after:?}"
);
}
#[test]
fn painting_mask_past_bounds_grows_mask_independently() {
use darkly::coord::CanvasRect;
let (cw, ch) = (128u32, 128u32);
let mut engine = test_engine(cw, ch);
let (ox, oy) = (15i32, 9i32);
engine.resize_canvas(CanvasRect::from_xywh(ox, oy, cw, ch));
let layer_id = engine.add_raster_layer(None);
engine.add_mask(layer_id);
let mask_id = engine.host_mask_id(layer_id).expect("mask exists");
let host_before = engine.layer_bounds(layer_id).expect("host bounds");
let mask_before = engine.node_pixel_bounds(mask_id).expect("mask bounds");
let black = |x: f32, y: f32| StrokeOp::BrushStroke {
x,
y,
pressure: 1.0,
x_tilt: 0.0,
y_tilt: 0.0,
rotation: 0.0,
tangential_pressure: 0.0,
time_ms: 0.0,
cr: 0.0,
cg: 0.0,
cb: 0.0,
ca: 1.0,
};
engine.begin_stroke(mask_id);
engine.stroke_to(black((ox + 30) as f32, (oy + 30) as f32));
engine.stroke_to(black(
(ox + cw as i32) as f32 + 40.0,
(oy + ch as i32 / 2) as f32,
));
engine.end_stroke();
engine.render(0.0);
let host_after = engine.layer_bounds(layer_id).expect("host still exists");
let mask_after = engine
.node_pixel_bounds(mask_id)
.expect("mask still exists");
assert!(
mask_after.width > mask_before.width,
"mask must grow itself when painted past its bounds; before {} after {}",
mask_before.width,
mask_after.width,
);
assert_eq!(
host_after, host_before,
"host must NOT grow when only the mask is painted; host={host_after:?}"
);
engine.undo();
engine.test_flush_readbacks();
engine.render(0.0);
let mask_pixels = engine.test_readback_mask(layer_id);
let darkest = mask_pixels.iter().copied().min().unwrap_or(255);
assert!(
darkest > 250,
"undo must restore the all-white mask (incl. the grown region); \
darkest pixel was {darkest}"
);
}
#[test]
fn add_paint_apply_undo_round_trip_preserves_mask() {
let (cw, ch) = (64u32, 64u32);
let mut engine = test_engine(cw, ch);
let layer_id = engine.add_raster_layer(None);
let probe_x = 16u32;
let probe_y = ch / 2;
paint_full_stroke(&mut engine, layer_id, cw, ch);
engine.test_flush_readbacks();
engine.render(0.0);
let host_before_apply = engine.test_readback_layer(layer_id);
let red_alpha = alpha_at(&host_before_apply, cw, probe_x, probe_y);
assert!(
red_alpha > 200,
"test setup: red stroke should produce opaque alpha at probe; got {red_alpha}"
);
engine.add_mask(layer_id);
let mask_id = engine.host_mask_id(layer_id).expect("mask just added");
paint_mask_dab(&mut engine, layer_id, probe_x as f32, probe_y as f32, 0.0);
let mask_pixels_before_apply = engine.test_readback_mask(layer_id);
let masked_byte = mask_byte_at(&mask_pixels_before_apply, cw, probe_x, probe_y);
assert!(
masked_byte < 200,
"test setup: black dab should drive mask well below 255 at probe; got {masked_byte}"
);
engine.apply_mask(layer_id);
engine.test_flush_readbacks();
engine.render(0.0);
assert!(
engine.host_mask_id(layer_id).is_none(),
"after apply_mask the filter must be detached"
);
let host_after_apply = engine.test_readback_layer(layer_id);
let baked_alpha = alpha_at(&host_after_apply, cw, probe_x, probe_y);
assert!(
baked_alpha < red_alpha,
"apply must multiply alpha by mask at probe; before={red_alpha} after={baked_alpha}"
);
for _ in 0..3 {
engine.undo();
engine.render(0.0);
}
let restored_mask_id = engine
.host_mask_id(layer_id)
.expect("undo must restore the mask filter");
assert_eq!(
restored_mask_id, mask_id,
"restored mask must keep its original id (the same Filter struct \
is re-attached)"
);
let host_after_undo = engine.test_readback_layer(layer_id);
let restored_alpha = alpha_at(&host_after_undo, cw, probe_x, probe_y);
assert_eq!(
restored_alpha, red_alpha,
"host alpha at probe must be byte-identically restored after undo"
);
let mask_after_undo = engine.test_readback_mask(layer_id);
let restored_mask_byte = mask_byte_at(&mask_after_undo, cw, probe_x, probe_y);
assert_eq!(
restored_mask_byte, masked_byte,
"mask painted byte at probe must be byte-identically restored after undo"
);
}
#[test]
fn passthrough_group_with_visible_mask_applies_via_snapshot_lerp() {
use darkly::document::MoveTarget;
let (cw, ch) = (64u32, 64u32);
let mut engine = test_engine(cw, ch);
let group_id = engine.add_group(None);
engine.set_group_passthrough(group_id, true);
let child_id = engine.add_raster_layer(None);
engine.move_layer(child_id, MoveTarget::IntoGroupTop(group_id));
paint_full_stroke(&mut engine, child_id, cw, ch);
engine.render(0.0);
engine.add_mask(group_id);
let group_mask_id = engine.host_mask_id(group_id).expect("group has mask");
engine.begin_stroke(group_mask_id);
engine.stroke_to(StrokeOp::FloodFill {
x: 4.0,
y: 4.0,
r: 0,
g: 0,
b: 0,
a: 255,
tolerance: 0,
});
engine.end_stroke();
engine.test_flush_readbacks();
engine.render(0.0);
let masked = engine.test_readback_canvas();
let masked_alpha = alpha_at(&masked, cw, cw / 2, ch / 2);
assert_eq!(
masked_alpha, 0,
"passthrough-group mask must hide the child's pixels when visible; \
got alpha={masked_alpha} — the snapshot+lerp branch did not engage"
);
engine.set_layer_visible(group_mask_id, false);
engine.render(0.0);
let unmasked = engine.test_readback_canvas();
let unmasked_alpha = alpha_at(&unmasked, cw, cw / 2, ch / 2);
assert!(
unmasked_alpha > 200,
"with mask hidden, plain passthrough must let the child's red pixels \
show through; got alpha={unmasked_alpha}"
);
}
#[test]
fn set_blend_mode_on_passthrough_group_disables_passthrough() {
use darkly::engine::types::LayerInfo;
let mut engine = test_engine(64, 64);
let group_id = engine.add_group(None);
engine.set_group_passthrough(group_id, true);
let group_view = |e: &DarklyEngine| -> (bool, &'static str) {
for node in e.layer_tree() {
if let LayerInfo::Group {
id,
passthrough,
blend_mode,
..
} = node
{
if id as u64 == group_id.to_ffi() {
return (passthrough, blend_mode);
}
}
}
panic!("group not found in layer tree");
};
assert_eq!(group_view(&engine), (true, "normal"));
engine.set_blend_mode(group_id, "multiply");
assert_eq!(
group_view(&engine),
(false, "multiply"),
"blend-mode change on a passthrough group must clear passthrough"
);
engine.undo();
assert_eq!(
group_view(&engine),
(true, "normal"),
"single undo must restore both passthrough and blend mode"
);
engine.redo();
assert_eq!(group_view(&engine), (false, "multiply"));
engine.set_group_passthrough(group_id, false);
engine.set_blend_mode(group_id, "screen");
assert_eq!(group_view(&engine), (false, "screen"));
}
#[test]
fn layer_node_tree_admits_only_layer_and_group_variants() {
use darkly::layer::{Layer, LayerGroup, LayerId, LayerNode, RasterLayer};
fn must_destructure(node: &LayerNode) {
match node {
LayerNode::Layer(Layer::Raster(_)) => {}
LayerNode::Layer(Layer::Void(_)) => {}
LayerNode::Layer(Layer::Filter(_)) => {}
LayerNode::Group(_) => {}
}
}
let raster = LayerNode::Layer(Layer::Raster(RasterLayer::new(
LayerId::from_ffi(1),
darkly::coord::CanvasRect::from_xywh(0, 0, 1, 1),
"raster".to_string(),
)));
let group = LayerNode::Group(LayerGroup::new(LayerId::from_ffi(2), "group".to_string()));
must_destructure(&raster);
must_destructure(&group);
}
#[test]
fn selection_to_mask_round_trip_preserves_pixels() {
use darkly::document::SelectionMode;
let (cw, ch) = (64u32, 64u32);
let mut engine = test_engine(cw, ch);
let layer_id = engine.add_raster_layer(None);
engine.select_rect(4.0, 4.0, 20.0, 16.0, SelectionMode::Replace, false, 0.0);
engine.test_flush_readbacks();
engine.render(0.0);
let original_cache = engine
.test_selection_cpu_cache()
.expect("Replace path populates the CPU cache eagerly")
.to_vec();
assert!(
original_cache.iter().any(|&b| b > 0),
"test setup: selection should contain non-zero pixels"
);
engine.add_mask(layer_id);
engine.selection_to_mask(layer_id);
engine.render(0.0);
let mask_pixels = engine.test_readback_mask(layer_id);
assert_eq!(
mask_pixels.len(),
original_cache.len(),
"mask + selection must be the same canvas size"
);
assert_eq!(
mask_pixels, original_cache,
"selection_to_mask must copy bytes through `clone_filter_pixels` \
without any transformation"
);
engine.clear_selection();
engine.test_flush_readbacks();
engine.render(0.0);
assert!(
!engine.has_selection(),
"clear_selection must deactivate the selection"
);
let mask_id = engine.host_mask_id(layer_id).expect("mask still attached");
engine.mask_to_selection(mask_id);
engine.test_flush_readbacks();
engine.render(0.0);
assert!(
engine.has_selection(),
"mask_to_selection must reactivate the selection filter"
);
let restored = engine
.test_selection_cpu_cache()
.expect("readback after mask_to_selection populates the CPU cache")
.to_vec();
assert_eq!(
restored, original_cache,
"round-trip must be byte-identical: clone_filter_pixels copies one \
R8 texture into another with no algorithmic change in either direction"
);
}
#[test]
fn selection_to_sub_canvas_mask_covers_whole_selection() {
use darkly::coord::CanvasRect;
let mut engine = test_engine(64, 64);
let layer_id = engine.add_raster_layer(None);
engine.add_mask(layer_id);
let mask_id = engine.host_mask_id(layer_id).expect("mask just added");
let canvas = CanvasRect::from_xywh(20, 16, 128, 96);
engine.resize_canvas(canvas);
engine.select_all();
engine.render(0.0);
engine.selection_to_mask(layer_id);
engine.render(0.0);
let bounds = engine
.node_pixel_bounds(mask_id)
.expect("mask still attached");
assert!(
bounds.contains(canvas),
"mask must have grown to cover the whole selection; mask={bounds:?} canvas={canvas:?}"
);
let mask_pixels = engine.test_readback_mask(layer_id);
let mw = bounds.width;
let lx = (130 - bounds.origin.x) as u32;
let ly = (100 - bounds.origin.y) as u32;
let v = mask_byte_at(&mask_pixels, mw, lx, ly);
assert!(
v > 200,
"selected pixel outside the original mask bounds must be revealed after \
grow-to-union; got {v}"
);
}
#[test]
fn mask_to_selection_from_sub_canvas_mask_does_not_crash() {
use darkly::coord::CanvasRect;
let mut engine = test_engine(64, 64);
let layer_id = engine.add_raster_layer(None);
engine.add_mask(layer_id);
let mask_id = engine.host_mask_id(layer_id).expect("mask just added");
paint_mask_dab(&mut engine, layer_id, 20.0, 20.0, 0.0);
engine.resize_canvas(CanvasRect::from_xywh(20, 16, 128, 96));
engine.mask_to_selection(mask_id);
engine.test_flush_readbacks();
engine.render(0.0);
assert!(
engine.has_selection(),
"mask_to_selection must activate the selection without crashing"
);
}
#[test]
fn masked_leaf_composites_through_projection_cropped() {
use darkly::coord::CanvasRect;
let (cw, ch) = (64u32, 64u32);
let mut engine = test_engine(cw, ch);
let (ox, oy) = (20i32, 16i32);
engine.resize_canvas(CanvasRect::from_xywh(ox, oy, cw, ch));
let layer_id = engine.add_raster_layer(None);
engine.begin_stroke(layer_id);
engine.stroke_to(StrokeOp::FloodFill {
x: (ox + 1) as f32,
y: (oy + 1) as f32,
r: 0,
g: 255,
b: 0,
a: 255,
tolerance: 255,
});
engine.end_stroke();
engine.render(0.0);
engine.add_mask(layer_id);
paint_mask_dab(
&mut engine,
layer_id,
(ox + 12) as f32,
(oy + 12) as f32,
0.0,
);
engine.test_flush_readbacks();
engine.render(0.0);
let canvas = engine.test_readback_canvas();
let hidden = rgba_at(&canvas, cw, 12, 12);
let shown = rgba_at(&canvas, cw, cw - 3, ch - 3);
assert!(
hidden[3] < 40,
"mask-painted region must hide the host (low alpha); got {hidden:?}"
);
assert!(
shown[1] > 200 && shown[3] > 200,
"unmasked region must show opaque green through the projection; got {shown:?}"
);
}
#[test]
fn transform_masked_sub_canvas_layer_previews_without_crash() {
use darkly::coord::CanvasRect;
use darkly::gpu::transform::affine_translate;
let mut engine = test_engine(64, 64);
let host = engine.add_raster_layer(None);
fill_layer(&mut engine, host, 0, 200, 0); engine.add_mask(host);
paint_mask_dab(&mut engine, host, 20.0, 20.0, 0.0); engine.test_flush_readbacks();
engine.resize_canvas(CanvasRect::from_xywh(20, 16, 128, 96));
engine.render(0.0);
engine.select_rect(25.0, 20.0, 40.0, 40.0, SelectionMode::Replace, false, 0.0);
engine.test_flush_readbacks();
assert!(engine.begin_transform(host), "begin_transform must start");
engine.update_floating_matrix(affine_translate(15.0, 10.0));
engine.render(0.0);
let preview = engine.test_readback_canvas();
assert!(
preview.iter().any(|&b| b > 0),
"masked-layer transform preview must render something through the projection"
);
engine.commit_floating();
engine.render(0.0);
}
#[test]
fn copy_selected_mask_region_populates_clipboard() {
let mut engine = test_engine(64, 64);
let host = engine.add_raster_layer(None);
fill_layer(&mut engine, host, 0, 200, 0);
engine.add_mask(host);
paint_mask_dab(&mut engine, host, 20.0, 20.0, 0.0);
engine.test_flush_readbacks();
let mask_id = engine.host_mask_id(host).expect("host has a mask");
engine.select_rect(10.0, 10.0, 24.0, 24.0, SelectionMode::Replace, false, 0.0);
engine.test_flush_readbacks();
engine.copy_layer_rich(mask_id);
engine.test_flush_readbacks();
let result = engine
.poll_copy_result()
.expect("copying a selected mask region must produce a clipboard result");
assert!(
result.width > 0 && result.height > 0 && !result.rgba.is_empty(),
"mask copy must carry the selected region's pixels; got {}x{} ({} bytes)",
result.width,
result.height,
result.rgba.len()
);
}
#[test]
fn paste_while_editing_mask_places_layer_at_top_level() {
use darkly::engine::types::LayerInfo;
let mut engine = test_engine(64, 64);
let host = engine.add_raster_layer(None);
engine.add_mask(host);
let mask_id = engine.host_mask_id(host).expect("host has a mask");
let rgba = vec![255u8; 8 * 8 * 4];
let pasted = engine.paste_image(8, 8, &rgba, 4, 4, Some(mask_id));
let in_tree = engine
.layer_tree()
.iter()
.any(|n| matches!(n, LayerInfo::Raster { id, .. } if *id == pasted.to_ffi() as f64));
assert!(
in_tree,
"pasted layer must be a top-level sibling of the host, not nested under \
the raster host (invalid tree → invisible paste)"
);
}
#[test]
fn document_selection_is_a_typed_modifier() {
use darkly::document::FilterKind;
let (cw, ch) = (32u32, 32u32);
let engine = test_engine(cw, ch);
let filter_id = engine
.selection_filter_id_test()
.expect("DarklyEngine::new must eagerly allocate the selection filter");
assert_ne!(
filter_id.to_ffi(),
0,
"selection filter id must be a real id allocated from the document"
);
assert!(
!engine.has_selection(),
"fresh engine must report no active selection"
);
let kind_is_selection = engine
.test_selection_filter_kind_is_selection()
.expect("selection filter must be present");
assert!(
kind_is_selection,
"Document.selection.kind must be FilterKind::Selection — the \
type-system unification of selection and mask under Filter"
);
let bounds = engine
.test_selection_pixel_buffer_bounds()
.expect("SelectionFilter must hold a PixelBuffer");
assert_eq!(
(bounds.width, bounds.height),
(cw, ch),
"selection PixelBuffer must cover the full canvas"
);
let _ = std::any::type_name::<FilterKind>();
}
fn rgba_at(pixels: &[u8], w: u32, x: u32, y: u32) -> [u8; 4] {
let i = ((y * w + x) * 4) as usize;
[pixels[i], pixels[i + 1], pixels[i + 2], pixels[i + 3]]
}
fn fill_layer(engine: &mut DarklyEngine, layer_id: LayerId, r: u8, g: u8, b: u8) {
engine.begin_stroke(layer_id);
engine.stroke_to(StrokeOp::FloodFill {
x: 1.0,
y: 1.0,
r,
g,
b,
a: 255,
tolerance: 0,
});
engine.end_stroke();
}
#[test]
fn isolate_skips_off_path_sibling_rasters() {
let (cw, ch) = (32u32, 32u32);
let mut engine = test_engine(cw, ch);
let bottom = engine.add_raster_layer(None);
fill_layer(&mut engine, bottom, 0, 0, 255); let top = engine.add_raster_layer(None);
fill_layer(&mut engine, top, 255, 0, 0); engine.test_flush_readbacks();
engine.render(0.0);
let normal = engine.test_readback_canvas();
let px = rgba_at(&normal, cw, cw / 2, ch / 2);
assert!(
px[0] > 200 && px[2] < 50,
"without isolation, top red layer should show; got {px:?}"
);
engine.set_isolated_node(Some(bottom));
engine.render(0.0);
let isolated = engine.test_readback_canvas();
let px = rgba_at(&isolated, cw, cw / 2, ch / 2);
assert!(
px[2] > 200 && px[0] < 50,
"isolating the bottom layer must hide the top; got {px:?}"
);
engine.set_isolated_node(None);
engine.render(0.0);
let restored = engine.test_readback_canvas();
assert_eq!(
restored, normal,
"clearing isolation must produce the same pixels as before isolating"
);
}
#[test]
fn isolation_does_not_mutate_layer_visibility() {
let (cw, ch) = (16u32, 16u32);
let mut engine = test_engine(cw, ch);
let red = engine.add_raster_layer(None);
fill_layer(&mut engine, red, 255, 0, 0);
let green = engine.add_raster_layer(None);
fill_layer(&mut engine, green, 0, 255, 0);
let blue = engine.add_raster_layer(None);
fill_layer(&mut engine, blue, 0, 0, 255);
engine.set_layer_visible(red, false);
engine.test_flush_readbacks();
engine.render(0.0);
let baseline = engine.test_readback_canvas();
let px = rgba_at(&baseline, cw, cw / 2, ch / 2);
assert!(px[2] > 200, "baseline: blue (top) should show; got {px:?}");
engine.set_isolated_node(Some(green));
engine.render(0.0);
let solo = engine.test_readback_canvas();
let px = rgba_at(&solo, cw, cw / 2, ch / 2);
assert!(
px[1] > 200 && px[0] < 50 && px[2] < 50,
"isolated green should be the only thing rendered; got {px:?}"
);
engine.set_isolated_node(None);
engine.render(0.0);
let after = engine.test_readback_canvas();
assert_eq!(
after, baseline,
"clearing isolation must round-trip exactly to the pre-isolation \
render — anything else means visibility was puppetted"
);
}
#[test]
fn isolated_transparency_presents_as_checker_not_black() {
let (cw, ch) = (16u32, 16u32);
let mut engine = test_engine(cw, ch);
let bg = engine.add_raster_layer(None);
fill_layer(&mut engine, bg, 255, 0, 0);
let empty = engine.add_raster_layer(None);
engine.set_isolated_node(Some(empty));
engine.test_flush_readbacks();
engine.render(0.0);
let pixels = engine.test_readback_present();
let cell_a = rgba_at(&pixels, cw, 0, 0);
let cell_b = rgba_at(&pixels, cw, 8, 0);
assert_eq!(
cell_a,
[102, 102, 102, 255],
"isolated-empty canvas pixel (0, 0) must show the darker checker tile"
);
assert_eq!(
cell_b,
[153, 153, 153, 255],
"isolated-empty canvas pixel (8, 0) must show the lighter checker tile"
);
}
#[test]
fn isolated_partial_alpha_blends_with_checker_not_at_full_intensity() {
let (cw, ch) = (16u32, 16u32);
let mut engine = test_engine(cw, ch);
let bg = engine.add_raster_layer(None);
fill_layer(&mut engine, bg, 0, 255, 0); let translucent = engine.add_raster_layer(None);
fill_layer(&mut engine, translucent, 255, 0, 0);
engine.set_opacity(translucent, 0.5);
engine.set_isolated_node(Some(translucent));
engine.test_flush_readbacks();
engine.render(0.0);
let pixels = engine.test_readback_present();
let cell_a = rgba_at(&pixels, cw, 0, 0);
let r_dark = cell_a[0];
let g_dark = cell_a[1];
assert!(
(170..=185).contains(&r_dark),
"translucent red over the dark checker tile must blend halfway — \
expected R ~178, got {r_dark} (full {cell_a:?}). The pre-fix \
premul-formula bug clamps R to 255."
);
assert!(
(45..=60).contains(&g_dark),
"checker green channel must show through — expected G ~51, got \
{g_dark} (full {cell_a:?}). Pre-fix the formula clamped G to ~76 \
and hid the checker entirely."
);
let cell_b = rgba_at(&pixels, cw, 8, 0);
assert_ne!(
cell_a, cell_b,
"adjacent checker cells must differ under the translucent layer — \
identical pixels mean the present shader is binarizing alpha."
);
}
#[test]
fn repeated_identity_transforms_on_mask_are_idempotent() {
let (cw, ch) = (16u32, 16u32);
let mut engine = test_engine(cw, ch);
let host = engine.add_raster_layer(None);
fill_layer(&mut engine, host, 255, 255, 255);
engine.add_mask(host);
let mask_id = engine.host_mask_id(host).expect("host has mask");
engine.begin_stroke(mask_id);
engine.stroke_to(StrokeOp::FloodFill {
x: 1.0,
y: 1.0,
r: 128,
g: 128,
b: 128,
a: 255,
tolerance: 0,
});
engine.end_stroke();
engine.test_flush_readbacks();
let baseline = engine.test_readback_mask(host);
assert!(
baseline.iter().all(|&v| v == 128),
"fixture: mask should be mid-gray everywhere before transforming"
);
for cycle in 0..5 {
engine.select_all();
let started = engine.begin_transform(mask_id);
assert!(
started,
"begin_transform on mask must succeed (cycle {cycle})"
);
engine.commit_floating();
let after = engine.test_readback_mask(host);
assert_eq!(
after,
baseline,
"identity transform on mask must preserve every pixel \
(cycle {cycle}); first diff at index {:?}",
after.iter().zip(&baseline).position(|(a, b)| a != b)
);
}
}
#[test]
fn isolating_mask_modifier_renders_grayscale() {
let (cw, ch) = (32u32, 32u32);
let mut engine = test_engine(cw, ch);
let layer = engine.add_raster_layer(None);
fill_layer(&mut engine, layer, 255, 0, 0);
engine.add_mask(layer);
let mask_id = engine.host_mask_id(layer).expect("layer has a mask");
engine.begin_stroke(mask_id);
engine.stroke_to(StrokeOp::FloodFill {
x: 1.0,
y: 1.0,
r: 128,
g: 128,
b: 128,
a: 255,
tolerance: 0,
});
engine.end_stroke();
engine.test_flush_readbacks();
engine.set_isolated_node(Some(mask_id));
engine.render(0.0);
let solo = engine.test_readback_canvas();
let px = rgba_at(&solo, cw, cw / 2, ch / 2);
assert!(
(px[0] as i32 - px[1] as i32).abs() < 4 && (px[1] as i32 - px[2] as i32).abs() < 4,
"isolated mask must render as RGB-equal grayscale (no red leak); \
got {px:?}"
);
assert!(
px[0] > 100 && px[0] < 160,
"grayscale value should reflect mid-gray mask coverage (~128); \
got {px:?}"
);
assert_eq!(
px[3], 255,
"isolated-mask grayscale output is fully opaque on canvas; got alpha={}",
px[3]
);
}
#[test]
fn transform_translate_on_mask_moves_pixels() {
use darkly::gpu::transform::affine_translate;
let (cw, ch) = (32u32, 32u32);
let mut engine = test_engine(cw, ch);
let host = engine.add_raster_layer(None);
fill_layer(&mut engine, host, 255, 255, 255);
engine.add_mask(host);
let mask_id = engine.host_mask_id(host).expect("host has mask");
engine.begin_stroke(mask_id);
engine.stroke_to(StrokeOp::FloodFill {
x: 1.0,
y: 1.0,
r: 200,
g: 200,
b: 200,
a: 255,
tolerance: 0,
});
engine.end_stroke();
engine.test_flush_readbacks();
let pre = engine.test_readback_mask(host);
assert_eq!(pre[0], 200, "fixture: mask is filled to value 200");
let sel_x = 8;
let sel_y = 8;
let sel_w = 8;
let sel_h = 8;
engine.select_rect(
sel_x as f32,
sel_y as f32,
sel_w as f32,
sel_h as f32,
SelectionMode::Replace,
false,
0.0,
);
let started = engine.begin_transform(mask_id);
assert!(started, "begin_transform on mask must succeed");
engine.update_floating_matrix(affine_translate(12.0, 0.0));
engine.commit_floating();
let post = engine.test_readback_mask(host);
let new_x = sel_x + 12;
for dy in 0..sel_h {
for dx in 0..sel_w {
let v = post[((sel_y + dy) * cw + new_x + dx) as usize];
assert!(
v > 150,
"moved pixel at ({}, {}) should carry the original mask value, got {v}",
new_x + dx,
sel_y + dy,
);
}
}
for dy in 0..sel_h {
for dx in 0..sel_w {
let v = post[((sel_y + dy) * cw + sel_x + dx) as usize];
assert_eq!(
v,
0,
"source pixel at ({}, {}) should be cleared after commit, got {v}",
sel_x + dx,
sel_y + dy,
);
}
}
let untouched = post[((cw - 1) + cw * (ch - 1)) as usize];
assert_eq!(
untouched, 200,
"pixels outside the affected rect must be unchanged"
);
}
#[test]
fn mask_visible_during_transform_drag() {
use darkly::gpu::transform::affine_translate;
let (cw, ch) = (32u32, 32u32);
let mut engine = test_engine(cw, ch);
let host = engine.add_raster_layer(None);
fill_layer(&mut engine, host, 255, 0, 0);
engine.add_mask(host);
let mask_id = engine.host_mask_id(host).expect("host has mask");
engine.begin_stroke(mask_id);
engine.stroke_to(StrokeOp::FloodFill {
x: 1.0,
y: 1.0,
r: 0,
g: 0,
b: 0,
a: 255,
tolerance: 0,
});
engine.end_stroke();
engine.test_flush_readbacks();
engine.select_rect(
0.0,
0.0,
(cw / 2) as f32,
ch as f32,
SelectionMode::Replace,
false,
0.0,
);
engine.begin_stroke(mask_id);
engine.stroke_to(StrokeOp::FloodFill {
x: 1.0,
y: 1.0,
r: 255,
g: 255,
b: 255,
a: 255,
tolerance: 0,
});
engine.end_stroke();
engine.test_flush_readbacks();
engine.clear_selection();
engine.render(0.0);
let mask_pre = engine.test_readback_mask(host);
assert_eq!(mask_pre[0], 255, "left edge of mask should be 255");
assert_eq!(
mask_pre[(cw - 1) as usize],
0,
"right edge of mask should be 0"
);
let baseline = engine.test_readback_canvas();
assert!(
rgba_at(&baseline, cw, 4, ch / 2)[0] > 200,
"left half should be opaque red; got {:?}",
rgba_at(&baseline, cw, 4, ch / 2)
);
assert_eq!(
rgba_at(&baseline, cw, cw - 4, ch / 2)[3],
0,
"right half should be transparent (mask=0)"
);
engine.select_all();
let started = engine.begin_transform(mask_id);
assert!(started, "begin_transform on mask must succeed");
engine.update_floating_matrix(affine_translate((cw / 2) as f32, 0.0));
engine.render(0.0);
let dragging = engine.test_readback_canvas();
assert_eq!(
rgba_at(&dragging, cw, 4, ch / 2)[3],
0,
"during drag, original mask position should be uncovered by the preview mask"
);
assert!(
rgba_at(&dragging, cw, cw - 4, ch / 2)[0] > 200,
"during drag, transformed mask position should reveal red"
);
engine.cancel_floating();
}
#[test]
fn cancel_transform_on_mask_leaves_texture_pristine() {
use darkly::gpu::transform::affine_translate;
let (cw, ch) = (32u32, 32u32);
let mut engine = test_engine(cw, ch);
let host = engine.add_raster_layer(None);
fill_layer(&mut engine, host, 255, 255, 255);
engine.add_mask(host);
let mask_id = engine.host_mask_id(host).expect("host has mask");
engine.begin_stroke(mask_id);
engine.stroke_to(StrokeOp::FloodFill {
x: 1.0,
y: 1.0,
r: 191,
g: 191,
b: 191,
a: 255,
tolerance: 0,
});
engine.end_stroke();
engine.test_flush_readbacks();
let baseline = engine.test_readback_mask(host);
engine.select_all();
let started = engine.begin_transform(mask_id);
assert!(started);
engine.update_floating_matrix(affine_translate(7.0, 3.0));
engine.cancel_floating();
let after = engine.test_readback_mask(host);
assert_eq!(
after, baseline,
"cancel must leave the mask exactly as it was pre-transform"
);
}
#[test]
fn transform_mask_under_isolation_previews_grayscale() {
use darkly::gpu::transform::affine_translate;
let (cw, ch) = (32u32, 32u32);
let mut engine = test_engine(cw, ch);
let host = engine.add_raster_layer(None);
fill_layer(&mut engine, host, 255, 0, 0);
engine.add_mask(host);
let mask_id = engine.host_mask_id(host).expect("host has mask");
engine.select_rect(4.0, 4.0, 8.0, 8.0, SelectionMode::Replace, false, 0.0);
engine.begin_stroke(mask_id);
engine.stroke_to(StrokeOp::FloodFill {
x: 1.0,
y: 1.0,
r: 255,
g: 255,
b: 255,
a: 255,
tolerance: 0,
});
engine.end_stroke();
engine.clear_selection();
engine.test_flush_readbacks();
engine.set_isolated_node(Some(mask_id));
engine.select_rect(4.0, 4.0, 8.0, 8.0, SelectionMode::Replace, false, 0.0);
let started = engine.begin_transform(mask_id);
assert!(started);
engine.update_floating_matrix(affine_translate(16.0, 0.0));
engine.render(0.0);
let canvas = engine.test_readback_canvas();
let moved = rgba_at(&canvas, cw, 24, 8);
assert!(
moved[0] > 200 && moved[1] > 200 && moved[2] > 200 && moved[3] == 255,
"transformed mask position should render as grayscale white through \
the isolated-host shader path; got {moved:?}"
);
assert!(
(moved[0] as i32 - moved[1] as i32).abs() < 4,
"isolated-mask preview must be RGB-equal grayscale; got {moved:?}"
);
let original = rgba_at(&canvas, cw, 8, 8);
assert!(
original[0] < 32 && original[1] < 32 && original[2] < 32,
"original mask position should be black after the preview shift; \
got {original:?}"
);
engine.cancel_floating();
}
#[test]
fn long_stabilized_stroke_no_fallback() {
let (w, h) = (512, 512);
let mut engine = test_engine(w, h);
let layer_id = engine.add_raster_layer(None);
let pen_id = find_node_id(&engine, pen_input::TYPE_ID);
engine
.brush_graph_set_port_default(pen_id, "stabilize", 1.0)
.unwrap();
engine.begin_stroke(layer_id);
let samples = 400usize;
let cx = (w / 2) as f32;
let cy = (h / 2) as f32;
for i in 0..samples {
let t = i as f32 / samples as f32;
let theta = t * std::f32::consts::TAU * 3.0;
let r = 20.0 + t * 200.0;
let x = cx + r * theta.cos();
let y = cy + r * theta.sin();
engine.stroke_to(StrokeOp::BrushStroke {
x,
y,
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();
assert_eq!(
engine.test_stroke_full_rerender_events(),
0,
"long stabilized stroke must not trigger any mid-stroke full \
re-render fallback — the coverage invariant guarantees \
`restore_before` succeeds for every reachable divergence index"
);
}
#[test]
fn export_readback_produces_rgba8_matching_composite() {
let (cw, ch) = (64, 48);
let mut engine = test_engine(cw, ch);
let layer = engine.add_raster_layer(None);
paint_at(
&mut engine,
layer,
(cw / 2) as f32,
(ch / 2) as f32,
1.0,
0.3,
0.0,
);
let reference = engine.test_readback_canvas();
assert_eq!(reference.len(), (cw * ch * 4) as usize);
engine.start_export();
let mut result = None;
for _ in 0..16 {
engine.test_flush_readbacks();
engine.render(0.0);
if let Some(r) = engine.poll_export_result() {
result = Some(r);
break;
}
}
let export = result.expect("export readback did not complete within 16 iterations");
assert_eq!(export.width, cw);
assert_eq!(export.height, ch);
assert_eq!(
export.rgba.len(),
(cw * ch * 4) as usize,
"export must be tightly packed RGBA8 with no row padding"
);
assert_eq!(
export.rgba, reference,
"async export bytes must equal the blocking composite readback — \
the production export path would otherwise lie about canvas contents"
);
}
#[test]
fn poll_export_result_returns_none_before_completion() {
let mut engine = test_engine(8, 8);
let _layer = engine.add_raster_layer(None);
engine.start_export();
assert!(
engine.poll_export_result().is_none(),
"result must not be available before the readback completes"
);
}
#[test]
fn locked_layer_rejects_modifications() {
let (w, h) = (64, 64);
let mut engine = test_engine(w, h);
let other = engine.add_raster_layer(None);
let layer_id = engine.add_raster_layer(None);
paint_at(&mut engine, layer_id, 32.0, 32.0, 1.0, 0.0, 0.0);
let baseline = engine.test_readback_layer(layer_id);
assert!(
alpha_at(&baseline, w, 32, 32) > 0,
"unlocked paint must land on the layer"
);
let old_name = "before-lock";
engine.set_layer_name(layer_id, old_name);
engine.set_opacity(layer_id, 0.5);
engine.set_node_locked(layer_id, true);
paint_at(&mut engine, layer_id, 10.0, 10.0, 0.0, 1.0, 0.0);
let after_paint = engine.test_readback_layer(layer_id);
assert_eq!(
baseline, after_paint,
"locked layer must not accept any paint"
);
engine.set_layer_name(layer_id, "should-be-ignored");
engine.set_opacity(layer_id, 1.0);
engine.set_blend_mode(layer_id, "multiply");
let tree = engine.layer_tree();
let info = tree
.iter()
.find_map(|n| match n {
darkly::engine::types::LayerInfo::Raster {
id,
name,
opacity,
blend_mode,
..
} if *id == layer_id.to_ffi() as f64 => Some((name.clone(), *opacity, *blend_mode)),
_ => None,
})
.expect("layer in tree");
assert_eq!(info.0, old_name, "rename must be rejected when locked");
assert!(
(info.1 - 0.5).abs() < 1e-6,
"opacity change must be rejected when locked"
);
assert_eq!(
info.2, "normal",
"blend mode change must be rejected when locked"
);
assert!(
engine.remove_layer(layer_id).is_err(),
"remove_layer must error when locked"
);
assert!(engine.has_layer(layer_id), "locked layer must still exist");
engine.set_node_locked(layer_id, false);
paint_at(&mut engine, layer_id, 10.0, 10.0, 0.0, 1.0, 0.0);
let after_unlock = engine.test_readback_layer(layer_id);
assert!(
alpha_at(&after_unlock, w, 10, 10) > 0,
"paint must land again after unlock"
);
let _ = other;
}
fn flat_rgba(width: u32, height: u32, color: [u8; 4]) -> Vec<u8> {
let mut v = Vec::with_capacity((width * height * 4) as usize);
for _ in 0..(width * height) {
v.extend_from_slice(&color);
}
v
}
#[test]
fn pick_color_layer_vs_merged_vs_outside_extent() {
use darkly::engine::PickSource;
let (w, h) = (128, 128);
let mut engine = test_engine(w, h);
let bottom_pixels = flat_rgba(w, h, [200, 0, 0, 255]);
let bottom_id = engine.paste_image(w, h, &bottom_pixels, 0, 0, None);
let top_w = 32;
let top_h = 32;
let top_off = (10i32, 10i32);
let top_pixels = flat_rgba(top_w, top_h, [0, 0, 200, 128]);
let top_id = engine.paste_image(
top_w,
top_h,
&top_pixels,
top_off.0,
top_off.1,
Some(bottom_id),
);
let _ = engine.test_readback_canvas();
let inside_x = 20.0_f32;
let inside_y = 20.0_f32;
engine.pick_color(inside_x, inside_y, PickSource::Layer(top_id));
engine.test_flush_readbacks();
let layer_top = engine.last_picked_color();
assert_eq!(
layer_top,
[0, 0, 200, 128],
"Layer(top) must return the raw top-layer pixel, not the composite"
);
engine.pick_color(inside_x, inside_y, PickSource::Layer(bottom_id));
engine.test_flush_readbacks();
let layer_bottom = engine.last_picked_color();
assert_eq!(
layer_bottom,
[200, 0, 0, 255],
"Layer(bottom) must return the raw bottom-layer pixel"
);
engine.pick_color(inside_x, inside_y, PickSource::Merged);
engine.test_flush_readbacks();
let merged = engine.last_picked_color();
assert_ne!(
merged, layer_top,
"Merged must differ from Layer(top); the half-alpha top must blend with red bottom"
);
assert_ne!(
merged, layer_bottom,
"Merged must differ from Layer(bottom); the top must contribute to the blend"
);
assert!(
merged[0] > 50 && merged[2] > 50,
"Merged should carry both red (from bottom) and blue (from top); got {:?}",
merged
);
let outside_x = 80.0_f32;
let outside_y = 80.0_f32;
engine.pick_color(outside_x, outside_y, PickSource::Layer(top_id));
engine.test_flush_readbacks();
let outside_top = engine.last_picked_color();
engine.pick_color(outside_x, outside_y, PickSource::Merged);
engine.test_flush_readbacks();
let outside_merged = engine.last_picked_color();
assert_eq!(
outside_top, outside_merged,
"Layer(top) at a point outside the top extent must fall back to Merged, not return [0;4]"
);
assert_eq!(
outside_merged,
[200, 0, 0, 255],
"Where only bottom covers, Merged equals bottom (sanity check on the test setup)"
);
}
#[test]
fn copy_with_aa_rect_selection_has_no_transparent_border() {
let (cw, ch) = (64u32, 64u32);
let mut engine = test_engine(cw, ch);
let mut rgba = vec![0u8; (cw * ch * 4) as usize];
for i in 0..(cw * ch) as usize {
rgba[i * 4] = 255;
rgba[i * 4 + 3] = 255;
}
let layer_id = engine.paste_image(cw, ch, &rgba, 0, 0, None);
let sel_x = 10.0_f32;
let sel_y = 10.0_f32;
let sel_w = 20.0_f32;
let sel_h = 20.0_f32;
engine.select_rect(
sel_x,
sel_y,
sel_w,
sel_h,
SelectionMode::Replace,
true,
0.0,
);
engine.copy(layer_id);
engine.test_flush_readbacks();
let exported = engine
.poll_copy_result()
.expect("copy result should be available after flushing readbacks");
assert_eq!(
exported.width, sel_w as u32,
"clipboard width must match selection width (got {}×{}, expected {}×{})",
exported.width, exported.height, sel_w as u32, sel_h as u32,
);
assert_eq!(
exported.height, sel_h as u32,
"clipboard height must match selection height (got {}×{}, expected {}×{})",
exported.width, exported.height, sel_w as u32, sel_h as u32,
);
assert_eq!(
exported.offset_x, sel_x as i32,
"clipboard offset_x must match selection origin"
);
assert_eq!(
exported.offset_y, sel_y as i32,
"clipboard offset_y must match selection origin"
);
let w = exported.width;
let h = exported.height;
for y in 0..h {
for x in 0..w {
let a = exported.rgba[((y * w + x) * 4 + 3) as usize];
assert_eq!(
a, 255,
"pixel ({x},{y}) should be opaque (alpha=255); got {a}. \
A transparent perimeter indicates AA-margin pixels leaked \
into the copy bounds."
);
}
}
}
#[test]
fn multi_delete_undo_redo_round_trip() {
let mut engine = test_engine(64, 64);
let l1 = engine.add_raster_layer(None);
let l2 = engine.add_raster_layer(None);
let l3 = engine.add_raster_layer(None);
let _keep = engine.add_raster_layer(None);
paint_at(&mut engine, l1, 8.0, 8.0, 1.0, 0.0, 0.0);
paint_at(&mut engine, l2, 16.0, 16.0, 0.0, 1.0, 0.0);
paint_at(&mut engine, l3, 24.0, 24.0, 0.0, 0.0, 1.0);
let pix1 = engine.test_readback_layer(l1);
let pix2 = engine.test_readback_layer(l2);
let pix3 = engine.test_readback_layer(l3);
let skipped = engine.remove_layers(vec![l1, l2, l3]).expect("remove ok");
assert_eq!(skipped, 0);
assert!(!engine.has_layer(l1));
assert!(!engine.has_layer(l2));
assert!(!engine.has_layer(l3));
engine.undo();
engine.render(0.0);
assert!(engine.has_layer(l1), "one undo must restore all three");
assert!(engine.has_layer(l2));
assert!(engine.has_layer(l3));
assert_eq!(engine.test_readback_layer(l1), pix1);
assert_eq!(engine.test_readback_layer(l2), pix2);
assert_eq!(engine.test_readback_layer(l3), pix3);
}
#[test]
fn delete_skips_locked_layers() {
let mut engine = test_engine(64, 64);
let l1 = engine.add_raster_layer(None);
let l2 = engine.add_raster_layer(None);
let _keep = engine.add_raster_layer(None);
engine.set_node_locked(l1, true);
let skipped = engine.remove_layers(vec![l1, l2]).expect("remove ok");
assert_eq!(skipped, 1, "the locked layer should be reported as skipped");
assert!(engine.has_layer(l1), "locked layer must remain in the tree");
assert!(!engine.has_layer(l2), "unlocked layer should be removed");
}
#[test]
fn delete_refuses_to_empty_document() {
let mut engine = test_engine(64, 64);
let l1 = engine.add_raster_layer(None);
let l2 = engine.add_raster_layer(None);
let result = engine.remove_layers(vec![l1, l2]);
assert!(result.is_err(), "must refuse leaving an empty document");
assert!(engine.has_layer(l1));
assert!(engine.has_layer(l2));
}
#[test]
fn delete_dedupes_ancestor_descendant() {
use darkly::document::MoveTarget;
use darkly::engine::types::LayerInfo;
let mut engine = test_engine(64, 64);
let _keep = engine.add_raster_layer(None);
let group = engine.add_group(None);
let child = engine.add_raster_layer(None);
engine.move_layer(child, MoveTarget::IntoGroupTop(group));
let group_in_root = |e: &DarklyEngine| -> bool {
e.layer_tree().iter().any(|n| {
matches!(
n,
LayerInfo::Group { id, .. } if *id == group.to_ffi() as f64
)
})
};
let skipped = engine.remove_layers(vec![group, child]).expect("remove ok");
assert_eq!(skipped, 0);
assert!(
!group_in_root(&engine),
"group should be detached from tree"
);
assert!(!engine.has_layer(group));
engine.undo();
engine.render(0.0);
assert!(group_in_root(&engine), "one undo restores the group");
assert!(
engine.has_layer(child),
"child must come back with the group"
);
}
#[test]
fn multi_move_preserves_relative_order() {
use darkly::document::MoveTarget;
use darkly::engine::types::LayerInfo;
let mut engine = test_engine(32, 32);
let l1 = engine.add_raster_layer(None);
let _l2 = engine.add_raster_layer(None);
let l3 = engine.add_raster_layer(None);
let _keep = engine.add_raster_layer(None);
let g = engine.add_group(None);
engine
.move_layers(vec![l1, l3], MoveTarget::IntoGroupTop(g))
.expect("move ok");
let tree = engine.layer_tree();
let group_info = tree
.iter()
.find(|n| {
matches!(
n, LayerInfo::Group { id, .. } if *id == g.to_ffi() as f64
)
})
.expect("group still in tree");
let children = match group_info {
LayerInfo::Group { children, .. } => children,
_ => panic!(),
};
let child_ids: Vec<f64> = children
.iter()
.map(|c| match c {
LayerInfo::Raster { id, .. } => *id,
LayerInfo::Void { id, .. } => *id,
LayerInfo::Filter { id, .. } => *id,
LayerInfo::Group { id, .. } => *id,
})
.collect();
assert_eq!(
child_ids,
vec![l3.to_ffi() as f64, l1.to_ffi() as f64],
"group's panel-order children should be [l3, l1] — preserving the \
original relative panel order"
);
}
#[test]
fn move_refuses_self_target() {
use darkly::document::MoveTarget;
let mut engine = test_engine(32, 32);
let _keep = engine.add_raster_layer(None);
let group = engine.add_group(None);
let child = engine.add_raster_layer(None);
engine.move_layer(child, MoveTarget::IntoGroupTop(group));
let result = engine.move_layers(vec![group], MoveTarget::Before(child));
assert!(
result.is_err(),
"moving a group into its own child is nonsense"
);
let result = engine.move_layers(vec![child, group], MoveTarget::Before(group));
assert!(
result.is_err(),
"moving a selection onto a member of itself is nonsense"
);
}
#[test]
fn group_layers_wraps_selection_at_topmost_slot() {
use darkly::engine::types::LayerInfo;
let mut engine = test_engine(32, 32);
let _keep_bottom = engine.add_raster_layer(None);
let l1 = engine.add_raster_layer(None);
let l2 = engine.add_raster_layer(None);
let l3 = engine.add_raster_layer(None); let _keep_top = engine.add_raster_layer(None);
let group_id = engine.group_layers(vec![l1, l2, l3]).expect("group ok");
let tree = engine.layer_tree();
let group_f = group_id.to_ffi() as f64;
let group_info = tree
.iter()
.find(|n| matches!(n, LayerInfo::Group { id, .. } if *id == group_f))
.expect("group in tree");
let children = match group_info {
LayerInfo::Group { children, .. } => children,
_ => panic!(),
};
let child_ids: Vec<f64> = children
.iter()
.map(|c| match c {
LayerInfo::Raster { id, .. } => *id,
LayerInfo::Void { id, .. } => *id,
LayerInfo::Filter { id, .. } => *id,
LayerInfo::Group { id, .. } => *id,
})
.collect();
assert_eq!(
child_ids,
vec![l3.to_ffi() as f64, l2.to_ffi() as f64, l1.to_ffi() as f64,],
"group children in panel order should preserve original relative order"
);
}
#[test]
fn group_layers_cross_parent() {
use darkly::document::MoveTarget;
use darkly::engine::types::LayerInfo;
let mut engine = test_engine(32, 32);
let _keep = engine.add_raster_layer(None);
let outer = engine.add_group(None);
let inside = engine.add_raster_layer(None);
engine.move_layer(inside, MoveTarget::IntoGroupTop(outer));
let root_top = engine.add_raster_layer(None);
let group_id = engine
.group_layers(vec![inside, root_top])
.expect("cross-parent group ok");
let tree = engine.layer_tree();
let new_group = tree
.iter()
.find(|n| matches!(n, LayerInfo::Group { id, .. } if *id == group_id.to_ffi() as f64))
.expect("new group at root");
let children = match new_group {
LayerInfo::Group { children, .. } => children,
_ => panic!(),
};
let child_ids: Vec<f64> = children
.iter()
.map(|c| match c {
LayerInfo::Raster { id, .. } => *id,
LayerInfo::Void { id, .. } => *id,
LayerInfo::Filter { id, .. } => *id,
LayerInfo::Group { id, .. } => *id,
})
.collect();
assert_eq!(child_ids.len(), 2);
assert!(child_ids.contains(&(inside.to_ffi() as f64)));
assert!(child_ids.contains(&(root_top.to_ffi() as f64)));
let outer_info = tree
.iter()
.find(|n| matches!(n, LayerInfo::Group { id, .. } if *id == outer.to_ffi() as f64))
.expect("outer group still in tree");
let outer_children = match outer_info {
LayerInfo::Group { children, .. } => children,
_ => panic!(),
};
assert!(
outer_children.is_empty(),
"outer group emptied as source moved out"
);
}
#[test]
fn group_layers_dedupes_ancestor_descendant() {
use darkly::document::MoveTarget;
use darkly::engine::types::LayerInfo;
let mut engine = test_engine(32, 32);
let _keep = engine.add_raster_layer(None);
let outer = engine.add_group(None);
let inside = engine.add_raster_layer(None);
engine.move_layer(inside, MoveTarget::IntoGroupTop(outer));
let new_group = engine.group_layers(vec![outer, inside]).expect("group ok");
let tree = engine.layer_tree();
let new_info = tree
.iter()
.find(|n| matches!(n, LayerInfo::Group { id, .. } if *id == new_group.to_ffi() as f64))
.expect("new group in tree");
let direct_children = match new_info {
LayerInfo::Group { children, .. } => children,
_ => panic!(),
};
assert_eq!(direct_children.len(), 1, "ancestor dedup keeps only outer");
match &direct_children[0] {
LayerInfo::Group { id, children, .. } => {
assert_eq!(*id, outer.to_ffi() as f64);
assert_eq!(children.len(), 1, "outer still contains inside");
}
_ => panic!("expected Group as direct child"),
}
}
#[test]
fn group_layers_one_undo_step_restores_tree() {
use darkly::engine::types::LayerInfo;
let mut engine = test_engine(32, 32);
let _keep = engine.add_raster_layer(None);
let l1 = engine.add_raster_layer(None);
let l2 = engine.add_raster_layer(None);
let group_id = engine.group_layers(vec![l1, l2]).expect("group ok");
let group_in_tree = |e: &DarklyEngine| -> bool {
e.layer_tree().iter().any(|n| {
matches!(
n,
LayerInfo::Group { id, .. } if *id == group_id.to_ffi() as f64
)
})
};
let layer_at_root = |e: &DarklyEngine, id: LayerId| -> bool {
e.layer_tree().iter().any(|n| match n {
LayerInfo::Raster { id: x, .. } => *x == id.to_ffi() as f64,
_ => false,
})
};
assert!(group_in_tree(&engine), "group at root after creation");
assert!(!layer_at_root(&engine, l1), "l1 moved into group");
assert!(!layer_at_root(&engine, l2), "l2 moved into group");
engine.undo();
engine.render(0.0);
assert!(!group_in_tree(&engine), "group removed by single undo");
assert!(layer_at_root(&engine, l1), "l1 back at root");
assert!(layer_at_root(&engine, l2), "l2 back at root");
}
#[test]
fn group_layers_skips_locked() {
let mut engine = test_engine(32, 32);
let _keep = engine.add_raster_layer(None);
let l1 = engine.add_raster_layer(None);
let l2 = engine.add_raster_layer(None);
engine.set_node_locked(l1, true);
let group_id = engine
.group_layers(vec![l1, l2])
.expect("group ok with one editable");
use darkly::engine::types::LayerInfo;
let tree = engine.layer_tree();
let l1_at_root = tree.iter().any(|n| match n {
LayerInfo::Raster { id, .. } => *id == l1.to_ffi() as f64,
_ => false,
});
assert!(l1_at_root, "locked layer stayed put at root");
let new_info = tree
.iter()
.find(|n| matches!(n, LayerInfo::Group { id, .. } if *id == group_id.to_ffi() as f64))
.expect("new group in tree");
let direct = match new_info {
LayerInfo::Group { children, .. } => children,
_ => panic!(),
};
assert_eq!(direct.len(), 1);
}
#[test]
fn group_layers_errors_when_all_locked() {
let mut engine = test_engine(32, 32);
let _keep = engine.add_raster_layer(None);
let l1 = engine.add_raster_layer(None);
engine.set_node_locked(l1, true);
let result = engine.group_layers(vec![l1]);
assert!(result.is_err(), "all-locked selection must error");
}
#[test]
fn multi_duplicate_each_lands_above_its_source() {
use darkly::engine::types::LayerInfo;
let mut engine = test_engine(32, 32);
let _keep_bottom = engine.add_raster_layer(None);
let a = engine.add_raster_layer(None);
let b = engine.add_raster_layer(None);
let c = engine.add_raster_layer(None);
engine.set_layer_name(a, "A");
engine.set_layer_name(b, "B");
engine.set_layer_name(c, "C");
let news = engine.duplicate_nodes(vec![a, b, c]);
assert_eq!(news.len(), 3);
let mut tree_ids: Vec<f64> = engine
.layer_tree()
.iter()
.map(|n| match n {
LayerInfo::Raster { id, .. } => *id,
LayerInfo::Void { id, .. } => *id,
LayerInfo::Filter { id, .. } => *id,
LayerInfo::Group { id, .. } => *id,
})
.collect();
tree_ids.reverse();
let pos = |id: darkly::layer::LayerId| {
tree_ids
.iter()
.position(|&x| x == id.to_ffi() as f64)
.expect("in tree")
};
let (pa, pb, pc) = (pos(a), pos(b), pos(c));
let (pad, pbd, pcd) = (pos(news[0]), pos(news[1]), pos(news[2]));
assert_eq!(pad, pa + 1, "A_dup should be at A+1, not at the top");
assert_eq!(pbd, pb + 1, "B_dup should be at B+1");
assert_eq!(pcd, pc + 1, "C_dup should be at C+1");
}
#[test]
fn multi_duplicate_returns_new_ids_one_undo() {
let mut engine = test_engine(64, 64);
let l1 = engine.add_raster_layer(None);
let l2 = engine.add_raster_layer(None);
let news = engine.duplicate_nodes(vec![l1, l2]);
assert_eq!(news.len(), 2);
for new in &news {
assert!(engine.has_layer(*new));
}
engine.undo();
engine.render(0.0);
for new in news {
assert!(
!engine.has_layer(new),
"one undo must remove every duplicate"
);
}
}