use std::sync::{Arc, OnceLock};
use darkly::brush::compile_graph;
use darkly::brush::eval::BrushGraphRunner;
use darkly::brush::gpu_context::{BrushGpuContext, BrushPerfCounters, DabBatch, StrokeResources};
use darkly::brush::paint_info::PaintInformation;
use darkly::brush::pipeline::BrushPipelines;
use darkly::brush::registry;
use darkly::brush::stroke_buffer::StrokeBuffer;
use darkly::brush::wire::BrushWireType;
use darkly::gpu::params::ParamValue;
use darkly::gpu::test_utils::{create_test_texture, readback_texture, test_device};
use darkly::nodegraph::{Graph, PortRef};
const CANVAS: u32 = 128;
fn shared_device() -> (Arc<wgpu::Device>, Arc<wgpu::Queue>) {
static HANDLES: OnceLock<(Arc<wgpu::Device>, Arc<wgpu::Queue>)> = OnceLock::new();
HANDLES
.get_or_init(|| {
let (d, q) = test_device();
(Arc::new(d), Arc::new(q))
})
.clone()
}
struct Harness {
device: Arc<wgpu::Device>,
queue: Arc<wgpu::Queue>,
layer_texture: wgpu::Texture,
layer_view: wgpu::TextureView,
pipelines: BrushPipelines,
stroke_buffer: StrokeBuffer,
runner: BrushGraphRunner,
}
fn build_test_graph(algorithm: i32, amplitude: f32, size: f32) -> Graph<BrushWireType> {
let registry = registry();
let mut graph = Graph::<BrushWireType>::new();
let pen = graph.add_node(
"pen_input",
registry.get("pen_input").unwrap().ports.clone(),
vec![],
);
let paint_color = graph.add_node(
"paint_color",
registry.get("paint_color").unwrap().ports.clone(),
vec![],
);
let curve = graph.add_node(
"curve",
registry.get("curve").unwrap().ports.clone(),
vec![ParamValue::Curve(vec![[0.0, 0.0], [1.0, 1.0]])],
);
let shape = graph.add_node(
"shape",
registry.get("shape").unwrap().ports.clone(),
vec![ParamValue::Int(algorithm)],
);
let stamp = graph.add_node(
"stamp",
registry.get("stamp").unwrap().ports.clone(),
vec![ParamValue::Int(0)], );
let terminal = graph.add_node(
"paint",
registry.get("paint").unwrap().ports.clone(),
vec![],
);
graph
.set_port_default(shape, "amplitude", amplitude)
.unwrap();
graph.set_port_default(shape, "softness", 0.0).unwrap();
graph.set_port_default(terminal, "size", size).unwrap();
graph.set_port_default(terminal, "opacity", 1.0).unwrap();
graph.set_port_default(terminal, "flow", 1.0).unwrap();
let wires = [
(pen, "pressure", curve, "input"),
(curve, "output", terminal, "size_input"),
(shape, "mask", stamp, "tip"),
(paint_color, "color", stamp, "color"),
(stamp, "dab", terminal, "rgba"),
(pen, "position", terminal, "position"),
];
for (fnode, fport, tnode, tport) in wires {
graph
.connect(
PortRef {
node: fnode,
port: fport.into(),
},
PortRef {
node: tnode,
port: tport.into(),
},
)
.unwrap();
}
graph
}
fn harness(initial: &[u8], graph: Graph<BrushWireType>) -> Harness {
let (device, queue) = shared_device();
let (layer_texture, layer_view) = create_test_texture(&device, &queue, CANVAS, CANVAS, initial);
let pipelines = BrushPipelines::new(
&device,
&queue,
&darkly::gpu::selection::selection_mask_bgl(&device),
);
let stroke_buffer = StrokeBuffer::new(&device, CANVAS, CANVAS, &pipelines);
let pre_stroke_paint_target = darkly::gpu::paint_target::GpuPaintTarget::from_canvas_texture(
&layer_texture,
&layer_view,
wgpu::TextureFormat::Rgba8Unorm,
darkly::coord::CanvasRect::from_xywh(0, 0, CANVAS, CANVAS),
);
let mut enc = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
label: Some("rough-ink-test-pre-stroke-init"),
});
stroke_buffer.save_pre_stroke(&device, &mut enc, &pipelines, &pre_stroke_paint_target);
queue.submit([enc.finish()]);
let runner = compile_graph(&graph).expect("graph compiles");
Harness {
device,
queue,
layer_texture,
layer_view,
pipelines,
stroke_buffer,
runner,
}
}
macro_rules! make_ctx {
($h:ident, $label:expr) => {{
let (_scratch, _pre_stroke_texture, _pre_stroke_bind_group) =
$h.stroke_buffer.parts_for_brush_ctx();
BrushGpuContext {
encoder: $h
.device
.create_command_encoder(&wgpu::CommandEncoderDescriptor {
label: Some($label),
}),
device: &$h.device,
queue: &$h.queue,
pipelines: &$h.pipelines,
selection_bind_group: $h.pipelines.default_selection_bind_group(),
canvas_width: CANVAS,
canvas_height: CANVAS,
canvas_origin: [0, 0],
blend_mode: 0,
view_rotation: 0.0,
perf: BrushPerfCounters::default(),
stroke: Some(StrokeResources {
scratch: _scratch,
paint_target: darkly::gpu::paint_target::GpuPaintTarget::from_canvas_texture(
&$h.layer_texture,
&$h.layer_view,
wgpu::TextureFormat::Rgba8Unorm,
darkly::coord::CanvasRect::from_xywh(0, 0, CANVAS, CANVAS),
),
pre_stroke_texture: _pre_stroke_texture,
pre_stroke_bind_group: _pre_stroke_bind_group,
}),
preview: None,
dab_batch: DabBatch::default(),
}
}};
}
impl Harness {
fn begin_stroke(&mut self) {
let mut ctx = make_ctx!(self, "rough-ink-test-begin");
self.runner.begin_stroke(&mut ctx);
self.queue.submit([ctx.encoder.finish()]);
}
fn dab_and_flush(&mut self, info: &PaintInformation, color: [f32; 4], dab_index: u32) {
let mut ctx = make_ctx!(self, "rough-ink-test-dab");
self.runner.seed_sensors(info, color, 0xC0FFEE, dab_index);
self.runner.execute_cpu();
self.runner.execute_gpu(&mut ctx);
self.runner.flush_dabs(&mut ctx);
self.runner.commit(&mut ctx);
self.queue.submit([ctx.encoder.finish()]);
}
fn two_dabs_same_phase(&mut self, a: &PaintInformation, b: &PaintInformation, color: [f32; 4]) {
let mut ctx = make_ctx!(self, "rough-ink-test-two-dabs");
self.runner.seed_sensors(a, color, 0xC0FFEE, 0);
self.runner.execute_cpu();
self.runner.execute_gpu(&mut ctx);
self.runner.seed_sensors(b, color, 0xC0FFEE, 1);
self.runner.execute_cpu();
self.runner.execute_gpu(&mut ctx);
self.runner.flush_dabs(&mut ctx);
self.runner.commit(&mut ctx);
self.queue.submit([ctx.encoder.finish()]);
}
fn readback_canvas(&self) -> Vec<u8> {
readback_texture(
&self.device,
&self.queue,
&self.layer_texture,
wgpu::TextureFormat::Rgba8Unorm,
CANVAS,
CANVAS,
)
}
}
fn center_pixel(rgba: &[u8], x: u32, y: u32) -> [u8; 4] {
let idx = ((y * CANVAS + x) * 4) as usize;
[rgba[idx], rgba[idx + 1], rgba[idx + 2], rgba[idx + 3]]
}
fn black_canvas() -> Vec<u8> {
let mut out = vec![0u8; (CANVAS * CANVAS * 4) as usize];
for px in out.chunks_exact_mut(4) {
px[3] = 255;
}
out
}
#[test]
fn single_dab_deposits_color_at_center() {
let graph = build_test_graph(
0, 0.0, 0.1,
);
let mut h = harness(&black_canvas(), graph);
h.begin_stroke();
let info = PaintInformation {
pos: [64.0, 64.0],
pressure: 1.0,
..Default::default()
};
h.dab_and_flush(&info, [1.0, 0.0, 0.0, 1.0], 0);
let rgba = h.readback_canvas();
let center = center_pixel(&rgba, 64, 64);
assert!(
center[0] > 200 && center[1] < 50 && center[2] < 50,
"center pixel should be ~red after dab, got {center:?}"
);
let outside = center_pixel(&rgba, 10, 10);
assert_eq!(
outside,
[0, 0, 0, 255],
"outside the dab should be unchanged"
);
}
#[test]
fn two_dabs_same_flush_both_deposit() {
let graph = build_test_graph(0, 0.0, 0.1);
let mut h = harness(&black_canvas(), graph);
h.begin_stroke();
let a = PaintInformation {
pos: [40.0, 40.0],
pressure: 1.0,
..Default::default()
};
let b = PaintInformation {
pos: [88.0, 88.0],
pressure: 1.0,
..Default::default()
};
h.two_dabs_same_phase(&a, &b, [0.0, 1.0, 0.0, 1.0]);
let rgba = h.readback_canvas();
let center_a = center_pixel(&rgba, 40, 40);
let center_b = center_pixel(&rgba, 88, 88);
assert!(
center_a[1] > 200 && center_a[0] < 50,
"dab A center should be green, got {center_a:?}"
);
assert!(
center_b[1] > 200 && center_b[0] < 50,
"dab B center should be green, got {center_b:?}"
);
let middle = center_pixel(&rgba, 64, 64);
assert_eq!(
middle,
[0, 0, 0, 255],
"between the two dabs should be untouched, got {middle:?}"
);
}
#[test]
fn builtin_rough_ink_brush_renders_within_declared_bbox() {
let rough_ink = darkly::brush::builtin_brushes::all()
.into_iter()
.find(|b| b.metadata.name == "Rough Ink")
.expect("Rough Ink registered");
let (device, queue) = shared_device();
let (layer_texture, layer_view) =
create_test_texture(&device, &queue, CANVAS, CANVAS, &black_canvas());
let pipelines = BrushPipelines::new(
&device,
&queue,
&darkly::gpu::selection::selection_mask_bgl(&device),
);
let stroke_buffer = StrokeBuffer::new(&device, CANVAS, CANVAS, &pipelines);
let pre_stroke_paint_target = darkly::gpu::paint_target::GpuPaintTarget::from_canvas_texture(
&layer_texture,
&layer_view,
wgpu::TextureFormat::Rgba8Unorm,
darkly::coord::CanvasRect::from_xywh(0, 0, CANVAS, CANVAS),
);
let mut enc = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
label: Some("rough-ink-builtin-pre-stroke"),
});
stroke_buffer.save_pre_stroke(&device, &mut enc, &pipelines, &pre_stroke_paint_target);
queue.submit([enc.finish()]);
let mut graph = rough_ink.metadata.graph.clone();
let term_id = darkly::brush::find_terminal(&graph).unwrap();
graph.set_port_default(term_id, "size", 0.15).unwrap();
let runner = compile_graph(&graph).expect("Rough Ink compiles");
let compiled = runner.compiled_brush().expect("compiled brush attached");
assert!(
(compiled.brush_extent_factor - 1.5).abs() < 1e-4,
"expected rough-ink extent factor ≈ 1.5, got {}",
compiled.brush_extent_factor,
);
let mut h = Harness {
device,
queue,
layer_texture,
layer_view,
pipelines,
stroke_buffer,
runner,
};
h.begin_stroke();
let info = PaintInformation {
pos: [64.0, 64.0],
pressure: 1.0,
..Default::default()
};
h.dab_and_flush(&info, [1.0, 0.5, 0.0, 1.0], 0);
let rgba = h.readback_canvas();
let mut deposited = 0;
let mut max_dist_sq: f32 = 0.0;
for y in 0..CANVAS {
for x in 0..CANVAS {
let p = center_pixel(&rgba, x, y);
if p[0] > 0 || p[1] > 0 || p[2] > 0 {
deposited += 1;
let dx = x as f32 - 64.0;
let dy = y as f32 - 64.0;
max_dist_sq = max_dist_sq.max(dx * dx + dy * dy);
}
}
}
assert!(
deposited > 50,
"expected ≥50 non-black pixels inside dab footprint, found {deposited} \
(shader compile silently failed or dab missed the layer)"
);
let effective_radius = 0.15 * darkly::brush::DAB_REFERENCE_SIZE as f32 * 0.5;
let bbox_radius =
effective_radius * compiled.brush_extent_factor + compiled.brush_extent_extra_px;
let max_dist = max_dist_sq.sqrt();
assert!(
max_dist <= bbox_radius + 1.0,
"rendered pixel at distance {max_dist} exceeds declared bbox \
{bbox_radius} (effective_radius {effective_radius}, factor \
{})",
compiled.brush_extent_factor,
);
assert!(
max_dist >= effective_radius * 0.5,
"rendered footprint suspiciously small (max_dist {max_dist}, \
effective_radius {effective_radius}) — shader may be \
clipping inside the declared bbox",
);
}
#[test]
fn rough_ink_overlapping_dabs_render_without_truncation() {
let rough_ink = darkly::brush::builtin_brushes::all()
.into_iter()
.find(|b| b.metadata.name == "Rough Ink")
.expect("Rough Ink registered");
let mut graph = rough_ink.metadata.graph.clone();
let term_id = darkly::brush::find_terminal(&graph).unwrap();
graph.set_port_default(term_id, "size", 0.15).unwrap();
let curve_id = graph
.nodes()
.iter()
.find(|(_, n)| n.type_id == darkly::brush::nodes::curve::TYPE_ID)
.map(|(id, _)| *id)
.unwrap();
graph
.set_param(curve_id, 0, ParamValue::Curve(vec![[0.0, 0.0], [1.0, 1.0]]))
.unwrap();
let mut h = harness(&black_canvas(), graph);
let compiled = h.runner.compiled_brush().expect("compiled brush attached");
h.begin_stroke();
let a = PaintInformation {
pos: [44.0, 64.0],
pressure: 0.5,
..Default::default()
};
let b = PaintInformation {
pos: [84.0, 64.0],
pressure: 1.0,
..Default::default()
};
h.two_dabs_same_phase(&a, &b, [1.0, 0.0, 0.0, 1.0]);
let rgba = h.readback_canvas();
let bbox_factor = compiled.brush_extent_factor;
let mut dab_a_pixels = 0;
let mut dab_b_pixels = 0;
let dab_size = 0.15 * darkly::brush::DAB_REFERENCE_SIZE as f32 * 0.5;
let r_a = (dab_size * 0.5 * bbox_factor) + 1.0;
let r_b = (dab_size * 1.0 * bbox_factor) + 1.0;
for y in 0..CANVAS {
for x in 0..CANVAS {
let p = center_pixel(&rgba, x, y);
if p[0] == 0 && p[1] == 0 && p[2] == 0 {
continue;
}
let dxa = x as f32 - a.pos[0];
let dya = y as f32 - a.pos[1];
let dxb = x as f32 - b.pos[0];
let dyb = y as f32 - b.pos[1];
let da = (dxa * dxa + dya * dya).sqrt();
let db = (dxb * dxb + dyb * dyb).sqrt();
assert!(
da <= r_a || db <= r_b,
"pixel at ({x}, {y}) deposited outside both declared \
bboxes (da={da}, r_a={r_a}; db={db}, r_b={r_b})",
);
if da <= r_a {
dab_a_pixels += 1;
}
if db <= r_b {
dab_b_pixels += 1;
}
}
}
assert!(
dab_a_pixels > 20 && dab_b_pixels > 20,
"both dabs must render — got A={dab_a_pixels}, B={dab_b_pixels}",
);
}
#[test]
fn terminal_flow_scales_dab_alpha() {
fn deposit_red_at_center(flow: f32) -> [u8; 4] {
let mut graph = build_test_graph(
0, 0.0, 0.2,
);
let term_id = darkly::brush::find_terminal(&graph).unwrap();
graph.set_port_default(term_id, "flow", flow).unwrap();
let mut h = harness(&black_canvas(), graph);
h.begin_stroke();
let info = PaintInformation {
pos: [64.0, 64.0],
pressure: 1.0,
..Default::default()
};
h.dab_and_flush(&info, [1.0, 0.0, 0.0, 1.0], 0);
let rgba = h.readback_canvas();
center_pixel(&rgba, 64, 64)
}
let full = deposit_red_at_center(1.0);
let third = deposit_red_at_center(0.3);
assert!(
full[0] > 200,
"flow=1.0 should deposit ~full red, got {full:?}",
);
assert!(
third[0] > 40 && third[0] < 120,
"flow=0.3 should deposit ~30% red over black, got {third:?}",
);
assert!(
full[0] > third[0] + 80,
"flow=1.0 ({}) must deposit more red than flow=0.3 ({})",
full[0],
third[0],
);
}
#[test]
fn perlin_amplitude_zero_collapses_to_disc() {
let graph = build_test_graph(
1, 0.0, 0.2,
);
let mut h = harness(&black_canvas(), graph);
h.begin_stroke();
let info = PaintInformation {
pos: [64.0, 64.0],
pressure: 1.0,
..Default::default()
};
h.dab_and_flush(&info, [0.0, 0.0, 1.0, 1.0], 0);
let rgba = h.readback_canvas();
let center = center_pixel(&rgba, 64, 64);
assert!(
center[2] > 200,
"center should be ~blue with amplitude=0, got {center:?}"
);
let outside = center_pixel(&rgba, 64, 0);
assert_eq!(
outside,
[0, 0, 0, 255],
"outside the disc should be unchanged, got {outside:?}"
);
}