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
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
use crate::coord::{CanvasPoint, CanvasRect, LayerPoint, LayerRect};
/// GPU-side texture storage for a single raster layer.
/// One Rgba8Unorm texture per layer, sized to the layer's pixel bounds —
/// which default to canvas dimensions but may be larger when content
/// extends past the canvas (e.g. paste of an oversized image).
///
/// ## Coordinate-space discipline
///
/// All coordinate-bearing fields are private. Callers go through the typed
/// accessors ([`canvas_extent`], [`layer_extent`], [`canvas_to_layer*`],
/// [`layer_to_canvas*`]) so the canvas/layer-local distinction lives in the
/// type system rather than in convention. See module docs of
/// [`crate::coord`] and the project's CLAUDE.md for the rule: every
/// coordinate at every interface names its space; only the texture itself
/// translates between them.
///
/// [`canvas_extent`]: LayerTexture::canvas_extent
/// [`layer_extent`]: LayerTexture::layer_extent
/// [`canvas_to_layer*`]: LayerTexture::canvas_to_layer
/// [`layer_to_canvas*`]: LayerTexture::layer_to_canvas
pub struct LayerTexture {
texture: wgpu::Texture,
view: wgpu::TextureView,
/// Texture dimensions in pixels. Mirrors the size used at allocation
/// so callers don't need to reach into the wgpu::Texture descriptor.
/// Exposed via [`layer_extent`](Self::layer_extent).
width: u32,
height: u32,
/// Canvas-space offset of the texture's (0,0) pixel. Default (0,0) for
/// canvas-aligned layers; non-zero for layers whose bounds extend past
/// or are placed inside canvas (e.g. paste of an oversized image).
/// Exposed via [`canvas_extent`](Self::canvas_extent).
offset_x: i32,
offset_y: i32,
/// Texture format. Exposed via [`format`](Self::format) so format-driven
/// dispatch (R8 vs RGBA paint pipelines, transform pipelines) doesn't
/// have to reach into `texture.format()`; matches what the document-side
/// `PixelBuffer` records.
format: wgpu::TextureFormat,
}
impl LayerTexture {
/// RGBA layer texture — default fill is 0 (transparent), which is the GPU's init value.
pub fn new(device: &wgpu::Device, width: u32, height: u32) -> Self {
Self::with_format(
device,
None,
width,
height,
wgpu::TextureFormat::Rgba8Unorm,
"layer-texture",
)
}
/// RGBA layer texture sized + positioned to match the given bounds.
/// Equivalent to `new` followed by setting `offset_x`/`offset_y`.
pub fn with_bounds(device: &wgpu::Device, bounds: CanvasRect) -> Self {
let mut t = Self::new(device, bounds.width, bounds.height);
t.offset_x = bounds.origin.x;
t.offset_y = bounds.origin.y;
t
}
/// R8Unorm mask texture — default fill is 255 (white = reveal all).
pub fn new_mask(device: &wgpu::Device, queue: &wgpu::Queue, width: u32, height: u32) -> Self {
Self::with_format(
device,
Some(queue),
width,
height,
wgpu::TextureFormat::R8Unorm,
"mask-texture",
)
}
/// R8Unorm mask texture sized + positioned to match the given canvas
/// extent. The mask shares the parent layer's bounds so per-pixel
/// sampling can use the same layer UV as the layer texture.
pub fn new_mask_with_extent(
device: &wgpu::Device,
queue: &wgpu::Queue,
extent: CanvasRect,
) -> Self {
let mut t = Self::new_mask(device, queue, extent.width, extent.height);
t.offset_x = extent.origin.x;
t.offset_y = extent.origin.y;
t
}
fn with_format(
device: &wgpu::Device,
queue: Option<&wgpu::Queue>,
width: u32,
height: u32,
format: wgpu::TextureFormat,
label: &str,
) -> Self {
let bpp = format.block_copy_size(None).unwrap_or(1);
let fill_byte = match format {
wgpu::TextureFormat::R8Unorm => 255u8, // white = reveal all
_ => 0u8, // transparent
};
let texture = device.create_texture(&wgpu::TextureDescriptor {
label: Some(label),
size: wgpu::Extent3d {
width,
height,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format,
usage: wgpu::TextureUsages::TEXTURE_BINDING
| wgpu::TextureUsages::COPY_SRC
| wgpu::TextureUsages::COPY_DST
| wgpu::TextureUsages::RENDER_ATTACHMENT,
view_formats: &[],
});
// Fill with non-zero default if needed (GPU textures init to 0).
if fill_byte != 0 {
if let Some(queue) = queue {
let row_bytes = width * bpp;
let data = vec![fill_byte; (row_bytes * height) as usize];
queue.write_texture(
wgpu::TexelCopyTextureInfo {
texture: &texture,
mip_level: 0,
origin: wgpu::Origin3d::ZERO,
aspect: wgpu::TextureAspect::All,
},
&data,
wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(row_bytes),
rows_per_image: Some(height),
},
wgpu::Extent3d {
width,
height,
depth_or_array_layers: 1,
},
);
}
}
let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
LayerTexture {
texture,
view,
width,
height,
offset_x: 0,
offset_y: 0,
format,
}
}
// ----- Typed accessors -----
/// Borrow the underlying GPU texture. Use this when handing the texture
/// to a wgpu API; do not reach for coordinate information through it.
pub fn texture(&self) -> &wgpu::Texture {
&self.texture
}
/// Borrow the texture's default view.
pub fn view(&self) -> &wgpu::TextureView {
&self.view
}
pub fn format(&self) -> wgpu::TextureFormat {
self.format
}
/// Texture-local extent — always at origin `(0, 0)` with the texture's
/// pixel dimensions. Use this when iterating over the texture in its own
/// coordinate frame or when handing dimensions to wgpu (which speaks in
/// texture pixels, not canvas pixels).
pub fn layer_extent(&self) -> LayerRect {
LayerRect::from_xywh(0, 0, self.width, self.height)
}
/// Canvas-space rect this texture occupies. The origin may be negative
/// for paste-extent layers or layers grown leftward / upward of canvas.
pub fn canvas_extent(&self) -> CanvasRect {
CanvasRect::from_xywh(self.offset_x, self.offset_y, self.width, self.height)
}
/// Translate a canvas-space point to the texture's local coordinate frame.
/// Returns `None` if the point falls outside the texture's extent.
pub fn canvas_to_layer(&self, p: CanvasPoint) -> Option<LayerPoint> {
let lx = p.x - self.offset_x;
let ly = p.y - self.offset_y;
if lx >= 0 && ly >= 0 && (lx as u32) < self.width && (ly as u32) < self.height {
Some(LayerPoint::new(lx as u32, ly as u32))
} else {
None
}
}
/// Intersect a canvas-space rect with the texture's extent and translate
/// the result into texture-local coordinates. Returns `None` if disjoint.
pub fn canvas_to_layer_rect(&self, r: CanvasRect) -> Option<LayerRect> {
let clipped = self.canvas_extent().intersect(r)?;
let lx = (clipped.origin.x - self.offset_x) as u32;
let ly = (clipped.origin.y - self.offset_y) as u32;
Some(LayerRect::from_xywh(lx, ly, clipped.width, clipped.height))
}
/// Translate a texture-local point back to canvas space.
pub fn layer_to_canvas(&self, p: LayerPoint) -> CanvasPoint {
CanvasPoint::new(self.offset_x + p.x as i32, self.offset_y + p.y as i32)
}
/// Translate a texture-local rect back to canvas space.
pub fn layer_to_canvas_rect(&self, r: LayerRect) -> CanvasRect {
CanvasRect::from_xywh(
self.offset_x + r.origin.x as i32,
self.offset_y + r.origin.y as i32,
r.width,
r.height,
)
}
/// Intersect a canvas-space rect with this texture's extent. Returns
/// `None` if disjoint or empty.
pub fn clamp_canvas_rect(&self, r: CanvasRect) -> Option<CanvasRect> {
self.canvas_extent().intersect(r)
}
/// Borrow this texture as a `CanvasFrame` — a thin (texture, canvas
/// extent) value passed to the GPU adapter boundary helpers.
pub fn canvas_frame(&self) -> CanvasFrame<'_> {
CanvasFrame {
texture: &self.texture,
canvas_extent: self.canvas_extent(),
}
}
}
/// A texture paired with the canvas-space rect it occupies. Used as the
/// argument type at the GPU adapter boundary so callers don't have to know
/// whether the underlying texture is a `LayerTexture` (layer-aligned) or a
/// canvas-aligned texture like the selection mask. The frame owns nothing;
/// borrows are elided at call sites.
#[derive(Copy, Clone)]
pub struct CanvasFrame<'a> {
pub texture: &'a wgpu::Texture,
pub canvas_extent: CanvasRect,
}
impl<'a> CanvasFrame<'a> {
/// Intersect a canvas-space rect with this frame's extent and translate
/// the result into texture-local coordinates. Returns `None` if disjoint.
pub fn canvas_to_layer_rect(&self, r: CanvasRect) -> Option<LayerRect> {
let clipped = self.canvas_extent.intersect(r)?;
let lx = (clipped.origin.x - self.canvas_extent.origin.x) as u32;
let ly = (clipped.origin.y - self.canvas_extent.origin.y) as u32;
Some(LayerRect::from_xywh(lx, ly, clipped.width, clipped.height))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::gpu::test_utils::test_device;
fn make_layer(off_x: i32, off_y: i32, w: u32, h: u32) -> LayerTexture {
let (device, _queue) = test_device();
LayerTexture::with_bounds(&device, CanvasRect::from_xywh(off_x, off_y, w, h))
}
#[test]
fn canvas_extent_reflects_offset_and_size() {
let l = make_layer(-100, 50, 200, 300);
assert_eq!(l.canvas_extent(), CanvasRect::from_xywh(-100, 50, 200, 300));
}
#[test]
fn canvas_to_layer_round_trip() {
let l = make_layer(-100, 50, 200, 300);
let p = CanvasPoint::new(-50, 100);
let lp = l.canvas_to_layer(p).unwrap();
assert_eq!(lp, LayerPoint::new(50, 50));
assert_eq!(l.layer_to_canvas(lp), p);
}
#[test]
fn canvas_to_layer_outside_returns_none() {
let l = make_layer(0, 0, 100, 100);
assert_eq!(l.canvas_to_layer(CanvasPoint::new(-1, 50)), None);
assert_eq!(l.canvas_to_layer(CanvasPoint::new(100, 50)), None);
assert_eq!(l.canvas_to_layer(CanvasPoint::new(50, 100)), None);
}
#[test]
fn canvas_to_layer_rect_clips_to_extent() {
let l = make_layer(0, 0, 100, 100);
let r = CanvasRect::from_xywh(50, 50, 200, 200);
let lr = l.canvas_to_layer_rect(r).unwrap();
assert_eq!(lr, LayerRect::from_xywh(50, 50, 50, 50));
}
#[test]
fn canvas_to_layer_rect_disjoint_is_none() {
let l = make_layer(0, 0, 100, 100);
assert_eq!(
l.canvas_to_layer_rect(CanvasRect::from_xywh(200, 200, 50, 50)),
None,
);
}
#[test]
fn clamp_canvas_rect_inside_is_unchanged() {
let l = make_layer(-50, -50, 200, 200);
let r = CanvasRect::from_xywh(0, 0, 100, 100);
assert_eq!(l.clamp_canvas_rect(r), Some(r));
}
#[test]
fn layer_to_canvas_rect_offsets_origin() {
let l = make_layer(-100, 50, 200, 300);
let lr = LayerRect::from_xywh(10, 20, 30, 40);
assert_eq!(
l.layer_to_canvas_rect(lr),
CanvasRect::from_xywh(-90, 70, 30, 40),
);
}
/// A dab footprint that crosses the edges of an offset (paste-extent) layer
/// clamps to the layer's canvas extent, then translates into texture-local
/// pixels. The layer spans canvas x[-100,100] y[50,350]; the dab x[60,120]
/// y[30,90] pokes past the right and top edges.
#[test]
fn clamp_f32_then_layer_rect_off_extent_dab() {
let l = make_layer(-100, 50, 200, 300);
let extent = l.canvas_extent();
let canvas_bbox = extent.clamp_f32(60.0, 30.0, 120.0, 90.0).unwrap();
// Clipped to the extent's right (100) and top (50) edges.
assert_eq!(canvas_bbox, CanvasRect::from_xywh(60, 50, 40, 40));
// Translated into the texture's local frame (subtract the -100/50 offset).
let local = l.canvas_to_layer_rect(canvas_bbox).unwrap();
assert_eq!(local, LayerRect::from_xywh(160, 0, 40, 40));
}
}