Render shape on stage
This commit is contained in:
parent
08232454a7
commit
1324cae7e3
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"] }
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in New Issue