rest of DCEL migration

This commit is contained in:
Skyler Lehmkuhl 2026-02-24 11:41:10 -05:00
parent 2739391257
commit 05966ed271
9 changed files with 599 additions and 219 deletions

View File

@ -9,7 +9,6 @@ pub mod add_layer;
pub mod add_shape; pub mod add_shape;
pub mod modify_shape_path; pub mod modify_shape_path;
pub mod move_clip_instances; pub mod move_clip_instances;
pub mod move_objects;
pub mod paint_bucket; pub mod paint_bucket;
pub mod remove_effect; pub mod remove_effect;
pub mod set_document_properties; pub mod set_document_properties;
@ -18,7 +17,6 @@ pub mod set_layer_properties;
pub mod set_shape_properties; pub mod set_shape_properties;
pub mod split_clip_instance; pub mod split_clip_instance;
pub mod transform_clip_instances; pub mod transform_clip_instances;
pub mod transform_objects;
pub mod trim_clip_instances; pub mod trim_clip_instances;
pub mod create_folder; pub mod create_folder;
pub mod rename_folder; pub mod rename_folder;
@ -27,7 +25,6 @@ pub mod move_asset_to_folder;
pub mod update_midi_notes; pub mod update_midi_notes;
pub mod loop_clip_instances; pub mod loop_clip_instances;
pub mod remove_clip_instances; pub mod remove_clip_instances;
pub mod remove_shapes;
pub mod set_keyframe; pub mod set_keyframe;
pub mod group_shapes; pub mod group_shapes;
pub mod convert_to_movie_clip; pub mod convert_to_movie_clip;
@ -39,16 +36,14 @@ pub use add_layer::AddLayerAction;
pub use add_shape::AddShapeAction; pub use add_shape::AddShapeAction;
pub use modify_shape_path::ModifyDcelAction; pub use modify_shape_path::ModifyDcelAction;
pub use move_clip_instances::MoveClipInstancesAction; pub use move_clip_instances::MoveClipInstancesAction;
pub use move_objects::MoveShapeInstancesAction;
pub use paint_bucket::PaintBucketAction; pub use paint_bucket::PaintBucketAction;
pub use remove_effect::RemoveEffectAction; pub use remove_effect::RemoveEffectAction;
pub use set_document_properties::SetDocumentPropertiesAction; pub use set_document_properties::SetDocumentPropertiesAction;
pub use set_instance_properties::{InstancePropertyChange, SetInstancePropertiesAction}; pub use set_instance_properties::{InstancePropertyChange, SetInstancePropertiesAction};
pub use set_layer_properties::{LayerProperty, SetLayerPropertiesAction}; 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 split_clip_instance::SplitClipInstanceAction;
pub use transform_clip_instances::TransformClipInstancesAction; pub use transform_clip_instances::TransformClipInstancesAction;
pub use transform_objects::TransformShapeInstancesAction;
pub use trim_clip_instances::{TrimClipInstancesAction, TrimData, TrimType}; pub use trim_clip_instances::{TrimClipInstancesAction, TrimData, TrimType};
pub use create_folder::CreateFolderAction; pub use create_folder::CreateFolderAction;
pub use rename_folder::RenameFolderAction; pub use rename_folder::RenameFolderAction;
@ -57,7 +52,6 @@ pub use move_asset_to_folder::MoveAssetToFolderAction;
pub use update_midi_notes::UpdateMidiNotesAction; pub use update_midi_notes::UpdateMidiNotesAction;
pub use loop_clip_instances::LoopClipInstancesAction; pub use loop_clip_instances::LoopClipInstancesAction;
pub use remove_clip_instances::RemoveClipInstancesAction; pub use remove_clip_instances::RemoveClipInstancesAction;
pub use remove_shapes::RemoveShapesAction;
pub use set_keyframe::SetKeyframeAction; pub use set_keyframe::SetKeyframeAction;
pub use group_shapes::GroupAction; pub use group_shapes::GroupAction;
pub use convert_to_movie_clip::ConvertToMovieClipAction; pub use convert_to_movie_clip::ConvertToMovieClipAction;

View File

