Render shape on stage

This commit is contained in:
Skyler Lehmkuhl 2025-11-16 02:40:06 -05:00
parent 08232454a7
commit 1324cae7e3
12 changed files with 2350 additions and 19 deletions

View File

@ -2358,6 +2358,7 @@ checksum = "c62026ae44756f8a599ba21140f350303d4f08dcdcc71b5ad9c9bb8128c13c62"
dependencies = [ dependencies = [
"arrayvec", "arrayvec",
"euclid", "euclid",
"serde",
"smallvec", "smallvec",
] ]
@ -2438,8 +2439,11 @@ dependencies = [
name = "lightningbeam-core" name = "lightningbeam-core"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"kurbo 0.11.3",
"serde", "serde",
"serde_json", "serde_json",
"uuid",
"vello",
] ]
[[package]] [[package]]
@ -3988,6 +3992,9 @@ name = "smallvec"
version = "1.15.1" version = "1.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
dependencies = [
"serde",
]
[[package]] [[package]]
name = "smithay-client-toolkit" name = "smithay-client-toolkit"
@ -4596,6 +4603,18 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" 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]] [[package]]
name = "v_frame" name = "v_frame"
version = "0.3.9" version = "0.3.9"

View File

@ -14,7 +14,7 @@ egui-wgpu = "0.29"
# GPU Rendering # GPU Rendering
vello = "0.3" vello = "0.3"
wgpu = "22" wgpu = "22"
kurbo = "0.11" kurbo = { version = "0.11", features = ["serde"] }
peniko = "0.5" peniko = "0.5"
# Windowing # Windowing

View File

@ -6,3 +6,10 @@ edition = "2021"
[dependencies] [dependencies]
serde = { workspace = true } serde = { workspace = true }
serde_json = { workspace = true } serde_json = { workspace = true }
# Geometry and rendering
kurbo = { workspace = true }
vello = { workspace = true }
# Unique identifiers
uuid = { version = "1.0", features = ["v4", "serde"] }

View File

@ -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<Keyframe>,
/// 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<AnimationTarget, AnimationCurve>,
}
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<AnimationCurve> {
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)
}
}

View File

@ -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<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
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<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")
}
}
/// 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<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()
}
}
/// 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<Item = &AnyLayer> {
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);
}
}

View File

@ -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<String>) -> 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<String>) -> 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<Shape>,
/// Object instances (references to shapes with transforms)
pub objects: Vec<Object>,
}
impl VectorLayer {
/// Create a new vector layer
pub fn new(name: impl Into<String>) -> 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<String>,
}
impl AudioLayer {
/// Create a new audio layer
pub fn new(name: impl Into<String>) -> 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<String>,
}
impl VideoLayer {
/// Create a new video layer
pub fn new(name: impl Into<String>) -> 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));
}
}

View File

@ -4,3 +4,10 @@
pub mod layout; pub mod layout;
pub mod pane; pub mod pane;
pub mod tool; 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;

View File

@ -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<String>,
}
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<String>) -> 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);
}
}

View File

@ -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<Point>, Vec<Point>) {
let mut left = Vec::new();
let mut right = Vec::new();
fn recurse(points: &[Point], t: f64, left: &mut Vec<Point>, right: &mut Vec<Point>) {
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<Point> {
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<PathCommand> {
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<Vec<Point>> {
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<PathCommand> {
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<PathCommand> {
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());
}
}

View File

@ -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
}
}

View File

@ -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<FillRule> 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<Cap> 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<Join> 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<Color> 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<ShapeVersion>,
/// Fill color
pub fill_color: Option<ShapeColor>,
/// Fill rule
#[serde(default)]
pub fill_rule: FillRule,
/// Stroke color
pub stroke_color: Option<ShapeColor>,
/// Stroke style
pub stroke_style: Option<StrokeStyle>,
}
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);
}
}

View File

@ -226,30 +226,41 @@ impl egui_wgpu::CallbackTrait for VelloCallback {
vello_resources.ensure_texture(device, width, height); 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(); let mut scene = vello::Scene::new();
// Draw a colored rectangle as proof of concept // Create a test document with a simple shape
use vello::kurbo::{RoundedRect, Affine}; use lightningbeam_core::document::Document;
use vello::peniko::Color; 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( let mut doc = Document::new("Test Animation");
100.0, 100.0,
400.0, 300.0,
10.0, // corner radius
);
// Apply camera transform: translate for pan, scale for zoom // Create a simple circle shape
let transform = Affine::translate((self.pan_offset.x as f64, self.pan_offset.y as f64)) 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); * Affine::scale(self.zoom as f64);
scene.fill( // Render the document to the scene with camera transform
vello::peniko::Fill::NonZero, lightningbeam_core::renderer::render_document_with_transform(&doc, &mut scene, camera_transform);
transform,
Color::rgb8(100, 150, 250),
None,
&rect,
);
// Render scene to texture // Render scene to texture
if let Some(texture_view) = &vello_resources.texture_view { if let Some(texture_view) = &vello_resources.texture_view {