diff --git a/lightningbeam-ui/Cargo.lock b/lightningbeam-ui/Cargo.lock index e04de2d..fcc93be 100644 --- a/lightningbeam-ui/Cargo.lock +++ b/lightningbeam-ui/Cargo.lock @@ -2358,6 +2358,7 @@ checksum = "c62026ae44756f8a599ba21140f350303d4f08dcdcc71b5ad9c9bb8128c13c62" dependencies = [ "arrayvec", "euclid", + "serde", "smallvec", ] @@ -2438,8 +2439,11 @@ dependencies = [ name = "lightningbeam-core" version = "0.1.0" dependencies = [ + "kurbo 0.11.3", "serde", "serde_json", + "uuid", + "vello", ] [[package]] @@ -3988,6 +3992,9 @@ name = "smallvec" version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +dependencies = [ + "serde", +] [[package]] name = "smithay-client-toolkit" @@ -4596,6 +4603,18 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "uuid" +version = "1.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f87b8aa10b915a06587d0dec516c282ff295b475d94abf425d62b57710070a2" +dependencies = [ + "getrandom 0.3.4", + "js-sys", + "serde", + "wasm-bindgen", +] + [[package]] name = "v_frame" version = "0.3.9" diff --git a/lightningbeam-ui/Cargo.toml b/lightningbeam-ui/Cargo.toml index 40d60a5..76e93a6 100644 --- a/lightningbeam-ui/Cargo.toml +++ b/lightningbeam-ui/Cargo.toml @@ -14,7 +14,7 @@ egui-wgpu = "0.29" # GPU Rendering vello = "0.3" wgpu = "22" -kurbo = "0.11" +kurbo = { version = "0.11", features = ["serde"] } peniko = "0.5" # Windowing diff --git a/lightningbeam-ui/lightningbeam-core/Cargo.toml b/lightningbeam-ui/lightningbeam-core/Cargo.toml index 220365c..eb61404 100644 --- a/lightningbeam-ui/lightningbeam-core/Cargo.toml +++ b/lightningbeam-ui/lightningbeam-core/Cargo.toml @@ -6,3 +6,10 @@ edition = "2021" [dependencies] serde = { workspace = true } serde_json = { workspace = true } + +# Geometry and rendering +kurbo = { workspace = true } +vello = { workspace = true } + +# Unique identifiers +uuid = { version = "1.0", features = ["v4", "serde"] } diff --git a/lightningbeam-ui/lightningbeam-core/src/animation.rs b/lightningbeam-ui/lightningbeam-core/src/animation.rs new file mode 100644 index 0000000..4092ccc --- /dev/null +++ b/lightningbeam-ui/lightningbeam-core/src/animation.rs @@ -0,0 +1,526 @@ +//! Animation system for Lightningbeam +//! +//! Provides keyframe-based animation curves with support for different +//! interpolation types and property targets. + +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use uuid::Uuid; + +/// Interpolation type for keyframes +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +pub enum InterpolationType { + /// Linear interpolation between keyframes + Linear, + /// Smooth bezier interpolation with handles + Bezier, + /// Hold value until next keyframe (step function) + Hold, +} + +/// Extrapolation type for values outside keyframe range +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +pub enum ExtrapolationType { + /// Hold the first/last keyframe value + Hold, + /// Continue with the slope from the first/last segment + Linear, + /// Repeat the curve pattern cyclically + Cyclic, + /// Repeat the curve, but offset each cycle by the change in the previous cycle + /// (each cycle starts where the previous one ended) + CyclicOffset, +} + +impl Default for ExtrapolationType { + fn default() -> Self { + ExtrapolationType::Hold + } +} + +/// A single keyframe in an animation curve +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Keyframe { + /// Time in seconds + pub time: f64, + /// Value at this keyframe + pub value: f64, + /// Interpolation type to use after this keyframe + pub interpolation: InterpolationType, + /// Bezier handle for smooth curves (in and out tangents) + /// Format: (in_time, in_value, out_time, out_value) + pub bezier_handles: Option<(f64, f64, f64, f64)>, +} + +impl Keyframe { + /// Create a new linear keyframe + pub fn linear(time: f64, value: f64) -> Self { + Self { + time, + value, + interpolation: InterpolationType::Linear, + bezier_handles: None, + } + } + + /// Create a new hold keyframe + pub fn hold(time: f64, value: f64) -> Self { + Self { + time, + value, + interpolation: InterpolationType::Hold, + bezier_handles: None, + } + } + + /// Create a new bezier keyframe with handles + pub fn bezier( + time: f64, + value: f64, + in_time: f64, + in_value: f64, + out_time: f64, + out_value: f64, + ) -> Self { + Self { + time, + value, + interpolation: InterpolationType::Bezier, + bezier_handles: Some((in_time, in_value, out_time, out_value)), + } + } +} + +/// Transform properties that can be animated +#[derive(Clone, Copy, Debug, Hash, Eq, PartialEq, Serialize, Deserialize)] +pub enum TransformProperty { + X, + Y, + Rotation, + ScaleX, + ScaleY, + SkewX, + SkewY, + Opacity, +} + +/// Shape properties that can be animated +#[derive(Clone, Copy, Debug, Hash, Eq, PartialEq, Serialize, Deserialize)] +pub enum ShapeProperty { + /// Whether the shape is visible (0 or 1, for animation) + Exists, + /// Z-order within the layer + ZOrder, + /// Morph between shape versions (fractional index) + ShapeIndex, +} + +/// Layer-level properties that can be animated +#[derive(Clone, Copy, Debug, Hash, Eq, PartialEq, Serialize, Deserialize)] +pub enum LayerProperty { + /// Layer opacity (0.0 to 1.0) + Opacity, + /// Layer visibility (0 or 1, for animation) + Visibility, +} + +/// Audio-specific properties that can be automated +#[derive(Clone, Copy, Debug, Hash, Eq, PartialEq, Serialize, Deserialize)] +pub enum AudioProperty { + /// Volume in dB (-60 to +12 typical range) + Volume, + /// Pan position (-1.0 left to +1.0 right) + Pan, + /// Pitch shift in semitones + Pitch, +} + +/// Video-specific properties that can be animated +#[derive(Clone, Copy, Debug, Hash, Eq, PartialEq, Serialize, Deserialize)] +pub enum VideoProperty { + /// Fade/opacity (0.0 to 1.0) + Fade, + /// X position + PositionX, + /// Y position + PositionY, + /// Scale factor + Scale, + /// Rotation in degrees + Rotation, +} + +/// Effect-specific properties that can be animated +#[derive(Clone, Copy, Debug, Hash, Eq, PartialEq, Serialize, Deserialize)] +pub enum EffectProperty { + /// Effect intensity (0.0 to 1.0) + Intensity, + /// Mix/blend amount (0.0 to 1.0) + Mix, + /// Custom effect parameter (effect-specific) + Custom(u32), +} + +/// Target for an animation curve (type-safe property identification) +#[derive(Clone, Debug, Hash, Eq, PartialEq, Serialize, Deserialize)] +pub enum AnimationTarget { + /// Object transform property + Object { + id: Uuid, + property: TransformProperty, + }, + /// Shape property + Shape { id: Uuid, property: ShapeProperty }, + /// Layer property + Layer { property: LayerProperty }, + /// Audio automation + Audio { id: Uuid, property: AudioProperty }, + /// Video property + Video { id: Uuid, property: VideoProperty }, + /// Effect parameter + Effect { + id: Uuid, + property: EffectProperty, + }, + /// Generic automation node parameter + Automation { node_id: u32, parameter: String }, +} + +/// An animation curve with keyframes +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct AnimationCurve { + /// What this curve animates + pub target: AnimationTarget, + /// Keyframes in chronological order + pub keyframes: Vec, + /// Default value when no keyframes are present + pub default_value: f64, + /// How to extrapolate before the first keyframe + #[serde(default)] + pub pre_extrapolation: ExtrapolationType, + /// How to extrapolate after the last keyframe + #[serde(default)] + pub post_extrapolation: ExtrapolationType, +} + +impl AnimationCurve { + /// Create a new animation curve + pub fn new(target: AnimationTarget, default_value: f64) -> Self { + Self { + target, + keyframes: Vec::new(), + default_value, + pre_extrapolation: ExtrapolationType::Hold, + post_extrapolation: ExtrapolationType::Hold, + } + } + + /// Get the time range of keyframes (min, max) + fn get_keyframe_range(&self) -> Option<(f64, f64)> { + if self.keyframes.is_empty() { + None + } else { + Some(( + self.keyframes.first().unwrap().time, + self.keyframes.last().unwrap().time, + )) + } + } + + /// Get the keyframes that bracket the given time + /// Returns (before, after) where: + /// - (None, Some(kf)) if time is before the first keyframe + /// - (Some(kf), None) if time is after the last keyframe + /// - (Some(before), Some(after)) if time is between two keyframes + /// - (None, None) if there are no keyframes + pub fn get_bracketing_keyframes(&self, time: f64) -> (Option<&Keyframe>, Option<&Keyframe>) { + if self.keyframes.is_empty() { + return (None, None); + } + + // Find the first keyframe after the given time + let after_idx = self.keyframes.iter().position(|kf| kf.time > time); + + match after_idx { + None => { + // Time is after all keyframes + (self.keyframes.last(), None) + } + Some(0) => { + // Time is before all keyframes + (None, self.keyframes.first()) + } + Some(idx) => { + // Time is between two keyframes + (Some(&self.keyframes[idx - 1]), Some(&self.keyframes[idx])) + } + } + } + + /// Interpolate between two keyframes + fn interpolate(&self, before_kf: &Keyframe, after_kf: &Keyframe, time: f64) -> f64 { + let t = (time - before_kf.time) / (after_kf.time - before_kf.time); + + match before_kf.interpolation { + InterpolationType::Linear => { + // Linear interpolation + before_kf.value + t * (after_kf.value - before_kf.value) + } + InterpolationType::Bezier => { + // Bezier interpolation using handles + if let Some((_, in_val, _, out_val)) = before_kf.bezier_handles { + // Cubic bezier interpolation + let p0 = before_kf.value; + let p1 = out_val; + let p2 = in_val; + let p3 = after_kf.value; + + let t2 = t * t; + let t3 = t2 * t; + let mt = 1.0 - t; + let mt2 = mt * mt; + let mt3 = mt2 * mt; + + mt3 * p0 + 3.0 * mt2 * t * p1 + 3.0 * mt * t2 * p2 + t3 * p3 + } else { + // Fallback to linear if no handles + before_kf.value + t * (after_kf.value - before_kf.value) + } + } + InterpolationType::Hold => { + // Hold until next keyframe + before_kf.value + } + } + } + + /// Evaluate the curve at a given time + pub fn eval(&self, time: f64) -> f64 { + if self.keyframes.is_empty() { + return self.default_value; + } + + let (before, after) = self.get_bracketing_keyframes(time); + + match (before, after) { + (None, None) => self.default_value, + + (None, Some(first_kf)) => { + // Before first keyframe - use pre-extrapolation + self.extrapolate_pre(time, first_kf) + } + + (Some(last_kf), None) => { + // After last keyframe - use post-extrapolation + self.extrapolate_post(time, last_kf) + } + + (Some(before_kf), Some(after_kf)) => { + // Between keyframes - interpolate + self.interpolate(before_kf, after_kf, time) + } + } + } + + /// Extrapolate before the first keyframe + fn extrapolate_pre(&self, time: f64, first_kf: &Keyframe) -> f64 { + match self.pre_extrapolation { + ExtrapolationType::Hold => first_kf.value, + + ExtrapolationType::Linear => { + // Use slope from first segment if available + if self.keyframes.len() >= 2 { + let second_kf = &self.keyframes[1]; + let slope = (second_kf.value - first_kf.value) + / (second_kf.time - first_kf.time); + first_kf.value + slope * (time - first_kf.time) + } else { + first_kf.value + } + } + + ExtrapolationType::Cyclic => { + let (start_time, end_time) = self.get_keyframe_range().unwrap(); + let duration = end_time - start_time; + if duration <= 0.0 { + return first_kf.value; + } + + // Map time into the keyframe range + let offset = ((start_time - time) / duration).ceil() * duration; + let mapped_time = time + offset; + self.eval(mapped_time) + } + + ExtrapolationType::CyclicOffset => { + let (start_time, end_time) = self.get_keyframe_range().unwrap(); + let duration = end_time - start_time; + if duration <= 0.0 { + return first_kf.value; + } + + let first_val = self.keyframes.first().unwrap().value; + let last_val = self.keyframes.last().unwrap().value; + let cycle_delta = last_val - first_val; + + // Calculate which cycle we're in (negative for pre-extrapolation) + let cycles = ((start_time - time) / duration).ceil(); + let offset = cycles * duration; + let mapped_time = time + offset; + + // Evaluate and offset by accumulated cycles + self.eval(mapped_time) - cycles * cycle_delta + } + } + } + + /// Extrapolate after the last keyframe + fn extrapolate_post(&self, time: f64, last_kf: &Keyframe) -> f64 { + match self.post_extrapolation { + ExtrapolationType::Hold => last_kf.value, + + ExtrapolationType::Linear => { + // Use slope from last segment if available + let n = self.keyframes.len(); + if n >= 2 { + let second_last_kf = &self.keyframes[n - 2]; + let slope = (last_kf.value - second_last_kf.value) + / (last_kf.time - second_last_kf.time); + last_kf.value + slope * (time - last_kf.time) + } else { + last_kf.value + } + } + + ExtrapolationType::Cyclic => { + let (start_time, end_time) = self.get_keyframe_range().unwrap(); + let duration = end_time - start_time; + if duration <= 0.0 { + return last_kf.value; + } + + // Map time into the keyframe range + let offset = ((time - start_time) / duration).floor() * duration; + let mapped_time = time - offset; + self.eval(mapped_time) + } + + ExtrapolationType::CyclicOffset => { + let (start_time, end_time) = self.get_keyframe_range().unwrap(); + let duration = end_time - start_time; + if duration <= 0.0 { + return last_kf.value; + } + + let first_val = self.keyframes.first().unwrap().value; + let last_val = self.keyframes.last().unwrap().value; + let cycle_delta = last_val - first_val; + + // Calculate which cycle we're in + let cycles = ((time - start_time) / duration).floor(); + let offset = cycles * duration; + let mapped_time = time - offset; + + // Evaluate and offset by accumulated cycles + self.eval(mapped_time) + cycles * cycle_delta + } + } + } + + /// Add or update a keyframe + pub fn set_keyframe(&mut self, keyframe: Keyframe) { + // Find existing keyframe at this time or insert new one + if let Some(existing) = self + .keyframes + .iter_mut() + .find(|kf| (kf.time - keyframe.time).abs() < 0.001) + { + *existing = keyframe; + } else { + self.keyframes.push(keyframe); + // Keep keyframes sorted by time + self.keyframes + .sort_by(|a, b| a.time.partial_cmp(&b.time).unwrap()); + } + } + + /// Remove a keyframe at the given time (within tolerance) + pub fn remove_keyframe(&mut self, time: f64, tolerance: f64) -> bool { + if let Some(idx) = self + .keyframes + .iter() + .position(|kf| (kf.time - time).abs() < tolerance) + { + self.keyframes.remove(idx); + true + } else { + false + } + } + + /// Get the keyframe closest to the given time, if within tolerance + pub fn get_keyframe_at(&self, time: f64, tolerance: f64) -> Option<&Keyframe> { + let (before, after) = self.get_bracketing_keyframes(time); + + // Check if before keyframe is within tolerance + if let Some(kf) = before { + if (kf.time - time).abs() < tolerance { + return Some(kf); + } + } + + // Check if after keyframe is within tolerance + if let Some(kf) = after { + if (kf.time - time).abs() < tolerance { + return Some(kf); + } + } + + None + } +} + +/// Collection of animation curves for a layer +#[derive(Clone, Debug, Default, Serialize, Deserialize)] +pub struct AnimationData { + /// Map of animation targets to their curves + pub curves: HashMap, +} + +impl AnimationData { + /// Create new empty animation data + pub fn new() -> Self { + Self { + curves: HashMap::new(), + } + } + + /// Get a curve for a specific target + pub fn get_curve(&self, target: &AnimationTarget) -> Option<&AnimationCurve> { + self.curves.get(target) + } + + /// Get a mutable curve for a specific target + pub fn get_curve_mut(&mut self, target: &AnimationTarget) -> Option<&mut AnimationCurve> { + self.curves.get_mut(target) + } + + /// Add or replace a curve + pub fn set_curve(&mut self, curve: AnimationCurve) { + let target = curve.target.clone(); + self.curves.insert(target, curve); + } + + /// Remove a curve + pub fn remove_curve(&mut self, target: &AnimationTarget) -> Option { + self.curves.remove(target) + } + + /// Evaluate a property at a given time + pub fn eval(&self, target: &AnimationTarget, time: f64, default: f64) -> f64 { + self.curves + .get(target) + .map(|curve| curve.eval(time)) + .unwrap_or(default) + } +} diff --git a/lightningbeam-ui/lightningbeam-core/src/document.rs b/lightningbeam-ui/lightningbeam-core/src/document.rs new file mode 100644 index 0000000..79c8381 --- /dev/null +++ b/lightningbeam-ui/lightningbeam-core/src/document.rs @@ -0,0 +1,219 @@ +//! Document structure for Lightningbeam +//! +//! The Document represents a complete animation project with settings +//! and a root graphics object containing the scene graph. + +use crate::layer::AnyLayer; +use crate::shape::ShapeColor; +use serde::{Deserialize, Serialize}; +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, +} + +impl GraphicsObject { + /// Create a new graphics object + pub fn new(name: impl Into) -> 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 + pub fn get_child(&self, id: &Uuid) -> Option<&AnyLayer> { + self.children.iter().find(|l| &l.id() == id) + } + + /// Get a mutable child layer by ID + pub fn get_child_mut(&mut self, id: &Uuid) -> Option<&mut AnyLayer> { + self.children.iter_mut().find(|l| &l.id() == id) + } + + /// Remove a child layer by ID + pub fn remove_child(&mut self, id: &Uuid) -> Option { + 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") + } +} + +/// 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, + + /// Duration in seconds + pub duration: f64, + + /// Root graphics object containing all layers + pub root: GraphicsObject, + + /// Current playback time in seconds + #[serde(skip)] + pub current_time: f64, +} + +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, + duration: 10.0, + root: GraphicsObject::default(), + current_time: 0.0, + } + } +} + +impl Document { + /// Create a new document with default settings + pub fn new(name: impl Into) -> Self { + Self { + name: name.into(), + ..Default::default() + } + } + + /// Create a document with custom dimensions + pub fn with_size(name: impl Into, width: f64, height: f64) -> Self { + Self { + name: name.into(), + width, + height, + ..Default::default() + } + } + + /// 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 + } + + /// 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 at the current time from the root graphics object + pub fn visible_layers(&self) -> impl Iterator { + self.root + .children + .iter() + .filter(|layer| { + let layer = layer.layer(); + layer.visible && layer.contains_time(self.current_time) + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::layer::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 mut layer1 = VectorLayer::new("Layer 1"); + layer1.layer.start_time = 0.0; + layer1.layer.end_time = 5.0; + + let mut layer2 = VectorLayer::new("Layer 2"); + layer2.layer.start_time = 3.0; + layer2.layer.end_time = 8.0; + + doc.root.add_child(AnyLayer::Vector(layer1)); + doc.root.add_child(AnyLayer::Vector(layer2)); + + doc.set_time(4.0); + assert_eq!(doc.visible_layers().count(), 2); + + doc.set_time(6.0); + assert_eq!(doc.visible_layers().count(), 1); + } +} diff --git a/lightningbeam-ui/lightningbeam-core/src/layer.rs b/lightningbeam-ui/lightningbeam-core/src/layer.rs new file mode 100644 index 0000000..676150f --- /dev/null +++ b/lightningbeam-ui/lightningbeam-core/src/layer.rs @@ -0,0 +1,270 @@ +//! Layer system for Lightningbeam +//! +//! Layers organize objects and shapes, and contain animation data. + +use crate::animation::AnimationData; +use crate::object::Object; +use crate::shape::Shape; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +/// Layer type +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum LayerType { + /// Vector graphics layer (shapes and objects) + Vector, + /// Audio track + Audio, + /// Video clip + Video, + /// Generic automation layer + Automation, +} + +/// Base layer structure +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct Layer { + /// Unique identifier + pub id: Uuid, + + /// Layer type + pub layer_type: LayerType, + + /// Layer name + pub name: String, + + /// Whether the layer is visible + pub visible: bool, + + /// Layer opacity (0.0 to 1.0) + pub opacity: f64, + + /// Start time in seconds + pub start_time: f64, + + /// End time in seconds + pub end_time: f64, + + /// Animation data for this layer + pub animation_data: AnimationData, +} + +impl Layer { + /// Create a new layer + pub fn new(layer_type: LayerType, name: impl Into) -> Self { + Self { + id: Uuid::new_v4(), + layer_type, + name: name.into(), + visible: true, + opacity: 1.0, + start_time: 0.0, + end_time: 10.0, // Default 10 second duration + animation_data: AnimationData::new(), + } + } + + /// Create with a specific ID + pub fn with_id(id: Uuid, layer_type: LayerType, name: impl Into) -> Self { + Self { + id, + layer_type, + name: name.into(), + visible: true, + opacity: 1.0, + start_time: 0.0, + end_time: 10.0, + animation_data: AnimationData::new(), + } + } + + /// Set the time range + pub fn with_time_range(mut self, start: f64, end: f64) -> Self { + self.start_time = start; + self.end_time = end; + self + } + + /// Set visibility + pub fn with_visibility(mut self, visible: bool) -> Self { + self.visible = visible; + self + } + + /// Get duration + pub fn duration(&self) -> f64 { + self.end_time - self.start_time + } + + /// Check if a time is within this layer's range + pub fn contains_time(&self, time: f64) -> bool { + time >= self.start_time && time <= self.end_time + } +} + +/// Vector layer containing shapes and objects +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct VectorLayer { + /// Base layer properties + pub layer: Layer, + + /// Shapes defined in this layer + pub shapes: Vec, + + /// Object instances (references to shapes with transforms) + pub objects: Vec, +} + +impl VectorLayer { + /// Create a new vector layer + pub fn new(name: impl Into) -> Self { + Self { + layer: Layer::new(LayerType::Vector, name), + shapes: Vec::new(), + objects: Vec::new(), + } + } + + /// Add a shape to this layer + pub fn add_shape(&mut self, shape: Shape) -> Uuid { + let id = shape.id; + self.shapes.push(shape); + id + } + + /// Add an object to this layer + pub fn add_object(&mut self, object: Object) -> Uuid { + let id = object.id; + self.objects.push(object); + id + } + + /// Find a shape by ID + pub fn get_shape(&self, id: &Uuid) -> Option<&Shape> { + self.shapes.iter().find(|s| &s.id == id) + } + + /// Find a mutable shape by ID + pub fn get_shape_mut(&mut self, id: &Uuid) -> Option<&mut Shape> { + self.shapes.iter_mut().find(|s| &s.id == id) + } + + /// Find an object by ID + pub fn get_object(&self, id: &Uuid) -> Option<&Object> { + self.objects.iter().find(|o| &o.id == id) + } + + /// Find a mutable object by ID + pub fn get_object_mut(&mut self, id: &Uuid) -> Option<&mut Object> { + self.objects.iter_mut().find(|o| &o.id == id) + } +} + +/// Audio layer (placeholder for future implementation) +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct AudioLayer { + /// Base layer properties + pub layer: Layer, + + /// Audio file path or data reference + pub audio_source: Option, +} + +impl AudioLayer { + /// Create a new audio layer + pub fn new(name: impl Into) -> Self { + Self { + layer: Layer::new(LayerType::Audio, name), + audio_source: None, + } + } +} + +/// Video layer (placeholder for future implementation) +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct VideoLayer { + /// Base layer properties + pub layer: Layer, + + /// Video file path or data reference + pub video_source: Option, +} + +impl VideoLayer { + /// Create a new video layer + pub fn new(name: impl Into) -> Self { + Self { + layer: Layer::new(LayerType::Video, name), + video_source: None, + } + } +} + +/// Unified layer enum for polymorphic handling +#[derive(Clone, Debug, Serialize, Deserialize)] +pub enum AnyLayer { + Vector(VectorLayer), + Audio(AudioLayer), + Video(VideoLayer), +} + +impl AnyLayer { + /// Get a reference to the base layer + pub fn layer(&self) -> &Layer { + match self { + AnyLayer::Vector(l) => &l.layer, + AnyLayer::Audio(l) => &l.layer, + AnyLayer::Video(l) => &l.layer, + } + } + + /// Get a mutable reference to the base layer + pub fn layer_mut(&mut self) -> &mut Layer { + match self { + AnyLayer::Vector(l) => &mut l.layer, + AnyLayer::Audio(l) => &mut l.layer, + AnyLayer::Video(l) => &mut l.layer, + } + } + + /// Get the layer ID + pub fn id(&self) -> Uuid { + self.layer().id + } + + /// Get the layer name + pub fn name(&self) -> &str { + &self.layer().name + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_layer_creation() { + let layer = Layer::new(LayerType::Vector, "Test Layer"); + assert_eq!(layer.layer_type, LayerType::Vector); + assert_eq!(layer.name, "Test Layer"); + assert_eq!(layer.opacity, 1.0); + } + + #[test] + fn test_vector_layer() { + let vector_layer = VectorLayer::new("My Layer"); + assert_eq!(vector_layer.shapes.len(), 0); + assert_eq!(vector_layer.objects.len(), 0); + } + + #[test] + fn test_layer_time_range() { + let layer = Layer::new(LayerType::Vector, "Test") + .with_time_range(5.0, 15.0); + + assert_eq!(layer.duration(), 10.0); + assert!(layer.contains_time(10.0)); + assert!(!layer.contains_time(3.0)); + assert!(!layer.contains_time(20.0)); + } +} diff --git a/lightningbeam-ui/lightningbeam-core/src/lib.rs b/lightningbeam-ui/lightningbeam-core/src/lib.rs index e01c5b0..88a7970 100644 --- a/lightningbeam-ui/lightningbeam-core/src/lib.rs +++ b/lightningbeam-ui/lightningbeam-core/src/lib.rs @@ -4,3 +4,10 @@ pub mod layout; pub mod pane; pub mod tool; +pub mod animation; +pub mod path_interpolation; +pub mod shape; +pub mod object; +pub mod layer; +pub mod document; +pub mod renderer; diff --git a/lightningbeam-ui/lightningbeam-core/src/object.rs b/lightningbeam-ui/lightningbeam-core/src/object.rs new file mode 100644 index 0000000..33df32c --- /dev/null +++ b/lightningbeam-ui/lightningbeam-core/src/object.rs @@ -0,0 +1,207 @@ +//! Object system for Lightningbeam +//! +//! An Object represents an instance of a Shape with transform properties. +//! Objects can be animated via the animation system. + +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +/// 2D transform for an object +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct Transform { + /// X position + pub x: f64, + /// Y position + pub y: f64, + /// Rotation in degrees + pub rotation: f64, + /// X scale factor + pub scale_x: f64, + /// Y scale factor + pub scale_y: f64, + /// X skew in degrees + pub skew_x: f64, + /// Y skew in degrees + pub skew_y: f64, + /// Opacity (0.0 to 1.0) + pub opacity: f64, +} + +impl Default for Transform { + fn default() -> Self { + Self { + x: 0.0, + y: 0.0, + rotation: 0.0, + scale_x: 1.0, + scale_y: 1.0, + skew_x: 0.0, + skew_y: 0.0, + opacity: 1.0, + } + } +} + +impl Transform { + /// Create a new default transform + pub fn new() -> Self { + Self::default() + } + + /// Create a transform with position + pub fn with_position(x: f64, y: f64) -> Self { + Self { + x, + y, + ..Default::default() + } + } + + /// Create a transform with rotation + pub fn with_rotation(rotation: f64) -> Self { + Self { + rotation, + ..Default::default() + } + } + + /// Set position + pub fn set_position(&mut self, x: f64, y: f64) { + self.x = x; + self.y = y; + } + + /// Set rotation + pub fn set_rotation(&mut self, rotation: f64) { + self.rotation = rotation; + } + + /// Set scale + pub fn set_scale(&mut self, scale_x: f64, scale_y: f64) { + self.scale_x = scale_x; + self.scale_y = scale_y; + } + + /// Set uniform scale + pub fn set_uniform_scale(&mut self, scale: f64) { + self.scale_x = scale; + self.scale_y = scale; + } + + /// Convert to an affine transform matrix + pub fn to_affine(&self) -> kurbo::Affine { + use kurbo::Affine; + + // Build transform: translate * rotate * scale * skew + let translate = Affine::translate((self.x, self.y)); + let rotate = Affine::rotate(self.rotation.to_radians()); + let scale = Affine::scale_non_uniform(self.scale_x, self.scale_y); + + // Skew transforms + let skew_x = if self.skew_x != 0.0 { + let tan_skew = self.skew_x.to_radians().tan(); + Affine::new([1.0, 0.0, tan_skew, 1.0, 0.0, 0.0]) + } else { + Affine::IDENTITY + }; + + let skew_y = if self.skew_y != 0.0 { + let tan_skew = self.skew_y.to_radians().tan(); + Affine::new([1.0, tan_skew, 0.0, 1.0, 0.0, 0.0]) + } else { + Affine::IDENTITY + }; + + translate * rotate * scale * skew_x * skew_y + } +} + +/// An object instance (shape with transform) +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct Object { + /// Unique identifier + pub id: Uuid, + + /// Reference to the shape this object uses + pub shape_id: Uuid, + + /// Transform properties + pub transform: Transform, + + /// Name for display in UI + pub name: Option, +} + +impl Object { + /// Create a new object for a shape + pub fn new(shape_id: Uuid) -> Self { + Self { + id: Uuid::new_v4(), + shape_id, + transform: Transform::default(), + name: None, + } + } + + /// Create a new object with a specific ID + pub fn with_id(id: Uuid, shape_id: Uuid) -> Self { + Self { + id, + shape_id, + transform: Transform::default(), + name: None, + } + } + + /// Set the name + pub fn with_name(mut self, name: impl Into) -> Self { + self.name = Some(name.into()); + self + } + + /// Set the transform + pub fn with_transform(mut self, transform: Transform) -> Self { + self.transform = transform; + self + } + + /// Set position + pub fn with_position(mut self, x: f64, y: f64) -> Self { + self.transform.set_position(x, y); + self + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_transform_default() { + let transform = Transform::default(); + assert_eq!(transform.x, 0.0); + assert_eq!(transform.y, 0.0); + assert_eq!(transform.scale_x, 1.0); + assert_eq!(transform.opacity, 1.0); + } + + #[test] + fn test_transform_affine() { + let mut transform = Transform::default(); + transform.set_position(100.0, 200.0); + transform.set_rotation(45.0); + + let affine = transform.to_affine(); + // Just verify it doesn't panic + let _ = affine.as_coeffs(); + } + + #[test] + fn test_object_creation() { + let shape_id = Uuid::new_v4(); + let object = Object::new(shape_id); + + assert_eq!(object.shape_id, shape_id); + assert_eq!(object.transform.x, 0.0); + } +} diff --git a/lightningbeam-ui/lightningbeam-core/src/path_interpolation.rs b/lightningbeam-ui/lightningbeam-core/src/path_interpolation.rs new file mode 100644 index 0000000..f6bf166 --- /dev/null +++ b/lightningbeam-ui/lightningbeam-core/src/path_interpolation.rs @@ -0,0 +1,470 @@ +//! Path interpolation using the d3-interpolate-path algorithm +//! +//! This module implements path morphing by normalizing two paths to have +//! the same number of segments and then interpolating between them. +//! +//! Based on: https://github.com/pbeshai/d3-interpolate-path + +use kurbo::{BezPath, PathEl, Point}; + +/// de Casteljau's algorithm for splitting bezier curves +/// +/// Takes a list of control points and a parameter t, and returns +/// the two curves (left and right) that result from splitting at t. +fn decasteljau(points: &[Point], t: f64) -> (Vec, Vec) { + let mut left = Vec::new(); + let mut right = Vec::new(); + + fn recurse(points: &[Point], t: f64, left: &mut Vec, right: &mut Vec) { + if points.len() == 1 { + left.push(points[0]); + right.push(points[0]); + } else { + let mut new_points = Vec::with_capacity(points.len() - 1); + + for i in 0..points.len() - 1 { + if i == 0 { + left.push(points[0]); + } + if i == points.len() - 2 { + right.push(points[i + 1]); + } + + // Linear interpolation between consecutive points + let x = (1.0 - t) * points[i].x + t * points[i + 1].x; + let y = (1.0 - t) * points[i].y + t * points[i + 1].y; + new_points.push(Point::new(x, y)); + } + + recurse(&new_points, t, left, right); + } + } + + if !points.is_empty() { + recurse(points, t, &mut left, &mut right); + right.reverse(); + } + + (left, right) +} + +/// A simplified path command representation for interpolation +#[derive(Clone, Debug)] +enum PathCommand { + MoveTo { x: f64, y: f64 }, + LineTo { x: f64, y: f64 }, + QuadTo { x1: f64, y1: f64, x: f64, y: f64 }, + CurveTo { x1: f64, y1: f64, x2: f64, y2: f64, x: f64, y: f64 }, + Close, +} + +impl PathCommand { + /// Get the end point of this command + fn end_point(&self) -> Point { + match self { + PathCommand::MoveTo { x, y } + | PathCommand::LineTo { x, y } + | PathCommand::QuadTo { x, y, .. } + | PathCommand::CurveTo { x, y, .. } => Point::new(*x, *y), + PathCommand::Close => Point::new(0.0, 0.0), // Will be handled specially + } + } + + /// Get all control points for this command (from start point) + fn to_points(&self, start: Point) -> Vec { + match self { + PathCommand::LineTo { x, y } => { + vec![start, Point::new(*x, *y)] + } + PathCommand::QuadTo { x1, y1, x, y } => { + vec![start, Point::new(*x1, *y1), Point::new(*x, *y)] + } + PathCommand::CurveTo { x1, y1, x2, y2, x, y } => { + vec![ + start, + Point::new(*x1, *y1), + Point::new(*x2, *y2), + Point::new(*x, *y), + ] + } + _ => vec![start], + } + } + + /// Convert command type to match another command + fn convert_to_type(&self, target: &PathCommand) -> PathCommand { + match target { + PathCommand::CurveTo { .. } => { + // Convert to cubic curve + let end = self.end_point(); + match self { + PathCommand::LineTo { .. } | PathCommand::MoveTo { .. } => { + PathCommand::CurveTo { + x1: end.x, + y1: end.y, + x2: end.x, + y2: end.y, + x: end.x, + y: end.y, + } + } + PathCommand::QuadTo { x1, y1, x, y } => { + // Convert quadratic to cubic + PathCommand::CurveTo { + x1: *x1, + y1: *y1, + x2: *x1, + y2: *y1, + x: *x, + y: *y, + } + } + PathCommand::CurveTo { .. } => self.clone(), + PathCommand::Close => self.clone(), + } + } + PathCommand::QuadTo { .. } => { + // Convert to quadratic curve + let end = self.end_point(); + match self { + PathCommand::LineTo { .. } | PathCommand::MoveTo { .. } => { + PathCommand::QuadTo { + x1: end.x, + y1: end.y, + x: end.x, + y: end.y, + } + } + PathCommand::QuadTo { .. } => self.clone(), + PathCommand::CurveTo { x1, y1, x, y, .. } => { + // Use first control point for quad + PathCommand::QuadTo { + x1: *x1, + y1: *y1, + x: *x, + y: *y, + } + } + PathCommand::Close => self.clone(), + } + } + PathCommand::LineTo { .. } => { + let end = self.end_point(); + PathCommand::LineTo { x: end.x, y: end.y } + } + _ => self.clone(), + } + } +} + +/// Convert points back to a command +fn points_to_command(points: &[Point]) -> PathCommand { + match points.len() { + 2 => PathCommand::LineTo { + x: points[1].x, + y: points[1].y, + }, + 3 => PathCommand::QuadTo { + x1: points[1].x, + y1: points[1].y, + x: points[2].x, + y: points[2].y, + }, + 4 => PathCommand::CurveTo { + x1: points[1].x, + y1: points[1].y, + x2: points[2].x, + y2: points[2].y, + x: points[3].x, + y: points[3].y, + }, + _ => PathCommand::LineTo { + x: points.last().map(|p| p.x).unwrap_or(0.0), + y: points.last().map(|p| p.y).unwrap_or(0.0), + }, + } +} + +/// Split a curve segment into multiple segments using de Casteljau +fn split_segment(start: Point, command: &PathCommand, count: usize) -> Vec { + if count == 0 { + return vec![]; + } + if count == 1 { + return vec![command.clone()]; + } + + // For splittable curves (L, Q, C), use de Casteljau + match command { + PathCommand::LineTo { .. } + | PathCommand::QuadTo { .. } + | PathCommand::CurveTo { .. } => { + let points = command.to_points(start); + split_curve_as_points(&points, count) + .into_iter() + .map(|pts| points_to_command(&pts)) + .collect() + } + _ => { + // For other commands, just repeat + vec![command.clone(); count] + } + } +} + +/// Split a curve (represented as points) into segment_count segments +fn split_curve_as_points(points: &[Point], segment_count: usize) -> Vec> { + let mut segments = Vec::new(); + let mut remaining_curve = points.to_vec(); + let t_increment = 1.0 / segment_count as f64; + + for i in 0..segment_count - 1 { + let t_relative = t_increment / (1.0 - t_increment * i as f64); + let (left, right) = decasteljau(&remaining_curve, t_relative); + segments.push(left); + remaining_curve = right; + } + + segments.push(remaining_curve); + segments +} + +/// Extend a path to match the length of a reference path +fn extend_commands( + commands_to_extend: &[PathCommand], + reference_commands: &[PathCommand], +) -> Vec { + if commands_to_extend.is_empty() || reference_commands.is_empty() { + return commands_to_extend.to_vec(); + } + + let num_segments_to_extend = commands_to_extend.len() - 1; + let num_reference_segments = reference_commands.len() - 1; + + if num_reference_segments == 0 { + return commands_to_extend.to_vec(); + } + + let segment_ratio = num_segments_to_extend as f64 / num_reference_segments as f64; + + // Count how many points should be in each segment + let mut count_per_segment = vec![0; num_segments_to_extend]; + for i in 0..num_reference_segments { + let insert_index = ((segment_ratio * i as f64).floor() as usize) + .min(num_segments_to_extend.saturating_sub(1)); + count_per_segment[insert_index] += 1; + } + + // Start with first command + let mut extended = vec![commands_to_extend[0].clone()]; + let mut current_point = commands_to_extend[0].end_point(); + + // Extend each segment + for (i, &count) in count_per_segment.iter().enumerate() { + if i >= commands_to_extend.len() - 1 { + // Handle last command + for _ in 0..count { + extended.push(commands_to_extend[commands_to_extend.len() - 1].clone()); + } + } else { + // Split this segment + let split_commands = + split_segment(current_point, &commands_to_extend[i + 1], count.max(1)); + extended.extend(split_commands); + current_point = commands_to_extend[i + 1].end_point(); + } + } + + extended +} + +/// Convert a BezPath to our internal command representation +fn bezpath_to_commands(path: &BezPath) -> Vec { + let mut commands = Vec::new(); + + for el in path.elements() { + match el { + PathEl::MoveTo(p) => { + commands.push(PathCommand::MoveTo { x: p.x, y: p.y }); + } + PathEl::LineTo(p) => { + commands.push(PathCommand::LineTo { x: p.x, y: p.y }); + } + PathEl::QuadTo(p1, p2) => { + commands.push(PathCommand::QuadTo { + x1: p1.x, + y1: p1.y, + x: p2.x, + y: p2.y, + }); + } + PathEl::CurveTo(p1, p2, p3) => { + commands.push(PathCommand::CurveTo { + x1: p1.x, + y1: p1.y, + x2: p2.x, + y2: p2.y, + x: p3.x, + y: p3.y, + }); + } + PathEl::ClosePath => { + commands.push(PathCommand::Close); + } + } + } + + commands +} + +/// Convert our internal commands back to a BezPath +fn commands_to_bezpath(commands: &[PathCommand]) -> BezPath { + let mut path = BezPath::new(); + + for cmd in commands { + match cmd { + PathCommand::MoveTo { x, y } => { + path.move_to((*x, *y)); + } + PathCommand::LineTo { x, y } => { + path.line_to((*x, *y)); + } + PathCommand::QuadTo { x1, y1, x, y } => { + path.quad_to((*x1, *y1), (*x, *y)); + } + PathCommand::CurveTo { x1, y1, x2, y2, x, y } => { + path.curve_to((*x1, *y1), (*x2, *y2), (*x, *y)); + } + PathCommand::Close => { + path.close_path(); + } + } + } + + path +} + +/// Interpolate between two paths at parameter t (0.0 to 1.0) +/// +/// Uses the d3-interpolate-path algorithm: +/// 1. Normalize paths to same length by splitting segments +/// 2. Convert commands to matching types +/// 3. Linearly interpolate all parameters +pub fn interpolate_paths(path_a: &BezPath, path_b: &BezPath, t: f64) -> BezPath { + let mut commands_a = bezpath_to_commands(path_a); + let mut commands_b = bezpath_to_commands(path_b); + + // Handle Z (close path) - remove temporarily, add back if both have it + let add_z = commands_a.last().map_or(false, |c| matches!(c, PathCommand::Close)) + && commands_b.last().map_or(false, |c| matches!(c, PathCommand::Close)); + + if commands_a.last().map_or(false, |c| matches!(c, PathCommand::Close)) { + commands_a.pop(); + } + if commands_b.last().map_or(false, |c| matches!(c, PathCommand::Close)) { + commands_b.pop(); + } + + // Handle empty paths + if commands_a.is_empty() && !commands_b.is_empty() { + commands_a.push(commands_b[0].clone()); + } else if commands_b.is_empty() && !commands_a.is_empty() { + commands_b.push(commands_a[0].clone()); + } else if commands_a.is_empty() && commands_b.is_empty() { + return BezPath::new(); + } + + // Extend paths to match length + if commands_a.len() < commands_b.len() { + commands_a = extend_commands(&commands_a, &commands_b); + } else if commands_b.len() < commands_a.len() { + commands_b = extend_commands(&commands_b, &commands_a); + } + + // Convert A commands to match B types + commands_a = commands_a + .iter() + .zip(commands_b.iter()) + .map(|(a, b)| a.convert_to_type(b)) + .collect(); + + // Interpolate + let mut interpolated = Vec::new(); + for (cmd_a, cmd_b) in commands_a.iter().zip(commands_b.iter()) { + let interpolated_cmd = match (cmd_a, cmd_b) { + (PathCommand::MoveTo { x: x1, y: y1 }, PathCommand::MoveTo { x: x2, y: y2 }) => { + PathCommand::MoveTo { + x: x1 + t * (x2 - x1), + y: y1 + t * (y2 - y1), + } + } + (PathCommand::LineTo { x: x1, y: y1 }, PathCommand::LineTo { x: x2, y: y2 }) => { + PathCommand::LineTo { + x: x1 + t * (x2 - x1), + y: y1 + t * (y2 - y1), + } + } + ( + PathCommand::QuadTo { x1: xa1, y1: ya1, x: x1, y: y1 }, + PathCommand::QuadTo { x1: xa2, y1: ya2, x: x2, y: y2 }, + ) => PathCommand::QuadTo { + x1: xa1 + t * (xa2 - xa1), + y1: ya1 + t * (ya2 - ya1), + x: x1 + t * (x2 - x1), + y: y1 + t * (y2 - y1), + }, + ( + PathCommand::CurveTo { x1: xa1, y1: ya1, x2: xb1, y2: yb1, x: x1, y: y1 }, + PathCommand::CurveTo { x1: xa2, y1: ya2, x2: xb2, y2: yb2, x: x2, y: y2 }, + ) => PathCommand::CurveTo { + x1: xa1 + t * (xa2 - xa1), + y1: ya1 + t * (ya2 - ya1), + x2: xb1 + t * (xb2 - xb1), + y2: yb1 + t * (yb2 - yb1), + x: x1 + t * (x2 - x1), + y: y1 + t * (y2 - y1), + }, + (PathCommand::Close, PathCommand::Close) => PathCommand::Close, + _ => cmd_a.clone(), // Fallback + }; + interpolated.push(interpolated_cmd); + } + + if add_z { + interpolated.push(PathCommand::Close); + } + + commands_to_bezpath(&interpolated) +} + +#[cfg(test)] +mod tests { + use super::*; + use kurbo::{Circle, Shape}; + + #[test] + fn test_decasteljau() { + let points = vec![ + Point::new(0.0, 0.0), + Point::new(50.0, 0.0), + Point::new(50.0, 50.0), + Point::new(100.0, 50.0), + ]; + + let (left, right) = decasteljau(&points, 0.5); + assert_eq!(left.len(), 4); + assert_eq!(right.len(), 4); + } + + #[test] + fn test_interpolate_circles() { + let circle1 = Circle::new((100.0, 100.0), 50.0); + let circle2 = Circle::new((100.0, 100.0), 100.0); + + let path1 = circle1.to_path(0.1); + let path2 = circle2.to_path(0.1); + + let interpolated = interpolate_paths(&path1, &path2, 0.5); + assert!(!interpolated.elements().is_empty()); + } +} diff --git a/lightningbeam-ui/lightningbeam-core/src/renderer.rs b/lightningbeam-ui/lightningbeam-core/src/renderer.rs new file mode 100644 index 0000000..59df4a9 --- /dev/null +++ b/lightningbeam-ui/lightningbeam-core/src/renderer.rs @@ -0,0 +1,264 @@ +//! Rendering system for Lightningbeam documents +//! +//! Renders documents to Vello scenes for GPU-accelerated display. + +use crate::animation::TransformProperty; +use crate::document::Document; +use crate::layer::{AnyLayer, VectorLayer}; +use kurbo::Affine; +use vello::kurbo::Rect; +use vello::peniko::Fill; +use vello::Scene; + +/// Render a document to a Vello scene +pub fn render_document(document: &Document, scene: &mut Scene) { + render_document_with_transform(document, scene, Affine::IDENTITY); +} + +/// Render a document to a Vello scene with a base transform +/// The base transform is composed with all object transforms (useful for camera zoom/pan) +pub fn render_document_with_transform(document: &Document, scene: &mut Scene, base_transform: Affine) { + // 1. Draw background + render_background(document, scene, base_transform); + + // 2. Recursively render the root graphics object + render_graphics_object(document, scene, base_transform); +} + +/// Draw the document background +fn render_background(document: &Document, scene: &mut Scene, base_transform: Affine) { + let background_rect = Rect::new(0.0, 0.0, document.width, document.height); + + // Convert our ShapeColor to vello's peniko Color + let background_color = document.background_color.to_peniko(); + + scene.fill( + Fill::NonZero, + base_transform, + background_color, + None, + &background_rect, + ); +} + +/// Recursively render the root graphics object and its children +fn render_graphics_object(document: &Document, scene: &mut Scene, base_transform: Affine) { + // Render all visible layers in the root graphics object + for layer in document.visible_layers() { + render_layer(document, layer, scene, base_transform); + } +} + +/// Render a single layer +fn render_layer(document: &Document, layer: &AnyLayer, scene: &mut Scene, base_transform: Affine) { + match layer { + AnyLayer::Vector(vector_layer) => render_vector_layer(document, vector_layer, scene, base_transform), + AnyLayer::Audio(_) => { + // Audio layers don't render visually + } + AnyLayer::Video(_) => { + // Video rendering not yet implemented + } + } +} + +/// Render a vector layer with all its objects +fn render_vector_layer(document: &Document, layer: &VectorLayer, scene: &mut Scene, base_transform: Affine) { + let time = document.current_time; + + // Get layer-level opacity + let layer_opacity = layer.layer.opacity; + + // Render each object in the layer + for object in &layer.objects { + // Get the shape for this object + let Some(shape) = layer.get_shape(&object.shape_id) else { + continue; + }; + + // Evaluate animated properties + let transform = &object.transform; + let x = layer + .layer + .animation_data + .eval( + &crate::animation::AnimationTarget::Object { + id: object.id, + property: TransformProperty::X, + }, + time, + transform.x, + ); + let y = layer + .layer + .animation_data + .eval( + &crate::animation::AnimationTarget::Object { + id: object.id, + property: TransformProperty::Y, + }, + time, + transform.y, + ); + let rotation = layer + .layer + .animation_data + .eval( + &crate::animation::AnimationTarget::Object { + id: object.id, + property: TransformProperty::Rotation, + }, + time, + transform.rotation, + ); + let scale_x = layer + .layer + .animation_data + .eval( + &crate::animation::AnimationTarget::Object { + id: object.id, + property: TransformProperty::ScaleX, + }, + time, + transform.scale_x, + ); + let scale_y = layer + .layer + .animation_data + .eval( + &crate::animation::AnimationTarget::Object { + id: object.id, + property: TransformProperty::ScaleY, + }, + time, + transform.scale_y, + ); + let opacity = layer + .layer + .animation_data + .eval( + &crate::animation::AnimationTarget::Object { + id: object.id, + property: TransformProperty::Opacity, + }, + time, + transform.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); + + // Build transform matrix (compose with base transform for camera) + let object_transform = Affine::translate((x, y)) + * Affine::rotate(rotation.to_radians()) + * Affine::scale_non_uniform(scale_x, scale_y); + let affine = base_transform * object_transform; + + // Calculate final opacity (layer * object) + let final_opacity = (layer_opacity * opacity) as f32; + + // Render fill if present + 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, + fill_color.g, + fill_color.b, + alpha, + ); + + let fill_rule = match shape.fill_rule { + crate::shape::FillRule::NonZero => Fill::NonZero, + crate::shape::FillRule::EvenOdd => Fill::EvenOdd, + }; + + scene.fill( + fill_rule, + affine, + adjusted_color.to_peniko(), + None, + &path, + ); + } + + // 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, + stroke_color.g, + stroke_color.b, + alpha, + ); + + scene.stroke( + &stroke_style.to_stroke(), + affine, + adjusted_color.to_peniko(), + None, + &path, + ); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::document::Document; + use crate::layer::{AnyLayer, VectorLayer}; + use crate::object::Object; + use crate::shape::{Shape, ShapeColor}; + use kurbo::{Circle, Shape as KurboShape}; + + #[test] + fn test_render_empty_document() { + let doc = Document::new("Test"); + let mut scene = Scene::new(); + + render_document(&doc, &mut scene); + // 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 an object for the shape + let object = Object::new(shape.id); + + // Create a vector layer + let mut vector_layer = VectorLayer::new("Layer 1"); + vector_layer.add_shape(shape); + vector_layer.add_object(object); + + // Add to document + doc.root.add_child(AnyLayer::Vector(vector_layer)); + + // Render + let mut scene = Scene::new(); + render_document(&doc, &mut scene); + // Should render without errors + } +} diff --git a/lightningbeam-ui/lightningbeam-core/src/shape.rs b/lightningbeam-ui/lightningbeam-core/src/shape.rs new file mode 100644 index 0000000..d9cfe01 --- /dev/null +++ b/lightningbeam-ui/lightningbeam-core/src/shape.rs @@ -0,0 +1,331 @@ +//! Shape system for Lightningbeam +//! +//! Provides bezier-based vector shapes with morphing support. +//! All shapes are composed of cubic bezier curves using kurbo::BezPath. + +use crate::path_interpolation::interpolate_paths; +use kurbo::{BezPath, Cap as KurboCap, Join as KurboJoin, Stroke as KurboStroke}; +use vello::peniko::{Brush, Color, Fill}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +/// A version of a shape (for morphing between different states) +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct ShapeVersion { + /// The bezier path defining this shape version + pub path: BezPath, + /// Index in the shape's versions array + pub index: usize, +} + +impl ShapeVersion { + /// Create a new shape version + pub fn new(path: BezPath, index: usize) -> Self { + Self { path, index } + } +} + +/// Fill rule for shapes +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum FillRule { + /// Non-zero winding rule + NonZero, + /// Even-odd rule + EvenOdd, +} + +impl Default for FillRule { + fn default() -> Self { + FillRule::NonZero + } +} + +impl From for Fill { + fn from(rule: FillRule) -> Self { + match rule { + FillRule::NonZero => Fill::NonZero, + FillRule::EvenOdd => Fill::EvenOdd, + } + } +} + +/// Stroke cap style +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum Cap { + Butt, + Round, + Square, +} + +impl Default for Cap { + fn default() -> Self { + Cap::Butt + } +} + +impl From for KurboCap { + fn from(cap: Cap) -> Self { + match cap { + Cap::Butt => KurboCap::Butt, + Cap::Round => KurboCap::Round, + Cap::Square => KurboCap::Square, + } + } +} + +/// Stroke join style +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum Join { + Miter, + Round, + Bevel, +} + +impl Default for Join { + fn default() -> Self { + Join::Miter + } +} + +impl From for KurboJoin { + fn from(join: Join) -> Self { + match join { + Join::Miter => KurboJoin::Miter, + Join::Round => KurboJoin::Round, + Join::Bevel => KurboJoin::Bevel, + } + } +} + +/// Stroke style for shapes +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct StrokeStyle { + /// Stroke width in pixels + pub width: f64, + /// Cap style + #[serde(default)] + pub cap: Cap, + /// Join style + #[serde(default)] + pub join: Join, + /// Miter limit (for miter joins) + #[serde(default = "default_miter_limit")] + pub miter_limit: f64, +} + +fn default_miter_limit() -> f64 { + 4.0 +} + +impl Default for StrokeStyle { + fn default() -> Self { + Self { + width: 1.0, + cap: Cap::Butt, + join: Join::Miter, + miter_limit: 4.0, + } + } +} + +impl StrokeStyle { + /// Convert to kurbo Stroke + pub fn to_stroke(&self) -> KurboStroke { + KurboStroke { + width: self.width, + join: self.join.into(), + miter_limit: self.miter_limit, + start_cap: self.cap.into(), + end_cap: self.cap.into(), + dash_pattern: Default::default(), + dash_offset: 0.0, + } + } +} + +/// Serializable color representation +#[derive(Clone, Copy, Debug, Serialize, Deserialize)] +pub struct ShapeColor { + pub r: u8, + pub g: u8, + pub b: u8, + pub a: u8, +} + +impl ShapeColor { + /// Create a new color + pub fn new(r: u8, g: u8, b: u8, a: u8) -> Self { + Self { r, g, b, a } + } + + /// Create from RGB (opaque) + pub fn rgb(r: u8, g: u8, b: u8) -> Self { + Self { r, g, b, a: 255 } + } + + /// Create from RGBA + pub fn rgba(r: u8, g: u8, b: u8, a: u8) -> Self { + Self { r, g, b, a } + } + + /// Convert to peniko Color + pub fn to_peniko(&self) -> Color { + Color::rgba8(self.r, self.g, self.b, self.a) + } + + /// Convert to peniko Brush + pub fn to_brush(&self) -> Brush { + Brush::Solid(self.to_peniko()) + } +} + +impl Default for ShapeColor { + fn default() -> Self { + Self::rgb(0, 0, 0) + } +} + +impl From for ShapeColor { + fn from(color: Color) -> Self { + Self { + r: color.r, + g: color.g, + b: color.b, + a: color.a, + } + } +} + +/// A shape with geometry and styling +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct Shape { + /// Unique identifier for this shape + pub id: Uuid, + + /// Multiple versions of the shape for morphing + /// The shape animates between these by varying the shapeIndex property + pub versions: Vec, + + /// Fill color + pub fill_color: Option, + + /// Fill rule + #[serde(default)] + pub fill_rule: FillRule, + + /// Stroke color + pub stroke_color: Option, + + /// Stroke style + pub stroke_style: Option, +} + +impl Shape { + /// Create a new shape with a single path + pub fn new(path: BezPath) -> Self { + Self { + id: Uuid::new_v4(), + versions: vec![ShapeVersion::new(path, 0)], + fill_color: Some(ShapeColor::rgb(0, 0, 0)), + fill_rule: FillRule::NonZero, + stroke_color: None, + stroke_style: None, + } + } + + /// Create a new shape with a specific ID + pub fn with_id(id: Uuid, path: BezPath) -> Self { + Self { + id, + versions: vec![ShapeVersion::new(path, 0)], + fill_color: Some(ShapeColor::rgb(0, 0, 0)), + fill_rule: FillRule::NonZero, + stroke_color: None, + stroke_style: None, + } + } + + /// Add a new version for morphing + pub fn add_version(&mut self, path: BezPath) -> usize { + let index = self.versions.len(); + self.versions.push(ShapeVersion::new(path, index)); + index + } + + /// Get the interpolated path for a fractional shape index + /// Used for shape morphing animation using d3-interpolate-path algorithm + pub fn get_morphed_path(&self, shape_index: f64) -> BezPath { + if self.versions.is_empty() { + return BezPath::new(); + } + + // Clamp to valid range + let shape_index = shape_index.max(0.0); + + // Get the two versions to interpolate between + let index0 = shape_index.floor() as usize; + let index1 = (index0 + 1).min(self.versions.len() - 1); + + if index0 >= self.versions.len() { + // Beyond last version, return last version + return self.versions.last().unwrap().path.clone(); + } + + if index0 == index1 { + // Exactly on a version + return self.versions[index0].path.clone(); + } + + // Interpolate between the two versions using d3-interpolate-path + let t = shape_index - index0 as f64; + interpolate_paths(&self.versions[index0].path, &self.versions[index1].path, t) + } + + /// Set fill color + pub fn with_fill(mut self, color: ShapeColor) -> Self { + self.fill_color = Some(color); + self + } + + /// Set stroke + pub fn with_stroke(mut self, color: ShapeColor, style: StrokeStyle) -> Self { + self.stroke_color = Some(color); + self.stroke_style = Some(style); + self + } + + /// Set fill rule + pub fn with_fill_rule(mut self, rule: FillRule) -> Self { + self.fill_rule = rule; + self + } +} + +#[cfg(test)] +mod tests { + use super::*; + use kurbo::{Circle, Shape as KurboShape}; + + #[test] + fn test_shape_creation() { + let circle = Circle::new((100.0, 100.0), 50.0); + let path = circle.to_path(0.1); + let shape = Shape::new(path); + + assert_eq!(shape.versions.len(), 1); + assert!(shape.fill_color.is_some()); + } + + #[test] + fn test_shape_morphing() { + let circle1 = Circle::new((100.0, 100.0), 50.0); + let circle2 = Circle::new((100.0, 100.0), 100.0); + + let mut shape = Shape::new(circle1.to_path(0.1)); + shape.add_version(circle2.to_path(0.1)); + + // Test that morphing doesn't panic + let _morphed = shape.get_morphed_path(0.5); + assert_eq!(shape.versions.len(), 2); + } +} diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs index 1076741..57dfa0f 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs @@ -226,30 +226,41 @@ impl egui_wgpu::CallbackTrait for VelloCallback { vello_resources.ensure_texture(device, width, height); - // Build Vello scene with a test rectangle + // Build Vello scene using the document renderer let mut scene = vello::Scene::new(); - // Draw a colored rectangle as proof of concept - use vello::kurbo::{RoundedRect, Affine}; - use vello::peniko::Color; + // Create a test document with a simple shape + use lightningbeam_core::document::Document; + use lightningbeam_core::layer::{AnyLayer, VectorLayer}; + use lightningbeam_core::object::Object; + use lightningbeam_core::shape::{Shape, ShapeColor}; + use vello::kurbo::{Circle, Shape as KurboShape}; - let rect = RoundedRect::new( - 100.0, 100.0, - 400.0, 300.0, - 10.0, // corner radius - ); + let mut doc = Document::new("Test Animation"); - // Apply camera transform: translate for pan, scale for zoom - let transform = Affine::translate((self.pan_offset.x as f64, self.pan_offset.y as f64)) + // Create a simple circle shape + let circle = Circle::new((200.0, 150.0), 50.0); + let path = circle.to_path(0.1); + let shape = Shape::new(path).with_fill(ShapeColor::rgb(100, 150, 250)); + + // Create an object for the shape + let object = Object::new(shape.id); + + // Create a vector layer + let mut vector_layer = VectorLayer::new("Layer 1"); + vector_layer.add_shape(shape); + vector_layer.add_object(object); + + // Add to document + doc.root.add_child(AnyLayer::Vector(vector_layer)); + + // Build camera transform: translate for pan, scale for zoom + use vello::kurbo::Affine; + let camera_transform = Affine::translate((self.pan_offset.x as f64, self.pan_offset.y as f64)) * Affine::scale(self.zoom as f64); - scene.fill( - vello::peniko::Fill::NonZero, - transform, - Color::rgb8(100, 150, 250), - None, - &rect, - ); + // Render the document to the scene with camera transform + lightningbeam_core::renderer::render_document_with_transform(&doc, &mut scene, camera_transform); // Render scene to texture if let Some(texture_view) = &vello_resources.texture_view {