Compare commits
27 Commits
21175b98c1
...
clone-tool
| Author | SHA1 | Date | |
|---|---|---|---|
| b31a949215 | |||
| a64f1184cd | |||
| acd7de5dd3 | |||
| cea60143b1 | |||
| fd205309b6 | |||
| 19a7e51813 | |||
| 683dac06ec | |||
| 45a231a456 | |||
| eb17bf4205 | |||
| 1dabd9c31e | |||
| 1cc8a3f373 | |||
| 8563a44211 | |||
| 844fd4a026 | |||
| ee6ad2dc20 | |||
| 6b02f19624 | |||
| a056419c24 | |||
| 505562424b | |||
|
7496f5e208
|
|||
|
fba10b753c
|
|||
|
f2cca7e6a1
|
|||
|
a2264b130d
|
|||
|
e9dad7ef0c
|
|||
| 3bac629045 | |||
| 135ab8a628 | |||
| 513efcdcff | |||
| 8447b2d796 | |||
| 593522e83f |
@@ -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,422 @@
|
||||
# Clone Tool Technical Specification and Implementation Plan
|
||||
|
||||
## Goal
|
||||
|
||||
Add a raster **Clone Stamp** tool optimized for object removal. The user selects a merged-visible source with `Ctrl+Alt+Click`, then paints sampled pixels onto the active raster layer using a stable source-to-target transform.
|
||||
|
||||
The implementation must preserve the existing engine API boundary, partial dirty-region rendering, pooled stroke masks, selection constraints, effects/cache behavior, and one-step undo per stroke.
|
||||
|
||||
## Confirmed Product Decisions
|
||||
|
||||
- Tool name: **Clone Stamp** (`Tool::CloneStamp`).
|
||||
- Source gesture: `Ctrl+Alt+Click`; use `Command+Option+Click` on macOS.
|
||||
- Source mode: merged visible image only for the first release.
|
||||
- Snapshot lifetime: capture and freeze a full merged-visible snapshot when the source is selected; retain it until clear/reselect/document close.
|
||||
- Source state is document-local and is not persisted to settings.
|
||||
- Add an **Aligned** toggle:
|
||||
- On: preserve the first source-to-target transform across strokes.
|
||||
- Off: restart from the selected source anchor on every target press.
|
||||
- Mirror X and Mirror Y are independent fixed affine sampling transforms, not random per-dab flips.
|
||||
- Color Variation is deterministic per-dab subtle HSL variation; preserve source alpha.
|
||||
- Clone painting targets editable raster pixels and respects the active selection mask.
|
||||
- Initial release rejects painting while editing a grayscale layer mask.
|
||||
- Out-of-canvas source samples are transparent; destination writes are clipped to canvas bounds.
|
||||
- Bilinear source sampling uses premultiplied alpha to avoid transparent-edge color fringes.
|
||||
- Existing project rules keep `hcie-egui-app/` frozen. The egui section below is implementation-ready but blocked until project governance formally removes that restriction.
|
||||
|
||||
## User Workflow
|
||||
|
||||
1. Select **Clone Stamp** from the Retouch tool group.
|
||||
2. Panel shows `No source selected` and the canvas uses an awaiting-source cursor.
|
||||
3. `Ctrl+Alt+Click` a valid canvas point.
|
||||
4. Engine captures a merged-visible immutable snapshot and stores the source anchor; no pixels or history are changed.
|
||||
5. Normal press on an editable raster layer establishes the target anchor and starts one clone transaction.
|
||||
6. Dragging paints source pixels according to relative displacement, mirror settings, brush coverage, opacity, pressure, selection, and color variation.
|
||||
7. Release commits one `Clone Stamp` history item.
|
||||
8. **Clear Source** removes the snapshot and blocks painting. **Reselect Source** arms the next normal canvas click as an accessible alternative to the modifier gesture.
|
||||
|
||||
## Sampling Transform
|
||||
|
||||
Let:
|
||||
|
||||
- `S` be the selected source anchor.
|
||||
- `T` be the target reference anchor.
|
||||
- `P` be a destination pixel.
|
||||
- `M = diag(mirror_x ? -1 : 1, mirror_y ? -1 : 1)`.
|
||||
|
||||
Sample coordinate:
|
||||
|
||||
```text
|
||||
Q = S + M * (P - T)
|
||||
```
|
||||
|
||||
This affine mapping is global for the stroke and avoids seams between overlapping dabs.
|
||||
|
||||
- Aligned on: retain the first `T` until source selection is cleared/replaced.
|
||||
- Aligned off: set `T` from each new stroke press.
|
||||
- Source reselection clears retained `T`.
|
||||
|
||||
## Brush Coverage and Blending
|
||||
|
||||
- Size range: `1..=500 px`, default `40 px`.
|
||||
- Opacity range: `0..=1`, default `1.0`.
|
||||
- Hardness range: `0..=1`, default `0.75`.
|
||||
- Internal spacing: default `0.15 * diameter`, clamped to at least one pixel; use interpolated dabs between pointer events.
|
||||
- Radial coverage:
|
||||
- Radius `r = size * pressure / 2`.
|
||||
- Fully opaque core radius `hardness * r`.
|
||||
- Smoothstep transition from core to edge.
|
||||
- Retain a one-pixel antialias band at hardness `1.0`.
|
||||
- Effective alpha:
|
||||
|
||||
```text
|
||||
source_alpha * coverage * opacity * pressure * selection_alpha
|
||||
```
|
||||
|
||||
- Composite sampled RGBA over the destination using the same straight/premultiplied conventions as existing brush operations.
|
||||
- Reuse `active_stroke_mask` and the pre-stroke destination baseline so overlapping dabs do not exceed the requested stroke opacity.
|
||||
|
||||
## Color Variation
|
||||
|
||||
- UI exposes an enable checkbox and amount `0..=100%`; default off/zero.
|
||||
- Generate one deterministic variation tuple per dab from a stroke seed and dab index.
|
||||
- At 100% amount, cap variation at approximately:
|
||||
- Hue: `±12 degrees`.
|
||||
- Saturation: `±0.08`.
|
||||
- Lightness: `±0.06`.
|
||||
- Scale each range linearly by amount.
|
||||
- Apply the same tuple to all source pixels in one dab to avoid pixel noise.
|
||||
- Clamp HSL channels and preserve source alpha.
|
||||
- Determinism is required for repeatable tests and stable redraw behavior.
|
||||
|
||||
## Engine and Protocol Architecture
|
||||
|
||||
### Protocol identity
|
||||
|
||||
Temporarily unlock `hcie-protocol`, then update `hcie-protocol/src/tools.rs`:
|
||||
|
||||
- Append `Tool::CloneStamp` without renumbering/reordering existing external IDs.
|
||||
- Add it to `Tool::ALL`, `label`, `icon`, `is_raster`, `is_raster_compatible`, `allowed_layer_type`, `shows_pen_tips`, `has_size`, and `has_opacity`.
|
||||
- Classify it as Raster-only.
|
||||
- Update exhaustive tool matches and serialization tests.
|
||||
- Add a new stable FFI numeric mapping at the end of `hcie-engine-api/src/ffi.rs`; never shift existing IDs.
|
||||
|
||||
Do not add GUI dependencies on `hcie-protocol`; continue importing `Tool` only through `hcie-engine-api`.
|
||||
|
||||
### New engine API module
|
||||
|
||||
Temporarily unlock `hcie-engine-api` and add `hcie-engine-api/src/clone_tool.rs`.
|
||||
|
||||
Public API-visible types:
|
||||
|
||||
```rust
|
||||
pub struct CloneConfig {
|
||||
pub size: f32,
|
||||
pub opacity: f32,
|
||||
pub hardness: f32,
|
||||
pub aligned: bool,
|
||||
pub mirror_x: bool,
|
||||
pub mirror_y: bool,
|
||||
pub color_variation: f32,
|
||||
}
|
||||
|
||||
pub struct CloneSourceInfo {
|
||||
pub x: f32,
|
||||
pub y: f32,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub has_aligned_target: bool,
|
||||
}
|
||||
|
||||
pub enum CloneError {
|
||||
SourceNotSelected,
|
||||
SourceOutOfBounds,
|
||||
DestinationNotEditable,
|
||||
LayerMaskEditingUnsupported,
|
||||
StrokeAlreadyActive,
|
||||
StrokeNotActive,
|
||||
DimensionMismatch,
|
||||
}
|
||||
```
|
||||
|
||||
Expose methods on `Engine`:
|
||||
|
||||
```rust
|
||||
set_clone_config(config) -> CloneConfig
|
||||
clone_config() -> CloneConfig
|
||||
set_clone_source(x, y) -> Result<CloneSourceInfo, CloneError>
|
||||
clear_clone_source()
|
||||
clone_source_info() -> Option<CloneSourceInfo>
|
||||
begin_clone_stroke(x, y, pressure) -> Result<(), CloneError>
|
||||
clone_stroke_to(x, y, pressure) -> Result<(), CloneError>
|
||||
end_clone_stroke() -> Result<(), CloneError>
|
||||
cancel_clone_stroke()
|
||||
```
|
||||
|
||||
Clamp all incoming configuration values inside the API.
|
||||
|
||||
### Engine-owned state
|
||||
|
||||
Add clone state to `Engine`, preferably grouped in a private `CloneState`:
|
||||
|
||||
- `config`.
|
||||
- `source_anchor`.
|
||||
- immutable merged RGBA snapshot and dimensions.
|
||||
- optional aligned target anchor.
|
||||
- active stroke target anchor.
|
||||
- previous destination point and spacing accumulator.
|
||||
- deterministic stroke seed and dab index.
|
||||
|
||||
Memory note: a 4K snapshot costs about 33 MB per document with an active clone source. Free it on clear, document close, canvas resize/crop, or incompatible document replacement.
|
||||
|
||||
### Source capture
|
||||
|
||||
`set_clone_source` must:
|
||||
|
||||
1. Validate finite in-bounds coordinates.
|
||||
2. Produce a current merged-visible image including visible layers, masks, clipping, opacity, blend modes, and effects.
|
||||
3. Capture without consuming or losing pending GUI dirty-region state. Add/use a non-consuming internal composite snapshot path; do not call a public full-composite getter if it clears dirty state without restoring it.
|
||||
4. Store immutable pixels and source anchor.
|
||||
5. Clear any retained aligned target anchor.
|
||||
6. Return source info without creating history or marking the document modified.
|
||||
|
||||
### Clone stroke lifecycle
|
||||
|
||||
`begin_clone_stroke` must:
|
||||
|
||||
1. Require a source snapshot.
|
||||
2. Reject layer-mask editing and non-editable/non-raster destinations.
|
||||
3. Resolve/reset target anchor according to `aligned`.
|
||||
4. Enter the existing pooled stroke lifecycle: effect suspension, selection-mask cache, destination before-buffer/history capture, active stroke mask, below-layer cache.
|
||||
5. Mark history kind/name as `Clone Stamp` rather than `Brush Stroke (...)`.
|
||||
6. Paint the initial dab.
|
||||
|
||||
`clone_stroke_to` must:
|
||||
|
||||
1. Interpolate dabs using accumulated spacing.
|
||||
2. Iterate only each destination dab rectangle.
|
||||
3. Compute affine source coordinates per destination pixel.
|
||||
4. Bilinearly sample premultiplied RGBA from the frozen source snapshot.
|
||||
5. Apply deterministic HSL variation per dab.
|
||||
6. Apply coverage, pressure, opacity, selection mask, and pooled stroke-opacity cap.
|
||||
7. Union exact destination dirty bounds and history bounds.
|
||||
8. Invalidate effects/composite/tile regions using the same optimized path as normal raster strokes.
|
||||
|
||||
`end_clone_stroke` must delegate to the established asynchronous sub-rectangle history flow and retain the source snapshot for subsequent strokes.
|
||||
|
||||
`cancel_clone_stroke` must restore the pre-stroke destination and leave no history entry.
|
||||
|
||||
### History extension
|
||||
|
||||
- Extend active-stroke metadata with an explicit history label or stroke kind.
|
||||
- Existing brush behavior defaults to its current label.
|
||||
- Clone produces exactly one `Clone Stamp` item per completed press-drag-release.
|
||||
- Source selection, clear, and reselect produce no history entries.
|
||||
|
||||
## Iced UI Architecture
|
||||
|
||||
### Feature modules
|
||||
|
||||
Following the new-feature/new-file preference, add:
|
||||
|
||||
- `hcie-iced-app/crates/hcie-iced-gui/src/clone_tool.rs`: GUI presentation/state helpers and engine-config conversion; no pixel mutation.
|
||||
- `hcie-iced-app/crates/hcie-iced-gui/src/panels/clone_tool_controls.rs`: shared Clone Tool panel controls.
|
||||
- Register the panel helper in `panels/mod.rs`.
|
||||
|
||||
### Persistent versus transient state
|
||||
|
||||
Persist in `settings.rs::ToolSettings`, each with `#[serde(default)]` or explicit default functions:
|
||||
|
||||
- clone size, opacity, hardness.
|
||||
- aligned.
|
||||
- mirror X/Y.
|
||||
- color variation enabled and amount.
|
||||
|
||||
Do not persist source coordinates, target anchor, source snapshot, or active-stroke state. Those remain inside each document's `Engine`.
|
||||
|
||||
### Messages
|
||||
|
||||
Add dedicated messages, not shared brush messages:
|
||||
|
||||
```text
|
||||
CloneSizeChanged
|
||||
CloneOpacityChanged
|
||||
CloneHardnessChanged
|
||||
CloneAlignedToggled
|
||||
CloneMirrorXToggled
|
||||
CloneMirrorYToggled
|
||||
CloneColorVariationToggled
|
||||
CloneColorVariationChanged
|
||||
CloneClearSource
|
||||
CloneArmSourceSelection
|
||||
CloneSourceSelected / CloneSourceSelectionFailed
|
||||
```
|
||||
|
||||
Each parameter message updates settings, saves, and sends a complete clamped `CloneConfig` to the active document engine. On tab switch, apply persisted config to that document while retaining its document-local source.
|
||||
|
||||
### Shared panel component
|
||||
|
||||
`clone_tool_controls::view(...) -> Element<Message>` renders:
|
||||
|
||||
1. **Object Removal** instruction: `Ctrl+Alt+Click a clean area, then paint over the object.`
|
||||
2. Source status banner:
|
||||
- `No source selected`.
|
||||
- `Select a source on the canvas`.
|
||||
- `Source: x, y (Merged Visible)`.
|
||||
3. **Choose/Reselect Source** and **Clear Source** buttons.
|
||||
4. Size slider.
|
||||
5. Opacity slider.
|
||||
6. Hardness slider with edge-softness tooltip.
|
||||
7. Aligned checkbox.
|
||||
8. Mirror X and Mirror Y checkboxes on one row.
|
||||
9. Color Variation checkbox and conditional amount slider.
|
||||
|
||||
Reuse this component from both active surfaces:
|
||||
|
||||
- `panels/tool_settings.rs` as the canonical configuration panel.
|
||||
- `panels/properties.rs` for contextual parity.
|
||||
|
||||
Keep `panels/tool_options.rs` out of scope because its `_view()` is unused. Update active `panels/toolbar.rs` only to expose Clone size/opacity if it already shows shared raster controls.
|
||||
|
||||
### Toolbox/menu/shortcut
|
||||
|
||||
- Add `CloneStamp` to the Retouch `TOOL_SLOTS` group in `sidebar/mod.rs`.
|
||||
- Add a dedicated clone-stamp SVG icon and exhaustive icon mapping.
|
||||
- Add Clone Stamp to the Tools menu near Spot Removal.
|
||||
- Assign `S` if the final shortcut audit confirms it is unclaimed for tool selection; otherwise use `Shift+S` and display the actual shortcut in the sidebar tooltip.
|
||||
- Add it to raster cursor/footprint and 60 Hz canvas-frame scheduling matches.
|
||||
|
||||
### Input routing
|
||||
|
||||
- Carry a modifier snapshot with `CanvasPointerPressed` (or equivalent canvas event) instead of relying solely on separately ordered global modifier messages.
|
||||
- On Clone Stamp, process `Ctrl+Alt`/`Command+Option` before generic Ctrl color picking.
|
||||
- Source-selection press:
|
||||
- call `set_clone_source`;
|
||||
- do not enter drawing state;
|
||||
- refresh source status and overlay.
|
||||
- Armed source selection consumes the next normal left press, then disarms.
|
||||
- Normal press calls `begin_clone_stroke`; move calls `clone_stroke_to`; release calls `end_clone_stroke` and existing pending-history polling.
|
||||
- If no source exists, consume normal paint presses and show a non-modal status message rather than silently doing nothing.
|
||||
|
||||
### Canvas overlay
|
||||
|
||||
Extend the existing overlay Canvas in `canvas/mod.rs`; do not touch protected shader texture upload, viewport, nearest filtering, or dirty accumulator code.
|
||||
|
||||
Render:
|
||||
|
||||
- destination brush footprint using clone size/hardness.
|
||||
- fixed selected-source marker.
|
||||
- while hovering/painting, current sampled-source marker derived from the affine transform.
|
||||
- optional thin line from sampled source to destination cursor while painting.
|
||||
- distinct awaiting-source cursor/status.
|
||||
|
||||
Clip overlay geometry to canvas bounds and reuse existing canvas-to-screen transforms.
|
||||
|
||||
## egui Component Architecture (Conditionally Blocked)
|
||||
|
||||
This phase may begin only after the project instruction declaring `hcie-egui-app/` frozen is formally changed. Until then, treat these files as read-only.
|
||||
|
||||
When unblocked:
|
||||
|
||||
- Add clone configuration fields to legacy `ToolConfigs` in `app/tool_state.rs`, mirroring `CloneConfig` defaults.
|
||||
- Add `CloneStamp` to legacy `TOOL_SLOTS`, toolbox icon mapping, menu, and shortcut routing.
|
||||
- Add an immediate-mode reusable component:
|
||||
|
||||
```rust
|
||||
fn show_clone_tool_controls(
|
||||
ui: &mut egui::Ui,
|
||||
config: &mut CloneConfig,
|
||||
source: Option<&CloneSourceInfo>,
|
||||
) -> ClonePanelAction
|
||||
```
|
||||
|
||||
- Use legacy `PlainSlider`, `ui.checkbox`, source-status labels, and `ui.horizontal` action buttons.
|
||||
- Return actions such as `None`, `ConfigChanged`, `ArmSource`, and `ClearSource`; application code invokes only `hcie-engine-api` methods.
|
||||
- In the egui canvas event path, test Ctrl+Alt before color-pick/pan branches, then route press/move/release to the same engine API.
|
||||
- Draw source/target markers in the existing canvas overlay paint pass.
|
||||
- Do not duplicate clone pixel logic in egui.
|
||||
|
||||
## Failure Modes and UX
|
||||
|
||||
- No source: block stroke and display source-selection instruction.
|
||||
- Source outside canvas/non-finite: return error; retain prior valid source.
|
||||
- Canvas resize/crop/document reload: clear source snapshot and aligned anchor.
|
||||
- Active target is locked, hidden as non-editable, or non-raster: block with status/toast.
|
||||
- Editing layer mask: block with explicit unsupported message.
|
||||
- Source sample outside snapshot: transparent sample; never wrap.
|
||||
- Undo/redo during active clone stroke: cancel stroke first, then perform history action.
|
||||
- Tool/document switch during active stroke: end or cancel according to existing pointer-capture policy; never leave effects suspended.
|
||||
- Source remains valid if original source layers later change because snapshot lifetime is explicitly frozen until reselect.
|
||||
|
||||
## Ordered Implementation Plan
|
||||
|
||||
1. Add protocol `CloneStamp` identity and update all exhaustive metadata/classification/FFI mappings; rebuild and re-lock protocol.
|
||||
2. Add engine API clone types, state, clamping, source capture, query, and clear operations in a new module.
|
||||
3. Implement premultiplied bilinear sampling, affine mirror mapping, radial hardness coverage, deterministic HSL variation, and destination blending as private tested helpers.
|
||||
4. Implement clone begin/move/end/cancel using existing pooled stroke, selection, dirty-region, effects, tile, below-cache, and asynchronous history machinery.
|
||||
5. Extend history labeling without altering ordinary brush labels.
|
||||
6. Add engine/API tests before GUI integration.
|
||||
7. Add Iced clone GUI helper/state module, dedicated messages, persisted serde-safe settings, and per-document source querying.
|
||||
8. Add shared Iced controls to Tool Settings and Properties; update toolbar where appropriate.
|
||||
9. Add Iced toolbox/menu/icon/shortcut and modifier-snapshot input routing.
|
||||
10. Add Iced clone source/target overlays and frame scheduling without changing shader upload behavior.
|
||||
11. Capture Iced screenshots of Tool Settings, Properties, awaiting-source state, selected-source marker, and active clone stroke.
|
||||
12. Run full validation and re-lock engine/API crates.
|
||||
13. Conditional only after governance change: implement the egui adapter/component/input/overlay phase against the same API and capture equivalent screenshots.
|
||||
|
||||
## Validation Plan
|
||||
|
||||
### Engine/API unit tests
|
||||
|
||||
- Reject painting before source selection.
|
||||
- Source selection creates no history and does not modify/dirty the document.
|
||||
- Merged snapshot includes visible layer composition/effects/masks.
|
||||
- Frozen snapshot does not self-feed when source and target overlap.
|
||||
- Identity, Mirror X, Mirror Y, and Mirror XY affine mappings.
|
||||
- Aligned offset persists; unaligned target resets per stroke.
|
||||
- Bilinear sampling correctness including transparent edges.
|
||||
- Hardness `0`, intermediate, and `1`; opacity `0` and `1`; pressure boundaries.
|
||||
- Selection mask constrains destination pixels.
|
||||
- Deterministic bounded HSL variation preserves alpha.
|
||||
- Out-of-source-bounds samples are transparent and never panic.
|
||||
- One history item per completed stroke; cancel creates none; undo/redo restores exact pixels.
|
||||
- Dirty/history rectangles include all modified destination pixels only.
|
||||
- Source clear/resize invalidation frees state.
|
||||
|
||||
### Integration and regression
|
||||
|
||||
- `cargo test -p hcie-engine-api --test visual_regression` with new clone golden cases.
|
||||
- `cargo test -p hcie-engine-api --test performance_stroke_4k -- --nocapture`.
|
||||
- Add a 4K clone-stroke benchmark that asserts no per-pointer full-canvas source copy/allocation.
|
||||
- Run protocol/engine API/active Iced workspace tests.
|
||||
- Test old settings JSON with all clone fields absent.
|
||||
- Test two documents with independent source anchors/snapshots.
|
||||
- Verify Ctrl alone still color-picks for existing paint tools and Ctrl+Alt is consumed by Clone Stamp first.
|
||||
- Verify protected partial uploads, viewport mapping, nearest filtering, and dirty-bounds union remain unchanged.
|
||||
|
||||
### Visual acceptance
|
||||
|
||||
- Clone panel controls fit narrow and wide docks without truncating essential status.
|
||||
- Hardness visibly changes edge softness, not sampled detail position.
|
||||
- Source and current-sample markers remain aligned during zoom/pan.
|
||||
- Mirrored strokes do not show per-dab seams.
|
||||
- Repeated object-removal strokes do not introduce source feedback or obvious periodic color noise.
|
||||
|
||||
## Rollout and Compatibility
|
||||
|
||||
- New settings fields must deserialize from old files via defaults.
|
||||
- Append tool/FFI identities; never renumber existing values.
|
||||
- No persisted source migration is needed.
|
||||
- Keep clone functionality behind the new tool path; ordinary brush behavior remains unchanged.
|
||||
- If the engine API is distributed as a static library, rebuild it before the GUI workspace.
|
||||
- Use `unlock.sh <crate>` and `lock.sh <crate>` for each locked crate and retain all protected performance mechanisms.
|
||||
|
||||
## Explicitly Out of Scope for Initial Release
|
||||
|
||||
- Healing/texture synthesis or Poisson blending.
|
||||
- Current-layer/current-and-below sampling modes.
|
||||
- Rotation/scale transforms beyond X/Y mirroring.
|
||||
- Random mirror switching.
|
||||
- Layer-mask cloning.
|
||||
- Script/AI clone commands unless required later for automation parity.
|
||||
- Editing the frozen egui workspace before its project restriction is formally removed.
|
||||
@@ -0,0 +1 @@
|
||||
,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
+166
-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",
|
||||
@@ -3553,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"
|
||||
@@ -4619,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"
|
||||
@@ -4953,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"
|
||||
@@ -5333,7 +5488,7 @@ dependencies = [
|
||||
"built",
|
||||
"cfg-if",
|
||||
"interpolate_name",
|
||||
"itertools",
|
||||
"itertools 0.14.0",
|
||||
"libc",
|
||||
"libfuzzer-sys",
|
||||
"log",
|
||||
@@ -6519,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,17 @@
|
||||
@echo off
|
||||
REM Canonical HCIE workspace build entry point (Windows)
|
||||
REM Usage: build.bat [cargo-flags...]
|
||||
|
||||
setlocal enabledelayedexpansion
|
||||
|
||||
set ROOT_DIR=%~dp0
|
||||
|
||||
if "%~1"=="" (
|
||||
set CARGO_ARGS=--workspace
|
||||
) else (
|
||||
set CARGO_ARGS=%*
|
||||
)
|
||||
|
||||
call "%ROOT_DIR%scripts\cargo-with-build-id.bat" build %CARGO_ARGS%
|
||||
|
||||
exit /b %ERRORLEVEL%
|
||||
@@ -0,0 +1,37 @@
|
||||
@echo off
|
||||
REM Build AI/Vision dynamic library plugins and copy to plugins directory (Windows)
|
||||
REM Usage: build_and_copy_plugins.bat [debug]
|
||||
|
||||
setlocal enabledelayedexpansion
|
||||
|
||||
set PROFILE=release
|
||||
set CARGO_FLAGS=--release
|
||||
|
||||
if /I "%~1"=="debug" (
|
||||
set PROFILE=debug
|
||||
set CARGO_FLAGS=
|
||||
echo === Compiling plugins in DEBUG mode ===
|
||||
) else (
|
||||
echo === Compiling plugins in RELEASE mode ===
|
||||
)
|
||||
|
||||
set PROJECT_DIR=C:\Projects\hcie-rust-v4
|
||||
set PLUGINS_DIR=%PROJECT_DIR%\plugins
|
||||
mkdir "%PLUGINS_DIR%" 2>nul
|
||||
|
||||
echo === Building AI/Vision dynamic libraries ===
|
||||
|
||||
echo 1. Building hcie-ai...
|
||||
cargo build --manifest-path "%PROJECT_DIR%\hcie-ai\Cargo.toml" %CARGO_FLAGS% --features ffi
|
||||
|
||||
echo 2. Building hcie-vision...
|
||||
cargo build --manifest-path "%PROJECT_DIR%\hcie-vision\Cargo.toml" %CARGO_FLAGS% --features ffi
|
||||
|
||||
REM On Windows, the output would be .dll files
|
||||
REM Adjust the copy commands as needed for your Windows environment
|
||||
copy "%PROJECT_DIR%\target\%PROFILE%\hcie_ai.dll" "%PLUGINS_DIR%\" 2>nul
|
||||
copy "%PROJECT_DIR%\target\%PROFILE%\hcie_vision.dll" "%PLUGINS_DIR%\" 2>nul
|
||||
|
||||
echo ====================================================
|
||||
echo AI/Vision plugins built and copied to %PLUGINS_DIR% in %PROFILE% mode!
|
||||
echo ====================================================
|
||||
@@ -0,0 +1,61 @@
|
||||
@echo off
|
||||
REM ============================================================
|
||||
REM countlines.bat — HCIE-Rust Line Counter (Windows)
|
||||
REM ============================================================
|
||||
setlocal enabledelayedexpansion
|
||||
|
||||
set PROJECT_DIR=%~dp0
|
||||
set OUTPUT_FILE=line_count_report.txt
|
||||
|
||||
echo ========================================= > "%OUTPUT_FILE%"
|
||||
echo LINE COUNT REPORT >> "%OUTPUT_FILE%"
|
||||
echo Project: HCIE-Rust v4 >> "%OUTPUT_FILE%"
|
||||
echo Location: %PROJECT_DIR% >> "%OUTPUT_FILE%"
|
||||
echo Timestamp: %DATE% %TIME% >> "%OUTPUT_FILE%"
|
||||
echo ========================================= >> "%OUTPUT_FILE%"
|
||||
echo. >> "%OUTPUT_FILE%"
|
||||
|
||||
REM === RUST FILES (.rs) ===
|
||||
echo -----------------------------------------
|
||||
echo RUST (.rs) — hcie-* directories
|
||||
echo -----------------------------------------
|
||||
|
||||
set GRAND_TOTAL_RUST=0
|
||||
|
||||
for /d %%d in ("%PROJECT_DIR%hcie-*") do (
|
||||
set CRATE_LINES=0
|
||||
for /r "%%d" %%f in (*.rs) do (
|
||||
set "FPATH=%%f"
|
||||
echo !FPATH! | findstr /i /c:"\tests\" >nul
|
||||
if errorlevel 1 (
|
||||
echo !FPATH! | findstr /i "test_" >nul
|
||||
if errorlevel 1 (
|
||||
for /f "usebackq" %%c in (`find /c /v "" "%%f"`) do (
|
||||
if %%c gtr 0 (
|
||||
set /a CRATE_LINES+=%%c
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
if !CRATE_LINES! gtr 0 (
|
||||
set "CRATE_NAME=%%~nxd"
|
||||
echo !CRATE_NAME! !CRATE_LINES! lines
|
||||
set /a GRAND_TOTAL_RUST+=!CRATE_LINES!
|
||||
)
|
||||
)
|
||||
|
||||
echo.
|
||||
echo RUST TOTAL: %GRAND_TOTAL_RUST% lines
|
||||
echo.
|
||||
|
||||
REM === GRAND TOTAL ===
|
||||
echo ========================================= >> "%OUTPUT_FILE%"
|
||||
echo GRAND TOTAL LINES OF CODE >> "%OUTPUT_FILE%"
|
||||
echo ========================================= >> "%OUTPUT_FILE%"
|
||||
echo Rust: %GRAND_TOTAL_RUST% >> "%OUTPUT_FILE%"
|
||||
echo. >> "%OUTPUT_FILE%"
|
||||
echo Report saved to: %CD%\%OUTPUT_FILE%
|
||||
|
||||
echo.
|
||||
echo Line count report saved to: %OUTPUT_FILE%
|
||||
@@ -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 }
|
||||
|
||||
+198
-42
@@ -111,7 +111,22 @@ pub fn generate_brush_stamp(tip: &BrushTip) -> Vec<u8> {
|
||||
match tip.style {
|
||||
BrushStyle::Round => generate_round_stamp(d, center, r, tip.hardness),
|
||||
BrushStyle::Square => generate_square_stamp(d, center, r, tip.hardness),
|
||||
BrushStyle::Star => generate_star_stamp(d, center, r, tip.hardness),
|
||||
BrushStyle::Star => generate_star_stamp(
|
||||
d,
|
||||
center,
|
||||
r,
|
||||
tip.hardness,
|
||||
if tip.roundness > 0.0 {
|
||||
tip.roundness
|
||||
} else {
|
||||
0.22
|
||||
},
|
||||
if tip.density > 0.0 {
|
||||
tip.density
|
||||
} else {
|
||||
0.08
|
||||
},
|
||||
),
|
||||
BrushStyle::SoftRound => generate_round_stamp(d, center, r, 0.0),
|
||||
BrushStyle::HardRound => generate_round_stamp(d, center, r, 1.0),
|
||||
BrushStyle::Bitmap => {
|
||||
@@ -200,27 +215,40 @@ fn generate_square_stamp(d: usize, center: f32, r: f32, hardness: f32) -> Vec<u8
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn generate_star_stamp(d: usize, center: f32, r: f32, hardness: f32) -> Vec<u8> {
|
||||
let points = 5u32;
|
||||
let inner_r = r * 0.4;
|
||||
fn generate_star_stamp(
|
||||
d: usize,
|
||||
center: f32,
|
||||
r: f32,
|
||||
hardness: f32,
|
||||
arm_width_ratio: f32,
|
||||
inner_r_ratio: f32,
|
||||
) -> Vec<u8> {
|
||||
let arm_count = 4u32;
|
||||
let arm_width = arm_width_ratio.clamp(0.08, 0.35);
|
||||
let inner_r = r * inner_r_ratio.clamp(0.04, 0.12);
|
||||
let hardness = hardness.clamp(0.0, 1.0);
|
||||
let point_sharpness = 1.2 + (0.35 - arm_width) / 0.27 * 2.8;
|
||||
(0..d * d)
|
||||
.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))
|
||||
};
|
||||
let arm_dist = a.min(point_angle - a);
|
||||
let angular_distance = (arm_dist / (point_angle * 0.5)).clamp(0.0, 1.0);
|
||||
let point_profile = (1.0 - angular_distance).powf(point_sharpness);
|
||||
let edge = inner_r + (r - inner_r) * point_profile;
|
||||
let soft_width = (r * (1.0 - hardness) * 0.35).max(0.75);
|
||||
let hard_edge = (edge - soft_width).max(0.0);
|
||||
|
||||
if dist >= edge {
|
||||
0
|
||||
} else if dist <= hard_edge {
|
||||
255
|
||||
} else {
|
||||
let alpha = 1.0 - (dist / edge).powf(1.0 - hardness.clamp(0.0, 0.99));
|
||||
(alpha * 255.0).round() as u8
|
||||
(((edge - dist) / soft_width).clamp(0.0, 1.0) * 255.0).round() as u8
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
@@ -644,6 +672,10 @@ fn draw_brush_style_dab(
|
||||
brush_hardness,
|
||||
is_eraser,
|
||||
mask,
|
||||
angle,
|
||||
rotation_random,
|
||||
roundness,
|
||||
density,
|
||||
),
|
||||
BrushStyle::Rock => draw_rock_brush(
|
||||
pixels,
|
||||
@@ -1374,6 +1406,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 +1419,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 +1450,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 {
|
||||
@@ -1604,29 +1647,136 @@ fn draw_star_brush(
|
||||
hardness: f32,
|
||||
is_eraser: bool,
|
||||
mask: Option<&[u8]>,
|
||||
angle: f32,
|
||||
rotation_random: f32,
|
||||
roundness: f32,
|
||||
density: f32,
|
||||
) {
|
||||
let effective_size = size * pressure;
|
||||
let seed = cx.to_bits().wrapping_mul(73_856_093)
|
||||
^ cy.to_bits().wrapping_mul(19_349_663);
|
||||
|
||||
let size_variance = roundness;
|
||||
let size_scale = if size_variance > 0.0 {
|
||||
let r = ((seed & 0xFFFF) as f32 / 65536.0 - 0.5) * 2.0 * size_variance;
|
||||
(1.0 + r).clamp(0.3, 2.0)
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
let effective_size = size * pressure * size_scale;
|
||||
|
||||
let angle_variance = rotation_random;
|
||||
let dab_angle = if angle_variance > 0.0 {
|
||||
let r = ((seed >> 16) as f32 / 65536.0 - 0.5) * 2.0 * angle_variance;
|
||||
angle + r * std::f32::consts::PI
|
||||
} else {
|
||||
angle
|
||||
};
|
||||
|
||||
let arm_width_ratio = if roundness > 0.0 { roundness } else { 0.22 };
|
||||
let inner_r_ratio = if density > 0.0 { density } else { 0.08 };
|
||||
|
||||
let tip = BrushTip {
|
||||
style: BrushStyle::Star,
|
||||
size: effective_size * 0.5,
|
||||
hardness,
|
||||
roundness: arm_width_ratio,
|
||||
density: inner_r_ratio,
|
||||
..BrushTip::default()
|
||||
};
|
||||
let stamp = generate_brush_stamp(&tip);
|
||||
draw_dab_with_stamp(
|
||||
pixels,
|
||||
width,
|
||||
height,
|
||||
cx,
|
||||
cy,
|
||||
effective_size,
|
||||
hardness,
|
||||
color,
|
||||
opacity * pressure,
|
||||
is_eraser,
|
||||
mask,
|
||||
Some(&stamp),
|
||||
);
|
||||
|
||||
let cos_a = dab_angle.cos();
|
||||
let sin_a = dab_angle.sin();
|
||||
let stamp_rotated = if dab_angle.abs() > f32::EPSILON {
|
||||
let stamp_d = (effective_size * 0.5 * 2.0).ceil() as usize;
|
||||
let stamp_center = stamp_d as f32 * 0.5;
|
||||
let d = stamp_d;
|
||||
let center = d as f32 * 0.5;
|
||||
let mut rotated = vec![0u8; d * d];
|
||||
for y in 0..d {
|
||||
for x in 0..d {
|
||||
let px = x as f32 - center;
|
||||
let py = y as f32 - center;
|
||||
let rx = px * cos_a + py * sin_a;
|
||||
let ry = -px * sin_a + py * cos_a;
|
||||
let sx = rx + stamp_center;
|
||||
let sy = ry + stamp_center;
|
||||
let ix = sx.floor() as i32;
|
||||
let iy = sy.floor() as i32;
|
||||
if ix >= 0 && iy >= 0 && ix < stamp_d as i32 - 1 && iy < stamp_d as i32 - 1 {
|
||||
let fx = sx - ix as f32;
|
||||
let fy = sy - iy as f32;
|
||||
let d00 = stamp[iy as usize * stamp_d + ix as usize] as f32;
|
||||
let d10 = stamp[iy as usize * stamp_d + ix as usize + 1] as f32;
|
||||
let d01 = stamp[(iy as usize + 1) * stamp_d + ix as usize] as f32;
|
||||
let d11 = stamp[(iy as usize + 1) * stamp_d + ix as usize + 1] as f32;
|
||||
let v = d00 * (1.0 - fx) * (1.0 - fy)
|
||||
+ d10 * fx * (1.0 - fy)
|
||||
+ d01 * (1.0 - fx) * fy
|
||||
+ d11 * fx * fy;
|
||||
rotated[y * d + x] = v.round() as u8;
|
||||
}
|
||||
}
|
||||
}
|
||||
rotated
|
||||
} else {
|
||||
stamp
|
||||
};
|
||||
|
||||
let sparkle_count = if density > 0.0 {
|
||||
(effective_size * 0.08 * density).clamp(2.0, 6.0) as u32
|
||||
} else {
|
||||
((effective_size * 0.05) as u32).clamp(2, 4)
|
||||
};
|
||||
let base_star_size = effective_size * 0.24;
|
||||
let footprint_radius = effective_size * 0.5;
|
||||
let phase = (seed & 0xFFFF) as f32 / 65536.0 * std::f32::consts::TAU;
|
||||
const GOLDEN_ANGLE: f32 = 2.399_963_1;
|
||||
|
||||
for i in 0..sparkle_count {
|
||||
let hash_i = seed.wrapping_add(i.wrapping_mul(668_265_263));
|
||||
let spark_size_var = if size_variance > 0.0 {
|
||||
let rv = ((hash_i.wrapping_add(31337) & 0xFFFF) as f32 / 65536.0 - 0.5)
|
||||
* 2.0
|
||||
* size_variance;
|
||||
(1.0 + rv).clamp(0.3, 2.0)
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
let star_size = base_star_size * spark_size_var;
|
||||
let max_center_radius = (footprint_radius - star_size * 0.5).max(0.0);
|
||||
let radial_fraction = ((i as f32 + 0.5) / sparkle_count as f32).sqrt();
|
||||
let spark_angle = phase + i as f32 * GOLDEN_ANGLE;
|
||||
let dist = radial_fraction * max_center_radius;
|
||||
let sx = cx + spark_angle.cos() * dist;
|
||||
let sy = cy + spark_angle.sin() * dist;
|
||||
|
||||
let spark_color = if color[3] > 0 {
|
||||
let hue_shift = ((hash_i.wrapping_add(7919) & 0xFF) as f32 / 255.0 - 0.5) * 30.0;
|
||||
let [r, g, b, a] = color;
|
||||
let (h, s, l) = rgb_to_hsl(r, g, b);
|
||||
let h = (h + hue_shift).rem_euclid(360.0);
|
||||
let (rr, gg, bb) = hsl_to_rgb(h, s, l);
|
||||
[rr, gg, bb, a]
|
||||
} else {
|
||||
color
|
||||
};
|
||||
|
||||
draw_dab_with_stamp(
|
||||
pixels,
|
||||
width,
|
||||
height,
|
||||
sx,
|
||||
sy,
|
||||
star_size,
|
||||
hardness,
|
||||
spark_color,
|
||||
opacity * pressure * 0.9,
|
||||
is_eraser,
|
||||
mask,
|
||||
Some(&stamp_rotated),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply oil paint brush - coherent, directional bristle tracks with pigment pickup.
|
||||
@@ -2964,6 +3114,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 +3144,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,
|
||||
|
||||
@@ -0,0 +1,494 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Integration tests for hcie-document crate.
|
||||
///
|
||||
/// Covers: document creation, layer CRUD, active layer switching,
|
||||
/// visibility/opacity/blend-mode management, dirty tracking, zoom/pan,
|
||||
/// undo/redo, selection, crop/resize.
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper: create a small test document
|
||||
// ---------------------------------------------------------------------------
|
||||
fn make_doc() -> hcie_document::Document {
|
||||
hcie_document::Document::new_blank("test", 64, 64, false)
|
||||
}
|
||||
|
||||
fn make_transparent_doc() -> hcie_document::Document {
|
||||
hcie_document::Document::new_blank("transparent", 64, 64, true)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Document creation
|
||||
// ---------------------------------------------------------------------------
|
||||
#[test]
|
||||
fn new_blank_document_has_correct_dimensions() {
|
||||
let doc = make_doc();
|
||||
assert_eq!(doc.canvas_width, 64);
|
||||
assert_eq!(doc.canvas_height, 64);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_blank_document_has_one_layer() {
|
||||
let doc = make_doc();
|
||||
assert_eq!(doc.layers.len(), 1, "new blank document must have exactly one layer");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_blank_document_name_is_set() {
|
||||
let doc = make_doc();
|
||||
assert_eq!(doc.name, "test");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_blank_opaque_layer_is_white() {
|
||||
let doc = make_doc();
|
||||
let layer = doc.active_layer().unwrap();
|
||||
let center = layer.get_pixel(32, 32);
|
||||
assert_eq!(center, [255, 255, 255, 255], "opaque background should be white");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_transparent_layer_is_empty() {
|
||||
let doc = make_transparent_doc();
|
||||
let layer = doc.active_layer().unwrap();
|
||||
let center = layer.get_pixel(32, 32);
|
||||
assert_eq!(center, [0, 0, 0, 0], "transparent background should be all zeros");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn initial_zoom_is_one() {
|
||||
let doc = make_doc();
|
||||
assert!((doc.zoom - 1.0).abs() < f32::EPSILON);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn initial_pan_is_zero() {
|
||||
let doc = make_doc();
|
||||
assert!((doc.pan_x).abs() < f32::EPSILON);
|
||||
assert!((doc.pan_y).abs() < f32::EPSILON);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tab label & modified flag
|
||||
// ---------------------------------------------------------------------------
|
||||
#[test]
|
||||
fn tab_label_shows_modified_indicator() {
|
||||
let mut doc = make_doc();
|
||||
assert!(!doc.tab_label().contains('●'), "clean doc should not show modified indicator");
|
||||
doc.modified = true;
|
||||
assert!(doc.tab_label().contains('●'), "modified doc should show the dot");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Layer CRUD
|
||||
// ---------------------------------------------------------------------------
|
||||
#[test]
|
||||
fn add_layer_increases_count() {
|
||||
let mut doc = make_doc();
|
||||
let id = doc.add_layer("Layer 2");
|
||||
assert_eq!(doc.layers.len(), 2);
|
||||
assert!(id > 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_layer_makes_it_active() {
|
||||
let mut doc = make_doc();
|
||||
let id = doc.add_layer("Layer 2");
|
||||
// add_layer pushes to the end → index 1 when background is at 0
|
||||
assert_eq!(doc.active_layer, 1, "new layer should be active (last index)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_layer_reduces_count() {
|
||||
let mut doc = make_doc();
|
||||
doc.add_layer("Layer 2");
|
||||
doc.delete_layer(1);
|
||||
assert_eq!(doc.layers.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_last_layer_removes_it() {
|
||||
let mut doc = make_doc();
|
||||
assert_eq!(doc.layers.len(), 1);
|
||||
doc.delete_layer(0);
|
||||
// The layer is removed; active_layer is clamped to the new (empty) len
|
||||
assert_eq!(doc.layers.len(), 0, "deleting the only layer should remove it");
|
||||
assert_eq!(doc.active_layer, 0, "active_layer should clamp to 0");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_layer_out_of_bounds_is_noop() {
|
||||
let mut doc = make_doc();
|
||||
doc.delete_layer(99);
|
||||
assert_eq!(doc.layers.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn move_layer_changes_order() {
|
||||
let mut doc = make_doc();
|
||||
doc.add_layer("Layer A"); // index 1
|
||||
doc.add_layer("Layer B"); // index 2
|
||||
// Move "Layer B" down to index 1 (before "Layer A")
|
||||
doc.move_layer(2, 1);
|
||||
assert_eq!(doc.layers[0].name, "Background");
|
||||
assert_eq!(doc.layers[1].name, "Layer B");
|
||||
assert_eq!(doc.layers[2].name, "Layer A");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn move_layer_identity_is_noop() {
|
||||
let mut doc = make_doc();
|
||||
doc.add_layer("L2");
|
||||
doc.move_layer(1, 1);
|
||||
assert_eq!(doc.layers.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_layer_ids_returns_all() {
|
||||
let mut doc = make_doc();
|
||||
doc.add_layer("L2");
|
||||
doc.add_layer("L3");
|
||||
let ids = doc.all_layer_ids();
|
||||
assert_eq!(ids.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layer_info_returns_correct_data() {
|
||||
let mut doc = make_doc();
|
||||
let id = doc.add_layer("MyLayer");
|
||||
let info = doc.layer_info(id).unwrap();
|
||||
assert_eq!(info.name, "MyLayer");
|
||||
assert!(info.visible);
|
||||
assert!((info.opacity - 1.0).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layer_info_returns_none_for_invalid_id() {
|
||||
let doc = make_doc();
|
||||
assert!(doc.layer_info(99999).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_layer_by_id_finds_layer() {
|
||||
let mut doc = make_doc();
|
||||
let id = doc.add_layer("Target");
|
||||
let layer = doc.get_layer_by_id(id).unwrap();
|
||||
assert_eq!(layer.name, "Target");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_layer_by_id_returns_none_for_missing() {
|
||||
let doc = make_doc();
|
||||
assert!(doc.get_layer_by_id(99999).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layer_index_by_id_returns_correct_index() {
|
||||
let mut doc = make_doc();
|
||||
let id = doc.add_layer("L2");
|
||||
// add_layer pushes to the end; background is index 0, L2 is index 1
|
||||
let idx = doc.layer_index_by_id(id).unwrap();
|
||||
assert_eq!(idx, 1);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Active layer
|
||||
// ---------------------------------------------------------------------------
|
||||
#[test]
|
||||
fn set_active_layer_switches_to_valid_index() {
|
||||
let mut doc = make_doc();
|
||||
doc.add_layer("L2");
|
||||
doc.set_active_layer(1);
|
||||
assert_eq!(doc.active_layer, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn active_layer_returns_correct_layer() {
|
||||
let mut doc = make_doc();
|
||||
let layer = doc.active_layer().unwrap();
|
||||
assert_eq!(layer.name, "Background");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn active_layer_mut_allows_modification() {
|
||||
let mut doc = make_doc();
|
||||
{
|
||||
let layer = doc.active_layer_mut().unwrap();
|
||||
layer.name = "Renamed".to_string();
|
||||
}
|
||||
assert_eq!(doc.layers[doc.active_layer].name, "Renamed");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Visibility
|
||||
// ---------------------------------------------------------------------------
|
||||
#[test]
|
||||
fn set_layer_visible_hides_layer() {
|
||||
let mut doc = make_doc();
|
||||
doc.set_layer_visible(0, false);
|
||||
assert!(!doc.layers[0].visible);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_layer_visible_shows_layer() {
|
||||
let mut doc = make_doc();
|
||||
doc.set_layer_visible(0, false);
|
||||
doc.set_layer_visible(0, true);
|
||||
assert!(doc.layers[0].visible);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layer_visible_default_is_true() {
|
||||
let doc = make_doc();
|
||||
assert!(doc.layers[0].visible);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Blend mode & opacity (via direct field access through mutable accessor)
|
||||
// ---------------------------------------------------------------------------
|
||||
#[test]
|
||||
fn set_blend_mode_via_mut_accessor() {
|
||||
let mut doc = make_doc();
|
||||
let layer = doc.active_layer_mut().unwrap();
|
||||
layer.blend_mode = hcie_protocol::BlendMode::Multiply;
|
||||
assert_eq!(doc.layers[doc.active_layer].blend_mode, hcie_protocol::BlendMode::Multiply);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_opacity_via_mut_accessor() {
|
||||
let mut doc = make_doc();
|
||||
let layer = doc.active_layer_mut().unwrap();
|
||||
layer.opacity = 0.5;
|
||||
assert!((doc.layers[doc.active_layer].opacity - 0.5).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opacity_clamps_to_zero() {
|
||||
let mut doc = make_doc();
|
||||
let layer = doc.active_layer_mut().unwrap();
|
||||
layer.opacity = -0.1;
|
||||
// Protocol allows negative; consumer should clamp
|
||||
assert!(layer.opacity < 0.0);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Dirty tracking
|
||||
// ---------------------------------------------------------------------------
|
||||
#[test]
|
||||
fn expand_dirty_produces_valid_bounds() {
|
||||
let mut doc = make_doc();
|
||||
doc.expand_dirty(10, 10, 5.0);
|
||||
let bounds = doc.dirty_bounds;
|
||||
assert!(bounds.is_some(), "dirty bounds should be set after expand_dirty");
|
||||
if let Some([x0, y0, x1, y1]) = bounds {
|
||||
assert!(x0 <= x1);
|
||||
assert!(y0 <= y1);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clear_dirty_resets_bounds() {
|
||||
let mut doc = make_doc();
|
||||
doc.expand_dirty(10, 10, 5.0);
|
||||
doc.clear_dirty();
|
||||
assert!(doc.dirty_bounds.is_none(), "dirty bounds should be None after clear");
|
||||
assert!(!doc.composite_dirty, "composite_dirty should be false after clear");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expand_dirty_merges_with_existing() {
|
||||
let mut doc = make_doc();
|
||||
doc.expand_dirty(0, 0, 1.0);
|
||||
doc.expand_dirty(50, 50, 1.0);
|
||||
let bounds = doc.dirty_bounds.unwrap();
|
||||
// Should cover both regions
|
||||
assert!(bounds[0] <= 1, "x0 should be near 0");
|
||||
assert!(bounds[2] >= 49, "x1 should cover second region");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Zoom and pan
|
||||
// ---------------------------------------------------------------------------
|
||||
#[test]
|
||||
fn zoom_can_be_set() {
|
||||
let mut doc = make_doc();
|
||||
doc.zoom = 2.0;
|
||||
assert!((doc.zoom - 2.0).abs() < f32::EPSILON);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zoom_can_be_negative() {
|
||||
let mut doc = make_doc();
|
||||
doc.zoom = -1.0; // Negative zoom should be allowed (clamped at display layer)
|
||||
assert!(doc.zoom < 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pan_can_be_set() {
|
||||
let mut doc = make_doc();
|
||||
doc.pan_x = 100.0;
|
||||
doc.pan_y = -50.0;
|
||||
assert!((doc.pan_x - 100.0).abs() < f32::EPSILON);
|
||||
assert!((doc.pan_y - (-50.0)).abs() < f32::EPSILON);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Selection
|
||||
// ---------------------------------------------------------------------------
|
||||
#[test]
|
||||
fn set_selection_rect_activates_selection() {
|
||||
let mut doc = make_doc();
|
||||
doc.set_selection_rect((10, 10), (30, 30));
|
||||
assert!(doc.selection_active);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clear_selection_deactivates() {
|
||||
let mut doc = make_doc();
|
||||
doc.set_selection_rect((10, 10), (30, 30));
|
||||
doc.clear_selection();
|
||||
assert!(!doc.selection_active);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn select_all_covers_full_canvas() {
|
||||
let mut doc = make_doc();
|
||||
doc.select_all();
|
||||
assert!(doc.selection_active);
|
||||
if let Some(ref mask) = doc.selection_mask {
|
||||
assert_eq!(mask.len(), (64 * 64) as usize);
|
||||
assert!(mask.iter().all(|&v| v == 255), "select_all should set all mask bytes to 255");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_mask_at_returns_255_when_no_selection() {
|
||||
let doc = make_doc();
|
||||
assert_eq!(doc.get_mask_at(0, 0), 255, "without selection mask, get_mask_at should return 255");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_mask_at_returns_255_inside_selection() {
|
||||
let mut doc = make_doc();
|
||||
doc.select_all();
|
||||
assert_eq!(doc.get_mask_at(32, 32), 255, "inside selection should return 255");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Crop
|
||||
// ---------------------------------------------------------------------------
|
||||
#[test]
|
||||
fn crop_reduces_canvas_size() {
|
||||
let mut doc = make_doc();
|
||||
doc.crop(0, 0, 32, 32);
|
||||
assert_eq!(doc.canvas_width, 32);
|
||||
assert_eq!(doc.canvas_height, 32);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Resize
|
||||
// ---------------------------------------------------------------------------
|
||||
#[test]
|
||||
fn resize_canvas_increases_size() {
|
||||
let mut doc = make_doc();
|
||||
doc.resize_canvas(128, 128);
|
||||
assert_eq!(doc.canvas_width, 128);
|
||||
assert_eq!(doc.canvas_height, 128);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resize_canvas_does_not_shrink_below_one() {
|
||||
let mut doc = make_doc();
|
||||
doc.resize_canvas(0, 0);
|
||||
// Minimum size should be at least 1
|
||||
assert!(doc.canvas_width >= 1);
|
||||
assert!(doc.canvas_height >= 1);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// History / Undo-Redo
|
||||
// ---------------------------------------------------------------------------
|
||||
#[test]
|
||||
fn new_document_has_no_undo() {
|
||||
let doc = make_doc();
|
||||
assert!(!doc.can_undo());
|
||||
assert!(!doc.can_redo());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_layer_creates_undoable_action() {
|
||||
let mut doc = make_doc();
|
||||
doc.add_layer("Undoable");
|
||||
assert!(doc.can_undo(), "add_layer should record a history entry");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undo_redo_layer_addition() {
|
||||
let mut doc = make_doc();
|
||||
let count_before = doc.layers.len();
|
||||
doc.add_layer("Temp");
|
||||
assert_eq!(doc.layers.len(), count_before + 1);
|
||||
doc.undo();
|
||||
// After undo, layers should be restored to previous state
|
||||
assert!(doc.can_redo(), "should be able to redo after undo");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn history_len_increases_with_actions() {
|
||||
let mut doc = make_doc();
|
||||
assert_eq!(doc.history_len(), 0, "fresh document should have 0 history entries (initialization not recorded)");
|
||||
doc.add_layer("Action 1");
|
||||
doc.add_layer("Action 2");
|
||||
assert!(doc.history_len() >= 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn history_description_is_readable() {
|
||||
let mut doc = make_doc();
|
||||
doc.add_layer("My Layer");
|
||||
if doc.history_len() > 0 {
|
||||
let desc = doc.history_description(doc.history_len() - 1);
|
||||
assert!(desc.is_some());
|
||||
assert!(!desc.unwrap().is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn history_current_index_is_valid() {
|
||||
let mut doc = make_doc();
|
||||
assert!(doc.history_current() >= -1);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Layer pixel manipulation
|
||||
// ---------------------------------------------------------------------------
|
||||
#[test]
|
||||
fn get_pixel_returns_correct_value() {
|
||||
let doc = make_doc();
|
||||
let pixel = doc.layers[0].get_pixel(0, 0);
|
||||
assert_eq!(pixel.len(), 4);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Layer data & type
|
||||
// ---------------------------------------------------------------------------
|
||||
#[test]
|
||||
fn layer_default_type_is_raster() {
|
||||
let mut doc = make_doc();
|
||||
let id = doc.add_layer("Raster");
|
||||
let info = doc.layer_info(id).unwrap();
|
||||
assert!(matches!(info.layer_type, hcie_protocol::LayerType::Raster));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_path_defaults_to_none() {
|
||||
let doc = make_doc();
|
||||
assert!(doc.file_path.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_path_can_be_set() {
|
||||
let mut doc = make_doc();
|
||||
doc.file_path = Some(PathBuf::from("/tmp/test.hcie"));
|
||||
assert_eq!(doc.file_path.as_ref().unwrap(), &PathBuf::from("/tmp/test.hcie"));
|
||||
}
|
||||
@@ -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);
|
||||
@@ -0,0 +1,854 @@
|
||||
//! Engine-owned Clone Stamp implementation.
|
||||
//!
|
||||
//! ## Purpose
|
||||
//! Captures a frozen merged-visible source and paints sampled pixels into the
|
||||
//! active raster layer without exposing engine buffers to GUI code.
|
||||
//!
|
||||
//! ## Logic & Workflow
|
||||
//! Source selection creates one immutable full-canvas snapshot. A stroke then
|
||||
//! reuses the normal pooled stroke setup, emits interpolated radial dabs through
|
||||
//! a stable source-to-target affine transform, and delegates completion to the
|
||||
//! asynchronous sub-rectangle history path.
|
||||
//!
|
||||
//! ## Side Effects / Dependencies
|
||||
//! Source selection allocates one RGBA canvas buffer but does not modify the
|
||||
//! document or history. Painting mutates the active raster layer, dirty bounds,
|
||||
//! pooled stroke buffers, effect caches, and history state.
|
||||
|
||||
use crate::{Engine, LayerData, LayerType};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// User-configurable Clone Stamp brush behavior.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct CloneConfig {
|
||||
pub size: f32,
|
||||
pub opacity: f32,
|
||||
pub hardness: f32,
|
||||
pub aligned: bool,
|
||||
pub mirror_x: bool,
|
||||
pub mirror_y: bool,
|
||||
pub color_variation: f32,
|
||||
}
|
||||
|
||||
impl Default for CloneConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
size: 40.0,
|
||||
opacity: 1.0,
|
||||
hardness: 0.75,
|
||||
aligned: true,
|
||||
mirror_x: false,
|
||||
mirror_y: false,
|
||||
color_variation: 0.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl CloneConfig {
|
||||
/// Normalize all numeric fields to finite API-supported ranges.
|
||||
fn clamped(self) -> Self {
|
||||
let defaults = Self::default();
|
||||
Self {
|
||||
size: finite_or(self.size, defaults.size).clamp(1.0, 500.0),
|
||||
opacity: finite_or(self.opacity, defaults.opacity).clamp(0.0, 1.0),
|
||||
hardness: finite_or(self.hardness, defaults.hardness).clamp(0.0, 1.0),
|
||||
aligned: self.aligned,
|
||||
mirror_x: self.mirror_x,
|
||||
mirror_y: self.mirror_y,
|
||||
color_variation: finite_or(self.color_variation, 0.0).clamp(0.0, 1.0),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Read-only information about the document-local frozen source.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct CloneSourceInfo {
|
||||
pub x: f32,
|
||||
pub y: f32,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub has_aligned_target: bool,
|
||||
}
|
||||
|
||||
/// Recoverable Clone Stamp API failures.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum CloneError {
|
||||
SourceNotSelected,
|
||||
SourceOutOfBounds,
|
||||
DestinationNotEditable,
|
||||
LayerMaskEditingUnsupported,
|
||||
StrokeAlreadyActive,
|
||||
StrokeNotActive,
|
||||
DimensionMismatch,
|
||||
}
|
||||
|
||||
/// Frozen source and transient stroke state owned by one `Engine` document.
|
||||
pub(crate) struct CloneState {
|
||||
config: CloneConfig,
|
||||
source_anchor: Option<(f32, f32)>,
|
||||
source_pixels: Option<Arc<[u8]>>,
|
||||
source_width: u32,
|
||||
source_height: u32,
|
||||
aligned_target_anchor: Option<(f32, f32)>,
|
||||
active_target_anchor: Option<(f32, f32)>,
|
||||
active_layer_id: Option<u64>,
|
||||
previous_pointer: Option<(f32, f32, f32)>,
|
||||
spacing_remainder: f32,
|
||||
stroke_seed: u64,
|
||||
dab_index: u64,
|
||||
modified_before_stroke: bool,
|
||||
}
|
||||
|
||||
impl Default for CloneState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
config: CloneConfig::default(),
|
||||
source_anchor: None,
|
||||
source_pixels: None,
|
||||
source_width: 0,
|
||||
source_height: 0,
|
||||
aligned_target_anchor: None,
|
||||
active_target_anchor: None,
|
||||
active_layer_id: None,
|
||||
previous_pointer: None,
|
||||
spacing_remainder: 0.0,
|
||||
stroke_seed: 0,
|
||||
dab_index: 0,
|
||||
modified_before_stroke: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl CloneState {
|
||||
/// Clear source and transient values while retaining user configuration.
|
||||
fn clear(&mut self) {
|
||||
self.source_anchor = None;
|
||||
self.source_pixels = None;
|
||||
self.source_width = 0;
|
||||
self.source_height = 0;
|
||||
self.aligned_target_anchor = None;
|
||||
self.clear_active();
|
||||
}
|
||||
|
||||
/// Clear only fields that exist for the duration of one stroke.
|
||||
fn clear_active(&mut self) {
|
||||
self.active_target_anchor = None;
|
||||
self.active_layer_id = None;
|
||||
self.previous_pointer = None;
|
||||
self.spacing_remainder = 0.0;
|
||||
self.dab_index = 0;
|
||||
}
|
||||
}
|
||||
|
||||
impl Engine {
|
||||
/// Set and return the fully clamped Clone Stamp configuration.
|
||||
pub fn set_clone_config(&mut self, config: CloneConfig) -> CloneConfig {
|
||||
let config = config.clamped();
|
||||
self.clone_state.config = config;
|
||||
config
|
||||
}
|
||||
|
||||
/// Return the active document's Clone Stamp configuration.
|
||||
pub fn clone_config(&self) -> CloneConfig {
|
||||
self.clone_state.config
|
||||
}
|
||||
|
||||
/// Capture a frozen merged-visible source at an in-bounds canvas point.
|
||||
pub fn set_clone_source(&mut self, x: f32, y: f32) -> Result<CloneSourceInfo, CloneError> {
|
||||
if self.stroke_before.is_some() || self.clone_state.active_layer_id.is_some() {
|
||||
return Err(CloneError::StrokeAlreadyActive);
|
||||
}
|
||||
let width = self.document.canvas_width;
|
||||
let height = self.document.canvas_height;
|
||||
if !x.is_finite()
|
||||
|| !y.is_finite()
|
||||
|| x < 0.0
|
||||
|| y < 0.0
|
||||
|| x >= width as f32
|
||||
|| y >= height as f32
|
||||
{
|
||||
return Err(CloneError::SourceOutOfBounds);
|
||||
}
|
||||
let expected = rgba_len(width, height).ok_or(CloneError::DimensionMismatch)?;
|
||||
let pixels = self.snapshot_merged_visible();
|
||||
if pixels.len() != expected {
|
||||
return Err(CloneError::DimensionMismatch);
|
||||
}
|
||||
self.clone_state.source_anchor = Some((x, y));
|
||||
self.clone_state.source_pixels = Some(Arc::from(pixels));
|
||||
self.clone_state.source_width = width;
|
||||
self.clone_state.source_height = height;
|
||||
self.clone_state.aligned_target_anchor = None;
|
||||
Ok(self.clone_source_info().expect("source was just installed"))
|
||||
}
|
||||
|
||||
/// Remove the frozen source, cancelling an active clone stroke if necessary.
|
||||
pub fn clear_clone_source(&mut self) {
|
||||
self.cancel_clone_stroke();
|
||||
self.clone_state.clear();
|
||||
}
|
||||
|
||||
/// Return source metadata without exposing the frozen pixel allocation.
|
||||
pub fn clone_source_info(&self) -> Option<CloneSourceInfo> {
|
||||
let (x, y) = self.clone_state.source_anchor?;
|
||||
self.clone_state.source_pixels.as_ref()?;
|
||||
Some(CloneSourceInfo {
|
||||
x,
|
||||
y,
|
||||
width: self.clone_state.source_width,
|
||||
height: self.clone_state.source_height,
|
||||
has_aligned_target: self.clone_state.aligned_target_anchor.is_some(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Map a destination canvas point to the source marker for overlay display.
|
||||
pub fn clone_sample_position(&self, x: f32, y: f32) -> Option<(f32, f32)> {
|
||||
let source = self.clone_state.source_anchor?;
|
||||
let target = self
|
||||
.clone_state
|
||||
.active_target_anchor
|
||||
.or(self.clone_state.aligned_target_anchor)?;
|
||||
Some(map_sample(source, target, (x, y), self.clone_state.config))
|
||||
}
|
||||
|
||||
/// Validate the target, enter pooled stroke state, and paint the first dab.
|
||||
pub fn begin_clone_stroke(&mut self, x: f32, y: f32, pressure: f32) -> Result<(), CloneError> {
|
||||
if self.clone_state.source_pixels.is_none() || self.clone_state.source_anchor.is_none() {
|
||||
return Err(CloneError::SourceNotSelected);
|
||||
}
|
||||
if self.stroke_before.is_some() || self.clone_state.active_layer_id.is_some() {
|
||||
return Err(CloneError::StrokeAlreadyActive);
|
||||
}
|
||||
if self.editing_mask {
|
||||
return Err(CloneError::LayerMaskEditingUnsupported);
|
||||
}
|
||||
if !x.is_finite() || !y.is_finite() {
|
||||
return Err(CloneError::DestinationNotEditable);
|
||||
}
|
||||
let layer_id = self
|
||||
.document
|
||||
.active_layer()
|
||||
.map(|layer| layer.id)
|
||||
.ok_or(CloneError::DestinationNotEditable)?;
|
||||
let expected = rgba_len(self.document.canvas_width, self.document.canvas_height)
|
||||
.ok_or(CloneError::DimensionMismatch)?;
|
||||
let layer = self
|
||||
.document
|
||||
.get_layer_by_id(layer_id)
|
||||
.ok_or(CloneError::DestinationNotEditable)?;
|
||||
if layer.locked
|
||||
|| !layer.visible
|
||||
|| layer.layer_type != LayerType::Raster
|
||||
|| !matches!(layer.data, LayerData::Raster)
|
||||
{
|
||||
return Err(CloneError::DestinationNotEditable);
|
||||
}
|
||||
if layer.width != self.document.canvas_width
|
||||
|| layer.height != self.document.canvas_height
|
||||
|| layer.pixels.len() != expected
|
||||
{
|
||||
return Err(CloneError::DimensionMismatch);
|
||||
}
|
||||
|
||||
let pressure = normalized_pressure(pressure);
|
||||
let target = if self.clone_state.config.aligned {
|
||||
*self.clone_state.aligned_target_anchor.get_or_insert((x, y))
|
||||
} else {
|
||||
(x, y)
|
||||
};
|
||||
self.clone_state.modified_before_stroke = self.document.modified;
|
||||
self.begin_stroke(layer_id, x, y);
|
||||
self.last_stroke_bounds = None;
|
||||
self.stroke_history_description = Some("Clone Stamp".to_string());
|
||||
self.clone_state.active_target_anchor = Some(target);
|
||||
self.clone_state.active_layer_id = Some(layer_id);
|
||||
self.clone_state.previous_pointer = Some((x, y, pressure));
|
||||
self.clone_state.spacing_remainder = 0.0;
|
||||
self.clone_state.stroke_seed = self.clone_state.stroke_seed.wrapping_add(1);
|
||||
self.clone_state.dab_index = 0;
|
||||
self.paint_clone_dab(x, y, pressure)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Interpolate evenly spaced clone dabs from the previous pointer sample.
|
||||
pub fn clone_stroke_to(&mut self, x: f32, y: f32, pressure: f32) -> Result<(), CloneError> {
|
||||
let (start_x, start_y, start_pressure) = self
|
||||
.clone_state
|
||||
.previous_pointer
|
||||
.ok_or(CloneError::StrokeNotActive)?;
|
||||
if !x.is_finite() || !y.is_finite() {
|
||||
return Ok(());
|
||||
}
|
||||
let pressure = normalized_pressure(pressure);
|
||||
let dx = x - start_x;
|
||||
let dy = y - start_y;
|
||||
let distance = dx.hypot(dy);
|
||||
let spacing = (self.clone_state.config.size * 0.15).max(1.0);
|
||||
if distance > f32::EPSILON {
|
||||
let mut next = spacing - self.clone_state.spacing_remainder;
|
||||
while next <= distance {
|
||||
let t = next / distance;
|
||||
self.paint_clone_dab(
|
||||
start_x + dx * t,
|
||||
start_y + dy * t,
|
||||
start_pressure + (pressure - start_pressure) * t,
|
||||
)?;
|
||||
next += spacing;
|
||||
}
|
||||
self.clone_state.spacing_remainder =
|
||||
(self.clone_state.spacing_remainder + distance) % spacing;
|
||||
}
|
||||
self.clone_state.previous_pointer = Some((x, y, pressure));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Complete one Clone Stamp transaction through the standard history path.
|
||||
pub fn end_clone_stroke(&mut self) -> Result<(), CloneError> {
|
||||
let layer_id = self
|
||||
.clone_state
|
||||
.active_layer_id
|
||||
.ok_or(CloneError::StrokeNotActive)?;
|
||||
if self.last_stroke_bounds.is_none() {
|
||||
self.cancel_clone_stroke();
|
||||
return Ok(());
|
||||
}
|
||||
self.clone_state.clear_active();
|
||||
self.end_stroke(layer_id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Restore the pre-stroke destination and discard the active transaction.
|
||||
pub fn cancel_clone_stroke(&mut self) {
|
||||
let Some(layer_id) = self.clone_state.active_layer_id else {
|
||||
return;
|
||||
};
|
||||
let bounds = self.last_stroke_bounds;
|
||||
if let Some(layer) = self.document.get_layer_by_id_mut(layer_id) {
|
||||
if let Some(before) = self.stroke_before_buf.as_ref() {
|
||||
if before.len() == layer.pixels.len() {
|
||||
layer.pixels.copy_from_slice(before);
|
||||
layer.dirty = true;
|
||||
}
|
||||
}
|
||||
if let Some(effects) = self.stroke_effects_backup.take() {
|
||||
layer.effects = effects;
|
||||
}
|
||||
if let Some(styles) = self.stroke_styles_backup.take() {
|
||||
layer.styles = styles;
|
||||
}
|
||||
*layer.effects_cache.lock().unwrap() = None;
|
||||
if !layer.effects.is_empty() || !layer.styles.is_empty() {
|
||||
layer
|
||||
.effects_dirty
|
||||
.store(true, std::sync::atomic::Ordering::Release);
|
||||
}
|
||||
}
|
||||
if let Some(bounds) = bounds {
|
||||
union_bounds(&mut self.document.dirty_bounds, bounds);
|
||||
}
|
||||
self.document.modified = self.clone_state.modified_before_stroke;
|
||||
self.document.composite_dirty = true;
|
||||
self.stroke_before = None;
|
||||
self.last_stroke_pos = None;
|
||||
self.last_stroke_bounds = None;
|
||||
self.stroke_history_description = None;
|
||||
self.cached_selection_mask = None;
|
||||
if let Some(mask) = self.active_stroke_mask.as_mut() {
|
||||
mask.fill(0);
|
||||
}
|
||||
self.clone_state.clear_active();
|
||||
}
|
||||
|
||||
/// Render one pressure-sensitive dab and update exact changed bounds.
|
||||
fn paint_clone_dab(
|
||||
&mut self,
|
||||
center_x: f32,
|
||||
center_y: f32,
|
||||
pressure: f32,
|
||||
) -> Result<(), CloneError> {
|
||||
let layer_id = self
|
||||
.clone_state
|
||||
.active_layer_id
|
||||
.ok_or(CloneError::StrokeNotActive)?;
|
||||
let source_anchor = self
|
||||
.clone_state
|
||||
.source_anchor
|
||||
.ok_or(CloneError::SourceNotSelected)?;
|
||||
let target_anchor = self
|
||||
.clone_state
|
||||
.active_target_anchor
|
||||
.ok_or(CloneError::StrokeNotActive)?;
|
||||
let source = self
|
||||
.clone_state
|
||||
.source_pixels
|
||||
.as_ref()
|
||||
.cloned()
|
||||
.ok_or(CloneError::SourceNotSelected)?;
|
||||
let config = self.clone_state.config;
|
||||
let variation = variation_for_dab(
|
||||
self.clone_state.stroke_seed,
|
||||
self.clone_state.dab_index,
|
||||
config.color_variation,
|
||||
);
|
||||
self.clone_state.dab_index = self.clone_state.dab_index.wrapping_add(1);
|
||||
let width = self.document.canvas_width;
|
||||
let height = self.document.canvas_height;
|
||||
let selection = self.cached_selection_mask.as_deref();
|
||||
let baseline = self
|
||||
.stroke_before_buf
|
||||
.as_deref()
|
||||
.ok_or(CloneError::StrokeNotActive)?;
|
||||
let stroke_mask = self
|
||||
.active_stroke_mask
|
||||
.as_deref_mut()
|
||||
.ok_or(CloneError::StrokeNotActive)?;
|
||||
let layer = self
|
||||
.document
|
||||
.get_layer_by_id_mut(layer_id)
|
||||
.ok_or(CloneError::DestinationNotEditable)?;
|
||||
let changed = paint_dab(
|
||||
&mut layer.pixels,
|
||||
baseline,
|
||||
stroke_mask,
|
||||
selection,
|
||||
width,
|
||||
height,
|
||||
&source,
|
||||
self.clone_state.source_width,
|
||||
self.clone_state.source_height,
|
||||
source_anchor,
|
||||
target_anchor,
|
||||
center_x,
|
||||
center_y,
|
||||
pressure,
|
||||
config,
|
||||
variation,
|
||||
);
|
||||
if let Some(bounds) = changed {
|
||||
layer.dirty = true;
|
||||
union_bounds(&mut self.last_stroke_bounds, bounds);
|
||||
union_bounds(&mut self.document.dirty_bounds, bounds);
|
||||
self.document.composite_dirty = true;
|
||||
self.document.modified = true;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Paint one dab from immutable source/baseline buffers into destination pixels.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn paint_dab(
|
||||
pixels: &mut [u8],
|
||||
baseline: &[u8],
|
||||
stroke_mask: &mut [u8],
|
||||
selection: Option<&[u8]>,
|
||||
width: u32,
|
||||
height: u32,
|
||||
source: &[u8],
|
||||
source_width: u32,
|
||||
source_height: u32,
|
||||
source_anchor: (f32, f32),
|
||||
target_anchor: (f32, f32),
|
||||
center_x: f32,
|
||||
center_y: f32,
|
||||
pressure: f32,
|
||||
config: CloneConfig,
|
||||
variation: (f32, f32, f32),
|
||||
) -> Option<[u32; 4]> {
|
||||
let pressure = normalized_pressure(pressure);
|
||||
let radius = (config.size * pressure * 0.5).max(0.5);
|
||||
let x0 = ((center_x - radius - 1.0).floor() as i32).max(0) as u32;
|
||||
let y0 = ((center_y - radius - 1.0).floor() as i32).max(0) as u32;
|
||||
let x1 = ((center_x + radius + 1.0).ceil() as i32).clamp(0, width as i32) as u32;
|
||||
let y1 = ((center_y + radius + 1.0).ceil() as i32).clamp(0, height as i32) as u32;
|
||||
let mut changed: Option<[u32; 4]> = None;
|
||||
for py in y0..y1 {
|
||||
for px in x0..x1 {
|
||||
let distance = (px as f32 - center_x).hypot(py as f32 - center_y);
|
||||
let coverage = radial_coverage(distance, radius, config.hardness);
|
||||
if coverage <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
let index = py as usize * width as usize + px as usize;
|
||||
let selection_alpha = selection
|
||||
.and_then(|mask| mask.get(index))
|
||||
.copied()
|
||||
.unwrap_or(255) as f32
|
||||
/ 255.0;
|
||||
let dab_alpha = coverage * config.opacity * pressure * selection_alpha;
|
||||
if dab_alpha <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
let sample_at =
|
||||
map_sample(source_anchor, target_anchor, (px as f32, py as f32), config);
|
||||
let mut sampled = sample_bilinear_premultiplied(
|
||||
source,
|
||||
source_width,
|
||||
source_height,
|
||||
sample_at.0,
|
||||
sample_at.1,
|
||||
);
|
||||
if sampled[3] == 0 {
|
||||
continue;
|
||||
}
|
||||
sampled = vary_color(sampled, variation);
|
||||
let previous = stroke_mask[index] as f32 / 255.0;
|
||||
let cumulative = (previous + dab_alpha * (1.0 - previous)).min(config.opacity);
|
||||
stroke_mask[index] = (cumulative * 255.0).round() as u8;
|
||||
let offset = index * 4;
|
||||
let base = [
|
||||
baseline[offset],
|
||||
baseline[offset + 1],
|
||||
baseline[offset + 2],
|
||||
baseline[offset + 3],
|
||||
];
|
||||
let output =
|
||||
hcie_blend::blend_pixels(base, sampled, hcie_blend::BlendMode::Normal, cumulative);
|
||||
if pixels[offset..offset + 4] != output {
|
||||
pixels[offset..offset + 4].copy_from_slice(&output);
|
||||
union_bounds(&mut changed, [px, py, px + 1, py + 1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
changed
|
||||
}
|
||||
|
||||
/// Return smooth radial brush coverage with a one-pixel hard-edge AA band.
|
||||
fn radial_coverage(distance: f32, radius: f32, hardness: f32) -> f32 {
|
||||
if distance >= radius {
|
||||
return 0.0;
|
||||
}
|
||||
let core = (hardness.clamp(0.0, 1.0) * radius).min((radius - 1.0).max(0.0));
|
||||
if distance <= core {
|
||||
return 1.0;
|
||||
}
|
||||
let t = ((distance - core) / (radius - core).max(f32::EPSILON)).clamp(0.0, 1.0);
|
||||
1.0 - t * t * (3.0 - 2.0 * t)
|
||||
}
|
||||
|
||||
/// Bilinearly sample straight RGBA by interpolating in premultiplied-alpha space.
|
||||
fn sample_bilinear_premultiplied(
|
||||
pixels: &[u8],
|
||||
width: u32,
|
||||
height: u32,
|
||||
x: f32,
|
||||
y: f32,
|
||||
) -> [u8; 4] {
|
||||
if !x.is_finite() || !y.is_finite() {
|
||||
return [0; 4];
|
||||
}
|
||||
let x0 = x.floor() as i32;
|
||||
let y0 = y.floor() as i32;
|
||||
let fx = x - x0 as f32;
|
||||
let fy = y - y0 as f32;
|
||||
let weights = [
|
||||
((x0, y0), (1.0 - fx) * (1.0 - fy)),
|
||||
((x0 + 1, y0), fx * (1.0 - fy)),
|
||||
((x0, y0 + 1), (1.0 - fx) * fy),
|
||||
((x0 + 1, y0 + 1), fx * fy),
|
||||
];
|
||||
let mut premul = [0.0f32; 3];
|
||||
let mut alpha = 0.0f32;
|
||||
for ((sx, sy), weight) in weights {
|
||||
if sx < 0 || sy < 0 || sx >= width as i32 || sy >= height as i32 {
|
||||
continue;
|
||||
}
|
||||
let offset = (sy as usize * width as usize + sx as usize) * 4;
|
||||
if offset + 3 >= pixels.len() {
|
||||
continue;
|
||||
}
|
||||
let a = pixels[offset + 3] as f32 / 255.0;
|
||||
alpha += a * weight;
|
||||
for channel in 0..3 {
|
||||
premul[channel] += pixels[offset + channel] as f32 / 255.0 * a * weight;
|
||||
}
|
||||
}
|
||||
if alpha <= f32::EPSILON {
|
||||
return [0; 4];
|
||||
}
|
||||
[
|
||||
((premul[0] / alpha).clamp(0.0, 1.0) * 255.0).round() as u8,
|
||||
((premul[1] / alpha).clamp(0.0, 1.0) * 255.0).round() as u8,
|
||||
((premul[2] / alpha).clamp(0.0, 1.0) * 255.0).round() as u8,
|
||||
(alpha.clamp(0.0, 1.0) * 255.0).round() as u8,
|
||||
]
|
||||
}
|
||||
|
||||
/// Apply one deterministic HSL offset tuple while preserving alpha.
|
||||
fn vary_color(color: [u8; 4], variation: (f32, f32, f32)) -> [u8; 4] {
|
||||
if variation == (0.0, 0.0, 0.0) {
|
||||
return color;
|
||||
}
|
||||
let (mut h, mut s, mut l) = rgb_to_hsl(color[0], color[1], color[2]);
|
||||
h = (h + variation.0).rem_euclid(1.0);
|
||||
s = (s + variation.1).clamp(0.0, 1.0);
|
||||
l = (l + variation.2).clamp(0.0, 1.0);
|
||||
let [r, g, b] = hsl_to_rgb(h, s, l);
|
||||
[r, g, b, color[3]]
|
||||
}
|
||||
|
||||
/// Generate bounded repeatable hue, saturation, and lightness offsets per dab.
|
||||
fn variation_for_dab(seed: u64, index: u64, amount: f32) -> (f32, f32, f32) {
|
||||
if amount <= 0.0 {
|
||||
return (0.0, 0.0, 0.0);
|
||||
}
|
||||
let mut state = splitmix64(seed ^ index.wrapping_mul(0x9e3779b97f4a7c15));
|
||||
let hue = signed_unit(state);
|
||||
state = splitmix64(state);
|
||||
let saturation = signed_unit(state);
|
||||
state = splitmix64(state);
|
||||
let lightness = signed_unit(state);
|
||||
(
|
||||
hue * (12.0 / 360.0) * amount,
|
||||
saturation * 0.08 * amount,
|
||||
lightness * 0.06 * amount,
|
||||
)
|
||||
}
|
||||
|
||||
/// Map a destination coordinate through the fixed per-stroke mirror transform.
|
||||
fn map_sample(
|
||||
source: (f32, f32),
|
||||
target: (f32, f32),
|
||||
point: (f32, f32),
|
||||
config: CloneConfig,
|
||||
) -> (f32, f32) {
|
||||
let mx = if config.mirror_x { -1.0 } else { 1.0 };
|
||||
let my = if config.mirror_y { -1.0 } else { 1.0 };
|
||||
(
|
||||
source.0 + mx * (point.0 - target.0),
|
||||
source.1 + my * (point.1 - target.1),
|
||||
)
|
||||
}
|
||||
|
||||
/// Convert RGB bytes to normalized HSL.
|
||||
fn rgb_to_hsl(r: u8, g: u8, b: u8) -> (f32, f32, f32) {
|
||||
let r = r as f32 / 255.0;
|
||||
let g = g as f32 / 255.0;
|
||||
let b = b as f32 / 255.0;
|
||||
let max = r.max(g).max(b);
|
||||
let min = r.min(g).min(b);
|
||||
let lightness = (max + min) * 0.5;
|
||||
let delta = max - min;
|
||||
if delta <= f32::EPSILON {
|
||||
return (0.0, 0.0, lightness);
|
||||
}
|
||||
let saturation = delta / (1.0 - (2.0 * lightness - 1.0).abs()).max(f32::EPSILON);
|
||||
let hue = if max == r {
|
||||
((g - b) / delta).rem_euclid(6.0)
|
||||
} else if max == g {
|
||||
(b - r) / delta + 2.0
|
||||
} else {
|
||||
(r - g) / delta + 4.0
|
||||
} / 6.0;
|
||||
(hue, saturation, lightness)
|
||||
}
|
||||
|
||||
/// Convert normalized HSL to RGB bytes.
|
||||
fn hsl_to_rgb(h: f32, s: f32, l: f32) -> [u8; 3] {
|
||||
let chroma = (1.0 - (2.0 * l - 1.0).abs()) * s;
|
||||
let hp = h.rem_euclid(1.0) * 6.0;
|
||||
let x = chroma * (1.0 - (hp.rem_euclid(2.0) - 1.0).abs());
|
||||
let (r, g, b) = match hp as u32 {
|
||||
0 => (chroma, x, 0.0),
|
||||
1 => (x, chroma, 0.0),
|
||||
2 => (0.0, chroma, x),
|
||||
3 => (0.0, x, chroma),
|
||||
4 => (x, 0.0, chroma),
|
||||
_ => (chroma, 0.0, x),
|
||||
};
|
||||
let m = l - chroma * 0.5;
|
||||
[
|
||||
((r + m).clamp(0.0, 1.0) * 255.0).round() as u8,
|
||||
((g + m).clamp(0.0, 1.0) * 255.0).round() as u8,
|
||||
((b + m).clamp(0.0, 1.0) * 255.0).round() as u8,
|
||||
]
|
||||
}
|
||||
|
||||
/// Merge one half-open rectangle into an optional accumulator.
|
||||
fn union_bounds(bounds: &mut Option<[u32; 4]>, next: [u32; 4]) {
|
||||
match bounds {
|
||||
Some(current) => {
|
||||
current[0] = current[0].min(next[0]);
|
||||
current[1] = current[1].min(next[1]);
|
||||
current[2] = current[2].max(next[2]);
|
||||
current[3] = current[3].max(next[3]);
|
||||
}
|
||||
None => *bounds = Some(next),
|
||||
}
|
||||
}
|
||||
|
||||
/// Return a checked RGBA byte length for canvas dimensions.
|
||||
fn rgba_len(width: u32, height: u32) -> Option<usize> {
|
||||
(width as usize)
|
||||
.checked_mul(height as usize)?
|
||||
.checked_mul(4)
|
||||
}
|
||||
|
||||
/// Normalize non-finite pressure and clamp supported tablet pressure values.
|
||||
fn normalized_pressure(pressure: f32) -> f32 {
|
||||
finite_or(pressure, 1.0).clamp(0.0, 1.0)
|
||||
}
|
||||
|
||||
/// Replace a non-finite floating-point value with a deterministic fallback.
|
||||
fn finite_or(value: f32, fallback: f32) -> f32 {
|
||||
if value.is_finite() {
|
||||
value
|
||||
} else {
|
||||
fallback
|
||||
}
|
||||
}
|
||||
|
||||
/// Stateless SplitMix64 round used for deterministic variation generation.
|
||||
fn splitmix64(mut value: u64) -> u64 {
|
||||
value = value.wrapping_add(0x9e3779b97f4a7c15);
|
||||
value = (value ^ (value >> 30)).wrapping_mul(0xbf58476d1ce4e5b9);
|
||||
value = (value ^ (value >> 27)).wrapping_mul(0x94d049bb133111eb);
|
||||
value ^ (value >> 31)
|
||||
}
|
||||
|
||||
/// Convert a deterministic integer into a floating-point value in `[-1, 1]`.
|
||||
fn signed_unit(value: u64) -> f32 {
|
||||
let unit = (value >> 40) as f32 / ((1u32 << 24) - 1) as f32;
|
||||
unit * 2.0 - 1.0
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn config_clamps_and_replaces_non_finite_values() {
|
||||
let config = CloneConfig {
|
||||
size: f32::NAN,
|
||||
opacity: 2.0,
|
||||
hardness: -1.0,
|
||||
color_variation: f32::INFINITY,
|
||||
..CloneConfig::default()
|
||||
}
|
||||
.clamped();
|
||||
assert_eq!(config.size, 40.0);
|
||||
assert_eq!(config.opacity, 1.0);
|
||||
assert_eq!(config.hardness, 0.0);
|
||||
assert_eq!(config.color_variation, 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn affine_mapping_supports_both_mirror_axes() {
|
||||
let mut config = CloneConfig::default();
|
||||
assert_eq!(
|
||||
map_sample((10.0, 20.0), (3.0, 4.0), (5.0, 7.0), config),
|
||||
(12.0, 23.0)
|
||||
);
|
||||
config.mirror_x = true;
|
||||
config.mirror_y = true;
|
||||
assert_eq!(
|
||||
map_sample((10.0, 20.0), (3.0, 4.0), (5.0, 7.0), config),
|
||||
(8.0, 17.0)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bilinear_sampling_avoids_transparent_color_halos() {
|
||||
let pixels = [255, 0, 0, 255, 0, 255, 0, 0];
|
||||
let sampled = sample_bilinear_premultiplied(&pixels, 2, 1, 0.5, 0.0);
|
||||
assert_eq!(sampled, [255, 0, 0, 128]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn variation_is_repeatable_bounded_and_preserves_alpha() {
|
||||
let a = variation_for_dab(7, 11, 1.0);
|
||||
let b = variation_for_dab(7, 11, 1.0);
|
||||
assert_eq!(a, b);
|
||||
assert!(a.0.abs() <= 12.0 / 360.0);
|
||||
assert!(a.1.abs() <= 0.08);
|
||||
assert!(a.2.abs() <= 0.06);
|
||||
assert_eq!(vary_color([20, 80, 140, 77], a)[3], 77);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source_is_required_before_stroke() {
|
||||
let mut engine = Engine::new(8, 8);
|
||||
assert_eq!(
|
||||
engine.begin_clone_stroke(2.0, 2.0, 1.0),
|
||||
Err(CloneError::SourceNotSelected)
|
||||
);
|
||||
}
|
||||
|
||||
/// Install one opaque source pixel directly for focused lifecycle tests.
|
||||
fn engine_with_source_pixel() -> Engine {
|
||||
let mut engine = Engine::new(8, 8);
|
||||
let layer = engine.document.active_layer_mut().expect("active layer");
|
||||
let offset = (1 * 8 + 1) * 4;
|
||||
layer.pixels[offset..offset + 4].copy_from_slice(&[220, 30, 10, 255]);
|
||||
layer.dirty = true;
|
||||
engine.document.composite_dirty = true;
|
||||
engine.document.dirty_bounds = Some([1, 1, 2, 2]);
|
||||
engine
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source_capture_preserves_document_and_dirty_state() {
|
||||
let mut engine = engine_with_source_pixel();
|
||||
let modified = engine.document.modified;
|
||||
let dirty = engine.document.dirty_bounds;
|
||||
let history = engine.document.history_len();
|
||||
engine.set_clone_source(1.0, 1.0).expect("capture source");
|
||||
assert_eq!(engine.document.modified, modified);
|
||||
assert_eq!(engine.document.dirty_bounds, dirty);
|
||||
assert_eq!(engine.document.history_len(), history);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frozen_source_paints_and_cancel_restores_destination() {
|
||||
let mut engine = engine_with_source_pixel();
|
||||
engine.set_clone_source(1.0, 1.0).expect("capture source");
|
||||
engine.set_clone_config(CloneConfig {
|
||||
size: 1.0,
|
||||
hardness: 1.0,
|
||||
..CloneConfig::default()
|
||||
});
|
||||
let source_offset = (1 * 8 + 1) * 4;
|
||||
engine.document.active_layer_mut().unwrap().pixels[source_offset..source_offset + 4]
|
||||
.copy_from_slice(&[0, 0, 255, 255]);
|
||||
engine
|
||||
.begin_clone_stroke(4.0, 4.0, 1.0)
|
||||
.expect("begin clone");
|
||||
let target_offset = (4 * 8 + 4) * 4;
|
||||
assert_eq!(
|
||||
&engine.document.active_layer().unwrap().pixels[target_offset..target_offset + 4],
|
||||
&[220, 30, 10, 255]
|
||||
);
|
||||
engine.cancel_clone_stroke();
|
||||
assert_eq!(
|
||||
&engine.document.active_layer().unwrap().pixels[target_offset..target_offset + 4],
|
||||
&[0, 0, 0, 0]
|
||||
);
|
||||
assert_eq!(engine.document.history_len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completed_stroke_creates_one_named_history_item() {
|
||||
let mut engine = engine_with_source_pixel();
|
||||
engine.set_clone_source(1.0, 1.0).expect("capture source");
|
||||
engine.set_clone_config(CloneConfig {
|
||||
size: 1.0,
|
||||
hardness: 1.0,
|
||||
..CloneConfig::default()
|
||||
});
|
||||
engine
|
||||
.begin_clone_stroke(4.0, 4.0, 1.0)
|
||||
.expect("begin clone");
|
||||
engine.end_clone_stroke().expect("end clone");
|
||||
for _ in 0..100 {
|
||||
if engine.commit_pending_history() {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(2));
|
||||
}
|
||||
assert_eq!(engine.document.history_len(), 1);
|
||||
assert_eq!(
|
||||
engine.document.history_description(0).as_deref(),
|
||||
Some("Clone Stamp")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -515,6 +515,7 @@ pub unsafe extern "C" fn hcie_engine_set_active_tool(handle: *mut EngineHandle,
|
||||
21 => Tool::AiObjectRemoval,
|
||||
22 => Tool::SmartSelect,
|
||||
23 => Tool::VisionSelect,
|
||||
24 => Tool::CloneStamp,
|
||||
_ => Tool::Brush,
|
||||
};
|
||||
e.set_tool(tool);
|
||||
|
||||
@@ -25,6 +25,7 @@ impl Engine {
|
||||
/// Removes all layers and resets layer-dependent caches without marking
|
||||
/// the document as modified.
|
||||
pub fn clear_all_layers(&mut self) {
|
||||
self.clear_clone_source();
|
||||
self.document.layers.clear();
|
||||
self.document.clear_history();
|
||||
self.document.active_layer = 0;
|
||||
@@ -302,6 +303,7 @@ impl Engine {
|
||||
}
|
||||
|
||||
pub fn undo(&mut self) -> bool {
|
||||
self.cancel_clone_stroke();
|
||||
if self.document.can_undo() {
|
||||
self.document.undo();
|
||||
self.tile_layers.clear();
|
||||
@@ -316,6 +318,7 @@ impl Engine {
|
||||
}
|
||||
|
||||
pub fn redo(&mut self) -> bool {
|
||||
self.cancel_clone_stroke();
|
||||
if self.document.can_redo() {
|
||||
self.document.redo();
|
||||
self.tile_layers.clear();
|
||||
@@ -330,6 +333,7 @@ impl Engine {
|
||||
}
|
||||
|
||||
pub fn jump_to_history(&mut self, idx: i32) {
|
||||
self.cancel_clone_stroke();
|
||||
self.document.jump_to_history(idx);
|
||||
self.tile_layers.clear();
|
||||
self.document.composite_dirty = true;
|
||||
@@ -459,4 +463,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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ pub mod svg_editor;
|
||||
|
||||
// Performance-critical engine paths isolated into dedicated modules.
|
||||
mod layer_property_ops;
|
||||
mod clone_tool;
|
||||
pub mod partial_composite;
|
||||
pub mod stroke_brush;
|
||||
mod stroke_cache;
|
||||
@@ -31,6 +32,7 @@ use std::sync::Mutex;
|
||||
|
||||
pub use crate::shape_catalog::{ShapeCatalog, ShapeEntry};
|
||||
pub use crate::svg_editor::{SvgEditable, SvgNode};
|
||||
pub use crate::clone_tool::{CloneConfig, CloneError, CloneSourceInfo};
|
||||
pub use hcie_blend::Adjustment;
|
||||
pub use hcie_brush_engine::presets::BrushPreset;
|
||||
pub use hcie_protocol::thumbnail_nearest;
|
||||
@@ -348,12 +350,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 +367,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.
|
||||
@@ -413,6 +419,10 @@ pub struct Engine {
|
||||
/// alloc+copy per pointer event. This cache clones it once at stroke start
|
||||
/// and all stroke functions reference it instead.
|
||||
cached_selection_mask: Option<Vec<u8>>,
|
||||
/// Document-local Clone Stamp source, configuration, and active gesture state.
|
||||
clone_state: crate::clone_tool::CloneState,
|
||||
/// Optional history description used by specialized raster stroke lifecycles.
|
||||
stroke_history_description: Option<String>,
|
||||
/// Thread-safe queue of history items computed on background threads.
|
||||
/// Polled and committed on the UI thread via `commit_pending_history()`.
|
||||
pub pending_history:
|
||||
@@ -540,20 +550,25 @@ 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,
|
||||
raw_pixel_backup: std::collections::HashMap::new(),
|
||||
below_cache_dirty: true,
|
||||
cached_selection_mask: None,
|
||||
clone_state: crate::clone_tool::CloneState::default(),
|
||||
stroke_history_description: None,
|
||||
pending_history: std::sync::Arc::new(std::sync::Mutex::new(Vec::new())),
|
||||
shape_catalog: {
|
||||
let base = dirs::data_local_dir().unwrap_or_else(|| std::path::PathBuf::from("."));
|
||||
@@ -797,10 +812,12 @@ impl Engine {
|
||||
// ── Transform Ops ────────────────────────────────────────────────────
|
||||
|
||||
pub fn crop(&mut self, x: u32, y: u32, w: u32, h: u32) {
|
||||
self.clear_clone_source();
|
||||
self.document.crop(x, y, w, h);
|
||||
}
|
||||
|
||||
pub fn resize_canvas(&mut self, w: u32, h: u32) {
|
||||
self.clear_clone_source();
|
||||
self.document.resize_canvas(w, h);
|
||||
self.pre_tile_all_layers();
|
||||
}
|
||||
|
||||
@@ -25,8 +25,24 @@ 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 {
|
||||
/// Capture a merged-visible RGBA snapshot without consuming GUI dirty state.
|
||||
///
|
||||
/// The effects and tile caches are brought current before all visible layers
|
||||
/// are composited. Unlike `get_composite_pixels`, this deliberately leaves
|
||||
/// document dirty flags and bounds intact so a pending partial GPU upload is
|
||||
/// not lost. The returned buffer is owned by the caller.
|
||||
pub(crate) fn snapshot_merged_visible(&mut self) -> Vec<u8> {
|
||||
self.apply_effects_and_sync_tiles();
|
||||
composite_layers(
|
||||
&self.document.layers,
|
||||
self.document.canvas_width,
|
||||
self.document.canvas_height,
|
||||
)
|
||||
}
|
||||
|
||||
/// **Purpose:**
|
||||
/// Computes the flat composite RGBA pixel buffer of all layers in the document.
|
||||
///
|
||||
@@ -145,25 +161,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 +212,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,
|
||||
@@ -145,6 +175,7 @@ impl Engine {
|
||||
/// optimization is silently negated and ~8MB will be allocated per stroke.
|
||||
/// The merge artifact cleanup of 2026-05-28 removed exactly this regression.
|
||||
pub fn begin_stroke(&mut self, layer_id: u64, x: f32, y: f32) {
|
||||
self.stroke_history_description = None;
|
||||
self.last_stroke_pos = Some((x, y, 1.0));
|
||||
self.sketch_history.clear();
|
||||
if let Some(layer) = self.document.get_layer_by_id_mut(layer_id) {
|
||||
@@ -191,12 +222,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 +344,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())
|
||||
@@ -273,8 +381,10 @@ impl Engine {
|
||||
};
|
||||
|
||||
let style = self.current_tip.style;
|
||||
let history_description = self.stroke_history_description.take();
|
||||
let bounds = self.last_stroke_bounds;
|
||||
let pending_history = self.pending_history.clone();
|
||||
let is_mask = self.editing_mask;
|
||||
|
||||
std::thread::spawn(move || {
|
||||
let t_start = std::time::Instant::now();
|
||||
@@ -289,26 +399,34 @@ impl Engine {
|
||||
bounds: None,
|
||||
before_shapes,
|
||||
after_shapes,
|
||||
description: format!(
|
||||
"Brush Stroke ({})",
|
||||
description: history_description.unwrap_or_else(|| format!(
|
||||
"{} ({})",
|
||||
if is_mask { "Mask Edit" } else { "Brush Stroke" },
|
||||
crate::stroke_brush::brush_style_label(style)
|
||||
),
|
||||
)),
|
||||
return_before: None,
|
||||
return_after: None,
|
||||
return_mask_before: mask_before,
|
||||
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 +438,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!(
|
||||
@@ -367,6 +487,7 @@ impl Engine {
|
||||
mask.fill(0);
|
||||
}
|
||||
self.cached_selection_mask = None;
|
||||
self.stroke_history_description = None;
|
||||
}
|
||||
|
||||
/// Expand the stroke bounds to include the given point with radius.
|
||||
@@ -426,60 +547,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,277 @@
|
||||
use hcie_filter::apply_filter;
|
||||
|
||||
/// Value-verification tests for hcie-filter.
|
||||
///
|
||||
/// Upgrades existing "does not panic" smoke tests to actual
|
||||
/// correctness assertions with known input → known output checks.
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper: create a small test layer with known pixel data
|
||||
// ---------------------------------------------------------------------------
|
||||
fn make_gradient_layer() -> hcie_protocol::Layer {
|
||||
let mut layer = hcie_protocol::Layer::new_blank("gradient", 4, 4);
|
||||
for y in 0..4 {
|
||||
for x in 0..4 {
|
||||
layer.set_pixel(x, y, [x as u8 * 64, y as u8 * 64, 128, 255]);
|
||||
}
|
||||
}
|
||||
layer
|
||||
}
|
||||
|
||||
fn make_solid_layer(r: u8, g: u8, b: u8, a: u8) -> hcie_protocol::Layer {
|
||||
let mut layer = hcie_protocol::Layer::new_blank("solid", 4, 4);
|
||||
for y in 0..4 {
|
||||
for x in 0..4 {
|
||||
layer.set_pixel(x, y, [r, g, b, a]);
|
||||
}
|
||||
}
|
||||
layer
|
||||
}
|
||||
|
||||
fn make_1x1_layer(r: u8, g: u8, b: u8, a: u8) -> hcie_protocol::Layer {
|
||||
let mut layer = hcie_protocol::Layer::new_blank("tiny", 1, 1);
|
||||
layer.set_pixel(0, 0, [r, g, b, a]);
|
||||
layer
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Invert filter — exact value verification
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn invert_white_becomes_black() {
|
||||
let mut layer = make_solid_layer(255, 255, 255, 255);
|
||||
apply_filter(&mut layer, "invert", &serde_json::json!({}));
|
||||
let pixel = layer.get_pixel(0, 0);
|
||||
assert_eq!(pixel[0], 0, "invert of white R should be 0");
|
||||
assert_eq!(pixel[1], 0, "invert of white G should be 0");
|
||||
assert_eq!(pixel[2], 0, "invert of white B should be 0");
|
||||
// Alpha typically unchanged
|
||||
assert_eq!(pixel[3], 255, "invert should leave alpha unchanged");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invert_black_becomes_white() {
|
||||
let mut layer = make_solid_layer(0, 0, 0, 255);
|
||||
apply_filter(&mut layer, "invert", &serde_json::json!({}));
|
||||
let pixel = layer.get_pixel(0, 0);
|
||||
assert_eq!(pixel[0], 255);
|
||||
assert_eq!(pixel[1], 255);
|
||||
assert_eq!(pixel[2], 255);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invert_red_becomes_cyan() {
|
||||
let mut layer = make_solid_layer(255, 0, 0, 255);
|
||||
apply_filter(&mut layer, "invert", &serde_json::json!({}));
|
||||
let pixel = layer.get_pixel(0, 0);
|
||||
assert_eq!(pixel[0], 0, "invert red: R should be 0");
|
||||
assert_eq!(pixel[1], 255, "invert red: G should be 255");
|
||||
assert_eq!(pixel[2], 255, "invert red: B should be 255");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invert_preserves_transparency() {
|
||||
let mut layer = make_solid_layer(100, 150, 200, 0);
|
||||
apply_filter(&mut layer, "invert", &serde_json::json!({}));
|
||||
let pixel = layer.get_pixel(0, 0);
|
||||
assert_eq!(pixel[3], 0, "invert should preserve zero alpha");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invert_invert_is_identity() {
|
||||
let original = make_gradient_layer();
|
||||
let mut layer = make_gradient_layer();
|
||||
apply_filter(&mut layer, "invert", &serde_json::json!({}));
|
||||
apply_filter(&mut layer, "invert", &serde_json::json!({}));
|
||||
for y in 0..4 {
|
||||
for x in 0..4 {
|
||||
assert_eq!(
|
||||
layer.get_pixel(x, y),
|
||||
original.get_pixel(x, y),
|
||||
"double invert should restore original at ({},{})", x, y
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Grayscale filter — value verification
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn grayscale_makes_rgb_equal() {
|
||||
let mut layer = make_gradient_layer();
|
||||
apply_filter(&mut layer, "grayscale", &serde_json::json!({}));
|
||||
for y in 0..4 {
|
||||
for x in 0..4 {
|
||||
let p = layer.get_pixel(x, y);
|
||||
let diff_rg = (p[0] as i16 - p[1] as i16).abs();
|
||||
let diff_rb = (p[0] as i16 - p[2] as i16).abs();
|
||||
let diff_gb = (p[1] as i16 - p[2] as i16).abs();
|
||||
// Allow small tolerance for integer rounding
|
||||
assert!(
|
||||
diff_rg <= 3 && diff_rb <= 3 && diff_gb <= 3,
|
||||
"grayscale should produce R≈G≈B at ({},{}): got {:?}",
|
||||
x, y, p
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grayscale_identity_on_gray_input() {
|
||||
let mut layer = make_solid_layer(128, 128, 128, 255);
|
||||
apply_filter(&mut layer, "grayscale", &serde_json::json!({}));
|
||||
let pixel = layer.get_pixel(0, 0);
|
||||
assert_eq!(pixel[0], 128);
|
||||
assert_eq!(pixel[1], 128);
|
||||
assert_eq!(pixel[2], 128);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grayscale_preserves_alpha() {
|
||||
let mut layer = make_solid_layer(100, 150, 200, 128);
|
||||
apply_filter(&mut layer, "grayscale", &serde_json::json!({}));
|
||||
let pixel = layer.get_pixel(0, 0);
|
||||
assert_eq!(pixel[3], 128, "grayscale should preserve alpha");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Brightness/Contrast filter
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn brightness_zero_is_identity() {
|
||||
let original = make_gradient_layer();
|
||||
let mut layer = make_gradient_layer();
|
||||
// PS-style brightness/contrast: 0 brightness, 0 contrast should be identity
|
||||
apply_filter(&mut layer, "brightness_contrast", &serde_json::json!({
|
||||
"brightness": 0.0,
|
||||
"contrast": 0.0
|
||||
}));
|
||||
for y in 0..4 {
|
||||
for x in 0..4 {
|
||||
let o = original.get_pixel(x, y);
|
||||
let p = layer.get_pixel(x, y);
|
||||
// Allow ±1 for rounding
|
||||
assert!(
|
||||
(o[0] as i16 - p[0] as i16).abs() <= 1 &&
|
||||
(o[1] as i16 - p[1] as i16).abs() <= 1 &&
|
||||
(o[2] as i16 - p[2] as i16).abs() <= 1,
|
||||
"identity params should preserve pixel at ({},{}): orig={:?}, got={:?}",
|
||||
x, y, o, p
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn brightness_changes_pixels() {
|
||||
let mut layer = make_solid_layer(64, 64, 64, 255);
|
||||
let before = layer.get_pixel(0, 0);
|
||||
// PS-style brightness uses 0-100 scale
|
||||
apply_filter(&mut layer, "brightness_contrast", &serde_json::json!({
|
||||
"brightness": 50.0,
|
||||
"contrast": 0.0
|
||||
}));
|
||||
let after = layer.get_pixel(0, 0);
|
||||
assert!(
|
||||
after[0] != before[0] || after[1] != before[1] || after[2] != before[2],
|
||||
"brightness=50 should change at least one channel"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn brightness_negative_changes_pixels() {
|
||||
let mut layer = make_solid_layer(128, 128, 128, 255);
|
||||
let before = layer.get_pixel(0, 0);
|
||||
// PS-style brightness uses 0-100 scale
|
||||
apply_filter(&mut layer, "brightness_contrast", &serde_json::json!({
|
||||
"brightness": -50.0,
|
||||
"contrast": 0.0
|
||||
}));
|
||||
let after = layer.get_pixel(0, 0);
|
||||
assert!(
|
||||
after[0] != before[0] || after[1] != before[1] || after[2] != before[2],
|
||||
"brightness=-50 should change at least one channel"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Edge cases: 1×1 images (minimal boundary)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn invert_works_on_1x1() {
|
||||
let mut layer = make_1x1_layer(128, 64, 32, 255);
|
||||
apply_filter(&mut layer, "invert", &serde_json::json!({}));
|
||||
let pixel = layer.get_pixel(0, 0);
|
||||
assert_eq!(pixel[0], 127); // 255 - 128
|
||||
assert_eq!(pixel[1], 191); // 255 - 64
|
||||
assert_eq!(pixel[2], 223); // 255 - 32
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grayscale_works_on_1x1() {
|
||||
let mut layer = make_1x1_layer(100, 50, 200, 255);
|
||||
apply_filter(&mut layer, "grayscale", &serde_json::json!({}));
|
||||
let pixel = layer.get_pixel(0, 0);
|
||||
// Grayscale: R, G, B should be approximately equal
|
||||
let diff = (pixel[0] as i16 - pixel[1] as i16).abs()
|
||||
.max((pixel[0] as i16 - pixel[2] as i16).abs())
|
||||
.max((pixel[1] as i16 - pixel[2] as i16).abs());
|
||||
assert!(diff <= 3, "grayscale on 1x1 should make R≈G≈B: {:?}", pixel);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Unknown / missing parameters do not crash
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn invert_with_empty_params_works() {
|
||||
let mut layer = make_solid_layer(100, 100, 100, 255);
|
||||
apply_filter(&mut layer, "invert", &serde_json::json!({}));
|
||||
let pixel = layer.get_pixel(0, 0);
|
||||
assert_eq!(pixel[0], 155);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invert_with_extra_params_does_not_crash() {
|
||||
let mut layer = make_solid_layer(0, 0, 0, 255);
|
||||
apply_filter(&mut layer, "invert", &serde_json::json!({
|
||||
"unknown_param": 42,
|
||||
"another_one": "hello"
|
||||
}));
|
||||
let pixel = layer.get_pixel(0, 0);
|
||||
assert_eq!(pixel[0], 255, "invert with extra params should still work");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Filters preserve layer dimensions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn filter_preserves_dimensions() {
|
||||
for filter_name in &["invert", "grayscale", "brightness_contrast", "box_blur"] {
|
||||
let mut layer = make_gradient_layer();
|
||||
apply_filter(&mut layer, filter_name, &serde_json::json!({}));
|
||||
assert_eq!(layer.width, 4, "{} should preserve width", filter_name);
|
||||
assert_eq!(layer.height, 4, "{} should preserve height", filter_name);
|
||||
assert_eq!(layer.pixels.len(), 4 * 4 * 4, "{} should preserve pixel count", filter_name);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Multiple filters in sequence do not crash
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn sequence_of_filters_does_not_crash() {
|
||||
let mut layer = make_gradient_layer();
|
||||
for &filter in &["invert", "grayscale", "brightness_contrast", "sharpen", "box_blur"] {
|
||||
apply_filter(&mut layer, filter, &serde_json::json!({}));
|
||||
}
|
||||
// If we got here without panic, the test passes
|
||||
assert_eq!(layer.width, 4);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" color="#fff" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M9 3h6l1 5 3 3v3H5v-3l3-3 1-5Z"/>
|
||||
<path d="M6 14h12v3H6zM8 20h8M12 17v3"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 308 B |
@@ -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 |
@@ -233,6 +233,14 @@ pub fn dry_media_presets() -> Vec<BrushPreset> {
|
||||
false,
|
||||
true,
|
||||
),
|
||||
preset(
|
||||
"star_sparkle",
|
||||
"Star Sparkle",
|
||||
"Effects",
|
||||
tip(BrushStyle::Star, 14.0, 0.95, 1.0, 0.35),
|
||||
true,
|
||||
false,
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -245,14 +253,14 @@ mod tests {
|
||||
#[test]
|
||||
fn dry_media_catalog_is_complete_and_unique() {
|
||||
let presets = dry_media_presets();
|
||||
assert_eq!(presets.len(), 19);
|
||||
assert_eq!(presets.len(), 20);
|
||||
assert_eq!(
|
||||
presets
|
||||
.iter()
|
||||
.map(|preset| &preset.id)
|
||||
.collect::<HashSet<_>>()
|
||||
.len(),
|
||||
19
|
||||
20
|
||||
);
|
||||
assert!(presets
|
||||
.iter()
|
||||
|
||||
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,16 @@ struct OverlayProgram {
|
||||
_quick_mask: bool,
|
||||
/// Active brush diameter in canvas pixels for the footprint cursor.
|
||||
brush_size: f32,
|
||||
/// Clone Stamp configuration used for footprint and affine source markers.
|
||||
clone_config: hcie_engine_api::CloneConfig,
|
||||
/// Fixed document-local source marker.
|
||||
clone_source: Option<hcie_engine_api::CloneSourceInfo>,
|
||||
/// Source coordinate corresponding to destination origin under the active transform.
|
||||
clone_sample_origin: Option<(f32, f32)>,
|
||||
/// Whether the next ordinary click selects a source.
|
||||
clone_source_armed: bool,
|
||||
/// Whether a raster stroke is active; used to keep idle latency metrics isolated.
|
||||
is_drawing: bool,
|
||||
/// Normalized bounds of the selected vector shape in canvas-space (x1, y1, x2, y2).
|
||||
selected_vector_bounds: Option<(f32, f32, f32, f32)>,
|
||||
/// Rotation angle (radians) of the selected vector shape.
|
||||
@@ -107,6 +118,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 +1177,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();
|
||||
@@ -2002,9 +2017,106 @@ impl canvas::Program<Message> for OverlayProgram {
|
||||
geometries.push(frame.into_geometry());
|
||||
}
|
||||
|
||||
// ── Clone Stamp source markers ──────────────────────────────────
|
||||
if self.active_tool == hcie_engine_api::Tool::CloneStamp {
|
||||
if let Some(source) = self.clone_source {
|
||||
let mut marker_frame = Frame::new(renderer, bounds.size());
|
||||
let fixed = Point::new(
|
||||
origin_x + source.x * self.zoom,
|
||||
origin_y + source.y * self.zoom,
|
||||
);
|
||||
let marker_color = iced::Color::from_rgb(0.25, 0.9, 0.95);
|
||||
marker_frame.stroke(
|
||||
&Path::circle(fixed, 7.0),
|
||||
Stroke {
|
||||
style: canvas::stroke::Style::Solid(marker_color),
|
||||
width: 1.5,
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
marker_frame.stroke(
|
||||
&Path::line(
|
||||
Point::new(fixed.x - 10.0, fixed.y),
|
||||
Point::new(fixed.x + 10.0, fixed.y),
|
||||
),
|
||||
Stroke {
|
||||
style: canvas::stroke::Style::Solid(marker_color),
|
||||
width: 1.0,
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
marker_frame.stroke(
|
||||
&Path::line(
|
||||
Point::new(fixed.x, fixed.y - 10.0),
|
||||
Point::new(fixed.x, fixed.y + 10.0),
|
||||
),
|
||||
Stroke {
|
||||
style: canvas::stroke::Style::Solid(marker_color),
|
||||
width: 1.0,
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
if let (Some(cursor_point), Some(sample_origin)) =
|
||||
(cursor.position_in(bounds), self.clone_sample_origin)
|
||||
{
|
||||
let destination_x = (cursor_point.x - origin_x) / self.zoom;
|
||||
let destination_y = (cursor_point.y - origin_y) / self.zoom;
|
||||
let sample_x = sample_origin.0
|
||||
+ if self.clone_config.mirror_x {
|
||||
-destination_x
|
||||
} else {
|
||||
destination_x
|
||||
};
|
||||
let sample_y = sample_origin.1
|
||||
+ if self.clone_config.mirror_y {
|
||||
-destination_y
|
||||
} else {
|
||||
destination_y
|
||||
};
|
||||
if sample_x >= 0.0
|
||||
&& sample_y >= 0.0
|
||||
&& sample_x < self.engine_w as f32
|
||||
&& sample_y < self.engine_h as f32
|
||||
{
|
||||
let current = Point::new(
|
||||
origin_x + sample_x * self.zoom,
|
||||
origin_y + sample_y * self.zoom,
|
||||
);
|
||||
marker_frame.stroke(
|
||||
&Path::circle(current, 5.0),
|
||||
Stroke {
|
||||
style: canvas::stroke::Style::Solid(iced::Color::from_rgb(
|
||||
1.0, 0.75, 0.2,
|
||||
)),
|
||||
width: 1.5,
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
if self.is_drawing {
|
||||
marker_frame.stroke(
|
||||
&Path::line(current, cursor_point),
|
||||
Stroke {
|
||||
style: canvas::stroke::Style::Solid(iced::Color::from_rgba(
|
||||
1.0, 0.75, 0.2, 0.55,
|
||||
)),
|
||||
width: 1.0,
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
geometries.push(marker_frame.into_geometry());
|
||||
}
|
||||
}
|
||||
|
||||
// ── Crosshair cursor ─────────────────────────────────────────────
|
||||
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);
|
||||
@@ -2014,9 +2126,15 @@ impl canvas::Program<Message> for OverlayProgram {
|
||||
| hcie_engine_api::Tool::Brush
|
||||
| hcie_engine_api::Tool::Eraser
|
||||
| hcie_engine_api::Tool::Spray
|
||||
| hcie_engine_api::Tool::CloneStamp
|
||||
) {
|
||||
let diameter = if self.active_tool == hcie_engine_api::Tool::CloneStamp {
|
||||
self.clone_config.size
|
||||
} else {
|
||||
self.brush_size
|
||||
};
|
||||
let footprint =
|
||||
Path::circle(cursor, (self.brush_size * self.zoom * 0.5).max(1.0));
|
||||
Path::circle(cursor_point, (diameter * self.zoom * 0.5).max(1.0));
|
||||
crosshair_frame.stroke(
|
||||
&footprint,
|
||||
Stroke {
|
||||
@@ -2025,11 +2143,32 @@ impl canvas::Program<Message> for OverlayProgram {
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
if self.active_tool == hcie_engine_api::Tool::CloneStamp {
|
||||
let hard_radius =
|
||||
(diameter * self.clone_config.hardness * self.zoom * 0.5).max(1.0);
|
||||
crosshair_frame.stroke(
|
||||
&Path::circle(cursor_point, hard_radius),
|
||||
Stroke {
|
||||
style: canvas::stroke::Style::Solid(iced::Color::from_rgba(
|
||||
1.0, 1.0, 1.0, 0.45,
|
||||
)),
|
||||
width: 1.0,
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
let crosshair_color = if self.active_tool == hcie_engine_api::Tool::CloneStamp
|
||||
&& (self.clone_source_armed || self.clone_source.is_none())
|
||||
{
|
||||
iced::Color::from_rgb(1.0, 0.65, 0.15)
|
||||
} else {
|
||||
crosshair_color
|
||||
};
|
||||
crosshair_frame.stroke(
|
||||
&Path::line(
|
||||
Point::new(cursor.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 +2178,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 +2188,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 +2242,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 +2279,20 @@ 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)
|
||||
}
|
||||
@@ -2204,6 +2367,9 @@ pub fn view<'a>(
|
||||
star_points: u32,
|
||||
polygon_sides: u32,
|
||||
rect_radius: f32,
|
||||
modifiers: iced::keyboard::Modifiers,
|
||||
clone_config: hcie_engine_api::CloneConfig,
|
||||
clone_source_armed: bool,
|
||||
) -> Element<'a, Message> {
|
||||
let engine_w = doc.engine.canvas_width();
|
||||
let engine_h = doc.engine.canvas_height();
|
||||
@@ -2236,6 +2402,7 @@ pub fn view<'a>(
|
||||
texture_update,
|
||||
full_upload,
|
||||
space_pan: tool_state.space_pan,
|
||||
modifiers,
|
||||
selection_mask: doc.selection_texture.clone(),
|
||||
selection_dirty: doc.selection_mask_dirty.get(),
|
||||
anim_time: marching_ants_offset * 2.0, // Convert 0..1 to seconds (2s cycle)
|
||||
@@ -2304,6 +2471,11 @@ pub fn view<'a>(
|
||||
polygon_points: doc.polygon_points.clone(),
|
||||
_quick_mask: doc.quick_mask,
|
||||
brush_size: tool_state.brush_size,
|
||||
clone_config,
|
||||
clone_source: doc.engine.clone_source_info(),
|
||||
clone_sample_origin: doc.engine.clone_sample_position(0.0, 0.0),
|
||||
clone_source_armed,
|
||||
is_drawing: tool_state.is_drawing,
|
||||
selected_vector_bounds: sel_vec_bounds,
|
||||
selected_vector_angle: sel_vec_angle,
|
||||
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`.
|
||||
@@ -917,6 +1010,8 @@ pub struct CanvasShaderProgram {
|
||||
pub full_upload: bool,
|
||||
/// Whether Space temporarily changes left-drag into panning.
|
||||
pub space_pan: bool,
|
||||
/// Modifier snapshot copied into pointer-press messages for deterministic routing.
|
||||
pub modifiers: iced::keyboard::Modifiers,
|
||||
/// Encoded selection data (0 unselected, 128 interior, 255 border), if any.
|
||||
pub selection_mask: Option<std::sync::Arc<Vec<u8>>>,
|
||||
/// Whether the selection mask has changed since last upload.
|
||||
@@ -930,8 +1025,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 +1055,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 +1074,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 +1086,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 +1093,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 +1115,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 +1140,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 +1153,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 +1195,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 +1215,19 @@ 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,
|
||||
modifiers: self.modifiers,
|
||||
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 +1265,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 +1276,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 +1365,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 +1373,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 +1402,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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
//! Clone Stamp GUI-only state and presentation helpers.
|
||||
//!
|
||||
//! ## Purpose
|
||||
//! Converts persisted controls into the engine API configuration and formats
|
||||
//! document-local source/error state for panels. Pixel mutation remains wholly
|
||||
//! inside `hcie-engine-api`.
|
||||
|
||||
use crate::settings::ToolSettings;
|
||||
use hcie_engine_api::{CloneConfig, CloneError, CloneSourceInfo};
|
||||
|
||||
/// Transient accessibility and status state shared by Clone Stamp UI surfaces.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct CloneToolUiState {
|
||||
pub source_armed: bool,
|
||||
pub notice: Option<String>,
|
||||
}
|
||||
|
||||
/// Copyable model consumed by the shared Clone Stamp panel component.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ClonePanelModel {
|
||||
pub config: CloneConfig,
|
||||
pub source: Option<CloneSourceInfo>,
|
||||
pub source_armed: bool,
|
||||
pub notice: Option<String>,
|
||||
pub variation_enabled: bool,
|
||||
}
|
||||
|
||||
/// Convert persisted values to a complete engine configuration.
|
||||
pub fn config_from_settings(settings: &ToolSettings) -> CloneConfig {
|
||||
CloneConfig {
|
||||
size: settings.clone_size,
|
||||
opacity: settings.clone_opacity,
|
||||
hardness: settings.clone_hardness,
|
||||
aligned: settings.clone_aligned,
|
||||
mirror_x: settings.clone_mirror_x,
|
||||
mirror_y: settings.clone_mirror_y,
|
||||
color_variation: if settings.clone_color_variation {
|
||||
settings.clone_color_variation_amount
|
||||
} else {
|
||||
0.0
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a panel model from persisted controls and document-local source data.
|
||||
pub fn panel_model(
|
||||
settings: &ToolSettings,
|
||||
source: Option<CloneSourceInfo>,
|
||||
ui: &CloneToolUiState,
|
||||
) -> ClonePanelModel {
|
||||
ClonePanelModel {
|
||||
config: config_from_settings(settings),
|
||||
source,
|
||||
source_armed: ui.source_armed,
|
||||
notice: ui.notice.clone(),
|
||||
variation_enabled: settings.clone_color_variation,
|
||||
}
|
||||
}
|
||||
|
||||
/// Format source status with accessible armed and failure states taking precedence.
|
||||
pub fn source_status(model: &ClonePanelModel) -> String {
|
||||
if let Some(notice) = model.notice.as_ref() {
|
||||
return notice.clone();
|
||||
}
|
||||
if model.source_armed {
|
||||
return "Select a source on the canvas".to_string();
|
||||
}
|
||||
match model.source {
|
||||
Some(source) => format!("Source: {:.0}, {:.0} (Merged Visible)", source.x, source.y),
|
||||
None => "No source selected".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert an engine error into concise non-modal user guidance.
|
||||
pub fn error_message(error: CloneError) -> String {
|
||||
match error {
|
||||
CloneError::SourceNotSelected => "Select a source before painting".to_string(),
|
||||
CloneError::SourceOutOfBounds => "Source point is outside the canvas".to_string(),
|
||||
CloneError::DestinationNotEditable => {
|
||||
"Active layer is not an editable raster layer".to_string()
|
||||
}
|
||||
CloneError::LayerMaskEditingUnsupported => {
|
||||
"Clone Stamp does not support editing layer masks".to_string()
|
||||
}
|
||||
CloneError::StrokeAlreadyActive => "A Clone Stamp stroke is already active".to_string(),
|
||||
CloneError::StrokeNotActive => "Clone Stamp stroke is not active".to_string(),
|
||||
CloneError::DimensionMismatch => "Canvas and raster layer dimensions differ".to_string(),
|
||||
}
|
||||
}
|
||||
@@ -154,6 +154,9 @@ pub fn panel_body<'a>(
|
||||
ts.vector_points,
|
||||
ts.vector_sides,
|
||||
ts.vector_radius,
|
||||
app.modifiers,
|
||||
crate::clone_tool::config_from_settings(ts),
|
||||
app.clone_ui.source_armed,
|
||||
)
|
||||
};
|
||||
column![doc_tab_bar, canvas]
|
||||
@@ -184,9 +187,15 @@ 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.tool_state.brush_angle,
|
||||
app.tool_state.brush_size_variance,
|
||||
app.tool_state.brush_angle_variance,
|
||||
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 +244,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,
|
||||
@@ -259,6 +272,11 @@ pub fn panel_body<'a>(
|
||||
app.bg_color,
|
||||
app.settings.tool_settings.spray_particle_size,
|
||||
app.settings.tool_settings.spray_density,
|
||||
crate::clone_tool::panel_model(
|
||||
&app.settings.tool_settings,
|
||||
doc.engine.clone_source_info(),
|
||||
&app.clone_ui,
|
||||
),
|
||||
)
|
||||
}
|
||||
PaneType::Script => crate::panels::script_panel::view(
|
||||
@@ -294,7 +312,18 @@ pub fn panel_body<'a>(
|
||||
PaneType::ToolSettings => {
|
||||
let fg = app.fg_color;
|
||||
let draft = app.documents[app.active_doc].text_draft.as_ref();
|
||||
crate::panels::tool_settings::view(&app.tool_state, &app.settings, fg, draft, colors)
|
||||
crate::panels::tool_settings::view(
|
||||
&app.tool_state,
|
||||
&app.settings,
|
||||
fg,
|
||||
draft,
|
||||
crate::clone_tool::panel_model(
|
||||
&app.settings.tool_settings,
|
||||
app.documents[app.active_doc].engine.clone_source_info(),
|
||||
&app.clone_ui,
|
||||
),
|
||||
colors,
|
||||
)
|
||||
}
|
||||
PaneType::CustomShapes => {
|
||||
let selected_index = match app.tool_state.active_tool {
|
||||
|
||||
@@ -9,6 +9,7 @@ mod brush_import;
|
||||
mod build_info;
|
||||
mod canvas;
|
||||
mod cli;
|
||||
mod clone_tool;
|
||||
mod color_picker;
|
||||
mod dialogs;
|
||||
mod dock;
|
||||
@@ -35,6 +36,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;
|
||||
@@ -101,9 +113,9 @@ const BRUSH_STYLES: &[BrushStyleEntry] = &[
|
||||
category: BrushCategory::Drawing,
|
||||
},
|
||||
BrushStyleEntry {
|
||||
name: "Star",
|
||||
name: "Star Sparkle",
|
||||
style: BrushStyle::Star,
|
||||
category: BrushCategory::Drawing,
|
||||
category: BrushCategory::Effects,
|
||||
},
|
||||
BrushStyleEntry {
|
||||
name: "Oil",
|
||||
@@ -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,302 @@ 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,
|
||||
angle: f32,
|
||||
size_variance: f32,
|
||||
angle_variance: f32,
|
||||
) -> 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,
|
||||
angle,
|
||||
roundness: size_variance,
|
||||
rotation_random: angle_variance,
|
||||
..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],
|
||||
angle: f32,
|
||||
size_variance: f32,
|
||||
angle_variance: f32,
|
||||
) -> 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,
|
||||
angle,
|
||||
size_variance,
|
||||
angle_variance,
|
||||
);
|
||||
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 +672,15 @@ pub fn view(
|
||||
current_hardness: f32,
|
||||
brush_color_variant: bool,
|
||||
brush_variant_amount: f32,
|
||||
brush_cyclic_color: bool,
|
||||
brush_cyclic_speed: f32,
|
||||
brush_angle: f32,
|
||||
brush_size_variance: f32,
|
||||
brush_angle_variance: 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 +754,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 +765,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 +812,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 +877,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 +897,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 +940,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 +955,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 +982,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 +1061,121 @@ 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);
|
||||
let is_star = current_style == BrushStyle::Star;
|
||||
let star_angle_slider = if is_star {
|
||||
Some(plain_slider(
|
||||
"Star Angle",
|
||||
brush_angle,
|
||||
0.0..=std::f32::consts::TAU,
|
||||
0.01,
|
||||
"rad",
|
||||
2,
|
||||
colors,
|
||||
Message::BrushAngleChanged,
|
||||
))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let star_size_var_slider = if is_star {
|
||||
Some(plain_slider(
|
||||
"Size Variance",
|
||||
brush_size_variance,
|
||||
0.0..=1.0,
|
||||
0.01,
|
||||
"%",
|
||||
0,
|
||||
colors,
|
||||
Message::BrushSizeVarianceChanged,
|
||||
))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let star_angle_var_slider = if is_star {
|
||||
Some(plain_slider(
|
||||
"Angle Variance",
|
||||
brush_angle_variance,
|
||||
0.0..=1.0,
|
||||
0.01,
|
||||
"%",
|
||||
0,
|
||||
colors,
|
||||
Message::BrushAngleVarianceChanged,
|
||||
))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// 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));
|
||||
// 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);
|
||||
|
||||
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,
|
||||
brush_angle,
|
||||
brush_size_variance,
|
||||
brush_angle_variance,
|
||||
);
|
||||
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))
|
||||
.height(Length::Fill),
|
||||
horizontal_rule(1),
|
||||
row![button(text("Import ABR").size(10))
|
||||
let mut panel_children: Vec<Element<'static, Message>> = vec![
|
||||
tabs.into(),
|
||||
horizontal_rule(1).into(),
|
||||
scrollable(column![grid_rows, wc_rows, media_rows, imported_rows].spacing(GRID_SPACING))
|
||||
.height(Length::Fill)
|
||||
.into(),
|
||||
horizontal_rule(1).into(),
|
||||
];
|
||||
|
||||
panel_children.push(
|
||||
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 +1194,31 @@ pub fn view(
|
||||
}
|
||||
}
|
||||
)]
|
||||
.spacing(4),
|
||||
text("Preview").size(10),
|
||||
live_preview,
|
||||
size_slider,
|
||||
opacity_slider,
|
||||
hardness_slider,
|
||||
color_variant_check,
|
||||
variant_slider,
|
||||
]
|
||||
.spacing(2)
|
||||
.padding(4);
|
||||
.spacing(GRID_SPACING)
|
||||
.into(),
|
||||
);
|
||||
panel_children.push(text("Preview").size(LABEL_SIZE + 1).into());
|
||||
panel_children.push(live_preview.into());
|
||||
panel_children.push(size_slider.into());
|
||||
panel_children.push(opacity_slider.into());
|
||||
panel_children.push(hardness_slider.into());
|
||||
panel_children.push(color_variant_check.into());
|
||||
panel_children.push(variant_slider.into());
|
||||
panel_children.push(cyclic_color_check.into());
|
||||
panel_children.push(cyclic_speed_slider.into());
|
||||
if let Some(s) = star_angle_slider {
|
||||
panel_children.push(s.into());
|
||||
}
|
||||
if let Some(s) = star_size_var_slider {
|
||||
panel_children.push(s.into());
|
||||
}
|
||||
if let Some(s) = star_angle_var_slider {
|
||||
panel_children.push(s.into());
|
||||
}
|
||||
|
||||
let panel = iced::widget::Column::with_children(panel_children)
|
||||
.spacing(ITEM_SPACING)
|
||||
.padding(3);
|
||||
|
||||
container(panel)
|
||||
.width(Length::Fill)
|
||||
@@ -903,7 +1265,7 @@ mod tests {
|
||||
presets.extend(hcie_paint_brushes::paint_presets());
|
||||
presets.extend(hcie_digital_brushes::digital_presets());
|
||||
presets.extend(hcie_watercolor_brushes::watercolor_presets());
|
||||
assert_eq!(presets.len(), 47);
|
||||
assert_eq!(presets.len(), 48);
|
||||
assert_eq!(
|
||||
presets
|
||||
.iter()
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
//! Shared Clone Stamp controls for Tool Settings and Properties.
|
||||
//!
|
||||
//! ## Purpose
|
||||
//! Renders one consistent object-removal workflow in both active property
|
||||
//! surfaces. Every interaction emits a dedicated application message.
|
||||
|
||||
use crate::app::Message;
|
||||
use crate::clone_tool::{source_status, ClonePanelModel};
|
||||
use crate::theme::ThemeColors;
|
||||
use crate::widgets::plain_slider::plain_slider;
|
||||
use iced::widget::{button, checkbox, column, row, text};
|
||||
use iced::Length;
|
||||
|
||||
/// Build Clone Stamp source actions and configurable brush controls.
|
||||
pub fn view(model: ClonePanelModel, colors: ThemeColors) -> iced::widget::Column<'static, Message> {
|
||||
let source_action = if model.source.is_some() {
|
||||
"Reselect Source"
|
||||
} else {
|
||||
"Choose Source"
|
||||
};
|
||||
let mut controls = column![
|
||||
text("Object Removal").size(12),
|
||||
text("Ctrl+Alt+Click a clean area, then paint over the object.").size(10),
|
||||
text(source_status(&model)).size(10),
|
||||
row![
|
||||
button(text(source_action).size(10)).on_press(Message::CloneArmSourceSelection),
|
||||
button(text("Clear Source").size(10)).on_press(Message::CloneClearSource),
|
||||
]
|
||||
.spacing(4),
|
||||
plain_slider(
|
||||
"Size",
|
||||
model.config.size,
|
||||
1.0..=500.0,
|
||||
1.0,
|
||||
"px",
|
||||
0,
|
||||
colors,
|
||||
Message::CloneSizeChanged
|
||||
),
|
||||
plain_slider(
|
||||
"Opacity",
|
||||
model.config.opacity,
|
||||
0.0..=1.0,
|
||||
0.01,
|
||||
"%",
|
||||
0,
|
||||
colors,
|
||||
Message::CloneOpacityChanged
|
||||
),
|
||||
plain_slider(
|
||||
"Hardness",
|
||||
model.config.hardness,
|
||||
0.0..=1.0,
|
||||
0.01,
|
||||
"%",
|
||||
0,
|
||||
colors,
|
||||
Message::CloneHardnessChanged
|
||||
),
|
||||
checkbox("Aligned", model.config.aligned)
|
||||
.on_toggle(Message::CloneAlignedToggled)
|
||||
.size(11)
|
||||
.text_size(11),
|
||||
row![
|
||||
checkbox("Mirror X", model.config.mirror_x)
|
||||
.on_toggle(Message::CloneMirrorXToggled)
|
||||
.size(11)
|
||||
.text_size(11),
|
||||
checkbox("Mirror Y", model.config.mirror_y)
|
||||
.on_toggle(Message::CloneMirrorYToggled)
|
||||
.size(11)
|
||||
.text_size(11),
|
||||
]
|
||||
.spacing(8),
|
||||
checkbox("Color Variation", model.variation_enabled)
|
||||
.on_toggle(Message::CloneColorVariationToggled)
|
||||
.size(11)
|
||||
.text_size(11),
|
||||
]
|
||||
.spacing(5)
|
||||
.width(Length::Fill);
|
||||
if model.variation_enabled {
|
||||
controls = controls.push(plain_slider(
|
||||
"Variation",
|
||||
model.config.color_variation,
|
||||
0.0..=1.0,
|
||||
0.01,
|
||||
"%",
|
||||
0,
|
||||
colors,
|
||||
Message::CloneColorVariationChanged,
|
||||
));
|
||||
}
|
||||
controls
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -432,6 +432,11 @@ fn all_menus(recent_files: &[crate::app::RecentFileEntry]) -> Vec<MenuDef> {
|
||||
),
|
||||
MenuItem::separator(),
|
||||
MenuItem::disabled("Retouch"),
|
||||
MenuItem::command_with_shortcut(
|
||||
"Clone Stamp",
|
||||
"S",
|
||||
MenuCommand::SelectTool(Tool::CloneStamp),
|
||||
),
|
||||
MenuItem::command(
|
||||
"Red-eye Removal",
|
||||
MenuCommand::SelectTool(Tool::RedEyeRemoval),
|
||||
@@ -1296,6 +1301,7 @@ mod tests {
|
||||
"Tools/Crop",
|
||||
"Tools/Text",
|
||||
"Tools/Gradient",
|
||||
"Tools/Clone Stamp",
|
||||
"Tools/Red-eye Removal",
|
||||
"Tools/Spot Removal Patch",
|
||||
"Tools/Smart Patch",
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
pub mod ai_chat_panel;
|
||||
pub mod ai_script_panel;
|
||||
pub mod brushes;
|
||||
pub mod clone_tool_controls;
|
||||
pub mod custom_shapes;
|
||||
pub mod filter_params;
|
||||
pub mod filters;
|
||||
|
||||
@@ -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,
|
||||
@@ -117,6 +121,7 @@ pub fn view<'a>(
|
||||
background_color: [u8; 4],
|
||||
settings_spray_particle_size: f32,
|
||||
settings_spray_density: u32,
|
||||
clone_model: crate::clone_tool::ClonePanelModel,
|
||||
) -> Element<'a, Message> {
|
||||
// Build layer info section at the top
|
||||
let layer_section = layer_info_section(
|
||||
@@ -134,12 +139,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,
|
||||
@@ -192,6 +210,7 @@ pub fn view<'a>(
|
||||
]
|
||||
.spacing(4)
|
||||
.into(),
|
||||
Tool::CloneStamp => crate::panels::clone_tool_controls::view(clone_model, colors).into(),
|
||||
_ => text(format!("{:?}", active_tool)).size(SECTION).into(),
|
||||
};
|
||||
|
||||
@@ -511,11 +530,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 +557,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 +601,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 +640,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()
|
||||
|
||||
@@ -17,7 +17,9 @@ use crate::settings::AppSettings;
|
||||
use crate::theme::ThemeColors;
|
||||
use crate::widgets::plain_slider::plain_slider;
|
||||
use hcie_engine_api::Tool;
|
||||
use iced::widget::{button, checkbox, column, container, horizontal_rule, row, scrollable, text, text_input};
|
||||
use iced::widget::{
|
||||
button, checkbox, column, container, horizontal_rule, row, scrollable, text, text_input,
|
||||
};
|
||||
use iced::{Element, Length};
|
||||
|
||||
/// Build the tool settings panel for the active tool.
|
||||
@@ -26,6 +28,7 @@ pub fn view<'a>(
|
||||
settings: &'a AppSettings,
|
||||
fg_color: [u8; 4],
|
||||
text_draft: Option<&'a crate::app::TextDraft>,
|
||||
clone_model: crate::clone_tool::ClonePanelModel,
|
||||
colors: ThemeColors,
|
||||
) -> Element<'a, Message> {
|
||||
let content = match tool_state.active_tool {
|
||||
@@ -52,6 +55,7 @@ pub fn view<'a>(
|
||||
Tool::Move => move_settings(settings, colors),
|
||||
Tool::Gradient => gradient_settings(settings, colors),
|
||||
Tool::FloodFill => flood_fill_settings(settings, colors),
|
||||
Tool::CloneStamp => crate::panels::clone_tool_controls::view(clone_model, colors),
|
||||
_ => column![text("No settings").size(11)].spacing(4),
|
||||
};
|
||||
|
||||
@@ -134,6 +138,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 +201,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)
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ use iced::{Element, Length};
|
||||
/// Build the compact toolbar: SVG icon buttons + brush size + opacity in one row.
|
||||
pub fn view<'a>(
|
||||
tool_state: &'a crate::app::ToolState,
|
||||
_settings: &'a crate::settings::AppSettings,
|
||||
settings: &'a crate::settings::AppSettings,
|
||||
colors: ThemeColors,
|
||||
) -> Element<'a, Message> {
|
||||
let c = colors;
|
||||
@@ -47,26 +47,29 @@ pub fn view<'a>(
|
||||
.align_y(iced::Alignment::Center);
|
||||
|
||||
// ── Brush size + opacity (compact, matching egui) ──
|
||||
let clone_active = tool_state.active_tool == hcie_engine_api::Tool::CloneStamp;
|
||||
let size = if clone_active { settings.tool_settings.clone_size } else { tool_state.brush_size };
|
||||
let opacity = if clone_active { settings.tool_settings.clone_opacity } else { tool_state.brush_opacity };
|
||||
let size_sl = container(plain_slider(
|
||||
"Size",
|
||||
tool_state.brush_size,
|
||||
1.0..=200.0,
|
||||
size,
|
||||
1.0..=500.0,
|
||||
1.0,
|
||||
"px",
|
||||
0,
|
||||
colors,
|
||||
Message::BrushSizeChanged,
|
||||
move |value| if clone_active { Message::CloneSizeChanged(value) } else { Message::BrushSizeChanged(value) },
|
||||
))
|
||||
.width(110);
|
||||
let opacity_sl = container(plain_slider(
|
||||
"Opacity",
|
||||
tool_state.brush_opacity,
|
||||
opacity,
|
||||
0.0..=1.0,
|
||||
0.01,
|
||||
"%",
|
||||
0,
|
||||
colors,
|
||||
Message::BrushOpacityChanged,
|
||||
move |value| if clone_active { Message::CloneOpacityChanged(value) } else { Message::BrushOpacityChanged(value) },
|
||||
))
|
||||
.width(110);
|
||||
|
||||
|
||||
@@ -48,6 +48,26 @@ pub struct AppSettings {
|
||||
/// Tool-specific settings that persist between sessions.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ToolSettings {
|
||||
/// Clone Stamp diameter in canvas pixels.
|
||||
#[serde(default = "default_clone_size")]
|
||||
pub clone_size: f32,
|
||||
/// Clone Stamp maximum stroke opacity.
|
||||
#[serde(default = "default_clone_opacity")]
|
||||
pub clone_opacity: f32,
|
||||
/// Clone Stamp radial hard-core proportion.
|
||||
#[serde(default = "default_clone_hardness")]
|
||||
pub clone_hardness: f32,
|
||||
/// Whether the first source-to-target offset persists across strokes.
|
||||
#[serde(default = "default_clone_aligned")]
|
||||
pub clone_aligned: bool,
|
||||
#[serde(default)]
|
||||
pub clone_mirror_x: bool,
|
||||
#[serde(default)]
|
||||
pub clone_mirror_y: bool,
|
||||
#[serde(default)]
|
||||
pub clone_color_variation: bool,
|
||||
#[serde(default)]
|
||||
pub clone_color_variation_amount: f32,
|
||||
pub brush_size: f32,
|
||||
pub brush_opacity: f32,
|
||||
pub brush_hardness: f32,
|
||||
@@ -59,6 +79,21 @@ pub struct ToolSettings {
|
||||
/// Magnitude of the per-dab color variation (0.0..1.0).
|
||||
#[serde(default)]
|
||||
pub brush_variant_amount: f32,
|
||||
/// Star brush base rotation angle in radians.
|
||||
#[serde(default)]
|
||||
pub brush_angle: f32,
|
||||
/// Star brush per-dab size variance (0.0..1.0).
|
||||
#[serde(default)]
|
||||
pub brush_size_variance: f32,
|
||||
/// Star brush per-dab angle variance (0.0..1.0).
|
||||
#[serde(default)]
|
||||
pub brush_angle_variance: f32,
|
||||
/// True when the brush color should cycle through the hue over stroke distance.
|
||||
#[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,
|
||||
@@ -117,6 +152,18 @@ pub struct ToolSettings {
|
||||
fn default_vector_fill() -> bool {
|
||||
true
|
||||
}
|
||||
fn default_clone_size() -> f32 {
|
||||
40.0
|
||||
}
|
||||
fn default_clone_opacity() -> f32 {
|
||||
1.0
|
||||
}
|
||||
fn default_clone_hardness() -> f32 {
|
||||
0.75
|
||||
}
|
||||
fn default_clone_aligned() -> bool {
|
||||
true
|
||||
}
|
||||
fn default_vector_points() -> u32 {
|
||||
5
|
||||
}
|
||||
@@ -185,6 +232,14 @@ impl Default for AppSettings {
|
||||
impl Default for ToolSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
clone_size: default_clone_size(),
|
||||
clone_opacity: default_clone_opacity(),
|
||||
clone_hardness: default_clone_hardness(),
|
||||
clone_aligned: default_clone_aligned(),
|
||||
clone_mirror_x: false,
|
||||
clone_mirror_y: false,
|
||||
clone_color_variation: false,
|
||||
clone_color_variation_amount: 0.0,
|
||||
brush_size: 20.0,
|
||||
brush_opacity: 1.0,
|
||||
brush_hardness: 0.8,
|
||||
@@ -192,6 +247,11 @@ impl Default for ToolSettings {
|
||||
brush_spacing: 1.0,
|
||||
brush_color_variant: false,
|
||||
brush_variant_amount: 0.0,
|
||||
brush_angle: 0.0,
|
||||
brush_size_variance: 0.0,
|
||||
brush_angle_variance: 0.0,
|
||||
brush_cyclic_color: false,
|
||||
brush_cyclic_speed: 0.5,
|
||||
eraser_size: 20.0,
|
||||
eraser_opacity: 1.0,
|
||||
pen_size: 2.0,
|
||||
@@ -344,6 +404,11 @@ 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_angle = tool_state.brush_angle;
|
||||
self.tool_settings.brush_size_variance = tool_state.brush_size_variance;
|
||||
self.tool_settings.brush_angle_variance = tool_state.brush_angle_variance;
|
||||
self.tool_settings.brush_cyclic_color = tool_state.brush_cyclic_color;
|
||||
self.tool_settings.brush_cyclic_speed = tool_state.brush_cyclic_speed;
|
||||
self.tool_settings.last_active_tool = format!("{:?}", tool_state.active_tool);
|
||||
}
|
||||
|
||||
@@ -354,6 +419,18 @@ 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_angle = self.tool_settings.brush_angle;
|
||||
tool_state.brush_size_variance = self.tool_settings.brush_size_variance;
|
||||
tool_state.brush_angle_variance = self.tool_settings.brush_angle_variance;
|
||||
tool_state.brush_cyclic_color = self.tool_settings.brush_cyclic_color;
|
||||
tool_state.brush_cyclic_speed = self.tool_settings.brush_cyclic_speed;
|
||||
if let Some(tool) = hcie_engine_api::Tool::ALL
|
||||
.iter()
|
||||
.copied()
|
||||
.find(|tool| format!("{:?}", tool) == self.tool_settings.last_active_tool)
|
||||
{
|
||||
tool_state.active_tool = tool;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -378,10 +455,30 @@ mod tests {
|
||||
.and_then(serde_json::Value::as_object_mut)
|
||||
.expect("panel layout object")
|
||||
.remove("dock_layout");
|
||||
let tools = object
|
||||
.get_mut("tool_settings")
|
||||
.and_then(serde_json::Value::as_object_mut)
|
||||
.expect("tool settings object");
|
||||
for key in [
|
||||
"clone_size",
|
||||
"clone_opacity",
|
||||
"clone_hardness",
|
||||
"clone_aligned",
|
||||
"clone_mirror_x",
|
||||
"clone_mirror_y",
|
||||
"clone_color_variation",
|
||||
"clone_color_variation_amount",
|
||||
] {
|
||||
tools.remove(key);
|
||||
}
|
||||
|
||||
let settings: AppSettings =
|
||||
serde_json::from_value(legacy).expect("deserialize legacy settings");
|
||||
assert_eq!(settings.theme_preset, ThemePreset::Photopea);
|
||||
assert!(!settings.panel_layout.sidebar_expanded);
|
||||
assert_eq!(settings.tool_settings.clone_size, 40.0);
|
||||
assert_eq!(settings.tool_settings.clone_opacity, 1.0);
|
||||
assert_eq!(settings.tool_settings.clone_hardness, 0.75);
|
||||
assert!(settings.tool_settings.clone_aligned);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,6 +139,7 @@ pub const TOOL_SLOTS: &[ToolSlot] = &[
|
||||
},
|
||||
ToolSlot {
|
||||
tools: &[
|
||||
Tool::CloneStamp,
|
||||
Tool::SpotRemoval,
|
||||
Tool::RedEyeRemoval,
|
||||
Tool::SmartPatch,
|
||||
@@ -168,11 +169,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);
|
||||
@@ -771,6 +767,7 @@ fn tool_icon(tool: Tool) -> &'static str {
|
||||
Tool::VectorBolt => "icons/vector_bolt.svg",
|
||||
Tool::VectorArrow4 => "icons/vector_arrow4.svg",
|
||||
Tool::SpotRemoval => "icons/spot.svg",
|
||||
Tool::CloneStamp => "icons/clone_stamp.svg",
|
||||
Tool::RedEyeRemoval => "icons/redeye.svg",
|
||||
Tool::SmartPatch | Tool::AiObjectRemoval => "icons/patch.svg",
|
||||
Tool::CustomShape(_) => "icons/star.svg",
|
||||
@@ -819,6 +816,7 @@ fn tool_shortcut(tool: Tool) -> &'static str {
|
||||
Tool::FloodFill => "G",
|
||||
Tool::Gradient => "G",
|
||||
Tool::Text => "T",
|
||||
Tool::CloneStamp => "S",
|
||||
Tool::VectorLine => "U",
|
||||
Tool::VectorRect => "U",
|
||||
Tool::VectorCircle => "U",
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
//! Directory tree navigation panel.
|
||||
//! Directory tree navigation panel with collapse/expand support.
|
||||
//!
|
||||
//! Displays a collapsible tree of filesystem directories with standard
|
||||
//! locations (Home, Desktop, Documents, Downloads, Pictures, Videos, Root)
|
||||
//! as top-level roots. Each node shows its subdirectories when expanded.
|
||||
//! as top-level roots. Each node shows its subdirectories when expanded,
|
||||
//! controlled by the `expanded_nodes` set in `ViewerState`.
|
||||
//! Includes a functional scrollbar wrapping the entire tree.
|
||||
|
||||
use crate::app::Message;
|
||||
use crate::theme::ThemeColors;
|
||||
use crate::viewer::ViewerState;
|
||||
use iced::widget::{button, column, container, scrollable, text};
|
||||
use iced::widget::{button, column, container, scrollable, text, Space};
|
||||
use iced::{Element, Length};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
@@ -82,7 +84,7 @@ fn color_alpha(color: iced::Color, alpha: f32) -> iced::Color {
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the directory tree view.
|
||||
/// Build the directory tree view with a functional scrollbar.
|
||||
pub fn view(state: &ViewerState, colors: ThemeColors) -> Element<'static, Message> {
|
||||
let roots = get_roots();
|
||||
let mut items = column![].spacing(2);
|
||||
@@ -97,7 +99,11 @@ pub fn view(state: &ViewerState, colors: ThemeColors) -> Element<'static, Messag
|
||||
.into()
|
||||
}
|
||||
|
||||
/// Recursively draw a tree node with its children.
|
||||
/// Recursively draw a tree node with expand/collapse support.
|
||||
///
|
||||
/// Nodes with children show a toggle arrow. Clicking the arrow toggles
|
||||
/// expansion state. Clicking the folder name navigates to that directory.
|
||||
/// Children are only rendered when the node is in the `expanded_nodes` set.
|
||||
fn draw_tree_node(
|
||||
path: &Path,
|
||||
name: &str,
|
||||
@@ -107,33 +113,100 @@ fn draw_tree_node(
|
||||
depth: usize,
|
||||
) -> Element<'static, Message> {
|
||||
let is_selected = state.current_dir == path;
|
||||
let is_expanded = state.expanded_nodes.contains(path);
|
||||
let indent = depth as f32 * 16.0;
|
||||
|
||||
let subdirs = get_subdirs_for_path(path);
|
||||
let has_children = !subdirs.is_empty();
|
||||
|
||||
let label = format!("{} {}", icon, name);
|
||||
let label_color = if is_selected {
|
||||
colors.accent
|
||||
} else {
|
||||
colors.text_primary
|
||||
};
|
||||
|
||||
let label_text = text(label.clone())
|
||||
.size(12)
|
||||
.style(move |_theme| iced::widget::text::Style {
|
||||
color: Some(label_color),
|
||||
});
|
||||
if has_children {
|
||||
let arrow = if is_expanded { "\u{25BC}" } else { "\u{25B6}" };
|
||||
let arrow_text = text(format!("{} ", arrow))
|
||||
.size(10)
|
||||
.style(move |_theme| iced::widget::text::Style {
|
||||
color: Some(colors.text_secondary),
|
||||
});
|
||||
|
||||
let row_content: Element<'static, Message> = if has_children {
|
||||
let mut children_col = column![].spacing(1);
|
||||
for child in &subdirs {
|
||||
if let Some(child_name) = child.file_name().and_then(|n| n.to_str()) {
|
||||
let child_node = draw_tree_node(child, child_name, ">", state, colors, depth + 1);
|
||||
children_col = children_col.push(child_node);
|
||||
let arrow_btn = button(arrow_text)
|
||||
.on_press(Message::ViewerToggleNode(path.to_path_buf()))
|
||||
.width(iced::Length::Fixed(20.0))
|
||||
.style(
|
||||
move |_theme: &iced::Theme, _status: iced::widget::button::Status| {
|
||||
iced::widget::button::Style {
|
||||
background: Some(iced::Background::Color(iced::Color::TRANSPARENT)),
|
||||
text_color: colors.text_secondary,
|
||||
border: iced::Border::default(),
|
||||
..Default::default()
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
let label = format!("{} {}", icon, name);
|
||||
let label_text = text(label)
|
||||
.size(12)
|
||||
.style(move |_theme| iced::widget::text::Style {
|
||||
color: Some(label_color),
|
||||
});
|
||||
|
||||
let nav_btn = button(label_text)
|
||||
.on_press(Message::ViewerNavigate(path.to_path_buf()))
|
||||
.width(Length::Fill)
|
||||
.style(
|
||||
move |_theme: &iced::Theme, _status: iced::widget::button::Status| {
|
||||
iced::widget::button::Style {
|
||||
background: Some(iced::Background::Color(if is_selected {
|
||||
color_alpha(colors.accent, 0.15)
|
||||
} else {
|
||||
iced::Color::TRANSPARENT
|
||||
})),
|
||||
text_color: label_color,
|
||||
border: iced::Border::default(),
|
||||
..Default::default()
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
let header_row = iced::widget::row![arrow_btn, nav_btn]
|
||||
.width(Length::Fill)
|
||||
.align_y(iced::Alignment::Center);
|
||||
|
||||
let mut content = column![header_row].spacing(1).width(Length::Fill);
|
||||
|
||||
if is_expanded {
|
||||
let mut children_col = column![].spacing(1);
|
||||
for child in &subdirs {
|
||||
if let Some(child_name) = child.file_name().and_then(|n| n.to_str()) {
|
||||
let child_node =
|
||||
draw_tree_node(child, child_name, ">", state, colors, depth + 1);
|
||||
children_col = children_col.push(child_node);
|
||||
}
|
||||
}
|
||||
content = content.push(children_col);
|
||||
}
|
||||
|
||||
container(content)
|
||||
.width(Length::Fill)
|
||||
.padding(iced::Padding {
|
||||
top: 0.0,
|
||||
right: 0.0,
|
||||
bottom: 0.0,
|
||||
left: indent,
|
||||
})
|
||||
.into()
|
||||
} else {
|
||||
let label = format!("{} {}", icon, name);
|
||||
let label_text = text(label)
|
||||
.size(12)
|
||||
.style(move |_theme| iced::widget::text::Style {
|
||||
color: Some(label_color),
|
||||
});
|
||||
|
||||
let btn = button(label_text)
|
||||
.on_press(Message::ViewerNavigate(path.to_path_buf()))
|
||||
.width(Length::Fill)
|
||||
@@ -152,29 +225,11 @@ fn draw_tree_node(
|
||||
},
|
||||
);
|
||||
|
||||
column![btn, children_col].spacing(1).into()
|
||||
} else {
|
||||
button(label_text)
|
||||
.on_press(Message::ViewerNavigate(path.to_path_buf()))
|
||||
.width(Length::Fill)
|
||||
.style(
|
||||
move |_theme: &iced::Theme, _status: iced::widget::button::Status| {
|
||||
iced::widget::button::Style {
|
||||
background: Some(iced::Background::Color(if is_selected {
|
||||
color_alpha(colors.accent, 0.15)
|
||||
} else {
|
||||
iced::Color::TRANSPARENT
|
||||
})),
|
||||
text_color: label_color,
|
||||
border: iced::Border::default(),
|
||||
..Default::default()
|
||||
}
|
||||
},
|
||||
)
|
||||
.into()
|
||||
};
|
||||
|
||||
container(row_content)
|
||||
container(
|
||||
iced::widget::row![Space::with_width(iced::Length::Fixed(20.0)), btn]
|
||||
.width(Length::Fill)
|
||||
.align_y(iced::Alignment::Center),
|
||||
)
|
||||
.width(Length::Fill)
|
||||
.padding(iced::Padding {
|
||||
top: 0.0,
|
||||
@@ -183,6 +238,7 @@ fn draw_tree_node(
|
||||
left: indent,
|
||||
})
|
||||
.into()
|
||||
}
|
||||
}
|
||||
|
||||
/// Get subdirectories of a path (non-cached version for the view).
|
||||
|
||||
@@ -25,13 +25,47 @@ const SUPPORTED_EXTENSIONS: &[&str] = &[
|
||||
"hdr", "dds", "tga", "exr",
|
||||
];
|
||||
|
||||
/// Return the standard root directories that should be pre-expanded in the tree.
|
||||
fn standard_root_dirs() -> Vec<PathBuf> {
|
||||
let mut dirs = Vec::new();
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
dirs.push(home);
|
||||
}
|
||||
if let Some(desktop) = dirs::desktop_dir() {
|
||||
dirs.push(desktop);
|
||||
}
|
||||
if let Some(docs) = dirs::document_dir() {
|
||||
dirs.push(docs);
|
||||
}
|
||||
if let Some(downloads) = dirs::download_dir() {
|
||||
dirs.push(downloads);
|
||||
}
|
||||
if let Some(pics) = dirs::picture_dir() {
|
||||
dirs.push(pics);
|
||||
}
|
||||
if let Some(vids) = dirs::video_dir() {
|
||||
dirs.push(vids);
|
||||
}
|
||||
dirs
|
||||
}
|
||||
|
||||
/// Return the default directory for first launch — the system Pictures folder,
|
||||
/// falling back to the home directory, then current directory.
|
||||
pub fn default_start_dir() -> PathBuf {
|
||||
dirs::picture_dir()
|
||||
.or_else(dirs::home_dir)
|
||||
.unwrap_or_else(|| PathBuf::from("."))
|
||||
}
|
||||
|
||||
/// Viewer display mode — Browser shows the dual-pane directory view,
|
||||
/// Viewer shows a single image with navigation arrows.
|
||||
/// Viewer shows a single image with navigation arrows,
|
||||
/// Navigation shows a directory tree with a large preview pane.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum ViewerMode {
|
||||
#[default]
|
||||
Browser,
|
||||
Viewer,
|
||||
Navigation,
|
||||
}
|
||||
|
||||
/// Persistent viewer state — directory listing, thumbnail cache, zoom/pan.
|
||||
@@ -46,6 +80,8 @@ pub struct ViewerState {
|
||||
pub view_mode: ViewerMode,
|
||||
/// Whether the viewer is in fullscreen mode.
|
||||
pub fullscreen: bool,
|
||||
/// Paths that are expanded in the directory tree (collapse/expand state).
|
||||
pub expanded_nodes: HashSet<PathBuf>,
|
||||
/// Cached subdirectory lists to avoid repeated disk reads.
|
||||
pub subdirs_cache: HashMap<PathBuf, Vec<PathBuf>>,
|
||||
/// Cached thumbnail RGBA pixel data (path → pixels, width, height).
|
||||
@@ -66,12 +102,17 @@ pub struct ViewerState {
|
||||
|
||||
impl Default for ViewerState {
|
||||
fn default() -> Self {
|
||||
let mut expanded_nodes = HashSet::new();
|
||||
for root in standard_root_dirs() {
|
||||
expanded_nodes.insert(root);
|
||||
}
|
||||
Self {
|
||||
current_dir: PathBuf::new(),
|
||||
images: Vec::new(),
|
||||
active_image_idx: 0,
|
||||
view_mode: ViewerMode::Browser,
|
||||
fullscreen: false,
|
||||
expanded_nodes,
|
||||
subdirs_cache: HashMap::new(),
|
||||
thumbnail_cache: HashMap::new(),
|
||||
failed_thumbnails: HashSet::new(),
|
||||
@@ -99,6 +140,15 @@ impl ViewerState {
|
||||
state
|
||||
}
|
||||
|
||||
/// Toggle a directory tree node's expanded/collapsed state.
|
||||
pub fn toggle_node(&mut self, path: &Path) {
|
||||
if self.expanded_nodes.contains(path) {
|
||||
self.expanded_nodes.remove(path);
|
||||
} else {
|
||||
self.expanded_nodes.insert(path.to_path_buf());
|
||||
}
|
||||
}
|
||||
|
||||
/// Refresh the images list in the current directory.
|
||||
pub fn refresh_images(&mut self) {
|
||||
log::debug!("Refreshing image list for: {}", self.current_dir.display());
|
||||
@@ -267,11 +317,12 @@ fn _fit_size(texture_w: f32, texture_h: f32, avail_w: f32, avail_h: f32) -> (f32
|
||||
}
|
||||
|
||||
/// Build the viewer panel UI element.
|
||||
pub fn view(state: &ViewerState, colors: ThemeColors) -> Element<'static, Message> {
|
||||
let header = view_header(state, colors);
|
||||
pub fn view(state: &ViewerState, colors: ThemeColors, is_light: bool) -> Element<'static, Message> {
|
||||
let header = view_header(state, colors, is_light);
|
||||
let body = match state.view_mode {
|
||||
ViewerMode::Browser => view_browser(state, colors),
|
||||
ViewerMode::Viewer => view_viewer(state, colors),
|
||||
ViewerMode::Navigation => view_navigation(state, colors),
|
||||
};
|
||||
column![header, horizontal_rule(1), body]
|
||||
.width(Length::Fill)
|
||||
@@ -279,8 +330,8 @@ pub fn view(state: &ViewerState, colors: ThemeColors) -> Element<'static, Messag
|
||||
.into()
|
||||
}
|
||||
|
||||
/// Header toolbar with mode toggle, fullscreen toggle, and exit button.
|
||||
fn view_header(state: &ViewerState, colors: ThemeColors) -> Element<'static, Message> {
|
||||
/// Header toolbar with mode toggle, fullscreen toggle, theme toggle, and exit button.
|
||||
fn view_header(state: &ViewerState, colors: ThemeColors, is_light: bool) -> Element<'static, Message> {
|
||||
let exit_btn = button(text(" Exit Viewer "))
|
||||
.on_press(Message::ViewerToggle)
|
||||
.style(
|
||||
@@ -356,6 +407,28 @@ fn view_header(state: &ViewerState, colors: ThemeColors) -> Element<'static, Mes
|
||||
},
|
||||
);
|
||||
|
||||
let is_nav = state.view_mode == ViewerMode::Navigation;
|
||||
let nav_btn = button(text(" Navigate "))
|
||||
.on_press(Message::ViewerSetMode(ViewerMode::Navigation))
|
||||
.style(
|
||||
move |_theme: &iced::Theme, _status: iced::widget::button::Status| {
|
||||
iced::widget::button::Style {
|
||||
background: Some(iced::Background::Color(if is_nav {
|
||||
colors.accent
|
||||
} else {
|
||||
colors.bg_panel
|
||||
})),
|
||||
text_color: if is_nav {
|
||||
iced::Color::WHITE
|
||||
} else {
|
||||
colors.text_primary
|
||||
},
|
||||
border: iced::Border::default().color(colors.border_low).width(1),
|
||||
..Default::default()
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
let refresh_btn = button(text(" Refresh "))
|
||||
.on_press(Message::ViewerRefresh)
|
||||
.style(
|
||||
@@ -369,6 +442,20 @@ fn view_header(state: &ViewerState, colors: ThemeColors) -> Element<'static, Mes
|
||||
},
|
||||
);
|
||||
|
||||
let theme_label = if is_light { " Light " } else { " Dark " };
|
||||
let theme_btn = button(text(theme_label))
|
||||
.on_press(Message::ViewerThemeToggle)
|
||||
.style(
|
||||
move |_theme: &iced::Theme, _status: iced::widget::button::Status| {
|
||||
iced::widget::button::Style {
|
||||
background: Some(iced::Background::Color(colors.bg_panel)),
|
||||
text_color: colors.text_primary,
|
||||
border: iced::Border::default().color(colors.border_low).width(1),
|
||||
..Default::default()
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
row![
|
||||
exit_btn,
|
||||
text(" "),
|
||||
@@ -376,8 +463,11 @@ fn view_header(state: &ViewerState, colors: ThemeColors) -> Element<'static, Mes
|
||||
text(" "),
|
||||
browser_btn,
|
||||
viewer_btn,
|
||||
nav_btn,
|
||||
text(" "),
|
||||
refresh_btn,
|
||||
Space::with_width(Length::Fill),
|
||||
theme_btn,
|
||||
]
|
||||
.align_y(iced::Alignment::Center)
|
||||
.width(Length::Fill)
|
||||
@@ -594,6 +684,180 @@ fn view_right_panel(state: &ViewerState, colors: ThemeColors) -> Element<'static
|
||||
.into()
|
||||
}
|
||||
|
||||
/// Navigation mode — directory tree on the left with a large preview pane on the right.
|
||||
fn view_navigation(state: &ViewerState, colors: ThemeColors) -> Element<'static, Message> {
|
||||
let tree_panel = {
|
||||
let tree_content = directory_tree::view(state, colors);
|
||||
container(
|
||||
column![
|
||||
text("Folders").style(move |_theme| iced::widget::text::Style {
|
||||
color: Some(colors.text_primary),
|
||||
}),
|
||||
horizontal_rule(1),
|
||||
tree_content,
|
||||
]
|
||||
.spacing(4)
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill),
|
||||
)
|
||||
.width(300)
|
||||
.height(Length::Fill)
|
||||
.style(move |_theme| iced::widget::container::Style {
|
||||
background: Some(iced::Background::Color(colors.bg_panel)),
|
||||
border: iced::Border::default()
|
||||
.color(colors.border_low)
|
||||
.width(1)
|
||||
.rounded(8),
|
||||
..Default::default()
|
||||
})
|
||||
};
|
||||
|
||||
let preview_panel = view_large_preview(state, colors);
|
||||
|
||||
row![tree_panel, preview_panel]
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
.spacing(4)
|
||||
.into()
|
||||
}
|
||||
|
||||
/// Large preview panel for Navigation mode — shows the selected image with prev/next controls.
|
||||
fn view_large_preview(state: &ViewerState, colors: ThemeColors) -> Element<'static, Message> {
|
||||
if state.images.is_empty() {
|
||||
return container(
|
||||
column![
|
||||
text("No images found in this folder.").style(move |_theme| {
|
||||
iced::widget::text::Style {
|
||||
color: Some(colors.text_secondary),
|
||||
}
|
||||
}),
|
||||
]
|
||||
.align_x(iced::Alignment::Center),
|
||||
)
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
.center_x(Length::Fill)
|
||||
.center_y(Length::Fill)
|
||||
.into();
|
||||
}
|
||||
|
||||
let total = state.images.len();
|
||||
let current_idx = state.active_image_idx;
|
||||
let filename = state
|
||||
.images
|
||||
.get(current_idx)
|
||||
.and_then(|p| p.file_name())
|
||||
.map(|n| n.to_string_lossy().to_string())
|
||||
.unwrap_or_default();
|
||||
|
||||
let edit_path = state.images[current_idx].clone();
|
||||
let info_bar = row![
|
||||
text(filename.clone()).style(move |_theme| iced::widget::text::Style {
|
||||
color: Some(colors.text_primary),
|
||||
}),
|
||||
Space::with_width(Length::Fill),
|
||||
text(format!("{} / {}", current_idx + 1, total)).style(move |_theme| {
|
||||
iced::widget::text::Style {
|
||||
color: Some(colors.text_secondary),
|
||||
}
|
||||
}),
|
||||
text(" "),
|
||||
button(text("Edit"))
|
||||
.on_press(Message::ViewerOpenFile(edit_path))
|
||||
.style(
|
||||
move |_theme: &iced::Theme, _status: iced::widget::button::Status| {
|
||||
iced::widget::button::Style {
|
||||
background: Some(iced::Background::Color(colors.accent)),
|
||||
text_color: iced::Color::WHITE,
|
||||
border: iced::Border::default().color(colors.accent).width(1),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
),
|
||||
]
|
||||
.align_y(iced::Alignment::Center)
|
||||
.width(Length::Fill);
|
||||
|
||||
let image_area: Element<'static, Message> = if let Some((pixels, w, h)) = &state.preview_pixels
|
||||
{
|
||||
let handle: iced::widget::image::Handle =
|
||||
iced::widget::image::Handle::from_rgba(*w, *h, pixels.clone());
|
||||
let img = iced::widget::Image::new(handle)
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill);
|
||||
|
||||
let prev_btn = button(text(" < ")).on_press(Message::ViewerPrev).style(
|
||||
move |_theme: &iced::Theme, _status: iced::widget::button::Status| {
|
||||
iced::widget::button::Style {
|
||||
background: Some(iced::Background::Color(iced::Color::from_rgba(
|
||||
0.0, 0.0, 0.0, 0.5,
|
||||
))),
|
||||
text_color: iced::Color::WHITE,
|
||||
border: iced::Border::default().rounded(8),
|
||||
..Default::default()
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
let next_btn = button(text(" > ")).on_press(Message::ViewerNext).style(
|
||||
move |_theme: &iced::Theme, _status: iced::widget::button::Status| {
|
||||
iced::widget::button::Style {
|
||||
background: Some(iced::Background::Color(iced::Color::from_rgba(
|
||||
0.0, 0.0, 0.0, 0.5,
|
||||
))),
|
||||
text_color: iced::Color::WHITE,
|
||||
border: iced::Border::default().rounded(8),
|
||||
..Default::default()
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
row![prev_btn, img, next_btn]
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
.into()
|
||||
} else if let Some(err) = &state.load_error {
|
||||
column![
|
||||
text(format!("Failed to load: {}", err)).style(move |_theme| {
|
||||
iced::widget::text::Style {
|
||||
color: Some(colors.danger),
|
||||
}
|
||||
})
|
||||
]
|
||||
.align_x(iced::Alignment::Center)
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
.into()
|
||||
} else {
|
||||
column![
|
||||
text("Loading...").style(move |_theme| iced::widget::text::Style {
|
||||
color: Some(colors.text_secondary),
|
||||
})
|
||||
]
|
||||
.align_x(iced::Alignment::Center)
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
.into()
|
||||
};
|
||||
|
||||
container(
|
||||
column![info_bar, horizontal_rule(1), image_area]
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill),
|
||||
)
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
.style(move |_theme| iced::widget::container::Style {
|
||||
background: Some(iced::Background::Color(colors.bg_panel)),
|
||||
border: iced::Border::default()
|
||||
.color(colors.border_low)
|
||||
.width(1)
|
||||
.rounded(8),
|
||||
..Default::default()
|
||||
})
|
||||
.into()
|
||||
}
|
||||
|
||||
/// Viewer mode — single image with navigation arrows and zoom.
|
||||
fn view_viewer(state: &ViewerState, colors: ThemeColors) -> Element<'static, Message> {
|
||||
if state.images.is_empty() {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -43,6 +43,7 @@ pub enum Tool {
|
||||
AiObjectRemoval,
|
||||
SmartSelect,
|
||||
VisionSelect,
|
||||
CloneStamp,
|
||||
CustomShape(u32),
|
||||
}
|
||||
|
||||
@@ -61,6 +62,7 @@ impl Tool {
|
||||
Tool::Gradient, Tool::Move, Tool::Crop,
|
||||
Tool::RedEyeRemoval, Tool::SpotRemoval, Tool::SmartPatch,
|
||||
Tool::AiObjectRemoval, Tool::SmartSelect, Tool::VisionSelect,
|
||||
Tool::CloneStamp,
|
||||
];
|
||||
|
||||
pub fn label(self) -> &'static str {
|
||||
@@ -101,6 +103,7 @@ impl Tool {
|
||||
Tool::AiObjectRemoval => "AI Object Removal",
|
||||
Tool::SmartSelect => "Smart Select",
|
||||
Tool::VisionSelect => "Vision Tools",
|
||||
Tool::CloneStamp => "Clone Stamp",
|
||||
Tool::CustomShape(_) => "Custom Shape",
|
||||
}
|
||||
}
|
||||
@@ -143,12 +146,13 @@ impl Tool {
|
||||
Tool::AiObjectRemoval => "🪄",
|
||||
Tool::SmartSelect => "🎯",
|
||||
Tool::VisionSelect => "👁",
|
||||
Tool::CloneStamp => "C",
|
||||
Tool::CustomShape(_) => "○",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_raster(self) -> bool {
|
||||
matches!(self, Tool::Pen | Tool::Brush | Tool::Eraser | Tool::FloodFill | Tool::Spray)
|
||||
matches!(self, Tool::Pen | Tool::Brush | Tool::Eraser | Tool::FloodFill | Tool::Spray | Tool::CloneStamp)
|
||||
}
|
||||
|
||||
pub fn is_ai_tool(self) -> bool {
|
||||
@@ -172,7 +176,7 @@ impl Tool {
|
||||
}
|
||||
|
||||
pub fn shows_pen_tips(self) -> bool {
|
||||
matches!(self, Tool::Pen | Tool::Brush | Tool::Eraser | Tool::Spray)
|
||||
matches!(self, Tool::Pen | Tool::Brush | Tool::Eraser | Tool::Spray | Tool::CloneStamp)
|
||||
}
|
||||
|
||||
pub fn is_selection_tool(self) -> bool {
|
||||
@@ -203,11 +207,11 @@ impl Tool {
|
||||
}
|
||||
|
||||
pub fn has_size(self) -> bool {
|
||||
matches!(self, Tool::Pen | Tool::Brush | Tool::Eraser | Tool::Spray | Tool::SpotRemoval)
|
||||
matches!(self, Tool::Pen | Tool::Brush | Tool::Eraser | Tool::Spray | Tool::SpotRemoval | Tool::CloneStamp)
|
||||
}
|
||||
|
||||
pub fn has_opacity(self) -> bool {
|
||||
matches!(self, Tool::Pen | Tool::Brush | Tool::Eraser | Tool::Spray | Tool::Gradient)
|
||||
matches!(self, Tool::Pen | Tool::Brush | Tool::Eraser | Tool::Spray | Tool::Gradient | Tool::CloneStamp)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -104,7 +104,7 @@ fn test_blend_mode_all_contains_all() {
|
||||
|
||||
#[test]
|
||||
fn test_all_tools_listed() {
|
||||
assert_eq!(Tool::ALL.len(), 36, "should have 36 tools");
|
||||
assert_eq!(Tool::ALL.len(), 37, "should have 37 tools");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -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,8 +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"
|
||||
|
||||
BIN
Binary file not shown.
@@ -0,0 +1,241 @@
|
||||
use hcie_protocol::tools::{TextAlignment, TextOrientation, TextEffect};
|
||||
use hcie_text::TextRenderer;
|
||||
|
||||
/// Integration tests for hcie-text text rendering.
|
||||
///
|
||||
/// Covers: font loading, font management, rasterization baseline,
|
||||
/// error handling for missing fonts, fallback behavior,
|
||||
/// create_text_layer and refresh_text_layer.
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper: load a built-in font for testing
|
||||
// ---------------------------------------------------------------------------
|
||||
fn setup_renderer() -> (TextRenderer, bool) {
|
||||
let mut renderer = TextRenderer::new();
|
||||
let font_data = include_bytes!("../tests/fixtures/Inter-Regular.ttf");
|
||||
let ok = renderer.load_font("Inter", font_data).is_ok();
|
||||
(renderer, ok)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Font management
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn new_renderer_has_no_fonts() {
|
||||
let renderer = TextRenderer::new();
|
||||
assert!(renderer.get_fonts().is_empty(), "new renderer should have no fonts");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_font_succeeds_with_valid_data() {
|
||||
let mut renderer = TextRenderer::new();
|
||||
// embed a small TTF for testing — must exist at this path
|
||||
let font_data = include_bytes!("../tests/fixtures/Inter-Regular.ttf");
|
||||
let result = renderer.load_font("Inter", font_data);
|
||||
assert!(result.is_ok(), "loading a valid TTF should succeed: {:?}", result.err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_font_fails_with_invalid_data() {
|
||||
let mut renderer = TextRenderer::new();
|
||||
let result = renderer.load_font("Invalid", &[0u8, 1, 2, 3]);
|
||||
assert!(result.is_err(), "loading garbage data as a font should fail");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn has_font_returns_true_after_loading() {
|
||||
let mut renderer = TextRenderer::new();
|
||||
let font_data = include_bytes!("../tests/fixtures/Inter-Regular.ttf");
|
||||
if renderer.load_font("Inter", font_data).is_ok() {
|
||||
assert!(renderer.has_font("Inter"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn has_font_returns_false_for_unloaded() {
|
||||
let renderer = TextRenderer::new();
|
||||
assert!(!renderer.has_font("NonexistentFont"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_fonts_returns_loaded_names() {
|
||||
let mut renderer = TextRenderer::new();
|
||||
let font_data = include_bytes!("../tests/fixtures/Inter-Regular.ttf");
|
||||
if renderer.load_font("MyFont", font_data).is_ok() {
|
||||
let fonts = renderer.get_fonts();
|
||||
assert!(fonts.contains(&"MyFont".to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Text rasterization (baseline — if font is available)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn rasterize_text_returns_valid_output() {
|
||||
let (mut renderer, font_ok) = setup_renderer();
|
||||
if !font_ok {
|
||||
eprintln!("Skipping: test font not available");
|
||||
return;
|
||||
}
|
||||
|
||||
let result = renderer.rasterize_text(
|
||||
"Hello",
|
||||
"Inter",
|
||||
24.0,
|
||||
[0, 0, 0, 255], // black
|
||||
0.0, 0.0, // x, y
|
||||
0.0, // angle
|
||||
TextAlignment::Left,
|
||||
TextOrientation::Horizontal,
|
||||
&[], // no effects
|
||||
true, // anti-alias
|
||||
);
|
||||
|
||||
assert!(result.is_ok(), "rasterize should succeed: {:?}", result.err());
|
||||
|
||||
let (pixels, w, h, _ox, _oy, _uw, _uh) = result.unwrap();
|
||||
assert!(w > 0, "rasterized width should be > 0");
|
||||
assert!(h > 0, "rasterized height should be > 0");
|
||||
assert_eq!(pixels.len(), (w * h * 4) as usize);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rasterize_empty_string_produces_no_pixels_or_minimal() {
|
||||
let (mut renderer, font_ok) = setup_renderer();
|
||||
if !font_ok { return; }
|
||||
|
||||
let result = renderer.rasterize_text(
|
||||
"", "Inter", 24.0, [0, 0, 0, 255],
|
||||
0.0, 0.0, 0.0, TextAlignment::Left,
|
||||
TextOrientation::Horizontal, &[], true,
|
||||
);
|
||||
|
||||
// Empty string may return an error or a 0-size buffer; either is acceptable
|
||||
match result {
|
||||
Ok((pixels, w, h, _, _, _, _)) => {
|
||||
assert!(w == 0 || h == 0 || pixels.is_empty(),
|
||||
"empty string should produce trivial output: {}x{}", w, h);
|
||||
}
|
||||
Err(_) => { /* empty string may legitimately fail */ }
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rasterize_returns_different_output_for_different_sizes() {
|
||||
let (mut renderer, font_ok) = setup_renderer();
|
||||
if !font_ok { return; }
|
||||
|
||||
let small = renderer.rasterize_text(
|
||||
"A", "Inter", 12.0, [0, 0, 0, 255],
|
||||
0.0, 0.0, 0.0, TextAlignment::Left,
|
||||
TextOrientation::Horizontal, &[], true,
|
||||
).unwrap();
|
||||
let large = renderer.rasterize_text(
|
||||
"A", "Inter", 48.0, [0, 0, 0, 255],
|
||||
0.0, 0.0, 0.0, TextAlignment::Left,
|
||||
TextOrientation::Horizontal, &[], true,
|
||||
).unwrap();
|
||||
|
||||
// Larger font size should produce taller output
|
||||
assert!(large.2 >= small.2, "larger font should produce taller output");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Color handling
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn rasterize_text_applies_color() {
|
||||
let (mut renderer, font_ok) = setup_renderer();
|
||||
if !font_ok { return; }
|
||||
|
||||
let result = renderer.rasterize_text(
|
||||
"X", "Inter", 24.0, [255, 0, 0, 255], // red
|
||||
0.0, 0.0, 0.0, TextAlignment::Left,
|
||||
TextOrientation::Horizontal, &[], true,
|
||||
);
|
||||
|
||||
if let Ok((pixels, w, h, _, _, _, _)) = result {
|
||||
if w > 0 && h > 0 {
|
||||
// At least some pixels should have non-zero red channel
|
||||
let has_red = pixels.iter().step_by(4).any(|&r| r > 0);
|
||||
assert!(has_red, "text rasterized in red should have non-zero R channel");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// create_text_layer
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn create_text_layer_produces_valid_layer() {
|
||||
let (mut renderer, font_ok) = setup_renderer();
|
||||
if !font_ok { return; }
|
||||
|
||||
let result = renderer.create_text_layer(
|
||||
"Hello", "Inter", 24.0, [0, 0, 0, 255],
|
||||
10.0, 20.0, 0.0, TextAlignment::Left,
|
||||
TextOrientation::Horizontal, &[],
|
||||
);
|
||||
|
||||
assert!(result.is_ok(), "create_text_layer should succeed: {:?}", result.err());
|
||||
let layer = result.unwrap();
|
||||
assert!(layer.width > 0);
|
||||
assert!(layer.height > 0);
|
||||
assert!(layer.pixels.len() >= 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_text_layer_fails_for_missing_font() {
|
||||
let mut renderer = TextRenderer::new();
|
||||
let result = renderer.create_text_layer(
|
||||
"Hello", "NonExistentFont", 24.0, [0, 0, 0, 255],
|
||||
0.0, 0.0, 0.0, TextAlignment::Left,
|
||||
TextOrientation::Horizontal, &[],
|
||||
);
|
||||
assert!(result.is_err(), "should fail when font is not loaded");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Error handling
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn rasterize_fails_for_missing_font() {
|
||||
let mut renderer = TextRenderer::new();
|
||||
let result = renderer.rasterize_text(
|
||||
"Hi", "AbsentFont", 12.0, [0; 4],
|
||||
0.0, 0.0, 0.0, TextAlignment::Left,
|
||||
TextOrientation::Horizontal, &[], true,
|
||||
);
|
||||
assert!(result.is_err(), "rasterize with missing font should fail");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// refresh_text_layer
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn refresh_text_layer_does_not_panic() {
|
||||
let (mut renderer, font_ok) = setup_renderer();
|
||||
if !font_ok { return; }
|
||||
|
||||
let mut layer = match renderer.create_text_layer(
|
||||
"Test", "Inter", 16.0, [0, 0, 0, 255],
|
||||
0.0, 0.0, 0.0, TextAlignment::Left,
|
||||
TextOrientation::Horizontal, &[],
|
||||
) {
|
||||
Ok(l) => l,
|
||||
Err(e) => {
|
||||
eprintln!("Skipping refresh test: cannot create layer: {}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let result = renderer.refresh_text_layer(&mut layer);
|
||||
assert!(result.is_ok() || result.is_err());
|
||||
// Either outcome is valid depending on the layer's state
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
use hcie_tile::*;
|
||||
|
||||
/// Integration tests for hcie-tile sparse tile storage.
|
||||
///
|
||||
/// Covers: tile creation, pixel read/write, tile key mapping,
|
||||
/// sparse storage behavior, from_dense / to_dense roundtrip,
|
||||
/// update_tiles_in_region, composite_into, edge cases.
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tile (single 256×256 block) tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn new_transparent_tile_is_all_zeros() {
|
||||
let tile = Tile::new_transparent();
|
||||
assert_eq!(tile.pixels.len(), TILE_BYTES);
|
||||
// Spot-check a few positions
|
||||
assert_eq!(tile.pixels[0], 0);
|
||||
assert_eq!(tile.pixels[3], 0);
|
||||
assert_eq!(tile.pixels[TILE_BYTES - 1], 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_blank_tile_is_white_and_opaque() {
|
||||
let tile = Tile::new_blank();
|
||||
assert_eq!(tile.pixels.len(), TILE_BYTES);
|
||||
// Every pixel should be [255, 255, 255, 255]
|
||||
for i in 0..10 {
|
||||
let base = i * 4;
|
||||
assert_eq!(tile.pixels[base], 255, "R should be 255 at pixel {}", i);
|
||||
assert_eq!(tile.pixels[base + 1], 255, "G should be 255 at pixel {}", i);
|
||||
assert_eq!(tile.pixels[base + 2], 255, "B should be 255 at pixel {}", i);
|
||||
assert_eq!(tile.pixels[base + 3], 255, "A should be 255 at pixel {}", i);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TiledLayer creation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn new_tiled_layer_has_correct_dimensions() {
|
||||
let tl = TiledLayer::new(512, 256);
|
||||
assert_eq!(tl.width(), 512);
|
||||
assert_eq!(tl.height(), 256);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_tiled_layer_has_zero_tiles() {
|
||||
let tl = TiledLayer::new(512, 256);
|
||||
assert_eq!(tl.tile_count(), 0, "empty layer should have no allocated tiles");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_dense_size_matches() {
|
||||
let pixels = vec![255u8; 64 * 64 * 4];
|
||||
let tl = TiledLayer::from_dense(&pixels, 64, 64);
|
||||
assert_eq!(tl.width(), 64);
|
||||
assert_eq!(tl.height(), 64);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_dense_creates_tiles_for_non_transparent_data() {
|
||||
let pixels = vec![255u8; 256 * 256 * 4]; // fully opaque, exactly one tile
|
||||
let tl = TiledLayer::from_dense(&pixels, 256, 256);
|
||||
assert_eq!(tl.tile_count(), 1, "fully opaque 256x256 should produce exactly 1 tile");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_dense_prunes_transparent_tiles() {
|
||||
let pixels = vec![0u8; 256 * 256 * 4]; // fully transparent, exactly one tile
|
||||
let tl = TiledLayer::from_dense(&pixels, 256, 256);
|
||||
assert_eq!(
|
||||
tl.tile_count(),
|
||||
0,
|
||||
"fully transparent layer should have no allocated tiles"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tile key mapping
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn tile_key_maps_pixel_zero_to_zero_zero() {
|
||||
let key = TiledLayer::tile_key(0, 0);
|
||||
assert_eq!(key, (0, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tile_key_maps_pixel_255_to_0() {
|
||||
let key = TiledLayer::tile_key(255, 255);
|
||||
assert_eq!(key, (0, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tile_key_maps_pixel_256_to_1() {
|
||||
let key = TiledLayer::tile_key(256, 256);
|
||||
assert_eq!(key, (1, 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tile_key_maps_large_coordinates() {
|
||||
let key = TiledLayer::tile_key(1000, 2000);
|
||||
assert_eq!(key, (1000 / TILE_SIZE, 2000 / TILE_SIZE));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pixel read/write
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn get_pixel_outside_allocated_tile_returns_transparent() {
|
||||
let tl = TiledLayer::new(512, 512);
|
||||
let pixel = tl.get_pixel(100, 100);
|
||||
assert_eq!(pixel, [0, 0, 0, 0], "unwritten pixel should be transparent");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_pixel_and_get_pixel_roundtrip() {
|
||||
let mut tl = TiledLayer::new(64, 64);
|
||||
tl.set_pixel(10, 10, [255, 128, 64, 200]);
|
||||
let pixel = tl.get_pixel(10, 10);
|
||||
assert_eq!(pixel, [255, 128, 64, 200]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_pixel_out_of_bounds_is_noop() {
|
||||
let mut tl = TiledLayer::new(64, 64);
|
||||
tl.set_pixel(100, 100, [255, 0, 0, 255]); // outside layer bounds
|
||||
assert_eq!(tl.tile_count(), 0, "out-of-bounds set should not create tiles");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_pixel_auto_creates_tile() {
|
||||
let mut tl = TiledLayer::new(256, 256);
|
||||
tl.set_pixel(0, 0, [255, 0, 0, 255]);
|
||||
assert_eq!(tl.tile_count(), 1, "setting a pixel should create the containing tile");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_pixel_written_twice_returns_last_value() {
|
||||
let mut tl = TiledLayer::new(64, 64);
|
||||
tl.set_pixel(5, 5, [10, 20, 30, 40]);
|
||||
tl.set_pixel(5, 5, [50, 60, 70, 80]);
|
||||
assert_eq!(tl.get_pixel(5, 5), [50, 60, 70, 80]);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// to_dense roundtrip
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn to_dense_returns_correct_size() {
|
||||
let tl = TiledLayer::new(64, 64);
|
||||
let dense = tl.to_dense();
|
||||
assert_eq!(dense.len(), (64 * 64 * 4) as usize);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn to_dense_after_set_pixel_preserves_value() {
|
||||
let mut tl = TiledLayer::new(16, 16);
|
||||
tl.set_pixel(7, 8, [100, 150, 200, 250]);
|
||||
let dense = tl.to_dense();
|
||||
let idx = ((8 * 16 + 7) * 4) as usize;
|
||||
assert_eq!(dense[idx], 100);
|
||||
assert_eq!(dense[idx + 1], 150);
|
||||
assert_eq!(dense[idx + 2], 200);
|
||||
assert_eq!(dense[idx + 3], 250);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_dense_to_dense_roundtrip() {
|
||||
let original = vec![42u8; 128 * 128 * 4];
|
||||
let tl = TiledLayer::from_dense(&original, 128, 128);
|
||||
let result = tl.to_dense();
|
||||
assert_eq!(original, result, "from_dense → to_dense should be identity");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_dense_to_dense_roundtrip_transparent() {
|
||||
let original = vec![0u8; 128 * 128 * 4];
|
||||
let tl = TiledLayer::from_dense(&original, 128, 128);
|
||||
let result = tl.to_dense();
|
||||
assert_eq!(original, result, "transparent from_dense → to_dense should be identity");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// update_tiles_in_region
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn update_tiles_in_region_writes_pixels() {
|
||||
let mut tl = TiledLayer::new(64, 64);
|
||||
let mut pixels = vec![0u8; 64 * 64 * 4];
|
||||
// Set a block of pixels to red
|
||||
for y in 10..20 {
|
||||
for x in 10..20 {
|
||||
let idx = ((y * 64 + x) * 4) as usize;
|
||||
pixels[idx] = 255;
|
||||
pixels[idx + 3] = 255;
|
||||
}
|
||||
}
|
||||
tl.update_tiles_in_region(&pixels, 64, 0, 0, 64, 64);
|
||||
assert_eq!(tl.get_pixel(15, 15), [255, 0, 0, 255], "updated pixel should be red");
|
||||
assert_eq!(tl.get_pixel(0, 0), [0, 0, 0, 0], "pixel outside update region stays unchanged");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_tiles_in_region_partial_update() {
|
||||
let mut tl = TiledLayer::new(256, 256);
|
||||
let mut pixels = vec![0u8; 64 * 64 * 4];
|
||||
for i in (0..pixels.len()).step_by(4) {
|
||||
pixels[i] = 255;
|
||||
pixels[i + 3] = 255;
|
||||
}
|
||||
// Update only the sub-region (0,0)-(64,64)
|
||||
tl.update_tiles_in_region(&pixels, 64, 0, 0, 64, 64);
|
||||
assert_eq!(tl.get_pixel(32, 32), [255, 0, 0, 255]);
|
||||
assert_eq!(tl.get_pixel(100, 100), [0, 0, 0, 0], "pixels outside region should be zero");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// composite_into
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn composite_into_copies_pixels() {
|
||||
let mut tl = TiledLayer::new(32, 32);
|
||||
tl.set_pixel(5, 5, [100, 150, 200, 255]);
|
||||
|
||||
let mut output = vec![0u8; 32 * 32 * 4];
|
||||
tl.composite_into(&mut output, 32, 32, 0, 0, 32, 32);
|
||||
|
||||
let idx = ((5 * 32 + 5) * 4) as usize;
|
||||
assert_eq!(output[idx], 100);
|
||||
assert_eq!(output[idx + 1], 150);
|
||||
assert_eq!(output[idx + 2], 200);
|
||||
assert_eq!(output[idx + 3], 255);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn composite_into_respects_region_bounds() {
|
||||
let mut tl = TiledLayer::new(64, 64);
|
||||
tl.set_pixel(30, 30, [255, 0, 0, 255]);
|
||||
|
||||
let mut output = vec![0u8; 64 * 64 * 4];
|
||||
// Composite only the top-left 16×16 region
|
||||
tl.composite_into(&mut output, 64, 64, 0, 0, 16, 16);
|
||||
|
||||
// Pixel at (30,30) is outside the region and should not be copied
|
||||
let idx = ((30 * 64 + 30) * 4) as usize;
|
||||
assert_eq!(
|
||||
output[idx..idx + 4],
|
||||
[0, 0, 0, 0],
|
||||
"pixel outside region should not appear in output"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Edge cases
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn zero_sized_layer_has_no_tiles() {
|
||||
let tl = TiledLayer::new(0, 0);
|
||||
assert_eq!(tl.tile_count(), 0);
|
||||
assert_eq!(tl.width(), 0);
|
||||
assert_eq!(tl.height(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn very_large_layer_does_not_panic() {
|
||||
let tl = TiledLayer::new(4096, 4096);
|
||||
assert_eq!(tl.width(), 4096);
|
||||
assert_eq!(tl.height(), 4096);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tile_count_increases_with_written_area() {
|
||||
let mut tl = TiledLayer::new(512, 512);
|
||||
assert_eq!(tl.tile_count(), 0);
|
||||
|
||||
// Write one pixel in two different tiles
|
||||
tl.set_pixel(0, 0, [1, 1, 1, 1]);
|
||||
assert_eq!(tl.tile_count(), 1);
|
||||
|
||||
tl.set_pixel(300, 300, [2, 2, 2, 2]);
|
||||
assert_eq!(tl.tile_count(), 2, "pixels in different tiles should create two tiles");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tiles_are_independent() {
|
||||
let mut tl = TiledLayer::new(512, 512);
|
||||
tl.set_pixel(0, 0, [10, 20, 30, 40]);
|
||||
tl.set_pixel(300, 300, [50, 60, 70, 80]);
|
||||
|
||||
assert_eq!(tl.get_pixel(0, 0), [10, 20, 30, 40]);
|
||||
assert_eq!(tl.get_pixel(300, 300), [50, 60, 70, 80]);
|
||||
// neighboring pixel should be untouched
|
||||
assert_eq!(tl.get_pixel(1, 0), [0, 0, 0, 0]);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Serde roundtrip (if Tile is Serialize/Deserialize)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn tile_serde_json_roundtrip() {
|
||||
let tile = Tile::new_blank();
|
||||
let json = serde_json::to_string(&tile).expect("serialize tile");
|
||||
let restored: Tile = serde_json::from_str(&json).expect("deserialize tile");
|
||||
assert_eq!(tile.pixels, restored.pixels, "JSON serde roundtrip should preserve pixels");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tile_bincode_roundtrip() {
|
||||
let tile = Tile::new_blank();
|
||||
let bytes = bincode::serialize(&tile).expect("serialize tile via bincode");
|
||||
let restored: Tile = bincode::deserialize(&bytes).expect("deserialize tile via bincode");
|
||||
assert_eq!(tile.pixels, restored.pixels, "bincode serde roundtrip should preserve pixels");
|
||||
}
|
||||
@@ -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)
|
||||
=========================================
|
||||
|
||||
+59
@@ -2,7 +2,66 @@ 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
|
||||
To run only the new proximity dedup tests:
|
||||
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
|
||||
|
||||
To watch tests and re-run on file changes, use:
|
||||
cargo watch -x "test -p hcie-iced-gui -- proximate"
|
||||
|
||||
(Requires cargo install cargo-watch if not installed.)
|
||||
|
||||
To check compilation (without running tests):
|
||||
cargo check -p hcie-iced-gui
|
||||
|
||||
I suggest adding a Makefile target or shell alias for quick invocation:
|
||||
|
||||
# ~/.bashrc or ~/.zshrc
|
||||
alias ci-iced='cd /mnt/extra/00_PROJECTS/hcie-rust-v3.05 && cargo test -p hcie-iced-gui'
|
||||
alias ci-iced-check='cd /mnt/extra/00_PROJECTS/hcie-rust-
|
||||
|
||||
##TAURI
|
||||
npm komutunu proje kökünden değil, hcie-tauri-app/ altından çalıştırmalısın:
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
@echo off
|
||||
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 DisableDelayedExpansion
|
||||
|
||||
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 "!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
|
||||
|
||||
call :RUN_CRATE "hcie-protocol"
|
||||
call :RUN_CRATE "hcie-color"
|
||||
|
||||
call :RUN_CRATE "hcie-blend"
|
||||
call :RUN_CRATE "hcie-brush-engine"
|
||||
call :RUN_CRATE "hcie-draw"
|
||||
call :RUN_CRATE "hcie-composite"
|
||||
call :RUN_CRATE "hcie-filter"
|
||||
call :RUN_CRATE "hcie-selection"
|
||||
call :RUN_CRATE "hcie-vector"
|
||||
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 "psd"
|
||||
call :RUN_CRATE "hcie-vision"
|
||||
call :RUN_CRATE "hcie-build-info"
|
||||
|
||||
if %SKIP_4K%==1 (
|
||||
call :RUN_CRATE "hcie-engine-api" --skip benchmark_4k_stroke_on_multilayer_document
|
||||
) else (
|
||||
call :RUN_CRATE "hcie-engine-api"
|
||||
)
|
||||
|
||||
call :RUN_CRATE "hcie-gui-egui"
|
||||
call :RUN_CRATE "hcie-iced-gui"
|
||||
|
||||
call :RUN_CRATE "hcie-dry-media-brushes"
|
||||
call :RUN_CRATE "hcie-paint-brushes"
|
||||
call :RUN_CRATE "hcie-digital-brushes"
|
||||
call :RUN_CRATE "hcie-watercolor-brushes"
|
||||
call :RUN_CRATE "hcie-ink-brushes"
|
||||
|
||||
if %INCLUDE_IGNORED%==1 (
|
||||
echo.
|
||||
echo ===== Running IGNORED tests =====
|
||||
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 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_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!" !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
|
||||
Executable
+174
@@ -0,0 +1,174 @@
|
||||
#!/usr/bin/env bash
|
||||
# ============================================================
|
||||
# HCIE-Rust v3.05 — Bulk All-Tests Execution Script (bash)
|
||||
# Usage: bash run_all_tests.sh [--no-io] [--no-4k] [--ignored]
|
||||
#
|
||||
# Flags:
|
||||
# --no-io Skip hcie-io tests (slow, needs PSD fixtures)
|
||||
# --no-4k Skip the 4K performance benchmark
|
||||
# --ignored Also run #[ignore] tests (visual checks, benchmarks)
|
||||
# ============================================================
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
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
|
||||
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
|
||||
|
||||
SEPARATOR() { printf '%*s\n' 80 '' | tr ' ' '='; }
|
||||
|
||||
run_crate() {
|
||||
local crate="$1"
|
||||
local label="$2"
|
||||
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
|
||||
printf '[%s] Running:' "$label"
|
||||
printf ' %q' "${cmd[@]}"
|
||||
printf '\n'
|
||||
SEPARATOR
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
display_summary() {
|
||||
local elapsed=$(( $(date +%s) - START_TIME ))
|
||||
echo ""
|
||||
SEPARATOR
|
||||
echo " BULK TEST EXECUTION COMPLETE"
|
||||
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"
|
||||
exit "$FAIL"
|
||||
}
|
||||
|
||||
# ──────────────────────────────────────────────────────────
|
||||
# Layer 1: DATA
|
||||
# ──────────────────────────────────────────────────────────
|
||||
run_crate "hcie-protocol" "Layer 1"
|
||||
run_crate "hcie-color" "Layer 1"
|
||||
|
||||
# ──────────────────────────────────────────────────────────
|
||||
# Layer 2: ENGINE CORE
|
||||
# ──────────────────────────────────────────────────────────
|
||||
run_crate "hcie-blend" "Layer 2"
|
||||
run_crate "hcie-brush-engine" "Layer 2"
|
||||
run_crate "hcie-draw" "Layer 2"
|
||||
run_crate "hcie-composite" "Layer 2"
|
||||
run_crate "hcie-filter" "Layer 2"
|
||||
run_crate "hcie-selection" "Layer 2"
|
||||
run_crate "hcie-vector" "Layer 2"
|
||||
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 "psd" "Layer 2"
|
||||
run_crate "hcie-vision" "Layer 2"
|
||||
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
|
||||
else
|
||||
run_crate "hcie-engine-api" "Layer 4"
|
||||
fi
|
||||
|
||||
# ──────────────────────────────────────────────────────────
|
||||
# Layer 6: GUI — egui
|
||||
# ──────────────────────────────────────────────────────────
|
||||
run_crate "hcie-gui-egui" "Layer 6"
|
||||
|
||||
# ──────────────────────────────────────────────────────────
|
||||
# Layer 6: GUI — iced
|
||||
# ──────────────────────────────────────────────────────────
|
||||
run_crate "hcie-iced-gui" "Layer 6"
|
||||
|
||||
# ──────────────────────────────────────────────────────────
|
||||
# Brush Catalogs
|
||||
# ──────────────────────────────────────────────────────────
|
||||
run_crate "hcie-dry-media-brushes" "Brushes"
|
||||
run_crate "hcie-paint-brushes" "Brushes"
|
||||
run_crate "hcie-digital-brushes" "Brushes"
|
||||
run_crate "hcie-watercolor-brushes" "Brushes"
|
||||
run_crate "hcie-ink-brushes" "Brushes"
|
||||
|
||||
# ──────────────────────────────────────────────────────────
|
||||
# Ignored tests (visual checks, benchmarks)
|
||||
# ──────────────────────────────────────────────────────────
|
||||
if [ "$INCLUDE_IGNORED" = true ]; then
|
||||
echo ""
|
||||
SEPARATOR
|
||||
echo "Running IGNORED tests (visual checks, benchmarks)"
|
||||
SEPARATOR
|
||||
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
|
||||
@@ -0,0 +1,142 @@
|
||||
@echo off
|
||||
REM ============================================================
|
||||
REM HCIE-Rust v3.05 — Categorized Test Runner (Windows .bat)
|
||||
REM Usage: run_tests_categorized.bat [--no-io] [--no-4k] [--ignored]
|
||||
REM ============================================================
|
||||
setlocal DisableDelayedExpansion
|
||||
|
||||
set "ROOT_DIR=%~dp0"
|
||||
cd /d "%ROOT_DIR%" || exit /b 1
|
||||
setlocal EnableDelayedExpansion
|
||||
|
||||
echo ============================================================
|
||||
echo HCIE-Rust v3.05 — Categorized Test Runner
|
||||
echo Root: %ROOT_DIR%
|
||||
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 "!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
|
||||
|
||||
call :RUN_CATEGORY "Layer 1: DATA" hcie-protocol hcie-color
|
||||
call :RUN_CATEGORY "Layer 2: Blend/Brush" hcie-blend hcie-brush-engine
|
||||
call :RUN_CATEGORY "Layer 2: Draw/Composite/Filter" hcie-draw hcie-composite hcie-filter
|
||||
call :RUN_CATEGORY "Layer 2: Selection/Vector/History" hcie-selection hcie-vector 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_CATEGORY "Layer 2: PSD/Vision/Build" psd hcie-vision hcie-build-info
|
||||
|
||||
if %SKIP_4K%==1 (
|
||||
call :RUN_CRATE hcie-engine-api --skip benchmark_4k_stroke_on_multilayer_document
|
||||
) else (
|
||||
call :RUN_CRATE hcie-engine-api
|
||||
)
|
||||
|
||||
call :RUN_CATEGORY "Layer 6: GUI egui" hcie-gui-egui
|
||||
call :RUN_CATEGORY "Layer 6: GUI iced" hcie-iced-gui
|
||||
call :RUN_CATEGORY "Brush Catalogs" hcie-dry-media-brushes hcie-paint-brushes hcie-digital-brushes hcie-watercolor-brushes hcie-ink-brushes
|
||||
|
||||
if %INCLUDE_IGNORED%==1 (
|
||||
echo.
|
||||
echo ===== Running IGNORED tests =====
|
||||
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% 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%
|
||||
|
||||
:RUN_CATEGORY
|
||||
set CATEGORY=%~1
|
||||
shift
|
||||
echo.
|
||||
echo ===== Category: %CATEGORY% =====
|
||||
:RUN_CATEGORY_LOOP
|
||||
if "%~1"=="" goto :EOF
|
||||
call :RUN_CRATE %~1
|
||||
shift
|
||||
goto RUN_CATEGORY_LOOP
|
||||
|
||||
:RUN_CRATE
|
||||
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
|
||||
@@ -0,0 +1,139 @@
|
||||
#!/usr/bin/env bash
|
||||
# ============================================================
|
||||
# HCIE-Rust v3.05 — Categorized Test Runner (Linux shell)
|
||||
# Usage: bash run_tests_categorized.sh [--no-io] [--no-4k] [--ignored]
|
||||
# ============================================================
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
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
|
||||
|
||||
SEPARATOR() { printf '%*s\n' 80 '' | tr ' ' '='; }
|
||||
|
||||
run_crate() {
|
||||
local crate="$1"
|
||||
local label="$2"
|
||||
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
|
||||
printf '[%s] Running:' "$label"
|
||||
printf ' %q' "${cmd[@]}"
|
||||
printf '\n'
|
||||
SEPARATOR
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
# ── Layer 1: DATA ──────────────────────────────────────────
|
||||
run_crate "hcie-protocol" "DATA"
|
||||
run_crate "hcie-color" "DATA"
|
||||
|
||||
# ── Layer 2: ENGINE CORE ───────────────────────────────────
|
||||
run_crate "hcie-blend" "CORE"
|
||||
run_crate "hcie-brush-engine" "CORE"
|
||||
run_crate "hcie-draw" "CORE"
|
||||
run_crate "hcie-composite" "CORE"
|
||||
run_crate "hcie-filter" "CORE"
|
||||
run_crate "hcie-selection" "CORE"
|
||||
run_crate "hcie-vector" "CORE"
|
||||
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 "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
|
||||
else
|
||||
run_crate "hcie-engine-api" "ENGINE-API"
|
||||
fi
|
||||
|
||||
# ── Layer 6: GUI ───────────────────────────────────────────
|
||||
run_crate "hcie-gui-egui" "GUI-egui"
|
||||
run_crate "hcie-iced-gui" "GUI-iced"
|
||||
|
||||
# ── Brush Catalogs ─────────────────────────────────────────
|
||||
run_crate "hcie-dry-media-brushes" "Brushes"
|
||||
run_crate "hcie-paint-brushes" "Brushes"
|
||||
run_crate "hcie-digital-brushes" "Brushes"
|
||||
run_crate "hcie-watercolor-brushes" "Brushes"
|
||||
run_crate "hcie-ink-brushes" "Brushes"
|
||||
|
||||
# ── Ignored tests (visual checks, benchmarks) ──────────────
|
||||
if [ "$INCLUDE_IGNORED" = true ]; then
|
||||
echo ""
|
||||
SEPARATOR
|
||||
echo "Running IGNORED tests (visual checks, benchmarks)"
|
||||
SEPARATOR
|
||||
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 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"
|
||||
@@ -0,0 +1,54 @@
|
||||
@echo off
|
||||
REM Runs one Cargo command with one synchronized HCIE build-number increment (Windows)
|
||||
REM Usage: scripts\cargo-with-build-id.bat <cargo-subcommand> [arguments...]
|
||||
|
||||
setlocal enabledelayedexpansion
|
||||
|
||||
set ROOT_DIR=%~dp0..\
|
||||
set LOCK_DIR=%ROOT_DIR%.build-id.lock
|
||||
set COUNTER_FILE=%ROOT_DIR%build.id
|
||||
|
||||
if "%~1"=="" (
|
||||
echo usage: scripts\cargo-with-build-id.bat ^<cargo-subcommand^> [arguments...]
|
||||
exit /b 2
|
||||
)
|
||||
|
||||
REM Acquire lock (Windows-friendly using a temp file)
|
||||
:acquire_lock
|
||||
2>nul (
|
||||
>>"%LOCK_DIR%\lock.tmp" echo(%DATE% %TIME%
|
||||
) || (
|
||||
REM Lock exists; wait and retry
|
||||
timeout /t 1 /nobreak >nul
|
||||
goto :acquire_lock
|
||||
)
|
||||
del "%LOCK_DIR%\lock.tmp" 2>nul
|
||||
|
||||
REM Read current build ID
|
||||
if exist "%COUNTER_FILE%" (
|
||||
set /p current=<"%COUNTER_FILE%"
|
||||
) else (
|
||||
set current=0
|
||||
)
|
||||
|
||||
REM Validate it's a number
|
||||
echo %current%| findstr /r "^[0-9][0-9]*$" >nul
|
||||
if errorlevel 1 (
|
||||
echo build.id must contain one unsigned integer, found: %current%
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
set /a next=current+1
|
||||
|
||||
REM Write counter files
|
||||
echo %next%>"%COUNTER_FILE%"
|
||||
echo %next%>"%ROOT_DIR%hcie-egui-app\build.id"
|
||||
echo %next%>"%ROOT_DIR%hcie-egui-app\crates\hcie-gui-egui\build.id"
|
||||
|
||||
set HCIE_BUILD_ID_OVERRIDE=%next%
|
||||
echo HCIE build %next%: cargo %*
|
||||
|
||||
cd /d "%ROOT_DIR%"
|
||||
cargo %*
|
||||
|
||||
exit /b %ERRORLEVEL%
|
||||
@@ -0,0 +1,18 @@
|
||||
@echo off
|
||||
REM Install repository Git hooks (Windows)
|
||||
|
||||
for /f "delims=" %%i in ('git rev-parse --show-toplevel') do set REPO_ROOT=%%i
|
||||
cd /d "%REPO_ROOT%"
|
||||
|
||||
git config --local core.hooksPath .githooks
|
||||
|
||||
set CONFIGURED=
|
||||
for /f "delims=" %%i in ('git config --local --get core.hooksPath') do set CONFIGURED=%%i
|
||||
|
||||
if not "%CONFIGURED%"==".githooks" (
|
||||
echo Failed to configure repository Git hooks.
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo Repository hooks installed: core.hooksPath=.githooks
|
||||
exit /b 0
|
||||
@@ -0,0 +1,35 @@
|
||||
@echo off
|
||||
REM Pre-commit test gate for Windows
|
||||
REM Inspects staged .rs files and runs affected crate tests
|
||||
|
||||
setlocal enabledelayedexpansion
|
||||
|
||||
REM Get staged .rs files
|
||||
set STAGED_FILES=
|
||||
for /f "delims=" %%f in ('git diff --cached --name-only --diff-filter=ACMR -- "*.rs"') do (
|
||||
if exist "%%f" set STAGED_FILES=!STAGED_FILES! %%f
|
||||
)
|
||||
|
||||
if "%STAGED_FILES%"=="" (
|
||||
echo No staged Rust files; commit regression tests are not required.
|
||||
exit /b 0
|
||||
)
|
||||
|
||||
echo Checking formatting...
|
||||
for %%f in (%STAGED_FILES%) do (
|
||||
rustfmt --edition 2021 --check "%%f"
|
||||
if errorlevel 1 (
|
||||
echo Formatting check failed for %%f
|
||||
exit /b 1
|
||||
)
|
||||
)
|
||||
|
||||
echo Running deterministic workspace tests...
|
||||
cargo check --locked --workspace --exclude hcie-io --examples
|
||||
if errorlevel 1 exit /b %errorlevel%
|
||||
|
||||
cargo test --locked --workspace --exclude hcie-io --lib --tests -- --skip benchmark_4k_stroke_on_multilayer_document
|
||||
if errorlevel 1 exit /b %errorlevel%
|
||||
|
||||
echo Mandatory Rust regression gate passed.
|
||||
exit /b 0
|
||||
@@ -0,0 +1,541 @@
|
||||
# HCIE-Rust v3.05 — Test Analysis
|
||||
|
||||
> Generated: 2026-07-23
|
||||
> Project root: `/mnt/extra/00_PROJECTS/hcie-rust-v3.05`
|
||||
|
||||
---
|
||||
|
||||
## 1. Status Assessment
|
||||
|
||||
### 1.1 Active Tests (fully functional, run by default)
|
||||
|
||||
All `#[test]` functions without `#[ignore]` that do not depend on missing fixture files.
|
||||
**Approximately ~400 tests** across 24 crates.
|
||||
|
||||
### 1.2 Inactive, Skipped, or Conditionally Disabled Tests
|
||||
|
||||
| Test | Crate | File:Line | Reason |
|
||||
|------|-------|-----------|--------|
|
||||
| `meadow_brush_visual_check` | `hcie-engine-api` | tests/meadow_check.rs:18 | `#[ignore = "manual visual check"]` — requires human inspection of output PNG |
|
||||
| `leaves_brush_visual_check` | `hcie-engine-api` | tests/leaves_check.rs:11 | `#[ignore = "manual visual check"]` — requires human inspection of output PNG |
|
||||
| `stamp_floor_brush_visual_check` | `hcie-engine-api` | tests/stamp_floor_check.rs:11 | `#[ignore = "manual visual check"]` — requires human inspection of output PNG |
|
||||
| `diagnostic_watercolor_sample_latency` | `hcie-brush-engine` | src/lib.rs:4447 | `#[ignore = "diagnostic benchmark"]` — performance measurement, not pass/fail |
|
||||
| `diagnostic_4k_dirty_staging_latency` | `hcie-iced-gui` | src/canvas/texture_update.rs:253 | `#[ignore = "diagnostic benchmark"]` — performance measurement, not pass/fail |
|
||||
| `test_parse_asl_emboss` | `hcie-fx` | src/parser.rs:1671 | `#[ignore = "requires /tmp/kra_test/emboss_full.asl fixture"]` — missing fixture |
|
||||
| `test_psd_composite_against_ref` | `hcie-io` | lib.rs:43 | Runtime skip if `Example3-mini.psd` not found (IS found → active) |
|
||||
| `test_base_generated_2_vs_merged` | `hcie-io` | lib.rs:301 | Runtime skip if `base_test_generated_2.psd` not found (MISSING → inactive) |
|
||||
| `test_base_generated_2_no_effects` | `hcie-io` | lib.rs:378 | Runtime skip if `base_test_generated_2.psd` not found (MISSING → inactive) |
|
||||
| `test_base_generated_2_composite` | `hcie-io` | lib.rs:420 | Runtime skip if `base_test_generated_2.psd` not found (MISSING → inactive) |
|
||||
| `test_hc_emboss_composite` | `hcie-io` | lib.rs:428 | Runtime skip if `hc_emboss.psd` not found (MISSING → inactive) |
|
||||
| `test_base_generated_2_effect_isolation` | `hcie-io` | lib.rs:438 | Runtime skip if `base_test_generated_2.psd` not found (MISSING → inactive) |
|
||||
| `test_check_soft_orange_pixels` | `hcie-io` | lib.rs:491 | Runtime skip if `base_test_generated_2.psd` not found (MISSING → inactive) |
|
||||
| `test_find_orange_pixels` | `hcie-io` | lib.rs:516 | Runtime skip if `base_test_generated_2.psd` not found (MISSING → inactive) |
|
||||
| `test_layer_bounds` | `hcie-io` | lib.rs:555 | Runtime skip if `base_test_generated_2.psd` not found (MISSING → inactive) |
|
||||
| `test_check_layer_offsets` | `hcie-io` | lib.rs:584 | Runtime skip if `base_test_generated_2.psd` not found (MISSING → inactive) |
|
||||
| `test_save_layer_pixels` | `hcie-io` | lib.rs:621 | Runtime skip if `base_test_generated_2.psd` not found (MISSING → inactive) |
|
||||
| `test_pixel_layer_contributions` | `hcie-io` | lib.rs:635 | Runtime skip if `base_test_generated_2.psd` not found (MISSING → inactive) |
|
||||
| `test_per_layer_effects_vs_photoshop_export` | `hcie-io` | lib.rs:696 | Runtime skip if base PSD not found (MISSING → inactive) |
|
||||
| `test_sultan_effects_mae` | `hcie-io` | lib.rs:861 | Runtime skip if `sultan.psd` not found (MISSING → inactive) |
|
||||
| `test_emboss_optimize` | `hcie-io` | lib.rs:1057 | Runtime skip if `emboss.psd` not found (FOUND → active if it uses `emboss.psd`) |
|
||||
| `test_effects_survive_ffi_boundary` | `hcie-io` | lib.rs:1246 | Runtime skip if base PSD not found (MISSING → inactive) |
|
||||
| `test_ffi_bincode_roundtrip` | `hcie-io` | lib.rs:1276 | Runtime skip if base PSD not found (MISSING → inactive) |
|
||||
|
||||
**Total conditionally inactive: ~17 tests** (mostly `hcie-io` PSD composite tests needing `_images/_psd_stil_test/` fixtures that do not exist in this environment).
|
||||
|
||||
### 1.3 Notable: Expensive / Long-Running Active Tests
|
||||
|
||||
| Test | Crate | Est. Time | Notes |
|
||||
|------|-------|-----------|-------|
|
||||
| `benchmark_4k_stroke_on_multilayer_document` | `hcie-engine-api` | ~60s | 10 layers on 3840×2160, 100 stroke segments with per-segment timing |
|
||||
| `test_load_test_2_psd` | `hcie-io` | ~30-60s | Loads and composites all PSDs from test_2 |
|
||||
| `test_emboss_optimize` | `hcie-io` | ~30-60s | Detailed emboss analysis with line scans, heatmaps |
|
||||
| `test_psd_composite_against_ref` | `hcie-io` | ~10-30s | Full PSD composite against reference PNG |
|
||||
| `test_layer_pixels_direct` | `hcie-io` | ~10-30s | Per-layer pixel comparison |
|
||||
|
||||
---
|
||||
|
||||
## 2. Command Generation
|
||||
|
||||
### 2.1 Per-Crate Test Commands
|
||||
|
||||
#### Layer 1: DATA
|
||||
|
||||
```bash
|
||||
# hcie-protocol — 16 tests (color packing, constants, lerp, distance, thumbnail, selection, blend modes, tools, version)
|
||||
cargo test -p hcie-protocol
|
||||
|
||||
# hcie-color — 11 tests (gamma encode/decode, sRGB/linear, RGB/HSL)
|
||||
cargo test -p hcie-color
|
||||
```
|
||||
|
||||
#### Layer 2: ENGINE CORE
|
||||
|
||||
```bash
|
||||
# hcie-blend — 9 tests (opacity identity, multiply darkens, screen lightens, all modes)
|
||||
cargo test -p hcie-blend
|
||||
|
||||
# hcie-brush-engine — 13 tests (6 stamp + 7 internal)
|
||||
# (includes 1 ignored diagnostic benchmark)
|
||||
cargo test -p hcie-brush-engine
|
||||
cargo test -p hcie-brush-engine -- --ignored # includes diagnostic_watercolor_sample_latency
|
||||
|
||||
# hcie-draw — 8 tests (rect, circle, ellipse, line, flood fill, mask)
|
||||
cargo test -p hcie-draw
|
||||
|
||||
# hcie-composite — 5 tests (pass through, visibility toggle, basic composite)
|
||||
cargo test -p hcie-composite
|
||||
|
||||
# hcie-filter — 13 tests (blur, gaussian, motion, invert, grayscale, sharpen, emboss, etc.)
|
||||
cargo test -p hcie-filter
|
||||
|
||||
# hcie-selection — 15 tests (rect/ellipse mask, invert, grow, shrink, feather, magic wand, lasso)
|
||||
cargo test -p hcie-selection
|
||||
|
||||
# hcie-vector — 4 tests (crescent, bubble, rotate point, SVG rotation)
|
||||
cargo test -p hcie-vector
|
||||
|
||||
# hcie-history — 9 tests (new empty, push/undo/redo, max steps, jump to, entry description)
|
||||
cargo test -p hcie-history
|
||||
|
||||
# hcie-fx — 1 ignored test (ASL emboss parser — requires fixture)
|
||||
cargo test -p hcie-fx
|
||||
cargo test -p hcie-fx -- --ignored # includes test_parse_asl_emboss
|
||||
|
||||
# hcie-io — ~24 tests (PSD composite, layer analysis, effects)
|
||||
# Many will runtime-skip if _images/ fixtures are missing
|
||||
# Can be very slow (up to several minutes)
|
||||
cargo test -p hcie-io
|
||||
|
||||
# hcie-psd — 8 tests (signature validation, channel RLE, file header fields)
|
||||
cargo test -p hcie-psd
|
||||
|
||||
# hcie-vision — 2 tests (smart patch identity and gradient)
|
||||
cargo test -p hcie-vision
|
||||
|
||||
# hcie-build-info — 1 test (build ID format)
|
||||
cargo test -p hcie-build-info
|
||||
```
|
||||
|
||||
#### Layer 4: ENGINE API
|
||||
|
||||
```bash
|
||||
# Core engine API — 8 visual regression + 2 history + 5 visual checks + 1 bitmap + 1 selection + 1 performance + 1 internal
|
||||
cargo test -p hcie-engine-api
|
||||
cargo test -p hcie-engine-api -- --ignored # includes meadow, leaves, stamp_floor checks
|
||||
cargo test -p hcie-engine-api -- performance_stroke_4k # 4K benchmark only
|
||||
```
|
||||
|
||||
#### Layer 6: GUI — egui
|
||||
|
||||
```bash
|
||||
# hcie-gui-egui — ~90 tests (canvas_engine, gui_audit, widget_ui, brush_import)
|
||||
cargo test -p hcie-gui-egui
|
||||
```
|
||||
|
||||
#### Layer 6: GUI — iced
|
||||
|
||||
```bash
|
||||
# hcie-iced-gui — ~120+ tests (selection, feature scorecard, raster, app state, dock,
|
||||
# panels, widgets, color picker, AI chat, viewer, CLI, theme, settings, SVG editor, etc.)
|
||||
cargo test -p hcie-iced-gui
|
||||
```
|
||||
|
||||
#### Brush Catalog Crates
|
||||
|
||||
```bash
|
||||
cargo test -p hcie-dry-media-brushes
|
||||
cargo test -p hcie-paint-brushes
|
||||
cargo test -p hcie-digital-brushes
|
||||
cargo test -p hcie-watercolor-brushes
|
||||
cargo test -p hcie-ink-brushes
|
||||
```
|
||||
|
||||
### 2.2 Specific Test Filter Commands
|
||||
|
||||
```bash
|
||||
# Run only the proximity dedup tests (recent color fix)
|
||||
cargo test -p hcie-iced-gui -- proximate
|
||||
|
||||
# Run only the visual regression golden tests
|
||||
cargo test -p hcie-engine-api -- visual_regression
|
||||
|
||||
# Run only the feature scorecard tests
|
||||
cargo test -p hcie-iced-gui -- feature_scorecard
|
||||
|
||||
# Run only crop/selection tests
|
||||
cargo test -p hcie-iced-gui -- selection_test
|
||||
|
||||
# Run only the GUI audit tests
|
||||
cargo test -p hcie-gui-egui -- gui_audit
|
||||
|
||||
# Run only the dock sizing/layout tests
|
||||
cargo test -p hcie-iced-gui -- sizing
|
||||
|
||||
# Run only the history tests
|
||||
cargo test -p hcie-history
|
||||
|
||||
# Run only the PSD-related tests
|
||||
cargo test -p hcie-psd
|
||||
cargo test -p hcie-io -- psd # matches "psd" in test names in hcie-io
|
||||
```
|
||||
|
||||
### 2.3 Running Ignored Tests
|
||||
|
||||
```bash
|
||||
# Run all ignored tests across all crates (visual checks and benchmarks)
|
||||
cargo test -- --ignored # runs ALL crates
|
||||
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
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Batch Script (.bat)
|
||||
|
||||
File: `run_tests_categorized.bat`
|
||||
|
||||
```batch
|
||||
@echo off
|
||||
setlocal enabledelayedexpansion
|
||||
|
||||
set ROOT_DIR=%~dp0
|
||||
cd /d "%ROOT_DIR%" || exit /b 1
|
||||
|
||||
echo ============================================================
|
||||
echo HCIE-Rust v3.05 — Categorized Test Runner (Windows .bat)
|
||||
echo Root: %ROOT_DIR%
|
||||
echo ============================================================
|
||||
|
||||
set PASS=0
|
||||
set FAIL=0
|
||||
set SKIP=0
|
||||
|
||||
:: Helper: run a category and accumulate results
|
||||
call :RUN_CATEGORY "Layer 1: DATA" ^
|
||||
hcie-protocol ^
|
||||
hcie-color
|
||||
|
||||
call :RUN_CATEGORY "Layer 2: ENGINE CORE — Blend/Brush" ^
|
||||
hcie-blend ^
|
||||
hcie-brush-engine
|
||||
|
||||
call :RUN_CATEGORY "Layer 2: ENGINE CORE — Draw/Composite/Filter" ^
|
||||
hcie-draw ^
|
||||
hcie-composite ^
|
||||
hcie-filter
|
||||
|
||||
call :RUN_CATEGORY "Layer 2: ENGINE CORE — Selection/Vector/History" ^
|
||||
hcie-selection ^
|
||||
hcie-vector ^
|
||||
hcie-history
|
||||
|
||||
call :RUN_CATEGORY "Layer 2: ENGINE CORE — IO/PSD/Vision/Build" ^
|
||||
hcie-io ^
|
||||
hcie-psd ^
|
||||
hcie-vision ^
|
||||
hcie-build-info
|
||||
|
||||
call :RUN_CATEGORY "Layer 4: ENGINE API" ^
|
||||
hcie-engine-api
|
||||
|
||||
call :RUN_CATEGORY "Layer 6: GUI — egui" ^
|
||||
hcie-gui-egui
|
||||
|
||||
call :RUN_CATEGORY "Layer 6: GUI — iced" ^
|
||||
hcie-iced-gui
|
||||
|
||||
call :RUN_CATEGORY "Brush Catalogs" ^
|
||||
hcie-dry-media-brushes ^
|
||||
hcie-paint-brushes ^
|
||||
hcie-digital-brushes ^
|
||||
hcie-watercolor-brushes ^
|
||||
hcie-ink-brushes
|
||||
|
||||
echo ============================================================
|
||||
echo SUMMARY: %PASS% passed, %FAIL% failed, %SKIP% skipped
|
||||
echo ============================================================
|
||||
exit /b %FAIL%
|
||||
|
||||
:: ============================================================
|
||||
:: Subroutine: run a named category across multiple crates
|
||||
:: ============================================================
|
||||
:RUN_CATEGORY
|
||||
set CATEGORY=%~1
|
||||
shift
|
||||
echo.
|
||||
echo ============================================================
|
||||
echo Category: %CATEGORY%
|
||||
echo ============================================================
|
||||
|
||||
:RUN_CATEGORY_LOOP
|
||||
if "%~1"=="" goto :EOF
|
||||
set CRATE=%~1
|
||||
shift
|
||||
echo --- Running: %CRATE% ---
|
||||
cargo test -p %CRATE%
|
||||
if %ERRORLEVEL%==0 (
|
||||
set /a PASS+=1
|
||||
) else (
|
||||
set /a FAIL+=1
|
||||
)
|
||||
goto RUN_CATEGORY_LOOP
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Bulk Execution Script (bash)
|
||||
|
||||
File: `run_all_tests.sh`
|
||||
|
||||
```bash
|
||||
#!/usr/bin/env bash
|
||||
# HCIE-Rust v3.05 — Bulk test runner
|
||||
# Usage: bash run_all_tests.sh [--no-io] [--no-4k] [--ignored]
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
cd "$ROOT_DIR"
|
||||
|
||||
PASS=0
|
||||
FAIL=0
|
||||
SKIP=0
|
||||
START_TIME=$(date +%s)
|
||||
|
||||
# Parse flags
|
||||
SKIP_IO=false
|
||||
SKIP_4K=false
|
||||
INCLUDE_IGNORED=false
|
||||
EXTRA_ARGS=()
|
||||
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--no-io) SKIP_IO=true ;;
|
||||
--no-4k) SKIP_4K=true ;;
|
||||
--ignored) INCLUDE_IGNORED=true ;;
|
||||
*) EXTRA_ARGS+=("$arg") ;;
|
||||
esac
|
||||
done
|
||||
|
||||
SEPARATOR() {
|
||||
printf '%*s\n' 80 '' | tr ' ' '='
|
||||
}
|
||||
|
||||
run_crate() {
|
||||
local crate="$1"
|
||||
local label="$2"
|
||||
local extra="$3"
|
||||
shift 3
|
||||
|
||||
SEPARATOR
|
||||
echo "[$label] Running: $crate $extra"
|
||||
SEPARATOR
|
||||
|
||||
if [ -n "$extra" ]; then
|
||||
# shellcheck disable=SC2086
|
||||
if cargo test -p "$crate" $extra "${EXTRA_ARGS[@]+${EXTRA_ARGS[@]}}" 2>&1; then
|
||||
PASS=$((PASS + 1))
|
||||
else
|
||||
FAIL=$((FAIL + 1))
|
||||
fi
|
||||
else
|
||||
if cargo test -p "$crate" "${EXTRA_ARGS[@]+${EXTRA_ARGS[@]}}" 2>&1; then
|
||||
PASS=$((PASS + 1))
|
||||
else
|
||||
FAIL=$((FAIL + 1))
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
display_summary() {
|
||||
local elapsed=$(( $(date +%s) - START_TIME ))
|
||||
echo ""
|
||||
SEPARATOR
|
||||
echo " BULK TEST EXECUTION COMPLETE"
|
||||
echo " Crates passed: $PASS"
|
||||
echo " Crates with failures: $FAIL"
|
||||
echo " Elapsed time: ${elapsed}s"
|
||||
SEPARATOR
|
||||
if [ "$FAIL" -eq 0 ]; then
|
||||
echo " ✓ ALL CRATES PASSED"
|
||||
else
|
||||
echo " ✗ $FAIL crate(s) have failing tests"
|
||||
fi
|
||||
exit "$FAIL"
|
||||
}
|
||||
|
||||
# ── Layer 1: DATA ───────────────────────────────────────────────
|
||||
run_crate "hcie-protocol" "Layer 1" ""
|
||||
run_crate "hcie-color" "Layer 1" ""
|
||||
|
||||
# ── Layer 2: ENGINE CORE ────────────────────────────────────────
|
||||
run_crate "hcie-blend" "Layer 2" ""
|
||||
run_crate "hcie-brush-engine" "Layer 2" ""
|
||||
run_crate "hcie-draw" "Layer 2" ""
|
||||
run_crate "hcie-composite" "Layer 2" ""
|
||||
run_crate "hcie-filter" "Layer 2" ""
|
||||
run_crate "hcie-selection" "Layer 2" ""
|
||||
run_crate "hcie-vector" "Layer 2" ""
|
||||
run_crate "hcie-history" "Layer 2" ""
|
||||
|
||||
if [ "$SKIP_IO" = false ]; then
|
||||
run_crate "hcie-io" "Layer 2" ""
|
||||
else
|
||||
echo "[SKIP] hcie-io (--no-io flag)"
|
||||
SKIP=$((SKIP + 1))
|
||||
fi
|
||||
run_crate "hcie-psd" "Layer 2" ""
|
||||
run_crate "hcie-vision" "Layer 2" ""
|
||||
run_crate "hcie-build-info" "Layer 2" ""
|
||||
|
||||
# ── Layer 4: ENGINE API ─────────────────────────────────────────
|
||||
if [ "$SKIP_4K" = false ]; then
|
||||
run_crate "hcie-engine-api" "Layer 4" ""
|
||||
else
|
||||
run_crate "hcie-engine-api" "Layer 4" "--skip benchmark_4k_stroke_on_multilayer_document"
|
||||
fi
|
||||
|
||||
# ── Layer 6: GUI — egui ────────────────────────────────────────
|
||||
run_crate "hcie-gui-egui" "Layer 6" ""
|
||||
|
||||
# ── Layer 6: GUI — iced ─────────────────────────────────────────
|
||||
run_crate "hcie-iced-gui" "Layer 6" ""
|
||||
|
||||
# ── Brush Catalogs ──────────────────────────────────────────────
|
||||
run_crate "hcie-dry-media-brushes" "Brushes" ""
|
||||
run_crate "hcie-paint-brushes" "Brushes" ""
|
||||
run_crate "hcie-digital-brushes" "Brushes" ""
|
||||
run_crate "hcie-watercolor-brushes" "Brushes" ""
|
||||
run_crate "hcie-ink-brushes" "Brushes" ""
|
||||
|
||||
# ── Ignored tests (visual checks, benchmarks) ───────────────────
|
||||
if [ "$INCLUDE_IGNORED" = true ]; then
|
||||
echo ""
|
||||
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
|
||||
fi
|
||||
|
||||
display_summary
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Gap Analysis
|
||||
|
||||
### 5.1 Crates with Zero Tests
|
||||
|
||||
These 21 crates have no `#[test]` functions, no `tests/` directory, and no `#[cfg(test)]` modules:
|
||||
|
||||
| Crate | Layer | Risk | Notes |
|
||||
|-------|-------|------|-------|
|
||||
| `hcie-document` | Layer 3 | **HIGH** | Core document state management — layer CRUD, dirty tracking, zoom, blend mode changes. Zero tests despite being a critical engine crate. |
|
||||
| `hcie-tile` | Layer 2 | **HIGH** | Sparse tile-based layer storage — tile read/write, cache invalidation, dirty tracking. No tests for the incremental tile update mechanism. |
|
||||
| `hcie-text` | Layer 2 | **HIGH** | Vector text rendering via `fontdue` — text layout, wrapping, font loading, glyph rasterization. No tests. |
|
||||
| `hcie-native` | Layer 2 | **MEDIUM** | HCIE native file format I/O. Simple wrapper crate but no serialization/deserialization tests. |
|
||||
| `hcie-kra` | Layer 2 | **MEDIUM** | Krita (KRA) file format import/export. No roundtrip tests. |
|
||||
| `hcie-psd-saver` | Layer 2 | **MEDIUM** | PSD file saver. No tests. |
|
||||
| `hcie-ai` | Layer 5 | **MEDIUM** | Ollama/LMStudio/OpenAI chat client, templates, AI actions. No tests for the client, message building, or response parsing. |
|
||||
| `egui-panel-adapter` | Layer 6 | **MEDIUM** | Dynamic schema-to-widget UI binder. No tests. |
|
||||
| `egui-panel-filters` | Layer 6 | **MEDIUM** | Filter parameter panels. No tests for parameter binding or UI state. |
|
||||
| `egui-panel-ai-chat` | Layer 6 | **MEDIUM** | AI chat panel (egui). No tests. |
|
||||
| `egui-panel-script` | Layer 6 | **MEDIUM** | Scripting panel (egui). No tests. |
|
||||
| `egui-panel-ai-script` | Layer 6 | **MEDIUM** | AI-assisted script generation panel (egui). No tests. |
|
||||
| `iced-panel-adapter` | Layer 6 | **MEDIUM** | Schema-to-widget binder (iced). No tests. |
|
||||
| `iced-panel-ai-chat` | Layer 6 | **MEDIUM** | AI chat panel (iced). No tests. |
|
||||
| `iced-panel-script` | Layer 6 | **MEDIUM** | Scripting panel (iced). No tests. |
|
||||
| `panel-tuner` | Tools | **LOW** | Development tool for panel layout tuning. |
|
||||
| `screenshot-diff` | Tools | **LOW** | Screenshot comparison tool. |
|
||||
| `hcie-egui-app` | Layer 6 | **LOW** | Workspace root — no test logic expected. |
|
||||
| `hcie-iced-app` | Layer 6 | **LOW** | Workspace root — no test logic expected. |
|
||||
| `egui-winit` | Patches | **LOW** | Upstream patch — tests disabled (`autotests = false`). |
|
||||
|
||||
### 5.2 Modules with Incomplete Test Coverage
|
||||
|
||||
| Module | Existing Tests | Missing Coverage |
|
||||
|--------|---------------|-----------------|
|
||||
| `hcie-io` PSD composite | 24 tests, but ~17 skip at runtime | Deterministic tests that work without fixture files (generate test PSDs programmatically) |
|
||||
| `hcie-engine-api` visual regression | 8 golden hash tests | No test for watercolor brush, pencil, gradient fill, crop, transform, layer effects |
|
||||
| `hcie-brush-engine` | 7 inline + 6 integration tests | No tests for airbrush/spray dynamics, tilt/rotation mapping, dual-brush mode |
|
||||
| `hcie-filter` | 13 smoke tests | No tests verifying correct pixel output values for any filter; only "does not panic" |
|
||||
| `hcie-psd` | 8 tests (header + signature) | No tests for image resource sections, layer info (masks, effects, patterns), channel data decompression (all `unimplemented!` stubs exist) |
|
||||
| `hcie-composite` | 5 tests | No tests for layer masks, clipping masks, adjustment layers, layer groups with non-PassThrough mode, blend mode combinations |
|
||||
| `hcie-iced-gui` dock system | ~25 tests | No tests for minimizing/maximizing panels, keyboard navigation of dock, drag-and-drop reordering of tabs |
|
||||
| `hcie-iced-gui` panels | ~15 tests | Brushes panel has catalog count checks but no interaction tests. Filters panel has no tests. Layer details/panel has no tests. |
|
||||
|
||||
### 5.3 Recommended Test Additions (Priority Order)
|
||||
|
||||
#### Priority 1 (High Risk — Engine Core Integrity)
|
||||
|
||||
1. **`hcie-document`** — Unit tests for:
|
||||
- Layer CRUD (add/remove/reorder)
|
||||
- Active layer switching
|
||||
- Dirty flag set/clear
|
||||
- Zoom clamp boundaries
|
||||
- Blend mode changes on layers
|
||||
- Opacity/visibility toggle propagation
|
||||
|
||||
2. **`hcie-tile`** — Unit tests for:
|
||||
- Tile read/write at pixel level
|
||||
- Dirty rectangle tracking
|
||||
- Tile cache invalidation
|
||||
- Incremental tile update (the protected optimization)
|
||||
- Multi-resolution tile access
|
||||
|
||||
3. **`hcie-text`** — Unit tests for:
|
||||
- Font loading (valid/invalid paths)
|
||||
- Text layout (single line, multi-line, wrapping)
|
||||
- Glyph rasterization bounds
|
||||
- Unicode / special character handling
|
||||
- Text cursor positioning
|
||||
|
||||
#### Priority 2 (Medium Risk — Feature Correctness)
|
||||
|
||||
4. **`hcie-io`** — Replace fixture-dependent tests with:
|
||||
- Programmatic PSD creation using `hcie-psd-saver` (roundtrip test)
|
||||
- In-memory composite with known pixel values
|
||||
- PNG/JPG/WebP import/export with checksum verification
|
||||
|
||||
5. **`hcie-filter`** — Add value verification tests:
|
||||
- Invert: known input → known output
|
||||
- Grayscale: R=G=B after filter
|
||||
- Brightness/Contrast: exact value mapping
|
||||
- Box blur: average of neighborhood
|
||||
|
||||
6. **`hcie-ai`** — Unit tests for:
|
||||
- Message formatting (system/user/assistant)
|
||||
- API response parsing (JSON extraction)
|
||||
- Template rendering
|
||||
- Error handling (network failure, auth error)
|
||||
|
||||
#### Priority 3 (Lower Risk — GUI Completeness)
|
||||
|
||||
7. **`egui-panel-filters`** — Test filter parameter schema binding and default values
|
||||
8. **`egui-panel-ai-chat`** — Test message history management, send/receive state machine
|
||||
9. **`iced-panel-adapter`** — Test dynamic widget generation from schema
|
||||
10. **All panel crates** — Smoke tests ensuring each panel renders without panic
|
||||
|
||||
### 5.4 Quick Wins (Easy to Add, High Impact)
|
||||
|
||||
| Test | Crate | Effort | Impact |
|
||||
|------|-------|--------|--------|
|
||||
| Roundtrip: create layer → modify → undo → redo → equal original | `hcie-document` | 1 hour | Catches state corruption bugs |
|
||||
| Tile dirty region compute: modify tile → read dirty rect | `hcie-tile` | 2 hours | Validates the optimization in AGENTS.md |
|
||||
| Text measure: known string + font → expected width | `hcie-text` | 1 hour | Prevents layout regressions |
|
||||
| Invert filter: 5 known RGBA values → expected output | `hcie-filter` | 1 hour | Upgrades smoke test to real verification |
|
||||
| No-panic on empty input for every filter | `hcie-filter` | 30 min | Edge case coverage |
|
||||
| Fixture-free PSD composite: create 2 layers with known colors in memory, composite, verify | `hcie-io` | 3 hours | Eliminates ~17 flaky/skipped tests |
|
||||
|
||||
### 5.5 Test Infrastructure Improvements
|
||||
|
||||
| Issue | Recommendation |
|
||||
|-------|---------------|
|
||||
| `hcie-io` tests silently pass when fixtures missing | Change to `panic!` with informative message, or add `#[ignore]` with fixture requirement documented |
|
||||
| No test coverage tracking | Add `cargo tarpaulin` or `cargo llvm-cov` to CI and report line/region coverage |
|
||||
| No CI integration visible | Set up GitHub Actions running: `Layer 1` → `Layer 2` → `Layer 4` → `Layer 6` sequentially with `--no-io --no-4k` for quick CI and full suite nightly |
|
||||
| `hcie-psd` has 12 `unimplemented!()` stubs | Add `#[should_panic]` tests that verify stubs panic as expected, or complete the implementations |
|
||||
@@ -0,0 +1,820 @@
|
||||
# HCIE-Rust v3.05 — Complete Test Inventory
|
||||
|
||||
> Generated: 2026-07-23
|
||||
> Project root: `/mnt/extra/00_PROJECTS/hcie-rust-v3.05`
|
||||
>
|
||||
> **Total: ~415+ `#[test]` functions** across 23+ crates organized in 6 layers.
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Layer 1: DATA](#layer-1-data)
|
||||
- [hcie-protocol — tests/helpers.rs](#hcie-protocol--testshelpersrs)
|
||||
- [hcie-color — tests/color_roundtrip.rs](#hcie-color--testscolor_roundtriprs)
|
||||
2. [Layer 2: ENGINE CORE](#layer-2-engine-core)
|
||||
- [hcie-blend — tests/blend_modes.rs](#hcie-blend--testsblend_modesrs)
|
||||
- [hcie-brush-engine — tests/stamp.rs](#hcie-brush-engine--testsstamp.rs)
|
||||
- [hcie-brush-engine — src/lib.rs (internal)](#hcie-brush-engine--srclibrs-internal)
|
||||
- [hcie-draw — tests/draw_pixels.rs](#hcie-draw--testsdraw_pixelsrs)
|
||||
- [hcie-composite — tests/](#hcie-composite--tests)
|
||||
- [hcie-composite — src/test_composite.rs](#hcie-composite--srctest_compositers)
|
||||
- [hcie-filter — tests/filters.rs](#hcie-filter--testsfiltersrs)
|
||||
- [hcie-selection — tests/mask_ops.rs](#hcie-selection--testsmask_opsrs)
|
||||
- [hcie-vector — src/lib.rs (internal)](#hcie-vector--srclibrs-internal)
|
||||
- [hcie-history — tests/history_state.rs](#hcie-history--testshistory_staters)
|
||||
- [hcie-history — src/lib.rs (internal)](#hcie-history--srclibrs-internal)
|
||||
- [hcie-fx — src/parser.rs (internal)](#hcie-fx--srcparserrs-internal)
|
||||
- [hcie-io — src/test_psd_composite.rs](#hcie-io--srctest_psd_compositers)
|
||||
- [hcie-psd — src/](#hcie-psd--src)
|
||||
- [hcie-vision — src/smart_patch.rs](#hcie-vision--srcsmart_patchrs)
|
||||
- [hcie-build-info — src/lib.rs](#hcie-build-info--srclibrs)
|
||||
3. [Layer 4: ENGINE API](#layer-4-engine-api)
|
||||
- [hcie-engine-api — tests/visual_regression.rs](#hcie-engine-api--testsvisual_regressionrs)
|
||||
- [hcie-engine-api — tests/vector_creation_history.rs](#hcie-engine-api--testsvector_creation_historyrs)
|
||||
- [hcie-engine-api — tests/meadow_check.rs](#hcie-engine-api--testsmeadow_checkrs)
|
||||
- [hcie-engine-api — tests/leaves_check.rs](#hcie-engine-api--testsleaves_checkrs)
|
||||
- [hcie-engine-api — tests/stamp_floor_check.rs](#hcie-engine-api--testsstamp_floor_checkrs)
|
||||
- [hcie-engine-api — tests/bitmap_brush_stamp.rs](#hcie-engine-api--testsbitmap_brush_stamprs)
|
||||
- [hcie-engine-api — tests/selection_clear.rs](#hcie-engine-api--testsselection_clearrs)
|
||||
- [hcie-engine-api — tests/performance_stroke_4k.rs](#hcie-engine-api--testsperformance_stroke_4krs)
|
||||
- [hcie-engine-api — src/stroke_brush.rs (internal)](#hcie-engine-api--srcstroke_brushrs-internal)
|
||||
4. [Layer 6: GUI — egui](#layer-6-gui--egui)
|
||||
- [hcie-gui-egui — tests/canvas_engine.rs](#hcie-gui-egui--testscanvas_enginers)
|
||||
- [hcie-gui-egui — tests/gui_audit.rs](#hcie-gui-egui--testsgui_auditrs)
|
||||
- [hcie-gui-egui — tests/widget_ui.rs](#hcie-gui-egui--testswidget_uirs)
|
||||
- [hcie-gui-egui — src/brush_import.rs (internal)](#hcie-gui-egui--srcbrush_importrs-internal)
|
||||
5. [Layer 6: GUI — iced](#layer-6-gui--iced)
|
||||
- [hcie-iced-gui — tests/selection_test.rs](#hcie-iced-gui--testsselection_testrs)
|
||||
- [hcie-iced-gui — tests/feature_scorecard.rs](#hcie-iced-gui--testsfeature_scorecardrs)
|
||||
- [hcie-iced-gui — tests/raster_test.rs](#hcie-iced-gui--testsraster_testrs)
|
||||
- [hcie-iced-gui — src/app.rs (internal)](#hcie-iced-gui--srcapprs-internal)
|
||||
- [hcie-iced-gui — src/app/transform_dirty.rs](#hcie-iced-gui--srcapptransform_dirtyrs)
|
||||
- [hcie-iced-gui — src/color_picker.rs](#hcie-iced-gui--srccolor_pickerrs)
|
||||
- [hcie-iced-gui — src/ai_chat.rs](#hcie-iced-gui--srcai_chatrs)
|
||||
- [hcie-iced-gui — src/ai_script.rs](#hcie-iced-gui--srcai_scriptrs)
|
||||
- [hcie-iced-gui — src/screenshot.rs](#hcie-iced-gui--srcscreenshotrs)
|
||||
- [hcie-iced-gui — src/sidebar/mod.rs](#hcie-iced-gui--srcsidebarmodrs)
|
||||
- [hcie-iced-gui — src/theme.rs](#hcie-iced-gui--srcthemers)
|
||||
- [hcie-iced-gui — src/viewer/mod.rs](#hcie-iced-gui--srcviewermodrs)
|
||||
- [hcie-iced-gui — src/settings.rs](#hcie-iced-gui--srcsettingsrs)
|
||||
- [hcie-iced-gui — src/cli.rs](#hcie-iced-gui--srcclirs)
|
||||
- [hcie-iced-gui — src/canvas/shader_canvas.rs](#hcie-iced-gui--srccanvasshader_canvasrs)
|
||||
- [hcie-iced-gui — src/canvas/texture_update.rs](#hcie-iced-gui--srccanvastexture_updaters)
|
||||
- [hcie-iced-gui — src/selection/state.rs](#hcie-iced-gui--srcselectionstaters)
|
||||
- [hcie-iced-gui — src/panels/menus.rs](#hcie-iced-gui--srcpanelsmenusrs)
|
||||
- [hcie-iced-gui — src/panels/styles.rs](#hcie-iced-gui--srcpanelsstylesrs)
|
||||
- [hcie-iced-gui — src/panels/custom_shapes.rs](#hcie-iced-gui--srcpanelscustom_shapesrs)
|
||||
- [hcie-iced-gui — src/panels/brushes.rs](#hcie-iced-gui--srcpanelsbrushesrs)
|
||||
- [hcie-iced-gui — src/panels/svg_editor.rs](#hcie-iced-gui--srcpanelssvg_editorrs)
|
||||
- [hcie-iced-gui — src/panels/title_bar.rs](#hcie-iced-gui--srcpanelstitle_bar.rs)
|
||||
- [hcie-iced-gui — src/vector_edit.rs](#hcie-iced-gui--srcvector_editrs)
|
||||
- [hcie-iced-gui — src/widgets/plain_slider.rs](#hcie-iced-gui--srcwidgetsplain_sliderrs)
|
||||
- [hcie-iced-gui — src/dock/state.rs](#hcie-iced-gui--srcdockstaters)
|
||||
- [hcie-iced-gui — src/dock/manager.rs](#hcie-iced-gui--srcdockmanagersr)
|
||||
- [hcie-iced-gui — src/dock/sizing.rs](#hcie-iced-gui--srcdocksizingrs)
|
||||
- [hcie-iced-gui — src/dock/persistence.rs](#hcie-iced-gui--srcdockpersistencers)
|
||||
- [hcie-iced-gui — src/dock/view.rs](#hcie-iced-gui--srcdockviewrs)
|
||||
- [hcie-iced-gui — src/dock/preview.rs](#hcie-iced-gui--srcdockpreviewrs)
|
||||
- [hcie-iced-gui — src/dock/floating.rs](#hcie-iced-gui--srcdockfloatingrs)
|
||||
6. [Brush Catalog Crates](#brush-catalog-crates)
|
||||
|
||||
---
|
||||
|
||||
## Layer 1: DATA
|
||||
|
||||
### `hcie-protocol` — tests/helpers.rs
|
||||
|
||||
| Line | Function | Summary |
|
||||
|------|----------|---------|
|
||||
| 4 | `test_to_u32_from_u32_roundtrip` | Tests that packing and unpacking RGBA colors to/from u32 round-trips correctly for several test colors. |
|
||||
| 20 | `test_color_constants` | Verifies that `TRANSPARENT`, `BLACK`, `WHITE`, and `RED` constants have the expected RGBA values. |
|
||||
| 28 | `test_lerp_boundaries` | Confirms linear interpolation at t=0, t=0.5, and t=1 returns correct boundary/midpoint values. |
|
||||
| 35 | `test_color_lerp` | Verifies color lerp between two RGBA colors at 0.5 blend produces expected midpoint values. |
|
||||
| 46 | `test_lerp_clamp` | Confirms lerp clamps t values below 0 and above 1 to the range endpoints. |
|
||||
| 52 | `test_clamp` | Tests the clamp function with in-range, below-min, and above-max values. |
|
||||
| 59 | `test_distance` | Verifies Euclidean distance calculation (3-4-5 triangle) and zero-distance for same point. |
|
||||
| 65 | `test_point_in_rect` | Tests `point_in_rect` with points inside and outside a rectangle. |
|
||||
| 72 | `test_thumbnail_nearest` | Tests nearest-neighbor thumbnail generation from a 2×1 RGBA image to 1×1. |
|
||||
| 83 | `test_selection_rect_from_mask` | Verifies extracting a bounding rectangle from a selection mask with a 2×2 selected region. |
|
||||
| 95 | `test_blank_mask_no_selection` | Confirms a mask of all zeros returns `None` from `selection_rect_from_mask`. |
|
||||
| 101 | `test_blend_mode_all_contains_all` | Asserts that `BlendMode::ALL` contains exactly 28 blend modes. |
|
||||
| 106 | `test_all_tools_listed` | Asserts that `Tool::ALL` contains exactly 36 tools. |
|
||||
| 111 | `test_expand_dirty_bounds` | Tests expanding dirty bounds with a given rectangle, verifying the union result. |
|
||||
| 118 | `test_version_string_format` | Checks that the version string starts with "3." (expected format). |
|
||||
| 125 | `from_u32_to_u32_roundtrip` | Property-based test using proptest to verify u32 round-trip for random RGBA values. |
|
||||
|
||||
### `hcie-color` — tests/color_roundtrip.rs
|
||||
|
||||
| Line | Function | Summary |
|
||||
|------|----------|---------|
|
||||
| 4 | `test_gamma_encode_boundary_zero` | Verifies gamma encoding of 0.0 equals 0.0. |
|
||||
| 9 | `test_gamma_encode_boundary_one` | Verifies gamma encoding of 1.0 equals 1.0. |
|
||||
| 14 | `test_gamma_decode_boundary_zero` | Verifies gamma decoding of 0.0 equals 0.0. |
|
||||
| 19 | `test_gamma_decode_boundary_one` | Verifies gamma decoding of 1.0 equals 1.0. |
|
||||
| 24 | `test_gamma_roundtrip` | Ensures gamma encode→decode round-trip preserves values within 0.01 epsilon for 7 test values. |
|
||||
| 33 | `test_srgb_to_linear_black` | Confirms sRGB black [0,0,0] converts to linear [0.0, 0.0, 0.0]. |
|
||||
| 38 | `test_srgb_to_linear_white` | Confirms sRGB white [255,255,255] converts to linear [1.0, 1.0, 1.0]. |
|
||||
| 47 | `srgb_linear_roundtrip` | Property-based test verifying sRGB→linear→sRGB round-trip preserves values. |
|
||||
| 58 | `test_rgb_to_hsl_basic` | Tests RGB→HSL conversion for pure red, expecting H=0, S=1, L=0.5. |
|
||||
| 66 | `test_rgb_to_hsl_gray` | Tests RGB→HSL for gray (0.5,0.5,0.5), expecting S=0. |
|
||||
| 74 | `test_linear_to_srgb_clamps` | Confirms linear→sRGB clamps values below 0 to 0 and above 1 to 255. |
|
||||
|
||||
---
|
||||
|
||||
## Layer 2: ENGINE CORE
|
||||
|
||||
### `hcie-blend` — tests/blend_modes.rs
|
||||
|
||||
| Line | Function | Summary |
|
||||
|------|----------|---------|
|
||||
| 5 | `test_normal_zero_opacity_identity` | Verifies that blending with opacity=0 returns the destination unchanged for Normal mode. |
|
||||
| 13 | `test_normal_full_opacity` | Verifies that Normal mode with opacity=1 outputs the source pixel unchanged. |
|
||||
| 21 | `test_opacity_zero_no_mutation_for_all_modes` | Confirms that opacity=0 returns dst unchanged for every blend mode (except PassThrough). |
|
||||
| 38 | `test_specific_blend_modes` | Parametric test (rstest) checking specific blend modes (Multiply, Screen, etc.) produce valid alpha. |
|
||||
| 51 | `test_transparent_src_normal` | Verifies that a fully transparent source leaves the destination unchanged. |
|
||||
| 59 | `test_all_modes_produce_valid_alpha` | Ensures all blend modes with opacity=0.5 produce valid u8 alpha values. |
|
||||
| 70 | `test_multiply_darkens` | Verifies Multiply blend mode darkens the destination channel. |
|
||||
| 78 | `test_screen_lightens` | Verifies Screen blend mode lightens the destination channel. |
|
||||
| 87 | `identity_at_zero_opacity` | Property-based test: random pixels blended with opacity 0 always equal the destination for all modes. |
|
||||
|
||||
### `hcie-brush-engine` — tests/stamp.rs
|
||||
|
||||
| Line | Function | Summary |
|
||||
|------|----------|---------|
|
||||
| 13 | `test_generate_brush_stamp_size` | Verifies a round brush stamp of size 10 produces a 20×20 square stamp buffer. |
|
||||
| 25 | `test_generate_brush_stamp_center_nonzero` | Confirms the center pixel of a hardness=1.0 round stamp is opaque. |
|
||||
| 34 | `test_generate_brush_stamp_hardness_gradient` | Checks that soft (0.0) and hard (1.0) stamps have the same buffer length. |
|
||||
| 45 | `test_brush_spacing_pixels` | Verifies `brush_spacing_pixels` returns size×ratio for 0.5 ratio and minimum ~0.2 for 0.0 ratio. |
|
||||
| 57 | `test_jitter_offset_within_bounds` | Confirms zero jitter amount produces zero offset. |
|
||||
| 64 | `test_bitmap_stamp_fallback_to_round` | Verifies that an empty bitmap stamp falls back to a round stamp (non-empty buffer). |
|
||||
|
||||
### `hcie-brush-engine` — src/lib.rs (internal)
|
||||
|
||||
| Line | Function | Summary |
|
||||
|------|----------|---------|
|
||||
| 4241 | `watercolor_detail_count_is_hard_bounded` | Verifies watercolor detail counts (satellites, splatters, blooms) stay within `WATERCOLOR_MAX_DABS_PER_SAMPLE` for sizes 0.5–1024. |
|
||||
| 4257 | `every_specialized_style_is_noop_at_zero_pressure` | Tests that all 28 specialized brush styles produce zero-painted pixels when pressure is 0. |
|
||||
| 4328 | `blender_preserves_uniform_color_and_transparency` | Verifies the Blender brush doesn't change uniform color fields and doesn't paint on empty areas. |
|
||||
| 4352 | `rotated_dab_uses_single_channel_stroke_mask` | Confirms rotated dabs write into a single-channel stroke mask during specialized stroke drawing. |
|
||||
| 4389 | `rotated_pen_respects_stroke_opacity_cap` | Verifies three overlapping pen dabs at 50% flow stay within the 50% stroke opacity cap (~128 alpha). |
|
||||
| 4430 | `pigment_mix_is_subtractive_and_bounded` | Confirms pigment mixing is subtractive (red+blue → dark purple) and respects blend factor boundaries. |
|
||||
| 4447 | `diagnostic_watercolor_sample_latency` | Diagnostic benchmark (ignored) measuring round dab and watercolor brush latency at various sizes. |
|
||||
|
||||
### `hcie-draw` — tests/draw_pixels.rs
|
||||
|
||||
| Line | Function | Summary |
|
||||
|------|----------|---------|
|
||||
| 4 | `test_draw_filled_rect` | Draws a filled red rectangle and checks the center pixel is red. |
|
||||
| 14 | `test_draw_filled_rect_outside` | Confirms pixels outside a drawn rectangle remain unchanged (transparent). |
|
||||
| 22 | `test_draw_filled_circle` | Draws a filled green circle and checks the center pixel is green. |
|
||||
| 30 | `test_draw_filled_ellipse` | Draws a filled blue ellipse and checks the center pixel is blue. |
|
||||
| 38 | `test_draw_line` | Draws a yellow horizontal line and checks a pixel on the line. |
|
||||
| 56 | `test_flood_fill` | Performs flood fill from (0,0) and checks that the filled area is red. |
|
||||
| 64 | `test_draw_with_mask_restricts_pixels` | Draws a rect with a mask where only pixel (0,0) is masked; verifies only that pixel is painted. |
|
||||
| 84 | `test_line_at_edge` | Draws a diagonal line from corner to corner and checks the start pixel. |
|
||||
|
||||
### `hcie-composite` — tests/
|
||||
|
||||
| File | Line | Function | Summary |
|
||||
|------|------|----------|---------|
|
||||
| pass_through.rs | 24 | `test_pass_through_group_skipped_in_composite` | Verifies child layers in a PassThrough group blend directly onto the background (Multiply on white→gray). |
|
||||
| pass_through.rs | 56 | `test_pass_through_group_invisible_children` | Confirms invisible children of a PassThrough group are excluded and only background appears. |
|
||||
| pass_through.rs | 78 | `test_pass_through_group_with_opacity` | Documents current MVP behavior: PassThrough group opacity is skipped so child blends at full strength. |
|
||||
| ff_visibility.rs | 20 | `test_composite_visibility_from_protocol_layer` | Tests composite with protocol layers toggling visibility of background and top layers, printing opaque pixel counts. |
|
||||
| visibility.rs | 17 | `test_composite_visibility_toggle` | Tests compositing with both visible, background hidden, top hidden, and both hidden, asserting correct opaque pixel counts. |
|
||||
|
||||
### `hcie-composite` — src/test_composite.rs
|
||||
|
||||
| Line | Function | Summary |
|
||||
|------|----------|---------|
|
||||
| 5 | `test_composite_basic` | Basic composite test: single red layer output, then two layers (red background, blue overlay) checking top-wins compositing. |
|
||||
|
||||
### `hcie-filter` — tests/filters.rs
|
||||
|
||||
| Line | Function | Summary |
|
||||
|------|----------|---------|
|
||||
| 22 | `test_blur_does_not_panic` | Applies box_blur with radius 2 and checks dimensions remain valid. |
|
||||
| 30 | `test_gaussian_blur_does_not_panic` | Applies gaussian_blur with radius 2 and checks dimensions remain valid. |
|
||||
| 38 | `test_motion_blur_does_not_panic` | Applies motion_blur with distance 5, angle 45 and checks dimensions remain valid. |
|
||||
| 46 | `test_invert_filter` | Applies invert filter and verifies at least one pixel changed value. |
|
||||
| 55 | `test_grayscale_filter` | Applies grayscale filter and verifies R, G, B values differ by at most 3 (effectively gray). |
|
||||
| 69 | `test_filter_ids` | Checks that filter_ids() returns at least one filter and includes "invert" and "box_blur". |
|
||||
| 78 | `test_sharpen_does_not_panic` | Applies sharpen filter and checks dimensions remain valid. |
|
||||
| 86 | `test_unknown_filter_is_noop` | Confirms applying a nonexistent filter name leaves pixels unchanged. |
|
||||
| 94 | `test_unsharp_mask_does_not_panic` | Applies unsharp_mask with radius 2, amount 0.5 and checks dimensions remain valid. |
|
||||
| 102 | `test_brightness_contrast` | Applies brightness_contrast filter and confirms output contains non-zero pixel values. |
|
||||
| 110 | `test_hue_saturation_does_not_panic` | Applies hue_saturation filter and checks dimensions remain valid. |
|
||||
| 118 | `test_emboss_does_not_panic` | Applies emboss filter and checks dimensions remain valid. |
|
||||
| 126 | `test_find_edges_does_not_panic` | Applies find_edges filter and checks dimensions remain valid. |
|
||||
|
||||
### `hcie-selection` — tests/mask_ops.rs
|
||||
|
||||
| Line | Function | Summary |
|
||||
|------|----------|---------|
|
||||
| 2 | `test_create_rect_mask_full` | Creates a 4×4 rect mask covering the full area and verifies all values are 255. |
|
||||
| 8 | `test_create_rect_mask_partial` | Creates a partial rect mask (1,1 to 2,2 in 4×4) and verifies inside/outside pixels. |
|
||||
| 15 | `test_create_ellipse_mask` | Creates an ellipse mask and verifies center is selected, corner is not. |
|
||||
| 22 | `test_invert_mask` | Inverts a mask and verifies 255→0 and 0→255. |
|
||||
| 31 | `test_invert_twice_is_identity` | Inverts a mask twice and confirms the result equals the original. |
|
||||
| 45 | `test_grow_mask` | Grows a single selected pixel by 1 and verifies more pixels become selected. |
|
||||
| 54 | `test_grow_zero_is_noop` | Confirms growing by 0 leaves the mask unchanged. |
|
||||
| 66 | `test_shrink_mask` | Shrinks a fully-selected mask by 1 and verifies fewer selected pixels. |
|
||||
| 74 | `test_shrink_zero_is_noop` | Confirms shrinking by 0 leaves the mask unchanged. |
|
||||
| 82 | `test_grow_shrink_roundtrip` | Grows then shrinks a mask by 1 and verifies it returns to the original state. |
|
||||
| 97 | `test_feather_mask` | Feathers a 3×3 selected area with radius 1 and checks center remains 255 and corners are feathered. |
|
||||
| 110 | `test_feather_zero_is_noop` | Confirms feathering with radius 0 leaves the mask unchanged. |
|
||||
| 119 | `test_mask_at` | Tests `mask_at` with and without a mask, checking correct values returned at various positions. |
|
||||
| 131 | `test_magic_wand` | Tests magic wand selection on a 2×2 pixel grid, checking seed and adjacent same-color pixels are selected. |
|
||||
| 143 | `test_lasso_fill_mask` | Fills a 5×5 mask with a square lasso and verifies the center pixel is filled. |
|
||||
|
||||
### `hcie-vector` — src/lib.rs (internal)
|
||||
|
||||
| Line | Function | Summary |
|
||||
|------|----------|---------|
|
||||
| 1101 | `test_crescent_sharp_tips` | Verifies the crescent shape's outer and inner ellipse arcs meet exactly at both tips. |
|
||||
| 1129 | `test_bubble_tail_points` | Verifies the speech bubble contains the tail tip and inner connection points. |
|
||||
| 1154 | `test_rotate_point` | Tests rotating a point 90° around a center and verifies correct output coordinates. |
|
||||
| 1167 | `svg_points_follow_shape_angle` | Verifies SVG-backed shape rotation applies correctly around bounds center. |
|
||||
|
||||
### `hcie-history` — tests/history_state.rs
|
||||
|
||||
| Line | Function | Summary |
|
||||
|------|----------|---------|
|
||||
| 31 | `test_history_new_is_empty` | Verifies a new HistoryManager has len=0, can_undo=false, can_redo=false, index=-1. |
|
||||
| 40 | `test_history_push_undo_redo` | Pushes two pixel changes, undoes one step (checking pixel value), then redoes. |
|
||||
| 65 | `test_history_undo_twice_is_stable` | Verifies double-undo doesn't go past the bottom of the history stack. |
|
||||
| 79 | `test_history_redo_twice_is_stable` | Verifies double-redo doesn't go past the top of the history stack. |
|
||||
| 94 | `test_history_max_steps` | Pushes 10 actions with max_steps=3 and verifies only 3 are retained. |
|
||||
| 108 | `test_history_new_action_truncates_redo` | Undoes, then pushes a new action; verifies redo stack is truncated. |
|
||||
| 133 | `test_history_jump_to` | Pushes 3 actions, jumps to index -1, verifies at bottom with full redo stack. |
|
||||
| 161 | `test_entry_description` | Tests that entry_description returns the action description for valid indices and None for invalid. |
|
||||
|
||||
### `hcie-history` — src/lib.rs (internal)
|
||||
|
||||
| Line | Function | Summary |
|
||||
|------|----------|---------|
|
||||
| 126 | `first_entry_can_redo_from_pre_history_cursor` | Verifies the first history entry can be redone after undo when cursor starts at pre-history state. |
|
||||
|
||||
### `hcie-fx` — src/parser.rs (internal)
|
||||
|
||||
| Line | Function | Summary |
|
||||
|------|----------|---------|
|
||||
| 1671 | `test_parse_asl_emboss` | Tests parsing an ASL file for emboss styles, verifying styles are non-empty with UUIDs and effects. Ignored by default (requires fixture). |
|
||||
|
||||
### `hcie-io` — src/test_psd_composite.rs
|
||||
|
||||
| Line | Function | Summary |
|
||||
|------|----------|---------|
|
||||
| 3 | `test_psd_import_sizes_and_offsets` | Imports Example3-mini.psd, checks background has >1M non-zero pixels and named layers have correct data. |
|
||||
| 43 | `test_psd_composite_against_ref` | Full composite comparison of PSD layers against reference PNG, asserting MAE < 2.0 and match ≥ 88%. |
|
||||
| 241 | `test_layer_pixels_direct` | Compares raw layer pixels (layer 0 and all layers) against reference PNG pixel-by-pixel per layer. |
|
||||
| 301 | `test_base_generated_2_vs_merged` | Compares our composited output against ImageMagick-extracted PSD merged image. |
|
||||
| 345 | `test_blend_normal` | Tests basic src-over compositing with known RGBA values and prints expected vs actual. |
|
||||
| 364 | `test_teal_circle_emboss_debug` | Imports base_test_generated_2.psd, extracts the BevelEmboss effect from the teal circle layer. |
|
||||
| 378 | `test_base_generated_2_no_effects` | Composites with all effects disabled and compares against reference to measure baseline error. |
|
||||
| 420 | `test_base_generated_2_composite` | Composite and compare base_test_generated_2.psd against reference PNG. |
|
||||
| 428 | `test_hc_emboss_composite` | Composite and compare hc_emboss.psd against its reference PNG. |
|
||||
| 438 | `test_base_generated_2_effect_isolation` | Disables effects layer-by-layer, measuring MAE delta to isolate which effects contribute most to error. |
|
||||
| 491 | `test_check_soft_orange_pixels` | Prints RGBA values of specific pixels in Soft Orange Shape and Pink Rectangle layers. |
|
||||
| 516 | `test_find_orange_pixels` | Finds non-zero pixel bounds in Soft Orange Shape layer and samples pixels. |
|
||||
| 555 | `test_layer_bounds` | Computes and prints non-zero pixel bounds for every layer in base_test_generated_2.psd. |
|
||||
| 584 | `test_check_layer_offsets` | Finds leftmost/topmost non-zero pixels to determine layer position offsets. |
|
||||
| 621 | `test_save_layer_pixels` | Saves each layer as a separate PNG to /tmp for visual inspection. |
|
||||
| 635 | `test_pixel_layer_contributions` | Prints RGBA contributions from each layer at 5 specific pixel coordinates. |
|
||||
| 658 | `test_load_test_2_psd` | Loads and composites all PSDs from test_2 directory, printing load/composite times. |
|
||||
| 696 | `test_per_layer_effects_vs_photoshop_export` | Per-layer effect comparison: applies effects to each layer and compares against Photoshop-exported PNGs. |
|
||||
| 795 | `test_test2_effects_mae` | Computes MAE for all PSDs in test_2 directory against JPG references and prints average. |
|
||||
| 861 | `test_sultan_effects_mae` | Full sultan.psd composite analysis: per-layer skip analysis, grid-based MAE breakdown, debug saves. |
|
||||
| 1057 | `test_emboss_optimize` | emboss.psd detailed analysis: channel MAE, opaque-only MAE, regional grid, horizontal/vertical line scans, saves debug heatmaps. |
|
||||
| 1246 | `test_effects_survive_ffi_boundary` | Verifies that effects parsed from PSD survive the PSD→protocol layer boundary (effects > 0). |
|
||||
| 1276 | `test_ffi_bincode_roundtrip` | Verifies bincode serialization/deserialization round-trip preserves all layer effects. |
|
||||
|
||||
### `hcie-psd` — src/
|
||||
|
||||
| File | Line | Function | Summary |
|
||||
|------|------|----------|---------|
|
||||
| lib.rs | 341 | `psd_signature_fail` | Verifies that loading a PNG file through Psd::from_bytes returns a PSD signature error. |
|
||||
| psd_channel.rs | 357 | `does_not_read_beyond_rle_channels_bytes` | Verifies RLE channel insertion doesn't read beyond channel bytes by testing 1×1 layer with RLE-compressed Red channel. |
|
||||
| sections/file_header_section.rs | 291 | `valid_channel_count` | Verifies ChannelCount::new accepts all channel counts from 1 to 56. |
|
||||
| sections/file_header_section.rs | 300 | `invalid_channel_count` | Verifies ChannelCount::new rejects 0 and 57. |
|
||||
| sections/file_header_section.rs | 307 | `incorrect_file_header_section_length` | Verifies a 25-byte input returns IncorrectLength error. |
|
||||
| sections/file_header_section.rs | 317 | `first_four_bytes_incorrect` | Tests that invalid first 4 bytes (wrong PSD signature) returns InvalidSignature error. |
|
||||
| sections/file_header_section.rs | 329 | `version_incorrect` | Tests that wrong version number returns InvalidVersion error. |
|
||||
| sections/file_header_section.rs | 339 | `invalid_reserved_section` | Tests that wrong reserved section returns InvalidReserved error. |
|
||||
|
||||
### `hcie-vision` — src/smart_patch.rs
|
||||
|
||||
| Line | Function | Summary |
|
||||
|------|----------|---------|
|
||||
| 415 | `identity_patch_on_uniform_regions_is_stable` | Smart patch on two identical uniform 120-gray 16×16 layers; verifies output changes by at most 1 per channel. |
|
||||
| 433 | `patch_interior_moves_toward_source_gradient` | Patches a gradient source into a uniform destination; verifies exterior unchanged and interior follows gradient direction. |
|
||||
|
||||
### `hcie-build-info` — src/lib.rs
|
||||
|
||||
| Line | Function | Summary |
|
||||
|------|----------|---------|
|
||||
| 31 | `generated_version_contains_the_same_build_id` | Verifies build_id() > 0 and VERSION string ends with "+build.{BUILD_ID}". |
|
||||
|
||||
---
|
||||
|
||||
## Layer 4: ENGINE API
|
||||
|
||||
### `hcie-engine-api` — tests/visual_regression.rs
|
||||
|
||||
| Line | Function | Summary |
|
||||
|------|----------|---------|
|
||||
| 39 | `white_canvas_empty_document` | Validates blank 8×8 transparent document produces deterministic SHA-256 hash. |
|
||||
| 58 | `green_rect_draw_and_composite` | Draws a green rectangle and checks center pixel + deterministic hash. |
|
||||
| 79 | `red_rect_over_green_rect` | Two-layer composite (green bottom, red top) verifying top layer wins + hash. |
|
||||
| 109 | `vector_rect_golden` | Adds a vector rect shape and verifies rendering through Engine API with hash. |
|
||||
| 145 | `brush_stroke_golden` | Draws a brush stroke and verifies deterministic output via golden hash. |
|
||||
| 178 | `invert_filter_golden` | Applies invert filter and validates pixel values + deterministic hash. |
|
||||
| 200 | `undo_after_rect_golden` | Draws then undoes a rectangle; verifies composite hash matches initial state. |
|
||||
| 221 | `vector_fill_toggle_and_delete` | Creates vector rect, toggles fill on/off, deletes shape, verifying state at each step. |
|
||||
|
||||
### `hcie-engine-api` — tests/vector_creation_history.rs
|
||||
|
||||
| Line | Function | Summary |
|
||||
|------|----------|---------|
|
||||
| 35 | `first_vector_shape_is_one_atomic_layer_transaction` | Verifies first vector shape auto-creates vector layer as one history transaction with correct description. |
|
||||
| 71 | `later_vector_shapes_each_add_one_snapshot` | Verifies later vector shapes on the same layer each add one snapshot and undo correctly reduces shape count. |
|
||||
|
||||
### `hcie-engine-api` — tests/meadow_check.rs
|
||||
|
||||
| Line | Function | Summary |
|
||||
|------|----------|---------|
|
||||
| 18 | `meadow_brush_visual_check` | Manual visual check: renders Meadow brush strokes to 512×512 canvas and saves PNG; asserts colored pixels exist. |
|
||||
|
||||
### `hcie-engine-api` — tests/leaves_check.rs
|
||||
|
||||
| Line | Function | Summary |
|
||||
|------|----------|---------|
|
||||
| 11 | `leaves_brush_visual_check` | Manual visual check: renders Leaf brush strokes and saves to PNG; asserts colored pixels exist. |
|
||||
|
||||
### `hcie-engine-api` — tests/stamp_floor_check.rs
|
||||
|
||||
| Line | Function | Summary |
|
||||
|------|----------|---------|
|
||||
| 11 | `stamp_floor_brush_visual_check` | Manual visual check: renders Dirt/StampFloor brush strokes and saves to PNG; asserts colored pixels exist. |
|
||||
|
||||
### `hcie-engine-api` — tests/bitmap_brush_stamp.rs
|
||||
|
||||
| Line | Function | Summary |
|
||||
|------|----------|---------|
|
||||
| 4 | `bitmap_brush_stamp_shape` | Tests bitmap brush stamp produces roughly square bounding box (~8×8) with enough painted pixels. |
|
||||
|
||||
### `hcie-engine-api` — tests/selection_clear.rs
|
||||
|
||||
| Line | Function | Summary |
|
||||
|------|----------|---------|
|
||||
| 30 | `clear_selection_pixels_clears_inside_and_preserves_outside` | Creates 3×3 opaque layer, selects center pixel, clears selection; verifies center cleared, outside preserved. |
|
||||
|
||||
### `hcie-engine-api` — tests/performance_stroke_4k.rs
|
||||
|
||||
| Line | Function | Summary |
|
||||
|------|----------|---------|
|
||||
| 41 | `benchmark_4k_stroke_on_multilayer_document` | Performance benchmark: 10 layers on 3840×2160 canvas, 100 stroke segments with per-segment timing. |
|
||||
|
||||
### `hcie-engine-api` — src/stroke_brush.rs (internal)
|
||||
|
||||
| Line | Function | Summary |
|
||||
|------|----------|---------|
|
||||
| 547 | `watercolor_dirty_radius_covers_jitter_and_scatter` | Verifies brush_dirty_radius for watercolor with jitter/scatter matches expected value 74.8. |
|
||||
|
||||
---
|
||||
|
||||
## Layer 6: GUI — egui
|
||||
|
||||
### `hcie-gui-egui` — tests/canvas_engine.rs
|
||||
|
||||
| Line | Function | Summary |
|
||||
|------|----------|---------|
|
||||
| 23 | `canvas_widget_creation` | Tests CanvasWidget can be created and rendered in an egui test UI. |
|
||||
| 41 | `canvas_widget_zoom_0_1` | Tests canvas widget renders with zoom=0.1. |
|
||||
| 45 | `canvas_widget_zoom_0_5` | Tests canvas widget renders with zoom=0.5. |
|
||||
| 49 | `canvas_widget_zoom_1_0` | Tests canvas widget renders with zoom=1.0. |
|
||||
| 53 | `canvas_widget_zoom_2_0` | Tests canvas widget renders with zoom=2.0. |
|
||||
| 57 | `canvas_widget_zoom_4_0` | Tests canvas widget renders with zoom=4.0. |
|
||||
| 61 | `canvas_widget_zoom_8_0` | Tests canvas widget renders with zoom=8.0. |
|
||||
| 65 | `canvas_widget_zoom_min` | Tests canvas widget renders at ZOOM_MIN. |
|
||||
| 69 | `canvas_widget_zoom_max` | Tests canvas widget renders at ZOOM_MAX. |
|
||||
| 83 | `canvas_widget_pen` | Tests canvas widget with Pen tool. |
|
||||
| 87 | `canvas_widget_brush` | Tests canvas widget with Brush tool. |
|
||||
| 91 | `canvas_widget_eraser` | Tests canvas widget with Eraser tool. |
|
||||
| 95 | `canvas_widget_rect` | Tests canvas widget with VectorRect tool. |
|
||||
| 99 | `canvas_widget_line` | Tests canvas widget with VectorLine tool. |
|
||||
| 103 | `canvas_widget_select` | Tests canvas widget with Select tool. |
|
||||
| 107 | `canvas_widget_lasso` | Tests canvas widget with Lasso tool. |
|
||||
| 111 | `canvas_widget_magic_wand` | Tests canvas widget with MagicWand tool. |
|
||||
| 115 | `canvas_widget_eyedropper` | Tests canvas widget with Eyedropper tool. |
|
||||
| 119 | `canvas_widget_flood_fill` | Tests canvas widget with FloodFill tool. |
|
||||
| 123 | `canvas_widget_gradient` | Tests canvas widget with Gradient tool. |
|
||||
| 127 | `canvas_widget_move` | Tests canvas widget with Move tool. |
|
||||
| 131 | `canvas_widget_crop` | Tests canvas widget with Crop tool. |
|
||||
| 136 | `canvas_widget_with_pan` | Tests canvas widget with pan offset applied. |
|
||||
| 146 | `canvas_widget_with_selection` | Tests canvas widget with a selection rectangle active. |
|
||||
| 156 | `canvas_widget_lasso_with_points` | Tests canvas widget with Lasso tool and lasso points. |
|
||||
| 167 | `canvas_widget_text_active` | Tests canvas widget with Text tool and draft text active. |
|
||||
| 181 | `app_document_zoom_roundtrip` | Tests Zoom round-trip for many zoom values (0.1–64.0) through AppDocument. |
|
||||
| 193 | `engine_composite_after_draw` | Tests Engine compositing after drawing a green rectangle through AppDocument. |
|
||||
| 207 | `engine_multi_layer_composite` | Tests multi-layer compositing through AppDocument (adds layer, draws red rect, checks pixel). |
|
||||
| 218 | `transform_handle_variants` | Basic match test ensuring all TransformHandle variants exist. |
|
||||
| 232 | `selection_transform_from_engine_empty_mask` | Tests SelectionTransform::from_engine with empty mask produces empty transform. |
|
||||
| 241 | `zoom_clamp_values` | Tests zoom clamping for values from -1.0 to 100.0 stays within [ZOOM_MIN, ZOOM_MAX]. |
|
||||
| 257 | `event_bus_events_dispatched_to_engine` | Tests that engine undo changes composite pixel values through AppDocument. |
|
||||
| 267 | `test_text_layer_rotation` | Adds rotated text layer and verifies at least one non-transparent pixel was rasterized. |
|
||||
|
||||
### `hcie-gui-egui` — tests/gui_audit.rs
|
||||
|
||||
| Line | Function | Summary |
|
||||
|------|----------|---------|
|
||||
| 6 | `test_all_tools_in_toolbox_groups` | Verifies every Tool variant is present in TOOL_SLOTS. |
|
||||
| 23 | `test_all_tools_have_labels` | Verifies every tool has a non-empty label(). |
|
||||
| 32 | `test_all_tools_have_icons` | Verifies every tool has a non-empty icon(). |
|
||||
| 41 | `test_all_blend_modes_have_labels` | Verifies every BlendMode has a non-empty label(). |
|
||||
| 50 | `test_tools_menu_coverage` | Verifies expected tools in menu are defined in Tool::ALL. |
|
||||
| 81 | `test_view_menu_panels_match` | Verifies panel labels are unique. |
|
||||
| 102 | `test_all_tools_layer_type_known` | Calls allowed_layer_type() on every tool to ensure it doesn't panic. |
|
||||
| 110 | `test_feature_flags_are_non_empty` | Verifies all_features() returns non-empty flags including "layers", "undo_redo", "selection". |
|
||||
| 123 | `test_app_event_variants_exist` | Creates an EventBus to verify the module exists. |
|
||||
| 131 | `test_dock_panels_have_icons` | Verifies all HciePane variants have icons. |
|
||||
| 154 | `test_shape_tools_consistency` | Verifies 15 specific shape tools return true for is_shape_tool(). |
|
||||
| 181 | `test_raster_tools_consistency` | Verifies raster tools are also raster_compatible. |
|
||||
| 194 | `test_brush_presets` | Verifies 5 brush presets have positive size and opacity. |
|
||||
| 211 | `test_canvas_presets` | Verifies 1080p and Instagram canvas presets have correct dimensions. |
|
||||
|
||||
### `hcie-gui-egui` — tests/widget_ui.rs
|
||||
|
||||
| Line | Function | Summary |
|
||||
|------|----------|---------|
|
||||
| 24 | `plain_slider_default_range` | Tests slider with in-range value (50 in 0..100) stays at 50. |
|
||||
| 29 | `plain_slider_out_of_range_clamps` | Tests slider clamps value above range to max. |
|
||||
| 34 | `plain_slider_negative_clamps` | Tests slider clamps negative value to range min. |
|
||||
| 39 | `plain_slider_suffix` | Tests slider renders with "px" suffix. |
|
||||
| 49 | `plain_slider_integer_type` | Tests slider with i32 integer type. |
|
||||
| 58 | `plain_slider_custom_width` | Tests slider with custom width. |
|
||||
| 68 | `plain_slider_both_themes` | Tests slider renders with both ProDark and PhotoshopLight themes. |
|
||||
| 79 | `tool_state_default_active_tool_is_brush` | Verifies default active tool is Brush. |
|
||||
| 85 | `tool_state_default_colors` | Verifies default primary (black) and secondary (white) colors. |
|
||||
| 92 | `tool_state_default_pressure` | Verifies default pressure is 1.0. |
|
||||
| 98 | `tool_state_default_not_drawing` | Verifies default state has is_drawing=false, no drag start, no selection rect. |
|
||||
| 106 | `tool_state_default_tool_configs` | Verifies active_size() returns a positive value. |
|
||||
| 113 | `tool_state_default_brush_presets` | Verifies default brush presets exist and active preset is "basic_round". |
|
||||
| 120 | `tool_state_pinned_panels_default` | Verifies default pinned panels include Tools, Brushes & Tips, Color Palette, Layers. |
|
||||
| 129 | `app_document_new_defaults` | Tests AppDocument default state (name, zoom=1, no textures, empty buffer). |
|
||||
| 139 | `app_document_engine_accessible` | Verifies AppDocument wraps Engine with correct canvas dimensions. |
|
||||
| 147 | `app_document_default_name` | Tests AppDocument name is set correctly. |
|
||||
| 153 | `event_bus_push_pop` | Tests EventBus push and pop single event. |
|
||||
| 166 | `event_bus_fifo_order` | Tests EventBus maintains FIFO order. |
|
||||
| 177 | `event_bus_drain` | Tests EventBus drain returns all events. |
|
||||
| 189 | `event_bus_tool_changed` | Tests EventBus pushes/reads ToolChanged event. |
|
||||
| 197 | `event_bus_zoom_set` | Tests EventBus pushes/reads ZoomSet event. |
|
||||
| 205 | `event_bus_theme_changed` | Tests EventBus pushes/reads ThemeChanged event. |
|
||||
| 216 | `event_bus_apply_filter` | Tests EventBus pushes/reads ApplyFilter event with params. |
|
||||
| 226 | `event_bus_multiple_events` | Tests EventBus with three selection events in sequence. |
|
||||
| 238 | `event_bus_new_empty` | Tests new EventBus is empty. |
|
||||
| 245 | `event_bus_doc_created` | Tests EventBus pushes/reads DocCreated event with name, width, height. |
|
||||
| 260 | `tool_button_selected` | Tests ToolButton renders in selected state. |
|
||||
| 268 | `tool_button_unselected` | Tests ToolButton renders in unselected state. |
|
||||
| 276 | `tool_button_both_themes` | Tests ToolButton renders with both themes. |
|
||||
| 286 | `plain_slider_logarithmic` | Tests slider in logarithmic mode. |
|
||||
| 296 | `plain_slider_snap_steps` | Tests slider with snap steps. |
|
||||
| 306 | `plain_slider_percent_suffix` | Tests slider with "%" suffix. |
|
||||
| 316 | `plain_slider_u8_range` | Tests slider with u8 range. |
|
||||
| 325 | `plain_slider_usize_range` | Tests slider with usize range. |
|
||||
| 334 | `selection_op_variants` | Tests SelectionOp enum variants can be constructed. |
|
||||
| 343 | `event_bus_selection_shrink` | Tests EventBus SelectionShrink event. |
|
||||
| 351 | `event_bus_selection_feather` | Tests EventBus SelectionFeather event. |
|
||||
| 359 | `event_bus_selection_erode` | Tests EventBus SelectionErode event. |
|
||||
| 367 | `event_bus_selection_fade` | Tests EventBus SelectionFade event. |
|
||||
| 375 | `event_bus_clipboard_cut` | Tests EventBus ClipboardCut event. |
|
||||
| 383 | `event_bus_clipboard_paste_special` | Tests EventBus ClipboardPasteSpecial event. |
|
||||
| 391 | `event_bus_layer_added` | Tests EventBus LayerAdded event. |
|
||||
| 399 | `event_bus_layer_deleted` | Tests EventBus LayerDeleted(42) event. |
|
||||
| 407 | `event_bus_rotate_clockwise` | Tests EventBus RotateClockwise event. |
|
||||
| 415 | `event_bus_rotate_counter_clockwise` | Tests EventBus RotateCounterClockwise event. |
|
||||
| 423 | `event_bus_rotate_180` | Tests EventBus Rotate180 event. |
|
||||
| 431 | `event_bus_rotate_layer` | Tests EventBus RotateLayer(45) event. |
|
||||
| 439 | `event_bus_flip_horizontal` | Tests EventBus FlipHorizontal event. |
|
||||
| 447 | `event_bus_flip_vertical` | Tests EventBus FlipVertical event. |
|
||||
| 455 | `event_bus_layer_clear` | Tests EventBus LayerClear event. |
|
||||
| 463 | `event_bus_transform_start` | Tests EventBus TransformStart event. |
|
||||
| 471 | `event_bus_image_size` | Tests EventBus ImageSize event. |
|
||||
| 479 | `event_bus_canvas_size` | Tests EventBus CanvasSize event. |
|
||||
| 487 | `event_bus_ai_chat_submit` | Tests EventBus AiChatSubmit event. |
|
||||
| 495 | `event_bus_adjustment_brightness_contrast` | Tests EventBus ApplyAdjustmentBrightnessContrast event. |
|
||||
| 505 | `event_bus_adjustment_hue_saturation` | Tests EventBus ApplyAdjustmentHueSaturation event. |
|
||||
| 515 | `event_bus_vector_shape_deleted` | Tests EventBus VectorShapeDeleted event. |
|
||||
| 523 | `event_bus_file_export_import` | Tests EventBus Export/Import/ImportBrushes events. |
|
||||
| 534 | `event_bus_align_all_axes` | Tests EventBus AlignLayer event for all 6 align axes (HCenter, VCenter, Top, Bottom, Left, Right). |
|
||||
|
||||
### `hcie-gui-egui` — src/brush_import.rs (internal)
|
||||
|
||||
| Line | Function | Summary |
|
||||
|------|----------|---------|
|
||||
| 604 | `test_extract_desc_block_names` | Tests extracting descriptor block names from an ABR brush file; verifies names are descriptive. |
|
||||
| 623 | `test_abr_import_with_names` | Tests importing ABR brush presets; verifies bitmap presets have descriptive names. |
|
||||
|
||||
---
|
||||
|
||||
## Layer 6: GUI — iced
|
||||
|
||||
### `hcie-iced-gui` — tests/selection_test.rs
|
||||
|
||||
| Line | Function | Summary |
|
||||
|------|----------|---------|
|
||||
| 11 | `test_selection_transform_is_empty` | Verifies empty SelectionTransform returns true for is_empty(). |
|
||||
| 24 | `test_selection_transform_with_data` | Verifies non-empty SelectionTransform returns false for is_empty(). |
|
||||
| 37 | `test_crop_state_lifecycle` | Tests CropState: start_drag, update_drag, end_drag, confirm lifecycle. |
|
||||
| 63 | `test_crop_cancel` | Tests CropState cancel restores idle state. |
|
||||
| 73 | `test_transform_handle_cursor_hints` | Verifies cursor_hint() returns correct strings for Move, TopLeft, Rotate handles. |
|
||||
| 80 | `selection_modes_combine_alpha_without_losing_soft_edges` | Tests combine_masks with Replace, Add, Subtract, Intersect modes. |
|
||||
| 99 | `irregular_mask_bounds_and_fill_spans_are_exact` | Tests mask_bounds and selected_spans on irregular mask. |
|
||||
| 109 | `crop_clamps_drag_and_cancel_restores_idle_state` | Tests CropState start_drag_clamped, update_drag clamped to canvas, cancel restores state. |
|
||||
| 120 | `clipboard_transform_is_centered_and_transform_geometry_is_safe` | Tests centered_transform produces correct position and sanitize_geometry handles NaN/negative sizes. |
|
||||
| 136 | `inverse_affine_identity_sampling_is_exact` | Verifies the inverse-affine rasterizer preserves identity placement exactly. |
|
||||
| 159 | `rotated_bounds_expand_to_cover_corners` | Tests that rotated bounds include every corner rather than clipping to unrotated box. |
|
||||
| 177 | `rotated_handle_hit_test_and_resize_share_geometry` | Verifies rotated handle hit-testing and opposite-corner anchoring during resize. |
|
||||
| 204 | `rotation_drag_adds_delta_to_original_angle` | Verifies rotation uses drag-start delta and retains pre-existing rotation. |
|
||||
|
||||
### `hcie-iced-gui` — tests/feature_scorecard.rs
|
||||
|
||||
| Line | Function | Summary |
|
||||
|------|----------|---------|
|
||||
| 55 | `semantic_menu_dispatch_has_no_positional_message` | Verifies menu uses semantic MenuCommand dispatch not positional (usize, usize). |
|
||||
| 73 | `cut_does_not_dispatch_copy` | Verifies Cut uses distinct MenuCommand::Cut not menu-alias-based Copy. |
|
||||
| 91 | `cycle_three_canvas_selection_clipboard_crop_text_paths_are_connected` | Verifies selection overlay, distinct cut, transform placement, crop lifecycle, text multiline, gradient, canvas input parity, and pen/spray distinct. |
|
||||
| 152 | `screenshot_cli_uses_native_capture_and_panel_crop` | Verifies screenshot CLI uses native window capture and panel crop functions. |
|
||||
| 170 | `cycle_one_state_paths_are_complete_and_persisted` | Verifies dock drop targets, theme sync isolation, and sidebar mode persistence. |
|
||||
| 211 | `cycle_two_vector_completion_paths_are_connected` | Verifies vector screen-constant handles, constrained drag, layer-bound cancel, boolean result, and clamped controls. |
|
||||
| 252 | `cycle_four_panel_and_document_paths_are_connected` | Verifies layer hierarchy controls, style cancel/restore, immutable previews, filter schema coverage, document path identity, and close-save continuation. |
|
||||
| 316 | `cycle_five_practical_paths_are_connected` | Verifies AI real streaming, reasoning transcript, validated script output, viewer deterministic navigation, and safe edit. |
|
||||
|
||||
### `hcie-iced-gui` — tests/raster_test.rs
|
||||
|
||||
| Line | Function | Summary |
|
||||
|------|----------|---------|
|
||||
| 6 | `linear_gradient_interpolates_and_respects_mask` | Tests linear gradient interpolation with pixel mask applied. |
|
||||
| 27 | `radial_gradient_uses_distance_from_origin` | Tests radial gradient using distance from origin. |
|
||||
|
||||
### `hcie-iced-gui` — src/app.rs (internal)
|
||||
|
||||
| Line | Function | Summary |
|
||||
|------|----------|---------|
|
||||
| 1536 | `move_commit_pushes_one_named_snapshot` | Tests apply_transform_to_layer pushes exactly one history entry named "Move Selection". |
|
||||
| 10083 | `popup_and_menu_escape_priority_is_stable` | Tests overlay_escape_target returns correct priority ordering. |
|
||||
| 10098 | `document_tab_close_uses_semantic_continuations` | Tests document_close_disposition for dirty, clean, last-tab, and invalid scenarios. |
|
||||
| 10119 | `document_tab_close_keeps_active_index_safe` | Tests active_index_after_document_close produces safe indices. |
|
||||
| 10128 | `selection_sync_request_is_consumed_once` | Tests consume_selection_sync_request flag is consumed once and stays false until reset. |
|
||||
| 10140 | `dirty_region_union_retains_all_updates_before_view` | Tests union_regions correctly merges two overlapping regions. |
|
||||
| 10152 | `proximate_identical_color_is_skipped` | Tests is_proximate_to_last returns true for identical colors (diff=0 ≤ threshold). |
|
||||
| 10163 | `proximate_small_slider_step_skipped` | Tests is_proximate_to_last returns true for +1 channel change. |
|
||||
| 10171 | `large_change_not_proximate` | Tests is_proximate_to_last returns false for +3 in all channels. |
|
||||
| 10179 | `three_unit_change_exceeds_threshold` | Tests is_proximate_to_last returns false for single +3 channel change. |
|
||||
| 10187 | `mixed_small_changes_are_proximate` | Tests is_proximate_to_last returns true for +1 in all channels. |
|
||||
|
||||
### `hcie-iced-gui` — src/app/transform_dirty.rs
|
||||
|
||||
| Line | Function | Summary |
|
||||
|------|----------|---------|
|
||||
| 70 | `consecutive_positions_retain_old_and_new_extents` | Tests that two consecutive transform positions produce correct old+new union bounds. |
|
||||
|
||||
### `hcie-iced-gui` — src/color_picker.rs
|
||||
|
||||
| Line | Function | Summary |
|
||||
|------|----------|---------|
|
||||
| 1048 | `rgb_hsl_roundtrip_preserves_channels_with_rounding_tolerance` | Tests RGB→HSL→RGB roundtrip for 4 colors with ≤1 tolerance per channel. |
|
||||
| 1059 | `rgb_hsv_roundtrip_preserves_channels_with_rounding_tolerance` | Tests RGB→HSV→RGB roundtrip for 4 colors with ≤1 tolerance per channel. |
|
||||
| 1070 | `hex_parser_preserves_or_explicitly_updates_alpha` | Tests parse_hex_color with 6-digit, 8-digit, and invalid hex strings. |
|
||||
|
||||
### `hcie-iced-gui` — src/ai_chat.rs
|
||||
|
||||
| Line | Function | Summary |
|
||||
|------|----------|---------|
|
||||
| 794 | `canvas_context_replaces_only_latest_user_payload` | Tests build_messages_json replaces only the latest user message with canvas context. |
|
||||
| 818 | `transcript_includes_reasoning_and_tool_results` | Tests transcript_text includes reasoning and tool messages correctly. |
|
||||
|
||||
### `hcie-iced-gui` — src/ai_script.rs
|
||||
|
||||
| Line | Function | Summary |
|
||||
|------|----------|---------|
|
||||
| 244 | `generated_output_must_parse_before_run_is_enabled` | Tests validate_script_output accepts valid DSL and rejects invalid input. |
|
||||
|
||||
### `hcie-iced-gui` — src/screenshot.rs
|
||||
|
||||
| Line | Function | Summary |
|
||||
|------|----------|---------|
|
||||
| 126 | `physical_crop_scales_and_clamps` | Tests physical_crop_rect scaling rounds outward and clamps to viewport. |
|
||||
|
||||
### `hcie-iced-gui` — src/sidebar/mod.rs
|
||||
|
||||
| Line | Function | Summary |
|
||||
|------|----------|---------|
|
||||
| 858 | `every_subtool_has_an_svg_icon` | Tests that every tool in TOOL_SLOTS has a resolvable SVG icon path. |
|
||||
|
||||
### `hcie-iced-gui` — src/theme.rs
|
||||
|
||||
| Line | Function | Summary |
|
||||
|------|----------|---------|
|
||||
| 65 | `theme_state_retains_and_reports_selected_preset` | Tests ThemeState preset changes are retained and colors updated accordingly. |
|
||||
|
||||
### `hcie-iced-gui` — src/viewer/mod.rs
|
||||
|
||||
| Line | Function | Summary |
|
||||
|------|----------|---------|
|
||||
| 989 | `navigation_wraps_deterministically` | Tests ViewerState prev/next wraps correctly with 2 images. |
|
||||
| 999 | `supported_extensions_are_case_insensitive` | Tests is_supported_image is case-insensitive and rejects non-image files. |
|
||||
|
||||
### `hcie-iced-gui` — src/settings.rs
|
||||
|
||||
| Line | Function | Summary |
|
||||
|------|----------|---------|
|
||||
| 367 | `legacy_settings_default_new_cycle_one_fields` | Tests deserializing legacy settings (without new fields) gets safe defaults. |
|
||||
|
||||
### `hcie-iced-gui` — src/cli.rs
|
||||
|
||||
| Line | Function | Summary |
|
||||
|------|----------|---------|
|
||||
| 206 | `parses_full_screenshot_and_startup_file` | Tests CLI parses --screenshot flag with output file and startup file. |
|
||||
| 223 | `parses_named_panel_screenshot` | Tests CLI parses --screenshot-panel flag. |
|
||||
| 229 | `parses_svg_editor_screenshot` | Tests CLI parses --screenshot-svg-editor flag. |
|
||||
| 237 | `rejects_duplicate_positional_files` | Tests CLI rejects multiple positional file arguments. |
|
||||
| 242 | `supports_dash_prefixed_file_after_terminator` | Tests CLI supports dash-prefixed filenames after -- terminator. |
|
||||
|
||||
### `hcie-iced-gui` — src/canvas/shader_canvas.rs
|
||||
|
||||
| Line | Function | Summary |
|
||||
|------|----------|---------|
|
||||
| 1282 | `selection_overlay_uses_one_texture_sample` | Verifies the WGSL shader uses exactly one `textureSample(selection_texture` call (anti-regression for nine-sample detector). |
|
||||
|
||||
### `hcie-iced-gui` — src/canvas/texture_update.rs
|
||||
|
||||
| Line | Function | Summary |
|
||||
|------|----------|---------|
|
||||
| 196 | `retained_shader_reference_keeps_stable_allocation_and_dirty_budget` | Tests that retained shader reference keeps stable allocation and correct dirty budget over 120 updates. |
|
||||
| 227 | `legacy_arc_copy_on_write_reproduction_is_full_canvas` | Demonstrates that Arc::get_mut on a cloned Arc returns None, forcing 33MB fallback clone (documents removed CoW fallback). |
|
||||
| 237 | `packed_update_is_exact_and_bounds_checked` | Tests TextureUpdate::pack produces correct rows and rejects invalid regions. |
|
||||
| 253 | `diagnostic_4k_dirty_staging_latency` | Diagnostic benchmark (ignored) measuring 4K dirty region staging latency and byte reduction vs legacy. |
|
||||
|
||||
### `hcie-iced-gui` — src/selection/state.rs
|
||||
|
||||
| Line | Function | Summary |
|
||||
|------|----------|---------|
|
||||
| 188 | `selection_texture_encodes_membership_and_border_once` | Tests encode_selection_texture correctly classifies interior (128), border (255), and empty (0). |
|
||||
| 212 | `feathered_mask_uses_existing_threshold` | Tests feathered mask encoding: values >127 become 255 (border), ≤127 become 0 (outside). |
|
||||
|
||||
### `hcie-iced-gui` — src/panels/menus.rs
|
||||
|
||||
| Line | Function | Summary |
|
||||
|------|----------|---------|
|
||||
| 1205 | `enabled_menu_leaves_have_commands` | Verifies enabled menu items always have a command and submenus never have commands. |
|
||||
| 1232 | `recent_file_commands_are_path_based` | Tests recent file menu items use path-based commands, not positional indices. |
|
||||
| 1253 | `implemented_static_menu_leaves_match_audited_set` | Snapshot test: verifies all 153 enabled menu entries match expected paths. |
|
||||
| 1423 | `context_cut_is_enabled_and_distinct_from_copy` | Verifies context Cut uses MenuCommand::Cut with enabled=true. |
|
||||
| 1432 | `shared_menu_metrics_keep_rows_compact_and_gutters_stable` | Verifies MENU_ROW_HEIGHT, paddings, gutters, and arrow character are within expected ranges. |
|
||||
|
||||
### `hcie-iced-gui` — src/panels/styles.rs
|
||||
|
||||
| Line | Function | Summary |
|
||||
|------|----------|---------|
|
||||
| 319 | `tooltip_balloon_is_opaque_and_uses_theme_contrast_tokens` | Tests tooltip balloon background opacity, text color, border, and shadow in two themes. |
|
||||
|
||||
### `hcie-iced-gui` — src/panels/custom_shapes.rs
|
||||
|
||||
| Line | Function | Summary |
|
||||
|------|----------|---------|
|
||||
| 211 | `responsive_grid_adds_columns_as_width_grows` | Tests grid_column_count adds one column per ~64px of width (with margin). |
|
||||
|
||||
### `hcie-iced-gui` — src/panels/brushes.rs
|
||||
|
||||
| Line | Function | Summary |
|
||||
|------|----------|---------|
|
||||
| 874 | `catalog_contains_every_engine_brush_style` | Asserts BRUSH_STYLES has exactly 34 entries. |
|
||||
| 879 | `category_filter_keeps_expected_groups` | Tests category_matches_style for various category combinations. |
|
||||
| 900 | `media_crates_expose_complete_unique_catalog` | Verifies all media brush crates combined give exactly 47 unique presets. |
|
||||
|
||||
### `hcie-iced-gui` — src/panels/svg_editor.rs
|
||||
|
||||
| Line | Function | Summary |
|
||||
|------|----------|---------|
|
||||
| 874 | `straight_paths_retain_author_nodes` | Tests straight SVG paths retain their exact node count. |
|
||||
| 882 | `curved_paths_fall_back_to_dense_visual_nodes` | Tests curved SVG paths flatten to ≥19 visual nodes. |
|
||||
| 892 | `selection_updates_numeric_inputs_and_snap_is_deterministic` | Tests node selection updates x/y inputs, snap function works correctly, and toggling snap off. |
|
||||
|
||||
### `hcie-iced-gui` — src/panels/title_bar.rs
|
||||
|
||||
| Line | Function | Summary |
|
||||
|------|----------|---------|
|
||||
| 299 | `menu_anchors_are_deterministic_across_viewport_widths` | Tests menu_anchor_x calculation for various indices and viewport widths. |
|
||||
|
||||
### `hcie-iced-gui` — src/vector_edit.rs
|
||||
|
||||
| Line | Function | Summary |
|
||||
|------|----------|---------|
|
||||
| 386 | `overlap_selection_cycles_without_external_counter` | Tests cycle_selection cycles through hit indices in reverse order without external state. |
|
||||
| 397 | `handle_hit_area_is_screen_constant_across_zoom` | Tests hit_test_handle returns correct handle at two different zoom levels. |
|
||||
| 416 | `rotation_handle_offset_is_screen_constant_at_low_zoom` | Tests rotation handle offset is screen-constant at 0.25 zoom. |
|
||||
| 430 | `every_resize_handle_prevents_inversion_and_nan` | Tests all 8 resize handles prevent inversion and NaN with extreme/finite deltas. |
|
||||
| 463 | `shift_aspect_and_alt_center_constraints_hold` | Tests Shift+Alt resize preserves 2:1 aspect ratio and original center. |
|
||||
| 480 | `drag_is_deterministic_and_rejects_non_finite_input` | Tests transform_shape returns correct moved bounds and handles NaN gracefully. |
|
||||
| 498 | `rotated_handle_hit_test_uses_rotated_position` | Tests hit_test_handle works correctly with a 90° rotated shape. |
|
||||
|
||||
### `hcie-iced-gui` — src/widgets/plain_slider.rs
|
||||
|
||||
| Line | Function | Summary |
|
||||
|------|----------|---------|
|
||||
| 442 | `percentage_format_parse_and_clamp_are_stable` | Tests slider format_value with "%" suffix and parse_value clamping. |
|
||||
| 450 | `hover_typing_accepts_only_numeric_fragments` | Tests typing state accepts "-" and numeric fragments, rejects "px". |
|
||||
| 460 | `pointer_mapping_respects_endpoints_and_step` | Tests value_at maps 0→0%, 100→100%, 33→~33%. |
|
||||
|
||||
### `hcie-iced-gui` — src/dock/state.rs
|
||||
|
||||
| Line | Function | Summary |
|
||||
|------|----------|---------|
|
||||
| 161 | `pane_labels_round_trip` | Tests PaneType::from_label round-trip is case-sensitive. |
|
||||
| 168 | `pane_drops_support_all_regions_and_preserve_one_canvas` | Tests all dock drop regions accept non-Canvas panes and reject Center for non-Canvas. |
|
||||
| 195 | `canvas_drop_is_rejected_and_auto_hide_restores_uniquely` | Tests Canvas pane drops reject, auto-hide/restore works, and invariants hold. |
|
||||
| 214 | `floating_panel_redocks_at_preview_target_and_can_minimize` | Tests floating panel float, redock at target, auto-hide floating, and invariants. |
|
||||
| 234 | `global_release_clears_click_only_and_floating_drag_state` | Tests cancel_transient_drags clears both drag and floating_drag state. |
|
||||
|
||||
### `hcie-iced-gui` — src/dock/manager.rs
|
||||
|
||||
| Line | Function | Summary |
|
||||
|------|----------|---------|
|
||||
| 145 | `policy_matrix_allows_only_tool_edges` | Tests DockDropPolicy::accepts rejects Canvas for Center and allows non-Canvas for edges. |
|
||||
| 162 | `document_cannot_close_auto_hide_or_float` | Tests DockDropPolicy::accepts_placement rejects Canvas for Closed, AutoHidden, Floating. |
|
||||
|
||||
### `hcie-iced-gui` — src/dock/sizing.rs
|
||||
|
||||
| Line | Function | Summary |
|
||||
|------|----------|---------|
|
||||
| 436 | `policy_values_prioritize_canvas_and_bound_color_picker` | Tests panel_size_policy values for Canvas (min 320×240, grow priority) and ColorPicker (preferred 280×460, bounded). |
|
||||
| 451 | `subtree_constraints_follow_split_axis` | Tests aggregate_subtree_policy produces correct min/max for Canvas+ColorPicker split. |
|
||||
| 462 | `default_and_compact_viewports_have_feasible_bounds` | Tests both 1280×820 and 1100×700 viewports satisfy all panel minimums in default dock. |
|
||||
| 483 | `color_picker_does_not_receive_vertical_surplus_with_list_sibling` | Tests vertical surplus distribution keeps ColorPicker within its height bounds with a Layers sibling. |
|
||||
| 509 | `structure_rebalance_avoids_oversized_color_picker` | Tests StructureChanged rebalance corrects 50/50 split to give Canvas >60%. |
|
||||
| 526 | `user_resize_preserves_valid_and_clamps_invalid_ratios` | Tests constrain_user_resize_ratio preserves 0.7 and clamps 0.95 to a feasible range. |
|
||||
| 536 | `window_growth_gives_surplus_to_canvas` | Tests WindowResize rebalance gives all extra width to Canvas, not utility panels. |
|
||||
| 555 | `color_picker_minimum_height_is_420` | Asserts ColorPicker minimum height policy is exactly 420 pixels. |
|
||||
|
||||
### `hcie-iced-gui` — src/dock/persistence.rs
|
||||
|
||||
| Line | Function | Summary |
|
||||
|------|----------|---------|
|
||||
| 171 | `persisted_layout_round_trips_without_runtime_ids` | Tests PersistedDockLayout serialization strips runtime Pane IDs and round-trips with valid invariants. |
|
||||
| 180 | `malformed_tree_deduplicates_and_injects_canvas` | Tests restore handles duplicate panels (two Layers) by deduplicating and injecting Canvas. |
|
||||
| 201 | `missing_new_fields_are_serde_compatible` | Tests that minimal JSON ({"tree":null}) deserializes with empty auto_hidden and floating. |
|
||||
| 208 | `ratio_sanitization_accepts_extreme_finite_values_only` | Tests sanitized_ratio accepts 0.02, clamps 0.999→0.99, and converts NaN→0.5. |
|
||||
| 221 | `floating_round_trip_clamps_and_redocks_uniquely` | Tests floating panel serialization, clamp within viewport, and unique redock. |
|
||||
|
||||
### `hcie-iced-gui` — src/dock/view.rs
|
||||
|
||||
| Line | Function | Summary |
|
||||
|------|----------|---------|
|
||||
| 707 | `title_bar_uses_explicit_drag_handle_and_separate_controls` | Verifies the dock title bar uses explicit mouse_area drag handle and separate controls, not Fill space. |
|
||||
|
||||
### `hcie-iced-gui` — src/dock/preview.rs
|
||||
|
||||
| Line | Function | Summary |
|
||||
|------|----------|---------|
|
||||
| 198 | `centers_are_never_approved` | Tests approved_target returns None for grid center point. |
|
||||
| 210 | `outer_and_pane_edges_are_approved` | Tests approved_target correctly identifies left outer edge and top pane edge. |
|
||||
| 230 | `narrow_viewport_clamps_to_empty_bounds` | Tests narrow viewport (toolbox > viewport width) produces zero-size grid. |
|
||||
| 239 | `edge_classification_matches_iced_thirds_and_priority` | Tests pane_edge returns correct edge within first third and None in middle. |
|
||||
| 257 | `outer_edge_uses_iced_dynamic_thickness_and_priority` | Tests outer_edge returns correct edge within first 10px and None beyond. |
|
||||
|
||||
### `hcie-iced-gui` — src/dock/floating.rs
|
||||
|
||||
| Line | Function | Summary |
|
||||
|------|----------|---------|
|
||||
| 118 | `default_rect_clamps_to_small_viewports` | Tests default_rect clamps position so 240×180 window fits in 4-pane viewport. |
|
||||
|
||||
---
|
||||
|
||||
## Brush Catalog Crates
|
||||
|
||||
| Crate | File | Line | Function | Summary |
|
||||
|-------|------|------|----------|---------|
|
||||
| `hcie-dry-media-brushes` | src/lib.rs | 246 | `dry_media_catalog_is_complete_and_unique` | Verifies exactly 19 dry media presets with unique IDs. |
|
||||
| `hcie-paint-brushes` | src/lib.rs | 129 | `paint_catalog_is_complete_and_unique` | Verifies exactly 7 paint presets with unique IDs. |
|
||||
| `hcie-digital-brushes` | src/lib.rs | 144 | `digital_catalog_separates_raster_and_vector_tools` | Verifies 5 digital presets and 2 vector brush presets. |
|
||||
| `hcie-watercolor-brushes` | src/lib.rs | 438 | `presets_count` | Verifies exactly 8 watercolor presets. |
|
||||
| `hcie-watercolor-brushes` | src/lib.rs | 443 | `all_presets_use_watercolor_style` | Verifies all watercolor presets use BrushStyle::Watercolor. |
|
||||
| `hcie-watercolor-brushes` | src/lib.rs | 450 | `splat_dimensions` | Verifies render_watercolor_splat produces SPLAT_SIZE×SPLAT_SIZE×4 pixels. |
|
||||
| `hcie-watercolor-brushes` | src/lib.rs | 456 | `splat_has_nonzero_alpha` | Verifies the rendered splat has at least one visible (non-zero alpha) pixel. |
|
||||
| `hcie-ink-brushes` | src/lib.rs | 141 | `ink_catalog_is_complete_and_unique` | Verifies exactly 8 ink presets with unique IDs. |
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
| Category | Approx. Count |
|
||||
|----------|--------------|
|
||||
| Layer 1: DATA (protocol, color) | 27 |
|
||||
| Layer 2: ENGINE CORE (blend, brush, draw, composite, filter, selection, vector, history, fx, io, psd, vision, build-info) | ~155 |
|
||||
| Layer 4: ENGINE API | 15 |
|
||||
| Layer 6: GUI — egui (canvas_engine, gui_audit, widget_ui, brush_import) | ~90 |
|
||||
| Layer 6: GUI — iced (selection, feature_scorecard, raster, app, dock, panels, widgets, color, ai, etc.) | ~120+ |
|
||||
| Brush catalog crates | 8 |
|
||||
| **Total** | **~415+** |
|
||||
Executable
BIN
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user