Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b31a949215 | |||
| a64f1184cd | |||
| acd7de5dd3 | |||
| cea60143b1 |
@@ -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.
|
||||||
+151
-50
@@ -111,7 +111,22 @@ pub fn generate_brush_stamp(tip: &BrushTip) -> Vec<u8> {
|
|||||||
match tip.style {
|
match tip.style {
|
||||||
BrushStyle::Round => generate_round_stamp(d, center, r, tip.hardness),
|
BrushStyle::Round => generate_round_stamp(d, center, r, tip.hardness),
|
||||||
BrushStyle::Square => generate_square_stamp(d, center, r, tip.hardness),
|
BrushStyle::Square => generate_square_stamp(d, center, r, tip.hardness),
|
||||||
BrushStyle::Star => generate_star_stamp(d, center, r, tip.hardness),
|
BrushStyle::Star => generate_star_stamp(
|
||||||
|
d,
|
||||||
|
center,
|
||||||
|
r,
|
||||||
|
tip.hardness,
|
||||||
|
if tip.roundness > 0.0 {
|
||||||
|
tip.roundness
|
||||||
|
} else {
|
||||||
|
0.22
|
||||||
|
},
|
||||||
|
if tip.density > 0.0 {
|
||||||
|
tip.density
|
||||||
|
} else {
|
||||||
|
0.08
|
||||||
|
},
|
||||||
|
),
|
||||||
BrushStyle::SoftRound => generate_round_stamp(d, center, r, 0.0),
|
BrushStyle::SoftRound => generate_round_stamp(d, center, r, 0.0),
|
||||||
BrushStyle::HardRound => generate_round_stamp(d, center, r, 1.0),
|
BrushStyle::HardRound => generate_round_stamp(d, center, r, 1.0),
|
||||||
BrushStyle::Bitmap => {
|
BrushStyle::Bitmap => {
|
||||||
@@ -200,10 +215,19 @@ fn generate_square_stamp(d: usize, center: f32, r: f32, hardness: f32) -> Vec<u8
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn generate_star_stamp(d: usize, center: f32, r: f32, hardness: f32) -> Vec<u8> {
|
fn generate_star_stamp(
|
||||||
|
d: usize,
|
||||||
|
center: f32,
|
||||||
|
r: f32,
|
||||||
|
hardness: f32,
|
||||||
|
arm_width_ratio: f32,
|
||||||
|
inner_r_ratio: f32,
|
||||||
|
) -> Vec<u8> {
|
||||||
let arm_count = 4u32;
|
let arm_count = 4u32;
|
||||||
let arm_width = r * 0.22;
|
let arm_width = arm_width_ratio.clamp(0.08, 0.35);
|
||||||
let inner_r = r * 0.08;
|
let inner_r = r * inner_r_ratio.clamp(0.04, 0.12);
|
||||||
|
let hardness = hardness.clamp(0.0, 1.0);
|
||||||
|
let point_sharpness = 1.2 + (0.35 - arm_width) / 0.27 * 2.8;
|
||||||
(0..d * d)
|
(0..d * d)
|
||||||
.map(|i| {
|
.map(|i| {
|
||||||
let x = (i % d) as f32 - center;
|
let x = (i % d) as f32 - center;
|
||||||
@@ -212,21 +236,19 @@ fn generate_star_stamp(d: usize, center: f32, r: f32, hardness: f32) -> Vec<u8>
|
|||||||
let dist = (x * x + y * y).sqrt();
|
let dist = (x * x + y * y).sqrt();
|
||||||
let point_angle = std::f32::consts::PI * 2.0 / arm_count as f32;
|
let point_angle = std::f32::consts::PI * 2.0 / arm_count as f32;
|
||||||
let a = angle.rem_euclid(point_angle);
|
let a = angle.rem_euclid(point_angle);
|
||||||
let arm_center = point_angle / 2.0;
|
let arm_dist = a.min(point_angle - a);
|
||||||
let arm_dist = (a - arm_center).abs();
|
let angular_distance = (arm_dist / (point_angle * 0.5)).clamp(0.0, 1.0);
|
||||||
let max_arm_dist = arm_width / r;
|
let point_profile = (1.0 - angular_distance).powf(point_sharpness);
|
||||||
if dist > r {
|
let edge = inner_r + (r - inner_r) * point_profile;
|
||||||
|
let soft_width = (r * (1.0 - hardness) * 0.35).max(0.75);
|
||||||
|
let hard_edge = (edge - soft_width).max(0.0);
|
||||||
|
|
||||||
|
if dist >= edge {
|
||||||
0
|
0
|
||||||
} else if arm_dist < max_arm_dist && dist > inner_r {
|
} else if dist <= hard_edge {
|
||||||
let arm_falloff = 1.0 - (arm_dist / max_arm_dist);
|
255
|
||||||
let radial = ((dist - inner_r) / (r - inner_r)).clamp(0.0, 1.0);
|
|
||||||
let alpha = arm_falloff * (1.0 - radial.powf(1.0 - hardness.clamp(0.0, 0.99)));
|
|
||||||
(alpha * 255.0).round() as u8
|
|
||||||
} else if dist <= inner_r {
|
|
||||||
let alpha = (1.0 - dist / inner_r).max(0.5);
|
|
||||||
(alpha * 255.0).round() as u8
|
|
||||||
} else {
|
} else {
|
||||||
0
|
(((edge - dist) / soft_width).clamp(0.0, 1.0) * 255.0).round() as u8
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
@@ -650,6 +672,10 @@ fn draw_brush_style_dab(
|
|||||||
brush_hardness,
|
brush_hardness,
|
||||||
is_eraser,
|
is_eraser,
|
||||||
mask,
|
mask,
|
||||||
|
angle,
|
||||||
|
rotation_random,
|
||||||
|
roundness,
|
||||||
|
density,
|
||||||
),
|
),
|
||||||
BrushStyle::Rock => draw_rock_brush(
|
BrushStyle::Rock => draw_rock_brush(
|
||||||
pixels,
|
pixels,
|
||||||
@@ -1621,59 +1647,134 @@ fn draw_star_brush(
|
|||||||
hardness: f32,
|
hardness: f32,
|
||||||
is_eraser: bool,
|
is_eraser: bool,
|
||||||
mask: Option<&[u8]>,
|
mask: Option<&[u8]>,
|
||||||
|
angle: f32,
|
||||||
|
rotation_random: f32,
|
||||||
|
roundness: f32,
|
||||||
|
density: f32,
|
||||||
) {
|
) {
|
||||||
let effective_size = size * pressure;
|
let seed = cx.to_bits().wrapping_mul(73_856_093)
|
||||||
|
^ cy.to_bits().wrapping_mul(19_349_663);
|
||||||
|
|
||||||
|
let size_variance = roundness;
|
||||||
|
let size_scale = if size_variance > 0.0 {
|
||||||
|
let r = ((seed & 0xFFFF) as f32 / 65536.0 - 0.5) * 2.0 * size_variance;
|
||||||
|
(1.0 + r).clamp(0.3, 2.0)
|
||||||
|
} else {
|
||||||
|
1.0
|
||||||
|
};
|
||||||
|
let effective_size = size * pressure * size_scale;
|
||||||
|
|
||||||
|
let angle_variance = rotation_random;
|
||||||
|
let dab_angle = if angle_variance > 0.0 {
|
||||||
|
let r = ((seed >> 16) as f32 / 65536.0 - 0.5) * 2.0 * angle_variance;
|
||||||
|
angle + r * std::f32::consts::PI
|
||||||
|
} else {
|
||||||
|
angle
|
||||||
|
};
|
||||||
|
|
||||||
|
let arm_width_ratio = if roundness > 0.0 { roundness } else { 0.22 };
|
||||||
|
let inner_r_ratio = if density > 0.0 { density } else { 0.08 };
|
||||||
|
|
||||||
let tip = BrushTip {
|
let tip = BrushTip {
|
||||||
style: BrushStyle::Star,
|
style: BrushStyle::Star,
|
||||||
size: effective_size * 0.5,
|
size: effective_size * 0.5,
|
||||||
hardness,
|
hardness,
|
||||||
|
roundness: arm_width_ratio,
|
||||||
|
density: inner_r_ratio,
|
||||||
..BrushTip::default()
|
..BrushTip::default()
|
||||||
};
|
};
|
||||||
let stamp = generate_brush_stamp(&tip);
|
let stamp = generate_brush_stamp(&tip);
|
||||||
draw_dab_with_stamp(
|
|
||||||
pixels,
|
let cos_a = dab_angle.cos();
|
||||||
width,
|
let sin_a = dab_angle.sin();
|
||||||
height,
|
let stamp_rotated = if dab_angle.abs() > f32::EPSILON {
|
||||||
cx,
|
let stamp_d = (effective_size * 0.5 * 2.0).ceil() as usize;
|
||||||
cy,
|
let stamp_center = stamp_d as f32 * 0.5;
|
||||||
effective_size,
|
let d = stamp_d;
|
||||||
hardness,
|
let center = d as f32 * 0.5;
|
||||||
color,
|
let mut rotated = vec![0u8; d * d];
|
||||||
opacity * pressure,
|
for y in 0..d {
|
||||||
is_eraser,
|
for x in 0..d {
|
||||||
mask,
|
let px = x as f32 - center;
|
||||||
Some(&stamp),
|
let py = y as f32 - center;
|
||||||
);
|
let rx = px * cos_a + py * sin_a;
|
||||||
let sparkle_count = ((effective_size * 0.15) as u32).clamp(2, 8);
|
let ry = -px * sin_a + py * cos_a;
|
||||||
let sparkle_r = effective_size * 0.12;
|
let sx = rx + stamp_center;
|
||||||
let tip_sm = BrushTip {
|
let sy = ry + stamp_center;
|
||||||
style: BrushStyle::Star,
|
let ix = sx.floor() as i32;
|
||||||
size: sparkle_r,
|
let iy = sy.floor() as i32;
|
||||||
hardness,
|
if ix >= 0 && iy >= 0 && ix < stamp_d as i32 - 1 && iy < stamp_d as i32 - 1 {
|
||||||
..BrushTip::default()
|
let fx = sx - ix as f32;
|
||||||
|
let fy = sy - iy as f32;
|
||||||
|
let d00 = stamp[iy as usize * stamp_d + ix as usize] as f32;
|
||||||
|
let d10 = stamp[iy as usize * stamp_d + ix as usize + 1] as f32;
|
||||||
|
let d01 = stamp[(iy as usize + 1) * stamp_d + ix as usize] as f32;
|
||||||
|
let d11 = stamp[(iy as usize + 1) * stamp_d + ix as usize + 1] as f32;
|
||||||
|
let v = d00 * (1.0 - fx) * (1.0 - fy)
|
||||||
|
+ d10 * fx * (1.0 - fy)
|
||||||
|
+ d01 * (1.0 - fx) * fy
|
||||||
|
+ d11 * fx * fy;
|
||||||
|
rotated[y * d + x] = v.round() as u8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rotated
|
||||||
|
} else {
|
||||||
|
stamp
|
||||||
};
|
};
|
||||||
let stamp_sm = generate_brush_stamp(&tip_sm);
|
|
||||||
let seed = cx.to_bits().wrapping_mul(73_856_093)
|
let sparkle_count = if density > 0.0 {
|
||||||
^ cy.to_bits().wrapping_mul(19_349_663);
|
(effective_size * 0.08 * density).clamp(2.0, 6.0) as u32
|
||||||
|
} else {
|
||||||
|
((effective_size * 0.05) as u32).clamp(2, 4)
|
||||||
|
};
|
||||||
|
let base_star_size = effective_size * 0.24;
|
||||||
|
let footprint_radius = effective_size * 0.5;
|
||||||
|
let phase = (seed & 0xFFFF) as f32 / 65536.0 * std::f32::consts::TAU;
|
||||||
|
const GOLDEN_ANGLE: f32 = 2.399_963_1;
|
||||||
|
|
||||||
for i in 0..sparkle_count {
|
for i in 0..sparkle_count {
|
||||||
let hash_i = seed.wrapping_add(i.wrapping_mul(668_265_263));
|
let hash_i = seed.wrapping_add(i.wrapping_mul(668_265_263));
|
||||||
let angle = (hash_i & 0xFFFF) as f32 / 65536.0 * std::f32::consts::TAU;
|
let spark_size_var = if size_variance > 0.0 {
|
||||||
let dist = ((hash_i >> 16) & 0xFFFF) as f32 / 65536.0 * effective_size * 0.6;
|
let rv = ((hash_i.wrapping_add(31337) & 0xFFFF) as f32 / 65536.0 - 0.5)
|
||||||
let sx = cx + angle.cos() * dist;
|
* 2.0
|
||||||
let sy = cy + angle.sin() * dist;
|
* size_variance;
|
||||||
|
(1.0 + rv).clamp(0.3, 2.0)
|
||||||
|
} else {
|
||||||
|
1.0
|
||||||
|
};
|
||||||
|
let star_size = base_star_size * spark_size_var;
|
||||||
|
let max_center_radius = (footprint_radius - star_size * 0.5).max(0.0);
|
||||||
|
let radial_fraction = ((i as f32 + 0.5) / sparkle_count as f32).sqrt();
|
||||||
|
let spark_angle = phase + i as f32 * GOLDEN_ANGLE;
|
||||||
|
let dist = radial_fraction * max_center_radius;
|
||||||
|
let sx = cx + spark_angle.cos() * dist;
|
||||||
|
let sy = cy + spark_angle.sin() * dist;
|
||||||
|
|
||||||
|
let spark_color = if color[3] > 0 {
|
||||||
|
let hue_shift = ((hash_i.wrapping_add(7919) & 0xFF) as f32 / 255.0 - 0.5) * 30.0;
|
||||||
|
let [r, g, b, a] = color;
|
||||||
|
let (h, s, l) = rgb_to_hsl(r, g, b);
|
||||||
|
let h = (h + hue_shift).rem_euclid(360.0);
|
||||||
|
let (rr, gg, bb) = hsl_to_rgb(h, s, l);
|
||||||
|
[rr, gg, bb, a]
|
||||||
|
} else {
|
||||||
|
color
|
||||||
|
};
|
||||||
|
|
||||||
draw_dab_with_stamp(
|
draw_dab_with_stamp(
|
||||||
pixels,
|
pixels,
|
||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
sx,
|
sx,
|
||||||
sy,
|
sy,
|
||||||
sparkle_r * 2.0,
|
star_size,
|
||||||
hardness,
|
hardness,
|
||||||
color,
|
spark_color,
|
||||||
opacity * pressure * 0.5,
|
opacity * pressure * 0.9,
|
||||||
is_eraser,
|
is_eraser,
|
||||||
mask,
|
mask,
|
||||||
Some(&stamp_sm),
|
Some(&stamp_rotated),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
21 => Tool::AiObjectRemoval,
|
||||||
22 => Tool::SmartSelect,
|
22 => Tool::SmartSelect,
|
||||||
23 => Tool::VisionSelect,
|
23 => Tool::VisionSelect,
|
||||||
|
24 => Tool::CloneStamp,
|
||||||
_ => Tool::Brush,
|
_ => Tool::Brush,
|
||||||
};
|
};
|
||||||
e.set_tool(tool);
|
e.set_tool(tool);
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ impl Engine {
|
|||||||
/// Removes all layers and resets layer-dependent caches without marking
|
/// Removes all layers and resets layer-dependent caches without marking
|
||||||
/// the document as modified.
|
/// the document as modified.
|
||||||
pub fn clear_all_layers(&mut self) {
|
pub fn clear_all_layers(&mut self) {
|
||||||
|
self.clear_clone_source();
|
||||||
self.document.layers.clear();
|
self.document.layers.clear();
|
||||||
self.document.clear_history();
|
self.document.clear_history();
|
||||||
self.document.active_layer = 0;
|
self.document.active_layer = 0;
|
||||||
@@ -302,6 +303,7 @@ impl Engine {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn undo(&mut self) -> bool {
|
pub fn undo(&mut self) -> bool {
|
||||||
|
self.cancel_clone_stroke();
|
||||||
if self.document.can_undo() {
|
if self.document.can_undo() {
|
||||||
self.document.undo();
|
self.document.undo();
|
||||||
self.tile_layers.clear();
|
self.tile_layers.clear();
|
||||||
@@ -316,6 +318,7 @@ impl Engine {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn redo(&mut self) -> bool {
|
pub fn redo(&mut self) -> bool {
|
||||||
|
self.cancel_clone_stroke();
|
||||||
if self.document.can_redo() {
|
if self.document.can_redo() {
|
||||||
self.document.redo();
|
self.document.redo();
|
||||||
self.tile_layers.clear();
|
self.tile_layers.clear();
|
||||||
@@ -330,6 +333,7 @@ impl Engine {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn jump_to_history(&mut self, idx: i32) {
|
pub fn jump_to_history(&mut self, idx: i32) {
|
||||||
|
self.cancel_clone_stroke();
|
||||||
self.document.jump_to_history(idx);
|
self.document.jump_to_history(idx);
|
||||||
self.tile_layers.clear();
|
self.tile_layers.clear();
|
||||||
self.document.composite_dirty = true;
|
self.document.composite_dirty = true;
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ pub mod svg_editor;
|
|||||||
|
|
||||||
// Performance-critical engine paths isolated into dedicated modules.
|
// Performance-critical engine paths isolated into dedicated modules.
|
||||||
mod layer_property_ops;
|
mod layer_property_ops;
|
||||||
|
mod clone_tool;
|
||||||
pub mod partial_composite;
|
pub mod partial_composite;
|
||||||
pub mod stroke_brush;
|
pub mod stroke_brush;
|
||||||
mod stroke_cache;
|
mod stroke_cache;
|
||||||
@@ -31,6 +32,7 @@ use std::sync::Mutex;
|
|||||||
|
|
||||||
pub use crate::shape_catalog::{ShapeCatalog, ShapeEntry};
|
pub use crate::shape_catalog::{ShapeCatalog, ShapeEntry};
|
||||||
pub use crate::svg_editor::{SvgEditable, SvgNode};
|
pub use crate::svg_editor::{SvgEditable, SvgNode};
|
||||||
|
pub use crate::clone_tool::{CloneConfig, CloneError, CloneSourceInfo};
|
||||||
pub use hcie_blend::Adjustment;
|
pub use hcie_blend::Adjustment;
|
||||||
pub use hcie_brush_engine::presets::BrushPreset;
|
pub use hcie_brush_engine::presets::BrushPreset;
|
||||||
pub use hcie_protocol::thumbnail_nearest;
|
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
|
/// alloc+copy per pointer event. This cache clones it once at stroke start
|
||||||
/// and all stroke functions reference it instead.
|
/// and all stroke functions reference it instead.
|
||||||
cached_selection_mask: Option<Vec<u8>>,
|
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.
|
/// Thread-safe queue of history items computed on background threads.
|
||||||
/// Polled and committed on the UI thread via `commit_pending_history()`.
|
/// Polled and committed on the UI thread via `commit_pending_history()`.
|
||||||
pub pending_history:
|
pub pending_history:
|
||||||
@@ -561,6 +567,8 @@ impl Engine {
|
|||||||
raw_pixel_backup: std::collections::HashMap::new(),
|
raw_pixel_backup: std::collections::HashMap::new(),
|
||||||
below_cache_dirty: true,
|
below_cache_dirty: true,
|
||||||
cached_selection_mask: None,
|
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())),
|
pending_history: std::sync::Arc::new(std::sync::Mutex::new(Vec::new())),
|
||||||
shape_catalog: {
|
shape_catalog: {
|
||||||
let base = dirs::data_local_dir().unwrap_or_else(|| std::path::PathBuf::from("."));
|
let base = dirs::data_local_dir().unwrap_or_else(|| std::path::PathBuf::from("."));
|
||||||
@@ -804,10 +812,12 @@ impl Engine {
|
|||||||
// ── Transform Ops ────────────────────────────────────────────────────
|
// ── Transform Ops ────────────────────────────────────────────────────
|
||||||
|
|
||||||
pub fn crop(&mut self, x: u32, y: u32, w: u32, h: u32) {
|
pub fn crop(&mut self, x: u32, y: u32, w: u32, h: u32) {
|
||||||
|
self.clear_clone_source();
|
||||||
self.document.crop(x, y, w, h);
|
self.document.crop(x, y, w, h);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn resize_canvas(&mut self, w: u32, h: u32) {
|
pub fn resize_canvas(&mut self, w: u32, h: u32) {
|
||||||
|
self.clear_clone_source();
|
||||||
self.document.resize_canvas(w, h);
|
self.document.resize_canvas(w, h);
|
||||||
self.pre_tile_all_layers();
|
self.pre_tile_all_layers();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,21 @@ use hcie_tile::TiledLayer;
|
|||||||
use rayon::prelude::*;
|
use rayon::prelude::*;
|
||||||
|
|
||||||
impl Engine {
|
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:**
|
/// **Purpose:**
|
||||||
/// Computes the flat composite RGBA pixel buffer of all layers in the document.
|
/// 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.
|
/// optimization is silently negated and ~8MB will be allocated per stroke.
|
||||||
/// The merge artifact cleanup of 2026-05-28 removed exactly this regression.
|
/// 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) {
|
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.last_stroke_pos = Some((x, y, 1.0));
|
||||||
self.sketch_history.clear();
|
self.sketch_history.clear();
|
||||||
if let Some(layer) = self.document.get_layer_by_id_mut(layer_id) {
|
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 style = self.current_tip.style;
|
||||||
|
let history_description = self.stroke_history_description.take();
|
||||||
let bounds = self.last_stroke_bounds;
|
let bounds = self.last_stroke_bounds;
|
||||||
let pending_history = self.pending_history.clone();
|
let pending_history = self.pending_history.clone();
|
||||||
let is_mask = self.editing_mask;
|
let is_mask = self.editing_mask;
|
||||||
@@ -397,11 +399,11 @@ impl Engine {
|
|||||||
bounds: None,
|
bounds: None,
|
||||||
before_shapes,
|
before_shapes,
|
||||||
after_shapes,
|
after_shapes,
|
||||||
description: format!(
|
description: history_description.unwrap_or_else(|| format!(
|
||||||
"{} ({})",
|
"{} ({})",
|
||||||
if is_mask { "Mask Edit" } else { "Brush Stroke" },
|
if is_mask { "Mask Edit" } else { "Brush Stroke" },
|
||||||
crate::stroke_brush::brush_style_label(style)
|
crate::stroke_brush::brush_style_label(style)
|
||||||
),
|
)),
|
||||||
return_before: None,
|
return_before: None,
|
||||||
return_after: None,
|
return_after: None,
|
||||||
return_mask_before: mask_before,
|
return_mask_before: mask_before,
|
||||||
@@ -485,6 +487,7 @@ impl Engine {
|
|||||||
mask.fill(0);
|
mask.fill(0);
|
||||||
}
|
}
|
||||||
self.cached_selection_mask = None;
|
self.cached_selection_mask = None;
|
||||||
|
self.stroke_history_description = None;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Expand the stroke bounds to include the given point with radius.
|
/// 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 |
@@ -233,6 +233,14 @@ pub fn dry_media_presets() -> Vec<BrushPreset> {
|
|||||||
false,
|
false,
|
||||||
true,
|
true,
|
||||||
),
|
),
|
||||||
|
preset(
|
||||||
|
"star_sparkle",
|
||||||
|
"Star Sparkle",
|
||||||
|
"Effects",
|
||||||
|
tip(BrushStyle::Star, 14.0, 0.95, 1.0, 0.35),
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
),
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -245,14 +253,14 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn dry_media_catalog_is_complete_and_unique() {
|
fn dry_media_catalog_is_complete_and_unique() {
|
||||||
let presets = dry_media_presets();
|
let presets = dry_media_presets();
|
||||||
assert_eq!(presets.len(), 19);
|
assert_eq!(presets.len(), 20);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
presets
|
presets
|
||||||
.iter()
|
.iter()
|
||||||
.map(|preset| &preset.id)
|
.map(|preset| &preset.id)
|
||||||
.collect::<HashSet<_>>()
|
.collect::<HashSet<_>>()
|
||||||
.len(),
|
.len(),
|
||||||
19
|
20
|
||||||
);
|
);
|
||||||
assert!(presets
|
assert!(presets
|
||||||
.iter()
|
.iter()
|
||||||
|
|||||||
@@ -185,6 +185,8 @@ pub struct HcieIcedApp {
|
|||||||
pub slot_last_used: Vec<usize>,
|
pub slot_last_used: Vec<usize>,
|
||||||
/// Persistent settings (tool settings, panel layout, window).
|
/// Persistent settings (tool settings, panel layout, window).
|
||||||
pub settings: crate::settings::AppSettings,
|
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).
|
/// Window ID for window control operations (drag, minimize, maximize, close).
|
||||||
pub window_id: Option<iced::window::Id>,
|
pub window_id: Option<iced::window::Id>,
|
||||||
/// Pending automated or keyboard-triggered full-viewport screenshot.
|
/// Pending automated or keyboard-triggered full-viewport screenshot.
|
||||||
@@ -587,6 +589,12 @@ pub struct ToolState {
|
|||||||
pub brush_color_variant: bool,
|
pub brush_color_variant: bool,
|
||||||
/// Magnitude of the per-dab color variation (0.0..1.0).
|
/// Magnitude of the per-dab color variation (0.0..1.0).
|
||||||
pub brush_variant_amount: f32,
|
pub brush_variant_amount: f32,
|
||||||
|
/// Star brush base rotation angle in radians.
|
||||||
|
pub brush_angle: f32,
|
||||||
|
/// Star brush per-dab size variance (0.0..1.0).
|
||||||
|
pub brush_size_variance: f32,
|
||||||
|
/// Star brush per-dab angle variance (0.0..1.0).
|
||||||
|
pub brush_angle_variance: f32,
|
||||||
/// True when the brush color should cycle through the hue over stroke distance.
|
/// True when the brush color should cycle through the hue over stroke distance.
|
||||||
pub brush_cyclic_color: bool,
|
pub brush_cyclic_color: bool,
|
||||||
/// Speed of the hue cycle along the stroke (0.01..5.0).
|
/// Speed of the hue cycle along the stroke (0.01..5.0).
|
||||||
@@ -625,6 +633,9 @@ impl Default for ToolState {
|
|||||||
brush_spacing: 1.0,
|
brush_spacing: 1.0,
|
||||||
brush_color_variant: false,
|
brush_color_variant: false,
|
||||||
brush_variant_amount: 0.0,
|
brush_variant_amount: 0.0,
|
||||||
|
brush_angle: 0.0,
|
||||||
|
brush_size_variance: 0.0,
|
||||||
|
brush_angle_variance: 0.0,
|
||||||
brush_cyclic_color: false,
|
brush_cyclic_color: false,
|
||||||
brush_cyclic_speed: 0.5,
|
brush_cyclic_speed: 0.5,
|
||||||
last_update_instant: std::time::Instant::now(),
|
last_update_instant: std::time::Instant::now(),
|
||||||
@@ -670,6 +681,7 @@ pub enum Message {
|
|||||||
CanvasPointerPressed {
|
CanvasPointerPressed {
|
||||||
x: f32,
|
x: f32,
|
||||||
y: f32,
|
y: f32,
|
||||||
|
modifiers: iced::keyboard::Modifiers,
|
||||||
captured_at: std::time::Instant,
|
captured_at: std::time::Instant,
|
||||||
},
|
},
|
||||||
CanvasPointerMoved {
|
CanvasPointerMoved {
|
||||||
@@ -784,12 +796,29 @@ pub enum Message {
|
|||||||
BrushSpacingChanged(f32),
|
BrushSpacingChanged(f32),
|
||||||
BrushColorVariantToggled(bool),
|
BrushColorVariantToggled(bool),
|
||||||
BrushVariantAmountChanged(f32),
|
BrushVariantAmountChanged(f32),
|
||||||
|
BrushAngleChanged(f32),
|
||||||
|
BrushSizeVarianceChanged(f32),
|
||||||
|
BrushAngleVarianceChanged(f32),
|
||||||
BrushCyclicColorToggled(bool),
|
BrushCyclicColorToggled(bool),
|
||||||
BrushCyclicSpeedChanged(f32),
|
BrushCyclicSpeedChanged(f32),
|
||||||
BrushImportAbr,
|
BrushImportAbr,
|
||||||
BrushImportAbrFile(Result<Vec<hcie_engine_api::BrushPreset>, String>),
|
BrushImportAbrFile(Result<Vec<hcie_engine_api::BrushPreset>, String>),
|
||||||
ResetToolDefaults,
|
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) ─
|
// ── Per-tool options (selection / spray / vector / text / gradient) ─
|
||||||
SelectionToleranceChanged(u8),
|
SelectionToleranceChanged(u8),
|
||||||
SelectionContiguousToggled(bool),
|
SelectionContiguousToggled(bool),
|
||||||
@@ -1323,11 +1352,13 @@ fn build_brush_tip(state: &ToolState) -> BrushTip {
|
|||||||
spacing: state.brush_spacing,
|
spacing: state.brush_spacing,
|
||||||
color_variant: state.brush_color_variant,
|
color_variant: state.brush_color_variant,
|
||||||
variant_amount: state.brush_variant_amount,
|
variant_amount: state.brush_variant_amount,
|
||||||
|
angle: state.brush_angle,
|
||||||
|
roundness: state.brush_size_variance,
|
||||||
|
rotation_random: state.brush_angle_variance,
|
||||||
..BrushTip::default()
|
..BrushTip::default()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/// Converts an imported/default brush-engine preset tip into the protocol tip used by `Engine`.
|
/// 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
|
/// **Arguments:** `source` is the preset-owned brush-engine tip. **Returns:** A protocol tip with
|
||||||
@@ -1809,7 +1840,7 @@ impl HcieIcedApp {
|
|||||||
.unwrap_or(0),
|
.unwrap_or(0),
|
||||||
open_subtool_slot: None,
|
open_subtool_slot: None,
|
||||||
sidebar_expanded: settings.panel_layout.sidebar_expanded,
|
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_input: String::new(),
|
||||||
_dialog_input2: String::new(),
|
_dialog_input2: String::new(),
|
||||||
_show_performance: false,
|
_show_performance: false,
|
||||||
@@ -1817,6 +1848,7 @@ impl HcieIcedApp {
|
|||||||
modifiers: iced::keyboard::Modifiers::default(),
|
modifiers: iced::keyboard::Modifiers::default(),
|
||||||
canvas_context_menu: None,
|
canvas_context_menu: None,
|
||||||
settings: settings.clone(),
|
settings: settings.clone(),
|
||||||
|
clone_ui: crate::clone_tool::CloneToolUiState::default(),
|
||||||
window_id: None,
|
window_id: None,
|
||||||
screenshot_request,
|
screenshot_request,
|
||||||
dialog_new_name: "Untitled".to_string(),
|
dialog_new_name: "Untitled".to_string(),
|
||||||
@@ -2002,6 +2034,10 @@ impl HcieIcedApp {
|
|||||||
}
|
}
|
||||||
let fg = app.fg_color;
|
let fg = app.fg_color;
|
||||||
app.active_document_mut().engine.set_color(fg);
|
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;
|
app.settings = settings;
|
||||||
|
|
||||||
// If a file path was provided, open it (route multi-layer formats to dedicated importers)
|
// If a file path was provided, open it (route multi-layer formats to dedicated importers)
|
||||||
@@ -2616,6 +2652,15 @@ impl HcieIcedApp {
|
|||||||
engine.set_cyclic_speed(self.tool_state.brush_cyclic_speed);
|
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
|
/// Push the current SVG editor model into the active vector shape so the
|
||||||
/// main canvas reflects node edits immediately. The public engine API marks
|
/// main canvas reflects node edits immediately. The public engine API marks
|
||||||
/// the vector layer dirty but does not create a history snapshot here;
|
/// the vector layer dirty but does not create a history snapshot here;
|
||||||
@@ -2663,6 +2708,11 @@ impl HcieIcedApp {
|
|||||||
match message {
|
match message {
|
||||||
Message::ToolSelected(tool) => {
|
Message::ToolSelected(tool) => {
|
||||||
log::info!("Tool selected: {:?}", 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;
|
self.tool_state.active_tool = tool;
|
||||||
if tool == Tool::Brush {
|
if tool == Tool::Brush {
|
||||||
self.dock.reopen_pane(crate::dock::state::PaneType::Brushes);
|
self.dock.reopen_pane(crate::dock::state::PaneType::Brushes);
|
||||||
@@ -2703,6 +2753,13 @@ impl HcieIcedApp {
|
|||||||
} else {
|
} else {
|
||||||
primary_tool
|
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;
|
self.tool_state.active_tool = selected_tool;
|
||||||
if selected_tool == Tool::Brush {
|
if selected_tool == Tool::Brush {
|
||||||
self.dock.reopen_pane(crate::dock::state::PaneType::Brushes);
|
self.dock.reopen_pane(crate::dock::state::PaneType::Brushes);
|
||||||
@@ -2887,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
|
// Coordinates are already in canvas-space from the canvas widget
|
||||||
let canvas_x = x;
|
let canvas_x = x;
|
||||||
let canvas_y = y;
|
let canvas_y = y;
|
||||||
@@ -2919,9 +2981,26 @@ impl HcieIcedApp {
|
|||||||
self.tool_state.last_click_time = now;
|
self.tool_state.last_click_time = now;
|
||||||
self.tool_state.last_click_pos = Some((canvas_x, canvas_y));
|
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 =
|
let paint_tool =
|
||||||
matches!(tool, Tool::Pen | Tool::Brush | Tool::Eraser | Tool::Spray);
|
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 width = self.documents[self.active_doc].engine.canvas_width();
|
||||||
let height = self.documents[self.active_doc].engine.canvas_height();
|
let height = self.documents[self.active_doc].engine.canvas_height();
|
||||||
if canvas_x >= 0.0
|
if canvas_x >= 0.0
|
||||||
@@ -2945,7 +3024,7 @@ impl HcieIcedApp {
|
|||||||
return Task::none();
|
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_resizing_brush = true;
|
||||||
self.tool_state.is_drawing = true;
|
self.tool_state.is_drawing = true;
|
||||||
self.tool_state.brush_resize_start_size = self.tool_state.brush_size;
|
self.tool_state.brush_resize_start_size = self.tool_state.brush_size;
|
||||||
@@ -2953,6 +3032,7 @@ impl HcieIcedApp {
|
|||||||
return Task::none();
|
return Task::none();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if tool != Tool::CloneStamp {
|
||||||
if let Some(req_type) = tool.allowed_layer_type() {
|
if let Some(req_type) = tool.allowed_layer_type() {
|
||||||
// Reject raster edits on non-editable layers
|
// Reject raster edits on non-editable layers
|
||||||
if req_type == hcie_engine_api::LayerType::Raster
|
if req_type == hcie_engine_api::LayerType::Raster
|
||||||
@@ -2980,8 +3060,34 @@ impl HcieIcedApp {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
match tool {
|
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 => {
|
Tool::Pen | Tool::Brush | Tool::Eraser | Tool::Spray => {
|
||||||
crate::canvas::perf::begin_stroke();
|
crate::canvas::perf::begin_stroke();
|
||||||
crate::canvas::perf::record_render_input(captured_at);
|
crate::canvas::perf::record_render_input(captured_at);
|
||||||
@@ -3254,6 +3360,22 @@ impl HcieIcedApp {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if self.tool_state.is_drawing {
|
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
|
// Selection tools (Lasso, Polygon, Vision, Rect) use is_drawing
|
||||||
// to track drag state but must not enter the brush code path.
|
// to track drag state but must not enter the brush code path.
|
||||||
let is_selection_tool = matches!(
|
let is_selection_tool = matches!(
|
||||||
@@ -3466,6 +3588,28 @@ impl HcieIcedApp {
|
|||||||
return self.update(Message::TransformDragEnd);
|
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.
|
// Lasso release finalizes a freehand selection path.
|
||||||
if self.tool_state.active_tool == Tool::Lasso && self.tool_state.is_drawing {
|
if self.tool_state.active_tool == Tool::Lasso && self.tool_state.is_drawing {
|
||||||
self.tool_state.is_drawing = false;
|
self.tool_state.is_drawing = false;
|
||||||
@@ -4976,6 +5120,21 @@ impl HcieIcedApp {
|
|||||||
self.settings.update_from_tool_state(&self.tool_state);
|
self.settings.update_from_tool_state(&self.tool_state);
|
||||||
let _ = self.settings.save();
|
let _ = self.settings.save();
|
||||||
}
|
}
|
||||||
|
Message::BrushAngleChanged(angle) => {
|
||||||
|
self.tool_state.brush_angle = angle;
|
||||||
|
self.settings.update_from_tool_state(&self.tool_state);
|
||||||
|
let _ = self.settings.save();
|
||||||
|
}
|
||||||
|
Message::BrushSizeVarianceChanged(variance) => {
|
||||||
|
self.tool_state.brush_size_variance = variance;
|
||||||
|
self.settings.update_from_tool_state(&self.tool_state);
|
||||||
|
let _ = self.settings.save();
|
||||||
|
}
|
||||||
|
Message::BrushAngleVarianceChanged(variance) => {
|
||||||
|
self.tool_state.brush_angle_variance = variance;
|
||||||
|
self.settings.update_from_tool_state(&self.tool_state);
|
||||||
|
let _ = self.settings.save();
|
||||||
|
}
|
||||||
Message::BrushCyclicColorToggled(enabled) => {
|
Message::BrushCyclicColorToggled(enabled) => {
|
||||||
self.tool_state.brush_cyclic_color = enabled;
|
self.tool_state.brush_cyclic_color = enabled;
|
||||||
self.documents[self.active_doc]
|
self.documents[self.active_doc]
|
||||||
@@ -4992,6 +5151,57 @@ impl HcieIcedApp {
|
|||||||
self.settings.update_from_tool_state(&self.tool_state);
|
self.settings.update_from_tool_state(&self.tool_state);
|
||||||
let _ = self.settings.save();
|
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) => {
|
Message::SelectionToleranceChanged(tol) => {
|
||||||
self.settings.tool_settings.magic_wand_tolerance = tol as f32;
|
self.settings.tool_settings.magic_wand_tolerance = tol as f32;
|
||||||
self.settings.tool_settings.flood_fill_tolerance = tol as f32;
|
self.settings.tool_settings.flood_fill_tolerance = tol as f32;
|
||||||
@@ -5163,6 +5373,7 @@ impl HcieIcedApp {
|
|||||||
Message::ResetToolDefaults => {
|
Message::ResetToolDefaults => {
|
||||||
self.settings.reset_tool_defaults();
|
self.settings.reset_tool_defaults();
|
||||||
self.settings.apply_to_tool_state(&mut self.tool_state);
|
self.settings.apply_to_tool_state(&mut self.tool_state);
|
||||||
|
self.sync_clone_config();
|
||||||
let _ = self.settings.save();
|
let _ = self.settings.save();
|
||||||
}
|
}
|
||||||
Message::BrushImportAbr => {
|
Message::BrushImportAbr => {
|
||||||
@@ -8406,6 +8617,9 @@ impl HcieIcedApp {
|
|||||||
Message::NewDocument(w, h) => {
|
Message::NewDocument(w, h) => {
|
||||||
self.show_welcome = false;
|
self.show_welcome = false;
|
||||||
let mut engine = Engine::new(w, h);
|
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();
|
engine.pre_tile_all_layers();
|
||||||
let composite_raw = engine.get_composite_pixels();
|
let composite_raw = engine.get_composite_pixels();
|
||||||
let cached_layers = engine.layer_infos();
|
let cached_layers = engine.layer_infos();
|
||||||
@@ -8473,10 +8687,13 @@ impl HcieIcedApp {
|
|||||||
thumb_gen: 0,
|
thumb_gen: 0,
|
||||||
});
|
});
|
||||||
self.active_doc = self.documents.len() - 1;
|
self.active_doc = self.documents.len() - 1;
|
||||||
|
self.clone_ui = crate::clone_tool::CloneToolUiState::default();
|
||||||
}
|
}
|
||||||
|
|
||||||
Message::DocSwitch(idx) => {
|
Message::DocSwitch(idx) => {
|
||||||
if idx < self.documents.len() {
|
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() {
|
if self.preview_baseline.is_some() {
|
||||||
self.restore_preview();
|
self.restore_preview();
|
||||||
self.filter_preview_active = false;
|
self.filter_preview_active = false;
|
||||||
@@ -8484,6 +8701,10 @@ impl HcieIcedApp {
|
|||||||
self.filter_params = serde_json::json!({});
|
self.filter_params = serde_json::json!({});
|
||||||
}
|
}
|
||||||
self.active_doc = idx;
|
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].full_upload.replace(true);
|
||||||
self.documents[idx].selection_mask_dirty.set(true);
|
self.documents[idx].selection_mask_dirty.set(true);
|
||||||
}
|
}
|
||||||
@@ -9905,6 +10126,11 @@ impl HcieIcedApp {
|
|||||||
iced::keyboard::Key::Character(ref c) if c.as_str() == "b" && !ctrl && !shift => {
|
iced::keyboard::Key::Character(ref c) if c.as_str() == "b" && !ctrl && !shift => {
|
||||||
Some(Message::ToolSelected(Tool::Brush))
|
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
|
// E = Eraser Tool
|
||||||
iced::keyboard::Key::Character(ref c) if c.as_str() == "e" && !ctrl && !shift => {
|
iced::keyboard::Key::Character(ref c) if c.as_str() == "e" && !ctrl && !shift => {
|
||||||
Some(Message::ToolSelected(Tool::Eraser))
|
Some(Message::ToolSelected(Tool::Eraser))
|
||||||
@@ -10219,7 +10445,11 @@ fn active_index_after_document_close(
|
|||||||
/// vector, transform, and idle states continue to use their event-driven previews.
|
/// vector, transform, and idle states continue to use their event-driven previews.
|
||||||
/// **Side Effects / Dependencies:** None.
|
/// **Side Effects / Dependencies:** None.
|
||||||
fn should_schedule_canvas_frames(is_drawing: bool, tool: Tool) -> bool {
|
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)]
|
#[cfg(test)]
|
||||||
@@ -10299,7 +10529,13 @@ mod cycle_one_ux_tests {
|
|||||||
/// Confirms the frame timer is bounded to active raster strokes.
|
/// Confirms the frame timer is bounded to active raster strokes.
|
||||||
#[test]
|
#[test]
|
||||||
fn canvas_frame_timer_runs_only_for_active_paint_strokes() {
|
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(true, tool));
|
||||||
assert!(!should_schedule_canvas_frames(false, tool));
|
assert!(!should_schedule_canvas_frames(false, tool));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -87,6 +87,14 @@ struct OverlayProgram {
|
|||||||
_quick_mask: bool,
|
_quick_mask: bool,
|
||||||
/// Active brush diameter in canvas pixels for the footprint cursor.
|
/// Active brush diameter in canvas pixels for the footprint cursor.
|
||||||
brush_size: f32,
|
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.
|
/// Whether a raster stroke is active; used to keep idle latency metrics isolated.
|
||||||
is_drawing: bool,
|
is_drawing: bool,
|
||||||
/// Normalized bounds of the selected vector shape in canvas-space (x1, y1, x2, y2).
|
/// 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());
|
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 ─────────────────────────────────────────────
|
// ── Crosshair cursor ─────────────────────────────────────────────
|
||||||
// Strategy 4: Use the `cursor` parameter from iced's current render
|
// Strategy 4: Use the `cursor` parameter from iced's current render
|
||||||
// frame instead of the retained `state.cursor_pos` (which lags by
|
// 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::Brush
|
||||||
| hcie_engine_api::Tool::Eraser
|
| hcie_engine_api::Tool::Eraser
|
||||||
| hcie_engine_api::Tool::Spray
|
| 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 =
|
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(
|
crosshair_frame.stroke(
|
||||||
&footprint,
|
&footprint,
|
||||||
Stroke {
|
Stroke {
|
||||||
@@ -2036,7 +2143,28 @@ impl canvas::Program<Message> for OverlayProgram {
|
|||||||
..Default::default()
|
..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(
|
crosshair_frame.stroke(
|
||||||
&Path::line(
|
&Path::line(
|
||||||
Point::new(cursor_point.x - crosshair_size, cursor_point.y),
|
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
|
// runs with the latest cursor position. Without this, iced may
|
||||||
// skip repaints during idle cursor movement because no application
|
// skip repaints during idle cursor movement because no application
|
||||||
// state changed.
|
// state changed.
|
||||||
if matches!(event, canvas::Event::Mouse(mouse::Event::CursorMoved { .. })) {
|
if matches!(
|
||||||
return (canvas::event::Status::Ignored, Some(Message::CursorOverlayRedraw));
|
event,
|
||||||
|
canvas::Event::Mouse(mouse::Event::CursorMoved { .. })
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
canvas::event::Status::Ignored,
|
||||||
|
Some(Message::CursorOverlayRedraw),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
// Always pass events through to the shader widget below
|
// Always pass events through to the shader widget below
|
||||||
(canvas::event::Status::Ignored, None)
|
(canvas::event::Status::Ignored, None)
|
||||||
@@ -2233,6 +2367,9 @@ pub fn view<'a>(
|
|||||||
star_points: u32,
|
star_points: u32,
|
||||||
polygon_sides: u32,
|
polygon_sides: u32,
|
||||||
rect_radius: f32,
|
rect_radius: f32,
|
||||||
|
modifiers: iced::keyboard::Modifiers,
|
||||||
|
clone_config: hcie_engine_api::CloneConfig,
|
||||||
|
clone_source_armed: bool,
|
||||||
) -> Element<'a, Message> {
|
) -> Element<'a, Message> {
|
||||||
let engine_w = doc.engine.canvas_width();
|
let engine_w = doc.engine.canvas_width();
|
||||||
let engine_h = doc.engine.canvas_height();
|
let engine_h = doc.engine.canvas_height();
|
||||||
@@ -2265,6 +2402,7 @@ pub fn view<'a>(
|
|||||||
texture_update,
|
texture_update,
|
||||||
full_upload,
|
full_upload,
|
||||||
space_pan: tool_state.space_pan,
|
space_pan: tool_state.space_pan,
|
||||||
|
modifiers,
|
||||||
selection_mask: doc.selection_texture.clone(),
|
selection_mask: doc.selection_texture.clone(),
|
||||||
selection_dirty: doc.selection_mask_dirty.get(),
|
selection_dirty: doc.selection_mask_dirty.get(),
|
||||||
anim_time: marching_ants_offset * 2.0, // Convert 0..1 to seconds (2s cycle)
|
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(),
|
polygon_points: doc.polygon_points.clone(),
|
||||||
_quick_mask: doc.quick_mask,
|
_quick_mask: doc.quick_mask,
|
||||||
brush_size: tool_state.brush_size,
|
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,
|
is_drawing: tool_state.is_drawing,
|
||||||
selected_vector_bounds: sel_vec_bounds,
|
selected_vector_bounds: sel_vec_bounds,
|
||||||
selected_vector_angle: sel_vec_angle,
|
selected_vector_angle: sel_vec_angle,
|
||||||
|
|||||||
@@ -1010,6 +1010,8 @@ pub struct CanvasShaderProgram {
|
|||||||
pub full_upload: bool,
|
pub full_upload: bool,
|
||||||
/// Whether Space temporarily changes left-drag into panning.
|
/// Whether Space temporarily changes left-drag into panning.
|
||||||
pub space_pan: bool,
|
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.
|
/// Encoded selection data (0 unselected, 128 interior, 255 border), if any.
|
||||||
pub selection_mask: Option<std::sync::Arc<Vec<u8>>>,
|
pub selection_mask: Option<std::sync::Arc<Vec<u8>>>,
|
||||||
/// Whether the selection mask has changed since last upload.
|
/// Whether the selection mask has changed since last upload.
|
||||||
@@ -1216,6 +1218,7 @@ impl shader::Program<Message> for CanvasShaderProgram {
|
|||||||
Some(Message::CanvasPointerPressed {
|
Some(Message::CanvasPointerPressed {
|
||||||
x: cx,
|
x: cx,
|
||||||
y: cy,
|
y: cy,
|
||||||
|
modifiers: self.modifiers,
|
||||||
captured_at: std::time::Instant::now(),
|
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_points,
|
||||||
ts.vector_sides,
|
ts.vector_sides,
|
||||||
ts.vector_radius,
|
ts.vector_radius,
|
||||||
|
app.modifiers,
|
||||||
|
crate::clone_tool::config_from_settings(ts),
|
||||||
|
app.clone_ui.source_armed,
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
column![doc_tab_bar, canvas]
|
column![doc_tab_bar, canvas]
|
||||||
@@ -186,6 +189,9 @@ pub fn panel_body<'a>(
|
|||||||
app.tool_state.brush_variant_amount,
|
app.tool_state.brush_variant_amount,
|
||||||
app.tool_state.brush_cyclic_color,
|
app.tool_state.brush_cyclic_color,
|
||||||
app.tool_state.brush_cyclic_speed,
|
app.tool_state.brush_cyclic_speed,
|
||||||
|
app.tool_state.brush_angle,
|
||||||
|
app.tool_state.brush_size_variance,
|
||||||
|
app.tool_state.brush_angle_variance,
|
||||||
app.brush_category,
|
app.brush_category,
|
||||||
&app.tool_state.imported_brushes,
|
&app.tool_state.imported_brushes,
|
||||||
app.tool_state.active_brush_preset.as_ref(),
|
app.tool_state.active_brush_preset.as_ref(),
|
||||||
@@ -266,6 +272,11 @@ pub fn panel_body<'a>(
|
|||||||
app.bg_color,
|
app.bg_color,
|
||||||
app.settings.tool_settings.spray_particle_size,
|
app.settings.tool_settings.spray_particle_size,
|
||||||
app.settings.tool_settings.spray_density,
|
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(
|
PaneType::Script => crate::panels::script_panel::view(
|
||||||
@@ -301,7 +312,18 @@ pub fn panel_body<'a>(
|
|||||||
PaneType::ToolSettings => {
|
PaneType::ToolSettings => {
|
||||||
let fg = app.fg_color;
|
let fg = app.fg_color;
|
||||||
let draft = app.documents[app.active_doc].text_draft.as_ref();
|
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 => {
|
PaneType::CustomShapes => {
|
||||||
let selected_index = match app.tool_state.active_tool {
|
let selected_index = match app.tool_state.active_tool {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ mod brush_import;
|
|||||||
mod build_info;
|
mod build_info;
|
||||||
mod canvas;
|
mod canvas;
|
||||||
mod cli;
|
mod cli;
|
||||||
|
mod clone_tool;
|
||||||
mod color_picker;
|
mod color_picker;
|
||||||
mod dialogs;
|
mod dialogs;
|
||||||
mod dock;
|
mod dock;
|
||||||
|
|||||||
@@ -113,9 +113,9 @@ const BRUSH_STYLES: &[BrushStyleEntry] = &[
|
|||||||
category: BrushCategory::Drawing,
|
category: BrushCategory::Drawing,
|
||||||
},
|
},
|
||||||
BrushStyleEntry {
|
BrushStyleEntry {
|
||||||
name: "Star",
|
name: "Star Sparkle",
|
||||||
style: BrushStyle::Star,
|
style: BrushStyle::Star,
|
||||||
category: BrushCategory::Drawing,
|
category: BrushCategory::Effects,
|
||||||
},
|
},
|
||||||
BrushStyleEntry {
|
BrushStyleEntry {
|
||||||
name: "Oil",
|
name: "Oil",
|
||||||
@@ -510,6 +510,9 @@ fn build_tip_from_state(
|
|||||||
opacity: f32,
|
opacity: f32,
|
||||||
hardness: f32,
|
hardness: f32,
|
||||||
cyclic_color: bool,
|
cyclic_color: bool,
|
||||||
|
angle: f32,
|
||||||
|
size_variance: f32,
|
||||||
|
angle_variance: f32,
|
||||||
) -> hcie_engine_api::BrushTip {
|
) -> hcie_engine_api::BrushTip {
|
||||||
hcie_engine_api::BrushTip {
|
hcie_engine_api::BrushTip {
|
||||||
style,
|
style,
|
||||||
@@ -523,6 +526,9 @@ fn build_tip_from_state(
|
|||||||
time_opacity_end: 1.0,
|
time_opacity_end: 1.0,
|
||||||
time_angle_start: 0.0,
|
time_angle_start: 0.0,
|
||||||
time_angle_end: 0.0,
|
time_angle_end: 0.0,
|
||||||
|
angle,
|
||||||
|
roundness: size_variance,
|
||||||
|
rotation_random: angle_variance,
|
||||||
..hcie_engine_api::BrushTip::default()
|
..hcie_engine_api::BrushTip::default()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -575,6 +581,9 @@ fn get_live_stroke_preview(
|
|||||||
hardness: f32,
|
hardness: f32,
|
||||||
cyclic_color: bool,
|
cyclic_color: bool,
|
||||||
fg_color: [u8; 4],
|
fg_color: [u8; 4],
|
||||||
|
angle: f32,
|
||||||
|
size_variance: f32,
|
||||||
|
angle_variance: f32,
|
||||||
) -> iced::widget::image::Handle {
|
) -> iced::widget::image::Handle {
|
||||||
// Clamp the rendered brush size so a 110 px soft brush does not fill the
|
// Clamp the rendered brush size so a 110 px soft brush does not fill the
|
||||||
// entire 40 px preview well with a solid blob. The preview shows texture and
|
// entire 40 px preview well with a solid blob. The preview shows texture and
|
||||||
@@ -588,7 +597,16 @@ fn get_live_stroke_preview(
|
|||||||
if let Some(handle) = cache.get(&key) {
|
if let Some(handle) = cache.get(&key) {
|
||||||
return handle.clone();
|
return handle.clone();
|
||||||
}
|
}
|
||||||
let tip = build_tip_from_state(style, preview_size, opacity, hardness, cyclic_color);
|
let tip = build_tip_from_state(
|
||||||
|
style,
|
||||||
|
preview_size,
|
||||||
|
opacity,
|
||||||
|
hardness,
|
||||||
|
cyclic_color,
|
||||||
|
angle,
|
||||||
|
size_variance,
|
||||||
|
angle_variance,
|
||||||
|
);
|
||||||
let pixels =
|
let pixels =
|
||||||
render_stroke_preview(LIVE_PREVIEW_IMG_SIZE, LIVE_PREVIEW_IMG_SIZE, &tip, fg_color);
|
render_stroke_preview(LIVE_PREVIEW_IMG_SIZE, LIVE_PREVIEW_IMG_SIZE, &tip, fg_color);
|
||||||
let handle = iced::widget::image::Handle::from_rgba(
|
let handle = iced::widget::image::Handle::from_rgba(
|
||||||
@@ -656,6 +674,9 @@ pub fn view(
|
|||||||
brush_variant_amount: f32,
|
brush_variant_amount: f32,
|
||||||
brush_cyclic_color: bool,
|
brush_cyclic_color: bool,
|
||||||
brush_cyclic_speed: f32,
|
brush_cyclic_speed: f32,
|
||||||
|
brush_angle: f32,
|
||||||
|
brush_size_variance: f32,
|
||||||
|
brush_angle_variance: f32,
|
||||||
active_category: BrushCategory,
|
active_category: BrushCategory,
|
||||||
imported_presets: &[hcie_engine_api::BrushPreset],
|
imported_presets: &[hcie_engine_api::BrushPreset],
|
||||||
active_brush_preset: Option<&hcie_engine_api::BrushPreset>,
|
active_brush_preset: Option<&hcie_engine_api::BrushPreset>,
|
||||||
@@ -1051,6 +1072,50 @@ pub fn view(
|
|||||||
Message::BrushCyclicSpeedChanged,
|
Message::BrushCyclicSpeedChanged,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
let is_star = current_style == BrushStyle::Star;
|
||||||
|
let star_angle_slider = if is_star {
|
||||||
|
Some(plain_slider(
|
||||||
|
"Star Angle",
|
||||||
|
brush_angle,
|
||||||
|
0.0..=std::f32::consts::TAU,
|
||||||
|
0.01,
|
||||||
|
"rad",
|
||||||
|
2,
|
||||||
|
colors,
|
||||||
|
Message::BrushAngleChanged,
|
||||||
|
))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
let star_size_var_slider = if is_star {
|
||||||
|
Some(plain_slider(
|
||||||
|
"Size Variance",
|
||||||
|
brush_size_variance,
|
||||||
|
0.0..=1.0,
|
||||||
|
0.01,
|
||||||
|
"%",
|
||||||
|
0,
|
||||||
|
colors,
|
||||||
|
Message::BrushSizeVarianceChanged,
|
||||||
|
))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
let star_angle_var_slider = if is_star {
|
||||||
|
Some(plain_slider(
|
||||||
|
"Angle Variance",
|
||||||
|
brush_angle_variance,
|
||||||
|
0.0..=1.0,
|
||||||
|
0.01,
|
||||||
|
"%",
|
||||||
|
0,
|
||||||
|
colors,
|
||||||
|
Message::BrushAngleVarianceChanged,
|
||||||
|
))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
// Compact color-variant toggle: an empty 12 px checkbox plus an 8 px label so
|
// Compact color-variant toggle: an empty 12 px checkbox plus an 8 px label so
|
||||||
// the control matches the rest of the compact panel typography.
|
// the control matches the rest of the compact panel typography.
|
||||||
let color_variant_check = row![
|
let color_variant_check = row![
|
||||||
@@ -1082,6 +1147,9 @@ pub fn view(
|
|||||||
current_hardness,
|
current_hardness,
|
||||||
brush_cyclic_color,
|
brush_cyclic_color,
|
||||||
fg_color,
|
fg_color,
|
||||||
|
brush_angle,
|
||||||
|
brush_size_variance,
|
||||||
|
brush_angle_variance,
|
||||||
);
|
);
|
||||||
let live_preview = container(
|
let live_preview = container(
|
||||||
iced::widget::image(preview_handle)
|
iced::widget::image(preview_handle)
|
||||||
@@ -1095,12 +1163,16 @@ pub fn view(
|
|||||||
.style(move |_theme| styles::recessed_control(colors));
|
.style(move |_theme| styles::recessed_control(colors));
|
||||||
|
|
||||||
// Panel title is in the dock tab — no duplicate inside the panel.
|
// Panel title is in the dock tab — no duplicate inside the panel.
|
||||||
let panel = column![
|
let mut panel_children: Vec<Element<'static, Message>> = vec![
|
||||||
tabs,
|
tabs.into(),
|
||||||
horizontal_rule(1),
|
horizontal_rule(1).into(),
|
||||||
scrollable(column![grid_rows, wc_rows, media_rows, imported_rows].spacing(GRID_SPACING))
|
scrollable(column![grid_rows, wc_rows, media_rows, imported_rows].spacing(GRID_SPACING))
|
||||||
.height(Length::Fill),
|
.height(Length::Fill)
|
||||||
horizontal_rule(1),
|
.into(),
|
||||||
|
horizontal_rule(1).into(),
|
||||||
|
];
|
||||||
|
|
||||||
|
panel_children.push(
|
||||||
row![button(text("Import ABR").size(LABEL_SIZE))
|
row![button(text("Import ABR").size(LABEL_SIZE))
|
||||||
.on_press(Message::BrushImportAbr)
|
.on_press(Message::BrushImportAbr)
|
||||||
.padding([3, 7])
|
.padding([3, 7])
|
||||||
@@ -1122,17 +1194,29 @@ pub fn view(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
)]
|
)]
|
||||||
.spacing(GRID_SPACING),
|
.spacing(GRID_SPACING)
|
||||||
text("Preview").size(LABEL_SIZE + 1),
|
.into(),
|
||||||
live_preview,
|
);
|
||||||
size_slider,
|
panel_children.push(text("Preview").size(LABEL_SIZE + 1).into());
|
||||||
opacity_slider,
|
panel_children.push(live_preview.into());
|
||||||
hardness_slider,
|
panel_children.push(size_slider.into());
|
||||||
color_variant_check,
|
panel_children.push(opacity_slider.into());
|
||||||
variant_slider,
|
panel_children.push(hardness_slider.into());
|
||||||
cyclic_color_check,
|
panel_children.push(color_variant_check.into());
|
||||||
cyclic_speed_slider,
|
panel_children.push(variant_slider.into());
|
||||||
]
|
panel_children.push(cyclic_color_check.into());
|
||||||
|
panel_children.push(cyclic_speed_slider.into());
|
||||||
|
if let Some(s) = star_angle_slider {
|
||||||
|
panel_children.push(s.into());
|
||||||
|
}
|
||||||
|
if let Some(s) = star_size_var_slider {
|
||||||
|
panel_children.push(s.into());
|
||||||
|
}
|
||||||
|
if let Some(s) = star_angle_var_slider {
|
||||||
|
panel_children.push(s.into());
|
||||||
|
}
|
||||||
|
|
||||||
|
let panel = iced::widget::Column::with_children(panel_children)
|
||||||
.spacing(ITEM_SPACING)
|
.spacing(ITEM_SPACING)
|
||||||
.padding(3);
|
.padding(3);
|
||||||
|
|
||||||
@@ -1181,7 +1265,7 @@ mod tests {
|
|||||||
presets.extend(hcie_paint_brushes::paint_presets());
|
presets.extend(hcie_paint_brushes::paint_presets());
|
||||||
presets.extend(hcie_digital_brushes::digital_presets());
|
presets.extend(hcie_digital_brushes::digital_presets());
|
||||||
presets.extend(hcie_watercolor_brushes::watercolor_presets());
|
presets.extend(hcie_watercolor_brushes::watercolor_presets());
|
||||||
assert_eq!(presets.len(), 47);
|
assert_eq!(presets.len(), 48);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
presets
|
presets
|
||||||
.iter()
|
.iter()
|
||||||
|
|||||||
@@ -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::separator(),
|
||||||
MenuItem::disabled("Retouch"),
|
MenuItem::disabled("Retouch"),
|
||||||
|
MenuItem::command_with_shortcut(
|
||||||
|
"Clone Stamp",
|
||||||
|
"S",
|
||||||
|
MenuCommand::SelectTool(Tool::CloneStamp),
|
||||||
|
),
|
||||||
MenuItem::command(
|
MenuItem::command(
|
||||||
"Red-eye Removal",
|
"Red-eye Removal",
|
||||||
MenuCommand::SelectTool(Tool::RedEyeRemoval),
|
MenuCommand::SelectTool(Tool::RedEyeRemoval),
|
||||||
@@ -1296,6 +1301,7 @@ mod tests {
|
|||||||
"Tools/Crop",
|
"Tools/Crop",
|
||||||
"Tools/Text",
|
"Tools/Text",
|
||||||
"Tools/Gradient",
|
"Tools/Gradient",
|
||||||
|
"Tools/Clone Stamp",
|
||||||
"Tools/Red-eye Removal",
|
"Tools/Red-eye Removal",
|
||||||
"Tools/Spot Removal Patch",
|
"Tools/Spot Removal Patch",
|
||||||
"Tools/Smart Patch",
|
"Tools/Smart Patch",
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
pub mod ai_chat_panel;
|
pub mod ai_chat_panel;
|
||||||
pub mod ai_script_panel;
|
pub mod ai_script_panel;
|
||||||
pub mod brushes;
|
pub mod brushes;
|
||||||
|
pub mod clone_tool_controls;
|
||||||
pub mod custom_shapes;
|
pub mod custom_shapes;
|
||||||
pub mod filter_params;
|
pub mod filter_params;
|
||||||
pub mod filters;
|
pub mod filters;
|
||||||
|
|||||||
@@ -121,6 +121,7 @@ pub fn view<'a>(
|
|||||||
background_color: [u8; 4],
|
background_color: [u8; 4],
|
||||||
settings_spray_particle_size: f32,
|
settings_spray_particle_size: f32,
|
||||||
settings_spray_density: u32,
|
settings_spray_density: u32,
|
||||||
|
clone_model: crate::clone_tool::ClonePanelModel,
|
||||||
) -> Element<'a, Message> {
|
) -> Element<'a, Message> {
|
||||||
// Build layer info section at the top
|
// Build layer info section at the top
|
||||||
let layer_section = layer_info_section(
|
let layer_section = layer_info_section(
|
||||||
@@ -209,6 +210,7 @@ pub fn view<'a>(
|
|||||||
]
|
]
|
||||||
.spacing(4)
|
.spacing(4)
|
||||||
.into(),
|
.into(),
|
||||||
|
Tool::CloneStamp => crate::panels::clone_tool_controls::view(clone_model, colors).into(),
|
||||||
_ => text(format!("{:?}", active_tool)).size(SECTION).into(),
|
_ => text(format!("{:?}", active_tool)).size(SECTION).into(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,9 @@ use crate::settings::AppSettings;
|
|||||||
use crate::theme::ThemeColors;
|
use crate::theme::ThemeColors;
|
||||||
use crate::widgets::plain_slider::plain_slider;
|
use crate::widgets::plain_slider::plain_slider;
|
||||||
use hcie_engine_api::Tool;
|
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};
|
use iced::{Element, Length};
|
||||||
|
|
||||||
/// Build the tool settings panel for the active tool.
|
/// Build the tool settings panel for the active tool.
|
||||||
@@ -26,6 +28,7 @@ pub fn view<'a>(
|
|||||||
settings: &'a AppSettings,
|
settings: &'a AppSettings,
|
||||||
fg_color: [u8; 4],
|
fg_color: [u8; 4],
|
||||||
text_draft: Option<&'a crate::app::TextDraft>,
|
text_draft: Option<&'a crate::app::TextDraft>,
|
||||||
|
clone_model: crate::clone_tool::ClonePanelModel,
|
||||||
colors: ThemeColors,
|
colors: ThemeColors,
|
||||||
) -> Element<'a, Message> {
|
) -> Element<'a, Message> {
|
||||||
let content = match tool_state.active_tool {
|
let content = match tool_state.active_tool {
|
||||||
@@ -52,6 +55,7 @@ pub fn view<'a>(
|
|||||||
Tool::Move => move_settings(settings, colors),
|
Tool::Move => move_settings(settings, colors),
|
||||||
Tool::Gradient => gradient_settings(settings, colors),
|
Tool::Gradient => gradient_settings(settings, colors),
|
||||||
Tool::FloodFill => flood_fill_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),
|
_ => 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.
|
/// Build the compact toolbar: SVG icon buttons + brush size + opacity in one row.
|
||||||
pub fn view<'a>(
|
pub fn view<'a>(
|
||||||
tool_state: &'a crate::app::ToolState,
|
tool_state: &'a crate::app::ToolState,
|
||||||
_settings: &'a crate::settings::AppSettings,
|
settings: &'a crate::settings::AppSettings,
|
||||||
colors: ThemeColors,
|
colors: ThemeColors,
|
||||||
) -> Element<'a, Message> {
|
) -> Element<'a, Message> {
|
||||||
let c = colors;
|
let c = colors;
|
||||||
@@ -47,26 +47,29 @@ pub fn view<'a>(
|
|||||||
.align_y(iced::Alignment::Center);
|
.align_y(iced::Alignment::Center);
|
||||||
|
|
||||||
// ── Brush size + opacity (compact, matching egui) ──
|
// ── 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(
|
let size_sl = container(plain_slider(
|
||||||
"Size",
|
"Size",
|
||||||
tool_state.brush_size,
|
size,
|
||||||
1.0..=200.0,
|
1.0..=500.0,
|
||||||
1.0,
|
1.0,
|
||||||
"px",
|
"px",
|
||||||
0,
|
0,
|
||||||
colors,
|
colors,
|
||||||
Message::BrushSizeChanged,
|
move |value| if clone_active { Message::CloneSizeChanged(value) } else { Message::BrushSizeChanged(value) },
|
||||||
))
|
))
|
||||||
.width(110);
|
.width(110);
|
||||||
let opacity_sl = container(plain_slider(
|
let opacity_sl = container(plain_slider(
|
||||||
"Opacity",
|
"Opacity",
|
||||||
tool_state.brush_opacity,
|
opacity,
|
||||||
0.0..=1.0,
|
0.0..=1.0,
|
||||||
0.01,
|
0.01,
|
||||||
"%",
|
"%",
|
||||||
0,
|
0,
|
||||||
colors,
|
colors,
|
||||||
Message::BrushOpacityChanged,
|
move |value| if clone_active { Message::CloneOpacityChanged(value) } else { Message::BrushOpacityChanged(value) },
|
||||||
))
|
))
|
||||||
.width(110);
|
.width(110);
|
||||||
|
|
||||||
|
|||||||
@@ -48,6 +48,26 @@ pub struct AppSettings {
|
|||||||
/// Tool-specific settings that persist between sessions.
|
/// Tool-specific settings that persist between sessions.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct ToolSettings {
|
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_size: f32,
|
||||||
pub brush_opacity: f32,
|
pub brush_opacity: f32,
|
||||||
pub brush_hardness: f32,
|
pub brush_hardness: f32,
|
||||||
@@ -59,6 +79,15 @@ pub struct ToolSettings {
|
|||||||
/// Magnitude of the per-dab color variation (0.0..1.0).
|
/// Magnitude of the per-dab color variation (0.0..1.0).
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub brush_variant_amount: f32,
|
pub brush_variant_amount: f32,
|
||||||
|
/// Star brush base rotation angle in radians.
|
||||||
|
#[serde(default)]
|
||||||
|
pub brush_angle: f32,
|
||||||
|
/// Star brush per-dab size variance (0.0..1.0).
|
||||||
|
#[serde(default)]
|
||||||
|
pub brush_size_variance: f32,
|
||||||
|
/// Star brush per-dab angle variance (0.0..1.0).
|
||||||
|
#[serde(default)]
|
||||||
|
pub brush_angle_variance: f32,
|
||||||
/// True when the brush color should cycle through the hue over stroke distance.
|
/// True when the brush color should cycle through the hue over stroke distance.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub brush_cyclic_color: bool,
|
pub brush_cyclic_color: bool,
|
||||||
@@ -123,6 +152,18 @@ pub struct ToolSettings {
|
|||||||
fn default_vector_fill() -> bool {
|
fn default_vector_fill() -> bool {
|
||||||
true
|
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 {
|
fn default_vector_points() -> u32 {
|
||||||
5
|
5
|
||||||
}
|
}
|
||||||
@@ -191,6 +232,14 @@ impl Default for AppSettings {
|
|||||||
impl Default for ToolSettings {
|
impl Default for ToolSettings {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
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_size: 20.0,
|
||||||
brush_opacity: 1.0,
|
brush_opacity: 1.0,
|
||||||
brush_hardness: 0.8,
|
brush_hardness: 0.8,
|
||||||
@@ -198,6 +247,9 @@ impl Default for ToolSettings {
|
|||||||
brush_spacing: 1.0,
|
brush_spacing: 1.0,
|
||||||
brush_color_variant: false,
|
brush_color_variant: false,
|
||||||
brush_variant_amount: 0.0,
|
brush_variant_amount: 0.0,
|
||||||
|
brush_angle: 0.0,
|
||||||
|
brush_size_variance: 0.0,
|
||||||
|
brush_angle_variance: 0.0,
|
||||||
brush_cyclic_color: false,
|
brush_cyclic_color: false,
|
||||||
brush_cyclic_speed: 0.5,
|
brush_cyclic_speed: 0.5,
|
||||||
eraser_size: 20.0,
|
eraser_size: 20.0,
|
||||||
@@ -352,6 +404,9 @@ impl AppSettings {
|
|||||||
self.tool_settings.brush_hardness = tool_state.brush_hardness;
|
self.tool_settings.brush_hardness = tool_state.brush_hardness;
|
||||||
self.tool_settings.brush_color_variant = tool_state.brush_color_variant;
|
self.tool_settings.brush_color_variant = tool_state.brush_color_variant;
|
||||||
self.tool_settings.brush_variant_amount = tool_state.brush_variant_amount;
|
self.tool_settings.brush_variant_amount = tool_state.brush_variant_amount;
|
||||||
|
self.tool_settings.brush_angle = tool_state.brush_angle;
|
||||||
|
self.tool_settings.brush_size_variance = tool_state.brush_size_variance;
|
||||||
|
self.tool_settings.brush_angle_variance = tool_state.brush_angle_variance;
|
||||||
self.tool_settings.brush_cyclic_color = tool_state.brush_cyclic_color;
|
self.tool_settings.brush_cyclic_color = tool_state.brush_cyclic_color;
|
||||||
self.tool_settings.brush_cyclic_speed = tool_state.brush_cyclic_speed;
|
self.tool_settings.brush_cyclic_speed = tool_state.brush_cyclic_speed;
|
||||||
self.tool_settings.last_active_tool = format!("{:?}", tool_state.active_tool);
|
self.tool_settings.last_active_tool = format!("{:?}", tool_state.active_tool);
|
||||||
@@ -364,8 +419,18 @@ impl AppSettings {
|
|||||||
tool_state.brush_hardness = self.tool_settings.brush_hardness;
|
tool_state.brush_hardness = self.tool_settings.brush_hardness;
|
||||||
tool_state.brush_color_variant = self.tool_settings.brush_color_variant;
|
tool_state.brush_color_variant = self.tool_settings.brush_color_variant;
|
||||||
tool_state.brush_variant_amount = self.tool_settings.brush_variant_amount;
|
tool_state.brush_variant_amount = self.tool_settings.brush_variant_amount;
|
||||||
|
tool_state.brush_angle = self.tool_settings.brush_angle;
|
||||||
|
tool_state.brush_size_variance = self.tool_settings.brush_size_variance;
|
||||||
|
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_color = self.tool_settings.brush_cyclic_color;
|
||||||
tool_state.brush_cyclic_speed = self.tool_settings.brush_cyclic_speed;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -390,10 +455,30 @@ mod tests {
|
|||||||
.and_then(serde_json::Value::as_object_mut)
|
.and_then(serde_json::Value::as_object_mut)
|
||||||
.expect("panel layout object")
|
.expect("panel layout object")
|
||||||
.remove("dock_layout");
|
.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 =
|
let settings: AppSettings =
|
||||||
serde_json::from_value(legacy).expect("deserialize legacy settings");
|
serde_json::from_value(legacy).expect("deserialize legacy settings");
|
||||||
assert_eq!(settings.theme_preset, ThemePreset::Photopea);
|
assert_eq!(settings.theme_preset, ThemePreset::Photopea);
|
||||||
assert!(!settings.panel_layout.sidebar_expanded);
|
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 {
|
ToolSlot {
|
||||||
tools: &[
|
tools: &[
|
||||||
|
Tool::CloneStamp,
|
||||||
Tool::SpotRemoval,
|
Tool::SpotRemoval,
|
||||||
Tool::RedEyeRemoval,
|
Tool::RedEyeRemoval,
|
||||||
Tool::SmartPatch,
|
Tool::SmartPatch,
|
||||||
@@ -766,6 +767,7 @@ fn tool_icon(tool: Tool) -> &'static str {
|
|||||||
Tool::VectorBolt => "icons/vector_bolt.svg",
|
Tool::VectorBolt => "icons/vector_bolt.svg",
|
||||||
Tool::VectorArrow4 => "icons/vector_arrow4.svg",
|
Tool::VectorArrow4 => "icons/vector_arrow4.svg",
|
||||||
Tool::SpotRemoval => "icons/spot.svg",
|
Tool::SpotRemoval => "icons/spot.svg",
|
||||||
|
Tool::CloneStamp => "icons/clone_stamp.svg",
|
||||||
Tool::RedEyeRemoval => "icons/redeye.svg",
|
Tool::RedEyeRemoval => "icons/redeye.svg",
|
||||||
Tool::SmartPatch | Tool::AiObjectRemoval => "icons/patch.svg",
|
Tool::SmartPatch | Tool::AiObjectRemoval => "icons/patch.svg",
|
||||||
Tool::CustomShape(_) => "icons/star.svg",
|
Tool::CustomShape(_) => "icons/star.svg",
|
||||||
@@ -814,6 +816,7 @@ fn tool_shortcut(tool: Tool) -> &'static str {
|
|||||||
Tool::FloodFill => "G",
|
Tool::FloodFill => "G",
|
||||||
Tool::Gradient => "G",
|
Tool::Gradient => "G",
|
||||||
Tool::Text => "T",
|
Tool::Text => "T",
|
||||||
|
Tool::CloneStamp => "S",
|
||||||
Tool::VectorLine => "U",
|
Tool::VectorLine => "U",
|
||||||
Tool::VectorRect => "U",
|
Tool::VectorRect => "U",
|
||||||
Tool::VectorCircle => "U",
|
Tool::VectorCircle => "U",
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ pub enum Tool {
|
|||||||
AiObjectRemoval,
|
AiObjectRemoval,
|
||||||
SmartSelect,
|
SmartSelect,
|
||||||
VisionSelect,
|
VisionSelect,
|
||||||
|
CloneStamp,
|
||||||
CustomShape(u32),
|
CustomShape(u32),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,6 +62,7 @@ impl Tool {
|
|||||||
Tool::Gradient, Tool::Move, Tool::Crop,
|
Tool::Gradient, Tool::Move, Tool::Crop,
|
||||||
Tool::RedEyeRemoval, Tool::SpotRemoval, Tool::SmartPatch,
|
Tool::RedEyeRemoval, Tool::SpotRemoval, Tool::SmartPatch,
|
||||||
Tool::AiObjectRemoval, Tool::SmartSelect, Tool::VisionSelect,
|
Tool::AiObjectRemoval, Tool::SmartSelect, Tool::VisionSelect,
|
||||||
|
Tool::CloneStamp,
|
||||||
];
|
];
|
||||||
|
|
||||||
pub fn label(self) -> &'static str {
|
pub fn label(self) -> &'static str {
|
||||||
@@ -101,6 +103,7 @@ impl Tool {
|
|||||||
Tool::AiObjectRemoval => "AI Object Removal",
|
Tool::AiObjectRemoval => "AI Object Removal",
|
||||||
Tool::SmartSelect => "Smart Select",
|
Tool::SmartSelect => "Smart Select",
|
||||||
Tool::VisionSelect => "Vision Tools",
|
Tool::VisionSelect => "Vision Tools",
|
||||||
|
Tool::CloneStamp => "Clone Stamp",
|
||||||
Tool::CustomShape(_) => "Custom Shape",
|
Tool::CustomShape(_) => "Custom Shape",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -143,12 +146,13 @@ impl Tool {
|
|||||||
Tool::AiObjectRemoval => "🪄",
|
Tool::AiObjectRemoval => "🪄",
|
||||||
Tool::SmartSelect => "🎯",
|
Tool::SmartSelect => "🎯",
|
||||||
Tool::VisionSelect => "👁",
|
Tool::VisionSelect => "👁",
|
||||||
|
Tool::CloneStamp => "C",
|
||||||
Tool::CustomShape(_) => "○",
|
Tool::CustomShape(_) => "○",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn is_raster(self) -> bool {
|
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 {
|
pub fn is_ai_tool(self) -> bool {
|
||||||
@@ -172,7 +176,7 @@ impl Tool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn shows_pen_tips(self) -> bool {
|
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 {
|
pub fn is_selection_tool(self) -> bool {
|
||||||
@@ -203,11 +207,11 @@ impl Tool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn has_size(self) -> bool {
|
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 {
|
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]
|
#[test]
|
||||||
fn test_all_tools_listed() {
|
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]
|
#[test]
|
||||||
|
|||||||
Reference in New Issue
Block a user