Files
hcie-rust-v3.05/hcie-egui-app/crates/hcie-gui-egui/src/canvas/render.rs
T
Your Name a2264b130d 4K MULTI LAYEPERFORMANCE OK : SOL 5.6 HIGH feat(perf): add real-time canvas performance measurements
- Introduced a new performance module for the iced canvas to measure input queueing, engine drawing, CPU compositing/staging, GPU upload, and renderer preparation costs.
- Implemented a performance probe in the egui reference renderer for comparative diagnostics.
- Added methods to record durations, byte transfers, and input timestamps, enabling detailed performance analysis.
- Enhanced the CanvasShaderPipeline with a new method to upload full textures without rebuilding GPU resources.
- Updated the main application to conditionally log performance diagnostics based on an environment variable.
- Created a performance plan document outlining steps to measure and optimize drawing performance in the iced application.
2026-07-23 23:57:03 +03:00

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],
&region_pixels,
);
tex.set_partial([x0 as usize, y0 as usize], region_image, options);
uploaded_bytes = region_pixels.len();
}
} else {
let color_image = egui::ColorImage::from_rgba_unmultiplied(
[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
}