From ffb53884b05dfb9c57bd50dab76d75f86ba77cca Mon Sep 17 00:00:00 2001 From: Skyler Lehmkuhl Date: Mon, 22 Dec 2025 18:34:01 -0500 Subject: [PATCH] initial vector editing --- .../lightningbeam-core/src/actions/mod.rs | 2 + .../src/actions/modify_shape_path.rs | 225 +++++ .../lightningbeam-core/src/bezier_vertex.rs | 104 +++ .../lightningbeam-core/src/bezpath_editing.rs | 375 ++++++++ .../lightningbeam-core/src/hit_test.rs | 169 ++++ .../lightningbeam-core/src/lib.rs | 2 + .../lightningbeam-core/src/tool.rs | 27 + .../lightningbeam-editor/src/panes/stage.rs | 850 +++++++++++++++++- 8 files changed, 1748 insertions(+), 6 deletions(-) create mode 100644 lightningbeam-ui/lightningbeam-core/src/actions/modify_shape_path.rs create mode 100644 lightningbeam-ui/lightningbeam-core/src/bezier_vertex.rs create mode 100644 lightningbeam-ui/lightningbeam-core/src/bezpath_editing.rs diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/mod.rs b/lightningbeam-ui/lightningbeam-core/src/actions/mod.rs index d6c3535..5685534 100644 --- a/lightningbeam-ui/lightningbeam-core/src/actions/mod.rs +++ b/lightningbeam-ui/lightningbeam-core/src/actions/mod.rs @@ -7,6 +7,7 @@ pub mod add_clip_instance; pub mod add_effect; pub mod add_layer; pub mod add_shape; +pub mod modify_shape_path; pub mod move_clip_instances; pub mod move_objects; pub mod paint_bucket; @@ -24,6 +25,7 @@ pub use add_clip_instance::AddClipInstanceAction; pub use add_effect::AddEffectAction; pub use add_layer::AddLayerAction; pub use add_shape::AddShapeAction; +pub use modify_shape_path::ModifyShapePathAction; pub use move_clip_instances::MoveClipInstancesAction; pub use move_objects::MoveShapeInstancesAction; pub use paint_bucket::PaintBucketAction; diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/modify_shape_path.rs b/lightningbeam-ui/lightningbeam-core/src/actions/modify_shape_path.rs new file mode 100644 index 0000000..5d59463 --- /dev/null +++ b/lightningbeam-ui/lightningbeam-core/src/actions/modify_shape_path.rs @@ -0,0 +1,225 @@ +//! Modify shape path action +//! +//! Handles modifying a shape's bezier path (for vector editing operations) +//! with undo/redo support. + +use crate::action::Action; +use crate::document::Document; +use crate::layer::AnyLayer; +use uuid::Uuid; +use vello::kurbo::BezPath; + +/// Action that modifies a shape's path +/// +/// This action is used for vector editing operations like dragging vertices, +/// reshaping curves, or manipulating control points. +pub struct ModifyShapePathAction { + /// Layer containing the shape + layer_id: Uuid, + + /// Shape to modify + shape_id: Uuid, + + /// The version index being modified (for shapes with multiple versions) + version_index: usize, + + /// New path + new_path: BezPath, + + /// Old path (stored after first execution for undo) + old_path: Option, +} + +impl ModifyShapePathAction { + /// Create a new action to modify a shape's path + /// + /// # Arguments + /// + /// * `layer_id` - The layer containing the shape + /// * `shape_id` - The shape to modify + /// * `version_index` - The version index to modify (usually 0) + /// * `new_path` - The new path to set + pub fn new(layer_id: Uuid, shape_id: Uuid, version_index: usize, new_path: BezPath) -> Self { + Self { + layer_id, + shape_id, + version_index, + new_path, + old_path: None, + } + } + + /// Create action with old path already known (for optimization) + pub fn with_old_path( + layer_id: Uuid, + shape_id: Uuid, + version_index: usize, + old_path: BezPath, + new_path: BezPath, + ) -> Self { + Self { + layer_id, + shape_id, + version_index, + new_path, + old_path: Some(old_path), + } + } +} + +impl Action for ModifyShapePathAction { + fn execute(&mut self, document: &mut Document) -> Result<(), String> { + if let Some(layer) = document.get_layer_mut(&self.layer_id) { + if let AnyLayer::Vector(vector_layer) = layer { + if let Some(shape) = vector_layer.shapes.get_mut(&self.shape_id) { + // Check if version exists + if self.version_index >= shape.versions.len() { + return Err(format!( + "Version index {} out of bounds (shape has {} versions)", + self.version_index, + shape.versions.len() + )); + } + + // Store old path if not already stored + if self.old_path.is_none() { + self.old_path = Some(shape.versions[self.version_index].path.clone()); + } + + // Apply new path + shape.versions[self.version_index].path = self.new_path.clone(); + + return Ok(()); + } + } + } + + Err(format!( + "Could not find shape {} in layer {}", + self.shape_id, self.layer_id + )) + } + + fn rollback(&mut self, document: &mut Document) -> Result<(), String> { + if let Some(old_path) = &self.old_path { + if let Some(layer) = document.get_layer_mut(&self.layer_id) { + if let AnyLayer::Vector(vector_layer) = layer { + if let Some(shape) = vector_layer.shapes.get_mut(&self.shape_id) { + if self.version_index < shape.versions.len() { + shape.versions[self.version_index].path = old_path.clone(); + return Ok(()); + } + } + } + } + } + + Err(format!( + "Could not rollback shape path modification for shape {} in layer {}", + self.shape_id, self.layer_id + )) + } + + fn description(&self) -> String { + "Modify shape path".to_string() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::layer::VectorLayer; + use crate::shape::Shape; + + fn create_test_path() -> BezPath { + let mut path = BezPath::new(); + path.move_to((0.0, 0.0)); + path.line_to((100.0, 0.0)); + path.line_to((100.0, 100.0)); + path.line_to((0.0, 100.0)); + path.close_path(); + path + } + + fn create_modified_path() -> BezPath { + let mut path = BezPath::new(); + path.move_to((0.0, 0.0)); + path.line_to((150.0, 0.0)); // Modified + path.line_to((150.0, 150.0)); // Modified + path.line_to((0.0, 150.0)); // Modified + path.close_path(); + path + } + + #[test] + fn test_modify_shape_path() { + let mut document = Document::new("Test"); + let mut layer = VectorLayer::new("Test Layer"); + + let shape = Shape::new(create_test_path()); + let shape_id = shape.id; + layer.shapes.insert(shape_id, shape); + + let layer_id = document.root_mut().add_child(AnyLayer::Vector(layer)); + + // Verify initial path + if let Some(AnyLayer::Vector(vl)) = document.get_layer_mut(&layer_id) { + let shape = vl.shapes.get(&shape_id).unwrap(); + let bbox = shape.versions[0].path.bounding_box(); + assert_eq!(bbox.width(), 100.0); + assert_eq!(bbox.height(), 100.0); + } + + // Create and execute action + let new_path = create_modified_path(); + let mut action = ModifyShapePathAction::new(layer_id, shape_id, 0, new_path); + action.execute(&mut document).unwrap(); + + // Verify path changed + if let Some(AnyLayer::Vector(vl)) = document.get_layer_mut(&layer_id) { + let shape = vl.shapes.get(&shape_id).unwrap(); + let bbox = shape.versions[0].path.bounding_box(); + assert_eq!(bbox.width(), 150.0); + assert_eq!(bbox.height(), 150.0); + } + + // Rollback + action.rollback(&mut document).unwrap(); + + // Verify restored + if let Some(AnyLayer::Vector(vl)) = document.get_layer_mut(&layer_id) { + let shape = vl.shapes.get(&shape_id).unwrap(); + let bbox = shape.versions[0].path.bounding_box(); + assert_eq!(bbox.width(), 100.0); + assert_eq!(bbox.height(), 100.0); + } + } + + #[test] + fn test_invalid_version_index() { + let mut document = Document::new("Test"); + let mut layer = VectorLayer::new("Test Layer"); + + let shape = Shape::new(create_test_path()); + let shape_id = shape.id; + layer.shapes.insert(shape_id, shape); + + let layer_id = document.root_mut().add_child(AnyLayer::Vector(layer)); + + // Try to modify non-existent version + let new_path = create_modified_path(); + let mut action = ModifyShapePathAction::new(layer_id, shape_id, 5, new_path); + let result = action.execute(&mut document); + + assert!(result.is_err()); + assert!(result.unwrap_err().contains("out of bounds")); + } + + #[test] + fn test_description() { + let layer_id = Uuid::new_v4(); + let shape_id = Uuid::new_v4(); + let action = ModifyShapePathAction::new(layer_id, shape_id, 0, create_test_path()); + assert_eq!(action.description(), "Modify shape path"); + } +} diff --git a/lightningbeam-ui/lightningbeam-core/src/bezier_vertex.rs b/lightningbeam-ui/lightningbeam-core/src/bezier_vertex.rs new file mode 100644 index 0000000..e77a9db --- /dev/null +++ b/lightningbeam-ui/lightningbeam-core/src/bezier_vertex.rs @@ -0,0 +1,104 @@ +//! Bezier vertex and editable curves structures for vector editing +//! +//! Provides data structures for editing bezier paths by extracting +//! vertices (where curves meet) and individual curve segments. + +use vello::kurbo::{CubicBez, Point}; + +/// A vertex in a shape path where curve segments meet +/// +/// Vertices are automatically generated from curve endpoints, with nearby +/// endpoints merged together (within VERTEX_MERGE_EPSILON). This allows +/// dragging a single vertex to update all connected curves simultaneously. +#[derive(Debug, Clone)] +pub struct BezierVertex { + /// The point location in local shape space + pub point: Point, + + /// Indices of curves that start at this vertex + /// (i.e., curves where p0 == this point) + pub start_curves: Vec, + + /// Indices of curves that end at this vertex + /// (i.e., curves where p3 == this point) + pub end_curves: Vec, +} + +impl BezierVertex { + /// Create a new vertex at the given point + pub fn new(point: Point) -> Self { + Self { + point, + start_curves: Vec::new(), + end_curves: Vec::new(), + } + } + + /// Check if this vertex connects to any curves + pub fn is_connected(&self) -> bool { + !self.start_curves.is_empty() || !self.end_curves.is_empty() + } + + /// Get total number of curves connected to this vertex + pub fn connection_count(&self) -> usize { + self.start_curves.len() + self.end_curves.len() + } +} + +/// Extracted editable bezier curve segments from a BezPath +/// +/// This structure represents a BezPath converted into an editable form, +/// with explicit curve segments and auto-generated vertices. This allows +/// for vertex-based and curve-based editing operations. +#[derive(Debug, Clone)] +pub struct EditableBezierCurves { + /// All cubic bezier curves extracted from the path + /// + /// All path elements (lines, quadratics, etc.) are converted to cubic beziers + /// for uniform editing. Each CubicBez has four control points: p0, p1, p2, p3. + pub curves: Vec, + + /// Auto-generated vertices from curve endpoints + /// + /// Vertices are created by merging nearby endpoints (within epsilon tolerance). + /// Each vertex tracks which curves connect to it via start_curves and end_curves. + pub vertices: Vec, + + /// Whether the path is closed + /// + /// A path is considered closed if the first curve's p0 is within epsilon + /// of the last curve's p3. + pub is_closed: bool, +} + +impl EditableBezierCurves { + /// Create a new empty editable curves structure + pub fn new() -> Self { + Self { + curves: Vec::new(), + vertices: Vec::new(), + is_closed: false, + } + } + + /// Get the number of curves + pub fn curve_count(&self) -> usize { + self.curves.len() + } + + /// Get the number of vertices + pub fn vertex_count(&self) -> usize { + self.vertices.len() + } + + /// Check if the structure is empty (no curves) + pub fn is_empty(&self) -> bool { + self.curves.is_empty() + } +} + +impl Default for EditableBezierCurves { + fn default() -> Self { + Self::new() + } +} diff --git a/lightningbeam-ui/lightningbeam-core/src/bezpath_editing.rs b/lightningbeam-ui/lightningbeam-core/src/bezpath_editing.rs new file mode 100644 index 0000000..f07973f --- /dev/null +++ b/lightningbeam-ui/lightningbeam-core/src/bezpath_editing.rs @@ -0,0 +1,375 @@ +//! BezPath editing utilities for vector shape manipulation +//! +//! Provides functions to convert BezPath to/from editable bezier curves, +//! generate vertices, and implement curve manipulation algorithms like moldCurve. + +use crate::bezier_vertex::{BezierVertex, EditableBezierCurves}; +use vello::kurbo::{BezPath, CubicBez, ParamCurve, ParamCurveNearest, PathEl, Point}; + +/// Tolerance for merging nearby vertices (in pixels) +pub const VERTEX_MERGE_EPSILON: f64 = 1.5; + +/// Default epsilon for moldCurve numerical differentiation +const MOLD_CURVE_EPSILON: f64 = 0.01; + +/// Extract editable curves and vertices from a BezPath +/// +/// Converts all path elements to cubic bezier curves and generates vertices +/// by merging nearby endpoints. This creates a structure suitable for +/// vertex and curve editing operations. +/// +/// # Arguments +/// +/// * `path` - The BezPath to extract from +/// +/// # Returns +/// +/// EditableBezierCurves containing curves, vertices, and closure status +pub fn extract_editable_curves(path: &BezPath) -> EditableBezierCurves { + let mut curves = Vec::new(); + let mut current_point = Point::ZERO; + let mut start_point = Point::ZERO; + let mut first_point_set = false; + + for el in path.elements() { + match el { + PathEl::MoveTo(p) => { + current_point = *p; + start_point = *p; + first_point_set = true; + } + PathEl::LineTo(p) => { + if first_point_set { + curves.push(line_to_cubic(current_point, *p)); + current_point = *p; + } + } + PathEl::QuadTo(p1, p2) => { + if first_point_set { + curves.push(quad_to_cubic(current_point, *p1, *p2)); + current_point = *p2; + } + } + PathEl::CurveTo(p1, p2, p3) => { + if first_point_set { + curves.push(CubicBez::new(current_point, *p1, *p2, *p3)); + current_point = *p3; + } + } + PathEl::ClosePath => { + // Add closing line if needed + if first_point_set && (current_point - start_point).hypot() > 1e-6 { + curves.push(line_to_cubic(current_point, start_point)); + current_point = start_point; + } + } + } + } + + let vertices = generate_vertices(&curves); + let is_closed = !curves.is_empty() + && (curves[0].p0 - curves.last().unwrap().p3).hypot() < VERTEX_MERGE_EPSILON; + + EditableBezierCurves { + curves, + vertices, + is_closed, + } +} + +/// Rebuild a BezPath from editable curves +/// +/// Converts the editable curve structure back into a BezPath for rendering. +/// +/// # Arguments +/// +/// * `editable` - The editable curves structure +/// +/// # Returns +/// +/// A BezPath ready for rendering +pub fn rebuild_bezpath(editable: &EditableBezierCurves) -> BezPath { + let mut path = BezPath::new(); + + if editable.curves.is_empty() { + return path; + } + + path.move_to(editable.curves[0].p0); + + for curve in &editable.curves { + path.curve_to(curve.p1, curve.p2, curve.p3); + } + + if editable.is_closed { + path.close_path(); + } + + path +} + +/// Convert a line segment to a cubic bezier curve +/// +/// Places control points at 1/3 and 2/3 along the line so the cubic +/// bezier exactly represents the straight line. +fn line_to_cubic(p0: Point, p3: Point) -> CubicBez { + let p1 = Point::new(p0.x + (p3.x - p0.x) / 3.0, p0.y + (p3.y - p0.y) / 3.0); + let p2 = Point::new( + p0.x + 2.0 * (p3.x - p0.x) / 3.0, + p0.y + 2.0 * (p3.y - p0.y) / 3.0, + ); + CubicBez::new(p0, p1, p2, p3) +} + +/// Convert a quadratic bezier to a cubic bezier +/// +/// Uses the standard quadratic-to-cubic conversion formula. +fn quad_to_cubic(p0: Point, p1: Point, p2: Point) -> CubicBez { + // Standard quadratic to cubic conversion formula + let c1 = Point::new( + p0.x + 2.0 * (p1.x - p0.x) / 3.0, + p0.y + 2.0 * (p1.y - p0.y) / 3.0, + ); + let c2 = Point::new( + p2.x + 2.0 * (p1.x - p2.x) / 3.0, + p2.y + 2.0 * (p1.y - p2.y) / 3.0, + ); + CubicBez::new(p0, c1, c2, p2) +} + +/// Generate vertices from curve endpoints +/// +/// Creates vertices by merging nearby endpoints (within VERTEX_MERGE_EPSILON). +/// Each vertex tracks which curves start and end at that point. +/// +/// # Arguments +/// +/// * `curves` - The array of cubic bezier curves +/// +/// # Returns +/// +/// A vector of BezierVertex structs with connection information +fn generate_vertices(curves: &[CubicBez]) -> Vec { + let mut vertices = Vec::new(); + + for (i, curve) in curves.iter().enumerate() { + // Process start point (p0) + add_or_merge_vertex(&mut vertices, curve.p0, i, true); + + // Process end point (p3) + add_or_merge_vertex(&mut vertices, curve.p3, i, false); + } + + vertices +} + +/// Add a point as a new vertex or merge with existing nearby vertex +/// +/// If a vertex already exists within VERTEX_MERGE_EPSILON, the curve +/// is added to that vertex's connection list. Otherwise, a new vertex +/// is created. +fn add_or_merge_vertex( + vertices: &mut Vec, + point: Point, + curve_index: usize, + is_start: bool, +) { + // Check if a vertex already exists at this point (within epsilon) + for vertex in vertices.iter_mut() { + let dist = (vertex.point - point).hypot(); + if dist < VERTEX_MERGE_EPSILON { + // Merge with existing vertex + if is_start { + if !vertex.start_curves.contains(&curve_index) { + vertex.start_curves.push(curve_index); + } + } else { + if !vertex.end_curves.contains(&curve_index) { + vertex.end_curves.push(curve_index); + } + } + return; + } + } + + // Create new vertex + let mut vertex = BezierVertex::new(point); + if is_start { + vertex.start_curves.push(curve_index); + } else { + vertex.end_curves.push(curve_index); + } + + vertices.push(vertex); +} + +/// Reshape a cubic bezier curve by dragging a point on it (moldCurve algorithm) +/// +/// This uses numerical differentiation to calculate how the control points +/// should move to make the curve pass through the mouse position while keeping +/// endpoints fixed. The algorithm is based on the JavaScript UI implementation. +/// +/// # Algorithm +/// +/// 1. Project old_mouse onto the curve to find the grab parameter t +/// 2. Create offset curves by nudging each control point by epsilon +/// 3. Evaluate offset curves at parameter t to get derivatives +/// 4. Calculate control point adjustments weighted by t +/// 5. Return curve with adjusted control points and same endpoints +/// +/// # Arguments +/// +/// * `curve` - The original curve +/// * `mouse` - The target position (where we want the curve to go) +/// * `old_mouse` - The starting position (where the drag started) +/// * `epsilon` - Step size for numerical differentiation (optional) +/// +/// # Returns +/// +/// A new CubicBez with adjusted control points +/// +/// # Reference +/// +/// Based on `src/main.js` lines 551-602 in the JavaScript UI +pub fn mold_curve(curve: &CubicBez, mouse: &Point, old_mouse: &Point) -> CubicBez { + mold_curve_with_epsilon(curve, mouse, old_mouse, MOLD_CURVE_EPSILON) +} + +/// Mold curve with custom epsilon (for testing or fine-tuning) +pub fn mold_curve_with_epsilon( + curve: &CubicBez, + mouse: &Point, + old_mouse: &Point, + epsilon: f64, +) -> CubicBez { + // Step 1: Find the closest point on the curve to old_mouse + let nearest = curve.nearest(*old_mouse, 1e-6); + let t = nearest.t; + let projection = curve.eval(t); + + // Step 2: Create offset curves by moving each control point by epsilon + let offset_p1 = Point::new(curve.p1.x + epsilon, curve.p1.y + epsilon); + let offset_p2 = Point::new(curve.p2.x + epsilon, curve.p2.y + epsilon); + + let offset_curve_p1 = CubicBez::new(curve.p0, offset_p1, curve.p2, curve.p3); + let offset_curve_p2 = CubicBez::new(curve.p0, curve.p1, offset_p2, curve.p3); + + // Step 3: Evaluate offset curves at parameter t + let offset1 = offset_curve_p1.eval(t); + let offset2 = offset_curve_p2.eval(t); + + // Step 4: Calculate derivatives (numerical differentiation) + let derivative_p1_x = (offset1.x - projection.x) / epsilon; + let derivative_p1_y = (offset1.y - projection.y) / epsilon; + let derivative_p2_x = (offset2.x - projection.x) / epsilon; + let derivative_p2_y = (offset2.y - projection.y) / epsilon; + + // Step 5: Calculate how much to move control points + let delta_x = mouse.x - projection.x; + let delta_y = mouse.y - projection.y; + + // Weight by parameter t: p1 affects curve more at t=0, p2 more at t=1 + let weight_p1 = 1.0 - t * t; // Stronger near start + let weight_p2 = t * t; // Stronger near end + + // Avoid division by zero + let adjust_p1_x = if derivative_p1_x.abs() > 1e-10 { + (delta_x / derivative_p1_x) * weight_p1 + } else { + 0.0 + }; + let adjust_p1_y = if derivative_p1_y.abs() > 1e-10 { + (delta_y / derivative_p1_y) * weight_p1 + } else { + 0.0 + }; + let adjust_p2_x = if derivative_p2_x.abs() > 1e-10 { + (delta_x / derivative_p2_x) * weight_p2 + } else { + 0.0 + }; + let adjust_p2_y = if derivative_p2_y.abs() > 1e-10 { + (delta_y / derivative_p2_y) * weight_p2 + } else { + 0.0 + }; + + let new_p1 = Point::new(curve.p1.x + adjust_p1_x, curve.p1.y + adjust_p1_y); + let new_p2 = Point::new(curve.p2.x + adjust_p2_x, curve.p2.y + adjust_p2_y); + + // Return updated curve with same endpoints + CubicBez::new(curve.p0, new_p1, new_p2, curve.p3) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_line_to_cubic() { + let p0 = Point::new(0.0, 0.0); + let p3 = Point::new(100.0, 100.0); + let cubic = line_to_cubic(p0, p3); + + // Check endpoints + assert_eq!(cubic.p0, p0); + assert_eq!(cubic.p3, p3); + + // Check that control points are collinear (on the line) + // Middle of line should be at (50, 50) + let mid = cubic.eval(0.5); + assert!((mid.x - 50.0).abs() < 0.01); + assert!((mid.y - 50.0).abs() < 0.01); + } + + #[test] + fn test_extract_and_rebuild_bezpath() { + let mut path = BezPath::new(); + path.move_to((0.0, 0.0)); + path.line_to((100.0, 0.0)); + path.line_to((100.0, 100.0)); + path.line_to((0.0, 100.0)); + path.close_path(); + + let editable = extract_editable_curves(&path); + assert_eq!(editable.curves.len(), 4); // 4 line segments + assert!(editable.is_closed); + + let rebuilt = rebuild_bezpath(&editable); + // Rebuilt path should have same shape + assert!(!rebuilt.is_empty()); + } + + #[test] + fn test_vertex_generation() { + let curves = vec![ + CubicBez::new( + Point::new(0.0, 0.0), + Point::new(33.0, 0.0), + Point::new(66.0, 0.0), + Point::new(100.0, 0.0), + ), + CubicBez::new( + Point::new(100.0, 0.0), + Point::new(100.0, 33.0), + Point::new(100.0, 66.0), + Point::new(100.0, 100.0), + ), + ]; + + let vertices = generate_vertices(&curves); + + // Should have 3 vertices: start of curve 0, junction, end of curve 1 + assert_eq!(vertices.len(), 3); + + // Middle vertex should connect both curves + let middle_vertex = vertices.iter().find(|v| { + let dist = (v.point - Point::new(100.0, 0.0)).hypot(); + dist < 1.0 + }); + assert!(middle_vertex.is_some()); + let middle = middle_vertex.unwrap(); + assert_eq!(middle.end_curves.len(), 1); // End of curve 0 + assert_eq!(middle.start_curves.len(), 1); // Start of curve 1 + } +} diff --git a/lightningbeam-ui/lightningbeam-core/src/hit_test.rs b/lightningbeam-ui/lightningbeam-core/src/hit_test.rs index a9d66fd..85eceba 100644 --- a/lightningbeam-ui/lightningbeam-core/src/hit_test.rs +++ b/lightningbeam-ui/lightningbeam-core/src/hit_test.rs @@ -344,6 +344,175 @@ pub fn hit_test_clip_instances_in_rect( hits } +/// Result of a vector editing hit test +/// +/// Represents different types of hits in order of priority: +/// ControlPoint > Vertex > Curve > Fill +#[derive(Debug, Clone, Copy)] +pub enum VectorEditHit { + /// Hit a control point (BezierEdit tool only) + ControlPoint { + shape_instance_id: Uuid, + curve_index: usize, + point_index: u8, // 1 or 2 (p1 or p2 of cubic bezier) + }, + /// Hit a vertex (anchor point) + Vertex { + shape_instance_id: Uuid, + vertex_index: usize, + }, + /// Hit a curve segment + Curve { + shape_instance_id: Uuid, + curve_index: usize, + parameter_t: f64, // Where on the curve (0.0 to 1.0) + }, + /// Hit shape fill + Fill { shape_instance_id: Uuid }, +} + +/// Tolerances for vector editing hit testing (in screen pixels) +#[derive(Debug, Clone, Copy)] +pub struct EditingHitTolerance { + /// Tolerance for hitting control points + pub control_point: f64, + /// Tolerance for hitting vertices + pub vertex: f64, + /// Tolerance for hitting curves + pub curve: f64, + /// Tolerance for hitting fill (usually 0.0 for exact containment) + pub fill: f64, +} + +impl Default for EditingHitTolerance { + fn default() -> Self { + Self { + control_point: 10.0, + vertex: 15.0, + curve: 15.0, + fill: 0.0, + } + } +} + +impl EditingHitTolerance { + /// Create tolerances scaled by zoom factor + /// + /// When zoomed in, hit targets appear larger in screen pixels, + /// so we divide by zoom to maintain consistent screen-space sizes. + pub fn scaled_by_zoom(zoom: f64) -> Self { + Self { + control_point: 10.0 / zoom, + vertex: 15.0 / zoom, + curve: 15.0 / zoom, + fill: 0.0, + } + } +} + +/// Hit test for vector editing with priority-based detection +/// +/// Tests objects in reverse order (front to back) and returns the first hit. +/// Priority order: Control points > Vertices > Curves > Fill +/// +/// # Arguments +/// +/// * `layer` - The vector layer to test +/// * `point` - The point to test in screen/canvas space +/// * `tolerance` - Hit tolerances for different element types +/// * `parent_transform` - Transform from parent GraphicsObject(s) +/// * `show_control_points` - Whether to test control points (BezierEdit tool) +/// +/// # Returns +/// +/// The first hit in priority order, or None if no hit +pub fn hit_test_vector_editing( + layer: &VectorLayer, + point: Point, + tolerance: &EditingHitTolerance, + parent_transform: Affine, + show_control_points: bool, +) -> Option { + use crate::bezpath_editing::extract_editable_curves; + use vello::kurbo::{ParamCurve, ParamCurveNearest}; + + // Test objects in reverse order (front to back for hit testing) + for object in layer.shape_instances.iter().rev() { + // Get the shape for this object + let shape = match layer.get_shape(&object.shape_id) { + Some(s) => s, + None => continue, + }; + + // Combine parent transform with object transform + let combined_transform = parent_transform * object.to_affine(); + let inverse_transform = combined_transform.inverse(); + let local_point = inverse_transform * point; + + // Extract editable curves and vertices from the shape's path + let editable = extract_editable_curves(shape.path()); + + // Priority 1: Control points (only in BezierEdit mode) + if show_control_points { + for (i, curve) in editable.curves.iter().enumerate() { + // Test p1 (first control point) + let dist_p1 = (curve.p1 - local_point).hypot(); + if dist_p1 < tolerance.control_point { + return Some(VectorEditHit::ControlPoint { + shape_instance_id: object.id, + curve_index: i, + point_index: 1, + }); + } + + // Test p2 (second control point) + let dist_p2 = (curve.p2 - local_point).hypot(); + if dist_p2 < tolerance.control_point { + return Some(VectorEditHit::ControlPoint { + shape_instance_id: object.id, + curve_index: i, + point_index: 2, + }); + } + } + } + + // Priority 2: Vertices (anchor points) + for (i, vertex) in editable.vertices.iter().enumerate() { + let dist = (vertex.point - local_point).hypot(); + if dist < tolerance.vertex { + return Some(VectorEditHit::Vertex { + shape_instance_id: object.id, + vertex_index: i, + }); + } + } + + // Priority 3: Curves + for (i, curve) in editable.curves.iter().enumerate() { + let nearest = curve.nearest(local_point, 1e-6); + let nearest_point = curve.eval(nearest.t); + let dist = (nearest_point - local_point).hypot(); + if dist < tolerance.curve { + return Some(VectorEditHit::Curve { + shape_instance_id: object.id, + curve_index: i, + parameter_t: nearest.t, + }); + } + } + + // Priority 4: Fill + if shape.fill_color.is_some() && shape.path().contains(local_point) { + return Some(VectorEditHit::Fill { + shape_instance_id: object.id, + }); + } + } + + None +} + #[cfg(test)] mod tests { use super::*; diff --git a/lightningbeam-ui/lightningbeam-core/src/lib.rs b/lightningbeam-ui/lightningbeam-core/src/lib.rs index 8559668..ca01d9e 100644 --- a/lightningbeam-ui/lightningbeam-core/src/lib.rs +++ b/lightningbeam-ui/lightningbeam-core/src/lib.rs @@ -9,6 +9,8 @@ pub mod animation; pub mod path_interpolation; pub mod path_fitting; pub mod shape; +pub mod bezier_vertex; +pub mod bezpath_editing; pub mod object; pub mod layer; pub mod layer_tree; diff --git a/lightningbeam-ui/lightningbeam-core/src/tool.rs b/lightningbeam-ui/lightningbeam-core/src/tool.rs index 2dfda83..880e97f 100644 --- a/lightningbeam-ui/lightningbeam-core/src/tool.rs +++ b/lightningbeam-ui/lightningbeam-core/src/tool.rs @@ -98,6 +98,33 @@ pub enum ToolState { current_point: Point, // Current mouse position (determines radius) num_sides: u32, // Number of sides (from properties, default 5) }, + + /// Editing a vertex (dragging it and connected curves) + EditingVertex { + shape_id: Uuid, // Which shape is being edited + vertex_index: usize, // Which vertex in the vertices array + start_pos: Point, // Vertex position when drag started + start_mouse: Point, // Mouse position when drag started + affected_curve_indices: Vec, // Which curves connect to this vertex + }, + + /// Editing a curve (reshaping with moldCurve algorithm) + EditingCurve { + shape_id: Uuid, // Which shape is being edited + curve_index: usize, // Which curve in the curves array + original_curve: vello::kurbo::CubicBez, // The curve when drag started + start_mouse: Point, // Mouse position when drag started + parameter_t: f64, // Parameter where the drag started (0.0-1.0) + }, + + /// Editing a control point (BezierEdit tool only) + EditingControlPoint { + shape_id: Uuid, // Which shape is being edited + curve_index: usize, // Which curve owns this control point + point_index: u8, // 1 or 2 (p1 or p2 of the cubic bezier) + original_curve: vello::kurbo::CubicBez, // The curve when drag started + start_pos: Point, // Control point position when drag started + }, } /// Path simplification mode for the draw tool diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs index 910cb4f..3a53c68 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs @@ -1911,6 +1911,24 @@ pub struct StagePane { pending_eyedropper_sample: Option<(egui::Pos2, super::ColorMode)>, // Last known viewport rect (for zoom-to-fit calculation) last_viewport_rect: Option, + // Vector editing cache + shape_editing_cache: Option, +} + +/// Cached data for editing a shape +struct ShapeEditingCache { + /// The shape ID being edited + shape_id: uuid::Uuid, + /// The shape instance ID being edited + instance_id: uuid::Uuid, + /// Extracted editable curves and vertices + editable_data: lightningbeam_core::bezier_vertex::EditableBezierCurves, + /// The version index of the shape being edited + version_index: usize, + /// Transform from shape-local to world space + local_to_world: vello::kurbo::Affine, + /// Transform from world to shape-local space + world_to_local: vello::kurbo::Affine, } // Global counter for generating unique instance IDs @@ -1930,6 +1948,7 @@ impl StagePane { instance_id, pending_eyedropper_sample: None, last_viewport_rect: None, + shape_editing_cache: None, } } @@ -2021,11 +2040,12 @@ impl StagePane { ) { use lightningbeam_core::tool::ToolState; use lightningbeam_core::layer::AnyLayer; - use lightningbeam_core::hit_test; + use lightningbeam_core::hit_test::{self, hit_test_vector_editing, EditingHitTolerance, VectorEditHit}; + use lightningbeam_core::bezpath_editing::{extract_editable_curves, mold_curve}; use vello::kurbo::{Point, Rect as KurboRect, Affine}; // Check if we have an active vector layer - let active_layer_id = match shared.active_layer_id { + let active_layer_id = match *shared.active_layer_id { Some(id) => id, None => return, // No active layer }; @@ -2044,7 +2064,37 @@ impl StagePane { let point = Point::new(world_pos.x as f64, world_pos.y as f64); // Mouse down: start interaction (use drag_started for immediate feedback) + // Scope this section to drop vector_layer borrow before drag handling if response.drag_started() || response.clicked() { + // VECTOR EDITING: Check for vertex/curve editing first (higher priority than selection) + let tolerance = EditingHitTolerance::scaled_by_zoom(self.zoom as f64); + let vector_hit = hit_test_vector_editing( + vector_layer, + point, + &tolerance, + Affine::IDENTITY, + false, // Select tool doesn't show control points + ); + // Priority 1: Vector editing (vertices and curves) + if let Some(hit) = vector_hit { + match hit { + VectorEditHit::Vertex { shape_instance_id, vertex_index } => { + // Start editing a vertex + self.start_vertex_editing(shape_instance_id, vertex_index, point, active_layer_id, shared); + return; + } + VectorEditHit::Curve { shape_instance_id, curve_index, parameter_t } => { + // Start editing a curve + self.start_curve_editing(shape_instance_id, curve_index, parameter_t, point, active_layer_id, shared); + return; + } + _ => { + // Fill hit - fall through to normal selection + } + } + } + + // Priority 2: Normal selection/dragging (no vector element hit) // Hit test at click position // Test clip instances first (they're on top of shapes) let document = shared.action_executor.document(); @@ -2149,6 +2199,10 @@ impl StagePane { // Mouse drag: update tool state if response.dragged() { match shared.tool_state { + ToolState::EditingVertex { .. } | ToolState::EditingCurve { .. } => { + // Vector editing - update happens in helper method + self.update_vector_editing(point, shared); + } ToolState::DraggingSelection { .. } => { // Update current position (visual feedback only) // Actual move happens on mouse up @@ -2168,9 +2222,14 @@ impl StagePane { let drag_stopped = response.drag_stopped(); let pointer_released = ui.input(|i| i.pointer.any_released()); let is_drag_or_marquee = matches!(shared.tool_state, ToolState::DraggingSelection { .. } | ToolState::MarqueeSelecting { .. }); + let is_vector_editing = matches!(shared.tool_state, ToolState::EditingVertex { .. } | ToolState::EditingCurve { .. } | ToolState::EditingControlPoint { .. }); - if drag_stopped || (pointer_released && is_drag_or_marquee) { + if drag_stopped || (pointer_released && (is_drag_or_marquee || is_vector_editing)) { match shared.tool_state.clone() { + ToolState::EditingVertex { shape_id, .. } | ToolState::EditingCurve { shape_id, .. } | ToolState::EditingControlPoint { shape_id, .. } => { + // Finish vector editing - create action + self.finish_vector_editing(shape_id, active_layer_id, shared); + } ToolState::DraggingSelection { start_mouse, original_positions, .. } => { // Calculate total delta let delta = point - start_mouse; @@ -2179,6 +2238,17 @@ impl StagePane { // Create move actions with new positions use std::collections::HashMap; + // Get vector layer again (to avoid holding borrow from earlier) + let document = shared.action_executor.document(); + let layer = match document.get_layer(&active_layer_id) { + Some(l) => l, + None => return, + }; + let vector_layer = match layer { + AnyLayer::Vector(vl) => vl, + _ => return, + }; + // Separate shape instances from clip instances let mut shape_instance_positions = HashMap::new(); let mut clip_instance_transforms = HashMap::new(); @@ -2213,14 +2283,14 @@ impl StagePane { // Create and submit move action for shape instances if !shape_instance_positions.is_empty() { use lightningbeam_core::actions::MoveShapeInstancesAction; - let action = MoveShapeInstancesAction::new(*active_layer_id, shape_instance_positions); + let action = MoveShapeInstancesAction::new(active_layer_id, shape_instance_positions); shared.pending_actions.push(Box::new(action)); } // Create and submit transform action for clip instances if !clip_instance_transforms.is_empty() { use lightningbeam_core::actions::TransformClipInstancesAction; - let action = TransformClipInstancesAction::new(*active_layer_id, clip_instance_transforms); + let action = TransformClipInstancesAction::new(active_layer_id, clip_instance_transforms); shared.pending_actions.push(Box::new(action)); } } @@ -2237,8 +2307,18 @@ impl StagePane { let selection_rect = KurboRect::new(min_x, min_y, max_x, max_y); - // Hit test clip instances in rectangle + // Get vector layer again (to avoid holding borrow from earlier) let document = shared.action_executor.document(); + let layer = match document.get_layer(&active_layer_id) { + Some(l) => l, + None => return, + }; + let vector_layer = match layer { + AnyLayer::Vector(vl) => vl, + _ => return, + }; + + // Hit test clip instances in rectangle let clip_hits = hit_test::hit_test_clip_instances_in_rect( &vector_layer.clip_instances, document, @@ -2292,6 +2372,515 @@ impl StagePane { } } + /// Start editing a vertex - called when user clicks on a vertex + fn start_vertex_editing( + &mut self, + shape_instance_id: uuid::Uuid, + vertex_index: usize, + mouse_pos: vello::kurbo::Point, + active_layer_id: uuid::Uuid, + shared: &mut SharedPaneState, + ) { + use lightningbeam_core::bezpath_editing::extract_editable_curves; + use lightningbeam_core::tool::ToolState; + use lightningbeam_core::layer::AnyLayer; + use vello::kurbo::Affine; + + // Get the vector layer + let layer = match shared.action_executor.document().get_layer(&active_layer_id) { + Some(l) => l, + None => return, + }; + + let vector_layer = match layer { + AnyLayer::Vector(vl) => vl, + _ => return, + }; + + // Get the shape instance + let shape_instance = match vector_layer.get_object(&shape_instance_id) { + Some(obj) => obj, + None => return, + }; + + // Get the shape definition + let shape = match vector_layer.get_shape(&shape_instance.shape_id) { + Some(s) => s, + None => return, + }; + + // Extract editable curves + let editable_data = extract_editable_curves(shape.path()); + + // Validate vertex index + if vertex_index >= editable_data.vertices.len() { + return; + } + + let vertex = &editable_data.vertices[vertex_index]; + + // Build transform matrices + let local_to_world = Affine::translate((shape_instance.transform.x, shape_instance.transform.y)) + * Affine::rotate(shape_instance.transform.rotation) + * Affine::scale_non_uniform(shape_instance.transform.scale_x, shape_instance.transform.scale_y); + let world_to_local = local_to_world.inverse(); + + // Store editing cache + self.shape_editing_cache = Some(ShapeEditingCache { + shape_id: shape_instance.shape_id, + instance_id: shape_instance_id, + editable_data: editable_data.clone(), + version_index: shape.versions.len() - 1, + local_to_world, + world_to_local, + }); + + // Set tool state + *shared.tool_state = ToolState::EditingVertex { + shape_id: shape_instance.shape_id, + vertex_index, + start_pos: vertex.point, + start_mouse: mouse_pos, + affected_curve_indices: vertex.start_curves.iter() + .chain(vertex.end_curves.iter()) + .copied() + .collect(), + }; + } + + /// Start editing a curve - called when user clicks on a curve + fn start_curve_editing( + &mut self, + shape_instance_id: uuid::Uuid, + curve_index: usize, + parameter_t: f64, + mouse_pos: vello::kurbo::Point, + active_layer_id: uuid::Uuid, + shared: &mut SharedPaneState, + ) { + use lightningbeam_core::bezpath_editing::extract_editable_curves; + use lightningbeam_core::tool::ToolState; + use lightningbeam_core::layer::AnyLayer; + use vello::kurbo::Affine; + + // Get the vector layer + let layer = match shared.action_executor.document().get_layer(&active_layer_id) { + Some(l) => l, + None => return, + }; + + let vector_layer = match layer { + AnyLayer::Vector(vl) => vl, + _ => return, + }; + + // Get the shape instance + let shape_instance = match vector_layer.get_object(&shape_instance_id) { + Some(obj) => obj, + None => return, + }; + + // Get the shape definition + let shape = match vector_layer.get_shape(&shape_instance.shape_id) { + Some(s) => s, + None => return, + }; + + // Extract editable curves + let editable_data = extract_editable_curves(shape.path()); + + // Validate curve index + if curve_index >= editable_data.curves.len() { + return; + } + + let original_curve = editable_data.curves[curve_index]; + + // Build transform matrices + let local_to_world = Affine::translate((shape_instance.transform.x, shape_instance.transform.y)) + * Affine::rotate(shape_instance.transform.rotation) + * Affine::scale_non_uniform(shape_instance.transform.scale_x, shape_instance.transform.scale_y); + let world_to_local = local_to_world.inverse(); + + // Store editing cache + self.shape_editing_cache = Some(ShapeEditingCache { + shape_id: shape_instance.shape_id, + instance_id: shape_instance_id, + editable_data, + version_index: shape.versions.len() - 1, + local_to_world, + world_to_local, + }); + + // Set tool state + *shared.tool_state = ToolState::EditingCurve { + shape_id: shape_instance.shape_id, + curve_index, + original_curve, + start_mouse: mouse_pos, + parameter_t, + }; + } + + /// Update vector editing during drag + fn update_vector_editing( + &mut self, + mouse_pos: vello::kurbo::Point, + shared: &mut SharedPaneState, + ) { + use lightningbeam_core::bezpath_editing::{mold_curve, rebuild_bezpath}; + use lightningbeam_core::tool::ToolState; + use vello::kurbo::Point; + + // Clone tool state to get owned values + let tool_state = shared.tool_state.clone(); + + let cache = match &mut self.shape_editing_cache { + Some(c) => c, + None => return, + }; + + match tool_state { + ToolState::EditingVertex { vertex_index, start_pos, start_mouse, affected_curve_indices, .. } => { + // Transform mouse position to local space + let local_mouse = cache.world_to_local * mouse_pos; + let local_start_mouse = cache.world_to_local * start_mouse; + + // Calculate delta in local space + let delta = local_mouse - local_start_mouse; + let new_vertex_pos = start_pos + delta; + + // Update the vertex position + if vertex_index < cache.editable_data.vertices.len() { + cache.editable_data.vertices[vertex_index].point = new_vertex_pos; + } + + // Update all affected curves + for &curve_idx in affected_curve_indices.iter() { + if curve_idx >= cache.editable_data.curves.len() { + continue; + } + + let curve = &mut cache.editable_data.curves[curve_idx]; + let vertex = &cache.editable_data.vertices[vertex_index]; + + // Check if this curve starts at this vertex + if vertex.start_curves.contains(&curve_idx) { + // Update endpoint p0 and adjacent control point p1 + let endpoint_delta = new_vertex_pos - curve.p0; + curve.p0 = new_vertex_pos; + curve.p1 = curve.p1 + endpoint_delta; + } + + // Check if this curve ends at this vertex + if vertex.end_curves.contains(&curve_idx) { + // Update endpoint p3 and adjacent control point p2 + let endpoint_delta = new_vertex_pos - curve.p3; + curve.p3 = new_vertex_pos; + curve.p2 = curve.p2 + endpoint_delta; + } + } + + // Note: We're only updating the cache here. The actual shape path will be updated + // via ModifyShapePathAction when the user releases the mouse button. + // For now, we'll skip live preview since we can't mutate through the vector_layer reference. + } + ToolState::EditingCurve { curve_index, original_curve, start_mouse, .. } => { + // Transform mouse positions to local space + let local_mouse = cache.world_to_local * mouse_pos; + let local_start_mouse = cache.world_to_local * start_mouse; + + // Apply moldCurve algorithm + let molded_curve = mold_curve(&original_curve, &local_mouse, &local_start_mouse); + + // Update the curve in the cache + if curve_index < cache.editable_data.curves.len() { + cache.editable_data.curves[curve_index] = molded_curve; + } + + // Note: We're only updating the cache here. The actual shape path will be updated + // via ModifyShapePathAction when the user releases the mouse button. + } + ToolState::EditingControlPoint { curve_index, point_index, .. } => { + // Transform mouse position to local space + let local_mouse = cache.world_to_local * mouse_pos; + + // Calculate new control point position + let new_control_point = local_mouse; + + // Update the control point in the cache + if curve_index < cache.editable_data.curves.len() { + let curve = &mut cache.editable_data.curves[curve_index]; + match point_index { + 1 => curve.p1 = new_control_point, + 2 => curve.p2 = new_control_point, + _ => {} // Invalid point index + } + } + + // Note: We're only updating the cache here. The actual shape path will be updated + // via ModifyShapePathAction when the user releases the mouse button. + } + _ => {} + } + } + + /// Finish vector editing and create action for undo/redo + fn finish_vector_editing( + &mut self, + shape_id: uuid::Uuid, + layer_id: uuid::Uuid, + shared: &mut SharedPaneState, + ) { + use lightningbeam_core::bezpath_editing::rebuild_bezpath; + use lightningbeam_core::actions::ModifyShapePathAction; + use lightningbeam_core::tool::ToolState; + + let cache = match self.shape_editing_cache.take() { + Some(c) => c, + None => { + *shared.tool_state = ToolState::Idle; + return; + } + }; + + // Get the original shape to retrieve the old path + let document = shared.action_executor.document(); + let layer = match document.get_layer(&layer_id) { + Some(l) => l, + None => { + *shared.tool_state = ToolState::Idle; + return; + } + }; + + let vector_layer = match layer { + lightningbeam_core::layer::AnyLayer::Vector(vl) => vl, + _ => { + *shared.tool_state = ToolState::Idle; + return; + } + }; + + let old_path = match vector_layer.get_shape(&shape_id) { + Some(shape) => { + if cache.version_index < shape.versions.len() { + // The shape has been temporarily updated during dragging + // We need to get the original path from history or recreate it + // For now, we'll use the version_index we stored + if let Some(version) = shape.versions.get(cache.version_index) { + version.path.clone() + } else { + // Fallback: use current path + shape.path().clone() + } + } else { + shape.path().clone() + } + } + None => { + *shared.tool_state = ToolState::Idle; + return; + } + }; + + // Rebuild the new path from edited curves + let new_path = rebuild_bezpath(&cache.editable_data); + + // Only create action if the path actually changed + if old_path != new_path { + let action = ModifyShapePathAction::with_old_path( + layer_id, + shape_id, + cache.version_index, + old_path, + new_path, + ); + shared.pending_actions.push(Box::new(action)); + } + + // Reset tool state + *shared.tool_state = ToolState::Idle; + } + + /// Handle BezierEdit tool - similar to Select but with control point editing + fn handle_bezier_edit_tool( + &mut self, + ui: &mut egui::Ui, + response: &egui::Response, + world_pos: egui::Vec2, + shift_held: bool, + shared: &mut SharedPaneState, + ) { + use lightningbeam_core::tool::ToolState; + use lightningbeam_core::layer::AnyLayer; + use lightningbeam_core::hit_test::{self, hit_test_vector_editing, EditingHitTolerance, VectorEditHit}; + use vello::kurbo::{Point, Affine}; + + // Check if we have an active vector layer + let active_layer_id = match *shared.active_layer_id { + Some(id) => id, + None => return, + }; + + let active_layer = match shared.action_executor.document().get_layer(&active_layer_id) { + Some(layer) => layer, + None => return, + }; + + // Only work on VectorLayer + let vector_layer = match active_layer { + AnyLayer::Vector(vl) => vl, + _ => return, + }; + + let point = Point::new(world_pos.x as f64, world_pos.y as f64); + + // VECTOR EDITING: Check for control points, vertices, and curves (higher priority than selection) + let tolerance = EditingHitTolerance::scaled_by_zoom(self.zoom as f64); + let vector_hit = hit_test_vector_editing( + vector_layer, + point, + &tolerance, + Affine::IDENTITY, + true, // BezierEdit tool shows control points + ); + + // Mouse down: start interaction + if response.drag_started() || response.clicked() { + // Priority 1: Vector editing (control points, vertices, and curves) + if let Some(hit) = vector_hit { + match hit { + VectorEditHit::ControlPoint { shape_instance_id, curve_index, point_index } => { + // Start editing a control point + self.start_control_point_editing(shape_instance_id, curve_index, point_index, point, active_layer_id, shared); + return; + } + VectorEditHit::Vertex { shape_instance_id, vertex_index } => { + // Start editing a vertex + self.start_vertex_editing(shape_instance_id, vertex_index, point, active_layer_id, shared); + return; + } + VectorEditHit::Curve { shape_instance_id, curve_index, parameter_t } => { + // Start editing a curve + self.start_curve_editing(shape_instance_id, curve_index, parameter_t, point, active_layer_id, shared); + return; + } + _ => { + // Fill hit - no selection in BezierEdit mode, just ignore + } + } + } + } + + // Mouse drag: update tool state + if response.dragged() { + match shared.tool_state { + ToolState::EditingVertex { .. } | ToolState::EditingCurve { .. } | ToolState::EditingControlPoint { .. } => { + // Vector editing - update happens in helper method + self.update_vector_editing(point, shared); + } + _ => {} + } + } + + // Mouse up: finish interaction + let drag_stopped = response.drag_stopped(); + let pointer_released = ui.input(|i| i.pointer.any_released()); + let is_vector_editing = matches!(shared.tool_state, ToolState::EditingVertex { .. } | ToolState::EditingCurve { .. } | ToolState::EditingControlPoint { .. }); + + if drag_stopped || (pointer_released && is_vector_editing) { + match shared.tool_state.clone() { + ToolState::EditingVertex { shape_id, .. } | ToolState::EditingCurve { shape_id, .. } | ToolState::EditingControlPoint { shape_id, .. } => { + // Finish vector editing - create action + self.finish_vector_editing(shape_id, active_layer_id, shared); + } + _ => {} + } + } + } + + /// Start editing a control point - called when user clicks on a control point + fn start_control_point_editing( + &mut self, + shape_instance_id: uuid::Uuid, + curve_index: usize, + point_index: u8, + mouse_pos: vello::kurbo::Point, + active_layer_id: uuid::Uuid, + shared: &mut SharedPaneState, + ) { + use lightningbeam_core::bezpath_editing::extract_editable_curves; + use lightningbeam_core::tool::ToolState; + use lightningbeam_core::layer::AnyLayer; + use vello::kurbo::Affine; + + // Get the vector layer + let layer = match shared.action_executor.document().get_layer(&active_layer_id) { + Some(l) => l, + None => return, + }; + + let vector_layer = match layer { + AnyLayer::Vector(vl) => vl, + _ => return, + }; + + // Get the shape instance + let shape_instance = match vector_layer.get_object(&shape_instance_id) { + Some(obj) => obj, + None => return, + }; + + // Get the shape definition + let shape = match vector_layer.get_shape(&shape_instance.shape_id) { + Some(s) => s, + None => return, + }; + + // Extract editable curves + let editable_data = extract_editable_curves(shape.path()); + + // Validate curve index + if curve_index >= editable_data.curves.len() { + return; + } + + let original_curve = editable_data.curves[curve_index]; + + // Get the control point position + let start_pos = match point_index { + 1 => original_curve.p1, + 2 => original_curve.p2, + _ => return, // Invalid point index + }; + + // Build transform matrices + let local_to_world = Affine::translate((shape_instance.transform.x, shape_instance.transform.y)) + * Affine::rotate(shape_instance.transform.rotation) + * Affine::scale_non_uniform(shape_instance.transform.scale_x, shape_instance.transform.scale_y); + let world_to_local = local_to_world.inverse(); + + // Store editing cache + self.shape_editing_cache = Some(ShapeEditingCache { + shape_id: shape_instance.shape_id, + instance_id: shape_instance_id, + editable_data, + version_index: shape.versions.len() - 1, + local_to_world, + world_to_local, + }); + + // Set tool state + *shared.tool_state = ToolState::EditingControlPoint { + shape_id: shape_instance.shape_id, + curve_index, + point_index, + original_curve, + start_pos, + }; + } + fn handle_rectangle_tool( &mut self, ui: &mut egui::Ui, @@ -4922,6 +5511,9 @@ impl StagePane { Tool::Select => { self.handle_select_tool(ui, &response, world_pos, shift_held, shared); } + Tool::BezierEdit => { + self.handle_bezier_edit_tool(ui, &response, world_pos, shift_held, shared); + } Tool::Rectangle => { self.handle_rectangle_tool(ui, &response, world_pos, shift_held, ctrl_held, shared); } @@ -5007,8 +5599,251 @@ impl StagePane { } } + /// Render vector editing overlays (vertices, control points, handles) + fn render_vector_editing_overlays( + &self, + ui: &mut egui::Ui, + rect: egui::Rect, + shared: &SharedPaneState, + ) { + use lightningbeam_core::bezpath_editing::extract_editable_curves; + use lightningbeam_core::layer::AnyLayer; + use lightningbeam_core::tool::{Tool, ToolState}; + use lightningbeam_core::hit_test::{hit_test_vector_editing, EditingHitTolerance, VectorEditHit}; + use vello::kurbo::{Affine, Point}; + + // Only show overlays for Select and BezierEdit tools + let is_bezier_edit_mode = matches!(*shared.selected_tool, Tool::BezierEdit); + let show_overlays = matches!(*shared.selected_tool, Tool::Select | Tool::BezierEdit); + + if !show_overlays { + return; + } + + // Get active layer + let active_layer_id = match *shared.active_layer_id { + Some(id) => id, + None => return, + }; + + let layer = match shared.action_executor.document().get_layer(&active_layer_id) { + Some(AnyLayer::Vector(layer)) => layer, + _ => return, + }; + + // Get mouse position in world coordinates + let mouse_screen_pos = ui.input(|i| i.pointer.hover_pos()).unwrap_or(rect.center()); + let mouse_canvas_pos = mouse_screen_pos - rect.min; + let mouse_world_pos = Point::new( + ((mouse_canvas_pos.x - self.pan_offset.x) / self.zoom) as f64, + ((mouse_canvas_pos.y - self.pan_offset.y) / self.zoom) as f64, + ); + + // Helper to convert world coordinates to screen coordinates + let world_to_screen = |world_pos: Point| -> egui::Pos2 { + let screen_x = (world_pos.x as f32 * self.zoom) + self.pan_offset.x + rect.min.x; + let screen_y = (world_pos.y as f32 * self.zoom) + self.pan_offset.y + rect.min.y; + egui::pos2(screen_x, screen_y) + }; + + let painter = ui.painter(); + + // Perform hit testing to find what's under the mouse + let tolerance = EditingHitTolerance::scaled_by_zoom(self.zoom as f64); + let hit = hit_test_vector_editing( + layer, + mouse_world_pos, + &tolerance, + Affine::IDENTITY, + is_bezier_edit_mode, + ); + + if is_bezier_edit_mode { + // BezierEdit mode: Show all vertices and control points for all shapes + // Also highlight the element under the mouse + let (hover_vertex, hover_control_point) = match hit { + Some(VectorEditHit::Vertex { shape_instance_id, vertex_index }) => { + (Some((shape_instance_id, vertex_index)), None) + } + Some(VectorEditHit::ControlPoint { shape_instance_id, curve_index, point_index }) => { + (None, Some((shape_instance_id, curve_index, point_index))) + } + _ => (None, None), + }; + + for instance in &layer.shape_instances { + let shape = match layer.get_shape(&instance.shape_id) { + Some(s) => s, + None => continue, + }; + + let local_to_world = instance.to_affine(); + let editable = extract_editable_curves(shape.path()); + + // Determine active element from tool state (being dragged) + let (active_vertex, active_control_point) = match &*shared.tool_state { + ToolState::EditingVertex { shape_id, vertex_index, .. } if *shape_id == instance.shape_id => { + (Some(*vertex_index), None) + } + ToolState::EditingControlPoint { shape_id, curve_index, point_index, .. } + if *shape_id == instance.shape_id => { + (None, Some((*curve_index, *point_index))) + } + _ => (None, None), + }; + + // Render all vertices + for (i, vertex) in editable.vertices.iter().enumerate() { + let world_pos = local_to_world * vertex.point; + let screen_pos = world_to_screen(world_pos); + let vertex_size = 10.0; + + let rect = egui::Rect::from_center_size( + screen_pos, + egui::vec2(vertex_size, vertex_size), + ); + + // Determine color: orange if active (dragging), yellow if hover, black otherwise + let (fill_color, stroke_width) = if Some(i) == active_vertex { + (egui::Color32::from_rgb(255, 200, 0), 2.0) // Orange if being dragged + } else if hover_vertex == Some((instance.id, i)) { + (egui::Color32::from_rgb(255, 255, 100), 2.0) // Yellow if hovering + } else { + (egui::Color32::from_rgba_premultiplied(0, 0, 0, 170), 1.0) + }; + + painter.rect_filled(rect, 0.0, fill_color); + painter.rect_stroke( + rect, + 0.0, + egui::Stroke::new(stroke_width, egui::Color32::WHITE), + egui::StrokeKind::Middle, + ); + } + + // Render all control points + for (i, curve) in editable.curves.iter().enumerate() { + let p0_world = local_to_world * curve.p0; + let p1_world = local_to_world * curve.p1; + let p2_world = local_to_world * curve.p2; + let p3_world = local_to_world * curve.p3; + + let p0_screen = world_to_screen(p0_world); + let p1_screen = world_to_screen(p1_world); + let p2_screen = world_to_screen(p2_world); + let p3_screen = world_to_screen(p3_world); + + // Draw handle lines + painter.line_segment( + [p0_screen, p1_screen], + egui::Stroke::new(1.0, egui::Color32::from_rgb(100, 100, 255)), + ); + painter.line_segment( + [p2_screen, p3_screen], + egui::Stroke::new(1.0, egui::Color32::from_rgb(100, 100, 255)), + ); + + let radius = 6.0; + + // p1 control point + let (p1_fill, p1_stroke_width) = if active_control_point == Some((i, 1)) { + (egui::Color32::from_rgb(255, 150, 0), 2.0) // Orange if being dragged + } else if hover_control_point == Some((instance.id, i, 1)) { + (egui::Color32::from_rgb(150, 150, 255), 2.0) // Lighter blue if hovering + } else { + (egui::Color32::from_rgb(100, 100, 255), 1.0) + }; + painter.circle_filled(p1_screen, radius, p1_fill); + painter.circle_stroke(p1_screen, radius, egui::Stroke::new(p1_stroke_width, egui::Color32::WHITE)); + + // p2 control point + let (p2_fill, p2_stroke_width) = if active_control_point == Some((i, 2)) { + (egui::Color32::from_rgb(255, 150, 0), 2.0) // Orange if being dragged + } else if hover_control_point == Some((instance.id, i, 2)) { + (egui::Color32::from_rgb(150, 150, 255), 2.0) // Lighter blue if hovering + } else { + (egui::Color32::from_rgb(100, 100, 255), 1.0) + }; + painter.circle_filled(p2_screen, radius, p2_fill); + painter.circle_stroke(p2_screen, radius, egui::Stroke::new(p2_stroke_width, egui::Color32::WHITE)); + } + } + } else { + // Select mode: Only show hover highlights based on hit testing + if let Some(hit_result) = hit { + match hit_result { + VectorEditHit::Vertex { shape_instance_id, vertex_index } => { + // Highlight the vertex under the mouse + if let Some(instance) = layer.shape_instances.iter().find(|i| i.id == shape_instance_id) { + if let Some(shape) = layer.get_shape(&instance.shape_id) { + let local_to_world = instance.to_affine(); + let editable = extract_editable_curves(shape.path()); + + if vertex_index < editable.vertices.len() { + let vertex = &editable.vertices[vertex_index]; + let world_pos = local_to_world * vertex.point; + let screen_pos = world_to_screen(world_pos); + let vertex_size = 10.0; + + let rect = egui::Rect::from_center_size( + screen_pos, + egui::vec2(vertex_size, vertex_size), + ); + + painter.rect_filled(rect, 0.0, egui::Color32::from_rgb(255, 200, 0)); + painter.rect_stroke( + rect, + 0.0, + egui::Stroke::new(2.0, egui::Color32::WHITE), + egui::StrokeKind::Middle, + ); + } + } + } + } + VectorEditHit::Curve { shape_instance_id, curve_index, .. } => { + // Highlight the curve under the mouse + if let Some(instance) = layer.shape_instances.iter().find(|i| i.id == shape_instance_id) { + if let Some(shape) = layer.get_shape(&instance.shape_id) { + let local_to_world = instance.to_affine(); + let editable = extract_editable_curves(shape.path()); + + if curve_index < editable.curves.len() { + let curve = &editable.curves[curve_index]; + let num_samples = 20; + + for j in 0..num_samples { + let t1 = j as f64 / num_samples as f64; + let t2 = (j + 1) as f64 / num_samples as f64; + + use vello::kurbo::ParamCurve; + let p1_local = curve.eval(t1); + let p2_local = curve.eval(t2); + + let p1_world = local_to_world * p1_local; + let p2_world = local_to_world * p2_local; + + let p1_screen = world_to_screen(p1_world); + let p2_screen = world_to_screen(p2_world); + + painter.line_segment( + [p1_screen, p2_screen], + egui::Stroke::new(3.0, egui::Color32::from_rgb(255, 0, 255)), + ); + } + } + } + } + } + _ => {} + } + } + } + } } + + impl PaneRenderer for StagePane { fn render_header(&mut self, ui: &mut egui::Ui, shared: &mut SharedPaneState) -> bool { ui.horizontal(|ui| { @@ -5407,6 +6242,9 @@ impl PaneRenderer for StagePane { egui::FontId::proportional(14.0), egui::Color32::from_gray(200), ); + + // Render vector editing overlays (vertices, control points, etc.) + self.render_vector_editing_overlays(ui, rect, shared); } fn name(&self) -> &str {