414 lines
10 KiB
Rust
414 lines
10 KiB
Rust
//! 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::object::Transform;
|
|
use crate::path_interpolation::interpolate_paths;
|
|
use kurbo::{BezPath, Cap as KurboCap, Join as KurboJoin, Stroke as KurboStroke};
|
|
use vello::peniko::{Brush, Color, Fill};
|
|
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::Round
|
|
}
|
|
}
|
|
|
|
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::Round,
|
|
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, PartialEq, Eq, 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::from_rgba8(self.r, self.g, self.b, self.a)
|
|
}
|
|
|
|
/// Convert to peniko Brush
|
|
pub fn to_brush(&self) -> Brush {
|
|
Brush::Solid(self.to_peniko())
|
|
}
|
|
|
|
/// Create from egui Color32
|
|
pub fn from_egui(color: egui::Color32) -> Self {
|
|
Self {
|
|
r: color.r(),
|
|
g: color.g(),
|
|
b: color.b(),
|
|
a: color.a(),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Default for ShapeColor {
|
|
fn default() -> Self {
|
|
Self::rgb(0, 0, 0)
|
|
}
|
|
}
|
|
|
|
impl From<Color> for ShapeColor {
|
|
fn from(color: Color) -> Self {
|
|
// peniko 0.4 uses components array [r, g, b, a] as floats 0.0-1.0
|
|
let components = color.components;
|
|
Self {
|
|
r: (components[0] * 255.0) as u8,
|
|
g: (components[1] * 255.0) as u8,
|
|
b: (components[2] * 255.0) as u8,
|
|
a: (components[3] * 255.0) as u8,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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 (used when image_fill is None)
|
|
pub fill_color: Option<ShapeColor>,
|
|
|
|
/// Image fill - references an ImageAsset by UUID
|
|
/// When set, the image is rendered as the fill instead of fill_color
|
|
#[serde(default)]
|
|
pub image_fill: Option<Uuid>,
|
|
|
|
/// Fill rule
|
|
#[serde(default)]
|
|
pub fill_rule: FillRule,
|
|
|
|
/// Stroke color
|
|
pub stroke_color: Option<ShapeColor>,
|
|
|
|
/// Stroke style
|
|
pub stroke_style: Option<StrokeStyle>,
|
|
|
|
/// Transform (position, rotation, scale, skew)
|
|
#[serde(default)]
|
|
pub transform: Transform,
|
|
|
|
/// Opacity (0.0 to 1.0)
|
|
#[serde(default = "default_opacity")]
|
|
pub opacity: f64,
|
|
|
|
/// Display name
|
|
#[serde(default)]
|
|
pub name: Option<String>,
|
|
}
|
|
|
|
fn default_opacity() -> f64 {
|
|
1.0
|
|
}
|
|
|
|
impl Shape {
|
|
/// Create a new shape with a single path (no fill or stroke by default)
|
|
pub fn new(path: BezPath) -> Self {
|
|
Self {
|
|
id: Uuid::new_v4(),
|
|
versions: vec![ShapeVersion::new(path, 0)],
|
|
fill_color: None,
|
|
image_fill: None,
|
|
fill_rule: FillRule::NonZero,
|
|
stroke_color: None,
|
|
stroke_style: None,
|
|
transform: Transform::default(),
|
|
opacity: 1.0,
|
|
name: None,
|
|
}
|
|
}
|
|
|
|
/// Create a new shape with a specific ID (no fill or stroke by default)
|
|
pub fn with_id(id: Uuid, path: BezPath) -> Self {
|
|
Self {
|
|
id,
|
|
versions: vec![ShapeVersion::new(path, 0)],
|
|
fill_color: None,
|
|
image_fill: None,
|
|
fill_rule: FillRule::NonZero,
|
|
stroke_color: None,
|
|
stroke_style: None,
|
|
transform: Transform::default(),
|
|
opacity: 1.0,
|
|
name: None,
|
|
}
|
|
}
|
|
|
|
/// Set image fill (references an ImageAsset by UUID)
|
|
pub fn with_image_fill(mut self, image_asset_id: Uuid) -> Self {
|
|
self.image_fill = Some(image_asset_id);
|
|
self.fill_color = None; // Image fill takes precedence
|
|
self
|
|
}
|
|
|
|
/// 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
|
|
}
|
|
|
|
/// Set position
|
|
pub fn with_position(mut self, x: f64, y: f64) -> Self {
|
|
self.transform.x = x;
|
|
self.transform.y = y;
|
|
self
|
|
}
|
|
|
|
/// Set transform
|
|
pub fn with_transform(mut self, transform: Transform) -> Self {
|
|
self.transform = transform;
|
|
self
|
|
}
|
|
|
|
/// Set opacity
|
|
pub fn with_opacity(mut self, opacity: f64) -> Self {
|
|
self.opacity = opacity;
|
|
self
|
|
}
|
|
|
|
/// Set display name
|
|
pub fn with_name(mut self, name: impl Into<String>) -> Self {
|
|
self.name = Some(name.into());
|
|
self
|
|
}
|
|
|
|
/// Get the base path (first version) for this shape
|
|
///
|
|
/// This is useful for hit testing and bounding box calculations
|
|
/// when shape morphing is not being considered.
|
|
pub fn path(&self) -> &BezPath {
|
|
&self.versions[0].path
|
|
}
|
|
}
|
|
|
|
#[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);
|
|
}
|
|
}
|