10 Commits

Author SHA1 Message Date
Your Name a056419c24 kra_saver_fixed
mandatory-regression-gate / deterministic-tests (push) Has been cancelled
mandatory-regression-gate / protected-performance-path (push) Has been cancelled
2026-07-24 16:23:45 +03:00
Your Name 505562424b feat: implement layer mask editing support with scratch buffer and coordinate normalization
mandatory-regression-gate / deterministic-tests (push) Has been cancelled
mandatory-regression-gate / protected-performance-path (push) Has been cancelled
2026-07-24 06:01:30 +03:00
Your Name 7496f5e208 perf: eliminate crosshair input lag by using immediate cursor position and forced view redraws
mandatory-regression-gate / deterministic-tests (push) Has been cancelled
mandatory-regression-gate / protected-performance-path (push) Has been cancelled
2026-07-24 02:46:30 +03:00
Your Name fba10b753c fix: optimize performance by wrapping trace logs, adding captured drag event subscriptions, and adding regression tests for adjustment compositing. 2026-07-24 02:04:35 +03:00
Your Name f2cca7e6a1 feat: implement file opening logic in a new tab and streamline document loading process
mandatory-regression-gate / deterministic-tests (push) Has been cancelled
mandatory-regression-gate / protected-performance-path (push) Has been cancelled
2026-07-24 00:51:09 +03:00
Your Name a2264b130d 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.
2026-07-23 23:57:03 +03:00
Your Name e9dad7ef0c refactor: clamp canvas pointer movement, prioritize local pan offset, and remove debug logs
mandatory-regression-gate / deterministic-tests (push) Has been cancelled
mandatory-regression-gate / protected-performance-path (push) Has been cancelled
2026-07-23 19:44:13 +03:00
Your Name 3bac629045 feat: add unlock icon and update layer lock UI, and fix document loading index management and cache synchronization
mandatory-regression-gate / deterministic-tests (push) Has been cancelled
mandatory-regression-gate / protected-performance-path (push) Has been cancelled
2026-07-23 18:16:49 +03:00
Your Name 135ab8a628 feat: add local pan offset to eliminate one-frame panning latency in shader canvas
mandatory-regression-gate / deterministic-tests (push) Has been cancelled
mandatory-regression-gate / protected-performance-path (push) Has been cancelled
2026-07-23 18:00:58 +03:00
Your Name 513efcdcff Refactor dependencies and improve test runner diagnostics
mandatory-regression-gate / deterministic-tests (push) Has been cancelled
mandatory-regression-gate / protected-performance-path (push) Has been cancelled
- Updated `Cargo.toml` files across multiple crates to replace local `egui` and `eframe` dependencies with workspace references, ensuring consistent platform support.
- Enhanced `run_all_tests.bat` and `run_all_tests.sh` scripts to capture and report names of failed and skipped crates, improving test diagnostics.
- Fixed duplicate keys in `hcie-text/Cargo.toml` and ensured proper feature activation for Linux builds in affected crates.
- Added a new repair plan document outlining steps to address failing crates and improve overall test execution reliability.
2026-07-23 16:34:05 +03:00
53 changed files with 3349 additions and 625 deletions
+9
View File
@@ -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 Cargos `--` 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 roots `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 crates 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.
+163
View File
@@ -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 324329, 409415
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 308316, 394403
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 166168).
### 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 647698
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 308316)
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 35 for `encode_mask_vers` (lines 394403)
### 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)
Generated
+2
View File
@@ -2897,6 +2897,8 @@ name = "hcie-text"
version = "0.1.0"
dependencies = [
"approx",
"eframe",
"egui",
"fontdue",
"hcie-protocol",
"proptest",
+1 -1
View File
@@ -102,7 +102,7 @@ approx = "0.5"
[profile.dev]
opt-level = 1
codegen-units = 1
codegen-units = 256
[profile.dev.package."*"]
opt-level = 3
+2 -2
View File
@@ -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"] }
+2 -2
View File
@@ -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
View File
@@ -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]);
}
+118
View File
@@ -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,
+2 -2
View File
@@ -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(
&region_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
}
+98
View File
@@ -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;
}
}
}
}
+7
View File
@@ -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,
+19 -15
View File
@@ -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;
+261 -65
View File
@@ -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;
+143 -24
View File
@@ -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;
+33
View File
@@ -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());
}
+2 -2
View File
@@ -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 }
+4
View File
@@ -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

+6
View File
@@ -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

+364 -172
View File
@@ -12,8 +12,10 @@
//! These sections are optimized for 4K performance. Modifying them
//! may cause rendering regressions or performance degradation.
mod document_open;
mod transform_dirty;
use self::document_open::load_path_into_engine;
use self::transform_dirty::preview_bounds;
use crate::ai_chat::{self, AiChatState, ChatMessage, ComfyUiState, SYSTEM_PROMPT};
use crate::dialogs;
@@ -515,6 +517,10 @@ pub struct IcedDocument {
/// Cached layer thumbnails keyed by layer ID — (thumb_w, thumb_h, RGBA pixels).
/// Regenerated only for dirty layers in `refresh_panel_caches()`.
pub layer_thumbnails: std::collections::HashMap<u64, (u32, u32, Vec<u8>)>,
/// Layer IDs whose thumbnails must be regenerated after a canvas-only refresh.
/// Dirty engine flags are cleared after compositing, so this set carries the
/// invalidation until panel work is safe to perform at stroke completion.
pub pending_thumbnail_layers: std::collections::HashSet<u64>,
/// Thumbnail generation generation counter; incremented when any thumbnail
/// is regenerated so the view can detect stale cache without locking.
pub thumb_gen: u64,
@@ -581,8 +587,6 @@ pub struct ToolState {
pub brush_color_variant: bool,
/// Magnitude of the per-dab color variation (0.0..1.0).
pub brush_variant_amount: f32,
/// Timestamp of the last composite refresh for throttling during strokes.
pub last_composite_refresh: std::time::Instant,
/// Timestamp of the last update() call for frame timing.
pub last_update_instant: std::time::Instant,
/// Imported brush presets from ABR/KPP files.
@@ -617,7 +621,6 @@ impl Default for ToolState {
brush_spacing: 1.0,
brush_color_variant: false,
brush_variant_amount: 0.0,
last_composite_refresh: std::time::Instant::now(),
last_update_instant: std::time::Instant::now(),
imported_brushes: Vec::new(),
active_brush_preset: None,
@@ -661,10 +664,12 @@ pub enum Message {
CanvasPointerPressed {
x: f32,
y: f32,
captured_at: std::time::Instant,
},
CanvasPointerMoved {
x: f32,
y: f32,
captured_at: std::time::Instant,
},
CanvasPointerReleased,
CanvasPointerRightClicked {
@@ -686,11 +691,18 @@ pub enum Message {
x: u32,
y: u32,
},
/// Lightweight no-op message emitted by the canvas overlay on every
/// `CursorMoved` event. Its sole purpose is to trigger a `view()`
/// rebuild so the crosshair overlay redraws at the latest cursor
/// position during idle (non-drawing) pointer movement.
CursorOverlayRedraw,
ModifiersChanged(iced::keyboard::Modifiers),
SpacePanChanged(bool),
// ── Engine operations ───────────────────────────────
CompositeRefresh,
/// Paces visible stroke updates independently from raw pointer message frequency.
CanvasFrameTick,
/// Polled after end_stroke to commit any pending history snapshots
/// that the background thread hadn't finished yet.
CompositeRefreshPending,
@@ -712,6 +724,11 @@ pub enum Message {
LayerSetFillOpacity(u64, f32),
LayerAddGroup,
LayerToggleCollapse(u64),
LayerAddMask(u64),
LayerRemoveMask(u64),
LayerToggleMask(u64),
LayerSetEditMask(bool),
LayerToggleEditMask,
// ── History ─────────────────────────────────────────
HistoryJumpTo(i32),
@@ -1762,6 +1779,7 @@ impl HcieIcedApp {
rotation_snap_angle: 0.0,
resize_snap_active: false,
layer_thumbnails: std::collections::HashMap::new(),
pending_thumbnail_layers: std::collections::HashSet::new(),
thumb_gen: 0,
};
@@ -2337,17 +2355,29 @@ impl HcieIcedApp {
}
}
/// Refresh the composite buffer using incremental dirty-region compositing.
/// Refresh the canvas using incremental dirty-region compositing.
///
/// Uses `render_composite_region()` which only re-composites dirty tiles.
/// Only the dirty region is copied from the engine's scratch buffer into
/// the local composite buffer, avoiding a full 33 MB copy for small strokes.
fn refresh_composite_if_needed(&mut self) {
fn refresh_canvas_if_needed(&mut self) {
let doc = &mut self.documents[self.active_doc];
if doc.engine.is_composite_dirty() {
let refresh_start = std::time::Instant::now();
for layer in &doc.cached_layers {
if doc.engine.is_layer_dirty(layer.id) {
doc.pending_thumbnail_layers.insert(layer.id);
}
}
let composite_start = std::time::Instant::now();
let (dirty_rect, ptr, len) = doc.engine.render_composite_region();
crate::canvas::perf::record_duration(
"render_composite_region",
composite_start.elapsed(),
);
if len > 0 && !ptr.is_null() {
let staging_start = std::time::Instant::now();
let mut is_full_copy = false;
// Resize composite_raw if canvas dimensions changed (e.g. after PSD import)
if doc.composite_raw.len() != len {
@@ -2359,6 +2389,7 @@ impl HcieIcedApp {
// If the canvas changed size, we MUST force a full upload because the old memory
// structure is no longer valid, and a partial copy would leave zeros in the new buffer.
if let Some(rect) = dirty_rect.filter(|_| !is_full_copy) {
crate::canvas::perf::record_dirty_region(rect);
let w = doc.engine.canvas_width() as usize;
let [x0, y0, x1, y1] = rect;
let x0 = x0 as usize;
@@ -2394,6 +2425,12 @@ impl HcieIcedApp {
doc.dirty_region.replace(Some(union_regions(current, rect)));
} else {
// Full update
crate::canvas::perf::record_dirty_region([
0,
0,
doc.engine.canvas_width(),
doc.engine.canvas_height(),
]);
unsafe {
std::ptr::copy_nonoverlapping(ptr, doc.composite_raw.as_mut_ptr(), len);
}
@@ -2403,25 +2440,37 @@ impl HcieIcedApp {
}
doc.render_generation = doc.render_generation.wrapping_add(1);
crate::canvas::perf::record_duration("cpu_staging", staging_start.elapsed());
}
doc.engine.clear_dirty_flags();
crate::canvas::perf::record_duration("canvas_refresh", refresh_start.elapsed());
}
sync_document_selection_if_needed(doc);
}
/// Refresh panel caches after canvas work has completed.
///
/// Layer pixel copies and history description allocation are intentionally
/// excluded from the active-stroke path and run only for completed document
/// operations or stroke release.
fn refresh_panel_caches(&mut self) {
let refresh_start = std::time::Instant::now();
let doc = &mut self.documents[self.active_doc];
// Always refresh cached panel data (cheap operation)
doc.cached_layers = doc.engine.layer_infos();
// Regenerate thumbnail cache — only for dirty or uncached layers
let max_dim = 48u32;
doc.layer_thumbnails
.retain(|id, _| doc.cached_layers.iter().any(|l| l.id == *id));
let mut thumbnails_changed = false;
for info in &doc.cached_layers {
if info.width == 0 || info.height == 0 {
continue;
}
let cached = doc.layer_thumbnails.contains_key(&info.id);
let dirty = doc.engine.is_layer_dirty(info.id);
let dirty = doc.pending_thumbnail_layers.contains(&info.id)
|| doc.engine.is_layer_dirty(info.id);
if cached && !dirty {
continue;
}
@@ -2436,15 +2485,80 @@ impl HcieIcedApp {
max_dim,
);
doc.layer_thumbnails.insert(info.id, (max_dim, th, thumb));
doc.pending_thumbnail_layers.remove(&info.id);
thumbnails_changed = true;
}
}
}
doc.thumb_gen += 1;
if thumbnails_changed {
doc.thumb_gen = doc.thumb_gen.wrapping_add(1);
}
let history_len = doc.engine.history_len();
doc.cached_history = (0..history_len)
.filter_map(|i| doc.engine.history_description(i).map(|d| (i, d)))
.collect();
doc.history_current = doc.engine.history_current();
crate::canvas::perf::record_duration("panel_cache_refresh", refresh_start.elapsed());
}
/// Refresh both the visible canvas and the document panel caches.
///
/// This preserves the existing behavior for non-interactive operations while
/// allowing active drawing to call `refresh_canvas_if_needed()` directly.
fn refresh_composite_if_needed(&mut self) {
self.refresh_canvas_if_needed();
self.refresh_panel_caches();
}
/// Opens a file in a newly allocated editor tab.
///
/// **Purpose:** Prevents file-open entry points from replacing the active
/// document engine. **Logic & Workflow:** Creates a disposable 1x1 document,
/// loads the selected path into its engine, activates and initializes it on
/// success, or removes it and restores the previous tab on failure.
/// **Arguments:** `path` is the file to load and `add_to_recent` controls
/// recent-file persistence. **Returns:** The new document index or an engine
/// error string. **Side Effects / Dependencies:** Reads the file, appends or
/// removes one document, updates GPU upload caches, and may persist recents.
fn open_path_in_new_tab(
&mut self,
path: std::path::PathBuf,
add_to_recent: bool,
) -> Result<usize, String> {
let previous_active = self.active_doc;
let previous_count = self.documents.len();
let _ = self.update(Message::NewDocument(1, 1));
let document_index = self.documents.len() - 1;
if let Err(error) = load_path_into_engine(&mut self.documents[document_index].engine, &path)
{
self.documents.remove(document_index);
self.active_doc = previous_active.min(self.documents.len().saturating_sub(1));
debug_assert_eq!(self.documents.len(), previous_count);
return Err(error);
}
self.show_welcome = false;
self.active_doc = document_index;
let document = &mut self.documents[document_index];
document.engine.pre_tile_all_layers();
document.selection_model_dirty = true;
document.name = path
.file_name()
.map(|name| name.to_string_lossy().to_string())
.unwrap_or_else(|| "Untitled".to_string());
document.source_path = Some(path.clone());
document.modified = false;
document.composite_raw = document.engine.get_composite_pixels();
document.composite_pixels.replace(&document.composite_raw);
document.render_generation = document.render_generation.wrapping_add(1);
document.full_upload.replace(true);
if add_to_recent {
self.add_recent_file(&path);
}
self.refresh_composite_if_needed();
Ok(document_index)
}
/// Apply brush parameters from tool state to the engine.
@@ -2752,7 +2866,7 @@ impl HcieIcedApp {
}
}
Message::CanvasPointerPressed { x, y } => {
Message::CanvasPointerPressed { x, y, captured_at } => {
// Coordinates are already in canvas-space from the canvas widget
let canvas_x = x;
let canvas_y = y;
@@ -2845,6 +2959,8 @@ impl HcieIcedApp {
match tool {
Tool::Pen | Tool::Brush | Tool::Eraser | Tool::Spray => {
crate::canvas::perf::begin_stroke();
crate::canvas::perf::record_render_input(captured_at);
// Apply brush parameters before starting stroke
self.apply_brush_params();
@@ -2864,8 +2980,7 @@ impl HcieIcedApp {
self.tool_state.is_drawing = true;
self.tool_state.last_stroke_pos = Some((canvas_x, canvas_y));
self.tool_state.brush_accumulated_dist = 0.0;
self.tool_state.last_composite_refresh = std::time::Instant::now();
self.refresh_composite_if_needed();
self.refresh_canvas_if_needed();
}
Tool::Eyedropper => {
let cx = canvas_x as u32;
@@ -3103,7 +3218,7 @@ impl HcieIcedApp {
}
}
Message::CanvasPointerMoved { x, y } => {
Message::CanvasPointerMoved { x, y, captured_at } => {
// Coordinates are already in canvas-space from the canvas widget
self.canvas_cursor_pos = Some((x as u32, y as u32));
@@ -3187,20 +3302,27 @@ impl HcieIcedApp {
.lock()
.map(|state| state.current_pressure())
.unwrap_or(1.0);
crate::canvas::perf::record_render_input(captured_at);
let engine_start = std::time::Instant::now();
self.documents[self.active_doc]
.engine
.draw_pen_segment(layer_id, last_x, last_y, x, y, pressure);
crate::canvas::perf::record_duration(
"engine_draw",
engine_start.elapsed(),
);
self.tool_state.last_stroke_pos = Some((x, y));
self.refresh_composite_if_needed();
}
return Task::none();
}
let spacing: f32 = match self.tool_state.active_tool {
Tool::Spray => 2.0_f32,
Tool::Spray => (self.tool_state.brush_size * 0.25).max(2.0),
Tool::Brush | Tool::Eraser => {
(self.tool_state.brush_size * 0.12).max(1.0)
}
_ => 1.0_f32,
}
.max(1.0);
};
if let Some((last_x, last_y)) = self.tool_state.last_stroke_pos {
let dx = x - last_x;
@@ -3216,25 +3338,29 @@ impl HcieIcedApp {
.unwrap_or(1.0);
if dist >= spacing {
crate::canvas::perf::record_render_input(captured_at);
let engine_start = std::time::Instant::now();
self.documents[self.active_doc]
.engine
.stroke_to(layer_id, x, y, pressure);
crate::canvas::perf::record_duration(
"engine_draw",
engine_start.elapsed(),
);
self.tool_state.last_stroke_pos = Some((x, y));
// Throttle composite refresh to ~60 FPS during strokes.
let now = std::time::Instant::now();
let elapsed =
now.duration_since(self.tool_state.last_composite_refresh);
if elapsed.as_secs_f32() >= 0.016 {
self.tool_state.last_composite_refresh = now;
self.refresh_composite_if_needed();
}
}
} else {
crate::canvas::perf::record_render_input(captured_at);
let engine_start = std::time::Instant::now();
self.documents[self.active_doc]
.engine
.stroke_to(layer_id, x, y, 1.0);
crate::canvas::perf::record_duration(
"engine_draw",
engine_start.elapsed(),
);
self.tool_state.last_stroke_pos = Some((x, y));
self.refresh_canvas_if_needed();
}
}
} // end else (non-selection tool)
@@ -3375,6 +3501,7 @@ impl HcieIcedApp {
self.documents[self.active_doc].modified = true;
self.refresh_composite_if_needed();
crate::canvas::perf::request_finish_stroke();
// If the background thread hasn't finished yet, schedule
// a delayed poll so the history entry is committed.
@@ -3433,9 +3560,15 @@ impl HcieIcedApp {
}
Message::CanvasCursorPos { x, y } => {
crate::canvas::perf::record_idle_pointer_message(false);
self.canvas_cursor_pos = Some((x, y));
}
// No-op — this message exists solely to trigger a view() rebuild
// so the crosshair overlay redraws at the latest cursor position.
// See Strategy 1 in the cursor lag fix.
Message::CursorOverlayRedraw => {}
Message::CanvasZoomSet(z) => {
let doc = &mut self.documents[self.active_doc];
doc.zoom = z.clamp(ZOOM_MIN, ZOOM_MAX);
@@ -3469,7 +3602,11 @@ impl HcieIcedApp {
// is resized (e.g. dock splitter dragged).
}
Message::CompositeRefresh | Message::Undo | Message::Redo => {
Message::CanvasFrameTick | Message::CompositeRefresh => {
self.refresh_canvas_if_needed();
}
Message::Undo | Message::Redo => {
match message {
Message::Undo => {
let doc = &mut self.documents[self.active_doc];
@@ -3495,7 +3632,7 @@ impl HcieIcedApp {
self.documents[self.active_doc].engine.redo();
self.documents[self.active_doc].selection_history.clear();
}
_ => {}
_ => unreachable!(),
}
self.refresh_composite_if_needed();
}
@@ -3509,7 +3646,6 @@ impl HcieIcedApp {
let committed = doc.engine.commit_pending_history();
if committed && doc.engine.has_pending_history() {
// Background thread still producing — schedule another poll.
self.refresh_composite_if_needed();
return Task::perform(
async {
tokio::time::sleep(std::time::Duration::from_millis(16)).await;
@@ -3517,13 +3653,8 @@ impl HcieIcedApp {
|_| Message::CompositeRefreshPending,
);
}
// All done — refresh composite and update cached history list.
let history_len = doc.engine.history_len();
doc.cached_history = (0..history_len)
.filter_map(|i| doc.engine.history_description(i).map(|d| (i, d)))
.collect();
doc.history_current = doc.engine.history_current();
self.refresh_composite_if_needed();
// Canvas pixels were already staged on release; only panel metadata changed.
self.refresh_panel_caches();
}
// ── Layer operations ──────────────────────────
@@ -3560,6 +3691,45 @@ impl HcieIcedApp {
return Task::perform(async {}, |_| Message::CompositeRefresh);
}
Message::LayerAddMask(id) => {
self.documents[self.active_doc].engine.add_layer_mask(id);
self.documents[self.active_doc].modified = true;
self.documents[self.active_doc].cached_layers =
self.documents[self.active_doc].engine.layer_infos();
self.refresh_composite_if_needed();
return Task::perform(async {}, |_| Message::CompositeRefresh);
}
Message::LayerRemoveMask(id) => {
self.documents[self.active_doc].engine.remove_layer_mask(id);
self.documents[self.active_doc].modified = true;
self.documents[self.active_doc].cached_layers =
self.documents[self.active_doc].engine.layer_infos();
self.refresh_composite_if_needed();
return Task::perform(async {}, |_| Message::CompositeRefresh);
}
Message::LayerToggleMask(id) => {
self.documents[self.active_doc].engine.toggle_layer_mask(id);
self.documents[self.active_doc].modified = true;
self.documents[self.active_doc].cached_layers =
self.documents[self.active_doc].engine.layer_infos();
self.refresh_composite_if_needed();
return Task::perform(async {}, |_| Message::CompositeRefresh);
}
Message::LayerSetEditMask(editing) => {
self.documents[self.active_doc].engine.set_editing_mask(editing);
self.documents[self.active_doc].cached_layers =
self.documents[self.active_doc].engine.layer_infos();
}
Message::LayerToggleEditMask => {
self.documents[self.active_doc].engine.toggle_editing_mask();
self.documents[self.active_doc].cached_layers =
self.documents[self.active_doc].engine.layer_infos();
}
Message::LayerDelete(id) => {
self.documents[self.active_doc].engine.delete_layer(id);
self.documents[self.active_doc].modified = true;
@@ -3591,6 +3761,8 @@ impl HcieIcedApp {
.engine
.set_layer_locked(id, locked);
self.documents[self.active_doc].modified = true;
self.documents[self.active_doc].cached_layers =
self.documents[self.active_doc].engine.layer_infos();
}
Message::LayerSetOpacity(id, opacity) => {
@@ -4590,6 +4762,7 @@ impl HcieIcedApp {
self.layer_style_dragging = true;
}
Message::LayerStyleDialogDragMove(x, y) | Message::DockPointerMoved(x, y) => {
crate::canvas::perf::record_idle_pointer_message(true);
self.last_cursor_pos = Some((x, y));
let pointer = iced::Point::new(x, y);
if self.dock.drag.is_none() {
@@ -5412,6 +5585,7 @@ impl HcieIcedApp {
rotation_snap_angle: 0.0,
resize_snap_active: false,
layer_thumbnails: std::collections::HashMap::new(),
pending_thumbnail_layers: std::collections::HashSet::new(),
thumb_gen: 0,
};
if self.show_welcome && self.documents.len() == 1 {
@@ -7830,38 +8004,16 @@ impl HcieIcedApp {
}
Message::FileDialogClosed(Some(path)) => {
let path_str = path.to_string_lossy().to_string();
let ext = path
.extension()
.and_then(|e| e.to_str())
.unwrap_or("png")
.to_lowercase();
match self.pending_file_operation.take() {
Some(PendingFileOperation::Open) => {
let engine = &mut self.documents[self.active_doc].engine;
let result = match ext.as_str() {
"psd" => engine.import_psd(&path_str),
"kra" => engine.import_kra(&path_str),
"hcie" => engine.load_native(&path_str),
_ => engine.open_image(&path_str),
};
match result {
Ok(()) => {
self.show_welcome = false;
self.documents[self.active_doc].engine.pre_tile_all_layers();
self.documents[self.active_doc].selection_model_dirty = true;
self.documents[self.active_doc].name = path
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| "Untitled".to_string());
self.documents[self.active_doc].source_path = Some(path.clone());
self.documents[self.active_doc].modified = false;
self.add_recent_file(&path);
self.documents[self.active_doc].full_upload.replace(true);
self.refresh_composite_if_needed();
match self.open_path_in_new_tab(path, true) {
Ok(_) => {
return Task::perform(async {}, |_| Message::CompositeRefresh);
}
Err(e) => log::error!("Failed to open: {}", e),
Err(e) => {
log::error!("Failed to open: {}", e);
}
}
}
Some(PendingFileOperation::SaveAs { close_after }) => {
@@ -8278,8 +8430,10 @@ impl HcieIcedApp {
rotation_snap_angle: 0.0,
resize_snap_active: false,
layer_thumbnails: std::collections::HashMap::new(),
pending_thumbnail_layers: std::collections::HashSet::new(),
thumb_gen: 0,
});
self.active_doc = self.documents.len() - 1;
}
Message::DocSwitch(idx) => {
@@ -8328,7 +8482,10 @@ impl HcieIcedApp {
}
Message::FileOpened(Ok(path)) => {
log::info!("File opened: {:?}", path);
match self.open_path_in_new_tab(path, true) {
Ok(_) => return Task::perform(async {}, |_| Message::CompositeRefresh),
Err(error) => log::error!("Failed to open file: {error}"),
}
}
Message::FileOpened(Err(e)) => {
@@ -8385,53 +8542,9 @@ impl HcieIcedApp {
self.active_menu = None;
let path = std::path::PathBuf::from(&path_str);
if path.exists() {
let ext = path
.extension()
.and_then(|e| e.to_str())
.unwrap_or("")
.to_lowercase();
let result = match ext.as_str() {
"psd" => self.documents[self.active_doc].engine.import_psd(&path_str),
"kra" => self.documents[self.active_doc].engine.import_kra(&path_str),
"hcie" => self.documents[self.active_doc]
.engine
.load_native(&path_str),
_ => self.documents[self.active_doc].engine.open_image(&path_str),
};
match result {
Ok(()) => {
self.documents[self.active_doc].engine.pre_tile_all_layers();
self.documents[self.active_doc].name = path
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| "Untitled".to_string());
self.documents[self.active_doc].source_path = Some(path.clone());
self.add_recent_file(&path);
let composite = self.documents[self.active_doc]
.engine
.get_composite_pixels();
self.documents[self.active_doc].composite_raw = composite;
self.documents[self.active_doc]
.composite_pixels
.replace(&self.documents[self.active_doc].composite_raw);
let selection = self.documents[self.active_doc]
.engine
.get_selection_mask()
.map(ToOwned::to_owned);
set_document_selection_mask(
&mut self.documents[self.active_doc],
selection,
);
self.documents[self.active_doc].full_upload.replace(true);
self.documents[self.active_doc].render_generation = self.documents
[self.active_doc]
.render_generation
.wrapping_add(1);
self.documents[self.active_doc].cached_layers =
self.documents[self.active_doc].engine.layer_infos();
return Task::perform(async {}, |_| Message::CompositeRefresh);
}
Err(e) => log::error!("Failed to open recent file: {}", e),
match self.open_path_in_new_tab(path, true) {
Ok(_) => return Task::perform(async {}, |_| Message::CompositeRefresh),
Err(error) => log::error!("Failed to open recent file: {error}"),
}
} else {
log::error!("Recent file no longer exists: {}", path_str);
@@ -8982,64 +9095,12 @@ impl HcieIcedApp {
}
Message::ViewerOpenFile(path) => {
// Open into a new editor document so browsing never overwrites a dirty canvas.
let _ = self.update(Message::NewDocument(1, 1));
let document_index = self.documents.len() - 1;
let path_str = path.to_string_lossy().to_string();
let ext = path
.extension()
.and_then(|e| e.to_str())
.unwrap_or("")
.to_lowercase();
let result = match ext.as_str() {
"psd" => self.documents[document_index].engine.import_psd(&path_str),
"kra" => self.documents[document_index].engine.import_kra(&path_str),
"hcie" => self.documents[document_index].engine.load_native(&path_str),
_ => self.documents[document_index].engine.open_image(&path_str),
};
match result {
Ok(()) => {
match self.open_path_in_new_tab(path.clone(), true) {
Ok(_) => {
self.viewer_active = false;
self.active_doc = document_index;
self.documents[document_index].engine.pre_tile_all_layers();
self.documents[document_index].name = path
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| "Untitled".to_string());
self.documents[document_index].source_path = Some(path.clone());
self.add_recent_file(&path);
let composite =
self.documents[document_index].engine.get_composite_pixels();
self.documents[document_index].composite_raw = composite;
self.documents[document_index]
.composite_pixels
.replace(&self.documents[document_index].composite_raw);
let selection = self.documents[document_index]
.engine
.get_selection_mask()
.map(ToOwned::to_owned);
set_document_selection_mask(&mut self.documents[document_index], selection);
self.documents[document_index].full_upload.replace(true);
self.documents[document_index].render_generation = self.documents
[document_index]
.render_generation
.wrapping_add(1);
self.documents[document_index].cached_layers =
self.documents[document_index].engine.layer_infos();
let history_len = self.documents[document_index].engine.history_len();
self.documents[document_index].cached_history = (0..history_len)
.filter_map(|i| {
self.documents[document_index]
.engine
.history_description(i)
.map(|d| (i, d))
})
.collect();
self.documents[document_index].history_current =
self.documents[document_index].engine.history_current();
return Task::perform(async {}, |_| Message::CompositeRefresh);
}
Err(e) => {
self.documents.remove(document_index);
log::error!("Failed to open file from viewer: {}", e);
self.viewer_state.load_error =
Some(format!("Could not edit {}: {e}", path.display()));
@@ -9481,7 +9542,6 @@ impl HcieIcedApp {
);
// Toolbox — 36px strip (single column) or 72px (2 columns) on the left edge
log::info!("[view] sidebar_expanded={}", self.sidebar_expanded);
let toolbox = crate::sidebar::view(
&self.tool_state,
&self.fg_color,
@@ -9673,13 +9733,19 @@ impl HcieIcedApp {
colors,
));
}
let _elapsed = view_start.elapsed();
let elapsed = view_start.elapsed();
if !self.tool_state.is_drawing {
crate::canvas::perf::record_idle_view(elapsed);
}
// Performance log measuring Elm view reconstruction time.
// useful to track widget layout allocation and view model reconstruction times.
// log::info!("[perf] view(): {:.1}ms", elapsed.as_secs_f64() * 1000.0);
stack.width(Length::Fill).height(Length::Fill).into()
} else {
let _elapsed = view_start.elapsed();
let elapsed = view_start.elapsed();
if !self.tool_state.is_drawing {
crate::canvas::perf::record_idle_view(elapsed);
}
// Performance log measuring Elm view reconstruction time.
// log::info!("[perf] view(): {:.1}ms", elapsed.as_secs_f64() * 1000.0);
content.into()
@@ -9929,7 +9995,9 @@ impl HcieIcedApp {
iced::mouse::Event::ButtonPressed(iced::mouse::Button::Left) => (status
== iced::event::Status::Ignored)
.then_some(Message::OverlayOutsideClick),
iced::mouse::Event::CursorMoved { position } => {
iced::mouse::Event::CursorMoved { position }
if status == iced::event::Status::Ignored =>
{
Some(Message::DockPointerMoved(position.x, position.y))
}
iced::mouse::Event::ButtonReleased(iced::mouse::Button::Left) => {
@@ -9978,6 +10046,27 @@ impl HcieIcedApp {
}
});
// Captured canvas events normally stay local to the retained overlay.
// During a real global drag, forward captured movement so dock/dialog
// interactions continue across the canvas without rebuilding the app
// for every idle canvas movement.
let captured_drag_pointer = if self.layer_style_dragging
|| self.dock.header_drag.is_some()
|| self.dock.drag.is_some()
|| self.dock.floating_drag.is_some()
{
iced::event::listen_with(|event, status, _id| match event {
iced::Event::Mouse(iced::mouse::Event::CursorMoved { position })
if status == iced::event::Status::Captured =>
{
Some(Message::DockPointerMoved(position.x, position.y))
}
_ => None,
})
} else {
iced::Subscription::none()
};
// The marching ants are rendered in the persistent GPU canvas shader.
// Use a real timer rather than an immediately-ready async loop: the old
// loop generated an unbounded stream of CompositeRefresh messages and
@@ -9994,7 +10083,25 @@ impl HcieIcedApp {
iced::Subscription::none()
};
iced::Subscription::batch(vec![keyboard, mouse, marching_ants])
// Raw pointer messages update the brush engine as quickly as input arrives,
// while this bounded timer controls expensive compositing and GPU staging.
let stroke_frames = if should_schedule_canvas_frames(
self.tool_state.is_drawing,
self.tool_state.active_tool,
) {
iced::time::every(std::time::Duration::from_millis(16))
.map(|_| Message::CanvasFrameTick)
} else {
iced::Subscription::none()
};
iced::Subscription::batch(vec![
keyboard,
mouse,
captured_drag_pointer,
marching_ants,
stroke_frames,
])
}
}
@@ -10070,13 +10177,24 @@ fn active_index_after_document_close(
}
}
/// Determines whether the bounded canvas frame timer should run.
///
/// **Arguments:** `is_drawing` is the pointer-drag state and `tool` is the active editor tool.
/// **Returns:** `true` only for an active raster paint stroke. **Logic & Workflow:** Selection,
/// vector, transform, and idle states continue to use their event-driven previews.
/// **Side Effects / Dependencies:** None.
fn should_schedule_canvas_frames(is_drawing: bool, tool: Tool) -> bool {
is_drawing && matches!(tool, Tool::Pen | Tool::Brush | Tool::Eraser | Tool::Spray)
}
#[cfg(test)]
mod cycle_one_ux_tests {
use super::{
active_index_after_document_close, consume_selection_sync_request,
document_close_disposition, overlay_escape_target, union_regions, DocumentCloseDisposition,
OverlayEscapeTarget,
document_close_disposition, overlay_escape_target, should_schedule_canvas_frames,
union_regions, DocumentCloseDisposition, HcieIcedApp, Message, OverlayEscapeTarget,
};
use hcie_engine_api::Tool;
/// Confirms Escape dismisses subtools before menus and leaves editor cancellation untouched.
#[test]
@@ -10143,6 +10261,80 @@ mod cycle_one_ux_tests {
assert_eq!(union, [10, 5, 50, 40]);
}
/// Confirms the frame timer is bounded to active raster strokes.
#[test]
fn canvas_frame_timer_runs_only_for_active_paint_strokes() {
for tool in [Tool::Pen, Tool::Brush, Tool::Eraser, Tool::Spray] {
assert!(should_schedule_canvas_frames(true, tool));
assert!(!should_schedule_canvas_frames(false, tool));
}
assert!(!should_schedule_canvas_frames(true, Tool::Select));
assert!(!should_schedule_canvas_frames(true, Tool::VectorSelect));
}
/// Confirms a newly created document becomes the selected tab.
///
/// **Purpose:** Guards against a tab-management regression where the new
/// document existed but later operations still targeted the previous tab.
/// **Logic & Workflow:** Creates application state, dispatches the normal
/// `NewDocument` message, and checks both count and active index.
/// **Arguments & Returns:** None. **Side Effects / Dependencies:** Allocates
/// two in-memory engine documents and reads ordinary application settings.
#[test]
fn new_document_becomes_the_active_tab() {
let (mut app, _) = HcieIcedApp::new(None, None);
let previous_count = app.documents.len();
let _ = app.update(Message::NewDocument(32, 24));
assert_eq!(app.documents.len(), previous_count + 1);
assert_eq!(app.active_doc, app.documents.len() - 1);
assert_eq!(app.documents[app.active_doc].engine.canvas_width(), 32);
assert_eq!(app.documents[app.active_doc].engine.canvas_height(), 24);
}
/// Confirms opening another file appends a tab without mutating the first.
///
/// **Purpose:** Reproduces the reported second-document overwrite regression.
/// **Logic & Workflow:** Writes a tiny PNG, opens it through the shared tab
/// helper, and verifies the initial document identity and new active tab.
/// **Arguments & Returns:** None. **Side Effects / Dependencies:** Creates and
/// removes one temporary image file; recent-file persistence is disabled.
#[test]
fn opening_a_second_file_preserves_the_existing_tab() {
let (mut app, _) = HcieIcedApp::new(None, None);
let original_name = app.documents[0].name.clone();
let unique = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
let path = std::env::temp_dir().join(format!(
"hcie-open-tab-{}-{unique}.png",
std::process::id()
));
image::save_buffer(
&path,
&[255, 0, 0, 255, 0, 255, 0, 255],
2,
1,
image::ColorType::Rgba8,
)
.unwrap();
let opened_index = app.open_path_in_new_tab(path.clone(), false).unwrap();
assert_eq!(app.documents.len(), 2);
assert_eq!(opened_index, 1);
assert_eq!(app.active_doc, 1);
assert_eq!(app.documents[0].name, original_name);
assert!(app.documents[0].source_path.is_none());
assert_eq!(app.documents[1].source_path.as_deref(), Some(path.as_path()));
assert_eq!(app.documents[1].engine.canvas_width(), 2);
assert_eq!(app.documents[1].engine.canvas_height(), 1);
let _ = std::fs::remove_file(path);
}
// ── Recent color proximity dedup ─────────────────────
/// Confirms an identical color IS proximate (diff=0 ≤ threshold).
@@ -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);
+2 -2
View File
@@ -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"] }
+47 -4
View File
@@ -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
View File
@@ -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('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
.replace('\'', "&apos;");
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('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
.replace('\'', "&apos;");
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
View File
@@ -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
View File
@@ -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);
}
}
+17 -1
View File
@@ -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);
}
}
}
}
+2 -4
View File
@@ -11,10 +11,8 @@ fontdue = "0.9"
crate-type = ["rlib"]
[dev-dependencies]
eframe = { version = "0.34", default-features = false, features = ["default_fonts", "glow"] }
egui = "0.34"
eframe = { workspace = true }
egui = { workspace = true }
rstest = "0.23"
proptest = "1.5"
approx = "0.5"
proptest = "1.5"
approx = "0.5"
+2 -2
View File
@@ -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 }
BIN
View File
Binary file not shown.
+1
View File
@@ -2,6 +2,7 @@ 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
##test
+74 -19
View File
@@ -3,25 +3,54 @@ REM ============================================================
REM HCIE-Rust v3.05 — Bulk All-Tests Execution Script (Windows)
REM Usage: run_all_tests.bat [--no-io] [--no-4k] [--ignored]
REM ============================================================
setlocal enabledelayedexpansion
setlocal DisableDelayedExpansion
set ROOT_DIR=%~dp0
set "ROOT_DIR=%~dp0"
cd /d "%ROOT_DIR%" || exit /b 1
setlocal EnableDelayedExpansion
set PASS=0
set FAIL=0
set SKIP=0
set FAILED_CRATES=
set SKIPPED_CRATES=
set START_TIME=%TIME%
set SKIP_IO=0
set SKIP_4K=0
set INCLUDE_IGNORED=0
set CARGO_ARGS=
set TEST_ARGS=
set PARSING_TEST_ARGS=0
:parse_args
if "%~1"=="" goto :done_parse
if /I "%~1"=="--no-io" set SKIP_IO=1
if /I "%~1"=="--no-4k" set SKIP_4K=1
if /I "%~1"=="--ignored" set INCLUDE_IGNORED=1
if "!PARSING_TEST_ARGS!"=="1" goto :collect_test_arg
if /I "%~1"=="--no-io" goto :enable_no_io
if /I "%~1"=="--no-4k" goto :enable_no_4k
if /I "%~1"=="--ignored" goto :enable_ignored
if "%~1"=="--" goto :begin_test_args
set "CARGO_ARGS=!CARGO_ARGS! "%~1""
shift
goto :parse_args
:enable_no_io
set SKIP_IO=1
shift
goto :parse_args
:enable_no_4k
set SKIP_4K=1
shift
goto :parse_args
:enable_ignored
set INCLUDE_IGNORED=1
shift
goto :parse_args
:begin_test_args
set PARSING_TEST_ARGS=1
shift
goto :parse_args
:collect_test_arg
set "TEST_ARGS=!TEST_ARGS! "%~1""
shift
goto :parse_args
:done_parse
@@ -41,18 +70,17 @@ call :RUN_CRATE "hcie-history"
if %SKIP_IO%==1 (
echo [SKIP] hcie-io
set /a SKIP+=1
set SKIPPED_CRATES=!SKIPPED_CRATES! hcie-io
) else (
call :RUN_CRATE "hcie-io"
)
call :RUN_CRATE "hcie-psd"
call :RUN_CRATE "psd"
call :RUN_CRATE "hcie-vision"
call :RUN_CRATE "hcie-build-info"
if %SKIP_4K%==1 (
echo [SKIP 4K benchmark in hcie-engine-api]
cargo test -p hcie-engine-api --skip benchmark_4k_stroke_on_multilayer_document
if !ERRORLEVEL!==0 ( set /a PASS+=1 ) else ( set /a FAIL+=1 )
call :RUN_CRATE "hcie-engine-api" --skip benchmark_4k_stroke_on_multilayer_document
) else (
call :RUN_CRATE "hcie-engine-api"
)
@@ -69,25 +97,52 @@ call :RUN_CRATE "hcie-ink-brushes"
if %INCLUDE_IGNORED%==1 (
echo.
echo ===== Running IGNORED tests =====
cargo test -p hcie-engine-api -- --ignored
cargo test -p hcie-brush-engine -- --ignored
cargo test -p hcie-iced-gui -- --ignored
cargo test -p hcie-fx -- --ignored
call :RUN_IGNORED hcie-engine-api
call :RUN_IGNORED hcie-brush-engine
call :RUN_IGNORED hcie-iced-gui
call :RUN_IGNORED hcie-fx
)
echo ============================================================
echo BULK TEST EXECUTION COMPLETE
echo Crates passed: %PASS%
echo Crates failed: %FAIL%
echo Test runs passed: %PASS%
echo Test runs failed: %FAIL%
echo Crates skipped: %SKIP%
if not "%FAILED_CRATES%"=="" echo Failed crates:%FAILED_CRATES%
if not "%SKIPPED_CRATES%"=="" echo Skipped crates:%SKIPPED_CRATES%
echo Start time: %START_TIME%
echo ============================================================
exit /b %FAIL%
:RUN_CRATE
set CRATE=%~1
set "CRATE=%~1"
set "CRATE_TEST_ARGS="
:RUN_CRATE_ARG_LOOP
shift
if "%~1"=="" goto :RUN_CRATE_EXECUTE
set "CRATE_TEST_ARGS=!CRATE_TEST_ARGS! "%~1""
goto :RUN_CRATE_ARG_LOOP
:RUN_CRATE_EXECUTE
set "TEST_SEPARATOR="
if not "!CRATE_TEST_ARGS!!TEST_ARGS!"=="" set "TEST_SEPARATOR=--"
echo.
echo ===== Running: %CRATE% =====
cargo test -p %CRATE%
if %ERRORLEVEL%==0 ( set /a PASS+=1 ) else ( set /a FAIL+=1 )
echo ===== Running: !CRATE! =====
cargo test -p "!CRATE!" !CARGO_ARGS! !TEST_SEPARATOR! !CRATE_TEST_ARGS! !TEST_ARGS!
if errorlevel 1 (
set /a FAIL+=1
set FAILED_CRATES=!FAILED_CRATES! !CRATE!
) else (
set /a PASS+=1
)
goto :EOF
:RUN_IGNORED
set "IGNORED_CRATE=%~1"
cargo test -p "!IGNORED_CRATE!" !CARGO_ARGS! -- --ignored !TEST_ARGS!
if errorlevel 1 (
set /a FAIL+=1
set FAILED_CRATES=!FAILED_CRATES! !IGNORED_CRATE![ignored]
) else (
set /a PASS+=1
)
goto :EOF
+49 -14
View File
@@ -16,19 +16,28 @@ cd "$ROOT_DIR"
PASS=0
FAIL=0
SKIP=0
FAILED_CRATES=()
SKIPPED_CRATES=()
START_TIME=$(date +%s)
SKIP_IO=false
SKIP_4K=false
INCLUDE_IGNORED=false
EXTRA_ARGS=()
CARGO_ARGS=()
TEST_ARGS=()
PARSING_TEST_ARGS=false
for arg in "$@"; do
if [ "$PARSING_TEST_ARGS" = true ]; then
TEST_ARGS+=("$arg")
continue
fi
case "$arg" in
--no-io) SKIP_IO=true ;;
--no-4k) SKIP_4K=true ;;
--ignored) INCLUDE_IGNORED=true ;;
*) EXTRA_ARGS+=("$arg") ;;
--) PARSING_TEST_ARGS=true ;;
*) CARGO_ARGS+=("$arg") ;;
esac
done
@@ -37,17 +46,36 @@ SEPARATOR() { printf '%*s\n' 80 '' | tr ' ' '='; }
run_crate() {
local crate="$1"
local label="$2"
local extra="${3:-}"
shift 2
local -a crate_test_args=("$@")
local -a cmd=(cargo test -p "$crate" "${CARGO_ARGS[@]}")
if (( ${#crate_test_args[@]} > 0 || ${#TEST_ARGS[@]} > 0 )); then
cmd+=(-- "${crate_test_args[@]}" "${TEST_ARGS[@]}")
fi
SEPARATOR
echo "[$label] Running: cargo test -p $crate $extra"
printf '[%s] Running:' "$label"
printf ' %q' "${cmd[@]}"
printf '\n'
SEPARATOR
# shellcheck disable=SC2086
if cargo test -p "$crate" $extra "${EXTRA_ARGS[@]+${EXTRA_ARGS[@]}}" 2>&1; then
if "${cmd[@]}" 2>&1; then
PASS=$((PASS + 1))
else
FAIL=$((FAIL + 1))
FAILED_CRATES+=("$crate")
fi
}
run_ignored_tests() {
local crate="$1"
local -a cmd=(cargo test -p "$crate" "${CARGO_ARGS[@]}" -- --ignored "${TEST_ARGS[@]}")
if "${cmd[@]}" 2>&1; then
PASS=$((PASS + 1))
else
FAIL=$((FAIL + 1))
FAILED_CRATES+=("${crate}[ignored]")
fi
}
@@ -56,9 +84,15 @@ display_summary() {
echo ""
SEPARATOR
echo " BULK TEST EXECUTION COMPLETE"
echo " Crates passed: $PASS"
echo " Crates failed: $FAIL"
echo " Test runs passed: $PASS"
echo " Test runs failed: $FAIL"
echo " Crates skipped: $SKIP"
if (( ${#FAILED_CRATES[@]} > 0 )); then
echo " Failed crates: ${FAILED_CRATES[*]}"
fi
if (( ${#SKIPPED_CRATES[@]} > 0 )); then
echo " Skipped crates: ${SKIPPED_CRATES[*]}"
fi
echo " Elapsed time: ${elapsed}s"
SEPARATOR
[ "$FAIL" -eq 0 ] && echo " ALL CRATES PASSED" || echo " $FAIL crate(s) have failing tests"
@@ -86,11 +120,12 @@ run_crate "hcie-history" "Layer 2"
if [ "$SKIP_IO" = true ]; then
echo "[SKIP] hcie-io (--no-io flag)"
SKIP=$((SKIP + 1))
SKIPPED_CRATES+=("hcie-io")
else
run_crate "hcie-io" "Layer 2"
fi
run_crate "hcie-psd" "Layer 2"
run_crate "psd" "Layer 2"
run_crate "hcie-vision" "Layer 2"
run_crate "hcie-build-info" "Layer 2"
@@ -98,7 +133,7 @@ run_crate "hcie-build-info" "Layer 2"
# Layer 4: ENGINE API
# ──────────────────────────────────────────────────────────
if [ "$SKIP_4K" = true ]; then
run_crate "hcie-engine-api" "Layer 4" "--skip benchmark_4k_stroke_on_multilayer_document"
run_crate "hcie-engine-api" "Layer 4" --skip benchmark_4k_stroke_on_multilayer_document
else
run_crate "hcie-engine-api" "Layer 4"
fi
@@ -130,10 +165,10 @@ if [ "$INCLUDE_IGNORED" = true ]; then
SEPARATOR
echo "Running IGNORED tests (visual checks, benchmarks)"
SEPARATOR
cargo test -p hcie-engine-api -- --ignored || true
cargo test -p hcie-brush-engine -- --ignored || true
cargo test -p hcie-iced-gui -- --ignored || true
cargo test -p hcie-fx -- --ignored || true
run_ignored_tests hcie-engine-api
run_ignored_tests hcie-brush-engine
run_ignored_tests hcie-iced-gui
run_ignored_tests hcie-fx
fi
display_summary
+73 -18
View File
@@ -3,10 +3,11 @@ REM ============================================================
REM HCIE-Rust v3.05 — Categorized Test Runner (Windows .bat)
REM Usage: run_tests_categorized.bat [--no-io] [--no-4k] [--ignored]
REM ============================================================
setlocal enabledelayedexpansion
setlocal DisableDelayedExpansion
set ROOT_DIR=%~dp0
set "ROOT_DIR=%~dp0"
cd /d "%ROOT_DIR%" || exit /b 1
setlocal EnableDelayedExpansion
echo ============================================================
echo HCIE-Rust v3.05 — Categorized Test Runner
@@ -16,15 +17,43 @@ echo ============================================================
set PASS=0
set FAIL=0
set SKIP=0
set FAILED_CRATES=
set SKIPPED_CRATES=
set SKIP_IO=0
set SKIP_4K=0
set INCLUDE_IGNORED=0
set CARGO_ARGS=
set TEST_ARGS=
set PARSING_TEST_ARGS=0
:parse_args
if "%~1"=="" goto :done_parse
if /I "%~1"=="--no-io" set SKIP_IO=1
if /I "%~1"=="--no-4k" set SKIP_4K=1
if /I "%~1"=="--ignored" set INCLUDE_IGNORED=1
if "!PARSING_TEST_ARGS!"=="1" goto :collect_test_arg
if /I "%~1"=="--no-io" goto :enable_no_io
if /I "%~1"=="--no-4k" goto :enable_no_4k
if /I "%~1"=="--ignored" goto :enable_ignored
if "%~1"=="--" goto :begin_test_args
set "CARGO_ARGS=!CARGO_ARGS! "%~1""
shift
goto :parse_args
:enable_no_io
set SKIP_IO=1
shift
goto :parse_args
:enable_no_4k
set SKIP_4K=1
shift
goto :parse_args
:enable_ignored
set INCLUDE_IGNORED=1
shift
goto :parse_args
:begin_test_args
set PARSING_TEST_ARGS=1
shift
goto :parse_args
:collect_test_arg
set "TEST_ARGS=!TEST_ARGS! "%~1""
shift
goto :parse_args
:done_parse
@@ -37,15 +66,14 @@ call :RUN_CATEGORY "Layer 2: Selection/Vector/History" hcie-selection hcie-vecto
if %SKIP_IO%==1 (
echo [SKIP] hcie-io
set /a SKIP+=1
set SKIPPED_CRATES=!SKIPPED_CRATES! hcie-io
) else (
call :RUN_CRATE hcie-io
)
call :RUN_CATEGORY "Layer 2: PSD/Vision/Build" hcie-psd hcie-vision hcie-build-info
call :RUN_CATEGORY "Layer 2: PSD/Vision/Build" psd hcie-vision hcie-build-info
if %SKIP_4K%==1 (
echo [SKIP 4K benchmark in hcie-engine-api]
cargo test -p hcie-engine-api --skip benchmark_4k_stroke_on_multilayer_document
if !ERRORLEVEL!==0 ( set /a PASS+=1 ) else ( set /a FAIL+=1 )
call :RUN_CRATE hcie-engine-api --skip benchmark_4k_stroke_on_multilayer_document
) else (
call :RUN_CRATE hcie-engine-api
)
@@ -57,14 +85,16 @@ call :RUN_CATEGORY "Brush Catalogs" hcie-dry-media-brushes hcie-paint-brushes hc
if %INCLUDE_IGNORED%==1 (
echo.
echo ===== Running IGNORED tests =====
cargo test -p hcie-engine-api -- --ignored
cargo test -p hcie-brush-engine -- --ignored
cargo test -p hcie-iced-gui -- --ignored
cargo test -p hcie-fx -- --ignored
call :RUN_IGNORED hcie-engine-api
call :RUN_IGNORED hcie-brush-engine
call :RUN_IGNORED hcie-iced-gui
call :RUN_IGNORED hcie-fx
)
echo ============================================================
echo SUMMARY: %PASS% passed, %FAIL% failed, %SKIP% skipped
echo SUMMARY: %PASS% test runs passed, %FAIL% failed, %SKIP% crates skipped
if not "%FAILED_CRATES%"=="" echo Failed crates:%FAILED_CRATES%
if not "%SKIPPED_CRATES%"=="" echo Skipped crates:%SKIPPED_CRATES%
echo ============================================================
exit /b %FAIL%
@@ -80,8 +110,33 @@ shift
goto RUN_CATEGORY_LOOP
:RUN_CRATE
set CRATE=%~1
echo --- Running: %CRATE% ---
cargo test -p %CRATE%
if %ERRORLEVEL%==0 ( set /a PASS+=1 ) else ( set /a FAIL+=1 )
set "CRATE=%~1"
set "CRATE_TEST_ARGS="
:RUN_CRATE_ARG_LOOP
shift
if "%~1"=="" goto :RUN_CRATE_EXECUTE
set "CRATE_TEST_ARGS=!CRATE_TEST_ARGS! "%~1""
goto :RUN_CRATE_ARG_LOOP
:RUN_CRATE_EXECUTE
set "TEST_SEPARATOR="
if not "!CRATE_TEST_ARGS!!TEST_ARGS!"=="" set "TEST_SEPARATOR=--"
echo --- Running: !CRATE! ---
cargo test -p "!CRATE!" !CARGO_ARGS! !TEST_SEPARATOR! !CRATE_TEST_ARGS! !TEST_ARGS!
if errorlevel 1 (
set /a FAIL+=1
set FAILED_CRATES=!FAILED_CRATES! !CRATE!
) else (
set /a PASS+=1
)
goto :EOF
:RUN_IGNORED
set "IGNORED_CRATE=%~1"
cargo test -p "!IGNORED_CRATE!" !CARGO_ARGS! -- --ignored !TEST_ARGS!
if errorlevel 1 (
set /a FAIL+=1
set FAILED_CRATES=!FAILED_CRATES! !IGNORED_CRATE![ignored]
) else (
set /a PASS+=1
)
goto :EOF
+48 -10
View File
@@ -11,15 +11,26 @@ cd "$ROOT_DIR"
PASS=0
FAIL=0
SKIP=0
FAILED_CRATES=()
SKIPPED_CRATES=()
SKIP_IO=false
SKIP_4K=false
INCLUDE_IGNORED=false
CARGO_ARGS=()
TEST_ARGS=()
PARSING_TEST_ARGS=false
for arg in "$@"; do
if [ "$PARSING_TEST_ARGS" = true ]; then
TEST_ARGS+=("$arg")
continue
fi
case "$arg" in
--no-io) SKIP_IO=true ;;
--no-4k) SKIP_4K=true ;;
--ignored) INCLUDE_IGNORED=true ;;
--) PARSING_TEST_ARGS=true ;;
*) CARGO_ARGS+=("$arg") ;;
esac
done
@@ -28,16 +39,36 @@ SEPARATOR() { printf '%*s\n' 80 '' | tr ' ' '='; }
run_crate() {
local crate="$1"
local label="$2"
local extra="${3:-}"
shift 2
local -a crate_test_args=("$@")
local -a cmd=(cargo test -p "$crate" "${CARGO_ARGS[@]}")
if (( ${#crate_test_args[@]} > 0 || ${#TEST_ARGS[@]} > 0 )); then
cmd+=(-- "${crate_test_args[@]}" "${TEST_ARGS[@]}")
fi
SEPARATOR
echo "[$label] Running: cargo test -p $crate $extra"
printf '[%s] Running:' "$label"
printf ' %q' "${cmd[@]}"
printf '\n'
SEPARATOR
if cargo test -p "$crate" $extra 2>&1; then
if "${cmd[@]}" 2>&1; then
PASS=$((PASS + 1))
else
FAIL=$((FAIL + 1))
FAILED_CRATES+=("$crate")
fi
}
run_ignored_tests() {
local crate="$1"
local -a cmd=(cargo test -p "$crate" "${CARGO_ARGS[@]}" -- --ignored "${TEST_ARGS[@]}")
if "${cmd[@]}" 2>&1; then
PASS=$((PASS + 1))
else
FAIL=$((FAIL + 1))
FAILED_CRATES+=("${crate}[ignored]")
fi
}
@@ -58,16 +89,17 @@ run_crate "hcie-history" "CORE"
if [ "$SKIP_IO" = true ]; then
echo "[SKIP] hcie-io"
SKIP=$((SKIP + 1))
SKIPPED_CRATES+=("hcie-io")
else
run_crate "hcie-io" "CORE"
fi
run_crate "hcie-psd" "CORE"
run_crate "psd" "CORE"
run_crate "hcie-vision" "CORE"
run_crate "hcie-build-info" "CORE"
# ── Layer 4: ENGINE API ────────────────────────────────────
if [ "$SKIP_4K" = true ]; then
run_crate "hcie-engine-api" "ENGINE-API" "--skip benchmark_4k_stroke_on_multilayer_document"
run_crate "hcie-engine-api" "ENGINE-API" --skip benchmark_4k_stroke_on_multilayer_document
else
run_crate "hcie-engine-api" "ENGINE-API"
fi
@@ -89,13 +121,19 @@ if [ "$INCLUDE_IGNORED" = true ]; then
SEPARATOR
echo "Running IGNORED tests (visual checks, benchmarks)"
SEPARATOR
cargo test -p hcie-engine-api -- --ignored || true
cargo test -p hcie-brush-engine -- --ignored || true
cargo test -p hcie-iced-gui -- --ignored || true
cargo test -p hcie-fx -- --ignored || true
run_ignored_tests hcie-engine-api
run_ignored_tests hcie-brush-engine
run_ignored_tests hcie-iced-gui
run_ignored_tests hcie-fx
fi
SEPARATOR
echo "CATEGORIZED TEST RUN COMPLETE: $PASS passed, $FAIL failed, $SKIP skipped"
echo "CATEGORIZED TEST RUN COMPLETE: $PASS test runs passed, $FAIL failed, $SKIP crates skipped"
if (( ${#FAILED_CRATES[@]} > 0 )); then
echo "Failed crates: ${FAILED_CRATES[*]}"
fi
if (( ${#SKIPPED_CRATES[@]} > 0 )); then
echo "Skipped crates: ${SKIPPED_CRATES[*]}"
fi
SEPARATOR
exit "$FAIL"
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
+17
View File
@@ -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.