Compare commits
7 Commits
116db01805
...
3ba6dcb3d2
| Author | SHA1 | Date |
|---|---|---|
|
|
3ba6dcb3d2 | |
|
|
7e2f63b62d | |
|
|
3ca03069ec | |
|
|
ce40147efa | |
|
|
042dd50db3 | |
|
|
66c848e218 | |
|
|
35089f3b2e |
|
|
@ -1153,6 +1153,7 @@ impl Engine {
|
|||
"Gain" => Box::new(GainNode::new("Gain".to_string())),
|
||||
"Mixer" => Box::new(MixerNode::new("Mixer".to_string())),
|
||||
"Filter" => Box::new(FilterNode::new("Filter".to_string())),
|
||||
"SVF" => Box::new(SVFNode::new("SVF".to_string())),
|
||||
"ADSR" => Box::new(ADSRNode::new("ADSR".to_string())),
|
||||
"LFO" => Box::new(LFONode::new("LFO".to_string())),
|
||||
"NoiseGenerator" => Box::new(NoiseGeneratorNode::new("Noise".to_string())),
|
||||
|
|
@ -1244,6 +1245,7 @@ impl Engine {
|
|||
"Gain" => Box::new(GainNode::new("Gain".to_string())),
|
||||
"Mixer" => Box::new(MixerNode::new("Mixer".to_string())),
|
||||
"Filter" => Box::new(FilterNode::new("Filter".to_string())),
|
||||
"SVF" => Box::new(SVFNode::new("SVF".to_string())),
|
||||
"ADSR" => Box::new(ADSRNode::new("ADSR".to_string())),
|
||||
"LFO" => Box::new(LFONode::new("LFO".to_string())),
|
||||
"NoiseGenerator" => Box::new(NoiseGeneratorNode::new("Noise".to_string())),
|
||||
|
|
@ -1838,6 +1840,21 @@ impl Engine {
|
|||
}
|
||||
}
|
||||
|
||||
Command::MultiSamplerClearLayers(track_id, node_id) => {
|
||||
use crate::audio::node_graph::nodes::MultiSamplerNode;
|
||||
|
||||
if let Some(TrackNode::Midi(track)) = self.project.get_track_mut(track_id) {
|
||||
let graph = &mut track.instrument_graph;
|
||||
let node_idx = NodeIndex::new(node_id as usize);
|
||||
|
||||
if let Some(graph_node) = graph.get_graph_node_mut(node_idx) {
|
||||
if let Some(multi_sampler_node) = graph_node.node.as_any_mut().downcast_mut::<MultiSamplerNode>() {
|
||||
multi_sampler_node.clear_layers();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Command::AutomationAddKeyframe(track_id, node_id, time, value, interpolation_str, ease_out, ease_in) => {
|
||||
use crate::audio::node_graph::nodes::{AutomationInputNode, AutomationKeyframe, InterpolationType};
|
||||
|
||||
|
|
@ -3327,6 +3344,11 @@ impl EngineController {
|
|||
let _ = self.command_tx.push(Command::MultiSamplerRemoveLayer(track_id, node_id, layer_index));
|
||||
}
|
||||
|
||||
/// Clear all layers from a MultiSampler node
|
||||
pub fn multi_sampler_clear_layers(&mut self, track_id: TrackId, node_id: u32) {
|
||||
let _ = self.command_tx.push(Command::MultiSamplerClearLayers(track_id, node_id));
|
||||
}
|
||||
|
||||
/// Send a synchronous query and wait for the response
|
||||
/// This blocks until the audio thread processes the query
|
||||
/// Generic method that works with any Query/QueryResponse pair
|
||||
|
|
|
|||
|
|
@ -988,6 +988,7 @@ impl AudioGraph {
|
|||
"Gain" => Box::new(GainNode::new("Gain")),
|
||||
"Mixer" => Box::new(MixerNode::new("Mixer")),
|
||||
"Filter" => Box::new(FilterNode::new("Filter")),
|
||||
"SVF" => Box::new(SVFNode::new("SVF")),
|
||||
"ADSR" => Box::new(ADSRNode::new("ADSR")),
|
||||
"LFO" => Box::new(LFONode::new("LFO")),
|
||||
"NoiseGenerator" => Box::new(NoiseGeneratorNode::new("Noise")),
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ mod sequencer;
|
|||
mod simple_sampler;
|
||||
mod slew_limiter;
|
||||
mod splitter;
|
||||
mod svf;
|
||||
mod template_io;
|
||||
mod vocoder;
|
||||
mod voice_allocator;
|
||||
|
|
@ -85,6 +86,7 @@ pub use sequencer::SequencerNode;
|
|||
pub use simple_sampler::SimpleSamplerNode;
|
||||
pub use slew_limiter::SlewLimiterNode;
|
||||
pub use splitter::SplitterNode;
|
||||
pub use svf::SVFNode;
|
||||
pub use template_io::{TemplateInputNode, TemplateOutputNode};
|
||||
pub use vocoder::VocoderNode;
|
||||
pub use voice_allocator::VoiceAllocatorNode;
|
||||
|
|
|
|||
|
|
@ -458,6 +458,16 @@ impl MultiSamplerNode {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// Remove all layers
|
||||
pub fn clear_layers(&mut self) {
|
||||
self.layers.clear();
|
||||
self.layer_infos.clear();
|
||||
// Stop all active voices
|
||||
for voice in &mut self.voices {
|
||||
voice.is_active = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Find the best matching layer for a given note and velocity
|
||||
fn find_layer(&self, note: u8, velocity: u8) -> Option<usize> {
|
||||
self.layers
|
||||
|
|
|
|||
|
|
@ -0,0 +1,199 @@
|
|||
use crate::audio::node_graph::{AudioNode, NodeCategory, NodePort, Parameter, ParameterUnit, SignalType, cv_input_or_default};
|
||||
use crate::audio::midi::MidiEvent;
|
||||
use crate::dsp::svf::SvfFilter;
|
||||
|
||||
const PARAM_CUTOFF: u32 = 0;
|
||||
const PARAM_RESONANCE: u32 = 1;
|
||||
|
||||
/// State Variable Filter node — simultaneously outputs lowpass, highpass,
|
||||
/// bandpass, and notch from one filter, with per-sample CV modulation of
|
||||
/// cutoff and resonance.
|
||||
pub struct SVFNode {
|
||||
name: String,
|
||||
filter: SvfFilter,
|
||||
cutoff: f32,
|
||||
resonance: f32,
|
||||
sample_rate: u32,
|
||||
inputs: Vec<NodePort>,
|
||||
outputs: Vec<NodePort>,
|
||||
parameters: Vec<Parameter>,
|
||||
}
|
||||
|
||||
impl SVFNode {
|
||||
pub fn new(name: impl Into<String>) -> Self {
|
||||
let name = name.into();
|
||||
|
||||
let inputs = vec![
|
||||
NodePort::new("Audio In", SignalType::Audio, 0),
|
||||
NodePort::new("Cutoff CV", SignalType::CV, 1),
|
||||
NodePort::new("Resonance CV", SignalType::CV, 2),
|
||||
];
|
||||
|
||||
let outputs = vec![
|
||||
NodePort::new("Lowpass", SignalType::Audio, 0),
|
||||
NodePort::new("Highpass", SignalType::Audio, 1),
|
||||
NodePort::new("Bandpass", SignalType::Audio, 2),
|
||||
NodePort::new("Notch", SignalType::Audio, 3),
|
||||
];
|
||||
|
||||
let parameters = vec![
|
||||
Parameter::new(PARAM_CUTOFF, "Cutoff", 20.0, 20000.0, 1000.0, ParameterUnit::Frequency),
|
||||
Parameter::new(PARAM_RESONANCE, "Resonance", 0.0, 1.0, 0.0, ParameterUnit::Generic),
|
||||
];
|
||||
|
||||
let mut filter = SvfFilter::new();
|
||||
filter.set_params(1000.0, 0.0, 44100.0);
|
||||
|
||||
Self {
|
||||
name,
|
||||
filter,
|
||||
cutoff: 1000.0,
|
||||
resonance: 0.0,
|
||||
sample_rate: 44100,
|
||||
inputs,
|
||||
outputs,
|
||||
parameters,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AudioNode for SVFNode {
|
||||
fn category(&self) -> NodeCategory {
|
||||
NodeCategory::Effect
|
||||
}
|
||||
|
||||
fn inputs(&self) -> &[NodePort] {
|
||||
&self.inputs
|
||||
}
|
||||
|
||||
fn outputs(&self) -> &[NodePort] {
|
||||
&self.outputs
|
||||
}
|
||||
|
||||
fn parameters(&self) -> &[Parameter] {
|
||||
&self.parameters
|
||||
}
|
||||
|
||||
fn set_parameter(&mut self, id: u32, value: f32) {
|
||||
match id {
|
||||
PARAM_CUTOFF => {
|
||||
self.cutoff = value.clamp(20.0, 20000.0);
|
||||
self.filter.set_params(self.cutoff, self.resonance, self.sample_rate as f32);
|
||||
}
|
||||
PARAM_RESONANCE => {
|
||||
self.resonance = value.clamp(0.0, 1.0);
|
||||
self.filter.set_params(self.cutoff, self.resonance, self.sample_rate as f32);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn get_parameter(&self, id: u32) -> f32 {
|
||||
match id {
|
||||
PARAM_CUTOFF => self.cutoff,
|
||||
PARAM_RESONANCE => self.resonance,
|
||||
_ => 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
fn process(
|
||||
&mut self,
|
||||
inputs: &[&[f32]],
|
||||
outputs: &mut [&mut [f32]],
|
||||
_midi_inputs: &[&[MidiEvent]],
|
||||
_midi_outputs: &mut [&mut Vec<MidiEvent>],
|
||||
sample_rate: u32,
|
||||
) {
|
||||
if inputs.is_empty() || outputs.len() < 4 {
|
||||
return;
|
||||
}
|
||||
|
||||
if self.sample_rate != sample_rate {
|
||||
self.sample_rate = sample_rate;
|
||||
self.filter.set_params(self.cutoff, self.resonance, sample_rate as f32);
|
||||
}
|
||||
|
||||
let input = inputs[0];
|
||||
// All 4 outputs are stereo interleaved
|
||||
let frames = input.len() / 2;
|
||||
let sr = self.sample_rate as f32;
|
||||
|
||||
// Check if CV inputs are connected (sample first frame to detect NaN)
|
||||
let has_cutoff_cv = !cv_input_or_default(inputs, 1, 0, f32::NAN).is_nan();
|
||||
let has_resonance_cv = !cv_input_or_default(inputs, 2, 0, f32::NAN).is_nan();
|
||||
|
||||
let mut last_cutoff = self.cutoff;
|
||||
let mut last_resonance = self.resonance;
|
||||
|
||||
for frame in 0..frames {
|
||||
// Update coefficients from CV if connected
|
||||
if has_cutoff_cv || has_resonance_cv {
|
||||
let cutoff = if has_cutoff_cv {
|
||||
let cv = cv_input_or_default(inputs, 1, frame, 0.5);
|
||||
let octave_shift = (cv.clamp(0.0, 1.0) - 0.5) * 4.0;
|
||||
(self.cutoff * 2.0_f32.powf(octave_shift)).clamp(20.0, 20000.0)
|
||||
} else {
|
||||
self.cutoff
|
||||
};
|
||||
|
||||
let resonance = if has_resonance_cv {
|
||||
cv_input_or_default(inputs, 2, frame, self.resonance).clamp(0.0, 1.0)
|
||||
} else {
|
||||
self.resonance
|
||||
};
|
||||
|
||||
if cutoff != last_cutoff || resonance != last_resonance {
|
||||
self.filter.set_params(cutoff, resonance, sr);
|
||||
last_cutoff = cutoff;
|
||||
last_resonance = resonance;
|
||||
}
|
||||
}
|
||||
|
||||
// Process both channels, writing all 4 outputs
|
||||
for ch in 0..2 {
|
||||
let idx = frame * 2 + ch;
|
||||
let (lp, hp, bp, notch) = self.filter.process_sample_quad(input[idx], ch);
|
||||
outputs[0][idx] = lp;
|
||||
outputs[1][idx] = hp;
|
||||
outputs[2][idx] = bp;
|
||||
outputs[3][idx] = notch;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.filter.reset();
|
||||
}
|
||||
|
||||
fn node_type(&self) -> &str {
|
||||
"SVF"
|
||||
}
|
||||
|
||||
fn name(&self) -> &str {
|
||||
&self.name
|
||||
}
|
||||
|
||||
fn clone_node(&self) -> Box<dyn AudioNode> {
|
||||
let mut filter = SvfFilter::new();
|
||||
filter.set_params(self.cutoff, self.resonance, self.sample_rate as f32);
|
||||
|
||||
Box::new(Self {
|
||||
name: self.name.clone(),
|
||||
filter,
|
||||
cutoff: self.cutoff,
|
||||
resonance: self.resonance,
|
||||
sample_rate: self.sample_rate,
|
||||
inputs: self.inputs.clone(),
|
||||
outputs: self.outputs.clone(),
|
||||
parameters: self.parameters.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
|
||||
self
|
||||
}
|
||||
|
||||
fn as_any(&self) -> &dyn std::any::Any {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
|
@ -194,6 +194,8 @@ pub enum Command {
|
|||
MultiSamplerUpdateLayer(TrackId, u32, usize, u8, u8, u8, u8, u8, Option<usize>, Option<usize>, LoopMode),
|
||||
/// Remove a layer from a MultiSampler node (track_id, node_id, layer_index)
|
||||
MultiSamplerRemoveLayer(TrackId, u32, usize),
|
||||
/// Clear all layers from a MultiSampler node (track_id, node_id)
|
||||
MultiSamplerClearLayers(TrackId, u32),
|
||||
|
||||
// Automation Input Node commands
|
||||
/// Add or update a keyframe on an AutomationInput node (track_id, node_id, time, value, interpolation, ease_out, ease_in)
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
pub mod biquad;
|
||||
pub mod svf;
|
||||
|
||||
pub use biquad::BiquadFilter;
|
||||
pub use svf::SvfFilter;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,135 @@
|
|||
use std::f32::consts::PI;
|
||||
|
||||
/// State Variable Filter mode
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub enum SvfMode {
|
||||
Lowpass = 0,
|
||||
Highpass = 1,
|
||||
Bandpass = 2,
|
||||
Notch = 3,
|
||||
}
|
||||
|
||||
impl SvfMode {
|
||||
pub fn from_f32(value: f32) -> Self {
|
||||
match value.round() as i32 {
|
||||
1 => SvfMode::Highpass,
|
||||
2 => SvfMode::Bandpass,
|
||||
3 => SvfMode::Notch,
|
||||
_ => SvfMode::Lowpass,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Linear trapezoidal integrated State Variable Filter (Simper/Cytomic)
|
||||
///
|
||||
/// Zero-delay feedback topology. Per-sample cutoff modulation is cheap —
|
||||
/// just update `g` and `k` coefficients (no per-sample trig needed if
|
||||
/// cutoff hasn't changed).
|
||||
#[derive(Clone)]
|
||||
pub struct SvfFilter {
|
||||
// Coefficients
|
||||
g: f32, // frequency warping: tan(π * cutoff / sample_rate)
|
||||
k: f32, // damping: 2 - 2*resonance
|
||||
a1: f32, // 1 / (1 + g*(g+k))
|
||||
a2: f32, // g * a1
|
||||
|
||||
// State per channel (up to 2 for stereo)
|
||||
ic1eq: [f32; 2],
|
||||
ic2eq: [f32; 2],
|
||||
|
||||
mode: SvfMode,
|
||||
}
|
||||
|
||||
impl SvfFilter {
|
||||
/// Create a new SVF with default parameters (1kHz lowpass, no resonance)
|
||||
pub fn new() -> Self {
|
||||
let mut filter = Self {
|
||||
g: 0.0,
|
||||
k: 2.0,
|
||||
a1: 0.0,
|
||||
a2: 0.0,
|
||||
ic1eq: [0.0; 2],
|
||||
ic2eq: [0.0; 2],
|
||||
mode: SvfMode::Lowpass,
|
||||
};
|
||||
filter.set_params(1000.0, 0.0, 44100.0);
|
||||
filter
|
||||
}
|
||||
|
||||
/// Set filter parameters
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `cutoff_hz` - Cutoff frequency in Hz (clamped to valid range)
|
||||
/// * `resonance` - Resonance 0.0 (none) to 1.0 (self-oscillation)
|
||||
/// * `sample_rate` - Sample rate in Hz
|
||||
#[inline]
|
||||
pub fn set_params(&mut self, cutoff_hz: f32, resonance: f32, sample_rate: f32) {
|
||||
// Clamp cutoff to avoid instability near Nyquist
|
||||
let cutoff = cutoff_hz.clamp(5.0, sample_rate * 0.49);
|
||||
let resonance = resonance.clamp(0.0, 1.0);
|
||||
|
||||
self.g = (PI * cutoff / sample_rate).tan();
|
||||
self.k = 2.0 - 2.0 * resonance;
|
||||
self.a1 = 1.0 / (1.0 + self.g * (self.g + self.k));
|
||||
self.a2 = self.g * self.a1;
|
||||
}
|
||||
|
||||
/// Set filter mode
|
||||
pub fn set_mode(&mut self, mode: SvfMode) {
|
||||
self.mode = mode;
|
||||
}
|
||||
|
||||
/// Process a single sample, returning all four outputs: (lowpass, highpass, bandpass, notch)
|
||||
#[inline]
|
||||
pub fn process_sample_quad(&mut self, input: f32, channel: usize) -> (f32, f32, f32, f32) {
|
||||
let ch = channel.min(1);
|
||||
|
||||
let v3 = input - self.ic2eq[ch];
|
||||
let v1 = self.a1 * self.ic1eq[ch] + self.a2 * v3;
|
||||
let v2 = self.ic2eq[ch] + self.g * v1;
|
||||
|
||||
self.ic1eq[ch] = 2.0 * v1 - self.ic1eq[ch];
|
||||
self.ic2eq[ch] = 2.0 * v2 - self.ic2eq[ch];
|
||||
|
||||
let hp = input - self.k * v1 - v2;
|
||||
(v2, hp, v1, hp + v2)
|
||||
}
|
||||
|
||||
/// Process a single sample with a selected mode
|
||||
#[inline]
|
||||
pub fn process_sample(&mut self, input: f32, channel: usize) -> f32 {
|
||||
let (lp, hp, bp, notch) = self.process_sample_quad(input, channel);
|
||||
match self.mode {
|
||||
SvfMode::Lowpass => lp,
|
||||
SvfMode::Highpass => hp,
|
||||
SvfMode::Bandpass => bp,
|
||||
SvfMode::Notch => notch,
|
||||
}
|
||||
}
|
||||
|
||||
/// Process a buffer of interleaved samples
|
||||
pub fn process_buffer(&mut self, buffer: &mut [f32], channels: usize) {
|
||||
if channels == 1 {
|
||||
for sample in buffer.iter_mut() {
|
||||
*sample = self.process_sample(*sample, 0);
|
||||
}
|
||||
} else if channels == 2 {
|
||||
for frame in buffer.chunks_exact_mut(2) {
|
||||
frame[0] = self.process_sample(frame[0], 0);
|
||||
frame[1] = self.process_sample(frame[1], 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Reset filter state (clear delay lines)
|
||||
pub fn reset(&mut self) {
|
||||
self.ic1eq = [0.0; 2];
|
||||
self.ic2eq = [0.0; 2];
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for SvfFilter {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
|
@ -20,7 +20,7 @@ egui_code_editor = "0.2"
|
|||
# GPU Rendering
|
||||
# vello from git uses wgpu 27, matching eframe 0.33
|
||||
vello = { git = "https://github.com/linebender/vello", branch = "main" }
|
||||
wgpu = { version = "27", features = ["vulkan", "metal"] }
|
||||
wgpu = { version = "27", features = ["vulkan", "metal", "gles"] }
|
||||
kurbo = { version = "0.12", features = ["serde"] }
|
||||
peniko = "0.5"
|
||||
|
||||
|
|
|
|||
|
|
@ -395,6 +395,7 @@ where
|
|||
src_pos,
|
||||
dst_pos,
|
||||
connection_color,
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -406,6 +407,8 @@ where
|
|||
.any_param_type(AnyParameterId::Output(output))
|
||||
.unwrap();
|
||||
let connection_color = port_type.data_type_color(user_state);
|
||||
let highlighted =
|
||||
self.highlighted_connection == Some((input, output));
|
||||
// outputs can't be wide yet so this is fine.
|
||||
let src_pos = port_locations[&AnyParameterId::Output(output)][0];
|
||||
let dst_pos = conn_locations[&input][hook_n];
|
||||
|
|
@ -415,6 +418,7 @@ where
|
|||
src_pos,
|
||||
dst_pos,
|
||||
connection_color,
|
||||
highlighted,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -586,10 +590,16 @@ fn draw_connection(
|
|||
src_pos: Pos2,
|
||||
dst_pos: Pos2,
|
||||
color: Color32,
|
||||
highlighted: bool,
|
||||
) {
|
||||
let (width, draw_color) = if highlighted {
|
||||
(7.0 * pan_zoom.zoom, Color32::from_rgb(100, 220, 100))
|
||||
} else {
|
||||
(5.0 * pan_zoom.zoom, color)
|
||||
};
|
||||
let connection_stroke = egui::Stroke {
|
||||
width: 5.0 * pan_zoom.zoom,
|
||||
color,
|
||||
width,
|
||||
color: draw_color,
|
||||
};
|
||||
|
||||
let control_scale = ((dst_pos.x - src_pos.x) * pan_zoom.zoom / 2.0).max(30.0 * pan_zoom.zoom);
|
||||
|
|
|
|||
|
|
@ -31,6 +31,10 @@ pub struct GraphEditorState<NodeData, DataType, ValueType, NodeTemplate, UserSta
|
|||
pub node_finder: Option<NodeFinder<NodeTemplate>>,
|
||||
/// The panning of the graph viewport.
|
||||
pub pan_zoom: PanZoom,
|
||||
/// A connection to highlight (e.g. as an insertion target during node drag).
|
||||
/// Stored as (InputId, OutputId). Not serialized.
|
||||
#[cfg_attr(feature = "persistence", serde(skip))]
|
||||
pub highlighted_connection: Option<(InputId, OutputId)>,
|
||||
pub _user_state: PhantomData<fn() -> UserState>,
|
||||
}
|
||||
|
||||
|
|
@ -57,6 +61,7 @@ impl<NodeData, DataType, ValueType, NodeKind, UserState> Default
|
|||
node_positions: Default::default(),
|
||||
node_finder: Default::default(),
|
||||
pan_zoom: Default::default(),
|
||||
highlighted_connection: Default::default(),
|
||||
_user_state: Default::default(),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,50 +1,35 @@
|
|||
//! Add shape action
|
||||
//!
|
||||
//! Handles adding a new shape and object to a vector layer.
|
||||
//! Handles adding a new shape to a vector layer's keyframe.
|
||||
|
||||
use crate::action::Action;
|
||||
use crate::document::Document;
|
||||
use crate::layer::AnyLayer;
|
||||
use crate::object::ShapeInstance;
|
||||
use crate::shape::Shape;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Action that adds a shape and object to a vector layer
|
||||
///
|
||||
/// This action creates both a Shape (the path/geometry) and an ShapeInstance
|
||||
/// (the instance with transform). Both are added to the layer.
|
||||
/// Action that adds a shape to a vector layer's keyframe
|
||||
pub struct AddShapeAction {
|
||||
/// Layer ID to add the shape to
|
||||
layer_id: Uuid,
|
||||
|
||||
/// The shape to add (contains path and styling)
|
||||
/// The shape to add (contains geometry, styling, transform, opacity)
|
||||
shape: Shape,
|
||||
|
||||
/// The object to add (references the shape with transform)
|
||||
object: ShapeInstance,
|
||||
/// Time of the keyframe to add to
|
||||
time: f64,
|
||||
|
||||
/// ID of the created shape (set after execution)
|
||||
created_shape_id: Option<Uuid>,
|
||||
|
||||
/// ID of the created object (set after execution)
|
||||
created_object_id: Option<Uuid>,
|
||||
}
|
||||
|
||||
impl AddShapeAction {
|
||||
/// Create a new add shape action
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `layer_id` - The layer to add the shape to
|
||||
/// * `shape` - The shape to add
|
||||
/// * `object` - The object instance referencing the shape
|
||||
pub fn new(layer_id: Uuid, shape: Shape, object: ShapeInstance) -> Self {
|
||||
pub fn new(layer_id: Uuid, shape: Shape, time: f64) -> Self {
|
||||
Self {
|
||||
layer_id,
|
||||
shape,
|
||||
object,
|
||||
time,
|
||||
created_shape_id: None,
|
||||
created_object_id: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -57,34 +42,25 @@ impl Action for AddShapeAction {
|
|||
};
|
||||
|
||||
if let AnyLayer::Vector(vector_layer) = layer {
|
||||
// Add shape and object to the layer
|
||||
let shape_id = vector_layer.add_shape_internal(self.shape.clone());
|
||||
let object_id = vector_layer.add_object_internal(self.object.clone());
|
||||
|
||||
// Store the IDs for rollback
|
||||
let shape_id = self.shape.id;
|
||||
vector_layer.add_shape_to_keyframe(self.shape.clone(), self.time);
|
||||
self.created_shape_id = Some(shape_id);
|
||||
self.created_object_id = Some(object_id);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn rollback(&mut self, document: &mut Document) -> Result<(), String> {
|
||||
// Remove the created shape and object if they exist
|
||||
if let (Some(shape_id), Some(object_id)) = (self.created_shape_id, self.created_object_id) {
|
||||
if let Some(shape_id) = self.created_shape_id {
|
||||
let layer = match document.get_layer_mut(&self.layer_id) {
|
||||
Some(l) => l,
|
||||
None => return Ok(()),
|
||||
};
|
||||
|
||||
if let AnyLayer::Vector(vector_layer) = layer {
|
||||
// Remove in reverse order: object first, then shape
|
||||
vector_layer.remove_object_internal(&object_id);
|
||||
vector_layer.remove_shape_internal(&shape_id);
|
||||
vector_layer.remove_shape_from_keyframe(&shape_id, self.time);
|
||||
}
|
||||
|
||||
// Clear the stored IDs
|
||||
self.created_shape_id = None;
|
||||
self.created_object_id = None;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -99,33 +75,28 @@ mod tests {
|
|||
use super::*;
|
||||
use crate::layer::VectorLayer;
|
||||
use crate::shape::ShapeColor;
|
||||
use vello::kurbo::{Circle, Rect, Shape as KurboShape};
|
||||
use vello::kurbo::{Rect, Shape as KurboShape};
|
||||
|
||||
#[test]
|
||||
fn test_add_shape_action_rectangle() {
|
||||
// Create a document with a vector layer
|
||||
let mut document = Document::new("Test");
|
||||
let vector_layer = VectorLayer::new("Layer 1");
|
||||
let layer_id = document.root.add_child(AnyLayer::Vector(vector_layer));
|
||||
|
||||
// Create a rectangle shape
|
||||
let rect = Rect::new(0.0, 0.0, 100.0, 50.0);
|
||||
let path = rect.to_path(0.1);
|
||||
let shape = Shape::new(path).with_fill(ShapeColor::rgb(255, 0, 0));
|
||||
let object = ShapeInstance::new(shape.id).with_position(50.0, 50.0);
|
||||
let shape = Shape::new(path)
|
||||
.with_fill(ShapeColor::rgb(255, 0, 0))
|
||||
.with_position(50.0, 50.0);
|
||||
|
||||
// Create and execute action
|
||||
let mut action = AddShapeAction::new(layer_id, shape, object);
|
||||
let mut action = AddShapeAction::new(layer_id, shape, 0.0);
|
||||
action.execute(&mut document).unwrap();
|
||||
|
||||
// Verify shape and object were added
|
||||
if let Some(AnyLayer::Vector(layer)) = document.get_layer(&layer_id) {
|
||||
assert_eq!(layer.shapes.len(), 1);
|
||||
assert_eq!(layer.shape_instances.len(), 1);
|
||||
|
||||
let added_object = &layer.shape_instances[0];
|
||||
assert_eq!(added_object.transform.x, 50.0);
|
||||
assert_eq!(added_object.transform.y, 50.0);
|
||||
let shapes = layer.shapes_at_time(0.0);
|
||||
assert_eq!(shapes.len(), 1);
|
||||
assert_eq!(shapes[0].transform.x, 50.0);
|
||||
assert_eq!(shapes[0].transform.y, 50.0);
|
||||
} else {
|
||||
panic!("Layer not found or not a vector layer");
|
||||
}
|
||||
|
|
@ -133,91 +104,8 @@ mod tests {
|
|||
// Rollback
|
||||
action.rollback(&mut document).unwrap();
|
||||
|
||||
// Verify shape and object were removed
|
||||
if let Some(AnyLayer::Vector(layer)) = document.get_layer(&layer_id) {
|
||||
assert_eq!(layer.shapes.len(), 0);
|
||||
assert_eq!(layer.shape_instances.len(), 0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_add_shape_action_circle() {
|
||||
let mut document = Document::new("Test");
|
||||
let vector_layer = VectorLayer::new("Layer 1");
|
||||
let layer_id = document.root.add_child(AnyLayer::Vector(vector_layer));
|
||||
|
||||
// Create a circle shape
|
||||
let circle = Circle::new((50.0, 50.0), 25.0);
|
||||
let path = circle.to_path(0.1);
|
||||
let shape = Shape::new(path)
|
||||
.with_fill(ShapeColor::rgb(0, 255, 0));
|
||||
let object = ShapeInstance::new(shape.id);
|
||||
|
||||
let mut action = AddShapeAction::new(layer_id, shape, object);
|
||||
|
||||
// Test description
|
||||
assert_eq!(action.description(), "Add shape");
|
||||
|
||||
// Execute
|
||||
action.execute(&mut document).unwrap();
|
||||
|
||||
if let Some(AnyLayer::Vector(layer)) = document.get_layer(&layer_id) {
|
||||
assert_eq!(layer.shapes.len(), 1);
|
||||
assert_eq!(layer.shape_instances.len(), 1);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_add_shape_action_multiple_execute() {
|
||||
let mut document = Document::new("Test");
|
||||
let vector_layer = VectorLayer::new("Layer 1");
|
||||
let layer_id = document.root.add_child(AnyLayer::Vector(vector_layer));
|
||||
|
||||
let rect = Rect::new(0.0, 0.0, 50.0, 50.0);
|
||||
let path = rect.to_path(0.1);
|
||||
let shape = Shape::new(path);
|
||||
let object = ShapeInstance::new(shape.id);
|
||||
|
||||
let mut action = AddShapeAction::new(layer_id, shape, object);
|
||||
|
||||
// Execute twice - shapes are stored in HashMap (keyed by ID, so same shape overwrites)
|
||||
// while shape_instances are stored in Vec (so duplicates accumulate)
|
||||
action.execute(&mut document).unwrap();
|
||||
action.execute(&mut document).unwrap();
|
||||
|
||||
if let Some(AnyLayer::Vector(layer)) = document.get_layer(&layer_id) {
|
||||
// Shapes use HashMap keyed by shape.id, so same shape overwrites = 1
|
||||
// Shape instances use Vec, so duplicates accumulate = 2
|
||||
assert_eq!(layer.shapes.len(), 1);
|
||||
assert_eq!(layer.shape_instances.len(), 2);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_add_multiple_different_shapes() {
|
||||
let mut document = Document::new("Test");
|
||||
let vector_layer = VectorLayer::new("Layer 1");
|
||||
let layer_id = document.root.add_child(AnyLayer::Vector(vector_layer));
|
||||
|
||||
// Create two different shapes
|
||||
let rect1 = Rect::new(0.0, 0.0, 50.0, 50.0);
|
||||
let shape1 = Shape::new(rect1.to_path(0.1));
|
||||
let object1 = ShapeInstance::new(shape1.id);
|
||||
|
||||
let rect2 = Rect::new(100.0, 100.0, 150.0, 150.0);
|
||||
let shape2 = Shape::new(rect2.to_path(0.1));
|
||||
let object2 = ShapeInstance::new(shape2.id);
|
||||
|
||||
let mut action1 = AddShapeAction::new(layer_id, shape1, object1);
|
||||
let mut action2 = AddShapeAction::new(layer_id, shape2, object2);
|
||||
|
||||
action1.execute(&mut document).unwrap();
|
||||
action2.execute(&mut document).unwrap();
|
||||
|
||||
if let Some(AnyLayer::Vector(layer)) = document.get_layer(&layer_id) {
|
||||
// Two different shapes = 2 entries in HashMap
|
||||
assert_eq!(layer.shapes.len(), 2);
|
||||
assert_eq!(layer.shape_instances.len(), 2);
|
||||
assert_eq!(layer.shapes_at_time(0.0).len(), 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,412 @@
|
|||
//! Group action
|
||||
//!
|
||||
//! Groups selected shapes and/or clip instances into a new VectorClip
|
||||
//! with a ClipInstance placed on the layer. Supports grouping shapes,
|
||||
//! existing clip instances (groups), or a mix of both.
|
||||
|
||||
use crate::action::Action;
|
||||
use crate::animation::{AnimationCurve, AnimationTarget, Keyframe, TransformProperty};
|
||||
use crate::clip::{ClipInstance, VectorClip};
|
||||
use crate::document::Document;
|
||||
use crate::layer::{AnyLayer, VectorLayer};
|
||||
use crate::shape::Shape;
|
||||
use uuid::Uuid;
|
||||
use vello::kurbo::{Rect, Shape as KurboShape};
|
||||
|
||||
/// Action that groups selected shapes and/or clip instances into a VectorClip
|
||||
pub struct GroupAction {
|
||||
/// Layer containing the items to group
|
||||
layer_id: Uuid,
|
||||
|
||||
/// Time of the keyframe to operate on (for shape lookup)
|
||||
time: f64,
|
||||
|
||||
/// Shape IDs to include in the group
|
||||
shape_ids: Vec<Uuid>,
|
||||
|
||||
/// Clip instance IDs to include in the group
|
||||
clip_instance_ids: Vec<Uuid>,
|
||||
|
||||
/// Pre-generated clip instance ID for the new group (so caller can update selection)
|
||||
instance_id: Uuid,
|
||||
|
||||
/// Created clip ID (for rollback)
|
||||
created_clip_id: Option<Uuid>,
|
||||
|
||||
/// Shapes removed from the keyframe (for rollback)
|
||||
removed_shapes: Vec<Shape>,
|
||||
|
||||
/// Clip instances removed from the layer (for rollback, preserving original order)
|
||||
removed_clip_instances: Vec<ClipInstance>,
|
||||
}
|
||||
|
||||
impl GroupAction {
|
||||
pub fn new(
|
||||
layer_id: Uuid,
|
||||
time: f64,
|
||||
shape_ids: Vec<Uuid>,
|
||||
clip_instance_ids: Vec<Uuid>,
|
||||
instance_id: Uuid,
|
||||
) -> Self {
|
||||
Self {
|
||||
layer_id,
|
||||
time,
|
||||
shape_ids,
|
||||
clip_instance_ids,
|
||||
instance_id,
|
||||
created_clip_id: None,
|
||||
removed_shapes: Vec::new(),
|
||||
removed_clip_instances: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Action for GroupAction {
|
||||
fn execute(&mut self, document: &mut Document) -> Result<(), String> {
|
||||
// --- Phase 1: Collect items and compute bounding box ---
|
||||
|
||||
let layer = document
|
||||
.get_layer(&self.layer_id)
|
||||
.ok_or_else(|| format!("Layer {} not found", self.layer_id))?;
|
||||
|
||||
let vl = match layer {
|
||||
AnyLayer::Vector(vl) => vl,
|
||||
_ => return Err("Group is only supported on vector layers".to_string()),
|
||||
};
|
||||
|
||||
// Collect shapes
|
||||
let shapes_at_time = vl.shapes_at_time(self.time);
|
||||
let mut group_shapes: Vec<Shape> = Vec::new();
|
||||
for id in &self.shape_ids {
|
||||
if let Some(shape) = shapes_at_time.iter().find(|s| &s.id == id) {
|
||||
group_shapes.push(shape.clone());
|
||||
}
|
||||
}
|
||||
|
||||
// Collect clip instances
|
||||
let mut group_clip_instances: Vec<ClipInstance> = Vec::new();
|
||||
for id in &self.clip_instance_ids {
|
||||
if let Some(ci) = vl.clip_instances.iter().find(|ci| &ci.id == id) {
|
||||
group_clip_instances.push(ci.clone());
|
||||
}
|
||||
}
|
||||
|
||||
let total_items = group_shapes.len() + group_clip_instances.len();
|
||||
if total_items < 2 {
|
||||
return Err("Need at least 2 items to group".to_string());
|
||||
}
|
||||
|
||||
// Compute combined bounding box in parent (layer) space
|
||||
let mut combined_bbox: Option<Rect> = None;
|
||||
|
||||
// Shape bounding boxes
|
||||
for shape in &group_shapes {
|
||||
let local_bbox = shape.path().bounding_box();
|
||||
let transform = shape.transform.to_affine();
|
||||
let transformed_bbox = transform.transform_rect_bbox(local_bbox);
|
||||
combined_bbox = Some(match combined_bbox {
|
||||
Some(existing) => existing.union(transformed_bbox),
|
||||
None => transformed_bbox,
|
||||
});
|
||||
}
|
||||
|
||||
// Clip instance bounding boxes
|
||||
for ci in &group_clip_instances {
|
||||
let content_bounds = if let Some(vector_clip) = document.get_vector_clip(&ci.clip_id) {
|
||||
let clip_time = ((self.time - ci.timeline_start) * ci.playback_speed) + ci.trim_start;
|
||||
vector_clip.calculate_content_bounds(document, clip_time)
|
||||
} else if let Some(video_clip) = document.get_video_clip(&ci.clip_id) {
|
||||
Rect::new(0.0, 0.0, video_clip.width, video_clip.height)
|
||||
} else {
|
||||
continue;
|
||||
};
|
||||
let ci_transform = ci.transform.to_affine();
|
||||
let transformed_bbox = ci_transform.transform_rect_bbox(content_bounds);
|
||||
combined_bbox = Some(match combined_bbox {
|
||||
Some(existing) => existing.union(transformed_bbox),
|
||||
None => transformed_bbox,
|
||||
});
|
||||
}
|
||||
|
||||
let bbox = combined_bbox.ok_or("Could not compute bounding box")?;
|
||||
let center_x = (bbox.x0 + bbox.x1) / 2.0;
|
||||
let center_y = (bbox.y0 + bbox.y1) / 2.0;
|
||||
|
||||
// --- Phase 2: Build the VectorClip ---
|
||||
|
||||
// Offset shapes so positions are relative to the group center
|
||||
let mut clip_shapes: Vec<Shape> = group_shapes.clone();
|
||||
for shape in &mut clip_shapes {
|
||||
shape.transform.x -= center_x;
|
||||
shape.transform.y -= center_y;
|
||||
}
|
||||
|
||||
// Offset clip instances similarly
|
||||
let mut clip_instances_inside: Vec<ClipInstance> = group_clip_instances.clone();
|
||||
for ci in &mut clip_instances_inside {
|
||||
ci.transform.x -= center_x;
|
||||
ci.transform.y -= center_y;
|
||||
}
|
||||
|
||||
// Create VectorClip — groups are static (one frame), not time-based clips
|
||||
let frame_duration = 1.0 / document.framerate;
|
||||
let mut clip = VectorClip::new("Group", bbox.width(), bbox.height(), frame_duration);
|
||||
clip.is_group = true;
|
||||
let clip_id = clip.id;
|
||||
|
||||
let mut inner_layer = VectorLayer::new("Layer 1");
|
||||
for shape in clip_shapes {
|
||||
inner_layer.add_shape_to_keyframe(shape, 0.0);
|
||||
}
|
||||
for ci in clip_instances_inside {
|
||||
inner_layer.clip_instances.push(ci);
|
||||
}
|
||||
clip.layers.add_root(AnyLayer::Vector(inner_layer));
|
||||
|
||||
// Add clip to document library
|
||||
document.add_vector_clip(clip);
|
||||
self.created_clip_id = Some(clip_id);
|
||||
|
||||
// --- Phase 3: Remove originals from the layer ---
|
||||
|
||||
let layer = document.get_layer_mut(&self.layer_id).unwrap();
|
||||
let vl = match layer {
|
||||
AnyLayer::Vector(vl) => vl,
|
||||
_ => unreachable!(),
|
||||
};
|
||||
|
||||
// Remove shapes
|
||||
self.removed_shapes.clear();
|
||||
for id in &self.shape_ids {
|
||||
if let Some(shape) = vl.remove_shape_from_keyframe(id, self.time) {
|
||||
self.removed_shapes.push(shape);
|
||||
}
|
||||
}
|
||||
|
||||
// Remove clip instances (preserve order for rollback)
|
||||
self.removed_clip_instances.clear();
|
||||
for id in &self.clip_instance_ids {
|
||||
if let Some(pos) = vl.clip_instances.iter().position(|ci| &ci.id == id) {
|
||||
self.removed_clip_instances.push(vl.clip_instances.remove(pos));
|
||||
}
|
||||
}
|
||||
|
||||
// --- Phase 4: Place the new group ClipInstance ---
|
||||
|
||||
let instance = ClipInstance::with_id(self.instance_id, clip_id)
|
||||
.with_position(center_x, center_y)
|
||||
.with_name("Group");
|
||||
vl.clip_instances.push(instance);
|
||||
|
||||
// Register the group in the current keyframe's clip_instance_ids
|
||||
if let Some(kf) = vl.keyframe_at_mut(self.time) {
|
||||
if !kf.clip_instance_ids.contains(&self.instance_id) {
|
||||
kf.clip_instance_ids.push(self.instance_id);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Phase 5: Create default animation curves with initial keyframe ---
|
||||
|
||||
let props_and_values = [
|
||||
(TransformProperty::X, center_x),
|
||||
(TransformProperty::Y, center_y),
|
||||
(TransformProperty::Rotation, 0.0),
|
||||
(TransformProperty::ScaleX, 1.0),
|
||||
(TransformProperty::ScaleY, 1.0),
|
||||
(TransformProperty::SkewX, 0.0),
|
||||
(TransformProperty::SkewY, 0.0),
|
||||
(TransformProperty::Opacity, 1.0),
|
||||
];
|
||||
|
||||
for (prop, value) in props_and_values {
|
||||
let target = AnimationTarget::Object {
|
||||
id: self.instance_id,
|
||||
property: prop,
|
||||
};
|
||||
let mut curve = AnimationCurve::new(target.clone(), value);
|
||||
curve.set_keyframe(Keyframe::linear(0.0, value));
|
||||
vl.layer.animation_data.set_curve(curve);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn rollback(&mut self, document: &mut Document) -> Result<(), String> {
|
||||
let layer = document
|
||||
.get_layer_mut(&self.layer_id)
|
||||
.ok_or_else(|| format!("Layer {} not found", self.layer_id))?;
|
||||
|
||||
if let AnyLayer::Vector(vl) = layer {
|
||||
// Remove animation curves for the group's clip instance
|
||||
for prop in &[
|
||||
TransformProperty::X, TransformProperty::Y,
|
||||
TransformProperty::Rotation,
|
||||
TransformProperty::ScaleX, TransformProperty::ScaleY,
|
||||
TransformProperty::SkewX, TransformProperty::SkewY,
|
||||
TransformProperty::Opacity,
|
||||
] {
|
||||
let target = AnimationTarget::Object {
|
||||
id: self.instance_id,
|
||||
property: *prop,
|
||||
};
|
||||
vl.layer.animation_data.remove_curve(&target);
|
||||
}
|
||||
|
||||
// Remove the group's clip instance
|
||||
vl.clip_instances.retain(|ci| ci.id != self.instance_id);
|
||||
|
||||
// Remove the group ID from the keyframe
|
||||
if let Some(kf) = vl.keyframe_at_mut(self.time) {
|
||||
kf.clip_instance_ids.retain(|id| id != &self.instance_id);
|
||||
}
|
||||
|
||||
// Re-insert removed shapes
|
||||
for shape in self.removed_shapes.drain(..) {
|
||||
vl.add_shape_to_keyframe(shape, self.time);
|
||||
}
|
||||
|
||||
// Re-insert removed clip instances
|
||||
for ci in self.removed_clip_instances.drain(..) {
|
||||
vl.clip_instances.push(ci);
|
||||
}
|
||||
}
|
||||
|
||||
// Remove the VectorClip from the document
|
||||
if let Some(clip_id) = self.created_clip_id.take() {
|
||||
document.remove_vector_clip(&clip_id);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn description(&self) -> String {
|
||||
let count = self.shape_ids.len() + self.clip_instance_ids.len();
|
||||
format!("Group {} objects", count)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::shape::ShapeColor;
|
||||
use vello::kurbo::{Circle, Shape as KurboShape};
|
||||
|
||||
#[test]
|
||||
fn test_group_shapes() {
|
||||
let mut document = Document::new("Test");
|
||||
let mut layer = VectorLayer::new("Test Layer");
|
||||
|
||||
let circle1 = Circle::new((0.0, 0.0), 20.0);
|
||||
let shape1 = Shape::new(circle1.to_path(0.1))
|
||||
.with_fill(ShapeColor::rgb(255, 0, 0))
|
||||
.with_position(50.0, 50.0);
|
||||
let shape1_id = shape1.id;
|
||||
|
||||
let circle2 = Circle::new((0.0, 0.0), 20.0);
|
||||
let shape2 = Shape::new(circle2.to_path(0.1))
|
||||
.with_fill(ShapeColor::rgb(0, 255, 0))
|
||||
.with_position(150.0, 50.0);
|
||||
let shape2_id = shape2.id;
|
||||
|
||||
layer.add_shape_to_keyframe(shape1, 0.0);
|
||||
layer.add_shape_to_keyframe(shape2, 0.0);
|
||||
|
||||
let layer_id = document.root_mut().add_child(AnyLayer::Vector(layer));
|
||||
|
||||
let instance_id = Uuid::new_v4();
|
||||
let mut action = GroupAction::new(
|
||||
layer_id, 0.0,
|
||||
vec![shape1_id, shape2_id],
|
||||
vec![],
|
||||
instance_id,
|
||||
);
|
||||
action.execute(&mut document).unwrap();
|
||||
|
||||
// Shapes removed, clip instance added
|
||||
if let Some(AnyLayer::Vector(vl)) = document.get_layer(&layer_id) {
|
||||
assert_eq!(vl.shapes_at_time(0.0).len(), 0);
|
||||
assert_eq!(vl.clip_instances.len(), 1);
|
||||
assert_eq!(vl.clip_instances[0].id, instance_id);
|
||||
}
|
||||
assert_eq!(document.vector_clips.len(), 1);
|
||||
|
||||
// Rollback
|
||||
action.rollback(&mut document).unwrap();
|
||||
|
||||
if let Some(AnyLayer::Vector(vl)) = document.get_layer(&layer_id) {
|
||||
assert_eq!(vl.shapes_at_time(0.0).len(), 2);
|
||||
assert_eq!(vl.clip_instances.len(), 0);
|
||||
}
|
||||
assert!(document.vector_clips.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_group_mixed_shapes_and_clips() {
|
||||
let mut document = Document::new("Test");
|
||||
let mut layer = VectorLayer::new("Test Layer");
|
||||
|
||||
// Add a shape
|
||||
let circle = Circle::new((0.0, 0.0), 20.0);
|
||||
let shape = Shape::new(circle.to_path(0.1))
|
||||
.with_fill(ShapeColor::rgb(255, 0, 0))
|
||||
.with_position(50.0, 50.0);
|
||||
let shape_id = shape.id;
|
||||
layer.add_shape_to_keyframe(shape, 0.0);
|
||||
|
||||
// Add a clip instance (create a clip for it first)
|
||||
let mut inner_clip = VectorClip::new("Inner", 40.0, 40.0, 1.0);
|
||||
let inner_clip_id = inner_clip.id;
|
||||
let mut inner_layer = VectorLayer::new("Inner Layer");
|
||||
let inner_shape = Shape::new(Circle::new((20.0, 20.0), 15.0).to_path(0.1))
|
||||
.with_fill(ShapeColor::rgb(0, 0, 255));
|
||||
inner_layer.add_shape_to_keyframe(inner_shape, 0.0);
|
||||
inner_clip.layers.add_root(AnyLayer::Vector(inner_layer));
|
||||
document.add_vector_clip(inner_clip);
|
||||
|
||||
let ci = ClipInstance::new(inner_clip_id).with_position(150.0, 50.0);
|
||||
let ci_id = ci.id;
|
||||
layer.clip_instances.push(ci);
|
||||
|
||||
let layer_id = document.root_mut().add_child(AnyLayer::Vector(layer));
|
||||
|
||||
let instance_id = Uuid::new_v4();
|
||||
let mut action = GroupAction::new(
|
||||
layer_id, 0.0,
|
||||
vec![shape_id],
|
||||
vec![ci_id],
|
||||
instance_id,
|
||||
);
|
||||
action.execute(&mut document).unwrap();
|
||||
|
||||
if let Some(AnyLayer::Vector(vl)) = document.get_layer(&layer_id) {
|
||||
assert_eq!(vl.shapes_at_time(0.0).len(), 0);
|
||||
// Only the new group instance remains (the inner clip instance was grouped)
|
||||
assert_eq!(vl.clip_instances.len(), 1);
|
||||
assert_eq!(vl.clip_instances[0].id, instance_id);
|
||||
}
|
||||
// Two vector clips: the inner one + the new group
|
||||
assert_eq!(document.vector_clips.len(), 2);
|
||||
|
||||
// Rollback
|
||||
action.rollback(&mut document).unwrap();
|
||||
|
||||
if let Some(AnyLayer::Vector(vl)) = document.get_layer(&layer_id) {
|
||||
assert_eq!(vl.shapes_at_time(0.0).len(), 1);
|
||||
assert_eq!(vl.clip_instances.len(), 1);
|
||||
assert_eq!(vl.clip_instances[0].id, ci_id);
|
||||
}
|
||||
// Only the inner clip remains
|
||||
assert_eq!(document.vector_clips.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_group_description() {
|
||||
let action = GroupAction::new(
|
||||
Uuid::new_v4(), 0.0,
|
||||
vec![Uuid::new_v4(), Uuid::new_v4()],
|
||||
vec![Uuid::new_v4()],
|
||||
Uuid::new_v4(),
|
||||
);
|
||||
assert_eq!(action.description(), "Group 3 objects");
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,157 @@
|
|||
//! Loop clip instances action
|
||||
//!
|
||||
//! Handles extending clip instances beyond their content duration to enable looping,
|
||||
//! by setting timeline_duration and/or loop_before on the ClipInstance.
|
||||
|
||||
use crate::action::Action;
|
||||
use crate::document::Document;
|
||||
use crate::layer::AnyLayer;
|
||||
use std::collections::HashMap;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Per-instance loop change: (instance_id, old_timeline_duration, new_timeline_duration, old_loop_before, new_loop_before)
|
||||
pub type LoopEntry = (Uuid, Option<f64>, Option<f64>, Option<f64>, Option<f64>);
|
||||
|
||||
/// Action that changes the loop duration of clip instances
|
||||
pub struct LoopClipInstancesAction {
|
||||
/// Map of layer IDs to vectors of loop entries
|
||||
layer_loops: HashMap<Uuid, Vec<LoopEntry>>,
|
||||
}
|
||||
|
||||
impl LoopClipInstancesAction {
|
||||
pub fn new(layer_loops: HashMap<Uuid, Vec<LoopEntry>>) -> Self {
|
||||
Self { layer_loops }
|
||||
}
|
||||
}
|
||||
|
||||
impl Action for LoopClipInstancesAction {
|
||||
fn execute(&mut self, document: &mut Document) -> Result<(), String> {
|
||||
for (layer_id, loops) in &self.layer_loops {
|
||||
let layer = document.get_layer_mut(layer_id)
|
||||
.ok_or_else(|| format!("Layer {} not found", layer_id))?;
|
||||
|
||||
let clip_instances = match layer {
|
||||
AnyLayer::Vector(vl) => &mut vl.clip_instances,
|
||||
AnyLayer::Audio(al) => &mut al.clip_instances,
|
||||
AnyLayer::Video(vl) => &mut vl.clip_instances,
|
||||
AnyLayer::Effect(el) => &mut el.clip_instances,
|
||||
};
|
||||
|
||||
for (instance_id, _old_dur, new_dur, _old_lb, new_lb) in loops {
|
||||
if let Some(instance) = clip_instances.iter_mut().find(|ci| ci.id == *instance_id) {
|
||||
instance.timeline_duration = *new_dur;
|
||||
instance.loop_before = *new_lb;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn rollback(&mut self, document: &mut Document) -> Result<(), String> {
|
||||
for (layer_id, loops) in &self.layer_loops {
|
||||
let layer = document.get_layer_mut(layer_id)
|
||||
.ok_or_else(|| format!("Layer {} not found", layer_id))?;
|
||||
|
||||
let clip_instances = match layer {
|
||||
AnyLayer::Vector(vl) => &mut vl.clip_instances,
|
||||
AnyLayer::Audio(al) => &mut al.clip_instances,
|
||||
AnyLayer::Video(vl) => &mut vl.clip_instances,
|
||||
AnyLayer::Effect(el) => &mut el.clip_instances,
|
||||
};
|
||||
|
||||
for (instance_id, old_dur, _new_dur, old_lb, _new_lb) in loops {
|
||||
if let Some(instance) = clip_instances.iter_mut().find(|ci| ci.id == *instance_id) {
|
||||
instance.timeline_duration = *old_dur;
|
||||
instance.loop_before = *old_lb;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn execute_backend(&mut self, backend: &mut crate::action::BackendContext, document: &Document) -> Result<(), String> {
|
||||
self.sync_backend(backend, document, false)
|
||||
}
|
||||
|
||||
fn rollback_backend(&mut self, backend: &mut crate::action::BackendContext, document: &Document) -> Result<(), String> {
|
||||
self.sync_backend(backend, document, true)
|
||||
}
|
||||
|
||||
fn description(&self) -> String {
|
||||
"Loop clip".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
impl LoopClipInstancesAction {
|
||||
fn sync_backend(&self, backend: &mut crate::action::BackendContext, document: &Document, rollback: bool) -> Result<(), String> {
|
||||
use crate::clip::AudioClipType;
|
||||
|
||||
let controller = match backend.audio_controller.as_mut() {
|
||||
Some(c) => c,
|
||||
None => return Ok(()),
|
||||
};
|
||||
|
||||
for (layer_id, loops) in &self.layer_loops {
|
||||
let layer = document.get_layer(layer_id)
|
||||
.ok_or_else(|| format!("Layer {} not found", layer_id))?;
|
||||
|
||||
if !matches!(layer, AnyLayer::Audio(_)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let track_id = backend.layer_to_track_map.get(layer_id)
|
||||
.ok_or_else(|| format!("Layer {} not mapped to backend track", layer_id))?;
|
||||
|
||||
let clip_instances = match layer {
|
||||
AnyLayer::Audio(al) => &al.clip_instances,
|
||||
_ => continue,
|
||||
};
|
||||
|
||||
for (instance_id, old_dur, new_dur, old_lb, new_lb) in loops {
|
||||
let instance = clip_instances.iter()
|
||||
.find(|ci| ci.id == *instance_id)
|
||||
.ok_or_else(|| format!("Clip instance {} not found", instance_id))?;
|
||||
|
||||
let clip = document.get_audio_clip(&instance.clip_id)
|
||||
.ok_or_else(|| format!("Audio clip {} not found", instance.clip_id))?;
|
||||
|
||||
let (target_duration, target_loop_before) = if rollback {
|
||||
(old_dur, old_lb)
|
||||
} else {
|
||||
(new_dur, new_lb)
|
||||
};
|
||||
|
||||
let content_window = {
|
||||
let trim_end = instance.trim_end.unwrap_or(clip.duration);
|
||||
(trim_end - instance.trim_start).max(0.0)
|
||||
};
|
||||
let right_duration = target_duration.unwrap_or(content_window);
|
||||
let left_duration = target_loop_before.unwrap_or(0.0);
|
||||
let external_duration = left_duration + right_duration;
|
||||
let external_start = instance.timeline_start - left_duration;
|
||||
|
||||
let get_backend_clip_id = |inst_id: &Uuid| -> Result<u32, String> {
|
||||
match &clip.clip_type {
|
||||
AudioClipType::Midi { midi_clip_id } => Ok(*midi_clip_id),
|
||||
AudioClipType::Sampled { .. } => {
|
||||
let backend_id = backend.clip_instance_to_backend_map.get(inst_id)
|
||||
.ok_or_else(|| format!("Clip instance {} not mapped to backend", inst_id))?;
|
||||
match backend_id {
|
||||
crate::action::BackendClipInstanceId::Audio(audio_id) => Ok(*audio_id),
|
||||
_ => Err("Expected audio instance ID for sampled clip".to_string()),
|
||||
}
|
||||
}
|
||||
AudioClipType::Recording => Err("Cannot sync recording clip".to_string()),
|
||||
}
|
||||
};
|
||||
|
||||
if let Ok(backend_clip_id) = get_backend_clip_id(instance_id) {
|
||||
controller.move_clip(*track_id, backend_clip_id, external_start);
|
||||
controller.extend_clip(*track_id, backend_clip_id, external_duration);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
|
@ -25,8 +25,11 @@ pub mod rename_folder;
|
|||
pub mod delete_folder;
|
||||
pub mod move_asset_to_folder;
|
||||
pub mod update_midi_notes;
|
||||
pub mod loop_clip_instances;
|
||||
pub mod remove_clip_instances;
|
||||
pub mod remove_shapes;
|
||||
pub mod set_keyframe;
|
||||
pub mod group_shapes;
|
||||
|
||||
pub use add_clip_instance::AddClipInstanceAction;
|
||||
pub use add_effect::AddEffectAction;
|
||||
|
|
@ -50,5 +53,8 @@ pub use rename_folder::RenameFolderAction;
|
|||
pub use delete_folder::{DeleteFolderAction, DeleteStrategy};
|
||||
pub use move_asset_to_folder::MoveAssetToFolderAction;
|
||||
pub use update_midi_notes::UpdateMidiNotesAction;
|
||||
pub use loop_clip_instances::LoopClipInstancesAction;
|
||||
pub use remove_clip_instances::RemoveClipInstancesAction;
|
||||
pub use remove_shapes::RemoveShapesAction;
|
||||
pub use set_keyframe::SetKeyframeAction;
|
||||
pub use group_shapes::GroupAction;
|
||||
|
|
|
|||
|
|
@ -20,6 +20,9 @@ pub struct ModifyShapePathAction {
|
|||
/// Shape to modify
|
||||
shape_id: Uuid,
|
||||
|
||||
/// Time of the keyframe containing the shape
|
||||
time: f64,
|
||||
|
||||
/// The version index being modified (for shapes with multiple versions)
|
||||
version_index: usize,
|
||||
|
||||
|
|
@ -32,17 +35,11 @@ pub struct ModifyShapePathAction {
|
|||
|
||||
impl ModifyShapePathAction {
|
||||
/// Create a new action to modify a shape's path
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `layer_id` - The layer containing the shape
|
||||
/// * `shape_id` - The shape to modify
|
||||
/// * `version_index` - The version index to modify (usually 0)
|
||||
/// * `new_path` - The new path to set
|
||||
pub fn new(layer_id: Uuid, shape_id: Uuid, version_index: usize, new_path: BezPath) -> Self {
|
||||
pub fn new(layer_id: Uuid, shape_id: Uuid, time: f64, version_index: usize, new_path: BezPath) -> Self {
|
||||
Self {
|
||||
layer_id,
|
||||
shape_id,
|
||||
time,
|
||||
version_index,
|
||||
new_path,
|
||||
old_path: None,
|
||||
|
|
@ -53,6 +50,7 @@ impl ModifyShapePathAction {
|
|||
pub fn with_old_path(
|
||||
layer_id: Uuid,
|
||||
shape_id: Uuid,
|
||||
time: f64,
|
||||
version_index: usize,
|
||||
old_path: BezPath,
|
||||
new_path: BezPath,
|
||||
|
|
@ -60,6 +58,7 @@ impl ModifyShapePathAction {
|
|||
Self {
|
||||
layer_id,
|
||||
shape_id,
|
||||
time,
|
||||
version_index,
|
||||
new_path,
|
||||
old_path: Some(old_path),
|
||||
|
|
@ -71,8 +70,7 @@ impl Action for ModifyShapePathAction {
|
|||
fn execute(&mut self, document: &mut Document) -> Result<(), String> {
|
||||
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) {
|
||||
// Check if version exists
|
||||
if let Some(shape) = vector_layer.get_shape_in_keyframe_mut(&self.shape_id, self.time) {
|
||||
if self.version_index >= shape.versions.len() {
|
||||
return Err(format!(
|
||||
"Version index {} out of bounds (shape has {} versions)",
|
||||
|
|
@ -104,7 +102,7 @@ impl Action for ModifyShapePathAction {
|
|||
if let Some(old_path) = &self.old_path {
|
||||
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) {
|
||||
if let Some(shape) = vector_layer.get_shape_in_keyframe_mut(&self.shape_id, self.time) {
|
||||
if self.version_index < shape.versions.len() {
|
||||
shape.versions[self.version_index].path = old_path.clone();
|
||||
return Ok(());
|
||||
|
|
@ -130,6 +128,7 @@ mod tests {
|
|||
use super::*;
|
||||
use crate::layer::VectorLayer;
|
||||
use crate::shape::Shape;
|
||||
use vello::kurbo::Shape as KurboShape;
|
||||
|
||||
fn create_test_path() -> BezPath {
|
||||
let mut path = BezPath::new();
|
||||
|
|
@ -144,9 +143,9 @@ mod tests {
|
|||
fn create_modified_path() -> BezPath {
|
||||
let mut path = BezPath::new();
|
||||
path.move_to((0.0, 0.0));
|
||||
path.line_to((150.0, 0.0)); // Modified
|
||||
path.line_to((150.0, 150.0)); // Modified
|
||||
path.line_to((0.0, 150.0)); // Modified
|
||||
path.line_to((150.0, 0.0));
|
||||
path.line_to((150.0, 150.0));
|
||||
path.line_to((0.0, 150.0));
|
||||
path.close_path();
|
||||
path
|
||||
}
|
||||
|
|
@ -158,13 +157,13 @@ mod tests {
|
|||
|
||||
let shape = Shape::new(create_test_path());
|
||||
let shape_id = shape.id;
|
||||
layer.shapes.insert(shape_id, shape);
|
||||
layer.add_shape_to_keyframe(shape, 0.0);
|
||||
|
||||
let layer_id = document.root_mut().add_child(AnyLayer::Vector(layer));
|
||||
|
||||
// Verify initial path
|
||||
if let Some(AnyLayer::Vector(vl)) = document.get_layer_mut(&layer_id) {
|
||||
let shape = vl.shapes.get(&shape_id).unwrap();
|
||||
if let Some(AnyLayer::Vector(vl)) = document.get_layer(&layer_id) {
|
||||
let shape = vl.get_shape_in_keyframe(&shape_id, 0.0).unwrap();
|
||||
let bbox = shape.versions[0].path.bounding_box();
|
||||
assert_eq!(bbox.width(), 100.0);
|
||||
assert_eq!(bbox.height(), 100.0);
|
||||
|
|
@ -172,12 +171,12 @@ mod tests {
|
|||
|
||||
// Create and execute action
|
||||
let new_path = create_modified_path();
|
||||
let mut action = ModifyShapePathAction::new(layer_id, shape_id, 0, new_path);
|
||||
let mut action = ModifyShapePathAction::new(layer_id, shape_id, 0.0, 0, new_path);
|
||||
action.execute(&mut document).unwrap();
|
||||
|
||||
// Verify path changed
|
||||
if let Some(AnyLayer::Vector(vl)) = document.get_layer_mut(&layer_id) {
|
||||
let shape = vl.shapes.get(&shape_id).unwrap();
|
||||
if let Some(AnyLayer::Vector(vl)) = document.get_layer(&layer_id) {
|
||||
let shape = vl.get_shape_in_keyframe(&shape_id, 0.0).unwrap();
|
||||
let bbox = shape.versions[0].path.bounding_box();
|
||||
assert_eq!(bbox.width(), 150.0);
|
||||
assert_eq!(bbox.height(), 150.0);
|
||||
|
|
@ -187,8 +186,8 @@ mod tests {
|
|||
action.rollback(&mut document).unwrap();
|
||||
|
||||
// Verify restored
|
||||
if let Some(AnyLayer::Vector(vl)) = document.get_layer_mut(&layer_id) {
|
||||
let shape = vl.shapes.get(&shape_id).unwrap();
|
||||
if let Some(AnyLayer::Vector(vl)) = document.get_layer(&layer_id) {
|
||||
let shape = vl.get_shape_in_keyframe(&shape_id, 0.0).unwrap();
|
||||
let bbox = shape.versions[0].path.bounding_box();
|
||||
assert_eq!(bbox.width(), 100.0);
|
||||
assert_eq!(bbox.height(), 100.0);
|
||||
|
|
@ -202,13 +201,12 @@ mod tests {
|
|||
|
||||
let shape = Shape::new(create_test_path());
|
||||
let shape_id = shape.id;
|
||||
layer.shapes.insert(shape_id, shape);
|
||||
layer.add_shape_to_keyframe(shape, 0.0);
|
||||
|
||||
let layer_id = document.root_mut().add_child(AnyLayer::Vector(layer));
|
||||
|
||||
// Try to modify non-existent version
|
||||
let new_path = create_modified_path();
|
||||
let mut action = ModifyShapePathAction::new(layer_id, shape_id, 5, new_path);
|
||||
let mut action = ModifyShapePathAction::new(layer_id, shape_id, 0.0, 5, new_path);
|
||||
let result = action.execute(&mut document);
|
||||
|
||||
assert!(result.is_err());
|
||||
|
|
@ -219,7 +217,7 @@ mod tests {
|
|||
fn test_description() {
|
||||
let layer_id = Uuid::new_v4();
|
||||
let shape_id = Uuid::new_v4();
|
||||
let action = ModifyShapePathAction::new(layer_id, shape_id, 0, create_test_path());
|
||||
let action = ModifyShapePathAction::new(layer_id, shape_id, 0.0, 0, create_test_path());
|
||||
assert_eq!(action.description(), "Modify shape path");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
//! Move shape instances action
|
||||
//! Move shapes action
|
||||
//!
|
||||
//! Handles moving one or more shape instances to new positions.
|
||||
//! Handles moving one or more shapes to new positions within a keyframe.
|
||||
|
||||
use crate::action::Action;
|
||||
use crate::document::Document;
|
||||
|
|
@ -9,26 +9,20 @@ use std::collections::HashMap;
|
|||
use uuid::Uuid;
|
||||
use vello::kurbo::Point;
|
||||
|
||||
/// Action that moves shape instances to new positions
|
||||
/// Action that moves shapes to new positions within a keyframe
|
||||
pub struct MoveShapeInstancesAction {
|
||||
/// Layer ID containing the shape instances
|
||||
layer_id: Uuid,
|
||||
|
||||
/// Map of object IDs to their old and new positions
|
||||
shape_instance_positions: HashMap<Uuid, (Point, Point)>, // (old_pos, new_pos)
|
||||
time: f64,
|
||||
/// Map of shape IDs to their old and new positions
|
||||
shape_positions: HashMap<Uuid, (Point, Point)>,
|
||||
}
|
||||
|
||||
impl MoveShapeInstancesAction {
|
||||
/// Create a new move shape instances action
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `layer_id` - The layer containing the shape instances
|
||||
/// * `shape_instance_positions` - Map of object IDs to (old_position, new_position)
|
||||
pub fn new(layer_id: Uuid, shape_instance_positions: HashMap<Uuid, (Point, Point)>) -> Self {
|
||||
pub fn new(layer_id: Uuid, time: f64, shape_positions: HashMap<Uuid, (Point, Point)>) -> Self {
|
||||
Self {
|
||||
layer_id,
|
||||
shape_instance_positions,
|
||||
time,
|
||||
shape_positions,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -41,11 +35,11 @@ impl Action for MoveShapeInstancesAction {
|
|||
};
|
||||
|
||||
if let AnyLayer::Vector(vector_layer) = layer {
|
||||
for (shape_instance_id, (_old, new)) in &self.shape_instance_positions {
|
||||
vector_layer.modify_object_internal(shape_instance_id, |obj| {
|
||||
obj.transform.x = new.x;
|
||||
obj.transform.y = new.y;
|
||||
});
|
||||
for (shape_id, (_old, new)) in &self.shape_positions {
|
||||
if let Some(shape) = vector_layer.get_shape_in_keyframe_mut(shape_id, self.time) {
|
||||
shape.transform.x = new.x;
|
||||
shape.transform.y = new.y;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
|
|
@ -58,76 +52,22 @@ impl Action for MoveShapeInstancesAction {
|
|||
};
|
||||
|
||||
if let AnyLayer::Vector(vector_layer) = layer {
|
||||
for (shape_instance_id, (old, _new)) in &self.shape_instance_positions {
|
||||
vector_layer.modify_object_internal(shape_instance_id, |obj| {
|
||||
obj.transform.x = old.x;
|
||||
obj.transform.y = old.y;
|
||||
});
|
||||
for (shape_id, (old, _new)) in &self.shape_positions {
|
||||
if let Some(shape) = vector_layer.get_shape_in_keyframe_mut(shape_id, self.time) {
|
||||
shape.transform.x = old.x;
|
||||
shape.transform.y = old.y;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn description(&self) -> String {
|
||||
let count = self.shape_instance_positions.len();
|
||||
let count = self.shape_positions.len();
|
||||
if count == 1 {
|
||||
"Move shape instance".to_string()
|
||||
"Move shape".to_string()
|
||||
} else {
|
||||
format!("Move {} shape instances", count)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::layer::VectorLayer;
|
||||
use crate::object::ShapeInstance;
|
||||
use crate::shape::Shape;
|
||||
use vello::kurbo::{Circle, Shape as KurboShape};
|
||||
|
||||
#[test]
|
||||
fn test_move_shape_instances_action() {
|
||||
// Create a document with a test object
|
||||
let mut document = Document::new("Test");
|
||||
|
||||
let circle = Circle::new((100.0, 100.0), 50.0);
|
||||
let path = circle.to_path(0.1);
|
||||
let shape = Shape::new(path);
|
||||
let object = ShapeInstance::new(shape.id).with_position(50.0, 50.0);
|
||||
|
||||
let mut vector_layer = VectorLayer::new("Layer 1");
|
||||
vector_layer.add_shape(shape);
|
||||
let shape_instance_id = vector_layer.add_object(object);
|
||||
let layer_id = document.root.add_child(AnyLayer::Vector(vector_layer));
|
||||
|
||||
// Create move action
|
||||
let mut positions = HashMap::new();
|
||||
positions.insert(
|
||||
shape_instance_id,
|
||||
(Point::new(50.0, 50.0), Point::new(150.0, 200.0))
|
||||
);
|
||||
|
||||
let mut action = MoveShapeInstancesAction::new(layer_id, positions);
|
||||
|
||||
// Execute
|
||||
action.execute(&mut document).unwrap();
|
||||
|
||||
// Verify position changed
|
||||
if let Some(AnyLayer::Vector(layer)) = document.get_layer(&layer_id) {
|
||||
let obj = layer.get_object(&shape_instance_id).unwrap();
|
||||
assert_eq!(obj.transform.x, 150.0);
|
||||
assert_eq!(obj.transform.y, 200.0);
|
||||
}
|
||||
|
||||
// Rollback
|
||||
action.rollback(&mut document).unwrap();
|
||||
|
||||
// Verify position restored
|
||||
if let Some(AnyLayer::Vector(layer)) = document.get_layer(&layer_id) {
|
||||
let obj = layer.get_object(&shape_instance_id).unwrap();
|
||||
assert_eq!(obj.transform.x, 50.0);
|
||||
assert_eq!(obj.transform.y, 50.0);
|
||||
format!("Move {} shapes", count)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ use crate::curve_segment::CurveSegment;
|
|||
use crate::document::Document;
|
||||
use crate::gap_handling::GapHandlingMode;
|
||||
use crate::layer::AnyLayer;
|
||||
use crate::object::ShapeInstance;
|
||||
use crate::planar_graph::PlanarGraph;
|
||||
use crate::shape::ShapeColor;
|
||||
use uuid::Uuid;
|
||||
|
|
@ -19,6 +18,9 @@ pub struct PaintBucketAction {
|
|||
/// Layer ID to add the filled shape to
|
||||
layer_id: Uuid,
|
||||
|
||||
/// Time of the keyframe to operate on
|
||||
time: f64,
|
||||
|
||||
/// Click point where fill was initiated
|
||||
click_point: Point,
|
||||
|
||||
|
|
@ -33,23 +35,13 @@ pub struct PaintBucketAction {
|
|||
|
||||
/// ID of the created shape (set after execution)
|
||||
created_shape_id: Option<Uuid>,
|
||||
|
||||
/// ID of the created shape instance (set after execution)
|
||||
created_shape_instance_id: Option<Uuid>,
|
||||
}
|
||||
|
||||
impl PaintBucketAction {
|
||||
/// Create a new paint bucket action
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `layer_id` - The layer to add the filled shape to
|
||||
/// * `click_point` - Point where the user clicked to initiate fill
|
||||
/// * `fill_color` - Color to fill the region with
|
||||
/// * `tolerance` - Gap tolerance in pixels (default: 2.0)
|
||||
/// * `gap_mode` - Gap handling mode (SnapAndSplit or BridgeSegment)
|
||||
pub fn new(
|
||||
layer_id: Uuid,
|
||||
time: f64,
|
||||
click_point: Point,
|
||||
fill_color: ShapeColor,
|
||||
tolerance: f64,
|
||||
|
|
@ -57,12 +49,12 @@ impl PaintBucketAction {
|
|||
) -> Self {
|
||||
Self {
|
||||
layer_id,
|
||||
time,
|
||||
click_point,
|
||||
fill_color,
|
||||
_tolerance: tolerance,
|
||||
_gap_mode: gap_mode,
|
||||
created_shape_id: None,
|
||||
created_shape_instance_id: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -72,60 +64,46 @@ impl Action for PaintBucketAction {
|
|||
println!("=== PaintBucketAction::execute ===");
|
||||
|
||||
// Optimization: Check if we're clicking on an existing shape first
|
||||
// This is much faster than building a planar graph
|
||||
if let Some(AnyLayer::Vector(vector_layer)) = document.get_layer_mut(&self.layer_id) {
|
||||
// Iterate through shape instances in reverse order (topmost first)
|
||||
for shape_instance in vector_layer.shape_instances.iter().rev() {
|
||||
// Find the corresponding shape (O(1) HashMap lookup)
|
||||
if let Some(shape) = vector_layer.shapes.get(&shape_instance.shape_id) {
|
||||
// Skip shapes without fill color (e.g., lines with only stroke)
|
||||
if shape.fill_color.is_none() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if the path is closed - winding number only makes sense for closed paths
|
||||
use vello::kurbo::PathEl;
|
||||
let is_closed = shape.path().elements().iter().any(|el| matches!(el, PathEl::ClosePath));
|
||||
|
||||
if !is_closed {
|
||||
// Skip non-closed paths - can't use winding number test
|
||||
continue;
|
||||
}
|
||||
|
||||
// Apply the shape instance's transform to get the transformed path
|
||||
let transform_affine = shape_instance.transform.to_affine();
|
||||
|
||||
// Transform the click point to shape's local coordinates (inverse transform)
|
||||
let inverse_transform = transform_affine.inverse();
|
||||
let local_point = inverse_transform * self.click_point;
|
||||
|
||||
// Test if the local point is inside the shape using winding number
|
||||
use vello::kurbo::Shape as KurboShape;
|
||||
let winding = shape.path().winding(local_point);
|
||||
|
||||
if winding != 0 {
|
||||
// Point is inside this shape! Just change its fill color
|
||||
println!("Clicked on existing shape, changing fill color");
|
||||
|
||||
// Store the shape ID before the immutable borrow ends
|
||||
let shape_id = shape.id;
|
||||
|
||||
// Find mutable reference to the shape and update its fill (O(1) HashMap lookup)
|
||||
if let Some(shape_mut) = vector_layer.shapes.get_mut(&shape_id) {
|
||||
shape_mut.fill_color = Some(self.fill_color);
|
||||
println!("Updated shape fill color");
|
||||
}
|
||||
|
||||
return Ok(()); // Done! No need to create a new shape
|
||||
// Iterate through shapes in the keyframe in reverse order (topmost first)
|
||||
let shapes = vector_layer.shapes_at_time(self.time);
|
||||
for shape in shapes.iter().rev() {
|
||||
// Skip shapes without fill color
|
||||
if shape.fill_color.is_none() {
|
||||
continue;
|
||||
}
|
||||
|
||||
use vello::kurbo::PathEl;
|
||||
let is_closed = shape.path().elements().iter().any(|el| matches!(el, PathEl::ClosePath));
|
||||
if !is_closed {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Apply the shape's transform
|
||||
let transform_affine = shape.transform.to_affine();
|
||||
let inverse_transform = transform_affine.inverse();
|
||||
let local_point = inverse_transform * self.click_point;
|
||||
|
||||
use vello::kurbo::Shape as KurboShape;
|
||||
let winding = shape.path().winding(local_point);
|
||||
|
||||
if winding != 0 {
|
||||
println!("Clicked on existing shape, changing fill color");
|
||||
let shape_id = shape.id;
|
||||
|
||||
// Now get mutable access to change the fill
|
||||
if let Some(shape_mut) = vector_layer.get_shape_in_keyframe_mut(&shape_id, self.time) {
|
||||
shape_mut.fill_color = Some(self.fill_color);
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
println!("No existing shape at click point, creating new fill region");
|
||||
}
|
||||
|
||||
// Step 1: Extract curves from all shapes (rectangles, ellipses, paths, etc.)
|
||||
let all_curves = extract_curves_from_all_shapes(document, &self.layer_id);
|
||||
// Step 1: Extract curves from all shapes in the keyframe
|
||||
let all_curves = extract_curves_from_keyframe(document, &self.layer_id, self.time);
|
||||
|
||||
println!("Extracted {} curves from all shapes", all_curves.len());
|
||||
|
||||
|
|
@ -138,62 +116,36 @@ impl Action for PaintBucketAction {
|
|||
println!("Building planar graph...");
|
||||
let graph = PlanarGraph::build(&all_curves);
|
||||
|
||||
// Step 3: Trace the face containing the click point (optimized - only traces one face)
|
||||
// Step 3: Trace the face containing the click point
|
||||
println!("Tracing face from click point {:?}...", self.click_point);
|
||||
if let Some(face) = graph.trace_face_from_point(self.click_point) {
|
||||
println!("Successfully traced face containing click point!");
|
||||
|
||||
// Build the face boundary using actual curve segments
|
||||
let face_path = graph.build_face_path(&face);
|
||||
|
||||
println!("DEBUG: Creating face shape with fill color: r={}, g={}, b={}, a={}",
|
||||
self.fill_color.r, self.fill_color.g, self.fill_color.b, self.fill_color.a);
|
||||
|
||||
let face_shape = crate::shape::Shape::new(face_path)
|
||||
.with_fill(self.fill_color); // Use the requested fill color
|
||||
.with_fill(self.fill_color);
|
||||
|
||||
println!("DEBUG: Face shape created with fill_color: {:?}", face_shape.fill_color);
|
||||
|
||||
let face_shape_instance = ShapeInstance::new(face_shape.id);
|
||||
|
||||
// Store the created IDs for rollback
|
||||
self.created_shape_id = Some(face_shape.id);
|
||||
self.created_shape_instance_id = Some(face_shape_instance.id);
|
||||
|
||||
if let Some(AnyLayer::Vector(vector_layer)) = document.get_layer_mut(&self.layer_id) {
|
||||
let shape_id_for_debug = face_shape.id;
|
||||
vector_layer.add_shape_internal(face_shape);
|
||||
vector_layer.add_object_internal(face_shape_instance);
|
||||
println!("DEBUG: Added filled shape");
|
||||
|
||||
// Verify the shape still has the fill color after being added (O(1) HashMap lookup)
|
||||
if let Some(added_shape) = vector_layer.shapes.get(&shape_id_for_debug) {
|
||||
println!("DEBUG: After adding to layer, shape fill_color = {:?}", added_shape.fill_color);
|
||||
}
|
||||
vector_layer.add_shape_to_keyframe(face_shape, self.time);
|
||||
println!("DEBUG: Added filled shape to keyframe");
|
||||
}
|
||||
} else {
|
||||
println!("Click point is not inside any face!");
|
||||
}
|
||||
|
||||
println!("=== Paint Bucket Complete: Face filled with curves ===");
|
||||
println!("=== Paint Bucket Complete ===");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn rollback(&mut self, document: &mut Document) -> Result<(), String> {
|
||||
// Remove the created shape and object if they exist
|
||||
if let (Some(shape_id), Some(object_id)) = (self.created_shape_id, self.created_shape_instance_id) {
|
||||
let layer = match document.get_layer_mut(&self.layer_id) {
|
||||
Some(l) => l,
|
||||
None => return Ok(()),
|
||||
};
|
||||
|
||||
if let AnyLayer::Vector(vector_layer) = layer {
|
||||
vector_layer.remove_object_internal(&object_id);
|
||||
vector_layer.remove_shape_internal(&shape_id);
|
||||
if let Some(shape_id) = self.created_shape_id {
|
||||
if let Some(AnyLayer::Vector(vector_layer)) = document.get_layer_mut(&self.layer_id) {
|
||||
vector_layer.remove_shape_from_keyframe(&shape_id, self.time);
|
||||
}
|
||||
|
||||
self.created_shape_id = None;
|
||||
self.created_shape_instance_id = None;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -203,54 +155,39 @@ impl Action for PaintBucketAction {
|
|||
}
|
||||
}
|
||||
|
||||
/// Extract curves from all shapes in the layer
|
||||
///
|
||||
/// Includes rectangles, ellipses, paths, and even previous paint bucket fills.
|
||||
/// The planar graph builder will handle deduplication of overlapping edges.
|
||||
fn extract_curves_from_all_shapes(
|
||||
/// Extract curves from all shapes in the keyframe at the given time
|
||||
fn extract_curves_from_keyframe(
|
||||
document: &Document,
|
||||
layer_id: &Uuid,
|
||||
time: f64,
|
||||
) -> Vec<CurveSegment> {
|
||||
let mut all_curves = Vec::new();
|
||||
|
||||
// Get the specified layer
|
||||
let layer = match document.get_layer(layer_id) {
|
||||
Some(l) => l,
|
||||
None => return all_curves,
|
||||
};
|
||||
|
||||
// Extract curves only from this vector layer
|
||||
if let AnyLayer::Vector(vector_layer) = layer {
|
||||
println!("Extracting curves from {} objects in layer", vector_layer.shape_instances.len());
|
||||
// Extract curves from each object (which applies transforms to shapes)
|
||||
for (obj_idx, object) in vector_layer.shape_instances.iter().enumerate() {
|
||||
// Find the shape for this object (O(1) HashMap lookup)
|
||||
let shape = match vector_layer.shapes.get(&object.shape_id) {
|
||||
Some(s) => s,
|
||||
None => continue,
|
||||
};
|
||||
let shapes = vector_layer.shapes_at_time(time);
|
||||
println!("Extracting curves from {} shapes in keyframe", shapes.len());
|
||||
|
||||
// Include all shapes - planar graph will handle deduplication
|
||||
// (Rectangles, ellipses, paths, and even previous paint bucket fills)
|
||||
|
||||
// Get the transform matrix from the object
|
||||
let transform_affine = object.transform.to_affine();
|
||||
for (shape_idx, shape) in shapes.iter().enumerate() {
|
||||
let transform_affine = shape.transform.to_affine();
|
||||
|
||||
let path = shape.path();
|
||||
let mut current_point = Point::ZERO;
|
||||
let mut subpath_start = Point::ZERO; // Track start of current subpath
|
||||
let mut subpath_start = Point::ZERO;
|
||||
let mut segment_index = 0;
|
||||
let mut curves_in_shape = 0;
|
||||
|
||||
for element in path.elements() {
|
||||
// Extract curve segment from path element
|
||||
if let Some(mut segment) = CurveSegment::from_path_element(
|
||||
shape.id.as_u128() as usize,
|
||||
segment_index,
|
||||
element,
|
||||
current_point,
|
||||
) {
|
||||
// Apply the object's transform to all control points
|
||||
for control_point in &mut segment.control_points {
|
||||
*control_point = transform_affine * (*control_point);
|
||||
}
|
||||
|
|
@ -260,24 +197,21 @@ fn extract_curves_from_all_shapes(
|
|||
curves_in_shape += 1;
|
||||
}
|
||||
|
||||
// Update current point for next iteration (keep in local space)
|
||||
match element {
|
||||
vello::kurbo::PathEl::MoveTo(p) => {
|
||||
current_point = *p;
|
||||
subpath_start = *p; // Mark start of new subpath
|
||||
subpath_start = *p;
|
||||
}
|
||||
vello::kurbo::PathEl::LineTo(p) => current_point = *p,
|
||||
vello::kurbo::PathEl::QuadTo(_, p) => current_point = *p,
|
||||
vello::kurbo::PathEl::CurveTo(_, _, p) => current_point = *p,
|
||||
vello::kurbo::PathEl::ClosePath => {
|
||||
// Create closing segment from current_point back to subpath_start
|
||||
if let Some(mut segment) = CurveSegment::from_path_element(
|
||||
shape.id.as_u128() as usize,
|
||||
segment_index,
|
||||
&vello::kurbo::PathEl::LineTo(subpath_start),
|
||||
current_point,
|
||||
) {
|
||||
// Apply transform
|
||||
for control_point in &mut segment.control_points {
|
||||
*control_point = transform_affine * (*control_point);
|
||||
}
|
||||
|
|
@ -286,12 +220,12 @@ fn extract_curves_from_all_shapes(
|
|||
segment_index += 1;
|
||||
curves_in_shape += 1;
|
||||
}
|
||||
current_point = subpath_start; // ClosePath moves back to start
|
||||
current_point = subpath_start;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
println!(" Object {}: Extracted {} curves from shape", obj_idx, curves_in_shape);
|
||||
println!(" Shape {}: Extracted {} curves", shape_idx, curves_in_shape);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -307,57 +241,46 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn test_paint_bucket_action_basic() {
|
||||
// Create a document with a vector layer
|
||||
let mut document = Document::new("Test");
|
||||
let vector_layer = VectorLayer::new("Layer 1");
|
||||
let layer_id = document.root.add_child(AnyLayer::Vector(vector_layer));
|
||||
let mut layer = VectorLayer::new("Layer 1");
|
||||
|
||||
// Create a simple rectangle shape (boundary for fill)
|
||||
let rect = Rect::new(0.0, 0.0, 100.0, 100.0);
|
||||
let path = rect.to_path(0.1);
|
||||
let shape = Shape::new(path);
|
||||
let shape_instance = ShapeInstance::new(shape.id);
|
||||
|
||||
// Add the boundary shape
|
||||
if let Some(AnyLayer::Vector(layer)) = document.get_layer_mut(&layer_id) {
|
||||
layer.add_shape_internal(shape);
|
||||
layer.add_object_internal(shape_instance);
|
||||
}
|
||||
layer.add_shape_to_keyframe(shape, 0.0);
|
||||
|
||||
let layer_id = document.root_mut().add_child(AnyLayer::Vector(layer));
|
||||
|
||||
// Create and execute paint bucket action
|
||||
let mut action = PaintBucketAction::new(
|
||||
layer_id,
|
||||
Point::new(50.0, 50.0), // Click in center
|
||||
ShapeColor::rgb(255, 0, 0), // Red fill
|
||||
0.0,
|
||||
Point::new(50.0, 50.0),
|
||||
ShapeColor::rgb(255, 0, 0),
|
||||
2.0,
|
||||
GapHandlingMode::BridgeSegment,
|
||||
);
|
||||
|
||||
action.execute(&mut document).unwrap();
|
||||
|
||||
// Verify a filled shape was created
|
||||
// Verify a filled shape was created (or existing shape was recolored)
|
||||
if let Some(AnyLayer::Vector(layer)) = document.get_layer(&layer_id) {
|
||||
// Should have original shape + filled shape
|
||||
assert!(layer.shapes.len() >= 1);
|
||||
assert!(layer.shape_instances.len() >= 1);
|
||||
assert!(layer.shapes_at_time(0.0).len() >= 1);
|
||||
} else {
|
||||
panic!("Layer not found or not a vector layer");
|
||||
}
|
||||
|
||||
// Test rollback
|
||||
action.rollback(&mut document).unwrap();
|
||||
|
||||
if let Some(AnyLayer::Vector(layer)) = document.get_layer(&layer_id) {
|
||||
// Should only have original shape
|
||||
assert_eq!(layer.shapes.len(), 1);
|
||||
assert_eq!(layer.shape_instances.len(), 1);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_paint_bucket_action_description() {
|
||||
let action = PaintBucketAction::new(
|
||||
Uuid::new_v4(),
|
||||
0.0,
|
||||
Point::ZERO,
|
||||
ShapeColor::rgb(0, 0, 255),
|
||||
2.0,
|
||||
|
|
|
|||
|
|
@ -1,37 +1,32 @@
|
|||
//! Remove shapes action
|
||||
//!
|
||||
//! Handles removing shapes and shape instances from a vector layer (for cut/delete).
|
||||
//! Handles removing shapes from a vector layer's keyframe (for cut/delete).
|
||||
|
||||
use crate::action::Action;
|
||||
use crate::document::Document;
|
||||
use crate::layer::AnyLayer;
|
||||
use crate::object::ShapeInstance;
|
||||
use crate::shape::Shape;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Action that removes shapes and their instances from a vector layer
|
||||
/// Action that removes shapes from a vector layer's keyframe
|
||||
pub struct RemoveShapesAction {
|
||||
/// Layer ID containing the shapes
|
||||
layer_id: Uuid,
|
||||
/// Shape IDs to remove
|
||||
shape_ids: Vec<Uuid>,
|
||||
/// Shape instance IDs to remove
|
||||
instance_ids: Vec<Uuid>,
|
||||
/// Time of the keyframe
|
||||
time: f64,
|
||||
/// Saved shapes for rollback
|
||||
saved_shapes: Vec<(Uuid, Shape)>,
|
||||
/// Saved instances for rollback
|
||||
saved_instances: Vec<ShapeInstance>,
|
||||
saved_shapes: Vec<Shape>,
|
||||
}
|
||||
|
||||
impl RemoveShapesAction {
|
||||
/// Create a new remove shapes action
|
||||
pub fn new(layer_id: Uuid, shape_ids: Vec<Uuid>, instance_ids: Vec<Uuid>) -> Self {
|
||||
pub fn new(layer_id: Uuid, shape_ids: Vec<Uuid>, time: f64) -> Self {
|
||||
Self {
|
||||
layer_id,
|
||||
shape_ids,
|
||||
instance_ids,
|
||||
time,
|
||||
saved_shapes: Vec::new(),
|
||||
saved_instances: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -39,7 +34,6 @@ impl RemoveShapesAction {
|
|||
impl Action for RemoveShapesAction {
|
||||
fn execute(&mut self, document: &mut Document) -> Result<(), String> {
|
||||
self.saved_shapes.clear();
|
||||
self.saved_instances.clear();
|
||||
|
||||
let layer = document
|
||||
.get_layer_mut(&self.layer_id)
|
||||
|
|
@ -50,21 +44,9 @@ impl Action for RemoveShapesAction {
|
|||
_ => return Err("Not a vector layer".to_string()),
|
||||
};
|
||||
|
||||
// Remove and save shape instances
|
||||
let mut remaining_instances = Vec::new();
|
||||
for inst in vector_layer.shape_instances.drain(..) {
|
||||
if self.instance_ids.contains(&inst.id) {
|
||||
self.saved_instances.push(inst);
|
||||
} else {
|
||||
remaining_instances.push(inst);
|
||||
}
|
||||
}
|
||||
vector_layer.shape_instances = remaining_instances;
|
||||
|
||||
// Remove and save shape definitions
|
||||
for shape_id in &self.shape_ids {
|
||||
if let Some(shape) = vector_layer.shapes.remove(shape_id) {
|
||||
self.saved_shapes.push((*shape_id, shape));
|
||||
if let Some(shape) = vector_layer.remove_shape_from_keyframe(shape_id, self.time) {
|
||||
self.saved_shapes.push(shape);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -81,21 +63,15 @@ impl Action for RemoveShapesAction {
|
|||
_ => return Err("Not a vector layer".to_string()),
|
||||
};
|
||||
|
||||
// Restore shapes
|
||||
for (id, shape) in self.saved_shapes.drain(..) {
|
||||
vector_layer.shapes.insert(id, shape);
|
||||
}
|
||||
|
||||
// Restore instances
|
||||
for inst in self.saved_instances.drain(..) {
|
||||
vector_layer.shape_instances.push(inst);
|
||||
for shape in self.saved_shapes.drain(..) {
|
||||
vector_layer.add_shape_to_keyframe(shape, self.time);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn description(&self) -> String {
|
||||
let count = self.instance_ids.len();
|
||||
let count = self.shape_ids.len();
|
||||
if count == 1 {
|
||||
"Delete shape".to_string()
|
||||
} else {
|
||||
|
|
@ -108,45 +84,35 @@ impl Action for RemoveShapesAction {
|
|||
mod tests {
|
||||
use super::*;
|
||||
use crate::layer::VectorLayer;
|
||||
use crate::object::ShapeInstance;
|
||||
use crate::shape::Shape;
|
||||
use vello::kurbo::BezPath;
|
||||
|
||||
#[test]
|
||||
fn test_remove_shapes() {
|
||||
let mut document = Document::new("Test");
|
||||
|
||||
let mut vector_layer = VectorLayer::new("Layer 1");
|
||||
|
||||
// Add a shape and instance
|
||||
let mut path = BezPath::new();
|
||||
path.move_to((0.0, 0.0));
|
||||
path.line_to((100.0, 100.0));
|
||||
let shape = Shape::new(path);
|
||||
let shape_id = shape.id;
|
||||
let instance = ShapeInstance::new(shape_id);
|
||||
let instance_id = instance.id;
|
||||
|
||||
vector_layer.shapes.insert(shape_id, shape);
|
||||
vector_layer.shape_instances.push(instance);
|
||||
vector_layer.add_shape_to_keyframe(shape, 0.0);
|
||||
|
||||
let layer_id = document.root_mut().add_child(AnyLayer::Vector(vector_layer));
|
||||
|
||||
// Remove
|
||||
let mut action = RemoveShapesAction::new(layer_id, vec![shape_id], vec![instance_id]);
|
||||
let mut action = RemoveShapesAction::new(layer_id, vec![shape_id], 0.0);
|
||||
action.execute(&mut document).unwrap();
|
||||
|
||||
if let Some(AnyLayer::Vector(vl)) = document.get_layer(&layer_id) {
|
||||
assert!(vl.shapes.is_empty());
|
||||
assert!(vl.shape_instances.is_empty());
|
||||
assert!(vl.shapes_at_time(0.0).is_empty());
|
||||
}
|
||||
|
||||
// Rollback
|
||||
action.rollback(&mut document).unwrap();
|
||||
|
||||
if let Some(AnyLayer::Vector(vl)) = document.get_layer(&layer_id) {
|
||||
assert_eq!(vl.shapes.len(), 1);
|
||||
assert_eq!(vl.shape_instances.len(), 1);
|
||||
assert_eq!(vl.shapes_at_time(0.0).len(), 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
//! Set shape instance properties action
|
||||
//!
|
||||
//! Handles changing individual properties on shape instances (position, rotation, scale, etc.)
|
||||
//! with undo/redo support.
|
||||
//! Handles changing individual properties on shapes (position, rotation, scale, etc.)
|
||||
//! with undo/redo support. In the keyframe model, these operate on Shape's transform
|
||||
//! and opacity fields within the active keyframe.
|
||||
|
||||
use crate::action::Action;
|
||||
use crate::document::Document;
|
||||
|
|
@ -37,53 +38,65 @@ impl InstancePropertyChange {
|
|||
}
|
||||
}
|
||||
|
||||
/// Action that sets a property on one or more shape instances
|
||||
/// Action that sets a property on one or more shapes in a keyframe
|
||||
pub struct SetInstancePropertiesAction {
|
||||
/// Layer containing the instances
|
||||
/// Layer containing the shapes
|
||||
layer_id: Uuid,
|
||||
|
||||
/// Instance IDs to modify and their old values
|
||||
instance_changes: Vec<(Uuid, Option<f64>)>,
|
||||
/// Time of the keyframe
|
||||
time: f64,
|
||||
|
||||
/// Shape IDs to modify and their old values
|
||||
shape_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 {
|
||||
/// Create a new action to set a property on a single shape
|
||||
pub fn new(layer_id: Uuid, time: f64, shape_id: Uuid, property: InstancePropertyChange) -> Self {
|
||||
Self {
|
||||
layer_id,
|
||||
instance_changes: vec![(instance_id, None)],
|
||||
time,
|
||||
shape_changes: vec![(shape_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 {
|
||||
/// Create a new action to set a property on multiple shapes
|
||||
pub fn new_batch(layer_id: Uuid, time: f64, shape_ids: Vec<Uuid>, property: InstancePropertyChange) -> Self {
|
||||
Self {
|
||||
layer_id,
|
||||
instance_changes: instance_ids.into_iter().map(|id| (id, None)).collect(),
|
||||
time,
|
||||
shape_changes: shape_ids.into_iter().map(|id| (id, None)).collect(),
|
||||
property,
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
});
|
||||
}
|
||||
fn get_value_from_shape(shape: &crate::shape::Shape, property: &InstancePropertyChange) -> f64 {
|
||||
match property {
|
||||
InstancePropertyChange::X(_) => shape.transform.x,
|
||||
InstancePropertyChange::Y(_) => shape.transform.y,
|
||||
InstancePropertyChange::Rotation(_) => shape.transform.rotation,
|
||||
InstancePropertyChange::ScaleX(_) => shape.transform.scale_x,
|
||||
InstancePropertyChange::ScaleY(_) => shape.transform.scale_y,
|
||||
InstancePropertyChange::SkewX(_) => shape.transform.skew_x,
|
||||
InstancePropertyChange::SkewY(_) => shape.transform.skew_y,
|
||||
InstancePropertyChange::Opacity(_) => shape.opacity,
|
||||
}
|
||||
}
|
||||
|
||||
fn set_value_on_shape(shape: &mut crate::shape::Shape, property: &InstancePropertyChange, value: f64) {
|
||||
match property {
|
||||
InstancePropertyChange::X(_) => shape.transform.x = value,
|
||||
InstancePropertyChange::Y(_) => shape.transform.y = value,
|
||||
InstancePropertyChange::Rotation(_) => shape.transform.rotation = value,
|
||||
InstancePropertyChange::ScaleX(_) => shape.transform.scale_x = value,
|
||||
InstancePropertyChange::ScaleY(_) => shape.transform.scale_y = value,
|
||||
InstancePropertyChange::SkewX(_) => shape.transform.skew_x = value,
|
||||
InstancePropertyChange::SkewY(_) => shape.transform.skew_y = value,
|
||||
InstancePropertyChange::Opacity(_) => shape.opacity = value,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -91,25 +104,14 @@ impl SetInstancePropertiesAction {
|
|||
impl Action for SetInstancePropertiesAction {
|
||||
fn execute(&mut self, document: &mut Document) -> Result<(), String> {
|
||||
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,
|
||||
});
|
||||
// First pass: collect old values
|
||||
if let Some(layer) = document.get_layer(&self.layer_id) {
|
||||
if let AnyLayer::Vector(vector_layer) = layer {
|
||||
for (shape_id, old_value) in &mut self.shape_changes {
|
||||
if old_value.is_none() {
|
||||
if let Some(shape) = vector_layer.get_shape_in_keyframe(shape_id, self.time) {
|
||||
*old_value = Some(Self::get_value_from_shape(shape, &self.property));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -117,16 +119,28 @@ impl Action for SetInstancePropertiesAction {
|
|||
}
|
||||
|
||||
// Second pass: apply new values
|
||||
for (instance_id, _) in &self.instance_changes {
|
||||
self.apply_to_instance(document, instance_id, new_value);
|
||||
if let Some(layer) = document.get_layer_mut(&self.layer_id) {
|
||||
if let AnyLayer::Vector(vector_layer) = layer {
|
||||
for (shape_id, _) in &self.shape_changes {
|
||||
if let Some(shape) = vector_layer.get_shape_in_keyframe_mut(shape_id, self.time) {
|
||||
Self::set_value_on_shape(shape, &self.property, new_value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn rollback(&mut self, document: &mut Document) -> Result<(), String> {
|
||||
for (instance_id, old_value) in &self.instance_changes {
|
||||
if let Some(value) = old_value {
|
||||
self.apply_to_instance(document, instance_id, *value);
|
||||
if let Some(layer) = document.get_layer_mut(&self.layer_id) {
|
||||
if let AnyLayer::Vector(vector_layer) = layer {
|
||||
for (shape_id, old_value) in &self.shape_changes {
|
||||
if let Some(value) = old_value {
|
||||
if let Some(shape) = vector_layer.get_shape_in_keyframe_mut(shape_id, self.time) {
|
||||
Self::set_value_on_shape(shape, &self.property, *value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
|
|
@ -144,10 +158,10 @@ impl Action for SetInstancePropertiesAction {
|
|||
InstancePropertyChange::Opacity(_) => "opacity",
|
||||
};
|
||||
|
||||
if self.instance_changes.len() == 1 {
|
||||
if self.shape_changes.len() == 1 {
|
||||
format!("Set {}", property_name)
|
||||
} else {
|
||||
format!("Set {} on {} objects", property_name, self.instance_changes.len())
|
||||
format!("Set {} on {} shapes", property_name, self.shape_changes.len())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -156,80 +170,46 @@ impl Action for SetInstancePropertiesAction {
|
|||
mod tests {
|
||||
use super::*;
|
||||
use crate::layer::VectorLayer;
|
||||
use crate::object::{ShapeInstance, Transform};
|
||||
use crate::shape::Shape;
|
||||
use vello::kurbo::BezPath;
|
||||
|
||||
fn make_shape_at(x: f64, y: f64) -> Shape {
|
||||
let mut path = BezPath::new();
|
||||
path.move_to((0.0, 0.0));
|
||||
path.line_to((10.0, 10.0));
|
||||
Shape::new(path).with_position(x, y)
|
||||
}
|
||||
|
||||
#[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 shape = make_shape_at(10.0, 20.0);
|
||||
let shape_id = shape.id;
|
||||
layer.add_shape_to_keyframe(shape, 0.0);
|
||||
|
||||
let layer_id = document.root_mut().add_child(AnyLayer::Vector(layer));
|
||||
|
||||
// Create and execute action
|
||||
let mut action = SetInstancePropertiesAction::new(
|
||||
layer_id,
|
||||
instance_id,
|
||||
0.0,
|
||||
shape_id,
|
||||
InstancePropertyChange::X(50.0),
|
||||
);
|
||||
action.execute(&mut document).unwrap();
|
||||
|
||||
// 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
|
||||
if let Some(AnyLayer::Vector(vl)) = document.get_layer(&layer_id) {
|
||||
let s = vl.get_shape_in_keyframe(&shape_id, 0.0).unwrap();
|
||||
assert_eq!(s.transform.x, 50.0);
|
||||
assert_eq!(s.transform.y, 20.0);
|
||||
}
|
||||
|
||||
// Rollback
|
||||
action.rollback(&mut document).unwrap();
|
||||
|
||||
// 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).unwrap();
|
||||
|
||||
// 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).unwrap();
|
||||
|
||||
// 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);
|
||||
if let Some(AnyLayer::Vector(vl)) = document.get_layer(&layer_id) {
|
||||
let s = vl.get_shape_in_keyframe(&shape_id, 0.0).unwrap();
|
||||
assert_eq!(s.transform.x, 10.0);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -238,35 +218,30 @@ mod tests {
|
|||
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 shape = make_shape_at(0.0, 0.0);
|
||||
let shape_id = shape.id;
|
||||
layer.add_shape_to_keyframe(shape, 0.0);
|
||||
|
||||
let layer_id = document.root_mut().add_child(AnyLayer::Vector(layer));
|
||||
|
||||
// Create and execute action
|
||||
let mut action = SetInstancePropertiesAction::new(
|
||||
layer_id,
|
||||
instance_id,
|
||||
0.0,
|
||||
shape_id,
|
||||
InstancePropertyChange::Opacity(0.5),
|
||||
);
|
||||
action.execute(&mut document).unwrap();
|
||||
|
||||
// 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);
|
||||
if let Some(AnyLayer::Vector(vl)) = document.get_layer(&layer_id) {
|
||||
let s = vl.get_shape_in_keyframe(&shape_id, 0.0).unwrap();
|
||||
assert_eq!(s.opacity, 0.5);
|
||||
}
|
||||
|
||||
// Rollback
|
||||
action.rollback(&mut document).unwrap();
|
||||
|
||||
// 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);
|
||||
if let Some(AnyLayer::Vector(vl)) = document.get_layer(&layer_id) {
|
||||
let s = vl.get_shape_in_keyframe(&shape_id, 0.0).unwrap();
|
||||
assert_eq!(s.opacity, 1.0);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -275,69 +250,59 @@ mod tests {
|
|||
let mut document = Document::new("Test");
|
||||
let mut layer = VectorLayer::new("Test Layer");
|
||||
|
||||
let shape_id = Uuid::new_v4();
|
||||
let shape1 = make_shape_at(0.0, 0.0);
|
||||
let shape1_id = shape1.id;
|
||||
let shape2 = make_shape_at(10.0, 10.0);
|
||||
let shape2_id = shape2.id;
|
||||
|
||||
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);
|
||||
layer.add_shape_to_keyframe(shape1, 0.0);
|
||||
layer.add_shape_to_keyframe(shape2, 0.0);
|
||||
|
||||
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],
|
||||
0.0,
|
||||
vec![shape1_id, shape2_id],
|
||||
InstancePropertyChange::ScaleX(2.0),
|
||||
);
|
||||
action.execute(&mut document).unwrap();
|
||||
|
||||
// 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);
|
||||
if let Some(AnyLayer::Vector(vl)) = document.get_layer(&layer_id) {
|
||||
assert_eq!(vl.get_shape_in_keyframe(&shape1_id, 0.0).unwrap().transform.scale_x, 2.0);
|
||||
assert_eq!(vl.get_shape_in_keyframe(&shape2_id, 0.0).unwrap().transform.scale_x, 2.0);
|
||||
}
|
||||
|
||||
// Rollback
|
||||
action.rollback(&mut document).unwrap();
|
||||
|
||||
// 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);
|
||||
if let Some(AnyLayer::Vector(vl)) = document.get_layer(&layer_id) {
|
||||
assert_eq!(vl.get_shape_in_keyframe(&shape1_id, 0.0).unwrap().transform.scale_x, 1.0);
|
||||
assert_eq!(vl.get_shape_in_keyframe(&shape2_id, 0.0).unwrap().transform.scale_x, 1.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_description() {
|
||||
let layer_id = Uuid::new_v4();
|
||||
let instance_id = Uuid::new_v4();
|
||||
let shape_id = Uuid::new_v4();
|
||||
|
||||
let action1 = SetInstancePropertiesAction::new(
|
||||
layer_id,
|
||||
instance_id,
|
||||
layer_id, 0.0, shape_id,
|
||||
InstancePropertyChange::X(0.0),
|
||||
);
|
||||
assert_eq!(action1.description(), "Set X position");
|
||||
|
||||
let action2 = SetInstancePropertiesAction::new(
|
||||
layer_id,
|
||||
instance_id,
|
||||
layer_id, 0.0, shape_id,
|
||||
InstancePropertyChange::Rotation(0.0),
|
||||
);
|
||||
assert_eq!(action2.description(), "Set rotation");
|
||||
|
||||
let action3 = SetInstancePropertiesAction::new_batch(
|
||||
layer_id,
|
||||
layer_id, 0.0,
|
||||
vec![Uuid::new_v4(), Uuid::new_v4()],
|
||||
InstancePropertyChange::Opacity(1.0),
|
||||
);
|
||||
assert_eq!(action3.description(), "Set opacity on 2 objects");
|
||||
assert_eq!(action3.description(), "Set opacity on 2 shapes");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,147 @@
|
|||
//! Set keyframe action
|
||||
//!
|
||||
//! For vector layers: creates a new ShapeKeyframe at the given time by copying
|
||||
//! shapes from the current keyframe span (with new UUIDs).
|
||||
//! For clip instances: adds AnimationData keyframes for transform properties.
|
||||
|
||||
use crate::action::Action;
|
||||
use crate::animation::{AnimationCurve, AnimationTarget, Keyframe, TransformProperty};
|
||||
use crate::document::Document;
|
||||
use crate::layer::{AnyLayer, ShapeKeyframe};
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Undo info for a clip animation curve
|
||||
struct ClipUndoEntry {
|
||||
target: AnimationTarget,
|
||||
old_keyframe: Option<Keyframe>,
|
||||
curve_created: bool,
|
||||
}
|
||||
|
||||
pub struct SetKeyframeAction {
|
||||
layer_id: Uuid,
|
||||
time: f64,
|
||||
/// Clip instance IDs to keyframe (motion tweens)
|
||||
clip_instance_ids: Vec<Uuid>,
|
||||
/// Whether a shape keyframe was created by this action
|
||||
shape_keyframe_created: bool,
|
||||
/// The removed keyframe for rollback (if we created one)
|
||||
removed_keyframe: Option<ShapeKeyframe>,
|
||||
/// Clip animation undo entries
|
||||
clip_undo_entries: Vec<ClipUndoEntry>,
|
||||
}
|
||||
|
||||
impl SetKeyframeAction {
|
||||
pub fn new(layer_id: Uuid, time: f64, clip_instance_ids: Vec<Uuid>) -> Self {
|
||||
Self {
|
||||
layer_id,
|
||||
time,
|
||||
clip_instance_ids,
|
||||
shape_keyframe_created: false,
|
||||
removed_keyframe: None,
|
||||
clip_undo_entries: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const TRANSFORM_PROPERTIES: &[TransformProperty] = &[
|
||||
TransformProperty::X,
|
||||
TransformProperty::Y,
|
||||
TransformProperty::Rotation,
|
||||
TransformProperty::ScaleX,
|
||||
TransformProperty::ScaleY,
|
||||
TransformProperty::SkewX,
|
||||
TransformProperty::SkewY,
|
||||
TransformProperty::Opacity,
|
||||
];
|
||||
|
||||
fn transform_default(prop: &TransformProperty) -> f64 {
|
||||
match prop {
|
||||
TransformProperty::ScaleX | TransformProperty::ScaleY => 1.0,
|
||||
TransformProperty::Opacity => 1.0,
|
||||
_ => 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
impl Action for SetKeyframeAction {
|
||||
fn execute(&mut self, document: &mut Document) -> Result<(), String> {
|
||||
self.clip_undo_entries.clear();
|
||||
self.shape_keyframe_created = false;
|
||||
|
||||
let layer = document
|
||||
.get_layer_mut(&self.layer_id)
|
||||
.ok_or_else(|| format!("Layer {} not found", self.layer_id))?;
|
||||
|
||||
// For vector layers: create a shape keyframe
|
||||
if let AnyLayer::Vector(vl) = layer {
|
||||
// Check if a keyframe already exists at this exact time
|
||||
let already_exists = vl.keyframe_index_at_exact(self.time, 0.001).is_some();
|
||||
if !already_exists {
|
||||
vl.insert_keyframe_from_current(self.time);
|
||||
self.shape_keyframe_created = true;
|
||||
}
|
||||
|
||||
// Add clip animation keyframes
|
||||
for clip_id in &self.clip_instance_ids {
|
||||
for prop in TRANSFORM_PROPERTIES {
|
||||
let target = AnimationTarget::Object {
|
||||
id: *clip_id,
|
||||
property: *prop,
|
||||
};
|
||||
let default = transform_default(prop);
|
||||
let value = vl.layer.animation_data.eval(&target, self.time, default);
|
||||
|
||||
let curve_created = vl.layer.animation_data.get_curve(&target).is_none();
|
||||
if curve_created {
|
||||
vl.layer
|
||||
.animation_data
|
||||
.set_curve(AnimationCurve::new(target.clone(), default));
|
||||
}
|
||||
|
||||
let curve = vl.layer.animation_data.get_curve_mut(&target).unwrap();
|
||||
let old_keyframe = curve.get_keyframe_at(self.time, 0.001).cloned();
|
||||
curve.set_keyframe(Keyframe::linear(self.time, value));
|
||||
|
||||
self.clip_undo_entries.push(ClipUndoEntry {
|
||||
target,
|
||||
old_keyframe,
|
||||
curve_created,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn rollback(&mut self, document: &mut Document) -> Result<(), String> {
|
||||
let layer = document
|
||||
.get_layer_mut(&self.layer_id)
|
||||
.ok_or_else(|| format!("Layer {} not found", self.layer_id))?;
|
||||
|
||||
if let AnyLayer::Vector(vl) = layer {
|
||||
// Undo clip animation keyframes in reverse order
|
||||
for entry in self.clip_undo_entries.drain(..).rev() {
|
||||
if entry.curve_created {
|
||||
vl.layer.animation_data.remove_curve(&entry.target);
|
||||
} else if let Some(curve) = vl.layer.animation_data.get_curve_mut(&entry.target) {
|
||||
curve.remove_keyframe(self.time, 0.001);
|
||||
if let Some(old_kf) = entry.old_keyframe {
|
||||
curve.set_keyframe(old_kf);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove the shape keyframe if we created one
|
||||
if self.shape_keyframe_created {
|
||||
self.removed_keyframe = vl.remove_keyframe_at(self.time, 0.001);
|
||||
self.shape_keyframe_created = false;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn description(&self) -> String {
|
||||
"New keyframe".to_string()
|
||||
}
|
||||
}
|
||||
|
|
@ -25,6 +25,9 @@ pub struct SetShapePropertiesAction {
|
|||
/// Shape to modify
|
||||
shape_id: Uuid,
|
||||
|
||||
/// Time of the keyframe containing the shape
|
||||
time: f64,
|
||||
|
||||
/// New property value
|
||||
new_value: ShapePropertyChange,
|
||||
|
||||
|
|
@ -34,28 +37,50 @@ pub struct SetShapePropertiesAction {
|
|||
|
||||
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 {
|
||||
pub fn new(layer_id: Uuid, shape_id: Uuid, time: f64, new_value: ShapePropertyChange) -> Self {
|
||||
Self {
|
||||
layer_id,
|
||||
shape_id,
|
||||
time,
|
||||
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))
|
||||
pub fn set_fill_color(layer_id: Uuid, shape_id: Uuid, time: f64, color: Option<ShapeColor>) -> Self {
|
||||
Self::new(layer_id, shape_id, time, 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))
|
||||
pub fn set_stroke_color(layer_id: Uuid, shape_id: Uuid, time: f64, color: Option<ShapeColor>) -> Self {
|
||||
Self::new(layer_id, shape_id, time, 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))
|
||||
pub fn set_stroke_width(layer_id: Uuid, shape_id: Uuid, time: f64, width: f64) -> Self {
|
||||
Self::new(layer_id, shape_id, time, ShapePropertyChange::StrokeWidth(width))
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_property(shape: &mut crate::shape::Shape, change: &ShapePropertyChange) {
|
||||
match change {
|
||||
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 {
|
||||
shape.stroke_style = Some(StrokeStyle {
|
||||
width: *width,
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -63,7 +88,7 @@ impl Action for SetShapePropertiesAction {
|
|||
fn execute(&mut self, document: &mut Document) -> Result<(), String> {
|
||||
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) {
|
||||
if let Some(shape) = vector_layer.get_shape_in_keyframe_mut(&self.shape_id, self.time) {
|
||||
// Store old value if not already stored
|
||||
if self.old_value.is_none() {
|
||||
self.old_value = Some(match &self.new_value {
|
||||
|
|
@ -84,26 +109,7 @@ impl Action for SetShapePropertiesAction {
|
|||
});
|
||||
}
|
||||
|
||||
// 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()
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
apply_property(shape, &self.new_value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -111,23 +117,11 @@ impl Action for SetShapePropertiesAction {
|
|||
}
|
||||
|
||||
fn rollback(&mut self, document: &mut Document) -> Result<(), String> {
|
||||
if let Some(old_value) = &self.old_value {
|
||||
if let Some(old_value) = &self.old_value.clone() {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(shape) = vector_layer.get_shape_in_keyframe_mut(&self.shape_id, self.time) {
|
||||
apply_property(shape, old_value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -149,7 +143,7 @@ mod tests {
|
|||
use super::*;
|
||||
use crate::layer::VectorLayer;
|
||||
use crate::shape::Shape;
|
||||
use kurbo::BezPath;
|
||||
use vello::kurbo::BezPath;
|
||||
|
||||
fn create_test_shape() -> Shape {
|
||||
let mut path = BezPath::new();
|
||||
|
|
@ -176,24 +170,24 @@ mod tests {
|
|||
|
||||
let shape = create_test_shape();
|
||||
let shape_id = shape.id;
|
||||
layer.shapes.insert(shape_id, shape);
|
||||
layer.add_shape_to_keyframe(shape, 0.0);
|
||||
|
||||
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();
|
||||
if let Some(AnyLayer::Vector(vl)) = document.get_layer(&layer_id) {
|
||||
let shape = vl.get_shape_in_keyframe(&shape_id, 0.0).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);
|
||||
let mut action = SetShapePropertiesAction::set_fill_color(layer_id, shape_id, 0.0, new_color);
|
||||
action.execute(&mut document).unwrap();
|
||||
|
||||
// Verify color changed
|
||||
if let Some(AnyLayer::Vector(vl)) = document.get_layer_mut(&layer_id) {
|
||||
let shape = vl.shapes.get(&shape_id).unwrap();
|
||||
if let Some(AnyLayer::Vector(vl)) = document.get_layer(&layer_id) {
|
||||
let shape = vl.get_shape_in_keyframe(&shape_id, 0.0).unwrap();
|
||||
assert_eq!(shape.fill_color.unwrap().g, 255);
|
||||
}
|
||||
|
||||
|
|
@ -201,8 +195,8 @@ mod tests {
|
|||
action.rollback(&mut document).unwrap();
|
||||
|
||||
// Verify restored
|
||||
if let Some(AnyLayer::Vector(vl)) = document.get_layer_mut(&layer_id) {
|
||||
let shape = vl.shapes.get(&shape_id).unwrap();
|
||||
if let Some(AnyLayer::Vector(vl)) = document.get_layer(&layer_id) {
|
||||
let shape = vl.get_shape_in_keyframe(&shape_id, 0.0).unwrap();
|
||||
assert_eq!(shape.fill_color.unwrap().r, 255);
|
||||
}
|
||||
}
|
||||
|
|
@ -214,23 +208,17 @@ mod tests {
|
|||
|
||||
let shape = create_test_shape();
|
||||
let shape_id = shape.id;
|
||||
layer.shapes.insert(shape_id, shape);
|
||||
layer.add_shape_to_keyframe(shape, 0.0);
|
||||
|
||||
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);
|
||||
let mut action = SetShapePropertiesAction::set_stroke_width(layer_id, shape_id, 0.0, 5.0);
|
||||
action.execute(&mut document).unwrap();
|
||||
|
||||
// Verify width changed
|
||||
if let Some(AnyLayer::Vector(vl)) = document.get_layer_mut(&layer_id) {
|
||||
let shape = vl.shapes.get(&shape_id).unwrap();
|
||||
if let Some(AnyLayer::Vector(vl)) = document.get_layer(&layer_id) {
|
||||
let shape = vl.get_shape_in_keyframe(&shape_id, 0.0).unwrap();
|
||||
assert_eq!(shape.stroke_style.as_ref().unwrap().width, 5.0);
|
||||
}
|
||||
|
||||
|
|
@ -238,8 +226,8 @@ mod tests {
|
|||
action.rollback(&mut document).unwrap();
|
||||
|
||||
// Verify restored
|
||||
if let Some(AnyLayer::Vector(vl)) = document.get_layer_mut(&layer_id) {
|
||||
let shape = vl.shapes.get(&shape_id).unwrap();
|
||||
if let Some(AnyLayer::Vector(vl)) = document.get_layer(&layer_id) {
|
||||
let shape = vl.get_shape_in_keyframe(&shape_id, 0.0).unwrap();
|
||||
assert_eq!(shape.stroke_style.as_ref().unwrap().width, 2.0);
|
||||
}
|
||||
}
|
||||
|
|
@ -250,14 +238,14 @@ mod tests {
|
|||
let shape_id = Uuid::new_v4();
|
||||
|
||||
let action1 =
|
||||
SetShapePropertiesAction::set_fill_color(layer_id, shape_id, Some(ShapeColor::rgb(0, 0, 0)));
|
||||
SetShapePropertiesAction::set_fill_color(layer_id, shape_id, 0.0, 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)));
|
||||
SetShapePropertiesAction::set_stroke_color(layer_id, shape_id, 0.0, 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);
|
||||
let action3 = SetShapePropertiesAction::set_stroke_width(layer_id, shape_id, 0.0, 3.0);
|
||||
assert_eq!(action3.description(), "Set stroke width");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
//! Transform clip instances action
|
||||
//!
|
||||
//! Handles spatial transformation (move, scale, rotate) of clip instances on the stage.
|
||||
//! Updates both the clip instance's transform and the animation keyframe at the current time.
|
||||
|
||||
use crate::action::Action;
|
||||
use crate::animation::{AnimationTarget, Keyframe, TransformProperty};
|
||||
use crate::document::Document;
|
||||
use crate::layer::AnyLayer;
|
||||
use crate::object::Transform;
|
||||
|
|
@ -12,6 +14,8 @@ use uuid::Uuid;
|
|||
/// Action that transforms clip instances spatially on the stage
|
||||
pub struct TransformClipInstancesAction {
|
||||
layer_id: Uuid,
|
||||
/// Current time for animation keyframe update
|
||||
time: f64,
|
||||
/// Map of clip instance ID to (old transform, new transform)
|
||||
clip_instance_transforms: HashMap<Uuid, (Transform, Transform)>,
|
||||
}
|
||||
|
|
@ -19,15 +23,48 @@ pub struct TransformClipInstancesAction {
|
|||
impl TransformClipInstancesAction {
|
||||
pub fn new(
|
||||
layer_id: Uuid,
|
||||
time: f64,
|
||||
clip_instance_transforms: HashMap<Uuid, (Transform, Transform)>,
|
||||
) -> Self {
|
||||
Self {
|
||||
layer_id,
|
||||
time,
|
||||
clip_instance_transforms,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Update animation keyframes for a clip instance's transform properties at the given time.
|
||||
/// If a curve exists for a property, updates the keyframe at that time. If no curve exists, does nothing.
|
||||
fn update_animation_keyframes(
|
||||
animation_data: &mut crate::animation::AnimationData,
|
||||
instance_id: Uuid,
|
||||
transform: &Transform,
|
||||
opacity: f64,
|
||||
time: f64,
|
||||
) {
|
||||
let props_and_values = [
|
||||
(TransformProperty::X, transform.x),
|
||||
(TransformProperty::Y, transform.y),
|
||||
(TransformProperty::Rotation, transform.rotation),
|
||||
(TransformProperty::ScaleX, transform.scale_x),
|
||||
(TransformProperty::ScaleY, transform.scale_y),
|
||||
(TransformProperty::SkewX, transform.skew_x),
|
||||
(TransformProperty::SkewY, transform.skew_y),
|
||||
(TransformProperty::Opacity, opacity),
|
||||
];
|
||||
|
||||
for (prop, value) in props_and_values {
|
||||
let target = AnimationTarget::Object {
|
||||
id: instance_id,
|
||||
property: prop,
|
||||
};
|
||||
if let Some(curve) = animation_data.get_curve_mut(&target) {
|
||||
curve.set_keyframe(Keyframe::linear(time, value));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Action for TransformClipInstancesAction {
|
||||
fn execute(&mut self, document: &mut Document) -> Result<(), String> {
|
||||
let layer = match document.get_layer_mut(&self.layer_id) {
|
||||
|
|
@ -35,19 +72,33 @@ impl Action for TransformClipInstancesAction {
|
|||
None => return Ok(()),
|
||||
};
|
||||
|
||||
// Get mutable reference to clip_instances for this layer type
|
||||
let clip_instances = match layer {
|
||||
AnyLayer::Vector(vl) => &mut vl.clip_instances,
|
||||
AnyLayer::Audio(al) => &mut al.clip_instances,
|
||||
AnyLayer::Video(vl) => &mut vl.clip_instances,
|
||||
AnyLayer::Effect(_) => return Ok(()), // Effect layers don't have clip instances
|
||||
};
|
||||
|
||||
// Apply new transforms
|
||||
for (clip_id, (_old, new)) in &self.clip_instance_transforms {
|
||||
if let Some(clip_instance) = clip_instances.iter_mut().find(|ci| ci.id == *clip_id) {
|
||||
clip_instance.transform = new.clone();
|
||||
match layer {
|
||||
AnyLayer::Vector(vl) => {
|
||||
for (clip_id, (_old, new)) in &self.clip_instance_transforms {
|
||||
if let Some(clip_instance) = vl.clip_instances.iter_mut().find(|ci| ci.id == *clip_id) {
|
||||
let opacity = clip_instance.opacity;
|
||||
clip_instance.transform = new.clone();
|
||||
update_animation_keyframes(
|
||||
&mut vl.layer.animation_data, *clip_id, new, opacity, self.time,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
AnyLayer::Audio(al) => {
|
||||
for (clip_id, (_old, new)) in &self.clip_instance_transforms {
|
||||
if let Some(ci) = al.clip_instances.iter_mut().find(|ci| ci.id == *clip_id) {
|
||||
ci.transform = new.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
AnyLayer::Video(vl) => {
|
||||
for (clip_id, (_old, new)) in &self.clip_instance_transforms {
|
||||
if let Some(ci) = vl.clip_instances.iter_mut().find(|ci| ci.id == *clip_id) {
|
||||
ci.transform = new.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
AnyLayer::Effect(_) => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -58,19 +109,33 @@ impl Action for TransformClipInstancesAction {
|
|||
None => return Ok(()),
|
||||
};
|
||||
|
||||
// Get mutable reference to clip_instances for this layer type
|
||||
let clip_instances = match layer {
|
||||
AnyLayer::Vector(vl) => &mut vl.clip_instances,
|
||||
AnyLayer::Audio(al) => &mut al.clip_instances,
|
||||
AnyLayer::Video(vl) => &mut vl.clip_instances,
|
||||
AnyLayer::Effect(_) => return Ok(()), // Effect layers don't have clip instances
|
||||
};
|
||||
|
||||
// Restore old transforms
|
||||
for (clip_id, (old, _new)) in &self.clip_instance_transforms {
|
||||
if let Some(clip_instance) = clip_instances.iter_mut().find(|ci| ci.id == *clip_id) {
|
||||
clip_instance.transform = old.clone();
|
||||
match layer {
|
||||
AnyLayer::Vector(vl) => {
|
||||
for (clip_id, (old, _new)) in &self.clip_instance_transforms {
|
||||
if let Some(clip_instance) = vl.clip_instances.iter_mut().find(|ci| ci.id == *clip_id) {
|
||||
let opacity = clip_instance.opacity;
|
||||
clip_instance.transform = old.clone();
|
||||
update_animation_keyframes(
|
||||
&mut vl.layer.animation_data, *clip_id, old, opacity, self.time,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
AnyLayer::Audio(al) => {
|
||||
for (clip_id, (old, _new)) in &self.clip_instance_transforms {
|
||||
if let Some(ci) = al.clip_instances.iter_mut().find(|ci| ci.id == *clip_id) {
|
||||
ci.transform = old.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
AnyLayer::Video(vl) => {
|
||||
for (clip_id, (old, _new)) in &self.clip_instance_transforms {
|
||||
if let Some(ci) = vl.clip_instances.iter_mut().find(|ci| ci.id == *clip_id) {
|
||||
ci.transform = old.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
AnyLayer::Effect(_) => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -94,7 +159,6 @@ mod tests {
|
|||
let mut document = Document::new("Test");
|
||||
let mut layer = VectorLayer::new("Test Layer");
|
||||
|
||||
// Create a clip instance with initial transform
|
||||
let clip_id = Uuid::new_v4();
|
||||
let instance_id = Uuid::new_v4();
|
||||
let mut instance = ClipInstance::with_id(instance_id, clip_id);
|
||||
|
|
@ -103,18 +167,14 @@ mod tests {
|
|||
|
||||
let layer_id = document.root_mut().add_child(AnyLayer::Vector(layer));
|
||||
|
||||
// Create transform action: move from (10, 20) to (100, 200)
|
||||
let old_transform = Transform::with_position(10.0, 20.0);
|
||||
let new_transform = Transform::with_position(100.0, 200.0);
|
||||
let mut transforms = HashMap::new();
|
||||
transforms.insert(instance_id, (old_transform, new_transform));
|
||||
|
||||
let mut action = TransformClipInstancesAction::new(layer_id, transforms);
|
||||
|
||||
// Execute action
|
||||
let mut action = TransformClipInstancesAction::new(layer_id, 0.0, transforms);
|
||||
action.execute(&mut document).unwrap();
|
||||
|
||||
// Verify transform changed
|
||||
if let Some(AnyLayer::Vector(vl)) = document.get_layer_mut(&layer_id) {
|
||||
let inst = vl.clip_instances.iter().find(|ci| ci.id == instance_id).unwrap();
|
||||
assert_eq!(inst.transform.x, 100.0);
|
||||
|
|
@ -123,10 +183,8 @@ mod tests {
|
|||
panic!("Layer not found");
|
||||
}
|
||||
|
||||
// Rollback
|
||||
action.rollback(&mut document).unwrap();
|
||||
|
||||
// Verify transform restored
|
||||
if let Some(AnyLayer::Vector(vl)) = document.get_layer_mut(&layer_id) {
|
||||
let inst = vl.clip_instances.iter().find(|ci| ci.id == instance_id).unwrap();
|
||||
assert_eq!(inst.transform.x, 10.0);
|
||||
|
|
@ -141,7 +199,6 @@ mod tests {
|
|||
let mut document = Document::new("Test");
|
||||
let mut layer = AudioLayer::new("Audio Layer");
|
||||
|
||||
// Create a clip instance
|
||||
let clip_id = Uuid::new_v4();
|
||||
let instance_id = Uuid::new_v4();
|
||||
let mut instance = ClipInstance::with_id(instance_id, clip_id);
|
||||
|
|
@ -150,16 +207,14 @@ mod tests {
|
|||
|
||||
let layer_id = document.root_mut().add_child(AnyLayer::Audio(layer));
|
||||
|
||||
// Create transform action
|
||||
let old_transform = Transform::with_position(0.0, 0.0);
|
||||
let new_transform = Transform::with_position(50.0, 75.0);
|
||||
let mut transforms = HashMap::new();
|
||||
transforms.insert(instance_id, (old_transform, new_transform));
|
||||
|
||||
let mut action = TransformClipInstancesAction::new(layer_id, transforms);
|
||||
let mut action = TransformClipInstancesAction::new(layer_id, 0.0, transforms);
|
||||
action.execute(&mut document).unwrap();
|
||||
|
||||
// Verify
|
||||
if let Some(AnyLayer::Audio(al)) = document.get_layer_mut(&layer_id) {
|
||||
let inst = al.clip_instances.iter().find(|ci| ci.id == instance_id).unwrap();
|
||||
assert_eq!(inst.transform.x, 50.0);
|
||||
|
|
@ -174,7 +229,6 @@ mod tests {
|
|||
let mut document = Document::new("Test");
|
||||
let mut layer = VideoLayer::new("Video Layer");
|
||||
|
||||
// Create a clip instance
|
||||
let clip_id = Uuid::new_v4();
|
||||
let instance_id = Uuid::new_v4();
|
||||
let mut instance = ClipInstance::with_id(instance_id, clip_id);
|
||||
|
|
@ -184,7 +238,6 @@ mod tests {
|
|||
|
||||
let layer_id = document.root_mut().add_child(AnyLayer::Video(layer));
|
||||
|
||||
// Create transform with rotation and scale
|
||||
let mut old_transform = Transform::new();
|
||||
old_transform.rotation = 0.0;
|
||||
old_transform.scale_x = 1.0;
|
||||
|
|
@ -197,10 +250,9 @@ mod tests {
|
|||
let mut transforms = HashMap::new();
|
||||
transforms.insert(instance_id, (old_transform, new_transform));
|
||||
|
||||
let mut action = TransformClipInstancesAction::new(layer_id, transforms);
|
||||
let mut action = TransformClipInstancesAction::new(layer_id, 0.0, transforms);
|
||||
action.execute(&mut document).unwrap();
|
||||
|
||||
// Verify rotation and scale
|
||||
if let Some(AnyLayer::Video(vl)) = document.get_layer_mut(&layer_id) {
|
||||
let inst = vl.clip_instances.iter().find(|ci| ci.id == instance_id).unwrap();
|
||||
assert_eq!(inst.transform.rotation, 45.0);
|
||||
|
|
@ -216,7 +268,6 @@ mod tests {
|
|||
let mut document = Document::new("Test");
|
||||
let mut layer = VectorLayer::new("Test Layer");
|
||||
|
||||
// Create two clip instances
|
||||
let clip_id = Uuid::new_v4();
|
||||
let instance1_id = Uuid::new_v4();
|
||||
let instance2_id = Uuid::new_v4();
|
||||
|
|
@ -232,7 +283,6 @@ mod tests {
|
|||
|
||||
let layer_id = document.root_mut().add_child(AnyLayer::Vector(layer));
|
||||
|
||||
// Transform both instances
|
||||
let mut transforms = HashMap::new();
|
||||
transforms.insert(
|
||||
instance1_id,
|
||||
|
|
@ -243,10 +293,9 @@ mod tests {
|
|||
(Transform::with_position(100.0, 100.0), Transform::with_position(150.0, 150.0)),
|
||||
);
|
||||
|
||||
let mut action = TransformClipInstancesAction::new(layer_id, transforms);
|
||||
let mut action = TransformClipInstancesAction::new(layer_id, 0.0, transforms);
|
||||
action.execute(&mut document).unwrap();
|
||||
|
||||
// Verify both transformed
|
||||
if let Some(AnyLayer::Vector(vl)) = document.get_layer_mut(&layer_id) {
|
||||
let inst1 = vl.clip_instances.iter().find(|ci| ci.id == instance1_id).unwrap();
|
||||
assert_eq!(inst1.transform.x, 50.0);
|
||||
|
|
@ -259,10 +308,8 @@ mod tests {
|
|||
panic!("Layer not found");
|
||||
}
|
||||
|
||||
// Rollback
|
||||
action.rollback(&mut document).unwrap();
|
||||
|
||||
// Verify both restored
|
||||
if let Some(AnyLayer::Vector(vl)) = document.get_layer_mut(&layer_id) {
|
||||
let inst1 = vl.clip_instances.iter().find(|ci| ci.id == instance1_id).unwrap();
|
||||
assert_eq!(inst1.transform.x, 0.0);
|
||||
|
|
@ -276,25 +323,6 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_transform_nonexistent_layer() {
|
||||
let mut document = Document::new("Test");
|
||||
let fake_layer_id = Uuid::new_v4();
|
||||
let instance_id = Uuid::new_v4();
|
||||
|
||||
let mut transforms = HashMap::new();
|
||||
transforms.insert(
|
||||
instance_id,
|
||||
(Transform::with_position(0.0, 0.0), Transform::with_position(50.0, 50.0)),
|
||||
);
|
||||
|
||||
let mut action = TransformClipInstancesAction::new(fake_layer_id, transforms);
|
||||
|
||||
// Should not panic, just return early
|
||||
action.execute(&mut document).unwrap();
|
||||
action.rollback(&mut document).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_description() {
|
||||
let layer_id = Uuid::new_v4();
|
||||
|
|
@ -306,16 +334,7 @@ mod tests {
|
|||
(Transform::new(), Transform::with_position(10.0, 10.0)),
|
||||
);
|
||||
|
||||
let action = TransformClipInstancesAction::new(layer_id, transforms);
|
||||
let action = TransformClipInstancesAction::new(layer_id, 0.0, transforms);
|
||||
assert_eq!(action.description(), "Transform 1 clip instance(s)");
|
||||
|
||||
// Multiple instances
|
||||
let mut transforms2 = HashMap::new();
|
||||
transforms2.insert(Uuid::new_v4(), (Transform::new(), Transform::new()));
|
||||
transforms2.insert(Uuid::new_v4(), (Transform::new(), Transform::new()));
|
||||
transforms2.insert(Uuid::new_v4(), (Transform::new(), Transform::new()));
|
||||
|
||||
let action2 = TransformClipInstancesAction::new(layer_id, transforms2);
|
||||
assert_eq!(action2.description(), "Transform 3 clip instance(s)");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
//! Transform shape instances action
|
||||
//! Transform shapes action
|
||||
//!
|
||||
//! Applies scale, rotation, and other transformations to shape instances with undo/redo support.
|
||||
//! Applies scale, rotation, and other transformations to shapes in a keyframe.
|
||||
|
||||
use crate::action::Action;
|
||||
use crate::document::Document;
|
||||
|
|
@ -9,22 +9,24 @@ use crate::object::Transform;
|
|||
use std::collections::HashMap;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Action to transform multiple shape instances
|
||||
/// Action to transform multiple shapes in a keyframe
|
||||
pub struct TransformShapeInstancesAction {
|
||||
layer_id: Uuid,
|
||||
/// Map of shape instance ID to (old transform, new transform)
|
||||
shape_instance_transforms: HashMap<Uuid, (Transform, Transform)>,
|
||||
time: f64,
|
||||
/// Map of shape ID to (old transform, new transform)
|
||||
shape_transforms: HashMap<Uuid, (Transform, Transform)>,
|
||||
}
|
||||
|
||||
impl TransformShapeInstancesAction {
|
||||
/// Create a new transform action
|
||||
pub fn new(
|
||||
layer_id: Uuid,
|
||||
shape_instance_transforms: HashMap<Uuid, (Transform, Transform)>,
|
||||
time: f64,
|
||||
shape_transforms: HashMap<Uuid, (Transform, Transform)>,
|
||||
) -> Self {
|
||||
Self {
|
||||
layer_id,
|
||||
shape_instance_transforms,
|
||||
time,
|
||||
shape_transforms,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -33,10 +35,10 @@ impl Action for TransformShapeInstancesAction {
|
|||
fn execute(&mut self, document: &mut Document) -> Result<(), String> {
|
||||
if let Some(layer) = document.get_layer_mut(&self.layer_id) {
|
||||
if let AnyLayer::Vector(vector_layer) = layer {
|
||||
for (shape_instance_id, (_old, new)) in &self.shape_instance_transforms {
|
||||
vector_layer.modify_object_internal(shape_instance_id, |obj| {
|
||||
obj.transform = new.clone();
|
||||
});
|
||||
for (shape_id, (_old, new)) in &self.shape_transforms {
|
||||
if let Some(shape) = vector_layer.get_shape_in_keyframe_mut(shape_id, self.time) {
|
||||
shape.transform = new.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -46,10 +48,10 @@ impl Action for TransformShapeInstancesAction {
|
|||
fn rollback(&mut self, document: &mut Document) -> Result<(), String> {
|
||||
if let Some(layer) = document.get_layer_mut(&self.layer_id) {
|
||||
if let AnyLayer::Vector(vector_layer) = layer {
|
||||
for (shape_instance_id, (old, _new)) in &self.shape_instance_transforms {
|
||||
vector_layer.modify_object_internal(shape_instance_id, |obj| {
|
||||
obj.transform = old.clone();
|
||||
});
|
||||
for (shape_id, (old, _new)) in &self.shape_transforms {
|
||||
if let Some(shape) = vector_layer.get_shape_in_keyframe_mut(shape_id, self.time) {
|
||||
shape.transform = old.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -57,7 +59,7 @@ impl Action for TransformShapeInstancesAction {
|
|||
}
|
||||
|
||||
fn description(&self) -> String {
|
||||
format!("Transform {} shape instance(s)", self.shape_instance_transforms.len())
|
||||
format!("Transform {} shape(s)", self.shape_transforms.len())
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -65,239 +67,43 @@ impl Action for TransformShapeInstancesAction {
|
|||
mod tests {
|
||||
use super::*;
|
||||
use crate::layer::VectorLayer;
|
||||
use crate::object::ShapeInstance;
|
||||
use crate::shape::Shape;
|
||||
use vello::kurbo::BezPath;
|
||||
|
||||
#[test]
|
||||
fn test_transform_single_shape_instance() {
|
||||
fn test_transform_shape() {
|
||||
let mut document = Document::new("Test");
|
||||
let mut layer = VectorLayer::new("Test Layer");
|
||||
|
||||
// Create a shape instance with initial position
|
||||
let shape_id = Uuid::new_v4();
|
||||
let instance_id = Uuid::new_v4();
|
||||
let mut instance = ShapeInstance::new(shape_id);
|
||||
instance.id = instance_id;
|
||||
instance.transform = Transform::with_position(10.0, 20.0);
|
||||
layer.add_object(instance);
|
||||
let mut path = BezPath::new();
|
||||
path.move_to((0.0, 0.0));
|
||||
path.line_to((100.0, 100.0));
|
||||
let shape = Shape::new(path).with_position(10.0, 20.0);
|
||||
let shape_id = shape.id;
|
||||
|
||||
layer.add_shape_to_keyframe(shape, 0.0);
|
||||
let layer_id = document.root_mut().add_child(AnyLayer::Vector(layer));
|
||||
|
||||
// Create transform action
|
||||
let old_transform = Transform::with_position(10.0, 20.0);
|
||||
let new_transform = Transform::with_position(100.0, 200.0);
|
||||
let mut transforms = HashMap::new();
|
||||
transforms.insert(instance_id, (old_transform, new_transform));
|
||||
transforms.insert(shape_id, (old_transform, new_transform));
|
||||
|
||||
let mut action = TransformShapeInstancesAction::new(layer_id, transforms);
|
||||
|
||||
// Execute
|
||||
let mut action = TransformShapeInstancesAction::new(layer_id, 0.0, transforms);
|
||||
action.execute(&mut document).unwrap();
|
||||
|
||||
// Verify transform 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, 100.0);
|
||||
assert_eq!(obj.transform.y, 200.0);
|
||||
} else {
|
||||
panic!("Layer not found");
|
||||
if let Some(AnyLayer::Vector(vl)) = document.get_layer(&layer_id) {
|
||||
let s = vl.get_shape_in_keyframe(&shape_id, 0.0).unwrap();
|
||||
assert_eq!(s.transform.x, 100.0);
|
||||
assert_eq!(s.transform.y, 200.0);
|
||||
}
|
||||
|
||||
// Rollback
|
||||
action.rollback(&mut document).unwrap();
|
||||
|
||||
// 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);
|
||||
assert_eq!(obj.transform.y, 20.0);
|
||||
} else {
|
||||
panic!("Layer not found");
|
||||
if let Some(AnyLayer::Vector(vl)) = document.get_layer(&layer_id) {
|
||||
let s = vl.get_shape_in_keyframe(&shape_id, 0.0).unwrap();
|
||||
assert_eq!(s.transform.x, 10.0);
|
||||
assert_eq!(s.transform.y, 20.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_transform_shape_instance_rotation_scale() {
|
||||
let mut document = Document::new("Test");
|
||||
let mut layer = VectorLayer::new("Test Layer");
|
||||
|
||||
let shape_id = Uuid::new_v4();
|
||||
let instance_id = Uuid::new_v4();
|
||||
let mut instance = ShapeInstance::new(shape_id);
|
||||
instance.id = instance_id;
|
||||
instance.transform.rotation = 0.0;
|
||||
instance.transform.scale_x = 1.0;
|
||||
instance.transform.scale_y = 1.0;
|
||||
layer.add_object(instance);
|
||||
|
||||
let layer_id = document.root_mut().add_child(AnyLayer::Vector(layer));
|
||||
|
||||
// Create transform with rotation and scale
|
||||
let mut old_transform = Transform::new();
|
||||
let mut new_transform = Transform::new();
|
||||
new_transform.rotation = 90.0;
|
||||
new_transform.scale_x = 2.0;
|
||||
new_transform.scale_y = 0.5;
|
||||
|
||||
let mut transforms = HashMap::new();
|
||||
transforms.insert(instance_id, (old_transform, new_transform));
|
||||
|
||||
let mut action = TransformShapeInstancesAction::new(layer_id, transforms);
|
||||
action.execute(&mut document).unwrap();
|
||||
|
||||
// Verify
|
||||
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, 90.0);
|
||||
assert_eq!(obj.transform.scale_x, 2.0);
|
||||
assert_eq!(obj.transform.scale_y, 0.5);
|
||||
} else {
|
||||
panic!("Layer not found");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_transform_multiple_shape_instances() {
|
||||
let mut document = Document::new("Test");
|
||||
let mut layer = VectorLayer::new("Test Layer");
|
||||
|
||||
let shape_id = Uuid::new_v4();
|
||||
let instance1_id = Uuid::new_v4();
|
||||
let instance2_id = Uuid::new_v4();
|
||||
|
||||
let mut instance1 = ShapeInstance::new(shape_id);
|
||||
instance1.id = instance1_id;
|
||||
instance1.transform = Transform::with_position(0.0, 0.0);
|
||||
|
||||
let mut instance2 = ShapeInstance::new(shape_id);
|
||||
instance2.id = instance2_id;
|
||||
instance2.transform = Transform::with_position(50.0, 50.0);
|
||||
|
||||
layer.add_object(instance1);
|
||||
layer.add_object(instance2);
|
||||
|
||||
let layer_id = document.root_mut().add_child(AnyLayer::Vector(layer));
|
||||
|
||||
// Transform both
|
||||
let mut transforms = HashMap::new();
|
||||
transforms.insert(
|
||||
instance1_id,
|
||||
(Transform::with_position(0.0, 0.0), Transform::with_position(10.0, 10.0)),
|
||||
);
|
||||
transforms.insert(
|
||||
instance2_id,
|
||||
(Transform::with_position(50.0, 50.0), Transform::with_position(60.0, 60.0)),
|
||||
);
|
||||
|
||||
let mut action = TransformShapeInstancesAction::new(layer_id, transforms);
|
||||
action.execute(&mut document).unwrap();
|
||||
|
||||
// Verify both transformed
|
||||
if let Some(AnyLayer::Vector(vl)) = document.get_layer_mut(&layer_id) {
|
||||
let obj1 = vl.get_object(&instance1_id).unwrap();
|
||||
assert_eq!(obj1.transform.x, 10.0);
|
||||
assert_eq!(obj1.transform.y, 10.0);
|
||||
|
||||
let obj2 = vl.get_object(&instance2_id).unwrap();
|
||||
assert_eq!(obj2.transform.x, 60.0);
|
||||
assert_eq!(obj2.transform.y, 60.0);
|
||||
} else {
|
||||
panic!("Layer not found");
|
||||
}
|
||||
|
||||
// Rollback
|
||||
action.rollback(&mut document).unwrap();
|
||||
|
||||
// Verify both restored
|
||||
if let Some(AnyLayer::Vector(vl)) = document.get_layer_mut(&layer_id) {
|
||||
let obj1 = vl.get_object(&instance1_id).unwrap();
|
||||
assert_eq!(obj1.transform.x, 0.0);
|
||||
assert_eq!(obj1.transform.y, 0.0);
|
||||
|
||||
let obj2 = vl.get_object(&instance2_id).unwrap();
|
||||
assert_eq!(obj2.transform.x, 50.0);
|
||||
assert_eq!(obj2.transform.y, 50.0);
|
||||
} else {
|
||||
panic!("Layer not found");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_transform_nonexistent_layer() {
|
||||
let mut document = Document::new("Test");
|
||||
let fake_layer_id = Uuid::new_v4();
|
||||
let instance_id = Uuid::new_v4();
|
||||
|
||||
let mut transforms = HashMap::new();
|
||||
transforms.insert(
|
||||
instance_id,
|
||||
(Transform::new(), Transform::with_position(10.0, 10.0)),
|
||||
);
|
||||
|
||||
let mut action = TransformShapeInstancesAction::new(fake_layer_id, transforms);
|
||||
|
||||
// Should not panic
|
||||
action.execute(&mut document).unwrap();
|
||||
action.rollback(&mut document).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_transform_nonexistent_instance() {
|
||||
let mut document = Document::new("Test");
|
||||
let layer = VectorLayer::new("Test Layer");
|
||||
let layer_id = document.root_mut().add_child(AnyLayer::Vector(layer));
|
||||
|
||||
let fake_instance_id = Uuid::new_v4();
|
||||
let mut transforms = HashMap::new();
|
||||
transforms.insert(
|
||||
fake_instance_id,
|
||||
(Transform::new(), Transform::with_position(10.0, 10.0)),
|
||||
);
|
||||
|
||||
let mut action = TransformShapeInstancesAction::new(layer_id, transforms);
|
||||
|
||||
// Should not panic - just silently skip nonexistent instance
|
||||
action.execute(&mut document).unwrap();
|
||||
action.rollback(&mut document).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_transform_on_non_vector_layer() {
|
||||
use crate::layer::AudioLayer;
|
||||
|
||||
let mut document = Document::new("Test");
|
||||
let layer = AudioLayer::new("Audio Layer");
|
||||
let layer_id = document.root_mut().add_child(AnyLayer::Audio(layer));
|
||||
|
||||
let instance_id = Uuid::new_v4();
|
||||
let mut transforms = HashMap::new();
|
||||
transforms.insert(
|
||||
instance_id,
|
||||
(Transform::new(), Transform::with_position(10.0, 10.0)),
|
||||
);
|
||||
|
||||
let mut action = TransformShapeInstancesAction::new(layer_id, transforms);
|
||||
|
||||
// Should not panic - action only operates on vector layers
|
||||
action.execute(&mut document).unwrap();
|
||||
action.rollback(&mut document).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_description() {
|
||||
let layer_id = Uuid::new_v4();
|
||||
|
||||
let mut transforms1 = HashMap::new();
|
||||
transforms1.insert(Uuid::new_v4(), (Transform::new(), Transform::new()));
|
||||
|
||||
let action1 = TransformShapeInstancesAction::new(layer_id, transforms1);
|
||||
assert_eq!(action1.description(), "Transform 1 shape instance(s)");
|
||||
|
||||
let mut transforms3 = HashMap::new();
|
||||
transforms3.insert(Uuid::new_v4(), (Transform::new(), Transform::new()));
|
||||
transforms3.insert(Uuid::new_v4(), (Transform::new(), Transform::new()));
|
||||
transforms3.insert(Uuid::new_v4(), (Transform::new(), Transform::new()));
|
||||
|
||||
let action3 = TransformShapeInstancesAction::new(layer_id, transforms3);
|
||||
assert_eq!(action3.description(), "Transform 3 shape instance(s)");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -523,4 +523,25 @@ impl AnimationData {
|
|||
.map(|curve| curve.eval(time))
|
||||
.unwrap_or(default)
|
||||
}
|
||||
|
||||
/// Evaluate the effective transform for a clip instance at a given time.
|
||||
/// Uses animation curves if they exist, falling back to the clip instance's base transform.
|
||||
pub fn eval_clip_instance_transform(
|
||||
&self,
|
||||
instance_id: uuid::Uuid,
|
||||
time: f64,
|
||||
base: &crate::object::Transform,
|
||||
base_opacity: f64,
|
||||
) -> (crate::object::Transform, f64) {
|
||||
let mut t = base.clone();
|
||||
t.x = self.eval(&AnimationTarget::Object { id: instance_id, property: TransformProperty::X }, time, base.x);
|
||||
t.y = self.eval(&AnimationTarget::Object { id: instance_id, property: TransformProperty::Y }, time, base.y);
|
||||
t.rotation = self.eval(&AnimationTarget::Object { id: instance_id, property: TransformProperty::Rotation }, time, base.rotation);
|
||||
t.scale_x = self.eval(&AnimationTarget::Object { id: instance_id, property: TransformProperty::ScaleX }, time, base.scale_x);
|
||||
t.scale_y = self.eval(&AnimationTarget::Object { id: instance_id, property: TransformProperty::ScaleY }, time, base.scale_y);
|
||||
t.skew_x = self.eval(&AnimationTarget::Object { id: instance_id, property: TransformProperty::SkewX }, time, base.skew_x);
|
||||
t.skew_y = self.eval(&AnimationTarget::Object { id: instance_id, property: TransformProperty::SkewY }, time, base.skew_y);
|
||||
let opacity = self.eval(&AnimationTarget::Object { id: instance_id, property: TransformProperty::Opacity }, time, base_opacity);
|
||||
(t, opacity)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -44,6 +44,12 @@ pub struct VectorClip {
|
|||
/// Nested layer hierarchy
|
||||
pub layers: LayerTree<AnyLayer>,
|
||||
|
||||
/// Whether this clip is a group (static collection) rather than an animated clip.
|
||||
/// Groups have their timeline extent determined by keyframe spans on the containing layer,
|
||||
/// not by their internal duration.
|
||||
#[serde(default)]
|
||||
pub is_group: bool,
|
||||
|
||||
/// Folder this clip belongs to (None = root of category)
|
||||
#[serde(default)]
|
||||
pub folder_id: Option<Uuid>,
|
||||
|
|
@ -59,6 +65,7 @@ impl VectorClip {
|
|||
height,
|
||||
duration,
|
||||
layers: LayerTree::new(),
|
||||
is_group: false,
|
||||
folder_id: None,
|
||||
}
|
||||
}
|
||||
|
|
@ -78,6 +85,7 @@ impl VectorClip {
|
|||
height,
|
||||
duration,
|
||||
layers: LayerTree::new(),
|
||||
is_group: false,
|
||||
folder_id: None,
|
||||
}
|
||||
}
|
||||
|
|
@ -100,23 +108,20 @@ impl VectorClip {
|
|||
for layer_node in self.layers.iter() {
|
||||
// Only process vector layers (skip other layer types)
|
||||
if let AnyLayer::Vector(vector_layer) = &layer_node.data {
|
||||
// Calculate bounds for all shape instances in this layer
|
||||
for shape_instance in &vector_layer.shape_instances {
|
||||
// Get the shape for this instance
|
||||
if let Some(shape) = vector_layer.shapes.get(&shape_instance.shape_id) {
|
||||
// Get the local bounding box of the shape's path
|
||||
let local_bbox = shape.path().bounding_box();
|
||||
// Calculate bounds for all shapes in the active keyframe
|
||||
for shape in vector_layer.shapes_at_time(clip_time) {
|
||||
// Get the local bounding box of the shape's path
|
||||
let local_bbox = shape.path().bounding_box();
|
||||
|
||||
// Apply the shape instance's transform (TODO: evaluate animations at clip_time)
|
||||
let instance_transform = shape_instance.to_affine();
|
||||
let transformed_bbox = instance_transform.transform_rect_bbox(local_bbox);
|
||||
// Apply the shape's transform
|
||||
let shape_transform = shape.transform.to_affine();
|
||||
let transformed_bbox = shape_transform.transform_rect_bbox(local_bbox);
|
||||
|
||||
// Union with combined bounds
|
||||
combined_bounds = Some(match combined_bounds {
|
||||
None => transformed_bbox,
|
||||
Some(existing) => existing.union(transformed_bbox),
|
||||
});
|
||||
}
|
||||
// Union with combined bounds
|
||||
combined_bounds = Some(match combined_bounds {
|
||||
None => transformed_bbox,
|
||||
Some(existing) => existing.union(transformed_bbox),
|
||||
});
|
||||
}
|
||||
|
||||
// Handle nested clip instances recursively
|
||||
|
|
@ -573,6 +578,12 @@ pub struct ClipInstance {
|
|||
/// Compatible with daw-backend's Clip.gain
|
||||
/// Default: 1.0
|
||||
pub gain: f32,
|
||||
|
||||
/// How far (in seconds) the looped content extends before timeline_start.
|
||||
/// When set, loop iterations are drawn/played before the content start.
|
||||
/// Default: None (no pre-loop)
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub loop_before: Option<f64>,
|
||||
}
|
||||
|
||||
impl ClipInstance {
|
||||
|
|
@ -590,6 +601,7 @@ impl ClipInstance {
|
|||
trim_end: None,
|
||||
playback_speed: 1.0,
|
||||
gain: 1.0,
|
||||
loop_before: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -607,6 +619,7 @@ impl ClipInstance {
|
|||
trim_end: None,
|
||||
playback_speed: 1.0,
|
||||
gain: 1.0,
|
||||
loop_before: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -683,6 +696,17 @@ impl ClipInstance {
|
|||
(end - self.trim_start).max(0.0)
|
||||
}
|
||||
|
||||
/// Get the effective start position on the timeline, accounting for loop_before.
|
||||
/// This is the left edge of the clip's visual extent.
|
||||
pub fn effective_start(&self) -> f64 {
|
||||
self.timeline_start - self.loop_before.unwrap_or(0.0)
|
||||
}
|
||||
|
||||
/// Get the total visual duration including both loop_before and effective_duration.
|
||||
pub fn total_duration(&self, clip_duration: f64) -> f64 {
|
||||
self.loop_before.unwrap_or(0.0) + self.effective_duration(clip_duration)
|
||||
}
|
||||
|
||||
/// Remap timeline time to clip content time
|
||||
///
|
||||
/// Takes a global timeline time and returns the corresponding time within this
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@
|
|||
|
||||
use crate::clip::{AudioClip, ClipInstance, ImageAsset, VectorClip, VideoClip};
|
||||
use crate::layer::{AudioLayerType, AnyLayer};
|
||||
use crate::object::ShapeInstance;
|
||||
use crate::shape::Shape;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
|
|
@ -71,12 +70,10 @@ pub enum ClipboardContent {
|
|||
/// Referenced image assets
|
||||
image_assets: Vec<(Uuid, ImageAsset)>,
|
||||
},
|
||||
/// Shapes and shape instances from a vector layer
|
||||
/// Shapes from a vector layer's keyframe
|
||||
Shapes {
|
||||
/// Shape definitions (id -> shape)
|
||||
shapes: Vec<(Uuid, Shape)>,
|
||||
/// Shape instances referencing the shapes above
|
||||
instances: Vec<ShapeInstance>,
|
||||
/// Shapes (with embedded transforms)
|
||||
shapes: Vec<Shape>,
|
||||
},
|
||||
}
|
||||
|
||||
|
|
@ -168,39 +165,22 @@ impl ClipboardContent {
|
|||
id_map,
|
||||
)
|
||||
}
|
||||
ClipboardContent::Shapes { shapes, instances } => {
|
||||
// Regenerate shape definition IDs
|
||||
let new_shapes: Vec<(Uuid, Shape)> = shapes
|
||||
ClipboardContent::Shapes { shapes } => {
|
||||
// Regenerate shape IDs
|
||||
let new_shapes: Vec<Shape> = shapes
|
||||
.iter()
|
||||
.map(|(old_id, shape)| {
|
||||
.map(|shape| {
|
||||
let new_id = Uuid::new_v4();
|
||||
id_map.insert(*old_id, new_id);
|
||||
id_map.insert(shape.id, new_id);
|
||||
let mut new_shape = shape.clone();
|
||||
new_shape.id = new_id;
|
||||
(new_id, new_shape)
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Regenerate instance IDs and remap shape_id references
|
||||
let new_instances: Vec<ShapeInstance> = instances
|
||||
.iter()
|
||||
.map(|inst| {
|
||||
let new_instance_id = Uuid::new_v4();
|
||||
id_map.insert(inst.id, new_instance_id);
|
||||
let mut new_inst = inst.clone();
|
||||
new_inst.id = new_instance_id;
|
||||
// Remap shape_id to new definition ID
|
||||
if let Some(new_shape_id) = id_map.get(&inst.shape_id) {
|
||||
new_inst.shape_id = *new_shape_id;
|
||||
}
|
||||
new_inst
|
||||
new_shape
|
||||
})
|
||||
.collect();
|
||||
|
||||
(
|
||||
ClipboardContent::Shapes {
|
||||
shapes: new_shapes,
|
||||
instances: new_instances,
|
||||
},
|
||||
id_map,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -604,15 +604,13 @@ impl Document {
|
|||
continue;
|
||||
}
|
||||
|
||||
// Calculate instance end time
|
||||
// Calculate instance extent (accounting for loop_before)
|
||||
let Some(clip_duration) = self.get_clip_duration(&instance.clip_id) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let instance_start = instance.timeline_start;
|
||||
let trim_start = instance.trim_start;
|
||||
let trim_end = instance.trim_end.unwrap_or(clip_duration);
|
||||
let instance_end = instance_start + (trim_end - trim_start);
|
||||
let instance_start = instance.effective_start();
|
||||
let instance_end = instance.timeline_start + instance.effective_duration(clip_duration);
|
||||
|
||||
// Check overlap: start_a < end_b AND start_b < end_a
|
||||
if start_time < instance_end && instance_start < end_time {
|
||||
|
|
@ -667,10 +665,8 @@ impl Document {
|
|||
}
|
||||
|
||||
if let Some(clip_dur) = self.get_clip_duration(&instance.clip_id) {
|
||||
let inst_start = instance.timeline_start;
|
||||
let trim_start = instance.trim_start;
|
||||
let trim_end = instance.trim_end.unwrap_or(clip_dur);
|
||||
let inst_end = inst_start + (trim_end - trim_start);
|
||||
let inst_start = instance.effective_start();
|
||||
let inst_end = instance.timeline_start + instance.effective_duration(clip_dur);
|
||||
occupied_ranges.push((inst_start, inst_end, instance.id));
|
||||
}
|
||||
}
|
||||
|
|
@ -762,8 +758,9 @@ impl Document {
|
|||
continue;
|
||||
}
|
||||
if let Some(dur) = self.get_clip_duration(&inst.clip_id) {
|
||||
let end = inst.timeline_start + (inst.trim_end.unwrap_or(dur) - inst.trim_start);
|
||||
non_group.push((inst.timeline_start, end));
|
||||
let start = inst.effective_start();
|
||||
let end = inst.timeline_start + inst.effective_duration(dur);
|
||||
non_group.push((start, end));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -828,10 +825,9 @@ impl Document {
|
|||
continue;
|
||||
}
|
||||
|
||||
// Calculate other clip's end time
|
||||
// Calculate other clip's extent (accounting for loop_before)
|
||||
if let Some(clip_duration) = self.get_clip_duration(&other.clip_id) {
|
||||
let trim_end = other.trim_end.unwrap_or(clip_duration);
|
||||
let other_end = other.timeline_start + (trim_end - other.trim_start);
|
||||
let other_end = other.timeline_start + other.effective_duration(clip_duration);
|
||||
|
||||
// If this clip is to the left and closer than current nearest
|
||||
if other_end <= current_timeline_start && other_end > nearest_end {
|
||||
|
|
@ -878,9 +874,10 @@ impl Document {
|
|||
continue;
|
||||
}
|
||||
|
||||
// If this clip is to the right and closer than current nearest
|
||||
if other.timeline_start >= current_end && other.timeline_start < nearest_start {
|
||||
nearest_start = other.timeline_start;
|
||||
// Use effective_start to account for loop_before on the other clip
|
||||
let other_start = other.effective_start();
|
||||
if other_start >= current_end && other_start < nearest_start {
|
||||
nearest_start = other_start;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -890,6 +887,48 @@ impl Document {
|
|||
(nearest_start - current_end).max(0.0) // Gap between our end and next clip's start
|
||||
}
|
||||
}
|
||||
/// Find the maximum amount we can extend loop_before to the left without overlapping.
|
||||
///
|
||||
/// Returns the max additional loop_before distance (from the current effective start).
|
||||
pub fn find_max_loop_extend_left(
|
||||
&self,
|
||||
layer_id: &Uuid,
|
||||
instance_id: &Uuid,
|
||||
current_effective_start: f64,
|
||||
) -> f64 {
|
||||
let Some(layer) = self.get_layer(layer_id) else {
|
||||
return current_effective_start;
|
||||
};
|
||||
|
||||
if matches!(layer, AnyLayer::Vector(_)) {
|
||||
return current_effective_start;
|
||||
}
|
||||
|
||||
let instances: &[ClipInstance] = match layer {
|
||||
AnyLayer::Audio(audio) => &audio.clip_instances,
|
||||
AnyLayer::Video(video) => &video.clip_instances,
|
||||
AnyLayer::Effect(effect) => &effect.clip_instances,
|
||||
AnyLayer::Vector(vector) => &vector.clip_instances,
|
||||
};
|
||||
|
||||
let mut nearest_end = 0.0;
|
||||
|
||||
for other in instances {
|
||||
if &other.id == instance_id {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(clip_duration) = self.get_clip_duration(&other.clip_id) {
|
||||
let other_end = other.timeline_start + other.effective_duration(clip_duration);
|
||||
|
||||
if other_end <= current_effective_start && other_end > nearest_end {
|
||||
nearest_end = other_end;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
current_effective_start - nearest_end
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@
|
|||
|
||||
use crate::clip::ClipInstance;
|
||||
use crate::layer::VectorLayer;
|
||||
use crate::object::ShapeInstance;
|
||||
use crate::shape::Shape;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
|
@ -22,35 +21,32 @@ pub enum HitResult {
|
|||
|
||||
/// Hit test a layer at a specific point
|
||||
///
|
||||
/// Tests objects in reverse order (front to back) and returns the first hit.
|
||||
/// Combines parent_transform with object transforms for hierarchical testing.
|
||||
/// Tests shapes in the active keyframe in reverse order (front to back) and returns the first hit.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `layer` - The vector layer to test
|
||||
/// * `time` - The current time (for keyframe lookup)
|
||||
/// * `point` - The point to test in screen/canvas space
|
||||
/// * `tolerance` - Additional tolerance in pixels for stroke hit testing
|
||||
/// * `parent_transform` - Transform from parent GraphicsObject(s)
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The UUID of the first object hit, or None if no hit
|
||||
/// The UUID of the first shape hit, or None if no hit
|
||||
pub fn hit_test_layer(
|
||||
layer: &VectorLayer,
|
||||
time: f64,
|
||||
point: Point,
|
||||
tolerance: f64,
|
||||
parent_transform: Affine,
|
||||
) -> Option<Uuid> {
|
||||
// Test objects in reverse order (back to front in Vec = front to back for hit testing)
|
||||
for object in layer.shape_instances.iter().rev() {
|
||||
// Get the shape for this object
|
||||
let shape = layer.get_shape(&object.shape_id)?;
|
||||
|
||||
// Combine parent transform with object transform
|
||||
let combined_transform = parent_transform * object.to_affine();
|
||||
// Test shapes in reverse order (front to back for hit testing)
|
||||
for shape in layer.shapes_at_time(time).iter().rev() {
|
||||
let combined_transform = parent_transform * shape.transform.to_affine();
|
||||
|
||||
if hit_test_shape(shape, point, tolerance, combined_transform) {
|
||||
return Some(object.id);
|
||||
return Some(shape.id);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -60,17 +56,6 @@ pub fn hit_test_layer(
|
|||
/// Hit test a single shape with a given transform
|
||||
///
|
||||
/// Tests if a point hits the shape, considering both fill and stroke.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `shape` - The shape to test
|
||||
/// * `point` - The point to test in screen/canvas space
|
||||
/// * `tolerance` - Additional tolerance in pixels for stroke hit testing
|
||||
/// * `transform` - The combined transform to apply to the shape
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// true if the point hits the shape, false otherwise
|
||||
pub fn hit_test_shape(
|
||||
shape: &Shape,
|
||||
point: Point,
|
||||
|
|
@ -78,7 +63,6 @@ pub fn hit_test_shape(
|
|||
transform: Affine,
|
||||
) -> bool {
|
||||
// Transform point to shape's local space
|
||||
// We need the inverse transform to go from screen space to shape space
|
||||
let inverse_transform = transform.inverse();
|
||||
let local_point = inverse_transform * point;
|
||||
|
||||
|
|
@ -93,11 +77,6 @@ pub fn hit_test_shape(
|
|||
if let Some(stroke_style) = &shape.stroke_style {
|
||||
let stroke_tolerance = stroke_style.width / 2.0 + tolerance;
|
||||
|
||||
// For stroke hit testing, we need to check if the point is within
|
||||
// stroke_tolerance distance of the path
|
||||
// kurbo's winding() method can be used, or we can check bounding box first
|
||||
|
||||
// Quick bounding box check with stroke tolerance
|
||||
let bbox = shape.path().bounding_box();
|
||||
let expanded_bbox = bbox.inflate(stroke_tolerance, stroke_tolerance);
|
||||
|
||||
|
|
@ -105,13 +84,6 @@ pub fn hit_test_shape(
|
|||
return false;
|
||||
}
|
||||
|
||||
// For more accurate stroke hit testing, we would need to:
|
||||
// 1. Stroke the path with the stroke width
|
||||
// 2. Check if the point is contained in the stroked outline
|
||||
// For now, we do a simpler bounding box check
|
||||
// TODO: Implement accurate stroke hit testing using kurbo's stroke functionality
|
||||
|
||||
// Simple approach: if within expanded bbox, consider it a hit for now
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
@ -120,82 +92,44 @@ pub fn hit_test_shape(
|
|||
|
||||
/// Hit test objects within a rectangle (for marquee selection)
|
||||
///
|
||||
/// Returns all objects whose bounding boxes intersect with the given rectangle.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `layer` - The vector layer to test
|
||||
/// * `rect` - The selection rectangle in screen/canvas space
|
||||
/// * `parent_transform` - Transform from parent GraphicsObject(s)
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Vector of UUIDs for all objects that intersect the rectangle
|
||||
/// Returns all shapes in the active keyframe whose bounding boxes intersect with the given rectangle.
|
||||
pub fn hit_test_objects_in_rect(
|
||||
layer: &VectorLayer,
|
||||
time: f64,
|
||||
rect: Rect,
|
||||
parent_transform: Affine,
|
||||
) -> Vec<Uuid> {
|
||||
let mut hits = Vec::new();
|
||||
|
||||
for object in &layer.shape_instances {
|
||||
// Get the shape for this object
|
||||
if let Some(shape) = layer.get_shape(&object.shape_id) {
|
||||
// Combine parent transform with object transform
|
||||
let combined_transform = parent_transform * object.to_affine();
|
||||
for shape in layer.shapes_at_time(time) {
|
||||
let combined_transform = parent_transform * shape.transform.to_affine();
|
||||
|
||||
// Get shape bounding box in local space
|
||||
let bbox = shape.path().bounding_box();
|
||||
// Get shape bounding box in local space
|
||||
let bbox = shape.path().bounding_box();
|
||||
|
||||
// Transform bounding box to screen space
|
||||
let transformed_bbox = combined_transform.transform_rect_bbox(bbox);
|
||||
// Transform bounding box to screen space
|
||||
let transformed_bbox = combined_transform.transform_rect_bbox(bbox);
|
||||
|
||||
// Check if rectangles intersect
|
||||
if rect.intersect(transformed_bbox).area() > 0.0 {
|
||||
hits.push(object.id);
|
||||
}
|
||||
// Check if rectangles intersect
|
||||
if rect.intersect(transformed_bbox).area() > 0.0 {
|
||||
hits.push(shape.id);
|
||||
}
|
||||
}
|
||||
|
||||
hits
|
||||
}
|
||||
|
||||
/// Get the bounding box of an object in screen space
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `object` - The object to get bounds for
|
||||
/// * `shape` - The shape definition
|
||||
/// * `parent_transform` - Transform from parent GraphicsObject(s)
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The bounding box in screen/canvas space
|
||||
pub fn get_object_bounds(
|
||||
object: &ShapeInstance,
|
||||
/// Get the bounding box of a shape in screen space
|
||||
pub fn get_shape_bounds(
|
||||
shape: &Shape,
|
||||
parent_transform: Affine,
|
||||
) -> Rect {
|
||||
let combined_transform = parent_transform * object.to_affine();
|
||||
let combined_transform = parent_transform * shape.transform.to_affine();
|
||||
let local_bbox = shape.path().bounding_box();
|
||||
combined_transform.transform_rect_bbox(local_bbox)
|
||||
}
|
||||
|
||||
/// Hit test a single clip instance with a given clip bounds
|
||||
///
|
||||
/// Tests if a point hits the clip instance's bounding box.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `clip_instance` - The clip instance to test
|
||||
/// * `clip_width` - The clip's width in pixels
|
||||
/// * `clip_height` - The clip's height in pixels
|
||||
/// * `point` - The point to test in screen/canvas space
|
||||
/// * `parent_transform` - Transform from parent layer/clip
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// true if the point hits the clip instance, false otherwise
|
||||
pub fn hit_test_clip_instance(
|
||||
clip_instance: &ClipInstance,
|
||||
clip_width: f64,
|
||||
|
|
@ -203,31 +137,13 @@ pub fn hit_test_clip_instance(
|
|||
point: Point,
|
||||
parent_transform: Affine,
|
||||
) -> bool {
|
||||
// Create bounding rectangle for the clip (top-left origin)
|
||||
let clip_rect = Rect::new(0.0, 0.0, clip_width, clip_height);
|
||||
|
||||
// Combine parent transform with clip instance transform
|
||||
let combined_transform = parent_transform * clip_instance.transform.to_affine();
|
||||
|
||||
// Transform the bounding rectangle to screen space
|
||||
let transformed_rect = combined_transform.transform_rect_bbox(clip_rect);
|
||||
|
||||
// Test if point is inside the transformed rectangle
|
||||
transformed_rect.contains(point)
|
||||
}
|
||||
|
||||
/// Get the bounding box of a clip instance in screen space
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `clip_instance` - The clip instance to get bounds for
|
||||
/// * `clip_width` - The clip's width in pixels
|
||||
/// * `clip_height` - The clip's height in pixels
|
||||
/// * `parent_transform` - Transform from parent layer/clip
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The bounding box in screen/canvas space
|
||||
pub fn get_clip_instance_bounds(
|
||||
clip_instance: &ClipInstance,
|
||||
clip_width: f64,
|
||||
|
|
@ -240,21 +156,6 @@ pub fn get_clip_instance_bounds(
|
|||
}
|
||||
|
||||
/// Hit test clip instances at a specific point
|
||||
///
|
||||
/// Tests clip instances in reverse order (front to back) and returns the first hit.
|
||||
/// Uses dynamic bounds calculation based on clip content and current time.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `clip_instances` - The clip instances to test
|
||||
/// * `document` - Document containing all clip definitions
|
||||
/// * `point` - The point to test in screen/canvas space
|
||||
/// * `parent_transform` - Transform from parent layer/clip
|
||||
/// * `timeline_time` - Current timeline time for evaluating animations
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The UUID of the first clip instance hit, or None if no hit
|
||||
pub fn hit_test_clip_instances(
|
||||
clip_instances: &[ClipInstance],
|
||||
document: &crate::document::Document,
|
||||
|
|
@ -262,27 +163,20 @@ pub fn hit_test_clip_instances(
|
|||
parent_transform: Affine,
|
||||
timeline_time: f64,
|
||||
) -> Option<Uuid> {
|
||||
// Test in reverse order (front to back)
|
||||
for clip_instance in clip_instances.iter().rev() {
|
||||
// Calculate clip-local time from timeline time
|
||||
// Apply timeline offset and playback speed, then add trim offset
|
||||
let clip_time = ((timeline_time - clip_instance.timeline_start) * clip_instance.playback_speed) + clip_instance.trim_start;
|
||||
|
||||
// Get dynamic clip bounds from content at this time
|
||||
let content_bounds = if let Some(vector_clip) = document.get_vector_clip(&clip_instance.clip_id) {
|
||||
vector_clip.calculate_content_bounds(document, clip_time)
|
||||
} else if let Some(video_clip) = document.get_video_clip(&clip_instance.clip_id) {
|
||||
Rect::new(0.0, 0.0, video_clip.width, video_clip.height)
|
||||
} else {
|
||||
// Clip not found or is audio (no spatial representation)
|
||||
continue;
|
||||
};
|
||||
|
||||
// Transform content bounds to screen space
|
||||
let clip_transform = parent_transform * clip_instance.transform.to_affine();
|
||||
let clip_bbox = clip_transform.transform_rect_bbox(content_bounds);
|
||||
|
||||
// Test if point is inside the transformed rectangle
|
||||
if clip_bbox.contains(point) {
|
||||
return Some(clip_instance.id);
|
||||
}
|
||||
|
|
@ -292,21 +186,6 @@ pub fn hit_test_clip_instances(
|
|||
}
|
||||
|
||||
/// Hit test clip instances within a rectangle (for marquee selection)
|
||||
///
|
||||
/// Returns all clip instances whose bounding boxes intersect with the given rectangle.
|
||||
/// Uses dynamic bounds calculation based on clip content and current time.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `clip_instances` - The clip instances to test
|
||||
/// * `document` - Document containing all clip definitions
|
||||
/// * `rect` - The selection rectangle in screen/canvas space
|
||||
/// * `parent_transform` - Transform from parent layer/clip
|
||||
/// * `timeline_time` - Current timeline time for evaluating animations
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Vector of UUIDs for all clip instances that intersect the rectangle
|
||||
pub fn hit_test_clip_instances_in_rect(
|
||||
clip_instances: &[ClipInstance],
|
||||
document: &crate::document::Document,
|
||||
|
|
@ -317,25 +196,19 @@ pub fn hit_test_clip_instances_in_rect(
|
|||
let mut hits = Vec::new();
|
||||
|
||||
for clip_instance in clip_instances {
|
||||
// Calculate clip-local time from timeline time
|
||||
// Apply timeline offset and playback speed, then add trim offset
|
||||
let clip_time = ((timeline_time - clip_instance.timeline_start) * clip_instance.playback_speed) + clip_instance.trim_start;
|
||||
|
||||
// Get dynamic clip bounds from content at this time
|
||||
let content_bounds = if let Some(vector_clip) = document.get_vector_clip(&clip_instance.clip_id) {
|
||||
vector_clip.calculate_content_bounds(document, clip_time)
|
||||
} else if let Some(video_clip) = document.get_video_clip(&clip_instance.clip_id) {
|
||||
Rect::new(0.0, 0.0, video_clip.width, video_clip.height)
|
||||
} else {
|
||||
// Clip not found or is audio (no spatial representation)
|
||||
continue;
|
||||
};
|
||||
|
||||
// Transform content bounds to screen space
|
||||
let clip_transform = parent_transform * clip_instance.transform.to_affine();
|
||||
let clip_bbox = clip_transform.transform_rect_bbox(content_bounds);
|
||||
|
||||
// Check if rectangles intersect
|
||||
if rect.intersect(clip_bbox).area() > 0.0 {
|
||||
hits.push(clip_instance.id);
|
||||
}
|
||||
|
|
@ -354,7 +227,7 @@ pub enum VectorEditHit {
|
|||
ControlPoint {
|
||||
shape_instance_id: Uuid,
|
||||
curve_index: usize,
|
||||
point_index: u8, // 1 or 2 (p1 or p2 of cubic bezier)
|
||||
point_index: u8,
|
||||
},
|
||||
/// Hit a vertex (anchor point)
|
||||
Vertex {
|
||||
|
|
@ -365,7 +238,7 @@ pub enum VectorEditHit {
|
|||
Curve {
|
||||
shape_instance_id: Uuid,
|
||||
curve_index: usize,
|
||||
parameter_t: f64, // Where on the curve (0.0 to 1.0)
|
||||
parameter_t: f64,
|
||||
},
|
||||
/// Hit shape fill
|
||||
Fill { shape_instance_id: Uuid },
|
||||
|
|
@ -374,13 +247,9 @@ pub enum VectorEditHit {
|
|||
/// Tolerances for vector editing hit testing (in screen pixels)
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct EditingHitTolerance {
|
||||
/// Tolerance for hitting control points
|
||||
pub control_point: f64,
|
||||
/// Tolerance for hitting vertices
|
||||
pub vertex: f64,
|
||||
/// Tolerance for hitting curves
|
||||
pub curve: f64,
|
||||
/// Tolerance for hitting fill (usually 0.0 for exact containment)
|
||||
pub fill: f64,
|
||||
}
|
||||
|
||||
|
|
@ -396,10 +265,6 @@ impl Default for EditingHitTolerance {
|
|||
}
|
||||
|
||||
impl EditingHitTolerance {
|
||||
/// Create tolerances scaled by zoom factor
|
||||
///
|
||||
/// When zoomed in, hit targets appear larger in screen pixels,
|
||||
/// so we divide by zoom to maintain consistent screen-space sizes.
|
||||
pub fn scaled_by_zoom(zoom: f64) -> Self {
|
||||
Self {
|
||||
control_point: 10.0 / zoom,
|
||||
|
|
@ -411,23 +276,9 @@ impl EditingHitTolerance {
|
|||
}
|
||||
|
||||
/// Hit test for vector editing with priority-based detection
|
||||
///
|
||||
/// Tests objects in reverse order (front to back) and returns the first hit.
|
||||
/// Priority order: Control points > Vertices > Curves > Fill
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `layer` - The vector layer to test
|
||||
/// * `point` - The point to test in screen/canvas space
|
||||
/// * `tolerance` - Hit tolerances for different element types
|
||||
/// * `parent_transform` - Transform from parent GraphicsObject(s)
|
||||
/// * `show_control_points` - Whether to test control points (BezierEdit tool)
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The first hit in priority order, or None if no hit
|
||||
pub fn hit_test_vector_editing(
|
||||
layer: &VectorLayer,
|
||||
time: f64,
|
||||
point: Point,
|
||||
tolerance: &EditingHitTolerance,
|
||||
parent_transform: Affine,
|
||||
|
|
@ -436,50 +287,38 @@ pub fn hit_test_vector_editing(
|
|||
use crate::bezpath_editing::extract_editable_curves;
|
||||
use vello::kurbo::{ParamCurve, ParamCurveNearest};
|
||||
|
||||
// Test objects in reverse order (front to back for hit testing)
|
||||
for object in layer.shape_instances.iter().rev() {
|
||||
// Get the shape for this object
|
||||
let shape = match layer.get_shape(&object.shape_id) {
|
||||
Some(s) => s,
|
||||
None => continue,
|
||||
};
|
||||
|
||||
// Combine parent transform with object transform
|
||||
let combined_transform = parent_transform * object.to_affine();
|
||||
// Test shapes in reverse order (front to back for hit testing)
|
||||
for shape in layer.shapes_at_time(time).iter().rev() {
|
||||
let combined_transform = parent_transform * shape.transform.to_affine();
|
||||
let inverse_transform = combined_transform.inverse();
|
||||
let local_point = inverse_transform * point;
|
||||
|
||||
// Calculate the scale factor to transform screen-space tolerances to local space
|
||||
// We need the inverse scale because we're in local space
|
||||
// Affine coefficients are [a, b, c, d, e, f] representing matrix [[a, c, e], [b, d, f]]
|
||||
let coeffs = combined_transform.as_coeffs();
|
||||
let scale_x = (coeffs[0].powi(2) + coeffs[1].powi(2)).sqrt();
|
||||
let scale_y = (coeffs[2].powi(2) + coeffs[3].powi(2)).sqrt();
|
||||
let avg_scale = (scale_x + scale_y) / 2.0;
|
||||
let local_tolerance_factor = 1.0 / avg_scale.max(0.001); // Avoid division by zero
|
||||
let local_tolerance_factor = 1.0 / avg_scale.max(0.001);
|
||||
|
||||
// Extract editable curves and vertices from the shape's path
|
||||
let editable = extract_editable_curves(shape.path());
|
||||
|
||||
// Priority 1: Control points (only in BezierEdit mode)
|
||||
if show_control_points {
|
||||
let local_cp_tolerance = tolerance.control_point * local_tolerance_factor;
|
||||
for (i, curve) in editable.curves.iter().enumerate() {
|
||||
// Test p1 (first control point)
|
||||
let dist_p1 = (curve.p1 - local_point).hypot();
|
||||
if dist_p1 < local_cp_tolerance {
|
||||
return Some(VectorEditHit::ControlPoint {
|
||||
shape_instance_id: object.id,
|
||||
shape_instance_id: shape.id,
|
||||
curve_index: i,
|
||||
point_index: 1,
|
||||
});
|
||||
}
|
||||
|
||||
// Test p2 (second control point)
|
||||
let dist_p2 = (curve.p2 - local_point).hypot();
|
||||
if dist_p2 < local_cp_tolerance {
|
||||
return Some(VectorEditHit::ControlPoint {
|
||||
shape_instance_id: object.id,
|
||||
shape_instance_id: shape.id,
|
||||
curve_index: i,
|
||||
point_index: 2,
|
||||
});
|
||||
|
|
@ -493,7 +332,7 @@ pub fn hit_test_vector_editing(
|
|||
let dist = (vertex.point - local_point).hypot();
|
||||
if dist < local_vertex_tolerance {
|
||||
return Some(VectorEditHit::Vertex {
|
||||
shape_instance_id: object.id,
|
||||
shape_instance_id: shape.id,
|
||||
vertex_index: i,
|
||||
});
|
||||
}
|
||||
|
|
@ -507,7 +346,7 @@ pub fn hit_test_vector_editing(
|
|||
let dist = (nearest_point - local_point).hypot();
|
||||
if dist < local_curve_tolerance {
|
||||
return Some(VectorEditHit::Curve {
|
||||
shape_instance_id: object.id,
|
||||
shape_instance_id: shape.id,
|
||||
curve_index: i,
|
||||
parameter_t: nearest.t,
|
||||
});
|
||||
|
|
@ -517,7 +356,7 @@ pub fn hit_test_vector_editing(
|
|||
// Priority 4: Fill
|
||||
if shape.fill_color.is_some() && shape.path().contains(local_point) {
|
||||
return Some(VectorEditHit::Fill {
|
||||
shape_instance_id: object.id,
|
||||
shape_instance_id: shape.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -535,21 +374,18 @@ mod tests {
|
|||
fn test_hit_test_simple_circle() {
|
||||
let mut layer = VectorLayer::new("Test Layer");
|
||||
|
||||
// Create a circle at (100, 100) with radius 50
|
||||
let circle = Circle::new((100.0, 100.0), 50.0);
|
||||
let path = circle.to_path(0.1);
|
||||
let shape = Shape::new(path).with_fill(ShapeColor::rgb(255, 0, 0));
|
||||
let shape_instance = ShapeInstance::new(shape.id);
|
||||
|
||||
layer.add_shape(shape);
|
||||
layer.add_object(shape_instance);
|
||||
layer.add_shape_to_keyframe(shape, 0.0);
|
||||
|
||||
// Test hit inside circle
|
||||
let hit = hit_test_layer(&layer, Point::new(100.0, 100.0), 0.0, Affine::IDENTITY);
|
||||
let hit = hit_test_layer(&layer, 0.0, Point::new(100.0, 100.0), 0.0, Affine::IDENTITY);
|
||||
assert!(hit.is_some());
|
||||
|
||||
// Test miss outside circle
|
||||
let miss = hit_test_layer(&layer, Point::new(200.0, 200.0), 0.0, Affine::IDENTITY);
|
||||
let miss = hit_test_layer(&layer, 0.0, Point::new(200.0, 200.0), 0.0, Affine::IDENTITY);
|
||||
assert!(miss.is_none());
|
||||
}
|
||||
|
||||
|
|
@ -557,23 +393,20 @@ mod tests {
|
|||
fn test_hit_test_with_transform() {
|
||||
let mut layer = VectorLayer::new("Test Layer");
|
||||
|
||||
// Create a circle at origin
|
||||
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(255, 0, 0));
|
||||
let shape = Shape::new(path)
|
||||
.with_fill(ShapeColor::rgb(255, 0, 0))
|
||||
.with_position(100.0, 100.0);
|
||||
|
||||
// Create shape instance with translation
|
||||
let shape_instance = ShapeInstance::new(shape.id).with_position(100.0, 100.0);
|
||||
|
||||
layer.add_shape(shape);
|
||||
layer.add_object(shape_instance);
|
||||
layer.add_shape_to_keyframe(shape, 0.0);
|
||||
|
||||
// Test hit at translated position
|
||||
let hit = hit_test_layer(&layer, Point::new(100.0, 100.0), 0.0, Affine::IDENTITY);
|
||||
let hit = hit_test_layer(&layer, 0.0, Point::new(100.0, 100.0), 0.0, Affine::IDENTITY);
|
||||
assert!(hit.is_some());
|
||||
|
||||
// Test miss at origin (where shape is defined, but object is translated)
|
||||
let miss = hit_test_layer(&layer, Point::new(0.0, 0.0), 0.0, Affine::IDENTITY);
|
||||
// Test miss at origin (where shape is defined, but transform moves it)
|
||||
let miss = hit_test_layer(&layer, 0.0, Point::new(0.0, 0.0), 0.0, Affine::IDENTITY);
|
||||
assert!(miss.is_none());
|
||||
}
|
||||
|
||||
|
|
@ -581,30 +414,23 @@ mod tests {
|
|||
fn test_marquee_selection() {
|
||||
let mut layer = VectorLayer::new("Test Layer");
|
||||
|
||||
// Create two circles
|
||||
let circle1 = Circle::new((50.0, 50.0), 20.0);
|
||||
let path1 = circle1.to_path(0.1);
|
||||
let shape1 = Shape::new(path1).with_fill(ShapeColor::rgb(255, 0, 0));
|
||||
let shape_instance1 = ShapeInstance::new(shape1.id);
|
||||
let shape1 = Shape::new(circle1.to_path(0.1)).with_fill(ShapeColor::rgb(255, 0, 0));
|
||||
|
||||
let circle2 = Circle::new((150.0, 150.0), 20.0);
|
||||
let path2 = circle2.to_path(0.1);
|
||||
let shape2 = Shape::new(path2).with_fill(ShapeColor::rgb(0, 255, 0));
|
||||
let shape_instance2 = ShapeInstance::new(shape2.id);
|
||||
let shape2 = Shape::new(circle2.to_path(0.1)).with_fill(ShapeColor::rgb(0, 255, 0));
|
||||
|
||||
layer.add_shape(shape1);
|
||||
layer.add_object(shape_instance1);
|
||||
layer.add_shape(shape2);
|
||||
layer.add_object(shape_instance2);
|
||||
layer.add_shape_to_keyframe(shape1, 0.0);
|
||||
layer.add_shape_to_keyframe(shape2, 0.0);
|
||||
|
||||
// Marquee that contains both circles
|
||||
let rect = Rect::new(0.0, 0.0, 200.0, 200.0);
|
||||
let hits = hit_test_objects_in_rect(&layer, rect, Affine::IDENTITY);
|
||||
let hits = hit_test_objects_in_rect(&layer, 0.0, rect, Affine::IDENTITY);
|
||||
assert_eq!(hits.len(), 2);
|
||||
|
||||
// Marquee that contains only first circle
|
||||
let rect = Rect::new(0.0, 0.0, 100.0, 100.0);
|
||||
let hits = hit_test_objects_in_rect(&layer, rect, Affine::IDENTITY);
|
||||
let hits = hit_test_objects_in_rect(&layer, 0.0, rect, Affine::IDENTITY);
|
||||
assert_eq!(hits.len(), 1);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -136,6 +136,59 @@ impl Layer {
|
|||
}
|
||||
}
|
||||
|
||||
/// Tween type between keyframes
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum TweenType {
|
||||
/// Hold shapes until next keyframe (no interpolation)
|
||||
None,
|
||||
/// Shape tween — morph geometry between keyframes
|
||||
Shape,
|
||||
}
|
||||
|
||||
impl Default for TweenType {
|
||||
fn default() -> Self {
|
||||
TweenType::None
|
||||
}
|
||||
}
|
||||
|
||||
/// A keyframe containing all shapes at a point in time
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct ShapeKeyframe {
|
||||
/// Time in seconds
|
||||
pub time: f64,
|
||||
/// All shapes at this keyframe
|
||||
pub shapes: Vec<Shape>,
|
||||
/// What happens between this keyframe and the next
|
||||
#[serde(default)]
|
||||
pub tween_after: TweenType,
|
||||
/// Clip instance IDs visible in this keyframe region.
|
||||
/// Groups are only rendered in regions where they appear in the left keyframe.
|
||||
#[serde(default)]
|
||||
pub clip_instance_ids: Vec<Uuid>,
|
||||
}
|
||||
|
||||
impl ShapeKeyframe {
|
||||
/// Create a new empty keyframe at a given time
|
||||
pub fn new(time: f64) -> Self {
|
||||
Self {
|
||||
time,
|
||||
shapes: Vec::new(),
|
||||
tween_after: TweenType::None,
|
||||
clip_instance_ids: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a keyframe with shapes
|
||||
pub fn with_shapes(time: f64, shapes: Vec<Shape>) -> Self {
|
||||
Self {
|
||||
time,
|
||||
shapes,
|
||||
tween_after: TweenType::None,
|
||||
clip_instance_ids: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Vector layer containing shapes and objects
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct VectorLayer {
|
||||
|
|
@ -148,6 +201,10 @@ pub struct VectorLayer {
|
|||
/// Shape instances (references to shapes with transforms)
|
||||
pub shape_instances: Vec<ShapeInstance>,
|
||||
|
||||
/// Shape keyframes (sorted by time) — replaces shapes/shape_instances
|
||||
#[serde(default)]
|
||||
pub keyframes: Vec<ShapeKeyframe>,
|
||||
|
||||
/// Clip instances (references to vector clips with transforms)
|
||||
/// VectorLayer can contain instances of VectorClips for nested compositions
|
||||
pub clip_instances: Vec<ClipInstance>,
|
||||
|
|
@ -230,6 +287,7 @@ impl VectorLayer {
|
|||
layer: Layer::new(LayerType::Vector, name),
|
||||
shapes: HashMap::new(),
|
||||
shape_instances: Vec::new(),
|
||||
keyframes: Vec::new(),
|
||||
clip_instances: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
|
@ -325,6 +383,176 @@ impl VectorLayer {
|
|||
f(object);
|
||||
}
|
||||
}
|
||||
|
||||
// === KEYFRAME METHODS ===
|
||||
|
||||
/// Find the keyframe at-or-before the given time
|
||||
pub fn keyframe_at(&self, time: f64) -> Option<&ShapeKeyframe> {
|
||||
// keyframes are sorted by time; find the last one <= time
|
||||
let idx = self.keyframes.partition_point(|kf| kf.time <= time);
|
||||
if idx > 0 {
|
||||
Some(&self.keyframes[idx - 1])
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Find the mutable keyframe at-or-before the given time
|
||||
pub fn keyframe_at_mut(&mut self, time: f64) -> Option<&mut ShapeKeyframe> {
|
||||
let idx = self.keyframes.partition_point(|kf| kf.time <= time);
|
||||
if idx > 0 {
|
||||
Some(&mut self.keyframes[idx - 1])
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Find the index of a keyframe at the exact time (within tolerance)
|
||||
pub fn keyframe_index_at_exact(&self, time: f64, tolerance: f64) -> Option<usize> {
|
||||
self.keyframes.iter().position(|kf| (kf.time - time).abs() < tolerance)
|
||||
}
|
||||
|
||||
/// Get shapes visible at a given time (from the keyframe at-or-before time)
|
||||
pub fn shapes_at_time(&self, time: f64) -> &[Shape] {
|
||||
match self.keyframe_at(time) {
|
||||
Some(kf) => &kf.shapes,
|
||||
None => &[],
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the duration of the keyframe span starting at-or-before `time`.
|
||||
/// Returns the time from `time` to the next keyframe, or to `fallback_end` if there is no next keyframe.
|
||||
pub fn keyframe_span_duration(&self, time: f64, fallback_end: f64) -> f64 {
|
||||
// Find the next keyframe after `time`
|
||||
let next_idx = self.keyframes.partition_point(|kf| kf.time <= time);
|
||||
let end = if next_idx < self.keyframes.len() {
|
||||
self.keyframes[next_idx].time
|
||||
} else {
|
||||
fallback_end
|
||||
};
|
||||
(end - time).max(0.0)
|
||||
}
|
||||
|
||||
/// Check if a clip instance (group) is visible at the given time.
|
||||
/// Returns true if the keyframe at-or-before `time` contains `clip_instance_id`.
|
||||
pub fn is_clip_instance_visible_at(&self, clip_instance_id: &Uuid, time: f64) -> bool {
|
||||
self.keyframe_at(time)
|
||||
.map_or(false, |kf| kf.clip_instance_ids.contains(clip_instance_id))
|
||||
}
|
||||
|
||||
/// Get the visibility end time for a group clip instance starting from `time`.
|
||||
/// A group is visible in regions bounded on the left by a keyframe that contains it
|
||||
/// and on the right by any keyframe. Walks forward through keyframe regions,
|
||||
/// extending as long as consecutive left-keyframes contain the clip instance.
|
||||
/// If the last containing keyframe has no next keyframe (no right border),
|
||||
/// the region is just one frame long.
|
||||
pub fn group_visibility_end(&self, clip_instance_id: &Uuid, time: f64, frame_duration: f64) -> f64 {
|
||||
let start_idx = self.keyframes.partition_point(|kf| kf.time <= time);
|
||||
// start_idx is the index of the first keyframe AFTER time
|
||||
// Walk forward: each keyframe that contains the group extends visibility to the NEXT keyframe
|
||||
for idx in start_idx..self.keyframes.len() {
|
||||
if !self.keyframes[idx].clip_instance_ids.contains(clip_instance_id) {
|
||||
// This keyframe doesn't contain the group — it's the right border of the last region
|
||||
return self.keyframes[idx].time;
|
||||
}
|
||||
// This keyframe contains the group — check if there's a next one to form a right border
|
||||
}
|
||||
// No more keyframes after the last containing one — last region is one frame
|
||||
if let Some(last_kf) = self.keyframes.last() {
|
||||
if last_kf.clip_instance_ids.contains(clip_instance_id) {
|
||||
return last_kf.time + frame_duration;
|
||||
}
|
||||
}
|
||||
time + frame_duration
|
||||
}
|
||||
|
||||
/// Get mutable shapes at a given time
|
||||
pub fn shapes_at_time_mut(&mut self, time: f64) -> Option<&mut Vec<Shape>> {
|
||||
self.keyframe_at_mut(time).map(|kf| &mut kf.shapes)
|
||||
}
|
||||
|
||||
/// Find a shape by ID within the keyframe active at the given time
|
||||
pub fn get_shape_in_keyframe(&self, shape_id: &Uuid, time: f64) -> Option<&Shape> {
|
||||
self.keyframe_at(time)
|
||||
.and_then(|kf| kf.shapes.iter().find(|s| &s.id == shape_id))
|
||||
}
|
||||
|
||||
/// Find a mutable shape by ID within the keyframe active at the given time
|
||||
pub fn get_shape_in_keyframe_mut(&mut self, shape_id: &Uuid, time: f64) -> Option<&mut Shape> {
|
||||
self.keyframe_at_mut(time)
|
||||
.and_then(|kf| kf.shapes.iter_mut().find(|s| &s.id == shape_id))
|
||||
}
|
||||
|
||||
/// Ensure a keyframe exists at the exact time, creating an empty one if needed.
|
||||
/// Returns a mutable reference to the keyframe.
|
||||
pub fn ensure_keyframe_at(&mut self, time: f64) -> &mut ShapeKeyframe {
|
||||
let tolerance = 0.001;
|
||||
if let Some(idx) = self.keyframe_index_at_exact(time, tolerance) {
|
||||
return &mut self.keyframes[idx];
|
||||
}
|
||||
// Insert in sorted position
|
||||
let insert_idx = self.keyframes.partition_point(|kf| kf.time < time);
|
||||
self.keyframes.insert(insert_idx, ShapeKeyframe::new(time));
|
||||
&mut self.keyframes[insert_idx]
|
||||
}
|
||||
|
||||
/// Insert a new keyframe at time by copying shapes from the active keyframe.
|
||||
/// Shape UUIDs are regenerated (no cross-keyframe identity).
|
||||
/// If a keyframe already exists at the exact time, does nothing and returns it.
|
||||
pub fn insert_keyframe_from_current(&mut self, time: f64) -> &mut ShapeKeyframe {
|
||||
let tolerance = 0.001;
|
||||
if let Some(idx) = self.keyframe_index_at_exact(time, tolerance) {
|
||||
return &mut self.keyframes[idx];
|
||||
}
|
||||
|
||||
// Clone shapes and clip instance IDs from the active keyframe
|
||||
let (cloned_shapes, cloned_clip_ids) = self
|
||||
.keyframe_at(time)
|
||||
.map(|kf| {
|
||||
let shapes: Vec<Shape> = kf.shapes
|
||||
.iter()
|
||||
.map(|s| {
|
||||
let mut new_shape = s.clone();
|
||||
new_shape.id = Uuid::new_v4();
|
||||
new_shape
|
||||
})
|
||||
.collect();
|
||||
let clip_ids = kf.clip_instance_ids.clone();
|
||||
(shapes, clip_ids)
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
let insert_idx = self.keyframes.partition_point(|kf| kf.time < time);
|
||||
let mut kf = ShapeKeyframe::with_shapes(time, cloned_shapes);
|
||||
kf.clip_instance_ids = cloned_clip_ids;
|
||||
self.keyframes.insert(insert_idx, kf);
|
||||
&mut self.keyframes[insert_idx]
|
||||
}
|
||||
|
||||
/// Add a shape to the keyframe at the given time.
|
||||
/// Creates a keyframe if none exists at that time.
|
||||
pub(crate) fn add_shape_to_keyframe(&mut self, shape: Shape, time: f64) {
|
||||
let kf = self.ensure_keyframe_at(time);
|
||||
kf.shapes.push(shape);
|
||||
}
|
||||
|
||||
/// Remove a shape from the keyframe at the given time.
|
||||
/// Returns the removed shape if found.
|
||||
pub(crate) fn remove_shape_from_keyframe(&mut self, shape_id: &Uuid, time: f64) -> Option<Shape> {
|
||||
let kf = self.keyframe_at_mut(time)?;
|
||||
let idx = kf.shapes.iter().position(|s| &s.id == shape_id)?;
|
||||
Some(kf.shapes.remove(idx))
|
||||
}
|
||||
|
||||
/// Remove a keyframe at the exact time (within tolerance).
|
||||
/// Returns the removed keyframe if found.
|
||||
pub(crate) fn remove_keyframe_at(&mut self, time: f64, tolerance: f64) -> Option<ShapeKeyframe> {
|
||||
if let Some(idx) = self.keyframe_index_at_exact(time, tolerance) {
|
||||
Some(self.keyframes.remove(idx))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Audio layer subtype - distinguishes sampled audio from MIDI
|
||||
|
|
|
|||
|
|
@ -261,7 +261,7 @@ pub fn render_layer_isolated(
|
|||
video_manager,
|
||||
skip_instance_id,
|
||||
);
|
||||
rendered.has_content = !vector_layer.shape_instances.is_empty()
|
||||
rendered.has_content = !vector_layer.shapes_at_time(time).is_empty()
|
||||
|| !vector_layer.clip_instances.is_empty();
|
||||
}
|
||||
AnyLayer::Audio(_) => {
|
||||
|
|
@ -462,6 +462,7 @@ fn render_clip_instance(
|
|||
animation_data: &crate::animation::AnimationData,
|
||||
image_cache: &mut ImageCache,
|
||||
video_manager: &std::sync::Arc<std::sync::Mutex<crate::video::VideoManager>>,
|
||||
group_end_time: Option<f64>,
|
||||
) {
|
||||
// Try to find the clip in the document's clip libraries
|
||||
// For now, only handle VectorClips (VideoClip and AudioClip rendering not yet implemented)
|
||||
|
|
@ -470,8 +471,18 @@ fn render_clip_instance(
|
|||
};
|
||||
|
||||
// Remap timeline time to clip's internal time
|
||||
let Some(clip_time) = clip_instance.remap_time(time, vector_clip.duration) else {
|
||||
return; // Clip instance not active at this time
|
||||
let clip_time = if vector_clip.is_group {
|
||||
// Groups are static — visible from timeline_start to the next keyframe boundary
|
||||
let end = group_end_time.unwrap_or(clip_instance.timeline_start);
|
||||
if time < clip_instance.timeline_start || time >= end {
|
||||
return;
|
||||
}
|
||||
0.0
|
||||
} else {
|
||||
let Some(t) = clip_instance.remap_time(time, vector_clip.duration) else {
|
||||
return; // Clip instance not active at this time
|
||||
};
|
||||
t
|
||||
};
|
||||
|
||||
// Evaluate animated transform properties
|
||||
|
|
@ -777,131 +788,38 @@ fn render_vector_layer(
|
|||
|
||||
// Render clip instances first (they appear under shape instances)
|
||||
for clip_instance in &layer.clip_instances {
|
||||
render_clip_instance(document, time, clip_instance, layer_opacity, scene, base_transform, &layer.layer.animation_data, image_cache, video_manager);
|
||||
// For groups, compute the visibility end from keyframe data
|
||||
let group_end_time = document.vector_clips.get(&clip_instance.clip_id)
|
||||
.filter(|vc| vc.is_group)
|
||||
.map(|_| {
|
||||
let frame_duration = 1.0 / document.framerate;
|
||||
layer.group_visibility_end(&clip_instance.id, clip_instance.timeline_start, frame_duration)
|
||||
});
|
||||
render_clip_instance(document, time, clip_instance, layer_opacity, scene, base_transform, &layer.layer.animation_data, image_cache, video_manager, group_end_time);
|
||||
}
|
||||
|
||||
// Render each shape instance in the layer
|
||||
for shape_instance in &layer.shape_instances {
|
||||
// Skip this instance if it's being edited
|
||||
if Some(shape_instance.id) == skip_instance_id {
|
||||
// Render each shape in the active keyframe
|
||||
for shape in layer.shapes_at_time(time) {
|
||||
// Skip this shape if it's being edited
|
||||
if Some(shape.id) == skip_instance_id {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Get the shape for this instance
|
||||
let Some(shape) = layer.get_shape(&shape_instance.shape_id) else {
|
||||
continue;
|
||||
};
|
||||
// Use shape's transform directly (keyframe model — no animation evaluation)
|
||||
let x = shape.transform.x;
|
||||
let y = shape.transform.y;
|
||||
let rotation = shape.transform.rotation;
|
||||
let scale_x = shape.transform.scale_x;
|
||||
let scale_y = shape.transform.scale_y;
|
||||
let skew_x = shape.transform.skew_x;
|
||||
let skew_y = shape.transform.skew_y;
|
||||
let opacity = shape.opacity;
|
||||
|
||||
// Evaluate animated properties
|
||||
let transform = &shape_instance.transform;
|
||||
let x = layer
|
||||
.layer
|
||||
.animation_data
|
||||
.eval(
|
||||
&crate::animation::AnimationTarget::Object {
|
||||
id: shape_instance.id,
|
||||
property: TransformProperty::X,
|
||||
},
|
||||
time,
|
||||
transform.x,
|
||||
);
|
||||
let y = layer
|
||||
.layer
|
||||
.animation_data
|
||||
.eval(
|
||||
&crate::animation::AnimationTarget::Object {
|
||||
id: shape_instance.id,
|
||||
property: TransformProperty::Y,
|
||||
},
|
||||
time,
|
||||
transform.y,
|
||||
);
|
||||
let rotation = layer
|
||||
.layer
|
||||
.animation_data
|
||||
.eval(
|
||||
&crate::animation::AnimationTarget::Object {
|
||||
id: shape_instance.id,
|
||||
property: TransformProperty::Rotation,
|
||||
},
|
||||
time,
|
||||
transform.rotation,
|
||||
);
|
||||
let scale_x = layer
|
||||
.layer
|
||||
.animation_data
|
||||
.eval(
|
||||
&crate::animation::AnimationTarget::Object {
|
||||
id: shape_instance.id,
|
||||
property: TransformProperty::ScaleX,
|
||||
},
|
||||
time,
|
||||
transform.scale_x,
|
||||
);
|
||||
let scale_y = layer
|
||||
.layer
|
||||
.animation_data
|
||||
.eval(
|
||||
&crate::animation::AnimationTarget::Object {
|
||||
id: shape_instance.id,
|
||||
property: TransformProperty::ScaleY,
|
||||
},
|
||||
time,
|
||||
transform.scale_y,
|
||||
);
|
||||
let skew_x = layer
|
||||
.layer
|
||||
.animation_data
|
||||
.eval(
|
||||
&crate::animation::AnimationTarget::Object {
|
||||
id: shape_instance.id,
|
||||
property: TransformProperty::SkewX,
|
||||
},
|
||||
time,
|
||||
transform.skew_x,
|
||||
);
|
||||
let skew_y = layer
|
||||
.layer
|
||||
.animation_data
|
||||
.eval(
|
||||
&crate::animation::AnimationTarget::Object {
|
||||
id: shape_instance.id,
|
||||
property: TransformProperty::SkewY,
|
||||
},
|
||||
time,
|
||||
transform.skew_y,
|
||||
);
|
||||
let opacity = layer
|
||||
.layer
|
||||
.animation_data
|
||||
.eval(
|
||||
&crate::animation::AnimationTarget::Object {
|
||||
id: shape_instance.id,
|
||||
property: TransformProperty::Opacity,
|
||||
},
|
||||
time,
|
||||
shape_instance.opacity,
|
||||
);
|
||||
|
||||
// Check if shape has morphing animation
|
||||
let shape_index = layer
|
||||
.layer
|
||||
.animation_data
|
||||
.eval(
|
||||
&crate::animation::AnimationTarget::Shape {
|
||||
id: shape.id,
|
||||
property: crate::animation::ShapeProperty::ShapeIndex,
|
||||
},
|
||||
time,
|
||||
0.0,
|
||||
);
|
||||
|
||||
// Get the morphed path
|
||||
let path = shape.get_morphed_path(shape_index);
|
||||
// Get the path
|
||||
let path = shape.path();
|
||||
|
||||
// Build transform matrix (compose with base transform for camera)
|
||||
// Get shape center for skewing around center
|
||||
let shape_bbox = shape.path().bounding_box();
|
||||
let shape_bbox = path.bounding_box();
|
||||
let center_x = (shape_bbox.x0 + shape_bbox.x1) / 2.0;
|
||||
let center_y = (shape_bbox.y0 + shape_bbox.y1) / 2.0;
|
||||
|
||||
|
|
@ -921,7 +839,6 @@ fn render_vector_layer(
|
|||
Affine::IDENTITY
|
||||
};
|
||||
|
||||
// Skew around center: translate to origin, skew, translate back
|
||||
Affine::translate((center_x, center_y))
|
||||
* skew_x_affine
|
||||
* skew_y_affine
|
||||
|
|
@ -936,8 +853,7 @@ fn render_vector_layer(
|
|||
* skew_transform;
|
||||
let affine = base_transform * object_transform;
|
||||
|
||||
// Calculate final opacity (cascaded from parent → layer → shape instance)
|
||||
// layer_opacity already includes parent_opacity from render_vector_layer
|
||||
// Calculate final opacity (cascaded from parent → layer → shape)
|
||||
let final_opacity = (layer_opacity * opacity) as f32;
|
||||
|
||||
// Determine fill rule
|
||||
|
|
@ -953,12 +869,7 @@ fn render_vector_layer(
|
|||
if let Some(image_asset_id) = shape.image_fill {
|
||||
if let Some(image_asset) = document.get_image_asset(&image_asset_id) {
|
||||
if let Some(image) = image_cache.get_or_decode(image_asset) {
|
||||
// Apply opacity to image (clone is cheap - Image uses Arc<Blob> internally)
|
||||
let image_with_alpha = (*image).clone().with_alpha(final_opacity);
|
||||
|
||||
// The image is rendered as a fill for the shape path
|
||||
// Since the shape path is a rectangle matching the image dimensions,
|
||||
// the image should fill the shape perfectly
|
||||
scene.fill(fill_rule, affine, &image_with_alpha, None, &path);
|
||||
filled = true;
|
||||
}
|
||||
|
|
@ -968,7 +879,6 @@ fn render_vector_layer(
|
|||
// Fall back to color fill if no image fill (or image failed to load)
|
||||
if !filled {
|
||||
if let Some(fill_color) = &shape.fill_color {
|
||||
// Apply opacity to color
|
||||
let alpha = ((fill_color.a as f32 / 255.0) * final_opacity * 255.0) as u8;
|
||||
let adjusted_color = crate::shape::ShapeColor::rgba(
|
||||
fill_color.r,
|
||||
|
|
@ -990,7 +900,6 @@ fn render_vector_layer(
|
|||
// Render stroke if present
|
||||
if let (Some(stroke_color), Some(stroke_style)) = (&shape.stroke_color, &shape.stroke_style)
|
||||
{
|
||||
// Apply opacity to color
|
||||
let alpha = ((stroke_color.a as f32 / 255.0) * final_opacity * 255.0) as u8;
|
||||
let adjusted_color = crate::shape::ShapeColor::rgba(
|
||||
stroke_color.r,
|
||||
|
|
@ -1015,48 +924,11 @@ mod tests {
|
|||
use super::*;
|
||||
use crate::document::Document;
|
||||
use crate::layer::{AnyLayer, LayerTrait, VectorLayer};
|
||||
use crate::object::ShapeInstance;
|
||||
use crate::shape::{Shape, ShapeColor};
|
||||
use kurbo::{Circle, Shape as KurboShape};
|
||||
use vello::kurbo::{Circle, Shape as KurboShape};
|
||||
|
||||
#[test]
|
||||
fn test_render_empty_document() {
|
||||
let doc = Document::new("Test");
|
||||
let mut scene = Scene::new();
|
||||
let mut image_cache = ImageCache::new();
|
||||
|
||||
render_document(&doc, &mut scene, &mut image_cache);
|
||||
// Should render background without errors
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_render_document_with_shape() {
|
||||
let mut doc = Document::new("Test");
|
||||
|
||||
// Create a simple circle shape
|
||||
let circle = Circle::new((100.0, 100.0), 50.0);
|
||||
let path = circle.to_path(0.1);
|
||||
let shape = Shape::new(path).with_fill(ShapeColor::rgb(255, 0, 0));
|
||||
|
||||
// Create a shape instance for the shape
|
||||
let shape_instance = ShapeInstance::new(shape.id);
|
||||
|
||||
// Create a vector layer
|
||||
let mut vector_layer = VectorLayer::new("Layer 1");
|
||||
vector_layer.add_shape(shape);
|
||||
vector_layer.add_object(shape_instance);
|
||||
|
||||
// Add to document
|
||||
doc.root.add_child(AnyLayer::Vector(vector_layer));
|
||||
|
||||
// Render
|
||||
let mut scene = Scene::new();
|
||||
let mut image_cache = ImageCache::new();
|
||||
render_document(&doc, &mut scene, &mut image_cache);
|
||||
// Should render without errors
|
||||
}
|
||||
|
||||
// === Solo Rendering Tests ===
|
||||
// Note: render_document tests require video_manager and are omitted here.
|
||||
// The solo/visibility logic is tested via helpers.
|
||||
|
||||
/// Helper to check if any layer is soloed in document
|
||||
fn has_soloed_layer(doc: &Document) -> bool {
|
||||
|
|
@ -1081,79 +953,30 @@ mod tests {
|
|||
fn test_no_solo_all_layers_render() {
|
||||
let mut doc = Document::new("Test");
|
||||
|
||||
// Add two visible layers, neither soloed
|
||||
let layer1 = VectorLayer::new("Layer 1");
|
||||
let layer2 = VectorLayer::new("Layer 2");
|
||||
|
||||
doc.root.add_child(AnyLayer::Vector(layer1));
|
||||
doc.root.add_child(AnyLayer::Vector(layer2));
|
||||
|
||||
// Both should be rendered
|
||||
assert_eq!(has_soloed_layer(&doc), false);
|
||||
assert_eq!(count_layers_to_render(&doc), 2);
|
||||
|
||||
// Render should work without errors
|
||||
let mut scene = Scene::new();
|
||||
let mut image_cache = ImageCache::new();
|
||||
render_document(&doc, &mut scene, &mut image_cache);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_one_layer_soloed() {
|
||||
let mut doc = Document::new("Test");
|
||||
|
||||
// Add two layers
|
||||
let mut layer1 = VectorLayer::new("Layer 1");
|
||||
let layer2 = VectorLayer::new("Layer 2");
|
||||
|
||||
// Solo layer 1
|
||||
layer1.layer.soloed = true;
|
||||
|
||||
doc.root.add_child(AnyLayer::Vector(layer1));
|
||||
doc.root.add_child(AnyLayer::Vector(layer2));
|
||||
|
||||
// Only soloed layer should be rendered
|
||||
assert_eq!(has_soloed_layer(&doc), true);
|
||||
assert_eq!(count_layers_to_render(&doc), 1);
|
||||
|
||||
// Verify the soloed layer is the one that would render
|
||||
let any_soloed = has_soloed_layer(&doc);
|
||||
let soloed_count: usize = doc.visible_layers()
|
||||
.filter(|l| any_soloed && l.soloed())
|
||||
.count();
|
||||
assert_eq!(soloed_count, 1);
|
||||
|
||||
// Render should work
|
||||
let mut scene = Scene::new();
|
||||
let mut image_cache = ImageCache::new();
|
||||
render_document(&doc, &mut scene, &mut image_cache);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_multiple_layers_soloed() {
|
||||
let mut doc = Document::new("Test");
|
||||
|
||||
// Add three layers
|
||||
let mut layer1 = VectorLayer::new("Layer 1");
|
||||
let mut layer2 = VectorLayer::new("Layer 2");
|
||||
let layer3 = VectorLayer::new("Layer 3");
|
||||
|
||||
// Solo layers 1 and 2
|
||||
layer1.layer.soloed = true;
|
||||
layer2.layer.soloed = true;
|
||||
|
||||
doc.root.add_child(AnyLayer::Vector(layer1));
|
||||
doc.root.add_child(AnyLayer::Vector(layer2));
|
||||
doc.root.add_child(AnyLayer::Vector(layer3));
|
||||
|
||||
// Only soloed layers (1 and 2) should render
|
||||
assert_eq!(has_soloed_layer(&doc), true);
|
||||
assert_eq!(count_layers_to_render(&doc), 2);
|
||||
|
||||
// Render
|
||||
let mut scene = Scene::new();
|
||||
let mut image_cache = ImageCache::new();
|
||||
render_document(&doc, &mut scene, &mut image_cache);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -1162,90 +985,12 @@ mod tests {
|
|||
|
||||
let layer1 = VectorLayer::new("Layer 1");
|
||||
let mut layer2 = VectorLayer::new("Layer 2");
|
||||
|
||||
// Hide layer 2
|
||||
layer2.layer.visible = false;
|
||||
|
||||
doc.root.add_child(AnyLayer::Vector(layer1));
|
||||
doc.root.add_child(AnyLayer::Vector(layer2));
|
||||
|
||||
// Only visible layer (1) should be considered
|
||||
assert_eq!(doc.visible_layers().count(), 1);
|
||||
|
||||
// Render
|
||||
let mut scene = Scene::new();
|
||||
let mut image_cache = ImageCache::new();
|
||||
render_document(&doc, &mut scene, &mut image_cache);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_hidden_but_soloed_layer() {
|
||||
// A hidden layer that is soloed shouldn't render
|
||||
// because visible_layers() filters out hidden layers first
|
||||
let mut doc = Document::new("Test");
|
||||
|
||||
let layer1 = VectorLayer::new("Layer 1");
|
||||
let mut layer2 = VectorLayer::new("Layer 2");
|
||||
|
||||
// Layer 2: soloed but hidden
|
||||
layer2.layer.soloed = true;
|
||||
layer2.layer.visible = false;
|
||||
|
||||
doc.root.add_child(AnyLayer::Vector(layer1));
|
||||
doc.root.add_child(AnyLayer::Vector(layer2));
|
||||
|
||||
// visible_layers only returns layer 1 (layer 2 is hidden)
|
||||
// Since layer 1 isn't soloed and no visible layers are soloed,
|
||||
// all visible layers render
|
||||
let any_soloed = has_soloed_layer(&doc);
|
||||
assert_eq!(any_soloed, false); // No *visible* layer is soloed
|
||||
|
||||
// Both visible layers render (only 1 is visible)
|
||||
assert_eq!(count_layers_to_render(&doc), 1);
|
||||
|
||||
// Render
|
||||
let mut scene = Scene::new();
|
||||
let mut image_cache = ImageCache::new();
|
||||
render_document(&doc, &mut scene, &mut image_cache);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_solo_with_layer_opacity() {
|
||||
let mut doc = Document::new("Test");
|
||||
|
||||
// Create layers with different opacities
|
||||
let mut layer1 = VectorLayer::new("Layer 1");
|
||||
let mut layer2 = VectorLayer::new("Layer 2");
|
||||
|
||||
layer1.layer.opacity = 0.5;
|
||||
layer1.layer.soloed = true;
|
||||
|
||||
layer2.layer.opacity = 0.8;
|
||||
|
||||
// Add circle shapes for visible rendering
|
||||
let circle = Circle::new((50.0, 50.0), 20.0);
|
||||
let path = circle.to_path(0.1);
|
||||
let shape = Shape::new(path).with_fill(ShapeColor::rgb(255, 0, 0));
|
||||
let shape_instance = ShapeInstance::new(shape.id);
|
||||
layer1.add_shape(shape.clone());
|
||||
layer1.add_object(shape_instance);
|
||||
|
||||
let shape2 = Shape::new(circle.to_path(0.1)).with_fill(ShapeColor::rgb(0, 255, 0));
|
||||
let shape_instance2 = ShapeInstance::new(shape2.id);
|
||||
layer2.add_shape(shape2);
|
||||
layer2.add_object(shape_instance2);
|
||||
|
||||
doc.root.add_child(AnyLayer::Vector(layer1));
|
||||
doc.root.add_child(AnyLayer::Vector(layer2));
|
||||
|
||||
// Only layer 1 (soloed with 0.5 opacity) should render
|
||||
assert_eq!(has_soloed_layer(&doc), true);
|
||||
assert_eq!(count_layers_to_render(&doc), 1);
|
||||
|
||||
// Render
|
||||
let mut scene = Scene::new();
|
||||
let mut image_cache = ImageCache::new();
|
||||
render_document(&doc, &mut scene, &mut image_cache);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -1253,23 +998,18 @@ mod tests {
|
|||
let mut doc = Document::new("Test");
|
||||
|
||||
let mut layer1 = VectorLayer::new("Layer 1");
|
||||
let mut layer2 = VectorLayer::new("Layer 2");
|
||||
|
||||
// First, solo layer 1
|
||||
layer1.layer.soloed = true;
|
||||
|
||||
let id1 = doc.root.add_child(AnyLayer::Vector(layer1));
|
||||
let id2 = doc.root.add_child(AnyLayer::Vector(layer2));
|
||||
doc.root.add_child(AnyLayer::Vector(VectorLayer::new("Layer 2")));
|
||||
|
||||
// Only 1 layer renders when soloed
|
||||
assert_eq!(count_layers_to_render(&doc), 1);
|
||||
|
||||
// Now unsolo layer 1
|
||||
if let Some(layer) = doc.root.get_child_mut(&id1) {
|
||||
layer.set_soloed(false);
|
||||
}
|
||||
|
||||
// Now both should render again
|
||||
assert_eq!(has_soloed_layer(&doc), false);
|
||||
assert_eq!(count_layers_to_render(&doc), 2);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
//! 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};
|
||||
|
|
@ -235,6 +236,22 @@ pub struct Shape {
|
|||
|
||||
/// 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 {
|
||||
|
|
@ -248,6 +265,9 @@ impl Shape {
|
|||
fill_rule: FillRule::NonZero,
|
||||
stroke_color: None,
|
||||
stroke_style: None,
|
||||
transform: Transform::default(),
|
||||
opacity: 1.0,
|
||||
name: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -261,6 +281,9 @@ impl Shape {
|
|||
fill_rule: FillRule::NonZero,
|
||||
stroke_color: None,
|
||||
stroke_style: None,
|
||||
transform: Transform::default(),
|
||||
opacity: 1.0,
|
||||
name: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -326,6 +349,31 @@ impl Shape {
|
|||
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
|
||||
|
|
|
|||
|
|
@ -0,0 +1,227 @@
|
|||
//! Custom cursor system
|
||||
//!
|
||||
//! Provides SVG-based custom cursors beyond egui's built-in system cursors.
|
||||
//! When a custom cursor is active, the system cursor is hidden and the SVG
|
||||
//! cursor image is drawn at the pointer position.
|
||||
|
||||
use eframe::egui;
|
||||
use egui::TextureHandle;
|
||||
use lightningbeam_core::tool::Tool;
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// Custom cursor identifiers
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum CustomCursor {
|
||||
// Stage tool cursors
|
||||
Select,
|
||||
Draw,
|
||||
Transform,
|
||||
Rectangle,
|
||||
Ellipse,
|
||||
PaintBucket,
|
||||
Eyedropper,
|
||||
Line,
|
||||
Polygon,
|
||||
BezierEdit,
|
||||
Text,
|
||||
// Timeline cursors
|
||||
LoopExtend,
|
||||
}
|
||||
|
||||
impl CustomCursor {
|
||||
/// Convert a Tool enum to the corresponding custom cursor
|
||||
pub fn from_tool(tool: Tool) -> Self {
|
||||
match tool {
|
||||
Tool::Select => CustomCursor::Select,
|
||||
Tool::Draw => CustomCursor::Draw,
|
||||
Tool::Transform => CustomCursor::Transform,
|
||||
Tool::Rectangle => CustomCursor::Rectangle,
|
||||
Tool::Ellipse => CustomCursor::Ellipse,
|
||||
Tool::PaintBucket => CustomCursor::PaintBucket,
|
||||
Tool::Eyedropper => CustomCursor::Eyedropper,
|
||||
Tool::Line => CustomCursor::Line,
|
||||
Tool::Polygon => CustomCursor::Polygon,
|
||||
Tool::BezierEdit => CustomCursor::BezierEdit,
|
||||
Tool::Text => CustomCursor::Text,
|
||||
}
|
||||
}
|
||||
|
||||
/// Hotspot offset — the "click point" relative to the image top-left
|
||||
pub fn hotspot(&self) -> egui::Vec2 {
|
||||
match self {
|
||||
// Select cursor: pointer tip at top-left
|
||||
CustomCursor::Select => egui::vec2(3.0, 1.0),
|
||||
// Drawing tools: tip at bottom-left
|
||||
CustomCursor::Draw => egui::vec2(1.0, 23.0),
|
||||
// Transform: center
|
||||
CustomCursor::Transform => egui::vec2(12.0, 12.0),
|
||||
// Shape tools: crosshair at center
|
||||
CustomCursor::Rectangle
|
||||
| CustomCursor::Ellipse
|
||||
| CustomCursor::Line
|
||||
| CustomCursor::Polygon => egui::vec2(12.0, 12.0),
|
||||
// Paint bucket: tip at bottom-left
|
||||
CustomCursor::PaintBucket => egui::vec2(2.0, 21.0),
|
||||
// Eyedropper: tip at bottom
|
||||
CustomCursor::Eyedropper => egui::vec2(4.0, 22.0),
|
||||
// Bezier edit: tip at top-left
|
||||
CustomCursor::BezierEdit => egui::vec2(3.0, 1.0),
|
||||
// Text: I-beam center
|
||||
CustomCursor::Text => egui::vec2(12.0, 12.0),
|
||||
// Loop extend: center of circular arrow
|
||||
CustomCursor::LoopExtend => egui::vec2(12.0, 12.0),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the embedded SVG data for this cursor
|
||||
fn svg_data(&self) -> &'static [u8] {
|
||||
match self {
|
||||
CustomCursor::Select => include_bytes!("../../../src/assets/select.svg"),
|
||||
CustomCursor::Draw => include_bytes!("../../../src/assets/draw.svg"),
|
||||
CustomCursor::Transform => include_bytes!("../../../src/assets/transform.svg"),
|
||||
CustomCursor::Rectangle => include_bytes!("../../../src/assets/rectangle.svg"),
|
||||
CustomCursor::Ellipse => include_bytes!("../../../src/assets/ellipse.svg"),
|
||||
CustomCursor::PaintBucket => include_bytes!("../../../src/assets/paint_bucket.svg"),
|
||||
CustomCursor::Eyedropper => include_bytes!("../../../src/assets/eyedropper.svg"),
|
||||
CustomCursor::Line => include_bytes!("../../../src/assets/line.svg"),
|
||||
CustomCursor::Polygon => include_bytes!("../../../src/assets/polygon.svg"),
|
||||
CustomCursor::BezierEdit => include_bytes!("../../../src/assets/bezier_edit.svg"),
|
||||
CustomCursor::Text => include_bytes!("../../../src/assets/text.svg"),
|
||||
CustomCursor::LoopExtend => include_bytes!("../../../src/assets/arrow-counterclockwise.svg"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Cache of rasterized cursor textures (black fill + white outline version)
|
||||
pub struct CursorCache {
|
||||
/// Black cursor for the main image
|
||||
textures: HashMap<CustomCursor, TextureHandle>,
|
||||
/// White cursor for the outline
|
||||
outline_textures: HashMap<CustomCursor, TextureHandle>,
|
||||
}
|
||||
|
||||
impl CursorCache {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
textures: HashMap::new(),
|
||||
outline_textures: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get or lazily load the black (fill) cursor texture
|
||||
pub fn get_or_load(&mut self, cursor: CustomCursor, ctx: &egui::Context) -> &TextureHandle {
|
||||
self.textures.entry(cursor).or_insert_with(|| {
|
||||
let svg_data = cursor.svg_data();
|
||||
let svg_string = String::from_utf8_lossy(svg_data);
|
||||
let svg_with_color = svg_string.replace("currentColor", "#000000");
|
||||
rasterize_cursor_svg(svg_with_color.as_bytes(), &format!("cursor_{:?}", cursor), CURSOR_SIZE, ctx)
|
||||
.expect("Failed to rasterize cursor SVG")
|
||||
})
|
||||
}
|
||||
|
||||
/// Get or lazily load the white (outline) cursor texture
|
||||
pub fn get_or_load_outline(&mut self, cursor: CustomCursor, ctx: &egui::Context) -> &TextureHandle {
|
||||
self.outline_textures.entry(cursor).or_insert_with(|| {
|
||||
let svg_data = cursor.svg_data();
|
||||
let svg_string = String::from_utf8_lossy(svg_data);
|
||||
// Replace all colors with white for the outline
|
||||
let svg_white = svg_string
|
||||
.replace("currentColor", "#ffffff")
|
||||
.replace("#000000", "#ffffff")
|
||||
.replace("#000", "#ffffff");
|
||||
rasterize_cursor_svg(svg_white.as_bytes(), &format!("cursor_{:?}_outline", cursor), CURSOR_SIZE, ctx)
|
||||
.expect("Failed to rasterize cursor SVG outline")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const CURSOR_SIZE: u32 = 24;
|
||||
const OUTLINE_OFFSET: f32 = 1.0;
|
||||
|
||||
/// Rasterize an SVG into an egui texture (same approach as main.rs rasterize_svg)
|
||||
fn rasterize_cursor_svg(
|
||||
svg_data: &[u8],
|
||||
name: &str,
|
||||
render_size: u32,
|
||||
ctx: &egui::Context,
|
||||
) -> Option<TextureHandle> {
|
||||
let tree = resvg::usvg::Tree::from_data(svg_data, &resvg::usvg::Options::default()).ok()?;
|
||||
let pixmap_size = tree.size().to_int_size();
|
||||
let scale_x = render_size as f32 / pixmap_size.width() as f32;
|
||||
let scale_y = render_size as f32 / pixmap_size.height() as f32;
|
||||
let mut pixmap = resvg::tiny_skia::Pixmap::new(render_size, render_size)?;
|
||||
resvg::render(
|
||||
&tree,
|
||||
resvg::tiny_skia::Transform::from_scale(scale_x, scale_y),
|
||||
&mut pixmap.as_mut(),
|
||||
);
|
||||
let rgba_data = pixmap.data().to_vec();
|
||||
let color_image = egui::ColorImage::from_rgba_unmultiplied(
|
||||
[render_size as usize, render_size as usize],
|
||||
&rgba_data,
|
||||
);
|
||||
Some(ctx.load_texture(name, color_image, egui::TextureOptions::LINEAR))
|
||||
}
|
||||
|
||||
// --- Per-frame cursor slot using egui context data ---
|
||||
|
||||
/// Key for storing the active custom cursor in egui's per-frame data
|
||||
#[derive(Clone, Copy)]
|
||||
struct ActiveCustomCursor(CustomCursor);
|
||||
|
||||
/// Set the custom cursor for this frame. Call from any pane during rendering.
|
||||
/// This hides the system cursor and draws the SVG cursor at pointer position.
|
||||
pub fn set(ctx: &egui::Context, cursor: CustomCursor) {
|
||||
ctx.data_mut(|d| d.insert_temp(egui::Id::new("active_custom_cursor"), ActiveCustomCursor(cursor)));
|
||||
}
|
||||
|
||||
/// Render the custom cursor overlay. Call at the end of the main update loop.
|
||||
pub fn render_overlay(ctx: &egui::Context, cache: &mut CursorCache) {
|
||||
// Take and remove the cursor so it doesn't persist to the next frame
|
||||
let id = egui::Id::new("active_custom_cursor");
|
||||
let cursor = ctx.data_mut(|d| {
|
||||
let val = d.get_temp::<ActiveCustomCursor>(id);
|
||||
d.remove::<ActiveCustomCursor>(id);
|
||||
val
|
||||
});
|
||||
|
||||
if let Some(ActiveCustomCursor(cursor)) = cursor {
|
||||
// If a system cursor was explicitly set (resize handles, text inputs, etc.),
|
||||
// let it take priority over the custom cursor
|
||||
let system_cursor = ctx.output(|o| o.cursor_icon);
|
||||
if system_cursor != egui::CursorIcon::Default {
|
||||
return;
|
||||
}
|
||||
|
||||
// Hide the system cursor
|
||||
ctx.set_cursor_icon(egui::CursorIcon::None);
|
||||
|
||||
if let Some(pos) = ctx.input(|i| i.pointer.latest_pos()) {
|
||||
let hotspot = cursor.hotspot();
|
||||
let size = egui::vec2(CURSOR_SIZE as f32, CURSOR_SIZE as f32);
|
||||
let uv = egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0));
|
||||
let painter = ctx.debug_painter();
|
||||
|
||||
// Draw white outline: render white version offset in 8 directions
|
||||
let outline_tex = cache.get_or_load_outline(cursor, ctx);
|
||||
let outline_id = outline_tex.id();
|
||||
for &(dx, dy) in &[
|
||||
(-OUTLINE_OFFSET, 0.0), (OUTLINE_OFFSET, 0.0),
|
||||
(0.0, -OUTLINE_OFFSET), (0.0, OUTLINE_OFFSET),
|
||||
(-OUTLINE_OFFSET, -OUTLINE_OFFSET), (OUTLINE_OFFSET, -OUTLINE_OFFSET),
|
||||
(-OUTLINE_OFFSET, OUTLINE_OFFSET), (OUTLINE_OFFSET, OUTLINE_OFFSET),
|
||||
] {
|
||||
let offset_rect = egui::Rect::from_min_size(
|
||||
pos - hotspot + egui::vec2(dx, dy),
|
||||
size,
|
||||
);
|
||||
painter.image(outline_id, offset_rect, uv, egui::Color32::WHITE);
|
||||
}
|
||||
|
||||
// Draw black fill on top
|
||||
let fill_tex = cache.get_or_load(cursor, ctx);
|
||||
let cursor_rect = egui::Rect::from_min_size(pos - hotspot, size);
|
||||
painter.image(fill_tex.id(), cursor_rect, uv, egui::Color32::WHITE);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -431,7 +431,7 @@ mod tests {
|
|||
let mut settings = AudioExportSettings::default();
|
||||
settings.sample_rate = 0; // Invalid
|
||||
|
||||
let project = Project::new();
|
||||
let project = Project::new(44100);
|
||||
let pool = AudioPool::new();
|
||||
let midi_pool = MidiClipPool::new();
|
||||
let cancel_flag = Arc::new(AtomicBool::new(false));
|
||||
|
|
@ -452,7 +452,7 @@ mod tests {
|
|||
#[test]
|
||||
fn test_export_audio_cancellation() {
|
||||
let settings = AudioExportSettings::default();
|
||||
let mut project = Project::new();
|
||||
let mut project = Project::new(44100);
|
||||
let pool = AudioPool::new();
|
||||
let midi_pool = MidiClipPool::new();
|
||||
let cancel_flag = Arc::new(AtomicBool::new(true)); // Pre-cancelled
|
||||
|
|
|
|||
|
|
@ -38,8 +38,12 @@ mod notifications;
|
|||
mod effect_thumbnails;
|
||||
use effect_thumbnails::EffectThumbnailGenerator;
|
||||
|
||||
mod custom_cursor;
|
||||
mod debug_overlay;
|
||||
|
||||
mod sample_import;
|
||||
mod sample_import_dialog;
|
||||
|
||||
/// Lightningbeam Editor - Animation and video editing software
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(name = "Lightningbeam Editor")]
|
||||
|
|
@ -140,6 +144,34 @@ fn main() -> eframe::Result {
|
|||
|
||||
let options = eframe::NativeOptions {
|
||||
viewport: viewport_builder,
|
||||
wgpu_options: egui_wgpu::WgpuConfiguration {
|
||||
wgpu_setup: egui_wgpu::WgpuSetup::CreateNew(egui_wgpu::WgpuSetupCreateNew {
|
||||
device_descriptor: std::sync::Arc::new(|adapter| {
|
||||
let features = adapter.features();
|
||||
// Request SHADER_F16 if available — needed on Mesa/llvmpipe for vello's
|
||||
// unpack2x16float (enables the SHADER_F16_IN_F32 downlevel capability)
|
||||
let optional_features = wgpu::Features::SHADER_F16;
|
||||
|
||||
let base_limits = if adapter.get_info().backend == wgpu::Backend::Gl {
|
||||
wgpu::Limits::downlevel_webgl2_defaults()
|
||||
} else {
|
||||
wgpu::Limits::default()
|
||||
};
|
||||
|
||||
wgpu::DeviceDescriptor {
|
||||
label: Some("lightningbeam wgpu device"),
|
||||
required_features: features & optional_features,
|
||||
required_limits: wgpu::Limits {
|
||||
max_texture_dimension_2d: 8192,
|
||||
..base_limits
|
||||
},
|
||||
..Default::default()
|
||||
}
|
||||
}),
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
|
|
@ -701,6 +733,8 @@ struct EditorApp {
|
|||
/// GPU-rendered effect thumbnail generator
|
||||
effect_thumbnail_generator: Option<EffectThumbnailGenerator>,
|
||||
|
||||
/// Custom cursor cache for SVG cursors
|
||||
cursor_cache: custom_cursor::CursorCache,
|
||||
/// Debug overlay (F3) state
|
||||
debug_overlay_visible: bool,
|
||||
debug_stats_collector: debug_overlay::DebugStatsCollector,
|
||||
|
|
@ -895,6 +929,7 @@ impl EditorApp {
|
|||
effect_thumbnail_generator: None, // Initialized when GPU available
|
||||
|
||||
// Debug overlay (F3)
|
||||
cursor_cache: custom_cursor::CursorCache::new(),
|
||||
debug_overlay_visible: false,
|
||||
debug_stats_collector: debug_overlay::DebugStatsCollector::new(),
|
||||
gpu_info,
|
||||
|
|
@ -1531,31 +1566,17 @@ impl EditorApp {
|
|||
_ => return,
|
||||
};
|
||||
|
||||
// Gather selected shape instances and their shape definitions
|
||||
let selected_instances: Vec<_> = vector_layer
|
||||
.shape_instances
|
||||
.iter()
|
||||
.filter(|si| self.selection.contains_shape_instance(&si.id))
|
||||
.cloned()
|
||||
// Gather selected shapes (they now contain their own transforms)
|
||||
let selected_shapes: Vec<_> = self.selection.shapes().iter()
|
||||
.filter_map(|id| vector_layer.shapes.get(id).cloned())
|
||||
.collect();
|
||||
|
||||
if selected_instances.is_empty() {
|
||||
if selected_shapes.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut shapes = Vec::new();
|
||||
let mut seen_shape_ids = std::collections::HashSet::new();
|
||||
for inst in &selected_instances {
|
||||
if seen_shape_ids.insert(inst.shape_id) {
|
||||
if let Some(shape) = vector_layer.shapes.get(&inst.shape_id) {
|
||||
shapes.push((inst.shape_id, shape.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let content = ClipboardContent::Shapes {
|
||||
shapes,
|
||||
instances: selected_instances,
|
||||
shapes: selected_shapes,
|
||||
};
|
||||
|
||||
self.clipboard_manager.copy(content);
|
||||
|
|
@ -1606,47 +1627,18 @@ impl EditorApp {
|
|||
}
|
||||
|
||||
self.selection.clear_clip_instances();
|
||||
} else if !self.selection.shape_instances().is_empty() {
|
||||
} else if !self.selection.shapes().is_empty() {
|
||||
let active_layer_id = match self.active_layer_id {
|
||||
Some(id) => id,
|
||||
None => return,
|
||||
};
|
||||
|
||||
let document = self.action_executor.document();
|
||||
let layer = match document.get_layer(&active_layer_id) {
|
||||
Some(l) => l,
|
||||
None => return,
|
||||
};
|
||||
|
||||
let vector_layer = match layer {
|
||||
AnyLayer::Vector(vl) => vl,
|
||||
_ => return,
|
||||
};
|
||||
|
||||
// Collect shape instance IDs and their shape IDs
|
||||
let instance_ids: Vec<Uuid> = self.selection.shape_instances().to_vec();
|
||||
let mut shape_ids: Vec<Uuid> = Vec::new();
|
||||
let mut shape_id_set = std::collections::HashSet::new();
|
||||
|
||||
for inst in &vector_layer.shape_instances {
|
||||
if instance_ids.contains(&inst.id) {
|
||||
if shape_id_set.insert(inst.shape_id) {
|
||||
// Only remove shape definition if no other instances reference it
|
||||
let other_refs = vector_layer
|
||||
.shape_instances
|
||||
.iter()
|
||||
.any(|si| si.shape_id == inst.shape_id && !instance_ids.contains(&si.id));
|
||||
if !other_refs {
|
||||
shape_ids.push(inst.shape_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let shape_ids: Vec<Uuid> = self.selection.shapes().to_vec();
|
||||
|
||||
let action = lightningbeam_core::actions::RemoveShapesAction::new(
|
||||
active_layer_id,
|
||||
shape_ids,
|
||||
instance_ids,
|
||||
self.playback_time,
|
||||
);
|
||||
|
||||
if let Err(e) = self.action_executor.execute(Box::new(action)) {
|
||||
|
|
@ -1762,16 +1754,13 @@ impl EditorApp {
|
|||
self.selection.add_clip_instance(id);
|
||||
}
|
||||
}
|
||||
ClipboardContent::Shapes {
|
||||
shapes,
|
||||
instances,
|
||||
} => {
|
||||
ClipboardContent::Shapes { shapes } => {
|
||||
let active_layer_id = match self.active_layer_id {
|
||||
Some(id) => id,
|
||||
None => return,
|
||||
};
|
||||
|
||||
// Add shapes and instances to the active vector layer
|
||||
// Add shapes to the active vector layer's keyframe
|
||||
let document = self.action_executor.document_mut();
|
||||
let layer = match document.get_layer_mut(&active_layer_id) {
|
||||
Some(l) => l,
|
||||
|
|
@ -1786,19 +1775,17 @@ impl EditorApp {
|
|||
}
|
||||
};
|
||||
|
||||
let new_instance_ids: Vec<Uuid> = instances.iter().map(|i| i.id).collect();
|
||||
let new_shape_ids: Vec<Uuid> = shapes.iter().map(|s| s.id).collect();
|
||||
|
||||
for (id, shape) in shapes {
|
||||
vector_layer.shapes.insert(id, shape);
|
||||
}
|
||||
for inst in instances {
|
||||
vector_layer.shape_instances.push(inst);
|
||||
let kf = vector_layer.ensure_keyframe_at(self.playback_time);
|
||||
for shape in shapes {
|
||||
kf.shapes.push(shape);
|
||||
}
|
||||
|
||||
// Select pasted shapes
|
||||
self.selection.clear_shape_instances();
|
||||
for id in new_instance_ids {
|
||||
self.selection.add_shape_instance(id);
|
||||
self.selection.clear_shapes();
|
||||
for id in new_shape_ids {
|
||||
self.selection.add_shape(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -2233,8 +2220,26 @@ impl EditorApp {
|
|||
|
||||
// Modify menu
|
||||
MenuAction::Group => {
|
||||
println!("Menu: Group");
|
||||
// TODO: Implement group
|
||||
if let Some(layer_id) = self.active_layer_id {
|
||||
let shape_ids: Vec<uuid::Uuid> = self.selection.shape_instances().to_vec();
|
||||
let clip_ids: Vec<uuid::Uuid> = self.selection.clip_instances().to_vec();
|
||||
if shape_ids.len() + clip_ids.len() >= 2 {
|
||||
let instance_id = uuid::Uuid::new_v4();
|
||||
let action = lightningbeam_core::actions::GroupAction::new(
|
||||
layer_id,
|
||||
self.playback_time,
|
||||
shape_ids,
|
||||
clip_ids,
|
||||
instance_id,
|
||||
);
|
||||
if let Err(e) = self.action_executor.execute(Box::new(action)) {
|
||||
eprintln!("Failed to group: {}", e);
|
||||
} else {
|
||||
self.selection.clear();
|
||||
self.selection.add_clip_instance(instance_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
MenuAction::SendToBack => {
|
||||
println!("Menu: Send to Back");
|
||||
|
|
@ -2362,7 +2367,6 @@ impl EditorApp {
|
|||
use lightningbeam_core::clip::VectorClip;
|
||||
use lightningbeam_core::layer::{VectorLayer, AnyLayer};
|
||||
use lightningbeam_core::shape::{Shape, ShapeColor};
|
||||
use lightningbeam_core::object::ShapeInstance;
|
||||
use kurbo::{Circle, Rect, Shape as KurboShape};
|
||||
|
||||
// Generate unique name based on existing clip count
|
||||
|
|
@ -2378,19 +2382,16 @@ impl EditorApp {
|
|||
let circle_path = Circle::new((100.0, 100.0), 50.0).to_path(0.1);
|
||||
let mut circle_shape = Shape::new(circle_path);
|
||||
circle_shape.fill_color = Some(ShapeColor::rgb(255, 0, 0));
|
||||
let circle_id = circle_shape.id;
|
||||
layer.add_shape(circle_shape);
|
||||
|
||||
// Create a blue rectangle shape
|
||||
let rect_path = Rect::new(200.0, 50.0, 350.0, 150.0).to_path(0.1);
|
||||
let mut rect_shape = Shape::new(rect_path);
|
||||
rect_shape.fill_color = Some(ShapeColor::rgb(0, 0, 255));
|
||||
let rect_id = rect_shape.id;
|
||||
layer.add_shape(rect_shape);
|
||||
|
||||
// Add shape instances
|
||||
layer.shape_instances.push(ShapeInstance::new(circle_id));
|
||||
layer.shape_instances.push(ShapeInstance::new(rect_id));
|
||||
// Add shapes to keyframe at time 0.0
|
||||
let kf = layer.ensure_keyframe_at(0.0);
|
||||
kf.shapes.push(circle_shape);
|
||||
kf.shapes.push(rect_shape);
|
||||
|
||||
// Add the layer to the clip
|
||||
test_clip.layers.add_root(AnyLayer::Vector(layer));
|
||||
|
|
@ -2409,9 +2410,36 @@ impl EditorApp {
|
|||
}
|
||||
|
||||
// Timeline menu
|
||||
MenuAction::NewKeyframe => {
|
||||
println!("Menu: New Keyframe");
|
||||
// TODO: Implement new keyframe
|
||||
MenuAction::NewKeyframe | MenuAction::AddKeyframeAtPlayhead => {
|
||||
if let Some(layer_id) = self.active_layer_id {
|
||||
let document = self.action_executor.document();
|
||||
// Determine which selected objects are shape instances vs clip instances
|
||||
let mut shape_ids = Vec::new();
|
||||
let mut clip_ids = Vec::new();
|
||||
if let Some(AnyLayer::Vector(vl)) = document.get_layer(&layer_id) {
|
||||
for &id in self.selection.shape_instances() {
|
||||
if vl.get_shape_in_keyframe(&id, self.playback_time).is_some() {
|
||||
shape_ids.push(id);
|
||||
}
|
||||
}
|
||||
for &id in self.selection.clip_instances() {
|
||||
if vl.clip_instances.iter().any(|ci| ci.id == id) {
|
||||
clip_ids.push(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
// For vector layers, always create a shape keyframe (even without clip selection)
|
||||
if document.get_layer(&layer_id).map_or(false, |l| matches!(l, AnyLayer::Vector(_))) || !clip_ids.is_empty() {
|
||||
let action = lightningbeam_core::actions::SetKeyframeAction::new(
|
||||
layer_id,
|
||||
self.playback_time,
|
||||
clip_ids,
|
||||
);
|
||||
if let Err(e) = self.action_executor.execute(Box::new(action)) {
|
||||
eprintln!("Failed to set keyframe: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
MenuAction::NewBlankKeyframe => {
|
||||
println!("Menu: New Blank Keyframe");
|
||||
|
|
@ -2425,10 +2453,7 @@ impl EditorApp {
|
|||
println!("Menu: Duplicate Keyframe");
|
||||
// TODO: Implement duplicate keyframe
|
||||
}
|
||||
MenuAction::AddKeyframeAtPlayhead => {
|
||||
println!("Menu: Add Keyframe at Playhead");
|
||||
// TODO: Implement add keyframe at playhead
|
||||
}
|
||||
// AddKeyframeAtPlayhead handled above together with NewKeyframe
|
||||
MenuAction::AddMotionTween => {
|
||||
println!("Menu: Add Motion Tween");
|
||||
// TODO: Implement add motion tween
|
||||
|
|
@ -3277,16 +3302,14 @@ impl EditorApp {
|
|||
use lightningbeam_core::shape::Shape;
|
||||
let shape = Shape::new(path).with_image_fill(asset_info.clip_id);
|
||||
|
||||
// Create shape instance at document center
|
||||
use lightningbeam_core::object::ShapeInstance;
|
||||
let shape_instance = ShapeInstance::new(shape.id)
|
||||
.with_position(center_x, center_y);
|
||||
// Set position on shape directly
|
||||
let shape = shape.with_position(center_x, center_y);
|
||||
|
||||
// Create and execute action
|
||||
let action = lightningbeam_core::actions::AddShapeAction::new(
|
||||
layer_id,
|
||||
shape,
|
||||
shape_instance,
|
||||
self.playback_time,
|
||||
);
|
||||
let _ = self.action_executor.execute(Box::new(action));
|
||||
} else {
|
||||
|
|
@ -4411,6 +4434,25 @@ impl eframe::App for EditorApp {
|
|||
// Empty cache fallback if generator not initialized
|
||||
let empty_thumbnail_cache: HashMap<Uuid, Vec<u8>> = HashMap::new();
|
||||
|
||||
// Sync clip instance transforms from animation data at current playback time.
|
||||
// This ensures selection boxes, hit testing, and interactive editing see the
|
||||
// animated transform values, not just the base values on the ClipInstance struct.
|
||||
{
|
||||
let time = self.playback_time;
|
||||
let document = self.action_executor.document_mut();
|
||||
for layer in document.root.children.iter_mut() {
|
||||
if let lightningbeam_core::layer::AnyLayer::Vector(vl) = layer {
|
||||
for ci in &mut vl.clip_instances {
|
||||
let (t, opacity) = vl.layer.animation_data.eval_clip_instance_transform(
|
||||
ci.id, time, &ci.transform, ci.opacity,
|
||||
);
|
||||
ci.transform = t;
|
||||
ci.opacity = opacity;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create render context
|
||||
let mut ctx = RenderContext {
|
||||
tool_icon_cache: &mut self.tool_icon_cache,
|
||||
|
|
@ -4665,6 +4707,9 @@ impl eframe::App for EditorApp {
|
|||
debug_overlay::render_debug_overlay(ctx, &stats);
|
||||
}
|
||||
|
||||
// Render custom cursor overlay (on top of everything including debug overlay)
|
||||
custom_cursor::render_overlay(ctx, &mut self.cursor_cache);
|
||||
|
||||
let frame_ms = _frame_start.elapsed().as_secs_f64() * 1000.0;
|
||||
if frame_ms > 50.0 {
|
||||
eprintln!("[TIMING] SLOW FRAME: {:.1}ms (pre-events={:.1}, events={:.1}, post-events={:.1})",
|
||||
|
|
|
|||
|
|
@ -440,84 +440,82 @@ fn generate_vector_thumbnail(clip: &VectorClip, bg_color: egui::Color32) -> Vec<
|
|||
// Iterate through layers and render shapes
|
||||
for layer_node in clip.layers.iter() {
|
||||
if let AnyLayer::Vector(vector_layer) = &layer_node.data {
|
||||
// Render each shape instance
|
||||
for shape_instance in &vector_layer.shape_instances {
|
||||
if let Some(shape) = vector_layer.shapes.get(&shape_instance.shape_id) {
|
||||
// Get the path (frame 0)
|
||||
let kurbo_path = shape.path();
|
||||
// Render each shape at time 0.0 (frame 0)
|
||||
for shape in vector_layer.shapes_at_time(0.0) {
|
||||
// Get the path (frame 0)
|
||||
let kurbo_path = shape.path();
|
||||
|
||||
// Convert kurbo BezPath to tiny-skia PathBuilder
|
||||
let mut path_builder = PathBuilder::new();
|
||||
for el in kurbo_path.iter() {
|
||||
match el {
|
||||
PathEl::MoveTo(p) => {
|
||||
let x = (p.x * scale + offset_x) as f32;
|
||||
let y = (p.y * scale + offset_y) as f32;
|
||||
path_builder.move_to(x, y);
|
||||
}
|
||||
PathEl::LineTo(p) => {
|
||||
let x = (p.x * scale + offset_x) as f32;
|
||||
let y = (p.y * scale + offset_y) as f32;
|
||||
path_builder.line_to(x, y);
|
||||
}
|
||||
PathEl::QuadTo(p1, p2) => {
|
||||
let x1 = (p1.x * scale + offset_x) as f32;
|
||||
let y1 = (p1.y * scale + offset_y) as f32;
|
||||
let x2 = (p2.x * scale + offset_x) as f32;
|
||||
let y2 = (p2.y * scale + offset_y) as f32;
|
||||
path_builder.quad_to(x1, y1, x2, y2);
|
||||
}
|
||||
PathEl::CurveTo(p1, p2, p3) => {
|
||||
let x1 = (p1.x * scale + offset_x) as f32;
|
||||
let y1 = (p1.y * scale + offset_y) as f32;
|
||||
let x2 = (p2.x * scale + offset_x) as f32;
|
||||
let y2 = (p2.y * scale + offset_y) as f32;
|
||||
let x3 = (p3.x * scale + offset_x) as f32;
|
||||
let y3 = (p3.y * scale + offset_y) as f32;
|
||||
path_builder.cubic_to(x1, y1, x2, y2, x3, y3);
|
||||
}
|
||||
PathEl::ClosePath => {
|
||||
path_builder.close();
|
||||
}
|
||||
// Convert kurbo BezPath to tiny-skia PathBuilder
|
||||
let mut path_builder = PathBuilder::new();
|
||||
for el in kurbo_path.iter() {
|
||||
match el {
|
||||
PathEl::MoveTo(p) => {
|
||||
let x = (p.x * scale + offset_x) as f32;
|
||||
let y = (p.y * scale + offset_y) as f32;
|
||||
path_builder.move_to(x, y);
|
||||
}
|
||||
PathEl::LineTo(p) => {
|
||||
let x = (p.x * scale + offset_x) as f32;
|
||||
let y = (p.y * scale + offset_y) as f32;
|
||||
path_builder.line_to(x, y);
|
||||
}
|
||||
PathEl::QuadTo(p1, p2) => {
|
||||
let x1 = (p1.x * scale + offset_x) as f32;
|
||||
let y1 = (p1.y * scale + offset_y) as f32;
|
||||
let x2 = (p2.x * scale + offset_x) as f32;
|
||||
let y2 = (p2.y * scale + offset_y) as f32;
|
||||
path_builder.quad_to(x1, y1, x2, y2);
|
||||
}
|
||||
PathEl::CurveTo(p1, p2, p3) => {
|
||||
let x1 = (p1.x * scale + offset_x) as f32;
|
||||
let y1 = (p1.y * scale + offset_y) as f32;
|
||||
let x2 = (p2.x * scale + offset_x) as f32;
|
||||
let y2 = (p2.y * scale + offset_y) as f32;
|
||||
let x3 = (p3.x * scale + offset_x) as f32;
|
||||
let y3 = (p3.y * scale + offset_y) as f32;
|
||||
path_builder.cubic_to(x1, y1, x2, y2, x3, y3);
|
||||
}
|
||||
PathEl::ClosePath => {
|
||||
path_builder.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(ts_path) = path_builder.finish() {
|
||||
// Draw fill if present
|
||||
if let Some(fill_color) = &shape.fill_color {
|
||||
if let Some(ts_path) = path_builder.finish() {
|
||||
// Draw fill if present
|
||||
if let Some(fill_color) = &shape.fill_color {
|
||||
let mut paint = Paint::default();
|
||||
paint.set_color(shape_color_to_tiny_skia(fill_color));
|
||||
paint.anti_alias = true;
|
||||
pixmap.fill_path(
|
||||
&ts_path,
|
||||
&paint,
|
||||
tiny_skia::FillRule::Winding,
|
||||
TsTransform::identity(),
|
||||
None,
|
||||
);
|
||||
}
|
||||
|
||||
// Draw stroke if present
|
||||
if let Some(stroke_color) = &shape.stroke_color {
|
||||
if let Some(stroke_style) = &shape.stroke_style {
|
||||
let mut paint = Paint::default();
|
||||
paint.set_color(shape_color_to_tiny_skia(fill_color));
|
||||
paint.set_color(shape_color_to_tiny_skia(stroke_color));
|
||||
paint.anti_alias = true;
|
||||
pixmap.fill_path(
|
||||
|
||||
let stroke = tiny_skia::Stroke {
|
||||
width: (stroke_style.width * scale) as f32,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
pixmap.stroke_path(
|
||||
&ts_path,
|
||||
&paint,
|
||||
tiny_skia::FillRule::Winding,
|
||||
&stroke,
|
||||
TsTransform::identity(),
|
||||
None,
|
||||
);
|
||||
}
|
||||
|
||||
// Draw stroke if present
|
||||
if let Some(stroke_color) = &shape.stroke_color {
|
||||
if let Some(stroke_style) = &shape.stroke_style {
|
||||
let mut paint = Paint::default();
|
||||
paint.set_color(shape_color_to_tiny_skia(stroke_color));
|
||||
paint.anti_alias = true;
|
||||
|
||||
let stroke = tiny_skia::Stroke {
|
||||
width: (stroke_style.width * scale) as f32,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
pixmap.stroke_path(
|
||||
&ts_path,
|
||||
&paint,
|
||||
&stroke,
|
||||
TsTransform::identity(),
|
||||
None,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -117,82 +117,78 @@ impl InfopanelPane {
|
|||
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 let Some(shape) = vector_layer.get_shape_in_keyframe(instance_id, *shared.playback_time) {
|
||||
info.shape_ids.push(*instance_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);
|
||||
// First shape - set initial values
|
||||
info.x = Some(shape.transform.x);
|
||||
info.y = Some(shape.transform.y);
|
||||
info.rotation = Some(shape.transform.rotation);
|
||||
info.scale_x = Some(shape.transform.scale_x);
|
||||
info.scale_y = Some(shape.transform.scale_y);
|
||||
info.skew_x = Some(shape.transform.skew_x);
|
||||
info.skew_y = Some(shape.transform.skew_y);
|
||||
info.opacity = Some(shape.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));
|
||||
}
|
||||
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) {
|
||||
if info.x != Some(shape.transform.x) {
|
||||
info.x = None;
|
||||
}
|
||||
if info.y != Some(instance.transform.y) {
|
||||
if info.y != Some(shape.transform.y) {
|
||||
info.y = None;
|
||||
}
|
||||
if info.rotation != Some(instance.transform.rotation) {
|
||||
if info.rotation != Some(shape.transform.rotation) {
|
||||
info.rotation = None;
|
||||
}
|
||||
if info.scale_x != Some(instance.transform.scale_x) {
|
||||
if info.scale_x != Some(shape.transform.scale_x) {
|
||||
info.scale_x = None;
|
||||
}
|
||||
if info.scale_y != Some(instance.transform.scale_y) {
|
||||
if info.scale_y != Some(shape.transform.scale_y) {
|
||||
info.scale_y = None;
|
||||
}
|
||||
if info.skew_x != Some(instance.transform.skew_x) {
|
||||
if info.skew_x != Some(shape.transform.skew_x) {
|
||||
info.skew_x = None;
|
||||
}
|
||||
if info.skew_y != Some(instance.transform.skew_y) {
|
||||
if info.skew_y != Some(shape.transform.skew_y) {
|
||||
info.skew_y = None;
|
||||
}
|
||||
if info.opacity != Some(instance.opacity) {
|
||||
if info.opacity != Some(shape.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 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;
|
||||
}
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -488,6 +484,7 @@ impl InfopanelPane {
|
|||
for instance_id in instance_ids {
|
||||
let action = SetInstancePropertiesAction::new(
|
||||
layer_id,
|
||||
*shared.playback_time,
|
||||
*instance_id,
|
||||
make_change(v),
|
||||
);
|
||||
|
|
@ -507,6 +504,7 @@ impl InfopanelPane {
|
|||
for instance_id in instance_ids {
|
||||
let action = SetInstancePropertiesAction::new(
|
||||
layer_id,
|
||||
*shared.playback_time,
|
||||
*instance_id,
|
||||
make_change(v),
|
||||
);
|
||||
|
|
@ -564,6 +562,7 @@ impl InfopanelPane {
|
|||
let action = SetShapePropertiesAction::set_fill_color(
|
||||
layer_id,
|
||||
*shape_id,
|
||||
*shared.playback_time,
|
||||
new_color,
|
||||
);
|
||||
shared.pending_actions.push(Box::new(action));
|
||||
|
|
@ -578,6 +577,7 @@ impl InfopanelPane {
|
|||
let action = SetShapePropertiesAction::set_fill_color(
|
||||
layer_id,
|
||||
*shape_id,
|
||||
*shared.playback_time,
|
||||
default_fill,
|
||||
);
|
||||
shared.pending_actions.push(Box::new(action));
|
||||
|
|
@ -612,6 +612,7 @@ impl InfopanelPane {
|
|||
let action = SetShapePropertiesAction::set_stroke_color(
|
||||
layer_id,
|
||||
*shape_id,
|
||||
*shared.playback_time,
|
||||
new_color,
|
||||
);
|
||||
shared.pending_actions.push(Box::new(action));
|
||||
|
|
@ -626,6 +627,7 @@ impl InfopanelPane {
|
|||
let action = SetShapePropertiesAction::set_stroke_color(
|
||||
layer_id,
|
||||
*shape_id,
|
||||
*shared.playback_time,
|
||||
default_stroke,
|
||||
);
|
||||
shared.pending_actions.push(Box::new(action));
|
||||
|
|
@ -654,6 +656,7 @@ impl InfopanelPane {
|
|||
let action = SetShapePropertiesAction::set_stroke_width(
|
||||
layer_id,
|
||||
*shape_id,
|
||||
*shared.playback_time,
|
||||
width,
|
||||
);
|
||||
shared.pending_actions.push(Box::new(action));
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ pub enum NodeTemplate {
|
|||
|
||||
// Effects
|
||||
Filter,
|
||||
Svf,
|
||||
Gain,
|
||||
Echo,
|
||||
Reverb,
|
||||
|
|
@ -100,6 +101,7 @@ impl NodeTemplate {
|
|||
NodeTemplate::SimpleSampler => "SimpleSampler",
|
||||
NodeTemplate::MultiSampler => "MultiSampler",
|
||||
NodeTemplate::Filter => "Filter",
|
||||
NodeTemplate::Svf => "SVF",
|
||||
NodeTemplate::Gain => "Gain",
|
||||
NodeTemplate::Echo => "Echo",
|
||||
NodeTemplate::Reverb => "Reverb",
|
||||
|
|
@ -222,6 +224,8 @@ pub enum PendingSamplerLoad {
|
|||
MultiFromFolder { node_id: NodeId, folder_id: uuid::Uuid },
|
||||
/// Open a file/folder dialog to load into a MultiSampler
|
||||
MultiFromFilesystem { node_id: NodeId, backend_node_id: u32 },
|
||||
/// Open a folder dialog for batch import with heuristic mapping
|
||||
MultiFromFolderFilesystem { node_id: NodeId, backend_node_id: u32 },
|
||||
}
|
||||
|
||||
/// Custom graph state - can track selected nodes, etc.
|
||||
|
|
@ -259,6 +263,8 @@ pub struct GraphState {
|
|||
pub draw_vms: HashMap<NodeId, beamdsp::DrawVM>,
|
||||
/// Pending param changes from draw block (node_id, param_index, new_value)
|
||||
pub pending_draw_param_changes: Vec<(NodeId, u32, f32)>,
|
||||
/// Active sample import dialog (folder import with heuristic mapping)
|
||||
pub sample_import_dialog: Option<crate::sample_import_dialog::SampleImportDialog>,
|
||||
}
|
||||
|
||||
impl Default for GraphState {
|
||||
|
|
@ -281,6 +287,7 @@ impl Default for GraphState {
|
|||
pending_script_sample_load: None,
|
||||
draw_vms: HashMap::new(),
|
||||
pending_draw_param_changes: Vec::new(),
|
||||
sample_import_dialog: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -400,6 +407,7 @@ impl NodeTemplateTrait for NodeTemplate {
|
|||
NodeTemplate::MultiSampler => "Multi Sampler".into(),
|
||||
// Effects
|
||||
NodeTemplate::Filter => "Filter".into(),
|
||||
NodeTemplate::Svf => "SVF".into(),
|
||||
NodeTemplate::Gain => "Gain".into(),
|
||||
NodeTemplate::Echo => "Echo".into(),
|
||||
NodeTemplate::Reverb => "Reverb".into(),
|
||||
|
|
@ -452,7 +460,7 @@ impl NodeTemplateTrait for NodeTemplate {
|
|||
NodeTemplate::MidiInput | NodeTemplate::AudioInput | NodeTemplate::AutomationInput | NodeTemplate::Beat => vec!["Inputs"],
|
||||
NodeTemplate::Oscillator | NodeTemplate::WavetableOscillator | NodeTemplate::FmSynth
|
||||
| NodeTemplate::Noise | NodeTemplate::SimpleSampler | NodeTemplate::MultiSampler => vec!["Generators"],
|
||||
NodeTemplate::Filter | NodeTemplate::Gain | NodeTemplate::Echo | NodeTemplate::Reverb
|
||||
NodeTemplate::Filter | NodeTemplate::Svf | NodeTemplate::Gain | NodeTemplate::Echo | NodeTemplate::Reverb
|
||||
| NodeTemplate::Chorus | NodeTemplate::Flanger | NodeTemplate::Phaser | NodeTemplate::Distortion
|
||||
| NodeTemplate::BitCrusher | NodeTemplate::Compressor | NodeTemplate::Limiter | NodeTemplate::Eq
|
||||
| NodeTemplate::Pan | NodeTemplate::RingModulator | NodeTemplate::Vocoder => vec!["Effects"],
|
||||
|
|
@ -513,6 +521,20 @@ impl NodeTemplateTrait for NodeTemplate {
|
|||
ValueType::float_param(0.0, 0.0, 3.0, "", 2, Some(&["LPF", "HPF", "BPF", "Notch"])), InputParamKind::ConstantOnly, true);
|
||||
graph.add_output_param(node_id, "Audio Out".into(), DataType::Audio);
|
||||
}
|
||||
NodeTemplate::Svf => {
|
||||
graph.add_input_param(node_id, "Audio In".into(), DataType::Audio, ValueType::float(0.0), InputParamKind::ConnectionOnly, true);
|
||||
graph.add_input_param(node_id, "Cutoff CV".into(), DataType::CV, ValueType::float(0.0), InputParamKind::ConnectionOnly, true);
|
||||
graph.add_input_param(node_id, "Resonance CV".into(), DataType::CV, ValueType::float(0.0), InputParamKind::ConnectionOnly, true);
|
||||
// Parameters
|
||||
graph.add_input_param(node_id, "Cutoff".into(), DataType::CV,
|
||||
ValueType::float_param(1000.0, 20.0, 20000.0, " Hz", 0, None), InputParamKind::ConstantOnly, true);
|
||||
graph.add_input_param(node_id, "Resonance".into(), DataType::CV,
|
||||
ValueType::float_param(0.0, 0.0, 1.0, "", 1, None), InputParamKind::ConstantOnly, true);
|
||||
graph.add_output_param(node_id, "Lowpass".into(), DataType::Audio);
|
||||
graph.add_output_param(node_id, "Highpass".into(), DataType::Audio);
|
||||
graph.add_output_param(node_id, "Bandpass".into(), DataType::Audio);
|
||||
graph.add_output_param(node_id, "Notch".into(), DataType::Audio);
|
||||
}
|
||||
NodeTemplate::Gain => {
|
||||
graph.add_input_param(node_id, "Audio In".into(), DataType::Audio, ValueType::float(0.0), InputParamKind::ConnectionOnly, true);
|
||||
graph.add_input_param(node_id, "Gain CV".into(), DataType::CV, ValueType::float(0.0), InputParamKind::ConnectionOnly, true);
|
||||
|
|
@ -644,6 +666,14 @@ impl NodeTemplateTrait for NodeTemplate {
|
|||
}
|
||||
NodeTemplate::MultiSampler => {
|
||||
graph.add_input_param(node_id, "MIDI In".into(), DataType::Midi, ValueType::float(0.0), InputParamKind::ConnectionOnly, true);
|
||||
graph.add_input_param(node_id, "Gain".into(), DataType::CV,
|
||||
ValueType::float_param(1.0, 0.0, 2.0, "", 0, None), InputParamKind::ConstantOnly, true);
|
||||
graph.add_input_param(node_id, "Attack".into(), DataType::CV,
|
||||
ValueType::float_param(0.01, 0.001, 1.0, " s", 1, None), InputParamKind::ConstantOnly, true);
|
||||
graph.add_input_param(node_id, "Release".into(), DataType::CV,
|
||||
ValueType::float_param(0.1, 0.01, 5.0, " s", 2, None), InputParamKind::ConstantOnly, true);
|
||||
graph.add_input_param(node_id, "Transpose".into(), DataType::CV,
|
||||
ValueType::float_param(0.0, -24.0, 24.0, " st", 3, None), InputParamKind::ConstantOnly, true);
|
||||
graph.add_output_param(node_id, "Audio Out".into(), DataType::Audio);
|
||||
}
|
||||
NodeTemplate::Reverb => {
|
||||
|
|
@ -1106,6 +1136,15 @@ impl NodeDataTrait for NodeData {
|
|||
}
|
||||
close_popup = true;
|
||||
}
|
||||
if is_multi {
|
||||
if ui.button("Import Folder...").clicked() {
|
||||
user_state.pending_sampler_load = Some(PendingSamplerLoad::MultiFromFolderFilesystem {
|
||||
node_id,
|
||||
backend_node_id,
|
||||
});
|
||||
close_popup = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if close_popup {
|
||||
|
|
@ -1676,6 +1715,7 @@ impl NodeTemplateIter for AllNodeTemplates {
|
|||
NodeTemplate::MultiSampler,
|
||||
// Effects
|
||||
NodeTemplate::Filter,
|
||||
NodeTemplate::Svf,
|
||||
NodeTemplate::Gain,
|
||||
NodeTemplate::Echo,
|
||||
NodeTemplate::Reverb,
|
||||
|
|
|
|||
|
|
@ -793,6 +793,23 @@ impl NodeGraphPane {
|
|||
}
|
||||
}
|
||||
}
|
||||
graph_data::PendingSamplerLoad::MultiFromFolderFilesystem { node_id, backend_node_id } => {
|
||||
if let Some(path) = rfd::FileDialog::new().pick_folder() {
|
||||
match crate::sample_import::scan_folder(&path) {
|
||||
Ok(samples) => {
|
||||
let scan_result = crate::sample_import::build_import_layers(samples, &path);
|
||||
let track_id = backend_track_id;
|
||||
let dialog = crate::sample_import_dialog::SampleImportDialog::new(
|
||||
path, scan_result, track_id, backend_node_id, node_id,
|
||||
);
|
||||
self.user_state.sample_import_dialog = Some(dialog);
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Failed to scan folder '{}': {}", path.display(), e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1111,36 +1128,6 @@ impl NodeGraphPane {
|
|||
best.map(|(input, output, src, dst, _)| (input, output, src, dst))
|
||||
}
|
||||
|
||||
/// Draw a highlight over a connection to indicate insertion target.
|
||||
/// src/dst are in graph space — converted to screen space here.
|
||||
fn draw_connection_highlight(
|
||||
ui: &egui::Ui,
|
||||
src_graph: egui::Pos2,
|
||||
dst_graph: egui::Pos2,
|
||||
zoom: f32,
|
||||
pan: egui::Vec2,
|
||||
editor_offset: egui::Vec2,
|
||||
) {
|
||||
// Convert graph space to screen space
|
||||
let to_screen = |p: egui::Pos2| -> egui::Pos2 {
|
||||
egui::pos2(p.x * zoom + pan.x + editor_offset.x, p.y * zoom + pan.y + editor_offset.y)
|
||||
};
|
||||
let src = to_screen(src_graph);
|
||||
let dst = to_screen(dst_graph);
|
||||
|
||||
let control_scale = ((dst.x - src.x) / 2.0).max(30.0 * zoom);
|
||||
let src_ctrl = egui::pos2(src.x + control_scale, src.y);
|
||||
let dst_ctrl = egui::pos2(dst.x - control_scale, dst.y);
|
||||
|
||||
let bezier = egui::epaint::CubicBezierShape::from_points_stroke(
|
||||
[src, src_ctrl, dst_ctrl, dst],
|
||||
false,
|
||||
egui::Color32::TRANSPARENT,
|
||||
egui::Stroke::new(7.0 * zoom, egui::Color32::from_rgb(100, 220, 100)),
|
||||
);
|
||||
ui.painter().add(bezier);
|
||||
}
|
||||
|
||||
/// Execute the insert-node-on-connection action
|
||||
fn execute_insert_on_connection(
|
||||
&mut self,
|
||||
|
|
@ -2125,6 +2112,7 @@ impl NodeGraphPane {
|
|||
"SimpleSampler" => Some(NodeTemplate::SimpleSampler),
|
||||
"MultiSampler" => Some(NodeTemplate::MultiSampler),
|
||||
"Filter" => Some(NodeTemplate::Filter),
|
||||
"SVF" => Some(NodeTemplate::Svf),
|
||||
"Gain" => Some(NodeTemplate::Gain),
|
||||
"Echo" | "Delay" => Some(NodeTemplate::Echo),
|
||||
"Reverb" => Some(NodeTemplate::Reverb),
|
||||
|
|
@ -2583,6 +2571,54 @@ impl crate::panes::PaneRenderer for NodeGraphPane {
|
|||
self.handle_pending_sampler_load(load, shared);
|
||||
}
|
||||
|
||||
// Render sample import dialog if active
|
||||
if let Some(dialog) = &mut self.user_state.sample_import_dialog {
|
||||
let still_open = dialog.show(ui.ctx());
|
||||
if !still_open {
|
||||
// Dialog closed — check if confirmed
|
||||
let dialog = self.user_state.sample_import_dialog.take().unwrap();
|
||||
if dialog.confirmed {
|
||||
let backend_track_id = dialog.track_id;
|
||||
let backend_node_id = dialog.backend_node_id;
|
||||
let node_id = dialog.node_id;
|
||||
let loop_mode = dialog.loop_mode;
|
||||
let enabled_layers: Vec<_> = dialog.scan_result.layers.iter()
|
||||
.filter(|l| l.enabled)
|
||||
.collect();
|
||||
let layer_count = enabled_layers.len();
|
||||
let folder_name = dialog.folder_path.file_name()
|
||||
.map(|n| n.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|| "Folder".to_string());
|
||||
|
||||
if let Some(controller_arc) = &shared.audio_controller {
|
||||
let mut controller = controller_arc.lock().unwrap();
|
||||
// Clear existing layers before importing new ones
|
||||
controller.multi_sampler_clear_layers(backend_track_id, backend_node_id);
|
||||
for layer in &enabled_layers {
|
||||
controller.multi_sampler_add_layer(
|
||||
backend_track_id,
|
||||
backend_node_id,
|
||||
layer.path.to_string_lossy().to_string(),
|
||||
layer.key_min,
|
||||
layer.key_max,
|
||||
layer.root_key,
|
||||
layer.velocity_min,
|
||||
layer.velocity_max,
|
||||
None, None, // loop points auto-detected by backend
|
||||
loop_mode,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(node) = self.state.graph.nodes.get_mut(node_id) {
|
||||
node.user_data.sample_display_name = Some(
|
||||
format!("{} ({} layers)", folder_name, layer_count)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle pending script sample load requests from bottom_ui()
|
||||
if let Some(load) = self.user_state.pending_script_sample_load.take() {
|
||||
self.handle_pending_script_sample_load(load, shared);
|
||||
|
|
@ -3037,21 +3073,16 @@ impl crate::panes::PaneRenderer for NodeGraphPane {
|
|||
if let Some(dragged) = self.dragging_node {
|
||||
if primary_down {
|
||||
// Still dragging — check for nearby compatible connection
|
||||
if let Some((input_id, output_id, src_graph, dst_graph)) = self.find_insert_target(dragged) {
|
||||
if let Some((input_id, output_id, _src_graph, _dst_graph)) = self.find_insert_target(dragged) {
|
||||
self.insert_target = Some((input_id, output_id));
|
||||
Self::draw_connection_highlight(
|
||||
ui,
|
||||
src_graph,
|
||||
dst_graph,
|
||||
self.state.pan_zoom.zoom,
|
||||
self.state.pan_zoom.pan,
|
||||
rect.min.to_vec2(),
|
||||
);
|
||||
self.state.highlighted_connection = Some((input_id, output_id));
|
||||
} else {
|
||||
self.insert_target = None;
|
||||
self.state.highlighted_connection = None;
|
||||
}
|
||||
} else {
|
||||
// Drag ended — execute insertion if we have a target
|
||||
self.state.highlighted_connection = None;
|
||||
if let Some((target_input, target_output)) = self.insert_target.take() {
|
||||
self.execute_insert_on_connection(dragged, target_input, target_output, shared);
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -17,6 +17,51 @@ const LAYER_HEADER_WIDTH: f32 = 200.0;
|
|||
const MIN_PIXELS_PER_SECOND: f32 = 1.0; // Allow zooming out to see 10+ minutes
|
||||
const MAX_PIXELS_PER_SECOND: f32 = 500.0;
|
||||
const EDGE_DETECTION_PIXELS: f32 = 8.0; // Distance from edge to detect trim handles
|
||||
const LOOP_CORNER_SIZE: f32 = 12.0; // Size of loop corner hotzone at top-right of clip
|
||||
const MIN_CLIP_WIDTH_PX: f32 = 8.0; // Minimum visible width for very short clips (e.g. groups)
|
||||
|
||||
/// Calculate vertical bounds for a clip instance within a layer row.
|
||||
/// For vector layers with multiple clip instances, stacks them vertically.
|
||||
/// Returns (y_min, y_max) relative to the layer top.
|
||||
fn clip_instance_y_bounds(
|
||||
layer: &AnyLayer,
|
||||
clip_index: usize,
|
||||
clip_count: usize,
|
||||
) -> (f32, f32) {
|
||||
if matches!(layer, AnyLayer::Vector(_)) && clip_count > 1 {
|
||||
let usable_height = LAYER_HEIGHT - 20.0; // 10px padding top/bottom
|
||||
let row_height = (usable_height / clip_count as f32).min(20.0);
|
||||
let top = 10.0 + clip_index as f32 * row_height;
|
||||
(top, top + row_height - 1.0)
|
||||
} else {
|
||||
(10.0, LAYER_HEIGHT - 10.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the effective clip duration for a clip instance on a given layer.
|
||||
/// For groups on vector layers, the duration spans all consecutive keyframes
|
||||
/// where the group is present. For regular clips, returns the clip's internal duration.
|
||||
fn effective_clip_duration(
|
||||
document: &lightningbeam_core::document::Document,
|
||||
layer: &AnyLayer,
|
||||
clip_instance: &ClipInstance,
|
||||
) -> Option<f64> {
|
||||
match layer {
|
||||
AnyLayer::Vector(vl) => {
|
||||
let vc = document.get_vector_clip(&clip_instance.clip_id)?;
|
||||
if vc.is_group {
|
||||
let frame_duration = 1.0 / document.framerate;
|
||||
let end = vl.group_visibility_end(&clip_instance.id, clip_instance.timeline_start, frame_duration);
|
||||
Some((end - clip_instance.timeline_start).max(0.0))
|
||||
} else {
|
||||
Some(vc.duration)
|
||||
}
|
||||
}
|
||||
AnyLayer::Audio(_) => document.get_audio_clip(&clip_instance.clip_id).map(|c| c.duration),
|
||||
AnyLayer::Video(_) => document.get_video_clip(&clip_instance.clip_id).map(|c| c.duration),
|
||||
AnyLayer::Effect(_) => Some(lightningbeam_core::effect::EFFECT_DURATION),
|
||||
}
|
||||
}
|
||||
|
||||
/// Type of clip drag operation
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
|
|
@ -24,6 +69,8 @@ enum ClipDragType {
|
|||
Move,
|
||||
TrimLeft,
|
||||
TrimRight,
|
||||
LoopExtendRight,
|
||||
LoopExtendLeft,
|
||||
}
|
||||
|
||||
pub struct TimelinePane {
|
||||
|
|
@ -192,7 +239,7 @@ impl TimelinePane {
|
|||
match layer_type {
|
||||
AudioLayerType::Midi => {
|
||||
// Create backend MIDI clip and start recording
|
||||
let clip_id = controller.create_midi_clip(track_id, start_time, 4.0);
|
||||
let clip_id = controller.create_midi_clip(track_id, start_time, 0.0);
|
||||
controller.start_midi_recording(track_id, clip_id, start_time);
|
||||
shared.recording_clips.insert(active_layer_id, clip_id);
|
||||
println!("🎹 Started MIDI recording on track {:?} at {:.2}s, clip_id={}",
|
||||
|
|
@ -202,7 +249,7 @@ impl TimelinePane {
|
|||
drop(controller);
|
||||
|
||||
// Create document clip + clip instance immediately (clip_id is known synchronously)
|
||||
let doc_clip = AudioClip::new_midi("Recording...", clip_id, 4.0);
|
||||
let doc_clip = AudioClip::new_midi("Recording...", clip_id, 0.0);
|
||||
let doc_clip_id = shared.action_executor.document_mut().add_audio_clip(doc_clip);
|
||||
|
||||
let clip_instance = ClipInstance::new(doc_clip_id)
|
||||
|
|
@ -301,7 +348,6 @@ impl TimelinePane {
|
|||
return None;
|
||||
}
|
||||
|
||||
let hover_time = self.x_to_time(pointer_pos.x - content_rect.min.x);
|
||||
let relative_y = pointer_pos.y - header_rect.min.y + self.viewport_scroll_y;
|
||||
let hovered_layer_index = (relative_y / LAYER_HEIGHT) as usize;
|
||||
|
||||
|
|
@ -321,36 +367,48 @@ impl TimelinePane {
|
|||
};
|
||||
|
||||
// Check each clip instance
|
||||
for clip_instance in clip_instances {
|
||||
let clip_duration = match layer {
|
||||
lightningbeam_core::layer::AnyLayer::Vector(_) => {
|
||||
document.get_vector_clip(&clip_instance.clip_id).map(|c| c.duration)
|
||||
}
|
||||
lightningbeam_core::layer::AnyLayer::Audio(_) => {
|
||||
document.get_audio_clip(&clip_instance.clip_id).map(|c| c.duration)
|
||||
}
|
||||
lightningbeam_core::layer::AnyLayer::Video(_) => {
|
||||
document.get_video_clip(&clip_instance.clip_id).map(|c| c.duration)
|
||||
}
|
||||
lightningbeam_core::layer::AnyLayer::Effect(_) => {
|
||||
Some(lightningbeam_core::effect::EFFECT_DURATION)
|
||||
}
|
||||
}?;
|
||||
let clip_count = clip_instances.len();
|
||||
for (ci_idx, clip_instance) in clip_instances.iter().enumerate() {
|
||||
let clip_duration = effective_clip_duration(document, layer, clip_instance)?;
|
||||
|
||||
let instance_duration = clip_instance.effective_duration(clip_duration);
|
||||
let instance_start = clip_instance.timeline_start;
|
||||
let instance_start = clip_instance.effective_start();
|
||||
let instance_duration = clip_instance.total_duration(clip_duration);
|
||||
let instance_end = instance_start + instance_duration;
|
||||
|
||||
if hover_time >= instance_start && hover_time <= instance_end {
|
||||
let start_x = self.time_to_x(instance_start);
|
||||
let end_x = self.time_to_x(instance_end);
|
||||
let mouse_x = pointer_pos.x - content_rect.min.x;
|
||||
let start_x = self.time_to_x(instance_start);
|
||||
let end_x = self.time_to_x(instance_end).max(start_x + MIN_CLIP_WIDTH_PX);
|
||||
let mouse_x = pointer_pos.x - content_rect.min.x;
|
||||
|
||||
if mouse_x >= start_x && mouse_x <= end_x {
|
||||
// Check vertical bounds for stacked vector layer clips
|
||||
let layer_top = header_rect.min.y + (hovered_layer_index as f32 * LAYER_HEIGHT) - self.viewport_scroll_y;
|
||||
let (cy_min, cy_max) = clip_instance_y_bounds(layer, ci_idx, clip_count);
|
||||
let mouse_rel_y = pointer_pos.y - layer_top;
|
||||
if mouse_rel_y < cy_min || mouse_rel_y > cy_max {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Determine drag type based on edge proximity (check both sides of edge)
|
||||
let is_audio_layer = matches!(layer, lightningbeam_core::layer::AnyLayer::Audio(_));
|
||||
let mouse_in_top_corner = pointer_pos.y < layer_top + LOOP_CORNER_SIZE;
|
||||
|
||||
let is_looping = clip_instance.timeline_duration.is_some() || clip_instance.loop_before.is_some();
|
||||
let drag_type = if (mouse_x - start_x).abs() <= EDGE_DETECTION_PIXELS {
|
||||
ClipDragType::TrimLeft
|
||||
// Left edge: loop extend left for audio clips that are looping or top-left corner
|
||||
let mouse_in_top_left_corner = pointer_pos.y < layer_top + LOOP_CORNER_SIZE;
|
||||
if is_audio_layer && (is_looping || mouse_in_top_left_corner) {
|
||||
ClipDragType::LoopExtendLeft
|
||||
} else {
|
||||
ClipDragType::TrimLeft
|
||||
}
|
||||
} else if (end_x - mouse_x).abs() <= EDGE_DETECTION_PIXELS {
|
||||
ClipDragType::TrimRight
|
||||
// If already looping, right edge is always loop extend
|
||||
// Otherwise, top-right corner of audio clips = loop extend
|
||||
if is_audio_layer && (is_looping || mouse_in_top_corner) {
|
||||
ClipDragType::LoopExtendRight
|
||||
} else {
|
||||
ClipDragType::TrimRight
|
||||
}
|
||||
} else {
|
||||
ClipDragType::Move
|
||||
};
|
||||
|
|
@ -545,6 +603,7 @@ impl TimelinePane {
|
|||
pixels_per_second: f32,
|
||||
theme: &crate::theme::Theme,
|
||||
ctx: &egui::Context,
|
||||
faded: bool,
|
||||
) {
|
||||
let clip_height = clip_rect.height();
|
||||
let note_height = clip_height / 12.0; // 12 semitones per octave
|
||||
|
|
@ -634,8 +693,13 @@ impl TimelinePane {
|
|||
}
|
||||
|
||||
// Second pass: render all note rectangles
|
||||
let render_color = if faded {
|
||||
egui::Color32::from_rgba_unmultiplied(note_color.r(), note_color.g(), note_color.b(), note_color.a() / 2)
|
||||
} else {
|
||||
note_color
|
||||
};
|
||||
for (note_rect, _note_number) in note_rectangles {
|
||||
painter.rect_filled(note_rect, 1.0, note_color);
|
||||
painter.rect_filled(note_rect, 1.0, render_color);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1009,7 +1073,7 @@ impl TimelinePane {
|
|||
.filter(|ci| selection.contains_clip_instance(&ci.id))
|
||||
.filter_map(|ci| {
|
||||
let dur = document.get_clip_duration(&ci.clip_id)?;
|
||||
Some((ci.id, ci.timeline_start, ci.effective_duration(dur)))
|
||||
Some((ci.id, ci.effective_start(), ci.total_duration(dur)))
|
||||
})
|
||||
.collect();
|
||||
if !group.is_empty() {
|
||||
|
|
@ -1021,35 +1085,20 @@ impl TimelinePane {
|
|||
None
|
||||
};
|
||||
|
||||
for clip_instance in clip_instances {
|
||||
let clip_instance_count = clip_instances.len();
|
||||
for (clip_instance_index, clip_instance) in clip_instances.iter().enumerate() {
|
||||
// Get the clip to determine duration
|
||||
let clip_duration = match layer {
|
||||
lightningbeam_core::layer::AnyLayer::Vector(_) => {
|
||||
document.get_vector_clip(&clip_instance.clip_id)
|
||||
.map(|c| c.duration)
|
||||
}
|
||||
lightningbeam_core::layer::AnyLayer::Audio(_) => {
|
||||
document.get_audio_clip(&clip_instance.clip_id)
|
||||
.map(|c| c.duration)
|
||||
}
|
||||
lightningbeam_core::layer::AnyLayer::Video(_) => {
|
||||
document.get_video_clip(&clip_instance.clip_id)
|
||||
.map(|c| c.duration)
|
||||
}
|
||||
lightningbeam_core::layer::AnyLayer::Effect(_) => {
|
||||
Some(lightningbeam_core::effect::EFFECT_DURATION)
|
||||
}
|
||||
};
|
||||
let clip_duration = effective_clip_duration(document, layer, clip_instance);
|
||||
|
||||
if let Some(clip_duration) = clip_duration {
|
||||
// Calculate effective duration accounting for trimming
|
||||
let mut instance_duration = clip_instance.effective_duration(clip_duration);
|
||||
let mut instance_duration = clip_instance.total_duration(clip_duration);
|
||||
|
||||
// Instance positioned on the layer's timeline using timeline_start
|
||||
// The layer itself has start_time, so the absolute timeline position is:
|
||||
// layer.start_time + instance.timeline_start
|
||||
let _layer_data = layer.layer();
|
||||
let mut instance_start = clip_instance.timeline_start;
|
||||
let mut instance_start = clip_instance.effective_start();
|
||||
|
||||
// Apply drag offset preview for selected clips with snapping
|
||||
let is_selected = selection.contains_clip_instance(&clip_instance.id);
|
||||
|
|
@ -1068,6 +1117,10 @@ impl TimelinePane {
|
|||
false
|
||||
};
|
||||
|
||||
// Content origin: where the first "real" content iteration starts
|
||||
// Loop iterations tile outward from this point
|
||||
let mut content_origin = clip_instance.timeline_start;
|
||||
|
||||
// Track preview trim values for waveform rendering
|
||||
let mut preview_trim_start = clip_instance.trim_start;
|
||||
let mut preview_clip_duration = clip_duration;
|
||||
|
|
@ -1077,7 +1130,8 @@ impl TimelinePane {
|
|||
match drag_type {
|
||||
ClipDragType::Move => {
|
||||
if let Some(offset) = group_move_offset {
|
||||
instance_start = (clip_instance.timeline_start + offset).max(0.0);
|
||||
instance_start = (clip_instance.effective_start() + offset).max(0.0);
|
||||
content_origin = instance_start + clip_instance.loop_before.unwrap_or(0.0);
|
||||
}
|
||||
}
|
||||
ClipDragType::TrimLeft => {
|
||||
|
|
@ -1091,7 +1145,7 @@ impl TimelinePane {
|
|||
let max_extend = document.find_max_trim_extend_left(
|
||||
&layer.id(),
|
||||
&clip_instance.id,
|
||||
clip_instance.timeline_start,
|
||||
clip_instance.effective_start(),
|
||||
);
|
||||
|
||||
let desired_extend = clip_instance.trim_start - desired_trim_start;
|
||||
|
|
@ -1107,6 +1161,7 @@ impl TimelinePane {
|
|||
// Move start and reduce duration by actual clamped offset
|
||||
instance_start = (clip_instance.timeline_start + actual_offset)
|
||||
.max(0.0);
|
||||
|
||||
instance_duration = (clip_duration - new_trim_start).max(0.0);
|
||||
|
||||
// Adjust for existing trim_end
|
||||
|
|
@ -1149,6 +1204,63 @@ impl TimelinePane {
|
|||
// (the waveform system uses clip_duration to determine visible range)
|
||||
preview_clip_duration = new_trim_end - preview_trim_start;
|
||||
}
|
||||
ClipDragType::LoopExtendRight => {
|
||||
// Loop extend right: extend clip beyond content window
|
||||
let trim_end = clip_instance.trim_end.unwrap_or(clip_duration);
|
||||
let content_window = (trim_end - clip_instance.trim_start).max(0.0);
|
||||
let current_right = clip_instance.timeline_duration.unwrap_or(content_window);
|
||||
let desired_right = (current_right + self.drag_offset).max(content_window);
|
||||
|
||||
let new_right = if desired_right > current_right {
|
||||
let max_extend = document.find_max_trim_extend_right(
|
||||
&layer.id(),
|
||||
&clip_instance.id,
|
||||
clip_instance.timeline_start,
|
||||
current_right,
|
||||
);
|
||||
let extend_amount = (desired_right - current_right).min(max_extend);
|
||||
current_right + extend_amount
|
||||
} else {
|
||||
desired_right
|
||||
};
|
||||
|
||||
// Total duration = loop_before + right duration
|
||||
let loop_before = clip_instance.loop_before.unwrap_or(0.0);
|
||||
instance_duration = loop_before + new_right;
|
||||
}
|
||||
ClipDragType::LoopExtendLeft => {
|
||||
// Loop extend left: extend loop_before (pre-loop region)
|
||||
// Snap to multiples of content_window so iterations align with backend
|
||||
let trim_end = clip_instance.trim_end.unwrap_or(clip_duration);
|
||||
let content_window = (trim_end - clip_instance.trim_start).max(0.001);
|
||||
let current_loop_before = clip_instance.loop_before.unwrap_or(0.0);
|
||||
// Invert: dragging left (negative offset) = extend
|
||||
let desired_loop_before = (current_loop_before - self.drag_offset).max(0.0);
|
||||
// Snap to whole iterations
|
||||
let desired_iters = (desired_loop_before / content_window).round();
|
||||
let snapped_loop_before = desired_iters * content_window;
|
||||
|
||||
let new_loop_before = if snapped_loop_before > current_loop_before {
|
||||
// Extending left - check for adjacent clips
|
||||
let max_extend = document.find_max_loop_extend_left(
|
||||
&layer.id(),
|
||||
&clip_instance.id,
|
||||
clip_instance.effective_start(),
|
||||
);
|
||||
let extend_amount = (snapped_loop_before - current_loop_before).min(max_extend);
|
||||
// Re-snap after clamping
|
||||
let clamped = current_loop_before + extend_amount;
|
||||
(clamped / content_window).floor() * content_window
|
||||
} else {
|
||||
snapped_loop_before
|
||||
};
|
||||
|
||||
// Recompute instance_start and instance_duration
|
||||
let right_duration = clip_instance.effective_duration(clip_duration);
|
||||
instance_start = clip_instance.timeline_start - new_loop_before;
|
||||
instance_duration = new_loop_before + right_duration;
|
||||
content_origin = clip_instance.timeline_start;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1156,7 +1268,7 @@ impl TimelinePane {
|
|||
let instance_end = instance_start + instance_duration;
|
||||
|
||||
let start_x = self.time_to_x(instance_start);
|
||||
let end_x = self.time_to_x(instance_end);
|
||||
let end_x = self.time_to_x(instance_end).max(start_x + MIN_CLIP_WIDTH_PX);
|
||||
|
||||
// Only draw if any part is visible in viewport
|
||||
if end_x >= 0.0 && start_x <= rect.width() {
|
||||
|
|
@ -1191,38 +1303,125 @@ impl TimelinePane {
|
|||
),
|
||||
};
|
||||
|
||||
let (cy_min, cy_max) = clip_instance_y_bounds(layer, clip_instance_index, clip_instance_count);
|
||||
|
||||
let clip_rect = egui::Rect::from_min_max(
|
||||
egui::pos2(rect.min.x + visible_start_x, y + 10.0),
|
||||
egui::pos2(rect.min.x + visible_end_x, y + LAYER_HEIGHT - 10.0),
|
||||
egui::pos2(rect.min.x + visible_start_x, y + cy_min),
|
||||
egui::pos2(rect.min.x + visible_end_x, y + cy_max),
|
||||
);
|
||||
|
||||
// Draw the clip instance
|
||||
painter.rect_filled(
|
||||
clip_rect,
|
||||
3.0, // Rounded corners
|
||||
clip_color,
|
||||
);
|
||||
// Draw the clip instance background(s)
|
||||
// For looping clips, draw each iteration as a separate rounded rect
|
||||
let trim_end_for_bg = clip_instance.trim_end.unwrap_or(clip_duration);
|
||||
let content_window_for_bg = (trim_end_for_bg - clip_instance.trim_start).max(0.0);
|
||||
let is_looping_bg = instance_duration > content_window_for_bg + 0.001 && content_window_for_bg > 0.0;
|
||||
|
||||
if is_looping_bg {
|
||||
// Compute iterations aligned to content_origin
|
||||
let loop_before_val = content_origin - instance_start;
|
||||
let pre_iters = if loop_before_val > 0.001 {
|
||||
(loop_before_val / content_window_for_bg).ceil() as usize
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let right_duration = instance_duration - loop_before_val;
|
||||
let post_iters = if right_duration > 0.001 {
|
||||
(right_duration / content_window_for_bg).ceil() as usize
|
||||
} else {
|
||||
1
|
||||
};
|
||||
let total_iters = pre_iters + post_iters;
|
||||
|
||||
let faded_color = egui::Color32::from_rgba_unmultiplied(
|
||||
clip_color.r(), clip_color.g(), clip_color.b(),
|
||||
(clip_color.a() as f32 * 0.55) as u8,
|
||||
);
|
||||
for i in 0..total_iters {
|
||||
let signed_i = i as i64 - pre_iters as i64;
|
||||
let iter_time_start_raw = content_origin + signed_i as f64 * content_window_for_bg;
|
||||
let iter_time_end_raw = iter_time_start_raw + content_window_for_bg;
|
||||
let iter_time_start = iter_time_start_raw.max(instance_start);
|
||||
let iter_time_end = iter_time_end_raw.min(instance_start + instance_duration);
|
||||
if iter_time_end <= iter_time_start { continue; }
|
||||
|
||||
let ix0 = (rect.min.x + ((iter_time_start - self.viewport_start_time) * self.pixels_per_second as f64) as f32).max(clip_rect.min.x);
|
||||
let ix1 = (rect.min.x + ((iter_time_end - self.viewport_start_time) * self.pixels_per_second as f64) as f32).min(clip_rect.max.x);
|
||||
if ix1 > ix0 {
|
||||
let iter_rect = egui::Rect::from_min_max(
|
||||
egui::pos2(ix0, clip_rect.min.y),
|
||||
egui::pos2(ix1, clip_rect.max.y),
|
||||
);
|
||||
let color = if signed_i == 0 { clip_color } else { faded_color };
|
||||
painter.rect_filled(iter_rect, 3.0, color);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
painter.rect_filled(
|
||||
clip_rect,
|
||||
3.0,
|
||||
clip_color,
|
||||
);
|
||||
}
|
||||
|
||||
// AUDIO VISUALIZATION: Draw piano roll or waveform overlay
|
||||
if let lightningbeam_core::layer::AnyLayer::Audio(_) = layer {
|
||||
if let Some(clip) = document.get_audio_clip(&clip_instance.clip_id) {
|
||||
match &clip.clip_type {
|
||||
// MIDI: Draw piano roll
|
||||
// MIDI: Draw piano roll (with loop iterations)
|
||||
lightningbeam_core::clip::AudioClipType::Midi { midi_clip_id } => {
|
||||
if let Some(events) = midi_event_cache.get(midi_clip_id) {
|
||||
Self::render_midi_piano_roll(
|
||||
painter,
|
||||
clip_rect,
|
||||
rect.min.x, // Pass timeline panel left edge for proper positioning
|
||||
events,
|
||||
clip_instance.trim_start,
|
||||
instance_duration,
|
||||
instance_start,
|
||||
self.viewport_start_time,
|
||||
self.pixels_per_second,
|
||||
theme,
|
||||
ui.ctx(),
|
||||
);
|
||||
// Calculate content window for loop detection
|
||||
let preview_trim_end = clip_instance.trim_end.unwrap_or(clip_duration);
|
||||
let content_window = (preview_trim_end - preview_trim_start).max(0.0);
|
||||
let is_looping = instance_duration > content_window + 0.001;
|
||||
|
||||
if is_looping && content_window > 0.0 {
|
||||
// Compute iterations aligned to content_origin
|
||||
let lb_val = content_origin - instance_start;
|
||||
let pre = if lb_val > 0.001 { (lb_val / content_window).ceil() as usize } else { 0 };
|
||||
let right_dur = instance_duration - lb_val;
|
||||
let post = if right_dur > 0.001 { (right_dur / content_window).ceil() as usize } else { 1 };
|
||||
|
||||
for i in 0..(pre + post) {
|
||||
let si = i as i64 - pre as i64;
|
||||
let iter_start_raw = content_origin + si as f64 * content_window;
|
||||
let iter_end_raw = iter_start_raw + content_window;
|
||||
let iter_start = iter_start_raw.max(instance_start);
|
||||
let iter_end = iter_end_raw.min(instance_start + instance_duration);
|
||||
let iter_duration = iter_end - iter_start;
|
||||
if iter_duration <= 0.0 { continue; }
|
||||
|
||||
Self::render_midi_piano_roll(
|
||||
painter,
|
||||
clip_rect,
|
||||
rect.min.x,
|
||||
events,
|
||||
clip_instance.trim_start,
|
||||
iter_duration,
|
||||
iter_start,
|
||||
self.viewport_start_time,
|
||||
self.pixels_per_second,
|
||||
theme,
|
||||
ui.ctx(),
|
||||
si != 0, // fade non-content iterations
|
||||
);
|
||||
}
|
||||
} else {
|
||||
Self::render_midi_piano_roll(
|
||||
painter,
|
||||
clip_rect,
|
||||
rect.min.x,
|
||||
events,
|
||||
clip_instance.trim_start,
|
||||
instance_duration,
|
||||
instance_start,
|
||||
self.viewport_start_time,
|
||||
self.pixels_per_second,
|
||||
theme,
|
||||
ui.ctx(),
|
||||
false,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Sampled Audio: Draw waveform via GPU
|
||||
|
|
@ -1250,45 +1449,82 @@ impl TimelinePane {
|
|||
bright_color.a() as f32 / 255.0,
|
||||
];
|
||||
|
||||
let clip_screen_start = rect.min.x + ((instance_start - self.viewport_start_time) * self.pixels_per_second as f64) as f32;
|
||||
let clip_screen_end = clip_screen_start + (preview_clip_duration * self.pixels_per_second as f64) as f32;
|
||||
let waveform_rect = egui::Rect::from_min_max(
|
||||
egui::pos2(clip_screen_start.max(clip_rect.min.x), clip_rect.min.y),
|
||||
egui::pos2(clip_screen_end.min(clip_rect.max.x), clip_rect.max.y),
|
||||
);
|
||||
// Calculate content window for loop detection
|
||||
// Use trimmed content window (preview_trim_start accounts for TrimLeft drag)
|
||||
let preview_trim_end = clip_instance.trim_end.unwrap_or(clip_duration);
|
||||
let content_window = (preview_trim_end - preview_trim_start).max(0.0);
|
||||
let is_looping = instance_duration > content_window + 0.001;
|
||||
|
||||
if waveform_rect.width() > 0.0 && waveform_rect.height() > 0.0 {
|
||||
// Use clip instance UUID's lower 64 bits as stable instance ID
|
||||
let instance_id = clip_instance.id.as_u128() as u64;
|
||||
let callback = crate::waveform_gpu::WaveformCallback {
|
||||
pool_index: *audio_pool_index,
|
||||
segment_index: 0,
|
||||
params: crate::waveform_gpu::WaveformParams {
|
||||
clip_rect: [waveform_rect.min.x, waveform_rect.min.y, waveform_rect.max.x, waveform_rect.max.y],
|
||||
viewport_start_time: self.viewport_start_time as f32,
|
||||
pixels_per_second: self.pixels_per_second as f32,
|
||||
audio_duration: audio_file_duration as f32,
|
||||
sample_rate: *sr as f32,
|
||||
clip_start_time: clip_screen_start,
|
||||
trim_start: preview_trim_start as f32,
|
||||
tex_width: crate::waveform_gpu::tex_width() as f32,
|
||||
total_frames: total_frames as f32,
|
||||
segment_start_frame: 0.0,
|
||||
display_mode: if waveform_stereo { 1.0 } else { 0.0 },
|
||||
_pad1: [0.0, 0.0],
|
||||
tint_color: tint,
|
||||
screen_size: [screen_size.x, screen_size.y],
|
||||
_pad: [0.0, 0.0],
|
||||
},
|
||||
target_format,
|
||||
pending_upload,
|
||||
instance_id,
|
||||
// Compute iterations aligned to content_origin
|
||||
let lb_val = content_origin - instance_start;
|
||||
let pre_w = if is_looping && lb_val > 0.001 { (lb_val / content_window).ceil() as usize } else { 0 };
|
||||
let right_dur_w = instance_duration - lb_val;
|
||||
let post_w = if is_looping && content_window > 0.0 {
|
||||
(right_dur_w / content_window).ceil() as usize
|
||||
} else {
|
||||
1
|
||||
};
|
||||
let total_w = pre_w + post_w;
|
||||
|
||||
for wi in 0..total_w {
|
||||
let si_w = wi as i64 - pre_w as i64;
|
||||
let (iter_start, iter_duration) = if is_looping {
|
||||
let raw_start = content_origin + si_w as f64 * content_window;
|
||||
let raw_end = raw_start + content_window;
|
||||
let s = raw_start.max(instance_start);
|
||||
let e = raw_end.min(instance_start + instance_duration);
|
||||
(s, (e - s).max(0.0))
|
||||
} else {
|
||||
(instance_start, instance_duration)
|
||||
};
|
||||
|
||||
ui.painter().add(egui_wgpu::Callback::new_paint_callback(
|
||||
waveform_rect,
|
||||
callback,
|
||||
));
|
||||
if iter_duration <= 0.0 { continue; }
|
||||
|
||||
let iter_screen_start = rect.min.x + ((iter_start - self.viewport_start_time) * self.pixels_per_second as f64) as f32;
|
||||
let iter_screen_end = iter_screen_start + (iter_duration * self.pixels_per_second as f64) as f32;
|
||||
let waveform_rect = egui::Rect::from_min_max(
|
||||
egui::pos2(iter_screen_start.max(clip_rect.min.x), clip_rect.min.y),
|
||||
egui::pos2(iter_screen_end.min(clip_rect.max.x), clip_rect.max.y),
|
||||
);
|
||||
|
||||
if waveform_rect.width() > 0.0 && waveform_rect.height() > 0.0 {
|
||||
let instance_id = clip_instance.id.as_u128() as u64 + wi as u64;
|
||||
let is_loop_iter = si_w != 0;
|
||||
let callback = crate::waveform_gpu::WaveformCallback {
|
||||
pool_index: *audio_pool_index,
|
||||
segment_index: 0,
|
||||
params: crate::waveform_gpu::WaveformParams {
|
||||
clip_rect: [waveform_rect.min.x, waveform_rect.min.y, waveform_rect.max.x, waveform_rect.max.y],
|
||||
viewport_start_time: self.viewport_start_time as f32,
|
||||
pixels_per_second: self.pixels_per_second as f32,
|
||||
audio_duration: audio_file_duration as f32,
|
||||
sample_rate: *sr as f32,
|
||||
clip_start_time: iter_screen_start,
|
||||
trim_start: preview_trim_start as f32,
|
||||
tex_width: crate::waveform_gpu::tex_width() as f32,
|
||||
total_frames: total_frames as f32,
|
||||
segment_start_frame: 0.0,
|
||||
display_mode: if waveform_stereo { 1.0 } else { 0.0 },
|
||||
_pad1: [0.0, 0.0],
|
||||
tint_color: if is_loop_iter {
|
||||
[tint[0], tint[1], tint[2], tint[3] * 0.5]
|
||||
} else {
|
||||
tint
|
||||
},
|
||||
screen_size: [screen_size.x, screen_size.y],
|
||||
_pad: [0.0, 0.0],
|
||||
},
|
||||
target_format,
|
||||
pending_upload: if wi == 0 { pending_upload.clone() } else { None },
|
||||
instance_id,
|
||||
};
|
||||
|
||||
ui.painter().add(egui_wgpu::Callback::new_paint_callback(
|
||||
waveform_rect,
|
||||
callback,
|
||||
));
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1370,28 +1606,46 @@ impl TimelinePane {
|
|||
video_clip_hovers.push((clip_rect, clip_instance.clip_id, clip_instance.trim_start, instance_start));
|
||||
}
|
||||
|
||||
// Draw border on all clips for visual separation
|
||||
if selection.contains_clip_instance(&clip_instance.id) {
|
||||
// Selected: bright colored border
|
||||
painter.rect_stroke(
|
||||
clip_rect,
|
||||
3.0,
|
||||
egui::Stroke::new(3.0, bright_color),
|
||||
egui::StrokeKind::Middle,
|
||||
);
|
||||
} else {
|
||||
// Unselected: thin dark border using darker version of clip color
|
||||
let dark_border = egui::Color32::from_rgb(
|
||||
clip_color.r() / 2,
|
||||
clip_color.g() / 2,
|
||||
clip_color.b() / 2,
|
||||
);
|
||||
painter.rect_stroke(
|
||||
clip_rect,
|
||||
3.0,
|
||||
egui::Stroke::new(1.0, dark_border),
|
||||
egui::StrokeKind::Middle,
|
||||
);
|
||||
// Draw border per segment (per loop iteration for looping clips)
|
||||
{
|
||||
let is_selected = selection.contains_clip_instance(&clip_instance.id);
|
||||
let border_stroke = if is_selected {
|
||||
egui::Stroke::new(3.0, bright_color)
|
||||
} else {
|
||||
let dark_border = egui::Color32::from_rgb(
|
||||
clip_color.r() / 2,
|
||||
clip_color.g() / 2,
|
||||
clip_color.b() / 2,
|
||||
);
|
||||
egui::Stroke::new(1.0, dark_border)
|
||||
};
|
||||
|
||||
if is_looping_bg {
|
||||
// Aligned to content_origin (same as bg rendering)
|
||||
let lb_border = content_origin - instance_start;
|
||||
let pre_b = if lb_border > 0.001 { (lb_border / content_window_for_bg).ceil() as usize } else { 0 };
|
||||
let right_b = instance_duration - lb_border;
|
||||
let post_b = if right_b > 0.001 { (right_b / content_window_for_bg).ceil() as usize } else { 1 };
|
||||
for i in 0..(pre_b + post_b) {
|
||||
let si_b = i as i64 - pre_b as i64;
|
||||
let iter_time_start_raw = content_origin + si_b as f64 * content_window_for_bg;
|
||||
let iter_time_end_raw = iter_time_start_raw + content_window_for_bg;
|
||||
let iter_time_start = iter_time_start_raw.max(instance_start);
|
||||
let iter_time_end = iter_time_end_raw.min(instance_start + instance_duration);
|
||||
if iter_time_end <= iter_time_start { continue; }
|
||||
let ix0 = (rect.min.x + ((iter_time_start - self.viewport_start_time) * self.pixels_per_second as f64) as f32).max(clip_rect.min.x);
|
||||
let ix1 = (rect.min.x + ((iter_time_end - self.viewport_start_time) * self.pixels_per_second as f64) as f32).min(clip_rect.max.x);
|
||||
if ix1 > ix0 {
|
||||
let iter_rect = egui::Rect::from_min_max(
|
||||
egui::pos2(ix0, clip_rect.min.y),
|
||||
egui::pos2(ix1, clip_rect.max.y),
|
||||
);
|
||||
painter.rect_stroke(iter_rect, 3.0, border_stroke, egui::StrokeKind::Middle);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
painter.rect_stroke(clip_rect, 3.0, border_stroke, egui::StrokeKind::Middle);
|
||||
}
|
||||
}
|
||||
|
||||
// Draw clip name if there's space
|
||||
|
|
@ -1410,6 +1664,31 @@ impl TimelinePane {
|
|||
}
|
||||
}
|
||||
|
||||
// Draw shape keyframe markers for vector layers
|
||||
if let lightningbeam_core::layer::AnyLayer::Vector(vl) = layer {
|
||||
for kf in &vl.keyframes {
|
||||
let x = self.time_to_x(kf.time);
|
||||
if x >= 0.0 && x <= rect.width() {
|
||||
let cx = rect.min.x + x;
|
||||
let cy = y + LAYER_HEIGHT - 8.0;
|
||||
let size = 5.0;
|
||||
// Draw diamond shape
|
||||
let diamond = [
|
||||
egui::pos2(cx, cy - size),
|
||||
egui::pos2(cx + size, cy),
|
||||
egui::pos2(cx, cy + size),
|
||||
egui::pos2(cx - size, cy),
|
||||
];
|
||||
let color = egui::Color32::from_rgb(255, 220, 100);
|
||||
painter.add(egui::Shape::convex_polygon(
|
||||
diamond.to_vec(),
|
||||
color,
|
||||
egui::Stroke::new(1.0, egui::Color32::from_rgb(180, 150, 50)),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Separator line at bottom
|
||||
painter.line_segment(
|
||||
[
|
||||
|
|
@ -1480,8 +1759,6 @@ impl TimelinePane {
|
|||
if pos.y >= header_rect.min.y && pos.x >= content_rect.min.x {
|
||||
let relative_y = pos.y - header_rect.min.y + self.viewport_scroll_y;
|
||||
let clicked_layer_index = (relative_y / LAYER_HEIGHT) as usize;
|
||||
let click_time = self.x_to_time(pos.x - content_rect.min.x);
|
||||
|
||||
// Get the layer at this index (accounting for reversed display order)
|
||||
if clicked_layer_index < layer_count {
|
||||
let layers: Vec<_> = document.root.children.iter().rev().collect();
|
||||
|
|
@ -1497,33 +1774,25 @@ impl TimelinePane {
|
|||
};
|
||||
|
||||
// Check if click is within any clip instance
|
||||
for clip_instance in clip_instances {
|
||||
// Get the clip to determine duration
|
||||
let clip_duration = match layer {
|
||||
lightningbeam_core::layer::AnyLayer::Vector(_) => {
|
||||
document.get_vector_clip(&clip_instance.clip_id)
|
||||
.map(|c| c.duration)
|
||||
}
|
||||
lightningbeam_core::layer::AnyLayer::Audio(_) => {
|
||||
document.get_audio_clip(&clip_instance.clip_id)
|
||||
.map(|c| c.duration)
|
||||
}
|
||||
lightningbeam_core::layer::AnyLayer::Video(_) => {
|
||||
document.get_video_clip(&clip_instance.clip_id)
|
||||
.map(|c| c.duration)
|
||||
}
|
||||
lightningbeam_core::layer::AnyLayer::Effect(_) => {
|
||||
Some(lightningbeam_core::effect::EFFECT_DURATION)
|
||||
}
|
||||
};
|
||||
let click_clip_count = clip_instances.len();
|
||||
let click_layer_top = pos.y - (relative_y % LAYER_HEIGHT);
|
||||
for (ci_idx, clip_instance) in clip_instances.iter().enumerate() {
|
||||
let clip_duration = effective_clip_duration(document, layer, clip_instance);
|
||||
|
||||
if let Some(clip_duration) = clip_duration {
|
||||
let instance_duration = clip_instance.effective_duration(clip_duration);
|
||||
let instance_start = clip_instance.timeline_start;
|
||||
let instance_duration = clip_instance.total_duration(clip_duration);
|
||||
let instance_start = clip_instance.effective_start();
|
||||
let instance_end = instance_start + instance_duration;
|
||||
|
||||
// Check if click is within this clip instance's time range
|
||||
if click_time >= instance_start && click_time <= instance_end {
|
||||
// Check if click is within this clip instance's pixel range and vertical bounds
|
||||
let ci_start_x = self.time_to_x(instance_start);
|
||||
let ci_end_x = self.time_to_x(instance_end).max(ci_start_x + MIN_CLIP_WIDTH_PX);
|
||||
let click_x = pos.x - content_rect.min.x;
|
||||
let (cy_min, cy_max) = clip_instance_y_bounds(layer, ci_idx, click_clip_count);
|
||||
let click_rel_y = pos.y - click_layer_top;
|
||||
if click_x >= ci_start_x && click_x <= ci_end_x
|
||||
&& click_rel_y >= cy_min && click_rel_y <= cy_max
|
||||
{
|
||||
// Found a clicked clip instance!
|
||||
if shift_held {
|
||||
// Shift+click: add to selection
|
||||
|
|
@ -1690,27 +1959,7 @@ impl TimelinePane {
|
|||
// Find selected clip instances in this layer
|
||||
for clip_instance in clip_instances {
|
||||
if selection.contains_clip_instance(&clip_instance.id) {
|
||||
// Get clip duration to validate trim bounds
|
||||
let clip_duration = match layer {
|
||||
lightningbeam_core::layer::AnyLayer::Vector(_) => {
|
||||
document
|
||||
.get_vector_clip(&clip_instance.clip_id)
|
||||
.map(|c| c.duration)
|
||||
}
|
||||
lightningbeam_core::layer::AnyLayer::Audio(_) => {
|
||||
document
|
||||
.get_audio_clip(&clip_instance.clip_id)
|
||||
.map(|c| c.duration)
|
||||
}
|
||||
lightningbeam_core::layer::AnyLayer::Video(_) => {
|
||||
document
|
||||
.get_video_clip(&clip_instance.clip_id)
|
||||
.map(|c| c.duration)
|
||||
}
|
||||
lightningbeam_core::layer::AnyLayer::Effect(_) => {
|
||||
Some(lightningbeam_core::effect::EFFECT_DURATION)
|
||||
}
|
||||
};
|
||||
let clip_duration = effective_clip_duration(document, layer, clip_instance);
|
||||
|
||||
if let Some(clip_duration) = clip_duration {
|
||||
match drag_type {
|
||||
|
|
@ -1826,6 +2075,153 @@ impl TimelinePane {
|
|||
pending_actions.push(action);
|
||||
}
|
||||
}
|
||||
ClipDragType::LoopExtendRight => {
|
||||
let mut layer_loops: HashMap<uuid::Uuid, Vec<lightningbeam_core::actions::loop_clip_instances::LoopEntry>> = HashMap::new();
|
||||
|
||||
for layer in &document.root.children {
|
||||
let layer_id = layer.id();
|
||||
let clip_instances = match layer {
|
||||
lightningbeam_core::layer::AnyLayer::Vector(vl) => &vl.clip_instances,
|
||||
lightningbeam_core::layer::AnyLayer::Audio(al) => &al.clip_instances,
|
||||
lightningbeam_core::layer::AnyLayer::Video(vl) => &vl.clip_instances,
|
||||
lightningbeam_core::layer::AnyLayer::Effect(el) => &el.clip_instances,
|
||||
};
|
||||
|
||||
for clip_instance in clip_instances {
|
||||
if selection.contains_clip_instance(&clip_instance.id) {
|
||||
let clip_duration = match layer {
|
||||
lightningbeam_core::layer::AnyLayer::Audio(_) => {
|
||||
document.get_audio_clip(&clip_instance.clip_id).map(|c| c.duration)
|
||||
}
|
||||
_ => continue,
|
||||
};
|
||||
|
||||
if let Some(clip_duration) = clip_duration {
|
||||
let trim_end = clip_instance.trim_end.unwrap_or(clip_duration);
|
||||
let content_window = (trim_end - clip_instance.trim_start).max(0.0);
|
||||
let current_right = clip_instance.timeline_duration.unwrap_or(content_window);
|
||||
let desired_right = current_right + self.drag_offset;
|
||||
|
||||
let new_right = if desired_right > current_right {
|
||||
let max_extend = document.find_max_trim_extend_right(
|
||||
&layer_id,
|
||||
&clip_instance.id,
|
||||
clip_instance.timeline_start,
|
||||
current_right,
|
||||
);
|
||||
let extend_amount = (desired_right - current_right).min(max_extend);
|
||||
current_right + extend_amount
|
||||
} else {
|
||||
desired_right
|
||||
};
|
||||
|
||||
let old_timeline_duration = clip_instance.timeline_duration;
|
||||
let new_timeline_duration = if new_right > content_window + 0.001 {
|
||||
Some(new_right)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
if old_timeline_duration != new_timeline_duration {
|
||||
layer_loops
|
||||
.entry(layer_id)
|
||||
.or_insert_with(Vec::new)
|
||||
.push((
|
||||
clip_instance.id,
|
||||
old_timeline_duration,
|
||||
new_timeline_duration,
|
||||
clip_instance.loop_before,
|
||||
clip_instance.loop_before, // loop_before unchanged
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !layer_loops.is_empty() {
|
||||
let action = Box::new(
|
||||
lightningbeam_core::actions::LoopClipInstancesAction::new(layer_loops),
|
||||
);
|
||||
pending_actions.push(action);
|
||||
}
|
||||
}
|
||||
ClipDragType::LoopExtendLeft => {
|
||||
// Extend loop_before (pre-loop region)
|
||||
let mut layer_loops: HashMap<uuid::Uuid, Vec<lightningbeam_core::actions::loop_clip_instances::LoopEntry>> = HashMap::new();
|
||||
|
||||
for layer in &document.root.children {
|
||||
let layer_id = layer.id();
|
||||
let clip_instances = match layer {
|
||||
lightningbeam_core::layer::AnyLayer::Vector(vl) => &vl.clip_instances,
|
||||
lightningbeam_core::layer::AnyLayer::Audio(al) => &al.clip_instances,
|
||||
lightningbeam_core::layer::AnyLayer::Video(vl) => &vl.clip_instances,
|
||||
lightningbeam_core::layer::AnyLayer::Effect(el) => &el.clip_instances,
|
||||
};
|
||||
|
||||
for clip_instance in clip_instances {
|
||||
if selection.contains_clip_instance(&clip_instance.id) {
|
||||
let clip_duration = match layer {
|
||||
lightningbeam_core::layer::AnyLayer::Audio(_) => {
|
||||
document.get_audio_clip(&clip_instance.clip_id).map(|c| c.duration)
|
||||
}
|
||||
_ => continue,
|
||||
};
|
||||
|
||||
if let Some(clip_duration) = clip_duration {
|
||||
let trim_end = clip_instance.trim_end.unwrap_or(clip_duration);
|
||||
let content_window = (trim_end - clip_instance.trim_start).max(0.001);
|
||||
let current_loop_before = clip_instance.loop_before.unwrap_or(0.0);
|
||||
// Invert: dragging left (negative offset) = extend
|
||||
let desired_loop_before = (current_loop_before - self.drag_offset).max(0.0);
|
||||
// Snap to whole iterations so backend modulo aligns
|
||||
let desired_iters = (desired_loop_before / content_window).round();
|
||||
let snapped = desired_iters * content_window;
|
||||
|
||||
let new_loop_before = if snapped > current_loop_before {
|
||||
let max_extend = document.find_max_loop_extend_left(
|
||||
&layer_id,
|
||||
&clip_instance.id,
|
||||
clip_instance.effective_start(),
|
||||
);
|
||||
let extend_amount = (snapped - current_loop_before).min(max_extend);
|
||||
let clamped = current_loop_before + extend_amount;
|
||||
(clamped / content_window).floor() * content_window
|
||||
} else {
|
||||
snapped
|
||||
};
|
||||
|
||||
let old_loop_before = clip_instance.loop_before;
|
||||
let new_lb = if new_loop_before > 0.001 {
|
||||
Some(new_loop_before)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
if old_loop_before != new_lb {
|
||||
layer_loops
|
||||
.entry(layer_id)
|
||||
.or_insert_with(Vec::new)
|
||||
.push((
|
||||
clip_instance.id,
|
||||
clip_instance.timeline_duration,
|
||||
clip_instance.timeline_duration, // timeline_duration unchanged
|
||||
old_loop_before,
|
||||
new_lb,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !layer_loops.is_empty() {
|
||||
let action = Box::new(
|
||||
lightningbeam_core::actions::LoopClipInstancesAction::new(layer_loops),
|
||||
);
|
||||
pending_actions.push(action);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Reset drag state
|
||||
|
|
@ -1973,10 +2369,16 @@ impl TimelinePane {
|
|||
|
||||
// Update cursor based on hover position (only if not scrubbing or panning)
|
||||
if !self.is_scrubbing && !self.is_panning {
|
||||
// If dragging a clip with trim, keep the resize cursor
|
||||
// If dragging a clip with trim/loop, keep the appropriate cursor
|
||||
if let Some(drag_type) = self.clip_drag_state {
|
||||
if drag_type != ClipDragType::Move {
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeHorizontal);
|
||||
match drag_type {
|
||||
ClipDragType::TrimLeft | ClipDragType::TrimRight => {
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeHorizontal);
|
||||
}
|
||||
ClipDragType::LoopExtendRight | ClipDragType::LoopExtendLeft => {
|
||||
crate::custom_cursor::set(ui.ctx(), crate::custom_cursor::CustomCursor::LoopExtend);
|
||||
}
|
||||
ClipDragType::Move => {}
|
||||
}
|
||||
} else if let Some(hover_pos) = response.hover_pos() {
|
||||
// Not dragging - detect hover for cursor feedback
|
||||
|
|
@ -1986,9 +2388,14 @@ impl TimelinePane {
|
|||
content_rect,
|
||||
header_rect,
|
||||
) {
|
||||
// Set cursor for trim operations
|
||||
if drag_type != ClipDragType::Move {
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeHorizontal);
|
||||
match drag_type {
|
||||
ClipDragType::TrimLeft | ClipDragType::TrimRight => {
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeHorizontal);
|
||||
}
|
||||
ClipDragType::LoopExtendRight | ClipDragType::LoopExtendLeft => {
|
||||
crate::custom_cursor::set(ui.ctx(), crate::custom_cursor::CustomCursor::LoopExtend);
|
||||
}
|
||||
ClipDragType::Move => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -2141,24 +2548,7 @@ impl PaneRenderer for TimelinePane {
|
|||
};
|
||||
|
||||
for clip_instance in clip_instances {
|
||||
// Get clip duration
|
||||
let clip_duration = match layer {
|
||||
lightningbeam_core::layer::AnyLayer::Vector(_) => {
|
||||
document.get_vector_clip(&clip_instance.clip_id)
|
||||
.map(|c| c.duration)
|
||||
}
|
||||
lightningbeam_core::layer::AnyLayer::Audio(_) => {
|
||||
document.get_audio_clip(&clip_instance.clip_id)
|
||||
.map(|c| c.duration)
|
||||
}
|
||||
lightningbeam_core::layer::AnyLayer::Video(_) => {
|
||||
document.get_video_clip(&clip_instance.clip_id)
|
||||
.map(|c| c.duration)
|
||||
}
|
||||
lightningbeam_core::layer::AnyLayer::Effect(_) => {
|
||||
Some(lightningbeam_core::effect::EFFECT_DURATION)
|
||||
}
|
||||
};
|
||||
let clip_duration = effective_clip_duration(document, layer, clip_instance);
|
||||
|
||||
if let Some(clip_duration) = clip_duration {
|
||||
let instance_duration = clip_instance.effective_duration(clip_duration);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,810 @@
|
|||
//! Sample filename parsing and folder scanning for MultiSampler import.
|
||||
//!
|
||||
//! Handles various naming conventions found in sample libraries:
|
||||
//! - Note-octave: `a#2`, `C4`, `Gb3` (Horns, Philharmonia)
|
||||
//! - Octave_note: `2_A`, `3_Gb`, `4_Bb` (NoBudgetOrch)
|
||||
//! - Dynamic velocity markers: `ff`, `mp`, `p`, `f`
|
||||
//! - Round-robin variants: `rr1`, `rr2`, or `_2` suffix
|
||||
//! - Loop hints from filename (`-loop`, `sus`) and folder path (`Sustain/`, `Pizzicato/`)
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::collections::HashMap;
|
||||
use daw_backend::audio::node_graph::nodes::LoopMode;
|
||||
|
||||
// ─── Audio file extensions ───────────────────────────────────────────────────
|
||||
|
||||
const AUDIO_EXTENSIONS: &[&str] = &["wav", "aif", "aiff", "flac", "mp3", "ogg"];
|
||||
|
||||
fn is_audio_file(path: &Path) -> bool {
|
||||
path.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.map(|e| AUDIO_EXTENSIONS.contains(&e.to_lowercase().as_str()))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
// ─── Note name ↔ MIDI conversion ─────────────────────────────────────────────
|
||||
|
||||
/// Parse a note letter + optional accidental into a semitone offset (0=C, 11=B).
|
||||
/// Returns (semitone, chars_consumed).
|
||||
fn parse_note_letter(s: &str) -> Option<(u8, usize)> {
|
||||
let bytes = s.as_bytes();
|
||||
if bytes.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let letter = bytes[0].to_ascii_uppercase();
|
||||
let base = match letter {
|
||||
b'C' => 0,
|
||||
b'D' => 2,
|
||||
b'E' => 4,
|
||||
b'F' => 5,
|
||||
b'G' => 7,
|
||||
b'A' => 9,
|
||||
b'B' => 11,
|
||||
_ => return None,
|
||||
};
|
||||
if bytes.len() > 1 && bytes[1] == b'#' {
|
||||
Some(((base + 1) % 12, 2))
|
||||
} else if bytes.len() > 1 && bytes[1] == b'b' {
|
||||
Some(((base + 11) % 12, 2))
|
||||
} else {
|
||||
Some((base, 1))
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert a note name like "C4", "A#3", "Bb2" to a MIDI note number.
|
||||
pub fn note_name_to_midi(note: &str, octave: i8) -> Option<u8> {
|
||||
let (semitone, _) = parse_note_letter(note)?;
|
||||
let midi = (octave as i32 + 1) * 12 + semitone as i32;
|
||||
if (0..=127).contains(&midi) {
|
||||
Some(midi as u8)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Format a MIDI note number as a note name (e.g., 60 → "C4").
|
||||
pub fn midi_to_note_name(midi: u8) -> String {
|
||||
const NAMES: [&str; 12] = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"];
|
||||
let octave = (midi as i32 / 12) - 1;
|
||||
let name = NAMES[midi as usize % 12];
|
||||
format!("{}{}", name, octave)
|
||||
}
|
||||
|
||||
// ─── Filename parsing ────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum LoopHint {
|
||||
Auto,
|
||||
OneShot,
|
||||
Loop,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ParsedSample {
|
||||
pub path: PathBuf,
|
||||
pub filename: String,
|
||||
pub detected_note: Option<u8>,
|
||||
pub velocity_marker: Option<String>,
|
||||
pub rr_index: Option<u8>,
|
||||
pub is_percussion: bool,
|
||||
pub loop_hint: LoopHint,
|
||||
}
|
||||
|
||||
/// Try to find a note-octave pattern like "a#2", "C4", "Gb3" in a token.
|
||||
/// Returns (midi_note, token_is_consumed) if found.
|
||||
fn try_note_octave(token: &str) -> Option<u8> {
|
||||
let bytes = token.as_bytes();
|
||||
if bytes.is_empty() {
|
||||
return None;
|
||||
}
|
||||
// Must start with a note letter
|
||||
let first = bytes[0].to_ascii_uppercase();
|
||||
if !matches!(first, b'A'..=b'G') {
|
||||
return None;
|
||||
}
|
||||
let (semitone, consumed) = parse_note_letter(token)?;
|
||||
let rest = &token[consumed..];
|
||||
// Next must be an octave digit (optionally preceded by -)
|
||||
let octave_str = rest;
|
||||
let octave: i8 = octave_str.parse().ok()?;
|
||||
if (-1..=9).contains(&octave) {
|
||||
let midi = (octave as i32 + 1) * 12 + semitone as i32;
|
||||
if (0..=127).contains(&midi) {
|
||||
return Some(midi as u8);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Try to find an octave_note pattern like "2_A", "3_Gb" across two adjacent tokens.
|
||||
/// token1 is the octave number, token2 is the note name.
|
||||
fn try_octave_note(octave_token: &str, note_token: &str) -> Option<u8> {
|
||||
let octave: i8 = octave_token.parse().ok()?;
|
||||
if !(-1..=9).contains(&octave) {
|
||||
return None;
|
||||
}
|
||||
// note_token should be just a note letter + optional accidental, no octave digit
|
||||
let (semitone, consumed) = parse_note_letter(note_token)?;
|
||||
// Remaining after note should be empty (pure note token)
|
||||
if consumed != note_token.len() {
|
||||
return None;
|
||||
}
|
||||
let midi = (octave as i32 + 1) * 12 + semitone as i32;
|
||||
if (0..=127).contains(&midi) {
|
||||
Some(midi as u8)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Dynamic markings sorted by loudness.
|
||||
const DYNAMICS: &[&str] = &["ppp", "pp", "p", "mp", "mf", "f", "ff", "fff"];
|
||||
|
||||
/// Check if a token is a dynamic marking (exact match, case-insensitive).
|
||||
fn is_dynamic_marker(token: &str) -> bool {
|
||||
let lower = token.to_lowercase();
|
||||
DYNAMICS.contains(&lower.as_str())
|
||||
}
|
||||
|
||||
/// Get the sort order for a velocity marker (lower = softer).
|
||||
pub fn velocity_marker_order(marker: &str) -> u8 {
|
||||
let lower = marker.to_lowercase();
|
||||
match lower.as_str() {
|
||||
"ppp" => 0,
|
||||
"pp" => 1,
|
||||
"p" => 2,
|
||||
"mp" => 3,
|
||||
"mf" => 4,
|
||||
"f" => 5,
|
||||
"ff" => 6,
|
||||
"fff" => 7,
|
||||
_ => {
|
||||
// Numeric markers: v1, v2, v3...
|
||||
if let Some(rest) = lower.strip_prefix('v') {
|
||||
if let Ok(n) = rest.parse::<u8>() {
|
||||
return n.saturating_add(10); // offset to separate from dynamics
|
||||
}
|
||||
}
|
||||
128 // unknown, sort last
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if a token is a round-robin marker like "rr1", "rr2".
|
||||
fn parse_rr_marker(token: &str) -> Option<u8> {
|
||||
let lower = token.to_lowercase();
|
||||
lower.strip_prefix("rr")?.parse().ok()
|
||||
}
|
||||
|
||||
/// Detect loop hints from filename tokens and folder path.
|
||||
fn detect_loop_hint(tokens: &[&str], full_path: &Path) -> LoopHint {
|
||||
// Check filename tokens
|
||||
for token in tokens {
|
||||
let lower = token.to_lowercase();
|
||||
if lower == "loop" {
|
||||
return LoopHint::Loop;
|
||||
}
|
||||
if matches!(lower.as_str(), "sus" | "sustain") {
|
||||
return LoopHint::Loop;
|
||||
}
|
||||
if matches!(lower.as_str(), "stac" | "stc" | "staccato" | "piz" | "pizz" | "pizzicato") {
|
||||
return LoopHint::OneShot;
|
||||
}
|
||||
}
|
||||
// Check folder path components
|
||||
for component in full_path.components() {
|
||||
if let std::path::Component::Normal(name) = component {
|
||||
let name_lower = name.to_string_lossy().to_lowercase();
|
||||
if matches!(name_lower.as_str(), "sustain" | "vibrato" | "tremolo") {
|
||||
return LoopHint::Loop;
|
||||
}
|
||||
if matches!(name_lower.as_str(), "pizzicato" | "staccato") {
|
||||
return LoopHint::OneShot;
|
||||
}
|
||||
}
|
||||
}
|
||||
LoopHint::Auto
|
||||
}
|
||||
|
||||
/// Tokenize a filename stem on common delimiters.
|
||||
fn tokenize(stem: &str) -> Vec<&str> {
|
||||
stem.split(|c: char| c == '-' || c == '_' || c == '.' || c == ' ')
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Parse a sample filename to extract note, velocity, round-robin, and loop hint info.
|
||||
/// `folder_path` is used for loop/articulation context from parent directory names.
|
||||
pub fn parse_sample_filename(path: &Path, folder_path: &Path) -> ParsedSample {
|
||||
let filename = path.file_name()
|
||||
.map(|n| n.to_string_lossy().to_string())
|
||||
.unwrap_or_default();
|
||||
|
||||
// Strip extension to get stem
|
||||
let stem = path.file_stem()
|
||||
.map(|s| s.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|| filename.clone());
|
||||
|
||||
let tokens = tokenize(&stem);
|
||||
let loop_hint = detect_loop_hint(&tokens, path);
|
||||
|
||||
let mut detected_note: Option<u8> = None;
|
||||
let mut velocity_marker: Option<String> = None;
|
||||
let mut rr_index: Option<u8> = None;
|
||||
let mut note_token_indices: Vec<usize> = Vec::new();
|
||||
|
||||
// Pass 1: Find notes using note-octave format (e.g., "a#2", "C4")
|
||||
// Use last match as it's most reliable
|
||||
for (i, token) in tokens.iter().enumerate() {
|
||||
if let Some(midi) = try_note_octave(token) {
|
||||
detected_note = Some(midi);
|
||||
note_token_indices.clear();
|
||||
note_token_indices.push(i);
|
||||
}
|
||||
}
|
||||
|
||||
// Pass 2: If no note-octave found, try octave_note format (e.g., "2" + "A", "3" + "Gb")
|
||||
if detected_note.is_none() && tokens.len() >= 2 {
|
||||
for i in 0..tokens.len() - 1 {
|
||||
if let Some(midi) = try_octave_note(tokens[i], tokens[i + 1]) {
|
||||
detected_note = Some(midi);
|
||||
note_token_indices.clear();
|
||||
note_token_indices.push(i);
|
||||
note_token_indices.push(i + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Pass 3: Find velocity markers and round-robin
|
||||
for (i, token) in tokens.iter().enumerate() {
|
||||
if note_token_indices.contains(&i) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Round-robin: "rr1", "rr2"
|
||||
if let Some(rr) = parse_rr_marker(token) {
|
||||
rr_index = Some(rr);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Dynamic markers: "ff", "mp", "p", "f" etc. (must be exact token match)
|
||||
if is_dynamic_marker(token) {
|
||||
velocity_marker = Some(token.to_lowercase());
|
||||
continue;
|
||||
}
|
||||
|
||||
// Numeric velocity: "v1", "v2"
|
||||
let lower = token.to_lowercase();
|
||||
if lower.starts_with('v') && lower[1..].parse::<u8>().is_ok() {
|
||||
velocity_marker = Some(lower);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Pass 4: For octave_note format, check if trailing bare number after note is RR variant
|
||||
// e.g., "5_C_2" → tokens ["5", "C", "2"] — "2" is RR, not a note
|
||||
if detected_note.is_some() && rr_index.is_none() && note_token_indices.len() == 2 {
|
||||
let after_note = note_token_indices[1] + 1;
|
||||
if after_note < tokens.len() {
|
||||
let candidate = tokens[after_note];
|
||||
// If it's a bare small number and NOT a dynamic marker, treat as RR
|
||||
if let Ok(n) = candidate.parse::<u8>() {
|
||||
if n >= 1 && n <= 20 && !is_dynamic_marker(candidate) {
|
||||
rr_index = Some(n);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Pass 5: Check for suffix velocity in octave_note format
|
||||
// e.g., "3_A_f.wav" → the "f" after note could be velocity
|
||||
// Only apply if we used octave_note format and velocity wasn't already found
|
||||
if velocity_marker.is_none() && note_token_indices.len() == 2 {
|
||||
let after_note = note_token_indices[1] + 1;
|
||||
if after_note < tokens.len() {
|
||||
let candidate = tokens[after_note];
|
||||
if is_dynamic_marker(candidate) && rr_index.as_ref().map_or(true, |&rr| {
|
||||
// If rr was parsed from this position, don't also treat it as velocity
|
||||
after_note < tokens.len() - 1 || rr == 0
|
||||
}) {
|
||||
velocity_marker = Some(candidate.to_lowercase());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ParsedSample {
|
||||
path: path.to_path_buf(),
|
||||
filename,
|
||||
detected_note,
|
||||
velocity_marker,
|
||||
rr_index,
|
||||
is_percussion: false, // set later in percussion pass
|
||||
loop_hint,
|
||||
}
|
||||
}
|
||||
|
||||
// ─── GM Drum Map ─────────────────────────────────────────────────────────────
|
||||
|
||||
/// GM drum note assignments for common percussion instrument names.
|
||||
const GM_DRUM_MAP: &[(&[&str], u8)] = &[
|
||||
(&["kick", "bass_drum", "bassdrum", "bdrum"], 36),
|
||||
(&["rimshot", "rim"], 37),
|
||||
(&["snare"], 38),
|
||||
(&["clap", "handclap"], 39),
|
||||
(&["hihat", "hi_hat", "hh"], 42),
|
||||
(&["tom"], 45),
|
||||
(&["crash"], 49),
|
||||
(&["ride"], 51),
|
||||
(&["cymbal"], 52),
|
||||
(&["tamtam", "tam_tam", "gong"], 52),
|
||||
(&["tambourine", "tamb"], 54),
|
||||
(&["cowbell"], 56),
|
||||
(&["bongo"], 60),
|
||||
(&["conga"], 63),
|
||||
(&["shaker"], 70),
|
||||
(&["woodblock"], 76),
|
||||
(&["triangle"], 81),
|
||||
(&["bar_chimes", "chime", "chimes"], 84),
|
||||
(&["castanets"], 85),
|
||||
];
|
||||
|
||||
/// Try to match a filename/path against GM drum instrument names.
|
||||
fn gm_drum_note(filename: &str, relative_path: &str) -> Option<u8> {
|
||||
let search = format!("{}/{}", relative_path, filename).to_lowercase();
|
||||
for (names, midi) in GM_DRUM_MAP {
|
||||
for name in *names {
|
||||
if search.contains(name) {
|
||||
return Some(*midi);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
// ─── Folder scanning ─────────────────────────────────────────────────────────
|
||||
|
||||
/// Recursively collect audio files from a folder.
|
||||
fn collect_audio_files(dir: &Path, files: &mut Vec<PathBuf>) -> std::io::Result<()> {
|
||||
if !dir.is_dir() {
|
||||
return Ok(());
|
||||
}
|
||||
let mut entries: Vec<_> = std::fs::read_dir(dir)?
|
||||
.filter_map(|e| e.ok())
|
||||
.collect();
|
||||
entries.sort_by_key(|e| e.file_name());
|
||||
|
||||
for entry in entries {
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
collect_audio_files(&path, files)?;
|
||||
} else if is_audio_file(&path) {
|
||||
files.push(path);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Scan a folder for audio samples, parse filenames, and apply percussion detection.
|
||||
pub fn scan_folder(folder_path: &Path) -> std::io::Result<Vec<ParsedSample>> {
|
||||
let mut files = Vec::new();
|
||||
collect_audio_files(folder_path, &mut files)?;
|
||||
|
||||
let mut samples: Vec<ParsedSample> = files.iter()
|
||||
.map(|path| parse_sample_filename(path, folder_path))
|
||||
.collect();
|
||||
|
||||
// Percussion pass: for samples with no detected note, try GM drum mapping
|
||||
let mut used_drum_notes: Vec<u8> = Vec::new();
|
||||
for sample in &mut samples {
|
||||
if sample.detected_note.is_some() {
|
||||
continue;
|
||||
}
|
||||
let relative = sample.path.strip_prefix(folder_path)
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
.unwrap_or_default();
|
||||
if let Some(drum_note) = gm_drum_note(&sample.filename, &relative) {
|
||||
// Avoid duplicate drum note assignments — if already taken, offset
|
||||
let mut note = drum_note;
|
||||
while used_drum_notes.contains(¬e) && note < 127 {
|
||||
note += 1;
|
||||
}
|
||||
sample.detected_note = Some(note);
|
||||
sample.is_percussion = true;
|
||||
used_drum_notes.push(note);
|
||||
}
|
||||
}
|
||||
|
||||
// For remaining unmapped percussion: assign sequential notes from 36
|
||||
let mut next_drum = 36u8;
|
||||
for sample in &mut samples {
|
||||
if sample.detected_note.is_some() {
|
||||
continue;
|
||||
}
|
||||
// Skip notes already used
|
||||
while used_drum_notes.contains(&next_drum) && next_drum < 127 {
|
||||
next_drum += 1;
|
||||
}
|
||||
if next_drum <= 127 {
|
||||
sample.detected_note = Some(next_drum);
|
||||
sample.is_percussion = true;
|
||||
used_drum_notes.push(next_drum);
|
||||
next_drum += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by note, then velocity, then RR index
|
||||
samples.sort_by(|a, b| {
|
||||
a.detected_note.cmp(&b.detected_note)
|
||||
.then_with(|| {
|
||||
let va = a.velocity_marker.as_deref().map(velocity_marker_order).unwrap_or(128);
|
||||
let vb = b.velocity_marker.as_deref().map(velocity_marker_order).unwrap_or(128);
|
||||
va.cmp(&vb)
|
||||
})
|
||||
.then_with(|| a.rr_index.cmp(&b.rr_index))
|
||||
});
|
||||
|
||||
Ok(samples)
|
||||
}
|
||||
|
||||
// ─── Import layer building ───────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ImportLayer {
|
||||
pub path: PathBuf,
|
||||
pub filename: String,
|
||||
pub root_key: u8,
|
||||
pub key_min: u8,
|
||||
pub key_max: u8,
|
||||
pub velocity_min: u8,
|
||||
pub velocity_max: u8,
|
||||
pub enabled: bool,
|
||||
pub is_percussion: bool,
|
||||
}
|
||||
|
||||
pub struct FolderScanResult {
|
||||
pub layers: Vec<ImportLayer>,
|
||||
pub unmapped: Vec<ParsedSample>,
|
||||
pub loop_mode: LoopMode,
|
||||
pub velocity_markers: Vec<String>,
|
||||
pub velocity_ranges: Vec<(String, u8, u8)>,
|
||||
pub detected_articulation: Option<String>,
|
||||
}
|
||||
|
||||
/// Compute auto key ranges for a sorted list of unique MIDI notes.
|
||||
/// Each note gets the range from midpoint-to-previous to midpoint-to-next.
|
||||
fn auto_key_ranges(notes: &[u8]) -> Vec<(u8, u8)> {
|
||||
if notes.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
if notes.len() == 1 {
|
||||
return vec![(0, 127)];
|
||||
}
|
||||
let mut ranges = Vec::with_capacity(notes.len());
|
||||
for i in 0..notes.len() {
|
||||
let min = if i == 0 {
|
||||
0
|
||||
} else {
|
||||
((notes[i - 1] as u16 + notes[i] as u16 + 1) / 2) as u8
|
||||
};
|
||||
let max = if i == notes.len() - 1 {
|
||||
127
|
||||
} else {
|
||||
((notes[i] as u16 + notes[i + 1] as u16) / 2) as u8
|
||||
};
|
||||
ranges.push((min, max));
|
||||
}
|
||||
ranges
|
||||
}
|
||||
|
||||
/// Compute velocity ranges by evenly splitting 0-127 among sorted markers.
|
||||
fn auto_velocity_ranges(markers: &[String]) -> Vec<(String, u8, u8)> {
|
||||
if markers.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
if markers.len() == 1 {
|
||||
return vec![(markers[0].clone(), 0, 127)];
|
||||
}
|
||||
let n = markers.len();
|
||||
let step = 128.0 / n as f32;
|
||||
markers.iter().enumerate().map(|(i, m)| {
|
||||
let min = (i as f32 * step).round() as u8;
|
||||
let max = if i == n - 1 { 127 } else { ((i + 1) as f32 * step).round() as u8 - 1 };
|
||||
(m.clone(), min, max)
|
||||
}).collect()
|
||||
}
|
||||
|
||||
/// Detect global loop mode from all parsed samples' loop hints.
|
||||
fn detect_global_loop_mode(samples: &[ParsedSample]) -> LoopMode {
|
||||
let mut loop_count = 0;
|
||||
let mut oneshot_count = 0;
|
||||
for s in samples {
|
||||
match s.loop_hint {
|
||||
LoopHint::Loop => loop_count += 1,
|
||||
LoopHint::OneShot => oneshot_count += 1,
|
||||
LoopHint::Auto => {}
|
||||
}
|
||||
}
|
||||
if loop_count > oneshot_count {
|
||||
LoopMode::Continuous
|
||||
} else if oneshot_count > 0 {
|
||||
LoopMode::OneShot
|
||||
} else {
|
||||
LoopMode::OneShot // default when no hints
|
||||
}
|
||||
}
|
||||
|
||||
/// Detect articulation from folder path.
|
||||
fn detect_articulation(folder_path: &Path) -> Option<String> {
|
||||
for component in folder_path.components().rev() {
|
||||
if let std::path::Component::Normal(name) = component {
|
||||
let lower = name.to_string_lossy().to_lowercase();
|
||||
match lower.as_str() {
|
||||
"sustain" | "vibrato" | "tremolo" | "pizzicato" | "staccato" |
|
||||
"legato" | "marcato" | "spiccato" | "arco" => {
|
||||
return Some(name.to_string_lossy().to_string());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Build import layers from parsed samples with auto key ranges and velocity mapping.
|
||||
pub fn build_import_layers(samples: Vec<ParsedSample>, folder_path: &Path) -> FolderScanResult {
|
||||
let loop_mode = detect_global_loop_mode(&samples);
|
||||
let detected_articulation = detect_articulation(folder_path);
|
||||
|
||||
// Separate mapped vs unmapped
|
||||
let mut mapped: Vec<ParsedSample> = Vec::new();
|
||||
let mut unmapped: Vec<ParsedSample> = Vec::new();
|
||||
for s in samples {
|
||||
if s.detected_note.is_some() {
|
||||
mapped.push(s);
|
||||
} else {
|
||||
unmapped.push(s);
|
||||
}
|
||||
}
|
||||
|
||||
// Collect unique velocity markers (sorted by loudness)
|
||||
let mut velocity_markers: Vec<String> = mapped.iter()
|
||||
.filter_map(|s| s.velocity_marker.clone())
|
||||
.collect::<std::collections::HashSet<_>>()
|
||||
.into_iter()
|
||||
.collect();
|
||||
velocity_markers.sort_by_key(|m| velocity_marker_order(m));
|
||||
|
||||
let velocity_ranges = auto_velocity_ranges(&velocity_markers);
|
||||
|
||||
// Build velocity lookup: marker → (min, max)
|
||||
let vel_map: HashMap<String, (u8, u8)> = velocity_ranges.iter()
|
||||
.map(|(m, min, max)| (m.clone(), (*min, *max)))
|
||||
.collect();
|
||||
|
||||
// Collect unique notes for auto key range computation
|
||||
let mut unique_notes: Vec<u8> = mapped.iter()
|
||||
.filter_map(|s| s.detected_note)
|
||||
.collect::<std::collections::HashSet<_>>()
|
||||
.into_iter()
|
||||
.collect();
|
||||
unique_notes.sort();
|
||||
|
||||
let key_ranges = auto_key_ranges(&unique_notes);
|
||||
let note_to_range: HashMap<u8, (u8, u8)> = unique_notes.iter()
|
||||
.zip(key_ranges.iter())
|
||||
.map(|(¬e, &range)| (note, range))
|
||||
.collect();
|
||||
|
||||
// Build layers
|
||||
let layers: Vec<ImportLayer> = mapped.iter().map(|s| {
|
||||
let root_key = s.detected_note.unwrap();
|
||||
let (key_min, key_max) = note_to_range.get(&root_key).copied().unwrap_or((0, 127));
|
||||
let (vel_min, vel_max) = s.velocity_marker.as_ref()
|
||||
.and_then(|m| vel_map.get(m))
|
||||
.copied()
|
||||
.unwrap_or((0, 127));
|
||||
|
||||
ImportLayer {
|
||||
path: s.path.clone(),
|
||||
filename: s.filename.clone(),
|
||||
root_key,
|
||||
key_min,
|
||||
key_max,
|
||||
velocity_min: vel_min,
|
||||
velocity_max: vel_max,
|
||||
enabled: true,
|
||||
is_percussion: s.is_percussion,
|
||||
}
|
||||
}).collect();
|
||||
|
||||
FolderScanResult {
|
||||
layers,
|
||||
unmapped,
|
||||
loop_mode,
|
||||
velocity_markers,
|
||||
velocity_ranges,
|
||||
detected_articulation,
|
||||
}
|
||||
}
|
||||
|
||||
/// Recompute key ranges for layers based on their current root_key values.
|
||||
/// Only affects enabled, non-percussion layers.
|
||||
pub fn recalc_key_ranges(layers: &mut [ImportLayer]) {
|
||||
let mut unique_notes: Vec<u8> = layers.iter()
|
||||
.filter(|l| l.enabled && !l.is_percussion)
|
||||
.map(|l| l.root_key)
|
||||
.collect::<std::collections::HashSet<_>>()
|
||||
.into_iter()
|
||||
.collect();
|
||||
unique_notes.sort();
|
||||
|
||||
let ranges = auto_key_ranges(&unique_notes);
|
||||
let note_to_range: HashMap<u8, (u8, u8)> = unique_notes.iter()
|
||||
.zip(ranges.iter())
|
||||
.map(|(¬e, &range)| (note, range))
|
||||
.collect();
|
||||
|
||||
for layer in layers.iter_mut() {
|
||||
if !layer.enabled || layer.is_percussion {
|
||||
continue;
|
||||
}
|
||||
if let Some(&(min, max)) = note_to_range.get(&layer.root_key) {
|
||||
layer.key_min = min;
|
||||
layer.key_max = max;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Tests ───────────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_note_name_to_midi() {
|
||||
assert_eq!(note_name_to_midi("C", 4), Some(60));
|
||||
assert_eq!(note_name_to_midi("A", 4), Some(69));
|
||||
assert_eq!(note_name_to_midi("A#", 3), Some(58));
|
||||
assert_eq!(note_name_to_midi("Bb", 2), Some(46));
|
||||
assert_eq!(note_name_to_midi("C", -1), Some(0));
|
||||
assert_eq!(note_name_to_midi("G", 9), Some(127));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_note_octave_format() {
|
||||
// Horns: horns-sus-ff-a#2-PB-loop.wav
|
||||
let p = parse_sample_filename(
|
||||
Path::new("/samples/horns-sus-ff-a#2-PB-loop.wav"),
|
||||
Path::new("/samples"),
|
||||
);
|
||||
assert_eq!(p.detected_note, Some(46)); // A#2
|
||||
assert_eq!(p.velocity_marker, Some("ff".to_string()));
|
||||
assert_eq!(p.loop_hint, LoopHint::Loop);
|
||||
|
||||
// Philharmonia: viola_A#3-staccato-rr1-PB.wav
|
||||
let p = parse_sample_filename(
|
||||
Path::new("/samples/viola_A#3-staccato-rr1-PB.wav"),
|
||||
Path::new("/samples"),
|
||||
);
|
||||
assert_eq!(p.detected_note, Some(58)); // A#3
|
||||
assert_eq!(p.rr_index, Some(1));
|
||||
assert_eq!(p.loop_hint, LoopHint::OneShot);
|
||||
|
||||
// Bare note: A1.mp3
|
||||
let p = parse_sample_filename(
|
||||
Path::new("/samples/A1.mp3"),
|
||||
Path::new("/samples"),
|
||||
);
|
||||
assert_eq!(p.detected_note, Some(33)); // A1
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_octave_note_format() {
|
||||
// NoBudgetOrch: 2_A-PB.wav
|
||||
let p = parse_sample_filename(
|
||||
Path::new("/samples/2_A-PB.wav"),
|
||||
Path::new("/samples"),
|
||||
);
|
||||
assert_eq!(p.detected_note, Some(45)); // A2
|
||||
|
||||
// 3_Gb-PB.wav
|
||||
let p = parse_sample_filename(
|
||||
Path::new("/samples/3_Gb-PB.wav"),
|
||||
Path::new("/samples"),
|
||||
);
|
||||
assert_eq!(p.detected_note, Some(54)); // Gb3
|
||||
|
||||
// 1_Bb.wav
|
||||
let p = parse_sample_filename(
|
||||
Path::new("/samples/1_Bb.wav"),
|
||||
Path::new("/samples"),
|
||||
);
|
||||
assert_eq!(p.detected_note, Some(34)); // Bb1
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_velocity_suffix() {
|
||||
// NoBudgetOrch TubularBells: 3_A_f.wav
|
||||
let p = parse_sample_filename(
|
||||
Path::new("/samples/3_A_f.wav"),
|
||||
Path::new("/samples"),
|
||||
);
|
||||
assert_eq!(p.detected_note, Some(57)); // A3
|
||||
assert_eq!(p.velocity_marker, Some("f".to_string()));
|
||||
|
||||
// 3_C_p.wav
|
||||
let p = parse_sample_filename(
|
||||
Path::new("/samples/3_C_p.wav"),
|
||||
Path::new("/samples"),
|
||||
);
|
||||
assert_eq!(p.detected_note, Some(48)); // C3
|
||||
assert_eq!(p.velocity_marker, Some("p".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rr_detection() {
|
||||
// NoBudgetOrch: 5_C_2-PB.wav → C5, rr2
|
||||
let p = parse_sample_filename(
|
||||
Path::new("/samples/5_C_2-PB.wav"),
|
||||
Path::new("/samples"),
|
||||
);
|
||||
assert_eq!(p.detected_note, Some(72)); // C5
|
||||
assert_eq!(p.rr_index, Some(2));
|
||||
|
||||
// rr marker: viola_A#3-staccato-rr1-PB.wav
|
||||
let p = parse_sample_filename(
|
||||
Path::new("/samples/viola_A#3-staccato-rr1-PB.wav"),
|
||||
Path::new("/samples"),
|
||||
);
|
||||
assert_eq!(p.rr_index, Some(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_loop_hints_from_folder() {
|
||||
let p = parse_sample_filename(
|
||||
Path::new("/libs/Cello/Sustain/2_A.wav"),
|
||||
Path::new("/libs/Cello/Sustain"),
|
||||
);
|
||||
assert_eq!(p.loop_hint, LoopHint::Loop);
|
||||
|
||||
let p = parse_sample_filename(
|
||||
Path::new("/libs/Cello/Pizzicato/2_A-PB.wav"),
|
||||
Path::new("/libs/Cello/Pizzicato"),
|
||||
);
|
||||
assert_eq!(p.loop_hint, LoopHint::OneShot);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_gm_drum_mapping() {
|
||||
assert_eq!(gm_drum_note("snare-lh-ff-PB.wav", "Percussion"), Some(38));
|
||||
assert_eq!(gm_drum_note("bass_drum-f-PB.wav", "Percussion"), Some(36));
|
||||
assert_eq!(gm_drum_note("castanets_mf1-PB.wav", "Percussion"), Some(85));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_auto_key_ranges() {
|
||||
let notes = vec![36, 48, 60, 72];
|
||||
let ranges = auto_key_ranges(¬es);
|
||||
assert_eq!(ranges[0], (0, 42)); // 36: 0 to (36+48)/2=42
|
||||
assert_eq!(ranges[1], (43, 54)); // 48: 43 to (48+60)/2=54
|
||||
assert_eq!(ranges[2], (55, 66)); // 60: 55 to (60+72)/2=66
|
||||
assert_eq!(ranges[3], (67, 127)); // 72: 67 to 127
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_auto_velocity_ranges() {
|
||||
let markers = vec!["p".to_string(), "f".to_string()];
|
||||
let ranges = auto_velocity_ranges(&markers);
|
||||
assert_eq!(ranges[0], ("p".to_string(), 0, 63));
|
||||
assert_eq!(ranges[1], ("f".to_string(), 64, 127));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_velocity_marker_order() {
|
||||
assert!(velocity_marker_order("p") < velocity_marker_order("f"));
|
||||
assert!(velocity_marker_order("pp") < velocity_marker_order("mp"));
|
||||
assert!(velocity_marker_order("mf") < velocity_marker_order("ff"));
|
||||
assert!(velocity_marker_order("v1") < velocity_marker_order("v2"));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,242 @@
|
|||
//! Import dialog for MultiSampler folder import.
|
||||
//!
|
||||
//! Shows a preview of parsed samples with editable note mappings, velocity ranges,
|
||||
//! and loop mode before committing the import.
|
||||
|
||||
use eframe::egui;
|
||||
use egui_node_graph2::NodeId;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::sample_import::{
|
||||
FolderScanResult, ImportLayer, midi_to_note_name, recalc_key_ranges,
|
||||
};
|
||||
use daw_backend::audio::node_graph::nodes::LoopMode;
|
||||
|
||||
pub struct SampleImportDialog {
|
||||
pub folder_path: PathBuf,
|
||||
pub scan_result: FolderScanResult,
|
||||
pub loop_mode: LoopMode,
|
||||
pub auto_key_ranges: bool,
|
||||
pub confirmed: bool,
|
||||
pub should_close: bool,
|
||||
pub track_id: u32,
|
||||
pub backend_node_id: u32,
|
||||
pub node_id: NodeId,
|
||||
}
|
||||
|
||||
impl SampleImportDialog {
|
||||
pub fn new(
|
||||
folder_path: PathBuf,
|
||||
scan_result: FolderScanResult,
|
||||
track_id: u32,
|
||||
backend_node_id: u32,
|
||||
node_id: NodeId,
|
||||
) -> Self {
|
||||
let loop_mode = scan_result.loop_mode;
|
||||
Self {
|
||||
folder_path,
|
||||
scan_result,
|
||||
loop_mode,
|
||||
auto_key_ranges: true,
|
||||
confirmed: false,
|
||||
should_close: false,
|
||||
track_id,
|
||||
backend_node_id,
|
||||
node_id,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns true while the dialog is still open.
|
||||
pub fn show(&mut self, ctx: &egui::Context) -> bool {
|
||||
let mut open = true;
|
||||
let mut should_import = false;
|
||||
let mut should_cancel = false;
|
||||
let mut recalc = false;
|
||||
|
||||
egui::Window::new("Import Samples")
|
||||
.open(&mut open)
|
||||
.resizable(true)
|
||||
.collapsible(false)
|
||||
.anchor(egui::Align2::CENTER_CENTER, egui::Vec2::ZERO)
|
||||
.default_width(700.0)
|
||||
.default_height(500.0)
|
||||
.show(ctx, |ui| {
|
||||
// Folder info
|
||||
ui.label(format!("Folder: {}", self.folder_path.display()));
|
||||
|
||||
let enabled_count = self.scan_result.layers.iter().filter(|l| l.enabled).count();
|
||||
let unique_notes: std::collections::HashSet<u8> = self.scan_result.layers.iter()
|
||||
.filter(|l| l.enabled)
|
||||
.map(|l| l.root_key)
|
||||
.collect();
|
||||
let vel_count = self.scan_result.velocity_markers.len();
|
||||
ui.label(format!(
|
||||
"Found: {} samples, {} notes, {} velocity layer{}",
|
||||
enabled_count,
|
||||
unique_notes.len(),
|
||||
vel_count,
|
||||
if vel_count != 1 { "s" } else { "" },
|
||||
));
|
||||
ui.add_space(4.0);
|
||||
|
||||
// Global controls
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Loop mode:");
|
||||
egui::ComboBox::from_id_salt("loop_mode")
|
||||
.selected_text(match self.loop_mode {
|
||||
LoopMode::OneShot => "One Shot",
|
||||
LoopMode::Continuous => "Continuous",
|
||||
})
|
||||
.show_ui(ui, |ui| {
|
||||
ui.selectable_value(&mut self.loop_mode, LoopMode::OneShot, "One Shot");
|
||||
ui.selectable_value(&mut self.loop_mode, LoopMode::Continuous, "Continuous");
|
||||
});
|
||||
|
||||
ui.add_space(16.0);
|
||||
if ui.checkbox(&mut self.auto_key_ranges, "Auto key ranges").changed() {
|
||||
if self.auto_key_ranges {
|
||||
recalc = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.add_space(4.0);
|
||||
|
||||
// Velocity mapping table
|
||||
if !self.scan_result.velocity_ranges.is_empty() {
|
||||
ui.collapsing("Velocity Mapping", |ui| {
|
||||
egui::Grid::new("vel_grid").striped(true).show(ui, |ui| {
|
||||
ui.label(egui::RichText::new("Marker").strong());
|
||||
ui.label(egui::RichText::new("Min").strong());
|
||||
ui.label(egui::RichText::new("Max").strong());
|
||||
ui.end_row();
|
||||
|
||||
for (marker, min, max) in &mut self.scan_result.velocity_ranges {
|
||||
ui.label(&*marker);
|
||||
ui.add(egui::DragValue::new(min).range(0..=127).speed(1));
|
||||
ui.add(egui::DragValue::new(max).range(0..=127).speed(1));
|
||||
ui.end_row();
|
||||
}
|
||||
});
|
||||
});
|
||||
ui.add_space(4.0);
|
||||
}
|
||||
|
||||
// Layers table
|
||||
ui.separator();
|
||||
ui.label(egui::RichText::new("Layers").strong());
|
||||
let available_height = ui.available_height() - 40.0; // reserve space for buttons
|
||||
egui::ScrollArea::vertical()
|
||||
.max_height(available_height.max(100.0))
|
||||
.show(ui, |ui| {
|
||||
egui::Grid::new("layers_grid")
|
||||
.striped(true)
|
||||
.min_col_width(20.0)
|
||||
.show(ui, |ui| {
|
||||
// Header
|
||||
ui.label(""); // checkbox column
|
||||
ui.label(egui::RichText::new("File").strong());
|
||||
ui.label(egui::RichText::new("Root").strong());
|
||||
ui.label(egui::RichText::new("Key Range").strong());
|
||||
ui.label(egui::RichText::new("Vel Range").strong());
|
||||
ui.end_row();
|
||||
|
||||
for i in 0..self.scan_result.layers.len() {
|
||||
let layer = &mut self.scan_result.layers[i];
|
||||
if ui.checkbox(&mut layer.enabled, "").changed() && self.auto_key_ranges {
|
||||
recalc = true;
|
||||
}
|
||||
|
||||
// Filename (truncated)
|
||||
let name = if layer.filename.len() > 40 {
|
||||
format!("...{}", &layer.filename[layer.filename.len()-37..])
|
||||
} else {
|
||||
layer.filename.clone()
|
||||
};
|
||||
ui.label(&name).on_hover_text(&layer.filename);
|
||||
|
||||
// Root note
|
||||
let mut root = layer.root_key as i32;
|
||||
if ui.add(egui::DragValue::new(&mut root)
|
||||
.range(0..=127)
|
||||
.speed(1)
|
||||
.custom_formatter(|v, _| midi_to_note_name(v as u8))
|
||||
).changed() {
|
||||
layer.root_key = root as u8;
|
||||
if self.auto_key_ranges {
|
||||
recalc = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Key range
|
||||
if self.auto_key_ranges {
|
||||
ui.label(format!("{}-{}", midi_to_note_name(layer.key_min), midi_to_note_name(layer.key_max)));
|
||||
} else {
|
||||
let mut kmin = layer.key_min as i32;
|
||||
let mut kmax = layer.key_max as i32;
|
||||
ui.horizontal(|ui| {
|
||||
if ui.add(egui::DragValue::new(&mut kmin).range(0..=127).speed(1)
|
||||
.custom_formatter(|v, _| midi_to_note_name(v as u8))
|
||||
).changed() {
|
||||
layer.key_min = kmin as u8;
|
||||
}
|
||||
ui.label("-");
|
||||
if ui.add(egui::DragValue::new(&mut kmax).range(0..=127).speed(1)
|
||||
.custom_formatter(|v, _| midi_to_note_name(v as u8))
|
||||
).changed() {
|
||||
layer.key_max = kmax as u8;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Velocity range
|
||||
ui.label(format!("{}-{}", layer.velocity_min, layer.velocity_max));
|
||||
ui.end_row();
|
||||
}
|
||||
});
|
||||
|
||||
// Unmapped section
|
||||
if !self.scan_result.unmapped.is_empty() {
|
||||
ui.add_space(8.0);
|
||||
ui.label(egui::RichText::new(format!("Unmapped ({})", self.scan_result.unmapped.len())).strong());
|
||||
for sample in &self.scan_result.unmapped {
|
||||
ui.label(format!(" {}", sample.filename));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Buttons
|
||||
ui.add_space(4.0);
|
||||
ui.separator();
|
||||
ui.horizontal(|ui| {
|
||||
if ui.button("Cancel").clicked() {
|
||||
should_cancel = true;
|
||||
}
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
let import_text = format!("Import {} layers", enabled_count);
|
||||
if ui.add_enabled(enabled_count > 0, egui::Button::new(&import_text)).clicked() {
|
||||
should_import = true;
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
if recalc {
|
||||
recalc_key_ranges(&mut self.scan_result.layers);
|
||||
}
|
||||
|
||||
if should_import {
|
||||
self.confirmed = true;
|
||||
self.should_close = true;
|
||||
}
|
||||
if should_cancel || !open {
|
||||
self.should_close = true;
|
||||
}
|
||||
|
||||
!self.should_close
|
||||
}
|
||||
|
||||
/// Get the enabled layers for import.
|
||||
pub fn enabled_layers(&self) -> Vec<&ImportLayer> {
|
||||
self.scan_result.layers.iter().filter(|l| l.enabled).collect()
|
||||
}
|
||||
}
|
||||
|
|
@ -103,6 +103,7 @@ pub struct WaveformCallback {
|
|||
|
||||
|
||||
/// Raw audio data waiting to be uploaded to GPU
|
||||
#[derive(Clone)]
|
||||
pub struct PendingUpload {
|
||||
pub samples: std::sync::Arc<Vec<f32>>,
|
||||
pub sample_rate: u32,
|
||||
|
|
|
|||
Loading…
Reference in New Issue