Add info panel
This commit is contained in:
parent
4d1e052ee7
commit
c943f7bfe6
|
|
@ -82,25 +82,48 @@ impl AudioFile {
|
|||
/// Generate a waveform overview with the specified number of peaks
|
||||
/// This creates a downsampled representation suitable for timeline visualization
|
||||
pub fn generate_waveform_overview(&self, target_peaks: usize) -> Vec<crate::io::WaveformPeak> {
|
||||
self.generate_waveform_overview_range(0, self.frames as usize, target_peaks)
|
||||
}
|
||||
|
||||
/// Generate a waveform overview for a specific range of frames
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `start_frame` - Starting frame index (0-based)
|
||||
/// * `end_frame` - Ending frame index (exclusive)
|
||||
/// * `target_peaks` - Desired number of peaks to generate
|
||||
pub fn generate_waveform_overview_range(
|
||||
&self,
|
||||
start_frame: usize,
|
||||
end_frame: usize,
|
||||
target_peaks: usize,
|
||||
) -> Vec<crate::io::WaveformPeak> {
|
||||
if self.frames == 0 || target_peaks == 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let total_frames = self.frames as usize;
|
||||
let frames_per_peak = (total_frames / target_peaks).max(1);
|
||||
let actual_peaks = (total_frames + frames_per_peak - 1) / frames_per_peak;
|
||||
let start_frame = start_frame.min(total_frames);
|
||||
let end_frame = end_frame.min(total_frames);
|
||||
|
||||
if start_frame >= end_frame {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let range_frames = end_frame - start_frame;
|
||||
let frames_per_peak = (range_frames / target_peaks).max(1);
|
||||
let actual_peaks = (range_frames + frames_per_peak - 1) / frames_per_peak;
|
||||
|
||||
let mut peaks = Vec::with_capacity(actual_peaks);
|
||||
|
||||
for peak_idx in 0..actual_peaks {
|
||||
let start_frame = peak_idx * frames_per_peak;
|
||||
let end_frame = ((peak_idx + 1) * frames_per_peak).min(total_frames);
|
||||
let peak_start = start_frame + peak_idx * frames_per_peak;
|
||||
let peak_end = (start_frame + (peak_idx + 1) * frames_per_peak).min(end_frame);
|
||||
|
||||
let mut min = 0.0f32;
|
||||
let mut max = 0.0f32;
|
||||
|
||||
// Scan all samples in this window
|
||||
for frame_idx in start_frame..end_frame {
|
||||
for frame_idx in peak_start..peak_end {
|
||||
// For multi-channel audio, combine all channels
|
||||
for ch in 0..self.channels as usize {
|
||||
let sample_idx = frame_idx * self.channels as usize + ch;
|
||||
|
|
@ -159,6 +182,25 @@ impl AudioClipPool {
|
|||
})
|
||||
}
|
||||
|
||||
/// Generate waveform overview for a specific range of a file in the pool
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `pool_index` - Index of the file in the pool
|
||||
/// * `start_frame` - Starting frame index (0-based)
|
||||
/// * `end_frame` - Ending frame index (exclusive)
|
||||
/// * `target_peaks` - Desired number of peaks to generate
|
||||
pub fn generate_waveform_range(
|
||||
&self,
|
||||
pool_index: usize,
|
||||
start_frame: usize,
|
||||
end_frame: usize,
|
||||
target_peaks: usize,
|
||||
) -> Option<Vec<crate::io::WaveformPeak>> {
|
||||
self.files.get(pool_index).map(|file| {
|
||||
file.generate_waveform_overview_range(start_frame, end_frame, target_peaks)
|
||||
})
|
||||
}
|
||||
|
||||
/// Add an audio file to the pool and return its index
|
||||
pub fn add_file(&mut self, file: AudioFile) -> usize {
|
||||
let index = self.files.len();
|
||||
|
|
|
|||
|
|
@ -136,25 +136,48 @@ impl AudioFile {
|
|||
/// Generate a waveform overview with the specified number of peaks
|
||||
/// This creates a downsampled representation suitable for timeline visualization
|
||||
pub fn generate_waveform_overview(&self, target_peaks: usize) -> Vec<WaveformPeak> {
|
||||
self.generate_waveform_overview_range(0, self.frames as usize, target_peaks)
|
||||
}
|
||||
|
||||
/// Generate a waveform overview for a specific range of frames
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `start_frame` - Starting frame index (0-based)
|
||||
/// * `end_frame` - Ending frame index (exclusive)
|
||||
/// * `target_peaks` - Desired number of peaks to generate
|
||||
pub fn generate_waveform_overview_range(
|
||||
&self,
|
||||
start_frame: usize,
|
||||
end_frame: usize,
|
||||
target_peaks: usize,
|
||||
) -> Vec<WaveformPeak> {
|
||||
if self.frames == 0 || target_peaks == 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let total_frames = self.frames as usize;
|
||||
let frames_per_peak = (total_frames / target_peaks).max(1);
|
||||
let actual_peaks = (total_frames + frames_per_peak - 1) / frames_per_peak;
|
||||
let start_frame = start_frame.min(total_frames);
|
||||
let end_frame = end_frame.min(total_frames);
|
||||
|
||||
if start_frame >= end_frame {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let range_frames = end_frame - start_frame;
|
||||
let frames_per_peak = (range_frames / target_peaks).max(1);
|
||||
let actual_peaks = (range_frames + frames_per_peak - 1) / frames_per_peak;
|
||||
|
||||
let mut peaks = Vec::with_capacity(actual_peaks);
|
||||
|
||||
for peak_idx in 0..actual_peaks {
|
||||
let start_frame = peak_idx * frames_per_peak;
|
||||
let end_frame = ((peak_idx + 1) * frames_per_peak).min(total_frames);
|
||||
let peak_start = start_frame + peak_idx * frames_per_peak;
|
||||
let peak_end = (start_frame + (peak_idx + 1) * frames_per_peak).min(end_frame);
|
||||
|
||||
let mut min = 0.0f32;
|
||||
let mut max = 0.0f32;
|
||||
|
||||
// Scan all samples in this window
|
||||
for frame_idx in start_frame..end_frame {
|
||||
for frame_idx in peak_start..peak_end {
|
||||
// For multi-channel audio, combine all channels
|
||||
for ch in 0..self.channels as usize {
|
||||
let sample_idx = frame_idx * self.channels as usize + ch;
|
||||
|
|
|
|||
|
|
@ -9,8 +9,16 @@
|
|||
//! - `ActionExecutor`: Wraps the document and manages undo/redo stacks
|
||||
//! - Document mutations are only accessible via `pub(crate)` methods
|
||||
//! - External code gets read-only access via `ActionExecutor::document()`
|
||||
//!
|
||||
//! ## Memory Model
|
||||
//!
|
||||
//! The document is stored in an `Arc<Document>` for efficient cloning during
|
||||
//! GPU render callbacks. When mutation is needed, `Arc::make_mut()` provides
|
||||
//! copy-on-write semantics - if other Arc holders exist (e.g., in-flight render
|
||||
//! callbacks), the document is cloned before mutation, preserving their snapshot.
|
||||
|
||||
use crate::document::Document;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Action trait for undo/redo operations
|
||||
///
|
||||
|
|
@ -31,9 +39,12 @@ pub trait Action: Send {
|
|||
///
|
||||
/// This is the only way to get mutable access to the document, ensuring
|
||||
/// all mutations go through the action system.
|
||||
///
|
||||
/// The document is stored in `Arc<Document>` for efficient sharing with
|
||||
/// render callbacks. Use `document_arc()` for cheap cloning to GPU passes.
|
||||
pub struct ActionExecutor {
|
||||
/// The document being edited
|
||||
document: Document,
|
||||
/// The document being edited (wrapped in Arc for cheap cloning)
|
||||
document: Arc<Document>,
|
||||
|
||||
/// Stack of executed actions (for undo)
|
||||
undo_stack: Vec<Box<dyn Action>>,
|
||||
|
|
@ -49,7 +60,7 @@ impl ActionExecutor {
|
|||
/// Create a new action executor with the given document
|
||||
pub fn new(document: Document) -> Self {
|
||||
Self {
|
||||
document,
|
||||
document: Arc::new(document),
|
||||
undo_stack: Vec::new(),
|
||||
redo_stack: Vec::new(),
|
||||
max_undo_depth: 100, // Default: keep last 100 actions
|
||||
|
|
@ -64,19 +75,33 @@ impl ActionExecutor {
|
|||
&self.document
|
||||
}
|
||||
|
||||
/// Get a cheap clone of the document Arc for render callbacks
|
||||
///
|
||||
/// Use this when passing the document to GPU render passes or other
|
||||
/// contexts that need to own a reference. Cloning Arc is just a pointer
|
||||
/// copy + atomic increment, not a deep clone.
|
||||
pub fn document_arc(&self) -> Arc<Document> {
|
||||
Arc::clone(&self.document)
|
||||
}
|
||||
|
||||
/// Get mutable access to the document
|
||||
///
|
||||
/// Uses copy-on-write semantics: if other Arc holders exist (e.g., in-flight
|
||||
/// render callbacks), the document is cloned before mutation. Otherwise,
|
||||
/// returns direct mutable access.
|
||||
///
|
||||
/// Note: This should only be used for live previews. Permanent changes
|
||||
/// should go through the execute() method to support undo/redo.
|
||||
pub fn document_mut(&mut self) -> &mut Document {
|
||||
&mut self.document
|
||||
Arc::make_mut(&mut self.document)
|
||||
}
|
||||
|
||||
/// Execute an action and add it to the undo stack
|
||||
///
|
||||
/// This clears the redo stack since we're creating a new timeline branch.
|
||||
pub fn execute(&mut self, mut action: Box<dyn Action>) {
|
||||
// Apply the action
|
||||
action.execute(&mut self.document);
|
||||
// Apply the action (uses copy-on-write if other Arc holders exist)
|
||||
action.execute(Arc::make_mut(&mut self.document));
|
||||
|
||||
// Clear redo stack (new action invalidates redo history)
|
||||
self.redo_stack.clear();
|
||||
|
|
@ -95,8 +120,8 @@ impl ActionExecutor {
|
|||
/// Returns true if an action was undone, false if undo stack is empty.
|
||||
pub fn undo(&mut self) -> bool {
|
||||
if let Some(mut action) = self.undo_stack.pop() {
|
||||
// Rollback the action
|
||||
action.rollback(&mut self.document);
|
||||
// Rollback the action (uses copy-on-write if other Arc holders exist)
|
||||
action.rollback(Arc::make_mut(&mut self.document));
|
||||
|
||||
// Move to redo stack
|
||||
self.redo_stack.push(action);
|
||||
|
|
@ -112,8 +137,8 @@ impl ActionExecutor {
|
|||
/// Returns true if an action was redone, false if redo stack is empty.
|
||||
pub fn redo(&mut self) -> bool {
|
||||
if let Some(mut action) = self.redo_stack.pop() {
|
||||
// Re-execute the action
|
||||
action.execute(&mut self.document);
|
||||
// Re-execute the action (uses copy-on-write if other Arc holders exist)
|
||||
action.execute(Arc::make_mut(&mut self.document));
|
||||
|
||||
// Move back to undo stack
|
||||
self.undo_stack.push(action);
|
||||
|
|
|
|||
|
|
@ -9,7 +9,10 @@ pub mod add_shape;
|
|||
pub mod move_clip_instances;
|
||||
pub mod move_objects;
|
||||
pub mod paint_bucket;
|
||||
pub mod set_document_properties;
|
||||
pub mod set_instance_properties;
|
||||
pub mod set_layer_properties;
|
||||
pub mod set_shape_properties;
|
||||
pub mod transform_clip_instances;
|
||||
pub mod transform_objects;
|
||||
pub mod trim_clip_instances;
|
||||
|
|
@ -20,7 +23,10 @@ pub use add_shape::AddShapeAction;
|
|||
pub use move_clip_instances::MoveClipInstancesAction;
|
||||
pub use move_objects::MoveShapeInstancesAction;
|
||||
pub use paint_bucket::PaintBucketAction;
|
||||
pub use set_document_properties::SetDocumentPropertiesAction;
|
||||
pub use set_instance_properties::{InstancePropertyChange, SetInstancePropertiesAction};
|
||||
pub use set_layer_properties::{LayerProperty, SetLayerPropertiesAction};
|
||||
pub use set_shape_properties::{SetShapePropertiesAction, ShapePropertyChange};
|
||||
pub use transform_clip_instances::TransformClipInstancesAction;
|
||||
pub use transform_objects::TransformShapeInstancesAction;
|
||||
pub use trim_clip_instances::{TrimClipInstancesAction, TrimData, TrimType};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,183 @@
|
|||
//! Set document properties action
|
||||
//!
|
||||
//! Handles changing document-level properties (width, height, duration, framerate)
|
||||
//! with undo/redo support.
|
||||
|
||||
use crate::action::Action;
|
||||
use crate::document::Document;
|
||||
|
||||
/// Individual property change for a document
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum DocumentPropertyChange {
|
||||
Width(f64),
|
||||
Height(f64),
|
||||
Duration(f64),
|
||||
Framerate(f64),
|
||||
}
|
||||
|
||||
impl DocumentPropertyChange {
|
||||
/// Extract the f64 value from any variant
|
||||
fn value(&self) -> f64 {
|
||||
match self {
|
||||
DocumentPropertyChange::Width(v) => *v,
|
||||
DocumentPropertyChange::Height(v) => *v,
|
||||
DocumentPropertyChange::Duration(v) => *v,
|
||||
DocumentPropertyChange::Framerate(v) => *v,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Action that sets a property on the document
|
||||
pub struct SetDocumentPropertiesAction {
|
||||
/// The new property value
|
||||
property: DocumentPropertyChange,
|
||||
/// The old value for undo
|
||||
old_value: Option<f64>,
|
||||
}
|
||||
|
||||
impl SetDocumentPropertiesAction {
|
||||
/// Create a new action to set width
|
||||
pub fn set_width(width: f64) -> Self {
|
||||
Self {
|
||||
property: DocumentPropertyChange::Width(width),
|
||||
old_value: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a new action to set height
|
||||
pub fn set_height(height: f64) -> Self {
|
||||
Self {
|
||||
property: DocumentPropertyChange::Height(height),
|
||||
old_value: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a new action to set duration
|
||||
pub fn set_duration(duration: f64) -> Self {
|
||||
Self {
|
||||
property: DocumentPropertyChange::Duration(duration),
|
||||
old_value: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a new action to set framerate
|
||||
pub fn set_framerate(framerate: f64) -> Self {
|
||||
Self {
|
||||
property: DocumentPropertyChange::Framerate(framerate),
|
||||
old_value: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn get_current_value(&self, document: &Document) -> f64 {
|
||||
match &self.property {
|
||||
DocumentPropertyChange::Width(_) => document.width,
|
||||
DocumentPropertyChange::Height(_) => document.height,
|
||||
DocumentPropertyChange::Duration(_) => document.duration,
|
||||
DocumentPropertyChange::Framerate(_) => document.framerate,
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_value(&self, document: &mut Document, value: f64) {
|
||||
match &self.property {
|
||||
DocumentPropertyChange::Width(_) => document.width = value,
|
||||
DocumentPropertyChange::Height(_) => document.height = value,
|
||||
DocumentPropertyChange::Duration(_) => document.duration = value,
|
||||
DocumentPropertyChange::Framerate(_) => document.framerate = value,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Action for SetDocumentPropertiesAction {
|
||||
fn execute(&mut self, document: &mut Document) {
|
||||
// Store old value if not already stored
|
||||
if self.old_value.is_none() {
|
||||
self.old_value = Some(self.get_current_value(document));
|
||||
}
|
||||
|
||||
// Apply new value
|
||||
let new_value = self.property.value();
|
||||
self.apply_value(document, new_value);
|
||||
}
|
||||
|
||||
fn rollback(&mut self, document: &mut Document) {
|
||||
if let Some(old_value) = self.old_value {
|
||||
self.apply_value(document, old_value);
|
||||
}
|
||||
}
|
||||
|
||||
fn description(&self) -> String {
|
||||
let property_name = match &self.property {
|
||||
DocumentPropertyChange::Width(_) => "canvas width",
|
||||
DocumentPropertyChange::Height(_) => "canvas height",
|
||||
DocumentPropertyChange::Duration(_) => "duration",
|
||||
DocumentPropertyChange::Framerate(_) => "framerate",
|
||||
};
|
||||
format!("Set {}", property_name)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_set_width() {
|
||||
let mut document = Document::new("Test");
|
||||
document.width = 1920.0;
|
||||
|
||||
let mut action = SetDocumentPropertiesAction::set_width(1280.0);
|
||||
action.execute(&mut document);
|
||||
assert_eq!(document.width, 1280.0);
|
||||
|
||||
action.rollback(&mut document);
|
||||
assert_eq!(document.width, 1920.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_set_height() {
|
||||
let mut document = Document::new("Test");
|
||||
document.height = 1080.0;
|
||||
|
||||
let mut action = SetDocumentPropertiesAction::set_height(720.0);
|
||||
action.execute(&mut document);
|
||||
assert_eq!(document.height, 720.0);
|
||||
|
||||
action.rollback(&mut document);
|
||||
assert_eq!(document.height, 1080.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_set_duration() {
|
||||
let mut document = Document::new("Test");
|
||||
document.duration = 10.0;
|
||||
|
||||
let mut action = SetDocumentPropertiesAction::set_duration(30.0);
|
||||
action.execute(&mut document);
|
||||
assert_eq!(document.duration, 30.0);
|
||||
|
||||
action.rollback(&mut document);
|
||||
assert_eq!(document.duration, 10.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_set_framerate() {
|
||||
let mut document = Document::new("Test");
|
||||
document.framerate = 30.0;
|
||||
|
||||
let mut action = SetDocumentPropertiesAction::set_framerate(60.0);
|
||||
action.execute(&mut document);
|
||||
assert_eq!(document.framerate, 60.0);
|
||||
|
||||
action.rollback(&mut document);
|
||||
assert_eq!(document.framerate, 30.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_description() {
|
||||
let action = SetDocumentPropertiesAction::set_width(100.0);
|
||||
assert_eq!(action.description(), "Set canvas width");
|
||||
|
||||
let action = SetDocumentPropertiesAction::set_duration(30.0);
|
||||
assert_eq!(action.description(), "Set duration");
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,361 @@
|
|||
//! Set shape instance properties action
|
||||
//!
|
||||
//! Handles changing individual properties on shape instances (position, rotation, scale, etc.)
|
||||
//! with undo/redo support.
|
||||
|
||||
use crate::action::Action;
|
||||
use crate::document::Document;
|
||||
use crate::layer::AnyLayer;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Individual property change for a shape instance
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum InstancePropertyChange {
|
||||
X(f64),
|
||||
Y(f64),
|
||||
Rotation(f64),
|
||||
ScaleX(f64),
|
||||
ScaleY(f64),
|
||||
SkewX(f64),
|
||||
SkewY(f64),
|
||||
Opacity(f64),
|
||||
}
|
||||
|
||||
impl InstancePropertyChange {
|
||||
/// Extract the f64 value from any variant
|
||||
fn value(&self) -> f64 {
|
||||
match self {
|
||||
InstancePropertyChange::X(v) => *v,
|
||||
InstancePropertyChange::Y(v) => *v,
|
||||
InstancePropertyChange::Rotation(v) => *v,
|
||||
InstancePropertyChange::ScaleX(v) => *v,
|
||||
InstancePropertyChange::ScaleY(v) => *v,
|
||||
InstancePropertyChange::SkewX(v) => *v,
|
||||
InstancePropertyChange::SkewY(v) => *v,
|
||||
InstancePropertyChange::Opacity(v) => *v,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Action that sets a property on one or more shape instances
|
||||
pub struct SetInstancePropertiesAction {
|
||||
/// Layer containing the instances
|
||||
layer_id: Uuid,
|
||||
|
||||
/// Instance IDs to modify and their old values
|
||||
instance_changes: Vec<(Uuid, Option<f64>)>,
|
||||
|
||||
/// Property to change
|
||||
property: InstancePropertyChange,
|
||||
}
|
||||
|
||||
impl SetInstancePropertiesAction {
|
||||
/// Create a new action to set a property on a single instance
|
||||
pub fn new(layer_id: Uuid, instance_id: Uuid, property: InstancePropertyChange) -> Self {
|
||||
Self {
|
||||
layer_id,
|
||||
instance_changes: vec![(instance_id, None)],
|
||||
property,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a new action to set a property on multiple instances
|
||||
pub fn new_batch(layer_id: Uuid, instance_ids: Vec<Uuid>, property: InstancePropertyChange) -> Self {
|
||||
Self {
|
||||
layer_id,
|
||||
instance_changes: instance_ids.into_iter().map(|id| (id, None)).collect(),
|
||||
property,
|
||||
}
|
||||
}
|
||||
|
||||
fn get_instance_value(&self, document: &Document, instance_id: &Uuid) -> Option<f64> {
|
||||
if let Some(layer) = document.get_layer(&self.layer_id) {
|
||||
if let AnyLayer::Vector(vector_layer) = layer {
|
||||
if let Some(instance) = vector_layer.get_object(instance_id) {
|
||||
return Some(match &self.property {
|
||||
InstancePropertyChange::X(_) => instance.transform.x,
|
||||
InstancePropertyChange::Y(_) => instance.transform.y,
|
||||
InstancePropertyChange::Rotation(_) => instance.transform.rotation,
|
||||
InstancePropertyChange::ScaleX(_) => instance.transform.scale_x,
|
||||
InstancePropertyChange::ScaleY(_) => instance.transform.scale_y,
|
||||
InstancePropertyChange::SkewX(_) => instance.transform.skew_x,
|
||||
InstancePropertyChange::SkewY(_) => instance.transform.skew_y,
|
||||
InstancePropertyChange::Opacity(_) => instance.opacity,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn apply_to_instance(&self, document: &mut Document, instance_id: &Uuid, value: f64) {
|
||||
if let Some(layer) = document.get_layer_mut(&self.layer_id) {
|
||||
if let AnyLayer::Vector(vector_layer) = layer {
|
||||
vector_layer.modify_object_internal(instance_id, |instance| {
|
||||
match &self.property {
|
||||
InstancePropertyChange::X(_) => instance.transform.x = value,
|
||||
InstancePropertyChange::Y(_) => instance.transform.y = value,
|
||||
InstancePropertyChange::Rotation(_) => instance.transform.rotation = value,
|
||||
InstancePropertyChange::ScaleX(_) => instance.transform.scale_x = value,
|
||||
InstancePropertyChange::ScaleY(_) => instance.transform.scale_y = value,
|
||||
InstancePropertyChange::SkewX(_) => instance.transform.skew_x = value,
|
||||
InstancePropertyChange::SkewY(_) => instance.transform.skew_y = value,
|
||||
InstancePropertyChange::Opacity(_) => instance.opacity = value,
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Action for SetInstancePropertiesAction {
|
||||
fn execute(&mut self, document: &mut Document) {
|
||||
let new_value = self.property.value();
|
||||
let layer_id = self.layer_id;
|
||||
|
||||
// First pass: collect old values for instances that don't have them yet
|
||||
for (instance_id, old_value) in &mut self.instance_changes {
|
||||
if old_value.is_none() {
|
||||
// Get old value inline to avoid borrow issues
|
||||
if let Some(layer) = document.get_layer(&layer_id) {
|
||||
if let AnyLayer::Vector(vector_layer) = layer {
|
||||
if let Some(instance) = vector_layer.get_object(instance_id) {
|
||||
*old_value = Some(match &self.property {
|
||||
InstancePropertyChange::X(_) => instance.transform.x,
|
||||
InstancePropertyChange::Y(_) => instance.transform.y,
|
||||
InstancePropertyChange::Rotation(_) => instance.transform.rotation,
|
||||
InstancePropertyChange::ScaleX(_) => instance.transform.scale_x,
|
||||
InstancePropertyChange::ScaleY(_) => instance.transform.scale_y,
|
||||
InstancePropertyChange::SkewX(_) => instance.transform.skew_x,
|
||||
InstancePropertyChange::SkewY(_) => instance.transform.skew_y,
|
||||
InstancePropertyChange::Opacity(_) => instance.opacity,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Second pass: apply new values
|
||||
for (instance_id, _) in &self.instance_changes {
|
||||
self.apply_to_instance(document, instance_id, new_value);
|
||||
}
|
||||
}
|
||||
|
||||
fn rollback(&mut self, document: &mut Document) {
|
||||
for (instance_id, old_value) in &self.instance_changes {
|
||||
if let Some(value) = old_value {
|
||||
self.apply_to_instance(document, instance_id, *value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn description(&self) -> String {
|
||||
let property_name = match &self.property {
|
||||
InstancePropertyChange::X(_) => "X position",
|
||||
InstancePropertyChange::Y(_) => "Y position",
|
||||
InstancePropertyChange::Rotation(_) => "rotation",
|
||||
InstancePropertyChange::ScaleX(_) => "scale X",
|
||||
InstancePropertyChange::ScaleY(_) => "scale Y",
|
||||
InstancePropertyChange::SkewX(_) => "skew X",
|
||||
InstancePropertyChange::SkewY(_) => "skew Y",
|
||||
InstancePropertyChange::Opacity(_) => "opacity",
|
||||
};
|
||||
|
||||
if self.instance_changes.len() == 1 {
|
||||
format!("Set {}", property_name)
|
||||
} else {
|
||||
format!("Set {} on {} objects", property_name, self.instance_changes.len())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::layer::VectorLayer;
|
||||
use crate::object::{ShapeInstance, Transform};
|
||||
|
||||
#[test]
|
||||
fn test_set_x_position() {
|
||||
let mut document = Document::new("Test");
|
||||
let mut layer = VectorLayer::new("Test Layer");
|
||||
|
||||
let shape_id = Uuid::new_v4();
|
||||
let mut instance = ShapeInstance::new(shape_id);
|
||||
let instance_id = instance.id;
|
||||
instance.transform = Transform::with_position(10.0, 20.0);
|
||||
layer.add_object(instance);
|
||||
|
||||
let layer_id = document.root_mut().add_child(AnyLayer::Vector(layer));
|
||||
|
||||
// Create and execute action
|
||||
let mut action = SetInstancePropertiesAction::new(
|
||||
layer_id,
|
||||
instance_id,
|
||||
InstancePropertyChange::X(50.0),
|
||||
);
|
||||
action.execute(&mut document);
|
||||
|
||||
// Verify position changed
|
||||
if let Some(AnyLayer::Vector(vl)) = document.get_layer_mut(&layer_id) {
|
||||
let obj = vl.get_object(&instance_id).unwrap();
|
||||
assert_eq!(obj.transform.x, 50.0);
|
||||
assert_eq!(obj.transform.y, 20.0); // Y unchanged
|
||||
}
|
||||
|
||||
// Rollback
|
||||
action.rollback(&mut document);
|
||||
|
||||
// Verify restored
|
||||
if let Some(AnyLayer::Vector(vl)) = document.get_layer_mut(&layer_id) {
|
||||
let obj = vl.get_object(&instance_id).unwrap();
|
||||
assert_eq!(obj.transform.x, 10.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_set_rotation() {
|
||||
let mut document = Document::new("Test");
|
||||
let mut layer = VectorLayer::new("Test Layer");
|
||||
|
||||
let shape_id = Uuid::new_v4();
|
||||
let mut instance = ShapeInstance::new(shape_id);
|
||||
let instance_id = instance.id;
|
||||
instance.transform.rotation = 0.0;
|
||||
layer.add_object(instance);
|
||||
|
||||
let layer_id = document.root_mut().add_child(AnyLayer::Vector(layer));
|
||||
|
||||
// Create and execute action
|
||||
let mut action = SetInstancePropertiesAction::new(
|
||||
layer_id,
|
||||
instance_id,
|
||||
InstancePropertyChange::Rotation(45.0),
|
||||
);
|
||||
action.execute(&mut document);
|
||||
|
||||
// Verify rotation changed
|
||||
if let Some(AnyLayer::Vector(vl)) = document.get_layer_mut(&layer_id) {
|
||||
let obj = vl.get_object(&instance_id).unwrap();
|
||||
assert_eq!(obj.transform.rotation, 45.0);
|
||||
}
|
||||
|
||||
// Rollback
|
||||
action.rollback(&mut document);
|
||||
|
||||
// Verify restored
|
||||
if let Some(AnyLayer::Vector(vl)) = document.get_layer_mut(&layer_id) {
|
||||
let obj = vl.get_object(&instance_id).unwrap();
|
||||
assert_eq!(obj.transform.rotation, 0.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_set_opacity() {
|
||||
let mut document = Document::new("Test");
|
||||
let mut layer = VectorLayer::new("Test Layer");
|
||||
|
||||
let shape_id = Uuid::new_v4();
|
||||
let mut instance = ShapeInstance::new(shape_id);
|
||||
let instance_id = instance.id;
|
||||
instance.opacity = 1.0;
|
||||
layer.add_object(instance);
|
||||
|
||||
let layer_id = document.root_mut().add_child(AnyLayer::Vector(layer));
|
||||
|
||||
// Create and execute action
|
||||
let mut action = SetInstancePropertiesAction::new(
|
||||
layer_id,
|
||||
instance_id,
|
||||
InstancePropertyChange::Opacity(0.5),
|
||||
);
|
||||
action.execute(&mut document);
|
||||
|
||||
// Verify opacity changed
|
||||
if let Some(AnyLayer::Vector(vl)) = document.get_layer_mut(&layer_id) {
|
||||
let obj = vl.get_object(&instance_id).unwrap();
|
||||
assert_eq!(obj.opacity, 0.5);
|
||||
}
|
||||
|
||||
// Rollback
|
||||
action.rollback(&mut document);
|
||||
|
||||
// Verify restored
|
||||
if let Some(AnyLayer::Vector(vl)) = document.get_layer_mut(&layer_id) {
|
||||
let obj = vl.get_object(&instance_id).unwrap();
|
||||
assert_eq!(obj.opacity, 1.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_batch_set_scale() {
|
||||
let mut document = Document::new("Test");
|
||||
let mut layer = VectorLayer::new("Test Layer");
|
||||
|
||||
let shape_id = Uuid::new_v4();
|
||||
|
||||
let mut instance1 = ShapeInstance::new(shape_id);
|
||||
let instance1_id = instance1.id;
|
||||
instance1.transform.scale_x = 1.0;
|
||||
|
||||
let mut instance2 = ShapeInstance::new(shape_id);
|
||||
let instance2_id = instance2.id;
|
||||
instance2.transform.scale_x = 1.0;
|
||||
|
||||
layer.add_object(instance1);
|
||||
layer.add_object(instance2);
|
||||
|
||||
let layer_id = document.root_mut().add_child(AnyLayer::Vector(layer));
|
||||
|
||||
// Create and execute batch action
|
||||
let mut action = SetInstancePropertiesAction::new_batch(
|
||||
layer_id,
|
||||
vec![instance1_id, instance2_id],
|
||||
InstancePropertyChange::ScaleX(2.0),
|
||||
);
|
||||
action.execute(&mut document);
|
||||
|
||||
// Verify both changed
|
||||
if let Some(AnyLayer::Vector(vl)) = document.get_layer_mut(&layer_id) {
|
||||
assert_eq!(vl.get_object(&instance1_id).unwrap().transform.scale_x, 2.0);
|
||||
assert_eq!(vl.get_object(&instance2_id).unwrap().transform.scale_x, 2.0);
|
||||
}
|
||||
|
||||
// Rollback
|
||||
action.rollback(&mut document);
|
||||
|
||||
// Verify both restored
|
||||
if let Some(AnyLayer::Vector(vl)) = document.get_layer_mut(&layer_id) {
|
||||
assert_eq!(vl.get_object(&instance1_id).unwrap().transform.scale_x, 1.0);
|
||||
assert_eq!(vl.get_object(&instance2_id).unwrap().transform.scale_x, 1.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_description() {
|
||||
let layer_id = Uuid::new_v4();
|
||||
let instance_id = Uuid::new_v4();
|
||||
|
||||
let action1 = SetInstancePropertiesAction::new(
|
||||
layer_id,
|
||||
instance_id,
|
||||
InstancePropertyChange::X(0.0),
|
||||
);
|
||||
assert_eq!(action1.description(), "Set X position");
|
||||
|
||||
let action2 = SetInstancePropertiesAction::new(
|
||||
layer_id,
|
||||
instance_id,
|
||||
InstancePropertyChange::Rotation(0.0),
|
||||
);
|
||||
assert_eq!(action2.description(), "Set rotation");
|
||||
|
||||
let action3 = SetInstancePropertiesAction::new_batch(
|
||||
layer_id,
|
||||
vec![Uuid::new_v4(), Uuid::new_v4()],
|
||||
InstancePropertyChange::Opacity(1.0),
|
||||
);
|
||||
assert_eq!(action3.description(), "Set opacity on 2 objects");
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,261 @@
|
|||
//! Set shape properties action
|
||||
//!
|
||||
//! Handles changing shape properties (fill color, stroke color, stroke width)
|
||||
//! with undo/redo support.
|
||||
|
||||
use crate::action::Action;
|
||||
use crate::document::Document;
|
||||
use crate::layer::AnyLayer;
|
||||
use crate::shape::{ShapeColor, StrokeStyle};
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Property change for a shape
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum ShapePropertyChange {
|
||||
FillColor(Option<ShapeColor>),
|
||||
StrokeColor(Option<ShapeColor>),
|
||||
StrokeWidth(f64),
|
||||
}
|
||||
|
||||
/// Action that sets properties on a shape
|
||||
pub struct SetShapePropertiesAction {
|
||||
/// Layer containing the shape
|
||||
layer_id: Uuid,
|
||||
|
||||
/// Shape to modify
|
||||
shape_id: Uuid,
|
||||
|
||||
/// New property value
|
||||
new_value: ShapePropertyChange,
|
||||
|
||||
/// Old property value (stored after first execution)
|
||||
old_value: Option<ShapePropertyChange>,
|
||||
}
|
||||
|
||||
impl SetShapePropertiesAction {
|
||||
/// Create a new action to set a property on a shape
|
||||
pub fn new(layer_id: Uuid, shape_id: Uuid, new_value: ShapePropertyChange) -> Self {
|
||||
Self {
|
||||
layer_id,
|
||||
shape_id,
|
||||
new_value,
|
||||
old_value: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create action to set fill color
|
||||
pub fn set_fill_color(layer_id: Uuid, shape_id: Uuid, color: Option<ShapeColor>) -> Self {
|
||||
Self::new(layer_id, shape_id, ShapePropertyChange::FillColor(color))
|
||||
}
|
||||
|
||||
/// Create action to set stroke color
|
||||
pub fn set_stroke_color(layer_id: Uuid, shape_id: Uuid, color: Option<ShapeColor>) -> Self {
|
||||
Self::new(layer_id, shape_id, ShapePropertyChange::StrokeColor(color))
|
||||
}
|
||||
|
||||
/// Create action to set stroke width
|
||||
pub fn set_stroke_width(layer_id: Uuid, shape_id: Uuid, width: f64) -> Self {
|
||||
Self::new(layer_id, shape_id, ShapePropertyChange::StrokeWidth(width))
|
||||
}
|
||||
}
|
||||
|
||||
impl Action for SetShapePropertiesAction {
|
||||
fn execute(&mut self, document: &mut Document) {
|
||||
if let Some(layer) = document.get_layer_mut(&self.layer_id) {
|
||||
if let AnyLayer::Vector(vector_layer) = layer {
|
||||
if let Some(shape) = vector_layer.shapes.get_mut(&self.shape_id) {
|
||||
// Store old value if not already stored
|
||||
if self.old_value.is_none() {
|
||||
self.old_value = Some(match &self.new_value {
|
||||
ShapePropertyChange::FillColor(_) => {
|
||||
ShapePropertyChange::FillColor(shape.fill_color)
|
||||
}
|
||||
ShapePropertyChange::StrokeColor(_) => {
|
||||
ShapePropertyChange::StrokeColor(shape.stroke_color)
|
||||
}
|
||||
ShapePropertyChange::StrokeWidth(_) => {
|
||||
let width = shape
|
||||
.stroke_style
|
||||
.as_ref()
|
||||
.map(|s| s.width)
|
||||
.unwrap_or(1.0);
|
||||
ShapePropertyChange::StrokeWidth(width)
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Apply new value
|
||||
match &self.new_value {
|
||||
ShapePropertyChange::FillColor(color) => {
|
||||
shape.fill_color = *color;
|
||||
}
|
||||
ShapePropertyChange::StrokeColor(color) => {
|
||||
shape.stroke_color = *color;
|
||||
}
|
||||
ShapePropertyChange::StrokeWidth(width) => {
|
||||
if let Some(ref mut style) = shape.stroke_style {
|
||||
style.width = *width;
|
||||
} else {
|
||||
// Create stroke style if it doesn't exist
|
||||
shape.stroke_style = Some(StrokeStyle {
|
||||
width: *width,
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn rollback(&mut self, document: &mut Document) {
|
||||
if let Some(old_value) = &self.old_value {
|
||||
if let Some(layer) = document.get_layer_mut(&self.layer_id) {
|
||||
if let AnyLayer::Vector(vector_layer) = layer {
|
||||
if let Some(shape) = vector_layer.shapes.get_mut(&self.shape_id) {
|
||||
match old_value {
|
||||
ShapePropertyChange::FillColor(color) => {
|
||||
shape.fill_color = *color;
|
||||
}
|
||||
ShapePropertyChange::StrokeColor(color) => {
|
||||
shape.stroke_color = *color;
|
||||
}
|
||||
ShapePropertyChange::StrokeWidth(width) => {
|
||||
if let Some(ref mut style) = shape.stroke_style {
|
||||
style.width = *width;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn description(&self) -> String {
|
||||
match &self.new_value {
|
||||
ShapePropertyChange::FillColor(_) => "Set fill color".to_string(),
|
||||
ShapePropertyChange::StrokeColor(_) => "Set stroke color".to_string(),
|
||||
ShapePropertyChange::StrokeWidth(_) => "Set stroke width".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::layer::VectorLayer;
|
||||
use crate::shape::Shape;
|
||||
use kurbo::BezPath;
|
||||
|
||||
fn create_test_shape() -> Shape {
|
||||
let mut path = BezPath::new();
|
||||
path.move_to((0.0, 0.0));
|
||||
path.line_to((100.0, 0.0));
|
||||
path.line_to((100.0, 100.0));
|
||||
path.line_to((0.0, 100.0));
|
||||
path.close_path();
|
||||
|
||||
let mut shape = Shape::new(path);
|
||||
shape.fill_color = Some(ShapeColor::rgb(255, 0, 0));
|
||||
shape.stroke_color = Some(ShapeColor::rgb(0, 0, 0));
|
||||
shape.stroke_style = Some(StrokeStyle {
|
||||
width: 2.0,
|
||||
..Default::default()
|
||||
});
|
||||
shape
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_set_fill_color() {
|
||||
let mut document = Document::new("Test");
|
||||
let mut layer = VectorLayer::new("Test Layer");
|
||||
|
||||
let shape = create_test_shape();
|
||||
let shape_id = shape.id;
|
||||
layer.shapes.insert(shape_id, shape);
|
||||
|
||||
let layer_id = document.root_mut().add_child(AnyLayer::Vector(layer));
|
||||
|
||||
// Verify initial color
|
||||
if let Some(AnyLayer::Vector(vl)) = document.get_layer_mut(&layer_id) {
|
||||
let shape = vl.shapes.get(&shape_id).unwrap();
|
||||
assert_eq!(shape.fill_color.unwrap().r, 255);
|
||||
}
|
||||
|
||||
// Create and execute action
|
||||
let new_color = Some(ShapeColor::rgb(0, 255, 0));
|
||||
let mut action = SetShapePropertiesAction::set_fill_color(layer_id, shape_id, new_color);
|
||||
action.execute(&mut document);
|
||||
|
||||
// Verify color changed
|
||||
if let Some(AnyLayer::Vector(vl)) = document.get_layer_mut(&layer_id) {
|
||||
let shape = vl.shapes.get(&shape_id).unwrap();
|
||||
assert_eq!(shape.fill_color.unwrap().g, 255);
|
||||
}
|
||||
|
||||
// Rollback
|
||||
action.rollback(&mut document);
|
||||
|
||||
// Verify restored
|
||||
if let Some(AnyLayer::Vector(vl)) = document.get_layer_mut(&layer_id) {
|
||||
let shape = vl.shapes.get(&shape_id).unwrap();
|
||||
assert_eq!(shape.fill_color.unwrap().r, 255);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_set_stroke_width() {
|
||||
let mut document = Document::new("Test");
|
||||
let mut layer = VectorLayer::new("Test Layer");
|
||||
|
||||
let shape = create_test_shape();
|
||||
let shape_id = shape.id;
|
||||
layer.shapes.insert(shape_id, shape);
|
||||
|
||||
let layer_id = document.root_mut().add_child(AnyLayer::Vector(layer));
|
||||
|
||||
// Verify initial width
|
||||
if let Some(AnyLayer::Vector(vl)) = document.get_layer_mut(&layer_id) {
|
||||
let shape = vl.shapes.get(&shape_id).unwrap();
|
||||
assert_eq!(shape.stroke_style.as_ref().unwrap().width, 2.0);
|
||||
}
|
||||
|
||||
// Create and execute action
|
||||
let mut action = SetShapePropertiesAction::set_stroke_width(layer_id, shape_id, 5.0);
|
||||
action.execute(&mut document);
|
||||
|
||||
// Verify width changed
|
||||
if let Some(AnyLayer::Vector(vl)) = document.get_layer_mut(&layer_id) {
|
||||
let shape = vl.shapes.get(&shape_id).unwrap();
|
||||
assert_eq!(shape.stroke_style.as_ref().unwrap().width, 5.0);
|
||||
}
|
||||
|
||||
// Rollback
|
||||
action.rollback(&mut document);
|
||||
|
||||
// Verify restored
|
||||
if let Some(AnyLayer::Vector(vl)) = document.get_layer_mut(&layer_id) {
|
||||
let shape = vl.shapes.get(&shape_id).unwrap();
|
||||
assert_eq!(shape.stroke_style.as_ref().unwrap().width, 2.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_description() {
|
||||
let layer_id = Uuid::new_v4();
|
||||
let shape_id = Uuid::new_v4();
|
||||
|
||||
let action1 =
|
||||
SetShapePropertiesAction::set_fill_color(layer_id, shape_id, Some(ShapeColor::rgb(0, 0, 0)));
|
||||
assert_eq!(action1.description(), "Set fill color");
|
||||
|
||||
let action2 =
|
||||
SetShapePropertiesAction::set_stroke_color(layer_id, shape_id, Some(ShapeColor::rgb(0, 0, 0)));
|
||||
assert_eq!(action2.description(), "Set stroke color");
|
||||
|
||||
let action3 = SetShapePropertiesAction::set_stroke_width(layer_id, shape_id, 3.0);
|
||||
assert_eq!(action3.description(), "Set stroke width");
|
||||
}
|
||||
}
|
||||
|
|
@ -144,7 +144,7 @@ impl StrokeStyle {
|
|||
}
|
||||
|
||||
/// Serializable color representation
|
||||
#[derive(Clone, Copy, Debug, Serialize, Deserialize)]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ShapeColor {
|
||||
pub r: u8,
|
||||
pub g: u8,
|
||||
|
|
@ -238,12 +238,12 @@ pub struct Shape {
|
|||
}
|
||||
|
||||
impl Shape {
|
||||
/// Create a new shape with a single path
|
||||
/// 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: Some(ShapeColor::rgb(0, 0, 0)),
|
||||
fill_color: None,
|
||||
image_fill: None,
|
||||
fill_rule: FillRule::NonZero,
|
||||
stroke_color: None,
|
||||
|
|
@ -251,12 +251,12 @@ impl Shape {
|
|||
}
|
||||
}
|
||||
|
||||
/// Create a new shape with a specific ID
|
||||
/// 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: Some(ShapeColor::rgb(0, 0, 0)),
|
||||
fill_color: None,
|
||||
image_fill: None,
|
||||
fill_rule: FillRule::NonZero,
|
||||
stroke_color: None,
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ use lightningbeam_core::clip::{ClipInstance, VectorClip};
|
|||
use lightningbeam_core::document::Document;
|
||||
use lightningbeam_core::layer::{AnyLayer, LayerTrait, VectorLayer};
|
||||
use lightningbeam_core::object::ShapeInstance;
|
||||
use lightningbeam_core::renderer::{render_document, render_document_with_transform};
|
||||
use lightningbeam_core::renderer::{render_document, render_document_with_transform, ImageCache};
|
||||
use lightningbeam_core::shape::{Shape, ShapeColor};
|
||||
use vello::kurbo::{Affine, Circle, Shape as KurboShape};
|
||||
use vello::Scene;
|
||||
|
|
@ -53,28 +53,31 @@ fn setup_rendering_document() -> (Document, Vec<uuid::Uuid>) {
|
|||
fn test_render_empty_document() {
|
||||
let document = Document::new("Empty");
|
||||
let mut scene = Scene::new();
|
||||
let mut image_cache = ImageCache::new();
|
||||
|
||||
// Should not panic
|
||||
render_document(&document, &mut scene);
|
||||
render_document(&document, &mut scene, &mut image_cache);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_render_document_with_shapes() {
|
||||
let (document, _ids) = setup_rendering_document();
|
||||
let mut scene = Scene::new();
|
||||
let mut image_cache = ImageCache::new();
|
||||
|
||||
// Should render all 3 layers without error
|
||||
render_document(&document, &mut scene);
|
||||
render_document(&document, &mut scene, &mut image_cache);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_render_with_transform() {
|
||||
let (document, _ids) = setup_rendering_document();
|
||||
let mut scene = Scene::new();
|
||||
let mut image_cache = ImageCache::new();
|
||||
|
||||
// Render with zoom and pan
|
||||
let transform = Affine::translate((100.0, 50.0)) * Affine::scale(2.0);
|
||||
render_document_with_transform(&document, &mut scene, transform);
|
||||
render_document_with_transform(&document, &mut scene, transform, &mut image_cache);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -98,7 +101,8 @@ fn test_render_solo_single_layer() {
|
|||
|
||||
// Render should work
|
||||
let mut scene = Scene::new();
|
||||
render_document(&document, &mut scene);
|
||||
let mut image_cache = ImageCache::new();
|
||||
render_document(&document, &mut scene, &mut image_cache);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -121,7 +125,8 @@ fn test_render_solo_multiple_layers() {
|
|||
assert_eq!(layers_to_render.len(), 2);
|
||||
|
||||
let mut scene = Scene::new();
|
||||
render_document(&document, &mut scene);
|
||||
let mut image_cache = ImageCache::new();
|
||||
render_document(&document, &mut scene, &mut image_cache);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -137,7 +142,8 @@ fn test_render_hidden_layer_not_rendered() {
|
|||
assert_eq!(document.visible_layers().count(), 2);
|
||||
|
||||
let mut scene = Scene::new();
|
||||
render_document(&document, &mut scene);
|
||||
let mut image_cache = ImageCache::new();
|
||||
render_document(&document, &mut scene, &mut image_cache);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -161,7 +167,8 @@ fn test_render_with_layer_opacity() {
|
|||
assert_eq!(document.root.get_child(&ids[2]).unwrap().opacity(), 1.0);
|
||||
|
||||
let mut scene = Scene::new();
|
||||
render_document(&document, &mut scene);
|
||||
let mut image_cache = ImageCache::new();
|
||||
render_document(&document, &mut scene, &mut image_cache);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -198,7 +205,8 @@ fn test_render_with_clip_instances() {
|
|||
document.set_time(2.0);
|
||||
|
||||
let mut scene = Scene::new();
|
||||
render_document(&document, &mut scene);
|
||||
let mut image_cache = ImageCache::new();
|
||||
render_document(&document, &mut scene, &mut image_cache);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -223,7 +231,8 @@ fn test_render_clip_instance_outside_time_range() {
|
|||
|
||||
// Clip shouldn't render (it hasn't started yet)
|
||||
let mut scene = Scene::new();
|
||||
render_document(&document, &mut scene);
|
||||
let mut image_cache = ImageCache::new();
|
||||
render_document(&document, &mut scene, &mut image_cache);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -242,7 +251,8 @@ fn test_render_all_layers_hidden() {
|
|||
|
||||
// Should still render (just background)
|
||||
let mut scene = Scene::new();
|
||||
render_document(&document, &mut scene);
|
||||
let mut image_cache = ImageCache::new();
|
||||
render_document(&document, &mut scene, &mut image_cache);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -269,7 +279,8 @@ fn test_render_solo_hidden_layer_interaction() {
|
|||
assert_eq!(document.visible_layers().count(), 2);
|
||||
|
||||
let mut scene = Scene::new();
|
||||
render_document(&document, &mut scene);
|
||||
let mut image_cache = ImageCache::new();
|
||||
render_document(&document, &mut scene, &mut image_cache);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -278,17 +289,19 @@ fn test_render_background_color() {
|
|||
document.background_color = ShapeColor::rgb(128, 128, 128);
|
||||
|
||||
let mut scene = Scene::new();
|
||||
render_document(&document, &mut scene);
|
||||
let mut image_cache = ImageCache::new();
|
||||
render_document(&document, &mut scene, &mut image_cache);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_render_at_different_times() {
|
||||
let (mut document, _ids) = setup_rendering_document();
|
||||
let mut image_cache = ImageCache::new();
|
||||
|
||||
// Render at different times
|
||||
for time in [0.0, 0.5, 1.0, 2.5, 5.0, 10.0] {
|
||||
document.set_time(time);
|
||||
let mut scene = Scene::new();
|
||||
render_document(&document, &mut scene);
|
||||
render_document(&document, &mut scene, &mut image_cache);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ serde_json = { workspace = true }
|
|||
# Image loading
|
||||
image = { workspace = true }
|
||||
resvg = { workspace = true }
|
||||
tiny-skia = "0.11"
|
||||
|
||||
# Utilities
|
||||
pollster = { workspace = true }
|
||||
|
|
|
|||
|
|
@ -277,6 +277,11 @@ struct EditorApp {
|
|||
dragging_asset: Option<panes::DraggingAsset>, // Asset being dragged from Asset Library
|
||||
// Import dialog state
|
||||
last_import_filter: ImportFilter, // Last used import filter (remembered across imports)
|
||||
// Tool-specific options (displayed in infopanel)
|
||||
stroke_width: f64, // Stroke width for drawing tools (default: 3.0)
|
||||
fill_enabled: bool, // Whether to fill shapes (default: true)
|
||||
paint_bucket_gap_tolerance: f64, // Fill gap tolerance for paint bucket (default: 5.0)
|
||||
polygon_sides: u32, // Number of sides for polygon tool (default: 5)
|
||||
}
|
||||
|
||||
/// Import filter types for the file dialog
|
||||
|
|
@ -308,10 +313,11 @@ impl EditorApp {
|
|||
use lightningbeam_core::shape::{Shape, ShapeColor};
|
||||
use vello::kurbo::{Circle, Shape as KurboShape};
|
||||
|
||||
let circle = Circle::new((200.0, 150.0), 50.0);
|
||||
// Create circle centered at origin, position via instance transform
|
||||
let circle = Circle::new((0.0, 0.0), 50.0);
|
||||
let path = circle.to_path(0.1);
|
||||
let shape = Shape::new(path).with_fill(ShapeColor::rgb(100, 150, 250));
|
||||
let object = ShapeInstance::new(shape.id);
|
||||
let object = ShapeInstance::new(shape.id).with_position(200.0, 150.0);
|
||||
|
||||
let mut vector_layer = VectorLayer::new("Layer 1");
|
||||
vector_layer.add_shape(shape);
|
||||
|
|
@ -364,6 +370,10 @@ impl EditorApp {
|
|||
is_playing: false, // Start paused
|
||||
dragging_asset: None, // No asset being dragged initially
|
||||
last_import_filter: ImportFilter::default(), // Default to "All Supported"
|
||||
stroke_width: 3.0, // Default stroke width
|
||||
fill_enabled: true, // Default to filling shapes
|
||||
paint_bucket_gap_tolerance: 5.0, // Default gap tolerance
|
||||
polygon_sides: 5, // Default to pentagon
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -981,6 +991,10 @@ impl eframe::App for EditorApp {
|
|||
playback_time: &mut self.playback_time,
|
||||
is_playing: &mut self.is_playing,
|
||||
dragging_asset: &mut self.dragging_asset,
|
||||
stroke_width: &mut self.stroke_width,
|
||||
fill_enabled: &mut self.fill_enabled,
|
||||
paint_bucket_gap_tolerance: &mut self.paint_bucket_gap_tolerance,
|
||||
polygon_sides: &mut self.polygon_sides,
|
||||
};
|
||||
|
||||
render_layout_node(
|
||||
|
|
@ -1121,6 +1135,11 @@ struct RenderContext<'a> {
|
|||
playback_time: &'a mut f64,
|
||||
is_playing: &'a mut bool,
|
||||
dragging_asset: &'a mut Option<panes::DraggingAsset>,
|
||||
// Tool-specific options for infopanel
|
||||
stroke_width: &'a mut f64,
|
||||
fill_enabled: &'a mut bool,
|
||||
paint_bucket_gap_tolerance: &'a mut f64,
|
||||
polygon_sides: &'a mut u32,
|
||||
}
|
||||
|
||||
/// Recursively render a layout node with drag support
|
||||
|
|
@ -1586,6 +1605,10 @@ fn render_pane(
|
|||
playback_time: ctx.playback_time,
|
||||
is_playing: ctx.is_playing,
|
||||
dragging_asset: ctx.dragging_asset,
|
||||
stroke_width: ctx.stroke_width,
|
||||
fill_enabled: ctx.fill_enabled,
|
||||
paint_bucket_gap_tolerance: ctx.paint_bucket_gap_tolerance,
|
||||
polygon_sides: ctx.polygon_sides,
|
||||
};
|
||||
pane_instance.render_header(&mut header_ui, &mut shared);
|
||||
}
|
||||
|
|
@ -1634,6 +1657,10 @@ fn render_pane(
|
|||
playback_time: ctx.playback_time,
|
||||
is_playing: ctx.is_playing,
|
||||
dragging_asset: ctx.dragging_asset,
|
||||
stroke_width: ctx.stroke_width,
|
||||
fill_enabled: ctx.fill_enabled,
|
||||
paint_bucket_gap_tolerance: ctx.paint_bucket_gap_tolerance,
|
||||
polygon_sides: ctx.polygon_sides,
|
||||
};
|
||||
|
||||
// Render pane content (header was already rendered above)
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,16 +1,752 @@
|
|||
/// Info Panel pane - displays properties of selected objects
|
||||
/// Info Panel pane - displays and edits properties of selected objects
|
||||
///
|
||||
/// This will eventually show editable properties.
|
||||
/// For now, it's a placeholder.
|
||||
/// Shows context-sensitive property editors based on current selection:
|
||||
/// - Tool options (when a tool is active)
|
||||
/// - Transform properties (when shapes are selected)
|
||||
/// - Shape properties (fill/stroke for selected shapes)
|
||||
/// - Document settings (when nothing is selected)
|
||||
|
||||
use eframe::egui;
|
||||
use eframe::egui::{self, DragValue, Sense, Ui};
|
||||
use lightningbeam_core::actions::{
|
||||
InstancePropertyChange, SetDocumentPropertiesAction, SetInstancePropertiesAction,
|
||||
SetShapePropertiesAction,
|
||||
};
|
||||
use lightningbeam_core::layer::AnyLayer;
|
||||
use lightningbeam_core::shape::ShapeColor;
|
||||
use lightningbeam_core::tool::{SimplifyMode, Tool};
|
||||
use super::{NodePath, PaneRenderer, SharedPaneState};
|
||||
use uuid::Uuid;
|
||||
|
||||
pub struct InfopanelPane {}
|
||||
/// Info panel pane state
|
||||
pub struct InfopanelPane {
|
||||
/// Whether the tool options section is expanded
|
||||
tool_section_open: bool,
|
||||
/// Whether the transform section is expanded
|
||||
transform_section_open: bool,
|
||||
/// Whether the shape properties section is expanded
|
||||
shape_section_open: bool,
|
||||
}
|
||||
|
||||
impl InfopanelPane {
|
||||
pub fn new() -> Self {
|
||||
Self {}
|
||||
Self {
|
||||
tool_section_open: true,
|
||||
transform_section_open: true,
|
||||
shape_section_open: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Aggregated info about the current selection
|
||||
struct SelectionInfo {
|
||||
/// True if nothing is selected
|
||||
is_empty: bool,
|
||||
/// Number of selected shape instances
|
||||
shape_count: usize,
|
||||
/// Layer ID of selected shapes (assumes single layer selection for now)
|
||||
layer_id: Option<Uuid>,
|
||||
/// Selected shape instance IDs
|
||||
instance_ids: Vec<Uuid>,
|
||||
/// Shape IDs referenced by selected instances
|
||||
shape_ids: Vec<Uuid>,
|
||||
|
||||
// Transform values (None = mixed values across selection)
|
||||
x: Option<f64>,
|
||||
y: Option<f64>,
|
||||
rotation: Option<f64>,
|
||||
scale_x: Option<f64>,
|
||||
scale_y: Option<f64>,
|
||||
skew_x: Option<f64>,
|
||||
skew_y: Option<f64>,
|
||||
opacity: Option<f64>,
|
||||
|
||||
// Shape property values (None = mixed)
|
||||
fill_color: Option<Option<ShapeColor>>,
|
||||
stroke_color: Option<Option<ShapeColor>>,
|
||||
stroke_width: Option<f64>,
|
||||
}
|
||||
|
||||
impl Default for SelectionInfo {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
is_empty: true,
|
||||
shape_count: 0,
|
||||
layer_id: None,
|
||||
instance_ids: Vec::new(),
|
||||
shape_ids: Vec::new(),
|
||||
x: None,
|
||||
y: None,
|
||||
rotation: None,
|
||||
scale_x: None,
|
||||
scale_y: None,
|
||||
skew_x: None,
|
||||
skew_y: None,
|
||||
opacity: None,
|
||||
fill_color: None,
|
||||
stroke_color: None,
|
||||
stroke_width: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl InfopanelPane {
|
||||
/// Gather info about the current selection
|
||||
fn gather_selection_info(&self, shared: &SharedPaneState) -> SelectionInfo {
|
||||
let mut info = SelectionInfo::default();
|
||||
|
||||
let selected_instances = shared.selection.shape_instances();
|
||||
info.shape_count = selected_instances.len();
|
||||
info.is_empty = info.shape_count == 0;
|
||||
|
||||
if info.is_empty {
|
||||
return info;
|
||||
}
|
||||
|
||||
info.instance_ids = selected_instances.to_vec();
|
||||
|
||||
// Find the layer containing the selected instances
|
||||
let document = shared.action_executor.document();
|
||||
let active_layer_id = *shared.active_layer_id;
|
||||
|
||||
if let Some(layer_id) = active_layer_id {
|
||||
info.layer_id = Some(layer_id);
|
||||
|
||||
if let Some(layer) = document.get_layer(&layer_id) {
|
||||
if let AnyLayer::Vector(vector_layer) = layer {
|
||||
// Gather values from all selected instances
|
||||
let mut first = true;
|
||||
|
||||
for instance_id in &info.instance_ids {
|
||||
if let Some(instance) = vector_layer.get_object(instance_id) {
|
||||
info.shape_ids.push(instance.shape_id);
|
||||
|
||||
if first {
|
||||
// First instance - set initial values
|
||||
info.x = Some(instance.transform.x);
|
||||
info.y = Some(instance.transform.y);
|
||||
info.rotation = Some(instance.transform.rotation);
|
||||
info.scale_x = Some(instance.transform.scale_x);
|
||||
info.scale_y = Some(instance.transform.scale_y);
|
||||
info.skew_x = Some(instance.transform.skew_x);
|
||||
info.skew_y = Some(instance.transform.skew_y);
|
||||
info.opacity = Some(instance.opacity);
|
||||
|
||||
// Get shape properties
|
||||
if let Some(shape) = vector_layer.shapes.get(&instance.shape_id) {
|
||||
info.fill_color = Some(shape.fill_color);
|
||||
info.stroke_color = Some(shape.stroke_color);
|
||||
info.stroke_width = shape
|
||||
.stroke_style
|
||||
.as_ref()
|
||||
.map(|s| Some(s.width))
|
||||
.unwrap_or(Some(1.0));
|
||||
}
|
||||
|
||||
first = false;
|
||||
} else {
|
||||
// Check if values differ (set to None if mixed)
|
||||
if info.x != Some(instance.transform.x) {
|
||||
info.x = None;
|
||||
}
|
||||
if info.y != Some(instance.transform.y) {
|
||||
info.y = None;
|
||||
}
|
||||
if info.rotation != Some(instance.transform.rotation) {
|
||||
info.rotation = None;
|
||||
}
|
||||
if info.scale_x != Some(instance.transform.scale_x) {
|
||||
info.scale_x = None;
|
||||
}
|
||||
if info.scale_y != Some(instance.transform.scale_y) {
|
||||
info.scale_y = None;
|
||||
}
|
||||
if info.skew_x != Some(instance.transform.skew_x) {
|
||||
info.skew_x = None;
|
||||
}
|
||||
if info.skew_y != Some(instance.transform.skew_y) {
|
||||
info.skew_y = None;
|
||||
}
|
||||
if info.opacity != Some(instance.opacity) {
|
||||
info.opacity = None;
|
||||
}
|
||||
|
||||
// Check shape properties
|
||||
if let Some(shape) = vector_layer.shapes.get(&instance.shape_id) {
|
||||
// Compare fill colors - set to None if mixed
|
||||
if let Some(current_fill) = &info.fill_color {
|
||||
if *current_fill != shape.fill_color {
|
||||
info.fill_color = None;
|
||||
}
|
||||
}
|
||||
// Compare stroke colors - set to None if mixed
|
||||
if let Some(current_stroke) = &info.stroke_color {
|
||||
if *current_stroke != shape.stroke_color {
|
||||
info.stroke_color = None;
|
||||
}
|
||||
}
|
||||
let stroke_w = shape
|
||||
.stroke_style
|
||||
.as_ref()
|
||||
.map(|s| s.width)
|
||||
.unwrap_or(1.0);
|
||||
if info.stroke_width != Some(stroke_w) {
|
||||
info.stroke_width = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
info
|
||||
}
|
||||
|
||||
/// Render tool-specific options section
|
||||
fn render_tool_section(&mut self, ui: &mut Ui, shared: &mut SharedPaneState) {
|
||||
let tool = *shared.selected_tool;
|
||||
|
||||
// Only show tool options for tools that have options
|
||||
let has_options = matches!(
|
||||
tool,
|
||||
Tool::Draw | Tool::Rectangle | Tool::Ellipse | Tool::PaintBucket | Tool::Polygon | Tool::Line
|
||||
);
|
||||
|
||||
if !has_options {
|
||||
return;
|
||||
}
|
||||
|
||||
egui::CollapsingHeader::new("Tool Options")
|
||||
.default_open(self.tool_section_open)
|
||||
.show(ui, |ui| {
|
||||
self.tool_section_open = true;
|
||||
ui.add_space(4.0);
|
||||
|
||||
match tool {
|
||||
Tool::Draw => {
|
||||
// Stroke width
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Stroke Width:");
|
||||
ui.add(DragValue::new(shared.stroke_width).speed(0.1).range(0.1..=100.0));
|
||||
});
|
||||
|
||||
// Simplify mode
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Simplify:");
|
||||
egui::ComboBox::from_id_salt("draw_simplify")
|
||||
.selected_text(match shared.draw_simplify_mode {
|
||||
SimplifyMode::Corners => "Corners",
|
||||
SimplifyMode::Smooth => "Smooth",
|
||||
SimplifyMode::Verbatim => "Verbatim",
|
||||
})
|
||||
.show_ui(ui, |ui| {
|
||||
ui.selectable_value(
|
||||
shared.draw_simplify_mode,
|
||||
SimplifyMode::Corners,
|
||||
"Corners",
|
||||
);
|
||||
ui.selectable_value(
|
||||
shared.draw_simplify_mode,
|
||||
SimplifyMode::Smooth,
|
||||
"Smooth",
|
||||
);
|
||||
ui.selectable_value(
|
||||
shared.draw_simplify_mode,
|
||||
SimplifyMode::Verbatim,
|
||||
"Verbatim",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// Fill shape toggle
|
||||
ui.checkbox(shared.fill_enabled, "Fill Shape");
|
||||
}
|
||||
|
||||
Tool::Rectangle | Tool::Ellipse => {
|
||||
// Stroke width
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Stroke Width:");
|
||||
ui.add(DragValue::new(shared.stroke_width).speed(0.1).range(0.1..=100.0));
|
||||
});
|
||||
|
||||
// Fill shape toggle
|
||||
ui.checkbox(shared.fill_enabled, "Fill Shape");
|
||||
}
|
||||
|
||||
Tool::PaintBucket => {
|
||||
// Gap tolerance
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Gap Tolerance:");
|
||||
ui.add(
|
||||
DragValue::new(shared.paint_bucket_gap_tolerance)
|
||||
.speed(0.1)
|
||||
.range(0.0..=50.0),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
Tool::Polygon => {
|
||||
// Number of sides
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Sides:");
|
||||
let mut sides = *shared.polygon_sides as i32;
|
||||
if ui.add(DragValue::new(&mut sides).range(3..=20)).changed() {
|
||||
*shared.polygon_sides = sides.max(3) as u32;
|
||||
}
|
||||
});
|
||||
|
||||
// Stroke width
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Stroke Width:");
|
||||
ui.add(DragValue::new(shared.stroke_width).speed(0.1).range(0.1..=100.0));
|
||||
});
|
||||
|
||||
// Fill shape toggle
|
||||
ui.checkbox(shared.fill_enabled, "Fill Shape");
|
||||
}
|
||||
|
||||
Tool::Line => {
|
||||
// Stroke width
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Stroke Width:");
|
||||
ui.add(DragValue::new(shared.stroke_width).speed(0.1).range(0.1..=100.0));
|
||||
});
|
||||
}
|
||||
|
||||
_ => {}
|
||||
}
|
||||
|
||||
ui.add_space(4.0);
|
||||
});
|
||||
}
|
||||
|
||||
/// Render transform properties section
|
||||
fn render_transform_section(
|
||||
&mut self,
|
||||
ui: &mut Ui,
|
||||
shared: &mut SharedPaneState,
|
||||
info: &SelectionInfo,
|
||||
) {
|
||||
egui::CollapsingHeader::new("Transform")
|
||||
.default_open(self.transform_section_open)
|
||||
.show(ui, |ui| {
|
||||
self.transform_section_open = true;
|
||||
ui.add_space(4.0);
|
||||
|
||||
let layer_id = match info.layer_id {
|
||||
Some(id) => id,
|
||||
None => return,
|
||||
};
|
||||
|
||||
// Position X
|
||||
self.render_transform_field(
|
||||
ui,
|
||||
"X:",
|
||||
info.x,
|
||||
1.0,
|
||||
f64::NEG_INFINITY..=f64::INFINITY,
|
||||
|value| InstancePropertyChange::X(value),
|
||||
layer_id,
|
||||
&info.instance_ids,
|
||||
shared,
|
||||
);
|
||||
|
||||
// Position Y
|
||||
self.render_transform_field(
|
||||
ui,
|
||||
"Y:",
|
||||
info.y,
|
||||
1.0,
|
||||
f64::NEG_INFINITY..=f64::INFINITY,
|
||||
|value| InstancePropertyChange::Y(value),
|
||||
layer_id,
|
||||
&info.instance_ids,
|
||||
shared,
|
||||
);
|
||||
|
||||
ui.add_space(4.0);
|
||||
|
||||
// Rotation
|
||||
self.render_transform_field(
|
||||
ui,
|
||||
"Rotation:",
|
||||
info.rotation,
|
||||
1.0,
|
||||
-360.0..=360.0,
|
||||
|value| InstancePropertyChange::Rotation(value),
|
||||
layer_id,
|
||||
&info.instance_ids,
|
||||
shared,
|
||||
);
|
||||
|
||||
ui.add_space(4.0);
|
||||
|
||||
// Scale X
|
||||
self.render_transform_field(
|
||||
ui,
|
||||
"Scale X:",
|
||||
info.scale_x,
|
||||
0.01,
|
||||
0.01..=100.0,
|
||||
|value| InstancePropertyChange::ScaleX(value),
|
||||
layer_id,
|
||||
&info.instance_ids,
|
||||
shared,
|
||||
);
|
||||
|
||||
// Scale Y
|
||||
self.render_transform_field(
|
||||
ui,
|
||||
"Scale Y:",
|
||||
info.scale_y,
|
||||
0.01,
|
||||
0.01..=100.0,
|
||||
|value| InstancePropertyChange::ScaleY(value),
|
||||
layer_id,
|
||||
&info.instance_ids,
|
||||
shared,
|
||||
);
|
||||
|
||||
ui.add_space(4.0);
|
||||
|
||||
// Skew X
|
||||
self.render_transform_field(
|
||||
ui,
|
||||
"Skew X:",
|
||||
info.skew_x,
|
||||
1.0,
|
||||
-89.0..=89.0,
|
||||
|value| InstancePropertyChange::SkewX(value),
|
||||
layer_id,
|
||||
&info.instance_ids,
|
||||
shared,
|
||||
);
|
||||
|
||||
// Skew Y
|
||||
self.render_transform_field(
|
||||
ui,
|
||||
"Skew Y:",
|
||||
info.skew_y,
|
||||
1.0,
|
||||
-89.0..=89.0,
|
||||
|value| InstancePropertyChange::SkewY(value),
|
||||
layer_id,
|
||||
&info.instance_ids,
|
||||
shared,
|
||||
);
|
||||
|
||||
ui.add_space(4.0);
|
||||
|
||||
// Opacity
|
||||
self.render_transform_field(
|
||||
ui,
|
||||
"Opacity:",
|
||||
info.opacity,
|
||||
0.01,
|
||||
0.0..=1.0,
|
||||
|value| InstancePropertyChange::Opacity(value),
|
||||
layer_id,
|
||||
&info.instance_ids,
|
||||
shared,
|
||||
);
|
||||
|
||||
ui.add_space(4.0);
|
||||
});
|
||||
}
|
||||
|
||||
/// Render a single transform property field with drag-to-adjust
|
||||
fn render_transform_field<F>(
|
||||
&self,
|
||||
ui: &mut Ui,
|
||||
label: &str,
|
||||
value: Option<f64>,
|
||||
speed: f64,
|
||||
range: std::ops::RangeInclusive<f64>,
|
||||
make_change: F,
|
||||
layer_id: Uuid,
|
||||
instance_ids: &[Uuid],
|
||||
shared: &mut SharedPaneState,
|
||||
) where
|
||||
F: Fn(f64) -> InstancePropertyChange,
|
||||
{
|
||||
ui.horizontal(|ui| {
|
||||
// Label with drag sense for drag-to-adjust
|
||||
let label_response = ui.add(egui::Label::new(label).sense(Sense::drag()));
|
||||
|
||||
match value {
|
||||
Some(mut v) => {
|
||||
// Handle drag on label
|
||||
if label_response.dragged() {
|
||||
let delta = label_response.drag_delta().x as f64 * speed;
|
||||
v = (v + delta).clamp(*range.start(), *range.end());
|
||||
|
||||
// Create action for each selected instance
|
||||
for instance_id in instance_ids {
|
||||
let action = SetInstancePropertiesAction::new(
|
||||
layer_id,
|
||||
*instance_id,
|
||||
make_change(v),
|
||||
);
|
||||
shared.pending_actions.push(Box::new(action));
|
||||
}
|
||||
}
|
||||
|
||||
// DragValue widget
|
||||
let response = ui.add(
|
||||
DragValue::new(&mut v)
|
||||
.speed(speed)
|
||||
.range(range.clone()),
|
||||
);
|
||||
|
||||
if response.changed() {
|
||||
// Create action for each selected instance
|
||||
for instance_id in instance_ids {
|
||||
let action = SetInstancePropertiesAction::new(
|
||||
layer_id,
|
||||
*instance_id,
|
||||
make_change(v),
|
||||
);
|
||||
shared.pending_actions.push(Box::new(action));
|
||||
}
|
||||
}
|
||||
}
|
||||
None => {
|
||||
// Mixed values - show placeholder
|
||||
ui.label("--");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Render shape properties section (fill/stroke)
|
||||
fn render_shape_section(
|
||||
&mut self,
|
||||
ui: &mut Ui,
|
||||
shared: &mut SharedPaneState,
|
||||
info: &SelectionInfo,
|
||||
) {
|
||||
egui::CollapsingHeader::new("Shape")
|
||||
.default_open(self.shape_section_open)
|
||||
.show(ui, |ui| {
|
||||
self.shape_section_open = true;
|
||||
ui.add_space(4.0);
|
||||
|
||||
let layer_id = match info.layer_id {
|
||||
Some(id) => id,
|
||||
None => return,
|
||||
};
|
||||
|
||||
// Fill color
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Fill:");
|
||||
match info.fill_color {
|
||||
Some(Some(color)) => {
|
||||
let mut egui_color = egui::Color32::from_rgba_unmultiplied(
|
||||
color.r, color.g, color.b, color.a,
|
||||
);
|
||||
|
||||
if ui.color_edit_button_srgba(&mut egui_color).changed() {
|
||||
let new_color = Some(ShapeColor::new(
|
||||
egui_color.r(),
|
||||
egui_color.g(),
|
||||
egui_color.b(),
|
||||
egui_color.a(),
|
||||
));
|
||||
|
||||
// Create action for each selected shape
|
||||
for shape_id in &info.shape_ids {
|
||||
let action = SetShapePropertiesAction::set_fill_color(
|
||||
layer_id,
|
||||
*shape_id,
|
||||
new_color,
|
||||
);
|
||||
shared.pending_actions.push(Box::new(action));
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(None) => {
|
||||
if ui.button("Add Fill").clicked() {
|
||||
// Add default black fill
|
||||
let default_fill = Some(ShapeColor::rgb(0, 0, 0));
|
||||
for shape_id in &info.shape_ids {
|
||||
let action = SetShapePropertiesAction::set_fill_color(
|
||||
layer_id,
|
||||
*shape_id,
|
||||
default_fill,
|
||||
);
|
||||
shared.pending_actions.push(Box::new(action));
|
||||
}
|
||||
}
|
||||
}
|
||||
None => {
|
||||
ui.label("--");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Stroke color
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Stroke:");
|
||||
match info.stroke_color {
|
||||
Some(Some(color)) => {
|
||||
let mut egui_color = egui::Color32::from_rgba_unmultiplied(
|
||||
color.r, color.g, color.b, color.a,
|
||||
);
|
||||
|
||||
if ui.color_edit_button_srgba(&mut egui_color).changed() {
|
||||
let new_color = Some(ShapeColor::new(
|
||||
egui_color.r(),
|
||||
egui_color.g(),
|
||||
egui_color.b(),
|
||||
egui_color.a(),
|
||||
));
|
||||
|
||||
// Create action for each selected shape
|
||||
for shape_id in &info.shape_ids {
|
||||
let action = SetShapePropertiesAction::set_stroke_color(
|
||||
layer_id,
|
||||
*shape_id,
|
||||
new_color,
|
||||
);
|
||||
shared.pending_actions.push(Box::new(action));
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(None) => {
|
||||
if ui.button("Add Stroke").clicked() {
|
||||
// Add default black stroke
|
||||
let default_stroke = Some(ShapeColor::rgb(0, 0, 0));
|
||||
for shape_id in &info.shape_ids {
|
||||
let action = SetShapePropertiesAction::set_stroke_color(
|
||||
layer_id,
|
||||
*shape_id,
|
||||
default_stroke,
|
||||
);
|
||||
shared.pending_actions.push(Box::new(action));
|
||||
}
|
||||
}
|
||||
}
|
||||
None => {
|
||||
ui.label("--");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Stroke width
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Stroke Width:");
|
||||
match info.stroke_width {
|
||||
Some(mut width) => {
|
||||
let response = ui.add(
|
||||
DragValue::new(&mut width)
|
||||
.speed(0.1)
|
||||
.range(0.1..=100.0),
|
||||
);
|
||||
|
||||
if response.changed() {
|
||||
for shape_id in &info.shape_ids {
|
||||
let action = SetShapePropertiesAction::set_stroke_width(
|
||||
layer_id,
|
||||
*shape_id,
|
||||
width,
|
||||
);
|
||||
shared.pending_actions.push(Box::new(action));
|
||||
}
|
||||
}
|
||||
}
|
||||
None => {
|
||||
ui.label("--");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
ui.add_space(4.0);
|
||||
});
|
||||
}
|
||||
|
||||
/// Render document settings section (shown when nothing is selected)
|
||||
fn render_document_section(&self, ui: &mut Ui, shared: &mut SharedPaneState) {
|
||||
egui::CollapsingHeader::new("Document")
|
||||
.default_open(true)
|
||||
.show(ui, |ui| {
|
||||
ui.add_space(4.0);
|
||||
|
||||
let document = shared.action_executor.document();
|
||||
|
||||
// Get current values for editing
|
||||
let mut width = document.width;
|
||||
let mut height = document.height;
|
||||
let mut duration = document.duration;
|
||||
let mut framerate = document.framerate;
|
||||
let layer_count = document.root.children.len();
|
||||
|
||||
// Canvas width
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Width:");
|
||||
if ui
|
||||
.add(DragValue::new(&mut width).speed(1.0).range(1.0..=10000.0))
|
||||
.changed()
|
||||
{
|
||||
let action = SetDocumentPropertiesAction::set_width(width);
|
||||
shared.pending_actions.push(Box::new(action));
|
||||
}
|
||||
});
|
||||
|
||||
// Canvas height
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Height:");
|
||||
if ui
|
||||
.add(DragValue::new(&mut height).speed(1.0).range(1.0..=10000.0))
|
||||
.changed()
|
||||
{
|
||||
let action = SetDocumentPropertiesAction::set_height(height);
|
||||
shared.pending_actions.push(Box::new(action));
|
||||
}
|
||||
});
|
||||
|
||||
// Duration
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Duration:");
|
||||
if ui
|
||||
.add(
|
||||
DragValue::new(&mut duration)
|
||||
.speed(0.1)
|
||||
.range(0.1..=3600.0)
|
||||
.suffix("s"),
|
||||
)
|
||||
.changed()
|
||||
{
|
||||
let action = SetDocumentPropertiesAction::set_duration(duration);
|
||||
shared.pending_actions.push(Box::new(action));
|
||||
}
|
||||
});
|
||||
|
||||
// Framerate
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Framerate:");
|
||||
if ui
|
||||
.add(
|
||||
DragValue::new(&mut framerate)
|
||||
.speed(1.0)
|
||||
.range(1.0..=120.0)
|
||||
.suffix(" fps"),
|
||||
)
|
||||
.changed()
|
||||
{
|
||||
let action = SetDocumentPropertiesAction::set_framerate(framerate);
|
||||
shared.pending_actions.push(Box::new(action));
|
||||
}
|
||||
});
|
||||
|
||||
// Layer count (read-only)
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Layers:");
|
||||
ui.label(format!("{}", layer_count));
|
||||
});
|
||||
|
||||
ui.add_space(4.0);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -20,23 +756,61 @@ impl PaneRenderer for InfopanelPane {
|
|||
ui: &mut egui::Ui,
|
||||
rect: egui::Rect,
|
||||
_path: &NodePath,
|
||||
_shared: &mut SharedPaneState,
|
||||
shared: &mut SharedPaneState,
|
||||
) {
|
||||
// Placeholder rendering
|
||||
// Background
|
||||
ui.painter().rect_filled(
|
||||
rect,
|
||||
0.0,
|
||||
egui::Color32::from_rgb(30, 50, 40),
|
||||
egui::Color32::from_rgb(30, 35, 40),
|
||||
);
|
||||
|
||||
let text = "Info Panel\n(TODO: Implement property editor)";
|
||||
ui.painter().text(
|
||||
rect.center(),
|
||||
egui::Align2::CENTER_CENTER,
|
||||
text,
|
||||
egui::FontId::proportional(16.0),
|
||||
egui::Color32::from_gray(150),
|
||||
// Create scrollable area for content
|
||||
let content_rect = rect.shrink(8.0);
|
||||
let mut content_ui = ui.new_child(
|
||||
egui::UiBuilder::new()
|
||||
.max_rect(content_rect)
|
||||
.layout(egui::Layout::top_down(egui::Align::LEFT)),
|
||||
);
|
||||
|
||||
egui::ScrollArea::vertical()
|
||||
.id_salt("infopanel_scroll")
|
||||
.show(&mut content_ui, |ui| {
|
||||
ui.set_min_width(content_rect.width() - 16.0);
|
||||
|
||||
// 1. Tool options section (always shown if tool has options)
|
||||
self.render_tool_section(ui, shared);
|
||||
|
||||
// 2. Gather selection info
|
||||
let info = self.gather_selection_info(shared);
|
||||
|
||||
// 3. Transform section (if shapes selected)
|
||||
if info.shape_count > 0 {
|
||||
self.render_transform_section(ui, shared, &info);
|
||||
}
|
||||
|
||||
// 4. Shape properties section (if shapes selected)
|
||||
if info.shape_count > 0 {
|
||||
self.render_shape_section(ui, shared, &info);
|
||||
}
|
||||
|
||||
// 5. Document settings (if nothing selected)
|
||||
if info.is_empty {
|
||||
self.render_document_section(ui, shared);
|
||||
}
|
||||
|
||||
// Show selection count at bottom
|
||||
if info.shape_count > 0 {
|
||||
ui.add_space(8.0);
|
||||
ui.separator();
|
||||
ui.add_space(4.0);
|
||||
ui.label(format!(
|
||||
"{} object{} selected",
|
||||
info.shape_count,
|
||||
if info.shape_count == 1 { "" } else { "s" }
|
||||
));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn name(&self) -> &str {
|
||||
|
|
|
|||
|
|
@ -113,6 +113,15 @@ pub struct SharedPaneState<'a> {
|
|||
pub is_playing: &'a mut bool, // Whether playback is currently active
|
||||
/// Asset being dragged from Asset Library (for cross-pane drag-and-drop)
|
||||
pub dragging_asset: &'a mut Option<DraggingAsset>,
|
||||
// Tool-specific options for infopanel
|
||||
/// Stroke width for drawing tools (Draw, Rectangle, Ellipse, Line, Polygon)
|
||||
pub stroke_width: &'a mut f64,
|
||||
/// Whether to fill shapes when drawing (Rectangle, Ellipse, Polygon)
|
||||
pub fill_enabled: &'a mut bool,
|
||||
/// Fill gap tolerance for paint bucket tool
|
||||
pub paint_bucket_gap_tolerance: &'a mut f64,
|
||||
/// Number of sides for polygon tool
|
||||
pub polygon_sides: &'a mut u32,
|
||||
}
|
||||
|
||||
/// Trait for pane rendering
|
||||
|
|
|
|||
|
|
@ -231,13 +231,14 @@ struct VelloCallback {
|
|||
pan_offset: egui::Vec2,
|
||||
zoom: f32,
|
||||
instance_id: u64,
|
||||
document: lightningbeam_core::document::Document,
|
||||
document: std::sync::Arc<lightningbeam_core::document::Document>,
|
||||
tool_state: lightningbeam_core::tool::ToolState,
|
||||
active_layer_id: Option<uuid::Uuid>,
|
||||
drag_delta: Option<vello::kurbo::Vec2>, // Delta for drag preview (world space)
|
||||
selection: lightningbeam_core::selection::Selection,
|
||||
fill_color: egui::Color32, // Current fill color for previews
|
||||
stroke_color: egui::Color32, // Current stroke color for previews
|
||||
stroke_width: f64, // Current stroke width for previews
|
||||
selected_tool: lightningbeam_core::tool::Tool, // Current tool for rendering mode-specific UI
|
||||
eyedropper_request: Option<(egui::Pos2, super::ColorMode)>, // Pending eyedropper sample
|
||||
playback_time: f64, // Current playback time for animation evaluation
|
||||
|
|
@ -249,18 +250,19 @@ impl VelloCallback {
|
|||
pan_offset: egui::Vec2,
|
||||
zoom: f32,
|
||||
instance_id: u64,
|
||||
document: lightningbeam_core::document::Document,
|
||||
document: std::sync::Arc<lightningbeam_core::document::Document>,
|
||||
tool_state: lightningbeam_core::tool::ToolState,
|
||||
active_layer_id: Option<uuid::Uuid>,
|
||||
drag_delta: Option<vello::kurbo::Vec2>,
|
||||
selection: lightningbeam_core::selection::Selection,
|
||||
fill_color: egui::Color32,
|
||||
stroke_color: egui::Color32,
|
||||
stroke_width: f64,
|
||||
selected_tool: lightningbeam_core::tool::Tool,
|
||||
eyedropper_request: Option<(egui::Pos2, super::ColorMode)>,
|
||||
playback_time: f64,
|
||||
) -> Self {
|
||||
Self { rect, pan_offset, zoom, instance_id, document, tool_state, active_layer_id, drag_delta, selection, fill_color, stroke_color, selected_tool, eyedropper_request, playback_time }
|
||||
Self { rect, pan_offset, zoom, instance_id, document, tool_state, active_layer_id, drag_delta, selection, fill_color, stroke_color, stroke_width, selected_tool, eyedropper_request, playback_time }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -331,14 +333,44 @@ impl egui_wgpu::CallbackTrait for VelloCallback {
|
|||
// Render each object at its preview position (original + delta)
|
||||
for (object_id, original_pos) in original_positions {
|
||||
// Try shape instance first
|
||||
if let Some(_object) = vector_layer.get_object(object_id) {
|
||||
if let Some(shape) = vector_layer.get_shape(&_object.shape_id) {
|
||||
if let Some(object) = vector_layer.get_object(object_id) {
|
||||
if let Some(shape) = vector_layer.get_shape(&object.shape_id) {
|
||||
// New position = original + delta
|
||||
let new_x = original_pos.x + delta.x;
|
||||
let new_y = original_pos.y + delta.y;
|
||||
|
||||
// Build transform for preview position
|
||||
let object_transform = Affine::translate((new_x, new_y));
|
||||
// Build skew transform around shape center (matching renderer.rs)
|
||||
let path = shape.path();
|
||||
let skew_transform = if object.transform.skew_x != 0.0 || object.transform.skew_y != 0.0 {
|
||||
let bbox = path.bounding_box();
|
||||
let center_x = (bbox.x0 + bbox.x1) / 2.0;
|
||||
let center_y = (bbox.y0 + bbox.y1) / 2.0;
|
||||
|
||||
let skew_x_affine = if object.transform.skew_x != 0.0 {
|
||||
Affine::skew(object.transform.skew_x.to_radians().tan(), 0.0)
|
||||
} else {
|
||||
Affine::IDENTITY
|
||||
};
|
||||
|
||||
let skew_y_affine = if object.transform.skew_y != 0.0 {
|
||||
Affine::skew(0.0, object.transform.skew_y.to_radians().tan())
|
||||
} else {
|
||||
Affine::IDENTITY
|
||||
};
|
||||
|
||||
Affine::translate((center_x, center_y))
|
||||
* skew_x_affine
|
||||
* skew_y_affine
|
||||
* Affine::translate((-center_x, -center_y))
|
||||
} else {
|
||||
Affine::IDENTITY
|
||||
};
|
||||
|
||||
// Build full transform: translate * rotate * scale * skew
|
||||
let object_transform = Affine::translate((new_x, new_y))
|
||||
* Affine::rotate(object.transform.rotation.to_radians())
|
||||
* Affine::scale_non_uniform(object.transform.scale_x, object.transform.scale_y)
|
||||
* skew_transform;
|
||||
let combined_transform = camera_transform * object_transform;
|
||||
|
||||
// Render shape with semi-transparent fill (light blue, 40% opacity)
|
||||
|
|
@ -348,7 +380,7 @@ impl egui_wgpu::CallbackTrait for VelloCallback {
|
|||
combined_transform,
|
||||
&Brush::Solid(alpha_color),
|
||||
None,
|
||||
shape.path(),
|
||||
path,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -783,8 +815,7 @@ impl egui_wgpu::CallbackTrait for VelloCallback {
|
|||
preview_path.line_to(*point);
|
||||
}
|
||||
|
||||
// Draw the preview path with stroke
|
||||
let stroke_width = (2.0 / self.zoom.max(0.5) as f64).max(1.0);
|
||||
// Draw the preview path with stroke using configured stroke width
|
||||
let stroke_color = Color::from_rgb8(
|
||||
self.stroke_color.r(),
|
||||
self.stroke_color.g(),
|
||||
|
|
@ -792,7 +823,7 @@ impl egui_wgpu::CallbackTrait for VelloCallback {
|
|||
);
|
||||
|
||||
scene.stroke(
|
||||
&Stroke::new(stroke_width),
|
||||
&Stroke::new(self.stroke_width),
|
||||
camera_transform,
|
||||
stroke_color,
|
||||
None,
|
||||
|
|
@ -1636,8 +1667,8 @@ impl StagePane {
|
|||
// Mouse up: create the rectangle shape
|
||||
if response.drag_stopped() || (ui.input(|i| i.pointer.any_released()) && matches!(shared.tool_state, ToolState::CreatingRectangle { .. })) {
|
||||
if let ToolState::CreatingRectangle { start_point, current_point, centered, constrain_square } = shared.tool_state.clone() {
|
||||
// Calculate rectangle bounds based on mode
|
||||
let (width, height, position) = if centered {
|
||||
// Calculate rectangle bounds and center position based on mode
|
||||
let (width, height, center) = if centered {
|
||||
// Centered mode: start_point is center
|
||||
let dx = current_point.x - start_point.x;
|
||||
let dy = current_point.y - start_point.y;
|
||||
|
|
@ -1649,8 +1680,8 @@ impl StagePane {
|
|||
(dx.abs() * 2.0, dy.abs() * 2.0)
|
||||
};
|
||||
|
||||
let pos = Point::new(start_point.x - w / 2.0, start_point.y - h / 2.0);
|
||||
(w, h, pos)
|
||||
// start_point is already the center
|
||||
(w, h, start_point)
|
||||
} else {
|
||||
// Corner mode: start_point is corner
|
||||
let mut min_x = start_point.x.min(current_point.x);
|
||||
|
|
@ -1676,21 +1707,35 @@ impl StagePane {
|
|||
}
|
||||
}
|
||||
|
||||
(max_x - min_x, max_y - min_y, Point::new(min_x, min_y))
|
||||
// Return width, height, and center position
|
||||
let center_x = (min_x + max_x) / 2.0;
|
||||
let center_y = (min_y + max_y) / 2.0;
|
||||
(max_x - min_x, max_y - min_y, Point::new(center_x, center_y))
|
||||
};
|
||||
|
||||
// Only create shape if rectangle has non-zero size
|
||||
if width > 1.0 && height > 1.0 {
|
||||
use lightningbeam_core::shape::{Shape, ShapeColor};
|
||||
use lightningbeam_core::shape::{Shape, ShapeColor, StrokeStyle};
|
||||
use lightningbeam_core::object::ShapeInstance;
|
||||
use lightningbeam_core::actions::AddShapeAction;
|
||||
|
||||
// Create shape with rectangle path (built from lines)
|
||||
// Create shape with rectangle path centered at origin
|
||||
let path = Self::create_rectangle_path(width, height);
|
||||
let shape = Shape::new(path).with_fill(ShapeColor::from_egui(*shared.fill_color));
|
||||
let mut shape = Shape::new(path);
|
||||
|
||||
// Create object at the calculated position
|
||||
let object = ShapeInstance::new(shape.id).with_position(position.x, position.y);
|
||||
// Apply fill if enabled
|
||||
if *shared.fill_enabled {
|
||||
shape = shape.with_fill(ShapeColor::from_egui(*shared.fill_color));
|
||||
}
|
||||
|
||||
// Apply stroke with configured width
|
||||
shape = shape.with_stroke(
|
||||
ShapeColor::from_egui(*shared.stroke_color),
|
||||
StrokeStyle { width: *shared.stroke_width, ..Default::default() }
|
||||
);
|
||||
|
||||
// Create object at the center position
|
||||
let object = ShapeInstance::new(shape.id).with_position(center.x, center.y);
|
||||
|
||||
// Create and execute action immediately
|
||||
let action = AddShapeAction::new(active_layer_id, shape, object);
|
||||
|
|
@ -1798,13 +1843,24 @@ impl StagePane {
|
|||
|
||||
// Only create shape if ellipse has non-zero size
|
||||
if rx > 1.0 && ry > 1.0 {
|
||||
use lightningbeam_core::shape::{Shape, ShapeColor};
|
||||
use lightningbeam_core::shape::{Shape, ShapeColor, StrokeStyle};
|
||||
use lightningbeam_core::object::ShapeInstance;
|
||||
use lightningbeam_core::actions::AddShapeAction;
|
||||
|
||||
// Create shape with ellipse path (built from bezier curves)
|
||||
let path = Self::create_ellipse_path(rx, ry);
|
||||
let shape = Shape::new(path).with_fill(ShapeColor::from_egui(*shared.fill_color));
|
||||
let mut shape = Shape::new(path);
|
||||
|
||||
// Apply fill if enabled
|
||||
if *shared.fill_enabled {
|
||||
shape = shape.with_fill(ShapeColor::from_egui(*shared.fill_color));
|
||||
}
|
||||
|
||||
// Apply stroke with configured width
|
||||
shape = shape.with_stroke(
|
||||
ShapeColor::from_egui(*shared.stroke_color),
|
||||
StrokeStyle { width: *shared.stroke_width, ..Default::default() }
|
||||
);
|
||||
|
||||
// Create object at the calculated position
|
||||
let object = ShapeInstance::new(shape.id).with_position(position.x, position.y);
|
||||
|
|
@ -1883,7 +1939,7 @@ impl StagePane {
|
|||
use lightningbeam_core::object::ShapeInstance;
|
||||
use lightningbeam_core::actions::AddShapeAction;
|
||||
|
||||
// Create shape with line path
|
||||
// Create shape with line path centered at origin
|
||||
let path = Self::create_line_path(dx, dy);
|
||||
|
||||
// Lines should have stroke by default, not fill
|
||||
|
|
@ -1891,13 +1947,15 @@ impl StagePane {
|
|||
.with_stroke(
|
||||
ShapeColor::from_egui(*shared.stroke_color),
|
||||
StrokeStyle {
|
||||
width: 2.0,
|
||||
width: *shared.stroke_width,
|
||||
..Default::default()
|
||||
}
|
||||
);
|
||||
|
||||
// Create object at the start point
|
||||
let object = ShapeInstance::new(shape.id).with_position(start_point.x, start_point.y);
|
||||
// Create object at the center of the line
|
||||
let center_x = (start_point.x + current_point.x) / 2.0;
|
||||
let center_y = (start_point.y + current_point.y) / 2.0;
|
||||
let object = ShapeInstance::new(shape.id).with_position(center_x, center_y);
|
||||
|
||||
// Create and execute action immediately
|
||||
let action = AddShapeAction::new(active_layer_id, shape, object);
|
||||
|
|
@ -1977,7 +2035,15 @@ impl StagePane {
|
|||
|
||||
// Create shape with polygon path
|
||||
let path = Self::create_polygon_path(num_sides, radius);
|
||||
let shape = Shape::new(path).with_fill(ShapeColor::from_egui(*shared.fill_color));
|
||||
use lightningbeam_core::shape::StrokeStyle;
|
||||
let mut shape = Shape::new(path);
|
||||
if *shared.fill_enabled {
|
||||
shape = shape.with_fill(ShapeColor::from_egui(*shared.fill_color));
|
||||
}
|
||||
shape = shape.with_stroke(
|
||||
ShapeColor::from_egui(*shared.stroke_color),
|
||||
StrokeStyle { width: *shared.stroke_width, ..Default::default() }
|
||||
);
|
||||
|
||||
// Create object at the center point
|
||||
let object = ShapeInstance::new(shape.id).with_position(center.x, center.y);
|
||||
|
|
@ -2006,23 +2072,26 @@ impl StagePane {
|
|||
}
|
||||
}
|
||||
|
||||
/// Create a rectangle path from lines (easier for curve editing later)
|
||||
/// Create a rectangle path centered at origin (easier for curve editing later)
|
||||
fn create_rectangle_path(width: f64, height: f64) -> vello::kurbo::BezPath {
|
||||
use vello::kurbo::{BezPath, Point};
|
||||
|
||||
let half_w = width / 2.0;
|
||||
let half_h = height / 2.0;
|
||||
|
||||
let mut path = BezPath::new();
|
||||
|
||||
// Start at top-left
|
||||
path.move_to(Point::new(0.0, 0.0));
|
||||
// Start at top-left (centered at origin)
|
||||
path.move_to(Point::new(-half_w, -half_h));
|
||||
|
||||
// Top-right
|
||||
path.line_to(Point::new(width, 0.0));
|
||||
path.line_to(Point::new(half_w, -half_h));
|
||||
|
||||
// Bottom-right
|
||||
path.line_to(Point::new(width, height));
|
||||
path.line_to(Point::new(half_w, half_h));
|
||||
|
||||
// Bottom-left
|
||||
path.line_to(Point::new(0.0, height));
|
||||
path.line_to(Point::new(-half_w, half_h));
|
||||
|
||||
// Close path (back to top-left)
|
||||
path.close_path();
|
||||
|
|
@ -2080,17 +2149,18 @@ impl StagePane {
|
|||
path
|
||||
}
|
||||
|
||||
/// Create a line path from start to end point
|
||||
/// Create a line path centered at origin
|
||||
fn create_line_path(dx: f64, dy: f64) -> vello::kurbo::BezPath {
|
||||
use vello::kurbo::{BezPath, Point};
|
||||
|
||||
let mut path = BezPath::new();
|
||||
|
||||
// Start at origin (object position will be the start point)
|
||||
path.move_to(Point::new(0.0, 0.0));
|
||||
// Line goes from -half to +half so it's centered at origin
|
||||
let half_dx = dx / 2.0;
|
||||
let half_dy = dy / 2.0;
|
||||
|
||||
// Line to end point
|
||||
path.line_to(Point::new(dx, dy));
|
||||
path.move_to(Point::new(-half_dx, -half_dy));
|
||||
path.line_to(Point::new(half_dx, half_dy));
|
||||
|
||||
path
|
||||
}
|
||||
|
|
@ -2238,26 +2308,29 @@ impl StagePane {
|
|||
|
||||
// Only create shape if path is not empty
|
||||
if !path.is_empty() {
|
||||
// Calculate bounding box to position the object
|
||||
// Calculate bounding box center for object position
|
||||
let bbox = path.bounding_box();
|
||||
let position = Point::new(bbox.x0, bbox.y0);
|
||||
let center_x = (bbox.x0 + bbox.x1) / 2.0;
|
||||
let center_y = (bbox.y0 + bbox.y1) / 2.0;
|
||||
|
||||
// Translate path to be relative to position (0,0 at top-left of bbox)
|
||||
// Translate path so its center is at origin (0,0)
|
||||
use vello::kurbo::Affine;
|
||||
let transform = Affine::translate((-bbox.x0, -bbox.y0));
|
||||
let transform = Affine::translate((-center_x, -center_y));
|
||||
let translated_path = transform * path;
|
||||
|
||||
// Create shape with both fill and stroke
|
||||
// Create shape with fill (if enabled) and stroke
|
||||
use lightningbeam_core::shape::StrokeStyle;
|
||||
let shape = Shape::new(translated_path)
|
||||
.with_fill(ShapeColor::from_egui(*shared.fill_color))
|
||||
.with_stroke(
|
||||
let mut shape = Shape::new(translated_path);
|
||||
if *shared.fill_enabled {
|
||||
shape = shape.with_fill(ShapeColor::from_egui(*shared.fill_color));
|
||||
}
|
||||
shape = shape.with_stroke(
|
||||
ShapeColor::from_egui(*shared.stroke_color),
|
||||
StrokeStyle::default(),
|
||||
StrokeStyle { width: *shared.stroke_width, ..Default::default() }
|
||||
);
|
||||
|
||||
// Create object at the calculated position
|
||||
let object = ShapeInstance::new(shape.id).with_position(position.x, position.y);
|
||||
// Create object at the center position
|
||||
let object = ShapeInstance::new(shape.id).with_position(center_x, center_y);
|
||||
|
||||
// Create and execute action immediately
|
||||
let action = AddShapeAction::new(active_layer_id, shape, object);
|
||||
|
|
@ -4257,18 +4330,20 @@ impl PaneRenderer for StagePane {
|
|||
};
|
||||
|
||||
// Use egui's custom painting callback for Vello
|
||||
// document_arc() returns Arc<Document> - cheap pointer copy, not deep clone
|
||||
let callback = VelloCallback::new(
|
||||
rect,
|
||||
self.pan_offset,
|
||||
self.zoom,
|
||||
self.instance_id,
|
||||
shared.action_executor.document().clone(),
|
||||
shared.action_executor.document_arc(),
|
||||
shared.tool_state.clone(),
|
||||
*shared.active_layer_id,
|
||||
drag_delta,
|
||||
shared.selection.clone(),
|
||||
*shared.fill_color,
|
||||
*shared.stroke_color,
|
||||
*shared.stroke_width,
|
||||
*shared.selected_tool,
|
||||
self.pending_eyedropper_sample,
|
||||
*shared.playback_time,
|
||||
|
|
|
|||
Loading…
Reference in New Issue