Motion tweens: implement Group + Convert to Movie Clip for DCEL geometry

Both actions were DCEL-stubs (no-ops). They now extract the selected geometry from a
vector layer's active keyframe into a new VectorClip (group vs movie clip) and place a
ClipInstance in its place (identity transform → renders where the geometry was), which
the existing transform-animation system can motion-tween.

- Shared `clip_from_geometry::extract_geometry_to_clip` (+ undo) does the work; the
  actions are thin wrappers. Undo snapshots the source graph + removes the clip/instance.
- `extract_subgraph` now DERIVES shared-fill boundary edges internally (an inside edge
  still used by a non-extracted fill must be duplicated, not moved) and unions them with
  the caller's `explicit_boundary` — so a plain geometry selection needs no boundary
  analysis (the selection already includes fill boundary edges via `select_fill`). The
  region-select caller keeps passing its lasso boundary.
- Handlers: Group / Convert to Movie Clip on a geometry selection now build these actions
  from `selected_fills`/`selected_edges`.

Next: shape tweens (same-topology lerp).
This commit is contained in:
Skyler Lehmkuhl 2026-06-21 09:12:39 -04:00
parent 0914818808
commit 0d253d9629
6 changed files with 211 additions and 97 deletions

View File

@ -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<FillId>,
edges: &HashSet<EdgeId>,
clip_id: Uuid,
instance_id: Uuid,
is_group: bool,
clip_name: &str,
) -> Result<VectorGraph, String> {
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();
}
}
}

View File

@ -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::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::document::Document;
use crate::vector_graph::{EdgeId, FillId, VectorGraph};
use uuid::Uuid; use uuid::Uuid;
/// Action that converts selected items to a Movie Clip
/// TODO: Rewrite for DCEL
#[allow(dead_code)]
pub struct ConvertToMovieClipAction { pub struct ConvertToMovieClipAction {
layer_id: Uuid, layer_id: Uuid,
time: f64, time: f64,
shape_ids: Vec<Uuid>, fills: Vec<FillId>,
clip_instance_ids: Vec<Uuid>, edges: Vec<EdgeId>,
clip_id: Uuid,
instance_id: Uuid, instance_id: Uuid,
created_clip_id: Option<Uuid>, graph_before: Option<VectorGraph>,
removed_clip_instances: Vec<ClipInstance>,
} }
impl ConvertToMovieClipAction { impl ConvertToMovieClipAction {
pub fn new( pub fn new(
layer_id: Uuid, layer_id: Uuid,
time: f64, time: f64,
shape_ids: Vec<Uuid>, fills: Vec<FillId>,
clip_instance_ids: Vec<Uuid>, edges: Vec<EdgeId>,
clip_id: Uuid,
instance_id: Uuid, instance_id: Uuid,
) -> Self { ) -> Self {
Self { Self { layer_id, time, fills, edges, clip_id, instance_id, graph_before: None }
layer_id,
time,
shape_ids,
clip_instance_ids,
instance_id,
created_clip_id: None,
removed_clip_instances: Vec::new(),
}
} }
} }
impl Action for ConvertToMovieClipAction { impl Action for ConvertToMovieClipAction {
fn execute(&mut self, _document: &mut Document) -> Result<(), String> { 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); let fills: HashSet<FillId> = self.fills.iter().copied().collect();
let edges: HashSet<EdgeId> = 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(()) 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(()) Ok(())
} }
fn description(&self) -> String { fn description(&self) -> String {
let count = self.shape_ids.len() + self.clip_instance_ids.len(); "Convert to Movie Clip".to_string()
format!("Convert {} object(s) to Movie Clip", count)
} }
} }

View File

