diff --git a/crates/darkly/presets/gimp.yaml b/crates/darkly/presets/gimp.yaml index eb98b427..9d427201 100644 --- a/crates/darkly/presets/gimp.yaml +++ b/crates/darkly/presets/gimp.yaml @@ -81,12 +81,14 @@ mouse_clicks: # isolateLayer mouse_click: GIMP has no thumbnail-modifier equivalent, # so intentionally unbound here. The hotkey form (KeyI above) is the # GIMP-native path. - # maskToSelection mouse_click: GIMP's native gesture is Alt+click - # (gimpitemtreeview.c → gimp_modifiers_to_channel_op), but Darkly's - # alt+click thumbnail slot is isolate's. GIMP itself ships no keyboard - # accelerator for Mask→Selection (layers-actions.c: NULL accel), so this - # is intentionally unbound here — reachable via the mask context menu and - # the command palette, faithful to GIMP's menu-only surface. + # maskToSelection / alphaToSelection mouse_clicks: GIMP's native gesture for + # both is Alt+click on the item thumbnail (gimpitemtreeview.c → + # gimp_modifiers_to_channel_op), but Darkly's alt+click thumbnail slot is + # isolate's. GIMP itself ships no keyboard accelerator for either + # (layers-actions.c: NULL accel on layers-mask-selection-* and + # layers-alpha-selection-*), so both are intentionally unbound here — + # reachable via the layer/mask context menus and the command palette, + # faithful to GIMP's menu-only surface. settings: tools.colorPickerSampleSource: merged diff --git a/crates/darkly/presets/krita.yaml b/crates/darkly/presets/krita.yaml index 2cbc8db1..cc937553 100644 --- a/crates/darkly/presets/krita.yaml +++ b/crates/darkly/presets/krita.yaml @@ -88,7 +88,13 @@ mouse_clicks: # Krita loads a mask/alpha as a selection with Ctrl+click on the thumbnail # (NodeDelegate.cpp → SelectOpaqueRole). $mod maps to Ctrl on Linux/Win, # Cmd on macOS. alt+click is taken by isolate above. + # + # Krita routes both thumbnails through the one Select Opaque path + # (kis_selection_manager.cc → selectOpaqueOnNode reads the node's + # projection opacity). Darkly splits them by which op is cheaper: a mask + # is an R8 clone, a layer needs its alpha read back. maskToSelection: maskThumb:$mod+click + alphaToSelection: layerThumb:$mod+click settings: tools.colorPickerSampleSource: merged diff --git a/crates/darkly/presets/photoshop.yaml b/crates/darkly/presets/photoshop.yaml index 9f166eb0..32eeaa1e 100644 --- a/crates/darkly/presets/photoshop.yaml +++ b/crates/darkly/presets/photoshop.yaml @@ -74,9 +74,11 @@ mouse_clicks: - layerThumb:alt+click - maskThumb:alt+click # Photoshop loads a mask as a selection with Ctrl/Cmd+click on the mask + # thumbnail, and the layer's transparency with the same chord on the layer # thumbnail. $mod maps to Ctrl on Linux/Win, Cmd on macOS. alt+click is # taken by isolate above. maskToSelection: maskThumb:$mod+click + alphaToSelection: layerThumb:$mod+click settings: # Photoshop's eyedropper samples the current layer by default. diff --git a/crates/darkly/shaders/brush/_prelude.wgsl b/crates/darkly/shaders/brush/_prelude.wgsl index b61b8d5d..342b31ee 100644 --- a/crates/darkly/shaders/brush/_prelude.wgsl +++ b/crates/darkly/shaders/brush/_prelude.wgsl @@ -50,7 +50,13 @@ struct IntrinsicUniforms { // uniforms that follow `intrinsic` in the generated `Uniforms` struct keep // their 16-byte alignment. Adding `canvas_origin` above pushed the size to // 56; without this pad the node params would misalign and read garbage. - _pad0: u32, + // How many dabs land on a given texel as the brush passes over it + // once: `diameter / spacing`. Stroke-constant, published by the stroke + // engine, which owns the spacing that produced it. A terminal + // accumulating a per-dab quantity divides by this to express its rate + // per *pass* rather than per dab — otherwise the knob's meaning moves + // with the spacing setting and with pressure. 1.0 when unset. + dabs_per_pass: f32, _pad1: u32, _pad2: u32, }; diff --git a/crates/darkly/src/actions/layers.rs b/crates/darkly/src/actions/layers.rs index 3d5f5135..b9b2a5c4 100644 --- a/crates/darkly/src/actions/layers.rs +++ b/crates/darkly/src/actions/layers.rs @@ -7,6 +7,24 @@ const ACTIONS: &[ActionDef] = &[ description: "Add a new layer above the active one.", icon: "fa6-solid:square-plus", }, + ActionDef { + id: "newFilterLayer", + display_name: "New Filter Layer", + description: "Add a non-destructive filter layer (curves, levels, invert, …) above the active one.", + icon: "fa6-solid:circle-half-stroke", + }, + ActionDef { + id: "newVeil", + display_name: "New Veil", + description: "Add a veil — a post-process effect (rainy glass, VHS, grain, …) over the whole canvas.", + icon: "material-symbols:curtains-rounded", + }, + ActionDef { + id: "newVoid", + display_name: "New Void", + description: "Add a void — a layer filled from a procedural or live source (noise, camera, screen share, …).", + icon: "tabler:galaxy", + }, ActionDef { id: "newGroup", display_name: "New Group", diff --git a/crates/darkly/src/actions/selection.rs b/crates/darkly/src/actions/selection.rs index 60507596..d8f56f70 100644 --- a/crates/darkly/src/actions/selection.rs +++ b/crates/darkly/src/actions/selection.rs @@ -25,6 +25,12 @@ const ACTIONS: &[ActionDef] = &[ description: "Load the active layer's mask as the selection.", icon: "radix-icons:mask-off", }, + ActionDef { + id: "alphaToSelection", + display_name: "Alpha to Selection", + description: "Load the layer's opacity as the selection.", + icon: "fa6-solid:clone", + }, ActionDef { id: "clearSelectionContents", display_name: "Clear Selection Contents", diff --git a/crates/darkly/src/brush/checkpoint_ring.rs b/crates/darkly/src/brush/checkpoint_ring.rs index d4132d39..7f454b32 100644 --- a/crates/darkly/src/brush/checkpoint_ring.rs +++ b/crates/darkly/src/brush/checkpoint_ring.rs @@ -32,10 +32,19 @@ struct CheckpointSlot { tex_w: u32, tex_h: u32, /// Format the slot was allocated in. The ring snapshots the stroke - /// scratch, whose format is the terminal's business — colour for most + /// scratch, whose format is the terminal's business — color for most /// brushes, a float displacement field for warp terminals — and /// `copy_texture_to_texture` requires the two to match. tex_format: wgpu::TextureFormat, + /// Snapshots of the stroke's channels, parallel to + /// `Scratch::channel_textures`. Empty for terminals that declare none. + /// + /// Captured and restored with the stroke buffer because a channel is + /// what dabs *read* to decide what to deposit. Rewinding the pixels + /// but not the channel would leave it holding contributions from + /// discarded dabs, and the dabs replayed over those pixels would read + /// values describing a stroke that no longer exists. + extra: Vec, /// The bbox region this checkpoint covers, in canvas pixel coords. /// Stable across mid-stroke layer growth. canvas_bbox: CanvasRect, @@ -68,40 +77,64 @@ impl CheckpointSlot { dab_count: 0, }, valid: false, + extra: Vec::new(), } } - /// Ensure the texture is at least `w × h` and in `format`. - /// Reallocate if needed — including on a format change, since a - /// slot cached from a colour stroke cannot receive a warp field. + /// Ensure the stroke-buffer snapshot is at least `w × h` and in + /// `format`, plus one channel snapshot per entry in `extra_formats`. + /// Reallocate if needed — including on a format change, since a slot + /// cached from a color stroke cannot receive a warp field. The whole + /// set is reallocated together so a slot's snapshots always share + /// dimensions. fn ensure_texture( &mut self, device: &wgpu::Device, w: u32, h: u32, format: wgpu::TextureFormat, + extra_formats: &[wgpu::TextureFormat], ) { - if self.tex_w >= w && self.tex_h >= h && self.tex_format == format && self.texture.is_some() + // Slots outlive strokes — `clear()` only flips `valid` — so a slot + // allocated for one terminal is reused by the next. Comparing the + // formats, not just the count, is what stops a `paint` stroke's + // empty slot (or a differently-typed channel set) being reused as + // though it held this terminal's snapshots. + let formats_match = self.extra.len() == extra_formats.len() + && self + .extra + .iter() + .zip(extra_formats) + .all(|(t, f)| t.format() == *f); + if self.tex_w >= w + && self.tex_h >= h + && self.tex_format == format + && self.texture.is_some() + && formats_match { return; } // Allocate with some headroom to reduce reallocation frequency. let alloc_w = w.next_power_of_two().max(64); let alloc_h = h.next_power_of_two().max(64); - self.texture = Some(device.create_texture(&wgpu::TextureDescriptor { - label: Some("checkpoint-slot"), - size: wgpu::Extent3d { - width: alloc_w, - height: alloc_h, - depth_or_array_layers: 1, - }, - mip_level_count: 1, - sample_count: 1, - dimension: wgpu::TextureDimension::D2, - format, - usage: wgpu::TextureUsages::COPY_SRC | wgpu::TextureUsages::COPY_DST, - view_formats: &[], - })); + let make = |format: wgpu::TextureFormat| { + device.create_texture(&wgpu::TextureDescriptor { + label: Some("checkpoint-slot"), + size: wgpu::Extent3d { + width: alloc_w, + height: alloc_h, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format, + usage: wgpu::TextureUsages::COPY_SRC | wgpu::TextureUsages::COPY_DST, + view_formats: &[], + }) + }; + self.texture = Some(make(format)); + self.extra = extra_formats.iter().copied().map(make).collect(); self.tex_w = alloc_w; self.tex_h = alloc_h; self.tex_format = format; @@ -256,6 +289,7 @@ impl CheckpointRing { device: &wgpu::Device, encoder: &mut wgpu::CommandEncoder, stroke: &CanvasFrame<'_>, + extra: &[&wgpu::Texture], save_point_index: usize, vector_index: usize, canvas_bbox: CanvasRect, @@ -274,6 +308,7 @@ impl CheckpointRing { None => return, }; + let extra_formats: Vec = extra.iter().map(|t| t.format()).collect(); let slot_idx = self.pick_slot(tip_vi, max_div_window, vector_index); let slot = &mut self.slots[slot_idx]; slot.ensure_texture( @@ -281,6 +316,7 @@ impl CheckpointRing { layer_rect.width, layer_rect.height, stroke.texture.format(), + &extra_formats, ); slot.canvas_bbox = clipped_canvas; slot.save_point_index = save_point_index; @@ -313,6 +349,35 @@ impl CheckpointRing { }, ); + // Same region, same coordinates — the accumulators are layer-sized + // and grown in lockstep with the stroke buffer, so one rect + // addresses all of them. + for (src, dst) in extra.iter().zip(slot.extra.iter()) { + encoder.copy_texture_to_texture( + wgpu::TexelCopyTextureInfo { + texture: src, + mip_level: 0, + origin: wgpu::Origin3d { + x: layer_rect.x0(), + y: layer_rect.y0(), + z: 0, + }, + aspect: wgpu::TextureAspect::All, + }, + wgpu::TexelCopyTextureInfo { + texture: dst, + mip_level: 0, + origin: wgpu::Origin3d::ZERO, + aspect: wgpu::TextureAspect::All, + }, + wgpu::Extent3d { + width: layer_rect.width, + height: layer_rect.height, + depth_or_array_layers: 1, + }, + ); + } + // Coverage invariant: after every save, at least one valid slot // must sit at or below the divergence boundary. If this fires, the // eviction policy lost the anchor or the stabilizer's bound was @@ -397,6 +462,7 @@ impl CheckpointRing { &self, encoder: &mut wgpu::CommandEncoder, stroke: &CanvasFrame<'_>, + extra: &[&wgpu::Texture], div_vector_index: usize, ) -> Option { let slot_idx = self.best_slot_before(div_vector_index)?; @@ -433,6 +499,35 @@ impl CheckpointRing { }, ); + // Restore the accumulators the stroke buffer's pixels were + // derived from, or the next resolve recomputes this region from + // state describing dabs that were just discarded. + for (dst, src) in extra.iter().zip(slot.extra.iter()) { + encoder.copy_texture_to_texture( + wgpu::TexelCopyTextureInfo { + texture: src, + mip_level: 0, + origin: wgpu::Origin3d::ZERO, + aspect: wgpu::TextureAspect::All, + }, + wgpu::TexelCopyTextureInfo { + texture: dst, + mip_level: 0, + origin: wgpu::Origin3d { + x: layer_rect.x0(), + y: layer_rect.y0(), + z: 0, + }, + aspect: wgpu::TextureAspect::All, + }, + wgpu::Extent3d { + width: layer_rect.width, + height: layer_rect.height, + depth_or_array_layers: 1, + }, + ); + } + Some(CheckpointRestore { save_point_index: slot.save_point_index, vector_index: slot.vector_index, diff --git a/crates/darkly/src/brush/eval.rs b/crates/darkly/src/brush/eval.rs index 2d29c034..b92f6cb7 100644 --- a/crates/darkly/src/brush/eval.rs +++ b/crates/darkly/src/brush/eval.rs @@ -51,6 +51,13 @@ pub struct EvalContext<'a> { /// Stroke-constant. Terminals multiply their per-touch modulation onto it /// via [`Self::base_size`]. pub base_size: f32, + /// How many dabs land on a given texel as the brush passes over it + /// once (`diameter / spacing`). Stroke-constant, set out-of-band at + /// stroke start by [`super::stroke_engine::StrokeEngine`]. A terminal + /// accumulating a per-dab quantity divides by this to express its rate + /// per *pass* rather than per dab — see + /// [`super::wgsl::IntrinsicUniforms::dabs_per_pass`]. + pub dabs_per_pass: f32, /// This node instance's ID (used to salt PRNG for independence). pub node_id: &'a NodeId, } @@ -85,6 +92,12 @@ impl EvalContext<'_> { self.base_size } + /// Dabs landing on one texel per pass of the brush (see the field). + /// Never below 1.0, so dividing a rate by it is always well-defined. + pub fn dabs_per_pass(&self) -> f32 { + self.dabs_per_pass.max(1.0) + } + /// O(1) curve lookup using the precomputed LUT. /// Falls back to identity (returns `t` unchanged) if no LUT is cached. #[inline] @@ -510,6 +523,10 @@ pub struct BrushGraphRunner { /// `brush_settings.size` graph signal is published on its slot by the /// runner's generic settable-source seeding, not here. base_size: f32, + /// Dabs landing on one texel per pass of the brush, set once per stroke + /// by [`Self::set_dabs_per_pass`]. Threaded into every `EvalContext`; + /// terminals read it via [`EvalContext::dabs_per_pass`]. + dabs_per_pass: f32, /// Compiled WGSL for this brush, populated by `compile_graph` when /// the graph terminates in `paint`. `None` for per-dab /// dispatch brushes. The runner copies this into the @@ -532,6 +549,7 @@ fn build_eval_ctx<'a>( stroke_seed: u32, dab_index: u32, base_size: f32, + dabs_per_pass: f32, ) -> EvalContext<'a> { let node = node_data.get(&step.node_id); EvalContext { @@ -542,6 +560,7 @@ fn build_eval_ctx<'a>( stroke_seed, dab_index, base_size, + dabs_per_pass, node_id: &step.node_id, } } @@ -674,6 +693,7 @@ impl BrushGraphRunner { // base size without a separate call. Live painting refreshes it // per stroke via `set_base_size`. base_size: brush_settings::base_size(graph), + dabs_per_pass: 1.0, compiled: None, }) } @@ -686,6 +706,15 @@ impl BrushGraphRunner { self.base_size = base_size; } + /// Publish how many dabs land on one texel per pass of the brush. Call + /// once before the first dab; the stroke engine derives it from the + /// spacing it is about to place dabs with, so a terminal's per-dab + /// rates can be stated per pass. See + /// [`EvalContext::dabs_per_pass`]. + pub fn set_dabs_per_pass(&mut self, dabs_per_pass: f32) { + self.dabs_per_pass = dabs_per_pass; + } + /// Attach a pre-built [`CompiledBrush`] to this runner. Called by /// [`crate::brush::compile_graph`] when the graph terminates in /// `paint`. Idempotent — overwrites any prior value. @@ -712,7 +741,12 @@ impl BrushGraphRunner { /// (has a `clone_source` node requesting the `@group(3)` snapshot). /// Drives the engine's no-op gate and the frontend's gesture arming. pub fn samples_source(&self) -> bool { - self.compiled.as_ref().is_some_and(|c| c.samples_source) + use crate::brush::texture_source::{LiveSource, ResolvedSource}; + self.compiled.as_ref().is_some_and(|c| { + c.graph_sources + .iter() + .any(|s| matches!(s, ResolvedSource::Live(LiveSource::StrokeSnapshot))) + }) } /// Returns `true` if the graph terminates in a compiled-WGSL @@ -889,6 +923,7 @@ impl BrushGraphRunner { self.stroke_seed, self.dab_index, self.base_size, + self.dabs_per_pass, ); let outputs = evaluator.evaluate_cpu(&ctx); @@ -1002,6 +1037,7 @@ impl BrushGraphRunner { self.stroke_seed, self.dab_index, self.base_size, + self.dabs_per_pass, ); // Pure-math nodes promoted to the GPU phase (because an input @@ -1072,6 +1108,10 @@ impl BrushGraphRunner { /// dispatch before the phase's `submit_final`. Fragment-path /// terminals no-op. pub fn flush_dabs(&mut self, gpu: &mut BrushGpuContext) { + // Live `@group(3)` slots are republished by their owning nodes + // during this dispatch; clearing first keeps a stale view from a + // previous flush from being bound if its producer drops out. + gpu.dab_batch.live_textures.clear(); self.dispatch_lifecycle(gpu, false, |_id, ev, ctx, gpu| ev.flush_dabs(ctx, gpu)); } @@ -1126,6 +1166,7 @@ impl BrushGraphRunner { self.stroke_seed, self.dab_index, self.base_size, + self.dabs_per_pass, ); f(&step.type_id, evaluator.as_ref(), &ctx, gpu); } diff --git a/crates/darkly/src/brush/gpu_context.rs b/crates/darkly/src/brush/gpu_context.rs index ee65fbd4..deb02aca 100644 --- a/crates/darkly/src/brush/gpu_context.rs +++ b/crates/darkly/src/brush/gpu_context.rs @@ -13,6 +13,7 @@ use std::sync::Arc; use super::eval::BrushCursorPreviewInfo; use super::pipeline::BrushPipelines; use super::scratch::Scratch; +use super::texture_source::LiveSource; use super::wgsl::{CompiledBrush, IntrinsicUniforms}; use super::wire::ScalarValue; use crate::gpu::overlay::ToolOverlay; @@ -253,6 +254,16 @@ pub struct DabBatch { /// `flush_dabs` to know the dab record / uniform layouts and the /// pipeline topology hash. pub compiled_brush: Option>, + /// `@group(3)` textures published for this flush by the nodes that + /// requested them, keyed by which live source they satisfy. A node + /// with a [`crate::brush::texture_source::ResolvedSource::Live`] slot + /// publishes its view from its own `flush_dabs`; the terminal reads + /// them all when it builds the graph-texture bind group. The runner + /// dispatches `flush_dabs` in topological order, so every producer + /// upstream of the terminal has published before the terminal binds. + /// Cleared at the start of each flush — a slot nobody published falls + /// back to `_fallback` (the cursor-preview path). + pub live_textures: Vec<(LiveSource, wgpu::TextureView)>, /// Name → value map of every output slot in the brush graph, built /// by the runner's `dispatch_gpu` immediately after `execute_cpu` /// and held for the duration of the dispatch pass. Keys follow the @@ -330,6 +341,31 @@ impl DabBatch { self.count = 0; self.bbox = None; self.meta_bytes.clear(); + self.live_textures.clear(); + } + + /// Publish a `@group(3)` texture for this flush. Called by the node + /// that requested the matching + /// [`crate::brush::texture_source::ResolvedSource::Live`] slot, from + /// its own `flush_dabs`, before the terminal binds. Last write wins, + /// so a node re-publishing within one flush replaces its own entry + /// rather than accumulating. + pub fn publish_live_texture(&mut self, kind: LiveSource, view: wgpu::TextureView) { + if let Some(slot) = self.live_textures.iter_mut().find(|(k, _)| *k == kind) { + slot.1 = view; + return; + } + self.live_textures.push((kind, view)); + } + + /// The view published for `kind` this flush, if any. `None` means the + /// slot binds `_fallback` — the cursor preview, where no stroke exists + /// to publish anything. + pub fn live_texture(&self, kind: LiveSource) -> Option<&wgpu::TextureView> { + self.live_textures + .iter() + .find(|(k, _)| *k == kind) + .map(|(_, v)| v) } /// Union a write-pass footprint into [`Self::write_canvas_bbox`]. @@ -450,7 +486,11 @@ impl<'a> BrushGpuContext<'a> { cursor_preview_centre: [0.0, 0.0], cursor_preview_size: [0, 0], view_rotation: self.view_rotation, - _pad: [0, 0, 0], + // Terminals that normalise a per-dab rate overwrite this from + // `EvalContext::dabs_per_pass`; 1.0 leaves the normalisation a + // no-op for those that don't. + dabs_per_pass: 1.0, + _pad: [0, 0], } } @@ -475,7 +515,11 @@ impl<'a> BrushGpuContext<'a> { cursor_preview_centre: centre, cursor_preview_size: [target_w, target_h], view_rotation: self.view_rotation, - _pad: [0, 0, 0], + // Terminals that normalise a per-dab rate overwrite this from + // `EvalContext::dabs_per_pass`; 1.0 leaves the normalisation a + // no-op for those that don't. + dabs_per_pass: 1.0, + _pad: [0, 0], } } diff --git a/crates/darkly/src/brush/nodes/clone_source.rs b/crates/darkly/src/brush/nodes/clone_source.rs index 3995acbb..4feb02b2 100644 --- a/crates/darkly/src/brush/nodes/clone_source.rs +++ b/crates/darkly/src/brush/nodes/clone_source.rs @@ -13,8 +13,8 @@ //! //! ## Source binding //! -//! Compilation calls [`CompileWgslCtx::request_source_texture`], which -//! sets [`crate::brush::wgsl::CompiledBrush::samples_source`] and reserves +//! Compilation calls [`CompileWgslCtx::request_live_texture`], which +//! reserves //! the `@group(3)` source slot. `paint`'s `flush_dabs` binds the stroke's //! source snapshot there: the pre-stroke snapshot of the painted layer //! (same-layer clone), or a separate snapshot frozen at stroke start when @@ -173,7 +173,8 @@ impl BrushNodeEvaluator for CloneSourceEvaluator { return Ok(wgsl); } - let slot = cctx.request_source_texture(); + let slot = + cctx.request_live_texture(crate::brush::texture_source::LiveSource::StrokeSnapshot); // Stroke-constant uniforms, seeded per pen event by the runner // from `CloneState` (keyed `n{id}_source_anchor` etc.): the two diff --git a/crates/darkly/src/brush/nodes/paint.rs b/crates/darkly/src/brush/nodes/paint.rs index 869e613f..1f1548cb 100644 --- a/crates/darkly/src/brush/nodes/paint.rs +++ b/crates/darkly/src/brush/nodes/paint.rs @@ -119,11 +119,11 @@ impl PerBrushPipeline { // The compile walk rejects graphs that combine an `image` // node with a terminal that also claims @group(3) (e.g. // watercolor's pickup atlas). - // `@group(3)` texture count = named `image` textures + one for - // the `clone_source` frozen snapshot, when present. `samples_source` - // brushes carry no named textures today (the compiler rejects the - // combination), so the source sits at slot 0. - let graph_tex_count = compiled.graph_sources.len() + usize::from(compiled.samples_source); + // `@group(3)` texture count — every slot the graph requested, + // whatever kind. Live slots (`clone_source`'s snapshot, `pickup`'s + // atlas) occupy a binding exactly like a named texture; only the + // moment their view resolves differs. + let graph_tex_count = compiled.graph_sources.len(); let graph_layout = if graph_tex_count == 0 { None } else { @@ -256,22 +256,24 @@ impl PerBrushPipeline { // texture so the pipeline always builds — surfaces a // `log::warn` instead of crashing while the user types in // the node editor. - // The `clone_source` snapshot is per-stroke, so its bind group is - // built fresh each `flush_dabs` (from the live snapshot view) and - // is `None` here. Static named textures (paper grain) build once - // and cache. - let graph_textures_bind_group = - if compiled.samples_source || compiled.graph_sources.is_empty() { - None - } else { - let (_layout, bg) = ctx.texture_registry.make_bind_group( - ctx.device, - ctx.queue, - ctx.baked_sources, - &compiled.graph_sources, - ); - Some(bg) - }; + // A graph with any live slot rebuilds its bind group every + // `flush_dabs` from whatever the producing nodes published, so + // there is nothing to cache here. Wholly static graphs (named + // textures, baked tiles) build once. + let graph_textures_bind_group = if compiled.graph_sources.iter().any(|s| s.is_live()) + || compiled.graph_sources.is_empty() + { + None + } else { + let (_layout, bg) = ctx.texture_registry.make_bind_group( + ctx.device, + ctx.queue, + ctx.baked_sources, + &compiled.graph_sources, + &[], + ); + Some(bg) + }; Self { paint_pipeline, @@ -541,34 +543,43 @@ impl BrushNodeEvaluator for PaintEvaluator { .expect("paint::flush_dabs requires dab_batch.slot_outputs"); pack_uniforms(&compiled, outputs, &mut uniform_bytes); - // `clone_source` brushes bind the stroke's frozen source snapshot - // at `@group(3)` — the cross-layer / merged snapshot when one was - // captured, else the pre-stroke snapshot (same-layer clone). A - // per-stroke resource, so the bind group is built here each flush - // rather than cached on the pipeline. The layout matches - // `layout_for_count(1)` (shared sampler + one texture); the - // compiler guarantees a source-sampling brush has no named graph - // textures, so the source is the sole slot-0 texture. - let source_bind_group = if compiled.samples_source { - let reg = gpu.pipelines.texture_registry(); - let view = stroke + // `@group(3)` for graphs with a live slot: rebuilt here each flush + // from the views the producing nodes published during their own + // `flush_dabs` (the runner dispatches those first, in topological + // order). `clone_source` publishes the stroke snapshot; `pickup` + // publishes its atlas. An unpublished slot resolves to `_fallback` + // inside `make_bind_group`. + let live_bind_group = if compiled.graph_sources.iter().any(|s| s.is_live()) { + // The stroke snapshot is a *stroke* resource, so the terminal + // that owns the stroke publishes it; node-owned live textures + // (the `pickup` atlas) are already in the table, published by + // their nodes earlier in this same topological dispatch. Both + // then resolve through one uniform lookup below. + let snapshot = stroke .source_texture() .create_view(&wgpu::TextureViewDescriptor::default()); - let layout = reg.layout_for_count(gpu.device, 1); - Some(gpu.device.create_bind_group(&wgpu::BindGroupDescriptor { - label: Some("paint-clone-source-bg"), - layout: &layout, - entries: &[ - wgpu::BindGroupEntry { - binding: 0, - resource: wgpu::BindingResource::Sampler(reg.sampler()), - }, - wgpu::BindGroupEntry { - binding: 1, - resource: wgpu::BindingResource::TextureView(&view), - }, - ], - })) + gpu.dab_batch.publish_live_texture( + crate::brush::texture_source::LiveSource::StrokeSnapshot, + snapshot, + ); + let published: Vec> = compiled + .graph_sources + .iter() + .map(|s| match s { + crate::brush::texture_source::ResolvedSource::Live(kind) => { + gpu.dab_batch.live_texture(*kind) + } + _ => None, + }) + .collect(); + let (_layout, bg) = gpu.pipelines.texture_registry().make_bind_group( + gpu.device, + gpu.queue, + gpu.pipelines.baked_sources(), + &compiled.graph_sources, + &published, + ); + Some(bg) } else { None }; @@ -618,12 +629,13 @@ impl BrushNodeEvaluator for PaintEvaluator { pass.set_bind_group(0, &per_brush.uniform_bind_group, &[uniform_offset]); pass.set_bind_group(1, &per_brush.dabs_bind_group, &[]); pass.set_bind_group(2, gpu.selection_bind_group, &[]); - // `@group(3)` holds the brush's graph textures (paper grain - // etc.) when any are requested, or the `clone_source` frozen - // snapshot for source-sampling brushes. Paint never uses - // group 3 for anything else. - if let Some(source_bg) = source_bind_group.as_ref() { - pass.set_bind_group(3, source_bg, &[]); + // `@group(3)` holds the brush's graph textures — paper grain, + // baked noise, the `clone_source` snapshot, the `pickup` + // atlas. Graphs with a live slot bind the group assembled + // above; wholly static ones bind the pipeline's cached group. + // Paint never uses group 3 for anything else. + if let Some(live_bg) = live_bind_group.as_ref() { + pass.set_bind_group(3, live_bg, &[]); } else if let Some(graph_bg) = per_brush.graph_textures_bind_group.as_ref() { pass.set_bind_group(3, graph_bg, &[]); } diff --git a/crates/darkly/src/brush/nodes/random.rs b/crates/darkly/src/brush/nodes/random.rs index fb911294..6247959f 100644 --- a/crates/darkly/src/brush/nodes/random.rs +++ b/crates/darkly/src/brush/nodes/random.rs @@ -128,6 +128,7 @@ mod tests { stroke_seed: 0x1234_5678, dab_index, base_size: 1.0, + dabs_per_pass: 1.0, node_id, }; match RandomEvaluator.evaluate_cpu(&ctx).into_iter().next() { diff --git a/crates/darkly/src/brush/nodes/watercolor.rs b/crates/darkly/src/brush/nodes/watercolor.rs index ad772063..dc0f8703 100644 --- a/crates/darkly/src/brush/nodes/watercolor.rs +++ b/crates/darkly/src/brush/nodes/watercolor.rs @@ -4,21 +4,34 @@ //! Structural shape mirrors [`paint`](super::paint), //! with one extra pass at the front: //! -//! 1. **Pickup atlas pass.** N instances, each writes the 8×8 alpha- -//! weighted neighborhood average of the *live canvas* at the dab's -//! footprint into its cell in a 128×128 atlas. Live canvas means -//! `pre_stroke_texture` with the stroke scratch composited over it — -//! the dry layer plus the wet paint deposited so far — which is what -//! lets a mark keep building where the brush passes more than once. -//! Reading the scratch here is legal because this pass targets the -//! atlas, not the scratch. The shader is brush-agnostic in math but -//! built per-brush so its `DabRecord` struct stride matches the -//! compiled brush's. Cell layout is `(idx % atlas_w, idx / atlas_w)`. -//! 2. **Composite pass.** One instanced draw, N quads. The fragment -//! shader is the framework-assembled per-brush WGSL: upstream -//! nodes (`circle`, `paint_color`, etc.) compile inline; this -//! terminal contributes the watercolor blend math (atlas pickup + -//! deposit/wetness load) and the extra atlas bind group. +//! 1. **Pickup atlas pass.** One instanced draw, N quads: each writes the +//! 8×8 alpha-weighted neighborhood average of the *pre-stroke snapshot* +//! at the dab's footprint into its cell in a 128×128 atlas. That is the +//! dry colour the pigment mixes away from, and it is frozen for the +//! whole stroke — buildup across passes comes from the deposit channel +//! below, not from feeding the mark back into its own input. The shader +//! is brush-agnostic in math but built per-brush so its `DabRecord` +//! struct stride matches the compiled brush's. Cell layout is +//! `(idx % atlas_w, idx / atlas_w)`. +//! 2. **Composite pass, one draw per dab.** The fragment shader is the +//! framework-assembled per-brush WGSL: upstream nodes (`circle`, +//! `paint_color`, etc.) compile inline; this terminal contributes the +//! watercolor blend math and the `@group(3)` bindings. +//! +//! ## How a mark builds up +//! +//! The scratch carries *coverage* and saturates almost immediately. How +//! much pigment has been delivered is a second, independent quantity, and +//! it lives in a [`StrokeChannel`] hung off the composite draw as a second +//! colour attachment (see [`DEPOSIT_CHANNEL`]). Each dab folds its own +//! delivery rate in under source-over, giving `1 − Π(1−rᵢ)`, and reads the +//! value under itself to decide how far from the dry canvas toward the +//! pigment its colour sits. A texel touched once sits near the canvas; one +//! dwelt on converges on the brush colour. +//! +//! A dab reads that field **once**, at its own centre, and resolves one +//! solid colour before it goes down. The stamp is flat; only its coverage +//! varies across the footprint. See `compile_wgsl`. //! //! ## Differences from `watercolor_batched` //! @@ -51,6 +64,7 @@ use crate::brush::paint_target_ext::BrushPaintTargetExt; use crate::brush::pipeline::{ BrushPipelineEntry, BrushPipelineRegistration, BuildContext, DynamicUniformRing, }; +use crate::brush::scratch::StrokeChannel; use crate::brush::wgsl::{ pack_intrinsic_uniforms, pack_uniforms, CompileWgslCtx, CompiledBrush, NodeWgsl, WgslType, INTRINSIC_UNIFORMS_SIZE, @@ -77,6 +91,38 @@ const _: () = assert!( const MAX_UNIFORM_BYTES: usize = 1024; +/// How much pigment this stroke has delivered to each texel, in `[0, 1]`. +/// +/// The scratch's own alpha is *coverage* — what the mark looks like — and +/// saturates almost immediately, which is why a dwelling mark used to stop +/// changing: every dab after the first few was compositing a fixed colour +/// under an alpha that had nowhere left to go. Deposit is the other +/// quantity: it starts at zero, each dab folds its own delivery rate in +/// under source-over, and the dab's *colour* is a function of it. A texel +/// that has been visited once sits near the canvas colour; one that has +/// been dwelt on sits at the pigment. +/// +/// Source-over accumulation gives `1 − Π(1−rᵢ)` over the dabs that touched +/// the texel. Being a product it has no memory of how it was parenthesised, +/// so the value is unchanged by how dabs were grouped into pointer events +/// or by a checkpoint replay re-deriving them in different batches. +const DEPOSIT_CHANNEL: StrokeChannel = StrokeChannel { + name: "deposit", + format: wgpu::TextureFormat::R8Unorm, + blend: wgpu::BlendState { + color: wgpu::BlendComponent { + src_factor: wgpu::BlendFactor::One, + dst_factor: wgpu::BlendFactor::OneMinusSrcAlpha, + operation: wgpu::BlendOperation::Add, + }, + alpha: wgpu::BlendComponent { + src_factor: wgpu::BlendFactor::One, + dst_factor: wgpu::BlendFactor::OneMinusSrcAlpha, + operation: wgpu::BlendOperation::Add, + }, + }, +}; + // ── Pickup uniforms ───────────────────────────────────────────────────── #[repr(C)] @@ -90,12 +136,10 @@ struct PickupUniforms { /// (half-extent in canvas-pixel terms is /// `pickup_size / dab.inv_radius_target_px`, valid in stroke mode /// where target px ≡ canvas px). Stroke-constant — see the - /// `pickup_size` port on `watercolor`. Sampling the full - /// bbox produced visibly too-large pickup neighborhoods (the bbox - /// is shape-extent-inflated, ~1.4× the visible disc for Rough - /// Watercolor); a third of the nominal radius matches the - /// "smudge from where the brush is now" intuition closer to - /// Krita's defaults. + /// `pickup_size` port on `watercolor`. It is measured against the + /// nominal radius rather than the shape bbox because the bbox is + /// extent-inflated (~1.4× the visible disc for Rough Watercolor), + /// which sampled visibly wider than where the brush is marking. pickup_size: f32, _pad: f32, } @@ -118,11 +162,21 @@ struct PerBrushPipeline { dabs_buffer: wgpu::Buffer, dabs_bind_group_pickup: wgpu::BindGroup, dabs_bind_group_composite: wgpu::BindGroup, - /// Pickup atlas texture + the bind group the composite shader reads - /// at `@group(3)`. + /// Pickup atlas texture and its two views — one to render into, one + /// for the composite shader to sample at `@group(3)`. _atlas_texture: wgpu::Texture, atlas_attachment_view: wgpu::TextureView, - atlas_bind_group: wgpu::BindGroup, + atlas_sample_view: wgpu::TextureView, + /// Per-dab deposit probe, written by the pickup pass and read by the + /// composite at the same cell. + _deposit_atlas: wgpu::Texture, + deposit_atlas_attachment_view: wgpu::TextureView, + deposit_atlas_sample_view: wgpu::TextureView, + /// Layout for `@group(3)`. Held rather than a prebuilt bind group + /// because the deposit mirror in it is reallocated whenever the layer + /// grows, so the group is rebuilt each flush. + composite_group3_bgl: wgpu::BindGroupLayout, + canvas_copy_sampler: wgpu::Sampler, } impl PerBrushPipeline { @@ -161,7 +215,48 @@ impl PerBrushPipeline { }], }); - // ── Composite pipeline layout: group(0..3) standard, group(3) atlas ── + // ── Composite `@group(3)`: the pickup atlas plus the deposit + // mirror. Both are terminal-private reads, and WebGPU's default + // `max_bind_groups = 4` leaves no slot 4 to put the second one in, + // so they share the group rather than the atlas reusing + // `canvas_copy_bgl`. The mirror is read with `textureLoad`, so it + // needs no sampler and imposes no filterability constraint on the + // channel format. + let composite_group3_bgl = + ctx.device + .create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("watercolor-composite-group3-bgl"), + entries: &[ + wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable: true }, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 1, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 2, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable: true }, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + count: None, + }, + ], + }); + + // ── Composite pipeline layout: group(0..2) standard, group(3) as above ── let composite_layout = ctx .device .create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { @@ -170,7 +265,7 @@ impl PerBrushPipeline { Some(ctx.uniform_bgl), Some(&dabs_bgl), Some(ctx.selection_bgl), - Some(ctx.canvas_copy_bgl), // atlas: same texture+sampler layout + Some(&composite_group3_bgl), ], immediate_size: 0, }); @@ -184,12 +279,17 @@ impl PerBrushPipeline { Some(ctx.uniform_bgl), Some(&dabs_bgl), Some(ctx.canvas_copy_bgl), // pre_stroke texture+sampler - Some(ctx.canvas_copy_bgl), // stroke scratch texture+sampler + Some(ctx.canvas_copy_bgl), // deposit channel texture+sampler ], immediate_size: 0, }); // ── Composite blend: premultiplied source-over ── + // + // A dab is a stamp of one solid colour, soft-edged in alpha. The + // colour is resolved before the dab goes down (see `compile_wgsl`), + // so the only thing varying across the footprint is coverage, and + // the ROP composites the stamp onto whatever is already there. let composite_blend = wgpu::BlendState { color: wgpu::BlendComponent { src_factor: wgpu::BlendFactor::One, @@ -217,11 +317,21 @@ impl PerBrushPipeline { fragment: Some(wgpu::FragmentState { module: &composite_shader, entry_point: Some("fs_main"), - targets: &[Some(wgpu::ColorTargetState { - format: wgpu::TextureFormat::Rgba8Unorm, - blend: Some(composite_blend), - write_mask: wgpu::ColorWrites::ALL, - })], + // Two targets, in the order the generated `FsOut` + // declares them: the scratch at `@location(0)`, + // the deposit channel at `@location(1)`. + targets: &[ + Some(wgpu::ColorTargetState { + format: wgpu::TextureFormat::Rgba8Unorm, + blend: Some(composite_blend), + write_mask: wgpu::ColorWrites::ALL, + }), + Some(wgpu::ColorTargetState { + format: DEPOSIT_CHANNEL.format, + blend: Some(DEPOSIT_CHANNEL.blend), + write_mask: wgpu::ColorWrites::ALL, + }), + ], compilation_options: Default::default(), }), primitive: wgpu::PrimitiveState { @@ -248,11 +358,20 @@ impl PerBrushPipeline { fragment: Some(wgpu::FragmentState { module: &pickup_shader, entry_point: Some("fs_main"), - targets: &[Some(wgpu::ColorTargetState { - format: wgpu::TextureFormat::Rgba8Unorm, - blend: None, - write_mask: wgpu::ColorWrites::ALL, - })], + // Cell-per-dab probes, written not blended: the dry + // canvas colour, and the deposit already under the dab. + targets: &[ + Some(wgpu::ColorTargetState { + format: wgpu::TextureFormat::Rgba8Unorm, + blend: None, + write_mask: wgpu::ColorWrites::ALL, + }), + Some(wgpu::ColorTargetState { + format: DEPOSIT_CHANNEL.format, + blend: None, + write_mask: wgpu::ColorWrites::ALL, + }), + ], compilation_options: Default::default(), }), primitive: wgpu::PrimitiveState { @@ -352,20 +471,30 @@ impl PerBrushPipeline { let atlas_attachment_view = atlas_texture.create_view(&wgpu::TextureViewDescriptor::default()); let atlas_sample_view = atlas_texture.create_view(&wgpu::TextureViewDescriptor::default()); - let atlas_bind_group = ctx.device.create_bind_group(&wgpu::BindGroupDescriptor { - label: Some("watercolor-atlas-bg"), - layout: ctx.canvas_copy_bgl, - entries: &[ - wgpu::BindGroupEntry { - binding: 0, - resource: wgpu::BindingResource::TextureView(&atlas_sample_view), - }, - wgpu::BindGroupEntry { - binding: 1, - resource: wgpu::BindingResource::Sampler(ctx.canvas_copy_sampler), - }, - ], + + // ── Deposit atlas ── + // Same cell layout as the colour atlas, one scalar per dab: the + // mean deposit already under that dab. Computed once per dab in the + // pickup pass rather than per fragment in the composite, where it + // would be the same number recomputed for every texel of the stamp. + let deposit_atlas = ctx.device.create_texture(&wgpu::TextureDescriptor { + label: Some("watercolor-deposit-atlas"), + size: wgpu::Extent3d { + width: ATLAS_WIDTH, + height: ATLAS_HEIGHT, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: DEPOSIT_CHANNEL.format, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING, + view_formats: &[], }); + let deposit_atlas_attachment_view = + deposit_atlas.create_view(&wgpu::TextureViewDescriptor::default()); + let deposit_atlas_sample_view = + deposit_atlas.create_view(&wgpu::TextureViewDescriptor::default()); let _ = dab_record_size; @@ -382,7 +511,12 @@ impl PerBrushPipeline { dabs_bind_group_composite, _atlas_texture: atlas_texture, atlas_attachment_view, - atlas_bind_group, + atlas_sample_view, + _deposit_atlas: deposit_atlas, + deposit_atlas_attachment_view, + deposit_atlas_sample_view, + composite_group3_bgl, + canvas_copy_sampler: ctx.canvas_copy_sampler.clone(), } } } @@ -447,9 +581,6 @@ fn watercolor_pipeline_reg() -> BrushPipelineRegistration { /// generated per brush — the file-level shader-compile test parses /// every `.wgsl` in isolation and a placeholder-bearing template /// fails that pass. -/// -/// Depends on `source_over` from `shaders/source_over.wgsl`, which -/// [`build_pickup_shader`] prepends. const PICKUP_SHADER_TAIL: &str = r#" struct PickupUniforms { pre_stroke_origin: vec2, @@ -464,11 +595,11 @@ struct PickupUniforms { @group(1) @binding(0) var dabs: array; @group(2) @binding(0) var t_pre_stroke: texture_2d; @group(2) @binding(1) var s_pre_stroke: sampler; -// The in-flight stroke scratch — premultiplied paint deposited so far this -// stroke. Sampling it here is safe: this pass renders to the atlas, so the -// scratch is read-only for the duration and there is no read/write alias. -@group(3) @binding(0) var t_scratch: texture_2d; -@group(3) @binding(1) var s_scratch: sampler; +// The deposit channel as it stands *now*. Legal to sample here because this +// pass renders to the atlas, not to the channel — the composite writes it, +// this pass only reads it, and they are separate passes. +@group(3) @binding(0) var t_deposit: texture_2d; +@group(3) @binding(1) var s_deposit: sampler; struct VertexOutput { @builtin(position) position: vec4, @@ -502,14 +633,21 @@ fn vs_main( return out; } +struct PickupOut { + // Alpha-weighted neighbourhood average of the dry canvas. + @location(0) canvas: vec4, + // Mean deposit under the dab — how loaded this spot already is. + @location(1) deposit: vec4, +} + @fragment -fn fs_main(in: VertexOutput) -> @location(0) vec4 { +fn fs_main(in: VertexOutput) -> PickupOut { let dab = dabs[in.instance_idx]; // Pickup samples within a fraction of the dab's *nominal* radius // (not the bbox-inflated extent). The visible "smudge influence" // should track where the brush is actually marking, not the // worst-case shape-bbox footprint. `pickup_size` is the brush - // property scrub — default ≈ 0.33, exposed on the terminal. + // property scrub, exposed on the terminal. // // STROKE-ONLY: this shader is dispatched only from the stroke // pipeline (it samples `t_pre_stroke`, which is unbound at preview @@ -537,20 +675,54 @@ fn fs_main(in: VertexOutput) -> @location(0) vec4 { if (uv.x < 0.0 || uv.x > 1.0 || uv.y < 0.0 || uv.y > 1.0) { continue; } - // The canvas the brush is actually touching: wet paint from this - // stroke over the dry layer beneath it. Reading only the dry - // layer would freeze the load for the whole stroke, so a mark - // could never build past its first pass. + // The *dry* layer only — the pre-stroke snapshot, frozen for + // the whole stroke. This is the colour the pigment is mixing + // away from, and it must not move while the stroke mixes away + // from it. Compositing the in-flight scratch in here instead + // closes a positive feedback loop: the deposit channel already + // says how far this texel has travelled, so mixing that far + // from a canvas that has itself already travelled multiplies + // the two. The distance left to the pigment becomes + // `Π(1−laidₖ) = (1−rate)^(n(n+1)/2)` — quadratic in the + // exponent, so even a 1% rate saturates within a few passes and + // the `deposit` port stops meaning anything. Against the dry + // snapshot it is `(1−rate)ⁿ`, which is what the port promises. let dry = textureSampleLevel(t_pre_stroke, s_pre_stroke, uv, 0.0); - let wet = textureSampleLevel(t_scratch, s_scratch, uv, 0.0); - let live = source_over(wet.rgb, wet.a, dry); - sum_rgb = sum_rgb + live.rgb * live.a; - sum_a = sum_a + live.a; + sum_rgb = sum_rgb + dry.rgb * dry.a; + sum_a = sum_a + dry.a; } } let avg_rgb = select(vec3(0.0), sum_rgb / sum_a, sum_a > 0.0001); let avg_a = sum_a / count; - return vec4(avg_rgb, avg_a); + + // Deposit under the dab, on its own grid. + // + // Averaged rather than point-sampled at the centre, and over a window + // tied to the dab's own radius rather than to `pickup_size`. A single + // texel is a noisy read of a field that has structure at the dab- + // spacing scale, and the noise lands straight in the dab's colour — + // consecutive stamps disagree and the mark looks mottled even though + // the field is smooth. `pickup_size` is deliberately not involved: + // it is a look control for the colour, and letting it move the read + // would make it move the buildup rate too. + // The deposit channel and the pre-stroke snapshot are both layer-sized + // and layer-anchored (`flush_dabs` asserts the scratch and snapshot + // share the frame), so one set of origin/size uniforms addresses both. + let dep_half = vec2(0.5 / dab.inv_radius_target_px); + var sum_dep = 0.0; + for (var j: u32 = 0u; j < n; j = j + 1u) { + for (var i: u32 = 0u; i < n; i = i + 1u) { + let cell = (vec2(f32(i), f32(j)) + 0.5) * inv_n; + let pos = dab.pos + (cell - 0.5) * 2.0 * dep_half; + let uv = (pos - origin_f) / size_f; + sum_dep = sum_dep + textureSampleLevel(t_deposit, s_deposit, uv, 0.0).r; + } + } + + var out: PickupOut; + out.canvas = vec4(avg_rgb, avg_a); + out.deposit = vec4(sum_dep / count, 0.0, 0.0, 0.0); + return out; } "#; @@ -560,8 +732,6 @@ fn fs_main(in: VertexOutput) -> @location(0) vec4 { /// pickup pipeline with the matching struct definition prepended. fn build_pickup_shader(compiled: &CompiledBrush) -> String { let mut out = String::with_capacity(PICKUP_SHADER_TAIL.len() + 1024); - out.push_str(include_str!("../../../shaders/source_over.wgsl")); - out.push('\n'); out.push_str("struct DabRecord {\n"); for f in &compiled.dab_layout { out.push_str(&format!(" {}: {},\n", f.name, f.ty.wgsl_name())); @@ -604,7 +774,10 @@ pub fn register() -> BrushNodeRegistration { .with_unit(UnitType::Percent) .with_icon("fa6-solid:droplet") .exposed() - .with_description("Per-dab flow (folded into color alpha → max-deposit ceiling)"), + .with_description( + "Per-dab delivery rate multiplier — scales how much pigment this dab \ + lays down, typically wired from pressure", + ), PortDef::input("opacity", BrushWireType::Scalar) .with_range(0.0, 1.0, 1.0) .with_natural_range(0.0, 1.0) @@ -614,14 +787,27 @@ pub fn register() -> BrushNodeRegistration { .exposed() .with_description("Stroke-level opacity cap (applied at commit)"), PortDef::input("deposit", BrushWireType::Scalar) - .with_range(0.0, 1.0, 0.5) + .with_range(0.0, 1.0, 0.25) .with_natural_range(0.0, 1.0) .with_label("Deposit") .with_unit(UnitType::Percent) .with_icon("fa6-solid:circle") .exposed() + // A preview is one pass, and one pass is all `deposit` + // promises — the mark it leaves peaks at `deposit * + // wetness`, which at the shipped values is 17% of the + // pigment and reads as an empty tile. Watercolor's + // identity is what dwelling builds, and a still frame + // cannot dwell; pinning the rate is how a single pass + // states in one stroke what the brush arrives at over + // several. Measured: the stroke peaks at 142/255 here, + // against 36 at the shipped default. + .with_preview_value(0.8) .with_description( - "How strongly the brush color replaces the pickup canvas color", + "Fraction of the remaining distance to the brush color that one pass of \ + the brush closes. At 25%, one pass over white paper leaves a quarter of \ + the way to the paint, a second pass reaches 44%, a third 58%. \ + Independent of spacing and pressure.", ), PortDef::input("wetness", BrushWireType::Scalar) .with_range(0.0, 1.0, 0.7) @@ -629,9 +815,13 @@ pub fn register() -> BrushNodeRegistration { .with_label("Wetness") .with_unit(UnitType::Percent) .exposed() - .with_description("How much pickup color tints the load"), + .with_description( + "How heavily each dab is laid down — scales the stamp's coverage, so \ + lower values give a thinner, more translucent wash that needs more \ + passes to read as solid.", + ), PortDef::input("pickup_size", BrushWireType::Scalar) - .with_range(0.0, 2.0, 1.0) + .with_range(0.0, 2.0, 0.8) .with_natural_range(0.0, 2.0) .with_label("Pickup Size") .with_unit(UnitType::Percent) @@ -757,6 +947,19 @@ impl BrushNodeEvaluator for WatercolorEvaluator { ensure_per_brush_pipeline(gpu, pipeline_ref, &compiled); + // Allocate the deposit channel, idempotently. A fragment cannot + // sample the attachment it blends into, so what a dab reads is a + // mirror, refreshed inside the composite loop below. + { + let device = gpu.device; + let Some(stroke) = gpu.stroke.as_mut() else { + return; + }; + stroke + .scratch + .ensure_channels(device, &mut gpu.encoder, &[DEPOSIT_CHANNEL]); + } + let stroke = gpu .stroke .as_ref() @@ -770,10 +973,11 @@ impl BrushNodeEvaluator for WatercolorEvaluator { // Build composite uniforms (intrinsic + node-contributed). let mut composite_uniform_bytes: Vec = Vec::with_capacity(MAX_UNIFORM_BYTES); - pack_intrinsic_uniforms( - &mut composite_uniform_bytes, - gpu.intrinsic_header(layer_offset, layer_size), - ); + let mut intrinsic = gpu.intrinsic_header(layer_offset, layer_size); + // The `deposit` knob is a per-*pass* figure; the shader divides it + // down to a per-dab rate with this. See the body in `compile_wgsl`. + intrinsic.dabs_per_pass = ctx.dabs_per_pass(); + pack_intrinsic_uniforms(&mut composite_uniform_bytes, intrinsic); let outputs = gpu .dab_batch .slot_outputs @@ -822,43 +1026,119 @@ impl BrushNodeEvaluator for WatercolorEvaluator { gpu.queue .write_buffer(&per_brush.dabs_buffer, 0, &dab_bytes); - // ── Pass 1: pickup atlas ── - { - let mut pass = gpu.encoder.begin_render_pass(&wgpu::RenderPassDescriptor { - label: Some("watercolor-pickup"), - color_attachments: &[Some(wgpu::RenderPassColorAttachment { - view: &per_brush.atlas_attachment_view, - resolve_target: None, - depth_slice: None, - ops: wgpu::Operations { - load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT), - store: wgpu::StoreOp::Store, - }, - })], - ..Default::default() - }); - pass.set_viewport(0.0, 0.0, ATLAS_WIDTH as f32, ATLAS_HEIGHT as f32, 0.0, 1.0); - pass.set_pipeline(&per_brush.pickup_pipeline); - pass.set_bind_group(0, &per_brush.pickup_uniform_bind_group, &[pickup_offset]); - pass.set_bind_group(1, &per_brush.dabs_bind_group_pickup, &[]); - pass.set_bind_group(2, pre_stroke_bg, &[]); - pass.set_bind_group(3, scratch.live_canvas_bind_group(), &[]); - pass.draw(0..6, 0..total_dabs); - } + // ── Per dab: probe, then stamp ── + // + // A dab's colour depends on the deposit earlier dabs left under + // it. Concurrent invocations of one instanced draw cannot + // observe each other, so batching would give every dab in the + // flush the same pre-flush answer, making the mark a function of + // how dabs happened to be grouped into pointer events — the + // banding in `docs/watercolor.md` §1, whose spatial period was + // measured as the pen's travel per pointer event. + // + // So each dab gets its own pickup (probe the canvas and the + // deposit under it, into one atlas cell) followed by its own + // composite (stamp one solid colour). The probe reads the + // deposit channel while the composite writes it; they are + // separate passes, so the ordering is real and no copy is + // needed to enforce it. + let group3_bind_group = gpu.device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("watercolor-composite-group3-bg"), + layout: &per_brush.composite_group3_bgl, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: wgpu::BindingResource::TextureView(&per_brush.atlas_sample_view), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::Sampler(&per_brush.canvas_copy_sampler), + }, + wgpu::BindGroupEntry { + binding: 2, + resource: wgpu::BindingResource::TextureView( + &per_brush.deposit_atlas_sample_view, + ), + }, + ], + }); + // The pickup reads the deposit channel directly — this pass + // targets the atlas, so there is no read/write alias. + let channel_views = scratch.channel_views(); + let deposit_read_bg = gpu.device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("watercolor-pickup-deposit-bg"), + layout: gpu.pipelines.canvas_copy_bind_group_layout(), + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: wgpu::BindingResource::TextureView(&channel_views[0]), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::Sampler(&per_brush.canvas_copy_sampler), + }, + ], + }); + let attachments = [ + Some(wgpu::RenderPassColorAttachment { + view: scratch.write_view(), + resolve_target: None, + depth_slice: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Load, + store: wgpu::StoreOp::Store, + }, + }), + Some(wgpu::RenderPassColorAttachment { + view: &channel_views[0], + resolve_target: None, + depth_slice: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Load, + store: wgpu::StoreOp::Store, + }, + }), + ]; + let pickup_attachments = [ + Some(wgpu::RenderPassColorAttachment { + view: &per_brush.atlas_attachment_view, + resolve_target: None, + depth_slice: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT), + store: wgpu::StoreOp::Store, + }, + }), + Some(wgpu::RenderPassColorAttachment { + view: &per_brush.deposit_atlas_attachment_view, + resolve_target: None, + depth_slice: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT), + store: wgpu::StoreOp::Store, + }, + }), + ]; + + for i in 0..total_dabs { + { + let mut pass = gpu.encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("watercolor-pickup"), + color_attachments: &pickup_attachments, + ..Default::default() + }); + pass.set_viewport(0.0, 0.0, ATLAS_WIDTH as f32, ATLAS_HEIGHT as f32, 0.0, 1.0); + pass.set_pipeline(&per_brush.pickup_pipeline); + pass.set_bind_group(0, &per_brush.pickup_uniform_bind_group, &[pickup_offset]); + pass.set_bind_group(1, &per_brush.dabs_bind_group_pickup, &[]); + pass.set_bind_group(2, pre_stroke_bg, &[]); + pass.set_bind_group(3, &deposit_read_bg, &[]); + pass.draw(0..6, i..i + 1); + } - // ── Pass 2: composite ── - { let mut pass = gpu.encoder.begin_render_pass(&wgpu::RenderPassDescriptor { label: Some("watercolor-composite"), - color_attachments: &[Some(wgpu::RenderPassColorAttachment { - view: scratch.write_view(), - resolve_target: None, - depth_slice: None, - ops: wgpu::Operations { - load: wgpu::LoadOp::Load, - store: wgpu::StoreOp::Store, - }, - })], + color_attachments: &attachments, ..Default::default() }); pass.set_viewport( @@ -877,8 +1157,8 @@ impl BrushNodeEvaluator for WatercolorEvaluator { ); pass.set_bind_group(1, &per_brush.dabs_bind_group_composite, &[]); pass.set_bind_group(2, gpu.selection_bind_group, &[]); - pass.set_bind_group(3, &per_brush.atlas_bind_group, &[]); - pass.draw(0..6, 0..total_dabs); + pass.set_bind_group(3, &group3_bind_group, &[]); + pass.draw(0..6, i..i + 1); } }); @@ -936,21 +1216,65 @@ impl BrushNodeEvaluator for WatercolorEvaluator { let deposit_expr = cctx.input("deposit").as_f32(); let wetness_expr = cctx.input("wetness").as_f32(); + wgsl.terminal_outputs = vec![DEPOSIT_CHANNEL.name.to_string()]; wgsl.terminal_bindings = "@group(3) @binding(0) var atlas_tex: texture_2d;\n\ - @group(3) @binding(1) var atlas_smp: sampler;\n" + @group(3) @binding(1) var atlas_smp: sampler;\n\ + @group(3) @binding(2) var deposit_tex: texture_2d;\n" .to_string(); // Atlas dimensions are baked into the shader — the per-brush // pipeline owns its own 128×128 atlas, so embedding the // constants avoids one more uniform field. If we ever vary // atlas size per brush, move these into the composite // uniforms. + // + // **A dab is one solid colour.** `load_rgb` and `load_alpha` are + // resolved before the dab goes down and are constant across its + // whole footprint; the only thing that varies per fragment is + // `fg_a`, the shape's coverage. The stamp then composites onto the + // scratch in the ROP. + // + // That is why `prior` is read at `d.pos` — the dab's own centre — + // and not at `target_pos`. Reading the deposit per *fragment* makes + // `load_rgb` vary across the footprint, and since neighbouring dabs + // then disagree about the colour of the texels they share, the mark + // comes out mottled at dab frequency even though the deposit field + // itself is smooth. One read per dab keeps each stamp flat, and the + // gradient across the stroke comes from consecutive dabs differing + // by one `deposit` step, which is what makes it look continuous. + // + // **`deposit` is per pass of the brush, not per dab.** An artist + // setting 30% means a stroke over black leaves 30% grey, and that + // has to hold whatever the spacing is. A dab is not a unit anyone + // can see: at the default 10% spacing ten of them land on every + // texel, so charging `deposit` once per dab compounds it ten times + // and 30% arrives as 87%. Worse, spacing is a fraction of dab + // *diameter* and diameter tracks pressure, so the overlap count — + // and with it the meaning of the knob — drifts inside a single + // stroke. + // + // per_dab = 1 − (1 − deposit)^(1/dabs_per_pass) + // + // inverts that exactly: `dabs_per_pass` of them compose back to + // `deposit`, and the result no longer depends on how finely the + // stroke was subdivided. + // + // `rate` deliberately carries no `mask`. The shape's falloff would + // make the outer dabs deliver less than `per_dab`, so a pass would + // land short of `deposit` by an amount set by the tip's softness — + // the knob would drift again, this time per brush. The mark still + // gets its soft edge, from `fg_a`; what the channel records is how + // many times the brush passed over a texel, which is the quantity + // the rate is stated against. Coverage stays in `fg_a`, delivery + // stays in `rate`. + // + // `flow` scales the delivery rate only, and is not folded into + // `fg_color.a` as well — one knob, counted once. wgsl.body = format!( " let mask = clamp({mask_expr}, 0.0, 1.0);\n\ \x20 if (mask <= 0.0) {{ discard; }}\n\ \x20 if (sel <= 0.0) {{ discard; }}\n\ - \x20 var fg_color: vec4 = {color_expr};\n\ + \x20 let fg_color: vec4 = {color_expr};\n\ \x20 let flow = clamp({flow_expr}, 0.0, 1.0);\n\ - \x20 fg_color.a = fg_color.a * flow;\n\ \x20 let deposit = clamp({deposit_expr}, 0.0, 1.0);\n\ \x20 let wetness = clamp({wetness_expr}, 0.0, 1.0);\n\ \x20 let atlas_w: u32 = {atlas_w}u;\n\ @@ -962,10 +1286,23 @@ impl BrushNodeEvaluator for WatercolorEvaluator { \x20 let pickup = textureSampleLevel(atlas_tex, atlas_smp, atlas_uv, 0.0);\n\ \x20 let has_canvas = pickup.a > 0.05;\n\ \x20 let canvas_rgb = select(fg_color.rgb, pickup.rgb, has_canvas);\n\ - \x20 let load_rgb = mix(canvas_rgb, fg_color.rgb, deposit);\n\ - \x20 let load_alpha = mix(pickup.a, fg_color.a, deposit);\n\ + \x20 let dab_local = d.pos - vec2(\n\ + \x20 f32(u.intrinsic.layer_offset.x),\n\ + \x20 f32(u.intrinsic.layer_offset.y),\n\ + \x20 );\n\ + \x20 let prior = textureLoad(deposit_tex,\n\ + \x20 vec2(atlas_x, atlas_y), 0).r;\n\ + \x20 let per_dab = 1.0 - pow(1.0 - deposit,\n\ + \x20 1.0 / max(u.intrinsic.dabs_per_pass, 1.0));\n\ + \x20 let rate = sel * flow * per_dab;\n\ + \x20 let laid = prior + (1.0 - prior) * rate;\n\ + \x20 let load_rgb = mix(canvas_rgb, fg_color.rgb, laid);\n\ + \x20 let load_alpha = mix(pickup.a, fg_color.a, laid);\n\ \x20 let fg_a = mask * sel * wetness * load_alpha;\n\ - \x20 return vec4(load_rgb * fg_a, fg_a);\n", + \x20 return FsOut(\n\ + \x20 vec4(load_rgb * fg_a, fg_a),\n\ + \x20 vec4(rate, 0.0, 0.0, rate),\n\ + \x20 );\n", atlas_w = ATLAS_WIDTH, atlas_h = ATLAS_HEIGHT, ); diff --git a/crates/darkly/src/brush/pipeline.rs b/crates/darkly/src/brush/pipeline.rs index 6cc3fd7e..7f3eef39 100644 --- a/crates/darkly/src/brush/pipeline.rs +++ b/crates/darkly/src/brush/pipeline.rs @@ -1079,18 +1079,12 @@ fn build_cursor_preview_pipeline( label: Some("brush-preview-shader"), source: wgpu::ShaderSource::Wgsl(compiled.cursor_preview_wgsl.clone().into()), }); - // `@group(3)` texture names for the preview pipeline. A brush that - // samples the frozen `clone_source` snapshot (`samples_source`) has - // no live snapshot at hover, so the source slot is filled with the - // registry `_fallback` tile — the shader body samples it and the - // cursor thumbnail comes out neutral. Named graph textures resolve - // normally; the source slot sits after them (see `assemble_shader`). - let mut preview_sources = compiled.graph_sources.clone(); - if compiled.samples_source { - preview_sources.push(crate::brush::texture_source::ResolvedSource::Named( - crate::gpu::texture_registry::FALLBACK_TEXTURE.to_string(), - )); - } + // `@group(3)` texture slots for the preview pipeline. Hover has no + // stroke and no dabs, so nothing is published for a live slot and + // `make_bind_group` resolves it to the registry `_fallback` tile — + // the shader body samples it and the cursor thumbnail comes out + // neutral. Named and baked slots resolve normally. + let preview_sources = compiled.graph_sources.clone(); // Pipeline layout. When the brush samples graph textures, slot 3 // holds the registry-resolved bind group; the preview shader @@ -1193,7 +1187,7 @@ fn build_cursor_preview_pipeline( None } else { let (_layout, bg) = - texture_registry.make_bind_group(device, queue, baked_sources, &preview_sources); + texture_registry.make_bind_group(device, queue, baked_sources, &preview_sources, &[]); // Per-pipeline empty bind group bound at @group(2) (matches // the cache's `empty_bgl`). Cheap to create — no GPU // resources — and keeps `render` self-contained without diff --git a/crates/darkly/src/brush/read_mirror_terminal.rs b/crates/darkly/src/brush/read_mirror_terminal.rs index 029f98fb..92c29ad3 100644 --- a/crates/darkly/src/brush/read_mirror_terminal.rs +++ b/crates/darkly/src/brush/read_mirror_terminal.rs @@ -747,6 +747,7 @@ mod tests { stroke_seed: 0, dab_index: 0, base_size, + dabs_per_pass: 1.0, node_id: TEST_NODE_ID.get_or_init(|| NodeId("test".into())), } } diff --git a/crates/darkly/src/brush/scratch.rs b/crates/darkly/src/brush/scratch.rs index 39022ff6..bfcef34c 100644 --- a/crates/darkly/src/brush/scratch.rs +++ b/crates/darkly/src/brush/scratch.rs @@ -93,6 +93,55 @@ pub struct Scratch { /// pixels (see [`crate::brush::warp_field`]) and declare their own /// format on [`crate::brush::node::BrushNodeRegistration`]. format: wgpu::TextureFormat, + + /// Per-pixel quantities a terminal accumulates alongside the write + /// side (see [`StrokeChannels`]). `None` until a terminal asks via + /// [`Scratch::ensure_channels`]; terminals that declare none pay + /// nothing. + channels: Option, +} + +/// One extra per-pixel quantity a terminal accumulates over a stroke. +/// +/// The write side carries coverage and nothing else — premultiplied +/// source-over saturating at 1. A terminal that needs to *remember* +/// something per pixel across the stroke declares a channel: it becomes +/// another color attachment on the terminal's existing draw, so the +/// blend unit accumulates it under `blend` for free, with no extra pass. +/// +/// The framework has no opinion on what a channel means. `name` is the +/// terminal's own vocabulary — watercolor's is `"deposit"` — and appears +/// in the generated `FsOut` struct as the field the terminal's body +/// writes. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct StrokeChannel { + /// WGSL identifier for the `FsOut` field, and the debug label stem. + pub name: &'static str, + pub format: wgpu::TextureFormat, + /// How the blend unit folds each dab's contribution into the running + /// value. Source-over gives `1 − Π(1−aᵢ)`, which is order-invariant + /// and therefore immune to how dabs are grouped into draws. + pub blend: wgpu::BlendState, +} + +/// The allocated realization of a terminal's declared channels. +/// +/// Managed atomically with the write side: allocated together, cleared +/// together, grown together, at the same dimensions and the same +/// canvas-anchored offset. There is no API by which a caller can resize +/// one without the other. +/// +/// A channel is written as a color attachment and read from a pass that +/// does *not* target it — watercolor's per-dab probe reads the deposit +/// while rendering to its atlas. That is an ordinary pass-to-pass +/// dependency, not the read/write alias WebGPU forbids, so a channel needs +/// no mirror. +struct StrokeChannels { + declared: Vec, + textures: Vec, + /// Attachment views, in declaration order — what the terminal hangs + /// off its render pass after [`Scratch::write_view`]. + views: Vec, } impl Scratch { @@ -161,7 +210,66 @@ impl Scratch { read_mirror_sampler, write_sampler, format, + channels: None, + } + } + + /// Allocate the terminal's declared channels if they aren't already, + /// clearing each to zero at the moment of allocation. + /// + /// Idempotent — safe to call every flush; only a first call, or one + /// whose declaration differs from what is allocated, does work. + /// + /// Clearing here rather than relying on the stroke prologue matters: + /// [`Lifecycle::ClearScratchToTransparent`] runs in `begin_stroke`, + /// before any flush, so on a stroke's first flush there is nothing + /// allocated for it to have cleared. A rewind, by contrast, clears + /// channels that already exist. Clearing at allocation makes the two + /// paths agree. + /// + /// [`Lifecycle::ClearScratchToTransparent`]: crate::brush::node::Lifecycle::ClearScratchToTransparent + pub fn ensure_channels( + &mut self, + device: &wgpu::Device, + encoder: &mut wgpu::CommandEncoder, + declared: &[StrokeChannel], + ) { + if declared.is_empty() { + return; } + if self + .channels + .as_ref() + .is_some_and(|c| c.declared == declared) + { + return; + } + let channels = build_channels(device, self.write_w, self.write_h, declared); + clear_channel_views(encoder, &channels.views); + self.channels = Some(channels); + } + + /// Attachment views for the declared channels, in declaration order — + /// what a terminal hangs off its render pass after + /// [`Scratch::write_view`]. Empty when none are declared. + pub fn channel_views(&self) -> &[wgpu::TextureView] { + self.channels.as_ref().map_or(&[], |c| &c.views) + } + + /// The channel textures, in declaration order. + /// + /// The checkpoint ring snapshots these alongside the write side: a + /// rewind that restores the scratch but not the channels replays the + /// post-checkpoint dabs onto a channel that already counted them. + pub fn channel_textures(&self) -> &[wgpu::Texture] { + self.channels.as_ref().map_or(&[], |c| &c.textures) + } + + /// Formats of [`Scratch::channel_textures`], in the same order. + pub fn channel_formats(&self) -> Vec { + self.channels + .as_ref() + .map_or_else(Vec::new, |c| c.declared.iter().map(|d| d.format).collect()) } pub fn write_texture(&self) -> &wgpu::Texture { @@ -181,17 +289,25 @@ impl Scratch { /// on the terminal's declared lifecycle, so the four terminals no /// longer carry a copy-pasted prologue each. pub fn clear_to_transparent(&self, encoder: &mut wgpu::CommandEncoder) { - let _ = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { - label: Some("scratch-clear-transparent"), - color_attachments: &[Some(wgpu::RenderPassColorAttachment { - view: &self.write_view, + // Channels clear alongside the write side. A channel surviving a + // stroke start or a rewind boundary would let dabs that no longer + // exist keep contributing to what the next dab reads. + let mut attachments: Vec> = + Vec::with_capacity(1 + self.channel_views().len()); + for view in std::iter::once(&self.write_view).chain(self.channel_views()) { + attachments.push(Some(wgpu::RenderPassColorAttachment { + view, resolve_target: None, depth_slice: None, ops: wgpu::Operations { load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT), store: wgpu::StoreOp::Store, }, - })], + })); + } + let _ = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("scratch-clear-transparent"), + color_attachments: &attachments, ..Default::default() }); } @@ -384,6 +500,27 @@ impl Scratch { &self.write_sampler, ); + // Channels rebase identically — same target size, same canvas- + // anchored offset — so they stay addressable at the write side's + // layer-local coordinates. An in-flight stroke's accumulated + // quantities are as unrecoverable as its pixels, so contents are + // preserved rather than recreated. + if let Some(old) = self.channels.take() { + let grown = build_channels(device, target_w, target_h, &old.declared); + for (src, dst) in old.textures.iter().zip(&grown.textures) { + copy_region_offset( + encoder, + src, + dst, + self.write_w, + self.write_h, + dst_offset_x, + dst_offset_y, + ); + } + self.channels = Some(grown); + } + self.write_texture = new_texture; self.write_view = new_view; self.write_bind_group = new_bind_group; @@ -416,6 +553,114 @@ impl Scratch { } } +/// Allocate every declared channel plus its mirror at `(width, height)`. +/// +/// Both sides are layer-sized: the accumulation texture because it must +/// stay addressable at the write side's layer-local coordinates, the +/// mirror so a dab can sample it without an origin translation. +fn build_channels( + device: &wgpu::Device, + width: u32, + height: u32, + declared: &[StrokeChannel], +) -> StrokeChannels { + let mut textures = Vec::with_capacity(declared.len()); + let mut views = Vec::with_capacity(declared.len()); + + for channel in declared { + let texture = device.create_texture(&wgpu::TextureDescriptor { + label: Some(&format!("scratch-channel-{}", channel.name)), + size: wgpu::Extent3d { + width, + height, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: channel.format, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT + | wgpu::TextureUsages::COPY_SRC + | wgpu::TextureUsages::COPY_DST + | wgpu::TextureUsages::TEXTURE_BINDING, + view_formats: &[], + }); + views.push(texture.create_view(&wgpu::TextureViewDescriptor::default())); + textures.push(texture); + } + + StrokeChannels { + declared: declared.to_vec(), + textures, + views, + } +} + +/// Zero every channel attachment in one clear pass. +fn clear_channel_views(encoder: &mut wgpu::CommandEncoder, views: &[wgpu::TextureView]) { + if views.is_empty() { + return; + } + let attachments: Vec> = views + .iter() + .map(|view| { + Some(wgpu::RenderPassColorAttachment { + view, + resolve_target: None, + depth_slice: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT), + store: wgpu::StoreOp::Store, + }, + }) + }) + .collect(); + let _ = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("scratch-channel-clear"), + color_attachments: &attachments, + ..Default::default() + }); +} + +/// Copy all of `src` into `dst` at a canvas-anchored destination offset — +/// the growth rebase, matching [`Scratch::grow_write`]'s own blit. +fn copy_region_offset( + encoder: &mut wgpu::CommandEncoder, + src: &wgpu::Texture, + dst: &wgpu::Texture, + w: u32, + h: u32, + dst_offset_x: u32, + dst_offset_y: u32, +) { + if w == 0 || h == 0 { + return; + } + encoder.copy_texture_to_texture( + wgpu::TexelCopyTextureInfo { + texture: src, + mip_level: 0, + origin: wgpu::Origin3d::ZERO, + aspect: wgpu::TextureAspect::All, + }, + wgpu::TexelCopyTextureInfo { + texture: dst, + mip_level: 0, + origin: wgpu::Origin3d { + x: dst_offset_x, + y: dst_offset_y, + z: 0, + }, + aspect: wgpu::TextureAspect::All, + }, + wgpu::Extent3d { + width: w, + height: h, + depth_or_array_layers: 1, + }, + ); +} + fn create_write_texture( device: &wgpu::Device, width: u32, diff --git a/crates/darkly/src/brush/stroke_engine.rs b/crates/darkly/src/brush/stroke_engine.rs index 326a9559..7b9e2b65 100644 --- a/crates/darkly/src/brush/stroke_engine.rs +++ b/crates/darkly/src/brush/stroke_engine.rs @@ -113,6 +113,16 @@ impl StrokeEngine { // see one consistent value. runner.set_base_size(base_size); + // How many dabs land on one texel as the brush passes over it once. + // A texel is inside every dab whose centre is within a radius of it, + // so that is one diameter of travel divided by the step — at the + // default 10% spacing, ten. Terminals accumulating a per-dab + // quantity divide their rate by this so the knob means "per pass" + // and stops moving when the spacing setting does. + let diameter = base_size * DAB_REFERENCE_SIZE as f32; + let step = spacing.distance(diameter); + runner.set_dabs_per_pass((diameter / step).max(1.0)); + let d = Self::default_diameter(); Self { runner, diff --git a/crates/darkly/src/brush/texture_source.rs b/crates/darkly/src/brush/texture_source.rs index f60d80ec..20583646 100644 --- a/crates/darkly/src/brush/texture_source.rs +++ b/crates/darkly/src/brush/texture_source.rs @@ -13,15 +13,21 @@ //! parameters (the `noise` node, when its field is static). Baking turns an //! ~80-hash per-fragment fBm kernel — re-run per canvas pixel per //! overlapping dab — into a single `textureSample`. +//! - [`ResolvedSource::Live`] — a texture the requesting node republishes +//! every flush (`clone_source`'s stroke snapshot, `pickup`'s per-dab +//! atlas). Resolved at bind time from the live table, so the slot +//! survives the texture being reallocated mid-stroke, and falls back to +//! `_fallback` when nothing has been published — which is what makes the +//! cursor preview neutral without a special case. //! -//! Both converge on the identical emission; the only divergence is a two-arm -//! match at the single bind point (`make_bind_group`). This is data only — no -//! trait, no registry. When a *third* bakeable field lands (e.g. a procedural -//! paper/hatch source), promote [`BakeKind`] to a `Bakeable` trait with -//! per-variant files, mirroring `gpu/veils/*`; two arms in one function is not -//! yet a subsystem. - -/// How a `@group(3)` slot resolves to a bound texture at build time. +//! All three converge on the identical emission; the only divergence is a +//! three-arm match at the single bind point (`make_bind_group`). This is data +//! only — no trait, no registry. When a *third* bakeable field lands (e.g. a +//! procedural paper/hatch source), promote [`BakeKind`] to a `Bakeable` trait +//! with per-variant files, mirroring `gpu/veils/*`; the arms here are one +//! expression each and are not yet a subsystem. + +/// How a `@group(3)` slot resolves to a bound texture. #[derive(Clone, Debug, PartialEq, Eq, Hash)] pub enum ResolvedSource { /// A texture already in the registry, by name (the `image` node). @@ -29,6 +35,10 @@ pub enum ResolvedSource { /// A procedural tile to bake-or-reuse, keyed by field-defining /// parameters (the `noise` node, static-field path). Baked(BakeSpec), + /// A texture the requesting node republishes every flush. Resolved at + /// bind time from the live table rather than at pipeline-build time, + /// so the slot survives the texture being reallocated mid-stroke. + Live(LiveSource), } impl ResolvedSource { @@ -38,6 +48,46 @@ impl ResolvedSource { match self { ResolvedSource::Named(name) => name.clone(), ResolvedSource::Baked(spec) => format!("", spec.kind.label()), + ResolvedSource::Live(live) => format!("", live.label()), + } + } + + /// Whether this slot is republished per flush. A brush with any live + /// slot cannot cache its `@group(3)` bind group on the pipeline. + pub fn is_live(&self) -> bool { + matches!(self, ResolvedSource::Live(_)) + } +} + +/// A `@group(3)` texture supplied fresh once per flush by the node that +/// requested it, rather than resolved against the registry or the bake +/// cache at pipeline-build time. +/// +/// Each producer publishes its view through +/// [`crate::brush::gpu_context::BrushGpuContext::publish_live_texture`] +/// during its own `flush_dabs`, which the runner dispatches in topological +/// order — so a producer upstream of the terminal has always published by +/// the time the terminal binds. A slot with nothing published falls back to +/// the registry's `_fallback` tile, which is what makes the cursor preview +/// (no stroke, no dabs, nothing published) render neutrally with no +/// special-casing in the preview pipeline. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum LiveSource { + /// The stroke's frozen source snapshot — the cross-layer / merged + /// snapshot when one was captured, else the pre-stroke snapshot + /// (same-layer clone). Published by `clone_source`. + StrokeSnapshot, + /// The per-dab pickup atlas: one texel per dab holding the + /// neighbourhood average of the dry canvas under it. Published by + /// `pickup`, which renders it in its own `flush_dabs`. + PickupAtlas, +} + +impl LiveSource { + fn label(&self) -> &'static str { + match self { + LiveSource::StrokeSnapshot => "stroke snapshot", + LiveSource::PickupAtlas => "pickup atlas", } } } diff --git a/crates/darkly/src/brush/wgsl/context.rs b/crates/darkly/src/brush/wgsl/context.rs index 10d4c9f1..a400f870 100644 --- a/crates/darkly/src/brush/wgsl/context.rs +++ b/crates/darkly/src/brush/wgsl/context.rs @@ -12,7 +12,7 @@ use std::cell::RefCell; use std::collections::{HashMap, HashSet}; use crate::brush::input_value::InputValue; -use crate::brush::texture_source::ResolvedSource; +use crate::brush::texture_source::{LiveSource, ResolvedSource}; use crate::brush::wgsl::type_system::{DabField, UniformField}; use crate::brush::wire::BrushWireType; use crate::nodegraph::{NodeId, PortDef, PortDir}; @@ -57,6 +57,19 @@ pub struct NodeWgsl { /// extension scoped to the one node that uses it instead of /// extending the `BrushNodeEvaluator` trait surface. pub terminal_bindings: String, + /// Extra fragment-shader outputs the terminal writes beyond + /// `@location(0)`, named in location order starting at 1. A + /// non-empty list switches `fs_main` from a bare + /// `-> @location(0) vec4` to a generated `FsOut` struct, and + /// the terminal's `body` must then return `FsOut(...)`. + /// + /// These are the per-texel accumulators a terminal writes alongside + /// the stroke scratch in one instanced draw — the blend unit + /// accumulates each under its own law, and the terminal's pipeline + /// declares a matching colour target per name. Stroke mode only: + /// the cursor-preview skeleton has no accumulators to write and + /// keeps the single-output signature. + pub terminal_outputs: Vec, } // ── Input binding ─────────────────────────────────────────────────────── @@ -177,14 +190,6 @@ pub struct CompileWgslCtx<'a> { /// `RefCell` so `compile_wgsl(&self)` can append without forcing a /// `&mut CompileWgslCtx` rewrite across every existing node. pub graph_sources: &'a RefCell>, - /// Set to `true` by [`Self::request_source_texture`] when a node - /// (currently `clone_source`) asks to sample the frozen pre-stroke - /// snapshot at `@group(3)`. The compiler copies the final value onto - /// [`crate::brush::wgsl::CompiledBrush::samples_source`]; the terminal - /// pipeline (`paint`) reads it to bind the snapshot per flush, and the - /// engine reads it for the no-op / gesture-arming gate. `RefCell` for - /// the same `&self`-append reason as `graph_sources`. - pub samples_source: &'a RefCell, } impl CompileWgslCtx<'_> { @@ -257,21 +262,18 @@ impl CompileWgslCtx<'_> { self.request_source(ResolvedSource::Named(name.to_string())) } - /// Reserve the `@group(3)` slot holding the frozen pre-stroke source - /// snapshot (the `clone_source` node's read texture) and flag the - /// compiled brush as source-sampling. Returns the slot index to - /// reference in emitted WGSL as `graph_tex_{slot}` (shared sampler - /// `graph_smp`), positioned after any named graph textures. + /// Reserve (or look up) a slot for a texture the requesting node + /// republishes every flush — the [`ResolvedSource::Live`] shim over + /// [`Self::request_source`]. /// - /// Unlike [`Self::request_texture`], the source is not resolved - /// against the [`crate::gpu::texture_registry::TextureRegistry`] — - /// the terminal binds the live per-stroke snapshot view instead (see - /// `paint`'s `flush_dabs`). The compiler rejects graphs that combine - /// a source-sampling node with named graph textures, so the returned - /// slot is always `0` today. - pub fn request_source_texture(&self) -> u32 { - *self.samples_source.borrow_mut() = true; - self.graph_sources.borrow().len() as u32 + /// Unlike [`Self::request_texture`], the view is not resolved against + /// the [`crate::gpu::texture_registry::TextureRegistry`] at + /// pipeline-build time; the producing node publishes it during its own + /// `flush_dabs` and the terminal binds whatever is there. A slot with + /// nothing published falls back to `_fallback`, which is how the + /// cursor preview renders without a stroke. + pub fn request_live_texture(&self, live: LiveSource) -> u32 { + self.request_source(ResolvedSource::Live(live)) } } diff --git a/crates/darkly/src/brush/wgsl/intrinsics.rs b/crates/darkly/src/brush/wgsl/intrinsics.rs index 0c3f8452..b9298923 100644 --- a/crates/darkly/src/brush/wgsl/intrinsics.rs +++ b/crates/darkly/src/brush/wgsl/intrinsics.rs @@ -33,10 +33,22 @@ pub struct IntrinsicUniforms { /// rotation — on-screen orientation stays put as the user rotates the /// view. See `_prelude.wgsl` and `wgsl/mod.rs::assemble_shader`. pub view_rotation: f32, + /// How many dabs land on a given texel as the brush passes over it + /// once: `diameter / spacing`. Stroke-constant, published by + /// [`crate::brush::stroke_engine::StrokeEngine`], which owns the + /// spacing that produced it. + /// + /// A terminal accumulating a per-dab quantity divides by this to + /// express its rate per *pass* instead of per dab. Without it a + /// "30% deposit" knob compounds once per dab — ten times over at the + /// default 10% spacing — so it reads as 87%, and its meaning shifts + /// whenever spacing or pressure changes the overlap count. 1.0 when + /// unset, which makes the normalisation a no-op. + pub dabs_per_pass: f32, /// Pads `IntrinsicUniforms` to 64 bytes (a multiple of 16) so the /// node-contributed uniforms packed after `intrinsic` keep 16-byte /// alignment. See the matching note in `_prelude.wgsl`. - pub _pad: [u32; 3], + pub _pad: [u32; 2], } /// Size in bytes of the WGSL/Rust `IntrinsicUniforms` struct. Read by diff --git a/crates/darkly/src/brush/wgsl/mod.rs b/crates/darkly/src/brush/wgsl/mod.rs index 9a971809..5e37c278 100644 --- a/crates/darkly/src/brush/wgsl/mod.rs +++ b/crates/darkly/src/brush/wgsl/mod.rs @@ -131,22 +131,14 @@ pub struct CompiledBrush { pub brush_extent_extra_px: f32, /// The `@group(3)` texture slots this graph requests, in /// `@binding(1+N)` order. Each is a [`crate::brush::texture_source::ResolvedSource`] - /// — a named registry texture (`image`) or a baked procedural tile - /// (`noise`). Empty for graphs without graph-texture nodes. Resolved - /// at pipeline-build time (named → `TextureRegistry`, baked → the - /// bake cache); deduplicated by the compiler so two nodes sampling - /// the same texture or baking the same field share one binding. + /// — a named registry texture (`image`), a baked procedural tile + /// (`noise`), or a live per-flush texture (`clone_source`'s stroke + /// snapshot, `pickup`'s atlas). Empty for graphs without + /// graph-texture nodes. Named and baked slots resolve at + /// pipeline-build time; live slots resolve at bind time from whatever + /// their producing node published this flush. Deduplicated by the + /// compiler so two nodes requesting the same source share one binding. pub graph_sources: Vec, - /// `true` when a node in this graph (`clone_source`) requested the - /// frozen pre-stroke source snapshot via - /// [`CompileWgslCtx::request_source_texture`]. Drives three - /// consumers off one derived fact: `paint`'s `flush_dabs` binds the - /// snapshot at `@group(3)`, the engine skips creating a stroke with - /// no source set, and the frontend arms the set-source gesture. Both - /// shaders declare the source binding in both modes; the neutral - /// preview body never samples it (the preview pipeline binds the - /// registry `_fallback` tile to that unread slot). - pub samples_source: bool, } impl std::fmt::Debug for CompiledBrush { @@ -247,33 +239,26 @@ pub fn compile_brush_to_wgsl( // (e.g. `watercolor`'s pickup atlas). Preview mode omits // these — the preview body doesn't sample scratch / atlas. let mut terminal_bindings = String::new(); - - // Graph-texture names contributed by `image`-style nodes, in the - // order each new name was first requested. Each node mutates - // this through `CompileWgslCtx::request_texture`; sharing the - // accumulator across the walk gives stable, dedup'd slot indices - // so two nodes sampling the same paper share a binding. + let mut terminal_outputs: Vec = Vec::new(); + + // `@group(3)` slots contributed by `image` / `noise` / live-texture + // nodes, in the order each distinct source was first requested. Each + // node mutates this through `CompileWgslCtx::request_texture` / + // `request_live_texture`; sharing the accumulator across the walk + // gives stable, dedup'd slot indices so two nodes sampling the same + // paper share a binding. + // + // The preview walk shares this same accumulator, which is what makes + // `graph_tex_N` mean the same slot in both compiled variants — + // `assemble_shader` declares the bindings once from this list for + // both. Requests dedup by value, so a preview body that re-requests + // a source its stroke body already asked for lands on the same index + // rather than allocating a second slot, and a node whose preview body + // declines to sample (e.g. `clone_source`'s neutral fill) simply + // leaves the declared slot unread. let graph_sources_cell: std::cell::RefCell> = std::cell::RefCell::new(Vec::new()); - // Flagged by `clone_source` through `CompileWgslCtx::request_source_texture` - // during the walk; read out onto `CompiledBrush::samples_source` after. - let samples_source_cell: std::cell::RefCell = std::cell::RefCell::new(false); - - // Throwaway allocation cells for the preview recompile. The preview - // pass runs `compile_cursor_preview_body` for every step against these - // cells and consumes only the emitted `body`; the binding/uniform/dab - // layout comes solely from the stroke cells above. Walk order is - // identical, so a preview body that samples `graph_tex_N` resolves to - // the same slot the stroke pass declared. Isolating the accounting is - // what keeps a preview body that re-requests a texture (e.g. `image`'s - // default preview, which delegates to `compile_wgsl`) from - // double-counting into the stroke layout. - let preview_graph_sources_cell: std::cell::RefCell< - Vec, - > = std::cell::RefCell::new(Vec::new()); - let preview_samples_source_cell: std::cell::RefCell = std::cell::RefCell::new(false); - // Track each output port's emitted expression so downstream nodes // can substitute. let mut output_exprs: HashMap = HashMap::new(); @@ -375,7 +360,6 @@ pub fn compile_brush_to_wgsl( lut: lut.as_ref(), consumed_outputs, graph_sources: &graph_sources_cell, - samples_source: &samples_source_cell, }; let result = @@ -437,8 +421,7 @@ pub fn compile_brush_to_wgsl( inputs: preview_inputs, lut: lut.as_ref(), consumed_outputs: preview_consumed, - graph_sources: &preview_graph_sources_cell, - samples_source: &preview_samples_source_cell, + graph_sources: &graph_sources_cell, }; let preview_result = evaluator .compile_cursor_preview_body(&preview_cctx) @@ -466,6 +449,7 @@ pub fn compile_brush_to_wgsl( } terminal_bindings.push_str(&result.terminal_bindings); } + terminal_outputs.extend(result.terminal_outputs); // Register this node's outputs so downstream nodes can resolve // their wires. @@ -507,44 +491,6 @@ pub fn compile_brush_to_wgsl( let stroke_body = format!("{shared_body}{stroke_terminal_body}"); let preview_body = format!("{preview_shared_body}{preview_terminal_body}"); let graph_sources = graph_sources_cell.into_inner(); - let samples_source = samples_source_cell.into_inner(); - // The preview cells only exist to give preview bodies correct slot - // numbering during the walk; the stroke pass owns the actual layout - // (Approach A — the preview shader declares the same `@group(3)` - // bindings the stroke shader does, per `assemble_shader` below). In a - // correct compile they mirror the stroke cells exactly. - let preview_graph_sources = preview_graph_sources_cell.into_inner(); - debug_assert_eq!( - preview_graph_sources.len(), - graph_sources.len(), - "preview graph-texture allocation diverged from the stroke pass", - ); - let _ = preview_graph_sources; - let _ = preview_samples_source_cell.into_inner(); - // A source-sampling node (`clone_source`) owns `@group(3)` for the - // frozen pre-stroke snapshot, the same slot named graph textures and - // terminal-owned bindings use. Reject either combination now so the - // failure mode is "brush won't load" rather than a runtime binding - // mismatch; today `clone.yaml` uses neither, and the slot the source - // reserves is always 0. - if samples_source && !graph_sources.is_empty() { - return Err(CompileError::NodeNotCompilable { - type_id: "clone_source".into(), - reason: format!( - "graph combines a source-sampling node with `image` graph textures \ - ({}); this combination is not yet supported", - source_labels(&graph_sources) - ), - }); - } - if samples_source && !terminal_bindings.is_empty() { - return Err(CompileError::NodeNotCompilable { - type_id: "clone_source".into(), - reason: "graph combines a source-sampling node with a terminal that owns \ - @group(3) bindings; this combination is not yet supported" - .into(), - }); - } // `@group(3)` collision check. Terminal `terminal_bindings` // (e.g. watercolor's pickup atlas) and the `image` node's // graph textures both target group 3 — the highest slot WebGPU's @@ -571,9 +517,12 @@ pub fn compile_brush_to_wgsl( &decls, &stroke_body, &terminal_bindings, + &terminal_outputs, &graph_sources, - samples_source, ); + // The preview skeleton writes no accumulators — it renders a cursor + // thumbnail, not a stroke — so it keeps the single-output signature + // and pairs with `compile_cursor_preview_body`'s plain `vec4`. let cursor_preview_wgsl = assemble_shader( ShaderMode::CursorPreview, &dab_fields, @@ -581,8 +530,8 @@ pub fn compile_brush_to_wgsl( &decls, &preview_body, "", + &[], &graph_sources, - samples_source, ); // Topology hash: stable across runs (uses DefaultHasher; if process @@ -603,7 +552,6 @@ pub fn compile_brush_to_wgsl( brush_extent_factor, brush_extent_extra_px, graph_sources, - samples_source, }) } @@ -897,8 +845,8 @@ fn assemble_shader( node_decls: &str, fs_body: &str, terminal_bindings: &str, + terminal_outputs: &[String], graph_sources: &[crate::brush::texture_source::ResolvedSource], - samples_source: bool, ) -> String { let mut out = String::new(); // Shared canvas-window helpers (plane_to_selection_uv) — WGSL has no @@ -968,16 +916,14 @@ fn assemble_shader( // rejects graphs that try to claim both — see the early-return // check in [`compile_brush_to_wgsl`]. // - // The frozen `clone_source` snapshot (`samples_source`) shares - // group 3, bound at the slot after any named textures. It is - // declared in *both* modes — the `clone_source` body is a single - // non-terminal contribution shared by the stroke and preview - // skeletons, so the binding must exist wherever that body samples. - // The stroke pipeline binds the live per-stroke snapshot; the - // preview pipeline binds the registry's `_fallback` tile (hover has - // no snapshot), giving a neutral cursor thumbnail. - let source_slot = graph_sources.len(); - if !graph_sources.is_empty() || samples_source { + // Live slots (`clone_source`'s snapshot, `pickup`'s atlas) are + // ordinary entries in this list — they differ only in *when* the view + // is resolved, not in how the binding is emitted. They are declared in + // *both* modes, because a non-terminal node's body is shared by the + // stroke and preview skeletons and the binding must exist wherever + // that body samples. With no stroke, nothing is published and the + // preview binds `_fallback`, giving a neutral cursor thumbnail. + if !graph_sources.is_empty() { out.push_str("@group(3) @binding(0) var graph_smp: sampler;\n"); for (i, _) in graph_sources.iter().enumerate() { out.push_str(&format!( @@ -986,13 +932,6 @@ fn assemble_shader( i )); } - if samples_source { - out.push_str(&format!( - "@group(3) @binding({}) var graph_tex_{}: texture_2d;\n", - 1 + source_slot, - source_slot - )); - } } out.push('\n'); @@ -1015,8 +954,24 @@ fn assemble_shader( // differs between modes: stroke samples a real texture, preview // hard-codes 1.0 (the full footprint, ignoring any active // selection — matches master's preview behavior). - out.push_str("@fragment\n"); - out.push_str("fn fs_main(in: VsOut) -> @location(0) vec4 {\n"); + // A terminal that accumulates extra per-texel quantities alongside + // the scratch writes them as additional colour attachments on this + // same draw, so `fs_main` returns a struct instead of a bare vec4. + // The terminal's pipeline declares one colour target per output, in + // the same order, each with its own blend law. + if terminal_outputs.is_empty() { + out.push_str("@fragment\n"); + out.push_str("fn fs_main(in: VsOut) -> @location(0) vec4 {\n"); + } else { + out.push_str("struct FsOut {\n"); + out.push_str(" @location(0) color: vec4,\n"); + for (i, name) in terminal_outputs.iter().enumerate() { + out.push_str(&format!(" @location({}) {}: vec4,\n", i + 1, name)); + } + out.push_str("};\n\n"); + out.push_str("@fragment\n"); + out.push_str("fn fs_main(in: VsOut) -> FsOut {\n"); + } out.push_str(" let d = dabs[in.dab_idx];\n"); // `target_pos` is in the target texture's pixel space — canvas px // for stroke (target ≡ canvas), preview-mask texels for preview. diff --git a/crates/darkly/src/clipboard.rs b/crates/darkly/src/clipboard.rs index 1829686b..419e113a 100644 --- a/crates/darkly/src/clipboard.rs +++ b/crates/darkly/src/clipboard.rs @@ -54,28 +54,83 @@ impl Clipboard { /// regardless of variant. A flat `ImageData` clip returns its buffer /// directly; a rich `Layer` clip decodes its base64 pixels. Used by the /// paste-in-place floating path so it works for the `Layer` clip a normal - /// copy produces, not just flat image clips. Returns `None` if a rich - /// clip's pixels are malformed. + /// copy produces, not just flat image clips. + /// + /// Trimmed to the pasted object — see [`trim_to_content`]. What was copied + /// is the region the user swept, but what is *pasted* is the thing inside + /// it, and the floating session draws its bounding box from these + /// dimensions: untrimmed, a select-all copy hands the transform gizmo a + /// canvas-sized box around a small stroke. + /// + /// Returns `None` if a rich clip's pixels are malformed, or if the clip is + /// entirely transparent and so has nothing to paste. pub fn paste_pixels(&self) -> Option<(Vec, u32, u32, i32, i32)> { - match self { - Clipboard::ImageData(c) => { - Some((c.data.clone(), c.width, c.height, c.offset_x, c.offset_y)) - } + let (rgba, width, height, x, y) = match self { + Clipboard::ImageData(c) => (c.data.clone(), c.width, c.height, c.offset_x, c.offset_y), Clipboard::Layer(l) => { let pixels = l.decode_pixels().ok()?; if pixels.len() != (l.bounds.width * l.bounds.height * 4) as usize { return None; } - Some(( + ( pixels, l.bounds.width, l.bounds.height, l.bounds.x, l.bounds.y, - )) + ) + } + }; + trim_to_content(&rgba, width, height, x, y) + } +} + +/// Shrink a straight-alpha RGBA region to the bounding box of its +/// non-transparent pixels, moving the origin so the content keeps its position. +/// `None` when every pixel is transparent. +pub fn trim_to_content( + rgba: &[u8], + width: u32, + height: u32, + offset_x: i32, + offset_y: i32, +) -> Option<(Vec, u32, u32, i32, i32)> { + let (w, h) = (width as usize, height as usize); + if rgba.len() < w * h * 4 { + return None; + } + let (mut min_x, mut min_y) = (w, h); + let (mut max_x, mut max_y) = (0usize, 0usize); + for y in 0..h { + for x in 0..w { + if rgba[(y * w + x) * 4 + 3] != 0 { + min_x = min_x.min(x); + min_y = min_y.min(y); + max_x = max_x.max(x); + max_y = max_y.max(y); } } } + if min_x > max_x || min_y > max_y { + return None; + } + if (min_x, min_y, max_x, max_y) == (0, 0, w - 1, h - 1) { + return Some((rgba.to_vec(), width, height, offset_x, offset_y)); + } + + let (tw, th) = (max_x - min_x + 1, max_y - min_y + 1); + let mut out = Vec::with_capacity(tw * th * 4); + for y in min_y..=max_y { + let row = (y * w + min_x) * 4; + out.extend_from_slice(&rgba[row..row + tw * 4]); + } + Some(( + out, + tw as u32, + th as u32, + offset_x + min_x as i32, + offset_y + min_y as i32, + )) } // --------------------------------------------------------------------------- @@ -216,6 +271,40 @@ impl LayerClipboard { mod tests { use super::*; + /// What a paste puts down is the object, not the region the user swept to + /// copy it — a select-all copy of one small dab must not paste a + /// canvas-sized rect, because the floating session takes its bounding box + /// from these dimensions. + #[test] + fn trim_to_content_shrinks_to_opaque_pixels_and_shifts_the_origin() { + // 4×4, transparent but for one opaque texel at (2, 1). + let mut rgba = vec![0u8; 4 * 4 * 4]; + let texel = |x: usize, y: usize| (y * 4 + x) * 4; + let i = texel(2, 1); + rgba[i..i + 4].copy_from_slice(&[10, 20, 30, 255]); + + let (out, w, h, x, y) = trim_to_content(&rgba, 4, 4, 100, 200).expect("has content"); + assert_eq!((w, h), (1, 1)); + // Origin moves by the trimmed margin so the pixel keeps its position. + assert_eq!((x, y), (102, 201)); + assert_eq!(out, vec![10, 20, 30, 255]); + } + + #[test] + fn trim_to_content_keeps_a_full_bleed_clip_intact() { + let rgba = vec![255u8; 3 * 2 * 4]; + let (out, w, h, x, y) = trim_to_content(&rgba, 3, 2, -5, 7).expect("has content"); + assert_eq!((w, h, x, y), (3, 2, -5, 7)); + assert_eq!(out.len(), rgba.len()); + } + + /// Nothing opaque means nothing to paste — the caller treats `None` as "no + /// paste happened" rather than floating an empty rect. + #[test] + fn trim_to_content_rejects_a_fully_transparent_clip() { + assert!(trim_to_content(&[0u8; 2 * 2 * 4], 2, 2, 0, 0).is_none()); + } + #[test] fn round_trip_rgba() { let w = 4u32; diff --git a/crates/darkly/src/config/mod.rs b/crates/darkly/src/config/mod.rs index 04d0c770..dad1ad6f 100644 --- a/crates/darkly/src/config/mod.rs +++ b/crates/darkly/src/config/mod.rs @@ -525,6 +525,31 @@ mod tests { assert_eq!(get_str("hotkeys.addBrushNode"), "Shift+KeyA"); } + /// REGRESSION: `$mod`+click on a thumbnail loads its coverage as the + /// selection — the mask half on `maskThumb`, the layer half on + /// `layerThumb`. The layer half was unbound, so the gesture fell through + /// to the plain-click fallback and silently did nothing. Krita and + /// Photoshop both ship the chord (see each overlay's comments); GIMP uses + /// alt+click, whose slot is `isolateLayer`, so it stays menu-only there. + #[test] + fn thumbnail_to_selection_gestures_are_bound_in_krita_and_photoshop() { + for editor in ["Krita", "Photoshop"] { + reset_state(); + pick(editor); + assert_eq!( + get_str("mouseclicks.maskToSelection"), + "maskThumb:$mod+click", + "{editor}" + ); + assert_eq!( + get_str("mouseclicks.alphaToSelection"), + "layerThumb:$mod+click", + "{editor}" + ); + } + reset_state(); + } + #[test] fn user_wins_over_overlay_and_defaults() { reset_state(); diff --git a/crates/darkly/src/engine/filters/selection.rs b/crates/darkly/src/engine/filters/selection.rs index 9ec20d42..5dafca87 100644 --- a/crates/darkly/src/engine/filters/selection.rs +++ b/crates/darkly/src/engine/filters/selection.rs @@ -20,7 +20,7 @@ use super::super::rendering::commit_undo_region; use super::super::{DarklyEngine, OverlayChannel, ReadbackContext}; use crate::coord::{CanvasRect, WindowRect}; use crate::document::SelectionMode; -use crate::gpu::flood_fill::{self, LayerFloodFillExtent}; +use crate::gpu::layer_readback::{self, LayerReadbackExtent}; use crate::gpu::overlay::{OverlayPrimitive, FLAG_CANVAS_SPACE, KIND_DASHED_LINE}; use crate::gpu::readback; use crate::gpu::selection::{CombineMode, MorphOp}; @@ -328,7 +328,7 @@ impl DarklyEngine { label: Some("magic-wand-readback"), }); let (request, extent) = - flood_fill::request_layer_flood_fill_readback(&self.gpu.device, &mut encoder, &pt); + layer_readback::request_layer_readback(&self.gpu.device, &mut encoder, &pt); self.gpu.queue.submit([encoder.finish()]); self.readbacks.submit( request, @@ -350,13 +350,61 @@ impl DarklyEngine { seed_canvas: crate::coord::CanvasPoint, tolerance: u8, mode: SelectionMode, - extent: LayerFloodFillExtent, + extent: LayerReadbackExtent, pixels: Vec, ) { let fill_mask = extent.flood_fill_to_canvas_mask(&pixels, seed_canvas, tolerance); self.apply_selection_full(fill_mask, mode, was_active); } + /// Load a node's per-pixel opacity as the selection — Krita's "select + /// opaque", GIMP's "alpha to selection", the `$mod`+click-the-thumbnail + /// gesture. Node-kind agnostic: an RGBA layer contributes its alpha + /// channel, an R8 mask filter its coverage (see + /// [`LayerReadbackExtent::opacity_to_canvas_mask`]). A node with no + /// texture is a no-op. + /// + /// The pixels come back through the async readback pipeline, so the + /// selection lands on a later frame — same shape as magic wand, and for + /// the same reason (`CLAUDE.md`: no blocking GPU readbacks). + #[handler] + pub fn alpha_to_selection(&mut self, id: LayerId) { + if self.paint_target(id).is_none() { + return; + } + + let was_active = self.has_selection(); + // Whole-layer coverage lands anywhere in the window — reserve a + // full-canvas undo rect. + let rect = self.selection_full_canvas_rect(); + self.save_selection_for_undo(rect); + + let pt = self.paint_target(id).unwrap(); + let mut encoder = self + .gpu + .device + .create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some("alpha-to-selection-readback"), + }); + let (request, extent) = + layer_readback::request_layer_readback(&self.gpu.device, &mut encoder, &pt); + self.gpu.queue.submit([encoder.finish()]); + self.readbacks.submit( + request, + ReadbackContext::AlphaToSelection { was_active, extent }, + ); + } + + pub(crate) fn complete_alpha_to_selection( + &mut self, + was_active: bool, + extent: LayerReadbackExtent, + pixels: Vec, + ) { + let mask = extent.opacity_to_canvas_mask(&pixels); + self.apply_selection_full(mask, SelectionMode::Replace, was_active); + } + #[handler] pub fn clear_selection(&mut self) { if !self.resolve_transform_conflict() { diff --git a/crates/darkly/src/engine/mod.rs b/crates/darkly/src/engine/mod.rs index ea1f97ec..802d8ed7 100644 --- a/crates/darkly/src/engine/mod.rs +++ b/crates/darkly/src/engine/mod.rs @@ -160,9 +160,9 @@ pub(crate) enum ReadbackContext { /// the texture offset/size + canvas size + format the completion /// handler needs to translate `seed_canvas` from canvas coords to /// texture coords and project the resulting mask back into a - /// canvas-aligned R8 buffer. See - /// `crate::gpu::flood_fill::LayerFloodFillExtent`. - extent: crate::gpu::flood_fill::LayerFloodFillExtent, + /// window-local R8 buffer. See + /// `crate::gpu::layer_readback::LayerReadbackExtent`. + extent: crate::gpu::layer_readback::LayerReadbackExtent, }, ColorPick, Copy { @@ -177,7 +177,14 @@ pub(crate) enum ReadbackContext { tolerance: u8, mode: crate::document::SelectionMode, /// See `FloodFill::extent` — same coordinate-frame snapshot. - extent: crate::gpu::flood_fill::LayerFloodFillExtent, + extent: crate::gpu::layer_readback::LayerReadbackExtent, + }, + /// Readback of a node's pixels for "alpha to selection": the completion + /// handler projects their opacity into the selection. + AlphaToSelection { + was_active: bool, + /// See `FloodFill::extent` — same coordinate-frame snapshot. + extent: crate::gpu::layer_readback::LayerReadbackExtent, }, /// Async readback of the selection GPU texture for CPU cache update. SelectionReadback, @@ -1008,6 +1015,22 @@ impl DarklyEngine { ) } + /// Canvas-space rect a node's texture occupies — the frame the buffers from + /// [`Self::test_readback_layer`] / [`Self::test_readback_mask`] are laid out + /// in. For test assertions only. + /// + /// Those readbacks are texture-local and a texture is not canvas-sized (a + /// mask on a 64×64 canvas is backed by a 256×256 allocation), so a test + /// that wants the value at a canvas coordinate has to come through here for + /// the stride and origin rather than assuming the canvas's own dimensions. + #[cfg(any(test, feature = "testing"))] + pub fn test_node_extent(&self, node_id: LayerId) -> crate::coord::CanvasRect { + self.compositor + .node_texture(node_id) + .expect("node texture not found") + .canvas_extent() + } + /// Plant a persistent void frame (camera void's last webcam frame) into /// the void's aux texture — the same entry point the load path uses via /// `restore_void_pixels`. For test assertions only: on native there is no diff --git a/crates/darkly/src/engine/painting.rs b/crates/darkly/src/engine/painting.rs index ec139bc6..c18e98b1 100644 --- a/crates/darkly/src/engine/painting.rs +++ b/crates/darkly/src/engine/painting.rs @@ -12,7 +12,7 @@ use crate::brush::spacing::SpacingConfig; use crate::brush::stroke_buffer::StrokeBuffer; use crate::brush::stroke_engine::StrokeEngine; use crate::coord::CanvasRect; -use crate::gpu::flood_fill; +use crate::gpu::layer_readback; use crate::gpu::paint_target::{GpuPaintTarget, PaintPipelines}; use crate::gpu::region_store::UndoRegionEntry; use crate::layer::LayerId; @@ -1203,9 +1203,15 @@ impl DarklyEngine { texture: stroke_buffer.scratch().write_texture(), canvas_extent: paint_target.canvas_frame().canvas_extent, }; + // Stroke channels rewind with the scratch. A channel left + // holding contributions from dabs this rewind discarded + // would feed those values back to the dabs replayed over + // the same pixels. + let channels: Vec<&wgpu::Texture> = + stroke_buffer.scratch().channel_textures().iter().collect(); let restore = self.gpu.encode_ret("stroke-checkpoint-restore", |encoder| { self.checkpoint_ring - .restore_before(encoder, &stroke_frame, div_idx) + .restore_before(encoder, &stroke_frame, &channels, div_idx) }); self.brush_perf.submits = self.brush_perf.submits.saturating_add(1); @@ -1268,11 +1274,14 @@ impl DarklyEngine { texture: stroke_buffer.scratch().write_texture(), canvas_extent: paint_target.canvas_frame().canvas_extent, }; + let channels: Vec<&wgpu::Texture> = + stroke_buffer.scratch().channel_textures().iter().collect(); self.gpu.encode("checkpoint-save", |encoder| { self.checkpoint_ring.save( &self.gpu.device, encoder, &stroke_frame, + &channels, sp_idx, boundary, bbox, @@ -1313,11 +1322,14 @@ impl DarklyEngine { texture: stroke_buffer.scratch().write_texture(), canvas_extent: paint_target.canvas_frame().canvas_extent, }; + let channels: Vec<&wgpu::Texture> = + stroke_buffer.scratch().channel_textures().iter().collect(); self.gpu.encode("checkpoint-save", |encoder| { self.checkpoint_ring.save( &self.gpu.device, encoder, &stroke_frame, + &channels, sp_idx, tip_vi, bbox, @@ -1404,7 +1416,7 @@ impl DarklyEngine { label: Some("flood-fill-readback"), }); let (request, extent) = - flood_fill::request_layer_flood_fill_readback(&self.gpu.device, &mut encoder, &pt); + layer_readback::request_layer_readback(&self.gpu.device, &mut encoder, &pt); self.gpu.queue.submit([encoder.finish()]); self.readbacks.submit( request, @@ -1428,7 +1440,7 @@ impl DarklyEngine { seed_canvas: crate::coord::CanvasPoint, color: [u8; 4], tolerance: u8, - extent: flood_fill::LayerFloodFillExtent, + extent: layer_readback::LayerReadbackExtent, pixels: Vec, ) { let fill_mask = extent.flood_fill_to_canvas_mask(&pixels, seed_canvas, tolerance); diff --git a/crates/darkly/src/engine/rendering.rs b/crates/darkly/src/engine/rendering.rs index 94f9ca5d..baf9a5c6 100644 --- a/crates/darkly/src/engine/rendering.rs +++ b/crates/darkly/src/engine/rendering.rs @@ -387,6 +387,9 @@ impl DarklyEngine { pixels, ); } + ReadbackContext::AlphaToSelection { was_active, extent } => { + self.complete_alpha_to_selection(was_active, extent, pixels); + } ReadbackContext::ExportImage { width, height } => { self.complete_export(width, height, pixels); } diff --git a/crates/darkly/src/gpu/flood_fill.rs b/crates/darkly/src/gpu/flood_fill.rs index e2484d07..5c200d59 100644 --- a/crates/darkly/src/gpu/flood_fill.rs +++ b/crates/darkly/src/gpu/flood_fill.rs @@ -5,29 +5,14 @@ //! //! Flow: readback layer → CPU scanline fill → upload mask → GPU stamp. //! -//! ## Layer-aware orchestration ([`LayerFloodFillExtent`]) -//! -//! Magic wand and the paint-bucket fill tool both flood-fill a layer from a -//! canvas-space seed and consume the result as a canvas-aligned mask. The -//! GPU layer texture, however, is **not** in general canvas-aligned: it can -//! sit at a non-zero canvas offset and be larger or smaller than the canvas -//! (paste-extent layers, leftward-grown layers from `ensure_layer_covers_dab`, -//! masks parented to off-canvas raster layers). -//! -//! [`request_layer_flood_fill_readback`] + [`LayerFloodFillExtent`] are the -//! single place that owns the canvas↔texture translation: -//! the readback samples the texture's full extent, the canvas-space seed is -//! translated to texture-local coords for the scanline fill, and the -//! resulting layer-local mask is projected back into a canvas-aligned R8 -//! buffer. Both call sites consume the same `extent.flood_fill_to_canvas_mask` -//! helper so the math lives once. **Do not** call `request_readback` with a -//! canvas-rect from a flood-fill call site — go through this helper. +//! The fills here are layer-local: they consume and produce buffers in the +//! texture's own frame. Translating a canvas-space seed into that frame, and +//! the resulting mask back into window-local coordinates, belongs to +//! [`crate::gpu::layer_readback`] — which is what magic wand and the +//! paint-bucket tool actually call. use std::collections::VecDeque; -use crate::gpu::paint_target::GpuPaintTarget; -use crate::gpu::readback::{self, ReadbackRequest}; - /// Scanline flood fill on flat RGBA pixel data. /// /// Returns an R8 mask (width × height bytes): 255 where the fill should apply, 0 elsewhere. @@ -269,188 +254,10 @@ fn fill_span(mask: &mut [u8], width: u32, start: i32, end: i32, y: i32) { } } -// --------------------------------------------------------------------------- -// Layer-aware flood-fill orchestration -// --------------------------------------------------------------------------- - -/// Snapshot of a paint target's coordinate frame, captured at flood-fill -/// request time and carried through the async readback round-trip. -/// -/// Owns no GPU resources — pure metadata. Pairs with the readback request -/// returned by [`request_layer_flood_fill_readback`]: the request reads the -/// texture's full extent (`width × height` pixels starting at texture-local -/// (0,0)), and this struct provides the canvas↔texture translation on the -/// other side so callers receive a canvas-aligned R8 mask without re-deriving -/// the layer offset. -#[derive(Copy, Clone)] -pub struct LayerFloodFillExtent { - /// Plane-space offset of the texture's (0, 0) pixel. - pub offset_x: i32, - pub offset_y: i32, - /// Texture pixel dimensions — the size of the readback buffer. - pub width: u32, - pub height: u32, - /// Document canvas (window) dimensions — the size of the produced mask. - pub canvas_width: u32, - pub canvas_height: u32, - /// Plane-space origin of the canvas window. The produced mask is - /// **window-local** (it uploads into the window-sized selection texture), - /// so the projection subtracts this. `(0, 0)` for an un-cropped doc. - pub canvas_origin_x: i32, - pub canvas_origin_y: i32, - pub format: wgpu::TextureFormat, -} - -impl LayerFloodFillExtent { - pub fn from_target(target: &GpuPaintTarget<'_>) -> Self { - let canvas_extent = target.canvas_extent(); - let layer_extent = target.layer_extent(); - let (canvas_w, canvas_h) = target.canvas_size(); - let (cox, coy) = target.canvas_origin(); - Self { - offset_x: canvas_extent.x0(), - offset_y: canvas_extent.y0(), - width: layer_extent.width, - height: layer_extent.height, - canvas_width: canvas_w, - canvas_height: canvas_h, - canvas_origin_x: cox, - canvas_origin_y: coy, - format: target.format(), - } - } - - /// Run the CPU scanline fill on the texture-extent buffer and project the - /// resulting layer-local mask into a **window-local** R8 mask sized - /// `canvas_width × canvas_height` (the frame the selection texture is - /// indexed in — see `crate::coord`). - /// - /// `seed_canvas` is the click point in plane coordinates. The seed is - /// translated to texture-local coords before the scanline fill runs; the - /// result is projected to window-local (`plane − canvas_origin`). Pixels - /// outside the layer's canvas-window footprint stay 0 in the output. - /// - /// Format dispatch matches the texture's own format — RGBA reads four - /// bytes per pixel, R8 reads one. - pub fn flood_fill_to_canvas_mask( - &self, - pixels: &[u8], - seed_canvas: crate::coord::CanvasPoint, - tolerance: u8, - ) -> Vec { - let layer_seed_x = seed_canvas.x - self.offset_x; - let layer_seed_y = seed_canvas.y - self.offset_y; - - let layer_mask = match self.format { - wgpu::TextureFormat::R8Unorm => flood_fill_r8( - pixels, - self.width, - self.height, - layer_seed_x, - layer_seed_y, - tolerance, - ), - _ => flood_fill_rgba( - pixels, - self.width, - self.height, - layer_seed_x, - layer_seed_y, - tolerance, - ), - }; - - let cw = self.canvas_width as usize; - let ch = self.canvas_height as usize; - let mut canvas_mask = vec![0u8; cw * ch]; - - let (cox, coy) = (self.canvas_origin_x, self.canvas_origin_y); - // Plane-space bounds of the layer footprint clipped to the canvas - // WINDOW `[canvas_origin, canvas_origin + canvas_size]`; the output is - // written at the window-local texel `plane − canvas_origin`. - let x0 = self.offset_x.max(cox); - let y0 = self.offset_y.max(coy); - let x1 = (self.offset_x + self.width as i32).min(cox + self.canvas_width as i32); - let y1 = (self.offset_y + self.height as i32).min(coy + self.canvas_height as i32); - if x0 >= x1 || y0 >= y1 { - return canvas_mask; - } - - let stride = self.width as usize; - for py in y0..y1 { - let ty = (py - self.offset_y) as usize; // layer-local row - let src_row = ty * stride; - let dst_row = (py - coy) as usize * cw; // window-local row - for px in x0..x1 { - let tx = (px - self.offset_x) as usize; // layer-local col - let wx = (px - cox) as usize; // window-local col - canvas_mask[dst_row + wx] = layer_mask[src_row + tx]; - } - } - - canvas_mask - } -} - -/// Encode a readback of a layer's full texture extent and return the request -/// paired with the extent snapshot the completion handler needs. -/// -/// Single source of truth for the readback rect used by magic wand and the -/// paint-bucket flood fill. The rect is the texture's own dimensions, NOT -/// the canvas — see the module docs for why. -pub fn request_layer_flood_fill_readback( - device: &wgpu::Device, - encoder: &mut wgpu::CommandEncoder, - target: &GpuPaintTarget<'_>, -) -> (ReadbackRequest, LayerFloodFillExtent) { - let extent = LayerFloodFillExtent::from_target(target); - // Texture-local rect spanning the entire layer — the canvas↔texture - // translation happens later, in `flood_fill_to_canvas_mask`. - let request = readback::request_readback( - device, - encoder, - target.texture(), - target.format(), - target.layer_extent(), - ); - (request, extent) -} - #[cfg(test)] mod tests { use super::*; - /// REGRESSION: the produced mask is **window-local** — the magic-wand fill - /// must land where the window-sized selection texture expects it after a - /// crop, i.e. at `plane − canvas_origin`, not at the raw plane coordinate. - #[test] - fn flood_fill_mask_is_window_local_after_crop() { - // A small 2×2 R8 layer, fully opaque, sitting at plane (3, 2). - let pixels = vec![255u8; 2 * 2]; - let ext = LayerFloodFillExtent { - offset_x: 3, - offset_y: 2, - width: 2, - height: 2, - canvas_width: 6, - canvas_height: 6, - canvas_origin_x: 2, // cropped window starts at plane (2, 1) - canvas_origin_y: 1, - format: wgpu::TextureFormat::R8Unorm, - }; - - // Seed inside the layer (plane (3, 2)); uniform color floods all of it. - let mask = ext.flood_fill_to_canvas_mask(&pixels, crate::coord::CanvasPoint::new(3, 2), 0); - let at = |x: usize, y: usize| mask[y * 6 + x]; - - // Layer plane footprint [3,5)×[2,4) → window-local [1,3)×[1,3). - assert_eq!(at(1, 1), 255, "window-local origin of the fill"); - assert_eq!(at(2, 2), 255, "window-local far corner of the fill"); - // The pre-fix plane-anchored projection would have written here instead. - assert_eq!(at(3, 2), 0, "must NOT land at the raw plane coordinate"); - assert_eq!(at(0, 0), 0, "outside the fill stays empty"); - } - #[test] fn flood_fill_rgba_basic() { // 4×4 image: top-left 2×2 is red, rest is transparent. diff --git a/crates/darkly/src/gpu/layer_readback.rs b/crates/darkly/src/gpu/layer_readback.rs new file mode 100644 index 00000000..55338674 --- /dev/null +++ b/crates/darkly/src/gpu/layer_readback.rs @@ -0,0 +1,282 @@ +//! Layer-aware readback orchestration: the canvas↔texture↔window translation +//! every op that consumes a layer's pixels on the CPU goes through. +//! +//! A GPU layer texture is **not** in general canvas-aligned: it can sit at a +//! non-zero canvas offset and be larger or smaller than the canvas +//! (paste-extent layers, leftward-grown layers from `ensure_layer_covers_dab`, +//! masks parented to off-canvas raster layers). +//! +//! [`request_layer_readback`] + [`LayerReadbackExtent`] are the single place +//! that owns that translation: the readback samples the texture's full extent, +//! and the extent projects the resulting layer-local bytes into a window-local +//! R8 mask — the frame the selection texture and the paint mask are indexed in. +//! **Do not** call `request_readback` with a canvas rect from such a call site; +//! go through this module. +//! +//! Producers layered on the projection: +//! +//! - [`LayerReadbackExtent::flood_fill_to_canvas_mask`] — magic wand and the +//! paint-bucket fill tool, via the scanline fills in [`crate::gpu::flood_fill`]. +//! - [`LayerReadbackExtent::opacity_to_canvas_mask`] — "alpha to selection", +//! the per-pixel opacity of the node. + +use crate::gpu::paint_target::GpuPaintTarget; +use crate::gpu::readback::{self, ReadbackRequest}; + +/// Snapshot of a paint target's coordinate frame, captured at readback-request +/// time and carried through the async round-trip. +/// +/// Owns no GPU resources — pure metadata. Pairs with the readback request +/// returned by [`request_layer_readback`]: the request reads the texture's full +/// extent (`width × height` pixels starting at texture-local (0,0)), and this +/// struct provides the canvas↔texture translation on the other side so callers +/// receive a window-local R8 mask without re-deriving the layer offset. +#[derive(Copy, Clone)] +pub struct LayerReadbackExtent { + /// Plane-space offset of the texture's (0, 0) pixel. + pub offset_x: i32, + pub offset_y: i32, + /// Texture pixel dimensions — the size of the readback buffer. + pub width: u32, + pub height: u32, + /// Document canvas (window) dimensions — the size of the produced mask. + pub canvas_width: u32, + pub canvas_height: u32, + /// Plane-space origin of the canvas window. The produced mask is + /// **window-local** (it uploads into the window-sized selection texture), + /// so the projection subtracts this. `(0, 0)` for an un-cropped doc. + pub canvas_origin_x: i32, + pub canvas_origin_y: i32, + pub format: wgpu::TextureFormat, +} + +impl LayerReadbackExtent { + pub fn from_target(target: &GpuPaintTarget<'_>) -> Self { + let canvas_extent = target.canvas_extent(); + let layer_extent = target.layer_extent(); + let (canvas_w, canvas_h) = target.canvas_size(); + let (cox, coy) = target.canvas_origin(); + Self { + offset_x: canvas_extent.x0(), + offset_y: canvas_extent.y0(), + width: layer_extent.width, + height: layer_extent.height, + canvas_width: canvas_w, + canvas_height: canvas_h, + canvas_origin_x: cox, + canvas_origin_y: coy, + format: target.format(), + } + } + + /// Run the CPU scanline fill on the texture-extent buffer and project the + /// result into a window-local R8 mask (see [`Self::project_to_window_mask`]). + /// + /// `seed_canvas` is the click point in plane coordinates, translated to + /// texture-local coords before the fill runs. Format dispatch matches the + /// texture's own format — RGBA reads four bytes per pixel, R8 reads one. + pub fn flood_fill_to_canvas_mask( + &self, + pixels: &[u8], + seed_canvas: crate::coord::CanvasPoint, + tolerance: u8, + ) -> Vec { + use crate::gpu::flood_fill::{flood_fill_r8, flood_fill_rgba}; + + let layer_seed_x = seed_canvas.x - self.offset_x; + let layer_seed_y = seed_canvas.y - self.offset_y; + + let layer_mask = match self.format { + wgpu::TextureFormat::R8Unorm => flood_fill_r8( + pixels, + self.width, + self.height, + layer_seed_x, + layer_seed_y, + tolerance, + ), + _ => flood_fill_rgba( + pixels, + self.width, + self.height, + layer_seed_x, + layer_seed_y, + tolerance, + ), + }; + + self.project_to_window_mask(&layer_mask) + } + + /// The node's per-pixel opacity as a window-local R8 mask — the coverage + /// "alpha to selection" loads. An RGBA texture contributes its alpha + /// channel; an R8 texture (mask / selection filter) *is* coverage already, + /// so its bytes pass through. + pub fn opacity_to_canvas_mask(&self, pixels: &[u8]) -> Vec { + match self.format { + wgpu::TextureFormat::R8Unorm => self.project_to_window_mask(pixels), + _ => { + let alpha: Vec = pixels.iter().skip(3).step_by(4).copied().collect(); + self.project_to_window_mask(&alpha) + } + } + } + + /// Project a layer-local R8 buffer (one byte per texture pixel) into a + /// **window-local** R8 mask sized `canvas_width × canvas_height` — the + /// frame the selection texture is indexed in (see `crate::coord`). + /// + /// Pixels outside the layer's canvas-window footprint stay 0. + fn project_to_window_mask(&self, layer_mask: &[u8]) -> Vec { + let cw = self.canvas_width as usize; + let ch = self.canvas_height as usize; + let mut canvas_mask = vec![0u8; cw * ch]; + + let (cox, coy) = (self.canvas_origin_x, self.canvas_origin_y); + // Plane-space bounds of the layer footprint clipped to the canvas + // WINDOW `[canvas_origin, canvas_origin + canvas_size]`; the output is + // written at the window-local texel `plane − canvas_origin`. + let x0 = self.offset_x.max(cox); + let y0 = self.offset_y.max(coy); + let x1 = (self.offset_x + self.width as i32).min(cox + self.canvas_width as i32); + let y1 = (self.offset_y + self.height as i32).min(coy + self.canvas_height as i32); + if x0 >= x1 || y0 >= y1 { + return canvas_mask; + } + + let stride = self.width as usize; + for py in y0..y1 { + let ty = (py - self.offset_y) as usize; // layer-local row + let src_row = ty * stride; + let dst_row = (py - coy) as usize * cw; // window-local row + for px in x0..x1 { + let tx = (px - self.offset_x) as usize; // layer-local col + let wx = (px - cox) as usize; // window-local col + canvas_mask[dst_row + wx] = layer_mask[src_row + tx]; + } + } + + canvas_mask + } +} + +/// Encode a readback of a layer's full texture extent and return the request +/// paired with the extent snapshot the completion handler needs. +/// +/// Single source of truth for the readback rect used by magic wand, the +/// paint-bucket flood fill, and alpha-to-selection. The rect is the texture's +/// own dimensions, NOT the canvas — see the module docs for why. +pub fn request_layer_readback( + device: &wgpu::Device, + encoder: &mut wgpu::CommandEncoder, + target: &GpuPaintTarget<'_>, +) -> (ReadbackRequest, LayerReadbackExtent) { + let extent = LayerReadbackExtent::from_target(target); + // Texture-local rect spanning the entire layer — the canvas↔texture + // translation happens later, in the extent's projection. + let request = readback::request_readback( + device, + encoder, + target.texture(), + target.format(), + target.layer_extent(), + ); + (request, extent) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A 2×2 layer sitting at plane (3, 2), inside a 6×6 canvas window whose + /// origin is plane (2, 1) — i.e. the layer projects to window-local + /// [1,3)×[1,3). + fn cropped_extent(format: wgpu::TextureFormat) -> LayerReadbackExtent { + LayerReadbackExtent { + offset_x: 3, + offset_y: 2, + width: 2, + height: 2, + canvas_width: 6, + canvas_height: 6, + canvas_origin_x: 2, + canvas_origin_y: 1, + format, + } + } + + /// REGRESSION: the produced mask is **window-local** — the magic-wand fill + /// must land where the window-sized selection texture expects it after a + /// crop, i.e. at `plane − canvas_origin`, not at the raw plane coordinate. + #[test] + fn flood_fill_mask_is_window_local_after_crop() { + // A small 2×2 R8 layer, fully opaque. + let pixels = vec![255u8; 2 * 2]; + let ext = cropped_extent(wgpu::TextureFormat::R8Unorm); + + // Seed inside the layer (plane (3, 2)); uniform color floods all of it. + let mask = ext.flood_fill_to_canvas_mask(&pixels, crate::coord::CanvasPoint::new(3, 2), 0); + let at = |x: usize, y: usize| mask[y * 6 + x]; + + // Layer plane footprint [3,5)×[2,4) → window-local [1,3)×[1,3). + assert_eq!(at(1, 1), 255, "window-local origin of the fill"); + assert_eq!(at(2, 2), 255, "window-local far corner of the fill"); + // The pre-fix plane-anchored projection would have written here instead. + assert_eq!(at(3, 2), 0, "must NOT land at the raw plane coordinate"); + assert_eq!(at(0, 0), 0, "outside the fill stays empty"); + } + + /// Alpha-to-selection reads the RGBA alpha channel, ignoring RGB — an + /// erased pixel keeps ghost color under `a = 0` (straight-alpha storage) + /// and must not be selected. + #[test] + fn opacity_mask_reads_rgba_alpha_only() { + // 2×2 RGBA: opaque red, half-transparent red, transparent red ghost, + // transparent black — reading row-major. + let pixels = vec![ + 255, 0, 0, 255, // (0, 0) + 255, 0, 0, 128, // (1, 0) + 255, 0, 0, 0, // (0, 1) + 0, 0, 0, 0, // (1, 1) + ]; + let ext = cropped_extent(wgpu::TextureFormat::Rgba8Unorm); + + let mask = ext.opacity_to_canvas_mask(&pixels); + let at = |x: usize, y: usize| mask[y * 6 + x]; + + assert_eq!(at(1, 1), 255, "opaque texel is fully selected"); + assert_eq!(at(2, 1), 128, "partial alpha carries through as coverage"); + assert_eq!(at(1, 2), 0, "ghost RGB under alpha = 0 is not selected"); + assert_eq!(at(2, 2), 0); + } + + /// An R8 node (mask filter) is coverage already — its bytes pass straight + /// through the projection. + #[test] + fn opacity_mask_passes_r8_coverage_through() { + let pixels = vec![255u8, 64, 0, 32]; + let ext = cropped_extent(wgpu::TextureFormat::R8Unorm); + + let mask = ext.opacity_to_canvas_mask(&pixels); + let at = |x: usize, y: usize| mask[y * 6 + x]; + + assert_eq!(at(1, 1), 255); + assert_eq!(at(2, 1), 64); + assert_eq!(at(1, 2), 0); + assert_eq!(at(2, 2), 32); + assert_eq!(at(0, 0), 0, "outside the layer footprint stays empty"); + } + + /// A layer entirely outside the canvas window projects to an empty mask + /// rather than indexing out of bounds. + #[test] + fn opacity_mask_of_offscreen_layer_is_empty() { + let mut ext = cropped_extent(wgpu::TextureFormat::Rgba8Unorm); + ext.offset_x = 40; + ext.offset_y = 40; + + let mask = ext.opacity_to_canvas_mask(&[255u8; 2 * 2 * 4]); + assert!(mask.iter().all(|&m| m == 0)); + assert_eq!(mask.len(), 6 * 6); + } +} diff --git a/crates/darkly/src/gpu/mod.rs b/crates/darkly/src/gpu/mod.rs index 7f422427..c608f680 100644 --- a/crates/darkly/src/gpu/mod.rs +++ b/crates/darkly/src/gpu/mod.rs @@ -112,6 +112,7 @@ pub mod floating_preview; pub mod flood_fill; pub mod hash; pub mod histogram; +pub mod layer_readback; pub mod lut_filter; pub mod ortho_transform; pub mod overlay; diff --git a/crates/darkly/src/gpu/texture_registry.rs b/crates/darkly/src/gpu/texture_registry.rs index 2ae39cfc..bd9c79f6 100644 --- a/crates/darkly/src/gpu/texture_registry.rs +++ b/crates/darkly/src/gpu/texture_registry.rs @@ -217,24 +217,40 @@ impl TextureRegistry { queue: &wgpu::Queue, baked: &BakedSourceCache, sources: &[ResolvedSource], + live: &[Option<&wgpu::TextureView>], ) -> (Arc, wgpu::BindGroup) { let fallback = self .textures .get(FALLBACK_TEXTURE) .expect("TextureRegistry must register _fallback at init") .clone(); - let textures: Vec> = sources + // Two shapes of view reach the bind group: registry / bake-cache + // textures own theirs, live slots borrow one published this flush. + // An unpublished live slot takes `_fallback` — the cursor preview + // path, where no stroke exists to publish anything. + enum SlotView<'v> { + Owned(Arc), + Published(&'v wgpu::TextureView), + } + let views: Vec> = sources .iter() - .map(|s| match s { + .enumerate() + .map(|(i, s)| match s { ResolvedSource::Named(name) => { - self.textures.get(name).cloned().unwrap_or_else(|| { + SlotView::Owned(self.textures.get(name).cloned().unwrap_or_else(|| { log::warn!( "TextureRegistry: no texture `{name}`, substituting `_fallback`", ); fallback.clone() - }) + })) + } + ResolvedSource::Baked(spec) => { + SlotView::Owned(baked.get_or_bake(device, queue, spec)) } - ResolvedSource::Baked(spec) => baked.get_or_bake(device, queue, spec), + ResolvedSource::Live(_) => match live.get(i).copied().flatten() { + Some(view) => SlotView::Published(view), + None => SlotView::Owned(fallback.clone()), + }, }) .collect(); let layout = self.layout_for_count(device, sources.len()); @@ -243,10 +259,14 @@ impl TextureRegistry { binding: 0, resource: wgpu::BindingResource::Sampler(&self.sampler), }); - for (i, t) in textures.iter().enumerate() { + for (i, v) in views.iter().enumerate() { + let view = match v { + SlotView::Owned(t) => &t.view, + SlotView::Published(view) => *view, + }; entries.push(wgpu::BindGroupEntry { binding: 1 + i as u32, - resource: wgpu::BindingResource::TextureView(&t.view), + resource: wgpu::BindingResource::TextureView(view), }); } let bg = device.create_bind_group(&wgpu::BindGroupDescriptor { diff --git a/crates/darkly/src/gpu/transform.rs b/crates/darkly/src/gpu/transform.rs index c624cd6d..c59826d9 100644 --- a/crates/darkly/src/gpu/transform.rs +++ b/crates/darkly/src/gpu/transform.rs @@ -226,6 +226,33 @@ pub struct TransformState { pub preview_blend_uniform_buf: wgpu::Buffer, } +/// Split a straight-alpha RGBA clip into the two channels a mask target needs: +/// `(values, coverage)`. +/// +/// A mask texel is a single grayscale value with no alpha of its own, so the +/// clip's own alpha cannot ride along in the pixel — it becomes coverage, which +/// is what the commit shader weights the write by. That is what makes the +/// transparent parts of a clip leave the mask's existing pixels untouched +/// instead of stamping a rectangle over them. GIMP draws the same line, +/// converting a floating paste to the pasted-to drawable's own format *with +/// alpha* (`gimp_edit_paste_get_layers`, app/core/gimp-edit.c). +/// +/// Values are emitted as opaque RGBA so they ride the same staging upload and +/// premultiply pass the RGBA path uses — premultiplying by an alpha of 1 is the +/// identity, and the shader reads the red channel for an R8 target. Luminance +/// uses the BT.709 weights shared with `lib/black_and_white.wgsl`. +fn rgba_to_mask_values(rgba: &[u8]) -> (Vec, Vec) { + let mut values = Vec::with_capacity(rgba.len()); + let mut coverage = Vec::with_capacity(rgba.len() / 4); + for px in rgba.chunks_exact(4) { + let luma = (0.2126 * px[0] as f32 + 0.7152 * px[1] as f32 + 0.0722 * px[2] as f32) / 255.0; + let v = (luma.clamp(0.0, 1.0) * 255.0).round() as u8; + values.extend_from_slice(&[v, v, v, 255]); + coverage.push(px[3]); + } + (values, coverage) +} + /// GPU pipelines for the floating-content commit pass + optional active state. pub struct TransformPass { /// Commit pipelines: render transform directly into a target texture. @@ -517,6 +544,14 @@ impl TransformPass { view_formats: &[], }); + // An RGBA clip has to be converted into the target's terms before the + // commit shader can sample it: for an R8 target that shader reads the + // red channel, which is a real mask value only once the conversion has + // happened, and weights the write by coverage, which is where the clip's + // alpha has to go. + let mask_conversion = + (target_format == wgpu::TextureFormat::R8Unorm).then(|| rgba_to_mask_values(rgba_data)); + queue.write_texture( wgpu::TexelCopyTextureInfo { texture: &temp_texture, @@ -524,7 +559,9 @@ impl TransformPass { origin: wgpu::Origin3d::ZERO, aspect: wgpu::TextureAspect::All, }, - rgba_data, + mask_conversion + .as_ref() + .map_or(rgba_data, |(values, _)| values.as_slice()), wgpu::TexelCopyBufferLayout { offset: 0, bytes_per_row: Some(source_width * 4), @@ -583,6 +620,24 @@ impl TransformPass { mapped_at_creation: false, }); + // A mask target's coverage is the clip's own alpha, narrowed by whatever + // coverage the caller already imposed (a selection). An RGBA target's + // source keeps its alpha channel and the shader's source-over consumes + // it directly, so there only the caller's coverage applies. + let mask_coverage = mask_conversion.as_ref().map(|(_, alpha)| { + source_coverage.map_or_else( + || alpha.clone(), + |caller| { + alpha + .iter() + .zip(caller) + .map(|(&a, &c)| ((a as u16 * c as u16) / 255) as u8) + .collect() + }, + ) + }); + let source_coverage = mask_coverage.as_deref().or(source_coverage); + let source_coverage = source_coverage.map(|coverage| { Self::create_source_coverage( device, @@ -1009,6 +1064,39 @@ mod tests { use super::*; use crate::transform::homography_from_corners; + /// A mask stores a value and no alpha, so an RGBA clip splits into a + /// luminance value plus a coverage channel carrying the clip's alpha. + /// Reading the source's red channel instead — which is what the commit + /// shader does for an R8 target before this conversion — turns any + /// non-red clip into a black rectangle over the mask. + #[test] + fn rgba_to_mask_values_splits_luminance_from_alpha() { + // Opaque green, opaque white, fully transparent, half-transparent black. + let src = [ + 0, 255, 0, 255, // + 255, 255, 255, 255, // + 0, 255, 0, 0, // + 0, 0, 0, 128, + ]; + let (values, coverage) = rgba_to_mask_values(&src); + + // BT.709 luminance, independent of alpha — alpha rides `coverage`. + assert_eq!(values[0], (0.7152f32 * 255.0).round() as u8); + assert_eq!(values[4], 255); + assert_eq!(values[8], (0.7152f32 * 255.0).round() as u8); + assert_eq!(values[12], 0); + + // Values are opaque so the shared premultiply pass is the identity. + assert!(values.chunks_exact(4).all(|px| px[3] == 255)); + // Gray, so the shader's red-channel read is the value whatever it picks. + assert!(values + .chunks_exact(4) + .all(|px| px[0] == px[1] && px[1] == px[2])); + + // Coverage is the clip's alpha: transparent texels write nothing. + assert_eq!(coverage, vec![255, 255, 0, 128]); + } + /// A near-degenerate matrix (a corner driving the homogeneous `w → 0`, /// folding behind the camera) has no usable inverse; `pack_inv_rows` must /// fall back to identity rows rather than emit NaN/∞ — the CPU half of the diff --git a/crates/darkly/src/gpu/veils/frozen.rs b/crates/darkly/src/gpu/veils/frozen.rs index 18ed813a..44490aac 100644 --- a/crates/darkly/src/gpu/veils/frozen.rs +++ b/crates/darkly/src/gpu/veils/frozen.rs @@ -8,7 +8,7 @@ use std::sync::Arc; const FROZEN_NORMAL_BYTES: &[u8] = include_bytes!("../../../resources/veils/frozen.jpg"); const PARAMS: &[ParamDef] = &[ - ParamDef::float("strength", 0.0, 0.2, 0.04) + ParamDef::float("strength", 0.0, 0.2, 0.02) .with_label("Strength") .with_description("How far the frosted surface displaces what is behind it."), ParamDef::float("scale", 0.1, 5.0, 1.0) @@ -30,7 +30,7 @@ pub fn register() -> VeilRegistration { from_params: |params, shared| { let strength = match params.first() { Some(ParamValue::Float(v)) => *v, - _ => 0.04, + _ => 0.02, }; let scale = match params.get(1) { Some(ParamValue::Float(v)) => *v, @@ -135,7 +135,7 @@ impl Veil for Frozen { /// fringing rides along. fn preview_at(&mut self, queue: &wgpu::Queue, cache: &EffectCache, t: f32) -> bool { self.scale = 0.6 + 1.8 * swing(t); - self.strength = 0.04 * self.scale; + self.strength = 0.02 * self.scale; cache.write_uniform(queue, 0, bytemuck::bytes_of(&self.uniforms())); true } diff --git a/crates/darkly/tests/engine.rs b/crates/darkly/tests/engine.rs index 70e79300..e5f8fe62 100644 --- a/crates/darkly/tests/engine.rs +++ b/crates/darkly/tests/engine.rs @@ -3425,6 +3425,105 @@ fn layer_node_tree_admits_only_layer_and_group_variants() { // `clone_filter_pixels(src, dst)` which is kind-uniform. // ============================================================================ +/// REGRESSION: `$mod`+clicking a layer thumbnail loads the layer's opacity as +/// the selection. The gesture did nothing at all — the binding was unbound and +/// no engine op read a layer's alpha into the selection (`mask_to_selection` +/// only clones one R8 filter into another, which an RGBA raster layer is not). +/// +/// The layer here sits at a canvas offset, so this also pins the frame +/// translation: the selection must land where the opaque pixels *project* onto +/// the canvas, not at the texture's own coordinates. +#[test] +fn engine_alpha_to_selection_selects_opaque_pixels() { + let (cw, ch) = (64u32, 64u32); + let mut engine = test_engine(cw, ch); + + // A 96×96 image pasted at canvas (-32, -32) — transparent except for an + // opaque red 32×32 block at texture (48..80, 48..80), which projects onto + // canvas (16..48, 16..48). + let (pw, ph) = (96u32, 96u32); + 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!( + !engine.has_selection(), + "test setup: nothing selected before the gesture" + ); + + engine.alpha_to_selection(pasted); + engine.test_flush_readbacks(); + + assert!( + engine.has_selection(), + "alpha_to_selection must activate the selection" + ); + let cache = engine + .test_selection_cpu_cache() + .expect("alpha_to_selection must populate the selection cpu cache"); + + let at = |x: u32, y: u32| cache[(y * cw + x) as usize]; + assert_eq!(at(32, 32), 255, "centre of the opaque block is selected"); + assert_eq!(at(16, 16), 255, "top-left corner of the block is selected"); + assert_eq!( + at(47, 47), + 255, + "bottom-right corner of the block is selected" + ); + assert_eq!(at(15, 15), 0, "transparent pixel outside the block is not"); + assert_eq!(at(48, 48), 0, "one past the block's far edge is not"); + assert_eq!(at(0, 0), 0, "the canvas corner is not"); +} + +/// Alpha-to-selection is node-kind agnostic: pointed at a mask filter it loads +/// the mask's coverage, matching `mask_to_selection`'s result. Krita drives +/// both from one gesture (`NodeDelegate.cpp` → `SelectOpaqueRole` on any node), +/// and so must we. +#[test] +fn engine_alpha_to_selection_on_a_mask_matches_mask_to_selection() { + use darkly::document::SelectionMode; + + let (cw, ch) = (64u32, 64u32); + let mut engine = test_engine(cw, ch); + let layer_id = engine.add_raster_layer(None); + + // Seed a mask from a rectangular selection, then clear the selection. + engine.select_rect(4.0, 4.0, 20.0, 16.0, SelectionMode::Replace, false, 0.0); + engine.add_mask(layer_id); + let mask_id = engine.host_mask_id(layer_id).expect("mask attached"); + engine.clear_selection(); + engine.test_flush_readbacks(); + + engine.mask_to_selection(mask_id); + engine.test_flush_readbacks(); + let via_mask_op = engine + .test_selection_cpu_cache() + .expect("mask_to_selection populates the cache") + .to_vec(); + + engine.clear_selection(); + engine.test_flush_readbacks(); + + engine.alpha_to_selection(mask_id); + engine.test_flush_readbacks(); + let via_alpha_op = engine + .test_selection_cpu_cache() + .expect("alpha_to_selection populates the cache") + .to_vec(); + + assert_eq!( + via_alpha_op, via_mask_op, + "an R8 node's coverage IS its opacity — both ops must land the same \ + selection bytes" + ); +} + /// `selection_to_mask` then `mask_to_selection` must round-trip the selection /// pixels byte-identically. This exercises the §4a unification: both sides /// of the bridge go through the single `clone_filter_pixels` helper, so a diff --git a/crates/darkly/tests/paste_mask.rs b/crates/darkly/tests/paste_mask.rs index 1c10b0c8..1457af67 100644 --- a/crates/darkly/tests/paste_mask.rs +++ b/crates/darkly/tests/paste_mask.rs @@ -1,9 +1,13 @@ //! Regression tests for the copy/paste ↔ mask interaction. //! //! Model: plain paste always makes its own layer; pasting INTO the active -//! target (a layer or a mask) is the "paste in place" verb, which floats the -//! clip onto the active node and commits through the shared `commit_floating` -//! path (RGBA layers and R8 masks alike). The three defects covered: +//! target (a layer or a mask) is the "paste in place" verb, which writes RGBA +//! layers and R8 masks alike. Target kind never changes the routing — with +//! transform-after-paste on, every target floats so the clip can be positioned +//! before it overwrites anything, and the transform tool is what commits it. +//! An RGBA clip converts to mask values on the way in (luminance, transparency +//! resolving to white); see `gpu::transform::rgba_to_mask_values`. +//! The defects covered: //! 1. Paste-in-place must write into the active mask (not silently no-op, //! and not create a new layer), and be undoable. //! 2. Copying a *region* (selection active) of a masked layer must produce a @@ -91,9 +95,10 @@ fn raster_props(engine: &DarklyEngine, id: LayerId) -> (String, f32, String, usi panic!("layer {id_f} not found in tree"); } -/// Bug 1 (floating verb): paste-in-place floats the clipboard onto the active -/// MASK and commits into it — no new layer, and undoable. This is the path the -/// UI takes when "activate transform after paste" is on. +/// Bug 1 (floating verb): floating the clipboard onto a MASK and committing it +/// writes into the mask — no new layer, and undoable. This is the path the UI +/// takes when "activate transform after paste" is on; the commit below stands +/// in for the gizmo gesture that ends the session. #[test] fn paste_in_place_floating_writes_into_active_mask() { let (w, h) = (64u32, 64u32); @@ -181,6 +186,151 @@ fn paste_in_place_committed_writes_into_active_mask() { ); } +/// REPRO: paste a pure-GREEN clip into a mask. A mask is a grayscale value, so +/// the clip must convert by luminance (green ≈ 0.7152 → ~182). Reading the +/// source's red channel instead yields 0 — the mask goes black and the host +/// vanishes, "it pastes full black regardless of what is in the clipboard". +#[test] +fn repro_rgba_paste_into_mask_converts_by_luminance_not_red() { + let (w, h) = (64u32, 64u32); + let mut e = test_engine(w, h); + + let src = e.add_raster_layer(None); + paint_dot(&mut e, src, 32.0, 32.0, (0.0, 1.0, 0.0)); + let host = e.add_raster_layer(None); + e.add_mask(host); + let mask = e.test_mask_id(host).expect("host has a mask filter"); + settle(&mut e); + + copy_into_clipboard(&mut e, src); + + assert!( + e.paste_in_place_floating(mask), + "paste must float onto the mask" + ); + e.commit_floating(); + settle(&mut e); + + let after = e.test_readback_mask(host); + let ext = e.test_node_extent(mask); + let stride = ext.width as usize; + let at = |x: i32, y: i32| after[(y - ext.y0()) as usize * stride + (x - ext.x0()) as usize]; + + // Under the dab, pure green converts by luminance: 0.7152 → ~182. Reading + // the source's red channel instead gives 0 and blacks the mask out. + let expected = (0.7152f32 * 255.0).round() as u8; + let under_dab = at(32, 32); + assert!( + under_dab.abs_diff(expected) <= 6, + "pure green must convert to its luminance (~{expected}); the mask reads \ + {under_dab} under the dab" + ); + // Away from the dab the clip is transparent, and transparent writes nothing. + assert_eq!( + at(2, 2), + 255, + "a transparent part of the clip must leave the mask as it was" + ); +} + +/// The floating session's box is the pasted *object*, not the region the copy +/// swept. A select-all copy of a layer holding one small dab used to hand the +/// transform gizmo a canvas-sized box around it. +#[test] +fn floating_paste_box_is_the_content_not_the_copied_region() { + let (w, h) = (64u32, 64u32); + let mut e = test_engine(w, h); + + let src = e.add_raster_layer(None); + paint_dot(&mut e, src, 32.0, 32.0, (0.0, 1.0, 0.0)); + let host = e.add_raster_layer(None); + e.add_mask(host); + let mask = e.test_mask_id(host).expect("host has a mask filter"); + settle(&mut e); + + // Select-all, so the copied region is the entire canvas. + e.select_all(); + settle(&mut e); + e.copy_layer_rich(src); + settle(&mut e); + + assert!( + e.paste_in_place_floating(mask), + "paste must float onto the mask" + ); + let (x, y, fw, fh, _) = e.floating_info().expect("a floating session is active"); + + assert!( + fw < w as f32 && fh < h as f32, + "the floating box must be the dab's bounds, not the {w}×{h} region that \ + was copied; got {fw}×{fh}" + ); + // The dab is centred at (32, 32), so its box must contain that point. + assert!( + x <= 32.0 && y <= 32.0 && x + fw > 32.0 && y + fh > 32.0, + "the floating box ({x}, {y}, {fw}, {fh}) must contain the dab at (32, 32)" + ); +} + +/// A mask paste is a real floating session: it can be repositioned before it +/// lands. Pasting into a mask used to commit the moment the key was pressed, +/// which both skipped the transform gizmo and clobbered the mask outright. +/// Translating the floating must move where the clip comes down. +#[test] +fn floating_paste_into_mask_lands_where_it_was_moved_to() { + let (w, h) = (64u32, 64u32); + let mut e = test_engine(w, h); + + // A dab up at (16, 16); the clipboard clip is trimmed to it. + let src = e.add_raster_layer(None); + paint_dot(&mut e, src, 16.0, 16.0, (0.0, 1.0, 0.0)); + let host = e.add_raster_layer(None); + e.add_mask(host); + let mask = e.test_mask_id(host).expect("host has a mask filter"); + settle(&mut e); + + copy_into_clipboard(&mut e, src); + + assert!( + e.paste_in_place_floating(mask), + "paste must open a floating session on the mask" + ); + // The transform tool gates its gizmo on this; "none" would leave a mask + // paste floating with no handles to position or commit it by. + assert_eq!( + e.layer_transform_capability(mask), + "destructive", + "a mask must be transformable, or the paste gets no gizmo" + ); + // Drag it down-right by (32, 32) before committing, as the gizmo would. + e.update_floating_matrix(darkly::transform::Transform::from_affine( + darkly::gpu::transform::affine_translate(32.0, 32.0), + )); + e.commit_floating(); + settle(&mut e); + + // Readback rows are texture-local, so canvas coords go through the mask + // texture's own extent rather than the canvas dimensions. + let after = e.test_readback_mask(host); + let ext = e.test_node_extent(mask); + let stride = ext.width as usize; + let at = |x: i32, y: i32| after[(y - ext.y0()) as usize * stride + (x - ext.x0()) as usize]; + + // The clip's transparent surround resolves to white, so the dab is the only + // thing that darkens the mask. Green converts by luminance (~182). + let moved_to = at(48, 48); + assert!( + moved_to.abs_diff((0.7152f32 * 255.0).round() as u8) <= 6, + "the dab must land at the moved-to position (48, 48); the mask reads \ + {moved_to} there" + ); + assert_eq!( + at(16, 16), + 255, + "the dab must not also be at its pre-move position (16, 16)" + ); +} + /// Bug 2: a region copy (selection active) of a masked layer pastes flat, with /// no mask attached. #[test] diff --git a/crates/darkly/tests/watercolor.rs b/crates/darkly/tests/watercolor.rs index be41b991..aa4a3713 100644 --- a/crates/darkly/tests/watercolor.rs +++ b/crates/darkly/tests/watercolor.rs @@ -56,15 +56,12 @@ struct FlushGroup<'a> { /// path. `begin_stroke` always runs before the first group; this only /// affects later ones. restart: bool, - /// Brush colour for this group, overriding the render call's default. - color: Option<[f32; 4]>, } fn group(dabs: &[(f32, f32)]) -> FlushGroup<'_> { FlushGroup { dabs, restart: false, - color: None, } } @@ -72,14 +69,6 @@ fn restart_group(dabs: &[(f32, f32)]) -> FlushGroup<'_> { FlushGroup { dabs, restart: true, - color: None, - } -} - -impl<'a> FlushGroup<'a> { - fn with_color(mut self, color: [f32; 4]) -> Self { - self.color = Some(color); - self } } @@ -224,7 +213,7 @@ fn render_flush_groups( pressure: 1.0, ..Default::default() }; - runner.seed_sensors(&info, g.color.unwrap_or(color), 0xC0FFEE, dab_index); + runner.seed_sensors(&info, color, 0xC0FFEE, dab_index); runner.execute_cpu(); runner.execute_gpu(&mut ctx); dab_index += 1; @@ -265,9 +254,13 @@ fn smooth_watercolor_deposits_blend_of_brush_and_pickup() { &[(64.0, 64.0)], ); let center = pixel(&rgba, 64, 64); - // Some red got deposited (would be 100 with no brush touch). + // Some red got deposited (would be 100 with no brush touch). One dab + // lays `deposit` of the way to the pigment, so the margin tracks that + // port's default — at 25% the centre reads ~127 against the canvas's + // 100. Widen the margin here if the default drops further; a failure + // means "no red arrived", not "the number moved". assert!( - center[0] > 130, + center[0] > 115, "Smooth Watercolor centre should add red over the light-blue \ pickup, got {center:?} (canvas r=100)" ); @@ -378,9 +371,11 @@ fn begin_stroke_clears_scratch_so_rewind_drops_defunct_pigment() { // Sanity: the surviving dab at (88, 64) must still deposit red — the // clear must not have wiped the dab we just rendered. + // Margin tracks the `deposit` default the same way the buildup test's + // does — the subject here is the clear, not the magnitude. let surviving = pixel(&rgba, 88, 64); assert!( - surviving[0] > 130, + surviving[0] > 115, "surviving dab at (88, 64) should still show red lift, got {surviving:?}" ); } @@ -440,75 +435,6 @@ fn watercolor_pigment_builds_up_across_flushes() { ); } -/// The pickup is a *neighbourhood* average, not a point sample — that is -/// the whole reason the atlas pass exists. A dab laid next to wet paint -/// must pull colour from it laterally. -/// -/// Without this, `watercolor_pigment_builds_up_across_flushes` would pass -/// for any per-flush-varying input at all; this pins the mechanism. -#[test] -fn watercolor_pickup_bleeds_neighbouring_wet_paint() { - let canvas = light_blue_canvas(); - // At size 0.2 the dab radius is 51.2 px (0.2 × DAB_REFERENCE_SIZE × 0.5) - // and `pickup_size` defaults to 1.0, so the target dab's pickup window - // is also ±51.2 px about its centre. - // - // The probe pixel is the crux. The atlas holds ONE pickup value per dab, - // sampled around that dab's centre and then applied across its whole - // footprint. So probe at a pixel the *target* covers but the neighbour - // does not: the only way the neighbour's colour can reach it is through - // the target's pickup. - // - // target (64, 64) covers x ∈ [12.8, 115.2] - // neighbour(94, 64) covers x ∈ [42.8, 145.2] - // probe (30, 64) — inside the target, 12.8 px clear of the neighbour, - // and the neighbour's paint sits inside the - // target's [12.8, 115.2] pickup window. - let neighbour = (94.0, 64.0); - let target = (64.0, 64.0); - const PROBE: (u32, u32) = (30, 64); - - const WHITE: [f32; 4] = [1.0, 1.0, 1.0, 1.0]; - const RED: [f32; 4] = [1.0, 0.0, 0.0, 1.0]; - - // A red dab in the first flush, then white over the overlap. - let with_neighbour = render_flush_groups( - "Smooth Watercolor", - 0.2, - WHITE, - &[ - group(&[neighbour]).with_color(RED), - group(&[target]).with_color(WHITE), - ], - &canvas, - ); - // The same white dab, but the first flush lays white too — identical - // flush structure and coverage, only the neighbour's colour differs. - let alone = render_flush_groups( - "Smooth Watercolor", - 0.2, - WHITE, - &[ - group(&[neighbour]).with_color(WHITE), - group(&[target]).with_color(WHITE), - ], - &canvas, - ); - - let bled = pixel(&with_neighbour, PROBE.0, PROBE.1); - let clean = pixel(&alone, PROBE.0, PROBE.1); - // Both runs paint white here with identical geometry and identical - // flush structure; only the neighbour's colour differs. A white brush - // over a red-tinted pickup lands pinker — less green and less blue — - // than the same brush over a white pickup. - assert!( - bled[1] + 4 < clean[1] && bled[2] + 4 < clean[2], - "the target dab's pickup should carry its red neighbour's wet paint to {PROBE:?}, \ - which the neighbour itself never covers: with red neighbour {bled:?}, \ - with white neighbour {clean:?}", - ); -} - /// Buildup must also work from nothing. On an empty layer the pickup /// alpha is zero, so the pickup branch stays disabled and watercolor /// degenerates to plain paint on the first flush; from the second flush @@ -558,3 +484,53 @@ fn watercolor_builds_up_on_transparent_canvas() { "a pure red brush must stay red, not grey out: 6 flushes {after_6:?}", ); } + +/// A mark must depend only on where the dabs are, never on how they were +/// batched into `flush_dabs` calls. +/// +/// Flush boundaries fall on pen events, so a grouping-dependent mark bands +/// at whatever spatial period the pen happened to report at — light patches +/// close together in a slow stroke, far apart in a fast one, and neither +/// under the artist's control. This is the regression test for that +/// banding: a straight run of dabs rendered as 1, 6, 3 and 1 flushes must +/// produce the same flat profile every time. +#[test] +fn watercolor_mark_is_invariant_to_flush_grouping() { + const RADIUS: f32 = 7.68; // size 0.03 × 256 + let spacing = 0.1 * 2.0 * RADIUS; + let black = solid_canvas([0, 0, 0, 255]); + + let dabs: Vec<(f32, f32)> = (0..59).map(|i| (20.0 + i as f32 * spacing, 64.0)).collect(); + + let mut means: Vec = Vec::new(); + for k in [1usize, 10, 20, 59] { + let chunks: Vec<&[(f32, f32)]> = dabs.chunks(k).collect(); + let groups: Vec> = chunks.iter().map(|c| group(c)).collect(); + let rgba = render_flush_groups( + "Smooth Watercolor", + 0.03, + [1.0, 1.0, 1.0, 1.0], + &groups, + &black, + ); + // Sample the stroke interior only — the caps taper by construction. + let profile: Vec = (25..105).map(|x| pixel(&rgba, x, 64)[0]).collect(); + let lo = *profile.iter().min().unwrap(); + let hi = *profile.iter().max().unwrap(); + assert!( + hi - lo <= 4, + "mark must be flat along a straight run, but with {k} dab(s) per flush it \ + varies {lo}..{hi} — that spread is per-flush banding: {profile:?}", + ); + means.push(profile.iter().map(|&v| v as f32).sum::() / profile.len() as f32); + } + + let lo = means.iter().cloned().fold(f32::INFINITY, f32::min); + let hi = means.iter().cloned().fold(f32::NEG_INFINITY, f32::max); + assert!( + hi - lo <= 4.0, + "the same dabs must mark the same regardless of flush grouping, but the mean \ + differs by {:.1} across groupings (1/10/20/59 dabs per flush): {means:?}", + hi - lo, + ); +} diff --git a/crates/darkly/tests/wgsl.rs b/crates/darkly/tests/wgsl.rs index b803281c..1e4c414f 100644 --- a/crates/darkly/tests/wgsl.rs +++ b/crates/darkly/tests/wgsl.rs @@ -440,13 +440,14 @@ fn paint_only_graph_falls_through_to_disc() { .contains("vec4(1.0, 1.0, 1.0, 1.0)")); } -/// The Clone builtin compiles to WGSL with `samples_source` set, the -/// stroke shader declares the `@group(3)` source binding and calls the -/// clone-sample helper, and the preview variant compiles too (it binds a -/// fallback so it must still declare the source). Naga validation of the -/// assembled shader happens when the pipeline builds — see `tests/clone.rs`. +/// The Clone builtin reserves a live `@group(3)` slot for the stroke +/// snapshot, the stroke shader declares that binding and calls the +/// clone-sample helper, and the preview variant compiles too (it declares +/// the same slot and binds `_fallback`, since hover publishes nothing). +/// Naga validation of the assembled shader happens when the pipeline +/// builds — see `tests/clone.rs`. #[test] -fn clone_brush_compiles_with_samples_source() { +fn clone_brush_reserves_a_live_source_slot() { let clone = darkly::brush::builtin_brushes::all() .into_iter() .find(|b| b.metadata.name == "Clone") @@ -456,10 +457,13 @@ fn clone_brush_compiles_with_samples_source() { let compiled = compile_brush_to_wgsl(&clone.metadata.graph, &plan, &evals()).expect("clone compiles"); - assert!(compiled.samples_source, "clone must set samples_source"); - assert!( - compiled.graph_sources.is_empty(), - "clone source is not a named registry texture" + assert_eq!( + compiled.graph_sources, + vec![darkly::brush::texture_source::ResolvedSource::Live( + darkly::brush::texture_source::LiveSource::StrokeSnapshot + )], + "clone must reserve exactly one live stroke-snapshot slot, and no \ + named registry texture", ); // Stroke shader declares the @group(3) source texture and samples it. assert!(compiled diff --git a/crates/darkly/tests/wgsl_validate.rs b/crates/darkly/tests/wgsl_validate.rs new file mode 100644 index 00000000..bdb26d9a --- /dev/null +++ b/crates/darkly/tests/wgsl_validate.rs @@ -0,0 +1,72 @@ +//! Every built-in brush's assembled WGSL must survive naga — both shader +//! variants, not just the one a stroke exercises. +//! +//! `builtin_brushes_compile` only checks that *assembly* succeeds; it never +//! looks at the text. So a terminal that samples a stroke-only `@group(3)` +//! binding without overriding `compile_cursor_preview_body` emits an +//! undeclared identifier into `cursor_preview_wgsl`, assembles fine, and then +//! fails when the preview pipeline is built — at first hover, in the browser, +//! far from the edit that caused it. `docs/brush-preview-and-overlays.md` +//! documents the rule; this is what enforces it. +//! +//! Deliberately *not* here: a table of committed hashes per brush. Shader +//! text is assembled from a shared skeleton, so an edit to `_prelude.wgsl` +//! moves every brush at once and the only available response is "regenerate +//! and paste" — a change detector with no signal in it. What matters is that +//! the emitted WGSL is *valid*, which naga answers directly. + +use darkly::brush::{builtin_brushes, compile_graph}; + +/// `(brush name, stroke_wgsl, cursor_preview_wgsl)` for every builtin. +fn compiled_wgsl() -> Vec<(String, String, String)> { + builtin_brushes::all() + .into_iter() + .map(|brush| { + let runner = compile_graph(&brush.metadata.graph).unwrap_or_else(|e| { + panic!("brush '{}' failed to compile: {e}", brush.metadata.name) + }); + let compiled = runner + .compiled_brush() + .unwrap_or_else(|| panic!("brush '{}' produced no terminal", brush.metadata.name)); + ( + brush.metadata.name.clone(), + compiled.stroke_wgsl.clone(), + compiled.cursor_preview_wgsl.clone(), + ) + }) + .collect() +} + +#[test] +fn builtin_brush_wgsl_validates() { + let brushes = compiled_wgsl(); + assert!(!brushes.is_empty(), "no built-in brushes found"); + + let mut failures = Vec::new(); + for (name, stroke, preview) in brushes { + for (variant, source) in [("stroke", &stroke), ("cursor_preview", &preview)] { + match naga::front::wgsl::parse_str(source) { + Err(e) => failures.push(format!( + "{name} / {variant}: {}\n--- source ---\n{source}\n--- end ---", + e.emit_to_string(source), + )), + Ok(module) => { + let mut validator = naga::valid::Validator::new( + naga::valid::ValidationFlags::all(), + naga::valid::Capabilities::all(), + ); + if let Err(e) = validator.validate(&module) { + failures.push(format!( + "{name} / {variant}: {e:?}\n--- source ---\n{source}\n--- end ---" + )); + } + } + } + } + } + assert!( + failures.is_empty(), + "built-in brush WGSL failed validation:\n\n{}", + failures.join("\n\n"), + ); +} diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index d74b1a10..dc97d5e5 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -11,6 +11,7 @@ import ImageRescaleModal from './ui/ImageRescaleModal.svelte'; import SelectionModifyModal from './ui/SelectionModifyModal.svelte'; import FilterModal from './ui/filters/FilterModal.svelte'; + import LayerPickers from './ui/layers/LayerPickers.svelte'; import ConfirmDiscardModal from './ui/ConfirmDiscardModal.svelte'; import RecoveryModal from './ui/RecoveryModal.svelte'; import AboutModal from './ui/AboutModal.svelte'; @@ -67,6 +68,7 @@ + diff --git a/frontend/src/actions/__tests__/clipboard.test.ts b/frontend/src/actions/__tests__/clipboard.test.ts index cf3f0964..c96323c8 100644 --- a/frontend/src/actions/__tests__/clipboard.test.ts +++ b/frontend/src/actions/__tests__/clipboard.test.ts @@ -44,6 +44,10 @@ beforeEach(() => { engine.send.mockClear(); fakeApp.onCopyResult.mockClear(); fakeApp.activeLayerId = 42; + fakeConfig.get.mockReturnValue(false); + // `mockClear` keeps the implementation, so restore the default response + // rather than letting one test's stub leak into the next. + engine.send.mockResolvedValue(null); }); // Give the fake engine a real typed `api` over its send/post spies. @@ -82,3 +86,31 @@ describe('copy/cut send the layer id under the `id` field (not `layer_id`)', () expect(engine.post).not.toHaveBeenCalled(); }); }); + +// Regression: paste-in-place special-cased a mask target onto the committed +// verb, so pasting into a mask entered no transform session and overwrote the +// mask the instant the key was pressed — no preview, no reposition, no cancel. +// The target's kind must not divert the routing: with transform-after-paste on, +// every target floats, and the transform tool is what commits it. +describe('paste-in-place routing', () => { + it('floats with transform-after-paste on, whatever the target kind', async () => { + registerClipboardActions(); + fakeConfig.get.mockReturnValue(true); + + await actions.get('pasteInPlace')!.handler({}); + + expect(engine.send).toHaveBeenCalledWith('paste_in_place_floating', { id: 42 }); + expect(engine.send).not.toHaveBeenCalledWith('paste_in_place', expect.anything()); + }); + + it('commits on arrival only when transform-after-paste is off', async () => { + registerClipboardActions(); + fakeConfig.get.mockReturnValue(false); + // The committed verb answers with the id it wrote into. + engine.send.mockResolvedValue({ id: 42 }); + + await actions.get('pasteInPlace')!.handler({}); + + expect(engine.send).toHaveBeenCalledWith('paste_in_place', { active_layer_id: 42 }); + }); +}); diff --git a/frontend/src/actions/__tests__/menu_actions.test.ts b/frontend/src/actions/__tests__/menu_actions.test.ts index 57cc1ad3..939849f4 100644 --- a/frontend/src/actions/__tests__/menu_actions.test.ts +++ b/frontend/src/actions/__tests__/menu_actions.test.ts @@ -1,7 +1,8 @@ import { describe, it, expect, beforeAll } from 'vitest'; -import { registerActions } from '../index'; +import { registerActions, NEW_LAYER_ACTION_IDS } from '../index'; import { actions, actionEnablement, parseMenuSegment, type Action } from '../registry'; import { buildTopMenus } from '../../ui/menu/menuModel'; +import { filterPalette } from '../../ui/menu/paletteFilter'; import { app } from '../../state/app.svelte'; import { rustActionDocs } from './rust_action_docs'; @@ -43,7 +44,7 @@ describe('menu action registrations', () => { }); it('puts the selection commands under Select', () => { - for (const id of ['selectAll', 'clearSelection', 'invertSelection', 'clearSelectionContents', 'maskToSelection']) { + for (const id of ['selectAll', 'clearSelection', 'invertSelection', 'clearSelectionContents', 'maskToSelection', 'alphaToSelection']) { const seg = actions.get(id)?.menuPath?.[0]; expect(parseMenuSegment(seg ?? '').title, id).toBe('Select'); } @@ -54,6 +55,20 @@ describe('menu action registrations', () => { expect(actions.get('maskToSelection')?.category).toBe('selection'); }); + it('slots alphaToSelection into Select right after maskToSelection', () => { + expect(actions.get('alphaToSelection')?.menuPath).toEqual(['Select:36']); + expect(actions.get('alphaToSelection')?.category).toBe('selection'); + }); + + it('disables alphaToSelection with a reason when the active node has no pixels', () => { + // No layer tree in this environment → activeNode is null, so the + // action can't know of any pixels to load. + const a2s = actions.get('alphaToSelection')!; + expect(a2s.enabled?.()).not.toBe(true); + expect(actionEnablement(a2s)).toMatchObject({ enabled: false }); + expect(actionEnablement(a2s).reason).toBe('Active layer has no pixels'); + }); + it('disables maskToSelection with a reason when the active layer has no mask', () => { // No layer tree / active mask in this environment → activeMaskId is null. const m2s = actions.get('maskToSelection')!; @@ -106,6 +121,9 @@ describe('menu action registrations', () => { .map(e => (e as { actionId: string }).actionId); expect(ids).toEqual([ 'newLayer', + 'newFilterLayer', + 'newVeil', + 'newVoid', 'newGroup', 'duplicateLayer', 'flipLayerH', @@ -120,6 +138,23 @@ describe('menu action registrations', () => { ]); }); + it('makes every layer kind the new-layer menu can add reachable from the palette', () => { + // Searching the palette for a layer kind used to come up empty for + // veils, voids and filter layers — those existed only as local state + // inside the layer panel's dropdown. + const hit = (query: string) => filterPalette(actions.all(), query).map(r => r.id); + expect(hit('veil')).toContain('newVeil'); + expect(hit('void')).toContain('newVoid'); + expect(hit('filter layer')).toContain('newFilterLayer'); + expect(hit('group')).toContain('newGroup'); + }); + + it('backs every new-layer dropdown entry with a registered action', () => { + // The dropdown renders label + icon straight from these registrations. + const missing = NEW_LAYER_ACTION_IDS.filter(id => !actions.get(id)); + expect(missing).toEqual([]); + }); + it('disables cropToSelection with a reason when no selection is active', () => { // No WASM handle in this environment → no active selection. const crop = actions.get('cropToSelection')!; diff --git a/frontend/src/actions/clipboard.ts b/frontend/src/actions/clipboard.ts index 6362f3ca..334124de 100644 --- a/frontend/src/actions/clipboard.ts +++ b/frontend/src/actions/clipboard.ts @@ -151,9 +151,11 @@ export function registerClipboardActions(): void { const engine = app.engine; if (!engine || app.activeLayerId == null) return; // Paste into the active target — a raster layer or, when a mask is - // the active edit target, the mask. Both flow through the same - // floating→commit path (`pasteInPlaceFloating` / `pasteInPlace`), - // which writes RGBA layers and R8 masks alike. + // the active edit target, the mask. Both write through the engine's + // shared paste path, which handles RGBA layers and R8 masks alike, + // and both float first so the clip can be positioned before it + // overwrites anything: a mask is a paintable surface like any other, + // and committing on arrival would clobber whatever it already held. const activateTransform = config.get('edit.activateTransformAfterPaste') !== false; if (activateTransform) { // Float onto the target so it can be repositioned before commit. diff --git a/frontend/src/actions/index.ts b/frontend/src/actions/index.ts index 5f715d32..10d80686 100644 --- a/frontend/src/actions/index.ts +++ b/frontend/src/actions/index.ts @@ -7,6 +7,7 @@ import { resizeCanvas } from '../state/resizeCanvas.svelte'; import { imageRescale } from '../state/imageRescale.svelte'; import { selectionModify } from '../state/selectionModify.svelte'; import { filterModal } from '../state/filterModal.svelte'; +import { layerPicker } from '../state/layerPicker.svelte'; import type { ParamInfo } from '../ui/filters/filterParams'; import { exportTimelapse } from '../state/exportTimelapse.svelte'; import { loadError, parseLoadErrorMessage } from '../state/loadError.svelte'; @@ -29,6 +30,18 @@ import { commandPalette } from '../state/commandPalette.svelte'; import { openCheatsheet } from '../ui/cheatsheet'; import { links, openExternal } from '../links'; +/** The commands that add something to the layer stack, in the order the + * layer panel's new-layer dropdown lists them. The dropdown renders straight + * from these registrations, so a new layer kind needs an action and nothing + * else — its label, icon and behaviour come along for free. */ +export const NEW_LAYER_ACTION_IDS = [ + 'newLayer', + 'newFilterLayer', + 'newVeil', + 'newVoid', + 'newGroup', +]; + /** Walk the layer tree to find a node by id. The layer tree is the * JSON shape produced by `app.refreshLayerTree`, with `children` on * groups and `modifiers` on hosts. */ @@ -316,6 +329,23 @@ export function registerActions() { app.requestFrame(); }, }); + actions.register({ + id: 'alphaToSelection', + menuPath: ['Select:36'], + // Sibling of `maskToSelection`, for the host rather than its mask. + // The engine op is node-kind agnostic — it reads whatever texture the + // id resolves to — but only pixel-bearing nodes have one, so the + // guard follows the same fact the layer panel uses to decide whether + // to draw a thumbnail at all. + enabled: () => app.activeNode?.hasThumbnail === true || 'Active layer has no pixels', + handler: (ctx) => { + const engine = app.engine; + const layerId = ctx.layerId ?? app.activeLayerId; + if (!engine || layerId == null) return; + engine.api.alphaToSelection({ id: layerId }); + app.requestFrame(); + }, + }); actions.register({ id: 'growSelection', menuPath: ['Select:50'], @@ -555,6 +585,24 @@ export function registerActions() { }, }); + actions.register({ + id: 'newFilterLayer', + menuPath: ['Layer:12'], + handler: () => { layerPicker.kind = 'filter'; }, + }); + + actions.register({ + id: 'newVeil', + menuPath: ['Layer:14'], + handler: () => { layerPicker.kind = 'veil'; }, + }); + + actions.register({ + id: 'newVoid', + menuPath: ['Layer:16'], + handler: () => { layerPicker.kind = 'void'; }, + }); + actions.register({ id: 'newGroup', menuPath: ['Layer:20'], diff --git a/frontend/src/engine/protocol_gen.ts b/frontend/src/engine/protocol_gen.ts index cc9feb93..d8184c29 100644 --- a/frontend/src/engine/protocol_gen.ts +++ b/frontend/src/engine/protocol_gen.ts @@ -54,6 +54,8 @@ export type AddVeilReq = { veil_type: string, params: JsonValue, }; export type AddVoidReq = { void_type: string, params: JsonValue, anchor: number | null, }; +export type AlphaToSelectionReq = { id: number, }; + export type ApplyFilterReq = { node_id: number, filter_type: string, params: JsonValue, }; export type ApplyMaskReq = { id: number, }; @@ -178,17 +180,11 @@ export type BrushLoadReq = { name: string, }; export type BrushNodePreviewReq = { node_id: string, }; -export type PreviewStaging = { -/** - * Iconify glyph shown in the dab slot, where a single stationary sample - * has no motion to make the effect visible at all. - */ -icon: string, -/** - * Field painted under the stroke preview, giving the node something to - * transport. - */ -backdrop: PreviewBackdrop, }; +export type InputValue = boolean | number | number | string | Array<[number, number]> | [number, number] | [number, number, number, number]; + +export type PortDir = "Input" | "Output"; + +export type BrushWireType = "Scalar" | "Int" | "Bool" | "Vec2" | "Vec4" | "Enum" | "String" | "Curve"; export type PortDef = { name: string, dir: PortDir, wire_type: BrushWireType, /** @@ -382,11 +378,17 @@ preview_image: boolean, */ source: boolean, }; -export type BrushWireType = "Scalar" | "Int" | "Bool" | "Vec2" | "Vec4" | "Enum" | "String" | "Curve"; - -export type PortDir = "Input" | "Output"; - -export type InputValue = boolean | number | number | string | Array<[number, number]> | [number, number] | [number, number, number, number]; +export type PreviewStaging = { +/** + * Iconify glyph shown in the dab slot, where a single stationary sample + * has no motion to make the effect visible at all. + */ +icon: string, +/** + * Field painted under the stroke preview, giving the node something to + * transport. + */ +backdrop: PreviewBackdrop, }; export type NodeRegistration = { /** @@ -497,6 +499,8 @@ supportsPreview: boolean, */ captureKind: CaptureKind | null, }; +export type CaptureKind = "camera" | "display" | "stream"; + export type ParamValue = boolean | number | number | string | Array<[number, number]> | [number, number, number, number, number] | [number, number, number] | [number, number] | Array<{ [key in string]: ParamValue }>; export type ParamDisplay = { min: string | null, max: string | null, default: string | null, @@ -522,8 +526,6 @@ widget: string, unit: UnitType, min: number | null, max: number | null, default: */ options: JsonValue | null, display: ParamDisplay, }; -export type CaptureKind = "camera" | "display" | "stream"; - export type Catalog = { id: string, title: string, description: string | null, icon: string | null, /** * Presentation order, for catalogs that declare one. Registry catalogs do @@ -579,17 +581,6 @@ export type HitTestVectorObjectReq = { id: number, x: number, y: number, }; export type LayerTransformCapabilityReq = { id: number, }; -export type ModifierInfo = { id: number, kind: string, name: string, visible: boolean, locked: boolean, -/** - * Whether this modifier participates in transforms with its host. - */ -linkedToHost: boolean, -/** - * See [`LayerInfo::Raster::editable`] — a modifier is editable when - * neither it nor its host (nor any ancestor of the host) is locked. - */ -editable: boolean, }; - export type LayerInfo = { "type": "raster", id: number, name: string, visible: boolean, locked: boolean, /** * Effective editability — `false` when this node *or any ancestor* @@ -642,6 +633,17 @@ pipeline: string, */ params: Array, } | { "type": "vector", id: number, name: string, visible: boolean, locked: boolean, editable: boolean, canHaveMask: boolean, canRename: boolean, hasThumbnail: boolean, icon: string, kindName: string, opacity: number, blendMode: string, modifiers: Array, } | { "type": "group", id: number, name: string, visible: boolean, locked: boolean, editable: boolean, canHaveMask: boolean, canRename: boolean, hasThumbnail: boolean, icon: string, kindName: string, collapsed: boolean, passthrough: boolean, opacity: number, blendMode: string, modifiers: Array, children: Array, }; +export type ModifierInfo = { id: number, kind: string, name: string, visible: boolean, locked: boolean, +/** + * Whether this modifier participates in transforms with its host. + */ +linkedToHost: boolean, +/** + * See [`LayerInfo::Raster::editable`] — a modifier is editable when + * neither it nor its host (nor any ancestor of the host) is locked. + */ +editable: boolean, }; + export type MaskToSelectionReq = { id: number, }; export type MergeDownReq = { source_id: number, }; @@ -824,6 +826,7 @@ export type RequestKind = | 'add_text_object' | 'add_veil' | 'add_void' + | 'alpha_to_selection' | 'antialias_selection' | 'apply_filter' | 'apply_mask' @@ -1013,6 +1016,7 @@ export const REQUEST_KINDS: readonly RequestKind[] = [ 'add_text_object', 'add_veil', 'add_void', + 'alpha_to_selection', 'antialias_selection', 'apply_filter', 'apply_mask', @@ -1210,6 +1214,7 @@ export interface EngineApi { addTextObject(req: AddTextObjectReq): Promise<{ object: number }>; addVeil(req: AddVeilReq): void; addVoid(req: AddVoidReq): Promise; + alphaToSelection(req: AlphaToSelectionReq): void; antialiasSelection(): void; applyFilter(req: ApplyFilterReq): Promise; applyMask(req: ApplyMaskReq): void; @@ -1401,6 +1406,7 @@ export function makeApi(t: Transport): EngineApi { addTextObject: (req) => t.request('add_text_object', req), addVeil: (req) => t.postFF('add_veil', req), addVoid: (req) => t.request('add_void', req), + alphaToSelection: (req) => t.postFF('alpha_to_selection', req), antialiasSelection: () => t.postFF('antialias_selection'), applyFilter: (req) => t.request('apply_filter', req), applyMask: (req) => t.postFF('apply_mask', req), diff --git a/frontend/src/icons/bundle.generated.ts b/frontend/src/icons/bundle.generated.ts index 3f902362..32b10093 100644 --- a/frontend/src/icons/bundle.generated.ts +++ b/frontend/src/icons/bundle.generated.ts @@ -2,7 +2,7 @@ // Regenerated automatically by the icon-bundle Vite plugin (dev + build) and by // `npm run gen:icons`. Derived from the Iconify icon-name string literals found // in the source, registered for offline rendering. -// 119 icon(s) across 11 collection(s). +// 118 icon(s) across 11 collection(s). /* eslint-disable */ // @ts-nocheck import { addCollection } from '@iconify/svelte/dist/offline-functions.js'; @@ -10,7 +10,7 @@ import { addCollection } from '@iconify/svelte/dist/offline-functions.js'; addCollection({"prefix":"at-icons","icons":{"text":{"body":"","left":0.969,"top":0.969,"width":14.063,"height":14.063}},"lastModified":1784526570}); addCollection({"prefix":"boxicons","icons":{"gradient":{"body":"","left":2.953,"top":2.953,"width":18.094,"height":18.094},"square-dashed":{"body":"","left":2.906,"top":2.953,"width":18.188,"height":18.094}},"lastModified":1771495506,"width":24,"height":24}); addCollection({"prefix":"fa6-brands","icons":{"github":{"body":"","width":496,"left":0,"top":7,"height":486}},"lastModified":1734421834,"width":448,"height":512}); -addCollection({"prefix":"fa6-solid","icons":{"anchor":{"body":"","width":576,"left":0,"top":0,"height":511.875},"arrow-right-arrow-left":{"body":"","width":448,"left":0,"top":0,"height":512},"arrow-up-right-from-square":{"body":"","left":0,"top":0,"width":512,"height":512},"arrows-down-to-line":{"body":"","width":576,"left":0,"top":30.375,"height":451.125},"arrows-left-right":{"body":"","left":0,"top":127,"width":512,"height":258},"arrows-up-down":{"body":"","width":258,"left":31,"top":0,"height":512},"arrows-up-down-left-right":{"body":"","left":0,"top":0,"width":512,"height":512},"ban":{"body":"","left":0,"top":0,"width":512,"height":512},"bars":{"body":"","width":448,"left":0,"top":63,"height":386},"book":{"body":"","width":448,"left":0,"top":0,"height":512},"border-all":{"body":"","width":448,"left":0,"top":31,"height":450},"chart-line":{"body":"","left":0,"top":31,"width":512,"height":450},"check":{"body":"","width":448,"left":0,"top":95,"height":322},"chevron-down":{"body":"","left":31,"top":159,"width":450,"height":258},"chevron-right":{"body":"","width":257,"left":63,"top":31,"height":450},"chevron-up":{"body":"","left":31,"top":95,"width":450,"height":258},"circle":{"body":"","left":0,"top":0,"width":512,"height":512},"circle-dot":{"body":"","left":0,"top":0,"width":512,"height":512},"circle-half-stroke":{"body":"","left":0,"top":0,"width":512,"height":512},"circle-info":{"body":"","left":0,"top":0,"width":512,"height":512},"circle-notch":{"body":"","left":0,"top":8,"width":512,"height":504},"clipboard":{"body":"","width":384,"left":0,"top":0,"height":512},"clock-rotate-left":{"body":"","left":0,"top":0,"width":512,"height":512},"clone":{"body":"","left":0,"top":0,"width":512,"height":512},"compress":{"body":"","width":448,"left":0,"top":31,"height":450},"copy":{"body":"","width":448,"left":0,"top":0,"height":512},"crop-simple":{"body":"","left":0,"top":0,"width":512,"height":512},"crosshairs":{"body":"","left":0,"top":0,"width":512,"height":512},"diagram-project":{"body":"","width":576,"left":0,"top":30.375,"height":451.125},"dice":{"body":"","width":628.75,"left":11.25,"top":11.25,"height":501.25},"display":{"body":"","width":576,"left":0,"top":0,"height":511.875},"down-left-and-up-right-to-center":{"body":"","left":0,"top":0,"width":512,"height":512},"droplet":{"body":"","width":384,"left":0,"top":0,"height":512},"droplet-slash":{"body":"","width":640,"left":0,"top":0,"height":512.5},"eraser":{"body":"","width":508.5,"left":37.125,"top":37.125,"height":444.375},"expand":{"body":"","width":448,"left":0,"top":31,"height":450},"eye":{"body":"","width":576,"left":0,"top":30.375,"height":451.125},"eye-dropper":{"body":"","left":0,"top":0,"width":512,"height":512},"eye-slash":{"body":"","width":640,"left":0,"top":0,"height":512.5},"feather":{"body":"","left":15,"top":0,"width":497,"height":512},"file":{"body":"","width":384,"left":0,"top":0,"height":512},"file-export":{"body":"","width":576,"left":0,"top":0,"height":511.875},"fill-drip":{"body":"","width":574.875,"left":1.125,"top":0,"height":511.875},"floppy-disk":{"body":"","width":448,"left":0,"top":31,"height":450},"folder":{"body":"","left":0,"top":31,"width":512,"height":450},"folder-open":{"body":"","width":576,"left":0,"top":30.375,"height":451.125},"folder-plus":{"body":"","left":0,"top":31,"width":512,"height":450},"gauge-high":{"body":"","left":0,"top":0,"width":512,"height":512},"gear":{"body":"","left":13,"top":0,"width":486,"height":512},"globe":{"body":"","left":0,"top":0,"width":512,"height":512},"grip-lines-vertical":{"body":"","width":192,"left":0,"top":31,"height":450},"grip-vertical":{"body":"","width":320,"left":0,"top":31,"height":450},"image":{"body":"","left":0,"top":31,"width":512,"height":450},"keyboard":{"body":"","width":576,"left":0,"top":63,"height":387},"layer-group":{"body":"","width":515.25,"left":30.375,"top":0,"height":511.875},"left-right":{"body":"","left":0,"top":127,"width":512,"height":258},"link":{"body":"","width":607.5,"left":16.25,"top":20,"height":472.5},"link-slash":{"body":"","width":640,"left":0,"top":0,"height":512.5},"lock":{"body":"","width":448,"left":0,"top":0,"height":512},"lock-open":{"body":"","width":576,"left":0,"top":0,"height":511.875},"magnifying-glass":{"body":"","left":0,"top":0,"width":512,"height":512},"mask":{"body":"","width":576,"left":0,"top":63,"height":387},"maximize":{"body":"","left":31,"top":31,"width":450,"height":450},"minus":{"body":"","width":418,"left":15,"top":223,"height":66},"paint-roller":{"body":"","left":0,"top":0,"width":512,"height":512},"paintbrush":{"body":"","width":545.625,"left":30.375,"top":0,"height":511.875},"palette":{"body":"","left":0,"top":0,"width":512,"height":512},"paste":{"body":"","left":0,"top":0,"width":512,"height":512},"pen":{"body":"","left":0,"top":0,"width":512,"height":512},"pen-nib":{"body":"","left":1,"top":1,"width":510,"height":510},"pen-to-square":{"body":"","left":0,"top":4,"width":508,"height":508},"plus":{"body":"","width":418,"left":15,"top":47,"height":418},"right-left":{"body":"","left":0,"top":0,"width":512,"height":512},"rotate":{"body":"","left":15,"top":31,"width":482,"height":450},"rotate-left":{"body":"","left":15,"top":31,"width":466,"height":450},"rotate-right":{"body":"","left":31,"top":31,"width":466,"height":450},"ruler-horizontal":{"body":"","width":640,"left":0,"top":126.25,"height":260},"scissors":{"body":"","left":0,"top":0,"width":506,"height":512},"screwdriver-wrench":{"body":"","left":0,"top":0,"width":512,"height":512},"sliders":{"body":"","left":0,"top":15,"width":512,"height":482},"square-plus":{"body":"","width":448,"left":0,"top":31,"height":450},"stopwatch":{"body":"","width":418,"left":15,"top":0,"height":512},"sun":{"body":"","left":0,"top":0,"width":512,"height":512},"thumbtack":{"body":"","width":384,"left":0,"top":0,"height":512},"trash":{"body":"","width":448,"left":0,"top":0,"height":512},"triangle-exclamation":{"body":"","left":0,"top":31,"width":512,"height":450},"up-down-left-right":{"body":"","left":0,"top":0,"width":512,"height":512},"up-right-and-down-left-from-center":{"body":"","left":0,"top":0,"width":512,"height":512},"vector-square":{"body":"","width":448,"left":0,"top":31,"height":450},"video":{"body":"","width":576,"left":0,"top":63,"height":387},"wand-magic":{"body":"","left":0,"top":0,"width":512,"height":512},"wand-magic-sparkles":{"body":"","width":545.625,"left":0,"top":0,"height":511.875},"wave-square":{"body":"","width":640,"left":0,"top":30,"height":452.5},"wrench":{"body":"","left":0,"top":0,"width":512,"height":512},"xmark":{"body":"","width":322,"left":31,"top":95,"height":322}},"lastModified":1732030010,"width":512,"height":512}); +addCollection({"prefix":"fa6-solid","icons":{"anchor":{"body":"","width":576,"left":0,"top":0,"height":511.875},"arrow-right-arrow-left":{"body":"","width":448,"left":0,"top":0,"height":512},"arrow-up-right-from-square":{"body":"","left":0,"top":0,"width":512,"height":512},"arrows-down-to-line":{"body":"","width":576,"left":0,"top":30.375,"height":451.125},"arrows-left-right":{"body":"","left":0,"top":127,"width":512,"height":258},"arrows-up-down":{"body":"","width":258,"left":31,"top":0,"height":512},"arrows-up-down-left-right":{"body":"","left":0,"top":0,"width":512,"height":512},"ban":{"body":"","left":0,"top":0,"width":512,"height":512},"bars":{"body":"","width":448,"left":0,"top":63,"height":386},"book":{"body":"","width":448,"left":0,"top":0,"height":512},"border-all":{"body":"","width":448,"left":0,"top":31,"height":450},"chart-line":{"body":"","left":0,"top":31,"width":512,"height":450},"check":{"body":"","width":448,"left":0,"top":95,"height":322},"chevron-down":{"body":"","left":31,"top":159,"width":450,"height":258},"chevron-right":{"body":"","width":257,"left":63,"top":31,"height":450},"chevron-up":{"body":"","left":31,"top":95,"width":450,"height":258},"circle":{"body":"","left":0,"top":0,"width":512,"height":512},"circle-dot":{"body":"","left":0,"top":0,"width":512,"height":512},"circle-half-stroke":{"body":"","left":0,"top":0,"width":512,"height":512},"circle-info":{"body":"","left":0,"top":0,"width":512,"height":512},"circle-notch":{"body":"","left":0,"top":8,"width":512,"height":504},"clipboard":{"body":"","width":384,"left":0,"top":0,"height":512},"clock-rotate-left":{"body":"","left":0,"top":0,"width":512,"height":512},"clone":{"body":"","left":0,"top":0,"width":512,"height":512},"compress":{"body":"","width":448,"left":0,"top":31,"height":450},"copy":{"body":"","width":448,"left":0,"top":0,"height":512},"crop-simple":{"body":"","left":0,"top":0,"width":512,"height":512},"crosshairs":{"body":"","left":0,"top":0,"width":512,"height":512},"diagram-project":{"body":"","width":576,"left":0,"top":30.375,"height":451.125},"dice":{"body":"","width":628.75,"left":11.25,"top":11.25,"height":501.25},"display":{"body":"","width":576,"left":0,"top":0,"height":511.875},"down-left-and-up-right-to-center":{"body":"","left":0,"top":0,"width":512,"height":512},"droplet":{"body":"","width":384,"left":0,"top":0,"height":512},"droplet-slash":{"body":"","width":640,"left":0,"top":0,"height":512.5},"eraser":{"body":"","width":508.5,"left":37.125,"top":37.125,"height":444.375},"expand":{"body":"","width":448,"left":0,"top":31,"height":450},"eye":{"body":"","width":576,"left":0,"top":30.375,"height":451.125},"eye-dropper":{"body":"","left":0,"top":0,"width":512,"height":512},"eye-slash":{"body":"","width":640,"left":0,"top":0,"height":512.5},"feather":{"body":"","left":15,"top":0,"width":497,"height":512},"file":{"body":"","width":384,"left":0,"top":0,"height":512},"file-export":{"body":"","width":576,"left":0,"top":0,"height":511.875},"fill-drip":{"body":"","width":574.875,"left":1.125,"top":0,"height":511.875},"floppy-disk":{"body":"","width":448,"left":0,"top":31,"height":450},"folder":{"body":"","left":0,"top":31,"width":512,"height":450},"folder-open":{"body":"","width":576,"left":0,"top":30.375,"height":451.125},"folder-plus":{"body":"","left":0,"top":31,"width":512,"height":450},"gauge-high":{"body":"","left":0,"top":0,"width":512,"height":512},"gear":{"body":"","left":13,"top":0,"width":486,"height":512},"globe":{"body":"","left":0,"top":0,"width":512,"height":512},"grip-lines-vertical":{"body":"","width":192,"left":0,"top":31,"height":450},"grip-vertical":{"body":"","width":320,"left":0,"top":31,"height":450},"keyboard":{"body":"","width":576,"left":0,"top":63,"height":387},"layer-group":{"body":"","width":515.25,"left":30.375,"top":0,"height":511.875},"left-right":{"body":"","left":0,"top":127,"width":512,"height":258},"link":{"body":"","width":607.5,"left":16.25,"top":20,"height":472.5},"link-slash":{"body":"","width":640,"left":0,"top":0,"height":512.5},"lock":{"body":"","width":448,"left":0,"top":0,"height":512},"lock-open":{"body":"","width":576,"left":0,"top":0,"height":511.875},"magnifying-glass":{"body":"","left":0,"top":0,"width":512,"height":512},"mask":{"body":"","width":576,"left":0,"top":63,"height":387},"maximize":{"body":"","left":31,"top":31,"width":450,"height":450},"minus":{"body":"","width":418,"left":15,"top":223,"height":66},"paint-roller":{"body":"","left":0,"top":0,"width":512,"height":512},"paintbrush":{"body":"","width":545.625,"left":30.375,"top":0,"height":511.875},"palette":{"body":"","left":0,"top":0,"width":512,"height":512},"paste":{"body":"","left":0,"top":0,"width":512,"height":512},"pen":{"body":"","left":0,"top":0,"width":512,"height":512},"pen-nib":{"body":"","left":1,"top":1,"width":510,"height":510},"pen-to-square":{"body":"","left":0,"top":4,"width":508,"height":508},"plus":{"body":"","width":418,"left":15,"top":47,"height":418},"right-left":{"body":"","left":0,"top":0,"width":512,"height":512},"rotate":{"body":"","left":15,"top":31,"width":482,"height":450},"rotate-left":{"body":"","left":15,"top":31,"width":466,"height":450},"rotate-right":{"body":"","left":31,"top":31,"width":466,"height":450},"ruler-horizontal":{"body":"","width":640,"left":0,"top":126.25,"height":260},"scissors":{"body":"","left":0,"top":0,"width":506,"height":512},"screwdriver-wrench":{"body":"","left":0,"top":0,"width":512,"height":512},"sliders":{"body":"","left":0,"top":15,"width":512,"height":482},"square-plus":{"body":"","width":448,"left":0,"top":31,"height":450},"stopwatch":{"body":"","width":418,"left":15,"top":0,"height":512},"sun":{"body":"","left":0,"top":0,"width":512,"height":512},"thumbtack":{"body":"","width":384,"left":0,"top":0,"height":512},"trash":{"body":"","width":448,"left":0,"top":0,"height":512},"triangle-exclamation":{"body":"","left":0,"top":31,"width":512,"height":450},"up-down-left-right":{"body":"","left":0,"top":0,"width":512,"height":512},"up-right-and-down-left-from-center":{"body":"","left":0,"top":0,"width":512,"height":512},"vector-square":{"body":"","width":448,"left":0,"top":31,"height":450},"video":{"body":"","width":576,"left":0,"top":63,"height":387},"wand-magic":{"body":"","left":0,"top":0,"width":512,"height":512},"wand-magic-sparkles":{"body":"","width":545.625,"left":0,"top":0,"height":511.875},"wave-square":{"body":"","width":640,"left":0,"top":30,"height":452.5},"wrench":{"body":"","left":0,"top":0,"width":512,"height":512},"xmark":{"body":"","width":322,"left":31,"top":95,"height":322}},"lastModified":1732030010,"width":512,"height":512}); addCollection({"prefix":"file-icons","icons":{"blender":{"body":"","left":0,"top":47,"width":512,"height":418}},"lastModified":1721244157,"width":512,"height":512}); addCollection({"prefix":"lucide","icons":{"circle-dashed":{"body":"","left":0.938,"top":0.938,"width":22.125,"height":22.125},"triangle-dashed":{"body":"","left":0.938,"top":1.922,"width":22.125,"height":20.156}},"lastModified":1784351942,"width":24,"height":24}); addCollection({"prefix":"lucide-lab","icons":{"venn":{"body":"","left":0.938,"top":4.922,"width":22.125,"height":14.156}},"lastModified":1731133495,"width":24,"height":24}); @@ -19,4 +19,4 @@ addCollection({"prefix":"mdi","icons":{"blur":{"body":"","left":0,"top":0.967,"width":15,"height":13.066},"mask-on":{"body":"","left":0,"top":0.967,"width":15,"height":13.066}},"lastModified":1766212494,"width":15,"height":15}); addCollection({"prefix":"tabler","icons":{"camera":{"body":"","left":1.922,"top":2.953,"width":20.156,"height":18.094},"flip-horizontal":{"body":"","left":1.922,"top":1.922,"width":20.156,"height":20.156},"galaxy":{"body":"","left":3.141,"top":1.922,"width":17.719,"height":20.156},"lasso":{"body":"","left":1.922,"top":1.922,"width":20.156,"height":20.156},"perspective":{"body":"","left":3.938,"top":3.094,"width":16.125,"height":17.813},"ripple":{"body":"","left":1.922,"top":4.453,"width":20.156,"height":15.516},"screen-share":{"body":"","left":1.922,"top":2.953,"width":20.156,"height":18.094},"test":{"body":"","hidden":true,"left":1.922,"top":1.922,"width":20.156,"height":20.156},"vector":{"body":"","left":1.922,"top":1.922,"width":20.156,"height":20.156}},"lastModified":1784526686,"width":24,"height":24}); -export const BUNDLED_ICON_NAMES = ["at-icons:text","boxicons:gradient","boxicons:square-dashed","fa6-brands:github","fa6-solid:anchor","fa6-solid:angles-left-right","fa6-solid:arrow-right-arrow-left","fa6-solid:arrow-up-right-from-square","fa6-solid:arrows-down-to-line","fa6-solid:arrows-left-right","fa6-solid:arrows-up-down","fa6-solid:arrows-up-down-left-right","fa6-solid:ban","fa6-solid:bars","fa6-solid:book","fa6-solid:border-all","fa6-solid:chart-line","fa6-solid:check","fa6-solid:chevron-down","fa6-solid:chevron-right","fa6-solid:chevron-up","fa6-solid:circle","fa6-solid:circle-dot","fa6-solid:circle-half-stroke","fa6-solid:circle-info","fa6-solid:circle-notch","fa6-solid:clipboard","fa6-solid:clock-rotate-left","fa6-solid:clone","fa6-solid:compress","fa6-solid:copy","fa6-solid:crop-simple","fa6-solid:crosshairs","fa6-solid:diagram-project","fa6-solid:dice","fa6-solid:display","fa6-solid:down-left-and-up-right-to-center","fa6-solid:droplet","fa6-solid:droplet-slash","fa6-solid:eraser","fa6-solid:expand","fa6-solid:eye","fa6-solid:eye-dropper","fa6-solid:eye-slash","fa6-solid:feather","fa6-solid:file","fa6-solid:file-export","fa6-solid:fill-drip","fa6-solid:floppy-disk","fa6-solid:folder","fa6-solid:folder-open","fa6-solid:folder-plus","fa6-solid:gauge-high","fa6-solid:gear","fa6-solid:globe","fa6-solid:grip-lines-vertical","fa6-solid:grip-vertical","fa6-solid:icon-name","fa6-solid:image","fa6-solid:keyboard","fa6-solid:layer-group","fa6-solid:left-right","fa6-solid:link","fa6-solid:link-slash","fa6-solid:lock","fa6-solid:lock-open","fa6-solid:magnifying-glass","fa6-solid:mask","fa6-solid:maximize","fa6-solid:minus","fa6-solid:paint-roller","fa6-solid:paintbrush","fa6-solid:palette","fa6-solid:paste","fa6-solid:pen","fa6-solid:pen-nib","fa6-solid:pen-to-square","fa6-solid:plus","fa6-solid:right-left","fa6-solid:rotate","fa6-solid:rotate-left","fa6-solid:rotate-right","fa6-solid:ruler-horizontal","fa6-solid:scissors","fa6-solid:screwdriver-wrench","fa6-solid:sliders","fa6-solid:square-plus","fa6-solid:stopwatch","fa6-solid:sun","fa6-solid:thumbtack","fa6-solid:trash","fa6-solid:triangle-exclamation","fa6-solid:up-down-left-right","fa6-solid:up-right-and-down-left-from-center","fa6-solid:vector-square","fa6-solid:video","fa6-solid:wand-magic","fa6-solid:wand-magic-sparkles","fa6-solid:wave-square","fa6-solid:wrench","fa6-solid:xmark","file-icons:blender","lucide-lab:venn","lucide:circle-dashed","lucide:triangle-dashed","material-symbols:curtains-rounded","mdi:blur","mdi:gesture-swipe","radix-icons:mask-off","radix-icons:mask-on","tabler:camera","tabler:flip-horizontal","tabler:galaxy","tabler:lasso","tabler:perspective","tabler:ripple","tabler:screen-share","tabler:test","tabler:vector"]; +export const BUNDLED_ICON_NAMES = ["at-icons:text","boxicons:gradient","boxicons:square-dashed","fa6-brands:github","fa6-solid:anchor","fa6-solid:angles-left-right","fa6-solid:arrow-right-arrow-left","fa6-solid:arrow-up-right-from-square","fa6-solid:arrows-down-to-line","fa6-solid:arrows-left-right","fa6-solid:arrows-up-down","fa6-solid:arrows-up-down-left-right","fa6-solid:ban","fa6-solid:bars","fa6-solid:book","fa6-solid:border-all","fa6-solid:chart-line","fa6-solid:check","fa6-solid:chevron-down","fa6-solid:chevron-right","fa6-solid:chevron-up","fa6-solid:circle","fa6-solid:circle-dot","fa6-solid:circle-half-stroke","fa6-solid:circle-info","fa6-solid:circle-notch","fa6-solid:clipboard","fa6-solid:clock-rotate-left","fa6-solid:clone","fa6-solid:compress","fa6-solid:copy","fa6-solid:crop-simple","fa6-solid:crosshairs","fa6-solid:diagram-project","fa6-solid:dice","fa6-solid:display","fa6-solid:down-left-and-up-right-to-center","fa6-solid:droplet","fa6-solid:droplet-slash","fa6-solid:eraser","fa6-solid:expand","fa6-solid:eye","fa6-solid:eye-dropper","fa6-solid:eye-slash","fa6-solid:feather","fa6-solid:file","fa6-solid:file-export","fa6-solid:fill-drip","fa6-solid:floppy-disk","fa6-solid:folder","fa6-solid:folder-open","fa6-solid:folder-plus","fa6-solid:gauge-high","fa6-solid:gear","fa6-solid:globe","fa6-solid:grip-lines-vertical","fa6-solid:grip-vertical","fa6-solid:icon-name","fa6-solid:keyboard","fa6-solid:layer-group","fa6-solid:left-right","fa6-solid:link","fa6-solid:link-slash","fa6-solid:lock","fa6-solid:lock-open","fa6-solid:magnifying-glass","fa6-solid:mask","fa6-solid:maximize","fa6-solid:minus","fa6-solid:paint-roller","fa6-solid:paintbrush","fa6-solid:palette","fa6-solid:paste","fa6-solid:pen","fa6-solid:pen-nib","fa6-solid:pen-to-square","fa6-solid:plus","fa6-solid:right-left","fa6-solid:rotate","fa6-solid:rotate-left","fa6-solid:rotate-right","fa6-solid:ruler-horizontal","fa6-solid:scissors","fa6-solid:screwdriver-wrench","fa6-solid:sliders","fa6-solid:square-plus","fa6-solid:stopwatch","fa6-solid:sun","fa6-solid:thumbtack","fa6-solid:trash","fa6-solid:triangle-exclamation","fa6-solid:up-down-left-right","fa6-solid:up-right-and-down-left-from-center","fa6-solid:vector-square","fa6-solid:video","fa6-solid:wand-magic","fa6-solid:wand-magic-sparkles","fa6-solid:wave-square","fa6-solid:wrench","fa6-solid:xmark","file-icons:blender","lucide-lab:venn","lucide:circle-dashed","lucide:triangle-dashed","material-symbols:curtains-rounded","mdi:blur","mdi:gesture-swipe","radix-icons:mask-off","radix-icons:mask-on","tabler:camera","tabler:flip-horizontal","tabler:galaxy","tabler:lasso","tabler:perspective","tabler:ripple","tabler:screen-share","tabler:test","tabler:vector"]; diff --git a/frontend/src/state/layerPicker.svelte.ts b/frontend/src/state/layerPicker.svelte.ts new file mode 100644 index 00000000..45e9d788 --- /dev/null +++ b/frontend/src/state/layerPicker.svelte.ts @@ -0,0 +1,12 @@ +/** + * Which "add a layer" picker modal is open, if any. The `newVeil` / `newVoid` + * / `newFilterLayer` actions set this; `ui/layers/LayerPickers.svelte` mounts + * the matching modal. + */ +export type LayerPickerKind = 'veil' | 'void' | 'filter'; + +class LayerPickerState { + kind = $state(null); +} + +export const layerPicker = new LayerPickerState(); diff --git a/frontend/src/tools/__tests__/transform_menu.test.ts b/frontend/src/tools/__tests__/transform_menu.test.ts index 179a1a4f..9b28ae66 100644 --- a/frontend/src/tools/__tests__/transform_menu.test.ts +++ b/frontend/src/tools/__tests__/transform_menu.test.ts @@ -51,6 +51,7 @@ vi.mock('../../canvas/gpu_overlay', () => ({ import { transformTool } from '../transform.svelte'; import { SessionEngine } from '../tool_session'; +import { mat3Apply, type Mat3 } from '../transform_projective'; withApi(fakeApp.engine); @@ -63,6 +64,7 @@ type TransformToolLike = { availableModes(): { tag: number; label: string }[]; activeModeTag(): number | null; setMode(tag: number): void; + flip(axis: 'h' | 'v'): void; }; const tool = transformTool.create(fakeApp as never) as unknown as TransformToolLike; @@ -129,3 +131,53 @@ describe('transform right-click mode menu', () => { expect(persp![1].transform.data.length).toBe(9); }); }); + +/** The matrix payload of the most recent transform push through the binding. */ +function lastPushedMatrix(): { mode: string; data: number[] } { + const calls = fakeApp.engine.post.mock.calls.filter((c) => c[0] === 'update_void_transform'); + expect(calls.length).toBeGreaterThan(0); + return calls[calls.length - 1][1].transform; +} + +describe('transform menu flips', () => { + beforeEach(async () => { + fakeApp.engine.post.mockClear(); + (fakeApp.session as SessionEngine | null)?.kill(); + fakeApp.session = new SessionEngine(fakeApp.engine as never); + await activeTool(); + fakeApp.engine.post.mockClear(); + }); + + // The fake void is a 100×80 rect at the origin under the identity matrix, + // so a mirror about its centre is exactly `x → 100 - x` / `y → 80 - y`. + it('flips horizontally about the source centre', () => { + tool.flip('h'); + expect(lastPushedMatrix()).toEqual({ mode: 'Basic', data: [-1, 0, 100, 0, 1, 0] }); + }); + + it('flips vertically about the source centre', () => { + tool.flip('v'); + expect(lastPushedMatrix()).toEqual({ mode: 'Basic', data: [1, 0, 0, 0, -1, 80] }); + }); + + it('flipping both axes is a 180° turn, and flipping twice restores the original', () => { + tool.flip('h'); + tool.flip('v'); + expect(lastPushedMatrix().data).toEqual([-1, 0, 100, 0, -1, 80]); + tool.flip('h'); + tool.flip('v'); + expect(lastPushedMatrix().data).toEqual([1, 0, 0, 0, 1, 0]); + }); + + it('mirrors a perspective quad in place, swapping its left and right edges', () => { + tool.setMode(1); + const before = lastPushedMatrix().data as Mat3; + tool.flip('h'); + const after = lastPushedMatrix(); + expect(after.mode).toBe('Perspective'); + // Source TL now lands where TR did, and vice versa — same quad, mirrored + // content. + expect(mat3Apply(after.data as Mat3, 0, 0)).toEqual(mat3Apply(before, 100, 0)); + expect(mat3Apply(after.data as Mat3, 100, 80)).toEqual(mat3Apply(before, 0, 80)); + }); +}); diff --git a/frontend/src/tools/transform.svelte.ts b/frontend/src/tools/transform.svelte.ts index 416f625a..152cb7f0 100644 --- a/frontend/src/tools/transform.svelte.ts +++ b/frontend/src/tools/transform.svelte.ts @@ -76,6 +76,12 @@ class TransformTool extends ToolBase { this.gizmo?.setMode(tag); } + /** Mirror the content being transformed within its bounding quad (menu + * action). No-op when no gizmo is up. */ + flip(axis: 'h' | 'v'): void { + this.gizmo?.flip(axis); + } + /** Apply this variant's entry mode after a fresh attach. Mode 0 is the * adopted default, so only a non-zero entry mode is seeded. */ private applyEntryMode(): void { @@ -148,8 +154,8 @@ class TransformTool extends ToolBase { async onPointerDown(e: PointerEvent, cx: number, cy: number): Promise { if (!this.gizmo) return; - // Right-click inside the active object opens the mode-switch menu - // (Free transform / Perspective / …). Always swallow button 2 so it + // Right-click inside the active object opens the transform menu (mode + // switch + flips). Always swallow button 2 so it // never starts a drag (the browser context menu is suppressed // app-wide in CanvasView). if (e.button === 2) { diff --git a/frontend/src/tools/transform_gizmo.ts b/frontend/src/tools/transform_gizmo.ts index 56a52385..1c1db9f9 100644 --- a/frontend/src/tools/transform_gizmo.ts +++ b/frontend/src/tools/transform_gizmo.ts @@ -15,7 +15,7 @@ import { app } from '../state/app.svelte'; import type { SessionEngine } from './tool_session'; import { OverlayBuilder } from '../canvas/gpu_overlay'; -import { MAT3_IDENTITY, type Mat3 } from './transform_projective'; +import { mat3Multiply, MAT3_IDENTITY, type Mat3 } from './transform_projective'; import { allModes, modeForTag, @@ -168,21 +168,36 @@ export class TransformGizmo { if (!this.binding || this.mode.tag === tag) return; const target = modeForTag(tag); const m = target.seedMatrix(this.geo); - this.geo.matrix = m; this.mode = target; - this.binding.update(m, tag); - this.rebuildOverlay(); - app.requestFrame(); + this.applyMatrix(m); + } + + /** + * Mirror the content about the source rect's centre, horizontally (`'h'`) or + * vertically (`'v'`) — Krita's transform-tool `Mirror Horizontal` / + * `Mirror Vertical` (`kis_tool_transform_config_widget.cpp::slotFlipX`, + * which negates `scaleX` around the anchor). + * + * Composing the mirror in *source* space (`M · mirror`) leaves the + * destination quad exactly where it is and only swaps which side of the + * content lands on which edge. That makes it exact and mode-agnostic: every + * mode's matrix maps the source rect onto that quad, so the same + * composition mirrors an affine box and a perspective quad alike. + */ + flip(axis: 'h' | 'v'): void { + const { srcW, srcH } = this.geo; + if (!this.binding || srcW <= 0 || srcH <= 0) return; + const mirror: Mat3 = + axis === 'h' + ? [-1, 0, srcW, 0, 1, 0, 0, 0, 1] + : [1, 0, 0, 0, -1, srcH, 0, 0, 1]; + this.applyMatrix(mat3Multiply(this.geo.matrix, mirror)); } pointerMove(cx: number, cy: number, shift: boolean): void { if (!this.binding) return; if (this.drag != null) { - const m = this.mode.updateDrag(this.geo, this.drag, cx, cy, shift); - this.geo.matrix = m; - this.binding.update(m, this.mode.tag); - this.rebuildOverlay(); - app.requestFrame(); + this.applyMatrix(this.mode.updateDrag(this.geo, this.drag, cx, cy, shift)); } else { app.toolCursor = this.mode.resolveHandle(this.geo, this.overlay, this.bbox, cx, cy).cursor; } @@ -206,6 +221,17 @@ export class TransformGizmo { app.requestFrame(); } + /** Adopt `m` as the current transform: store it, push it through the binding + * under the active mode, and redraw. The one path every matrix edit (drag, + * mode switch, flip) takes. */ + private applyMatrix(m: Mat3): void { + if (!this.binding) return; + this.geo.matrix = m; + this.binding.update(m, this.mode.tag); + this.rebuildOverlay(); + app.requestFrame(); + } + private rebuildOverlay(): void { const engine = this.session(); if (!engine) return; diff --git a/frontend/src/ui/TransformModeMenu.svelte b/frontend/src/ui/TransformModeMenu.svelte index c4a9797c..2e0f7df1 100644 --- a/frontend/src/ui/TransformModeMenu.svelte +++ b/frontend/src/ui/TransformModeMenu.svelte @@ -11,16 +11,24 @@ // tool (not reactive state), so this derived would otherwise compute once // and freeze. Reading the reactive `menu` ties it to each menu open, // re-resolving the active mode (the checkmark) every time. + // + // Mode switches first, then the flips — the same grouping Krita's transform + // tool uses (`kis_tool_transform.cc::popupActionsMenu`). let items = $derived.by(() => { void menu; const tool = focusedTransformTool(); if (!tool) return []; const active = tool.activeModeTag(); - return tool.availableModes().map((m) => ({ - label: m.label, - checked: m.tag === active, - onclick: () => tool.setMode(m.tag), - })); + return [ + ...tool.availableModes().map((m) => ({ + label: m.label, + checked: m.tag === active, + onclick: () => tool.setMode(m.tag), + })), + { separator: true }, + { label: 'Flip Horizontally', onclick: () => tool.flip('h') }, + { label: 'Flip Vertically', onclick: () => tool.flip('v') }, + ]; }); diff --git a/frontend/src/ui/__tests__/transformModeMenu.component.test.ts b/frontend/src/ui/__tests__/transformModeMenu.component.test.ts new file mode 100644 index 00000000..85fdddac --- /dev/null +++ b/frontend/src/ui/__tests__/transformModeMenu.component.test.ts @@ -0,0 +1,71 @@ +// @vitest-environment jsdom +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { mount, unmount } from 'svelte'; + +// The menu renders against the focused instance's transform tool: modes come +// from the gizmo, the flips are plain actions on the tool. +const { fakeApp, fakeTool } = vi.hoisted(() => ({ + fakeApp: { transformModeMenu: { x: 10, y: 20 } as { x: number; y: number } | null }, + fakeTool: { + availableModes: () => [ + { tag: 0, label: 'Free transform' }, + { tag: 1, label: 'Perspective' }, + ], + activeModeTag: () => 0, + setMode: vi.fn(), + flip: vi.fn(), + }, +})); +vi.mock('../../state/app.svelte', () => ({ app: fakeApp })); +vi.mock('../../tools/transform.svelte', () => ({ focusedTransformTool: () => fakeTool })); + +import TransformModeMenu from '../TransformModeMenu.svelte'; + +const mounted: Array> = []; + +function render() { + const target = document.createElement('div'); + document.body.append(target); + mounted.push(mount(TransformModeMenu, { target }) as Record); + return target; +} + +afterEach(() => { + for (const instance of mounted.splice(0)) void unmount(instance); + document.body.innerHTML = ''; + fakeTool.flip.mockClear(); +}); + +/** Menu rows render their text in a `.label` span. */ +function labels(target: HTMLElement): string[] { + return Array.from(target.querySelectorAll('button .label')).map((s) => s.textContent ?? ''); +} + +function click(target: HTMLElement, label: string) { + const row = Array.from(target.querySelectorAll('button')).find( + (b) => b.querySelector('.label')?.textContent === label, + ); + if (!row) throw new Error(`Missing menu item: ${label}`); + row.click(); +} + +describe('transform right-click menu', () => { + it('offers the flips below the modes', () => { + const target = render(); + expect(labels(target)).toEqual([ + 'Free transform', + 'Perspective', + 'Flip Horizontally', + 'Flip Vertically', + ]); + expect(target.querySelectorAll('.context-menu-sep').length).toBe(1); + }); + + it('routes each flip to its axis on the tool', () => { + const target = render(); + click(target, 'Flip Horizontally'); + expect(fakeTool.flip).toHaveBeenCalledWith('h'); + click(target, 'Flip Vertically'); + expect(fakeTool.flip).toHaveBeenLastCalledWith('v'); + }); +}); diff --git a/frontend/src/ui/layers/LayerFooter.svelte b/frontend/src/ui/layers/LayerFooter.svelte index faead7dd..d065bf1a 100644 --- a/frontend/src/ui/layers/LayerFooter.svelte +++ b/frontend/src/ui/layers/LayerFooter.svelte @@ -1,9 +1,6 @@ + +{#if layerPicker.kind === 'veil'} + +{:else if layerPicker.kind === 'void'} + +{:else if layerPicker.kind === 'filter'} + +{/if} diff --git a/frontend/src/ui/layers/NewLayerMenu.svelte b/frontend/src/ui/layers/NewLayerMenu.svelte index 7e6740e6..30fb4dad 100644 --- a/frontend/src/ui/layers/NewLayerMenu.svelte +++ b/frontend/src/ui/layers/NewLayerMenu.svelte @@ -1,12 +1,24 @@