Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ee6ad2dc20 | |||
| 6b02f19624 | |||
| a056419c24 | |||
| 505562424b | |||
|
7496f5e208
|
|||
|
fba10b753c
|
|||
|
f2cca7e6a1
|
|||
|
a2264b130d
|
|||
|
e9dad7ef0c
|
|||
| 3bac629045 | |||
| 135ab8a628 | |||
| 513efcdcff | |||
| 8447b2d796 | |||
| 593522e83f |
@@ -1,3 +1,12 @@
|
||||
[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"
|
||||
|
||||
[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"
|
||||
|
||||
@@ -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 @@
|
||||
,dev-user,hc-20w1s30u2j,24.07.2026 16:44,file:///home/dev-user/.config/libreoffice/4;
|
||||
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 `./target/criterion/report/index.html` olarak dökülecektir._
|
||||
@@ -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 }
|
||||
|
||||
+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 }
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -34,9 +34,27 @@ usvg = { workspace = true }
|
||||
|
||||
[lib]
|
||||
crate-type = ["rlib", "staticlib"]
|
||||
bench = false
|
||||
|
||||
[dev-dependencies]
|
||||
rstest = "0.23"
|
||||
proptest = "1.5"
|
||||
approx = "0.5"
|
||||
sha2 = "0.10"
|
||||
criterion = { version = "0.5", features = ["html_reports"] }
|
||||
|
||||
[[bench]]
|
||||
name = "stroke_mask_pooling"
|
||||
harness = false
|
||||
|
||||
[[bench]]
|
||||
name = "composite_scratch_pooling"
|
||||
harness = false
|
||||
|
||||
[[bench]]
|
||||
name = "below_cache_reuse"
|
||||
harness = false
|
||||
|
||||
[[bench]]
|
||||
name = "effects_skip"
|
||||
harness = false
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
//! Criterion benchmark: `below_cache` reuse across consecutive strokes.
|
||||
//!
|
||||
//! ## Purpose
|
||||
//! Verifies that painting multiple strokes on the **same layer** reuses the
|
||||
//! pre-built `below_cache` (composite of all layers below the active one)
|
||||
//! instead of rebuilding it from scratch on every `begin_stroke()`.
|
||||
//!
|
||||
//! On a 4K 10-layer document the below-layer composite costs ~15–25 ms.
|
||||
//! If `below_cache_dirty` is incorrectly set to `true` after every
|
||||
//! `end_stroke()`, each subsequent stroke pays that cost again.
|
||||
//!
|
||||
//! ## Logic & Workflow
|
||||
//! 1. Creates a 3840×2160, 10-layer document (all filled).
|
||||
//! 2. **first_stroke**: Benchmarks a full stroke including below_cache build.
|
||||
//! 3. **second_stroke_same_layer**: Benchmarks a stroke on the same layer
|
||||
//! where `below_cache` should be reused (much cheaper `begin_stroke`).
|
||||
//!
|
||||
//! When the cache works correctly, the second stroke's `begin_stroke()` is
|
||||
//! essentially free (just a dirty-flag check), while the first one must
|
||||
//! composite 9 below-layers into a 33 MB buffer.
|
||||
//!
|
||||
//! ## Arguments & Returns
|
||||
//! Standard criterion benchmark — run with:
|
||||
//! ```bash
|
||||
//! cargo bench -p hcie-engine-api --bench below_cache_reuse
|
||||
//! ```
|
||||
//!
|
||||
//! ## Side Effects / Dependencies
|
||||
//! Allocates ~400 MB (10 layers × 4K RGBA + below_cache + composite_scratch).
|
||||
|
||||
use criterion::{criterion_group, criterion_main, Criterion};
|
||||
use hcie_engine_api::{BrushStyle, BrushTip, Engine};
|
||||
|
||||
/// Canvas dimensions — 4K UHD.
|
||||
const W: u32 = 3840;
|
||||
const H: u32 = 2160;
|
||||
/// Number of raster layers.
|
||||
const LAYERS: usize = 10;
|
||||
|
||||
/// Builds a 10-layer 4K document. The top layer is the active drawing target.
|
||||
///
|
||||
/// **Purpose:** Creates a worst-case scenario where the below-layer composite
|
||||
/// is expensive (9 filled layers below the active one).
|
||||
/// **Returns:** `(Engine, top_layer_id)`.
|
||||
fn setup_10layer_engine() -> (Engine, u64) {
|
||||
let mut engine = Engine::new(W, H);
|
||||
|
||||
let bg_id = engine.active_layer_id();
|
||||
engine.set_active_layer(bg_id);
|
||||
engine.draw_filled_rect_rgba(0, 0, W, H, [240, 240, 240, 255]);
|
||||
|
||||
let mut layer_ids = vec![bg_id];
|
||||
for i in 1..LAYERS {
|
||||
let id = engine.add_layer(&format!("Layer {}", i));
|
||||
engine.set_active_layer(id);
|
||||
let color = match i % 4 {
|
||||
0 => [180, 80, 80, 255],
|
||||
1 => [80, 180, 80, 255],
|
||||
2 => [80, 80, 180, 255],
|
||||
_ => [160, 140, 100, 255],
|
||||
};
|
||||
engine.draw_filled_rect_rgba(0, 0, W, H, color);
|
||||
layer_ids.push(id);
|
||||
}
|
||||
|
||||
let top_id = *layer_ids.last().unwrap();
|
||||
engine.set_active_layer(top_id);
|
||||
|
||||
let mut tip = BrushTip::default();
|
||||
tip.style = BrushStyle::Round;
|
||||
tip.size = 24.0;
|
||||
tip.opacity = 1.0;
|
||||
tip.hardness = 0.85;
|
||||
tip.spacing = 0.1;
|
||||
engine.set_brush_tip(tip);
|
||||
engine.set_color([220, 60, 60, 255]);
|
||||
engine.set_eraser(false);
|
||||
|
||||
(engine, top_id)
|
||||
}
|
||||
|
||||
/// Performs a short stroke: begin → 5 segments → composite → end.
|
||||
///
|
||||
/// **Purpose:** Exercises the full `begin_stroke()` (below_cache build/reuse)
|
||||
/// through `end_stroke()` lifecycle.
|
||||
/// **Arguments:** `engine` — mutable engine, `layer_id` — target layer,
|
||||
/// `offset` — slight position offset to avoid degenerate repeat painting.
|
||||
/// **Side Effects:** Mutates layer pixels, triggers composite, commits history.
|
||||
fn do_stroke(engine: &mut Engine, layer_id: u64, offset: f32) {
|
||||
let cx = W as f32 / 2.0 + offset;
|
||||
let cy = H as f32 / 2.0 + offset;
|
||||
engine.begin_stroke(layer_id, cx, cy);
|
||||
for i in 1..=5 {
|
||||
let d = i as f32 * 8.0;
|
||||
engine.stroke_to(layer_id, cx + d, cy + d, 0.8);
|
||||
}
|
||||
let (_region, ptr, _size) = engine.render_composite_region();
|
||||
assert!(!ptr.is_null());
|
||||
engine.end_stroke(layer_id);
|
||||
std::thread::sleep(std::time::Duration::from_millis(10));
|
||||
engine.commit_pending_history();
|
||||
}
|
||||
|
||||
/// Benchmark group: below_cache build (first stroke) vs reuse (second stroke).
|
||||
///
|
||||
/// **Purpose:** Detects regressions where `below_cache_dirty` is incorrectly
|
||||
/// set to `true` in `end_stroke()` or where `below_cache` is dropped to `None`,
|
||||
/// forcing a full 9-layer re-composite on every stroke.
|
||||
///
|
||||
/// **Logic & Workflow:**
|
||||
/// - `first_stroke_cache_build`: On a fresh engine, `begin_stroke()` must
|
||||
/// build the below_cache from scratch (composite 9 layers → 33 MB buffer).
|
||||
/// - `second_stroke_cache_reuse`: After the first stroke on the same layer,
|
||||
/// `begin_stroke()` should find `below_cache_dirty == false` and
|
||||
/// `below_cache_active_idx == current`, skipping the entire composite.
|
||||
fn bench_below_cache_reuse(c: &mut Criterion) {
|
||||
let mut group = c.benchmark_group("below_cache_reuse");
|
||||
|
||||
// ── First stroke: cache must be built from scratch ──
|
||||
group.bench_function("first_stroke_cache_build", |b| {
|
||||
b.iter_with_setup(
|
||||
|| setup_10layer_engine(),
|
||||
|(mut engine, layer_id)| {
|
||||
do_stroke(&mut engine, layer_id, 0.0);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
// ── Second stroke on same layer: cache should be reused ──
|
||||
group.bench_function("second_stroke_cache_reuse", |b| {
|
||||
let (mut engine, layer_id) = setup_10layer_engine();
|
||||
// Build the cache with the first stroke
|
||||
do_stroke(&mut engine, layer_id, 0.0);
|
||||
|
||||
let mut offset = 0.0_f32;
|
||||
b.iter(|| {
|
||||
offset += 5.0;
|
||||
do_stroke(&mut engine, layer_id, offset);
|
||||
});
|
||||
});
|
||||
|
||||
group.finish();
|
||||
}
|
||||
|
||||
criterion_group!(benches, bench_below_cache_reuse);
|
||||
criterion_main!(benches);
|
||||
@@ -0,0 +1,146 @@
|
||||
//! Criterion benchmark: `composite_scratch` buffer pooling guard.
|
||||
//!
|
||||
//! ## Purpose
|
||||
//! Verifies that the ~33 MB `composite_scratch` buffer inside
|
||||
//! `render_composite_region()` is allocated once and reused across calls.
|
||||
//! If a regression replaces the pooled `Option<Vec<u8>>` with a fresh
|
||||
//! `Vec::new()` on every call, this benchmark detects the allocation storm.
|
||||
//!
|
||||
//! ## Logic & Workflow
|
||||
//! 1. Creates a 3840×2160, 10-layer document with all layers filled.
|
||||
//! 2. Performs a stroke + composite to warm the scratch buffer.
|
||||
//! 3. **cold_composite**: Measures a `render_composite_region()` call on a
|
||||
//! fresh engine where `composite_scratch` is `None`.
|
||||
//! 4. **warm_composite**: Measures the same call after the buffer has been
|
||||
//! allocated (should be faster — no allocation, just memset + composite).
|
||||
//!
|
||||
//! ## Arguments & Returns
|
||||
//! Standard criterion benchmark — run with:
|
||||
//! ```bash
|
||||
//! cargo bench -p hcie-engine-api --bench composite_scratch_pooling
|
||||
//! ```
|
||||
//!
|
||||
//! ## Side Effects / Dependencies
|
||||
//! Allocates ~400 MB (10 layers × 4K RGBA + scratch + tile caches).
|
||||
|
||||
use criterion::{criterion_group, criterion_main, Criterion};
|
||||
use hcie_engine_api::{BrushStyle, BrushTip, Engine};
|
||||
|
||||
/// Canvas dimensions — 4K UHD.
|
||||
const W: u32 = 3840;
|
||||
const H: u32 = 2160;
|
||||
/// Number of raster layers to create (worst-case workload).
|
||||
const LAYERS: usize = 10;
|
||||
|
||||
/// Builds a multi-layer 4K document ready for compositing.
|
||||
///
|
||||
/// **Purpose:** Creates the worst-case composite scenario with every layer
|
||||
/// filled so tile and pixel compositing operate on real data.
|
||||
/// **Returns:** `(Engine, top_layer_id)`.
|
||||
fn setup_multilayer_engine() -> (Engine, u64) {
|
||||
let mut engine = Engine::new(W, H);
|
||||
|
||||
let bg_id = engine.active_layer_id();
|
||||
engine.set_active_layer(bg_id);
|
||||
engine.draw_filled_rect_rgba(0, 0, W, H, [240, 240, 240, 255]);
|
||||
|
||||
let mut layer_ids = vec![bg_id];
|
||||
for i in 1..LAYERS {
|
||||
let id = engine.add_layer(&format!("Layer {}", i));
|
||||
engine.set_active_layer(id);
|
||||
let color = match i % 4 {
|
||||
0 => [180, 80, 80, 255],
|
||||
1 => [80, 180, 80, 255],
|
||||
2 => [80, 80, 180, 255],
|
||||
_ => [160, 140, 100, 255],
|
||||
};
|
||||
engine.draw_filled_rect_rgba(0, 0, W, H, color);
|
||||
layer_ids.push(id);
|
||||
}
|
||||
|
||||
let top_id = *layer_ids.last().unwrap();
|
||||
engine.set_active_layer(top_id);
|
||||
|
||||
let mut tip = BrushTip::default();
|
||||
tip.style = BrushStyle::Round;
|
||||
tip.size = 24.0;
|
||||
tip.opacity = 1.0;
|
||||
tip.hardness = 0.85;
|
||||
tip.spacing = 0.1;
|
||||
engine.set_brush_tip(tip);
|
||||
engine.set_color([220, 60, 60, 255]);
|
||||
engine.set_eraser(false);
|
||||
|
||||
(engine, top_id)
|
||||
}
|
||||
|
||||
/// Forces a dirty region and composites it.
|
||||
///
|
||||
/// **Purpose:** Exercises the `render_composite_region()` path including
|
||||
/// scratch buffer allocation/reuse, tile compositing, and dirty rect handling.
|
||||
/// **Arguments:** `engine` — mutable engine, `layer_id` — target layer.
|
||||
/// **Side Effects:** Paints a short stroke segment to create a dirty region,
|
||||
/// then calls `render_composite_region()`.
|
||||
fn stroke_and_composite(engine: &mut Engine, layer_id: u64) {
|
||||
let cx = W as f32 / 2.0;
|
||||
let cy = H as f32 / 2.0;
|
||||
engine.begin_stroke(layer_id, cx, cy);
|
||||
engine.stroke_to(layer_id, cx + 50.0, cy + 50.0, 0.8);
|
||||
let (region, ptr, _size) = engine.render_composite_region();
|
||||
assert!(!ptr.is_null());
|
||||
let _ = region;
|
||||
engine.end_stroke(layer_id);
|
||||
std::thread::sleep(std::time::Duration::from_millis(10));
|
||||
engine.commit_pending_history();
|
||||
}
|
||||
|
||||
/// Benchmark group: cold vs warm `composite_scratch` allocation.
|
||||
///
|
||||
/// **Purpose:** Detects regressions where `composite_scratch` is dropped or
|
||||
/// reallocated on every `render_composite_region()` call. When pooling works,
|
||||
/// the warm benchmark avoids a ~33 MB allocation and should be measurably faster.
|
||||
///
|
||||
/// **Logic & Workflow:**
|
||||
/// - `cold_composite`: Fresh engine, `composite_scratch = None`. First call
|
||||
/// allocates the buffer.
|
||||
/// - `warm_composite`: Engine with pre-allocated scratch buffer. Measures
|
||||
/// pure composite cost without allocation overhead.
|
||||
fn bench_composite_scratch_pooling(c: &mut Criterion) {
|
||||
let mut group = c.benchmark_group("composite_scratch_pooling");
|
||||
|
||||
// ── Cold: composite on a fresh engine (scratch allocation included) ──
|
||||
group.bench_function("cold_composite", |b| {
|
||||
b.iter_with_setup(
|
||||
|| setup_multilayer_engine(),
|
||||
|(mut engine, layer_id)| {
|
||||
stroke_and_composite(&mut engine, layer_id);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
// ── Warm: composite on a pre-warmed engine (scratch already pooled) ──
|
||||
group.bench_function("warm_composite", |b| {
|
||||
let (mut engine, layer_id) = setup_multilayer_engine();
|
||||
// Warm up: allocate the scratch buffer
|
||||
stroke_and_composite(&mut engine, layer_id);
|
||||
|
||||
b.iter(|| {
|
||||
// Paint a new segment and composite — scratch should be reused
|
||||
let cx = W as f32 / 2.0;
|
||||
let cy = H as f32 / 2.0;
|
||||
engine.begin_stroke(layer_id, cx, cy);
|
||||
engine.stroke_to(layer_id, cx + 30.0, cy - 30.0, 0.7);
|
||||
let (region, ptr, _size) = engine.render_composite_region();
|
||||
assert!(!ptr.is_null());
|
||||
let _ = region;
|
||||
engine.end_stroke(layer_id);
|
||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||
engine.commit_pending_history();
|
||||
});
|
||||
});
|
||||
|
||||
group.finish();
|
||||
}
|
||||
|
||||
criterion_group!(benches, bench_composite_scratch_pooling);
|
||||
criterion_main!(benches);
|
||||
@@ -0,0 +1,166 @@
|
||||
//! Criterion benchmark: conditional `effects_dirty` skip guard.
|
||||
//!
|
||||
//! ## Purpose
|
||||
//! Verifies that `render_composite_region()` / `apply_effects_and_sync_tiles()`
|
||||
//! skips the expensive effects pipeline for layers that have **no effects or
|
||||
//! styles**. If a regression removes the `effects.is_empty() && styles.is_empty()`
|
||||
//! early-exit or unconditionally sets `effects_dirty = true`, composite cost
|
||||
//! on effect-free documents will increase dramatically.
|
||||
//!
|
||||
//! ## Logic & Workflow
|
||||
//! 1. Creates a 3840×2160, 10-layer document (all filled, no effects).
|
||||
//! 2. **no_effects**: Benchmarks `render_composite_region()` where every layer
|
||||
//! has empty `effects` and `styles` vectors → the effects pipeline is
|
||||
//! completely skipped.
|
||||
//! 3. **with_one_effect**: Same document but one layer has a DropShadow style
|
||||
//! → the effects pipeline runs for that single layer.
|
||||
//!
|
||||
//! When the conditional skip works, `no_effects` should be measurably faster
|
||||
//! than `with_one_effect` because no `apply_layer_effects` calls are made.
|
||||
//!
|
||||
//! ## Arguments & Returns
|
||||
//! Standard criterion benchmark — run with:
|
||||
//! ```bash
|
||||
//! cargo bench -p hcie-engine-api --bench effects_skip
|
||||
//! ```
|
||||
//!
|
||||
//! ## Side Effects / Dependencies
|
||||
//! Allocates ~400 MB (10 layers × 4K RGBA + composite buffers).
|
||||
|
||||
use criterion::{criterion_group, criterion_main, Criterion};
|
||||
use hcie_engine_api::{BrushStyle, BrushTip, Engine, LayerStyle};
|
||||
|
||||
/// Canvas dimensions — 4K UHD.
|
||||
const W: u32 = 3840;
|
||||
const H: u32 = 2160;
|
||||
/// Number of raster layers.
|
||||
const LAYERS: usize = 10;
|
||||
|
||||
/// Builds a 10-layer 4K document with NO effects on any layer.
|
||||
///
|
||||
/// **Purpose:** Creates the baseline scenario where the effects pipeline
|
||||
/// should be completely skipped during compositing.
|
||||
/// **Returns:** `(Engine, top_layer_id, all_layer_ids)`.
|
||||
fn setup_no_effects_engine() -> (Engine, u64, Vec<u64>) {
|
||||
let mut engine = Engine::new(W, H);
|
||||
|
||||
let bg_id = engine.active_layer_id();
|
||||
engine.set_active_layer(bg_id);
|
||||
engine.draw_filled_rect_rgba(0, 0, W, H, [240, 240, 240, 255]);
|
||||
|
||||
let mut layer_ids = vec![bg_id];
|
||||
for i in 1..LAYERS {
|
||||
let id = engine.add_layer(&format!("Layer {}", i));
|
||||
engine.set_active_layer(id);
|
||||
let color = match i % 4 {
|
||||
0 => [180, 80, 80, 255],
|
||||
1 => [80, 180, 80, 255],
|
||||
2 => [80, 80, 180, 255],
|
||||
_ => [160, 140, 100, 255],
|
||||
};
|
||||
engine.draw_filled_rect_rgba(0, 0, W, H, color);
|
||||
layer_ids.push(id);
|
||||
}
|
||||
|
||||
let top_id = *layer_ids.last().unwrap();
|
||||
engine.set_active_layer(top_id);
|
||||
|
||||
let mut tip = BrushTip::default();
|
||||
tip.style = BrushStyle::Round;
|
||||
tip.size = 24.0;
|
||||
tip.opacity = 1.0;
|
||||
tip.hardness = 0.85;
|
||||
tip.spacing = 0.1;
|
||||
engine.set_brush_tip(tip);
|
||||
engine.set_color([220, 60, 60, 255]);
|
||||
engine.set_eraser(false);
|
||||
|
||||
(engine, top_id, layer_ids)
|
||||
}
|
||||
|
||||
/// Performs a stroke + composite cycle and returns.
|
||||
///
|
||||
/// **Purpose:** Creates a dirty region and composites it, exercising the
|
||||
/// effects pipeline path.
|
||||
/// **Arguments:** `engine` — mutable engine, `layer_id` — target layer,
|
||||
/// `offset` — position variation.
|
||||
/// **Side Effects:** Mutates layer pixels, triggers composite.
|
||||
fn stroke_and_composite(engine: &mut Engine, layer_id: u64, offset: f32) {
|
||||
let cx = W as f32 / 2.0 + offset;
|
||||
let cy = H as f32 / 2.0 + offset;
|
||||
engine.begin_stroke(layer_id, cx, cy);
|
||||
for i in 1..=5 {
|
||||
let d = i as f32 * 8.0;
|
||||
engine.stroke_to(layer_id, cx + d, cy + d, 0.8);
|
||||
}
|
||||
let (_region, ptr, _size) = engine.render_composite_region();
|
||||
assert!(!ptr.is_null());
|
||||
engine.end_stroke(layer_id);
|
||||
std::thread::sleep(std::time::Duration::from_millis(10));
|
||||
engine.commit_pending_history();
|
||||
}
|
||||
|
||||
/// Benchmark group: effects-free composite vs single-effect composite.
|
||||
///
|
||||
/// **Purpose:** Detects regressions where the effects pipeline runs
|
||||
/// unnecessarily on layers with no effects, causing wasted CPU cycles
|
||||
/// (apply_layer_effects + 33 MB buffer clone per affected layer).
|
||||
///
|
||||
/// **Logic & Workflow:**
|
||||
/// - `no_effects_composite`: All 10 layers have empty effects/styles.
|
||||
/// `apply_effects_and_sync_tiles()` should skip the effects pass entirely.
|
||||
/// - `with_one_dropshadow`: Layer 5 has a DropShadow style enabled.
|
||||
/// The effects pipeline runs for that single layer but skips the other 9.
|
||||
/// This should be slightly slower than `no_effects_composite` but not 10×
|
||||
/// slower (which would indicate the skip logic is broken).
|
||||
fn bench_effects_skip(c: &mut Criterion) {
|
||||
let mut group = c.benchmark_group("effects_skip");
|
||||
|
||||
// ── No effects on any layer ──
|
||||
group.bench_function("no_effects_composite", |b| {
|
||||
let (mut engine, layer_id, _ids) = setup_no_effects_engine();
|
||||
// Warm up composite scratch
|
||||
stroke_and_composite(&mut engine, layer_id, 0.0);
|
||||
|
||||
let mut offset = 0.0_f32;
|
||||
b.iter(|| {
|
||||
offset += 3.0;
|
||||
stroke_and_composite(&mut engine, layer_id, offset);
|
||||
});
|
||||
});
|
||||
|
||||
// ── One layer has a DropShadow effect ──
|
||||
group.bench_function("with_one_dropshadow", |b| {
|
||||
let (mut engine, layer_id, layer_ids) = setup_no_effects_engine();
|
||||
|
||||
// Add a DropShadow style to layer 5 (middle of the stack)
|
||||
let target_layer_id = layer_ids[5];
|
||||
engine.update_layer_style(
|
||||
target_layer_id,
|
||||
LayerStyle::DropShadow {
|
||||
enabled: true,
|
||||
opacity: 0.6,
|
||||
angle: 135.0,
|
||||
distance: 5.0,
|
||||
spread: 0.0,
|
||||
size: 10.0,
|
||||
color: [0, 0, 0, 255],
|
||||
blend_mode: "Normal".to_string(),
|
||||
},
|
||||
);
|
||||
|
||||
// Warm up
|
||||
stroke_and_composite(&mut engine, layer_id, 0.0);
|
||||
|
||||
let mut offset = 0.0_f32;
|
||||
b.iter(|| {
|
||||
offset += 3.0;
|
||||
stroke_and_composite(&mut engine, layer_id, offset);
|
||||
});
|
||||
});
|
||||
|
||||
group.finish();
|
||||
}
|
||||
|
||||
criterion_group!(benches, bench_effects_skip);
|
||||
criterion_main!(benches);
|
||||
@@ -0,0 +1,120 @@
|
||||
//! Criterion benchmark: `active_stroke_mask` buffer pooling guard.
|
||||
//!
|
||||
//! ## Purpose
|
||||
//! Verifies that the `active_stroke_mask` (~8 MB on a 4K canvas) is **pooled**
|
||||
//! across consecutive strokes rather than re-allocated on every
|
||||
//! `begin_stroke()` / `end_stroke()` cycle. If a regression drops the mask
|
||||
//! with `self.active_stroke_mask = None` inside `end_stroke()`, this benchmark
|
||||
//! will show a measurable increase in allocation overhead.
|
||||
//!
|
||||
//! ## Logic & Workflow
|
||||
//! 1. Creates a 3840×2160 document with one raster layer.
|
||||
//! 2. **cold** group: a single begin/end stroke pair (first allocation).
|
||||
//! 3. **warm** group: the 10th consecutive stroke on the same engine (buffer
|
||||
//! should already be allocated and simply zeroed with `fill(0)`).
|
||||
//!
|
||||
//! When pooling works correctly, the warm benchmark should be significantly
|
||||
//! faster than the cold one because no allocation occurs — only a memset.
|
||||
//!
|
||||
//! ## Arguments & Returns
|
||||
//! Standard criterion benchmark — run with:
|
||||
//! ```bash
|
||||
//! cargo bench -p hcie-engine-api --bench stroke_mask_pooling
|
||||
//! ```
|
||||
//!
|
||||
//! ## Side Effects / Dependencies
|
||||
//! Allocates ~140 MB (4K RGBA layer + mask + before buffer). Uses
|
||||
//! `criterion::Criterion` for statistical measurement.
|
||||
|
||||
use criterion::{criterion_group, criterion_main, Criterion};
|
||||
use hcie_engine_api::{BrushStyle, BrushTip, Engine};
|
||||
|
||||
/// Canvas dimensions — 4K UHD as requested.
|
||||
const W: u32 = 3840;
|
||||
const H: u32 = 2160;
|
||||
|
||||
/// Creates a pre-configured engine with a filled background layer and a
|
||||
/// standard round brush tip.
|
||||
///
|
||||
/// **Purpose:** Shared setup for all mask pooling benchmark variants.
|
||||
/// **Returns:** `(Engine, layer_id)` ready for `begin_stroke()`.
|
||||
fn setup_engine() -> (Engine, u64) {
|
||||
let mut engine = Engine::new(W, H);
|
||||
let layer_id = engine.active_layer_id();
|
||||
engine.set_active_layer(layer_id);
|
||||
engine.draw_filled_rect_rgba(0, 0, W, H, [200, 200, 200, 255]);
|
||||
|
||||
let mut tip = BrushTip::default();
|
||||
tip.style = BrushStyle::Round;
|
||||
tip.size = 24.0;
|
||||
tip.opacity = 1.0;
|
||||
tip.hardness = 0.85;
|
||||
tip.spacing = 0.1;
|
||||
engine.set_brush_tip(tip);
|
||||
engine.set_color([60, 60, 220, 255]);
|
||||
engine.set_eraser(false);
|
||||
|
||||
(engine, layer_id)
|
||||
}
|
||||
|
||||
/// Performs a single begin_stroke → short paint → end_stroke cycle.
|
||||
///
|
||||
/// **Purpose:** Exercises the full mask allocation / reuse path.
|
||||
/// **Arguments:** `engine` — mutable engine reference, `layer_id` — target layer.
|
||||
/// **Side Effects:** Mutates layer pixels and engine caches.
|
||||
fn do_one_stroke(engine: &mut Engine, layer_id: u64) {
|
||||
let cx = W as f32 / 2.0;
|
||||
let cy = H as f32 / 2.0;
|
||||
engine.begin_stroke(layer_id, cx, cy);
|
||||
// Paint 5 short segments to exercise the mask
|
||||
for i in 1..=5 {
|
||||
let offset = i as f32 * 10.0;
|
||||
engine.stroke_to(layer_id, cx + offset, cy + offset, 0.8);
|
||||
}
|
||||
engine.end_stroke(layer_id);
|
||||
// Commit pending history so background thread completes
|
||||
std::thread::sleep(std::time::Duration::from_millis(10));
|
||||
engine.commit_pending_history();
|
||||
}
|
||||
|
||||
/// Benchmark group: cold vs warm `active_stroke_mask` allocation.
|
||||
///
|
||||
/// **Purpose:** Detects pooling regressions by comparing the first stroke
|
||||
/// (cold allocation) against a subsequent stroke (warm / reused buffer).
|
||||
///
|
||||
/// **Logic & Workflow:**
|
||||
/// - `cold_first_stroke`: Measures `begin_stroke` + short paint + `end_stroke`
|
||||
/// on a fresh engine where `active_stroke_mask` is `None`.
|
||||
/// - `warm_10th_stroke`: Same operation but after 9 warm-up strokes have already
|
||||
/// populated the pooled mask buffer. If pooling works, this should be faster.
|
||||
fn bench_mask_pooling(c: &mut Criterion) {
|
||||
let mut group = c.benchmark_group("stroke_mask_pooling");
|
||||
|
||||
// ── Cold: first stroke on a fresh engine (allocation happens) ──
|
||||
group.bench_function("cold_first_stroke", |b| {
|
||||
b.iter_with_setup(
|
||||
|| setup_engine(),
|
||||
|(mut engine, layer_id)| {
|
||||
do_one_stroke(&mut engine, layer_id);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
// ── Warm: 10th stroke on a warmed-up engine (buffer reuse) ──
|
||||
group.bench_function("warm_10th_stroke", |b| {
|
||||
// Pre-warm the engine outside the measured loop
|
||||
let (mut engine, layer_id) = setup_engine();
|
||||
for _ in 0..9 {
|
||||
do_one_stroke(&mut engine, layer_id);
|
||||
}
|
||||
|
||||
b.iter(|| {
|
||||
do_one_stroke(&mut engine, layer_id);
|
||||
});
|
||||
});
|
||||
|
||||
group.finish();
|
||||
}
|
||||
|
||||
criterion_group!(benches, bench_mask_pooling);
|
||||
criterion_main!(benches);
|
||||
@@ -459,4 +459,102 @@ impl Engine {
|
||||
self.below_cache_active_idx = None;
|
||||
self.below_cache_dirty = true;
|
||||
}
|
||||
|
||||
/// Check if a layer has an active layer mask.
|
||||
pub fn has_layer_mask(&self, layer_id: u64) -> bool {
|
||||
if let Some(idx) = self.document.layer_index_by_id(layer_id) {
|
||||
self.document.layers.get(idx).map(|l| l.mask_pixels.is_some()).unwrap_or(false)
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Add a white (opaque/reveal) layer mask to the specified layer if not present.
|
||||
pub fn add_layer_mask(&mut self, layer_id: u64) {
|
||||
if let Some(idx) = self.document.layer_index_by_id(layer_id) {
|
||||
if let Some(layer) = self.document.layers.get_mut(idx) {
|
||||
let mask_size = (layer.width * layer.height) as usize;
|
||||
layer.mask_pixels = Some(vec![255u8; mask_size]);
|
||||
layer.mask_bounds = Some([0, 0, layer.height as i32, layer.width as i32]);
|
||||
layer.mask_default_color = 255;
|
||||
layer.dirty = true;
|
||||
self.document.composite_dirty = true;
|
||||
self.document.modified = true;
|
||||
self.below_cache_dirty = true;
|
||||
self.document.composite_dirty = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove the layer mask from the specified layer.
|
||||
pub fn remove_layer_mask(&mut self, layer_id: u64) {
|
||||
if let Some(idx) = self.document.layer_index_by_id(layer_id) {
|
||||
if let Some(layer) = self.document.layers.get_mut(idx) {
|
||||
layer.mask_pixels = None;
|
||||
layer.mask_bounds = None;
|
||||
layer.dirty = true;
|
||||
self.document.composite_dirty = true;
|
||||
self.document.modified = true;
|
||||
self.below_cache_dirty = true;
|
||||
self.document.composite_dirty = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Toggle the existence of a layer mask on the specified layer.
|
||||
pub fn toggle_layer_mask(&mut self, layer_id: u64) {
|
||||
if self.has_layer_mask(layer_id) {
|
||||
self.remove_layer_mask(layer_id);
|
||||
} else {
|
||||
self.add_layer_mask(layer_id);
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns `true` if current drawing operations target the layer mask.
|
||||
pub fn is_editing_mask(&self) -> bool {
|
||||
self.editing_mask
|
||||
}
|
||||
|
||||
/// Set whether current drawing operations target the active layer's mask.
|
||||
pub fn set_editing_mask(&mut self, editing: bool) {
|
||||
self.editing_mask = editing;
|
||||
}
|
||||
|
||||
/// Toggle mask editing mode.
|
||||
pub fn toggle_editing_mask(&mut self) {
|
||||
self.editing_mask = !self.editing_mask;
|
||||
}
|
||||
|
||||
/// Returns a reference to the mask pixel bytes for the given layer.
|
||||
pub fn get_layer_mask_pixels(&self, layer_id: u64) -> Option<&[u8]> {
|
||||
if let Some(idx) = self.document.layer_index_by_id(layer_id) {
|
||||
self.document.layers.get(idx).and_then(|l| l.mask_pixels.as_deref())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns mask bounds [top, left, bottom, right] for the given layer if set.
|
||||
pub fn get_layer_mask_bounds(&self, layer_id: u64) -> Option<[i32; 4]> {
|
||||
if let Some(idx) = self.document.layer_index_by_id(layer_id) {
|
||||
self.document.layers.get(idx).and_then(|l| l.mask_bounds)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Update the mask pixel bytes and bounds for the given layer.
|
||||
pub fn set_layer_mask_pixels(&mut self, layer_id: u64, mask: Vec<u8>, bounds: Option<[i32; 4]>) {
|
||||
if let Some(idx) = self.document.layer_index_by_id(layer_id) {
|
||||
if let Some(layer) = self.document.layers.get_mut(idx) {
|
||||
layer.mask_pixels = Some(mask);
|
||||
layer.mask_bounds = bounds;
|
||||
layer.dirty = true;
|
||||
self.document.composite_dirty = true;
|
||||
self.document.modified = true;
|
||||
self.below_cache_dirty = true;
|
||||
self.document.composite_dirty = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -348,12 +348,14 @@ pub struct Engine {
|
||||
/// At `end_stroke()`, the buffer is moved into the background thread for
|
||||
/// sub-rect extraction, then returned via the pending_history channel.
|
||||
stroke_before_buf: Option<Vec<u8>>,
|
||||
mask_before_buf: Option<Vec<u8>>,
|
||||
tile_layers: Vec<Option<TiledLayer>>,
|
||||
svg_sources: std::collections::HashMap<u64, Vec<u8>>,
|
||||
filter_preview_original: Option<Vec<u8>>,
|
||||
stroke_effects_backup: Option<Vec<hcie_protocol::effects::LayerEffect>>,
|
||||
stroke_styles_backup: Option<Vec<hcie_protocol::LayerStyle>>,
|
||||
pub is_eraser: bool,
|
||||
pub editing_mask: bool,
|
||||
/// Pooled stroke mask buffer. Zeroed on `begin_stroke()` and `end_stroke()`
|
||||
/// but **never dropped** between strokes. Reused if size matches, reallocated
|
||||
/// only when canvas dimensions change. Avoids ~8MB alloc/stroke on 4K.
|
||||
@@ -363,6 +365,8 @@ pub struct Engine {
|
||||
/// on 4K canvases. The pixel-bridge extracts dirty rect from this buffer
|
||||
/// without allocating its own buffer.
|
||||
composite_scratch: Option<Vec<u8>>,
|
||||
/// Pooled RGBA scratch buffer for layer mask editing passes.
|
||||
pub mask_rgba_scratch: Option<Vec<u8>>,
|
||||
/// Accumulated stroke extent `[x0, y0, x1, y1]` for sub-rect snapshot.
|
||||
/// Tracks the union of all brush areas painted during the current stroke.
|
||||
/// Independent from `document.dirty_bounds` — survives composite emits.
|
||||
@@ -540,14 +544,17 @@ impl Engine {
|
||||
cyclic_speed: 0.5,
|
||||
stroke_before: None,
|
||||
stroke_before_buf: None,
|
||||
mask_before_buf: None,
|
||||
tile_layers: Vec::new(),
|
||||
svg_sources: std::collections::HashMap::new(),
|
||||
filter_preview_original: None,
|
||||
stroke_effects_backup: None,
|
||||
stroke_styles_backup: None,
|
||||
is_eraser: false,
|
||||
editing_mask: false,
|
||||
active_stroke_mask: None,
|
||||
composite_scratch: None,
|
||||
mask_rgba_scratch: None,
|
||||
last_stroke_bounds: None,
|
||||
below_cache: None,
|
||||
below_cache_active_idx: None,
|
||||
|
||||
@@ -145,25 +145,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;
|
||||
|
||||
@@ -105,6 +105,167 @@ 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;
|
||||
@@ -115,59 +276,66 @@ impl Engine {
|
||||
}
|
||||
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);
|
||||
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.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 +344,60 @@ 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 {
|
||||
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);
|
||||
|
||||
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;
|
||||
|
||||
@@ -59,6 +59,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 +102,33 @@ impl Engine {
|
||||
self.stroke_before_buf = Some(buf);
|
||||
}
|
||||
}
|
||||
if let Some(buf) = item.return_mask_before {
|
||||
if self
|
||||
.mask_before_buf
|
||||
.as_ref()
|
||||
.map_or(true, |b| b.len() != buf.len())
|
||||
{
|
||||
self.mask_before_buf = Some(buf);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(bounds) = item.bounds {
|
||||
if item.is_mask {
|
||||
if let Some(bounds) = item.bounds {
|
||||
self.document.push_mask_draw_snapshot_subrect(
|
||||
item.layer_idx,
|
||||
item.before_pixels,
|
||||
item.after_pixels,
|
||||
(bounds[0], bounds[1], bounds[2], bounds[3]),
|
||||
item.description,
|
||||
);
|
||||
} else {
|
||||
self.document.push_mask_draw_snapshot(
|
||||
item.layer_idx,
|
||||
item.before_pixels,
|
||||
item.description,
|
||||
);
|
||||
}
|
||||
} else if let Some(bounds) = item.bounds {
|
||||
self.document.push_draw_snapshot_subrect(
|
||||
item.layer_idx,
|
||||
item.before_pixels,
|
||||
@@ -191,12 +220,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 +342,35 @@ impl Engine {
|
||||
layer_pixels,
|
||||
self.stroke_before_buf.as_ref().map(|b| b.len())
|
||||
);
|
||||
let before = match self.stroke_before_buf.take() {
|
||||
Some(buf) if buf.len() == layer_pixels => buf,
|
||||
_ => vec![0u8; layer_pixels],
|
||||
let mut mask_before = None;
|
||||
let before = if self.editing_mask {
|
||||
let gray_len = lw as usize * layer.height as usize;
|
||||
let mb = match self.mask_before_buf.take() {
|
||||
Some(buf) if buf.len() == gray_len => buf,
|
||||
_ => vec![255u8; gray_len],
|
||||
};
|
||||
mask_before = Some(mb.clone());
|
||||
mb
|
||||
} else {
|
||||
match self.stroke_before_buf.take() {
|
||||
Some(buf) if buf.len() == layer_pixels => buf,
|
||||
_ => vec![0u8; layer_pixels],
|
||||
}
|
||||
};
|
||||
log::debug!("[end_stroke] before.len()={}, lw={}", before.len(), lw);
|
||||
|
||||
// Layer 3: Zero-copy after-snapshot via raw pointer.
|
||||
// SAFETY: After end_stroke(), no mutations happen to layer.pixels
|
||||
// until the next begin_stroke(). The background thread only reads.
|
||||
let after_ptr = SendPtr::new(layer.pixels.as_ptr());
|
||||
let after_len = layer.pixels.len();
|
||||
let (after_ptr, after_len) = if self.editing_mask {
|
||||
if layer.mask_pixels.is_none() {
|
||||
layer.mask_pixels = Some(vec![255; (layer.width * layer.height) as usize]);
|
||||
}
|
||||
let mask = layer.mask_pixels.as_ref().unwrap();
|
||||
(SendPtr::new(mask.as_ptr()), mask.len())
|
||||
} else {
|
||||
(SendPtr::new(layer.pixels.as_ptr()), layer.pixels.len())
|
||||
};
|
||||
|
||||
let after_shapes = if let LayerData::Vector { shapes } = &layer.data {
|
||||
Some(shapes.clone())
|
||||
@@ -275,6 +381,7 @@ impl Engine {
|
||||
let style = self.current_tip.style;
|
||||
let bounds = self.last_stroke_bounds;
|
||||
let pending_history = self.pending_history.clone();
|
||||
let is_mask = self.editing_mask;
|
||||
|
||||
std::thread::spawn(move || {
|
||||
let t_start = std::time::Instant::now();
|
||||
@@ -290,25 +397,33 @@ impl Engine {
|
||||
before_shapes,
|
||||
after_shapes,
|
||||
description: format!(
|
||||
"Brush Stroke ({})",
|
||||
"{} ({})",
|
||||
if is_mask { "Mask Edit" } else { "Brush Stroke" },
|
||||
crate::stroke_brush::brush_style_label(style)
|
||||
),
|
||||
return_before: None,
|
||||
return_after: None,
|
||||
return_mask_before: mask_before,
|
||||
is_mask,
|
||||
};
|
||||
|
||||
if let Some([sx0, sy0, sx1, sy1]) = bounds {
|
||||
if sx0 < sx1 && sy0 < sy1 {
|
||||
let rw = sx1 - sx0;
|
||||
let rh = sy1 - sy0;
|
||||
let rect_size = (rw * rh * 4) as usize;
|
||||
let rect_size = if is_mask {
|
||||
(rw * rh) as usize
|
||||
} else {
|
||||
(rw * rh * 4) as usize
|
||||
};
|
||||
log::debug!("[end_stroke_bg] bounds=[{},{},{},{}], rw={}, rh={}, rect_size={}, before.len()={}, after.len()={}, lw={}", sx0, sy0, sx1, sy1, rw, rh, rect_size, before.len(), after_slice.len(), lw);
|
||||
let mut before_rect = vec![0u8; rect_size];
|
||||
let mut after_rect = vec![0u8; rect_size];
|
||||
let bpp = if is_mask { 1 } else { 4 };
|
||||
for row in 0..rh {
|
||||
let src_start = (((sy0 + row) * lw + sx0) * 4) as usize;
|
||||
let dst_start = (row * rw * 4) as usize;
|
||||
let len = (rw * 4) as usize;
|
||||
let src_start = (((sy0 + row) * lw + sx0) * bpp) as usize;
|
||||
let dst_start = (row * rw * bpp) as usize;
|
||||
let len = (rw * bpp) as usize;
|
||||
before_rect[dst_start..dst_start + len]
|
||||
.copy_from_slice(&before[src_start..src_start + len]);
|
||||
after_rect[dst_start..dst_start + len]
|
||||
@@ -320,7 +435,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!(
|
||||
@@ -470,13 +587,15 @@ impl Engine {
|
||||
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
|
||||
);
|
||||
if log::log_enabled!(log::Level::Trace) {
|
||||
let non_zero = cache.iter().filter(|&&b| b != 0).count();
|
||||
log::trace!(
|
||||
"[begin_stroke] below_cache built: {} bytes, non-zero bytes={}, active_idx={}",
|
||||
cache.len(),
|
||||
non_zero,
|
||||
active_idx
|
||||
);
|
||||
}
|
||||
self.below_cache = Some(cache);
|
||||
self.below_cache_active_idx = Some(active_idx);
|
||||
self.below_cache_dirty = false;
|
||||
|
||||
@@ -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,4 @@
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/>
|
||||
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 268 B |
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" fill="currentColor">
|
||||
<g transform="translate(40.96,40.96) scale(17.920000) translate(-0.00,-0.00)">
|
||||
<path d="M18 10h-7V7c0-1.7 1.4-3.1 3.1-3.1 1.7 0 3.1 1.4 3.1 3.1h2c0-2.8-2.2-5-5-5S7 4.2 7 7v3H6c-1.1 0-2 .9-2 2v8c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2v-8c0-1.1-.9-2-2-2zm-6 9c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 439 B |
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,60 @@
|
||||
//! File-format routing for documents opened in editor tabs.
|
||||
//!
|
||||
//! **Purpose:** Keeps all tab-opening entry points on one engine-loading path.
|
||||
//! **Logic & Workflow:** Classifies the extension case-insensitively, delegates
|
||||
//! layered formats to their dedicated engine API importers, and uses the generic
|
||||
//! image loader for raster formats. **Side Effects / Dependencies:** Mutates only
|
||||
//! the supplied `hcie_engine_api::Engine` and reads the requested file.
|
||||
|
||||
use hcie_engine_api::Engine;
|
||||
use std::path::Path;
|
||||
|
||||
/// Loads a path into an otherwise disposable document engine.
|
||||
///
|
||||
/// **Purpose:** Ensures Open, Open Recent, Viewer, and imported file messages use
|
||||
/// identical format routing. **Logic & Workflow:** PSD, KRA, and HCIE extensions
|
||||
/// select dedicated APIs; every other extension uses `Engine::open_image`.
|
||||
/// **Arguments:** `engine` receives the file and `path` identifies it.
|
||||
/// **Returns:** `Ok(())` on success or the engine's error text on failure.
|
||||
/// **Side Effects / Dependencies:** Replaces document state inside `engine` and
|
||||
/// performs file I/O through `hcie-engine-api`.
|
||||
pub(crate) fn load_path_into_engine(engine: &mut Engine, path: &Path) -> Result<(), String> {
|
||||
let path_text = path.to_string_lossy();
|
||||
match normalized_extension(path).as_str() {
|
||||
"psd" => engine.import_psd(&path_text),
|
||||
"kra" => engine.import_kra(&path_text),
|
||||
"hcie" => engine.load_native(&path_text),
|
||||
_ => engine.open_image(&path_text),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns a lowercase extension for format routing.
|
||||
///
|
||||
/// **Purpose:** Makes format selection deterministic across differently cased
|
||||
/// file names. **Arguments:** `path` is the source file. **Returns:** A lowercase
|
||||
/// extension or an empty string. **Side Effects / Dependencies:** None.
|
||||
fn normalized_extension(path: &Path) -> String {
|
||||
path.extension()
|
||||
.and_then(|extension| extension.to_str())
|
||||
.unwrap_or_default()
|
||||
.to_ascii_lowercase()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::normalized_extension;
|
||||
use std::path::Path;
|
||||
|
||||
/// Confirms extension routing remains case-insensitive.
|
||||
///
|
||||
/// **Purpose:** Prevents layered formats from accidentally taking the raster
|
||||
/// loader after path normalization changes. **Logic & Workflow:** Exercises
|
||||
/// uppercase and extensionless names. **Arguments & Returns:** None.
|
||||
/// **Side Effects / Dependencies:** None.
|
||||
#[test]
|
||||
fn extension_normalization_is_case_insensitive() {
|
||||
assert_eq!(normalized_extension(Path::new("image.PSD")), "psd");
|
||||
assert_eq!(normalized_extension(Path::new("project.KrA")), "kra");
|
||||
assert_eq!(normalized_extension(Path::new("untitled")), "");
|
||||
}
|
||||
}
|
||||
@@ -1,126 +1,35 @@
|
||||
//! Runtime build-ID management.
|
||||
//! Runtime access to immutable HCIE build identity.
|
||||
//!
|
||||
//! **Purpose:** Reads, atomically increments, and writes `build.id` on every application startup
|
||||
//! so each launch receives a unique, monotonically increasing build number — without requiring
|
||||
//! a wrapper script or a separate build step.
|
||||
//! **Logic & Workflow:** Locates the repository root by walking upward from `CARGO_MANIFEST_DIR`
|
||||
//! until a `Cargo.toml` containing `[workspace]` is found, then reads/increments/writes `build.id`
|
||||
//! under a file lock. Exposes the resulting version string for title-bar display.
|
||||
//! **Side Effects / Dependencies:** Performs one file-system write per startup; concurrent launches
|
||||
//! are serialized via an advisory directory lock.
|
||||
//! **Purpose:** Exposes the version generated by `hcie-build-info` without
|
||||
//! depending on Cargo-only environment variables or a writable source tree.
|
||||
//! **Logic & Workflow:** Cargo's build script embeds the canonical build ID in
|
||||
//! `hcie_build_info::VERSION`; application startup clones that static value for
|
||||
//! title-bar state. **Side Effects / Dependencies:** None at runtime.
|
||||
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
/// Maximum age (seconds) before a lock is considered stale and forcefully removed.
|
||||
const LOCK_STALE_SECS: u64 = 30;
|
||||
|
||||
/// File-system lock directory used to serialize concurrent increments.
|
||||
const LOCK_DIR: &str = ".build-id.lock";
|
||||
|
||||
/// Finds the repository root by walking upward from the compile-time manifest directory.
|
||||
/// Returns the build version embedded by Cargo during compilation.
|
||||
///
|
||||
/// **Returns:** The repository root `PathBuf`.
|
||||
/// **Side Effects / Dependencies:** Panics if the workspace root cannot be located.
|
||||
fn find_repo_root() -> PathBuf {
|
||||
let manifest_dir = PathBuf::from(
|
||||
std::env::var_os("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR is required"),
|
||||
);
|
||||
let mut candidate = manifest_dir.clone();
|
||||
loop {
|
||||
let tombstone = candidate.join("Cargo.toml");
|
||||
if tombstone.exists() {
|
||||
if let Ok(content) = fs::read_to_string(&tombstone) {
|
||||
if content.contains("[workspace]") {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
if !candidate.pop() {
|
||||
panic!("could not locate repository root above {:?}", manifest_dir);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns current Unix timestamp in seconds, or 0 on clock error.
|
||||
fn unix_now() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Checks whether a lock directory is stale (older than `LOCK_STALE_SECS`).
|
||||
fn is_lock_stale(lock_dir: &Path) -> bool {
|
||||
let ts_file = lock_dir.join("ts");
|
||||
if let Ok(ts_str) = fs::read_to_string(&ts_file) {
|
||||
if let Ok(ts) = ts_str.trim().parse::<u64>() {
|
||||
return unix_now().saturating_sub(ts) > LOCK_STALE_SECS;
|
||||
}
|
||||
}
|
||||
// No timestamp file or unparseable → treat as stale.
|
||||
true
|
||||
}
|
||||
|
||||
/// Acquires an advisory directory lock, increments the counter, and releases the lock.
|
||||
///
|
||||
/// **Returns:** The new (post-increment) build ID.
|
||||
/// **Side Effects / Dependencies:** Creates and removes a temporary lock directory.
|
||||
fn increment_build_id(root: &Path) -> u64 {
|
||||
let lock_dir = root.join(LOCK_DIR);
|
||||
let counter_file = root.join("build.id");
|
||||
|
||||
// Busy-wait with stale-lock detection.
|
||||
loop {
|
||||
match fs::create_dir(&lock_dir) {
|
||||
Ok(()) => {
|
||||
// Write timestamp for stale-lock detection by other instances.
|
||||
let _ = fs::write(
|
||||
lock_dir.join("ts"),
|
||||
format!("{}\n", unix_now()),
|
||||
);
|
||||
break;
|
||||
}
|
||||
Err(_) => {
|
||||
if is_lock_stale(&lock_dir) {
|
||||
let _ = fs::remove_dir_all(&lock_dir);
|
||||
continue;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Read current value.
|
||||
let current: u64 = fs::read_to_string(&counter_file)
|
||||
.ok()
|
||||
.and_then(|s| s.trim().parse().ok())
|
||||
.unwrap_or(0);
|
||||
|
||||
let next = current + 1;
|
||||
|
||||
// Atomic write: write to temp file, then rename.
|
||||
let tmp = counter_file.with_extension("id.tmp");
|
||||
fs::write(&tmp, format!("{}\n", next)).expect("failed to write build.id tmp");
|
||||
fs::rename(&tmp, &counter_file).expect("failed to rename build.id");
|
||||
|
||||
// Release lock.
|
||||
let _ = fs::remove_dir_all(&lock_dir);
|
||||
|
||||
next
|
||||
}
|
||||
|
||||
/// Reads, increments, and returns the build version string at runtime.
|
||||
///
|
||||
/// **Returns:** A version string like `"0.1.0+build.992"`.
|
||||
/// **Side Effects / Dependencies:** Writes to `build.id` on every call (intended for startup only).
|
||||
/// **Purpose:** Supplies a stable title-bar version in development, packaged,
|
||||
/// and directly executed binaries. **Logic & Workflow:** Copies the compile-time
|
||||
/// constant instead of searching for and mutating `build.id` at startup.
|
||||
/// **Arguments:** None. **Returns:** A version such as `0.1.0+build.1019`.
|
||||
/// **Side Effects / Dependencies:** Depends only on the `hcie-build-info` crate;
|
||||
/// it performs no environment lookup or file-system access.
|
||||
pub fn runtime_build_version() -> String {
|
||||
let root = find_repo_root();
|
||||
let build_id = increment_build_id(&root);
|
||||
let base_version = hcie_build_info::VERSION
|
||||
.split("+build.")
|
||||
.next()
|
||||
.unwrap_or("0.1.0");
|
||||
format!("{base_version}+build.{build_id}")
|
||||
hcie_build_info::VERSION.to_owned()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::runtime_build_version;
|
||||
|
||||
/// Confirms startup uses the immutable build-script output.
|
||||
///
|
||||
/// **Purpose:** Prevents regression to runtime `CARGO_MANIFEST_DIR` access.
|
||||
/// **Logic & Workflow:** Compares the public wrapper with the embedded value.
|
||||
/// **Arguments & Returns:** Takes no arguments and returns nothing.
|
||||
/// **Side Effects / Dependencies:** None.
|
||||
#[test]
|
||||
fn runtime_version_is_the_compile_time_version() {
|
||||
assert_eq!(runtime_build_version(), hcie_build_info::VERSION);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
//! - Only dirty sub-regions are uploaded — never the full 33MB buffer
|
||||
//! - The checkerboard is procedural (in the fragment shader) — no CPU geometry
|
||||
|
||||
pub mod perf;
|
||||
pub mod shader_canvas;
|
||||
pub mod texture_update;
|
||||
// pub mod viewport;
|
||||
@@ -86,6 +87,8 @@ struct OverlayProgram {
|
||||
_quick_mask: bool,
|
||||
/// Active brush diameter in canvas pixels for the footprint cursor.
|
||||
brush_size: f32,
|
||||
/// Whether a raster stroke is active; used to keep idle latency metrics isolated.
|
||||
is_drawing: bool,
|
||||
/// Normalized bounds of the selected vector shape in canvas-space (x1, y1, x2, y2).
|
||||
selected_vector_bounds: Option<(f32, f32, f32, f32)>,
|
||||
/// Rotation angle (radians) of the selected vector shape.
|
||||
@@ -107,6 +110,10 @@ struct OverlayProgram {
|
||||
struct OverlayState {
|
||||
/// Current cursor position in viewport-local coordinates.
|
||||
cursor_pos: Option<Point>,
|
||||
/// Monotonic timestamp of the latest cursor event for opt-in latency diagnostics.
|
||||
cursor_event_at: Option<std::time::Instant>,
|
||||
/// Sequence used to avoid measuring duplicate redraws of one cursor event.
|
||||
cursor_event_sequence: u64,
|
||||
/// Whether the mouse is over the overlay.
|
||||
is_hovered: bool,
|
||||
/// Current handle being hovered (for cursor changes).
|
||||
@@ -1162,7 +1169,7 @@ impl canvas::Program<Message> for OverlayProgram {
|
||||
renderer: &iced::Renderer,
|
||||
_theme: &iced::Theme,
|
||||
bounds: Rectangle,
|
||||
_cursor: Cursor,
|
||||
cursor: Cursor,
|
||||
) -> Vec<canvas::Geometry> {
|
||||
let (origin_x, origin_y, _display_w, _display_h) = self.canvas_origin(bounds.size());
|
||||
let mut geometries = Vec::new();
|
||||
@@ -2003,8 +2010,12 @@ impl canvas::Program<Message> for OverlayProgram {
|
||||
}
|
||||
|
||||
// ── Crosshair cursor ─────────────────────────────────────────────
|
||||
if state.is_hovered {
|
||||
if let Some(cursor) = state.cursor_pos {
|
||||
// Strategy 4: Use the `cursor` parameter from iced's current render
|
||||
// frame instead of the retained `state.cursor_pos` (which lags by
|
||||
// one Elm update cycle). This eliminates the 1-frame positional
|
||||
// delay between the OS pointer and the brush-circle indicator.
|
||||
if cursor.is_over(bounds) {
|
||||
if let Some(cursor_point) = cursor.position_in(bounds) {
|
||||
let mut crosshair_frame = Frame::new(renderer, bounds.size());
|
||||
let crosshair_size = 10.0;
|
||||
let crosshair_color = iced::Color::from_rgb(1.0, 1.0, 1.0);
|
||||
@@ -2016,7 +2027,7 @@ impl canvas::Program<Message> for OverlayProgram {
|
||||
| hcie_engine_api::Tool::Spray
|
||||
) {
|
||||
let footprint =
|
||||
Path::circle(cursor, (self.brush_size * self.zoom * 0.5).max(1.0));
|
||||
Path::circle(cursor_point, (self.brush_size * self.zoom * 0.5).max(1.0));
|
||||
crosshair_frame.stroke(
|
||||
&footprint,
|
||||
Stroke {
|
||||
@@ -2028,8 +2039,8 @@ impl canvas::Program<Message> for OverlayProgram {
|
||||
}
|
||||
crosshair_frame.stroke(
|
||||
&Path::line(
|
||||
Point::new(cursor.x - crosshair_size, cursor.y),
|
||||
Point::new(cursor.x + crosshair_size, cursor.y),
|
||||
Point::new(cursor_point.x - crosshair_size, cursor_point.y),
|
||||
Point::new(cursor_point.x + crosshair_size, cursor_point.y),
|
||||
),
|
||||
Stroke {
|
||||
style: canvas::stroke::Style::Solid(crosshair_color),
|
||||
@@ -2039,8 +2050,8 @@ impl canvas::Program<Message> for OverlayProgram {
|
||||
);
|
||||
crosshair_frame.stroke(
|
||||
&Path::line(
|
||||
Point::new(cursor.x, cursor.y - crosshair_size),
|
||||
Point::new(cursor.x, cursor.y + crosshair_size),
|
||||
Point::new(cursor_point.x, cursor_point.y - crosshair_size),
|
||||
Point::new(cursor_point.x, cursor_point.y + crosshair_size),
|
||||
),
|
||||
Stroke {
|
||||
style: canvas::stroke::Style::Solid(crosshair_color),
|
||||
@@ -2049,6 +2060,14 @@ impl canvas::Program<Message> for OverlayProgram {
|
||||
},
|
||||
);
|
||||
geometries.push(crosshair_frame.into_geometry());
|
||||
if !self.is_drawing {
|
||||
if let Some(captured_at) = state.cursor_event_at {
|
||||
crate::canvas::perf::record_idle_cursor_draw(
|
||||
state.cursor_event_sequence,
|
||||
captured_at,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2095,6 +2114,8 @@ impl canvas::Program<Message> for OverlayProgram {
|
||||
canvas::Event::Mouse(mouse::Event::CursorMoved { position }) => {
|
||||
let local_pos = Point::new(position.x - bounds.x, position.y - bounds.y);
|
||||
state.cursor_pos = Some(local_pos);
|
||||
state.cursor_event_at = Some(std::time::Instant::now());
|
||||
state.cursor_event_sequence = state.cursor_event_sequence.wrapping_add(1);
|
||||
state.is_hovered = bounds.contains(position);
|
||||
|
||||
// Update hovered handle
|
||||
@@ -2130,6 +2151,14 @@ impl canvas::Program<Message> for OverlayProgram {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Strategy 1: Emit a lightweight redraw message on every CursorMoved
|
||||
// event so iced triggers a view() rebuild and the overlay's draw()
|
||||
// runs with the latest cursor position. Without this, iced may
|
||||
// skip repaints during idle cursor movement because no application
|
||||
// state changed.
|
||||
if matches!(event, canvas::Event::Mouse(mouse::Event::CursorMoved { .. })) {
|
||||
return (canvas::event::Status::Ignored, Some(Message::CursorOverlayRedraw));
|
||||
}
|
||||
// Always pass events through to the shader widget below
|
||||
(canvas::event::Status::Ignored, None)
|
||||
}
|
||||
@@ -2304,6 +2333,7 @@ pub fn view<'a>(
|
||||
polygon_points: doc.polygon_points.clone(),
|
||||
_quick_mask: doc.quick_mask,
|
||||
brush_size: tool_state.brush_size,
|
||||
is_drawing: tool_state.is_drawing,
|
||||
selected_vector_bounds: sel_vec_bounds,
|
||||
selected_vector_angle: sel_vec_angle,
|
||||
vector_edit_handle: doc.vector_edit_handle,
|
||||
|
||||
@@ -0,0 +1,366 @@
|
||||
//! Opt-in real-time canvas performance measurements.
|
||||
//!
|
||||
//! **Purpose:** Separates input queueing, engine drawing, CPU compositing/staging,
|
||||
//! GPU upload, and renderer preparation costs without adding production log noise.
|
||||
//! **Logic & Workflow:** When `HCIE_CANVAS_PERF=1`, callers record named durations
|
||||
//! and byte counters in a process-wide stroke accumulator. Stroke completion sorts
|
||||
//! samples and emits one p50/p95/max summary. **Side Effects / Dependencies:** Uses
|
||||
//! a standard-library mutex and `log`; disabled builds do not allocate sample data.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// Environment variable that enables canvas performance diagnostics.
|
||||
const PERF_ENV: &str = "HCIE_CANVAS_PERF";
|
||||
|
||||
/// Mutable measurements for the current stroke and renderer cadence.
|
||||
#[derive(Default)]
|
||||
struct PerfState {
|
||||
stroke_number: u64,
|
||||
active: bool,
|
||||
samples_ms: BTreeMap<&'static str, Vec<f64>>,
|
||||
dirty_bytes: u64,
|
||||
uploaded_bytes: u64,
|
||||
full_uploads: u64,
|
||||
visible_updates: u64,
|
||||
latest_render_input: Option<Instant>,
|
||||
last_prepare: Option<Instant>,
|
||||
finish_pending: bool,
|
||||
idle_cursor_samples_ms: Vec<f64>,
|
||||
idle_view_samples_ms: Vec<f64>,
|
||||
idle_canvas_messages: u64,
|
||||
idle_dock_messages: u64,
|
||||
last_idle_cursor_sequence: Option<u64>,
|
||||
}
|
||||
|
||||
/// Records one application view reconstruction during cursor diagnostics.
|
||||
///
|
||||
/// **Arguments:** `elapsed` is the time spent constructing the Elm element tree.
|
||||
/// **Returns:** Nothing. **Side Effects / Dependencies:** Stores a bounded sample
|
||||
/// only when `HCIE_CANVAS_PERF=1`; reporting is performed by cursor draw samples.
|
||||
pub fn record_idle_view(elapsed: Duration) {
|
||||
if !enabled() {
|
||||
return;
|
||||
}
|
||||
with_state(|state| {
|
||||
if state.idle_view_samples_ms.len() < 240 {
|
||||
state
|
||||
.idle_view_samples_ms
|
||||
.push(elapsed.as_secs_f64() * 1000.0);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Counts an app-level message generated by idle pointer movement.
|
||||
///
|
||||
/// **Arguments:** `dock_message` is `true` for the global dock subscription and
|
||||
/// `false` for canvas status coordinates. **Returns:** Nothing.
|
||||
/// **Side Effects / Dependencies:** Updates diagnostic counters only when enabled.
|
||||
pub fn record_idle_pointer_message(dock_message: bool) {
|
||||
if !enabled() {
|
||||
return;
|
||||
}
|
||||
with_state(|state| {
|
||||
if dock_message {
|
||||
state.idle_dock_messages = state.idle_dock_messages.saturating_add(1);
|
||||
} else {
|
||||
state.idle_canvas_messages = state.idle_canvas_messages.saturating_add(1);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Records native cursor-event to overlay-draw latency and periodically reports it.
|
||||
///
|
||||
/// **Arguments:** `sequence` identifies a unique retained-overlay event and
|
||||
/// `captured_at` is its monotonic timestamp. **Returns:** Nothing.
|
||||
/// **Side Effects / Dependencies:** Emits one summary per 120 unique cursor draws
|
||||
/// when `HCIE_CANVAS_PERF=1`; duplicate draws of the same event are ignored.
|
||||
pub fn record_idle_cursor_draw(sequence: u64, captured_at: Instant) {
|
||||
if !enabled() {
|
||||
return;
|
||||
}
|
||||
with_state(|state| {
|
||||
if state.last_idle_cursor_sequence == Some(sequence) {
|
||||
return;
|
||||
}
|
||||
state.last_idle_cursor_sequence = Some(sequence);
|
||||
state
|
||||
.idle_cursor_samples_ms
|
||||
.push(captured_at.elapsed().as_secs_f64() * 1000.0);
|
||||
if state.idle_cursor_samples_ms.len() < 120 {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut cursor_samples = std::mem::take(&mut state.idle_cursor_samples_ms);
|
||||
let mut view_samples = std::mem::take(&mut state.idle_view_samples_ms);
|
||||
cursor_samples.sort_by(f64::total_cmp);
|
||||
view_samples.sort_by(f64::total_cmp);
|
||||
log::info!(
|
||||
target: "hcie_canvas_perf",
|
||||
"idle_cursor draws={} event_to_overlay_draw[p50={:.3}ms,p95={:.3}ms,max={:.3}ms] view_rebuild[n={},p50={:.3}ms,p95={:.3}ms,max={:.3}ms] messages[canvas={},dock={}]",
|
||||
cursor_samples.len(),
|
||||
percentile(&cursor_samples, 0.50),
|
||||
percentile(&cursor_samples, 0.95),
|
||||
cursor_samples.last().copied().unwrap_or(0.0),
|
||||
view_samples.len(),
|
||||
percentile(&view_samples, 0.50),
|
||||
percentile(&view_samples, 0.95),
|
||||
view_samples.last().copied().unwrap_or(0.0),
|
||||
state.idle_canvas_messages,
|
||||
state.idle_dock_messages,
|
||||
);
|
||||
state.idle_canvas_messages = 0;
|
||||
state.idle_dock_messages = 0;
|
||||
});
|
||||
}
|
||||
|
||||
/// Returns whether diagnostics are enabled for this process.
|
||||
///
|
||||
/// **Arguments:** None. **Returns:** `true` only for `1`, `true`, or `yes`.
|
||||
/// **Side Effects / Dependencies:** Reads `HCIE_CANVAS_PERF` once and caches it.
|
||||
pub fn enabled() -> bool {
|
||||
static ENABLED: OnceLock<bool> = OnceLock::new();
|
||||
*ENABLED.get_or_init(|| {
|
||||
std::env::var(PERF_ENV)
|
||||
.map(|value| matches!(value.to_ascii_lowercase().as_str(), "1" | "true" | "yes"))
|
||||
.unwrap_or(false)
|
||||
})
|
||||
}
|
||||
|
||||
/// Starts a fresh stroke measurement window.
|
||||
///
|
||||
/// **Arguments:** None. **Returns:** Nothing.
|
||||
/// **Side Effects / Dependencies:** Clears prior samples and resets frame cadence.
|
||||
pub fn begin_stroke() {
|
||||
if !enabled() {
|
||||
return;
|
||||
}
|
||||
with_state(|state| {
|
||||
state.stroke_number = state.stroke_number.wrapping_add(1);
|
||||
state.active = true;
|
||||
state.samples_ms.clear();
|
||||
state.dirty_bytes = 0;
|
||||
state.uploaded_bytes = 0;
|
||||
state.full_uploads = 0;
|
||||
state.visible_updates = 0;
|
||||
state.latest_render_input = None;
|
||||
state.last_prepare = None;
|
||||
state.finish_pending = false;
|
||||
});
|
||||
}
|
||||
|
||||
/// Records elapsed time for one named pipeline stage.
|
||||
///
|
||||
/// **Arguments:** `stage` is a stable metric name and `elapsed` is its duration.
|
||||
/// **Returns:** Nothing. **Side Effects / Dependencies:** Appends one sample while active.
|
||||
pub fn record_duration(stage: &'static str, elapsed: Duration) {
|
||||
if !enabled() {
|
||||
return;
|
||||
}
|
||||
with_state(|state| {
|
||||
if state.active {
|
||||
state
|
||||
.samples_ms
|
||||
.entry(stage)
|
||||
.or_default()
|
||||
.push(elapsed.as_secs_f64() * 1000.0);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Records event queue delay and marks the input represented by the next texture update.
|
||||
///
|
||||
/// **Arguments:** `captured_at` is the time the shader widget emitted the input message.
|
||||
/// **Returns:** Nothing. **Side Effects / Dependencies:** Updates input latency samples.
|
||||
pub fn record_render_input(captured_at: Instant) {
|
||||
if !enabled() {
|
||||
return;
|
||||
}
|
||||
with_state(|state| {
|
||||
if state.active {
|
||||
state
|
||||
.samples_ms
|
||||
.entry("input_to_update")
|
||||
.or_default()
|
||||
.push(captured_at.elapsed().as_secs_f64() * 1000.0);
|
||||
state.latest_render_input = Some(captured_at);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Returns the latest input timestamp that changed engine pixels.
|
||||
///
|
||||
/// **Arguments:** None. **Returns:** Optional monotonic input timestamp.
|
||||
/// **Side Effects / Dependencies:** Reads the shared diagnostic accumulator.
|
||||
pub fn latest_render_input() -> Option<Instant> {
|
||||
if !enabled() {
|
||||
return None;
|
||||
}
|
||||
with_state(|state| state.latest_render_input)
|
||||
}
|
||||
|
||||
/// Records one dirty region produced by CPU compositing.
|
||||
///
|
||||
/// **Arguments:** `region` uses exclusive bounds. **Returns:** Nothing.
|
||||
/// **Side Effects / Dependencies:** Adds the RGBA byte area to the current stroke.
|
||||
pub fn record_dirty_region(region: [u32; 4]) {
|
||||
if !enabled() {
|
||||
return;
|
||||
}
|
||||
let bytes = (region[2].saturating_sub(region[0]) as u64)
|
||||
* (region[3].saturating_sub(region[1]) as u64)
|
||||
* 4;
|
||||
with_state(|state| {
|
||||
if state.active {
|
||||
state.dirty_bytes = state.dirty_bytes.saturating_add(bytes);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Records bytes submitted to the GPU texture and whether the upload was full-canvas.
|
||||
///
|
||||
/// **Arguments:** `bytes` is payload size and `full` identifies recovery/replace uploads.
|
||||
/// **Returns:** Nothing. **Side Effects / Dependencies:** Updates current stroke counters.
|
||||
pub fn record_upload(bytes: usize, full: bool) {
|
||||
if !enabled() {
|
||||
return;
|
||||
}
|
||||
with_state(|state| {
|
||||
if state.active {
|
||||
state.uploaded_bytes = state.uploaded_bytes.saturating_add(bytes as u64);
|
||||
state.full_uploads += u64::from(full);
|
||||
state.visible_updates += 1;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Records renderer preparation cadence and input-to-GPU-prepare proxy latency.
|
||||
///
|
||||
/// **Arguments:** `input_at` is the latest pixel-changing input represented by this update.
|
||||
/// **Returns:** Nothing. **Side Effects / Dependencies:** Updates shared prepare timing state.
|
||||
pub fn record_prepare(input_at: Option<Instant>) {
|
||||
if !enabled() {
|
||||
return;
|
||||
}
|
||||
let now = Instant::now();
|
||||
let should_finish = with_state(|state| {
|
||||
if let Some(previous) = state.last_prepare.replace(now) {
|
||||
if state.active {
|
||||
state
|
||||
.samples_ms
|
||||
.entry("prepare_interval")
|
||||
.or_default()
|
||||
.push(now.duration_since(previous).as_secs_f64() * 1000.0);
|
||||
}
|
||||
}
|
||||
if state.active {
|
||||
if let Some(input_at) = input_at {
|
||||
state
|
||||
.samples_ms
|
||||
.entry("input_to_gpu_prepare_proxy")
|
||||
.or_default()
|
||||
.push(now.duration_since(input_at).as_secs_f64() * 1000.0);
|
||||
}
|
||||
}
|
||||
state.finish_pending
|
||||
});
|
||||
if should_finish {
|
||||
finish_stroke();
|
||||
}
|
||||
}
|
||||
|
||||
/// Requests summary emission after the final renderer preparation for this stroke.
|
||||
///
|
||||
/// **Arguments:** None. **Returns:** Nothing.
|
||||
/// **Side Effects / Dependencies:** Marks the active diagnostic window for deferred completion.
|
||||
pub fn request_finish_stroke() {
|
||||
if !enabled() {
|
||||
return;
|
||||
}
|
||||
with_state(|state| {
|
||||
if state.active {
|
||||
state.finish_pending = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Finishes the current stroke and logs one percentile summary.
|
||||
///
|
||||
/// **Arguments:** None. **Returns:** Nothing.
|
||||
/// **Side Effects / Dependencies:** Sorts captured samples and emits one info-level log entry.
|
||||
fn finish_stroke() {
|
||||
if !enabled() {
|
||||
return;
|
||||
}
|
||||
with_state(|state| {
|
||||
if !state.active {
|
||||
return;
|
||||
}
|
||||
state.active = false;
|
||||
state.finish_pending = false;
|
||||
let mut stages = Vec::with_capacity(state.samples_ms.len());
|
||||
for (name, samples) in &mut state.samples_ms {
|
||||
samples.sort_by(f64::total_cmp);
|
||||
if let Some(max) = samples.last().copied() {
|
||||
stages.push(format!(
|
||||
"{}[n={},p50={:.3}ms,p95={:.3}ms,max={:.3}ms]",
|
||||
name,
|
||||
samples.len(),
|
||||
percentile(samples, 0.50),
|
||||
percentile(samples, 0.95),
|
||||
max
|
||||
));
|
||||
}
|
||||
}
|
||||
log::info!(
|
||||
target: "hcie_canvas_perf",
|
||||
"stroke={} {} counters[dirty={}B,uploaded={}B,full_uploads={},visible_updates={}]",
|
||||
state.stroke_number,
|
||||
stages.join(" "),
|
||||
state.dirty_bytes,
|
||||
state.uploaded_bytes,
|
||||
state.full_uploads,
|
||||
state.visible_updates
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/// Returns a percentile from a sorted sample slice.
|
||||
///
|
||||
/// **Arguments:** `samples` must be sorted and `fraction` is in `0.0..=1.0`.
|
||||
/// **Returns:** Selected sample or zero for an empty slice. **Side Effects:** None.
|
||||
fn percentile(samples: &[f64], fraction: f64) -> f64 {
|
||||
if samples.is_empty() {
|
||||
return 0.0;
|
||||
}
|
||||
let index = ((samples.len() - 1) as f64 * fraction.clamp(0.0, 1.0)).round() as usize;
|
||||
samples[index]
|
||||
}
|
||||
|
||||
/// Executes a closure while holding the diagnostic state lock.
|
||||
///
|
||||
/// **Arguments:** `operation` receives mutable state. **Returns:** Closure result.
|
||||
/// **Side Effects / Dependencies:** Initializes and locks the process-wide accumulator.
|
||||
fn with_state<T>(operation: impl FnOnce(&mut PerfState) -> T) -> T {
|
||||
static STATE: OnceLock<Mutex<PerfState>> = OnceLock::new();
|
||||
let mut state = STATE
|
||||
.get_or_init(|| Mutex::new(PerfState::default()))
|
||||
.lock()
|
||||
.unwrap_or_else(|error| error.into_inner());
|
||||
operation(&mut state)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::percentile;
|
||||
|
||||
/// Confirms percentile selection is deterministic for short frame samples.
|
||||
#[test]
|
||||
fn percentile_uses_nearest_ranked_sample() {
|
||||
let samples = [1.0, 2.0, 3.0, 4.0, 5.0];
|
||||
assert_eq!(percentile(&samples, 0.50), 3.0);
|
||||
assert_eq!(percentile(&samples, 0.95), 5.0);
|
||||
assert_eq!(percentile(&[], 0.50), 0.0);
|
||||
}
|
||||
}
|
||||
@@ -231,6 +231,7 @@ impl CanvasShaderPipeline {
|
||||
canvas_h,
|
||||
initial_pixels.len()
|
||||
);
|
||||
super::perf::record_upload(initial_pixels.len(), true);
|
||||
}
|
||||
|
||||
// ── Sampler (nearest for zoom-in, linear for zoom-out) ───────────
|
||||
@@ -517,6 +518,7 @@ impl CanvasShaderPipeline {
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
);
|
||||
super::perf::record_upload(pixels.len(), true);
|
||||
}
|
||||
|
||||
// Recreate selection mask texture with new dimensions
|
||||
@@ -570,6 +572,59 @@ impl CanvasShaderPipeline {
|
||||
self.texture_h = canvas_h;
|
||||
}
|
||||
|
||||
/// Replace every pixel in the existing composite texture without rebuilding GPU resources.
|
||||
///
|
||||
/// ## Arguments
|
||||
/// * `queue` — wgpu queue used for the texture write
|
||||
/// * `pixels` — complete tightly packed RGBA image matching the current dimensions
|
||||
///
|
||||
/// ## Returns
|
||||
/// Nothing. Invalid buffer lengths are logged and ignored.
|
||||
///
|
||||
/// ## Side Effects
|
||||
/// Enqueues one full texture write while preserving texture views, selection resources,
|
||||
/// samplers, and the bind group.
|
||||
fn upload_full_texture(&self, queue: &wgpu::Queue, pixels: &[u8]) {
|
||||
let expected = self.texture_w as usize * self.texture_h as usize * 4;
|
||||
if self.texture_w == 0 || self.texture_h == 0 || pixels.len() != expected {
|
||||
log::warn!(
|
||||
"[CanvasShaderPipeline::upload_full_texture] size mismatch: pixels={} expected={} canvas={}x{}",
|
||||
pixels.len(),
|
||||
expected,
|
||||
self.texture_w,
|
||||
self.texture_h
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
queue.write_texture(
|
||||
wgpu::ImageCopyTexture {
|
||||
texture: &self.texture,
|
||||
mip_level: 0,
|
||||
origin: wgpu::Origin3d::ZERO,
|
||||
aspect: wgpu::TextureAspect::All,
|
||||
},
|
||||
pixels,
|
||||
wgpu::ImageDataLayout {
|
||||
offset: 0,
|
||||
bytes_per_row: Some(self.texture_w * 4),
|
||||
rows_per_image: Some(self.texture_h),
|
||||
},
|
||||
wgpu::Extent3d {
|
||||
width: self.texture_w,
|
||||
height: self.texture_h,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
);
|
||||
super::perf::record_upload(pixels.len(), true);
|
||||
log::debug!(
|
||||
"[CanvasShaderPipeline::upload_full_texture] uploaded {}x{} texture ({} bytes)",
|
||||
self.texture_w,
|
||||
self.texture_h,
|
||||
pixels.len()
|
||||
);
|
||||
}
|
||||
|
||||
/// Upload only the dirty sub-region of the composite buffer to the GPU.
|
||||
///
|
||||
/// ## Arguments
|
||||
@@ -598,6 +653,8 @@ impl CanvasShaderPipeline {
|
||||
},
|
||||
);
|
||||
let elapsed = t0.elapsed();
|
||||
super::perf::record_duration("gpu_upload", elapsed);
|
||||
super::perf::record_upload(update.pixels.len(), false);
|
||||
log::debug!(
|
||||
"[CanvasShaderPipeline::upload_dirty_region] {}×{} region at ({},{}) → {:.2}ms, {} bytes",
|
||||
update.width(), update.height(), x0, y0, elapsed.as_secs_f64() * 1000.0, update.pixels.len()
|
||||
@@ -710,6 +767,15 @@ impl shader::Primitive for CanvasShaderPrimitive {
|
||||
_viewport: &shader::Viewport,
|
||||
) {
|
||||
let t0 = std::time::Instant::now();
|
||||
let represented_input = self
|
||||
.texture_update
|
||||
.as_ref()
|
||||
.and_then(|update| update.input_at)
|
||||
.or_else(|| {
|
||||
self.full_upload
|
||||
.then(super::perf::latest_render_input)
|
||||
.flatten()
|
||||
});
|
||||
|
||||
// ── Create or retrieve pipeline ──────────────────────────────────
|
||||
let created_pipeline = !storage.has::<CanvasShaderPipeline>();
|
||||
@@ -736,9 +802,9 @@ impl shader::Primitive for CanvasShaderPrimitive {
|
||||
let pixels = self.composite_pixels.read();
|
||||
pipeline.resize_texture(device, queue, self.canvas_w, self.canvas_h, &pixels);
|
||||
} else if !created_pipeline && self.full_upload {
|
||||
// Full upload requested (e.g., after loading a file)
|
||||
// Same-size document replacement: preserve all persistent GPU resources.
|
||||
let pixels = self.composite_pixels.read();
|
||||
pipeline.resize_texture(device, queue, self.canvas_w, self.canvas_h, &pixels);
|
||||
pipeline.upload_full_texture(queue, &pixels);
|
||||
} else if !created_pipeline {
|
||||
// ── Partial upload: only the dirty sub-region ────────────────
|
||||
if let Some(update) = self.texture_update.as_ref() {
|
||||
@@ -806,6 +872,8 @@ impl shader::Primitive for CanvasShaderPrimitive {
|
||||
pipeline.update_uniforms(queue, &uniforms);
|
||||
|
||||
let elapsed = t0.elapsed();
|
||||
super::perf::record_duration("gpu_prepare", elapsed);
|
||||
super::perf::record_prepare(represented_input);
|
||||
log::trace!(
|
||||
"[CanvasShaderPrimitive::prepare] {:.2}ms | viewport={}×{} | canvas={}×{} | zoom={:.2}",
|
||||
elapsed.as_secs_f64() * 1000.0,
|
||||
@@ -893,6 +961,32 @@ 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`.
|
||||
@@ -988,6 +1082,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 +1094,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 +1101,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).clamp(0.0, self.engine_w as f32);
|
||||
let cy = canvas_y.unwrap_or(0.0).clamp(0.0, self.engine_h as f32);
|
||||
return (
|
||||
iced::event::Status::Captured,
|
||||
Some(Message::CanvasPointerMoved {
|
||||
x: cx,
|
||||
y: cy,
|
||||
captured_at: std::time::Instant::now(),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// Middle mouse drag (panning)
|
||||
@@ -1024,7 +1123,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 +1148,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 +1161,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 +1203,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 +1223,18 @@ impl shader::Program<Message> for CanvasShaderProgram {
|
||||
state.left_pressed = true;
|
||||
return (
|
||||
iced::event::Status::Captured,
|
||||
Some(Message::CanvasPointerPressed { x: cx, y: cy }),
|
||||
Some(Message::CanvasPointerPressed {
|
||||
x: cx,
|
||||
y: cy,
|
||||
captured_at: std::time::Instant::now(),
|
||||
}),
|
||||
);
|
||||
}
|
||||
} else if button == mouse::Button::Middle {
|
||||
if let Some(pos) = cursor.position() {
|
||||
state.middle_pressed = true;
|
||||
state.pan_start = Some(pos);
|
||||
state.local_pan_offset = Some(self.pan_offset);
|
||||
return (iced::event::Status::Captured, None);
|
||||
}
|
||||
} else if button == mouse::Button::Right {
|
||||
@@ -1142,6 +1272,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 +1283,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 +1372,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 +1380,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 +1409,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.
|
||||
|
||||
@@ -35,6 +35,15 @@ fn main() {
|
||||
.try_init();
|
||||
|
||||
log::info!("Starting HCIE Iced GUI");
|
||||
if canvas::perf::enabled() {
|
||||
log::info!(
|
||||
target: "hcie_canvas_perf",
|
||||
"renderer diagnostics enabled: ICED_BACKEND={} WGPU_BACKEND={} WGPU_POWER_PREF={}; verify iced_wgpu Selected AdapterInfo is a hardware Vulkan adapter",
|
||||
std::env::var("ICED_BACKEND").unwrap_or_else(|_| "<default>".to_string()),
|
||||
std::env::var("WGPU_BACKEND").unwrap_or_else(|_| "<default>".to_string()),
|
||||
std::env::var("WGPU_POWER_PREF").unwrap_or_else(|_| "<default>".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
let args = match cli::parse_args(std::env::args().skip(1)) {
|
||||
Ok(args) => args,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -168,11 +168,6 @@ pub fn view<'a>(
|
||||
expanded: bool,
|
||||
) -> Element<'a, Message> {
|
||||
let sidebar_width = if expanded { 72 } else { 36 };
|
||||
log::info!(
|
||||
"[sidebar] expanded={} open_subtool_slot={:?}",
|
||||
expanded,
|
||||
open_subtool_slot
|
||||
);
|
||||
|
||||
// Tool buttons — single column or 2-column grid depending on expanded state
|
||||
let mut tool_list = column![].spacing(1);
|
||||
|
||||
@@ -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() {
|
||||
|
||||
+2
-2
@@ -26,8 +26,8 @@ crate-type = ["staticlib", "rlib"]
|
||||
rstest = "0.23"
|
||||
proptest = "1.5"
|
||||
approx = "0.5"
|
||||
egui = "0.34"
|
||||
eframe = { version = "0.34", default-features = false, features = ["default_fonts", "glow", "x11", "wayland"] }
|
||||
egui = { workspace = true }
|
||||
eframe = { workspace = true }
|
||||
rfd = "0.15"
|
||||
hcie-psd-saver = { path = "../hcie-psd-saver" }
|
||||
zip = { version = "2.2", default-features = false, features = ["deflate"] }
|
||||
|
||||
@@ -1,4 +1,31 @@
|
||||
#![allow(dead_code, unused_imports, unused_variables, unused_macros, unreachable_patterns, unused_assignments, clippy::cfg)]
|
||||
|
||||
/// Reports whether a required test fixture is absent.
|
||||
///
|
||||
/// **Purpose:** Lets fixture-dependent integration tests remain runnable in checkouts that do not
|
||||
/// contain the external PSD reference corpus.
|
||||
/// **Logic & Workflow:** Reads filesystem metadata and classifies only `NotFound` as an optional
|
||||
/// fixture skip. Any other metadata error remains a hard failure so permission and filesystem
|
||||
/// problems are not hidden.
|
||||
/// **Arguments:** `path` is the required fixture path to inspect.
|
||||
/// **Returns:** `true` when the fixture does not exist and the caller should return early;
|
||||
/// otherwise `false`.
|
||||
/// **Side Effects / Dependencies:** Writes one diagnostic line for a missing fixture and panics on
|
||||
/// unexpected filesystem metadata errors.
|
||||
fn fixture_is_missing(path: &std::path::Path) -> bool {
|
||||
match std::fs::metadata(path) {
|
||||
Ok(_) => false,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
|
||||
eprintln!("Fixture not found, skipping test: {}", path.display());
|
||||
true
|
||||
}
|
||||
Err(error) => panic!(
|
||||
"failed to inspect test fixture '{}': {error}",
|
||||
path.display()
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_psd_import_sizes_and_offsets() {
|
||||
let path = std::path::Path::new("_images/_test_images/example3/Example3-mini.psd");
|
||||
@@ -240,8 +267,10 @@ fn composite_and_compare(psd_path: &str, ref_path: &str) {
|
||||
#[test]
|
||||
fn test_layer_pixels_direct() {
|
||||
let psd_path = std::path::Path::new("_images/_psd_stil_test/base_test_generated_2.psd");
|
||||
let ref_path = std::path::Path::new("_images/_psd_stil_test/base_test_generated_2.png");
|
||||
if fixture_is_missing(psd_path) || fixture_is_missing(ref_path) { return; }
|
||||
let layers = hcie_psd::import_psd(psd_path).unwrap();
|
||||
let ref_img = image::open("_images/_psd_stil_test/base_test_generated_2.png").unwrap().to_rgba8();
|
||||
let ref_img = image::open(ref_path).unwrap().to_rgba8();
|
||||
let ref_pixels = ref_img.as_raw();
|
||||
|
||||
// Compare layer 0 (Background Grid) pixels directly with reference
|
||||
@@ -299,6 +328,9 @@ fn test_layer_pixels_direct() {
|
||||
|
||||
#[test]
|
||||
fn test_base_generated_2_vs_merged() {
|
||||
let psd_path = std::path::Path::new("_images/_psd_stil_test/base_test_generated_2.psd");
|
||||
if fixture_is_missing(psd_path) { return; }
|
||||
|
||||
// Extract PSD merged image using ImageMagick
|
||||
let _ = std::process::Command::new("convert")
|
||||
.args(&[
|
||||
@@ -310,7 +342,7 @@ fn test_base_generated_2_vs_merged() {
|
||||
let psd_merged = image::open("/tmp/psd2_merged_for_test.png").unwrap().to_rgba8();
|
||||
let psd_pixels = psd_merged.as_raw();
|
||||
|
||||
let layers = hcie_psd::import_psd(std::path::Path::new("_images/_psd_stil_test/base_test_generated_2.psd")).unwrap();
|
||||
let layers = hcie_psd::import_psd(psd_path).unwrap();
|
||||
let canvas_w = layers[0].width;
|
||||
let canvas_h = layers[0].height;
|
||||
|
||||
@@ -363,6 +395,7 @@ fn test_blend_normal() {
|
||||
#[test]
|
||||
fn test_teal_circle_emboss_debug() {
|
||||
let psd_path = std::path::Path::new("_images/_psd_stil_test/base_test_generated_2.psd");
|
||||
if fixture_is_missing(psd_path) { return; }
|
||||
let layers = hcie_psd::import_psd(psd_path).unwrap();
|
||||
let teal = &layers[1]; // Teal Circle
|
||||
|
||||
@@ -377,8 +410,10 @@ fn test_teal_circle_emboss_debug() {
|
||||
#[test]
|
||||
fn test_base_generated_2_no_effects() {
|
||||
let psd_path = std::path::Path::new("_images/_psd_stil_test/base_test_generated_2.psd");
|
||||
let ref_path = std::path::Path::new("_images/_psd_stil_test/base_test_generated_2.png");
|
||||
if fixture_is_missing(psd_path) || fixture_is_missing(ref_path) { return; }
|
||||
let layers = hcie_psd::import_psd(psd_path).unwrap();
|
||||
let ref_img = image::open("_images/_psd_stil_test/base_test_generated_2.png").unwrap().to_rgba8();
|
||||
let ref_img = image::open(ref_path).unwrap().to_rgba8();
|
||||
let ref_pixels = ref_img.as_raw();
|
||||
|
||||
let canvas_w = layers[0].width;
|
||||
@@ -438,6 +473,7 @@ fn test_hc_emboss_composite() {
|
||||
fn test_base_generated_2_effect_isolation() {
|
||||
let psd_path = std::path::Path::new("_images/_psd_stil_test/base_test_generated_2.psd");
|
||||
let ref_path = std::path::Path::new("_images/_psd_stil_test/base_test_generated_2.png");
|
||||
if fixture_is_missing(psd_path) || fixture_is_missing(ref_path) { return; }
|
||||
|
||||
let all_layers = hcie_psd::import_psd(psd_path).unwrap();
|
||||
let ref_img = image::open(ref_path).unwrap().to_rgba8();
|
||||
@@ -490,6 +526,7 @@ fn compute_mae(a: &[u8], b: &[u8], pixels: usize) -> f64 {
|
||||
#[test]
|
||||
fn test_check_soft_orange_pixels() {
|
||||
let psd_path = std::path::Path::new("_images/_psd_stil_test/base_test_generated_2.psd");
|
||||
if fixture_is_missing(psd_path) { return; }
|
||||
let layers = hcie_psd::import_psd(psd_path).unwrap();
|
||||
|
||||
let orange = &layers[5]; // Soft Orange Shape
|
||||
@@ -515,6 +552,7 @@ fn test_check_soft_orange_pixels() {
|
||||
#[test]
|
||||
fn test_find_orange_pixels() {
|
||||
let psd_path = std::path::Path::new("_images/_psd_stil_test/base_test_generated_2.psd");
|
||||
if fixture_is_missing(psd_path) { return; }
|
||||
let layers = hcie_psd::import_psd(psd_path).unwrap();
|
||||
|
||||
let orange = &layers[5]; // Soft Orange Shape
|
||||
@@ -554,6 +592,7 @@ fn test_find_orange_pixels() {
|
||||
#[test]
|
||||
fn test_layer_bounds() {
|
||||
let psd_path = std::path::Path::new("_images/_psd_stil_test/base_test_generated_2.psd");
|
||||
if fixture_is_missing(psd_path) { return; }
|
||||
let layers = hcie_psd::import_psd(psd_path).unwrap();
|
||||
|
||||
for (i, layer) in layers.iter().enumerate() {
|
||||
@@ -583,6 +622,7 @@ fn test_layer_bounds() {
|
||||
#[test]
|
||||
fn test_check_layer_offsets() {
|
||||
let psd_path = std::path::Path::new("_images/_psd_stil_test/base_test_generated_2.psd");
|
||||
if fixture_is_missing(psd_path) { return; }
|
||||
let layers = hcie_psd::import_psd(psd_path).unwrap();
|
||||
|
||||
println!("Layer positions and sizes:");
|
||||
@@ -620,6 +660,7 @@ fn test_check_layer_offsets() {
|
||||
#[test]
|
||||
fn test_save_layer_pixels() {
|
||||
let psd_path = std::path::Path::new("_images/_psd_stil_test/base_test_generated_2.psd");
|
||||
if fixture_is_missing(psd_path) { return; }
|
||||
let layers = hcie_psd::import_psd(psd_path).unwrap();
|
||||
|
||||
for (i, layer) in layers.iter().enumerate() {
|
||||
@@ -634,6 +675,7 @@ fn test_save_layer_pixels() {
|
||||
#[test]
|
||||
fn test_pixel_layer_contributions() {
|
||||
let psd_path = std::path::Path::new("_images/_psd_stil_test/base_test_generated_2.psd");
|
||||
if fixture_is_missing(psd_path) { return; }
|
||||
let layers = hcie_psd::import_psd(psd_path).unwrap();
|
||||
|
||||
let test_pixels = [(300, 200), (400, 200), (200, 300), (500, 200), (500, 500)];
|
||||
@@ -696,6 +738,7 @@ fn test_load_test_2_psd() {
|
||||
fn test_per_layer_effects_vs_photoshop_export() {
|
||||
let psd_path = std::path::Path::new("_images/_psd_stil_test/base_test_generated_2.psd");
|
||||
let ref_dir = std::path::Path::new("_images/_psd_stil_test/base_test_generated_2");
|
||||
if fixture_is_missing(psd_path) { return; }
|
||||
let layers = hcie_psd::import_psd(psd_path).unwrap();
|
||||
let layer_names = [
|
||||
"Background Grid", "Teal Circle", "Pink Rectangle",
|
||||
@@ -706,7 +749,7 @@ fn test_per_layer_effects_vs_photoshop_export() {
|
||||
if i >= layers.len() { break; }
|
||||
let layer = &layers[i];
|
||||
let ref_path = ref_dir.join(format!("{}.png", name));
|
||||
if !ref_path.exists() {
|
||||
if fixture_is_missing(&ref_path) {
|
||||
println!("{}: export not found, skipping", name);
|
||||
continue;
|
||||
}
|
||||
|
||||
+264
-46
@@ -1,5 +1,6 @@
|
||||
use hcie_protocol::{Layer, LayerData, LayerType, VectorShape};
|
||||
use hcie_protocol::BlendMode;
|
||||
use rand;
|
||||
use std::io::{Cursor, Write};
|
||||
use std::path::Path;
|
||||
|
||||
@@ -106,13 +107,27 @@ pub fn save_kra(
|
||||
let base_filename = format!("layer{}", i + 1);
|
||||
|
||||
match layer.layer_type {
|
||||
LayerType::Raster | LayerType::Mask => {
|
||||
LayerType::Raster | LayerType::Mask | LayerType::Group => {
|
||||
let encoded = encode_layer_vers(layer);
|
||||
let layer_path = format!("{}/layers/{}", internal_name, base_filename);
|
||||
zip.start_file(&layer_path, zip::write::FileOptions::<'_, ()>::default())
|
||||
.map_err(|e| e.to_string())?;
|
||||
zip.write_all(&encoded)
|
||||
.map_err(|e| e.to_string())?;
|
||||
zip.write_all(&encoded).map_err(|e| e.to_string())?;
|
||||
|
||||
if let Some(ref mask) = layer.mask_pixels {
|
||||
let mask_filename = format!("{}_mask", base_filename);
|
||||
let mask_encoded = encode_mask_vers(layer, mask);
|
||||
let mask_path = format!("{}/layers/{}", internal_name, mask_filename);
|
||||
zip.start_file(&mask_path, zip::write::FileOptions::<'_, ()>::default())
|
||||
.map_err(|e| e.to_string())?;
|
||||
zip.write_all(&mask_encoded).map_err(|e| e.to_string())?;
|
||||
|
||||
let mask_dp_path = format!("{}/layers/{}.defaultpixel", internal_name, mask_filename);
|
||||
zip.start_file(&mask_dp_path, zip::write::FileOptions::<'_, ()>::default())
|
||||
.map_err(|e| e.to_string())?;
|
||||
zip.write_all(&[0u8; 1]) // 1 byte for Alpha
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
|
||||
let icc_path = format!("{}/layers/{}.icc", internal_name, base_filename);
|
||||
zip.start_file(&icc_path, zip::write::FileOptions::<'_, ()>::default())
|
||||
@@ -125,6 +140,18 @@ pub fn save_kra(
|
||||
.map_err(|e| e.to_string())?;
|
||||
zip.write_all(&[0u8; 4])
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
if layer.layer_type == LayerType::Group {
|
||||
let group_json = serde_json::json!({
|
||||
"collapsed": layer.collapsed,
|
||||
"parent_id": layer.parent_id,
|
||||
});
|
||||
let json_path = format!("{}/layers/{}.grouplayer", internal_name, base_filename);
|
||||
zip.start_file(&json_path, zip::write::FileOptions::<'_, ()>::default())
|
||||
.map_err(|e| e.to_string())?;
|
||||
zip.write_all(group_json.to_string().as_bytes())
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
}
|
||||
LayerType::Vector | LayerType::Text => {
|
||||
let layer_path = format!("{}/layers/{}", internal_name, base_filename);
|
||||
@@ -175,22 +202,7 @@ pub fn save_kra(
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
}
|
||||
LayerType::Group => {
|
||||
let group_json = serde_json::json!({
|
||||
"collapsed": layer.collapsed,
|
||||
"parent_id": layer.parent_id,
|
||||
});
|
||||
let group_path =
|
||||
format!("{}/layers/group/{}.json", internal_name, base_filename);
|
||||
zip.start_file(&group_path, zip::write::FileOptions::<'_, ()>::default())
|
||||
.map_err(|e| e.to_string())?;
|
||||
zip.write_all(
|
||||
serde_json::to_string_pretty(&group_json)
|
||||
.unwrap_or_default()
|
||||
.as_bytes(),
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -203,9 +215,16 @@ fn generate_maindoc(layers: &[Layer], w: u32, h: u32, name: &str) -> String {
|
||||
xml.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
|
||||
xml.push_str("<!DOCTYPE DOC PUBLIC '-//KDE//DTD krita 2.0//EN' 'http://www.calligra.org/DTD/krita-2.0.dtd'>\n");
|
||||
xml.push_str("<DOC xmlns=\"http://www.calligra.org/DTD/krita\" editor=\"Krita\" kritaVersion=\"6.0.1\" syntaxVersion=\"2.0\">\n");
|
||||
let escaped_doc_name = name
|
||||
.replace('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
.replace('"', """)
|
||||
.replace('\'', "'");
|
||||
|
||||
xml.push_str(&format!(
|
||||
" <IMAGE width=\"{}\" height=\"{}\" name=\"{}\" mime=\"application/x-kra\" colorspacename=\"RGBA\" channeldepth=\"U8\" profile=\"sRGB-elle-V2-g10.icc\" x-res=\"100\" y-res=\"100\">\n",
|
||||
w, h, name
|
||||
w, h, escaped_doc_name
|
||||
));
|
||||
xml.push_str(" <layers>\n");
|
||||
|
||||
@@ -219,13 +238,49 @@ fn generate_maindoc(layers: &[Layer], w: u32, h: u32, name: &str) -> String {
|
||||
let visible = if layer.visible { "1" } else { "0" };
|
||||
let opacity = (layer.opacity * 255.0) as u8;
|
||||
let filename = format!("layer{}", i + 1);
|
||||
let uuid = format!("{{{:032x}}}", layer.id);
|
||||
let uuid_high = rand::random::<u64>();
|
||||
let uuid_low = rand::random::<u64>();
|
||||
let uuid = format!(
|
||||
"{{{:08x}-{:04x}-{:04x}-{:04x}-{:012x}}}",
|
||||
(uuid_high >> 32) as u32,
|
||||
(uuid_high >> 16) as u16 & 0xFFFF,
|
||||
uuid_high as u16,
|
||||
(uuid_low >> 48) as u16,
|
||||
uuid_low & 0x0000_FFFF_FFFF_FFFF
|
||||
);
|
||||
let compositeop = blend_mode_to_krita_compositeop(layer.blend_mode);
|
||||
|
||||
let escaped_name = layer.name
|
||||
.replace('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
.replace('"', """)
|
||||
.replace('\'', "'");
|
||||
|
||||
xml.push_str(&format!(
|
||||
" <layer name=\"{}\" filename=\"{}\" nodetype=\"{}\" visible=\"{}\" opacity=\"{}\" uuid=\"{}\" compositeop=\"{}\" colorspacename=\"RGBA\" channeldepth=\"U8\" profile=\"sRGB-elle-V2-g10.icc\" x=\"0\" y=\"0\" />\n",
|
||||
layer.name, filename, node_type, visible, opacity, uuid, compositeop
|
||||
" <layer name=\"{}\" filename=\"{}\" nodetype=\"{}\" visible=\"{}\" opacity=\"{}\" uuid=\"{}\" compositeop=\"{}\" colorspacename=\"RGBA\" channeldepth=\"U8\" profile=\"sRGB-elle-V2-g10.icc\" x=\"0\" y=\"0\">\n",
|
||||
escaped_name, filename, node_type, visible, opacity, uuid, compositeop
|
||||
));
|
||||
if layer.mask_pixels.is_some() {
|
||||
xml.push_str(" <layers>\n");
|
||||
let mask_filename = format!("{}_mask", filename);
|
||||
let mask_high = rand::random::<u64>();
|
||||
let mask_low = rand::random::<u64>();
|
||||
let mask_uuid = format!(
|
||||
"{{{:08x}-{:04x}-{:04x}-{:04x}-{:012x}}}",
|
||||
(mask_high >> 32) as u32,
|
||||
(mask_high >> 16) as u16 & 0xFFFF,
|
||||
mask_high as u16,
|
||||
(mask_low >> 48) as u16,
|
||||
mask_low & 0x0000_FFFF_FFFF_FFFF
|
||||
);
|
||||
xml.push_str(&format!(
|
||||
" <layer name=\"Transparency Mask\" filename=\"{}\" nodetype=\"transparencymask\" visible=\"1\" opacity=\"255\" uuid=\"{}\" compositeop=\"normal\" colorspacename=\"GRAYA\" channeldepth=\"U8\" profile=\"Gray-D50-elle-V2-g10.icc\" x=\"0\" y=\"0\" />\n",
|
||||
mask_filename, mask_uuid
|
||||
));
|
||||
xml.push_str(" </layers>\n");
|
||||
}
|
||||
xml.push_str(" </layer>\n");
|
||||
}
|
||||
|
||||
xml.push_str(" </layers>\n");
|
||||
@@ -257,10 +312,11 @@ fn encode_layer_vers(layer: &Layer) -> Vec<u8> {
|
||||
let idx = (ly as usize * layer.width as usize + lx as usize) * 4;
|
||||
let tidx = y as usize * tw as usize + x as usize;
|
||||
|
||||
tile_raw[tidx] = layer.pixels[idx + 2];
|
||||
tile_raw[tidx + 1 * plane_size] = layer.pixels[idx + 1];
|
||||
tile_raw[tidx + 2 * plane_size] = layer.pixels[idx];
|
||||
tile_raw[tidx + 3 * plane_size] = layer.pixels[idx + 3];
|
||||
// Krita planar order: B, G, R, A
|
||||
tile_raw[tidx] = layer.pixels[idx + 2]; // B
|
||||
tile_raw[tidx + 1 * plane_size] = layer.pixels[idx + 1]; // G
|
||||
tile_raw[tidx + 2 * plane_size] = layer.pixels[idx]; // R
|
||||
tile_raw[tidx + 3 * plane_size] = layer.pixels[idx + 3]; // A
|
||||
|
||||
if layer.pixels[idx + 3] > 0 {
|
||||
has_content = true;
|
||||
@@ -270,34 +326,196 @@ fn encode_layer_vers(layer: &Layer) -> Vec<u8> {
|
||||
}
|
||||
|
||||
if has_content {
|
||||
let mut compressed = vec![1u8];
|
||||
let mut i = 0;
|
||||
while i < tile_raw.len() {
|
||||
let chunk = (tile_raw.len() - i).min(32);
|
||||
compressed.push((chunk - 1) as u8);
|
||||
compressed.extend_from_slice(&tile_raw[i..i + chunk]);
|
||||
i += chunk;
|
||||
}
|
||||
tiles.push((tx, ty, compressed));
|
||||
// Krita VERSION 2 tile format:
|
||||
// payload = [version_byte = 1][lzf_compressed_planar_data]
|
||||
// version_byte < 2 → no delta-decode on read (straightforward LZF).
|
||||
let compressed = lzf_compress(&tile_raw);
|
||||
let mut payload = Vec::with_capacity(1 + compressed.len());
|
||||
payload.push(1u8); // LZF version byte
|
||||
payload.extend_from_slice(&compressed);
|
||||
tiles.push((tx, ty, payload));
|
||||
}
|
||||
tx += tw;
|
||||
}
|
||||
ty += th;
|
||||
}
|
||||
|
||||
let mut buf = Vec::new();
|
||||
writeln!(buf, "VERSION 2").unwrap();
|
||||
writeln!(buf, "TILEWIDTH 64").unwrap();
|
||||
writeln!(buf, "TILEHEIGHT 64").unwrap();
|
||||
writeln!(buf, "PIXELSIZE 4").unwrap();
|
||||
writeln!(buf, "DATA {}", tiles.len()).unwrap();
|
||||
let mut output = Vec::new();
|
||||
writeln!(output, "VERSION 2").unwrap();
|
||||
writeln!(output, "TILEWIDTH 64").unwrap();
|
||||
writeln!(output, "TILEHEIGHT 64").unwrap();
|
||||
writeln!(output, "PIXELSIZE 4").unwrap();
|
||||
writeln!(output, "DATA {}", tiles.len()).unwrap();
|
||||
|
||||
for (tx, ty, data) in tiles {
|
||||
writeln!(buf, "{},{},LZF,{}", tx, ty, data.len()).unwrap();
|
||||
buf.extend_from_slice(&data);
|
||||
tiles.sort_by_key(|(x, y, _)| (*y, *x));
|
||||
for (tx, ty, payload) in tiles {
|
||||
let header = format!("{},{},LZF,{}\n", tx, ty, payload.len());
|
||||
output.extend_from_slice(header.as_bytes());
|
||||
output.extend_from_slice(&payload);
|
||||
}
|
||||
|
||||
buf
|
||||
output
|
||||
}
|
||||
|
||||
/// Purpose: LZF compress a byte slice for use as Krita tile payload.
|
||||
/// Logic: Simple LZF implementation — hash-table based back-reference search.
|
||||
/// Each control byte describes either a literal run (ctrl < 32) or a
|
||||
/// back-reference (ctrl >= 32). This matches the format expected by the
|
||||
/// lzf_decompress reader in lib.rs.
|
||||
fn lzf_compress(input: &[u8]) -> Vec<u8> {
|
||||
// Worst-case output: all literals → 1 control byte per 32 data bytes.
|
||||
let mut output = Vec::with_capacity(input.len() + input.len() / 20 + 32);
|
||||
let mut hash_table: Vec<usize> = vec![usize::MAX; 1 << 16];
|
||||
|
||||
let mut ip = 0usize;
|
||||
let mut lit_start = 0usize;
|
||||
|
||||
// Flush pending literal bytes [lit_start .. lit_end) into output.
|
||||
let flush_literals = |out: &mut Vec<u8>, data: &[u8], from: usize, to: usize| {
|
||||
let mut i = from;
|
||||
while i < to {
|
||||
let run = (to - i).min(32);
|
||||
out.push((run - 1) as u8); // ctrl: literal run length − 1
|
||||
out.extend_from_slice(&data[i..i + run]);
|
||||
i += run;
|
||||
}
|
||||
};
|
||||
|
||||
while ip + 2 < input.len() {
|
||||
// 16-bit hash of three bytes at ip.
|
||||
let h = ((input[ip] as u32)
|
||||
.wrapping_mul(2654435761)
|
||||
^ (input[ip + 1] as u32)
|
||||
.wrapping_mul(2246822519)
|
||||
^ (input[ip + 2] as u32))
|
||||
as usize
|
||||
& 0xFFFF;
|
||||
|
||||
let ref_pos = hash_table[h];
|
||||
hash_table[h] = ip;
|
||||
|
||||
// Check if back-reference is valid (within last 8191 bytes, matches >= 3 bytes).
|
||||
let max_len = (input.len() - ip).min(264);
|
||||
if ref_pos != usize::MAX
|
||||
&& ip > ref_pos
|
||||
&& (ip - ref_pos) <= 8191
|
||||
&& ref_pos + 2 < input.len()
|
||||
&& input[ref_pos] == input[ip]
|
||||
&& input[ref_pos + 1] == input[ip + 1]
|
||||
&& input[ref_pos + 2] == input[ip + 2]
|
||||
{
|
||||
// Count match length (capped at 264).
|
||||
let ofs = ip - ref_pos - 1;
|
||||
let mut mlen = 3usize;
|
||||
while mlen < max_len && input[ref_pos + mlen] == input[ip + mlen] {
|
||||
mlen += 1;
|
||||
}
|
||||
|
||||
// Flush literals accumulated before this back-reference.
|
||||
flush_literals(&mut output, input, lit_start, ip);
|
||||
lit_start = ip + mlen;
|
||||
ip += mlen;
|
||||
|
||||
// Encode back-reference.
|
||||
let len_code = mlen - 2; // stored as (len − 2)
|
||||
if len_code < 7 {
|
||||
output.push(((len_code << 5) | (ofs >> 8)) as u8);
|
||||
} else {
|
||||
output.push(((7 << 5) | (ofs >> 8)) as u8);
|
||||
output.push((len_code - 7) as u8);
|
||||
}
|
||||
output.push((ofs & 0xFF) as u8);
|
||||
} else {
|
||||
ip += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Flush any remaining literal bytes.
|
||||
flush_literals(&mut output, input, lit_start, input.len());
|
||||
output
|
||||
}
|
||||
|
||||
fn encode_mask_vers(layer: &Layer, mask: &[u8]) -> Vec<u8> {
|
||||
let tw: i32 = 64;
|
||||
let th: i32 = 64;
|
||||
let plane_size = (tw * th) as usize;
|
||||
let w = layer.width as i32;
|
||||
let h = layer.height as i32;
|
||||
|
||||
let mut tiles: Vec<(i32, i32, Vec<u8>)> = Vec::new();
|
||||
let mut ty = 0;
|
||||
while ty < h {
|
||||
let mut tx = 0;
|
||||
while tx < w {
|
||||
let mut tile_raw = vec![0u8; plane_size];
|
||||
let mut has_content = false;
|
||||
|
||||
for y in 0..th {
|
||||
let ly = ty + y;
|
||||
if ly >= h {
|
||||
continue;
|
||||
}
|
||||
for x in 0..tw {
|
||||
let lx = tx + x;
|
||||
if lx >= w {
|
||||
continue;
|
||||
}
|
||||
let tidx = (y as usize) * (tw as usize) + (x as usize);
|
||||
let mut v = layer.mask_default_color;
|
||||
if let Some(mb) = layer.mask_bounds {
|
||||
let m_top = mb[0] as i32;
|
||||
let m_left = mb[1] as i32;
|
||||
let m_bottom = mb[2] as i32;
|
||||
let m_right = mb[3] as i32;
|
||||
if ly >= m_top && ly < m_bottom && lx >= m_left && lx < m_right {
|
||||
let mask_w = m_right - m_left;
|
||||
let mask_idx = ((ly - m_top) * mask_w + (lx - m_left)) as usize;
|
||||
if mask_idx < mask.len() {
|
||||
v = mask[mask_idx];
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let idx = ly as usize * layer.width as usize + lx as usize;
|
||||
if idx < mask.len() {
|
||||
v = mask[idx];
|
||||
}
|
||||
}
|
||||
|
||||
tile_raw[tidx] = v;
|
||||
if v < 255 {
|
||||
has_content = true; // Any masking means content
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if has_content {
|
||||
// Same LZF format as raster tiles but PIXELSIZE=1.
|
||||
let compressed = lzf_compress(&tile_raw);
|
||||
let mut payload = Vec::with_capacity(1 + compressed.len());
|
||||
payload.push(1u8); // version byte (no delta-encode)
|
||||
payload.extend_from_slice(&compressed);
|
||||
tiles.push((tx, ty, payload));
|
||||
}
|
||||
tx += tw;
|
||||
}
|
||||
ty += th;
|
||||
}
|
||||
|
||||
let mut output = Vec::new();
|
||||
output.extend_from_slice(b"VERSION 2\n");
|
||||
output.extend_from_slice(b"TILEWIDTH 64\n");
|
||||
output.extend_from_slice(b"TILEHEIGHT 64\n");
|
||||
output.extend_from_slice(b"PIXELSIZE 1\n");
|
||||
output.extend_from_slice(format!("DATA {}\n", tiles.len()).as_bytes());
|
||||
|
||||
tiles.sort_by_key(|(x, y, _)| (*y, *x));
|
||||
for (tx, ty, payload) in tiles {
|
||||
let header = format!("{},{},LZF,{}\n", tx, ty, payload.len());
|
||||
output.extend_from_slice(header.as_bytes());
|
||||
output.extend_from_slice(&payload);
|
||||
}
|
||||
|
||||
output
|
||||
}
|
||||
|
||||
fn shapes_to_svg(shapes: &[VectorShape], w: u32, h: u32) -> String {
|
||||
|
||||
+120
-20
@@ -40,6 +40,7 @@ pub fn import_kra(path: &Path) -> Result<Vec<Layer>, String> {
|
||||
}
|
||||
|
||||
/// Purpose: Intermediate metadata structure representing Krita layer elements.
|
||||
#[derive(Debug, Clone)]
|
||||
struct KritaLayerInfo {
|
||||
name: String,
|
||||
filename: String,
|
||||
@@ -50,6 +51,7 @@ struct KritaLayerInfo {
|
||||
nodetype: String,
|
||||
compositeop: String,
|
||||
layerstyle_uuid: String,
|
||||
parent_filename: Option<String>,
|
||||
}
|
||||
|
||||
|
||||
@@ -111,7 +113,8 @@ fn parse_vers_tiles(
|
||||
return Err("DATA marker not found".into());
|
||||
}
|
||||
|
||||
let mut pixels = vec![0u8; (doc_w * doc_h * 4) as usize];
|
||||
let bpp = if pixel_size == 1 { 1 } else { 4 };
|
||||
let mut pixels = vec![0u8; (doc_w * doc_h * bpp) as usize];
|
||||
let plane_size = tile_w * tile_h;
|
||||
let mut offset = data_offset;
|
||||
let mut tiles_decoded = 0usize;
|
||||
@@ -173,7 +176,7 @@ fn parse_vers_tiles(
|
||||
if target_x >= 0 && target_x < doc_w as i32
|
||||
&& target_y >= 0 && target_y < doc_h as i32
|
||||
{
|
||||
let target_idx = (target_y as usize * doc_w as usize + target_x as usize) * 4;
|
||||
let target_idx = (target_y as usize * doc_w as usize + target_x as usize) * bpp as usize;
|
||||
let src_idx = y * tile_w + x;
|
||||
|
||||
if pixel_size == 4 {
|
||||
@@ -186,6 +189,8 @@ fn parse_vers_tiles(
|
||||
pixels[target_idx + 1] = tile_data[src_idx + 3 * plane_size];
|
||||
pixels[target_idx + 2] = tile_data[src_idx + 1 * plane_size];
|
||||
pixels[target_idx + 3] = tile_data[src_idx + 7 * plane_size];
|
||||
} else if pixel_size == 1 {
|
||||
pixels[target_idx] = tile_data[src_idx];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -196,8 +201,18 @@ fn parse_vers_tiles(
|
||||
|
||||
if tiles_decoded == 0 {
|
||||
if let Some(dp) = default_pixel {
|
||||
for chunk in pixels.chunks_exact_mut(4) {
|
||||
chunk.copy_from_slice(&dp);
|
||||
if pixel_size == 1 {
|
||||
let v = dp.get(0).copied().unwrap_or(0);
|
||||
for p in pixels.iter_mut() {
|
||||
*p = v;
|
||||
}
|
||||
} else {
|
||||
for chunk in pixels.chunks_exact_mut(4) {
|
||||
chunk[0] = dp[2];
|
||||
chunk[1] = dp[1];
|
||||
chunk[2] = dp[0];
|
||||
chunk[3] = dp[3];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -612,6 +627,12 @@ fn try_import_krita_maindoc(archive: &mut zip::ZipArchive<std::fs::File>) -> Res
|
||||
f.read_to_string(&mut xml_content).map_err(|e| e.to_string())?;
|
||||
found = true;
|
||||
}
|
||||
if !found {
|
||||
if let Ok(mut f) = archive.by_name("layers.xml") {
|
||||
f.read_to_string(&mut xml_content).map_err(|e| e.to_string())?;
|
||||
found = true;
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
@@ -624,21 +645,35 @@ fn try_import_krita_maindoc(archive: &mut zip::ZipArchive<std::fs::File>) -> Res
|
||||
let mut height = 600u32;
|
||||
let mut layers_info = Vec::new();
|
||||
let mut buf = Vec::new();
|
||||
let mut layer_stack: Vec<String> = Vec::new();
|
||||
|
||||
loop {
|
||||
match reader.read_event_into(&mut buf) {
|
||||
Ok(Event::Start(e)) | Ok(Event::Empty(e)) => {
|
||||
let event = reader.read_event_into(&mut buf);
|
||||
match event {
|
||||
Ok(Event::Start(ref e)) | Ok(Event::Empty(ref e)) => {
|
||||
let is_empty = matches!(event, Ok(Event::Empty(_)));
|
||||
let name = e.name();
|
||||
let tag_name = std::str::from_utf8(name.as_ref()).unwrap_or("").to_lowercase();
|
||||
if tag_name == "image" {
|
||||
let mut colorspace = String::new();
|
||||
let mut depth = String::new();
|
||||
for attr in e.attributes() {
|
||||
let a = attr.map_err(|e| format!("XML attr error: {}", e))?;
|
||||
let val = std::str::from_utf8(&a.value).unwrap_or("");
|
||||
match a.key.as_ref() {
|
||||
b"width" => width = std::str::from_utf8(&a.value).unwrap_or("800").parse().unwrap_or(800),
|
||||
b"height" => height = std::str::from_utf8(&a.value).unwrap_or("600").parse().unwrap_or(600),
|
||||
b"width" => width = val.parse().unwrap_or(800),
|
||||
b"height" => height = val.parse().unwrap_or(600),
|
||||
b"colorspacename" => colorspace = val.to_string(),
|
||||
b"channeldepth" => depth = val.to_string(),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if !colorspace.is_empty() && colorspace != "RGBA" && colorspace != "RGB" {
|
||||
return Err(format!("Unsupported KRA color space: '{}' (only RGBA/RGB is supported)", colorspace));
|
||||
}
|
||||
if !depth.is_empty() && depth != "U8" {
|
||||
return Err(format!("Unsupported KRA channel depth: '{}' (only U8 is supported)", depth));
|
||||
}
|
||||
} else if tag_name == "layer"
|
||||
|| tag_name == "paintlayer"
|
||||
|| tag_name == "vectorlayer"
|
||||
@@ -681,7 +716,7 @@ fn try_import_krita_maindoc(archive: &mut zip::ZipArchive<std::fs::File>) -> Res
|
||||
if has_filename && !filename.is_empty() {
|
||||
layers_info.push(KritaLayerInfo {
|
||||
name,
|
||||
filename,
|
||||
filename: filename.clone(),
|
||||
visible,
|
||||
opacity,
|
||||
x: lx,
|
||||
@@ -689,10 +724,29 @@ fn try_import_krita_maindoc(archive: &mut zip::ZipArchive<std::fs::File>) -> Res
|
||||
nodetype,
|
||||
compositeop,
|
||||
layerstyle_uuid,
|
||||
parent_filename: layer_stack.last().cloned(),
|
||||
});
|
||||
if !is_empty {
|
||||
layer_stack.push(filename);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Event::End(ref e)) => {
|
||||
let name = e.name();
|
||||
let tag_name = std::str::from_utf8(name.as_ref()).unwrap_or("").to_lowercase();
|
||||
if tag_name == "layer"
|
||||
|| tag_name == "paintlayer"
|
||||
|| tag_name == "vectorlayer"
|
||||
|| tag_name == "filllayer"
|
||||
|| tag_name == "shapelayer"
|
||||
|| tag_name == "adjustmentlayer"
|
||||
|| tag_name == "filterlayer"
|
||||
|| tag_name == "grouplayer"
|
||||
{
|
||||
layer_stack.pop();
|
||||
}
|
||||
}
|
||||
Ok(Event::Eof) => break,
|
||||
Err(e) => return Err(format!("XML parse error: {}", e)),
|
||||
_ => {}
|
||||
@@ -701,7 +755,7 @@ fn try_import_krita_maindoc(archive: &mut zip::ZipArchive<std::fs::File>) -> Res
|
||||
}
|
||||
|
||||
let all_files: Vec<String> = archive.file_names().map(|n| n.to_string()).collect();
|
||||
let mut layers = Vec::new();
|
||||
let mut intermediate_layers: Vec<(String, Layer)> = Vec::new();
|
||||
|
||||
let asl_styles: std::collections::HashMap<String, Vec<hcie_protocol::effects::LayerEffect>> = {
|
||||
let mut styles_map = std::collections::HashMap::new();
|
||||
@@ -724,7 +778,7 @@ fn try_import_krita_maindoc(archive: &mut zip::ZipArchive<std::fs::File>) -> Res
|
||||
styles_map
|
||||
};
|
||||
|
||||
for info in layers_info.into_iter().rev() {
|
||||
for info in layers_info.clone().into_iter().rev() {
|
||||
if info.nodetype == "adjustmentlayer" || info.nodetype == "filterlayer" {
|
||||
let pixels = vec![0u8; (width * height * 4) as usize];
|
||||
let mut layer = Layer::from_rgba(&info.name, width, height, pixels);
|
||||
@@ -739,14 +793,21 @@ fn try_import_krita_maindoc(archive: &mut zip::ZipArchive<std::fs::File>) -> Res
|
||||
layer.styles = effects.iter().map(kra_effect_to_layer_style).collect();
|
||||
}
|
||||
}
|
||||
layers.push(layer);
|
||||
intermediate_layers.push((info.filename.clone(), layer));
|
||||
continue;
|
||||
}
|
||||
|
||||
if info.nodetype == "transparencymask" {
|
||||
continue;
|
||||
}
|
||||
|
||||
let layer_path = find_krita_layer_file(&all_files, &info.filename, &info.nodetype);
|
||||
let zip_path = match layer_path {
|
||||
Some(ref path) => path,
|
||||
None => continue,
|
||||
None => {
|
||||
log::warn!("KRA import: layer file not found for '{}' (filename={}, type={})", info.name, info.filename, info.nodetype);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let mut buf = Vec::new();
|
||||
@@ -764,13 +825,19 @@ fn try_import_krita_maindoc(archive: &mut zip::ZipArchive<std::fs::File>) -> Res
|
||||
let pixels = if is_vector {
|
||||
match rasterize_svg_to_rgba(&buf, width, height) {
|
||||
Ok(p) => p,
|
||||
Err(_) => vec![0u8; (width * height * 4) as usize],
|
||||
Err(e) => {
|
||||
log::warn!("KRA import: SVG rasterization failed for layer '{}': {}", info.name, e);
|
||||
vec![0u8; (width * height * 4) as usize]
|
||||
}
|
||||
}
|
||||
} else if buf.starts_with(b"VERS") {
|
||||
let default_pixel = read_defaultpixel(archive, &info.filename);
|
||||
match parse_vers_tiles(&buf, width, height, info.x, info.y, default_pixel) {
|
||||
Ok(p) => p,
|
||||
Err(_) => vec![0u8; (width * height * 4) as usize],
|
||||
Err(e) => {
|
||||
log::warn!("KRA import: VERS tile parse failed for layer '{}': {}", info.name, e);
|
||||
vec![0u8; (width * height * 4) as usize]
|
||||
}
|
||||
}
|
||||
} else {
|
||||
match image::load_from_memory(&buf) {
|
||||
@@ -793,7 +860,10 @@ fn try_import_krita_maindoc(archive: &mut zip::ZipArchive<std::fs::File>) -> Res
|
||||
}
|
||||
pixels
|
||||
}
|
||||
Err(_) => vec![0u8; (width * height * 4) as usize],
|
||||
Err(e) => {
|
||||
log::warn!("KRA import: image decode failed for layer '{}': {}", info.name, e);
|
||||
vec![0u8; (width * height * 4) as usize]
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -827,19 +897,49 @@ fn try_import_krita_maindoc(archive: &mut zip::ZipArchive<std::fs::File>) -> Res
|
||||
layer.adjustment_raw = Some((*b"svg ", buf.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
if !info.layerstyle_uuid.is_empty() {
|
||||
if let Some(effects) = asl_styles.get(&info.layerstyle_uuid) {
|
||||
layer.effects = effects.clone();
|
||||
layer.styles = effects.iter().map(kra_effect_to_layer_style).collect();
|
||||
}
|
||||
}
|
||||
|
||||
layers.push(layer);
|
||||
intermediate_layers.push((info.filename.clone(), layer));
|
||||
}
|
||||
|
||||
// Pass 2: Transparency Masks
|
||||
let layers_info_clone = layers_info.clone();
|
||||
for info in layers_info_clone.into_iter().rev() {
|
||||
if info.nodetype == "transparencymask" {
|
||||
if let Some(parent_filename) = info.parent_filename {
|
||||
let layer_path = find_krita_layer_file(&all_files, &info.filename, &info.nodetype);
|
||||
if let Some(zip_path) = layer_path {
|
||||
let mut buf = Vec::new();
|
||||
{
|
||||
let mut file = match archive.by_name(&zip_path) {
|
||||
Ok(f) => f,
|
||||
Err(_) => continue,
|
||||
};
|
||||
let _ = file.read_to_end(&mut buf);
|
||||
}
|
||||
if !buf.is_empty() {
|
||||
let doc_w = width;
|
||||
let doc_h = height;
|
||||
let default_pixel = read_defaultpixel(archive, &info.filename);
|
||||
if let Ok(mask_pixels) = parse_vers_tiles(&buf, doc_w, doc_h, info.x, info.y, default_pixel) {
|
||||
if let Some((_, parent_layer)) = intermediate_layers.iter_mut().find(|(fname, _)| *fname == parent_filename) {
|
||||
parent_layer.mask_pixels = Some(mask_pixels);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let layers = intermediate_layers.into_iter().map(|(_, l)| l).collect::<Vec<_>>();
|
||||
|
||||
if layers.is_empty() {
|
||||
Err("No valid layers found in maindoc.xml".to_string())
|
||||
return Err("No valid layers found in maindoc.xml".to_string());
|
||||
} else {
|
||||
Ok(layers)
|
||||
}
|
||||
|
||||
+67
-2
@@ -27,6 +27,14 @@ struct HcieLayerMeta {
|
||||
effects: Vec<hcie_protocol::effects::LayerEffect>,
|
||||
#[serde(default = "default_fill_opacity")]
|
||||
fill_opacity: f32,
|
||||
#[serde(default)]
|
||||
mask_bounds: Option<[i32; 4]>,
|
||||
#[serde(default)]
|
||||
mask_default_color: u8,
|
||||
#[serde(default)]
|
||||
mask_offset: Option<u64>,
|
||||
#[serde(default)]
|
||||
mask_len: Option<u64>,
|
||||
}
|
||||
|
||||
fn default_fill_opacity() -> f32 { 1.0 }
|
||||
@@ -44,10 +52,22 @@ pub fn save_native(layers: &[Layer], path: &Path) -> Result<(), String> {
|
||||
|
||||
let mut file = std::fs::File::create(path).map_err(|e| e.to_string())?;
|
||||
|
||||
let mut pixel_offset = 0u64;
|
||||
let mut binary_offset = 0u64;
|
||||
let mut layer_metas = Vec::with_capacity(layers.len());
|
||||
for layer in layers {
|
||||
let pixel_len = layer.pixels.len() as u64;
|
||||
let pixel_offset = binary_offset;
|
||||
binary_offset += pixel_len;
|
||||
|
||||
let (mask_offset, mask_len) = if let Some(ref mask) = layer.mask_pixels {
|
||||
let m_off = binary_offset;
|
||||
let m_len = mask.len() as u64;
|
||||
binary_offset += m_len;
|
||||
(Some(m_off), Some(m_len))
|
||||
} else {
|
||||
(None, None)
|
||||
};
|
||||
|
||||
layer_metas.push(HcieLayerMeta {
|
||||
id: layer.id,
|
||||
name: layer.name.clone(),
|
||||
@@ -67,8 +87,11 @@ pub fn save_native(layers: &[Layer], path: &Path) -> Result<(), String> {
|
||||
pixel_len,
|
||||
effects: layer.effects.clone(),
|
||||
fill_opacity: layer.fill_opacity,
|
||||
mask_bounds: layer.mask_bounds,
|
||||
mask_default_color: layer.mask_default_color,
|
||||
mask_offset,
|
||||
mask_len,
|
||||
});
|
||||
pixel_offset += pixel_len;
|
||||
}
|
||||
|
||||
let doc_meta = HcieDocumentMeta {
|
||||
@@ -88,6 +111,9 @@ pub fn save_native(layers: &[Layer], path: &Path) -> Result<(), String> {
|
||||
|
||||
for layer in layers {
|
||||
file.write_all(&layer.pixels).map_err(|e| e.to_string())?;
|
||||
if let Some(ref mask) = layer.mask_pixels {
|
||||
file.write_all(mask).map_err(|e| e.to_string())?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -144,10 +170,49 @@ pub fn load_native(path: &Path) -> Result<Vec<Layer>, String> {
|
||||
layer.id = meta.id;
|
||||
layer.effects = meta.effects;
|
||||
layer.fill_opacity = meta.fill_opacity;
|
||||
|
||||
if let (Some(m_off), Some(m_len)) = (meta.mask_offset, meta.mask_len) {
|
||||
let m_off_sz = m_off as usize;
|
||||
let m_len_sz = m_len as usize;
|
||||
if m_off_sz + m_len_sz <= pixel_data.len() {
|
||||
layer.mask_pixels = Some(pixel_data[m_off_sz..m_off_sz + m_len_sz].to_vec());
|
||||
layer.mask_bounds = meta.mask_bounds;
|
||||
layer.mask_default_color = meta.mask_default_color;
|
||||
}
|
||||
}
|
||||
|
||||
layers.push(layer);
|
||||
}
|
||||
|
||||
Ok(layers)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_save_load_native_layer_mask() {
|
||||
let mut layer = Layer::new_transparent("Test Layer", 10, 10);
|
||||
let mask = vec![128u8; 100];
|
||||
layer.mask_pixels = Some(mask.clone());
|
||||
layer.mask_bounds = Some([0, 0, 10, 10]);
|
||||
layer.mask_default_color = 255;
|
||||
|
||||
let temp_dir = std::env::temp_dir();
|
||||
let path = temp_dir.join("test_mask_native.hcie");
|
||||
|
||||
save_native(&[layer], &path).expect("Failed to save native document");
|
||||
|
||||
let loaded = load_native(&path).expect("Failed to load native document");
|
||||
assert_eq!(loaded.len(), 1);
|
||||
let loaded_layer = &loaded[0];
|
||||
assert_eq!(loaded_layer.mask_pixels, Some(mask));
|
||||
assert_eq!(loaded_layer.mask_bounds, Some([0, 0, 10, 10]));
|
||||
assert_eq!(loaded_layer.mask_default_color, 255);
|
||||
|
||||
let _ = std::fs::remove_file(path);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -615,7 +615,7 @@ pub fn parse_psd_sequential_full(bytes: &[u8]) -> Result<ParsedPsdData, String>
|
||||
let mut mask_default_color = 0;
|
||||
|
||||
for channel in &layer.channels {
|
||||
if channel.id == -2 {
|
||||
if (channel.id == -2 || channel.id == -3) && decompressed_mask.is_none() {
|
||||
if let Some(mask) = &layer.mask_info {
|
||||
let w = (mask.right - mask.left) as u32;
|
||||
let h = (mask.bottom - mask.top) as u32;
|
||||
@@ -892,3 +892,19 @@ pub fn import_psd(path: &Path) -> Result<Vec<hcie_protocol::Layer>, String> {
|
||||
|
||||
Ok(flat_layers)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_example3_mask_import() {
|
||||
let path = std::path::Path::new("/mnt/extra/00_PROJECTS/hcie-rust-v3.05/_images/_test_images/example3/Example3-mini.psd");
|
||||
if path.exists() {
|
||||
let layers = import_psd(path).expect("import_psd failed");
|
||||
for l in &layers {
|
||||
println!("TEST_LAYER: '{}' has_mask={} bounds={:?}", l.name, l.mask_pixels.is_some(), l.mask_bounds);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,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.
+40
@@ -2,7 +2,47 @@ 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: Hata almamak için hcie-engine-api/Cargo.toml'da [lib] altına bench = false eklendi)
|
||||
cargo bench -p hcie-engine-api -- --save-baseline gold_standard
|
||||
|
||||
# Kaydedilen 'gold_standard' baseline'ı ile karşılaştırma (regresyon testi) yapmak için:
|
||||
cargo bench -p hcie-engine-api -- --baseline gold_standard
|
||||
|
||||
criterion.md
|
||||
|
||||
|
||||
##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.
Executable
BIN
Binary file not shown.
Executable
BIN
Binary file not shown.
Executable
BIN
Binary file not shown.
@@ -0,0 +1,17 @@
|
||||
Build:1022
|
||||
Egui ile Iced arasında hız farkı var. Egui hızlı . Ama o kadar büyük değil. Zaman zaman bu gerileme ortaya çıkıyor. O yüzden buraya karşılaştırma için dosyaları bırakıyorum.
|
||||
|
||||
commit : f2cca7e6a19daa9f470680260bfdf24746f214ee
|
||||
DÜZELTME
|
||||
feat: implement file opening logic in a new tab and streamline document loading process
|
||||
|
||||
commit : a2264b130d47fbbd565226040bc28fd5b9626177
|
||||
4K MULTI LAYEPERFORMANCE OK : SOL 5.6 HIGH feat(perf): add real-time canvas performance measurements
|
||||
|
||||
- Introduced a new performance module for the iced canvas to measure input queueing, engine drawing, CPU compositing/staging, GPU upload, and renderer preparation costs.
|
||||
- Implemented a performance probe in the egui reference renderer for comparative diagnostics.
|
||||
- Added methods to record durations, byte transfers, and input timestamps, enabling detailed performance analysis.
|
||||
- Enhanced the CanvasShaderPipeline with a new method to upload full textures without rebuilding GPU resources.
|
||||
- Updated the main application to conditionally log performance diagnostics based on an environment variable.
|
||||
- Created a performance plan document outlining steps to measure and optimize drawing performance in the iced application.
|
||||
|
||||
Reference in New Issue
Block a user