From b31a94921583143a20f42caea3bc026d0976defe Mon Sep 17 00:00:00 2001 From: Your Name Date: Sun, 26 Jul 2026 06:08:07 +0300 Subject: [PATCH] feat: implement Clone Stamp tool with GUI integration - Added Clone Stamp implementation in `clone_tool.rs` for engine API, enabling pixel sampling and painting functionality. - Introduced a new SVG icon for the Clone Stamp tool. - Created `clone_tool.rs` in the GUI crate to manage Clone Stamp UI state and interactions. - Developed `clone_tool_controls.rs` to render Clone Stamp controls, including size, opacity, hardness, and mirroring options. - Implemented error handling and source status messaging for user feedback in the Clone Stamp workflow. --- .../1784990207115-clone-tool-specification.md | 422 +++++++++ hcie-engine-api/src/clone_tool.rs | 854 ++++++++++++++++++ hcie-engine-api/src/ffi.rs | 1 + hcie-engine-api/src/layer_property_ops.rs | 4 + hcie-engine-api/src/lib.rs | 10 + hcie-engine-api/src/partial_composite.rs | 15 + hcie-engine-api/src/stroke_cache.rs | 7 +- hcie-iced-app/assets/icons/clone_stamp.svg | 5 + hcie-iced-app/crates/hcie-iced-gui/src/app.rs | 222 ++++- .../crates/hcie-iced-gui/src/canvas/mod.rs | 148 ++- .../hcie-iced-gui/src/canvas/shader_canvas.rs | 3 + .../crates/hcie-iced-gui/src/clone_tool.rs | 89 ++ .../crates/hcie-iced-gui/src/dock/view.rs | 21 +- .../crates/hcie-iced-gui/src/main.rs | 1 + .../src/panels/clone_tool_controls.rs | 95 ++ .../crates/hcie-iced-gui/src/panels/menus.rs | 6 + .../crates/hcie-iced-gui/src/panels/mod.rs | 1 + .../hcie-iced-gui/src/panels/properties.rs | 2 + .../hcie-iced-gui/src/panels/tool_settings.rs | 6 +- .../hcie-iced-gui/src/panels/toolbar.rs | 15 +- .../crates/hcie-iced-gui/src/settings.rs | 67 ++ .../crates/hcie-iced-gui/src/sidebar/mod.rs | 3 + hcie-protocol/src/tools.rs | 12 +- hcie-protocol/tests/helpers.rs | 2 +- 24 files changed, 1985 insertions(+), 26 deletions(-) create mode 100644 .kilo/plans/1784990207115-clone-tool-specification.md create mode 100644 hcie-engine-api/src/clone_tool.rs create mode 100644 hcie-iced-app/assets/icons/clone_stamp.svg create mode 100644 hcie-iced-app/crates/hcie-iced-gui/src/clone_tool.rs create mode 100644 hcie-iced-app/crates/hcie-iced-gui/src/panels/clone_tool_controls.rs diff --git a/.kilo/plans/1784990207115-clone-tool-specification.md b/.kilo/plans/1784990207115-clone-tool-specification.md new file mode 100644 index 0000000..bcd93e6 --- /dev/null +++ b/.kilo/plans/1784990207115-clone-tool-specification.md @@ -0,0 +1,422 @@ +# Clone Tool Technical Specification and Implementation Plan + +## Goal + +Add a raster **Clone Stamp** tool optimized for object removal. The user selects a merged-visible source with `Ctrl+Alt+Click`, then paints sampled pixels onto the active raster layer using a stable source-to-target transform. + +The implementation must preserve the existing engine API boundary, partial dirty-region rendering, pooled stroke masks, selection constraints, effects/cache behavior, and one-step undo per stroke. + +## Confirmed Product Decisions + +- Tool name: **Clone Stamp** (`Tool::CloneStamp`). +- Source gesture: `Ctrl+Alt+Click`; use `Command+Option+Click` on macOS. +- Source mode: merged visible image only for the first release. +- Snapshot lifetime: capture and freeze a full merged-visible snapshot when the source is selected; retain it until clear/reselect/document close. +- Source state is document-local and is not persisted to settings. +- Add an **Aligned** toggle: + - On: preserve the first source-to-target transform across strokes. + - Off: restart from the selected source anchor on every target press. +- Mirror X and Mirror Y are independent fixed affine sampling transforms, not random per-dab flips. +- Color Variation is deterministic per-dab subtle HSL variation; preserve source alpha. +- Clone painting targets editable raster pixels and respects the active selection mask. +- Initial release rejects painting while editing a grayscale layer mask. +- Out-of-canvas source samples are transparent; destination writes are clipped to canvas bounds. +- Bilinear source sampling uses premultiplied alpha to avoid transparent-edge color fringes. +- Existing project rules keep `hcie-egui-app/` frozen. The egui section below is implementation-ready but blocked until project governance formally removes that restriction. + +## User Workflow + +1. Select **Clone Stamp** from the Retouch tool group. +2. Panel shows `No source selected` and the canvas uses an awaiting-source cursor. +3. `Ctrl+Alt+Click` a valid canvas point. +4. Engine captures a merged-visible immutable snapshot and stores the source anchor; no pixels or history are changed. +5. Normal press on an editable raster layer establishes the target anchor and starts one clone transaction. +6. Dragging paints source pixels according to relative displacement, mirror settings, brush coverage, opacity, pressure, selection, and color variation. +7. Release commits one `Clone Stamp` history item. +8. **Clear Source** removes the snapshot and blocks painting. **Reselect Source** arms the next normal canvas click as an accessible alternative to the modifier gesture. + +## Sampling Transform + +Let: + +- `S` be the selected source anchor. +- `T` be the target reference anchor. +- `P` be a destination pixel. +- `M = diag(mirror_x ? -1 : 1, mirror_y ? -1 : 1)`. + +Sample coordinate: + +```text +Q = S + M * (P - T) +``` + +This affine mapping is global for the stroke and avoids seams between overlapping dabs. + +- Aligned on: retain the first `T` until source selection is cleared/replaced. +- Aligned off: set `T` from each new stroke press. +- Source reselection clears retained `T`. + +## Brush Coverage and Blending + +- Size range: `1..=500 px`, default `40 px`. +- Opacity range: `0..=1`, default `1.0`. +- Hardness range: `0..=1`, default `0.75`. +- Internal spacing: default `0.15 * diameter`, clamped to at least one pixel; use interpolated dabs between pointer events. +- Radial coverage: + - Radius `r = size * pressure / 2`. + - Fully opaque core radius `hardness * r`. + - Smoothstep transition from core to edge. + - Retain a one-pixel antialias band at hardness `1.0`. +- Effective alpha: + +```text +source_alpha * coverage * opacity * pressure * selection_alpha +``` + +- Composite sampled RGBA over the destination using the same straight/premultiplied conventions as existing brush operations. +- Reuse `active_stroke_mask` and the pre-stroke destination baseline so overlapping dabs do not exceed the requested stroke opacity. + +## Color Variation + +- UI exposes an enable checkbox and amount `0..=100%`; default off/zero. +- Generate one deterministic variation tuple per dab from a stroke seed and dab index. +- At 100% amount, cap variation at approximately: + - Hue: `±12 degrees`. + - Saturation: `±0.08`. + - Lightness: `±0.06`. +- Scale each range linearly by amount. +- Apply the same tuple to all source pixels in one dab to avoid pixel noise. +- Clamp HSL channels and preserve source alpha. +- Determinism is required for repeatable tests and stable redraw behavior. + +## Engine and Protocol Architecture + +### Protocol identity + +Temporarily unlock `hcie-protocol`, then update `hcie-protocol/src/tools.rs`: + +- Append `Tool::CloneStamp` without renumbering/reordering existing external IDs. +- Add it to `Tool::ALL`, `label`, `icon`, `is_raster`, `is_raster_compatible`, `allowed_layer_type`, `shows_pen_tips`, `has_size`, and `has_opacity`. +- Classify it as Raster-only. +- Update exhaustive tool matches and serialization tests. +- Add a new stable FFI numeric mapping at the end of `hcie-engine-api/src/ffi.rs`; never shift existing IDs. + +Do not add GUI dependencies on `hcie-protocol`; continue importing `Tool` only through `hcie-engine-api`. + +### New engine API module + +Temporarily unlock `hcie-engine-api` and add `hcie-engine-api/src/clone_tool.rs`. + +Public API-visible types: + +```rust +pub struct CloneConfig { + pub size: f32, + pub opacity: f32, + pub hardness: f32, + pub aligned: bool, + pub mirror_x: bool, + pub mirror_y: bool, + pub color_variation: f32, +} + +pub struct CloneSourceInfo { + pub x: f32, + pub y: f32, + pub width: u32, + pub height: u32, + pub has_aligned_target: bool, +} + +pub enum CloneError { + SourceNotSelected, + SourceOutOfBounds, + DestinationNotEditable, + LayerMaskEditingUnsupported, + StrokeAlreadyActive, + StrokeNotActive, + DimensionMismatch, +} +``` + +Expose methods on `Engine`: + +```rust +set_clone_config(config) -> CloneConfig +clone_config() -> CloneConfig +set_clone_source(x, y) -> Result +clear_clone_source() +clone_source_info() -> Option +begin_clone_stroke(x, y, pressure) -> Result<(), CloneError> +clone_stroke_to(x, y, pressure) -> Result<(), CloneError> +end_clone_stroke() -> Result<(), CloneError> +cancel_clone_stroke() +``` + +Clamp all incoming configuration values inside the API. + +### Engine-owned state + +Add clone state to `Engine`, preferably grouped in a private `CloneState`: + +- `config`. +- `source_anchor`. +- immutable merged RGBA snapshot and dimensions. +- optional aligned target anchor. +- active stroke target anchor. +- previous destination point and spacing accumulator. +- deterministic stroke seed and dab index. + +Memory note: a 4K snapshot costs about 33 MB per document with an active clone source. Free it on clear, document close, canvas resize/crop, or incompatible document replacement. + +### Source capture + +`set_clone_source` must: + +1. Validate finite in-bounds coordinates. +2. Produce a current merged-visible image including visible layers, masks, clipping, opacity, blend modes, and effects. +3. Capture without consuming or losing pending GUI dirty-region state. Add/use a non-consuming internal composite snapshot path; do not call a public full-composite getter if it clears dirty state without restoring it. +4. Store immutable pixels and source anchor. +5. Clear any retained aligned target anchor. +6. Return source info without creating history or marking the document modified. + +### Clone stroke lifecycle + +`begin_clone_stroke` must: + +1. Require a source snapshot. +2. Reject layer-mask editing and non-editable/non-raster destinations. +3. Resolve/reset target anchor according to `aligned`. +4. Enter the existing pooled stroke lifecycle: effect suspension, selection-mask cache, destination before-buffer/history capture, active stroke mask, below-layer cache. +5. Mark history kind/name as `Clone Stamp` rather than `Brush Stroke (...)`. +6. Paint the initial dab. + +`clone_stroke_to` must: + +1. Interpolate dabs using accumulated spacing. +2. Iterate only each destination dab rectangle. +3. Compute affine source coordinates per destination pixel. +4. Bilinearly sample premultiplied RGBA from the frozen source snapshot. +5. Apply deterministic HSL variation per dab. +6. Apply coverage, pressure, opacity, selection mask, and pooled stroke-opacity cap. +7. Union exact destination dirty bounds and history bounds. +8. Invalidate effects/composite/tile regions using the same optimized path as normal raster strokes. + +`end_clone_stroke` must delegate to the established asynchronous sub-rectangle history flow and retain the source snapshot for subsequent strokes. + +`cancel_clone_stroke` must restore the pre-stroke destination and leave no history entry. + +### History extension + +- Extend active-stroke metadata with an explicit history label or stroke kind. +- Existing brush behavior defaults to its current label. +- Clone produces exactly one `Clone Stamp` item per completed press-drag-release. +- Source selection, clear, and reselect produce no history entries. + +## Iced UI Architecture + +### Feature modules + +Following the new-feature/new-file preference, add: + +- `hcie-iced-app/crates/hcie-iced-gui/src/clone_tool.rs`: GUI presentation/state helpers and engine-config conversion; no pixel mutation. +- `hcie-iced-app/crates/hcie-iced-gui/src/panels/clone_tool_controls.rs`: shared Clone Tool panel controls. +- Register the panel helper in `panels/mod.rs`. + +### Persistent versus transient state + +Persist in `settings.rs::ToolSettings`, each with `#[serde(default)]` or explicit default functions: + +- clone size, opacity, hardness. +- aligned. +- mirror X/Y. +- color variation enabled and amount. + +Do not persist source coordinates, target anchor, source snapshot, or active-stroke state. Those remain inside each document's `Engine`. + +### Messages + +Add dedicated messages, not shared brush messages: + +```text +CloneSizeChanged +CloneOpacityChanged +CloneHardnessChanged +CloneAlignedToggled +CloneMirrorXToggled +CloneMirrorYToggled +CloneColorVariationToggled +CloneColorVariationChanged +CloneClearSource +CloneArmSourceSelection +CloneSourceSelected / CloneSourceSelectionFailed +``` + +Each parameter message updates settings, saves, and sends a complete clamped `CloneConfig` to the active document engine. On tab switch, apply persisted config to that document while retaining its document-local source. + +### Shared panel component + +`clone_tool_controls::view(...) -> Element` renders: + +1. **Object Removal** instruction: `Ctrl+Alt+Click a clean area, then paint over the object.` +2. Source status banner: + - `No source selected`. + - `Select a source on the canvas`. + - `Source: x, y (Merged Visible)`. +3. **Choose/Reselect Source** and **Clear Source** buttons. +4. Size slider. +5. Opacity slider. +6. Hardness slider with edge-softness tooltip. +7. Aligned checkbox. +8. Mirror X and Mirror Y checkboxes on one row. +9. Color Variation checkbox and conditional amount slider. + +Reuse this component from both active surfaces: + +- `panels/tool_settings.rs` as the canonical configuration panel. +- `panels/properties.rs` for contextual parity. + +Keep `panels/tool_options.rs` out of scope because its `_view()` is unused. Update active `panels/toolbar.rs` only to expose Clone size/opacity if it already shows shared raster controls. + +### Toolbox/menu/shortcut + +- Add `CloneStamp` to the Retouch `TOOL_SLOTS` group in `sidebar/mod.rs`. +- Add a dedicated clone-stamp SVG icon and exhaustive icon mapping. +- Add Clone Stamp to the Tools menu near Spot Removal. +- Assign `S` if the final shortcut audit confirms it is unclaimed for tool selection; otherwise use `Shift+S` and display the actual shortcut in the sidebar tooltip. +- Add it to raster cursor/footprint and 60 Hz canvas-frame scheduling matches. + +### Input routing + +- Carry a modifier snapshot with `CanvasPointerPressed` (or equivalent canvas event) instead of relying solely on separately ordered global modifier messages. +- On Clone Stamp, process `Ctrl+Alt`/`Command+Option` before generic Ctrl color picking. +- Source-selection press: + - call `set_clone_source`; + - do not enter drawing state; + - refresh source status and overlay. +- Armed source selection consumes the next normal left press, then disarms. +- Normal press calls `begin_clone_stroke`; move calls `clone_stroke_to`; release calls `end_clone_stroke` and existing pending-history polling. +- If no source exists, consume normal paint presses and show a non-modal status message rather than silently doing nothing. + +### Canvas overlay + +Extend the existing overlay Canvas in `canvas/mod.rs`; do not touch protected shader texture upload, viewport, nearest filtering, or dirty accumulator code. + +Render: + +- destination brush footprint using clone size/hardness. +- fixed selected-source marker. +- while hovering/painting, current sampled-source marker derived from the affine transform. +- optional thin line from sampled source to destination cursor while painting. +- distinct awaiting-source cursor/status. + +Clip overlay geometry to canvas bounds and reuse existing canvas-to-screen transforms. + +## egui Component Architecture (Conditionally Blocked) + +This phase may begin only after the project instruction declaring `hcie-egui-app/` frozen is formally changed. Until then, treat these files as read-only. + +When unblocked: + +- Add clone configuration fields to legacy `ToolConfigs` in `app/tool_state.rs`, mirroring `CloneConfig` defaults. +- Add `CloneStamp` to legacy `TOOL_SLOTS`, toolbox icon mapping, menu, and shortcut routing. +- Add an immediate-mode reusable component: + +```rust +fn show_clone_tool_controls( + ui: &mut egui::Ui, + config: &mut CloneConfig, + source: Option<&CloneSourceInfo>, +) -> ClonePanelAction +``` + +- Use legacy `PlainSlider`, `ui.checkbox`, source-status labels, and `ui.horizontal` action buttons. +- Return actions such as `None`, `ConfigChanged`, `ArmSource`, and `ClearSource`; application code invokes only `hcie-engine-api` methods. +- In the egui canvas event path, test Ctrl+Alt before color-pick/pan branches, then route press/move/release to the same engine API. +- Draw source/target markers in the existing canvas overlay paint pass. +- Do not duplicate clone pixel logic in egui. + +## Failure Modes and UX + +- No source: block stroke and display source-selection instruction. +- Source outside canvas/non-finite: return error; retain prior valid source. +- Canvas resize/crop/document reload: clear source snapshot and aligned anchor. +- Active target is locked, hidden as non-editable, or non-raster: block with status/toast. +- Editing layer mask: block with explicit unsupported message. +- Source sample outside snapshot: transparent sample; never wrap. +- Undo/redo during active clone stroke: cancel stroke first, then perform history action. +- Tool/document switch during active stroke: end or cancel according to existing pointer-capture policy; never leave effects suspended. +- Source remains valid if original source layers later change because snapshot lifetime is explicitly frozen until reselect. + +## Ordered Implementation Plan + +1. Add protocol `CloneStamp` identity and update all exhaustive metadata/classification/FFI mappings; rebuild and re-lock protocol. +2. Add engine API clone types, state, clamping, source capture, query, and clear operations in a new module. +3. Implement premultiplied bilinear sampling, affine mirror mapping, radial hardness coverage, deterministic HSL variation, and destination blending as private tested helpers. +4. Implement clone begin/move/end/cancel using existing pooled stroke, selection, dirty-region, effects, tile, below-cache, and asynchronous history machinery. +5. Extend history labeling without altering ordinary brush labels. +6. Add engine/API tests before GUI integration. +7. Add Iced clone GUI helper/state module, dedicated messages, persisted serde-safe settings, and per-document source querying. +8. Add shared Iced controls to Tool Settings and Properties; update toolbar where appropriate. +9. Add Iced toolbox/menu/icon/shortcut and modifier-snapshot input routing. +10. Add Iced clone source/target overlays and frame scheduling without changing shader upload behavior. +11. Capture Iced screenshots of Tool Settings, Properties, awaiting-source state, selected-source marker, and active clone stroke. +12. Run full validation and re-lock engine/API crates. +13. Conditional only after governance change: implement the egui adapter/component/input/overlay phase against the same API and capture equivalent screenshots. + +## Validation Plan + +### Engine/API unit tests + +- Reject painting before source selection. +- Source selection creates no history and does not modify/dirty the document. +- Merged snapshot includes visible layer composition/effects/masks. +- Frozen snapshot does not self-feed when source and target overlap. +- Identity, Mirror X, Mirror Y, and Mirror XY affine mappings. +- Aligned offset persists; unaligned target resets per stroke. +- Bilinear sampling correctness including transparent edges. +- Hardness `0`, intermediate, and `1`; opacity `0` and `1`; pressure boundaries. +- Selection mask constrains destination pixels. +- Deterministic bounded HSL variation preserves alpha. +- Out-of-source-bounds samples are transparent and never panic. +- One history item per completed stroke; cancel creates none; undo/redo restores exact pixels. +- Dirty/history rectangles include all modified destination pixels only. +- Source clear/resize invalidation frees state. + +### Integration and regression + +- `cargo test -p hcie-engine-api --test visual_regression` with new clone golden cases. +- `cargo test -p hcie-engine-api --test performance_stroke_4k -- --nocapture`. +- Add a 4K clone-stroke benchmark that asserts no per-pointer full-canvas source copy/allocation. +- Run protocol/engine API/active Iced workspace tests. +- Test old settings JSON with all clone fields absent. +- Test two documents with independent source anchors/snapshots. +- Verify Ctrl alone still color-picks for existing paint tools and Ctrl+Alt is consumed by Clone Stamp first. +- Verify protected partial uploads, viewport mapping, nearest filtering, and dirty-bounds union remain unchanged. + +### Visual acceptance + +- Clone panel controls fit narrow and wide docks without truncating essential status. +- Hardness visibly changes edge softness, not sampled detail position. +- Source and current-sample markers remain aligned during zoom/pan. +- Mirrored strokes do not show per-dab seams. +- Repeated object-removal strokes do not introduce source feedback or obvious periodic color noise. + +## Rollout and Compatibility + +- New settings fields must deserialize from old files via defaults. +- Append tool/FFI identities; never renumber existing values. +- No persisted source migration is needed. +- Keep clone functionality behind the new tool path; ordinary brush behavior remains unchanged. +- If the engine API is distributed as a static library, rebuild it before the GUI workspace. +- Use `unlock.sh ` and `lock.sh ` for each locked crate and retain all protected performance mechanisms. + +## Explicitly Out of Scope for Initial Release + +- Healing/texture synthesis or Poisson blending. +- Current-layer/current-and-below sampling modes. +- Rotation/scale transforms beyond X/Y mirroring. +- Random mirror switching. +- Layer-mask cloning. +- Script/AI clone commands unless required later for automation parity. +- Editing the frozen egui workspace before its project restriction is formally removed. diff --git a/hcie-engine-api/src/clone_tool.rs b/hcie-engine-api/src/clone_tool.rs new file mode 100644 index 0000000..3f06853 --- /dev/null +++ b/hcie-engine-api/src/clone_tool.rs @@ -0,0 +1,854 @@ +//! Engine-owned Clone Stamp implementation. +//! +//! ## Purpose +//! Captures a frozen merged-visible source and paints sampled pixels into the +//! active raster layer without exposing engine buffers to GUI code. +//! +//! ## Logic & Workflow +//! Source selection creates one immutable full-canvas snapshot. A stroke then +//! reuses the normal pooled stroke setup, emits interpolated radial dabs through +//! a stable source-to-target affine transform, and delegates completion to the +//! asynchronous sub-rectangle history path. +//! +//! ## Side Effects / Dependencies +//! Source selection allocates one RGBA canvas buffer but does not modify the +//! document or history. Painting mutates the active raster layer, dirty bounds, +//! pooled stroke buffers, effect caches, and history state. + +use crate::{Engine, LayerData, LayerType}; +use std::sync::Arc; + +/// User-configurable Clone Stamp brush behavior. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct CloneConfig { + pub size: f32, + pub opacity: f32, + pub hardness: f32, + pub aligned: bool, + pub mirror_x: bool, + pub mirror_y: bool, + pub color_variation: f32, +} + +impl Default for CloneConfig { + fn default() -> Self { + Self { + size: 40.0, + opacity: 1.0, + hardness: 0.75, + aligned: true, + mirror_x: false, + mirror_y: false, + color_variation: 0.0, + } + } +} + +impl CloneConfig { + /// Normalize all numeric fields to finite API-supported ranges. + fn clamped(self) -> Self { + let defaults = Self::default(); + Self { + size: finite_or(self.size, defaults.size).clamp(1.0, 500.0), + opacity: finite_or(self.opacity, defaults.opacity).clamp(0.0, 1.0), + hardness: finite_or(self.hardness, defaults.hardness).clamp(0.0, 1.0), + aligned: self.aligned, + mirror_x: self.mirror_x, + mirror_y: self.mirror_y, + color_variation: finite_or(self.color_variation, 0.0).clamp(0.0, 1.0), + } + } +} + +/// Read-only information about the document-local frozen source. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct CloneSourceInfo { + pub x: f32, + pub y: f32, + pub width: u32, + pub height: u32, + pub has_aligned_target: bool, +} + +/// Recoverable Clone Stamp API failures. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CloneError { + SourceNotSelected, + SourceOutOfBounds, + DestinationNotEditable, + LayerMaskEditingUnsupported, + StrokeAlreadyActive, + StrokeNotActive, + DimensionMismatch, +} + +/// Frozen source and transient stroke state owned by one `Engine` document. +pub(crate) struct CloneState { + config: CloneConfig, + source_anchor: Option<(f32, f32)>, + source_pixels: Option>, + source_width: u32, + source_height: u32, + aligned_target_anchor: Option<(f32, f32)>, + active_target_anchor: Option<(f32, f32)>, + active_layer_id: Option, + previous_pointer: Option<(f32, f32, f32)>, + spacing_remainder: f32, + stroke_seed: u64, + dab_index: u64, + modified_before_stroke: bool, +} + +impl Default for CloneState { + fn default() -> Self { + Self { + config: CloneConfig::default(), + source_anchor: None, + source_pixels: None, + source_width: 0, + source_height: 0, + aligned_target_anchor: None, + active_target_anchor: None, + active_layer_id: None, + previous_pointer: None, + spacing_remainder: 0.0, + stroke_seed: 0, + dab_index: 0, + modified_before_stroke: false, + } + } +} + +impl CloneState { + /// Clear source and transient values while retaining user configuration. + fn clear(&mut self) { + self.source_anchor = None; + self.source_pixels = None; + self.source_width = 0; + self.source_height = 0; + self.aligned_target_anchor = None; + self.clear_active(); + } + + /// Clear only fields that exist for the duration of one stroke. + fn clear_active(&mut self) { + self.active_target_anchor = None; + self.active_layer_id = None; + self.previous_pointer = None; + self.spacing_remainder = 0.0; + self.dab_index = 0; + } +} + +impl Engine { + /// Set and return the fully clamped Clone Stamp configuration. + pub fn set_clone_config(&mut self, config: CloneConfig) -> CloneConfig { + let config = config.clamped(); + self.clone_state.config = config; + config + } + + /// Return the active document's Clone Stamp configuration. + pub fn clone_config(&self) -> CloneConfig { + self.clone_state.config + } + + /// Capture a frozen merged-visible source at an in-bounds canvas point. + pub fn set_clone_source(&mut self, x: f32, y: f32) -> Result { + if self.stroke_before.is_some() || self.clone_state.active_layer_id.is_some() { + return Err(CloneError::StrokeAlreadyActive); + } + let width = self.document.canvas_width; + let height = self.document.canvas_height; + if !x.is_finite() + || !y.is_finite() + || x < 0.0 + || y < 0.0 + || x >= width as f32 + || y >= height as f32 + { + return Err(CloneError::SourceOutOfBounds); + } + let expected = rgba_len(width, height).ok_or(CloneError::DimensionMismatch)?; + let pixels = self.snapshot_merged_visible(); + if pixels.len() != expected { + return Err(CloneError::DimensionMismatch); + } + self.clone_state.source_anchor = Some((x, y)); + self.clone_state.source_pixels = Some(Arc::from(pixels)); + self.clone_state.source_width = width; + self.clone_state.source_height = height; + self.clone_state.aligned_target_anchor = None; + Ok(self.clone_source_info().expect("source was just installed")) + } + + /// Remove the frozen source, cancelling an active clone stroke if necessary. + pub fn clear_clone_source(&mut self) { + self.cancel_clone_stroke(); + self.clone_state.clear(); + } + + /// Return source metadata without exposing the frozen pixel allocation. + pub fn clone_source_info(&self) -> Option { + let (x, y) = self.clone_state.source_anchor?; + self.clone_state.source_pixels.as_ref()?; + Some(CloneSourceInfo { + x, + y, + width: self.clone_state.source_width, + height: self.clone_state.source_height, + has_aligned_target: self.clone_state.aligned_target_anchor.is_some(), + }) + } + + /// Map a destination canvas point to the source marker for overlay display. + pub fn clone_sample_position(&self, x: f32, y: f32) -> Option<(f32, f32)> { + let source = self.clone_state.source_anchor?; + let target = self + .clone_state + .active_target_anchor + .or(self.clone_state.aligned_target_anchor)?; + Some(map_sample(source, target, (x, y), self.clone_state.config)) + } + + /// Validate the target, enter pooled stroke state, and paint the first dab. + pub fn begin_clone_stroke(&mut self, x: f32, y: f32, pressure: f32) -> Result<(), CloneError> { + if self.clone_state.source_pixels.is_none() || self.clone_state.source_anchor.is_none() { + return Err(CloneError::SourceNotSelected); + } + if self.stroke_before.is_some() || self.clone_state.active_layer_id.is_some() { + return Err(CloneError::StrokeAlreadyActive); + } + if self.editing_mask { + return Err(CloneError::LayerMaskEditingUnsupported); + } + if !x.is_finite() || !y.is_finite() { + return Err(CloneError::DestinationNotEditable); + } + let layer_id = self + .document + .active_layer() + .map(|layer| layer.id) + .ok_or(CloneError::DestinationNotEditable)?; + let expected = rgba_len(self.document.canvas_width, self.document.canvas_height) + .ok_or(CloneError::DimensionMismatch)?; + let layer = self + .document + .get_layer_by_id(layer_id) + .ok_or(CloneError::DestinationNotEditable)?; + if layer.locked + || !layer.visible + || layer.layer_type != LayerType::Raster + || !matches!(layer.data, LayerData::Raster) + { + return Err(CloneError::DestinationNotEditable); + } + if layer.width != self.document.canvas_width + || layer.height != self.document.canvas_height + || layer.pixels.len() != expected + { + return Err(CloneError::DimensionMismatch); + } + + let pressure = normalized_pressure(pressure); + let target = if self.clone_state.config.aligned { + *self.clone_state.aligned_target_anchor.get_or_insert((x, y)) + } else { + (x, y) + }; + self.clone_state.modified_before_stroke = self.document.modified; + self.begin_stroke(layer_id, x, y); + self.last_stroke_bounds = None; + self.stroke_history_description = Some("Clone Stamp".to_string()); + self.clone_state.active_target_anchor = Some(target); + self.clone_state.active_layer_id = Some(layer_id); + self.clone_state.previous_pointer = Some((x, y, pressure)); + self.clone_state.spacing_remainder = 0.0; + self.clone_state.stroke_seed = self.clone_state.stroke_seed.wrapping_add(1); + self.clone_state.dab_index = 0; + self.paint_clone_dab(x, y, pressure)?; + Ok(()) + } + + /// Interpolate evenly spaced clone dabs from the previous pointer sample. + pub fn clone_stroke_to(&mut self, x: f32, y: f32, pressure: f32) -> Result<(), CloneError> { + let (start_x, start_y, start_pressure) = self + .clone_state + .previous_pointer + .ok_or(CloneError::StrokeNotActive)?; + if !x.is_finite() || !y.is_finite() { + return Ok(()); + } + let pressure = normalized_pressure(pressure); + let dx = x - start_x; + let dy = y - start_y; + let distance = dx.hypot(dy); + let spacing = (self.clone_state.config.size * 0.15).max(1.0); + if distance > f32::EPSILON { + let mut next = spacing - self.clone_state.spacing_remainder; + while next <= distance { + let t = next / distance; + self.paint_clone_dab( + start_x + dx * t, + start_y + dy * t, + start_pressure + (pressure - start_pressure) * t, + )?; + next += spacing; + } + self.clone_state.spacing_remainder = + (self.clone_state.spacing_remainder + distance) % spacing; + } + self.clone_state.previous_pointer = Some((x, y, pressure)); + Ok(()) + } + + /// Complete one Clone Stamp transaction through the standard history path. + pub fn end_clone_stroke(&mut self) -> Result<(), CloneError> { + let layer_id = self + .clone_state + .active_layer_id + .ok_or(CloneError::StrokeNotActive)?; + if self.last_stroke_bounds.is_none() { + self.cancel_clone_stroke(); + return Ok(()); + } + self.clone_state.clear_active(); + self.end_stroke(layer_id); + Ok(()) + } + + /// Restore the pre-stroke destination and discard the active transaction. + pub fn cancel_clone_stroke(&mut self) { + let Some(layer_id) = self.clone_state.active_layer_id else { + return; + }; + let bounds = self.last_stroke_bounds; + if let Some(layer) = self.document.get_layer_by_id_mut(layer_id) { + if let Some(before) = self.stroke_before_buf.as_ref() { + if before.len() == layer.pixels.len() { + layer.pixels.copy_from_slice(before); + layer.dirty = true; + } + } + if let Some(effects) = self.stroke_effects_backup.take() { + layer.effects = effects; + } + if let Some(styles) = self.stroke_styles_backup.take() { + layer.styles = styles; + } + *layer.effects_cache.lock().unwrap() = None; + if !layer.effects.is_empty() || !layer.styles.is_empty() { + layer + .effects_dirty + .store(true, std::sync::atomic::Ordering::Release); + } + } + if let Some(bounds) = bounds { + union_bounds(&mut self.document.dirty_bounds, bounds); + } + self.document.modified = self.clone_state.modified_before_stroke; + self.document.composite_dirty = true; + self.stroke_before = None; + self.last_stroke_pos = None; + self.last_stroke_bounds = None; + self.stroke_history_description = None; + self.cached_selection_mask = None; + if let Some(mask) = self.active_stroke_mask.as_mut() { + mask.fill(0); + } + self.clone_state.clear_active(); + } + + /// Render one pressure-sensitive dab and update exact changed bounds. + fn paint_clone_dab( + &mut self, + center_x: f32, + center_y: f32, + pressure: f32, + ) -> Result<(), CloneError> { + let layer_id = self + .clone_state + .active_layer_id + .ok_or(CloneError::StrokeNotActive)?; + let source_anchor = self + .clone_state + .source_anchor + .ok_or(CloneError::SourceNotSelected)?; + let target_anchor = self + .clone_state + .active_target_anchor + .ok_or(CloneError::StrokeNotActive)?; + let source = self + .clone_state + .source_pixels + .as_ref() + .cloned() + .ok_or(CloneError::SourceNotSelected)?; + let config = self.clone_state.config; + let variation = variation_for_dab( + self.clone_state.stroke_seed, + self.clone_state.dab_index, + config.color_variation, + ); + self.clone_state.dab_index = self.clone_state.dab_index.wrapping_add(1); + let width = self.document.canvas_width; + let height = self.document.canvas_height; + let selection = self.cached_selection_mask.as_deref(); + let baseline = self + .stroke_before_buf + .as_deref() + .ok_or(CloneError::StrokeNotActive)?; + let stroke_mask = self + .active_stroke_mask + .as_deref_mut() + .ok_or(CloneError::StrokeNotActive)?; + let layer = self + .document + .get_layer_by_id_mut(layer_id) + .ok_or(CloneError::DestinationNotEditable)?; + let changed = paint_dab( + &mut layer.pixels, + baseline, + stroke_mask, + selection, + width, + height, + &source, + self.clone_state.source_width, + self.clone_state.source_height, + source_anchor, + target_anchor, + center_x, + center_y, + pressure, + config, + variation, + ); + if let Some(bounds) = changed { + layer.dirty = true; + union_bounds(&mut self.last_stroke_bounds, bounds); + union_bounds(&mut self.document.dirty_bounds, bounds); + self.document.composite_dirty = true; + self.document.modified = true; + } + Ok(()) + } +} + +/// Paint one dab from immutable source/baseline buffers into destination pixels. +#[allow(clippy::too_many_arguments)] +fn paint_dab( + pixels: &mut [u8], + baseline: &[u8], + stroke_mask: &mut [u8], + selection: Option<&[u8]>, + width: u32, + height: u32, + source: &[u8], + source_width: u32, + source_height: u32, + source_anchor: (f32, f32), + target_anchor: (f32, f32), + center_x: f32, + center_y: f32, + pressure: f32, + config: CloneConfig, + variation: (f32, f32, f32), +) -> Option<[u32; 4]> { + let pressure = normalized_pressure(pressure); + let radius = (config.size * pressure * 0.5).max(0.5); + let x0 = ((center_x - radius - 1.0).floor() as i32).max(0) as u32; + let y0 = ((center_y - radius - 1.0).floor() as i32).max(0) as u32; + let x1 = ((center_x + radius + 1.0).ceil() as i32).clamp(0, width as i32) as u32; + let y1 = ((center_y + radius + 1.0).ceil() as i32).clamp(0, height as i32) as u32; + let mut changed: Option<[u32; 4]> = None; + for py in y0..y1 { + for px in x0..x1 { + let distance = (px as f32 - center_x).hypot(py as f32 - center_y); + let coverage = radial_coverage(distance, radius, config.hardness); + if coverage <= 0.0 { + continue; + } + let index = py as usize * width as usize + px as usize; + let selection_alpha = selection + .and_then(|mask| mask.get(index)) + .copied() + .unwrap_or(255) as f32 + / 255.0; + let dab_alpha = coverage * config.opacity * pressure * selection_alpha; + if dab_alpha <= 0.0 { + continue; + } + let sample_at = + map_sample(source_anchor, target_anchor, (px as f32, py as f32), config); + let mut sampled = sample_bilinear_premultiplied( + source, + source_width, + source_height, + sample_at.0, + sample_at.1, + ); + if sampled[3] == 0 { + continue; + } + sampled = vary_color(sampled, variation); + let previous = stroke_mask[index] as f32 / 255.0; + let cumulative = (previous + dab_alpha * (1.0 - previous)).min(config.opacity); + stroke_mask[index] = (cumulative * 255.0).round() as u8; + let offset = index * 4; + let base = [ + baseline[offset], + baseline[offset + 1], + baseline[offset + 2], + baseline[offset + 3], + ]; + let output = + hcie_blend::blend_pixels(base, sampled, hcie_blend::BlendMode::Normal, cumulative); + if pixels[offset..offset + 4] != output { + pixels[offset..offset + 4].copy_from_slice(&output); + union_bounds(&mut changed, [px, py, px + 1, py + 1]); + } + } + } + changed +} + +/// Return smooth radial brush coverage with a one-pixel hard-edge AA band. +fn radial_coverage(distance: f32, radius: f32, hardness: f32) -> f32 { + if distance >= radius { + return 0.0; + } + let core = (hardness.clamp(0.0, 1.0) * radius).min((radius - 1.0).max(0.0)); + if distance <= core { + return 1.0; + } + let t = ((distance - core) / (radius - core).max(f32::EPSILON)).clamp(0.0, 1.0); + 1.0 - t * t * (3.0 - 2.0 * t) +} + +/// Bilinearly sample straight RGBA by interpolating in premultiplied-alpha space. +fn sample_bilinear_premultiplied( + pixels: &[u8], + width: u32, + height: u32, + x: f32, + y: f32, +) -> [u8; 4] { + if !x.is_finite() || !y.is_finite() { + return [0; 4]; + } + let x0 = x.floor() as i32; + let y0 = y.floor() as i32; + let fx = x - x0 as f32; + let fy = y - y0 as f32; + let weights = [ + ((x0, y0), (1.0 - fx) * (1.0 - fy)), + ((x0 + 1, y0), fx * (1.0 - fy)), + ((x0, y0 + 1), (1.0 - fx) * fy), + ((x0 + 1, y0 + 1), fx * fy), + ]; + let mut premul = [0.0f32; 3]; + let mut alpha = 0.0f32; + for ((sx, sy), weight) in weights { + if sx < 0 || sy < 0 || sx >= width as i32 || sy >= height as i32 { + continue; + } + let offset = (sy as usize * width as usize + sx as usize) * 4; + if offset + 3 >= pixels.len() { + continue; + } + let a = pixels[offset + 3] as f32 / 255.0; + alpha += a * weight; + for channel in 0..3 { + premul[channel] += pixels[offset + channel] as f32 / 255.0 * a * weight; + } + } + if alpha <= f32::EPSILON { + return [0; 4]; + } + [ + ((premul[0] / alpha).clamp(0.0, 1.0) * 255.0).round() as u8, + ((premul[1] / alpha).clamp(0.0, 1.0) * 255.0).round() as u8, + ((premul[2] / alpha).clamp(0.0, 1.0) * 255.0).round() as u8, + (alpha.clamp(0.0, 1.0) * 255.0).round() as u8, + ] +} + +/// Apply one deterministic HSL offset tuple while preserving alpha. +fn vary_color(color: [u8; 4], variation: (f32, f32, f32)) -> [u8; 4] { + if variation == (0.0, 0.0, 0.0) { + return color; + } + let (mut h, mut s, mut l) = rgb_to_hsl(color[0], color[1], color[2]); + h = (h + variation.0).rem_euclid(1.0); + s = (s + variation.1).clamp(0.0, 1.0); + l = (l + variation.2).clamp(0.0, 1.0); + let [r, g, b] = hsl_to_rgb(h, s, l); + [r, g, b, color[3]] +} + +/// Generate bounded repeatable hue, saturation, and lightness offsets per dab. +fn variation_for_dab(seed: u64, index: u64, amount: f32) -> (f32, f32, f32) { + if amount <= 0.0 { + return (0.0, 0.0, 0.0); + } + let mut state = splitmix64(seed ^ index.wrapping_mul(0x9e3779b97f4a7c15)); + let hue = signed_unit(state); + state = splitmix64(state); + let saturation = signed_unit(state); + state = splitmix64(state); + let lightness = signed_unit(state); + ( + hue * (12.0 / 360.0) * amount, + saturation * 0.08 * amount, + lightness * 0.06 * amount, + ) +} + +/// Map a destination coordinate through the fixed per-stroke mirror transform. +fn map_sample( + source: (f32, f32), + target: (f32, f32), + point: (f32, f32), + config: CloneConfig, +) -> (f32, f32) { + let mx = if config.mirror_x { -1.0 } else { 1.0 }; + let my = if config.mirror_y { -1.0 } else { 1.0 }; + ( + source.0 + mx * (point.0 - target.0), + source.1 + my * (point.1 - target.1), + ) +} + +/// Convert RGB bytes to normalized HSL. +fn rgb_to_hsl(r: u8, g: u8, b: u8) -> (f32, f32, f32) { + let r = r as f32 / 255.0; + let g = g as f32 / 255.0; + let b = b as f32 / 255.0; + let max = r.max(g).max(b); + let min = r.min(g).min(b); + let lightness = (max + min) * 0.5; + let delta = max - min; + if delta <= f32::EPSILON { + return (0.0, 0.0, lightness); + } + let saturation = delta / (1.0 - (2.0 * lightness - 1.0).abs()).max(f32::EPSILON); + let hue = if max == r { + ((g - b) / delta).rem_euclid(6.0) + } else if max == g { + (b - r) / delta + 2.0 + } else { + (r - g) / delta + 4.0 + } / 6.0; + (hue, saturation, lightness) +} + +/// Convert normalized HSL to RGB bytes. +fn hsl_to_rgb(h: f32, s: f32, l: f32) -> [u8; 3] { + let chroma = (1.0 - (2.0 * l - 1.0).abs()) * s; + let hp = h.rem_euclid(1.0) * 6.0; + let x = chroma * (1.0 - (hp.rem_euclid(2.0) - 1.0).abs()); + let (r, g, b) = match hp as u32 { + 0 => (chroma, x, 0.0), + 1 => (x, chroma, 0.0), + 2 => (0.0, chroma, x), + 3 => (0.0, x, chroma), + 4 => (x, 0.0, chroma), + _ => (chroma, 0.0, x), + }; + let m = l - chroma * 0.5; + [ + ((r + m).clamp(0.0, 1.0) * 255.0).round() as u8, + ((g + m).clamp(0.0, 1.0) * 255.0).round() as u8, + ((b + m).clamp(0.0, 1.0) * 255.0).round() as u8, + ] +} + +/// Merge one half-open rectangle into an optional accumulator. +fn union_bounds(bounds: &mut Option<[u32; 4]>, next: [u32; 4]) { + match bounds { + Some(current) => { + current[0] = current[0].min(next[0]); + current[1] = current[1].min(next[1]); + current[2] = current[2].max(next[2]); + current[3] = current[3].max(next[3]); + } + None => *bounds = Some(next), + } +} + +/// Return a checked RGBA byte length for canvas dimensions. +fn rgba_len(width: u32, height: u32) -> Option { + (width as usize) + .checked_mul(height as usize)? + .checked_mul(4) +} + +/// Normalize non-finite pressure and clamp supported tablet pressure values. +fn normalized_pressure(pressure: f32) -> f32 { + finite_or(pressure, 1.0).clamp(0.0, 1.0) +} + +/// Replace a non-finite floating-point value with a deterministic fallback. +fn finite_or(value: f32, fallback: f32) -> f32 { + if value.is_finite() { + value + } else { + fallback + } +} + +/// Stateless SplitMix64 round used for deterministic variation generation. +fn splitmix64(mut value: u64) -> u64 { + value = value.wrapping_add(0x9e3779b97f4a7c15); + value = (value ^ (value >> 30)).wrapping_mul(0xbf58476d1ce4e5b9); + value = (value ^ (value >> 27)).wrapping_mul(0x94d049bb133111eb); + value ^ (value >> 31) +} + +/// Convert a deterministic integer into a floating-point value in `[-1, 1]`. +fn signed_unit(value: u64) -> f32 { + let unit = (value >> 40) as f32 / ((1u32 << 24) - 1) as f32; + unit * 2.0 - 1.0 +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn config_clamps_and_replaces_non_finite_values() { + let config = CloneConfig { + size: f32::NAN, + opacity: 2.0, + hardness: -1.0, + color_variation: f32::INFINITY, + ..CloneConfig::default() + } + .clamped(); + assert_eq!(config.size, 40.0); + assert_eq!(config.opacity, 1.0); + assert_eq!(config.hardness, 0.0); + assert_eq!(config.color_variation, 0.0); + } + + #[test] + fn affine_mapping_supports_both_mirror_axes() { + let mut config = CloneConfig::default(); + assert_eq!( + map_sample((10.0, 20.0), (3.0, 4.0), (5.0, 7.0), config), + (12.0, 23.0) + ); + config.mirror_x = true; + config.mirror_y = true; + assert_eq!( + map_sample((10.0, 20.0), (3.0, 4.0), (5.0, 7.0), config), + (8.0, 17.0) + ); + } + + #[test] + fn bilinear_sampling_avoids_transparent_color_halos() { + let pixels = [255, 0, 0, 255, 0, 255, 0, 0]; + let sampled = sample_bilinear_premultiplied(&pixels, 2, 1, 0.5, 0.0); + assert_eq!(sampled, [255, 0, 0, 128]); + } + + #[test] + fn variation_is_repeatable_bounded_and_preserves_alpha() { + let a = variation_for_dab(7, 11, 1.0); + let b = variation_for_dab(7, 11, 1.0); + assert_eq!(a, b); + assert!(a.0.abs() <= 12.0 / 360.0); + assert!(a.1.abs() <= 0.08); + assert!(a.2.abs() <= 0.06); + assert_eq!(vary_color([20, 80, 140, 77], a)[3], 77); + } + + #[test] + fn source_is_required_before_stroke() { + let mut engine = Engine::new(8, 8); + assert_eq!( + engine.begin_clone_stroke(2.0, 2.0, 1.0), + Err(CloneError::SourceNotSelected) + ); + } + + /// Install one opaque source pixel directly for focused lifecycle tests. + fn engine_with_source_pixel() -> Engine { + let mut engine = Engine::new(8, 8); + let layer = engine.document.active_layer_mut().expect("active layer"); + let offset = (1 * 8 + 1) * 4; + layer.pixels[offset..offset + 4].copy_from_slice(&[220, 30, 10, 255]); + layer.dirty = true; + engine.document.composite_dirty = true; + engine.document.dirty_bounds = Some([1, 1, 2, 2]); + engine + } + + #[test] + fn source_capture_preserves_document_and_dirty_state() { + let mut engine = engine_with_source_pixel(); + let modified = engine.document.modified; + let dirty = engine.document.dirty_bounds; + let history = engine.document.history_len(); + engine.set_clone_source(1.0, 1.0).expect("capture source"); + assert_eq!(engine.document.modified, modified); + assert_eq!(engine.document.dirty_bounds, dirty); + assert_eq!(engine.document.history_len(), history); + } + + #[test] + fn frozen_source_paints_and_cancel_restores_destination() { + let mut engine = engine_with_source_pixel(); + engine.set_clone_source(1.0, 1.0).expect("capture source"); + engine.set_clone_config(CloneConfig { + size: 1.0, + hardness: 1.0, + ..CloneConfig::default() + }); + let source_offset = (1 * 8 + 1) * 4; + engine.document.active_layer_mut().unwrap().pixels[source_offset..source_offset + 4] + .copy_from_slice(&[0, 0, 255, 255]); + engine + .begin_clone_stroke(4.0, 4.0, 1.0) + .expect("begin clone"); + let target_offset = (4 * 8 + 4) * 4; + assert_eq!( + &engine.document.active_layer().unwrap().pixels[target_offset..target_offset + 4], + &[220, 30, 10, 255] + ); + engine.cancel_clone_stroke(); + assert_eq!( + &engine.document.active_layer().unwrap().pixels[target_offset..target_offset + 4], + &[0, 0, 0, 0] + ); + assert_eq!(engine.document.history_len(), 0); + } + + #[test] + fn completed_stroke_creates_one_named_history_item() { + let mut engine = engine_with_source_pixel(); + engine.set_clone_source(1.0, 1.0).expect("capture source"); + engine.set_clone_config(CloneConfig { + size: 1.0, + hardness: 1.0, + ..CloneConfig::default() + }); + engine + .begin_clone_stroke(4.0, 4.0, 1.0) + .expect("begin clone"); + engine.end_clone_stroke().expect("end clone"); + for _ in 0..100 { + if engine.commit_pending_history() { + break; + } + std::thread::sleep(std::time::Duration::from_millis(2)); + } + assert_eq!(engine.document.history_len(), 1); + assert_eq!( + engine.document.history_description(0).as_deref(), + Some("Clone Stamp") + ); + } +} diff --git a/hcie-engine-api/src/ffi.rs b/hcie-engine-api/src/ffi.rs index f80e6f5..e983675 100644 --- a/hcie-engine-api/src/ffi.rs +++ b/hcie-engine-api/src/ffi.rs @@ -515,6 +515,7 @@ pub unsafe extern "C" fn hcie_engine_set_active_tool(handle: *mut EngineHandle, 21 => Tool::AiObjectRemoval, 22 => Tool::SmartSelect, 23 => Tool::VisionSelect, + 24 => Tool::CloneStamp, _ => Tool::Brush, }; e.set_tool(tool); diff --git a/hcie-engine-api/src/layer_property_ops.rs b/hcie-engine-api/src/layer_property_ops.rs index 5a0bbf5..71669e7 100644 --- a/hcie-engine-api/src/layer_property_ops.rs +++ b/hcie-engine-api/src/layer_property_ops.rs @@ -25,6 +25,7 @@ impl Engine { /// Removes all layers and resets layer-dependent caches without marking /// the document as modified. pub fn clear_all_layers(&mut self) { + self.clear_clone_source(); self.document.layers.clear(); self.document.clear_history(); self.document.active_layer = 0; @@ -302,6 +303,7 @@ impl Engine { } pub fn undo(&mut self) -> bool { + self.cancel_clone_stroke(); if self.document.can_undo() { self.document.undo(); self.tile_layers.clear(); @@ -316,6 +318,7 @@ impl Engine { } pub fn redo(&mut self) -> bool { + self.cancel_clone_stroke(); if self.document.can_redo() { self.document.redo(); self.tile_layers.clear(); @@ -330,6 +333,7 @@ impl Engine { } pub fn jump_to_history(&mut self, idx: i32) { + self.cancel_clone_stroke(); self.document.jump_to_history(idx); self.tile_layers.clear(); self.document.composite_dirty = true; diff --git a/hcie-engine-api/src/lib.rs b/hcie-engine-api/src/lib.rs index 1ae5484..d49f84a 100644 --- a/hcie-engine-api/src/lib.rs +++ b/hcie-engine-api/src/lib.rs @@ -12,6 +12,7 @@ pub mod svg_editor; // Performance-critical engine paths isolated into dedicated modules. mod layer_property_ops; +mod clone_tool; pub mod partial_composite; pub mod stroke_brush; mod stroke_cache; @@ -31,6 +32,7 @@ use std::sync::Mutex; pub use crate::shape_catalog::{ShapeCatalog, ShapeEntry}; pub use crate::svg_editor::{SvgEditable, SvgNode}; +pub use crate::clone_tool::{CloneConfig, CloneError, CloneSourceInfo}; pub use hcie_blend::Adjustment; pub use hcie_brush_engine::presets::BrushPreset; pub use hcie_protocol::thumbnail_nearest; @@ -417,6 +419,10 @@ pub struct Engine { /// alloc+copy per pointer event. This cache clones it once at stroke start /// and all stroke functions reference it instead. cached_selection_mask: Option>, + /// Document-local Clone Stamp source, configuration, and active gesture state. + clone_state: crate::clone_tool::CloneState, + /// Optional history description used by specialized raster stroke lifecycles. + stroke_history_description: Option, /// Thread-safe queue of history items computed on background threads. /// Polled and committed on the UI thread via `commit_pending_history()`. pub pending_history: @@ -561,6 +567,8 @@ impl Engine { raw_pixel_backup: std::collections::HashMap::new(), below_cache_dirty: true, cached_selection_mask: None, + clone_state: crate::clone_tool::CloneState::default(), + stroke_history_description: None, pending_history: std::sync::Arc::new(std::sync::Mutex::new(Vec::new())), shape_catalog: { let base = dirs::data_local_dir().unwrap_or_else(|| std::path::PathBuf::from(".")); @@ -804,10 +812,12 @@ impl Engine { // ── Transform Ops ──────────────────────────────────────────────────── pub fn crop(&mut self, x: u32, y: u32, w: u32, h: u32) { + self.clear_clone_source(); self.document.crop(x, y, w, h); } pub fn resize_canvas(&mut self, w: u32, h: u32) { + self.clear_clone_source(); self.document.resize_canvas(w, h); self.pre_tile_all_layers(); } diff --git a/hcie-engine-api/src/partial_composite.rs b/hcie-engine-api/src/partial_composite.rs index b01bf0f..c59f7e7 100644 --- a/hcie-engine-api/src/partial_composite.rs +++ b/hcie-engine-api/src/partial_composite.rs @@ -28,6 +28,21 @@ use hcie_tile::TiledLayer; use rayon::prelude::*; impl Engine { + /// Capture a merged-visible RGBA snapshot without consuming GUI dirty state. + /// + /// The effects and tile caches are brought current before all visible layers + /// are composited. Unlike `get_composite_pixels`, this deliberately leaves + /// document dirty flags and bounds intact so a pending partial GPU upload is + /// not lost. The returned buffer is owned by the caller. + pub(crate) fn snapshot_merged_visible(&mut self) -> Vec { + self.apply_effects_and_sync_tiles(); + composite_layers( + &self.document.layers, + self.document.canvas_width, + self.document.canvas_height, + ) + } + /// **Purpose:** /// Computes the flat composite RGBA pixel buffer of all layers in the document. /// diff --git a/hcie-engine-api/src/stroke_cache.rs b/hcie-engine-api/src/stroke_cache.rs index 203a5d9..47d29ba 100644 --- a/hcie-engine-api/src/stroke_cache.rs +++ b/hcie-engine-api/src/stroke_cache.rs @@ -175,6 +175,7 @@ impl Engine { /// optimization is silently negated and ~8MB will be allocated per stroke. /// The merge artifact cleanup of 2026-05-28 removed exactly this regression. pub fn begin_stroke(&mut self, layer_id: u64, x: f32, y: f32) { + self.stroke_history_description = None; self.last_stroke_pos = Some((x, y, 1.0)); self.sketch_history.clear(); if let Some(layer) = self.document.get_layer_by_id_mut(layer_id) { @@ -380,6 +381,7 @@ impl Engine { }; let style = self.current_tip.style; + let history_description = self.stroke_history_description.take(); let bounds = self.last_stroke_bounds; let pending_history = self.pending_history.clone(); let is_mask = self.editing_mask; @@ -397,11 +399,11 @@ impl Engine { bounds: None, before_shapes, after_shapes, - description: format!( + description: history_description.unwrap_or_else(|| format!( "{} ({})", if is_mask { "Mask Edit" } else { "Brush Stroke" }, crate::stroke_brush::brush_style_label(style) - ), + )), return_before: None, return_after: None, return_mask_before: mask_before, @@ -485,6 +487,7 @@ impl Engine { mask.fill(0); } self.cached_selection_mask = None; + self.stroke_history_description = None; } /// Expand the stroke bounds to include the given point with radius. diff --git a/hcie-iced-app/assets/icons/clone_stamp.svg b/hcie-iced-app/assets/icons/clone_stamp.svg new file mode 100644 index 0000000..f8d64de --- /dev/null +++ b/hcie-iced-app/assets/icons/clone_stamp.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/app.rs b/hcie-iced-app/crates/hcie-iced-gui/src/app.rs index e81e1f0..4c502c9 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/app.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/app.rs @@ -185,6 +185,8 @@ pub struct HcieIcedApp { pub slot_last_used: Vec, /// Persistent settings (tool settings, panel layout, window). pub settings: crate::settings::AppSettings, + /// Transient Clone Stamp source-selection and non-modal status state. + pub clone_ui: crate::clone_tool::CloneToolUiState, /// Window ID for window control operations (drag, minimize, maximize, close). pub window_id: Option, /// Pending automated or keyboard-triggered full-viewport screenshot. @@ -679,6 +681,7 @@ pub enum Message { CanvasPointerPressed { x: f32, y: f32, + modifiers: iced::keyboard::Modifiers, captured_at: std::time::Instant, }, CanvasPointerMoved { @@ -802,6 +805,20 @@ pub enum Message { BrushImportAbrFile(Result, String>), ResetToolDefaults, + // ── Clone Stamp ───────────────────────────────────── + CloneSizeChanged(f32), + CloneOpacityChanged(f32), + CloneHardnessChanged(f32), + CloneAlignedToggled(bool), + CloneMirrorXToggled(bool), + CloneMirrorYToggled(bool), + CloneColorVariationToggled(bool), + CloneColorVariationChanged(f32), + CloneClearSource, + CloneArmSourceSelection, + CloneSourceSelected, + CloneSourceSelectionFailed(String), + // ── Per-tool options (selection / spray / vector / text / gradient) ─ SelectionToleranceChanged(u8), SelectionContiguousToggled(bool), @@ -1342,7 +1359,6 @@ fn build_brush_tip(state: &ToolState) -> BrushTip { } } - /// Converts an imported/default brush-engine preset tip into the protocol tip used by `Engine`. /// /// **Arguments:** `source` is the preset-owned brush-engine tip. **Returns:** A protocol tip with @@ -1824,7 +1840,7 @@ impl HcieIcedApp { .unwrap_or(0), open_subtool_slot: None, sidebar_expanded: settings.panel_layout.sidebar_expanded, - slot_last_used: vec![0; 21], + slot_last_used: vec![0; crate::sidebar::tool_slots().len()], _dialog_input: String::new(), _dialog_input2: String::new(), _show_performance: false, @@ -1832,6 +1848,7 @@ impl HcieIcedApp { modifiers: iced::keyboard::Modifiers::default(), canvas_context_menu: None, settings: settings.clone(), + clone_ui: crate::clone_tool::CloneToolUiState::default(), window_id: None, screenshot_request, dialog_new_name: "Untitled".to_string(), @@ -2017,6 +2034,10 @@ impl HcieIcedApp { } let fg = app.fg_color; app.active_document_mut().engine.set_color(fg); + let clone_config = crate::clone_tool::config_from_settings(&settings.tool_settings); + app.active_document_mut() + .engine + .set_clone_config(clone_config); app.settings = settings; // If a file path was provided, open it (route multi-layer formats to dedicated importers) @@ -2631,6 +2652,15 @@ impl HcieIcedApp { engine.set_cyclic_speed(self.tool_state.brush_cyclic_speed); } + /// Persist and apply the complete Clone Stamp configuration to the active document. + fn sync_clone_config(&mut self) { + let config = crate::clone_tool::config_from_settings(&self.settings.tool_settings); + self.documents[self.active_doc] + .engine + .set_clone_config(config); + let _ = self.settings.save(); + } + /// Push the current SVG editor model into the active vector shape so the /// main canvas reflects node edits immediately. The public engine API marks /// the vector layer dirty but does not create a history snapshot here; @@ -2678,6 +2708,11 @@ impl HcieIcedApp { match message { Message::ToolSelected(tool) => { log::info!("Tool selected: {:?}", tool); + if self.tool_state.active_tool == Tool::CloneStamp && tool != Tool::CloneStamp { + self.active_document_mut().engine.cancel_clone_stroke(); + self.tool_state.is_drawing = false; + self.clone_ui.source_armed = false; + } self.tool_state.active_tool = tool; if tool == Tool::Brush { self.dock.reopen_pane(crate::dock::state::PaneType::Brushes); @@ -2718,6 +2753,13 @@ impl HcieIcedApp { } else { primary_tool }; + if self.tool_state.active_tool == Tool::CloneStamp + && selected_tool != Tool::CloneStamp + { + self.active_document_mut().engine.cancel_clone_stroke(); + self.tool_state.is_drawing = false; + self.clone_ui.source_armed = false; + } self.tool_state.active_tool = selected_tool; if selected_tool == Tool::Brush { self.dock.reopen_pane(crate::dock::state::PaneType::Brushes); @@ -2902,7 +2944,12 @@ impl HcieIcedApp { } } - Message::CanvasPointerPressed { x, y, captured_at } => { + Message::CanvasPointerPressed { + x, + y, + modifiers, + captured_at, + } => { // Coordinates are already in canvas-space from the canvas widget let canvas_x = x; let canvas_y = y; @@ -2934,9 +2981,26 @@ impl HcieIcedApp { self.tool_state.last_click_time = now; self.tool_state.last_click_pos = Some((canvas_x, canvas_y)); + if tool == Tool::CloneStamp { + let primary = modifiers.control() || modifiers.logo(); + let source_gesture = primary && modifiers.alt(); + if self.clone_ui.source_armed || source_gesture { + let result = self.documents[self.active_doc] + .engine + .set_clone_source(canvas_x, canvas_y); + self.clone_ui.source_armed = false; + return match result { + Ok(_) => self.update(Message::CloneSourceSelected), + Err(error) => self.update(Message::CloneSourceSelectionFailed( + crate::clone_tool::error_message(error), + )), + }; + } + } + let paint_tool = matches!(tool, Tool::Pen | Tool::Brush | Tool::Eraser | Tool::Spray); - if paint_tool && (self.modifiers.control() || self.modifiers.logo()) { + if paint_tool && (modifiers.control() || modifiers.logo()) { let width = self.documents[self.active_doc].engine.canvas_width(); let height = self.documents[self.active_doc].engine.canvas_height(); if canvas_x >= 0.0 @@ -2960,7 +3024,7 @@ impl HcieIcedApp { return Task::none(); } - if paint_tool && self.modifiers.shift() { + if paint_tool && modifiers.shift() { self.tool_state.is_resizing_brush = true; self.tool_state.is_drawing = true; self.tool_state.brush_resize_start_size = self.tool_state.brush_size; @@ -2968,7 +3032,8 @@ impl HcieIcedApp { return Task::none(); } - if let Some(req_type) = tool.allowed_layer_type() { + if tool != Tool::CloneStamp { + if let Some(req_type) = tool.allowed_layer_type() { // Reject raster edits on non-editable layers if req_type == hcie_engine_api::LayerType::Raster && !self.documents[self.active_doc] @@ -2994,9 +3059,35 @@ impl HcieIcedApp { } } } + } } match tool { + Tool::CloneStamp => { + let config = + crate::clone_tool::config_from_settings(&self.settings.tool_settings); + let pressure = self + .tablet_state + .lock() + .map(|state| state.current_pressure()) + .unwrap_or(1.0); + let engine = &mut self.documents[self.active_doc].engine; + engine.set_clone_config(config); + match engine.begin_clone_stroke(canvas_x, canvas_y, pressure) { + Ok(()) => { + crate::canvas::perf::begin_stroke(); + crate::canvas::perf::record_render_input(captured_at); + self.tool_state.is_drawing = true; + self.tool_state.last_stroke_pos = Some((canvas_x, canvas_y)); + self.clone_ui.notice = None; + self.refresh_canvas_if_needed(); + } + Err(error) => { + self.clone_ui.notice = + Some(crate::clone_tool::error_message(error)); + } + } + } Tool::Pen | Tool::Brush | Tool::Eraser | Tool::Spray => { crate::canvas::perf::begin_stroke(); crate::canvas::perf::record_render_input(captured_at); @@ -3269,6 +3360,22 @@ impl HcieIcedApp { } if self.tool_state.is_drawing { + if self.tool_state.active_tool == Tool::CloneStamp { + let pressure = self + .tablet_state + .lock() + .map(|state| state.current_pressure()) + .unwrap_or(1.0); + crate::canvas::perf::record_render_input(captured_at); + if let Err(error) = self.documents[self.active_doc] + .engine + .clone_stroke_to(x, y, pressure) + { + self.clone_ui.notice = Some(crate::clone_tool::error_message(error)); + } + self.tool_state.last_stroke_pos = Some((x, y)); + return Task::none(); + } // Selection tools (Lasso, Polygon, Vision, Rect) use is_drawing // to track drag state but must not enter the brush code path. let is_selection_tool = matches!( @@ -3481,6 +3588,28 @@ impl HcieIcedApp { return self.update(Message::TransformDragEnd); } + if self.tool_state.active_tool == Tool::CloneStamp && self.tool_state.is_drawing { + let result = self.documents[self.active_doc].engine.end_clone_stroke(); + self.tool_state.is_drawing = false; + self.tool_state.last_stroke_pos = None; + match result { + Ok(()) => { + self.documents[self.active_doc].modified = true; + self.refresh_composite_if_needed(); + crate::canvas::perf::request_finish_stroke(); + } + Err(error) => { + self.clone_ui.notice = Some(crate::clone_tool::error_message(error)); + } + } + return Task::perform( + async { + tokio::time::sleep(std::time::Duration::from_millis(8)).await; + }, + |_| Message::CompositeRefreshPending, + ); + } + // Lasso release finalizes a freehand selection path. if self.tool_state.active_tool == Tool::Lasso && self.tool_state.is_drawing { self.tool_state.is_drawing = false; @@ -5022,6 +5151,57 @@ impl HcieIcedApp { self.settings.update_from_tool_state(&self.tool_state); let _ = self.settings.save(); } + Message::CloneSizeChanged(value) => { + self.settings.tool_settings.clone_size = value.clamp(1.0, 500.0); + self.sync_clone_config(); + } + Message::CloneOpacityChanged(value) => { + self.settings.tool_settings.clone_opacity = value.clamp(0.0, 1.0); + self.sync_clone_config(); + } + Message::CloneHardnessChanged(value) => { + self.settings.tool_settings.clone_hardness = value.clamp(0.0, 1.0); + self.sync_clone_config(); + } + Message::CloneAlignedToggled(value) => { + self.settings.tool_settings.clone_aligned = value; + self.sync_clone_config(); + } + Message::CloneMirrorXToggled(value) => { + self.settings.tool_settings.clone_mirror_x = value; + self.sync_clone_config(); + } + Message::CloneMirrorYToggled(value) => { + self.settings.tool_settings.clone_mirror_y = value; + self.sync_clone_config(); + } + Message::CloneColorVariationToggled(value) => { + self.settings.tool_settings.clone_color_variation = value; + self.sync_clone_config(); + } + Message::CloneColorVariationChanged(value) => { + self.settings.tool_settings.clone_color_variation_amount = value.clamp(0.0, 1.0); + self.sync_clone_config(); + } + Message::CloneClearSource => { + self.documents[self.active_doc].engine.clear_clone_source(); + self.tool_state.is_drawing = false; + self.clone_ui = crate::clone_tool::CloneToolUiState::default(); + } + Message::CloneArmSourceSelection => { + self.documents[self.active_doc].engine.cancel_clone_stroke(); + self.tool_state.is_drawing = false; + self.clone_ui.source_armed = true; + self.clone_ui.notice = None; + } + Message::CloneSourceSelected => { + self.clone_ui.source_armed = false; + self.clone_ui.notice = None; + } + Message::CloneSourceSelectionFailed(message) => { + self.clone_ui.source_armed = false; + self.clone_ui.notice = Some(message); + } Message::SelectionToleranceChanged(tol) => { self.settings.tool_settings.magic_wand_tolerance = tol as f32; self.settings.tool_settings.flood_fill_tolerance = tol as f32; @@ -5193,6 +5373,7 @@ impl HcieIcedApp { Message::ResetToolDefaults => { self.settings.reset_tool_defaults(); self.settings.apply_to_tool_state(&mut self.tool_state); + self.sync_clone_config(); let _ = self.settings.save(); } Message::BrushImportAbr => { @@ -8436,6 +8617,9 @@ impl HcieIcedApp { Message::NewDocument(w, h) => { self.show_welcome = false; let mut engine = Engine::new(w, h); + engine.set_clone_config(crate::clone_tool::config_from_settings( + &self.settings.tool_settings, + )); engine.pre_tile_all_layers(); let composite_raw = engine.get_composite_pixels(); let cached_layers = engine.layer_infos(); @@ -8503,10 +8687,13 @@ impl HcieIcedApp { thumb_gen: 0, }); self.active_doc = self.documents.len() - 1; + self.clone_ui = crate::clone_tool::CloneToolUiState::default(); } Message::DocSwitch(idx) => { if idx < self.documents.len() { + self.documents[self.active_doc].engine.cancel_clone_stroke(); + self.tool_state.is_drawing = false; if self.preview_baseline.is_some() { self.restore_preview(); self.filter_preview_active = false; @@ -8514,6 +8701,10 @@ impl HcieIcedApp { self.filter_params = serde_json::json!({}); } self.active_doc = idx; + let clone_config = + crate::clone_tool::config_from_settings(&self.settings.tool_settings); + self.documents[idx].engine.set_clone_config(clone_config); + self.clone_ui = crate::clone_tool::CloneToolUiState::default(); self.documents[idx].full_upload.replace(true); self.documents[idx].selection_mask_dirty.set(true); } @@ -9935,6 +10126,11 @@ impl HcieIcedApp { iced::keyboard::Key::Character(ref c) if c.as_str() == "b" && !ctrl && !shift => { Some(Message::ToolSelected(Tool::Brush)) } + iced::keyboard::Key::Character(ref c) + if c.as_str().eq_ignore_ascii_case("s") && !ctrl && !shift => + { + Some(Message::ToolSelected(Tool::CloneStamp)) + } // E = Eraser Tool iced::keyboard::Key::Character(ref c) if c.as_str() == "e" && !ctrl && !shift => { Some(Message::ToolSelected(Tool::Eraser)) @@ -10249,7 +10445,11 @@ fn active_index_after_document_close( /// vector, transform, and idle states continue to use their event-driven previews. /// **Side Effects / Dependencies:** None. fn should_schedule_canvas_frames(is_drawing: bool, tool: Tool) -> bool { - is_drawing && matches!(tool, Tool::Pen | Tool::Brush | Tool::Eraser | Tool::Spray) + is_drawing + && matches!( + tool, + Tool::Pen | Tool::Brush | Tool::Eraser | Tool::Spray | Tool::CloneStamp + ) } #[cfg(test)] @@ -10329,7 +10529,13 @@ mod cycle_one_ux_tests { /// Confirms the frame timer is bounded to active raster strokes. #[test] fn canvas_frame_timer_runs_only_for_active_paint_strokes() { - for tool in [Tool::Pen, Tool::Brush, Tool::Eraser, Tool::Spray] { + for tool in [ + Tool::Pen, + Tool::Brush, + Tool::Eraser, + Tool::Spray, + Tool::CloneStamp, + ] { assert!(should_schedule_canvas_frames(true, tool)); assert!(!should_schedule_canvas_frames(false, tool)); } diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/canvas/mod.rs b/hcie-iced-app/crates/hcie-iced-gui/src/canvas/mod.rs index d6de205..e5b409f 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/canvas/mod.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/canvas/mod.rs @@ -87,6 +87,14 @@ struct OverlayProgram { _quick_mask: bool, /// Active brush diameter in canvas pixels for the footprint cursor. brush_size: f32, + /// Clone Stamp configuration used for footprint and affine source markers. + clone_config: hcie_engine_api::CloneConfig, + /// Fixed document-local source marker. + clone_source: Option, + /// Source coordinate corresponding to destination origin under the active transform. + clone_sample_origin: Option<(f32, f32)>, + /// Whether the next ordinary click selects a source. + clone_source_armed: bool, /// Whether a raster stroke is active; used to keep idle latency metrics isolated. is_drawing: bool, /// Normalized bounds of the selected vector shape in canvas-space (x1, y1, x2, y2). @@ -2009,6 +2017,99 @@ impl canvas::Program for OverlayProgram { geometries.push(frame.into_geometry()); } + // ── Clone Stamp source markers ────────────────────────────────── + if self.active_tool == hcie_engine_api::Tool::CloneStamp { + if let Some(source) = self.clone_source { + let mut marker_frame = Frame::new(renderer, bounds.size()); + let fixed = Point::new( + origin_x + source.x * self.zoom, + origin_y + source.y * self.zoom, + ); + let marker_color = iced::Color::from_rgb(0.25, 0.9, 0.95); + marker_frame.stroke( + &Path::circle(fixed, 7.0), + Stroke { + style: canvas::stroke::Style::Solid(marker_color), + width: 1.5, + ..Default::default() + }, + ); + marker_frame.stroke( + &Path::line( + Point::new(fixed.x - 10.0, fixed.y), + Point::new(fixed.x + 10.0, fixed.y), + ), + Stroke { + style: canvas::stroke::Style::Solid(marker_color), + width: 1.0, + ..Default::default() + }, + ); + marker_frame.stroke( + &Path::line( + Point::new(fixed.x, fixed.y - 10.0), + Point::new(fixed.x, fixed.y + 10.0), + ), + Stroke { + style: canvas::stroke::Style::Solid(marker_color), + width: 1.0, + ..Default::default() + }, + ); + if let (Some(cursor_point), Some(sample_origin)) = + (cursor.position_in(bounds), self.clone_sample_origin) + { + let destination_x = (cursor_point.x - origin_x) / self.zoom; + let destination_y = (cursor_point.y - origin_y) / self.zoom; + let sample_x = sample_origin.0 + + if self.clone_config.mirror_x { + -destination_x + } else { + destination_x + }; + let sample_y = sample_origin.1 + + if self.clone_config.mirror_y { + -destination_y + } else { + destination_y + }; + if sample_x >= 0.0 + && sample_y >= 0.0 + && sample_x < self.engine_w as f32 + && sample_y < self.engine_h as f32 + { + let current = Point::new( + origin_x + sample_x * self.zoom, + origin_y + sample_y * self.zoom, + ); + marker_frame.stroke( + &Path::circle(current, 5.0), + Stroke { + style: canvas::stroke::Style::Solid(iced::Color::from_rgb( + 1.0, 0.75, 0.2, + )), + width: 1.5, + ..Default::default() + }, + ); + if self.is_drawing { + marker_frame.stroke( + &Path::line(current, cursor_point), + Stroke { + style: canvas::stroke::Style::Solid(iced::Color::from_rgba( + 1.0, 0.75, 0.2, 0.55, + )), + width: 1.0, + ..Default::default() + }, + ); + } + } + } + geometries.push(marker_frame.into_geometry()); + } + } + // ── Crosshair cursor ───────────────────────────────────────────── // Strategy 4: Use the `cursor` parameter from iced's current render // frame instead of the retained `state.cursor_pos` (which lags by @@ -2025,9 +2126,15 @@ impl canvas::Program for OverlayProgram { | hcie_engine_api::Tool::Brush | hcie_engine_api::Tool::Eraser | hcie_engine_api::Tool::Spray + | hcie_engine_api::Tool::CloneStamp ) { + let diameter = if self.active_tool == hcie_engine_api::Tool::CloneStamp { + self.clone_config.size + } else { + self.brush_size + }; let footprint = - Path::circle(cursor_point, (self.brush_size * self.zoom * 0.5).max(1.0)); + Path::circle(cursor_point, (diameter * self.zoom * 0.5).max(1.0)); crosshair_frame.stroke( &footprint, Stroke { @@ -2036,7 +2143,28 @@ impl canvas::Program for OverlayProgram { ..Default::default() }, ); + if self.active_tool == hcie_engine_api::Tool::CloneStamp { + let hard_radius = + (diameter * self.clone_config.hardness * self.zoom * 0.5).max(1.0); + crosshair_frame.stroke( + &Path::circle(cursor_point, hard_radius), + Stroke { + style: canvas::stroke::Style::Solid(iced::Color::from_rgba( + 1.0, 1.0, 1.0, 0.45, + )), + width: 1.0, + ..Default::default() + }, + ); + } } + let crosshair_color = if self.active_tool == hcie_engine_api::Tool::CloneStamp + && (self.clone_source_armed || self.clone_source.is_none()) + { + iced::Color::from_rgb(1.0, 0.65, 0.15) + } else { + crosshair_color + }; crosshair_frame.stroke( &Path::line( Point::new(cursor_point.x - crosshair_size, cursor_point.y), @@ -2156,8 +2284,14 @@ impl canvas::Program for OverlayProgram { // runs with the latest cursor position. Without this, iced may // skip repaints during idle cursor movement because no application // state changed. - if matches!(event, canvas::Event::Mouse(mouse::Event::CursorMoved { .. })) { - return (canvas::event::Status::Ignored, Some(Message::CursorOverlayRedraw)); + if matches!( + event, + canvas::Event::Mouse(mouse::Event::CursorMoved { .. }) + ) { + return ( + canvas::event::Status::Ignored, + Some(Message::CursorOverlayRedraw), + ); } // Always pass events through to the shader widget below (canvas::event::Status::Ignored, None) @@ -2233,6 +2367,9 @@ pub fn view<'a>( star_points: u32, polygon_sides: u32, rect_radius: f32, + modifiers: iced::keyboard::Modifiers, + clone_config: hcie_engine_api::CloneConfig, + clone_source_armed: bool, ) -> Element<'a, Message> { let engine_w = doc.engine.canvas_width(); let engine_h = doc.engine.canvas_height(); @@ -2265,6 +2402,7 @@ pub fn view<'a>( texture_update, full_upload, space_pan: tool_state.space_pan, + modifiers, selection_mask: doc.selection_texture.clone(), selection_dirty: doc.selection_mask_dirty.get(), anim_time: marching_ants_offset * 2.0, // Convert 0..1 to seconds (2s cycle) @@ -2333,6 +2471,10 @@ pub fn view<'a>( polygon_points: doc.polygon_points.clone(), _quick_mask: doc.quick_mask, brush_size: tool_state.brush_size, + clone_config, + clone_source: doc.engine.clone_source_info(), + clone_sample_origin: doc.engine.clone_sample_position(0.0, 0.0), + clone_source_armed, is_drawing: tool_state.is_drawing, selected_vector_bounds: sel_vec_bounds, selected_vector_angle: sel_vec_angle, diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/canvas/shader_canvas.rs b/hcie-iced-app/crates/hcie-iced-gui/src/canvas/shader_canvas.rs index 0f3f786..1bb66e3 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/canvas/shader_canvas.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/canvas/shader_canvas.rs @@ -1010,6 +1010,8 @@ pub struct CanvasShaderProgram { pub full_upload: bool, /// Whether Space temporarily changes left-drag into panning. pub space_pan: bool, + /// Modifier snapshot copied into pointer-press messages for deterministic routing. + pub modifiers: iced::keyboard::Modifiers, /// Encoded selection data (0 unselected, 128 interior, 255 border), if any. pub selection_mask: Option>>, /// Whether the selection mask has changed since last upload. @@ -1216,6 +1218,7 @@ impl shader::Program for CanvasShaderProgram { Some(Message::CanvasPointerPressed { x: cx, y: cy, + modifiers: self.modifiers, captured_at: std::time::Instant::now(), }), ); diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/clone_tool.rs b/hcie-iced-app/crates/hcie-iced-gui/src/clone_tool.rs new file mode 100644 index 0000000..9882f29 --- /dev/null +++ b/hcie-iced-app/crates/hcie-iced-gui/src/clone_tool.rs @@ -0,0 +1,89 @@ +//! Clone Stamp GUI-only state and presentation helpers. +//! +//! ## Purpose +//! Converts persisted controls into the engine API configuration and formats +//! document-local source/error state for panels. Pixel mutation remains wholly +//! inside `hcie-engine-api`. + +use crate::settings::ToolSettings; +use hcie_engine_api::{CloneConfig, CloneError, CloneSourceInfo}; + +/// Transient accessibility and status state shared by Clone Stamp UI surfaces. +#[derive(Debug, Clone, Default)] +pub struct CloneToolUiState { + pub source_armed: bool, + pub notice: Option, +} + +/// Copyable model consumed by the shared Clone Stamp panel component. +#[derive(Debug, Clone)] +pub struct ClonePanelModel { + pub config: CloneConfig, + pub source: Option, + pub source_armed: bool, + pub notice: Option, + pub variation_enabled: bool, +} + +/// Convert persisted values to a complete engine configuration. +pub fn config_from_settings(settings: &ToolSettings) -> CloneConfig { + CloneConfig { + size: settings.clone_size, + opacity: settings.clone_opacity, + hardness: settings.clone_hardness, + aligned: settings.clone_aligned, + mirror_x: settings.clone_mirror_x, + mirror_y: settings.clone_mirror_y, + color_variation: if settings.clone_color_variation { + settings.clone_color_variation_amount + } else { + 0.0 + }, + } +} + +/// Build a panel model from persisted controls and document-local source data. +pub fn panel_model( + settings: &ToolSettings, + source: Option, + ui: &CloneToolUiState, +) -> ClonePanelModel { + ClonePanelModel { + config: config_from_settings(settings), + source, + source_armed: ui.source_armed, + notice: ui.notice.clone(), + variation_enabled: settings.clone_color_variation, + } +} + +/// Format source status with accessible armed and failure states taking precedence. +pub fn source_status(model: &ClonePanelModel) -> String { + if let Some(notice) = model.notice.as_ref() { + return notice.clone(); + } + if model.source_armed { + return "Select a source on the canvas".to_string(); + } + match model.source { + Some(source) => format!("Source: {:.0}, {:.0} (Merged Visible)", source.x, source.y), + None => "No source selected".to_string(), + } +} + +/// Convert an engine error into concise non-modal user guidance. +pub fn error_message(error: CloneError) -> String { + match error { + CloneError::SourceNotSelected => "Select a source before painting".to_string(), + CloneError::SourceOutOfBounds => "Source point is outside the canvas".to_string(), + CloneError::DestinationNotEditable => { + "Active layer is not an editable raster layer".to_string() + } + CloneError::LayerMaskEditingUnsupported => { + "Clone Stamp does not support editing layer masks".to_string() + } + CloneError::StrokeAlreadyActive => "A Clone Stamp stroke is already active".to_string(), + CloneError::StrokeNotActive => "Clone Stamp stroke is not active".to_string(), + CloneError::DimensionMismatch => "Canvas and raster layer dimensions differ".to_string(), + } +} diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/dock/view.rs b/hcie-iced-app/crates/hcie-iced-gui/src/dock/view.rs index e2b95d8..2e661cc 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/dock/view.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/dock/view.rs @@ -154,6 +154,9 @@ pub fn panel_body<'a>( ts.vector_points, ts.vector_sides, ts.vector_radius, + app.modifiers, + crate::clone_tool::config_from_settings(ts), + app.clone_ui.source_armed, ) }; column![doc_tab_bar, canvas] @@ -269,6 +272,11 @@ pub fn panel_body<'a>( app.bg_color, app.settings.tool_settings.spray_particle_size, app.settings.tool_settings.spray_density, + crate::clone_tool::panel_model( + &app.settings.tool_settings, + doc.engine.clone_source_info(), + &app.clone_ui, + ), ) } PaneType::Script => crate::panels::script_panel::view( @@ -304,7 +312,18 @@ pub fn panel_body<'a>( PaneType::ToolSettings => { let fg = app.fg_color; let draft = app.documents[app.active_doc].text_draft.as_ref(); - crate::panels::tool_settings::view(&app.tool_state, &app.settings, fg, draft, colors) + crate::panels::tool_settings::view( + &app.tool_state, + &app.settings, + fg, + draft, + crate::clone_tool::panel_model( + &app.settings.tool_settings, + app.documents[app.active_doc].engine.clone_source_info(), + &app.clone_ui, + ), + colors, + ) } PaneType::CustomShapes => { let selected_index = match app.tool_state.active_tool { diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/main.rs b/hcie-iced-app/crates/hcie-iced-gui/src/main.rs index c363bdf..dc331c6 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/main.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/main.rs @@ -9,6 +9,7 @@ mod brush_import; mod build_info; mod canvas; mod cli; +mod clone_tool; mod color_picker; mod dialogs; mod dock; diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/panels/clone_tool_controls.rs b/hcie-iced-app/crates/hcie-iced-gui/src/panels/clone_tool_controls.rs new file mode 100644 index 0000000..ee933f6 --- /dev/null +++ b/hcie-iced-app/crates/hcie-iced-gui/src/panels/clone_tool_controls.rs @@ -0,0 +1,95 @@ +//! Shared Clone Stamp controls for Tool Settings and Properties. +//! +//! ## Purpose +//! Renders one consistent object-removal workflow in both active property +//! surfaces. Every interaction emits a dedicated application message. + +use crate::app::Message; +use crate::clone_tool::{source_status, ClonePanelModel}; +use crate::theme::ThemeColors; +use crate::widgets::plain_slider::plain_slider; +use iced::widget::{button, checkbox, column, row, text}; +use iced::Length; + +/// Build Clone Stamp source actions and configurable brush controls. +pub fn view(model: ClonePanelModel, colors: ThemeColors) -> iced::widget::Column<'static, Message> { + let source_action = if model.source.is_some() { + "Reselect Source" + } else { + "Choose Source" + }; + let mut controls = column![ + text("Object Removal").size(12), + text("Ctrl+Alt+Click a clean area, then paint over the object.").size(10), + text(source_status(&model)).size(10), + row![ + button(text(source_action).size(10)).on_press(Message::CloneArmSourceSelection), + button(text("Clear Source").size(10)).on_press(Message::CloneClearSource), + ] + .spacing(4), + plain_slider( + "Size", + model.config.size, + 1.0..=500.0, + 1.0, + "px", + 0, + colors, + Message::CloneSizeChanged + ), + plain_slider( + "Opacity", + model.config.opacity, + 0.0..=1.0, + 0.01, + "%", + 0, + colors, + Message::CloneOpacityChanged + ), + plain_slider( + "Hardness", + model.config.hardness, + 0.0..=1.0, + 0.01, + "%", + 0, + colors, + Message::CloneHardnessChanged + ), + checkbox("Aligned", model.config.aligned) + .on_toggle(Message::CloneAlignedToggled) + .size(11) + .text_size(11), + row![ + checkbox("Mirror X", model.config.mirror_x) + .on_toggle(Message::CloneMirrorXToggled) + .size(11) + .text_size(11), + checkbox("Mirror Y", model.config.mirror_y) + .on_toggle(Message::CloneMirrorYToggled) + .size(11) + .text_size(11), + ] + .spacing(8), + checkbox("Color Variation", model.variation_enabled) + .on_toggle(Message::CloneColorVariationToggled) + .size(11) + .text_size(11), + ] + .spacing(5) + .width(Length::Fill); + if model.variation_enabled { + controls = controls.push(plain_slider( + "Variation", + model.config.color_variation, + 0.0..=1.0, + 0.01, + "%", + 0, + colors, + Message::CloneColorVariationChanged, + )); + } + controls +} diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/panels/menus.rs b/hcie-iced-app/crates/hcie-iced-gui/src/panels/menus.rs index fb2e00d..ba5863d 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/panels/menus.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/panels/menus.rs @@ -432,6 +432,11 @@ fn all_menus(recent_files: &[crate::app::RecentFileEntry]) -> Vec { ), MenuItem::separator(), MenuItem::disabled("Retouch"), + MenuItem::command_with_shortcut( + "Clone Stamp", + "S", + MenuCommand::SelectTool(Tool::CloneStamp), + ), MenuItem::command( "Red-eye Removal", MenuCommand::SelectTool(Tool::RedEyeRemoval), @@ -1296,6 +1301,7 @@ mod tests { "Tools/Crop", "Tools/Text", "Tools/Gradient", + "Tools/Clone Stamp", "Tools/Red-eye Removal", "Tools/Spot Removal Patch", "Tools/Smart Patch", diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/panels/mod.rs b/hcie-iced-app/crates/hcie-iced-gui/src/panels/mod.rs index 29d40d3..e753a03 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/panels/mod.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/panels/mod.rs @@ -3,6 +3,7 @@ pub mod ai_chat_panel; pub mod ai_script_panel; pub mod brushes; +pub mod clone_tool_controls; pub mod custom_shapes; pub mod filter_params; pub mod filters; diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/panels/properties.rs b/hcie-iced-app/crates/hcie-iced-gui/src/panels/properties.rs index 2402955..853d536 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/panels/properties.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/panels/properties.rs @@ -121,6 +121,7 @@ pub fn view<'a>( background_color: [u8; 4], settings_spray_particle_size: f32, settings_spray_density: u32, + clone_model: crate::clone_tool::ClonePanelModel, ) -> Element<'a, Message> { // Build layer info section at the top let layer_section = layer_info_section( @@ -209,6 +210,7 @@ pub fn view<'a>( ] .spacing(4) .into(), + Tool::CloneStamp => crate::panels::clone_tool_controls::view(clone_model, colors).into(), _ => text(format!("{:?}", active_tool)).size(SECTION).into(), }; diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/panels/tool_settings.rs b/hcie-iced-app/crates/hcie-iced-gui/src/panels/tool_settings.rs index 10aa0fa..1509c6c 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/panels/tool_settings.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/panels/tool_settings.rs @@ -17,7 +17,9 @@ use crate::settings::AppSettings; use crate::theme::ThemeColors; use crate::widgets::plain_slider::plain_slider; use hcie_engine_api::Tool; -use iced::widget::{button, checkbox, column, container, horizontal_rule, row, scrollable, text, text_input}; +use iced::widget::{ + button, checkbox, column, container, horizontal_rule, row, scrollable, text, text_input, +}; use iced::{Element, Length}; /// Build the tool settings panel for the active tool. @@ -26,6 +28,7 @@ pub fn view<'a>( settings: &'a AppSettings, fg_color: [u8; 4], text_draft: Option<&'a crate::app::TextDraft>, + clone_model: crate::clone_tool::ClonePanelModel, colors: ThemeColors, ) -> Element<'a, Message> { let content = match tool_state.active_tool { @@ -52,6 +55,7 @@ pub fn view<'a>( Tool::Move => move_settings(settings, colors), Tool::Gradient => gradient_settings(settings, colors), Tool::FloodFill => flood_fill_settings(settings, colors), + Tool::CloneStamp => crate::panels::clone_tool_controls::view(clone_model, colors), _ => column![text("No settings").size(11)].spacing(4), }; diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/panels/toolbar.rs b/hcie-iced-app/crates/hcie-iced-gui/src/panels/toolbar.rs index 53ead1c..09ebb59 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/panels/toolbar.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/panels/toolbar.rs @@ -15,7 +15,7 @@ use iced::{Element, Length}; /// Build the compact toolbar: SVG icon buttons + brush size + opacity in one row. pub fn view<'a>( tool_state: &'a crate::app::ToolState, - _settings: &'a crate::settings::AppSettings, + settings: &'a crate::settings::AppSettings, colors: ThemeColors, ) -> Element<'a, Message> { let c = colors; @@ -47,26 +47,29 @@ pub fn view<'a>( .align_y(iced::Alignment::Center); // ── Brush size + opacity (compact, matching egui) ── + let clone_active = tool_state.active_tool == hcie_engine_api::Tool::CloneStamp; + let size = if clone_active { settings.tool_settings.clone_size } else { tool_state.brush_size }; + let opacity = if clone_active { settings.tool_settings.clone_opacity } else { tool_state.brush_opacity }; let size_sl = container(plain_slider( "Size", - tool_state.brush_size, - 1.0..=200.0, + size, + 1.0..=500.0, 1.0, "px", 0, colors, - Message::BrushSizeChanged, + move |value| if clone_active { Message::CloneSizeChanged(value) } else { Message::BrushSizeChanged(value) }, )) .width(110); let opacity_sl = container(plain_slider( "Opacity", - tool_state.brush_opacity, + opacity, 0.0..=1.0, 0.01, "%", 0, colors, - Message::BrushOpacityChanged, + move |value| if clone_active { Message::CloneOpacityChanged(value) } else { Message::BrushOpacityChanged(value) }, )) .width(110); diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/settings.rs b/hcie-iced-app/crates/hcie-iced-gui/src/settings.rs index dad6d08..5f82a39 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/settings.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/settings.rs @@ -48,6 +48,26 @@ pub struct AppSettings { /// Tool-specific settings that persist between sessions. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ToolSettings { + /// Clone Stamp diameter in canvas pixels. + #[serde(default = "default_clone_size")] + pub clone_size: f32, + /// Clone Stamp maximum stroke opacity. + #[serde(default = "default_clone_opacity")] + pub clone_opacity: f32, + /// Clone Stamp radial hard-core proportion. + #[serde(default = "default_clone_hardness")] + pub clone_hardness: f32, + /// Whether the first source-to-target offset persists across strokes. + #[serde(default = "default_clone_aligned")] + pub clone_aligned: bool, + #[serde(default)] + pub clone_mirror_x: bool, + #[serde(default)] + pub clone_mirror_y: bool, + #[serde(default)] + pub clone_color_variation: bool, + #[serde(default)] + pub clone_color_variation_amount: f32, pub brush_size: f32, pub brush_opacity: f32, pub brush_hardness: f32, @@ -132,6 +152,18 @@ pub struct ToolSettings { fn default_vector_fill() -> bool { true } +fn default_clone_size() -> f32 { + 40.0 +} +fn default_clone_opacity() -> f32 { + 1.0 +} +fn default_clone_hardness() -> f32 { + 0.75 +} +fn default_clone_aligned() -> bool { + true +} fn default_vector_points() -> u32 { 5 } @@ -200,6 +232,14 @@ impl Default for AppSettings { impl Default for ToolSettings { fn default() -> Self { Self { + clone_size: default_clone_size(), + clone_opacity: default_clone_opacity(), + clone_hardness: default_clone_hardness(), + clone_aligned: default_clone_aligned(), + clone_mirror_x: false, + clone_mirror_y: false, + clone_color_variation: false, + clone_color_variation_amount: 0.0, brush_size: 20.0, brush_opacity: 1.0, brush_hardness: 0.8, @@ -384,6 +424,13 @@ impl AppSettings { tool_state.brush_angle_variance = self.tool_settings.brush_angle_variance; tool_state.brush_cyclic_color = self.tool_settings.brush_cyclic_color; tool_state.brush_cyclic_speed = self.tool_settings.brush_cyclic_speed; + if let Some(tool) = hcie_engine_api::Tool::ALL + .iter() + .copied() + .find(|tool| format!("{:?}", tool) == self.tool_settings.last_active_tool) + { + tool_state.active_tool = tool; + } } } @@ -408,10 +455,30 @@ mod tests { .and_then(serde_json::Value::as_object_mut) .expect("panel layout object") .remove("dock_layout"); + let tools = object + .get_mut("tool_settings") + .and_then(serde_json::Value::as_object_mut) + .expect("tool settings object"); + for key in [ + "clone_size", + "clone_opacity", + "clone_hardness", + "clone_aligned", + "clone_mirror_x", + "clone_mirror_y", + "clone_color_variation", + "clone_color_variation_amount", + ] { + tools.remove(key); + } let settings: AppSettings = serde_json::from_value(legacy).expect("deserialize legacy settings"); assert_eq!(settings.theme_preset, ThemePreset::Photopea); assert!(!settings.panel_layout.sidebar_expanded); + assert_eq!(settings.tool_settings.clone_size, 40.0); + assert_eq!(settings.tool_settings.clone_opacity, 1.0); + assert_eq!(settings.tool_settings.clone_hardness, 0.75); + assert!(settings.tool_settings.clone_aligned); } } diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/sidebar/mod.rs b/hcie-iced-app/crates/hcie-iced-gui/src/sidebar/mod.rs index 9c69e11..ac0bbaf 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/sidebar/mod.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/sidebar/mod.rs @@ -139,6 +139,7 @@ pub const TOOL_SLOTS: &[ToolSlot] = &[ }, ToolSlot { tools: &[ + Tool::CloneStamp, Tool::SpotRemoval, Tool::RedEyeRemoval, Tool::SmartPatch, @@ -766,6 +767,7 @@ fn tool_icon(tool: Tool) -> &'static str { Tool::VectorBolt => "icons/vector_bolt.svg", Tool::VectorArrow4 => "icons/vector_arrow4.svg", Tool::SpotRemoval => "icons/spot.svg", + Tool::CloneStamp => "icons/clone_stamp.svg", Tool::RedEyeRemoval => "icons/redeye.svg", Tool::SmartPatch | Tool::AiObjectRemoval => "icons/patch.svg", Tool::CustomShape(_) => "icons/star.svg", @@ -814,6 +816,7 @@ fn tool_shortcut(tool: Tool) -> &'static str { Tool::FloodFill => "G", Tool::Gradient => "G", Tool::Text => "T", + Tool::CloneStamp => "S", Tool::VectorLine => "U", Tool::VectorRect => "U", Tool::VectorCircle => "U", diff --git a/hcie-protocol/src/tools.rs b/hcie-protocol/src/tools.rs index b0c104f..3fcf7a1 100644 --- a/hcie-protocol/src/tools.rs +++ b/hcie-protocol/src/tools.rs @@ -43,6 +43,7 @@ pub enum Tool { AiObjectRemoval, SmartSelect, VisionSelect, + CloneStamp, CustomShape(u32), } @@ -61,6 +62,7 @@ impl Tool { Tool::Gradient, Tool::Move, Tool::Crop, Tool::RedEyeRemoval, Tool::SpotRemoval, Tool::SmartPatch, Tool::AiObjectRemoval, Tool::SmartSelect, Tool::VisionSelect, + Tool::CloneStamp, ]; pub fn label(self) -> &'static str { @@ -101,6 +103,7 @@ impl Tool { Tool::AiObjectRemoval => "AI Object Removal", Tool::SmartSelect => "Smart Select", Tool::VisionSelect => "Vision Tools", + Tool::CloneStamp => "Clone Stamp", Tool::CustomShape(_) => "Custom Shape", } } @@ -143,12 +146,13 @@ impl Tool { Tool::AiObjectRemoval => "🪄", Tool::SmartSelect => "🎯", Tool::VisionSelect => "👁", + Tool::CloneStamp => "C", Tool::CustomShape(_) => "○", } } pub fn is_raster(self) -> bool { - matches!(self, Tool::Pen | Tool::Brush | Tool::Eraser | Tool::FloodFill | Tool::Spray) + matches!(self, Tool::Pen | Tool::Brush | Tool::Eraser | Tool::FloodFill | Tool::Spray | Tool::CloneStamp) } pub fn is_ai_tool(self) -> bool { @@ -172,7 +176,7 @@ impl Tool { } pub fn shows_pen_tips(self) -> bool { - matches!(self, Tool::Pen | Tool::Brush | Tool::Eraser | Tool::Spray) + matches!(self, Tool::Pen | Tool::Brush | Tool::Eraser | Tool::Spray | Tool::CloneStamp) } pub fn is_selection_tool(self) -> bool { @@ -203,11 +207,11 @@ impl Tool { } pub fn has_size(self) -> bool { - matches!(self, Tool::Pen | Tool::Brush | Tool::Eraser | Tool::Spray | Tool::SpotRemoval) + matches!(self, Tool::Pen | Tool::Brush | Tool::Eraser | Tool::Spray | Tool::SpotRemoval | Tool::CloneStamp) } pub fn has_opacity(self) -> bool { - matches!(self, Tool::Pen | Tool::Brush | Tool::Eraser | Tool::Spray | Tool::Gradient) + matches!(self, Tool::Pen | Tool::Brush | Tool::Eraser | Tool::Spray | Tool::Gradient | Tool::CloneStamp) } } diff --git a/hcie-protocol/tests/helpers.rs b/hcie-protocol/tests/helpers.rs index 9247d4f..ec27349 100644 --- a/hcie-protocol/tests/helpers.rs +++ b/hcie-protocol/tests/helpers.rs @@ -104,7 +104,7 @@ fn test_blend_mode_all_contains_all() { #[test] fn test_all_tools_listed() { - assert_eq!(Tool::ALL.len(), 36, "should have 36 tools"); + assert_eq!(Tool::ALL.len(), 37, "should have 37 tools"); } #[test]