From 9204308033b859b30426dbb3148ea48c1cd255b1 Mon Sep 17 00:00:00 2001 From: Skyler Lehmkuhl Date: Tue, 18 Nov 2025 05:08:33 -0500 Subject: [PATCH] Transform shapes --- lightningbeam-ui/Cargo.lock | 1 + .../lightningbeam-core/Cargo.toml | 3 + .../lightningbeam-core/src/action.rs | 7 + .../src/actions/add_shape.rs | 191 ++ .../lightningbeam-core/src/actions/mod.rs | 4 + .../src/actions/transform_objects.rs | 60 + .../lightningbeam-core/src/document.rs | 2 +- .../lightningbeam-core/src/layer.rs | 2 +- .../lightningbeam-core/src/lib.rs | 1 + .../lightningbeam-core/src/path_fitting.rs | 613 ++++++ .../lightningbeam-core/src/shape.rs | 10 + .../lightningbeam-core/src/tool.rs | 15 +- .../assets/com.lightningbeam.editor.desktop | 10 + .../lightningbeam-editor/src/main.rs | 86 +- .../lightningbeam-editor/src/panes/mod.rs | 9 +- .../lightningbeam-editor/src/panes/stage.rs | 1782 ++++++++++++++++- 16 files changed, 2771 insertions(+), 25 deletions(-) create mode 100644 lightningbeam-ui/lightningbeam-core/src/actions/add_shape.rs create mode 100644 lightningbeam-ui/lightningbeam-core/src/actions/transform_objects.rs create mode 100644 lightningbeam-ui/lightningbeam-core/src/path_fitting.rs create mode 100644 lightningbeam-ui/lightningbeam-editor/assets/com.lightningbeam.editor.desktop diff --git a/lightningbeam-ui/Cargo.lock b/lightningbeam-ui/Cargo.lock index c611a23..6e2d885 100644 --- a/lightningbeam-ui/Cargo.lock +++ b/lightningbeam-ui/Cargo.lock @@ -2737,6 +2737,7 @@ dependencies = [ name = "lightningbeam-core" version = "0.1.0" dependencies = [ + "egui", "kurbo 0.11.3", "serde", "serde_json", diff --git a/lightningbeam-ui/lightningbeam-core/Cargo.toml b/lightningbeam-ui/lightningbeam-core/Cargo.toml index eb61404..cdff2b1 100644 --- a/lightningbeam-ui/lightningbeam-core/Cargo.toml +++ b/lightningbeam-ui/lightningbeam-core/Cargo.toml @@ -7,6 +7,9 @@ edition = "2021" serde = { workspace = true } serde_json = { workspace = true } +# UI framework (for Color32 conversion) +egui = "0.29" + # Geometry and rendering kurbo = { workspace = true } vello = { workspace = true } diff --git a/lightningbeam-ui/lightningbeam-core/src/action.rs b/lightningbeam-ui/lightningbeam-core/src/action.rs index d597006..b317adc 100644 --- a/lightningbeam-ui/lightningbeam-core/src/action.rs +++ b/lightningbeam-ui/lightningbeam-core/src/action.rs @@ -64,6 +64,13 @@ impl ActionExecutor { &self.document } + /// Get mutable access to the document + /// Note: This should only be used for live previews. Permanent changes + /// should go through the execute() method to support undo/redo. + pub fn document_mut(&mut self) -> &mut Document { + &mut self.document + } + /// Execute an action and add it to the undo stack /// /// This clears the redo stack since we're creating a new timeline branch. diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/add_shape.rs b/lightningbeam-ui/lightningbeam-core/src/actions/add_shape.rs new file mode 100644 index 0000000..0898c6a --- /dev/null +++ b/lightningbeam-ui/lightningbeam-core/src/actions/add_shape.rs @@ -0,0 +1,191 @@ +//! Add shape action +//! +//! Handles adding a new shape and object to a vector layer. + +use crate::action::Action; +use crate::document::Document; +use crate::layer::AnyLayer; +use crate::object::Object; +use crate::shape::Shape; +use uuid::Uuid; + +/// Action that adds a shape and object to a vector layer +/// +/// This action creates both a Shape (the path/geometry) and an Object +/// (the instance with transform). Both are added to the layer. +pub struct AddShapeAction { + /// Layer ID to add the shape to + layer_id: Uuid, + + /// The shape to add (contains path and styling) + shape: Shape, + + /// The object to add (references the shape with transform) + object: Object, + + /// ID of the created shape (set after execution) + created_shape_id: Option, + + /// ID of the created object (set after execution) + created_object_id: Option, +} + +impl AddShapeAction { + /// Create a new add shape action + /// + /// # Arguments + /// + /// * `layer_id` - The layer to add the shape to + /// * `shape` - The shape to add + /// * `object` - The object instance referencing the shape + pub fn new(layer_id: Uuid, shape: Shape, object: Object) -> Self { + Self { + layer_id, + shape, + object, + created_shape_id: None, + created_object_id: None, + } + } +} + +impl Action for AddShapeAction { + fn execute(&mut self, document: &mut Document) { + let layer = match document.get_layer_mut(&self.layer_id) { + Some(l) => l, + None => return, + }; + + if let AnyLayer::Vector(vector_layer) = layer { + // Add shape and object to the layer + let shape_id = vector_layer.add_shape_internal(self.shape.clone()); + let object_id = vector_layer.add_object_internal(self.object.clone()); + + // Store the IDs for rollback + self.created_shape_id = Some(shape_id); + self.created_object_id = Some(object_id); + } + } + + fn rollback(&mut self, document: &mut Document) { + // Remove the created shape and object if they exist + if let (Some(shape_id), Some(object_id)) = (self.created_shape_id, self.created_object_id) { + let layer = match document.get_layer_mut(&self.layer_id) { + Some(l) => l, + None => return, + }; + + if let AnyLayer::Vector(vector_layer) = layer { + // Remove in reverse order: object first, then shape + vector_layer.remove_object_internal(&object_id); + vector_layer.remove_shape_internal(&shape_id); + } + + // Clear the stored IDs + self.created_shape_id = None; + self.created_object_id = None; + } + } + + fn description(&self) -> String { + "Add shape".to_string() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::layer::VectorLayer; + use crate::shape::ShapeColor; + use vello::kurbo::{Circle, Rect, Shape as KurboShape}; + + #[test] + fn test_add_shape_action_rectangle() { + // Create a document with a vector layer + let mut document = Document::new("Test"); + let vector_layer = VectorLayer::new("Layer 1"); + let layer_id = document.root.add_child(AnyLayer::Vector(vector_layer)); + + // Create a rectangle shape + let rect = Rect::new(0.0, 0.0, 100.0, 50.0); + let path = rect.to_path(0.1); + let shape = Shape::new(path).with_fill(ShapeColor::rgb(255, 0, 0)); + let object = Object::new(shape.id).with_position(50.0, 50.0); + + // Create and execute action + let mut action = AddShapeAction::new(layer_id, shape, object); + action.execute(&mut document); + + // Verify shape and object were added + if let Some(AnyLayer::Vector(layer)) = document.get_layer(&layer_id) { + assert_eq!(layer.shapes.len(), 1); + assert_eq!(layer.objects.len(), 1); + + let added_object = &layer.objects[0]; + assert_eq!(added_object.transform.x, 50.0); + assert_eq!(added_object.transform.y, 50.0); + } else { + panic!("Layer not found or not a vector layer"); + } + + // Rollback + action.rollback(&mut document); + + // Verify shape and object were removed + if let Some(AnyLayer::Vector(layer)) = document.get_layer(&layer_id) { + assert_eq!(layer.shapes.len(), 0); + assert_eq!(layer.objects.len(), 0); + } + } + + #[test] + fn test_add_shape_action_circle() { + let mut document = Document::new("Test"); + let vector_layer = VectorLayer::new("Layer 1"); + let layer_id = document.root.add_child(AnyLayer::Vector(vector_layer)); + + // Create a circle shape + let circle = Circle::new((50.0, 50.0), 25.0); + let path = circle.to_path(0.1); + let shape = Shape::new(path) + .with_fill(ShapeColor::rgb(0, 255, 0)); + let object = Object::new(shape.id); + + let mut action = AddShapeAction::new(layer_id, shape, object); + + // Test description + assert_eq!(action.description(), "Add shape"); + + // Execute + action.execute(&mut document); + + if let Some(AnyLayer::Vector(layer)) = document.get_layer(&layer_id) { + assert_eq!(layer.shapes.len(), 1); + assert_eq!(layer.objects.len(), 1); + } + } + + #[test] + fn test_add_shape_action_multiple_execute() { + let mut document = Document::new("Test"); + let vector_layer = VectorLayer::new("Layer 1"); + let layer_id = document.root.add_child(AnyLayer::Vector(vector_layer)); + + let rect = Rect::new(0.0, 0.0, 50.0, 50.0); + let path = rect.to_path(0.1); + let shape = Shape::new(path); + let object = Object::new(shape.id); + + let mut action = AddShapeAction::new(layer_id, shape, object); + + // Execute twice (should add duplicate) + action.execute(&mut document); + action.execute(&mut document); + + if let Some(AnyLayer::Vector(layer)) = document.get_layer(&layer_id) { + // Should have 2 shapes and 2 objects + assert_eq!(layer.shapes.len(), 2); + assert_eq!(layer.objects.len(), 2); + } + } +} diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/mod.rs b/lightningbeam-ui/lightningbeam-core/src/actions/mod.rs index dc1fcb1..db1d722 100644 --- a/lightningbeam-ui/lightningbeam-core/src/actions/mod.rs +++ b/lightningbeam-ui/lightningbeam-core/src/actions/mod.rs @@ -3,6 +3,10 @@ //! This module contains all the concrete action types that can be executed //! through the action system. +pub mod add_shape; pub mod move_objects; +pub mod transform_objects; +pub use add_shape::AddShapeAction; pub use move_objects::MoveObjectsAction; +pub use transform_objects::TransformObjectsAction; diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/transform_objects.rs b/lightningbeam-ui/lightningbeam-core/src/actions/transform_objects.rs new file mode 100644 index 0000000..f774345 --- /dev/null +++ b/lightningbeam-ui/lightningbeam-core/src/actions/transform_objects.rs @@ -0,0 +1,60 @@ +//! Transform objects action +//! +//! Applies scale, rotation, and other transformations to objects with undo/redo support. + +use crate::action::Action; +use crate::document::Document; +use crate::layer::AnyLayer; +use crate::object::Transform; +use std::collections::HashMap; +use uuid::Uuid; + +/// Action to transform multiple objects +pub struct TransformObjectsAction { + layer_id: Uuid, + /// Map of object ID to (old transform, new transform) + object_transforms: HashMap, +} + +impl TransformObjectsAction { + /// Create a new transform action + pub fn new( + layer_id: Uuid, + object_transforms: HashMap, + ) -> Self { + Self { + layer_id, + object_transforms, + } + } +} + +impl Action for TransformObjectsAction { + fn execute(&mut self, document: &mut Document) { + if let Some(layer) = document.get_layer_mut(&self.layer_id) { + if let AnyLayer::Vector(vector_layer) = layer { + for (object_id, (_old, new)) in &self.object_transforms { + vector_layer.modify_object_internal(object_id, |obj| { + obj.transform = new.clone(); + }); + } + } + } + } + + fn rollback(&mut self, document: &mut Document) { + if let Some(layer) = document.get_layer_mut(&self.layer_id) { + if let AnyLayer::Vector(vector_layer) = layer { + for (object_id, (old, _new)) in &self.object_transforms { + vector_layer.modify_object_internal(object_id, |obj| { + obj.transform = old.clone(); + }); + } + } + } + } + + fn description(&self) -> String { + format!("Transform {} object(s)", self.object_transforms.len()) + } +} diff --git a/lightningbeam-ui/lightningbeam-core/src/document.rs b/lightningbeam-ui/lightningbeam-core/src/document.rs index 12aa14f..e161ffc 100644 --- a/lightningbeam-ui/lightningbeam-core/src/document.rs +++ b/lightningbeam-ui/lightningbeam-core/src/document.rs @@ -189,7 +189,7 @@ impl Document { /// /// This method is intentionally `pub(crate)` to ensure mutations /// only happen through the action system. - pub(crate) fn get_layer_mut(&mut self, id: &Uuid) -> Option<&mut AnyLayer> { + pub fn get_layer_mut(&mut self, id: &Uuid) -> Option<&mut AnyLayer> { self.root.get_child_mut(id) } } diff --git a/lightningbeam-ui/lightningbeam-core/src/layer.rs b/lightningbeam-ui/lightningbeam-core/src/layer.rs index cfd9744..034e969 100644 --- a/lightningbeam-ui/lightningbeam-core/src/layer.rs +++ b/lightningbeam-ui/lightningbeam-core/src/layer.rs @@ -212,7 +212,7 @@ impl VectorLayer { /// Applies the given function to the object if found. /// This method is intentionally `pub(crate)` to ensure mutations /// only happen through the action system. - pub(crate) fn modify_object_internal(&mut self, id: &Uuid, f: F) + pub fn modify_object_internal(&mut self, id: &Uuid, f: F) where F: FnOnce(&mut Object), { diff --git a/lightningbeam-ui/lightningbeam-core/src/lib.rs b/lightningbeam-ui/lightningbeam-core/src/lib.rs index 7115301..dacd2f5 100644 --- a/lightningbeam-ui/lightningbeam-core/src/lib.rs +++ b/lightningbeam-ui/lightningbeam-core/src/lib.rs @@ -6,6 +6,7 @@ pub mod pane; pub mod tool; pub mod animation; pub mod path_interpolation; +pub mod path_fitting; pub mod shape; pub mod object; pub mod layer; diff --git a/lightningbeam-ui/lightningbeam-core/src/path_fitting.rs b/lightningbeam-ui/lightningbeam-core/src/path_fitting.rs new file mode 100644 index 0000000..9b7f998 --- /dev/null +++ b/lightningbeam-ui/lightningbeam-core/src/path_fitting.rs @@ -0,0 +1,613 @@ +//! Path fitting algorithms for converting raw points to smooth curves +//! +//! Provides two main algorithms: +//! - Ramer-Douglas-Peucker (RDP) simplification for corner detection +//! - Schneider curve fitting for smooth Bezier curves +//! +//! Based on: +//! - simplify.js by Vladimir Agafonkin +//! - fit-curve by Philip J. Schneider (Graphics Gems, 1990) + +use kurbo::{BezPath, Point, Vec2}; + +/// Configuration for RDP simplification +#[derive(Debug, Clone, Copy)] +pub struct RdpConfig { + /// Tolerance for simplification (default: 10.0) + /// Higher values = more simplification (fewer points) + pub tolerance: f64, + /// Whether to use highest quality (skip radial distance filter) + pub highest_quality: bool, +} + +impl Default for RdpConfig { + fn default() -> Self { + Self { + tolerance: 10.0, + highest_quality: false, + } + } +} + +/// Configuration for Schneider curve fitting +#[derive(Debug, Clone, Copy)] +pub struct SchneiderConfig { + /// Maximum error tolerance (default: 30.0) + /// Lower values = more accurate curves (more segments) + pub max_error: f64, +} + +impl Default for SchneiderConfig { + fn default() -> Self { + Self { max_error: 30.0 } + } +} + +/// Simplify a polyline using Ramer-Douglas-Peucker algorithm +/// +/// This is a two-stage process: +/// 1. Radial distance filter (unless highest_quality is true) +/// 2. Douglas-Peucker recursive simplification +pub fn simplify_rdp(points: &[Point], config: RdpConfig) -> Vec { + if points.len() <= 2 { + return points.to_vec(); + } + + let sq_tolerance = config.tolerance * config.tolerance; + + let mut simplified = if config.highest_quality { + points.to_vec() + } else { + simplify_radial_dist(points, sq_tolerance) + }; + + simplified = simplify_douglas_peucker(&simplified, sq_tolerance); + + simplified +} + +/// First stage: Remove points that are too close to the previous point +fn simplify_radial_dist(points: &[Point], sq_tolerance: f64) -> Vec { + if points.is_empty() { + return Vec::new(); + } + + let mut result = vec![points[0]]; + let mut prev_point = points[0]; + + for &point in &points[1..] { + if sq_dist(point, prev_point) > sq_tolerance { + result.push(point); + prev_point = point; + } + } + + // Always include the last point if it's different from the previous one + if let Some(&last) = points.last() { + if last != prev_point { + result.push(last); + } + } + + result +} + +/// Second stage: Douglas-Peucker recursive simplification +fn simplify_douglas_peucker(points: &[Point], sq_tolerance: f64) -> Vec { + if points.len() < 2 { + return points.to_vec(); + } + + let last = points.len() - 1; + let mut simplified = vec![points[0]]; + simplify_dp_step(points, 0, last, sq_tolerance, &mut simplified); + simplified.push(points[last]); + + simplified +} + +/// Recursive Douglas-Peucker step +fn simplify_dp_step( + points: &[Point], + first: usize, + last: usize, + sq_tolerance: f64, + simplified: &mut Vec, +) { + let mut max_sq_dist = sq_tolerance; + let mut index = 0; + + for i in first + 1..last { + let sq_dist = sq_seg_dist(points[i], points[first], points[last]); + + if sq_dist > max_sq_dist { + index = i; + max_sq_dist = sq_dist; + } + } + + if max_sq_dist > sq_tolerance { + if index - first > 1 { + simplify_dp_step(points, first, index, sq_tolerance, simplified); + } + simplified.push(points[index]); + if last - index > 1 { + simplify_dp_step(points, index, last, sq_tolerance, simplified); + } + } +} + +/// Square distance between two points +#[inline] +fn sq_dist(p1: Point, p2: Point) -> f64 { + let dx = p1.x - p2.x; + let dy = p1.y - p2.y; + dx * dx + dy * dy +} + +/// Square distance from a point to a line segment +fn sq_seg_dist(p: Point, p1: Point, p2: Point) -> f64 { + let mut x = p1.x; + let mut y = p1.y; + let dx = p2.x - x; + let dy = p2.y - y; + + if dx != 0.0 || dy != 0.0 { + let t = ((p.x - x) * dx + (p.y - y) * dy) / (dx * dx + dy * dy); + + if t > 1.0 { + x = p2.x; + y = p2.y; + } else if t > 0.0 { + x += dx * t; + y += dy * t; + } + } + + let dx = p.x - x; + let dy = p.y - y; + dx * dx + dy * dy +} + +/// Fit Bezier curves to a set of points using Schneider's algorithm +/// +/// Returns a BezPath containing the fitted cubic Bezier curves +pub fn fit_bezier_curves(points: &[Point], config: SchneiderConfig) -> BezPath { + if points.len() < 2 { + return BezPath::new(); + } + + // Remove duplicate points + let mut unique_points = Vec::new(); + unique_points.push(points[0]); + for i in 1..points.len() { + if points[i] != points[i - 1] { + unique_points.push(points[i]); + } + } + + if unique_points.len() < 2 { + return BezPath::new(); + } + + let len = unique_points.len(); + let left_tangent = create_tangent(unique_points[1], unique_points[0]); + let right_tangent = create_tangent(unique_points[len - 2], unique_points[len - 1]); + + let curves = fit_cubic(&unique_points, left_tangent, right_tangent, config.max_error); + + // Convert curves to BezPath + let mut path = BezPath::new(); + if curves.is_empty() { + return path; + } + + // Start at the first point + path.move_to(curves[0][0]); + + // Add all the curves + for curve in curves { + path.curve_to(curve[1], curve[2], curve[3]); + } + + path +} + +/// Fit a cubic Bezier curve to a set of points +/// +/// Returns an array of Bezier curves, where each curve is [p0, p1, p2, p3] +fn fit_cubic( + points: &[Point], + left_tangent: Vec2, + right_tangent: Vec2, + error: f64, +) -> Vec<[Point; 4]> { + const MAX_ITERATIONS: usize = 20; + + // Use heuristic if region only has two points + if points.len() == 2 { + let dist = (points[1] - points[0]).hypot() / 3.0; + let bez_curve = [ + points[0], + points[0] + left_tangent * dist, + points[1] + right_tangent * dist, + points[1], + ]; + return vec![bez_curve]; + } + + // Parameterize points and attempt to fit curve + let u = chord_length_parameterize(points); + let (mut bez_curve, mut max_error, mut split_point) = + generate_and_report(points, &u, &u, left_tangent, right_tangent); + + if max_error < error { + return vec![bez_curve]; + } + + // If error not too large, try reparameterization and iteration + if max_error < error * error { + let mut u_prime = u.clone(); + let mut prev_err = max_error; + let mut prev_split = split_point; + + for _ in 0..MAX_ITERATIONS { + u_prime = reparameterize(&bez_curve, points, &u_prime); + + let result = generate_and_report(points, &u, &u_prime, left_tangent, right_tangent); + bez_curve = result.0; + max_error = result.1; + split_point = result.2; + + if max_error < error { + return vec![bez_curve]; + } + + // If development grinds to a halt, abort + if split_point == prev_split { + let err_change = max_error / prev_err; + if err_change > 0.9999 && err_change < 1.0001 { + break; + } + } + + prev_err = max_error; + prev_split = split_point; + } + } + + // Fitting failed -- split at max error point and fit recursively + let mut beziers = Vec::new(); + + // Calculate tangent at split point + let mut center_vector = points[split_point - 1] - points[split_point + 1]; + + // Handle case where points are the same + if center_vector.hypot() == 0.0 { + center_vector = points[split_point - 1] - points[split_point]; + center_vector = Vec2::new(-center_vector.y, center_vector.x); + } + + let to_center_tangent = normalize(center_vector); + let from_center_tangent = -to_center_tangent; + + // Recursively fit curves + beziers.extend(fit_cubic( + &points[0..=split_point], + left_tangent, + to_center_tangent, + error, + )); + beziers.extend(fit_cubic( + &points[split_point..], + from_center_tangent, + right_tangent, + error, + )); + + beziers +} + +/// Generate a Bezier curve and compute its error +fn generate_and_report( + points: &[Point], + params_orig: &[f64], + params_prime: &[f64], + left_tangent: Vec2, + right_tangent: Vec2, +) -> ([Point; 4], f64, usize) { + let bez_curve = generate_bezier(points, params_prime, left_tangent, right_tangent); + let (max_error, split_point) = compute_max_error(points, &bez_curve, params_orig); + + (bez_curve, max_error, split_point) +} + +/// Use least-squares method to find Bezier control points +fn generate_bezier( + points: &[Point], + parameters: &[f64], + left_tangent: Vec2, + right_tangent: Vec2, +) -> [Point; 4] { + let first_point = points[0]; + let last_point = points[points.len() - 1]; + + // Compute the A matrix + let mut a = Vec::new(); + for &u in parameters { + let ux = 1.0 - u; + let a0 = left_tangent * (3.0 * u * ux * ux); + let a1 = right_tangent * (3.0 * ux * u * u); + a.push([a0, a1]); + } + + // Create C and X matrices + let mut c = [[0.0, 0.0], [0.0, 0.0]]; + let mut x = [0.0, 0.0]; + + for i in 0..points.len() { + let u = parameters[i]; + let ai = a[i]; + + c[0][0] += dot(ai[0], ai[0]); + c[0][1] += dot(ai[0], ai[1]); + c[1][0] += dot(ai[0], ai[1]); + c[1][1] += dot(ai[1], ai[1]); + + let tmp = points[i] - bezier_q(&[first_point, first_point, last_point, last_point], u); + + x[0] += dot(ai[0], tmp); + x[1] += dot(ai[1], tmp); + } + + // Compute determinants + let det_c0_c1 = c[0][0] * c[1][1] - c[1][0] * c[0][1]; + let det_c0_x = c[0][0] * x[1] - c[1][0] * x[0]; + let det_x_c1 = x[0] * c[1][1] - x[1] * c[0][1]; + + // Derive alpha values + let alpha_l = if det_c0_c1 == 0.0 { + 0.0 + } else { + det_x_c1 / det_c0_c1 + }; + let alpha_r = if det_c0_c1 == 0.0 { + 0.0 + } else { + det_c0_x / det_c0_c1 + }; + + // If alpha is negative or too small, use heuristic + let seg_length = (last_point - first_point).hypot(); + let epsilon = 1.0e-6 * seg_length; + + let (p1, p2) = if alpha_l < epsilon || alpha_r < epsilon { + // Fall back on standard formula + ( + first_point + left_tangent * (seg_length / 3.0), + last_point + right_tangent * (seg_length / 3.0), + ) + } else { + ( + first_point + left_tangent * alpha_l, + last_point + right_tangent * alpha_r, + ) + }; + + [first_point, p1, p2, last_point] +} + +/// Reparameterize points using Newton-Raphson +fn reparameterize(bezier: &[Point; 4], points: &[Point], parameters: &[f64]) -> Vec { + parameters + .iter() + .zip(points.iter()) + .map(|(&p, &point)| newton_raphson_root_find(bezier, point, p)) + .collect() +} + +/// Use Newton-Raphson iteration to find better root +fn newton_raphson_root_find(bez: &[Point; 4], point: Point, u: f64) -> f64 { + let d = bezier_q(bez, u) - point; + let qprime = bezier_qprime(bez, u); + let numerator = dot(d, qprime); + let qprimeprime = bezier_qprimeprime(bez, u); + let denominator = dot(qprime, qprime) + 2.0 * dot(d, qprimeprime); + + if denominator == 0.0 { + u + } else { + u - numerator / denominator + } +} + +/// Assign parameter values using chord length +fn chord_length_parameterize(points: &[Point]) -> Vec { + let mut u = Vec::new(); + let mut curr_u = 0.0; + + u.push(0.0); + + for i in 1..points.len() { + curr_u += (points[i] - points[i - 1]).hypot(); + u.push(curr_u); + } + + let total_length = u[u.len() - 1]; + u.iter().map(|&x| x / total_length).collect() +} + +/// Find maximum squared distance of points to fitted curve +fn compute_max_error(points: &[Point], bez: &[Point; 4], parameters: &[f64]) -> (f64, usize) { + let mut max_dist = 0.0; + let mut split_point = points.len() / 2; + + let t_dist_map = map_t_to_relative_distances(bez, 10); + + for i in 0..points.len() { + let point = points[i]; + let t = find_t(bez, parameters[i], &t_dist_map, 10); + + let v = bezier_q(bez, t) - point; + let dist = v.x * v.x + v.y * v.y; + + if dist > max_dist { + max_dist = dist; + split_point = i; + } + } + + (max_dist, split_point) +} + +/// Sample t values and map to relative distances along curve +fn map_t_to_relative_distances(bez: &[Point; 4], b_parts: usize) -> Vec { + let mut b_t_dist = vec![0.0]; + let mut b_t_prev = bez[0]; + let mut sum_len = 0.0; + + for i in 1..=b_parts { + let b_t_curr = bezier_q(bez, i as f64 / b_parts as f64); + sum_len += (b_t_curr - b_t_prev).hypot(); + b_t_dist.push(sum_len); + b_t_prev = b_t_curr; + } + + // Normalize to 0..1 + b_t_dist.iter().map(|&x| x / sum_len).collect() +} + +/// Find t value for a given parameter distance +fn find_t(bez: &[Point; 4], param: f64, t_dist_map: &[f64], b_parts: usize) -> f64 { + if param < 0.0 { + return 0.0; + } + if param > 1.0 { + return 1.0; + } + + for i in 1..=b_parts { + if param <= t_dist_map[i] { + let t_min = (i - 1) as f64 / b_parts as f64; + let t_max = i as f64 / b_parts as f64; + let len_min = t_dist_map[i - 1]; + let len_max = t_dist_map[i]; + + let t = (param - len_min) / (len_max - len_min) * (t_max - t_min) + t_min; + return t; + } + } + + 1.0 +} + +/// Evaluate cubic Bezier at parameter t +fn bezier_q(ctrl_poly: &[Point; 4], t: f64) -> Point { + let tx = 1.0 - t; + let p_a = ctrl_poly[0].to_vec2() * (tx * tx * tx); + let p_b = ctrl_poly[1].to_vec2() * (3.0 * tx * tx * t); + let p_c = ctrl_poly[2].to_vec2() * (3.0 * tx * t * t); + let p_d = ctrl_poly[3].to_vec2() * (t * t * t); + + (p_a + p_b + p_c + p_d).to_point() +} + +/// Evaluate first derivative of cubic Bezier at parameter t +fn bezier_qprime(ctrl_poly: &[Point; 4], t: f64) -> Vec2 { + let tx = 1.0 - t; + let p_a = (ctrl_poly[1] - ctrl_poly[0]) * (3.0 * tx * tx); + let p_b = (ctrl_poly[2] - ctrl_poly[1]) * (6.0 * tx * t); + let p_c = (ctrl_poly[3] - ctrl_poly[2]) * (3.0 * t * t); + + p_a + p_b + p_c +} + +/// Evaluate second derivative of cubic Bezier at parameter t +fn bezier_qprimeprime(ctrl_poly: &[Point; 4], t: f64) -> Vec2 { + let v0 = ctrl_poly[2].to_vec2() - ctrl_poly[1].to_vec2() * 2.0 + ctrl_poly[0].to_vec2(); + let v1 = ctrl_poly[3].to_vec2() - ctrl_poly[2].to_vec2() * 2.0 + ctrl_poly[1].to_vec2(); + v0 * (6.0 * (1.0 - t)) + v1 * (6.0 * t) +} + +/// Create a unit tangent vector from A to B +fn create_tangent(point_a: Point, point_b: Point) -> Vec2 { + normalize(point_a - point_b) +} + +/// Normalize a vector to unit length +fn normalize(v: Vec2) -> Vec2 { + let len = v.hypot(); + if len == 0.0 { + Vec2::ZERO + } else { + v / len + } +} + +/// Dot product of two vectors +fn dot(v1: Vec2, v2: Vec2) -> f64 { + v1.x * v2.x + v1.y * v2.y +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_rdp_simplification() { + let points = vec![ + Point::new(0.0, 0.0), + Point::new(1.0, 0.1), + Point::new(2.0, 0.0), + Point::new(3.0, 0.0), + Point::new(4.0, 0.0), + Point::new(5.0, 0.0), + ]; + + let config = RdpConfig { + tolerance: 0.5, + highest_quality: false, + }; + + let simplified = simplify_rdp(&points, config); + + // Should simplify the nearly-straight line + assert!(simplified.len() < points.len()); + assert_eq!(simplified[0], points[0]); + assert_eq!(simplified[simplified.len() - 1], points[points.len() - 1]); + } + + #[test] + fn test_schneider_curve_fitting() { + let points = vec![ + Point::new(0.0, 0.0), + Point::new(50.0, 100.0), + Point::new(100.0, 50.0), + Point::new(150.0, 100.0), + ]; + + let config = SchneiderConfig { max_error: 30.0 }; + + let path = fit_bezier_curves(&points, config); + + // Should create a valid BezPath + assert!(!path.is_empty()); + } + + #[test] + fn test_chord_length_parameterization() { + let points = vec![ + Point::new(0.0, 0.0), + Point::new(1.0, 0.0), + Point::new(2.0, 0.0), + ]; + + let params = chord_length_parameterize(&points); + + // Should start at 0 and end at 1 + assert_eq!(params[0], 0.0); + assert_eq!(params[params.len() - 1], 1.0); + // Should be evenly spaced for uniform spacing + assert!((params[1] - 0.5).abs() < 0.01); + } +} diff --git a/lightningbeam-ui/lightningbeam-core/src/shape.rs b/lightningbeam-ui/lightningbeam-core/src/shape.rs index 01380dd..c529b98 100644 --- a/lightningbeam-ui/lightningbeam-core/src/shape.rs +++ b/lightningbeam-ui/lightningbeam-core/src/shape.rs @@ -177,6 +177,16 @@ impl ShapeColor { pub fn to_brush(&self) -> Brush { Brush::Solid(self.to_peniko()) } + + /// Create from egui Color32 + pub fn from_egui(color: egui::Color32) -> Self { + Self { + r: color.r(), + g: color.g(), + b: color.b(), + a: color.a(), + } + } } impl Default for ShapeColor { diff --git a/lightningbeam-ui/lightningbeam-core/src/tool.rs b/lightningbeam-ui/lightningbeam-core/src/tool.rs index 6e6b2ae..e3170ff 100644 --- a/lightningbeam-ui/lightningbeam-core/src/tool.rs +++ b/lightningbeam-ui/lightningbeam-core/src/tool.rs @@ -54,14 +54,18 @@ pub enum ToolState { /// Creating a rectangle shape CreatingRectangle { - start_corner: Point, - current_corner: Point, + start_point: Point, // Starting point (corner or center depending on modifiers) + current_point: Point, // Current mouse position + centered: bool, // If true, start_point is center; if false, it's a corner + constrain_square: bool, // If true, constrain to square (equal width/height) }, /// Creating an ellipse shape CreatingEllipse { - center: Point, - current_point: Point, + start_point: Point, // Starting point (center or corner depending on modifiers) + current_point: Point, // Current mouse position + corner_mode: bool, // If true, start is corner; if false, start is center + constrain_circle: bool, // If true, constrain to circle (equal radii) }, /// Transforming selected objects (scale, rotate) @@ -69,6 +73,9 @@ pub enum ToolState { mode: TransformMode, original_transforms: HashMap, pivot: Point, + start_mouse: Point, // Mouse position when transform started + current_mouse: Point, // Current mouse position during drag + original_bbox: vello::kurbo::Rect, // Bounding box at start of transform (fixed) }, } diff --git a/lightningbeam-ui/lightningbeam-editor/assets/com.lightningbeam.editor.desktop b/lightningbeam-ui/lightningbeam-editor/assets/com.lightningbeam.editor.desktop new file mode 100644 index 0000000..534ab3e --- /dev/null +++ b/lightningbeam-ui/lightningbeam-editor/assets/com.lightningbeam.editor.desktop @@ -0,0 +1,10 @@ +[Desktop Entry] +Version=1.0 +Type=Application +Name=Lightningbeam Editor +Comment=Animation and video editing software +Exec=lightningbeam-editor +Icon=lightningbeam-editor +Terminal=false +Categories=Graphics;AudioVideo;Video; +StartupWMClass=lightningbeam-editor diff --git a/lightningbeam-ui/lightningbeam-editor/src/main.rs b/lightningbeam-ui/lightningbeam-editor/src/main.rs index f901f68..d857e40 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/main.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/main.rs @@ -68,10 +68,36 @@ fn main() -> eframe::Result { } } + // Load window icon + let icon_data = include_bytes!("../../../src-tauri/icons/icon.png"); + let icon_image = match image::load_from_memory(icon_data) { + Ok(img) => { + let rgba = img.to_rgba8(); + let (width, height) = (rgba.width(), rgba.height()); + println!("✅ Loaded window icon: {}x{}", width, height); + Some(egui::IconData { + rgba: rgba.into_raw(), + width, + height, + }) + } + Err(e) => { + eprintln!("❌ Failed to load window icon: {}", e); + None + } + }; + + let mut viewport_builder = egui::ViewportBuilder::default() + .with_inner_size([1920.0, 1080.0]) + .with_title("Lightningbeam Editor") + .with_app_id("lightningbeam-editor"); // Set app_id for Wayland + + if let Some(icon) = icon_image { + viewport_builder = viewport_builder.with_icon(icon); + } + let options = eframe::NativeOptions { - viewport: egui::ViewportBuilder::default() - .with_inner_size([1920.0, 1080.0]) - .with_title("Lightningbeam Editor"), + viewport: viewport_builder, ..Default::default() }; @@ -235,6 +261,10 @@ struct EditorApp { active_layer_id: Option, // Currently active layer for editing selection: lightningbeam_core::selection::Selection, // Current selection state tool_state: lightningbeam_core::tool::ToolState, // Current tool interaction state + // Draw tool configuration + draw_simplify_mode: lightningbeam_core::tool::SimplifyMode, // Current simplification mode for draw tool + rdp_tolerance: f64, // RDP simplification tolerance (default: 10.0) + schneider_max_error: f64, // Schneider curve fitting max error (default: 30.0) } impl EditorApp { @@ -289,6 +319,9 @@ impl EditorApp { active_layer_id: Some(layer_id), selection: lightningbeam_core::selection::Selection::new(), tool_state: lightningbeam_core::tool::ToolState::default(), + draw_simplify_mode: lightningbeam_core::tool::SimplifyMode::Smooth, // Default to smooth curves + rdp_tolerance: 10.0, // Default RDP tolerance + schneider_max_error: 30.0, // Default Schneider max error } } @@ -610,11 +643,14 @@ impl eframe::App for EditorApp { &mut fallback_pane_priority, &mut pending_handlers, &self.theme, - self.action_executor.document(), + &mut self.action_executor, &mut self.selection, &self.active_layer_id, &mut self.tool_state, &mut pending_actions, + &mut self.draw_simplify_mode, + &mut self.rdp_tolerance, + &mut self.schneider_max_error, ); // Execute action on the best handler (two-phase dispatch) @@ -697,15 +733,18 @@ fn render_layout_node( fallback_pane_priority: &mut Option, pending_handlers: &mut Vec, theme: &Theme, - document: &lightningbeam_core::document::Document, + action_executor: &mut lightningbeam_core::action::ActionExecutor, selection: &mut lightningbeam_core::selection::Selection, active_layer_id: &Option, tool_state: &mut lightningbeam_core::tool::ToolState, pending_actions: &mut Vec>, + draw_simplify_mode: &mut lightningbeam_core::tool::SimplifyMode, + rdp_tolerance: &mut f64, + schneider_max_error: &mut f64, ) { match node { LayoutNode::Pane { name } => { - render_pane(ui, name, rect, selected_pane, layout_action, split_preview_mode, icon_cache, tool_icon_cache, selected_tool, fill_color, stroke_color, pane_instances, path, pending_view_action, fallback_pane_priority, pending_handlers, theme, document, selection, active_layer_id, tool_state, pending_actions); + render_pane(ui, name, rect, selected_pane, layout_action, split_preview_mode, icon_cache, tool_icon_cache, selected_tool, fill_color, stroke_color, pane_instances, path, pending_view_action, fallback_pane_priority, pending_handlers, theme, action_executor, selection, active_layer_id, tool_state, pending_actions, draw_simplify_mode, rdp_tolerance, schneider_max_error); } LayoutNode::HorizontalGrid { percent, children } => { // Handle dragging @@ -749,11 +788,14 @@ fn render_layout_node( fallback_pane_priority, pending_handlers, theme, - document, + action_executor, selection, active_layer_id, tool_state, pending_actions, + draw_simplify_mode, + rdp_tolerance, + schneider_max_error, ); let mut right_path = path.clone(); @@ -778,11 +820,14 @@ fn render_layout_node( fallback_pane_priority, pending_handlers, theme, - document, + action_executor, selection, active_layer_id, tool_state, pending_actions, + draw_simplify_mode, + rdp_tolerance, + schneider_max_error, ); // Draw divider with interaction @@ -899,11 +944,14 @@ fn render_layout_node( fallback_pane_priority, pending_handlers, theme, - document, + action_executor, selection, active_layer_id, tool_state, pending_actions, + draw_simplify_mode, + rdp_tolerance, + schneider_max_error, ); let mut bottom_path = path.clone(); @@ -928,11 +976,14 @@ fn render_layout_node( fallback_pane_priority, pending_handlers, theme, - document, + action_executor, selection, active_layer_id, tool_state, pending_actions, + draw_simplify_mode, + rdp_tolerance, + schneider_max_error, ); // Draw divider with interaction @@ -1029,11 +1080,14 @@ fn render_pane( fallback_pane_priority: &mut Option, pending_handlers: &mut Vec, theme: &Theme, - document: &lightningbeam_core::document::Document, + action_executor: &mut lightningbeam_core::action::ActionExecutor, selection: &mut lightningbeam_core::selection::Selection, active_layer_id: &Option, tool_state: &mut lightningbeam_core::tool::ToolState, pending_actions: &mut Vec>, + draw_simplify_mode: &mut lightningbeam_core::tool::SimplifyMode, + rdp_tolerance: &mut f64, + schneider_max_error: &mut f64, ) { let pane_type = PaneType::from_name(pane_name); @@ -1208,11 +1262,14 @@ fn render_pane( fallback_pane_priority, theme, pending_handlers, - document, + action_executor, selection, active_layer_id, tool_state, pending_actions, + draw_simplify_mode, + rdp_tolerance, + schneider_max_error, }; pane_instance.render_header(&mut header_ui, &mut shared); } @@ -1248,11 +1305,14 @@ fn render_pane( fallback_pane_priority, theme, pending_handlers, - document, + action_executor, selection, active_layer_id, tool_state, pending_actions, + draw_simplify_mode, + rdp_tolerance, + schneider_max_error, }; // Render pane content (header was already rendered above) diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs index 9f960c9..96ac1d3 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs @@ -43,8 +43,9 @@ pub struct SharedPaneState<'a> { /// Registry of handlers for the current pending action /// Panes register themselves here during render, execution happens after pub pending_handlers: &'a mut Vec, - /// Active document being edited (read-only, mutations go through actions) - pub document: &'a lightningbeam_core::document::Document, + /// Action executor for immediate action execution (for shape tools to avoid flicker) + /// Also provides read-only access to the document via action_executor.document() + pub action_executor: &'a mut lightningbeam_core::action::ActionExecutor, /// Current selection state (mutable for tools to modify) pub selection: &'a mut lightningbeam_core::selection::Selection, /// Currently active layer ID @@ -53,6 +54,10 @@ pub struct SharedPaneState<'a> { pub tool_state: &'a mut lightningbeam_core::tool::ToolState, /// Actions to execute after rendering completes (two-phase dispatch) pub pending_actions: &'a mut Vec>, + /// Draw tool configuration + pub draw_simplify_mode: &'a mut lightningbeam_core::tool::SimplifyMode, + pub rdp_tolerance: &'a mut f64, + pub schneider_max_error: &'a mut f64, } /// Trait for pane rendering diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs index 4648cf1..b5cb61c 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs @@ -5,6 +5,7 @@ use eframe::egui; use super::{NodePath, PaneRenderer, SharedPaneState}; use std::sync::{Arc, Mutex}; +use vello::kurbo::Shape; /// Shared Vello resources (created once, reused by all Stage panes) struct SharedVelloResources { @@ -201,6 +202,9 @@ struct VelloCallback { active_layer_id: Option, drag_delta: Option, // Delta for drag preview (world space) selection: lightningbeam_core::selection::Selection, + fill_color: egui::Color32, // Current fill color for previews + stroke_color: egui::Color32, // Current stroke color for previews + selected_tool: lightningbeam_core::tool::Tool, // Current tool for rendering mode-specific UI } impl VelloCallback { @@ -214,8 +218,11 @@ impl VelloCallback { active_layer_id: Option, drag_delta: Option, selection: lightningbeam_core::selection::Selection, + fill_color: egui::Color32, + stroke_color: egui::Color32, + selected_tool: lightningbeam_core::tool::Tool, ) -> Self { - Self { rect, pan_offset, zoom, instance_id, document, tool_state, active_layer_id, drag_delta, selection } + Self { rect, pan_offset, zoom, instance_id, document, tool_state, active_layer_id, drag_delta, selection, fill_color, stroke_color, selected_tool } } } @@ -321,7 +328,8 @@ impl egui_wgpu::CallbackTrait for VelloCallback { let stroke_width = 2.0 / self.zoom.max(0.5) as f64; // 1. Draw selection outlines around selected objects - if !self.selection.is_empty() { + // NOTE: Skip this if Transform tool is active (it has its own handles) + if !self.selection.is_empty() && !matches!(self.selected_tool, Tool::Transform) { for &object_id in self.selection.objects() { if let Some(object) = vector_layer.get_object(&object_id) { if let Some(shape) = vector_layer.get_shape(&object.shape_id) { @@ -405,6 +413,413 @@ impl egui_wgpu::CallbackTrait for VelloCallback { &marquee_rect, ); } + + // 3. Draw rectangle creation preview + if let lightningbeam_core::tool::ToolState::CreatingRectangle { ref start_point, ref current_point, centered, constrain_square, .. } = self.tool_state { + use vello::kurbo::Point; + + // Calculate rectangle bounds based on mode (same logic as in handler) + let (width, height, position) = if centered { + let dx = current_point.x - start_point.x; + let dy = current_point.y - start_point.y; + + let (w, h) = if constrain_square { + let size = dx.abs().max(dy.abs()) * 2.0; + (size, size) + } else { + (dx.abs() * 2.0, dy.abs() * 2.0) + }; + + let pos = Point::new(start_point.x - w / 2.0, start_point.y - h / 2.0); + (w, h, pos) + } else { + let mut min_x = start_point.x.min(current_point.x); + let mut min_y = start_point.y.min(current_point.y); + let mut max_x = start_point.x.max(current_point.x); + let mut max_y = start_point.y.max(current_point.y); + + if constrain_square { + let width = max_x - min_x; + let height = max_y - min_y; + let size = width.max(height); + + if current_point.x > start_point.x { + max_x = min_x + size; + } else { + min_x = max_x - size; + } + + if current_point.y > start_point.y { + max_y = min_y + size; + } else { + min_y = max_y - size; + } + } + + (max_x - min_x, max_y - min_y, Point::new(min_x, min_y)) + }; + + if width > 0.0 && height > 0.0 { + let rect = KurboRect::new(0.0, 0.0, width, height); + let preview_transform = camera_transform * Affine::translate((position.x, position.y)); + + // Use actual fill color (same as final shape) + let fill_color = Color::rgba8( + self.fill_color.r(), + self.fill_color.g(), + self.fill_color.b(), + self.fill_color.a(), + ); + scene.fill( + Fill::NonZero, + preview_transform, + fill_color, + None, + &rect, + ); + } + } + + // 4. Draw ellipse creation preview + if let lightningbeam_core::tool::ToolState::CreatingEllipse { ref start_point, ref current_point, corner_mode, constrain_circle, .. } = self.tool_state { + use vello::kurbo::{Point, Circle as KurboCircle, Ellipse}; + + // Calculate ellipse parameters based on mode (same logic as in handler) + let (rx, ry, position) = if corner_mode { + let min_x = start_point.x.min(current_point.x); + let min_y = start_point.y.min(current_point.y); + let max_x = start_point.x.max(current_point.x); + let max_y = start_point.y.max(current_point.y); + + let width = max_x - min_x; + let height = max_y - min_y; + + let (rx, ry) = if constrain_circle { + let radius = width.max(height) / 2.0; + (radius, radius) + } else { + (width / 2.0, height / 2.0) + }; + + let position = Point::new(min_x + rx, min_y + ry); + + (rx, ry, position) + } else { + let dx = (current_point.x - start_point.x).abs(); + let dy = (current_point.y - start_point.y).abs(); + + let (rx, ry) = if constrain_circle { + let radius = (dx * dx + dy * dy).sqrt(); + (radius, radius) + } else { + (dx, dy) + }; + + (rx, ry, *start_point) + }; + + if rx > 0.0 && ry > 0.0 { + let preview_transform = camera_transform * Affine::translate((position.x, position.y)); + + // Use actual fill color (same as final shape) + let fill_color = Color::rgba8( + self.fill_color.r(), + self.fill_color.g(), + self.fill_color.b(), + self.fill_color.a(), + ); + + // Render circle or ellipse directly (can't use Box due to trait constraints) + if rx == ry { + // Circle + let circle = KurboCircle::new((0.0, 0.0), rx); + scene.fill( + Fill::NonZero, + preview_transform, + fill_color, + None, + &circle, + ); + } else { + // Ellipse + let ellipse = Ellipse::new((0.0, 0.0), (rx, ry), 0.0); + scene.fill( + Fill::NonZero, + preview_transform, + fill_color, + None, + &ellipse, + ); + } + } + } + + // 5. Draw path drawing preview + if let lightningbeam_core::tool::ToolState::DrawingPath { ref points, .. } = self.tool_state { + use vello::kurbo::{BezPath, Point}; + + if points.len() >= 2 { + // Build a simple line path from the raw points for preview + let mut preview_path = BezPath::new(); + preview_path.move_to(points[0]); + for point in &points[1..] { + preview_path.line_to(*point); + } + + // Draw the preview path with stroke + let stroke_width = (2.0 / self.zoom.max(0.5) as f64).max(1.0); + let stroke_color = Color::rgb8( + self.stroke_color.r(), + self.stroke_color.g(), + self.stroke_color.b(), + ); + + scene.stroke( + &Stroke::new(stroke_width), + camera_transform, + stroke_color, + None, + &preview_path, + ); + } + } + + // 6. Draw transform tool handles (when Transform tool is active) + use lightningbeam_core::tool::Tool; + if matches!(self.selected_tool, Tool::Transform) && !self.selection.is_empty() { + // For single object: use object-aligned (rotated) bounding box + // For multiple objects: use axis-aligned bounding box (simpler for now) + + if self.selection.objects().len() == 1 { + // Single object - draw rotated bounding box + let object_id = *self.selection.objects().iter().next().unwrap(); + + if let Some(object) = vector_layer.get_object(&object_id) { + if let Some(shape) = vector_layer.get_shape(&object.shape_id) { + let handle_size = (8.0 / self.zoom.max(0.5) as f64).max(6.0); + let handle_color = Color::rgb8(0, 120, 255); // Blue + let rotation_handle_offset = 20.0 / self.zoom.max(0.5) as f64; + + // Get shape's local bounding box + let local_bbox = shape.path().bounding_box(); + + // Calculate the 4 corners in local space + let local_corners = [ + vello::kurbo::Point::new(local_bbox.x0, local_bbox.y0), // Top-left + vello::kurbo::Point::new(local_bbox.x1, local_bbox.y0), // Top-right + vello::kurbo::Point::new(local_bbox.x1, local_bbox.y1), // Bottom-right + vello::kurbo::Point::new(local_bbox.x0, local_bbox.y1), // Bottom-left + ]; + + // Transform to world space + let obj_transform = Affine::translate((object.transform.x, object.transform.y)) + * Affine::rotate(object.transform.rotation.to_radians()) + * Affine::scale_non_uniform(object.transform.scale_x, object.transform.scale_y); + + let world_corners: Vec = local_corners + .iter() + .map(|&p| obj_transform * p) + .collect(); + + // Draw rotated bounding box outline + let bbox_path = { + let mut path = vello::kurbo::BezPath::new(); + path.move_to(world_corners[0]); + path.line_to(world_corners[1]); + path.line_to(world_corners[2]); + path.line_to(world_corners[3]); + path.close_path(); + path + }; + + scene.stroke( + &Stroke::new(stroke_width), + camera_transform, + handle_color, + None, + &bbox_path, + ); + + // Draw 4 corner handles (squares) + for corner in &world_corners { + let handle_rect = KurboRect::new( + corner.x - handle_size / 2.0, + corner.y - handle_size / 2.0, + corner.x + handle_size / 2.0, + corner.y + handle_size / 2.0, + ); + + // Fill + scene.fill( + Fill::NonZero, + camera_transform, + handle_color, + None, + &handle_rect, + ); + + // White outline + scene.stroke( + &Stroke::new(1.0), + camera_transform, + Color::rgb8(255, 255, 255), + None, + &handle_rect, + ); + } + + // Draw 4 edge handles (circles at midpoints) + let edge_midpoints = [ + vello::kurbo::Point::new((world_corners[0].x + world_corners[1].x) / 2.0, (world_corners[0].y + world_corners[1].y) / 2.0), // Top + vello::kurbo::Point::new((world_corners[1].x + world_corners[2].x) / 2.0, (world_corners[1].y + world_corners[2].y) / 2.0), // Right + vello::kurbo::Point::new((world_corners[2].x + world_corners[3].x) / 2.0, (world_corners[2].y + world_corners[3].y) / 2.0), // Bottom + vello::kurbo::Point::new((world_corners[3].x + world_corners[0].x) / 2.0, (world_corners[3].y + world_corners[0].y) / 2.0), // Left + ]; + + for edge in &edge_midpoints { + let edge_circle = Circle::new(*edge, handle_size / 2.0); + + // Fill + scene.fill( + Fill::NonZero, + camera_transform, + handle_color, + None, + &edge_circle, + ); + + // White outline + scene.stroke( + &Stroke::new(1.0), + camera_transform, + Color::rgb8(255, 255, 255), + None, + &edge_circle, + ); + } + + // Draw rotation handle (circle above top edge center) + let top_center = edge_midpoints[0]; + // Calculate offset vector in object's rotated coordinate space + let rotation_rad = object.transform.rotation.to_radians(); + let cos_r = rotation_rad.cos(); + let sin_r = rotation_rad.sin(); + // Rotate the offset vector (0, -offset) by the object's rotation + let offset_x = -(-rotation_handle_offset) * sin_r; + let offset_y = -rotation_handle_offset * cos_r; + let rotation_handle_pos = vello::kurbo::Point::new( + top_center.x + offset_x, + top_center.y + offset_y, + ); + let rotation_circle = Circle::new(rotation_handle_pos, handle_size / 2.0); + + // Fill with different color (green) + scene.fill( + Fill::NonZero, + camera_transform, + Color::rgb8(50, 200, 50), + None, + &rotation_circle, + ); + + // White outline + scene.stroke( + &Stroke::new(1.0), + camera_transform, + Color::rgb8(255, 255, 255), + None, + &rotation_circle, + ); + + // Draw line connecting rotation handle to bbox + let line_path = { + let mut path = vello::kurbo::BezPath::new(); + path.move_to(rotation_handle_pos); + path.line_to(top_center); + path + }; + + scene.stroke( + &Stroke::new(1.0), + camera_transform, + Color::rgb8(50, 200, 50), + None, + &line_path, + ); + } + } + } else { + // Multiple objects - use axis-aligned bbox (existing code) + let mut combined_bbox: Option = None; + + for &object_id in self.selection.objects() { + if let Some(object) = vector_layer.get_object(&object_id) { + if let Some(shape) = vector_layer.get_shape(&object.shape_id) { + let shape_bbox = shape.path().bounding_box(); + let transform = Affine::translate((object.transform.x, object.transform.y)) + * Affine::rotate(object.transform.rotation.to_radians()) + * Affine::scale_non_uniform(object.transform.scale_x, object.transform.scale_y); + let transformed_bbox = transform.transform_rect_bbox(shape_bbox); + + combined_bbox = Some(match combined_bbox { + None => transformed_bbox, + Some(existing) => existing.union(transformed_bbox), + }); + } + } + } + + if let Some(bbox) = combined_bbox { + let handle_size = (8.0 / self.zoom.max(0.5) as f64).max(6.0); + let handle_color = Color::rgb8(0, 120, 255); + let rotation_handle_offset = 20.0 / self.zoom.max(0.5) as f64; + + scene.stroke(&Stroke::new(stroke_width), camera_transform, handle_color, None, &bbox); + + let corners = [ + vello::kurbo::Point::new(bbox.x0, bbox.y0), + vello::kurbo::Point::new(bbox.x1, bbox.y0), + vello::kurbo::Point::new(bbox.x1, bbox.y1), + vello::kurbo::Point::new(bbox.x0, bbox.y1), + ]; + + for corner in &corners { + let handle_rect = KurboRect::new( + corner.x - handle_size / 2.0, corner.y - handle_size / 2.0, + corner.x + handle_size / 2.0, corner.y + handle_size / 2.0, + ); + scene.fill(Fill::NonZero, camera_transform, handle_color, None, &handle_rect); + scene.stroke(&Stroke::new(1.0), camera_transform, Color::rgb8(255, 255, 255), None, &handle_rect); + } + + let edges = [ + vello::kurbo::Point::new(bbox.center().x, bbox.y0), + vello::kurbo::Point::new(bbox.x1, bbox.center().y), + vello::kurbo::Point::new(bbox.center().x, bbox.y1), + vello::kurbo::Point::new(bbox.x0, bbox.center().y), + ]; + + for edge in &edges { + let edge_circle = Circle::new(*edge, handle_size / 2.0); + scene.fill(Fill::NonZero, camera_transform, handle_color, None, &edge_circle); + scene.stroke(&Stroke::new(1.0), camera_transform, Color::rgb8(255, 255, 255), None, &edge_circle); + } + + let rotation_handle_pos = vello::kurbo::Point::new(bbox.center().x, bbox.y0 - rotation_handle_offset); + let rotation_circle = Circle::new(rotation_handle_pos, handle_size / 2.0); + scene.fill(Fill::NonZero, camera_transform, Color::rgb8(50, 200, 50), None, &rotation_circle); + scene.stroke(&Stroke::new(1.0), camera_transform, Color::rgb8(255, 255, 255), None, &rotation_circle); + + let line_path = { + let mut path = vello::kurbo::BezPath::new(); + path.move_to(rotation_handle_pos); + path.line_to(vello::kurbo::Point::new(bbox.center().x, bbox.y0)); + path + }; + scene.stroke(&Stroke::new(1.0), camera_transform, Color::rgb8(50, 200, 50), None, &line_path); + } + } + } } } } @@ -559,7 +974,7 @@ impl StagePane { None => return, // No active layer }; - let active_layer = match shared.document.get_layer(active_layer_id) { + let active_layer = match shared.action_executor.document().get_layer(active_layer_id) { Some(layer) => layer, None => return, }; @@ -707,9 +1122,1353 @@ impl StagePane { } } + fn handle_rectangle_tool( + &mut self, + ui: &mut egui::Ui, + response: &egui::Response, + world_pos: egui::Vec2, + shift_held: bool, + ctrl_held: bool, + shared: &mut SharedPaneState, + ) { + use lightningbeam_core::tool::ToolState; + use lightningbeam_core::layer::AnyLayer; + use vello::kurbo::Point; + + // 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 + if !matches!(active_layer, AnyLayer::Vector(_)) { + return; + } + + let point = Point::new(world_pos.x as f64, world_pos.y as f64); + + // Mouse down: start creating rectangle (clears any previous preview) + if response.drag_started() || response.clicked() { + *shared.tool_state = ToolState::CreatingRectangle { + start_point: point, + current_point: point, + centered: ctrl_held, + constrain_square: shift_held, + }; + } + + // Mouse drag: update rectangle + if response.dragged() { + if let ToolState::CreatingRectangle { start_point, .. } = shared.tool_state { + *shared.tool_state = ToolState::CreatingRectangle { + start_point: *start_point, + current_point: point, + centered: ctrl_held, + constrain_square: shift_held, + }; + } + } + + // Mouse up: create the rectangle shape + if response.drag_stopped() || (ui.input(|i| i.pointer.any_released()) && matches!(shared.tool_state, ToolState::CreatingRectangle { .. })) { + if let ToolState::CreatingRectangle { start_point, current_point, centered, constrain_square } = shared.tool_state.clone() { + // Calculate rectangle bounds based on mode + let (width, height, position) = if centered { + // Centered mode: start_point is center + let dx = current_point.x - start_point.x; + let dy = current_point.y - start_point.y; + + let (w, h) = if constrain_square { + let size = dx.abs().max(dy.abs()) * 2.0; + (size, size) + } else { + (dx.abs() * 2.0, dy.abs() * 2.0) + }; + + let pos = Point::new(start_point.x - w / 2.0, start_point.y - h / 2.0); + (w, h, pos) + } else { + // Corner mode: start_point is corner + let mut min_x = start_point.x.min(current_point.x); + let mut min_y = start_point.y.min(current_point.y); + let mut max_x = start_point.x.max(current_point.x); + let mut max_y = start_point.y.max(current_point.y); + + if constrain_square { + let width = max_x - min_x; + let height = max_y - min_y; + let size = width.max(height); + + if current_point.x > start_point.x { + max_x = min_x + size; + } else { + min_x = max_x - size; + } + + if current_point.y > start_point.y { + max_y = min_y + size; + } else { + min_y = max_y - size; + } + } + + (max_x - min_x, max_y - min_y, Point::new(min_x, min_y)) + }; + + // Only create shape if rectangle has non-zero size + if width > 1.0 && height > 1.0 { + use lightningbeam_core::shape::{Shape, ShapeColor}; + use lightningbeam_core::object::Object; + use lightningbeam_core::actions::AddShapeAction; + + // Create shape with rectangle path (built from lines) + let path = Self::create_rectangle_path(width, height); + let shape = Shape::new(path).with_fill(ShapeColor::from_egui(*shared.fill_color)); + + // Create object at the calculated position + let object = Object::new(shape.id).with_position(position.x, position.y); + + // Create and execute action immediately + let action = AddShapeAction::new(*active_layer_id, shape, object); + shared.action_executor.execute(Box::new(action)); + + // Clear tool state to stop preview rendering + *shared.tool_state = ToolState::Idle; + } + } + } + } + + fn handle_ellipse_tool( + &mut self, + ui: &mut egui::Ui, + response: &egui::Response, + world_pos: egui::Vec2, + shift_held: bool, + ctrl_held: bool, + shared: &mut SharedPaneState, + ) { + use lightningbeam_core::tool::ToolState; + use lightningbeam_core::layer::AnyLayer; + use vello::kurbo::Point; + + // 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 + if !matches!(active_layer, AnyLayer::Vector(_)) { + return; + } + + let point = Point::new(world_pos.x as f64, world_pos.y as f64); + + // Mouse down: start creating ellipse (clears any previous preview) + if response.drag_started() || response.clicked() { + *shared.tool_state = ToolState::CreatingEllipse { + start_point: point, + current_point: point, + corner_mode: !ctrl_held, // Inverted: Ctrl = centered (like rectangle) + constrain_circle: shift_held, + }; + } + + // Mouse drag: update ellipse + if response.dragged() { + if let ToolState::CreatingEllipse { start_point, .. } = shared.tool_state { + *shared.tool_state = ToolState::CreatingEllipse { + start_point: *start_point, + current_point: point, + corner_mode: !ctrl_held, // Inverted: Ctrl = centered (like rectangle) + constrain_circle: shift_held, + }; + } + } + + // Mouse up: create the ellipse shape + if response.drag_stopped() || (ui.input(|i| i.pointer.any_released()) && matches!(shared.tool_state, ToolState::CreatingEllipse { .. })) { + if let ToolState::CreatingEllipse { start_point, current_point, corner_mode, constrain_circle } = shared.tool_state.clone() { + // Calculate ellipse parameters based on mode + // Note: corner_mode is true when Ctrl is NOT held (inverted for consistency with rectangle) + let (rx, ry, position) = if corner_mode { + // Corner mode (default): start_point is corner of bounding box + let min_x = start_point.x.min(current_point.x); + let min_y = start_point.y.min(current_point.y); + let max_x = start_point.x.max(current_point.x); + let max_y = start_point.y.max(current_point.y); + + let width = max_x - min_x; + let height = max_y - min_y; + + let (rx, ry) = if constrain_circle { + let radius = width.max(height) / 2.0; + (radius, radius) + } else { + (width / 2.0, height / 2.0) + }; + + let position = Point::new(min_x + rx, min_y + ry); + + (rx, ry, position) + } else { + // Center mode (Ctrl held): start_point is center + let dx = (current_point.x - start_point.x).abs(); + let dy = (current_point.y - start_point.y).abs(); + + let (rx, ry) = if constrain_circle { + let radius = (dx * dx + dy * dy).sqrt(); + (radius, radius) + } else { + (dx, dy) + }; + + (rx, ry, start_point) + }; + + // Only create shape if ellipse has non-zero size + if rx > 1.0 && ry > 1.0 { + use lightningbeam_core::shape::{Shape, ShapeColor}; + use lightningbeam_core::object::Object; + use lightningbeam_core::actions::AddShapeAction; + + // Create shape with ellipse path (built from bezier curves) + let path = Self::create_ellipse_path(rx, ry); + let shape = Shape::new(path).with_fill(ShapeColor::from_egui(*shared.fill_color)); + + // Create object at the calculated position + let object = Object::new(shape.id).with_position(position.x, position.y); + + // Create and execute action immediately + let action = AddShapeAction::new(*active_layer_id, shape, object); + shared.action_executor.execute(Box::new(action)); + + // Clear tool state to stop preview rendering + *shared.tool_state = ToolState::Idle; + } + } + } + } + + /// Create a rectangle path from lines (easier for curve editing later) + fn create_rectangle_path(width: f64, height: f64) -> vello::kurbo::BezPath { + use vello::kurbo::{BezPath, Point}; + + let mut path = BezPath::new(); + + // Start at top-left + path.move_to(Point::new(0.0, 0.0)); + + // Top-right + path.line_to(Point::new(width, 0.0)); + + // Bottom-right + path.line_to(Point::new(width, height)); + + // Bottom-left + path.line_to(Point::new(0.0, height)); + + // Close path (back to top-left) + path.close_path(); + + path + } + + /// Create an ellipse path from bezier curves (easier for curve editing later) + /// Uses 4 cubic bezier segments to approximate the ellipse + fn create_ellipse_path(rx: f64, ry: f64) -> vello::kurbo::BezPath { + use vello::kurbo::{BezPath, Point}; + + // Magic constant for circular arc approximation with cubic beziers + // k = 4/3 * (sqrt(2) - 1) ≈ 0.5522847498 + const KAPPA: f64 = 0.5522847498; + + let kx = rx * KAPPA; + let ky = ry * KAPPA; + + let mut path = BezPath::new(); + + // Start at right point (rx, 0) + path.move_to(Point::new(rx, 0.0)); + + // Top-right quadrant (to top point) + path.curve_to( + Point::new(rx, -ky), // control point 1 + Point::new(kx, -ry), // control point 2 + Point::new(0.0, -ry), // end point (top) + ); + + // Top-left quadrant (to left point) + path.curve_to( + Point::new(-kx, -ry), // control point 1 + Point::new(-rx, -ky), // control point 2 + Point::new(-rx, 0.0), // end point (left) + ); + + // Bottom-left quadrant (to bottom point) + path.curve_to( + Point::new(-rx, ky), // control point 1 + Point::new(-kx, ry), // control point 2 + Point::new(0.0, ry), // end point (bottom) + ); + + // Bottom-right quadrant (back to right point) + path.curve_to( + Point::new(kx, ry), // control point 1 + Point::new(rx, ky), // control point 2 + Point::new(rx, 0.0), // end point (right) + ); + + path.close_path(); + + path + } + + fn handle_draw_tool( + &mut self, + ui: &mut egui::Ui, + response: &egui::Response, + world_pos: egui::Vec2, + shared: &mut SharedPaneState, + ) { + use lightningbeam_core::tool::ToolState; + use lightningbeam_core::layer::AnyLayer; + use vello::kurbo::Point; + + // 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 + if !matches!(active_layer, AnyLayer::Vector(_)) { + return; + } + + let point = Point::new(world_pos.x as f64, world_pos.y as f64); + + // Mouse down: start drawing path + if response.drag_started() || response.clicked() { + *shared.tool_state = ToolState::DrawingPath { + points: vec![point], + simplify_mode: *shared.draw_simplify_mode, + }; + } + + // Mouse drag: add points to path + if response.dragged() { + if let ToolState::DrawingPath { points, simplify_mode } = &mut *shared.tool_state { + // Only add point if it's far enough from the last point (reduce noise) + const MIN_POINT_DISTANCE: f64 = 2.0; + + if let Some(last_point) = points.last() { + let dist_sq = (point.x - last_point.x).powi(2) + (point.y - last_point.y).powi(2); + if dist_sq > MIN_POINT_DISTANCE * MIN_POINT_DISTANCE { + points.push(point); + } + } else { + points.push(point); + } + } + } + + // Mouse up: complete the path and create shape + if response.drag_stopped() || (ui.input(|i| i.pointer.any_released()) && matches!(shared.tool_state, ToolState::DrawingPath { .. })) { + if let ToolState::DrawingPath { points, simplify_mode } = shared.tool_state.clone() { + // Only create shape if we have enough points + if points.len() >= 2 { + use lightningbeam_core::path_fitting::{ + simplify_rdp, fit_bezier_curves, RdpConfig, SchneiderConfig, + }; + use lightningbeam_core::shape::{Shape, ShapeColor}; + use lightningbeam_core::object::Object; + use lightningbeam_core::actions::AddShapeAction; + + // Convert points to the appropriate path based on simplify mode + let path = match simplify_mode { + lightningbeam_core::tool::SimplifyMode::Corners => { + // RDP simplification first, then convert to bezier + let config = RdpConfig { + tolerance: *shared.rdp_tolerance, + highest_quality: false, + }; + let simplified = simplify_rdp(&points, config); + + // Convert simplified points to smooth bezier with mid-point curves + fit_bezier_curves(&simplified, SchneiderConfig { + max_error: *shared.schneider_max_error + }) + } + lightningbeam_core::tool::SimplifyMode::Smooth => { + // Direct Schneider curve fitting for smooth curves + let config = SchneiderConfig { + max_error: *shared.schneider_max_error, + }; + fit_bezier_curves(&points, config) + } + lightningbeam_core::tool::SimplifyMode::Verbatim => { + // Use raw points as line segments + let mut path = vello::kurbo::BezPath::new(); + if let Some(first) = points.first() { + path.move_to(*first); + for point in &points[1..] { + path.line_to(*point); + } + } + path + } + }; + + // Only create shape if path is not empty + if !path.is_empty() { + // Calculate bounding box to position the object + let bbox = path.bounding_box(); + let position = Point::new(bbox.x0, bbox.y0); + + // Translate path to be relative to position (0,0 at top-left of bbox) + use vello::kurbo::Affine; + let transform = Affine::translate((-bbox.x0, -bbox.y0)); + let translated_path = transform * path; + + // Create shape with both fill and stroke + use lightningbeam_core::shape::StrokeStyle; + let shape = Shape::new(translated_path) + .with_fill(ShapeColor::from_egui(*shared.fill_color)) + .with_stroke( + ShapeColor::from_egui(*shared.stroke_color), + StrokeStyle::default(), + ); + + // Create object at the calculated position + let object = Object::new(shape.id).with_position(position.x, position.y); + + // Create and execute action immediately + let action = AddShapeAction::new(*active_layer_id, shape, object); + shared.action_executor.execute(Box::new(action)); + } + } + + // Clear tool state to stop preview rendering + *shared.tool_state = ToolState::Idle; + } + } + } + + /// Apply transform preview to objects based on current mouse position + fn apply_transform_preview( + vector_layer: &mut lightningbeam_core::layer::VectorLayer, + mode: &lightningbeam_core::tool::TransformMode, + original_transforms: &std::collections::HashMap, + pivot: vello::kurbo::Point, + start_mouse: vello::kurbo::Point, + current_mouse: vello::kurbo::Point, + ) { + use lightningbeam_core::tool::{TransformMode, Axis}; + + match mode { + TransformMode::ScaleCorner { origin } => { + println!("--- SCALE CORNER ---"); + println!("Origin: ({:.1}, {:.1})", origin.x, origin.y); + println!("Start mouse: ({:.1}, {:.1})", start_mouse.x, start_mouse.y); + println!("Current mouse: ({:.1}, {:.1})", current_mouse.x, current_mouse.y); + + // Calculate world-space scale from opposite corner + let start_vec = start_mouse - *origin; + let current_vec = current_mouse - *origin; + + println!("Start vec: ({:.1}, {:.1})", start_vec.x, start_vec.y); + println!("Current vec: ({:.1}, {:.1})", current_vec.x, current_vec.y); + + let scale_x_world = if start_vec.x.abs() > 0.001 { + current_vec.x / start_vec.x + } else { + 1.0 + }; + + let scale_y_world = if start_vec.y.abs() > 0.001 { + current_vec.y / start_vec.y + } else { + 1.0 + }; + + println!("Scale world: ({:.3}, {:.3})", scale_x_world, scale_y_world); + + // Apply scale to all selected objects + for (object_id, original_transform) in original_transforms { + println!("\nObject {:?}:", object_id); + println!(" Original pos: ({:.1}, {:.1})", original_transform.x, original_transform.y); + println!(" Original rotation: {:.1}°", original_transform.rotation); + println!(" Original scale: ({:.3}, {:.3})", original_transform.scale_x, original_transform.scale_y); + + vector_layer.modify_object_internal(object_id, |obj| { + // Get object's rotation in radians + let rotation_rad = original_transform.rotation.to_radians(); + let cos_r = rotation_rad.cos(); + let sin_r = rotation_rad.sin(); + + // Transform scale from world space to object's local space + // The object's local axes are rotated by rotation_rad from world axes + // We need to figure out how much to scale along each local axis + // to achieve the world-space scaling + + // For a rotated object, world-space scale affects local-space scale as: + // local_x axis aligns with (cos(r), sin(r)) in world space + // local_y axis aligns with (-sin(r), cos(r)) in world space + // When we scale by (sx, sy) in world, the local scale changes by: + let cos_r_sq = cos_r * cos_r; + let sin_r_sq = sin_r * sin_r; + let sx_abs = scale_x_world.abs(); + let sy_abs = scale_y_world.abs(); + + // Compute how much the object grows along its local axes + // when the world-space bbox is scaled + let local_scale_x = (cos_r_sq * sx_abs * sx_abs + sin_r_sq * sy_abs * sy_abs).sqrt(); + let local_scale_y = (sin_r_sq * sx_abs * sx_abs + cos_r_sq * sy_abs * sy_abs).sqrt(); + + println!(" Local scale factors: ({:.3}, {:.3})", local_scale_x, local_scale_y); + + // Scale the object's position relative to the origin point in world space + let rel_x = original_transform.x - origin.x; + let rel_y = original_transform.y - origin.y; + + println!(" Relative pos from origin: ({:.1}, {:.1})", rel_x, rel_y); + + obj.transform.x = origin.x + rel_x * scale_x_world; + obj.transform.y = origin.y + rel_y * scale_y_world; + + println!(" New pos: ({:.1}, {:.1})", obj.transform.x, obj.transform.y); + + // Apply local-space scale + obj.transform.scale_x = original_transform.scale_x * local_scale_x; + obj.transform.scale_y = original_transform.scale_y * local_scale_y; + + println!(" New scale: ({:.3}, {:.3})", obj.transform.scale_x, obj.transform.scale_y); + + // Keep rotation unchanged + obj.transform.rotation = original_transform.rotation; + }); + } + } + + TransformMode::ScaleEdge { axis, origin } => { + // Calculate scale along one axis + let (scale_x_world, scale_y_world) = match axis { + Axis::Horizontal => { + let start_dist = (start_mouse.x - origin.x).abs(); + let current_dist = (current_mouse.x - origin.x).abs(); + let scale = if start_dist > 0.001 { + current_dist / start_dist + } else { + 1.0 + }; + (scale, 1.0) + } + Axis::Vertical => { + let start_dist = (start_mouse.y - origin.y).abs(); + let current_dist = (current_mouse.y - origin.y).abs(); + let scale = if start_dist > 0.001 { + current_dist / start_dist + } else { + 1.0 + }; + (1.0, scale) + } + }; + + // Apply scale to all selected objects + for (object_id, original_transform) in original_transforms { + vector_layer.modify_object_internal(object_id, |obj| { + // Get object's rotation in radians + let rotation_rad = original_transform.rotation.to_radians(); + let cos_r = rotation_rad.cos(); + let sin_r = rotation_rad.sin(); + + // Transform scale from world space to local space (same as corner mode) + let cos_r_sq = cos_r * cos_r; + let sin_r_sq = sin_r * sin_r; + let sx_abs = scale_x_world.abs(); + let sy_abs = scale_y_world.abs(); + + let local_scale_x = (cos_r_sq * sx_abs * sx_abs + sin_r_sq * sy_abs * sy_abs).sqrt(); + let local_scale_y = (sin_r_sq * sx_abs * sx_abs + cos_r_sq * sy_abs * sy_abs).sqrt(); + + // Scale position relative to origin in world space + let rel_x = original_transform.x - origin.x; + let rel_y = original_transform.y - origin.y; + + obj.transform.x = origin.x + rel_x * scale_x_world; + obj.transform.y = origin.y + rel_y * scale_y_world; + + // Apply local-space scale + obj.transform.scale_x = original_transform.scale_x * local_scale_x; + obj.transform.scale_y = original_transform.scale_y * local_scale_y; + + // Keep rotation unchanged + obj.transform.rotation = original_transform.rotation; + }); + } + } + + TransformMode::Rotate { center } => { + // Calculate rotation angle + let start_vec = start_mouse - *center; + let current_vec = current_mouse - *center; + + let start_angle = start_vec.y.atan2(start_vec.x); + let current_angle = current_vec.y.atan2(current_vec.x); + let delta_angle = (current_angle - start_angle).to_degrees(); + + // Apply rotation to all selected objects + for (object_id, original_transform) in original_transforms { + vector_layer.modify_object_internal(object_id, |obj| { + // Rotate position around center + let rel_x = original_transform.x - center.x; + let rel_y = original_transform.y - center.y; + + let angle_rad = delta_angle.to_radians(); + let cos_a = angle_rad.cos(); + let sin_a = angle_rad.sin(); + + obj.transform.x = center.x + rel_x * cos_a - rel_y * sin_a; + obj.transform.y = center.y + rel_x * sin_a + rel_y * cos_a; + obj.transform.rotation = original_transform.rotation + delta_angle; + + // Keep scale unchanged + obj.transform.scale_x = original_transform.scale_x; + obj.transform.scale_y = original_transform.scale_y; + }); + } + } + } + } + + /// Hit test transform handles and return which handle was clicked + fn hit_test_transform_handle( + point: vello::kurbo::Point, + bbox: vello::kurbo::Rect, + tolerance: f64, + ) -> Option { + use lightningbeam_core::tool::{TransformMode, Axis}; + use vello::kurbo::Point; + + // Check rotation handle first (20px above top edge) + let rotation_handle = Point::new(bbox.center().x, bbox.y0 - 20.0); + if point.distance(rotation_handle) < tolerance { + return Some(TransformMode::Rotate { + center: bbox.center(), + }); + } + + // Check corner handles (8x8 squares) + let corners = [ + (Point::new(bbox.x0, bbox.y0), 0), // Top-left + (Point::new(bbox.x1, bbox.y0), 1), // Top-right + (Point::new(bbox.x1, bbox.y1), 2), // Bottom-right + (Point::new(bbox.x0, bbox.y1), 3), // Bottom-left + ]; + + for (corner, idx) in &corners { + if point.distance(*corner) < tolerance { + // Opposite corner is 2 positions away (diagonal) + let opposite = corners[(idx + 2) % 4].0; + return Some(TransformMode::ScaleCorner { origin: opposite }); + } + } + + // Check edge handles (circles at midpoints) + let edges = [ + (Point::new(bbox.center().x, bbox.y0), Axis::Vertical, bbox.y1), // Top + (Point::new(bbox.x1, bbox.center().y), Axis::Horizontal, bbox.x0), // Right + (Point::new(bbox.center().x, bbox.y1), Axis::Vertical, bbox.y0), // Bottom + (Point::new(bbox.x0, bbox.center().y), Axis::Horizontal, bbox.x1), // Left + ]; + + for (edge, axis, origin_coord) in &edges { + if point.distance(*edge) < tolerance { + let origin = match axis { + Axis::Horizontal => Point::new(*origin_coord, edge.y), + Axis::Vertical => Point::new(edge.x, *origin_coord), + }; + return Some(TransformMode::ScaleEdge { + axis: *axis, + origin, + }); + } + } + + None + } + + fn handle_transform_tool( + &mut self, + ui: &mut egui::Ui, + response: &egui::Response, + world_pos: egui::Vec2, + shared: &mut SharedPaneState, + ) { + use lightningbeam_core::tool::ToolState; + use lightningbeam_core::layer::AnyLayer; + use vello::kurbo::Point; + + // Check if we have an active vector layer + let active_layer_id = match shared.active_layer_id { + Some(id) => id, + None => return, + }; + + // Only work on VectorLayer - just check type, don't hold reference + { + let active_layer = match shared.action_executor.document().get_layer(active_layer_id) { + Some(layer) => layer, + None => return, + }; + + if !matches!(active_layer, AnyLayer::Vector(_)) { + return; + } + } + + // Need a selection to transform + if shared.selection.is_empty() { + return; + } + + let point = Point::new(world_pos.x as f64, world_pos.y as f64); + + // For single object: use rotated bounding box + // For multiple objects: use axis-aligned bounding box + if shared.selection.objects().len() == 1 { + // Single object - rotated bounding box + self.handle_transform_single_object(ui, response, point, active_layer_id, shared); + } else { + // Multiple objects - axis-aligned bounding box + // Calculate combined bounding box for handle hit testing + let mut combined_bbox: Option = None; + + // Get immutable reference just for bbox calculation + if let Some(AnyLayer::Vector(vector_layer)) = shared.action_executor.document().get_layer(active_layer_id) { + for &object_id in shared.selection.objects() { + if let Some(object) = vector_layer.get_object(&object_id) { + if let Some(shape) = vector_layer.get_shape(&object.shape_id) { + // Get shape's local bounding box + let shape_bbox = shape.path().bounding_box(); + + // Transform to world space: translate by object position + // Then apply scale and rotation around that position + use vello::kurbo::Affine; + let transform = Affine::translate((object.transform.x, object.transform.y)) + * Affine::rotate(object.transform.rotation.to_radians()) + * Affine::scale_non_uniform(object.transform.scale_x, object.transform.scale_y); + + let transformed_bbox = transform.transform_rect_bbox(shape_bbox); + + combined_bbox = Some(match combined_bbox { + None => transformed_bbox, + Some(existing) => existing.union(transformed_bbox), + }); + } + } + } + } + + let bbox = match combined_bbox { + Some(b) => b, + None => return, + }; + + // Mouse down: check if clicking on a handle + if response.drag_started() || response.clicked() { + let tolerance = 10.0; // Click tolerance in world space + + if let Some(mode) = Self::hit_test_transform_handle(point, bbox, tolerance) { + // Store original transforms of all selected objects + use std::collections::HashMap; + let mut original_transforms = HashMap::new(); + + if let Some(AnyLayer::Vector(vector_layer)) = shared.action_executor.document().get_layer(active_layer_id) { + for &object_id in shared.selection.objects() { + if let Some(object) = vector_layer.get_object(&object_id) { + original_transforms.insert(object_id, object.transform.clone()); + } + } + } + + println!("=== TRANSFORM START ==="); + println!("Mode: {:?}", mode); + println!("Bbox: x0={:.1}, y0={:.1}, x1={:.1}, y1={:.1}", bbox.x0, bbox.y0, bbox.x1, bbox.y1); + println!("Start mouse: ({:.1}, {:.1})", point.x, point.y); + + *shared.tool_state = ToolState::Transforming { + mode, + original_transforms, + pivot: bbox.center(), + start_mouse: point, + current_mouse: point, + original_bbox: bbox, // Store the bbox at start of transform + }; + } + } + + // Mouse drag: update current mouse position and apply transforms + if response.dragged() { + if let ToolState::Transforming { mode, original_transforms, pivot, start_mouse, original_bbox, .. } = shared.tool_state.clone() { + // Update current mouse position + *shared.tool_state = ToolState::Transforming { + mode, + original_transforms: original_transforms.clone(), + pivot, + start_mouse, + current_mouse: point, + original_bbox, + }; + + // Get mutable access to layer to apply transform preview + if let Some(layer) = shared.action_executor.document_mut().get_layer_mut(&active_layer_id) { + if let AnyLayer::Vector(vector_layer) = layer { + Self::apply_transform_preview( + vector_layer, + &mode, + &original_transforms, + pivot, + start_mouse, + point, + ); + } + } + } + } + + // Mouse up: finalize transform + if response.drag_stopped() || (ui.input(|i| i.pointer.any_released()) && matches!(shared.tool_state, ToolState::Transforming { .. })) { + if let ToolState::Transforming { original_transforms, .. } = shared.tool_state.clone() { + use std::collections::HashMap; + use lightningbeam_core::actions::TransformObjectsAction; + + let mut object_transforms = HashMap::new(); + + // Get current transforms and pair with originals + if let Some(AnyLayer::Vector(vector_layer)) = shared.action_executor.document().get_layer(active_layer_id) { + for (object_id, original) in original_transforms { + if let Some(object) = vector_layer.get_object(&object_id) { + let new_transform = object.transform.clone(); + object_transforms.insert(object_id, (original, new_transform)); + } + } + } + + if !object_transforms.is_empty() { + let action = TransformObjectsAction::new(*active_layer_id, object_transforms); + shared.pending_actions.push(Box::new(action)); + } + + *shared.tool_state = ToolState::Idle; + } + } + } // End of multi-object else block + } + + /// Handle transform tool for a single object with rotated bounding box + fn handle_transform_single_object( + &mut self, + ui: &mut egui::Ui, + response: &egui::Response, + point: vello::kurbo::Point, + active_layer_id: &uuid::Uuid, + shared: &mut SharedPaneState, + ) { + use lightningbeam_core::tool::ToolState; + use lightningbeam_core::layer::AnyLayer; + use vello::kurbo::Affine; + + let object_id = *shared.selection.objects().iter().next().unwrap(); + + // Calculate rotated bounding box corners + let (local_bbox, world_corners, obj_transform, object) = { + if let Some(AnyLayer::Vector(vector_layer)) = shared.action_executor.document().get_layer(active_layer_id) { + if let Some(object) = vector_layer.get_object(&object_id) { + if let Some(shape) = vector_layer.get_shape(&object.shape_id) { + let local_bbox = shape.path().bounding_box(); + + let local_corners = [ + vello::kurbo::Point::new(local_bbox.x0, local_bbox.y0), + vello::kurbo::Point::new(local_bbox.x1, local_bbox.y0), + vello::kurbo::Point::new(local_bbox.x1, local_bbox.y1), + vello::kurbo::Point::new(local_bbox.x0, local_bbox.y1), + ]; + + let obj_transform = Affine::translate((object.transform.x, object.transform.y)) + * Affine::rotate(object.transform.rotation.to_radians()) + * Affine::scale_non_uniform(object.transform.scale_x, object.transform.scale_y); + + let world_corners: Vec = local_corners + .iter() + .map(|&p| obj_transform * p) + .collect(); + + (local_bbox, world_corners, obj_transform, object.clone()) + } else { + return; + } + } else { + return; + } + } else { + return; + } + }; + + // === Calculate ALL handle positions once (shared by cursor and click logic) === + let tolerance = 15.0; + + // Edge midpoints + let edge_midpoints = [ + vello::kurbo::Point::new((world_corners[0].x + world_corners[1].x) / 2.0, (world_corners[0].y + world_corners[1].y) / 2.0), + vello::kurbo::Point::new((world_corners[1].x + world_corners[2].x) / 2.0, (world_corners[1].y + world_corners[2].y) / 2.0), + vello::kurbo::Point::new((world_corners[2].x + world_corners[3].x) / 2.0, (world_corners[2].y + world_corners[3].y) / 2.0), + vello::kurbo::Point::new((world_corners[3].x + world_corners[0].x) / 2.0, (world_corners[3].y + world_corners[0].y) / 2.0), + ]; + + // Rotation handle position + let rotation_rad = object.transform.rotation.to_radians(); + let cos_r = rotation_rad.cos(); + let sin_r = rotation_rad.sin(); + let rotation_handle_offset = 20.0; + let top_center = edge_midpoints[0]; + let offset_x = -(-rotation_handle_offset) * sin_r; + let offset_y = -rotation_handle_offset * cos_r; + let rotation_handle_pos = vello::kurbo::Point::new(top_center.x + offset_x, top_center.y + offset_y); + + // === Set cursor based on hover (using the same handle positions) === + if point.distance(rotation_handle_pos) < tolerance { + ui.ctx().set_cursor_icon(egui::CursorIcon::AllScroll); // 4-way arrows for rotation + } else { + let mut hovering_handle = false; + + // Check corner handles with correct diagonal cursors + for (idx, corner) in world_corners.iter().enumerate() { + if point.distance(*corner) < tolerance { + // Top-left (0) and bottom-right (2): NW-SE diagonal (\) + // Top-right (1) and bottom-left (3): NE-SW diagonal (/) + let cursor = match idx { + 0 | 2 => egui::CursorIcon::ResizeNwSe, // Top-left, Bottom-right + 1 | 3 => egui::CursorIcon::ResizeNeSw, // Top-right, Bottom-left + _ => egui::CursorIcon::Default, + }; + ui.ctx().set_cursor_icon(cursor); + hovering_handle = true; + break; + } + } + + // Check edge handles + if !hovering_handle { + for (idx, edge_pos) in edge_midpoints.iter().enumerate() { + if point.distance(*edge_pos) < tolerance { + let cursor = match idx { + 0 | 2 => egui::CursorIcon::ResizeVertical, // Top/Bottom + 1 | 3 => egui::CursorIcon::ResizeHorizontal, // Right/Left + _ => egui::CursorIcon::Default, + }; + ui.ctx().set_cursor_icon(cursor); + hovering_handle = true; + break; + } + } + } + } + + // === Mouse down: hit test handles (using the same handle positions and order as cursor logic) === + if response.drag_started() || response.clicked() { + // Check rotation handle (same as cursor logic) + if point.distance(rotation_handle_pos) < tolerance { + // Start rotation around the visual center of the shape + // Calculate local center + let local_center = vello::kurbo::Point::new( + (local_bbox.x0 + local_bbox.x1) / 2.0, + (local_bbox.y0 + local_bbox.y1) / 2.0, + ); + + // Transform to world space to get the visual center + let visual_center = obj_transform * local_center; + + use std::collections::HashMap; + let mut original_transforms = HashMap::new(); + original_transforms.insert(object_id, object.transform.clone()); + + *shared.tool_state = ToolState::Transforming { + mode: lightningbeam_core::tool::TransformMode::Rotate { center: visual_center }, + original_transforms, + pivot: visual_center, + start_mouse: point, + current_mouse: point, + original_bbox: vello::kurbo::Rect::new(local_bbox.x0, local_bbox.y0, local_bbox.x1, local_bbox.y1), + }; + return; + } + + // Check corner handles + for (idx, corner) in world_corners.iter().enumerate() { + if point.distance(*corner) < tolerance { + // Get opposite corner in local space + let opposite_idx = (idx + 2) % 4; + + use std::collections::HashMap; + let mut original_transforms = HashMap::new(); + original_transforms.insert(object_id, object.transform.clone()); + + *shared.tool_state = ToolState::Transforming { + mode: lightningbeam_core::tool::TransformMode::ScaleCorner { + origin: world_corners[opposite_idx], + }, + original_transforms, + pivot: world_corners[opposite_idx], + start_mouse: point, + current_mouse: point, + original_bbox: vello::kurbo::Rect::new(local_bbox.x0, local_bbox.y0, local_bbox.x1, local_bbox.y1), + }; + return; + } + } + + // Check edge handles + for (idx, edge_pos) in edge_midpoints.iter().enumerate() { + if point.distance(*edge_pos) < tolerance { + use std::collections::HashMap; + use lightningbeam_core::tool::Axis; + + let mut original_transforms = HashMap::new(); + original_transforms.insert(object_id, object.transform.clone()); + + // Determine axis and opposite edge + let (axis, opposite_edge) = match idx { + 0 => (Axis::Vertical, edge_midpoints[2]), // Top -> opposite is Bottom + 1 => (Axis::Horizontal, edge_midpoints[3]), // Right -> opposite is Left + 2 => (Axis::Vertical, edge_midpoints[0]), // Bottom -> opposite is Top + 3 => (Axis::Horizontal, edge_midpoints[1]), // Left -> opposite is Right + _ => unreachable!(), + }; + + *shared.tool_state = ToolState::Transforming { + mode: lightningbeam_core::tool::TransformMode::ScaleEdge { + axis, + origin: opposite_edge, + }, + original_transforms, + pivot: opposite_edge, + start_mouse: point, + current_mouse: point, + original_bbox: vello::kurbo::Rect::new(local_bbox.x0, local_bbox.y0, local_bbox.x1, local_bbox.y1), + }; + return; + } + } + } + + // Mouse drag: apply transform in local space + if response.dragged() { + if let ToolState::Transforming { mode, original_transforms, start_mouse, current_mouse: _, .. } = shared.tool_state.clone() { + // Update current mouse + if let ToolState::Transforming { mode, original_transforms, pivot, start_mouse, original_bbox, current_mouse: _ } = shared.tool_state.clone() { + *shared.tool_state = ToolState::Transforming { + mode, + original_transforms: original_transforms.clone(), + pivot, + start_mouse, + current_mouse: point, + original_bbox, + }; + } + + // Apply transform in LOCAL space (much simpler!) + if let Some(layer) = shared.action_executor.document_mut().get_layer_mut(active_layer_id) { + if let AnyLayer::Vector(vector_layer) = layer { + if let Some(original) = original_transforms.get(&object_id) { + match mode { + lightningbeam_core::tool::TransformMode::ScaleCorner { origin } => { + // Use ORIGINAL transform to avoid numerical issues when scale is small + let original_transform = Affine::translate((original.x, original.y)) + * Affine::rotate(original.rotation.to_radians()) + * Affine::scale_non_uniform(original.scale_x, original.scale_y); + let inv_original_transform = original_transform.inverse(); + + // Transform mouse positions to local space using original transform + let local_start = inv_original_transform * start_mouse; + let local_current = inv_original_transform * point; + let local_origin = inv_original_transform * origin; + + // Calculate scale in local space + let start_dx = local_start.x - local_origin.x; + let start_dy = local_start.y - local_origin.y; + let current_dx = local_current.x - local_origin.x; + let current_dy = local_current.y - local_origin.y; + + let scale_x = if start_dx.abs() > 0.001 { + current_dx / start_dx + } else { + 1.0 + }; + + let scale_y = if start_dy.abs() > 0.001 { + current_dy / start_dy + } else { + 1.0 + }; + + // Calculate new scale values + let new_scale_x = original.scale_x * scale_x; + let new_scale_y = original.scale_y * scale_y; + + // Clamp to minimum absolute value while preserving sign (for flipping) + const MIN_SCALE: f64 = 0.01; + let new_scale_x = if new_scale_x.abs() < MIN_SCALE { + MIN_SCALE * new_scale_x.signum() + } else { + new_scale_x + }; + let new_scale_y = if new_scale_y.abs() < MIN_SCALE { + MIN_SCALE * new_scale_y.signum() + } else { + new_scale_y + }; + + // To keep the opposite corner fixed, we need to adjust position + // Transform the origin point with OLD transform + let old_transform = Affine::translate((original.x, original.y)) + * Affine::rotate(original.rotation.to_radians()) + * Affine::scale_non_uniform(original.scale_x, original.scale_y); + let world_origin_before = old_transform * local_origin; + + // Transform the origin point with NEW transform (new scale) + let new_transform = Affine::translate((original.x, original.y)) + * Affine::rotate(original.rotation.to_radians()) + * Affine::scale_non_uniform(new_scale_x, new_scale_y); + let world_origin_after = new_transform * local_origin; + + // Adjust position to keep origin fixed + let pos_offset_x = world_origin_before.x - world_origin_after.x; + let pos_offset_y = world_origin_before.y - world_origin_after.y; + + // Apply scale and position adjustment + vector_layer.modify_object_internal(&object_id, |obj| { + obj.transform.scale_x = new_scale_x; + obj.transform.scale_y = new_scale_y; + obj.transform.x = original.x + pos_offset_x; + obj.transform.y = original.y + pos_offset_y; + obj.transform.rotation = original.rotation; + }); + } + lightningbeam_core::tool::TransformMode::Rotate { center } => { + // Calculate rotation angle change + let start_vec = start_mouse - center; + let current_vec = point - center; + + let start_angle = start_vec.y.atan2(start_vec.x); + let current_angle = current_vec.y.atan2(current_vec.x); + let delta_angle = (current_angle - start_angle).to_degrees(); + + // Calculate the visual center of the shape in world space (before rotation) + let local_center = vello::kurbo::Point::new( + (local_bbox.x0 + local_bbox.x1) / 2.0, + (local_bbox.y0 + local_bbox.y1) / 2.0, + ); + + // Transform local center to world space with ORIGINAL transform + let original_transform = Affine::translate((original.x, original.y)) + * Affine::rotate(original.rotation.to_radians()) + * Affine::scale_non_uniform(original.scale_x, original.scale_y); + let world_center_before = original_transform * local_center; + + // Now with NEW rotation + let new_rotation = original.rotation + delta_angle; + let new_transform = Affine::translate((original.x, original.y)) + * Affine::rotate(new_rotation.to_radians()) + * Affine::scale_non_uniform(original.scale_x, original.scale_y); + let world_center_after = new_transform * local_center; + + // Adjust position to keep the center fixed + let pos_offset_x = world_center_before.x - world_center_after.x; + let pos_offset_y = world_center_before.y - world_center_after.y; + + vector_layer.modify_object_internal(&object_id, |obj| { + obj.transform.rotation = new_rotation; + obj.transform.x = original.x + pos_offset_x; + obj.transform.y = original.y + pos_offset_y; + obj.transform.scale_x = original.scale_x; + obj.transform.scale_y = original.scale_y; + }); + } + lightningbeam_core::tool::TransformMode::ScaleEdge { axis, origin } => { + // Similar to corner scaling, but only scale along one axis + let original_transform = Affine::translate((original.x, original.y)) + * Affine::rotate(original.rotation.to_radians()) + * Affine::scale_non_uniform(original.scale_x, original.scale_y); + let inv_original_transform = original_transform.inverse(); + + let local_start = inv_original_transform * start_mouse; + let local_current = inv_original_transform * point; + let local_origin = inv_original_transform * origin; + + use lightningbeam_core::tool::Axis; + let (new_scale_x, new_scale_y) = match axis { + Axis::Horizontal => { + // Scale along X axis only + let start_dx = local_start.x - local_origin.x; + let current_dx = local_current.x - local_origin.x; + let scale_x = if start_dx.abs() > 0.001 { + current_dx / start_dx + } else { + 1.0 + }; + let new_scale_x = original.scale_x * scale_x; + const MIN_SCALE: f64 = 0.01; + let new_scale_x = if new_scale_x.abs() < MIN_SCALE { + MIN_SCALE * new_scale_x.signum() + } else { + new_scale_x + }; + (new_scale_x, original.scale_y) + } + Axis::Vertical => { + // Scale along Y axis only + let start_dy = local_start.y - local_origin.y; + let current_dy = local_current.y - local_origin.y; + let scale_y = if start_dy.abs() > 0.001 { + current_dy / start_dy + } else { + 1.0 + }; + let new_scale_y = original.scale_y * scale_y; + const MIN_SCALE: f64 = 0.01; + let new_scale_y = if new_scale_y.abs() < MIN_SCALE { + MIN_SCALE * new_scale_y.signum() + } else { + new_scale_y + }; + (original.scale_x, new_scale_y) + } + }; + + // Keep opposite edge fixed + let old_transform = Affine::translate((original.x, original.y)) + * Affine::rotate(original.rotation.to_radians()) + * Affine::scale_non_uniform(original.scale_x, original.scale_y); + let world_origin_before = old_transform * local_origin; + + let new_transform = Affine::translate((original.x, original.y)) + * Affine::rotate(original.rotation.to_radians()) + * Affine::scale_non_uniform(new_scale_x, new_scale_y); + let world_origin_after = new_transform * local_origin; + + let pos_offset_x = world_origin_before.x - world_origin_after.x; + let pos_offset_y = world_origin_before.y - world_origin_after.y; + + vector_layer.modify_object_internal(&object_id, |obj| { + obj.transform.scale_x = new_scale_x; + obj.transform.scale_y = new_scale_y; + obj.transform.x = original.x + pos_offset_x; + obj.transform.y = original.y + pos_offset_y; + obj.transform.rotation = original.rotation; + }); + } + _ => {} + } + } + } + } + } + } + + // Mouse up: finalize + if response.drag_stopped() || (ui.input(|i| i.pointer.any_released()) && matches!(shared.tool_state, ToolState::Transforming { .. })) { + if let ToolState::Transforming { original_transforms, .. } = shared.tool_state.clone() { + use std::collections::HashMap; + use lightningbeam_core::actions::TransformObjectsAction; + + let mut object_transforms = HashMap::new(); + + if let Some(AnyLayer::Vector(vector_layer)) = shared.action_executor.document().get_layer(active_layer_id) { + for (obj_id, original) in original_transforms { + if let Some(object) = vector_layer.get_object(&obj_id) { + object_transforms.insert(obj_id, (original, object.transform.clone())); + } + } + } + + if !object_transforms.is_empty() { + let action = TransformObjectsAction::new(*active_layer_id, object_transforms); + shared.pending_actions.push(Box::new(action)); + } + + *shared.tool_state = ToolState::Idle; + } + } + } + fn handle_input(&mut self, ui: &mut egui::Ui, rect: egui::Rect, shared: &mut SharedPaneState) { let response = ui.allocate_rect(rect, egui::Sense::click_and_drag()); + // Check for mouse release to complete drag operations (even if mouse is offscreen) + use lightningbeam_core::tool::ToolState; + use vello::kurbo::Point; + + if ui.input(|i| i.pointer.any_released()) { + match shared.tool_state.clone() { + ToolState::DraggingSelection { start_mouse, original_positions, .. } => { + // Get last known mouse position (will be at edge if offscreen) + if let Some(mouse_pos) = ui.input(|i| i.pointer.latest_pos()) { + let mouse_canvas_pos = mouse_pos - rect.min; + let world_pos = (mouse_canvas_pos - self.pan_offset) / self.zoom; + let point = Point::new(world_pos.x as f64, world_pos.y as f64); + + let delta = point - start_mouse; + + if delta.x.abs() > 0.01 || delta.y.abs() > 0.01 { + if let Some(active_layer_id) = shared.active_layer_id { + use std::collections::HashMap; + let mut object_positions = HashMap::new(); + + for (object_id, original_pos) in original_positions { + let new_pos = Point::new( + original_pos.x + delta.x, + original_pos.y + delta.y, + ); + object_positions.insert(object_id, (original_pos, new_pos)); + } + + use lightningbeam_core::actions::MoveObjectsAction; + let action = MoveObjectsAction::new(*active_layer_id, object_positions); + shared.pending_actions.push(Box::new(action)); + } + } + } + *shared.tool_state = ToolState::Idle; + } + ToolState::MarqueeSelecting { .. } => { + // Just cancel marquee selection if released offscreen + *shared.tool_state = ToolState::Idle; + } + _ => {} + } + } + // Only process input if mouse is over the stage pane if !response.hovered() { self.is_panning = false; @@ -737,6 +2496,18 @@ impl StagePane { Tool::Select => { self.handle_select_tool(ui, &response, world_pos, shift_held, shared); } + Tool::Rectangle => { + self.handle_rectangle_tool(ui, &response, world_pos, shift_held, ctrl_held, shared); + } + Tool::Ellipse => { + self.handle_ellipse_tool(ui, &response, world_pos, shift_held, ctrl_held, shared); + } + Tool::Draw => { + self.handle_draw_tool(ui, &response, world_pos, shared); + } + Tool::Transform => { + self.handle_transform_tool(ui, &response, world_pos, shared); + } _ => { // Other tools not implemented yet } @@ -889,11 +2660,14 @@ impl PaneRenderer for StagePane { self.pan_offset, self.zoom, self.instance_id, - shared.document.clone(), + shared.action_executor.document().clone(), shared.tool_state.clone(), *shared.active_layer_id, drag_delta, shared.selection.clone(), + *shared.fill_color, + *shared.stroke_color, + *shared.selected_tool, ); let cb = egui_wgpu::Callback::new_paint_callback(