Compare commits
No commits in common. "05966ed2714e347bf6d957c31fa2041100e5e58a" and "205dc9dd675572c82421cbbbd3c2c5429106f540" have entirely different histories.
05966ed271
...
205dc9dd67
|
|
@ -1136,11 +1136,6 @@ impl Engine {
|
|||
self.metronome.set_enabled(enabled);
|
||||
}
|
||||
|
||||
Command::SetTempo(bpm, time_sig) => {
|
||||
self.metronome.update_timing(bpm, time_sig);
|
||||
self.project.set_tempo(bpm, time_sig.0);
|
||||
}
|
||||
|
||||
// Node graph commands
|
||||
Command::GraphAddNode(track_id, node_type, x, y) => {
|
||||
eprintln!("[DEBUG] GraphAddNode received: track_id={}, node_type={}, x={}, y={}", track_id, node_type, x, y);
|
||||
|
|
@ -3202,11 +3197,6 @@ impl EngineController {
|
|||
let _ = self.command_tx.push(Command::SetMetronomeEnabled(enabled));
|
||||
}
|
||||
|
||||
/// Set project tempo (BPM) and time signature
|
||||
pub fn set_tempo(&mut self, bpm: f32, time_signature: (u32, u32)) {
|
||||
let _ = self.command_tx.push(Command::SetTempo(bpm, time_signature));
|
||||
}
|
||||
|
||||
// Node graph operations
|
||||
|
||||
/// Add a node to a track's instrument graph
|
||||
|
|
|
|||
|
|
@ -96,11 +96,6 @@ pub struct AudioGraph {
|
|||
/// Current playback time (for automation nodes)
|
||||
playback_time: f64,
|
||||
|
||||
/// Project tempo (synced from Engine via SetTempo)
|
||||
bpm: f32,
|
||||
/// Beats per bar (time signature numerator)
|
||||
beats_per_bar: u32,
|
||||
|
||||
/// Cached topological sort order (invalidated on graph mutation)
|
||||
topo_cache: Option<Vec<NodeIndex>>,
|
||||
|
||||
|
|
@ -124,19 +119,11 @@ impl AudioGraph {
|
|||
midi_input_buffers: (0..16).map(|_| Vec::with_capacity(128)).collect(),
|
||||
node_positions: std::collections::HashMap::new(),
|
||||
playback_time: 0.0,
|
||||
bpm: 120.0,
|
||||
beats_per_bar: 4,
|
||||
topo_cache: None,
|
||||
frontend_groups: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the project tempo and time signature for BeatNodes
|
||||
pub fn set_tempo(&mut self, bpm: f32, beats_per_bar: u32) {
|
||||
self.bpm = bpm;
|
||||
self.beats_per_bar = beats_per_bar;
|
||||
}
|
||||
|
||||
/// Add a node to the graph
|
||||
pub fn add_node(&mut self, node: Box<dyn AudioNode>) -> NodeIndex {
|
||||
let graph_node = GraphNode::new(node, self.buffer_size);
|
||||
|
|
@ -465,7 +452,6 @@ impl AudioGraph {
|
|||
auto_node.set_playback_time(playback_time);
|
||||
} else if let Some(beat_node) = node.node.as_any_mut().downcast_mut::<BeatNode>() {
|
||||
beat_node.set_playback_time(playback_time);
|
||||
beat_node.set_tempo(self.bpm, self.beats_per_bar);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,8 +3,8 @@ use crate::audio::midi::MidiEvent;
|
|||
|
||||
const PARAM_RESOLUTION: u32 = 0;
|
||||
|
||||
/// Hardcoded BPM until project tempo is implemented
|
||||
const DEFAULT_BPM: f32 = 120.0;
|
||||
const DEFAULT_BEATS_PER_BAR: u32 = 4;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub enum BeatResolution {
|
||||
|
|
@ -47,19 +47,17 @@ impl BeatResolution {
|
|||
|
||||
/// Beat clock node — generates tempo-synced CV signals.
|
||||
///
|
||||
/// BPM and time signature are synced from the project document via SetTempo.
|
||||
/// When playing: synced to timeline position.
|
||||
/// When stopped: free-runs continuously at the project BPM.
|
||||
/// When stopped: free-runs continuously at the set BPM.
|
||||
///
|
||||
/// Outputs:
|
||||
/// - BPM: constant CV proportional to tempo (bpm / 240)
|
||||
/// - Beat Phase: sawtooth 0→1 per beat subdivision
|
||||
/// - Bar Phase: sawtooth 0→1 per bar (uses project time signature)
|
||||
/// - Bar Phase: sawtooth 0→1 per bar (4 beats)
|
||||
/// - Gate: 1.0 for first half of each subdivision, 0.0 otherwise
|
||||
pub struct BeatNode {
|
||||
name: String,
|
||||
bpm: f32,
|
||||
beats_per_bar: u32,
|
||||
resolution: BeatResolution,
|
||||
/// Playback time in seconds, set by the graph before process()
|
||||
playback_time: f64,
|
||||
|
|
@ -90,7 +88,6 @@ impl BeatNode {
|
|||
Self {
|
||||
name: name.into(),
|
||||
bpm: DEFAULT_BPM,
|
||||
beats_per_bar: DEFAULT_BEATS_PER_BAR,
|
||||
resolution: BeatResolution::Quarter,
|
||||
playback_time: 0.0,
|
||||
prev_playback_time: -1.0,
|
||||
|
|
@ -104,11 +101,6 @@ impl BeatNode {
|
|||
pub fn set_playback_time(&mut self, time: f64) {
|
||||
self.playback_time = time;
|
||||
}
|
||||
|
||||
pub fn set_tempo(&mut self, bpm: f32, beats_per_bar: u32) {
|
||||
self.bpm = bpm;
|
||||
self.beats_per_bar = beats_per_bar;
|
||||
}
|
||||
}
|
||||
|
||||
impl AudioNode for BeatNode {
|
||||
|
|
@ -175,8 +167,8 @@ impl AudioNode for BeatNode {
|
|||
// Beat subdivision phase: 0→1 sawtooth
|
||||
let sub_phase = ((beat_pos * subs_per_beat) % 1.0) as f32;
|
||||
|
||||
// Bar phase: 0→1 over one bar (beats_per_bar beats)
|
||||
let bar_phase = ((beat_pos / self.beats_per_bar as f64) % 1.0) as f32;
|
||||
// Bar phase: 0→1 over 4 quarter-note beats
|
||||
let bar_phase = ((beat_pos / 4.0) % 1.0) as f32;
|
||||
|
||||
// Gate: high for first half of each subdivision
|
||||
let gate = if sub_phase < 0.5 { 1.0f32 } else { 0.0 };
|
||||
|
|
@ -209,7 +201,6 @@ impl AudioNode for BeatNode {
|
|||
Box::new(Self {
|
||||
name: self.name.clone(),
|
||||
bpm: self.bpm,
|
||||
beats_per_bar: self.beats_per_bar,
|
||||
resolution: self.resolution,
|
||||
playback_time: 0.0,
|
||||
prev_playback_time: -1.0,
|
||||
|
|
|
|||
|
|
@ -569,17 +569,6 @@ impl Project {
|
|||
}
|
||||
}
|
||||
|
||||
/// Propagate tempo to all audio graphs (for BeatNode sync)
|
||||
pub fn set_tempo(&mut self, bpm: f32, beats_per_bar: u32) {
|
||||
for track in self.tracks.values_mut() {
|
||||
match track {
|
||||
TrackNode::Audio(t) => t.effects_graph.set_tempo(bpm, beats_per_bar),
|
||||
TrackNode::Midi(t) => t.instrument_graph.set_tempo(bpm, beats_per_bar),
|
||||
TrackNode::Group(g) => g.audio_graph.set_tempo(bpm, beats_per_bar),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Process live MIDI input from all MIDI tracks (called even when not playing)
|
||||
pub fn process_live_midi(&mut self, output: &mut [f32], sample_rate: u32, channels: u32) {
|
||||
// Process all MIDI tracks to handle queued live input events
|
||||
|
|
|
|||
|
|
@ -138,8 +138,6 @@ pub enum Command {
|
|||
// Metronome command
|
||||
/// Enable or disable the metronome click track
|
||||
SetMetronomeEnabled(bool),
|
||||
/// Set project tempo and time signature (bpm, (numerator, denominator))
|
||||
SetTempo(f32, (u32, u32)),
|
||||
|
||||
// Node graph commands
|
||||
/// Add a node to a track's instrument graph (track_id, node_type, position_x, position_y)
|
||||
|
|
|
|||
|
|
@ -3444,10 +3444,8 @@ dependencies = [
|
|||
"kurbo 0.12.0",
|
||||
"lru",
|
||||
"pathdiff",
|
||||
"rstar",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tiny-skia",
|
||||
"uuid",
|
||||
"vello",
|
||||
"wgpu",
|
||||
|
|
@ -5347,17 +5345,6 @@ version = "0.20.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97"
|
||||
|
||||
[[package]]
|
||||
name = "rstar"
|
||||
version = "0.12.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "421400d13ccfd26dfa5858199c30a5d76f9c54e0dba7575273025b43c5175dbb"
|
||||
dependencies = [
|
||||
"heapless",
|
||||
"num-traits",
|
||||
"smallvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rtrb"
|
||||
version = "0.3.2"
|
||||
|
|
|
|||
|
|
@ -41,11 +41,5 @@ pathdiff = "0.2"
|
|||
flacenc = "0.4" # For FLAC encoding (lossless)
|
||||
claxon = "0.4" # For FLAC decoding
|
||||
|
||||
# Spatial indexing for DCEL vertex snapping
|
||||
rstar = "0.12"
|
||||
|
||||
# System clipboard
|
||||
arboard = "3"
|
||||
|
||||
[dev-dependencies]
|
||||
tiny-skia = "0.11"
|
||||
|
|
|
|||
|
|
@ -1,124 +1,111 @@
|
|||
//! Add shape action — inserts strokes into the DCEL.
|
||||
//! Add shape action
|
||||
//!
|
||||
//! Converts a BezPath into cubic segments and inserts them via
|
||||
//! `Dcel::insert_stroke()`. Undo is handled by snapshotting the DCEL.
|
||||
//! Handles adding a new shape to a vector layer's keyframe.
|
||||
|
||||
use crate::action::Action;
|
||||
use crate::dcel::{bezpath_to_cubic_segments, Dcel, DEFAULT_SNAP_EPSILON};
|
||||
use crate::document::Document;
|
||||
use crate::layer::AnyLayer;
|
||||
use crate::shape::{ShapeColor, StrokeStyle};
|
||||
use kurbo::BezPath;
|
||||
use crate::shape::Shape;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Action that inserts a drawn path into a vector layer's DCEL keyframe.
|
||||
/// 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 geometry, styling, transform, opacity)
|
||||
shape: Shape,
|
||||
|
||||
/// Time of the keyframe to add to
|
||||
time: f64,
|
||||
path: BezPath,
|
||||
stroke_style: Option<StrokeStyle>,
|
||||
stroke_color: Option<ShapeColor>,
|
||||
fill_color: Option<ShapeColor>,
|
||||
is_closed: bool,
|
||||
description_text: String,
|
||||
/// Snapshot of the DCEL before insertion (for undo).
|
||||
dcel_before: Option<Dcel>,
|
||||
|
||||
/// ID of the created shape (set after execution)
|
||||
created_shape_id: Option<Uuid>,
|
||||
}
|
||||
|
||||
impl AddShapeAction {
|
||||
pub fn new(
|
||||
layer_id: Uuid,
|
||||
time: f64,
|
||||
path: BezPath,
|
||||
stroke_style: Option<StrokeStyle>,
|
||||
stroke_color: Option<ShapeColor>,
|
||||
fill_color: Option<ShapeColor>,
|
||||
is_closed: bool,
|
||||
) -> Self {
|
||||
pub fn new(layer_id: Uuid, shape: Shape, time: f64) -> Self {
|
||||
Self {
|
||||
layer_id,
|
||||
shape,
|
||||
time,
|
||||
path,
|
||||
stroke_style,
|
||||
stroke_color,
|
||||
fill_color,
|
||||
is_closed,
|
||||
description_text: "Add shape".to_string(),
|
||||
dcel_before: None,
|
||||
created_shape_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_description(mut self, desc: impl Into<String>) -> Self {
|
||||
self.description_text = desc.into();
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Action for AddShapeAction {
|
||||
fn execute(&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))?;
|
||||
|
||||
let vl = match layer {
|
||||
AnyLayer::Vector(vl) => vl,
|
||||
_ => return Err("Not a vector layer".to_string()),
|
||||
let layer = match document.get_layer_mut(&self.layer_id) {
|
||||
Some(l) => l,
|
||||
None => return Ok(()),
|
||||
};
|
||||
|
||||
let keyframe = vl.ensure_keyframe_at(self.time);
|
||||
let dcel = &mut keyframe.dcel;
|
||||
|
||||
// Snapshot for undo
|
||||
self.dcel_before = Some(dcel.clone());
|
||||
|
||||
let subpaths = bezpath_to_cubic_segments(&self.path);
|
||||
|
||||
for segments in &subpaths {
|
||||
if segments.is_empty() {
|
||||
continue;
|
||||
if let AnyLayer::Vector(vector_layer) = layer {
|
||||
let shape_id = self.shape.id;
|
||||
vector_layer.add_shape_to_keyframe(self.shape.clone(), self.time);
|
||||
self.created_shape_id = Some(shape_id);
|
||||
}
|
||||
let result = dcel.insert_stroke(
|
||||
segments,
|
||||
self.stroke_style.clone(),
|
||||
self.stroke_color.clone(),
|
||||
DEFAULT_SNAP_EPSILON,
|
||||
);
|
||||
|
||||
// Apply fill to new faces if this is a closed shape with fill
|
||||
if self.is_closed {
|
||||
if let Some(ref fill) = self.fill_color {
|
||||
for face_id in &result.new_faces {
|
||||
dcel.face_mut(*face_id).fill_color = Some(fill.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dcel.rebuild_spatial_index();
|
||||
|
||||
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))?;
|
||||
|
||||
let vl = match layer {
|
||||
AnyLayer::Vector(vl) => vl,
|
||||
_ => return Err("Not a vector layer".to_string()),
|
||||
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(()),
|
||||
};
|
||||
|
||||
let keyframe = vl.ensure_keyframe_at(self.time);
|
||||
keyframe.dcel = self
|
||||
.dcel_before
|
||||
.take()
|
||||
.ok_or_else(|| "No DCEL snapshot for undo".to_string())?;
|
||||
if let AnyLayer::Vector(vector_layer) = layer {
|
||||
vector_layer.remove_shape_from_keyframe(&shape_id, self.time);
|
||||
}
|
||||
|
||||
self.created_shape_id = None;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn description(&self) -> String {
|
||||
self.description_text.clone()
|
||||
"Add shape".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::layer::VectorLayer;
|
||||
use crate::shape::ShapeColor;
|
||||
use vello::kurbo::{Rect, Shape as KurboShape};
|
||||
|
||||
#[test]
|
||||
fn test_add_shape_action_rectangle() {
|
||||
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, 100.0, 50.0);
|
||||
let path = rect.to_path(0.1);
|
||||
let shape = Shape::new(path)
|
||||
.with_fill(ShapeColor::rgb(255, 0, 0))
|
||||
.with_position(50.0, 50.0);
|
||||
|
||||
let mut action = AddShapeAction::new(layer_id, shape, 0.0);
|
||||
action.execute(&mut document).unwrap();
|
||||
|
||||
if let Some(AnyLayer::Vector(layer)) = document.get_layer(&layer_id) {
|
||||
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");
|
||||
}
|
||||
|
||||
// Rollback
|
||||
action.rollback(&mut document).unwrap();
|
||||
|
||||
if let Some(AnyLayer::Vector(layer)) = document.get_layer(&layer_id) {
|
||||
assert_eq!(layer.shapes_at_time(0.0).len(), 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,13 +1,18 @@
|
|||
//! Convert to Movie Clip action — STUB: needs DCEL rewrite
|
||||
//! Convert to Movie Clip action
|
||||
//!
|
||||
//! Wraps selected shapes and/or clip instances into a new VectorClip
|
||||
//! with is_group = false, giving it a real internal timeline.
|
||||
//! Works with 1+ selected items (unlike Group which requires 2+).
|
||||
|
||||
use crate::action::Action;
|
||||
use crate::clip::ClipInstance;
|
||||
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 converts selected items to a Movie Clip
|
||||
/// TODO: Rewrite for DCEL
|
||||
#[allow(dead_code)]
|
||||
pub struct ConvertToMovieClipAction {
|
||||
layer_id: Uuid,
|
||||
time: f64,
|
||||
|
|
@ -15,6 +20,7 @@ pub struct ConvertToMovieClipAction {
|
|||
clip_instance_ids: Vec<Uuid>,
|
||||
instance_id: Uuid,
|
||||
created_clip_id: Option<Uuid>,
|
||||
removed_shapes: Vec<Shape>,
|
||||
removed_clip_instances: Vec<ClipInstance>,
|
||||
}
|
||||
|
||||
|
|
@ -33,18 +39,201 @@ impl ConvertToMovieClipAction {
|
|||
clip_instance_ids,
|
||||
instance_id,
|
||||
created_clip_id: None,
|
||||
removed_shapes: Vec::new(),
|
||||
removed_clip_instances: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Action for ConvertToMovieClipAction {
|
||||
fn execute(&mut self, _document: &mut Document) -> Result<(), String> {
|
||||
let _ = (&self.layer_id, self.time, &self.shape_ids, &self.clip_instance_ids, self.instance_id);
|
||||
fn execute(&mut self, document: &mut Document) -> Result<(), String> {
|
||||
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("Convert to Movie Clip is only supported on vector layers".to_string()),
|
||||
};
|
||||
|
||||
// Collect shapes
|
||||
let shapes_at_time = vl.shapes_at_time(self.time);
|
||||
let mut collected_shapes: Vec<Shape> = Vec::new();
|
||||
for id in &self.shape_ids {
|
||||
if let Some(shape) = shapes_at_time.iter().find(|s| &s.id == id) {
|
||||
collected_shapes.push(shape.clone());
|
||||
}
|
||||
}
|
||||
|
||||
// Collect clip instances
|
||||
let mut collected_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) {
|
||||
collected_clip_instances.push(ci.clone());
|
||||
}
|
||||
}
|
||||
|
||||
let total_items = collected_shapes.len() + collected_clip_instances.len();
|
||||
if total_items < 1 {
|
||||
return Err("Need at least 1 item to convert to movie clip".to_string());
|
||||
}
|
||||
|
||||
// Compute combined bounding box
|
||||
let mut combined_bbox: Option<Rect> = None;
|
||||
|
||||
for shape in &collected_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,
|
||||
});
|
||||
}
|
||||
|
||||
for ci in &collected_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;
|
||||
|
||||
// Offset shapes relative to center
|
||||
let mut clip_shapes: Vec<Shape> = collected_shapes.clone();
|
||||
for shape in &mut clip_shapes {
|
||||
shape.transform.x -= center_x;
|
||||
shape.transform.y -= center_y;
|
||||
}
|
||||
|
||||
let mut clip_instances_inside: Vec<ClipInstance> = collected_clip_instances.clone();
|
||||
for ci in &mut clip_instances_inside {
|
||||
ci.transform.x -= center_x;
|
||||
ci.transform.y -= center_y;
|
||||
}
|
||||
|
||||
// Create VectorClip with real timeline duration
|
||||
let mut clip = VectorClip::new("Movie Clip", bbox.width(), bbox.height(), document.duration);
|
||||
// is_group defaults to false — movie clips have real timelines
|
||||
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));
|
||||
|
||||
document.add_vector_clip(clip);
|
||||
self.created_clip_id = Some(clip_id);
|
||||
|
||||
// Remove originals from the layer
|
||||
let layer = document.get_layer_mut(&self.layer_id).unwrap();
|
||||
let vl = match layer {
|
||||
AnyLayer::Vector(vl) => vl,
|
||||
_ => unreachable!(),
|
||||
};
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
// Place the new ClipInstance
|
||||
let instance = ClipInstance::with_id(self.instance_id, clip_id)
|
||||
.with_position(center_x, center_y)
|
||||
.with_name("Movie Clip");
|
||||
vl.clip_instances.push(instance);
|
||||
|
||||
// Create default animation curves
|
||||
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> {
|
||||
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 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 clip instance
|
||||
vl.clip_instances.retain(|ci| ci.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(())
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,20 +1,42 @@
|
|||
//! Group action — STUB: needs DCEL rewrite
|
||||
//! 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::clip::ClipInstance;
|
||||
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
|
||||
/// TODO: Rewrite for DCEL (group DCEL faces/edges into a sub-clip)
|
||||
#[allow(dead_code)]
|
||||
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>,
|
||||
}
|
||||
|
||||
|
|
@ -33,19 +55,227 @@ impl GroupAction {
|
|||
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> {
|
||||
let _ = (&self.layer_id, self.time, &self.shape_ids, &self.clip_instance_ids, self.instance_id);
|
||||
// TODO: Implement DCEL-aware grouping
|
||||
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> {
|
||||
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(())
|
||||
}
|
||||
|
||||
|
|
@ -54,3 +284,129 @@ impl Action for GroupAction {
|
|||
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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ pub mod add_layer;
|
|||
pub mod add_shape;
|
||||
pub mod modify_shape_path;
|
||||
pub mod move_clip_instances;
|
||||
pub mod move_objects;
|
||||
pub mod paint_bucket;
|
||||
pub mod remove_effect;
|
||||
pub mod set_document_properties;
|
||||
|
|
@ -17,6 +18,7 @@ pub mod set_layer_properties;
|
|||
pub mod set_shape_properties;
|
||||
pub mod split_clip_instance;
|
||||
pub mod transform_clip_instances;
|
||||
pub mod transform_objects;
|
||||
pub mod trim_clip_instances;
|
||||
pub mod create_folder;
|
||||
pub mod rename_folder;
|
||||
|
|
@ -25,6 +27,7 @@ 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 mod convert_to_movie_clip;
|
||||
|
|
@ -34,16 +37,18 @@ pub use add_clip_instance::AddClipInstanceAction;
|
|||
pub use add_effect::AddEffectAction;
|
||||
pub use add_layer::AddLayerAction;
|
||||
pub use add_shape::AddShapeAction;
|
||||
pub use modify_shape_path::ModifyDcelAction;
|
||||
pub use modify_shape_path::ModifyShapePathAction;
|
||||
pub use move_clip_instances::MoveClipInstancesAction;
|
||||
pub use move_objects::MoveShapeInstancesAction;
|
||||
pub use paint_bucket::PaintBucketAction;
|
||||
pub use remove_effect::RemoveEffectAction;
|
||||
pub use set_document_properties::SetDocumentPropertiesAction;
|
||||
pub use set_instance_properties::{InstancePropertyChange, SetInstancePropertiesAction};
|
||||
pub use set_layer_properties::{LayerProperty, SetLayerPropertiesAction};
|
||||
pub use set_shape_properties::SetShapePropertiesAction;
|
||||
pub use set_shape_properties::{SetShapePropertiesAction, ShapePropertyChange};
|
||||
pub use split_clip_instance::SplitClipInstanceAction;
|
||||
pub use transform_clip_instances::TransformClipInstancesAction;
|
||||
pub use transform_objects::TransformShapeInstancesAction;
|
||||
pub use trim_clip_instances::{TrimClipInstancesAction, TrimData, TrimType};
|
||||
pub use create_folder::CreateFolderAction;
|
||||
pub use rename_folder::RenameFolderAction;
|
||||
|
|
@ -52,6 +57,7 @@ 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;
|
||||
pub use convert_to_movie_clip::ConvertToMovieClipAction;
|
||||
|
|
|
|||
|
|
@ -1,83 +1,223 @@
|
|||
//! Modify DCEL action — snapshot-based undo for DCEL editing
|
||||
//! Modify shape path action
|
||||
//!
|
||||
//! Handles modifying a shape's bezier path (for vector editing operations)
|
||||
//! with undo/redo support.
|
||||
|
||||
use crate::action::Action;
|
||||
use crate::dcel::Dcel;
|
||||
use crate::document::Document;
|
||||
use crate::layer::AnyLayer;
|
||||
use uuid::Uuid;
|
||||
use vello::kurbo::BezPath;
|
||||
|
||||
/// Action that captures a before/after DCEL snapshot for undo/redo.
|
||||
/// Action that modifies a shape's path
|
||||
///
|
||||
/// Used by vertex editing, curve editing, and control point editing.
|
||||
/// The caller provides both snapshots (taken before and after the edit).
|
||||
pub struct ModifyDcelAction {
|
||||
/// This action is used for vector editing operations like dragging vertices,
|
||||
/// reshaping curves, or manipulating control points.
|
||||
pub struct ModifyShapePathAction {
|
||||
/// Layer containing the shape
|
||||
layer_id: Uuid,
|
||||
|
||||
/// Shape to modify
|
||||
shape_id: Uuid,
|
||||
|
||||
/// Time of the keyframe containing the shape
|
||||
time: f64,
|
||||
dcel_before: Option<Dcel>,
|
||||
dcel_after: Option<Dcel>,
|
||||
description_text: String,
|
||||
|
||||
/// The version index being modified (for shapes with multiple versions)
|
||||
version_index: usize,
|
||||
|
||||
/// New path
|
||||
new_path: BezPath,
|
||||
|
||||
/// Old path (stored after first execution for undo)
|
||||
old_path: Option<BezPath>,
|
||||
}
|
||||
|
||||
impl ModifyDcelAction {
|
||||
pub fn new(
|
||||
impl ModifyShapePathAction {
|
||||
/// Create a new action to modify a shape's path
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create action with old path already known (for optimization)
|
||||
pub fn with_old_path(
|
||||
layer_id: Uuid,
|
||||
shape_id: Uuid,
|
||||
time: f64,
|
||||
dcel_before: Dcel,
|
||||
dcel_after: Dcel,
|
||||
description: impl Into<String>,
|
||||
version_index: usize,
|
||||
old_path: BezPath,
|
||||
new_path: BezPath,
|
||||
) -> Self {
|
||||
Self {
|
||||
layer_id,
|
||||
shape_id,
|
||||
time,
|
||||
dcel_before: Some(dcel_before),
|
||||
dcel_after: Some(dcel_after),
|
||||
description_text: description.into(),
|
||||
version_index,
|
||||
new_path,
|
||||
old_path: Some(old_path),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Action for ModifyDcelAction {
|
||||
impl Action for ModifyShapePathAction {
|
||||
fn execute(&mut self, document: &mut Document) -> Result<(), String> {
|
||||
let dcel_after = self.dcel_after.as_ref()
|
||||
.ok_or("ModifyDcelAction: no dcel_after snapshot")?
|
||||
.clone();
|
||||
|
||||
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 {
|
||||
if let Some(kf) = vl.keyframe_at_mut(self.time) {
|
||||
kf.dcel = dcel_after;
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!("No keyframe at time {}", self.time))
|
||||
if let Some(layer) = document.get_layer_mut(&self.layer_id) {
|
||||
if let AnyLayer::Vector(vector_layer) = layer {
|
||||
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)",
|
||||
self.version_index,
|
||||
shape.versions.len()
|
||||
));
|
||||
}
|
||||
} else {
|
||||
Err("Not a vector layer".to_string())
|
||||
|
||||
// Store old path if not already stored
|
||||
if self.old_path.is_none() {
|
||||
self.old_path = Some(shape.versions[self.version_index].path.clone());
|
||||
}
|
||||
|
||||
// Apply new path
|
||||
shape.versions[self.version_index].path = self.new_path.clone();
|
||||
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(format!(
|
||||
"Could not find shape {} in layer {}",
|
||||
self.shape_id, self.layer_id
|
||||
))
|
||||
}
|
||||
|
||||
fn rollback(&mut self, document: &mut Document) -> Result<(), String> {
|
||||
let dcel_before = self.dcel_before.as_ref()
|
||||
.ok_or("ModifyDcelAction: no dcel_before snapshot")?
|
||||
.clone();
|
||||
|
||||
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 {
|
||||
if let Some(kf) = vl.keyframe_at_mut(self.time) {
|
||||
kf.dcel = dcel_before;
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!("No keyframe at time {}", self.time))
|
||||
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.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(());
|
||||
}
|
||||
} else {
|
||||
Err("Not a vector layer".to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(format!(
|
||||
"Could not rollback shape path modification for shape {} in layer {}",
|
||||
self.shape_id, self.layer_id
|
||||
))
|
||||
}
|
||||
|
||||
fn description(&self) -> String {
|
||||
self.description_text.clone()
|
||||
"Modify shape path".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
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();
|
||||
path.move_to((0.0, 0.0));
|
||||
path.line_to((100.0, 0.0));
|
||||
path.line_to((100.0, 100.0));
|
||||
path.line_to((0.0, 100.0));
|
||||
path.close_path();
|
||||
path
|
||||
}
|
||||
|
||||
fn create_modified_path() -> BezPath {
|
||||
let mut path = BezPath::new();
|
||||
path.move_to((0.0, 0.0));
|
||||
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
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_modify_shape_path() {
|
||||
let mut document = Document::new("Test");
|
||||
let mut layer = VectorLayer::new("Test Layer");
|
||||
|
||||
let shape = Shape::new(create_test_path());
|
||||
let shape_id = shape.id;
|
||||
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(&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);
|
||||
}
|
||||
|
||||
// Create and execute action
|
||||
let new_path = create_modified_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(&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);
|
||||
}
|
||||
|
||||
// Rollback
|
||||
action.rollback(&mut document).unwrap();
|
||||
|
||||
// Verify restored
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_invalid_version_index() {
|
||||
let mut document = Document::new("Test");
|
||||
let mut layer = VectorLayer::new("Test Layer");
|
||||
|
||||
let shape = Shape::new(create_test_path());
|
||||
let shape_id = shape.id;
|
||||
layer.add_shape_to_keyframe(shape, 0.0);
|
||||
|
||||
let layer_id = document.root_mut().add_child(AnyLayer::Vector(layer));
|
||||
|
||||
let new_path = create_modified_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());
|
||||
assert!(result.unwrap_err().contains("out of bounds"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_description() {
|
||||
let layer_id = Uuid::new_v4();
|
||||
let shape_id = Uuid::new_v4();
|
||||
let action = ModifyShapePathAction::new(layer_id, shape_id, 0.0, 0, create_test_path());
|
||||
assert_eq!(action.description(), "Modify shape path");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -247,7 +247,7 @@ mod tests {
|
|||
let folder2_id = folder2_action.created_folder_id().unwrap();
|
||||
|
||||
// Create a clip in folder 1
|
||||
let mut clip = crate::clip::AudioClip::new_sampled("Test Audio", 0, 5.0);
|
||||
let mut clip = crate::clip::AudioClip::new_sampled("Test Audio", 5.0, 0);
|
||||
clip.folder_id = Some(folder1_id);
|
||||
let clip_id = clip.id;
|
||||
document.audio_clips.insert(clip_id, clip);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,73 @@
|
|||
//! Move shapes action
|
||||
//!
|
||||
//! Handles moving one or more shapes to new positions within a keyframe.
|
||||
|
||||
use crate::action::Action;
|
||||
use crate::document::Document;
|
||||
use crate::layer::AnyLayer;
|
||||
use std::collections::HashMap;
|
||||
use uuid::Uuid;
|
||||
use vello::kurbo::Point;
|
||||
|
||||
/// Action that moves shapes to new positions within a keyframe
|
||||
pub struct MoveShapeInstancesAction {
|
||||
layer_id: Uuid,
|
||||
time: f64,
|
||||
/// Map of shape IDs to their old and new positions
|
||||
shape_positions: HashMap<Uuid, (Point, Point)>,
|
||||
}
|
||||
|
||||
impl MoveShapeInstancesAction {
|
||||
pub fn new(layer_id: Uuid, time: f64, shape_positions: HashMap<Uuid, (Point, Point)>) -> Self {
|
||||
Self {
|
||||
layer_id,
|
||||
time,
|
||||
shape_positions,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Action for MoveShapeInstancesAction {
|
||||
fn execute(&mut self, document: &mut Document) -> Result<(), String> {
|
||||
let layer = match document.get_layer_mut(&self.layer_id) {
|
||||
Some(l) => l,
|
||||
None => return Ok(()),
|
||||
};
|
||||
|
||||
if let AnyLayer::Vector(vector_layer) = layer {
|
||||
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(())
|
||||
}
|
||||
|
||||
fn rollback(&mut self, document: &mut Document) -> Result<(), String> {
|
||||
let layer = match document.get_layer_mut(&self.layer_id) {
|
||||
Some(l) => l,
|
||||
None => return Ok(()),
|
||||
};
|
||||
|
||||
if let AnyLayer::Vector(vector_layer) = layer {
|
||||
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_positions.len();
|
||||
if count == 1 {
|
||||
"Move shape".to_string()
|
||||
} else {
|
||||
format!("Move {} shapes", count)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,103 +1,152 @@
|
|||
//! Paint bucket fill action — sets fill_color on a DCEL face.
|
||||
//! Paint bucket fill action
|
||||
//!
|
||||
//! This action performs a paint bucket fill operation starting from a click point,
|
||||
//! using planar graph face detection to identify the region to fill.
|
||||
|
||||
use crate::action::Action;
|
||||
use crate::dcel::FaceId;
|
||||
use crate::curve_segment::CurveSegment;
|
||||
use crate::document::Document;
|
||||
use crate::gap_handling::GapHandlingMode;
|
||||
use crate::layer::AnyLayer;
|
||||
use crate::planar_graph::PlanarGraph;
|
||||
use crate::shape::ShapeColor;
|
||||
use uuid::Uuid;
|
||||
use vello::kurbo::Point;
|
||||
|
||||
/// Action that performs a paint bucket fill on a DCEL face.
|
||||
/// Action that performs a paint bucket fill operation
|
||||
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,
|
||||
|
||||
/// Fill color for the shape
|
||||
fill_color: ShapeColor,
|
||||
/// The face that was hit (resolved during execute)
|
||||
hit_face: Option<FaceId>,
|
||||
/// Previous fill color for undo
|
||||
old_fill_color: Option<Option<ShapeColor>>,
|
||||
|
||||
/// Tolerance for gap bridging (in pixels)
|
||||
_tolerance: f64,
|
||||
|
||||
/// Gap handling mode
|
||||
_gap_mode: GapHandlingMode,
|
||||
|
||||
/// ID of the created shape (set after execution)
|
||||
created_shape_id: Option<Uuid>,
|
||||
}
|
||||
|
||||
impl PaintBucketAction {
|
||||
/// Create a new paint bucket action
|
||||
pub fn new(
|
||||
layer_id: Uuid,
|
||||
time: f64,
|
||||
click_point: Point,
|
||||
fill_color: ShapeColor,
|
||||
tolerance: f64,
|
||||
gap_mode: GapHandlingMode,
|
||||
) -> Self {
|
||||
Self {
|
||||
layer_id,
|
||||
time,
|
||||
click_point,
|
||||
fill_color,
|
||||
hit_face: None,
|
||||
old_fill_color: None,
|
||||
_tolerance: tolerance,
|
||||
_gap_mode: gap_mode,
|
||||
created_shape_id: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Action for PaintBucketAction {
|
||||
fn execute(&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))?;
|
||||
println!("=== PaintBucketAction::execute ===");
|
||||
|
||||
let vl = match layer {
|
||||
AnyLayer::Vector(vl) => vl,
|
||||
_ => return Err("Not a vector layer".to_string()),
|
||||
};
|
||||
|
||||
let keyframe = vl.ensure_keyframe_at(self.time);
|
||||
let dcel = &mut keyframe.dcel;
|
||||
|
||||
// Record for debug test generation (if recording is active)
|
||||
dcel.record_paint_point(self.click_point);
|
||||
|
||||
// Hit-test to find which face was clicked
|
||||
let face_id = dcel.find_face_containing_point(self.click_point);
|
||||
|
||||
// Dump cumulative test to stderr after every paint click (if recording)
|
||||
// Do this before the early return so failed clicks are captured too.
|
||||
if dcel.is_recording() {
|
||||
eprintln!("\n--- DCEL debug test (cumulative, face={:?}) ---", face_id);
|
||||
dcel.debug_recorder.as_ref().unwrap().dump_test("test_recorded");
|
||||
eprintln!("--- end test ---\n");
|
||||
// Optimization: Check if we're clicking on an existing shape first
|
||||
if let Some(AnyLayer::Vector(vector_layer)) = document.get_layer_mut(&self.layer_id) {
|
||||
// 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;
|
||||
}
|
||||
|
||||
if face_id.0 == 0 {
|
||||
// FaceId(0) is the unbounded exterior face — nothing to fill
|
||||
return Err("No face at click point".to_string());
|
||||
use vello::kurbo::PathEl;
|
||||
let is_closed = shape.path().elements().iter().any(|el| matches!(el, PathEl::ClosePath));
|
||||
if !is_closed {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Store for undo
|
||||
self.hit_face = Some(face_id);
|
||||
self.old_fill_color = Some(dcel.face(face_id).fill_color.clone());
|
||||
// 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;
|
||||
|
||||
// Apply fill
|
||||
dcel.face_mut(face_id).fill_color = Some(self.fill_color.clone());
|
||||
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 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());
|
||||
|
||||
if all_curves.is_empty() {
|
||||
println!("No curves found, returning");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Step 2: Build planar graph
|
||||
println!("Building planar graph...");
|
||||
let graph = PlanarGraph::build(&all_curves);
|
||||
|
||||
// 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!");
|
||||
|
||||
let face_path = graph.build_face_path(&face);
|
||||
|
||||
let face_shape = crate::shape::Shape::new(face_path)
|
||||
.with_fill(self.fill_color);
|
||||
|
||||
self.created_shape_id = Some(face_shape.id);
|
||||
|
||||
if let Some(AnyLayer::Vector(vector_layer)) = document.get_layer_mut(&self.layer_id) {
|
||||
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 ===");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn rollback(&mut self, document: &mut Document) -> Result<(), String> {
|
||||
let face_id = self.hit_face.ok_or("No face to undo")?;
|
||||
|
||||
let layer = document
|
||||
.get_layer_mut(&self.layer_id)
|
||||
.ok_or_else(|| format!("Layer {} not found", self.layer_id))?;
|
||||
|
||||
let vl = match layer {
|
||||
AnyLayer::Vector(vl) => vl,
|
||||
_ => return Err("Not a vector layer".to_string()),
|
||||
};
|
||||
|
||||
let keyframe = vl.ensure_keyframe_at(self.time);
|
||||
let dcel = &mut keyframe.dcel;
|
||||
|
||||
dcel.face_mut(face_id).fill_color = self.old_fill_color.take().unwrap_or(None);
|
||||
|
||||
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;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
|
@ -105,3 +154,139 @@ impl Action for PaintBucketAction {
|
|||
"Paint bucket fill".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// 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();
|
||||
|
||||
let layer = match document.get_layer(layer_id) {
|
||||
Some(l) => l,
|
||||
None => return all_curves,
|
||||
};
|
||||
|
||||
if let AnyLayer::Vector(vector_layer) = layer {
|
||||
let shapes = vector_layer.shapes_at_time(time);
|
||||
println!("Extracting curves from {} shapes in keyframe", shapes.len());
|
||||
|
||||
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;
|
||||
let mut segment_index = 0;
|
||||
let mut curves_in_shape = 0;
|
||||
|
||||
for element in path.elements() {
|
||||
if let Some(mut segment) = CurveSegment::from_path_element(
|
||||
shape.id.as_u128() as usize,
|
||||
segment_index,
|
||||
element,
|
||||
current_point,
|
||||
) {
|
||||
for control_point in &mut segment.control_points {
|
||||
*control_point = transform_affine * (*control_point);
|
||||
}
|
||||
|
||||
all_curves.push(segment);
|
||||
segment_index += 1;
|
||||
curves_in_shape += 1;
|
||||
}
|
||||
|
||||
match element {
|
||||
vello::kurbo::PathEl::MoveTo(p) => {
|
||||
current_point = *p;
|
||||
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 => {
|
||||
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,
|
||||
) {
|
||||
for control_point in &mut segment.control_points {
|
||||
*control_point = transform_affine * (*control_point);
|
||||
}
|
||||
|
||||
all_curves.push(segment);
|
||||
segment_index += 1;
|
||||
curves_in_shape += 1;
|
||||
}
|
||||
current_point = subpath_start;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
println!(" Shape {}: Extracted {} curves", shape_idx, curves_in_shape);
|
||||
}
|
||||
}
|
||||
|
||||
all_curves
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::layer::VectorLayer;
|
||||
use crate::shape::Shape;
|
||||
use vello::kurbo::{Rect, Shape as KurboShape};
|
||||
|
||||
#[test]
|
||||
fn test_paint_bucket_action_basic() {
|
||||
let mut document = Document::new("Test");
|
||||
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);
|
||||
|
||||
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,
|
||||
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 (or existing shape was recolored)
|
||||
if let Some(AnyLayer::Vector(layer)) = document.get_layer(&layer_id) {
|
||||
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();
|
||||
}
|
||||
|
||||
#[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,
|
||||
GapHandlingMode::BridgeSegment,
|
||||
);
|
||||
|
||||
assert_eq!(action.description(), "Paint bucket fill");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,42 +1,119 @@
|
|||
//! Region split action — STUB: needs DCEL rewrite
|
||||
//! Region split action
|
||||
//!
|
||||
//! Commits a temporary region-based shape split permanently.
|
||||
//! Replaces original shapes with their inside and outside portions.
|
||||
|
||||
use crate::action::Action;
|
||||
use crate::document::Document;
|
||||
use crate::layer::AnyLayer;
|
||||
use crate::shape::Shape;
|
||||
use uuid::Uuid;
|
||||
use vello::kurbo::BezPath;
|
||||
|
||||
/// Action that commits a region split
|
||||
/// TODO: Rewrite for DCEL edge splitting
|
||||
/// One shape split entry for the action
|
||||
#[derive(Clone, Debug)]
|
||||
struct SplitEntry {
|
||||
/// The original shape (for rollback)
|
||||
original_shape: Shape,
|
||||
/// The inside portion shape
|
||||
inside_shape: Shape,
|
||||
/// The outside portion shape
|
||||
outside_shape: Shape,
|
||||
}
|
||||
|
||||
/// Action that commits a region split — replacing original shapes with
|
||||
/// their inside and outside portions.
|
||||
pub struct RegionSplitAction {
|
||||
layer_id: Uuid,
|
||||
time: f64,
|
||||
splits: Vec<SplitEntry>,
|
||||
}
|
||||
|
||||
impl RegionSplitAction {
|
||||
/// Create a new region split action.
|
||||
///
|
||||
/// Each tuple is (original_shape, inside_path, inside_id, outside_path, outside_id).
|
||||
pub fn new(
|
||||
layer_id: Uuid,
|
||||
time: f64,
|
||||
_split_data: Vec<(Shape, BezPath, Uuid, BezPath, Uuid)>,
|
||||
split_data: Vec<(Shape, BezPath, Uuid, BezPath, Uuid)>,
|
||||
) -> Self {
|
||||
let splits = split_data
|
||||
.into_iter()
|
||||
.map(|(original, inside_path, inside_id, outside_path, outside_id)| {
|
||||
let mut inside_shape = original.clone();
|
||||
inside_shape.id = inside_id;
|
||||
inside_shape.versions[0].path = inside_path;
|
||||
|
||||
let mut outside_shape = original.clone();
|
||||
outside_shape.id = outside_id;
|
||||
outside_shape.versions[0].path = outside_path;
|
||||
|
||||
SplitEntry {
|
||||
original_shape: original,
|
||||
inside_shape,
|
||||
outside_shape,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
Self {
|
||||
layer_id,
|
||||
time,
|
||||
splits,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Action for RegionSplitAction {
|
||||
fn execute(&mut self, _document: &mut Document) -> Result<(), String> {
|
||||
let _ = (&self.layer_id, self.time);
|
||||
fn execute(&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))?;
|
||||
|
||||
let vector_layer = match layer {
|
||||
AnyLayer::Vector(vl) => vl,
|
||||
_ => return Err("Not a vector layer".to_string()),
|
||||
};
|
||||
|
||||
for split in &self.splits {
|
||||
// Remove original
|
||||
vector_layer.remove_shape_from_keyframe(&split.original_shape.id, self.time);
|
||||
// Add inside and outside portions
|
||||
vector_layer.add_shape_to_keyframe(split.inside_shape.clone(), self.time);
|
||||
vector_layer.add_shape_to_keyframe(split.outside_shape.clone(), self.time);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn rollback(&mut self, _document: &mut Document) -> Result<(), String> {
|
||||
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))?;
|
||||
|
||||
let vector_layer = match layer {
|
||||
AnyLayer::Vector(vl) => vl,
|
||||
_ => return Err("Not a vector layer".to_string()),
|
||||
};
|
||||
|
||||
for split in &self.splits {
|
||||
// Remove inside and outside portions
|
||||
vector_layer.remove_shape_from_keyframe(&split.inside_shape.id, self.time);
|
||||
vector_layer.remove_shape_from_keyframe(&split.outside_shape.id, self.time);
|
||||
// Restore original
|
||||
vector_layer.add_shape_to_keyframe(split.original_shape.clone(), self.time);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn description(&self) -> String {
|
||||
"Region split".to_string()
|
||||
let count = self.splits.len();
|
||||
if count == 1 {
|
||||
"Region split shape".to_string()
|
||||
} else {
|
||||
format!("Region split {} shapes", count)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,118 @@
|
|||
//! Remove shapes action
|
||||
//!
|
||||
//! 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::shape::Shape;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// 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>,
|
||||
/// Time of the keyframe
|
||||
time: f64,
|
||||
/// Saved shapes for rollback
|
||||
saved_shapes: Vec<Shape>,
|
||||
}
|
||||
|
||||
impl RemoveShapesAction {
|
||||
pub fn new(layer_id: Uuid, shape_ids: Vec<Uuid>, time: f64) -> Self {
|
||||
Self {
|
||||
layer_id,
|
||||
shape_ids,
|
||||
time,
|
||||
saved_shapes: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Action for RemoveShapesAction {
|
||||
fn execute(&mut self, document: &mut Document) -> Result<(), String> {
|
||||
self.saved_shapes.clear();
|
||||
|
||||
let layer = document
|
||||
.get_layer_mut(&self.layer_id)
|
||||
.ok_or_else(|| format!("Layer {} not found", self.layer_id))?;
|
||||
|
||||
let vector_layer = match layer {
|
||||
AnyLayer::Vector(vl) => vl,
|
||||
_ => return Err("Not a vector layer".to_string()),
|
||||
};
|
||||
|
||||
for shape_id in &self.shape_ids {
|
||||
if let Some(shape) = vector_layer.remove_shape_from_keyframe(shape_id, self.time) {
|
||||
self.saved_shapes.push(shape);
|
||||
}
|
||||
}
|
||||
|
||||
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))?;
|
||||
|
||||
let vector_layer = match layer {
|
||||
AnyLayer::Vector(vl) => vl,
|
||||
_ => return Err("Not a vector layer".to_string()),
|
||||
};
|
||||
|
||||
for shape in self.saved_shapes.drain(..) {
|
||||
vector_layer.add_shape_to_keyframe(shape, self.time);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn description(&self) -> String {
|
||||
let count = self.shape_ids.len();
|
||||
if count == 1 {
|
||||
"Delete shape".to_string()
|
||||
} else {
|
||||
format!("Delete {} shapes", count)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::layer::VectorLayer;
|
||||
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");
|
||||
|
||||
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;
|
||||
|
||||
vector_layer.add_shape_to_keyframe(shape, 0.0);
|
||||
|
||||
let layer_id = document.root_mut().add_child(AnyLayer::Vector(vector_layer));
|
||||
|
||||
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_at_time(0.0).is_empty());
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,7 +1,12 @@
|
|||
//! Set shape instance properties action — STUB: needs DCEL rewrite
|
||||
//! Set shape instance properties action
|
||||
//!
|
||||
//! 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;
|
||||
use crate::layer::AnyLayer;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Individual property change for a shape instance
|
||||
|
|
@ -18,7 +23,8 @@ pub enum InstancePropertyChange {
|
|||
}
|
||||
|
||||
impl InstancePropertyChange {
|
||||
pub fn value(&self) -> f64 {
|
||||
/// Extract the f64 value from any variant
|
||||
fn value(&self) -> f64 {
|
||||
match self {
|
||||
InstancePropertyChange::X(v) => *v,
|
||||
InstancePropertyChange::Y(v) => *v,
|
||||
|
|
@ -33,15 +39,22 @@ impl InstancePropertyChange {
|
|||
}
|
||||
|
||||
/// Action that sets a property on one or more shapes in a keyframe
|
||||
/// TODO: Replace with DCEL-based property changes
|
||||
pub struct SetInstancePropertiesAction {
|
||||
/// Layer containing the shapes
|
||||
layer_id: Uuid,
|
||||
|
||||
/// 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 shape
|
||||
pub fn new(layer_id: Uuid, time: f64, shape_id: Uuid, property: InstancePropertyChange) -> Self {
|
||||
Self {
|
||||
layer_id,
|
||||
|
|
@ -51,6 +64,7 @@ impl SetInstancePropertiesAction {
|
|||
}
|
||||
}
|
||||
|
||||
/// 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,
|
||||
|
|
@ -59,15 +73,76 @@ impl SetInstancePropertiesAction {
|
|||
property,
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Action for SetInstancePropertiesAction {
|
||||
fn execute(&mut self, _document: &mut Document) -> Result<(), String> {
|
||||
let _ = (&self.layer_id, self.time, &self.shape_changes, &self.property);
|
||||
fn execute(&mut self, document: &mut Document) -> Result<(), String> {
|
||||
let new_value = self.property.value();
|
||||
|
||||
// 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Second pass: apply new values
|
||||
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> {
|
||||
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_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(())
|
||||
}
|
||||
|
||||
|
|
@ -90,3 +165,144 @@ impl Action for SetInstancePropertiesAction {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::layer::VectorLayer;
|
||||
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 = 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));
|
||||
|
||||
let mut action = SetInstancePropertiesAction::new(
|
||||
layer_id,
|
||||
0.0,
|
||||
shape_id,
|
||||
InstancePropertyChange::X(50.0),
|
||||
);
|
||||
action.execute(&mut document).unwrap();
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
action.rollback(&mut document).unwrap();
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_set_opacity() {
|
||||
let mut document = Document::new("Test");
|
||||
let mut layer = VectorLayer::new("Test Layer");
|
||||
|
||||
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));
|
||||
|
||||
let mut action = SetInstancePropertiesAction::new(
|
||||
layer_id,
|
||||
0.0,
|
||||
shape_id,
|
||||
InstancePropertyChange::Opacity(0.5),
|
||||
);
|
||||
action.execute(&mut document).unwrap();
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
action.rollback(&mut document).unwrap();
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_batch_set_scale() {
|
||||
let mut document = Document::new("Test");
|
||||
let mut layer = VectorLayer::new("Test Layer");
|
||||
|
||||
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;
|
||||
|
||||
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 mut action = SetInstancePropertiesAction::new_batch(
|
||||
layer_id,
|
||||
0.0,
|
||||
vec![shape1_id, shape2_id],
|
||||
InstancePropertyChange::ScaleX(2.0),
|
||||
);
|
||||
action.execute(&mut document).unwrap();
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
action.rollback(&mut document).unwrap();
|
||||
|
||||
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 shape_id = Uuid::new_v4();
|
||||
|
||||
let action1 = SetInstancePropertiesAction::new(
|
||||
layer_id, 0.0, shape_id,
|
||||
InstancePropertyChange::X(0.0),
|
||||
);
|
||||
assert_eq!(action1.description(), "Set X position");
|
||||
|
||||
let action2 = SetInstancePropertiesAction::new(
|
||||
layer_id, 0.0, shape_id,
|
||||
InstancePropertyChange::Rotation(0.0),
|
||||
);
|
||||
assert_eq!(action2.description(), "Set rotation");
|
||||
|
||||
let action3 = SetInstancePropertiesAction::new_batch(
|
||||
layer_id, 0.0,
|
||||
vec![Uuid::new_v4(), Uuid::new_v4()],
|
||||
InstancePropertyChange::Opacity(1.0),
|
||||
);
|
||||
assert_eq!(action3.description(), "Set opacity on 2 shapes");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,170 +1,251 @@
|
|||
//! Set shape properties action — operates on DCEL edge/face IDs.
|
||||
//! Set shape properties action
|
||||
//!
|
||||
//! Handles changing shape properties (fill color, stroke color, stroke width)
|
||||
//! with undo/redo support.
|
||||
|
||||
use crate::action::Action;
|
||||
use crate::dcel::{EdgeId, FaceId};
|
||||
use crate::document::Document;
|
||||
use crate::layer::AnyLayer;
|
||||
use crate::shape::ShapeColor;
|
||||
use crate::shape::{ShapeColor, StrokeStyle};
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Action that sets fill/stroke properties on DCEL elements.
|
||||
pub struct SetShapePropertiesAction {
|
||||
layer_id: Uuid,
|
||||
time: f64,
|
||||
change: PropertyChange,
|
||||
old_edge_values: Vec<(EdgeId, Option<ShapeColor>, Option<f64>)>,
|
||||
old_face_values: Vec<(FaceId, Option<ShapeColor>)>,
|
||||
/// Property change for a shape
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum ShapePropertyChange {
|
||||
FillColor(Option<ShapeColor>),
|
||||
StrokeColor(Option<ShapeColor>),
|
||||
StrokeWidth(f64),
|
||||
}
|
||||
|
||||
enum PropertyChange {
|
||||
FillColor {
|
||||
face_ids: Vec<FaceId>,
|
||||
color: Option<ShapeColor>,
|
||||
},
|
||||
StrokeColor {
|
||||
edge_ids: Vec<EdgeId>,
|
||||
color: Option<ShapeColor>,
|
||||
},
|
||||
StrokeWidth {
|
||||
edge_ids: Vec<EdgeId>,
|
||||
width: f64,
|
||||
},
|
||||
/// Action that sets properties on a shape
|
||||
pub struct SetShapePropertiesAction {
|
||||
/// Layer containing the shape
|
||||
layer_id: Uuid,
|
||||
|
||||
/// Shape to modify
|
||||
shape_id: Uuid,
|
||||
|
||||
/// Time of the keyframe containing the shape
|
||||
time: f64,
|
||||
|
||||
/// New property value
|
||||
new_value: ShapePropertyChange,
|
||||
|
||||
/// Old property value (stored after first execution)
|
||||
old_value: Option<ShapePropertyChange>,
|
||||
}
|
||||
|
||||
impl SetShapePropertiesAction {
|
||||
pub fn set_fill_color(
|
||||
layer_id: Uuid,
|
||||
time: f64,
|
||||
face_ids: Vec<FaceId>,
|
||||
color: Option<ShapeColor>,
|
||||
) -> Self {
|
||||
/// Create a new action to set a property on a shape
|
||||
pub fn new(layer_id: Uuid, shape_id: Uuid, time: f64, new_value: ShapePropertyChange) -> Self {
|
||||
Self {
|
||||
layer_id,
|
||||
shape_id,
|
||||
time,
|
||||
change: PropertyChange::FillColor { face_ids, color },
|
||||
old_edge_values: Vec::new(),
|
||||
old_face_values: Vec::new(),
|
||||
new_value,
|
||||
old_value: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_stroke_color(
|
||||
layer_id: Uuid,
|
||||
time: f64,
|
||||
edge_ids: Vec<EdgeId>,
|
||||
color: Option<ShapeColor>,
|
||||
) -> Self {
|
||||
Self {
|
||||
layer_id,
|
||||
time,
|
||||
change: PropertyChange::StrokeColor { edge_ids, color },
|
||||
old_edge_values: Vec::new(),
|
||||
old_face_values: Vec::new(),
|
||||
/// Create action to set fill 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, 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, time: f64, width: f64) -> Self {
|
||||
Self::new(layer_id, shape_id, time, ShapePropertyChange::StrokeWidth(width))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_stroke_width(
|
||||
layer_id: Uuid,
|
||||
time: f64,
|
||||
edge_ids: Vec<EdgeId>,
|
||||
width: f64,
|
||||
) -> Self {
|
||||
Self {
|
||||
layer_id,
|
||||
time,
|
||||
change: PropertyChange::StrokeWidth { edge_ids, width },
|
||||
old_edge_values: Vec::new(),
|
||||
old_face_values: Vec::new(),
|
||||
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()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn get_dcel_mut<'a>(
|
||||
document: &'a mut Document,
|
||||
layer_id: &Uuid,
|
||||
time: f64,
|
||||
) -> Result<&'a mut crate::dcel::Dcel, String> {
|
||||
let layer = document
|
||||
.get_layer_mut(layer_id)
|
||||
.ok_or_else(|| format!("Layer {} not found", layer_id))?;
|
||||
let vl = match layer {
|
||||
AnyLayer::Vector(vl) => vl,
|
||||
_ => return Err("Not a vector layer".to_string()),
|
||||
};
|
||||
vl.dcel_at_time_mut(time)
|
||||
.ok_or_else(|| format!("No keyframe at time {}", time))
|
||||
}
|
||||
}
|
||||
|
||||
impl Action for SetShapePropertiesAction {
|
||||
fn execute(&mut self, document: &mut Document) -> Result<(), String> {
|
||||
let dcel = Self::get_dcel_mut(document, &self.layer_id, self.time)?;
|
||||
|
||||
match &self.change {
|
||||
PropertyChange::FillColor { face_ids, color } => {
|
||||
self.old_face_values.clear();
|
||||
for &fid in face_ids {
|
||||
let face = dcel.face(fid);
|
||||
self.old_face_values.push((fid, face.fill_color));
|
||||
dcel.face_mut(fid).fill_color = *color;
|
||||
}
|
||||
}
|
||||
PropertyChange::StrokeColor { edge_ids, color } => {
|
||||
self.old_edge_values.clear();
|
||||
for &eid in edge_ids {
|
||||
let edge = dcel.edge(eid);
|
||||
let old_width = edge.stroke_style.as_ref().map(|s| s.width);
|
||||
self.old_edge_values.push((eid, edge.stroke_color, old_width));
|
||||
dcel.edge_mut(eid).stroke_color = *color;
|
||||
}
|
||||
}
|
||||
PropertyChange::StrokeWidth { edge_ids, width } => {
|
||||
self.old_edge_values.clear();
|
||||
for &eid in edge_ids {
|
||||
let edge = dcel.edge(eid);
|
||||
let old_width = edge.stroke_style.as_ref().map(|s| s.width);
|
||||
self.old_edge_values.push((eid, edge.stroke_color, old_width));
|
||||
if let Some(ref mut style) = dcel.edge_mut(eid).stroke_style {
|
||||
style.width = *width;
|
||||
if let Some(layer) = document.get_layer_mut(&self.layer_id) {
|
||||
if let AnyLayer::Vector(vector_layer) = layer {
|
||||
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 {
|
||||
ShapePropertyChange::FillColor(_) => {
|
||||
ShapePropertyChange::FillColor(shape.fill_color)
|
||||
}
|
||||
ShapePropertyChange::StrokeColor(_) => {
|
||||
ShapePropertyChange::StrokeColor(shape.stroke_color)
|
||||
}
|
||||
ShapePropertyChange::StrokeWidth(_) => {
|
||||
let width = shape
|
||||
.stroke_style
|
||||
.as_ref()
|
||||
.map(|s| s.width)
|
||||
.unwrap_or(1.0);
|
||||
ShapePropertyChange::StrokeWidth(width)
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
apply_property(shape, &self.new_value);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn rollback(&mut self, document: &mut Document) -> Result<(), String> {
|
||||
let dcel = Self::get_dcel_mut(document, &self.layer_id, self.time)?;
|
||||
|
||||
match &self.change {
|
||||
PropertyChange::FillColor { .. } => {
|
||||
for &(fid, old_color) in &self.old_face_values {
|
||||
dcel.face_mut(fid).fill_color = old_color;
|
||||
}
|
||||
}
|
||||
PropertyChange::StrokeColor { .. } => {
|
||||
for &(eid, old_color, _) in &self.old_edge_values {
|
||||
dcel.edge_mut(eid).stroke_color = old_color;
|
||||
}
|
||||
}
|
||||
PropertyChange::StrokeWidth { .. } => {
|
||||
for &(eid, _, old_width) in &self.old_edge_values {
|
||||
if let Some(w) = old_width {
|
||||
if let Some(ref mut style) = dcel.edge_mut(eid).stroke_style {
|
||||
style.width = w;
|
||||
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.get_shape_in_keyframe_mut(&self.shape_id, self.time) {
|
||||
apply_property(shape, old_value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn description(&self) -> String {
|
||||
match &self.change {
|
||||
PropertyChange::FillColor { .. } => "Set fill color".to_string(),
|
||||
PropertyChange::StrokeColor { .. } => "Set stroke color".to_string(),
|
||||
PropertyChange::StrokeWidth { .. } => "Set stroke width".to_string(),
|
||||
match &self.new_value {
|
||||
ShapePropertyChange::FillColor(_) => "Set fill color".to_string(),
|
||||
ShapePropertyChange::StrokeColor(_) => "Set stroke color".to_string(),
|
||||
ShapePropertyChange::StrokeWidth(_) => "Set stroke width".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::layer::VectorLayer;
|
||||
use crate::shape::Shape;
|
||||
use vello::kurbo::BezPath;
|
||||
|
||||
fn create_test_shape() -> Shape {
|
||||
let mut path = BezPath::new();
|
||||
path.move_to((0.0, 0.0));
|
||||
path.line_to((100.0, 0.0));
|
||||
path.line_to((100.0, 100.0));
|
||||
path.line_to((0.0, 100.0));
|
||||
path.close_path();
|
||||
|
||||
let mut shape = Shape::new(path);
|
||||
shape.fill_color = Some(ShapeColor::rgb(255, 0, 0));
|
||||
shape.stroke_color = Some(ShapeColor::rgb(0, 0, 0));
|
||||
shape.stroke_style = Some(StrokeStyle {
|
||||
width: 2.0,
|
||||
..Default::default()
|
||||
});
|
||||
shape
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_set_fill_color() {
|
||||
let mut document = Document::new("Test");
|
||||
let mut layer = VectorLayer::new("Test Layer");
|
||||
|
||||
let shape = create_test_shape();
|
||||
let shape_id = shape.id;
|
||||
layer.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(&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, 0.0, new_color);
|
||||
action.execute(&mut document).unwrap();
|
||||
|
||||
// Verify color changed
|
||||
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);
|
||||
}
|
||||
|
||||
// Rollback
|
||||
action.rollback(&mut document).unwrap();
|
||||
|
||||
// Verify restored
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_set_stroke_width() {
|
||||
let mut document = Document::new("Test");
|
||||
let mut layer = VectorLayer::new("Test Layer");
|
||||
|
||||
let shape = create_test_shape();
|
||||
let shape_id = shape.id;
|
||||
layer.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 = 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(&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);
|
||||
}
|
||||
|
||||
// Rollback
|
||||
action.rollback(&mut document).unwrap();
|
||||
|
||||
// Verify restored
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_description() {
|
||||
let layer_id = Uuid::new_v4();
|
||||
let shape_id = Uuid::new_v4();
|
||||
|
||||
let action1 =
|
||||
SetShapePropertiesAction::set_fill_color(layer_id, shape_id, 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, 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, 0.0, 3.0);
|
||||
assert_eq!(action3.description(), "Set stroke width");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,109 @@
|
|||
//! Transform shapes action
|
||||
//!
|
||||
//! Applies scale, rotation, and other transformations to shapes in a keyframe.
|
||||
|
||||
use crate::action::Action;
|
||||
use crate::document::Document;
|
||||
use crate::layer::AnyLayer;
|
||||
use crate::object::Transform;
|
||||
use std::collections::HashMap;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Action to transform multiple shapes in a keyframe
|
||||
pub struct TransformShapeInstancesAction {
|
||||
layer_id: Uuid,
|
||||
time: f64,
|
||||
/// Map of shape ID to (old transform, new transform)
|
||||
shape_transforms: HashMap<Uuid, (Transform, Transform)>,
|
||||
}
|
||||
|
||||
impl TransformShapeInstancesAction {
|
||||
pub fn new(
|
||||
layer_id: Uuid,
|
||||
time: f64,
|
||||
shape_transforms: HashMap<Uuid, (Transform, Transform)>,
|
||||
) -> Self {
|
||||
Self {
|
||||
layer_id,
|
||||
time,
|
||||
shape_transforms,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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_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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
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_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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn description(&self) -> String {
|
||||
format!("Transform {} shape(s)", self.shape_transforms.len())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::layer::VectorLayer;
|
||||
use crate::shape::Shape;
|
||||
use vello::kurbo::BezPath;
|
||||
|
||||
#[test]
|
||||
fn test_transform_shape() {
|
||||
let mut document = Document::new("Test");
|
||||
let mut layer = VectorLayer::new("Test Layer");
|
||||
|
||||
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));
|
||||
|
||||
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(shape_id, (old_transform, new_transform));
|
||||
|
||||
let mut action = TransformShapeInstancesAction::new(layer_id, 0.0, transforms);
|
||||
action.execute(&mut document).unwrap();
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
action.rollback(&mut document).unwrap();
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,44 +0,0 @@
|
|||
//! Beat/measure ↔ seconds conversion utilities
|
||||
|
||||
use crate::document::TimeSignature;
|
||||
|
||||
/// Position expressed as measure, beat, tick
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct MeasurePosition {
|
||||
pub measure: u32, // 1-indexed
|
||||
pub beat: u32, // 1-indexed
|
||||
pub tick: u32, // 0-999 (subdivision of beat)
|
||||
}
|
||||
|
||||
/// Convert a time in seconds to a measure position
|
||||
pub fn time_to_measure(time: f64, bpm: f64, time_sig: &TimeSignature) -> MeasurePosition {
|
||||
let beats_per_second = bpm / 60.0;
|
||||
let total_beats = (time * beats_per_second).max(0.0);
|
||||
let beats_per_measure = time_sig.numerator as f64;
|
||||
|
||||
let measure = (total_beats / beats_per_measure).floor() as u32 + 1;
|
||||
let beat = (total_beats.rem_euclid(beats_per_measure)).floor() as u32 + 1;
|
||||
let tick = ((total_beats.rem_euclid(1.0)) * 1000.0).floor() as u32;
|
||||
|
||||
MeasurePosition { measure, beat, tick }
|
||||
}
|
||||
|
||||
/// Convert a measure position to seconds
|
||||
pub fn measure_to_time(pos: MeasurePosition, bpm: f64, time_sig: &TimeSignature) -> f64 {
|
||||
let beats_per_measure = time_sig.numerator as f64;
|
||||
let total_beats = (pos.measure as f64 - 1.0) * beats_per_measure
|
||||
+ (pos.beat as f64 - 1.0)
|
||||
+ (pos.tick as f64 / 1000.0);
|
||||
let beats_per_second = bpm / 60.0;
|
||||
total_beats / beats_per_second
|
||||
}
|
||||
|
||||
/// Get the duration of one beat in seconds
|
||||
pub fn beat_duration(bpm: f64) -> f64 {
|
||||
60.0 / bpm
|
||||
}
|
||||
|
||||
/// Get the duration of one measure in seconds
|
||||
pub fn measure_duration(bpm: f64, time_sig: &TimeSignature) -> f64 {
|
||||
beat_duration(bpm) * time_sig.numerator as f64
|
||||
}
|
||||
|
|
@ -17,7 +17,7 @@ use crate::object::Transform;
|
|||
use serde::{Deserialize, Serialize};
|
||||
use std::path::PathBuf;
|
||||
use uuid::Uuid;
|
||||
use vello::kurbo::Rect;
|
||||
use vello::kurbo::{Rect, Shape as KurboShape};
|
||||
|
||||
/// Vector clip containing nested layers
|
||||
///
|
||||
|
|
@ -167,20 +167,21 @@ 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 from DCEL edges
|
||||
if let Some(dcel) = vector_layer.dcel_at_time(clip_time) {
|
||||
use kurbo::Shape as KurboShape;
|
||||
for edge in &dcel.edges {
|
||||
if edge.deleted {
|
||||
continue;
|
||||
}
|
||||
let edge_bbox = edge.curve.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'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 => edge_bbox,
|
||||
Some(existing) => existing.union(edge_bbox),
|
||||
None => transformed_bbox,
|
||||
Some(existing) => existing.union(transformed_bbox),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Handle nested clip instances recursively
|
||||
for clip_instance in &vector_layer.clip_instances {
|
||||
|
|
@ -846,13 +847,11 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn test_audio_clip_midi() {
|
||||
let clip = AudioClip::new_midi("Piano Melody", 1, 60.0);
|
||||
let events = vec![MidiEvent::note_on(0.0, 0, 60, 100)];
|
||||
let clip = AudioClip::new_midi("Piano Melody", 60.0, events.clone(), false);
|
||||
assert_eq!(clip.name, "Piano Melody");
|
||||
assert_eq!(clip.duration, 60.0);
|
||||
match &clip.clip_type {
|
||||
AudioClipType::Midi { midi_clip_id } => assert_eq!(*midi_clip_id, 1),
|
||||
_ => panic!("Expected Midi clip type"),
|
||||
}
|
||||
assert_eq!(clip.midi_events().map(|e| e.len()), Some(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -74,9 +74,8 @@ fn find_intersections_recursive(
|
|||
// Maximum recursion depth
|
||||
const MAX_DEPTH: usize = 20;
|
||||
|
||||
// Pixel-space convergence threshold: stop subdividing when both
|
||||
// subsegments span less than this many pixels.
|
||||
const PIXEL_TOL: f64 = 0.25;
|
||||
// Minimum parameter range (if smaller, we've found an intersection)
|
||||
const MIN_RANGE: f64 = 0.001;
|
||||
|
||||
// Get bounding boxes of current subsegments
|
||||
let bbox1 = curve1.bounding_box();
|
||||
|
|
@ -91,76 +90,16 @@ fn find_intersections_recursive(
|
|||
return;
|
||||
}
|
||||
|
||||
// Evaluate subsegment endpoints for convergence check and line-line solve
|
||||
let a0 = orig_curve1.eval(t1_start);
|
||||
let a1 = orig_curve1.eval(t1_end);
|
||||
let b0 = orig_curve2.eval(t2_start);
|
||||
let b1 = orig_curve2.eval(t2_end);
|
||||
|
||||
// Check convergence in pixel space: both subsegment spans must be
|
||||
// below the tolerance. This ensures the linear approximation error
|
||||
// is always well within the vertex snap threshold regardless of
|
||||
// curve length.
|
||||
let a_span = (a1 - a0).hypot();
|
||||
let b_span = (b1 - b0).hypot();
|
||||
|
||||
if depth >= MAX_DEPTH || (a_span < PIXEL_TOL && b_span < PIXEL_TOL) {
|
||||
|
||||
let (t1, t2, point) = if let Some((s, u)) = line_line_intersect(a0, a1, b0, b1) {
|
||||
let s = s.clamp(0.0, 1.0);
|
||||
let u = u.clamp(0.0, 1.0);
|
||||
let mut t1 = t1_start + s * (t1_end - t1_start);
|
||||
let mut t2 = t2_start + u * (t2_end - t2_start);
|
||||
|
||||
// Newton refinement: converge t1, t2 so that
|
||||
// curve1.eval(t1) == curve2.eval(t2) to sub-pixel accuracy.
|
||||
// We solve F(t1,t2) = curve1(t1) - curve2(t2) = 0 via the
|
||||
// Jacobian [d1, -d2] where d1/d2 are the curve tangents.
|
||||
let t1_orig = t1;
|
||||
let t2_orig = t2;
|
||||
for _ in 0..8 {
|
||||
let p1 = orig_curve1.eval(t1);
|
||||
let p2 = orig_curve2.eval(t2);
|
||||
let err = Point::new(p1.x - p2.x, p1.y - p2.y);
|
||||
if err.x * err.x + err.y * err.y < 1e-6 {
|
||||
break;
|
||||
}
|
||||
// Tangent vectors (derivative of cubic bezier)
|
||||
let d1 = cubic_deriv(orig_curve1, t1);
|
||||
let d2 = cubic_deriv(orig_curve2, t2);
|
||||
// Solve [d1.x, -d2.x; d1.y, -d2.y] * [dt1; dt2] = -[err.x; err.y]
|
||||
let det = d1.x * (-d2.y) - d1.y * (-d2.x);
|
||||
if det.abs() < 1e-12 {
|
||||
break; // tangents parallel, can't refine
|
||||
}
|
||||
let dt1 = (-d2.y * (-err.x) - (-d2.x) * (-err.y)) / det;
|
||||
let dt2 = (d1.x * (-err.y) - d1.y * (-err.x)) / det;
|
||||
t1 = (t1 + dt1).clamp(0.0, 1.0);
|
||||
t2 = (t2 + dt2).clamp(0.0, 1.0);
|
||||
}
|
||||
// If Newton diverged far from the initial estimate, it may have
|
||||
// jumped to a different crossing. Reject and fall back.
|
||||
if (t1 - t1_orig).abs() > (t1_end - t1_start) * 2.0
|
||||
|| (t2 - t2_orig).abs() > (t2_end - t2_start) * 2.0
|
||||
{
|
||||
t1 = t1_orig;
|
||||
t2 = t2_orig;
|
||||
}
|
||||
|
||||
let p1 = orig_curve1.eval(t1);
|
||||
let p2 = orig_curve2.eval(t2);
|
||||
(t1, t2, Point::new((p1.x + p2.x) * 0.5, (p1.y + p2.y) * 0.5))
|
||||
} else {
|
||||
// Lines are parallel/degenerate — fall back to midpoint
|
||||
// If we've recursed deep enough or ranges are small enough, record intersection
|
||||
if depth >= MAX_DEPTH ||
|
||||
((t1_end - t1_start) < MIN_RANGE && (t2_end - t2_start) < MIN_RANGE) {
|
||||
let t1 = (t1_start + t1_end) / 2.0;
|
||||
let t2 = (t2_start + t2_end) / 2.0;
|
||||
(t1, t2, orig_curve1.eval(t1))
|
||||
};
|
||||
|
||||
intersections.push(Intersection {
|
||||
t1,
|
||||
t2: Some(t2),
|
||||
point,
|
||||
point: orig_curve1.eval(t1),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
|
@ -313,100 +252,21 @@ fn refine_self_intersection(curve: &CubicBez, mut t1: f64, mut t2: f64) -> (f64,
|
|||
(t1.clamp(0.0, 1.0), t2.clamp(0.0, 1.0))
|
||||
}
|
||||
|
||||
/// Remove duplicate intersections by clustering on parameter proximity.
|
||||
///
|
||||
/// Raw hits from subdivision can produce chains of near-duplicates spaced
|
||||
/// just over the spatial tolerance (e.g. 4 hits at 1.02 px apart for a
|
||||
/// single crossing of shallow-angle curves). Pairwise spatial dedup fails
|
||||
/// on these chains. Instead, we sort by t1, cluster consecutive hits whose
|
||||
/// t1 values are within `param_tol`, and keep the median of each cluster.
|
||||
fn dedup_intersections(intersections: &mut Vec<Intersection>, _tolerance: f64) {
|
||||
if intersections.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
const PARAM_TOL: f64 = 0.05;
|
||||
|
||||
// Sort by t1 (primary) then t2 (secondary)
|
||||
intersections.sort_by(|a, b| {
|
||||
a.t1.partial_cmp(&b.t1)
|
||||
.unwrap()
|
||||
.then_with(|| {
|
||||
let at2 = a.t2.unwrap_or(0.0);
|
||||
let bt2 = b.t2.unwrap_or(0.0);
|
||||
at2.partial_cmp(&bt2).unwrap()
|
||||
})
|
||||
});
|
||||
|
||||
// Cluster consecutive intersections that are close in both t1 and t2
|
||||
let mut clusters: Vec<Vec<usize>> = Vec::new();
|
||||
let mut current_cluster = vec![0usize];
|
||||
|
||||
for i in 1..intersections.len() {
|
||||
let prev = &intersections[*current_cluster.last().unwrap()];
|
||||
let curr = &intersections[i];
|
||||
let t1_close = (curr.t1 - prev.t1).abs() < PARAM_TOL;
|
||||
let t2_close = match (curr.t2, prev.t2) {
|
||||
(Some(a), Some(b)) => (a - b).abs() < PARAM_TOL,
|
||||
_ => true,
|
||||
};
|
||||
if t1_close && t2_close {
|
||||
current_cluster.push(i);
|
||||
/// Remove duplicate intersections within a tolerance
|
||||
fn dedup_intersections(intersections: &mut Vec<Intersection>, tolerance: f64) {
|
||||
let mut i = 0;
|
||||
while i < intersections.len() {
|
||||
let mut j = i + 1;
|
||||
while j < intersections.len() {
|
||||
let dist = (intersections[i].point - intersections[j].point).hypot();
|
||||
if dist < tolerance {
|
||||
intersections.remove(j);
|
||||
} else {
|
||||
clusters.push(std::mem::take(&mut current_cluster));
|
||||
current_cluster = vec![i];
|
||||
j += 1;
|
||||
}
|
||||
}
|
||||
clusters.push(current_cluster);
|
||||
|
||||
// Keep the median of each cluster
|
||||
let mut result = Vec::with_capacity(clusters.len());
|
||||
for cluster in &clusters {
|
||||
let median_idx = cluster[cluster.len() / 2];
|
||||
result.push(intersections[median_idx].clone());
|
||||
i += 1;
|
||||
}
|
||||
|
||||
*intersections = result;
|
||||
}
|
||||
|
||||
/// Derivative (tangent vector) of a cubic Bezier at parameter t.
|
||||
///
|
||||
/// B'(t) = 3[(1-t)²(P1-P0) + 2(1-t)t(P2-P1) + t²(P3-P2)]
|
||||
fn cubic_deriv(c: &CubicBez, t: f64) -> Point {
|
||||
let u = 1.0 - t;
|
||||
let d0 = Point::new(c.p1.x - c.p0.x, c.p1.y - c.p0.y);
|
||||
let d1 = Point::new(c.p2.x - c.p1.x, c.p2.y - c.p1.y);
|
||||
let d2 = Point::new(c.p3.x - c.p2.x, c.p3.y - c.p2.y);
|
||||
Point::new(
|
||||
3.0 * (u * u * d0.x + 2.0 * u * t * d1.x + t * t * d2.x),
|
||||
3.0 * (u * u * d0.y + 2.0 * u * t * d1.y + t * t * d2.y),
|
||||
)
|
||||
}
|
||||
|
||||
/// 2D line-line intersection.
|
||||
///
|
||||
/// Given line segment A (a0→a1) and line segment B (b0→b1),
|
||||
/// returns `Some((s, u))` where `s` is the parameter on A and
|
||||
/// `u` is the parameter on B at the intersection point.
|
||||
/// Returns `None` if the lines are parallel or degenerate.
|
||||
fn line_line_intersect(a0: Point, a1: Point, b0: Point, b1: Point) -> Option<(f64, f64)> {
|
||||
let dx_a = a1.x - a0.x;
|
||||
let dy_a = a1.y - a0.y;
|
||||
let dx_b = b1.x - b0.x;
|
||||
let dy_b = b1.y - b0.y;
|
||||
|
||||
let denom = dx_a * dy_b - dy_a * dx_b;
|
||||
if denom.abs() < 1e-12 {
|
||||
return None; // parallel or degenerate
|
||||
}
|
||||
|
||||
let dx_ab = b0.x - a0.x;
|
||||
let dy_ab = b0.y - a0.y;
|
||||
|
||||
let s = (dx_ab * dy_b - dy_ab * dx_b) / denom;
|
||||
let u = (dx_ab * dy_a - dy_ab * dx_a) / denom;
|
||||
|
||||
Some((s, u))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -70,21 +70,6 @@ impl Default for GraphicsObject {
|
|||
}
|
||||
}
|
||||
|
||||
/// Musical time signature
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct TimeSignature {
|
||||
pub numerator: u32, // beats per measure (e.g., 4)
|
||||
pub denominator: u32, // beat unit (e.g., 4 = quarter note)
|
||||
}
|
||||
|
||||
impl Default for TimeSignature {
|
||||
fn default() -> Self {
|
||||
Self { numerator: 4, denominator: 4 }
|
||||
}
|
||||
}
|
||||
|
||||
fn default_bpm() -> f64 { 120.0 }
|
||||
|
||||
/// Asset category for folder tree access
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum AssetCategory {
|
||||
|
|
@ -116,14 +101,6 @@ pub struct Document {
|
|||
/// Framerate (frames per second)
|
||||
pub framerate: f64,
|
||||
|
||||
/// Tempo in beats per minute
|
||||
#[serde(default = "default_bpm")]
|
||||
pub bpm: f64,
|
||||
|
||||
/// Time signature
|
||||
#[serde(default)]
|
||||
pub time_signature: TimeSignature,
|
||||
|
||||
/// Duration in seconds
|
||||
pub duration: f64,
|
||||
|
||||
|
|
@ -205,8 +182,6 @@ impl Default for Document {
|
|||
width: 1920.0,
|
||||
height: 1080.0,
|
||||
framerate: 60.0,
|
||||
bpm: 120.0,
|
||||
time_signature: TimeSignature::default(),
|
||||
duration: 10.0,
|
||||
root: GraphicsObject::default(),
|
||||
vector_clips: HashMap::new(),
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
//! Hit testing for selection and interaction
|
||||
//!
|
||||
//! Provides functions for testing if points or rectangles intersect with
|
||||
//! DCEL elements and clip instances, taking into account transform hierarchies.
|
||||
//! shapes and objects, taking into account transform hierarchies.
|
||||
|
||||
use crate::clip::ClipInstance;
|
||||
use crate::dcel::{VertexId, EdgeId, FaceId};
|
||||
use crate::layer::VectorLayer;
|
||||
use crate::region_select;
|
||||
use crate::shape::Shape;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
|
@ -14,25 +14,15 @@ use vello::kurbo::{Affine, BezPath, Point, Rect, Shape as KurboShape};
|
|||
/// Result of a hit test operation
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum HitResult {
|
||||
/// Hit a DCEL edge (stroke)
|
||||
Edge(EdgeId),
|
||||
/// Hit a DCEL face (fill)
|
||||
Face(FaceId),
|
||||
/// Hit a shape instance
|
||||
ShapeInstance(Uuid),
|
||||
/// Hit a clip instance
|
||||
ClipInstance(Uuid),
|
||||
}
|
||||
|
||||
/// Result of a DCEL-only hit test (no clip instances)
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum DcelHitResult {
|
||||
Edge(EdgeId),
|
||||
Face(FaceId),
|
||||
}
|
||||
|
||||
/// Hit test a layer at a specific point, returning edge or face hits.
|
||||
/// Hit test a layer at a specific point
|
||||
///
|
||||
/// Tests DCEL edges (strokes) and faces (fills) in the active keyframe.
|
||||
/// Edge hits take priority over face hits.
|
||||
/// Tests shapes in the active keyframe in reverse order (front to back) and returns the first hit.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
|
|
@ -44,66 +34,20 @@ pub enum DcelHitResult {
|
|||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The first DCEL element 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<DcelHitResult> {
|
||||
let dcel = layer.dcel_at_time(time)?;
|
||||
) -> Option<Uuid> {
|
||||
// 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();
|
||||
|
||||
// Transform point to local space
|
||||
let local_point = parent_transform.inverse() * point;
|
||||
|
||||
// 1. Check edges (strokes) — priority over faces
|
||||
let mut best_edge: Option<(EdgeId, f64)> = None;
|
||||
for (i, edge) in dcel.edges.iter().enumerate() {
|
||||
if edge.deleted {
|
||||
continue;
|
||||
}
|
||||
// Only hit-test edges that have a visible stroke
|
||||
if edge.stroke_color.is_none() && edge.stroke_style.is_none() {
|
||||
continue;
|
||||
}
|
||||
|
||||
use kurbo::ParamCurveNearest;
|
||||
let nearest = edge.curve.nearest(local_point, 0.5);
|
||||
let dist = nearest.distance_sq.sqrt();
|
||||
|
||||
let hit_radius = edge
|
||||
.stroke_style
|
||||
.as_ref()
|
||||
.map(|s| s.width / 2.0)
|
||||
.unwrap_or(0.0)
|
||||
+ tolerance;
|
||||
|
||||
if dist < hit_radius {
|
||||
if best_edge.is_none() || dist < best_edge.unwrap().1 {
|
||||
best_edge = Some((EdgeId(i as u32), dist));
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some((edge_id, _)) = best_edge {
|
||||
return Some(DcelHitResult::Edge(edge_id));
|
||||
}
|
||||
|
||||
// 2. Check faces (fills)
|
||||
for (i, face) in dcel.faces.iter().enumerate() {
|
||||
if face.deleted || i == 0 {
|
||||
continue; // skip unbounded face
|
||||
}
|
||||
if face.fill_color.is_none() && face.image_fill.is_none() {
|
||||
continue;
|
||||
}
|
||||
if face.outer_half_edge.is_none() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let path = dcel.face_to_bezpath(FaceId(i as u32));
|
||||
if path.winding(local_point) != 0 {
|
||||
return Some(DcelHitResult::Face(FaceId(i as u32)));
|
||||
if hit_test_shape(shape, point, tolerance, combined_transform) {
|
||||
return Some(shape.id);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -147,73 +91,33 @@ pub fn hit_test_shape(
|
|||
false
|
||||
}
|
||||
|
||||
/// Result of DCEL marquee selection
|
||||
#[derive(Debug, Default)]
|
||||
pub struct DcelMarqueeResult {
|
||||
pub edges: Vec<EdgeId>,
|
||||
pub faces: Vec<FaceId>,
|
||||
}
|
||||
|
||||
/// Hit test DCEL elements within a rectangle (for marquee selection).
|
||||
/// Hit test objects within a rectangle (for marquee selection)
|
||||
///
|
||||
/// Selects edges whose both endpoints are inside the rect,
|
||||
/// and faces whose all boundary vertices are inside the rect.
|
||||
pub fn hit_test_dcel_in_rect(
|
||||
/// 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,
|
||||
) -> DcelMarqueeResult {
|
||||
let mut result = DcelMarqueeResult::default();
|
||||
) -> Vec<Uuid> {
|
||||
let mut hits = Vec::new();
|
||||
|
||||
let dcel = match layer.dcel_at_time(time) {
|
||||
Some(d) => d,
|
||||
None => return result,
|
||||
};
|
||||
for shape in layer.shapes_at_time(time) {
|
||||
let combined_transform = parent_transform * shape.transform.to_affine();
|
||||
|
||||
let inv = parent_transform.inverse();
|
||||
let local_rect = inv.transform_rect_bbox(rect);
|
||||
// Get shape bounding box in local space
|
||||
let bbox = shape.path().bounding_box();
|
||||
|
||||
// Check edges: both endpoints inside rect
|
||||
for (i, edge) in dcel.edges.iter().enumerate() {
|
||||
if edge.deleted {
|
||||
continue;
|
||||
}
|
||||
let [he_fwd, he_bwd] = edge.half_edges;
|
||||
if he_fwd.is_none() || he_bwd.is_none() {
|
||||
continue;
|
||||
}
|
||||
let v1 = dcel.half_edge(he_fwd).origin;
|
||||
let v2 = dcel.half_edge(he_bwd).origin;
|
||||
if v1.is_none() || v2.is_none() {
|
||||
continue;
|
||||
}
|
||||
let p1 = dcel.vertex(v1).position;
|
||||
let p2 = dcel.vertex(v2).position;
|
||||
if local_rect.contains(p1) && local_rect.contains(p2) {
|
||||
result.edges.push(EdgeId(i as u32));
|
||||
// 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(shape.id);
|
||||
}
|
||||
}
|
||||
|
||||
// Check faces: all boundary vertices inside rect
|
||||
for (i, face) in dcel.faces.iter().enumerate() {
|
||||
if face.deleted || i == 0 {
|
||||
continue;
|
||||
}
|
||||
if face.outer_half_edge.is_none() {
|
||||
continue;
|
||||
}
|
||||
let boundary = dcel.face_boundary(FaceId(i as u32));
|
||||
let all_inside = boundary.iter().all(|&he_id| {
|
||||
let v = dcel.half_edge(he_id).origin;
|
||||
!v.is_none() && local_rect.contains(dcel.vertex(v).position)
|
||||
});
|
||||
if all_inside && !boundary.is_empty() {
|
||||
result.faces.push(FaceId(i as u32));
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
hits
|
||||
}
|
||||
|
||||
/// Classification of shapes relative to a clipping region
|
||||
|
|
@ -237,7 +141,7 @@ pub fn classify_shapes_by_region(
|
|||
region: &BezPath,
|
||||
parent_transform: Affine,
|
||||
) -> ShapeRegionClassification {
|
||||
let result = ShapeRegionClassification {
|
||||
let mut result = ShapeRegionClassification {
|
||||
fully_inside: Vec::new(),
|
||||
intersecting: Vec::new(),
|
||||
fully_outside: Vec::new(),
|
||||
|
|
@ -245,8 +149,33 @@ pub fn classify_shapes_by_region(
|
|||
|
||||
let region_bbox = region.bounding_box();
|
||||
|
||||
// TODO: Implement DCEL-based region classification
|
||||
let _ = (layer, time, parent_transform, region_bbox);
|
||||
for shape in layer.shapes_at_time(time) {
|
||||
let combined_transform = parent_transform * shape.transform.to_affine();
|
||||
let bbox = shape.path().bounding_box();
|
||||
let transformed_bbox = combined_transform.transform_rect_bbox(bbox);
|
||||
|
||||
// Fast rejection: if bounding boxes don't overlap, fully outside
|
||||
if region_bbox.intersect(transformed_bbox).area() <= 0.0 {
|
||||
result.fully_outside.push(shape.id);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Transform the shape path to world space for accurate testing
|
||||
let world_path = {
|
||||
let mut p = shape.path().clone();
|
||||
p.apply_affine(combined_transform);
|
||||
p
|
||||
};
|
||||
|
||||
// Check if the path crosses the region boundary
|
||||
if region_select::path_intersects_region(&world_path, region) {
|
||||
result.intersecting.push(shape.id);
|
||||
} else if region_select::path_fully_inside_region(&world_path, region) {
|
||||
result.fully_inside.push(shape.id);
|
||||
} else {
|
||||
result.fully_outside.push(shape.id);
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
|
@ -371,22 +300,23 @@ pub fn hit_test_clip_instances_in_rect(
|
|||
pub enum VectorEditHit {
|
||||
/// Hit a control point (BezierEdit tool only)
|
||||
ControlPoint {
|
||||
edge_id: EdgeId,
|
||||
point_index: u8, // 1 = p1, 2 = p2
|
||||
shape_instance_id: Uuid,
|
||||
curve_index: usize,
|
||||
point_index: u8,
|
||||
},
|
||||
/// Hit a vertex (anchor point)
|
||||
Vertex {
|
||||
vertex_id: VertexId,
|
||||
shape_instance_id: Uuid,
|
||||
vertex_index: usize,
|
||||
},
|
||||
/// Hit a curve segment
|
||||
Curve {
|
||||
edge_id: EdgeId,
|
||||
shape_instance_id: Uuid,
|
||||
curve_index: usize,
|
||||
parameter_t: f64,
|
||||
},
|
||||
/// Hit shape fill
|
||||
Fill {
|
||||
face_id: FaceId,
|
||||
},
|
||||
Fill { shape_instance_id: Uuid },
|
||||
}
|
||||
|
||||
/// Tolerances for vector editing hit testing (in screen pixels)
|
||||
|
|
@ -429,92 +359,80 @@ pub fn hit_test_vector_editing(
|
|||
parent_transform: Affine,
|
||||
show_control_points: bool,
|
||||
) -> Option<VectorEditHit> {
|
||||
use kurbo::ParamCurveNearest;
|
||||
use crate::bezpath_editing::extract_editable_curves;
|
||||
use vello::kurbo::{ParamCurve, ParamCurveNearest};
|
||||
|
||||
let dcel = layer.dcel_at_time(time)?;
|
||||
// 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;
|
||||
|
||||
// Transform point into layer-local space
|
||||
let local_point = parent_transform.inverse() * point;
|
||||
// Calculate the scale factor to transform screen-space tolerances to local space
|
||||
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);
|
||||
|
||||
// Priority: ControlPoint > Vertex > Curve > Fill
|
||||
let editable = extract_editable_curves(shape.path());
|
||||
|
||||
// 1. Control points (only when show_control_points is true, e.g. BezierEdit tool)
|
||||
// Priority 1: Control points (only in BezierEdit mode)
|
||||
if show_control_points {
|
||||
let mut best_cp: Option<(EdgeId, u8, f64)> = None;
|
||||
for (i, edge) in dcel.edges.iter().enumerate() {
|
||||
if edge.deleted {
|
||||
continue;
|
||||
let local_cp_tolerance = tolerance.control_point * local_tolerance_factor;
|
||||
for (i, curve) in editable.curves.iter().enumerate() {
|
||||
let dist_p1 = (curve.p1 - local_point).hypot();
|
||||
if dist_p1 < local_cp_tolerance {
|
||||
return Some(VectorEditHit::ControlPoint {
|
||||
shape_instance_id: shape.id,
|
||||
curve_index: i,
|
||||
point_index: 1,
|
||||
});
|
||||
}
|
||||
let edge_id = EdgeId(i as u32);
|
||||
// Check p1
|
||||
let d1 = local_point.distance(edge.curve.p1);
|
||||
if d1 < tolerance.control_point {
|
||||
if best_cp.is_none() || d1 < best_cp.unwrap().2 {
|
||||
best_cp = Some((edge_id, 1, d1));
|
||||
|
||||
let dist_p2 = (curve.p2 - local_point).hypot();
|
||||
if dist_p2 < local_cp_tolerance {
|
||||
return Some(VectorEditHit::ControlPoint {
|
||||
shape_instance_id: shape.id,
|
||||
curve_index: i,
|
||||
point_index: 2,
|
||||
});
|
||||
}
|
||||
}
|
||||
// Check p2
|
||||
let d2 = local_point.distance(edge.curve.p2);
|
||||
if d2 < tolerance.control_point {
|
||||
if best_cp.is_none() || d2 < best_cp.unwrap().2 {
|
||||
best_cp = Some((edge_id, 2, d2));
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some((edge_id, point_index, _)) = best_cp {
|
||||
return Some(VectorEditHit::ControlPoint { edge_id, point_index });
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Vertices
|
||||
let mut best_vertex: Option<(VertexId, f64)> = None;
|
||||
for (i, vertex) in dcel.vertices.iter().enumerate() {
|
||||
if vertex.deleted {
|
||||
continue;
|
||||
// Priority 2: Vertices (anchor points)
|
||||
let local_vertex_tolerance = tolerance.vertex * local_tolerance_factor;
|
||||
for (i, vertex) in editable.vertices.iter().enumerate() {
|
||||
let dist = (vertex.point - local_point).hypot();
|
||||
if dist < local_vertex_tolerance {
|
||||
return Some(VectorEditHit::Vertex {
|
||||
shape_instance_id: shape.id,
|
||||
vertex_index: i,
|
||||
});
|
||||
}
|
||||
let dist = local_point.distance(vertex.position);
|
||||
if dist < tolerance.vertex {
|
||||
if best_vertex.is_none() || dist < best_vertex.unwrap().1 {
|
||||
best_vertex = Some((VertexId(i as u32), dist));
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some((vertex_id, _)) = best_vertex {
|
||||
return Some(VectorEditHit::Vertex { vertex_id });
|
||||
}
|
||||
|
||||
// 3. Curves (edges)
|
||||
let mut best_curve: Option<(EdgeId, f64, f64)> = None; // (edge_id, t, dist)
|
||||
for (i, edge) in dcel.edges.iter().enumerate() {
|
||||
if edge.deleted {
|
||||
continue;
|
||||
// Priority 3: Curves
|
||||
let local_curve_tolerance = tolerance.curve * local_tolerance_factor;
|
||||
for (i, curve) in editable.curves.iter().enumerate() {
|
||||
let nearest = curve.nearest(local_point, 1e-6);
|
||||
let nearest_point = curve.eval(nearest.t);
|
||||
let dist = (nearest_point - local_point).hypot();
|
||||
if dist < local_curve_tolerance {
|
||||
return Some(VectorEditHit::Curve {
|
||||
shape_instance_id: shape.id,
|
||||
curve_index: i,
|
||||
parameter_t: nearest.t,
|
||||
});
|
||||
}
|
||||
let nearest = edge.curve.nearest(local_point, 0.5);
|
||||
let dist = nearest.distance_sq.sqrt();
|
||||
if dist < tolerance.curve {
|
||||
if best_curve.is_none() || dist < best_curve.unwrap().2 {
|
||||
best_curve = Some((EdgeId(i as u32), nearest.t, dist));
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some((edge_id, parameter_t, _)) = best_curve {
|
||||
return Some(VectorEditHit::Curve { edge_id, parameter_t });
|
||||
}
|
||||
|
||||
// 4. Face fill testing
|
||||
for (i, face) in dcel.faces.iter().enumerate() {
|
||||
if face.deleted || i == 0 {
|
||||
continue;
|
||||
}
|
||||
if face.fill_color.is_none() && face.image_fill.is_none() {
|
||||
continue;
|
||||
}
|
||||
if face.outer_half_edge.is_none() {
|
||||
continue;
|
||||
}
|
||||
let path = dcel.face_to_bezpath(FaceId(i as u32));
|
||||
if path.winding(local_point) != 0 {
|
||||
return Some(VectorEditHit::Fill { face_id: FaceId(i as u32) });
|
||||
// Priority 4: Fill
|
||||
if shape.fill_color.is_some() && shape.path().contains(local_point) {
|
||||
return Some(VectorEditHit::Fill {
|
||||
shape_instance_id: shape.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -529,16 +447,65 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn test_hit_test_simple_circle() {
|
||||
// TODO: DCEL - rewrite test
|
||||
let mut layer = VectorLayer::new("Test Layer");
|
||||
|
||||
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));
|
||||
|
||||
layer.add_shape_to_keyframe(shape, 0.0);
|
||||
|
||||
// Test hit inside circle
|
||||
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, 0.0, Point::new(200.0, 200.0), 0.0, Affine::IDENTITY);
|
||||
assert!(miss.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_hit_test_with_transform() {
|
||||
// TODO: DCEL - rewrite test
|
||||
let mut layer = VectorLayer::new("Test Layer");
|
||||
|
||||
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))
|
||||
.with_position(100.0, 100.0);
|
||||
|
||||
layer.add_shape_to_keyframe(shape, 0.0);
|
||||
|
||||
// Test hit at translated position
|
||||
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 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());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_marquee_selection() {
|
||||
// TODO: DCEL - rewrite test
|
||||
let mut layer = VectorLayer::new("Test Layer");
|
||||
|
||||
let circle1 = Circle::new((50.0, 50.0), 20.0);
|
||||
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 shape2 = Shape::new(circle2.to_path(0.1)).with_fill(ShapeColor::rgb(0, 255, 0));
|
||||
|
||||
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, 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, 0.0, rect, Affine::IDENTITY);
|
||||
assert_eq!(hits.len(), 1);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@
|
|||
|
||||
use crate::animation::AnimationData;
|
||||
use crate::clip::ClipInstance;
|
||||
use crate::dcel::Dcel;
|
||||
use crate::effect_layer::EffectLayer;
|
||||
use crate::object::ShapeInstance;
|
||||
use crate::shape::Shape;
|
||||
|
|
@ -152,13 +151,13 @@ impl Default for TweenType {
|
|||
}
|
||||
}
|
||||
|
||||
/// A keyframe containing vector artwork as a DCEL planar subdivision.
|
||||
/// A keyframe containing all shapes at a point in time
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct ShapeKeyframe {
|
||||
/// Time in seconds
|
||||
pub time: f64,
|
||||
/// DCEL planar subdivision containing all vector artwork
|
||||
pub dcel: Dcel,
|
||||
/// All shapes at this keyframe
|
||||
pub shapes: Vec<Shape>,
|
||||
/// What happens between this keyframe and the next
|
||||
#[serde(default)]
|
||||
pub tween_after: TweenType,
|
||||
|
|
@ -173,7 +172,17 @@ impl ShapeKeyframe {
|
|||
pub fn new(time: f64) -> Self {
|
||||
Self {
|
||||
time,
|
||||
dcel: Dcel::new(),
|
||||
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(),
|
||||
}
|
||||
|
|
@ -186,15 +195,13 @@ pub struct VectorLayer {
|
|||
/// Base layer properties
|
||||
pub layer: Layer,
|
||||
|
||||
/// Legacy shapes — kept for old .beam file compat, not written to new files.
|
||||
#[serde(default, skip_serializing)]
|
||||
/// Shapes defined in this layer (indexed by UUID for O(1) lookup)
|
||||
pub shapes: HashMap<Uuid, Shape>,
|
||||
|
||||
/// Legacy shape instances — kept for old .beam file compat, not written to new files.
|
||||
#[serde(default, skip_serializing)]
|
||||
/// Shape instances (references to shapes with transforms)
|
||||
pub shape_instances: Vec<ShapeInstance>,
|
||||
|
||||
/// Shape keyframes (sorted by time)
|
||||
/// Shape keyframes (sorted by time) — replaces shapes/shape_instances
|
||||
#[serde(default)]
|
||||
pub keyframes: Vec<ShapeKeyframe>,
|
||||
|
||||
|
|
@ -363,14 +370,12 @@ impl VectorLayer {
|
|||
self.keyframes.iter().position(|kf| (kf.time - time).abs() < tolerance)
|
||||
}
|
||||
|
||||
/// Get the DCEL at a given time (from the keyframe at-or-before time)
|
||||
pub fn dcel_at_time(&self, time: f64) -> Option<&Dcel> {
|
||||
self.keyframe_at(time).map(|kf| &kf.dcel)
|
||||
/// 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 a mutable DCEL at a given time
|
||||
pub fn dcel_at_time_mut(&mut self, time: f64) -> Option<&mut Dcel> {
|
||||
self.keyframe_at_mut(time).map(|kf| &mut kf.dcel)
|
||||
}
|
||||
|
||||
/// Get the duration of the keyframe span starting at-or-before `time`.
|
||||
|
|
@ -419,10 +424,22 @@ impl VectorLayer {
|
|||
time + frame_duration
|
||||
}
|
||||
|
||||
// Shape-based methods removed — use DCEL methods instead.
|
||||
// - shapes_at_time_mut → dcel_at_time_mut
|
||||
// - get_shape_in_keyframe → use DCEL vertex/edge/face accessors
|
||||
// - get_shape_in_keyframe_mut → use DCEL vertex/edge/face accessors
|
||||
/// 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.
|
||||
|
|
@ -437,7 +454,8 @@ impl VectorLayer {
|
|||
&mut self.keyframes[insert_idx]
|
||||
}
|
||||
|
||||
/// Insert a new keyframe at time by cloning the DCEL from the active keyframe.
|
||||
/// 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;
|
||||
|
|
@ -445,22 +463,45 @@ impl VectorLayer {
|
|||
return &mut self.keyframes[idx];
|
||||
}
|
||||
|
||||
// Clone DCEL and clip instance IDs from the active keyframe
|
||||
let (cloned_dcel, cloned_clip_ids) = self
|
||||
// Clone shapes and clip instance IDs from the active keyframe
|
||||
let (cloned_shapes, cloned_clip_ids) = self
|
||||
.keyframe_at(time)
|
||||
.map(|kf| {
|
||||
(kf.dcel.clone(), kf.clip_instance_ids.clone())
|
||||
let shapes: Vec<Shape> = kf.shapes
|
||||
.iter()
|
||||
.map(|s| {
|
||||
let mut new_shape = s.clone();
|
||||
new_shape.id = Uuid::new_v4();
|
||||
new_shape
|
||||
})
|
||||
.unwrap_or_else(|| (Dcel::new(), Vec::new()));
|
||||
.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::new(time);
|
||||
kf.dcel = cloned_dcel;
|
||||
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 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 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> {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
// Lightningbeam Core Library
|
||||
// Shared data structures and types
|
||||
|
||||
pub mod beat_time;
|
||||
pub mod gpu;
|
||||
pub mod layout;
|
||||
pub mod pane;
|
||||
|
|
@ -44,4 +43,3 @@ pub mod file_io;
|
|||
pub mod export;
|
||||
pub mod clipboard;
|
||||
pub mod region_select;
|
||||
pub mod dcel;
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ use crate::clip::{ClipInstance, ImageAsset};
|
|||
use crate::document::Document;
|
||||
use crate::gpu::BlendMode;
|
||||
use crate::layer::{AnyLayer, LayerTrait, VectorLayer};
|
||||
use kurbo::Affine;
|
||||
use kurbo::{Affine, Shape};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use uuid::Uuid;
|
||||
|
|
@ -178,6 +178,7 @@ pub fn render_document_for_compositing(
|
|||
base_transform: Affine,
|
||||
image_cache: &mut ImageCache,
|
||||
video_manager: &std::sync::Arc<std::sync::Mutex<crate::video::VideoManager>>,
|
||||
skip_instance_id: Option<uuid::Uuid>,
|
||||
) -> CompositeRenderResult {
|
||||
let time = document.current_time;
|
||||
|
||||
|
|
@ -211,6 +212,7 @@ pub fn render_document_for_compositing(
|
|||
base_transform,
|
||||
image_cache,
|
||||
video_manager,
|
||||
skip_instance_id,
|
||||
);
|
||||
rendered_layers.push(rendered);
|
||||
}
|
||||
|
|
@ -235,6 +237,7 @@ pub fn render_layer_isolated(
|
|||
base_transform: Affine,
|
||||
image_cache: &mut ImageCache,
|
||||
video_manager: &std::sync::Arc<std::sync::Mutex<crate::video::VideoManager>>,
|
||||
skip_instance_id: Option<uuid::Uuid>,
|
||||
) -> RenderedLayer {
|
||||
let layer_id = layer.id();
|
||||
let opacity = layer.opacity() as f32;
|
||||
|
|
@ -256,9 +259,9 @@ pub fn render_layer_isolated(
|
|||
1.0, // Full opacity - layer opacity handled in compositing
|
||||
image_cache,
|
||||
video_manager,
|
||||
skip_instance_id,
|
||||
);
|
||||
rendered.has_content = vector_layer.dcel_at_time(time)
|
||||
.map_or(false, |dcel| !dcel.edges.iter().all(|e| e.deleted) || !dcel.faces.iter().skip(1).all(|f| f.deleted))
|
||||
rendered.has_content = !vector_layer.shapes_at_time(time).is_empty()
|
||||
|| !vector_layer.clip_instances.is_empty();
|
||||
}
|
||||
AnyLayer::Audio(_) => {
|
||||
|
|
@ -303,7 +306,9 @@ fn render_vector_layer_to_scene(
|
|||
parent_opacity: f64,
|
||||
image_cache: &mut ImageCache,
|
||||
video_manager: &std::sync::Arc<std::sync::Mutex<crate::video::VideoManager>>,
|
||||
skip_instance_id: Option<uuid::Uuid>,
|
||||
) {
|
||||
// Render using the existing function but to this isolated scene
|
||||
render_vector_layer(
|
||||
document,
|
||||
time,
|
||||
|
|
@ -313,6 +318,7 @@ fn render_vector_layer_to_scene(
|
|||
parent_opacity,
|
||||
image_cache,
|
||||
video_manager,
|
||||
skip_instance_id,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -349,7 +355,7 @@ pub fn render_document(
|
|||
image_cache: &mut ImageCache,
|
||||
video_manager: &std::sync::Arc<std::sync::Mutex<crate::video::VideoManager>>,
|
||||
) {
|
||||
render_document_with_transform(document, scene, Affine::IDENTITY, image_cache, video_manager);
|
||||
render_document_with_transform(document, scene, Affine::IDENTITY, image_cache, video_manager, None);
|
||||
}
|
||||
|
||||
/// Render a document to a Vello scene with a base transform
|
||||
|
|
@ -360,6 +366,7 @@ pub fn render_document_with_transform(
|
|||
base_transform: Affine,
|
||||
image_cache: &mut ImageCache,
|
||||
video_manager: &std::sync::Arc<std::sync::Mutex<crate::video::VideoManager>>,
|
||||
skip_instance_id: Option<uuid::Uuid>,
|
||||
) {
|
||||
// 1. Draw background
|
||||
render_background(document, scene, base_transform);
|
||||
|
|
@ -373,10 +380,10 @@ pub fn render_document_with_transform(
|
|||
for layer in document.visible_layers() {
|
||||
if any_soloed {
|
||||
if layer.soloed() {
|
||||
render_layer(document, time, layer, scene, base_transform, 1.0, image_cache, video_manager);
|
||||
render_layer(document, time, layer, scene, base_transform, 1.0, image_cache, video_manager, skip_instance_id);
|
||||
}
|
||||
} else {
|
||||
render_layer(document, time, layer, scene, base_transform, 1.0, image_cache, video_manager);
|
||||
render_layer(document, time, layer, scene, base_transform, 1.0, image_cache, video_manager, skip_instance_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -408,10 +415,11 @@ fn render_layer(
|
|||
parent_opacity: f64,
|
||||
image_cache: &mut ImageCache,
|
||||
video_manager: &std::sync::Arc<std::sync::Mutex<crate::video::VideoManager>>,
|
||||
skip_instance_id: Option<uuid::Uuid>,
|
||||
) {
|
||||
match layer {
|
||||
AnyLayer::Vector(vector_layer) => {
|
||||
render_vector_layer(document, time, vector_layer, scene, base_transform, parent_opacity, image_cache, video_manager)
|
||||
render_vector_layer(document, time, vector_layer, scene, base_transform, parent_opacity, image_cache, video_manager, skip_instance_id)
|
||||
}
|
||||
AnyLayer::Audio(_) => {
|
||||
// Audio layers don't render visually
|
||||
|
|
@ -612,7 +620,7 @@ fn render_clip_instance(
|
|||
if !layer_node.data.visible() {
|
||||
continue;
|
||||
}
|
||||
render_layer(document, clip_time, &layer_node.data, scene, instance_transform, clip_opacity, image_cache, video_manager);
|
||||
render_layer(document, clip_time, &layer_node.data, scene, instance_transform, clip_opacity, image_cache, video_manager, None);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -784,89 +792,6 @@ fn render_video_layer(
|
|||
}
|
||||
|
||||
/// Render a vector layer with all its clip instances and shape instances
|
||||
/// Render a DCEL to a Vello scene.
|
||||
///
|
||||
/// Walks faces for fills and edges for strokes.
|
||||
pub fn render_dcel(
|
||||
dcel: &crate::dcel::Dcel,
|
||||
scene: &mut Scene,
|
||||
base_transform: Affine,
|
||||
layer_opacity: f64,
|
||||
document: &Document,
|
||||
image_cache: &mut ImageCache,
|
||||
) {
|
||||
let opacity_f32 = layer_opacity as f32;
|
||||
|
||||
// 1. Render faces (fills)
|
||||
for (i, face) in dcel.faces.iter().enumerate() {
|
||||
if face.deleted || i == 0 {
|
||||
continue; // Skip unbounded face and deleted faces
|
||||
}
|
||||
if face.fill_color.is_none() && face.image_fill.is_none() {
|
||||
continue; // No fill to render
|
||||
}
|
||||
|
||||
let face_id = crate::dcel::FaceId(i as u32);
|
||||
let path = dcel.face_to_bezpath_with_holes(face_id);
|
||||
let fill_rule: Fill = face.fill_rule.into();
|
||||
|
||||
let mut filled = false;
|
||||
|
||||
// Image fill
|
||||
if let Some(image_asset_id) = face.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) {
|
||||
let image_with_alpha = (*image).clone().with_alpha(opacity_f32);
|
||||
scene.fill(fill_rule, base_transform, &image_with_alpha, None, &path);
|
||||
filled = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Color fill
|
||||
if !filled {
|
||||
if let Some(fill_color) = &face.fill_color {
|
||||
let alpha = ((fill_color.a as f32 / 255.0) * opacity_f32 * 255.0) as u8;
|
||||
let adjusted = crate::shape::ShapeColor::rgba(
|
||||
fill_color.r,
|
||||
fill_color.g,
|
||||
fill_color.b,
|
||||
alpha,
|
||||
);
|
||||
scene.fill(fill_rule, base_transform, adjusted.to_peniko(), None, &path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Render edges (strokes)
|
||||
for edge in &dcel.edges {
|
||||
if edge.deleted {
|
||||
continue;
|
||||
}
|
||||
if let (Some(stroke_color), Some(stroke_style)) = (&edge.stroke_color, &edge.stroke_style) {
|
||||
let alpha = ((stroke_color.a as f32 / 255.0) * opacity_f32 * 255.0) as u8;
|
||||
let adjusted = crate::shape::ShapeColor::rgba(
|
||||
stroke_color.r,
|
||||
stroke_color.g,
|
||||
stroke_color.b,
|
||||
alpha,
|
||||
);
|
||||
|
||||
let mut path = kurbo::BezPath::new();
|
||||
path.move_to(edge.curve.p0);
|
||||
path.curve_to(edge.curve.p1, edge.curve.p2, edge.curve.p3);
|
||||
|
||||
scene.stroke(
|
||||
&stroke_style.to_stroke(),
|
||||
base_transform,
|
||||
adjusted.to_peniko(),
|
||||
None,
|
||||
&path,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn render_vector_layer(
|
||||
document: &Document,
|
||||
time: f64,
|
||||
|
|
@ -876,6 +801,7 @@ fn render_vector_layer(
|
|||
parent_opacity: f64,
|
||||
image_cache: &mut ImageCache,
|
||||
video_manager: &std::sync::Arc<std::sync::Mutex<crate::video::VideoManager>>,
|
||||
skip_instance_id: Option<uuid::Uuid>,
|
||||
) {
|
||||
// Cascade opacity: parent_opacity × layer.opacity
|
||||
let layer_opacity = parent_opacity * layer.layer.opacity;
|
||||
|
|
@ -892,9 +818,124 @@ fn render_vector_layer(
|
|||
render_clip_instance(document, time, clip_instance, layer_opacity, scene, base_transform, &layer.layer.animation_data, image_cache, video_manager, group_end_time);
|
||||
}
|
||||
|
||||
// Render DCEL from active keyframe
|
||||
if let Some(dcel) = layer.dcel_at_time(time) {
|
||||
render_dcel(dcel, scene, base_transform, layer_opacity, document, image_cache);
|
||||
// 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;
|
||||
}
|
||||
|
||||
// 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;
|
||||
|
||||
// Get the path
|
||||
let path = shape.path();
|
||||
|
||||
// Build transform matrix (compose with base transform for camera)
|
||||
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;
|
||||
|
||||
// Build skew transforms (applied around shape center)
|
||||
let skew_transform = if skew_x != 0.0 || skew_y != 0.0 {
|
||||
let skew_x_affine = if skew_x != 0.0 {
|
||||
let tan_skew = skew_x.to_radians().tan();
|
||||
Affine::new([1.0, 0.0, tan_skew, 1.0, 0.0, 0.0])
|
||||
} else {
|
||||
Affine::IDENTITY
|
||||
};
|
||||
|
||||
let skew_y_affine = if skew_y != 0.0 {
|
||||
let tan_skew = skew_y.to_radians().tan();
|
||||
Affine::new([1.0, tan_skew, 0.0, 1.0, 0.0, 0.0])
|
||||
} else {
|
||||
Affine::IDENTITY
|
||||
};
|
||||
|
||||
Affine::translate((center_x, center_y))
|
||||
* skew_x_affine
|
||||
* skew_y_affine
|
||||
* Affine::translate((-center_x, -center_y))
|
||||
} else {
|
||||
Affine::IDENTITY
|
||||
};
|
||||
|
||||
let object_transform = Affine::translate((x, y))
|
||||
* Affine::rotate(rotation.to_radians())
|
||||
* Affine::scale_non_uniform(scale_x, scale_y)
|
||||
* skew_transform;
|
||||
let affine = base_transform * object_transform;
|
||||
|
||||
// Calculate final opacity (cascaded from parent → layer → shape)
|
||||
let final_opacity = (layer_opacity * opacity) as f32;
|
||||
|
||||
// Determine fill rule
|
||||
let fill_rule = match shape.fill_rule {
|
||||
crate::shape::FillRule::NonZero => Fill::NonZero,
|
||||
crate::shape::FillRule::EvenOdd => Fill::EvenOdd,
|
||||
};
|
||||
|
||||
// Render fill - prefer image fill over color fill
|
||||
let mut filled = false;
|
||||
|
||||
// Check for image fill first
|
||||
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) {
|
||||
let image_with_alpha = (*image).clone().with_alpha(final_opacity);
|
||||
scene.fill(fill_rule, affine, &image_with_alpha, None, &path);
|
||||
filled = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to color fill if no image fill (or image failed to load)
|
||||
if !filled {
|
||||
if let Some(fill_color) = &shape.fill_color {
|
||||
let alpha = ((fill_color.a as f32 / 255.0) * final_opacity * 255.0) as u8;
|
||||
let adjusted_color = crate::shape::ShapeColor::rgba(
|
||||
fill_color.r,
|
||||
fill_color.g,
|
||||
fill_color.b,
|
||||
alpha,
|
||||
);
|
||||
|
||||
scene.fill(
|
||||
fill_rule,
|
||||
affine,
|
||||
adjusted_color.to_peniko(),
|
||||
None,
|
||||
&path,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Render stroke if present
|
||||
if let (Some(stroke_color), Some(stroke_style)) = (&shape.stroke_color, &shape.stroke_style)
|
||||
{
|
||||
let alpha = ((stroke_color.a as f32 / 255.0) * final_opacity * 255.0) as u8;
|
||||
let adjusted_color = crate::shape::ShapeColor::rgba(
|
||||
stroke_color.r,
|
||||
stroke_color.g,
|
||||
stroke_color.b,
|
||||
alpha,
|
||||
);
|
||||
|
||||
scene.stroke(
|
||||
&stroke_style.to_stroke(),
|
||||
affine,
|
||||
adjusted_color.to_peniko(),
|
||||
None,
|
||||
&path,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,28 +1,24 @@
|
|||
//! Selection state management
|
||||
//!
|
||||
//! Tracks selected DCEL elements (edges, faces, vertices) and clip instances for editing operations.
|
||||
//! Tracks selected shape instances, clip instances, and shapes for editing operations.
|
||||
|
||||
use crate::dcel::{Dcel, EdgeId, FaceId, VertexId};
|
||||
use crate::shape::Shape;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashSet;
|
||||
use uuid::Uuid;
|
||||
use vello::kurbo::BezPath;
|
||||
|
||||
/// Selection state for the editor
|
||||
///
|
||||
/// Maintains sets of selected DCEL elements and clip instances.
|
||||
/// The vertex/edge/face sets implicitly represent a subgraph of the DCEL —
|
||||
/// connectivity is determined by shared vertices between edges.
|
||||
/// Maintains sets of selected shape instances, clip instances, and shapes.
|
||||
/// This is separate from the document to make it easy to
|
||||
/// pass around for UI rendering without needing mutable access.
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
||||
pub struct Selection {
|
||||
/// Currently selected vertices
|
||||
selected_vertices: HashSet<VertexId>,
|
||||
/// Currently selected shape instances
|
||||
selected_shape_instances: Vec<Uuid>,
|
||||
|
||||
/// Currently selected edges
|
||||
selected_edges: HashSet<EdgeId>,
|
||||
|
||||
/// Currently selected faces
|
||||
selected_faces: HashSet<FaceId>,
|
||||
/// Currently selected shapes (definitions)
|
||||
selected_shapes: Vec<Uuid>,
|
||||
|
||||
/// Currently selected clip instances
|
||||
selected_clip_instances: Vec<Uuid>,
|
||||
|
|
@ -32,168 +28,54 @@ impl Selection {
|
|||
/// Create a new empty selection
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
selected_vertices: HashSet::new(),
|
||||
selected_edges: HashSet::new(),
|
||||
selected_faces: HashSet::new(),
|
||||
selected_shape_instances: Vec::new(),
|
||||
selected_shapes: Vec::new(),
|
||||
selected_clip_instances: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// DCEL element selection
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// Select an edge and its endpoint vertices, forming/extending a subgraph.
|
||||
pub fn select_edge(&mut self, edge_id: EdgeId, dcel: &Dcel) {
|
||||
if edge_id.is_none() || dcel.edge(edge_id).deleted {
|
||||
return;
|
||||
}
|
||||
self.selected_edges.insert(edge_id);
|
||||
|
||||
// Add both endpoint vertices
|
||||
let [he_fwd, he_bwd] = dcel.edge(edge_id).half_edges;
|
||||
if !he_fwd.is_none() {
|
||||
let v = dcel.half_edge(he_fwd).origin;
|
||||
if !v.is_none() {
|
||||
self.selected_vertices.insert(v);
|
||||
}
|
||||
}
|
||||
if !he_bwd.is_none() {
|
||||
let v = dcel.half_edge(he_bwd).origin;
|
||||
if !v.is_none() {
|
||||
self.selected_vertices.insert(v);
|
||||
}
|
||||
/// Add a shape instance to the selection
|
||||
pub fn add_shape_instance(&mut self, id: Uuid) {
|
||||
if !self.selected_shape_instances.contains(&id) {
|
||||
self.selected_shape_instances.push(id);
|
||||
}
|
||||
}
|
||||
|
||||
/// Select a face and all its boundary edges + vertices.
|
||||
pub fn select_face(&mut self, face_id: FaceId, dcel: &Dcel) {
|
||||
if face_id.is_none() || face_id.0 == 0 || dcel.face(face_id).deleted {
|
||||
return;
|
||||
}
|
||||
self.selected_faces.insert(face_id);
|
||||
|
||||
// Add all boundary edges and vertices
|
||||
let boundary = dcel.face_boundary(face_id);
|
||||
for he_id in boundary {
|
||||
let he = dcel.half_edge(he_id);
|
||||
let edge_id = he.edge;
|
||||
if !edge_id.is_none() {
|
||||
self.selected_edges.insert(edge_id);
|
||||
// Add endpoints
|
||||
let [he_fwd, he_bwd] = dcel.edge(edge_id).half_edges;
|
||||
if !he_fwd.is_none() {
|
||||
let v = dcel.half_edge(he_fwd).origin;
|
||||
if !v.is_none() {
|
||||
self.selected_vertices.insert(v);
|
||||
}
|
||||
}
|
||||
if !he_bwd.is_none() {
|
||||
let v = dcel.half_edge(he_bwd).origin;
|
||||
if !v.is_none() {
|
||||
self.selected_vertices.insert(v);
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Add a shape definition to the selection
|
||||
pub fn add_shape(&mut self, id: Uuid) {
|
||||
if !self.selected_shapes.contains(&id) {
|
||||
self.selected_shapes.push(id);
|
||||
}
|
||||
}
|
||||
|
||||
/// Deselect an edge and its vertices (if they have no other selected edges).
|
||||
pub fn deselect_edge(&mut self, edge_id: EdgeId, dcel: &Dcel) {
|
||||
self.selected_edges.remove(&edge_id);
|
||||
|
||||
// Remove endpoint vertices only if they're not used by other selected edges
|
||||
let [he_fwd, he_bwd] = dcel.edge(edge_id).half_edges;
|
||||
for he_id in [he_fwd, he_bwd] {
|
||||
if he_id.is_none() {
|
||||
continue;
|
||||
}
|
||||
let v = dcel.half_edge(he_id).origin;
|
||||
if v.is_none() {
|
||||
continue;
|
||||
}
|
||||
// Check if any other selected edge uses this vertex
|
||||
let used = self.selected_edges.iter().any(|&eid| {
|
||||
let e = dcel.edge(eid);
|
||||
let [a, b] = e.half_edges;
|
||||
(!a.is_none() && dcel.half_edge(a).origin == v)
|
||||
|| (!b.is_none() && dcel.half_edge(b).origin == v)
|
||||
});
|
||||
if !used {
|
||||
self.selected_vertices.remove(&v);
|
||||
}
|
||||
}
|
||||
/// Remove a shape instance from the selection
|
||||
pub fn remove_shape_instance(&mut self, id: &Uuid) {
|
||||
self.selected_shape_instances.retain(|&x| x != *id);
|
||||
}
|
||||
|
||||
/// Deselect a face (edges/vertices stay if still referenced by other selections).
|
||||
pub fn deselect_face(&mut self, face_id: FaceId) {
|
||||
self.selected_faces.remove(&face_id);
|
||||
/// Remove a shape definition from the selection
|
||||
pub fn remove_shape(&mut self, id: &Uuid) {
|
||||
self.selected_shapes.retain(|&x| x != *id);
|
||||
}
|
||||
|
||||
/// Toggle an edge's selection state.
|
||||
pub fn toggle_edge(&mut self, edge_id: EdgeId, dcel: &Dcel) {
|
||||
if self.selected_edges.contains(&edge_id) {
|
||||
self.deselect_edge(edge_id, dcel);
|
||||
/// Toggle a shape instance's selection state
|
||||
pub fn toggle_shape_instance(&mut self, id: Uuid) {
|
||||
if self.contains_shape_instance(&id) {
|
||||
self.remove_shape_instance(&id);
|
||||
} else {
|
||||
self.select_edge(edge_id, dcel);
|
||||
self.add_shape_instance(id);
|
||||
}
|
||||
}
|
||||
|
||||
/// Toggle a face's selection state.
|
||||
pub fn toggle_face(&mut self, face_id: FaceId, dcel: &Dcel) {
|
||||
if self.selected_faces.contains(&face_id) {
|
||||
self.deselect_face(face_id);
|
||||
/// Toggle a shape's selection state
|
||||
pub fn toggle_shape(&mut self, id: Uuid) {
|
||||
if self.contains_shape(&id) {
|
||||
self.remove_shape(&id);
|
||||
} else {
|
||||
self.select_face(face_id, dcel);
|
||||
self.add_shape(id);
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if an edge is selected.
|
||||
pub fn contains_edge(&self, edge_id: &EdgeId) -> bool {
|
||||
self.selected_edges.contains(edge_id)
|
||||
}
|
||||
|
||||
/// Check if a face is selected.
|
||||
pub fn contains_face(&self, face_id: &FaceId) -> bool {
|
||||
self.selected_faces.contains(face_id)
|
||||
}
|
||||
|
||||
/// Check if a vertex is selected.
|
||||
pub fn contains_vertex(&self, vertex_id: &VertexId) -> bool {
|
||||
self.selected_vertices.contains(vertex_id)
|
||||
}
|
||||
|
||||
/// Clear DCEL element selections (edges, faces, vertices).
|
||||
pub fn clear_dcel_selection(&mut self) {
|
||||
self.selected_vertices.clear();
|
||||
self.selected_edges.clear();
|
||||
self.selected_faces.clear();
|
||||
}
|
||||
|
||||
/// Check if any DCEL elements are selected.
|
||||
pub fn has_dcel_selection(&self) -> bool {
|
||||
!self.selected_edges.is_empty() || !self.selected_faces.is_empty()
|
||||
}
|
||||
|
||||
/// Get selected edges.
|
||||
pub fn selected_edges(&self) -> &HashSet<EdgeId> {
|
||||
&self.selected_edges
|
||||
}
|
||||
|
||||
/// Get selected faces.
|
||||
pub fn selected_faces(&self) -> &HashSet<FaceId> {
|
||||
&self.selected_faces
|
||||
}
|
||||
|
||||
/// Get selected vertices.
|
||||
pub fn selected_vertices(&self) -> &HashSet<VertexId> {
|
||||
&self.selected_vertices
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Clip instance selection (unchanged)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// Add a clip instance to the selection
|
||||
pub fn add_clip_instance(&mut self, id: Uuid) {
|
||||
if !self.selected_clip_instances.contains(&id) {
|
||||
|
|
@ -215,9 +97,21 @@ impl Selection {
|
|||
}
|
||||
}
|
||||
|
||||
/// Check if a clip instance is selected
|
||||
pub fn contains_clip_instance(&self, id: &Uuid) -> bool {
|
||||
self.selected_clip_instances.contains(id)
|
||||
/// Clear all selections
|
||||
pub fn clear(&mut self) {
|
||||
self.selected_shape_instances.clear();
|
||||
self.selected_shapes.clear();
|
||||
self.selected_clip_instances.clear();
|
||||
}
|
||||
|
||||
/// Clear only object selections
|
||||
pub fn clear_shape_instances(&mut self) {
|
||||
self.selected_shape_instances.clear();
|
||||
}
|
||||
|
||||
/// Clear only shape selections
|
||||
pub fn clear_shapes(&mut self) {
|
||||
self.selected_shapes.clear();
|
||||
}
|
||||
|
||||
/// Clear only clip instance selections
|
||||
|
|
@ -225,6 +119,48 @@ impl Selection {
|
|||
self.selected_clip_instances.clear();
|
||||
}
|
||||
|
||||
/// Check if an object is selected
|
||||
pub fn contains_shape_instance(&self, id: &Uuid) -> bool {
|
||||
self.selected_shape_instances.contains(id)
|
||||
}
|
||||
|
||||
/// Check if a shape is selected
|
||||
pub fn contains_shape(&self, id: &Uuid) -> bool {
|
||||
self.selected_shapes.contains(id)
|
||||
}
|
||||
|
||||
/// Check if a clip instance is selected
|
||||
pub fn contains_clip_instance(&self, id: &Uuid) -> bool {
|
||||
self.selected_clip_instances.contains(id)
|
||||
}
|
||||
|
||||
/// Check if selection is empty
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.selected_shape_instances.is_empty()
|
||||
&& self.selected_shapes.is_empty()
|
||||
&& self.selected_clip_instances.is_empty()
|
||||
}
|
||||
|
||||
/// Get the selected objects
|
||||
pub fn shape_instances(&self) -> &[Uuid] {
|
||||
&self.selected_shape_instances
|
||||
}
|
||||
|
||||
/// Get the selected shapes
|
||||
pub fn shapes(&self) -> &[Uuid] {
|
||||
&self.selected_shapes
|
||||
}
|
||||
|
||||
/// Get the number of selected objects
|
||||
pub fn shape_instance_count(&self) -> usize {
|
||||
self.selected_shape_instances.len()
|
||||
}
|
||||
|
||||
/// Get the number of selected shapes
|
||||
pub fn shape_count(&self) -> usize {
|
||||
self.selected_shapes.len()
|
||||
}
|
||||
|
||||
/// Get the selected clip instances
|
||||
pub fn clip_instances(&self) -> &[Uuid] {
|
||||
&self.selected_clip_instances
|
||||
|
|
@ -235,61 +171,86 @@ impl Selection {
|
|||
self.selected_clip_instances.len()
|
||||
}
|
||||
|
||||
/// Set selection to a single object (clears previous selection)
|
||||
pub fn select_only_shape_instance(&mut self, id: Uuid) {
|
||||
self.clear();
|
||||
self.add_shape_instance(id);
|
||||
}
|
||||
|
||||
/// Set selection to a single shape (clears previous selection)
|
||||
pub fn select_only_shape(&mut self, id: Uuid) {
|
||||
self.clear();
|
||||
self.add_shape(id);
|
||||
}
|
||||
|
||||
/// Set selection to a single clip instance (clears previous selection)
|
||||
pub fn select_only_clip_instance(&mut self, id: Uuid) {
|
||||
self.clear();
|
||||
self.add_clip_instance(id);
|
||||
}
|
||||
|
||||
/// Set selection to multiple clip instances (clears previous clip selection)
|
||||
/// Set selection to multiple objects (clears previous selection)
|
||||
pub fn select_shape_instances(&mut self, ids: &[Uuid]) {
|
||||
self.clear_shape_instances();
|
||||
for &id in ids {
|
||||
self.add_shape_instance(id);
|
||||
}
|
||||
}
|
||||
|
||||
/// Set selection to multiple shapes (clears previous selection)
|
||||
pub fn select_shapes(&mut self, ids: &[Uuid]) {
|
||||
self.clear_shapes();
|
||||
for &id in ids {
|
||||
self.add_shape(id);
|
||||
}
|
||||
}
|
||||
|
||||
/// Set selection to multiple clip instances (clears previous selection)
|
||||
pub fn select_clip_instances(&mut self, ids: &[Uuid]) {
|
||||
self.clear_clip_instances();
|
||||
for &id in ids {
|
||||
self.add_clip_instance(id);
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// General
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// Clear all selections
|
||||
pub fn clear(&mut self) {
|
||||
self.selected_vertices.clear();
|
||||
self.selected_edges.clear();
|
||||
self.selected_faces.clear();
|
||||
self.selected_clip_instances.clear();
|
||||
}
|
||||
|
||||
/// Check if selection is empty
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.selected_edges.is_empty()
|
||||
&& self.selected_faces.is_empty()
|
||||
&& self.selected_clip_instances.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// Represents a temporary region-based selection.
|
||||
/// Represents a temporary region-based split of shapes.
|
||||
///
|
||||
/// When a region select is active, elements that cross the region boundary
|
||||
/// are tracked. If the user performs an operation, the selection is
|
||||
/// committed; if they deselect, the original state is restored.
|
||||
/// When a region select is active, shapes that cross the region boundary
|
||||
/// are temporarily split into "inside" and "outside" parts. The inside
|
||||
/// parts are selected. If the user performs an operation, the split is
|
||||
/// committed; if they deselect, the original shapes are restored.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct RegionSelection {
|
||||
/// The clipping region as a closed BezPath (polygon or rect)
|
||||
pub region_path: BezPath,
|
||||
/// Layer containing the affected elements
|
||||
/// Layer containing the affected shapes
|
||||
pub layer_id: Uuid,
|
||||
/// Keyframe time
|
||||
pub time: f64,
|
||||
/// Per-shape split results (legacy, kept for compatibility)
|
||||
pub splits: Vec<()>,
|
||||
/// IDs that were fully inside the region
|
||||
/// Per-shape split results
|
||||
pub splits: Vec<ShapeSplit>,
|
||||
/// Shape IDs that were fully inside the region (not split, just selected)
|
||||
pub fully_inside_ids: Vec<Uuid>,
|
||||
/// Whether the selection has been committed (via an operation on the selection)
|
||||
/// Whether the split has been committed (via an operation on the selection)
|
||||
pub committed: bool,
|
||||
}
|
||||
|
||||
/// One shape's split result from a region selection
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ShapeSplit {
|
||||
/// The original shape (stored for reverting)
|
||||
pub original_shape: Shape,
|
||||
/// UUID for the "inside" portion shape
|
||||
pub inside_shape_id: Uuid,
|
||||
/// The clipped path inside the region
|
||||
pub inside_path: BezPath,
|
||||
/// UUID for the "outside" portion shape
|
||||
pub outside_shape_id: Uuid,
|
||||
/// The clipped path outside the region
|
||||
pub outside_path: BezPath,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
|
@ -298,7 +259,67 @@ mod tests {
|
|||
fn test_selection_creation() {
|
||||
let selection = Selection::new();
|
||||
assert!(selection.is_empty());
|
||||
assert_eq!(selection.clip_instance_count(), 0);
|
||||
assert_eq!(selection.shape_instance_count(), 0);
|
||||
assert_eq!(selection.shape_count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_add_remove_objects() {
|
||||
let mut selection = Selection::new();
|
||||
let id1 = Uuid::new_v4();
|
||||
let id2 = Uuid::new_v4();
|
||||
|
||||
selection.add_shape_instance(id1);
|
||||
assert_eq!(selection.shape_instance_count(), 1);
|
||||
assert!(selection.contains_shape_instance(&id1));
|
||||
|
||||
selection.add_shape_instance(id2);
|
||||
assert_eq!(selection.shape_instance_count(), 2);
|
||||
|
||||
selection.remove_shape_instance(&id1);
|
||||
assert_eq!(selection.shape_instance_count(), 1);
|
||||
assert!(!selection.contains_shape_instance(&id1));
|
||||
assert!(selection.contains_shape_instance(&id2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_toggle() {
|
||||
let mut selection = Selection::new();
|
||||
let id = Uuid::new_v4();
|
||||
|
||||
selection.toggle_shape_instance(id);
|
||||
assert!(selection.contains_shape_instance(&id));
|
||||
|
||||
selection.toggle_shape_instance(id);
|
||||
assert!(!selection.contains_shape_instance(&id));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_select_only() {
|
||||
let mut selection = Selection::new();
|
||||
let id1 = Uuid::new_v4();
|
||||
let id2 = Uuid::new_v4();
|
||||
|
||||
selection.add_shape_instance(id1);
|
||||
selection.add_shape_instance(id2);
|
||||
assert_eq!(selection.shape_instance_count(), 2);
|
||||
|
||||
selection.select_only_shape_instance(id1);
|
||||
assert_eq!(selection.shape_instance_count(), 1);
|
||||
assert!(selection.contains_shape_instance(&id1));
|
||||
assert!(!selection.contains_shape_instance(&id2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_clear() {
|
||||
let mut selection = Selection::new();
|
||||
selection.add_shape_instance(Uuid::new_v4());
|
||||
selection.add_shape(Uuid::new_v4());
|
||||
|
||||
assert!(!selection.is_empty());
|
||||
|
||||
selection.clear();
|
||||
assert!(selection.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -349,34 +370,54 @@ mod tests {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn test_clear() {
|
||||
fn test_clear_clip_instances() {
|
||||
let mut selection = Selection::new();
|
||||
selection.add_clip_instance(Uuid::new_v4());
|
||||
selection.add_clip_instance(Uuid::new_v4());
|
||||
selection.add_shape_instance(Uuid::new_v4());
|
||||
|
||||
assert_eq!(selection.clip_instance_count(), 2);
|
||||
assert_eq!(selection.shape_instance_count(), 1);
|
||||
|
||||
selection.clear_clip_instances();
|
||||
assert_eq!(selection.clip_instance_count(), 0);
|
||||
assert_eq!(selection.shape_instance_count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_clip_instances_getter() {
|
||||
let mut selection = Selection::new();
|
||||
let id1 = Uuid::new_v4();
|
||||
let id2 = Uuid::new_v4();
|
||||
|
||||
selection.add_clip_instance(id1);
|
||||
selection.add_clip_instance(id2);
|
||||
|
||||
let clip_instances = selection.clip_instances();
|
||||
assert_eq!(clip_instances.len(), 2);
|
||||
assert!(clip_instances.contains(&id1));
|
||||
assert!(clip_instances.contains(&id2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mixed_selection() {
|
||||
let mut selection = Selection::new();
|
||||
let shape_instance_id = Uuid::new_v4();
|
||||
let clip_instance_id = Uuid::new_v4();
|
||||
|
||||
selection.add_shape_instance(shape_instance_id);
|
||||
selection.add_clip_instance(clip_instance_id);
|
||||
|
||||
assert_eq!(selection.shape_instance_count(), 1);
|
||||
assert_eq!(selection.clip_instance_count(), 1);
|
||||
assert!(!selection.is_empty());
|
||||
|
||||
selection.clear_shape_instances();
|
||||
assert_eq!(selection.shape_instance_count(), 0);
|
||||
assert_eq!(selection.clip_instance_count(), 1);
|
||||
assert!(!selection.is_empty());
|
||||
|
||||
selection.clear();
|
||||
assert!(selection.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_dcel_selection_basics() {
|
||||
let selection = Selection::new();
|
||||
assert!(!selection.has_dcel_selection());
|
||||
assert!(selection.selected_edges().is_empty());
|
||||
assert!(selection.selected_faces().is_empty());
|
||||
assert!(selection.selected_vertices().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_clear_dcel_selection() {
|
||||
let mut selection = Selection::new();
|
||||
// Manually insert for unit test (no DCEL needed)
|
||||
selection.selected_edges.insert(EdgeId(0));
|
||||
selection.selected_vertices.insert(VertexId(0));
|
||||
assert!(selection.has_dcel_selection());
|
||||
|
||||
selection.clear_dcel_selection();
|
||||
assert!(!selection.has_dcel_selection());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -60,7 +60,7 @@ pub enum Cap {
|
|||
|
||||
impl Default for Cap {
|
||||
fn default() -> Self {
|
||||
Cap::Round
|
||||
Cap::Butt
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -122,7 +122,7 @@ impl Default for StrokeStyle {
|
|||
fn default() -> Self {
|
||||
Self {
|
||||
width: 1.0,
|
||||
cap: Cap::Round,
|
||||
cap: Cap::Butt,
|
||||
join: Join::Miter,
|
||||
miter_limit: 4.0,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -116,25 +116,22 @@ pub enum ToolState {
|
|||
num_sides: u32, // Number of sides (from properties, default 5)
|
||||
},
|
||||
|
||||
/// Editing a vertex (dragging it and connected edges)
|
||||
/// Editing a vertex (dragging it and connected curves)
|
||||
EditingVertex {
|
||||
vertex_id: crate::dcel::VertexId,
|
||||
connected_edges: Vec<crate::dcel::EdgeId>, // edges to update when vertex moves
|
||||
shape_id: Uuid, // Which shape is being edited
|
||||
vertex_index: usize, // Which vertex in the vertices array
|
||||
start_pos: Point, // Vertex position when drag started
|
||||
start_mouse: Point, // Mouse position when drag started
|
||||
affected_curve_indices: Vec<usize>, // Which curves connect to this vertex
|
||||
},
|
||||
|
||||
/// Editing a curve (reshaping with moldCurve algorithm)
|
||||
EditingCurve {
|
||||
edge_id: crate::dcel::EdgeId,
|
||||
original_curve: vello::kurbo::CubicBez,
|
||||
start_mouse: Point,
|
||||
parameter_t: f64,
|
||||
},
|
||||
|
||||
/// Pending curve interaction: click selects edge, drag starts curve editing
|
||||
PendingCurveInteraction {
|
||||
edge_id: crate::dcel::EdgeId,
|
||||
parameter_t: f64,
|
||||
start_mouse: Point,
|
||||
shape_id: Uuid, // Which shape is being edited
|
||||
curve_index: usize, // Which curve in the curves array
|
||||
original_curve: vello::kurbo::CubicBez, // The curve when drag started
|
||||
start_mouse: Point, // Mouse position when drag started
|
||||
parameter_t: f64, // Parameter where the drag started (0.0-1.0)
|
||||
},
|
||||
|
||||
/// Drawing a region selection rectangle
|
||||
|
|
@ -150,10 +147,11 @@ pub enum ToolState {
|
|||
|
||||
/// Editing a control point (BezierEdit tool only)
|
||||
EditingControlPoint {
|
||||
edge_id: crate::dcel::EdgeId,
|
||||
shape_id: Uuid, // Which shape is being edited
|
||||
curve_index: usize, // Which curve owns this control point
|
||||
point_index: u8, // 1 or 2 (p1 or p2 of the cubic bezier)
|
||||
original_curve: vello::kurbo::CubicBez,
|
||||
start_pos: Point,
|
||||
original_curve: vello::kurbo::CubicBez, // The curve when drag started
|
||||
start_pos: Point, // Control point position when drag started
|
||||
},
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ fn setup_test_document() -> (Document, Uuid, Uuid, Uuid) {
|
|||
let mut document = Document::new("Test Project");
|
||||
|
||||
// Create a vector clip
|
||||
let vector_clip = VectorClip::new("Test Clip", 1920.0, 1080.0, 10.0);
|
||||
let vector_clip = VectorClip::new("Test Clip", 10.0, 1920.0, 1080.0);
|
||||
let clip_id = vector_clip.id;
|
||||
document.vector_clips.insert(clip_id, vector_clip);
|
||||
|
||||
|
|
@ -126,7 +126,7 @@ fn test_transform_clip_instance_workflow() {
|
|||
let mut transforms = HashMap::new();
|
||||
transforms.insert(instance_id, (old_transform, new_transform));
|
||||
|
||||
let mut action = TransformClipInstancesAction::new(layer_id, 0.0, transforms);
|
||||
let mut action = TransformClipInstancesAction::new(layer_id, transforms);
|
||||
|
||||
// Execute
|
||||
action.execute(&mut document);
|
||||
|
|
@ -214,7 +214,7 @@ fn test_multiple_clip_instances_workflow() {
|
|||
let mut document = Document::new("Test Project");
|
||||
|
||||
// Create a vector clip
|
||||
let vector_clip = VectorClip::new("Test Clip", 1920.0, 1080.0, 10.0);
|
||||
let vector_clip = VectorClip::new("Test Clip", 10.0, 1920.0, 1080.0);
|
||||
let clip_id = vector_clip.id;
|
||||
document.vector_clips.insert(clip_id, vector_clip);
|
||||
|
||||
|
|
@ -294,7 +294,7 @@ fn test_clip_time_remapping() {
|
|||
let mut document = Document::new("Test Project");
|
||||
|
||||
// Create a 10 second clip
|
||||
let vector_clip = VectorClip::new("Test Clip", 1920.0, 1080.0, 10.0);
|
||||
let vector_clip = VectorClip::new("Test Clip", 10.0, 1920.0, 1080.0);
|
||||
let clip_id = vector_clip.id;
|
||||
let clip_duration = vector_clip.duration;
|
||||
document.vector_clips.insert(clip_id, vector_clip);
|
||||
|
|
|
|||
|
|
@ -80,7 +80,7 @@ fn test_render_with_transform() {
|
|||
|
||||
// Render with zoom and pan
|
||||
let transform = Affine::translate((100.0, 50.0)) * Affine::scale(2.0);
|
||||
render_document_with_transform(&document, &mut scene, transform, &mut image_cache, &video_manager);
|
||||
render_document_with_transform(&document, &mut scene, transform, &mut image_cache, &video_manager, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -189,7 +189,7 @@ fn test_selection_with_transform_action() {
|
|||
transforms.insert(id, (old_transform.clone(), new_transform.clone()));
|
||||
}
|
||||
|
||||
let mut action = TransformClipInstancesAction::new(layer_id, 0.0, transforms);
|
||||
let mut action = TransformClipInstancesAction::new(layer_id, transforms);
|
||||
action.execute(&mut document);
|
||||
|
||||
// Verify transform applied
|
||||
|
|
|
|||
|
|
@ -747,6 +747,7 @@ pub fn render_frame_to_rgba_hdr(
|
|||
base_transform,
|
||||
image_cache,
|
||||
video_manager,
|
||||
None, // No skipping during export
|
||||
);
|
||||
|
||||
// Buffer specs for layer rendering
|
||||
|
|
@ -1132,6 +1133,7 @@ pub fn render_frame_to_gpu_rgba(
|
|||
base_transform,
|
||||
image_cache,
|
||||
video_manager,
|
||||
None, // No skipping during export
|
||||
);
|
||||
|
||||
// Buffer specs for layer rendering
|
||||
|
|
|
|||
|
|
@ -1658,8 +1658,37 @@ impl EditorApp {
|
|||
};
|
||||
|
||||
self.clipboard_manager.copy(content);
|
||||
} else if self.selection.has_dcel_selection() {
|
||||
// TODO: DCEL copy/paste deferred to Phase 2 (serialize subgraph)
|
||||
} else if !self.selection.shape_instances().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,
|
||||
};
|
||||
|
||||
// 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_shapes.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let content = ClipboardContent::Shapes {
|
||||
shapes: selected_shapes,
|
||||
};
|
||||
|
||||
self.clipboard_manager.copy(content);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1707,45 +1736,26 @@ impl EditorApp {
|
|||
}
|
||||
|
||||
self.selection.clear_clip_instances();
|
||||
} else if self.selection.has_dcel_selection() {
|
||||
} else if !self.selection.shapes().is_empty() {
|
||||
let active_layer_id = match self.active_layer_id {
|
||||
Some(id) => id,
|
||||
None => return,
|
||||
};
|
||||
|
||||
// Delete selected edges via snapshot-based ModifyDcelAction
|
||||
let edge_ids: Vec<lightningbeam_core::dcel::EdgeId> =
|
||||
self.selection.selected_edges().iter().copied().collect();
|
||||
let shape_ids: Vec<Uuid> = self.selection.shapes().to_vec();
|
||||
|
||||
if !edge_ids.is_empty() {
|
||||
let document = self.action_executor.document();
|
||||
if let Some(layer) = document.get_layer(&active_layer_id) {
|
||||
if let lightningbeam_core::layer::AnyLayer::Vector(vector_layer) = layer {
|
||||
if let Some(dcel_before) = vector_layer.dcel_at_time(self.playback_time) {
|
||||
let mut dcel_after = dcel_before.clone();
|
||||
for edge_id in &edge_ids {
|
||||
if !dcel_after.edge(*edge_id).deleted {
|
||||
dcel_after.remove_edge(*edge_id);
|
||||
}
|
||||
}
|
||||
|
||||
let action = lightningbeam_core::actions::ModifyDcelAction::new(
|
||||
let action = lightningbeam_core::actions::RemoveShapesAction::new(
|
||||
active_layer_id,
|
||||
shape_ids,
|
||||
self.playback_time,
|
||||
dcel_before.clone(),
|
||||
dcel_after,
|
||||
"Delete selected edges",
|
||||
);
|
||||
|
||||
if let Err(e) = self.action_executor.execute(Box::new(action)) {
|
||||
eprintln!("Delete DCEL edges failed: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
eprintln!("Delete shapes failed: {}", e);
|
||||
}
|
||||
|
||||
self.selection.clear_dcel_selection();
|
||||
self.selection.clear_shape_instances();
|
||||
self.selection.clear_shapes();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1875,8 +1885,18 @@ impl EditorApp {
|
|||
}
|
||||
};
|
||||
|
||||
// TODO: DCEL - paste shapes not yet implemented
|
||||
let _ = (vector_layer, shapes);
|
||||
let new_shape_ids: Vec<Uuid> = shapes.iter().map(|s| s.id).collect();
|
||||
|
||||
let kf = vector_layer.ensure_keyframe_at(self.playback_time);
|
||||
for shape in shapes {
|
||||
kf.shapes.push(shape);
|
||||
}
|
||||
|
||||
// Select pasted shapes
|
||||
self.selection.clear_shapes();
|
||||
for id in new_shape_ids {
|
||||
self.selection.add_shape(id);
|
||||
}
|
||||
}
|
||||
ClipboardContent::MidiNotes { .. } => {
|
||||
// MIDI notes are pasted directly in the piano roll pane, not here
|
||||
|
|
@ -2078,9 +2098,11 @@ impl EditorApp {
|
|||
_ => return,
|
||||
};
|
||||
|
||||
// TODO: DCEL - region selection revert disabled during migration
|
||||
// (was: remove/add_shape_from/to_keyframe for splits)
|
||||
let _ = vector_layer;
|
||||
for split in ®ion_sel.splits {
|
||||
vector_layer.remove_shape_from_keyframe(&split.inside_shape_id, region_sel.time);
|
||||
vector_layer.remove_shape_from_keyframe(&split.outside_shape_id, region_sel.time);
|
||||
vector_layer.add_shape_to_keyframe(split.original_shape.clone(), region_sel.time);
|
||||
}
|
||||
|
||||
selection.clear();
|
||||
}
|
||||
|
|
@ -2407,16 +2429,14 @@ impl EditorApp {
|
|||
// Modify menu
|
||||
MenuAction::Group => {
|
||||
if let Some(layer_id) = self.active_layer_id {
|
||||
if self.selection.has_dcel_selection() {
|
||||
// TODO: DCEL group deferred to Phase 2 (extract subgraph)
|
||||
} else {
|
||||
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 clip_ids.len() >= 2 {
|
||||
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,
|
||||
Vec::new(),
|
||||
shape_ids,
|
||||
clip_ids,
|
||||
instance_id,
|
||||
);
|
||||
|
|
@ -2428,21 +2448,17 @@ impl EditorApp {
|
|||
}
|
||||
}
|
||||
}
|
||||
let _ = layer_id;
|
||||
}
|
||||
}
|
||||
MenuAction::ConvertToMovieClip => {
|
||||
if let Some(layer_id) = self.active_layer_id {
|
||||
if self.selection.has_dcel_selection() {
|
||||
// TODO: DCEL convert-to-movie-clip deferred to Phase 2
|
||||
} else {
|
||||
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 clip_ids.len() >= 1 {
|
||||
if shape_ids.len() + clip_ids.len() >= 1 {
|
||||
let instance_id = uuid::Uuid::new_v4();
|
||||
let action = lightningbeam_core::actions::ConvertToMovieClipAction::new(
|
||||
layer_id,
|
||||
self.playback_time,
|
||||
Vec::new(),
|
||||
shape_ids,
|
||||
clip_ids,
|
||||
instance_id,
|
||||
);
|
||||
|
|
@ -2455,7 +2471,6 @@ impl EditorApp {
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
MenuAction::SendToBack => {
|
||||
println!("Menu: Send to Back");
|
||||
// TODO: Implement send to back
|
||||
|
|
@ -2611,7 +2626,7 @@ impl EditorApp {
|
|||
let mut test_clip = VectorClip::new(&clip_name, 400.0, 400.0, 5.0);
|
||||
|
||||
// Create a layer with some shapes
|
||||
let layer = VectorLayer::new("Shapes");
|
||||
let mut layer = VectorLayer::new("Shapes");
|
||||
|
||||
// Create a red circle shape
|
||||
let circle_path = Circle::new((100.0, 100.0), 50.0).to_path(0.1);
|
||||
|
|
@ -2623,8 +2638,10 @@ impl EditorApp {
|
|||
let mut rect_shape = Shape::new(rect_path);
|
||||
rect_shape.fill_color = Some(ShapeColor::rgb(0, 0, 255));
|
||||
|
||||
// TODO: DCEL - test shape creation not yet implemented
|
||||
let _ = (circle_shape, rect_shape);
|
||||
// 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));
|
||||
|
|
@ -2647,11 +2664,14 @@ impl EditorApp {
|
|||
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 _shape_ids: Vec<uuid::Uuid> = Vec::new();
|
||||
let mut shape_ids = Vec::new();
|
||||
let mut clip_ids = Vec::new();
|
||||
if let Some(AnyLayer::Vector(vl)) = document.get_layer(&layer_id) {
|
||||
// TODO: DCEL - shape instance lookup disabled during migration
|
||||
// (was: get_shape_in_keyframe to check which selected objects are shapes)
|
||||
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);
|
||||
|
|
@ -2920,13 +2940,6 @@ impl EditorApp {
|
|||
return;
|
||||
}
|
||||
eprintln!("📊 [APPLY] Step 4: Set audio project took {:.2}ms", step4_start.elapsed().as_secs_f64() * 1000.0);
|
||||
|
||||
// Sync BPM/time signature to metronome
|
||||
let doc = self.action_executor.document();
|
||||
controller.set_tempo(
|
||||
doc.bpm as f32,
|
||||
(doc.time_signature.numerator, doc.time_signature.denominator),
|
||||
);
|
||||
}
|
||||
|
||||
// Reset state and restore track mappings
|
||||
|
|
@ -3535,10 +3548,34 @@ impl EditorApp {
|
|||
// Get image dimensions
|
||||
let (width, height) = asset_info.dimensions.unwrap_or((100.0, 100.0));
|
||||
|
||||
// TODO: Image fills on DCEL faces are a separate feature.
|
||||
// For now, just log a message.
|
||||
let _ = (layer_id, width, height);
|
||||
eprintln!("Image drop to canvas not yet supported with DCEL backend");
|
||||
// Get document center position
|
||||
let doc = self.action_executor.document();
|
||||
let center_x = doc.width / 2.0;
|
||||
let center_y = doc.height / 2.0;
|
||||
|
||||
// Create a rectangle path at the origin (position handled by transform)
|
||||
use kurbo::BezPath;
|
||||
let mut path = BezPath::new();
|
||||
path.move_to((0.0, 0.0));
|
||||
path.line_to((width, 0.0));
|
||||
path.line_to((width, height));
|
||||
path.line_to((0.0, height));
|
||||
path.close_path();
|
||||
|
||||
// Create shape with image fill (references the ImageAsset)
|
||||
use lightningbeam_core::shape::Shape;
|
||||
let shape = Shape::new(path).with_image_fill(asset_info.clip_id);
|
||||
|
||||
// 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,
|
||||
self.playback_time,
|
||||
);
|
||||
let _ = self.action_executor.execute(Box::new(action));
|
||||
} else {
|
||||
// For clips, create a clip instance
|
||||
let mut clip_instance = ClipInstance::new(asset_info.clip_id)
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ use eframe::egui;
|
|||
use lightningbeam_core::clip::{AudioClipType, VectorClip};
|
||||
use lightningbeam_core::document::Document;
|
||||
use lightningbeam_core::layer::AnyLayer;
|
||||
use lightningbeam_core::shape::ShapeColor;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use uuid::Uuid;
|
||||
|
||||
|
|
@ -412,7 +413,8 @@ fn generate_midi_thumbnail(
|
|||
/// Generate a 64x64 RGBA thumbnail for a vector clip
|
||||
/// Renders frame 0 of the clip using tiny-skia for software rendering
|
||||
fn generate_vector_thumbnail(clip: &VectorClip, bg_color: egui::Color32) -> Vec<u8> {
|
||||
use tiny_skia::Pixmap;
|
||||
use kurbo::PathEl;
|
||||
use tiny_skia::{Paint, PathBuilder, Pixmap, Transform as TsTransform};
|
||||
|
||||
let size = THUMBNAIL_SIZE as usize;
|
||||
let mut pixmap = Pixmap::new(THUMBNAIL_SIZE, THUMBNAIL_SIZE)
|
||||
|
|
@ -429,14 +431,94 @@ fn generate_vector_thumbnail(clip: &VectorClip, bg_color: egui::Color32) -> Vec<
|
|||
// Calculate scale to fit clip dimensions into thumbnail
|
||||
let scale_x = THUMBNAIL_SIZE as f64 / clip.width.max(1.0);
|
||||
let scale_y = THUMBNAIL_SIZE as f64 / clip.height.max(1.0);
|
||||
let _scale = scale_x.min(scale_y) * 0.9; // 90% to leave a small margin
|
||||
let scale = scale_x.min(scale_y) * 0.9; // 90% to leave a small margin
|
||||
|
||||
// Center offset
|
||||
let offset_x = (THUMBNAIL_SIZE as f64 - clip.width * scale) / 2.0;
|
||||
let offset_y = (THUMBNAIL_SIZE as f64 - clip.height * scale) / 2.0;
|
||||
|
||||
// Iterate through layers and render shapes
|
||||
for layer_node in clip.layers.iter() {
|
||||
if let AnyLayer::Vector(vector_layer) = &layer_node.data {
|
||||
// TODO: DCEL - thumbnail shape rendering disabled during migration
|
||||
// (was: shapes_at_time(0.0) to render shape fills/strokes into thumbnail)
|
||||
let _ = vector_layer;
|
||||
// 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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(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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -459,6 +541,11 @@ fn generate_vector_thumbnail(clip: &VectorClip, bg_color: egui::Color32) -> Vec<
|
|||
rgba
|
||||
}
|
||||
|
||||
/// Convert ShapeColor to tiny_skia Color
|
||||
fn shape_color_to_tiny_skia(color: &ShapeColor) -> tiny_skia::Color {
|
||||
tiny_skia::Color::from_rgba8(color.r, color.g, color.b, color.a)
|
||||
}
|
||||
|
||||
/// Generate a simple effect thumbnail with a pink gradient
|
||||
#[allow(dead_code)]
|
||||
fn generate_effect_thumbnail() -> Vec<u8> {
|
||||
|
|
|
|||
|
|
@ -6,8 +6,11 @@
|
|||
/// - Shape properties (fill/stroke for selected shapes)
|
||||
/// - Document settings (when nothing is selected)
|
||||
|
||||
use eframe::egui::{self, DragValue, Ui};
|
||||
use lightningbeam_core::actions::{SetDocumentPropertiesAction, SetShapePropertiesAction};
|
||||
use eframe::egui::{self, DragValue, Sense, Ui};
|
||||
use lightningbeam_core::actions::{
|
||||
InstancePropertyChange, SetDocumentPropertiesAction, SetInstancePropertiesAction,
|
||||
SetShapePropertiesAction,
|
||||
};
|
||||
use lightningbeam_core::layer::AnyLayer;
|
||||
use lightningbeam_core::shape::ShapeColor;
|
||||
use lightningbeam_core::tool::{SimplifyMode, Tool};
|
||||
|
|
@ -18,6 +21,8 @@ use uuid::Uuid;
|
|||
pub struct InfopanelPane {
|
||||
/// Whether the tool options section is expanded
|
||||
tool_section_open: bool,
|
||||
/// Whether the transform section is expanded
|
||||
transform_section_open: bool,
|
||||
/// Whether the shape properties section is expanded
|
||||
shape_section_open: bool,
|
||||
}
|
||||
|
|
@ -26,6 +31,7 @@ impl InfopanelPane {
|
|||
pub fn new() -> Self {
|
||||
Self {
|
||||
tool_section_open: true,
|
||||
transform_section_open: true,
|
||||
shape_section_open: true,
|
||||
}
|
||||
}
|
||||
|
|
@ -35,10 +41,24 @@ impl InfopanelPane {
|
|||
struct SelectionInfo {
|
||||
/// True if nothing is selected
|
||||
is_empty: bool,
|
||||
/// Number of selected DCEL elements (edges + faces)
|
||||
dcel_count: usize,
|
||||
/// Layer ID of selected elements (assumes single layer selection for now)
|
||||
/// Number of selected shape instances
|
||||
shape_count: usize,
|
||||
/// Layer ID of selected shapes (assumes single layer selection for now)
|
||||
layer_id: Option<Uuid>,
|
||||
/// Selected shape instance IDs
|
||||
instance_ids: Vec<Uuid>,
|
||||
/// Shape IDs referenced by selected instances
|
||||
shape_ids: Vec<Uuid>,
|
||||
|
||||
// Transform values (None = mixed values across selection)
|
||||
x: Option<f64>,
|
||||
y: Option<f64>,
|
||||
rotation: Option<f64>,
|
||||
scale_x: Option<f64>,
|
||||
scale_y: Option<f64>,
|
||||
skew_x: Option<f64>,
|
||||
skew_y: Option<f64>,
|
||||
opacity: Option<f64>,
|
||||
|
||||
// Shape property values (None = mixed)
|
||||
fill_color: Option<Option<ShapeColor>>,
|
||||
|
|
@ -50,8 +70,18 @@ impl Default for SelectionInfo {
|
|||
fn default() -> Self {
|
||||
Self {
|
||||
is_empty: true,
|
||||
dcel_count: 0,
|
||||
shape_count: 0,
|
||||
layer_id: None,
|
||||
instance_ids: Vec::new(),
|
||||
shape_ids: Vec::new(),
|
||||
x: None,
|
||||
y: None,
|
||||
rotation: None,
|
||||
scale_x: None,
|
||||
scale_y: None,
|
||||
skew_x: None,
|
||||
skew_y: None,
|
||||
opacity: None,
|
||||
fill_color: None,
|
||||
stroke_color: None,
|
||||
stroke_width: None,
|
||||
|
|
@ -64,15 +94,17 @@ impl InfopanelPane {
|
|||
fn gather_selection_info(&self, shared: &SharedPaneState) -> SelectionInfo {
|
||||
let mut info = SelectionInfo::default();
|
||||
|
||||
let edge_count = shared.selection.selected_edges().len();
|
||||
let face_count = shared.selection.selected_faces().len();
|
||||
info.dcel_count = edge_count + face_count;
|
||||
info.is_empty = info.dcel_count == 0;
|
||||
let selected_instances = shared.selection.shape_instances();
|
||||
info.shape_count = selected_instances.len();
|
||||
info.is_empty = info.shape_count == 0;
|
||||
|
||||
if info.is_empty {
|
||||
return info;
|
||||
}
|
||||
|
||||
info.instance_ids = selected_instances.to_vec();
|
||||
|
||||
// Find the layer containing the selected instances
|
||||
let document = shared.action_executor.document();
|
||||
let active_layer_id = *shared.active_layer_id;
|
||||
|
||||
|
|
@ -81,54 +113,83 @@ impl InfopanelPane {
|
|||
|
||||
if let Some(layer) = document.get_layer(&layer_id) {
|
||||
if let AnyLayer::Vector(vector_layer) = layer {
|
||||
if let Some(dcel) = vector_layer.dcel_at_time(*shared.playback_time) {
|
||||
// Gather stroke properties from selected edges
|
||||
let mut first_stroke_color: Option<Option<ShapeColor>> = None;
|
||||
let mut first_stroke_width: Option<f64> = None;
|
||||
let mut stroke_color_mixed = false;
|
||||
let mut stroke_width_mixed = false;
|
||||
// Gather values from all selected instances
|
||||
let mut first = true;
|
||||
|
||||
for &eid in shared.selection.selected_edges() {
|
||||
let edge = dcel.edge(eid);
|
||||
let sc = edge.stroke_color;
|
||||
let sw = edge.stroke_style.as_ref().map(|s| s.width);
|
||||
for instance_id in &info.instance_ids {
|
||||
if let Some(shape) = vector_layer.get_shape_in_keyframe(instance_id, *shared.playback_time) {
|
||||
info.shape_ids.push(*instance_id);
|
||||
|
||||
match first_stroke_color {
|
||||
None => first_stroke_color = Some(sc),
|
||||
Some(prev) if prev != sc => stroke_color_mixed = true,
|
||||
_ => {}
|
||||
if first {
|
||||
// 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
|
||||
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(shape.transform.x) {
|
||||
info.x = None;
|
||||
}
|
||||
match (first_stroke_width, sw) {
|
||||
(None, _) => first_stroke_width = sw,
|
||||
(Some(prev), Some(cur)) if (prev - cur).abs() > 0.01 => stroke_width_mixed = true,
|
||||
_ => {}
|
||||
if info.y != Some(shape.transform.y) {
|
||||
info.y = None;
|
||||
}
|
||||
if info.rotation != Some(shape.transform.rotation) {
|
||||
info.rotation = None;
|
||||
}
|
||||
if info.scale_x != Some(shape.transform.scale_x) {
|
||||
info.scale_x = None;
|
||||
}
|
||||
if info.scale_y != Some(shape.transform.scale_y) {
|
||||
info.scale_y = None;
|
||||
}
|
||||
if info.skew_x != Some(shape.transform.skew_x) {
|
||||
info.skew_x = None;
|
||||
}
|
||||
if info.skew_y != Some(shape.transform.skew_y) {
|
||||
info.skew_y = None;
|
||||
}
|
||||
if info.opacity != Some(shape.opacity) {
|
||||
info.opacity = None;
|
||||
}
|
||||
|
||||
if !stroke_color_mixed {
|
||||
info.stroke_color = first_stroke_color;
|
||||
}
|
||||
if !stroke_width_mixed {
|
||||
info.stroke_width = first_stroke_width;
|
||||
}
|
||||
|
||||
// Gather fill properties from selected faces
|
||||
let mut first_fill_color: Option<Option<ShapeColor>> = None;
|
||||
let mut fill_color_mixed = false;
|
||||
|
||||
for &fid in shared.selection.selected_faces() {
|
||||
let face = dcel.face(fid);
|
||||
let fc = face.fill_color;
|
||||
|
||||
match first_fill_color {
|
||||
None => first_fill_color = Some(fc),
|
||||
Some(prev) if prev != fc => fill_color_mixed = true,
|
||||
_ => {}
|
||||
// Check shape properties
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
||||
if !fill_color_mixed {
|
||||
info.fill_color = first_fill_color;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -276,7 +337,207 @@ impl InfopanelPane {
|
|||
});
|
||||
}
|
||||
|
||||
// Transform section: deferred to Phase 2 (DCEL elements don't have instance transforms)
|
||||
/// Render transform properties section
|
||||
fn render_transform_section(
|
||||
&mut self,
|
||||
ui: &mut Ui,
|
||||
path: &NodePath,
|
||||
shared: &mut SharedPaneState,
|
||||
info: &SelectionInfo,
|
||||
) {
|
||||
egui::CollapsingHeader::new("Transform")
|
||||
.id_salt(("transform", path))
|
||||
.default_open(self.transform_section_open)
|
||||
.show(ui, |ui| {
|
||||
self.transform_section_open = true;
|
||||
ui.add_space(4.0);
|
||||
|
||||
let layer_id = match info.layer_id {
|
||||
Some(id) => id,
|
||||
None => return,
|
||||
};
|
||||
|
||||
// Position X
|
||||
self.render_transform_field(
|
||||
ui,
|
||||
"X:",
|
||||
info.x,
|
||||
1.0,
|
||||
f64::NEG_INFINITY..=f64::INFINITY,
|
||||
|value| InstancePropertyChange::X(value),
|
||||
layer_id,
|
||||
&info.instance_ids,
|
||||
shared,
|
||||
);
|
||||
|
||||
// Position Y
|
||||
self.render_transform_field(
|
||||
ui,
|
||||
"Y:",
|
||||
info.y,
|
||||
1.0,
|
||||
f64::NEG_INFINITY..=f64::INFINITY,
|
||||
|value| InstancePropertyChange::Y(value),
|
||||
layer_id,
|
||||
&info.instance_ids,
|
||||
shared,
|
||||
);
|
||||
|
||||
ui.add_space(4.0);
|
||||
|
||||
// Rotation
|
||||
self.render_transform_field(
|
||||
ui,
|
||||
"Rotation:",
|
||||
info.rotation,
|
||||
1.0,
|
||||
-360.0..=360.0,
|
||||
|value| InstancePropertyChange::Rotation(value),
|
||||
layer_id,
|
||||
&info.instance_ids,
|
||||
shared,
|
||||
);
|
||||
|
||||
ui.add_space(4.0);
|
||||
|
||||
// Scale X
|
||||
self.render_transform_field(
|
||||
ui,
|
||||
"Scale X:",
|
||||
info.scale_x,
|
||||
0.01,
|
||||
0.01..=100.0,
|
||||
|value| InstancePropertyChange::ScaleX(value),
|
||||
layer_id,
|
||||
&info.instance_ids,
|
||||
shared,
|
||||
);
|
||||
|
||||
// Scale Y
|
||||
self.render_transform_field(
|
||||
ui,
|
||||
"Scale Y:",
|
||||
info.scale_y,
|
||||
0.01,
|
||||
0.01..=100.0,
|
||||
|value| InstancePropertyChange::ScaleY(value),
|
||||
layer_id,
|
||||
&info.instance_ids,
|
||||
shared,
|
||||
);
|
||||
|
||||
ui.add_space(4.0);
|
||||
|
||||
// Skew X
|
||||
self.render_transform_field(
|
||||
ui,
|
||||
"Skew X:",
|
||||
info.skew_x,
|
||||
1.0,
|
||||
-89.0..=89.0,
|
||||
|value| InstancePropertyChange::SkewX(value),
|
||||
layer_id,
|
||||
&info.instance_ids,
|
||||
shared,
|
||||
);
|
||||
|
||||
// Skew Y
|
||||
self.render_transform_field(
|
||||
ui,
|
||||
"Skew Y:",
|
||||
info.skew_y,
|
||||
1.0,
|
||||
-89.0..=89.0,
|
||||
|value| InstancePropertyChange::SkewY(value),
|
||||
layer_id,
|
||||
&info.instance_ids,
|
||||
shared,
|
||||
);
|
||||
|
||||
ui.add_space(4.0);
|
||||
|
||||
// Opacity
|
||||
self.render_transform_field(
|
||||
ui,
|
||||
"Opacity:",
|
||||
info.opacity,
|
||||
0.01,
|
||||
0.0..=1.0,
|
||||
|value| InstancePropertyChange::Opacity(value),
|
||||
layer_id,
|
||||
&info.instance_ids,
|
||||
shared,
|
||||
);
|
||||
|
||||
ui.add_space(4.0);
|
||||
});
|
||||
}
|
||||
|
||||
/// Render a single transform property field with drag-to-adjust
|
||||
fn render_transform_field<F>(
|
||||
&self,
|
||||
ui: &mut Ui,
|
||||
label: &str,
|
||||
value: Option<f64>,
|
||||
speed: f64,
|
||||
range: std::ops::RangeInclusive<f64>,
|
||||
make_change: F,
|
||||
layer_id: Uuid,
|
||||
instance_ids: &[Uuid],
|
||||
shared: &mut SharedPaneState,
|
||||
) where
|
||||
F: Fn(f64) -> InstancePropertyChange,
|
||||
{
|
||||
ui.horizontal(|ui| {
|
||||
// Label with drag sense for drag-to-adjust
|
||||
let label_response = ui.add(egui::Label::new(label).sense(Sense::drag()));
|
||||
|
||||
match value {
|
||||
Some(mut v) => {
|
||||
// Handle drag on label
|
||||
if label_response.dragged() {
|
||||
let delta = label_response.drag_delta().x as f64 * speed;
|
||||
v = (v + delta).clamp(*range.start(), *range.end());
|
||||
|
||||
// Create action for each selected instance
|
||||
for instance_id in instance_ids {
|
||||
let action = SetInstancePropertiesAction::new(
|
||||
layer_id,
|
||||
*shared.playback_time,
|
||||
*instance_id,
|
||||
make_change(v),
|
||||
);
|
||||
shared.pending_actions.push(Box::new(action));
|
||||
}
|
||||
}
|
||||
|
||||
// DragValue widget
|
||||
let response = ui.add(
|
||||
DragValue::new(&mut v)
|
||||
.speed(speed)
|
||||
.range(range.clone()),
|
||||
);
|
||||
|
||||
if response.changed() {
|
||||
// Create action for each selected instance
|
||||
for instance_id in instance_ids {
|
||||
let action = SetInstancePropertiesAction::new(
|
||||
layer_id,
|
||||
*shared.playback_time,
|
||||
*instance_id,
|
||||
make_change(v),
|
||||
);
|
||||
shared.pending_actions.push(Box::new(action));
|
||||
}
|
||||
}
|
||||
}
|
||||
None => {
|
||||
// Mixed values - show placeholder
|
||||
ui.label("--");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Render shape properties section (fill/stroke)
|
||||
fn render_shape_section(
|
||||
|
|
@ -286,15 +547,6 @@ impl InfopanelPane {
|
|||
shared: &mut SharedPaneState,
|
||||
info: &SelectionInfo,
|
||||
) {
|
||||
// Clone IDs and values we need before borrowing shared mutably
|
||||
let layer_id = match info.layer_id {
|
||||
Some(id) => id,
|
||||
None => return,
|
||||
};
|
||||
let time = *shared.playback_time;
|
||||
let face_ids: Vec<_> = shared.selection.selected_faces().iter().copied().collect();
|
||||
let edge_ids: Vec<_> = shared.selection.selected_edges().iter().copied().collect();
|
||||
|
||||
egui::CollapsingHeader::new("Shape")
|
||||
.id_salt(("shape", path))
|
||||
.default_open(self.shape_section_open)
|
||||
|
|
@ -302,6 +554,11 @@ impl InfopanelPane {
|
|||
self.shape_section_open = true;
|
||||
ui.add_space(4.0);
|
||||
|
||||
let layer_id = match info.layer_id {
|
||||
Some(id) => id,
|
||||
None => return,
|
||||
};
|
||||
|
||||
// Fill color
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Fill:");
|
||||
|
|
@ -310,25 +567,41 @@ impl InfopanelPane {
|
|||
let mut egui_color = egui::Color32::from_rgba_unmultiplied(
|
||||
color.r, color.g, color.b, color.a,
|
||||
);
|
||||
if egui::color_picker::color_edit_button_srgba(
|
||||
ui,
|
||||
&mut egui_color,
|
||||
egui::color_picker::Alpha::OnlyBlend,
|
||||
).changed() {
|
||||
let new_color = ShapeColor {
|
||||
r: egui_color.r(),
|
||||
g: egui_color.g(),
|
||||
b: egui_color.b(),
|
||||
a: egui_color.a(),
|
||||
};
|
||||
|
||||
if ui.color_edit_button_srgba(&mut egui_color).changed() {
|
||||
let new_color = Some(ShapeColor::new(
|
||||
egui_color.r(),
|
||||
egui_color.g(),
|
||||
egui_color.b(),
|
||||
egui_color.a(),
|
||||
));
|
||||
|
||||
// Create action for each selected shape
|
||||
for shape_id in &info.shape_ids {
|
||||
let action = SetShapePropertiesAction::set_fill_color(
|
||||
layer_id, time, face_ids.clone(), Some(new_color),
|
||||
layer_id,
|
||||
*shape_id,
|
||||
*shared.playback_time,
|
||||
new_color,
|
||||
);
|
||||
shared.pending_actions.push(Box::new(action));
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(None) => {
|
||||
ui.label("None");
|
||||
if ui.button("Add Fill").clicked() {
|
||||
// Add default black fill
|
||||
let default_fill = Some(ShapeColor::rgb(0, 0, 0));
|
||||
for shape_id in &info.shape_ids {
|
||||
let action = SetShapePropertiesAction::set_fill_color(
|
||||
layer_id,
|
||||
*shape_id,
|
||||
*shared.playback_time,
|
||||
default_fill,
|
||||
);
|
||||
shared.pending_actions.push(Box::new(action));
|
||||
}
|
||||
}
|
||||
}
|
||||
None => {
|
||||
ui.label("--");
|
||||
|
|
@ -344,25 +617,41 @@ impl InfopanelPane {
|
|||
let mut egui_color = egui::Color32::from_rgba_unmultiplied(
|
||||
color.r, color.g, color.b, color.a,
|
||||
);
|
||||
if egui::color_picker::color_edit_button_srgba(
|
||||
ui,
|
||||
&mut egui_color,
|
||||
egui::color_picker::Alpha::OnlyBlend,
|
||||
).changed() {
|
||||
let new_color = ShapeColor {
|
||||
r: egui_color.r(),
|
||||
g: egui_color.g(),
|
||||
b: egui_color.b(),
|
||||
a: egui_color.a(),
|
||||
};
|
||||
|
||||
if ui.color_edit_button_srgba(&mut egui_color).changed() {
|
||||
let new_color = Some(ShapeColor::new(
|
||||
egui_color.r(),
|
||||
egui_color.g(),
|
||||
egui_color.b(),
|
||||
egui_color.a(),
|
||||
));
|
||||
|
||||
// Create action for each selected shape
|
||||
for shape_id in &info.shape_ids {
|
||||
let action = SetShapePropertiesAction::set_stroke_color(
|
||||
layer_id, time, edge_ids.clone(), Some(new_color),
|
||||
layer_id,
|
||||
*shape_id,
|
||||
*shared.playback_time,
|
||||
new_color,
|
||||
);
|
||||
shared.pending_actions.push(Box::new(action));
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(None) => {
|
||||
ui.label("None");
|
||||
if ui.button("Add Stroke").clicked() {
|
||||
// Add default black stroke
|
||||
let default_stroke = Some(ShapeColor::rgb(0, 0, 0));
|
||||
for shape_id in &info.shape_ids {
|
||||
let action = SetShapePropertiesAction::set_stroke_color(
|
||||
layer_id,
|
||||
*shape_id,
|
||||
*shared.playback_time,
|
||||
default_stroke,
|
||||
);
|
||||
shared.pending_actions.push(Box::new(action));
|
||||
}
|
||||
}
|
||||
}
|
||||
None => {
|
||||
ui.label("--");
|
||||
|
|
@ -375,17 +664,24 @@ impl InfopanelPane {
|
|||
ui.label("Stroke Width:");
|
||||
match info.stroke_width {
|
||||
Some(mut width) => {
|
||||
if ui.add(
|
||||
let response = ui.add(
|
||||
DragValue::new(&mut width)
|
||||
.speed(0.1)
|
||||
.range(0.1..=100.0),
|
||||
).changed() {
|
||||
);
|
||||
|
||||
if response.changed() {
|
||||
for shape_id in &info.shape_ids {
|
||||
let action = SetShapePropertiesAction::set_stroke_width(
|
||||
layer_id, time, edge_ids.clone(), width,
|
||||
layer_id,
|
||||
*shape_id,
|
||||
*shared.playback_time,
|
||||
width,
|
||||
);
|
||||
shared.pending_actions.push(Box::new(action));
|
||||
}
|
||||
}
|
||||
}
|
||||
None => {
|
||||
ui.label("--");
|
||||
}
|
||||
|
|
@ -516,8 +812,13 @@ impl PaneRenderer for InfopanelPane {
|
|||
// 2. Gather selection info
|
||||
let info = self.gather_selection_info(shared);
|
||||
|
||||
// 3. Shape properties section (if DCEL elements selected)
|
||||
if info.dcel_count > 0 {
|
||||
// 3. Transform section (if shapes selected)
|
||||
if info.shape_count > 0 {
|
||||
self.render_transform_section(ui, path, shared, &info);
|
||||
}
|
||||
|
||||
// 4. Shape properties section (if shapes selected)
|
||||
if info.shape_count > 0 {
|
||||
self.render_shape_section(ui, path, shared, &info);
|
||||
}
|
||||
|
||||
|
|
@ -527,14 +828,14 @@ impl PaneRenderer for InfopanelPane {
|
|||
}
|
||||
|
||||
// Show selection count at bottom
|
||||
if info.dcel_count > 0 {
|
||||
if info.shape_count > 0 {
|
||||
ui.add_space(8.0);
|
||||
ui.separator();
|
||||
ui.add_space(4.0);
|
||||
ui.label(format!(
|
||||
"{} object{} selected",
|
||||
info.dcel_count,
|
||||
if info.dcel_count == 1 { "" } else { "s" }
|
||||
info.shape_count,
|
||||
if info.shape_count == 1 { "" } else { "s" }
|
||||
));
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -205,23 +205,16 @@ impl PianoRollPane {
|
|||
|
||||
// ── Ruler interval calculation ───────────────────────────────────────
|
||||
|
||||
fn ruler_interval(&self, bpm: f64, time_sig: &lightningbeam_core::document::TimeSignature) -> f64 {
|
||||
fn ruler_interval(&self) -> f64 {
|
||||
let min_pixel_gap = 80.0;
|
||||
let min_seconds = (min_pixel_gap / self.pixels_per_second) as f64;
|
||||
|
||||
// Use beat-aligned intervals
|
||||
let beat_dur = lightningbeam_core::beat_time::beat_duration(bpm);
|
||||
let measure_dur = lightningbeam_core::beat_time::measure_duration(bpm, time_sig);
|
||||
let beat_intervals = [
|
||||
beat_dur / 4.0, beat_dur / 2.0, beat_dur, beat_dur * 2.0,
|
||||
measure_dur, measure_dur * 2.0, measure_dur * 4.0,
|
||||
];
|
||||
for &interval in &beat_intervals {
|
||||
if interval >= min_seconds {
|
||||
let min_seconds = min_pixel_gap / self.pixels_per_second;
|
||||
let intervals = [0.01, 0.02, 0.05, 0.1, 0.2, 0.5, 1.0, 2.0, 5.0, 10.0, 30.0, 60.0];
|
||||
for &interval in &intervals {
|
||||
if interval >= min_seconds as f64 {
|
||||
return interval;
|
||||
}
|
||||
}
|
||||
measure_dur * 4.0
|
||||
60.0
|
||||
}
|
||||
|
||||
// ── MIDI mode rendering ──────────────────────────────────────────────
|
||||
|
|
@ -294,11 +287,7 @@ impl PianoRollPane {
|
|||
|
||||
// Render grid (clipped to grid area)
|
||||
let grid_painter = ui.painter_at(grid_rect);
|
||||
let (grid_bpm, grid_time_sig) = {
|
||||
let doc = shared.action_executor.document();
|
||||
(doc.bpm, doc.time_signature.clone())
|
||||
};
|
||||
self.render_grid(&grid_painter, grid_rect, grid_bpm, &grid_time_sig);
|
||||
self.render_grid(&grid_painter, grid_rect);
|
||||
|
||||
// Render clip boundaries and notes
|
||||
for &(midi_clip_id, timeline_start, trim_start, duration, _instance_id) in &clip_data {
|
||||
|
|
@ -430,8 +419,7 @@ impl PianoRollPane {
|
|||
);
|
||||
}
|
||||
|
||||
fn render_grid(&self, painter: &egui::Painter, grid_rect: Rect,
|
||||
bpm: f64, time_sig: &lightningbeam_core::document::TimeSignature) {
|
||||
fn render_grid(&self, painter: &egui::Painter, grid_rect: Rect) {
|
||||
// Horizontal lines (note separators)
|
||||
for note in MIN_NOTE..=MAX_NOTE {
|
||||
let y = self.note_to_y(note, grid_rect);
|
||||
|
|
@ -457,11 +445,8 @@ impl PianoRollPane {
|
|||
);
|
||||
}
|
||||
|
||||
// Vertical lines (beat-aligned time grid)
|
||||
let interval = self.ruler_interval(bpm, time_sig);
|
||||
let beat_dur = lightningbeam_core::beat_time::beat_duration(bpm);
|
||||
let measure_dur = lightningbeam_core::beat_time::measure_duration(bpm, time_sig);
|
||||
|
||||
// Vertical lines (time grid)
|
||||
let interval = self.ruler_interval();
|
||||
let start = (self.viewport_start_time / interval).floor() as i64;
|
||||
let end_time = self.viewport_start_time + (grid_rect.width() / self.pixels_per_second) as f64;
|
||||
let end = (end_time / interval).ceil() as i64;
|
||||
|
|
@ -473,36 +458,27 @@ impl PianoRollPane {
|
|||
continue;
|
||||
}
|
||||
|
||||
// Determine tick importance: measure boundary > beat > subdivision
|
||||
let is_measure = (time / measure_dur).fract().abs() < 1e-9 || (time / measure_dur).fract() > 1.0 - 1e-9;
|
||||
let is_beat = (time / beat_dur).fract().abs() < 1e-9 || (time / beat_dur).fract() > 1.0 - 1e-9;
|
||||
let alpha = if is_measure { 60 } else if is_beat { 35 } else { 20 };
|
||||
|
||||
let is_major = (i % 4 == 0) || interval >= 1.0;
|
||||
let alpha = if is_major { 50 } else { 20 };
|
||||
painter.line_segment(
|
||||
[pos2(x, grid_rect.min.y), pos2(x, grid_rect.max.y)],
|
||||
Stroke::new(1.0, Color32::from_white_alpha(alpha)),
|
||||
);
|
||||
|
||||
// Labels at measure boundaries
|
||||
if is_measure && x > grid_rect.min.x + 20.0 {
|
||||
let pos = lightningbeam_core::beat_time::time_to_measure(time, bpm, time_sig);
|
||||
// Time labels at major lines
|
||||
if is_major && x > grid_rect.min.x + 20.0 {
|
||||
let label = if time >= 60.0 {
|
||||
format!("{}:{:05.2}", (time / 60.0) as u32, time % 60.0)
|
||||
} else {
|
||||
format!("{:.2}s", time)
|
||||
};
|
||||
painter.text(
|
||||
pos2(x + 2.0, grid_rect.min.y + 2.0),
|
||||
Align2::LEFT_TOP,
|
||||
format!("{}", pos.measure),
|
||||
label,
|
||||
FontId::proportional(9.0),
|
||||
Color32::from_white_alpha(80),
|
||||
);
|
||||
} else if is_beat && !is_measure && x > grid_rect.min.x + 20.0
|
||||
&& beat_dur as f32 * self.pixels_per_second > 40.0 {
|
||||
let pos = lightningbeam_core::beat_time::time_to_measure(time, bpm, time_sig);
|
||||
painter.text(
|
||||
pos2(x + 2.0, grid_rect.min.y + 2.0),
|
||||
Align2::LEFT_TOP,
|
||||
format!("{}.{}", pos.measure, pos.beat),
|
||||
FontId::proportional(9.0),
|
||||
Color32::from_white_alpha(50),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -602,10 +578,9 @@ impl PianoRollPane {
|
|||
);
|
||||
}
|
||||
|
||||
fn render_dot_grid(&self, painter: &egui::Painter, grid_rect: Rect,
|
||||
bpm: f64, time_sig: &lightningbeam_core::document::TimeSignature) {
|
||||
fn render_dot_grid(&self, painter: &egui::Painter, grid_rect: Rect) {
|
||||
// Collect visible time grid positions
|
||||
let interval = self.ruler_interval(bpm, time_sig);
|
||||
let interval = self.ruler_interval();
|
||||
let start = (self.viewport_start_time / interval).floor() as i64;
|
||||
let end_time = self.viewport_start_time + (grid_rect.width() / self.pixels_per_second) as f64;
|
||||
let end = (end_time / interval).ceil() as i64;
|
||||
|
|
@ -1439,13 +1414,7 @@ impl PianoRollPane {
|
|||
|
||||
// Dot grid background (visible where the spectrogram doesn't draw)
|
||||
let grid_painter = ui.painter_at(view_rect);
|
||||
{
|
||||
let (dot_bpm, dot_ts) = {
|
||||
let doc = shared.action_executor.document();
|
||||
(doc.bpm, doc.time_signature.clone())
|
||||
};
|
||||
self.render_dot_grid(&grid_painter, view_rect, dot_bpm, &dot_ts);
|
||||
}
|
||||
self.render_dot_grid(&grid_painter, view_rect);
|
||||
|
||||
// Find audio pool index for the active layer's clips
|
||||
let layer_id = match *shared.active_layer_id {
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -130,13 +130,6 @@ enum ClipDragType {
|
|||
LoopExtendLeft,
|
||||
}
|
||||
|
||||
/// How time is displayed in the ruler and header
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
enum TimeDisplayFormat {
|
||||
Seconds,
|
||||
Measures,
|
||||
}
|
||||
|
||||
pub struct TimelinePane {
|
||||
/// Horizontal zoom level (pixels per second)
|
||||
pixels_per_second: f32,
|
||||
|
|
@ -170,9 +163,6 @@ pub struct TimelinePane {
|
|||
/// Context menu state: Some((optional_clip_instance_id, position)) when a right-click menu is open
|
||||
/// clip_id is None when right-clicking on empty timeline space
|
||||
context_menu_clip: Option<(Option<uuid::Uuid>, egui::Pos2)>,
|
||||
|
||||
/// Whether to display time as seconds or measures
|
||||
time_display_format: TimeDisplayFormat,
|
||||
}
|
||||
|
||||
/// Check if a clip type can be dropped on a layer type
|
||||
|
|
@ -241,7 +231,6 @@ impl TimelinePane {
|
|||
mousedown_pos: None,
|
||||
layer_control_clicked: false,
|
||||
context_menu_clip: None,
|
||||
time_display_format: TimeDisplayFormat::Seconds,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -559,107 +548,74 @@ impl TimelinePane {
|
|||
}
|
||||
|
||||
/// Render the time ruler at the top
|
||||
fn render_ruler(&self, ui: &mut egui::Ui, rect: egui::Rect, theme: &crate::theme::Theme,
|
||||
bpm: f64, time_sig: &lightningbeam_core::document::TimeSignature) {
|
||||
fn render_ruler(&self, ui: &mut egui::Ui, rect: egui::Rect, theme: &crate::theme::Theme) {
|
||||
let painter = ui.painter();
|
||||
|
||||
// Background
|
||||
let bg_style = theme.style(".timeline-background", ui.ctx());
|
||||
let bg_color = bg_style.background_color.unwrap_or(egui::Color32::from_rgb(34, 34, 34));
|
||||
painter.rect_filled(rect, 0.0, bg_color);
|
||||
painter.rect_filled(
|
||||
rect,
|
||||
0.0,
|
||||
bg_color,
|
||||
);
|
||||
|
||||
// Get text color from theme
|
||||
let text_style = theme.style(".text-primary", ui.ctx());
|
||||
let text_color = text_style.text_color.unwrap_or(egui::Color32::from_gray(200));
|
||||
|
||||
match self.time_display_format {
|
||||
TimeDisplayFormat::Seconds => {
|
||||
// Calculate interval for tick marks
|
||||
let interval = self.calculate_ruler_interval();
|
||||
|
||||
// Draw tick marks and labels
|
||||
let start_time = (self.viewport_start_time / interval).floor() * interval;
|
||||
let end_time = self.x_to_time(rect.width());
|
||||
|
||||
let mut time = start_time;
|
||||
while time <= end_time {
|
||||
let x = self.time_to_x(time);
|
||||
|
||||
if x >= 0.0 && x <= rect.width() {
|
||||
// Major tick mark
|
||||
painter.line_segment(
|
||||
[rect.min + egui::vec2(x, rect.height() - 10.0),
|
||||
rect.min + egui::vec2(x, rect.height())],
|
||||
[
|
||||
rect.min + egui::vec2(x, rect.height() - 10.0),
|
||||
rect.min + egui::vec2(x, rect.height()),
|
||||
],
|
||||
egui::Stroke::new(1.0, egui::Color32::from_gray(100)),
|
||||
);
|
||||
|
||||
// Time label
|
||||
let label = format!("{:.1}s", time);
|
||||
painter.text(
|
||||
rect.min + egui::vec2(x + 2.0, 5.0), egui::Align2::LEFT_TOP,
|
||||
format!("{:.1}s", time), egui::FontId::proportional(12.0), text_color,
|
||||
rect.min + egui::vec2(x + 2.0, 5.0),
|
||||
egui::Align2::LEFT_TOP,
|
||||
label,
|
||||
egui::FontId::proportional(12.0),
|
||||
text_color,
|
||||
);
|
||||
}
|
||||
|
||||
// Minor tick marks (subdivisions)
|
||||
let minor_interval = interval / 5.0;
|
||||
for i in 1..5 {
|
||||
let minor_x = self.time_to_x(time + minor_interval * i as f64);
|
||||
let minor_time = time + minor_interval * i as f64;
|
||||
let minor_x = self.time_to_x(minor_time);
|
||||
|
||||
if minor_x >= 0.0 && minor_x <= rect.width() {
|
||||
painter.line_segment(
|
||||
[rect.min + egui::vec2(minor_x, rect.height() - 5.0),
|
||||
rect.min + egui::vec2(minor_x, rect.height())],
|
||||
[
|
||||
rect.min + egui::vec2(minor_x, rect.height() - 5.0),
|
||||
rect.min + egui::vec2(minor_x, rect.height()),
|
||||
],
|
||||
egui::Stroke::new(1.0, egui::Color32::from_gray(60)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
time += interval;
|
||||
}
|
||||
}
|
||||
TimeDisplayFormat::Measures => {
|
||||
let beats_per_second = bpm / 60.0;
|
||||
let beat_dur = lightningbeam_core::beat_time::beat_duration(bpm);
|
||||
let bpm_count = time_sig.numerator;
|
||||
let px_per_beat = beat_dur as f32 * self.pixels_per_second;
|
||||
|
||||
let start_beat = (self.viewport_start_time.max(0.0) * beats_per_second).floor() as i64;
|
||||
let end_beat = (self.x_to_time(rect.width()) * beats_per_second).ceil() as i64;
|
||||
|
||||
// Adaptive: how often to label measures
|
||||
let measure_px = px_per_beat * bpm_count as f32;
|
||||
let label_every = if measure_px > 60.0 { 1u32 } else if measure_px > 20.0 { 4 } else { 16 };
|
||||
|
||||
for beat_idx in start_beat..=end_beat {
|
||||
if beat_idx < 0 { continue; }
|
||||
let x = self.time_to_x(beat_idx as f64 / beats_per_second);
|
||||
if x < 0.0 || x > rect.width() { continue; }
|
||||
|
||||
let beat_in_measure = (beat_idx as u32) % bpm_count;
|
||||
let measure = (beat_idx as u32) / bpm_count + 1;
|
||||
let is_measure_boundary = beat_in_measure == 0;
|
||||
|
||||
// Tick height, stroke width, and brightness based on beat importance
|
||||
let (tick_h, stroke_w, gray) = if is_measure_boundary {
|
||||
(12.0, 2.0, 140u8)
|
||||
} else if beat_in_measure % 2 == 0 {
|
||||
(8.0, 1.0, 80)
|
||||
} else {
|
||||
(5.0, 1.0, 50)
|
||||
};
|
||||
|
||||
painter.line_segment(
|
||||
[rect.min + egui::vec2(x, rect.height() - tick_h),
|
||||
rect.min + egui::vec2(x, rect.height())],
|
||||
egui::Stroke::new(stroke_w, egui::Color32::from_gray(gray)),
|
||||
);
|
||||
|
||||
// Labels: measure numbers at boundaries, beat numbers when zoomed in
|
||||
if is_measure_boundary && (label_every == 1 || measure % label_every == 1) {
|
||||
painter.text(
|
||||
rect.min + egui::vec2(x + 3.0, 3.0), egui::Align2::LEFT_TOP,
|
||||
format!("{}", measure), egui::FontId::proportional(12.0), text_color,
|
||||
);
|
||||
} else if !is_measure_boundary && px_per_beat > 40.0 {
|
||||
let alpha = if beat_in_measure % 2 == 0 { 0.5 } else if px_per_beat > 80.0 { 0.25 } else { continue };
|
||||
painter.text(
|
||||
rect.min + egui::vec2(x + 2.0, 5.0), egui::Align2::LEFT_TOP,
|
||||
format!("{}.{}", measure, beat_in_measure + 1),
|
||||
egui::FontId::proportional(10.0), text_color.gamma_multiply(alpha),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Render the playhead (current time indicator)
|
||||
fn render_playhead(&self, ui: &mut egui::Ui, rect: egui::Rect, theme: &crate::theme::Theme, playback_time: f64) {
|
||||
|
|
@ -1148,43 +1104,26 @@ impl TimelinePane {
|
|||
painter.rect_filled(layer_rect, 0.0, bg_color);
|
||||
|
||||
// Grid lines matching ruler
|
||||
match self.time_display_format {
|
||||
TimeDisplayFormat::Seconds => {
|
||||
let interval = self.calculate_ruler_interval();
|
||||
let start_time = (self.viewport_start_time / interval).floor() * interval;
|
||||
let end_time = self.x_to_time(rect.width());
|
||||
|
||||
let mut time = start_time;
|
||||
while time <= end_time {
|
||||
let x = self.time_to_x(time);
|
||||
|
||||
if x >= 0.0 && x <= rect.width() {
|
||||
painter.line_segment(
|
||||
[egui::pos2(rect.min.x + x, y),
|
||||
egui::pos2(rect.min.x + x, y + LAYER_HEIGHT)],
|
||||
[
|
||||
egui::pos2(rect.min.x + x, y),
|
||||
egui::pos2(rect.min.x + x, y + LAYER_HEIGHT),
|
||||
],
|
||||
egui::Stroke::new(1.0, egui::Color32::from_gray(30)),
|
||||
);
|
||||
}
|
||||
|
||||
time += interval;
|
||||
}
|
||||
}
|
||||
TimeDisplayFormat::Measures => {
|
||||
let beats_per_second = document.bpm / 60.0;
|
||||
let bpm_count = document.time_signature.numerator;
|
||||
let start_beat = (self.viewport_start_time.max(0.0) * beats_per_second).floor() as i64;
|
||||
let end_beat = (self.x_to_time(rect.width()) * beats_per_second).ceil() as i64;
|
||||
for beat_idx in start_beat..=end_beat {
|
||||
if beat_idx < 0 { continue; }
|
||||
let x = self.time_to_x(beat_idx as f64 / beats_per_second);
|
||||
if x < 0.0 || x > rect.width() { continue; }
|
||||
let is_measure_boundary = (beat_idx as u32) % bpm_count == 0;
|
||||
let gray = if is_measure_boundary { 45 } else { 25 };
|
||||
painter.line_segment(
|
||||
[egui::pos2(rect.min.x + x, y),
|
||||
egui::pos2(rect.min.x + x, y + LAYER_HEIGHT)],
|
||||
egui::Stroke::new(if is_measure_boundary { 1.5 } else { 1.0 }, egui::Color32::from_gray(gray)),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Draw clip instances for this layer
|
||||
let clip_instances = match layer {
|
||||
|
|
@ -2708,96 +2647,14 @@ impl PaneRenderer for TimelinePane {
|
|||
let text_style = shared.theme.style(".text-primary", ui.ctx());
|
||||
let text_color = text_style.text_color.unwrap_or(egui::Color32::from_gray(200));
|
||||
|
||||
// Time display (format-dependent)
|
||||
{
|
||||
let (bpm, time_sig_num, time_sig_den) = {
|
||||
let doc = shared.action_executor.document();
|
||||
(doc.bpm, doc.time_signature.numerator, doc.time_signature.denominator)
|
||||
};
|
||||
|
||||
match self.time_display_format {
|
||||
TimeDisplayFormat::Seconds => {
|
||||
// Time display
|
||||
ui.colored_label(text_color, format!("Time: {:.2}s / {:.2}s", *shared.playback_time, self.duration));
|
||||
}
|
||||
TimeDisplayFormat::Measures => {
|
||||
let time_sig = lightningbeam_core::document::TimeSignature { numerator: time_sig_num, denominator: time_sig_den };
|
||||
let pos = lightningbeam_core::beat_time::time_to_measure(
|
||||
*shared.playback_time, bpm, &time_sig,
|
||||
);
|
||||
ui.colored_label(text_color, format!(
|
||||
"BAR: {}.{} | BPM: {:.0} | {}/{}",
|
||||
pos.measure, pos.beat, bpm,
|
||||
time_sig_num, time_sig_den,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
ui.separator();
|
||||
|
||||
// Zoom display
|
||||
ui.colored_label(text_color, format!("Zoom: {:.0}px/s", self.pixels_per_second));
|
||||
|
||||
ui.separator();
|
||||
|
||||
// Time display format toggle
|
||||
egui::ComboBox::from_id_salt("time_format")
|
||||
.selected_text(match self.time_display_format {
|
||||
TimeDisplayFormat::Seconds => "Seconds",
|
||||
TimeDisplayFormat::Measures => "Measures",
|
||||
})
|
||||
.width(80.0)
|
||||
.show_ui(ui, |ui| {
|
||||
ui.selectable_value(&mut self.time_display_format, TimeDisplayFormat::Seconds, "Seconds");
|
||||
ui.selectable_value(&mut self.time_display_format, TimeDisplayFormat::Measures, "Measures");
|
||||
});
|
||||
|
||||
ui.separator();
|
||||
|
||||
// BPM control
|
||||
let mut bpm_val = bpm;
|
||||
ui.label("BPM:");
|
||||
let bpm_response = ui.add(egui::DragValue::new(&mut bpm_val)
|
||||
.range(20.0..=300.0)
|
||||
.speed(0.5)
|
||||
.fixed_decimals(1));
|
||||
if bpm_response.changed() {
|
||||
shared.action_executor.document_mut().bpm = bpm_val;
|
||||
if let Some(controller_arc) = shared.audio_controller {
|
||||
let mut controller = controller_arc.lock().unwrap();
|
||||
controller.set_tempo(bpm_val as f32, (time_sig_num, time_sig_den));
|
||||
}
|
||||
}
|
||||
|
||||
ui.separator();
|
||||
|
||||
// Time signature selector
|
||||
let time_sig_presets: [(u32, u32); 8] = [
|
||||
(2, 4), (3, 4), (4, 4), (5, 4),
|
||||
(6, 8), (7, 8), (9, 8), (12, 8),
|
||||
];
|
||||
let current_ts_label = format!("{}/{}", time_sig_num, time_sig_den);
|
||||
egui::ComboBox::from_id_salt("time_sig")
|
||||
.selected_text(¤t_ts_label)
|
||||
.width(60.0)
|
||||
.show_ui(ui, |ui| {
|
||||
for (num, den) in &time_sig_presets {
|
||||
let label = format!("{}/{}", num, den);
|
||||
if ui.selectable_label(
|
||||
time_sig_num == *num && time_sig_den == *den,
|
||||
&label,
|
||||
).clicked() {
|
||||
let doc = shared.action_executor.document_mut();
|
||||
doc.time_signature.numerator = *num;
|
||||
doc.time_signature.denominator = *den;
|
||||
if let Some(controller_arc) = shared.audio_controller {
|
||||
let mut controller = controller_arc.lock().unwrap();
|
||||
controller.set_tempo(doc.bpm as f32, (*num, *den));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
|
|
@ -2893,7 +2750,7 @@ impl PaneRenderer for TimelinePane {
|
|||
|
||||
// Render time ruler (clip to ruler rect)
|
||||
ui.set_clip_rect(ruler_rect.intersect(original_clip_rect));
|
||||
self.render_ruler(ui, ruler_rect, shared.theme, document.bpm, &document.time_signature);
|
||||
self.render_ruler(ui, ruler_rect, shared.theme);
|
||||
|
||||
// Render layer rows with clipping
|
||||
ui.set_clip_rect(content_rect.intersect(original_clip_rect));
|
||||
|
|
|
|||
Loading…
Reference in New Issue