@ -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::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::document::Document;
use crate::vector_graph::{EdgeId, FillId, VectorGraph};
use uuid::Uuid; use uuid::Uuid;
/// Action that groups selected shapes and/or clip instances into a VectorClip /// Groups the selected DCEL geometry (fills/edges) of a vector layer's active keyframe
/// TODO: Rewrite for DCEL (group DCEL faces/edges into a sub-clip) /// into a new group clip, placing a clip instance in its place (which can be tweened).
#[allow(dead_code)]
pub struct GroupAction { pub struct GroupAction {
layer_id: Uuid, layer_id: Uuid,
time: f64, time: f64,
shape_ids: Vec<Uuid>, fills: Vec<FillId>,
clip_instance_ids: Vec<Uuid>, edges: Vec<EdgeId>,
clip_id: Uuid,
instance_id: Uuid, instance_id: Uuid,
created_clip_id: Option<Uuid>, graph_before: Option<VectorGraph>,
removed_clip_instances: Vec<ClipInstance>,
} }
impl GroupAction { impl GroupAction {
pub fn new( pub fn new(
layer_id: Uuid, layer_id: Uuid,
time: f64, time: f64,
shape_ids: Vec<Uuid>, fills: Vec<FillId>,
clip_instance_ids: Vec<Uuid>, edges: Vec<EdgeId>,
clip_id: Uuid,
instance_id: Uuid, instance_id: Uuid,
) -> Self { ) -> Self {
Self { Self { layer_id, time, fills, edges, clip_id, instance_id, graph_before: None }
layer_id,
time,
shape_ids,
clip_instance_ids,
instance_id,
created_clip_id: None,
removed_clip_instances: Vec::new(),
}
} }
} }
impl Action for GroupAction { impl Action for GroupAction {
fn execute(&mut self, _document: &mut Document) -> Result<(), String> { 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); let fills: HashSet<FillId> = self.fills.iter().copied().collect();
// TODO: Implement DCEL-aware grouping let edges: HashSet<EdgeId> = 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(()) 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(()) Ok(())
} }
fn description(&self) -> String { fn description(&self) -> String {
let count = self.shape_ids.len() + self.clip_instance_ids.len(); "Group".to_string()
format!("Group {} objects", count)
} }
} }

View File

@ -34,6 +34,7 @@ pub mod convert_to_movie_clip;
pub mod region_split; pub mod region_split;
pub mod toggle_group_expansion; pub mod toggle_group_expansion;
pub mod group_layers; pub mod group_layers;
pub mod clip_from_geometry;
pub mod raster_diff; pub mod raster_diff;
pub mod raster_stroke; pub mod raster_stroke;
pub mod raster_fill; pub mod raster_fill;

View File

@ -1458,11 +1458,14 @@ impl VectorGraph {
// ── Region selection: extract / merge subgraph ────────────────────── // ── 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 /// **Boundary edges** are *duplicated* (copied into the returned graph but kept in
/// both the returned graph and `self`, so both sides have closed fill /// `self`, so remaining shapes keep closed boundaries). They are `explicit_boundary`
/// boundaries when the selection is moved. /// (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 /// Returns `(new_graph, vertex_map, edge_map)` where the maps go from
/// old (self) IDs to new (returned graph) IDs. /// old (self) IDs to new (returned graph) IDs.
@ -1470,17 +1473,32 @@ impl VectorGraph {
&mut self, &mut self,
inside_edges: &HashSet<EdgeId>, inside_edges: &HashSet<EdgeId>,
inside_fills: &HashSet<FillId>, inside_fills: &HashSet<FillId>,
boundary_edge_ids: &HashSet<EdgeId>, explicit_boundary: &HashSet<EdgeId>,
) -> (VectorGraph, HashMap<VertexId, VertexId>, HashMap<EdgeId, EdgeId>) { ) -> (VectorGraph, HashMap<VertexId, VertexId>, HashMap<EdgeId, EdgeId>) {
let mut new_graph = VectorGraph::new(); let mut new_graph = VectorGraph::new();
let mut vtx_map: HashMap<VertexId, VertexId> = HashMap::new(); let mut vtx_map: HashMap<VertexId, VertexId> = HashMap::new();
let mut edge_map: HashMap<EdgeId, EdgeId> = HashMap::new(); let mut edge_map: HashMap<EdgeId, EdgeId> = HashMap::new();
// Collect all edge IDs we need to copy into the new graph // Boundary = `explicit_boundary` (e.g. a region/lasso cut the caller knows about)
let edges_to_copy: HashSet<EdgeId> = inside_edges // UNION any inside edge still referenced by a fill we're NOT extracting (a shared
.union(boundary_edge_ids) // DCEL edge — must be duplicated, not moved, or that fill dangles). Deriving the
.copied() // latter here means a plain geometry selection needs no boundary analysis.
.collect(); let mut boundary_edge_ids: HashSet<EdgeId> = 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<EdgeId> = inside_edges.union(boundary_edge_ids).copied().collect();
// Collect all vertices referenced by edges we're copying // Collect all vertices referenced by edges we're copying
let mut referenced_vids: HashSet<VertexId> = HashSet::new(); let mut referenced_vids: HashSet<VertexId> = HashSet::new();
@ -1566,9 +1584,10 @@ impl VectorGraph {
new_graph.fills[new_fid.idx()].image_fill = fill.image_fill; 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 { 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); self.free_edge(eid);
} }
} }

