diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/mod.rs b/lightningbeam-ui/lightningbeam-core/src/actions/mod.rs index 2a60f72..bfab90e 100644 --- a/lightningbeam-ui/lightningbeam-core/src/actions/mod.rs +++ b/lightningbeam-ui/lightningbeam-core/src/actions/mod.rs @@ -9,7 +9,6 @@ pub mod add_layer; pub mod add_shape; pub mod modify_shape_path; pub mod move_clip_instances; -pub mod move_objects; pub mod paint_bucket; pub mod remove_effect; pub mod set_document_properties; @@ -18,7 +17,6 @@ pub mod set_layer_properties; pub mod set_shape_properties; pub mod split_clip_instance; pub mod transform_clip_instances; -pub mod transform_objects; pub mod trim_clip_instances; pub mod create_folder; pub mod rename_folder; @@ -27,7 +25,6 @@ pub mod move_asset_to_folder; pub mod update_midi_notes; pub mod loop_clip_instances; pub mod remove_clip_instances; -pub mod remove_shapes; pub mod set_keyframe; pub mod group_shapes; pub mod convert_to_movie_clip; @@ -39,16 +36,14 @@ pub use add_layer::AddLayerAction; pub use add_shape::AddShapeAction; pub use modify_shape_path::ModifyDcelAction; pub use move_clip_instances::MoveClipInstancesAction; -pub use move_objects::MoveShapeInstancesAction; pub use paint_bucket::PaintBucketAction; pub use remove_effect::RemoveEffectAction; pub use set_document_properties::SetDocumentPropertiesAction; pub use set_instance_properties::{InstancePropertyChange, SetInstancePropertiesAction}; pub use set_layer_properties::{LayerProperty, SetLayerPropertiesAction}; -pub use set_shape_properties::{SetShapePropertiesAction, ShapePropertyChange}; +pub use set_shape_properties::SetShapePropertiesAction; pub use split_clip_instance::SplitClipInstanceAction; pub use transform_clip_instances::TransformClipInstancesAction; -pub use transform_objects::TransformShapeInstancesAction; pub use trim_clip_instances::{TrimClipInstancesAction, TrimData, TrimType}; pub use create_folder::CreateFolderAction; pub use rename_folder::RenameFolderAction; @@ -57,7 +52,6 @@ pub use move_asset_to_folder::MoveAssetToFolderAction; pub use update_midi_notes::UpdateMidiNotesAction; pub use loop_clip_instances::LoopClipInstancesAction; pub use remove_clip_instances::RemoveClipInstancesAction; -pub use remove_shapes::RemoveShapesAction; pub use set_keyframe::SetKeyframeAction; pub use group_shapes::GroupAction; pub use convert_to_movie_clip::ConvertToMovieClipAction; diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/move_objects.rs b/lightningbeam-ui/lightningbeam-core/src/actions/move_objects.rs deleted file mode 100644 index 9e3c54a..0000000 --- a/lightningbeam-ui/lightningbeam-core/src/actions/move_objects.rs +++ /dev/null @@ -1,45 +0,0 @@ -//! Move shapes action — STUB: needs DCEL rewrite - -use crate::action::Action; -use crate::document::Document; -use std::collections::HashMap; -use uuid::Uuid; -use vello::kurbo::Point; - -/// Action that moves shapes to new positions within a keyframe -/// TODO: Replace with DCEL vertex translation -pub struct MoveShapeInstancesAction { - layer_id: Uuid, - time: f64, - shape_positions: HashMap, -} - -impl MoveShapeInstancesAction { - pub fn new(layer_id: Uuid, time: f64, shape_positions: HashMap) -> Self { - Self { - layer_id, - time, - shape_positions, - } - } -} - -impl Action for MoveShapeInstancesAction { - fn execute(&mut self, _document: &mut Document) -> Result<(), String> { - let _ = (&self.layer_id, self.time, &self.shape_positions); - Ok(()) - } - - fn rollback(&mut self, _document: &mut Document) -> Result<(), String> { - Ok(()) - } - - fn description(&self) -> String { - let count = self.shape_positions.len(); - if count == 1 { - "Move shape".to_string() - } else { - format!("Move {} shapes", count) - } - } -} diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/remove_shapes.rs b/lightningbeam-ui/lightningbeam-core/src/actions/remove_shapes.rs deleted file mode 100644 index b2b5fdc..0000000 --- a/lightningbeam-ui/lightningbeam-core/src/actions/remove_shapes.rs +++ /dev/null @@ -1,43 +0,0 @@ -//! Remove shapes action — STUB: needs DCEL rewrite - -use crate::action::Action; -use crate::document::Document; -use uuid::Uuid; - -/// Action that removes shapes from a vector layer's keyframe -/// TODO: Replace with DCEL edge/face removal actions -pub struct RemoveShapesAction { - layer_id: Uuid, - shape_ids: Vec, - time: f64, -} - -impl RemoveShapesAction { - pub fn new(layer_id: Uuid, shape_ids: Vec, time: f64) -> Self { - Self { - layer_id, - shape_ids, - time, - } - } -} - -impl Action for RemoveShapesAction { - fn execute(&mut self, _document: &mut Document) -> Result<(), String> { - let _ = (&self.layer_id, &self.shape_ids, self.time); - Ok(()) - } - - fn rollback(&mut self, _document: &mut Document) -> Result<(), String> { - Ok(()) - } - - fn description(&self) -> String { - let count = self.shape_ids.len(); - if count == 1 { - "Delete shape".to_string() - } else { - format!("Delete {} shapes", count) - } - } -} diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/set_shape_properties.rs b/lightningbeam-ui/lightningbeam-core/src/actions/set_shape_properties.rs index 843b714..d258357 100644 --- a/lightningbeam-ui/lightningbeam-core/src/actions/set_shape_properties.rs +++ b/lightningbeam-ui/lightningbeam-core/src/actions/set_shape_properties.rs @@ -1,68 +1,170 @@ -//! Set shape properties action — STUB: needs DCEL rewrite +//! Set shape properties action — operates on DCEL edge/face IDs. use crate::action::Action; +use crate::dcel::{EdgeId, FaceId}; use crate::document::Document; +use crate::layer::AnyLayer; use crate::shape::ShapeColor; use uuid::Uuid; -/// Property change for a shape -#[derive(Clone, Debug)] -pub enum ShapePropertyChange { - FillColor(Option), - StrokeColor(Option), - StrokeWidth(f64), -} - -/// Action that sets properties on a shape -/// TODO: Replace with DCEL face/edge property changes +/// Action that sets fill/stroke properties on DCEL elements. pub struct SetShapePropertiesAction { layer_id: Uuid, - shape_id: Uuid, time: f64, - new_value: ShapePropertyChange, - old_value: Option, + change: PropertyChange, + old_edge_values: Vec<(EdgeId, Option, Option)>, + old_face_values: Vec<(FaceId, Option)>, +} + +enum PropertyChange { + FillColor { + face_ids: Vec, + color: Option, + }, + StrokeColor { + edge_ids: Vec, + color: Option, + }, + StrokeWidth { + edge_ids: Vec, + width: f64, + }, } impl SetShapePropertiesAction { - pub fn new(layer_id: Uuid, shape_id: Uuid, time: f64, new_value: ShapePropertyChange) -> Self { + pub fn set_fill_color( + layer_id: Uuid, + time: f64, + face_ids: Vec, + color: Option, + ) -> Self { Self { layer_id, - shape_id, time, - new_value, - old_value: None, + change: PropertyChange::FillColor { face_ids, color }, + old_edge_values: Vec::new(), + old_face_values: Vec::new(), } } - pub fn set_fill_color(layer_id: Uuid, shape_id: Uuid, time: f64, color: Option) -> Self { - Self::new(layer_id, shape_id, time, ShapePropertyChange::FillColor(color)) + pub fn set_stroke_color( + layer_id: Uuid, + time: f64, + edge_ids: Vec, + color: Option, + ) -> Self { + Self { + layer_id, + time, + change: PropertyChange::StrokeColor { edge_ids, color }, + old_edge_values: Vec::new(), + old_face_values: Vec::new(), + } } - pub fn set_stroke_color(layer_id: Uuid, shape_id: Uuid, time: f64, color: Option) -> Self { - Self::new(layer_id, shape_id, time, ShapePropertyChange::StrokeColor(color)) + pub fn set_stroke_width( + layer_id: Uuid, + time: f64, + edge_ids: Vec, + width: f64, + ) -> Self { + Self { + layer_id, + time, + change: PropertyChange::StrokeWidth { edge_ids, width }, + old_edge_values: Vec::new(), + old_face_values: Vec::new(), + } } - pub fn set_stroke_width(layer_id: Uuid, shape_id: Uuid, time: f64, width: f64) -> Self { - Self::new(layer_id, shape_id, time, ShapePropertyChange::StrokeWidth(width)) + fn get_dcel_mut<'a>( + document: &'a mut Document, + layer_id: &Uuid, + time: f64, + ) -> Result<&'a mut crate::dcel::Dcel, String> { + let layer = document + .get_layer_mut(layer_id) + .ok_or_else(|| format!("Layer {} not found", layer_id))?; + let vl = match layer { + AnyLayer::Vector(vl) => vl, + _ => return Err("Not a vector layer".to_string()), + }; + vl.dcel_at_time_mut(time) + .ok_or_else(|| format!("No keyframe at time {}", time)) } } impl Action for SetShapePropertiesAction { - fn execute(&mut self, _document: &mut Document) -> Result<(), String> { - let _ = (&self.layer_id, &self.shape_id, self.time, &self.new_value); + fn execute(&mut self, document: &mut Document) -> Result<(), String> { + let dcel = Self::get_dcel_mut(document, &self.layer_id, self.time)?; + + match &self.change { + PropertyChange::FillColor { face_ids, color } => { + self.old_face_values.clear(); + for &fid in face_ids { + let face = dcel.face(fid); + self.old_face_values.push((fid, face.fill_color)); + dcel.face_mut(fid).fill_color = *color; + } + } + PropertyChange::StrokeColor { edge_ids, color } => { + self.old_edge_values.clear(); + for &eid in edge_ids { + let edge = dcel.edge(eid); + let old_width = edge.stroke_style.as_ref().map(|s| s.width); + self.old_edge_values.push((eid, edge.stroke_color, old_width)); + dcel.edge_mut(eid).stroke_color = *color; + } + } + PropertyChange::StrokeWidth { edge_ids, width } => { + self.old_edge_values.clear(); + for &eid in edge_ids { + let edge = dcel.edge(eid); + let old_width = edge.stroke_style.as_ref().map(|s| s.width); + self.old_edge_values.push((eid, edge.stroke_color, old_width)); + if let Some(ref mut style) = dcel.edge_mut(eid).stroke_style { + style.width = *width; + } + } + } + } + Ok(()) } - fn rollback(&mut self, _document: &mut Document) -> Result<(), String> { - let _ = &self.old_value; + fn rollback(&mut self, document: &mut Document) -> Result<(), String> { + let dcel = Self::get_dcel_mut(document, &self.layer_id, self.time)?; + + match &self.change { + PropertyChange::FillColor { .. } => { + for &(fid, old_color) in &self.old_face_values { + dcel.face_mut(fid).fill_color = old_color; + } + } + PropertyChange::StrokeColor { .. } => { + for &(eid, old_color, _) in &self.old_edge_values { + dcel.edge_mut(eid).stroke_color = old_color; + } + } + PropertyChange::StrokeWidth { .. } => { + for &(eid, _, old_width) in &self.old_edge_values { + if let Some(w) = old_width { + if let Some(ref mut style) = dcel.edge_mut(eid).stroke_style { + style.width = w; + } + } + } + } + } + Ok(()) } fn description(&self) -> String { - match &self.new_value { - ShapePropertyChange::FillColor(_) => "Set fill color".to_string(), - ShapePropertyChange::StrokeColor(_) => "Set stroke color".to_string(), - ShapePropertyChange::StrokeWidth(_) => "Set stroke width".to_string(), + match &self.change { + PropertyChange::FillColor { .. } => "Set fill color".to_string(), + PropertyChange::StrokeColor { .. } => "Set stroke color".to_string(), + PropertyChange::StrokeWidth { .. } => "Set stroke width".to_string(), } } } diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/transform_objects.rs b/lightningbeam-ui/lightningbeam-core/src/actions/transform_objects.rs deleted file mode 100644 index 3c99104..0000000 --- a/lightningbeam-ui/lightningbeam-core/src/actions/transform_objects.rs +++ /dev/null @@ -1,44 +0,0 @@ -//! Transform shapes action — STUB: needs DCEL rewrite - -use crate::action::Action; -use crate::document::Document; -use crate::object::Transform; -use std::collections::HashMap; -use uuid::Uuid; - -/// Action to transform multiple shapes in a keyframe -/// TODO: Replace with DCEL-based transforms (affine on vertices/edges) -pub struct TransformShapeInstancesAction { - layer_id: Uuid, - time: f64, - shape_transforms: HashMap, -} - -impl TransformShapeInstancesAction { - pub fn new( - layer_id: Uuid, - time: f64, - shape_transforms: HashMap, - ) -> Self { - Self { - layer_id, - time, - shape_transforms, - } - } -} - -impl Action for TransformShapeInstancesAction { - fn execute(&mut self, _document: &mut Document) -> Result<(), String> { - let _ = (&self.layer_id, self.time, &self.shape_transforms); - Ok(()) - } - - fn rollback(&mut self, _document: &mut Document) -> Result<(), String> { - Ok(()) - } - - fn description(&self) -> String { - format!("Transform {} shape(s)", self.shape_transforms.len()) - } -} diff --git a/lightningbeam-ui/lightningbeam-core/src/layer.rs b/lightningbeam-ui/lightningbeam-core/src/layer.rs index c77cd04..4ef9037 100644 --- a/lightningbeam-ui/lightningbeam-core/src/layer.rs +++ b/lightningbeam-ui/lightningbeam-core/src/layer.rs @@ -186,13 +186,15 @@ pub struct VectorLayer { /// Base layer properties pub layer: Layer, - /// Shapes defined in this layer (indexed by UUID for O(1) lookup) + /// Legacy shapes — kept for old .beam file compat, not written to new files. + #[serde(default, skip_serializing)] pub shapes: HashMap, - /// Shape instances (references to shapes with transforms) + /// Legacy shape instances — kept for old .beam file compat, not written to new files. + #[serde(default, skip_serializing)] pub shape_instances: Vec, - /// Shape keyframes (sorted by time) — replaces shapes/shape_instances + /// Shape keyframes (sorted by time) #[serde(default)] pub keyframes: Vec, diff --git a/lightningbeam-ui/lightningbeam-editor/src/main.rs b/lightningbeam-ui/lightningbeam-editor/src/main.rs index 00e74e7..e6ccef6 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/main.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/main.rs @@ -1875,8 +1875,7 @@ impl EditorApp { } }; - // TODO: DCEL - paste shapes disabled during migration - // (was: push shapes into kf.shapes, select pasted shapes) + // TODO: DCEL - paste shapes not yet implemented let _ = (vector_layer, shapes); } ClipboardContent::MidiNotes { .. } => { @@ -2624,8 +2623,7 @@ impl EditorApp { let mut rect_shape = Shape::new(rect_path); rect_shape.fill_color = Some(ShapeColor::rgb(0, 0, 255)); - // TODO: DCEL - test shape creation disabled during migration - // (was: push shapes into kf.shapes) + // TODO: DCEL - test shape creation not yet implemented let _ = (circle_shape, rect_shape); // Add the layer to the clip diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/infopanel.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/infopanel.rs index 2ea6905..b64d73c 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/infopanel.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/infopanel.rs @@ -7,7 +7,7 @@ /// - Document settings (when nothing is selected) use eframe::egui::{self, DragValue, Ui}; -use lightningbeam_core::actions::SetDocumentPropertiesAction; +use lightningbeam_core::actions::{SetDocumentPropertiesAction, SetShapePropertiesAction}; use lightningbeam_core::layer::AnyLayer; use lightningbeam_core::shape::ShapeColor; use lightningbeam_core::tool::{SimplifyMode, Tool}; @@ -283,9 +283,18 @@ impl InfopanelPane { &mut self, ui: &mut Ui, path: &NodePath, - _shared: &mut SharedPaneState, + shared: &mut SharedPaneState, info: &SelectionInfo, ) { + // Clone IDs and values we need before borrowing shared mutably + let layer_id = match info.layer_id { + Some(id) => id, + None => return, + }; + let time = *shared.playback_time; + let face_ids: Vec<_> = shared.selection.selected_faces().iter().copied().collect(); + let edge_ids: Vec<_> = shared.selection.selected_edges().iter().copied().collect(); + egui::CollapsingHeader::new("Shape") .id_salt(("shape", path)) .default_open(self.shape_section_open) @@ -293,19 +302,30 @@ impl InfopanelPane { self.shape_section_open = true; ui.add_space(4.0); - // Fill color (read-only display for now) + // Fill color ui.horizontal(|ui| { ui.label("Fill:"); match info.fill_color { Some(Some(color)) => { - let egui_color = egui::Color32::from_rgba_unmultiplied( + let mut egui_color = egui::Color32::from_rgba_unmultiplied( color.r, color.g, color.b, color.a, ); - let (rect, _) = ui.allocate_exact_size( - egui::vec2(20.0, 20.0), - egui::Sense::hover(), - ); - ui.painter().rect_filled(rect, 2.0, egui_color); + if egui::color_picker::color_edit_button_srgba( + ui, + &mut egui_color, + egui::color_picker::Alpha::OnlyBlend, + ).changed() { + let new_color = ShapeColor { + r: egui_color.r(), + g: egui_color.g(), + b: egui_color.b(), + a: egui_color.a(), + }; + let action = SetShapePropertiesAction::set_fill_color( + layer_id, time, face_ids.clone(), Some(new_color), + ); + shared.pending_actions.push(Box::new(action)); + } } Some(None) => { ui.label("None"); @@ -316,19 +336,30 @@ impl InfopanelPane { } }); - // Stroke color (read-only display for now) + // Stroke color ui.horizontal(|ui| { ui.label("Stroke:"); match info.stroke_color { Some(Some(color)) => { - let egui_color = egui::Color32::from_rgba_unmultiplied( + let mut egui_color = egui::Color32::from_rgba_unmultiplied( color.r, color.g, color.b, color.a, ); - let (rect, _) = ui.allocate_exact_size( - egui::vec2(20.0, 20.0), - egui::Sense::hover(), - ); - ui.painter().rect_filled(rect, 2.0, egui_color); + if egui::color_picker::color_edit_button_srgba( + ui, + &mut egui_color, + egui::color_picker::Alpha::OnlyBlend, + ).changed() { + let new_color = ShapeColor { + r: egui_color.r(), + g: egui_color.g(), + b: egui_color.b(), + a: egui_color.a(), + }; + let action = SetShapePropertiesAction::set_stroke_color( + layer_id, time, edge_ids.clone(), Some(new_color), + ); + shared.pending_actions.push(Box::new(action)); + } } Some(None) => { ui.label("None"); @@ -339,12 +370,21 @@ impl InfopanelPane { } }); - // Stroke width (read-only display for now) + // Stroke width ui.horizontal(|ui| { ui.label("Stroke Width:"); match info.stroke_width { - Some(width) => { - ui.label(format!("{:.1}", width)); + Some(mut width) => { + if ui.add( + DragValue::new(&mut width) + .speed(0.1) + .range(0.1..=100.0), + ).changed() { + let action = SetShapePropertiesAction::set_stroke_width( + layer_id, time, edge_ids.clone(), width, + ); + shared.pending_actions.push(Box::new(action)); + } } None => { ui.label("--"); diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs index 434cf43..649d1e5 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs @@ -1476,7 +1476,77 @@ impl egui_wgpu::CallbackTrait for VelloCallback { // For multiple objects: use axis-aligned bounding box (simpler for now) let total_selected = self.ctx.selection.clip_instances().len(); - if total_selected == 1 { + if self.ctx.selection.has_dcel_selection() { + // DCEL selection: compute bbox from selected vertices + if let Some(dcel) = vector_layer.dcel_at_time(self.ctx.playback_time) { + let mut min_x = f64::INFINITY; + let mut min_y = f64::INFINITY; + let mut max_x = f64::NEG_INFINITY; + let mut max_y = f64::NEG_INFINITY; + let mut found_any = false; + + for &vid in self.ctx.selection.selected_vertices() { + let v = dcel.vertex(vid); + if v.deleted { continue; } + min_x = min_x.min(v.position.x); + min_y = min_y.min(v.position.y); + max_x = max_x.max(v.position.x); + max_y = max_y.max(v.position.y); + found_any = true; + } + + if found_any { + let bbox = KurboRect::new(min_x, min_y, max_x, max_y); + let handle_size = (8.0 / self.ctx.zoom.max(0.5) as f64).max(6.0); + let handle_color = Color::from_rgb8(0, 120, 255); + let rotation_handle_offset = 20.0 / self.ctx.zoom.max(0.5) as f64; + + scene.stroke(&Stroke::new(stroke_width), overlay_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, overlay_transform, handle_color, None, &handle_rect); + scene.stroke(&Stroke::new(1.0), overlay_transform, Color::from_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, overlay_transform, handle_color, None, &edge_circle); + scene.stroke(&Stroke::new(1.0), overlay_transform, Color::from_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, overlay_transform, Color::from_rgb8(50, 200, 50), None, &rotation_circle); + scene.stroke(&Stroke::new(1.0), overlay_transform, Color::from_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), overlay_transform, Color::from_rgb8(50, 200, 50), None, &line_path); + } + } + } else if total_selected == 1 { // Single clip instance - draw rotated bounding box let object_id = *self.ctx.selection.clip_instances().iter().next().unwrap(); @@ -4239,6 +4309,260 @@ impl StagePane { None } + /// Handle transform tool for DCEL elements (vertices/edges). + /// Uses snapshot-based undo via ModifyDcelAction. + fn handle_transform_dcel( + &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; + + let time = *shared.playback_time; + + // Calculate bounding box of selected DCEL vertices + let selected_verts: Vec = + shared.selection.selected_vertices().iter().copied().collect(); + + if selected_verts.is_empty() { + return; + } + + let bbox = { + let document = shared.action_executor.document(); + if let Some(AnyLayer::Vector(vl)) = document.get_layer(active_layer_id) { + if let Some(dcel) = vl.dcel_at_time(time) { + let mut min_x = f64::MAX; + let mut min_y = f64::MAX; + let mut max_x = f64::MIN; + let mut max_y = f64::MIN; + for &vid in &selected_verts { + let v = dcel.vertex(vid); + if v.deleted { continue; } + min_x = min_x.min(v.position.x); + min_y = min_y.min(v.position.y); + max_x = max_x.max(v.position.x); + max_y = max_y.max(v.position.y); + } + if min_x > max_x { return; } + vello::kurbo::Rect::new(min_x, min_y, max_x, max_y) + } else { + return; + } + } else { + return; + } + }; + + // If already transforming, handle drag and release + match shared.tool_state.clone() { + ToolState::Transforming { mode, start_mouse, original_bbox, .. } => { + // Drag: apply transform preview to DCEL + if response.dragged() { + *shared.tool_state = ToolState::Transforming { + mode: mode.clone(), + original_transforms: std::collections::HashMap::new(), + pivot: original_bbox.center(), + start_mouse, + current_mouse: point, + original_bbox, + }; + + if let Some(ref cache) = self.dcel_editing_cache { + let original_dcel = cache.dcel_before.clone(); + let selected_verts_set: std::collections::HashSet = + selected_verts.iter().copied().collect(); + let selected_edges: std::collections::HashSet = + shared.selection.selected_edges().iter().copied().collect(); + + let affine = Self::compute_transform_affine( + &mode, start_mouse, point, &original_bbox, + ); + + let document = shared.action_executor.document_mut(); + if let Some(AnyLayer::Vector(vl)) = document.get_layer_mut(active_layer_id) { + if let Some(dcel) = vl.dcel_at_time_mut(time) { + Self::apply_dcel_transform( + dcel, &original_dcel, &selected_verts_set, &selected_edges, affine, + ); + } + } + } + } + + // Release: finalize + if response.drag_stopped() || (ui.input(|i| i.pointer.any_released()) && matches!(*shared.tool_state, ToolState::Transforming { .. })) { + if let Some(cache) = self.dcel_editing_cache.take() { + let dcel_after = { + let document = shared.action_executor.document(); + match document.get_layer(active_layer_id) { + Some(AnyLayer::Vector(vl)) => vl.dcel_at_time(time).cloned(), + _ => None, + } + }; + if let Some(dcel_after) = dcel_after { + use lightningbeam_core::actions::ModifyDcelAction; + let action = ModifyDcelAction::new( + cache.layer_id, cache.time, cache.dcel_before, dcel_after, "Transform", + ); + shared.pending_actions.push(Box::new(action)); + } + } + *shared.tool_state = ToolState::Idle; + } + + return; + } + _ => {} + } + + // Idle: check for handle clicks to start a transform + if response.drag_started() || response.clicked() { + let tolerance = 10.0; + if let Some(mode) = Self::hit_test_transform_handle(point, bbox, tolerance) { + // Snapshot DCEL for undo + let document = shared.action_executor.document(); + if let Some(AnyLayer::Vector(vl)) = document.get_layer(active_layer_id) { + if let Some(dcel) = vl.dcel_at_time(time) { + self.dcel_editing_cache = Some(DcelEditingCache { + layer_id: *active_layer_id, + time, + dcel_before: dcel.clone(), + }); + } + } + + *shared.tool_state = ToolState::Transforming { + mode, + original_transforms: std::collections::HashMap::new(), + pivot: bbox.center(), + start_mouse: point, + current_mouse: point, + original_bbox: bbox, + }; + } + } + } + + /// Compute an Affine transform from a TransformMode, start mouse, and current mouse position. + fn compute_transform_affine( + mode: &lightningbeam_core::tool::TransformMode, + start_mouse: vello::kurbo::Point, + current_mouse: vello::kurbo::Point, + original_bbox: &vello::kurbo::Rect, + ) -> vello::kurbo::Affine { + use lightningbeam_core::tool::{TransformMode, Axis}; + use vello::kurbo::Affine; + + match mode { + TransformMode::ScaleCorner { origin } => { + let start_vec = start_mouse - *origin; + let current_vec = current_mouse - *origin; + let sx = if start_vec.x.abs() > 0.001 { current_vec.x / start_vec.x } else { 1.0 }; + let sy = if start_vec.y.abs() > 0.001 { current_vec.y / start_vec.y } else { 1.0 }; + Affine::translate((origin.x, origin.y)) + * Affine::scale_non_uniform(sx, sy) + * Affine::translate((-origin.x, -origin.y)) + } + TransformMode::ScaleEdge { axis, origin } => { + let (sx, sy) = match axis { + Axis::Horizontal => { + let sd = start_mouse.x - origin.x; + let cd = current_mouse.x - origin.x; + (if sd.abs() > 0.001 { cd / sd } else { 1.0 }, 1.0) + } + Axis::Vertical => { + let sd = start_mouse.y - origin.y; + let cd = current_mouse.y - origin.y; + (1.0, if sd.abs() > 0.001 { cd / sd } else { 1.0 }) + } + }; + Affine::translate((origin.x, origin.y)) + * Affine::scale_non_uniform(sx, sy) + * Affine::translate((-origin.x, -origin.y)) + } + TransformMode::Rotate { center } => { + let start_angle = (start_mouse.y - center.y).atan2(start_mouse.x - center.x); + let current_angle = (current_mouse.y - center.y).atan2(current_mouse.x - center.x); + let delta = current_angle - start_angle; + Affine::translate((center.x, center.y)) + * Affine::rotate(delta) + * Affine::translate((-center.x, -center.y)) + } + TransformMode::Skew { axis, origin } => { + let center = original_bbox.center(); + let skew_radians = match axis { + Axis::Horizontal => { + let edge_y = if (origin.y - original_bbox.y0).abs() < 0.1 { + original_bbox.y1 + } else { + original_bbox.y0 + }; + let distance = edge_y - center.y; + if distance.abs() > 0.1 { + ((current_mouse.x - start_mouse.x) / distance).atan() + } else { + 0.0 + } + } + Axis::Vertical => { + let edge_x = if (origin.x - original_bbox.x0).abs() < 0.1 { + original_bbox.x1 + } else { + original_bbox.x0 + }; + let distance = edge_x - center.x; + if distance.abs() > 0.1 { + ((current_mouse.y - start_mouse.y) / distance).atan() + } else { + 0.0 + } + } + }; + let tan_s = skew_radians.tan(); + let (kx, ky) = match axis { + Axis::Horizontal => (tan_s, 0.0), + Axis::Vertical => (0.0, tan_s), + }; + // Skew around center: translate to center, skew, translate back + let skew = Affine::new([1.0, ky, kx, 1.0, 0.0, 0.0]); + Affine::translate((center.x, center.y)) + * skew + * Affine::translate((-center.x, -center.y)) + } + } + } + + /// Apply an affine transform to selected DCEL vertices and their connected edge control points. + /// Reads original positions from `original_dcel` and writes transformed positions to `dcel`. + fn apply_dcel_transform( + dcel: &mut lightningbeam_core::dcel::Dcel, + original_dcel: &lightningbeam_core::dcel::Dcel, + selected_verts: &std::collections::HashSet, + selected_edges: &std::collections::HashSet, + affine: vello::kurbo::Affine, + ) { + // Transform selected vertex positions + for &vid in selected_verts { + let original_pos = original_dcel.vertex(vid).position; + dcel.vertex_mut(vid).position = affine * original_pos; + } + + // Transform edge curves for selected edges + for &eid in selected_edges { + let original_curve = original_dcel.edge(eid).curve; + let edge = dcel.edge_mut(eid); + edge.curve.p0 = affine * original_curve.p0; + edge.curve.p1 = affine * original_curve.p1; + edge.curve.p2 = affine * original_curve.p2; + edge.curve.p3 = affine * original_curve.p3; + } + } + fn handle_transform_tool( &mut self, ui: &mut egui::Ui, @@ -4285,6 +4609,12 @@ impl StagePane { return; } + // For vector layers with DCEL selection, use DCEL-specific transform path + if shared.selection.has_dcel_selection() { + self.handle_transform_dcel(ui, response, point, &active_layer_id, shared); + return; + } + // For vector layers: single object uses rotated bbox, multiple objects use axis-aligned bbox let total_selected = shared.selection.clip_instances().len(); if total_selected == 1 { @@ -4451,16 +4781,13 @@ impl StagePane { 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::{TransformShapeInstancesAction, TransformClipInstancesAction}; + use lightningbeam_core::actions::TransformClipInstancesAction; - let shape_instance_transforms = HashMap::new(); let mut clip_instance_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 { - // TODO: DCEL - shape instance transform lookup disabled during migration - // Try clip instance if let Some(clip_instance) = vector_layer.clip_instances.iter().find(|ci| ci.id == object_id) { let new_transform = clip_instance.transform.clone(); clip_instance_transforms.insert(object_id, (original, new_transform)); @@ -4468,12 +4795,6 @@ impl StagePane { } } - // Create action for shape instances - if !shape_instance_transforms.is_empty() { - let action = TransformShapeInstancesAction::new(active_layer_id, *shared.playback_time, shape_instance_transforms); - shared.pending_actions.push(Box::new(action)); - } - // Create action for clip instances if !clip_instance_transforms.is_empty() { let action = TransformClipInstancesAction::new(active_layer_id, *shared.playback_time, clip_instance_transforms); @@ -5195,21 +5516,17 @@ impl StagePane { 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::{TransformShapeInstancesAction, TransformClipInstancesAction}; + use lightningbeam_core::actions::TransformClipInstancesAction; - let shape_instance_transforms = HashMap::new(); let mut clip_instance_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 { - // TODO: DCEL - shape instance transform lookup disabled during migration - // Try clip instance if let Some(clip_instance) = vector_layer.clip_instances.iter().find(|ci| ci.id == obj_id) { clip_instance_transforms.insert(obj_id, (original, clip_instance.transform.clone())); } } } else if let Some(AnyLayer::Video(video_layer)) = shared.action_executor.document().get_layer(&active_layer_id) { - // Handle Video layer clip instances for (obj_id, original) in original_transforms { if let Some(clip_instance) = video_layer.clip_instances.iter().find(|ci| ci.id == obj_id) { clip_instance_transforms.insert(obj_id, (original, clip_instance.transform.clone())); @@ -5217,12 +5534,6 @@ impl StagePane { } } - // Create action for shape instances - if !shape_instance_transforms.is_empty() { - let action = TransformShapeInstancesAction::new(*active_layer_id, *shared.playback_time, shape_instance_transforms); - shared.pending_actions.push(Box::new(action)); - } - // Create action for clip instances if !clip_instance_transforms.is_empty() { let action = TransformClipInstancesAction::new(*active_layer_id, *shared.playback_time, clip_instance_transforms); @@ -5463,6 +5774,71 @@ impl StagePane { } } + // Delete/Backspace: remove selected DCEL elements + if ui.input(|i| i.key_pressed(egui::Key::Delete) || i.key_pressed(egui::Key::Backspace)) { + if shared.selection.has_dcel_selection() { + if let Some(active_layer_id) = *shared.active_layer_id { + let time = *shared.playback_time; + + // Collect selected edge IDs before mutating + let selected_edges: Vec = + shared.selection.selected_edges().iter().copied().collect(); + + if !selected_edges.is_empty() { + // Snapshot before + let dcel_before = { + let document = shared.action_executor.document(); + match document.get_layer(&active_layer_id) { + Some(lightningbeam_core::layer::AnyLayer::Vector(vl)) => { + vl.dcel_at_time(time).cloned() + } + _ => None, + } + }; + + if let Some(dcel_before) = dcel_before { + // Remove selected edges + { + let document = shared.action_executor.document_mut(); + if let Some(lightningbeam_core::layer::AnyLayer::Vector(vl)) = document.get_layer_mut(&active_layer_id) { + if let Some(dcel) = vl.dcel_at_time_mut(time) { + for eid in &selected_edges { + dcel.remove_edge(*eid); + } + } + } + } + + // Snapshot after + let dcel_after = { + let document = shared.action_executor.document(); + match document.get_layer(&active_layer_id) { + Some(lightningbeam_core::layer::AnyLayer::Vector(vl)) => { + vl.dcel_at_time(time).cloned() + } + _ => None, + } + }; + + if let Some(dcel_after) = dcel_after { + use lightningbeam_core::actions::ModifyDcelAction; + let action = ModifyDcelAction::new( + active_layer_id, + time, + dcel_before, + dcel_after, + "Delete", + ); + shared.pending_actions.push(Box::new(action)); + } + + shared.selection.clear_dcel_selection(); + } + } + } + } + } + // Distinguish between mouse wheel (discrete) and trackpad (smooth) let mut handled = false; ui.input(|i| {