Compare commits
1 Commits
main
...
clone-tool
| Author | SHA1 | Date | |
|---|---|---|---|
| b31a949215 |
@@ -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<CloneSourceInfo, CloneError>
|
||||
clear_clone_source()
|
||||
clone_source_info() -> Option<CloneSourceInfo>
|
||||
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<Message>` 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 <crate>` and `lock.sh <crate>` 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.
|
||||
@@ -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<Arc<[u8]>>,
|
||||
source_width: u32,
|
||||
source_height: u32,
|
||||
aligned_target_anchor: Option<(f32, f32)>,
|
||||
active_target_anchor: Option<(f32, f32)>,
|
||||
active_layer_id: Option<u64>,
|
||||
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<CloneSourceInfo, CloneError> {
|
||||
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<CloneSourceInfo> {
|
||||
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<usize> {
|
||||
(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")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<Vec<u8>>,
|
||||
/// 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<String>,
|
||||
/// 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();
|
||||
}
|
||||
|
||||
@@ -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<u8> {
|
||||
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.
|
||||
///
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" color="#fff" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M9 3h6l1 5 3 3v3H5v-3l3-3 1-5Z"/>
|
||||
<path d="M6 14h12v3H6zM8 20h8M12 17v3"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 308 B |
@@ -185,6 +185,8 @@ pub struct HcieIcedApp {
|
||||
pub slot_last_used: Vec<usize>,
|
||||
/// 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<iced::window::Id>,
|
||||
/// 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<Vec<hcie_engine_api::BrushPreset>, 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));
|
||||
}
|
||||
|
||||
@@ -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<hcie_engine_api::CloneSourceInfo>,
|
||||
/// 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<Message> 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<Message> 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<Message> 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<Message> 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,
|
||||
|
||||
@@ -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<std::sync::Arc<Vec<u8>>>,
|
||||
/// Whether the selection mask has changed since last upload.
|
||||
@@ -1216,6 +1218,7 @@ impl shader::Program<Message> for CanvasShaderProgram {
|
||||
Some(Message::CanvasPointerPressed {
|
||||
x: cx,
|
||||
y: cy,
|
||||
modifiers: self.modifiers,
|
||||
captured_at: std::time::Instant::now(),
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -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<String>,
|
||||
}
|
||||
|
||||
/// Copyable model consumed by the shared Clone Stamp panel component.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ClonePanelModel {
|
||||
pub config: CloneConfig,
|
||||
pub source: Option<CloneSourceInfo>,
|
||||
pub source_armed: bool,
|
||||
pub notice: Option<String>,
|
||||
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<CloneSourceInfo>,
|
||||
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(),
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -9,6 +9,7 @@ mod brush_import;
|
||||
mod build_info;
|
||||
mod canvas;
|
||||
mod cli;
|
||||
mod clone_tool;
|
||||
mod color_picker;
|
||||
mod dialogs;
|
||||
mod dock;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -432,6 +432,11 @@ fn all_menus(recent_files: &[crate::app::RecentFileEntry]) -> Vec<MenuDef> {
|
||||
),
|
||||
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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user