Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| acd7de5dd3 | |||
| cea60143b1 | |||
| fd205309b6 | |||
| 19a7e51813 | |||
| 683dac06ec | |||
| 45a231a456 | |||
| eb17bf4205 | |||
| 1dabd9c31e | |||
| 1cc8a3f373 | |||
| 8563a44211 | |||
| 844fd4a026 |
@@ -1,6 +1,7 @@
|
||||
[env]
|
||||
PKG_CONFIG_PATH = "/tmp/dav1d-dev/usr/lib/x86_64-linux-gnu/pkgconfig"
|
||||
LIBRARY_PATH = "/tmp/dav1d-dev/usr/lib/x86_64-linux-gnu"
|
||||
CRITERION_HOME = { value = "criterion", relative = true }
|
||||
|
||||
[target.x86_64-unknown-linux-gnu]
|
||||
rustflags = ["-C", "link-arg=-fuse-ld=mold"]
|
||||
|
||||
@@ -51,6 +51,10 @@ Thumbs.db
|
||||
_images
|
||||
_tmp
|
||||
.history
|
||||
|
||||
# Criterion benchmark output (generated, gold_standard baseline kept manually)
|
||||
/criterion/
|
||||
|
||||
# Custom dev environment
|
||||
/.rustup/
|
||||
/logs/last_semantic_audit.txt
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
# Iced Brushes Panel — Compact Layout + Real-Time Preview
|
||||
|
||||
## Goal
|
||||
|
||||
Make the `hcie-iced-app` Brushes & Tips panel more compact and add a real-time brush-tip preview that reacts to current brush parameters and foreground color.
|
||||
|
||||
**Scope is limited to the Iced GUI layer.** Engine crates stay locked and untouched; all rendering goes through the public `hcie_engine_api` surface.
|
||||
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
- Active target workspace (per user override): `hcie-iced-app/`.
|
||||
- Primary file: `hcie-iced-app/crates/hcie-iced-gui/src/panels/brushes.rs`.
|
||||
- Panel wiring: `hcie-iced-app/crates/hcie-iced-gui/src/dock/view.rs` calls `crate::panels::brushes::view(...)`.
|
||||
- Current layout:
|
||||
- Category tabs with 16 px SVG icons.
|
||||
- Thumbnail image 48 px inside a 56 px cell.
|
||||
- Two-column grid with 4 px spacing.
|
||||
- Label text size 9 px.
|
||||
- A bottom "Preview" area using a static style-keyed 64 px image.
|
||||
- Available engine APIs (already public in `hcie_engine_api`):
|
||||
- `Engine::render_brush_stamp_preview(width, height, &BrushTip, color)` — fast single-dab preview.
|
||||
- `Engine::render_brush_preview(width, height, &BrushTip, color, &points)` — full stroke preview.
|
||||
|
||||
---
|
||||
|
||||
## Decisions
|
||||
|
||||
| Decision | Choice |
|
||||
|----------|--------|
|
||||
| Preview API | `Engine::render_brush_stamp_preview` (fast, parameter-aware). |
|
||||
| Thumbnail image size | 36 px. |
|
||||
| Thumbnail cell size | 40 px. |
|
||||
| Grid columns | 3. |
|
||||
| Label text | 8 px, kept. |
|
||||
| Grid/item spacing | 3 px. |
|
||||
| Tab icon size | Keep 16 px (already compact). |
|
||||
| Live preview color | Current foreground color `fg_color`. |
|
||||
| Cache key | `(style, size_byte, hardness_byte, opacity_byte, fg_color)` so the preview updates when parameters change. |
|
||||
| Watercolor preset thumbnails | Keep current splat renderer but shrink to 36 px image / 40 px cell and align with the new grid. |
|
||||
| Imported preset thumbnails | Keep list row style but shrink preview to 36 px image in 40 px cell. |
|
||||
|
||||
---
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
### 1. Add foreground color to panel signature
|
||||
|
||||
In `hcie-iced-app/crates/hcie-iced-gui/src/dock/view.rs`, pass `app.fg_color` into `brushes::view(...)`.
|
||||
|
||||
In `hcie-iced-app/crates/hcie-iced-gui/src/panels/brushes.rs`, extend the function signature:
|
||||
|
||||
```rust
|
||||
pub fn view(
|
||||
current_style: BrushStyle,
|
||||
current_size: f32,
|
||||
current_opacity: f32,
|
||||
current_hardness: f32,
|
||||
brush_color_variant: bool,
|
||||
brush_variant_amount: f32,
|
||||
active_category: BrushCategory,
|
||||
imported_presets: &[hcie_engine_api::BrushPreset],
|
||||
active_brush_preset: Option<&hcie_engine_api::BrushPreset>,
|
||||
fg_color: [u8; 4], // NEW
|
||||
colors: ThemeColors,
|
||||
) -> Element<'static, Message>
|
||||
```
|
||||
|
||||
### 2. Introduce compact layout constants
|
||||
|
||||
At the top of `brushes.rs`, replace/adjust constants:
|
||||
|
||||
```rust
|
||||
const THUMB_SIZE: u32 = 40;
|
||||
const PREVIEW_IMG_SIZE: u32 = 36;
|
||||
const GRID_COLUMNS: usize = 3;
|
||||
const GRID_SPACING: u16 = 3;
|
||||
const ITEM_SPACING: u16 = 3;
|
||||
const LABEL_SIZE: u16 = 8;
|
||||
```
|
||||
|
||||
Also add a real-time preview cache keyed by parameters:
|
||||
|
||||
```rust
|
||||
static LIVE_PREVIEW_CACHE: OnceLock<Mutex<HashMap<PreviewKey, iced::widget::image::Handle>>> = OnceLock::new();
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
struct PreviewKey {
|
||||
style_idx: usize,
|
||||
size: u8, // quantized to whole px
|
||||
hardness: u8, // 0..100
|
||||
opacity: u8, // 0..100
|
||||
r: u8,
|
||||
g: u8,
|
||||
b: u8,
|
||||
a: u8,
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Build a parameter-aware preview generator
|
||||
|
||||
Add a helper that constructs a `BrushTip` from the current tool state and calls `Engine::render_brush_stamp_preview`:
|
||||
|
||||
```rust
|
||||
fn build_tip_from_state(style: BrushStyle, size: f32, opacity: f32, hardness: f32) -> hcie_engine_api::BrushTip {
|
||||
hcie_engine_api::BrushTip {
|
||||
style,
|
||||
size,
|
||||
opacity,
|
||||
hardness,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Add:
|
||||
|
||||
```rust
|
||||
fn get_live_stamp_preview(
|
||||
style: BrushStyle,
|
||||
size: f32,
|
||||
opacity: f32,
|
||||
hardness: f32,
|
||||
fg_color: [u8; 4],
|
||||
) -> iced::widget::image::Handle {
|
||||
let key = PreviewKey {
|
||||
style_idx: style as usize,
|
||||
size: size.clamp(1.0, 255.0) as u8,
|
||||
hardness: (hardness * 100.0).clamp(0.0, 100.0) as u8,
|
||||
opacity: (opacity * 100.0).clamp(0.0, 100.0) as u8,
|
||||
r: fg_color[0],
|
||||
g: fg_color[1],
|
||||
b: fg_color[2],
|
||||
a: fg_color[3],
|
||||
};
|
||||
let cache = LIVE_PREVIEW_CACHE.get_or_init(|| Mutex::new(HashMap::new()));
|
||||
let mut cache = cache.lock().unwrap_or_else(|p| p.into_inner());
|
||||
if let Some(handle) = cache.get(&key) {
|
||||
return handle.clone();
|
||||
}
|
||||
let tip = build_tip_from_state(style, size, opacity, hardness);
|
||||
let pixels = hcie_engine_api::Engine::render_brush_stamp_preview(
|
||||
PREVIEW_IMG_SIZE,
|
||||
PREVIEW_IMG_SIZE,
|
||||
&tip,
|
||||
fg_color,
|
||||
);
|
||||
let handle = iced::widget::image::Handle::from_rgba(
|
||||
PREVIEW_IMG_SIZE,
|
||||
PREVIEW_IMG_SIZE,
|
||||
pixels,
|
||||
);
|
||||
cache.insert(key, handle.clone());
|
||||
handle
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Update existing thumbnail cache to new size
|
||||
|
||||
- Keep `BRUSH_PREVIEW_CACHE` for style-only previews.
|
||||
- Regenerate it with the new `THUMB_SIZE` (40 px). The existing sine-wave `generate_brush_preview` can stay, just adjust the drawing bounds to match.
|
||||
- Ensure the function returns `width=THUMB_SIZE, height=THUMB_SIZE` pixels.
|
||||
|
||||
### 5. Re-layout the grid
|
||||
|
||||
Change the grid construction from 2 columns to 3 columns using `GRID_COLUMNS`:
|
||||
|
||||
```rust
|
||||
let mut grid_rows = column![].spacing(GRID_SPACING);
|
||||
let mut current_row = row![].spacing(GRID_SPACING);
|
||||
|
||||
for (idx, preset) in filtered_styles.iter().enumerate() {
|
||||
// ... build 40 px cell with 36 px image + 8 px label ...
|
||||
current_row = current_row.push(clickable);
|
||||
if (idx + 1) % GRID_COLUMNS == 0 || idx == filtered_styles.len() - 1 {
|
||||
grid_rows = grid_rows.push(current_row);
|
||||
current_row = row![].spacing(GRID_SPACING);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Apply the same 3-column compact layout to:
|
||||
- Built-in styles grid.
|
||||
- Watercolor presets grid.
|
||||
- Media presets grid.
|
||||
|
||||
For imported presets, keep the list-row layout but set the preview image to 36 px and container to 40 px.
|
||||
|
||||
### 6. Replace the bottom static preview with the live preview
|
||||
|
||||
Locate the existing code:
|
||||
|
||||
```rust
|
||||
let preview_handle = get_brush_preview(current_style);
|
||||
let live_preview = container(iced::widget::image(preview_handle).width(64).height(64))
|
||||
...
|
||||
```
|
||||
|
||||
Replace with:
|
||||
|
||||
```rust
|
||||
let preview_handle = get_live_stamp_preview(
|
||||
current_style,
|
||||
current_size,
|
||||
current_opacity,
|
||||
current_hardness,
|
||||
fg_color,
|
||||
);
|
||||
let live_preview = container(iced::widget::image(preview_handle).width(PREVIEW_IMG_SIZE as f32).height(PREVIEW_IMG_SIZE as f32))
|
||||
.width(Length::Fill)
|
||||
.height(56)
|
||||
.center_x(Length::Fill)
|
||||
.center_y(Length::Fill)
|
||||
.style(move |_theme| styles::recessed_control(colors));
|
||||
```
|
||||
|
||||
Make the preview area slightly shorter (56 px instead of 80 px) to reclaim vertical space.
|
||||
|
||||
### 7. Tighten slider/controls spacing
|
||||
|
||||
- Reduce the column padding from `4` to `3`.
|
||||
- Keep sliders, but make the panel column spacing `2` or `3`.
|
||||
|
||||
---
|
||||
|
||||
## Affected Files
|
||||
|
||||
1. `hcie-iced-app/crates/hcie-iced-gui/src/panels/brushes.rs` — main panel layout and preview logic.
|
||||
2. `hcie-iced-app/crates/hcie-iced-gui/src/dock/view.rs` — pass `app.fg_color` to `brushes::view`.
|
||||
|
||||
No engine crates are modified. No `Cargo.toml` changes are needed because `hcie_engine_api` is already a dependency.
|
||||
|
||||
---
|
||||
|
||||
## Validation
|
||||
|
||||
1. `cargo check -p hcie-iced-gui` must pass.
|
||||
2. `cargo test -p hcie-iced-gui brushes` (or `cargo test -p hcie-iced-gui`) must pass; the existing tests in `brushes.rs` (`catalog_contains_every_engine_brush_style`, `category_filter_keeps_expected_groups`, `media_crates_expose_complete_unique_catalog`) must remain green.
|
||||
3. Launch the Iced GUI and open the Brushes & Tips panel. Visually verify:
|
||||
- 3-column grid of 40 px cells.
|
||||
- 8 px labels still readable.
|
||||
- Bottom preview area updates when size, opacity, hardness, or foreground color changes.
|
||||
- Selection border still highlights the active brush.
|
||||
4. If the Iced CLI screenshot mechanism works, capture the Brushes panel for before/after comparison.
|
||||
|
||||
---
|
||||
|
||||
## Risks
|
||||
|
||||
1. **Cache growth**: the parameter-keyed cache can grow quickly if size/opacity/hardness are continuous. Mitigation: quantize size to `u8`, hardness/opacity to 0..100, and fg color to full bytes; this bounds the space to ~256 * 101 * 101 * 256^4 possible keys but only populated entries consume memory.
|
||||
2. **Performance of `render_brush_stamp_preview`**: small single-dab previews (36x36 or 40x40) should be fast; if not, consider throttling updates to panel redraws only.
|
||||
3. **Iced layout overflow**: switching to 3 columns with 3 px spacing in a narrow pane may still wrap. If so, the pane minimum width in `dock/sizing.rs` may need a small bump (currently 160 px preferred 300 px). Test first; only bump if required.
|
||||
4. **Tests assume style-only cache**: the existing `BRUSH_PREVIEW_CACHE` tests do not inspect cache contents, so resizing should not break them.
|
||||
|
||||
---
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- EGUI (`hcie-egui-app`) changes — explicitly excluded by the user; it remains a reference only.
|
||||
- Engine-side brush rendering changes — engine crates are locked.
|
||||
- New brush engine presets or categories.
|
||||
- Bitmap/imported brush stamp preview fidelity beyond what `render_brush_stamp_preview` already provides.
|
||||
@@ -0,0 +1,149 @@
|
||||
# Fix Iced PlainSlider Visual Artifacts and Boundary Handling
|
||||
|
||||
## Goal
|
||||
Clean up the horizontal rendering artifacts on the Iced `PlainSlider` track and ensure the slider can be dragged to the absolute minimum (0 %) and maximum (100 %) values even when the mouse cursor moves slightly outside the widget bounds.
|
||||
|
||||
## Scope
|
||||
Only the Iced GUI slider implementation is in scope:
|
||||
- `hcie-iced-app/crates/hcie-iced-gui/src/widgets/plain_slider.rs`
|
||||
- Re-exported by `hcie-iced-app/crates/iced-panel-adapter/src/lib.rs`
|
||||
|
||||
The egui `PlainSlider` is explicitly out of scope (user chose option A).
|
||||
|
||||
## Root Cause Analysis
|
||||
|
||||
### 1. Horizontal lines / visual artifacts
|
||||
The value fill is currently drawn as 4 horizontal bands:
|
||||
```rust
|
||||
let bands = 4;
|
||||
for band in 0..bands {
|
||||
let t = band as f32 / (bands - 1) as f32;
|
||||
let mut fill = mix_color(...);
|
||||
frame.fill_rectangle(
|
||||
Point::new(0.0, bounds.height * band as f32 / bands as f32),
|
||||
Size::new(fill_width, bounds.height / bands as f32 + 0.5),
|
||||
fill,
|
||||
);
|
||||
}
|
||||
```
|
||||
The `+ 0.5` height overlap and sub-pixel band edges create visible horizontal seams/aliasing, especially on high-DPI displays or when the widget height is small (22 px). The vertical handle line drawn on top also adds to the perceived noise.
|
||||
|
||||
### 2. Cannot reach 0 % / 100 % with the mouse
|
||||
The current drag handler only processes `CursorMoved` while `local` (cursor position in bounds) is `Some`:
|
||||
```rust
|
||||
canvas::Event::Mouse(mouse::Event::CursorMoved { .. }) if state.dragging => {
|
||||
if let Some(position) = local {
|
||||
let value = self.value_at(position.x, bounds.width);
|
||||
...
|
||||
}
|
||||
(canvas::event::Status::Captured, None)
|
||||
}
|
||||
```
|
||||
When the user drags past the left or right edge of the slider, Iced stops reporting a local position, so the value is never clamped to the endpoint. The slider gets stuck just short of 0 % or 100 %.
|
||||
|
||||
In addition, `value_at` normalizes against `track_width = width - SPINNER_WIDTH`, so the effective draggable area is narrower than the full widget. The rightmost spinner region is reserved for step buttons, but the boundary between track and spinner is sensitive to small mouse movements.
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
### Change A: Remove band-based fill artifact
|
||||
Replace the 4-band gradient loop with a single rounded rectangle fill.
|
||||
|
||||
Suggested draw code:
|
||||
```rust
|
||||
let track_width = (bounds.width - SPINNER_WIDTH).max(0.0);
|
||||
let fill_width = track_width * self.fraction();
|
||||
if fill_width > 0.0 {
|
||||
let fill = self.colors.accent;
|
||||
let fill_rect = Path::rounded_rectangle(
|
||||
Point::new(0.0, 0.0),
|
||||
Size::new(fill_width, bounds.height),
|
||||
radius,
|
||||
);
|
||||
frame.fill(&fill_rect, fill);
|
||||
}
|
||||
```
|
||||
This eliminates:
|
||||
- The `+ 0.5` band overlap
|
||||
- Sub-pixel horizontal edges
|
||||
- The separate vertical handle line (the filled rounded rectangle itself acts as the value indicator)
|
||||
|
||||
If a gradient is still desired, use `canvas::Gradient` or a single linear gradient path instead of multiple overlapping rectangles.
|
||||
|
||||
### Change B: Clamp to endpoints when dragging outside bounds
|
||||
Use the global cursor position during an active drag so the slider value can continue updating even when the cursor leaves the widget.
|
||||
|
||||
In `update`:
|
||||
1. Store `dragging` state as already done.
|
||||
2. In the `CursorMoved` branch, if `state.dragging`:
|
||||
- If `local` is `Some`, use `position.x`.
|
||||
- If `local` is `None`, fall back to `cursor.position()` (global) and convert it to widget-local X by subtracting `bounds.x`.
|
||||
- Clamp the resulting X to `[0.0, track_width]`.
|
||||
- Compute the value with `value_at` and emit `on_change`.
|
||||
|
||||
Suggested helper:
|
||||
```rust
|
||||
fn drag_value(&self, cursor: mouse::Cursor, bounds: Rectangle) -> f32 {
|
||||
let track_width = (bounds.width - SPINNER_WIDTH).max(1.0);
|
||||
let x = match cursor.position_in(bounds) {
|
||||
Some(local) => local.x,
|
||||
None => cursor.position().map_or(0.0, |p| p.x - bounds.x),
|
||||
}
|
||||
.clamp(0.0, track_width);
|
||||
self.value_at(x, bounds.width)
|
||||
}
|
||||
```
|
||||
|
||||
This ensures:
|
||||
- Dragging left of the widget clamps to `range.start()` (0 %).
|
||||
- Dragging right of the widget clamps to `range.end()` (100 %).
|
||||
- Normal in-bounds dragging still works exactly as before.
|
||||
|
||||
### Change C: Preserve spinner step behavior while improving boundary feel
|
||||
Do **not** remove the spinner buttons. Keep the existing step-button logic in `ButtonPressed`:
|
||||
```rust
|
||||
if position.x >= bounds.width - SPINNER_WIDTH {
|
||||
// up/down step
|
||||
}
|
||||
```
|
||||
The boundary fix in Change B applies only during active drags. A click in the spinner area still performs a single step; a drag that started in the track area can now overshoot the track/spinner boundary and clamp correctly.
|
||||
|
||||
### Change D: Update or add unit tests
|
||||
The existing test `pointer_mapping_respects_endpoints_and_step` already covers in-bounds endpoint mapping. Extend it with out-of-bounds cases:
|
||||
|
||||
```rust
|
||||
#[test]
|
||||
fn pointer_mapping_clamps_outside_track() {
|
||||
let slider = test_slider("");
|
||||
assert_eq!(slider.value_at(-10.0, 124.0), 0.0);
|
||||
assert_eq!(slider.value_at(120.0, 124.0), 1.0);
|
||||
}
|
||||
```
|
||||
|
||||
Add a test for the global-to-local conversion logic if it is extracted into a testable helper.
|
||||
|
||||
## Validation Steps
|
||||
1. Run the existing widget tests:
|
||||
```bash
|
||||
cargo test -p hcie-iced-gui widgets::plain_slider
|
||||
```
|
||||
2. Build the iced GUI crate:
|
||||
```bash
|
||||
cargo check -p hcie-iced-gui
|
||||
cargo check -p iced-panel-adapter
|
||||
```
|
||||
3. Manual visual check (if running the app):
|
||||
- Open a panel with a slider such as Opacity / Alpha.
|
||||
- Verify no horizontal streaks across the slider fill.
|
||||
- Drag the mouse left past the widget edge: value should snap to 0 %.
|
||||
- Drag the mouse right past the widget edge: value should snap to 100 %.
|
||||
- Verify spinner arrows still increment/decrement the value by one step.
|
||||
|
||||
## Risks and Mitigations
|
||||
| Risk | Mitigation |
|
||||
|------|------------|
|
||||
| Removing the gradient makes the slider look too flat | Use a single `canvas::Gradient` linear fill if the design requires a gradient; do not reintroduce overlapping bands. |
|
||||
| Global cursor fallback reports incorrect position on multi-window / scaled displays | Iced's `cursor.position()` is in logical window coordinates; subtracting `bounds.x` (also logical) is safe. Test on the target platform. |
|
||||
| Spinner clicks accidentally interpreted as drags | The `ButtonPressed` branch already returns `Captured` immediately for spinner clicks. Drags are only detected via `CursorMoved` after a non-spinner press, so the distinction remains. |
|
||||
|
||||
## Open Questions
|
||||
None — the user confirmed Iced-only scope (option A) and the design direction is straightforward.
|
||||
@@ -0,0 +1,189 @@
|
||||
# Plan: Relocate Criterion Results + Optimize to 2× gold_standard
|
||||
|
||||
## Context
|
||||
|
||||
Criterion benchmarks exist under `target/criterion/` with gold-standard baselines
|
||||
already saved. The `criterion-test` branch introduced benchmarks; the current
|
||||
`performance-optimization` branch added `bench = false` and reverted `blend_mode`.
|
||||
|
||||
**Current gold_standard timings (mean):**
|
||||
|
||||
| Benchmark | ns (mean) | ~seconds |
|
||||
|---|---|---|
|
||||
| `below_cache_reuse/first_stroke_cache_build` | 807,729,115 | 0.81 s |
|
||||
| `below_cache_reuse/second_stroke_cache_reuse` | 14,573,274 | 0.015 s |
|
||||
| `composite_scratch_pooling/cold_composite` | 1,048,196,885 | 1.05 s |
|
||||
| `composite_scratch_pooling/warm_composite` | 10,073,839 | 0.010 s |
|
||||
| `effects_skip/no_effects_composite` | 14,340,411 | 0.014 s |
|
||||
| `effects_skip/with_one_dropshadow` | 14,780,582 | 0.015 s |
|
||||
| `stroke_mask_pooling/cold_first_stroke` | 46,427,571 | 0.046 s |
|
||||
| `stroke_mask_pooling/warm_10th_stroke` | 16,749,902 | 0.017 s |
|
||||
|
||||
Dominant costs: **first_stroke_cache_build** (0.81 s) and **cold_composite** (1.05 s).
|
||||
|
||||
**Target:** 2× faster on cold/first-stroke paths; zero regressions on warm paths.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 — Relocate `target/criterion` to project root
|
||||
|
||||
### 1.1: Move the results directory
|
||||
```bash
|
||||
mv target/criterion ./criterion
|
||||
```
|
||||
|
||||
### 1.2: Update `.gitignore`
|
||||
Add `!/criterion` exception. The current rule `/target/` won't affect root-level `criterion/` but `/target/` plus `**/target/` should be reviewed. Actually `/target/` only matches the root `target/` dir, and `**/target/` matches nested ones, so `criterion/` at root is fine. No `.gitignore` change needed.
|
||||
|
||||
### 1.3: Configure criterion output directory
|
||||
Criterion 0.5 uses `CRITERION_HOME` env var (defaults to `target/criterion`). After move, either:
|
||||
|
||||
**Option A (preferred):** Set `CRITERION_HOME` via `.cargo/config.toml`:
|
||||
```toml
|
||||
[env]
|
||||
CRITERION_HOME = "criterion"
|
||||
```
|
||||
|
||||
**Option B:** Export before benching:
|
||||
```bash
|
||||
CRITERION_HOME=criterion cargo bench -p hcie-engine-api
|
||||
```
|
||||
|
||||
### 1.4: Update `criterion.md`
|
||||
Replace `./target/criterion/report/index.html` → `./criterion/report/index.html`.
|
||||
|
||||
### 1.5: Update `notlar.txt`
|
||||
Update the bench command section to reflect `CRITERION_HOME=criterion` usage.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 — Fix benchmark correctness
|
||||
|
||||
### 2.1: Remove `bench = false` from `hcie-engine-api/Cargo.toml`
|
||||
Line 37: `bench = false` must be removed for `[[bench]]` targets to work correctly.
|
||||
This was a workaround from commit `ee6ad2d`.
|
||||
|
||||
### 2.2: Verify `effects_skip.rs` blend_mode
|
||||
Current `"Normal".to_string()` matches `LayerStyle::DropShadow { blend_mode: String }` — correct. The `criterion-test` branch had `hcie_engine_api::BlendMode::Normal` which is a type mismatch. No change needed.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3 — Optimization loop targeting 2× on cold paths
|
||||
|
||||
**Prerequisite:** Engine crates are locked (`chmod 444`). Use `./unlock.sh <crate>` to modify, `./lock.sh <crate>` after.
|
||||
|
||||
### 3.0: Save current state as gold_standard baseline
|
||||
```bash
|
||||
cargo bench -p hcie-engine-api -- --save-baseline gold_standard
|
||||
```
|
||||
|
||||
### 3.1: Optimize `below_cache` construction (first_stroke_cache_build: 0.81 s → ≤0.40 s)
|
||||
|
||||
**Unlock:** `./unlock.sh hcie-engine-api`
|
||||
**File:** `hcie-engine-api/src/stroke_cache.rs:513-608` → `rebuild_below_cache_if_needed()`
|
||||
|
||||
The function calls `tiled::composite_tiled_into()` for the full canvas (3840×2160). Optimization candidates:
|
||||
|
||||
1. **Parallelize the below-cache tile build loop** (line 547-561). Currently iterates layers sequentially to build tiles. Use `par_iter` for tile construction.
|
||||
|
||||
2. **Skip fully invisible layers in below-cache.** Check `tl.visible` before compositing.
|
||||
|
||||
3. **Pre-fill with topmost opaque Normal-blend layer.** Walk bottom→top, find the first fully opaque Normal-blend layer, fill everything below it at once.
|
||||
|
||||
4. **Reduce zero-fill cost.** The 33 MB `cache.fill(0)` at line 564 is sequential. Consider `unsafe { std::ptr::write_bytes }` for SIMD-accelerated zeroing.
|
||||
|
||||
**Lock:** `./lock.sh hcie-engine-api`
|
||||
|
||||
### 3.2: Optimize cold composite (cold_composite: 1.05 s → ≤0.52 s)
|
||||
|
||||
**Unlock:** `./unlock.sh hcie-engine-api`
|
||||
**File:** `hcie-engine-api/src/partial_composite.rs:90-228` → `render_composite_region()` cold path (lines 194-222)
|
||||
|
||||
The cold path zeroes the scratch buffer then composites all layers. Optimization candidates:
|
||||
|
||||
1. **Parallel scratch buffer zeroing.** Replace row-by-row `buf[start..end].fill(0)` with `par_chunks_exact_mut`.
|
||||
|
||||
2. **Merge zero-fill with first-layer composite.** Instead of fill(0) then composite, start the first layer's composite directly (it overwrites anyway if using Normal blend).
|
||||
|
||||
3. **Skip tile sync for unchanged layers.** In `apply_effects_and_sync_tiles()`, the `sync_dirty_tiles()` re-tiles every dirty layer even if pixels haven't changed since last sync.
|
||||
|
||||
4. **Cache the tile compositing results.** If the same layer stack was composited before, reuse intermediate results.
|
||||
|
||||
**Lock:** `./lock.sh hcie-engine-api`
|
||||
|
||||
### 3.3: Optimize tiled compositing inner loop
|
||||
|
||||
**Unlock:** `./unlock.sh hcie-composite`
|
||||
**File:** `hcie-composite/src/tiled.rs`
|
||||
|
||||
The `composite_tiled_into()` function handles small-region vs full-canvas compositing. The AGENTS.md note says small regions use sequential, large regions use `par_chunks_exact_mut`. Verify the full-canvas path (below-cache build and cold composite) actually uses parallel compositing.
|
||||
|
||||
Optimization candidates:
|
||||
1. **Ensure `par_chunks_exact_mut` is used for full-canvas compositing** in tiled.rs.
|
||||
2. **Reduce per-tile overhead.** Check if tile lookups, bounds checks, or branching in the hot loop can be eliminated.
|
||||
|
||||
**Lock:** `./lock.sh hcie-composite`
|
||||
|
||||
### 3.4: Validate after each optimization
|
||||
|
||||
After EACH change:
|
||||
```bash
|
||||
# Pixel-perfect correctness (MUST pass)
|
||||
cargo test -p hcie-engine-api --test visual_regression
|
||||
|
||||
# Regression check vs gold_standard (MUST show improvement, no regressions)
|
||||
cargo bench -p hcie-engine-api -- --baseline gold_standard
|
||||
|
||||
# Performance regression test
|
||||
cargo test -p hcie-engine-api --test performance_stroke_4k -- --nocapture
|
||||
```
|
||||
|
||||
### 3.5: Re-save gold_standard after achieving target
|
||||
```bash
|
||||
cargo bench -p hcie-engine-api -- --save-baseline gold_standard
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 4 — Re-lock all engine crates
|
||||
```bash
|
||||
./lock.sh hcie-engine-api
|
||||
./lock.sh hcie-composite
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Files Modified
|
||||
|
||||
| File | Change |
|
||||
|---|---|
|
||||
| `target/criterion/` → `./criterion/` | Directory move |
|
||||
| `.cargo/config.toml` | Add `CRITERION_HOME = "criterion"` |
|
||||
| `criterion.md` | Update output path |
|
||||
| `notlar.txt` | Update bench instructions |
|
||||
| `hcie-engine-api/Cargo.toml` | Remove `bench = false` |
|
||||
| `hcie-engine-api/src/stroke_cache.rs` | below_cache optimization |
|
||||
| `hcie-engine-api/src/partial_composite.rs` | Cold composite optimization |
|
||||
| `hcie-composite/src/tiled.rs` | Tiled compositing optimization |
|
||||
|
||||
---
|
||||
|
||||
## Risks
|
||||
|
||||
| Risk | Mitigation |
|
||||
|---|---|
|
||||
| `CRITERION_HOME` doesn't work with criterion 0.5 | Fallback: use `--output-dir criterion` flag or `.cargo/config.toml` `[env]` section |
|
||||
| Engine optimizations break pixel-perfect rendering | Run `visual_regression` test after every change |
|
||||
| Changes to hot path break AGENTS.md-protected mechanisms | Verify all protected mechanisms still pass; no changes to pooling logic correctness |
|
||||
| 2× target not achievable with these optimizations | Profile further; consider SIMD, parallelism in deeper layers |
|
||||
|
||||
---
|
||||
|
||||
## Validation Commands
|
||||
|
||||
```bash
|
||||
cargo bench -p hcie-engine-api -- --baseline gold_standard
|
||||
cargo test -p hcie-engine-api --test visual_regression
|
||||
cargo test -p hcie-engine-api --test performance_stroke_4k -- --nocapture
|
||||
cargo bench -p hcie-engine-api --no-run
|
||||
```
|
||||
@@ -0,0 +1,218 @@
|
||||
# Criterion Merge & 2x Optimization Plan
|
||||
|
||||
## Context
|
||||
|
||||
Two criterion output directories exist:
|
||||
- **Root `criterion/`** — has HTML report (`report/index.html`) + all 8 `gold_standard` baseline variants
|
||||
- **`hcie-engine-api/criterion/`** — partial duplicate (4 benchmark dirs, no report, no gold_standard)
|
||||
|
||||
Root cause: each benchmark file hardcodes `Criterion::default().output_directory(Path::new("criterion"))` — a relative path that resolves differently depending on CWD. No `.cargo/config.toml` exists to set `CRITERION_HOME`.
|
||||
|
||||
Gold standard timings (from root `criterion/`):
|
||||
|
||||
| Benchmark | Mean | Dominated by |
|
||||
|-----------|------|--------------|
|
||||
| `below_cache_reuse/first_stroke_cache_build` | 0.81 s | Full 9-layer below-cache composite |
|
||||
| `below_cache_reuse/second_stroke_cache_reuse` | 0.015 s | Cache hit — fast |
|
||||
| `composite_scratch_pooling/cold_composite` | 1.05 s | Full 10-layer composite + scratch alloc |
|
||||
| `composite_scratch_pooling/warm_composite` | 0.010 s | Pooled scratch — fast |
|
||||
| `effects_skip/no_effects_composite` | 0.014 s | Effects skipped — fast |
|
||||
| `effects_skip/with_one_dropshadow` | 0.015 s | Single effect — fast |
|
||||
| `stroke_mask_pooling/cold_first_stroke` | 0.046 s | Mask alloc + first stroke |
|
||||
| `stroke_mask_pooling/warm_10th_stroke` | 0.017 s | Pooled mask — fast |
|
||||
|
||||
## Goal
|
||||
|
||||
1. Merge criterion directories into single root `criterion/`
|
||||
2. Fix output path configuration so benchmarks always write to the same place
|
||||
3. Achieve 2x speedup across all 8 benchmark variants vs `gold_standard` baseline
|
||||
4. Update `notlar.txt` and `criterion.md` with correct paths
|
||||
|
||||
## Task List
|
||||
|
||||
### Phase 1: Directory Merge & Path Fix (config-only, no engine changes)
|
||||
|
||||
**1.1** Delete `hcie-engine-api/criterion/` directory entirely
|
||||
```bash
|
||||
rm -rf hcie-engine-api/criterion/
|
||||
```
|
||||
|
||||
**1.2** Create `.cargo/config.toml` at workspace root:
|
||||
```toml
|
||||
[env]
|
||||
CRITERION_HOME = { value = "criterion", relative = true }
|
||||
```
|
||||
This ensures all `cargo bench` invocations write to `./criterion/` relative to workspace root, regardless of CWD.
|
||||
|
||||
**1.3** Remove hardcoded `output_directory` from all 4 benchmark files. Change:
|
||||
```rust
|
||||
criterion_group!{
|
||||
name = benches;
|
||||
config = Criterion::default().output_directory(std::path::Path::new("criterion"));
|
||||
targets = bench_xxx
|
||||
}
|
||||
```
|
||||
To:
|
||||
```rust
|
||||
criterion_group!(benches, bench_xxx);
|
||||
criterion_main!(benches);
|
||||
```
|
||||
Files to edit:
|
||||
- `hcie-engine-api/benches/stroke_mask_pooling.rs:119-124`
|
||||
- `hcie-engine-api/benches/composite_scratch_pooling.rs:145-150`
|
||||
- `hcie-engine-api/benches/below_cache_reuse.rs:145-150`
|
||||
- `hcie-engine-api/benches/effects_skip.rs:165-170`
|
||||
|
||||
**1.4** Remove `bench = false` from `hcie-engine-api/Cargo.toml` `[lib]` section (line 37). This was a workaround; with explicit `[[bench]]` targets it's not needed.
|
||||
|
||||
**1.5** Update `.gitignore` — add `criterion/` to prevent benchmark output from being committed (the gold_standard baseline should be kept but the report and run data are generated).
|
||||
|
||||
**1.6** Update `notlar.txt`:
|
||||
- Fix line 19: `CRITERION_HOME=criterion` → reference `.cargo/config.toml`
|
||||
- Fix line 20: `./criterion/report/index.html` stays correct (now guaranteed by config.toml)
|
||||
- Add note about `.cargo/config.toml` setting `CRITERION_HOME`
|
||||
|
||||
**1.7** Update `criterion.md`:
|
||||
- Fix line 40: `./criterion/report/index.html` stays correct
|
||||
- Add note about `.cargo/config.toml` configuration
|
||||
|
||||
**1.8** Verify: run `cargo bench -p hcie-engine-api` and confirm output goes to root `criterion/` only.
|
||||
|
||||
### Phase 2: Save Fresh Baseline & Verify
|
||||
|
||||
**2.1** Save a fresh `gold_standard` baseline after path fixes:
|
||||
```bash
|
||||
cargo bench -p hcie-engine-api -- --save-baseline gold_standard
|
||||
```
|
||||
|
||||
**2.2** Run regression comparison to confirm baselines match:
|
||||
```bash
|
||||
cargo bench -p hcie-engine-api -- --baseline gold_standard
|
||||
```
|
||||
|
||||
**2.3** Run visual regression and performance tests to confirm no breakage:
|
||||
```bash
|
||||
cargo test -p hcie-engine-api --test visual_regression
|
||||
cargo test -p hcie-engine-api --test performance_stroke_4k -- --nocapture
|
||||
```
|
||||
|
||||
### Phase 3: Optimization Loop
|
||||
|
||||
For each optimization, follow this cycle:
|
||||
1. Unlock target crate: `./unlock.sh <crate>`
|
||||
2. Apply optimization
|
||||
3. Build: `cargo build -p hcie-engine-api`
|
||||
4. Run benchmarks: `cargo bench -p hcie-engine-api -- --baseline gold_standard`
|
||||
5. Check for regressions: `cargo test -p hcie-engine-api --test visual_regression`
|
||||
6. If improvement < 2x, continue to next optimization
|
||||
7. Lock crate: `./lock.sh <crate>`
|
||||
|
||||
**Optimization targets (in priority order):**
|
||||
|
||||
#### 3.1 Pre-allocate pooled buffers in `Engine::new()` (hcie-engine-api)
|
||||
|
||||
Currently `composite_scratch`, `active_stroke_mask`, `below_cache`, and `stroke_before_buf` are all `None` at construction and allocated lazily on first use. The cold benchmarks pay this allocation cost.
|
||||
|
||||
Change `Engine::new_with_options()` in `hcie-engine-api/src/lib.rs` to pre-allocate:
|
||||
- `composite_scratch = Some(vec![0u8; w * h * 4])` — ~33MB
|
||||
- `active_stroke_mask = Some(vec![0u8; w * h])` — ~8MB
|
||||
- `below_cache = Some(vec![0u8; w * h * 4])` — ~33MB
|
||||
- `stroke_before_buf = Some(vec![0u8; w * h * 4])` — ~33MB
|
||||
|
||||
This moves allocation cost from first-stroke time to engine-creation time (which is excluded from benchmark measurement via `iter_with_setup`). Expected impact: cold benchmarks drop significantly because they no longer include allocation.
|
||||
|
||||
#### 3.2 Opaque-layer early-exit in composite (hcie-composite, hcie-engine-api)
|
||||
|
||||
In `composite_tiled_into` (`hcie-composite/src/tiled.rs`), when compositing layers bottom-to-top, if a layer is:
|
||||
- Fully opaque (alpha=255 everywhere in the dirty region)
|
||||
- Normal blend mode
|
||||
- 100% opacity
|
||||
|
||||
...then all layers below it are completely occluded and can be skipped.
|
||||
|
||||
Implementation: Before compositing each layer, check if the output buffer is already fully opaque in the dirty region. If so, skip remaining below-layers. This is most impactful for the `first_stroke_cache_build` and `cold_composite` benchmarks where all 10 layers are filled with opaque colors.
|
||||
|
||||
#### 3.3 `copy_from_slice` fast-path for opaque Normal layers (hcie-composite)
|
||||
|
||||
In the per-pixel blend loop, add a fast-path: if the source pixel is fully opaque (alpha=255) and blend mode is Normal, use `copy_from_slice` for the entire row instead of per-pixel blending. This avoids the blend math for the common case of opaque layers.
|
||||
|
||||
#### 3.4 Reduce `Vec::clone()` in hot paths (hcie-engine-api)
|
||||
|
||||
In `draw_filled_rect_rgba` (`stroke_brush.rs:582`): `layer.pixels.clone()` creates a full ~33MB copy for undo. Replace with `copy_from_slice` into a pooled buffer.
|
||||
|
||||
In `begin_stroke` (`stroke_cache.rs:200-208`): `layer.effects.clone()` and `layer.styles.clone()` are small but unnecessary — effects/styles are typically empty. Add an early-return if both are empty.
|
||||
|
||||
#### 3.5 Parallelize `rebuild_below_cache_if_needed` (hcie-engine-api)
|
||||
|
||||
The below-cache rebuild in `stroke_cache.rs:514-611` composites all layers below the active one. This is currently sequential. Use Rayon to parallelize the tile compositing within this function, similar to how `render_composite_region` uses `par_chunks_mut`.
|
||||
|
||||
#### 3.6 SIMD blend operations (hcie-blend, if needed)
|
||||
|
||||
If the above optimizations don't reach 2x, explore SIMD-accelerated blend operations in `hcie-blend`. The `blend_pixel` function is called millions of times per composite. Using `std::simd` or explicit SSE/AVX intrinsics could yield 4-8x speedup on blend operations.
|
||||
|
||||
### Phase 4: Final Verification
|
||||
|
||||
**4.1** Run full benchmark suite and compare against original gold_standard:
|
||||
```bash
|
||||
cargo bench -p hcie-engine-api -- --baseline gold_standard
|
||||
```
|
||||
|
||||
**4.2** Run visual regression tests:
|
||||
```bash
|
||||
cargo test -p hcie-engine-api --test visual_regression
|
||||
cargo test -p hcie-engine-api --test performance_stroke_4k -- --nocapture
|
||||
```
|
||||
|
||||
**4.3** Save final optimized baseline:
|
||||
```bash
|
||||
cargo bench -p hcie-engine-api -- --save-baseline gold_standard
|
||||
```
|
||||
|
||||
**4.4** Update `notlar.txt` with final benchmark results.
|
||||
|
||||
## Files to Modify
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `.cargo/config.toml` | **Create** — set `CRITERION_HOME` |
|
||||
| `.gitignore` | Add `criterion/` entry |
|
||||
| `hcie-engine-api/Cargo.toml` | Remove `bench = false` from `[lib]` |
|
||||
| `hcie-engine-api/benches/stroke_mask_pooling.rs` | Remove `output_directory`, simplify `criterion_group!` |
|
||||
| `hcie-engine-api/benches/composite_scratch_pooling.rs` | Same |
|
||||
| `hcie-engine-api/benches/below_cache_reuse.rs` | Same |
|
||||
| `hcie-engine-api/benches/effects_skip.rs` | Same |
|
||||
| `notlar.txt` | Update paths and commands |
|
||||
| `criterion.md` | Update paths |
|
||||
| `hcie-engine-api/src/lib.rs` | Pre-allocate buffers in `new_with_options()` |
|
||||
| `hcie-engine-api/src/stroke_cache.rs` | Optimize `begin_stroke`, `rebuild_below_cache_if_needed` |
|
||||
| `hcie-engine-api/src/stroke_brush.rs` | Optimize `draw_filled_rect_rgba` clone |
|
||||
| `hcie-composite/src/tiled.rs` | Opaque-layer skip, `copy_from_slice` fast-path |
|
||||
|
||||
## Files to Delete
|
||||
|
||||
| Path | Reason |
|
||||
|------|--------|
|
||||
| `hcie-engine-api/criterion/` | Duplicate — root `criterion/` is canonical |
|
||||
|
||||
## Locked Crates That Need Unlocking
|
||||
|
||||
| Crate | Reason |
|
||||
|-------|--------|
|
||||
| `hcie-engine-api` | Pre-allocation, hot-path optimizations |
|
||||
| `hcie-composite` | Opaque-layer skip, copy_from_slice fast-path |
|
||||
| `hcie-blend` | SIMD blend (only if Phase 3.1-3.5 insufficient) |
|
||||
|
||||
## Validation
|
||||
|
||||
After each phase:
|
||||
- `cargo build -p hcie-engine-api` must succeed
|
||||
- `cargo bench -p hcie-engine-api` must produce output in root `criterion/` only
|
||||
- `cargo test -p hcie-engine-api --test visual_regression` must pass (8/8)
|
||||
- `cargo test -p hcie-engine-api --test performance_stroke_4k -- --nocapture` must not regress
|
||||
- `cargo bench -p hcie-engine-api -- --baseline gold_standard` must show improvement (not regression)
|
||||
|
||||
## Risks
|
||||
|
||||
- **Pre-allocation increases engine memory footprint** by ~107MB (33+8+33+33). This is acceptable for a 4K image editor where the document itself is already ~33MB per layer.
|
||||
- **Opaque-layer skip changes composite behavior** if a layer has non-Normal blend mode but appears opaque. The check must verify both alpha=255 AND blend mode=Normal.
|
||||
- **SIMD requires `std::simd` (unstable)** or explicit intrinsics with `cfg(target_feature)` guards. Prefer portable approaches first.
|
||||
@@ -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`.
|
||||
@@ -1,8 +1,8 @@
|
||||
# HCIE-Rust v4 — AI-Aware Architecture
|
||||
# HCIE-Rust v3.05 — AI-Aware Architecture
|
||||
|
||||
## 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)
|
||||
|
||||
@@ -56,13 +56,16 @@ One GUI workspace exists:
|
||||
|
||||
| Crate / Project | Directory | Responsibility | Dependencies |
|
||||
|-----------------|-----------|----------------|--------------|
|
||||
| `hcie-egui-app` | `hcie-egui-app/` | Workspace root for the native GUI. | `hcie-engine-api`, `eframe`, panels |
|
||||
| `hcie-gui-egui` | `hcie-egui-app/crates/hcie-gui-egui/` | 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` |
|
||||
| `egui-panel-filters` | `hcie-egui-app/crates/egui-panel-filters/` | Filter parameter panels. | `hcie-engine-api` |
|
||||
| `egui-panel-ai-chat` | `hcie-egui-app/crates/egui-panel-ai-chat/` | AI chat panel. | `hcie-engine-api`, `hcie-ai` |
|
||||
| `egui-panel-script` | `hcie-egui-app/crates/egui-panel-script/` | Scripting / automation panel. | `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-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 (graphite, charcoal, pastel). | `hcie-engine-api` |
|
||||
| `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
|
||||
|
||||
@@ -82,7 +85,7 @@ Layer 2: hcie-blend, hcie-history, hcie-brush-engine, hcie-draw,
|
||||
Layer 3: hcie-document
|
||||
Layer 4: hcie-engine-api (rlib + staticlib)
|
||||
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)
|
||||
@@ -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)
|
||||
|
||||
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
|
||||
|
||||
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-*/`).
|
||||
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.
|
||||
@@ -169,15 +172,18 @@ Use `unlock.sh <crate>` to temporarily open a crate, make the required change, t
|
||||
|
||||
## Open Files (agents may write here)
|
||||
|
||||
### Active egui GUI
|
||||
### Active Iced GUI
|
||||
|
||||
- `hcie-egui-app/crates/hcie-gui-egui/src/*.rs`
|
||||
- `hcie-egui-app/crates/hcie-gui-egui/Cargo.toml`
|
||||
- `hcie-egui-app/crates/egui-panel-adapter/src/*.rs`
|
||||
- `hcie-egui-app/crates/egui-panel-filters/src/*.rs`
|
||||
- `hcie-egui-app/crates/egui-panel-ai-chat/src/*.rs`
|
||||
- `hcie-egui-app/crates/egui-panel-script/src/*.rs`
|
||||
- `hcie-egui-app/crates/egui-panel-ai-script/src/*.rs`
|
||||
- `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`
|
||||
|
||||
|
||||
## 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 access `hcie-protocol` directly from GUI code.
|
||||
- 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
|
||||
|
||||
|
||||
+1
-1
@@ -38,4 +38,4 @@ HCIE v4 engine API'si üzerindeki 4K tuval optimizasyonlarının yanlışlıkla
|
||||
> ```bash
|
||||
> cargo bench -p hcie-engine-api
|
||||
> ```
|
||||
> _Not: Bu testler 4K ve çok katmanlı benchmarklar içerdiğinden belleği yoğun kullanır. Sonuçlar `./target/criterion/report/index.html` olarak dökülecektir._
|
||||
> _Not: Bu testler 4K ve çok katmanlı benchmarklar içerdiğinden belleği yoğun kullanır. Sonuçlar `./criterion/report/index.html` olarak dökülecektir. CRITERION_HOME, `.cargo/config.toml` içinde `relative = true` olarak ayarlanmıştır; tüm `cargo bench` çağrıları workspace root'taki `criterion/` dizinine yazar._
|
||||
|
||||
@@ -201,26 +201,32 @@ fn generate_square_stamp(d: usize, center: f32, r: f32, hardness: f32) -> Vec<u8
|
||||
}
|
||||
|
||||
fn generate_star_stamp(d: usize, center: f32, r: f32, hardness: f32) -> Vec<u8> {
|
||||
let points = 5u32;
|
||||
let inner_r = r * 0.4;
|
||||
let arm_count = 4u32;
|
||||
let arm_width = r * 0.22;
|
||||
let inner_r = r * 0.08;
|
||||
(0..d * d)
|
||||
.map(|i| {
|
||||
let x = (i % d) as f32 - center;
|
||||
let y = (i / d) as f32 - center;
|
||||
let angle = y.atan2(x);
|
||||
let dist = (x * x + y * y).sqrt();
|
||||
let point_angle = std::f32::consts::PI * 2.0 / points as f32;
|
||||
let point_angle = std::f32::consts::PI * 2.0 / arm_count as f32;
|
||||
let a = angle.rem_euclid(point_angle);
|
||||
let edge = if a < point_angle / 2.0 {
|
||||
inner_r + (r - inner_r) * (a / (point_angle / 2.0))
|
||||
} else {
|
||||
r - (r - inner_r) * ((a - point_angle / 2.0) / (point_angle / 2.0))
|
||||
};
|
||||
if dist >= edge {
|
||||
let arm_center = point_angle / 2.0;
|
||||
let arm_dist = (a - arm_center).abs();
|
||||
let max_arm_dist = arm_width / r;
|
||||
if dist > r {
|
||||
0
|
||||
} else {
|
||||
let alpha = 1.0 - (dist / edge).powf(1.0 - hardness.clamp(0.0, 0.99));
|
||||
} else if arm_dist < max_arm_dist && dist > inner_r {
|
||||
let arm_falloff = 1.0 - (arm_dist / max_arm_dist);
|
||||
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 {
|
||||
0
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
@@ -1374,6 +1380,11 @@ fn draw_grain_brush(
|
||||
let x1 = (cx + radius).ceil().min(width.saturating_sub(1) as f32) as u32;
|
||||
let y0 = (cy - radius).floor().max(0.0) as u32;
|
||||
let y1 = (cy + radius).ceil().min(height.saturating_sub(1) as f32) as u32;
|
||||
|
||||
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);
|
||||
|
||||
for y in y0..=y1 {
|
||||
for x in x0..=x1 {
|
||||
let dx = x as f32 - cx;
|
||||
@@ -1382,16 +1393,22 @@ fn draw_grain_brush(
|
||||
if distance > 1.0 {
|
||||
continue;
|
||||
}
|
||||
let ox = x.wrapping_add(seed_x);
|
||||
let oy = y.wrapping_add(seed_y);
|
||||
let hash =
|
||||
((x.wrapping_mul(73_856_093) ^ y.wrapping_mul(19_349_663)) & 1023) as f32 / 1023.0;
|
||||
((ox.wrapping_mul(73_856_093) ^ oy.wrapping_mul(19_349_663)) & 1023) as f32 / 1023.0;
|
||||
let texture = if woven {
|
||||
0.55 + 0.45 * ((x as f32 * 0.42).sin() * (y as f32 * 0.31).cos()).abs()
|
||||
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
|
||||
};
|
||||
if texture < threshold {
|
||||
continue;
|
||||
}
|
||||
let grain_alpha = if texture < threshold {
|
||||
(texture / threshold).clamp(0.0, 1.0)
|
||||
} else {
|
||||
0.55 + 0.45 * texture
|
||||
};
|
||||
let falloff = if distance <= hardness {
|
||||
1.0
|
||||
} else {
|
||||
@@ -1407,7 +1424,7 @@ fn draw_grain_brush(
|
||||
* pressure
|
||||
* falloff
|
||||
* mask_alpha
|
||||
* (0.55 + 0.45 * texture)
|
||||
* grain_alpha
|
||||
* (color[3] as f32 / 255.0);
|
||||
let offset = index * 4;
|
||||
if is_eraser {
|
||||
@@ -1627,6 +1644,38 @@ fn draw_star_brush(
|
||||
mask,
|
||||
Some(&stamp),
|
||||
);
|
||||
let sparkle_count = ((effective_size * 0.15) as u32).clamp(2, 8);
|
||||
let sparkle_r = effective_size * 0.12;
|
||||
let tip_sm = BrushTip {
|
||||
style: BrushStyle::Star,
|
||||
size: sparkle_r,
|
||||
hardness,
|
||||
..BrushTip::default()
|
||||
};
|
||||
let stamp_sm = generate_brush_stamp(&tip_sm);
|
||||
let seed = cx.to_bits().wrapping_mul(73_856_093)
|
||||
^ cy.to_bits().wrapping_mul(19_349_663);
|
||||
for i in 0..sparkle_count {
|
||||
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 dist = ((hash_i >> 16) & 0xFFFF) as f32 / 65536.0 * effective_size * 0.6;
|
||||
let sx = cx + angle.cos() * dist;
|
||||
let sy = cy + angle.sin() * dist;
|
||||
draw_dab_with_stamp(
|
||||
pixels,
|
||||
width,
|
||||
height,
|
||||
sx,
|
||||
sy,
|
||||
sparkle_r * 2.0,
|
||||
hardness,
|
||||
color,
|
||||
opacity * pressure * 0.5,
|
||||
is_eraser,
|
||||
mask,
|
||||
Some(&stamp_sm),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply oil paint brush - coherent, directional bristle tracks with pigment pickup.
|
||||
@@ -2964,6 +3013,10 @@ pub fn draw_crayon_brush(
|
||||
|
||||
let [fr, fg, fb, _] = color;
|
||||
|
||||
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);
|
||||
|
||||
for py in y_min..=y_max {
|
||||
for px in x_min..=x_max {
|
||||
let dx = px as f32 - cx;
|
||||
@@ -2990,20 +3043,22 @@ pub fn draw_crayon_brush(
|
||||
(1.0 - (d_sq - hard_sq) * soft_range_inv).clamp(0.0, 1.0)
|
||||
};
|
||||
|
||||
// Stable canvas-space paper tooth keeps pores aligned across consecutive dabs.
|
||||
let noise = ((px as u32).wrapping_mul(73_856_093)
|
||||
^ (py as u32).wrapping_mul(19_349_663))
|
||||
& 1023;
|
||||
let grain = noise as f32 / 1023.0;
|
||||
if grain < 0.18 * (1.0 - pressure) + 0.08 {
|
||||
continue;
|
||||
}
|
||||
let ox = (px as u32).wrapping_add(seed_x);
|
||||
let oy = (py as u32).wrapping_add(seed_y);
|
||||
let noise = ox.wrapping_mul(73_856_093) ^ oy.wrapping_mul(19_349_663);
|
||||
let grain = (noise & 1023) 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
|
||||
};
|
||||
|
||||
let brush_alpha = (alpha_factor
|
||||
* opacity
|
||||
* pressure
|
||||
* (color[3] as f32 / 255.0)
|
||||
* (0.35 + 0.65 * grain)
|
||||
* grain_alpha
|
||||
* (mask_val as f32 / 255.0)
|
||||
* 255.0)
|
||||
.round() as u8;
|
||||
|
||||
@@ -1718,7 +1718,7 @@ pub fn show_brushes_ui(app: &mut HcieApp, ui: &mut egui::Ui) {
|
||||
(hcie_engine_api::tools::BrushStyle::Marker, "Marker"),
|
||||
(hcie_engine_api::tools::BrushStyle::Sketch, "Sketch"),
|
||||
(hcie_engine_api::tools::BrushStyle::Hatch, "Hatch"),
|
||||
(hcie_engine_api::tools::BrushStyle::Star, "Stipple Dots"),
|
||||
(hcie_engine_api::tools::BrushStyle::Star, "Star Sparkle"),
|
||||
(hcie_engine_api::tools::BrushStyle::Glow, "Glow"),
|
||||
(hcie_engine_api::tools::BrushStyle::Airbrush, "Airbrush"),
|
||||
(hcie_engine_api::tools::BrushStyle::Pencil, "Pencil"),
|
||||
|
||||
@@ -34,7 +34,6 @@ usvg = { workspace = true }
|
||||
|
||||
[lib]
|
||||
crate-type = ["rlib", "staticlib"]
|
||||
bench = false
|
||||
|
||||
[dev-dependencies]
|
||||
rstest = "0.23"
|
||||
|
||||
@@ -25,6 +25,7 @@ use crate::dynamic_loader::vector::render_vector_shapes;
|
||||
use crate::dynamic_loader::{composite_layers, tiled};
|
||||
use crate::Engine;
|
||||
use hcie_tile::TiledLayer;
|
||||
use rayon::prelude::*;
|
||||
|
||||
impl Engine {
|
||||
/// **Purpose:**
|
||||
@@ -196,10 +197,15 @@ impl Engine {
|
||||
"[render_composite_region] CACHE MISS: full composite of all {} layers, dirty_rect=[{},{},{},{}]",
|
||||
self.document.layers.len(), x0, y0, x1, y1
|
||||
);
|
||||
for y in y0..y1 {
|
||||
let start = (y as usize * wu + x0u) * 4;
|
||||
let end = start + ((x1 - x0) as usize) * 4;
|
||||
buf[start..end].fill(0);
|
||||
let is_full_canvas = x0 == 0 && y0 == 0 && x1 == w && y1 == h;
|
||||
if is_full_canvas {
|
||||
buf.par_chunks_mut(65536).for_each(|chunk| chunk.fill(0));
|
||||
} else {
|
||||
for y in y0..y1 {
|
||||
let start = (y as usize * wu + x0u) * 4;
|
||||
let end = start + ((x1 - x0) as usize) * 4;
|
||||
buf[start..end].fill(0);
|
||||
}
|
||||
}
|
||||
tiled::composite_tiled_into(
|
||||
&self.document.layers,
|
||||
|
||||
@@ -117,17 +117,25 @@ impl Engine {
|
||||
if mask.len() != full_size {
|
||||
let mut full_mask = vec![layer.mask_default_color; full_size];
|
||||
if let Some(mb) = layer.mask_bounds {
|
||||
let (m_top, m_left, m_bottom, m_right) =
|
||||
(mb[0].max(0) as u32, mb[1].max(0) as u32, mb[2].max(0) as u32, mb[3].max(0) as u32);
|
||||
let (m_top, m_left, m_bottom, m_right) = (
|
||||
mb[0].max(0) as u32,
|
||||
mb[1].max(0) as u32,
|
||||
mb[2].max(0) as u32,
|
||||
mb[3].max(0) as u32,
|
||||
);
|
||||
let mw = (m_right - m_left) as usize;
|
||||
let mh = (m_bottom - m_top) as usize;
|
||||
if mw > 0 && mh > 0 && mask.len() == mw * mh {
|
||||
for y in 0..mh {
|
||||
let gy = m_top as usize + y;
|
||||
if gy >= ch as usize { break; }
|
||||
if gy >= ch as usize {
|
||||
break;
|
||||
}
|
||||
for x in 0..mw {
|
||||
let gx = m_left as usize + x;
|
||||
if gx >= cw as usize { break; }
|
||||
if gx >= cw as usize {
|
||||
break;
|
||||
}
|
||||
full_mask[gy * cw as usize + gx] = mask[y * mw + x];
|
||||
}
|
||||
}
|
||||
@@ -181,7 +189,9 @@ impl Engine {
|
||||
let cw = self.document.canvas_width as usize;
|
||||
let ch = self.document.canvas_height as usize;
|
||||
let mask_len = cw * ch;
|
||||
if mask_len == 0 { return; }
|
||||
if mask_len == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
if let Some(layer) = self.document.get_layer_by_id_mut(layer_id) {
|
||||
if layer.mask_pixels.is_none() {
|
||||
@@ -270,10 +280,6 @@ impl Engine {
|
||||
let w = self.document.canvas_width as f32;
|
||||
let h = self.document.canvas_height as f32;
|
||||
let inside = x >= 0.0 && y >= 0.0 && x < w && y < h;
|
||||
if !inside {
|
||||
self.last_stroke_pos = None;
|
||||
return;
|
||||
}
|
||||
let tip = self.brush_tip_with_time_dynamics(x, y);
|
||||
let color = self.apply_cyclic_color(self.current_color);
|
||||
|
||||
@@ -287,14 +293,24 @@ impl Engine {
|
||||
let is_eraser = self.is_eraser;
|
||||
|
||||
let dirty_r = Self::brush_dirty_radius(&tip);
|
||||
self.document.expand_dirty(lx as u32, ly as u32, dirty_r);
|
||||
self.document.expand_dirty(x as u32, y as u32, dirty_r);
|
||||
self.expand_stroke_bounds(lx as u32, ly as u32, dirty_r);
|
||||
self.expand_stroke_bounds(x as u32, y as u32, dirty_r);
|
||||
if lx >= 0.0 && ly >= 0.0 && lx < w && ly < h {
|
||||
self.document.expand_dirty(lx as u32, ly as u32, dirty_r);
|
||||
self.expand_stroke_bounds(lx as u32, ly as u32, dirty_r);
|
||||
}
|
||||
if inside {
|
||||
self.document.expand_dirty(x as u32, y as u32, dirty_r);
|
||||
self.expand_stroke_bounds(x as u32, y as u32, dirty_r);
|
||||
}
|
||||
|
||||
self.draw_target_pixels_or_mask(
|
||||
layer_id,
|
||||
|pixels, lw, lh, mask_ref, active_stroke_mask, stroke_before_buf, sketch_history| {
|
||||
|pixels,
|
||||
lw,
|
||||
lh,
|
||||
mask_ref,
|
||||
active_stroke_mask,
|
||||
stroke_before_buf,
|
||||
sketch_history| {
|
||||
let sketch_hist = if tip.style == BrushStyle::Sketch {
|
||||
sketch_history
|
||||
} else {
|
||||
@@ -348,19 +364,28 @@ impl Engine {
|
||||
|
||||
let dirty_r = tip.size.max(2.0);
|
||||
if let Some((lx, ly, _)) = self.last_stroke_pos {
|
||||
self.document.expand_dirty(lx as u32, ly as u32, dirty_r);
|
||||
if lx >= 0.0 && ly >= 0.0 && lx < w && ly < h {
|
||||
self.document.expand_dirty(lx as u32, ly as u32, dirty_r);
|
||||
self.expand_stroke_bounds(lx as u32, ly as u32, dirty_r);
|
||||
}
|
||||
}
|
||||
self.document.expand_dirty(x as u32, y as u32, dirty_r);
|
||||
if let Some((lx, ly, _)) = self.last_stroke_pos {
|
||||
self.expand_stroke_bounds(lx as u32, ly as u32, dirty_r);
|
||||
if inside {
|
||||
self.document.expand_dirty(x as u32, y as u32, dirty_r);
|
||||
self.expand_stroke_bounds(x as u32, y as u32, dirty_r);
|
||||
}
|
||||
self.expand_stroke_bounds(x as u32, y as u32, dirty_r);
|
||||
|
||||
if self.editing_mask {
|
||||
self.draw_target_pixels_or_mask(
|
||||
layer_id,
|
||||
|pixels, lw, lh, mask_ref, active_stroke_mask, stroke_before_buf, _sketch_history| {
|
||||
let mut temp_layer = hcie_protocol::Layer::from_rgba("temp", lw, lh, pixels.to_vec());
|
||||
|pixels,
|
||||
lw,
|
||||
lh,
|
||||
mask_ref,
|
||||
active_stroke_mask,
|
||||
stroke_before_buf,
|
||||
_sketch_history| {
|
||||
let mut temp_layer =
|
||||
hcie_protocol::Layer::from_rgba("temp", lw, lh, pixels.to_vec());
|
||||
draw_brush_stroke(
|
||||
&mut temp_layer,
|
||||
&[(x, y, pressure)],
|
||||
|
||||
@@ -24,6 +24,7 @@ use crate::dynamic_loader::tiled;
|
||||
use crate::Engine;
|
||||
use hcie_protocol::LayerData;
|
||||
use hcie_tile::TiledLayer;
|
||||
use rayon::prelude::*;
|
||||
|
||||
/// Wrapper to send a raw pixel pointer to a background thread as a `usize`.
|
||||
///
|
||||
@@ -543,62 +544,64 @@ impl Engine {
|
||||
if self.tile_layers.len() < lcount {
|
||||
self.tile_layers.resize_with(lcount, || None);
|
||||
}
|
||||
let mut tiles_built = 0usize;
|
||||
for (ti, tl) in self.document.layers.iter().enumerate() {
|
||||
if ti >= active_idx {
|
||||
break;
|
||||
}
|
||||
if !tl.pixels.is_empty() && self.tile_layers[ti].is_none() {
|
||||
self.tile_layers[ti] =
|
||||
Some(TiledLayer::from_dense(&tl.pixels, tl.width, tl.height));
|
||||
tiles_built += 1;
|
||||
log::trace!(
|
||||
"[begin_stroke] built tile for below layer[{}] id={}",
|
||||
ti,
|
||||
tl.id
|
||||
);
|
||||
}
|
||||
}
|
||||
let mut cache = match self.below_cache.take() {
|
||||
Some(mut b) if b.len() == buf_size => {
|
||||
b.fill(0);
|
||||
b
|
||||
}
|
||||
_ => vec![0u8; buf_size],
|
||||
};
|
||||
let tile_slice_len = active_idx.min(self.tile_layers.len());
|
||||
let visible_below: Vec<(usize, bool)> = self.document.layers[..active_idx]
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, l)| (i, l.visible))
|
||||
|
||||
let layers_needing_tiles: Vec<usize> = (0..active_idx)
|
||||
.filter(|&ti| !self.document.layers[ti].pixels.is_empty() && self.tile_layers[ti].is_none())
|
||||
.collect();
|
||||
log::trace!(
|
||||
"[begin_stroke] compositing below_cache: {} below_layers, tile_slice_len={}, visible_below={:?}, tiles_built={}",
|
||||
active_idx, tile_slice_len, visible_below, tiles_built
|
||||
);
|
||||
tiled::composite_tiled_into(
|
||||
&self.document.layers[..active_idx],
|
||||
&self.tile_layers[..tile_slice_len],
|
||||
cw,
|
||||
ch,
|
||||
0,
|
||||
0,
|
||||
cw,
|
||||
ch,
|
||||
&mut cache,
|
||||
);
|
||||
if log::log_enabled!(log::Level::Trace) {
|
||||
let non_zero = cache.iter().filter(|&&b| b != 0).count();
|
||||
log::trace!(
|
||||
"[begin_stroke] below_cache built: {} bytes, non-zero bytes={}, active_idx={}",
|
||||
cache.len(),
|
||||
non_zero,
|
||||
active_idx
|
||||
);
|
||||
if !layers_needing_tiles.is_empty() {
|
||||
let tile_data: Vec<(usize, TiledLayer)> = layers_needing_tiles
|
||||
.par_iter()
|
||||
.map(|&ti| {
|
||||
let l = &self.document.layers[ti];
|
||||
(ti, TiledLayer::from_dense(&l.pixels, l.width, l.height))
|
||||
})
|
||||
.collect();
|
||||
for (ti, tl) in tile_data {
|
||||
self.tile_layers[ti] = Some(tl);
|
||||
log::trace!("[begin_stroke] built tile for below layer[{}] id={}", ti, self.document.layers[ti].id);
|
||||
}
|
||||
}
|
||||
|
||||
let top_below = &self.document.layers[active_idx - 1];
|
||||
let top_is_opaque_normal = top_below.visible
|
||||
&& top_below.blend_mode == hcie_protocol::BlendMode::Normal
|
||||
&& (top_below.opacity - 1.0).abs() < f32::EPSILON
|
||||
&& top_below.adjustment.is_none()
|
||||
&& top_below.effects.is_empty()
|
||||
&& top_below.styles.is_empty()
|
||||
&& !top_below.clipping_mask
|
||||
&& top_below.width == cw
|
||||
&& top_below.height == ch
|
||||
&& top_below.pixels.len() == buf_size
|
||||
&& top_below.pixels.par_chunks_exact(4).all(|c| c[3] == 255);
|
||||
|
||||
if top_is_opaque_normal {
|
||||
let cache = match self.below_cache.take() {
|
||||
Some(mut b) if b.len() == buf_size => { b.copy_from_slice(&top_below.pixels); b }
|
||||
_ => top_below.pixels.clone(),
|
||||
};
|
||||
self.below_cache = Some(cache);
|
||||
self.below_cache_active_idx = Some(active_idx);
|
||||
self.below_cache_dirty = false;
|
||||
log::trace!("[begin_stroke] below_cache fast-path: topmost below layer[{}] opaque Normal, skip compositing", active_idx - 1);
|
||||
} else {
|
||||
let mut cache = match self.below_cache.take() {
|
||||
Some(mut b) if b.len() == buf_size => {
|
||||
b.par_chunks_mut(65536).for_each(|chunk| chunk.fill(0));
|
||||
b
|
||||
}
|
||||
_ => vec![0u8; buf_size],
|
||||
};
|
||||
let tile_slice_len = active_idx.min(self.tile_layers.len());
|
||||
tiled::composite_tiled_into(
|
||||
&self.document.layers[..active_idx],
|
||||
&self.tile_layers[..tile_slice_len],
|
||||
cw, ch, 0, 0, cw, ch, &mut cache,
|
||||
);
|
||||
self.below_cache = Some(cache);
|
||||
self.below_cache_active_idx = Some(active_idx);
|
||||
self.below_cache_dirty = false;
|
||||
}
|
||||
self.below_cache = Some(cache);
|
||||
self.below_cache_active_idx = Some(active_idx);
|
||||
self.below_cache_dirty = false;
|
||||
} else {
|
||||
log::trace!("[begin_stroke] active_idx=0 (bottom layer), no below_cache");
|
||||
self.below_cache = None;
|
||||
|
||||
@@ -587,6 +587,10 @@ pub struct ToolState {
|
||||
pub brush_color_variant: bool,
|
||||
/// Magnitude of the per-dab color variation (0.0..1.0).
|
||||
pub brush_variant_amount: f32,
|
||||
/// True when the brush color should cycle through the hue over stroke distance.
|
||||
pub brush_cyclic_color: bool,
|
||||
/// Speed of the hue cycle along the stroke (0.01..5.0).
|
||||
pub brush_cyclic_speed: f32,
|
||||
/// Timestamp of the last update() call for frame timing.
|
||||
pub last_update_instant: std::time::Instant,
|
||||
/// Imported brush presets from ABR/KPP files.
|
||||
@@ -621,6 +625,8 @@ impl Default for ToolState {
|
||||
brush_spacing: 1.0,
|
||||
brush_color_variant: false,
|
||||
brush_variant_amount: 0.0,
|
||||
brush_cyclic_color: false,
|
||||
brush_cyclic_speed: 0.5,
|
||||
last_update_instant: std::time::Instant::now(),
|
||||
imported_brushes: Vec::new(),
|
||||
active_brush_preset: None,
|
||||
@@ -778,6 +784,8 @@ pub enum Message {
|
||||
BrushSpacingChanged(f32),
|
||||
BrushColorVariantToggled(bool),
|
||||
BrushVariantAmountChanged(f32),
|
||||
BrushCyclicColorToggled(bool),
|
||||
BrushCyclicSpeedChanged(f32),
|
||||
BrushImportAbr,
|
||||
BrushImportAbrFile(Result<Vec<hcie_engine_api::BrushPreset>, String>),
|
||||
ResetToolDefaults,
|
||||
@@ -1319,6 +1327,7 @@ fn build_brush_tip(state: &ToolState) -> BrushTip {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// Converts an imported/default brush-engine preset tip into the protocol tip used by `Engine`.
|
||||
///
|
||||
/// **Arguments:** `source` is the preset-owned brush-engine tip. **Returns:** A protocol tip with
|
||||
@@ -2564,7 +2573,7 @@ impl HcieIcedApp {
|
||||
/// Apply brush parameters from tool state to the engine.
|
||||
///
|
||||
/// Called before starting a stroke to ensure the engine uses
|
||||
/// the current brush size, opacity, hardness, and style.
|
||||
/// the current brush size, opacity, hardness, style, and cyclic color.
|
||||
fn apply_brush_params(&mut self) {
|
||||
let mut tip = self
|
||||
.tool_state
|
||||
@@ -2577,6 +2586,15 @@ impl HcieIcedApp {
|
||||
tip.opacity = self.tool_state.brush_opacity;
|
||||
tip.hardness = self.tool_state.brush_hardness;
|
||||
tip.spacing = self.tool_state.brush_spacing;
|
||||
// Cyclic color is handled by engine state, not the tip, but preview rendering
|
||||
// also reads the tip's time dynamics. Synchronize both here.
|
||||
tip.time_enabled = self.tool_state.brush_cyclic_color;
|
||||
tip.time_size_start = 1.0;
|
||||
tip.time_size_end = 1.0;
|
||||
tip.time_opacity_start = 1.0;
|
||||
tip.time_opacity_end = 1.0;
|
||||
tip.time_angle_start = 0.0;
|
||||
tip.time_angle_end = 0.0;
|
||||
match self.tool_state.active_tool {
|
||||
Tool::Pen => {
|
||||
tip.style = BrushStyle::Round;
|
||||
@@ -2592,7 +2610,10 @@ impl HcieIcedApp {
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
self.documents[self.active_doc].engine.set_brush_tip(tip);
|
||||
let engine = &mut self.documents[self.active_doc].engine;
|
||||
engine.set_brush_tip(tip);
|
||||
engine.set_cyclic_color(self.tool_state.brush_cyclic_color);
|
||||
engine.set_cyclic_speed(self.tool_state.brush_cyclic_speed);
|
||||
}
|
||||
|
||||
/// Push the current SVG editor model into the active vector shape so the
|
||||
@@ -2901,11 +2922,14 @@ impl HcieIcedApp {
|
||||
let paint_tool =
|
||||
matches!(tool, Tool::Pen | Tool::Brush | Tool::Eraser | Tool::Spray);
|
||||
if paint_tool && (self.modifiers.control() || self.modifiers.logo()) {
|
||||
let cx = canvas_x as u32;
|
||||
let cy = canvas_y as u32;
|
||||
let width = self.documents[self.active_doc].engine.canvas_width();
|
||||
if cx < width && cy < self.documents[self.active_doc].engine.canvas_height() {
|
||||
let index = ((cy * width + cx) * 4) as usize;
|
||||
let height = self.documents[self.active_doc].engine.canvas_height();
|
||||
if canvas_x >= 0.0
|
||||
&& canvas_y >= 0.0
|
||||
&& (canvas_x as u32) < width
|
||||
&& (canvas_y as u32) < height
|
||||
{
|
||||
let index = ((canvas_y as u32 * width + canvas_x as u32) * 4) as usize;
|
||||
if index + 3 < self.documents[self.active_doc].composite_raw.len() {
|
||||
let pixels = &self.documents[self.active_doc].composite_raw;
|
||||
let color = [
|
||||
@@ -3247,13 +3271,11 @@ impl HcieIcedApp {
|
||||
let is_ctrl = self.modifiers.control() || self.modifiers.logo();
|
||||
if is_ctrl {
|
||||
// Pick color
|
||||
let cx = x as u32;
|
||||
let cy = y as u32;
|
||||
let w = self.documents[self.active_doc].engine.canvas_width();
|
||||
let h = self.documents[self.active_doc].engine.canvas_height();
|
||||
if cx < w && cy < h {
|
||||
if x >= 0.0 && y >= 0.0 && (x as u32) < w && (y as u32) < h {
|
||||
let pixels = &self.documents[self.active_doc].composite_raw;
|
||||
let idx = ((cy * w + cx) * 4) as usize;
|
||||
let idx = ((y as u32 * w + x as u32) * 4) as usize;
|
||||
if idx + 3 < pixels.len() {
|
||||
let color = [
|
||||
pixels[idx],
|
||||
@@ -3719,7 +3741,9 @@ impl HcieIcedApp {
|
||||
}
|
||||
|
||||
Message::LayerSetEditMask(editing) => {
|
||||
self.documents[self.active_doc].engine.set_editing_mask(editing);
|
||||
self.documents[self.active_doc]
|
||||
.engine
|
||||
.set_editing_mask(editing);
|
||||
self.documents[self.active_doc].cached_layers =
|
||||
self.documents[self.active_doc].engine.layer_infos();
|
||||
}
|
||||
@@ -4952,6 +4976,22 @@ impl HcieIcedApp {
|
||||
self.settings.update_from_tool_state(&self.tool_state);
|
||||
let _ = self.settings.save();
|
||||
}
|
||||
Message::BrushCyclicColorToggled(enabled) => {
|
||||
self.tool_state.brush_cyclic_color = enabled;
|
||||
self.documents[self.active_doc]
|
||||
.engine
|
||||
.set_cyclic_color(enabled);
|
||||
self.settings.update_from_tool_state(&self.tool_state);
|
||||
let _ = self.settings.save();
|
||||
}
|
||||
Message::BrushCyclicSpeedChanged(speed) => {
|
||||
self.tool_state.brush_cyclic_speed = speed;
|
||||
self.documents[self.active_doc]
|
||||
.engine
|
||||
.set_cyclic_speed(speed);
|
||||
self.settings.update_from_tool_state(&self.tool_state);
|
||||
let _ = self.settings.save();
|
||||
}
|
||||
Message::SelectionToleranceChanged(tol) => {
|
||||
self.settings.tool_settings.magic_wand_tolerance = tol as f32;
|
||||
self.settings.tool_settings.flood_fill_tolerance = tol as f32;
|
||||
@@ -6711,10 +6751,8 @@ impl HcieIcedApp {
|
||||
self.settings.tool_settings.vector_angle = angle.to_degrees();
|
||||
|
||||
// Track snap state for visual overlay feedback
|
||||
let is_rotate = matches!(
|
||||
session.handle,
|
||||
hcie_engine_api::VectorEditHandle::Rotate
|
||||
);
|
||||
let is_rotate =
|
||||
matches!(session.handle, hcie_engine_api::VectorEditHandle::Rotate);
|
||||
let is_resize = matches!(
|
||||
session.handle,
|
||||
hcie_engine_api::VectorEditHandle::TopLeft
|
||||
@@ -7784,7 +7822,8 @@ impl HcieIcedApp {
|
||||
}
|
||||
Message::PasteImage => {
|
||||
// Check internal clipboard first
|
||||
if let Some(transform) = self.documents[self.active_doc].internal_clipboard.clone() {
|
||||
if let Some(transform) = self.documents[self.active_doc].internal_clipboard.clone()
|
||||
{
|
||||
// Enter transform mode with the clipboard content
|
||||
let mut placed = transform;
|
||||
placed.pos.x += 10.0;
|
||||
@@ -8481,12 +8520,10 @@ impl HcieIcedApp {
|
||||
return self.update(Message::OpenFileRfd);
|
||||
}
|
||||
|
||||
Message::FileOpened(Ok(path)) => {
|
||||
match self.open_path_in_new_tab(path, true) {
|
||||
Ok(_) => return Task::perform(async {}, |_| Message::CompositeRefresh),
|
||||
Err(error) => log::error!("Failed to open file: {error}"),
|
||||
}
|
||||
}
|
||||
Message::FileOpened(Ok(path)) => match self.open_path_in_new_tab(path, true) {
|
||||
Ok(_) => return Task::perform(async {}, |_| Message::CompositeRefresh),
|
||||
Err(error) => log::error!("Failed to open file: {error}"),
|
||||
},
|
||||
|
||||
Message::FileOpened(Err(e)) => {
|
||||
log::error!("Failed to open file: {}", e);
|
||||
@@ -9153,9 +9190,7 @@ impl HcieIcedApp {
|
||||
| crate::theme::ThemePreset::ProDark
|
||||
| crate::theme::ThemePreset::Amoled => crate::theme::ThemePreset::ProLight,
|
||||
crate::theme::ThemePreset::PhotoshopLight
|
||||
| crate::theme::ThemePreset::ProLight => {
|
||||
crate::theme::ThemePreset::Photopea
|
||||
}
|
||||
| crate::theme::ThemePreset::ProLight => crate::theme::ThemePreset::Photopea,
|
||||
};
|
||||
self.theme_state.set_preset(new_preset);
|
||||
self.settings.theme_preset = new_preset;
|
||||
@@ -10308,10 +10343,8 @@ mod cycle_one_ux_tests {
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos();
|
||||
let path = std::env::temp_dir().join(format!(
|
||||
"hcie-open-tab-{}-{unique}.png",
|
||||
std::process::id()
|
||||
));
|
||||
let path =
|
||||
std::env::temp_dir().join(format!("hcie-open-tab-{}-{unique}.png", std::process::id()));
|
||||
image::save_buffer(
|
||||
&path,
|
||||
&[255, 0, 0, 255, 0, 255, 0, 255],
|
||||
@@ -10328,7 +10361,10 @@ mod cycle_one_ux_tests {
|
||||
assert_eq!(app.active_doc, 1);
|
||||
assert_eq!(app.documents[0].name, original_name);
|
||||
assert!(app.documents[0].source_path.is_none());
|
||||
assert_eq!(app.documents[1].source_path.as_deref(), Some(path.as_path()));
|
||||
assert_eq!(
|
||||
app.documents[1].source_path.as_deref(),
|
||||
Some(path.as_path())
|
||||
);
|
||||
assert_eq!(app.documents[1].engine.canvas_width(), 2);
|
||||
assert_eq!(app.documents[1].engine.canvas_height(), 1);
|
||||
|
||||
|
||||
@@ -985,8 +985,7 @@ fn should_publish_cursor_status(
|
||||
current: (u32, u32),
|
||||
elapsed: Option<std::time::Duration>,
|
||||
) -> bool {
|
||||
previous != Some(current)
|
||||
&& elapsed.map_or(true, |duration| duration >= CURSOR_STATUS_INTERVAL)
|
||||
previous != Some(current) && elapsed.map_or(true, |duration| duration >= CURSOR_STATUS_INTERVAL)
|
||||
}
|
||||
|
||||
/// Canvas shader program — implements `iced::widget::shader::Program`.
|
||||
@@ -1024,8 +1023,9 @@ pub struct CanvasShaderProgram {
|
||||
/// Convert a viewport-local point to canvas-space coordinates.
|
||||
///
|
||||
/// `local_pos` is relative to the shader widget's top-left corner.
|
||||
/// `viewport_size` is the widget's size. Returns `(Some(x), Some(y))`
|
||||
/// when inside the canvas image bounds, otherwise `(None, None)`.
|
||||
/// `viewport_size` is the widget's size. Returns the full signed canvas
|
||||
/// coordinates even when the cursor is outside the visible canvas image,
|
||||
/// so brush strokes can continue mathematically beyond the canvas edges.
|
||||
fn screen_to_canvas_local(
|
||||
local_pos: Point,
|
||||
viewport_w: f32,
|
||||
@@ -1053,17 +1053,7 @@ fn screen_to_canvas_local(
|
||||
let canvas_x = (local_pos.x - origin_x) / zoom;
|
||||
let canvas_y = (local_pos.y - origin_y) / zoom;
|
||||
|
||||
let cx = if canvas_x >= 0.0 && canvas_x < engine_w {
|
||||
Some(canvas_x)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let cy = if canvas_y >= 0.0 && canvas_y < engine_h {
|
||||
Some(canvas_y)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
(cx, cy)
|
||||
(Some(canvas_x), Some(canvas_y))
|
||||
}
|
||||
|
||||
impl shader::Program<Message> for CanvasShaderProgram {
|
||||
@@ -1106,8 +1096,8 @@ impl shader::Program<Message> for CanvasShaderProgram {
|
||||
|
||||
// Left mouse drag (drawing)
|
||||
if state.left_pressed {
|
||||
let cx = canvas_x.unwrap_or(0.0).clamp(0.0, self.engine_w as f32);
|
||||
let cy = canvas_y.unwrap_or(0.0).clamp(0.0, self.engine_h as f32);
|
||||
let cx = canvas_x.unwrap_or(0.0);
|
||||
let cy = canvas_y.unwrap_or(0.0);
|
||||
return (
|
||||
iced::event::Status::Captured,
|
||||
Some(Message::CanvasPointerMoved {
|
||||
|
||||
@@ -110,23 +110,44 @@ impl Program<Message> for CanvasViewportProgram {
|
||||
let mut frame = Frame::new(bounds.size());
|
||||
|
||||
// Draw checkerboard background (tiled to cover display area)
|
||||
let checker_pattern = canvas::Pattern::new(&cfg.checker_handle).mode(canvas::PatternMode::Repeat);
|
||||
let checker_path = Path::rectangle(Point::new(origin_x, origin_y), Size::new(display_w, display_h));
|
||||
let checker_pattern =
|
||||
canvas::Pattern::new(&cfg.checker_handle).mode(canvas::PatternMode::Repeat);
|
||||
let checker_path = Path::rectangle(
|
||||
Point::new(origin_x, origin_y),
|
||||
Size::new(display_w, display_h),
|
||||
);
|
||||
frame.fill(&checker_path, checker_pattern);
|
||||
|
||||
// Draw composite texture
|
||||
let composite_image = canvas::Image::new(&cfg.composite_handle)
|
||||
.destination_rectangle(Rectangle::new(Point::new(origin_x, origin_y), Size::new(display_w, display_h)));
|
||||
let composite_image =
|
||||
canvas::Image::new(&cfg.composite_handle).destination_rectangle(Rectangle::new(
|
||||
Point::new(origin_x, origin_y),
|
||||
Size::new(display_w, display_h),
|
||||
));
|
||||
frame.draw_image(composite_image);
|
||||
|
||||
// Draw canvas border
|
||||
let border_path = Path::rectangle(Point::new(origin_x, origin_y), Size::new(display_w, display_h));
|
||||
frame.stroke(&border_path, Stroke::default().with_color(Color::from_rgb(0.35, 0.35, 0.35)).with_width(1.0));
|
||||
let border_path = Path::rectangle(
|
||||
Point::new(origin_x, origin_y),
|
||||
Size::new(display_w, display_h),
|
||||
);
|
||||
frame.stroke(
|
||||
&border_path,
|
||||
Stroke::default()
|
||||
.with_color(Color::from_rgb(0.35, 0.35, 0.35))
|
||||
.with_width(1.0),
|
||||
);
|
||||
|
||||
vec![frame.into_geometry()]
|
||||
}
|
||||
|
||||
fn update(&self, state: &mut (), event: iced::Event, bounds: Rectangle, cursor: Cursor) -> (iced::widget::canvas::Status, Option<Message>) {
|
||||
fn update(
|
||||
&self,
|
||||
state: &mut (),
|
||||
event: iced::Event,
|
||||
bounds: Rectangle,
|
||||
cursor: Cursor,
|
||||
) -> (iced::widget::canvas::Status, Option<Message>) {
|
||||
let mut program_state = self.state.lock().unwrap();
|
||||
|
||||
match event {
|
||||
@@ -140,17 +161,22 @@ impl Program<Message> for CanvasViewportProgram {
|
||||
iced::mouse::Event::ButtonPressed(iced::mouse::Button::Left) => {
|
||||
if let Some(pos) = program_state.last_cursor {
|
||||
if bounds.contains(pos) {
|
||||
let (cx, cy) = viewport_to_canvas(pos, bounds, &program_state.config);
|
||||
let (cx, cy) =
|
||||
viewport_to_canvas(pos, bounds, &program_state.config);
|
||||
if let Some((cx, cy)) = (cx, cy) {
|
||||
return (iced::widget::canvas::Status::Captured, Some(Message::CanvasPointerPressed { x: cx, y: cy }));
|
||||
return (
|
||||
iced::widget::canvas::Status::Captured,
|
||||
Some(Message::CanvasPointerPressed { x: cx, y: cy }),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
(iced::widget::canvas::Status::Captured, None)
|
||||
}
|
||||
iced::mouse::Event::ButtonReleased(iced::mouse::Button::Left) => {
|
||||
(iced::widget::canvas::Status::Captured, Some(Message::CanvasPointerReleased))
|
||||
}
|
||||
iced::mouse::Event::ButtonReleased(iced::mouse::Button::Left) => (
|
||||
iced::widget::canvas::Status::Captured,
|
||||
Some(Message::CanvasPointerReleased),
|
||||
),
|
||||
iced::mouse::Event::Scrolled { delta } => {
|
||||
if program_state.is_hovered {
|
||||
let scroll_delta = match delta {
|
||||
@@ -164,7 +190,12 @@ impl Program<Message> for CanvasViewportProgram {
|
||||
let new_zoom = (old_zoom * factor).clamp(0.1, 10.0);
|
||||
|
||||
if let Some(cursor_pos) = program_state.last_cursor {
|
||||
let (canvas_x, canvas_y) = viewport_to_canvas(cursor_pos, bounds, &program_state.config).unwrap_or((0.0, 0.0));
|
||||
let (canvas_x, canvas_y) = viewport_to_canvas(
|
||||
cursor_pos,
|
||||
bounds,
|
||||
&program_state.config,
|
||||
)
|
||||
.unwrap_or((0.0, 0.0));
|
||||
|
||||
// Calculate new pan_offset to keep cursor over same canvas point
|
||||
let display_w = program_state.config.engine_w as f32 * new_zoom;
|
||||
@@ -174,11 +205,18 @@ impl Program<Message> for CanvasViewportProgram {
|
||||
let new_default_origin_x = (bounds.width - display_w) / 2.0;
|
||||
let new_default_origin_y = (bounds.height - display_h) / 2.0;
|
||||
|
||||
program_state.config.pan_offset.x = new_origin_x - new_default_origin_x;
|
||||
program_state.config.pan_offset.y = new_origin_y - new_default_origin_y;
|
||||
program_state.config.pan_offset.x =
|
||||
new_origin_x - new_default_origin_x;
|
||||
program_state.config.pan_offset.y =
|
||||
new_origin_y - new_default_origin_y;
|
||||
}
|
||||
program_state.config.zoom = new_zoom;
|
||||
return (iced::widget::canvas::Status::Captured, Some(Message::CanvasZoom { delta: scroll_delta }));
|
||||
return (
|
||||
iced::widget::canvas::Status::Captured,
|
||||
Some(Message::CanvasZoom {
|
||||
delta: scroll_delta,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
(iced::widget::canvas::Status::Ignored, None)
|
||||
@@ -190,7 +228,12 @@ impl Program<Message> for CanvasViewportProgram {
|
||||
}
|
||||
}
|
||||
|
||||
fn mouse_interaction(&self, _state: &(), bounds: Rectangle, cursor: Cursor) -> iced::mouse::Interaction {
|
||||
fn mouse_interaction(
|
||||
&self,
|
||||
_state: &(),
|
||||
bounds: Rectangle,
|
||||
cursor: Cursor,
|
||||
) -> iced::mouse::Interaction {
|
||||
if cursor.is_over(bounds) {
|
||||
iced::mouse::Interaction::Crosshair
|
||||
} else {
|
||||
@@ -200,7 +243,14 @@ impl Program<Message> for CanvasViewportProgram {
|
||||
}
|
||||
|
||||
/// Convert viewport-space coordinates to canvas-space coordinates.
|
||||
fn viewport_to_canvas(viewport_pos: Point, bounds: Rectangle, config: &CanvasViewportConfig) -> Option<(f32, f32)> {
|
||||
///
|
||||
/// Returns signed canvas coordinates even when the cursor is outside the canvas
|
||||
/// image so brush strokes can start or continue beyond the visible edges.
|
||||
fn viewport_to_canvas(
|
||||
viewport_pos: Point,
|
||||
bounds: Rectangle,
|
||||
config: &CanvasViewportConfig,
|
||||
) -> Option<(f32, f32)> {
|
||||
let display_w = config.engine_w as f32 * config.zoom;
|
||||
let display_h = config.engine_h as f32 * config.zoom;
|
||||
|
||||
@@ -210,13 +260,7 @@ fn viewport_to_canvas(viewport_pos: Point, bounds: Rectangle, config: &CanvasVie
|
||||
let canvas_x = (viewport_pos.x - origin_x) / config.zoom;
|
||||
let canvas_y = (viewport_pos.y - origin_y) / config.zoom;
|
||||
|
||||
if canvas_x >= 0.0 && canvas_x < config.engine_w as f32
|
||||
&& canvas_y >= 0.0 && canvas_y < config.engine_h as f32
|
||||
{
|
||||
Some((canvas_x, canvas_y))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
Some((canvas_x, canvas_y))
|
||||
}
|
||||
|
||||
/// Build the canvas viewport widget.
|
||||
|
||||
@@ -184,9 +184,12 @@ pub fn panel_body<'a>(
|
||||
app.tool_state.brush_hardness,
|
||||
app.tool_state.brush_color_variant,
|
||||
app.tool_state.brush_variant_amount,
|
||||
app.tool_state.brush_cyclic_color,
|
||||
app.tool_state.brush_cyclic_speed,
|
||||
app.brush_category,
|
||||
&app.tool_state.imported_brushes,
|
||||
app.tool_state.active_brush_preset.as_ref(),
|
||||
app.fg_color,
|
||||
colors,
|
||||
),
|
||||
PaneType::Filters => crate::panels::filters::view(
|
||||
@@ -235,6 +238,10 @@ pub fn panel_body<'a>(
|
||||
app.tool_state.brush_size,
|
||||
app.tool_state.brush_opacity,
|
||||
app.tool_state.brush_hardness,
|
||||
app.tool_state.brush_color_variant,
|
||||
app.tool_state.brush_variant_amount,
|
||||
app.tool_state.brush_cyclic_color,
|
||||
app.tool_state.brush_cyclic_speed,
|
||||
doc.selection_rect,
|
||||
doc.vector_draw,
|
||||
colors,
|
||||
|
||||
@@ -1,12 +1,24 @@
|
||||
//! Brushes panel — brush preset browser with visual thumbnails and live preview.
|
||||
//! Brushes panel — compact brush preset browser with realistic live preview.
|
||||
//!
|
||||
//! Matches the egui version's layout:
|
||||
//! - Category tabs (All, Drawing, Painting, Watercolor, Effects)
|
||||
//! - Grid of brush tip thumbnails with visual stroke preview
|
||||
//! - Selected brush highlighted with accent border
|
||||
//! - Size/Opacity/Hardness sliders at bottom
|
||||
//! **Purpose:** Displays the brush library as a compact 3-column grid of
|
||||
//! realistic brush-stroke thumbnails and renders a live, parameter-aware brush
|
||||
//! stroke preview at the bottom of the panel. Every preview is produced by the
|
||||
//! public `hcie_engine_api::Engine::render_brush_preview` path, so the thumbnail
|
||||
//! and preview pixels match what the brush engine actually draws on the canvas.
|
||||
//!
|
||||
//! ⚠️ PERFORMANCE: Brush preview images are cached via OnceLock.
|
||||
//! **Layout:**
|
||||
//! - Category tabs (All, Drawing, Painting, Watercolor, Effects, Custom, Imported).
|
||||
//! - 3-column grid of 36 px stroke thumbnails inside 40 px cells with 8 px labels.
|
||||
//! - Selected brush highlighted with an accent border.
|
||||
//! - Live preview area showing the current style, size, opacity, hardness, and
|
||||
//! foreground color as a short engine-rendered stroke.
|
||||
//! - Size/Opacity/Hardness sliders, color variant toggle, and variant amount.
|
||||
//!
|
||||
//! **Performance:**
|
||||
//! - Built-in style thumbnails are cached by `BrushStyle` via `OnceLock`.
|
||||
//! - Media / watercolor / imported preset thumbnails are cached by preset id.
|
||||
//! - The live stroke preview is cached by a quantized parameter key so it only
|
||||
//! regenerates when size, opacity, hardness, or foreground color actually change.
|
||||
|
||||
use crate::app::Message;
|
||||
use crate::panels::styles;
|
||||
@@ -212,12 +224,78 @@ const BRUSH_STYLES: &[BrushStyleEntry] = &[
|
||||
},
|
||||
];
|
||||
|
||||
/// Cached brush preview handles keyed by engine style index.
|
||||
static BRUSH_PREVIEW_CACHE: OnceLock<Mutex<HashMap<usize, iced::widget::image::Handle>>> =
|
||||
/// Default thumbnail color: neutral light gray so the stroke shape is visible
|
||||
/// on dark panel backgrounds. Thumbnails intentionally do not use the active
|
||||
/// foreground color so each brush's visual identity remains stable.
|
||||
const THUMB_COLOR: [u8; 4] = [200, 200, 200, 255];
|
||||
|
||||
/// Cached built-in style stroke preview handles keyed by style index.
|
||||
///
|
||||
/// Realistic stroke thumbnails are generated once per style via
|
||||
/// `Engine::render_brush_preview` and reused for the session.
|
||||
static STYLE_PREVIEW_CACHE: OnceLock<Mutex<HashMap<usize, iced::widget::image::Handle>>> =
|
||||
OnceLock::new();
|
||||
|
||||
/// Thumbnail size in pixels.
|
||||
const THUMB_SIZE: u32 = 64;
|
||||
/// Cached preset stroke preview handles keyed by preset id.
|
||||
///
|
||||
/// Media, watercolor, and imported presets each supply their own `BrushTip`; this
|
||||
/// cache stores the rendered stroke preview keyed by the unique preset id.
|
||||
static PRESET_PREVIEW_CACHE: OnceLock<Mutex<HashMap<String, iced::widget::image::Handle>>> =
|
||||
OnceLock::new();
|
||||
|
||||
/// Cached live stroke preview handles keyed by the full brush parameter set.
|
||||
///
|
||||
/// This cache lets the bottom "Preview" area update in real time as the user
|
||||
/// changes size, opacity, hardness, or foreground color without re-rendering
|
||||
/// the same configuration on every frame.
|
||||
static LIVE_PREVIEW_CACHE: OnceLock<Mutex<HashMap<PreviewKey, iced::widget::image::Handle>>> =
|
||||
OnceLock::new();
|
||||
|
||||
/// Compact thumbnail cell size in pixels.
|
||||
const THUMB_SIZE: u32 = 40;
|
||||
|
||||
/// Image size drawn inside each thumbnail cell.
|
||||
const THUMB_IMG_SIZE: u32 = 36;
|
||||
|
||||
/// Maximum brush size used for thumbnail previews so the full stroke fits.
|
||||
const THUMB_BRUSH_SIZE: f32 = 16.0;
|
||||
|
||||
/// Number of brush thumbnail columns in the grid.
|
||||
const GRID_COLUMNS: usize = 3;
|
||||
|
||||
/// Spacing between grid rows and columns, in logical pixels.
|
||||
const GRID_SPACING: u16 = 3;
|
||||
|
||||
/// Vertical spacing inside a single thumbnail cell (image + label).
|
||||
const ITEM_SPACING: u16 = 2;
|
||||
|
||||
/// Label text size for thumbnail names.
|
||||
const LABEL_SIZE: u16 = 8;
|
||||
|
||||
/// Size of the live stroke preview image at the bottom of the panel.
|
||||
const LIVE_PREVIEW_IMG_SIZE: u32 = 40;
|
||||
|
||||
/// Maximum brush size rendered in the live preview so the stroke character
|
||||
/// remains visible inside the small preview well even when the user sets a
|
||||
/// much larger brush for actual painting.
|
||||
const LIVE_PREVIEW_BRUSH_SIZE: f32 = 22.0;
|
||||
|
||||
/// Cache key identifying one unique live brush stroke preview.
|
||||
///
|
||||
/// Size, hardness, and opacity are quantized to keep the cache bounded while
|
||||
/// still capturing the visual changes the user makes in the panel.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
struct PreviewKey {
|
||||
style_idx: usize,
|
||||
size: u8,
|
||||
hardness: u8,
|
||||
opacity: u8,
|
||||
cyclic: bool,
|
||||
r: u8,
|
||||
g: u8,
|
||||
b: u8,
|
||||
a: u8,
|
||||
}
|
||||
|
||||
/// Determines whether a built-in style belongs in the selected category.
|
||||
///
|
||||
@@ -290,132 +368,284 @@ fn media_preset_category(category: &str) -> BrushCategory {
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate a brush stroke preview as RGBA pixels.
|
||||
/// Build a representative protocol `BrushTip` for a built-in style thumbnail.
|
||||
///
|
||||
/// Draws a sine-wave stroke with varying thickness to show the brush characteristics.
|
||||
fn generate_brush_preview(style: BrushStyle) -> Vec<u8> {
|
||||
let w = THUMB_SIZE;
|
||||
let h = THUMB_SIZE;
|
||||
let mut pixels = vec![0u8; (w * h * 4) as usize];
|
||||
|
||||
let n_points = 40;
|
||||
let mut points = Vec::with_capacity(n_points + 1);
|
||||
for i in 0..=n_points {
|
||||
let t = i as f32 / n_points as f32;
|
||||
let x = 4.0 + t * (w as f32 - 8.0);
|
||||
let angle = t * std::f32::consts::TAU * 1.25;
|
||||
let sine = angle.sin();
|
||||
let taper = (t * (1.0 - t) * 4.0).powf(1.1);
|
||||
let y = h as f32 / 2.0 + sine * (h as f32 * 0.22) * taper;
|
||||
points.push((x, y, t));
|
||||
}
|
||||
|
||||
// Color based on style
|
||||
let color: [u8; 3] = match style {
|
||||
BrushStyle::Pencil | BrushStyle::Sketch => [180, 180, 180],
|
||||
BrushStyle::Pen | BrushStyle::InkPen => [40, 40, 40],
|
||||
BrushStyle::Calligraphy => [60, 40, 30],
|
||||
BrushStyle::Oil => [180, 120, 60],
|
||||
BrushStyle::Charcoal => [80, 80, 80],
|
||||
BrushStyle::Watercolor => [100, 150, 200],
|
||||
BrushStyle::Crayon => [200, 100, 100],
|
||||
BrushStyle::Airbrush | BrushStyle::Spray => [120, 120, 120],
|
||||
BrushStyle::Marker => [60, 120, 60],
|
||||
BrushStyle::Glow => [200, 200, 100],
|
||||
BrushStyle::Leaf => [80, 160, 60],
|
||||
_ => [200, 200, 200],
|
||||
/// **Purpose:** Each built-in style needs a tip that lets the engine reveal its
|
||||
/// actual stroke character in a small preview. Size is clamped later to fit
|
||||
/// the thumbnail so only relative opacity/hardness matter here.
|
||||
fn thumbnail_tip_for_style(style: BrushStyle) -> hcie_engine_api::BrushTip {
|
||||
let (size, opacity, hardness) = match style {
|
||||
BrushStyle::Pencil | BrushStyle::Pen | BrushStyle::InkPen | BrushStyle::Calligraphy => {
|
||||
(4.0, 0.95, 1.0)
|
||||
}
|
||||
BrushStyle::Marker | BrushStyle::Sketch | BrushStyle::Hatch => (6.0, 0.9, 0.85),
|
||||
BrushStyle::Oil | BrushStyle::Charcoal | BrushStyle::Crayon => (12.0, 0.9, 0.5),
|
||||
BrushStyle::Watercolor
|
||||
| BrushStyle::WetPaint
|
||||
| BrushStyle::Airbrush
|
||||
| BrushStyle::Spray => (14.0, 0.75, 0.0),
|
||||
BrushStyle::Leaf
|
||||
| BrushStyle::Rock
|
||||
| BrushStyle::Meadow
|
||||
| BrushStyle::Tree
|
||||
| BrushStyle::Clouds
|
||||
| BrushStyle::Dirt
|
||||
| BrushStyle::Glow
|
||||
| BrushStyle::Noise
|
||||
| BrushStyle::Texture => (18.0, 0.9, 0.3),
|
||||
BrushStyle::Star | BrushStyle::Square | BrushStyle::HardRound => (14.0, 0.95, 1.0),
|
||||
BrushStyle::SoftRound | BrushStyle::Round | BrushStyle::Default => (14.0, 0.9, 0.5),
|
||||
BrushStyle::Wood | BrushStyle::Bristle | BrushStyle::Mixer | BrushStyle::Blender => {
|
||||
(14.0, 0.85, 0.45)
|
||||
}
|
||||
BrushStyle::Bitmap => (20.0, 0.9, 0.5),
|
||||
};
|
||||
|
||||
// Draw stroke with varying thickness
|
||||
for i in 0..n_points {
|
||||
let (x1, y1, t1) = points[i];
|
||||
let (x2, y2, _) = points[i + 1];
|
||||
|
||||
let thickness = match style {
|
||||
BrushStyle::Pencil | BrushStyle::Sketch => {
|
||||
1.0 + 2.0 * (t1 * std::f32::consts::PI).sin().abs()
|
||||
}
|
||||
BrushStyle::Pen | BrushStyle::InkPen => {
|
||||
2.0 + 3.0 * (t1 * std::f32::consts::PI).sin().abs()
|
||||
}
|
||||
BrushStyle::Calligraphy => 1.0 + 5.0 * ((t1 * 3.0).sin().abs()),
|
||||
BrushStyle::Oil | BrushStyle::Charcoal => {
|
||||
3.0 + 6.0 * (t1 * std::f32::consts::PI).sin().abs()
|
||||
}
|
||||
BrushStyle::Watercolor => 2.0 + 4.0 * (t1 * std::f32::consts::PI).sin().abs(),
|
||||
BrushStyle::Airbrush | BrushStyle::Spray => {
|
||||
4.0 + 8.0 * (t1 * std::f32::consts::PI).sin().abs()
|
||||
}
|
||||
BrushStyle::Glow => 3.0 + 7.0 * (t1 * std::f32::consts::PI).sin().abs(),
|
||||
_ => 2.0 + 5.0 * (t1 * std::f32::consts::PI).sin().abs(),
|
||||
};
|
||||
|
||||
// Draw line segment with anti-aliasing
|
||||
let steps = ((thickness * 2.0) as u32).max(1);
|
||||
for s in 0..steps {
|
||||
let frac = s as f32 / steps as f32;
|
||||
let px = x1 + (x2 - x1) * frac;
|
||||
let py = y1 + (y2 - y1) * frac;
|
||||
let r = thickness / 2.0;
|
||||
|
||||
// Fill circle at (px, py) with radius r
|
||||
let ix0 = (px - r).max(0.0) as u32;
|
||||
let iy0 = (py - r).max(0.0) as u32;
|
||||
let ix1 = (px + r).min(w as f32 - 1.0) as u32;
|
||||
let iy1 = (py + r).min(h as f32 - 1.0) as u32;
|
||||
|
||||
for iy in iy0..=iy1 {
|
||||
for ix in ix0..=ix1 {
|
||||
let dx = ix as f32 - px;
|
||||
let dy = iy as f32 - py;
|
||||
let dist = (dx * dx + dy * dy).sqrt();
|
||||
if dist <= r {
|
||||
let alpha = ((1.0 - dist / r) * 255.0) as u8;
|
||||
let idx = ((iy * w + ix) * 4) as usize;
|
||||
// Alpha blend
|
||||
let src_a = alpha as f32 / 255.0;
|
||||
let dst_a = pixels[idx + 3] as f32 / 255.0;
|
||||
let out_a = src_a + dst_a * (1.0 - src_a);
|
||||
if out_a > 0.0 {
|
||||
pixels[idx] = ((color[0] as f32 * src_a
|
||||
+ pixels[idx] as f32 * dst_a * (1.0 - src_a))
|
||||
/ out_a) as u8;
|
||||
pixels[idx + 1] = ((color[1] as f32 * src_a
|
||||
+ pixels[idx + 1] as f32 * dst_a * (1.0 - src_a))
|
||||
/ out_a) as u8;
|
||||
pixels[idx + 2] = ((color[2] as f32 * src_a
|
||||
+ pixels[idx + 2] as f32 * dst_a * (1.0 - src_a))
|
||||
/ out_a) as u8;
|
||||
pixels[idx + 3] = (out_a * 255.0) as u8;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
hcie_engine_api::BrushTip {
|
||||
style,
|
||||
size,
|
||||
opacity,
|
||||
hardness,
|
||||
..hcie_engine_api::BrushTip::default()
|
||||
}
|
||||
|
||||
pixels
|
||||
}
|
||||
|
||||
/// Get or generate brush preview for a style.
|
||||
fn get_brush_preview(style: BrushStyle) -> iced::widget::image::Handle {
|
||||
/// Sample stroke path that fits inside a square preview buffer.
|
||||
///
|
||||
/// The path is a short sine wave with pressure rising toward the middle,
|
||||
/// which is enough to show brush spacing, texture, and tapering behavior.
|
||||
fn preview_stroke_points(width: u32, height: u32) -> Vec<(f32, f32, f32)> {
|
||||
let pad = 3.0;
|
||||
let n = 24;
|
||||
let mut points = Vec::with_capacity(n);
|
||||
for i in 0..n {
|
||||
let t = i as f32 / (n - 1) as f32;
|
||||
let x = pad + t * (width as f32 - pad * 2.0);
|
||||
let y =
|
||||
height as f32 / 2.0 + (t * std::f32::consts::PI * 2.0).sin() * (height as f32 * 0.22);
|
||||
let pressure = 0.6 + 0.4 * (1.0 - (t - 0.5).abs() * 2.0);
|
||||
points.push((x, y, pressure));
|
||||
}
|
||||
points
|
||||
}
|
||||
|
||||
/// Render a realistic brush stroke preview via the engine.
|
||||
///
|
||||
/// **Arguments:**
|
||||
/// * `width`, `height` — Output buffer dimensions.
|
||||
/// * `tip` — Protocol `BrushTip` describing the brush to preview.
|
||||
/// * `color` — RGBA tint for the stroke.
|
||||
///
|
||||
/// **Returns:** RGBA pixel buffer produced by `Engine::render_brush_preview`.
|
||||
/// **Side Effects:** Allocates a temporary engine; none on application state.
|
||||
fn render_stroke_preview(
|
||||
width: u32,
|
||||
height: u32,
|
||||
tip: &hcie_engine_api::BrushTip,
|
||||
color: [u8; 4],
|
||||
) -> Vec<u8> {
|
||||
let points = preview_stroke_points(width, height);
|
||||
hcie_engine_api::Engine::render_brush_preview(width, height, tip, color, &points)
|
||||
}
|
||||
|
||||
/// Convert a brush-engine preset tip to the protocol tip used by engine previews.
|
||||
///
|
||||
/// `BrushPreset` stores the engine's internal `BrushTip`; the public preview API
|
||||
/// expects the protocol `BrushTip`. This copies all shared rendering fields.
|
||||
fn protocol_tip_from_preset(
|
||||
source: &hcie_engine_api::brush::BrushTip,
|
||||
) -> hcie_engine_api::BrushTip {
|
||||
let mut tip = hcie_engine_api::BrushTip::default();
|
||||
tip.style = preset_style(source.style);
|
||||
tip.size = source.size;
|
||||
tip.opacity = source.opacity;
|
||||
tip.hardness = source.hardness;
|
||||
tip.spacing = source.spacing;
|
||||
tip.flow = source.flow;
|
||||
tip.jitter_amount = source.jitter_amount;
|
||||
tip.scatter_amount = source.scatter_amount;
|
||||
tip.angle = source.angle;
|
||||
tip.roundness = source.roundness;
|
||||
tip.spray_particle_size = source.spray_particle_size;
|
||||
tip.spray_density = source.spray_density;
|
||||
tip.bitmap_pixels = source.bitmap_pixels.clone();
|
||||
tip.bitmap_w = source.bitmap_w;
|
||||
tip.bitmap_h = source.bitmap_h;
|
||||
tip.color_variant = source.color_variant;
|
||||
tip.variant_amount = source.variant_amount;
|
||||
tip.density = source.density;
|
||||
tip.drawing_angle = source.drawing_angle;
|
||||
tip.rotation_random = source.rotation_random;
|
||||
tip
|
||||
}
|
||||
|
||||
/// Clamp a tip's size so the preview stroke fits a small thumbnail.
|
||||
///
|
||||
/// Hardness is nudged up slightly when shrinking so detail is not lost; opacity
|
||||
/// and other dynamics are preserved.
|
||||
fn clamped_preview_tip(
|
||||
mut tip: hcie_engine_api::BrushTip,
|
||||
max_size: f32,
|
||||
) -> hcie_engine_api::BrushTip {
|
||||
if tip.size > max_size {
|
||||
let scale = max_size / tip.size;
|
||||
// Slightly increase hardness as the brush shrinks to keep edges readable.
|
||||
tip.hardness = (tip.hardness + (1.0 - tip.hardness) * (1.0 - scale)).clamp(0.0, 1.0);
|
||||
tip.size = max_size;
|
||||
}
|
||||
if tip.size < 1.0 {
|
||||
tip.size = 1.0;
|
||||
}
|
||||
tip
|
||||
}
|
||||
|
||||
/// Build a protocol `BrushTip` from the live panel parameters.
|
||||
///
|
||||
/// **Purpose:** Feeds the live stroke preview renderer without needing the
|
||||
/// full `ToolState`. The returned tip captures style, size, opacity, and
|
||||
/// hardness so the preview matches the next brush stroke.
|
||||
fn build_tip_from_state(
|
||||
style: BrushStyle,
|
||||
size: f32,
|
||||
opacity: f32,
|
||||
hardness: f32,
|
||||
cyclic_color: bool,
|
||||
) -> hcie_engine_api::BrushTip {
|
||||
hcie_engine_api::BrushTip {
|
||||
style,
|
||||
size,
|
||||
opacity,
|
||||
hardness,
|
||||
time_enabled: cyclic_color,
|
||||
time_size_start: 1.0,
|
||||
time_size_end: 1.0,
|
||||
time_opacity_start: 1.0,
|
||||
time_opacity_end: 1.0,
|
||||
time_angle_start: 0.0,
|
||||
time_angle_end: 0.0,
|
||||
..hcie_engine_api::BrushTip::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Quantize preview parameters into a cacheable key.
|
||||
///
|
||||
/// **Purpose:** Bound the live preview cache while still updating on every
|
||||
/// meaningful parameter change made in the panel.
|
||||
fn preview_key(
|
||||
style: BrushStyle,
|
||||
size: f32,
|
||||
opacity: f32,
|
||||
hardness: f32,
|
||||
cyclic: bool,
|
||||
fg_color: [u8; 4],
|
||||
) -> PreviewKey {
|
||||
PreviewKey {
|
||||
style_idx: style as usize,
|
||||
size: size.clamp(1.0, 255.0) as u8,
|
||||
hardness: (hardness * 100.0).clamp(0.0, 100.0) as u8,
|
||||
opacity: (opacity * 100.0).clamp(0.0, 100.0) as u8,
|
||||
cyclic,
|
||||
r: fg_color[0],
|
||||
g: fg_color[1],
|
||||
b: fg_color[2],
|
||||
a: fg_color[3],
|
||||
}
|
||||
}
|
||||
|
||||
/// Get or generate a live brush stroke preview for the current tool state.
|
||||
///
|
||||
/// **Purpose:** Renders a short engine-drawn stroke that reflects the active
|
||||
/// style, size, opacity, hardness, and foreground color.
|
||||
///
|
||||
/// **Logic & Workflow:**
|
||||
/// 1. Build a cache key from quantized parameters.
|
||||
/// 2. Return a cached `image::Handle` if the same configuration was rendered
|
||||
/// earlier in this session.
|
||||
/// 3. Otherwise, build a `BrushTip`, ask the engine to render a stroke preview,
|
||||
/// store the handle, and return it.
|
||||
///
|
||||
/// **Arguments:** See `build_tip_from_state` plus `fg_color`, the RGBA tint.
|
||||
///
|
||||
/// **Returns:** An Iced image handle sized `LIVE_PREVIEW_IMG_SIZE` square.
|
||||
/// **Side Effects:** Populates `LIVE_PREVIEW_CACHE`.
|
||||
fn get_live_stroke_preview(
|
||||
style: BrushStyle,
|
||||
size: f32,
|
||||
opacity: f32,
|
||||
hardness: f32,
|
||||
cyclic_color: bool,
|
||||
fg_color: [u8; 4],
|
||||
) -> iced::widget::image::Handle {
|
||||
// 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
|
||||
// stroke character; the slider still reports the real painting size.
|
||||
let preview_size = size.clamp(4.0, LIVE_PREVIEW_BRUSH_SIZE);
|
||||
let key = preview_key(style, preview_size, opacity, hardness, cyclic_color, fg_color);
|
||||
let cache = LIVE_PREVIEW_CACHE.get_or_init(|| Mutex::new(HashMap::new()));
|
||||
let mut cache = cache
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
if let Some(handle) = cache.get(&key) {
|
||||
return handle.clone();
|
||||
}
|
||||
let tip = build_tip_from_state(style, preview_size, opacity, hardness, cyclic_color);
|
||||
let pixels =
|
||||
render_stroke_preview(LIVE_PREVIEW_IMG_SIZE, LIVE_PREVIEW_IMG_SIZE, &tip, fg_color);
|
||||
let handle = iced::widget::image::Handle::from_rgba(
|
||||
LIVE_PREVIEW_IMG_SIZE,
|
||||
LIVE_PREVIEW_IMG_SIZE,
|
||||
pixels,
|
||||
);
|
||||
cache.insert(key, handle.clone());
|
||||
handle
|
||||
}
|
||||
|
||||
/// Clamp a cyclic speed value to the engine's valid range.
|
||||
fn clamp_cyclic_speed(speed: f32) -> f32 {
|
||||
speed.clamp(0.01, 5.0)
|
||||
}
|
||||
|
||||
/// Get or generate a realistic stroke preview for a built-in brush style.
|
||||
///
|
||||
/// **Purpose:** Provides the thumbnails shown in the built-in style grid. The
|
||||
/// preview is rendered through the engine so the stroke matches what the user
|
||||
/// actually paints with that style.
|
||||
fn get_style_preview(style: BrushStyle) -> iced::widget::image::Handle {
|
||||
let style_idx = style as usize;
|
||||
let cache = BRUSH_PREVIEW_CACHE.get_or_init(|| Mutex::new(HashMap::new()));
|
||||
let cache = STYLE_PREVIEW_CACHE.get_or_init(|| Mutex::new(HashMap::new()));
|
||||
let mut cache = cache
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
if let Some(handle) = cache.get(&style_idx) {
|
||||
return handle.clone();
|
||||
}
|
||||
let handle = iced::widget::image::Handle::from_rgba(
|
||||
THUMB_SIZE,
|
||||
THUMB_SIZE,
|
||||
generate_brush_preview(style),
|
||||
);
|
||||
let tip = clamped_preview_tip(thumbnail_tip_for_style(style), THUMB_BRUSH_SIZE);
|
||||
let pixels = render_stroke_preview(THUMB_SIZE, THUMB_SIZE, &tip, THUMB_COLOR);
|
||||
let handle = iced::widget::image::Handle::from_rgba(THUMB_SIZE, THUMB_SIZE, pixels);
|
||||
cache.insert(style_idx, handle.clone());
|
||||
handle
|
||||
}
|
||||
|
||||
/// Get or generate a realistic stroke preview for a runtime brush preset.
|
||||
///
|
||||
/// **Purpose:** Provides the thumbnails shown for media, watercolor, and
|
||||
/// imported presets. The preset's own `BrushTip` is used so the preview reflects
|
||||
/// the preset's size, hardness, opacity, density, and other dynamics.
|
||||
fn get_preset_preview(preset: &hcie_engine_api::BrushPreset) -> iced::widget::image::Handle {
|
||||
let cache = PRESET_PREVIEW_CACHE.get_or_init(|| Mutex::new(HashMap::new()));
|
||||
let mut cache = cache
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
if let Some(handle) = cache.get(&preset.id) {
|
||||
return handle.clone();
|
||||
}
|
||||
let tip = clamped_preview_tip(protocol_tip_from_preset(&preset.tip), THUMB_BRUSH_SIZE);
|
||||
let pixels = render_stroke_preview(THUMB_SIZE, THUMB_SIZE, &tip, THUMB_COLOR);
|
||||
let handle = iced::widget::image::Handle::from_rgba(THUMB_SIZE, THUMB_SIZE, pixels);
|
||||
cache.insert(preset.id.clone(), handle.clone());
|
||||
handle
|
||||
}
|
||||
|
||||
/// Build the brushes panel with visual thumbnails.
|
||||
pub fn view(
|
||||
current_style: BrushStyle,
|
||||
@@ -424,9 +654,12 @@ pub fn view(
|
||||
current_hardness: f32,
|
||||
brush_color_variant: bool,
|
||||
brush_variant_amount: f32,
|
||||
brush_cyclic_color: bool,
|
||||
brush_cyclic_speed: f32,
|
||||
active_category: BrushCategory,
|
||||
imported_presets: &[hcie_engine_api::BrushPreset],
|
||||
active_brush_preset: Option<&hcie_engine_api::BrushPreset>,
|
||||
fg_color: [u8; 4],
|
||||
colors: ThemeColors,
|
||||
) -> Element<'static, Message> {
|
||||
// Category tabs
|
||||
@@ -500,9 +733,9 @@ pub fn view(
|
||||
));
|
||||
}
|
||||
|
||||
// Brush preset grid — two columns remain usable in narrow dock panes.
|
||||
let mut grid_rows = column![].spacing(4);
|
||||
let mut current_row = row![].spacing(4);
|
||||
// Brush preset grid — three compact columns fit comfortably in narrow dock panes.
|
||||
let mut grid_rows = column![].spacing(GRID_SPACING);
|
||||
let mut current_row = row![].spacing(GRID_SPACING);
|
||||
|
||||
let filtered_styles: Vec<&BrushStyleEntry> = BRUSH_STYLES
|
||||
.iter()
|
||||
@@ -511,23 +744,27 @@ pub fn view(
|
||||
|
||||
for (idx, preset) in filtered_styles.iter().enumerate() {
|
||||
let is_selected = preset.style == current_style;
|
||||
let preview = get_brush_preview(preset.style);
|
||||
let preview = get_style_preview(preset.style);
|
||||
let c = colors;
|
||||
let style = preset.style;
|
||||
|
||||
let thumb = container(iced::widget::image(preview).width(48).height(48))
|
||||
.width(56)
|
||||
.height(56)
|
||||
.center_x(48)
|
||||
.center_y(48)
|
||||
.style(move |_theme| styles::raised_card(c, is_selected));
|
||||
let thumb = container(
|
||||
iced::widget::image(preview)
|
||||
.width(THUMB_IMG_SIZE as f32)
|
||||
.height(THUMB_IMG_SIZE as f32),
|
||||
)
|
||||
.width(THUMB_SIZE as f32)
|
||||
.height(THUMB_SIZE as f32)
|
||||
.align_x(iced::alignment::Horizontal::Center)
|
||||
.align_y(iced::alignment::Vertical::Center)
|
||||
.style(move |_theme| styles::raised_card(c, is_selected));
|
||||
|
||||
let label = container(text(preset.name).size(9))
|
||||
let label = container(text(preset.name).size(LABEL_SIZE))
|
||||
.width(Length::Fill)
|
||||
.center_x(Length::Fill);
|
||||
.align_x(iced::alignment::Horizontal::Center);
|
||||
|
||||
let cell = column![thumb, label]
|
||||
.spacing(2)
|
||||
.spacing(ITEM_SPACING)
|
||||
.align_x(iced::Alignment::Center);
|
||||
|
||||
let clickable = button(cell)
|
||||
@@ -554,54 +791,44 @@ pub fn view(
|
||||
|
||||
current_row = current_row.push(clickable);
|
||||
|
||||
if (idx + 1) % 2 == 0 || idx == filtered_styles.len() - 1 {
|
||||
if (idx + 1) % GRID_COLUMNS == 0 || idx == filtered_styles.len() - 1 {
|
||||
grid_rows = grid_rows.push(current_row);
|
||||
current_row = row![].spacing(4);
|
||||
current_row = row![].spacing(GRID_SPACING);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Watercolor presets (from hcie-watercolor-brushes crate) ───────────
|
||||
let mut wc_rows = column![].spacing(4);
|
||||
let mut wc_rows = column![].spacing(GRID_SPACING);
|
||||
if matches!(
|
||||
active_category,
|
||||
BrushCategory::All | BrushCategory::Watercolor
|
||||
) {
|
||||
let wc_presets = hcie_watercolor_brushes::watercolor_presets();
|
||||
let mut wc_row = row![].spacing(4);
|
||||
let mut wc_row = row![].spacing(GRID_SPACING);
|
||||
for (idx, preset) in wc_presets.iter().enumerate() {
|
||||
let is_selected = preset_style(preset.style) == current_style
|
||||
&& active_brush_preset
|
||||
.as_ref()
|
||||
.map_or(false, |p| p.id == preset.id);
|
||||
let c = colors;
|
||||
let wc_color: [u8; 3] = match preset.id.as_str() {
|
||||
"wc_wash" => [100, 150, 200],
|
||||
"wc_wet" => [80, 130, 190],
|
||||
"wc_dry" => [140, 110, 80],
|
||||
"wc_glaze" => [120, 160, 210],
|
||||
"wc_splat" => [90, 140, 180],
|
||||
"wc_bleed" => [110, 80, 150],
|
||||
"wc_grain" => [130, 120, 100],
|
||||
"wc_bloom" => [90, 170, 200],
|
||||
_ => [100, 150, 200],
|
||||
};
|
||||
let splat_pixels =
|
||||
hcie_watercolor_brushes::render_watercolor_splat(preset.style, wc_color);
|
||||
let preview =
|
||||
iced::widget::image::Handle::from_rgba(THUMB_SIZE, THUMB_SIZE, splat_pixels);
|
||||
let thumb = container(iced::widget::image(preview).width(48).height(48))
|
||||
.width(56)
|
||||
.height(56)
|
||||
.center_x(48)
|
||||
.center_y(48)
|
||||
.style(move |_theme| styles::raised_card(c, is_selected));
|
||||
let preview = get_preset_preview(preset);
|
||||
let thumb = container(
|
||||
iced::widget::image(preview)
|
||||
.width(THUMB_IMG_SIZE as f32)
|
||||
.height(THUMB_IMG_SIZE as f32),
|
||||
)
|
||||
.width(THUMB_SIZE as f32)
|
||||
.height(THUMB_SIZE as f32)
|
||||
.align_x(iced::alignment::Horizontal::Center)
|
||||
.align_y(iced::alignment::Vertical::Center)
|
||||
.style(move |_theme| styles::raised_card(c, is_selected));
|
||||
|
||||
let label = container(text(preset.name.clone()).size(9))
|
||||
let label = container(text(preset.name.clone()).size(LABEL_SIZE))
|
||||
.width(Length::Fill)
|
||||
.center_x(Length::Fill);
|
||||
.align_x(iced::alignment::Horizontal::Center);
|
||||
|
||||
let cell = column![thumb, label]
|
||||
.spacing(2)
|
||||
.spacing(ITEM_SPACING)
|
||||
.align_x(iced::Alignment::Center);
|
||||
|
||||
let preset_msg = preset.clone();
|
||||
@@ -629,9 +856,9 @@ pub fn view(
|
||||
|
||||
wc_row = wc_row.push(clickable);
|
||||
|
||||
if (idx + 1) % 2 == 0 || idx == wc_presets.len() - 1 {
|
||||
if (idx + 1) % GRID_COLUMNS == 0 || idx == wc_presets.len() - 1 {
|
||||
wc_rows = wc_rows.push(wc_row);
|
||||
wc_row = row![].spacing(4);
|
||||
wc_row = row![].spacing(GRID_SPACING);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -649,27 +876,31 @@ pub fn view(
|
||||
|| active_category == media_preset_category(&preset.category)
|
||||
})
|
||||
.collect();
|
||||
let mut media_rows = column![].spacing(4);
|
||||
let mut media_row = row![].spacing(4);
|
||||
let mut media_rows = column![].spacing(GRID_SPACING);
|
||||
let mut media_row = row![].spacing(GRID_SPACING);
|
||||
for (idx, preset) in filtered_media.iter().enumerate() {
|
||||
let style = preset_style(preset.style);
|
||||
let selected = style == current_style
|
||||
&& active_brush_preset.is_some_and(|active| active.id == preset.id);
|
||||
let preview = get_brush_preview(style);
|
||||
let preview = get_preset_preview(preset);
|
||||
let preset_message = preset.clone();
|
||||
let c = colors;
|
||||
let cell = column![
|
||||
container(iced::widget::image(preview).width(48).height(48))
|
||||
.width(56)
|
||||
.height(56)
|
||||
.center_x(48)
|
||||
.center_y(48)
|
||||
.style(move |_theme| styles::raised_card(c, selected)),
|
||||
container(text(preset.name.clone()).size(9))
|
||||
container(
|
||||
iced::widget::image(preview)
|
||||
.width(THUMB_IMG_SIZE as f32)
|
||||
.height(THUMB_IMG_SIZE as f32)
|
||||
)
|
||||
.width(THUMB_SIZE as f32)
|
||||
.height(THUMB_SIZE as f32)
|
||||
.align_x(iced::alignment::Horizontal::Center)
|
||||
.align_y(iced::alignment::Vertical::Center)
|
||||
.style(move |_theme| styles::raised_card(c, selected)),
|
||||
container(text(preset.name.clone()).size(LABEL_SIZE))
|
||||
.width(Length::Fill)
|
||||
.center_x(Length::Fill),
|
||||
.align_x(iced::alignment::Horizontal::Center),
|
||||
]
|
||||
.spacing(2)
|
||||
.spacing(ITEM_SPACING)
|
||||
.align_x(iced::Alignment::Center);
|
||||
media_row = media_row.push(
|
||||
button(cell)
|
||||
@@ -688,13 +919,13 @@ pub fn view(
|
||||
..Default::default()
|
||||
}),
|
||||
);
|
||||
if (idx + 1) % 2 == 0 || idx == filtered_media.len() - 1 {
|
||||
if (idx + 1) % GRID_COLUMNS == 0 || idx == filtered_media.len() - 1 {
|
||||
media_rows = media_rows.push(media_row);
|
||||
media_row = row![].spacing(4);
|
||||
media_row = row![].spacing(GRID_SPACING);
|
||||
}
|
||||
}
|
||||
|
||||
let mut imported_rows = column![].spacing(3);
|
||||
let mut imported_rows = column![].spacing(GRID_SPACING);
|
||||
if matches!(
|
||||
active_category,
|
||||
BrushCategory::All | BrushCategory::Imported
|
||||
@@ -703,19 +934,25 @@ pub fn view(
|
||||
let style = preset_style(preset.style);
|
||||
let selected = style == current_style;
|
||||
let preset_message = preset.clone();
|
||||
let preview = get_brush_preview(style);
|
||||
let preview = get_preset_preview(preset);
|
||||
let name = preset.name.clone();
|
||||
imported_rows = imported_rows.push(
|
||||
button(
|
||||
row![
|
||||
container(iced::widget::image(preview).width(42).height(24))
|
||||
.width(48)
|
||||
.height(28)
|
||||
.center_x(Length::Fill)
|
||||
.center_y(Length::Fill),
|
||||
container(
|
||||
iced::widget::image(preview)
|
||||
.width(THUMB_IMG_SIZE as f32)
|
||||
.height(THUMB_IMG_SIZE as f32)
|
||||
)
|
||||
.width(THUMB_SIZE as f32)
|
||||
.height(THUMB_SIZE as f32)
|
||||
.align_x(iced::alignment::Horizontal::Center)
|
||||
.align_y(iced::alignment::Vertical::Center),
|
||||
column![
|
||||
text(name).size(10),
|
||||
text("Imported ABR").size(8).color(colors.text_secondary),
|
||||
text(name).size(LABEL_SIZE + 1),
|
||||
text("Imported ABR")
|
||||
.size(LABEL_SIZE)
|
||||
.color(colors.text_secondary),
|
||||
]
|
||||
.spacing(1),
|
||||
]
|
||||
@@ -724,7 +961,7 @@ pub fn view(
|
||||
)
|
||||
.on_press(Message::BrushPresetSelected(preset_message))
|
||||
.width(Length::Fill)
|
||||
.padding(3)
|
||||
.padding(2)
|
||||
.style(move |_theme, status| iced::widget::button::Style {
|
||||
background: Some(iced::Background::Color(if selected {
|
||||
colors.bg_active
|
||||
@@ -803,31 +1040,70 @@ pub fn view(
|
||||
colors,
|
||||
Message::BrushVariantAmountChanged,
|
||||
);
|
||||
let cyclic_speed_slider = plain_slider(
|
||||
"Cyclic Speed",
|
||||
clamp_cyclic_speed(brush_cyclic_speed),
|
||||
0.01..=5.0,
|
||||
0.01,
|
||||
"x",
|
||||
2,
|
||||
colors,
|
||||
Message::BrushCyclicSpeedChanged,
|
||||
);
|
||||
|
||||
let color_variant_check = checkbox("Color variant", brush_color_variant)
|
||||
.on_toggle(Message::BrushColorVariantToggled)
|
||||
.size(16)
|
||||
.spacing(6);
|
||||
// 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.
|
||||
let color_variant_check = row![
|
||||
checkbox("", brush_color_variant)
|
||||
.on_toggle(Message::BrushColorVariantToggled)
|
||||
.size(12),
|
||||
text("Color variant").size(LABEL_SIZE),
|
||||
]
|
||||
.spacing(4)
|
||||
.align_y(iced::Alignment::Center);
|
||||
|
||||
// Live preview — show current brush tip
|
||||
let preview_handle = get_brush_preview(current_style);
|
||||
let live_preview = container(iced::widget::image(preview_handle).width(64).height(64))
|
||||
.width(Length::Fill)
|
||||
.height(80)
|
||||
.center_x(Length::Fill)
|
||||
.center_y(Length::Fill)
|
||||
.style(move |_theme| styles::recessed_control(colors));
|
||||
let cyclic_color_check = row![
|
||||
checkbox("", brush_cyclic_color)
|
||||
.on_toggle(Message::BrushCyclicColorToggled)
|
||||
.size(12),
|
||||
text("Cyclic color").size(LABEL_SIZE),
|
||||
]
|
||||
.spacing(4)
|
||||
.align_y(iced::Alignment::Center);
|
||||
|
||||
// Live preview — show a realistic engine-rendered stroke using the active
|
||||
// size, opacity, hardness, and foreground color. The preview is
|
||||
// parameter-keyed and cached so it updates in real time without
|
||||
// re-rendering identical configurations.
|
||||
let preview_handle = get_live_stroke_preview(
|
||||
current_style,
|
||||
current_size,
|
||||
current_opacity,
|
||||
current_hardness,
|
||||
brush_cyclic_color,
|
||||
fg_color,
|
||||
);
|
||||
let live_preview = container(
|
||||
iced::widget::image(preview_handle)
|
||||
.width(LIVE_PREVIEW_IMG_SIZE as f32)
|
||||
.height(LIVE_PREVIEW_IMG_SIZE as f32),
|
||||
)
|
||||
.width(Length::Fill)
|
||||
.height(56)
|
||||
.align_x(iced::alignment::Horizontal::Center)
|
||||
.align_y(iced::alignment::Vertical::Center)
|
||||
.style(move |_theme| styles::recessed_control(colors));
|
||||
|
||||
// Panel title is in the dock tab — no duplicate inside the panel.
|
||||
let panel = column![
|
||||
tabs,
|
||||
horizontal_rule(1),
|
||||
scrollable(column![grid_rows, wc_rows, media_rows, imported_rows].spacing(6))
|
||||
scrollable(column![grid_rows, wc_rows, media_rows, imported_rows].spacing(GRID_SPACING))
|
||||
.height(Length::Fill),
|
||||
horizontal_rule(1),
|
||||
row![button(text("Import ABR").size(10))
|
||||
row![button(text("Import ABR").size(LABEL_SIZE))
|
||||
.on_press(Message::BrushImportAbr)
|
||||
.padding([4, 8])
|
||||
.padding([3, 7])
|
||||
.style(
|
||||
move |_theme: &iced::Theme, status: iced::widget::button::Status| {
|
||||
match status {
|
||||
@@ -846,17 +1122,19 @@ pub fn view(
|
||||
}
|
||||
}
|
||||
)]
|
||||
.spacing(4),
|
||||
text("Preview").size(10),
|
||||
.spacing(GRID_SPACING),
|
||||
text("Preview").size(LABEL_SIZE + 1),
|
||||
live_preview,
|
||||
size_slider,
|
||||
opacity_slider,
|
||||
hardness_slider,
|
||||
color_variant_check,
|
||||
variant_slider,
|
||||
cyclic_color_check,
|
||||
cyclic_speed_slider,
|
||||
]
|
||||
.spacing(2)
|
||||
.padding(4);
|
||||
.spacing(ITEM_SPACING)
|
||||
.padding(3);
|
||||
|
||||
container(panel)
|
||||
.width(Length::Fill)
|
||||
|
||||
@@ -93,6 +93,10 @@ pub fn view<'a>(
|
||||
brush_size: f32,
|
||||
brush_opacity: f32,
|
||||
brush_hardness: f32,
|
||||
brush_color_variant: bool,
|
||||
brush_variant_amount: f32,
|
||||
brush_cyclic_color: bool,
|
||||
brush_cyclic_speed: f32,
|
||||
selection_rect: Option<(f32, f32, f32, f32)>,
|
||||
vector_draw: Option<((f32, f32), (f32, f32))>,
|
||||
colors: ThemeColors,
|
||||
@@ -134,12 +138,25 @@ pub fn view<'a>(
|
||||
// Build tool/shape-specific section
|
||||
let tool_section: Element<'a, Message> = match active_tool {
|
||||
Tool::Pen | Tool::Brush | Tool::Eraser => {
|
||||
brush_properties(brush_size, brush_opacity, brush_hardness, colors)
|
||||
brush_properties(
|
||||
brush_size,
|
||||
brush_opacity,
|
||||
brush_hardness,
|
||||
brush_color_variant,
|
||||
brush_variant_amount,
|
||||
brush_cyclic_color,
|
||||
brush_cyclic_speed,
|
||||
colors,
|
||||
)
|
||||
}
|
||||
Tool::Spray => spray_properties(
|
||||
brush_size,
|
||||
brush_opacity,
|
||||
brush_hardness,
|
||||
brush_color_variant,
|
||||
brush_variant_amount,
|
||||
brush_cyclic_color,
|
||||
brush_cyclic_speed,
|
||||
settings_spray_particle_size,
|
||||
settings_spray_density,
|
||||
colors,
|
||||
@@ -511,11 +528,20 @@ fn vector_tool_properties<'a>(
|
||||
col.into()
|
||||
}
|
||||
|
||||
/// Shared cyclic speed clamp used by brush and spray property sections.
|
||||
fn clamp_cyclic_speed(speed: f32) -> f32 {
|
||||
speed.clamp(0.01, 5.0)
|
||||
}
|
||||
|
||||
/// Brush tool properties (Pen, Brush, Eraser).
|
||||
fn brush_properties<'a>(
|
||||
size: f32,
|
||||
opacity: f32,
|
||||
hardness: f32,
|
||||
color_variant: bool,
|
||||
variant_amount: f32,
|
||||
cyclic_color: bool,
|
||||
cyclic_speed: f32,
|
||||
colors: ThemeColors,
|
||||
) -> Element<'a, Message> {
|
||||
column![
|
||||
@@ -529,6 +555,40 @@ fn brush_properties<'a>(
|
||||
plain_slider("Hardness", hardness, 0.0..=1.0, 0.01, "%", 0, colors, |v| {
|
||||
Message::BrushHardnessChanged(v)
|
||||
},),
|
||||
row![
|
||||
checkbox("Color Variant", color_variant)
|
||||
.on_toggle(Message::BrushColorVariantToggled)
|
||||
.size(11),
|
||||
]
|
||||
.spacing(4)
|
||||
.align_y(iced::Alignment::Center),
|
||||
plain_slider(
|
||||
"Variant Amount",
|
||||
variant_amount,
|
||||
0.0..=1.0,
|
||||
0.01,
|
||||
"%",
|
||||
0,
|
||||
colors,
|
||||
Message::BrushVariantAmountChanged,
|
||||
),
|
||||
row![
|
||||
checkbox("Cyclic Color", cyclic_color)
|
||||
.on_toggle(Message::BrushCyclicColorToggled)
|
||||
.size(11),
|
||||
]
|
||||
.spacing(4)
|
||||
.align_y(iced::Alignment::Center),
|
||||
plain_slider(
|
||||
"Cyclic Speed",
|
||||
clamp_cyclic_speed(cyclic_speed),
|
||||
0.01..=5.0,
|
||||
0.01,
|
||||
"x",
|
||||
2,
|
||||
colors,
|
||||
Message::BrushCyclicSpeedChanged,
|
||||
),
|
||||
]
|
||||
.spacing(4)
|
||||
.into()
|
||||
@@ -539,6 +599,10 @@ fn spray_properties<'a>(
|
||||
size: f32,
|
||||
opacity: f32,
|
||||
hardness: f32,
|
||||
color_variant: bool,
|
||||
variant_amount: f32,
|
||||
cyclic_color: bool,
|
||||
cyclic_speed: f32,
|
||||
particle_size: f32,
|
||||
density: u32,
|
||||
colors: ThemeColors,
|
||||
@@ -574,6 +638,40 @@ fn spray_properties<'a>(
|
||||
plain_slider("Hardness", hardness, 0.0..=1.0, 0.01, "%", 0, colors, |v| {
|
||||
Message::BrushHardnessChanged(v)
|
||||
},),
|
||||
row![
|
||||
checkbox("Color Variant", color_variant)
|
||||
.on_toggle(Message::BrushColorVariantToggled)
|
||||
.size(11),
|
||||
]
|
||||
.spacing(4)
|
||||
.align_y(iced::Alignment::Center),
|
||||
plain_slider(
|
||||
"Variant Amount",
|
||||
variant_amount,
|
||||
0.0..=1.0,
|
||||
0.01,
|
||||
"%",
|
||||
0,
|
||||
colors,
|
||||
Message::BrushVariantAmountChanged,
|
||||
),
|
||||
row![
|
||||
checkbox("Cyclic Color", cyclic_color)
|
||||
.on_toggle(Message::BrushCyclicColorToggled)
|
||||
.size(11),
|
||||
]
|
||||
.spacing(4)
|
||||
.align_y(iced::Alignment::Center),
|
||||
plain_slider(
|
||||
"Cyclic Speed",
|
||||
clamp_cyclic_speed(cyclic_speed),
|
||||
0.01..=5.0,
|
||||
0.01,
|
||||
"x",
|
||||
2,
|
||||
colors,
|
||||
Message::BrushCyclicSpeedChanged,
|
||||
),
|
||||
]
|
||||
.spacing(4)
|
||||
.into()
|
||||
|
||||
@@ -134,6 +134,17 @@ fn brush_settings(
|
||||
|v| Message::BrushVariantAmountChanged(v / 100.0),
|
||||
c
|
||||
),
|
||||
checkbox("Cyclic Color", ts.brush_cyclic_color)
|
||||
.on_toggle(Message::BrushCyclicColorToggled)
|
||||
.size(11),
|
||||
prop_slider(
|
||||
"Cyclic Speed",
|
||||
ts.brush_cyclic_speed.clamp(0.01, 5.0),
|
||||
0.01,
|
||||
5.0,
|
||||
Message::BrushCyclicSpeedChanged,
|
||||
c
|
||||
),
|
||||
]
|
||||
.spacing(4)
|
||||
}
|
||||
@@ -186,6 +197,28 @@ fn spray_settings(
|
||||
|v| Message::BrushHardnessChanged(v / 100.0),
|
||||
c
|
||||
),
|
||||
checkbox("Color Variant", ts.brush_color_variant)
|
||||
.on_toggle(Message::BrushColorVariantToggled)
|
||||
.size(11),
|
||||
prop_slider(
|
||||
"Variant Amount",
|
||||
ts.brush_variant_amount * 100.0,
|
||||
0.0,
|
||||
100.0,
|
||||
|v| Message::BrushVariantAmountChanged(v / 100.0),
|
||||
c
|
||||
),
|
||||
checkbox("Cyclic Color", ts.brush_cyclic_color)
|
||||
.on_toggle(Message::BrushCyclicColorToggled)
|
||||
.size(11),
|
||||
prop_slider(
|
||||
"Cyclic Speed",
|
||||
ts.brush_cyclic_speed.clamp(0.01, 5.0),
|
||||
0.01,
|
||||
5.0,
|
||||
Message::BrushCyclicSpeedChanged,
|
||||
c
|
||||
),
|
||||
]
|
||||
.spacing(4)
|
||||
}
|
||||
|
||||
@@ -59,6 +59,12 @@ pub struct ToolSettings {
|
||||
/// Magnitude of the per-dab color variation (0.0..1.0).
|
||||
#[serde(default)]
|
||||
pub brush_variant_amount: f32,
|
||||
/// True when the brush color should cycle through the hue over stroke distance.
|
||||
#[serde(default)]
|
||||
pub brush_cyclic_color: bool,
|
||||
/// Speed of the hue cycle along the stroke (0.01..5.0).
|
||||
#[serde(default)]
|
||||
pub brush_cyclic_speed: f32,
|
||||
pub eraser_size: f32,
|
||||
pub eraser_opacity: f32,
|
||||
pub pen_size: f32,
|
||||
@@ -192,6 +198,8 @@ impl Default for ToolSettings {
|
||||
brush_spacing: 1.0,
|
||||
brush_color_variant: false,
|
||||
brush_variant_amount: 0.0,
|
||||
brush_cyclic_color: false,
|
||||
brush_cyclic_speed: 0.5,
|
||||
eraser_size: 20.0,
|
||||
eraser_opacity: 1.0,
|
||||
pen_size: 2.0,
|
||||
@@ -344,6 +352,8 @@ impl AppSettings {
|
||||
self.tool_settings.brush_hardness = tool_state.brush_hardness;
|
||||
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_cyclic_color = tool_state.brush_cyclic_color;
|
||||
self.tool_settings.brush_cyclic_speed = tool_state.brush_cyclic_speed;
|
||||
self.tool_settings.last_active_tool = format!("{:?}", tool_state.active_tool);
|
||||
}
|
||||
|
||||
@@ -354,6 +364,8 @@ impl AppSettings {
|
||||
tool_state.brush_hardness = self.tool_settings.brush_hardness;
|
||||
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_cyclic_color = self.tool_settings.brush_cyclic_color;
|
||||
tool_state.brush_cyclic_speed = self.tool_settings.brush_cyclic_speed;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -110,6 +110,25 @@ impl<Message> PlainSlider<Message> {
|
||||
self.snap(*self.range.start() + (*self.range.end() - *self.range.start()) * fraction)
|
||||
}
|
||||
|
||||
/// Computes the slider value for the current cursor position while dragging.
|
||||
///
|
||||
/// **Purpose:** Allow an active drag to continue updating even when the cursor moves
|
||||
/// slightly outside the widget bounds, so 0 % and 100 % can always be reached.
|
||||
/// **Logic & Workflow:** If the cursor is inside the widget bounds, use its local X.
|
||||
/// Otherwise fall back to the global cursor position and convert it to widget-local
|
||||
/// X by subtracting `bounds.x`. Clamp the resulting X to the draggable track width.
|
||||
/// **Arguments:** `cursor` is the current mouse cursor; `bounds` is the widget rectangle.
|
||||
/// **Returns:** The stepped value at the clamped cursor position.
|
||||
fn drag_value(&self, cursor: mouse::Cursor, bounds: Rectangle) -> f32 {
|
||||
let track_width = (bounds.width - SPINNER_WIDTH).max(1.0);
|
||||
let x = match cursor.position_in(bounds) {
|
||||
Some(local) => local.x,
|
||||
None => cursor.position().map_or(0.0, |p| p.x - bounds.x),
|
||||
}
|
||||
.clamp(0.0, track_width);
|
||||
self.value_at(x, bounds.width)
|
||||
}
|
||||
|
||||
/// Returns the normalized fill fraction for the current value.
|
||||
fn fraction(&self) -> f32 {
|
||||
let span = *self.range.end() - *self.range.start();
|
||||
@@ -170,22 +189,10 @@ impl<Message> canvas::Program<Message> for PlainSlider<Message> {
|
||||
let track_width = (bounds.width - SPINNER_WIDTH).max(0.0);
|
||||
let fill_width = track_width * self.fraction();
|
||||
if fill_width > 0.0 {
|
||||
let bands = 4;
|
||||
for band in 0..bands {
|
||||
let t = band as f32 / (bands - 1) as f32;
|
||||
let mut fill = mix_color(self.colors.accent, self.colors.accent_hover, t * 0.55);
|
||||
fill.a = if self.colors.is_light { 0.30 } else { 0.25 };
|
||||
frame.fill_rectangle(
|
||||
Point::new(0.0, bounds.height * band as f32 / bands as f32),
|
||||
Size::new(fill_width, bounds.height / bands as f32 + 0.5),
|
||||
fill,
|
||||
);
|
||||
}
|
||||
frame.fill_rectangle(
|
||||
Point::new((fill_width - 1.5).max(0.0), 1.0),
|
||||
Size::new(3.0_f32.min(fill_width), (bounds.height - 2.0).max(0.0)),
|
||||
self.colors.accent,
|
||||
);
|
||||
let mut fill = self.colors.accent;
|
||||
fill.a = if self.colors.is_light { 0.30 } else { 0.25 };
|
||||
let fill_rect = Path::rounded_rectangle(Point::new(0.0, 0.0), Size::new(fill_width, bounds.height), radius);
|
||||
frame.fill(&fill_rect, fill);
|
||||
}
|
||||
|
||||
frame.stroke(
|
||||
@@ -298,14 +305,11 @@ impl<Message> canvas::Program<Message> for PlainSlider<Message> {
|
||||
(canvas::event::Status::Ignored, None)
|
||||
}
|
||||
canvas::Event::Mouse(mouse::Event::CursorMoved { .. }) if state.dragging => {
|
||||
if let Some(position) = local {
|
||||
let value = self.value_at(position.x, bounds.width);
|
||||
return (
|
||||
canvas::event::Status::Captured,
|
||||
Some((self.on_change)(value)),
|
||||
);
|
||||
}
|
||||
(canvas::event::Status::Captured, None)
|
||||
let value = self.drag_value(cursor, bounds);
|
||||
return (
|
||||
canvas::event::Status::Captured,
|
||||
Some((self.on_change)(value)),
|
||||
);
|
||||
}
|
||||
canvas::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) => {
|
||||
if state.dragging {
|
||||
@@ -409,17 +413,6 @@ fn is_numeric_fragment(text: &str) -> bool {
|
||||
.all(|character| character.is_ascii_digit() || matches!(character, '.' | ',' | '-'))
|
||||
}
|
||||
|
||||
/// Blends two theme colors while preserving alpha interpolation.
|
||||
fn mix_color(from: iced::Color, to: iced::Color, amount: f32) -> iced::Color {
|
||||
let amount = amount.clamp(0.0, 1.0);
|
||||
iced::Color::new(
|
||||
from.r + (to.r - from.r) * amount,
|
||||
from.g + (to.g - from.g) * amount,
|
||||
from.b + (to.b - from.b) * amount,
|
||||
from.a + (to.a - from.a) * amount,
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{is_numeric_fragment, PlainSlider, State};
|
||||
@@ -463,4 +456,11 @@ mod tests {
|
||||
assert_eq!(slider.value_at(100.0, 124.0), 1.0);
|
||||
assert!((slider.value_at(33.0, 124.0) - 0.33).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pointer_mapping_clamps_outside_track() {
|
||||
let slider = test_slider("");
|
||||
assert_eq!(slider.value_at(-10.0, 124.0), 0.0);
|
||||
assert_eq!(slider.value_at(120.0, 124.0), 1.0);
|
||||
}
|
||||
}
|
||||
|
||||
+19
-19
@@ -2,7 +2,7 @@
|
||||
SATIR SAYISI RAPORU
|
||||
Proje: HCIE-Rust v4
|
||||
Konum: /mnt/extra/00_PROJECTS/hcie-rust-v3.05
|
||||
Tarih: 2026-07-21 05:08:37
|
||||
Tarih: 2026-07-24 20:30:00
|
||||
=========================================
|
||||
|
||||
-----------------------------------------
|
||||
@@ -10,24 +10,24 @@ Tarih: 2026-07-21 05:08:37
|
||||
-----------------------------------------
|
||||
hcie-ai 2402 satır
|
||||
hcie-blend 530 satır
|
||||
hcie-brush-engine 4845 satır
|
||||
hcie-brush-engine 6190 satır
|
||||
hcie-build-info 93 satır
|
||||
hcie-color 85 satır
|
||||
hcie-composite 1456 satır
|
||||
hcie-document 761 satır
|
||||
hcie-composite 1482 satır
|
||||
hcie-document 879 satır
|
||||
hcie-draw 619 satır
|
||||
hcie-egui-app 37434 satır
|
||||
hcie-engine-api 8366 satır
|
||||
hcie-egui-app 37645 satır
|
||||
hcie-engine-api 9782 satır
|
||||
hcie-engine-api-orig 3874 satır
|
||||
hcie-filter 3238 satır
|
||||
hcie-fx 5535 satır
|
||||
hcie-history 139 satır
|
||||
hcie-iced-app 38968 satır
|
||||
hcie-iced-app 42998 satır
|
||||
hcie-io 12122 satır
|
||||
hcie-kra 1398 satır
|
||||
hcie-native 153 satır
|
||||
hcie-kra 1716 satır
|
||||
hcie-native 218 satır
|
||||
hcie-protocol 3756 satır
|
||||
hcie-psd 5120 satır
|
||||
hcie-psd 5136 satır
|
||||
hcie-psd-saver 1277 satır
|
||||
hcie-selection 392 satır
|
||||
hcie-text 1010 satır
|
||||
@@ -35,7 +35,7 @@ Tarih: 2026-07-21 05:08:37
|
||||
hcie-vector 2921 satır
|
||||
hcie-vision 3131 satır
|
||||
|
||||
TOPLAM RUST: 139986 satır
|
||||
TOPLAM RUST: 147531 satır
|
||||
|
||||
-----------------------------------------
|
||||
C++ / HEADER (.cpp, .h)
|
||||
@@ -86,10 +86,10 @@ Tarih: 2026-07-21 05:08:37
|
||||
-----------------------------------------
|
||||
SHELL SCRIPT (.sh)
|
||||
-----------------------------------------
|
||||
(kök dizin) 1306 satır
|
||||
(kök dizin) 1689 satır
|
||||
logs/ 220 satır
|
||||
|
||||
TOPLAM SHELL: 1526 satır
|
||||
TOPLAM SHELL: 1909 satır
|
||||
|
||||
-----------------------------------------
|
||||
DOKÜMANTASYON / VERİ DOSYALARI
|
||||
@@ -103,14 +103,14 @@ Tarih: 2026-07-21 05:08:37
|
||||
|
||||
MARKDOWN (.md)
|
||||
--------------------------------
|
||||
(kök dizin) 17606 satır
|
||||
(kök dizin) 20147 satır
|
||||
|
||||
TOPLAM MARKDOWN: 17606 satır
|
||||
TOPLAM MARKDOWN: 20147 satır
|
||||
|
||||
=========================================
|
||||
KOD SATIRLARI TOPLAMI (GRAND TOTAL)
|
||||
=========================================
|
||||
Rust: 139986 satır
|
||||
Rust: 147531 satır
|
||||
C++ / Header: 5031 satır
|
||||
JavaScript: 0 satır
|
||||
Svelte: 0 satır
|
||||
@@ -118,11 +118,11 @@ Tarih: 2026-07-21 05:08:37
|
||||
Python: 2267 satır
|
||||
HTML: 0 satır
|
||||
CSS: 0 satır
|
||||
Shell Script: 1526 satır
|
||||
Shell Script: 1909 satır
|
||||
-----------------------------------------
|
||||
KOD TOPLAMI: 148810 satır
|
||||
KOD TOPLAMI: 156738 satır
|
||||
|
||||
RUST + C++/H TOPLAMI: 145017 satır
|
||||
RUST + C++/H TOPLAMI: 152562 satır
|
||||
|
||||
(JSON ve Markdown dosyaları yukarıda ayrı olarak gösterilmiştir)
|
||||
=========================================
|
||||
|
||||
+22
-3
@@ -9,14 +9,33 @@ criterion Benchmark
|
||||
cargo bench -p hcie-engine-api
|
||||
|
||||
# Mevcut mükemmel performansı kalıcı bir baseline (referans) olarak kaydetmek için:
|
||||
# (Not: Hata almamak için hcie-engine-api/Cargo.toml'da [lib] altına bench = false eklendi)
|
||||
cargo bench -p hcie-engine-api -- --save-baseline gold_standard
|
||||
# (Not: bench = false kaldırıldı, [[bench]] target'ları doğrudan çalışıyor)
|
||||
cargo bench -p hcie-engine-api --bench stroke_mask_pooling -- --save-baseline gold_standard
|
||||
cargo bench -p hcie-engine-api --bench composite_scratch_pooling -- --save-baseline gold_standard
|
||||
cargo bench -p hcie-engine-api --bench below_cache_reuse -- --save-baseline gold_standard
|
||||
cargo bench -p hcie-engine-api --bench effects_skip -- --save-baseline gold_standard
|
||||
|
||||
# Kaydedilen 'gold_standard' baseline'ı ile karşılaştırma (regresyon testi) yapmak için:
|
||||
cargo bench -p hcie-engine-api -- --baseline gold_standard
|
||||
cargo bench -p hcie-engine-api --bench stroke_mask_pooling -- --baseline gold_standard
|
||||
cargo bench -p hcie-engine-api --bench composite_scratch_pooling -- --baseline gold_standard
|
||||
cargo bench -p hcie-engine-api --bench below_cache_reuse -- --baseline gold_standard
|
||||
cargo bench -p hcie-engine-api --bench effects_skip -- --baseline gold_standard
|
||||
|
||||
# Criterion sonuçları ./criterion/ dizinine kaydedilir
|
||||
# CRITERION_HOME, .cargo/config.toml içinde ayarlanmıştır (relative = true)
|
||||
# Rapor: ./criterion/report/index.html
|
||||
#criterion açıklamaları
|
||||
criterion.md
|
||||
|
||||
# Gold Standard Baseline (2026-07-25, par_chunks_exact optimization):
|
||||
# stroke_mask_pooling/cold_first_stroke: 39.74 ms
|
||||
# stroke_mask_pooling/warm_10th_stroke: 17.34 ms
|
||||
# composite_scratch_pooling/cold_composite: 770.86 ms
|
||||
# composite_scratch_pooling/warm_composite: 10.43 ms
|
||||
# below_cache_reuse/first_stroke_cache_build: 781.66 ms
|
||||
# below_cache_reuse/second_stroke_cache_reuse: 16.33 ms
|
||||
# effects_skip/no_effects_composite: 17.15 ms
|
||||
# effects_skip/with_one_dropshadow: 15.16 ms
|
||||
|
||||
##test
|
||||
Running the Tests Automatically
|
||||
|
||||
Reference in New Issue
Block a user