View File

@ -3471,26 +3471,24 @@ impl EditorApp {
self.pending_node_group = true; 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 let Some(layer_id) = self.active_layer_id {
if self.selection.has_geometry_selection() { if self.selection.has_geometry_selection() {
// TODO: DCEL group deferred to Phase 2 let fills: Vec<_> = self.selection.selected_fills().iter().copied().collect();
} else { let edges: Vec<_> = self.selection.selected_edges().iter().copied().collect();
let clip_ids: Vec<uuid::Uuid> = self.selection.clip_instances().to_vec(); let clip_id = uuid::Uuid::new_v4();
if clip_ids.len() >= 2 { let instance_id = uuid::Uuid::new_v4();
let instance_id = uuid::Uuid::new_v4(); let action = lightningbeam_core::actions::GroupAction::new(
let action = lightningbeam_core::actions::GroupAction::new( layer_id, self.playback_time, fills, edges, clip_id, instance_id,
layer_id, self.playback_time, Vec::new(), clip_ids, instance_id, );
); if let Err(e) = self.action_executor.execute(Box::new(action)) {
if let Err(e) = self.action_executor.execute(Box::new(action)) { eprintln!("Failed to group: {}", e);
eprintln!("Failed to group: {}", e); } else {
} else { self.selection.clear();
self.selection.clear(); self.selection.add_clip_instance(instance_id);
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 => { MenuAction::ConvertToMovieClip => {
if let Some(layer_id) = self.active_layer_id { if let Some(layer_id) = self.active_layer_id {
if self.selection.has_geometry_selection() { if self.selection.has_geometry_selection() {
// TODO: DCEL convert-to-movie-clip deferred to Phase 2 let fills: Vec<_> = self.selection.selected_fills().iter().copied().collect();
} else { let edges: Vec<_> = self.selection.selected_edges().iter().copied().collect();
let clip_ids: Vec<uuid::Uuid> = self.selection.clip_instances().to_vec(); let clip_id = uuid::Uuid::new_v4();
if clip_ids.len() >= 1 { let instance_id = uuid::Uuid::new_v4();
let instance_id = uuid::Uuid::new_v4(); let action = lightningbeam_core::actions::ConvertToMovieClipAction::new(
let action = lightningbeam_core::actions::ConvertToMovieClipAction::new( layer_id, self.playback_time, fills, edges, clip_id, instance_id,
layer_id, );
self.playback_time, if let Err(e) = self.action_executor.execute(Box::new(action)) {
Vec::new(), eprintln!("Failed to convert to movie clip: {}", e);
clip_ids, } else {
instance_id, self.selection.clear();
); self.selection.add_clip_instance(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);
}
} }
} }
} }