Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fd205309b6 | |||
| 19a7e51813 | |||
| 683dac06ec | |||
| 45a231a456 | |||
| eb17bf4205 | |||
| 1dabd9c31e | |||
| 1cc8a3f373 | |||
| 8563a44211 | |||
| 844fd4a026 | |||
| ee6ad2dc20 | |||
| 6b02f19624 | |||
| a056419c24 | |||
| 505562424b | |||
|
7496f5e208
|
|||
|
fba10b753c
|
|||
|
f2cca7e6a1
|
|||
|
a2264b130d
|
|||
|
e9dad7ef0c
|
|||
| 3bac629045 | |||
| 135ab8a628 | |||
| 513efcdcff |
@@ -1,3 +1,13 @@
|
||||
[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"]
|
||||
|
||||
[target.x86_64-pc-windows-msvc]
|
||||
linker = "rust-lld.exe"
|
||||
|
||||
[target.x86_64-pc-windows-gnu]
|
||||
linker = "rust-lld.exe"
|
||||
|
||||
@@ -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,71 @@
|
||||
# Failing Crates Repair Plan
|
||||
|
||||
## Goal
|
||||
|
||||
Make the bulk Linux and Windows test runners complete without platform-feature build failures, preserve compilation coverage for the existing egui visual examples, and report the exact names of failed crates instead of only a count.
|
||||
|
||||
## Confirmed Findings
|
||||
|
||||
- `hcie-blend` fails because its `eframe` dev-dependency disables defaults without enabling either Linux backend; `winit 0.30.13` therefore emits “platform ... not supported”.
|
||||
- The same manifest defect exists in `hcie-draw`, `hcie-filter`, `hcie-text`, and `hcie-vector`.
|
||||
- `hcie-brush-engine` succeeds because it enables `x11`; `hcie-io` enables both `x11` and `wayland`.
|
||||
- The root workspace already defines the intended cross-platform `eframe` configuration with `wayland` and `x11`.
|
||||
- `hcie-text/Cargo.toml` currently repeats `proptest` and `approx`, which is a duplicate-key manifest error and must be fixed before any Cargo validation.
|
||||
- The supplied terminal output proves one named failure (`hcie-blend`) and six failures in total. It does not expose all six names, so the prior six-name claim must not be treated as authoritative.
|
||||
- `run_all_tests.*` and `run_tests_categorized.*` only retain failure counts. Their `--no-4k` command also places `--skip` on the Cargo side instead of after Cargo’s `--` test-harness separator.
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
1. **Restore a valid workspace manifest.**
|
||||
- Remove the duplicate `proptest` and `approx` entries from `hcie-text/Cargo.toml`.
|
||||
- Run `cargo metadata --no-deps` immediately; stop if any other manifest error remains.
|
||||
|
||||
2. **Centralize visual-example GUI dev-dependencies.**
|
||||
- In `hcie-blend`, `hcie-brush-engine`, `hcie-draw`, `hcie-filter`, `hcie-text`, `hcie-vector`, and `hcie-io`, replace crate-local `egui = "0.34"` and custom `eframe` feature lists with `egui = { workspace = true }` and `eframe = { workspace = true }` under `[dev-dependencies]`.
|
||||
- Preserve all non-GUI dev-dependencies and existing visual examples.
|
||||
- Do not add GUI dependencies to runtime `[dependencies]` and do not modify engine source files.
|
||||
- This intentionally reuses the root’s `default_fonts`, `glow`, `persistence`, `wayland`, and `x11` configuration so platform support cannot drift between crates.
|
||||
|
||||
3. **Verify feature resolution before compiling.**
|
||||
- Use `cargo tree -p <crate> -e features -i winit` for the five confirmed failing crates and the two known-good comparison crates.
|
||||
- Confirm Linux builds activate at least one backend and the standardized configuration activates both `winit/x11` and `winit/wayland`.
|
||||
|
||||
4. **Improve test-runner diagnostics in both platform versions.**
|
||||
- Update `run_all_tests.sh`, `run_all_tests.bat`, `run_tests_categorized.sh`, and `run_tests_categorized.bat` to append every failed crate name to a failure list and every skipped crate name to a skip list.
|
||||
- Print those lists in the final summary while preserving pass/fail/skip counts and non-zero exit status.
|
||||
- In shell scripts, use arrays for Cargo arguments rather than a space-split string.
|
||||
- Fix the 4K exclusion to invoke `cargo test -p hcie-engine-api -- --skip benchmark_4k_stroke_on_multilayer_document`; apply equivalent quoting and argument ordering in `.bat` files.
|
||||
- Keep default `cargo test -p <crate>` behavior so examples are compiled; do not hide the defect by globally switching to `--tests`.
|
||||
|
||||
5. **Run focused crate validation.**
|
||||
- Run `cargo check -p <crate> --examples` and `cargo test -p <crate>` for `hcie-blend`, `hcie-draw`, `hcie-filter`, `hcie-text`, and `hcie-vector`.
|
||||
- Re-run `hcie-brush-engine` and `hcie-io` to detect regressions from dependency centralization.
|
||||
- For `hcie-io`, distinguish build/test failures from missing external PSD fixtures; fixture-dependent early returns are not platform compilation failures.
|
||||
|
||||
6. **Identify and repair the sixth failure from evidence.**
|
||||
- Run the improved Linux bulk runner first with `--no-io --no-4k` for deterministic feedback.
|
||||
- Read the emitted failed-crate list. If a failure remains outside the five confirmed manifests, capture its first compiler/test error and fix that crate’s actual cause rather than assuming another `winit` defect.
|
||||
- Re-run only the remaining failed crate until green, then repeat the deterministic bulk run.
|
||||
|
||||
7. **Complete end-to-end validation.**
|
||||
- Run `./run_all_tests.sh --no-io --no-4k`; require zero failed crates and verify the summary names no failures.
|
||||
- Run `cargo test -p hcie-io` separately with the expected fixture set or record fixture skips distinctly.
|
||||
- Run the 4K engine test explicitly with the protected command from `AGENTS.md` rather than silently omitting performance coverage.
|
||||
- Validate `.bat` parity on Windows (or Windows CI): targeted five-crate tests, `run_all_tests.bat --no-io --no-4k`, correct failed-name reporting, and non-zero exit code on an intentional failing command.
|
||||
|
||||
## Risks And Guardrails
|
||||
|
||||
- Enabling `x11`/`wayland` compiles native backend support but does not launch windows during tests; headless execution remains safe because examples are only compiled.
|
||||
- Do not use cached success as proof. At least one validation pass should use a fresh dedicated target directory (`CARGO_TARGET_DIR=target/platform-test`) rather than deleting the shared target directory.
|
||||
- Do not alter locked engine logic or protected performance mechanisms; this repair is confined to manifests and test runners unless the newly identified sixth failure proves independent.
|
||||
- Do not relocate the visual examples in this repair. Removing all GUI dev-dependencies from engine crates would require a separate architecture migration into the GUI workspace.
|
||||
- The Linux environment cannot establish Windows batch correctness alone; Windows execution is a required validation gate, not an inferred result.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- `cargo metadata --no-deps` succeeds with no duplicate manifest keys.
|
||||
- The five confirmed crates compile their examples and pass their tests on Linux.
|
||||
- The bulk runner reports failed crate names and exits zero when all selected crates pass.
|
||||
- `./run_all_tests.sh --no-io --no-4k` reports zero failed crates from a fresh target directory.
|
||||
- Windows `.bat` behavior matches Linux for argument handling, result lists, and exit status.
|
||||
- Any sixth failure is named and resolved from its own diagnostic output, not from dependency-based speculation.
|
||||
@@ -0,0 +1,54 @@
|
||||
# Iced Gercek Zamanli Cizim Performansi Plani
|
||||
|
||||
## Hedef
|
||||
|
||||
Aktif iced uygulamasini egui referansiyla ayni 3840x2160, 10 katman, 100 segment firca senaryosunda olcmek; input, motor, CPU staging, GPU upload ve frame pacing maliyetlerini ayirmak; kanitlanan iced darboğazlarini GUI katmaninda gidermek.
|
||||
|
||||
## Mevcut Mimari ve Kararlar
|
||||
|
||||
- egui immediate-mode akista pointer durumunu ve motor cagrilarini ayni UI gecisinde isler; aktif cizimde acikca `request_repaint()` kullanir. Dusuk ek yuku olan surekli frame yenilemesine dogal olarak uygundur.
|
||||
- iced Elm/declarative akista her pointer olayi `Message -> update -> view -> diff/layout -> prepare/render` zincirinden gecer. Mevcut kod her hareket mesajinda senkron motor isi yapar ve gorunur kompoziti yeni pointer mesajinin gelmesine bagli 16 ms esigiyle yeniler.
|
||||
- Aktif iced canvas GPU hizlandirmalidir: `canvas/shader_canvas.rs` kalici wgpu texture, tek quad draw call ve dirty-region `queue.write_texture()` kullanir. Eski tam-frame `ImageHandle` yolu `canvas/viewport.rs` icinde devre disidir.
|
||||
- Her iki GUI ayni engine-side sparse tile ve dirty-region kompozit yolunu kullanir. GPU tile atlas yoktur, fakat normal firca guncellemesinde buna ihtiyac olmadan kismi texture upload yapilir. Olcum buyuk/full dirty region gostermedikce yeni tiled GPU renderer yapilmayacak.
|
||||
- iced normal dirty update'te yalnizca kirli satirlari tasir. egui ise kismi GPU upload'dan once motorun tam 4K tamponunu yaklasik 33 MB kopyalar (`hcie-egui-app/.../canvas/render.rs:169-178`). Bu nedenle mevcut bulgular iced'deki sorunu salt GPU bant genisligiyle aciklamiyor.
|
||||
- Mevcut makinede Intel Iris Xe, `i915`, donanim hizlandirmali OpenGL ve Intel Vulkan vardir; Vulkan ayrica llvmpipe listeler. Runtime adapter secimi olculmeden GPU varsayimi yapilmayacak.
|
||||
- Engine ve korumali performans mekanizmalari degistirilmeyecek. Calisma `hcie-iced-app/crates/hcie-iced-gui` ile gerekli karsilastirma olcumleri icin `hcie-egui-app/crates/hcie-gui-egui` sinirinda kalacak.
|
||||
|
||||
## Uygulama Adimlari
|
||||
|
||||
1. **Tekrarlanabilir performans gozlemi ekle.** Her iki GUI'de varsayilan olarak kapali, ortam degiskeniyle etkinlesen hafif bir canvas performans probu ekle. Ayni adlarla su evreleri p50/p95/max ve sayac olarak raporla: pointer-event-to-update, brush/pen engine call, `render_composite_region`, dirty CPU row copy/pack, GPU prepare/upload, prepare araligi, dirty piksel/byte orani, full-upload sayisi ve stroke basina gorunur update sayisi. Iced `TextureUpdate` ile son input zamanini/generation'i shader `prepare` asamasina tasiyarak input-to-GPU-prepare gecikmesini olc; bunu gercek present gecikmesi olarak adlandirma. Raporu stroke sonunda tek satir/ozet halinde yaz ve hot path'te her olay icin `info` log uretme.
|
||||
|
||||
2. **GPU backend'i kanitla.** Performans calistirmasinda wgpu adapter/backend adini kaydet veya wgpu baslangic loglarindan dogrula. Intel Vulkan yerine llvmpipe/software adapter secilmisse once backend/adapter tercihini donanim Vulkan'a sabitleyecek en kucuk iced baslangic ayarini uygula; donanim adapter zaten seciliyse renderer secimini degistirme.
|
||||
|
||||
3. **Baseline'i ayristir.** 4K, 10 katman ve 100 segmentte uc isinmali tekrar al. Once engine'in mevcut `performance_stroke_4k` sonucunu kaydet, sonra egui ve iced GUI problarini ayni firca boyutu, spacing, zoom ve efekt/selection kapali durumda karsilastir. Ikinci matris olarak selection acik ve efektli katman senaryosunu ayri raporla. Darbogazi su sirayla siniflandir: software GPU, engine/composite, iced update/view scheduling, CPU staging, GPU upload. Optimizasyon oncesi metrik olmadan pipeline yeniden yazma.
|
||||
|
||||
4. **Iced cizim ve frame zamanlamasini ayir.** `CanvasPointerMoved` hot path'inde brush/pen noktalarini motora isle, fakat her hareket mesajinda kompozit alma. Cizim surerken yaklasik 16 ms'lik gercek `iced::time::every` subscription'i ile ayri bir canvas-frame mesaji uret; bu mesaj dirty ise yalnizca canvas kompozit/staging yapar. Ilk dab ve release son kompoziti hemen yapmaya devam et. Mevcut olay-gelmesine-bagli `last_composite_refresh` esigini kaldir veya yalnizca duplicate tick korumasi olarak kullan. Timer sadece aktif stroke boyunca calissin; daha once pointer starvation olusturan hemen-hazir async donguyu geri getirme.
|
||||
|
||||
5. **Kompozit hot path'ini panel cache isinden ayir.** `refresh_composite_if_needed` sorumluluklarini minimum iki kapsama bol: canvas-only ve document-metadata. Canvas-frame tick yalnizca engine dirty region, kismi CPU copy, dirty union ve gerekli selection senkronunu yapsin. `layer_infos`, layer thumbnail, tum history aciklamalari ve `thumb_gen` yenilemesini stroke release/undo/redo/layer islemleri veya kisa idle sonrasina ertele. Mevcut dirty flag temizleme sirasi nedeniyle thumbnail'larin stale kalmamasini saglamak icin render oncesi kirli/aktif layer kimligini yakala ve deferred metadata yenilemesinde kullan; cizim sirasinda tam layer pixel clone yapma.
|
||||
|
||||
6. **Nadir full upload yolunu ucuzlat.** `CanvasShaderPrimitive::prepare` icinde ayni boyutlu `full_upload` talebinde texture, selection texture ve bind group'u yeniden yaratma. Boyut degismediyse mevcut texture'a tam `queue.write_texture()` uygula; `resize_texture` yalnizca gercek boyut/format degisiminde calissin. Normal partial-upload, `RefCell` dirty union, viewport mapping ve nearest sampler kodunu aynen koru.
|
||||
|
||||
7. **Yalniz metrik gerekirse ikinci kademe optimizasyon yap.** Ilk duzeltmelerden sonra `view/diff/layout` p95 hala frame butcesini asiyorsa shader widget'inin yayinladigi hareket mesajlarini guvenli bir ust frekansta birlestir veya sinirla; son koordinati ve release olayini kaybetme, engine interpolasyon/spacing davranisini koru. Overlay `Canvas` geometri olusturma maliyeti baskinsa yalniz aktif preview/cursor durumlarini cache'le. Dirty pack allocation veya lock maliyeti anlamli degilse staging buffer havuzu ya da GPU tile atlas ekleme.
|
||||
|
||||
8. **Karsilastirma raporunu sonuclandir.** Once/sonra tablosunda engine zamani, iced update/composite/staging/upload p95, input-to-GPU-prepare p95, gorunur update hizi, upload byte miktari ve full-upload sayisini ver. Sonucu paradigmayla dogru bagla: immediate mode tek basina hiz garantisi degildir; bu uygulamada egui'nin acik repaint davranisi ile iced'in event-driven Elm zinciri ve hot-path yan isleri fark yaratir. GPU/tile hipotezinin hangi metriklerle elendigi veya dogrulandigini belirt.
|
||||
|
||||
## Dogrulama
|
||||
|
||||
- `cargo test -p hcie-engine-api --test visual_regression`
|
||||
- `cargo test -p hcie-engine-api --test performance_stroke_4k -- --nocapture`
|
||||
- `cargo test -p hcie-iced-gui`
|
||||
- `cargo test -p hcie-iced-gui diagnostic_4k_dirty_staging_latency -- --ignored --nocapture`
|
||||
- `cargo test -p hcie-gui-egui`
|
||||
- Debug profile yerine mevcut optimize edilmis dev profile ve release'te ayri baseline al; sonuclari profil adi ve secilen GPU adapter ile etiketle.
|
||||
- 4K/10 katman/100 segment senaryosunda normal stroke boyunca full upload sifir, upload byte miktari dirty rectangle ile orantili ve software renderer kullanimi yok olmalidir.
|
||||
- Hedef: steady-state gorunur canvas yenilemesi yaklasik 60 Hz, input-to-GPU-prepare p95 en fazla 16.7 ms veya ayni makinedeki egui referansina esit/daha iyi; engine tek basina 16.7 ms'yi asiyorsa GUI hedefini engine maliyetinden ayri raporla ve locked engine'i bu calismada degistirme.
|
||||
- Brush, pen, eraser ve spray icin ilk dab, surekli stroke, kisa duraklama ve release son pikselinin gorunmesi; hizli stroke sirasinda eksik dirty bolge olmamasi; pan/zoom koordinat hizasi ve selection shader goruntusu regresyon testinden gecmelidir.
|
||||
- Her GUI degisikliginden sonra proje talimatina uygun screenshot al. Performans goruntuden anlasilamayacagi icin screenshot'i metriklerin yerine kullanma; ilk frame, canvas hizasi ve overlay regresyonunu kontrol etmek icin kullan.
|
||||
|
||||
## Riskler ve Sinirlar
|
||||
|
||||
- `iced::time::every` cok sik veya stroke disinda calistirilirsa pointer starvation geri gelebilir; subscription aktif stroke ile sinirli ve 16 ms paced olmalidir.
|
||||
- Pointer mesajlarini birlestirmek stroke noktalari kaybettirebilir; bu adim kosullu olacak ve release/son nokta zorunlu korunacaktir.
|
||||
- `full_upload` ayni boyutta texture yenilemeden yapilirken pipeline recovery ve document switch davranisi korunmalidir.
|
||||
- Gercek display present zamani iced shader API'sinden dogrudan elde edilmiyorsa input-to-prepare metriği acikca proxy olarak raporlanmalidir.
|
||||
- GPU tile atlas bu planin parcasi degildir. Dirty upload'larin beklenmedik bicimde full-canvas oldugu kanitlanirsa once dirty bounds kaynagi duzeltilir; atlas ancak ayri bir tasarim karari olarak ele alinir.
|
||||
@@ -0,0 +1,163 @@
|
||||
# KRA Format Fix Plan
|
||||
|
||||
## Root Causes (confirmed by analysis)
|
||||
|
||||
### 1. VERSION Header Mismatch — Krita Crash + Self-Import Failures
|
||||
**File:** `hcie-kra/src/kra_saver.rs` lines 324–329, 409–415
|
||||
|
||||
The saver writes `VERSION 2\n` in the VERS tile header, but the compressed payload uses
|
||||
version byte `1` (meaning **no** delta decoding). Krita's VERS parser sees VERSION 2 and
|
||||
applies delta decoding after decompression, producing garbage or a crash.
|
||||
|
||||
Our own importer (`hcie-kra/src/lib.rs` line 158) also checks `if version >= 2` and
|
||||
would apply delta decoding, producing wrong pixel data.
|
||||
|
||||
**Fix:** Change both `encode_layer_vers` and `encode_mask_vers` to write `VERSION 1`
|
||||
instead of `VERSION 2`.
|
||||
|
||||
### 2. Fake "LZF" Compression — Bloated Files + Non-Standard
|
||||
**File:** `hcie-kra/src/kra_saver.rs` lines 308–316, 394–403
|
||||
|
||||
The "LZF" compression is a custom RLE scheme that adds 513 bytes of overhead per tile
|
||||
(1 version byte + 512 control bytes), making files **larger** than uncompressed. A full
|
||||
1920×1080 layer (510 tiles) goes from 8.29 MB to 8.62 MB (+4%). The format happens to
|
||||
look like real LZF literal runs, so our `lzf_decompress` function processes it correctly,
|
||||
but a real LZF decoder would fail on tiles requiring back-references.
|
||||
|
||||
**Fix:** Store tiles as RAW (uncompressed). Remove the fake compression logic and write
|
||||
raw planar pixel data. Label tiles as `RAW` instead of `LZF` in the tile header. The
|
||||
importer already handles RAW tiles correctly (lib.rs line 166–168).
|
||||
|
||||
### 3. Invalid UUID Format — Krita Rejects Our XML
|
||||
**File:** `hcie-kra/src/kra_saver.rs` line 240
|
||||
|
||||
UUIDs are generated from a single `u64` (8 bytes) and formatted as 32 hex digits with
|
||||
leading zeros: `{00000000000000003aeba3f7cf423ad9}`. Proper UUIDs are 128 bits (16 bytes)
|
||||
with dashed formatting: `{3aeba3f7-cf42-3ad9-…}`. Krita may validate UUID format and
|
||||
reject our non-standard forms.
|
||||
|
||||
**Fix:** Generate proper 128-bit random UUIDs using two `rand::random::<u64>()` calls
|
||||
and format with dashes per standard UUID layout.
|
||||
|
||||
### 4. Color Space / Bit Depth Ignored — Wrong Colors on Import
|
||||
**File:** `hcie-kra/src/lib.rs` lines 647–698
|
||||
|
||||
The importer reads `colorspacename`, `channeldepth`, and `profile` attributes from the
|
||||
`<IMAGE>` and `<layer>` tags but ignores them. If the source file uses a non-RGBA color
|
||||
space (e.g., CMYK, Grayscale) or 16-bit channel depth, the raw pixel bytes are
|
||||
misinterpreted, producing wrong colors.
|
||||
|
||||
**Fix:** At minimum, validate on import that:
|
||||
- `colorspacename` is `"RGBA"`
|
||||
- `channeldepth` is `"U8"` or `"U16"` (for U16, convert to U8 via shifting)
|
||||
- Return an error if the format is unsupported
|
||||
|
||||
### 5. Silent Layer Failures — Data Loss Without Notice
|
||||
**File:** `hcie-kra/src/lib.rs` lines 790, 808, 814, 837
|
||||
|
||||
When a layer file can't be found, VERS parsing fails, or image decoding fails, the
|
||||
layer is silently skipped or turned into a transparent layer. The user has no indication
|
||||
that data was lost.
|
||||
|
||||
**Fix:** Add `log::warn!` calls at each failure point, including the layer name and
|
||||
reason for failure.
|
||||
|
||||
### 6. Missing `layers.xml` Fallback
|
||||
**File:** `hcie-kra/src/lib.rs` line 626
|
||||
|
||||
The main importer only tries `maindoc.xml`. Some Krita variants write `layers.xml`
|
||||
instead. The v2 loader (`custom_loaders_kra_v2.rs`) has this fallback but it's separate.
|
||||
|
||||
**Fix:** Add `layers.xml` as a secondary attempt in `try_import_krita_maindoc`.
|
||||
|
||||
### 7. Inconsistent `DATA` Line Format
|
||||
**File:** `hcie-kra/src/kra_saver.rs` lines 329 vs 415
|
||||
|
||||
`encode_layer_vers` writes `DATA {tiles.len()}\n` but `encode_mask_vers` writes `DATA\n`
|
||||
(no count). The parser handles both, but it's inconsistent.
|
||||
|
||||
**Fix:** Make both use `DATA\n` (no count) — the parser ignores the count anyway, and
|
||||
removing it avoids an extra format requirement.
|
||||
|
||||
---
|
||||
|
||||
## Implementation Tasks
|
||||
|
||||
### Task A: Fix VERS header and compression (`kra_saver.rs`)
|
||||
1. Change `writeln!(output, "VERSION 2")` → `writeln!(output, "VERSION 1")` in `encode_layer_vers`
|
||||
2. Change `b"VERSION 2\n"` → `b"VERSION 1\n"` in `encode_mask_vers`
|
||||
3. Remove the fake LZF compression loop in `encode_layer_vers` (lines 308–316)
|
||||
4. Replace with writing raw planar pixel data directly (no version byte, no control bytes)
|
||||
5. Update tile header tag from `"LZF"` to `"RAW"` (line 333)
|
||||
6. Repeat steps 3–5 for `encode_mask_vers` (lines 394–403)
|
||||
|
||||
### Task B: Fix UUID format (`kra_saver.rs`)
|
||||
7. Replace `let uuid = format!("{{{:032x}}}", layer.id)` with proper 128-bit UUID:
|
||||
```rust
|
||||
let high = rand::random::<u64>();
|
||||
let low = rand::random::<u64>();
|
||||
let uuid = format!(
|
||||
"{{{:08x}-{:04x}-{:04x}-{:04x}-{:012x}}}",
|
||||
(high >> 32) as u32,
|
||||
(high >> 16) as u16 & 0xFFFF,
|
||||
high as u16,
|
||||
(low >> 48) as u16,
|
||||
low & 0x0000_FFFF_FFFF_FFFF
|
||||
);
|
||||
```
|
||||
8. Apply same fix to mask UUID generation on line 257
|
||||
|
||||
### Task C: Add color space validation (`lib.rs`)
|
||||
9. In `try_import_krita_maindoc`, after reading width/height from `<IMAGE>`:
|
||||
- Read `colorspacename` and `channeldepth` attributes
|
||||
- If `colorspacename != "RGBA"`, return error: "Unsupported color space: {name}"
|
||||
- If `channeldepth != "U8"`, return error: "Unsupported channel depth: {depth}"
|
||||
- (Future: add U16→U8 conversion)
|
||||
|
||||
### Task D: Add error logging (`lib.rs`)
|
||||
10. Add `log::warn!` before each `continue` / silent fallback:
|
||||
- Line 790 (layer file not found): warn with layer name
|
||||
- Line 808 (SVG rasterization failed): warn with layer name
|
||||
- Line 814 (VERS parse failed): warn with layer name
|
||||
- Line 837 (image load failed): warn with layer name
|
||||
|
||||
### Task E: Add `layers.xml` fallback (`lib.rs`)
|
||||
11. In `try_import_krita_maindoc`, after the `maindoc.xml` attempt fails, try
|
||||
`archive.by_name("layers.xml")` before returning `Ok(vec![])`.
|
||||
|
||||
### Task F: Normalize `DATA` line format (`kra_saver.rs`)
|
||||
12. Change line 329: `writeln!(output, "DATA {}", tiles.len())` → `writeln!(output, "DATA")`
|
||||
13. The mask encoder already uses `b"DATA\n"` — leave unchanged.
|
||||
|
||||
---
|
||||
|
||||
## Files to Modify
|
||||
|
||||
| File | Changes |
|
||||
|------|---------|
|
||||
| `hcie-kra/src/kra_saver.rs` | Tasks A, B, F |
|
||||
| `hcie-kra/src/lib.rs` | Tasks C, D, E |
|
||||
|
||||
---
|
||||
|
||||
## Validation
|
||||
|
||||
After implementing, verify with:
|
||||
|
||||
1. **Roundtrip test:** Save a multi-layer document as KRA, then import it back. Compare
|
||||
pixels and layer names against the original.
|
||||
|
||||
2. **Krita compatibility:** Open the exported KRA in Krita ≥ 5.x. Verify it opens without
|
||||
crash and displays correct colors. Also open the PSD→KRA exported file and verify it
|
||||
matches the mergedimage.png preview.
|
||||
|
||||
3. **Existing test suite:** Run `cargo test -p hcie-kra` and
|
||||
`cargo test -p hcie-engine-api --test visual_regression` to ensure no regressions.
|
||||
|
||||
4. **Edge cases:**
|
||||
- Empty layers (all-transparent pixels)
|
||||
- Layers with masks
|
||||
- Group layers
|
||||
- Layer names containing special characters (`&`, `<`, `>`, Unicode)
|
||||
- Error on unsupported color space (e.g., CMYK, Grayscale)
|
||||
- Error on unsupported bit depth (e.g., U16, F16)
|
||||
@@ -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`.
|
||||
@@ -0,0 +1 @@
|
||||
,dev-user,hc-20w1s30u2j,24.07.2026 16:44,file:///home/dev-user/.config/libreoffice/4;
|
||||
@@ -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
|
||||
|
||||
|
||||
Generated
+168
-1
@@ -133,6 +133,12 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anes"
|
||||
version = "0.1.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299"
|
||||
|
||||
[[package]]
|
||||
name = "anstream"
|
||||
version = "1.0.0"
|
||||
@@ -759,6 +765,12 @@ dependencies = [
|
||||
"wayland-client",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cast"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5"
|
||||
|
||||
[[package]]
|
||||
name = "cc"
|
||||
version = "1.2.65"
|
||||
@@ -798,6 +810,58 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ciborium"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e"
|
||||
dependencies = [
|
||||
"ciborium-io",
|
||||
"ciborium-ll",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ciborium-io"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757"
|
||||
|
||||
[[package]]
|
||||
name = "ciborium-ll"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9"
|
||||
dependencies = [
|
||||
"ciborium-io",
|
||||
"half",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap"
|
||||
version = "4.6.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d91e0c145792ef73a6ad36d27c75ac09f1832222a3c209689d90f534685ee5b7"
|
||||
dependencies = [
|
||||
"clap_builder",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap_builder"
|
||||
version = "4.6.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f09628afdcc538b57f3c6341e9c8e9970f18e4a481690a64974d7023bd33548b"
|
||||
dependencies = [
|
||||
"anstyle",
|
||||
"clap_lex",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap_lex"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
|
||||
|
||||
[[package]]
|
||||
name = "clipboard-win"
|
||||
version = "5.4.1"
|
||||
@@ -1027,6 +1091,42 @@ dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "criterion"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f2b12d017a929603d80db1831cd3a24082f8137ce19c69e6447f54f5fc8d692f"
|
||||
dependencies = [
|
||||
"anes",
|
||||
"cast",
|
||||
"ciborium",
|
||||
"clap",
|
||||
"criterion-plot",
|
||||
"is-terminal",
|
||||
"itertools 0.10.5",
|
||||
"num-traits",
|
||||
"once_cell",
|
||||
"oorandom",
|
||||
"plotters",
|
||||
"rayon",
|
||||
"regex",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"tinytemplate",
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "criterion-plot"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6b50826342786a51a89e2da3a28f1c32b06e387201bc2d19791f622c673706b1"
|
||||
dependencies = [
|
||||
"cast",
|
||||
"itertools 0.10.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-deque"
|
||||
version = "0.8.6"
|
||||
@@ -2651,6 +2751,7 @@ name = "hcie-engine-api"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"approx",
|
||||
"criterion",
|
||||
"dirs 6.0.0",
|
||||
"hcie-blend",
|
||||
"hcie-brush-engine",
|
||||
@@ -2897,6 +2998,8 @@ name = "hcie-text"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"approx",
|
||||
"eframe",
|
||||
"egui",
|
||||
"fontdue",
|
||||
"hcie-protocol",
|
||||
"proptest",
|
||||
@@ -3551,12 +3654,32 @@ version = "2.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2"
|
||||
|
||||
[[package]]
|
||||
name = "is-terminal"
|
||||
version = "0.4.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46"
|
||||
dependencies = [
|
||||
"hermit-abi",
|
||||
"libc",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "is_terminal_polyfill"
|
||||
version = "1.70.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
|
||||
|
||||
[[package]]
|
||||
name = "itertools"
|
||||
version = "0.10.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473"
|
||||
dependencies = [
|
||||
"either",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itertools"
|
||||
version = "0.14.0"
|
||||
@@ -4617,6 +4740,12 @@ version = "1.70.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
|
||||
|
||||
[[package]]
|
||||
name = "oorandom"
|
||||
version = "11.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e"
|
||||
|
||||
[[package]]
|
||||
name = "openssl"
|
||||
version = "0.10.81"
|
||||
@@ -4951,6 +5080,34 @@ version = "0.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
|
||||
|
||||
[[package]]
|
||||
name = "plotters"
|
||||
version = "0.3.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5aeb6f403d7a4911efb1e33402027fc44f29b5bf6def3effcc22d7bb75f2b747"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
"plotters-backend",
|
||||
"plotters-svg",
|
||||
"wasm-bindgen",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "plotters-backend"
|
||||
version = "0.3.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df42e13c12958a16b3f7f4386b9ab1f3e7933914ecea48da7139435263a4172a"
|
||||
|
||||
[[package]]
|
||||
name = "plotters-svg"
|
||||
version = "0.3.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "51bae2ac328883f7acdfea3d66a7c35751187f870bc81f94563733a154d7a670"
|
||||
dependencies = [
|
||||
"plotters-backend",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "png"
|
||||
version = "0.17.16"
|
||||
@@ -5331,7 +5488,7 @@ dependencies = [
|
||||
"built",
|
||||
"cfg-if",
|
||||
"interpolate_name",
|
||||
"itertools",
|
||||
"itertools 0.14.0",
|
||||
"libc",
|
||||
"libfuzzer-sys",
|
||||
"log",
|
||||
@@ -6517,6 +6674,16 @@ dependencies = [
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tinytemplate"
|
||||
version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "be4d6b5f19ff7664e8c98d03e2139cb510db9b0a60b55f8e8709b689d939b6bc"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tinyvec"
|
||||
version = "1.11.0"
|
||||
|
||||
+1
-1
@@ -102,7 +102,7 @@ approx = "0.5"
|
||||
|
||||
[profile.dev]
|
||||
opt-level = 1
|
||||
codegen-units = 1
|
||||
codegen-units = 256
|
||||
|
||||
[profile.dev.package."*"]
|
||||
opt-level = 3
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
# Canvas Performance Regression Protection — Walkthrough
|
||||
|
||||
HCIE v4 engine API'si üzerindeki 4K tuval optimizasyonlarının yanlışlıkla bozulmasını (regresyon) engellemek için `criterion` kütüphanesi entegre edildi ve dört kritik mekanizma için kalıcı benchmark testleri yazıldı.
|
||||
|
||||
## Neler Yapıldı?
|
||||
|
||||
1. **`hcie-engine-api/Cargo.toml` Güncellemesi**
|
||||
- `criterion` kütüphanesi dev-dependency olarak eklendi.
|
||||
- Her benchmark için özel `[[bench]]` kayıtları tanımlandı.
|
||||
|
||||
2. **`active_stroke_mask` Pooling Koruması**
|
||||
- [benches/stroke_mask_pooling.rs](file:///mnt/extra/00_PROJECTS/hcie-rust-v3.05/hcie-engine-api/benches/stroke_mask_pooling.rs)
|
||||
- Brush engine'in kullandığı ~8 MB mask buffer'ın `end_stroke()` sırasında serbest bırakılıp bırakılmadığını kontrol eder.
|
||||
- İlk tahsis (cold) ile sonraki ardışık strokların (warm) sürelerini karşılaştırır.
|
||||
|
||||
3. **`composite_scratch` Pooling Koruması**
|
||||
- [benches/composite_scratch_pooling.rs](file:///mnt/extra/00_PROJECTS/hcie-rust-v3.05/hcie-engine-api/benches/composite_scratch_pooling.rs)
|
||||
- ~33 MB composite render buffer'ın `render_composite_region()` içinde yeniden kullanıldığını doğrular.
|
||||
|
||||
4. **`below_cache` Ardışık Stroke Reuse Koruması**
|
||||
- [benches/below_cache_reuse.rs](file:///mnt/extra/00_PROJECTS/hcie-rust-v3.05/hcie-engine-api/benches/below_cache_reuse.rs)
|
||||
- Aynı katmanda birden çok stroke atıldığında alttaki katmanların yeniden derlenmek yerine (cache rebuild) mevcut cache'in kullanıldığını test eder.
|
||||
- 4K çözünürlükte, 10 katmanlı senaryoda cache build (ilk vuruş) ve cache reuse (ikinci vuruş) maliyetlerini ayırır.
|
||||
|
||||
5. **`effects_dirty` Conditional Skip Koruması**
|
||||
- [benches/effects_skip.rs](file:///mnt/extra/00_PROJECTS/hcie-rust-v3.05/hcie-engine-api/benches/effects_skip.rs)
|
||||
- Efekt/stili olmayan katmanlarda pahalı effects pipeline (ve 33 MB buffer kopyası) adımının bypass edildiğinden emin olur.
|
||||
|
||||
## Doğrulama Sonuçları
|
||||
|
||||
- Yeni criterion testlerinin API erişimleri (özellikle `update_layer_style` fonksiyon imzası) ve bağımlılıkları derlenip onaylandı.
|
||||
- Eski golden testler olan `visual_regression.rs` çalıştırılarak yeni performans testlerinin deterministik render sonuçlarını (pixel-perfect) bozmadığı **doğrulandı** (8/8 başarılı).
|
||||
- Eski performans testi `performance_stroke_4k.rs` de sorunsuz çalıştırıldı.
|
||||
|
||||
> [!TIP]
|
||||
> **Tüm Benchmark'ları Çalıştırmak İçin:**
|
||||
> Terminal üzerinden engine dizinine giderek şu komutu kullanabilirsiniz:
|
||||
> ```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 `./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._
|
||||
@@ -16,6 +16,6 @@ crate-type = ["staticlib", "rlib"]
|
||||
rstest = "0.23"
|
||||
proptest = "1.5"
|
||||
approx = "0.5"
|
||||
egui = "0.34"
|
||||
eframe = { version = "0.34", default-features = false, features = ["default_fonts", "glow"] }
|
||||
egui = { workspace = true }
|
||||
eframe = { workspace = true }
|
||||
image = { version = "0.25", default-features = false, features = ["png", "jpeg"] }
|
||||
|
||||
@@ -15,5 +15,5 @@ crate-type = ["staticlib", "rlib"]
|
||||
rstest = "0.23"
|
||||
proptest = "1.5"
|
||||
approx = "0.5"
|
||||
egui = "0.34"
|
||||
eframe = { version = "0.34", default-features = false, features = ["default_fonts", "glow", "x11"] }
|
||||
egui = { workspace = true }
|
||||
eframe = { workspace = true }
|
||||
|
||||
@@ -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;
|
||||
|
||||
+40
-14
@@ -384,6 +384,16 @@ pub fn composite_tiled_into(
|
||||
}
|
||||
}
|
||||
|
||||
/// Applies one adjustment-layer pixel to the current composite row.
|
||||
///
|
||||
/// **Purpose:** Evaluates curves, gradient-map, or hue/saturation data while
|
||||
/// respecting masks, opacity, and blend mode. **Logic & Workflow:** Transparent
|
||||
/// or masked-out destinations exit early; Normal blend bypasses the generic
|
||||
/// blend dispatcher, and fully opaque adjustment pixels are assigned directly.
|
||||
/// **Arguments:** `row` is the destination row, `x`/`gy` are canvas coordinates,
|
||||
/// `layer` owns adjustment and mask data, and `blend`/`opacity` control mixing.
|
||||
/// **Returns:** Nothing. **Side Effects / Dependencies:** Mutates only the RGB
|
||||
/// channels of one destination pixel and uses `hcie-blend` for non-Normal modes.
|
||||
#[inline]
|
||||
fn blend_adjustment_pixel(
|
||||
row: &mut [u8],
|
||||
@@ -435,20 +445,36 @@ fn blend_adjustment_pixel(
|
||||
};
|
||||
|
||||
let eff_opacity = opacity * (mask_val as f32 / 255.0);
|
||||
let blended_opaque = blend_pixels(
|
||||
[dst[0], dst[1], dst[2], 255],
|
||||
[adj_rgb[0], adj_rgb[1], adj_rgb[2], 255],
|
||||
blend,
|
||||
1.0,
|
||||
);
|
||||
row[out_idx] = ((1.0 - eff_opacity) * dst[0] as f32 + eff_opacity * blended_opaque[0] as f32)
|
||||
.round() as u8;
|
||||
row[out_idx + 1] = ((1.0 - eff_opacity) * dst[1] as f32
|
||||
+ eff_opacity * blended_opaque[1] as f32)
|
||||
.round() as u8;
|
||||
row[out_idx + 2] = ((1.0 - eff_opacity) * dst[2] as f32
|
||||
+ eff_opacity * blended_opaque[2] as f32)
|
||||
.round() as u8;
|
||||
if eff_opacity <= 0.0 {
|
||||
return;
|
||||
}
|
||||
|
||||
let blended_rgb = if blend == hcie_blend::BlendMode::Normal {
|
||||
adj_rgb
|
||||
} else {
|
||||
let blended = blend_pixels(
|
||||
[dst[0], dst[1], dst[2], 255],
|
||||
[adj_rgb[0], adj_rgb[1], adj_rgb[2], 255],
|
||||
blend,
|
||||
1.0,
|
||||
);
|
||||
[blended[0], blended[1], blended[2]]
|
||||
};
|
||||
|
||||
if eff_opacity >= 1.0 {
|
||||
row[out_idx] = blended_rgb[0];
|
||||
row[out_idx + 1] = blended_rgb[1];
|
||||
row[out_idx + 2] = blended_rgb[2];
|
||||
return;
|
||||
}
|
||||
|
||||
let inverse_opacity = 1.0 - eff_opacity;
|
||||
row[out_idx] =
|
||||
(inverse_opacity * dst[0] as f32 + eff_opacity * blended_rgb[0] as f32).round() as u8;
|
||||
row[out_idx + 1] =
|
||||
(inverse_opacity * dst[1] as f32 + eff_opacity * blended_rgb[1] as f32).round() as u8;
|
||||
row[out_idx + 2] =
|
||||
(inverse_opacity * dst[2] as f32 + eff_opacity * blended_rgb[2] as f32).round() as u8;
|
||||
}
|
||||
|
||||
#[inline]
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
//! Regression coverage for optimized Normal-blend adjustment compositing.
|
||||
//!
|
||||
//! **Purpose:** Verifies that bypassing the generic blend dispatcher preserves
|
||||
//! exact RGB output for opaque and partially opaque adjustments.
|
||||
//! **Logic & Workflow:** Builds a one-pixel base and Curves layer, composites
|
||||
//! through the public tiled path, and compares deterministic channel values.
|
||||
//! **Side Effects / Dependencies:** Allocates only one-pixel protocol layers.
|
||||
|
||||
use hcie_blend::Adjustment;
|
||||
use hcie_composite::tiled::composite_tiled;
|
||||
use hcie_protocol::Layer;
|
||||
use hcie_tile::TiledLayer;
|
||||
|
||||
/// Builds a Curves adjustment whose lookup tables map selected input channels.
|
||||
///
|
||||
/// **Arguments:** `opacity` controls the layer-level adjustment mix.
|
||||
/// **Returns:** A one-pixel adjustment layer. **Side Effects / Dependencies:** None.
|
||||
fn curves_layer(opacity: f32) -> Layer {
|
||||
let mut lut_r: Vec<u8> = (0..=255).map(|value| value as u8).collect();
|
||||
let mut lut_g = lut_r.clone();
|
||||
let mut lut_b = lut_r.clone();
|
||||
lut_r[10] = 100;
|
||||
lut_g[20] = 110;
|
||||
lut_b[30] = 120;
|
||||
|
||||
let mut layer = Layer::new_transparent("Curves", 1, 1);
|
||||
layer.adjustment = Some(Adjustment::Curves {
|
||||
lut_r,
|
||||
lut_g,
|
||||
lut_b,
|
||||
});
|
||||
layer.opacity = opacity;
|
||||
layer
|
||||
}
|
||||
|
||||
/// Confirms a fully opaque Normal adjustment assigns the LUT result exactly.
|
||||
#[test]
|
||||
fn opaque_normal_adjustment_matches_lookup_result() {
|
||||
let base = Layer::from_rgba("Base", 1, 1, vec![10, 20, 30, 255]);
|
||||
let adjustment = curves_layer(1.0);
|
||||
let tiles = vec![
|
||||
Some(TiledLayer::from_dense(&base.pixels, 1, 1)),
|
||||
Some(TiledLayer::from_dense(&adjustment.pixels, 1, 1)),
|
||||
];
|
||||
|
||||
let output = composite_tiled(&[base, adjustment], &tiles, 1, 1);
|
||||
|
||||
assert_eq!(output, vec![100, 110, 120, 255]);
|
||||
}
|
||||
|
||||
/// Confirms partial opacity retains the previous rounded interpolation behavior.
|
||||
#[test]
|
||||
fn partial_normal_adjustment_preserves_rounded_interpolation() {
|
||||
let base = Layer::from_rgba("Base", 1, 1, vec![10, 20, 30, 255]);
|
||||
let adjustment = curves_layer(0.5);
|
||||
let tiles = vec![
|
||||
Some(TiledLayer::from_dense(&base.pixels, 1, 1)),
|
||||
Some(TiledLayer::from_dense(&adjustment.pixels, 1, 1)),
|
||||
];
|
||||
|
||||
let output = composite_tiled(&[base, adjustment], &tiles, 1, 1);
|
||||
|
||||
assert_eq!(output, vec![55, 65, 75, 255]);
|
||||
}
|
||||
@@ -307,6 +307,29 @@ impl Document {
|
||||
}));
|
||||
}
|
||||
|
||||
pub fn push_mask_draw_snapshot_subrect(
|
||||
&mut self,
|
||||
layer_idx: usize,
|
||||
before_pixels: Vec<u8>,
|
||||
after_pixels: Vec<u8>,
|
||||
bounds: (u32, u32, u32, u32),
|
||||
description: String,
|
||||
) {
|
||||
if layer_idx >= self.layers.len() {
|
||||
return;
|
||||
}
|
||||
if before_pixels == after_pixels {
|
||||
return;
|
||||
}
|
||||
self.history.push(Box::new(MaskDrawAction {
|
||||
layer_index: layer_idx,
|
||||
before_pixels,
|
||||
after_pixels,
|
||||
bounds: Some([bounds.0, bounds.1, bounds.2, bounds.3]),
|
||||
description,
|
||||
}));
|
||||
}
|
||||
|
||||
/// Legacy full-layer snapshot (used by non-brush operations: filter, fill, etc.)
|
||||
pub fn push_draw_snapshot(
|
||||
&mut self,
|
||||
@@ -339,6 +362,31 @@ impl Document {
|
||||
}));
|
||||
}
|
||||
|
||||
pub fn push_mask_draw_snapshot(
|
||||
&mut self,
|
||||
layer_idx: usize,
|
||||
before_pixels: Vec<u8>,
|
||||
description: String,
|
||||
) {
|
||||
if layer_idx >= self.layers.len() {
|
||||
return;
|
||||
}
|
||||
let layer = &self.layers[layer_idx];
|
||||
if let Some(ref mask) = layer.mask_pixels {
|
||||
let after_pixels = mask.clone();
|
||||
if before_pixels == after_pixels {
|
||||
return;
|
||||
}
|
||||
self.history.push(Box::new(MaskDrawAction {
|
||||
layer_index: layer_idx,
|
||||
before_pixels,
|
||||
after_pixels,
|
||||
bounds: None,
|
||||
description,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
/// Clears the undo/redo history stack.
|
||||
///
|
||||
/// **Purpose:** Removes all recorded actions so a freshly loaded document
|
||||
@@ -735,6 +783,76 @@ impl UndoableAction<Vec<Layer>> for DrawAction {
|
||||
}
|
||||
}
|
||||
|
||||
struct MaskDrawAction {
|
||||
layer_index: usize,
|
||||
before_pixels: Vec<u8>,
|
||||
after_pixels: Vec<u8>,
|
||||
bounds: Option<[u32; 4]>,
|
||||
description: String,
|
||||
}
|
||||
|
||||
impl UndoableAction<Vec<Layer>> for MaskDrawAction {
|
||||
fn undo(&mut self, layers: &mut Vec<Layer>) {
|
||||
if let Some(layer) = layers.get_mut(self.layer_index) {
|
||||
if layer.mask_pixels.is_none() {
|
||||
layer.mask_pixels = Some(vec![255; (layer.width * layer.height) as usize]);
|
||||
}
|
||||
if let Some(ref mut mask) = layer.mask_pixels {
|
||||
if let Some([x0, y0, x1, y1]) = self.bounds {
|
||||
let dst_w = layer.width;
|
||||
let rw = x1 - x0;
|
||||
for row in 0..(y1 - y0) {
|
||||
let src_start = (row * rw) as usize;
|
||||
let dst_start = (((y0 + row) * dst_w + x0)) as usize;
|
||||
let len = (rw) as usize;
|
||||
if src_start + len <= self.before_pixels.len()
|
||||
&& dst_start + len <= mask.len()
|
||||
{
|
||||
mask[dst_start..dst_start + len]
|
||||
.copy_from_slice(&self.before_pixels[src_start..src_start + len]);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
mask.clone_from(&self.before_pixels);
|
||||
}
|
||||
}
|
||||
layer.dirty = true;
|
||||
layer.effects_dirty.store(true, std::sync::atomic::Ordering::Release);
|
||||
}
|
||||
}
|
||||
fn redo(&mut self, layers: &mut Vec<Layer>) {
|
||||
if let Some(layer) = layers.get_mut(self.layer_index) {
|
||||
if layer.mask_pixels.is_none() {
|
||||
layer.mask_pixels = Some(vec![255; (layer.width * layer.height) as usize]);
|
||||
}
|
||||
if let Some(ref mut mask) = layer.mask_pixels {
|
||||
if let Some([x0, y0, x1, y1]) = self.bounds {
|
||||
let dst_w = layer.width;
|
||||
let rw = x1 - x0;
|
||||
for row in 0..(y1 - y0) {
|
||||
let src_start = (row * rw) as usize;
|
||||
let dst_start = (((y0 + row) * dst_w + x0)) as usize;
|
||||
let len = (rw) as usize;
|
||||
if src_start + len <= self.after_pixels.len()
|
||||
&& dst_start + len <= mask.len()
|
||||
{
|
||||
mask[dst_start..dst_start + len]
|
||||
.copy_from_slice(&self.after_pixels[src_start..src_start + len]);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
mask.clone_from(&self.after_pixels);
|
||||
}
|
||||
}
|
||||
layer.dirty = true;
|
||||
layer.effects_dirty.store(true, std::sync::atomic::Ordering::Release);
|
||||
}
|
||||
}
|
||||
fn description(&self) -> String {
|
||||
self.description.clone()
|
||||
}
|
||||
}
|
||||
|
||||
struct LayerVisibilityAction {
|
||||
layer_index: usize,
|
||||
old_visible: bool,
|
||||
|
||||
@@ -16,5 +16,5 @@ crate-type = ["staticlib", "rlib"]
|
||||
rstest = "0.23"
|
||||
proptest = "1.5"
|
||||
approx = "0.5"
|
||||
egui = "0.34"
|
||||
eframe = { version = "0.34", default-features = false, features = ["default_fonts", "glow"] }
|
||||
egui = { workspace = true }
|
||||
eframe = { workspace = true }
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -6,6 +6,7 @@ use eframe::egui;
|
||||
use hcie_engine_api::{LayerData, Tool, VectorEditHandle, VectorShape, ZOOM_MAX, ZOOM_MIN};
|
||||
use std::hash::{Hash, Hasher};
|
||||
|
||||
pub mod perf;
|
||||
pub mod render;
|
||||
pub mod text_overlay;
|
||||
|
||||
@@ -299,6 +300,9 @@ impl<'a> egui::Widget for CanvasWidget<'a> {
|
||||
));
|
||||
return response;
|
||||
}
|
||||
let input_at = std::time::Instant::now();
|
||||
perf::begin_stroke(input_at);
|
||||
perf::record_input(input_at);
|
||||
// Send current brush state to engine before stroke
|
||||
self.doc.engine.set_color(self.state.primary_color);
|
||||
let mut tip = hcie_engine_api::BrushTip::default();
|
||||
@@ -487,6 +491,7 @@ impl<'a> egui::Widget for CanvasWidget<'a> {
|
||||
self.state.brush_accumulated_dist = 0.0;
|
||||
let layer_id = self.doc.engine.active_layer_id();
|
||||
if self.state.active_tool != Tool::Pen {
|
||||
let engine_start = std::time::Instant::now();
|
||||
self.doc.engine.begin_stroke(layer_id, ux as f32, uy as f32);
|
||||
// Draw single dab on click (even without movement).
|
||||
// The spacing check in the drag handler prevents
|
||||
@@ -497,6 +502,7 @@ impl<'a> egui::Widget for CanvasWidget<'a> {
|
||||
uy as f32,
|
||||
self.state.current_pressure,
|
||||
);
|
||||
perf::record_duration("engine_draw", engine_start.elapsed());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -915,6 +921,9 @@ impl<'a> egui::Widget for CanvasWidget<'a> {
|
||||
match self.state.active_tool {
|
||||
Tool::Pen => {
|
||||
if let Some((lx, ly)) = self.state.last_draw_pos {
|
||||
let input_at = std::time::Instant::now();
|
||||
perf::record_input(input_at);
|
||||
let engine_start = std::time::Instant::now();
|
||||
let layer_id = self.doc.engine.active_layer_id();
|
||||
if !self.doc.engine.is_stroke_active() {
|
||||
self.doc
|
||||
@@ -929,6 +938,7 @@ impl<'a> egui::Widget for CanvasWidget<'a> {
|
||||
uy as f32,
|
||||
self.state.current_pressure,
|
||||
);
|
||||
perf::record_duration("engine_draw", engine_start.elapsed());
|
||||
self.state.last_draw_pos = Some((ux, uy));
|
||||
}
|
||||
}
|
||||
@@ -947,6 +957,9 @@ impl<'a> egui::Widget for CanvasWidget<'a> {
|
||||
}
|
||||
.max(1.0);
|
||||
if dx >= spacing {
|
||||
let input_at = std::time::Instant::now();
|
||||
perf::record_input(input_at);
|
||||
let engine_start = std::time::Instant::now();
|
||||
let layer_id = self.doc.engine.active_layer_id();
|
||||
self.doc.engine.stroke_to(
|
||||
layer_id,
|
||||
@@ -954,6 +967,10 @@ impl<'a> egui::Widget for CanvasWidget<'a> {
|
||||
uy as f32,
|
||||
self.state.current_pressure,
|
||||
);
|
||||
perf::record_duration(
|
||||
"engine_draw",
|
||||
engine_start.elapsed(),
|
||||
);
|
||||
self.state.last_draw_pos = Some((ux, uy));
|
||||
}
|
||||
}
|
||||
@@ -1223,6 +1240,7 @@ impl<'a> egui::Widget for CanvasWidget<'a> {
|
||||
|
||||
self.event_bus.push(AppEvent::RenderRequested);
|
||||
self.event_bus.push(AppEvent::DrawingFinished);
|
||||
perf::request_finish();
|
||||
}
|
||||
Tool::Select => {
|
||||
if let Some(rect) = self.state.selection_rect {
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
//! Opt-in canvas performance probe for the egui reference renderer.
|
||||
//!
|
||||
//! **Purpose:** Produces the same stage names as the iced canvas diagnostics so
|
||||
//! immediate-mode and reactive pipelines can be compared on one machine.
|
||||
//! **Logic & Workflow:** `HCIE_CANVAS_PERF=1` enables per-stroke duration and byte
|
||||
//! collection; the first texture update after release emits p50/p95/max values.
|
||||
//! **Side Effects / Dependencies:** Uses a process-wide mutex and the `log` crate.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// Mutable measurements for one egui stroke.
|
||||
#[derive(Default)]
|
||||
struct PerfState {
|
||||
stroke: u64,
|
||||
active: bool,
|
||||
finish_pending: bool,
|
||||
latest_input: Option<Instant>,
|
||||
samples: BTreeMap<&'static str, Vec<f64>>,
|
||||
copied_bytes: u64,
|
||||
uploaded_bytes: u64,
|
||||
visible_updates: u64,
|
||||
}
|
||||
|
||||
/// Returns whether `HCIE_CANVAS_PERF` enables diagnostics.
|
||||
pub fn enabled() -> bool {
|
||||
static ENABLED: OnceLock<bool> = OnceLock::new();
|
||||
*ENABLED.get_or_init(|| {
|
||||
std::env::var("HCIE_CANVAS_PERF")
|
||||
.map(|value| matches!(value.to_ascii_lowercase().as_str(), "1" | "true" | "yes"))
|
||||
.unwrap_or(false)
|
||||
})
|
||||
}
|
||||
|
||||
/// Starts a new per-stroke sample window.
|
||||
pub fn begin_stroke(input_at: Instant) {
|
||||
if !enabled() {
|
||||
return;
|
||||
}
|
||||
with_state(|state| {
|
||||
state.stroke = state.stroke.wrapping_add(1);
|
||||
state.active = true;
|
||||
state.finish_pending = false;
|
||||
state.latest_input = Some(input_at);
|
||||
state.samples.clear();
|
||||
state.copied_bytes = 0;
|
||||
state.uploaded_bytes = 0;
|
||||
state.visible_updates = 0;
|
||||
});
|
||||
}
|
||||
|
||||
/// Records the latest pixel-changing input and its immediate dispatch delay.
|
||||
pub fn record_input(input_at: Instant) {
|
||||
if !enabled() {
|
||||
return;
|
||||
}
|
||||
with_state(|state| {
|
||||
if state.active {
|
||||
state.latest_input = Some(input_at);
|
||||
state
|
||||
.samples
|
||||
.entry("input_to_update")
|
||||
.or_default()
|
||||
.push(input_at.elapsed().as_secs_f64() * 1000.0);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Records one named stage duration.
|
||||
pub fn record_duration(stage: &'static str, duration: Duration) {
|
||||
if !enabled() {
|
||||
return;
|
||||
}
|
||||
with_state(|state| {
|
||||
if state.active {
|
||||
state
|
||||
.samples
|
||||
.entry(stage)
|
||||
.or_default()
|
||||
.push(duration.as_secs_f64() * 1000.0);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Records CPU copy and texture payload bytes for one visible update.
|
||||
pub fn record_transfer(copied_bytes: usize, uploaded_bytes: usize) {
|
||||
if !enabled() {
|
||||
return;
|
||||
}
|
||||
with_state(|state| {
|
||||
if state.active {
|
||||
state.copied_bytes = state.copied_bytes.saturating_add(copied_bytes as u64);
|
||||
state.uploaded_bytes = state.uploaded_bytes.saturating_add(uploaded_bytes as u64);
|
||||
state.visible_updates += 1;
|
||||
if let Some(input) = state.latest_input {
|
||||
state
|
||||
.samples
|
||||
.entry("input_to_texture_update_proxy")
|
||||
.or_default()
|
||||
.push(input.elapsed().as_secs_f64() * 1000.0);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Requests summary emission after the final texture update.
|
||||
pub fn request_finish() {
|
||||
if enabled() {
|
||||
with_state(|state| state.finish_pending = state.active);
|
||||
}
|
||||
}
|
||||
|
||||
/// Emits the pending summary after a render pass has had an opportunity to upload.
|
||||
pub fn finish_after_render() {
|
||||
if !enabled() {
|
||||
return;
|
||||
}
|
||||
with_state(|state| {
|
||||
if !state.active || !state.finish_pending {
|
||||
return;
|
||||
}
|
||||
state.active = false;
|
||||
state.finish_pending = false;
|
||||
let mut stages = Vec::new();
|
||||
for (name, samples) in &mut state.samples {
|
||||
samples.sort_by(f64::total_cmp);
|
||||
if let Some(max) = samples.last().copied() {
|
||||
stages.push(format!(
|
||||
"{}[n={},p50={:.3}ms,p95={:.3}ms,max={:.3}ms]",
|
||||
name,
|
||||
samples.len(),
|
||||
percentile(samples, 0.50),
|
||||
percentile(samples, 0.95),
|
||||
max
|
||||
));
|
||||
}
|
||||
}
|
||||
log::info!(
|
||||
target: "hcie_canvas_perf",
|
||||
"egui stroke={} {} counters[copied={}B,uploaded={}B,visible_updates={}]",
|
||||
state.stroke,
|
||||
stages.join(" "),
|
||||
state.copied_bytes,
|
||||
state.uploaded_bytes,
|
||||
state.visible_updates
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/// Selects the nearest sample for a percentile from an already sorted slice.
|
||||
fn percentile(samples: &[f64], fraction: f64) -> f64 {
|
||||
if samples.is_empty() {
|
||||
return 0.0;
|
||||
}
|
||||
let index = ((samples.len() - 1) as f64 * fraction.clamp(0.0, 1.0)).round() as usize;
|
||||
samples[index]
|
||||
}
|
||||
|
||||
/// Runs an operation while holding the diagnostic accumulator lock.
|
||||
fn with_state<T>(operation: impl FnOnce(&mut PerfState) -> T) -> T {
|
||||
static STATE: OnceLock<Mutex<PerfState>> = OnceLock::new();
|
||||
let mut state = STATE
|
||||
.get_or_init(|| Mutex::new(PerfState::default()))
|
||||
.lock()
|
||||
.unwrap_or_else(|error| error.into_inner());
|
||||
operation(&mut state)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::percentile;
|
||||
|
||||
/// Confirms percentile output matches the iced probe for equivalent samples.
|
||||
#[test]
|
||||
fn percentile_uses_nearest_ranked_sample() {
|
||||
let samples = [1.0, 2.0, 3.0, 4.0, 5.0];
|
||||
assert_eq!(percentile(&samples, 0.50), 3.0);
|
||||
assert_eq!(percentile(&samples, 0.95), 5.0);
|
||||
}
|
||||
}
|
||||
@@ -169,13 +169,17 @@ pub fn render_composition(
|
||||
if needs_render {
|
||||
let t_composite_start = std::time::Instant::now();
|
||||
let (region_result, buf_ptr, buf_size) = doc.engine.render_composite_region();
|
||||
let composite_ms = t_composite_start.elapsed().as_millis();
|
||||
let composite_elapsed = t_composite_start.elapsed();
|
||||
let composite_ms = composite_elapsed.as_millis();
|
||||
super::perf::record_duration("render_composite_region", composite_elapsed);
|
||||
|
||||
let copy_start = std::time::Instant::now();
|
||||
if !buf_ptr.is_null() && buf_size > 0 && buf_size == doc.composite_buffer.len() {
|
||||
unsafe {
|
||||
std::ptr::copy_nonoverlapping(buf_ptr, doc.composite_buffer.as_mut_ptr(), buf_size);
|
||||
}
|
||||
}
|
||||
super::perf::record_duration("cpu_staging", copy_start.elapsed());
|
||||
|
||||
let region = match region_result {
|
||||
Some([x0, y0, x1, y1]) if x1 > x0 && y1 > y0 => [x0, y0, x1, y1],
|
||||
@@ -188,6 +192,8 @@ pub fn render_composition(
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let texture_start = std::time::Instant::now();
|
||||
let mut uploaded_bytes = 0usize;
|
||||
// Partial texture upload for the dirty region only
|
||||
if let Some(tex) = &mut doc.composite_texture {
|
||||
let size = tex.size();
|
||||
@@ -212,6 +218,7 @@ pub fn render_composition(
|
||||
®ion_pixels,
|
||||
);
|
||||
tex.set_partial([x0 as usize, y0 as usize], region_image, options);
|
||||
uploaded_bytes = region_pixels.len();
|
||||
}
|
||||
} else {
|
||||
let color_image = egui::ColorImage::from_rgba_unmultiplied(
|
||||
@@ -219,6 +226,7 @@ pub fn render_composition(
|
||||
&doc.composite_buffer,
|
||||
);
|
||||
tex.set(color_image, options);
|
||||
uploaded_bytes = doc.composite_buffer.len();
|
||||
}
|
||||
} else {
|
||||
let color_image = egui::ColorImage::from_rgba_unmultiplied(
|
||||
@@ -226,7 +234,10 @@ pub fn render_composition(
|
||||
&doc.composite_buffer,
|
||||
);
|
||||
doc.composite_texture = Some(ctx.load_texture("composite-view", color_image, options));
|
||||
uploaded_bytes = doc.composite_buffer.len();
|
||||
}
|
||||
super::perf::record_duration("texture_update", texture_start.elapsed());
|
||||
super::perf::record_transfer(buf_size, uploaded_bytes);
|
||||
|
||||
doc.thumbnails_dirty = true;
|
||||
doc.engine.clear_dirty_flags();
|
||||
@@ -265,6 +276,7 @@ pub fn render_composition(
|
||||
}
|
||||
}
|
||||
|
||||
super::perf::finish_after_render();
|
||||
false
|
||||
}
|
||||
|
||||
|
||||
@@ -40,3 +40,20 @@ rstest = "0.23"
|
||||
proptest = "1.5"
|
||||
approx = "0.5"
|
||||
sha2 = "0.10"
|
||||
criterion = { version = "0.5", features = ["html_reports"] }
|
||||
|
||||
[[bench]]
|
||||
name = "stroke_mask_pooling"
|
||||
harness = false
|
||||
|
||||
[[bench]]
|
||||
name = "composite_scratch_pooling"
|
||||
harness = false
|
||||
|
||||
[[bench]]
|
||||
name = "below_cache_reuse"
|
||||
harness = false
|
||||
|
||||
[[bench]]
|
||||
name = "effects_skip"
|
||||
harness = false
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
//! Criterion benchmark: `below_cache` reuse across consecutive strokes.
|
||||
//!
|
||||
//! ## Purpose
|
||||
//! Verifies that painting multiple strokes on the **same layer** reuses the
|
||||
//! pre-built `below_cache` (composite of all layers below the active one)
|
||||
//! instead of rebuilding it from scratch on every `begin_stroke()`.
|
||||
//!
|
||||
//! On a 4K 10-layer document the below-layer composite costs ~15–25 ms.
|
||||
//! If `below_cache_dirty` is incorrectly set to `true` after every
|
||||
//! `end_stroke()`, each subsequent stroke pays that cost again.
|
||||
//!
|
||||
//! ## Logic & Workflow
|
||||
//! 1. Creates a 3840×2160, 10-layer document (all filled).
|
||||
//! 2. **first_stroke**: Benchmarks a full stroke including below_cache build.
|
||||
//! 3. **second_stroke_same_layer**: Benchmarks a stroke on the same layer
|
||||
//! where `below_cache` should be reused (much cheaper `begin_stroke`).
|
||||
//!
|
||||
//! When the cache works correctly, the second stroke's `begin_stroke()` is
|
||||
//! essentially free (just a dirty-flag check), while the first one must
|
||||
//! composite 9 below-layers into a 33 MB buffer.
|
||||
//!
|
||||
//! ## Arguments & Returns
|
||||
//! Standard criterion benchmark — run with:
|
||||
//! ```bash
|
||||
//! cargo bench -p hcie-engine-api --bench below_cache_reuse
|
||||
//! ```
|
||||
//!
|
||||
//! ## Side Effects / Dependencies
|
||||
//! Allocates ~400 MB (10 layers × 4K RGBA + below_cache + composite_scratch).
|
||||
|
||||
use criterion::{criterion_group, criterion_main, Criterion};
|
||||
use hcie_engine_api::{BrushStyle, BrushTip, Engine};
|
||||
|
||||
/// Canvas dimensions — 4K UHD.
|
||||
const W: u32 = 3840;
|
||||
const H: u32 = 2160;
|
||||
/// Number of raster layers.
|
||||
const LAYERS: usize = 10;
|
||||
|
||||
/// Builds a 10-layer 4K document. The top layer is the active drawing target.
|
||||
///
|
||||
/// **Purpose:** Creates a worst-case scenario where the below-layer composite
|
||||
/// is expensive (9 filled layers below the active one).
|
||||
/// **Returns:** `(Engine, top_layer_id)`.
|
||||
fn setup_10layer_engine() -> (Engine, u64) {
|
||||
let mut engine = Engine::new(W, H);
|
||||
|
||||
let bg_id = engine.active_layer_id();
|
||||
engine.set_active_layer(bg_id);
|
||||
engine.draw_filled_rect_rgba(0, 0, W, H, [240, 240, 240, 255]);
|
||||
|
||||
let mut layer_ids = vec![bg_id];
|
||||
for i in 1..LAYERS {
|
||||
let id = engine.add_layer(&format!("Layer {}", i));
|
||||
engine.set_active_layer(id);
|
||||
let color = match i % 4 {
|
||||
0 => [180, 80, 80, 255],
|
||||
1 => [80, 180, 80, 255],
|
||||
2 => [80, 80, 180, 255],
|
||||
_ => [160, 140, 100, 255],
|
||||
};
|
||||
engine.draw_filled_rect_rgba(0, 0, W, H, color);
|
||||
layer_ids.push(id);
|
||||
}
|
||||
|
||||
let top_id = *layer_ids.last().unwrap();
|
||||
engine.set_active_layer(top_id);
|
||||
|
||||
let mut tip = BrushTip::default();
|
||||
tip.style = BrushStyle::Round;
|
||||
tip.size = 24.0;
|
||||
tip.opacity = 1.0;
|
||||
tip.hardness = 0.85;
|
||||
tip.spacing = 0.1;
|
||||
engine.set_brush_tip(tip);
|
||||
engine.set_color([220, 60, 60, 255]);
|
||||
engine.set_eraser(false);
|
||||
|
||||
(engine, top_id)
|
||||
}
|
||||
|
||||
/// Performs a short stroke: begin → 5 segments → composite → end.
|
||||
///
|
||||
/// **Purpose:** Exercises the full `begin_stroke()` (below_cache build/reuse)
|
||||
/// through `end_stroke()` lifecycle.
|
||||
/// **Arguments:** `engine` — mutable engine, `layer_id` — target layer,
|
||||
/// `offset` — slight position offset to avoid degenerate repeat painting.
|
||||
/// **Side Effects:** Mutates layer pixels, triggers composite, commits history.
|
||||
fn do_stroke(engine: &mut Engine, layer_id: u64, offset: f32) {
|
||||
let cx = W as f32 / 2.0 + offset;
|
||||
let cy = H as f32 / 2.0 + offset;
|
||||
engine.begin_stroke(layer_id, cx, cy);
|
||||
for i in 1..=5 {
|
||||
let d = i as f32 * 8.0;
|
||||
engine.stroke_to(layer_id, cx + d, cy + d, 0.8);
|
||||
}
|
||||
let (_region, ptr, _size) = engine.render_composite_region();
|
||||
assert!(!ptr.is_null());
|
||||
engine.end_stroke(layer_id);
|
||||
std::thread::sleep(std::time::Duration::from_millis(10));
|
||||
engine.commit_pending_history();
|
||||
}
|
||||
|
||||
/// Benchmark group: below_cache build (first stroke) vs reuse (second stroke).
|
||||
///
|
||||
/// **Purpose:** Detects regressions where `below_cache_dirty` is incorrectly
|
||||
/// set to `true` in `end_stroke()` or where `below_cache` is dropped to `None`,
|
||||
/// forcing a full 9-layer re-composite on every stroke.
|
||||
///
|
||||
/// **Logic & Workflow:**
|
||||
/// - `first_stroke_cache_build`: On a fresh engine, `begin_stroke()` must
|
||||
/// build the below_cache from scratch (composite 9 layers → 33 MB buffer).
|
||||
/// - `second_stroke_cache_reuse`: After the first stroke on the same layer,
|
||||
/// `begin_stroke()` should find `below_cache_dirty == false` and
|
||||
/// `below_cache_active_idx == current`, skipping the entire composite.
|
||||
fn bench_below_cache_reuse(c: &mut Criterion) {
|
||||
let mut group = c.benchmark_group("below_cache_reuse");
|
||||
|
||||
// ── First stroke: cache must be built from scratch ──
|
||||
group.bench_function("first_stroke_cache_build", |b| {
|
||||
b.iter_with_setup(
|
||||
|| setup_10layer_engine(),
|
||||
|(mut engine, layer_id)| {
|
||||
do_stroke(&mut engine, layer_id, 0.0);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
// ── Second stroke on same layer: cache should be reused ──
|
||||
group.bench_function("second_stroke_cache_reuse", |b| {
|
||||
let (mut engine, layer_id) = setup_10layer_engine();
|
||||
// Build the cache with the first stroke
|
||||
do_stroke(&mut engine, layer_id, 0.0);
|
||||
|
||||
let mut offset = 0.0_f32;
|
||||
b.iter(|| {
|
||||
offset += 5.0;
|
||||
do_stroke(&mut engine, layer_id, offset);
|
||||
});
|
||||
});
|
||||
|
||||
group.finish();
|
||||
}
|
||||
|
||||
criterion_group!(benches, bench_below_cache_reuse);
|
||||
criterion_main!(benches);
|
||||
@@ -0,0 +1,146 @@
|
||||
//! Criterion benchmark: `composite_scratch` buffer pooling guard.
|
||||
//!
|
||||
//! ## Purpose
|
||||
//! Verifies that the ~33 MB `composite_scratch` buffer inside
|
||||
//! `render_composite_region()` is allocated once and reused across calls.
|
||||
//! If a regression replaces the pooled `Option<Vec<u8>>` with a fresh
|
||||
//! `Vec::new()` on every call, this benchmark detects the allocation storm.
|
||||
//!
|
||||
//! ## Logic & Workflow
|
||||
//! 1. Creates a 3840×2160, 10-layer document with all layers filled.
|
||||
//! 2. Performs a stroke + composite to warm the scratch buffer.
|
||||
//! 3. **cold_composite**: Measures a `render_composite_region()` call on a
|
||||
//! fresh engine where `composite_scratch` is `None`.
|
||||
//! 4. **warm_composite**: Measures the same call after the buffer has been
|
||||
//! allocated (should be faster — no allocation, just memset + composite).
|
||||
//!
|
||||
//! ## Arguments & Returns
|
||||
//! Standard criterion benchmark — run with:
|
||||
//! ```bash
|
||||
//! cargo bench -p hcie-engine-api --bench composite_scratch_pooling
|
||||
//! ```
|
||||
//!
|
||||
//! ## Side Effects / Dependencies
|
||||
//! Allocates ~400 MB (10 layers × 4K RGBA + scratch + tile caches).
|
||||
|
||||
use criterion::{criterion_group, criterion_main, Criterion};
|
||||
use hcie_engine_api::{BrushStyle, BrushTip, Engine};
|
||||
|
||||
/// Canvas dimensions — 4K UHD.
|
||||
const W: u32 = 3840;
|
||||
const H: u32 = 2160;
|
||||
/// Number of raster layers to create (worst-case workload).
|
||||
const LAYERS: usize = 10;
|
||||
|
||||
/// Builds a multi-layer 4K document ready for compositing.
|
||||
///
|
||||
/// **Purpose:** Creates the worst-case composite scenario with every layer
|
||||
/// filled so tile and pixel compositing operate on real data.
|
||||
/// **Returns:** `(Engine, top_layer_id)`.
|
||||
fn setup_multilayer_engine() -> (Engine, u64) {
|
||||
let mut engine = Engine::new(W, H);
|
||||
|
||||
let bg_id = engine.active_layer_id();
|
||||
engine.set_active_layer(bg_id);
|
||||
engine.draw_filled_rect_rgba(0, 0, W, H, [240, 240, 240, 255]);
|
||||
|
||||
let mut layer_ids = vec![bg_id];
|
||||
for i in 1..LAYERS {
|
||||
let id = engine.add_layer(&format!("Layer {}", i));
|
||||
engine.set_active_layer(id);
|
||||
let color = match i % 4 {
|
||||
0 => [180, 80, 80, 255],
|
||||
1 => [80, 180, 80, 255],
|
||||
2 => [80, 80, 180, 255],
|
||||
_ => [160, 140, 100, 255],
|
||||
};
|
||||
engine.draw_filled_rect_rgba(0, 0, W, H, color);
|
||||
layer_ids.push(id);
|
||||
}
|
||||
|
||||
let top_id = *layer_ids.last().unwrap();
|
||||
engine.set_active_layer(top_id);
|
||||
|
||||
let mut tip = BrushTip::default();
|
||||
tip.style = BrushStyle::Round;
|
||||
tip.size = 24.0;
|
||||
tip.opacity = 1.0;
|
||||
tip.hardness = 0.85;
|
||||
tip.spacing = 0.1;
|
||||
engine.set_brush_tip(tip);
|
||||
engine.set_color([220, 60, 60, 255]);
|
||||
engine.set_eraser(false);
|
||||
|
||||
(engine, top_id)
|
||||
}
|
||||
|
||||
/// Forces a dirty region and composites it.
|
||||
///
|
||||
/// **Purpose:** Exercises the `render_composite_region()` path including
|
||||
/// scratch buffer allocation/reuse, tile compositing, and dirty rect handling.
|
||||
/// **Arguments:** `engine` — mutable engine, `layer_id` — target layer.
|
||||
/// **Side Effects:** Paints a short stroke segment to create a dirty region,
|
||||
/// then calls `render_composite_region()`.
|
||||
fn stroke_and_composite(engine: &mut Engine, layer_id: u64) {
|
||||
let cx = W as f32 / 2.0;
|
||||
let cy = H as f32 / 2.0;
|
||||
engine.begin_stroke(layer_id, cx, cy);
|
||||
engine.stroke_to(layer_id, cx + 50.0, cy + 50.0, 0.8);
|
||||
let (region, ptr, _size) = engine.render_composite_region();
|
||||
assert!(!ptr.is_null());
|
||||
let _ = region;
|
||||
engine.end_stroke(layer_id);
|
||||
std::thread::sleep(std::time::Duration::from_millis(10));
|
||||
engine.commit_pending_history();
|
||||
}
|
||||
|
||||
/// Benchmark group: cold vs warm `composite_scratch` allocation.
|
||||
///
|
||||
/// **Purpose:** Detects regressions where `composite_scratch` is dropped or
|
||||
/// reallocated on every `render_composite_region()` call. When pooling works,
|
||||
/// the warm benchmark avoids a ~33 MB allocation and should be measurably faster.
|
||||
///
|
||||
/// **Logic & Workflow:**
|
||||
/// - `cold_composite`: Fresh engine, `composite_scratch = None`. First call
|
||||
/// allocates the buffer.
|
||||
/// - `warm_composite`: Engine with pre-allocated scratch buffer. Measures
|
||||
/// pure composite cost without allocation overhead.
|
||||
fn bench_composite_scratch_pooling(c: &mut Criterion) {
|
||||
let mut group = c.benchmark_group("composite_scratch_pooling");
|
||||
|
||||
// ── Cold: composite on a fresh engine (scratch allocation included) ──
|
||||
group.bench_function("cold_composite", |b| {
|
||||
b.iter_with_setup(
|
||||
|| setup_multilayer_engine(),
|
||||
|(mut engine, layer_id)| {
|
||||
stroke_and_composite(&mut engine, layer_id);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
// ── Warm: composite on a pre-warmed engine (scratch already pooled) ──
|
||||
group.bench_function("warm_composite", |b| {
|
||||
let (mut engine, layer_id) = setup_multilayer_engine();
|
||||
// Warm up: allocate the scratch buffer
|
||||
stroke_and_composite(&mut engine, layer_id);
|
||||
|
||||
b.iter(|| {
|
||||
// Paint a new segment and composite — scratch should be reused
|
||||
let cx = W as f32 / 2.0;
|
||||
let cy = H as f32 / 2.0;
|
||||
engine.begin_stroke(layer_id, cx, cy);
|
||||
engine.stroke_to(layer_id, cx + 30.0, cy - 30.0, 0.7);
|
||||
let (region, ptr, _size) = engine.render_composite_region();
|
||||
assert!(!ptr.is_null());
|
||||
let _ = region;
|
||||
engine.end_stroke(layer_id);
|
||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||
engine.commit_pending_history();
|
||||
});
|
||||
});
|
||||
|
||||
group.finish();
|
||||
}
|
||||
|
||||
criterion_group!(benches, bench_composite_scratch_pooling);
|
||||
criterion_main!(benches);
|
||||
@@ -0,0 +1,166 @@
|
||||
//! Criterion benchmark: conditional `effects_dirty` skip guard.
|
||||
//!
|
||||
//! ## Purpose
|
||||
//! Verifies that `render_composite_region()` / `apply_effects_and_sync_tiles()`
|
||||
//! skips the expensive effects pipeline for layers that have **no effects or
|
||||
//! styles**. If a regression removes the `effects.is_empty() && styles.is_empty()`
|
||||
//! early-exit or unconditionally sets `effects_dirty = true`, composite cost
|
||||
//! on effect-free documents will increase dramatically.
|
||||
//!
|
||||
//! ## Logic & Workflow
|
||||
//! 1. Creates a 3840×2160, 10-layer document (all filled, no effects).
|
||||
//! 2. **no_effects**: Benchmarks `render_composite_region()` where every layer
|
||||
//! has empty `effects` and `styles` vectors → the effects pipeline is
|
||||
//! completely skipped.
|
||||
//! 3. **with_one_effect**: Same document but one layer has a DropShadow style
|
||||
//! → the effects pipeline runs for that single layer.
|
||||
//!
|
||||
//! When the conditional skip works, `no_effects` should be measurably faster
|
||||
//! than `with_one_effect` because no `apply_layer_effects` calls are made.
|
||||
//!
|
||||
//! ## Arguments & Returns
|
||||
//! Standard criterion benchmark — run with:
|
||||
//! ```bash
|
||||
//! cargo bench -p hcie-engine-api --bench effects_skip
|
||||
//! ```
|
||||
//!
|
||||
//! ## Side Effects / Dependencies
|
||||
//! Allocates ~400 MB (10 layers × 4K RGBA + composite buffers).
|
||||
|
||||
use criterion::{criterion_group, criterion_main, Criterion};
|
||||
use hcie_engine_api::{BrushStyle, BrushTip, Engine, LayerStyle};
|
||||
|
||||
/// Canvas dimensions — 4K UHD.
|
||||
const W: u32 = 3840;
|
||||
const H: u32 = 2160;
|
||||
/// Number of raster layers.
|
||||
const LAYERS: usize = 10;
|
||||
|
||||
/// Builds a 10-layer 4K document with NO effects on any layer.
|
||||
///
|
||||
/// **Purpose:** Creates the baseline scenario where the effects pipeline
|
||||
/// should be completely skipped during compositing.
|
||||
/// **Returns:** `(Engine, top_layer_id, all_layer_ids)`.
|
||||
fn setup_no_effects_engine() -> (Engine, u64, Vec<u64>) {
|
||||
let mut engine = Engine::new(W, H);
|
||||
|
||||
let bg_id = engine.active_layer_id();
|
||||
engine.set_active_layer(bg_id);
|
||||
engine.draw_filled_rect_rgba(0, 0, W, H, [240, 240, 240, 255]);
|
||||
|
||||
let mut layer_ids = vec![bg_id];
|
||||
for i in 1..LAYERS {
|
||||
let id = engine.add_layer(&format!("Layer {}", i));
|
||||
engine.set_active_layer(id);
|
||||
let color = match i % 4 {
|
||||
0 => [180, 80, 80, 255],
|
||||
1 => [80, 180, 80, 255],
|
||||
2 => [80, 80, 180, 255],
|
||||
_ => [160, 140, 100, 255],
|
||||
};
|
||||
engine.draw_filled_rect_rgba(0, 0, W, H, color);
|
||||
layer_ids.push(id);
|
||||
}
|
||||
|
||||
let top_id = *layer_ids.last().unwrap();
|
||||
engine.set_active_layer(top_id);
|
||||
|
||||
let mut tip = BrushTip::default();
|
||||
tip.style = BrushStyle::Round;
|
||||
tip.size = 24.0;
|
||||
tip.opacity = 1.0;
|
||||
tip.hardness = 0.85;
|
||||
tip.spacing = 0.1;
|
||||
engine.set_brush_tip(tip);
|
||||
engine.set_color([220, 60, 60, 255]);
|
||||
engine.set_eraser(false);
|
||||
|
||||
(engine, top_id, layer_ids)
|
||||
}
|
||||
|
||||
/// Performs a stroke + composite cycle and returns.
|
||||
///
|
||||
/// **Purpose:** Creates a dirty region and composites it, exercising the
|
||||
/// effects pipeline path.
|
||||
/// **Arguments:** `engine` — mutable engine, `layer_id` — target layer,
|
||||
/// `offset` — position variation.
|
||||
/// **Side Effects:** Mutates layer pixels, triggers composite.
|
||||
fn stroke_and_composite(engine: &mut Engine, layer_id: u64, offset: f32) {
|
||||
let cx = W as f32 / 2.0 + offset;
|
||||
let cy = H as f32 / 2.0 + offset;
|
||||
engine.begin_stroke(layer_id, cx, cy);
|
||||
for i in 1..=5 {
|
||||
let d = i as f32 * 8.0;
|
||||
engine.stroke_to(layer_id, cx + d, cy + d, 0.8);
|
||||
}
|
||||
let (_region, ptr, _size) = engine.render_composite_region();
|
||||
assert!(!ptr.is_null());
|
||||
engine.end_stroke(layer_id);
|
||||
std::thread::sleep(std::time::Duration::from_millis(10));
|
||||
engine.commit_pending_history();
|
||||
}
|
||||
|
||||
/// Benchmark group: effects-free composite vs single-effect composite.
|
||||
///
|
||||
/// **Purpose:** Detects regressions where the effects pipeline runs
|
||||
/// unnecessarily on layers with no effects, causing wasted CPU cycles
|
||||
/// (apply_layer_effects + 33 MB buffer clone per affected layer).
|
||||
///
|
||||
/// **Logic & Workflow:**
|
||||
/// - `no_effects_composite`: All 10 layers have empty effects/styles.
|
||||
/// `apply_effects_and_sync_tiles()` should skip the effects pass entirely.
|
||||
/// - `with_one_dropshadow`: Layer 5 has a DropShadow style enabled.
|
||||
/// The effects pipeline runs for that single layer but skips the other 9.
|
||||
/// This should be slightly slower than `no_effects_composite` but not 10×
|
||||
/// slower (which would indicate the skip logic is broken).
|
||||
fn bench_effects_skip(c: &mut Criterion) {
|
||||
let mut group = c.benchmark_group("effects_skip");
|
||||
|
||||
// ── No effects on any layer ──
|
||||
group.bench_function("no_effects_composite", |b| {
|
||||
let (mut engine, layer_id, _ids) = setup_no_effects_engine();
|
||||
// Warm up composite scratch
|
||||
stroke_and_composite(&mut engine, layer_id, 0.0);
|
||||
|
||||
let mut offset = 0.0_f32;
|
||||
b.iter(|| {
|
||||
offset += 3.0;
|
||||
stroke_and_composite(&mut engine, layer_id, offset);
|
||||
});
|
||||
});
|
||||
|
||||
// ── One layer has a DropShadow effect ──
|
||||
group.bench_function("with_one_dropshadow", |b| {
|
||||
let (mut engine, layer_id, layer_ids) = setup_no_effects_engine();
|
||||
|
||||
// Add a DropShadow style to layer 5 (middle of the stack)
|
||||
let target_layer_id = layer_ids[5];
|
||||
engine.update_layer_style(
|
||||
target_layer_id,
|
||||
LayerStyle::DropShadow {
|
||||
enabled: true,
|
||||
opacity: 0.6,
|
||||
angle: 135.0,
|
||||
distance: 5.0,
|
||||
spread: 0.0,
|
||||
size: 10.0,
|
||||
color: [0, 0, 0, 255],
|
||||
blend_mode: "Normal".to_string(),
|
||||
},
|
||||
);
|
||||
|
||||
// Warm up
|
||||
stroke_and_composite(&mut engine, layer_id, 0.0);
|
||||
|
||||
let mut offset = 0.0_f32;
|
||||
b.iter(|| {
|
||||
offset += 3.0;
|
||||
stroke_and_composite(&mut engine, layer_id, offset);
|
||||
});
|
||||
});
|
||||
|
||||
group.finish();
|
||||
}
|
||||
|
||||
criterion_group!(benches, bench_effects_skip);
|
||||
criterion_main!(benches);
|
||||
@@ -0,0 +1,120 @@
|
||||
//! Criterion benchmark: `active_stroke_mask` buffer pooling guard.
|
||||
//!
|
||||
//! ## Purpose
|
||||
//! Verifies that the `active_stroke_mask` (~8 MB on a 4K canvas) is **pooled**
|
||||
//! across consecutive strokes rather than re-allocated on every
|
||||
//! `begin_stroke()` / `end_stroke()` cycle. If a regression drops the mask
|
||||
//! with `self.active_stroke_mask = None` inside `end_stroke()`, this benchmark
|
||||
//! will show a measurable increase in allocation overhead.
|
||||
//!
|
||||
//! ## Logic & Workflow
|
||||
//! 1. Creates a 3840×2160 document with one raster layer.
|
||||
//! 2. **cold** group: a single begin/end stroke pair (first allocation).
|
||||
//! 3. **warm** group: the 10th consecutive stroke on the same engine (buffer
|
||||
//! should already be allocated and simply zeroed with `fill(0)`).
|
||||
//!
|
||||
//! When pooling works correctly, the warm benchmark should be significantly
|
||||
//! faster than the cold one because no allocation occurs — only a memset.
|
||||
//!
|
||||
//! ## Arguments & Returns
|
||||
//! Standard criterion benchmark — run with:
|
||||
//! ```bash
|
||||
//! cargo bench -p hcie-engine-api --bench stroke_mask_pooling
|
||||
//! ```
|
||||
//!
|
||||
//! ## Side Effects / Dependencies
|
||||
//! Allocates ~140 MB (4K RGBA layer + mask + before buffer). Uses
|
||||
//! `criterion::Criterion` for statistical measurement.
|
||||
|
||||
use criterion::{criterion_group, criterion_main, Criterion};
|
||||
use hcie_engine_api::{BrushStyle, BrushTip, Engine};
|
||||
|
||||
/// Canvas dimensions — 4K UHD as requested.
|
||||
const W: u32 = 3840;
|
||||
const H: u32 = 2160;
|
||||
|
||||
/// Creates a pre-configured engine with a filled background layer and a
|
||||
/// standard round brush tip.
|
||||
///
|
||||
/// **Purpose:** Shared setup for all mask pooling benchmark variants.
|
||||
/// **Returns:** `(Engine, layer_id)` ready for `begin_stroke()`.
|
||||
fn setup_engine() -> (Engine, u64) {
|
||||
let mut engine = Engine::new(W, H);
|
||||
let layer_id = engine.active_layer_id();
|
||||
engine.set_active_layer(layer_id);
|
||||
engine.draw_filled_rect_rgba(0, 0, W, H, [200, 200, 200, 255]);
|
||||
|
||||
let mut tip = BrushTip::default();
|
||||
tip.style = BrushStyle::Round;
|
||||
tip.size = 24.0;
|
||||
tip.opacity = 1.0;
|
||||
tip.hardness = 0.85;
|
||||
tip.spacing = 0.1;
|
||||
engine.set_brush_tip(tip);
|
||||
engine.set_color([60, 60, 220, 255]);
|
||||
engine.set_eraser(false);
|
||||
|
||||
(engine, layer_id)
|
||||
}
|
||||
|
||||
/// Performs a single begin_stroke → short paint → end_stroke cycle.
|
||||
///
|
||||
/// **Purpose:** Exercises the full mask allocation / reuse path.
|
||||
/// **Arguments:** `engine` — mutable engine reference, `layer_id` — target layer.
|
||||
/// **Side Effects:** Mutates layer pixels and engine caches.
|
||||
fn do_one_stroke(engine: &mut Engine, layer_id: u64) {
|
||||
let cx = W as f32 / 2.0;
|
||||
let cy = H as f32 / 2.0;
|
||||
engine.begin_stroke(layer_id, cx, cy);
|
||||
// Paint 5 short segments to exercise the mask
|
||||
for i in 1..=5 {
|
||||
let offset = i as f32 * 10.0;
|
||||
engine.stroke_to(layer_id, cx + offset, cy + offset, 0.8);
|
||||
}
|
||||
engine.end_stroke(layer_id);
|
||||
// Commit pending history so background thread completes
|
||||
std::thread::sleep(std::time::Duration::from_millis(10));
|
||||
engine.commit_pending_history();
|
||||
}
|
||||
|
||||
/// Benchmark group: cold vs warm `active_stroke_mask` allocation.
|
||||
///
|
||||
/// **Purpose:** Detects pooling regressions by comparing the first stroke
|
||||
/// (cold allocation) against a subsequent stroke (warm / reused buffer).
|
||||
///
|
||||
/// **Logic & Workflow:**
|
||||
/// - `cold_first_stroke`: Measures `begin_stroke` + short paint + `end_stroke`
|
||||
/// on a fresh engine where `active_stroke_mask` is `None`.
|
||||
/// - `warm_10th_stroke`: Same operation but after 9 warm-up strokes have already
|
||||
/// populated the pooled mask buffer. If pooling works, this should be faster.
|
||||
fn bench_mask_pooling(c: &mut Criterion) {
|
||||
let mut group = c.benchmark_group("stroke_mask_pooling");
|
||||
|
||||
// ── Cold: first stroke on a fresh engine (allocation happens) ──
|
||||
group.bench_function("cold_first_stroke", |b| {
|
||||
b.iter_with_setup(
|
||||
|| setup_engine(),
|
||||
|(mut engine, layer_id)| {
|
||||
do_one_stroke(&mut engine, layer_id);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
// ── Warm: 10th stroke on a warmed-up engine (buffer reuse) ──
|
||||
group.bench_function("warm_10th_stroke", |b| {
|
||||
// Pre-warm the engine outside the measured loop
|
||||
let (mut engine, layer_id) = setup_engine();
|
||||
for _ in 0..9 {
|
||||
do_one_stroke(&mut engine, layer_id);
|
||||
}
|
||||
|
||||
b.iter(|| {
|
||||
do_one_stroke(&mut engine, layer_id);
|
||||
});
|
||||
});
|
||||
|
||||
group.finish();
|
||||
}
|
||||
|
||||
criterion_group!(benches, bench_mask_pooling);
|
||||
criterion_main!(benches);
|
||||
@@ -459,4 +459,102 @@ impl Engine {
|
||||
self.below_cache_active_idx = None;
|
||||
self.below_cache_dirty = true;
|
||||
}
|
||||
|
||||
/// Check if a layer has an active layer mask.
|
||||
pub fn has_layer_mask(&self, layer_id: u64) -> bool {
|
||||
if let Some(idx) = self.document.layer_index_by_id(layer_id) {
|
||||
self.document.layers.get(idx).map(|l| l.mask_pixels.is_some()).unwrap_or(false)
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Add a white (opaque/reveal) layer mask to the specified layer if not present.
|
||||
pub fn add_layer_mask(&mut self, layer_id: u64) {
|
||||
if let Some(idx) = self.document.layer_index_by_id(layer_id) {
|
||||
if let Some(layer) = self.document.layers.get_mut(idx) {
|
||||
let mask_size = (layer.width * layer.height) as usize;
|
||||
layer.mask_pixels = Some(vec![255u8; mask_size]);
|
||||
layer.mask_bounds = Some([0, 0, layer.height as i32, layer.width as i32]);
|
||||
layer.mask_default_color = 255;
|
||||
layer.dirty = true;
|
||||
self.document.composite_dirty = true;
|
||||
self.document.modified = true;
|
||||
self.below_cache_dirty = true;
|
||||
self.document.composite_dirty = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove the layer mask from the specified layer.
|
||||
pub fn remove_layer_mask(&mut self, layer_id: u64) {
|
||||
if let Some(idx) = self.document.layer_index_by_id(layer_id) {
|
||||
if let Some(layer) = self.document.layers.get_mut(idx) {
|
||||
layer.mask_pixels = None;
|
||||
layer.mask_bounds = None;
|
||||
layer.dirty = true;
|
||||
self.document.composite_dirty = true;
|
||||
self.document.modified = true;
|
||||
self.below_cache_dirty = true;
|
||||
self.document.composite_dirty = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Toggle the existence of a layer mask on the specified layer.
|
||||
pub fn toggle_layer_mask(&mut self, layer_id: u64) {
|
||||
if self.has_layer_mask(layer_id) {
|
||||
self.remove_layer_mask(layer_id);
|
||||
} else {
|
||||
self.add_layer_mask(layer_id);
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns `true` if current drawing operations target the layer mask.
|
||||
pub fn is_editing_mask(&self) -> bool {
|
||||
self.editing_mask
|
||||
}
|
||||
|
||||
/// Set whether current drawing operations target the active layer's mask.
|
||||
pub fn set_editing_mask(&mut self, editing: bool) {
|
||||
self.editing_mask = editing;
|
||||
}
|
||||
|
||||
/// Toggle mask editing mode.
|
||||
pub fn toggle_editing_mask(&mut self) {
|
||||
self.editing_mask = !self.editing_mask;
|
||||
}
|
||||
|
||||
/// Returns a reference to the mask pixel bytes for the given layer.
|
||||
pub fn get_layer_mask_pixels(&self, layer_id: u64) -> Option<&[u8]> {
|
||||
if let Some(idx) = self.document.layer_index_by_id(layer_id) {
|
||||
self.document.layers.get(idx).and_then(|l| l.mask_pixels.as_deref())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns mask bounds [top, left, bottom, right] for the given layer if set.
|
||||
pub fn get_layer_mask_bounds(&self, layer_id: u64) -> Option<[i32; 4]> {
|
||||
if let Some(idx) = self.document.layer_index_by_id(layer_id) {
|
||||
self.document.layers.get(idx).and_then(|l| l.mask_bounds)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Update the mask pixel bytes and bounds for the given layer.
|
||||
pub fn set_layer_mask_pixels(&mut self, layer_id: u64, mask: Vec<u8>, bounds: Option<[i32; 4]>) {
|
||||
if let Some(idx) = self.document.layer_index_by_id(layer_id) {
|
||||
if let Some(layer) = self.document.layers.get_mut(idx) {
|
||||
layer.mask_pixels = Some(mask);
|
||||
layer.mask_bounds = bounds;
|
||||
layer.dirty = true;
|
||||
self.document.composite_dirty = true;
|
||||
self.document.modified = true;
|
||||
self.below_cache_dirty = true;
|
||||
self.document.composite_dirty = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -348,12 +348,14 @@ pub struct Engine {
|
||||
/// At `end_stroke()`, the buffer is moved into the background thread for
|
||||
/// sub-rect extraction, then returned via the pending_history channel.
|
||||
stroke_before_buf: Option<Vec<u8>>,
|
||||
mask_before_buf: Option<Vec<u8>>,
|
||||
tile_layers: Vec<Option<TiledLayer>>,
|
||||
svg_sources: std::collections::HashMap<u64, Vec<u8>>,
|
||||
filter_preview_original: Option<Vec<u8>>,
|
||||
stroke_effects_backup: Option<Vec<hcie_protocol::effects::LayerEffect>>,
|
||||
stroke_styles_backup: Option<Vec<hcie_protocol::LayerStyle>>,
|
||||
pub is_eraser: bool,
|
||||
pub editing_mask: bool,
|
||||
/// Pooled stroke mask buffer. Zeroed on `begin_stroke()` and `end_stroke()`
|
||||
/// but **never dropped** between strokes. Reused if size matches, reallocated
|
||||
/// only when canvas dimensions change. Avoids ~8MB alloc/stroke on 4K.
|
||||
@@ -363,6 +365,8 @@ pub struct Engine {
|
||||
/// on 4K canvases. The pixel-bridge extracts dirty rect from this buffer
|
||||
/// without allocating its own buffer.
|
||||
composite_scratch: Option<Vec<u8>>,
|
||||
/// Pooled RGBA scratch buffer for layer mask editing passes.
|
||||
pub mask_rgba_scratch: Option<Vec<u8>>,
|
||||
/// Accumulated stroke extent `[x0, y0, x1, y1]` for sub-rect snapshot.
|
||||
/// Tracks the union of all brush areas painted during the current stroke.
|
||||
/// Independent from `document.dirty_bounds` — survives composite emits.
|
||||
@@ -540,14 +544,17 @@ impl Engine {
|
||||
cyclic_speed: 0.5,
|
||||
stroke_before: None,
|
||||
stroke_before_buf: None,
|
||||
mask_before_buf: None,
|
||||
tile_layers: Vec::new(),
|
||||
svg_sources: std::collections::HashMap::new(),
|
||||
filter_preview_original: None,
|
||||
stroke_effects_backup: None,
|
||||
stroke_styles_backup: None,
|
||||
is_eraser: false,
|
||||
editing_mask: false,
|
||||
active_stroke_mask: None,
|
||||
composite_scratch: None,
|
||||
mask_rgba_scratch: None,
|
||||
last_stroke_bounds: None,
|
||||
below_cache: None,
|
||||
below_cache_active_idx: None,
|
||||
|
||||
@@ -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:**
|
||||
@@ -145,25 +146,29 @@ impl Engine {
|
||||
"[render_composite_region] BEFORE composite: active_idx={}, cache_valid={}, below_cache={}, below_cache_active_idx={:?}, tile_layers_len={}, dirty_rect=[{},{},{},{}]",
|
||||
active_idx, cache_valid, self.below_cache.is_some(), self.below_cache_active_idx, self.tile_layers.len(), x0, y0, x1, y1
|
||||
);
|
||||
for (i, l) in self.document.layers.iter().enumerate() {
|
||||
let tc = self
|
||||
.tile_layers
|
||||
.get(i)
|
||||
.and_then(|t| t.as_ref().map(|tl| tl.tile_count()))
|
||||
.unwrap_or(0);
|
||||
log::trace!(
|
||||
"[render_composite_region] layer[{}] id={} visible={} dirty={} opacity={} blend={:?} tile_count={}",
|
||||
i, l.id, l.visible, l.dirty, l.opacity, l.blend_mode, tc
|
||||
);
|
||||
if log::log_enabled!(log::Level::Trace) {
|
||||
for (i, l) in self.document.layers.iter().enumerate() {
|
||||
let tc = self
|
||||
.tile_layers
|
||||
.get(i)
|
||||
.and_then(|t| t.as_ref().map(|tl| tl.tile_count()))
|
||||
.unwrap_or(0);
|
||||
log::trace!(
|
||||
"[render_composite_region] layer[{}] id={} visible={} dirty={} opacity={} blend={:?} tile_count={}",
|
||||
i, l.id, l.visible, l.dirty, l.opacity, l.blend_mode, tc
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if cache_valid {
|
||||
let cache = self.below_cache.as_ref().unwrap();
|
||||
let below_non_zero = cache.iter().filter(|&&b| b != 0).count();
|
||||
log::trace!(
|
||||
"[render_composite_region] CACHE HIT: using below_cache ({} non-zero bytes), compositing layers[{}..{}] on top",
|
||||
below_non_zero, active_idx, self.document.layers.len()
|
||||
);
|
||||
if log::log_enabled!(log::Level::Trace) {
|
||||
let below_non_zero = cache.iter().filter(|&&b| b != 0).count();
|
||||
log::trace!(
|
||||
"[render_composite_region] CACHE HIT: using below_cache ({} non-zero bytes), compositing layers[{}..{}] on top",
|
||||
below_non_zero, active_idx, self.document.layers.len()
|
||||
);
|
||||
}
|
||||
for y in y0..y1 {
|
||||
let start = (y as usize * wu + x0u) * 4;
|
||||
let end = start + ((x1 - x0) as usize) * 4;
|
||||
@@ -192,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,
|
||||
|
||||
@@ -105,69 +105,253 @@ impl Engine {
|
||||
tip
|
||||
}
|
||||
|
||||
/// Helper method to execute a drawing action either on active layer pixels (when editing_mask is false)
|
||||
/// or on the active layer mask (when editing_mask is true), converting between RGBA and greyscale mask.
|
||||
/// Ensures active layer's mask is expanded to full canvas dimensions if present.
|
||||
pub fn ensure_full_canvas_mask(&mut self, layer_id: u64) {
|
||||
let cw = self.document.canvas_width;
|
||||
let ch = self.document.canvas_height;
|
||||
if let Some(layer) = self.document.get_layer_by_id_mut(layer_id) {
|
||||
let full_size = (cw * ch) as usize;
|
||||
if let Some(ref mask) = layer.mask_pixels {
|
||||
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 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;
|
||||
}
|
||||
for x in 0..mw {
|
||||
let gx = m_left as usize + x;
|
||||
if gx >= cw as usize {
|
||||
break;
|
||||
}
|
||||
full_mask[gy * cw as usize + gx] = mask[y * mw + x];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
layer.mask_pixels = Some(full_mask);
|
||||
layer.mask_bounds = Some([0, 0, ch as i32, cw as i32]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn draw_target_pixels_or_mask<F>(&mut self, layer_id: u64, f: F)
|
||||
where
|
||||
F: FnOnce(
|
||||
&mut [u8],
|
||||
u32,
|
||||
u32,
|
||||
Option<&[u8]>,
|
||||
Option<&mut [u8]>,
|
||||
Option<&[u8]>,
|
||||
Option<&mut Vec<(f32, f32)>>,
|
||||
),
|
||||
{
|
||||
let is_editing = self.editing_mask;
|
||||
if is_editing {
|
||||
self.ensure_full_canvas_mask(layer_id);
|
||||
}
|
||||
|
||||
if !is_editing {
|
||||
let mask_ref = self.cached_selection_mask.as_deref();
|
||||
let active_stroke_mask = self.active_stroke_mask.as_deref_mut();
|
||||
let stroke_before_buf = self.stroke_before_buf.as_deref();
|
||||
let sketch_hist = &mut self.sketch_history;
|
||||
|
||||
if let Some(layer) = self.document.get_layer_by_id_mut(layer_id) {
|
||||
let w = layer.width;
|
||||
let h = layer.height;
|
||||
f(
|
||||
&mut layer.pixels,
|
||||
w,
|
||||
h,
|
||||
mask_ref,
|
||||
active_stroke_mask,
|
||||
stroke_before_buf,
|
||||
Some(sketch_hist),
|
||||
);
|
||||
layer.dirty = true;
|
||||
}
|
||||
} else {
|
||||
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 let Some(layer) = self.document.get_layer_by_id_mut(layer_id) {
|
||||
if layer.mask_pixels.is_none() {
|
||||
layer.mask_pixels = Some(vec![255u8; mask_len]);
|
||||
layer.mask_bounds = Some([0, 0, ch as i32, cw as i32]);
|
||||
layer.mask_default_color = 255;
|
||||
}
|
||||
}
|
||||
|
||||
let (rx0, ry0, rx1, ry1) = match self.last_stroke_bounds {
|
||||
Some([x0, y0, x1, y1]) => (
|
||||
(x0.saturating_sub(15) as usize).min(cw),
|
||||
(y0.saturating_sub(15) as usize).min(ch),
|
||||
((x1 + 15) as usize).min(cw),
|
||||
((y1 + 15) as usize).min(ch),
|
||||
),
|
||||
None => (0, 0, cw, ch),
|
||||
};
|
||||
|
||||
let needed_len = mask_len * 4;
|
||||
let mut scratch = self.mask_rgba_scratch.take().unwrap_or_default();
|
||||
if scratch.len() != needed_len {
|
||||
scratch.resize(needed_len, 0);
|
||||
}
|
||||
|
||||
if let Some(layer) = self.document.get_layer_by_id(layer_id) {
|
||||
if let Some(ref mask) = layer.mask_pixels {
|
||||
for y in ry0..ry1 {
|
||||
let row_off = y * cw;
|
||||
for x in rx0..rx1 {
|
||||
let idx = row_off + x;
|
||||
let v = mask[idx];
|
||||
let off = idx * 4;
|
||||
scratch[off] = v;
|
||||
scratch[off + 1] = v;
|
||||
scratch[off + 2] = v;
|
||||
scratch[off + 3] = 255;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
let mask_ref = self.cached_selection_mask.as_deref();
|
||||
let active_stroke_mask = self.active_stroke_mask.as_deref_mut();
|
||||
let stroke_before_buf = self.stroke_before_buf.as_deref();
|
||||
let sketch_hist = &mut self.sketch_history;
|
||||
f(
|
||||
&mut scratch,
|
||||
cw as u32,
|
||||
ch as u32,
|
||||
mask_ref,
|
||||
active_stroke_mask,
|
||||
stroke_before_buf,
|
||||
Some(sketch_hist),
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(layer) = self.document.get_layer_by_id_mut(layer_id) {
|
||||
if let Some(ref mut mask) = layer.mask_pixels {
|
||||
for y in ry0..ry1 {
|
||||
let row_off = y * cw;
|
||||
for x in rx0..rx1 {
|
||||
let idx = row_off + x;
|
||||
let off = idx * 4;
|
||||
let r = scratch[off] as u32;
|
||||
let g = scratch[off + 1] as u32;
|
||||
let b = scratch[off + 2] as u32;
|
||||
let a = scratch[off + 3] as u32;
|
||||
let gray = (r * 299 + g * 587 + b * 114) / 1000;
|
||||
mask[idx] = ((gray * a) / 255) as u8;
|
||||
}
|
||||
}
|
||||
}
|
||||
layer.dirty = true;
|
||||
}
|
||||
|
||||
self.mask_rgba_scratch = Some(scratch);
|
||||
}
|
||||
self.below_cache_dirty = true;
|
||||
self.document.composite_dirty = true;
|
||||
self.document.modified = true;
|
||||
}
|
||||
|
||||
pub fn stroke_to(&mut self, layer_id: u64, x: f32, y: f32, pressure: f32) {
|
||||
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);
|
||||
let mask_ref = self.cached_selection_mask.as_deref();
|
||||
|
||||
if Self::is_specialized_style(tip.style) {
|
||||
if let Some((lx, ly, lp)) = self.last_stroke_pos {
|
||||
if let Some(layer) = self.document.get_layer_by_id_mut(layer_id) {
|
||||
// If click without movement, draw a single dab at current position
|
||||
let points_vec = if (lx - x).abs() < 0.001 && (ly - y).abs() < 0.001 {
|
||||
vec![(x, y, pressure)]
|
||||
} else {
|
||||
vec![(lx, ly, lp), (x, y, pressure)]
|
||||
};
|
||||
draw_specialized_stroke(
|
||||
&mut layer.pixels,
|
||||
layer.width,
|
||||
layer.height,
|
||||
&points_vec,
|
||||
tip.style,
|
||||
tip.size,
|
||||
tip.hardness,
|
||||
color,
|
||||
tip.opacity,
|
||||
tip.spacing,
|
||||
self.is_eraser,
|
||||
if tip.style == BrushStyle::Sketch {
|
||||
Some(&mut self.sketch_history)
|
||||
let points_vec = if (lx - x).abs() < 0.001 && (ly - y).abs() < 0.001 {
|
||||
vec![(x, y, pressure)]
|
||||
} else {
|
||||
vec![(lx, ly, lp), (x, y, pressure)]
|
||||
};
|
||||
let is_eraser = self.is_eraser;
|
||||
|
||||
let dirty_r = Self::brush_dirty_radius(&tip);
|
||||
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| {
|
||||
let sketch_hist = if tip.style == BrushStyle::Sketch {
|
||||
sketch_history
|
||||
} else {
|
||||
None
|
||||
},
|
||||
tip.spray_particle_size,
|
||||
tip.spray_density,
|
||||
mask_ref,
|
||||
self.active_stroke_mask.as_deref_mut(),
|
||||
self.stroke_before_buf.as_deref(),
|
||||
tip.color_variant,
|
||||
tip.variant_amount,
|
||||
tip.density,
|
||||
tip.jitter_amount,
|
||||
tip.scatter_amount,
|
||||
tip.angle,
|
||||
tip.roundness,
|
||||
tip.rotation_random,
|
||||
tip.drawing_angle,
|
||||
false,
|
||||
);
|
||||
layer.dirty = true;
|
||||
};
|
||||
draw_specialized_stroke(
|
||||
pixels,
|
||||
lw,
|
||||
lh,
|
||||
&points_vec,
|
||||
tip.style,
|
||||
tip.size,
|
||||
tip.hardness,
|
||||
color,
|
||||
tip.opacity,
|
||||
tip.spacing,
|
||||
is_eraser,
|
||||
sketch_hist,
|
||||
tip.spray_particle_size,
|
||||
tip.spray_density,
|
||||
mask_ref,
|
||||
active_stroke_mask,
|
||||
stroke_before_buf,
|
||||
tip.color_variant,
|
||||
tip.variant_amount,
|
||||
tip.density,
|
||||
tip.jitter_amount,
|
||||
tip.scatter_amount,
|
||||
tip.angle,
|
||||
tip.roundness,
|
||||
tip.rotation_random,
|
||||
tip.drawing_angle,
|
||||
false,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
if let Some(layer) = self.document.get_layer_by_id_mut(layer_id) {
|
||||
if !layer.effects.is_empty() || !layer.styles.is_empty() {
|
||||
*layer.effects_cache.lock().unwrap() = None;
|
||||
}
|
||||
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);
|
||||
}
|
||||
self.document.composite_dirty = true;
|
||||
self.document.modified = true;
|
||||
@@ -176,32 +360,69 @@ impl Engine {
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
if let Some(layer) = self.document.get_layer_by_id_mut(layer_id) {
|
||||
draw_brush_stroke(
|
||||
layer,
|
||||
&[(x, y, pressure)],
|
||||
color,
|
||||
&tip,
|
||||
self.is_eraser,
|
||||
mask_ref,
|
||||
self.active_stroke_mask.as_deref_mut(),
|
||||
self.stroke_before_buf.as_deref(),
|
||||
let is_eraser = self.is_eraser;
|
||||
|
||||
let dirty_r = tip.size.max(2.0);
|
||||
if let Some((lx, ly, _)) = self.last_stroke_pos {
|
||||
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);
|
||||
}
|
||||
|
||||
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());
|
||||
draw_brush_stroke(
|
||||
&mut temp_layer,
|
||||
&[(x, y, pressure)],
|
||||
color,
|
||||
&tip,
|
||||
is_eraser,
|
||||
mask_ref,
|
||||
active_stroke_mask,
|
||||
stroke_before_buf,
|
||||
);
|
||||
pixels.copy_from_slice(&temp_layer.pixels);
|
||||
},
|
||||
);
|
||||
layer.dirty = true;
|
||||
} else {
|
||||
let mask_ref = self.cached_selection_mask.as_deref();
|
||||
let active_stroke_mask = self.active_stroke_mask.as_deref_mut();
|
||||
let stroke_before_buf = self.stroke_before_buf.as_deref();
|
||||
if let Some(layer) = self.document.get_layer_by_id_mut(layer_id) {
|
||||
draw_brush_stroke(
|
||||
layer,
|
||||
&[(x, y, pressure)],
|
||||
color,
|
||||
&tip,
|
||||
is_eraser,
|
||||
mask_ref,
|
||||
active_stroke_mask,
|
||||
stroke_before_buf,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(layer) = self.document.get_layer_by_id_mut(layer_id) {
|
||||
if !layer.effects.is_empty() || !layer.styles.is_empty() {
|
||||
layer
|
||||
.effects_dirty
|
||||
.store(true, std::sync::atomic::Ordering::Release);
|
||||
}
|
||||
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);
|
||||
}
|
||||
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);
|
||||
}
|
||||
self.expand_stroke_bounds(x as u32, y as u32, dirty_r);
|
||||
}
|
||||
self.document.composite_dirty = true;
|
||||
self.document.modified = true;
|
||||
|
||||
@@ -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`.
|
||||
///
|
||||
@@ -59,6 +60,10 @@ pub struct PendingHistoryItem {
|
||||
pub return_before: Option<Vec<u8>>,
|
||||
/// Full-layer after buffer returned from background thread for pool reuse.
|
||||
pub return_after: Option<Vec<u8>>,
|
||||
/// Grayscale before mask buffer returned from background thread for pool reuse.
|
||||
pub return_mask_before: Option<Vec<u8>>,
|
||||
/// Indicates if this snapshot represents a mask edit rather than a pixel edit.
|
||||
pub is_mask: bool,
|
||||
}
|
||||
|
||||
impl Engine {
|
||||
@@ -98,8 +103,33 @@ impl Engine {
|
||||
self.stroke_before_buf = Some(buf);
|
||||
}
|
||||
}
|
||||
if let Some(buf) = item.return_mask_before {
|
||||
if self
|
||||
.mask_before_buf
|
||||
.as_ref()
|
||||
.map_or(true, |b| b.len() != buf.len())
|
||||
{
|
||||
self.mask_before_buf = Some(buf);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(bounds) = item.bounds {
|
||||
if item.is_mask {
|
||||
if let Some(bounds) = item.bounds {
|
||||
self.document.push_mask_draw_snapshot_subrect(
|
||||
item.layer_idx,
|
||||
item.before_pixels,
|
||||
item.after_pixels,
|
||||
(bounds[0], bounds[1], bounds[2], bounds[3]),
|
||||
item.description,
|
||||
);
|
||||
} else {
|
||||
self.document.push_mask_draw_snapshot(
|
||||
item.layer_idx,
|
||||
item.before_pixels,
|
||||
item.description,
|
||||
);
|
||||
}
|
||||
} else if let Some(bounds) = item.bounds {
|
||||
self.document.push_draw_snapshot_subrect(
|
||||
item.layer_idx,
|
||||
item.before_pixels,
|
||||
@@ -191,12 +221,71 @@ impl Engine {
|
||||
|
||||
// Pool the "before" pixel buffer: reuse if size matches, otherwise allocate.
|
||||
// This avoids a ~33MB allocation per stroke on 4K canvases.
|
||||
match &mut self.stroke_before_buf {
|
||||
Some(buf) if buf.len() == layer_pixels => {
|
||||
buf.copy_from_slice(&layer.pixels);
|
||||
if self.editing_mask {
|
||||
let mut rgba_buf = match self.stroke_before_buf.take() {
|
||||
Some(mut b) if b.len() == layer_pixels => {
|
||||
b.fill(255);
|
||||
b
|
||||
}
|
||||
_ => vec![255u8; layer_pixels],
|
||||
};
|
||||
let mut gray_buf = match self.mask_before_buf.take() {
|
||||
Some(mut b) if b.len() == layer_size => {
|
||||
b.fill(255);
|
||||
b
|
||||
}
|
||||
_ => vec![255u8; layer_size],
|
||||
};
|
||||
|
||||
let ch = layer.height as usize;
|
||||
let cw = layer.width as usize;
|
||||
let full_size = cw * ch;
|
||||
|
||||
if let Some(ref mut mask) = layer.mask_pixels {
|
||||
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 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 { break; }
|
||||
for x in 0..mw {
|
||||
let gx = m_left as usize + x;
|
||||
if gx >= cw { break; }
|
||||
full_mask[gy * cw + gx] = mask[y * mw + x];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
*mask = full_mask;
|
||||
layer.mask_bounds = Some([0, 0, ch as i32, cw as i32]);
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
self.stroke_before_buf = Some(layer.pixels.clone());
|
||||
|
||||
if let Some(ref mask) = layer.mask_pixels {
|
||||
gray_buf.copy_from_slice(mask);
|
||||
for (i, &v) in mask.iter().enumerate() {
|
||||
let off = i * 4;
|
||||
rgba_buf[off] = v;
|
||||
rgba_buf[off + 1] = v;
|
||||
rgba_buf[off + 2] = v;
|
||||
rgba_buf[off + 3] = 255;
|
||||
}
|
||||
}
|
||||
self.stroke_before_buf = Some(rgba_buf);
|
||||
self.mask_before_buf = Some(gray_buf);
|
||||
} else {
|
||||
match &mut self.stroke_before_buf {
|
||||
Some(buf) if buf.len() == layer_pixels => {
|
||||
buf.copy_from_slice(&layer.pixels);
|
||||
}
|
||||
_ => {
|
||||
self.stroke_before_buf = Some(layer.pixels.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
let before_shapes = if let LayerData::Vector { shapes } = &layer.data {
|
||||
@@ -254,17 +343,35 @@ impl Engine {
|
||||
layer_pixels,
|
||||
self.stroke_before_buf.as_ref().map(|b| b.len())
|
||||
);
|
||||
let before = match self.stroke_before_buf.take() {
|
||||
Some(buf) if buf.len() == layer_pixels => buf,
|
||||
_ => vec![0u8; layer_pixels],
|
||||
let mut mask_before = None;
|
||||
let before = if self.editing_mask {
|
||||
let gray_len = lw as usize * layer.height as usize;
|
||||
let mb = match self.mask_before_buf.take() {
|
||||
Some(buf) if buf.len() == gray_len => buf,
|
||||
_ => vec![255u8; gray_len],
|
||||
};
|
||||
mask_before = Some(mb.clone());
|
||||
mb
|
||||
} else {
|
||||
match self.stroke_before_buf.take() {
|
||||
Some(buf) if buf.len() == layer_pixels => buf,
|
||||
_ => vec![0u8; layer_pixels],
|
||||
}
|
||||
};
|
||||
log::debug!("[end_stroke] before.len()={}, lw={}", before.len(), lw);
|
||||
|
||||
// Layer 3: Zero-copy after-snapshot via raw pointer.
|
||||
// SAFETY: After end_stroke(), no mutations happen to layer.pixels
|
||||
// until the next begin_stroke(). The background thread only reads.
|
||||
let after_ptr = SendPtr::new(layer.pixels.as_ptr());
|
||||
let after_len = layer.pixels.len();
|
||||
let (after_ptr, after_len) = if self.editing_mask {
|
||||
if layer.mask_pixels.is_none() {
|
||||
layer.mask_pixels = Some(vec![255; (layer.width * layer.height) as usize]);
|
||||
}
|
||||
let mask = layer.mask_pixels.as_ref().unwrap();
|
||||
(SendPtr::new(mask.as_ptr()), mask.len())
|
||||
} else {
|
||||
(SendPtr::new(layer.pixels.as_ptr()), layer.pixels.len())
|
||||
};
|
||||
|
||||
let after_shapes = if let LayerData::Vector { shapes } = &layer.data {
|
||||
Some(shapes.clone())
|
||||
@@ -275,6 +382,7 @@ impl Engine {
|
||||
let style = self.current_tip.style;
|
||||
let bounds = self.last_stroke_bounds;
|
||||
let pending_history = self.pending_history.clone();
|
||||
let is_mask = self.editing_mask;
|
||||
|
||||
std::thread::spawn(move || {
|
||||
let t_start = std::time::Instant::now();
|
||||
@@ -290,25 +398,33 @@ impl Engine {
|
||||
before_shapes,
|
||||
after_shapes,
|
||||
description: format!(
|
||||
"Brush Stroke ({})",
|
||||
"{} ({})",
|
||||
if is_mask { "Mask Edit" } else { "Brush Stroke" },
|
||||
crate::stroke_brush::brush_style_label(style)
|
||||
),
|
||||
return_before: None,
|
||||
return_after: None,
|
||||
return_mask_before: mask_before,
|
||||
is_mask,
|
||||
};
|
||||
|
||||
if let Some([sx0, sy0, sx1, sy1]) = bounds {
|
||||
if sx0 < sx1 && sy0 < sy1 {
|
||||
let rw = sx1 - sx0;
|
||||
let rh = sy1 - sy0;
|
||||
let rect_size = (rw * rh * 4) as usize;
|
||||
let rect_size = if is_mask {
|
||||
(rw * rh) as usize
|
||||
} else {
|
||||
(rw * rh * 4) as usize
|
||||
};
|
||||
log::debug!("[end_stroke_bg] bounds=[{},{},{},{}], rw={}, rh={}, rect_size={}, before.len()={}, after.len()={}, lw={}", sx0, sy0, sx1, sy1, rw, rh, rect_size, before.len(), after_slice.len(), lw);
|
||||
let mut before_rect = vec![0u8; rect_size];
|
||||
let mut after_rect = vec![0u8; rect_size];
|
||||
let bpp = if is_mask { 1 } else { 4 };
|
||||
for row in 0..rh {
|
||||
let src_start = (((sy0 + row) * lw + sx0) * 4) as usize;
|
||||
let dst_start = (row * rw * 4) as usize;
|
||||
let len = (rw * 4) as usize;
|
||||
let src_start = (((sy0 + row) * lw + sx0) * bpp) as usize;
|
||||
let dst_start = (row * rw * bpp) as usize;
|
||||
let len = (rw * bpp) as usize;
|
||||
before_rect[dst_start..dst_start + len]
|
||||
.copy_from_slice(&before[src_start..src_start + len]);
|
||||
after_rect[dst_start..dst_start + len]
|
||||
@@ -320,7 +436,9 @@ impl Engine {
|
||||
item.after_pixels = after_rect;
|
||||
item.bounds = Some([sx0, sy0, sx1, sy1]);
|
||||
// Return before buffer to pool (after is a borrowed pointer)
|
||||
item.return_before = Some(before);
|
||||
if !is_mask {
|
||||
item.return_before = Some(before);
|
||||
}
|
||||
pending_history.lock().unwrap().push(item);
|
||||
}
|
||||
log::trace!(
|
||||
@@ -426,60 +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 layers_needing_tiles: Vec<usize> = (0..active_idx)
|
||||
.filter(|&ti| !self.document.layers[ti].pixels.is_empty() && self.tile_layers[ti].is_none())
|
||||
.collect();
|
||||
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 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))
|
||||
.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,
|
||||
);
|
||||
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
|
||||
);
|
||||
self.below_cache = Some(cache);
|
||||
self.below_cache_active_idx = Some(active_idx);
|
||||
self.below_cache_dirty = false;
|
||||
|
||||
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;
|
||||
}
|
||||
} else {
|
||||
log::trace!("[begin_stroke] active_idx=0 (bottom layer), no below_cache");
|
||||
self.below_cache = None;
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
use hcie_engine_api::Engine;
|
||||
|
||||
#[test]
|
||||
fn test_layer_mask_api_and_editing() {
|
||||
let mut engine = Engine::new(100, 100);
|
||||
let layer_id = engine.active_layer_id();
|
||||
|
||||
// Initially layer has no mask
|
||||
assert!(!engine.has_layer_mask(layer_id));
|
||||
assert!(engine.get_layer_mask_pixels(layer_id).is_none());
|
||||
|
||||
// Add layer mask
|
||||
engine.add_layer_mask(layer_id);
|
||||
assert!(engine.has_layer_mask(layer_id));
|
||||
let mask = engine.get_layer_mask_pixels(layer_id).expect("Mask pixels must exist");
|
||||
assert_eq!(mask.len(), 100 * 100);
|
||||
assert_eq!(mask[0], 255);
|
||||
|
||||
// Toggle mask editing mode
|
||||
assert!(!engine.is_editing_mask());
|
||||
engine.set_editing_mask(true);
|
||||
assert!(engine.is_editing_mask());
|
||||
|
||||
// Set custom mask pixels
|
||||
let custom_mask = vec![128u8; 100 * 100];
|
||||
engine.set_layer_mask_pixels(layer_id, custom_mask.clone(), Some([0, 0, 100, 100]));
|
||||
assert_eq!(engine.get_layer_mask_pixels(layer_id), Some(custom_mask.as_slice()));
|
||||
|
||||
// Remove layer mask
|
||||
engine.remove_layer_mask(layer_id);
|
||||
assert!(!engine.has_layer_mask(layer_id));
|
||||
assert!(engine.get_layer_mask_pixels(layer_id).is_none());
|
||||
}
|
||||
@@ -21,5 +21,5 @@ crate-type = ["staticlib", "rlib"]
|
||||
rstest = "0.23"
|
||||
proptest = "1.5"
|
||||
approx = "0.5"
|
||||
egui = "0.34"
|
||||
eframe = { version = "0.34", default-features = false, features = ["default_fonts", "glow"] }
|
||||
egui = { workspace = true }
|
||||
eframe = { workspace = true }
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/>
|
||||
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 268 B |
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" fill="currentColor">
|
||||
<g transform="translate(40.96,40.96) scale(17.920000) translate(-0.00,-0.00)">
|
||||
<path d="M18 10h-7V7c0-1.7 1.4-3.1 3.1-3.1 1.7 0 3.1 1.4 3.1 3.1h2c0-2.8-2.2-5-5-5S7 4.2 7 7v3H6c-1.1 0-2 .9-2 2v8c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2v-8c0-1.1-.9-2-2-2zm-6 9c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 439 B |
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,60 @@
|
||||
//! File-format routing for documents opened in editor tabs.
|
||||
//!
|
||||
//! **Purpose:** Keeps all tab-opening entry points on one engine-loading path.
|
||||
//! **Logic & Workflow:** Classifies the extension case-insensitively, delegates
|
||||
//! layered formats to their dedicated engine API importers, and uses the generic
|
||||
//! image loader for raster formats. **Side Effects / Dependencies:** Mutates only
|
||||
//! the supplied `hcie_engine_api::Engine` and reads the requested file.
|
||||
|
||||
use hcie_engine_api::Engine;
|
||||
use std::path::Path;
|
||||
|
||||
/// Loads a path into an otherwise disposable document engine.
|
||||
///
|
||||
/// **Purpose:** Ensures Open, Open Recent, Viewer, and imported file messages use
|
||||
/// identical format routing. **Logic & Workflow:** PSD, KRA, and HCIE extensions
|
||||
/// select dedicated APIs; every other extension uses `Engine::open_image`.
|
||||
/// **Arguments:** `engine` receives the file and `path` identifies it.
|
||||
/// **Returns:** `Ok(())` on success or the engine's error text on failure.
|
||||
/// **Side Effects / Dependencies:** Replaces document state inside `engine` and
|
||||
/// performs file I/O through `hcie-engine-api`.
|
||||
pub(crate) fn load_path_into_engine(engine: &mut Engine, path: &Path) -> Result<(), String> {
|
||||
let path_text = path.to_string_lossy();
|
||||
match normalized_extension(path).as_str() {
|
||||
"psd" => engine.import_psd(&path_text),
|
||||
"kra" => engine.import_kra(&path_text),
|
||||
"hcie" => engine.load_native(&path_text),
|
||||
_ => engine.open_image(&path_text),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns a lowercase extension for format routing.
|
||||
///
|
||||
/// **Purpose:** Makes format selection deterministic across differently cased
|
||||
/// file names. **Arguments:** `path` is the source file. **Returns:** A lowercase
|
||||
/// extension or an empty string. **Side Effects / Dependencies:** None.
|
||||
fn normalized_extension(path: &Path) -> String {
|
||||
path.extension()
|
||||
.and_then(|extension| extension.to_str())
|
||||
.unwrap_or_default()
|
||||
.to_ascii_lowercase()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::normalized_extension;
|
||||
use std::path::Path;
|
||||
|
||||
/// Confirms extension routing remains case-insensitive.
|
||||
///
|
||||
/// **Purpose:** Prevents layered formats from accidentally taking the raster
|
||||
/// loader after path normalization changes. **Logic & Workflow:** Exercises
|
||||
/// uppercase and extensionless names. **Arguments & Returns:** None.
|
||||
/// **Side Effects / Dependencies:** None.
|
||||
#[test]
|
||||
fn extension_normalization_is_case_insensitive() {
|
||||
assert_eq!(normalized_extension(Path::new("image.PSD")), "psd");
|
||||
assert_eq!(normalized_extension(Path::new("project.KrA")), "kra");
|
||||
assert_eq!(normalized_extension(Path::new("untitled")), "");
|
||||
}
|
||||
}
|
||||
@@ -1,126 +1,35 @@
|
||||
//! Runtime build-ID management.
|
||||
//! Runtime access to immutable HCIE build identity.
|
||||
//!
|
||||
//! **Purpose:** Reads, atomically increments, and writes `build.id` on every application startup
|
||||
//! so each launch receives a unique, monotonically increasing build number — without requiring
|
||||
//! a wrapper script or a separate build step.
|
||||
//! **Logic & Workflow:** Locates the repository root by walking upward from `CARGO_MANIFEST_DIR`
|
||||
//! until a `Cargo.toml` containing `[workspace]` is found, then reads/increments/writes `build.id`
|
||||
//! under a file lock. Exposes the resulting version string for title-bar display.
|
||||
//! **Side Effects / Dependencies:** Performs one file-system write per startup; concurrent launches
|
||||
//! are serialized via an advisory directory lock.
|
||||
//! **Purpose:** Exposes the version generated by `hcie-build-info` without
|
||||
//! depending on Cargo-only environment variables or a writable source tree.
|
||||
//! **Logic & Workflow:** Cargo's build script embeds the canonical build ID in
|
||||
//! `hcie_build_info::VERSION`; application startup clones that static value for
|
||||
//! title-bar state. **Side Effects / Dependencies:** None at runtime.
|
||||
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
/// Maximum age (seconds) before a lock is considered stale and forcefully removed.
|
||||
const LOCK_STALE_SECS: u64 = 30;
|
||||
|
||||
/// File-system lock directory used to serialize concurrent increments.
|
||||
const LOCK_DIR: &str = ".build-id.lock";
|
||||
|
||||
/// Finds the repository root by walking upward from the compile-time manifest directory.
|
||||
/// Returns the build version embedded by Cargo during compilation.
|
||||
///
|
||||
/// **Returns:** The repository root `PathBuf`.
|
||||
/// **Side Effects / Dependencies:** Panics if the workspace root cannot be located.
|
||||
fn find_repo_root() -> PathBuf {
|
||||
let manifest_dir = PathBuf::from(
|
||||
std::env::var_os("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR is required"),
|
||||
);
|
||||
let mut candidate = manifest_dir.clone();
|
||||
loop {
|
||||
let tombstone = candidate.join("Cargo.toml");
|
||||
if tombstone.exists() {
|
||||
if let Ok(content) = fs::read_to_string(&tombstone) {
|
||||
if content.contains("[workspace]") {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
if !candidate.pop() {
|
||||
panic!("could not locate repository root above {:?}", manifest_dir);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns current Unix timestamp in seconds, or 0 on clock error.
|
||||
fn unix_now() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Checks whether a lock directory is stale (older than `LOCK_STALE_SECS`).
|
||||
fn is_lock_stale(lock_dir: &Path) -> bool {
|
||||
let ts_file = lock_dir.join("ts");
|
||||
if let Ok(ts_str) = fs::read_to_string(&ts_file) {
|
||||
if let Ok(ts) = ts_str.trim().parse::<u64>() {
|
||||
return unix_now().saturating_sub(ts) > LOCK_STALE_SECS;
|
||||
}
|
||||
}
|
||||
// No timestamp file or unparseable → treat as stale.
|
||||
true
|
||||
}
|
||||
|
||||
/// Acquires an advisory directory lock, increments the counter, and releases the lock.
|
||||
///
|
||||
/// **Returns:** The new (post-increment) build ID.
|
||||
/// **Side Effects / Dependencies:** Creates and removes a temporary lock directory.
|
||||
fn increment_build_id(root: &Path) -> u64 {
|
||||
let lock_dir = root.join(LOCK_DIR);
|
||||
let counter_file = root.join("build.id");
|
||||
|
||||
// Busy-wait with stale-lock detection.
|
||||
loop {
|
||||
match fs::create_dir(&lock_dir) {
|
||||
Ok(()) => {
|
||||
// Write timestamp for stale-lock detection by other instances.
|
||||
let _ = fs::write(
|
||||
lock_dir.join("ts"),
|
||||
format!("{}\n", unix_now()),
|
||||
);
|
||||
break;
|
||||
}
|
||||
Err(_) => {
|
||||
if is_lock_stale(&lock_dir) {
|
||||
let _ = fs::remove_dir_all(&lock_dir);
|
||||
continue;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Read current value.
|
||||
let current: u64 = fs::read_to_string(&counter_file)
|
||||
.ok()
|
||||
.and_then(|s| s.trim().parse().ok())
|
||||
.unwrap_or(0);
|
||||
|
||||
let next = current + 1;
|
||||
|
||||
// Atomic write: write to temp file, then rename.
|
||||
let tmp = counter_file.with_extension("id.tmp");
|
||||
fs::write(&tmp, format!("{}\n", next)).expect("failed to write build.id tmp");
|
||||
fs::rename(&tmp, &counter_file).expect("failed to rename build.id");
|
||||
|
||||
// Release lock.
|
||||
let _ = fs::remove_dir_all(&lock_dir);
|
||||
|
||||
next
|
||||
}
|
||||
|
||||
/// Reads, increments, and returns the build version string at runtime.
|
||||
///
|
||||
/// **Returns:** A version string like `"0.1.0+build.992"`.
|
||||
/// **Side Effects / Dependencies:** Writes to `build.id` on every call (intended for startup only).
|
||||
/// **Purpose:** Supplies a stable title-bar version in development, packaged,
|
||||
/// and directly executed binaries. **Logic & Workflow:** Copies the compile-time
|
||||
/// constant instead of searching for and mutating `build.id` at startup.
|
||||
/// **Arguments:** None. **Returns:** A version such as `0.1.0+build.1019`.
|
||||
/// **Side Effects / Dependencies:** Depends only on the `hcie-build-info` crate;
|
||||
/// it performs no environment lookup or file-system access.
|
||||
pub fn runtime_build_version() -> String {
|
||||
let root = find_repo_root();
|
||||
let build_id = increment_build_id(&root);
|
||||
let base_version = hcie_build_info::VERSION
|
||||
.split("+build.")
|
||||
.next()
|
||||
.unwrap_or("0.1.0");
|
||||
format!("{base_version}+build.{build_id}")
|
||||
hcie_build_info::VERSION.to_owned()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::runtime_build_version;
|
||||
|
||||
/// Confirms startup uses the immutable build-script output.
|
||||
///
|
||||
/// **Purpose:** Prevents regression to runtime `CARGO_MANIFEST_DIR` access.
|
||||
/// **Logic & Workflow:** Compares the public wrapper with the embedded value.
|
||||
/// **Arguments & Returns:** Takes no arguments and returns nothing.
|
||||
/// **Side Effects / Dependencies:** None.
|
||||
#[test]
|
||||
fn runtime_version_is_the_compile_time_version() {
|
||||
assert_eq!(runtime_build_version(), hcie_build_info::VERSION);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
//! - Only dirty sub-regions are uploaded — never the full 33MB buffer
|
||||
//! - The checkerboard is procedural (in the fragment shader) — no CPU geometry
|
||||
|
||||
pub mod perf;
|
||||
pub mod shader_canvas;
|
||||
pub mod texture_update;
|
||||
// pub mod viewport;
|
||||
@@ -86,6 +87,8 @@ struct OverlayProgram {
|
||||
_quick_mask: bool,
|
||||
/// Active brush diameter in canvas pixels for the footprint cursor.
|
||||
brush_size: f32,
|
||||
/// Whether a raster stroke is active; used to keep idle latency metrics isolated.
|
||||
is_drawing: bool,
|
||||
/// Normalized bounds of the selected vector shape in canvas-space (x1, y1, x2, y2).
|
||||
selected_vector_bounds: Option<(f32, f32, f32, f32)>,
|
||||
/// Rotation angle (radians) of the selected vector shape.
|
||||
@@ -107,6 +110,10 @@ struct OverlayProgram {
|
||||
struct OverlayState {
|
||||
/// Current cursor position in viewport-local coordinates.
|
||||
cursor_pos: Option<Point>,
|
||||
/// Monotonic timestamp of the latest cursor event for opt-in latency diagnostics.
|
||||
cursor_event_at: Option<std::time::Instant>,
|
||||
/// Sequence used to avoid measuring duplicate redraws of one cursor event.
|
||||
cursor_event_sequence: u64,
|
||||
/// Whether the mouse is over the overlay.
|
||||
is_hovered: bool,
|
||||
/// Current handle being hovered (for cursor changes).
|
||||
@@ -1162,7 +1169,7 @@ impl canvas::Program<Message> for OverlayProgram {
|
||||
renderer: &iced::Renderer,
|
||||
_theme: &iced::Theme,
|
||||
bounds: Rectangle,
|
||||
_cursor: Cursor,
|
||||
cursor: Cursor,
|
||||
) -> Vec<canvas::Geometry> {
|
||||
let (origin_x, origin_y, _display_w, _display_h) = self.canvas_origin(bounds.size());
|
||||
let mut geometries = Vec::new();
|
||||
@@ -2003,8 +2010,12 @@ impl canvas::Program<Message> for OverlayProgram {
|
||||
}
|
||||
|
||||
// ── Crosshair cursor ─────────────────────────────────────────────
|
||||
if state.is_hovered {
|
||||
if let Some(cursor) = state.cursor_pos {
|
||||
// Strategy 4: Use the `cursor` parameter from iced's current render
|
||||
// frame instead of the retained `state.cursor_pos` (which lags by
|
||||
// one Elm update cycle). This eliminates the 1-frame positional
|
||||
// delay between the OS pointer and the brush-circle indicator.
|
||||
if cursor.is_over(bounds) {
|
||||
if let Some(cursor_point) = cursor.position_in(bounds) {
|
||||
let mut crosshair_frame = Frame::new(renderer, bounds.size());
|
||||
let crosshair_size = 10.0;
|
||||
let crosshair_color = iced::Color::from_rgb(1.0, 1.0, 1.0);
|
||||
@@ -2016,7 +2027,7 @@ impl canvas::Program<Message> for OverlayProgram {
|
||||
| hcie_engine_api::Tool::Spray
|
||||
) {
|
||||
let footprint =
|
||||
Path::circle(cursor, (self.brush_size * self.zoom * 0.5).max(1.0));
|
||||
Path::circle(cursor_point, (self.brush_size * self.zoom * 0.5).max(1.0));
|
||||
crosshair_frame.stroke(
|
||||
&footprint,
|
||||
Stroke {
|
||||
@@ -2028,8 +2039,8 @@ impl canvas::Program<Message> for OverlayProgram {
|
||||
}
|
||||
crosshair_frame.stroke(
|
||||
&Path::line(
|
||||
Point::new(cursor.x - crosshair_size, cursor.y),
|
||||
Point::new(cursor.x + crosshair_size, cursor.y),
|
||||
Point::new(cursor_point.x - crosshair_size, cursor_point.y),
|
||||
Point::new(cursor_point.x + crosshair_size, cursor_point.y),
|
||||
),
|
||||
Stroke {
|
||||
style: canvas::stroke::Style::Solid(crosshair_color),
|
||||
@@ -2039,8 +2050,8 @@ impl canvas::Program<Message> for OverlayProgram {
|
||||
);
|
||||
crosshair_frame.stroke(
|
||||
&Path::line(
|
||||
Point::new(cursor.x, cursor.y - crosshair_size),
|
||||
Point::new(cursor.x, cursor.y + crosshair_size),
|
||||
Point::new(cursor_point.x, cursor_point.y - crosshair_size),
|
||||
Point::new(cursor_point.x, cursor_point.y + crosshair_size),
|
||||
),
|
||||
Stroke {
|
||||
style: canvas::stroke::Style::Solid(crosshair_color),
|
||||
@@ -2049,6 +2060,14 @@ impl canvas::Program<Message> for OverlayProgram {
|
||||
},
|
||||
);
|
||||
geometries.push(crosshair_frame.into_geometry());
|
||||
if !self.is_drawing {
|
||||
if let Some(captured_at) = state.cursor_event_at {
|
||||
crate::canvas::perf::record_idle_cursor_draw(
|
||||
state.cursor_event_sequence,
|
||||
captured_at,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2095,6 +2114,8 @@ impl canvas::Program<Message> for OverlayProgram {
|
||||
canvas::Event::Mouse(mouse::Event::CursorMoved { position }) => {
|
||||
let local_pos = Point::new(position.x - bounds.x, position.y - bounds.y);
|
||||
state.cursor_pos = Some(local_pos);
|
||||
state.cursor_event_at = Some(std::time::Instant::now());
|
||||
state.cursor_event_sequence = state.cursor_event_sequence.wrapping_add(1);
|
||||
state.is_hovered = bounds.contains(position);
|
||||
|
||||
// Update hovered handle
|
||||
@@ -2130,6 +2151,14 @@ impl canvas::Program<Message> for OverlayProgram {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Strategy 1: Emit a lightweight redraw message on every CursorMoved
|
||||
// event so iced triggers a view() rebuild and the overlay's draw()
|
||||
// runs with the latest cursor position. Without this, iced may
|
||||
// skip repaints during idle cursor movement because no application
|
||||
// state changed.
|
||||
if matches!(event, canvas::Event::Mouse(mouse::Event::CursorMoved { .. })) {
|
||||
return (canvas::event::Status::Ignored, Some(Message::CursorOverlayRedraw));
|
||||
}
|
||||
// Always pass events through to the shader widget below
|
||||
(canvas::event::Status::Ignored, None)
|
||||
}
|
||||
@@ -2304,6 +2333,7 @@ pub fn view<'a>(
|
||||
polygon_points: doc.polygon_points.clone(),
|
||||
_quick_mask: doc.quick_mask,
|
||||
brush_size: tool_state.brush_size,
|
||||
is_drawing: tool_state.is_drawing,
|
||||
selected_vector_bounds: sel_vec_bounds,
|
||||
selected_vector_angle: sel_vec_angle,
|
||||
vector_edit_handle: doc.vector_edit_handle,
|
||||
|
||||
@@ -0,0 +1,366 @@
|
||||
//! Opt-in real-time canvas performance measurements.
|
||||
//!
|
||||
//! **Purpose:** Separates input queueing, engine drawing, CPU compositing/staging,
|
||||
//! GPU upload, and renderer preparation costs without adding production log noise.
|
||||
//! **Logic & Workflow:** When `HCIE_CANVAS_PERF=1`, callers record named durations
|
||||
//! and byte counters in a process-wide stroke accumulator. Stroke completion sorts
|
||||
//! samples and emits one p50/p95/max summary. **Side Effects / Dependencies:** Uses
|
||||
//! a standard-library mutex and `log`; disabled builds do not allocate sample data.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// Environment variable that enables canvas performance diagnostics.
|
||||
const PERF_ENV: &str = "HCIE_CANVAS_PERF";
|
||||
|
||||
/// Mutable measurements for the current stroke and renderer cadence.
|
||||
#[derive(Default)]
|
||||
struct PerfState {
|
||||
stroke_number: u64,
|
||||
active: bool,
|
||||
samples_ms: BTreeMap<&'static str, Vec<f64>>,
|
||||
dirty_bytes: u64,
|
||||
uploaded_bytes: u64,
|
||||
full_uploads: u64,
|
||||
visible_updates: u64,
|
||||
latest_render_input: Option<Instant>,
|
||||
last_prepare: Option<Instant>,
|
||||
finish_pending: bool,
|
||||
idle_cursor_samples_ms: Vec<f64>,
|
||||
idle_view_samples_ms: Vec<f64>,
|
||||
idle_canvas_messages: u64,
|
||||
idle_dock_messages: u64,
|
||||
last_idle_cursor_sequence: Option<u64>,
|
||||
}
|
||||
|
||||
/// Records one application view reconstruction during cursor diagnostics.
|
||||
///
|
||||
/// **Arguments:** `elapsed` is the time spent constructing the Elm element tree.
|
||||
/// **Returns:** Nothing. **Side Effects / Dependencies:** Stores a bounded sample
|
||||
/// only when `HCIE_CANVAS_PERF=1`; reporting is performed by cursor draw samples.
|
||||
pub fn record_idle_view(elapsed: Duration) {
|
||||
if !enabled() {
|
||||
return;
|
||||
}
|
||||
with_state(|state| {
|
||||
if state.idle_view_samples_ms.len() < 240 {
|
||||
state
|
||||
.idle_view_samples_ms
|
||||
.push(elapsed.as_secs_f64() * 1000.0);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Counts an app-level message generated by idle pointer movement.
|
||||
///
|
||||
/// **Arguments:** `dock_message` is `true` for the global dock subscription and
|
||||
/// `false` for canvas status coordinates. **Returns:** Nothing.
|
||||
/// **Side Effects / Dependencies:** Updates diagnostic counters only when enabled.
|
||||
pub fn record_idle_pointer_message(dock_message: bool) {
|
||||
if !enabled() {
|
||||
return;
|
||||
}
|
||||
with_state(|state| {
|
||||
if dock_message {
|
||||
state.idle_dock_messages = state.idle_dock_messages.saturating_add(1);
|
||||
} else {
|
||||
state.idle_canvas_messages = state.idle_canvas_messages.saturating_add(1);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Records native cursor-event to overlay-draw latency and periodically reports it.
|
||||
///
|
||||
/// **Arguments:** `sequence` identifies a unique retained-overlay event and
|
||||
/// `captured_at` is its monotonic timestamp. **Returns:** Nothing.
|
||||
/// **Side Effects / Dependencies:** Emits one summary per 120 unique cursor draws
|
||||
/// when `HCIE_CANVAS_PERF=1`; duplicate draws of the same event are ignored.
|
||||
pub fn record_idle_cursor_draw(sequence: u64, captured_at: Instant) {
|
||||
if !enabled() {
|
||||
return;
|
||||
}
|
||||
with_state(|state| {
|
||||
if state.last_idle_cursor_sequence == Some(sequence) {
|
||||
return;
|
||||
}
|
||||
state.last_idle_cursor_sequence = Some(sequence);
|
||||
state
|
||||
.idle_cursor_samples_ms
|
||||
.push(captured_at.elapsed().as_secs_f64() * 1000.0);
|
||||
if state.idle_cursor_samples_ms.len() < 120 {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut cursor_samples = std::mem::take(&mut state.idle_cursor_samples_ms);
|
||||
let mut view_samples = std::mem::take(&mut state.idle_view_samples_ms);
|
||||
cursor_samples.sort_by(f64::total_cmp);
|
||||
view_samples.sort_by(f64::total_cmp);
|
||||
log::info!(
|
||||
target: "hcie_canvas_perf",
|
||||
"idle_cursor draws={} event_to_overlay_draw[p50={:.3}ms,p95={:.3}ms,max={:.3}ms] view_rebuild[n={},p50={:.3}ms,p95={:.3}ms,max={:.3}ms] messages[canvas={},dock={}]",
|
||||
cursor_samples.len(),
|
||||
percentile(&cursor_samples, 0.50),
|
||||
percentile(&cursor_samples, 0.95),
|
||||
cursor_samples.last().copied().unwrap_or(0.0),
|
||||
view_samples.len(),
|
||||
percentile(&view_samples, 0.50),
|
||||
percentile(&view_samples, 0.95),
|
||||
view_samples.last().copied().unwrap_or(0.0),
|
||||
state.idle_canvas_messages,
|
||||
state.idle_dock_messages,
|
||||
);
|
||||
state.idle_canvas_messages = 0;
|
||||
state.idle_dock_messages = 0;
|
||||
});
|
||||
}
|
||||
|
||||
/// Returns whether diagnostics are enabled for this process.
|
||||
///
|
||||
/// **Arguments:** None. **Returns:** `true` only for `1`, `true`, or `yes`.
|
||||
/// **Side Effects / Dependencies:** Reads `HCIE_CANVAS_PERF` once and caches it.
|
||||
pub fn enabled() -> bool {
|
||||
static ENABLED: OnceLock<bool> = OnceLock::new();
|
||||
*ENABLED.get_or_init(|| {
|
||||
std::env::var(PERF_ENV)
|
||||
.map(|value| matches!(value.to_ascii_lowercase().as_str(), "1" | "true" | "yes"))
|
||||
.unwrap_or(false)
|
||||
})
|
||||
}
|
||||
|
||||
/// Starts a fresh stroke measurement window.
|
||||
///
|
||||
/// **Arguments:** None. **Returns:** Nothing.
|
||||
/// **Side Effects / Dependencies:** Clears prior samples and resets frame cadence.
|
||||
pub fn begin_stroke() {
|
||||
if !enabled() {
|
||||
return;
|
||||
}
|
||||
with_state(|state| {
|
||||
state.stroke_number = state.stroke_number.wrapping_add(1);
|
||||
state.active = true;
|
||||
state.samples_ms.clear();
|
||||
state.dirty_bytes = 0;
|
||||
state.uploaded_bytes = 0;
|
||||
state.full_uploads = 0;
|
||||
state.visible_updates = 0;
|
||||
state.latest_render_input = None;
|
||||
state.last_prepare = None;
|
||||
state.finish_pending = false;
|
||||
});
|
||||
}
|
||||
|
||||
/// Records elapsed time for one named pipeline stage.
|
||||
///
|
||||
/// **Arguments:** `stage` is a stable metric name and `elapsed` is its duration.
|
||||
/// **Returns:** Nothing. **Side Effects / Dependencies:** Appends one sample while active.
|
||||
pub fn record_duration(stage: &'static str, elapsed: Duration) {
|
||||
if !enabled() {
|
||||
return;
|
||||
}
|
||||
with_state(|state| {
|
||||
if state.active {
|
||||
state
|
||||
.samples_ms
|
||||
.entry(stage)
|
||||
.or_default()
|
||||
.push(elapsed.as_secs_f64() * 1000.0);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Records event queue delay and marks the input represented by the next texture update.
|
||||
///
|
||||
/// **Arguments:** `captured_at` is the time the shader widget emitted the input message.
|
||||
/// **Returns:** Nothing. **Side Effects / Dependencies:** Updates input latency samples.
|
||||
pub fn record_render_input(captured_at: Instant) {
|
||||
if !enabled() {
|
||||
return;
|
||||
}
|
||||
with_state(|state| {
|
||||
if state.active {
|
||||
state
|
||||
.samples_ms
|
||||
.entry("input_to_update")
|
||||
.or_default()
|
||||
.push(captured_at.elapsed().as_secs_f64() * 1000.0);
|
||||
state.latest_render_input = Some(captured_at);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Returns the latest input timestamp that changed engine pixels.
|
||||
///
|
||||
/// **Arguments:** None. **Returns:** Optional monotonic input timestamp.
|
||||
/// **Side Effects / Dependencies:** Reads the shared diagnostic accumulator.
|
||||
pub fn latest_render_input() -> Option<Instant> {
|
||||
if !enabled() {
|
||||
return None;
|
||||
}
|
||||
with_state(|state| state.latest_render_input)
|
||||
}
|
||||
|
||||
/// Records one dirty region produced by CPU compositing.
|
||||
///
|
||||
/// **Arguments:** `region` uses exclusive bounds. **Returns:** Nothing.
|
||||
/// **Side Effects / Dependencies:** Adds the RGBA byte area to the current stroke.
|
||||
pub fn record_dirty_region(region: [u32; 4]) {
|
||||
if !enabled() {
|
||||
return;
|
||||
}
|
||||
let bytes = (region[2].saturating_sub(region[0]) as u64)
|
||||
* (region[3].saturating_sub(region[1]) as u64)
|
||||
* 4;
|
||||
with_state(|state| {
|
||||
if state.active {
|
||||
state.dirty_bytes = state.dirty_bytes.saturating_add(bytes);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Records bytes submitted to the GPU texture and whether the upload was full-canvas.
|
||||
///
|
||||
/// **Arguments:** `bytes` is payload size and `full` identifies recovery/replace uploads.
|
||||
/// **Returns:** Nothing. **Side Effects / Dependencies:** Updates current stroke counters.
|
||||
pub fn record_upload(bytes: usize, full: bool) {
|
||||
if !enabled() {
|
||||
return;
|
||||
}
|
||||
with_state(|state| {
|
||||
if state.active {
|
||||
state.uploaded_bytes = state.uploaded_bytes.saturating_add(bytes as u64);
|
||||
state.full_uploads += u64::from(full);
|
||||
state.visible_updates += 1;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Records renderer preparation cadence and input-to-GPU-prepare proxy latency.
|
||||
///
|
||||
/// **Arguments:** `input_at` is the latest pixel-changing input represented by this update.
|
||||
/// **Returns:** Nothing. **Side Effects / Dependencies:** Updates shared prepare timing state.
|
||||
pub fn record_prepare(input_at: Option<Instant>) {
|
||||
if !enabled() {
|
||||
return;
|
||||
}
|
||||
let now = Instant::now();
|
||||
let should_finish = with_state(|state| {
|
||||
if let Some(previous) = state.last_prepare.replace(now) {
|
||||
if state.active {
|
||||
state
|
||||
.samples_ms
|
||||
.entry("prepare_interval")
|
||||
.or_default()
|
||||
.push(now.duration_since(previous).as_secs_f64() * 1000.0);
|
||||
}
|
||||
}
|
||||
if state.active {
|
||||
if let Some(input_at) = input_at {
|
||||
state
|
||||
.samples_ms
|
||||
.entry("input_to_gpu_prepare_proxy")
|
||||
.or_default()
|
||||
.push(now.duration_since(input_at).as_secs_f64() * 1000.0);
|
||||
}
|
||||
}
|
||||
state.finish_pending
|
||||
});
|
||||
if should_finish {
|
||||
finish_stroke();
|
||||
}
|
||||
}
|
||||
|
||||
/// Requests summary emission after the final renderer preparation for this stroke.
|
||||
///
|
||||
/// **Arguments:** None. **Returns:** Nothing.
|
||||
/// **Side Effects / Dependencies:** Marks the active diagnostic window for deferred completion.
|
||||
pub fn request_finish_stroke() {
|
||||
if !enabled() {
|
||||
return;
|
||||
}
|
||||
with_state(|state| {
|
||||
if state.active {
|
||||
state.finish_pending = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Finishes the current stroke and logs one percentile summary.
|
||||
///
|
||||
/// **Arguments:** None. **Returns:** Nothing.
|
||||
/// **Side Effects / Dependencies:** Sorts captured samples and emits one info-level log entry.
|
||||
fn finish_stroke() {
|
||||
if !enabled() {
|
||||
return;
|
||||
}
|
||||
with_state(|state| {
|
||||
if !state.active {
|
||||
return;
|
||||
}
|
||||
state.active = false;
|
||||
state.finish_pending = false;
|
||||
let mut stages = Vec::with_capacity(state.samples_ms.len());
|
||||
for (name, samples) in &mut state.samples_ms {
|
||||
samples.sort_by(f64::total_cmp);
|
||||
if let Some(max) = samples.last().copied() {
|
||||
stages.push(format!(
|
||||
"{}[n={},p50={:.3}ms,p95={:.3}ms,max={:.3}ms]",
|
||||
name,
|
||||
samples.len(),
|
||||
percentile(samples, 0.50),
|
||||
percentile(samples, 0.95),
|
||||
max
|
||||
));
|
||||
}
|
||||
}
|
||||
log::info!(
|
||||
target: "hcie_canvas_perf",
|
||||
"stroke={} {} counters[dirty={}B,uploaded={}B,full_uploads={},visible_updates={}]",
|
||||
state.stroke_number,
|
||||
stages.join(" "),
|
||||
state.dirty_bytes,
|
||||
state.uploaded_bytes,
|
||||
state.full_uploads,
|
||||
state.visible_updates
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/// Returns a percentile from a sorted sample slice.
|
||||
///
|
||||
/// **Arguments:** `samples` must be sorted and `fraction` is in `0.0..=1.0`.
|
||||
/// **Returns:** Selected sample or zero for an empty slice. **Side Effects:** None.
|
||||
fn percentile(samples: &[f64], fraction: f64) -> f64 {
|
||||
if samples.is_empty() {
|
||||
return 0.0;
|
||||
}
|
||||
let index = ((samples.len() - 1) as f64 * fraction.clamp(0.0, 1.0)).round() as usize;
|
||||
samples[index]
|
||||
}
|
||||
|
||||
/// Executes a closure while holding the diagnostic state lock.
|
||||
///
|
||||
/// **Arguments:** `operation` receives mutable state. **Returns:** Closure result.
|
||||
/// **Side Effects / Dependencies:** Initializes and locks the process-wide accumulator.
|
||||
fn with_state<T>(operation: impl FnOnce(&mut PerfState) -> T) -> T {
|
||||
static STATE: OnceLock<Mutex<PerfState>> = OnceLock::new();
|
||||
let mut state = STATE
|
||||
.get_or_init(|| Mutex::new(PerfState::default()))
|
||||
.lock()
|
||||
.unwrap_or_else(|error| error.into_inner());
|
||||
operation(&mut state)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::percentile;
|
||||
|
||||
/// Confirms percentile selection is deterministic for short frame samples.
|
||||
#[test]
|
||||
fn percentile_uses_nearest_ranked_sample() {
|
||||
let samples = [1.0, 2.0, 3.0, 4.0, 5.0];
|
||||
assert_eq!(percentile(&samples, 0.50), 3.0);
|
||||
assert_eq!(percentile(&samples, 0.95), 5.0);
|
||||
assert_eq!(percentile(&[], 0.50), 0.0);
|
||||
}
|
||||
}
|
||||
@@ -231,6 +231,7 @@ impl CanvasShaderPipeline {
|
||||
canvas_h,
|
||||
initial_pixels.len()
|
||||
);
|
||||
super::perf::record_upload(initial_pixels.len(), true);
|
||||
}
|
||||
|
||||
// ── Sampler (nearest for zoom-in, linear for zoom-out) ───────────
|
||||
@@ -517,6 +518,7 @@ impl CanvasShaderPipeline {
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
);
|
||||
super::perf::record_upload(pixels.len(), true);
|
||||
}
|
||||
|
||||
// Recreate selection mask texture with new dimensions
|
||||
@@ -570,6 +572,59 @@ impl CanvasShaderPipeline {
|
||||
self.texture_h = canvas_h;
|
||||
}
|
||||
|
||||
/// Replace every pixel in the existing composite texture without rebuilding GPU resources.
|
||||
///
|
||||
/// ## Arguments
|
||||
/// * `queue` — wgpu queue used for the texture write
|
||||
/// * `pixels` — complete tightly packed RGBA image matching the current dimensions
|
||||
///
|
||||
/// ## Returns
|
||||
/// Nothing. Invalid buffer lengths are logged and ignored.
|
||||
///
|
||||
/// ## Side Effects
|
||||
/// Enqueues one full texture write while preserving texture views, selection resources,
|
||||
/// samplers, and the bind group.
|
||||
fn upload_full_texture(&self, queue: &wgpu::Queue, pixels: &[u8]) {
|
||||
let expected = self.texture_w as usize * self.texture_h as usize * 4;
|
||||
if self.texture_w == 0 || self.texture_h == 0 || pixels.len() != expected {
|
||||
log::warn!(
|
||||
"[CanvasShaderPipeline::upload_full_texture] size mismatch: pixels={} expected={} canvas={}x{}",
|
||||
pixels.len(),
|
||||
expected,
|
||||
self.texture_w,
|
||||
self.texture_h
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
queue.write_texture(
|
||||
wgpu::ImageCopyTexture {
|
||||
texture: &self.texture,
|
||||
mip_level: 0,
|
||||
origin: wgpu::Origin3d::ZERO,
|
||||
aspect: wgpu::TextureAspect::All,
|
||||
},
|
||||
pixels,
|
||||
wgpu::ImageDataLayout {
|
||||
offset: 0,
|
||||
bytes_per_row: Some(self.texture_w * 4),
|
||||
rows_per_image: Some(self.texture_h),
|
||||
},
|
||||
wgpu::Extent3d {
|
||||
width: self.texture_w,
|
||||
height: self.texture_h,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
);
|
||||
super::perf::record_upload(pixels.len(), true);
|
||||
log::debug!(
|
||||
"[CanvasShaderPipeline::upload_full_texture] uploaded {}x{} texture ({} bytes)",
|
||||
self.texture_w,
|
||||
self.texture_h,
|
||||
pixels.len()
|
||||
);
|
||||
}
|
||||
|
||||
/// Upload only the dirty sub-region of the composite buffer to the GPU.
|
||||
///
|
||||
/// ## Arguments
|
||||
@@ -598,6 +653,8 @@ impl CanvasShaderPipeline {
|
||||
},
|
||||
);
|
||||
let elapsed = t0.elapsed();
|
||||
super::perf::record_duration("gpu_upload", elapsed);
|
||||
super::perf::record_upload(update.pixels.len(), false);
|
||||
log::debug!(
|
||||
"[CanvasShaderPipeline::upload_dirty_region] {}×{} region at ({},{}) → {:.2}ms, {} bytes",
|
||||
update.width(), update.height(), x0, y0, elapsed.as_secs_f64() * 1000.0, update.pixels.len()
|
||||
@@ -710,6 +767,15 @@ impl shader::Primitive for CanvasShaderPrimitive {
|
||||
_viewport: &shader::Viewport,
|
||||
) {
|
||||
let t0 = std::time::Instant::now();
|
||||
let represented_input = self
|
||||
.texture_update
|
||||
.as_ref()
|
||||
.and_then(|update| update.input_at)
|
||||
.or_else(|| {
|
||||
self.full_upload
|
||||
.then(super::perf::latest_render_input)
|
||||
.flatten()
|
||||
});
|
||||
|
||||
// ── Create or retrieve pipeline ──────────────────────────────────
|
||||
let created_pipeline = !storage.has::<CanvasShaderPipeline>();
|
||||
@@ -736,9 +802,9 @@ impl shader::Primitive for CanvasShaderPrimitive {
|
||||
let pixels = self.composite_pixels.read();
|
||||
pipeline.resize_texture(device, queue, self.canvas_w, self.canvas_h, &pixels);
|
||||
} else if !created_pipeline && self.full_upload {
|
||||
// Full upload requested (e.g., after loading a file)
|
||||
// Same-size document replacement: preserve all persistent GPU resources.
|
||||
let pixels = self.composite_pixels.read();
|
||||
pipeline.resize_texture(device, queue, self.canvas_w, self.canvas_h, &pixels);
|
||||
pipeline.upload_full_texture(queue, &pixels);
|
||||
} else if !created_pipeline {
|
||||
// ── Partial upload: only the dirty sub-region ────────────────
|
||||
if let Some(update) = self.texture_update.as_ref() {
|
||||
@@ -806,6 +872,8 @@ impl shader::Primitive for CanvasShaderPrimitive {
|
||||
pipeline.update_uniforms(queue, &uniforms);
|
||||
|
||||
let elapsed = t0.elapsed();
|
||||
super::perf::record_duration("gpu_prepare", elapsed);
|
||||
super::perf::record_prepare(represented_input);
|
||||
log::trace!(
|
||||
"[CanvasShaderPrimitive::prepare] {:.2}ms | viewport={}×{} | canvas={}×{} | zoom={:.2}",
|
||||
elapsed.as_secs_f64() * 1000.0,
|
||||
@@ -893,6 +961,31 @@ pub struct CanvasShaderState {
|
||||
pub middle_pressed: bool,
|
||||
/// Distinguishes Space+left temporary pan from a physical middle-button pan.
|
||||
pub space_left_pan: bool,
|
||||
/// Immediate local pan offset maintained during active panning to eliminate 1-frame Elm update latency.
|
||||
pub local_pan_offset: Option<Vector>,
|
||||
/// Last canvas coordinate published to the status bar.
|
||||
pub last_status_cursor: Option<(u32, u32)>,
|
||||
/// Time of the last status-bar coordinate publication.
|
||||
pub last_status_emit: Option<std::time::Instant>,
|
||||
/// Last pane size sent to application state, stored as exact float bit patterns.
|
||||
pub last_reported_pane_size: Option<(u32, u32)>,
|
||||
}
|
||||
|
||||
/// Minimum interval between status-bar cursor messages during idle movement.
|
||||
const CURSOR_STATUS_INTERVAL: std::time::Duration = std::time::Duration::from_millis(500);
|
||||
|
||||
/// Decides whether an idle cursor coordinate needs an Elm application message.
|
||||
///
|
||||
/// **Arguments:** `previous` and `current` are integer canvas coordinates;
|
||||
/// `elapsed` is time since the previous publication, or `None` for the first.
|
||||
/// **Returns:** `true` only for a changed coordinate whose interval is due.
|
||||
/// **Side Effects / Dependencies:** None.
|
||||
fn should_publish_cursor_status(
|
||||
previous: Option<(u32, u32)>,
|
||||
current: (u32, u32),
|
||||
elapsed: Option<std::time::Duration>,
|
||||
) -> bool {
|
||||
previous != Some(current) && elapsed.map_or(true, |duration| duration >= CURSOR_STATUS_INTERVAL)
|
||||
}
|
||||
|
||||
/// Canvas shader program — implements `iced::widget::shader::Program`.
|
||||
@@ -930,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,
|
||||
@@ -959,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 {
|
||||
@@ -988,6 +1072,10 @@ impl shader::Program<Message> for CanvasShaderProgram {
|
||||
cursor: mouse::Cursor,
|
||||
_shell: &mut Shell<'_, Message>,
|
||||
) -> (iced::event::Status, Option<Message>) {
|
||||
if !state.middle_pressed {
|
||||
state.local_pan_offset = None;
|
||||
}
|
||||
|
||||
match event {
|
||||
shader::Event::Mouse(mouse_event) => {
|
||||
match mouse_event {
|
||||
@@ -996,8 +1084,6 @@ impl shader::Program<Message> for CanvasShaderProgram {
|
||||
state.cursor_pos = Some(local_pos);
|
||||
state.is_hovered = bounds.contains(position);
|
||||
|
||||
let pane_size_msg = Message::CanvasSize((bounds.width, bounds.height));
|
||||
|
||||
let (canvas_x, canvas_y) = screen_to_canvas_local(
|
||||
local_pos,
|
||||
bounds.width,
|
||||
@@ -1005,18 +1091,21 @@ impl shader::Program<Message> for CanvasShaderProgram {
|
||||
self.engine_w as f32,
|
||||
self.engine_h as f32,
|
||||
self.zoom,
|
||||
self.pan_offset,
|
||||
state.local_pan_offset.unwrap_or(self.pan_offset),
|
||||
);
|
||||
|
||||
// Left mouse drag (drawing)
|
||||
if state.left_pressed {
|
||||
if let (Some(cx), Some(cy)) = (canvas_x, canvas_y) {
|
||||
return (
|
||||
iced::event::Status::Captured,
|
||||
Some(Message::CanvasPointerMoved { x: cx, y: cy }),
|
||||
);
|
||||
}
|
||||
return (iced::event::Status::Captured, Some(pane_size_msg));
|
||||
let cx = canvas_x.unwrap_or(0.0);
|
||||
let cy = canvas_y.unwrap_or(0.0);
|
||||
return (
|
||||
iced::event::Status::Captured,
|
||||
Some(Message::CanvasPointerMoved {
|
||||
x: cx,
|
||||
y: cy,
|
||||
captured_at: std::time::Instant::now(),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// Middle mouse drag (panning)
|
||||
@@ -1024,7 +1113,8 @@ impl shader::Program<Message> for CanvasShaderProgram {
|
||||
let start = state.pan_start.unwrap();
|
||||
let delta = position - start;
|
||||
state.pan_start = Some(position);
|
||||
let mut new_pan = self.pan_offset;
|
||||
let current_pan = state.local_pan_offset.unwrap_or(self.pan_offset);
|
||||
let mut new_pan = current_pan;
|
||||
new_pan.x += delta.x;
|
||||
new_pan.y += delta.y;
|
||||
|
||||
@@ -1048,6 +1138,8 @@ impl shader::Program<Message> for CanvasShaderProgram {
|
||||
(bounds.height - min_vis_h).max(0.0),
|
||||
) - default_oy;
|
||||
|
||||
state.local_pan_offset = Some(new_pan);
|
||||
|
||||
return (
|
||||
iced::event::Status::Captured,
|
||||
Some(Message::CanvasPanZoom {
|
||||
@@ -1059,16 +1151,38 @@ impl shader::Program<Message> for CanvasShaderProgram {
|
||||
|
||||
// Cursor over canvas → status bar coords
|
||||
if let (Some(cx), Some(cy)) = (canvas_x, canvas_y) {
|
||||
return (
|
||||
iced::event::Status::Captured,
|
||||
Some(Message::CanvasCursorPos {
|
||||
x: cx as u32,
|
||||
y: cy as u32,
|
||||
}),
|
||||
);
|
||||
let current = (cx as u32, cy as u32);
|
||||
let now = std::time::Instant::now();
|
||||
let elapsed = state
|
||||
.last_status_emit
|
||||
.map(|previous| now.duration_since(previous));
|
||||
if should_publish_cursor_status(
|
||||
state.last_status_cursor,
|
||||
current,
|
||||
elapsed,
|
||||
) {
|
||||
state.last_status_cursor = Some(current);
|
||||
state.last_status_emit = Some(now);
|
||||
return (
|
||||
iced::event::Status::Captured,
|
||||
Some(Message::CanvasCursorPos {
|
||||
x: current.0,
|
||||
y: current.1,
|
||||
}),
|
||||
);
|
||||
}
|
||||
return (iced::event::Status::Captured, None);
|
||||
}
|
||||
|
||||
return (iced::event::Status::Captured, Some(pane_size_msg));
|
||||
let pane_size = (bounds.width.to_bits(), bounds.height.to_bits());
|
||||
if state.last_reported_pane_size != Some(pane_size) {
|
||||
state.last_reported_pane_size = Some(pane_size);
|
||||
return (
|
||||
iced::event::Status::Captured,
|
||||
Some(Message::CanvasSize((bounds.width, bounds.height))),
|
||||
);
|
||||
}
|
||||
return (iced::event::Status::Captured, None);
|
||||
}
|
||||
|
||||
mouse::Event::ButtonPressed(button) => {
|
||||
@@ -1079,6 +1193,7 @@ impl shader::Program<Message> for CanvasShaderProgram {
|
||||
state.middle_pressed = true;
|
||||
state.space_left_pan = true;
|
||||
state.pan_start = cursor.position();
|
||||
state.local_pan_offset = Some(self.pan_offset);
|
||||
return (iced::event::Status::Captured, None);
|
||||
}
|
||||
let local_pos = {
|
||||
@@ -1098,13 +1213,18 @@ impl shader::Program<Message> for CanvasShaderProgram {
|
||||
state.left_pressed = true;
|
||||
return (
|
||||
iced::event::Status::Captured,
|
||||
Some(Message::CanvasPointerPressed { x: cx, y: cy }),
|
||||
Some(Message::CanvasPointerPressed {
|
||||
x: cx,
|
||||
y: cy,
|
||||
captured_at: std::time::Instant::now(),
|
||||
}),
|
||||
);
|
||||
}
|
||||
} else if button == mouse::Button::Middle {
|
||||
if let Some(pos) = cursor.position() {
|
||||
state.middle_pressed = true;
|
||||
state.pan_start = Some(pos);
|
||||
state.local_pan_offset = Some(self.pan_offset);
|
||||
return (iced::event::Status::Captured, None);
|
||||
}
|
||||
} else if button == mouse::Button::Right {
|
||||
@@ -1142,6 +1262,7 @@ impl shader::Program<Message> for CanvasShaderProgram {
|
||||
state.middle_pressed = false;
|
||||
state.space_left_pan = false;
|
||||
state.pan_start = None;
|
||||
state.local_pan_offset = None;
|
||||
return (iced::event::Status::Captured, None);
|
||||
}
|
||||
state.left_pressed = false;
|
||||
@@ -1152,6 +1273,7 @@ impl shader::Program<Message> for CanvasShaderProgram {
|
||||
} else if button == mouse::Button::Middle {
|
||||
state.middle_pressed = false;
|
||||
state.pan_start = None;
|
||||
state.local_pan_offset = None;
|
||||
return (iced::event::Status::Captured, None);
|
||||
}
|
||||
}
|
||||
@@ -1240,7 +1362,7 @@ impl shader::Program<Message> for CanvasShaderProgram {
|
||||
/// and pixel data so `prepare()` can upload only the changed sub-region.
|
||||
fn draw(
|
||||
&self,
|
||||
_state: &Self::State,
|
||||
state: &Self::State,
|
||||
_cursor: mouse::Cursor,
|
||||
bounds: Rectangle,
|
||||
) -> Self::Primitive {
|
||||
@@ -1248,7 +1370,7 @@ impl shader::Program<Message> for CanvasShaderProgram {
|
||||
canvas_w: self.engine_w,
|
||||
canvas_h: self.engine_h,
|
||||
zoom: self.zoom,
|
||||
pan_offset: self.pan_offset,
|
||||
pan_offset: state.local_pan_offset.unwrap_or(self.pan_offset),
|
||||
texture_update: self.texture_update.clone(),
|
||||
composite_pixels: self.composite_pixels.clone(),
|
||||
full_upload: self.full_upload,
|
||||
@@ -1277,10 +1399,38 @@ impl shader::Program<Message> for CanvasShaderProgram {
|
||||
|
||||
#[cfg(test)]
|
||||
mod shader_regression_tests {
|
||||
use super::{should_publish_cursor_status, CURSOR_STATUS_INTERVAL};
|
||||
|
||||
/// Prevents restoration of the nine-sample per-fragment selection border detector.
|
||||
#[test]
|
||||
fn selection_overlay_uses_one_texture_sample() {
|
||||
let shader = include_str!("canvas.wgsl");
|
||||
assert_eq!(shader.matches("textureSample(selection_texture").count(), 1);
|
||||
}
|
||||
|
||||
/// Confirms status-bar messages are bounded without suppressing the first update.
|
||||
///
|
||||
/// **Purpose:** Protects the retained overlay from accidental per-pointer Elm
|
||||
/// rebuilds. **Logic & Workflow:** Exercises first, early, due, and unchanged
|
||||
/// cursor publications. **Arguments & Returns:** None.
|
||||
/// **Side Effects / Dependencies:** None.
|
||||
#[test]
|
||||
fn idle_cursor_status_updates_are_rate_limited() {
|
||||
assert!(should_publish_cursor_status(None, (10, 20), None));
|
||||
assert!(!should_publish_cursor_status(
|
||||
Some((10, 20)),
|
||||
(11, 20),
|
||||
Some(CURSOR_STATUS_INTERVAL / 2),
|
||||
));
|
||||
assert!(should_publish_cursor_status(
|
||||
Some((10, 20)),
|
||||
(11, 20),
|
||||
Some(CURSOR_STATUS_INTERVAL),
|
||||
));
|
||||
assert!(!should_publish_cursor_status(
|
||||
Some((11, 20)),
|
||||
(11, 20),
|
||||
Some(CURSOR_STATUS_INTERVAL * 2),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,6 +91,8 @@ pub struct TextureUpdate {
|
||||
pub bytes_per_row: u32,
|
||||
/// Immutable packed RGBA rows.
|
||||
pub pixels: Arc<[u8]>,
|
||||
/// Latest pixel-changing input represented by this upload, for diagnostics.
|
||||
pub input_at: Option<std::time::Instant>,
|
||||
}
|
||||
|
||||
impl TextureUpdate {
|
||||
@@ -100,6 +102,7 @@ impl TextureUpdate {
|
||||
/// exclusive canvas bounds. **Returns:** A tightly packed upload payload or a validation error.
|
||||
/// **Side Effects / Dependencies:** Allocates exactly `dirty_width * dirty_height * 4` bytes.
|
||||
pub fn pack(source: &[u8], canvas_w: u32, region: [u32; 4]) -> Result<Self, &'static str> {
|
||||
let started = std::time::Instant::now();
|
||||
let validated = ValidatedRegion::new(source.len(), canvas_w, region, 4)?;
|
||||
let mut pixels = vec![0; validated.row_bytes * validated.height];
|
||||
for row in 0..validated.height {
|
||||
@@ -108,11 +111,14 @@ impl TextureUpdate {
|
||||
pixels[target_offset..target_offset + validated.row_bytes]
|
||||
.copy_from_slice(&source[source_offset..source_offset + validated.row_bytes]);
|
||||
}
|
||||
Ok(Self {
|
||||
let update = Self {
|
||||
region,
|
||||
bytes_per_row: validated.row_bytes as u32,
|
||||
pixels: pixels.into(),
|
||||
})
|
||||
input_at: super::perf::latest_render_input(),
|
||||
};
|
||||
super::perf::record_duration("dirty_pack", started.elapsed());
|
||||
Ok(update)
|
||||
}
|
||||
|
||||
/// Returns the packed rectangle width.
|
||||
|
||||
@@ -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,8 +190,13 @@ 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;
|
||||
let display_h = program_state.config.engine_h as f32 * new_zoom;
|
||||
@@ -173,12 +204,19 @@ impl Program<Message> for CanvasViewportProgram {
|
||||
let new_origin_y = cursor_pos.y - canvas_y * new_zoom;
|
||||
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.
|
||||
@@ -237,4 +281,4 @@ pub fn canvas_viewport<'a>(
|
||||
..Default::default()
|
||||
})
|
||||
.into()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -35,6 +35,15 @@ fn main() {
|
||||
.try_init();
|
||||
|
||||
log::info!("Starting HCIE Iced GUI");
|
||||
if canvas::perf::enabled() {
|
||||
log::info!(
|
||||
target: "hcie_canvas_perf",
|
||||
"renderer diagnostics enabled: ICED_BACKEND={} WGPU_BACKEND={} WGPU_POWER_PREF={}; verify iced_wgpu Selected AdapterInfo is a hardware Vulkan adapter",
|
||||
std::env::var("ICED_BACKEND").unwrap_or_else(|_| "<default>".to_string()),
|
||||
std::env::var("WGPU_BACKEND").unwrap_or_else(|_| "<default>".to_string()),
|
||||
std::env::var("WGPU_POWER_PREF").unwrap_or_else(|_| "<default>".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
let args = match cli::parse_args(std::env::args().skip(1)) {
|
||||
Ok(args) => args,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -173,15 +173,23 @@ fn layer_thumbnail<'a>(
|
||||
thumb_cache: &std::collections::HashMap<u64, (u32, u32, Vec<u8>)>,
|
||||
_thumb_gen: u64,
|
||||
colors: ThemeColors,
|
||||
is_active_target: bool,
|
||||
) -> Element<'a, Message> {
|
||||
if entry.is_group {
|
||||
return container(text("\u{1F4C1}").size(16))
|
||||
.width(50)
|
||||
.height(38)
|
||||
.center_y(Length::Fixed(38.0))
|
||||
.width(42)
|
||||
.height(32)
|
||||
.center_y(Length::Fixed(32.0))
|
||||
.into();
|
||||
}
|
||||
|
||||
let border_color = if is_active_target {
|
||||
iced::Color::WHITE
|
||||
} else {
|
||||
colors.border_high
|
||||
};
|
||||
let border_width = if is_active_target { 2 } else { 1 };
|
||||
|
||||
if let Some((tw, th, pixels)) = thumb_cache.get(&entry.info.id) {
|
||||
let (tw32, th32) = (*tw as u32, *th as u32);
|
||||
let handle = iced::widget::image::Handle::from_rgba(tw32, th32, pixels.clone());
|
||||
@@ -189,12 +197,14 @@ fn layer_thumbnail<'a>(
|
||||
.width(Length::Fixed(*tw as f32))
|
||||
.height(Length::Fixed(*th as f32));
|
||||
return container(img)
|
||||
.width(50)
|
||||
.height(38)
|
||||
.center_y(Length::Fixed(38.0))
|
||||
.width(42)
|
||||
.height(32)
|
||||
.center_x(Length::Fixed(42.0))
|
||||
.center_y(Length::Fixed(32.0))
|
||||
.padding(1)
|
||||
.style(move |_theme| iced::widget::container::Style {
|
||||
border: iced::Border::default().color(colors.border_high).width(1),
|
||||
background: Some(iced::Background::Color(iced::Color::BLACK)),
|
||||
border: iced::Border::default().color(border_color).width(border_width),
|
||||
..Default::default()
|
||||
})
|
||||
.into();
|
||||
@@ -213,15 +223,99 @@ fn layer_thumbnail<'a>(
|
||||
color: Some(icon_color),
|
||||
}
|
||||
}))
|
||||
.width(50)
|
||||
.height(38)
|
||||
.center_x(Length::Fixed(50.0))
|
||||
.center_y(Length::Fixed(38.0))
|
||||
.width(42)
|
||||
.height(32)
|
||||
.center_x(Length::Fixed(42.0))
|
||||
.center_y(Length::Fixed(32.0))
|
||||
.style(move |_theme| iced::widget::container::Style {
|
||||
background: Some(iced::Background::Color(iced::Color::from_rgba(
|
||||
1.0, 1.0, 1.0, 0.05,
|
||||
))),
|
||||
border: iced::Border::default().color(colors.border_high).width(1),
|
||||
border: iced::Border::default().color(border_color).width(border_width),
|
||||
..Default::default()
|
||||
})
|
||||
.into()
|
||||
}
|
||||
|
||||
/// Build the black-and-white thumbnail element for a layer mask.
|
||||
fn mask_thumbnail<'a>(
|
||||
engine: &hcie_engine_api::Engine,
|
||||
layer_id: u64,
|
||||
colors: ThemeColors,
|
||||
is_active_target: bool,
|
||||
) -> Element<'a, Message> {
|
||||
let border_color = if is_active_target {
|
||||
iced::Color::WHITE
|
||||
} else {
|
||||
colors.border_high
|
||||
};
|
||||
let border_width = if is_active_target { 2 } else { 1 };
|
||||
|
||||
if let Some(mask_pixels) = engine.get_layer_mask_pixels(layer_id) {
|
||||
if let Some(info) = engine.get_layer_info(layer_id) {
|
||||
let (cw, ch) = (info.width as usize, info.height as usize);
|
||||
if cw > 0 && ch > 0 {
|
||||
let (mw, mh) = match engine.get_layer_mask_bounds(layer_id) {
|
||||
Some(mb) if (mb[3] > mb[1]) && (mb[2] > mb[0]) => {
|
||||
let w = (mb[3] - mb[1]) as usize;
|
||||
let h = (mb[2] - mb[0]) as usize;
|
||||
if mask_pixels.len() == w * h {
|
||||
(w, h)
|
||||
} else {
|
||||
(cw, ch)
|
||||
}
|
||||
}
|
||||
_ => (cw, ch),
|
||||
};
|
||||
|
||||
if mask_pixels.len() == mw * mh {
|
||||
let tw = 40usize;
|
||||
let th = (40 * mh / mw).max(16).min(32);
|
||||
let mut rgba = Vec::with_capacity(tw * th * 4);
|
||||
for ty in 0..th {
|
||||
let sy = (ty * mh / th).min(mh - 1);
|
||||
for tx in 0..tw {
|
||||
let sx = (tx * mw / tw).min(mw - 1);
|
||||
let val = mask_pixels[sy * mw + sx];
|
||||
rgba.push(val);
|
||||
rgba.push(val);
|
||||
rgba.push(val);
|
||||
rgba.push(255);
|
||||
}
|
||||
}
|
||||
let handle = iced::widget::image::Handle::from_rgba(tw as u32, th as u32, rgba);
|
||||
let img = iced::widget::Image::new(handle)
|
||||
.width(Length::Fixed(tw as f32))
|
||||
.height(Length::Fixed(th as f32));
|
||||
return container(img)
|
||||
.width(42)
|
||||
.height(32)
|
||||
.center_x(Length::Fixed(42.0))
|
||||
.center_y(Length::Fixed(32.0))
|
||||
.padding(1)
|
||||
.style(move |_theme| iced::widget::container::Style {
|
||||
background: Some(iced::Background::Color(iced::Color::BLACK)),
|
||||
border: iced::Border::default().color(border_color).width(border_width),
|
||||
..Default::default()
|
||||
})
|
||||
.into();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
container(text("Mask").size(9).style(move |_theme: &iced::Theme| {
|
||||
iced::widget::text::Style {
|
||||
color: Some(colors.text_primary),
|
||||
}
|
||||
}))
|
||||
.width(42)
|
||||
.height(32)
|
||||
.center_x(Length::Fixed(42.0))
|
||||
.center_y(Length::Fixed(32.0))
|
||||
.style(move |_theme| iced::widget::container::Style {
|
||||
background: Some(iced::Background::Color(iced::Color::BLACK)),
|
||||
border: iced::Border::default().color(border_color).width(border_width),
|
||||
..Default::default()
|
||||
})
|
||||
.into()
|
||||
@@ -282,14 +376,44 @@ pub fn view<'a>(
|
||||
.padding([1, 4])
|
||||
.style(move |_theme, _status| flat_btn_style(colors));
|
||||
|
||||
let mask_banner: Element<'a, Message> = if engine.is_editing_mask() {
|
||||
let white_btn = button(text("⚪ White (Reveal)").size(10).style(move |_theme: &iced::Theme| {
|
||||
iced::widget::text::Style { color: Some(iced::Color::BLACK) }
|
||||
}))
|
||||
.on_press(Message::FgColorChanged([255, 255, 255, 255]))
|
||||
.padding([2, 4])
|
||||
.style(move |_theme, _status| flat_btn_style(colors));
|
||||
|
||||
let black_btn = button(text("⬛ Black (Hide)").size(10).style(move |_theme: &iced::Theme| {
|
||||
iced::widget::text::Style { color: Some(iced::Color::WHITE) }
|
||||
}))
|
||||
.on_press(Message::FgColorChanged([0, 0, 0, 255]))
|
||||
.padding([2, 4])
|
||||
.style(move |_theme, _status| flat_btn_style(colors));
|
||||
|
||||
container(
|
||||
column![
|
||||
text("Mask Mode Active:").size(10).style(move |_theme: &iced::Theme| iced::widget::text::Style {
|
||||
color: Some(colors.accent),
|
||||
}),
|
||||
row![white_btn, black_btn].spacing(4).align_y(iced::Alignment::Center),
|
||||
].spacing(2)
|
||||
)
|
||||
.padding(4)
|
||||
.style(move |_theme| iced::widget::container::Style {
|
||||
background: Some(iced::Background::Color(iced::Color::from_rgba(0.2, 0.2, 0.2, 0.5))),
|
||||
border: iced::Border::default().color(colors.accent).width(1),
|
||||
..Default::default()
|
||||
})
|
||||
.into()
|
||||
} else {
|
||||
Space::with_width(Length::Fixed(0.0)).into()
|
||||
};
|
||||
|
||||
let controls = column![
|
||||
mask_banner,
|
||||
row![blend_list].spacing(2),
|
||||
opacity_row,
|
||||
text("Fill opacity: unavailable in hcie-engine-api")
|
||||
.size(BODY)
|
||||
.style(move |_theme: &iced::Theme| iced::widget::text::Style {
|
||||
color: Some(colors.text_secondary),
|
||||
}),
|
||||
row![lock_btn, fx_btn]
|
||||
.spacing(8)
|
||||
.align_y(iced::Alignment::Center),
|
||||
@@ -339,16 +463,55 @@ pub fn view<'a>(
|
||||
14.0,
|
||||
);
|
||||
|
||||
let lock_icon_path = if info.locked {
|
||||
"icons/lock.svg"
|
||||
} else {
|
||||
"icons/unlock.svg"
|
||||
};
|
||||
let row_lock = svg_icon_btn(
|
||||
"icons/lock.svg",
|
||||
lock_icon_path,
|
||||
if info.locked { "Unlock" } else { "Lock" },
|
||||
Message::LayerToggleLock(info.id),
|
||||
colors,
|
||||
12.0,
|
||||
);
|
||||
|
||||
// Thumbnail
|
||||
let thumb_elem = layer_thumbnail(entry, thumb_cache, thumb_gen, colors);
|
||||
// Thumbnail targets
|
||||
let is_layer_target_active = is_active && !engine.is_editing_mask();
|
||||
let is_mask_target_active = is_active && engine.is_editing_mask();
|
||||
|
||||
let thumb_elem = iced::widget::mouse_area(layer_thumbnail(
|
||||
entry,
|
||||
thumb_cache,
|
||||
thumb_gen,
|
||||
colors,
|
||||
is_layer_target_active,
|
||||
))
|
||||
.on_press(Message::LayerSetEditMask(false));
|
||||
|
||||
// Layer mask thumbnail preview
|
||||
let has_mask = engine.has_layer_mask(info.id);
|
||||
let link_icon: Element<'a, Message> = if has_mask {
|
||||
svg_icon_btn(
|
||||
"icons/link.svg",
|
||||
"Link Mask",
|
||||
Message::LayerToggleEditMask,
|
||||
colors,
|
||||
12.0,
|
||||
)
|
||||
} else {
|
||||
Space::with_width(Length::Fixed(0.0)).into()
|
||||
};
|
||||
|
||||
let mask_elem: Element<'a, Message> = if has_mask {
|
||||
let mask_btn = button(mask_thumbnail(engine, info.id, colors, is_mask_target_active))
|
||||
.on_press(Message::LayerSetEditMask(!is_mask_target_active))
|
||||
.padding(0)
|
||||
.style(move |_theme, _status| flat_btn_style(colors));
|
||||
mask_btn.into()
|
||||
} else {
|
||||
Space::with_width(Length::Fixed(0.0)).into()
|
||||
};
|
||||
|
||||
// Layer name
|
||||
let c = if is_active {
|
||||
@@ -407,7 +570,7 @@ pub fn view<'a>(
|
||||
|
||||
// Main layer row
|
||||
let name_row =
|
||||
row![collapse, vis_btn, row_lock, thumb_elem, name_text, fx_icon, type_badge,]
|
||||
row![collapse, vis_btn, row_lock, thumb_elem, link_icon, mask_elem, name_text, fx_icon, type_badge,]
|
||||
.spacing(2)
|
||||
.align_y(iced::Alignment::Center);
|
||||
|
||||
@@ -521,10 +684,18 @@ pub fn view<'a>(
|
||||
.padding([3, 6])
|
||||
.style(move |_theme, _status| flat_btn_style(colors));
|
||||
let duplicate_btn = button(text("Duplicate").size(BODY)).padding([5, 8]);
|
||||
let mask_btn = button(text("Mask").size(BODY)).padding([5, 8]);
|
||||
let has_active_mask = engine.has_layer_mask(active_layer_id);
|
||||
let mask_btn_label = if has_active_mask { "Remove Mask" } else { "Add Mask" };
|
||||
let mask_btn = button(text(mask_btn_label).size(BODY))
|
||||
.on_press(if has_active_mask {
|
||||
Message::LayerRemoveMask(active_layer_id)
|
||||
} else {
|
||||
Message::LayerAddMask(active_layer_id)
|
||||
})
|
||||
.padding([5, 8])
|
||||
.style(move |_theme, _status| flat_btn_style(colors));
|
||||
let rasterize_btn = button(text("Rasterize").size(BODY)).padding([5, 8]);
|
||||
let toolbar = column![
|
||||
text("Unavailable: duplicate, mask, rasterize").size(BODY),
|
||||
row![duplicate_btn, mask_btn]
|
||||
.spacing(2)
|
||||
.align_y(iced::Alignment::Center),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -168,11 +168,6 @@ pub fn view<'a>(
|
||||
expanded: bool,
|
||||
) -> Element<'a, Message> {
|
||||
let sidebar_width = if expanded { 72 } else { 36 };
|
||||
log::info!(
|
||||
"[sidebar] expanded={} open_subtool_slot={:?}",
|
||||
expanded,
|
||||
open_subtool_slot
|
||||
);
|
||||
|
||||
// Tool buttons — single column or 2-column grid depending on expanded state
|
||||
let mut tool_list = column![].spacing(1);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -26,8 +26,8 @@ crate-type = ["staticlib", "rlib"]
|
||||
rstest = "0.23"
|
||||
proptest = "1.5"
|
||||
approx = "0.5"
|
||||
egui = "0.34"
|
||||
eframe = { version = "0.34", default-features = false, features = ["default_fonts", "glow", "x11", "wayland"] }
|
||||
egui = { workspace = true }
|
||||
eframe = { workspace = true }
|
||||
rfd = "0.15"
|
||||
hcie-psd-saver = { path = "../hcie-psd-saver" }
|
||||
zip = { version = "2.2", default-features = false, features = ["deflate"] }
|
||||
|
||||
@@ -1,4 +1,31 @@
|
||||
#![allow(dead_code, unused_imports, unused_variables, unused_macros, unreachable_patterns, unused_assignments, clippy::cfg)]
|
||||
|
||||
/// Reports whether a required test fixture is absent.
|
||||
///
|
||||
/// **Purpose:** Lets fixture-dependent integration tests remain runnable in checkouts that do not
|
||||
/// contain the external PSD reference corpus.
|
||||
/// **Logic & Workflow:** Reads filesystem metadata and classifies only `NotFound` as an optional
|
||||
/// fixture skip. Any other metadata error remains a hard failure so permission and filesystem
|
||||
/// problems are not hidden.
|
||||
/// **Arguments:** `path` is the required fixture path to inspect.
|
||||
/// **Returns:** `true` when the fixture does not exist and the caller should return early;
|
||||
/// otherwise `false`.
|
||||
/// **Side Effects / Dependencies:** Writes one diagnostic line for a missing fixture and panics on
|
||||
/// unexpected filesystem metadata errors.
|
||||
fn fixture_is_missing(path: &std::path::Path) -> bool {
|
||||
match std::fs::metadata(path) {
|
||||
Ok(_) => false,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
|
||||
eprintln!("Fixture not found, skipping test: {}", path.display());
|
||||
true
|
||||
}
|
||||
Err(error) => panic!(
|
||||
"failed to inspect test fixture '{}': {error}",
|
||||
path.display()
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_psd_import_sizes_and_offsets() {
|
||||
let path = std::path::Path::new("_images/_test_images/example3/Example3-mini.psd");
|
||||
@@ -240,8 +267,10 @@ fn composite_and_compare(psd_path: &str, ref_path: &str) {
|
||||
#[test]
|
||||
fn test_layer_pixels_direct() {
|
||||
let psd_path = std::path::Path::new("_images/_psd_stil_test/base_test_generated_2.psd");
|
||||
let ref_path = std::path::Path::new("_images/_psd_stil_test/base_test_generated_2.png");
|
||||
if fixture_is_missing(psd_path) || fixture_is_missing(ref_path) { return; }
|
||||
let layers = hcie_psd::import_psd(psd_path).unwrap();
|
||||
let ref_img = image::open("_images/_psd_stil_test/base_test_generated_2.png").unwrap().to_rgba8();
|
||||
let ref_img = image::open(ref_path).unwrap().to_rgba8();
|
||||
let ref_pixels = ref_img.as_raw();
|
||||
|
||||
// Compare layer 0 (Background Grid) pixels directly with reference
|
||||
@@ -299,6 +328,9 @@ fn test_layer_pixels_direct() {
|
||||
|
||||
#[test]
|
||||
fn test_base_generated_2_vs_merged() {
|
||||
let psd_path = std::path::Path::new("_images/_psd_stil_test/base_test_generated_2.psd");
|
||||
if fixture_is_missing(psd_path) { return; }
|
||||
|
||||
// Extract PSD merged image using ImageMagick
|
||||
let _ = std::process::Command::new("convert")
|
||||
.args(&[
|
||||
@@ -310,7 +342,7 @@ fn test_base_generated_2_vs_merged() {
|
||||
let psd_merged = image::open("/tmp/psd2_merged_for_test.png").unwrap().to_rgba8();
|
||||
let psd_pixels = psd_merged.as_raw();
|
||||
|
||||
let layers = hcie_psd::import_psd(std::path::Path::new("_images/_psd_stil_test/base_test_generated_2.psd")).unwrap();
|
||||
let layers = hcie_psd::import_psd(psd_path).unwrap();
|
||||
let canvas_w = layers[0].width;
|
||||
let canvas_h = layers[0].height;
|
||||
|
||||
@@ -363,6 +395,7 @@ fn test_blend_normal() {
|
||||
#[test]
|
||||
fn test_teal_circle_emboss_debug() {
|
||||
let psd_path = std::path::Path::new("_images/_psd_stil_test/base_test_generated_2.psd");
|
||||
if fixture_is_missing(psd_path) { return; }
|
||||
let layers = hcie_psd::import_psd(psd_path).unwrap();
|
||||
let teal = &layers[1]; // Teal Circle
|
||||
|
||||
@@ -377,8 +410,10 @@ fn test_teal_circle_emboss_debug() {
|
||||
#[test]
|
||||
fn test_base_generated_2_no_effects() {
|
||||
let psd_path = std::path::Path::new("_images/_psd_stil_test/base_test_generated_2.psd");
|
||||
let ref_path = std::path::Path::new("_images/_psd_stil_test/base_test_generated_2.png");
|
||||
if fixture_is_missing(psd_path) || fixture_is_missing(ref_path) { return; }
|
||||
let layers = hcie_psd::import_psd(psd_path).unwrap();
|
||||
let ref_img = image::open("_images/_psd_stil_test/base_test_generated_2.png").unwrap().to_rgba8();
|
||||
let ref_img = image::open(ref_path).unwrap().to_rgba8();
|
||||
let ref_pixels = ref_img.as_raw();
|
||||
|
||||
let canvas_w = layers[0].width;
|
||||
@@ -438,6 +473,7 @@ fn test_hc_emboss_composite() {
|
||||
fn test_base_generated_2_effect_isolation() {
|
||||
let psd_path = std::path::Path::new("_images/_psd_stil_test/base_test_generated_2.psd");
|
||||
let ref_path = std::path::Path::new("_images/_psd_stil_test/base_test_generated_2.png");
|
||||
if fixture_is_missing(psd_path) || fixture_is_missing(ref_path) { return; }
|
||||
|
||||
let all_layers = hcie_psd::import_psd(psd_path).unwrap();
|
||||
let ref_img = image::open(ref_path).unwrap().to_rgba8();
|
||||
@@ -490,6 +526,7 @@ fn compute_mae(a: &[u8], b: &[u8], pixels: usize) -> f64 {
|
||||
#[test]
|
||||
fn test_check_soft_orange_pixels() {
|
||||
let psd_path = std::path::Path::new("_images/_psd_stil_test/base_test_generated_2.psd");
|
||||
if fixture_is_missing(psd_path) { return; }
|
||||
let layers = hcie_psd::import_psd(psd_path).unwrap();
|
||||
|
||||
let orange = &layers[5]; // Soft Orange Shape
|
||||
@@ -515,6 +552,7 @@ fn test_check_soft_orange_pixels() {
|
||||
#[test]
|
||||
fn test_find_orange_pixels() {
|
||||
let psd_path = std::path::Path::new("_images/_psd_stil_test/base_test_generated_2.psd");
|
||||
if fixture_is_missing(psd_path) { return; }
|
||||
let layers = hcie_psd::import_psd(psd_path).unwrap();
|
||||
|
||||
let orange = &layers[5]; // Soft Orange Shape
|
||||
@@ -554,6 +592,7 @@ fn test_find_orange_pixels() {
|
||||
#[test]
|
||||
fn test_layer_bounds() {
|
||||
let psd_path = std::path::Path::new("_images/_psd_stil_test/base_test_generated_2.psd");
|
||||
if fixture_is_missing(psd_path) { return; }
|
||||
let layers = hcie_psd::import_psd(psd_path).unwrap();
|
||||
|
||||
for (i, layer) in layers.iter().enumerate() {
|
||||
@@ -583,6 +622,7 @@ fn test_layer_bounds() {
|
||||
#[test]
|
||||
fn test_check_layer_offsets() {
|
||||
let psd_path = std::path::Path::new("_images/_psd_stil_test/base_test_generated_2.psd");
|
||||
if fixture_is_missing(psd_path) { return; }
|
||||
let layers = hcie_psd::import_psd(psd_path).unwrap();
|
||||
|
||||
println!("Layer positions and sizes:");
|
||||
@@ -620,6 +660,7 @@ fn test_check_layer_offsets() {
|
||||
#[test]
|
||||
fn test_save_layer_pixels() {
|
||||
let psd_path = std::path::Path::new("_images/_psd_stil_test/base_test_generated_2.psd");
|
||||
if fixture_is_missing(psd_path) { return; }
|
||||
let layers = hcie_psd::import_psd(psd_path).unwrap();
|
||||
|
||||
for (i, layer) in layers.iter().enumerate() {
|
||||
@@ -634,6 +675,7 @@ fn test_save_layer_pixels() {
|
||||
#[test]
|
||||
fn test_pixel_layer_contributions() {
|
||||
let psd_path = std::path::Path::new("_images/_psd_stil_test/base_test_generated_2.psd");
|
||||
if fixture_is_missing(psd_path) { return; }
|
||||
let layers = hcie_psd::import_psd(psd_path).unwrap();
|
||||
|
||||
let test_pixels = [(300, 200), (400, 200), (200, 300), (500, 200), (500, 500)];
|
||||
@@ -696,6 +738,7 @@ fn test_load_test_2_psd() {
|
||||
fn test_per_layer_effects_vs_photoshop_export() {
|
||||
let psd_path = std::path::Path::new("_images/_psd_stil_test/base_test_generated_2.psd");
|
||||
let ref_dir = std::path::Path::new("_images/_psd_stil_test/base_test_generated_2");
|
||||
if fixture_is_missing(psd_path) { return; }
|
||||
let layers = hcie_psd::import_psd(psd_path).unwrap();
|
||||
let layer_names = [
|
||||
"Background Grid", "Teal Circle", "Pink Rectangle",
|
||||
@@ -706,7 +749,7 @@ fn test_per_layer_effects_vs_photoshop_export() {
|
||||
if i >= layers.len() { break; }
|
||||
let layer = &layers[i];
|
||||
let ref_path = ref_dir.join(format!("{}.png", name));
|
||||
if !ref_path.exists() {
|
||||
if fixture_is_missing(&ref_path) {
|
||||
println!("{}: export not found, skipping", name);
|
||||
continue;
|
||||
}
|
||||
|
||||
+264
-46
@@ -1,5 +1,6 @@
|
||||
use hcie_protocol::{Layer, LayerData, LayerType, VectorShape};
|
||||
use hcie_protocol::BlendMode;
|
||||
use rand;
|
||||
use std::io::{Cursor, Write};
|
||||
use std::path::Path;
|
||||
|
||||
@@ -106,13 +107,27 @@ pub fn save_kra(
|
||||
let base_filename = format!("layer{}", i + 1);
|
||||
|
||||
match layer.layer_type {
|
||||
LayerType::Raster | LayerType::Mask => {
|
||||
LayerType::Raster | LayerType::Mask | LayerType::Group => {
|
||||
let encoded = encode_layer_vers(layer);
|
||||
let layer_path = format!("{}/layers/{}", internal_name, base_filename);
|
||||
zip.start_file(&layer_path, zip::write::FileOptions::<'_, ()>::default())
|
||||
.map_err(|e| e.to_string())?;
|
||||
zip.write_all(&encoded)
|
||||
.map_err(|e| e.to_string())?;
|
||||
zip.write_all(&encoded).map_err(|e| e.to_string())?;
|
||||
|
||||
if let Some(ref mask) = layer.mask_pixels {
|
||||
let mask_filename = format!("{}_mask", base_filename);
|
||||
let mask_encoded = encode_mask_vers(layer, mask);
|
||||
let mask_path = format!("{}/layers/{}", internal_name, mask_filename);
|
||||
zip.start_file(&mask_path, zip::write::FileOptions::<'_, ()>::default())
|
||||
.map_err(|e| e.to_string())?;
|
||||
zip.write_all(&mask_encoded).map_err(|e| e.to_string())?;
|
||||
|
||||
let mask_dp_path = format!("{}/layers/{}.defaultpixel", internal_name, mask_filename);
|
||||
zip.start_file(&mask_dp_path, zip::write::FileOptions::<'_, ()>::default())
|
||||
.map_err(|e| e.to_string())?;
|
||||
zip.write_all(&[0u8; 1]) // 1 byte for Alpha
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
|
||||
let icc_path = format!("{}/layers/{}.icc", internal_name, base_filename);
|
||||
zip.start_file(&icc_path, zip::write::FileOptions::<'_, ()>::default())
|
||||
@@ -125,6 +140,18 @@ pub fn save_kra(
|
||||
.map_err(|e| e.to_string())?;
|
||||
zip.write_all(&[0u8; 4])
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
if layer.layer_type == LayerType::Group {
|
||||
let group_json = serde_json::json!({
|
||||
"collapsed": layer.collapsed,
|
||||
"parent_id": layer.parent_id,
|
||||
});
|
||||
let json_path = format!("{}/layers/{}.grouplayer", internal_name, base_filename);
|
||||
zip.start_file(&json_path, zip::write::FileOptions::<'_, ()>::default())
|
||||
.map_err(|e| e.to_string())?;
|
||||
zip.write_all(group_json.to_string().as_bytes())
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
}
|
||||
LayerType::Vector | LayerType::Text => {
|
||||
let layer_path = format!("{}/layers/{}", internal_name, base_filename);
|
||||
@@ -175,22 +202,7 @@ pub fn save_kra(
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
}
|
||||
LayerType::Group => {
|
||||
let group_json = serde_json::json!({
|
||||
"collapsed": layer.collapsed,
|
||||
"parent_id": layer.parent_id,
|
||||
});
|
||||
let group_path =
|
||||
format!("{}/layers/group/{}.json", internal_name, base_filename);
|
||||
zip.start_file(&group_path, zip::write::FileOptions::<'_, ()>::default())
|
||||
.map_err(|e| e.to_string())?;
|
||||
zip.write_all(
|
||||
serde_json::to_string_pretty(&group_json)
|
||||
.unwrap_or_default()
|
||||
.as_bytes(),
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -203,9 +215,16 @@ fn generate_maindoc(layers: &[Layer], w: u32, h: u32, name: &str) -> String {
|
||||
xml.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
|
||||
xml.push_str("<!DOCTYPE DOC PUBLIC '-//KDE//DTD krita 2.0//EN' 'http://www.calligra.org/DTD/krita-2.0.dtd'>\n");
|
||||
xml.push_str("<DOC xmlns=\"http://www.calligra.org/DTD/krita\" editor=\"Krita\" kritaVersion=\"6.0.1\" syntaxVersion=\"2.0\">\n");
|
||||
let escaped_doc_name = name
|
||||
.replace('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
.replace('"', """)
|
||||
.replace('\'', "'");
|
||||
|
||||
xml.push_str(&format!(
|
||||
" <IMAGE width=\"{}\" height=\"{}\" name=\"{}\" mime=\"application/x-kra\" colorspacename=\"RGBA\" channeldepth=\"U8\" profile=\"sRGB-elle-V2-g10.icc\" x-res=\"100\" y-res=\"100\">\n",
|
||||
w, h, name
|
||||
w, h, escaped_doc_name
|
||||
));
|
||||
xml.push_str(" <layers>\n");
|
||||
|
||||
@@ -219,13 +238,49 @@ fn generate_maindoc(layers: &[Layer], w: u32, h: u32, name: &str) -> String {
|
||||
let visible = if layer.visible { "1" } else { "0" };
|
||||
let opacity = (layer.opacity * 255.0) as u8;
|
||||
let filename = format!("layer{}", i + 1);
|
||||
let uuid = format!("{{{:032x}}}", layer.id);
|
||||
let uuid_high = rand::random::<u64>();
|
||||
let uuid_low = rand::random::<u64>();
|
||||
let uuid = format!(
|
||||
"{{{:08x}-{:04x}-{:04x}-{:04x}-{:012x}}}",
|
||||
(uuid_high >> 32) as u32,
|
||||
(uuid_high >> 16) as u16 & 0xFFFF,
|
||||
uuid_high as u16,
|
||||
(uuid_low >> 48) as u16,
|
||||
uuid_low & 0x0000_FFFF_FFFF_FFFF
|
||||
);
|
||||
let compositeop = blend_mode_to_krita_compositeop(layer.blend_mode);
|
||||
|
||||
let escaped_name = layer.name
|
||||
.replace('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
.replace('"', """)
|
||||
.replace('\'', "'");
|
||||
|
||||
xml.push_str(&format!(
|
||||
" <layer name=\"{}\" filename=\"{}\" nodetype=\"{}\" visible=\"{}\" opacity=\"{}\" uuid=\"{}\" compositeop=\"{}\" colorspacename=\"RGBA\" channeldepth=\"U8\" profile=\"sRGB-elle-V2-g10.icc\" x=\"0\" y=\"0\" />\n",
|
||||
layer.name, filename, node_type, visible, opacity, uuid, compositeop
|
||||
" <layer name=\"{}\" filename=\"{}\" nodetype=\"{}\" visible=\"{}\" opacity=\"{}\" uuid=\"{}\" compositeop=\"{}\" colorspacename=\"RGBA\" channeldepth=\"U8\" profile=\"sRGB-elle-V2-g10.icc\" x=\"0\" y=\"0\">\n",
|
||||
escaped_name, filename, node_type, visible, opacity, uuid, compositeop
|
||||
));
|
||||
if layer.mask_pixels.is_some() {
|
||||
xml.push_str(" <layers>\n");
|
||||
let mask_filename = format!("{}_mask", filename);
|
||||
let mask_high = rand::random::<u64>();
|
||||
let mask_low = rand::random::<u64>();
|
||||
let mask_uuid = format!(
|
||||
"{{{:08x}-{:04x}-{:04x}-{:04x}-{:012x}}}",
|
||||
(mask_high >> 32) as u32,
|
||||
(mask_high >> 16) as u16 & 0xFFFF,
|
||||
mask_high as u16,
|
||||
(mask_low >> 48) as u16,
|
||||
mask_low & 0x0000_FFFF_FFFF_FFFF
|
||||
);
|
||||
xml.push_str(&format!(
|
||||
" <layer name=\"Transparency Mask\" filename=\"{}\" nodetype=\"transparencymask\" visible=\"1\" opacity=\"255\" uuid=\"{}\" compositeop=\"normal\" colorspacename=\"GRAYA\" channeldepth=\"U8\" profile=\"Gray-D50-elle-V2-g10.icc\" x=\"0\" y=\"0\" />\n",
|
||||
mask_filename, mask_uuid
|
||||
));
|
||||
xml.push_str(" </layers>\n");
|
||||
}
|
||||
xml.push_str(" </layer>\n");
|
||||
}
|
||||
|
||||
xml.push_str(" </layers>\n");
|
||||
@@ -257,10 +312,11 @@ fn encode_layer_vers(layer: &Layer) -> Vec<u8> {
|
||||
let idx = (ly as usize * layer.width as usize + lx as usize) * 4;
|
||||
let tidx = y as usize * tw as usize + x as usize;
|
||||
|
||||
tile_raw[tidx] = layer.pixels[idx + 2];
|
||||
tile_raw[tidx + 1 * plane_size] = layer.pixels[idx + 1];
|
||||
tile_raw[tidx + 2 * plane_size] = layer.pixels[idx];
|
||||
tile_raw[tidx + 3 * plane_size] = layer.pixels[idx + 3];
|
||||
// Krita planar order: B, G, R, A
|
||||
tile_raw[tidx] = layer.pixels[idx + 2]; // B
|
||||
tile_raw[tidx + 1 * plane_size] = layer.pixels[idx + 1]; // G
|
||||
tile_raw[tidx + 2 * plane_size] = layer.pixels[idx]; // R
|
||||
tile_raw[tidx + 3 * plane_size] = layer.pixels[idx + 3]; // A
|
||||
|
||||
if layer.pixels[idx + 3] > 0 {
|
||||
has_content = true;
|
||||
@@ -270,34 +326,196 @@ fn encode_layer_vers(layer: &Layer) -> Vec<u8> {
|
||||
}
|
||||
|
||||
if has_content {
|
||||
let mut compressed = vec![1u8];
|
||||
let mut i = 0;
|
||||
while i < tile_raw.len() {
|
||||
let chunk = (tile_raw.len() - i).min(32);
|
||||
compressed.push((chunk - 1) as u8);
|
||||
compressed.extend_from_slice(&tile_raw[i..i + chunk]);
|
||||
i += chunk;
|
||||
}
|
||||
tiles.push((tx, ty, compressed));
|
||||
// Krita VERSION 2 tile format:
|
||||
// payload = [version_byte = 1][lzf_compressed_planar_data]
|
||||
// version_byte < 2 → no delta-decode on read (straightforward LZF).
|
||||
let compressed = lzf_compress(&tile_raw);
|
||||
let mut payload = Vec::with_capacity(1 + compressed.len());
|
||||
payload.push(1u8); // LZF version byte
|
||||
payload.extend_from_slice(&compressed);
|
||||
tiles.push((tx, ty, payload));
|
||||
}
|
||||
tx += tw;
|
||||
}
|
||||
ty += th;
|
||||
}
|
||||
|
||||
let mut buf = Vec::new();
|
||||
writeln!(buf, "VERSION 2").unwrap();
|
||||
writeln!(buf, "TILEWIDTH 64").unwrap();
|
||||
writeln!(buf, "TILEHEIGHT 64").unwrap();
|
||||
writeln!(buf, "PIXELSIZE 4").unwrap();
|
||||
writeln!(buf, "DATA {}", tiles.len()).unwrap();
|
||||
let mut output = Vec::new();
|
||||
writeln!(output, "VERSION 2").unwrap();
|
||||
writeln!(output, "TILEWIDTH 64").unwrap();
|
||||
writeln!(output, "TILEHEIGHT 64").unwrap();
|
||||
writeln!(output, "PIXELSIZE 4").unwrap();
|
||||
writeln!(output, "DATA {}", tiles.len()).unwrap();
|
||||
|
||||
for (tx, ty, data) in tiles {
|
||||
writeln!(buf, "{},{},LZF,{}", tx, ty, data.len()).unwrap();
|
||||
buf.extend_from_slice(&data);
|
||||
tiles.sort_by_key(|(x, y, _)| (*y, *x));
|
||||
for (tx, ty, payload) in tiles {
|
||||
let header = format!("{},{},LZF,{}\n", tx, ty, payload.len());
|
||||
output.extend_from_slice(header.as_bytes());
|
||||
output.extend_from_slice(&payload);
|
||||
}
|
||||
|
||||
buf
|
||||
output
|
||||
}
|
||||
|
||||
/// Purpose: LZF compress a byte slice for use as Krita tile payload.
|
||||
/// Logic: Simple LZF implementation — hash-table based back-reference search.
|
||||
/// Each control byte describes either a literal run (ctrl < 32) or a
|
||||
/// back-reference (ctrl >= 32). This matches the format expected by the
|
||||
/// lzf_decompress reader in lib.rs.
|
||||
fn lzf_compress(input: &[u8]) -> Vec<u8> {
|
||||
// Worst-case output: all literals → 1 control byte per 32 data bytes.
|
||||
let mut output = Vec::with_capacity(input.len() + input.len() / 20 + 32);
|
||||
let mut hash_table: Vec<usize> = vec![usize::MAX; 1 << 16];
|
||||
|
||||
let mut ip = 0usize;
|
||||
let mut lit_start = 0usize;
|
||||
|
||||
// Flush pending literal bytes [lit_start .. lit_end) into output.
|
||||
let flush_literals = |out: &mut Vec<u8>, data: &[u8], from: usize, to: usize| {
|
||||
let mut i = from;
|
||||
while i < to {
|
||||
let run = (to - i).min(32);
|
||||
out.push((run - 1) as u8); // ctrl: literal run length − 1
|
||||
out.extend_from_slice(&data[i..i + run]);
|
||||
i += run;
|
||||
}
|
||||
};
|
||||
|
||||
while ip + 2 < input.len() {
|
||||
// 16-bit hash of three bytes at ip.
|
||||
let h = ((input[ip] as u32)
|
||||
.wrapping_mul(2654435761)
|
||||
^ (input[ip + 1] as u32)
|
||||
.wrapping_mul(2246822519)
|
||||
^ (input[ip + 2] as u32))
|
||||
as usize
|
||||
& 0xFFFF;
|
||||
|
||||
let ref_pos = hash_table[h];
|
||||
hash_table[h] = ip;
|
||||
|
||||
// Check if back-reference is valid (within last 8191 bytes, matches >= 3 bytes).
|
||||
let max_len = (input.len() - ip).min(264);
|
||||
if ref_pos != usize::MAX
|
||||
&& ip > ref_pos
|
||||
&& (ip - ref_pos) <= 8191
|
||||
&& ref_pos + 2 < input.len()
|
||||
&& input[ref_pos] == input[ip]
|
||||
&& input[ref_pos + 1] == input[ip + 1]
|
||||
&& input[ref_pos + 2] == input[ip + 2]
|
||||
{
|
||||
// Count match length (capped at 264).
|
||||
let ofs = ip - ref_pos - 1;
|
||||
let mut mlen = 3usize;
|
||||
while mlen < max_len && input[ref_pos + mlen] == input[ip + mlen] {
|
||||
mlen += 1;
|
||||
}
|
||||
|
||||
// Flush literals accumulated before this back-reference.
|
||||
flush_literals(&mut output, input, lit_start, ip);
|
||||
lit_start = ip + mlen;
|
||||
ip += mlen;
|
||||
|
||||
// Encode back-reference.
|
||||
let len_code = mlen - 2; // stored as (len − 2)
|
||||
if len_code < 7 {
|
||||
output.push(((len_code << 5) | (ofs >> 8)) as u8);
|
||||
} else {
|
||||
output.push(((7 << 5) | (ofs >> 8)) as u8);
|
||||
output.push((len_code - 7) as u8);
|
||||
}
|
||||
output.push((ofs & 0xFF) as u8);
|
||||
} else {
|
||||
ip += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Flush any remaining literal bytes.
|
||||
flush_literals(&mut output, input, lit_start, input.len());
|
||||
output
|
||||
}
|
||||
|
||||
fn encode_mask_vers(layer: &Layer, mask: &[u8]) -> Vec<u8> {
|
||||
let tw: i32 = 64;
|
||||
let th: i32 = 64;
|
||||
let plane_size = (tw * th) as usize;
|
||||
let w = layer.width as i32;
|
||||
let h = layer.height as i32;
|
||||
|
||||
let mut tiles: Vec<(i32, i32, Vec<u8>)> = Vec::new();
|
||||
let mut ty = 0;
|
||||
while ty < h {
|
||||
let mut tx = 0;
|
||||
while tx < w {
|
||||
let mut tile_raw = vec![0u8; plane_size];
|
||||
let mut has_content = false;
|
||||
|
||||
for y in 0..th {
|
||||
let ly = ty + y;
|
||||
if ly >= h {
|
||||
continue;
|
||||
}
|
||||
for x in 0..tw {
|
||||
let lx = tx + x;
|
||||
if lx >= w {
|
||||
continue;
|
||||
}
|
||||
let tidx = (y as usize) * (tw as usize) + (x as usize);
|
||||
let mut v = layer.mask_default_color;
|
||||
if let Some(mb) = layer.mask_bounds {
|
||||
let m_top = mb[0] as i32;
|
||||
let m_left = mb[1] as i32;
|
||||
let m_bottom = mb[2] as i32;
|
||||
let m_right = mb[3] as i32;
|
||||
if ly >= m_top && ly < m_bottom && lx >= m_left && lx < m_right {
|
||||
let mask_w = m_right - m_left;
|
||||
let mask_idx = ((ly - m_top) * mask_w + (lx - m_left)) as usize;
|
||||
if mask_idx < mask.len() {
|
||||
v = mask[mask_idx];
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let idx = ly as usize * layer.width as usize + lx as usize;
|
||||
if idx < mask.len() {
|
||||
v = mask[idx];
|
||||
}
|
||||
}
|
||||
|
||||
tile_raw[tidx] = v;
|
||||
if v < 255 {
|
||||
has_content = true; // Any masking means content
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if has_content {
|
||||
// Same LZF format as raster tiles but PIXELSIZE=1.
|
||||
let compressed = lzf_compress(&tile_raw);
|
||||
let mut payload = Vec::with_capacity(1 + compressed.len());
|
||||
payload.push(1u8); // version byte (no delta-encode)
|
||||
payload.extend_from_slice(&compressed);
|
||||
tiles.push((tx, ty, payload));
|
||||
}
|
||||
tx += tw;
|
||||
}
|
||||
ty += th;
|
||||
}
|
||||
|
||||
let mut output = Vec::new();
|
||||
output.extend_from_slice(b"VERSION 2\n");
|
||||
output.extend_from_slice(b"TILEWIDTH 64\n");
|
||||
output.extend_from_slice(b"TILEHEIGHT 64\n");
|
||||
output.extend_from_slice(b"PIXELSIZE 1\n");
|
||||
output.extend_from_slice(format!("DATA {}\n", tiles.len()).as_bytes());
|
||||
|
||||
tiles.sort_by_key(|(x, y, _)| (*y, *x));
|
||||
for (tx, ty, payload) in tiles {
|
||||
let header = format!("{},{},LZF,{}\n", tx, ty, payload.len());
|
||||
output.extend_from_slice(header.as_bytes());
|
||||
output.extend_from_slice(&payload);
|
||||
}
|
||||
|
||||
output
|
||||
}
|
||||
|
||||
fn shapes_to_svg(shapes: &[VectorShape], w: u32, h: u32) -> String {
|
||||
|
||||
+120
-20
@@ -40,6 +40,7 @@ pub fn import_kra(path: &Path) -> Result<Vec<Layer>, String> {
|
||||
}
|
||||
|
||||
/// Purpose: Intermediate metadata structure representing Krita layer elements.
|
||||
#[derive(Debug, Clone)]
|
||||
struct KritaLayerInfo {
|
||||
name: String,
|
||||
filename: String,
|
||||
@@ -50,6 +51,7 @@ struct KritaLayerInfo {
|
||||
nodetype: String,
|
||||
compositeop: String,
|
||||
layerstyle_uuid: String,
|
||||
parent_filename: Option<String>,
|
||||
}
|
||||
|
||||
|
||||
@@ -111,7 +113,8 @@ fn parse_vers_tiles(
|
||||
return Err("DATA marker not found".into());
|
||||
}
|
||||
|
||||
let mut pixels = vec![0u8; (doc_w * doc_h * 4) as usize];
|
||||
let bpp = if pixel_size == 1 { 1 } else { 4 };
|
||||
let mut pixels = vec![0u8; (doc_w * doc_h * bpp) as usize];
|
||||
let plane_size = tile_w * tile_h;
|
||||
let mut offset = data_offset;
|
||||
let mut tiles_decoded = 0usize;
|
||||
@@ -173,7 +176,7 @@ fn parse_vers_tiles(
|
||||
if target_x >= 0 && target_x < doc_w as i32
|
||||
&& target_y >= 0 && target_y < doc_h as i32
|
||||
{
|
||||
let target_idx = (target_y as usize * doc_w as usize + target_x as usize) * 4;
|
||||
let target_idx = (target_y as usize * doc_w as usize + target_x as usize) * bpp as usize;
|
||||
let src_idx = y * tile_w + x;
|
||||
|
||||
if pixel_size == 4 {
|
||||
@@ -186,6 +189,8 @@ fn parse_vers_tiles(
|
||||
pixels[target_idx + 1] = tile_data[src_idx + 3 * plane_size];
|
||||
pixels[target_idx + 2] = tile_data[src_idx + 1 * plane_size];
|
||||
pixels[target_idx + 3] = tile_data[src_idx + 7 * plane_size];
|
||||
} else if pixel_size == 1 {
|
||||
pixels[target_idx] = tile_data[src_idx];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -196,8 +201,18 @@ fn parse_vers_tiles(
|
||||
|
||||
if tiles_decoded == 0 {
|
||||
if let Some(dp) = default_pixel {
|
||||
for chunk in pixels.chunks_exact_mut(4) {
|
||||
chunk.copy_from_slice(&dp);
|
||||
if pixel_size == 1 {
|
||||
let v = dp.get(0).copied().unwrap_or(0);
|
||||
for p in pixels.iter_mut() {
|
||||
*p = v;
|
||||
}
|
||||
} else {
|
||||
for chunk in pixels.chunks_exact_mut(4) {
|
||||
chunk[0] = dp[2];
|
||||
chunk[1] = dp[1];
|
||||
chunk[2] = dp[0];
|
||||
chunk[3] = dp[3];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -612,6 +627,12 @@ fn try_import_krita_maindoc(archive: &mut zip::ZipArchive<std::fs::File>) -> Res
|
||||
f.read_to_string(&mut xml_content).map_err(|e| e.to_string())?;
|
||||
found = true;
|
||||
}
|
||||
if !found {
|
||||
if let Ok(mut f) = archive.by_name("layers.xml") {
|
||||
f.read_to_string(&mut xml_content).map_err(|e| e.to_string())?;
|
||||
found = true;
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
@@ -624,21 +645,35 @@ fn try_import_krita_maindoc(archive: &mut zip::ZipArchive<std::fs::File>) -> Res
|
||||
let mut height = 600u32;
|
||||
let mut layers_info = Vec::new();
|
||||
let mut buf = Vec::new();
|
||||
let mut layer_stack: Vec<String> = Vec::new();
|
||||
|
||||
loop {
|
||||
match reader.read_event_into(&mut buf) {
|
||||
Ok(Event::Start(e)) | Ok(Event::Empty(e)) => {
|
||||
let event = reader.read_event_into(&mut buf);
|
||||
match event {
|
||||
Ok(Event::Start(ref e)) | Ok(Event::Empty(ref e)) => {
|
||||
let is_empty = matches!(event, Ok(Event::Empty(_)));
|
||||
let name = e.name();
|
||||
let tag_name = std::str::from_utf8(name.as_ref()).unwrap_or("").to_lowercase();
|
||||
if tag_name == "image" {
|
||||
let mut colorspace = String::new();
|
||||
let mut depth = String::new();
|
||||
for attr in e.attributes() {
|
||||
let a = attr.map_err(|e| format!("XML attr error: {}", e))?;
|
||||
let val = std::str::from_utf8(&a.value).unwrap_or("");
|
||||
match a.key.as_ref() {
|
||||
b"width" => width = std::str::from_utf8(&a.value).unwrap_or("800").parse().unwrap_or(800),
|
||||
b"height" => height = std::str::from_utf8(&a.value).unwrap_or("600").parse().unwrap_or(600),
|
||||
b"width" => width = val.parse().unwrap_or(800),
|
||||
b"height" => height = val.parse().unwrap_or(600),
|
||||
b"colorspacename" => colorspace = val.to_string(),
|
||||
b"channeldepth" => depth = val.to_string(),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if !colorspace.is_empty() && colorspace != "RGBA" && colorspace != "RGB" {
|
||||
return Err(format!("Unsupported KRA color space: '{}' (only RGBA/RGB is supported)", colorspace));
|
||||
}
|
||||
if !depth.is_empty() && depth != "U8" {
|
||||
return Err(format!("Unsupported KRA channel depth: '{}' (only U8 is supported)", depth));
|
||||
}
|
||||
} else if tag_name == "layer"
|
||||
|| tag_name == "paintlayer"
|
||||
|| tag_name == "vectorlayer"
|
||||
@@ -681,7 +716,7 @@ fn try_import_krita_maindoc(archive: &mut zip::ZipArchive<std::fs::File>) -> Res
|
||||
if has_filename && !filename.is_empty() {
|
||||
layers_info.push(KritaLayerInfo {
|
||||
name,
|
||||
filename,
|
||||
filename: filename.clone(),
|
||||
visible,
|
||||
opacity,
|
||||
x: lx,
|
||||
@@ -689,10 +724,29 @@ fn try_import_krita_maindoc(archive: &mut zip::ZipArchive<std::fs::File>) -> Res
|
||||
nodetype,
|
||||
compositeop,
|
||||
layerstyle_uuid,
|
||||
parent_filename: layer_stack.last().cloned(),
|
||||
});
|
||||
if !is_empty {
|
||||
layer_stack.push(filename);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Event::End(ref e)) => {
|
||||
let name = e.name();
|
||||
let tag_name = std::str::from_utf8(name.as_ref()).unwrap_or("").to_lowercase();
|
||||
if tag_name == "layer"
|
||||
|| tag_name == "paintlayer"
|
||||
|| tag_name == "vectorlayer"
|
||||
|| tag_name == "filllayer"
|
||||
|| tag_name == "shapelayer"
|
||||
|| tag_name == "adjustmentlayer"
|
||||
|| tag_name == "filterlayer"
|
||||
|| tag_name == "grouplayer"
|
||||
{
|
||||
layer_stack.pop();
|
||||
}
|
||||
}
|
||||
Ok(Event::Eof) => break,
|
||||
Err(e) => return Err(format!("XML parse error: {}", e)),
|
||||
_ => {}
|
||||
@@ -701,7 +755,7 @@ fn try_import_krita_maindoc(archive: &mut zip::ZipArchive<std::fs::File>) -> Res
|
||||
}
|
||||
|
||||
let all_files: Vec<String> = archive.file_names().map(|n| n.to_string()).collect();
|
||||
let mut layers = Vec::new();
|
||||
let mut intermediate_layers: Vec<(String, Layer)> = Vec::new();
|
||||
|
||||
let asl_styles: std::collections::HashMap<String, Vec<hcie_protocol::effects::LayerEffect>> = {
|
||||
let mut styles_map = std::collections::HashMap::new();
|
||||
@@ -724,7 +778,7 @@ fn try_import_krita_maindoc(archive: &mut zip::ZipArchive<std::fs::File>) -> Res
|
||||
styles_map
|
||||
};
|
||||
|
||||
for info in layers_info.into_iter().rev() {
|
||||
for info in layers_info.clone().into_iter().rev() {
|
||||
if info.nodetype == "adjustmentlayer" || info.nodetype == "filterlayer" {
|
||||
let pixels = vec![0u8; (width * height * 4) as usize];
|
||||
let mut layer = Layer::from_rgba(&info.name, width, height, pixels);
|
||||
@@ -739,14 +793,21 @@ fn try_import_krita_maindoc(archive: &mut zip::ZipArchive<std::fs::File>) -> Res
|
||||
layer.styles = effects.iter().map(kra_effect_to_layer_style).collect();
|
||||
}
|
||||
}
|
||||
layers.push(layer);
|
||||
intermediate_layers.push((info.filename.clone(), layer));
|
||||
continue;
|
||||
}
|
||||
|
||||
if info.nodetype == "transparencymask" {
|
||||
continue;
|
||||
}
|
||||
|
||||
let layer_path = find_krita_layer_file(&all_files, &info.filename, &info.nodetype);
|
||||
let zip_path = match layer_path {
|
||||
Some(ref path) => path,
|
||||
None => continue,
|
||||
None => {
|
||||
log::warn!("KRA import: layer file not found for '{}' (filename={}, type={})", info.name, info.filename, info.nodetype);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let mut buf = Vec::new();
|
||||
@@ -764,13 +825,19 @@ fn try_import_krita_maindoc(archive: &mut zip::ZipArchive<std::fs::File>) -> Res
|
||||
let pixels = if is_vector {
|
||||
match rasterize_svg_to_rgba(&buf, width, height) {
|
||||
Ok(p) => p,
|
||||
Err(_) => vec![0u8; (width * height * 4) as usize],
|
||||
Err(e) => {
|
||||
log::warn!("KRA import: SVG rasterization failed for layer '{}': {}", info.name, e);
|
||||
vec![0u8; (width * height * 4) as usize]
|
||||
}
|
||||
}
|
||||
} else if buf.starts_with(b"VERS") {
|
||||
let default_pixel = read_defaultpixel(archive, &info.filename);
|
||||
match parse_vers_tiles(&buf, width, height, info.x, info.y, default_pixel) {
|
||||
Ok(p) => p,
|
||||
Err(_) => vec![0u8; (width * height * 4) as usize],
|
||||
Err(e) => {
|
||||
log::warn!("KRA import: VERS tile parse failed for layer '{}': {}", info.name, e);
|
||||
vec![0u8; (width * height * 4) as usize]
|
||||
}
|
||||
}
|
||||
} else {
|
||||
match image::load_from_memory(&buf) {
|
||||
@@ -793,7 +860,10 @@ fn try_import_krita_maindoc(archive: &mut zip::ZipArchive<std::fs::File>) -> Res
|
||||
}
|
||||
pixels
|
||||
}
|
||||
Err(_) => vec![0u8; (width * height * 4) as usize],
|
||||
Err(e) => {
|
||||
log::warn!("KRA import: image decode failed for layer '{}': {}", info.name, e);
|
||||
vec![0u8; (width * height * 4) as usize]
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -827,19 +897,49 @@ fn try_import_krita_maindoc(archive: &mut zip::ZipArchive<std::fs::File>) -> Res
|
||||
layer.adjustment_raw = Some((*b"svg ", buf.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
if !info.layerstyle_uuid.is_empty() {
|
||||
if let Some(effects) = asl_styles.get(&info.layerstyle_uuid) {
|
||||
layer.effects = effects.clone();
|
||||
layer.styles = effects.iter().map(kra_effect_to_layer_style).collect();
|
||||
}
|
||||
}
|
||||
|
||||
layers.push(layer);
|
||||
intermediate_layers.push((info.filename.clone(), layer));
|
||||
}
|
||||
|
||||
// Pass 2: Transparency Masks
|
||||
let layers_info_clone = layers_info.clone();
|
||||
for info in layers_info_clone.into_iter().rev() {
|
||||
if info.nodetype == "transparencymask" {
|
||||
if let Some(parent_filename) = info.parent_filename {
|
||||
let layer_path = find_krita_layer_file(&all_files, &info.filename, &info.nodetype);
|
||||
if let Some(zip_path) = layer_path {
|
||||
let mut buf = Vec::new();
|
||||
{
|
||||
let mut file = match archive.by_name(&zip_path) {
|
||||
Ok(f) => f,
|
||||
Err(_) => continue,
|
||||
};
|
||||
let _ = file.read_to_end(&mut buf);
|
||||
}
|
||||
if !buf.is_empty() {
|
||||
let doc_w = width;
|
||||
let doc_h = height;
|
||||
let default_pixel = read_defaultpixel(archive, &info.filename);
|
||||
if let Ok(mask_pixels) = parse_vers_tiles(&buf, doc_w, doc_h, info.x, info.y, default_pixel) {
|
||||
if let Some((_, parent_layer)) = intermediate_layers.iter_mut().find(|(fname, _)| *fname == parent_filename) {
|
||||
parent_layer.mask_pixels = Some(mask_pixels);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let layers = intermediate_layers.into_iter().map(|(_, l)| l).collect::<Vec<_>>();
|
||||
|
||||
if layers.is_empty() {
|
||||
Err("No valid layers found in maindoc.xml".to_string())
|
||||
return Err("No valid layers found in maindoc.xml".to_string());
|
||||
} else {
|
||||
Ok(layers)
|
||||
}
|
||||
|
||||
+67
-2
@@ -27,6 +27,14 @@ struct HcieLayerMeta {
|
||||
effects: Vec<hcie_protocol::effects::LayerEffect>,
|
||||
#[serde(default = "default_fill_opacity")]
|
||||
fill_opacity: f32,
|
||||
#[serde(default)]
|
||||
mask_bounds: Option<[i32; 4]>,
|
||||
#[serde(default)]
|
||||
mask_default_color: u8,
|
||||
#[serde(default)]
|
||||
mask_offset: Option<u64>,
|
||||
#[serde(default)]
|
||||
mask_len: Option<u64>,
|
||||
}
|
||||
|
||||
fn default_fill_opacity() -> f32 { 1.0 }
|
||||
@@ -44,10 +52,22 @@ pub fn save_native(layers: &[Layer], path: &Path) -> Result<(), String> {
|
||||
|
||||
let mut file = std::fs::File::create(path).map_err(|e| e.to_string())?;
|
||||
|
||||
let mut pixel_offset = 0u64;
|
||||
let mut binary_offset = 0u64;
|
||||
let mut layer_metas = Vec::with_capacity(layers.len());
|
||||
for layer in layers {
|
||||
let pixel_len = layer.pixels.len() as u64;
|
||||
let pixel_offset = binary_offset;
|
||||
binary_offset += pixel_len;
|
||||
|
||||
let (mask_offset, mask_len) = if let Some(ref mask) = layer.mask_pixels {
|
||||
let m_off = binary_offset;
|
||||
let m_len = mask.len() as u64;
|
||||
binary_offset += m_len;
|
||||
(Some(m_off), Some(m_len))
|
||||
} else {
|
||||
(None, None)
|
||||
};
|
||||
|
||||
layer_metas.push(HcieLayerMeta {
|
||||
id: layer.id,
|
||||
name: layer.name.clone(),
|
||||
@@ -67,8 +87,11 @@ pub fn save_native(layers: &[Layer], path: &Path) -> Result<(), String> {
|
||||
pixel_len,
|
||||
effects: layer.effects.clone(),
|
||||
fill_opacity: layer.fill_opacity,
|
||||
mask_bounds: layer.mask_bounds,
|
||||
mask_default_color: layer.mask_default_color,
|
||||
mask_offset,
|
||||
mask_len,
|
||||
});
|
||||
pixel_offset += pixel_len;
|
||||
}
|
||||
|
||||
let doc_meta = HcieDocumentMeta {
|
||||
@@ -88,6 +111,9 @@ pub fn save_native(layers: &[Layer], path: &Path) -> Result<(), String> {
|
||||
|
||||
for layer in layers {
|
||||
file.write_all(&layer.pixels).map_err(|e| e.to_string())?;
|
||||
if let Some(ref mask) = layer.mask_pixels {
|
||||
file.write_all(mask).map_err(|e| e.to_string())?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -144,10 +170,49 @@ pub fn load_native(path: &Path) -> Result<Vec<Layer>, String> {
|
||||
layer.id = meta.id;
|
||||
layer.effects = meta.effects;
|
||||
layer.fill_opacity = meta.fill_opacity;
|
||||
|
||||
if let (Some(m_off), Some(m_len)) = (meta.mask_offset, meta.mask_len) {
|
||||
let m_off_sz = m_off as usize;
|
||||
let m_len_sz = m_len as usize;
|
||||
if m_off_sz + m_len_sz <= pixel_data.len() {
|
||||
layer.mask_pixels = Some(pixel_data[m_off_sz..m_off_sz + m_len_sz].to_vec());
|
||||
layer.mask_bounds = meta.mask_bounds;
|
||||
layer.mask_default_color = meta.mask_default_color;
|
||||
}
|
||||
}
|
||||
|
||||
layers.push(layer);
|
||||
}
|
||||
|
||||
Ok(layers)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_save_load_native_layer_mask() {
|
||||
let mut layer = Layer::new_transparent("Test Layer", 10, 10);
|
||||
let mask = vec![128u8; 100];
|
||||
layer.mask_pixels = Some(mask.clone());
|
||||
layer.mask_bounds = Some([0, 0, 10, 10]);
|
||||
layer.mask_default_color = 255;
|
||||
|
||||
let temp_dir = std::env::temp_dir();
|
||||
let path = temp_dir.join("test_mask_native.hcie");
|
||||
|
||||
save_native(&[layer], &path).expect("Failed to save native document");
|
||||
|
||||
let loaded = load_native(&path).expect("Failed to load native document");
|
||||
assert_eq!(loaded.len(), 1);
|
||||
let loaded_layer = &loaded[0];
|
||||
assert_eq!(loaded_layer.mask_pixels, Some(mask));
|
||||
assert_eq!(loaded_layer.mask_bounds, Some([0, 0, 10, 10]));
|
||||
assert_eq!(loaded_layer.mask_default_color, 255);
|
||||
|
||||
let _ = std::fs::remove_file(path);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -615,7 +615,7 @@ pub fn parse_psd_sequential_full(bytes: &[u8]) -> Result<ParsedPsdData, String>
|
||||
let mut mask_default_color = 0;
|
||||
|
||||
for channel in &layer.channels {
|
||||
if channel.id == -2 {
|
||||
if (channel.id == -2 || channel.id == -3) && decompressed_mask.is_none() {
|
||||
if let Some(mask) = &layer.mask_info {
|
||||
let w = (mask.right - mask.left) as u32;
|
||||
let h = (mask.bottom - mask.top) as u32;
|
||||
@@ -892,3 +892,19 @@ pub fn import_psd(path: &Path) -> Result<Vec<hcie_protocol::Layer>, String> {
|
||||
|
||||
Ok(flat_layers)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_example3_mask_import() {
|
||||
let path = std::path::Path::new("/mnt/extra/00_PROJECTS/hcie-rust-v3.05/_images/_test_images/example3/Example3-mini.psd");
|
||||
if path.exists() {
|
||||
let layers = import_psd(path).expect("import_psd failed");
|
||||
for l in &layers {
|
||||
println!("TEST_LAYER: '{}' has_mask={} bounds={:?}", l.name, l.mask_pixels.is_some(), l.mask_bounds);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,10 +11,8 @@ fontdue = "0.9"
|
||||
crate-type = ["rlib"]
|
||||
|
||||
[dev-dependencies]
|
||||
eframe = { version = "0.34", default-features = false, features = ["default_fonts", "glow"] }
|
||||
egui = "0.34"
|
||||
eframe = { workspace = true }
|
||||
egui = { workspace = true }
|
||||
rstest = "0.23"
|
||||
proptest = "1.5"
|
||||
approx = "0.5"
|
||||
proptest = "1.5"
|
||||
approx = "0.5"
|
||||
|
||||
@@ -17,5 +17,5 @@ crate-type = ["staticlib", "rlib"]
|
||||
rstest = "0.23"
|
||||
proptest = "1.5"
|
||||
approx = "0.5"
|
||||
egui = "0.34"
|
||||
eframe = { version = "0.34", default-features = false, features = ["default_fonts", "glow"] }
|
||||
egui = { workspace = true }
|
||||
eframe = { workspace = true }
|
||||
|
||||
Binary file not shown.
+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)
|
||||
=========================================
|
||||
|
||||
+34
@@ -2,7 +2,40 @@ cargo run -p hcie-wx-app --bin hcie-wx-app
|
||||
cargo run --bin hcie-gui
|
||||
cargo run --example gui
|
||||
cargo run -p hcie-iced-gui
|
||||
cargo build -p hcie-iced-gui --bin hcie-iced
|
||||
scripts/cargo-with-build-id.sh run -p hcie-iced-gui
|
||||
#######
|
||||
criterion Benchmark
|
||||
cargo bench -p hcie-engine-api
|
||||
|
||||
# Mevcut mükemmel performansı kalıcı bir baseline (referans) olarak kaydetmek için:
|
||||
# (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 --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
|
||||
@@ -12,6 +45,7 @@ cargo test -p hcie-iced-gui -- proximate
|
||||
To run all cycle_one_ux_tests (including the new ones):
|
||||
cargo test -p hcie-iced-gui -- cycle_one_ux_tests
|
||||
|
||||
###########
|
||||
To run the FULL test suite for the iced GUI crate:
|
||||
cargo test -p hcie-iced-gui
|
||||
|
||||
|
||||
+74
-19
@@ -3,25 +3,54 @@ REM ============================================================
|
||||
REM HCIE-Rust v3.05 — Bulk All-Tests Execution Script (Windows)
|
||||
REM Usage: run_all_tests.bat [--no-io] [--no-4k] [--ignored]
|
||||
REM ============================================================
|
||||
setlocal enabledelayedexpansion
|
||||
setlocal DisableDelayedExpansion
|
||||
|
||||
set ROOT_DIR=%~dp0
|
||||
set "ROOT_DIR=%~dp0"
|
||||
cd /d "%ROOT_DIR%" || exit /b 1
|
||||
setlocal EnableDelayedExpansion
|
||||
|
||||
set PASS=0
|
||||
set FAIL=0
|
||||
set SKIP=0
|
||||
set FAILED_CRATES=
|
||||
set SKIPPED_CRATES=
|
||||
set START_TIME=%TIME%
|
||||
|
||||
set SKIP_IO=0
|
||||
set SKIP_4K=0
|
||||
set INCLUDE_IGNORED=0
|
||||
set CARGO_ARGS=
|
||||
set TEST_ARGS=
|
||||
set PARSING_TEST_ARGS=0
|
||||
|
||||
:parse_args
|
||||
if "%~1"=="" goto :done_parse
|
||||
if /I "%~1"=="--no-io" set SKIP_IO=1
|
||||
if /I "%~1"=="--no-4k" set SKIP_4K=1
|
||||
if /I "%~1"=="--ignored" set INCLUDE_IGNORED=1
|
||||
if "!PARSING_TEST_ARGS!"=="1" goto :collect_test_arg
|
||||
if /I "%~1"=="--no-io" goto :enable_no_io
|
||||
if /I "%~1"=="--no-4k" goto :enable_no_4k
|
||||
if /I "%~1"=="--ignored" goto :enable_ignored
|
||||
if "%~1"=="--" goto :begin_test_args
|
||||
set "CARGO_ARGS=!CARGO_ARGS! "%~1""
|
||||
shift
|
||||
goto :parse_args
|
||||
:enable_no_io
|
||||
set SKIP_IO=1
|
||||
shift
|
||||
goto :parse_args
|
||||
:enable_no_4k
|
||||
set SKIP_4K=1
|
||||
shift
|
||||
goto :parse_args
|
||||
:enable_ignored
|
||||
set INCLUDE_IGNORED=1
|
||||
shift
|
||||
goto :parse_args
|
||||
:begin_test_args
|
||||
set PARSING_TEST_ARGS=1
|
||||
shift
|
||||
goto :parse_args
|
||||
:collect_test_arg
|
||||
set "TEST_ARGS=!TEST_ARGS! "%~1""
|
||||
shift
|
||||
goto :parse_args
|
||||
:done_parse
|
||||
@@ -41,18 +70,17 @@ call :RUN_CRATE "hcie-history"
|
||||
if %SKIP_IO%==1 (
|
||||
echo [SKIP] hcie-io
|
||||
set /a SKIP+=1
|
||||
set SKIPPED_CRATES=!SKIPPED_CRATES! hcie-io
|
||||
) else (
|
||||
call :RUN_CRATE "hcie-io"
|
||||
)
|
||||
|
||||
call :RUN_CRATE "hcie-psd"
|
||||
call :RUN_CRATE "psd"
|
||||
call :RUN_CRATE "hcie-vision"
|
||||
call :RUN_CRATE "hcie-build-info"
|
||||
|
||||
if %SKIP_4K%==1 (
|
||||
echo [SKIP 4K benchmark in hcie-engine-api]
|
||||
cargo test -p hcie-engine-api --skip benchmark_4k_stroke_on_multilayer_document
|
||||
if !ERRORLEVEL!==0 ( set /a PASS+=1 ) else ( set /a FAIL+=1 )
|
||||
call :RUN_CRATE "hcie-engine-api" --skip benchmark_4k_stroke_on_multilayer_document
|
||||
) else (
|
||||
call :RUN_CRATE "hcie-engine-api"
|
||||
)
|
||||
@@ -69,25 +97,52 @@ call :RUN_CRATE "hcie-ink-brushes"
|
||||
if %INCLUDE_IGNORED%==1 (
|
||||
echo.
|
||||
echo ===== Running IGNORED tests =====
|
||||
cargo test -p hcie-engine-api -- --ignored
|
||||
cargo test -p hcie-brush-engine -- --ignored
|
||||
cargo test -p hcie-iced-gui -- --ignored
|
||||
cargo test -p hcie-fx -- --ignored
|
||||
call :RUN_IGNORED hcie-engine-api
|
||||
call :RUN_IGNORED hcie-brush-engine
|
||||
call :RUN_IGNORED hcie-iced-gui
|
||||
call :RUN_IGNORED hcie-fx
|
||||
)
|
||||
|
||||
echo ============================================================
|
||||
echo BULK TEST EXECUTION COMPLETE
|
||||
echo Crates passed: %PASS%
|
||||
echo Crates failed: %FAIL%
|
||||
echo Test runs passed: %PASS%
|
||||
echo Test runs failed: %FAIL%
|
||||
echo Crates skipped: %SKIP%
|
||||
if not "%FAILED_CRATES%"=="" echo Failed crates:%FAILED_CRATES%
|
||||
if not "%SKIPPED_CRATES%"=="" echo Skipped crates:%SKIPPED_CRATES%
|
||||
echo Start time: %START_TIME%
|
||||
echo ============================================================
|
||||
exit /b %FAIL%
|
||||
|
||||
:RUN_CRATE
|
||||
set CRATE=%~1
|
||||
set "CRATE=%~1"
|
||||
set "CRATE_TEST_ARGS="
|
||||
:RUN_CRATE_ARG_LOOP
|
||||
shift
|
||||
if "%~1"=="" goto :RUN_CRATE_EXECUTE
|
||||
set "CRATE_TEST_ARGS=!CRATE_TEST_ARGS! "%~1""
|
||||
goto :RUN_CRATE_ARG_LOOP
|
||||
:RUN_CRATE_EXECUTE
|
||||
set "TEST_SEPARATOR="
|
||||
if not "!CRATE_TEST_ARGS!!TEST_ARGS!"=="" set "TEST_SEPARATOR=--"
|
||||
echo.
|
||||
echo ===== Running: %CRATE% =====
|
||||
cargo test -p %CRATE%
|
||||
if %ERRORLEVEL%==0 ( set /a PASS+=1 ) else ( set /a FAIL+=1 )
|
||||
echo ===== Running: !CRATE! =====
|
||||
cargo test -p "!CRATE!" !CARGO_ARGS! !TEST_SEPARATOR! !CRATE_TEST_ARGS! !TEST_ARGS!
|
||||
if errorlevel 1 (
|
||||
set /a FAIL+=1
|
||||
set FAILED_CRATES=!FAILED_CRATES! !CRATE!
|
||||
) else (
|
||||
set /a PASS+=1
|
||||
)
|
||||
goto :EOF
|
||||
|
||||
:RUN_IGNORED
|
||||
set "IGNORED_CRATE=%~1"
|
||||
cargo test -p "!IGNORED_CRATE!" !CARGO_ARGS! -- --ignored !TEST_ARGS!
|
||||
if errorlevel 1 (
|
||||
set /a FAIL+=1
|
||||
set FAILED_CRATES=!FAILED_CRATES! !IGNORED_CRATE![ignored]
|
||||
) else (
|
||||
set /a PASS+=1
|
||||
)
|
||||
goto :EOF
|
||||
|
||||
+49
-14
@@ -16,19 +16,28 @@ cd "$ROOT_DIR"
|
||||
PASS=0
|
||||
FAIL=0
|
||||
SKIP=0
|
||||
FAILED_CRATES=()
|
||||
SKIPPED_CRATES=()
|
||||
START_TIME=$(date +%s)
|
||||
|
||||
SKIP_IO=false
|
||||
SKIP_4K=false
|
||||
INCLUDE_IGNORED=false
|
||||
EXTRA_ARGS=()
|
||||
CARGO_ARGS=()
|
||||
TEST_ARGS=()
|
||||
PARSING_TEST_ARGS=false
|
||||
|
||||
for arg in "$@"; do
|
||||
if [ "$PARSING_TEST_ARGS" = true ]; then
|
||||
TEST_ARGS+=("$arg")
|
||||
continue
|
||||
fi
|
||||
case "$arg" in
|
||||
--no-io) SKIP_IO=true ;;
|
||||
--no-4k) SKIP_4K=true ;;
|
||||
--ignored) INCLUDE_IGNORED=true ;;
|
||||
*) EXTRA_ARGS+=("$arg") ;;
|
||||
--) PARSING_TEST_ARGS=true ;;
|
||||
*) CARGO_ARGS+=("$arg") ;;
|
||||
esac
|
||||
done
|
||||
|
||||
@@ -37,17 +46,36 @@ SEPARATOR() { printf '%*s\n' 80 '' | tr ' ' '='; }
|
||||
run_crate() {
|
||||
local crate="$1"
|
||||
local label="$2"
|
||||
local extra="${3:-}"
|
||||
shift 2
|
||||
local -a crate_test_args=("$@")
|
||||
local -a cmd=(cargo test -p "$crate" "${CARGO_ARGS[@]}")
|
||||
if (( ${#crate_test_args[@]} > 0 || ${#TEST_ARGS[@]} > 0 )); then
|
||||
cmd+=(-- "${crate_test_args[@]}" "${TEST_ARGS[@]}")
|
||||
fi
|
||||
|
||||
SEPARATOR
|
||||
echo "[$label] Running: cargo test -p $crate $extra"
|
||||
printf '[%s] Running:' "$label"
|
||||
printf ' %q' "${cmd[@]}"
|
||||
printf '\n'
|
||||
SEPARATOR
|
||||
|
||||
# shellcheck disable=SC2086
|
||||
if cargo test -p "$crate" $extra "${EXTRA_ARGS[@]+${EXTRA_ARGS[@]}}" 2>&1; then
|
||||
if "${cmd[@]}" 2>&1; then
|
||||
PASS=$((PASS + 1))
|
||||
else
|
||||
FAIL=$((FAIL + 1))
|
||||
FAILED_CRATES+=("$crate")
|
||||
fi
|
||||
}
|
||||
|
||||
run_ignored_tests() {
|
||||
local crate="$1"
|
||||
local -a cmd=(cargo test -p "$crate" "${CARGO_ARGS[@]}" -- --ignored "${TEST_ARGS[@]}")
|
||||
|
||||
if "${cmd[@]}" 2>&1; then
|
||||
PASS=$((PASS + 1))
|
||||
else
|
||||
FAIL=$((FAIL + 1))
|
||||
FAILED_CRATES+=("${crate}[ignored]")
|
||||
fi
|
||||
}
|
||||
|
||||
@@ -56,9 +84,15 @@ display_summary() {
|
||||
echo ""
|
||||
SEPARATOR
|
||||
echo " BULK TEST EXECUTION COMPLETE"
|
||||
echo " Crates passed: $PASS"
|
||||
echo " Crates failed: $FAIL"
|
||||
echo " Test runs passed: $PASS"
|
||||
echo " Test runs failed: $FAIL"
|
||||
echo " Crates skipped: $SKIP"
|
||||
if (( ${#FAILED_CRATES[@]} > 0 )); then
|
||||
echo " Failed crates: ${FAILED_CRATES[*]}"
|
||||
fi
|
||||
if (( ${#SKIPPED_CRATES[@]} > 0 )); then
|
||||
echo " Skipped crates: ${SKIPPED_CRATES[*]}"
|
||||
fi
|
||||
echo " Elapsed time: ${elapsed}s"
|
||||
SEPARATOR
|
||||
[ "$FAIL" -eq 0 ] && echo " ALL CRATES PASSED" || echo " $FAIL crate(s) have failing tests"
|
||||
@@ -86,11 +120,12 @@ run_crate "hcie-history" "Layer 2"
|
||||
if [ "$SKIP_IO" = true ]; then
|
||||
echo "[SKIP] hcie-io (--no-io flag)"
|
||||
SKIP=$((SKIP + 1))
|
||||
SKIPPED_CRATES+=("hcie-io")
|
||||
else
|
||||
run_crate "hcie-io" "Layer 2"
|
||||
fi
|
||||
|
||||
run_crate "hcie-psd" "Layer 2"
|
||||
run_crate "psd" "Layer 2"
|
||||
run_crate "hcie-vision" "Layer 2"
|
||||
run_crate "hcie-build-info" "Layer 2"
|
||||
|
||||
@@ -98,7 +133,7 @@ run_crate "hcie-build-info" "Layer 2"
|
||||
# Layer 4: ENGINE API
|
||||
# ──────────────────────────────────────────────────────────
|
||||
if [ "$SKIP_4K" = true ]; then
|
||||
run_crate "hcie-engine-api" "Layer 4" "--skip benchmark_4k_stroke_on_multilayer_document"
|
||||
run_crate "hcie-engine-api" "Layer 4" --skip benchmark_4k_stroke_on_multilayer_document
|
||||
else
|
||||
run_crate "hcie-engine-api" "Layer 4"
|
||||
fi
|
||||
@@ -130,10 +165,10 @@ if [ "$INCLUDE_IGNORED" = true ]; then
|
||||
SEPARATOR
|
||||
echo "Running IGNORED tests (visual checks, benchmarks)"
|
||||
SEPARATOR
|
||||
cargo test -p hcie-engine-api -- --ignored || true
|
||||
cargo test -p hcie-brush-engine -- --ignored || true
|
||||
cargo test -p hcie-iced-gui -- --ignored || true
|
||||
cargo test -p hcie-fx -- --ignored || true
|
||||
run_ignored_tests hcie-engine-api
|
||||
run_ignored_tests hcie-brush-engine
|
||||
run_ignored_tests hcie-iced-gui
|
||||
run_ignored_tests hcie-fx
|
||||
fi
|
||||
|
||||
display_summary
|
||||
|
||||
+73
-18
@@ -3,10 +3,11 @@ REM ============================================================
|
||||
REM HCIE-Rust v3.05 — Categorized Test Runner (Windows .bat)
|
||||
REM Usage: run_tests_categorized.bat [--no-io] [--no-4k] [--ignored]
|
||||
REM ============================================================
|
||||
setlocal enabledelayedexpansion
|
||||
setlocal DisableDelayedExpansion
|
||||
|
||||
set ROOT_DIR=%~dp0
|
||||
set "ROOT_DIR=%~dp0"
|
||||
cd /d "%ROOT_DIR%" || exit /b 1
|
||||
setlocal EnableDelayedExpansion
|
||||
|
||||
echo ============================================================
|
||||
echo HCIE-Rust v3.05 — Categorized Test Runner
|
||||
@@ -16,15 +17,43 @@ echo ============================================================
|
||||
set PASS=0
|
||||
set FAIL=0
|
||||
set SKIP=0
|
||||
set FAILED_CRATES=
|
||||
set SKIPPED_CRATES=
|
||||
set SKIP_IO=0
|
||||
set SKIP_4K=0
|
||||
set INCLUDE_IGNORED=0
|
||||
set CARGO_ARGS=
|
||||
set TEST_ARGS=
|
||||
set PARSING_TEST_ARGS=0
|
||||
|
||||
:parse_args
|
||||
if "%~1"=="" goto :done_parse
|
||||
if /I "%~1"=="--no-io" set SKIP_IO=1
|
||||
if /I "%~1"=="--no-4k" set SKIP_4K=1
|
||||
if /I "%~1"=="--ignored" set INCLUDE_IGNORED=1
|
||||
if "!PARSING_TEST_ARGS!"=="1" goto :collect_test_arg
|
||||
if /I "%~1"=="--no-io" goto :enable_no_io
|
||||
if /I "%~1"=="--no-4k" goto :enable_no_4k
|
||||
if /I "%~1"=="--ignored" goto :enable_ignored
|
||||
if "%~1"=="--" goto :begin_test_args
|
||||
set "CARGO_ARGS=!CARGO_ARGS! "%~1""
|
||||
shift
|
||||
goto :parse_args
|
||||
:enable_no_io
|
||||
set SKIP_IO=1
|
||||
shift
|
||||
goto :parse_args
|
||||
:enable_no_4k
|
||||
set SKIP_4K=1
|
||||
shift
|
||||
goto :parse_args
|
||||
:enable_ignored
|
||||
set INCLUDE_IGNORED=1
|
||||
shift
|
||||
goto :parse_args
|
||||
:begin_test_args
|
||||
set PARSING_TEST_ARGS=1
|
||||
shift
|
||||
goto :parse_args
|
||||
:collect_test_arg
|
||||
set "TEST_ARGS=!TEST_ARGS! "%~1""
|
||||
shift
|
||||
goto :parse_args
|
||||
:done_parse
|
||||
@@ -37,15 +66,14 @@ call :RUN_CATEGORY "Layer 2: Selection/Vector/History" hcie-selection hcie-vecto
|
||||
if %SKIP_IO%==1 (
|
||||
echo [SKIP] hcie-io
|
||||
set /a SKIP+=1
|
||||
set SKIPPED_CRATES=!SKIPPED_CRATES! hcie-io
|
||||
) else (
|
||||
call :RUN_CRATE hcie-io
|
||||
)
|
||||
call :RUN_CATEGORY "Layer 2: PSD/Vision/Build" hcie-psd hcie-vision hcie-build-info
|
||||
call :RUN_CATEGORY "Layer 2: PSD/Vision/Build" psd hcie-vision hcie-build-info
|
||||
|
||||
if %SKIP_4K%==1 (
|
||||
echo [SKIP 4K benchmark in hcie-engine-api]
|
||||
cargo test -p hcie-engine-api --skip benchmark_4k_stroke_on_multilayer_document
|
||||
if !ERRORLEVEL!==0 ( set /a PASS+=1 ) else ( set /a FAIL+=1 )
|
||||
call :RUN_CRATE hcie-engine-api --skip benchmark_4k_stroke_on_multilayer_document
|
||||
) else (
|
||||
call :RUN_CRATE hcie-engine-api
|
||||
)
|
||||
@@ -57,14 +85,16 @@ call :RUN_CATEGORY "Brush Catalogs" hcie-dry-media-brushes hcie-paint-brushes hc
|
||||
if %INCLUDE_IGNORED%==1 (
|
||||
echo.
|
||||
echo ===== Running IGNORED tests =====
|
||||
cargo test -p hcie-engine-api -- --ignored
|
||||
cargo test -p hcie-brush-engine -- --ignored
|
||||
cargo test -p hcie-iced-gui -- --ignored
|
||||
cargo test -p hcie-fx -- --ignored
|
||||
call :RUN_IGNORED hcie-engine-api
|
||||
call :RUN_IGNORED hcie-brush-engine
|
||||
call :RUN_IGNORED hcie-iced-gui
|
||||
call :RUN_IGNORED hcie-fx
|
||||
)
|
||||
|
||||
echo ============================================================
|
||||
echo SUMMARY: %PASS% passed, %FAIL% failed, %SKIP% skipped
|
||||
echo SUMMARY: %PASS% test runs passed, %FAIL% failed, %SKIP% crates skipped
|
||||
if not "%FAILED_CRATES%"=="" echo Failed crates:%FAILED_CRATES%
|
||||
if not "%SKIPPED_CRATES%"=="" echo Skipped crates:%SKIPPED_CRATES%
|
||||
echo ============================================================
|
||||
exit /b %FAIL%
|
||||
|
||||
@@ -80,8 +110,33 @@ shift
|
||||
goto RUN_CATEGORY_LOOP
|
||||
|
||||
:RUN_CRATE
|
||||
set CRATE=%~1
|
||||
echo --- Running: %CRATE% ---
|
||||
cargo test -p %CRATE%
|
||||
if %ERRORLEVEL%==0 ( set /a PASS+=1 ) else ( set /a FAIL+=1 )
|
||||
set "CRATE=%~1"
|
||||
set "CRATE_TEST_ARGS="
|
||||
:RUN_CRATE_ARG_LOOP
|
||||
shift
|
||||
if "%~1"=="" goto :RUN_CRATE_EXECUTE
|
||||
set "CRATE_TEST_ARGS=!CRATE_TEST_ARGS! "%~1""
|
||||
goto :RUN_CRATE_ARG_LOOP
|
||||
:RUN_CRATE_EXECUTE
|
||||
set "TEST_SEPARATOR="
|
||||
if not "!CRATE_TEST_ARGS!!TEST_ARGS!"=="" set "TEST_SEPARATOR=--"
|
||||
echo --- Running: !CRATE! ---
|
||||
cargo test -p "!CRATE!" !CARGO_ARGS! !TEST_SEPARATOR! !CRATE_TEST_ARGS! !TEST_ARGS!
|
||||
if errorlevel 1 (
|
||||
set /a FAIL+=1
|
||||
set FAILED_CRATES=!FAILED_CRATES! !CRATE!
|
||||
) else (
|
||||
set /a PASS+=1
|
||||
)
|
||||
goto :EOF
|
||||
|
||||
:RUN_IGNORED
|
||||
set "IGNORED_CRATE=%~1"
|
||||
cargo test -p "!IGNORED_CRATE!" !CARGO_ARGS! -- --ignored !TEST_ARGS!
|
||||
if errorlevel 1 (
|
||||
set /a FAIL+=1
|
||||
set FAILED_CRATES=!FAILED_CRATES! !IGNORED_CRATE![ignored]
|
||||
) else (
|
||||
set /a PASS+=1
|
||||
)
|
||||
goto :EOF
|
||||
|
||||
+48
-10
@@ -11,15 +11,26 @@ cd "$ROOT_DIR"
|
||||
PASS=0
|
||||
FAIL=0
|
||||
SKIP=0
|
||||
FAILED_CRATES=()
|
||||
SKIPPED_CRATES=()
|
||||
SKIP_IO=false
|
||||
SKIP_4K=false
|
||||
INCLUDE_IGNORED=false
|
||||
CARGO_ARGS=()
|
||||
TEST_ARGS=()
|
||||
PARSING_TEST_ARGS=false
|
||||
|
||||
for arg in "$@"; do
|
||||
if [ "$PARSING_TEST_ARGS" = true ]; then
|
||||
TEST_ARGS+=("$arg")
|
||||
continue
|
||||
fi
|
||||
case "$arg" in
|
||||
--no-io) SKIP_IO=true ;;
|
||||
--no-4k) SKIP_4K=true ;;
|
||||
--ignored) INCLUDE_IGNORED=true ;;
|
||||
--) PARSING_TEST_ARGS=true ;;
|
||||
*) CARGO_ARGS+=("$arg") ;;
|
||||
esac
|
||||
done
|
||||
|
||||
@@ -28,16 +39,36 @@ SEPARATOR() { printf '%*s\n' 80 '' | tr ' ' '='; }
|
||||
run_crate() {
|
||||
local crate="$1"
|
||||
local label="$2"
|
||||
local extra="${3:-}"
|
||||
shift 2
|
||||
local -a crate_test_args=("$@")
|
||||
local -a cmd=(cargo test -p "$crate" "${CARGO_ARGS[@]}")
|
||||
if (( ${#crate_test_args[@]} > 0 || ${#TEST_ARGS[@]} > 0 )); then
|
||||
cmd+=(-- "${crate_test_args[@]}" "${TEST_ARGS[@]}")
|
||||
fi
|
||||
|
||||
SEPARATOR
|
||||
echo "[$label] Running: cargo test -p $crate $extra"
|
||||
printf '[%s] Running:' "$label"
|
||||
printf ' %q' "${cmd[@]}"
|
||||
printf '\n'
|
||||
SEPARATOR
|
||||
|
||||
if cargo test -p "$crate" $extra 2>&1; then
|
||||
if "${cmd[@]}" 2>&1; then
|
||||
PASS=$((PASS + 1))
|
||||
else
|
||||
FAIL=$((FAIL + 1))
|
||||
FAILED_CRATES+=("$crate")
|
||||
fi
|
||||
}
|
||||
|
||||
run_ignored_tests() {
|
||||
local crate="$1"
|
||||
local -a cmd=(cargo test -p "$crate" "${CARGO_ARGS[@]}" -- --ignored "${TEST_ARGS[@]}")
|
||||
|
||||
if "${cmd[@]}" 2>&1; then
|
||||
PASS=$((PASS + 1))
|
||||
else
|
||||
FAIL=$((FAIL + 1))
|
||||
FAILED_CRATES+=("${crate}[ignored]")
|
||||
fi
|
||||
}
|
||||
|
||||
@@ -58,16 +89,17 @@ run_crate "hcie-history" "CORE"
|
||||
if [ "$SKIP_IO" = true ]; then
|
||||
echo "[SKIP] hcie-io"
|
||||
SKIP=$((SKIP + 1))
|
||||
SKIPPED_CRATES+=("hcie-io")
|
||||
else
|
||||
run_crate "hcie-io" "CORE"
|
||||
fi
|
||||
run_crate "hcie-psd" "CORE"
|
||||
run_crate "psd" "CORE"
|
||||
run_crate "hcie-vision" "CORE"
|
||||
run_crate "hcie-build-info" "CORE"
|
||||
|
||||
# ── Layer 4: ENGINE API ────────────────────────────────────
|
||||
if [ "$SKIP_4K" = true ]; then
|
||||
run_crate "hcie-engine-api" "ENGINE-API" "--skip benchmark_4k_stroke_on_multilayer_document"
|
||||
run_crate "hcie-engine-api" "ENGINE-API" --skip benchmark_4k_stroke_on_multilayer_document
|
||||
else
|
||||
run_crate "hcie-engine-api" "ENGINE-API"
|
||||
fi
|
||||
@@ -89,13 +121,19 @@ if [ "$INCLUDE_IGNORED" = true ]; then
|
||||
SEPARATOR
|
||||
echo "Running IGNORED tests (visual checks, benchmarks)"
|
||||
SEPARATOR
|
||||
cargo test -p hcie-engine-api -- --ignored || true
|
||||
cargo test -p hcie-brush-engine -- --ignored || true
|
||||
cargo test -p hcie-iced-gui -- --ignored || true
|
||||
cargo test -p hcie-fx -- --ignored || true
|
||||
run_ignored_tests hcie-engine-api
|
||||
run_ignored_tests hcie-brush-engine
|
||||
run_ignored_tests hcie-iced-gui
|
||||
run_ignored_tests hcie-fx
|
||||
fi
|
||||
|
||||
SEPARATOR
|
||||
echo "CATEGORIZED TEST RUN COMPLETE: $PASS passed, $FAIL failed, $SKIP skipped"
|
||||
echo "CATEGORIZED TEST RUN COMPLETE: $PASS test runs passed, $FAIL failed, $SKIP crates skipped"
|
||||
if (( ${#FAILED_CRATES[@]} > 0 )); then
|
||||
echo "Failed crates: ${FAILED_CRATES[*]}"
|
||||
fi
|
||||
if (( ${#SKIPPED_CRATES[@]} > 0 )); then
|
||||
echo "Skipped crates: ${SKIPPED_CRATES[*]}"
|
||||
fi
|
||||
SEPARATOR
|
||||
exit "$FAIL"
|
||||
|
||||
Executable
BIN
Binary file not shown.
Executable
BIN
Binary file not shown.
Executable
BIN
Binary file not shown.
Executable
BIN
Binary file not shown.
@@ -0,0 +1,17 @@
|
||||
Build:1022
|
||||
Egui ile Iced arasında hız farkı var. Egui hızlı . Ama o kadar büyük değil. Zaman zaman bu gerileme ortaya çıkıyor. O yüzden buraya karşılaştırma için dosyaları bırakıyorum.
|
||||
|
||||
commit : f2cca7e6a19daa9f470680260bfdf24746f214ee
|
||||
DÜZELTME
|
||||
feat: implement file opening logic in a new tab and streamline document loading process
|
||||
|
||||
commit : a2264b130d47fbbd565226040bc28fd5b9626177
|
||||
4K MULTI LAYEPERFORMANCE OK : SOL 5.6 HIGH feat(perf): add real-time canvas performance measurements
|
||||
|
||||
- Introduced a new performance module for the iced canvas to measure input queueing, engine drawing, CPU compositing/staging, GPU upload, and renderer preparation costs.
|
||||
- Implemented a performance probe in the egui reference renderer for comparative diagnostics.
|
||||
- Added methods to record durations, byte transfers, and input timestamps, enabling detailed performance analysis.
|
||||
- Enhanced the CanvasShaderPipeline with a new method to upload full textures without rebuilding GPU resources.
|
||||
- Updated the main application to conditionally log performance diagnostics based on an environment variable.
|
||||
- Created a performance plan document outlining steps to measure and optimize drawing performance in the iced application.
|
||||
|
||||
Reference in New Issue
Block a user