Compare commits
4 Commits
cea60143b1
...
clone-tool
| Author | SHA1 | Date | |
|---|---|---|---|
| b31a949215 | |||
| a64f1184cd | |||
| acd7de5dd3 | |||
| fd205309b6 |
@@ -0,0 +1,282 @@
|
|||||||
|
# Brush Checkerboard Fix + AGENTS.md Update Plan
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
- Project root: `/mnt/extra/00_PROJECTS/hcie-rust-v3.05`
|
||||||
|
- Active GUI: `hcie-iced-app/` (Iced + wgpu). `hcie-egui-app/` is legacy/abandoned.
|
||||||
|
- The reported artifact is a regular grid/checkerboard inside brush strokes on white canvas, visible only with certain brushes.
|
||||||
|
- `AGENTS.md` is outdated: it still says "HCIE-Rust v4", lists egui as the active GUI, and omits the iced workspace.
|
||||||
|
|
||||||
|
## Part 1: Brush Checkerboard Root Cause
|
||||||
|
|
||||||
|
The grid comes from deterministic, canvas-space pseudo-random sampling in `hcie-brush-engine/src/lib.rs`:
|
||||||
|
|
||||||
|
- `draw_grain_brush(..., woven: bool)` uses:
|
||||||
|
```rust
|
||||||
|
let hash = ((x.wrapping_mul(73_856_093) ^ y.wrapping_mul(19_349_663)) & 1023) as f32 / 1023.0;
|
||||||
|
if texture < threshold { continue; }
|
||||||
|
```
|
||||||
|
- `draw_crayon_brush` uses the same constants and a hard `continue` skip:
|
||||||
|
```rust
|
||||||
|
let noise = ((px * 73_856_093) ^ (py * 19_349_663)) & 1023;
|
||||||
|
if grain < threshold { continue; }
|
||||||
|
```
|
||||||
|
|
||||||
|
Because the hash depends only on absolute pixel `(x, y)` and the same pixels are re-sampled on every overlapping dab, the skipped pixels form a rigid canvas-locked checkerboard.
|
||||||
|
|
||||||
|
### Affected Brush Styles
|
||||||
|
|
||||||
|
| Style | Function | Mechanism |
|
||||||
|
|-------|----------|-----------|
|
||||||
|
| `Noise` | `draw_grain_brush(..., woven=false)` | hash threshold skip |
|
||||||
|
| `Texture` | `draw_grain_brush(..., woven=true)` | sin/cos woven threshold skip |
|
||||||
|
| `Pencil` | `draw_pencil_brush` → `draw_grain_brush` | hash threshold skip |
|
||||||
|
| `Charcoal` | `draw_charcoal_brush` → `draw_grain_brush` | hash threshold skip |
|
||||||
|
| `Sketch` | `draw_sketch_brush` → `draw_grain_brush` | hash threshold skip |
|
||||||
|
| `Crayon` | `draw_crayon_brush` | hash threshold skip |
|
||||||
|
|
||||||
|
Dry-media presets that map to these styles are also affected:
|
||||||
|
- Compressed Charcoal, Vine Charcoal, Charcoal Pencil
|
||||||
|
- Soft Pastel, Hard Pastel, Oil Pastel, Water-Soluble Pastel, PanPastel, Chalk Sanguine
|
||||||
|
- Graphite Pencils (HB/9B/Carpenter), Wax/Oil/Watercolor Pencils
|
||||||
|
|
||||||
|
Styles **not** affected: Round, HardRound, SoftRound, Square, Star, Spray, Pen, InkPen, Calligraphy, Oil, Airbrush, Watercolor, Marker, Glow, WetPaint, Leaf, Rock, Meadow, Clouds, Dirt, Tree, Bristle, Wood, Mixer, Blender, Bitmap.
|
||||||
|
|
||||||
|
## Part 2: Proposed Fix
|
||||||
|
|
||||||
|
### Decision
|
||||||
|
|
||||||
|
Apply both mechanisms together:
|
||||||
|
1. **Per-dab grain seed offset** in `draw_grain_brush` / `draw_crayon_brush` so consecutive dabs no longer hit the exact same canvas pixels.
|
||||||
|
2. **Convert hard threshold skip to alpha modulation** so "empty" pores become translucent rather than fully transparent, removing the rigid checkerboard.
|
||||||
|
|
||||||
|
### Implementation Details
|
||||||
|
|
||||||
|
#### 2.1 `draw_grain_brush` changes
|
||||||
|
|
||||||
|
Location: `hcie-brush-engine/src/lib.rs:1350-1429`
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// Add inside the function, after radius/bbox calculation:
|
||||||
|
let seed_x = (cx.to_bits().wrapping_mul(73_856_093)
|
||||||
|
^ cy.to_bits().wrapping_mul(19_349_663)) as u32;
|
||||||
|
let seed_y = seed_x.wrapping_mul(668_265_263);
|
||||||
|
|
||||||
|
// Replace the hash computation with decorrelated coordinates:
|
||||||
|
let ox = x.wrapping_add(seed_x);
|
||||||
|
let oy = y.wrapping_add(seed_y);
|
||||||
|
let hash = ((ox.wrapping_mul(73_856_093) ^ oy.wrapping_mul(19_349_663)) & 1023) as f32 / 1023.0;
|
||||||
|
|
||||||
|
// For woven mode, shift the sin/cos arguments by the seed:
|
||||||
|
let texture = if woven {
|
||||||
|
0.55 + 0.45 * ((x as f32 * 0.42 + seed_x as f32 * 0.001).sin()
|
||||||
|
* (y as f32 * 0.31 + seed_y as f32 * 0.001).cos()).abs()
|
||||||
|
} else {
|
||||||
|
hash
|
||||||
|
};
|
||||||
|
|
||||||
|
// Replace `if texture < threshold { continue; }` with soft alpha modulation:
|
||||||
|
let grain_alpha = if texture < threshold {
|
||||||
|
(texture / threshold).clamp(0.0, 1.0)
|
||||||
|
} else {
|
||||||
|
0.55 + 0.45 * texture
|
||||||
|
};
|
||||||
|
|
||||||
|
// Use grain_alpha in the final alpha instead of the old `(0.55 + 0.45 * texture)` factor.
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2.2 `draw_crayon_brush` changes
|
||||||
|
|
||||||
|
Location: `hcie-brush-engine/src/lib.rs:2941-3026`
|
||||||
|
|
||||||
|
Apply the same seed + soft alpha approach to the crayon noise loop:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let seed_x = (cx.to_bits().wrapping_mul(73_856_093)
|
||||||
|
^ cy.to_bits().wrapping_mul(19_349_663)) as u32;
|
||||||
|
let seed_y = seed_x.wrapping_mul(668_265_263);
|
||||||
|
|
||||||
|
// inside pixel loop:
|
||||||
|
let ox = px.wrapping_add(seed_x);
|
||||||
|
let oy = py.wrapping_add(seed_y);
|
||||||
|
let noise = (ox.wrapping_mul(73_856_093) ^ oy.wrapping_mul(19_349_663)) & 1023;
|
||||||
|
let grain = noise as f32 / 1023.0;
|
||||||
|
let threshold = 0.18 * (1.0 - pressure) + 0.08;
|
||||||
|
let grain_alpha = if grain < threshold {
|
||||||
|
(grain / threshold).clamp(0.0, 1.0)
|
||||||
|
} else {
|
||||||
|
0.35 + 0.65 * grain
|
||||||
|
};
|
||||||
|
|
||||||
|
// use grain_alpha instead of `(0.35 + 0.65 * grain)`.
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2.3 Preset tuning (optional, verify visually)
|
||||||
|
|
||||||
|
If the new soft alpha makes dry-media strokes too dark or too dense, tune `hcie-dry-media-brushes/src/lib.rs`:
|
||||||
|
- Reduce `opacity` slightly for charcoal/pastel presets.
|
||||||
|
- Increase `spacing` a little for coarse charcoal presets.
|
||||||
|
|
||||||
|
Do **not** add jitter as the primary fix; jitter alone does not break the canvas-space hash pattern.
|
||||||
|
|
||||||
|
## Part 3: AGENTS.md Update
|
||||||
|
|
||||||
|
`AGENTS.md` currently describes the old egui-first architecture. Update it to match the actual project state.
|
||||||
|
|
||||||
|
### 3.1 Title & Mission
|
||||||
|
|
||||||
|
Change:
|
||||||
|
```markdown
|
||||||
|
# HCIE-Rust v4 — AI-Aware Architecture
|
||||||
|
```
|
||||||
|
to:
|
||||||
|
```markdown
|
||||||
|
# HCIE-Rust v3.05 — AI-Aware Architecture
|
||||||
|
```
|
||||||
|
|
||||||
|
Add a note:
|
||||||
|
```markdown
|
||||||
|
> Note: `v3.05` is the in-repo identifier for what was previously labelled "v4" in older documentation and paths. The egui workspace is no longer maintained; the active native GUI is the Iced workspace.
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 Layer 6 GUI section
|
||||||
|
|
||||||
|
Replace the existing Layer 6 table with:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
### Layer 6: GUI — open, agents work here
|
||||||
|
|
||||||
|
The active native GUI is the Iced workspace. The egui workspace exists for legacy reference but is not the current target.
|
||||||
|
|
||||||
|
| Crate / Project | Directory | Responsibility | Dependencies |
|
||||||
|
|-----------------|-----------|----------------|--------------|
|
||||||
|
| `hcie-iced-app` | `hcie-iced-app/` | Workspace root for the native Iced GUI. | `hcie-engine-api`, `hcie-iced-gui`, panels |
|
||||||
|
| `hcie-iced-gui` | `hcie-iced-app/crates/hcie-iced-gui/` | Main binary and application orchestration. | `hcie-engine-api`, panels |
|
||||||
|
| `iced-panel-adapter` | `hcie-iced-app/crates/iced-panel-adapter/` | Dynamic schema-to-widget UI binder. | `hcie-engine-api` |
|
||||||
|
| `iced-panel-ai-chat` | `hcie-iced-app/crates/iced-panel-ai-chat/` | AI chat panel. | `hcie-engine-api`, `hcie-ai` |
|
||||||
|
| `iced-panel-script` | `hcie-iced-app/crates/iced-panel-script/` | Scripting / automation panel. | `hcie-engine-api` |
|
||||||
|
| `hcie-dry-media-brushes` | `hcie-iced-app/crates/hcie-dry-media-brushes/` | Dry-media brush presets. | `hcie-engine-api` |
|
||||||
|
| `hcie-watercolor-brushes` | `hcie-iced-app/crates/hcie-watercolor-brushes/` | Watercolor brush presets + splat preview. | `hcie-engine-api` |
|
||||||
|
| `hcie-ink-brushes` | `hcie-iced-app/crates/hcie-ink-brushes/` | Ink brush presets. | `hcie-engine-api` |
|
||||||
|
| `hcie-paint-brushes` | `hcie-iced-app/crates/hcie-paint-brushes/` | Paint brush presets. | `hcie-engine-api` |
|
||||||
|
| `hcie-digital-brushes` | `hcie-iced-app/crates/hcie-digital-brushes/` | Digital/vector brush presets. | `hcie-engine-api` |
|
||||||
|
```
|
||||||
|
|
||||||
|
Keep the egui workspace mentioned in a short "Legacy / frozen" subsection so existing paths are not a surprise, but mark it clearly as not to be modified.
|
||||||
|
|
||||||
|
### 3.3 Build Order
|
||||||
|
|
||||||
|
Update Layer 6 line to:
|
||||||
|
```
|
||||||
|
Layer 6: hcie-iced-app / hcie-iced-gui
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.4 Performance Protection Notes
|
||||||
|
|
||||||
|
Replace egui-specific references with Iced equivalents:
|
||||||
|
- `hcie-egui-app/crates/hcie-gui-egui/src/app/mod.rs` → `hcie-iced-app/crates/hcie-iced-gui/src/app.rs`
|
||||||
|
- `hcie-egui-app/crates/hcie-gui-egui/src/canvas/mod.rs` → `hcie-iced-app/crates/hcie-iced-gui/src/canvas/mod.rs`
|
||||||
|
- Keep all wgpu/Shader/RefCell dirty bounds/Nearest-Neighbor protections as-is (they already describe the Iced path).
|
||||||
|
|
||||||
|
### 3.5 Screenshot & Visual Verification
|
||||||
|
|
||||||
|
Replace the egui screenshot section with the Iced CLI and F12 mechanisms documented in `hcie-iced-app/crates/hcie-iced-gui/src/cli.rs` and `screenshot.rs`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Full viewport
|
||||||
|
cargo run -p hcie-iced-gui -- --screenshot output.png
|
||||||
|
|
||||||
|
# Named panel
|
||||||
|
cargo run -p hcie-iced-gui -- --screenshot-panel "Brushes" output.png
|
||||||
|
```
|
||||||
|
|
||||||
|
Available panel names: inspect `src/screenshot.rs` / `src/dock/state.rs` for the exact name list.
|
||||||
|
|
||||||
|
### 3.6 Open Files List
|
||||||
|
|
||||||
|
Update the "Open Files" section to the iced paths:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
- `hcie-iced-app/crates/hcie-iced-gui/src/*.rs`
|
||||||
|
- `hcie-iced-app/crates/hcie-iced-gui/Cargo.toml`
|
||||||
|
- `hcie-iced-app/crates/iced-panel-adapter/src/*.rs`
|
||||||
|
- `hcie-iced-app/crates/iced-panel-ai-chat/src/*.rs`
|
||||||
|
- `hcie-iced-app/crates/iced-panel-script/src/*.rs`
|
||||||
|
- `hcie-iced-app/crates/hcie-dry-media-brushes/src/*.rs`
|
||||||
|
- `hcie-iced-app/crates/hcie-watercolor-brushes/src/*.rs`
|
||||||
|
- `hcie-iced-app/crates/hcie-ink-brushes/src/*.rs`
|
||||||
|
- `hcie-iced-app/crates/hcie-paint-brushes/src/*.rs`
|
||||||
|
- `hcie-iced-app/crates/hcie-digital-brushes/src/*.rs`
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.7 Important Notes
|
||||||
|
|
||||||
|
Update to:
|
||||||
|
- Never add an `egui` dependency to any engine crate.
|
||||||
|
- Never access `hcie-protocol` directly from GUI code.
|
||||||
|
- Never import a locked engine crate directly from GUI code; route everything through `hcie-engine-api`.
|
||||||
|
- The active GUI workspace is `hcie-iced-app/`. Treat `hcie-egui-app/` as read-only legacy reference.
|
||||||
|
|
||||||
|
## Part 4: Validation Steps
|
||||||
|
|
||||||
|
### 4.1 Compile
|
||||||
|
```bash
|
||||||
|
cargo check -p hcie-brush-engine
|
||||||
|
cargo check -p hcie-engine-api
|
||||||
|
cargo check -p hcie-iced-gui
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 Unit / regression tests
|
||||||
|
```bash
|
||||||
|
cargo test -p hcie-engine-api --test visual_regression
|
||||||
|
cargo test -p hcie-engine-api --test performance_stroke_4k -- --nocapture
|
||||||
|
cargo test -p hcie-dry-media-brushes
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.3 Visual verification (Iced)
|
||||||
|
1. Launch the Iced GUI.
|
||||||
|
2. Select a dry-media preset (e.g., Soft Pastel, Charcoal Pencil).
|
||||||
|
3. Draw several overlapping strokes on white canvas.
|
||||||
|
4. Confirm the checkerboard is gone and a soft paper-grain texture remains.
|
||||||
|
5. Compare with a round soft brush to ensure regular brushes are unchanged.
|
||||||
|
6. Capture before/after screenshots:
|
||||||
|
```bash
|
||||||
|
cargo run -p hcie-iced-gui -- --screenshot after_brushes.png
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.4 Lock crate after edits
|
||||||
|
Because `hcie-brush-engine` is a locked engine crate, unlock before editing and lock after:
|
||||||
|
```bash
|
||||||
|
./unlock.sh hcie-brush-engine
|
||||||
|
# ... apply source edits ...
|
||||||
|
cargo test -p hcie-engine-api --test visual_regression
|
||||||
|
./lock.sh hcie-brush-engine
|
||||||
|
```
|
||||||
|
|
||||||
|
## Risks & Rollback
|
||||||
|
|
||||||
|
- **Risk:** The alpha-modulation change may darken/lighten existing dry-media presets.
|
||||||
|
- *Mitigation:* Tune preset opacity/spacing after visual comparison.
|
||||||
|
- **Risk:** `visual_regression` golden hashes may change because brush pixel output changes.
|
||||||
|
- *Mitigation:* If the new output is visually correct, regenerate golden hashes with `HCIE_REGEN_GOLDENS=1 cargo test -p hcie-engine-api --test visual_regression -- --nocapture` and commit the updated hashes.
|
||||||
|
- **Risk:** `AGENTS.md` references to exact line numbers may drift.
|
||||||
|
- *Mitigation:* Replace line-number citations with file paths or function names where possible.
|
||||||
|
|
||||||
|
## Task Summary
|
||||||
|
|
||||||
|
1. ✅ Unlock `hcie-brush-engine` — file already writable (666), directory 555.
|
||||||
|
2. ✅ Edit `draw_grain_brush` — per-dab seed offset + soft alpha modulation applied.
|
||||||
|
3. ✅ Edit `draw_crayon_brush` — per-dab seed offset + soft alpha modulation applied.
|
||||||
|
4. ✅ Fix `generate_star_stamp` — changed from 5-pointed star to 4-pointed plus-cross.
|
||||||
|
5. ✅ Enhance `draw_star_brush` — added sparkle particle scatter around main dab.
|
||||||
|
6. ✅ Iced GUI: Renamed "Star" → "Star Sparkle", moved to Effects category.
|
||||||
|
7. ✅ Iced GUI: Updated `media_preset_category` to handle "Effects" category.
|
||||||
|
8. ✅ Egui GUI: Renamed "Stipple Dots" → "Star Sparkle" in styles grid.
|
||||||
|
9. ✅ Added "Star Sparkle" preset to `hcie-dry-media-brushes` crate (id: `star_sparkle`).
|
||||||
|
10. ✅ Updated preset count assertions (dry-media: 19→20, iced panel: 47→48).
|
||||||
|
11. ✅ `cargo check` passes for brush-engine, dry-media-brushes, iced-gui.
|
||||||
|
12. ✅ `cargo test -p hcie-dry-media-brushes` passes.
|
||||||
|
13. ⬜ Build and run `visual_regression` + `performance_stroke_4k` tests (needs full rebuild).
|
||||||
|
14. ⬜ Run Iced GUI, test affected presets, capture before/after screenshots.
|
||||||
|
15. ⬜ Update `AGENTS.md` to reflect v3.05 naming, the Iced workspace, and current open/locked boundaries.
|
||||||
|
16. ⬜ Lock `hcie-brush-engine` and `hcie-egui-app`.
|
||||||
@@ -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.
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
# HCIE-Rust v4 — AI-Aware Architecture
|
# HCIE-Rust v3.05 — AI-Aware Architecture
|
||||||
|
|
||||||
## Mission Statement
|
## Mission Statement
|
||||||
|
|
||||||
HCIE-Rust v4 is a pixel-grade image editor built around an atomic project split that prevents automated agents from corrupting the engine while still allowing them to iterate on the graphical user interface.
|
HCIE-Rust v3.05 is a pixel-grade image editor built around an atomic project split that prevents automated agents from corrupting the engine while still allowing them to iterate on the graphical user interface. The active native GUI is the **Iced** workspace (`hcie-iced-app/`). The `hcie-egui-app/` workspace is legacy/frozen — do not modify it for new features.
|
||||||
|
|
||||||
## Atomic Crate Split (23 crates / 6 layers)
|
## Atomic Crate Split (23 crates / 6 layers)
|
||||||
|
|
||||||
@@ -56,13 +56,16 @@ One GUI workspace exists:
|
|||||||
|
|
||||||
| Crate / Project | Directory | Responsibility | Dependencies |
|
| Crate / Project | Directory | Responsibility | Dependencies |
|
||||||
|-----------------|-----------|----------------|--------------|
|
|-----------------|-----------|----------------|--------------|
|
||||||
| `hcie-egui-app` | `hcie-egui-app/` | Workspace root for the native GUI. | `hcie-engine-api`, `eframe`, panels |
|
| `hcie-iced-app` | `hcie-iced-app/` | Workspace root for the native Iced GUI. | `hcie-engine-api`, `hcie-iced-gui`, panels |
|
||||||
| `hcie-gui-egui` | `hcie-egui-app/crates/hcie-gui-egui/` | Main binary and application orchestration. | `hcie-engine-api`, panels |
|
| `hcie-iced-gui` | `hcie-iced-app/crates/hcie-iced-gui/` | Main binary and application orchestration. | `hcie-engine-api`, panels |
|
||||||
| `egui-panel-adapter` | `hcie-egui-app/crates/egui-panel-adapter/` | Dynamic schema-to-widget UI binder. | `hcie-engine-api` |
|
| `iced-panel-adapter` | `hcie-iced-app/crates/iced-panel-adapter/` | Dynamic schema-to-widget UI binder. | `hcie-engine-api` |
|
||||||
| `egui-panel-filters` | `hcie-egui-app/crates/egui-panel-filters/` | Filter parameter panels. | `hcie-engine-api` |
|
| `iced-panel-ai-chat` | `hcie-iced-app/crates/iced-panel-ai-chat/` | AI chat panel. | `hcie-engine-api`, `hcie-ai` |
|
||||||
| `egui-panel-ai-chat` | `hcie-egui-app/crates/egui-panel-ai-chat/` | AI chat panel. | `hcie-engine-api`, `hcie-ai` |
|
| `iced-panel-script` | `hcie-iced-app/crates/iced-panel-script/` | Scripting / automation panel. | `hcie-engine-api` |
|
||||||
| `egui-panel-script` | `hcie-egui-app/crates/egui-panel-script/` | Scripting / automation panel. | `hcie-engine-api` |
|
| `hcie-dry-media-brushes` | `hcie-iced-app/crates/hcie-dry-media-brushes/` | Dry-media brush presets (graphite, charcoal, pastel). | `hcie-engine-api` |
|
||||||
| `egui-panel-ai-script` | `hcie-egui-app/crates/egui-panel-ai-script/` | AI-assisted script generation panel. | `hcie-engine-api`, `egui-panel-script` |
|
| `hcie-watercolor-brushes` | `hcie-iced-app/crates/hcie-watercolor-brushes/` | Watercolor brush presets. | `hcie-engine-api` |
|
||||||
|
| `hcie-ink-brushes` | `hcie-iced-app/crates/hcie-ink-brushes/` | Ink brush presets. | `hcie-engine-api` |
|
||||||
|
| `hcie-paint-brushes` | `hcie-iced-app/crates/hcie-paint-brushes/` | Paint brush presets. | `hcie-engine-api` |
|
||||||
|
| `hcie-digital-brushes` | `hcie-iced-app/crates/hcie-digital-brushes/` | Digital/vector brush presets. | `hcie-engine-api` |
|
||||||
|
|
||||||
## Critical Rules
|
## Critical Rules
|
||||||
|
|
||||||
@@ -82,7 +85,7 @@ Layer 2: hcie-blend, hcie-history, hcie-brush-engine, hcie-draw,
|
|||||||
Layer 3: hcie-document
|
Layer 3: hcie-document
|
||||||
Layer 4: hcie-engine-api (rlib + staticlib)
|
Layer 4: hcie-engine-api (rlib + staticlib)
|
||||||
Layer 5: hcie-ai, hcie-vision
|
Layer 5: hcie-ai, hcie-vision
|
||||||
Layer 6: hcie-egui-app / hcie-gui-egui
|
Layer 6: hcie-iced-app / hcie-iced-gui
|
||||||
```
|
```
|
||||||
|
|
||||||
## Performance Protection Notes (CRITICAL — Regression Prevention)
|
## Performance Protection Notes (CRITICAL — Regression Prevention)
|
||||||
@@ -126,13 +129,13 @@ If a new feature conflicts with one of these mechanisms, extend the existing API
|
|||||||
|
|
||||||
## GUI Workspace Architecture (CRITICAL — AI Search Scope)
|
## GUI Workspace Architecture (CRITICAL — AI Search Scope)
|
||||||
|
|
||||||
Only one GUI workspace exists: `hcie-egui-app/`. The GUI crates are `hcie-gui-egui/` and `egui-panel-*/`.
|
Only one GUI workspace exists: `hcie-iced-app/`. The GUI crates are `hcie-iced-gui/` and `iced-panel-*/`. The `hcie-egui-app/` workspace is legacy and frozen.
|
||||||
|
|
||||||
### AI Search Rule
|
### AI Search Rule
|
||||||
|
|
||||||
When searching for a crate, module, file, or symbol:
|
When searching for a crate, module, file, or symbol:
|
||||||
|
|
||||||
1. Scan the active GUI workspace first (`hcie-egui-app/`).
|
1. Scan the active GUI workspace first (`hcie-iced-app/`).
|
||||||
2. Then scan sibling engine crates (`../hcie-*/`).
|
2. Then scan sibling engine crates (`../hcie-*/`).
|
||||||
3. Follow the `Cargo.toml` dependency chain; if a dependency lives in another workspace, scan that workspace too.
|
3. Follow the `Cargo.toml` dependency chain; if a dependency lives in another workspace, scan that workspace too.
|
||||||
4. If a symbol name appears in two different modules (for example, `plugins/`), examine both.
|
4. If a symbol name appears in two different modules (for example, `plugins/`), examine both.
|
||||||
@@ -169,15 +172,18 @@ Use `unlock.sh <crate>` to temporarily open a crate, make the required change, t
|
|||||||
|
|
||||||
## Open Files (agents may write here)
|
## Open Files (agents may write here)
|
||||||
|
|
||||||
### Active egui GUI
|
### Active Iced GUI
|
||||||
|
|
||||||
- `hcie-egui-app/crates/hcie-gui-egui/src/*.rs`
|
- `hcie-iced-app/crates/hcie-iced-gui/src/*.rs`
|
||||||
- `hcie-egui-app/crates/hcie-gui-egui/Cargo.toml`
|
- `hcie-iced-app/crates/hcie-iced-gui/Cargo.toml`
|
||||||
- `hcie-egui-app/crates/egui-panel-adapter/src/*.rs`
|
- `hcie-iced-app/crates/iced-panel-adapter/src/*.rs`
|
||||||
- `hcie-egui-app/crates/egui-panel-filters/src/*.rs`
|
- `hcie-iced-app/crates/iced-panel-ai-chat/src/*.rs`
|
||||||
- `hcie-egui-app/crates/egui-panel-ai-chat/src/*.rs`
|
- `hcie-iced-app/crates/iced-panel-script/src/*.rs`
|
||||||
- `hcie-egui-app/crates/egui-panel-script/src/*.rs`
|
- `hcie-iced-app/crates/hcie-dry-media-brushes/src/*.rs`
|
||||||
- `hcie-egui-app/crates/egui-panel-ai-script/src/*.rs`
|
- `hcie-iced-app/crates/hcie-watercolor-brushes/src/*.rs`
|
||||||
|
- `hcie-iced-app/crates/hcie-ink-brushes/src/*.rs`
|
||||||
|
- `hcie-iced-app/crates/hcie-paint-brushes/src/*.rs`
|
||||||
|
- `hcie-iced-app/crates/hcie-digital-brushes/src/*.rs`
|
||||||
|
|
||||||
|
|
||||||
## Important Notes
|
## Important Notes
|
||||||
@@ -185,7 +191,7 @@ Use `unlock.sh <crate>` to temporarily open a crate, make the required change, t
|
|||||||
- Never add an `egui` dependency to any engine crate.
|
- Never add an `egui` dependency to any engine crate.
|
||||||
- Never access `hcie-protocol` directly from GUI code.
|
- Never access `hcie-protocol` directly from GUI code.
|
||||||
- Never import a locked engine crate directly from GUI code; route everything through `hcie-engine-api`.
|
- Never import a locked engine crate directly from GUI code; route everything through `hcie-engine-api`.
|
||||||
- The root `hcie-egui-app/Cargo.toml` currently lists `hcie-protocol`, `hcie-blend`, and `hcie-io` for legacy compatibility. Do not add new direct engine dependencies there.
|
- The active GUI workspace is `hcie-iced-app/`. Treat `hcie-egui-app/` as read-only legacy reference.
|
||||||
|
|
||||||
## Engine API — Public Surface
|
## Engine API — Public Surface
|
||||||
|
|
||||||
|
|||||||
+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,7 +3032,8 @@ impl HcieIcedApp {
|
|||||||
return Task::none();
|
return Task::none();
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(req_type) = tool.allowed_layer_type() {
|
if tool != Tool::CloneStamp {
|
||||||
|
if let Some(req_type) = tool.allowed_layer_type() {
|
||||||
// Reject raster edits on non-editable layers
|
// Reject raster edits on non-editable layers
|
||||||
if req_type == hcie_engine_api::LayerType::Raster
|
if req_type == hcie_engine_api::LayerType::Raster
|
||||||
&& !self.documents[self.active_doc]
|
&& !self.documents[self.active_doc]
|
||||||
@@ -2979,9 +3059,35 @@ 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,19 +1194,31 @@ 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());
|
||||||
.spacing(ITEM_SPACING)
|
panel_children.push(cyclic_speed_slider.into());
|
||||||
.padding(3);
|
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)
|
||||||
|
.padding(3);
|
||||||
|
|
||||||
container(panel)
|
container(panel)
|
||||||
.width(Length::Fill)
|
.width(Length::Fill)
|
||||||
@@ -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