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.
2640 lines
137 KiB
Rust
2640 lines
137 KiB
Rust
//! Canvas widget — zoom, pan, pointer interaction, and texture display.
|
||
|
||
use crate::app::{tools::shape_sync, AppDocument, SelectionTransform, ToolState, TransformHandle};
|
||
use crate::event_bus::{AppEvent, EventBus};
|
||
use eframe::egui;
|
||
use hcie_engine_api::{LayerData, Tool, VectorEditHandle, VectorShape, ZOOM_MAX, ZOOM_MIN};
|
||
use std::hash::{Hash, Hasher};
|
||
|
||
pub mod perf;
|
||
pub mod render;
|
||
pub mod text_overlay;
|
||
|
||
pub struct CanvasWidget<'a> {
|
||
doc: &'a mut AppDocument,
|
||
state: &'a mut ToolState,
|
||
event_bus: &'a mut EventBus,
|
||
transform_texture: &'a mut Option<egui::TextureHandle>,
|
||
/// Index of this document in the documents vec. Used to prevent
|
||
/// cross-document drawing interference: only the active document
|
||
/// accepts stroke/drawing interactions.
|
||
doc_index: usize,
|
||
/// Index of the currently active document, used together with doc_index
|
||
/// to guard drawing operations.
|
||
active_doc: usize,
|
||
}
|
||
|
||
impl<'a> CanvasWidget<'a> {
|
||
pub fn new(
|
||
doc: &'a mut AppDocument,
|
||
state: &'a mut ToolState,
|
||
event_bus: &'a mut EventBus,
|
||
transform_texture: &'a mut Option<egui::TextureHandle>,
|
||
doc_index: usize,
|
||
active_doc: usize,
|
||
) -> Self {
|
||
Self {
|
||
doc,
|
||
state,
|
||
event_bus,
|
||
transform_texture,
|
||
doc_index,
|
||
active_doc,
|
||
}
|
||
}
|
||
}
|
||
|
||
impl<'a> egui::Widget for CanvasWidget<'a> {
|
||
/// ### Purpose
|
||
/// Renders the central painting canvas, handles pan/zoom operations, tracks pointer/pressure inputs,
|
||
/// and invokes engine compositing/rendering functions.
|
||
///
|
||
/// ### Logic & Workflow
|
||
/// 1. Computes the current zoom-adjusted display size and allocates a draggable/clickable painter.
|
||
/// 2. Calculates the absolute canvas rectangle offset by the current pan coordinates.
|
||
/// 3. Binds scroll-wheel events to update zoom levels, shifting the pan offset to center on the mouse pointer.
|
||
/// 4. Maps raw screen cursor coordinates to canvas-space coordinates.
|
||
/// 5. Feeds touch/pen pressure levels to the tablet state manager.
|
||
/// 6. Tracks drag/press operations to send start_stroke, stroke_to, and end_stroke events to the Engine. Leverages raw pointer state fallbacks to keep drawing active if egui's default drag states are interrupted by focus changes or tab activation.
|
||
/// 7. Records specific brush/eraser stroke actions with refined names into the document's history log.
|
||
/// 8. Invokes texture rendering for composite buffers and selection/transform overlays.
|
||
///
|
||
/// ### Arguments
|
||
/// * `ui` - The active egui UI layout context.
|
||
///
|
||
/// ### Returns
|
||
/// An egui response containing widget layout and interaction state.
|
||
///
|
||
/// ### Side Effects / Dependencies
|
||
/// - Mutates `AppDocument` (zoom, canvas textures, history description, engine state).
|
||
/// - Mutates `ToolState` (cursor position, pan offset, pressure levels, lasso/drawing states).
|
||
/// - Dispatches rendering, settings save, and drawing finish events to the `EventBus`.
|
||
fn ui(self, ui: &mut egui::Ui) -> egui::Response {
|
||
let current_ctx = ui.ctx().clone();
|
||
if self.doc.last_ctx.as_ref() != Some(¤t_ctx) {
|
||
log::info!(
|
||
"[CanvasWidget::ui] Context changed for doc {}; clearing cached GPU textures",
|
||
self.doc.name
|
||
);
|
||
self.doc.composite_texture = None;
|
||
self.doc.layer_textures.clear();
|
||
self.doc.last_ctx = Some(current_ctx);
|
||
}
|
||
|
||
let canvas_w = self.doc.engine.canvas_width() as f32;
|
||
let canvas_h = self.doc.engine.canvas_height() as f32;
|
||
let zoom = self.doc.zoom;
|
||
|
||
let display_size = egui::Vec2::new(canvas_w * zoom, canvas_h * zoom);
|
||
|
||
// Guard: only the active document should accept drawing input.
|
||
// Non-active canvases still render their texture but skip pointer interactions.
|
||
let is_active_doc = self.doc_index == self.active_doc;
|
||
|
||
let primary_down = ui.input(|i| i.pointer.button_down(egui::PointerButton::Primary));
|
||
let was_drawing = self.state.is_drawing && is_active_doc;
|
||
let primary_released = !primary_down && was_drawing;
|
||
if !primary_down && is_active_doc {
|
||
self.state.is_drawing = false;
|
||
self.state.brush_accumulated_dist = 0.0;
|
||
}
|
||
|
||
let (response, painter) =
|
||
ui.allocate_painter(ui.available_size(), egui::Sense::click_and_drag());
|
||
|
||
let pan = self.doc.pan_offset;
|
||
let canvas_rect = egui::Rect::from_center_size(
|
||
response.rect.center() + egui::vec2(pan.x, pan.y),
|
||
display_size,
|
||
);
|
||
|
||
// Hover tracking: prefer egui pointer hover, fallback to tablet state.
|
||
let hover_pos = ui.input(|i| i.pointer.hover_pos()).or_else(|| {
|
||
self.state
|
||
.tablet_state
|
||
.lock()
|
||
.ok()
|
||
.and_then(|s| s.current_pos())
|
||
});
|
||
if let Some(pos) = hover_pos {
|
||
if response.hovered() && canvas_rect.contains(pos) {
|
||
let rel = pos - canvas_rect.min;
|
||
let cx = (rel.x / zoom) as i32;
|
||
let cy = (rel.y / zoom) as i32;
|
||
if cx >= 0 && cy >= 0 {
|
||
self.state.cursor_canvas_pos = Some((cx as u32, cy as u32));
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── Zoom (scroll wheel) ────────────────────────────────
|
||
let scroll_delta = ui.input(|i| i.smooth_scroll_delta.y);
|
||
if scroll_delta != 0.0 && response.hovered() {
|
||
let ctrl = ui.input(|i| i.modifiers.command);
|
||
let factor = if ctrl {
|
||
if scroll_delta > 0.0 {
|
||
1.1
|
||
} else {
|
||
1.0 / 1.1
|
||
}
|
||
} else {
|
||
(scroll_delta / 200.0).exp()
|
||
};
|
||
let old_zoom = self.doc.zoom;
|
||
let new_zoom = (old_zoom * factor).clamp(ZOOM_MIN, ZOOM_MAX);
|
||
self.doc.zoom = new_zoom;
|
||
if let Some(mouse_pos) = ui.input(|i| i.pointer.hover_pos()) {
|
||
let actual_factor = new_zoom / old_zoom;
|
||
let rel_pos = mouse_pos - response.rect.center();
|
||
let scroll_shift = (rel_pos - egui::vec2(pan.x, pan.y)) * (1.0 - actual_factor);
|
||
self.doc.pan_offset.x += scroll_shift.x;
|
||
self.doc.pan_offset.y += scroll_shift.y;
|
||
}
|
||
}
|
||
|
||
// ── Pan (middle mouse drag) ─────────────────────────────
|
||
if response.dragged_by(egui::PointerButton::Middle) {
|
||
let delta = response.drag_delta();
|
||
self.doc.pan_offset.x += delta.x;
|
||
self.doc.pan_offset.y += delta.y;
|
||
}
|
||
|
||
// ── Pointer interaction ──────────────────────────────────
|
||
// Feed screen size so evdev can map tablet coords to screen pixels.
|
||
{
|
||
let screen = ui.available_size();
|
||
if let Ok(mut ts) = self.state.tablet_state.lock() {
|
||
ts.set_screen_size(screen);
|
||
}
|
||
}
|
||
|
||
// Feed pointer/touch events into the shared tablet state.
|
||
ui.input(|i| {
|
||
for event in &i.events {
|
||
match event {
|
||
egui::Event::PointerMoved(pos) => {
|
||
if let Ok(mut state) = self.state.tablet_state.lock() {
|
||
state.set_screen_pos(*pos);
|
||
}
|
||
}
|
||
egui::Event::Touch { force, .. } => {
|
||
if let Ok(mut state) = self.state.tablet_state.lock() {
|
||
state.set_touch_pressure(force.unwrap_or(1.0));
|
||
}
|
||
}
|
||
_ => {}
|
||
}
|
||
}
|
||
});
|
||
|
||
// Read current pressure from the shared tablet state (evdev/winit/touch).
|
||
let ts_pressure = self
|
||
.state
|
||
.tablet_state
|
||
.lock()
|
||
.map(|s| (s.current_pressure(), s.source()))
|
||
.unwrap_or((1.0, crate::app::shell::tablet_input::TabletSource::NoTablet));
|
||
self.state.current_pressure = ts_pressure.0;
|
||
log::trace!(
|
||
"canvas pressure: {:.3} source: {:?}",
|
||
ts_pressure.0,
|
||
ts_pressure.1
|
||
);
|
||
|
||
let active_pos = if self.state.is_drawing && is_active_doc {
|
||
hover_pos
|
||
} else {
|
||
response.interact_pointer_pos()
|
||
};
|
||
|
||
if let Some(pos) = active_pos {
|
||
let rel = pos - canvas_rect.min;
|
||
let cx = (rel.x / zoom) as i32;
|
||
let cy = (rel.y / zoom) as i32;
|
||
let in_bounds = cx >= 0 && cx < canvas_w as i32 && cy >= 0 && cy < canvas_h as i32;
|
||
let should_interact = is_active_doc && (in_bounds || self.state.is_drawing);
|
||
|
||
// Update cursor position only for the active document so status bar
|
||
// and panels show coordinates relevant to the document being edited.
|
||
if is_active_doc && in_bounds {
|
||
let ux = cx.clamp(0, canvas_w as i32 - 1) as u32;
|
||
let uy = cy.clamp(0, canvas_h as i32 - 1) as u32;
|
||
self.state.cursor_canvas_pos = Some((ux, uy));
|
||
}
|
||
|
||
if should_interact {
|
||
let ux = cx.clamp(0, canvas_w as i32 - 1) as u32;
|
||
let uy = cy.clamp(0, canvas_h as i32 - 1) as u32;
|
||
|
||
let primary_pressed =
|
||
ui.input(|i| i.pointer.primary_pressed()) && response.hovered();
|
||
|
||
// Reject raster edits on non-editable layers (e.g. group or mask layers)
|
||
if primary_pressed && !self.state.is_drawing {
|
||
if let Some(req_type) = self.state.active_tool.allowed_layer_type() {
|
||
if req_type == hcie_engine_api::LayerType::Raster
|
||
&& !self.doc.engine.active_layer_is_editable()
|
||
{
|
||
self.event_bus.push(AppEvent::ShowStatusMessage(
|
||
"Layer not editable".to_string(),
|
||
));
|
||
// Consume the press so we don't fall through to drawing
|
||
return response;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Auto-create layer if current tool requires a different layer type
|
||
if primary_pressed && !self.state.is_drawing {
|
||
if let Some(req_type) = self.state.active_tool.allowed_layer_type() {
|
||
if req_type != hcie_engine_api::LayerType::Text {
|
||
if let Some(active) = self.doc.engine.active_layer() {
|
||
if active.layer_type != req_type {
|
||
match req_type {
|
||
hcie_engine_api::LayerType::Vector => {
|
||
let id = self.doc.engine.add_layer("Vector Layer");
|
||
self.doc.engine.set_active_layer(id);
|
||
}
|
||
_ => {
|
||
let id = self.doc.engine.add_layer("Layer");
|
||
self.doc.engine.set_active_layer(id);
|
||
}
|
||
}
|
||
self.event_bus.push(AppEvent::RenderRequested);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
if primary_pressed {
|
||
let is_ctrl = ui.input(|i| i.modifiers.command || i.modifiers.ctrl);
|
||
let is_shift = ui.input(|i| i.modifiers.shift);
|
||
if is_ctrl {
|
||
if let Some(layer_id) = self.doc.engine.active_layer_id_opt() {
|
||
if let Some(px) = self.doc.engine.get_pixel(layer_id, ux, uy) {
|
||
self.state.primary_color = px;
|
||
}
|
||
}
|
||
self.state.is_drawing = true;
|
||
self.state.drag_start_pos = Some((ux, uy));
|
||
self.state.last_draw_pos = Some((ux, uy));
|
||
} else if is_shift
|
||
&& matches!(
|
||
self.state.active_tool,
|
||
Tool::Pen | Tool::Brush | Tool::Eraser | Tool::Spray
|
||
)
|
||
{
|
||
self.state.is_resizing_brush = true;
|
||
self.state.brush_resize_start_size = self.state.active_size();
|
||
self.state.brush_resize_start_pos = Some(pos);
|
||
self.state.is_drawing = true;
|
||
self.state.drag_start_pos = Some((ux, uy));
|
||
self.state.last_draw_pos = Some((ux, uy));
|
||
} else {
|
||
match self.state.active_tool {
|
||
Tool::Pen | Tool::Brush | Tool::Eraser | Tool::Spray => {
|
||
if !self.doc.engine.active_layer_is_editable() {
|
||
self.event_bus.push(AppEvent::ShowStatusMessage(
|
||
"Layer not editable".to_string(),
|
||
));
|
||
return response;
|
||
}
|
||
let input_at = std::time::Instant::now();
|
||
perf::begin_stroke(input_at);
|
||
perf::record_input(input_at);
|
||
// Send current brush state to engine before stroke
|
||
self.doc.engine.set_color(self.state.primary_color);
|
||
let mut tip = hcie_engine_api::BrushTip::default();
|
||
match self.state.active_tool {
|
||
Tool::Pen => {
|
||
tip.style = hcie_engine_api::BrushStyle::Round;
|
||
tip.size = self.state.tool_configs.pen.size;
|
||
tip.opacity = self.state.tool_configs.pen.opacity;
|
||
tip.hardness = self.state.tool_configs.pen.hardness;
|
||
tip.spacing = 0.05;
|
||
}
|
||
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 => {
|
||
// Preserve bitmap data from the active brush preset
|
||
let presets = &self.state.brush_presets;
|
||
if let Some(p) = presets.iter().find(|bp| {
|
||
bp.id == self.state.active_brush_preset_id
|
||
}) {
|
||
tip.bitmap_pixels = p.tip.bitmap_pixels.clone();
|
||
tip.bitmap_w = p.tip.bitmap_w;
|
||
tip.bitmap_h = p.tip.bitmap_h;
|
||
}
|
||
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.color_variant =
|
||
self.state.tool_configs.brush.color_variant;
|
||
tip.variant_amount =
|
||
self.state.tool_configs.brush.variant_amount;
|
||
tip.density = self.state.tool_configs.brush.density;
|
||
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.drawing_angle =
|
||
self.state.tool_configs.brush.drawing_angle;
|
||
tip.rotation_random =
|
||
self.state.tool_configs.brush.rotation_random;
|
||
tip.time_enabled = false;
|
||
tip.time_size_start = 1.0;
|
||
tip.time_size_end = 1.0;
|
||
tip.time_opacity_start = 1.0;
|
||
tip.time_opacity_end = 1.0;
|
||
tip.time_angle_start = 0.0;
|
||
tip.time_angle_end = 0.0;
|
||
}
|
||
Tool::Eraser => {
|
||
tip.style = hcie_engine_api::BrushStyle::Round;
|
||
tip.size = self.state.tool_configs.eraser.size;
|
||
tip.opacity = self.state.tool_configs.eraser.opacity;
|
||
tip.hardness = self.state.tool_configs.eraser.hardness;
|
||
}
|
||
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;
|
||
tip.spacing = 0.5;
|
||
tip.spray_particle_size =
|
||
self.state.tool_configs.spray.dot_size;
|
||
tip.spray_density = self.state.tool_configs.spray.density;
|
||
}
|
||
_ => {}
|
||
}
|
||
self.doc.engine.set_brush_tip(tip);
|
||
// Sync eraser mode based on active tool
|
||
self.doc
|
||
.engine
|
||
.set_eraser(self.state.active_tool == Tool::Eraser);
|
||
self.doc
|
||
.engine
|
||
.set_cyclic_color(self.state.tool_configs.brush.cyclic_color);
|
||
self.doc
|
||
.engine
|
||
.set_cyclic_speed(self.state.tool_configs.brush.cyclic_speed);
|
||
|
||
self.state.is_drawing = true;
|
||
self.state.drag_start_pos = Some((ux, uy));
|
||
self.state.last_draw_pos = Some((ux, uy));
|
||
self.state.brush_accumulated_dist = 0.0;
|
||
let layer_id = self.doc.engine.active_layer_id();
|
||
if self.state.active_tool != Tool::Pen {
|
||
let engine_start = std::time::Instant::now();
|
||
self.doc.engine.begin_stroke(layer_id, ux as f32, uy as f32);
|
||
// Draw single dab on click (even without movement).
|
||
// The spacing check in the drag handler prevents
|
||
// 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,
|
||
);
|
||
perf::record_duration("engine_draw", engine_start.elapsed());
|
||
}
|
||
}
|
||
|
||
Tool::FloodFill => {
|
||
if !self.doc.engine.active_layer_is_editable() {
|
||
self.event_bus.push(AppEvent::ShowStatusMessage(
|
||
"Layer not editable".to_string(),
|
||
));
|
||
return response;
|
||
}
|
||
let color = self.state.primary_color;
|
||
self.doc.engine.flood_fill(ux, uy, color, 32);
|
||
self.event_bus.push(AppEvent::RenderRequested);
|
||
}
|
||
Tool::Select => {
|
||
self.state.is_drawing = true;
|
||
self.state.drag_start_pos = Some((ux, uy));
|
||
self.state.selection_rect = Some([ux, uy, ux, uy]);
|
||
}
|
||
Tool::MagicWand => {
|
||
// Magic Wand: single click — flood-select by colour
|
||
let tolerance = self.state.tool_configs.magic_wand.tolerance;
|
||
self.doc
|
||
.engine
|
||
.create_selection_magic_wand(ux, uy, tolerance);
|
||
self.event_bus.push(AppEvent::RenderRequested);
|
||
}
|
||
Tool::SmartSelect => {
|
||
// Smart Select: fire immediately on click (like MagicWand).
|
||
// Do NOT also fire on release; that would double-run the AI/fallback.
|
||
self.event_bus
|
||
.push(AppEvent::SmartSelectRun { x: ux, y: uy });
|
||
}
|
||
Tool::VisionSelect => {
|
||
// VisionSelect: start a drag that becomes either a point click
|
||
// (small drag) or a bounding-box prompt (large drag) on release.
|
||
self.state.is_drawing = true;
|
||
self.state.drag_start_pos = Some((ux, uy));
|
||
self.state.last_draw_pos = Some((ux, uy));
|
||
self.state.vision_select_rect = Some([ux, uy, ux, uy]);
|
||
}
|
||
Tool::Lasso => {
|
||
self.state.is_drawing = true;
|
||
self.state.lasso_points.clear();
|
||
self.state.lasso_points.push((ux, uy));
|
||
self.state.drag_start_pos = Some((ux, uy));
|
||
}
|
||
Tool::PolygonSelect => {
|
||
// PolygonSelect: accumulate vertices on each click;
|
||
// close the polygon when clicking near the start point
|
||
// or when the point list already has >=3 points and
|
||
// the user double-clicks (distance to last point ≤ 4px).
|
||
let close_dist = 10i32;
|
||
let closed = if self.state.lasso_points.len() >= 3 {
|
||
// Check distance to first point
|
||
let (fx, fy) = self.state.lasso_points[0];
|
||
let dx = fx as i32 - ux as i32;
|
||
let dy = fy as i32 - uy as i32;
|
||
let near_start = dx * dx + dy * dy <= close_dist * close_dist;
|
||
// Also check distance to last point (double-click)
|
||
let last = *self.state.lasso_points.last().unwrap();
|
||
let dlx = last.0 as i32 - ux as i32;
|
||
let dly = last.1 as i32 - uy as i32;
|
||
let near_last = dlx * dlx + dly * dly <= 4;
|
||
near_start || near_last
|
||
} else {
|
||
false
|
||
};
|
||
if closed {
|
||
// Finalise polygon
|
||
if self.state.lasso_points.len() >= 3 {
|
||
self.doc
|
||
.engine
|
||
.create_selection_polygon(&self.state.lasso_points);
|
||
self.event_bus.push(AppEvent::RenderRequested);
|
||
}
|
||
self.state.lasso_points.clear();
|
||
} else {
|
||
self.state.lasso_points.push((ux, uy));
|
||
}
|
||
}
|
||
Tool::Move => {
|
||
if let Some(tr) = &mut self.state.transform {
|
||
let m_pos = ui.input(|i| {
|
||
i.pointer
|
||
.hover_pos()
|
||
.unwrap_or(egui::pos2(-1000.0, -1000.0))
|
||
});
|
||
self.state.transform_drag_handle =
|
||
render::get_transform_handle_at(
|
||
m_pos,
|
||
canvas_rect,
|
||
tr,
|
||
zoom,
|
||
);
|
||
} else {
|
||
let mask = self.doc.engine.get_selection_mask();
|
||
if let Some(mask_data) = mask {
|
||
let w = self.doc.engine.canvas_width();
|
||
let h = self.doc.engine.canvas_height();
|
||
if ux < w && uy < h && mask_data[(uy * w + ux) as usize] > 0
|
||
{
|
||
self.state.transform =
|
||
Some(SelectionTransform::from_engine(
|
||
&self.doc.engine,
|
||
mask_data,
|
||
w,
|
||
h,
|
||
));
|
||
// Only clear pixels within the selection area, not the entire layer.
|
||
if let Some(mut layer_pixels) =
|
||
self.doc.engine.get_active_layer_pixels()
|
||
{
|
||
let cw = w as usize;
|
||
let ch = h as usize;
|
||
for y in 0..ch {
|
||
for x in 0..cw {
|
||
let midx = y * cw + x;
|
||
if midx < mask_data.len()
|
||
&& mask_data[midx] > 0
|
||
{
|
||
let pidx = midx * 4;
|
||
if pidx + 3 < layer_pixels.len() {
|
||
layer_pixels[pidx..pidx + 4]
|
||
.fill(0);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
self.doc
|
||
.engine
|
||
.set_active_layer_pixels(layer_pixels);
|
||
}
|
||
self.doc.engine.selection_clear();
|
||
self.event_bus.push(AppEvent::RenderRequested);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
Tool::SmartPatch
|
||
| Tool::SpotRemoval
|
||
| Tool::RedEyeRemoval
|
||
| Tool::AiObjectRemoval => {
|
||
if matches!(self.state.active_tool, Tool::AiObjectRemoval) {
|
||
self.state.smart_patch_points.clear();
|
||
}
|
||
self.state.is_drawing = true;
|
||
self.state.drag_start_pos = Some((ux, uy));
|
||
self.state.last_draw_pos = Some((ux, uy));
|
||
self.state.smart_patch_points.push((ux, uy));
|
||
}
|
||
Tool::VectorSelect => {
|
||
// Check handle hit on currently selected shape first
|
||
let handle_hit = self.state.selected_vector_shape.and_then(|idx| {
|
||
let layer = self.doc.engine.active_layer()?;
|
||
match &layer.data {
|
||
LayerData::Vector { shapes } => {
|
||
let shape = shapes.get(idx)?;
|
||
let screen_pos = egui::pos2(pos.x, pos.y);
|
||
Some(render::get_vector_handle_at(
|
||
screen_pos,
|
||
canvas_rect,
|
||
shape,
|
||
zoom,
|
||
))
|
||
}
|
||
_ => None,
|
||
}
|
||
});
|
||
if let Some(handle) = handle_hit {
|
||
if handle != VectorEditHandle::None {
|
||
self.state.vector_edit_handle = handle;
|
||
// Save snapshot for Apply/Cancel
|
||
if self.state.vector_shapes_snapshot.is_none() {
|
||
if let Some(shapes) =
|
||
self.doc.engine.active_vector_shapes()
|
||
{
|
||
self.state.vector_shapes_snapshot = Some(shapes);
|
||
}
|
||
}
|
||
} else {
|
||
// Clicked on shape body with Move handle - select this shape
|
||
self.state.vector_edit_handle = VectorEditHandle::Move;
|
||
if let Some(shapes) = self.doc.engine.active_vector_shapes()
|
||
{
|
||
self.state.vector_shapes_snapshot = Some(shapes);
|
||
}
|
||
}
|
||
} else if let Some(mut idx) =
|
||
self.doc.engine.find_vector_shape_at(ux as f32, uy as f32)
|
||
{
|
||
// If the shapes are overlapping in the same region and the
|
||
// currently selected shape is at this point, cycle to the
|
||
// next shape below so the user can reach both.
|
||
if Some(idx) == self.state.selected_vector_shape {
|
||
if let Some(shapes) = self.doc.engine.active_vector_shapes()
|
||
{
|
||
// Find another shape at this point (iterating upward
|
||
// from shape 0 so we get the next shape below the
|
||
// currently selected one).
|
||
let mut found_other = false;
|
||
for (i, shape) in shapes.iter().enumerate() {
|
||
if i == idx {
|
||
continue;
|
||
}
|
||
let (l, t, r, b) = shape.normalized_bounds();
|
||
if ux as f32 >= l
|
||
&& ux as f32 <= r
|
||
&& uy as f32 >= t
|
||
&& uy as f32 <= b
|
||
{
|
||
idx = i;
|
||
found_other = true;
|
||
break;
|
||
}
|
||
}
|
||
// If no other shape found at this point, keep the
|
||
// current selection.
|
||
if !found_other {
|
||
idx =
|
||
self.state.selected_vector_shape.unwrap_or(idx);
|
||
}
|
||
}
|
||
}
|
||
self.state.selected_vector_shape = Some(idx);
|
||
self.state.vector_edit_handle = VectorEditHandle::None;
|
||
// Save snapshot for property edits
|
||
if let Some(shapes) = self.doc.engine.active_vector_shapes() {
|
||
self.state.vector_shapes_snapshot = Some(shapes);
|
||
}
|
||
// Sync tool state from selected shape
|
||
shape_sync::sync_tool_from_shape(self.doc, self.state);
|
||
} else {
|
||
self.state.selected_vector_shape = None;
|
||
self.state.vector_edit_handle = VectorEditHandle::None;
|
||
self.state.vector_shapes_snapshot = None;
|
||
}
|
||
self.state.is_drawing = true;
|
||
self.state.drag_start_pos = Some((ux, uy));
|
||
}
|
||
Tool::Eyedropper => {
|
||
if let Some(layer_id) = self.doc.engine.active_layer_id_opt() {
|
||
if let Some(px) = self.doc.engine.get_pixel(layer_id, ux, uy) {
|
||
self.state.primary_color = px;
|
||
}
|
||
}
|
||
}
|
||
Tool::Text => {
|
||
// Hit-test existing text layers first. If the click lands on a
|
||
// text layer (single click while Text tool active), enter
|
||
// edit mode for that layer instead of creating a new draft.
|
||
if let Some(layer_id) =
|
||
find_text_layer_at(&self.doc.engine, ux as f32, uy as f32)
|
||
{
|
||
self.event_bus.push(
|
||
crate::event_bus::AppEvent::TextLayerSelected(layer_id),
|
||
);
|
||
} else {
|
||
self.event_bus.push(
|
||
crate::event_bus::AppEvent::ToolTextStart { x: ux, y: uy },
|
||
);
|
||
}
|
||
}
|
||
tool if tool.is_shape_tool() => {
|
||
self.state.is_drawing = true;
|
||
self.state.drag_start_pos = Some((ux, uy));
|
||
self.state.last_draw_pos = Some((ux, uy));
|
||
}
|
||
_ => {}
|
||
}
|
||
}
|
||
}
|
||
|
||
let is_dragging = response.dragged_by(egui::PointerButton::Primary)
|
||
|| (primary_down
|
||
&& self.state.is_drawing
|
||
&& self
|
||
.state
|
||
.last_draw_pos
|
||
.map_or(true, |(lx, ly)| lx != ux || ly != uy));
|
||
let is_move_dragging = response.dragged_by(egui::PointerButton::Primary)
|
||
&& self.state.active_tool == Tool::Move
|
||
&& self.state.transform.is_some();
|
||
if is_move_dragging {
|
||
if let Some(tr) = &mut self.state.transform {
|
||
let delta = response.drag_delta();
|
||
let canvas_dx = delta.x / zoom;
|
||
let canvas_dy = delta.y / zoom;
|
||
let aspect = if tr.width > 0 {
|
||
tr.height as f32 / tr.width as f32
|
||
} else {
|
||
1.0
|
||
};
|
||
let shift = ui.input(|i| i.modifiers.shift);
|
||
match self.state.transform_drag_handle {
|
||
TransformHandle::Move => {
|
||
tr.pos.x += canvas_dx;
|
||
tr.pos.y += canvas_dy;
|
||
}
|
||
TransformHandle::TopLeft => {
|
||
tr.pos.x += canvas_dx;
|
||
tr.pos.y += canvas_dy;
|
||
tr.size.x -= canvas_dx;
|
||
tr.size.y -= canvas_dy;
|
||
if shift {
|
||
let s = tr.size.x.max(tr.size.y);
|
||
let new_h = s * aspect;
|
||
tr.pos.y += tr.size.y - new_h;
|
||
tr.size.y = new_h;
|
||
tr.size.x = s;
|
||
}
|
||
}
|
||
TransformHandle::TopRight => {
|
||
tr.pos.y += canvas_dy;
|
||
tr.size.x += canvas_dx;
|
||
tr.size.y -= canvas_dy;
|
||
if shift {
|
||
let s = tr.size.x.max(tr.size.y);
|
||
let new_h = s * aspect;
|
||
tr.pos.y += tr.size.y - new_h;
|
||
tr.size.y = new_h;
|
||
tr.size.x = s;
|
||
}
|
||
}
|
||
TransformHandle::BottomRight => {
|
||
tr.size.x += canvas_dx;
|
||
tr.size.y += canvas_dy;
|
||
if shift {
|
||
let s = tr.size.x.max(tr.size.y);
|
||
tr.size.y = s * aspect;
|
||
tr.size.x = s;
|
||
}
|
||
}
|
||
TransformHandle::BottomLeft => {
|
||
tr.pos.x += canvas_dx;
|
||
tr.size.x -= canvas_dx;
|
||
tr.size.y += canvas_dy;
|
||
if shift {
|
||
let s = tr.size.x.max(tr.size.y);
|
||
let new_w = s;
|
||
let new_h = s * aspect;
|
||
tr.pos.x += tr.size.x - new_w;
|
||
tr.size.x = new_w;
|
||
tr.size.y = new_h;
|
||
}
|
||
}
|
||
TransformHandle::Top => {
|
||
tr.pos.y += canvas_dy;
|
||
tr.size.y -= canvas_dy;
|
||
if shift {
|
||
let new_w = tr.size.y / aspect;
|
||
let old_cx = tr.pos.x + tr.size.x * 0.5;
|
||
tr.pos.x = old_cx - new_w * 0.5;
|
||
tr.size.x = new_w;
|
||
}
|
||
}
|
||
TransformHandle::Bottom => {
|
||
tr.size.y += canvas_dy;
|
||
if shift {
|
||
let new_w = tr.size.y / aspect;
|
||
let old_cx = tr.pos.x + tr.size.x * 0.5;
|
||
tr.pos.x = old_cx - new_w * 0.5;
|
||
tr.size.x = new_w;
|
||
}
|
||
}
|
||
TransformHandle::Left => {
|
||
tr.pos.x += canvas_dx;
|
||
tr.size.x -= canvas_dx;
|
||
if shift {
|
||
let new_h = tr.size.x * aspect;
|
||
let old_cy = tr.pos.y + tr.size.y * 0.5;
|
||
tr.pos.y = old_cy - new_h * 0.5;
|
||
tr.size.y = new_h;
|
||
}
|
||
}
|
||
TransformHandle::Right => {
|
||
tr.size.x += canvas_dx;
|
||
if shift {
|
||
let new_h = tr.size.x * aspect;
|
||
let old_cy = tr.pos.y + tr.size.y * 0.5;
|
||
tr.pos.y = old_cy - new_h * 0.5;
|
||
tr.size.y = new_h;
|
||
}
|
||
}
|
||
TransformHandle::None => {}
|
||
}
|
||
tr.size.x = tr.size.x.max(1.0);
|
||
tr.size.y = tr.size.y.max(1.0);
|
||
}
|
||
}
|
||
if is_dragging && self.state.is_drawing {
|
||
let is_ctrl = ui.input(|i| i.modifiers.command || i.modifiers.ctrl);
|
||
if is_ctrl {
|
||
if let Some(layer_id) = self.doc.engine.active_layer_id_opt() {
|
||
if let Some(px) = self.doc.engine.get_pixel(layer_id, ux, uy) {
|
||
self.state.primary_color = px;
|
||
}
|
||
}
|
||
} else if self.state.is_resizing_brush {
|
||
if let Some(start_pos) = self.state.brush_resize_start_pos {
|
||
let current_pos =
|
||
ui.input(|i| i.pointer.hover_pos().unwrap_or(start_pos));
|
||
let dx = current_pos.x - start_pos.x;
|
||
let new_size =
|
||
(self.state.brush_resize_start_size + dx).clamp(1.0, 500.0);
|
||
match self.state.active_tool {
|
||
Tool::Pen => self.state.tool_configs.pen.size = new_size,
|
||
Tool::Brush => self.state.tool_configs.brush.size = new_size,
|
||
Tool::Eraser => self.state.tool_configs.eraser.size = new_size,
|
||
Tool::Spray => self.state.tool_configs.spray.radius = new_size,
|
||
_ => {}
|
||
}
|
||
}
|
||
} else {
|
||
log::trace!("canvas drag segment: ux={}, uy={}", ux, uy);
|
||
match self.state.active_tool {
|
||
Tool::Pen => {
|
||
if let Some((lx, ly)) = self.state.last_draw_pos {
|
||
let input_at = std::time::Instant::now();
|
||
perf::record_input(input_at);
|
||
let engine_start = std::time::Instant::now();
|
||
let layer_id = self.doc.engine.active_layer_id();
|
||
if !self.doc.engine.is_stroke_active() {
|
||
self.doc
|
||
.engine
|
||
.begin_stroke(layer_id, lx as f32, ly as f32);
|
||
}
|
||
self.doc.engine.draw_pen_segment(
|
||
layer_id,
|
||
lx as f32,
|
||
ly as f32,
|
||
ux as f32,
|
||
uy as f32,
|
||
self.state.current_pressure,
|
||
);
|
||
perf::record_duration("engine_draw", engine_start.elapsed());
|
||
self.state.last_draw_pos = Some((ux, uy));
|
||
}
|
||
}
|
||
Tool::Brush | Tool::Eraser | Tool::Spray => {
|
||
if let Some(last) = self.state.last_draw_pos {
|
||
let dx = (ux as f32 - last.0 as f32)
|
||
.hypot(uy as f32 - last.1 as f32);
|
||
self.state.brush_accumulated_dist += dx;
|
||
// Pointer-event downsampling: do not forward every sub-pixel motion
|
||
// event to the engine. On 4K canvases each stroke_to call triggers
|
||
// dirty-bound expansion and tile sync; coalescing events reduces
|
||
// that overhead without visible quality loss for basic brush tools.
|
||
let spacing = match self.state.active_tool {
|
||
Tool::Spray => self.state.tool_configs.spray.radius * 0.25,
|
||
_ => self.state.tool_configs.brush.size * 0.12,
|
||
}
|
||
.max(1.0);
|
||
if dx >= spacing {
|
||
let input_at = std::time::Instant::now();
|
||
perf::record_input(input_at);
|
||
let engine_start = std::time::Instant::now();
|
||
let layer_id = self.doc.engine.active_layer_id();
|
||
self.doc.engine.stroke_to(
|
||
layer_id,
|
||
ux as f32,
|
||
uy as f32,
|
||
self.state.current_pressure,
|
||
);
|
||
perf::record_duration(
|
||
"engine_draw",
|
||
engine_start.elapsed(),
|
||
);
|
||
self.state.last_draw_pos = Some((ux, uy));
|
||
}
|
||
}
|
||
}
|
||
Tool::Select => {
|
||
if let Some(rect) = self.state.selection_rect.as_mut() {
|
||
rect[2] = ux;
|
||
rect[3] = uy;
|
||
}
|
||
}
|
||
Tool::Lasso => {
|
||
if let Some(&(lx, ly)) = self.state.lasso_points.last() {
|
||
if (lx as i32 - ux as i32).abs() > 2
|
||
|| (ly as i32 - uy as i32).abs() > 2
|
||
{
|
||
self.state.lasso_points.push((ux, uy));
|
||
}
|
||
}
|
||
}
|
||
Tool::VisionSelect => {
|
||
self.state.last_draw_pos = Some((ux, uy));
|
||
if let Some(rect) = self.state.vision_select_rect.as_mut() {
|
||
rect[2] = ux;
|
||
rect[3] = uy;
|
||
}
|
||
self.event_bus.push(AppEvent::RenderRequested);
|
||
}
|
||
Tool::Move => {
|
||
// Move tool drag is handled above in is_move_dragging block
|
||
}
|
||
Tool::SmartPatch
|
||
| Tool::SpotRemoval
|
||
| Tool::RedEyeRemoval
|
||
| Tool::AiObjectRemoval => {
|
||
self.state.smart_patch_points.push((ux, uy));
|
||
self.state.last_draw_pos = Some((ux, uy));
|
||
}
|
||
Tool::VectorSelect => {
|
||
if let Some(shape_idx) = self.state.selected_vector_shape {
|
||
let handle = self.state.vector_edit_handle;
|
||
let layer_id = self.doc.engine.active_layer_id();
|
||
let delta = response.drag_delta();
|
||
let canvas_dx = delta.x / zoom;
|
||
let canvas_dy = delta.y / zoom;
|
||
if handle == VectorEditHandle::Rotate {
|
||
// Rotation: compute angle delta from mouse movement around shape center
|
||
if let Some((cx, cy)) = self
|
||
.doc
|
||
.engine
|
||
.get_vector_shape_center(layer_id, shape_idx)
|
||
{
|
||
let curr_angle = (uy as f32 - cy).atan2(ux as f32 - cx);
|
||
let prev_angle = if let Some(prev_pos) =
|
||
self.state.vector_drag_last
|
||
{
|
||
(prev_pos.y - cy).atan2(prev_pos.x - cx)
|
||
} else {
|
||
curr_angle
|
||
};
|
||
let da = curr_angle - prev_angle;
|
||
let mut angle = self
|
||
.doc
|
||
.engine
|
||
.vector_shape_angle(layer_id, shape_idx);
|
||
angle += da;
|
||
let shift = ui.input(|i| i.modifiers.shift);
|
||
if shift {
|
||
let step = std::f32::consts::PI / 12.0;
|
||
angle = (angle / step).round() * step;
|
||
}
|
||
self.doc
|
||
.engine
|
||
.set_vector_shape_angle(layer_id, shape_idx, angle);
|
||
self.state.vector_drag_last =
|
||
Some(egui::pos2(ux as f32, uy as f32));
|
||
if self.doc.engine.get_svg_source(layer_id).is_some() {
|
||
let w = self.doc.engine.canvas_width();
|
||
let h = self.doc.engine.canvas_height();
|
||
if let Some(shapes) =
|
||
self.doc.engine.active_vector_shapes()
|
||
{
|
||
crate::app::io::custom_loaders_kra_v2::rerasterize_svg_layer(&mut self.doc.engine, layer_id, &shapes, w, h);
|
||
}
|
||
}
|
||
self.event_bus.push(AppEvent::RenderRequested);
|
||
}
|
||
} else {
|
||
self.state.vector_drag_last = None;
|
||
let effective_handle = if handle == VectorEditHandle::None {
|
||
VectorEditHandle::Move
|
||
} else {
|
||
handle
|
||
};
|
||
self.doc.engine.modify_vector_shape(
|
||
layer_id,
|
||
shape_idx,
|
||
effective_handle,
|
||
canvas_dx,
|
||
canvas_dy,
|
||
);
|
||
if self.doc.engine.get_svg_source(layer_id).is_some() {
|
||
let w = self.doc.engine.canvas_width();
|
||
let h = self.doc.engine.canvas_height();
|
||
if let Some(shapes) =
|
||
self.doc.engine.active_vector_shapes()
|
||
{
|
||
crate::app::io::custom_loaders_kra_v2::rerasterize_svg_layer(&mut self.doc.engine, layer_id, &shapes, w, h);
|
||
}
|
||
}
|
||
self.event_bus.push(AppEvent::RenderRequested);
|
||
}
|
||
}
|
||
}
|
||
tool if tool.is_shape_tool() => {
|
||
self.state.last_draw_pos = Some((ux, uy));
|
||
self.event_bus.push(AppEvent::RenderRequested);
|
||
}
|
||
_ => {}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
let is_ctrl = ui.input(|i| i.modifiers.command || i.modifiers.ctrl);
|
||
if (response.drag_stopped_by(egui::PointerButton::Primary) || primary_released)
|
||
&& was_drawing
|
||
{
|
||
log::debug!(
|
||
"Canvas stroke finished (egui_stopped: {}, fallback_released: {})",
|
||
response.drag_stopped_by(egui::PointerButton::Primary),
|
||
primary_released
|
||
);
|
||
let was_resizing = self.state.is_resizing_brush;
|
||
self.state.is_resizing_brush = false;
|
||
|
||
if !was_resizing && !is_ctrl {
|
||
match self.state.active_tool {
|
||
Tool::Move => {
|
||
if let Some(tr) = self.state.transform.take() {
|
||
apply_transform_to_layer(&mut self.doc.engine, &tr);
|
||
self.state.transform_drag_handle = TransformHandle::None;
|
||
self.event_bus.push(AppEvent::RenderRequested);
|
||
}
|
||
}
|
||
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();
|
||
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 {
|
||
Tool::Pen => "Pen Stroke".to_string(),
|
||
Tool::Eraser => "Eraser Stroke".to_string(),
|
||
Tool::Spray => "Spray Stroke".to_string(),
|
||
Tool::Brush => match self.state.tool_configs.brush.style {
|
||
hcie_engine_api::tools::BrushStyle::Default => {
|
||
"Default Brush Stroke".to_string()
|
||
}
|
||
hcie_engine_api::tools::BrushStyle::Round => {
|
||
"Round Brush Stroke".to_string()
|
||
}
|
||
hcie_engine_api::tools::BrushStyle::Square => {
|
||
"Square Brush Stroke".to_string()
|
||
}
|
||
hcie_engine_api::tools::BrushStyle::HardRound => {
|
||
"Hard Round Brush Stroke".to_string()
|
||
}
|
||
hcie_engine_api::tools::BrushStyle::SoftRound => {
|
||
"Soft Round Brush Stroke".to_string()
|
||
}
|
||
hcie_engine_api::tools::BrushStyle::Noise => {
|
||
"Noise Brush Stroke".to_string()
|
||
}
|
||
hcie_engine_api::tools::BrushStyle::Texture => {
|
||
"Texture Brush Stroke".to_string()
|
||
}
|
||
hcie_engine_api::tools::BrushStyle::Spray => {
|
||
"Spray Brush Stroke".to_string()
|
||
}
|
||
hcie_engine_api::tools::BrushStyle::Pen => {
|
||
"Pen Brush Stroke".to_string()
|
||
}
|
||
hcie_engine_api::tools::BrushStyle::Oil => {
|
||
"Oil Brush Stroke".to_string()
|
||
}
|
||
hcie_engine_api::tools::BrushStyle::Charcoal => {
|
||
"Charcoal Brush Stroke".to_string()
|
||
}
|
||
hcie_engine_api::tools::BrushStyle::Leaf => {
|
||
"Leaf Brush Stroke".to_string()
|
||
}
|
||
hcie_engine_api::tools::BrushStyle::Rock => {
|
||
"Rock Brush Stroke".to_string()
|
||
}
|
||
hcie_engine_api::tools::BrushStyle::Meadow => {
|
||
"Meadow Brush Stroke".to_string()
|
||
}
|
||
hcie_engine_api::tools::BrushStyle::Wood => {
|
||
"Wood Brush Stroke".to_string()
|
||
}
|
||
hcie_engine_api::tools::BrushStyle::Watercolor => {
|
||
"Watercolor Brush Stroke".to_string()
|
||
}
|
||
hcie_engine_api::tools::BrushStyle::Calligraphy => {
|
||
"Calligraphy Brush Stroke".to_string()
|
||
}
|
||
hcie_engine_api::tools::BrushStyle::Marker => {
|
||
"Marker Brush Stroke".to_string()
|
||
}
|
||
hcie_engine_api::tools::BrushStyle::Sketch => {
|
||
"Sketch Brush Stroke".to_string()
|
||
}
|
||
hcie_engine_api::tools::BrushStyle::Hatch => {
|
||
"Hatch Brush Stroke".to_string()
|
||
}
|
||
hcie_engine_api::tools::BrushStyle::Star => {
|
||
"Star Brush Stroke".to_string()
|
||
}
|
||
hcie_engine_api::tools::BrushStyle::Glow => {
|
||
"Glow Brush Stroke".to_string()
|
||
}
|
||
hcie_engine_api::tools::BrushStyle::Airbrush => {
|
||
"Airbrush Stroke".to_string()
|
||
}
|
||
hcie_engine_api::tools::BrushStyle::Pencil => {
|
||
"Pencil Brush Stroke".to_string()
|
||
}
|
||
hcie_engine_api::tools::BrushStyle::Crayon => {
|
||
"Crayon Brush Stroke".to_string()
|
||
}
|
||
hcie_engine_api::tools::BrushStyle::WetPaint => {
|
||
"Wet Paint Brush Stroke".to_string()
|
||
}
|
||
hcie_engine_api::tools::BrushStyle::InkPen => {
|
||
"Ink Pen Stroke".to_string()
|
||
}
|
||
hcie_engine_api::tools::BrushStyle::Clouds => {
|
||
"Clouds Brush Stroke".to_string()
|
||
}
|
||
hcie_engine_api::tools::BrushStyle::Dirt => {
|
||
"Dirt Brush Stroke".to_string()
|
||
}
|
||
hcie_engine_api::tools::BrushStyle::Tree => {
|
||
"Tree Brush Stroke".to_string()
|
||
}
|
||
hcie_engine_api::tools::BrushStyle::Bristle => {
|
||
"Bristle Brush Stroke".to_string()
|
||
}
|
||
hcie_engine_api::tools::BrushStyle::Mixer => {
|
||
"Mixer Brush Stroke".to_string()
|
||
}
|
||
hcie_engine_api::tools::BrushStyle::Blender => {
|
||
"Blender Brush Stroke".to_string()
|
||
}
|
||
hcie_engine_api::tools::BrushStyle::Bitmap => {
|
||
"Bitmap Brush Stroke".to_string()
|
||
}
|
||
},
|
||
_ => "Brush Stroke".to_string(),
|
||
};
|
||
self.doc.record_history_description(desc);
|
||
|
||
self.event_bus.push(AppEvent::RenderRequested);
|
||
self.event_bus.push(AppEvent::DrawingFinished);
|
||
perf::request_finish();
|
||
}
|
||
Tool::Select => {
|
||
if let Some(rect) = self.state.selection_rect {
|
||
let x1 = rect[0].min(rect[2]);
|
||
let y1 = rect[1].min(rect[3]);
|
||
let x2 = rect[0].max(rect[2]);
|
||
let y2 = rect[1].max(rect[3]);
|
||
self.doc.engine.create_selection_rect(x1, y1, x2, y2);
|
||
self.state.selection_rect = None;
|
||
self.event_bus.push(AppEvent::RenderRequested);
|
||
}
|
||
}
|
||
Tool::MagicWand => {
|
||
// Magic Wand fires on press (primary_pressed), not on drag release.
|
||
// Nothing extra needed here — reset drawing state only.
|
||
}
|
||
Tool::Lasso => {
|
||
if self.state.lasso_points.len() >= 3 {
|
||
self.doc
|
||
.engine
|
||
.create_selection_lasso(&self.state.lasso_points);
|
||
self.event_bus.push(AppEvent::RenderRequested);
|
||
}
|
||
self.state.lasso_points.clear();
|
||
}
|
||
Tool::PolygonSelect => {
|
||
// PolygonSelect is finalised on click (primary_pressed path above).
|
||
// drag_stopped here means the user dragged slightly while clicking —
|
||
// treat as a normal vertex add (already done), no extra action needed.
|
||
}
|
||
Tool::SmartPatch => {
|
||
self.event_bus.push(AppEvent::SmartPatch);
|
||
}
|
||
Tool::SmartSelect => {
|
||
// SmartSelect already fired on primary press; do not fire again on release.
|
||
}
|
||
Tool::VisionSelect => {
|
||
// Finish a VisionSelect interaction. If the user dragged a meaningful
|
||
// distance, treat it as a bounding-box prompt for SAM; otherwise use the
|
||
// click point as a point prompt.
|
||
if let (Some((x1, y1)), Some((x2, y2))) =
|
||
(self.state.drag_start_pos, self.state.last_draw_pos)
|
||
{
|
||
let dx = x1 as i32 - x2 as i32;
|
||
let dy = y1 as i32 - y2 as i32;
|
||
if dx * dx + dy * dy > 4 {
|
||
self.event_bus.push(AppEvent::VisionSelectRun {
|
||
x1: x1.min(x2),
|
||
y1: y1.min(y2),
|
||
x2: x1.max(x2),
|
||
y2: y1.max(y2),
|
||
});
|
||
} else {
|
||
self.event_bus.push(AppEvent::VisionSelectRun {
|
||
x1: x1,
|
||
y1: y1,
|
||
x2: x1,
|
||
y2: y1,
|
||
});
|
||
}
|
||
}
|
||
self.state.vision_select_rect = None;
|
||
}
|
||
Tool::SpotRemoval => {
|
||
self.event_bus.push(AppEvent::SpotRemoval);
|
||
}
|
||
Tool::RedEyeRemoval => {
|
||
self.event_bus.push(AppEvent::RedEyeRemoval);
|
||
}
|
||
Tool::AiObjectRemoval => {
|
||
// handled via smart_patch_points
|
||
}
|
||
tool if tool.is_shape_tool() => {
|
||
if let (Some((x1, y1)), Some((x2, y2))) =
|
||
(self.state.drag_start_pos, self.state.last_draw_pos)
|
||
{
|
||
let shape = create_shape_from_drag(
|
||
tool,
|
||
x1 as f32,
|
||
y1 as f32,
|
||
x2 as f32,
|
||
y2 as f32,
|
||
&self.state,
|
||
&self.doc.engine.shape_catalog,
|
||
);
|
||
self.doc.engine.add_vector_shape(shape);
|
||
// Auto-select the newly created shape
|
||
if let Some(shapes) = self.doc.engine.active_vector_shapes() {
|
||
self.state.selected_vector_shape = Some(shapes.len() - 1);
|
||
self.state.vector_shapes_snapshot = Some(shapes.clone());
|
||
}
|
||
self.event_bus.push(AppEvent::RenderRequested);
|
||
self.event_bus.push(AppEvent::DrawingFinished);
|
||
}
|
||
}
|
||
Tool::VectorSelect => {
|
||
self.state.vector_drag_last = None;
|
||
}
|
||
_ => {}
|
||
}
|
||
}
|
||
self.state.is_drawing = false;
|
||
self.state.drag_start_pos = None;
|
||
self.state.last_draw_pos = None;
|
||
self.event_bus.push(AppEvent::SaveSettings);
|
||
}
|
||
|
||
// ── Render composite texture ───────────────────────────
|
||
let created_new_texture =
|
||
render::render_composition(ui.ctx(), self.doc, self.state, canvas_rect, zoom);
|
||
// egui uploads textures asynchronously — the frame that creates the texture
|
||
// will NOT display it yet. Request multiple repaints so the GPU upload completes
|
||
// and the canvas appears without requiring a user click on the tab title.
|
||
// Two frames are the minimum; a third covers slow GPU drivers.
|
||
if created_new_texture {
|
||
ui.ctx().request_repaint();
|
||
ui.ctx()
|
||
.request_repaint_after(std::time::Duration::from_millis(33));
|
||
}
|
||
|
||
// Checkerboard behind transparent areas
|
||
// Batched into a single draw call to avoid 9K+ individual primitive allocations.
|
||
// Tiles are aligned to the screen origin (0,0) so the pattern stays
|
||
// perfectly static during zoom/pan instead of shifting with canvas_rect.
|
||
{
|
||
let tile_size = 30.0;
|
||
let color_a = egui::Color32::from_gray(170);
|
||
let color_b = egui::Color32::from_gray(200);
|
||
let mut shapes: Vec<egui::Shape> = Vec::with_capacity(2048);
|
||
let x_start = 0.0f32;
|
||
let y_start = 0.0f32;
|
||
let mut x = x_start;
|
||
let mut row = 0i32;
|
||
while x < canvas_rect.max.x {
|
||
let mut y = y_start;
|
||
let mut col = 0i32;
|
||
while y < canvas_rect.max.y {
|
||
let tile_rect = egui::Rect::from_min_max(
|
||
egui::pos2(x, y),
|
||
egui::pos2(x + tile_size, y + tile_size),
|
||
);
|
||
if tile_rect.intersects(canvas_rect) {
|
||
let fill = if (row + col) & 1 == 0 {
|
||
color_a
|
||
} else {
|
||
color_b
|
||
};
|
||
let clipped = tile_rect.intersect(canvas_rect);
|
||
shapes.push(egui::Shape::rect_filled(clipped, 0.0, fill));
|
||
}
|
||
y += tile_size;
|
||
col += 1;
|
||
}
|
||
x += tile_size;
|
||
row += 1;
|
||
}
|
||
painter.extend(shapes);
|
||
}
|
||
|
||
// Paint the texture
|
||
if let Some(tex) = &self.doc.composite_texture {
|
||
painter.image(
|
||
tex.id(),
|
||
canvas_rect,
|
||
egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)),
|
||
egui::Color32::WHITE,
|
||
);
|
||
} else {
|
||
painter.rect_filled(canvas_rect, 0.0, egui::Color32::from_gray(40));
|
||
}
|
||
|
||
// Canvas border around composited texture area
|
||
painter.rect_stroke(
|
||
canvas_rect.expand(1.0),
|
||
0.0,
|
||
egui::Stroke::new(1.0, egui::Color32::from_gray(100)),
|
||
egui::StrokeKind::Outside,
|
||
);
|
||
|
||
// Vector handle overlay (must be after texture paint)
|
||
render::draw_vector_handles(&painter, ui.ctx(), self.doc, self.state, canvas_rect, zoom);
|
||
|
||
// ── Shape tool realtime preview ──────────────────────────
|
||
// Draws a bright preview while the user is dragging a shape.
|
||
// This is drawn directly on top of the canvas so it is visible even
|
||
// before the engine has composited the final shape.
|
||
//
|
||
// DO NOT REMOVE OR BYPASS THIS PREVIEW. Removing it causes the shape tool
|
||
// to feel broken because the user sees no feedback while dragging.
|
||
if self.state.active_tool.is_shape_tool() {
|
||
if let Some((x1, y1)) = self.state.drag_start_pos {
|
||
// Use last drag position if available, otherwise fall back to the
|
||
// current cursor position so the preview appears immediately.
|
||
let (x2, y2) = self
|
||
.state
|
||
.last_draw_pos
|
||
.or(self.state.cursor_canvas_pos.map(|p| (p.0, p.1)))
|
||
.unwrap_or((x1, y1));
|
||
let sx = x1.min(x2) as f32;
|
||
let sy = y1.min(y2) as f32;
|
||
let ex = x1.max(x2) as f32;
|
||
let ey = y1.max(y2) as f32;
|
||
|
||
// Always draw a high-visibility filled guide rectangle so the
|
||
// user can see the drag area even for tools that render a thin
|
||
// outline (line, polygon, etc.).
|
||
let guide_rect = egui::Rect::from_min_max(
|
||
canvas_rect.min + egui::vec2(sx * zoom, sy * zoom),
|
||
canvas_rect.min + egui::vec2(ex * zoom, ey * zoom),
|
||
);
|
||
painter.rect_filled(
|
||
guide_rect,
|
||
0.0,
|
||
egui::Color32::from_rgba_unmultiplied(0, 200, 255, 40),
|
||
);
|
||
|
||
let preview_stroke = egui::Stroke::new(2.0, egui::Color32::from_rgb(0, 220, 255));
|
||
draw_shape_tool_preview(
|
||
&painter,
|
||
self.state.active_tool,
|
||
canvas_rect.min,
|
||
zoom,
|
||
sx,
|
||
sy,
|
||
ex,
|
||
ey,
|
||
preview_stroke,
|
||
&self.state.tool_configs.vector,
|
||
);
|
||
}
|
||
}
|
||
|
||
// ── 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 self.state.active_tool.shows_pen_tips() {
|
||
let screen_x = canvas_rect.min.x + cx as f32 * zoom;
|
||
let screen_y = canvas_rect.min.y + cy as f32 * zoom;
|
||
|
||
// Build a live tip from the active tool config so the preview matches
|
||
// the Properties panel in real time.
|
||
let mut tip = hcie_engine_api::BrushTip::default();
|
||
match self.state.active_tool {
|
||
Tool::Pen => {
|
||
tip.style = hcie_engine_api::BrushStyle::Round;
|
||
tip.size = self.state.tool_configs.pen.size;
|
||
tip.opacity = self.state.tool_configs.pen.opacity;
|
||
tip.hardness = self.state.tool_configs.pen.hardness;
|
||
}
|
||
Tool::Eraser => {
|
||
tip.style = hcie_engine_api::BrushStyle::Round;
|
||
tip.size = self.state.tool_configs.eraser.size;
|
||
tip.opacity = self.state.tool_configs.eraser.opacity;
|
||
tip.hardness = self.state.tool_configs.eraser.hardness;
|
||
}
|
||
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
|
||
{
|
||
tip.bitmap_pixels = preset.tip.bitmap_pixels.clone();
|
||
tip.bitmap_w = preset.tip.bitmap_w;
|
||
tip.bitmap_h = preset.tip.bitmap_h;
|
||
// aspect ratio is preserved by scaling to the square stamp
|
||
is_bitmap = true;
|
||
}
|
||
}
|
||
}
|
||
|
||
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), display_dim);
|
||
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),
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── Selection overlay ──────────────────────────────────
|
||
// Draw semi-transparent fill for selected region + marching ants
|
||
if self.doc.engine.is_selection_active() {
|
||
if let Some(mask) = self.doc.engine.get_selection_mask() {
|
||
let w = self.doc.engine.canvas_width();
|
||
let h = self.doc.engine.canvas_height();
|
||
|
||
// Draw semi-transparent purple fill over selected pixels using row spans
|
||
let sel_color = egui::Color32::from_rgba_premultiplied(128, 0, 255, 40);
|
||
let step = if zoom >= 4.0 {
|
||
1
|
||
} else if zoom >= 1.0 {
|
||
2
|
||
} else {
|
||
4
|
||
};
|
||
for cy in (0..h).step_by(step) {
|
||
let mut run_start: Option<u32> = None;
|
||
for cx in 0..=w {
|
||
let selected =
|
||
cx < w && mask.get((cy * w + cx) as usize).copied().unwrap_or(0) > 128;
|
||
if selected && run_start.is_none() {
|
||
run_start = Some(cx);
|
||
} else if !selected && run_start.is_some() {
|
||
let sx = canvas_rect.min.x + run_start.unwrap() as f32 * zoom;
|
||
let ex = canvas_rect.min.x + cx as f32 * zoom;
|
||
let sy = canvas_rect.min.y + cy as f32 * zoom;
|
||
let ey = sy + (step as f32 * zoom).min(canvas_rect.max.y - sy);
|
||
painter.rect_filled(
|
||
egui::Rect::from_min_max(egui::pos2(sx, sy), egui::pos2(ex, ey)),
|
||
0.0,
|
||
sel_color,
|
||
);
|
||
run_start = None;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Draw marching ants on edges
|
||
let time = ui.input(|i| i.time);
|
||
render::draw_selection_overlay(
|
||
&mut self.doc.selection_edge_cache,
|
||
&painter,
|
||
canvas_rect,
|
||
mask,
|
||
w,
|
||
h,
|
||
zoom,
|
||
time,
|
||
);
|
||
ui.ctx()
|
||
.request_repaint_after(std::time::Duration::from_millis(33));
|
||
}
|
||
}
|
||
// Draw selection rect preview while dragging
|
||
if let Some(rect) = self.state.selection_rect {
|
||
let x1 = rect[0].min(rect[2]);
|
||
let y1 = rect[1].min(rect[3]);
|
||
let x2 = rect[0].max(rect[2]);
|
||
let y2 = rect[1].max(rect[3]);
|
||
let r = egui::Rect::from_min_max(
|
||
canvas_rect.min + egui::vec2(x1 as f32 * zoom, y1 as f32 * zoom),
|
||
canvas_rect.min + egui::vec2(x2 as f32 * zoom, y2 as f32 * zoom),
|
||
);
|
||
painter.rect_stroke(
|
||
r,
|
||
0.0,
|
||
egui::Stroke::new(1.0, egui::Color32::from_rgb(0, 120, 215)),
|
||
egui::StrokeKind::Outside,
|
||
);
|
||
}
|
||
// Draw VisionSelect bbox preview while dragging
|
||
if let Some(rect) = self.state.vision_select_rect {
|
||
let x1 = rect[0].min(rect[2]);
|
||
let y1 = rect[1].min(rect[3]);
|
||
let x2 = rect[0].max(rect[2]);
|
||
let y2 = rect[1].max(rect[3]);
|
||
let r = egui::Rect::from_min_max(
|
||
canvas_rect.min + egui::vec2(x1 as f32 * zoom, y1 as f32 * zoom),
|
||
canvas_rect.min + egui::vec2(x2 as f32 * zoom, y2 as f32 * zoom),
|
||
);
|
||
painter.rect_stroke(
|
||
r,
|
||
0.0,
|
||
egui::Stroke::new(1.5, egui::Color32::from_rgb(255, 140, 0)),
|
||
egui::StrokeKind::Outside,
|
||
);
|
||
}
|
||
// Draw lasso path while dragging (Lasso tool — continuous drag)
|
||
if self.state.is_drawing
|
||
&& self.state.active_tool == Tool::Lasso
|
||
&& self.state.lasso_points.len() > 1
|
||
{
|
||
let to_screen = |p: &(u32, u32)| -> egui::Pos2 {
|
||
canvas_rect.min + egui::vec2(p.0 as f32 * zoom, p.1 as f32 * zoom)
|
||
};
|
||
for i in 0..self.state.lasso_points.len() - 1 {
|
||
painter.line_segment(
|
||
[
|
||
to_screen(&self.state.lasso_points[i]),
|
||
to_screen(&self.state.lasso_points[i + 1]),
|
||
],
|
||
egui::Stroke::new(1.5, egui::Color32::WHITE),
|
||
);
|
||
}
|
||
}
|
||
|
||
// Draw PolygonSelect in-progress polygon (click-by-click vertices)
|
||
if self.state.active_tool == Tool::PolygonSelect && self.state.lasso_points.len() > 0 {
|
||
let to_screen = |p: &(u32, u32)| -> egui::Pos2 {
|
||
canvas_rect.min + egui::vec2(p.0 as f32 * zoom, p.1 as f32 * zoom)
|
||
};
|
||
// Draw edges between consecutive vertices
|
||
for i in 0..self.state.lasso_points.len().saturating_sub(1) {
|
||
painter.line_segment(
|
||
[
|
||
to_screen(&self.state.lasso_points[i]),
|
||
to_screen(&self.state.lasso_points[i + 1]),
|
||
],
|
||
egui::Stroke::new(1.5, egui::Color32::from_rgb(0, 200, 255)),
|
||
);
|
||
}
|
||
// Draw a dotted/closing line from last vertex to cursor (if available)
|
||
if let Some(cursor) = self.state.cursor_canvas_pos {
|
||
if let Some(&last) = self.state.lasso_points.last() {
|
||
painter.line_segment(
|
||
[to_screen(&last), to_screen(&cursor)],
|
||
egui::Stroke::new(
|
||
1.0,
|
||
egui::Color32::from_rgba_unmultiplied(0, 200, 255, 100),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
// Show close-ring indicator near the first vertex (≥3 points only)
|
||
if self.state.lasso_points.len() >= 3 {
|
||
let first_screen = to_screen(&self.state.lasso_points[0]);
|
||
painter.circle_stroke(
|
||
first_screen,
|
||
10.0 * zoom.min(1.0),
|
||
egui::Stroke::new(1.5, egui::Color32::from_rgb(0, 255, 100)),
|
||
);
|
||
}
|
||
// Draw vertex dots
|
||
for pt in &self.state.lasso_points {
|
||
painter.circle_filled(to_screen(pt), 3.0, egui::Color32::from_rgb(0, 200, 255));
|
||
}
|
||
}
|
||
|
||
// ── Transform overlay ────────────────────────────────
|
||
if self.state.transform.is_some() {
|
||
let ctx = ui.ctx().clone();
|
||
if render::draw_transform_overlay(
|
||
ui,
|
||
canvas_rect,
|
||
&self.state,
|
||
zoom,
|
||
&ctx,
|
||
self.transform_texture,
|
||
) {
|
||
if let Some(tr) = self.state.transform.take() {
|
||
crate::canvas::apply_transform_to_layer(&mut self.doc.engine, &tr);
|
||
self.state.transform_drag_handle = TransformHandle::None;
|
||
self.event_bus.push(AppEvent::RenderRequested);
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── Vector shape Apply/Cancel overlay ────────────────────
|
||
// Shows while a vector shape is being transformed so the user can
|
||
// commit (OK) or revert (Cancel) the change. OK also deselects the
|
||
// shape so the handles disappear, matching typical vector editor UX.
|
||
if self.state.vector_shapes_snapshot.is_some() {
|
||
if let Some(idx) = self.state.selected_vector_shape {
|
||
if let Some(shapes) = self.doc.engine.active_vector_shapes() {
|
||
if let Some(shape) = shapes.get(idx) {
|
||
let (x1, y1, x2, y2) = shape.normalized_bounds();
|
||
let cx = (x1 + x2) * 0.5;
|
||
let cy = (y1 + y2) * 0.5;
|
||
let btn_pos = canvas_rect.min
|
||
+ egui::vec2(cx * zoom, cy * zoom)
|
||
+ egui::vec2(24.0, -24.0);
|
||
egui::Area::new(ui.id().with("vec_apply_cancel"))
|
||
.fixed_pos(btn_pos)
|
||
.order(egui::Order::Foreground)
|
||
.show(ui.ctx(), |ui| {
|
||
ui.horizontal(|ui| {
|
||
if ui
|
||
.add(
|
||
egui::Button::new("✓")
|
||
.fill(egui::Color32::from_rgb(40, 100, 40)),
|
||
)
|
||
.clicked()
|
||
{
|
||
// Commit: clear the snapshot and deselect the shape.
|
||
self.state.vector_shapes_snapshot = None;
|
||
self.state.selected_vector_shape = None;
|
||
self.state.vector_edit_handle = VectorEditHandle::None;
|
||
self.event_bus.push(AppEvent::RenderRequested);
|
||
}
|
||
if ui
|
||
.add(
|
||
egui::Button::new("✗")
|
||
.fill(egui::Color32::from_rgb(120, 40, 40)),
|
||
)
|
||
.clicked()
|
||
{
|
||
let layer_id = self.doc.engine.active_layer_id();
|
||
if let Some(snapshot) =
|
||
self.state.vector_shapes_snapshot.take()
|
||
{
|
||
if let Some(shapes) =
|
||
self.doc.engine.get_layer_shapes_direct(layer_id)
|
||
{
|
||
*shapes = snapshot;
|
||
self.doc.engine.mark_composite_dirty();
|
||
}
|
||
}
|
||
// Revert: deselect and request a re-render so the
|
||
// original shape is visible again.
|
||
self.state.selected_vector_shape = None;
|
||
self.state.vector_edit_handle = VectorEditHandle::None;
|
||
self.event_bus.push(AppEvent::RenderRequested);
|
||
}
|
||
// Flip H/V buttons for Bubble and Arrow shapes
|
||
if let Some(idx) = self.state.selected_vector_shape {
|
||
let layer_id = self.doc.engine.active_layer_id();
|
||
if let Some(shapes) = self.doc.engine.active_vector_shapes()
|
||
{
|
||
if let Some(shape) = shapes.get(idx) {
|
||
let can_flip = matches!(
|
||
shape,
|
||
VectorShape::Bubble { .. }
|
||
| VectorShape::Arrow { .. }
|
||
| VectorShape::Arrow4 { .. }
|
||
);
|
||
if can_flip {
|
||
ui.separator();
|
||
if ui
|
||
.button("⟷")
|
||
.on_hover_text("Flip Horizontal")
|
||
.clicked()
|
||
{
|
||
self.doc.engine.modify_vector_shape(
|
||
layer_id,
|
||
idx,
|
||
VectorEditHandle::None,
|
||
-1.0,
|
||
0.0,
|
||
);
|
||
}
|
||
if ui
|
||
.button("↕")
|
||
.on_hover_text("Flip Vertical")
|
||
.clicked()
|
||
{
|
||
self.doc.engine.modify_vector_shape(
|
||
layer_id,
|
||
idx,
|
||
VectorEditHandle::None,
|
||
0.0,
|
||
-1.0,
|
||
);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
});
|
||
});
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── Smart Patch / AI tool overlay ──────────────────────────
|
||
if self.state.active_tool.is_ai_tool()
|
||
|| self.state.active_tool == Tool::SpotRemoval
|
||
|| self.state.active_tool == Tool::RedEyeRemoval
|
||
|| self.state.active_tool == Tool::VisionSelect
|
||
{
|
||
let color = egui::Color32::from_rgba_unmultiplied(255, 50, 255, 120);
|
||
if !self.state.smart_patch_points.is_empty() {
|
||
let to_screen = |p: &(u32, u32)| -> egui::Pos2 {
|
||
canvas_rect.min + egui::vec2(p.0 as f32 * zoom, p.1 as f32 * zoom)
|
||
};
|
||
let size = self.state.active_size() * zoom;
|
||
let skip = (self.state.smart_patch_points.len() / 500).max(1);
|
||
for (idx, &p) in self.state.smart_patch_points.iter().enumerate() {
|
||
if idx % skip == 0 {
|
||
painter.circle_filled(to_screen(&p), size / 2.0, color);
|
||
}
|
||
}
|
||
// Draw outline for lasso-like polygon
|
||
if self.state.smart_patch_points.len() > 2 {
|
||
for i in 0..self.state.smart_patch_points.len() - 1 {
|
||
painter.line_segment(
|
||
[
|
||
to_screen(&self.state.smart_patch_points[i]),
|
||
to_screen(&self.state.smart_patch_points[i + 1]),
|
||
],
|
||
egui::Stroke::new(2.0, egui::Color32::from_rgb(255, 80, 255)),
|
||
);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── Raster tool cursor preview (crosshair + circle) ──────────
|
||
// 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 canvas_rect.contains(hover_pos) {
|
||
if !matches!(self.state.active_tool, Tool::AiObjectRemoval) {
|
||
let size = self.state.active_size() * zoom;
|
||
let stroke_color = if self.state.is_drawing {
|
||
egui::Color32::WHITE.gamma_multiply(0.5)
|
||
} else {
|
||
egui::Color32::from_gray(200)
|
||
};
|
||
painter.circle_stroke(
|
||
hover_pos,
|
||
size / 2.0,
|
||
egui::Stroke::new(1.0, stroke_color),
|
||
);
|
||
painter.line_segment(
|
||
[
|
||
hover_pos - egui::vec2(6.0, 0.0),
|
||
hover_pos + egui::vec2(6.0, 0.0),
|
||
],
|
||
egui::Stroke::new(1.0, stroke_color),
|
||
);
|
||
painter.line_segment(
|
||
[
|
||
hover_pos - egui::vec2(0.0, 6.0),
|
||
hover_pos + egui::vec2(0.0, 6.0),
|
||
],
|
||
egui::Stroke::new(1.0, stroke_color),
|
||
);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── Inline text editor overlay ───────────────────────────────
|
||
// Rendered last so it sits on top of the canvas and accepts keystrokes.
|
||
if self.state.text_edit.active {
|
||
text_overlay::draw_text_overlay(
|
||
self.doc,
|
||
self.state,
|
||
self.event_bus,
|
||
ui.ctx(),
|
||
&painter,
|
||
canvas_rect,
|
||
zoom,
|
||
);
|
||
}
|
||
|
||
// ── Double-click on canvas to edit a text layer ─────────────
|
||
// Detected after overlays so clicks in the inline editor do not
|
||
// accidentally trigger a layer re-select.
|
||
if response.double_clicked() && is_active_doc {
|
||
if let Some(pos) = response.hover_pos() {
|
||
if canvas_rect.contains(pos) {
|
||
let rel = pos - canvas_rect.min;
|
||
let cx = (rel.x / zoom) as f32;
|
||
let cy = (rel.y / zoom) as f32;
|
||
if let Some(layer_id) = find_text_layer_at(&self.doc.engine, cx, cy) {
|
||
self.event_bus
|
||
.push(crate::event_bus::AppEvent::TextLayerSelected(layer_id));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
response.context_menu(|ui| {
|
||
ui.set_min_width(180.0);
|
||
|
||
if ui.button("📋 Copy").clicked() {
|
||
self.event_bus.push(AppEvent::ClipboardCopy);
|
||
ui.close();
|
||
}
|
||
|
||
if self.state.selection_rect.is_some() {
|
||
if ui.button("Cut").clicked() {
|
||
self.event_bus.push(AppEvent::ClipboardCut);
|
||
ui.close();
|
||
}
|
||
}
|
||
|
||
if ui.button("📋 Paste").clicked() {
|
||
self.event_bus.push(AppEvent::ClipboardPaste);
|
||
ui.close();
|
||
}
|
||
|
||
ui.separator();
|
||
|
||
if ui.button("➕ New Layer").clicked() {
|
||
self.event_bus.push(AppEvent::LayerAdded);
|
||
ui.close();
|
||
}
|
||
|
||
if self.state.selection_rect.is_some() {
|
||
if ui.button("🚫 Deselect").clicked() {
|
||
self.event_bus.push(AppEvent::SelectionDeselect);
|
||
ui.close();
|
||
}
|
||
if ui.button("Crop Image").clicked() {
|
||
self.event_bus.push(AppEvent::Crop);
|
||
ui.close();
|
||
}
|
||
}
|
||
|
||
ui.separator();
|
||
ui.menu_button("✨ Quick Filters", |ui| {
|
||
if ui.button("Gaussian Blur (5px)").clicked() {
|
||
self.event_bus.push(AppEvent::ApplyFilter(
|
||
"gaussian_blur".to_string(),
|
||
serde_json::json!({ "radius": 5.0 }),
|
||
));
|
||
ui.close();
|
||
}
|
||
if ui.button("Mosaic / Pixelate (8px)").clicked() {
|
||
self.event_bus.push(AppEvent::ApplyFilter(
|
||
"mosaic".to_string(),
|
||
serde_json::json!({ "size": 8 }),
|
||
));
|
||
ui.close();
|
||
}
|
||
if ui.button("Unsharp Mask").clicked() {
|
||
self.event_bus.push(AppEvent::ApplyFilter(
|
||
"unsharp_mask".to_string(),
|
||
serde_json::json!({ "amount": 1.5, "radius": 1.0 }),
|
||
));
|
||
ui.close();
|
||
}
|
||
});
|
||
});
|
||
|
||
response
|
||
}
|
||
}
|
||
|
||
/// Create a VectorShape from a drag operation based on the active tool.
|
||
fn create_shape_from_drag(
|
||
tool: Tool,
|
||
x1: f32,
|
||
y1: f32,
|
||
x2: f32,
|
||
y2: f32,
|
||
state: &ToolState,
|
||
catalog: &hcie_engine_api::ShapeCatalog,
|
||
) -> hcie_engine_api::VectorShape {
|
||
use hcie_engine_api::VectorShape;
|
||
let color = state.primary_color;
|
||
let fill_color = state.secondary_color;
|
||
let stroke = state.tool_configs.vector.stroke_size;
|
||
let opacity = state.tool_configs.vector.opacity;
|
||
let fill = state.tool_configs.vector.fill_shape;
|
||
let hardness = state.tool_configs.vector.hardness;
|
||
|
||
match tool {
|
||
Tool::VectorLine => VectorShape::Line {
|
||
name: String::new(),
|
||
x1,
|
||
y1,
|
||
x2,
|
||
y2,
|
||
stroke,
|
||
color,
|
||
angle: 0.0,
|
||
cap_start: hcie_engine_api::LineCap::Round,
|
||
cap_end: hcie_engine_api::LineCap::Round,
|
||
opacity,
|
||
hardness,
|
||
},
|
||
Tool::VectorRect => VectorShape::Rect {
|
||
name: String::new(),
|
||
x1,
|
||
y1,
|
||
x2,
|
||
y2,
|
||
stroke,
|
||
color,
|
||
fill_color,
|
||
fill,
|
||
radius: state.tool_configs.vector.round_rect_radius,
|
||
angle: 0.0,
|
||
opacity,
|
||
hardness,
|
||
},
|
||
Tool::VectorCircle => VectorShape::Circle {
|
||
name: String::new(),
|
||
x1,
|
||
y1,
|
||
x2,
|
||
y2,
|
||
stroke,
|
||
color,
|
||
fill_color,
|
||
fill,
|
||
angle: 0.0,
|
||
opacity,
|
||
hardness,
|
||
},
|
||
Tool::VectorArrow => {
|
||
let mut p = std::collections::HashMap::new();
|
||
p.insert("thick".to_string(), 0.0);
|
||
match hcie_engine_api::create_vector_shape("arrow", x1, y1, x2, y2, &p) {
|
||
VectorShape::SvgShape { kind, svg, .. } => {
|
||
// Derive the arrow's rotation from the drag direction so
|
||
// it points along the line drawn by the user.
|
||
let arrow_angle = (y2 - y1).atan2(x2 - x1);
|
||
VectorShape::SvgShape {
|
||
name: String::new(), kind, svg, x1, y1, x2, y2, stroke, color, fill_color, fill,
|
||
angle: arrow_angle, opacity, hardness,
|
||
}
|
||
}
|
||
_ => unreachable!(),
|
||
}
|
||
},
|
||
Tool::VectorStar => {
|
||
let mut p = std::collections::HashMap::new();
|
||
p.insert("points".to_string(), state.tool_configs.vector.star_points as f32);
|
||
p.insert("inner_radius".to_string(), state.tool_configs.vector.star_inner_radius);
|
||
match hcie_engine_api::create_vector_shape("star", x1, y1, x2, y2, &p) {
|
||
VectorShape::SvgShape { kind, svg, .. } => VectorShape::SvgShape {
|
||
name: String::new(), kind, svg, x1, y1, x2, y2, stroke, color, fill_color, fill,
|
||
angle: 0.0, opacity, hardness,
|
||
},
|
||
_ => unreachable!(),
|
||
}
|
||
},
|
||
Tool::VectorPolygon => VectorShape::Polygon {
|
||
name: String::new(),
|
||
x1,
|
||
y1,
|
||
x2,
|
||
y2,
|
||
stroke,
|
||
color,
|
||
fill_color,
|
||
fill,
|
||
angle: 0.0,
|
||
opacity,
|
||
hardness,
|
||
sides: state.tool_configs.vector.polygon_sides,
|
||
},
|
||
Tool::VectorRhombus => {
|
||
match hcie_engine_api::create_vector_shape("rhombus", x1, y1, x2, y2, &std::collections::HashMap::new()) {
|
||
VectorShape::SvgShape { kind, svg, .. } => VectorShape::SvgShape {
|
||
name: String::new(), kind, svg, x1, y1, x2, y2, stroke, color, fill_color, fill,
|
||
angle: 0.0, opacity, hardness,
|
||
},
|
||
_ => unreachable!(),
|
||
}
|
||
},
|
||
Tool::VectorCylinder => {
|
||
match hcie_engine_api::create_vector_shape("cylinder", x1, y1, x2, y2, &std::collections::HashMap::new()) {
|
||
VectorShape::SvgShape { kind, svg, .. } => VectorShape::SvgShape {
|
||
name: String::new(), kind, svg, x1, y1, x2, y2, stroke, color, fill_color, fill,
|
||
angle: 0.0, opacity, hardness,
|
||
},
|
||
_ => unreachable!(),
|
||
}
|
||
},
|
||
Tool::VectorHeart => {
|
||
match hcie_engine_api::create_vector_shape("heart", x1, y1, x2, y2, &std::collections::HashMap::new()) {
|
||
VectorShape::SvgShape { kind, svg, .. } => VectorShape::SvgShape {
|
||
name: String::new(), kind, svg, x1, y1, x2, y2, stroke, color, fill_color, fill,
|
||
angle: 0.0, opacity, hardness,
|
||
},
|
||
_ => unreachable!(),
|
||
}
|
||
},
|
||
Tool::VectorBubble => {
|
||
match hcie_engine_api::create_vector_shape("bubble", x1, y1, x2, y2, &std::collections::HashMap::new()) {
|
||
VectorShape::SvgShape { kind, svg, .. } => VectorShape::SvgShape {
|
||
name: String::new(), kind, svg, x1, y1, x2, y2, stroke, color, fill_color, fill,
|
||
angle: 0.0, opacity, hardness,
|
||
},
|
||
_ => unreachable!(),
|
||
}
|
||
},
|
||
Tool::VectorGear => {
|
||
match hcie_engine_api::create_vector_shape("gear", x1, y1, x2, y2, &std::collections::HashMap::new()) {
|
||
VectorShape::SvgShape { kind, svg, .. } => VectorShape::SvgShape {
|
||
name: String::new(), kind, svg, x1, y1, x2, y2, stroke, color, fill_color, fill,
|
||
angle: 0.0, opacity, hardness,
|
||
},
|
||
_ => unreachable!(),
|
||
}
|
||
},
|
||
Tool::VectorCross => {
|
||
match hcie_engine_api::create_vector_shape("cross", x1, y1, x2, y2, &std::collections::HashMap::new()) {
|
||
VectorShape::SvgShape { kind, svg, .. } => VectorShape::SvgShape {
|
||
name: String::new(), kind, svg, x1, y1, x2, y2, stroke, color, fill_color, fill,
|
||
angle: 0.0, opacity, hardness,
|
||
},
|
||
_ => unreachable!(),
|
||
}
|
||
},
|
||
Tool::VectorCrescent => {
|
||
match hcie_engine_api::create_vector_shape("crescent", x1, y1, x2, y2, &std::collections::HashMap::new()) {
|
||
VectorShape::SvgShape { kind, svg, .. } => VectorShape::SvgShape {
|
||
name: String::new(), kind, svg, x1, y1, x2, y2, stroke, color, fill_color, fill,
|
||
angle: 0.0, opacity, hardness,
|
||
},
|
||
_ => unreachable!(),
|
||
}
|
||
},
|
||
Tool::VectorBolt => {
|
||
match hcie_engine_api::create_vector_shape("bolt", x1, y1, x2, y2, &std::collections::HashMap::new()) {
|
||
VectorShape::SvgShape { kind, svg, .. } => VectorShape::SvgShape {
|
||
name: String::new(), kind, svg, x1, y1, x2, y2, stroke, color, fill_color, fill,
|
||
angle: 0.0, opacity, hardness,
|
||
},
|
||
_ => unreachable!(),
|
||
}
|
||
},
|
||
Tool::VectorArrow4 => {
|
||
match hcie_engine_api::create_vector_shape("arrow4", x1, y1, x2, y2, &std::collections::HashMap::new()) {
|
||
VectorShape::SvgShape { kind, svg, .. } => VectorShape::SvgShape {
|
||
name: String::new(), kind, svg, x1, y1, x2, y2, stroke, color, fill_color, fill,
|
||
angle: 0.0, opacity, hardness,
|
||
},
|
||
_ => unreachable!(),
|
||
}
|
||
},
|
||
Tool::CustomShape(idx) => {
|
||
if let Some(entry) = catalog.get(idx) {
|
||
VectorShape::SvgShape {
|
||
name: String::new(),
|
||
x1, y1, x2, y2,
|
||
kind: entry.kind.clone(),
|
||
svg: entry.svg.clone(),
|
||
stroke, color, fill_color, fill,
|
||
angle: 0.0, opacity, hardness,
|
||
}
|
||
} else {
|
||
// Fallback: empty SVG rectangle
|
||
VectorShape::SvgShape {
|
||
name: String::new(),
|
||
x1, y1, x2, y2,
|
||
kind: "custom".to_string(),
|
||
svg: r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1 1"><path d="M 0,0 L 1,0 L 1,1 L 0,1 Z"/></svg>"#.to_string(),
|
||
stroke, color, fill_color, fill,
|
||
angle: 0.0, opacity, hardness,
|
||
}
|
||
}
|
||
},
|
||
_ => VectorShape::Line {
|
||
name: String::new(),
|
||
x1,
|
||
y1,
|
||
x2,
|
||
y2,
|
||
stroke,
|
||
color,
|
||
angle: 0.0,
|
||
cap_start: hcie_engine_api::LineCap::Round,
|
||
cap_end: hcie_engine_api::LineCap::Round,
|
||
opacity,
|
||
hardness,
|
||
},
|
||
}
|
||
}
|
||
|
||
/// Draw a realtime preview for the active shape tool while the user drags.
|
||
///
|
||
/// # Purpose
|
||
/// Gives immediate visual feedback of the shape that will be created. The
|
||
/// preview is rendered directly by egui on top of the canvas, so it does not
|
||
/// depend on the engine compositing pipeline.
|
||
///
|
||
/// # Logic & Workflow
|
||
/// 1. Maps the canvas-space drag rectangle to screen space using the current zoom.
|
||
/// 2. For `VectorLine`, draws a line between the two drag corners.
|
||
/// 3. For `VectorCircle`, draws an axis-aligned ellipse matching the drag rect.
|
||
/// 4. For `VectorStar` and `VectorPolygon`, samples the vertices and draws the
|
||
/// outline as connected line segments.
|
||
/// 5. For all remaining shape tools, falls back to a stroked rectangle preview.
|
||
///
|
||
/// # Arguments
|
||
/// * `painter` - The egui painter for the canvas widget.
|
||
/// * `tool` - The active shape tool.
|
||
/// * `canvas_min` - The screen-space top-left corner of the canvas.
|
||
/// * `zoom` - Current canvas zoom factor.
|
||
/// * `x1`, `y1`, `x2`, `y2` - Canvas-space drag rectangle (already normalized for rect preview).
|
||
/// * `stroke` - Common preview stroke style.
|
||
/// * `cfg` - Vector tool configuration used for star/polygon parameters.
|
||
fn draw_shape_tool_preview(
|
||
painter: &egui::Painter,
|
||
tool: Tool,
|
||
canvas_min: egui::Pos2,
|
||
zoom: f32,
|
||
x1: f32,
|
||
y1: f32,
|
||
x2: f32,
|
||
y2: f32,
|
||
stroke: egui::Stroke,
|
||
cfg: &hcie_engine_api::tools::VectorConfig,
|
||
) {
|
||
let to_screen =
|
||
|cx: f32, cy: f32| -> egui::Pos2 { canvas_min + egui::vec2(cx * zoom, cy * zoom) };
|
||
let left = x1.min(x2);
|
||
let top = y1.min(y2);
|
||
let right = x1.max(x2);
|
||
let bottom = y1.max(y2);
|
||
|
||
match tool {
|
||
Tool::VectorLine => {
|
||
painter.line_segment([to_screen(x1, y1), to_screen(x2, y2)], stroke);
|
||
}
|
||
Tool::VectorCircle => {
|
||
let cx = (left + right) / 2.0;
|
||
let cy = (top + bottom) / 2.0;
|
||
let rx = (right - left) / 2.0;
|
||
let ry = (bottom - top) / 2.0;
|
||
// Use enough line segments for a smooth ellipse preview.
|
||
let steps = ((rx + ry).max(1.0) * 4.0).ceil() as i32;
|
||
let mut pts = Vec::with_capacity(steps as usize + 1);
|
||
for i in 0..=steps {
|
||
let t = i as f32 / steps as f32 * 2.0 * std::f32::consts::PI;
|
||
pts.push(to_screen(cx + rx * t.cos(), cy + ry * t.sin()));
|
||
}
|
||
painter.add(egui::Shape::line(pts, stroke));
|
||
}
|
||
Tool::VectorStar => {
|
||
let cx = (left + right) / 2.0;
|
||
let cy = (top + bottom) / 2.0;
|
||
let outer_r = (right - left).min(bottom - top) / 2.0;
|
||
let inner_r = outer_r * cfg.star_inner_radius.max(0.1);
|
||
let n = cfg.star_points as i32;
|
||
if n >= 2 {
|
||
let mut pts = Vec::with_capacity((n * 2) as usize);
|
||
for i in 0..(n * 2) {
|
||
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 };
|
||
pts.push(to_screen(cx + r * a.cos(), cy + r * a.sin()));
|
||
}
|
||
painter.add(egui::Shape::closed_line(pts, stroke));
|
||
}
|
||
}
|
||
Tool::VectorPolygon => {
|
||
let cx = (left + right) / 2.0;
|
||
let cy = (top + bottom) / 2.0;
|
||
let r = (right - left).min(bottom - top) / 2.0;
|
||
let n = cfg.polygon_sides.max(3) as i32;
|
||
let mut pts = Vec::with_capacity(n as usize);
|
||
for i in 0..n {
|
||
let a =
|
||
std::f32::consts::PI * 2.0 * i as f32 / n as f32 - std::f32::consts::PI / 2.0;
|
||
pts.push(to_screen(cx + r * a.cos(), cy + r * a.sin()));
|
||
}
|
||
painter.add(egui::Shape::closed_line(pts, stroke));
|
||
}
|
||
_ => {
|
||
let screen_rect =
|
||
egui::Rect::from_min_max(to_screen(left, top), to_screen(right, bottom));
|
||
let radius = if tool == Tool::VectorRect {
|
||
cfg.round_rect_radius
|
||
.min((right - left) * 0.5)
|
||
.min((bottom - top) * 0.5)
|
||
* zoom
|
||
} else {
|
||
0.0
|
||
};
|
||
painter.rect_stroke(screen_rect, radius, stroke, egui::StrokeKind::Outside);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// ### Purpose
|
||
/// Applies a `SelectionTransform` (floating overlay) back to the active layer
|
||
/// by writing the transform pixels at the transform position. The original
|
||
/// selection area was already cleared when the transform started, so this
|
||
/// function only writes the moved pixels and leaves the rest of the layer intact.
|
||
///
|
||
/// ### Logic & Workflow
|
||
/// 1. Reads the current active layer pixels (which already have the original
|
||
/// selection area cleared).
|
||
/// 2. Writes the transform pixels at `tr.pos` with optional scaling based on
|
||
/// `tr.size / original_size`.
|
||
///
|
||
/// ### Side Effects / Dependencies
|
||
/// - Mutates engine layer pixels via `get_active_layer_pixels` and
|
||
/// `set_active_layer_pixels`. Does NOT clear the entire layer.
|
||
fn apply_transform_to_layer(engine: &mut hcie_engine_api::Engine, tr: &SelectionTransform) {
|
||
let canvas_w = engine.canvas_width();
|
||
let canvas_h = engine.canvas_height();
|
||
let px = tr.pos.x.round() as i32;
|
||
let py = tr.pos.y.round() as i32;
|
||
|
||
// Do NOT clear the entire layer here. The original selection area was already
|
||
// cleared when the transform started (in the Tool::Move press handler). Clearing
|
||
// the whole layer again would delete pixels outside the selection.
|
||
|
||
if let Some(mut dest) = engine.get_active_layer_pixels() {
|
||
let src_w = tr.width as f32;
|
||
let src_h = tr.height as f32;
|
||
let scale_x = if src_w > 0.0 { tr.size.x / src_w } else { 1.0 };
|
||
let scale_y = if src_h > 0.0 { tr.size.y / src_h } else { 1.0 };
|
||
|
||
if (scale_x - 1.0).abs() < 0.001 && (scale_y - 1.0).abs() < 0.001 {
|
||
for y in 0..tr.height as i32 {
|
||
for x in 0..tr.width as i32 {
|
||
let dst_x = px + x;
|
||
let dst_y = py + y;
|
||
if dst_x >= 0
|
||
&& dst_x < canvas_w as i32
|
||
&& dst_y >= 0
|
||
&& dst_y < canvas_h as i32
|
||
{
|
||
let src_idx = (y as u32 * tr.width + x as u32) as usize * 4;
|
||
if src_idx + 3 < tr.pixels.len() && tr.pixels[src_idx + 3] > 0 {
|
||
let dst_idx = (dst_y as u32 * canvas_w + dst_x as u32) as usize * 4;
|
||
if dst_idx + 3 < dest.len() {
|
||
dest[dst_idx..dst_idx + 4]
|
||
.copy_from_slice(&tr.pixels[src_idx..src_idx + 4]);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
} else {
|
||
let dst_w = tr.size.x.round() as i32;
|
||
let dst_h = tr.size.y.round() as i32;
|
||
for dy in 0..dst_h {
|
||
for dx in 0..dst_w {
|
||
let dst_x = px + dx;
|
||
let dst_y = py + dy;
|
||
if dst_x >= 0
|
||
&& dst_x < canvas_w as i32
|
||
&& dst_y >= 0
|
||
&& dst_y < canvas_h as i32
|
||
{
|
||
let sx = (dx as f32 / scale_x) as u32;
|
||
let sy = (dy as f32 / scale_y) as u32;
|
||
if sx < tr.width && sy < tr.height {
|
||
let src_idx = (sy * tr.width + sx) as usize * 4;
|
||
if src_idx + 3 < tr.pixels.len() && tr.pixels[src_idx + 3] > 0 {
|
||
let dst_idx = (dst_y as u32 * canvas_w + dst_x as u32) as usize * 4;
|
||
if dst_idx + 3 < dest.len() {
|
||
dest[dst_idx..dst_idx + 4]
|
||
.copy_from_slice(&tr.pixels[src_idx..src_idx + 4]);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
engine.set_active_layer_pixels(dest);
|
||
}
|
||
}
|
||
|
||
/// Find the topmost text layer whose rasterized pixels contain the point
|
||
/// `(x, y)` in canvas coordinates.
|
||
///
|
||
/// Iterates layers from top (highest engine index) to bottom so clicking
|
||
/// stacked text layers selects the visible one first. For each Text layer the
|
||
/// point-in-pixel test uses `engine.get_pixel` with an alpha threshold of 10
|
||
/// so fully transparent gaps between glyphs do not count as hits.
|
||
///
|
||
/// # Arguments
|
||
/// * `engine` - Engine exposing layer and pixel queries.
|
||
/// * `x`, `y` - Canvas-space point to test.
|
||
///
|
||
/// # Returns
|
||
/// The matching text layer id, or `None` if the point is over empty canvas.
|
||
fn find_text_layer_at(engine: &hcie_engine_api::Engine, x: f32, y: f32) -> Option<u64> {
|
||
let ux = x.round().clamp(0.0, u32::MAX as f32) as u32;
|
||
let uy = y.round().clamp(0.0, u32::MAX as f32) as u32;
|
||
let infos = engine.layer_infos();
|
||
// layer_infos returns bottom-to-top; iterate in reverse for topmost first.
|
||
for info in infos.iter().rev() {
|
||
if info.layer_type != hcie_engine_api::LayerType::Text {
|
||
continue;
|
||
}
|
||
if !info.visible {
|
||
continue;
|
||
}
|
||
if let Some(px) = engine.get_pixel(info.id, ux, uy) {
|
||
if px[3] > 10 {
|
||
return Some(info.id);
|
||
}
|
||
}
|
||
}
|
||
None
|
||
}
|