diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/app.rs b/hcie-iced-app/crates/hcie-iced-gui/src/app.rs index bf26db0..ad9c92e 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/app.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/app.rs @@ -256,12 +256,8 @@ pub struct HcieIcedApp { /// Dirty flag set whenever fg/bg/recent colors change, so colors can be /// persisted to disk (egui persists these under the `hcie_colors` key). pub colors_dirty: bool, - /// Index of the currently selected vector shape (None = no selection). - pub selected_vector_shape: Option, /// Snapshot of vector shapes before editing (for undo/Cancel). pub vector_shapes_snapshot: Option>, - /// Which transform handle is being dragged on the selected vector shape. - pub vector_edit_handle: hcie_engine_api::VectorEditHandle, /// Last drag position during vector shape editing (canvas coordinates). pub vector_drag_last: Option<(f32, f32)>, } @@ -344,6 +340,10 @@ pub struct IcedDocument { pub selection_edge_cache: crate::canvas::edge_cache::SelectionEdgeCache, /// Extracted selection edges for overlay rendering. pub marching_ants_edges: Option>>, + /// Index of the currently selected vector shape (None = no selection). + pub selected_vector_shape: Option, + /// Which vector edit handle is active (hovered or being dragged). + pub vector_edit_handle: hcie_engine_api::VectorEditHandle, } /// In-progress text layer being authored with the Text tool. @@ -992,6 +992,8 @@ impl HcieIcedApp { text_draft: None, selection_edge_cache: Default::default(), marching_ants_edges: None, + selected_vector_shape: None, + vector_edit_handle: hcie_engine_api::VectorEditHandle::None, }; // Load saved settings @@ -1078,9 +1080,7 @@ impl HcieIcedApp { show_dock_profile_dialog: false, language: i18n::Language::default(), colors_dirty: false, - selected_vector_shape: None, vector_shapes_snapshot: None, - vector_edit_handle: hcie_engine_api::VectorEditHandle::None, vector_drag_last: None, }; @@ -1191,6 +1191,86 @@ impl HcieIcedApp { } } + /// Hit-test which vector edit handle is at the given canvas-space position. + /// + /// Checks rotation handle first (circle area), then resize handles (square + /// hit-box), then the move handle (inside bounding box). Returns the + /// matching `VectorEditHandle` variant. Coordinates are in canvas-space. + fn vector_hit_test_handle(&self, cx: f32, cy: f32) -> hcie_engine_api::VectorEditHandle { + use hcie_engine_api::VectorEditHandle as VEH; + + let doc = &self.documents[self.active_doc]; + let Some(idx) = doc.selected_vector_shape else { + return VEH::None; + }; + let layer_id = doc.engine.active_layer_id(); + let shapes = match doc.engine.active_vector_shapes() { + Some(s) => s, + None => return VEH::None, + }; + let shape = match shapes.get(idx) { + Some(s) => s, + None => return VEH::None, + }; + + let (x1, y1, x2, y2) = shape.normalized_bounds(); + let angle = doc.engine.vector_shape_angle(layer_id, idx); + let w = (x2 - x1).abs(); + let h = (y2 - y1).abs(); + if w < 0.5 || h < 0.5 { return VEH::None; } + + let bcx = (x1 + x2) / 2.0; + let bcy = (y1 + y2) / 2.0; + let cos_a = angle.cos(); + let sin_a = angle.sin(); + + // Inverse-rotate a canvas-space point around the bounding-box center. + let unrotate = |px: f32, py: f32| -> (f32, f32) { + let dx = px - bcx; + let dy = py - bcy; + (bcx + dx * cos_a + dy * sin_a, bcy - dx * sin_a + dy * cos_a) + }; + + let (lx, ly) = unrotate(cx, cy); + + // Check rotation handle first (outside the box). + let rotate_offset = 24.0; + let rot_circ_x = bcx; + let rot_circ_y = y1 - rotate_offset; + let rot_radius = 6.0; + let drx = lx - rot_circ_x; + let dry = ly - rot_circ_y; + if drx * drx + dry * dry <= rot_radius * rot_radius { + return VEH::Rotate; + } + + // Check 8 resize handles. + let handle_half = 7.0; + let handle_points: [(VEH, f32, f32); 8] = [ + (VEH::TopLeft, x1, y1), + (VEH::TopRight, x2, y1), + (VEH::BottomLeft, x1, y2), + (VEH::BottomRight, x2, y2), + (VEH::Top, bcx, y1), + (VEH::Bottom, bcx, y2), + (VEH::Left, x1, bcy), + (VEH::Right, x2, bcy), + ]; + + for &(handle, hx, hy) in &handle_points { + if (lx - hx).abs() <= handle_half && (ly - hy).abs() <= handle_half { + return handle; + } + } + + // Check move handle (inside bounding box). + if lx >= x1 && lx <= x2 && ly >= y1 && ly <= y2 { + return VEH::Move; + } + + VEH::None + } + /// Add a file to the recent files list (most recent first, max 20) and persist to disk. @@ -2962,6 +3042,8 @@ impl HcieIcedApp { text_draft: None, selection_edge_cache: Default::default(), marching_ants_edges: None, + selected_vector_shape: None, + vector_edit_handle: hcie_engine_api::VectorEditHandle::None, }); self.active_doc = self.documents.len() - 1; self.active_dialog = ActiveDialog::None; @@ -3356,55 +3438,87 @@ impl HcieIcedApp { // ── Vector select / edit ───────────────────── Message::VectorSelectClick { x, y } => { + use hcie_engine_api::VectorEditHandle as VEH; + + // If a shape is already selected, hit-test its edit handles first. + if self.documents[self.active_doc].selected_vector_shape.is_some() { + let handle = self.vector_hit_test_handle(x, y); + if handle != VEH::None && handle != VEH::Move { + // Clicked on a resize/rotate handle — start drag with that handle. + self.documents[self.active_doc].vector_edit_handle = handle; + self.vector_drag_last = Some((x, y)); + return Task::none(); + } + } + // Hit-test vector shapes under cursor; select the topmost one. if let Some(idx) = self.documents[self.active_doc].engine.find_vector_shape_at(x, y) { // Save snapshot before editing (for undo). let snapshot = self.documents[self.active_doc].engine.active_vector_shapes(); self.vector_shapes_snapshot = snapshot; - self.selected_vector_shape = Some(idx); - // Start a drag from this position (move on drag, deselect on release without drag). let doc = &mut self.documents[self.active_doc]; + doc.selected_vector_shape = Some(idx); doc.vector_draw = None; // clear any preview + doc.vector_edit_handle = VEH::Move; self.vector_drag_last = Some((x, y)); - self.vector_edit_handle = hcie_engine_api::VectorEditHandle::Move; } else { // No shape under cursor — deselect. - self.selected_vector_shape = None; + let doc = &mut self.documents[self.active_doc]; + doc.selected_vector_shape = None; + doc.vector_edit_handle = VEH::None; self.vector_shapes_snapshot = None; - self.vector_edit_handle = hcie_engine_api::VectorEditHandle::None; self.vector_drag_last = None; } } Message::VectorSelectDragStart { x, y } => { - if self.selected_vector_shape.is_some() { + if self.documents[self.active_doc].selected_vector_shape.is_some() { + // Hit-test vector edit handles to determine drag mode. + let handle = self.vector_hit_test_handle(x, y); + self.documents[self.active_doc].vector_edit_handle = handle; self.vector_drag_last = Some((x, y)); } } Message::VectorSelectDragMove { x, y } => { - if let (Some(idx), Some((last_x, last_y))) = (self.selected_vector_shape, self.vector_drag_last) { + let idx = self.documents[self.active_doc].selected_vector_shape; + if let (Some(idx), Some((last_x, last_y))) = (idx, self.vector_drag_last) { let dx = x - last_x; let dy = y - last_y; let layer_id = self.documents[self.active_doc].engine.active_layer_id(); - self.documents[self.active_doc].engine.modify_vector_shape( - layer_id, - idx, - self.vector_edit_handle, - dx, - dy, - ); + let handle = self.documents[self.active_doc].vector_edit_handle; + match handle { + hcie_engine_api::VectorEditHandle::Rotate => { + // Compute rotation delta from mouse movement. + if let Some((cx, cy)) = self.documents[self.active_doc].engine.get_vector_shape_center(layer_id, idx) { + let angle_start = (last_y - cy).atan2(last_x - cx); + let angle_end = (y - cy).atan2(x - cx); + let delta = angle_end - angle_start; + let current = self.documents[self.active_doc].engine.vector_shape_angle(layer_id, idx); + self.documents[self.active_doc].engine.set_vector_shape_angle(layer_id, idx, current + delta); + } + } + _ => { + self.documents[self.active_doc].engine.modify_vector_shape( + layer_id, + idx, + handle, + dx, + dy, + ); + } + } self.vector_drag_last = Some((x, y)); self.refresh_composite_if_needed(); } } Message::VectorSelectDragEnd => { if self.vector_drag_last.is_some() { - // Commit the move: if shapes changed, push history snapshot. + // Commit the move/resize/rotate: if shapes changed, push history snapshot. self.vector_drag_last = None; - self.vector_edit_handle = hcie_engine_api::VectorEditHandle::None; + self.documents[self.active_doc].vector_edit_handle = hcie_engine_api::VectorEditHandle::None; } } Message::VectorShapeDelete => { - if let Some(idx) = self.selected_vector_shape { + if let Some(idx) = self.documents[self.active_doc].selected_vector_shape { let layer_id = self.documents[self.active_doc].engine.active_layer_id(); // Remove the shape from the layer's shape list. if let Some(shapes) = self.documents[self.active_doc].engine.get_layer_shapes_direct(layer_id) { @@ -3412,7 +3526,7 @@ impl HcieIcedApp { shapes.remove(idx); } } - self.selected_vector_shape = None; + self.documents[self.active_doc].selected_vector_shape = None; self.vector_shapes_snapshot = None; self.documents[self.active_doc].engine.mark_composite_dirty(); self.refresh_composite_if_needed(); @@ -3420,7 +3534,7 @@ impl HcieIcedApp { } } Message::VectorShapeSelect(idx) => { - self.selected_vector_shape = idx; + self.documents[self.active_doc].selected_vector_shape = idx; if idx.is_some() { let snapshot = self.documents[self.active_doc].engine.active_vector_shapes(); self.vector_shapes_snapshot = snapshot; @@ -4193,6 +4307,8 @@ impl HcieIcedApp { text_draft: None, selection_edge_cache: Default::default(), marching_ants_edges: None, + selected_vector_shape: None, + vector_edit_handle: hcie_engine_api::VectorEditHandle::None, }); } @@ -4760,10 +4876,10 @@ impl HcieIcedApp { return self.update(Message::CropCancel); } else if self.documents[self.active_doc].selection_transform.is_some() { return self.update(Message::TransformCancel); - } else if self.selected_vector_shape.is_some() { - self.selected_vector_shape = None; + } else if self.documents[self.active_doc].selected_vector_shape.is_some() { + self.documents[self.active_doc].selected_vector_shape = None; self.vector_shapes_snapshot = None; - self.vector_edit_handle = hcie_engine_api::VectorEditHandle::None; + self.documents[self.active_doc].vector_edit_handle = hcie_engine_api::VectorEditHandle::None; self.vector_drag_last = None; } else if self.documents[self.active_doc].selection_bounds.is_some() || self.documents[self.active_doc].selection_rect.is_some() diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/canvas/mod.rs b/hcie-iced-app/crates/hcie-iced-gui/src/canvas/mod.rs index 049f5a3..44eab9c 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/canvas/mod.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/canvas/mod.rs @@ -72,6 +72,12 @@ struct OverlayProgram { gradient_drag: Option<((f32, f32), (f32, f32))>, /// Extracted edges for marching ants rendering. marching_ants_edges: Option>>, + /// Normalized bounds of the selected vector shape in canvas-space (x1, y1, x2, y2). + selected_vector_bounds: Option<(f32, f32, f32, f32)>, + /// Rotation angle (radians) of the selected vector shape. + selected_vector_angle: f32, + /// Which vector edit handle is active (hovered or being dragged). + vector_edit_handle: hcie_engine_api::VectorEditHandle, } /// Overlay state — tracks hover and cursor position for crosshair. @@ -325,6 +331,195 @@ impl OverlayProgram { } } } + + /// Draw transform handles for selected vector shapes. + /// + /// Renders a blue selection outline, 8 white resize handles at corners/edges, + /// and a rotation handle (line + blue circle) above the top-center. All + /// geometry is rotated by `angle` around the bounding-box center. + fn draw_vector_edit_handles( + &self, + frame: &mut Frame, + x1: f32, y1: f32, x2: f32, y2: f32, + angle: f32, + origin_x: f32, + origin_y: f32, + ) { + let cx = (x1 + x2) / 2.0; + let cy = (y1 + y2) / 2.0; + let w = (x2 - x1).abs(); + let h = (y2 - y1).abs(); + if w < 0.5 || h < 0.5 { return; } + + let cos_a = angle.cos(); + let sin_a = angle.sin(); + + // Helper: rotate a canvas-space point around (cx, cy) by angle. + let rotate = |px: f32, py: f32| -> (f32, f32) { + let dx = px - cx; + let dy = py - cy; + (cx + dx * cos_a - dy * sin_a, cy + dx * sin_a + dy * cos_a) + }; + + let line_width = 1.5 / self.zoom.max(1.0); + + // ── Blue selection outline ───────────────────────────────────────── + let outline_color = iced::Color::from_rgb(0.2, 0.5, 1.0); + let corners: [(f32, f32); 4] = [(x1, y1), (x2, y1), (x2, y2), (x1, y2)]; + let rotated_corners: Vec<(f32, f32)> = corners.iter().map(|&(px, py)| rotate(px, py)).collect(); + let outline = Path::new(|b| { + for (i, &(px, py)) in rotated_corners.iter().enumerate() { + let sx = origin_x + px * self.zoom; + let sy = origin_y + py * self.zoom; + if i == 0 { b.move_to(Point::new(sx, sy)); } + else { b.line_to(Point::new(sx, sy)); } + } + b.close(); + }); + frame.stroke(&outline, Stroke { + style: canvas::stroke::Style::Solid(outline_color), + width: line_width, + ..Default::default() + }); + + // ── 8 resize handles (white squares with black stroke) ───────────── + let handle_size = 8.0 / self.zoom.max(1.0); + let half = handle_size / 2.0; + let white = iced::Color::from_rgb(1.0, 1.0, 1.0); + let black = iced::Color::from_rgb(0.0, 0.0, 0.0); + + let handle_points: [(f32, f32); 8] = [ + (x1, y1), // TopLeft + (x2, y1), // TopRight + (x1, y2), // BottomLeft + (x2, y2), // BottomRight + (cx, y1), // Top + (cx, y2), // Bottom + (x1, cy), // Left + (x2, cy), // Right + ]; + + for &(px, py) in &handle_points { + let (rpx, rpy) = rotate(px, py); + let sx = origin_x + rpx * self.zoom; + let sy = origin_y + rpy * self.zoom; + let rect = Path::rectangle( + Point::new(sx - half, sy - half), + Size::new(handle_size, handle_size), + ); + frame.fill(&rect, white); + frame.stroke(&rect, Stroke { + style: canvas::stroke::Style::Solid(black), + width: line_width, + ..Default::default() + }); + } + + // ── Rotation handle (line from top-center upward + blue circle) ──── + let rotate_offset = 24.0 / self.zoom.max(1.0); + let (rot_line_start_x, rot_line_start_y) = rotate(cx, y1); + let (rot_circ_x, rot_circ_y) = rotate(cx, y1 - rotate_offset); + let rot_circ_radius = 4.0 / self.zoom.max(1.0); + + // Line from top-center to rotation circle + let rot_line = Path::line( + Point::new(origin_x + rot_line_start_x * self.zoom, origin_y + rot_line_start_y * self.zoom), + Point::new(origin_x + rot_circ_x * self.zoom, origin_y + rot_circ_y * self.zoom), + ); + frame.stroke(&rot_line, Stroke { + style: canvas::stroke::Style::Solid(black), + width: line_width, + ..Default::default() + }); + + // Blue rotation circle + let rot_circle = Path::circle( + Point::new(origin_x + rot_circ_x * self.zoom, origin_y + rot_circ_y * self.zoom), + rot_circ_radius, + ); + let blue = iced::Color::from_rgb(0.2, 0.5, 1.0); + frame.fill(&rot_circle, blue); + frame.stroke(&rot_circle, Stroke { + style: canvas::stroke::Style::Solid(black), + width: line_width, + ..Default::default() + }); + } + + /// Hit-test which vector edit handle is at the given viewport position. + /// + /// Checks rotation handle first (circle area), then resize handles (square + /// hit-box), then the move handle (expanded bounding box). Returns the + /// matching `VectorEditHandle` variant. + pub fn hit_test_vector_handle( + &self, + viewport_x: f32, + viewport_y: f32, + ) -> hcie_engine_api::VectorEditHandle { + use hcie_engine_api::VectorEditHandle as VEH; + + let Some((x1, y1, x2, y2)) = self.selected_vector_bounds else { + return VEH::None; + }; + + let (origin_x, origin_y, _, _) = self.canvas_origin(iced::Size::new(0.0, 0.0)); + + // Convert viewport to canvas coordinates + let canvas_x = (viewport_x - origin_x) / self.zoom; + let canvas_y = (viewport_y - origin_y) / self.zoom; + + let cx = (x1 + x2) / 2.0; + let cy = (y1 + y2) / 2.0; + let angle = self.selected_vector_angle; + let cos_a = angle.cos(); + let sin_a = angle.sin(); + + // Inverse-rotate a canvas-space point around (cx, cy) by -angle. + let unrotate = |px: f32, py: f32| -> (f32, f32) { + let dx = px - cx; + let dy = py - cy; + (cx + dx * cos_a + dy * sin_a, cy - dx * sin_a + dy * cos_a) + }; + + let (lx, ly) = unrotate(canvas_x, canvas_y); + + // ── Check rotation handle first (outside the box) ────────────────── + let rotate_offset = 24.0 / self.zoom.max(1.0); + let rot_circ_x = cx; + let rot_circ_y = y1 - rotate_offset; + let rot_radius = 6.0 / self.zoom.max(1.0); + let drx = lx - rot_circ_x; + let dry = ly - rot_circ_y; + if drx * drx + dry * dry <= rot_radius * rot_radius { + return VEH::Rotate; + } + + // ── Check 8 resize handles ──────────────────────────────────────── + let handle_half = 7.0 / self.zoom.max(1.0); + let handle_points: [(VEH, f32, f32); 8] = [ + (VEH::TopLeft, x1, y1), + (VEH::TopRight, x2, y1), + (VEH::BottomLeft, x1, y2), + (VEH::BottomRight, x2, y2), + (VEH::Top, cx, y1), + (VEH::Bottom, cx, y2), + (VEH::Left, x1, cy), + (VEH::Right, x2, cy), + ]; + + for &(handle, hx, hy) in &handle_points { + if (lx - hx).abs() <= handle_half && (ly - hy).abs() <= handle_half { + return handle; + } + } + + // ── Check move handle (inside bounding box) ──────────────────────── + if lx >= x1 && lx <= x2 && ly >= y1 && ly <= y2 { + return VEH::Move; + } + + VEH::None + } } impl canvas::Program for OverlayProgram { @@ -568,6 +763,11 @@ impl canvas::Program for OverlayProgram { } } + // Draw vector edit handles for selected shape (blue outline, resize + rotation) + if let Some((x1, y1, x2, y2)) = self.selected_vector_bounds { + self.draw_vector_edit_handles(&mut frame, x1, y1, x2, y2, self.selected_vector_angle, origin_x, origin_y); + } + // Draw gradient endpoint preview line. if let Some(((gx0, gy0), (gx1, gy1))) = self.gradient_drag { let p0 = Point::new(origin_x + gx0 * self.zoom, origin_y + gy0 * self.zoom); @@ -735,6 +935,24 @@ pub fn view<'a>( .height(Length::Fill); // ── Overlay canvas (selection, vector preview, crosshair, pressure HUD) ────── + // Compute selected vector shape bounds for handle rendering. + let (sel_vec_bounds, sel_vec_angle) = if let Some(idx) = doc.selected_vector_shape { + let layer_id = doc.engine.active_layer_id(); + if let Some(shapes) = doc.engine.active_vector_shapes() { + if let Some(shape) = shapes.get(idx) { + let (x1, y1, x2, y2) = shape.normalized_bounds(); + let angle = doc.engine.vector_shape_angle(layer_id, idx); + (Some((x1, y1, x2, y2)), angle) + } else { + (None, 0.0) + } + } else { + (None, 0.0) + } + } else { + (None, 0.0) + }; + let overlay_program = OverlayProgram { engine_w, engine_h, @@ -751,6 +969,9 @@ pub fn view<'a>( active_tool: tool_state.active_tool, gradient_drag: doc.gradient_drag, marching_ants_edges: doc.marching_ants_edges.clone(), + selected_vector_bounds: sel_vec_bounds, + selected_vector_angle: sel_vec_angle, + vector_edit_handle: doc.vector_edit_handle, }; let overlay_canvas = canvas(overlay_program) diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/dock/view.rs b/hcie-iced-app/crates/hcie-iced-gui/src/dock/view.rs index 7b2c9f0..3c085d5 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/dock/view.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/dock/view.rs @@ -104,7 +104,7 @@ pub fn dock_view<'a>( } PaneType::Geometry => { let shapes = app.documents[app.active_doc].engine.active_vector_shapes().unwrap_or_default(); - crate::panels::geometry::view(shapes, colors, app.selected_vector_shape) + crate::panels::geometry::view(shapes, colors, app.documents[app.active_doc].selected_vector_shape) } PaneType::ToolSettings => { let fg = app.fg_color;