Clips in timeline
This commit is contained in:
parent
1cb2aabc9c
commit
bbeb85b3a3
|
|
@ -0,0 +1,126 @@
|
||||||
|
//! Add layer action
|
||||||
|
//!
|
||||||
|
//! Handles adding a new layer to the document.
|
||||||
|
|
||||||
|
use crate::action::Action;
|
||||||
|
use crate::document::Document;
|
||||||
|
use crate::layer::{AnyLayer, VectorLayer};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
/// Action that adds a new layer to the document
|
||||||
|
pub struct AddLayerAction {
|
||||||
|
/// The layer to add
|
||||||
|
layer: AnyLayer,
|
||||||
|
|
||||||
|
/// ID of the created layer (set after execution)
|
||||||
|
created_layer_id: Option<Uuid>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AddLayerAction {
|
||||||
|
/// Create a new add layer action with a vector layer
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `name` - The name for the new layer
|
||||||
|
pub fn new_vector(name: impl Into<String>) -> Self {
|
||||||
|
let layer = VectorLayer::new(name);
|
||||||
|
Self {
|
||||||
|
layer: AnyLayer::Vector(layer),
|
||||||
|
created_layer_id: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a new add layer action with any layer type
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `layer` - The layer to add
|
||||||
|
pub fn new(layer: AnyLayer) -> Self {
|
||||||
|
Self {
|
||||||
|
layer,
|
||||||
|
created_layer_id: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the ID of the created layer (after execution)
|
||||||
|
pub fn created_layer_id(&self) -> Option<Uuid> {
|
||||||
|
self.created_layer_id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Action for AddLayerAction {
|
||||||
|
fn execute(&mut self, document: &mut Document) {
|
||||||
|
// Add layer to the document's root
|
||||||
|
let layer_id = document.root_mut().add_child(self.layer.clone());
|
||||||
|
|
||||||
|
// Store the ID for rollback
|
||||||
|
self.created_layer_id = Some(layer_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rollback(&mut self, document: &mut Document) {
|
||||||
|
// Remove the created layer if it exists
|
||||||
|
if let Some(layer_id) = self.created_layer_id {
|
||||||
|
document.root_mut().remove_child(&layer_id);
|
||||||
|
|
||||||
|
// Clear the stored ID
|
||||||
|
self.created_layer_id = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn description(&self) -> String {
|
||||||
|
match &self.layer {
|
||||||
|
AnyLayer::Vector(_) => "Add vector layer",
|
||||||
|
AnyLayer::Audio(_) => "Add audio layer",
|
||||||
|
AnyLayer::Video(_) => "Add video layer",
|
||||||
|
}
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_add_vector_layer() {
|
||||||
|
let mut document = Document::new("Test");
|
||||||
|
assert_eq!(document.root.children.len(), 0);
|
||||||
|
|
||||||
|
// Create and execute action
|
||||||
|
let mut action = AddLayerAction::new_vector("New Layer");
|
||||||
|
action.execute(&mut document);
|
||||||
|
|
||||||
|
// Verify layer was added
|
||||||
|
assert_eq!(document.root.children.len(), 1);
|
||||||
|
let layer = &document.root.children[0];
|
||||||
|
assert_eq!(layer.layer().name, "New Layer");
|
||||||
|
assert!(matches!(layer, AnyLayer::Vector(_)));
|
||||||
|
|
||||||
|
// Rollback
|
||||||
|
action.rollback(&mut document);
|
||||||
|
|
||||||
|
// Verify layer was removed
|
||||||
|
assert_eq!(document.root.children.len(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_add_layer_description() {
|
||||||
|
let action = AddLayerAction::new_vector("Test");
|
||||||
|
assert_eq!(action.description(), "Add vector layer");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_add_multiple_layers() {
|
||||||
|
let mut document = Document::new("Test");
|
||||||
|
|
||||||
|
let mut action1 = AddLayerAction::new_vector("Layer 1");
|
||||||
|
let mut action2 = AddLayerAction::new_vector("Layer 2");
|
||||||
|
|
||||||
|
action1.execute(&mut document);
|
||||||
|
action2.execute(&mut document);
|
||||||
|
|
||||||
|
assert_eq!(document.root.children.len(), 2);
|
||||||
|
assert_eq!(document.root.children[0].layer().name, "Layer 1");
|
||||||
|
assert_eq!(document.root.children[1].layer().name, "Layer 2");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -5,13 +5,13 @@
|
||||||
use crate::action::Action;
|
use crate::action::Action;
|
||||||
use crate::document::Document;
|
use crate::document::Document;
|
||||||
use crate::layer::AnyLayer;
|
use crate::layer::AnyLayer;
|
||||||
use crate::object::Object;
|
use crate::object::ShapeInstance;
|
||||||
use crate::shape::Shape;
|
use crate::shape::Shape;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
/// Action that adds a shape and object to a vector layer
|
/// Action that adds a shape and object to a vector layer
|
||||||
///
|
///
|
||||||
/// This action creates both a Shape (the path/geometry) and an Object
|
/// This action creates both a Shape (the path/geometry) and an ShapeInstance
|
||||||
/// (the instance with transform). Both are added to the layer.
|
/// (the instance with transform). Both are added to the layer.
|
||||||
pub struct AddShapeAction {
|
pub struct AddShapeAction {
|
||||||
/// Layer ID to add the shape to
|
/// Layer ID to add the shape to
|
||||||
|
|
@ -21,7 +21,7 @@ pub struct AddShapeAction {
|
||||||
shape: Shape,
|
shape: Shape,
|
||||||
|
|
||||||
/// The object to add (references the shape with transform)
|
/// The object to add (references the shape with transform)
|
||||||
object: Object,
|
object: ShapeInstance,
|
||||||
|
|
||||||
/// ID of the created shape (set after execution)
|
/// ID of the created shape (set after execution)
|
||||||
created_shape_id: Option<Uuid>,
|
created_shape_id: Option<Uuid>,
|
||||||
|
|
@ -38,7 +38,7 @@ impl AddShapeAction {
|
||||||
/// * `layer_id` - The layer to add the shape to
|
/// * `layer_id` - The layer to add the shape to
|
||||||
/// * `shape` - The shape to add
|
/// * `shape` - The shape to add
|
||||||
/// * `object` - The object instance referencing the shape
|
/// * `object` - The object instance referencing the shape
|
||||||
pub fn new(layer_id: Uuid, shape: Shape, object: Object) -> Self {
|
pub fn new(layer_id: Uuid, shape: Shape, object: ShapeInstance) -> Self {
|
||||||
Self {
|
Self {
|
||||||
layer_id,
|
layer_id,
|
||||||
shape,
|
shape,
|
||||||
|
|
@ -110,7 +110,7 @@ mod tests {
|
||||||
let rect = Rect::new(0.0, 0.0, 100.0, 50.0);
|
let rect = Rect::new(0.0, 0.0, 100.0, 50.0);
|
||||||
let path = rect.to_path(0.1);
|
let path = rect.to_path(0.1);
|
||||||
let shape = Shape::new(path).with_fill(ShapeColor::rgb(255, 0, 0));
|
let shape = Shape::new(path).with_fill(ShapeColor::rgb(255, 0, 0));
|
||||||
let object = Object::new(shape.id).with_position(50.0, 50.0);
|
let object = ShapeInstance::new(shape.id).with_position(50.0, 50.0);
|
||||||
|
|
||||||
// Create and execute action
|
// Create and execute action
|
||||||
let mut action = AddShapeAction::new(layer_id, shape, object);
|
let mut action = AddShapeAction::new(layer_id, shape, object);
|
||||||
|
|
@ -119,9 +119,9 @@ mod tests {
|
||||||
// Verify shape and object were added
|
// Verify shape and object were added
|
||||||
if let Some(AnyLayer::Vector(layer)) = document.get_layer(&layer_id) {
|
if let Some(AnyLayer::Vector(layer)) = document.get_layer(&layer_id) {
|
||||||
assert_eq!(layer.shapes.len(), 1);
|
assert_eq!(layer.shapes.len(), 1);
|
||||||
assert_eq!(layer.objects.len(), 1);
|
assert_eq!(layer.shape_instances.len(), 1);
|
||||||
|
|
||||||
let added_object = &layer.objects[0];
|
let added_object = &layer.shape_instances[0];
|
||||||
assert_eq!(added_object.transform.x, 50.0);
|
assert_eq!(added_object.transform.x, 50.0);
|
||||||
assert_eq!(added_object.transform.y, 50.0);
|
assert_eq!(added_object.transform.y, 50.0);
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -134,7 +134,7 @@ mod tests {
|
||||||
// Verify shape and object were removed
|
// Verify shape and object were removed
|
||||||
if let Some(AnyLayer::Vector(layer)) = document.get_layer(&layer_id) {
|
if let Some(AnyLayer::Vector(layer)) = document.get_layer(&layer_id) {
|
||||||
assert_eq!(layer.shapes.len(), 0);
|
assert_eq!(layer.shapes.len(), 0);
|
||||||
assert_eq!(layer.objects.len(), 0);
|
assert_eq!(layer.shape_instances.len(), 0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -149,7 +149,7 @@ mod tests {
|
||||||
let path = circle.to_path(0.1);
|
let path = circle.to_path(0.1);
|
||||||
let shape = Shape::new(path)
|
let shape = Shape::new(path)
|
||||||
.with_fill(ShapeColor::rgb(0, 255, 0));
|
.with_fill(ShapeColor::rgb(0, 255, 0));
|
||||||
let object = Object::new(shape.id);
|
let object = ShapeInstance::new(shape.id);
|
||||||
|
|
||||||
let mut action = AddShapeAction::new(layer_id, shape, object);
|
let mut action = AddShapeAction::new(layer_id, shape, object);
|
||||||
|
|
||||||
|
|
@ -161,7 +161,7 @@ mod tests {
|
||||||
|
|
||||||
if let Some(AnyLayer::Vector(layer)) = document.get_layer(&layer_id) {
|
if let Some(AnyLayer::Vector(layer)) = document.get_layer(&layer_id) {
|
||||||
assert_eq!(layer.shapes.len(), 1);
|
assert_eq!(layer.shapes.len(), 1);
|
||||||
assert_eq!(layer.objects.len(), 1);
|
assert_eq!(layer.shape_instances.len(), 1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -174,7 +174,7 @@ mod tests {
|
||||||
let rect = Rect::new(0.0, 0.0, 50.0, 50.0);
|
let rect = Rect::new(0.0, 0.0, 50.0, 50.0);
|
||||||
let path = rect.to_path(0.1);
|
let path = rect.to_path(0.1);
|
||||||
let shape = Shape::new(path);
|
let shape = Shape::new(path);
|
||||||
let object = Object::new(shape.id);
|
let object = ShapeInstance::new(shape.id);
|
||||||
|
|
||||||
let mut action = AddShapeAction::new(layer_id, shape, object);
|
let mut action = AddShapeAction::new(layer_id, shape, object);
|
||||||
|
|
||||||
|
|
@ -185,7 +185,7 @@ mod tests {
|
||||||
if let Some(AnyLayer::Vector(layer)) = document.get_layer(&layer_id) {
|
if let Some(AnyLayer::Vector(layer)) = document.get_layer(&layer_id) {
|
||||||
// Should have 2 shapes and 2 objects
|
// Should have 2 shapes and 2 objects
|
||||||
assert_eq!(layer.shapes.len(), 2);
|
assert_eq!(layer.shapes.len(), 2);
|
||||||
assert_eq!(layer.objects.len(), 2);
|
assert_eq!(layer.shape_instances.len(), 2);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,12 +3,20 @@
|
||||||
//! This module contains all the concrete action types that can be executed
|
//! This module contains all the concrete action types that can be executed
|
||||||
//! through the action system.
|
//! through the action system.
|
||||||
|
|
||||||
|
pub mod add_layer;
|
||||||
pub mod add_shape;
|
pub mod add_shape;
|
||||||
|
pub mod move_clip_instances;
|
||||||
pub mod move_objects;
|
pub mod move_objects;
|
||||||
pub mod paint_bucket;
|
pub mod paint_bucket;
|
||||||
|
pub mod transform_clip_instances;
|
||||||
pub mod transform_objects;
|
pub mod transform_objects;
|
||||||
|
pub mod trim_clip_instances;
|
||||||
|
|
||||||
|
pub use add_layer::AddLayerAction;
|
||||||
pub use add_shape::AddShapeAction;
|
pub use add_shape::AddShapeAction;
|
||||||
pub use move_objects::MoveObjectsAction;
|
pub use move_clip_instances::MoveClipInstancesAction;
|
||||||
|
pub use move_objects::MoveShapeInstancesAction;
|
||||||
pub use paint_bucket::PaintBucketAction;
|
pub use paint_bucket::PaintBucketAction;
|
||||||
pub use transform_objects::TransformObjectsAction;
|
pub use transform_clip_instances::TransformClipInstancesAction;
|
||||||
|
pub use transform_objects::TransformShapeInstancesAction;
|
||||||
|
pub use trim_clip_instances::{TrimClipInstancesAction, TrimData, TrimType};
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,143 @@
|
||||||
|
//! Move clip instances action
|
||||||
|
//!
|
||||||
|
//! Handles moving one or more clip instances along the timeline.
|
||||||
|
|
||||||
|
use crate::action::Action;
|
||||||
|
use crate::document::Document;
|
||||||
|
use crate::layer::AnyLayer;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
/// Action that moves clip instances to new timeline positions
|
||||||
|
pub struct MoveClipInstancesAction {
|
||||||
|
/// Map of layer IDs to vectors of (clip_instance_id, old_timeline_start, new_timeline_start)
|
||||||
|
layer_moves: HashMap<Uuid, Vec<(Uuid, f64, f64)>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MoveClipInstancesAction {
|
||||||
|
/// Create a new move clip instances action
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `layer_moves` - Map of layer IDs to vectors of (clip_instance_id, old_timeline_start, new_timeline_start)
|
||||||
|
pub fn new(layer_moves: HashMap<Uuid, Vec<(Uuid, f64, f64)>>) -> Self {
|
||||||
|
Self { layer_moves }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Action for MoveClipInstancesAction {
|
||||||
|
fn execute(&mut self, document: &mut Document) {
|
||||||
|
for (layer_id, moves) in &self.layer_moves {
|
||||||
|
let layer = match document.get_layer_mut(layer_id) {
|
||||||
|
Some(l) => l,
|
||||||
|
None => continue,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get mutable reference to clip_instances for this layer type
|
||||||
|
let clip_instances = match layer {
|
||||||
|
AnyLayer::Vector(vl) => &mut vl.clip_instances,
|
||||||
|
AnyLayer::Audio(al) => &mut al.clip_instances,
|
||||||
|
AnyLayer::Video(vl) => &mut vl.clip_instances,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Update timeline_start for each clip instance
|
||||||
|
for (clip_id, _old, new) in moves {
|
||||||
|
if let Some(clip_instance) = clip_instances.iter_mut().find(|ci| ci.id == *clip_id)
|
||||||
|
{
|
||||||
|
clip_instance.timeline_start = *new;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rollback(&mut self, document: &mut Document) {
|
||||||
|
for (layer_id, moves) in &self.layer_moves {
|
||||||
|
let layer = match document.get_layer_mut(layer_id) {
|
||||||
|
Some(l) => l,
|
||||||
|
None => continue,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get mutable reference to clip_instances for this layer type
|
||||||
|
let clip_instances = match layer {
|
||||||
|
AnyLayer::Vector(vl) => &mut vl.clip_instances,
|
||||||
|
AnyLayer::Audio(al) => &mut al.clip_instances,
|
||||||
|
AnyLayer::Video(vl) => &mut vl.clip_instances,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Restore original timeline_start for each clip instance
|
||||||
|
for (clip_id, old, _new) in moves {
|
||||||
|
if let Some(clip_instance) = clip_instances.iter_mut().find(|ci| ci.id == *clip_id)
|
||||||
|
{
|
||||||
|
clip_instance.timeline_start = *old;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn description(&self) -> String {
|
||||||
|
let total_count: usize = self.layer_moves.values().map(|v| v.len()).sum();
|
||||||
|
if total_count == 1 {
|
||||||
|
"Move clip instance".to_string()
|
||||||
|
} else {
|
||||||
|
format!("Move {} clip instances", total_count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::clip::{Clip, ClipInstance, ClipType};
|
||||||
|
use crate::layer::VectorLayer;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_move_clip_instances_action() {
|
||||||
|
// Create a document with a test clip instance
|
||||||
|
let mut document = Document::new("Test");
|
||||||
|
|
||||||
|
let clip = Clip::new(ClipType::Vector, "Test Clip", None);
|
||||||
|
let clip_id = clip.id;
|
||||||
|
|
||||||
|
let mut vector_layer = VectorLayer::new("Layer 1");
|
||||||
|
vector_layer.clips.push(clip);
|
||||||
|
|
||||||
|
let mut clip_instance = ClipInstance::new(clip_id);
|
||||||
|
clip_instance.timeline_start = 1.0; // Start at 1 second
|
||||||
|
let instance_id = clip_instance.id;
|
||||||
|
vector_layer.clip_instances.push(clip_instance);
|
||||||
|
|
||||||
|
let layer_id = document.root.add_child(AnyLayer::Vector(vector_layer));
|
||||||
|
|
||||||
|
// Create move action: move from 1.0 to 5.0 seconds
|
||||||
|
let mut layer_moves = HashMap::new();
|
||||||
|
layer_moves.insert(layer_id, vec![(instance_id, 1.0, 5.0)]);
|
||||||
|
|
||||||
|
let mut action = MoveClipInstancesAction::new(layer_moves);
|
||||||
|
|
||||||
|
// Execute
|
||||||
|
action.execute(&mut document);
|
||||||
|
|
||||||
|
// Verify position changed
|
||||||
|
if let Some(AnyLayer::Vector(layer)) = document.get_layer(&layer_id) {
|
||||||
|
let instance = layer
|
||||||
|
.clip_instances
|
||||||
|
.iter()
|
||||||
|
.find(|ci| ci.id == instance_id)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(instance.timeline_start, 5.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rollback
|
||||||
|
action.rollback(&mut document);
|
||||||
|
|
||||||
|
// Verify position restored
|
||||||
|
if let Some(AnyLayer::Vector(layer)) = document.get_layer(&layer_id) {
|
||||||
|
let instance = layer
|
||||||
|
.clip_instances
|
||||||
|
.iter()
|
||||||
|
.find(|ci| ci.id == instance_id)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(instance.timeline_start, 1.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
//! Move objects action
|
//! Move shape instances action
|
||||||
//!
|
//!
|
||||||
//! Handles moving one or more objects to new positions.
|
//! Handles moving one or more shape instances to new positions.
|
||||||
|
|
||||||
use crate::action::Action;
|
use crate::action::Action;
|
||||||
use crate::document::Document;
|
use crate::document::Document;
|
||||||
|
|
@ -9,31 +9,31 @@ use std::collections::HashMap;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
use vello::kurbo::Point;
|
use vello::kurbo::Point;
|
||||||
|
|
||||||
/// Action that moves objects to new positions
|
/// Action that moves shape instances to new positions
|
||||||
pub struct MoveObjectsAction {
|
pub struct MoveShapeInstancesAction {
|
||||||
/// Layer ID containing the objects
|
/// Layer ID containing the shape instances
|
||||||
layer_id: Uuid,
|
layer_id: Uuid,
|
||||||
|
|
||||||
/// Map of object IDs to their old and new positions
|
/// Map of object IDs to their old and new positions
|
||||||
object_positions: HashMap<Uuid, (Point, Point)>, // (old_pos, new_pos)
|
shape_instance_positions: HashMap<Uuid, (Point, Point)>, // (old_pos, new_pos)
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MoveObjectsAction {
|
impl MoveShapeInstancesAction {
|
||||||
/// Create a new move objects action
|
/// Create a new move shape instances action
|
||||||
///
|
///
|
||||||
/// # Arguments
|
/// # Arguments
|
||||||
///
|
///
|
||||||
/// * `layer_id` - The layer containing the objects
|
/// * `layer_id` - The layer containing the shape instances
|
||||||
/// * `object_positions` - Map of object IDs to (old_position, new_position)
|
/// * `shape_instance_positions` - Map of object IDs to (old_position, new_position)
|
||||||
pub fn new(layer_id: Uuid, object_positions: HashMap<Uuid, (Point, Point)>) -> Self {
|
pub fn new(layer_id: Uuid, shape_instance_positions: HashMap<Uuid, (Point, Point)>) -> Self {
|
||||||
Self {
|
Self {
|
||||||
layer_id,
|
layer_id,
|
||||||
object_positions,
|
shape_instance_positions,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Action for MoveObjectsAction {
|
impl Action for MoveShapeInstancesAction {
|
||||||
fn execute(&mut self, document: &mut Document) {
|
fn execute(&mut self, document: &mut Document) {
|
||||||
let layer = match document.get_layer_mut(&self.layer_id) {
|
let layer = match document.get_layer_mut(&self.layer_id) {
|
||||||
Some(l) => l,
|
Some(l) => l,
|
||||||
|
|
@ -41,8 +41,8 @@ impl Action for MoveObjectsAction {
|
||||||
};
|
};
|
||||||
|
|
||||||
if let AnyLayer::Vector(vector_layer) = layer {
|
if let AnyLayer::Vector(vector_layer) = layer {
|
||||||
for (object_id, (_old, new)) in &self.object_positions {
|
for (shape_instance_id, (_old, new)) in &self.shape_instance_positions {
|
||||||
vector_layer.modify_object_internal(object_id, |obj| {
|
vector_layer.modify_object_internal(shape_instance_id, |obj| {
|
||||||
obj.transform.x = new.x;
|
obj.transform.x = new.x;
|
||||||
obj.transform.y = new.y;
|
obj.transform.y = new.y;
|
||||||
});
|
});
|
||||||
|
|
@ -57,8 +57,8 @@ impl Action for MoveObjectsAction {
|
||||||
};
|
};
|
||||||
|
|
||||||
if let AnyLayer::Vector(vector_layer) = layer {
|
if let AnyLayer::Vector(vector_layer) = layer {
|
||||||
for (object_id, (old, _new)) in &self.object_positions {
|
for (shape_instance_id, (old, _new)) in &self.shape_instance_positions {
|
||||||
vector_layer.modify_object_internal(object_id, |obj| {
|
vector_layer.modify_object_internal(shape_instance_id, |obj| {
|
||||||
obj.transform.x = old.x;
|
obj.transform.x = old.x;
|
||||||
obj.transform.y = old.y;
|
obj.transform.y = old.y;
|
||||||
});
|
});
|
||||||
|
|
@ -67,11 +67,11 @@ impl Action for MoveObjectsAction {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn description(&self) -> String {
|
fn description(&self) -> String {
|
||||||
let count = self.object_positions.len();
|
let count = self.shape_instance_positions.len();
|
||||||
if count == 1 {
|
if count == 1 {
|
||||||
"Move object".to_string()
|
"Move shape instance".to_string()
|
||||||
} else {
|
} else {
|
||||||
format!("Move {} objects", count)
|
format!("Move {} shape instances", count)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -80,40 +80,40 @@ impl Action for MoveObjectsAction {
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::layer::VectorLayer;
|
use crate::layer::VectorLayer;
|
||||||
use crate::object::Object;
|
use crate::object::ShapeInstance;
|
||||||
use crate::shape::Shape;
|
use crate::shape::Shape;
|
||||||
use vello::kurbo::{Circle, Shape as KurboShape};
|
use vello::kurbo::{Circle, Shape as KurboShape};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_move_objects_action() {
|
fn test_move_shape_instances_action() {
|
||||||
// Create a document with a test object
|
// Create a document with a test object
|
||||||
let mut document = Document::new("Test");
|
let mut document = Document::new("Test");
|
||||||
|
|
||||||
let circle = Circle::new((100.0, 100.0), 50.0);
|
let circle = Circle::new((100.0, 100.0), 50.0);
|
||||||
let path = circle.to_path(0.1);
|
let path = circle.to_path(0.1);
|
||||||
let shape = Shape::new(path);
|
let shape = Shape::new(path);
|
||||||
let object = Object::new(shape.id).with_position(50.0, 50.0);
|
let object = ShapeInstance::new(shape.id).with_position(50.0, 50.0);
|
||||||
|
|
||||||
let mut vector_layer = VectorLayer::new("Layer 1");
|
let mut vector_layer = VectorLayer::new("Layer 1");
|
||||||
vector_layer.add_shape(shape);
|
vector_layer.add_shape(shape);
|
||||||
let object_id = vector_layer.add_object(object);
|
let shape_instance_id = vector_layer.add_object(object);
|
||||||
let layer_id = document.root.add_child(AnyLayer::Vector(vector_layer));
|
let layer_id = document.root.add_child(AnyLayer::Vector(vector_layer));
|
||||||
|
|
||||||
// Create move action
|
// Create move action
|
||||||
let mut positions = HashMap::new();
|
let mut positions = HashMap::new();
|
||||||
positions.insert(
|
positions.insert(
|
||||||
object_id,
|
shape_instance_id,
|
||||||
(Point::new(50.0, 50.0), Point::new(150.0, 200.0))
|
(Point::new(50.0, 50.0), Point::new(150.0, 200.0))
|
||||||
);
|
);
|
||||||
|
|
||||||
let mut action = MoveObjectsAction::new(layer_id, positions);
|
let mut action = MoveShapeInstancesAction::new(layer_id, positions);
|
||||||
|
|
||||||
// Execute
|
// Execute
|
||||||
action.execute(&mut document);
|
action.execute(&mut document);
|
||||||
|
|
||||||
// Verify position changed
|
// Verify position changed
|
||||||
if let Some(AnyLayer::Vector(layer)) = document.get_layer(&layer_id) {
|
if let Some(AnyLayer::Vector(layer)) = document.get_layer(&layer_id) {
|
||||||
let obj = layer.get_object(&object_id).unwrap();
|
let obj = layer.get_object(&shape_instance_id).unwrap();
|
||||||
assert_eq!(obj.transform.x, 150.0);
|
assert_eq!(obj.transform.x, 150.0);
|
||||||
assert_eq!(obj.transform.y, 200.0);
|
assert_eq!(obj.transform.y, 200.0);
|
||||||
}
|
}
|
||||||
|
|
@ -123,7 +123,7 @@ mod tests {
|
||||||
|
|
||||||
// Verify position restored
|
// Verify position restored
|
||||||
if let Some(AnyLayer::Vector(layer)) = document.get_layer(&layer_id) {
|
if let Some(AnyLayer::Vector(layer)) = document.get_layer(&layer_id) {
|
||||||
let obj = layer.get_object(&object_id).unwrap();
|
let obj = layer.get_object(&shape_instance_id).unwrap();
|
||||||
assert_eq!(obj.transform.x, 50.0);
|
assert_eq!(obj.transform.x, 50.0);
|
||||||
assert_eq!(obj.transform.y, 50.0);
|
assert_eq!(obj.transform.y, 50.0);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ use crate::curve_segment::CurveSegment;
|
||||||
use crate::document::Document;
|
use crate::document::Document;
|
||||||
use crate::gap_handling::GapHandlingMode;
|
use crate::gap_handling::GapHandlingMode;
|
||||||
use crate::layer::AnyLayer;
|
use crate::layer::AnyLayer;
|
||||||
use crate::object::Object;
|
use crate::object::ShapeInstance;
|
||||||
use crate::planar_graph::PlanarGraph;
|
use crate::planar_graph::PlanarGraph;
|
||||||
use crate::shape::ShapeColor;
|
use crate::shape::ShapeColor;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
@ -34,8 +34,8 @@ pub struct PaintBucketAction {
|
||||||
/// ID of the created shape (set after execution)
|
/// ID of the created shape (set after execution)
|
||||||
created_shape_id: Option<Uuid>,
|
created_shape_id: Option<Uuid>,
|
||||||
|
|
||||||
/// ID of the created object (set after execution)
|
/// ID of the created shape instance (set after execution)
|
||||||
created_object_id: Option<Uuid>,
|
created_shape_instance_id: Option<Uuid>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PaintBucketAction {
|
impl PaintBucketAction {
|
||||||
|
|
@ -62,7 +62,7 @@ impl PaintBucketAction {
|
||||||
tolerance,
|
tolerance,
|
||||||
gap_mode,
|
gap_mode,
|
||||||
created_shape_id: None,
|
created_shape_id: None,
|
||||||
created_object_id: None,
|
created_shape_instance_id: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -74,10 +74,10 @@ impl Action for PaintBucketAction {
|
||||||
// Optimization: Check if we're clicking on an existing shape first
|
// Optimization: Check if we're clicking on an existing shape first
|
||||||
// This is much faster than building a planar graph
|
// This is much faster than building a planar graph
|
||||||
if let Some(AnyLayer::Vector(vector_layer)) = document.get_layer_mut(&self.layer_id) {
|
if let Some(AnyLayer::Vector(vector_layer)) = document.get_layer_mut(&self.layer_id) {
|
||||||
// Iterate through objects in reverse order (topmost first)
|
// Iterate through shape instances in reverse order (topmost first)
|
||||||
for object in vector_layer.objects.iter().rev() {
|
for shape_instance in vector_layer.shape_instances.iter().rev() {
|
||||||
// Find the corresponding shape
|
// Find the corresponding shape (O(1) HashMap lookup)
|
||||||
if let Some(shape) = vector_layer.shapes.iter().find(|s| s.id == object.shape_id) {
|
if let Some(shape) = vector_layer.shapes.get(&shape_instance.shape_id) {
|
||||||
// Skip shapes without fill color (e.g., lines with only stroke)
|
// Skip shapes without fill color (e.g., lines with only stroke)
|
||||||
if shape.fill_color.is_none() {
|
if shape.fill_color.is_none() {
|
||||||
continue;
|
continue;
|
||||||
|
|
@ -92,8 +92,8 @@ impl Action for PaintBucketAction {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply the object's transform to get the transformed path
|
// Apply the shape instance's transform to get the transformed path
|
||||||
let transform_affine = object.transform.to_affine();
|
let transform_affine = shape_instance.transform.to_affine();
|
||||||
|
|
||||||
// Transform the click point to shape's local coordinates (inverse transform)
|
// Transform the click point to shape's local coordinates (inverse transform)
|
||||||
let inverse_transform = transform_affine.inverse();
|
let inverse_transform = transform_affine.inverse();
|
||||||
|
|
@ -110,8 +110,8 @@ impl Action for PaintBucketAction {
|
||||||
// Store the shape ID before the immutable borrow ends
|
// Store the shape ID before the immutable borrow ends
|
||||||
let shape_id = shape.id;
|
let shape_id = shape.id;
|
||||||
|
|
||||||
// Find mutable reference to the shape and update its fill
|
// Find mutable reference to the shape and update its fill (O(1) HashMap lookup)
|
||||||
if let Some(shape_mut) = vector_layer.shapes.iter_mut().find(|s| s.id == shape_id) {
|
if let Some(shape_mut) = vector_layer.shapes.get_mut(&shape_id) {
|
||||||
shape_mut.fill_color = Some(self.fill_color);
|
shape_mut.fill_color = Some(self.fill_color);
|
||||||
println!("Updated shape fill color");
|
println!("Updated shape fill color");
|
||||||
}
|
}
|
||||||
|
|
@ -154,20 +154,20 @@ impl Action for PaintBucketAction {
|
||||||
|
|
||||||
println!("DEBUG: Face shape created with fill_color: {:?}", face_shape.fill_color);
|
println!("DEBUG: Face shape created with fill_color: {:?}", face_shape.fill_color);
|
||||||
|
|
||||||
let face_object = Object::new(face_shape.id);
|
let face_shape_instance = ShapeInstance::new(face_shape.id);
|
||||||
|
|
||||||
// Store the created IDs for rollback
|
// Store the created IDs for rollback
|
||||||
self.created_shape_id = Some(face_shape.id);
|
self.created_shape_id = Some(face_shape.id);
|
||||||
self.created_object_id = Some(face_object.id);
|
self.created_shape_instance_id = Some(face_shape_instance.id);
|
||||||
|
|
||||||
if let Some(AnyLayer::Vector(vector_layer)) = document.get_layer_mut(&self.layer_id) {
|
if let Some(AnyLayer::Vector(vector_layer)) = document.get_layer_mut(&self.layer_id) {
|
||||||
let shape_id_for_debug = face_shape.id;
|
let shape_id_for_debug = face_shape.id;
|
||||||
vector_layer.add_shape_internal(face_shape);
|
vector_layer.add_shape_internal(face_shape);
|
||||||
vector_layer.add_object_internal(face_object);
|
vector_layer.add_object_internal(face_shape_instance);
|
||||||
println!("DEBUG: Added filled shape");
|
println!("DEBUG: Added filled shape");
|
||||||
|
|
||||||
// Verify the shape still has the fill color after being added
|
// Verify the shape still has the fill color after being added (O(1) HashMap lookup)
|
||||||
if let Some(added_shape) = vector_layer.shapes.iter().find(|s| s.id == shape_id_for_debug) {
|
if let Some(added_shape) = vector_layer.shapes.get(&shape_id_for_debug) {
|
||||||
println!("DEBUG: After adding to layer, shape fill_color = {:?}", added_shape.fill_color);
|
println!("DEBUG: After adding to layer, shape fill_color = {:?}", added_shape.fill_color);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -180,7 +180,7 @@ impl Action for PaintBucketAction {
|
||||||
|
|
||||||
fn rollback(&mut self, document: &mut Document) {
|
fn rollback(&mut self, document: &mut Document) {
|
||||||
// Remove the created shape and object if they exist
|
// Remove the created shape and object if they exist
|
||||||
if let (Some(shape_id), Some(object_id)) = (self.created_shape_id, self.created_object_id) {
|
if let (Some(shape_id), Some(object_id)) = (self.created_shape_id, self.created_shape_instance_id) {
|
||||||
let layer = match document.get_layer_mut(&self.layer_id) {
|
let layer = match document.get_layer_mut(&self.layer_id) {
|
||||||
Some(l) => l,
|
Some(l) => l,
|
||||||
None => return,
|
None => return,
|
||||||
|
|
@ -192,7 +192,7 @@ impl Action for PaintBucketAction {
|
||||||
}
|
}
|
||||||
|
|
||||||
self.created_shape_id = None;
|
self.created_shape_id = None;
|
||||||
self.created_object_id = None;
|
self.created_shape_instance_id = None;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -219,11 +219,11 @@ fn extract_curves_from_all_shapes(
|
||||||
|
|
||||||
// Extract curves only from this vector layer
|
// Extract curves only from this vector layer
|
||||||
if let AnyLayer::Vector(vector_layer) = layer {
|
if let AnyLayer::Vector(vector_layer) = layer {
|
||||||
println!("Extracting curves from {} objects in layer", vector_layer.objects.len());
|
println!("Extracting curves from {} objects in layer", vector_layer.shape_instances.len());
|
||||||
// Extract curves from each object (which applies transforms to shapes)
|
// Extract curves from each object (which applies transforms to shapes)
|
||||||
for (obj_idx, object) in vector_layer.objects.iter().enumerate() {
|
for (obj_idx, object) in vector_layer.shape_instances.iter().enumerate() {
|
||||||
// Find the shape for this object
|
// Find the shape for this object (O(1) HashMap lookup)
|
||||||
let shape = match vector_layer.shapes.iter().find(|s| s.id == object.shape_id) {
|
let shape = match vector_layer.shapes.get(&object.shape_id) {
|
||||||
Some(s) => s,
|
Some(s) => s,
|
||||||
None => continue,
|
None => continue,
|
||||||
};
|
};
|
||||||
|
|
@ -313,12 +313,12 @@ mod tests {
|
||||||
let rect = Rect::new(0.0, 0.0, 100.0, 100.0);
|
let rect = Rect::new(0.0, 0.0, 100.0, 100.0);
|
||||||
let path = rect.to_path(0.1);
|
let path = rect.to_path(0.1);
|
||||||
let shape = Shape::new(path);
|
let shape = Shape::new(path);
|
||||||
let object = Object::new(shape.id);
|
let shape_instance = ShapeInstance::new(shape.id);
|
||||||
|
|
||||||
// Add the boundary shape
|
// Add the boundary shape
|
||||||
if let Some(AnyLayer::Vector(layer)) = document.get_layer_mut(&layer_id) {
|
if let Some(AnyLayer::Vector(layer)) = document.get_layer_mut(&layer_id) {
|
||||||
layer.add_shape_internal(shape);
|
layer.add_shape_internal(shape);
|
||||||
layer.add_object_internal(object);
|
layer.add_object_internal(shape_instance);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create and execute paint bucket action
|
// Create and execute paint bucket action
|
||||||
|
|
@ -336,7 +336,7 @@ mod tests {
|
||||||
if let Some(AnyLayer::Vector(layer)) = document.get_layer(&layer_id) {
|
if let Some(AnyLayer::Vector(layer)) = document.get_layer(&layer_id) {
|
||||||
// Should have original shape + filled shape
|
// Should have original shape + filled shape
|
||||||
assert!(layer.shapes.len() >= 1);
|
assert!(layer.shapes.len() >= 1);
|
||||||
assert!(layer.objects.len() >= 1);
|
assert!(layer.shape_instances.len() >= 1);
|
||||||
} else {
|
} else {
|
||||||
panic!("Layer not found or not a vector layer");
|
panic!("Layer not found or not a vector layer");
|
||||||
}
|
}
|
||||||
|
|
@ -347,7 +347,7 @@ mod tests {
|
||||||
if let Some(AnyLayer::Vector(layer)) = document.get_layer(&layer_id) {
|
if let Some(AnyLayer::Vector(layer)) = document.get_layer(&layer_id) {
|
||||||
// Should only have original shape
|
// Should only have original shape
|
||||||
assert_eq!(layer.shapes.len(), 1);
|
assert_eq!(layer.shapes.len(), 1);
|
||||||
assert_eq!(layer.objects.len(), 1);
|
assert_eq!(layer.shape_instances.len(), 1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,80 @@
|
||||||
|
//! Transform clip instances action
|
||||||
|
//!
|
||||||
|
//! Handles spatial transformation (move, scale, rotate) of clip instances on the stage.
|
||||||
|
|
||||||
|
use crate::action::Action;
|
||||||
|
use crate::document::Document;
|
||||||
|
use crate::layer::AnyLayer;
|
||||||
|
use crate::object::Transform;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
/// Action that transforms clip instances spatially on the stage
|
||||||
|
pub struct TransformClipInstancesAction {
|
||||||
|
layer_id: Uuid,
|
||||||
|
/// Map of clip instance ID to (old transform, new transform)
|
||||||
|
clip_instance_transforms: HashMap<Uuid, (Transform, Transform)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TransformClipInstancesAction {
|
||||||
|
pub fn new(
|
||||||
|
layer_id: Uuid,
|
||||||
|
clip_instance_transforms: HashMap<Uuid, (Transform, Transform)>,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
layer_id,
|
||||||
|
clip_instance_transforms,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Action for TransformClipInstancesAction {
|
||||||
|
fn execute(&mut self, document: &mut Document) {
|
||||||
|
let layer = match document.get_layer_mut(&self.layer_id) {
|
||||||
|
Some(l) => l,
|
||||||
|
None => return,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get mutable reference to clip_instances for this layer type
|
||||||
|
let clip_instances = match layer {
|
||||||
|
AnyLayer::Vector(vl) => &mut vl.clip_instances,
|
||||||
|
AnyLayer::Audio(al) => &mut al.clip_instances,
|
||||||
|
AnyLayer::Video(vl) => &mut vl.clip_instances,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Apply new transforms
|
||||||
|
for (clip_id, (_old, new)) in &self.clip_instance_transforms {
|
||||||
|
if let Some(clip_instance) = clip_instances.iter_mut().find(|ci| ci.id == *clip_id) {
|
||||||
|
clip_instance.transform = new.clone();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rollback(&mut self, document: &mut Document) {
|
||||||
|
let layer = match document.get_layer_mut(&self.layer_id) {
|
||||||
|
Some(l) => l,
|
||||||
|
None => return,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get mutable reference to clip_instances for this layer type
|
||||||
|
let clip_instances = match layer {
|
||||||
|
AnyLayer::Vector(vl) => &mut vl.clip_instances,
|
||||||
|
AnyLayer::Audio(al) => &mut al.clip_instances,
|
||||||
|
AnyLayer::Video(vl) => &mut vl.clip_instances,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Restore old transforms
|
||||||
|
for (clip_id, (old, _new)) in &self.clip_instance_transforms {
|
||||||
|
if let Some(clip_instance) = clip_instances.iter_mut().find(|ci| ci.id == *clip_id) {
|
||||||
|
clip_instance.transform = old.clone();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn description(&self) -> String {
|
||||||
|
format!(
|
||||||
|
"Transform {} clip instance(s)",
|
||||||
|
self.clip_instance_transforms.len()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
//! Transform objects action
|
//! Transform shape instances action
|
||||||
//!
|
//!
|
||||||
//! Applies scale, rotation, and other transformations to objects with undo/redo support.
|
//! Applies scale, rotation, and other transformations to shape instances with undo/redo support.
|
||||||
|
|
||||||
use crate::action::Action;
|
use crate::action::Action;
|
||||||
use crate::document::Document;
|
use crate::document::Document;
|
||||||
|
|
@ -9,32 +9,32 @@ use crate::object::Transform;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
/// Action to transform multiple objects
|
/// Action to transform multiple shape instances
|
||||||
pub struct TransformObjectsAction {
|
pub struct TransformShapeInstancesAction {
|
||||||
layer_id: Uuid,
|
layer_id: Uuid,
|
||||||
/// Map of object ID to (old transform, new transform)
|
/// Map of shape instance ID to (old transform, new transform)
|
||||||
object_transforms: HashMap<Uuid, (Transform, Transform)>,
|
shape_instance_transforms: HashMap<Uuid, (Transform, Transform)>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TransformObjectsAction {
|
impl TransformShapeInstancesAction {
|
||||||
/// Create a new transform action
|
/// Create a new transform action
|
||||||
pub fn new(
|
pub fn new(
|
||||||
layer_id: Uuid,
|
layer_id: Uuid,
|
||||||
object_transforms: HashMap<Uuid, (Transform, Transform)>,
|
shape_instance_transforms: HashMap<Uuid, (Transform, Transform)>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
layer_id,
|
layer_id,
|
||||||
object_transforms,
|
shape_instance_transforms,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Action for TransformObjectsAction {
|
impl Action for TransformShapeInstancesAction {
|
||||||
fn execute(&mut self, document: &mut Document) {
|
fn execute(&mut self, document: &mut Document) {
|
||||||
if let Some(layer) = document.get_layer_mut(&self.layer_id) {
|
if let Some(layer) = document.get_layer_mut(&self.layer_id) {
|
||||||
if let AnyLayer::Vector(vector_layer) = layer {
|
if let AnyLayer::Vector(vector_layer) = layer {
|
||||||
for (object_id, (_old, new)) in &self.object_transforms {
|
for (shape_instance_id, (_old, new)) in &self.shape_instance_transforms {
|
||||||
vector_layer.modify_object_internal(object_id, |obj| {
|
vector_layer.modify_object_internal(shape_instance_id, |obj| {
|
||||||
obj.transform = new.clone();
|
obj.transform = new.clone();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -45,8 +45,8 @@ impl Action for TransformObjectsAction {
|
||||||
fn rollback(&mut self, document: &mut Document) {
|
fn rollback(&mut self, document: &mut Document) {
|
||||||
if let Some(layer) = document.get_layer_mut(&self.layer_id) {
|
if let Some(layer) = document.get_layer_mut(&self.layer_id) {
|
||||||
if let AnyLayer::Vector(vector_layer) = layer {
|
if let AnyLayer::Vector(vector_layer) = layer {
|
||||||
for (object_id, (old, _new)) in &self.object_transforms {
|
for (shape_instance_id, (old, _new)) in &self.shape_instance_transforms {
|
||||||
vector_layer.modify_object_internal(object_id, |obj| {
|
vector_layer.modify_object_internal(shape_instance_id, |obj| {
|
||||||
obj.transform = old.clone();
|
obj.transform = old.clone();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -55,6 +55,6 @@ impl Action for TransformObjectsAction {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn description(&self) -> String {
|
fn description(&self) -> String {
|
||||||
format!("Transform {} object(s)", self.object_transforms.len())
|
format!("Transform {} shape instance(s)", self.shape_instance_transforms.len())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,271 @@
|
||||||
|
//! Trim clip instances action
|
||||||
|
//!
|
||||||
|
//! Handles trimming one or more clip instances by adjusting trim_start and/or trim_end.
|
||||||
|
|
||||||
|
use crate::action::Action;
|
||||||
|
use crate::document::Document;
|
||||||
|
use crate::layer::AnyLayer;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
/// Type of trim operation
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||||
|
pub enum TrimType {
|
||||||
|
/// Trim from the start (adjust trim_start and timeline_start)
|
||||||
|
TrimLeft,
|
||||||
|
/// Trim from the end (adjust trim_end)
|
||||||
|
TrimRight,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Action that trims clip instances
|
||||||
|
pub struct TrimClipInstancesAction {
|
||||||
|
/// Map of layer IDs to vectors of (clip_instance_id, trim_type, old_values, new_values)
|
||||||
|
/// For TrimLeft: (old_trim_start, old_timeline_start, new_trim_start, new_timeline_start)
|
||||||
|
/// For TrimRight: (old_trim_end, new_trim_end) - stored as Option<f64>
|
||||||
|
layer_trims: HashMap<Uuid, Vec<(Uuid, TrimType, TrimData, TrimData)>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Trim data that can represent either left or right trim values
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct TrimData {
|
||||||
|
/// For TrimLeft: trim_start value
|
||||||
|
/// For TrimRight: trim_end value (Option because it can be None)
|
||||||
|
pub trim_value: Option<f64>,
|
||||||
|
/// For TrimLeft: timeline_start value (where the clip appears on timeline)
|
||||||
|
/// For TrimRight: unused (None)
|
||||||
|
pub timeline_start: Option<f64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TrimData {
|
||||||
|
/// Create TrimData for left trim
|
||||||
|
pub fn left(trim_start: f64, timeline_start: f64) -> Self {
|
||||||
|
Self {
|
||||||
|
trim_value: Some(trim_start),
|
||||||
|
timeline_start: Some(timeline_start),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create TrimData for right trim
|
||||||
|
pub fn right(trim_end: Option<f64>) -> Self {
|
||||||
|
Self {
|
||||||
|
trim_value: trim_end,
|
||||||
|
timeline_start: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TrimClipInstancesAction {
|
||||||
|
/// Create a new trim clip instances action
|
||||||
|
pub fn new(layer_trims: HashMap<Uuid, Vec<(Uuid, TrimType, TrimData, TrimData)>>) -> Self {
|
||||||
|
Self { layer_trims }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Action for TrimClipInstancesAction {
|
||||||
|
fn execute(&mut self, document: &mut Document) {
|
||||||
|
for (layer_id, trims) in &self.layer_trims {
|
||||||
|
let layer = match document.get_layer_mut(layer_id) {
|
||||||
|
Some(l) => l,
|
||||||
|
None => continue,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get mutable reference to clip_instances for this layer type
|
||||||
|
let clip_instances = match layer {
|
||||||
|
AnyLayer::Vector(vl) => &mut vl.clip_instances,
|
||||||
|
AnyLayer::Audio(al) => &mut al.clip_instances,
|
||||||
|
AnyLayer::Video(vl) => &mut vl.clip_instances,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Apply trims
|
||||||
|
for (clip_id, trim_type, _old, new) in trims {
|
||||||
|
if let Some(clip_instance) = clip_instances.iter_mut().find(|ci| ci.id == *clip_id)
|
||||||
|
{
|
||||||
|
match trim_type {
|
||||||
|
TrimType::TrimLeft => {
|
||||||
|
if let (Some(new_trim), Some(new_timeline)) =
|
||||||
|
(new.trim_value, new.timeline_start)
|
||||||
|
{
|
||||||
|
clip_instance.trim_start = new_trim;
|
||||||
|
clip_instance.timeline_start = new_timeline;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
TrimType::TrimRight => {
|
||||||
|
clip_instance.trim_end = new.trim_value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rollback(&mut self, document: &mut Document) {
|
||||||
|
for (layer_id, trims) in &self.layer_trims {
|
||||||
|
let layer = match document.get_layer_mut(layer_id) {
|
||||||
|
Some(l) => l,
|
||||||
|
None => continue,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get mutable reference to clip_instances for this layer type
|
||||||
|
let clip_instances = match layer {
|
||||||
|
AnyLayer::Vector(vl) => &mut vl.clip_instances,
|
||||||
|
AnyLayer::Audio(al) => &mut al.clip_instances,
|
||||||
|
AnyLayer::Video(vl) => &mut vl.clip_instances,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Restore original trim values
|
||||||
|
for (clip_id, trim_type, old, _new) in trims {
|
||||||
|
if let Some(clip_instance) = clip_instances.iter_mut().find(|ci| ci.id == *clip_id)
|
||||||
|
{
|
||||||
|
match trim_type {
|
||||||
|
TrimType::TrimLeft => {
|
||||||
|
if let (Some(old_trim), Some(old_timeline)) =
|
||||||
|
(old.trim_value, old.timeline_start)
|
||||||
|
{
|
||||||
|
clip_instance.trim_start = old_trim;
|
||||||
|
clip_instance.timeline_start = old_timeline;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
TrimType::TrimRight => {
|
||||||
|
clip_instance.trim_end = old.trim_value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn description(&self) -> String {
|
||||||
|
let total_count: usize = self.layer_trims.values().map(|v| v.len()).sum();
|
||||||
|
if total_count == 1 {
|
||||||
|
"Trim clip instance".to_string()
|
||||||
|
} else {
|
||||||
|
format!("Trim {} clip instances", total_count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::clip::{Clip, ClipInstance, ClipType};
|
||||||
|
use crate::layer::VectorLayer;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_trim_left_action() {
|
||||||
|
let mut document = Document::new("Test");
|
||||||
|
|
||||||
|
let clip = Clip::new(ClipType::Vector, "Test Clip", Some(10.0));
|
||||||
|
let clip_id = clip.id;
|
||||||
|
|
||||||
|
let mut vector_layer = VectorLayer::new("Layer 1");
|
||||||
|
vector_layer.clips.push(clip);
|
||||||
|
|
||||||
|
let mut clip_instance = ClipInstance::new(clip_id);
|
||||||
|
clip_instance.timeline_start = 0.0;
|
||||||
|
clip_instance.trim_start = 0.0;
|
||||||
|
let instance_id = clip_instance.id;
|
||||||
|
vector_layer.clip_instances.push(clip_instance);
|
||||||
|
|
||||||
|
let layer_id = document.root.add_child(AnyLayer::Vector(vector_layer));
|
||||||
|
|
||||||
|
// Create trim action: trim 2 seconds from left
|
||||||
|
let mut layer_trims = HashMap::new();
|
||||||
|
layer_trims.insert(
|
||||||
|
layer_id,
|
||||||
|
vec![(
|
||||||
|
instance_id,
|
||||||
|
TrimType::TrimLeft,
|
||||||
|
TrimData::left(0.0, 0.0),
|
||||||
|
TrimData::left(2.0, 2.0),
|
||||||
|
)],
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut action = TrimClipInstancesAction::new(layer_trims);
|
||||||
|
|
||||||
|
// Execute
|
||||||
|
action.execute(&mut document);
|
||||||
|
|
||||||
|
// Verify trim applied
|
||||||
|
if let Some(AnyLayer::Vector(layer)) = document.get_layer(&layer_id) {
|
||||||
|
let instance = layer
|
||||||
|
.clip_instances
|
||||||
|
.iter()
|
||||||
|
.find(|ci| ci.id == instance_id)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(instance.trim_start, 2.0);
|
||||||
|
assert_eq!(instance.timeline_start, 2.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rollback
|
||||||
|
action.rollback(&mut document);
|
||||||
|
|
||||||
|
// Verify restored
|
||||||
|
if let Some(AnyLayer::Vector(layer)) = document.get_layer(&layer_id) {
|
||||||
|
let instance = layer
|
||||||
|
.clip_instances
|
||||||
|
.iter()
|
||||||
|
.find(|ci| ci.id == instance_id)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(instance.trim_start, 0.0);
|
||||||
|
assert_eq!(instance.timeline_start, 0.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_trim_right_action() {
|
||||||
|
let mut document = Document::new("Test");
|
||||||
|
|
||||||
|
let clip = Clip::new(ClipType::Vector, "Test Clip", Some(10.0));
|
||||||
|
let clip_id = clip.id;
|
||||||
|
|
||||||
|
let mut vector_layer = VectorLayer::new("Layer 1");
|
||||||
|
vector_layer.clips.push(clip);
|
||||||
|
|
||||||
|
let mut clip_instance = ClipInstance::new(clip_id);
|
||||||
|
clip_instance.trim_end = None; // Full duration
|
||||||
|
let instance_id = clip_instance.id;
|
||||||
|
vector_layer.clip_instances.push(clip_instance);
|
||||||
|
|
||||||
|
let layer_id = document.root.add_child(AnyLayer::Vector(vector_layer));
|
||||||
|
|
||||||
|
// Create trim action: trim to 8 seconds from right
|
||||||
|
let mut layer_trims = HashMap::new();
|
||||||
|
layer_trims.insert(
|
||||||
|
layer_id,
|
||||||
|
vec![(
|
||||||
|
instance_id,
|
||||||
|
TrimType::TrimRight,
|
||||||
|
TrimData::right(None),
|
||||||
|
TrimData::right(Some(8.0)),
|
||||||
|
)],
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut action = TrimClipInstancesAction::new(layer_trims);
|
||||||
|
|
||||||
|
// Execute
|
||||||
|
action.execute(&mut document);
|
||||||
|
|
||||||
|
// Verify trim applied
|
||||||
|
if let Some(AnyLayer::Vector(layer)) = document.get_layer(&layer_id) {
|
||||||
|
let instance = layer
|
||||||
|
.clip_instances
|
||||||
|
.iter()
|
||||||
|
.find(|ci| ci.id == instance_id)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(instance.trim_end, Some(8.0));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rollback
|
||||||
|
action.rollback(&mut document);
|
||||||
|
|
||||||
|
// Verify restored
|
||||||
|
if let Some(AnyLayer::Vector(layer)) = document.get_layer(&layer_id) {
|
||||||
|
let instance = layer
|
||||||
|
.clip_instances
|
||||||
|
.iter()
|
||||||
|
.find(|ci| ci.id == instance_id)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(instance.trim_end, None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,621 @@
|
||||||
|
//! Clip system for Lightningbeam
|
||||||
|
//!
|
||||||
|
//! Clips are reusable compositions that can contain layers and be instantiated multiple times.
|
||||||
|
//! Similar to MovieClips in Flash or Compositions in After Effects.
|
||||||
|
//!
|
||||||
|
//! ## Architecture
|
||||||
|
//!
|
||||||
|
//! - **Clip**: The reusable definition (VectorClip, VideoClip, AudioClip)
|
||||||
|
//! - **ClipInstance**: An instance of a clip with its own transform, timing, and playback properties
|
||||||
|
//!
|
||||||
|
//! Multiple ClipInstances can reference the same Clip, each with different positions,
|
||||||
|
//! timing windows, and playback speeds.
|
||||||
|
|
||||||
|
use crate::layer::AnyLayer;
|
||||||
|
use crate::layer_tree::LayerTree;
|
||||||
|
use crate::object::Transform;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
/// Vector clip containing nested layers
|
||||||
|
///
|
||||||
|
/// A VectorClip is a composition that contains its own layer hierarchy.
|
||||||
|
/// Multiple ClipInstances can reference the same VectorClip, each with
|
||||||
|
/// their own transform and timing properties.
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
|
pub struct VectorClip {
|
||||||
|
/// Unique identifier
|
||||||
|
pub id: Uuid,
|
||||||
|
|
||||||
|
/// Clip name
|
||||||
|
pub name: String,
|
||||||
|
|
||||||
|
/// Canvas width in pixels
|
||||||
|
pub width: f64,
|
||||||
|
|
||||||
|
/// Canvas height in pixels
|
||||||
|
pub height: f64,
|
||||||
|
|
||||||
|
/// Duration in seconds
|
||||||
|
pub duration: f64,
|
||||||
|
|
||||||
|
/// Nested layer hierarchy
|
||||||
|
pub layers: LayerTree<AnyLayer>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl VectorClip {
|
||||||
|
/// Create a new vector clip
|
||||||
|
pub fn new(name: impl Into<String>, width: f64, height: f64, duration: f64) -> Self {
|
||||||
|
Self {
|
||||||
|
id: Uuid::new_v4(),
|
||||||
|
name: name.into(),
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
duration,
|
||||||
|
layers: LayerTree::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create with a specific ID
|
||||||
|
pub fn with_id(
|
||||||
|
id: Uuid,
|
||||||
|
name: impl Into<String>,
|
||||||
|
width: f64,
|
||||||
|
height: f64,
|
||||||
|
duration: f64,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
id,
|
||||||
|
name: name.into(),
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
duration,
|
||||||
|
layers: LayerTree::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Video clip referencing an external video file
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
|
pub struct VideoClip {
|
||||||
|
/// Unique identifier
|
||||||
|
pub id: Uuid,
|
||||||
|
|
||||||
|
/// Clip name
|
||||||
|
pub name: String,
|
||||||
|
|
||||||
|
/// Path to video file
|
||||||
|
pub file_path: String,
|
||||||
|
|
||||||
|
/// Video width in pixels
|
||||||
|
pub width: f64,
|
||||||
|
|
||||||
|
/// Video height in pixels
|
||||||
|
pub height: f64,
|
||||||
|
|
||||||
|
/// Duration in seconds (from video metadata)
|
||||||
|
pub duration: f64,
|
||||||
|
|
||||||
|
/// Frame rate (from video metadata)
|
||||||
|
pub frame_rate: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl VideoClip {
|
||||||
|
/// Create a new video clip
|
||||||
|
pub fn new(
|
||||||
|
name: impl Into<String>,
|
||||||
|
file_path: impl Into<String>,
|
||||||
|
width: f64,
|
||||||
|
height: f64,
|
||||||
|
duration: f64,
|
||||||
|
frame_rate: f64,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
id: Uuid::new_v4(),
|
||||||
|
name: name.into(),
|
||||||
|
file_path: file_path.into(),
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
duration,
|
||||||
|
frame_rate,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// MIDI event representing a single MIDI message
|
||||||
|
///
|
||||||
|
/// Compatible with daw-backend's MidiEvent structure
|
||||||
|
#[derive(Clone, Copy, Debug, Serialize, Deserialize)]
|
||||||
|
pub struct MidiEvent {
|
||||||
|
/// Time position within the clip in seconds
|
||||||
|
pub timestamp: f64,
|
||||||
|
/// MIDI status byte (includes channel)
|
||||||
|
pub status: u8,
|
||||||
|
/// First data byte (note number, CC number, etc.)
|
||||||
|
pub data1: u8,
|
||||||
|
/// Second data byte (velocity, CC value, etc.)
|
||||||
|
pub data2: u8,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MidiEvent {
|
||||||
|
/// Create a new MIDI event
|
||||||
|
pub fn new(timestamp: f64, status: u8, data1: u8, data2: u8) -> Self {
|
||||||
|
Self {
|
||||||
|
timestamp,
|
||||||
|
status,
|
||||||
|
data1,
|
||||||
|
data2,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a note on event
|
||||||
|
pub fn note_on(timestamp: f64, channel: u8, note: u8, velocity: u8) -> Self {
|
||||||
|
Self {
|
||||||
|
timestamp,
|
||||||
|
status: 0x90 | (channel & 0x0F),
|
||||||
|
data1: note,
|
||||||
|
data2: velocity,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a note off event
|
||||||
|
pub fn note_off(timestamp: f64, channel: u8, note: u8, velocity: u8) -> Self {
|
||||||
|
Self {
|
||||||
|
timestamp,
|
||||||
|
status: 0x80 | (channel & 0x0F),
|
||||||
|
data1: note,
|
||||||
|
data2: velocity,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Audio clip type
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
|
pub enum AudioClipType {
|
||||||
|
/// Sampled audio from a file
|
||||||
|
///
|
||||||
|
/// References audio data in a shared AudioPool (managed by daw-backend).
|
||||||
|
/// Compatible with daw-backend's Clip structure.
|
||||||
|
Sampled {
|
||||||
|
/// Index into the audio pool (references AudioFile)
|
||||||
|
/// This allows sharing audio data between multiple clip instances
|
||||||
|
audio_pool_index: usize,
|
||||||
|
},
|
||||||
|
/// MIDI sequence
|
||||||
|
///
|
||||||
|
/// Compatible with daw-backend's MidiClip structure.
|
||||||
|
Midi {
|
||||||
|
/// MIDI events with timestamps
|
||||||
|
events: Vec<MidiEvent>,
|
||||||
|
/// Whether the clip loops
|
||||||
|
loop_enabled: bool,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Audio clip
|
||||||
|
///
|
||||||
|
/// This is compatible with daw-backend's audio system:
|
||||||
|
/// - Sampled audio references data in AudioPool (managed externally)
|
||||||
|
/// - MIDI audio stores events directly in the clip
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
|
pub struct AudioClip {
|
||||||
|
/// Unique identifier
|
||||||
|
pub id: Uuid,
|
||||||
|
|
||||||
|
/// Clip name
|
||||||
|
pub name: String,
|
||||||
|
|
||||||
|
/// Duration in seconds
|
||||||
|
/// For sampled audio, this can be set to trim the audio shorter than the source file
|
||||||
|
pub duration: f64,
|
||||||
|
|
||||||
|
/// Audio clip type (sampled or MIDI)
|
||||||
|
pub clip_type: AudioClipType,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AudioClip {
|
||||||
|
/// Create a new sampled audio clip
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
/// * `name` - Clip name
|
||||||
|
/// * `audio_pool_index` - Index into the AudioPool (from daw-backend)
|
||||||
|
/// * `duration` - Clip duration (can be shorter than source file for trimming)
|
||||||
|
pub fn new_sampled(name: impl Into<String>, audio_pool_index: usize, duration: f64) -> Self {
|
||||||
|
Self {
|
||||||
|
id: Uuid::new_v4(),
|
||||||
|
name: name.into(),
|
||||||
|
duration,
|
||||||
|
clip_type: AudioClipType::Sampled { audio_pool_index },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a new MIDI clip
|
||||||
|
pub fn new_midi(
|
||||||
|
name: impl Into<String>,
|
||||||
|
duration: f64,
|
||||||
|
events: Vec<MidiEvent>,
|
||||||
|
loop_enabled: bool,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
id: Uuid::new_v4(),
|
||||||
|
name: name.into(),
|
||||||
|
duration,
|
||||||
|
clip_type: AudioClipType::Midi {
|
||||||
|
events,
|
||||||
|
loop_enabled,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the audio pool index if this is a sampled audio clip
|
||||||
|
pub fn audio_pool_index(&self) -> Option<usize> {
|
||||||
|
match &self.clip_type {
|
||||||
|
AudioClipType::Sampled { audio_pool_index } => Some(*audio_pool_index),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get MIDI events if this is a MIDI clip
|
||||||
|
pub fn midi_events(&self) -> Option<&[MidiEvent]> {
|
||||||
|
match &self.clip_type {
|
||||||
|
AudioClipType::Midi { events, .. } => Some(events),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Unified clip enum for polymorphic handling
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
|
pub enum AnyClip {
|
||||||
|
Vector(VectorClip),
|
||||||
|
Video(VideoClip),
|
||||||
|
Audio(AudioClip),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AnyClip {
|
||||||
|
/// Get the clip ID
|
||||||
|
pub fn id(&self) -> Uuid {
|
||||||
|
match self {
|
||||||
|
AnyClip::Vector(c) => c.id,
|
||||||
|
AnyClip::Audio(c) => c.id,
|
||||||
|
AnyClip::Video(c) => c.id,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the clip name
|
||||||
|
pub fn name(&self) -> &str {
|
||||||
|
match self {
|
||||||
|
AnyClip::Vector(c) => &c.name,
|
||||||
|
AnyClip::Audio(c) => &c.name,
|
||||||
|
AnyClip::Video(c) => &c.name,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the clip duration
|
||||||
|
pub fn duration(&self) -> f64 {
|
||||||
|
match self {
|
||||||
|
AnyClip::Vector(c) => c.duration,
|
||||||
|
AnyClip::Audio(c) => c.duration,
|
||||||
|
AnyClip::Video(c) => c.duration,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clip instance with transform, timing, and playback properties
|
||||||
|
///
|
||||||
|
/// References a clip and provides instance-specific properties:
|
||||||
|
/// - Transform (position, rotation, scale)
|
||||||
|
/// - Timeline placement (when this instance appears on the parent layer's timeline)
|
||||||
|
/// - Trimming (trim_start, trim_end within the clip's internal content)
|
||||||
|
/// - Playback speed (time remapping)
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
|
pub struct ClipInstance {
|
||||||
|
/// Unique identifier for this instance
|
||||||
|
pub id: Uuid,
|
||||||
|
|
||||||
|
/// The clip this instance references
|
||||||
|
pub clip_id: Uuid,
|
||||||
|
|
||||||
|
/// Transform (position, rotation, scale, skew)
|
||||||
|
pub transform: Transform,
|
||||||
|
|
||||||
|
/// Opacity (0.0 to 1.0)
|
||||||
|
pub opacity: f64,
|
||||||
|
|
||||||
|
/// Optional name for this instance
|
||||||
|
pub name: Option<String>,
|
||||||
|
|
||||||
|
/// When this instance starts on the timeline (in seconds, relative to parent layer)
|
||||||
|
/// This is the external positioning - where the instance appears on the timeline
|
||||||
|
/// Default: 0.0 (start at beginning of layer)
|
||||||
|
pub timeline_start: f64,
|
||||||
|
|
||||||
|
/// How long this instance appears on the timeline (in seconds)
|
||||||
|
/// If timeline_duration > (trim_end - trim_start), the trimmed content will loop
|
||||||
|
/// Default: None (use trimmed clip duration, no looping)
|
||||||
|
pub timeline_duration: Option<f64>,
|
||||||
|
|
||||||
|
/// Trim start: offset into the clip's internal content (in seconds)
|
||||||
|
/// Allows trimming the beginning of the clip
|
||||||
|
/// - For audio: offset into the audio file
|
||||||
|
/// - For video: offset into the video file
|
||||||
|
/// - For vector: offset into the animation timeline
|
||||||
|
/// Default: 0.0 (start at beginning of clip)
|
||||||
|
pub trim_start: f64,
|
||||||
|
|
||||||
|
/// Trim end: offset into the clip's internal content (in seconds)
|
||||||
|
/// Allows trimming the end of the clip
|
||||||
|
/// Default: None (use full clip duration)
|
||||||
|
pub trim_end: Option<f64>,
|
||||||
|
|
||||||
|
/// Playback speed multiplier
|
||||||
|
/// 1.0 = normal speed, 0.5 = half speed, 2.0 = double speed
|
||||||
|
/// Default: 1.0
|
||||||
|
pub playback_speed: f64,
|
||||||
|
|
||||||
|
/// Clip-level gain/volume (for audio clips)
|
||||||
|
/// Compatible with daw-backend's Clip.gain
|
||||||
|
/// Default: 1.0
|
||||||
|
pub gain: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ClipInstance {
|
||||||
|
/// Create a new clip instance
|
||||||
|
pub fn new(clip_id: Uuid) -> Self {
|
||||||
|
Self {
|
||||||
|
id: Uuid::new_v4(),
|
||||||
|
clip_id,
|
||||||
|
transform: Transform::default(),
|
||||||
|
opacity: 1.0,
|
||||||
|
name: None,
|
||||||
|
timeline_start: 0.0,
|
||||||
|
timeline_duration: None,
|
||||||
|
trim_start: 0.0,
|
||||||
|
trim_end: None,
|
||||||
|
playback_speed: 1.0,
|
||||||
|
gain: 1.0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create with a specific ID
|
||||||
|
pub fn with_id(id: Uuid, clip_id: Uuid) -> Self {
|
||||||
|
Self {
|
||||||
|
id,
|
||||||
|
clip_id,
|
||||||
|
transform: Transform::default(),
|
||||||
|
opacity: 1.0,
|
||||||
|
name: None,
|
||||||
|
timeline_start: 0.0,
|
||||||
|
timeline_duration: None,
|
||||||
|
trim_start: 0.0,
|
||||||
|
trim_end: None,
|
||||||
|
playback_speed: 1.0,
|
||||||
|
gain: 1.0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set the transform
|
||||||
|
pub fn with_transform(mut self, transform: Transform) -> Self {
|
||||||
|
self.transform = transform;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set the position
|
||||||
|
pub fn with_position(mut self, x: f64, y: f64) -> Self {
|
||||||
|
self.transform.x = x;
|
||||||
|
self.transform.y = y;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set the opacity
|
||||||
|
pub fn with_opacity(mut self, opacity: f64) -> Self {
|
||||||
|
self.opacity = opacity;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set the name
|
||||||
|
pub fn with_name(mut self, name: impl Into<String>) -> Self {
|
||||||
|
self.name = Some(name.into());
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set timeline position
|
||||||
|
pub fn with_timeline_start(mut self, timeline_start: f64) -> Self {
|
||||||
|
self.timeline_start = timeline_start;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set trimming (start and end time within the clip's internal content)
|
||||||
|
pub fn with_trimming(mut self, trim_start: f64, trim_end: Option<f64>) -> Self {
|
||||||
|
self.trim_start = trim_start;
|
||||||
|
self.trim_end = trim_end;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set playback speed
|
||||||
|
pub fn with_playback_speed(mut self, speed: f64) -> Self {
|
||||||
|
self.playback_speed = speed;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set gain/volume (for audio)
|
||||||
|
pub fn with_gain(mut self, gain: f32) -> Self {
|
||||||
|
self.gain = gain;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the effective duration of this instance (accounting for trimming and looping)
|
||||||
|
/// If timeline_duration is set, returns that (enabling content looping)
|
||||||
|
/// Otherwise returns the trimmed content duration
|
||||||
|
pub fn effective_duration(&self, clip_duration: f64) -> f64 {
|
||||||
|
// If timeline_duration is explicitly set, use that (for looping)
|
||||||
|
if let Some(timeline_dur) = self.timeline_duration {
|
||||||
|
return timeline_dur;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise, return the trimmed content duration
|
||||||
|
let end = self.trim_end.unwrap_or(clip_duration);
|
||||||
|
(end - self.trim_start).max(0.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remap timeline time to clip content time
|
||||||
|
///
|
||||||
|
/// Takes a global timeline time and returns the corresponding time within this
|
||||||
|
/// clip's content, accounting for:
|
||||||
|
/// - Instance position (timeline_start)
|
||||||
|
/// - Playback speed
|
||||||
|
/// - Trimming (trim_start, trim_end)
|
||||||
|
/// - Looping (if timeline_duration > content window)
|
||||||
|
///
|
||||||
|
/// Returns None if the clip instance is not active at the given timeline time.
|
||||||
|
pub fn remap_time(&self, timeline_time: f64, clip_duration: f64) -> Option<f64> {
|
||||||
|
// Check if clip instance is active at this time
|
||||||
|
let instance_end = self.timeline_start + self.effective_duration(clip_duration);
|
||||||
|
if timeline_time < self.timeline_start || timeline_time >= instance_end {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate relative time within the instance (0.0 = start of instance)
|
||||||
|
let relative_time = timeline_time - self.timeline_start;
|
||||||
|
|
||||||
|
// Account for playback speed
|
||||||
|
let content_time = relative_time * self.playback_speed;
|
||||||
|
|
||||||
|
// Get the content window size (the portion of clip we're sampling)
|
||||||
|
let trim_end = self.trim_end.unwrap_or(clip_duration);
|
||||||
|
let content_window = (trim_end - self.trim_start).max(0.0);
|
||||||
|
|
||||||
|
// If content_window is zero, can't sample anything
|
||||||
|
if content_window == 0.0 {
|
||||||
|
return Some(self.trim_start);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply looping if content exceeds the window
|
||||||
|
let looped_time = if content_time > content_window {
|
||||||
|
content_time % content_window
|
||||||
|
} else {
|
||||||
|
content_time
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add trim_start offset to get final clip time
|
||||||
|
Some(self.trim_start + looped_time)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert to affine transform
|
||||||
|
pub fn to_affine(&self) -> vello::kurbo::Affine {
|
||||||
|
self.transform.to_affine()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_vector_clip_creation() {
|
||||||
|
let clip = VectorClip::new("My Composition", 1920.0, 1080.0, 10.0);
|
||||||
|
assert_eq!(clip.name, "My Composition");
|
||||||
|
assert_eq!(clip.width, 1920.0);
|
||||||
|
assert_eq!(clip.height, 1080.0);
|
||||||
|
assert_eq!(clip.duration, 10.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_video_clip_creation() {
|
||||||
|
let clip = VideoClip::new("My Video", "/path/to/video.mp4", 1920.0, 1080.0, 30.0, 24.0);
|
||||||
|
assert_eq!(clip.name, "My Video");
|
||||||
|
assert_eq!(clip.file_path, "/path/to/video.mp4");
|
||||||
|
assert_eq!(clip.duration, 30.0);
|
||||||
|
assert_eq!(clip.frame_rate, 24.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_audio_clip_sampled() {
|
||||||
|
let clip = AudioClip::new_sampled("Background Music", 0, 180.0);
|
||||||
|
assert_eq!(clip.name, "Background Music");
|
||||||
|
assert_eq!(clip.duration, 180.0);
|
||||||
|
assert_eq!(clip.audio_pool_index(), Some(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_audio_clip_midi() {
|
||||||
|
let events = vec![MidiEvent::note_on(0.0, 0, 60, 100)];
|
||||||
|
let clip = AudioClip::new_midi("Piano Melody", 60.0, events.clone(), false);
|
||||||
|
assert_eq!(clip.name, "Piano Melody");
|
||||||
|
assert_eq!(clip.duration, 60.0);
|
||||||
|
assert_eq!(clip.midi_events().map(|e| e.len()), Some(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_midi_event_creation() {
|
||||||
|
let event = MidiEvent::note_on(1.5, 0, 60, 100);
|
||||||
|
assert_eq!(event.timestamp, 1.5);
|
||||||
|
assert_eq!(event.status, 0x90); // Note on, channel 0
|
||||||
|
assert_eq!(event.data1, 60); // Middle C
|
||||||
|
assert_eq!(event.data2, 100); // Velocity
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_any_clip_enum() {
|
||||||
|
let vector_clip = VectorClip::new("Comp", 1920.0, 1080.0, 10.0);
|
||||||
|
let any_clip = AnyClip::Vector(vector_clip.clone());
|
||||||
|
|
||||||
|
assert_eq!(any_clip.id(), vector_clip.id);
|
||||||
|
assert_eq!(any_clip.name(), "Comp");
|
||||||
|
assert_eq!(any_clip.duration(), 10.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_clip_instance_creation() {
|
||||||
|
let clip_id = Uuid::new_v4();
|
||||||
|
let instance = ClipInstance::new(clip_id);
|
||||||
|
|
||||||
|
assert_eq!(instance.clip_id, clip_id);
|
||||||
|
assert_eq!(instance.opacity, 1.0);
|
||||||
|
assert_eq!(instance.timeline_start, 0.0);
|
||||||
|
assert_eq!(instance.trim_start, 0.0);
|
||||||
|
assert_eq!(instance.trim_end, None);
|
||||||
|
assert_eq!(instance.playback_speed, 1.0);
|
||||||
|
assert_eq!(instance.gain, 1.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_clip_instance_trimming() {
|
||||||
|
let clip_id = Uuid::new_v4();
|
||||||
|
let instance = ClipInstance::new(clip_id)
|
||||||
|
.with_trimming(2.0, Some(8.0));
|
||||||
|
|
||||||
|
assert_eq!(instance.trim_start, 2.0);
|
||||||
|
assert_eq!(instance.trim_end, Some(8.0));
|
||||||
|
assert_eq!(instance.effective_duration(10.0), 6.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_clip_instance_no_end_trim() {
|
||||||
|
let clip_id = Uuid::new_v4();
|
||||||
|
let instance = ClipInstance::new(clip_id)
|
||||||
|
.with_trimming(2.0, None);
|
||||||
|
|
||||||
|
assert_eq!(instance.trim_start, 2.0);
|
||||||
|
assert_eq!(instance.trim_end, None);
|
||||||
|
assert_eq!(instance.effective_duration(10.0), 8.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_clip_instance_builder() {
|
||||||
|
let clip_id = Uuid::new_v4();
|
||||||
|
let instance = ClipInstance::new(clip_id)
|
||||||
|
.with_position(100.0, 200.0)
|
||||||
|
.with_opacity(0.5)
|
||||||
|
.with_name("My Instance")
|
||||||
|
.with_playback_speed(2.0)
|
||||||
|
.with_gain(0.8);
|
||||||
|
|
||||||
|
assert_eq!(instance.transform.x, 100.0);
|
||||||
|
assert_eq!(instance.transform.y, 200.0);
|
||||||
|
assert_eq!(instance.opacity, 0.5);
|
||||||
|
assert_eq!(instance.name, Some("My Instance".to_string()));
|
||||||
|
assert_eq!(instance.playback_speed, 2.0);
|
||||||
|
assert_eq!(instance.gain, 0.8);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -3,9 +3,11 @@
|
||||||
//! The Document represents a complete animation project with settings
|
//! The Document represents a complete animation project with settings
|
||||||
//! and a root graphics object containing the scene graph.
|
//! and a root graphics object containing the scene graph.
|
||||||
|
|
||||||
|
use crate::clip::{AudioClip, VideoClip, VectorClip};
|
||||||
use crate::layer::AnyLayer;
|
use crate::layer::AnyLayer;
|
||||||
use crate::shape::ShapeColor;
|
use crate::shape::ShapeColor;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::collections::HashMap;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
/// Root graphics object containing all layers in the scene
|
/// Root graphics object containing all layers in the scene
|
||||||
|
|
@ -91,6 +93,16 @@ pub struct Document {
|
||||||
/// Root graphics object containing all layers
|
/// Root graphics object containing all layers
|
||||||
pub root: GraphicsObject,
|
pub root: GraphicsObject,
|
||||||
|
|
||||||
|
/// Clip libraries - reusable clip definitions
|
||||||
|
/// VectorClips can be instantiated multiple times with different transforms/timing
|
||||||
|
pub vector_clips: HashMap<Uuid, VectorClip>,
|
||||||
|
|
||||||
|
/// Video clip library - references to video files
|
||||||
|
pub video_clips: HashMap<Uuid, VideoClip>,
|
||||||
|
|
||||||
|
/// Audio clip library - sampled audio and MIDI clips
|
||||||
|
pub audio_clips: HashMap<Uuid, AudioClip>,
|
||||||
|
|
||||||
/// Current playback time in seconds
|
/// Current playback time in seconds
|
||||||
#[serde(skip)]
|
#[serde(skip)]
|
||||||
pub current_time: f64,
|
pub current_time: f64,
|
||||||
|
|
@ -107,6 +119,9 @@ impl Default for Document {
|
||||||
framerate: 60.0,
|
framerate: 60.0,
|
||||||
duration: 10.0,
|
duration: 10.0,
|
||||||
root: GraphicsObject::default(),
|
root: GraphicsObject::default(),
|
||||||
|
vector_clips: HashMap::new(),
|
||||||
|
video_clips: HashMap::new(),
|
||||||
|
audio_clips: HashMap::new(),
|
||||||
current_time: 0.0,
|
current_time: 0.0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -159,15 +174,12 @@ impl Document {
|
||||||
self.current_time = time.max(0.0).min(self.duration);
|
self.current_time = time.max(0.0).min(self.duration);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get visible layers at the current time from the root graphics object
|
/// Get visible layers from the root graphics object
|
||||||
pub fn visible_layers(&self) -> impl Iterator<Item = &AnyLayer> {
|
pub fn visible_layers(&self) -> impl Iterator<Item = &AnyLayer> {
|
||||||
self.root
|
self.root
|
||||||
.children
|
.children
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|layer| {
|
.filter(|layer| layer.layer().visible)
|
||||||
let layer = layer.layer();
|
|
||||||
layer.visible && layer.contains_time(self.current_time)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get a layer by ID
|
/// Get a layer by ID
|
||||||
|
|
@ -192,6 +204,74 @@ impl Document {
|
||||||
pub fn get_layer_mut(&mut self, id: &Uuid) -> Option<&mut AnyLayer> {
|
pub fn get_layer_mut(&mut self, id: &Uuid) -> Option<&mut AnyLayer> {
|
||||||
self.root.get_child_mut(id)
|
self.root.get_child_mut(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// === CLIP LIBRARY METHODS ===
|
||||||
|
|
||||||
|
/// Add a vector clip to the library
|
||||||
|
pub fn add_vector_clip(&mut self, clip: VectorClip) -> Uuid {
|
||||||
|
let id = clip.id;
|
||||||
|
self.vector_clips.insert(id, clip);
|
||||||
|
id
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add a video clip to the library
|
||||||
|
pub fn add_video_clip(&mut self, clip: VideoClip) -> Uuid {
|
||||||
|
let id = clip.id;
|
||||||
|
self.video_clips.insert(id, clip);
|
||||||
|
id
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add an audio clip to the library
|
||||||
|
pub fn add_audio_clip(&mut self, clip: AudioClip) -> Uuid {
|
||||||
|
let id = clip.id;
|
||||||
|
self.audio_clips.insert(id, clip);
|
||||||
|
id
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get a vector clip by ID
|
||||||
|
pub fn get_vector_clip(&self, id: &Uuid) -> Option<&VectorClip> {
|
||||||
|
self.vector_clips.get(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get a video clip by ID
|
||||||
|
pub fn get_video_clip(&self, id: &Uuid) -> Option<&VideoClip> {
|
||||||
|
self.video_clips.get(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get an audio clip by ID
|
||||||
|
pub fn get_audio_clip(&self, id: &Uuid) -> Option<&AudioClip> {
|
||||||
|
self.audio_clips.get(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get a mutable vector clip by ID
|
||||||
|
pub fn get_vector_clip_mut(&mut self, id: &Uuid) -> Option<&mut VectorClip> {
|
||||||
|
self.vector_clips.get_mut(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get a mutable video clip by ID
|
||||||
|
pub fn get_video_clip_mut(&mut self, id: &Uuid) -> Option<&mut VideoClip> {
|
||||||
|
self.video_clips.get_mut(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get a mutable audio clip by ID
|
||||||
|
pub fn get_audio_clip_mut(&mut self, id: &Uuid) -> Option<&mut AudioClip> {
|
||||||
|
self.audio_clips.get_mut(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove a vector clip from the library
|
||||||
|
pub fn remove_vector_clip(&mut self, id: &Uuid) -> Option<VectorClip> {
|
||||||
|
self.vector_clips.remove(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove a video clip from the library
|
||||||
|
pub fn remove_video_clip(&mut self, id: &Uuid) -> Option<VideoClip> {
|
||||||
|
self.video_clips.remove(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove an audio clip from the library
|
||||||
|
pub fn remove_audio_clip(&mut self, id: &Uuid) -> Option<AudioClip> {
|
||||||
|
self.audio_clips.remove(id)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|
|
||||||
|
|
@ -3,12 +3,23 @@
|
||||||
//! Provides functions for testing if points or rectangles intersect with
|
//! Provides functions for testing if points or rectangles intersect with
|
||||||
//! shapes and objects, taking into account transform hierarchies.
|
//! shapes and objects, taking into account transform hierarchies.
|
||||||
|
|
||||||
|
use crate::clip::{ClipInstance, VectorClip, VideoClip};
|
||||||
use crate::layer::VectorLayer;
|
use crate::layer::VectorLayer;
|
||||||
use crate::object::Object;
|
use crate::object::ShapeInstance;
|
||||||
use crate::shape::Shape;
|
use crate::shape::Shape;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
use vello::kurbo::{Affine, Point, Rect, Shape as KurboShape};
|
use vello::kurbo::{Affine, Point, Rect, Shape as KurboShape};
|
||||||
|
|
||||||
|
/// Result of a hit test operation
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub enum HitResult {
|
||||||
|
/// Hit a shape instance
|
||||||
|
ShapeInstance(Uuid),
|
||||||
|
/// Hit a clip instance
|
||||||
|
ClipInstance(Uuid),
|
||||||
|
}
|
||||||
|
|
||||||
/// Hit test a layer at a specific point
|
/// Hit test a layer at a specific point
|
||||||
///
|
///
|
||||||
/// Tests objects in reverse order (front to back) and returns the first hit.
|
/// Tests objects in reverse order (front to back) and returns the first hit.
|
||||||
|
|
@ -31,7 +42,7 @@ pub fn hit_test_layer(
|
||||||
parent_transform: Affine,
|
parent_transform: Affine,
|
||||||
) -> Option<Uuid> {
|
) -> Option<Uuid> {
|
||||||
// Test objects in reverse order (back to front in Vec = front to back for hit testing)
|
// Test objects in reverse order (back to front in Vec = front to back for hit testing)
|
||||||
for object in layer.objects.iter().rev() {
|
for object in layer.shape_instances.iter().rev() {
|
||||||
// Get the shape for this object
|
// Get the shape for this object
|
||||||
let shape = layer.get_shape(&object.shape_id)?;
|
let shape = layer.get_shape(&object.shape_id)?;
|
||||||
|
|
||||||
|
|
@ -127,7 +138,7 @@ pub fn hit_test_objects_in_rect(
|
||||||
) -> Vec<Uuid> {
|
) -> Vec<Uuid> {
|
||||||
let mut hits = Vec::new();
|
let mut hits = Vec::new();
|
||||||
|
|
||||||
for object in &layer.objects {
|
for object in &layer.shape_instances {
|
||||||
// Get the shape for this object
|
// Get the shape for this object
|
||||||
if let Some(shape) = layer.get_shape(&object.shape_id) {
|
if let Some(shape) = layer.get_shape(&object.shape_id) {
|
||||||
// Combine parent transform with object transform
|
// Combine parent transform with object transform
|
||||||
|
|
@ -161,7 +172,7 @@ pub fn hit_test_objects_in_rect(
|
||||||
///
|
///
|
||||||
/// The bounding box in screen/canvas space
|
/// The bounding box in screen/canvas space
|
||||||
pub fn get_object_bounds(
|
pub fn get_object_bounds(
|
||||||
object: &Object,
|
object: &ShapeInstance,
|
||||||
shape: &Shape,
|
shape: &Shape,
|
||||||
parent_transform: Affine,
|
parent_transform: Affine,
|
||||||
) -> Rect {
|
) -> Rect {
|
||||||
|
|
@ -170,6 +181,154 @@ pub fn get_object_bounds(
|
||||||
combined_transform.transform_rect_bbox(local_bbox)
|
combined_transform.transform_rect_bbox(local_bbox)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Hit test a single clip instance with a given clip bounds
|
||||||
|
///
|
||||||
|
/// Tests if a point hits the clip instance's bounding box.
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `clip_instance` - The clip instance to test
|
||||||
|
/// * `clip_width` - The clip's width in pixels
|
||||||
|
/// * `clip_height` - The clip's height in pixels
|
||||||
|
/// * `point` - The point to test in screen/canvas space
|
||||||
|
/// * `parent_transform` - Transform from parent layer/clip
|
||||||
|
///
|
||||||
|
/// # Returns
|
||||||
|
///
|
||||||
|
/// true if the point hits the clip instance, false otherwise
|
||||||
|
pub fn hit_test_clip_instance(
|
||||||
|
clip_instance: &ClipInstance,
|
||||||
|
clip_width: f64,
|
||||||
|
clip_height: f64,
|
||||||
|
point: Point,
|
||||||
|
parent_transform: Affine,
|
||||||
|
) -> bool {
|
||||||
|
// Create bounding rectangle for the clip
|
||||||
|
let clip_rect = Rect::new(0.0, 0.0, clip_width, clip_height);
|
||||||
|
|
||||||
|
// Combine parent transform with clip instance transform
|
||||||
|
let combined_transform = parent_transform * clip_instance.transform.to_affine();
|
||||||
|
|
||||||
|
// Transform the bounding rectangle to screen space
|
||||||
|
let transformed_rect = combined_transform.transform_rect_bbox(clip_rect);
|
||||||
|
|
||||||
|
// Test if point is inside the transformed rectangle
|
||||||
|
transformed_rect.contains(point)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the bounding box of a clip instance in screen space
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `clip_instance` - The clip instance to get bounds for
|
||||||
|
/// * `clip_width` - The clip's width in pixels
|
||||||
|
/// * `clip_height` - The clip's height in pixels
|
||||||
|
/// * `parent_transform` - Transform from parent layer/clip
|
||||||
|
///
|
||||||
|
/// # Returns
|
||||||
|
///
|
||||||
|
/// The bounding box in screen/canvas space
|
||||||
|
pub fn get_clip_instance_bounds(
|
||||||
|
clip_instance: &ClipInstance,
|
||||||
|
clip_width: f64,
|
||||||
|
clip_height: f64,
|
||||||
|
parent_transform: Affine,
|
||||||
|
) -> Rect {
|
||||||
|
let clip_rect = Rect::new(0.0, 0.0, clip_width, clip_height);
|
||||||
|
let combined_transform = parent_transform * clip_instance.transform.to_affine();
|
||||||
|
combined_transform.transform_rect_bbox(clip_rect)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hit test clip instances at a specific point
|
||||||
|
///
|
||||||
|
/// Tests clip instances in reverse order (front to back) and returns the first hit.
|
||||||
|
/// This function requires the clip libraries to look up clip dimensions.
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `clip_instances` - The clip instances to test
|
||||||
|
/// * `vector_clips` - HashMap of vector clips for looking up dimensions
|
||||||
|
/// * `video_clips` - HashMap of video clips for looking up dimensions
|
||||||
|
/// * `point` - The point to test in screen/canvas space
|
||||||
|
/// * `parent_transform` - Transform from parent layer/clip
|
||||||
|
///
|
||||||
|
/// # Returns
|
||||||
|
///
|
||||||
|
/// The UUID of the first clip instance hit, or None if no hit
|
||||||
|
pub fn hit_test_clip_instances(
|
||||||
|
clip_instances: &[ClipInstance],
|
||||||
|
vector_clips: &std::collections::HashMap<Uuid, VectorClip>,
|
||||||
|
video_clips: &std::collections::HashMap<Uuid, VideoClip>,
|
||||||
|
point: Point,
|
||||||
|
parent_transform: Affine,
|
||||||
|
) -> Option<Uuid> {
|
||||||
|
// Test in reverse order (front to back)
|
||||||
|
for clip_instance in clip_instances.iter().rev() {
|
||||||
|
// Try to get clip dimensions from either vector or video clips
|
||||||
|
let (width, height) = if let Some(vector_clip) = vector_clips.get(&clip_instance.clip_id) {
|
||||||
|
(vector_clip.width, vector_clip.height)
|
||||||
|
} else if let Some(video_clip) = video_clips.get(&clip_instance.clip_id) {
|
||||||
|
(video_clip.width, video_clip.height)
|
||||||
|
} else {
|
||||||
|
// Clip not found or is audio (no spatial representation)
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
if hit_test_clip_instance(clip_instance, width, height, point, parent_transform) {
|
||||||
|
return Some(clip_instance.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hit test clip instances within a rectangle (for marquee selection)
|
||||||
|
///
|
||||||
|
/// Returns all clip instances whose bounding boxes intersect with the given rectangle.
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `clip_instances` - The clip instances to test
|
||||||
|
/// * `vector_clips` - HashMap of vector clips for looking up dimensions
|
||||||
|
/// * `video_clips` - HashMap of video clips for looking up dimensions
|
||||||
|
/// * `rect` - The selection rectangle in screen/canvas space
|
||||||
|
/// * `parent_transform` - Transform from parent layer/clip
|
||||||
|
///
|
||||||
|
/// # Returns
|
||||||
|
///
|
||||||
|
/// Vector of UUIDs for all clip instances that intersect the rectangle
|
||||||
|
pub fn hit_test_clip_instances_in_rect(
|
||||||
|
clip_instances: &[ClipInstance],
|
||||||
|
vector_clips: &std::collections::HashMap<Uuid, VectorClip>,
|
||||||
|
video_clips: &std::collections::HashMap<Uuid, VideoClip>,
|
||||||
|
rect: Rect,
|
||||||
|
parent_transform: Affine,
|
||||||
|
) -> Vec<Uuid> {
|
||||||
|
let mut hits = Vec::new();
|
||||||
|
|
||||||
|
for clip_instance in clip_instances {
|
||||||
|
// Try to get clip dimensions from either vector or video clips
|
||||||
|
let (width, height) = if let Some(vector_clip) = vector_clips.get(&clip_instance.clip_id) {
|
||||||
|
(vector_clip.width, vector_clip.height)
|
||||||
|
} else if let Some(video_clip) = video_clips.get(&clip_instance.clip_id) {
|
||||||
|
(video_clip.width, video_clip.height)
|
||||||
|
} else {
|
||||||
|
// Clip not found or is audio (no spatial representation)
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get clip instance bounding box in screen space
|
||||||
|
let clip_bbox = get_clip_instance_bounds(clip_instance, width, height, parent_transform);
|
||||||
|
|
||||||
|
// Check if rectangles intersect
|
||||||
|
if rect.intersect(clip_bbox).area() > 0.0 {
|
||||||
|
hits.push(clip_instance.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
hits
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,11 @@
|
||||||
//! Layers organize objects and shapes, and contain animation data.
|
//! Layers organize objects and shapes, and contain animation data.
|
||||||
|
|
||||||
use crate::animation::AnimationData;
|
use crate::animation::AnimationData;
|
||||||
use crate::object::Object;
|
use crate::clip::ClipInstance;
|
||||||
|
use crate::object::ShapeInstance;
|
||||||
use crate::shape::Shape;
|
use crate::shape::Shape;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::collections::HashMap;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
/// Layer type
|
/// Layer type
|
||||||
|
|
@ -21,6 +23,37 @@ pub enum LayerType {
|
||||||
Automation,
|
Automation,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Common trait for all layer types
|
||||||
|
///
|
||||||
|
/// Provides uniform access to common layer properties across VectorLayer,
|
||||||
|
/// AudioLayer, VideoLayer, and their wrapper AnyLayer enum.
|
||||||
|
pub trait LayerTrait {
|
||||||
|
// Identity
|
||||||
|
fn id(&self) -> Uuid;
|
||||||
|
fn name(&self) -> &str;
|
||||||
|
fn set_name(&mut self, name: String);
|
||||||
|
fn has_custom_name(&self) -> bool;
|
||||||
|
fn set_has_custom_name(&mut self, custom: bool);
|
||||||
|
|
||||||
|
// Visual properties
|
||||||
|
fn visible(&self) -> bool;
|
||||||
|
fn set_visible(&mut self, visible: bool);
|
||||||
|
fn opacity(&self) -> f64;
|
||||||
|
fn set_opacity(&mut self, opacity: f64);
|
||||||
|
|
||||||
|
// Audio properties (all layers can affect audio through nesting)
|
||||||
|
fn volume(&self) -> f64;
|
||||||
|
fn set_volume(&mut self, volume: f64);
|
||||||
|
fn muted(&self) -> bool;
|
||||||
|
fn set_muted(&mut self, muted: bool);
|
||||||
|
|
||||||
|
// Editor state
|
||||||
|
fn soloed(&self) -> bool;
|
||||||
|
fn set_soloed(&mut self, soloed: bool);
|
||||||
|
fn locked(&self) -> bool;
|
||||||
|
fn set_locked(&mut self, locked: bool);
|
||||||
|
}
|
||||||
|
|
||||||
/// Base layer structure
|
/// Base layer structure
|
||||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
pub struct Layer {
|
pub struct Layer {
|
||||||
|
|
@ -33,17 +66,26 @@ pub struct Layer {
|
||||||
/// Layer name
|
/// Layer name
|
||||||
pub name: String,
|
pub name: String,
|
||||||
|
|
||||||
|
/// Whether the name was set by user (vs auto-generated)
|
||||||
|
pub has_custom_name: bool,
|
||||||
|
|
||||||
/// Whether the layer is visible
|
/// Whether the layer is visible
|
||||||
pub visible: bool,
|
pub visible: bool,
|
||||||
|
|
||||||
/// Layer opacity (0.0 to 1.0)
|
/// Layer opacity (0.0 to 1.0)
|
||||||
pub opacity: f64,
|
pub opacity: f64,
|
||||||
|
|
||||||
/// Start time in seconds
|
/// Audio volume (1.0 = 100%, affects nested audio layers/clips)
|
||||||
pub start_time: f64,
|
pub volume: f64,
|
||||||
|
|
||||||
/// End time in seconds
|
/// Audio mute state
|
||||||
pub end_time: f64,
|
pub muted: bool,
|
||||||
|
|
||||||
|
/// Solo state (for isolating layers)
|
||||||
|
pub soloed: bool,
|
||||||
|
|
||||||
|
/// Lock state (prevents editing)
|
||||||
|
pub locked: bool,
|
||||||
|
|
||||||
/// Animation data for this layer
|
/// Animation data for this layer
|
||||||
pub animation_data: AnimationData,
|
pub animation_data: AnimationData,
|
||||||
|
|
@ -56,10 +98,13 @@ impl Layer {
|
||||||
id: Uuid::new_v4(),
|
id: Uuid::new_v4(),
|
||||||
layer_type,
|
layer_type,
|
||||||
name: name.into(),
|
name: name.into(),
|
||||||
|
has_custom_name: false, // Auto-generated by default
|
||||||
visible: true,
|
visible: true,
|
||||||
opacity: 1.0,
|
opacity: 1.0,
|
||||||
start_time: 0.0,
|
volume: 1.0, // 100% volume
|
||||||
end_time: 10.0, // Default 10 second duration
|
muted: false,
|
||||||
|
soloed: false,
|
||||||
|
locked: false,
|
||||||
animation_data: AnimationData::new(),
|
animation_data: AnimationData::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -70,36 +115,22 @@ impl Layer {
|
||||||
id,
|
id,
|
||||||
layer_type,
|
layer_type,
|
||||||
name: name.into(),
|
name: name.into(),
|
||||||
|
has_custom_name: false,
|
||||||
visible: true,
|
visible: true,
|
||||||
opacity: 1.0,
|
opacity: 1.0,
|
||||||
start_time: 0.0,
|
volume: 1.0,
|
||||||
end_time: 10.0,
|
muted: false,
|
||||||
|
soloed: false,
|
||||||
|
locked: false,
|
||||||
animation_data: AnimationData::new(),
|
animation_data: AnimationData::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set the time range
|
|
||||||
pub fn with_time_range(mut self, start: f64, end: f64) -> Self {
|
|
||||||
self.start_time = start;
|
|
||||||
self.end_time = end;
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Set visibility
|
/// Set visibility
|
||||||
pub fn with_visibility(mut self, visible: bool) -> Self {
|
pub fn with_visibility(mut self, visible: bool) -> Self {
|
||||||
self.visible = visible;
|
self.visible = visible;
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get duration
|
|
||||||
pub fn duration(&self) -> f64 {
|
|
||||||
self.end_time - self.start_time
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Check if a time is within this layer's range
|
|
||||||
pub fn contains_time(&self, time: f64) -> bool {
|
|
||||||
time >= self.start_time && time <= self.end_time
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Vector layer containing shapes and objects
|
/// Vector layer containing shapes and objects
|
||||||
|
|
@ -108,11 +139,85 @@ pub struct VectorLayer {
|
||||||
/// Base layer properties
|
/// Base layer properties
|
||||||
pub layer: Layer,
|
pub layer: Layer,
|
||||||
|
|
||||||
/// Shapes defined in this layer
|
/// Shapes defined in this layer (indexed by UUID for O(1) lookup)
|
||||||
pub shapes: Vec<Shape>,
|
pub shapes: HashMap<Uuid, Shape>,
|
||||||
|
|
||||||
/// Object instances (references to shapes with transforms)
|
/// Shape instances (references to shapes with transforms)
|
||||||
pub objects: Vec<Object>,
|
pub shape_instances: Vec<ShapeInstance>,
|
||||||
|
|
||||||
|
/// Clip instances (references to vector clips with transforms)
|
||||||
|
/// VectorLayer can contain instances of VectorClips for nested compositions
|
||||||
|
pub clip_instances: Vec<ClipInstance>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LayerTrait for VectorLayer {
|
||||||
|
fn id(&self) -> Uuid {
|
||||||
|
self.layer.id
|
||||||
|
}
|
||||||
|
|
||||||
|
fn name(&self) -> &str {
|
||||||
|
&self.layer.name
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_name(&mut self, name: String) {
|
||||||
|
self.layer.name = name;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn has_custom_name(&self) -> bool {
|
||||||
|
self.layer.has_custom_name
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_has_custom_name(&mut self, custom: bool) {
|
||||||
|
self.layer.has_custom_name = custom;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn visible(&self) -> bool {
|
||||||
|
self.layer.visible
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_visible(&mut self, visible: bool) {
|
||||||
|
self.layer.visible = visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn opacity(&self) -> f64 {
|
||||||
|
self.layer.opacity
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_opacity(&mut self, opacity: f64) {
|
||||||
|
self.layer.opacity = opacity;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn volume(&self) -> f64 {
|
||||||
|
self.layer.volume
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_volume(&mut self, volume: f64) {
|
||||||
|
self.layer.volume = volume;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn muted(&self) -> bool {
|
||||||
|
self.layer.muted
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_muted(&mut self, muted: bool) {
|
||||||
|
self.layer.muted = muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn soloed(&self) -> bool {
|
||||||
|
self.layer.soloed
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_soloed(&mut self, soloed: bool) {
|
||||||
|
self.layer.soloed = soloed;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn locked(&self) -> bool {
|
||||||
|
self.layer.locked
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_locked(&mut self, locked: bool) {
|
||||||
|
self.layer.locked = locked;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl VectorLayer {
|
impl VectorLayer {
|
||||||
|
|
@ -120,43 +225,44 @@ impl VectorLayer {
|
||||||
pub fn new(name: impl Into<String>) -> Self {
|
pub fn new(name: impl Into<String>) -> Self {
|
||||||
Self {
|
Self {
|
||||||
layer: Layer::new(LayerType::Vector, name),
|
layer: Layer::new(LayerType::Vector, name),
|
||||||
shapes: Vec::new(),
|
shapes: HashMap::new(),
|
||||||
objects: Vec::new(),
|
shape_instances: Vec::new(),
|
||||||
|
clip_instances: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Add a shape to this layer
|
/// Add a shape to this layer
|
||||||
pub fn add_shape(&mut self, shape: Shape) -> Uuid {
|
pub fn add_shape(&mut self, shape: Shape) -> Uuid {
|
||||||
let id = shape.id;
|
let id = shape.id;
|
||||||
self.shapes.push(shape);
|
self.shapes.insert(id, shape);
|
||||||
id
|
id
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Add an object to this layer
|
/// Add an object to this layer
|
||||||
pub fn add_object(&mut self, object: Object) -> Uuid {
|
pub fn add_object(&mut self, object: ShapeInstance) -> Uuid {
|
||||||
let id = object.id;
|
let id = object.id;
|
||||||
self.objects.push(object);
|
self.shape_instances.push(object);
|
||||||
id
|
id
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Find a shape by ID
|
/// Find a shape by ID
|
||||||
pub fn get_shape(&self, id: &Uuid) -> Option<&Shape> {
|
pub fn get_shape(&self, id: &Uuid) -> Option<&Shape> {
|
||||||
self.shapes.iter().find(|s| &s.id == id)
|
self.shapes.get(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Find a mutable shape by ID
|
/// Find a mutable shape by ID
|
||||||
pub fn get_shape_mut(&mut self, id: &Uuid) -> Option<&mut Shape> {
|
pub fn get_shape_mut(&mut self, id: &Uuid) -> Option<&mut Shape> {
|
||||||
self.shapes.iter_mut().find(|s| &s.id == id)
|
self.shapes.get_mut(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Find an object by ID
|
/// Find an object by ID
|
||||||
pub fn get_object(&self, id: &Uuid) -> Option<&Object> {
|
pub fn get_object(&self, id: &Uuid) -> Option<&ShapeInstance> {
|
||||||
self.objects.iter().find(|o| &o.id == id)
|
self.shape_instances.iter().find(|o| &o.id == id)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Find a mutable object by ID
|
/// Find a mutable object by ID
|
||||||
pub fn get_object_mut(&mut self, id: &Uuid) -> Option<&mut Object> {
|
pub fn get_object_mut(&mut self, id: &Uuid) -> Option<&mut ShapeInstance> {
|
||||||
self.objects.iter_mut().find(|o| &o.id == id)
|
self.shape_instances.iter_mut().find(|o| &o.id == id)
|
||||||
}
|
}
|
||||||
|
|
||||||
// === MUTATION METHODS (pub(crate) - only accessible to action module) ===
|
// === MUTATION METHODS (pub(crate) - only accessible to action module) ===
|
||||||
|
|
@ -167,7 +273,7 @@ impl VectorLayer {
|
||||||
/// only happen through the action system.
|
/// only happen through the action system.
|
||||||
pub(crate) fn add_shape_internal(&mut self, shape: Shape) -> Uuid {
|
pub(crate) fn add_shape_internal(&mut self, shape: Shape) -> Uuid {
|
||||||
let id = shape.id;
|
let id = shape.id;
|
||||||
self.shapes.push(shape);
|
self.shapes.insert(id, shape);
|
||||||
id
|
id
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -175,9 +281,9 @@ impl VectorLayer {
|
||||||
///
|
///
|
||||||
/// This method is intentionally `pub(crate)` to ensure mutations
|
/// This method is intentionally `pub(crate)` to ensure mutations
|
||||||
/// only happen through the action system.
|
/// only happen through the action system.
|
||||||
pub(crate) fn add_object_internal(&mut self, object: Object) -> Uuid {
|
pub(crate) fn add_object_internal(&mut self, object: ShapeInstance) -> Uuid {
|
||||||
let id = object.id;
|
let id = object.id;
|
||||||
self.objects.push(object);
|
self.shape_instances.push(object);
|
||||||
id
|
id
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -187,11 +293,7 @@ impl VectorLayer {
|
||||||
/// This method is intentionally `pub(crate)` to ensure mutations
|
/// This method is intentionally `pub(crate)` to ensure mutations
|
||||||
/// only happen through the action system.
|
/// only happen through the action system.
|
||||||
pub(crate) fn remove_shape_internal(&mut self, id: &Uuid) -> Option<Shape> {
|
pub(crate) fn remove_shape_internal(&mut self, id: &Uuid) -> Option<Shape> {
|
||||||
if let Some(index) = self.shapes.iter().position(|s| &s.id == id) {
|
self.shapes.remove(id)
|
||||||
Some(self.shapes.remove(index))
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Remove an object from this layer (internal, for actions only)
|
/// Remove an object from this layer (internal, for actions only)
|
||||||
|
|
@ -199,9 +301,9 @@ impl VectorLayer {
|
||||||
/// Returns the removed object if found.
|
/// Returns the removed object if found.
|
||||||
/// This method is intentionally `pub(crate)` to ensure mutations
|
/// This method is intentionally `pub(crate)` to ensure mutations
|
||||||
/// only happen through the action system.
|
/// only happen through the action system.
|
||||||
pub(crate) fn remove_object_internal(&mut self, id: &Uuid) -> Option<Object> {
|
pub(crate) fn remove_object_internal(&mut self, id: &Uuid) -> Option<ShapeInstance> {
|
||||||
if let Some(index) = self.objects.iter().position(|o| &o.id == id) {
|
if let Some(index) = self.shape_instances.iter().position(|o| &o.id == id) {
|
||||||
Some(self.objects.remove(index))
|
Some(self.shape_instances.remove(index))
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
@ -214,7 +316,7 @@ impl VectorLayer {
|
||||||
/// only happen through the action system.
|
/// only happen through the action system.
|
||||||
pub fn modify_object_internal<F>(&mut self, id: &Uuid, f: F)
|
pub fn modify_object_internal<F>(&mut self, id: &Uuid, f: F)
|
||||||
where
|
where
|
||||||
F: FnOnce(&mut Object),
|
F: FnOnce(&mut ShapeInstance),
|
||||||
{
|
{
|
||||||
if let Some(object) = self.get_object_mut(id) {
|
if let Some(object) = self.get_object_mut(id) {
|
||||||
f(object);
|
f(object);
|
||||||
|
|
@ -222,14 +324,85 @@ impl VectorLayer {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Audio layer (placeholder for future implementation)
|
/// Audio layer containing audio clips
|
||||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
pub struct AudioLayer {
|
pub struct AudioLayer {
|
||||||
/// Base layer properties
|
/// Base layer properties
|
||||||
pub layer: Layer,
|
pub layer: Layer,
|
||||||
|
|
||||||
/// Audio file path or data reference
|
/// Clip instances (references to audio clips)
|
||||||
pub audio_source: Option<String>,
|
/// AudioLayer can contain instances of AudioClips (sampled or MIDI)
|
||||||
|
pub clip_instances: Vec<ClipInstance>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LayerTrait for AudioLayer {
|
||||||
|
fn id(&self) -> Uuid {
|
||||||
|
self.layer.id
|
||||||
|
}
|
||||||
|
|
||||||
|
fn name(&self) -> &str {
|
||||||
|
&self.layer.name
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_name(&mut self, name: String) {
|
||||||
|
self.layer.name = name;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn has_custom_name(&self) -> bool {
|
||||||
|
self.layer.has_custom_name
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_has_custom_name(&mut self, custom: bool) {
|
||||||
|
self.layer.has_custom_name = custom;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn visible(&self) -> bool {
|
||||||
|
self.layer.visible
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_visible(&mut self, visible: bool) {
|
||||||
|
self.layer.visible = visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn opacity(&self) -> f64 {
|
||||||
|
self.layer.opacity
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_opacity(&mut self, opacity: f64) {
|
||||||
|
self.layer.opacity = opacity;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn volume(&self) -> f64 {
|
||||||
|
self.layer.volume
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_volume(&mut self, volume: f64) {
|
||||||
|
self.layer.volume = volume;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn muted(&self) -> bool {
|
||||||
|
self.layer.muted
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_muted(&mut self, muted: bool) {
|
||||||
|
self.layer.muted = muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn soloed(&self) -> bool {
|
||||||
|
self.layer.soloed
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_soloed(&mut self, soloed: bool) {
|
||||||
|
self.layer.soloed = soloed;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn locked(&self) -> bool {
|
||||||
|
self.layer.locked
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_locked(&mut self, locked: bool) {
|
||||||
|
self.layer.locked = locked;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AudioLayer {
|
impl AudioLayer {
|
||||||
|
|
@ -237,19 +410,90 @@ impl AudioLayer {
|
||||||
pub fn new(name: impl Into<String>) -> Self {
|
pub fn new(name: impl Into<String>) -> Self {
|
||||||
Self {
|
Self {
|
||||||
layer: Layer::new(LayerType::Audio, name),
|
layer: Layer::new(LayerType::Audio, name),
|
||||||
audio_source: None,
|
clip_instances: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Video layer (placeholder for future implementation)
|
/// Video layer containing video clips
|
||||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
pub struct VideoLayer {
|
pub struct VideoLayer {
|
||||||
/// Base layer properties
|
/// Base layer properties
|
||||||
pub layer: Layer,
|
pub layer: Layer,
|
||||||
|
|
||||||
/// Video file path or data reference
|
/// Clip instances (references to video clips)
|
||||||
pub video_source: Option<String>,
|
/// VideoLayer can contain instances of VideoClips
|
||||||
|
pub clip_instances: Vec<ClipInstance>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LayerTrait for VideoLayer {
|
||||||
|
fn id(&self) -> Uuid {
|
||||||
|
self.layer.id
|
||||||
|
}
|
||||||
|
|
||||||
|
fn name(&self) -> &str {
|
||||||
|
&self.layer.name
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_name(&mut self, name: String) {
|
||||||
|
self.layer.name = name;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn has_custom_name(&self) -> bool {
|
||||||
|
self.layer.has_custom_name
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_has_custom_name(&mut self, custom: bool) {
|
||||||
|
self.layer.has_custom_name = custom;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn visible(&self) -> bool {
|
||||||
|
self.layer.visible
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_visible(&mut self, visible: bool) {
|
||||||
|
self.layer.visible = visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn opacity(&self) -> f64 {
|
||||||
|
self.layer.opacity
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_opacity(&mut self, opacity: f64) {
|
||||||
|
self.layer.opacity = opacity;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn volume(&self) -> f64 {
|
||||||
|
self.layer.volume
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_volume(&mut self, volume: f64) {
|
||||||
|
self.layer.volume = volume;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn muted(&self) -> bool {
|
||||||
|
self.layer.muted
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_muted(&mut self, muted: bool) {
|
||||||
|
self.layer.muted = muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn soloed(&self) -> bool {
|
||||||
|
self.layer.soloed
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_soloed(&mut self, soloed: bool) {
|
||||||
|
self.layer.soloed = soloed;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn locked(&self) -> bool {
|
||||||
|
self.layer.locked
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_locked(&mut self, locked: bool) {
|
||||||
|
self.layer.locked = locked;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl VideoLayer {
|
impl VideoLayer {
|
||||||
|
|
@ -257,7 +501,7 @@ impl VideoLayer {
|
||||||
pub fn new(name: impl Into<String>) -> Self {
|
pub fn new(name: impl Into<String>) -> Self {
|
||||||
Self {
|
Self {
|
||||||
layer: Layer::new(LayerType::Video, name),
|
layer: Layer::new(LayerType::Video, name),
|
||||||
video_source: None,
|
clip_instances: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -270,6 +514,144 @@ pub enum AnyLayer {
|
||||||
Video(VideoLayer),
|
Video(VideoLayer),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl LayerTrait for AnyLayer {
|
||||||
|
fn id(&self) -> Uuid {
|
||||||
|
match self {
|
||||||
|
AnyLayer::Vector(l) => l.id(),
|
||||||
|
AnyLayer::Audio(l) => l.id(),
|
||||||
|
AnyLayer::Video(l) => l.id(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn name(&self) -> &str {
|
||||||
|
match self {
|
||||||
|
AnyLayer::Vector(l) => l.name(),
|
||||||
|
AnyLayer::Audio(l) => l.name(),
|
||||||
|
AnyLayer::Video(l) => l.name(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_name(&mut self, name: String) {
|
||||||
|
match self {
|
||||||
|
AnyLayer::Vector(l) => l.set_name(name),
|
||||||
|
AnyLayer::Audio(l) => l.set_name(name),
|
||||||
|
AnyLayer::Video(l) => l.set_name(name),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn has_custom_name(&self) -> bool {
|
||||||
|
match self {
|
||||||
|
AnyLayer::Vector(l) => l.has_custom_name(),
|
||||||
|
AnyLayer::Audio(l) => l.has_custom_name(),
|
||||||
|
AnyLayer::Video(l) => l.has_custom_name(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_has_custom_name(&mut self, custom: bool) {
|
||||||
|
match self {
|
||||||
|
AnyLayer::Vector(l) => l.set_has_custom_name(custom),
|
||||||
|
AnyLayer::Audio(l) => l.set_has_custom_name(custom),
|
||||||
|
AnyLayer::Video(l) => l.set_has_custom_name(custom),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn visible(&self) -> bool {
|
||||||
|
match self {
|
||||||
|
AnyLayer::Vector(l) => l.visible(),
|
||||||
|
AnyLayer::Audio(l) => l.visible(),
|
||||||
|
AnyLayer::Video(l) => l.visible(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_visible(&mut self, visible: bool) {
|
||||||
|
match self {
|
||||||
|
AnyLayer::Vector(l) => l.set_visible(visible),
|
||||||
|
AnyLayer::Audio(l) => l.set_visible(visible),
|
||||||
|
AnyLayer::Video(l) => l.set_visible(visible),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn opacity(&self) -> f64 {
|
||||||
|
match self {
|
||||||
|
AnyLayer::Vector(l) => l.opacity(),
|
||||||
|
AnyLayer::Audio(l) => l.opacity(),
|
||||||
|
AnyLayer::Video(l) => l.opacity(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_opacity(&mut self, opacity: f64) {
|
||||||
|
match self {
|
||||||
|
AnyLayer::Vector(l) => l.set_opacity(opacity),
|
||||||
|
AnyLayer::Audio(l) => l.set_opacity(opacity),
|
||||||
|
AnyLayer::Video(l) => l.set_opacity(opacity),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn volume(&self) -> f64 {
|
||||||
|
match self {
|
||||||
|
AnyLayer::Vector(l) => l.volume(),
|
||||||
|
AnyLayer::Audio(l) => l.volume(),
|
||||||
|
AnyLayer::Video(l) => l.volume(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_volume(&mut self, volume: f64) {
|
||||||
|
match self {
|
||||||
|
AnyLayer::Vector(l) => l.set_volume(volume),
|
||||||
|
AnyLayer::Audio(l) => l.set_volume(volume),
|
||||||
|
AnyLayer::Video(l) => l.set_volume(volume),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn muted(&self) -> bool {
|
||||||
|
match self {
|
||||||
|
AnyLayer::Vector(l) => l.muted(),
|
||||||
|
AnyLayer::Audio(l) => l.muted(),
|
||||||
|
AnyLayer::Video(l) => l.muted(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_muted(&mut self, muted: bool) {
|
||||||
|
match self {
|
||||||
|
AnyLayer::Vector(l) => l.set_muted(muted),
|
||||||
|
AnyLayer::Audio(l) => l.set_muted(muted),
|
||||||
|
AnyLayer::Video(l) => l.set_muted(muted),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn soloed(&self) -> bool {
|
||||||
|
match self {
|
||||||
|
AnyLayer::Vector(l) => l.soloed(),
|
||||||
|
AnyLayer::Audio(l) => l.soloed(),
|
||||||
|
AnyLayer::Video(l) => l.soloed(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_soloed(&mut self, soloed: bool) {
|
||||||
|
match self {
|
||||||
|
AnyLayer::Vector(l) => l.set_soloed(soloed),
|
||||||
|
AnyLayer::Audio(l) => l.set_soloed(soloed),
|
||||||
|
AnyLayer::Video(l) => l.set_soloed(soloed),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn locked(&self) -> bool {
|
||||||
|
match self {
|
||||||
|
AnyLayer::Vector(l) => l.locked(),
|
||||||
|
AnyLayer::Audio(l) => l.locked(),
|
||||||
|
AnyLayer::Video(l) => l.locked(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_locked(&mut self, locked: bool) {
|
||||||
|
match self {
|
||||||
|
AnyLayer::Vector(l) => l.set_locked(locked),
|
||||||
|
AnyLayer::Audio(l) => l.set_locked(locked),
|
||||||
|
AnyLayer::Video(l) => l.set_locked(locked),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl AnyLayer {
|
impl AnyLayer {
|
||||||
/// Get a reference to the base layer
|
/// Get a reference to the base layer
|
||||||
pub fn layer(&self) -> &Layer {
|
pub fn layer(&self) -> &Layer {
|
||||||
|
|
@ -316,7 +698,7 @@ mod tests {
|
||||||
fn test_vector_layer() {
|
fn test_vector_layer() {
|
||||||
let vector_layer = VectorLayer::new("My Layer");
|
let vector_layer = VectorLayer::new("My Layer");
|
||||||
assert_eq!(vector_layer.shapes.len(), 0);
|
assert_eq!(vector_layer.shapes.len(), 0);
|
||||||
assert_eq!(vector_layer.objects.len(), 0);
|
assert_eq!(vector_layer.shape_instances.len(), 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,187 @@
|
||||||
|
//! Hierarchical layer tree
|
||||||
|
//!
|
||||||
|
//! Provides a tree structure for organizing layers in a hierarchical manner.
|
||||||
|
//! Layers can be nested within other layers for organizational purposes.
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
/// Node in the layer tree
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
|
pub struct LayerNode<T> {
|
||||||
|
/// The layer data
|
||||||
|
pub data: T,
|
||||||
|
|
||||||
|
/// Child layers
|
||||||
|
pub children: Vec<LayerNode<T>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T> LayerNode<T> {
|
||||||
|
/// Create a new layer node
|
||||||
|
pub fn new(data: T) -> Self {
|
||||||
|
Self {
|
||||||
|
data,
|
||||||
|
children: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add a child layer
|
||||||
|
pub fn add_child(&mut self, child: LayerNode<T>) {
|
||||||
|
self.children.push(child);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove a child layer by index
|
||||||
|
pub fn remove_child(&mut self, index: usize) -> Option<LayerNode<T>> {
|
||||||
|
if index < self.children.len() {
|
||||||
|
Some(self.children.remove(index))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get a reference to a child
|
||||||
|
pub fn get_child(&self, index: usize) -> Option<&LayerNode<T>> {
|
||||||
|
self.children.get(index)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get a mutable reference to a child
|
||||||
|
pub fn get_child_mut(&mut self, index: usize) -> Option<&mut LayerNode<T>> {
|
||||||
|
self.children.get_mut(index)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get number of children
|
||||||
|
pub fn child_count(&self) -> usize {
|
||||||
|
self.children.len()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Layer tree root
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
|
pub struct LayerTree<T> {
|
||||||
|
/// Root layers (no parent)
|
||||||
|
pub roots: Vec<LayerNode<T>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T> LayerTree<T> {
|
||||||
|
/// Create a new empty layer tree
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self { roots: Vec::new() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add a root layer and return its index
|
||||||
|
pub fn add_root(&mut self, data: T) -> usize {
|
||||||
|
let node = LayerNode::new(data);
|
||||||
|
let index = self.roots.len();
|
||||||
|
self.roots.push(node);
|
||||||
|
index
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove a root layer by index
|
||||||
|
pub fn remove_root(&mut self, index: usize) -> Option<LayerNode<T>> {
|
||||||
|
if index < self.roots.len() {
|
||||||
|
Some(self.roots.remove(index))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get a reference to a root layer
|
||||||
|
pub fn get_root(&self, index: usize) -> Option<&LayerNode<T>> {
|
||||||
|
self.roots.get(index)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get a mutable reference to a root layer
|
||||||
|
pub fn get_root_mut(&mut self, index: usize) -> Option<&mut LayerNode<T>> {
|
||||||
|
self.roots.get_mut(index)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get number of root layers
|
||||||
|
pub fn root_count(&self) -> usize {
|
||||||
|
self.roots.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Iterate over all root layers
|
||||||
|
pub fn iter(&self) -> impl Iterator<Item = &LayerNode<T>> {
|
||||||
|
self.roots.iter()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Iterate over all root layers mutably
|
||||||
|
pub fn iter_mut(&mut self) -> impl Iterator<Item = &mut LayerNode<T>> {
|
||||||
|
self.roots.iter_mut()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T> Default for LayerTree<T> {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_layer_tree_creation() {
|
||||||
|
let tree: LayerTree<i32> = LayerTree::new();
|
||||||
|
assert_eq!(tree.root_count(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_add_root_layers() {
|
||||||
|
let mut tree = LayerTree::new();
|
||||||
|
tree.add_root(1);
|
||||||
|
tree.add_root(2);
|
||||||
|
tree.add_root(3);
|
||||||
|
|
||||||
|
assert_eq!(tree.root_count(), 3);
|
||||||
|
assert_eq!(tree.get_root(0).unwrap().data, 1);
|
||||||
|
assert_eq!(tree.get_root(1).unwrap().data, 2);
|
||||||
|
assert_eq!(tree.get_root(2).unwrap().data, 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_nested_layers() {
|
||||||
|
let mut tree = LayerTree::new();
|
||||||
|
let root_idx = tree.add_root("Root");
|
||||||
|
|
||||||
|
let root = tree.get_root_mut(root_idx).unwrap();
|
||||||
|
root.add_child(LayerNode::new("Child 1"));
|
||||||
|
root.add_child(LayerNode::new("Child 2"));
|
||||||
|
|
||||||
|
assert_eq!(root.child_count(), 2);
|
||||||
|
assert_eq!(root.get_child(0).unwrap().data, "Child 1");
|
||||||
|
assert_eq!(root.get_child(1).unwrap().data, "Child 2");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_remove_root() {
|
||||||
|
let mut tree = LayerTree::new();
|
||||||
|
tree.add_root(1);
|
||||||
|
tree.add_root(2);
|
||||||
|
tree.add_root(3);
|
||||||
|
|
||||||
|
let removed = tree.remove_root(1);
|
||||||
|
assert_eq!(removed.unwrap().data, 2);
|
||||||
|
assert_eq!(tree.root_count(), 2);
|
||||||
|
assert_eq!(tree.get_root(0).unwrap().data, 1);
|
||||||
|
assert_eq!(tree.get_root(1).unwrap().data, 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_remove_child() {
|
||||||
|
let mut tree = LayerTree::new();
|
||||||
|
let root_idx = tree.add_root("Root");
|
||||||
|
|
||||||
|
let root = tree.get_root_mut(root_idx).unwrap();
|
||||||
|
root.add_child(LayerNode::new("Child 1"));
|
||||||
|
root.add_child(LayerNode::new("Child 2"));
|
||||||
|
root.add_child(LayerNode::new("Child 3"));
|
||||||
|
|
||||||
|
let removed = root.remove_child(1);
|
||||||
|
assert_eq!(removed.unwrap().data, "Child 2");
|
||||||
|
assert_eq!(root.child_count(), 2);
|
||||||
|
assert_eq!(root.get_child(0).unwrap().data, "Child 1");
|
||||||
|
assert_eq!(root.get_child(1).unwrap().data, "Child 3");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -10,6 +10,8 @@ pub mod path_fitting;
|
||||||
pub mod shape;
|
pub mod shape;
|
||||||
pub mod object;
|
pub mod object;
|
||||||
pub mod layer;
|
pub mod layer;
|
||||||
|
pub mod layer_tree;
|
||||||
|
pub mod clip;
|
||||||
pub mod document;
|
pub mod document;
|
||||||
pub mod renderer;
|
pub mod renderer;
|
||||||
pub mod action;
|
pub mod action;
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ use uuid::Uuid;
|
||||||
use vello::kurbo::Shape as KurboShape;
|
use vello::kurbo::Shape as KurboShape;
|
||||||
|
|
||||||
/// 2D transform for an object
|
/// 2D transform for an object
|
||||||
|
/// Contains only geometric transformations (position, rotation, scale, skew)
|
||||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
pub struct Transform {
|
pub struct Transform {
|
||||||
/// X position
|
/// X position
|
||||||
|
|
@ -24,8 +25,6 @@ pub struct Transform {
|
||||||
pub skew_x: f64,
|
pub skew_x: f64,
|
||||||
/// Y skew in degrees
|
/// Y skew in degrees
|
||||||
pub skew_y: f64,
|
pub skew_y: f64,
|
||||||
/// Opacity (0.0 to 1.0)
|
|
||||||
pub opacity: f64,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for Transform {
|
impl Default for Transform {
|
||||||
|
|
@ -38,7 +37,6 @@ impl Default for Transform {
|
||||||
scale_y: 1.0,
|
scale_y: 1.0,
|
||||||
skew_x: 0.0,
|
skew_x: 0.0,
|
||||||
skew_y: 0.0,
|
skew_y: 0.0,
|
||||||
opacity: 1.0,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -119,39 +117,46 @@ impl Transform {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// An object instance (shape with transform)
|
/// A shape instance (shape with transform)
|
||||||
|
/// Represents an instance of a Shape with its own transform properties.
|
||||||
|
/// Multiple instances can reference the same shape.
|
||||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
pub struct Object {
|
pub struct ShapeInstance {
|
||||||
/// Unique identifier
|
/// Unique identifier
|
||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
|
|
||||||
/// Reference to the shape this object uses
|
/// Reference to the shape this object uses
|
||||||
pub shape_id: Uuid,
|
pub shape_id: Uuid,
|
||||||
|
|
||||||
/// Transform properties
|
/// Transform properties (position, rotation, scale, skew)
|
||||||
pub transform: Transform,
|
pub transform: Transform,
|
||||||
|
|
||||||
|
/// Opacity (0.0 to 1.0, separate from geometric transform)
|
||||||
|
pub opacity: f64,
|
||||||
|
|
||||||
/// Name for display in UI
|
/// Name for display in UI
|
||||||
pub name: Option<String>,
|
pub name: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Object {
|
impl ShapeInstance {
|
||||||
/// Create a new object for a shape
|
/// Create a new shape instance for a shape
|
||||||
pub fn new(shape_id: Uuid) -> Self {
|
pub fn new(shape_id: Uuid) -> Self {
|
||||||
Self {
|
Self {
|
||||||
id: Uuid::new_v4(),
|
id: Uuid::new_v4(),
|
||||||
shape_id,
|
shape_id,
|
||||||
transform: Transform::default(),
|
transform: Transform::default(),
|
||||||
|
opacity: 1.0,
|
||||||
name: None,
|
name: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create a new object with a specific ID
|
/// Create a new shape instance with a specific ID
|
||||||
pub fn with_id(id: Uuid, shape_id: Uuid) -> Self {
|
pub fn with_id(id: Uuid, shape_id: Uuid) -> Self {
|
||||||
Self {
|
Self {
|
||||||
id,
|
id,
|
||||||
shape_id,
|
shape_id,
|
||||||
transform: Transform::default(),
|
transform: Transform::default(),
|
||||||
|
opacity: 1.0,
|
||||||
name: None,
|
name: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -174,15 +179,15 @@ impl Object {
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Convert object transform to affine matrix
|
/// Convert shape instance transform to affine matrix
|
||||||
pub fn to_affine(&self) -> kurbo::Affine {
|
pub fn to_affine(&self) -> kurbo::Affine {
|
||||||
self.transform.to_affine()
|
self.transform.to_affine()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get the bounding box of this object given its shape
|
/// Get the bounding box of this shape instance given its shape
|
||||||
///
|
///
|
||||||
/// Returns the bounding box in the object's parent coordinate space
|
/// Returns the bounding box in the instance's parent coordinate space
|
||||||
/// (i.e., with the object's transform applied).
|
/// (i.e., with the instance's transform applied).
|
||||||
pub fn bounding_box(&self, shape: &crate::shape::Shape) -> kurbo::Rect {
|
pub fn bounding_box(&self, shape: &crate::shape::Shape) -> kurbo::Rect {
|
||||||
let path_bbox = shape.path().bounding_box();
|
let path_bbox = shape.path().bounding_box();
|
||||||
self.to_affine().transform_rect_bbox(path_bbox)
|
self.to_affine().transform_rect_bbox(path_bbox)
|
||||||
|
|
@ -214,11 +219,11 @@ mod tests {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_object_creation() {
|
fn test_shape_instance_creation() {
|
||||||
let shape_id = Uuid::new_v4();
|
let shape_id = Uuid::new_v4();
|
||||||
let object = Object::new(shape_id);
|
let shape_instance = ShapeInstance::new(shape_id);
|
||||||
|
|
||||||
assert_eq!(object.shape_id, shape_id);
|
assert_eq!(shape_instance.shape_id, shape_id);
|
||||||
assert_eq!(object.transform.x, 0.0);
|
assert_eq!(shape_instance.transform.x, 0.0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
use crate::animation::TransformProperty;
|
use crate::animation::TransformProperty;
|
||||||
use crate::document::Document;
|
use crate::document::Document;
|
||||||
use crate::layer::{AnyLayer, VectorLayer};
|
use crate::layer::{AnyLayer, VectorLayer};
|
||||||
|
use crate::object::ShapeInstance;
|
||||||
use kurbo::{Affine, Shape};
|
use kurbo::{Affine, Shape};
|
||||||
use vello::kurbo::Rect;
|
use vello::kurbo::Rect;
|
||||||
use vello::peniko::Fill;
|
use vello::peniko::Fill;
|
||||||
|
|
@ -21,8 +22,9 @@ pub fn render_document_with_transform(document: &Document, scene: &mut Scene, ba
|
||||||
// 1. Draw background
|
// 1. Draw background
|
||||||
render_background(document, scene, base_transform);
|
render_background(document, scene, base_transform);
|
||||||
|
|
||||||
// 2. Recursively render the root graphics object
|
// 2. Recursively render the root graphics object at current time
|
||||||
render_graphics_object(document, scene, base_transform);
|
let time = document.current_time;
|
||||||
|
render_graphics_object(document, time, scene, base_transform);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Draw the document background
|
/// Draw the document background
|
||||||
|
|
@ -42,17 +44,17 @@ fn render_background(document: &Document, scene: &mut Scene, base_transform: Aff
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Recursively render the root graphics object and its children
|
/// Recursively render the root graphics object and its children
|
||||||
fn render_graphics_object(document: &Document, scene: &mut Scene, base_transform: Affine) {
|
fn render_graphics_object(document: &Document, time: f64, scene: &mut Scene, base_transform: Affine) {
|
||||||
// Render all visible layers in the root graphics object
|
// Render all visible layers in the root graphics object
|
||||||
for layer in document.visible_layers() {
|
for layer in document.visible_layers() {
|
||||||
render_layer(document, layer, scene, base_transform);
|
render_layer(document, time, layer, scene, base_transform);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Render a single layer
|
/// Render a single layer
|
||||||
fn render_layer(document: &Document, layer: &AnyLayer, scene: &mut Scene, base_transform: Affine) {
|
fn render_layer(document: &Document, time: f64, layer: &AnyLayer, scene: &mut Scene, base_transform: Affine) {
|
||||||
match layer {
|
match layer {
|
||||||
AnyLayer::Vector(vector_layer) => render_vector_layer(document, vector_layer, scene, base_transform),
|
AnyLayer::Vector(vector_layer) => render_vector_layer(document, time, vector_layer, scene, base_transform),
|
||||||
AnyLayer::Audio(_) => {
|
AnyLayer::Audio(_) => {
|
||||||
// Audio layers don't render visually
|
// Audio layers don't render visually
|
||||||
}
|
}
|
||||||
|
|
@ -62,28 +64,65 @@ fn render_layer(document: &Document, layer: &AnyLayer, scene: &mut Scene, base_t
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Render a vector layer with all its objects
|
/// Render a clip instance (recursive rendering for nested compositions)
|
||||||
fn render_vector_layer(document: &Document, layer: &VectorLayer, scene: &mut Scene, base_transform: Affine) {
|
fn render_clip_instance(
|
||||||
let time = document.current_time;
|
document: &Document,
|
||||||
|
time: f64,
|
||||||
|
clip_instance: &crate::clip::ClipInstance,
|
||||||
|
_parent_opacity: f64,
|
||||||
|
scene: &mut Scene,
|
||||||
|
base_transform: Affine,
|
||||||
|
) {
|
||||||
|
// Try to find the clip in the document's clip libraries
|
||||||
|
// For now, only handle VectorClips (VideoClip and AudioClip rendering not yet implemented)
|
||||||
|
let Some(vector_clip) = document.vector_clips.get(&clip_instance.clip_id) else {
|
||||||
|
return; // Clip not found or not a vector clip
|
||||||
|
};
|
||||||
|
|
||||||
|
// Remap timeline time to clip's internal time
|
||||||
|
let Some(clip_time) = clip_instance.remap_time(time, vector_clip.duration) else {
|
||||||
|
return; // Clip instance not active at this time
|
||||||
|
};
|
||||||
|
|
||||||
|
// Build transform for this clip instance
|
||||||
|
let instance_transform = base_transform * clip_instance.to_affine();
|
||||||
|
|
||||||
|
// TODO: Properly handle clip instance opacity by threading opacity through rendering pipeline
|
||||||
|
// Currently clip_instance.opacity is not being applied to nested layers
|
||||||
|
|
||||||
|
// Recursively render all root layers in the clip at the remapped time
|
||||||
|
for layer_node in vector_clip.layers.iter() {
|
||||||
|
// TODO: Filter by visibility and time range once LayerNode exposes that data
|
||||||
|
render_layer(document, clip_time, &layer_node.data, scene, instance_transform);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render a vector layer with all its clip instances and shape instances
|
||||||
|
fn render_vector_layer(document: &Document, time: f64, layer: &VectorLayer, scene: &mut Scene, base_transform: Affine) {
|
||||||
|
|
||||||
// Get layer-level opacity
|
// Get layer-level opacity
|
||||||
let layer_opacity = layer.layer.opacity;
|
let layer_opacity = layer.layer.opacity;
|
||||||
|
|
||||||
// Render each object in the layer
|
// Render clip instances first (they appear under shape instances)
|
||||||
for object in &layer.objects {
|
for clip_instance in &layer.clip_instances {
|
||||||
// Get the shape for this object
|
render_clip_instance(document, time, clip_instance, layer_opacity, scene, base_transform);
|
||||||
let Some(shape) = layer.get_shape(&object.shape_id) else {
|
}
|
||||||
|
|
||||||
|
// Render each shape instance in the layer
|
||||||
|
for shape_instance in &layer.shape_instances {
|
||||||
|
// Get the shape for this instance
|
||||||
|
let Some(shape) = layer.get_shape(&shape_instance.shape_id) else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Evaluate animated properties
|
// Evaluate animated properties
|
||||||
let transform = &object.transform;
|
let transform = &shape_instance.transform;
|
||||||
let x = layer
|
let x = layer
|
||||||
.layer
|
.layer
|
||||||
.animation_data
|
.animation_data
|
||||||
.eval(
|
.eval(
|
||||||
&crate::animation::AnimationTarget::Object {
|
&crate::animation::AnimationTarget::Object {
|
||||||
id: object.id,
|
id: shape_instance.id,
|
||||||
property: TransformProperty::X,
|
property: TransformProperty::X,
|
||||||
},
|
},
|
||||||
time,
|
time,
|
||||||
|
|
@ -94,7 +133,7 @@ fn render_vector_layer(document: &Document, layer: &VectorLayer, scene: &mut Sce
|
||||||
.animation_data
|
.animation_data
|
||||||
.eval(
|
.eval(
|
||||||
&crate::animation::AnimationTarget::Object {
|
&crate::animation::AnimationTarget::Object {
|
||||||
id: object.id,
|
id: shape_instance.id,
|
||||||
property: TransformProperty::Y,
|
property: TransformProperty::Y,
|
||||||
},
|
},
|
||||||
time,
|
time,
|
||||||
|
|
@ -105,7 +144,7 @@ fn render_vector_layer(document: &Document, layer: &VectorLayer, scene: &mut Sce
|
||||||
.animation_data
|
.animation_data
|
||||||
.eval(
|
.eval(
|
||||||
&crate::animation::AnimationTarget::Object {
|
&crate::animation::AnimationTarget::Object {
|
||||||
id: object.id,
|
id: shape_instance.id,
|
||||||
property: TransformProperty::Rotation,
|
property: TransformProperty::Rotation,
|
||||||
},
|
},
|
||||||
time,
|
time,
|
||||||
|
|
@ -116,7 +155,7 @@ fn render_vector_layer(document: &Document, layer: &VectorLayer, scene: &mut Sce
|
||||||
.animation_data
|
.animation_data
|
||||||
.eval(
|
.eval(
|
||||||
&crate::animation::AnimationTarget::Object {
|
&crate::animation::AnimationTarget::Object {
|
||||||
id: object.id,
|
id: shape_instance.id,
|
||||||
property: TransformProperty::ScaleX,
|
property: TransformProperty::ScaleX,
|
||||||
},
|
},
|
||||||
time,
|
time,
|
||||||
|
|
@ -127,7 +166,7 @@ fn render_vector_layer(document: &Document, layer: &VectorLayer, scene: &mut Sce
|
||||||
.animation_data
|
.animation_data
|
||||||
.eval(
|
.eval(
|
||||||
&crate::animation::AnimationTarget::Object {
|
&crate::animation::AnimationTarget::Object {
|
||||||
id: object.id,
|
id: shape_instance.id,
|
||||||
property: TransformProperty::ScaleY,
|
property: TransformProperty::ScaleY,
|
||||||
},
|
},
|
||||||
time,
|
time,
|
||||||
|
|
@ -138,7 +177,7 @@ fn render_vector_layer(document: &Document, layer: &VectorLayer, scene: &mut Sce
|
||||||
.animation_data
|
.animation_data
|
||||||
.eval(
|
.eval(
|
||||||
&crate::animation::AnimationTarget::Object {
|
&crate::animation::AnimationTarget::Object {
|
||||||
id: object.id,
|
id: shape_instance.id,
|
||||||
property: TransformProperty::SkewX,
|
property: TransformProperty::SkewX,
|
||||||
},
|
},
|
||||||
time,
|
time,
|
||||||
|
|
@ -149,7 +188,7 @@ fn render_vector_layer(document: &Document, layer: &VectorLayer, scene: &mut Sce
|
||||||
.animation_data
|
.animation_data
|
||||||
.eval(
|
.eval(
|
||||||
&crate::animation::AnimationTarget::Object {
|
&crate::animation::AnimationTarget::Object {
|
||||||
id: object.id,
|
id: shape_instance.id,
|
||||||
property: TransformProperty::SkewY,
|
property: TransformProperty::SkewY,
|
||||||
},
|
},
|
||||||
time,
|
time,
|
||||||
|
|
@ -160,11 +199,11 @@ fn render_vector_layer(document: &Document, layer: &VectorLayer, scene: &mut Sce
|
||||||
.animation_data
|
.animation_data
|
||||||
.eval(
|
.eval(
|
||||||
&crate::animation::AnimationTarget::Object {
|
&crate::animation::AnimationTarget::Object {
|
||||||
id: object.id,
|
id: shape_instance.id,
|
||||||
property: TransformProperty::Opacity,
|
property: TransformProperty::Opacity,
|
||||||
},
|
},
|
||||||
time,
|
time,
|
||||||
transform.opacity,
|
shape_instance.opacity,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Check if shape has morphing animation
|
// Check if shape has morphing animation
|
||||||
|
|
@ -276,7 +315,7 @@ mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::document::Document;
|
use crate::document::Document;
|
||||||
use crate::layer::{AnyLayer, VectorLayer};
|
use crate::layer::{AnyLayer, VectorLayer};
|
||||||
use crate::object::Object;
|
use crate::object::ShapeInstance;
|
||||||
use crate::shape::{Shape, ShapeColor};
|
use crate::shape::{Shape, ShapeColor};
|
||||||
use kurbo::{Circle, Shape as KurboShape};
|
use kurbo::{Circle, Shape as KurboShape};
|
||||||
|
|
||||||
|
|
@ -298,13 +337,13 @@ mod tests {
|
||||||
let path = circle.to_path(0.1);
|
let path = circle.to_path(0.1);
|
||||||
let shape = Shape::new(path).with_fill(ShapeColor::rgb(255, 0, 0));
|
let shape = Shape::new(path).with_fill(ShapeColor::rgb(255, 0, 0));
|
||||||
|
|
||||||
// Create an object for the shape
|
// Create a shape instance for the shape
|
||||||
let object = Object::new(shape.id);
|
let shape_instance = ShapeInstance::new(shape.id);
|
||||||
|
|
||||||
// Create a vector layer
|
// Create a vector layer
|
||||||
let mut vector_layer = VectorLayer::new("Layer 1");
|
let mut vector_layer = VectorLayer::new("Layer 1");
|
||||||
vector_layer.add_shape(shape);
|
vector_layer.add_shape(shape);
|
||||||
vector_layer.add_object(object);
|
vector_layer.add_object(shape_instance);
|
||||||
|
|
||||||
// Add to document
|
// Add to document
|
||||||
doc.root.add_child(AnyLayer::Vector(vector_layer));
|
doc.root.add_child(AnyLayer::Vector(vector_layer));
|
||||||
|
|
|
||||||
|
|
@ -1,63 +1,67 @@
|
||||||
//! Selection state management
|
//! Selection state management
|
||||||
//!
|
//!
|
||||||
//! Tracks selected objects and shapes for editing operations.
|
//! Tracks selected shape instances, clip instances, and shapes for editing operations.
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
/// Selection state for the editor
|
/// Selection state for the editor
|
||||||
///
|
///
|
||||||
/// Maintains sets of selected objects and shapes.
|
/// Maintains sets of selected shape instances, clip instances, and shapes.
|
||||||
/// This is separate from the document to make it easy to
|
/// This is separate from the document to make it easy to
|
||||||
/// pass around for UI rendering without needing mutable access.
|
/// pass around for UI rendering without needing mutable access.
|
||||||
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
||||||
pub struct Selection {
|
pub struct Selection {
|
||||||
/// Currently selected objects (instances)
|
/// Currently selected shape instances
|
||||||
selected_objects: Vec<Uuid>,
|
selected_shape_instances: Vec<Uuid>,
|
||||||
|
|
||||||
/// Currently selected shapes (definitions)
|
/// Currently selected shapes (definitions)
|
||||||
selected_shapes: Vec<Uuid>,
|
selected_shapes: Vec<Uuid>,
|
||||||
|
|
||||||
|
/// Currently selected clip instances
|
||||||
|
selected_clip_instances: Vec<Uuid>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Selection {
|
impl Selection {
|
||||||
/// Create a new empty selection
|
/// Create a new empty selection
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
Self {
|
Self {
|
||||||
selected_objects: Vec::new(),
|
selected_shape_instances: Vec::new(),
|
||||||
selected_shapes: Vec::new(),
|
selected_shapes: Vec::new(),
|
||||||
|
selected_clip_instances: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Add an object to the selection
|
/// Add a shape instance to the selection
|
||||||
pub fn add_object(&mut self, id: Uuid) {
|
pub fn add_shape_instance(&mut self, id: Uuid) {
|
||||||
if !self.selected_objects.contains(&id) {
|
if !self.selected_shape_instances.contains(&id) {
|
||||||
self.selected_objects.push(id);
|
self.selected_shape_instances.push(id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Add a shape to the selection
|
/// Add a shape definition to the selection
|
||||||
pub fn add_shape(&mut self, id: Uuid) {
|
pub fn add_shape(&mut self, id: Uuid) {
|
||||||
if !self.selected_shapes.contains(&id) {
|
if !self.selected_shapes.contains(&id) {
|
||||||
self.selected_shapes.push(id);
|
self.selected_shapes.push(id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Remove an object from the selection
|
/// Remove a shape instance from the selection
|
||||||
pub fn remove_object(&mut self, id: &Uuid) {
|
pub fn remove_shape_instance(&mut self, id: &Uuid) {
|
||||||
self.selected_objects.retain(|&x| x != *id);
|
self.selected_shape_instances.retain(|&x| x != *id);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Remove a shape from the selection
|
/// Remove a shape definition from the selection
|
||||||
pub fn remove_shape(&mut self, id: &Uuid) {
|
pub fn remove_shape(&mut self, id: &Uuid) {
|
||||||
self.selected_shapes.retain(|&x| x != *id);
|
self.selected_shapes.retain(|&x| x != *id);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Toggle an object's selection state
|
/// Toggle a shape instance's selection state
|
||||||
pub fn toggle_object(&mut self, id: Uuid) {
|
pub fn toggle_shape_instance(&mut self, id: Uuid) {
|
||||||
if self.contains_object(&id) {
|
if self.contains_shape_instance(&id) {
|
||||||
self.remove_object(&id);
|
self.remove_shape_instance(&id);
|
||||||
} else {
|
} else {
|
||||||
self.add_object(id);
|
self.add_shape_instance(id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -70,15 +74,37 @@ impl Selection {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Add a clip instance to the selection
|
||||||
|
pub fn add_clip_instance(&mut self, id: Uuid) {
|
||||||
|
if !self.selected_clip_instances.contains(&id) {
|
||||||
|
self.selected_clip_instances.push(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove a clip instance from the selection
|
||||||
|
pub fn remove_clip_instance(&mut self, id: &Uuid) {
|
||||||
|
self.selected_clip_instances.retain(|&x| x != *id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Toggle a clip instance's selection state
|
||||||
|
pub fn toggle_clip_instance(&mut self, id: Uuid) {
|
||||||
|
if self.contains_clip_instance(&id) {
|
||||||
|
self.remove_clip_instance(&id);
|
||||||
|
} else {
|
||||||
|
self.add_clip_instance(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Clear all selections
|
/// Clear all selections
|
||||||
pub fn clear(&mut self) {
|
pub fn clear(&mut self) {
|
||||||
self.selected_objects.clear();
|
self.selected_shape_instances.clear();
|
||||||
self.selected_shapes.clear();
|
self.selected_shapes.clear();
|
||||||
|
self.selected_clip_instances.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Clear only object selections
|
/// Clear only object selections
|
||||||
pub fn clear_objects(&mut self) {
|
pub fn clear_shape_instances(&mut self) {
|
||||||
self.selected_objects.clear();
|
self.selected_shape_instances.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Clear only shape selections
|
/// Clear only shape selections
|
||||||
|
|
@ -86,9 +112,14 @@ impl Selection {
|
||||||
self.selected_shapes.clear();
|
self.selected_shapes.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Clear only clip instance selections
|
||||||
|
pub fn clear_clip_instances(&mut self) {
|
||||||
|
self.selected_clip_instances.clear();
|
||||||
|
}
|
||||||
|
|
||||||
/// Check if an object is selected
|
/// Check if an object is selected
|
||||||
pub fn contains_object(&self, id: &Uuid) -> bool {
|
pub fn contains_shape_instance(&self, id: &Uuid) -> bool {
|
||||||
self.selected_objects.contains(id)
|
self.selected_shape_instances.contains(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check if a shape is selected
|
/// Check if a shape is selected
|
||||||
|
|
@ -96,14 +127,21 @@ impl Selection {
|
||||||
self.selected_shapes.contains(id)
|
self.selected_shapes.contains(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Check if a clip instance is selected
|
||||||
|
pub fn contains_clip_instance(&self, id: &Uuid) -> bool {
|
||||||
|
self.selected_clip_instances.contains(id)
|
||||||
|
}
|
||||||
|
|
||||||
/// Check if selection is empty
|
/// Check if selection is empty
|
||||||
pub fn is_empty(&self) -> bool {
|
pub fn is_empty(&self) -> bool {
|
||||||
self.selected_objects.is_empty() && self.selected_shapes.is_empty()
|
self.selected_shape_instances.is_empty()
|
||||||
|
&& self.selected_shapes.is_empty()
|
||||||
|
&& self.selected_clip_instances.is_empty()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get the selected objects
|
/// Get the selected objects
|
||||||
pub fn objects(&self) -> &[Uuid] {
|
pub fn shape_instances(&self) -> &[Uuid] {
|
||||||
&self.selected_objects
|
&self.selected_shape_instances
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get the selected shapes
|
/// Get the selected shapes
|
||||||
|
|
@ -112,8 +150,8 @@ impl Selection {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get the number of selected objects
|
/// Get the number of selected objects
|
||||||
pub fn object_count(&self) -> usize {
|
pub fn shape_instance_count(&self) -> usize {
|
||||||
self.selected_objects.len()
|
self.selected_shape_instances.len()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get the number of selected shapes
|
/// Get the number of selected shapes
|
||||||
|
|
@ -121,10 +159,20 @@ impl Selection {
|
||||||
self.selected_shapes.len()
|
self.selected_shapes.len()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Get the selected clip instances
|
||||||
|
pub fn clip_instances(&self) -> &[Uuid] {
|
||||||
|
&self.selected_clip_instances
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the number of selected clip instances
|
||||||
|
pub fn clip_instance_count(&self) -> usize {
|
||||||
|
self.selected_clip_instances.len()
|
||||||
|
}
|
||||||
|
|
||||||
/// Set selection to a single object (clears previous selection)
|
/// Set selection to a single object (clears previous selection)
|
||||||
pub fn select_only_object(&mut self, id: Uuid) {
|
pub fn select_only_shape_instance(&mut self, id: Uuid) {
|
||||||
self.clear();
|
self.clear();
|
||||||
self.add_object(id);
|
self.add_shape_instance(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set selection to a single shape (clears previous selection)
|
/// Set selection to a single shape (clears previous selection)
|
||||||
|
|
@ -133,11 +181,17 @@ impl Selection {
|
||||||
self.add_shape(id);
|
self.add_shape(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Set selection to a single clip instance (clears previous selection)
|
||||||
|
pub fn select_only_clip_instance(&mut self, id: Uuid) {
|
||||||
|
self.clear();
|
||||||
|
self.add_clip_instance(id);
|
||||||
|
}
|
||||||
|
|
||||||
/// Set selection to multiple objects (clears previous selection)
|
/// Set selection to multiple objects (clears previous selection)
|
||||||
pub fn select_objects(&mut self, ids: &[Uuid]) {
|
pub fn select_shape_instances(&mut self, ids: &[Uuid]) {
|
||||||
self.clear_objects();
|
self.clear_shape_instances();
|
||||||
for &id in ids {
|
for &id in ids {
|
||||||
self.add_object(id);
|
self.add_shape_instance(id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -148,6 +202,14 @@ impl Selection {
|
||||||
self.add_shape(id);
|
self.add_shape(id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Set selection to multiple clip instances (clears previous selection)
|
||||||
|
pub fn select_clip_instances(&mut self, ids: &[Uuid]) {
|
||||||
|
self.clear_clip_instances();
|
||||||
|
for &id in ids {
|
||||||
|
self.add_clip_instance(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|
@ -158,7 +220,7 @@ mod tests {
|
||||||
fn test_selection_creation() {
|
fn test_selection_creation() {
|
||||||
let selection = Selection::new();
|
let selection = Selection::new();
|
||||||
assert!(selection.is_empty());
|
assert!(selection.is_empty());
|
||||||
assert_eq!(selection.object_count(), 0);
|
assert_eq!(selection.shape_instance_count(), 0);
|
||||||
assert_eq!(selection.shape_count(), 0);
|
assert_eq!(selection.shape_count(), 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -168,17 +230,17 @@ mod tests {
|
||||||
let id1 = Uuid::new_v4();
|
let id1 = Uuid::new_v4();
|
||||||
let id2 = Uuid::new_v4();
|
let id2 = Uuid::new_v4();
|
||||||
|
|
||||||
selection.add_object(id1);
|
selection.add_shape_instance(id1);
|
||||||
assert_eq!(selection.object_count(), 1);
|
assert_eq!(selection.shape_instance_count(), 1);
|
||||||
assert!(selection.contains_object(&id1));
|
assert!(selection.contains_shape_instance(&id1));
|
||||||
|
|
||||||
selection.add_object(id2);
|
selection.add_shape_instance(id2);
|
||||||
assert_eq!(selection.object_count(), 2);
|
assert_eq!(selection.shape_instance_count(), 2);
|
||||||
|
|
||||||
selection.remove_object(&id1);
|
selection.remove_shape_instance(&id1);
|
||||||
assert_eq!(selection.object_count(), 1);
|
assert_eq!(selection.shape_instance_count(), 1);
|
||||||
assert!(!selection.contains_object(&id1));
|
assert!(!selection.contains_shape_instance(&id1));
|
||||||
assert!(selection.contains_object(&id2));
|
assert!(selection.contains_shape_instance(&id2));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -186,11 +248,11 @@ mod tests {
|
||||||
let mut selection = Selection::new();
|
let mut selection = Selection::new();
|
||||||
let id = Uuid::new_v4();
|
let id = Uuid::new_v4();
|
||||||
|
|
||||||
selection.toggle_object(id);
|
selection.toggle_shape_instance(id);
|
||||||
assert!(selection.contains_object(&id));
|
assert!(selection.contains_shape_instance(&id));
|
||||||
|
|
||||||
selection.toggle_object(id);
|
selection.toggle_shape_instance(id);
|
||||||
assert!(!selection.contains_object(&id));
|
assert!(!selection.contains_shape_instance(&id));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -199,20 +261,20 @@ mod tests {
|
||||||
let id1 = Uuid::new_v4();
|
let id1 = Uuid::new_v4();
|
||||||
let id2 = Uuid::new_v4();
|
let id2 = Uuid::new_v4();
|
||||||
|
|
||||||
selection.add_object(id1);
|
selection.add_shape_instance(id1);
|
||||||
selection.add_object(id2);
|
selection.add_shape_instance(id2);
|
||||||
assert_eq!(selection.object_count(), 2);
|
assert_eq!(selection.shape_instance_count(), 2);
|
||||||
|
|
||||||
selection.select_only_object(id1);
|
selection.select_only_shape_instance(id1);
|
||||||
assert_eq!(selection.object_count(), 1);
|
assert_eq!(selection.shape_instance_count(), 1);
|
||||||
assert!(selection.contains_object(&id1));
|
assert!(selection.contains_shape_instance(&id1));
|
||||||
assert!(!selection.contains_object(&id2));
|
assert!(!selection.contains_shape_instance(&id2));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_clear() {
|
fn test_clear() {
|
||||||
let mut selection = Selection::new();
|
let mut selection = Selection::new();
|
||||||
selection.add_object(Uuid::new_v4());
|
selection.add_shape_instance(Uuid::new_v4());
|
||||||
selection.add_shape(Uuid::new_v4());
|
selection.add_shape(Uuid::new_v4());
|
||||||
|
|
||||||
assert!(!selection.is_empty());
|
assert!(!selection.is_empty());
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ edition = "2021"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
lightningbeam-core = { path = "../lightningbeam-core" }
|
lightningbeam-core = { path = "../lightningbeam-core" }
|
||||||
|
daw-backend = { path = "../../daw-backend" }
|
||||||
|
|
||||||
# UI Framework
|
# UI Framework
|
||||||
eframe = { workspace = true }
|
eframe = { workspace = true }
|
||||||
|
|
|
||||||
|
|
@ -266,6 +266,9 @@ struct EditorApp {
|
||||||
draw_simplify_mode: lightningbeam_core::tool::SimplifyMode, // Current simplification mode for draw tool
|
draw_simplify_mode: lightningbeam_core::tool::SimplifyMode, // Current simplification mode for draw tool
|
||||||
rdp_tolerance: f64, // RDP simplification tolerance (default: 10.0)
|
rdp_tolerance: f64, // RDP simplification tolerance (default: 10.0)
|
||||||
schneider_max_error: f64, // Schneider curve fitting max error (default: 30.0)
|
schneider_max_error: f64, // Schneider curve fitting max error (default: 30.0)
|
||||||
|
// Audio engine integration
|
||||||
|
audio_controller: Option<daw_backend::EngineController>, // Audio engine controller for playback
|
||||||
|
audio_event_rx: Option<rtrb::Consumer<daw_backend::AudioEvent>>, // Audio event receiver
|
||||||
}
|
}
|
||||||
|
|
||||||
impl EditorApp {
|
impl EditorApp {
|
||||||
|
|
@ -282,14 +285,14 @@ impl EditorApp {
|
||||||
|
|
||||||
// Add a test layer with a simple shape to visualize
|
// Add a test layer with a simple shape to visualize
|
||||||
use lightningbeam_core::layer::{AnyLayer, VectorLayer};
|
use lightningbeam_core::layer::{AnyLayer, VectorLayer};
|
||||||
use lightningbeam_core::object::Object;
|
use lightningbeam_core::object::ShapeInstance;
|
||||||
use lightningbeam_core::shape::{Shape, ShapeColor};
|
use lightningbeam_core::shape::{Shape, ShapeColor};
|
||||||
use vello::kurbo::{Circle, Shape as KurboShape};
|
use vello::kurbo::{Circle, Shape as KurboShape};
|
||||||
|
|
||||||
let circle = Circle::new((200.0, 150.0), 50.0);
|
let circle = Circle::new((200.0, 150.0), 50.0);
|
||||||
let path = circle.to_path(0.1);
|
let path = circle.to_path(0.1);
|
||||||
let shape = Shape::new(path).with_fill(ShapeColor::rgb(100, 150, 250));
|
let shape = Shape::new(path).with_fill(ShapeColor::rgb(100, 150, 250));
|
||||||
let object = Object::new(shape.id);
|
let object = ShapeInstance::new(shape.id);
|
||||||
|
|
||||||
let mut vector_layer = VectorLayer::new("Layer 1");
|
let mut vector_layer = VectorLayer::new("Layer 1");
|
||||||
vector_layer.add_shape(shape);
|
vector_layer.add_shape(shape);
|
||||||
|
|
@ -464,8 +467,17 @@ impl EditorApp {
|
||||||
|
|
||||||
// Layer menu
|
// Layer menu
|
||||||
MenuAction::AddLayer => {
|
MenuAction::AddLayer => {
|
||||||
println!("Menu: Add Layer");
|
// Create a new vector layer with a default name
|
||||||
// TODO: Implement add layer
|
let layer_count = self.action_executor.document().root.children.len();
|
||||||
|
let layer_name = format!("Layer {}", layer_count + 1);
|
||||||
|
|
||||||
|
let action = lightningbeam_core::actions::AddLayerAction::new_vector(layer_name);
|
||||||
|
self.action_executor.execute(Box::new(action));
|
||||||
|
|
||||||
|
// Select the newly created layer (last child in the document)
|
||||||
|
if let Some(last_layer) = self.action_executor.document().root.children.last() {
|
||||||
|
self.active_layer_id = Some(last_layer.id());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
MenuAction::AddVideoLayer => {
|
MenuAction::AddVideoLayer => {
|
||||||
println!("Menu: Add Video Layer");
|
println!("Menu: Add Video Layer");
|
||||||
|
|
@ -479,6 +491,65 @@ impl EditorApp {
|
||||||
println!("Menu: Add MIDI Track");
|
println!("Menu: Add MIDI Track");
|
||||||
// TODO: Implement add MIDI track
|
// TODO: Implement add MIDI track
|
||||||
}
|
}
|
||||||
|
MenuAction::AddTestClip => {
|
||||||
|
// Require an active layer
|
||||||
|
if let Some(layer_id) = self.active_layer_id {
|
||||||
|
// Create a test vector clip (5 second duration)
|
||||||
|
use lightningbeam_core::clip::{VectorClip, ClipInstance};
|
||||||
|
use lightningbeam_core::layer::{VectorLayer, AnyLayer};
|
||||||
|
use lightningbeam_core::shape::{Shape, ShapeColor};
|
||||||
|
use lightningbeam_core::object::ShapeInstance;
|
||||||
|
use kurbo::{Circle, Rect, Shape as KurboShape};
|
||||||
|
|
||||||
|
let mut test_clip = VectorClip::new("Test Clip", 400.0, 400.0, 5.0);
|
||||||
|
|
||||||
|
// Create a layer with some shapes
|
||||||
|
let mut layer = VectorLayer::new("Test Layer");
|
||||||
|
|
||||||
|
// Create a red circle shape
|
||||||
|
let circle_path = Circle::new((100.0, 100.0), 50.0).to_path(0.1);
|
||||||
|
let mut circle_shape = Shape::new(circle_path);
|
||||||
|
circle_shape.fill_color = Some(ShapeColor::rgb(255, 0, 0));
|
||||||
|
let circle_id = circle_shape.id;
|
||||||
|
layer.add_shape(circle_shape);
|
||||||
|
|
||||||
|
// Create a blue rectangle shape
|
||||||
|
let rect_path = Rect::new(200.0, 50.0, 350.0, 150.0).to_path(0.1);
|
||||||
|
let mut rect_shape = Shape::new(rect_path);
|
||||||
|
rect_shape.fill_color = Some(ShapeColor::rgb(0, 0, 255));
|
||||||
|
let rect_id = rect_shape.id;
|
||||||
|
layer.add_shape(rect_shape);
|
||||||
|
|
||||||
|
// Add shape instances
|
||||||
|
layer.shape_instances.push(ShapeInstance::new(circle_id));
|
||||||
|
layer.shape_instances.push(ShapeInstance::new(rect_id));
|
||||||
|
|
||||||
|
// Add the layer to the clip
|
||||||
|
test_clip.layers.add_root(AnyLayer::Vector(layer));
|
||||||
|
|
||||||
|
// Add to document's clip library
|
||||||
|
let clip_id = self.action_executor.document_mut().add_vector_clip(test_clip);
|
||||||
|
|
||||||
|
// Create clip instance at current time
|
||||||
|
let current_time = self.action_executor.document().current_time;
|
||||||
|
let instance = ClipInstance::new(clip_id)
|
||||||
|
.with_timeline_start(current_time)
|
||||||
|
.with_name("Test Instance");
|
||||||
|
|
||||||
|
// Add to layer (only vector layers can have clip instances)
|
||||||
|
if let Some(layer) = self.action_executor.document_mut().get_layer_mut(&layer_id) {
|
||||||
|
use lightningbeam_core::layer::AnyLayer;
|
||||||
|
if let AnyLayer::Vector(vector_layer) = layer {
|
||||||
|
vector_layer.clip_instances.push(instance);
|
||||||
|
println!("Added test clip instance with red circle and blue rectangle at time {}", current_time);
|
||||||
|
} else {
|
||||||
|
println!("Can only add clip instances to vector layers");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
println!("No active layer selected");
|
||||||
|
}
|
||||||
|
}
|
||||||
MenuAction::DeleteLayer => {
|
MenuAction::DeleteLayer => {
|
||||||
println!("Menu: Delete Layer");
|
println!("Menu: Delete Layer");
|
||||||
// TODO: Implement delete layer
|
// TODO: Implement delete layer
|
||||||
|
|
@ -678,7 +749,7 @@ impl eframe::App for EditorApp {
|
||||||
&self.theme,
|
&self.theme,
|
||||||
&mut self.action_executor,
|
&mut self.action_executor,
|
||||||
&mut self.selection,
|
&mut self.selection,
|
||||||
&self.active_layer_id,
|
&mut self.active_layer_id,
|
||||||
&mut self.tool_state,
|
&mut self.tool_state,
|
||||||
&mut pending_actions,
|
&mut pending_actions,
|
||||||
&mut self.draw_simplify_mode,
|
&mut self.draw_simplify_mode,
|
||||||
|
|
@ -769,7 +840,7 @@ fn render_layout_node(
|
||||||
theme: &Theme,
|
theme: &Theme,
|
||||||
action_executor: &mut lightningbeam_core::action::ActionExecutor,
|
action_executor: &mut lightningbeam_core::action::ActionExecutor,
|
||||||
selection: &mut lightningbeam_core::selection::Selection,
|
selection: &mut lightningbeam_core::selection::Selection,
|
||||||
active_layer_id: &Option<Uuid>,
|
active_layer_id: &mut Option<Uuid>,
|
||||||
tool_state: &mut lightningbeam_core::tool::ToolState,
|
tool_state: &mut lightningbeam_core::tool::ToolState,
|
||||||
pending_actions: &mut Vec<Box<dyn lightningbeam_core::action::Action>>,
|
pending_actions: &mut Vec<Box<dyn lightningbeam_core::action::Action>>,
|
||||||
draw_simplify_mode: &mut lightningbeam_core::tool::SimplifyMode,
|
draw_simplify_mode: &mut lightningbeam_core::tool::SimplifyMode,
|
||||||
|
|
@ -1121,7 +1192,7 @@ fn render_pane(
|
||||||
theme: &Theme,
|
theme: &Theme,
|
||||||
action_executor: &mut lightningbeam_core::action::ActionExecutor,
|
action_executor: &mut lightningbeam_core::action::ActionExecutor,
|
||||||
selection: &mut lightningbeam_core::selection::Selection,
|
selection: &mut lightningbeam_core::selection::Selection,
|
||||||
active_layer_id: &Option<Uuid>,
|
active_layer_id: &mut Option<Uuid>,
|
||||||
tool_state: &mut lightningbeam_core::tool::ToolState,
|
tool_state: &mut lightningbeam_core::tool::ToolState,
|
||||||
pending_actions: &mut Vec<Box<dyn lightningbeam_core::action::Action>>,
|
pending_actions: &mut Vec<Box<dyn lightningbeam_core::action::Action>>,
|
||||||
draw_simplify_mode: &mut lightningbeam_core::tool::SimplifyMode,
|
draw_simplify_mode: &mut lightningbeam_core::tool::SimplifyMode,
|
||||||
|
|
|
||||||
|
|
@ -164,6 +164,7 @@ pub enum MenuAction {
|
||||||
AddVideoLayer,
|
AddVideoLayer,
|
||||||
AddAudioTrack,
|
AddAudioTrack,
|
||||||
AddMidiTrack,
|
AddMidiTrack,
|
||||||
|
AddTestClip, // For testing: adds a test clip instance to the current layer
|
||||||
DeleteLayer,
|
DeleteLayer,
|
||||||
ToggleLayerVisibility,
|
ToggleLayerVisibility,
|
||||||
|
|
||||||
|
|
@ -254,6 +255,7 @@ impl MenuItemDef {
|
||||||
const ADD_VIDEO_LAYER: Self = Self { label: "Add Video Layer", action: MenuAction::AddVideoLayer, shortcut: None };
|
const ADD_VIDEO_LAYER: Self = Self { label: "Add Video Layer", action: MenuAction::AddVideoLayer, shortcut: None };
|
||||||
const ADD_AUDIO_TRACK: Self = Self { label: "Add Audio Track", action: MenuAction::AddAudioTrack, shortcut: None };
|
const ADD_AUDIO_TRACK: Self = Self { label: "Add Audio Track", action: MenuAction::AddAudioTrack, shortcut: None };
|
||||||
const ADD_MIDI_TRACK: Self = Self { label: "Add MIDI Track", action: MenuAction::AddMidiTrack, shortcut: None };
|
const ADD_MIDI_TRACK: Self = Self { label: "Add MIDI Track", action: MenuAction::AddMidiTrack, shortcut: None };
|
||||||
|
const ADD_TEST_CLIP: Self = Self { label: "Add Test Clip", action: MenuAction::AddTestClip, shortcut: None };
|
||||||
const DELETE_LAYER: Self = Self { label: "Delete Layer", action: MenuAction::DeleteLayer, shortcut: None };
|
const DELETE_LAYER: Self = Self { label: "Delete Layer", action: MenuAction::DeleteLayer, shortcut: None };
|
||||||
const TOGGLE_LAYER_VISIBILITY: Self = Self { label: "Hide/Show Layer", action: MenuAction::ToggleLayerVisibility, shortcut: None };
|
const TOGGLE_LAYER_VISIBILITY: Self = Self { label: "Hide/Show Layer", action: MenuAction::ToggleLayerVisibility, shortcut: None };
|
||||||
|
|
||||||
|
|
@ -364,6 +366,8 @@ impl MenuItemDef {
|
||||||
MenuDef::Item(&Self::ADD_AUDIO_TRACK),
|
MenuDef::Item(&Self::ADD_AUDIO_TRACK),
|
||||||
MenuDef::Item(&Self::ADD_MIDI_TRACK),
|
MenuDef::Item(&Self::ADD_MIDI_TRACK),
|
||||||
MenuDef::Separator,
|
MenuDef::Separator,
|
||||||
|
MenuDef::Item(&Self::ADD_TEST_CLIP),
|
||||||
|
MenuDef::Separator,
|
||||||
MenuDef::Item(&Self::DELETE_LAYER),
|
MenuDef::Item(&Self::DELETE_LAYER),
|
||||||
MenuDef::Item(&Self::TOGGLE_LAYER_VISIBILITY),
|
MenuDef::Item(&Self::TOGGLE_LAYER_VISIBILITY),
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -64,7 +64,7 @@ pub struct SharedPaneState<'a> {
|
||||||
/// Current selection state (mutable for tools to modify)
|
/// Current selection state (mutable for tools to modify)
|
||||||
pub selection: &'a mut lightningbeam_core::selection::Selection,
|
pub selection: &'a mut lightningbeam_core::selection::Selection,
|
||||||
/// Currently active layer ID
|
/// Currently active layer ID
|
||||||
pub active_layer_id: &'a Option<uuid::Uuid>,
|
pub active_layer_id: &'a mut Option<uuid::Uuid>,
|
||||||
/// Current tool interaction state (mutable for tools to modify)
|
/// Current tool interaction state (mutable for tools to modify)
|
||||||
pub tool_state: &'a mut lightningbeam_core::tool::ToolState,
|
pub tool_state: &'a mut lightningbeam_core::tool::ToolState,
|
||||||
/// Actions to execute after rendering completes (two-phase dispatch)
|
/// Actions to execute after rendering completes (two-phase dispatch)
|
||||||
|
|
|
||||||
|
|
@ -332,7 +332,7 @@ impl egui_wgpu::CallbackTrait for VelloCallback {
|
||||||
// 1. Draw selection outlines around selected objects
|
// 1. Draw selection outlines around selected objects
|
||||||
// NOTE: Skip this if Transform tool is active (it has its own handles)
|
// NOTE: Skip this if Transform tool is active (it has its own handles)
|
||||||
if !self.selection.is_empty() && !matches!(self.selected_tool, Tool::Transform) {
|
if !self.selection.is_empty() && !matches!(self.selected_tool, Tool::Transform) {
|
||||||
for &object_id in self.selection.objects() {
|
for &object_id in self.selection.shape_instances() {
|
||||||
if let Some(object) = vector_layer.get_object(&object_id) {
|
if let Some(object) = vector_layer.get_object(&object_id) {
|
||||||
if let Some(shape) = vector_layer.get_shape(&object.shape_id) {
|
if let Some(shape) = vector_layer.get_shape(&object.shape_id) {
|
||||||
// Get shape bounding box
|
// Get shape bounding box
|
||||||
|
|
@ -385,6 +385,66 @@ impl egui_wgpu::CallbackTrait for VelloCallback {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Also draw selection outlines for clip instances
|
||||||
|
for &clip_id in self.selection.clip_instances() {
|
||||||
|
if let Some(clip_instance) = vector_layer.clip_instances.iter().find(|ci| ci.id == clip_id) {
|
||||||
|
// Get clip dimensions from document
|
||||||
|
let (width, height) = if let Some(vector_clip) = self.document.get_vector_clip(&clip_instance.clip_id) {
|
||||||
|
(vector_clip.width, vector_clip.height)
|
||||||
|
} else if let Some(video_clip) = self.document.get_video_clip(&clip_instance.clip_id) {
|
||||||
|
(video_clip.width, video_clip.height)
|
||||||
|
} else {
|
||||||
|
continue; // Clip not found or is audio
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create bounding box from clip dimensions
|
||||||
|
let bbox = KurboRect::new(0.0, 0.0, width, height);
|
||||||
|
|
||||||
|
// Apply clip instance transform and camera transform
|
||||||
|
let clip_transform = clip_instance.transform.to_affine();
|
||||||
|
let combined_transform = camera_transform * clip_transform;
|
||||||
|
|
||||||
|
// Draw selection outline with different color for clip instances
|
||||||
|
let clip_selection_color = Color::rgb8(255, 120, 0); // Orange
|
||||||
|
scene.stroke(
|
||||||
|
&Stroke::new(stroke_width),
|
||||||
|
combined_transform,
|
||||||
|
clip_selection_color,
|
||||||
|
None,
|
||||||
|
&bbox,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Draw corner handles (4 circles at corners)
|
||||||
|
let handle_radius = (6.0 / self.zoom.max(0.5) as f64).max(4.0);
|
||||||
|
let corners = [
|
||||||
|
(bbox.x0, bbox.y0),
|
||||||
|
(bbox.x1, bbox.y0),
|
||||||
|
(bbox.x1, bbox.y1),
|
||||||
|
(bbox.x0, bbox.y1),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (x, y) in corners {
|
||||||
|
let corner_circle = Circle::new((x, y), handle_radius);
|
||||||
|
// Fill with orange
|
||||||
|
scene.fill(
|
||||||
|
Fill::NonZero,
|
||||||
|
combined_transform,
|
||||||
|
clip_selection_color,
|
||||||
|
None,
|
||||||
|
&corner_circle,
|
||||||
|
);
|
||||||
|
// White outline
|
||||||
|
scene.stroke(
|
||||||
|
&Stroke::new(1.0),
|
||||||
|
combined_transform,
|
||||||
|
Color::rgb8(255, 255, 255),
|
||||||
|
None,
|
||||||
|
&corner_circle,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Draw marquee selection rectangle
|
// 2. Draw marquee selection rectangle
|
||||||
|
|
@ -673,9 +733,9 @@ impl egui_wgpu::CallbackTrait for VelloCallback {
|
||||||
// For single object: use object-aligned (rotated) bounding box
|
// For single object: use object-aligned (rotated) bounding box
|
||||||
// For multiple objects: use axis-aligned bounding box (simpler for now)
|
// For multiple objects: use axis-aligned bounding box (simpler for now)
|
||||||
|
|
||||||
if self.selection.objects().len() == 1 {
|
if self.selection.shape_instances().len() == 1 {
|
||||||
// Single object - draw rotated bounding box
|
// Single object - draw rotated bounding box
|
||||||
let object_id = *self.selection.objects().iter().next().unwrap();
|
let object_id = *self.selection.shape_instances().iter().next().unwrap();
|
||||||
|
|
||||||
if let Some(object) = vector_layer.get_object(&object_id) {
|
if let Some(object) = vector_layer.get_object(&object_id) {
|
||||||
if let Some(shape) = vector_layer.get_shape(&object.shape_id) {
|
if let Some(shape) = vector_layer.get_shape(&object.shape_id) {
|
||||||
|
|
@ -863,7 +923,7 @@ impl egui_wgpu::CallbackTrait for VelloCallback {
|
||||||
// Multiple objects - use axis-aligned bbox (existing code)
|
// Multiple objects - use axis-aligned bbox (existing code)
|
||||||
let mut combined_bbox: Option<KurboRect> = None;
|
let mut combined_bbox: Option<KurboRect> = None;
|
||||||
|
|
||||||
for &object_id in self.selection.objects() {
|
for &object_id in self.selection.shape_instances() {
|
||||||
if let Some(object) = vector_layer.get_object(&object_id) {
|
if let Some(object) = vector_layer.get_object(&object_id) {
|
||||||
if let Some(shape) = vector_layer.get_shape(&object.shape_id) {
|
if let Some(shape) = vector_layer.get_shape(&object.shape_id) {
|
||||||
let shape_bbox = shape.path().bounding_box();
|
let shape_bbox = shape.path().bounding_box();
|
||||||
|
|
@ -1178,7 +1238,7 @@ impl StagePane {
|
||||||
None => return, // No active layer
|
None => return, // No active layer
|
||||||
};
|
};
|
||||||
|
|
||||||
let active_layer = match shared.action_executor.document().get_layer(active_layer_id) {
|
let active_layer = match shared.action_executor.document().get_layer(&active_layer_id) {
|
||||||
Some(layer) => layer,
|
Some(layer) => layer,
|
||||||
None => return,
|
None => return,
|
||||||
};
|
};
|
||||||
|
|
@ -1194,25 +1254,43 @@ impl StagePane {
|
||||||
// Mouse down: start interaction (use drag_started for immediate feedback)
|
// Mouse down: start interaction (use drag_started for immediate feedback)
|
||||||
if response.drag_started() || response.clicked() {
|
if response.drag_started() || response.clicked() {
|
||||||
// Hit test at click position
|
// Hit test at click position
|
||||||
let hit = hit_test::hit_test_layer(vector_layer, point, 5.0, Affine::IDENTITY);
|
// Test clip instances first (they're on top of shapes)
|
||||||
|
let document = shared.action_executor.document();
|
||||||
|
let clip_hit = hit_test::hit_test_clip_instances(
|
||||||
|
&vector_layer.clip_instances,
|
||||||
|
&document.vector_clips,
|
||||||
|
&document.video_clips,
|
||||||
|
point,
|
||||||
|
Affine::IDENTITY,
|
||||||
|
);
|
||||||
|
|
||||||
if let Some(object_id) = hit {
|
let hit_result = if let Some(clip_id) = clip_hit {
|
||||||
// Object was hit
|
Some(hit_test::HitResult::ClipInstance(clip_id))
|
||||||
|
} else {
|
||||||
|
// No clip hit, test shape instances
|
||||||
|
hit_test::hit_test_layer(vector_layer, point, 5.0, Affine::IDENTITY)
|
||||||
|
.map(|id| hit_test::HitResult::ShapeInstance(id))
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(hit) = hit_result {
|
||||||
|
match hit {
|
||||||
|
hit_test::HitResult::ShapeInstance(object_id) => {
|
||||||
|
// Shape instance was hit
|
||||||
if shift_held {
|
if shift_held {
|
||||||
// Shift: toggle selection
|
// Shift: toggle selection
|
||||||
shared.selection.toggle_object(object_id);
|
shared.selection.toggle_shape_instance(object_id);
|
||||||
} else {
|
} else {
|
||||||
// No shift: replace selection
|
// No shift: replace selection
|
||||||
if !shared.selection.contains_object(&object_id) {
|
if !shared.selection.contains_shape_instance(&object_id) {
|
||||||
shared.selection.select_only_object(object_id);
|
shared.selection.select_only_shape_instance(object_id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// If object is now selected, prepare for dragging
|
// If object is now selected, prepare for dragging
|
||||||
if shared.selection.contains_object(&object_id) {
|
if shared.selection.contains_shape_instance(&object_id) {
|
||||||
// Store original positions of all selected objects
|
// Store original positions of all selected objects
|
||||||
let mut original_positions = std::collections::HashMap::new();
|
let mut original_positions = std::collections::HashMap::new();
|
||||||
for &obj_id in shared.selection.objects() {
|
for &obj_id in shared.selection.shape_instances() {
|
||||||
if let Some(obj) = vector_layer.get_object(&obj_id) {
|
if let Some(obj) = vector_layer.get_object(&obj_id) {
|
||||||
original_positions.insert(
|
original_positions.insert(
|
||||||
obj_id,
|
obj_id,
|
||||||
|
|
@ -1227,6 +1305,42 @@ impl StagePane {
|
||||||
original_positions,
|
original_positions,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
hit_test::HitResult::ClipInstance(clip_id) => {
|
||||||
|
// Clip instance was hit
|
||||||
|
if shift_held {
|
||||||
|
// Shift: toggle selection
|
||||||
|
shared.selection.toggle_clip_instance(clip_id);
|
||||||
|
} else {
|
||||||
|
// No shift: replace selection
|
||||||
|
if !shared.selection.contains_clip_instance(&clip_id) {
|
||||||
|
shared.selection.select_only_clip_instance(clip_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If clip instance is now selected, prepare for dragging
|
||||||
|
if shared.selection.contains_clip_instance(&clip_id) {
|
||||||
|
// Store original positions of all selected clip instances
|
||||||
|
let mut original_positions = std::collections::HashMap::new();
|
||||||
|
for &clip_inst_id in shared.selection.clip_instances() {
|
||||||
|
// Find the clip instance in the layer
|
||||||
|
if let Some(clip_inst) = vector_layer.clip_instances.iter()
|
||||||
|
.find(|ci| ci.id == clip_inst_id) {
|
||||||
|
original_positions.insert(
|
||||||
|
clip_inst_id,
|
||||||
|
Point::new(clip_inst.transform.x, clip_inst.transform.y),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
*shared.tool_state = ToolState::DraggingSelection {
|
||||||
|
start_pos: point,
|
||||||
|
start_mouse: point,
|
||||||
|
original_positions,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// Nothing hit - start marquee selection
|
// Nothing hit - start marquee selection
|
||||||
if !shift_held {
|
if !shift_held {
|
||||||
|
|
@ -1266,24 +1380,56 @@ impl StagePane {
|
||||||
let delta = point - start_mouse;
|
let delta = point - start_mouse;
|
||||||
|
|
||||||
if delta.x.abs() > 0.01 || delta.y.abs() > 0.01 {
|
if delta.x.abs() > 0.01 || delta.y.abs() > 0.01 {
|
||||||
// Create move action with new positions
|
// Create move actions with new positions
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
let mut object_positions = HashMap::new();
|
|
||||||
|
|
||||||
for (object_id, original_pos) in original_positions {
|
// Separate shape instances from clip instances
|
||||||
|
use lightningbeam_core::object::Transform;
|
||||||
|
let mut shape_instance_positions = HashMap::new();
|
||||||
|
let mut clip_instance_transforms = HashMap::new();
|
||||||
|
|
||||||
|
for (id, original_pos) in original_positions {
|
||||||
let new_pos = Point::new(
|
let new_pos = Point::new(
|
||||||
original_pos.x + delta.x,
|
original_pos.x + delta.x,
|
||||||
original_pos.y + delta.y,
|
original_pos.y + delta.y,
|
||||||
);
|
);
|
||||||
object_positions.insert(object_id, (original_pos, new_pos));
|
|
||||||
|
// Check if this is a shape instance or clip instance
|
||||||
|
if shared.selection.contains_shape_instance(&id) {
|
||||||
|
shape_instance_positions.insert(id, (original_pos, new_pos));
|
||||||
|
} else if shared.selection.contains_clip_instance(&id) {
|
||||||
|
// For clip instances, we need to get the full Transform
|
||||||
|
// Find the clip instance in the layer
|
||||||
|
if let Some(clip_inst) = vector_layer.clip_instances.iter()
|
||||||
|
.find(|ci| ci.id == id) {
|
||||||
|
let mut old_transform = clip_inst.transform.clone();
|
||||||
|
old_transform.x = original_pos.x;
|
||||||
|
old_transform.y = original_pos.y;
|
||||||
|
|
||||||
|
let mut new_transform = clip_inst.transform.clone();
|
||||||
|
new_transform.x = new_pos.x;
|
||||||
|
new_transform.y = new_pos.y;
|
||||||
|
|
||||||
|
clip_instance_transforms.insert(id, (old_transform, new_transform));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create and submit the action
|
// Create and submit move action for shape instances
|
||||||
use lightningbeam_core::actions::MoveObjectsAction;
|
if !shape_instance_positions.is_empty() {
|
||||||
let action = MoveObjectsAction::new(*active_layer_id, object_positions);
|
use lightningbeam_core::actions::MoveShapeInstancesAction;
|
||||||
|
let action = MoveShapeInstancesAction::new(*active_layer_id, shape_instance_positions);
|
||||||
shared.pending_actions.push(Box::new(action));
|
shared.pending_actions.push(Box::new(action));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Create and submit transform action for clip instances
|
||||||
|
if !clip_instance_transforms.is_empty() {
|
||||||
|
use lightningbeam_core::actions::TransformClipInstancesAction;
|
||||||
|
let action = TransformClipInstancesAction::new(*active_layer_id, clip_instance_transforms);
|
||||||
|
shared.pending_actions.push(Box::new(action));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Reset tool state
|
// Reset tool state
|
||||||
*shared.tool_state = ToolState::Idle;
|
*shared.tool_state = ToolState::Idle;
|
||||||
}
|
}
|
||||||
|
|
@ -1296,24 +1442,49 @@ impl StagePane {
|
||||||
|
|
||||||
let selection_rect = KurboRect::new(min_x, min_y, max_x, max_y);
|
let selection_rect = KurboRect::new(min_x, min_y, max_x, max_y);
|
||||||
|
|
||||||
// Hit test all objects in rectangle
|
// Hit test clip instances in rectangle
|
||||||
let hits = hit_test::hit_test_objects_in_rect(
|
let document = shared.action_executor.document();
|
||||||
|
let clip_hits = hit_test::hit_test_clip_instances_in_rect(
|
||||||
|
&vector_layer.clip_instances,
|
||||||
|
&document.vector_clips,
|
||||||
|
&document.video_clips,
|
||||||
|
selection_rect,
|
||||||
|
Affine::IDENTITY,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Hit test shape instances in rectangle
|
||||||
|
let shape_hits = hit_test::hit_test_objects_in_rect(
|
||||||
vector_layer,
|
vector_layer,
|
||||||
selection_rect,
|
selection_rect,
|
||||||
Affine::IDENTITY,
|
Affine::IDENTITY,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Add to selection
|
// Add clip instances to selection
|
||||||
for obj_id in hits {
|
for clip_id in clip_hits {
|
||||||
if shift_held {
|
if shift_held {
|
||||||
shared.selection.add_object(obj_id);
|
shared.selection.add_clip_instance(clip_id);
|
||||||
} else {
|
} else {
|
||||||
// First hit replaces selection
|
// First hit replaces selection
|
||||||
if shared.selection.is_empty() {
|
if shared.selection.is_empty() {
|
||||||
shared.selection.add_object(obj_id);
|
shared.selection.add_clip_instance(clip_id);
|
||||||
} else {
|
} else {
|
||||||
// Subsequent hits add to selection
|
// Subsequent hits add to selection
|
||||||
shared.selection.add_object(obj_id);
|
shared.selection.add_clip_instance(clip_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add shape instances to selection
|
||||||
|
for obj_id in shape_hits {
|
||||||
|
if shift_held {
|
||||||
|
shared.selection.add_shape_instance(obj_id);
|
||||||
|
} else {
|
||||||
|
// First hit replaces selection
|
||||||
|
if shared.selection.is_empty() {
|
||||||
|
shared.selection.add_shape_instance(obj_id);
|
||||||
|
} else {
|
||||||
|
// Subsequent hits add to selection
|
||||||
|
shared.selection.add_shape_instance(obj_id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1340,12 +1511,12 @@ impl StagePane {
|
||||||
use vello::kurbo::Point;
|
use vello::kurbo::Point;
|
||||||
|
|
||||||
// Check if we have an active vector layer
|
// Check if we have an active vector layer
|
||||||
let active_layer_id = match shared.active_layer_id {
|
let active_layer_id = match *shared.active_layer_id {
|
||||||
Some(id) => id,
|
Some(id) => id,
|
||||||
None => return,
|
None => return,
|
||||||
};
|
};
|
||||||
|
|
||||||
let active_layer = match shared.action_executor.document().get_layer(active_layer_id) {
|
let active_layer = match shared.action_executor.document().get_layer(&active_layer_id) {
|
||||||
Some(layer) => layer,
|
Some(layer) => layer,
|
||||||
None => return,
|
None => return,
|
||||||
};
|
};
|
||||||
|
|
@ -1428,7 +1599,7 @@ impl StagePane {
|
||||||
// Only create shape if rectangle has non-zero size
|
// Only create shape if rectangle has non-zero size
|
||||||
if width > 1.0 && height > 1.0 {
|
if width > 1.0 && height > 1.0 {
|
||||||
use lightningbeam_core::shape::{Shape, ShapeColor};
|
use lightningbeam_core::shape::{Shape, ShapeColor};
|
||||||
use lightningbeam_core::object::Object;
|
use lightningbeam_core::object::ShapeInstance;
|
||||||
use lightningbeam_core::actions::AddShapeAction;
|
use lightningbeam_core::actions::AddShapeAction;
|
||||||
|
|
||||||
// Create shape with rectangle path (built from lines)
|
// Create shape with rectangle path (built from lines)
|
||||||
|
|
@ -1436,10 +1607,10 @@ impl StagePane {
|
||||||
let shape = Shape::new(path).with_fill(ShapeColor::from_egui(*shared.fill_color));
|
let shape = Shape::new(path).with_fill(ShapeColor::from_egui(*shared.fill_color));
|
||||||
|
|
||||||
// Create object at the calculated position
|
// Create object at the calculated position
|
||||||
let object = Object::new(shape.id).with_position(position.x, position.y);
|
let object = ShapeInstance::new(shape.id).with_position(position.x, position.y);
|
||||||
|
|
||||||
// Create and execute action immediately
|
// Create and execute action immediately
|
||||||
let action = AddShapeAction::new(*active_layer_id, shape, object);
|
let action = AddShapeAction::new(active_layer_id, shape, object);
|
||||||
shared.action_executor.execute(Box::new(action));
|
shared.action_executor.execute(Box::new(action));
|
||||||
|
|
||||||
// Clear tool state to stop preview rendering
|
// Clear tool state to stop preview rendering
|
||||||
|
|
@ -1463,12 +1634,12 @@ impl StagePane {
|
||||||
use vello::kurbo::Point;
|
use vello::kurbo::Point;
|
||||||
|
|
||||||
// Check if we have an active vector layer
|
// Check if we have an active vector layer
|
||||||
let active_layer_id = match shared.active_layer_id {
|
let active_layer_id = match *shared.active_layer_id {
|
||||||
Some(id) => id,
|
Some(id) => id,
|
||||||
None => return,
|
None => return,
|
||||||
};
|
};
|
||||||
|
|
||||||
let active_layer = match shared.action_executor.document().get_layer(active_layer_id) {
|
let active_layer = match shared.action_executor.document().get_layer(&active_layer_id) {
|
||||||
Some(layer) => layer,
|
Some(layer) => layer,
|
||||||
None => return,
|
None => return,
|
||||||
};
|
};
|
||||||
|
|
@ -1545,7 +1716,7 @@ impl StagePane {
|
||||||
// Only create shape if ellipse has non-zero size
|
// Only create shape if ellipse has non-zero size
|
||||||
if rx > 1.0 && ry > 1.0 {
|
if rx > 1.0 && ry > 1.0 {
|
||||||
use lightningbeam_core::shape::{Shape, ShapeColor};
|
use lightningbeam_core::shape::{Shape, ShapeColor};
|
||||||
use lightningbeam_core::object::Object;
|
use lightningbeam_core::object::ShapeInstance;
|
||||||
use lightningbeam_core::actions::AddShapeAction;
|
use lightningbeam_core::actions::AddShapeAction;
|
||||||
|
|
||||||
// Create shape with ellipse path (built from bezier curves)
|
// Create shape with ellipse path (built from bezier curves)
|
||||||
|
|
@ -1553,10 +1724,10 @@ impl StagePane {
|
||||||
let shape = Shape::new(path).with_fill(ShapeColor::from_egui(*shared.fill_color));
|
let shape = Shape::new(path).with_fill(ShapeColor::from_egui(*shared.fill_color));
|
||||||
|
|
||||||
// Create object at the calculated position
|
// Create object at the calculated position
|
||||||
let object = Object::new(shape.id).with_position(position.x, position.y);
|
let object = ShapeInstance::new(shape.id).with_position(position.x, position.y);
|
||||||
|
|
||||||
// Create and execute action immediately
|
// Create and execute action immediately
|
||||||
let action = AddShapeAction::new(*active_layer_id, shape, object);
|
let action = AddShapeAction::new(active_layer_id, shape, object);
|
||||||
shared.action_executor.execute(Box::new(action));
|
shared.action_executor.execute(Box::new(action));
|
||||||
|
|
||||||
// Clear tool state to stop preview rendering
|
// Clear tool state to stop preview rendering
|
||||||
|
|
@ -1580,12 +1751,12 @@ impl StagePane {
|
||||||
use vello::kurbo::Point;
|
use vello::kurbo::Point;
|
||||||
|
|
||||||
// Check if we have an active vector layer
|
// Check if we have an active vector layer
|
||||||
let active_layer_id = match shared.active_layer_id {
|
let active_layer_id = match *shared.active_layer_id {
|
||||||
Some(id) => id,
|
Some(id) => id,
|
||||||
None => return,
|
None => return,
|
||||||
};
|
};
|
||||||
|
|
||||||
let active_layer = match shared.action_executor.document().get_layer(active_layer_id) {
|
let active_layer = match shared.action_executor.document().get_layer(&active_layer_id) {
|
||||||
Some(layer) => layer,
|
Some(layer) => layer,
|
||||||
None => return,
|
None => return,
|
||||||
};
|
};
|
||||||
|
|
@ -1626,7 +1797,7 @@ impl StagePane {
|
||||||
// Only create shape if line has reasonable length
|
// Only create shape if line has reasonable length
|
||||||
if length > 1.0 {
|
if length > 1.0 {
|
||||||
use lightningbeam_core::shape::{Shape, ShapeColor, StrokeStyle};
|
use lightningbeam_core::shape::{Shape, ShapeColor, StrokeStyle};
|
||||||
use lightningbeam_core::object::Object;
|
use lightningbeam_core::object::ShapeInstance;
|
||||||
use lightningbeam_core::actions::AddShapeAction;
|
use lightningbeam_core::actions::AddShapeAction;
|
||||||
|
|
||||||
// Create shape with line path
|
// Create shape with line path
|
||||||
|
|
@ -1643,10 +1814,10 @@ impl StagePane {
|
||||||
);
|
);
|
||||||
|
|
||||||
// Create object at the start point
|
// Create object at the start point
|
||||||
let object = Object::new(shape.id).with_position(start_point.x, start_point.y);
|
let object = ShapeInstance::new(shape.id).with_position(start_point.x, start_point.y);
|
||||||
|
|
||||||
// Create and execute action immediately
|
// Create and execute action immediately
|
||||||
let action = AddShapeAction::new(*active_layer_id, shape, object);
|
let action = AddShapeAction::new(active_layer_id, shape, object);
|
||||||
shared.action_executor.execute(Box::new(action));
|
shared.action_executor.execute(Box::new(action));
|
||||||
|
|
||||||
// Clear tool state to stop preview rendering
|
// Clear tool state to stop preview rendering
|
||||||
|
|
@ -1670,12 +1841,12 @@ impl StagePane {
|
||||||
use vello::kurbo::Point;
|
use vello::kurbo::Point;
|
||||||
|
|
||||||
// Check if we have an active vector layer
|
// Check if we have an active vector layer
|
||||||
let active_layer_id = match shared.active_layer_id {
|
let active_layer_id = match *shared.active_layer_id {
|
||||||
Some(id) => id,
|
Some(id) => id,
|
||||||
None => return,
|
None => return,
|
||||||
};
|
};
|
||||||
|
|
||||||
let active_layer = match shared.action_executor.document().get_layer(active_layer_id) {
|
let active_layer = match shared.action_executor.document().get_layer(&active_layer_id) {
|
||||||
Some(layer) => layer,
|
Some(layer) => layer,
|
||||||
None => return,
|
None => return,
|
||||||
};
|
};
|
||||||
|
|
@ -1718,7 +1889,7 @@ impl StagePane {
|
||||||
// Only create shape if polygon has reasonable size
|
// Only create shape if polygon has reasonable size
|
||||||
if radius > 5.0 {
|
if radius > 5.0 {
|
||||||
use lightningbeam_core::shape::{Shape, ShapeColor};
|
use lightningbeam_core::shape::{Shape, ShapeColor};
|
||||||
use lightningbeam_core::object::Object;
|
use lightningbeam_core::object::ShapeInstance;
|
||||||
use lightningbeam_core::actions::AddShapeAction;
|
use lightningbeam_core::actions::AddShapeAction;
|
||||||
|
|
||||||
// Create shape with polygon path
|
// Create shape with polygon path
|
||||||
|
|
@ -1726,10 +1897,10 @@ impl StagePane {
|
||||||
let shape = Shape::new(path).with_fill(ShapeColor::from_egui(*shared.fill_color));
|
let shape = Shape::new(path).with_fill(ShapeColor::from_egui(*shared.fill_color));
|
||||||
|
|
||||||
// Create object at the center point
|
// Create object at the center point
|
||||||
let object = Object::new(shape.id).with_position(center.x, center.y);
|
let object = ShapeInstance::new(shape.id).with_position(center.x, center.y);
|
||||||
|
|
||||||
// Create and execute action immediately
|
// Create and execute action immediately
|
||||||
let action = AddShapeAction::new(*active_layer_id, shape, object);
|
let action = AddShapeAction::new(active_layer_id, shape, object);
|
||||||
shared.action_executor.execute(Box::new(action));
|
shared.action_executor.execute(Box::new(action));
|
||||||
|
|
||||||
// Clear tool state to stop preview rendering
|
// Clear tool state to stop preview rendering
|
||||||
|
|
@ -1893,12 +2064,12 @@ impl StagePane {
|
||||||
use vello::kurbo::Point;
|
use vello::kurbo::Point;
|
||||||
|
|
||||||
// Check if we have an active vector layer
|
// Check if we have an active vector layer
|
||||||
let active_layer_id = match shared.active_layer_id {
|
let active_layer_id = match *shared.active_layer_id {
|
||||||
Some(id) => id,
|
Some(id) => id,
|
||||||
None => return,
|
None => return,
|
||||||
};
|
};
|
||||||
|
|
||||||
let active_layer = match shared.action_executor.document().get_layer(active_layer_id) {
|
let active_layer = match shared.action_executor.document().get_layer(&active_layer_id) {
|
||||||
Some(layer) => layer,
|
Some(layer) => layer,
|
||||||
None => return,
|
None => return,
|
||||||
};
|
};
|
||||||
|
|
@ -1944,7 +2115,7 @@ impl StagePane {
|
||||||
simplify_rdp, fit_bezier_curves, RdpConfig, SchneiderConfig,
|
simplify_rdp, fit_bezier_curves, RdpConfig, SchneiderConfig,
|
||||||
};
|
};
|
||||||
use lightningbeam_core::shape::{Shape, ShapeColor};
|
use lightningbeam_core::shape::{Shape, ShapeColor};
|
||||||
use lightningbeam_core::object::Object;
|
use lightningbeam_core::object::ShapeInstance;
|
||||||
use lightningbeam_core::actions::AddShapeAction;
|
use lightningbeam_core::actions::AddShapeAction;
|
||||||
|
|
||||||
// Convert points to the appropriate path based on simplify mode
|
// Convert points to the appropriate path based on simplify mode
|
||||||
|
|
@ -2003,10 +2174,10 @@ impl StagePane {
|
||||||
);
|
);
|
||||||
|
|
||||||
// Create object at the calculated position
|
// Create object at the calculated position
|
||||||
let object = Object::new(shape.id).with_position(position.x, position.y);
|
let object = ShapeInstance::new(shape.id).with_position(position.x, position.y);
|
||||||
|
|
||||||
// Create and execute action immediately
|
// Create and execute action immediately
|
||||||
let action = AddShapeAction::new(*active_layer_id, shape, object);
|
let action = AddShapeAction::new(active_layer_id, shape, object);
|
||||||
shared.action_executor.execute(Box::new(action));
|
shared.action_executor.execute(Box::new(action));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -2037,7 +2208,7 @@ impl StagePane {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let active_layer = match shared.action_executor.document().get_layer(active_layer_id) {
|
let active_layer = match shared.action_executor.document().get_layer(&active_layer_id) {
|
||||||
Some(layer) => layer,
|
Some(layer) => layer,
|
||||||
None => {
|
None => {
|
||||||
println!("Paint bucket: Layer not found");
|
println!("Paint bucket: Layer not found");
|
||||||
|
|
@ -2071,6 +2242,57 @@ impl StagePane {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Decompose an affine matrix into transform components
|
||||||
|
/// Returns (translation_x, translation_y, rotation_deg, scale_x, scale_y, skew_x_deg, skew_y_deg)
|
||||||
|
fn decompose_affine(affine: kurbo::Affine) -> (f64, f64, f64, f64, f64, f64, f64) {
|
||||||
|
let coeffs = affine.as_coeffs();
|
||||||
|
let a = coeffs[0];
|
||||||
|
let b = coeffs[1];
|
||||||
|
let c = coeffs[2];
|
||||||
|
let d = coeffs[3];
|
||||||
|
let e = coeffs[4]; // translation_x
|
||||||
|
let f = coeffs[5]; // translation_y
|
||||||
|
|
||||||
|
// Extract translation
|
||||||
|
let tx = e;
|
||||||
|
let ty = f;
|
||||||
|
|
||||||
|
// Decompose linear part [[a, c], [b, d]] into rotate * scale * skew
|
||||||
|
// Using QR-like decomposition
|
||||||
|
|
||||||
|
// Extract rotation
|
||||||
|
let rotation_rad = b.atan2(a);
|
||||||
|
let cos_r = rotation_rad.cos();
|
||||||
|
let sin_r = rotation_rad.sin();
|
||||||
|
|
||||||
|
// Remove rotation to get scale * skew
|
||||||
|
// R^(-1) * M where M = [[a, c], [b, d]]
|
||||||
|
let m11 = a * cos_r + b * sin_r;
|
||||||
|
let m12 = c * cos_r + d * sin_r;
|
||||||
|
let m21 = -a * sin_r + b * cos_r;
|
||||||
|
let m22 = -c * sin_r + d * cos_r;
|
||||||
|
|
||||||
|
// Now [[m11, m12], [m21, m22]] = scale * skew
|
||||||
|
// scale * skew = [[sx, 0], [0, sy]] * [[1, tan(skew_y)], [tan(skew_x), 1]]
|
||||||
|
// = [[sx, sx*tan(skew_y)], [sy*tan(skew_x), sy]]
|
||||||
|
|
||||||
|
let scale_x = m11;
|
||||||
|
let scale_y = m22;
|
||||||
|
|
||||||
|
let skew_x_rad = if scale_y.abs() > 0.001 { (m21 / scale_y).atan() } else { 0.0 };
|
||||||
|
let skew_y_rad = if scale_x.abs() > 0.001 { (m12 / scale_x).atan() } else { 0.0 };
|
||||||
|
|
||||||
|
(
|
||||||
|
tx,
|
||||||
|
ty,
|
||||||
|
rotation_rad.to_degrees(),
|
||||||
|
scale_x,
|
||||||
|
scale_y,
|
||||||
|
skew_x_rad.to_degrees(),
|
||||||
|
skew_y_rad.to_degrees(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/// Apply transform preview to objects based on current mouse position
|
/// Apply transform preview to objects based on current mouse position
|
||||||
fn apply_transform_preview(
|
fn apply_transform_preview(
|
||||||
vector_layer: &mut lightningbeam_core::layer::VectorLayer,
|
vector_layer: &mut lightningbeam_core::layer::VectorLayer,
|
||||||
|
|
@ -2169,12 +2391,15 @@ impl StagePane {
|
||||||
}
|
}
|
||||||
|
|
||||||
TransformMode::ScaleEdge { axis, origin } => {
|
TransformMode::ScaleEdge { axis, origin } => {
|
||||||
// Calculate scale along one axis
|
// UNIFIED MATRIX APPROACH: Calculate bounding box transform, then apply to each object
|
||||||
|
|
||||||
|
// Step 1: Calculate the bounding box transform (world-space scale from origin)
|
||||||
|
// Preserve sign to allow flipping when dragging past the origin
|
||||||
let (scale_x_world, scale_y_world) = match axis {
|
let (scale_x_world, scale_y_world) = match axis {
|
||||||
Axis::Horizontal => {
|
Axis::Horizontal => {
|
||||||
let start_dist = (start_mouse.x - origin.x).abs();
|
let start_dist = start_mouse.x - origin.x;
|
||||||
let current_dist = (current_mouse.x - origin.x).abs();
|
let current_dist = current_mouse.x - origin.x;
|
||||||
let scale = if start_dist > 0.001 {
|
let scale = if start_dist.abs() > 0.001 {
|
||||||
current_dist / start_dist
|
current_dist / start_dist
|
||||||
} else {
|
} else {
|
||||||
1.0
|
1.0
|
||||||
|
|
@ -2182,9 +2407,9 @@ impl StagePane {
|
||||||
(scale, 1.0)
|
(scale, 1.0)
|
||||||
}
|
}
|
||||||
Axis::Vertical => {
|
Axis::Vertical => {
|
||||||
let start_dist = (start_mouse.y - origin.y).abs();
|
let start_dist = start_mouse.y - origin.y;
|
||||||
let current_dist = (current_mouse.y - origin.y).abs();
|
let current_dist = current_mouse.y - origin.y;
|
||||||
let scale = if start_dist > 0.001 {
|
let scale = if start_dist.abs() > 0.001 {
|
||||||
current_dist / start_dist
|
current_dist / start_dist
|
||||||
} else {
|
} else {
|
||||||
1.0
|
1.0
|
||||||
|
|
@ -2193,36 +2418,59 @@ impl StagePane {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Apply scale to all selected objects
|
// Build the bounding box transform: translate to origin, scale, translate back
|
||||||
|
use kurbo::Affine;
|
||||||
|
let bbox_transform = Affine::translate((origin.x, origin.y))
|
||||||
|
* Affine::scale_non_uniform(scale_x_world, scale_y_world)
|
||||||
|
* Affine::translate((-origin.x, -origin.y));
|
||||||
|
|
||||||
|
// Step 2: Apply to each object using matrix composition
|
||||||
for (object_id, original_transform) in original_transforms {
|
for (object_id, original_transform) in original_transforms {
|
||||||
|
// Get original opacity (now separate from transform)
|
||||||
|
let original_opacity = if let Some(obj) = vector_layer.get_object(object_id) {
|
||||||
|
obj.opacity
|
||||||
|
} else {
|
||||||
|
1.0
|
||||||
|
};
|
||||||
|
|
||||||
|
// New position: transform the object's position through bbox_transform
|
||||||
|
let new_pos = bbox_transform * kurbo::Point::new(original_transform.x, original_transform.y);
|
||||||
|
|
||||||
|
// Transform bbox operation to object's local space
|
||||||
|
// local_transform = R^(-1) * bbox_transform * R
|
||||||
|
let rotation = Affine::rotate(original_transform.rotation.to_radians());
|
||||||
|
let rotation_inv = Affine::rotate(-original_transform.rotation.to_radians());
|
||||||
|
|
||||||
|
// Extract just the linear part of bbox_transform (no translation)
|
||||||
|
let bbox_linear = Affine::scale_non_uniform(scale_x_world, scale_y_world);
|
||||||
|
|
||||||
|
// Transform to local space
|
||||||
|
let local_transform = rotation_inv * bbox_linear * rotation;
|
||||||
|
|
||||||
|
// Extract scale and skew directly from the 2x2 matrix
|
||||||
|
// Matrix form: [[a, c], [b, d]] = [[sx, sx*tan(ky)], [sy*tan(kx), sy]]
|
||||||
|
let coeffs = local_transform.as_coeffs();
|
||||||
|
let a = coeffs[0];
|
||||||
|
let b = coeffs[1];
|
||||||
|
let c = coeffs[2];
|
||||||
|
let d = coeffs[3];
|
||||||
|
|
||||||
|
// Direct extraction (no rotation assumed in local space)
|
||||||
|
let local_sx = a;
|
||||||
|
let local_sy = d;
|
||||||
|
let local_skew_x = if d.abs() > 0.001 { (b / d).atan().to_degrees() } else { 0.0 };
|
||||||
|
let local_skew_y = if a.abs() > 0.001 { (c / a).atan().to_degrees() } else { 0.0 };
|
||||||
|
|
||||||
|
// Apply to object
|
||||||
vector_layer.modify_object_internal(object_id, |obj| {
|
vector_layer.modify_object_internal(object_id, |obj| {
|
||||||
// Get object's rotation in radians
|
obj.transform.x = new_pos.x;
|
||||||
let rotation_rad = original_transform.rotation.to_radians();
|
obj.transform.y = new_pos.y;
|
||||||
let cos_r = rotation_rad.cos();
|
obj.transform.rotation = original_transform.rotation; // Preserve rotation
|
||||||
let sin_r = rotation_rad.sin();
|
obj.transform.scale_x = original_transform.scale_x * local_sx;
|
||||||
|
obj.transform.scale_y = original_transform.scale_y * local_sy;
|
||||||
// Transform scale from world space to local space (same as corner mode)
|
obj.transform.skew_x = original_transform.skew_x + local_skew_x;
|
||||||
let cos_r_sq = cos_r * cos_r;
|
obj.transform.skew_y = original_transform.skew_y + local_skew_y;
|
||||||
let sin_r_sq = sin_r * sin_r;
|
obj.opacity = original_opacity; // Preserve opacity (now separate from transform)
|
||||||
let sx_abs = scale_x_world.abs();
|
|
||||||
let sy_abs = scale_y_world.abs();
|
|
||||||
|
|
||||||
let local_scale_x = (cos_r_sq * sx_abs * sx_abs + sin_r_sq * sy_abs * sy_abs).sqrt();
|
|
||||||
let local_scale_y = (sin_r_sq * sx_abs * sx_abs + cos_r_sq * sy_abs * sy_abs).sqrt();
|
|
||||||
|
|
||||||
// Scale position relative to origin in world space
|
|
||||||
let rel_x = original_transform.x - origin.x;
|
|
||||||
let rel_y = original_transform.y - origin.y;
|
|
||||||
|
|
||||||
obj.transform.x = origin.x + rel_x * scale_x_world;
|
|
||||||
obj.transform.y = origin.y + rel_y * scale_y_world;
|
|
||||||
|
|
||||||
// Apply local-space scale
|
|
||||||
obj.transform.scale_x = original_transform.scale_x * local_scale_x;
|
|
||||||
obj.transform.scale_y = original_transform.scale_y * local_scale_y;
|
|
||||||
|
|
||||||
// Keep rotation unchanged
|
|
||||||
obj.transform.rotation = original_transform.rotation;
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -2492,14 +2740,14 @@ impl StagePane {
|
||||||
use vello::kurbo::Point;
|
use vello::kurbo::Point;
|
||||||
|
|
||||||
// Check if we have an active vector layer
|
// Check if we have an active vector layer
|
||||||
let active_layer_id = match shared.active_layer_id {
|
let active_layer_id = match *shared.active_layer_id {
|
||||||
Some(id) => id,
|
Some(id) => id,
|
||||||
None => return,
|
None => return,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Only work on VectorLayer - just check type, don't hold reference
|
// Only work on VectorLayer - just check type, don't hold reference
|
||||||
{
|
{
|
||||||
let active_layer = match shared.action_executor.document().get_layer(active_layer_id) {
|
let active_layer = match shared.action_executor.document().get_layer(&active_layer_id) {
|
||||||
Some(layer) => layer,
|
Some(layer) => layer,
|
||||||
None => return,
|
None => return,
|
||||||
};
|
};
|
||||||
|
|
@ -2518,17 +2766,17 @@ impl StagePane {
|
||||||
|
|
||||||
// For single object: use rotated bounding box
|
// For single object: use rotated bounding box
|
||||||
// For multiple objects: use axis-aligned bounding box
|
// For multiple objects: use axis-aligned bounding box
|
||||||
if shared.selection.objects().len() == 1 {
|
if shared.selection.shape_instances().len() == 1 {
|
||||||
// Single object - rotated bounding box
|
// Single object - rotated bounding box
|
||||||
self.handle_transform_single_object(ui, response, point, active_layer_id, shared);
|
self.handle_transform_single_object(ui, response, point, &active_layer_id, shared);
|
||||||
} else {
|
} else {
|
||||||
// Multiple objects - axis-aligned bounding box
|
// Multiple objects - axis-aligned bounding box
|
||||||
// Calculate combined bounding box for handle hit testing
|
// Calculate combined bounding box for handle hit testing
|
||||||
let mut combined_bbox: Option<vello::kurbo::Rect> = None;
|
let mut combined_bbox: Option<vello::kurbo::Rect> = None;
|
||||||
|
|
||||||
// Get immutable reference just for bbox calculation
|
// Get immutable reference just for bbox calculation
|
||||||
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 in shared.selection.objects() {
|
for &object_id in shared.selection.shape_instances() {
|
||||||
if let Some(object) = vector_layer.get_object(&object_id) {
|
if let Some(object) = vector_layer.get_object(&object_id) {
|
||||||
if let Some(shape) = vector_layer.get_shape(&object.shape_id) {
|
if let Some(shape) = vector_layer.get_shape(&object.shape_id) {
|
||||||
// Get shape's local bounding box
|
// Get shape's local bounding box
|
||||||
|
|
@ -2603,8 +2851,8 @@ impl StagePane {
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
let mut original_transforms = HashMap::new();
|
let mut original_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 &object_id in shared.selection.objects() {
|
for &object_id in shared.selection.shape_instances() {
|
||||||
if let Some(object) = vector_layer.get_object(&object_id) {
|
if let Some(object) = vector_layer.get_object(&object_id) {
|
||||||
original_transforms.insert(object_id, object.transform.clone());
|
original_transforms.insert(object_id, object.transform.clone());
|
||||||
}
|
}
|
||||||
|
|
@ -2661,12 +2909,12 @@ 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::TransformObjectsAction;
|
use lightningbeam_core::actions::TransformShapeInstancesAction;
|
||||||
|
|
||||||
let mut object_transforms = HashMap::new();
|
let mut object_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 {
|
||||||
if let Some(object) = vector_layer.get_object(&object_id) {
|
if let Some(object) = vector_layer.get_object(&object_id) {
|
||||||
let new_transform = object.transform.clone();
|
let new_transform = object.transform.clone();
|
||||||
|
|
@ -2676,7 +2924,7 @@ impl StagePane {
|
||||||
}
|
}
|
||||||
|
|
||||||
if !object_transforms.is_empty() {
|
if !object_transforms.is_empty() {
|
||||||
let action = TransformObjectsAction::new(*active_layer_id, object_transforms);
|
let action = TransformShapeInstancesAction::new(active_layer_id, object_transforms);
|
||||||
shared.pending_actions.push(Box::new(action));
|
shared.pending_actions.push(Box::new(action));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2699,11 +2947,11 @@ impl StagePane {
|
||||||
use lightningbeam_core::layer::AnyLayer;
|
use lightningbeam_core::layer::AnyLayer;
|
||||||
use vello::kurbo::Affine;
|
use vello::kurbo::Affine;
|
||||||
|
|
||||||
let object_id = *shared.selection.objects().iter().next().unwrap();
|
let object_id = *shared.selection.shape_instances().iter().next().unwrap();
|
||||||
|
|
||||||
// Calculate rotated bounding box corners
|
// Calculate rotated bounding box corners
|
||||||
let (local_bbox, world_corners, obj_transform, object) = {
|
let (local_bbox, world_corners, obj_transform, object) = {
|
||||||
if let Some(AnyLayer::Vector(vector_layer)) = shared.action_executor.document().get_layer(active_layer_id) {
|
if let Some(AnyLayer::Vector(vector_layer)) = shared.action_executor.document().get_layer(&active_layer_id) {
|
||||||
if let Some(object) = vector_layer.get_object(&object_id) {
|
if let Some(object) = vector_layer.get_object(&object_id) {
|
||||||
if let Some(shape) = vector_layer.get_shape(&object.shape_id) {
|
if let Some(shape) = vector_layer.get_shape(&object.shape_id) {
|
||||||
let local_bbox = shape.path().bounding_box();
|
let local_bbox = shape.path().bounding_box();
|
||||||
|
|
@ -3303,11 +3551,11 @@ 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::TransformObjectsAction;
|
use lightningbeam_core::actions::TransformShapeInstancesAction;
|
||||||
|
|
||||||
let mut object_transforms = HashMap::new();
|
let mut object_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 {
|
||||||
if let Some(object) = vector_layer.get_object(&obj_id) {
|
if let Some(object) = vector_layer.get_object(&obj_id) {
|
||||||
object_transforms.insert(obj_id, (original, object.transform.clone()));
|
object_transforms.insert(obj_id, (original, object.transform.clone()));
|
||||||
|
|
@ -3316,7 +3564,7 @@ impl StagePane {
|
||||||
}
|
}
|
||||||
|
|
||||||
if !object_transforms.is_empty() {
|
if !object_transforms.is_empty() {
|
||||||
let action = TransformObjectsAction::new(*active_layer_id, object_transforms);
|
let action = TransformShapeInstancesAction::new(*active_layer_id, object_transforms);
|
||||||
shared.pending_actions.push(Box::new(action));
|
shared.pending_actions.push(Box::new(action));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -3356,8 +3604,8 @@ impl StagePane {
|
||||||
object_positions.insert(object_id, (original_pos, new_pos));
|
object_positions.insert(object_id, (original_pos, new_pos));
|
||||||
}
|
}
|
||||||
|
|
||||||
use lightningbeam_core::actions::MoveObjectsAction;
|
use lightningbeam_core::actions::MoveShapeInstancesAction;
|
||||||
let action = MoveObjectsAction::new(*active_layer_id, object_positions);
|
let action = MoveShapeInstancesAction::new(*active_layer_id, object_positions);
|
||||||
shared.pending_actions.push(Box::new(action));
|
shared.pending_actions.push(Box::new(action));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -3372,7 +3620,7 @@ impl StagePane {
|
||||||
use lightningbeam_core::hit_test;
|
use lightningbeam_core::hit_test;
|
||||||
use vello::kurbo::{Rect as KurboRect, Affine};
|
use vello::kurbo::{Rect as KurboRect, Affine};
|
||||||
|
|
||||||
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) {
|
||||||
// Create selection rectangle
|
// Create selection rectangle
|
||||||
let min_x = start.x.min(current.x);
|
let min_x = start.x.min(current.x);
|
||||||
let min_y = start.y.min(current.y);
|
let min_y = start.y.min(current.y);
|
||||||
|
|
@ -3390,7 +3638,7 @@ impl StagePane {
|
||||||
|
|
||||||
// Add to selection
|
// Add to selection
|
||||||
for obj_id in hits {
|
for obj_id in hits {
|
||||||
shared.selection.add_object(obj_id);
|
shared.selection.add_shape_instance(obj_id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,15 @@ const LAYER_HEIGHT: f32 = 60.0;
|
||||||
const LAYER_HEADER_WIDTH: f32 = 200.0;
|
const LAYER_HEADER_WIDTH: f32 = 200.0;
|
||||||
const MIN_PIXELS_PER_SECOND: f32 = 20.0;
|
const MIN_PIXELS_PER_SECOND: f32 = 20.0;
|
||||||
const MAX_PIXELS_PER_SECOND: f32 = 500.0;
|
const MAX_PIXELS_PER_SECOND: f32 = 500.0;
|
||||||
|
const EDGE_DETECTION_PIXELS: f32 = 8.0; // Distance from edge to detect trim handles
|
||||||
|
|
||||||
|
/// Type of clip drag operation
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||||
|
enum ClipDragType {
|
||||||
|
Move,
|
||||||
|
TrimLeft,
|
||||||
|
TrimRight,
|
||||||
|
}
|
||||||
|
|
||||||
pub struct TimelinePane {
|
pub struct TimelinePane {
|
||||||
/// Current playback time in seconds
|
/// Current playback time in seconds
|
||||||
|
|
@ -38,11 +47,15 @@ pub struct TimelinePane {
|
||||||
is_panning: bool,
|
is_panning: bool,
|
||||||
last_pan_pos: Option<egui::Pos2>,
|
last_pan_pos: Option<egui::Pos2>,
|
||||||
|
|
||||||
|
/// Clip drag state (None if not dragging)
|
||||||
|
clip_drag_state: Option<ClipDragType>,
|
||||||
|
drag_offset: f64, // Time offset being applied during drag (for preview)
|
||||||
|
|
||||||
|
/// Cached mouse position from mousedown (used for edge detection when drag starts)
|
||||||
|
mousedown_pos: Option<egui::Pos2>,
|
||||||
|
|
||||||
/// Is playback currently active?
|
/// Is playback currently active?
|
||||||
is_playing: bool,
|
is_playing: bool,
|
||||||
|
|
||||||
/// Currently selected/active layer index
|
|
||||||
active_layer: usize,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TimelinePane {
|
impl TimelinePane {
|
||||||
|
|
@ -51,12 +64,14 @@ impl TimelinePane {
|
||||||
current_time: 0.0,
|
current_time: 0.0,
|
||||||
pixels_per_second: 100.0,
|
pixels_per_second: 100.0,
|
||||||
viewport_start_time: 0.0,
|
viewport_start_time: 0.0,
|
||||||
active_layer: 0,
|
|
||||||
viewport_scroll_y: 0.0,
|
viewport_scroll_y: 0.0,
|
||||||
duration: 10.0, // Default 10 seconds
|
duration: 10.0, // Default 10 seconds
|
||||||
is_scrubbing: false,
|
is_scrubbing: false,
|
||||||
is_panning: false,
|
is_panning: false,
|
||||||
last_pan_pos: None,
|
last_pan_pos: None,
|
||||||
|
clip_drag_state: None,
|
||||||
|
drag_offset: 0.0,
|
||||||
|
mousedown_pos: None,
|
||||||
is_playing: false,
|
is_playing: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -74,6 +89,83 @@ impl TimelinePane {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Detect which clip is under the pointer and what type of drag would occur
|
||||||
|
///
|
||||||
|
/// Returns (drag_type, clip_id) if pointer is over a clip, None otherwise
|
||||||
|
fn detect_clip_at_pointer(
|
||||||
|
&self,
|
||||||
|
pointer_pos: egui::Pos2,
|
||||||
|
document: &lightningbeam_core::document::Document,
|
||||||
|
content_rect: egui::Rect,
|
||||||
|
header_rect: egui::Rect,
|
||||||
|
) -> Option<(ClipDragType, uuid::Uuid)> {
|
||||||
|
let layer_count = document.root.children.len();
|
||||||
|
|
||||||
|
// Check if pointer is in valid area
|
||||||
|
if pointer_pos.y < header_rect.min.y {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
if pointer_pos.x < content_rect.min.x {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let hover_time = self.x_to_time(pointer_pos.x - content_rect.min.x);
|
||||||
|
let relative_y = pointer_pos.y - header_rect.min.y + self.viewport_scroll_y;
|
||||||
|
let hovered_layer_index = (relative_y / LAYER_HEIGHT) as usize;
|
||||||
|
|
||||||
|
if hovered_layer_index >= layer_count {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let layers: Vec<_> = document.root.children.iter().rev().collect();
|
||||||
|
let layer = layers.get(hovered_layer_index)?;
|
||||||
|
let layer_data = layer.layer();
|
||||||
|
|
||||||
|
let clip_instances = match layer {
|
||||||
|
lightningbeam_core::layer::AnyLayer::Vector(vl) => &vl.clip_instances,
|
||||||
|
lightningbeam_core::layer::AnyLayer::Audio(al) => &al.clip_instances,
|
||||||
|
lightningbeam_core::layer::AnyLayer::Video(vl) => &vl.clip_instances,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check each clip instance
|
||||||
|
for clip_instance in clip_instances {
|
||||||
|
let clip_duration = match layer {
|
||||||
|
lightningbeam_core::layer::AnyLayer::Vector(_) => {
|
||||||
|
document.get_vector_clip(&clip_instance.clip_id).map(|c| c.duration)
|
||||||
|
}
|
||||||
|
lightningbeam_core::layer::AnyLayer::Audio(_) => {
|
||||||
|
document.get_audio_clip(&clip_instance.clip_id).map(|c| c.duration)
|
||||||
|
}
|
||||||
|
lightningbeam_core::layer::AnyLayer::Video(_) => {
|
||||||
|
document.get_video_clip(&clip_instance.clip_id).map(|c| c.duration)
|
||||||
|
}
|
||||||
|
}?;
|
||||||
|
|
||||||
|
let instance_duration = clip_instance.effective_duration(clip_duration);
|
||||||
|
let instance_start = clip_instance.timeline_start;
|
||||||
|
let instance_end = instance_start + instance_duration;
|
||||||
|
|
||||||
|
if hover_time >= instance_start && hover_time <= instance_end {
|
||||||
|
let start_x = self.time_to_x(instance_start);
|
||||||
|
let end_x = self.time_to_x(instance_end);
|
||||||
|
let mouse_x = pointer_pos.x - content_rect.min.x;
|
||||||
|
|
||||||
|
// Determine drag type based on edge proximity (check both sides of edge)
|
||||||
|
let drag_type = if (mouse_x - start_x).abs() <= EDGE_DETECTION_PIXELS {
|
||||||
|
ClipDragType::TrimLeft
|
||||||
|
} else if (end_x - mouse_x).abs() <= EDGE_DETECTION_PIXELS {
|
||||||
|
ClipDragType::TrimRight
|
||||||
|
} else {
|
||||||
|
ClipDragType::Move
|
||||||
|
};
|
||||||
|
|
||||||
|
return Some((drag_type, clip_instance.id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
/// Zoom in by a fixed increment
|
/// Zoom in by a fixed increment
|
||||||
pub fn zoom_in(&mut self, center_x: f32) {
|
pub fn zoom_in(&mut self, center_x: f32) {
|
||||||
self.apply_zoom_at_point(0.2, center_x);
|
self.apply_zoom_at_point(0.2, center_x);
|
||||||
|
|
@ -243,7 +335,14 @@ impl TimelinePane {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Render layer header column (left side with track names and controls)
|
/// Render layer header column (left side with track names and controls)
|
||||||
fn render_layer_headers(&self, ui: &mut egui::Ui, rect: egui::Rect, theme: &crate::theme::Theme) {
|
fn render_layer_headers(
|
||||||
|
&self,
|
||||||
|
ui: &mut egui::Ui,
|
||||||
|
rect: egui::Rect,
|
||||||
|
theme: &crate::theme::Theme,
|
||||||
|
document: &lightningbeam_core::document::Document,
|
||||||
|
active_layer_id: &Option<uuid::Uuid>,
|
||||||
|
) {
|
||||||
let painter = ui.painter();
|
let painter = ui.painter();
|
||||||
|
|
||||||
// Background for header column
|
// Background for header column
|
||||||
|
|
@ -264,9 +363,10 @@ impl TimelinePane {
|
||||||
// Get text color from theme
|
// Get text color from theme
|
||||||
let text_style = theme.style(".text-primary", ui.ctx());
|
let text_style = theme.style(".text-primary", ui.ctx());
|
||||||
let text_color = text_style.text_color.unwrap_or(egui::Color32::from_gray(200));
|
let text_color = text_style.text_color.unwrap_or(egui::Color32::from_gray(200));
|
||||||
|
let secondary_text_color = egui::Color32::from_gray(150);
|
||||||
|
|
||||||
// Test: Draw 3 layer headers
|
// Draw layer headers from document (reversed so newest layers appear on top)
|
||||||
for i in 0..3 {
|
for (i, layer) in document.root.children.iter().rev().enumerate() {
|
||||||
let y = rect.min.y + i as f32 * LAYER_HEIGHT - self.viewport_scroll_y;
|
let y = rect.min.y + i as f32 * LAYER_HEIGHT - self.viewport_scroll_y;
|
||||||
|
|
||||||
// Skip if layer is outside visible area
|
// Skip if layer is outside visible area
|
||||||
|
|
@ -280,7 +380,8 @@ impl TimelinePane {
|
||||||
);
|
);
|
||||||
|
|
||||||
// Active vs inactive background colors
|
// Active vs inactive background colors
|
||||||
let bg_color = if i == self.active_layer {
|
let is_active = active_layer_id.map_or(false, |id| id == layer.id());
|
||||||
|
let bg_color = if is_active {
|
||||||
active_color
|
active_color
|
||||||
} else {
|
} else {
|
||||||
inactive_color
|
inactive_color
|
||||||
|
|
@ -288,15 +389,58 @@ impl TimelinePane {
|
||||||
|
|
||||||
painter.rect_filled(header_rect, 0.0, bg_color);
|
painter.rect_filled(header_rect, 0.0, bg_color);
|
||||||
|
|
||||||
|
// Get layer info
|
||||||
|
let layer_data = layer.layer();
|
||||||
|
let layer_name = &layer_data.name;
|
||||||
|
let (layer_type, type_color) = match layer {
|
||||||
|
lightningbeam_core::layer::AnyLayer::Vector(_) => ("Vector", egui::Color32::from_rgb(100, 150, 255)), // Blue
|
||||||
|
lightningbeam_core::layer::AnyLayer::Audio(_) => ("Audio", egui::Color32::from_rgb(100, 255, 150)), // Green
|
||||||
|
lightningbeam_core::layer::AnyLayer::Video(_) => ("Video", egui::Color32::from_rgb(255, 150, 100)), // Orange
|
||||||
|
};
|
||||||
|
|
||||||
|
// Color indicator bar on the left edge
|
||||||
|
let indicator_rect = egui::Rect::from_min_size(
|
||||||
|
header_rect.min,
|
||||||
|
egui::vec2(4.0, LAYER_HEIGHT),
|
||||||
|
);
|
||||||
|
painter.rect_filled(indicator_rect, 0.0, type_color);
|
||||||
|
|
||||||
// Layer name
|
// Layer name
|
||||||
painter.text(
|
painter.text(
|
||||||
header_rect.min + egui::vec2(10.0, 10.0),
|
header_rect.min + egui::vec2(10.0, 10.0),
|
||||||
egui::Align2::LEFT_TOP,
|
egui::Align2::LEFT_TOP,
|
||||||
format!("Layer {}", i + 1),
|
layer_name,
|
||||||
egui::FontId::proportional(14.0),
|
egui::FontId::proportional(14.0),
|
||||||
text_color,
|
text_color,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Layer type (smaller text below name with colored background)
|
||||||
|
let type_text_pos = header_rect.min + egui::vec2(10.0, 28.0);
|
||||||
|
let type_text_galley = painter.layout_no_wrap(
|
||||||
|
layer_type.to_string(),
|
||||||
|
egui::FontId::proportional(11.0),
|
||||||
|
secondary_text_color,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Draw colored background for type label
|
||||||
|
let type_bg_rect = egui::Rect::from_min_size(
|
||||||
|
type_text_pos + egui::vec2(-2.0, -1.0),
|
||||||
|
egui::vec2(type_text_galley.size().x + 4.0, type_text_galley.size().y + 2.0),
|
||||||
|
);
|
||||||
|
painter.rect_filled(
|
||||||
|
type_bg_rect,
|
||||||
|
2.0,
|
||||||
|
egui::Color32::from_rgba_unmultiplied(type_color.r(), type_color.g(), type_color.b(), 60),
|
||||||
|
);
|
||||||
|
|
||||||
|
painter.text(
|
||||||
|
type_text_pos,
|
||||||
|
egui::Align2::LEFT_TOP,
|
||||||
|
layer_type,
|
||||||
|
egui::FontId::proportional(11.0),
|
||||||
|
secondary_text_color,
|
||||||
|
);
|
||||||
|
|
||||||
// Separator line at bottom
|
// Separator line at bottom
|
||||||
painter.line_segment(
|
painter.line_segment(
|
||||||
[
|
[
|
||||||
|
|
@ -318,7 +462,15 @@ impl TimelinePane {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Render layer rows (timeline content area)
|
/// Render layer rows (timeline content area)
|
||||||
fn render_layers(&self, ui: &mut egui::Ui, rect: egui::Rect, theme: &crate::theme::Theme) {
|
fn render_layers(
|
||||||
|
&self,
|
||||||
|
ui: &mut egui::Ui,
|
||||||
|
rect: egui::Rect,
|
||||||
|
theme: &crate::theme::Theme,
|
||||||
|
document: &lightningbeam_core::document::Document,
|
||||||
|
active_layer_id: &Option<uuid::Uuid>,
|
||||||
|
selection: &lightningbeam_core::selection::Selection,
|
||||||
|
) {
|
||||||
let painter = ui.painter();
|
let painter = ui.painter();
|
||||||
|
|
||||||
// Theme colors for active/inactive layers
|
// Theme colors for active/inactive layers
|
||||||
|
|
@ -327,8 +479,8 @@ impl TimelinePane {
|
||||||
let active_color = active_style.background_color.unwrap_or(egui::Color32::from_rgb(85, 85, 85));
|
let active_color = active_style.background_color.unwrap_or(egui::Color32::from_rgb(85, 85, 85));
|
||||||
let inactive_color = inactive_style.background_color.unwrap_or(egui::Color32::from_rgb(136, 136, 136));
|
let inactive_color = inactive_style.background_color.unwrap_or(egui::Color32::from_rgb(136, 136, 136));
|
||||||
|
|
||||||
// Test: Draw 3 layer rows
|
// Draw layer rows from document (reversed so newest layers appear on top)
|
||||||
for i in 0..3 {
|
for (i, layer) in document.root.children.iter().rev().enumerate() {
|
||||||
let y = rect.min.y + i as f32 * LAYER_HEIGHT - self.viewport_scroll_y;
|
let y = rect.min.y + i as f32 * LAYER_HEIGHT - self.viewport_scroll_y;
|
||||||
|
|
||||||
// Skip if layer is outside visible area
|
// Skip if layer is outside visible area
|
||||||
|
|
@ -342,7 +494,8 @@ impl TimelinePane {
|
||||||
);
|
);
|
||||||
|
|
||||||
// Active vs inactive background colors
|
// Active vs inactive background colors
|
||||||
let bg_color = if i == self.active_layer {
|
let is_active = active_layer_id.map_or(false, |id| id == layer.id());
|
||||||
|
let bg_color = if is_active {
|
||||||
active_color
|
active_color
|
||||||
} else {
|
} else {
|
||||||
inactive_color
|
inactive_color
|
||||||
|
|
@ -372,6 +525,141 @@ impl TimelinePane {
|
||||||
time += interval;
|
time += interval;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Draw clip instances for this layer
|
||||||
|
let clip_instances = match layer {
|
||||||
|
lightningbeam_core::layer::AnyLayer::Vector(vl) => &vl.clip_instances,
|
||||||
|
lightningbeam_core::layer::AnyLayer::Audio(al) => &al.clip_instances,
|
||||||
|
lightningbeam_core::layer::AnyLayer::Video(vl) => &vl.clip_instances,
|
||||||
|
};
|
||||||
|
|
||||||
|
for clip_instance in clip_instances {
|
||||||
|
// Get the clip to determine duration
|
||||||
|
let clip_duration = match layer {
|
||||||
|
lightningbeam_core::layer::AnyLayer::Vector(_) => {
|
||||||
|
document.get_vector_clip(&clip_instance.clip_id)
|
||||||
|
.map(|c| c.duration)
|
||||||
|
}
|
||||||
|
lightningbeam_core::layer::AnyLayer::Audio(_) => {
|
||||||
|
document.get_audio_clip(&clip_instance.clip_id)
|
||||||
|
.map(|c| c.duration)
|
||||||
|
}
|
||||||
|
lightningbeam_core::layer::AnyLayer::Video(_) => {
|
||||||
|
document.get_video_clip(&clip_instance.clip_id)
|
||||||
|
.map(|c| c.duration)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(clip_duration) = clip_duration {
|
||||||
|
// Calculate effective duration accounting for trimming
|
||||||
|
let mut instance_duration = clip_instance.effective_duration(clip_duration);
|
||||||
|
|
||||||
|
// Instance positioned on the layer's timeline using timeline_start
|
||||||
|
// The layer itself has start_time, so the absolute timeline position is:
|
||||||
|
// layer.start_time + instance.timeline_start
|
||||||
|
let layer_data = layer.layer();
|
||||||
|
let mut instance_start = clip_instance.timeline_start;
|
||||||
|
|
||||||
|
// Apply drag offset preview for selected clips
|
||||||
|
let is_selected = selection.contains_clip_instance(&clip_instance.id);
|
||||||
|
|
||||||
|
if let Some(drag_type) = self.clip_drag_state {
|
||||||
|
if is_selected {
|
||||||
|
match drag_type {
|
||||||
|
ClipDragType::Move => {
|
||||||
|
// Move: shift the entire clip along the timeline
|
||||||
|
instance_start += self.drag_offset;
|
||||||
|
}
|
||||||
|
ClipDragType::TrimLeft => {
|
||||||
|
// Trim left: calculate new trim_start and clamp to valid range
|
||||||
|
let new_trim_start = (clip_instance.trim_start + self.drag_offset)
|
||||||
|
.max(0.0)
|
||||||
|
.min(clip_duration);
|
||||||
|
let actual_offset = new_trim_start - clip_instance.trim_start;
|
||||||
|
|
||||||
|
// Move start and reduce duration by actual clamped offset
|
||||||
|
instance_start = (clip_instance.timeline_start + actual_offset)
|
||||||
|
.max(0.0);
|
||||||
|
instance_duration = (clip_duration - new_trim_start).max(0.0);
|
||||||
|
|
||||||
|
// Adjust for existing trim_end
|
||||||
|
if let Some(trim_end) = clip_instance.trim_end {
|
||||||
|
instance_duration = (trim_end - new_trim_start).max(0.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ClipDragType::TrimRight => {
|
||||||
|
// Trim right: extend or reduce duration, clamped to available content
|
||||||
|
let max_duration = clip_duration - clip_instance.trim_start;
|
||||||
|
instance_duration = (instance_duration + self.drag_offset)
|
||||||
|
.max(0.0)
|
||||||
|
.min(max_duration);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let instance_end = instance_start + instance_duration;
|
||||||
|
|
||||||
|
let start_x = self.time_to_x(instance_start);
|
||||||
|
let end_x = self.time_to_x(instance_end);
|
||||||
|
|
||||||
|
// Only draw if any part is visible in viewport
|
||||||
|
if end_x >= 0.0 && start_x <= rect.width() {
|
||||||
|
let visible_start_x = start_x.max(0.0);
|
||||||
|
let visible_end_x = end_x.min(rect.width());
|
||||||
|
|
||||||
|
// Choose color based on layer type
|
||||||
|
let (clip_color, bright_color) = match layer {
|
||||||
|
lightningbeam_core::layer::AnyLayer::Vector(_) => (
|
||||||
|
egui::Color32::from_rgb(100, 150, 255), // Blue
|
||||||
|
egui::Color32::from_rgb(150, 200, 255), // Bright blue
|
||||||
|
),
|
||||||
|
lightningbeam_core::layer::AnyLayer::Audio(_) => (
|
||||||
|
egui::Color32::from_rgb(100, 255, 150), // Green
|
||||||
|
egui::Color32::from_rgb(150, 255, 200), // Bright green
|
||||||
|
),
|
||||||
|
lightningbeam_core::layer::AnyLayer::Video(_) => (
|
||||||
|
egui::Color32::from_rgb(255, 150, 100), // Orange
|
||||||
|
egui::Color32::from_rgb(255, 200, 150), // Bright orange
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
let clip_rect = egui::Rect::from_min_max(
|
||||||
|
egui::pos2(rect.min.x + visible_start_x, y + 10.0),
|
||||||
|
egui::pos2(rect.min.x + visible_end_x, y + LAYER_HEIGHT - 10.0),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Draw the clip instance
|
||||||
|
painter.rect_filled(
|
||||||
|
clip_rect,
|
||||||
|
3.0, // Rounded corners
|
||||||
|
clip_color,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Draw border only if selected (brighter version of clip color)
|
||||||
|
if selection.contains_clip_instance(&clip_instance.id) {
|
||||||
|
painter.rect_stroke(
|
||||||
|
clip_rect,
|
||||||
|
3.0,
|
||||||
|
egui::Stroke::new(3.0, bright_color),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw clip name if there's space
|
||||||
|
if let Some(name) = &clip_instance.name {
|
||||||
|
if clip_rect.width() > 50.0 {
|
||||||
|
painter.text(
|
||||||
|
clip_rect.min + egui::vec2(5.0, 5.0),
|
||||||
|
egui::Align2::LEFT_TOP,
|
||||||
|
name,
|
||||||
|
egui::FontId::proportional(11.0),
|
||||||
|
egui::Color32::WHITE,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Separator line at bottom
|
// Separator line at bottom
|
||||||
painter.line_segment(
|
painter.line_segment(
|
||||||
[
|
[
|
||||||
|
|
@ -383,8 +671,20 @@ impl TimelinePane {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Handle mouse input for scrubbing, panning, and zooming
|
/// Handle mouse input for scrubbing, panning, zooming, layer selection, and clip instance selection
|
||||||
fn handle_input(&mut self, ui: &mut egui::Ui, full_timeline_rect: egui::Rect, ruler_rect: egui::Rect, content_rect: egui::Rect) {
|
fn handle_input(
|
||||||
|
&mut self,
|
||||||
|
ui: &mut egui::Ui,
|
||||||
|
full_timeline_rect: egui::Rect,
|
||||||
|
ruler_rect: egui::Rect,
|
||||||
|
content_rect: egui::Rect,
|
||||||
|
header_rect: egui::Rect,
|
||||||
|
layer_count: usize,
|
||||||
|
document: &lightningbeam_core::document::Document,
|
||||||
|
active_layer_id: &mut Option<uuid::Uuid>,
|
||||||
|
selection: &mut lightningbeam_core::selection::Selection,
|
||||||
|
pending_actions: &mut Vec<Box<dyn lightningbeam_core::action::Action>>,
|
||||||
|
) {
|
||||||
let response = ui.allocate_rect(full_timeline_rect, egui::Sense::click_and_drag());
|
let response = ui.allocate_rect(full_timeline_rect, egui::Sense::click_and_drag());
|
||||||
|
|
||||||
// Only process input if mouse is over the timeline pane
|
// Only process input if mouse is over the timeline pane
|
||||||
|
|
@ -397,34 +697,358 @@ impl TimelinePane {
|
||||||
|
|
||||||
let alt_held = ui.input(|i| i.modifiers.alt);
|
let alt_held = ui.input(|i| i.modifiers.alt);
|
||||||
let ctrl_held = ui.input(|i| i.modifiers.ctrl || i.modifiers.command);
|
let ctrl_held = ui.input(|i| i.modifiers.ctrl || i.modifiers.command);
|
||||||
|
let shift_held = ui.input(|i| i.modifiers.shift);
|
||||||
|
|
||||||
|
// Handle clip instance selection by clicking on clip rectangles
|
||||||
|
let mut clicked_clip_instance = false;
|
||||||
|
if response.clicked() && !alt_held {
|
||||||
|
if let Some(pos) = response.interact_pointer_pos() {
|
||||||
|
// Check if click is in content area (not ruler or header column)
|
||||||
|
if pos.y >= header_rect.min.y && pos.x >= content_rect.min.x {
|
||||||
|
let relative_y = pos.y - header_rect.min.y + self.viewport_scroll_y;
|
||||||
|
let clicked_layer_index = (relative_y / LAYER_HEIGHT) as usize;
|
||||||
|
let click_time = self.x_to_time(pos.x - content_rect.min.x);
|
||||||
|
|
||||||
|
// Get the layer at this index (accounting for reversed display order)
|
||||||
|
if clicked_layer_index < layer_count {
|
||||||
|
let layers: Vec<_> = document.root.children.iter().rev().collect();
|
||||||
|
if let Some(layer) = layers.get(clicked_layer_index) {
|
||||||
|
let layer_data = layer.layer();
|
||||||
|
|
||||||
|
// Get clip instances for this layer
|
||||||
|
let clip_instances = match layer {
|
||||||
|
lightningbeam_core::layer::AnyLayer::Vector(vl) => &vl.clip_instances,
|
||||||
|
lightningbeam_core::layer::AnyLayer::Audio(al) => &al.clip_instances,
|
||||||
|
lightningbeam_core::layer::AnyLayer::Video(vl) => &vl.clip_instances,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if click is within any clip instance
|
||||||
|
for clip_instance in clip_instances {
|
||||||
|
// Get the clip to determine duration
|
||||||
|
let clip_duration = match layer {
|
||||||
|
lightningbeam_core::layer::AnyLayer::Vector(_) => {
|
||||||
|
document.get_vector_clip(&clip_instance.clip_id)
|
||||||
|
.map(|c| c.duration)
|
||||||
|
}
|
||||||
|
lightningbeam_core::layer::AnyLayer::Audio(_) => {
|
||||||
|
document.get_audio_clip(&clip_instance.clip_id)
|
||||||
|
.map(|c| c.duration)
|
||||||
|
}
|
||||||
|
lightningbeam_core::layer::AnyLayer::Video(_) => {
|
||||||
|
document.get_video_clip(&clip_instance.clip_id)
|
||||||
|
.map(|c| c.duration)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(clip_duration) = clip_duration {
|
||||||
|
let instance_duration = clip_instance.effective_duration(clip_duration);
|
||||||
|
let instance_start = clip_instance.timeline_start;
|
||||||
|
let instance_end = instance_start + instance_duration;
|
||||||
|
|
||||||
|
// Check if click is within this clip instance's time range
|
||||||
|
if click_time >= instance_start && click_time <= instance_end {
|
||||||
|
// Found a clicked clip instance!
|
||||||
|
if shift_held {
|
||||||
|
// Shift+click: add to selection
|
||||||
|
selection.add_clip_instance(clip_instance.id);
|
||||||
|
} else {
|
||||||
|
// Regular click: select only this clip
|
||||||
|
selection.select_only_clip_instance(clip_instance.id);
|
||||||
|
}
|
||||||
|
clicked_clip_instance = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache mouse position on mousedown (before any dragging)
|
||||||
|
if response.hovered() && ui.input(|i| i.pointer.button_pressed(egui::PointerButton::Primary)) {
|
||||||
|
if let Some(pos) = response.hover_pos() {
|
||||||
|
self.mousedown_pos = Some(pos);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle clip dragging (only if not panning or scrubbing)
|
||||||
|
if !alt_held && !self.is_scrubbing && !self.is_panning {
|
||||||
|
if response.drag_started() {
|
||||||
|
// Use cached mousedown position for edge detection
|
||||||
|
if let Some(mousedown_pos) = self.mousedown_pos {
|
||||||
|
if let Some((drag_type, clip_id)) = self.detect_clip_at_pointer(
|
||||||
|
mousedown_pos,
|
||||||
|
document,
|
||||||
|
content_rect,
|
||||||
|
header_rect,
|
||||||
|
) {
|
||||||
|
// If this clip is not selected, select it (respecting shift key)
|
||||||
|
if !selection.contains_clip_instance(&clip_id) {
|
||||||
|
if shift_held {
|
||||||
|
selection.add_clip_instance(clip_id);
|
||||||
|
} else {
|
||||||
|
selection.select_only_clip_instance(clip_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start dragging with the detected drag type
|
||||||
|
self.clip_drag_state = Some(drag_type);
|
||||||
|
self.drag_offset = 0.0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update drag offset during drag
|
||||||
|
if self.clip_drag_state.is_some() && response.dragged() {
|
||||||
|
let drag_delta = response.drag_delta();
|
||||||
|
let time_delta = drag_delta.x / self.pixels_per_second;
|
||||||
|
self.drag_offset += time_delta as f64;
|
||||||
|
}
|
||||||
|
|
||||||
|
// End drag - create action based on drag type
|
||||||
|
if let Some(drag_type) = self.clip_drag_state {
|
||||||
|
if response.drag_stopped() {
|
||||||
|
// Build layer_moves map for the action
|
||||||
|
use std::collections::HashMap;
|
||||||
|
let mut layer_moves: HashMap<uuid::Uuid, Vec<(uuid::Uuid, f64, f64)>> =
|
||||||
|
HashMap::new();
|
||||||
|
|
||||||
|
// Iterate through all layers to find selected clip instances
|
||||||
|
for layer in &document.root.children {
|
||||||
|
let layer_id = layer.id();
|
||||||
|
|
||||||
|
// Get clip instances for this layer
|
||||||
|
let clip_instances = match layer {
|
||||||
|
lightningbeam_core::layer::AnyLayer::Vector(vl) => &vl.clip_instances,
|
||||||
|
lightningbeam_core::layer::AnyLayer::Audio(al) => &al.clip_instances,
|
||||||
|
lightningbeam_core::layer::AnyLayer::Video(vl) => &vl.clip_instances,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Find selected clip instances in this layer
|
||||||
|
for clip_instance in clip_instances {
|
||||||
|
if selection.contains_clip_instance(&clip_instance.id) {
|
||||||
|
let old_timeline_start = clip_instance.timeline_start;
|
||||||
|
let new_timeline_start = old_timeline_start + self.drag_offset;
|
||||||
|
|
||||||
|
// Add to layer_moves
|
||||||
|
layer_moves
|
||||||
|
.entry(layer_id)
|
||||||
|
.or_insert_with(Vec::new)
|
||||||
|
.push((clip_instance.id, old_timeline_start, new_timeline_start));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create and add the action based on drag type
|
||||||
|
match drag_type {
|
||||||
|
ClipDragType::Move => {
|
||||||
|
if !layer_moves.is_empty() {
|
||||||
|
let action = Box::new(
|
||||||
|
lightningbeam_core::actions::MoveClipInstancesAction::new(
|
||||||
|
layer_moves,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
pending_actions.push(action);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ClipDragType::TrimLeft | ClipDragType::TrimRight => {
|
||||||
|
// Build layer_trims map for trim action
|
||||||
|
let mut layer_trims: HashMap<
|
||||||
|
uuid::Uuid,
|
||||||
|
Vec<(
|
||||||
|
uuid::Uuid,
|
||||||
|
lightningbeam_core::actions::TrimType,
|
||||||
|
lightningbeam_core::actions::TrimData,
|
||||||
|
lightningbeam_core::actions::TrimData,
|
||||||
|
)>,
|
||||||
|
> = HashMap::new();
|
||||||
|
|
||||||
|
// Iterate through all layers to find selected clip instances
|
||||||
|
for layer in &document.root.children {
|
||||||
|
let layer_id = layer.id();
|
||||||
|
let layer_data = layer.layer();
|
||||||
|
|
||||||
|
let clip_instances = match layer {
|
||||||
|
lightningbeam_core::layer::AnyLayer::Vector(vl) => {
|
||||||
|
&vl.clip_instances
|
||||||
|
}
|
||||||
|
lightningbeam_core::layer::AnyLayer::Audio(al) => {
|
||||||
|
&al.clip_instances
|
||||||
|
}
|
||||||
|
lightningbeam_core::layer::AnyLayer::Video(vl) => {
|
||||||
|
&vl.clip_instances
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Find selected clip instances in this layer
|
||||||
|
for clip_instance in clip_instances {
|
||||||
|
if selection.contains_clip_instance(&clip_instance.id) {
|
||||||
|
// Get clip duration to validate trim bounds
|
||||||
|
let clip_duration = match layer {
|
||||||
|
lightningbeam_core::layer::AnyLayer::Vector(_) => {
|
||||||
|
document
|
||||||
|
.get_vector_clip(&clip_instance.clip_id)
|
||||||
|
.map(|c| c.duration)
|
||||||
|
}
|
||||||
|
lightningbeam_core::layer::AnyLayer::Audio(_) => {
|
||||||
|
document
|
||||||
|
.get_audio_clip(&clip_instance.clip_id)
|
||||||
|
.map(|c| c.duration)
|
||||||
|
}
|
||||||
|
lightningbeam_core::layer::AnyLayer::Video(_) => {
|
||||||
|
document
|
||||||
|
.get_video_clip(&clip_instance.clip_id)
|
||||||
|
.map(|c| c.duration)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(clip_duration) = clip_duration {
|
||||||
|
match drag_type {
|
||||||
|
ClipDragType::TrimLeft => {
|
||||||
|
let old_trim_start = clip_instance.trim_start;
|
||||||
|
let old_timeline_start =
|
||||||
|
clip_instance.timeline_start;
|
||||||
|
|
||||||
|
// New trim_start is clamped to valid range
|
||||||
|
let new_trim_start = (old_trim_start
|
||||||
|
+ self.drag_offset)
|
||||||
|
.max(0.0)
|
||||||
|
.min(clip_duration);
|
||||||
|
|
||||||
|
// Calculate actual offset after clamping
|
||||||
|
let actual_offset = new_trim_start - old_trim_start;
|
||||||
|
let new_timeline_start =
|
||||||
|
old_timeline_start + actual_offset;
|
||||||
|
|
||||||
|
layer_trims
|
||||||
|
.entry(layer_id)
|
||||||
|
.or_insert_with(Vec::new)
|
||||||
|
.push((
|
||||||
|
clip_instance.id,
|
||||||
|
lightningbeam_core::actions::TrimType::TrimLeft,
|
||||||
|
lightningbeam_core::actions::TrimData::left(
|
||||||
|
old_trim_start,
|
||||||
|
old_timeline_start,
|
||||||
|
),
|
||||||
|
lightningbeam_core::actions::TrimData::left(
|
||||||
|
new_trim_start,
|
||||||
|
new_timeline_start,
|
||||||
|
),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
ClipDragType::TrimRight => {
|
||||||
|
let old_trim_end = clip_instance.trim_end;
|
||||||
|
|
||||||
|
// Calculate new trim_end based on current duration
|
||||||
|
let current_duration =
|
||||||
|
clip_instance.effective_duration(clip_duration);
|
||||||
|
let new_duration =
|
||||||
|
(current_duration + self.drag_offset).max(0.0);
|
||||||
|
|
||||||
|
// Convert new duration back to trim_end value
|
||||||
|
let new_trim_end = if new_duration >= clip_duration {
|
||||||
|
None // Use full clip duration
|
||||||
|
} else {
|
||||||
|
Some((clip_instance.trim_start + new_duration).min(clip_duration))
|
||||||
|
};
|
||||||
|
|
||||||
|
layer_trims
|
||||||
|
.entry(layer_id)
|
||||||
|
.or_insert_with(Vec::new)
|
||||||
|
.push((
|
||||||
|
clip_instance.id,
|
||||||
|
lightningbeam_core::actions::TrimType::TrimRight,
|
||||||
|
lightningbeam_core::actions::TrimData::right(
|
||||||
|
old_trim_end,
|
||||||
|
),
|
||||||
|
lightningbeam_core::actions::TrimData::right(
|
||||||
|
new_trim_end,
|
||||||
|
),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create and add the trim action if there are any trims
|
||||||
|
if !layer_trims.is_empty() {
|
||||||
|
let action = Box::new(
|
||||||
|
lightningbeam_core::actions::TrimClipInstancesAction::new(
|
||||||
|
layer_trims,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
pending_actions.push(action);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset drag state
|
||||||
|
self.clip_drag_state = None;
|
||||||
|
self.drag_offset = 0.0;
|
||||||
|
self.mousedown_pos = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle layer selection by clicking on layer header or content (only if no clip was clicked)
|
||||||
|
if response.clicked() && !alt_held && !clicked_clip_instance {
|
||||||
|
if let Some(pos) = response.interact_pointer_pos() {
|
||||||
|
// Check if click is in header or content area (not ruler)
|
||||||
|
if pos.y >= header_rect.min.y {
|
||||||
|
let relative_y = pos.y - header_rect.min.y + self.viewport_scroll_y;
|
||||||
|
let clicked_layer_index = (relative_y / LAYER_HEIGHT) as usize;
|
||||||
|
|
||||||
|
// Get the layer at this index (accounting for reversed display order)
|
||||||
|
if clicked_layer_index < layer_count {
|
||||||
|
let layers: Vec<_> = document.root.children.iter().rev().collect();
|
||||||
|
if let Some(layer) = layers.get(clicked_layer_index) {
|
||||||
|
*active_layer_id = Some(layer.id());
|
||||||
|
// Clear clip instance selection when clicking on empty layer area
|
||||||
|
if !shift_held {
|
||||||
|
selection.clear_clip_instances();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Get mouse position relative to content area
|
// Get mouse position relative to content area
|
||||||
let mouse_pos = response.hover_pos().unwrap_or(content_rect.center());
|
let mouse_pos = response.hover_pos().unwrap_or(content_rect.center());
|
||||||
let mouse_x = (mouse_pos.x - content_rect.min.x).max(0.0);
|
let mouse_x = (mouse_pos.x - content_rect.min.x).max(0.0);
|
||||||
|
|
||||||
// Calculate max vertical scroll based on number of layers
|
// Calculate max vertical scroll based on number of layers
|
||||||
// TODO: Get actual layer count from document - for now using test count of 3
|
let total_content_height = layer_count as f32 * LAYER_HEIGHT;
|
||||||
const TEST_LAYER_COUNT: usize = 3;
|
|
||||||
let total_content_height = TEST_LAYER_COUNT as f32 * LAYER_HEIGHT;
|
|
||||||
let visible_height = content_rect.height();
|
let visible_height = content_rect.height();
|
||||||
let max_scroll_y = (total_content_height - visible_height).max(0.0);
|
let max_scroll_y = (total_content_height - visible_height).max(0.0);
|
||||||
|
|
||||||
// Scrubbing (clicking/dragging on ruler, but only when not panning)
|
// Scrubbing (clicking/dragging on ruler, but only when not panning)
|
||||||
if ruler_rect.contains(ui.input(|i| i.pointer.hover_pos().unwrap_or_default())) && !alt_held {
|
let cursor_over_ruler = ruler_rect.contains(ui.input(|i| i.pointer.hover_pos().unwrap_or_default()));
|
||||||
if response.clicked() || (response.dragged() && !self.is_panning) {
|
|
||||||
|
// Start scrubbing if cursor is over ruler and we click/drag
|
||||||
|
if cursor_over_ruler && !alt_held && (response.clicked() || (response.dragged() && !self.is_panning)) {
|
||||||
if let Some(pos) = response.interact_pointer_pos() {
|
if let Some(pos) = response.interact_pointer_pos() {
|
||||||
let x = (pos.x - content_rect.min.x).max(0.0);
|
let x = (pos.x - content_rect.min.x).max(0.0);
|
||||||
self.current_time = self.x_to_time(x).max(0.0).min(self.duration);
|
self.current_time = self.x_to_time(x).max(0.0);
|
||||||
self.is_scrubbing = true;
|
self.is_scrubbing = true;
|
||||||
}
|
}
|
||||||
} else if !response.dragged() {
|
|
||||||
self.is_scrubbing = false;
|
|
||||||
}
|
}
|
||||||
} else {
|
// Continue scrubbing while dragging, even if cursor leaves ruler
|
||||||
if !response.dragged() {
|
else if self.is_scrubbing && response.dragged() && !self.is_panning {
|
||||||
self.is_scrubbing = false;
|
if let Some(pos) = response.interact_pointer_pos() {
|
||||||
|
let x = (pos.x - content_rect.min.x).max(0.0);
|
||||||
|
self.current_time = self.x_to_time(x).max(0.0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Stop scrubbing when drag ends
|
||||||
|
else if !response.dragged() {
|
||||||
|
self.is_scrubbing = false;
|
||||||
|
}
|
||||||
|
|
||||||
// Distinguish between mouse wheel (discrete) and trackpad (smooth)
|
// Distinguish between mouse wheel (discrete) and trackpad (smooth)
|
||||||
let mut handled = false;
|
let mut handled = false;
|
||||||
|
|
@ -492,6 +1116,29 @@ impl TimelinePane {
|
||||||
self.last_pan_pos = None;
|
self.last_pan_pos = None;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Update cursor based on hover position (only if not scrubbing or panning)
|
||||||
|
if !self.is_scrubbing && !self.is_panning {
|
||||||
|
// If dragging a clip with trim, keep the resize cursor
|
||||||
|
if let Some(drag_type) = self.clip_drag_state {
|
||||||
|
if drag_type != ClipDragType::Move {
|
||||||
|
ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeHorizontal);
|
||||||
|
}
|
||||||
|
} else if let Some(hover_pos) = response.hover_pos() {
|
||||||
|
// Not dragging - detect hover for cursor feedback
|
||||||
|
if let Some((drag_type, _clip_id)) = self.detect_clip_at_pointer(
|
||||||
|
hover_pos,
|
||||||
|
document,
|
||||||
|
content_rect,
|
||||||
|
header_rect,
|
||||||
|
) {
|
||||||
|
// Set cursor for trim operations
|
||||||
|
if drag_type != ClipDragType::Move {
|
||||||
|
ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeHorizontal);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -558,6 +1205,48 @@ impl PaneRenderer for TimelinePane {
|
||||||
_path: &NodePath,
|
_path: &NodePath,
|
||||||
shared: &mut SharedPaneState,
|
shared: &mut SharedPaneState,
|
||||||
) {
|
) {
|
||||||
|
// Sync timeline's current_time to document
|
||||||
|
shared.action_executor.document_mut().current_time = self.current_time;
|
||||||
|
|
||||||
|
// Get document from action executor
|
||||||
|
let document = shared.action_executor.document();
|
||||||
|
let layer_count = document.root.children.len();
|
||||||
|
|
||||||
|
// Calculate project duration from last clip endpoint across all layers
|
||||||
|
let mut max_endpoint: f64 = 10.0; // Default minimum duration
|
||||||
|
for layer in &document.root.children {
|
||||||
|
let clip_instances = match layer {
|
||||||
|
lightningbeam_core::layer::AnyLayer::Vector(vl) => &vl.clip_instances,
|
||||||
|
lightningbeam_core::layer::AnyLayer::Audio(al) => &al.clip_instances,
|
||||||
|
lightningbeam_core::layer::AnyLayer::Video(vl) => &vl.clip_instances,
|
||||||
|
};
|
||||||
|
|
||||||
|
for clip_instance in clip_instances {
|
||||||
|
// Get clip duration
|
||||||
|
let clip_duration = match layer {
|
||||||
|
lightningbeam_core::layer::AnyLayer::Vector(_) => {
|
||||||
|
document.get_vector_clip(&clip_instance.clip_id)
|
||||||
|
.map(|c| c.duration)
|
||||||
|
}
|
||||||
|
lightningbeam_core::layer::AnyLayer::Audio(_) => {
|
||||||
|
document.get_audio_clip(&clip_instance.clip_id)
|
||||||
|
.map(|c| c.duration)
|
||||||
|
}
|
||||||
|
lightningbeam_core::layer::AnyLayer::Video(_) => {
|
||||||
|
document.get_video_clip(&clip_instance.clip_id)
|
||||||
|
.map(|c| c.duration)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(clip_duration) = clip_duration {
|
||||||
|
let instance_duration = clip_instance.effective_duration(clip_duration);
|
||||||
|
let instance_end = clip_instance.timeline_start + instance_duration;
|
||||||
|
max_endpoint = max_endpoint.max(instance_end);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.duration = max_endpoint;
|
||||||
|
|
||||||
// Split into layer header column (left) and timeline content (right)
|
// Split into layer header column (left) and timeline content (right)
|
||||||
let header_column_rect = egui::Rect::from_min_size(
|
let header_column_rect = egui::Rect::from_min_size(
|
||||||
rect.min,
|
rect.min,
|
||||||
|
|
@ -605,7 +1294,7 @@ impl PaneRenderer for TimelinePane {
|
||||||
|
|
||||||
// Render layer header column with clipping
|
// Render layer header column with clipping
|
||||||
ui.set_clip_rect(layer_headers_rect.intersect(original_clip_rect));
|
ui.set_clip_rect(layer_headers_rect.intersect(original_clip_rect));
|
||||||
self.render_layer_headers(ui, layer_headers_rect, shared.theme);
|
self.render_layer_headers(ui, layer_headers_rect, shared.theme, document, shared.active_layer_id);
|
||||||
|
|
||||||
// Render time ruler (clip to ruler rect)
|
// Render time ruler (clip to ruler rect)
|
||||||
ui.set_clip_rect(ruler_rect.intersect(original_clip_rect));
|
ui.set_clip_rect(ruler_rect.intersect(original_clip_rect));
|
||||||
|
|
@ -613,7 +1302,7 @@ impl PaneRenderer for TimelinePane {
|
||||||
|
|
||||||
// Render layer rows with clipping
|
// Render layer rows with clipping
|
||||||
ui.set_clip_rect(content_rect.intersect(original_clip_rect));
|
ui.set_clip_rect(content_rect.intersect(original_clip_rect));
|
||||||
self.render_layers(ui, content_rect, shared.theme);
|
self.render_layers(ui, content_rect, shared.theme, document, shared.active_layer_id, shared.selection);
|
||||||
|
|
||||||
// Render playhead on top (clip to timeline area)
|
// Render playhead on top (clip to timeline area)
|
||||||
ui.set_clip_rect(timeline_rect.intersect(original_clip_rect));
|
ui.set_clip_rect(timeline_rect.intersect(original_clip_rect));
|
||||||
|
|
@ -623,7 +1312,18 @@ impl PaneRenderer for TimelinePane {
|
||||||
ui.set_clip_rect(original_clip_rect);
|
ui.set_clip_rect(original_clip_rect);
|
||||||
|
|
||||||
// Handle input (use full rect including header column)
|
// Handle input (use full rect including header column)
|
||||||
self.handle_input(ui, rect, ruler_rect, content_rect);
|
self.handle_input(
|
||||||
|
ui,
|
||||||
|
rect,
|
||||||
|
ruler_rect,
|
||||||
|
content_rect,
|
||||||
|
layer_headers_rect,
|
||||||
|
layer_count,
|
||||||
|
document,
|
||||||
|
shared.active_layer_id,
|
||||||
|
shared.selection,
|
||||||
|
shared.pending_actions,
|
||||||
|
);
|
||||||
|
|
||||||
// Register handler for pending view actions (two-phase dispatch)
|
// Register handler for pending view actions (two-phase dispatch)
|
||||||
// Priority: Mouse-over (0-99) > Fallback Timeline(1001)
|
// Priority: Mouse-over (0-99) > Fallback Timeline(1001)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue