a2264b130d
- 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.
858 lines
31 KiB
Rust
858 lines
31 KiB
Rust
//! Composite texture rendering — dirty-region compositing, layer thumbnails, vector handles,
|
|
//! selection overlay, and transform overlay.
|
|
|
|
use crate::app::{AppDocument, SelectionTransform, ToolState, TransformHandle};
|
|
use eframe::egui;
|
|
use hcie_engine_api::thumbnail_nearest;
|
|
use hcie_engine_api::{LayerData, VectorEditHandle, VectorShape};
|
|
use std::sync::Arc;
|
|
|
|
const THUMB_W: usize = 32;
|
|
const THUMB_H: usize = 24;
|
|
|
|
/// Shared cached selection-edge data used by [`draw_selection_overlay`].
|
|
///
|
|
/// # Purpose
|
|
/// Avoids re-scanning the entire 4K selection mask every frame to find
|
|
/// marching-ants edges. The edges are recomputed only when the mask changes
|
|
/// (detected by pointer/len comparison). The cached `Vec` is shared behind an
|
|
/// `Arc` so the overlay renderer can cheaply clone it each frame.
|
|
#[derive(Clone, Default)]
|
|
pub struct SelectionEdgeCache {
|
|
/// Pointer to the mask buffer that produced the cached edges. We use the
|
|
/// raw pointer plus length because the engine returns `Option<&[u8]>` and
|
|
/// we cannot hold a borrow across frames.
|
|
mask_ptr: usize,
|
|
mask_len: usize,
|
|
/// Cached edge line segments as `(x1, y1, x2, y2)` in canvas pixels.
|
|
edges: Arc<Vec<(u32, u32, u32, u32)>>,
|
|
}
|
|
|
|
impl SelectionEdgeCache {
|
|
/// Return the cached edge list if `mask` is the same buffer as last call.
|
|
pub fn get(&self, mask: &[u8]) -> Option<Arc<Vec<(u32, u32, u32, u32)>>> {
|
|
if mask.as_ptr() as usize == self.mask_ptr && mask.len() == self.mask_len {
|
|
Some(Arc::clone(&self.edges))
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
|
|
/// Rebuild and cache the edge list for the given mask.
|
|
pub fn rebuild(&mut self, mask: &[u8], w: u32, h: u32) -> Arc<Vec<(u32, u32, u32, u32)>> {
|
|
let edges = Arc::new(extract_selection_edges(mask, w, h));
|
|
self.mask_ptr = mask.as_ptr() as usize;
|
|
self.mask_len = mask.len();
|
|
self.edges = Arc::clone(&edges);
|
|
edges
|
|
}
|
|
}
|
|
|
|
/// Extract the marching-ants edge segments from a binary-ish selection mask.
|
|
///
|
|
/// # Logic & Workflow
|
|
/// For every pixel whose mask value is above the threshold, inspect the four
|
|
/// neighbours. If a neighbour is outside the canvas or below the threshold,
|
|
/// emit a line segment representing the border between selected and unselected
|
|
/// pixels.
|
|
pub fn extract_selection_edges(
|
|
selection_mask: &[u8],
|
|
canvas_width: u32,
|
|
canvas_height: u32,
|
|
) -> Vec<(u32, u32, u32, u32)> {
|
|
let w = canvas_width as usize;
|
|
let h = canvas_height as usize;
|
|
if selection_mask.len() != w * h {
|
|
return Vec::new();
|
|
}
|
|
|
|
let threshold: u8 = 128;
|
|
let mut edges = Vec::with_capacity(1024);
|
|
for y in 0..h {
|
|
for x in 0..w {
|
|
let idx = y * w + x;
|
|
if selection_mask[idx] <= threshold {
|
|
continue;
|
|
}
|
|
// Left edge
|
|
if x == 0 || selection_mask[idx - 1] <= threshold {
|
|
edges.push((x as u32, y as u32, x as u32, (y + 1) as u32));
|
|
}
|
|
// Right edge
|
|
if x == w - 1 || selection_mask[idx + 1] <= threshold {
|
|
edges.push(((x + 1) as u32, y as u32, (x + 1) as u32, (y + 1) as u32));
|
|
}
|
|
// Top edge
|
|
if y == 0 || selection_mask[idx - w] <= threshold {
|
|
edges.push((x as u32, y as u32, (x + 1) as u32, y as u32));
|
|
}
|
|
// Bottom edge
|
|
if y == h - 1 || selection_mask[idx + w] <= threshold {
|
|
edges.push((x as u32, (y + 1) as u32, (x + 1) as u32, (y + 1) as u32));
|
|
}
|
|
}
|
|
}
|
|
edges
|
|
}
|
|
|
|
/// # Purpose
|
|
/// Composites the engine output into the cached `composite_buffer` and uploads it to
|
|
/// the `composite_texture` egui texture handle, performing incremental dirty-region
|
|
/// uploads when possible.
|
|
///
|
|
/// # Logic & Workflow
|
|
/// 1. Detects whether a full re-render is required (engine composite dirty flag,
|
|
/// missing/size-mismatched texture, or missing/size-mismatched buffer).
|
|
/// 2. Allocates the composite buffer if absent and copies the engine's pooled
|
|
/// composite buffer into it.
|
|
/// 3. Uploads the full canvas or the dirty sub-region into the texture handle,
|
|
/// creating the handle on first use.
|
|
/// 4. Regenerates layer thumbnails only for layers the engine reports as dirty.
|
|
/// 5. Clears the engine dirty flags so subsequent frames skip this work.
|
|
///
|
|
/// # Returns
|
|
/// `true` when a brand-new composite texture was created during this call. The
|
|
/// caller uses this to request an extra repaint, which is required because egui
|
|
/// uploads textures asynchronously — the first frame after texture creation would
|
|
/// otherwise show a blank canvas until the next user interaction triggers a repaint.
|
|
pub fn render_composition(
|
|
ctx: &egui::Context,
|
|
doc: &mut AppDocument,
|
|
state: &mut ToolState,
|
|
_canvas_rect: egui::Rect,
|
|
_zoom: f32,
|
|
) -> 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 rh = doc.engine.canvas_height().max(1);
|
|
|
|
let full_size = (rw * rh * 4) as usize;
|
|
let has_buffer = doc.composite_buffer.len() == full_size;
|
|
let composite_dirty = doc.engine.is_composite_dirty();
|
|
|
|
// Ensure buffer exists with correct size
|
|
if !has_buffer {
|
|
doc.composite_buffer = vec![0u8; full_size];
|
|
}
|
|
|
|
let needs_render = composite_dirty || doc.composite_texture.is_none() || !has_buffer;
|
|
|
|
// ── Drawing-time composite throttle ─────────────────────────────────
|
|
// During active drawing, composite every other frame to keep the stroke
|
|
// visible in real-time while giving the brush engine time to accumulate
|
|
// 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 needs_render {
|
|
let t_composite_start = std::time::Instant::now();
|
|
let (region_result, buf_ptr, buf_size) = doc.engine.render_composite_region();
|
|
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],
|
|
_ => [0, 0, rw, rh],
|
|
};
|
|
|
|
let options = egui::TextureOptions {
|
|
magnification: egui::TextureFilter::Nearest,
|
|
minification: egui::TextureFilter::Linear,
|
|
..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();
|
|
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);
|
|
uploaded_bytes = region_pixels.len();
|
|
}
|
|
} else {
|
|
let color_image = egui::ColorImage::from_rgba_unmultiplied(
|
|
[rw as usize, rh as usize],
|
|
&doc.composite_buffer,
|
|
);
|
|
tex.set(color_image, options);
|
|
uploaded_bytes = doc.composite_buffer.len();
|
|
}
|
|
} else {
|
|
let color_image = egui::ColorImage::from_rgba_unmultiplied(
|
|
[rw as usize, rh as usize],
|
|
&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();
|
|
|
|
let total_ms = t_start.elapsed().as_millis();
|
|
if total_ms > 16 {
|
|
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
|
|
);
|
|
}
|
|
}
|
|
|
|
// ── Step 3: Regenerate deferred thumbnails ──────────────────────────────
|
|
// Delay thumbnail generation until the user has stopped drawing for at least 250ms
|
|
// 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;
|
|
}
|
|
}
|
|
|
|
super::perf::finish_after_render();
|
|
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();
|
|
doc.layer_textures.retain(|k, _| *k < layers.len());
|
|
|
|
for (i, info) in layers.iter().enumerate() {
|
|
let layer_dirty = doc.engine.is_layer_dirty(info.id);
|
|
if !layer_dirty && doc.layer_textures.contains_key(&i) {
|
|
continue;
|
|
}
|
|
if let Some(pixels) = doc.engine.get_layer_pixels(info.id) {
|
|
let thumb = thumbnail_nearest(
|
|
&pixels,
|
|
info.width,
|
|
info.height,
|
|
THUMB_W as u32,
|
|
THUMB_H as u32,
|
|
);
|
|
let mut color_pixels = Vec::with_capacity(THUMB_W * THUMB_H);
|
|
for chunk in thumb.chunks_exact(4) {
|
|
color_pixels.push(egui::Color32::from_rgba_unmultiplied(
|
|
chunk[0], chunk[1], chunk[2], chunk[3],
|
|
));
|
|
}
|
|
let img = egui::ColorImage {
|
|
size: [THUMB_W, THUMB_H],
|
|
pixels: color_pixels,
|
|
source_size: egui::vec2(THUMB_W as _, THUMB_H as _),
|
|
};
|
|
let thumb_opts = egui::TextureOptions {
|
|
magnification: egui::TextureFilter::Nearest,
|
|
minification: egui::TextureFilter::Nearest,
|
|
..Default::default()
|
|
};
|
|
match doc.layer_textures.get_mut(&i) {
|
|
Some(tex) => tex.set(img, thumb_opts),
|
|
None => {
|
|
let name = format!("layer-thumb-{}", i);
|
|
doc.layer_textures
|
|
.insert(i, ctx.load_texture(name, img, thumb_opts));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
let elapsed = t_regen.elapsed().as_millis();
|
|
if elapsed > 1 {
|
|
log::warn!("[PERF] regenerate_thumbnails took {}ms", elapsed);
|
|
}
|
|
}
|
|
|
|
/// Compute shape-specific key handle positions in canvas space.
|
|
///
|
|
/// Returns `(center_x, center_y, angle_rad, key_points)` where `key_points`
|
|
/// is a list of `(lx, ly)` canvas-space positions of the shape's defining vertices.
|
|
fn get_shape_key_points(shape: &VectorShape) -> (f32, f32, f32, Vec<(f32, f32)>) {
|
|
let (x1, y1, x2, y2) = shape.normalized_bounds();
|
|
let cx = (x1 + x2) / 2.0;
|
|
let cy = (y1 + y2) / 2.0;
|
|
let angle = shape.angle();
|
|
let (left, top, right, bottom) = (x1, y1, x2, y2);
|
|
|
|
let rotate = |px: f32, py: f32| -> (f32, f32) {
|
|
let dx = px - cx;
|
|
let dy = py - cy;
|
|
let c = angle.cos();
|
|
let s = angle.sin();
|
|
(cx + dx * c - dy * s, cy + dx * s + dy * c)
|
|
};
|
|
|
|
let pts: Vec<(f32, f32)> = match shape {
|
|
VectorShape::Line { .. } => {
|
|
let (lx1, ly1, lx2, ly2) = shape.bounds();
|
|
vec![rotate(lx1, ly1), rotate(lx2, ly2)]
|
|
}
|
|
VectorShape::Circle { .. } => {
|
|
let rx = (right - left) / 2.0;
|
|
let ry = (bottom - top) / 2.0;
|
|
(0..8)
|
|
.map(|i| {
|
|
let a = std::f32::consts::PI * 2.0 * i as f32 / 8.0;
|
|
rotate(cx + rx * a.cos(), cy + ry * a.sin())
|
|
})
|
|
.collect()
|
|
}
|
|
VectorShape::Star {
|
|
points,
|
|
inner_radius,
|
|
..
|
|
} => {
|
|
let outer_r = (right - left).min(bottom - top) / 2.0;
|
|
let inner_r_val = outer_r * inner_radius.max(0.1);
|
|
let n = *points as i32;
|
|
(0..n * 2)
|
|
.map(|i| {
|
|
let a = std::f32::consts::PI * 2.0 * i as f32 / (n * 2) as f32
|
|
- std::f32::consts::PI / 2.0;
|
|
let r = if i % 2 == 0 { outer_r } else { inner_r_val };
|
|
rotate(cx + r * a.cos(), cy + r * a.sin())
|
|
})
|
|
.collect()
|
|
}
|
|
VectorShape::Polygon { sides, .. } => {
|
|
let r = (right - left).min(bottom - top) / 2.0;
|
|
let n = (*sides).max(3) as i32;
|
|
(0..n)
|
|
.map(|i| {
|
|
let a = std::f32::consts::PI * 2.0 * i as f32 / n as f32
|
|
- std::f32::consts::PI / 2.0;
|
|
rotate(cx + r * a.cos(), cy + r * a.sin())
|
|
})
|
|
.collect()
|
|
}
|
|
VectorShape::SvgShape { .. } => {
|
|
vec![
|
|
rotate(left, top),
|
|
rotate(right, top),
|
|
rotate(right, bottom),
|
|
rotate(left, bottom),
|
|
]
|
|
}
|
|
_ => {
|
|
vec![
|
|
rotate(left, top),
|
|
rotate(right, top),
|
|
rotate(right, bottom),
|
|
rotate(left, bottom),
|
|
]
|
|
}
|
|
};
|
|
(cx, cy, angle, pts)
|
|
}
|
|
|
|
/// Draw vector selection handles on the canvas.
|
|
pub fn draw_vector_handles(
|
|
painter: &egui::Painter,
|
|
ctx: &egui::Context,
|
|
doc: &AppDocument,
|
|
state: &ToolState,
|
|
canvas_rect: egui::Rect,
|
|
zoom: f32,
|
|
) {
|
|
let shape_idx = match state.selected_vector_shape {
|
|
Some(idx) => idx,
|
|
None => return,
|
|
};
|
|
|
|
let active_layer = match doc.engine.active_layer() {
|
|
Some(l) => l,
|
|
None => return,
|
|
};
|
|
|
|
let shape = match &active_layer.data {
|
|
LayerData::Vector { shapes } => shapes.get(shape_idx),
|
|
_ => return,
|
|
};
|
|
let shape = match shape {
|
|
Some(s) => s.clone(),
|
|
None => return,
|
|
};
|
|
|
|
let (cx, cy, _angle, key_pts) = get_shape_key_points(&shape);
|
|
|
|
let to_screen =
|
|
|px: f32, py: f32| -> egui::Pos2 { canvas_rect.min + egui::vec2(px * zoom, py * zoom) };
|
|
let center_screen = to_screen(cx, cy);
|
|
|
|
let (x1, y1, x2, y2) = shape.normalized_bounds();
|
|
let sh_screen = (y2 - y1) * zoom / 2.0 + 2.0;
|
|
|
|
// Selection outline (shape-specific)
|
|
let sel_color = egui::Color32::from_rgb(0, 120, 215);
|
|
let stroke = egui::Stroke::new(1.5, sel_color);
|
|
if key_pts.len() >= 2 {
|
|
let screen_pts: Vec<egui::Pos2> = key_pts.iter().map(|&(x, y)| to_screen(x, y)).collect();
|
|
if matches!(shape, VectorShape::Line { .. }) {
|
|
painter.line_segment([screen_pts[0], screen_pts[1]], stroke);
|
|
} else {
|
|
for i in 0..screen_pts.len() {
|
|
let j = (i + 1) % screen_pts.len();
|
|
painter.line_segment([screen_pts[i], screen_pts[j]], stroke);
|
|
}
|
|
}
|
|
} else {
|
|
let sp1 = to_screen(x1, y1);
|
|
let sp2 = to_screen(x2, y2);
|
|
let sr = egui::Rect::from_two_pos(sp1, sp2);
|
|
painter.rect_stroke(sr, 0.0, stroke, egui::StrokeKind::Outside);
|
|
}
|
|
|
|
// Resize handles at key points
|
|
let h_sz = 7.0 * zoom.min(1.0).max(0.5);
|
|
let h_fill = egui::Color32::WHITE;
|
|
let h_stroke = egui::Stroke::new(1.0, egui::Color32::BLACK);
|
|
|
|
let handle_positions: Vec<egui::Pos2> = key_pts.iter().map(|&(x, y)| to_screen(x, y)).collect();
|
|
|
|
let hover_pos = ctx.input(|i| i.pointer.hover_pos());
|
|
|
|
for &hp in &handle_positions {
|
|
let hr = egui::Rect::from_center_size(hp, egui::Vec2::splat(h_sz));
|
|
painter.rect_filled(hr, 0.0, h_fill);
|
|
painter.rect_stroke(hr, 0.0, h_stroke, egui::StrokeKind::Outside);
|
|
}
|
|
|
|
// Rotation handle
|
|
let rot_handle_offset = egui::vec2(0.0, -(sh_screen + 25.0));
|
|
let rotated_handle_pos = center_screen + rot_handle_offset;
|
|
let rot_line_start = center_screen + egui::vec2(0.0, -sh_screen);
|
|
|
|
painter.line_segment(
|
|
[rot_line_start, rotated_handle_pos],
|
|
egui::Stroke::new(1.5, sel_color),
|
|
);
|
|
painter.circle_filled(rotated_handle_pos, 6.0 * zoom.min(1.0).max(0.5), sel_color);
|
|
painter.circle_stroke(rotated_handle_pos, 6.0 * zoom.min(1.0).max(0.5), h_stroke);
|
|
|
|
// Cursor feedback
|
|
if let Some(hp) = hover_pos {
|
|
for &handle_screen_pos in &handle_positions {
|
|
let hr = egui::Rect::from_center_size(handle_screen_pos, egui::Vec2::splat(h_sz + 4.0));
|
|
if hr.contains(hp) {
|
|
ctx.set_cursor_icon(egui::CursorIcon::Grab);
|
|
}
|
|
}
|
|
if hp.distance(rotated_handle_pos) <= 12.0 {
|
|
ctx.set_cursor_icon(egui::CursorIcon::Crosshair);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Hit-test vector handle positions in screen space.
|
|
pub fn get_vector_handle_at(
|
|
pos: egui::Pos2,
|
|
canvas_rect: egui::Rect,
|
|
shape: &VectorShape,
|
|
zoom: f32,
|
|
) -> VectorEditHandle {
|
|
let (cx, cy, _angle, key_pts) = get_shape_key_points(shape);
|
|
|
|
let to_screen =
|
|
|px: f32, py: f32| -> egui::Pos2 { canvas_rect.min + egui::vec2(px * zoom, py * zoom) };
|
|
let center_screen = to_screen(cx, cy);
|
|
|
|
let (_x1, y1, _x2, y2) = shape.normalized_bounds();
|
|
let sh_screen = (y2 - y1) * zoom / 2.0 + 2.0;
|
|
|
|
// Rotation handle
|
|
let rot_handle_offset = egui::vec2(0.0, -(sh_screen + 25.0));
|
|
let rotated_handle_pos = center_screen + rot_handle_offset;
|
|
if pos.distance(rotated_handle_pos) <= 18.0 {
|
|
return VectorEditHandle::Rotate;
|
|
}
|
|
|
|
let handle_ids: &[VectorEditHandle] = if key_pts.len() == 2 {
|
|
&[VectorEditHandle::TopLeft, VectorEditHandle::BottomRight]
|
|
} else if key_pts.len() == 4 {
|
|
&[
|
|
VectorEditHandle::TopLeft,
|
|
VectorEditHandle::TopRight,
|
|
VectorEditHandle::BottomRight,
|
|
VectorEditHandle::BottomLeft,
|
|
]
|
|
} else if key_pts.len() == 8 {
|
|
&[
|
|
VectorEditHandle::TopLeft,
|
|
VectorEditHandle::Top,
|
|
VectorEditHandle::TopRight,
|
|
VectorEditHandle::Right,
|
|
VectorEditHandle::BottomRight,
|
|
VectorEditHandle::Bottom,
|
|
VectorEditHandle::BottomLeft,
|
|
VectorEditHandle::Left,
|
|
]
|
|
} else {
|
|
&[
|
|
VectorEditHandle::TopLeft,
|
|
VectorEditHandle::TopRight,
|
|
VectorEditHandle::BottomRight,
|
|
VectorEditHandle::BottomLeft,
|
|
]
|
|
};
|
|
|
|
for (i, &(px, py)) in key_pts.iter().enumerate() {
|
|
let hp = to_screen(px, py);
|
|
let hr = egui::Rect::from_center_size(hp, egui::Vec2::splat(14.0));
|
|
if hr.contains(pos) {
|
|
let idx = i.min(handle_ids.len() - 1);
|
|
return handle_ids[idx];
|
|
}
|
|
}
|
|
|
|
// Check bounding box for Move
|
|
let (bx1, by1, bx2, by2) = shape.normalized_bounds();
|
|
let sr = egui::Rect::from_two_pos(to_screen(bx1, by1), to_screen(bx2, by2));
|
|
if sr.expand(8.0).contains(pos) {
|
|
return VectorEditHandle::Move;
|
|
}
|
|
|
|
VectorEditHandle::None
|
|
}
|
|
|
|
/// Draw marching-ants selection overlay for the active selection mask.
|
|
///
|
|
/// # Purpose
|
|
/// Renders a classic marching-ants dashed border around the selected pixels.
|
|
///
|
|
/// # Logic & Workflow
|
|
/// 1. Look up the shared [`SelectionEdgeCache`] for a cached edge list that
|
|
/// matches the current `selection_mask` pointer/length.
|
|
/// 2. If no matching cache entry exists, rebuild the edge list once by
|
|
/// scanning the mask (this is the expensive part on 4K masks).
|
|
/// 3. Draw each cached edge segment with a dashed black-and-white stroke.
|
|
///
|
|
/// # Arguments
|
|
/// * `edge_cache` — mutable reference to the overlay's persistent cache.
|
|
/// * `painter` — egui painter used for line segments.
|
|
/// * `canvas_rect` — screen rectangle where the canvas is displayed.
|
|
/// * `selection_mask` — byte mask from the engine (`>128` means selected).
|
|
/// * `canvas_width`, `canvas_height` — mask dimensions.
|
|
/// * `zoom` — current canvas zoom factor (maps canvas pixels to screen pixels).
|
|
/// * `time` — current UI time in seconds, drives the dash animation offset.
|
|
///
|
|
/// # Side Effects / Dependencies
|
|
/// Mutates `edge_cache` when the mask buffer changes.
|
|
pub fn draw_selection_overlay(
|
|
edge_cache: &mut SelectionEdgeCache,
|
|
painter: &egui::Painter,
|
|
canvas_rect: egui::Rect,
|
|
selection_mask: &[u8],
|
|
canvas_width: u32,
|
|
canvas_height: u32,
|
|
zoom: f32,
|
|
time: f64,
|
|
) {
|
|
let clip_rect = painter.clip_rect();
|
|
|
|
let to_screen = |cx: u32, cy: u32| -> egui::Pos2 {
|
|
canvas_rect.min + egui::vec2(cx as f32 * zoom, cy as f32 * zoom)
|
|
};
|
|
|
|
let dash_len: f32 = 6.0;
|
|
let gap_len: f32 = 6.0;
|
|
let cycle = (dash_len + gap_len).max(1.0);
|
|
let offset = (time as f32 * 22.0) % cycle;
|
|
|
|
let bg_stroke = egui::Color32::from_rgba_unmultiplied(0, 0, 0, 180);
|
|
let fg_stroke = egui::Color32::from_rgba_unmultiplied(255, 255, 255, 200);
|
|
let stroke_width = 1.5;
|
|
|
|
let edges = edge_cache
|
|
.get(selection_mask)
|
|
.unwrap_or_else(|| edge_cache.rebuild(selection_mask, canvas_width, canvas_height));
|
|
|
|
for &(x1, y1, x2, y2) in edges.iter() {
|
|
let s1 = to_screen(x1, y1);
|
|
let s2 = to_screen(x2, y2);
|
|
let seg_rect = egui::Rect::from_two_pos(s1, s2).expand(stroke_width);
|
|
if !clip_rect.intersects(seg_rect) {
|
|
continue;
|
|
}
|
|
let dist = s1.distance(s2);
|
|
if dist < 1e-6 {
|
|
continue;
|
|
}
|
|
painter.line_segment([s1, s2], egui::Stroke::new(stroke_width, bg_stroke));
|
|
let dir = (s2 - s1) / dist;
|
|
let mut curr = -offset;
|
|
let mut iter = 0u32;
|
|
while curr < dist && iter < 1000 {
|
|
iter += 1;
|
|
let start_d = curr.max(0.0);
|
|
let end_d = (curr + dash_len).min(dist);
|
|
if end_d > start_d {
|
|
painter.line_segment(
|
|
[s1 + dir * start_d, s1 + dir * end_d],
|
|
egui::Stroke::new(stroke_width, fg_stroke),
|
|
);
|
|
}
|
|
curr += cycle;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Draw the move/resize transform overlay (8 handles + image preview).
|
|
/// Returns `true` if the user clicked "Apply", `false` otherwise.
|
|
pub fn draw_transform_overlay(
|
|
ui: &mut egui::Ui,
|
|
canvas_rect: egui::Rect,
|
|
state: &ToolState,
|
|
zoom: f32,
|
|
ctx: &egui::Context,
|
|
transform_texture: &mut Option<egui::TextureHandle>,
|
|
) -> bool {
|
|
let tr = match &state.transform {
|
|
Some(t) => t,
|
|
None => return false,
|
|
};
|
|
|
|
let screen_pos = canvas_rect.min + egui::vec2(tr.pos.x * zoom, tr.pos.y * zoom);
|
|
let screen_size = egui::vec2(tr.size.x * zoom, tr.size.y * zoom);
|
|
let rect = egui::Rect::from_min_size(screen_pos, screen_size);
|
|
|
|
let color_image = egui::ColorImage::from_rgba_unmultiplied(
|
|
[tr.width as usize, tr.height as usize],
|
|
&tr.pixels,
|
|
);
|
|
|
|
let options = egui::TextureOptions::NEAREST;
|
|
match transform_texture {
|
|
Some(tex) => {
|
|
if tex.size()[0] == tr.width as usize && tex.size()[1] == tr.height as usize {
|
|
tex.set(color_image, options);
|
|
} else {
|
|
*transform_texture = Some(ctx.load_texture("transform_temp", color_image, options));
|
|
}
|
|
}
|
|
None => {
|
|
*transform_texture = Some(ctx.load_texture("transform_temp", color_image, options));
|
|
}
|
|
}
|
|
|
|
let painter = ui.painter();
|
|
if let Some(tex) = transform_texture {
|
|
painter.image(
|
|
tex.id(),
|
|
rect,
|
|
egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)),
|
|
egui::Color32::WHITE,
|
|
);
|
|
}
|
|
|
|
painter.rect_stroke(
|
|
rect,
|
|
0.0,
|
|
egui::Stroke::new(1.0, egui::Color32::from_rgb(0, 120, 215)),
|
|
egui::StrokeKind::Outside,
|
|
);
|
|
|
|
let handle_size = 10.0;
|
|
let handle_color = egui::Color32::WHITE;
|
|
let handle_stroke = egui::Stroke::new(1.0, egui::Color32::BLACK);
|
|
|
|
let handle_defs: &[(egui::Pos2, egui::CursorIcon)] = &[
|
|
(rect.left_top(), egui::CursorIcon::ResizeNwSe),
|
|
(rect.right_top(), egui::CursorIcon::ResizeNeSw),
|
|
(rect.right_bottom(), egui::CursorIcon::ResizeNwSe),
|
|
(rect.left_bottom(), egui::CursorIcon::ResizeNeSw),
|
|
(rect.center_top(), egui::CursorIcon::ResizeNorth),
|
|
(rect.center_bottom(), egui::CursorIcon::ResizeSouth),
|
|
(rect.left_center(), egui::CursorIcon::ResizeWest),
|
|
(rect.right_center(), egui::CursorIcon::ResizeEast),
|
|
];
|
|
|
|
let hover_pos = ctx.input(|i| i.pointer.hover_pos());
|
|
|
|
for (p, cursor) in handle_defs {
|
|
let hr = egui::Rect::from_center_size(*p, egui::Vec2::splat(handle_size));
|
|
painter.rect_filled(hr, 0.0, handle_color);
|
|
painter.rect_stroke(hr, 0.0, handle_stroke, egui::StrokeKind::Outside);
|
|
|
|
if let Some(pos) = hover_pos {
|
|
if hr.expand(2.0).contains(pos) {
|
|
ctx.set_cursor_icon(*cursor);
|
|
}
|
|
}
|
|
}
|
|
|
|
if let Some(pos) = hover_pos {
|
|
if rect.contains(pos) {
|
|
ctx.set_cursor_icon(egui::CursorIcon::Grab);
|
|
}
|
|
}
|
|
|
|
// Apply / Cancel buttons positioned at top-right of the transform rect
|
|
let btn_w = 70.0;
|
|
let btn_h = 22.0;
|
|
let gap = 4.0;
|
|
let btn_area_top = rect.top() - btn_h - gap;
|
|
let btn_area_right = rect.right();
|
|
let apply_rect = egui::Rect::from_min_size(
|
|
egui::pos2(btn_area_right - btn_w - gap - btn_w, btn_area_top),
|
|
egui::vec2(btn_w, btn_h),
|
|
);
|
|
let cancel_rect = egui::Rect::from_min_size(
|
|
egui::pos2(btn_area_right - btn_w, btn_area_top),
|
|
egui::vec2(btn_w, btn_h),
|
|
);
|
|
|
|
let apply_btn = egui::Button::new("Apply")
|
|
.fill(egui::Color32::from_rgb(0, 120, 215))
|
|
.stroke(egui::Stroke::NONE);
|
|
let cancel_btn = egui::Button::new("Cancel")
|
|
.fill(egui::Color32::from_rgb(80, 80, 80))
|
|
.stroke(egui::Stroke::NONE);
|
|
|
|
let mut clicked_apply = false;
|
|
ui.put(
|
|
egui::Rect::from_min_size(apply_rect.min, apply_rect.size()),
|
|
apply_btn,
|
|
)
|
|
.clicked()
|
|
.then(|| {
|
|
clicked_apply = true;
|
|
});
|
|
ui.put(
|
|
egui::Rect::from_min_size(cancel_rect.min, cancel_rect.size()),
|
|
cancel_btn,
|
|
)
|
|
.clicked();
|
|
|
|
clicked_apply
|
|
}
|
|
|
|
/// Hit-test a screen position against the 8 transform handles + body.
|
|
pub fn get_transform_handle_at(
|
|
pos: egui::Pos2,
|
|
canvas_rect: egui::Rect,
|
|
tr: &SelectionTransform,
|
|
zoom: f32,
|
|
) -> TransformHandle {
|
|
let screen_pos = canvas_rect.min + egui::vec2(tr.pos.x * zoom, tr.pos.y * zoom);
|
|
let screen_size = egui::vec2(tr.size.x * zoom, tr.size.y * zoom);
|
|
let rect = egui::Rect::from_min_size(screen_pos, screen_size);
|
|
|
|
let handle_size = 12.0;
|
|
|
|
if egui::Rect::from_center_size(rect.left_top(), egui::Vec2::splat(handle_size)).contains(pos) {
|
|
return TransformHandle::TopLeft;
|
|
}
|
|
if egui::Rect::from_center_size(rect.right_top(), egui::Vec2::splat(handle_size)).contains(pos)
|
|
{
|
|
return TransformHandle::TopRight;
|
|
}
|
|
if egui::Rect::from_center_size(rect.right_bottom(), egui::Vec2::splat(handle_size))
|
|
.contains(pos)
|
|
{
|
|
return TransformHandle::BottomRight;
|
|
}
|
|
if egui::Rect::from_center_size(rect.left_bottom(), egui::Vec2::splat(handle_size))
|
|
.contains(pos)
|
|
{
|
|
return TransformHandle::BottomLeft;
|
|
}
|
|
if egui::Rect::from_center_size(rect.center_top(), egui::Vec2::splat(handle_size)).contains(pos)
|
|
{
|
|
return TransformHandle::Top;
|
|
}
|
|
if egui::Rect::from_center_size(rect.center_bottom(), egui::Vec2::splat(handle_size))
|
|
.contains(pos)
|
|
{
|
|
return TransformHandle::Bottom;
|
|
}
|
|
if egui::Rect::from_center_size(rect.left_center(), egui::Vec2::splat(handle_size))
|
|
.contains(pos)
|
|
{
|
|
return TransformHandle::Left;
|
|
}
|
|
if egui::Rect::from_center_size(rect.right_center(), egui::Vec2::splat(handle_size))
|
|
.contains(pos)
|
|
{
|
|
return TransformHandle::Right;
|
|
}
|
|
|
|
if rect.contains(pos) {
|
|
return TransformHandle::Move;
|
|
}
|
|
|
|
TransformHandle::None
|
|
}
|