diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/clip_from_geometry.rs b/lightningbeam-ui/lightningbeam-core/src/actions/clip_from_geometry.rs new file mode 100644 index 0000000..fdfd36b --- /dev/null +++ b/lightningbeam-ui/lightningbeam-core/src/actions/clip_from_geometry.rs @@ -0,0 +1,98 @@ +//! Shared logic for the "Group" and "Convert to Movie Clip" actions: extract the +//! selected DCEL geometry from a vector layer's active keyframe into a new `VectorClip` +//! and drop a `ClipInstance` in its place (so it can then be motion-tweened). +//! +//! A *group* (`is_group = true`) is a static container; a *movie clip* (`is_group = +//! false`) has its own timeline. Both are tweenable via the clip instance's transform. + +use std::collections::HashSet; + +use crate::clip::{ClipInstance, VectorClip}; +use crate::document::Document; +use crate::layer::{AnyLayer, ShapeKeyframe, VectorLayer}; +use crate::vector_graph::{EdgeId, FillId, VectorGraph}; +use uuid::Uuid; + +/// Extract the selected geometry into a new clip + place a `ClipInstance`. Returns the +/// pre-extraction graph snapshot for undo. `clip_id`/`instance_id` are caller-provided +/// so undo/redo is stable. The selection sets come straight from the editor selection +/// (`select_fill` already includes each fill's boundary edges); `extract_subgraph` +/// derives which of those edges are shared with non-selected shapes. +pub fn extract_geometry_to_clip( + document: &mut Document, + layer_id: Uuid, + time: f64, + fills: &HashSet, + edges: &HashSet, + clip_id: Uuid, + instance_id: Uuid, + is_group: bool, + clip_name: &str, +) -> Result { + if fills.is_empty() && edges.is_empty() { + return Err("No geometry selected".to_string()); + } + let (doc_w, doc_h, doc_dur) = (document.width, document.height, document.duration.max(1.0)); + + // 1. Extract from the source graph (extract_subgraph removes the moved geometry). + let (graph_before, sub_graph) = { + let layer = document.get_layer_mut(&layer_id).ok_or("Layer not found")?; + let vl = match layer { + AnyLayer::Vector(vl) => vl, + _ => return Err("Not a vector layer".to_string()), + }; + let graph = vl.graph_at_time_mut(time).ok_or("No keyframe at time")?; + let before = graph.clone(); + // No explicit cut boundary — extract_subgraph derives shared-fill boundaries. + let (sub, _, _) = graph.extract_subgraph(edges, fills, &HashSet::new()); + (before, sub) + }; + + // 2. Build the clip: a vector layer whose single keyframe holds the extracted graph + // (in the source's coordinate space, so identity placement renders it in place). + let mut inner = VectorLayer::new("Layer 1"); + let mut kf = ShapeKeyframe::new(0.0); + kf.graph = sub_graph; + inner.keyframes.push(kf); + let mut clip = VectorClip::with_id(clip_id, clip_name, doc_w, doc_h, doc_dur); + clip.is_group = is_group; + clip.layers.add_root(AnyLayer::Vector(inner)); + document.add_vector_clip(clip); + + // 3. Place a ClipInstance (identity transform → geometry stays put). + let instance = ClipInstance::with_id(instance_id, clip_id); + if let Some(AnyLayer::Vector(vl)) = document.get_layer_mut(&layer_id) { + // Groups gate visibility by the active keyframe's clip_instance_ids; movie + // clips render unconditionally. + if is_group { + if let Some(kf) = vl.keyframe_at_mut(time) { + kf.clip_instance_ids.push(instance_id); + } + } + vl.clip_instances.push(instance); + } + + Ok(graph_before) +} + +/// Reverse `extract_geometry_to_clip`: remove the clip + instance and restore the graph. +pub fn undo_extract_geometry( + document: &mut Document, + layer_id: Uuid, + time: f64, + clip_id: Uuid, + instance_id: Uuid, + graph_before: &VectorGraph, +) { + document.vector_clips.remove(&clip_id); + document.rebuild_layer_to_clip_map(); + if let Some(AnyLayer::Vector(vl)) = document.get_layer_mut(&layer_id) { + vl.clip_instances.retain(|ci| ci.id != instance_id); + if let Some(kf) = vl.keyframe_at_mut(time) { + kf.clip_instance_ids.retain(|id| *id != instance_id); + } + if let Some(graph) = vl.graph_at_time_mut(time) { + *graph = graph_before.clone(); + } + } +} diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/convert_to_movie_clip.rs b/lightningbeam-ui/lightningbeam-core/src/actions/convert_to_movie_clip.rs index c9ea444..fe5133a 100644 --- a/lightningbeam-ui/lightningbeam-core/src/actions/convert_to_movie_clip.rs +++ b/lightningbeam-ui/lightningbeam-core/src/actions/convert_to_movie_clip.rs @@ -1,55 +1,57 @@ -//! Convert to Movie Clip action — STUB: needs DCEL rewrite +//! Convert to Movie Clip — extract selected geometry into a movie-clip `VectorClip` +//! (its own timeline) + a `ClipInstance` that can be motion-tweened. + +use std::collections::HashSet; use crate::action::Action; -use crate::clip::ClipInstance; +use crate::actions::clip_from_geometry::{extract_geometry_to_clip, undo_extract_geometry}; use crate::document::Document; +use crate::vector_graph::{EdgeId, FillId, VectorGraph}; use uuid::Uuid; -/// Action that converts selected items to a Movie Clip -/// TODO: Rewrite for DCEL -#[allow(dead_code)] pub struct ConvertToMovieClipAction { layer_id: Uuid, time: f64, - shape_ids: Vec, - clip_instance_ids: Vec, + fills: Vec, + edges: Vec, + clip_id: Uuid, instance_id: Uuid, - created_clip_id: Option, - removed_clip_instances: Vec, + graph_before: Option, } impl ConvertToMovieClipAction { pub fn new( layer_id: Uuid, time: f64, - shape_ids: Vec, - clip_instance_ids: Vec, + fills: Vec, + edges: Vec, + clip_id: Uuid, instance_id: Uuid, ) -> Self { - Self { - layer_id, - time, - shape_ids, - clip_instance_ids, - instance_id, - created_clip_id: None, - removed_clip_instances: Vec::new(), - } + Self { layer_id, time, fills, edges, clip_id, instance_id, graph_before: None } } } impl Action for ConvertToMovieClipAction { - fn execute(&mut self, _document: &mut Document) -> Result<(), String> { - let _ = (&self.layer_id, self.time, &self.shape_ids, &self.clip_instance_ids, self.instance_id); + fn execute(&mut self, document: &mut Document) -> Result<(), String> { + let fills: HashSet = self.fills.iter().copied().collect(); + let edges: HashSet = self.edges.iter().copied().collect(); + let before = extract_geometry_to_clip( + document, self.layer_id, self.time, &fills, &edges, + self.clip_id, self.instance_id, false, "Movie Clip", + )?; + self.graph_before = Some(before); Ok(()) } - fn rollback(&mut self, _document: &mut Document) -> Result<(), String> { + fn rollback(&mut self, document: &mut Document) -> Result<(), String> { + if let Some(before) = &self.graph_before { + undo_extract_geometry(document, self.layer_id, self.time, self.clip_id, self.instance_id, before); + } Ok(()) } fn description(&self) -> String { - let count = self.shape_ids.len() + self.clip_instance_ids.len(); - format!("Convert {} object(s) to Movie Clip", count) + "Convert to Movie Clip".to_string() } } diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/group_shapes.rs b/lightningbeam-ui/lightningbeam-core/src/actions/group_shapes.rs index ab32889..7043f84 100644 --- a/lightningbeam-ui/lightningbeam-core/src/actions/group_shapes.rs +++ b/lightningbeam-ui/lightningbeam-core/src/actions/group_shapes.rs @@ -1,56 +1,58 @@ -//! Group action — STUB: needs DCEL rewrite +//! Group action — extract selected geometry into a group `VectorClip` + a `ClipInstance`. + +use std::collections::HashSet; use crate::action::Action; -use crate::clip::ClipInstance; +use crate::actions::clip_from_geometry::{extract_geometry_to_clip, undo_extract_geometry}; use crate::document::Document; +use crate::vector_graph::{EdgeId, FillId, VectorGraph}; use uuid::Uuid; -/// Action that groups selected shapes and/or clip instances into a VectorClip -/// TODO: Rewrite for DCEL (group DCEL faces/edges into a sub-clip) -#[allow(dead_code)] +/// Groups the selected DCEL geometry (fills/edges) of a vector layer's active keyframe +/// into a new group clip, placing a clip instance in its place (which can be tweened). pub struct GroupAction { layer_id: Uuid, time: f64, - shape_ids: Vec, - clip_instance_ids: Vec, + fills: Vec, + edges: Vec, + clip_id: Uuid, instance_id: Uuid, - created_clip_id: Option, - removed_clip_instances: Vec, + graph_before: Option, } impl GroupAction { pub fn new( layer_id: Uuid, time: f64, - shape_ids: Vec, - clip_instance_ids: Vec, + fills: Vec, + edges: Vec, + clip_id: Uuid, instance_id: Uuid, ) -> Self { - Self { - layer_id, - time, - shape_ids, - clip_instance_ids, - instance_id, - created_clip_id: None, - removed_clip_instances: Vec::new(), - } + Self { layer_id, time, fills, edges, clip_id, instance_id, graph_before: None } } } impl Action for GroupAction { - fn execute(&mut self, _document: &mut Document) -> Result<(), String> { - let _ = (&self.layer_id, self.time, &self.shape_ids, &self.clip_instance_ids, self.instance_id); - // TODO: Implement DCEL-aware grouping + fn execute(&mut self, document: &mut Document) -> Result<(), String> { + let fills: HashSet = self.fills.iter().copied().collect(); + let edges: HashSet = self.edges.iter().copied().collect(); + let before = extract_geometry_to_clip( + document, self.layer_id, self.time, &fills, &edges, + self.clip_id, self.instance_id, true, "Group", + )?; + self.graph_before = Some(before); Ok(()) } - fn rollback(&mut self, _document: &mut Document) -> Result<(), String> { + fn rollback(&mut self, document: &mut Document) -> Result<(), String> { + if let Some(before) = &self.graph_before { + undo_extract_geometry(document, self.layer_id, self.time, self.clip_id, self.instance_id, before); + } Ok(()) } fn description(&self) -> String { - let count = self.shape_ids.len() + self.clip_instance_ids.len(); - format!("Group {} objects", count) + "Group".to_string() } } diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/mod.rs b/lightningbeam-ui/lightningbeam-core/src/actions/mod.rs index 0c96a79..1663b49 100644 --- a/lightningbeam-ui/lightningbeam-core/src/actions/mod.rs +++ b/lightningbeam-ui/lightningbeam-core/src/actions/mod.rs @@ -34,6 +34,7 @@ pub mod convert_to_movie_clip; pub mod region_split; pub mod toggle_group_expansion; pub mod group_layers; +pub mod clip_from_geometry; pub mod raster_diff; pub mod raster_stroke; pub mod raster_fill; diff --git a/lightningbeam-ui/lightningbeam-core/src/vector_graph/mod.rs b/lightningbeam-ui/lightningbeam-core/src/vector_graph/mod.rs index 5478004..4aeb980 100644 --- a/lightningbeam-ui/lightningbeam-core/src/vector_graph/mod.rs +++ b/lightningbeam-ui/lightningbeam-core/src/vector_graph/mod.rs @@ -1458,11 +1458,14 @@ impl VectorGraph { // ── Region selection: extract / merge subgraph ────────────────────── - /// Extract a subgraph containing `inside_edges` and `inside_fills`. + /// Extract a subgraph containing `inside_edges` and `inside_fills` (typically a + /// geometry selection — `select_fill` already includes each fill's boundary edges). /// - /// Boundary edges (`boundary_edge_ids`) are **duplicated** — they exist in - /// both the returned graph and `self`, so both sides have closed fill - /// boundaries when the selection is moved. + /// **Boundary edges** are *duplicated* (copied into the returned graph but kept in + /// `self`, so remaining shapes keep closed boundaries). They are `explicit_boundary` + /// (a cut the caller knows about, e.g. a lasso region — pass an empty set if none) + /// UNION any inside edge still shared with a non-extracted fill (derived here, so a + /// plain geometry selection needs no boundary analysis from the caller). /// /// Returns `(new_graph, vertex_map, edge_map)` where the maps go from /// old (self) IDs to new (returned graph) IDs. @@ -1470,17 +1473,32 @@ impl VectorGraph { &mut self, inside_edges: &HashSet, inside_fills: &HashSet, - boundary_edge_ids: &HashSet, + explicit_boundary: &HashSet, ) -> (VectorGraph, HashMap, HashMap) { let mut new_graph = VectorGraph::new(); let mut vtx_map: HashMap = HashMap::new(); let mut edge_map: HashMap = HashMap::new(); - // Collect all edge IDs we need to copy into the new graph - let edges_to_copy: HashSet = inside_edges - .union(boundary_edge_ids) - .copied() - .collect(); + // Boundary = `explicit_boundary` (e.g. a region/lasso cut the caller knows about) + // UNION any inside edge still referenced by a fill we're NOT extracting (a shared + // DCEL edge — must be duplicated, not moved, or that fill dangles). Deriving the + // latter here means a plain geometry selection needs no boundary analysis. + let mut boundary_edge_ids: HashSet = explicit_boundary.clone(); + for (i, fill) in self.fills.iter().enumerate() { + if fill.deleted || inside_fills.contains(&FillId(i as u32)) { + continue; + } + for &(eid, _) in &fill.boundary { + if !eid.is_none() && inside_edges.contains(&eid) { + boundary_edge_ids.insert(eid); + } + } + } + let boundary_edge_ids = &boundary_edge_ids; + + // Copy all inside edges + any boundary edges (the explicit ones may not be in + // inside_edges); boundary edges are kept in self below. + let edges_to_copy: HashSet = inside_edges.union(boundary_edge_ids).copied().collect(); // Collect all vertices referenced by edges we're copying let mut referenced_vids: HashSet = HashSet::new(); @@ -1566,9 +1584,10 @@ impl VectorGraph { new_graph.fills[new_fid.idx()].image_fill = fill.image_fill; } - // Remove inside_edges from self (but NOT boundary edges — those are duplicated) + // Remove inside_edges from self, EXCEPT boundary edges (those are duplicated — + // a non-extracted fill still needs them). for &eid in inside_edges { - if !eid.is_none() && !self.edges[eid.idx()].deleted { + if !eid.is_none() && !boundary_edge_ids.contains(&eid) && !self.edges[eid.idx()].deleted { self.free_edge(eid); } } diff --git a/lightningbeam-ui/lightningbeam-editor/src/main.rs b/lightningbeam-ui/lightningbeam-editor/src/main.rs index 7ab6f99..1405ad8 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/main.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/main.rs @@ -3471,26 +3471,24 @@ impl EditorApp { self.pending_node_group = true; } _ => { - // Existing clip instance grouping fallback (stub) + // Group selected geometry into a group clip + clip instance. if let Some(layer_id) = self.active_layer_id { if self.selection.has_geometry_selection() { - // TODO: DCEL group deferred to Phase 2 - } else { - let clip_ids: Vec = self.selection.clip_instances().to_vec(); - if clip_ids.len() >= 2 { - let instance_id = uuid::Uuid::new_v4(); - let action = lightningbeam_core::actions::GroupAction::new( - layer_id, self.playback_time, Vec::new(), clip_ids, instance_id, - ); - if let Err(e) = self.action_executor.execute(Box::new(action)) { - eprintln!("Failed to group: {}", e); - } else { - self.selection.clear(); - self.selection.add_clip_instance(instance_id); - } + let fills: Vec<_> = self.selection.selected_fills().iter().copied().collect(); + let edges: Vec<_> = self.selection.selected_edges().iter().copied().collect(); + let clip_id = uuid::Uuid::new_v4(); + let instance_id = uuid::Uuid::new_v4(); + let action = lightningbeam_core::actions::GroupAction::new( + layer_id, self.playback_time, fills, edges, clip_id, instance_id, + ); + if let Err(e) = self.action_executor.execute(Box::new(action)) { + eprintln!("Failed to group: {}", e); + } else { + self.selection.clear(); + self.selection.add_clip_instance(instance_id); } } - let _ = layer_id; + // (Grouping existing clip instances is a separate op, not wired.) } } } @@ -3498,24 +3496,18 @@ impl EditorApp { MenuAction::ConvertToMovieClip => { if let Some(layer_id) = self.active_layer_id { if self.selection.has_geometry_selection() { - // TODO: DCEL convert-to-movie-clip deferred to Phase 2 - } else { - let clip_ids: Vec = self.selection.clip_instances().to_vec(); - if clip_ids.len() >= 1 { - let instance_id = uuid::Uuid::new_v4(); - let action = lightningbeam_core::actions::ConvertToMovieClipAction::new( - layer_id, - self.playback_time, - Vec::new(), - clip_ids, - instance_id, - ); - if let Err(e) = self.action_executor.execute(Box::new(action)) { - eprintln!("Failed to convert to movie clip: {}", e); - } else { - self.selection.clear(); - self.selection.add_clip_instance(instance_id); - } + let fills: Vec<_> = self.selection.selected_fills().iter().copied().collect(); + let edges: Vec<_> = self.selection.selected_edges().iter().copied().collect(); + let clip_id = uuid::Uuid::new_v4(); + let instance_id = uuid::Uuid::new_v4(); + let action = lightningbeam_core::actions::ConvertToMovieClipAction::new( + layer_id, self.playback_time, fills, edges, clip_id, instance_id, + ); + if let Err(e) = self.action_executor.execute(Box::new(action)) { + eprintln!("Failed to convert to movie clip: {}", e); + } else { + self.selection.clear(); + self.selection.add_clip_instance(instance_id); } } }