1280 lines
46 KiB
Rust
1280 lines
46 KiB
Rust
//! Document structure for Lightningbeam
|
|
//!
|
|
//! The Document represents a complete animation project with settings
|
|
//! and a root graphics object containing the scene graph.
|
|
|
|
use crate::asset_folder::AssetFolderTree;
|
|
use crate::clip::{AudioClip, ClipInstance, ImageAsset, VideoClip, VectorClip};
|
|
use crate::effect::EffectDefinition;
|
|
use crate::layer::AnyLayer;
|
|
use crate::script::ScriptDefinition;
|
|
use crate::layout::LayoutNode;
|
|
use crate::shape::ShapeColor;
|
|
use serde::{Deserialize, Serialize};
|
|
use std::collections::HashMap;
|
|
use uuid::Uuid;
|
|
|
|
/// Root graphics object containing all layers in the scene
|
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
|
pub struct GraphicsObject {
|
|
/// Unique identifier
|
|
pub id: Uuid,
|
|
|
|
/// Name of this graphics object
|
|
pub name: String,
|
|
|
|
/// Child layers
|
|
pub children: Vec<AnyLayer>,
|
|
}
|
|
|
|
impl GraphicsObject {
|
|
/// Create a new graphics object
|
|
pub fn new(name: impl Into<String>) -> Self {
|
|
Self {
|
|
id: Uuid::new_v4(),
|
|
name: name.into(),
|
|
children: Vec::new(),
|
|
}
|
|
}
|
|
|
|
/// Add a layer as a child
|
|
pub fn add_child(&mut self, layer: AnyLayer) -> Uuid {
|
|
let id = layer.id();
|
|
self.children.push(layer);
|
|
id
|
|
}
|
|
|
|
/// Get a child layer by ID (searches direct children and recurses into groups)
|
|
pub fn get_child(&self, id: &Uuid) -> Option<&AnyLayer> {
|
|
for layer in &self.children {
|
|
if &layer.id() == id {
|
|
return Some(layer);
|
|
}
|
|
if let AnyLayer::Group(group) = layer {
|
|
if let Some(found) = Self::find_in_group(&group.children, id) {
|
|
return Some(found);
|
|
}
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
/// Get a mutable child layer by ID (searches direct children and recurses into groups)
|
|
pub fn get_child_mut(&mut self, id: &Uuid) -> Option<&mut AnyLayer> {
|
|
for layer in &mut self.children {
|
|
if &layer.id() == id {
|
|
return Some(layer);
|
|
}
|
|
if let AnyLayer::Group(group) = layer {
|
|
if let Some(found) = Self::find_in_group_mut(&mut group.children, id) {
|
|
return Some(found);
|
|
}
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
fn find_in_group<'a>(children: &'a [AnyLayer], id: &Uuid) -> Option<&'a AnyLayer> {
|
|
for child in children {
|
|
if &child.id() == id {
|
|
return Some(child);
|
|
}
|
|
if let AnyLayer::Group(group) = child {
|
|
if let Some(found) = Self::find_in_group(&group.children, id) {
|
|
return Some(found);
|
|
}
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
fn find_in_group_mut<'a>(children: &'a mut [AnyLayer], id: &Uuid) -> Option<&'a mut AnyLayer> {
|
|
for child in children {
|
|
if &child.id() == id {
|
|
return Some(child);
|
|
}
|
|
if let AnyLayer::Group(group) = child {
|
|
if let Some(found) = Self::find_in_group_mut(&mut group.children, id) {
|
|
return Some(found);
|
|
}
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
/// Remove a child layer by ID
|
|
pub fn remove_child(&mut self, id: &Uuid) -> Option<AnyLayer> {
|
|
if let Some(index) = self.children.iter().position(|l| &l.id() == id) {
|
|
Some(self.children.remove(index))
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Default for GraphicsObject {
|
|
fn default() -> Self {
|
|
Self::new("Root")
|
|
}
|
|
}
|
|
|
|
/// Musical time signature
|
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
|
pub struct TimeSignature {
|
|
pub numerator: u32, // beats per measure (e.g., 4)
|
|
pub denominator: u32, // beat unit (e.g., 4 = quarter note)
|
|
}
|
|
|
|
impl Default for TimeSignature {
|
|
fn default() -> Self {
|
|
Self { numerator: 4, denominator: 4 }
|
|
}
|
|
}
|
|
|
|
fn default_bpm() -> f64 { 120.0 }
|
|
|
|
/// Asset category for folder tree access
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum AssetCategory {
|
|
Vector,
|
|
Video,
|
|
Audio,
|
|
Images,
|
|
Effects,
|
|
}
|
|
|
|
/// Document settings and scene
|
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
|
pub struct Document {
|
|
/// Unique identifier for this document
|
|
pub id: Uuid,
|
|
|
|
/// Document name
|
|
pub name: String,
|
|
|
|
/// Background color
|
|
pub background_color: ShapeColor,
|
|
|
|
/// Canvas width in pixels
|
|
pub width: f64,
|
|
|
|
/// Canvas height in pixels
|
|
pub height: f64,
|
|
|
|
/// Framerate (frames per second)
|
|
pub framerate: f64,
|
|
|
|
/// Tempo in beats per minute
|
|
#[serde(default = "default_bpm")]
|
|
pub bpm: f64,
|
|
|
|
/// Time signature
|
|
#[serde(default)]
|
|
pub time_signature: TimeSignature,
|
|
|
|
/// Duration in seconds
|
|
pub duration: f64,
|
|
|
|
/// Root graphics object containing all layers
|
|
pub root: GraphicsObject,
|
|
|
|
/// Clip libraries - reusable clip definitions
|
|
/// VectorClips can be instantiated multiple times with different transforms/timing
|
|
pub vector_clips: HashMap<Uuid, VectorClip>,
|
|
|
|
/// Video clip library - references to video files
|
|
pub video_clips: HashMap<Uuid, VideoClip>,
|
|
|
|
/// Audio clip library - sampled audio and MIDI clips
|
|
pub audio_clips: HashMap<Uuid, AudioClip>,
|
|
|
|
/// Image asset library - static images for fill textures
|
|
pub image_assets: HashMap<Uuid, ImageAsset>,
|
|
|
|
/// Instance groups for linked clip instances
|
|
pub instance_groups: HashMap<Uuid, crate::instance_group::InstanceGroup>,
|
|
|
|
/// Effect definitions (all effects are embedded in the document)
|
|
#[serde(default)]
|
|
pub effect_definitions: HashMap<Uuid, EffectDefinition>,
|
|
|
|
/// Folder organization for vector clips
|
|
#[serde(default)]
|
|
pub vector_folders: AssetFolderTree,
|
|
|
|
/// Folder organization for video clips
|
|
#[serde(default)]
|
|
pub video_folders: AssetFolderTree,
|
|
|
|
/// Folder organization for audio clips
|
|
#[serde(default)]
|
|
pub audio_folders: AssetFolderTree,
|
|
|
|
/// Folder organization for image assets
|
|
#[serde(default)]
|
|
pub image_folders: AssetFolderTree,
|
|
|
|
/// Folder organization for effect definitions
|
|
#[serde(default)]
|
|
pub effect_folders: AssetFolderTree,
|
|
|
|
/// BeamDSP script definitions (audio DSP scripts for node graph)
|
|
#[serde(default)]
|
|
pub script_definitions: HashMap<Uuid, ScriptDefinition>,
|
|
|
|
/// Folder organization for script definitions
|
|
#[serde(default)]
|
|
pub script_folders: AssetFolderTree,
|
|
|
|
/// Current UI layout state (serialized for save/load)
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub ui_layout: Option<LayoutNode>,
|
|
|
|
/// Name of base layout this was derived from (for reference only)
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub ui_layout_base: Option<String>,
|
|
|
|
/// Current playback time in seconds
|
|
#[serde(skip)]
|
|
pub current_time: f64,
|
|
|
|
/// Reverse lookup: layer_id → clip_id for layers inside vector clips.
|
|
/// Enables O(1) lookup in get_layer/get_layer_mut instead of scanning all clips.
|
|
#[serde(skip)]
|
|
pub layer_to_clip_map: HashMap<Uuid, Uuid>,
|
|
}
|
|
|
|
impl Default for Document {
|
|
fn default() -> Self {
|
|
Self {
|
|
id: Uuid::new_v4(),
|
|
name: "Untitled".to_string(),
|
|
background_color: ShapeColor::rgb(255, 255, 255), // White background
|
|
width: 1920.0,
|
|
height: 1080.0,
|
|
framerate: 60.0,
|
|
bpm: 120.0,
|
|
time_signature: TimeSignature::default(),
|
|
duration: 10.0,
|
|
root: GraphicsObject::default(),
|
|
vector_clips: HashMap::new(),
|
|
video_clips: HashMap::new(),
|
|
audio_clips: HashMap::new(),
|
|
image_assets: HashMap::new(),
|
|
instance_groups: HashMap::new(),
|
|
effect_definitions: HashMap::new(),
|
|
vector_folders: AssetFolderTree::new(),
|
|
video_folders: AssetFolderTree::new(),
|
|
audio_folders: AssetFolderTree::new(),
|
|
image_folders: AssetFolderTree::new(),
|
|
effect_folders: AssetFolderTree::new(),
|
|
script_definitions: HashMap::new(),
|
|
script_folders: AssetFolderTree::new(),
|
|
ui_layout: None,
|
|
ui_layout_base: None,
|
|
current_time: 0.0,
|
|
layer_to_clip_map: HashMap::new(),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Document {
|
|
/// Create a new document with default settings
|
|
pub fn new(name: impl Into<String>) -> Self {
|
|
Self {
|
|
name: name.into(),
|
|
..Default::default()
|
|
}
|
|
}
|
|
|
|
/// Create a document with custom dimensions
|
|
pub fn with_size(name: impl Into<String>, width: f64, height: f64) -> Self {
|
|
Self {
|
|
name: name.into(),
|
|
width,
|
|
height,
|
|
..Default::default()
|
|
}
|
|
}
|
|
|
|
/// Rebuild the layer→clip reverse lookup map from all vector clips.
|
|
/// Call after deserialization or bulk clip modifications.
|
|
pub fn rebuild_layer_to_clip_map(&mut self) {
|
|
self.layer_to_clip_map.clear();
|
|
for (clip_id, clip) in &self.vector_clips {
|
|
for node in &clip.layers.roots {
|
|
self.layer_to_clip_map.insert(node.data.id(), *clip_id);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Register a layer as belonging to a clip (for O(1) lookup).
|
|
pub fn register_layer_in_clip(&mut self, layer_id: Uuid, clip_id: Uuid) {
|
|
self.layer_to_clip_map.insert(layer_id, clip_id);
|
|
}
|
|
|
|
/// Unregister a layer from the clip lookup map.
|
|
pub fn unregister_layer_from_clip(&mut self, layer_id: &Uuid) {
|
|
self.layer_to_clip_map.remove(layer_id);
|
|
}
|
|
|
|
/// Set the background color
|
|
pub fn with_background(mut self, color: ShapeColor) -> Self {
|
|
self.background_color = color;
|
|
self
|
|
}
|
|
|
|
/// Set the framerate
|
|
pub fn with_framerate(mut self, framerate: f64) -> Self {
|
|
self.framerate = framerate;
|
|
self
|
|
}
|
|
|
|
/// Set the duration
|
|
pub fn with_duration(mut self, duration: f64) -> Self {
|
|
self.duration = duration;
|
|
self
|
|
}
|
|
|
|
/// Get the aspect ratio
|
|
pub fn aspect_ratio(&self) -> f64 {
|
|
self.width / self.height
|
|
}
|
|
|
|
/// Get the folder tree for a specific asset category
|
|
pub fn get_folder_tree(&self, category: AssetCategory) -> &AssetFolderTree {
|
|
match category {
|
|
AssetCategory::Vector => &self.vector_folders,
|
|
AssetCategory::Video => &self.video_folders,
|
|
AssetCategory::Audio => &self.audio_folders,
|
|
AssetCategory::Images => &self.image_folders,
|
|
AssetCategory::Effects => &self.effect_folders,
|
|
}
|
|
}
|
|
|
|
/// Get a mutable reference to the folder tree for a specific asset category
|
|
pub fn get_folder_tree_mut(&mut self, category: AssetCategory) -> &mut AssetFolderTree {
|
|
match category {
|
|
AssetCategory::Vector => &mut self.vector_folders,
|
|
AssetCategory::Video => &mut self.video_folders,
|
|
AssetCategory::Audio => &mut self.audio_folders,
|
|
AssetCategory::Images => &mut self.image_folders,
|
|
AssetCategory::Effects => &mut self.effect_folders,
|
|
}
|
|
}
|
|
|
|
/// Calculate the actual timeline endpoint based on the last clip
|
|
///
|
|
/// Returns the end time of the last clip instance across all layers,
|
|
/// or the document's duration if no clips are found.
|
|
pub fn calculate_timeline_endpoint(&self) -> f64 {
|
|
let mut max_end_time: f64 = 0.0;
|
|
|
|
// Helper function to calculate the end time of a clip instance
|
|
let calculate_instance_end = |instance: &ClipInstance, clip_duration: f64| -> f64 {
|
|
let effective_duration = if let Some(timeline_duration) = instance.timeline_duration {
|
|
// Explicit timeline duration set (may include looping)
|
|
timeline_duration
|
|
} else {
|
|
// Calculate from trim points
|
|
let trim_end = instance.trim_end.unwrap_or(clip_duration);
|
|
let trimmed_duration = trim_end - instance.trim_start;
|
|
trimmed_duration / instance.playback_speed // Adjust for playback speed
|
|
};
|
|
instance.timeline_start + effective_duration
|
|
};
|
|
|
|
// Iterate through all layers to find the maximum end time
|
|
for layer in &self.root.children {
|
|
match layer {
|
|
crate::layer::AnyLayer::Vector(vector_layer) => {
|
|
for instance in &vector_layer.clip_instances {
|
|
if let Some(clip) = self.vector_clips.get(&instance.clip_id) {
|
|
let end_time = calculate_instance_end(instance, clip.duration);
|
|
max_end_time = max_end_time.max(end_time);
|
|
}
|
|
}
|
|
}
|
|
crate::layer::AnyLayer::Audio(audio_layer) => {
|
|
for instance in &audio_layer.clip_instances {
|
|
if let Some(clip) = self.audio_clips.get(&instance.clip_id) {
|
|
let end_time = calculate_instance_end(instance, clip.duration);
|
|
max_end_time = max_end_time.max(end_time);
|
|
}
|
|
}
|
|
}
|
|
crate::layer::AnyLayer::Video(video_layer) => {
|
|
for instance in &video_layer.clip_instances {
|
|
if let Some(clip) = self.video_clips.get(&instance.clip_id) {
|
|
let end_time = calculate_instance_end(instance, clip.duration);
|
|
max_end_time = max_end_time.max(end_time);
|
|
}
|
|
}
|
|
}
|
|
crate::layer::AnyLayer::Effect(effect_layer) => {
|
|
for instance in &effect_layer.clip_instances {
|
|
if let Some(clip_duration) = self.get_clip_duration(&instance.clip_id) {
|
|
let end_time = calculate_instance_end(instance, clip_duration);
|
|
max_end_time = max_end_time.max(end_time);
|
|
}
|
|
}
|
|
}
|
|
crate::layer::AnyLayer::Raster(_) => {
|
|
// Raster layers don't have clip instances
|
|
}
|
|
crate::layer::AnyLayer::Group(group) => {
|
|
// Recurse into group children to find their clip instance endpoints
|
|
fn process_group_children(
|
|
children: &[crate::layer::AnyLayer],
|
|
doc: &Document,
|
|
max_end: &mut f64,
|
|
calc_end: &dyn Fn(&ClipInstance, f64) -> f64,
|
|
) {
|
|
for child in children {
|
|
match child {
|
|
crate::layer::AnyLayer::Vector(vl) => {
|
|
for inst in &vl.clip_instances {
|
|
if let Some(clip) = doc.vector_clips.get(&inst.clip_id) {
|
|
*max_end = max_end.max(calc_end(inst, clip.duration));
|
|
}
|
|
}
|
|
}
|
|
crate::layer::AnyLayer::Audio(al) => {
|
|
for inst in &al.clip_instances {
|
|
if let Some(clip) = doc.audio_clips.get(&inst.clip_id) {
|
|
*max_end = max_end.max(calc_end(inst, clip.duration));
|
|
}
|
|
}
|
|
}
|
|
crate::layer::AnyLayer::Video(vl) => {
|
|
for inst in &vl.clip_instances {
|
|
if let Some(clip) = doc.video_clips.get(&inst.clip_id) {
|
|
*max_end = max_end.max(calc_end(inst, clip.duration));
|
|
}
|
|
}
|
|
}
|
|
crate::layer::AnyLayer::Effect(el) => {
|
|
for inst in &el.clip_instances {
|
|
if let Some(dur) = doc.get_clip_duration(&inst.clip_id) {
|
|
*max_end = max_end.max(calc_end(inst, dur));
|
|
}
|
|
}
|
|
}
|
|
crate::layer::AnyLayer::Raster(_) => {
|
|
// Raster layers don't have clip instances
|
|
}
|
|
crate::layer::AnyLayer::Group(g) => {
|
|
process_group_children(&g.children, doc, max_end, calc_end);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
process_group_children(&group.children, self, &mut max_end_time, &calculate_instance_end);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Return the maximum end time, or document duration if no clips found
|
|
if max_end_time > 0.0 {
|
|
max_end_time
|
|
} else {
|
|
self.duration
|
|
}
|
|
}
|
|
|
|
/// Set the current playback time
|
|
pub fn set_time(&mut self, time: f64) {
|
|
self.current_time = time.max(0.0).min(self.duration);
|
|
}
|
|
|
|
/// Get visible layers from the root graphics object
|
|
pub fn visible_layers(&self) -> impl Iterator<Item = &AnyLayer> {
|
|
self.root
|
|
.children
|
|
.iter()
|
|
.filter(|layer| layer.layer().visible)
|
|
}
|
|
|
|
/// Get visible layers for the current editing context
|
|
pub fn context_visible_layers(&self, clip_id: Option<&Uuid>) -> Vec<&AnyLayer> {
|
|
self.context_layers(clip_id)
|
|
.into_iter()
|
|
.filter(|layer| layer.layer().visible)
|
|
.collect()
|
|
}
|
|
|
|
/// Get a layer by ID (searches root layers, then clip layers via O(1) map lookup)
|
|
pub fn get_layer(&self, id: &Uuid) -> Option<&AnyLayer> {
|
|
// First check root layers
|
|
if let Some(layer) = self.root.get_child(id) {
|
|
return Some(layer);
|
|
}
|
|
// O(1) lookup: check if this layer belongs to a clip
|
|
if let Some(clip_id) = self.layer_to_clip_map.get(id) {
|
|
if let Some(clip) = self.vector_clips.get(clip_id) {
|
|
for node in &clip.layers.roots {
|
|
if &node.data.id() == id {
|
|
return Some(&node.data);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
// === MUTATION METHODS (pub(crate) - only accessible to action module) ===
|
|
|
|
/// Get mutable access to the root graphics object
|
|
///
|
|
/// This method is intentionally `pub(crate)` to ensure mutations
|
|
/// only happen through the action system.
|
|
pub(crate) fn root_mut(&mut self) -> &mut GraphicsObject {
|
|
&mut self.root
|
|
}
|
|
|
|
/// Get mutable access to a layer by ID (searches root layers, then clip layers via O(1) map lookup)
|
|
///
|
|
/// This method is intentionally `pub(crate)` to ensure mutations
|
|
/// only happen through the action system.
|
|
pub fn get_layer_mut(&mut self, id: &Uuid) -> Option<&mut AnyLayer> {
|
|
// First check root layers
|
|
if self.root.get_child(id).is_some() {
|
|
return self.root.get_child_mut(id);
|
|
}
|
|
// O(1) lookup: check if this layer belongs to a clip
|
|
if let Some(clip_id) = self.layer_to_clip_map.get(id).copied() {
|
|
if let Some(clip) = self.vector_clips.get_mut(&clip_id) {
|
|
for node in &mut clip.layers.roots {
|
|
if &node.data.id() == id {
|
|
return Some(&mut node.data);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
// === EDITING CONTEXT METHODS ===
|
|
|
|
/// Get the layers for the current editing context.
|
|
/// When `clip_id` is None, returns root layers. When Some, returns the clip's layers.
|
|
pub fn context_layers(&self, clip_id: Option<&Uuid>) -> Vec<&AnyLayer> {
|
|
match clip_id {
|
|
None => self.root.children.iter().collect(),
|
|
Some(id) => self.vector_clips.get(id)
|
|
.map(|clip| clip.layers.root_data())
|
|
.unwrap_or_default(),
|
|
}
|
|
}
|
|
|
|
/// Get mutable layers for the current editing context.
|
|
pub fn context_layers_mut(&mut self, clip_id: Option<&Uuid>) -> Vec<&mut AnyLayer> {
|
|
match clip_id {
|
|
None => self.root.children.iter_mut().collect(),
|
|
Some(id) => self.vector_clips.get_mut(id)
|
|
.map(|clip| clip.layers.root_data_mut())
|
|
.unwrap_or_default(),
|
|
}
|
|
}
|
|
|
|
/// Look up a layer by ID within an editing context.
|
|
pub fn get_layer_in_context(&self, clip_id: Option<&Uuid>, layer_id: &Uuid) -> Option<&AnyLayer> {
|
|
self.context_layers(clip_id).into_iter().find(|l| &l.id() == layer_id)
|
|
}
|
|
|
|
/// Look up a mutable layer by ID within an editing context.
|
|
pub fn get_layer_in_context_mut(&mut self, clip_id: Option<&Uuid>, layer_id: &Uuid) -> Option<&mut AnyLayer> {
|
|
self.context_layers_mut(clip_id).into_iter().find(|l| &l.id() == layer_id)
|
|
}
|
|
|
|
/// Get all layers across the entire document (root + inside all vector clips).
|
|
pub fn all_layers(&self) -> Vec<&AnyLayer> {
|
|
let mut layers: Vec<&AnyLayer> = Vec::new();
|
|
fn collect_layers<'a>(list: &'a [AnyLayer], out: &mut Vec<&'a AnyLayer>) {
|
|
for layer in list {
|
|
out.push(layer);
|
|
if let AnyLayer::Group(g) = layer {
|
|
collect_layers(&g.children, out);
|
|
}
|
|
}
|
|
}
|
|
collect_layers(&self.root.children, &mut layers);
|
|
for clip in self.vector_clips.values() {
|
|
layers.extend(clip.layers.root_data());
|
|
}
|
|
layers
|
|
}
|
|
|
|
// === CLIP LIBRARY METHODS ===
|
|
|
|
/// Add a vector clip to the library
|
|
pub fn add_vector_clip(&mut self, clip: VectorClip) -> Uuid {
|
|
let id = clip.id;
|
|
// Register all layers in the clip for O(1) reverse lookup
|
|
for node in &clip.layers.roots {
|
|
self.layer_to_clip_map.insert(node.data.id(), id);
|
|
}
|
|
self.vector_clips.insert(id, clip);
|
|
id
|
|
}
|
|
|
|
/// Add a video clip to the library
|
|
pub fn add_video_clip(&mut self, clip: VideoClip) -> Uuid {
|
|
let id = clip.id;
|
|
self.video_clips.insert(id, clip);
|
|
id
|
|
}
|
|
|
|
/// Add an audio clip to the library
|
|
pub fn add_audio_clip(&mut self, clip: AudioClip) -> Uuid {
|
|
let id = clip.id;
|
|
self.audio_clips.insert(id, clip);
|
|
id
|
|
}
|
|
|
|
/// Add an instance group to the document
|
|
pub fn add_instance_group(&mut self, group: crate::instance_group::InstanceGroup) -> Uuid {
|
|
let id = group.id;
|
|
self.instance_groups.insert(id, group);
|
|
id
|
|
}
|
|
|
|
/// Remove an instance group from the document
|
|
pub fn remove_instance_group(&mut self, group_id: &Uuid) {
|
|
self.instance_groups.remove(group_id);
|
|
}
|
|
|
|
/// Find the group that contains a specific clip instance
|
|
pub fn find_group_for_instance(&self, instance_id: &Uuid) -> Option<&crate::instance_group::InstanceGroup> {
|
|
self.instance_groups.values()
|
|
.find(|group| group.contains_instance(instance_id))
|
|
}
|
|
|
|
/// Get a vector clip by ID
|
|
pub fn get_vector_clip(&self, id: &Uuid) -> Option<&VectorClip> {
|
|
self.vector_clips.get(id)
|
|
}
|
|
|
|
/// Get a video clip by ID
|
|
pub fn get_video_clip(&self, id: &Uuid) -> Option<&VideoClip> {
|
|
self.video_clips.get(id)
|
|
}
|
|
|
|
/// Get an audio clip by ID
|
|
pub fn get_audio_clip(&self, id: &Uuid) -> Option<&AudioClip> {
|
|
self.audio_clips.get(id)
|
|
}
|
|
|
|
/// Find the document audio clip (UUID + ref) that owns the given backend pool index.
|
|
pub fn audio_clip_by_pool_index(&self, pool_index: usize) -> Option<(Uuid, &AudioClip)> {
|
|
self.audio_clips.iter()
|
|
.find(|(_, c)| c.audio_pool_index() == Some(pool_index))
|
|
.map(|(&id, c)| (id, c))
|
|
}
|
|
|
|
/// Find the document audio clip (UUID + ref) that owns the given backend MIDI clip ID.
|
|
pub fn audio_clip_by_midi_clip_id(&self, midi_clip_id: u32) -> Option<(Uuid, &AudioClip)> {
|
|
self.audio_clips.iter()
|
|
.find(|(_, c)| c.midi_clip_id() == Some(midi_clip_id))
|
|
.map(|(&id, c)| (id, c))
|
|
}
|
|
|
|
/// Get a mutable vector clip by ID
|
|
pub fn get_vector_clip_mut(&mut self, id: &Uuid) -> Option<&mut VectorClip> {
|
|
self.vector_clips.get_mut(id)
|
|
}
|
|
|
|
/// Get a mutable video clip by ID
|
|
pub fn get_video_clip_mut(&mut self, id: &Uuid) -> Option<&mut VideoClip> {
|
|
self.video_clips.get_mut(id)
|
|
}
|
|
|
|
/// Get a mutable audio clip by ID
|
|
pub fn get_audio_clip_mut(&mut self, id: &Uuid) -> Option<&mut AudioClip> {
|
|
self.audio_clips.get_mut(id)
|
|
}
|
|
|
|
/// Remove a vector clip from the library
|
|
pub fn remove_vector_clip(&mut self, id: &Uuid) -> Option<VectorClip> {
|
|
if let Some(clip) = self.vector_clips.remove(id) {
|
|
// Unregister all layers from the reverse lookup map
|
|
for node in &clip.layers.roots {
|
|
self.layer_to_clip_map.remove(&node.data.id());
|
|
}
|
|
Some(clip)
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
|
|
/// Remove a video clip from the library
|
|
pub fn remove_video_clip(&mut self, id: &Uuid) -> Option<VideoClip> {
|
|
self.video_clips.remove(id)
|
|
}
|
|
|
|
/// Remove an audio clip from the library
|
|
pub fn remove_audio_clip(&mut self, id: &Uuid) -> Option<AudioClip> {
|
|
self.audio_clips.remove(id)
|
|
}
|
|
|
|
// === IMAGE ASSET METHODS ===
|
|
|
|
/// Add an image asset to the library
|
|
pub fn add_image_asset(&mut self, asset: ImageAsset) -> Uuid {
|
|
let id = asset.id;
|
|
self.image_assets.insert(id, asset);
|
|
id
|
|
}
|
|
|
|
/// Get an image asset by ID
|
|
pub fn get_image_asset(&self, id: &Uuid) -> Option<&ImageAsset> {
|
|
self.image_assets.get(id)
|
|
}
|
|
|
|
/// Get a mutable image asset by ID
|
|
pub fn get_image_asset_mut(&mut self, id: &Uuid) -> Option<&mut ImageAsset> {
|
|
self.image_assets.get_mut(id)
|
|
}
|
|
|
|
/// Remove an image asset from the library
|
|
pub fn remove_image_asset(&mut self, id: &Uuid) -> Option<ImageAsset> {
|
|
self.image_assets.remove(id)
|
|
}
|
|
|
|
// === EFFECT DEFINITION METHODS ===
|
|
|
|
/// Add an effect definition to the document
|
|
pub fn add_effect_definition(&mut self, definition: EffectDefinition) -> Uuid {
|
|
let id = definition.id;
|
|
self.effect_definitions.insert(id, definition);
|
|
id
|
|
}
|
|
|
|
/// Get an effect definition by ID
|
|
pub fn get_effect_definition(&self, id: &Uuid) -> Option<&EffectDefinition> {
|
|
self.effect_definitions.get(id)
|
|
}
|
|
|
|
/// Get a mutable effect definition by ID
|
|
pub fn get_effect_definition_mut(&mut self, id: &Uuid) -> Option<&mut EffectDefinition> {
|
|
self.effect_definitions.get_mut(id)
|
|
}
|
|
|
|
/// Remove an effect definition from the document
|
|
pub fn remove_effect_definition(&mut self, id: &Uuid) -> Option<EffectDefinition> {
|
|
self.effect_definitions.remove(id)
|
|
}
|
|
|
|
/// Get all effect definitions
|
|
pub fn effect_definitions(&self) -> impl Iterator<Item = &EffectDefinition> {
|
|
self.effect_definitions.values()
|
|
}
|
|
|
|
// === SCRIPT DEFINITION METHODS ===
|
|
|
|
pub fn add_script_definition(&mut self, definition: ScriptDefinition) -> Uuid {
|
|
let id = definition.id;
|
|
self.script_definitions.insert(id, definition);
|
|
id
|
|
}
|
|
|
|
pub fn get_script_definition(&self, id: &Uuid) -> Option<&ScriptDefinition> {
|
|
self.script_definitions.get(id)
|
|
}
|
|
|
|
pub fn get_script_definition_mut(&mut self, id: &Uuid) -> Option<&mut ScriptDefinition> {
|
|
self.script_definitions.get_mut(id)
|
|
}
|
|
|
|
pub fn script_definitions(&self) -> impl Iterator<Item = &ScriptDefinition> {
|
|
self.script_definitions.values()
|
|
}
|
|
|
|
// === CLIP OVERLAP DETECTION METHODS ===
|
|
|
|
/// Get the duration of any clip type by ID
|
|
///
|
|
/// Searches through all clip libraries to find the clip and return its duration.
|
|
/// For effect definitions, returns `EFFECT_DURATION` (f64::MAX) since effects
|
|
/// have infinite internal duration.
|
|
pub fn get_clip_duration(&self, clip_id: &Uuid) -> Option<f64> {
|
|
if let Some(clip) = self.vector_clips.get(clip_id) {
|
|
if clip.is_group {
|
|
Some(clip.duration)
|
|
} else {
|
|
Some(clip.content_duration_with(self.framerate, |id| {
|
|
// Resolve nested clip durations (audio, video, other vector clips)
|
|
if let Some(vc) = self.vector_clips.get(id) {
|
|
// Avoid deep recursion — use stored duration for nested vector clips
|
|
Some(vc.content_duration(self.framerate))
|
|
} else if let Some(ac) = self.audio_clips.get(id) {
|
|
Some(ac.duration)
|
|
} else if let Some(vc) = self.video_clips.get(id) {
|
|
Some(vc.duration)
|
|
} else if self.effect_definitions.contains_key(id) {
|
|
Some(crate::effect::EFFECT_DURATION)
|
|
} else {
|
|
None
|
|
}
|
|
}))
|
|
}
|
|
} else if let Some(clip) = self.video_clips.get(clip_id) {
|
|
Some(clip.duration)
|
|
} else if let Some(clip) = self.audio_clips.get(clip_id) {
|
|
Some(clip.duration)
|
|
} else if self.effect_definitions.contains_key(clip_id) {
|
|
// Effects have infinite internal duration - their timeline length
|
|
// is controlled by ClipInstance.trim_end
|
|
Some(crate::effect::EFFECT_DURATION)
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
|
|
/// Calculate the end time of a clip instance on the timeline
|
|
pub fn get_clip_instance_end_time(&self, layer_id: &Uuid, instance_id: &Uuid) -> Option<f64> {
|
|
let layer = self.get_layer(layer_id)?;
|
|
|
|
// Find the clip instance
|
|
let instances: &[ClipInstance] = match layer {
|
|
AnyLayer::Audio(audio) => &audio.clip_instances,
|
|
AnyLayer::Video(video) => &video.clip_instances,
|
|
AnyLayer::Vector(vector) => &vector.clip_instances,
|
|
AnyLayer::Effect(effect) => &effect.clip_instances,
|
|
AnyLayer::Group(_) => &[],
|
|
AnyLayer::Raster(_) => &[],
|
|
};
|
|
|
|
let instance = instances.iter().find(|inst| &inst.id == instance_id)?;
|
|
let clip_duration = self.get_clip_duration(&instance.clip_id)?;
|
|
|
|
let trim_start = instance.trim_start;
|
|
let trim_end = instance.trim_end.unwrap_or(clip_duration);
|
|
let effective_duration = trim_end - trim_start;
|
|
|
|
Some(instance.timeline_start + effective_duration)
|
|
}
|
|
|
|
/// Check if a time range overlaps with any existing clip on the layer
|
|
///
|
|
/// Returns (overlaps, conflicting_instance_id)
|
|
///
|
|
/// Only checks audio, video, and effect layers - vector/MIDI layers return false
|
|
pub fn check_overlap_on_layer(
|
|
&self,
|
|
layer_id: &Uuid,
|
|
start_time: f64,
|
|
end_time: f64,
|
|
exclude_instance_ids: &[Uuid],
|
|
) -> (bool, Option<Uuid>) {
|
|
let Some(layer) = self.get_layer(layer_id) else {
|
|
return (false, None);
|
|
};
|
|
|
|
// Check audio, video, and effect layers (effects cannot overlap on same layer)
|
|
if !matches!(layer, AnyLayer::Audio(_) | AnyLayer::Video(_) | AnyLayer::Effect(_)) {
|
|
return (false, None);
|
|
}
|
|
|
|
let instances: &[ClipInstance] = match layer {
|
|
AnyLayer::Audio(audio) => &audio.clip_instances,
|
|
AnyLayer::Video(video) => &video.clip_instances,
|
|
AnyLayer::Vector(vector) => &vector.clip_instances,
|
|
AnyLayer::Effect(effect) => &effect.clip_instances,
|
|
AnyLayer::Group(_) => &[],
|
|
AnyLayer::Raster(_) => &[],
|
|
};
|
|
|
|
for instance in instances {
|
|
// Skip excluded instances
|
|
if exclude_instance_ids.contains(&instance.id) {
|
|
continue;
|
|
}
|
|
|
|
// Calculate instance extent (accounting for loop_before)
|
|
let Some(clip_duration) = self.get_clip_duration(&instance.clip_id) else {
|
|
continue;
|
|
};
|
|
|
|
let instance_start = instance.effective_start();
|
|
let instance_end = instance.timeline_start + instance.effective_duration(clip_duration);
|
|
|
|
// Check overlap: start_a < end_b AND start_b < end_a
|
|
if start_time < instance_end && instance_start < end_time {
|
|
return (true, Some(instance.id));
|
|
}
|
|
}
|
|
|
|
(false, None)
|
|
}
|
|
|
|
/// Find the nearest valid position for a clip on a layer to avoid overlaps
|
|
///
|
|
/// Returns adjusted timeline_start, or None if no valid position exists
|
|
///
|
|
/// Strategy: Snaps to whichever side (left or right) is closest to the desired position
|
|
pub fn find_nearest_valid_position(
|
|
&self,
|
|
layer_id: &Uuid,
|
|
desired_start: f64,
|
|
clip_duration: f64,
|
|
exclude_instance_ids: &[Uuid],
|
|
) -> Option<f64> {
|
|
let layer = self.get_layer(layer_id)?;
|
|
|
|
// Clamp to timeline start (can't go before 0)
|
|
let desired_start = desired_start.max(0.0);
|
|
|
|
// Vector layers don't need overlap adjustment, but still respect timeline start
|
|
if matches!(layer, AnyLayer::Vector(_) | AnyLayer::Group(_)) {
|
|
return Some(desired_start);
|
|
}
|
|
|
|
// Check if desired position is already valid
|
|
let desired_end = desired_start + clip_duration;
|
|
let (overlaps, _) = self.check_overlap_on_layer(layer_id, desired_start, desired_end, exclude_instance_ids);
|
|
if !overlaps {
|
|
return Some(desired_start);
|
|
}
|
|
|
|
// Collect all existing clip time ranges on this layer
|
|
let instances: &[ClipInstance] = match layer {
|
|
AnyLayer::Audio(audio) => &audio.clip_instances,
|
|
AnyLayer::Video(video) => &video.clip_instances,
|
|
AnyLayer::Effect(effect) => &effect.clip_instances,
|
|
AnyLayer::Vector(_) => return Some(desired_start), // Shouldn't reach here
|
|
AnyLayer::Group(_) => return Some(desired_start), // Groups don't have own clips
|
|
AnyLayer::Raster(_) => return Some(desired_start), // Raster layers don't have own clips
|
|
};
|
|
|
|
let mut occupied_ranges: Vec<(f64, f64, Uuid)> = Vec::new();
|
|
for instance in instances {
|
|
if exclude_instance_ids.contains(&instance.id) {
|
|
continue;
|
|
}
|
|
|
|
if let Some(clip_dur) = self.get_clip_duration(&instance.clip_id) {
|
|
let inst_start = instance.effective_start();
|
|
let inst_end = instance.timeline_start + instance.effective_duration(clip_dur);
|
|
occupied_ranges.push((inst_start, inst_end, instance.id));
|
|
}
|
|
}
|
|
|
|
// Sort by start time
|
|
occupied_ranges.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
|
|
|
|
// Find the clip we're overlapping with and try both sides, pick nearest
|
|
for (occupied_start, occupied_end, _) in &occupied_ranges {
|
|
if desired_start < *occupied_end && *occupied_start < desired_end {
|
|
let mut candidates: Vec<f64> = Vec::new();
|
|
|
|
// Try snapping to the right (after this clip)
|
|
let snap_right = *occupied_end;
|
|
let snap_right_end = snap_right + clip_duration;
|
|
let (overlaps_right, _) = self.check_overlap_on_layer(
|
|
layer_id,
|
|
snap_right,
|
|
snap_right_end,
|
|
exclude_instance_ids,
|
|
);
|
|
if !overlaps_right {
|
|
candidates.push(snap_right);
|
|
}
|
|
|
|
// Try snapping to the left (before this clip)
|
|
let snap_left = occupied_start - clip_duration;
|
|
if snap_left >= 0.0 {
|
|
let (overlaps_left, _) = self.check_overlap_on_layer(
|
|
layer_id,
|
|
snap_left,
|
|
*occupied_start,
|
|
exclude_instance_ids,
|
|
);
|
|
if !overlaps_left {
|
|
candidates.push(snap_left);
|
|
}
|
|
}
|
|
|
|
// Pick the candidate closest to desired_start
|
|
if !candidates.is_empty() {
|
|
candidates.sort_by(|a, b| {
|
|
let dist_a = (a - desired_start).abs();
|
|
let dist_b = (b - desired_start).abs();
|
|
dist_a.partial_cmp(&dist_b).unwrap_or(std::cmp::Ordering::Equal)
|
|
});
|
|
return Some(candidates[0]);
|
|
}
|
|
}
|
|
}
|
|
|
|
// If no gap found, try placing at timeline start
|
|
if occupied_ranges.is_empty() || occupied_ranges[0].0 >= clip_duration {
|
|
return Some(0.0);
|
|
}
|
|
|
|
// No valid position found
|
|
None
|
|
}
|
|
|
|
/// Clamp a group move offset so no clip in the group overlaps a non-group clip or
|
|
/// goes before timeline start. All clips move by the same returned offset.
|
|
pub fn clamp_group_move_offset(
|
|
&self,
|
|
layer_id: &Uuid,
|
|
group: &[(Uuid, f64, f64)], // (instance_id, timeline_start, effective_duration)
|
|
desired_offset: f64,
|
|
) -> f64 {
|
|
let Some(layer) = self.get_layer(layer_id) else {
|
|
return desired_offset;
|
|
};
|
|
if matches!(layer, AnyLayer::Vector(_) | AnyLayer::Group(_)) {
|
|
return desired_offset;
|
|
}
|
|
|
|
let group_ids: Vec<Uuid> = group.iter().map(|(id, _, _)| *id).collect();
|
|
|
|
let instances: &[ClipInstance] = match layer {
|
|
AnyLayer::Audio(a) => &a.clip_instances,
|
|
AnyLayer::Video(v) => &v.clip_instances,
|
|
AnyLayer::Effect(e) => &e.clip_instances,
|
|
AnyLayer::Vector(v) => &v.clip_instances,
|
|
AnyLayer::Group(_) => &[],
|
|
AnyLayer::Raster(_) => &[],
|
|
};
|
|
|
|
// Collect non-group clip ranges
|
|
let mut non_group: Vec<(f64, f64)> = Vec::new();
|
|
for inst in instances {
|
|
if group_ids.contains(&inst.id) {
|
|
continue;
|
|
}
|
|
if let Some(dur) = self.get_clip_duration(&inst.clip_id) {
|
|
let start = inst.effective_start();
|
|
let end = inst.timeline_start + inst.effective_duration(dur);
|
|
non_group.push((start, end));
|
|
}
|
|
}
|
|
|
|
let mut clamped = desired_offset;
|
|
|
|
for &(_, start, duration) in group {
|
|
let end = start + duration;
|
|
|
|
// Can't go before timeline start
|
|
clamped = clamped.max(-start);
|
|
|
|
// Check against non-group clips
|
|
for &(ns, ne) in &non_group {
|
|
if clamped < 0.0 {
|
|
// Moving left: if non-group clip end is between our destination and current start
|
|
if ne <= start && ne > start + clamped {
|
|
clamped = clamped.max(ne - start);
|
|
}
|
|
} else if clamped > 0.0 {
|
|
// Moving right: if non-group clip start is between our current end and destination
|
|
if ns >= end && ns < end + clamped {
|
|
clamped = clamped.min(ns - end);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
clamped
|
|
}
|
|
|
|
/// Find the maximum amount we can extend a clip to the left without overlapping
|
|
///
|
|
/// Returns the distance to the nearest clip to the left, or the distance to
|
|
/// timeline start (0.0) if no clips exist to the left.
|
|
pub fn find_max_trim_extend_left(
|
|
&self,
|
|
layer_id: &Uuid,
|
|
instance_id: &Uuid,
|
|
current_timeline_start: f64,
|
|
) -> f64 {
|
|
let Some(layer) = self.get_layer(layer_id) else {
|
|
return current_timeline_start; // No limit if layer not found
|
|
};
|
|
|
|
// Only check audio, video, and effect layers
|
|
if matches!(layer, AnyLayer::Vector(_) | AnyLayer::Group(_)) {
|
|
return current_timeline_start; // No limit for vector/group layers
|
|
};
|
|
|
|
// Find the nearest clip to the left
|
|
let mut nearest_end = 0.0; // Can extend to timeline start by default
|
|
|
|
let instances: &[ClipInstance] = match layer {
|
|
AnyLayer::Audio(audio) => &audio.clip_instances,
|
|
AnyLayer::Video(video) => &video.clip_instances,
|
|
AnyLayer::Effect(effect) => &effect.clip_instances,
|
|
AnyLayer::Vector(vector) => &vector.clip_instances,
|
|
AnyLayer::Group(_) => &[],
|
|
AnyLayer::Raster(_) => &[],
|
|
};
|
|
|
|
for other in instances {
|
|
if &other.id == instance_id {
|
|
continue;
|
|
}
|
|
|
|
// Calculate other clip's extent (accounting for loop_before)
|
|
if let Some(clip_duration) = self.get_clip_duration(&other.clip_id) {
|
|
let other_end = other.timeline_start + other.effective_duration(clip_duration);
|
|
|
|
// If this clip is to the left and closer than current nearest
|
|
if other_end <= current_timeline_start && other_end > nearest_end {
|
|
nearest_end = other_end;
|
|
}
|
|
}
|
|
}
|
|
|
|
current_timeline_start - nearest_end
|
|
}
|
|
|
|
/// Find the maximum amount we can extend a clip to the right without overlapping
|
|
///
|
|
/// Returns the distance to the nearest clip to the right, or f64::MAX if no
|
|
/// clips exist to the right.
|
|
pub fn find_max_trim_extend_right(
|
|
&self,
|
|
layer_id: &Uuid,
|
|
instance_id: &Uuid,
|
|
current_timeline_start: f64,
|
|
current_effective_duration: f64,
|
|
) -> f64 {
|
|
let Some(layer) = self.get_layer(layer_id) else {
|
|
return f64::MAX; // No limit if layer not found
|
|
};
|
|
|
|
// Only check audio, video, and effect layers
|
|
if matches!(layer, AnyLayer::Vector(_) | AnyLayer::Group(_)) {
|
|
return f64::MAX; // No limit for vector/group layers
|
|
}
|
|
|
|
let instances: &[ClipInstance] = match layer {
|
|
AnyLayer::Audio(audio) => &audio.clip_instances,
|
|
AnyLayer::Video(video) => &video.clip_instances,
|
|
AnyLayer::Effect(effect) => &effect.clip_instances,
|
|
AnyLayer::Vector(vector) => &vector.clip_instances,
|
|
AnyLayer::Group(_) => &[],
|
|
AnyLayer::Raster(_) => &[],
|
|
};
|
|
|
|
let mut nearest_start = f64::MAX;
|
|
let current_end = current_timeline_start + current_effective_duration;
|
|
|
|
for other in instances {
|
|
if &other.id == instance_id {
|
|
continue;
|
|
}
|
|
|
|
// Use effective_start to account for loop_before on the other clip
|
|
let other_start = other.effective_start();
|
|
if other_start >= current_end && other_start < nearest_start {
|
|
nearest_start = other_start;
|
|
}
|
|
}
|
|
|
|
if nearest_start == f64::MAX {
|
|
f64::MAX // No clip to the right, can extend freely
|
|
} else {
|
|
(nearest_start - current_end).max(0.0) // Gap between our end and next clip's start
|
|
}
|
|
}
|
|
/// Find the maximum amount we can extend loop_before to the left without overlapping.
|
|
///
|
|
/// Returns the max additional loop_before distance (from the current effective start).
|
|
pub fn find_max_loop_extend_left(
|
|
&self,
|
|
layer_id: &Uuid,
|
|
instance_id: &Uuid,
|
|
current_effective_start: f64,
|
|
) -> f64 {
|
|
let Some(layer) = self.get_layer(layer_id) else {
|
|
return current_effective_start;
|
|
};
|
|
|
|
if matches!(layer, AnyLayer::Vector(_) | AnyLayer::Group(_)) {
|
|
return current_effective_start;
|
|
}
|
|
|
|
let instances: &[ClipInstance] = match layer {
|
|
AnyLayer::Audio(audio) => &audio.clip_instances,
|
|
AnyLayer::Video(video) => &video.clip_instances,
|
|
AnyLayer::Effect(effect) => &effect.clip_instances,
|
|
AnyLayer::Vector(vector) => &vector.clip_instances,
|
|
AnyLayer::Group(_) => &[],
|
|
AnyLayer::Raster(_) => &[],
|
|
};
|
|
|
|
let mut nearest_end = 0.0;
|
|
|
|
for other in instances {
|
|
if &other.id == instance_id {
|
|
continue;
|
|
}
|
|
|
|
if let Some(clip_duration) = self.get_clip_duration(&other.clip_id) {
|
|
let other_end = other.timeline_start + other.effective_duration(clip_duration);
|
|
|
|
if other_end <= current_effective_start && other_end > nearest_end {
|
|
nearest_end = other_end;
|
|
}
|
|
}
|
|
}
|
|
|
|
current_effective_start - nearest_end
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::layer::{LayerTrait, VectorLayer};
|
|
|
|
#[test]
|
|
fn test_document_creation() {
|
|
let doc = Document::new("Test Project");
|
|
assert_eq!(doc.name, "Test Project");
|
|
assert_eq!(doc.width, 1920.0);
|
|
assert_eq!(doc.height, 1080.0);
|
|
assert_eq!(doc.root.children.len(), 0);
|
|
}
|
|
|
|
#[test]
|
|
fn test_graphics_object() {
|
|
let mut root = GraphicsObject::new("Root");
|
|
let vector_layer = VectorLayer::new("Layer 1");
|
|
let layer_id = root.add_child(AnyLayer::Vector(vector_layer));
|
|
|
|
assert_eq!(root.children.len(), 1);
|
|
assert!(root.get_child(&layer_id).is_some());
|
|
}
|
|
|
|
#[test]
|
|
fn test_document_with_layers() {
|
|
let mut doc = Document::new("Test");
|
|
|
|
let layer1 = VectorLayer::new("Layer 1");
|
|
let mut layer2 = VectorLayer::new("Layer 2");
|
|
|
|
// Hide layer2 to test visibility filtering
|
|
layer2.layer.visible = false;
|
|
|
|
doc.root.add_child(AnyLayer::Vector(layer1));
|
|
doc.root.add_child(AnyLayer::Vector(layer2));
|
|
|
|
// Only visible layers should be returned
|
|
assert_eq!(doc.visible_layers().count(), 1);
|
|
|
|
// Update layer2 to be visible via root access
|
|
let ids: Vec<_> = doc.root.children.iter().map(|n| n.id()).collect();
|
|
if let Some(layer) = doc.root.get_child_mut(&ids[1]) {
|
|
layer.set_visible(true);
|
|
}
|
|
|
|
assert_eq!(doc.visible_layers().count(), 2);
|
|
}
|
|
}
|