1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
//! Regression test for the brush preview unit-mismatch bug.
//!
//! The bug: when the preview-mask texture is smaller than the brush's
//! canvas-px bbox (the production `MAX_PREVIEW_MASK_SIDE = 512` clamp,
//! or any test-allocated mask sized below the brush's natural bbox),
//! the fragment shader's discard test compared a target-pixel `local`
//! against a canvas-pixel `bbox_radius` from the dab record. The
//! discard never fired and the dab filled the texture to its square
//! edge — visible as "square-clipped" cursor previews at large brush
//! sizes.
//!
//! The fix: the intrinsic dab header is packed in the target's pixel
//! space (canvas px for stroke, mask texels for preview). The bug
//! becomes structurally inexpressible. This test exercises the
//! preview path with a deliberately undersized mask to confirm the
//! discard now fires correctly.
//!
//! **Pre-fix**: assertion 1 (corners transparent) FAILS — corners
//! come back nearly opaque because the discard never fires.
use std::sync::Arc;
use darkly::brush::compile_graph;
use darkly::brush::eval::BrushGraphRunner;
use darkly::brush::gpu_context::{
BrushGpuContext, BrushPerfCounters, CursorPreviewState, DabBatch,
};
use darkly::brush::paint_info::PaintInformation;
use darkly::brush::pipeline::BrushPipelines;
use darkly::gpu::test_utils::{readback_texture, test_device};
/// Deliberately undersized vs the brush's natural bbox — simulates the
/// production `MAX_PREVIEW_MASK_SIDE` clamp without needing a real
/// `ToolOverlay`. A 128² mask + a brush with canvas-px bbox ~512 puts
/// the dab well over the texture's inscribed disc (`texture_half = 64`).
const PREVIEW_SIDE: u32 = 128;
fn preview_target(device: &wgpu::Device) -> (wgpu::Texture, wgpu::TextureView) {
let tex = device.create_texture(&wgpu::TextureDescriptor {
label: Some("preview-unit-invariance-mask"),
size: wgpu::Extent3d {
width: PREVIEW_SIDE,
height: PREVIEW_SIDE,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba8Unorm,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT
| wgpu::TextureUsages::TEXTURE_BINDING
| wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
});
let view = tex.create_view(&wgpu::TextureViewDescriptor::default());
(tex, view)
}
struct Out {
rgba: Vec<u8>,
half_extent_canvas_px: [f32; 2],
}
/// Render `Round` at a brush size whose canvas-px bbox exceeds the
/// 128² test mask's inscribed half-side (64). The exact size value
/// is pinned so future default-tuning of `Round` doesn't drift the
/// assertions.
fn render_big_round() -> Out {
let brush = darkly::brush::builtin_brushes::all()
.into_iter()
.find(|b| b.metadata.name == "Round")
.expect("Round brush is registered");
let mut graph = brush.metadata.graph.clone();
let term_id = darkly::brush::find_terminal(&graph).expect("Round has a terminal");
// size 2.0 → effective_radius = 2.0 * 512 * 0.5 = 512 canvas px
// (extent factor ≥ 1, so bbox_canvas_px is at least 512, far above
// texture_half = 64).
graph.set_port_default(term_id, "size", 2.0).unwrap();
let (device, queue) = test_device();
let device = Arc::new(device);
let queue = Arc::new(queue);
let pipelines = BrushPipelines::new(
&device,
&queue,
&darkly::gpu::selection::selection_mask_bgl(&device),
);
let (target_tex, target_view) = preview_target(&device);
let mut runner: BrushGraphRunner = compile_graph(&graph).expect("brush compiles");
let encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
label: Some("preview-unit-invariance"),
});
let mut ctx = BrushGpuContext {
encoder,
device: &device,
queue: &queue,
pipelines: &pipelines,
selection_bind_group: pipelines.default_selection_bind_group(),
canvas_width: PREVIEW_SIDE,
canvas_height: PREVIEW_SIDE,
canvas_origin: [0, 0],
blend_mode: 0,
view_rotation: 0.0,
perf: BrushPerfCounters::default(),
stroke: None,
// Drive the test-fallback path on `ensure_cursor_preview_mask` — the
// production clamp path needs a real `ToolOverlay`, which is
// heavier to construct than this test needs. The undersized
// mask reproduces the exact same target-vs-canvas unit
// mismatch the production clamp triggers.
preview: Some(CursorPreviewState {
mask_view: Some(&target_view),
mask_size: (PREVIEW_SIDE, PREVIEW_SIDE),
mask_overlay: None,
info: None,
}),
dab_batch: DabBatch::default(),
};
let info = PaintInformation {
pos: [PREVIEW_SIDE as f32 * 0.5, PREVIEW_SIDE as f32 * 0.5],
pressure: 1.0,
..Default::default()
};
runner.seed_sensors(&info, [1.0, 1.0, 1.0, 1.0], 0xABCDEF, 0);
runner.execute_cpu();
runner.render_cursor_preview_pipeline(&mut ctx);
let published = ctx
.preview
.as_ref()
.and_then(|p| p.info)
.expect("render_compiled_preview publishes BrushCursorPreviewInfo");
queue.submit([ctx.encoder.finish()]);
let rgba = readback_texture(
&device,
&queue,
&target_tex,
wgpu::TextureFormat::Rgba8Unorm,
PREVIEW_SIDE,
PREVIEW_SIDE,
);
Out {
rgba,
half_extent_canvas_px: published.half_extent_canvas_px,
}
}
fn px(rgba: &[u8], x: u32, y: u32) -> [u8; 4] {
let i = ((y * PREVIEW_SIDE + x) * 4) as usize;
[rgba[i], rgba[i + 1], rgba[i + 2], rgba[i + 3]]
}
#[test]
fn large_brush_does_not_fill_preview_mask_with_square() {
let out = render_big_round();
// The overlay consumer-facing bbox stays in canvas px. With size
// 2.0 → radius 512 and extent factor ≥ 1, the published bbox
// half-extent is well above the 128 mask's texture_half (64).
// This is what tells the overlay to display a quad bigger than
// the mask, which gets bilinearly upsampled.
assert!(
out.half_extent_canvas_px[0] > 256.0,
"expected canvas-px bbox >> texture half; got {:?}",
out.half_extent_canvas_px
);
// ── Headline regression: the four corners must be transparent.
//
// The corners sit at distance ~85 from the centre (64, 64) — well
// outside the dab's `bbox_target_px = 64`, so the fragment discard
// fires and writes nothing.
//
// Pre-fix: `bbox_target_px` was packed as the canvas-px value
// (≈512), the discard never fired within the 128² texture, and the
// brush body painted opaque white at every corner. Alpha → ~255.
for (cx, cy) in [(0, 0), (127, 0), (0, 127), (127, 127)] {
let p = px(&out.rgba, cx, cy);
assert_eq!(
p[3], 0,
"corner ({cx}, {cy}) must be transparent — discard fires past `bbox_target_px`; got {p:?}",
);
}
// ── Sanity: the dab actually rendered something at the centre.
// Without this, a regression that turned every fragment into a
// no-op would satisfy the corner check above.
let centre = px(&out.rgba, PREVIEW_SIDE / 2, PREVIEW_SIDE / 2);
assert!(
centre[3] > 0,
"centre must be inside the dab and have non-zero alpha; got {centre:?}",
);
// ── Radial symmetry. Round has no angular dependence; four
// symmetric points well inside the bbox should agree closely.
// Pick offset 32 from centre (half the bbox half-extent) — deep
// in the falloff, so any rotational asymmetry would read clearly
// but we're not sitting on the discard boundary.
let half = PREVIEW_SIDE / 2;
let r: u32 = 32;
let north = px(&out.rgba, half, half - r);
let south = px(&out.rgba, half, half + r);
let east = px(&out.rgba, half + r, half);
let west = px(&out.rgba, half - r, half);
let max_dev = [north[3], south[3], east[3], west[3]]
.iter()
.map(|&a| (a as i32 - north[3] as i32).abs())
.max()
.unwrap();
assert!(
max_dev <= 3,
"axial samples at radius {r} should be ~equal (Round is rotationally invariant); \
N={north:?} S={south:?} E={east:?} W={west:?}",
);
}