@ -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<Uuid, (Point, Point)>,
}
impl MoveShapeInstancesAction {
pub fn new(layer_id: Uuid, time: f64, shape_positions: HashMap<Uuid, (Point, Point)>) -> 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)
}
}
}

View File

@ -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<Uuid>,
time: f64,
}
impl RemoveShapesAction {
pub fn new(layer_id: Uuid, shape_ids: Vec<Uuid>, 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)
}
}
}

View File

@ -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::action::Action;
use crate::dcel::{EdgeId, FaceId};
use crate::document::Document; use crate::document::Document;
use crate::layer::AnyLayer;
use crate::shape::ShapeColor; use crate::shape::ShapeColor;
use uuid::Uuid; use uuid::Uuid;
/// Property change for a shape /// Action that sets fill/stroke properties on DCEL elements.
#[derive(Clone, Debug)]
pub enum ShapePropertyChange {
FillColor(Option<ShapeColor>),
StrokeColor(Option<ShapeColor>),
StrokeWidth(f64),
}
/// Action that sets properties on a shape
/// TODO: Replace with DCEL face/edge property changes
pub struct SetShapePropertiesAction { pub struct SetShapePropertiesAction {
layer_id: Uuid, layer_id: Uuid,
shape_id: Uuid,
time: f64, time: f64,
new_value: ShapePropertyChange, change: PropertyChange,
old_value: Option<ShapePropertyChange>, old_edge_values: Vec<(EdgeId, Option<ShapeColor>, Option<f64>)>,
old_face_values: Vec<(FaceId, Option<ShapeColor>)>,
}
enum PropertyChange {
FillColor {
face_ids: Vec<FaceId>,
color: Option<ShapeColor>,
},
StrokeColor {
edge_ids: Vec<EdgeId>,
color: Option<ShapeColor>,
},
StrokeWidth {
edge_ids: Vec<EdgeId>,
width: f64,
},
} }
impl SetShapePropertiesAction { 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<FaceId>,
color: Option<ShapeColor>,
) -> Self {
Self { Self {
layer_id, layer_id,
shape_id,
time, time,
new_value, change: PropertyChange::FillColor { face_ids, color },
old_value: None, 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<ShapeColor>) -> Self { pub fn set_stroke_color(
Self::new(layer_id, shape_id, time, ShapePropertyChange::FillColor(color)) layer_id: Uuid,
time: f64,
edge_ids: Vec<EdgeId>,
color: Option<ShapeColor>,
) -> 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<ShapeColor>) -> Self { pub fn set_stroke_width(
Self::new(layer_id, shape_id, time, ShapePropertyChange::StrokeColor(color)) layer_id: Uuid,
time: f64,
edge_ids: Vec<EdgeId>,
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 { fn get_dcel_mut<'a>(
Self::new(layer_id, shape_id, time, ShapePropertyChange::StrokeWidth(width)) 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 { impl Action for SetShapePropertiesAction {
fn execute(&mut self, _document: &mut Document) -> Result<(), String> { fn execute(&mut self, document: &mut Document) -> Result<(), String> {
let _ = (&self.layer_id, &self.shape_id, self.time, &self.new_value); 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(()) Ok(())
} }
fn rollback(&mut self, _document: &mut Document) -> Result<(), String> { fn rollback(&mut self, document: &mut Document) -> Result<(), String> {
let _ = &self.old_value; 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(()) Ok(())
} }
fn description(&self) -> String { fn description(&self) -> String {
match &self.new_value { match &self.change {
ShapePropertyChange::FillColor(_) => "Set fill color".to_string(), PropertyChange::FillColor { .. } => "Set fill color".to_string(),
ShapePropertyChange::StrokeColor(_) => "Set stroke color".to_string(), PropertyChange::StrokeColor { .. } => "Set stroke color".to_string(),
ShapePropertyChange::StrokeWidth(_) => "Set stroke width".to_string(), PropertyChange::StrokeWidth { .. } => "Set stroke width".to_string(),
} }
} }
} }

View File

@ -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<Uuid, (Transform, Transform)>,
}
impl TransformShapeInstancesAction {
pub fn new(
layer_id: Uuid,
time: f64,
shape_transforms: HashMap<Uuid, (Transform, Transform)>,
) -> 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())
}
}

View File

@ -186,13 +186,15 @@ pub struct VectorLayer {
/// Base layer properties /// Base layer properties
pub layer: Layer, 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<Uuid, Shape>, pub shapes: HashMap<Uuid, Shape>,
/// 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<ShapeInstance>, pub shape_instances: Vec<ShapeInstance>,
/// Shape keyframes (sorted by time) — replaces shapes/shape_instances /// Shape keyframes (sorted by time)
#[serde(default)] #[serde(default)]
pub keyframes: Vec<ShapeKeyframe>, pub keyframes: Vec<ShapeKeyframe>,

View File

@ -1875,8 +1875,7 @@ impl EditorApp {
} }
}; };
// TODO: DCEL - paste shapes disabled during migration // TODO: DCEL - paste shapes not yet implemented
// (was: push shapes into kf.shapes, select pasted shapes)
let _ = (vector_layer, shapes); let _ = (vector_layer, shapes);
} }
ClipboardContent::MidiNotes { .. } => { ClipboardContent::MidiNotes { .. } => {
@ -2624,8 +2623,7 @@ impl EditorApp {
let mut rect_shape = Shape::new(rect_path); let mut rect_shape = Shape::new(rect_path);
rect_shape.fill_color = Some(ShapeColor::rgb(0, 0, 255)); rect_shape.fill_color = Some(ShapeColor::rgb(0, 0, 255));
// TODO: DCEL - test shape creation disabled during migration // TODO: DCEL - test shape creation not yet implemented
// (was: push shapes into kf.shapes)
let _ = (circle_shape, rect_shape); let _ = (circle_shape, rect_shape);
// Add the layer to the clip // Add the layer to the clip

View File

@ -7,7 +7,7 @@
/// - Document settings (when nothing is selected) /// - Document settings (when nothing is selected)
use eframe::egui::{self, DragValue, Ui}; 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::layer::AnyLayer;
use lightningbeam_core::shape::ShapeColor; use lightningbeam_core::shape::ShapeColor;
use lightningbeam_core::tool::{SimplifyMode, Tool}; use lightningbeam_core::tool::{SimplifyMode, Tool};
@ -283,9 +283,18 @@ impl InfopanelPane {
&mut self, &mut self,
ui: &mut Ui, ui: &mut Ui,
path: &NodePath, path: &NodePath,
_shared: &mut SharedPaneState, shared: &mut SharedPaneState,
info: &SelectionInfo, 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") egui::CollapsingHeader::new("Shape")
.id_salt(("shape", path)) .id_salt(("shape", path))
.default_open(self.shape_section_open) .default_open(self.shape_section_open)
@ -293,19 +302,30 @@ impl InfopanelPane {
self.shape_section_open = true; self.shape_section_open = true;
ui.add_space(4.0); ui.add_space(4.0);
// Fill color (read-only display for now) // Fill color
ui.horizontal(|ui| { ui.horizontal(|ui| {
ui.label("Fill:"); ui.label("Fill:");
match info.fill_color { match info.fill_color {
Some(Some(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, color.r, color.g, color.b, color.a,
); );
let (rect, _) = ui.allocate_exact_size( if egui::color_picker::color_edit_button_srgba(
egui::vec2(20.0, 20.0), ui,
egui::Sense::hover(), &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),
); );
ui.painter().rect_filled(rect, 2.0, egui_color); shared.pending_actions.push(Box::new(action));
}
} }
Some(None) => { Some(None) => {
ui.label("None"); ui.label("None");
@ -316,19 +336,30 @@ impl InfopanelPane {
} }
}); });
// Stroke color (read-only display for now) // Stroke color
ui.horizontal(|ui| { ui.horizontal(|ui| {
ui.label("Stroke:"); ui.label("Stroke:");
match info.stroke_color { match info.stroke_color {
Some(Some(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, color.r, color.g, color.b, color.a,
); );
let (rect, _) = ui.allocate_exact_size( if egui::color_picker::color_edit_button_srgba(
egui::vec2(20.0, 20.0), ui,
egui::Sense::hover(), &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),
); );
ui.painter().rect_filled(rect, 2.0, egui_color); shared.pending_actions.push(Box::new(action));
}
} }
Some(None) => { Some(None) => {
ui.label("None"); ui.label("None");
@ -339,12 +370,21 @@ impl InfopanelPane {
} }
}); });
// Stroke width (read-only display for now) // Stroke width
ui.horizontal(|ui| { ui.horizontal(|ui| {
ui.label("Stroke Width:"); ui.label("Stroke Width:");
match info.stroke_width { match info.stroke_width {
Some(width) => { Some(mut width) => {
ui.label(format!("{:.1}", 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 => { None => {
ui.label("--"); ui.label("--");

View File

@ -1476,7 +1476,77 @@ impl egui_wgpu::CallbackTrait for VelloCallback {
// For multiple objects: use axis-aligned bounding box (simpler for now) // For multiple objects: use axis-aligned bounding box (simpler for now)
let total_selected = self.ctx.selection.clip_instances().len(); 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 // Single clip instance - draw rotated bounding box
let object_id = *self.ctx.selection.clip_instances().iter().next().unwrap(); let object_id = *self.ctx.selection.clip_instances().iter().next().unwrap();
@ -4239,6 +4309,260 @@ impl StagePane {
None 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<lightningbeam_core::dcel::VertexId> =
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<lightningbeam_core::dcel::VertexId> =
selected_verts.iter().copied().collect();
let selected_edges: std::collections::HashSet<lightningbeam_core::dcel::EdgeId> =
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<lightningbeam_core::dcel::VertexId>,
selected_edges: &std::collections::HashSet<lightningbeam_core::dcel::EdgeId>,
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( fn handle_transform_tool(
&mut self, &mut self,
ui: &mut egui::Ui, ui: &mut egui::Ui,
@ -4285,6 +4609,12 @@ impl StagePane {
return; 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 // For vector layers: single object uses rotated bbox, multiple objects use axis-aligned bbox
let total_selected = shared.selection.clip_instances().len(); let total_selected = shared.selection.clip_instances().len();
if total_selected == 1 { 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 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() { if let ToolState::Transforming { original_transforms, .. } = shared.tool_state.clone() {
use std::collections::HashMap; 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(); let mut clip_instance_transforms = HashMap::new();
// Get current transforms and pair with originals // Get current transforms and pair with originals
if let Some(AnyLayer::Vector(vector_layer)) = shared.action_executor.document().get_layer(&active_layer_id) { if let Some(AnyLayer::Vector(vector_layer)) = shared.action_executor.document().get_layer(&active_layer_id) {
for (object_id, original) in original_transforms { 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) { if let Some(clip_instance) = vector_layer.clip_instances.iter().find(|ci| ci.id == object_id) {
let new_transform = clip_instance.transform.clone(); let new_transform = clip_instance.transform.clone();
clip_instance_transforms.insert(object_id, (original, new_transform)); 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 // Create action for clip instances
if !clip_instance_transforms.is_empty() { if !clip_instance_transforms.is_empty() {
let action = TransformClipInstancesAction::new(active_layer_id, *shared.playback_time, clip_instance_transforms); 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 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() { if let ToolState::Transforming { original_transforms, .. } = shared.tool_state.clone() {
use std::collections::HashMap; 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(); let mut clip_instance_transforms = HashMap::new();
if let Some(AnyLayer::Vector(vector_layer)) = shared.action_executor.document().get_layer(&active_layer_id) { if let Some(AnyLayer::Vector(vector_layer)) = shared.action_executor.document().get_layer(&active_layer_id) {
for (obj_id, original) in original_transforms { 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) { 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())); 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) { } 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 { for (obj_id, original) in original_transforms {
if let Some(clip_instance) = video_layer.clip_instances.iter().find(|ci| ci.id == obj_id) { 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())); 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 // Create action for clip instances
if !clip_instance_transforms.is_empty() { if !clip_instance_transforms.is_empty() {
let action = TransformClipInstancesAction::new(*active_layer_id, *shared.playback_time, clip_instance_transforms); 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<lightningbeam_core::dcel::EdgeId> =
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) // Distinguish between mouse wheel (discrete) and trackpad (smooth)
let mut handled = false; let mut handled = false;
ui.input(|i| { ui.input(|i| {