fix: optimize brush stroke handling and improve cursor preview
- Changed default VISION_BACKEND from CPU to GPU for better performance. - Made `map_brush_style` public to allow external access. - Updated `Engine` struct to use pooled buffers for stroke snapshots, reducing memory allocations during brush strokes. - Introduced `CompositeSnapshot` for efficient background compositing without blocking the UI thread. - Implemented `SendPtr` for safe raw pointer handling across threads. - Enhanced `commit_pending_history` to recycle buffers and improve performance. - Fixed cursor preview issues for procedural brushes by removing unnecessary preset synchronization. - Added tests and documentation for new features and performance improvements.
This commit is contained in:
@@ -0,0 +1,72 @@
|
|||||||
|
# Brush Stroke End Freeze Fix + Cursor Preview Fix
|
||||||
|
|
||||||
|
## Issue 1: Stroke End Freeze (Partially Fixed)
|
||||||
|
|
||||||
|
### Current State
|
||||||
|
- During drawing: canvas updates correctly (synchronous composite)
|
||||||
|
- After stroke end: still freezes due to `create_composite_snapshot()` being expensive
|
||||||
|
|
||||||
|
### Root Cause
|
||||||
|
`create_composite_snapshot()` in `partial_composite.rs:354` clones ALL layers (33MB per layer on 4K) and ALL tile layers. For a 10-layer 4K document, this is ~330MB of cloning on the UI thread.
|
||||||
|
|
||||||
|
### Fix: Skip background composite for brush strokes, use synchronous composite
|
||||||
|
|
||||||
|
**File:** `hcie-egui-app/crates/hcie-gui-egui/src/canvas/render.rs`
|
||||||
|
|
||||||
|
In `render_composition()`, the after-stroke-end path currently spawns a background thread. Instead:
|
||||||
|
- Do the synchronous composite directly (like during drawing)
|
||||||
|
- Only use background thread for very expensive operations (filters, fills)
|
||||||
|
- This eliminates the freeze for brush strokes
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// After stroke end: do synchronous composite (not background)
|
||||||
|
// Background thread is too expensive due to create_composite_snapshot()
|
||||||
|
let (region, buf_ptr, buf_size) = doc.engine.render_composite_region();
|
||||||
|
// ... upload to texture ...
|
||||||
|
doc.engine.clear_dirty_flags();
|
||||||
|
```
|
||||||
|
|
||||||
|
## Issue 2: Brush Cursor Preview Shows Wrong Shape
|
||||||
|
|
||||||
|
### Current State
|
||||||
|
- ABR (bitmap) brushes: correct preview
|
||||||
|
- Procedural brushes (Round, Square, Star, etc.): shows as small circle
|
||||||
|
- After using ABR brush: preview gets stuck on ABR shape
|
||||||
|
|
||||||
|
### Root Cause
|
||||||
|
The `brushes_panel.rs` changes (from other AI) added `active_brush_preset_id` sync that overrides the brush style. When a procedural style cell is clicked:
|
||||||
|
1. `state.tool_configs.brush.style` is set correctly
|
||||||
|
2. But then `active_brush_preset_id` is synced to a preset matching that style
|
||||||
|
3. If no matching preset exists, or if the preset has a different style, the preview gets confused
|
||||||
|
|
||||||
|
### Fix: Remove the `active_brush_preset_id` sync
|
||||||
|
|
||||||
|
**File:** `hcie-egui-app/crates/hcie-gui-egui/src/app/tools/brushes_panel.rs`
|
||||||
|
|
||||||
|
Remove the 7-line block that was added:
|
||||||
|
```rust
|
||||||
|
// Sync active_brush_preset_id to a preset matching this style
|
||||||
|
// so the cursor preview shows the correct brush tip.
|
||||||
|
if let Some(matching) = state.brush_presets.iter().find(|p| {
|
||||||
|
to_tools_style(map_brush_style(p.tip.style)) == style
|
||||||
|
}) {
|
||||||
|
state.active_brush_preset_id = matching.id.clone();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The cursor preview already uses `state.tool_configs.brush.style` directly, not the preset. This sync is unnecessary and causes the preview to get stuck.
|
||||||
|
|
||||||
|
## Files to Modify
|
||||||
|
|
||||||
|
| File | Change |
|
||||||
|
|------|--------|
|
||||||
|
| `hcie-egui-app/crates/hcie-gui-egui/src/canvas/render.rs` | Use synchronous composite after stroke end |
|
||||||
|
| `hcie-egui-app/crates/hcie-gui-egui/src/app/tools/brushes_panel.rs` | Remove `active_brush_preset_id` sync |
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
1. **Stroke freeze test:** Draw rapid brush strokes — no visible freeze on stroke end
|
||||||
|
2. **Cursor preview test:** Switch between procedural brushes (Round, Square, Star) — preview should show correct shape
|
||||||
|
3. **ABR transition test:** Use ABR brush, then switch to procedural — preview should update correctly
|
||||||
|
4. **Visual regression:** `cargo test -p hcie-engine-api --test visual_regression`
|
||||||
|
5. **Performance test:** `cargo test -p hcie-engine-api --test performance_stroke_4k -- --nocapture`
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
# Double-Buffering Plan: Çizim Bitimindeki Takılmayı Giderme — TAMAMLANDI (Katman 1+2+3)
|
||||||
|
|
||||||
|
## Yapılan Değişiklikler
|
||||||
|
|
||||||
|
### 1. Engine Struct Güncellendi (`lib.rs`)
|
||||||
|
- `stroke_before: Option<(u64, Vec<u8>, ...)>` → `stroke_before: Option<(u64, Option<Vec<VectorShape>>)>`
|
||||||
|
- Yeni: `stroke_before_buf: Option<Vec<u8>>` — havuzlu before buffer
|
||||||
|
|
||||||
|
### 2. SendPtr Wrapper Eklendi (`stroke_cache.rs`)
|
||||||
|
- `*const u8` yerine `usize` kullanarak `Send` trait'ini çözdük
|
||||||
|
- Background thread'e raw pointer güvenli bir şekilde gönderiliyor
|
||||||
|
|
||||||
|
### 3. PendingHistoryItem Güncellendi (`stroke_cache.rs`)
|
||||||
|
- `return_before: Option<Vec<u8>>` — background thread'den buffer geri dönüşü
|
||||||
|
|
||||||
|
### 4. begin_stroke() Güncellendi (`stroke_cache.rs`)
|
||||||
|
- `layer.pixels.clone()` → `copy_from_slice` ile havuzlu buffer'a kopyalama
|
||||||
|
- İlk stroke'ta ~33MB alloc, sonraki stroke'larda sadece copy
|
||||||
|
|
||||||
|
### 5. end_stroke() Güncellendi (`stroke_cache.rs`) — Katman 3
|
||||||
|
- Before buffer: havuzlu buffer'dan take (sıfır alloc)
|
||||||
|
- After buffer: **sıfır kopya** — `SendPtr` ile raw pointer background thread'e gönderiliyor
|
||||||
|
- Background thread `unsafe { slice::from_raw_parts(...) }` ile okuyor
|
||||||
|
|
||||||
|
### 6. commit_pending_history() Güncellendi (`stroke_cache.rs`)
|
||||||
|
- Return edilen before buffer'ı havuza geri koyar
|
||||||
|
|
||||||
|
### 7. stroke_brush.rs Güncellendi
|
||||||
|
- `stroke_before.as_ref().map(|(_, px, _)| px.as_slice())` → `stroke_before_buf.as_deref()`
|
||||||
|
|
||||||
|
## Performans Etkisi
|
||||||
|
|
||||||
|
| Metrik | Önce | Sonra (Katman 1+2+3) |
|
||||||
|
|--------|------|-------|
|
||||||
|
| begin_stroke alloc | ~33MB (her stroke) | İlk stroke'ta ~33MB, sonrasında 0 alloc |
|
||||||
|
| end_stroke alloc | ~33MB (her stroke) | **Sıfır** (pointer paylaşımı) |
|
||||||
|
| end_stroke kopya | ~33MB | **Sıfır** (zero-copy after snapshot) |
|
||||||
|
| Toplam alloc/stroke | ~66MB | **~33MB** (sadece before) |
|
||||||
|
|
||||||
|
**Katman 3 Kazanımı:** `end_stroke()`'daki ~33MB alloc+copy tamamen ortadan kalktı.
|
||||||
|
|
||||||
|
## Teknik Çözüm: SendPtr
|
||||||
|
|
||||||
|
Rust'ta `*const u8` `Send` trait'ini implement etmiyor. Çözüm:
|
||||||
|
```rust
|
||||||
|
struct SendPtr(usize); // usize her zaman Send
|
||||||
|
impl SendPtr {
|
||||||
|
fn new(p: *const u8) -> Self { Self(p as usize) }
|
||||||
|
fn as_ptr(&self) -> *const u8 { self.0 as *const u8 }
|
||||||
|
}
|
||||||
|
unsafe impl Send for SendPtr {}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Test Sonuçları
|
||||||
|
- ✅ `bitmap_brush_stamp_shape` — başarılı
|
||||||
|
- ✅ `visual_regression` (8 test) — başarılı
|
||||||
|
- ✅ `performance_stroke_4k` — başarılı (1.95s toplam)
|
||||||
|
- ✅ GUI derlemesi — başarılı
|
||||||
|
|
||||||
|
## Dosya Değişiklikleri
|
||||||
|
| Dosya | Değişiklik |
|
||||||
|
|-------|------------|
|
||||||
|
| `hcie-engine-api/src/lib.rs` | Engine struct + init + docstring |
|
||||||
|
| `hcie-engine-api/src/stroke_cache.rs` | SendPtr + begin_stroke + end_stroke + commit_pending_history |
|
||||||
|
| `hcie-engine-api/src/stroke_brush.rs` | stroke_before referansı güncellendi |
|
||||||
|
| `docs/compose/specs/snapshot-freeze-analysis.md` | Bulgular dosyası (yeni) |
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
# Snapshot Freeze Analysis — Çizim Bitimindeki Takılma
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
Fırça darbesi bittiğinde (pen-up), UI thread kısa süreli donuyor. Bu takılma snapshot (geri alım) mekanizmasının UI thread'de yaptığı büyük bellek tahsislerinden kaynaklanıyor.
|
||||||
|
|
||||||
|
## Kök Neden
|
||||||
|
|
||||||
|
`end_stroke()` ve `begin_stroke()` fonksiyonları UI thread'de çalışır ve her biri ~33MB'lik `layer.pixels.clone()` çağrısı yapar. 4K canvas'ta (3840×2160×4 = ~33MB) bu kopyalamalar allocate+copy gerektirir.
|
||||||
|
|
||||||
|
## Suçlu İşlemler
|
||||||
|
|
||||||
|
| # | İşlem | Dosya:Satır | Boyut | Etki |
|
||||||
|
|---|-------|-------------|-------|------|
|
||||||
|
| 1 | `layer.pixels.clone()` — before snapshot | `stroke_cache.rs:125` | ~33MB alloc+copy | Yüksek |
|
||||||
|
| 2 | `layer.pixels.clone()` — after snapshot | `stroke_cache.rs:159` | ~33MB alloc+copy | **En yüksek** |
|
||||||
|
| 3 | `active_stroke_mask.fill(0)` | `stroke_cache.rs:224` | ~8MB sıfırlama | Orta |
|
||||||
|
| 4 | `raw_pixel_backup.remove()` — drop | `stroke_cache.rs:219` | ~33MB deallocation | Orta |
|
||||||
|
| 5 | `apply_effects_and_sync_tiles()` | `partial_composite.rs:206-268` | ~99MB+ (efektli katmanlarda) | Yüksek |
|
||||||
|
|
||||||
|
## Akış Diyagramı
|
||||||
|
|
||||||
|
```
|
||||||
|
begin_stroke() end_stroke()
|
||||||
|
───────────── ─────────────
|
||||||
|
1. mask.fill(0) [pooled, ~8MB] 1. before = stroke_before.take()
|
||||||
|
2. before = layer.pixels.clone() [33MB] 2. after = layer.pixels.clone() [33MB] ← DONMA
|
||||||
|
3. stroke_before = Some(before) 3. thread::spawn(move || { ... })
|
||||||
|
4. rebuild_below_cache() 4. mask.fill(0) [pooled]
|
||||||
|
5. raw_pixel_backup.remove()
|
||||||
|
6. cached_selection_mask = None
|
||||||
|
```
|
||||||
|
|
||||||
|
## Mevcut Havuz Mekanizmaları (İyi)
|
||||||
|
|
||||||
|
| Buffer | Boyut | Havuzda mı? | Not |
|
||||||
|
|--------|-------|-------------|-----|
|
||||||
|
| `active_stroke_mask` | ~8MB | Evet | `begin_stroke`'da fill(0), stroke arası korunur |
|
||||||
|
| `composite_scratch` | ~33MB | Evet | Render sırasında kullanılır, serbest bırakılmaz |
|
||||||
|
| `below_cache` | ~33MB | Evet | Stroke arası korunur, `below_cache_dirty` ile yönetilir |
|
||||||
|
|
||||||
|
## Havuzlanmayan Buffer'lar (Kötü)
|
||||||
|
|
||||||
|
| Buffer | Boyut | Neden havuzlanmıyor? |
|
||||||
|
|--------|-------|----------------------|
|
||||||
|
| `stroke_before` (before snapshot) | ~33MB | Her `begin_stroke`'da `clone()`, her `end_stroke`'da consume edilir |
|
||||||
|
| `stroke_after` (after snapshot) | ~33MB | Her `end_stroke`'da `clone()`, background thread'e move edilir |
|
||||||
|
|
||||||
|
## Kritik Gözlem
|
||||||
|
|
||||||
|
`end_stroke()` çağrıldığında fırça darbesi bitmiştir. Bir sonraki `begin_stroke()`'a kadar `layer.pixels`'e hiçbir yazılmaz. Bu, background thread'in raw pointer ile okuma yapabileceği anlamına gelir — unsafe ama güvenli.
|
||||||
|
|
||||||
|
## Optimize Edilmiş Akış (Hedef)
|
||||||
|
|
||||||
|
```
|
||||||
|
begin_stroke() end_stroke()
|
||||||
|
───────────── ─────────────
|
||||||
|
1. mask.fill(0) [pooled] 1. before = pooled buffer'dan take
|
||||||
|
2. copy layer.pixels → pooled buf 2. copy layer.pixels → pooled after buf
|
||||||
|
3. stroke_before = Some(pooled buf) 3. both buflar → background thread
|
||||||
|
4. rebuild_below_cache() 4. mask.fill(0) [pooled]
|
||||||
|
5. background thread: sub-rect çıkar, buffer'ları geri gönder
|
||||||
|
```
|
||||||
|
|
||||||
|
## Dosyalar
|
||||||
|
|
||||||
|
- `hcie-engine-api/src/stroke_cache.rs` — begin_stroke, end_stroke
|
||||||
|
- `hcie-engine-api/src/lib.rs` — Engine struct tanımı
|
||||||
|
- `hcie-document/src/lib.rs` — push_draw_snapshot, push_draw_snapshot_subrect
|
||||||
|
- `hcie-protocol/src/lib.rs` — Layer struct tanımı
|
||||||
@@ -1905,7 +1905,10 @@ pub fn draw_stipple_brush(
|
|||||||
) {
|
) {
|
||||||
let mut rng = rand::thread_rng();
|
let mut rng = rand::thread_rng();
|
||||||
let effective_size = size * pressure;
|
let effective_size = size * pressure;
|
||||||
let spread = effective_size * 2.2;
|
// Spread controls how far particles scatter from the center.
|
||||||
|
// Previous value (2.2×) caused the brush to paint ~4.4× the nominal
|
||||||
|
// diameter. 0.65× keeps the footprint close to the user-specified size.
|
||||||
|
let spread = effective_size * 0.65;
|
||||||
let particle_opacity = opacity * pressure;
|
let particle_opacity = opacity * pressure;
|
||||||
let count = (effective_size / 4.0).max(3.0) as u32;
|
let count = (effective_size / 4.0).max(3.0) as u32;
|
||||||
|
|
||||||
@@ -1914,16 +1917,18 @@ pub fn draw_stipple_brush(
|
|||||||
let theta = rng.gen::<f32>() * std::f32::consts::PI * 2.0;
|
let theta = rng.gen::<f32>() * std::f32::consts::PI * 2.0;
|
||||||
let px = cx + r * theta.cos();
|
let px = cx + r * theta.cos();
|
||||||
let py = cy + r * theta.sin();
|
let py = cy + r * theta.sin();
|
||||||
let star_size = rng.gen_range(3.0..(effective_size * 0.22).max(4.0));
|
// Smaller star particles to match the tighter spread.
|
||||||
|
let star_size = rng.gen_range(2.0..(effective_size * 0.15).max(3.0));
|
||||||
|
|
||||||
// A. Small soft center core
|
// A. Small soft center core
|
||||||
draw_dab(pixels, width, height, px, py, star_size * 0.35, hardness, color, particle_opacity, is_eraser, mask);
|
draw_dab(pixels, width, height, px, py, star_size * 0.35, hardness, color, particle_opacity, is_eraser, mask);
|
||||||
|
|
||||||
// B. Long, tapering arm dabs stretching out along X and Y axes
|
// B. Long, tapering arm dabs stretching out along X and Y axes
|
||||||
for i in 1..=4 {
|
// Arm distance scaled down to keep the star within the spread radius.
|
||||||
let arm_dist = star_size * 0.45 * i as f32;
|
for i in 1..=3 {
|
||||||
let arm_size = star_size * 0.30 * (1.0 - (i as f32 / 5.5)); // Tapering diameter
|
let arm_dist = star_size * 0.35 * i as f32;
|
||||||
let arm_op = particle_opacity * (1.0 - (i as f32 / 6.0)); // Concentric opacity decay
|
let arm_size = star_size * 0.25 * (1.0 - (i as f32 / 4.5)); // Tapering diameter
|
||||||
|
let arm_op = particle_opacity * (1.0 - (i as f32 / 5.0)); // Concentric opacity decay
|
||||||
|
|
||||||
draw_dab(pixels, width, height, px + arm_dist, py, arm_size, hardness, color, arm_op, is_eraser, mask);
|
draw_dab(pixels, width, height, px + arm_dist, py, arm_size, hardness, color, arm_op, is_eraser, mask);
|
||||||
draw_dab(pixels, width, height, px - arm_dist, py, arm_size, hardness, color, arm_op, is_eraser, mask);
|
draw_dab(pixels, width, height, px - arm_dist, py, arm_size, hardness, color, arm_op, is_eraser, mask);
|
||||||
|
|||||||
@@ -42,6 +42,16 @@ pub struct AppDocument {
|
|||||||
/// The last egui Context used to render/upload this document's GPU textures.
|
/// The last egui Context used to render/upload this document's GPU textures.
|
||||||
/// Used to detect when a panel is floated/docked (context change) and re-upload textures.
|
/// Used to detect when a panel is floated/docked (context change) and re-upload textures.
|
||||||
pub last_ctx: Option<egui::Context>,
|
pub last_ctx: Option<egui::Context>,
|
||||||
|
/// When true, layer thumbnails need regeneration on the next frame.
|
||||||
|
/// Deferring thumbnail generation by one frame avoids cloning ~33MB per
|
||||||
|
/// dirty layer on the same frame the composite runs.
|
||||||
|
/// When true, layer thumbnails need regeneration on the next frame.
|
||||||
|
/// Deferring thumbnail generation by one frame avoids cloning ~33MB per
|
||||||
|
/// dirty layer on the same frame the composite runs.
|
||||||
|
pub thumbnails_dirty: bool,
|
||||||
|
/// Tracks when the last stroke ended so thumbnail regeneration can be
|
||||||
|
/// deferred until the user has been idle (no drawing) for a short period.
|
||||||
|
pub last_stroke_end_time: Option<std::time::Instant>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AppDocument {
|
impl AppDocument {
|
||||||
@@ -68,6 +78,8 @@ impl AppDocument {
|
|||||||
selection_edge_cache: SelectionEdgeCache::default(),
|
selection_edge_cache: SelectionEdgeCache::default(),
|
||||||
pan_offset: egui::Vec2::ZERO,
|
pan_offset: egui::Vec2::ZERO,
|
||||||
last_ctx: None,
|
last_ctx: None,
|
||||||
|
thumbnails_dirty: false,
|
||||||
|
last_stroke_end_time: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5126,8 +5126,17 @@ impl eframe::App for HcieApp {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Auto-save interval for eframe storage persistence.
|
||||||
|
///
|
||||||
|
/// **IMPORTANT**: eframe calls `save()` on the UI thread at this interval.
|
||||||
|
/// `save()` serializes all settings, brush presets (including ABR bitmap
|
||||||
|
/// data), dock state, and writes them to disk. A 5-second interval caused
|
||||||
|
/// ~500ms periodic freezes during active drawing because the serialization
|
||||||
|
/// blocked the frame loop. 120 seconds is a safe trade-off: settings are
|
||||||
|
/// still persisted on explicit events (SaveSettings), and the auto-save
|
||||||
|
/// catches any unsaved state on idle.
|
||||||
fn auto_save_interval(&self) -> std::time::Duration {
|
fn auto_save_interval(&self) -> std::time::Duration {
|
||||||
std::time::Duration::from_secs(5)
|
std::time::Duration::from_secs(120)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -107,6 +107,11 @@ pub struct ToolState {
|
|||||||
pub brush_thumbnail_cache: std::collections::HashMap<String, egui::TextureHandle>,
|
pub brush_thumbnail_cache: std::collections::HashMap<String, egui::TextureHandle>,
|
||||||
/// When true, all cached thumbnails are regenerated on the next frame.
|
/// When true, all cached thumbnails are regenerated on the next frame.
|
||||||
pub brush_thumbnail_dirty: bool,
|
pub brush_thumbnail_dirty: bool,
|
||||||
|
/// Frame counter used by `render_composition` to throttle compositing
|
||||||
|
/// during active drawing. Composite runs every other frame (even counts)
|
||||||
|
/// to keep strokes visible in real-time while limiting performance cost.
|
||||||
|
/// Reset to 0 when drawing ends.
|
||||||
|
pub drawing_composite_skip_counter: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for ToolState {
|
impl Default for ToolState {
|
||||||
@@ -206,6 +211,7 @@ impl Default for ToolState {
|
|||||||
brush_resize_start_pos: None,
|
brush_resize_start_pos: None,
|
||||||
brush_thumbnail_cache: std::collections::HashMap::new(),
|
brush_thumbnail_cache: std::collections::HashMap::new(),
|
||||||
brush_thumbnail_dirty: true,
|
brush_thumbnail_dirty: true,
|
||||||
|
drawing_composite_skip_counter: 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ use crate::app::{tools::shape_sync, AppDocument, SelectionTransform, ToolState,
|
|||||||
use crate::event_bus::{AppEvent, EventBus};
|
use crate::event_bus::{AppEvent, EventBus};
|
||||||
use eframe::egui;
|
use eframe::egui;
|
||||||
use hcie_engine_api::{LayerData, Tool, VectorEditHandle, VectorShape, ZOOM_MAX, ZOOM_MIN};
|
use hcie_engine_api::{LayerData, Tool, VectorEditHandle, VectorShape, ZOOM_MAX, ZOOM_MIN};
|
||||||
use hcie_engine_api::brush::BrushStyle;
|
use std::hash::{Hash, Hasher};
|
||||||
|
|
||||||
pub mod render;
|
pub mod render;
|
||||||
pub mod text_overlay;
|
pub mod text_overlay;
|
||||||
@@ -488,6 +488,15 @@ impl<'a> egui::Widget for CanvasWidget<'a> {
|
|||||||
let layer_id = self.doc.engine.active_layer_id();
|
let layer_id = self.doc.engine.active_layer_id();
|
||||||
if self.state.active_tool != Tool::Pen {
|
if self.state.active_tool != Tool::Pen {
|
||||||
self.doc.engine.begin_stroke(layer_id, ux as f32, uy as f32);
|
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
|
||||||
|
// re-drawing the same dab on the first move event.
|
||||||
|
self.doc.engine.stroke_to(
|
||||||
|
layer_id,
|
||||||
|
ux as f32,
|
||||||
|
uy as f32,
|
||||||
|
self.state.current_pressure,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1086,8 +1095,11 @@ impl<'a> egui::Widget for CanvasWidget<'a> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Tool::Pen | Tool::Brush | Tool::Eraser | Tool::Spray => {
|
Tool::Pen | Tool::Brush | Tool::Eraser | Tool::Spray => {
|
||||||
|
let t_stroke_end = std::time::Instant::now();
|
||||||
let layer_id = self.doc.engine.active_layer_id();
|
let layer_id = self.doc.engine.active_layer_id();
|
||||||
self.doc.engine.end_stroke(layer_id);
|
self.doc.engine.end_stroke(layer_id);
|
||||||
|
log::warn!("[PERF] self.doc.engine.end_stroke took {}ms", t_stroke_end.elapsed().as_millis());
|
||||||
|
self.doc.last_stroke_end_time = Some(std::time::Instant::now());
|
||||||
|
|
||||||
let desc = match self.state.active_tool {
|
let desc = match self.state.active_tool {
|
||||||
Tool::Pen => "Pen Stroke".to_string(),
|
Tool::Pen => "Pen Stroke".to_string(),
|
||||||
@@ -1430,151 +1442,203 @@ impl<'a> egui::Widget for CanvasWidget<'a> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Brush cursor preview ─────────────────────────────────
|
// ── Brush cursor preview ─────────────────────────────────
|
||||||
|
// Renders the active brush tip as a single-dab stamp texture. The texture is
|
||||||
|
// cached by style, size, hardness, opacity, color, and zoom, and is regenerated
|
||||||
|
// only when one of those parameters changes.
|
||||||
if let Some((cx, cy)) = self.state.cursor_canvas_pos {
|
if let Some((cx, cy)) = self.state.cursor_canvas_pos {
|
||||||
if self.state.active_tool.shows_pen_tips() {
|
if self.state.active_tool.shows_pen_tips() {
|
||||||
let screen_x = canvas_rect.min.x + cx as f32 * zoom;
|
let screen_x = canvas_rect.min.x + cx as f32 * zoom;
|
||||||
let screen_y = canvas_rect.min.y + cy as f32 * zoom;
|
let screen_y = canvas_rect.min.y + cy as f32 * zoom;
|
||||||
let cursor_color = egui::Color32::from_rgba_unmultiplied(
|
|
||||||
self.state.primary_color[0],
|
// Build a live tip from the active tool config so the preview matches
|
||||||
self.state.primary_color[1],
|
// the Properties panel in real time.
|
||||||
self.state.primary_color[2],
|
let mut tip = hcie_engine_api::BrushTip::default();
|
||||||
120,
|
match self.state.active_tool {
|
||||||
);
|
Tool::Pen => {
|
||||||
let active_tip = self
|
tip.style = hcie_engine_api::BrushStyle::Round;
|
||||||
.state
|
tip.size = self.state.tool_configs.pen.size;
|
||||||
.brush_presets
|
tip.opacity = self.state.tool_configs.pen.opacity;
|
||||||
.iter()
|
tip.hardness = self.state.tool_configs.pen.hardness;
|
||||||
.find(|p| p.id == self.state.active_brush_preset_id)
|
}
|
||||||
.map(|p| &p.tip);
|
Tool::Eraser => {
|
||||||
if let Some(tip) = active_tip {
|
tip.style = hcie_engine_api::BrushStyle::Round;
|
||||||
match tip.style {
|
tip.size = self.state.tool_configs.eraser.size;
|
||||||
BrushStyle::Bitmap
|
tip.opacity = self.state.tool_configs.eraser.opacity;
|
||||||
if !tip.bitmap_pixels.is_empty()
|
tip.hardness = self.state.tool_configs.eraser.hardness;
|
||||||
&& tip.bitmap_w > 0
|
}
|
||||||
&& tip.bitmap_h > 0 =>
|
Tool::Spray => {
|
||||||
|
tip.style = hcie_engine_api::BrushStyle::Spray;
|
||||||
|
tip.size = self.state.tool_configs.spray.radius;
|
||||||
|
tip.opacity = self.state.tool_configs.spray.opacity;
|
||||||
|
tip.hardness = self.state.tool_configs.spray.hardness;
|
||||||
|
}
|
||||||
|
Tool::Brush | _ => {
|
||||||
|
tip.style = match self.state.tool_configs.brush.style {
|
||||||
|
hcie_engine_api::tools::BrushStyle::Default => hcie_engine_api::BrushStyle::Round,
|
||||||
|
hcie_engine_api::tools::BrushStyle::Round => hcie_engine_api::BrushStyle::Round,
|
||||||
|
hcie_engine_api::tools::BrushStyle::Square => hcie_engine_api::BrushStyle::Square,
|
||||||
|
hcie_engine_api::tools::BrushStyle::HardRound => hcie_engine_api::BrushStyle::HardRound,
|
||||||
|
hcie_engine_api::tools::BrushStyle::SoftRound => hcie_engine_api::BrushStyle::SoftRound,
|
||||||
|
hcie_engine_api::tools::BrushStyle::Noise => hcie_engine_api::BrushStyle::Noise,
|
||||||
|
hcie_engine_api::tools::BrushStyle::Texture => hcie_engine_api::BrushStyle::Texture,
|
||||||
|
hcie_engine_api::tools::BrushStyle::Spray => hcie_engine_api::BrushStyle::Spray,
|
||||||
|
hcie_engine_api::tools::BrushStyle::Pen => hcie_engine_api::BrushStyle::Pen,
|
||||||
|
hcie_engine_api::tools::BrushStyle::Oil => hcie_engine_api::BrushStyle::Oil,
|
||||||
|
hcie_engine_api::tools::BrushStyle::Charcoal => hcie_engine_api::BrushStyle::Charcoal,
|
||||||
|
hcie_engine_api::tools::BrushStyle::Leaf => hcie_engine_api::BrushStyle::Leaf,
|
||||||
|
hcie_engine_api::tools::BrushStyle::Rock => hcie_engine_api::BrushStyle::Rock,
|
||||||
|
hcie_engine_api::tools::BrushStyle::Meadow => hcie_engine_api::BrushStyle::Meadow,
|
||||||
|
hcie_engine_api::tools::BrushStyle::Wood => hcie_engine_api::BrushStyle::Wood,
|
||||||
|
hcie_engine_api::tools::BrushStyle::Watercolor => hcie_engine_api::BrushStyle::Watercolor,
|
||||||
|
hcie_engine_api::tools::BrushStyle::Calligraphy => hcie_engine_api::BrushStyle::Calligraphy,
|
||||||
|
hcie_engine_api::tools::BrushStyle::Marker => hcie_engine_api::BrushStyle::Marker,
|
||||||
|
hcie_engine_api::tools::BrushStyle::Sketch => hcie_engine_api::BrushStyle::Sketch,
|
||||||
|
hcie_engine_api::tools::BrushStyle::Hatch => hcie_engine_api::BrushStyle::Hatch,
|
||||||
|
hcie_engine_api::tools::BrushStyle::Star => hcie_engine_api::BrushStyle::Star,
|
||||||
|
hcie_engine_api::tools::BrushStyle::Glow => hcie_engine_api::BrushStyle::Glow,
|
||||||
|
hcie_engine_api::tools::BrushStyle::Airbrush => hcie_engine_api::BrushStyle::Airbrush,
|
||||||
|
hcie_engine_api::tools::BrushStyle::Pencil => hcie_engine_api::BrushStyle::Pencil,
|
||||||
|
hcie_engine_api::tools::BrushStyle::Crayon => hcie_engine_api::BrushStyle::Crayon,
|
||||||
|
hcie_engine_api::tools::BrushStyle::WetPaint => hcie_engine_api::BrushStyle::WetPaint,
|
||||||
|
hcie_engine_api::tools::BrushStyle::InkPen => hcie_engine_api::BrushStyle::InkPen,
|
||||||
|
hcie_engine_api::tools::BrushStyle::Clouds => hcie_engine_api::BrushStyle::Clouds,
|
||||||
|
hcie_engine_api::tools::BrushStyle::Dirt => hcie_engine_api::BrushStyle::Dirt,
|
||||||
|
hcie_engine_api::tools::BrushStyle::Tree => hcie_engine_api::BrushStyle::Tree,
|
||||||
|
hcie_engine_api::tools::BrushStyle::Bristle => hcie_engine_api::BrushStyle::Bristle,
|
||||||
|
hcie_engine_api::tools::BrushStyle::Mixer => hcie_engine_api::BrushStyle::Mixer,
|
||||||
|
hcie_engine_api::tools::BrushStyle::Blender => hcie_engine_api::BrushStyle::Blender,
|
||||||
|
hcie_engine_api::tools::BrushStyle::Bitmap => hcie_engine_api::BrushStyle::Bitmap,
|
||||||
|
};
|
||||||
|
tip.size = self.state.tool_configs.brush.size;
|
||||||
|
tip.opacity = self.state.tool_configs.brush.opacity;
|
||||||
|
tip.hardness = self.state.tool_configs.brush.hardness;
|
||||||
|
tip.spacing = self.state.tool_configs.brush.spacing;
|
||||||
|
tip.jitter_amount = self.state.tool_configs.brush.jitter_amount;
|
||||||
|
tip.scatter_amount = self.state.tool_configs.brush.scatter_amount;
|
||||||
|
tip.angle = self.state.tool_configs.brush.angle;
|
||||||
|
tip.roundness = self.state.tool_configs.brush.roundness;
|
||||||
|
tip.density = self.state.tool_configs.brush.density;
|
||||||
|
tip.color_variant = self.state.tool_configs.brush.color_variant;
|
||||||
|
tip.variant_amount = self.state.tool_configs.brush.variant_amount;
|
||||||
|
tip.drawing_angle = self.state.tool_configs.brush.drawing_angle;
|
||||||
|
tip.rotation_random = self.state.tool_configs.brush.rotation_random;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// For bitmap (ABR) brushes, copy the imported alpha mask from the active
|
||||||
|
// preset so the preview shows the actual brush shape, but keep the live
|
||||||
|
// size from the Properties panel so scaling works correctly.
|
||||||
|
let mut is_bitmap = false;
|
||||||
|
if tip.style == hcie_engine_api::BrushStyle::Bitmap {
|
||||||
|
if let Some(preset) = self
|
||||||
|
.state
|
||||||
|
.brush_presets
|
||||||
|
.iter()
|
||||||
|
.find(|p| p.id == self.state.active_brush_preset_id)
|
||||||
|
{
|
||||||
|
if !preset.tip.bitmap_pixels.is_empty()
|
||||||
|
&& preset.tip.bitmap_w > 0
|
||||||
|
&& preset.tip.bitmap_h > 0
|
||||||
{
|
{
|
||||||
let bw = tip.bitmap_w as f32;
|
tip.bitmap_pixels = preset.tip.bitmap_pixels.clone();
|
||||||
let bh = tip.bitmap_h as f32;
|
tip.bitmap_w = preset.tip.bitmap_w;
|
||||||
let stamp_size = self.state.active_size() * zoom;
|
tip.bitmap_h = preset.tip.bitmap_h;
|
||||||
let scale = (stamp_size / bw).min(stamp_size / bh);
|
// aspect ratio is preserved by scaling to the square stamp
|
||||||
let draw_w = bw * scale;
|
is_bitmap = true;
|
||||||
let draw_h = bh * scale;
|
|
||||||
let origin_x = screen_x - draw_w * 0.5;
|
|
||||||
let origin_y = screen_y - draw_h * 0.5;
|
|
||||||
let px_size = scale.max(1.0);
|
|
||||||
let mut shapes = Vec::new();
|
|
||||||
let mut py = 0.0;
|
|
||||||
let mut sy = 0u32;
|
|
||||||
while sy < tip.bitmap_h {
|
|
||||||
let mut px = 0.0;
|
|
||||||
let mut sx = 0u32;
|
|
||||||
while sx < tip.bitmap_w {
|
|
||||||
let mask = tip.bitmap_pixels
|
|
||||||
[(sy * tip.bitmap_w + sx) as usize]
|
|
||||||
as f32
|
|
||||||
/ 255.0;
|
|
||||||
if mask > 0.01 {
|
|
||||||
let a = (mask * 120.0).round() as u8;
|
|
||||||
let c = egui::Color32::from_rgba_unmultiplied(
|
|
||||||
cursor_color.r(),
|
|
||||||
cursor_color.g(),
|
|
||||||
cursor_color.b(),
|
|
||||||
a,
|
|
||||||
);
|
|
||||||
let p = egui::pos2(origin_x + px, origin_y + py);
|
|
||||||
shapes.push(egui::Shape::rect_filled(
|
|
||||||
egui::Rect::from_min_size(
|
|
||||||
p,
|
|
||||||
egui::vec2(px_size, px_size),
|
|
||||||
),
|
|
||||||
0.0,
|
|
||||||
c,
|
|
||||||
));
|
|
||||||
}
|
|
||||||
px += px_size;
|
|
||||||
sx += 1;
|
|
||||||
}
|
|
||||||
py += px_size;
|
|
||||||
sy += 1;
|
|
||||||
}
|
|
||||||
painter.add(egui::Shape::Vec(shapes));
|
|
||||||
}
|
|
||||||
BrushStyle::Round
|
|
||||||
| BrushStyle::HardRound
|
|
||||||
| BrushStyle::SoftRound => {
|
|
||||||
let radius = self.state.active_size() * zoom * 0.5;
|
|
||||||
let hardness = tip.hardness;
|
|
||||||
if hardness >= 0.95 {
|
|
||||||
painter.circle_stroke(
|
|
||||||
egui::pos2(screen_x, screen_y),
|
|
||||||
radius,
|
|
||||||
egui::Stroke::new(1.5, cursor_color),
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
let softness = 1.0 - hardness;
|
|
||||||
let layers = 5;
|
|
||||||
for i in 0..layers {
|
|
||||||
let frac = i as f32 / layers as f32;
|
|
||||||
let r = radius * (1.0 + softness * 0.3 * (1.0 - frac));
|
|
||||||
let a = (120.0 * (1.0 - frac * 0.6)) as u8;
|
|
||||||
let c = egui::Color32::from_rgba_unmultiplied(
|
|
||||||
cursor_color.r(),
|
|
||||||
cursor_color.g(),
|
|
||||||
cursor_color.b(),
|
|
||||||
a,
|
|
||||||
);
|
|
||||||
painter.circle_stroke(
|
|
||||||
egui::pos2(screen_x, screen_y),
|
|
||||||
r,
|
|
||||||
egui::Stroke::new(1.0, c),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
BrushStyle::Square => {
|
|
||||||
let radius = self.state.active_size() * zoom * 0.5;
|
|
||||||
let rect = egui::Rect::from_center_size(
|
|
||||||
egui::pos2(screen_x, screen_y),
|
|
||||||
egui::vec2(radius * 2.0, radius * 2.0),
|
|
||||||
);
|
|
||||||
painter.rect_stroke(
|
|
||||||
rect,
|
|
||||||
0.0,
|
|
||||||
egui::Stroke::new(1.5, cursor_color),
|
|
||||||
egui::StrokeKind::Outside,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
BrushStyle::Star | BrushStyle::Spray => {
|
|
||||||
let radius = self.state.active_size() * zoom * 1.6;
|
|
||||||
painter.circle_stroke(
|
|
||||||
egui::pos2(screen_x, screen_y),
|
|
||||||
radius,
|
|
||||||
egui::Stroke::new(1.0, cursor_color),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
BrushStyle::Clouds | BrushStyle::Tree => {
|
|
||||||
let radius = self.state.active_size() * zoom;
|
|
||||||
painter.circle_stroke(
|
|
||||||
egui::pos2(screen_x, screen_y),
|
|
||||||
radius,
|
|
||||||
egui::Stroke::new(1.0, cursor_color),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
_ => {
|
|
||||||
let radius = self.state.active_size() * zoom * 0.5;
|
|
||||||
painter.circle_stroke(
|
|
||||||
egui::pos2(screen_x, screen_y),
|
|
||||||
radius,
|
|
||||||
egui::Stroke::new(1.5, cursor_color),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
}
|
||||||
let radius = self.state.active_size() * zoom * 0.5;
|
|
||||||
painter.circle_stroke(
|
let display_size = self.state.active_size();
|
||||||
|
// active_size() returns the diameter of the brush.
|
||||||
|
// The preview texture must cover this diameter in screen space.
|
||||||
|
let diameter_px = display_size * zoom;
|
||||||
|
let tex_size = (diameter_px.ceil() as u32).clamp(1, 256);
|
||||||
|
|
||||||
|
// Cache key: hash all stamp-relevant tip fields + color + zoom + active preset ID
|
||||||
|
let mut hasher = std::collections::hash_map::DefaultHasher::new();
|
||||||
|
tip.style.hash(&mut hasher);
|
||||||
|
display_size.to_bits().hash(&mut hasher);
|
||||||
|
tip.hardness.to_bits().hash(&mut hasher);
|
||||||
|
tip.opacity.to_bits().hash(&mut hasher);
|
||||||
|
self.state.primary_color.hash(&mut hasher);
|
||||||
|
self.state.active_brush_preset_id.hash(&mut hasher);
|
||||||
|
zoom.to_bits().hash(&mut hasher);
|
||||||
|
if is_bitmap {
|
||||||
|
tip.bitmap_w.hash(&mut hasher);
|
||||||
|
tip.bitmap_h.hash(&mut hasher);
|
||||||
|
tip.bitmap_pixels.len().hash(&mut hasher);
|
||||||
|
if !tip.bitmap_pixels.is_empty() {
|
||||||
|
let end = tip.bitmap_pixels.len().min(256);
|
||||||
|
hasher.write(&tip.bitmap_pixels[..end]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let cache_hash = hasher.finish();
|
||||||
|
|
||||||
|
let cache_id = ui.id().with("cursor_stamp");
|
||||||
|
let cached_hash = ui.data(|d| d.get_temp::<u64>(cache_id));
|
||||||
|
|
||||||
|
if cached_hash != Some(cache_hash) {
|
||||||
|
let pixels = hcie_engine_api::Engine::render_brush_stamp_preview(
|
||||||
|
tex_size,
|
||||||
|
tex_size,
|
||||||
|
&tip,
|
||||||
|
self.state.primary_color,
|
||||||
|
);
|
||||||
|
let image = egui::ColorImage::from_rgba_unmultiplied(
|
||||||
|
[tex_size as usize, tex_size as usize],
|
||||||
|
&pixels,
|
||||||
|
);
|
||||||
|
let tex = ui.ctx().load_texture(
|
||||||
|
&format!("cursor_stamp_{:x}", cache_hash),
|
||||||
|
image,
|
||||||
|
egui::TextureOptions::LINEAR,
|
||||||
|
);
|
||||||
|
ui.data_mut(|d| {
|
||||||
|
d.insert_temp(cache_id, cache_hash);
|
||||||
|
d.insert_temp(ui.id().with("cursor_stamp_tex"), tex);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(tex) = ui
|
||||||
|
.data(|d| d.get_temp::<egui::TextureHandle>(ui.id().with("cursor_stamp_tex")))
|
||||||
|
{
|
||||||
|
// Display the stamp preview at the correct diameter.
|
||||||
|
// The texture was rendered at tex_size × tex_size pixels;
|
||||||
|
// display it at diameter_px × diameter_px screen pixels so
|
||||||
|
// the preview matches the actual brush footprint.
|
||||||
|
let display_dim = egui::vec2(diameter_px, diameter_px);
|
||||||
|
let rect = egui::Rect::from_center_size(
|
||||||
egui::pos2(screen_x, screen_y),
|
egui::pos2(screen_x, screen_y),
|
||||||
radius,
|
display_dim,
|
||||||
egui::Stroke::new(1.5, cursor_color),
|
);
|
||||||
|
painter.add(egui::Shape::image(
|
||||||
|
tex.id(),
|
||||||
|
rect,
|
||||||
|
egui::Rect::from_min_max(
|
||||||
|
egui::pos2(0.0, 0.0),
|
||||||
|
egui::pos2(1.0, 1.0),
|
||||||
|
),
|
||||||
|
egui::Color32::WHITE,
|
||||||
|
));
|
||||||
|
|
||||||
|
// Draw a thin crosshair at the center of the stamp preview
|
||||||
|
// so the user can see the exact brush center point.
|
||||||
|
let cross_color = egui::Color32::from_rgba_unmultiplied(200, 200, 200, 160);
|
||||||
|
let cross_len = 5.0_f32;
|
||||||
|
painter.line_segment(
|
||||||
|
[
|
||||||
|
egui::pos2(screen_x - cross_len, screen_y),
|
||||||
|
egui::pos2(screen_x + cross_len, screen_y),
|
||||||
|
],
|
||||||
|
egui::Stroke::new(1.0, cross_color),
|
||||||
|
);
|
||||||
|
painter.line_segment(
|
||||||
|
[
|
||||||
|
egui::pos2(screen_x, screen_y - cross_len),
|
||||||
|
egui::pos2(screen_x, screen_y + cross_len),
|
||||||
|
],
|
||||||
|
egui::Stroke::new(1.0, cross_color),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1888,8 +1952,14 @@ impl<'a> egui::Widget for CanvasWidget<'a> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Raster tool cursor preview (crosshair) ────────────────
|
// ── Raster tool cursor preview (crosshair + circle) ──────────
|
||||||
if self.state.active_tool.is_raster_compatible() || self.state.active_tool.is_ai_tool() {
|
// Only draw the circle+crosshair overlay for raster tools that do NOT
|
||||||
|
// already have a stamp-texture preview (those are handled by the
|
||||||
|
// "Brush cursor preview" block above). Tools with shows_pen_tips()
|
||||||
|
// already get a shaped stamp preview + crosshair there.
|
||||||
|
if (self.state.active_tool.is_raster_compatible() || self.state.active_tool.is_ai_tool())
|
||||||
|
&& !self.state.active_tool.shows_pen_tips()
|
||||||
|
{
|
||||||
if let Some(hover_pos) = ui.input(|i| i.pointer.hover_pos()) {
|
if let Some(hover_pos) = ui.input(|i| i.pointer.hover_pos()) {
|
||||||
if canvas_rect.contains(hover_pos) {
|
if canvas_rect.contains(hover_pos) {
|
||||||
if !matches!(self.state.active_tool, Tool::AiObjectRemoval) {
|
if !matches!(self.state.active_tool, Tool::AiObjectRemoval) {
|
||||||
@@ -2481,3 +2551,5 @@ fn find_text_layer_at(engine: &hcie_engine_api::Engine, x: f32, y: f32) -> Optio
|
|||||||
}
|
}
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -122,90 +122,100 @@ pub fn render_composition(
|
|||||||
_canvas_rect: egui::Rect,
|
_canvas_rect: egui::Rect,
|
||||||
_zoom: f32,
|
_zoom: f32,
|
||||||
) -> bool {
|
) -> bool {
|
||||||
|
// Commit any background-computed undo snapshots before compositing
|
||||||
|
let t_commit = std::time::Instant::now();
|
||||||
|
doc.engine.commit_pending_history();
|
||||||
|
let commit_elapsed = t_commit.elapsed().as_millis();
|
||||||
|
if commit_elapsed > 1 {
|
||||||
|
log::warn!("[PERF] commit_pending_history took {}ms", commit_elapsed);
|
||||||
|
}
|
||||||
|
|
||||||
|
let t_start = std::time::Instant::now();
|
||||||
|
|
||||||
let rw = doc.engine.canvas_width().max(1);
|
let rw = doc.engine.canvas_width().max(1);
|
||||||
let rh = doc.engine.canvas_height().max(1);
|
let rh = doc.engine.canvas_height().max(1);
|
||||||
|
|
||||||
let full_size = (rw * rh * 4) as usize;
|
let full_size = (rw * rh * 4) as usize;
|
||||||
let has_buffer = doc.composite_buffer.len() == full_size;
|
let has_buffer = doc.composite_buffer.len() == full_size;
|
||||||
|
let composite_dirty = doc.engine.is_composite_dirty();
|
||||||
let needs_render =
|
|
||||||
doc.engine.is_composite_dirty() || doc.composite_texture.is_none() || !has_buffer;
|
|
||||||
|
|
||||||
if !needs_render {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Skip expensive composite rendering during active drawing to prevent UI freezes.
|
|
||||||
// The composite will be rendered on the next frame after the stroke ends.
|
|
||||||
if state.is_drawing && doc.composite_texture.is_some() && has_buffer {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure buffer exists with correct size
|
// Ensure buffer exists with correct size
|
||||||
if !has_buffer {
|
if !has_buffer {
|
||||||
doc.composite_buffer = vec![0u8; full_size];
|
doc.composite_buffer = vec![0u8; full_size];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try region-based compositing first (partial update)
|
let needs_render = composite_dirty || doc.composite_texture.is_none() || !has_buffer;
|
||||||
let (region, buf_ptr, buf_size) = doc.engine.render_composite_region();
|
|
||||||
|
|
||||||
// Copy from engine's pooled internal buffer into our composite_buffer
|
// ── Drawing-time composite throttle ─────────────────────────────────
|
||||||
if !buf_ptr.is_null() && buf_size > 0 && buf_size == doc.composite_buffer.len() {
|
// During active drawing, composite every other frame to keep the stroke
|
||||||
unsafe {
|
// visible in real-time while giving the brush engine time to accumulate
|
||||||
std::ptr::copy_nonoverlapping(buf_ptr, doc.composite_buffer.as_mut_ptr(), buf_size);
|
// more dabs between composites. This replaces the old is_drawing guard
|
||||||
|
// that completely blocked compositing and made strokes invisible until
|
||||||
|
// mouse release.
|
||||||
|
//
|
||||||
|
// The counter resets to 0 when not drawing, so the first frame after
|
||||||
|
// stroke end always composites immediately.
|
||||||
|
if needs_render && state.is_drawing && doc.composite_texture.is_some() && has_buffer {
|
||||||
|
state.drawing_composite_skip_counter += 1;
|
||||||
|
if state.drawing_composite_skip_counter % 2 != 0 {
|
||||||
|
// Skip this frame, composite on the next one.
|
||||||
|
ctx.request_repaint();
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
state.drawing_composite_skip_counter = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If the engine reports no dirty region we may still need to create the
|
if needs_render {
|
||||||
// initial texture (e.g. a brand-new blank canvas). In that case the local
|
let t_composite_start = std::time::Instant::now();
|
||||||
// composite_buffer is already zero-filled, which is enough to show the
|
let (region_result, buf_ptr, buf_size) = doc.engine.render_composite_region();
|
||||||
// checkerboard background behind it.
|
let composite_ms = t_composite_start.elapsed().as_millis();
|
||||||
let region = match region {
|
|
||||||
Some([x0, y0, x1, y1]) if x1 > x0 && y1 > y0 => [x0, y0, x1, y1],
|
|
||||||
_ => [0, 0, rw, rh],
|
|
||||||
};
|
|
||||||
|
|
||||||
let options = egui::TextureOptions {
|
if !buf_ptr.is_null() && buf_size > 0 && buf_size == doc.composite_buffer.len() {
|
||||||
magnification: egui::TextureFilter::Nearest,
|
unsafe {
|
||||||
minification: egui::TextureFilter::Linear,
|
std::ptr::copy_nonoverlapping(buf_ptr, doc.composite_buffer.as_mut_ptr(), buf_size);
|
||||||
..Default::default()
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
// Tracks whether this call created a brand-new composite texture. egui
|
let region = match region_result {
|
||||||
// uploads GPU textures asynchronously, so the frame that creates the
|
Some([x0, y0, x1, y1]) if x1 > x0 && y1 > y0 => [x0, y0, x1, y1],
|
||||||
// texture will not yet display it. Returning `true` lets the caller issue
|
_ => [0, 0, rw, rh],
|
||||||
// an extra repaint so the texture appears without requiring a user click.
|
};
|
||||||
let mut created_new_texture = false;
|
|
||||||
|
|
||||||
if let Some(tex) = &mut doc.composite_texture {
|
let options = egui::TextureOptions {
|
||||||
let size = tex.size();
|
magnification: egui::TextureFilter::Nearest,
|
||||||
if size[0] == rw as usize && size[1] == rh as usize {
|
minification: egui::TextureFilter::Linear,
|
||||||
let region_w = (region[2] - region[0]) as usize;
|
..Default::default()
|
||||||
let region_h = (region[3] - region[1]) as usize;
|
};
|
||||||
if region_w == rw as usize && region_h == rh as usize {
|
|
||||||
// Full canvas update — fast path avoiding sub-region copy
|
// Partial texture upload for the dirty region only
|
||||||
|
if let Some(tex) = &mut doc.composite_texture {
|
||||||
|
let size = tex.size();
|
||||||
|
if size[0] == rw as usize && size[1] == rh as usize {
|
||||||
|
let region_w = (region[2] - region[0]) as usize;
|
||||||
|
let region_h = (region[3] - region[1]) as usize;
|
||||||
|
if region_w > 0 && region_h > 0 {
|
||||||
|
let mut region_pixels = vec![0u8; region_w * region_h * 4];
|
||||||
|
let x0 = region[0];
|
||||||
|
let y0 = region[1];
|
||||||
|
let y1 = region[3];
|
||||||
|
for y in y0..y1 {
|
||||||
|
let src_start = ((y * rw + x0) as usize) * 4;
|
||||||
|
let dst_start = ((y - y0) as usize * region_w) * 4;
|
||||||
|
let row_bytes = region_w * 4;
|
||||||
|
region_pixels[dst_start..dst_start + row_bytes]
|
||||||
|
.copy_from_slice(&doc.composite_buffer[src_start..src_start + row_bytes]);
|
||||||
|
}
|
||||||
|
let region_image =
|
||||||
|
egui::ColorImage::from_rgba_unmultiplied([region_w, region_h], ®ion_pixels);
|
||||||
|
tex.set_partial([x0 as usize, y0 as usize], region_image, options);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
let color_image = egui::ColorImage::from_rgba_unmultiplied(
|
let color_image = egui::ColorImage::from_rgba_unmultiplied(
|
||||||
[rw as usize, rh as usize],
|
[rw as usize, rh as usize],
|
||||||
&doc.composite_buffer,
|
&doc.composite_buffer,
|
||||||
);
|
);
|
||||||
tex.set(color_image, options);
|
tex.set(color_image, options);
|
||||||
} else if region_w > 0 && region_h > 0 {
|
|
||||||
// Partial texture upload: only the dirty sub-region
|
|
||||||
let mut region_pixels = vec![0u8; region_w * region_h * 4];
|
|
||||||
let x0 = region[0];
|
|
||||||
let y0 = region[1];
|
|
||||||
let _x1 = region[2];
|
|
||||||
let y1 = region[3];
|
|
||||||
for y in y0..y1 {
|
|
||||||
let src_start = ((y * rw + x0) as usize) * 4;
|
|
||||||
let dst_start = ((y - y0) as usize * region_w) * 4;
|
|
||||||
let row_bytes = region_w * 4;
|
|
||||||
region_pixels[dst_start..dst_start + row_bytes]
|
|
||||||
.copy_from_slice(&doc.composite_buffer[src_start..src_start + row_bytes]);
|
|
||||||
}
|
|
||||||
let region_image =
|
|
||||||
egui::ColorImage::from_rgba_unmultiplied([region_w, region_h], ®ion_pixels);
|
|
||||||
tex.set_partial([x0 as usize, y0 as usize], region_image, options);
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
let color_image = egui::ColorImage::from_rgba_unmultiplied(
|
let color_image = egui::ColorImage::from_rgba_unmultiplied(
|
||||||
@@ -213,20 +223,56 @@ pub fn render_composition(
|
|||||||
&doc.composite_buffer,
|
&doc.composite_buffer,
|
||||||
);
|
);
|
||||||
doc.composite_texture = Some(ctx.load_texture("composite-view", color_image, options));
|
doc.composite_texture = Some(ctx.load_texture("composite-view", color_image, options));
|
||||||
created_new_texture = true;
|
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
let color_image = egui::ColorImage::from_rgba_unmultiplied(
|
doc.thumbnails_dirty = true;
|
||||||
[rw as usize, rh as usize],
|
doc.engine.clear_dirty_flags();
|
||||||
&doc.composite_buffer,
|
|
||||||
);
|
let total_ms = t_start.elapsed().as_millis();
|
||||||
doc.composite_texture = Some(ctx.load_texture("composite-view", color_image, options));
|
if total_ms > 16 {
|
||||||
created_new_texture = true;
|
log::warn!(
|
||||||
|
"[render_composition] frame budget exceeded: composite={}ms, total={}ms, region=[{},{},{},{}], is_drawing={}",
|
||||||
|
composite_ms, total_ms, region[0], region[1], region[2], region[3], state.is_drawing
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Layer thumbnails: only regenerate when the engine reports the layer is dirty
|
// ── Step 3: Regenerate deferred thumbnails ──────────────────────────────
|
||||||
// or when we do not yet have a texture for it. The engine's `is_layer_dirty`
|
// Delay thumbnail generation until the user has stopped drawing for at least 250ms
|
||||||
// flag is cleared after each composite, so thumbnails stay cheap between frames.
|
// to prevent freezing between fast consecutive brush strokes.
|
||||||
|
if doc.thumbnails_dirty {
|
||||||
|
if state.is_drawing {
|
||||||
|
// Skip while actively drawing
|
||||||
|
} else if let Some(last_end) = doc.last_stroke_end_time {
|
||||||
|
let elapsed = last_end.elapsed();
|
||||||
|
let delay = std::time::Duration::from_millis(250);
|
||||||
|
if elapsed < delay {
|
||||||
|
// Not enough idle time yet. Request repaint at the exact moment
|
||||||
|
// the delay expires to perform the thumbnail update.
|
||||||
|
ctx.request_repaint_after(delay - elapsed);
|
||||||
|
} else {
|
||||||
|
// User has been idle for >=250ms. Regenerate now.
|
||||||
|
regenerate_thumbnails(ctx, doc);
|
||||||
|
doc.thumbnails_dirty = false;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Fallback: no stroke time recorded, regenerate immediately
|
||||||
|
regenerate_thumbnails(ctx, doc);
|
||||||
|
doc.thumbnails_dirty = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Regenerate layer thumbnails that were deferred from a previous frame.
|
||||||
|
///
|
||||||
|
/// # Purpose
|
||||||
|
/// Generates layer thumbnail textures for the layers panel. This is called
|
||||||
|
/// one frame after the composite to avoid cloning ~33MB per dirty layer
|
||||||
|
/// on the same frame as the composite.
|
||||||
|
fn regenerate_thumbnails(ctx: &egui::Context, doc: &mut AppDocument) {
|
||||||
|
let t_regen = std::time::Instant::now();
|
||||||
let layers = doc.engine.layer_infos();
|
let layers = doc.engine.layer_infos();
|
||||||
doc.layer_textures.retain(|k, _| *k < layers.len());
|
doc.layer_textures.retain(|k, _| *k < layers.len());
|
||||||
|
|
||||||
@@ -269,10 +315,10 @@ pub fn render_composition(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
let elapsed = t_regen.elapsed().as_millis();
|
||||||
doc.engine.clear_dirty_flags();
|
if elapsed > 1 {
|
||||||
|
log::warn!("[PERF] regenerate_thumbnails took {}ms", elapsed);
|
||||||
created_new_texture
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Compute shape-specific key handle positions in canvas space.
|
/// Compute shape-specific key handle positions in canvas space.
|
||||||
|
|||||||
@@ -7,9 +7,9 @@ use libloading::Library;
|
|||||||
|
|
||||||
/// Global vision backend preference mirrored into the dynamic `hcie-vision` plugin.
|
/// Global vision backend preference mirrored into the dynamic `hcie-vision` plugin.
|
||||||
/// 0 = CPU, 1 = GPU. Stored here so it can be set before the plugin is loaded.
|
/// 0 = CPU, 1 = GPU. Stored here so it can be set before the plugin is loaded.
|
||||||
static VISION_BACKEND: AtomicU8 = AtomicU8::new(0);
|
static VISION_BACKEND: AtomicU8 = AtomicU8::new(1);
|
||||||
|
|
||||||
fn map_brush_style(style: hcie_protocol::BrushStyle) -> hcie_brush_engine::BrushStyle {
|
pub fn map_brush_style(style: hcie_protocol::BrushStyle) -> hcie_brush_engine::BrushStyle {
|
||||||
match style {
|
match style {
|
||||||
hcie_protocol::BrushStyle::Round => hcie_brush_engine::BrushStyle::Round,
|
hcie_protocol::BrushStyle::Round => hcie_brush_engine::BrushStyle::Round,
|
||||||
hcie_protocol::BrushStyle::Square => hcie_brush_engine::BrushStyle::Square,
|
hcie_protocol::BrushStyle::Square => hcie_brush_engine::BrushStyle::Square,
|
||||||
|
|||||||
+134
-11
@@ -10,7 +10,7 @@ pub mod ffi;
|
|||||||
|
|
||||||
// Performance-critical engine paths isolated into dedicated modules.
|
// Performance-critical engine paths isolated into dedicated modules.
|
||||||
mod layer_property_ops;
|
mod layer_property_ops;
|
||||||
mod partial_composite;
|
pub mod partial_composite;
|
||||||
mod stroke_brush;
|
mod stroke_brush;
|
||||||
mod stroke_cache;
|
mod stroke_cache;
|
||||||
|
|
||||||
@@ -21,6 +21,7 @@ use crate::dynamic_loader::{
|
|||||||
composite_layers,
|
composite_layers,
|
||||||
apply_filter,
|
apply_filter,
|
||||||
load_image, save_image, import_psd, import_kra, save_native, load_native, export_psd, export_kra,
|
load_image, save_image, import_psd, import_kra, save_native, load_native, export_psd, export_kra,
|
||||||
|
map_brush_style,
|
||||||
};
|
};
|
||||||
use crate::dynamic_loader::svg_import::import_svg;
|
use crate::dynamic_loader::svg_import::import_svg;
|
||||||
use hcie_tile::TiledLayer;
|
use hcie_tile::TiledLayer;
|
||||||
@@ -133,12 +134,15 @@ fn protocol_effect_to_style(fx: &hcie_protocol::effects::LayerEffect) -> Option<
|
|||||||
/// a fresh allocation occurs. On `end_stroke()` the buffer is zeroed but
|
/// a fresh allocation occurs. On `end_stroke()` the buffer is zeroed but
|
||||||
/// **retained** (not set to `None`). This avoids ~8MB allocation per stroke.
|
/// **retained** (not set to `None`). This avoids ~8MB allocation per stroke.
|
||||||
///
|
///
|
||||||
/// - `stroke_before`: **NOT YET POOLED.** Currently `layer.pixels.clone()`
|
/// - `stroke_before` + `stroke_before_buf`: Pooled.
|
||||||
/// allocates a fresh ~33MB buffer on every `begin_stroke()`. This buffer is
|
/// At `begin_stroke()`, `layer.pixels` is copied into `stroke_before_buf`
|
||||||
/// then passed to `push_draw_snapshot()` on `end_stroke()` and owned by the
|
/// (no allocation after first use). At `end_stroke()`, the buffer is moved
|
||||||
/// history system. Pooling this buffer requires changes to both `Engine` and
|
/// into the background thread for sub-rect extraction, then returned via
|
||||||
/// `hcie-history` (the snapshot stores the buffer permanently, so a pool
|
/// `PendingHistoryItem.return_before` and recycled by `commit_pending_history()`.
|
||||||
/// would need a take-and-return mechanism). See PROGRESS.md item #4.
|
/// The "after" snapshot uses a zero-copy raw pointer (Layer 3): the background
|
||||||
|
/// thread reads directly from `layer.pixels` via a `SendPtr` wrapper, which
|
||||||
|
/// is safe because no mutations happen between `end_stroke()` and the next
|
||||||
|
/// `begin_stroke()`. This avoids ~66MB allocation per stroke on 4K canvases.
|
||||||
///
|
///
|
||||||
/// ## Merge Conflict Artifact Cleanup (2026-05-28)
|
/// ## Merge Conflict Artifact Cleanup (2026-05-28)
|
||||||
///
|
///
|
||||||
@@ -172,10 +176,17 @@ pub struct Engine {
|
|||||||
cyclic_speed: f32,
|
cyclic_speed: f32,
|
||||||
/// Pre-stroke snapshot for undo history: (layer_id, pixel_buffer, vector_shapes).
|
/// Pre-stroke snapshot for undo history: (layer_id, pixel_buffer, vector_shapes).
|
||||||
///
|
///
|
||||||
/// Allocated as `layer.pixels.clone()` (~33MB on 4K) on every `begin_stroke()`.
|
/// The pixel buffer is now **pooled** in `stroke_before_buf` to avoid a
|
||||||
/// Consumed by `push_draw_snapshot()` on `end_stroke()`. NOT pooled — see
|
/// ~33MB allocation per stroke on 4K canvases. Only the layer_id and
|
||||||
/// struct-level doc for pooling roadmap.
|
/// vector shapes are stored here; the pixel data lives in the pooled buffer.
|
||||||
stroke_before: Option<(u64, Vec<u8>, Option<Vec<VectorShape>>)>,
|
stroke_before: Option<(u64, Option<Vec<VectorShape>>)>,
|
||||||
|
/// Pooled buffer for the "before" snapshot pixel data.
|
||||||
|
///
|
||||||
|
/// Retained across strokes and reused if size matches. At `begin_stroke()`,
|
||||||
|
/// `layer.pixels` is copied into this buffer (no allocation after first use).
|
||||||
|
/// 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>>,
|
||||||
tile_layers: Vec<Option<TiledLayer>>,
|
tile_layers: Vec<Option<TiledLayer>>,
|
||||||
svg_sources: std::collections::HashMap<u64, Vec<u8>>,
|
svg_sources: std::collections::HashMap<u64, Vec<u8>>,
|
||||||
filter_preview_original: Option<Vec<u8>>,
|
filter_preview_original: Option<Vec<u8>>,
|
||||||
@@ -239,6 +250,9 @@ pub struct Engine {
|
|||||||
/// alloc+copy per pointer event. This cache clones it once at stroke start
|
/// alloc+copy per pointer event. This cache clones it once at stroke start
|
||||||
/// and all stroke functions reference it instead.
|
/// and all stroke functions reference it instead.
|
||||||
cached_selection_mask: Option<Vec<u8>>,
|
cached_selection_mask: Option<Vec<u8>>,
|
||||||
|
/// Thread-safe queue of history items computed on background threads.
|
||||||
|
/// Polled and committed on the UI thread via `commit_pending_history()`.
|
||||||
|
pub pending_history: std::sync::Arc<std::sync::Mutex<Vec<crate::stroke_cache::PendingHistoryItem>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Engine {
|
impl Engine {
|
||||||
@@ -295,6 +309,7 @@ impl Engine {
|
|||||||
cyclic_color: false,
|
cyclic_color: false,
|
||||||
cyclic_speed: 0.5,
|
cyclic_speed: 0.5,
|
||||||
stroke_before: None,
|
stroke_before: None,
|
||||||
|
stroke_before_buf: None,
|
||||||
tile_layers: Vec::new(),
|
tile_layers: Vec::new(),
|
||||||
svg_sources: std::collections::HashMap::new(),
|
svg_sources: std::collections::HashMap::new(),
|
||||||
filter_preview_original: None,
|
filter_preview_original: None,
|
||||||
@@ -307,6 +322,7 @@ impl Engine {
|
|||||||
raw_pixel_backup: std::collections::HashMap::new(),
|
raw_pixel_backup: std::collections::HashMap::new(),
|
||||||
below_cache_dirty: true,
|
below_cache_dirty: true,
|
||||||
cached_selection_mask: None,
|
cached_selection_mask: None,
|
||||||
|
pending_history: std::sync::Arc::new(std::sync::Mutex::new(Vec::new())),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1470,6 +1486,113 @@ impl Engine {
|
|||||||
preview.get_composite_pixels()
|
preview.get_composite_pixels()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Render a single brush-dab stamp into an RGBA buffer for use as a cursor preview.
|
||||||
|
///
|
||||||
|
/// **Purpose:** Provides a lightweight, pixel-accurate preview of the current brush
|
||||||
|
/// tip shape, size, hardness, opacity, and color without mutating any document.
|
||||||
|
///
|
||||||
|
/// **Logic & Workflow:**
|
||||||
|
/// 1. Clamp the requested dimensions to a safe range (1..=256).
|
||||||
|
/// 2. Use the brush engine's existing `generate_brush_stamp` to obtain the correct
|
||||||
|
/// alpha mask for the brush style (Round, Square, Star, Bitmap, etc.).
|
||||||
|
/// 3. For bitmap brushes, scale the imported alpha mask to the requested size.
|
||||||
|
/// 4. Multiply the stamp alpha by the color alpha, brush opacity, and a visibility
|
||||||
|
/// factor, then write the result into an RGBA buffer.
|
||||||
|
///
|
||||||
|
/// **Arguments:**
|
||||||
|
/// * `width`, `height` — Output buffer dimensions in pixels.
|
||||||
|
/// * `tip` — The `BrushTip` describing style, size, hardness, opacity, and bitmap data.
|
||||||
|
/// * `color` — The RGBA color to tint the preview.
|
||||||
|
///
|
||||||
|
/// **Returns:** A `Vec<u8>` of size `width * height * 4` containing RGBA pixels,
|
||||||
|
/// suitable for upload as an egui texture.
|
||||||
|
///
|
||||||
|
/// **Side Effects:** None; only allocates a small temporary stamp buffer.
|
||||||
|
pub fn render_brush_stamp_preview(width: u32, height: u32, tip: &BrushTip, color: [u8; 4]) -> Vec<u8> {
|
||||||
|
let w = width.clamp(1, 256) as usize;
|
||||||
|
let h = height.clamp(1, 256) as usize;
|
||||||
|
let mut buf = vec![0u8; w * h * 4];
|
||||||
|
|
||||||
|
let preview_alpha = (color[3] as f32 / 255.0) * tip.opacity * 0.85;
|
||||||
|
|
||||||
|
match tip.style {
|
||||||
|
BrushStyle::Bitmap if !tip.bitmap_pixels.is_empty() && tip.bitmap_w > 0 && tip.bitmap_h > 0 => {
|
||||||
|
let bw = tip.bitmap_w as f32;
|
||||||
|
let bh = tip.bitmap_h as f32;
|
||||||
|
for py in 0..h {
|
||||||
|
let map_y = (py as f32 / h as f32 * (bh - 1.0))
|
||||||
|
.round()
|
||||||
|
.clamp(0.0, bh - 1.0) as u32;
|
||||||
|
for px in 0..w {
|
||||||
|
let map_x = (px as f32 / w as f32 * (bw - 1.0))
|
||||||
|
.round()
|
||||||
|
.clamp(0.0, bw - 1.0) as u32;
|
||||||
|
let mask = tip.bitmap_pixels[(map_y * tip.bitmap_w + map_x) as usize] as f32
|
||||||
|
/ 255.0;
|
||||||
|
let a = (mask * preview_alpha * 255.0).round() as u8;
|
||||||
|
let idx = (py * w + px) * 4;
|
||||||
|
buf[idx] = color[0];
|
||||||
|
buf[idx + 1] = color[1];
|
||||||
|
buf[idx + 2] = color[2];
|
||||||
|
buf[idx + 3] = a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
// Build a procedural stamp via the brush engine. This correctly handles
|
||||||
|
// Round, Square, Star, HardRound, SoftRound, and falls back to Round for
|
||||||
|
// procedural styles whose visual identity comes from multi-dab strokes.
|
||||||
|
// Convert the protocol BrushTip to the engine's internal type.
|
||||||
|
let engine_tip = hcie_brush_engine::BrushTip {
|
||||||
|
style: map_brush_style(tip.style),
|
||||||
|
size: tip.size,
|
||||||
|
opacity: tip.opacity,
|
||||||
|
hardness: tip.hardness,
|
||||||
|
spacing: tip.spacing,
|
||||||
|
flow: tip.flow,
|
||||||
|
jitter_amount: tip.jitter_amount,
|
||||||
|
scatter_amount: tip.scatter_amount,
|
||||||
|
angle: tip.angle,
|
||||||
|
roundness: tip.roundness,
|
||||||
|
spray_particle_size: tip.spray_particle_size,
|
||||||
|
spray_density: tip.spray_density,
|
||||||
|
bitmap_pixels: tip.bitmap_pixels.clone(),
|
||||||
|
bitmap_w: tip.bitmap_w,
|
||||||
|
bitmap_h: tip.bitmap_h,
|
||||||
|
color_variant: tip.color_variant,
|
||||||
|
variant_amount: tip.variant_amount,
|
||||||
|
density: tip.density,
|
||||||
|
drawing_angle: false,
|
||||||
|
rotation_random: 0.0,
|
||||||
|
};
|
||||||
|
let stamp = hcie_brush_engine::generate_brush_stamp(&engine_tip);
|
||||||
|
let stamp_d = (stamp.len() as f32).sqrt().round() as usize;
|
||||||
|
if stamp_d == 0 {
|
||||||
|
return buf;
|
||||||
|
}
|
||||||
|
for py in 0..h {
|
||||||
|
let map_y = (py as f32 / h as f32 * (stamp_d as f32 - 1.0))
|
||||||
|
.round()
|
||||||
|
.clamp(0.0, stamp_d as f32 - 1.0) as usize;
|
||||||
|
for px in 0..w {
|
||||||
|
let map_x = (px as f32 / w as f32 * (stamp_d as f32 - 1.0))
|
||||||
|
.round()
|
||||||
|
.clamp(0.0, stamp_d as f32 - 1.0) as usize;
|
||||||
|
let mask = stamp[map_y * stamp_d + map_x] as f32 / 255.0;
|
||||||
|
let a = (mask * preview_alpha * 255.0).round() as u8;
|
||||||
|
let idx = (py * w + px) * 4;
|
||||||
|
buf[idx] = color[0];
|
||||||
|
buf[idx + 1] = color[1];
|
||||||
|
buf[idx + 2] = color[2];
|
||||||
|
buf[idx + 3] = a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
buf
|
||||||
|
}
|
||||||
|
|
||||||
pub fn rename_layer(&mut self, id: u64, name: &str) {
|
pub fn rename_layer(&mut self, id: u64, name: &str) {
|
||||||
if let Some(l) = self.document.get_layer_by_id_mut(id) {
|
if let Some(l) = self.document.get_layer_by_id_mut(id) {
|
||||||
l.name = name.to_string();
|
l.name = name.to_string();
|
||||||
|
|||||||
@@ -319,3 +319,103 @@ impl Engine {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Snapshot of layer data needed for background compositing.
|
||||||
|
///
|
||||||
|
/// # Purpose
|
||||||
|
/// Captures all state required to composite the canvas without borrowing
|
||||||
|
/// `&mut Engine`. The background thread uses this snapshot to compute the
|
||||||
|
/// composite independently of the UI thread.
|
||||||
|
///
|
||||||
|
/// # Logic & Workflow
|
||||||
|
/// 1. `Engine::create_composite_snapshot()` clones layer pixels, properties,
|
||||||
|
/// and tile data into this struct.
|
||||||
|
/// 2. `composite_from_snapshot()` reads the snapshot and produces the flat
|
||||||
|
/// RGBA composite buffer on a background thread.
|
||||||
|
pub struct CompositeSnapshot {
|
||||||
|
pub layers: Vec<hcie_protocol::Layer>,
|
||||||
|
pub tile_layers: Vec<Option<TiledLayer>>,
|
||||||
|
pub canvas_width: u32,
|
||||||
|
pub canvas_height: u32,
|
||||||
|
pub active_layer: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Engine {
|
||||||
|
/// Create a snapshot of the current layer state for background compositing.
|
||||||
|
///
|
||||||
|
/// # Purpose
|
||||||
|
/// Clones all layer pixels, properties, and tile data so a background
|
||||||
|
/// thread can compute the composite without borrowing the engine.
|
||||||
|
///
|
||||||
|
/// # Side Effects
|
||||||
|
/// Calls `apply_effects_and_sync_tiles()` to ensure dirty layers are
|
||||||
|
/// up-to-date before cloning. This mutates engine state but is safe
|
||||||
|
/// because it's called on the UI thread before spawning the background.
|
||||||
|
pub fn create_composite_snapshot(&mut self) -> CompositeSnapshot {
|
||||||
|
// Ensure all dirty layers are up-to-date before cloning
|
||||||
|
self.apply_effects_and_sync_tiles();
|
||||||
|
|
||||||
|
let layers = self.document.layers.clone();
|
||||||
|
let tile_layers = self.tile_layers.clone();
|
||||||
|
let canvas_width = self.document.canvas_width;
|
||||||
|
let canvas_height = self.document.canvas_height;
|
||||||
|
let active_layer = self.document.active_layer;
|
||||||
|
|
||||||
|
CompositeSnapshot {
|
||||||
|
layers,
|
||||||
|
tile_layers,
|
||||||
|
canvas_width,
|
||||||
|
canvas_height,
|
||||||
|
active_layer,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compute the flat composite RGBA buffer from a snapshot.
|
||||||
|
///
|
||||||
|
/// # Purpose
|
||||||
|
/// Performs the full canvas composite on a background thread using the
|
||||||
|
/// cloned layer data from `CompositeSnapshot`. This function is standalone
|
||||||
|
/// (not a method on `Engine`) so it can be called from a background thread
|
||||||
|
/// without borrowing the engine.
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
/// * `snapshot` — Cloned layer data from `create_composite_snapshot()`.
|
||||||
|
///
|
||||||
|
/// # Returns
|
||||||
|
/// A `Vec<u8>` of size `width * height * 4` containing the flat RGBA composite.
|
||||||
|
pub fn composite_from_snapshot(snapshot: CompositeSnapshot) -> Vec<u8> {
|
||||||
|
let w = snapshot.canvas_width;
|
||||||
|
let h = snapshot.canvas_height;
|
||||||
|
let buf_size = (w * h * 4) as usize;
|
||||||
|
|
||||||
|
// Build tile slices for the compositor
|
||||||
|
let tile_slice_len = snapshot.active_layer.min(snapshot.tile_layers.len());
|
||||||
|
let _visible_below: Vec<(usize, bool)> = snapshot.layers[..snapshot.active_layer]
|
||||||
|
.iter().enumerate().map(|(i, l)| (i, l.visible)).collect();
|
||||||
|
|
||||||
|
let mut buf = vec![0u8; buf_size];
|
||||||
|
|
||||||
|
// Composite below layers first (if any)
|
||||||
|
if snapshot.active_layer > 0 {
|
||||||
|
tiled::composite_tiled_into(
|
||||||
|
&snapshot.layers[..snapshot.active_layer],
|
||||||
|
&snapshot.tile_layers[..tile_slice_len],
|
||||||
|
w, h,
|
||||||
|
0, 0, w, h,
|
||||||
|
&mut buf,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Composite active layer and above
|
||||||
|
let tile_start = snapshot.active_layer.min(snapshot.tile_layers.len());
|
||||||
|
tiled::composite_tiled_into(
|
||||||
|
&snapshot.layers[snapshot.active_layer..],
|
||||||
|
&snapshot.tile_layers[tile_start..],
|
||||||
|
w, h,
|
||||||
|
0, 0, w, h,
|
||||||
|
&mut buf,
|
||||||
|
);
|
||||||
|
|
||||||
|
buf
|
||||||
|
}
|
||||||
|
|||||||
@@ -121,7 +121,7 @@ impl Engine {
|
|||||||
tip.spray_density,
|
tip.spray_density,
|
||||||
mask_ref,
|
mask_ref,
|
||||||
self.active_stroke_mask.as_deref_mut(),
|
self.active_stroke_mask.as_deref_mut(),
|
||||||
self.stroke_before.as_ref().map(|(_, px, _)| px.as_slice()),
|
self.stroke_before_buf.as_deref(),
|
||||||
tip.color_variant,
|
tip.color_variant,
|
||||||
tip.variant_amount,
|
tip.variant_amount,
|
||||||
tip.density,
|
tip.density,
|
||||||
@@ -134,8 +134,12 @@ impl Engine {
|
|||||||
layer.effects_dirty.store(true, std::sync::atomic::Ordering::Release);
|
layer.effects_dirty.store(true, std::sync::atomic::Ordering::Release);
|
||||||
}
|
}
|
||||||
let dirty_r = match tip.style {
|
let dirty_r = match tip.style {
|
||||||
BrushStyle::Star | BrushStyle::Spray => tip.size * 3.2,
|
// Star/Spray: spread was reduced to 0.65× so 1.8× covers the footprint.
|
||||||
BrushStyle::Clouds | BrushStyle::Tree => tip.size * 2.0,
|
BrushStyle::Star | BrushStyle::Spray => tip.size * 1.8,
|
||||||
|
// Meadow/Leaf draw blades/leaves upward from center — asymmetric extent.
|
||||||
|
BrushStyle::Meadow | BrushStyle::Leaf => tip.size * 3.0,
|
||||||
|
// Clouds/Tree scatter particles in a wide area.
|
||||||
|
BrushStyle::Clouds | BrushStyle::Tree => tip.size * 2.5,
|
||||||
_ => tip.size.max(2.0),
|
_ => tip.size.max(2.0),
|
||||||
};
|
};
|
||||||
self.document.expand_dirty(lx as u32, ly as u32, dirty_r);
|
self.document.expand_dirty(lx as u32, ly as u32, dirty_r);
|
||||||
@@ -159,7 +163,7 @@ impl Engine {
|
|||||||
self.is_eraser,
|
self.is_eraser,
|
||||||
mask_ref,
|
mask_ref,
|
||||||
self.active_stroke_mask.as_deref_mut(),
|
self.active_stroke_mask.as_deref_mut(),
|
||||||
self.stroke_before.as_ref().map(|(_, px, _)| px.as_slice()),
|
self.stroke_before_buf.as_deref(),
|
||||||
);
|
);
|
||||||
layer.dirty = true;
|
layer.dirty = true;
|
||||||
// Avoid triggering the expensive effects pass for layers that
|
// Avoid triggering the expensive effects pass for layers that
|
||||||
@@ -267,8 +271,12 @@ impl Engine {
|
|||||||
self.document.composite_dirty = true;
|
self.document.composite_dirty = true;
|
||||||
self.document.modified = true;
|
self.document.modified = true;
|
||||||
let dirty_r = match tip.style {
|
let dirty_r = match tip.style {
|
||||||
BrushStyle::Star | BrushStyle::Spray => tip.size * 3.2,
|
// Star/Spray: spread was reduced to 0.65× so 1.8× covers the footprint.
|
||||||
BrushStyle::Clouds | BrushStyle::Tree => tip.size * 2.0,
|
BrushStyle::Star | BrushStyle::Spray => tip.size * 1.8,
|
||||||
|
// Meadow/Leaf draw blades/leaves upward from center — asymmetric extent.
|
||||||
|
BrushStyle::Meadow | BrushStyle::Leaf => tip.size * 3.0,
|
||||||
|
// Clouds/Tree scatter particles in a wide area.
|
||||||
|
BrushStyle::Clouds | BrushStyle::Tree => tip.size * 2.5,
|
||||||
_ => tip.size.max(2.0),
|
_ => tip.size.max(2.0),
|
||||||
};
|
};
|
||||||
for &(px, py, _) in points {
|
for &(px, py, _) in points {
|
||||||
|
|||||||
@@ -25,23 +25,97 @@ use hcie_tile::TiledLayer;
|
|||||||
use crate::Engine;
|
use crate::Engine;
|
||||||
use crate::dynamic_loader::tiled;
|
use crate::dynamic_loader::tiled;
|
||||||
|
|
||||||
|
/// Wrapper to send a raw pixel pointer to a background thread as a `usize`.
|
||||||
|
///
|
||||||
|
/// # Safety Invariant
|
||||||
|
///
|
||||||
|
/// The pointer is valid for the entire lifetime of the `Engine`. The background
|
||||||
|
/// thread only reads from it, and no mutations happen to `layer.pixels` between
|
||||||
|
/// `end_stroke()` and the next `begin_stroke()`.
|
||||||
|
struct SendPtr(usize);
|
||||||
|
impl SendPtr {
|
||||||
|
#[inline]
|
||||||
|
fn new(p: *const u8) -> Self { Self(p as usize) }
|
||||||
|
#[inline]
|
||||||
|
fn as_ptr(&self) -> *const u8 { self.0 as *const u8 }
|
||||||
|
}
|
||||||
|
// SAFETY: The usize encodes a pointer to layer.pixels which is stable for the engine lifetime.
|
||||||
|
// The background thread only reads, never writes.
|
||||||
|
unsafe impl Send for SendPtr {}
|
||||||
|
|
||||||
|
pub struct PendingHistoryItem {
|
||||||
|
pub layer_idx: usize,
|
||||||
|
pub before_pixels: Vec<u8>,
|
||||||
|
pub after_pixels: Vec<u8>,
|
||||||
|
pub bounds: Option<[u32; 4]>,
|
||||||
|
pub before_shapes: Option<Vec<hcie_protocol::VectorShape>>,
|
||||||
|
pub after_shapes: Option<Vec<hcie_protocol::VectorShape>>,
|
||||||
|
pub description: String,
|
||||||
|
/// Full-layer before buffer returned from background thread for pool reuse.
|
||||||
|
pub return_before: Option<Vec<u8>>,
|
||||||
|
/// Full-layer after buffer returned from background thread for pool reuse.
|
||||||
|
pub return_after: Option<Vec<u8>>,
|
||||||
|
}
|
||||||
|
|
||||||
impl Engine {
|
impl Engine {
|
||||||
|
/// Commit any pending history items computed in the background thread.
|
||||||
|
/// This should be polled on the UI thread.
|
||||||
|
///
|
||||||
|
/// Also recycles returned buffers back into the engine's pool to avoid
|
||||||
|
/// allocations on subsequent strokes.
|
||||||
|
pub fn commit_pending_history(&mut self) {
|
||||||
|
let items = {
|
||||||
|
let mut pending = self.pending_history.lock().unwrap();
|
||||||
|
if pending.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
std::mem::take(&mut *pending)
|
||||||
|
};
|
||||||
|
for item in items {
|
||||||
|
// Recycle returned before buffer back into the pool
|
||||||
|
if let Some(buf) = item.return_before {
|
||||||
|
if self.stroke_before_buf.as_ref().map_or(true, |b| b.len() != buf.len()) {
|
||||||
|
self.stroke_before_buf = Some(buf);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(bounds) = item.bounds {
|
||||||
|
self.document.push_draw_snapshot_subrect(
|
||||||
|
item.layer_idx,
|
||||||
|
item.before_pixels,
|
||||||
|
item.after_pixels,
|
||||||
|
(bounds[0], bounds[1], bounds[2], bounds[3]),
|
||||||
|
item.description,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// If bounds are None, perform full-layer snapshot using the cached after pixels.
|
||||||
|
// We use document's internal layers state to push the action directly.
|
||||||
|
self.document.push_draw_snapshot(
|
||||||
|
item.layer_idx,
|
||||||
|
item.before_pixels,
|
||||||
|
item.before_shapes,
|
||||||
|
item.description,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Begin a new brush stroke on the given layer.
|
/// Begin a new brush stroke on the given layer.
|
||||||
///
|
///
|
||||||
/// # Allocation Behaviour
|
/// # Allocation Behaviour
|
||||||
///
|
///
|
||||||
/// This function performs two allocations per call:
|
/// This function performs two pooled buffer operations per call:
|
||||||
///
|
///
|
||||||
/// 1. `active_stroke_mask` — **Pooled**: if the existing buffer matches
|
/// 1. `active_stroke_mask` — **Pooled**: if the existing buffer matches
|
||||||
/// `layer_size`, it is zeroed in-place with `fill(0)`. Only if the
|
/// `layer_size`, it is zeroed in-place with `fill(0)`. Only if the
|
||||||
/// canvas dimensions changed does a fresh `vec![0u8; layer_size]`
|
/// canvas dimensions changed does a fresh `vec![0u8; layer_size]`
|
||||||
/// allocation occur (~8MB on 4K). The buffer is retained across strokes.
|
/// allocation occur (~8MB on 4K). The buffer is retained across strokes.
|
||||||
///
|
///
|
||||||
/// 2. `stroke_before` — **NOT pooled**: `layer.pixels.clone()` allocates a
|
/// 2. `stroke_before_buf` — **Pooled**: `layer.pixels` is copied into the
|
||||||
/// fresh ~33MB buffer on every call. This buffer is consumed by
|
/// existing buffer via `copy_from_slice` (no allocation after first use).
|
||||||
/// `push_draw_snapshot()` during `end_stroke()` and becomes owned by the
|
/// If the buffer doesn't exist or has wrong size, a fresh allocation
|
||||||
/// undo history. Pooling would require the history crate to
|
/// occurs (~33MB on 4K). The buffer is returned to the pool after the
|
||||||
/// participate in a buffer return mechanism. See struct-level doc.
|
/// background thread extracts the sub-rect snapshot.
|
||||||
///
|
///
|
||||||
/// # Risk
|
/// # Risk
|
||||||
///
|
///
|
||||||
@@ -53,7 +127,8 @@ impl Engine {
|
|||||||
self.last_stroke_pos = Some((x, y, 1.0));
|
self.last_stroke_pos = Some((x, y, 1.0));
|
||||||
self.sketch_history.clear();
|
self.sketch_history.clear();
|
||||||
if let Some(layer) = self.document.get_layer_by_id(layer_id) {
|
if let Some(layer) = self.document.get_layer_by_id(layer_id) {
|
||||||
let layer_size = (layer.width * layer.height) as usize;
|
let layer_pixels = (layer.width * layer.height * 4) as usize; // RGBA byte size
|
||||||
|
let layer_size = (layer.width * layer.height) as usize; // pixel count (for mask)
|
||||||
|
|
||||||
// Pool active_stroke_mask: reuse buffer if size matches, otherwise allocate.
|
// Pool active_stroke_mask: reuse buffer if size matches, otherwise allocate.
|
||||||
// This avoids a ~16MB allocation per stroke on a 4K canvas.
|
// This avoids a ~16MB allocation per stroke on a 4K canvas.
|
||||||
@@ -80,13 +155,22 @@ impl Engine {
|
|||||||
let y1 = ((iy + dr + 1).min(layer.height as i32 - 1)).max(0) as u32;
|
let y1 = ((iy + dr + 1).min(layer.height as i32 - 1)).max(0) as u32;
|
||||||
self.last_stroke_bounds = Some([x0, y0, x1, y1]);
|
self.last_stroke_bounds = Some([x0, y0, x1, y1]);
|
||||||
|
|
||||||
let before = layer.pixels.clone();
|
// 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);
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
self.stroke_before_buf = Some(layer.pixels.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
let before_shapes = if let LayerData::Vector { shapes } = &layer.data {
|
let before_shapes = if let LayerData::Vector { shapes } = &layer.data {
|
||||||
Some(shapes.clone())
|
Some(shapes.clone())
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
};
|
};
|
||||||
self.stroke_before = Some((layer_id, before, before_shapes));
|
self.stroke_before = Some((layer_id, before_shapes));
|
||||||
|
|
||||||
// Cache selection mask once at stroke start to avoid ~8MB clone per stroke_to().
|
// Cache selection mask once at stroke start to avoid ~8MB clone per stroke_to().
|
||||||
self.cached_selection_mask = self.document.selection_mask.clone();
|
self.cached_selection_mask = self.document.selection_mask.clone();
|
||||||
@@ -98,79 +182,122 @@ impl Engine {
|
|||||||
|
|
||||||
/// End the current brush stroke and commit a sub-rect undo snapshot.
|
/// End the current brush stroke and commit a sub-rect undo snapshot.
|
||||||
///
|
///
|
||||||
/// Uses `last_stroke_bounds` to extract only the changed region (~100KB on a
|
/// Offloads the expensive sub-rect copy and vector comparison to a background
|
||||||
/// 4K canvas with a typical brush) instead of cloning the entire 33MB layer.
|
/// thread using `std::thread::spawn` so the UI remains completely responsive and free of pauses.
|
||||||
/// The full `stroke_before` buffer is still used for draw_brush_stroke
|
|
||||||
/// reference, but the history snapshot is sub-rect.
|
|
||||||
pub fn end_stroke(&mut self, layer_id: u64) {
|
pub fn end_stroke(&mut self, layer_id: u64) {
|
||||||
log::trace!(
|
log::trace!(
|
||||||
"[end_stroke] layer_id={}, stroke_before={}, below_cache={}, below_cache_active_idx={:?}, last_stroke_bounds={:?}",
|
"[end_stroke_async] layer_id={}, stroke_before={}, below_cache={}, below_cache_active_idx={:?}, last_stroke_bounds={:?}",
|
||||||
layer_id,
|
layer_id,
|
||||||
self.stroke_before.is_some(),
|
self.stroke_before.is_some(),
|
||||||
self.below_cache.is_some(),
|
self.below_cache.is_some(),
|
||||||
self.below_cache_active_idx,
|
self.below_cache_active_idx,
|
||||||
self.last_stroke_bounds
|
self.last_stroke_bounds
|
||||||
);
|
);
|
||||||
if let Some((id, before, before_shapes)) = self.stroke_before.take() {
|
if let Some((id, before_shapes)) = self.stroke_before.take() {
|
||||||
if id == layer_id {
|
if id == layer_id {
|
||||||
if let Some(idx) = self.document.layer_index_by_id(layer_id) {
|
if let Some(idx) = self.document.layer_index_by_id(layer_id) {
|
||||||
if let Some([sx0, sy0, sx1, sy1]) = self.last_stroke_bounds {
|
let layer = &self.document.layers[idx];
|
||||||
if sx0 < sx1 && sy0 < sy1 {
|
let lw = layer.width;
|
||||||
let layer = &self.document.layers[idx];
|
let layer_pixels = (layer.width * layer.height * 4) as usize; // RGBA byte size
|
||||||
let lw = layer.width;
|
|
||||||
let rw = sx1 - sx0;
|
// Take the pooled before buffer (no allocation)
|
||||||
let rh = sy1 - sy0;
|
log::debug!("[end_stroke] layer_pixels={}, stroke_before_buf={:?}", layer_pixels, self.stroke_before_buf.as_ref().map(|b| b.len()));
|
||||||
let rect_size = (rw * rh * 4) as usize;
|
let before = match self.stroke_before_buf.take() {
|
||||||
let mut before_rect = vec![0u8; rect_size];
|
Some(buf) if buf.len() == layer_pixels => buf,
|
||||||
let mut after_rect = vec![0u8; rect_size];
|
_ => vec![0u8; layer_pixels],
|
||||||
for row in 0..rh {
|
};
|
||||||
let src_start = (((sy0 + row) * lw + sx0) * 4) as usize;
|
log::debug!("[end_stroke] before.len()={}, lw={}", before.len(), lw);
|
||||||
let dst_start = (row * rw * 4) as usize;
|
|
||||||
let len = (rw * 4) as usize;
|
// Layer 3: Zero-copy after-snapshot via raw pointer.
|
||||||
before_rect[dst_start..dst_start + len]
|
// SAFETY: After end_stroke(), no mutations happen to layer.pixels
|
||||||
.copy_from_slice(&before[src_start..src_start + len]);
|
// until the next begin_stroke(). The background thread only reads.
|
||||||
after_rect[dst_start..dst_start + len]
|
let after_ptr = SendPtr::new(layer.pixels.as_ptr());
|
||||||
.copy_from_slice(&layer.pixels[src_start..src_start + len]);
|
let after_len = layer.pixels.len();
|
||||||
|
|
||||||
|
let after_shapes = if let LayerData::Vector { shapes } = &layer.data {
|
||||||
|
Some(shapes.clone())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
let bounds = self.last_stroke_bounds;
|
||||||
|
let pending_history = self.pending_history.clone();
|
||||||
|
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let t_start = std::time::Instant::now();
|
||||||
|
// SAFETY: after_ptr points to layer.pixels which is valid for the
|
||||||
|
// engine lifetime. No mutations happen between end_stroke() and next begin_stroke().
|
||||||
|
let after_slice = unsafe { std::slice::from_raw_parts(after_ptr.as_ptr(), after_len) };
|
||||||
|
let mut item = PendingHistoryItem {
|
||||||
|
layer_idx: idx,
|
||||||
|
before_pixels: Vec::new(),
|
||||||
|
after_pixels: Vec::new(),
|
||||||
|
bounds: None,
|
||||||
|
before_shapes,
|
||||||
|
after_shapes,
|
||||||
|
description: "Brush Stroke".to_string(),
|
||||||
|
return_before: None,
|
||||||
|
return_after: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
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;
|
||||||
|
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];
|
||||||
|
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;
|
||||||
|
before_rect[dst_start..dst_start + len]
|
||||||
|
.copy_from_slice(&before[src_start..src_start + len]);
|
||||||
|
after_rect[dst_start..dst_start + len]
|
||||||
|
.copy_from_slice(&after_slice[src_start..src_start + len]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if before_rect != after_rect {
|
||||||
|
item.before_pixels = before_rect;
|
||||||
|
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);
|
||||||
|
pending_history.lock().unwrap().push(item);
|
||||||
|
}
|
||||||
|
log::trace!("[end_stroke_async] Background snapshot comparison took {}ms", t_start.elapsed().as_millis());
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
self.document.push_draw_snapshot_subrect(
|
|
||||||
idx, before_rect, after_rect,
|
|
||||||
(sx0, sy0, sx1, sy1),
|
|
||||||
"Brush Stroke".to_string(),
|
|
||||||
);
|
|
||||||
// Stroke changed layer.pixels — invalidate effects backup so
|
|
||||||
// the next style edit captures the post-stroke raw pixels.
|
|
||||||
self.raw_pixel_backup.remove(&layer_id);
|
|
||||||
self.last_stroke_bounds = None;
|
|
||||||
self.last_stroke_pos = None;
|
|
||||||
// The active layer's pixels changed during the stroke, but
|
|
||||||
// the layers *below* the active layer did not. Retain the
|
|
||||||
// below_cache snapshot and mark it valid so the next
|
|
||||||
// begin_stroke on the same active layer can reuse it.
|
|
||||||
self.below_cache_dirty = false;
|
|
||||||
log::trace!("[end_stroke] sub-rect snapshot done, below_cache retained for reuse");
|
|
||||||
if let Some(mask) = &mut self.active_stroke_mask {
|
|
||||||
mask.fill(0);
|
|
||||||
}
|
|
||||||
self.cached_selection_mask = None;
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
self.document.push_draw_snapshot(
|
// Fallback: use full buffers as pixels (no sub-rect optimization)
|
||||||
idx, before, before_shapes,
|
item.before_pixels = before;
|
||||||
"Brush Stroke".to_string(),
|
// SAFETY: after_slice is valid for the engine lifetime.
|
||||||
);
|
// Copy into owned memory for PendingHistoryItem.
|
||||||
// Stroke changed layer.pixels — invalidate effects backup.
|
item.after_pixels = after_slice.to_vec();
|
||||||
|
pending_history.lock().unwrap().push(item);
|
||||||
|
log::trace!("[end_stroke_async] Background fallback snapshot comparison took {}ms", t_start.elapsed().as_millis());
|
||||||
|
});
|
||||||
|
|
||||||
|
// Stroke changed layer.pixels — invalidate effects backup so
|
||||||
|
// the next style edit captures the post-stroke raw pixels.
|
||||||
self.raw_pixel_backup.remove(&layer_id);
|
self.raw_pixel_backup.remove(&layer_id);
|
||||||
|
self.last_stroke_bounds = None;
|
||||||
|
self.last_stroke_pos = None;
|
||||||
|
self.below_cache_dirty = false;
|
||||||
|
if let Some(mask) = &mut self.active_stroke_mask {
|
||||||
|
mask.fill(0);
|
||||||
|
}
|
||||||
|
self.cached_selection_mask = None;
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
self.last_stroke_pos = None;
|
self.last_stroke_pos = None;
|
||||||
self.last_stroke_bounds = None;
|
self.last_stroke_bounds = None;
|
||||||
// Retain the below_cache snapshot across strokes: the layers below the
|
|
||||||
// active layer did not change, so the cache is still valid for the
|
|
||||||
// same active layer index.
|
|
||||||
self.below_cache_dirty = false;
|
self.below_cache_dirty = false;
|
||||||
log::trace!("[end_stroke] fallback cleanup done, below_cache retained for reuse");
|
log::trace!("[end_stroke_async] fallback cleanup done, below_cache retained for reuse");
|
||||||
if let Some(mask) = &mut self.active_stroke_mask {
|
if let Some(mask) = &mut self.active_stroke_mask {
|
||||||
mask.fill(0);
|
mask.fill(0);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user