Group shapes

This commit is contained in:
Skyler Lehmkuhl 2026-02-20 11:13:56 -05:00
parent 7e2f63b62d
commit 3ba6dcb3d2
24 changed files with 2092 additions and 2176 deletions

View File

@ -1,50 +1,35 @@
//! Add shape action
//!
//! Handles adding a new shape and object to a vector layer.
//! Handles adding a new shape to a vector layer's keyframe.
use crate::action::Action;
use crate::document::Document;
use crate::layer::AnyLayer;
use crate::object::ShapeInstance;
use crate::shape::Shape;
use uuid::Uuid;
/// Action that adds a shape and object to a vector layer
///
/// This action creates both a Shape (the path/geometry) and an ShapeInstance
/// (the instance with transform). Both are added to the layer.
/// Action that adds a shape to a vector layer's keyframe
pub struct AddShapeAction {
/// Layer ID to add the shape to
layer_id: Uuid,
/// The shape to add (contains path and styling)
/// The shape to add (contains geometry, styling, transform, opacity)
shape: Shape,
/// The object to add (references the shape with transform)
object: ShapeInstance,
/// Time of the keyframe to add to
time: f64,
/// ID of the created shape (set after execution)
created_shape_id: Option<Uuid>,
/// ID of the created object (set after execution)
created_object_id: Option<Uuid>,
}
impl AddShapeAction {
/// Create a new add shape action
///
/// # Arguments
///
/// * `layer_id` - The layer to add the shape to
/// * `shape` - The shape to add
/// * `object` - The object instance referencing the shape
pub fn new(layer_id: Uuid, shape: Shape, object: ShapeInstance) -> Self {
pub fn new(layer_id: Uuid, shape: Shape, time: f64) -> Self {
Self {
layer_id,
shape,
object,
time,
created_shape_id: None,
created_object_id: None,
}
}
}
@ -57,34 +42,25 @@ impl Action for AddShapeAction {
};
if let AnyLayer::Vector(vector_layer) = layer {
// Add shape and object to the layer
let shape_id = vector_layer.add_shape_internal(self.shape.clone());
let object_id = vector_layer.add_object_internal(self.object.clone());
// Store the IDs for rollback
let shape_id = self.shape.id;
vector_layer.add_shape_to_keyframe(self.shape.clone(), self.time);
self.created_shape_id = Some(shape_id);
self.created_object_id = Some(object_id);
}
Ok(())
}
fn rollback(&mut self, document: &mut Document) -> Result<(), String> {
// 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) = self.created_shape_id {
let layer = match document.get_layer_mut(&self.layer_id) {
Some(l) => l,
None => return Ok(()),
};
if let AnyLayer::Vector(vector_layer) = layer {
// Remove in reverse order: object first, then shape
vector_layer.remove_object_internal(&object_id);
vector_layer.remove_shape_internal(&shape_id);
vector_layer.remove_shape_from_keyframe(&shape_id, self.time);
}
// Clear the stored IDs
self.created_shape_id = None;
self.created_object_id = None;
}
Ok(())
}
@ -99,33 +75,28 @@ mod tests {
use super::*;
use crate::layer::VectorLayer;
use crate::shape::ShapeColor;
use vello::kurbo::{Circle, Rect, Shape as KurboShape};
use vello::kurbo::{Rect, Shape as KurboShape};
#[test]
fn test_add_shape_action_rectangle() {
// Create a document with a vector layer
let mut document = Document::new("Test");
let vector_layer = VectorLayer::new("Layer 1");
let layer_id = document.root.add_child(AnyLayer::Vector(vector_layer));
// Create a rectangle shape
let rect = Rect::new(0.0, 0.0, 100.0, 50.0);
let path = rect.to_path(0.1);
let shape = Shape::new(path).with_fill(ShapeColor::rgb(255, 0, 0));
let object = ShapeInstance::new(shape.id).with_position(50.0, 50.0);
let shape = Shape::new(path)
.with_fill(ShapeColor::rgb(255, 0, 0))
.with_position(50.0, 50.0);
// Create and execute action
let mut action = AddShapeAction::new(layer_id, shape, object);
let mut action = AddShapeAction::new(layer_id, shape, 0.0);
action.execute(&mut document).unwrap();
// Verify shape and object were added
if let Some(AnyLayer::Vector(layer)) = document.get_layer(&layer_id) {
assert_eq!(layer.shapes.len(), 1);
assert_eq!(layer.shape_instances.len(), 1);
let added_object = &layer.shape_instances[0];
assert_eq!(added_object.transform.x, 50.0);
assert_eq!(added_object.transform.y, 50.0);
let shapes = layer.shapes_at_time(0.0);
assert_eq!(shapes.len(), 1);
assert_eq!(shapes[0].transform.x, 50.0);
assert_eq!(shapes[0].transform.y, 50.0);
} else {
panic!("Layer not found or not a vector layer");
}
@ -133,91 +104,8 @@ mod tests {
// Rollback
action.rollback(&mut document).unwrap();
// Verify shape and object were removed
if let Some(AnyLayer::Vector(layer)) = document.get_layer(&layer_id) {
assert_eq!(layer.shapes.len(), 0);
assert_eq!(layer.shape_instances.len(), 0);
}
}
#[test]
fn test_add_shape_action_circle() {
let mut document = Document::new("Test");
let vector_layer = VectorLayer::new("Layer 1");
let layer_id = document.root.add_child(AnyLayer::Vector(vector_layer));
// Create a circle shape
let circle = Circle::new((50.0, 50.0), 25.0);
let path = circle.to_path(0.1);
let shape = Shape::new(path)
.with_fill(ShapeColor::rgb(0, 255, 0));
let object = ShapeInstance::new(shape.id);
let mut action = AddShapeAction::new(layer_id, shape, object);
// Test description
assert_eq!(action.description(), "Add shape");
// Execute
action.execute(&mut document).unwrap();
if let Some(AnyLayer::Vector(layer)) = document.get_layer(&layer_id) {
assert_eq!(layer.shapes.len(), 1);
assert_eq!(layer.shape_instances.len(), 1);
}
}
#[test]
fn test_add_shape_action_multiple_execute() {
let mut document = Document::new("Test");
let vector_layer = VectorLayer::new("Layer 1");
let layer_id = document.root.add_child(AnyLayer::Vector(vector_layer));
let rect = Rect::new(0.0, 0.0, 50.0, 50.0);
let path = rect.to_path(0.1);
let shape = Shape::new(path);
let object = ShapeInstance::new(shape.id);
let mut action = AddShapeAction::new(layer_id, shape, object);
// Execute twice - shapes are stored in HashMap (keyed by ID, so same shape overwrites)
// while shape_instances are stored in Vec (so duplicates accumulate)
action.execute(&mut document).unwrap();
action.execute(&mut document).unwrap();
if let Some(AnyLayer::Vector(layer)) = document.get_layer(&layer_id) {
// Shapes use HashMap keyed by shape.id, so same shape overwrites = 1
// Shape instances use Vec, so duplicates accumulate = 2
assert_eq!(layer.shapes.len(), 1);
assert_eq!(layer.shape_instances.len(), 2);
}
}
#[test]
fn test_add_multiple_different_shapes() {
let mut document = Document::new("Test");
let vector_layer = VectorLayer::new("Layer 1");
let layer_id = document.root.add_child(AnyLayer::Vector(vector_layer));
// Create two different shapes
let rect1 = Rect::new(0.0, 0.0, 50.0, 50.0);
let shape1 = Shape::new(rect1.to_path(0.1));
let object1 = ShapeInstance::new(shape1.id);
let rect2 = Rect::new(100.0, 100.0, 150.0, 150.0);
let shape2 = Shape::new(rect2.to_path(0.1));
let object2 = ShapeInstance::new(shape2.id);
let mut action1 = AddShapeAction::new(layer_id, shape1, object1);
let mut action2 = AddShapeAction::new(layer_id, shape2, object2);
action1.execute(&mut document).unwrap();
action2.execute(&mut document).unwrap();
if let Some(AnyLayer::Vector(layer)) = document.get_layer(&layer_id) {
// Two different shapes = 2 entries in HashMap
assert_eq!(layer.shapes.len(), 2);
assert_eq!(layer.shape_instances.len(), 2);
assert_eq!(layer.shapes_at_time(0.0).len(), 0);
}
}
}

View File

@ -0,0 +1,412 @@
//! Group action
//!
//! Groups selected shapes and/or clip instances into a new VectorClip
//! with a ClipInstance placed on the layer. Supports grouping shapes,
//! existing clip instances (groups), or a mix of both.
use crate::action::Action;
use crate::animation::{AnimationCurve, AnimationTarget, Keyframe, TransformProperty};
use crate::clip::{ClipInstance, VectorClip};
use crate::document::Document;
use crate::layer::{AnyLayer, VectorLayer};
use crate::shape::Shape;
use uuid::Uuid;
use vello::kurbo::{Rect, Shape as KurboShape};
/// Action that groups selected shapes and/or clip instances into a VectorClip
pub struct GroupAction {
/// Layer containing the items to group
layer_id: Uuid,
/// Time of the keyframe to operate on (for shape lookup)
time: f64,
/// Shape IDs to include in the group
shape_ids: Vec<Uuid>,
/// Clip instance IDs to include in the group
clip_instance_ids: Vec<Uuid>,
/// Pre-generated clip instance ID for the new group (so caller can update selection)
instance_id: Uuid,
/// Created clip ID (for rollback)
created_clip_id: Option<Uuid>,
/// Shapes removed from the keyframe (for rollback)
removed_shapes: Vec<Shape>,
/// Clip instances removed from the layer (for rollback, preserving original order)
removed_clip_instances: Vec<ClipInstance>,
}
impl GroupAction {
pub fn new(
layer_id: Uuid,
time: f64,
shape_ids: Vec<Uuid>,
clip_instance_ids: Vec<Uuid>,
instance_id: Uuid,
) -> Self {
Self {
layer_id,
time,
shape_ids,
clip_instance_ids,
instance_id,
created_clip_id: None,
removed_shapes: Vec::new(),
removed_clip_instances: Vec::new(),
}
}
}
impl Action for GroupAction {
fn execute(&mut self, document: &mut Document) -> Result<(), String> {
// --- Phase 1: Collect items and compute bounding box ---
let layer = document
.get_layer(&self.layer_id)
.ok_or_else(|| format!("Layer {} not found", self.layer_id))?;
let vl = match layer {
AnyLayer::Vector(vl) => vl,
_ => return Err("Group is only supported on vector layers".to_string()),
};
// Collect shapes
let shapes_at_time = vl.shapes_at_time(self.time);
let mut group_shapes: Vec<Shape> = Vec::new();
for id in &self.shape_ids {
if let Some(shape) = shapes_at_time.iter().find(|s| &s.id == id) {
group_shapes.push(shape.clone());
}
}
// Collect clip instances
let mut group_clip_instances: Vec<ClipInstance> = Vec::new();
for id in &self.clip_instance_ids {
if let Some(ci) = vl.clip_instances.iter().find(|ci| &ci.id == id) {
group_clip_instances.push(ci.clone());
}
}
let total_items = group_shapes.len() + group_clip_instances.len();
if total_items < 2 {
return Err("Need at least 2 items to group".to_string());
}
// Compute combined bounding box in parent (layer) space
let mut combined_bbox: Option<Rect> = None;
// Shape bounding boxes
for shape in &group_shapes {
let local_bbox = shape.path().bounding_box();
let transform = shape.transform.to_affine();
let transformed_bbox = transform.transform_rect_bbox(local_bbox);
combined_bbox = Some(match combined_bbox {
Some(existing) => existing.union(transformed_bbox),
None => transformed_bbox,
});
}
// Clip instance bounding boxes
for ci in &group_clip_instances {
let content_bounds = if let Some(vector_clip) = document.get_vector_clip(&ci.clip_id) {
let clip_time = ((self.time - ci.timeline_start) * ci.playback_speed) + ci.trim_start;
vector_clip.calculate_content_bounds(document, clip_time)
} else if let Some(video_clip) = document.get_video_clip(&ci.clip_id) {
Rect::new(0.0, 0.0, video_clip.width, video_clip.height)
} else {
continue;
};
let ci_transform = ci.transform.to_affine();
let transformed_bbox = ci_transform.transform_rect_bbox(content_bounds);
combined_bbox = Some(match combined_bbox {
Some(existing) => existing.union(transformed_bbox),
None => transformed_bbox,
});
}
let bbox = combined_bbox.ok_or("Could not compute bounding box")?;
let center_x = (bbox.x0 + bbox.x1) / 2.0;
let center_y = (bbox.y0 + bbox.y1) / 2.0;
// --- Phase 2: Build the VectorClip ---
// Offset shapes so positions are relative to the group center
let mut clip_shapes: Vec<Shape> = group_shapes.clone();
for shape in &mut clip_shapes {
shape.transform.x -= center_x;
shape.transform.y -= center_y;
}
// Offset clip instances similarly
let mut clip_instances_inside: Vec<ClipInstance> = group_clip_instances.clone();
for ci in &mut clip_instances_inside {
ci.transform.x -= center_x;
ci.transform.y -= center_y;
}
// Create VectorClip — groups are static (one frame), not time-based clips
let frame_duration = 1.0 / document.framerate;
let mut clip = VectorClip::new("Group", bbox.width(), bbox.height(), frame_duration);
clip.is_group = true;
let clip_id = clip.id;
let mut inner_layer = VectorLayer::new("Layer 1");
for shape in clip_shapes {
inner_layer.add_shape_to_keyframe(shape, 0.0);
}
for ci in clip_instances_inside {
inner_layer.clip_instances.push(ci);
}
clip.layers.add_root(AnyLayer::Vector(inner_layer));
// Add clip to document library
document.add_vector_clip(clip);
self.created_clip_id = Some(clip_id);
// --- Phase 3: Remove originals from the layer ---
let layer = document.get_layer_mut(&self.layer_id).unwrap();
let vl = match layer {
AnyLayer::Vector(vl) => vl,
_ => unreachable!(),
};
// Remove shapes
self.removed_shapes.clear();
for id in &self.shape_ids {
if let Some(shape) = vl.remove_shape_from_keyframe(id, self.time) {
self.removed_shapes.push(shape);
}
}
// Remove clip instances (preserve order for rollback)
self.removed_clip_instances.clear();
for id in &self.clip_instance_ids {
if let Some(pos) = vl.clip_instances.iter().position(|ci| &ci.id == id) {
self.removed_clip_instances.push(vl.clip_instances.remove(pos));
}
}
// --- Phase 4: Place the new group ClipInstance ---
let instance = ClipInstance::with_id(self.instance_id, clip_id)
.with_position(center_x, center_y)
.with_name("Group");
vl.clip_instances.push(instance);
// Register the group in the current keyframe's clip_instance_ids
if let Some(kf) = vl.keyframe_at_mut(self.time) {
if !kf.clip_instance_ids.contains(&self.instance_id) {
kf.clip_instance_ids.push(self.instance_id);
}
}
// --- Phase 5: Create default animation curves with initial keyframe ---
let props_and_values = [
(TransformProperty::X, center_x),
(TransformProperty::Y, center_y),
(TransformProperty::Rotation, 0.0),
(TransformProperty::ScaleX, 1.0),
(TransformProperty::ScaleY, 1.0),
(TransformProperty::SkewX, 0.0),
(TransformProperty::SkewY, 0.0),
(TransformProperty::Opacity, 1.0),
];
for (prop, value) in props_and_values {
let target = AnimationTarget::Object {
id: self.instance_id,
property: prop,
};
let mut curve = AnimationCurve::new(target.clone(), value);
curve.set_keyframe(Keyframe::linear(0.0, value));
vl.layer.animation_data.set_curve(curve);
}
Ok(())
}
fn rollback(&mut self, document: &mut Document) -> Result<(), String> {
let layer = document
.get_layer_mut(&self.layer_id)
.ok_or_else(|| format!("Layer {} not found", self.layer_id))?;
if let AnyLayer::Vector(vl) = layer {
// Remove animation curves for the group's clip instance
for prop in &[
TransformProperty::X, TransformProperty::Y,
TransformProperty::Rotation,
TransformProperty::ScaleX, TransformProperty::ScaleY,
TransformProperty::SkewX, TransformProperty::SkewY,
TransformProperty::Opacity,
] {
let target = AnimationTarget::Object {
id: self.instance_id,
property: *prop,
};
vl.layer.animation_data.remove_curve(&target);
}
// Remove the group's clip instance
vl.clip_instances.retain(|ci| ci.id != self.instance_id);
// Remove the group ID from the keyframe
if let Some(kf) = vl.keyframe_at_mut(self.time) {
kf.clip_instance_ids.retain(|id| id != &self.instance_id);
}
// Re-insert removed shapes
for shape in self.removed_shapes.drain(..) {
vl.add_shape_to_keyframe(shape, self.time);
}
// Re-insert removed clip instances
for ci in self.removed_clip_instances.drain(..) {
vl.clip_instances.push(ci);
}
}
// Remove the VectorClip from the document
if let Some(clip_id) = self.created_clip_id.take() {
document.remove_vector_clip(&clip_id);
}
Ok(())
}
fn description(&self) -> String {
let count = self.shape_ids.len() + self.clip_instance_ids.len();
format!("Group {} objects", count)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::shape::ShapeColor;
use vello::kurbo::{Circle, Shape as KurboShape};
#[test]
fn test_group_shapes() {
let mut document = Document::new("Test");
let mut layer = VectorLayer::new("Test Layer");
let circle1 = Circle::new((0.0, 0.0), 20.0);
let shape1 = Shape::new(circle1.to_path(0.1))
.with_fill(ShapeColor::rgb(255, 0, 0))
.with_position(50.0, 50.0);
let shape1_id = shape1.id;
let circle2 = Circle::new((0.0, 0.0), 20.0);
let shape2 = Shape::new(circle2.to_path(0.1))
.with_fill(ShapeColor::rgb(0, 255, 0))
.with_position(150.0, 50.0);
let shape2_id = shape2.id;
layer.add_shape_to_keyframe(shape1, 0.0);
layer.add_shape_to_keyframe(shape2, 0.0);
let layer_id = document.root_mut().add_child(AnyLayer::Vector(layer));
let instance_id = Uuid::new_v4();
let mut action = GroupAction::new(
layer_id, 0.0,
vec![shape1_id, shape2_id],
vec![],
instance_id,
);
action.execute(&mut document).unwrap();
// Shapes removed, clip instance added
if let Some(AnyLayer::Vector(vl)) = document.get_layer(&layer_id) {
assert_eq!(vl.shapes_at_time(0.0).len(), 0);
assert_eq!(vl.clip_instances.len(), 1);
assert_eq!(vl.clip_instances[0].id, instance_id);
}
assert_eq!(document.vector_clips.len(), 1);
// Rollback
action.rollback(&mut document).unwrap();
if let Some(AnyLayer::Vector(vl)) = document.get_layer(&layer_id) {
assert_eq!(vl.shapes_at_time(0.0).len(), 2);
assert_eq!(vl.clip_instances.len(), 0);
}
assert!(document.vector_clips.is_empty());
}
#[test]
fn test_group_mixed_shapes_and_clips() {
let mut document = Document::new("Test");
let mut layer = VectorLayer::new("Test Layer");
// Add a shape
let circle = Circle::new((0.0, 0.0), 20.0);
let shape = Shape::new(circle.to_path(0.1))
.with_fill(ShapeColor::rgb(255, 0, 0))
.with_position(50.0, 50.0);
let shape_id = shape.id;
layer.add_shape_to_keyframe(shape, 0.0);
// Add a clip instance (create a clip for it first)
let mut inner_clip = VectorClip::new("Inner", 40.0, 40.0, 1.0);
let inner_clip_id = inner_clip.id;
let mut inner_layer = VectorLayer::new("Inner Layer");
let inner_shape = Shape::new(Circle::new((20.0, 20.0), 15.0).to_path(0.1))
.with_fill(ShapeColor::rgb(0, 0, 255));
inner_layer.add_shape_to_keyframe(inner_shape, 0.0);
inner_clip.layers.add_root(AnyLayer::Vector(inner_layer));
document.add_vector_clip(inner_clip);
let ci = ClipInstance::new(inner_clip_id).with_position(150.0, 50.0);
let ci_id = ci.id;
layer.clip_instances.push(ci);
let layer_id = document.root_mut().add_child(AnyLayer::Vector(layer));
let instance_id = Uuid::new_v4();
let mut action = GroupAction::new(
layer_id, 0.0,
vec![shape_id],
vec![ci_id],
instance_id,
);
action.execute(&mut document).unwrap();
if let Some(AnyLayer::Vector(vl)) = document.get_layer(&layer_id) {
assert_eq!(vl.shapes_at_time(0.0).len(), 0);
// Only the new group instance remains (the inner clip instance was grouped)
assert_eq!(vl.clip_instances.len(), 1);
assert_eq!(vl.clip_instances[0].id, instance_id);
}
// Two vector clips: the inner one + the new group
assert_eq!(document.vector_clips.len(), 2);
// Rollback
action.rollback(&mut document).unwrap();
if let Some(AnyLayer::Vector(vl)) = document.get_layer(&layer_id) {
assert_eq!(vl.shapes_at_time(0.0).len(), 1);
assert_eq!(vl.clip_instances.len(), 1);
assert_eq!(vl.clip_instances[0].id, ci_id);
}
// Only the inner clip remains
assert_eq!(document.vector_clips.len(), 1);
}
#[test]
fn test_group_description() {
let action = GroupAction::new(
Uuid::new_v4(), 0.0,
vec![Uuid::new_v4(), Uuid::new_v4()],
vec![Uuid::new_v4()],
Uuid::new_v4(),
);
assert_eq!(action.description(), "Group 3 objects");
}
}

View File

@ -28,6 +28,8 @@ pub mod update_midi_notes;
pub mod loop_clip_instances;
pub mod remove_clip_instances;
pub mod remove_shapes;
pub mod set_keyframe;
pub mod group_shapes;
pub use add_clip_instance::AddClipInstanceAction;
pub use add_effect::AddEffectAction;
@ -54,3 +56,5 @@ pub use update_midi_notes::UpdateMidiNotesAction;
pub use loop_clip_instances::LoopClipInstancesAction;
pub use remove_clip_instances::RemoveClipInstancesAction;
pub use remove_shapes::RemoveShapesAction;
pub use set_keyframe::SetKeyframeAction;
pub use group_shapes::GroupAction;

View File

@ -20,6 +20,9 @@ pub struct ModifyShapePathAction {
/// Shape to modify
shape_id: Uuid,
/// Time of the keyframe containing the shape
time: f64,
/// The version index being modified (for shapes with multiple versions)
version_index: usize,
@ -32,17 +35,11 @@ pub struct ModifyShapePathAction {
impl ModifyShapePathAction {
/// Create a new action to modify a shape's path
///
/// # Arguments
///
/// * `layer_id` - The layer containing the shape
/// * `shape_id` - The shape to modify
/// * `version_index` - The version index to modify (usually 0)
/// * `new_path` - The new path to set
pub fn new(layer_id: Uuid, shape_id: Uuid, version_index: usize, new_path: BezPath) -> Self {
pub fn new(layer_id: Uuid, shape_id: Uuid, time: f64, version_index: usize, new_path: BezPath) -> Self {
Self {
layer_id,
shape_id,
time,
version_index,
new_path,
old_path: None,
@ -53,6 +50,7 @@ impl ModifyShapePathAction {
pub fn with_old_path(
layer_id: Uuid,
shape_id: Uuid,
time: f64,
version_index: usize,
old_path: BezPath,
new_path: BezPath,
@ -60,6 +58,7 @@ impl ModifyShapePathAction {
Self {
layer_id,
shape_id,
time,
version_index,
new_path,
old_path: Some(old_path),
@ -71,8 +70,7 @@ impl Action for ModifyShapePathAction {
fn execute(&mut self, document: &mut Document) -> Result<(), String> {
if let Some(layer) = document.get_layer_mut(&self.layer_id) {
if let AnyLayer::Vector(vector_layer) = layer {
if let Some(shape) = vector_layer.shapes.get_mut(&self.shape_id) {
// Check if version exists
if let Some(shape) = vector_layer.get_shape_in_keyframe_mut(&self.shape_id, self.time) {
if self.version_index >= shape.versions.len() {
return Err(format!(
"Version index {} out of bounds (shape has {} versions)",
@ -104,7 +102,7 @@ impl Action for ModifyShapePathAction {
if let Some(old_path) = &self.old_path {
if let Some(layer) = document.get_layer_mut(&self.layer_id) {
if let AnyLayer::Vector(vector_layer) = layer {
if let Some(shape) = vector_layer.shapes.get_mut(&self.shape_id) {
if let Some(shape) = vector_layer.get_shape_in_keyframe_mut(&self.shape_id, self.time) {
if self.version_index < shape.versions.len() {
shape.versions[self.version_index].path = old_path.clone();
return Ok(());
@ -130,6 +128,7 @@ mod tests {
use super::*;
use crate::layer::VectorLayer;
use crate::shape::Shape;
use vello::kurbo::Shape as KurboShape;
fn create_test_path() -> BezPath {
let mut path = BezPath::new();
@ -144,9 +143,9 @@ mod tests {
fn create_modified_path() -> BezPath {
let mut path = BezPath::new();
path.move_to((0.0, 0.0));
path.line_to((150.0, 0.0)); // Modified
path.line_to((150.0, 150.0)); // Modified
path.line_to((0.0, 150.0)); // Modified
path.line_to((150.0, 0.0));
path.line_to((150.0, 150.0));
path.line_to((0.0, 150.0));
path.close_path();
path
}
@ -158,13 +157,13 @@ mod tests {
let shape = Shape::new(create_test_path());
let shape_id = shape.id;
layer.shapes.insert(shape_id, shape);
layer.add_shape_to_keyframe(shape, 0.0);
let layer_id = document.root_mut().add_child(AnyLayer::Vector(layer));
// Verify initial path
if let Some(AnyLayer::Vector(vl)) = document.get_layer_mut(&layer_id) {
let shape = vl.shapes.get(&shape_id).unwrap();
if let Some(AnyLayer::Vector(vl)) = document.get_layer(&layer_id) {
let shape = vl.get_shape_in_keyframe(&shape_id, 0.0).unwrap();
let bbox = shape.versions[0].path.bounding_box();
assert_eq!(bbox.width(), 100.0);
assert_eq!(bbox.height(), 100.0);
@ -172,12 +171,12 @@ mod tests {
// Create and execute action
let new_path = create_modified_path();
let mut action = ModifyShapePathAction::new(layer_id, shape_id, 0, new_path);
let mut action = ModifyShapePathAction::new(layer_id, shape_id, 0.0, 0, new_path);
action.execute(&mut document).unwrap();
// Verify path changed
if let Some(AnyLayer::Vector(vl)) = document.get_layer_mut(&layer_id) {
let shape = vl.shapes.get(&shape_id).unwrap();
if let Some(AnyLayer::Vector(vl)) = document.get_layer(&layer_id) {
let shape = vl.get_shape_in_keyframe(&shape_id, 0.0).unwrap();
let bbox = shape.versions[0].path.bounding_box();
assert_eq!(bbox.width(), 150.0);
assert_eq!(bbox.height(), 150.0);
@ -187,8 +186,8 @@ mod tests {
action.rollback(&mut document).unwrap();
// Verify restored
if let Some(AnyLayer::Vector(vl)) = document.get_layer_mut(&layer_id) {
let shape = vl.shapes.get(&shape_id).unwrap();
if let Some(AnyLayer::Vector(vl)) = document.get_layer(&layer_id) {
let shape = vl.get_shape_in_keyframe(&shape_id, 0.0).unwrap();
let bbox = shape.versions[0].path.bounding_box();
assert_eq!(bbox.width(), 100.0);
assert_eq!(bbox.height(), 100.0);
@ -202,13 +201,12 @@ mod tests {
let shape = Shape::new(create_test_path());
let shape_id = shape.id;
layer.shapes.insert(shape_id, shape);
layer.add_shape_to_keyframe(shape, 0.0);
let layer_id = document.root_mut().add_child(AnyLayer::Vector(layer));
// Try to modify non-existent version
let new_path = create_modified_path();
let mut action = ModifyShapePathAction::new(layer_id, shape_id, 5, new_path);
let mut action = ModifyShapePathAction::new(layer_id, shape_id, 0.0, 5, new_path);
let result = action.execute(&mut document);
assert!(result.is_err());
@ -219,7 +217,7 @@ mod tests {
fn test_description() {
let layer_id = Uuid::new_v4();
let shape_id = Uuid::new_v4();
let action = ModifyShapePathAction::new(layer_id, shape_id, 0, create_test_path());
let action = ModifyShapePathAction::new(layer_id, shape_id, 0.0, 0, create_test_path());
assert_eq!(action.description(), "Modify shape path");
}
}

View File

@ -1,6 +1,6 @@
//! Move shape instances action
//! Move shapes action
//!
//! Handles moving one or more shape instances to new positions.
//! Handles moving one or more shapes to new positions within a keyframe.
use crate::action::Action;
use crate::document::Document;
@ -9,26 +9,20 @@ use std::collections::HashMap;
use uuid::Uuid;
use vello::kurbo::Point;
/// Action that moves shape instances to new positions
/// Action that moves shapes to new positions within a keyframe
pub struct MoveShapeInstancesAction {
/// Layer ID containing the shape instances
layer_id: Uuid,
/// Map of object IDs to their old and new positions
shape_instance_positions: HashMap<Uuid, (Point, Point)>, // (old_pos, new_pos)
time: f64,
/// Map of shape IDs to their old and new positions
shape_positions: HashMap<Uuid, (Point, Point)>,
}
impl MoveShapeInstancesAction {
/// Create a new move shape instances action
///
/// # Arguments
///
/// * `layer_id` - The layer containing the shape instances
/// * `shape_instance_positions` - Map of object IDs to (old_position, new_position)
pub fn new(layer_id: Uuid, shape_instance_positions: HashMap<Uuid, (Point, Point)>) -> Self {
pub fn new(layer_id: Uuid, time: f64, shape_positions: HashMap<Uuid, (Point, Point)>) -> Self {
Self {
layer_id,
shape_instance_positions,
time,
shape_positions,
}
}
}
@ -41,11 +35,11 @@ impl Action for MoveShapeInstancesAction {
};
if let AnyLayer::Vector(vector_layer) = layer {
for (shape_instance_id, (_old, new)) in &self.shape_instance_positions {
vector_layer.modify_object_internal(shape_instance_id, |obj| {
obj.transform.x = new.x;
obj.transform.y = new.y;
});
for (shape_id, (_old, new)) in &self.shape_positions {
if let Some(shape) = vector_layer.get_shape_in_keyframe_mut(shape_id, self.time) {
shape.transform.x = new.x;
shape.transform.y = new.y;
}
}
}
Ok(())
@ -58,76 +52,22 @@ impl Action for MoveShapeInstancesAction {
};
if let AnyLayer::Vector(vector_layer) = layer {
for (shape_instance_id, (old, _new)) in &self.shape_instance_positions {
vector_layer.modify_object_internal(shape_instance_id, |obj| {
obj.transform.x = old.x;
obj.transform.y = old.y;
});
for (shape_id, (old, _new)) in &self.shape_positions {
if let Some(shape) = vector_layer.get_shape_in_keyframe_mut(shape_id, self.time) {
shape.transform.x = old.x;
shape.transform.y = old.y;
}
}
}
Ok(())
}
fn description(&self) -> String {
let count = self.shape_instance_positions.len();
let count = self.shape_positions.len();
if count == 1 {
"Move shape instance".to_string()
"Move shape".to_string()
} else {
format!("Move {} shape instances", count)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::layer::VectorLayer;
use crate::object::ShapeInstance;
use crate::shape::Shape;
use vello::kurbo::{Circle, Shape as KurboShape};
#[test]
fn test_move_shape_instances_action() {
// Create a document with a test object
let mut document = Document::new("Test");
let circle = Circle::new((100.0, 100.0), 50.0);
let path = circle.to_path(0.1);
let shape = Shape::new(path);
let object = ShapeInstance::new(shape.id).with_position(50.0, 50.0);
let mut vector_layer = VectorLayer::new("Layer 1");
vector_layer.add_shape(shape);
let shape_instance_id = vector_layer.add_object(object);
let layer_id = document.root.add_child(AnyLayer::Vector(vector_layer));
// Create move action
let mut positions = HashMap::new();
positions.insert(
shape_instance_id,
(Point::new(50.0, 50.0), Point::new(150.0, 200.0))
);
let mut action = MoveShapeInstancesAction::new(layer_id, positions);
// Execute
action.execute(&mut document).unwrap();
// Verify position changed
if let Some(AnyLayer::Vector(layer)) = document.get_layer(&layer_id) {
let obj = layer.get_object(&shape_instance_id).unwrap();
assert_eq!(obj.transform.x, 150.0);
assert_eq!(obj.transform.y, 200.0);
}
// Rollback
action.rollback(&mut document).unwrap();
// Verify position restored
if let Some(AnyLayer::Vector(layer)) = document.get_layer(&layer_id) {
let obj = layer.get_object(&shape_instance_id).unwrap();
assert_eq!(obj.transform.x, 50.0);
assert_eq!(obj.transform.y, 50.0);
format!("Move {} shapes", count)
}
}
}

View File

@ -8,7 +8,6 @@ use crate::curve_segment::CurveSegment;
use crate::document::Document;
use crate::gap_handling::GapHandlingMode;
use crate::layer::AnyLayer;
use crate::object::ShapeInstance;
use crate::planar_graph::PlanarGraph;
use crate::shape::ShapeColor;
use uuid::Uuid;
@ -19,6 +18,9 @@ pub struct PaintBucketAction {
/// Layer ID to add the filled shape to
layer_id: Uuid,
/// Time of the keyframe to operate on
time: f64,
/// Click point where fill was initiated
click_point: Point,
@ -33,23 +35,13 @@ pub struct PaintBucketAction {
/// ID of the created shape (set after execution)
created_shape_id: Option<Uuid>,
/// ID of the created shape instance (set after execution)
created_shape_instance_id: Option<Uuid>,
}
impl PaintBucketAction {
/// Create a new paint bucket action
///
/// # Arguments
///
/// * `layer_id` - The layer to add the filled shape to
/// * `click_point` - Point where the user clicked to initiate fill
/// * `fill_color` - Color to fill the region with
/// * `tolerance` - Gap tolerance in pixels (default: 2.0)
/// * `gap_mode` - Gap handling mode (SnapAndSplit or BridgeSegment)
pub fn new(
layer_id: Uuid,
time: f64,
click_point: Point,
fill_color: ShapeColor,
tolerance: f64,
@ -57,12 +49,12 @@ impl PaintBucketAction {
) -> Self {
Self {
layer_id,
time,
click_point,
fill_color,
_tolerance: tolerance,
_gap_mode: gap_mode,
created_shape_id: None,
created_shape_instance_id: None,
}
}
}
@ -72,60 +64,46 @@ impl Action for PaintBucketAction {
println!("=== PaintBucketAction::execute ===");
// Optimization: Check if we're clicking on an existing shape first
// This is much faster than building a planar graph
if let Some(AnyLayer::Vector(vector_layer)) = document.get_layer_mut(&self.layer_id) {
// Iterate through shape instances in reverse order (topmost first)
for shape_instance in vector_layer.shape_instances.iter().rev() {
// Find the corresponding shape (O(1) HashMap lookup)
if let Some(shape) = vector_layer.shapes.get(&shape_instance.shape_id) {
// Skip shapes without fill color (e.g., lines with only stroke)
// Iterate through shapes in the keyframe in reverse order (topmost first)
let shapes = vector_layer.shapes_at_time(self.time);
for shape in shapes.iter().rev() {
// Skip shapes without fill color
if shape.fill_color.is_none() {
continue;
}
// Check if the path is closed - winding number only makes sense for closed paths
use vello::kurbo::PathEl;
let is_closed = shape.path().elements().iter().any(|el| matches!(el, PathEl::ClosePath));
if !is_closed {
// Skip non-closed paths - can't use winding number test
continue;
}
// Apply the shape instance's transform to get the transformed path
let transform_affine = shape_instance.transform.to_affine();
// Transform the click point to shape's local coordinates (inverse transform)
// Apply the shape's transform
let transform_affine = shape.transform.to_affine();
let inverse_transform = transform_affine.inverse();
let local_point = inverse_transform * self.click_point;
// Test if the local point is inside the shape using winding number
use vello::kurbo::Shape as KurboShape;
let winding = shape.path().winding(local_point);
if winding != 0 {
// Point is inside this shape! Just change its fill color
println!("Clicked on existing shape, changing fill color");
// Store the shape ID before the immutable borrow ends
let shape_id = shape.id;
// Find mutable reference to the shape and update its fill (O(1) HashMap lookup)
if let Some(shape_mut) = vector_layer.shapes.get_mut(&shape_id) {
// Now get mutable access to change the fill
if let Some(shape_mut) = vector_layer.get_shape_in_keyframe_mut(&shape_id, self.time) {
shape_mut.fill_color = Some(self.fill_color);
println!("Updated shape fill color");
}
return Ok(()); // Done! No need to create a new shape
}
return Ok(());
}
}
println!("No existing shape at click point, creating new fill region");
}
// Step 1: Extract curves from all shapes (rectangles, ellipses, paths, etc.)
let all_curves = extract_curves_from_all_shapes(document, &self.layer_id);
// Step 1: Extract curves from all shapes in the keyframe
let all_curves = extract_curves_from_keyframe(document, &self.layer_id, self.time);
println!("Extracted {} curves from all shapes", all_curves.len());
@ -138,62 +116,36 @@ impl Action for PaintBucketAction {
println!("Building planar graph...");
let graph = PlanarGraph::build(&all_curves);
// Step 3: Trace the face containing the click point (optimized - only traces one face)
// Step 3: Trace the face containing the click point
println!("Tracing face from click point {:?}...", self.click_point);
if let Some(face) = graph.trace_face_from_point(self.click_point) {
println!("Successfully traced face containing click point!");
// Build the face boundary using actual curve segments
let face_path = graph.build_face_path(&face);
println!("DEBUG: Creating face shape with fill color: r={}, g={}, b={}, a={}",
self.fill_color.r, self.fill_color.g, self.fill_color.b, self.fill_color.a);
let face_shape = crate::shape::Shape::new(face_path)
.with_fill(self.fill_color); // Use the requested fill color
.with_fill(self.fill_color);
println!("DEBUG: Face shape created with fill_color: {:?}", face_shape.fill_color);
let face_shape_instance = ShapeInstance::new(face_shape.id);
// Store the created IDs for rollback
self.created_shape_id = Some(face_shape.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) {
let shape_id_for_debug = face_shape.id;
vector_layer.add_shape_internal(face_shape);
vector_layer.add_object_internal(face_shape_instance);
println!("DEBUG: Added filled shape");
// Verify the shape still has the fill color after being added (O(1) HashMap lookup)
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);
}
vector_layer.add_shape_to_keyframe(face_shape, self.time);
println!("DEBUG: Added filled shape to keyframe");
}
} else {
println!("Click point is not inside any face!");
}
println!("=== Paint Bucket Complete: Face filled with curves ===");
println!("=== Paint Bucket Complete ===");
Ok(())
}
fn rollback(&mut self, document: &mut Document) -> Result<(), String> {
// Remove the created shape and object if they exist
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) {
Some(l) => l,
None => return Ok(()),
};
if let AnyLayer::Vector(vector_layer) = layer {
vector_layer.remove_object_internal(&object_id);
vector_layer.remove_shape_internal(&shape_id);
if let Some(shape_id) = self.created_shape_id {
if let Some(AnyLayer::Vector(vector_layer)) = document.get_layer_mut(&self.layer_id) {
vector_layer.remove_shape_from_keyframe(&shape_id, self.time);
}
self.created_shape_id = None;
self.created_shape_instance_id = None;
}
Ok(())
}
@ -203,54 +155,39 @@ impl Action for PaintBucketAction {
}
}
/// Extract curves from all shapes in the layer
///
/// Includes rectangles, ellipses, paths, and even previous paint bucket fills.
/// The planar graph builder will handle deduplication of overlapping edges.
fn extract_curves_from_all_shapes(
/// Extract curves from all shapes in the keyframe at the given time
fn extract_curves_from_keyframe(
document: &Document,
layer_id: &Uuid,
time: f64,
) -> Vec<CurveSegment> {
let mut all_curves = Vec::new();
// Get the specified layer
let layer = match document.get_layer(layer_id) {
Some(l) => l,
None => return all_curves,
};
// Extract curves only from this vector layer
if let AnyLayer::Vector(vector_layer) = layer {
println!("Extracting curves from {} objects in layer", vector_layer.shape_instances.len());
// Extract curves from each object (which applies transforms to shapes)
for (obj_idx, object) in vector_layer.shape_instances.iter().enumerate() {
// Find the shape for this object (O(1) HashMap lookup)
let shape = match vector_layer.shapes.get(&object.shape_id) {
Some(s) => s,
None => continue,
};
let shapes = vector_layer.shapes_at_time(time);
println!("Extracting curves from {} shapes in keyframe", shapes.len());
// Include all shapes - planar graph will handle deduplication
// (Rectangles, ellipses, paths, and even previous paint bucket fills)
// Get the transform matrix from the object
let transform_affine = object.transform.to_affine();
for (shape_idx, shape) in shapes.iter().enumerate() {
let transform_affine = shape.transform.to_affine();
let path = shape.path();
let mut current_point = Point::ZERO;
let mut subpath_start = Point::ZERO; // Track start of current subpath
let mut subpath_start = Point::ZERO;
let mut segment_index = 0;
let mut curves_in_shape = 0;
for element in path.elements() {
// Extract curve segment from path element
if let Some(mut segment) = CurveSegment::from_path_element(
shape.id.as_u128() as usize,
segment_index,
element,
current_point,
) {
// Apply the object's transform to all control points
for control_point in &mut segment.control_points {
*control_point = transform_affine * (*control_point);
}
@ -260,24 +197,21 @@ fn extract_curves_from_all_shapes(
curves_in_shape += 1;
}
// Update current point for next iteration (keep in local space)
match element {
vello::kurbo::PathEl::MoveTo(p) => {
current_point = *p;
subpath_start = *p; // Mark start of new subpath
subpath_start = *p;
}
vello::kurbo::PathEl::LineTo(p) => current_point = *p,
vello::kurbo::PathEl::QuadTo(_, p) => current_point = *p,
vello::kurbo::PathEl::CurveTo(_, _, p) => current_point = *p,
vello::kurbo::PathEl::ClosePath => {
// Create closing segment from current_point back to subpath_start
if let Some(mut segment) = CurveSegment::from_path_element(
shape.id.as_u128() as usize,
segment_index,
&vello::kurbo::PathEl::LineTo(subpath_start),
current_point,
) {
// Apply transform
for control_point in &mut segment.control_points {
*control_point = transform_affine * (*control_point);
}
@ -286,12 +220,12 @@ fn extract_curves_from_all_shapes(
segment_index += 1;
curves_in_shape += 1;
}
current_point = subpath_start; // ClosePath moves back to start
current_point = subpath_start;
}
}
}
println!(" Object {}: Extracted {} curves from shape", obj_idx, curves_in_shape);
println!(" Shape {}: Extracted {} curves", shape_idx, curves_in_shape);
}
}
@ -307,57 +241,46 @@ mod tests {
#[test]
fn test_paint_bucket_action_basic() {
// Create a document with a vector layer
let mut document = Document::new("Test");
let vector_layer = VectorLayer::new("Layer 1");
let layer_id = document.root.add_child(AnyLayer::Vector(vector_layer));
let mut layer = VectorLayer::new("Layer 1");
// Create a simple rectangle shape (boundary for fill)
let rect = Rect::new(0.0, 0.0, 100.0, 100.0);
let path = rect.to_path(0.1);
let shape = Shape::new(path);
let shape_instance = ShapeInstance::new(shape.id);
// Add the boundary shape
if let Some(AnyLayer::Vector(layer)) = document.get_layer_mut(&layer_id) {
layer.add_shape_internal(shape);
layer.add_object_internal(shape_instance);
}
layer.add_shape_to_keyframe(shape, 0.0);
let layer_id = document.root_mut().add_child(AnyLayer::Vector(layer));
// Create and execute paint bucket action
let mut action = PaintBucketAction::new(
layer_id,
Point::new(50.0, 50.0), // Click in center
ShapeColor::rgb(255, 0, 0), // Red fill
0.0,
Point::new(50.0, 50.0),
ShapeColor::rgb(255, 0, 0),
2.0,
GapHandlingMode::BridgeSegment,
);
action.execute(&mut document).unwrap();
// Verify a filled shape was created
// Verify a filled shape was created (or existing shape was recolored)
if let Some(AnyLayer::Vector(layer)) = document.get_layer(&layer_id) {
// Should have original shape + filled shape
assert!(layer.shapes.len() >= 1);
assert!(layer.shape_instances.len() >= 1);
assert!(layer.shapes_at_time(0.0).len() >= 1);
} else {
panic!("Layer not found or not a vector layer");
}
// Test rollback
action.rollback(&mut document).unwrap();
if let Some(AnyLayer::Vector(layer)) = document.get_layer(&layer_id) {
// Should only have original shape
assert_eq!(layer.shapes.len(), 1);
assert_eq!(layer.shape_instances.len(), 1);
}
}
#[test]
fn test_paint_bucket_action_description() {
let action = PaintBucketAction::new(
Uuid::new_v4(),
0.0,
Point::ZERO,
ShapeColor::rgb(0, 0, 255),
2.0,

View File

@ -1,37 +1,32 @@
//! Remove shapes action
//!
//! Handles removing shapes and shape instances from a vector layer (for cut/delete).
//! Handles removing shapes from a vector layer's keyframe (for cut/delete).
use crate::action::Action;
use crate::document::Document;
use crate::layer::AnyLayer;
use crate::object::ShapeInstance;
use crate::shape::Shape;
use uuid::Uuid;
/// Action that removes shapes and their instances from a vector layer
/// Action that removes shapes from a vector layer's keyframe
pub struct RemoveShapesAction {
/// Layer ID containing the shapes
layer_id: Uuid,
/// Shape IDs to remove
shape_ids: Vec<Uuid>,
/// Shape instance IDs to remove
instance_ids: Vec<Uuid>,
/// Time of the keyframe
time: f64,
/// Saved shapes for rollback
saved_shapes: Vec<(Uuid, Shape)>,
/// Saved instances for rollback
saved_instances: Vec<ShapeInstance>,
saved_shapes: Vec<Shape>,
}
impl RemoveShapesAction {
/// Create a new remove shapes action
pub fn new(layer_id: Uuid, shape_ids: Vec<Uuid>, instance_ids: Vec<Uuid>) -> Self {
pub fn new(layer_id: Uuid, shape_ids: Vec<Uuid>, time: f64) -> Self {
Self {
layer_id,
shape_ids,
instance_ids,
time,
saved_shapes: Vec::new(),
saved_instances: Vec::new(),
}
}
}
@ -39,7 +34,6 @@ impl RemoveShapesAction {
impl Action for RemoveShapesAction {
fn execute(&mut self, document: &mut Document) -> Result<(), String> {
self.saved_shapes.clear();
self.saved_instances.clear();
let layer = document
.get_layer_mut(&self.layer_id)
@ -50,21 +44,9 @@ impl Action for RemoveShapesAction {
_ => return Err("Not a vector layer".to_string()),
};
// Remove and save shape instances
let mut remaining_instances = Vec::new();
for inst in vector_layer.shape_instances.drain(..) {
if self.instance_ids.contains(&inst.id) {
self.saved_instances.push(inst);
} else {
remaining_instances.push(inst);
}
}
vector_layer.shape_instances = remaining_instances;
// Remove and save shape definitions
for shape_id in &self.shape_ids {
if let Some(shape) = vector_layer.shapes.remove(shape_id) {
self.saved_shapes.push((*shape_id, shape));
if let Some(shape) = vector_layer.remove_shape_from_keyframe(shape_id, self.time) {
self.saved_shapes.push(shape);
}
}
@ -81,21 +63,15 @@ impl Action for RemoveShapesAction {
_ => return Err("Not a vector layer".to_string()),
};
// Restore shapes
for (id, shape) in self.saved_shapes.drain(..) {
vector_layer.shapes.insert(id, shape);
}
// Restore instances
for inst in self.saved_instances.drain(..) {
vector_layer.shape_instances.push(inst);
for shape in self.saved_shapes.drain(..) {
vector_layer.add_shape_to_keyframe(shape, self.time);
}
Ok(())
}
fn description(&self) -> String {
let count = self.instance_ids.len();
let count = self.shape_ids.len();
if count == 1 {
"Delete shape".to_string()
} else {
@ -108,45 +84,35 @@ impl Action for RemoveShapesAction {
mod tests {
use super::*;
use crate::layer::VectorLayer;
use crate::object::ShapeInstance;
use crate::shape::Shape;
use vello::kurbo::BezPath;
#[test]
fn test_remove_shapes() {
let mut document = Document::new("Test");
let mut vector_layer = VectorLayer::new("Layer 1");
// Add a shape and instance
let mut path = BezPath::new();
path.move_to((0.0, 0.0));
path.line_to((100.0, 100.0));
let shape = Shape::new(path);
let shape_id = shape.id;
let instance = ShapeInstance::new(shape_id);
let instance_id = instance.id;
vector_layer.shapes.insert(shape_id, shape);
vector_layer.shape_instances.push(instance);
vector_layer.add_shape_to_keyframe(shape, 0.0);
let layer_id = document.root_mut().add_child(AnyLayer::Vector(vector_layer));
// Remove
let mut action = RemoveShapesAction::new(layer_id, vec![shape_id], vec![instance_id]);
let mut action = RemoveShapesAction::new(layer_id, vec![shape_id], 0.0);
action.execute(&mut document).unwrap();
if let Some(AnyLayer::Vector(vl)) = document.get_layer(&layer_id) {
assert!(vl.shapes.is_empty());
assert!(vl.shape_instances.is_empty());
assert!(vl.shapes_at_time(0.0).is_empty());
}
// Rollback
action.rollback(&mut document).unwrap();
if let Some(AnyLayer::Vector(vl)) = document.get_layer(&layer_id) {
assert_eq!(vl.shapes.len(), 1);
assert_eq!(vl.shape_instances.len(), 1);
assert_eq!(vl.shapes_at_time(0.0).len(), 1);
}
}
}

View File

@ -1,7 +1,8 @@
//! Set shape instance properties action
//!
//! Handles changing individual properties on shape instances (position, rotation, scale, etc.)
//! with undo/redo support.
//! Handles changing individual properties on shapes (position, rotation, scale, etc.)
//! with undo/redo support. In the keyframe model, these operate on Shape's transform
//! and opacity fields within the active keyframe.
use crate::action::Action;
use crate::document::Document;
@ -37,53 +38,65 @@ impl InstancePropertyChange {
}
}
/// Action that sets a property on one or more shape instances
/// Action that sets a property on one or more shapes in a keyframe
pub struct SetInstancePropertiesAction {
/// Layer containing the instances
/// Layer containing the shapes
layer_id: Uuid,
/// Instance IDs to modify and their old values
instance_changes: Vec<(Uuid, Option<f64>)>,
/// Time of the keyframe
time: f64,
/// Shape IDs to modify and their old values
shape_changes: Vec<(Uuid, Option<f64>)>,
/// Property to change
property: InstancePropertyChange,
}
impl SetInstancePropertiesAction {
/// Create a new action to set a property on a single instance
pub fn new(layer_id: Uuid, instance_id: Uuid, property: InstancePropertyChange) -> Self {
/// Create a new action to set a property on a single shape
pub fn new(layer_id: Uuid, time: f64, shape_id: Uuid, property: InstancePropertyChange) -> Self {
Self {
layer_id,
instance_changes: vec![(instance_id, None)],
time,
shape_changes: vec![(shape_id, None)],
property,
}
}
/// Create a new action to set a property on multiple instances
pub fn new_batch(layer_id: Uuid, instance_ids: Vec<Uuid>, property: InstancePropertyChange) -> Self {
/// Create a new action to set a property on multiple shapes
pub fn new_batch(layer_id: Uuid, time: f64, shape_ids: Vec<Uuid>, property: InstancePropertyChange) -> Self {
Self {
layer_id,
instance_changes: instance_ids.into_iter().map(|id| (id, None)).collect(),
time,
shape_changes: shape_ids.into_iter().map(|id| (id, None)).collect(),
property,
}
}
fn apply_to_instance(&self, document: &mut Document, instance_id: &Uuid, value: f64) {
if let Some(layer) = document.get_layer_mut(&self.layer_id) {
if let AnyLayer::Vector(vector_layer) = layer {
vector_layer.modify_object_internal(instance_id, |instance| {
match &self.property {
InstancePropertyChange::X(_) => instance.transform.x = value,
InstancePropertyChange::Y(_) => instance.transform.y = value,
InstancePropertyChange::Rotation(_) => instance.transform.rotation = value,
InstancePropertyChange::ScaleX(_) => instance.transform.scale_x = value,
InstancePropertyChange::ScaleY(_) => instance.transform.scale_y = value,
InstancePropertyChange::SkewX(_) => instance.transform.skew_x = value,
InstancePropertyChange::SkewY(_) => instance.transform.skew_y = value,
InstancePropertyChange::Opacity(_) => instance.opacity = value,
fn get_value_from_shape(shape: &crate::shape::Shape, property: &InstancePropertyChange) -> f64 {
match property {
InstancePropertyChange::X(_) => shape.transform.x,
InstancePropertyChange::Y(_) => shape.transform.y,
InstancePropertyChange::Rotation(_) => shape.transform.rotation,
InstancePropertyChange::ScaleX(_) => shape.transform.scale_x,
InstancePropertyChange::ScaleY(_) => shape.transform.scale_y,
InstancePropertyChange::SkewX(_) => shape.transform.skew_x,
InstancePropertyChange::SkewY(_) => shape.transform.skew_y,
InstancePropertyChange::Opacity(_) => shape.opacity,
}
});
}
fn set_value_on_shape(shape: &mut crate::shape::Shape, property: &InstancePropertyChange, value: f64) {
match property {
InstancePropertyChange::X(_) => shape.transform.x = value,
InstancePropertyChange::Y(_) => shape.transform.y = value,
InstancePropertyChange::Rotation(_) => shape.transform.rotation = value,
InstancePropertyChange::ScaleX(_) => shape.transform.scale_x = value,
InstancePropertyChange::ScaleY(_) => shape.transform.scale_y = value,
InstancePropertyChange::SkewX(_) => shape.transform.skew_x = value,
InstancePropertyChange::SkewY(_) => shape.transform.skew_y = value,
InstancePropertyChange::Opacity(_) => shape.opacity = value,
}
}
}
@ -91,25 +104,14 @@ impl SetInstancePropertiesAction {
impl Action for SetInstancePropertiesAction {
fn execute(&mut self, document: &mut Document) -> Result<(), String> {
let new_value = self.property.value();
let layer_id = self.layer_id;
// First pass: collect old values for instances that don't have them yet
for (instance_id, old_value) in &mut self.instance_changes {
if old_value.is_none() {
// Get old value inline to avoid borrow issues
if let Some(layer) = document.get_layer(&layer_id) {
// First pass: collect old values
if let Some(layer) = document.get_layer(&self.layer_id) {
if let AnyLayer::Vector(vector_layer) = layer {
if let Some(instance) = vector_layer.get_object(instance_id) {
*old_value = Some(match &self.property {
InstancePropertyChange::X(_) => instance.transform.x,
InstancePropertyChange::Y(_) => instance.transform.y,
InstancePropertyChange::Rotation(_) => instance.transform.rotation,
InstancePropertyChange::ScaleX(_) => instance.transform.scale_x,
InstancePropertyChange::ScaleY(_) => instance.transform.scale_y,
InstancePropertyChange::SkewX(_) => instance.transform.skew_x,
InstancePropertyChange::SkewY(_) => instance.transform.skew_y,
InstancePropertyChange::Opacity(_) => instance.opacity,
});
for (shape_id, old_value) in &mut self.shape_changes {
if old_value.is_none() {
if let Some(shape) = vector_layer.get_shape_in_keyframe(shape_id, self.time) {
*old_value = Some(Self::get_value_from_shape(shape, &self.property));
}
}
}
@ -117,16 +119,28 @@ impl Action for SetInstancePropertiesAction {
}
// Second pass: apply new values
for (instance_id, _) in &self.instance_changes {
self.apply_to_instance(document, instance_id, new_value);
if let Some(layer) = document.get_layer_mut(&self.layer_id) {
if let AnyLayer::Vector(vector_layer) = layer {
for (shape_id, _) in &self.shape_changes {
if let Some(shape) = vector_layer.get_shape_in_keyframe_mut(shape_id, self.time) {
Self::set_value_on_shape(shape, &self.property, new_value);
}
}
}
}
Ok(())
}
fn rollback(&mut self, document: &mut Document) -> Result<(), String> {
for (instance_id, old_value) in &self.instance_changes {
if let Some(layer) = document.get_layer_mut(&self.layer_id) {
if let AnyLayer::Vector(vector_layer) = layer {
for (shape_id, old_value) in &self.shape_changes {
if let Some(value) = old_value {
self.apply_to_instance(document, instance_id, *value);
if let Some(shape) = vector_layer.get_shape_in_keyframe_mut(shape_id, self.time) {
Self::set_value_on_shape(shape, &self.property, *value);
}
}
}
}
}
Ok(())
@ -144,10 +158,10 @@ impl Action for SetInstancePropertiesAction {
InstancePropertyChange::Opacity(_) => "opacity",
};
if self.instance_changes.len() == 1 {
if self.shape_changes.len() == 1 {
format!("Set {}", property_name)
} else {
format!("Set {} on {} objects", property_name, self.instance_changes.len())
format!("Set {} on {} shapes", property_name, self.shape_changes.len())
}
}
}
@ -156,80 +170,46 @@ impl Action for SetInstancePropertiesAction {
mod tests {
use super::*;
use crate::layer::VectorLayer;
use crate::object::{ShapeInstance, Transform};
use crate::shape::Shape;
use vello::kurbo::BezPath;
fn make_shape_at(x: f64, y: f64) -> Shape {
let mut path = BezPath::new();
path.move_to((0.0, 0.0));
path.line_to((10.0, 10.0));
Shape::new(path).with_position(x, y)
}
#[test]
fn test_set_x_position() {
let mut document = Document::new("Test");
let mut layer = VectorLayer::new("Test Layer");
let shape_id = Uuid::new_v4();
let mut instance = ShapeInstance::new(shape_id);
let instance_id = instance.id;
instance.transform = Transform::with_position(10.0, 20.0);
layer.add_object(instance);
let shape = make_shape_at(10.0, 20.0);
let shape_id = shape.id;
layer.add_shape_to_keyframe(shape, 0.0);
let layer_id = document.root_mut().add_child(AnyLayer::Vector(layer));
// Create and execute action
let mut action = SetInstancePropertiesAction::new(
layer_id,
instance_id,
0.0,
shape_id,
InstancePropertyChange::X(50.0),
);
action.execute(&mut document).unwrap();
// Verify position changed
if let Some(AnyLayer::Vector(vl)) = document.get_layer_mut(&layer_id) {
let obj = vl.get_object(&instance_id).unwrap();
assert_eq!(obj.transform.x, 50.0);
assert_eq!(obj.transform.y, 20.0); // Y unchanged
if let Some(AnyLayer::Vector(vl)) = document.get_layer(&layer_id) {
let s = vl.get_shape_in_keyframe(&shape_id, 0.0).unwrap();
assert_eq!(s.transform.x, 50.0);
assert_eq!(s.transform.y, 20.0);
}
// Rollback
action.rollback(&mut document).unwrap();
// Verify restored
if let Some(AnyLayer::Vector(vl)) = document.get_layer_mut(&layer_id) {
let obj = vl.get_object(&instance_id).unwrap();
assert_eq!(obj.transform.x, 10.0);
}
}
#[test]
fn test_set_rotation() {
let mut document = Document::new("Test");
let mut layer = VectorLayer::new("Test Layer");
let shape_id = Uuid::new_v4();
let mut instance = ShapeInstance::new(shape_id);
let instance_id = instance.id;
instance.transform.rotation = 0.0;
layer.add_object(instance);
let layer_id = document.root_mut().add_child(AnyLayer::Vector(layer));
// Create and execute action
let mut action = SetInstancePropertiesAction::new(
layer_id,
instance_id,
InstancePropertyChange::Rotation(45.0),
);
action.execute(&mut document).unwrap();
// Verify rotation changed
if let Some(AnyLayer::Vector(vl)) = document.get_layer_mut(&layer_id) {
let obj = vl.get_object(&instance_id).unwrap();
assert_eq!(obj.transform.rotation, 45.0);
}
// Rollback
action.rollback(&mut document).unwrap();
// Verify restored
if let Some(AnyLayer::Vector(vl)) = document.get_layer_mut(&layer_id) {
let obj = vl.get_object(&instance_id).unwrap();
assert_eq!(obj.transform.rotation, 0.0);
if let Some(AnyLayer::Vector(vl)) = document.get_layer(&layer_id) {
let s = vl.get_shape_in_keyframe(&shape_id, 0.0).unwrap();
assert_eq!(s.transform.x, 10.0);
}
}
@ -238,35 +218,30 @@ mod tests {
let mut document = Document::new("Test");
let mut layer = VectorLayer::new("Test Layer");
let shape_id = Uuid::new_v4();
let mut instance = ShapeInstance::new(shape_id);
let instance_id = instance.id;
instance.opacity = 1.0;
layer.add_object(instance);
let shape = make_shape_at(0.0, 0.0);
let shape_id = shape.id;
layer.add_shape_to_keyframe(shape, 0.0);
let layer_id = document.root_mut().add_child(AnyLayer::Vector(layer));
// Create and execute action
let mut action = SetInstancePropertiesAction::new(
layer_id,
instance_id,
0.0,
shape_id,
InstancePropertyChange::Opacity(0.5),
);
action.execute(&mut document).unwrap();
// Verify opacity changed
if let Some(AnyLayer::Vector(vl)) = document.get_layer_mut(&layer_id) {
let obj = vl.get_object(&instance_id).unwrap();
assert_eq!(obj.opacity, 0.5);
if let Some(AnyLayer::Vector(vl)) = document.get_layer(&layer_id) {
let s = vl.get_shape_in_keyframe(&shape_id, 0.0).unwrap();
assert_eq!(s.opacity, 0.5);
}
// Rollback
action.rollback(&mut document).unwrap();
// Verify restored
if let Some(AnyLayer::Vector(vl)) = document.get_layer_mut(&layer_id) {
let obj = vl.get_object(&instance_id).unwrap();
assert_eq!(obj.opacity, 1.0);
if let Some(AnyLayer::Vector(vl)) = document.get_layer(&layer_id) {
let s = vl.get_shape_in_keyframe(&shape_id, 0.0).unwrap();
assert_eq!(s.opacity, 1.0);
}
}
@ -275,69 +250,59 @@ mod tests {
let mut document = Document::new("Test");
let mut layer = VectorLayer::new("Test Layer");
let shape_id = Uuid::new_v4();
let shape1 = make_shape_at(0.0, 0.0);
let shape1_id = shape1.id;
let shape2 = make_shape_at(10.0, 10.0);
let shape2_id = shape2.id;
let mut instance1 = ShapeInstance::new(shape_id);
let instance1_id = instance1.id;
instance1.transform.scale_x = 1.0;
let mut instance2 = ShapeInstance::new(shape_id);
let instance2_id = instance2.id;
instance2.transform.scale_x = 1.0;
layer.add_object(instance1);
layer.add_object(instance2);
layer.add_shape_to_keyframe(shape1, 0.0);
layer.add_shape_to_keyframe(shape2, 0.0);
let layer_id = document.root_mut().add_child(AnyLayer::Vector(layer));
// Create and execute batch action
let mut action = SetInstancePropertiesAction::new_batch(
layer_id,
vec![instance1_id, instance2_id],
0.0,
vec![shape1_id, shape2_id],
InstancePropertyChange::ScaleX(2.0),
);
action.execute(&mut document).unwrap();
// Verify both changed
if let Some(AnyLayer::Vector(vl)) = document.get_layer_mut(&layer_id) {
assert_eq!(vl.get_object(&instance1_id).unwrap().transform.scale_x, 2.0);
assert_eq!(vl.get_object(&instance2_id).unwrap().transform.scale_x, 2.0);
if let Some(AnyLayer::Vector(vl)) = document.get_layer(&layer_id) {
assert_eq!(vl.get_shape_in_keyframe(&shape1_id, 0.0).unwrap().transform.scale_x, 2.0);
assert_eq!(vl.get_shape_in_keyframe(&shape2_id, 0.0).unwrap().transform.scale_x, 2.0);
}
// Rollback
action.rollback(&mut document).unwrap();
// Verify both restored
if let Some(AnyLayer::Vector(vl)) = document.get_layer_mut(&layer_id) {
assert_eq!(vl.get_object(&instance1_id).unwrap().transform.scale_x, 1.0);
assert_eq!(vl.get_object(&instance2_id).unwrap().transform.scale_x, 1.0);
if let Some(AnyLayer::Vector(vl)) = document.get_layer(&layer_id) {
assert_eq!(vl.get_shape_in_keyframe(&shape1_id, 0.0).unwrap().transform.scale_x, 1.0);
assert_eq!(vl.get_shape_in_keyframe(&shape2_id, 0.0).unwrap().transform.scale_x, 1.0);
}
}
#[test]
fn test_description() {
let layer_id = Uuid::new_v4();
let instance_id = Uuid::new_v4();
let shape_id = Uuid::new_v4();
let action1 = SetInstancePropertiesAction::new(
layer_id,
instance_id,
layer_id, 0.0, shape_id,
InstancePropertyChange::X(0.0),
);
assert_eq!(action1.description(), "Set X position");
let action2 = SetInstancePropertiesAction::new(
layer_id,
instance_id,
layer_id, 0.0, shape_id,
InstancePropertyChange::Rotation(0.0),
);
assert_eq!(action2.description(), "Set rotation");
let action3 = SetInstancePropertiesAction::new_batch(
layer_id,
layer_id, 0.0,
vec![Uuid::new_v4(), Uuid::new_v4()],
InstancePropertyChange::Opacity(1.0),
);
assert_eq!(action3.description(), "Set opacity on 2 objects");
assert_eq!(action3.description(), "Set opacity on 2 shapes");
}
}

View File

@ -0,0 +1,147 @@
//! Set keyframe action
//!
//! For vector layers: creates a new ShapeKeyframe at the given time by copying
//! shapes from the current keyframe span (with new UUIDs).
//! For clip instances: adds AnimationData keyframes for transform properties.
use crate::action::Action;
use crate::animation::{AnimationCurve, AnimationTarget, Keyframe, TransformProperty};
use crate::document::Document;
use crate::layer::{AnyLayer, ShapeKeyframe};
use uuid::Uuid;
/// Undo info for a clip animation curve
struct ClipUndoEntry {
target: AnimationTarget,
old_keyframe: Option<Keyframe>,
curve_created: bool,
}
pub struct SetKeyframeAction {
layer_id: Uuid,
time: f64,
/// Clip instance IDs to keyframe (motion tweens)
clip_instance_ids: Vec<Uuid>,
/// Whether a shape keyframe was created by this action
shape_keyframe_created: bool,
/// The removed keyframe for rollback (if we created one)
removed_keyframe: Option<ShapeKeyframe>,
/// Clip animation undo entries
clip_undo_entries: Vec<ClipUndoEntry>,
}
impl SetKeyframeAction {
pub fn new(layer_id: Uuid, time: f64, clip_instance_ids: Vec<Uuid>) -> Self {
Self {
layer_id,
time,
clip_instance_ids,
shape_keyframe_created: false,
removed_keyframe: None,
clip_undo_entries: Vec::new(),
}
}
}
const TRANSFORM_PROPERTIES: &[TransformProperty] = &[
TransformProperty::X,
TransformProperty::Y,
TransformProperty::Rotation,
TransformProperty::ScaleX,
TransformProperty::ScaleY,
TransformProperty::SkewX,
TransformProperty::SkewY,
TransformProperty::Opacity,
];
fn transform_default(prop: &TransformProperty) -> f64 {
match prop {
TransformProperty::ScaleX | TransformProperty::ScaleY => 1.0,
TransformProperty::Opacity => 1.0,
_ => 0.0,
}
}
impl Action for SetKeyframeAction {
fn execute(&mut self, document: &mut Document) -> Result<(), String> {
self.clip_undo_entries.clear();
self.shape_keyframe_created = false;
let layer = document
.get_layer_mut(&self.layer_id)
.ok_or_else(|| format!("Layer {} not found", self.layer_id))?;
// For vector layers: create a shape keyframe
if let AnyLayer::Vector(vl) = layer {
// Check if a keyframe already exists at this exact time
let already_exists = vl.keyframe_index_at_exact(self.time, 0.001).is_some();
if !already_exists {
vl.insert_keyframe_from_current(self.time);
self.shape_keyframe_created = true;
}
// Add clip animation keyframes
for clip_id in &self.clip_instance_ids {
for prop in TRANSFORM_PROPERTIES {
let target = AnimationTarget::Object {
id: *clip_id,
property: *prop,
};
let default = transform_default(prop);
let value = vl.layer.animation_data.eval(&target, self.time, default);
let curve_created = vl.layer.animation_data.get_curve(&target).is_none();
if curve_created {
vl.layer
.animation_data
.set_curve(AnimationCurve::new(target.clone(), default));
}
let curve = vl.layer.animation_data.get_curve_mut(&target).unwrap();
let old_keyframe = curve.get_keyframe_at(self.time, 0.001).cloned();
curve.set_keyframe(Keyframe::linear(self.time, value));
self.clip_undo_entries.push(ClipUndoEntry {
target,
old_keyframe,
curve_created,
});
}
}
}
Ok(())
}
fn rollback(&mut self, document: &mut Document) -> Result<(), String> {
let layer = document
.get_layer_mut(&self.layer_id)
.ok_or_else(|| format!("Layer {} not found", self.layer_id))?;
if let AnyLayer::Vector(vl) = layer {
// Undo clip animation keyframes in reverse order
for entry in self.clip_undo_entries.drain(..).rev() {
if entry.curve_created {
vl.layer.animation_data.remove_curve(&entry.target);
} else if let Some(curve) = vl.layer.animation_data.get_curve_mut(&entry.target) {
curve.remove_keyframe(self.time, 0.001);
if let Some(old_kf) = entry.old_keyframe {
curve.set_keyframe(old_kf);
}
}
}
// Remove the shape keyframe if we created one
if self.shape_keyframe_created {
self.removed_keyframe = vl.remove_keyframe_at(self.time, 0.001);
self.shape_keyframe_created = false;
}
}
Ok(())
}
fn description(&self) -> String {
"New keyframe".to_string()
}
}

View File

@ -25,6 +25,9 @@ pub struct SetShapePropertiesAction {
/// Shape to modify
shape_id: Uuid,
/// Time of the keyframe containing the shape
time: f64,
/// New property value
new_value: ShapePropertyChange,
@ -34,28 +37,50 @@ pub struct SetShapePropertiesAction {
impl SetShapePropertiesAction {
/// Create a new action to set a property on a shape
pub fn new(layer_id: Uuid, shape_id: Uuid, new_value: ShapePropertyChange) -> Self {
pub fn new(layer_id: Uuid, shape_id: Uuid, time: f64, new_value: ShapePropertyChange) -> Self {
Self {
layer_id,
shape_id,
time,
new_value,
old_value: None,
}
}
/// Create action to set fill color
pub fn set_fill_color(layer_id: Uuid, shape_id: Uuid, color: Option<ShapeColor>) -> Self {
Self::new(layer_id, shape_id, ShapePropertyChange::FillColor(color))
pub fn set_fill_color(layer_id: Uuid, shape_id: Uuid, time: f64, color: Option<ShapeColor>) -> Self {
Self::new(layer_id, shape_id, time, ShapePropertyChange::FillColor(color))
}
/// Create action to set stroke color
pub fn set_stroke_color(layer_id: Uuid, shape_id: Uuid, color: Option<ShapeColor>) -> Self {
Self::new(layer_id, shape_id, ShapePropertyChange::StrokeColor(color))
pub fn set_stroke_color(layer_id: Uuid, shape_id: Uuid, time: f64, color: Option<ShapeColor>) -> Self {
Self::new(layer_id, shape_id, time, ShapePropertyChange::StrokeColor(color))
}
/// Create action to set stroke width
pub fn set_stroke_width(layer_id: Uuid, shape_id: Uuid, width: f64) -> Self {
Self::new(layer_id, shape_id, ShapePropertyChange::StrokeWidth(width))
pub fn set_stroke_width(layer_id: Uuid, shape_id: Uuid, time: f64, width: f64) -> Self {
Self::new(layer_id, shape_id, time, ShapePropertyChange::StrokeWidth(width))
}
}
fn apply_property(shape: &mut crate::shape::Shape, change: &ShapePropertyChange) {
match change {
ShapePropertyChange::FillColor(color) => {
shape.fill_color = *color;
}
ShapePropertyChange::StrokeColor(color) => {
shape.stroke_color = *color;
}
ShapePropertyChange::StrokeWidth(width) => {
if let Some(ref mut style) = shape.stroke_style {
style.width = *width;
} else {
shape.stroke_style = Some(StrokeStyle {
width: *width,
..Default::default()
});
}
}
}
}
@ -63,7 +88,7 @@ impl Action for SetShapePropertiesAction {
fn execute(&mut self, document: &mut Document) -> Result<(), String> {
if let Some(layer) = document.get_layer_mut(&self.layer_id) {
if let AnyLayer::Vector(vector_layer) = layer {
if let Some(shape) = vector_layer.shapes.get_mut(&self.shape_id) {
if let Some(shape) = vector_layer.get_shape_in_keyframe_mut(&self.shape_id, self.time) {
// Store old value if not already stored
if self.old_value.is_none() {
self.old_value = Some(match &self.new_value {
@ -84,26 +109,7 @@ impl Action for SetShapePropertiesAction {
});
}
// Apply new value
match &self.new_value {
ShapePropertyChange::FillColor(color) => {
shape.fill_color = *color;
}
ShapePropertyChange::StrokeColor(color) => {
shape.stroke_color = *color;
}
ShapePropertyChange::StrokeWidth(width) => {
if let Some(ref mut style) = shape.stroke_style {
style.width = *width;
} else {
// Create stroke style if it doesn't exist
shape.stroke_style = Some(StrokeStyle {
width: *width,
..Default::default()
});
}
}
}
apply_property(shape, &self.new_value);
}
}
}
@ -111,23 +117,11 @@ impl Action for SetShapePropertiesAction {
}
fn rollback(&mut self, document: &mut Document) -> Result<(), String> {
if let Some(old_value) = &self.old_value {
if let Some(old_value) = &self.old_value.clone() {
if let Some(layer) = document.get_layer_mut(&self.layer_id) {
if let AnyLayer::Vector(vector_layer) = layer {
if let Some(shape) = vector_layer.shapes.get_mut(&self.shape_id) {
match old_value {
ShapePropertyChange::FillColor(color) => {
shape.fill_color = *color;
}
ShapePropertyChange::StrokeColor(color) => {
shape.stroke_color = *color;
}
ShapePropertyChange::StrokeWidth(width) => {
if let Some(ref mut style) = shape.stroke_style {
style.width = *width;
}
}
}
if let Some(shape) = vector_layer.get_shape_in_keyframe_mut(&self.shape_id, self.time) {
apply_property(shape, old_value);
}
}
}
@ -149,7 +143,7 @@ mod tests {
use super::*;
use crate::layer::VectorLayer;
use crate::shape::Shape;
use kurbo::BezPath;
use vello::kurbo::BezPath;
fn create_test_shape() -> Shape {
let mut path = BezPath::new();
@ -176,24 +170,24 @@ mod tests {
let shape = create_test_shape();
let shape_id = shape.id;
layer.shapes.insert(shape_id, shape);
layer.add_shape_to_keyframe(shape, 0.0);
let layer_id = document.root_mut().add_child(AnyLayer::Vector(layer));
// Verify initial color
if let Some(AnyLayer::Vector(vl)) = document.get_layer_mut(&layer_id) {
let shape = vl.shapes.get(&shape_id).unwrap();
if let Some(AnyLayer::Vector(vl)) = document.get_layer(&layer_id) {
let shape = vl.get_shape_in_keyframe(&shape_id, 0.0).unwrap();
assert_eq!(shape.fill_color.unwrap().r, 255);
}
// Create and execute action
let new_color = Some(ShapeColor::rgb(0, 255, 0));
let mut action = SetShapePropertiesAction::set_fill_color(layer_id, shape_id, new_color);
let mut action = SetShapePropertiesAction::set_fill_color(layer_id, shape_id, 0.0, new_color);
action.execute(&mut document).unwrap();
// Verify color changed
if let Some(AnyLayer::Vector(vl)) = document.get_layer_mut(&layer_id) {
let shape = vl.shapes.get(&shape_id).unwrap();
if let Some(AnyLayer::Vector(vl)) = document.get_layer(&layer_id) {
let shape = vl.get_shape_in_keyframe(&shape_id, 0.0).unwrap();
assert_eq!(shape.fill_color.unwrap().g, 255);
}
@ -201,8 +195,8 @@ mod tests {
action.rollback(&mut document).unwrap();
// Verify restored
if let Some(AnyLayer::Vector(vl)) = document.get_layer_mut(&layer_id) {
let shape = vl.shapes.get(&shape_id).unwrap();
if let Some(AnyLayer::Vector(vl)) = document.get_layer(&layer_id) {
let shape = vl.get_shape_in_keyframe(&shape_id, 0.0).unwrap();
assert_eq!(shape.fill_color.unwrap().r, 255);
}
}
@ -214,23 +208,17 @@ mod tests {
let shape = create_test_shape();
let shape_id = shape.id;
layer.shapes.insert(shape_id, shape);
layer.add_shape_to_keyframe(shape, 0.0);
let layer_id = document.root_mut().add_child(AnyLayer::Vector(layer));
// Verify initial width
if let Some(AnyLayer::Vector(vl)) = document.get_layer_mut(&layer_id) {
let shape = vl.shapes.get(&shape_id).unwrap();
assert_eq!(shape.stroke_style.as_ref().unwrap().width, 2.0);
}
// Create and execute action
let mut action = SetShapePropertiesAction::set_stroke_width(layer_id, shape_id, 5.0);
let mut action = SetShapePropertiesAction::set_stroke_width(layer_id, shape_id, 0.0, 5.0);
action.execute(&mut document).unwrap();
// Verify width changed
if let Some(AnyLayer::Vector(vl)) = document.get_layer_mut(&layer_id) {
let shape = vl.shapes.get(&shape_id).unwrap();
if let Some(AnyLayer::Vector(vl)) = document.get_layer(&layer_id) {
let shape = vl.get_shape_in_keyframe(&shape_id, 0.0).unwrap();
assert_eq!(shape.stroke_style.as_ref().unwrap().width, 5.0);
}
@ -238,8 +226,8 @@ mod tests {
action.rollback(&mut document).unwrap();
// Verify restored
if let Some(AnyLayer::Vector(vl)) = document.get_layer_mut(&layer_id) {
let shape = vl.shapes.get(&shape_id).unwrap();
if let Some(AnyLayer::Vector(vl)) = document.get_layer(&layer_id) {
let shape = vl.get_shape_in_keyframe(&shape_id, 0.0).unwrap();
assert_eq!(shape.stroke_style.as_ref().unwrap().width, 2.0);
}
}
@ -250,14 +238,14 @@ mod tests {
let shape_id = Uuid::new_v4();
let action1 =
SetShapePropertiesAction::set_fill_color(layer_id, shape_id, Some(ShapeColor::rgb(0, 0, 0)));
SetShapePropertiesAction::set_fill_color(layer_id, shape_id, 0.0, Some(ShapeColor::rgb(0, 0, 0)));
assert_eq!(action1.description(), "Set fill color");
let action2 =
SetShapePropertiesAction::set_stroke_color(layer_id, shape_id, Some(ShapeColor::rgb(0, 0, 0)));
SetShapePropertiesAction::set_stroke_color(layer_id, shape_id, 0.0, Some(ShapeColor::rgb(0, 0, 0)));
assert_eq!(action2.description(), "Set stroke color");
let action3 = SetShapePropertiesAction::set_stroke_width(layer_id, shape_id, 3.0);
let action3 = SetShapePropertiesAction::set_stroke_width(layer_id, shape_id, 0.0, 3.0);
assert_eq!(action3.description(), "Set stroke width");
}
}

View File

@ -1,8 +1,10 @@
//! Transform clip instances action
//!
//! Handles spatial transformation (move, scale, rotate) of clip instances on the stage.
//! Updates both the clip instance's transform and the animation keyframe at the current time.
use crate::action::Action;
use crate::animation::{AnimationTarget, Keyframe, TransformProperty};
use crate::document::Document;
use crate::layer::AnyLayer;
use crate::object::Transform;
@ -12,6 +14,8 @@ use uuid::Uuid;
/// Action that transforms clip instances spatially on the stage
pub struct TransformClipInstancesAction {
layer_id: Uuid,
/// Current time for animation keyframe update
time: f64,
/// Map of clip instance ID to (old transform, new transform)
clip_instance_transforms: HashMap<Uuid, (Transform, Transform)>,
}
@ -19,15 +23,48 @@ pub struct TransformClipInstancesAction {
impl TransformClipInstancesAction {
pub fn new(
layer_id: Uuid,
time: f64,
clip_instance_transforms: HashMap<Uuid, (Transform, Transform)>,
) -> Self {
Self {
layer_id,
time,
clip_instance_transforms,
}
}
}
/// Update animation keyframes for a clip instance's transform properties at the given time.
/// If a curve exists for a property, updates the keyframe at that time. If no curve exists, does nothing.
fn update_animation_keyframes(
animation_data: &mut crate::animation::AnimationData,
instance_id: Uuid,
transform: &Transform,
opacity: f64,
time: f64,
) {
let props_and_values = [
(TransformProperty::X, transform.x),
(TransformProperty::Y, transform.y),
(TransformProperty::Rotation, transform.rotation),
(TransformProperty::ScaleX, transform.scale_x),
(TransformProperty::ScaleY, transform.scale_y),
(TransformProperty::SkewX, transform.skew_x),
(TransformProperty::SkewY, transform.skew_y),
(TransformProperty::Opacity, opacity),
];
for (prop, value) in props_and_values {
let target = AnimationTarget::Object {
id: instance_id,
property: prop,
};
if let Some(curve) = animation_data.get_curve_mut(&target) {
curve.set_keyframe(Keyframe::linear(time, value));
}
}
}
impl Action for TransformClipInstancesAction {
fn execute(&mut self, document: &mut Document) -> Result<(), String> {
let layer = match document.get_layer_mut(&self.layer_id) {
@ -35,20 +72,34 @@ impl Action for TransformClipInstancesAction {
None => return Ok(()),
};
// 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,
AnyLayer::Effect(_) => return Ok(()), // Effect layers don't have clip instances
};
// Apply new transforms
match layer {
AnyLayer::Vector(vl) => {
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) {
if let Some(clip_instance) = vl.clip_instances.iter_mut().find(|ci| ci.id == *clip_id) {
let opacity = clip_instance.opacity;
clip_instance.transform = new.clone();
update_animation_keyframes(
&mut vl.layer.animation_data, *clip_id, new, opacity, self.time,
);
}
}
}
AnyLayer::Audio(al) => {
for (clip_id, (_old, new)) in &self.clip_instance_transforms {
if let Some(ci) = al.clip_instances.iter_mut().find(|ci| ci.id == *clip_id) {
ci.transform = new.clone();
}
}
}
AnyLayer::Video(vl) => {
for (clip_id, (_old, new)) in &self.clip_instance_transforms {
if let Some(ci) = vl.clip_instances.iter_mut().find(|ci| ci.id == *clip_id) {
ci.transform = new.clone();
}
}
}
AnyLayer::Effect(_) => {}
}
Ok(())
}
@ -58,20 +109,34 @@ impl Action for TransformClipInstancesAction {
None => return Ok(()),
};
// 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,
AnyLayer::Effect(_) => return Ok(()), // Effect layers don't have clip instances
};
// Restore old transforms
match layer {
AnyLayer::Vector(vl) => {
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) {
if let Some(clip_instance) = vl.clip_instances.iter_mut().find(|ci| ci.id == *clip_id) {
let opacity = clip_instance.opacity;
clip_instance.transform = old.clone();
update_animation_keyframes(
&mut vl.layer.animation_data, *clip_id, old, opacity, self.time,
);
}
}
}
AnyLayer::Audio(al) => {
for (clip_id, (old, _new)) in &self.clip_instance_transforms {
if let Some(ci) = al.clip_instances.iter_mut().find(|ci| ci.id == *clip_id) {
ci.transform = old.clone();
}
}
}
AnyLayer::Video(vl) => {
for (clip_id, (old, _new)) in &self.clip_instance_transforms {
if let Some(ci) = vl.clip_instances.iter_mut().find(|ci| ci.id == *clip_id) {
ci.transform = old.clone();
}
}
}
AnyLayer::Effect(_) => {}
}
Ok(())
}
@ -94,7 +159,6 @@ mod tests {
let mut document = Document::new("Test");
let mut layer = VectorLayer::new("Test Layer");
// Create a clip instance with initial transform
let clip_id = Uuid::new_v4();
let instance_id = Uuid::new_v4();
let mut instance = ClipInstance::with_id(instance_id, clip_id);
@ -103,18 +167,14 @@ mod tests {
let layer_id = document.root_mut().add_child(AnyLayer::Vector(layer));
// Create transform action: move from (10, 20) to (100, 200)
let old_transform = Transform::with_position(10.0, 20.0);
let new_transform = Transform::with_position(100.0, 200.0);
let mut transforms = HashMap::new();
transforms.insert(instance_id, (old_transform, new_transform));
let mut action = TransformClipInstancesAction::new(layer_id, transforms);
// Execute action
let mut action = TransformClipInstancesAction::new(layer_id, 0.0, transforms);
action.execute(&mut document).unwrap();
// Verify transform changed
if let Some(AnyLayer::Vector(vl)) = document.get_layer_mut(&layer_id) {
let inst = vl.clip_instances.iter().find(|ci| ci.id == instance_id).unwrap();
assert_eq!(inst.transform.x, 100.0);
@ -123,10 +183,8 @@ mod tests {
panic!("Layer not found");
}
// Rollback
action.rollback(&mut document).unwrap();
// Verify transform restored
if let Some(AnyLayer::Vector(vl)) = document.get_layer_mut(&layer_id) {
let inst = vl.clip_instances.iter().find(|ci| ci.id == instance_id).unwrap();
assert_eq!(inst.transform.x, 10.0);
@ -141,7 +199,6 @@ mod tests {
let mut document = Document::new("Test");
let mut layer = AudioLayer::new("Audio Layer");
// Create a clip instance
let clip_id = Uuid::new_v4();
let instance_id = Uuid::new_v4();
let mut instance = ClipInstance::with_id(instance_id, clip_id);
@ -150,16 +207,14 @@ mod tests {
let layer_id = document.root_mut().add_child(AnyLayer::Audio(layer));
// Create transform action
let old_transform = Transform::with_position(0.0, 0.0);
let new_transform = Transform::with_position(50.0, 75.0);
let mut transforms = HashMap::new();
transforms.insert(instance_id, (old_transform, new_transform));
let mut action = TransformClipInstancesAction::new(layer_id, transforms);
let mut action = TransformClipInstancesAction::new(layer_id, 0.0, transforms);
action.execute(&mut document).unwrap();
// Verify
if let Some(AnyLayer::Audio(al)) = document.get_layer_mut(&layer_id) {
let inst = al.clip_instances.iter().find(|ci| ci.id == instance_id).unwrap();
assert_eq!(inst.transform.x, 50.0);
@ -174,7 +229,6 @@ mod tests {
let mut document = Document::new("Test");
let mut layer = VideoLayer::new("Video Layer");
// Create a clip instance
let clip_id = Uuid::new_v4();
let instance_id = Uuid::new_v4();
let mut instance = ClipInstance::with_id(instance_id, clip_id);
@ -184,7 +238,6 @@ mod tests {
let layer_id = document.root_mut().add_child(AnyLayer::Video(layer));
// Create transform with rotation and scale
let mut old_transform = Transform::new();
old_transform.rotation = 0.0;
old_transform.scale_x = 1.0;
@ -197,10 +250,9 @@ mod tests {
let mut transforms = HashMap::new();
transforms.insert(instance_id, (old_transform, new_transform));
let mut action = TransformClipInstancesAction::new(layer_id, transforms);
let mut action = TransformClipInstancesAction::new(layer_id, 0.0, transforms);
action.execute(&mut document).unwrap();
// Verify rotation and scale
if let Some(AnyLayer::Video(vl)) = document.get_layer_mut(&layer_id) {
let inst = vl.clip_instances.iter().find(|ci| ci.id == instance_id).unwrap();
assert_eq!(inst.transform.rotation, 45.0);
@ -216,7 +268,6 @@ mod tests {
let mut document = Document::new("Test");
let mut layer = VectorLayer::new("Test Layer");
// Create two clip instances
let clip_id = Uuid::new_v4();
let instance1_id = Uuid::new_v4();
let instance2_id = Uuid::new_v4();
@ -232,7 +283,6 @@ mod tests {
let layer_id = document.root_mut().add_child(AnyLayer::Vector(layer));
// Transform both instances
let mut transforms = HashMap::new();
transforms.insert(
instance1_id,
@ -243,10 +293,9 @@ mod tests {
(Transform::with_position(100.0, 100.0), Transform::with_position(150.0, 150.0)),
);
let mut action = TransformClipInstancesAction::new(layer_id, transforms);
let mut action = TransformClipInstancesAction::new(layer_id, 0.0, transforms);
action.execute(&mut document).unwrap();
// Verify both transformed
if let Some(AnyLayer::Vector(vl)) = document.get_layer_mut(&layer_id) {
let inst1 = vl.clip_instances.iter().find(|ci| ci.id == instance1_id).unwrap();
assert_eq!(inst1.transform.x, 50.0);
@ -259,10 +308,8 @@ mod tests {
panic!("Layer not found");
}
// Rollback
action.rollback(&mut document).unwrap();
// Verify both restored
if let Some(AnyLayer::Vector(vl)) = document.get_layer_mut(&layer_id) {
let inst1 = vl.clip_instances.iter().find(|ci| ci.id == instance1_id).unwrap();
assert_eq!(inst1.transform.x, 0.0);
@ -276,25 +323,6 @@ mod tests {
}
}
#[test]
fn test_transform_nonexistent_layer() {
let mut document = Document::new("Test");
let fake_layer_id = Uuid::new_v4();
let instance_id = Uuid::new_v4();
let mut transforms = HashMap::new();
transforms.insert(
instance_id,
(Transform::with_position(0.0, 0.0), Transform::with_position(50.0, 50.0)),
);
let mut action = TransformClipInstancesAction::new(fake_layer_id, transforms);
// Should not panic, just return early
action.execute(&mut document).unwrap();
action.rollback(&mut document).unwrap();
}
#[test]
fn test_description() {
let layer_id = Uuid::new_v4();
@ -306,16 +334,7 @@ mod tests {
(Transform::new(), Transform::with_position(10.0, 10.0)),
);
let action = TransformClipInstancesAction::new(layer_id, transforms);
let action = TransformClipInstancesAction::new(layer_id, 0.0, transforms);
assert_eq!(action.description(), "Transform 1 clip instance(s)");
// Multiple instances
let mut transforms2 = HashMap::new();
transforms2.insert(Uuid::new_v4(), (Transform::new(), Transform::new()));
transforms2.insert(Uuid::new_v4(), (Transform::new(), Transform::new()));
transforms2.insert(Uuid::new_v4(), (Transform::new(), Transform::new()));
let action2 = TransformClipInstancesAction::new(layer_id, transforms2);
assert_eq!(action2.description(), "Transform 3 clip instance(s)");
}
}

View File

@ -1,6 +1,6 @@
//! Transform shape instances action
//! Transform shapes action
//!
//! Applies scale, rotation, and other transformations to shape instances with undo/redo support.
//! Applies scale, rotation, and other transformations to shapes in a keyframe.
use crate::action::Action;
use crate::document::Document;
@ -9,22 +9,24 @@ use crate::object::Transform;
use std::collections::HashMap;
use uuid::Uuid;
/// Action to transform multiple shape instances
/// Action to transform multiple shapes in a keyframe
pub struct TransformShapeInstancesAction {
layer_id: Uuid,
/// Map of shape instance ID to (old transform, new transform)
shape_instance_transforms: HashMap<Uuid, (Transform, Transform)>,
time: f64,
/// Map of shape ID to (old transform, new transform)
shape_transforms: HashMap<Uuid, (Transform, Transform)>,
}
impl TransformShapeInstancesAction {
/// Create a new transform action
pub fn new(
layer_id: Uuid,
shape_instance_transforms: HashMap<Uuid, (Transform, Transform)>,
time: f64,
shape_transforms: HashMap<Uuid, (Transform, Transform)>,
) -> Self {
Self {
layer_id,
shape_instance_transforms,
time,
shape_transforms,
}
}
}
@ -33,10 +35,10 @@ impl Action for TransformShapeInstancesAction {
fn execute(&mut self, document: &mut Document) -> Result<(), String> {
if let Some(layer) = document.get_layer_mut(&self.layer_id) {
if let AnyLayer::Vector(vector_layer) = layer {
for (shape_instance_id, (_old, new)) in &self.shape_instance_transforms {
vector_layer.modify_object_internal(shape_instance_id, |obj| {
obj.transform = new.clone();
});
for (shape_id, (_old, new)) in &self.shape_transforms {
if let Some(shape) = vector_layer.get_shape_in_keyframe_mut(shape_id, self.time) {
shape.transform = new.clone();
}
}
}
}
@ -46,10 +48,10 @@ impl Action for TransformShapeInstancesAction {
fn rollback(&mut self, document: &mut Document) -> Result<(), String> {
if let Some(layer) = document.get_layer_mut(&self.layer_id) {
if let AnyLayer::Vector(vector_layer) = layer {
for (shape_instance_id, (old, _new)) in &self.shape_instance_transforms {
vector_layer.modify_object_internal(shape_instance_id, |obj| {
obj.transform = old.clone();
});
for (shape_id, (old, _new)) in &self.shape_transforms {
if let Some(shape) = vector_layer.get_shape_in_keyframe_mut(shape_id, self.time) {
shape.transform = old.clone();
}
}
}
}
@ -57,7 +59,7 @@ impl Action for TransformShapeInstancesAction {
}
fn description(&self) -> String {
format!("Transform {} shape instance(s)", self.shape_instance_transforms.len())
format!("Transform {} shape(s)", self.shape_transforms.len())
}
}
@ -65,239 +67,43 @@ impl Action for TransformShapeInstancesAction {
mod tests {
use super::*;
use crate::layer::VectorLayer;
use crate::object::ShapeInstance;
use crate::shape::Shape;
use vello::kurbo::BezPath;
#[test]
fn test_transform_single_shape_instance() {
fn test_transform_shape() {
let mut document = Document::new("Test");
let mut layer = VectorLayer::new("Test Layer");
// Create a shape instance with initial position
let shape_id = Uuid::new_v4();
let instance_id = Uuid::new_v4();
let mut instance = ShapeInstance::new(shape_id);
instance.id = instance_id;
instance.transform = Transform::with_position(10.0, 20.0);
layer.add_object(instance);
let mut path = BezPath::new();
path.move_to((0.0, 0.0));
path.line_to((100.0, 100.0));
let shape = Shape::new(path).with_position(10.0, 20.0);
let shape_id = shape.id;
layer.add_shape_to_keyframe(shape, 0.0);
let layer_id = document.root_mut().add_child(AnyLayer::Vector(layer));
// Create transform action
let old_transform = Transform::with_position(10.0, 20.0);
let new_transform = Transform::with_position(100.0, 200.0);
let mut transforms = HashMap::new();
transforms.insert(instance_id, (old_transform, new_transform));
transforms.insert(shape_id, (old_transform, new_transform));
let mut action = TransformShapeInstancesAction::new(layer_id, transforms);
// Execute
let mut action = TransformShapeInstancesAction::new(layer_id, 0.0, transforms);
action.execute(&mut document).unwrap();
// Verify transform changed
if let Some(AnyLayer::Vector(vl)) = document.get_layer_mut(&layer_id) {
let obj = vl.get_object(&instance_id).unwrap();
assert_eq!(obj.transform.x, 100.0);
assert_eq!(obj.transform.y, 200.0);
} else {
panic!("Layer not found");
if let Some(AnyLayer::Vector(vl)) = document.get_layer(&layer_id) {
let s = vl.get_shape_in_keyframe(&shape_id, 0.0).unwrap();
assert_eq!(s.transform.x, 100.0);
assert_eq!(s.transform.y, 200.0);
}
// Rollback
action.rollback(&mut document).unwrap();
// Verify restored
if let Some(AnyLayer::Vector(vl)) = document.get_layer_mut(&layer_id) {
let obj = vl.get_object(&instance_id).unwrap();
assert_eq!(obj.transform.x, 10.0);
assert_eq!(obj.transform.y, 20.0);
} else {
panic!("Layer not found");
if let Some(AnyLayer::Vector(vl)) = document.get_layer(&layer_id) {
let s = vl.get_shape_in_keyframe(&shape_id, 0.0).unwrap();
assert_eq!(s.transform.x, 10.0);
assert_eq!(s.transform.y, 20.0);
}
}
#[test]
fn test_transform_shape_instance_rotation_scale() {
let mut document = Document::new("Test");
let mut layer = VectorLayer::new("Test Layer");
let shape_id = Uuid::new_v4();
let instance_id = Uuid::new_v4();
let mut instance = ShapeInstance::new(shape_id);
instance.id = instance_id;
instance.transform.rotation = 0.0;
instance.transform.scale_x = 1.0;
instance.transform.scale_y = 1.0;
layer.add_object(instance);
let layer_id = document.root_mut().add_child(AnyLayer::Vector(layer));
// Create transform with rotation and scale
let mut old_transform = Transform::new();
let mut new_transform = Transform::new();
new_transform.rotation = 90.0;
new_transform.scale_x = 2.0;
new_transform.scale_y = 0.5;
let mut transforms = HashMap::new();
transforms.insert(instance_id, (old_transform, new_transform));
let mut action = TransformShapeInstancesAction::new(layer_id, transforms);
action.execute(&mut document).unwrap();
// Verify
if let Some(AnyLayer::Vector(vl)) = document.get_layer_mut(&layer_id) {
let obj = vl.get_object(&instance_id).unwrap();
assert_eq!(obj.transform.rotation, 90.0);
assert_eq!(obj.transform.scale_x, 2.0);
assert_eq!(obj.transform.scale_y, 0.5);
} else {
panic!("Layer not found");
}
}
#[test]
fn test_transform_multiple_shape_instances() {
let mut document = Document::new("Test");
let mut layer = VectorLayer::new("Test Layer");
let shape_id = Uuid::new_v4();
let instance1_id = Uuid::new_v4();
let instance2_id = Uuid::new_v4();
let mut instance1 = ShapeInstance::new(shape_id);
instance1.id = instance1_id;
instance1.transform = Transform::with_position(0.0, 0.0);
let mut instance2 = ShapeInstance::new(shape_id);
instance2.id = instance2_id;
instance2.transform = Transform::with_position(50.0, 50.0);
layer.add_object(instance1);
layer.add_object(instance2);
let layer_id = document.root_mut().add_child(AnyLayer::Vector(layer));
// Transform both
let mut transforms = HashMap::new();
transforms.insert(
instance1_id,
(Transform::with_position(0.0, 0.0), Transform::with_position(10.0, 10.0)),
);
transforms.insert(
instance2_id,
(Transform::with_position(50.0, 50.0), Transform::with_position(60.0, 60.0)),
);
let mut action = TransformShapeInstancesAction::new(layer_id, transforms);
action.execute(&mut document).unwrap();
// Verify both transformed
if let Some(AnyLayer::Vector(vl)) = document.get_layer_mut(&layer_id) {
let obj1 = vl.get_object(&instance1_id).unwrap();
assert_eq!(obj1.transform.x, 10.0);
assert_eq!(obj1.transform.y, 10.0);
let obj2 = vl.get_object(&instance2_id).unwrap();
assert_eq!(obj2.transform.x, 60.0);
assert_eq!(obj2.transform.y, 60.0);
} else {
panic!("Layer not found");
}
// Rollback
action.rollback(&mut document).unwrap();
// Verify both restored
if let Some(AnyLayer::Vector(vl)) = document.get_layer_mut(&layer_id) {
let obj1 = vl.get_object(&instance1_id).unwrap();
assert_eq!(obj1.transform.x, 0.0);
assert_eq!(obj1.transform.y, 0.0);
let obj2 = vl.get_object(&instance2_id).unwrap();
assert_eq!(obj2.transform.x, 50.0);
assert_eq!(obj2.transform.y, 50.0);
} else {
panic!("Layer not found");
}
}
#[test]
fn test_transform_nonexistent_layer() {
let mut document = Document::new("Test");
let fake_layer_id = Uuid::new_v4();
let instance_id = Uuid::new_v4();
let mut transforms = HashMap::new();
transforms.insert(
instance_id,
(Transform::new(), Transform::with_position(10.0, 10.0)),
);
let mut action = TransformShapeInstancesAction::new(fake_layer_id, transforms);
// Should not panic
action.execute(&mut document).unwrap();
action.rollback(&mut document).unwrap();
}
#[test]
fn test_transform_nonexistent_instance() {
let mut document = Document::new("Test");
let layer = VectorLayer::new("Test Layer");
let layer_id = document.root_mut().add_child(AnyLayer::Vector(layer));
let fake_instance_id = Uuid::new_v4();
let mut transforms = HashMap::new();
transforms.insert(
fake_instance_id,
(Transform::new(), Transform::with_position(10.0, 10.0)),
);
let mut action = TransformShapeInstancesAction::new(layer_id, transforms);
// Should not panic - just silently skip nonexistent instance
action.execute(&mut document).unwrap();
action.rollback(&mut document).unwrap();
}
#[test]
fn test_transform_on_non_vector_layer() {
use crate::layer::AudioLayer;
let mut document = Document::new("Test");
let layer = AudioLayer::new("Audio Layer");
let layer_id = document.root_mut().add_child(AnyLayer::Audio(layer));
let instance_id = Uuid::new_v4();
let mut transforms = HashMap::new();
transforms.insert(
instance_id,
(Transform::new(), Transform::with_position(10.0, 10.0)),
);
let mut action = TransformShapeInstancesAction::new(layer_id, transforms);
// Should not panic - action only operates on vector layers
action.execute(&mut document).unwrap();
action.rollback(&mut document).unwrap();
}
#[test]
fn test_description() {
let layer_id = Uuid::new_v4();
let mut transforms1 = HashMap::new();
transforms1.insert(Uuid::new_v4(), (Transform::new(), Transform::new()));
let action1 = TransformShapeInstancesAction::new(layer_id, transforms1);
assert_eq!(action1.description(), "Transform 1 shape instance(s)");
let mut transforms3 = HashMap::new();
transforms3.insert(Uuid::new_v4(), (Transform::new(), Transform::new()));
transforms3.insert(Uuid::new_v4(), (Transform::new(), Transform::new()));
transforms3.insert(Uuid::new_v4(), (Transform::new(), Transform::new()));
let action3 = TransformShapeInstancesAction::new(layer_id, transforms3);
assert_eq!(action3.description(), "Transform 3 shape instance(s)");
}
}

View File

@ -523,4 +523,25 @@ impl AnimationData {
.map(|curve| curve.eval(time))
.unwrap_or(default)
}
/// Evaluate the effective transform for a clip instance at a given time.
/// Uses animation curves if they exist, falling back to the clip instance's base transform.
pub fn eval_clip_instance_transform(
&self,
instance_id: uuid::Uuid,
time: f64,
base: &crate::object::Transform,
base_opacity: f64,
) -> (crate::object::Transform, f64) {
let mut t = base.clone();
t.x = self.eval(&AnimationTarget::Object { id: instance_id, property: TransformProperty::X }, time, base.x);
t.y = self.eval(&AnimationTarget::Object { id: instance_id, property: TransformProperty::Y }, time, base.y);
t.rotation = self.eval(&AnimationTarget::Object { id: instance_id, property: TransformProperty::Rotation }, time, base.rotation);
t.scale_x = self.eval(&AnimationTarget::Object { id: instance_id, property: TransformProperty::ScaleX }, time, base.scale_x);
t.scale_y = self.eval(&AnimationTarget::Object { id: instance_id, property: TransformProperty::ScaleY }, time, base.scale_y);
t.skew_x = self.eval(&AnimationTarget::Object { id: instance_id, property: TransformProperty::SkewX }, time, base.skew_x);
t.skew_y = self.eval(&AnimationTarget::Object { id: instance_id, property: TransformProperty::SkewY }, time, base.skew_y);
let opacity = self.eval(&AnimationTarget::Object { id: instance_id, property: TransformProperty::Opacity }, time, base_opacity);
(t, opacity)
}
}

View File

@ -44,6 +44,12 @@ pub struct VectorClip {
/// Nested layer hierarchy
pub layers: LayerTree<AnyLayer>,
/// Whether this clip is a group (static collection) rather than an animated clip.
/// Groups have their timeline extent determined by keyframe spans on the containing layer,
/// not by their internal duration.
#[serde(default)]
pub is_group: bool,
/// Folder this clip belongs to (None = root of category)
#[serde(default)]
pub folder_id: Option<Uuid>,
@ -59,6 +65,7 @@ impl VectorClip {
height,
duration,
layers: LayerTree::new(),
is_group: false,
folder_id: None,
}
}
@ -78,6 +85,7 @@ impl VectorClip {
height,
duration,
layers: LayerTree::new(),
is_group: false,
folder_id: None,
}
}
@ -100,16 +108,14 @@ impl VectorClip {
for layer_node in self.layers.iter() {
// Only process vector layers (skip other layer types)
if let AnyLayer::Vector(vector_layer) = &layer_node.data {
// Calculate bounds for all shape instances in this layer
for shape_instance in &vector_layer.shape_instances {
// Get the shape for this instance
if let Some(shape) = vector_layer.shapes.get(&shape_instance.shape_id) {
// Calculate bounds for all shapes in the active keyframe
for shape in vector_layer.shapes_at_time(clip_time) {
// Get the local bounding box of the shape's path
let local_bbox = shape.path().bounding_box();
// Apply the shape instance's transform (TODO: evaluate animations at clip_time)
let instance_transform = shape_instance.to_affine();
let transformed_bbox = instance_transform.transform_rect_bbox(local_bbox);
// Apply the shape's transform
let shape_transform = shape.transform.to_affine();
let transformed_bbox = shape_transform.transform_rect_bbox(local_bbox);
// Union with combined bounds
combined_bounds = Some(match combined_bounds {
@ -117,7 +123,6 @@ impl VectorClip {
Some(existing) => existing.union(transformed_bbox),
});
}
}
// Handle nested clip instances recursively
for clip_instance in &vector_layer.clip_instances {

View File

@ -5,7 +5,6 @@
use crate::clip::{AudioClip, ClipInstance, ImageAsset, VectorClip, VideoClip};
use crate::layer::{AudioLayerType, AnyLayer};
use crate::object::ShapeInstance;
use crate::shape::Shape;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
@ -71,12 +70,10 @@ pub enum ClipboardContent {
/// Referenced image assets
image_assets: Vec<(Uuid, ImageAsset)>,
},
/// Shapes and shape instances from a vector layer
/// Shapes from a vector layer's keyframe
Shapes {
/// Shape definitions (id -> shape)
shapes: Vec<(Uuid, Shape)>,
/// Shape instances referencing the shapes above
instances: Vec<ShapeInstance>,
/// Shapes (with embedded transforms)
shapes: Vec<Shape>,
},
}
@ -168,39 +165,22 @@ impl ClipboardContent {
id_map,
)
}
ClipboardContent::Shapes { shapes, instances } => {
// Regenerate shape definition IDs
let new_shapes: Vec<(Uuid, Shape)> = shapes
ClipboardContent::Shapes { shapes } => {
// Regenerate shape IDs
let new_shapes: Vec<Shape> = shapes
.iter()
.map(|(old_id, shape)| {
.map(|shape| {
let new_id = Uuid::new_v4();
id_map.insert(*old_id, new_id);
id_map.insert(shape.id, new_id);
let mut new_shape = shape.clone();
new_shape.id = new_id;
(new_id, new_shape)
})
.collect();
// Regenerate instance IDs and remap shape_id references
let new_instances: Vec<ShapeInstance> = instances
.iter()
.map(|inst| {
let new_instance_id = Uuid::new_v4();
id_map.insert(inst.id, new_instance_id);
let mut new_inst = inst.clone();
new_inst.id = new_instance_id;
// Remap shape_id to new definition ID
if let Some(new_shape_id) = id_map.get(&inst.shape_id) {
new_inst.shape_id = *new_shape_id;
}
new_inst
new_shape
})
.collect();
(
ClipboardContent::Shapes {
shapes: new_shapes,
instances: new_instances,
},
id_map,
)

View File

@ -5,7 +5,6 @@
use crate::clip::ClipInstance;
use crate::layer::VectorLayer;
use crate::object::ShapeInstance;
use crate::shape::Shape;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
@ -22,35 +21,32 @@ pub enum HitResult {
/// Hit test a layer at a specific point
///
/// Tests objects in reverse order (front to back) and returns the first hit.
/// Combines parent_transform with object transforms for hierarchical testing.
/// Tests shapes in the active keyframe in reverse order (front to back) and returns the first hit.
///
/// # Arguments
///
/// * `layer` - The vector layer to test
/// * `time` - The current time (for keyframe lookup)
/// * `point` - The point to test in screen/canvas space
/// * `tolerance` - Additional tolerance in pixels for stroke hit testing
/// * `parent_transform` - Transform from parent GraphicsObject(s)
///
/// # Returns
///
/// The UUID of the first object hit, or None if no hit
/// The UUID of the first shape hit, or None if no hit
pub fn hit_test_layer(
layer: &VectorLayer,
time: f64,
point: Point,
tolerance: f64,
parent_transform: Affine,
) -> Option<Uuid> {
// Test objects in reverse order (back to front in Vec = front to back for hit testing)
for object in layer.shape_instances.iter().rev() {
// Get the shape for this object
let shape = layer.get_shape(&object.shape_id)?;
// Combine parent transform with object transform
let combined_transform = parent_transform * object.to_affine();
// Test shapes in reverse order (front to back for hit testing)
for shape in layer.shapes_at_time(time).iter().rev() {
let combined_transform = parent_transform * shape.transform.to_affine();
if hit_test_shape(shape, point, tolerance, combined_transform) {
return Some(object.id);
return Some(shape.id);
}
}
@ -60,17 +56,6 @@ pub fn hit_test_layer(
/// Hit test a single shape with a given transform
///
/// Tests if a point hits the shape, considering both fill and stroke.
///
/// # Arguments
///
/// * `shape` - The shape to test
/// * `point` - The point to test in screen/canvas space
/// * `tolerance` - Additional tolerance in pixels for stroke hit testing
/// * `transform` - The combined transform to apply to the shape
///
/// # Returns
///
/// true if the point hits the shape, false otherwise
pub fn hit_test_shape(
shape: &Shape,
point: Point,
@ -78,7 +63,6 @@ pub fn hit_test_shape(
transform: Affine,
) -> bool {
// Transform point to shape's local space
// We need the inverse transform to go from screen space to shape space
let inverse_transform = transform.inverse();
let local_point = inverse_transform * point;
@ -93,11 +77,6 @@ pub fn hit_test_shape(
if let Some(stroke_style) = &shape.stroke_style {
let stroke_tolerance = stroke_style.width / 2.0 + tolerance;
// For stroke hit testing, we need to check if the point is within
// stroke_tolerance distance of the path
// kurbo's winding() method can be used, or we can check bounding box first
// Quick bounding box check with stroke tolerance
let bbox = shape.path().bounding_box();
let expanded_bbox = bbox.inflate(stroke_tolerance, stroke_tolerance);
@ -105,13 +84,6 @@ pub fn hit_test_shape(
return false;
}
// For more accurate stroke hit testing, we would need to:
// 1. Stroke the path with the stroke width
// 2. Check if the point is contained in the stroked outline
// For now, we do a simpler bounding box check
// TODO: Implement accurate stroke hit testing using kurbo's stroke functionality
// Simple approach: if within expanded bbox, consider it a hit for now
return true;
}
@ -120,29 +92,17 @@ pub fn hit_test_shape(
/// Hit test objects within a rectangle (for marquee selection)
///
/// Returns all objects whose bounding boxes intersect with the given rectangle.
///
/// # Arguments
///
/// * `layer` - The vector layer to test
/// * `rect` - The selection rectangle in screen/canvas space
/// * `parent_transform` - Transform from parent GraphicsObject(s)
///
/// # Returns
///
/// Vector of UUIDs for all objects that intersect the rectangle
/// Returns all shapes in the active keyframe whose bounding boxes intersect with the given rectangle.
pub fn hit_test_objects_in_rect(
layer: &VectorLayer,
time: f64,
rect: Rect,
parent_transform: Affine,
) -> Vec<Uuid> {
let mut hits = Vec::new();
for object in &layer.shape_instances {
// Get the shape for this object
if let Some(shape) = layer.get_shape(&object.shape_id) {
// Combine parent transform with object transform
let combined_transform = parent_transform * object.to_affine();
for shape in layer.shapes_at_time(time) {
let combined_transform = parent_transform * shape.transform.to_affine();
// Get shape bounding box in local space
let bbox = shape.path().bounding_box();
@ -152,50 +112,24 @@ pub fn hit_test_objects_in_rect(
// Check if rectangles intersect
if rect.intersect(transformed_bbox).area() > 0.0 {
hits.push(object.id);
}
hits.push(shape.id);
}
}
hits
}
/// Get the bounding box of an object in screen space
///
/// # Arguments
///
/// * `object` - The object to get bounds for
/// * `shape` - The shape definition
/// * `parent_transform` - Transform from parent GraphicsObject(s)
///
/// # Returns
///
/// The bounding box in screen/canvas space
pub fn get_object_bounds(
object: &ShapeInstance,
/// Get the bounding box of a shape in screen space
pub fn get_shape_bounds(
shape: &Shape,
parent_transform: Affine,
) -> Rect {
let combined_transform = parent_transform * object.to_affine();
let combined_transform = parent_transform * shape.transform.to_affine();
let local_bbox = shape.path().bounding_box();
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,
@ -203,31 +137,13 @@ pub fn hit_test_clip_instance(
point: Point,
parent_transform: Affine,
) -> bool {
// Create bounding rectangle for the clip (top-left origin)
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,
@ -240,21 +156,6 @@ pub fn get_clip_instance_bounds(
}
/// Hit test clip instances at a specific point
///
/// Tests clip instances in reverse order (front to back) and returns the first hit.
/// Uses dynamic bounds calculation based on clip content and current time.
///
/// # Arguments
///
/// * `clip_instances` - The clip instances to test
/// * `document` - Document containing all clip definitions
/// * `point` - The point to test in screen/canvas space
/// * `parent_transform` - Transform from parent layer/clip
/// * `timeline_time` - Current timeline time for evaluating animations
///
/// # Returns
///
/// The UUID of the first clip instance hit, or None if no hit
pub fn hit_test_clip_instances(
clip_instances: &[ClipInstance],
document: &crate::document::Document,
@ -262,27 +163,20 @@ pub fn hit_test_clip_instances(
parent_transform: Affine,
timeline_time: f64,
) -> Option<Uuid> {
// Test in reverse order (front to back)
for clip_instance in clip_instances.iter().rev() {
// Calculate clip-local time from timeline time
// Apply timeline offset and playback speed, then add trim offset
let clip_time = ((timeline_time - clip_instance.timeline_start) * clip_instance.playback_speed) + clip_instance.trim_start;
// Get dynamic clip bounds from content at this time
let content_bounds = if let Some(vector_clip) = document.get_vector_clip(&clip_instance.clip_id) {
vector_clip.calculate_content_bounds(document, clip_time)
} else if let Some(video_clip) = document.get_video_clip(&clip_instance.clip_id) {
Rect::new(0.0, 0.0, video_clip.width, video_clip.height)
} else {
// Clip not found or is audio (no spatial representation)
continue;
};
// Transform content bounds to screen space
let clip_transform = parent_transform * clip_instance.transform.to_affine();
let clip_bbox = clip_transform.transform_rect_bbox(content_bounds);
// Test if point is inside the transformed rectangle
if clip_bbox.contains(point) {
return Some(clip_instance.id);
}
@ -292,21 +186,6 @@ pub fn hit_test_clip_instances(
}
/// Hit test clip instances within a rectangle (for marquee selection)
///
/// Returns all clip instances whose bounding boxes intersect with the given rectangle.
/// Uses dynamic bounds calculation based on clip content and current time.
///
/// # Arguments
///
/// * `clip_instances` - The clip instances to test
/// * `document` - Document containing all clip definitions
/// * `rect` - The selection rectangle in screen/canvas space
/// * `parent_transform` - Transform from parent layer/clip
/// * `timeline_time` - Current timeline time for evaluating animations
///
/// # Returns
///
/// Vector of UUIDs for all clip instances that intersect the rectangle
pub fn hit_test_clip_instances_in_rect(
clip_instances: &[ClipInstance],
document: &crate::document::Document,
@ -317,25 +196,19 @@ pub fn hit_test_clip_instances_in_rect(
let mut hits = Vec::new();
for clip_instance in clip_instances {
// Calculate clip-local time from timeline time
// Apply timeline offset and playback speed, then add trim offset
let clip_time = ((timeline_time - clip_instance.timeline_start) * clip_instance.playback_speed) + clip_instance.trim_start;
// Get dynamic clip bounds from content at this time
let content_bounds = if let Some(vector_clip) = document.get_vector_clip(&clip_instance.clip_id) {
vector_clip.calculate_content_bounds(document, clip_time)
} else if let Some(video_clip) = document.get_video_clip(&clip_instance.clip_id) {
Rect::new(0.0, 0.0, video_clip.width, video_clip.height)
} else {
// Clip not found or is audio (no spatial representation)
continue;
};
// Transform content bounds to screen space
let clip_transform = parent_transform * clip_instance.transform.to_affine();
let clip_bbox = clip_transform.transform_rect_bbox(content_bounds);
// Check if rectangles intersect
if rect.intersect(clip_bbox).area() > 0.0 {
hits.push(clip_instance.id);
}
@ -354,7 +227,7 @@ pub enum VectorEditHit {
ControlPoint {
shape_instance_id: Uuid,
curve_index: usize,
point_index: u8, // 1 or 2 (p1 or p2 of cubic bezier)
point_index: u8,
},
/// Hit a vertex (anchor point)
Vertex {
@ -365,7 +238,7 @@ pub enum VectorEditHit {
Curve {
shape_instance_id: Uuid,
curve_index: usize,
parameter_t: f64, // Where on the curve (0.0 to 1.0)
parameter_t: f64,
},
/// Hit shape fill
Fill { shape_instance_id: Uuid },
@ -374,13 +247,9 @@ pub enum VectorEditHit {
/// Tolerances for vector editing hit testing (in screen pixels)
#[derive(Debug, Clone, Copy)]
pub struct EditingHitTolerance {
/// Tolerance for hitting control points
pub control_point: f64,
/// Tolerance for hitting vertices
pub vertex: f64,
/// Tolerance for hitting curves
pub curve: f64,
/// Tolerance for hitting fill (usually 0.0 for exact containment)
pub fill: f64,
}
@ -396,10 +265,6 @@ impl Default for EditingHitTolerance {
}
impl EditingHitTolerance {
/// Create tolerances scaled by zoom factor
///
/// When zoomed in, hit targets appear larger in screen pixels,
/// so we divide by zoom to maintain consistent screen-space sizes.
pub fn scaled_by_zoom(zoom: f64) -> Self {
Self {
control_point: 10.0 / zoom,
@ -411,23 +276,9 @@ impl EditingHitTolerance {
}
/// Hit test for vector editing with priority-based detection
///
/// Tests objects in reverse order (front to back) and returns the first hit.
/// Priority order: Control points > Vertices > Curves > Fill
///
/// # Arguments
///
/// * `layer` - The vector layer to test
/// * `point` - The point to test in screen/canvas space
/// * `tolerance` - Hit tolerances for different element types
/// * `parent_transform` - Transform from parent GraphicsObject(s)
/// * `show_control_points` - Whether to test control points (BezierEdit tool)
///
/// # Returns
///
/// The first hit in priority order, or None if no hit
pub fn hit_test_vector_editing(
layer: &VectorLayer,
time: f64,
point: Point,
tolerance: &EditingHitTolerance,
parent_transform: Affine,
@ -436,50 +287,38 @@ pub fn hit_test_vector_editing(
use crate::bezpath_editing::extract_editable_curves;
use vello::kurbo::{ParamCurve, ParamCurveNearest};
// Test objects in reverse order (front to back for hit testing)
for object in layer.shape_instances.iter().rev() {
// Get the shape for this object
let shape = match layer.get_shape(&object.shape_id) {
Some(s) => s,
None => continue,
};
// Combine parent transform with object transform
let combined_transform = parent_transform * object.to_affine();
// Test shapes in reverse order (front to back for hit testing)
for shape in layer.shapes_at_time(time).iter().rev() {
let combined_transform = parent_transform * shape.transform.to_affine();
let inverse_transform = combined_transform.inverse();
let local_point = inverse_transform * point;
// Calculate the scale factor to transform screen-space tolerances to local space
// We need the inverse scale because we're in local space
// Affine coefficients are [a, b, c, d, e, f] representing matrix [[a, c, e], [b, d, f]]
let coeffs = combined_transform.as_coeffs();
let scale_x = (coeffs[0].powi(2) + coeffs[1].powi(2)).sqrt();
let scale_y = (coeffs[2].powi(2) + coeffs[3].powi(2)).sqrt();
let avg_scale = (scale_x + scale_y) / 2.0;
let local_tolerance_factor = 1.0 / avg_scale.max(0.001); // Avoid division by zero
let local_tolerance_factor = 1.0 / avg_scale.max(0.001);
// Extract editable curves and vertices from the shape's path
let editable = extract_editable_curves(shape.path());
// Priority 1: Control points (only in BezierEdit mode)
if show_control_points {
let local_cp_tolerance = tolerance.control_point * local_tolerance_factor;
for (i, curve) in editable.curves.iter().enumerate() {
// Test p1 (first control point)
let dist_p1 = (curve.p1 - local_point).hypot();
if dist_p1 < local_cp_tolerance {
return Some(VectorEditHit::ControlPoint {
shape_instance_id: object.id,
shape_instance_id: shape.id,
curve_index: i,
point_index: 1,
});
}
// Test p2 (second control point)
let dist_p2 = (curve.p2 - local_point).hypot();
if dist_p2 < local_cp_tolerance {
return Some(VectorEditHit::ControlPoint {
shape_instance_id: object.id,
shape_instance_id: shape.id,
curve_index: i,
point_index: 2,
});
@ -493,7 +332,7 @@ pub fn hit_test_vector_editing(
let dist = (vertex.point - local_point).hypot();
if dist < local_vertex_tolerance {
return Some(VectorEditHit::Vertex {
shape_instance_id: object.id,
shape_instance_id: shape.id,
vertex_index: i,
});
}
@ -507,7 +346,7 @@ pub fn hit_test_vector_editing(
let dist = (nearest_point - local_point).hypot();
if dist < local_curve_tolerance {
return Some(VectorEditHit::Curve {
shape_instance_id: object.id,
shape_instance_id: shape.id,
curve_index: i,
parameter_t: nearest.t,
});
@ -517,7 +356,7 @@ pub fn hit_test_vector_editing(
// Priority 4: Fill
if shape.fill_color.is_some() && shape.path().contains(local_point) {
return Some(VectorEditHit::Fill {
shape_instance_id: object.id,
shape_instance_id: shape.id,
});
}
}
@ -535,21 +374,18 @@ mod tests {
fn test_hit_test_simple_circle() {
let mut layer = VectorLayer::new("Test Layer");
// Create a circle at (100, 100) with radius 50
let circle = Circle::new((100.0, 100.0), 50.0);
let path = circle.to_path(0.1);
let shape = Shape::new(path).with_fill(ShapeColor::rgb(255, 0, 0));
let shape_instance = ShapeInstance::new(shape.id);
layer.add_shape(shape);
layer.add_object(shape_instance);
layer.add_shape_to_keyframe(shape, 0.0);
// Test hit inside circle
let hit = hit_test_layer(&layer, Point::new(100.0, 100.0), 0.0, Affine::IDENTITY);
let hit = hit_test_layer(&layer, 0.0, Point::new(100.0, 100.0), 0.0, Affine::IDENTITY);
assert!(hit.is_some());
// Test miss outside circle
let miss = hit_test_layer(&layer, Point::new(200.0, 200.0), 0.0, Affine::IDENTITY);
let miss = hit_test_layer(&layer, 0.0, Point::new(200.0, 200.0), 0.0, Affine::IDENTITY);
assert!(miss.is_none());
}
@ -557,23 +393,20 @@ mod tests {
fn test_hit_test_with_transform() {
let mut layer = VectorLayer::new("Test Layer");
// Create a circle at origin
let circle = Circle::new((0.0, 0.0), 50.0);
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))
.with_position(100.0, 100.0);
// Create shape instance with translation
let shape_instance = ShapeInstance::new(shape.id).with_position(100.0, 100.0);
layer.add_shape(shape);
layer.add_object(shape_instance);
layer.add_shape_to_keyframe(shape, 0.0);
// Test hit at translated position
let hit = hit_test_layer(&layer, Point::new(100.0, 100.0), 0.0, Affine::IDENTITY);
let hit = hit_test_layer(&layer, 0.0, Point::new(100.0, 100.0), 0.0, Affine::IDENTITY);
assert!(hit.is_some());
// Test miss at origin (where shape is defined, but object is translated)
let miss = hit_test_layer(&layer, Point::new(0.0, 0.0), 0.0, Affine::IDENTITY);
// Test miss at origin (where shape is defined, but transform moves it)
let miss = hit_test_layer(&layer, 0.0, Point::new(0.0, 0.0), 0.0, Affine::IDENTITY);
assert!(miss.is_none());
}
@ -581,30 +414,23 @@ mod tests {
fn test_marquee_selection() {
let mut layer = VectorLayer::new("Test Layer");
// Create two circles
let circle1 = Circle::new((50.0, 50.0), 20.0);
let path1 = circle1.to_path(0.1);
let shape1 = Shape::new(path1).with_fill(ShapeColor::rgb(255, 0, 0));
let shape_instance1 = ShapeInstance::new(shape1.id);
let shape1 = Shape::new(circle1.to_path(0.1)).with_fill(ShapeColor::rgb(255, 0, 0));
let circle2 = Circle::new((150.0, 150.0), 20.0);
let path2 = circle2.to_path(0.1);
let shape2 = Shape::new(path2).with_fill(ShapeColor::rgb(0, 255, 0));
let shape_instance2 = ShapeInstance::new(shape2.id);
let shape2 = Shape::new(circle2.to_path(0.1)).with_fill(ShapeColor::rgb(0, 255, 0));
layer.add_shape(shape1);
layer.add_object(shape_instance1);
layer.add_shape(shape2);
layer.add_object(shape_instance2);
layer.add_shape_to_keyframe(shape1, 0.0);
layer.add_shape_to_keyframe(shape2, 0.0);
// Marquee that contains both circles
let rect = Rect::new(0.0, 0.0, 200.0, 200.0);
let hits = hit_test_objects_in_rect(&layer, rect, Affine::IDENTITY);
let hits = hit_test_objects_in_rect(&layer, 0.0, rect, Affine::IDENTITY);
assert_eq!(hits.len(), 2);
// Marquee that contains only first circle
let rect = Rect::new(0.0, 0.0, 100.0, 100.0);
let hits = hit_test_objects_in_rect(&layer, rect, Affine::IDENTITY);
let hits = hit_test_objects_in_rect(&layer, 0.0, rect, Affine::IDENTITY);
assert_eq!(hits.len(), 1);
}
}

View File

@ -136,6 +136,59 @@ impl Layer {
}
}
/// Tween type between keyframes
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum TweenType {
/// Hold shapes until next keyframe (no interpolation)
None,
/// Shape tween — morph geometry between keyframes
Shape,
}
impl Default for TweenType {
fn default() -> Self {
TweenType::None
}
}
/// A keyframe containing all shapes at a point in time
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct ShapeKeyframe {
/// Time in seconds
pub time: f64,
/// All shapes at this keyframe
pub shapes: Vec<Shape>,
/// What happens between this keyframe and the next
#[serde(default)]
pub tween_after: TweenType,
/// Clip instance IDs visible in this keyframe region.
/// Groups are only rendered in regions where they appear in the left keyframe.
#[serde(default)]
pub clip_instance_ids: Vec<Uuid>,
}
impl ShapeKeyframe {
/// Create a new empty keyframe at a given time
pub fn new(time: f64) -> Self {
Self {
time,
shapes: Vec::new(),
tween_after: TweenType::None,
clip_instance_ids: Vec::new(),
}
}
/// Create a keyframe with shapes
pub fn with_shapes(time: f64, shapes: Vec<Shape>) -> Self {
Self {
time,
shapes,
tween_after: TweenType::None,
clip_instance_ids: Vec::new(),
}
}
}
/// Vector layer containing shapes and objects
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct VectorLayer {
@ -148,6 +201,10 @@ pub struct VectorLayer {
/// Shape instances (references to shapes with transforms)
pub shape_instances: Vec<ShapeInstance>,
/// Shape keyframes (sorted by time) — replaces shapes/shape_instances
#[serde(default)]
pub keyframes: Vec<ShapeKeyframe>,
/// Clip instances (references to vector clips with transforms)
/// VectorLayer can contain instances of VectorClips for nested compositions
pub clip_instances: Vec<ClipInstance>,
@ -230,6 +287,7 @@ impl VectorLayer {
layer: Layer::new(LayerType::Vector, name),
shapes: HashMap::new(),
shape_instances: Vec::new(),
keyframes: Vec::new(),
clip_instances: Vec::new(),
}
}
@ -325,6 +383,176 @@ impl VectorLayer {
f(object);
}
}
// === KEYFRAME METHODS ===
/// Find the keyframe at-or-before the given time
pub fn keyframe_at(&self, time: f64) -> Option<&ShapeKeyframe> {
// keyframes are sorted by time; find the last one <= time
let idx = self.keyframes.partition_point(|kf| kf.time <= time);
if idx > 0 {
Some(&self.keyframes[idx - 1])
} else {
None
}
}
/// Find the mutable keyframe at-or-before the given time
pub fn keyframe_at_mut(&mut self, time: f64) -> Option<&mut ShapeKeyframe> {
let idx = self.keyframes.partition_point(|kf| kf.time <= time);
if idx > 0 {
Some(&mut self.keyframes[idx - 1])
} else {
None
}
}
/// Find the index of a keyframe at the exact time (within tolerance)
pub fn keyframe_index_at_exact(&self, time: f64, tolerance: f64) -> Option<usize> {
self.keyframes.iter().position(|kf| (kf.time - time).abs() < tolerance)
}
/// Get shapes visible at a given time (from the keyframe at-or-before time)
pub fn shapes_at_time(&self, time: f64) -> &[Shape] {
match self.keyframe_at(time) {
Some(kf) => &kf.shapes,
None => &[],
}
}
/// Get the duration of the keyframe span starting at-or-before `time`.
/// Returns the time from `time` to the next keyframe, or to `fallback_end` if there is no next keyframe.
pub fn keyframe_span_duration(&self, time: f64, fallback_end: f64) -> f64 {
// Find the next keyframe after `time`
let next_idx = self.keyframes.partition_point(|kf| kf.time <= time);
let end = if next_idx < self.keyframes.len() {
self.keyframes[next_idx].time
} else {
fallback_end
};
(end - time).max(0.0)
}
/// Check if a clip instance (group) is visible at the given time.
/// Returns true if the keyframe at-or-before `time` contains `clip_instance_id`.
pub fn is_clip_instance_visible_at(&self, clip_instance_id: &Uuid, time: f64) -> bool {
self.keyframe_at(time)
.map_or(false, |kf| kf.clip_instance_ids.contains(clip_instance_id))
}
/// Get the visibility end time for a group clip instance starting from `time`.
/// A group is visible in regions bounded on the left by a keyframe that contains it
/// and on the right by any keyframe. Walks forward through keyframe regions,
/// extending as long as consecutive left-keyframes contain the clip instance.
/// If the last containing keyframe has no next keyframe (no right border),
/// the region is just one frame long.
pub fn group_visibility_end(&self, clip_instance_id: &Uuid, time: f64, frame_duration: f64) -> f64 {
let start_idx = self.keyframes.partition_point(|kf| kf.time <= time);
// start_idx is the index of the first keyframe AFTER time
// Walk forward: each keyframe that contains the group extends visibility to the NEXT keyframe
for idx in start_idx..self.keyframes.len() {
if !self.keyframes[idx].clip_instance_ids.contains(clip_instance_id) {
// This keyframe doesn't contain the group — it's the right border of the last region
return self.keyframes[idx].time;
}
// This keyframe contains the group — check if there's a next one to form a right border
}
// No more keyframes after the last containing one — last region is one frame
if let Some(last_kf) = self.keyframes.last() {
if last_kf.clip_instance_ids.contains(clip_instance_id) {
return last_kf.time + frame_duration;
}
}
time + frame_duration
}
/// Get mutable shapes at a given time
pub fn shapes_at_time_mut(&mut self, time: f64) -> Option<&mut Vec<Shape>> {
self.keyframe_at_mut(time).map(|kf| &mut kf.shapes)
}
/// Find a shape by ID within the keyframe active at the given time
pub fn get_shape_in_keyframe(&self, shape_id: &Uuid, time: f64) -> Option<&Shape> {
self.keyframe_at(time)
.and_then(|kf| kf.shapes.iter().find(|s| &s.id == shape_id))
}
/// Find a mutable shape by ID within the keyframe active at the given time
pub fn get_shape_in_keyframe_mut(&mut self, shape_id: &Uuid, time: f64) -> Option<&mut Shape> {
self.keyframe_at_mut(time)
.and_then(|kf| kf.shapes.iter_mut().find(|s| &s.id == shape_id))
}
/// Ensure a keyframe exists at the exact time, creating an empty one if needed.
/// Returns a mutable reference to the keyframe.
pub fn ensure_keyframe_at(&mut self, time: f64) -> &mut ShapeKeyframe {
let tolerance = 0.001;
if let Some(idx) = self.keyframe_index_at_exact(time, tolerance) {
return &mut self.keyframes[idx];
}
// Insert in sorted position
let insert_idx = self.keyframes.partition_point(|kf| kf.time < time);
self.keyframes.insert(insert_idx, ShapeKeyframe::new(time));
&mut self.keyframes[insert_idx]
}
/// Insert a new keyframe at time by copying shapes from the active keyframe.
/// Shape UUIDs are regenerated (no cross-keyframe identity).
/// If a keyframe already exists at the exact time, does nothing and returns it.
pub fn insert_keyframe_from_current(&mut self, time: f64) -> &mut ShapeKeyframe {
let tolerance = 0.001;
if let Some(idx) = self.keyframe_index_at_exact(time, tolerance) {
return &mut self.keyframes[idx];
}
// Clone shapes and clip instance IDs from the active keyframe
let (cloned_shapes, cloned_clip_ids) = self
.keyframe_at(time)
.map(|kf| {
let shapes: Vec<Shape> = kf.shapes
.iter()
.map(|s| {
let mut new_shape = s.clone();
new_shape.id = Uuid::new_v4();
new_shape
})
.collect();
let clip_ids = kf.clip_instance_ids.clone();
(shapes, clip_ids)
})
.unwrap_or_default();
let insert_idx = self.keyframes.partition_point(|kf| kf.time < time);
let mut kf = ShapeKeyframe::with_shapes(time, cloned_shapes);
kf.clip_instance_ids = cloned_clip_ids;
self.keyframes.insert(insert_idx, kf);
&mut self.keyframes[insert_idx]
}
/// Add a shape to the keyframe at the given time.
/// Creates a keyframe if none exists at that time.
pub(crate) fn add_shape_to_keyframe(&mut self, shape: Shape, time: f64) {
let kf = self.ensure_keyframe_at(time);
kf.shapes.push(shape);
}
/// Remove a shape from the keyframe at the given time.
/// Returns the removed shape if found.
pub(crate) fn remove_shape_from_keyframe(&mut self, shape_id: &Uuid, time: f64) -> Option<Shape> {
let kf = self.keyframe_at_mut(time)?;
let idx = kf.shapes.iter().position(|s| &s.id == shape_id)?;
Some(kf.shapes.remove(idx))
}
/// Remove a keyframe at the exact time (within tolerance).
/// Returns the removed keyframe if found.
pub(crate) fn remove_keyframe_at(&mut self, time: f64, tolerance: f64) -> Option<ShapeKeyframe> {
if let Some(idx) = self.keyframe_index_at_exact(time, tolerance) {
Some(self.keyframes.remove(idx))
} else {
None
}
}
}
/// Audio layer subtype - distinguishes sampled audio from MIDI

View File

@ -261,7 +261,7 @@ pub fn render_layer_isolated(
video_manager,
skip_instance_id,
);
rendered.has_content = !vector_layer.shape_instances.is_empty()
rendered.has_content = !vector_layer.shapes_at_time(time).is_empty()
|| !vector_layer.clip_instances.is_empty();
}
AnyLayer::Audio(_) => {
@ -462,6 +462,7 @@ fn render_clip_instance(
animation_data: &crate::animation::AnimationData,
image_cache: &mut ImageCache,
video_manager: &std::sync::Arc<std::sync::Mutex<crate::video::VideoManager>>,
group_end_time: Option<f64>,
) {
// Try to find the clip in the document's clip libraries
// For now, only handle VectorClips (VideoClip and AudioClip rendering not yet implemented)
@ -470,9 +471,19 @@ fn render_clip_instance(
};
// Remap timeline time to clip's internal time
let Some(clip_time) = clip_instance.remap_time(time, vector_clip.duration) else {
let clip_time = if vector_clip.is_group {
// Groups are static — visible from timeline_start to the next keyframe boundary
let end = group_end_time.unwrap_or(clip_instance.timeline_start);
if time < clip_instance.timeline_start || time >= end {
return;
}
0.0
} else {
let Some(t) = clip_instance.remap_time(time, vector_clip.duration) else {
return; // Clip instance not active at this time
};
t
};
// Evaluate animated transform properties
let transform = &clip_instance.transform;
@ -777,131 +788,38 @@ fn render_vector_layer(
// Render clip instances first (they appear under shape instances)
for clip_instance in &layer.clip_instances {
render_clip_instance(document, time, clip_instance, layer_opacity, scene, base_transform, &layer.layer.animation_data, image_cache, video_manager);
// For groups, compute the visibility end from keyframe data
let group_end_time = document.vector_clips.get(&clip_instance.clip_id)
.filter(|vc| vc.is_group)
.map(|_| {
let frame_duration = 1.0 / document.framerate;
layer.group_visibility_end(&clip_instance.id, clip_instance.timeline_start, frame_duration)
});
render_clip_instance(document, time, clip_instance, layer_opacity, scene, base_transform, &layer.layer.animation_data, image_cache, video_manager, group_end_time);
}
// Render each shape instance in the layer
for shape_instance in &layer.shape_instances {
// Skip this instance if it's being edited
if Some(shape_instance.id) == skip_instance_id {
// Render each shape in the active keyframe
for shape in layer.shapes_at_time(time) {
// Skip this shape if it's being edited
if Some(shape.id) == skip_instance_id {
continue;
}
// Get the shape for this instance
let Some(shape) = layer.get_shape(&shape_instance.shape_id) else {
continue;
};
// Use shape's transform directly (keyframe model — no animation evaluation)
let x = shape.transform.x;
let y = shape.transform.y;
let rotation = shape.transform.rotation;
let scale_x = shape.transform.scale_x;
let scale_y = shape.transform.scale_y;
let skew_x = shape.transform.skew_x;
let skew_y = shape.transform.skew_y;
let opacity = shape.opacity;
// Evaluate animated properties
let transform = &shape_instance.transform;
let x = layer
.layer
.animation_data
.eval(
&crate::animation::AnimationTarget::Object {
id: shape_instance.id,
property: TransformProperty::X,
},
time,
transform.x,
);
let y = layer
.layer
.animation_data
.eval(
&crate::animation::AnimationTarget::Object {
id: shape_instance.id,
property: TransformProperty::Y,
},
time,
transform.y,
);
let rotation = layer
.layer
.animation_data
.eval(
&crate::animation::AnimationTarget::Object {
id: shape_instance.id,
property: TransformProperty::Rotation,
},
time,
transform.rotation,
);
let scale_x = layer
.layer
.animation_data
.eval(
&crate::animation::AnimationTarget::Object {
id: shape_instance.id,
property: TransformProperty::ScaleX,
},
time,
transform.scale_x,
);
let scale_y = layer
.layer
.animation_data
.eval(
&crate::animation::AnimationTarget::Object {
id: shape_instance.id,
property: TransformProperty::ScaleY,
},
time,
transform.scale_y,
);
let skew_x = layer
.layer
.animation_data
.eval(
&crate::animation::AnimationTarget::Object {
id: shape_instance.id,
property: TransformProperty::SkewX,
},
time,
transform.skew_x,
);
let skew_y = layer
.layer
.animation_data
.eval(
&crate::animation::AnimationTarget::Object {
id: shape_instance.id,
property: TransformProperty::SkewY,
},
time,
transform.skew_y,
);
let opacity = layer
.layer
.animation_data
.eval(
&crate::animation::AnimationTarget::Object {
id: shape_instance.id,
property: TransformProperty::Opacity,
},
time,
shape_instance.opacity,
);
// Check if shape has morphing animation
let shape_index = layer
.layer
.animation_data
.eval(
&crate::animation::AnimationTarget::Shape {
id: shape.id,
property: crate::animation::ShapeProperty::ShapeIndex,
},
time,
0.0,
);
// Get the morphed path
let path = shape.get_morphed_path(shape_index);
// Get the path
let path = shape.path();
// Build transform matrix (compose with base transform for camera)
// Get shape center for skewing around center
let shape_bbox = shape.path().bounding_box();
let shape_bbox = path.bounding_box();
let center_x = (shape_bbox.x0 + shape_bbox.x1) / 2.0;
let center_y = (shape_bbox.y0 + shape_bbox.y1) / 2.0;
@ -921,7 +839,6 @@ fn render_vector_layer(
Affine::IDENTITY
};
// Skew around center: translate to origin, skew, translate back
Affine::translate((center_x, center_y))
* skew_x_affine
* skew_y_affine
@ -936,8 +853,7 @@ fn render_vector_layer(
* skew_transform;
let affine = base_transform * object_transform;
// Calculate final opacity (cascaded from parent → layer → shape instance)
// layer_opacity already includes parent_opacity from render_vector_layer
// Calculate final opacity (cascaded from parent → layer → shape)
let final_opacity = (layer_opacity * opacity) as f32;
// Determine fill rule
@ -953,12 +869,7 @@ fn render_vector_layer(
if let Some(image_asset_id) = shape.image_fill {
if let Some(image_asset) = document.get_image_asset(&image_asset_id) {
if let Some(image) = image_cache.get_or_decode(image_asset) {
// Apply opacity to image (clone is cheap - Image uses Arc<Blob> internally)
let image_with_alpha = (*image).clone().with_alpha(final_opacity);
// The image is rendered as a fill for the shape path
// Since the shape path is a rectangle matching the image dimensions,
// the image should fill the shape perfectly
scene.fill(fill_rule, affine, &image_with_alpha, None, &path);
filled = true;
}
@ -968,7 +879,6 @@ fn render_vector_layer(
// Fall back to color fill if no image fill (or image failed to load)
if !filled {
if let Some(fill_color) = &shape.fill_color {
// Apply opacity to color
let alpha = ((fill_color.a as f32 / 255.0) * final_opacity * 255.0) as u8;
let adjusted_color = crate::shape::ShapeColor::rgba(
fill_color.r,
@ -990,7 +900,6 @@ fn render_vector_layer(
// Render stroke if present
if let (Some(stroke_color), Some(stroke_style)) = (&shape.stroke_color, &shape.stroke_style)
{
// Apply opacity to color
let alpha = ((stroke_color.a as f32 / 255.0) * final_opacity * 255.0) as u8;
let adjusted_color = crate::shape::ShapeColor::rgba(
stroke_color.r,
@ -1015,48 +924,11 @@ mod tests {
use super::*;
use crate::document::Document;
use crate::layer::{AnyLayer, LayerTrait, VectorLayer};
use crate::object::ShapeInstance;
use crate::shape::{Shape, ShapeColor};
use kurbo::{Circle, Shape as KurboShape};
use vello::kurbo::{Circle, Shape as KurboShape};
#[test]
fn test_render_empty_document() {
let doc = Document::new("Test");
let mut scene = Scene::new();
let mut image_cache = ImageCache::new();
render_document(&doc, &mut scene, &mut image_cache);
// Should render background without errors
}
#[test]
fn test_render_document_with_shape() {
let mut doc = Document::new("Test");
// Create a simple circle shape
let circle = Circle::new((100.0, 100.0), 50.0);
let path = circle.to_path(0.1);
let shape = Shape::new(path).with_fill(ShapeColor::rgb(255, 0, 0));
// Create a shape instance for the shape
let shape_instance = ShapeInstance::new(shape.id);
// Create a vector layer
let mut vector_layer = VectorLayer::new("Layer 1");
vector_layer.add_shape(shape);
vector_layer.add_object(shape_instance);
// Add to document
doc.root.add_child(AnyLayer::Vector(vector_layer));
// Render
let mut scene = Scene::new();
let mut image_cache = ImageCache::new();
render_document(&doc, &mut scene, &mut image_cache);
// Should render without errors
}
// === Solo Rendering Tests ===
// Note: render_document tests require video_manager and are omitted here.
// The solo/visibility logic is tested via helpers.
/// Helper to check if any layer is soloed in document
fn has_soloed_layer(doc: &Document) -> bool {
@ -1081,79 +953,30 @@ mod tests {
fn test_no_solo_all_layers_render() {
let mut doc = Document::new("Test");
// Add two visible layers, neither soloed
let layer1 = VectorLayer::new("Layer 1");
let layer2 = VectorLayer::new("Layer 2");
doc.root.add_child(AnyLayer::Vector(layer1));
doc.root.add_child(AnyLayer::Vector(layer2));
// Both should be rendered
assert_eq!(has_soloed_layer(&doc), false);
assert_eq!(count_layers_to_render(&doc), 2);
// Render should work without errors
let mut scene = Scene::new();
let mut image_cache = ImageCache::new();
render_document(&doc, &mut scene, &mut image_cache);
}
#[test]
fn test_one_layer_soloed() {
let mut doc = Document::new("Test");
// Add two layers
let mut layer1 = VectorLayer::new("Layer 1");
let layer2 = VectorLayer::new("Layer 2");
// Solo layer 1
layer1.layer.soloed = true;
doc.root.add_child(AnyLayer::Vector(layer1));
doc.root.add_child(AnyLayer::Vector(layer2));
// Only soloed layer should be rendered
assert_eq!(has_soloed_layer(&doc), true);
assert_eq!(count_layers_to_render(&doc), 1);
// Verify the soloed layer is the one that would render
let any_soloed = has_soloed_layer(&doc);
let soloed_count: usize = doc.visible_layers()
.filter(|l| any_soloed && l.soloed())
.count();
assert_eq!(soloed_count, 1);
// Render should work
let mut scene = Scene::new();
let mut image_cache = ImageCache::new();
render_document(&doc, &mut scene, &mut image_cache);
}
#[test]
fn test_multiple_layers_soloed() {
let mut doc = Document::new("Test");
// Add three layers
let mut layer1 = VectorLayer::new("Layer 1");
let mut layer2 = VectorLayer::new("Layer 2");
let layer3 = VectorLayer::new("Layer 3");
// Solo layers 1 and 2
layer1.layer.soloed = true;
layer2.layer.soloed = true;
doc.root.add_child(AnyLayer::Vector(layer1));
doc.root.add_child(AnyLayer::Vector(layer2));
doc.root.add_child(AnyLayer::Vector(layer3));
// Only soloed layers (1 and 2) should render
assert_eq!(has_soloed_layer(&doc), true);
assert_eq!(count_layers_to_render(&doc), 2);
// Render
let mut scene = Scene::new();
let mut image_cache = ImageCache::new();
render_document(&doc, &mut scene, &mut image_cache);
}
#[test]
@ -1162,90 +985,12 @@ mod tests {
let layer1 = VectorLayer::new("Layer 1");
let mut layer2 = VectorLayer::new("Layer 2");
// Hide layer 2
layer2.layer.visible = false;
doc.root.add_child(AnyLayer::Vector(layer1));
doc.root.add_child(AnyLayer::Vector(layer2));
// Only visible layer (1) should be considered
assert_eq!(doc.visible_layers().count(), 1);
// Render
let mut scene = Scene::new();
let mut image_cache = ImageCache::new();
render_document(&doc, &mut scene, &mut image_cache);
}
#[test]
fn test_hidden_but_soloed_layer() {
// A hidden layer that is soloed shouldn't render
// because visible_layers() filters out hidden layers first
let mut doc = Document::new("Test");
let layer1 = VectorLayer::new("Layer 1");
let mut layer2 = VectorLayer::new("Layer 2");
// Layer 2: soloed but hidden
layer2.layer.soloed = true;
layer2.layer.visible = false;
doc.root.add_child(AnyLayer::Vector(layer1));
doc.root.add_child(AnyLayer::Vector(layer2));
// visible_layers only returns layer 1 (layer 2 is hidden)
// Since layer 1 isn't soloed and no visible layers are soloed,
// all visible layers render
let any_soloed = has_soloed_layer(&doc);
assert_eq!(any_soloed, false); // No *visible* layer is soloed
// Both visible layers render (only 1 is visible)
assert_eq!(count_layers_to_render(&doc), 1);
// Render
let mut scene = Scene::new();
let mut image_cache = ImageCache::new();
render_document(&doc, &mut scene, &mut image_cache);
}
#[test]
fn test_solo_with_layer_opacity() {
let mut doc = Document::new("Test");
// Create layers with different opacities
let mut layer1 = VectorLayer::new("Layer 1");
let mut layer2 = VectorLayer::new("Layer 2");
layer1.layer.opacity = 0.5;
layer1.layer.soloed = true;
layer2.layer.opacity = 0.8;
// Add circle shapes for visible rendering
let circle = Circle::new((50.0, 50.0), 20.0);
let path = circle.to_path(0.1);
let shape = Shape::new(path).with_fill(ShapeColor::rgb(255, 0, 0));
let shape_instance = ShapeInstance::new(shape.id);
layer1.add_shape(shape.clone());
layer1.add_object(shape_instance);
let shape2 = Shape::new(circle.to_path(0.1)).with_fill(ShapeColor::rgb(0, 255, 0));
let shape_instance2 = ShapeInstance::new(shape2.id);
layer2.add_shape(shape2);
layer2.add_object(shape_instance2);
doc.root.add_child(AnyLayer::Vector(layer1));
doc.root.add_child(AnyLayer::Vector(layer2));
// Only layer 1 (soloed with 0.5 opacity) should render
assert_eq!(has_soloed_layer(&doc), true);
assert_eq!(count_layers_to_render(&doc), 1);
// Render
let mut scene = Scene::new();
let mut image_cache = ImageCache::new();
render_document(&doc, &mut scene, &mut image_cache);
}
#[test]
@ -1253,23 +998,18 @@ mod tests {
let mut doc = Document::new("Test");
let mut layer1 = VectorLayer::new("Layer 1");
let mut layer2 = VectorLayer::new("Layer 2");
// First, solo layer 1
layer1.layer.soloed = true;
let id1 = doc.root.add_child(AnyLayer::Vector(layer1));
let id2 = doc.root.add_child(AnyLayer::Vector(layer2));
doc.root.add_child(AnyLayer::Vector(VectorLayer::new("Layer 2")));
// Only 1 layer renders when soloed
assert_eq!(count_layers_to_render(&doc), 1);
// Now unsolo layer 1
if let Some(layer) = doc.root.get_child_mut(&id1) {
layer.set_soloed(false);
}
// Now both should render again
assert_eq!(has_soloed_layer(&doc), false);
assert_eq!(count_layers_to_render(&doc), 2);
}

View File

@ -3,6 +3,7 @@
//! Provides bezier-based vector shapes with morphing support.
//! All shapes are composed of cubic bezier curves using kurbo::BezPath.
use crate::object::Transform;
use crate::path_interpolation::interpolate_paths;
use kurbo::{BezPath, Cap as KurboCap, Join as KurboJoin, Stroke as KurboStroke};
use vello::peniko::{Brush, Color, Fill};
@ -235,6 +236,22 @@ pub struct Shape {
/// Stroke style
pub stroke_style: Option<StrokeStyle>,
/// Transform (position, rotation, scale, skew)
#[serde(default)]
pub transform: Transform,
/// Opacity (0.0 to 1.0)
#[serde(default = "default_opacity")]
pub opacity: f64,
/// Display name
#[serde(default)]
pub name: Option<String>,
}
fn default_opacity() -> f64 {
1.0
}
impl Shape {
@ -248,6 +265,9 @@ impl Shape {
fill_rule: FillRule::NonZero,
stroke_color: None,
stroke_style: None,
transform: Transform::default(),
opacity: 1.0,
name: None,
}
}
@ -261,6 +281,9 @@ impl Shape {
fill_rule: FillRule::NonZero,
stroke_color: None,
stroke_style: None,
transform: Transform::default(),
opacity: 1.0,
name: None,
}
}
@ -326,6 +349,31 @@ impl Shape {
self
}
/// Set position
pub fn with_position(mut self, x: f64, y: f64) -> Self {
self.transform.x = x;
self.transform.y = y;
self
}
/// Set transform
pub fn with_transform(mut self, transform: Transform) -> Self {
self.transform = transform;
self
}
/// Set opacity
pub fn with_opacity(mut self, opacity: f64) -> Self {
self.opacity = opacity;
self
}
/// Set display name
pub fn with_name(mut self, name: impl Into<String>) -> Self {
self.name = Some(name.into());
self
}
/// Get the base path (first version) for this shape
///
/// This is useful for hit testing and bounding box calculations

View File

@ -1566,31 +1566,17 @@ impl EditorApp {
_ => return,
};
// Gather selected shape instances and their shape definitions
let selected_instances: Vec<_> = vector_layer
.shape_instances
.iter()
.filter(|si| self.selection.contains_shape_instance(&si.id))
.cloned()
// Gather selected shapes (they now contain their own transforms)
let selected_shapes: Vec<_> = self.selection.shapes().iter()
.filter_map(|id| vector_layer.shapes.get(id).cloned())
.collect();
if selected_instances.is_empty() {
if selected_shapes.is_empty() {
return;
}
let mut shapes = Vec::new();
let mut seen_shape_ids = std::collections::HashSet::new();
for inst in &selected_instances {
if seen_shape_ids.insert(inst.shape_id) {
if let Some(shape) = vector_layer.shapes.get(&inst.shape_id) {
shapes.push((inst.shape_id, shape.clone()));
}
}
}
let content = ClipboardContent::Shapes {
shapes,
instances: selected_instances,
shapes: selected_shapes,
};
self.clipboard_manager.copy(content);
@ -1641,47 +1627,18 @@ impl EditorApp {
}
self.selection.clear_clip_instances();
} else if !self.selection.shape_instances().is_empty() {
} else if !self.selection.shapes().is_empty() {
let active_layer_id = match self.active_layer_id {
Some(id) => id,
None => return,
};
let document = self.action_executor.document();
let layer = match document.get_layer(&active_layer_id) {
Some(l) => l,
None => return,
};
let vector_layer = match layer {
AnyLayer::Vector(vl) => vl,
_ => return,
};
// Collect shape instance IDs and their shape IDs
let instance_ids: Vec<Uuid> = self.selection.shape_instances().to_vec();
let mut shape_ids: Vec<Uuid> = Vec::new();
let mut shape_id_set = std::collections::HashSet::new();
for inst in &vector_layer.shape_instances {
if instance_ids.contains(&inst.id) {
if shape_id_set.insert(inst.shape_id) {
// Only remove shape definition if no other instances reference it
let other_refs = vector_layer
.shape_instances
.iter()
.any(|si| si.shape_id == inst.shape_id && !instance_ids.contains(&si.id));
if !other_refs {
shape_ids.push(inst.shape_id);
}
}
}
}
let shape_ids: Vec<Uuid> = self.selection.shapes().to_vec();
let action = lightningbeam_core::actions::RemoveShapesAction::new(
active_layer_id,
shape_ids,
instance_ids,
self.playback_time,
);
if let Err(e) = self.action_executor.execute(Box::new(action)) {
@ -1797,16 +1754,13 @@ impl EditorApp {
self.selection.add_clip_instance(id);
}
}
ClipboardContent::Shapes {
shapes,
instances,
} => {
ClipboardContent::Shapes { shapes } => {
let active_layer_id = match self.active_layer_id {
Some(id) => id,
None => return,
};
// Add shapes and instances to the active vector layer
// Add shapes to the active vector layer's keyframe
let document = self.action_executor.document_mut();
let layer = match document.get_layer_mut(&active_layer_id) {
Some(l) => l,
@ -1821,19 +1775,17 @@ impl EditorApp {
}
};
let new_instance_ids: Vec<Uuid> = instances.iter().map(|i| i.id).collect();
let new_shape_ids: Vec<Uuid> = shapes.iter().map(|s| s.id).collect();
for (id, shape) in shapes {
vector_layer.shapes.insert(id, shape);
}
for inst in instances {
vector_layer.shape_instances.push(inst);
let kf = vector_layer.ensure_keyframe_at(self.playback_time);
for shape in shapes {
kf.shapes.push(shape);
}
// Select pasted shapes
self.selection.clear_shape_instances();
for id in new_instance_ids {
self.selection.add_shape_instance(id);
self.selection.clear_shapes();
for id in new_shape_ids {
self.selection.add_shape(id);
}
}
}
@ -2268,8 +2220,26 @@ impl EditorApp {
// Modify menu
MenuAction::Group => {
println!("Menu: Group");
// TODO: Implement group
if let Some(layer_id) = self.active_layer_id {
let shape_ids: Vec<uuid::Uuid> = self.selection.shape_instances().to_vec();
let clip_ids: Vec<uuid::Uuid> = self.selection.clip_instances().to_vec();
if shape_ids.len() + clip_ids.len() >= 2 {
let instance_id = uuid::Uuid::new_v4();
let action = lightningbeam_core::actions::GroupAction::new(
layer_id,
self.playback_time,
shape_ids,
clip_ids,
instance_id,
);
if let Err(e) = self.action_executor.execute(Box::new(action)) {
eprintln!("Failed to group: {}", e);
} else {
self.selection.clear();
self.selection.add_clip_instance(instance_id);
}
}
}
}
MenuAction::SendToBack => {
println!("Menu: Send to Back");
@ -2397,7 +2367,6 @@ impl EditorApp {
use lightningbeam_core::clip::VectorClip;
use lightningbeam_core::layer::{VectorLayer, AnyLayer};
use lightningbeam_core::shape::{Shape, ShapeColor};
use lightningbeam_core::object::ShapeInstance;
use kurbo::{Circle, Rect, Shape as KurboShape};
// Generate unique name based on existing clip count
@ -2413,19 +2382,16 @@ impl EditorApp {
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 shapes to keyframe at time 0.0
let kf = layer.ensure_keyframe_at(0.0);
kf.shapes.push(circle_shape);
kf.shapes.push(rect_shape);
// Add the layer to the clip
test_clip.layers.add_root(AnyLayer::Vector(layer));
@ -2444,9 +2410,36 @@ impl EditorApp {
}
// Timeline menu
MenuAction::NewKeyframe => {
println!("Menu: New Keyframe");
// TODO: Implement new keyframe
MenuAction::NewKeyframe | MenuAction::AddKeyframeAtPlayhead => {
if let Some(layer_id) = self.active_layer_id {
let document = self.action_executor.document();
// Determine which selected objects are shape instances vs clip instances
let mut shape_ids = Vec::new();
let mut clip_ids = Vec::new();
if let Some(AnyLayer::Vector(vl)) = document.get_layer(&layer_id) {
for &id in self.selection.shape_instances() {
if vl.get_shape_in_keyframe(&id, self.playback_time).is_some() {
shape_ids.push(id);
}
}
for &id in self.selection.clip_instances() {
if vl.clip_instances.iter().any(|ci| ci.id == id) {
clip_ids.push(id);
}
}
}
// For vector layers, always create a shape keyframe (even without clip selection)
if document.get_layer(&layer_id).map_or(false, |l| matches!(l, AnyLayer::Vector(_))) || !clip_ids.is_empty() {
let action = lightningbeam_core::actions::SetKeyframeAction::new(
layer_id,
self.playback_time,
clip_ids,
);
if let Err(e) = self.action_executor.execute(Box::new(action)) {
eprintln!("Failed to set keyframe: {}", e);
}
}
}
}
MenuAction::NewBlankKeyframe => {
println!("Menu: New Blank Keyframe");
@ -2460,10 +2453,7 @@ impl EditorApp {
println!("Menu: Duplicate Keyframe");
// TODO: Implement duplicate keyframe
}
MenuAction::AddKeyframeAtPlayhead => {
println!("Menu: Add Keyframe at Playhead");
// TODO: Implement add keyframe at playhead
}
// AddKeyframeAtPlayhead handled above together with NewKeyframe
MenuAction::AddMotionTween => {
println!("Menu: Add Motion Tween");
// TODO: Implement add motion tween
@ -3312,16 +3302,14 @@ impl EditorApp {
use lightningbeam_core::shape::Shape;
let shape = Shape::new(path).with_image_fill(asset_info.clip_id);
// Create shape instance at document center
use lightningbeam_core::object::ShapeInstance;
let shape_instance = ShapeInstance::new(shape.id)
.with_position(center_x, center_y);
// Set position on shape directly
let shape = shape.with_position(center_x, center_y);
// Create and execute action
let action = lightningbeam_core::actions::AddShapeAction::new(
layer_id,
shape,
shape_instance,
self.playback_time,
);
let _ = self.action_executor.execute(Box::new(action));
} else {
@ -4446,6 +4434,25 @@ impl eframe::App for EditorApp {
// Empty cache fallback if generator not initialized
let empty_thumbnail_cache: HashMap<Uuid, Vec<u8>> = HashMap::new();
// Sync clip instance transforms from animation data at current playback time.
// This ensures selection boxes, hit testing, and interactive editing see the
// animated transform values, not just the base values on the ClipInstance struct.
{
let time = self.playback_time;
let document = self.action_executor.document_mut();
for layer in document.root.children.iter_mut() {
if let lightningbeam_core::layer::AnyLayer::Vector(vl) = layer {
for ci in &mut vl.clip_instances {
let (t, opacity) = vl.layer.animation_data.eval_clip_instance_transform(
ci.id, time, &ci.transform, ci.opacity,
);
ci.transform = t;
ci.opacity = opacity;
}
}
}
}
// Create render context
let mut ctx = RenderContext {
tool_icon_cache: &mut self.tool_icon_cache,

View File

@ -440,9 +440,8 @@ fn generate_vector_thumbnail(clip: &VectorClip, bg_color: egui::Color32) -> Vec<
// Iterate through layers and render shapes
for layer_node in clip.layers.iter() {
if let AnyLayer::Vector(vector_layer) = &layer_node.data {
// Render each shape instance
for shape_instance in &vector_layer.shape_instances {
if let Some(shape) = vector_layer.shapes.get(&shape_instance.shape_id) {
// Render each shape at time 0.0 (frame 0)
for shape in vector_layer.shapes_at_time(0.0) {
// Get the path (frame 0)
let kurbo_path = shape.path();
@ -522,7 +521,6 @@ fn generate_vector_thumbnail(clip: &VectorClip, bg_color: egui::Color32) -> Vec<
}
}
}
}
// Convert to RGBA bytes
let data = pixmap.data();

View File

@ -117,22 +117,21 @@ impl InfopanelPane {
let mut first = true;
for instance_id in &info.instance_ids {
if let Some(instance) = vector_layer.get_object(instance_id) {
info.shape_ids.push(instance.shape_id);
if let Some(shape) = vector_layer.get_shape_in_keyframe(instance_id, *shared.playback_time) {
info.shape_ids.push(*instance_id);
if first {
// First instance - set initial values
info.x = Some(instance.transform.x);
info.y = Some(instance.transform.y);
info.rotation = Some(instance.transform.rotation);
info.scale_x = Some(instance.transform.scale_x);
info.scale_y = Some(instance.transform.scale_y);
info.skew_x = Some(instance.transform.skew_x);
info.skew_y = Some(instance.transform.skew_y);
info.opacity = Some(instance.opacity);
// First shape - set initial values
info.x = Some(shape.transform.x);
info.y = Some(shape.transform.y);
info.rotation = Some(shape.transform.rotation);
info.scale_x = Some(shape.transform.scale_x);
info.scale_y = Some(shape.transform.scale_y);
info.skew_x = Some(shape.transform.skew_x);
info.skew_y = Some(shape.transform.skew_y);
info.opacity = Some(shape.opacity);
// Get shape properties
if let Some(shape) = vector_layer.shapes.get(&instance.shape_id) {
info.fill_color = Some(shape.fill_color);
info.stroke_color = Some(shape.stroke_color);
info.stroke_width = shape
@ -140,38 +139,36 @@ impl InfopanelPane {
.as_ref()
.map(|s| Some(s.width))
.unwrap_or(Some(1.0));
}
first = false;
} else {
// Check if values differ (set to None if mixed)
if info.x != Some(instance.transform.x) {
if info.x != Some(shape.transform.x) {
info.x = None;
}
if info.y != Some(instance.transform.y) {
if info.y != Some(shape.transform.y) {
info.y = None;
}
if info.rotation != Some(instance.transform.rotation) {
if info.rotation != Some(shape.transform.rotation) {
info.rotation = None;
}
if info.scale_x != Some(instance.transform.scale_x) {
if info.scale_x != Some(shape.transform.scale_x) {
info.scale_x = None;
}
if info.scale_y != Some(instance.transform.scale_y) {
if info.scale_y != Some(shape.transform.scale_y) {
info.scale_y = None;
}
if info.skew_x != Some(instance.transform.skew_x) {
if info.skew_x != Some(shape.transform.skew_x) {
info.skew_x = None;
}
if info.skew_y != Some(instance.transform.skew_y) {
if info.skew_y != Some(shape.transform.skew_y) {
info.skew_y = None;
}
if info.opacity != Some(instance.opacity) {
if info.opacity != Some(shape.opacity) {
info.opacity = None;
}
// Check shape properties
if let Some(shape) = vector_layer.shapes.get(&instance.shape_id) {
// Compare fill colors - set to None if mixed
if let Some(current_fill) = &info.fill_color {
if *current_fill != shape.fill_color {
@ -198,7 +195,6 @@ impl InfopanelPane {
}
}
}
}
info
}
@ -488,6 +484,7 @@ impl InfopanelPane {
for instance_id in instance_ids {
let action = SetInstancePropertiesAction::new(
layer_id,
*shared.playback_time,
*instance_id,
make_change(v),
);
@ -507,6 +504,7 @@ impl InfopanelPane {
for instance_id in instance_ids {
let action = SetInstancePropertiesAction::new(
layer_id,
*shared.playback_time,
*instance_id,
make_change(v),
);
@ -564,6 +562,7 @@ impl InfopanelPane {
let action = SetShapePropertiesAction::set_fill_color(
layer_id,
*shape_id,
*shared.playback_time,
new_color,
);
shared.pending_actions.push(Box::new(action));
@ -578,6 +577,7 @@ impl InfopanelPane {
let action = SetShapePropertiesAction::set_fill_color(
layer_id,
*shape_id,
*shared.playback_time,
default_fill,
);
shared.pending_actions.push(Box::new(action));
@ -612,6 +612,7 @@ impl InfopanelPane {
let action = SetShapePropertiesAction::set_stroke_color(
layer_id,
*shape_id,
*shared.playback_time,
new_color,
);
shared.pending_actions.push(Box::new(action));
@ -626,6 +627,7 @@ impl InfopanelPane {
let action = SetShapePropertiesAction::set_stroke_color(
layer_id,
*shape_id,
*shared.playback_time,
default_stroke,
);
shared.pending_actions.push(Box::new(action));
@ -654,6 +656,7 @@ impl InfopanelPane {
let action = SetShapePropertiesAction::set_stroke_width(
layer_id,
*shape_id,
*shared.playback_time,
width,
);
shared.pending_actions.push(Box::new(action));

File diff suppressed because it is too large Load Diff

View File

@ -18,6 +18,50 @@ const MIN_PIXELS_PER_SECOND: f32 = 1.0; // Allow zooming out to see 10+ minutes
const MAX_PIXELS_PER_SECOND: f32 = 500.0;
const EDGE_DETECTION_PIXELS: f32 = 8.0; // Distance from edge to detect trim handles
const LOOP_CORNER_SIZE: f32 = 12.0; // Size of loop corner hotzone at top-right of clip
const MIN_CLIP_WIDTH_PX: f32 = 8.0; // Minimum visible width for very short clips (e.g. groups)
/// Calculate vertical bounds for a clip instance within a layer row.
/// For vector layers with multiple clip instances, stacks them vertically.
/// Returns (y_min, y_max) relative to the layer top.
fn clip_instance_y_bounds(
layer: &AnyLayer,
clip_index: usize,
clip_count: usize,
) -> (f32, f32) {
if matches!(layer, AnyLayer::Vector(_)) && clip_count > 1 {
let usable_height = LAYER_HEIGHT - 20.0; // 10px padding top/bottom
let row_height = (usable_height / clip_count as f32).min(20.0);
let top = 10.0 + clip_index as f32 * row_height;
(top, top + row_height - 1.0)
} else {
(10.0, LAYER_HEIGHT - 10.0)
}
}
/// Get the effective clip duration for a clip instance on a given layer.
/// For groups on vector layers, the duration spans all consecutive keyframes
/// where the group is present. For regular clips, returns the clip's internal duration.
fn effective_clip_duration(
document: &lightningbeam_core::document::Document,
layer: &AnyLayer,
clip_instance: &ClipInstance,
) -> Option<f64> {
match layer {
AnyLayer::Vector(vl) => {
let vc = document.get_vector_clip(&clip_instance.clip_id)?;
if vc.is_group {
let frame_duration = 1.0 / document.framerate;
let end = vl.group_visibility_end(&clip_instance.id, clip_instance.timeline_start, frame_duration);
Some((end - clip_instance.timeline_start).max(0.0))
} else {
Some(vc.duration)
}
}
AnyLayer::Audio(_) => document.get_audio_clip(&clip_instance.clip_id).map(|c| c.duration),
AnyLayer::Video(_) => document.get_video_clip(&clip_instance.clip_id).map(|c| c.duration),
AnyLayer::Effect(_) => Some(lightningbeam_core::effect::EFFECT_DURATION),
}
}
/// Type of clip drag operation
#[derive(Debug, Clone, Copy, PartialEq)]
@ -304,7 +348,6 @@ impl TimelinePane {
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;
@ -324,34 +367,29 @@ impl TimelinePane {
};
// 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)
}
lightningbeam_core::layer::AnyLayer::Effect(_) => {
Some(lightningbeam_core::effect::EFFECT_DURATION)
}
}?;
let clip_count = clip_instances.len();
for (ci_idx, clip_instance) in clip_instances.iter().enumerate() {
let clip_duration = effective_clip_duration(document, layer, clip_instance)?;
let instance_start = clip_instance.effective_start();
let instance_duration = clip_instance.total_duration(clip_duration);
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 end_x = self.time_to_x(instance_end).max(start_x + MIN_CLIP_WIDTH_PX);
let mouse_x = pointer_pos.x - content_rect.min.x;
if mouse_x >= start_x && mouse_x <= end_x {
// Check vertical bounds for stacked vector layer clips
let layer_top = header_rect.min.y + (hovered_layer_index as f32 * LAYER_HEIGHT) - self.viewport_scroll_y;
let (cy_min, cy_max) = clip_instance_y_bounds(layer, ci_idx, clip_count);
let mouse_rel_y = pointer_pos.y - layer_top;
if mouse_rel_y < cy_min || mouse_rel_y > cy_max {
continue;
}
// Determine drag type based on edge proximity (check both sides of edge)
let is_audio_layer = matches!(layer, lightningbeam_core::layer::AnyLayer::Audio(_));
let layer_top = header_rect.min.y + (hovered_layer_index as f32 * LAYER_HEIGHT) - self.viewport_scroll_y;
let mouse_in_top_corner = pointer_pos.y < layer_top + LOOP_CORNER_SIZE;
let is_looping = clip_instance.timeline_duration.is_some() || clip_instance.loop_before.is_some();
@ -1047,25 +1085,10 @@ impl TimelinePane {
None
};
for clip_instance in clip_instances {
let clip_instance_count = clip_instances.len();
for (clip_instance_index, clip_instance) in clip_instances.iter().enumerate() {
// 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)
}
lightningbeam_core::layer::AnyLayer::Effect(_) => {
Some(lightningbeam_core::effect::EFFECT_DURATION)
}
};
let clip_duration = effective_clip_duration(document, layer, clip_instance);
if let Some(clip_duration) = clip_duration {
// Calculate effective duration accounting for trimming
@ -1245,7 +1268,7 @@ impl TimelinePane {
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);
let end_x = self.time_to_x(instance_end).max(start_x + MIN_CLIP_WIDTH_PX);
// Only draw if any part is visible in viewport
if end_x >= 0.0 && start_x <= rect.width() {
@ -1280,9 +1303,11 @@ impl TimelinePane {
),
};
let (cy_min, cy_max) = clip_instance_y_bounds(layer, clip_instance_index, clip_instance_count);
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),
egui::pos2(rect.min.x + visible_start_x, y + cy_min),
egui::pos2(rect.min.x + visible_end_x, y + cy_max),
);
// Draw the clip instance background(s)
@ -1639,6 +1664,31 @@ impl TimelinePane {
}
}
// Draw shape keyframe markers for vector layers
if let lightningbeam_core::layer::AnyLayer::Vector(vl) = layer {
for kf in &vl.keyframes {
let x = self.time_to_x(kf.time);
if x >= 0.0 && x <= rect.width() {
let cx = rect.min.x + x;
let cy = y + LAYER_HEIGHT - 8.0;
let size = 5.0;
// Draw diamond shape
let diamond = [
egui::pos2(cx, cy - size),
egui::pos2(cx + size, cy),
egui::pos2(cx, cy + size),
egui::pos2(cx - size, cy),
];
let color = egui::Color32::from_rgb(255, 220, 100);
painter.add(egui::Shape::convex_polygon(
diamond.to_vec(),
color,
egui::Stroke::new(1.0, egui::Color32::from_rgb(180, 150, 50)),
));
}
}
}
// Separator line at bottom
painter.line_segment(
[
@ -1709,8 +1759,6 @@ impl TimelinePane {
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();
@ -1726,33 +1774,25 @@ impl TimelinePane {
};
// 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)
}
lightningbeam_core::layer::AnyLayer::Effect(_) => {
Some(lightningbeam_core::effect::EFFECT_DURATION)
}
};
let click_clip_count = clip_instances.len();
let click_layer_top = pos.y - (relative_y % LAYER_HEIGHT);
for (ci_idx, clip_instance) in clip_instances.iter().enumerate() {
let clip_duration = effective_clip_duration(document, layer, clip_instance);
if let Some(clip_duration) = clip_duration {
let instance_duration = clip_instance.total_duration(clip_duration);
let instance_start = clip_instance.effective_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 {
// Check if click is within this clip instance's pixel range and vertical bounds
let ci_start_x = self.time_to_x(instance_start);
let ci_end_x = self.time_to_x(instance_end).max(ci_start_x + MIN_CLIP_WIDTH_PX);
let click_x = pos.x - content_rect.min.x;
let (cy_min, cy_max) = clip_instance_y_bounds(layer, ci_idx, click_clip_count);
let click_rel_y = pos.y - click_layer_top;
if click_x >= ci_start_x && click_x <= ci_end_x
&& click_rel_y >= cy_min && click_rel_y <= cy_max
{
// Found a clicked clip instance!
if shift_held {
// Shift+click: add to selection
@ -1919,27 +1959,7 @@ impl TimelinePane {
// 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)
}
lightningbeam_core::layer::AnyLayer::Effect(_) => {
Some(lightningbeam_core::effect::EFFECT_DURATION)
}
};
let clip_duration = effective_clip_duration(document, layer, clip_instance);
if let Some(clip_duration) = clip_duration {
match drag_type {
@ -2528,24 +2548,7 @@ impl PaneRenderer for TimelinePane {
};
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)
}
lightningbeam_core::layer::AnyLayer::Effect(_) => {
Some(lightningbeam_core::effect::EFFECT_DURATION)
}
};
let clip_duration = effective_clip_duration(document, layer, clip_instance);
if let Some(clip_duration) = clip_duration {
let instance_duration = clip_instance.effective_duration(clip_duration);