Select and move shapes
This commit is contained in:
parent
afda2d9d4f
commit
67724c944c
|
|
@ -2762,6 +2762,7 @@ dependencies = [
|
||||||
"resvg 0.42.0",
|
"resvg 0.42.0",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"uuid",
|
||||||
"vello",
|
"vello",
|
||||||
"wgpu",
|
"wgpu",
|
||||||
"winit",
|
"winit",
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,277 @@
|
||||||
|
//! Action system for undo/redo functionality
|
||||||
|
//!
|
||||||
|
//! This module provides a type-safe action system that ensures document
|
||||||
|
//! mutations can only happen through actions, enforced by Rust's type system.
|
||||||
|
//!
|
||||||
|
//! ## Architecture
|
||||||
|
//!
|
||||||
|
//! - `Action` trait: Defines execute() and rollback() operations
|
||||||
|
//! - `ActionExecutor`: Wraps the document and manages undo/redo stacks
|
||||||
|
//! - Document mutations are only accessible via `pub(crate)` methods
|
||||||
|
//! - External code gets read-only access via `ActionExecutor::document()`
|
||||||
|
|
||||||
|
use crate::document::Document;
|
||||||
|
|
||||||
|
/// Action trait for undo/redo operations
|
||||||
|
///
|
||||||
|
/// Each action must be able to execute (apply changes) and rollback (undo changes).
|
||||||
|
/// Actions are stored in the undo stack and can be re-executed from the redo stack.
|
||||||
|
pub trait Action: Send {
|
||||||
|
/// Apply this action to the document
|
||||||
|
fn execute(&mut self, document: &mut Document);
|
||||||
|
|
||||||
|
/// Undo this action (rollback changes)
|
||||||
|
fn rollback(&mut self, document: &mut Document);
|
||||||
|
|
||||||
|
/// Get a human-readable description of this action (for UI display)
|
||||||
|
fn description(&self) -> String;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Action executor that wraps the document and manages undo/redo
|
||||||
|
///
|
||||||
|
/// This is the only way to get mutable access to the document, ensuring
|
||||||
|
/// all mutations go through the action system.
|
||||||
|
pub struct ActionExecutor {
|
||||||
|
/// The document being edited
|
||||||
|
document: Document,
|
||||||
|
|
||||||
|
/// Stack of executed actions (for undo)
|
||||||
|
undo_stack: Vec<Box<dyn Action>>,
|
||||||
|
|
||||||
|
/// Stack of undone actions (for redo)
|
||||||
|
redo_stack: Vec<Box<dyn Action>>,
|
||||||
|
|
||||||
|
/// Maximum number of actions to keep in undo stack
|
||||||
|
max_undo_depth: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ActionExecutor {
|
||||||
|
/// Create a new action executor with the given document
|
||||||
|
pub fn new(document: Document) -> Self {
|
||||||
|
Self {
|
||||||
|
document,
|
||||||
|
undo_stack: Vec::new(),
|
||||||
|
redo_stack: Vec::new(),
|
||||||
|
max_undo_depth: 100, // Default: keep last 100 actions
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get read-only access to the document
|
||||||
|
///
|
||||||
|
/// This is the public API for reading document state.
|
||||||
|
/// Mutations must go through execute() which requires an Action.
|
||||||
|
pub fn document(&self) -> &Document {
|
||||||
|
&self.document
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Execute an action and add it to the undo stack
|
||||||
|
///
|
||||||
|
/// This clears the redo stack since we're creating a new timeline branch.
|
||||||
|
pub fn execute(&mut self, mut action: Box<dyn Action>) {
|
||||||
|
// Apply the action
|
||||||
|
action.execute(&mut self.document);
|
||||||
|
|
||||||
|
// Clear redo stack (new action invalidates redo history)
|
||||||
|
self.redo_stack.clear();
|
||||||
|
|
||||||
|
// Add to undo stack
|
||||||
|
self.undo_stack.push(action);
|
||||||
|
|
||||||
|
// Limit undo stack size
|
||||||
|
if self.undo_stack.len() > self.max_undo_depth {
|
||||||
|
self.undo_stack.remove(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Undo the last action
|
||||||
|
///
|
||||||
|
/// Returns true if an action was undone, false if undo stack is empty.
|
||||||
|
pub fn undo(&mut self) -> bool {
|
||||||
|
if let Some(mut action) = self.undo_stack.pop() {
|
||||||
|
// Rollback the action
|
||||||
|
action.rollback(&mut self.document);
|
||||||
|
|
||||||
|
// Move to redo stack
|
||||||
|
self.redo_stack.push(action);
|
||||||
|
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Redo the last undone action
|
||||||
|
///
|
||||||
|
/// Returns true if an action was redone, false if redo stack is empty.
|
||||||
|
pub fn redo(&mut self) -> bool {
|
||||||
|
if let Some(mut action) = self.redo_stack.pop() {
|
||||||
|
// Re-execute the action
|
||||||
|
action.execute(&mut self.document);
|
||||||
|
|
||||||
|
// Move back to undo stack
|
||||||
|
self.undo_stack.push(action);
|
||||||
|
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if undo is available
|
||||||
|
pub fn can_undo(&self) -> bool {
|
||||||
|
!self.undo_stack.is_empty()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if redo is available
|
||||||
|
pub fn can_redo(&self) -> bool {
|
||||||
|
!self.redo_stack.is_empty()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the description of the next action to undo
|
||||||
|
pub fn undo_description(&self) -> Option<String> {
|
||||||
|
self.undo_stack.last().map(|a| a.description())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the description of the next action to redo
|
||||||
|
pub fn redo_description(&self) -> Option<String> {
|
||||||
|
self.redo_stack.last().map(|a| a.description())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the number of actions in the undo stack
|
||||||
|
pub fn undo_depth(&self) -> usize {
|
||||||
|
self.undo_stack.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the number of actions in the redo stack
|
||||||
|
pub fn redo_depth(&self) -> usize {
|
||||||
|
self.redo_stack.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear all undo/redo history
|
||||||
|
pub fn clear_history(&mut self) {
|
||||||
|
self.undo_stack.clear();
|
||||||
|
self.redo_stack.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set the maximum undo depth
|
||||||
|
pub fn set_max_undo_depth(&mut self, depth: usize) {
|
||||||
|
self.max_undo_depth = depth;
|
||||||
|
|
||||||
|
// Trim undo stack if needed
|
||||||
|
if self.undo_stack.len() > depth {
|
||||||
|
let remove_count = self.undo_stack.len() - depth;
|
||||||
|
self.undo_stack.drain(0..remove_count);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
// Test action that just tracks execute/rollback calls
|
||||||
|
struct TestAction {
|
||||||
|
description: String,
|
||||||
|
executed: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TestAction {
|
||||||
|
fn new(description: &str) -> Self {
|
||||||
|
Self {
|
||||||
|
description: description.to_string(),
|
||||||
|
executed: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Action for TestAction {
|
||||||
|
fn execute(&mut self, _document: &mut Document) {
|
||||||
|
self.executed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rollback(&mut self, _document: &mut Document) {
|
||||||
|
self.executed = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn description(&self) -> String {
|
||||||
|
self.description.clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_action_executor_basic() {
|
||||||
|
let document = Document::new("Test");
|
||||||
|
let mut executor = ActionExecutor::new(document);
|
||||||
|
|
||||||
|
assert!(!executor.can_undo());
|
||||||
|
assert!(!executor.can_redo());
|
||||||
|
|
||||||
|
// Execute an action
|
||||||
|
let action = Box::new(TestAction::new("Test Action"));
|
||||||
|
executor.execute(action);
|
||||||
|
|
||||||
|
assert!(executor.can_undo());
|
||||||
|
assert!(!executor.can_redo());
|
||||||
|
assert_eq!(executor.undo_depth(), 1);
|
||||||
|
|
||||||
|
// Undo
|
||||||
|
assert!(executor.undo());
|
||||||
|
assert!(!executor.can_undo());
|
||||||
|
assert!(executor.can_redo());
|
||||||
|
assert_eq!(executor.redo_depth(), 1);
|
||||||
|
|
||||||
|
// Redo
|
||||||
|
assert!(executor.redo());
|
||||||
|
assert!(executor.can_undo());
|
||||||
|
assert!(!executor.can_redo());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_action_descriptions() {
|
||||||
|
let document = Document::new("Test");
|
||||||
|
let mut executor = ActionExecutor::new(document);
|
||||||
|
|
||||||
|
executor.execute(Box::new(TestAction::new("Action 1")));
|
||||||
|
executor.execute(Box::new(TestAction::new("Action 2")));
|
||||||
|
|
||||||
|
assert_eq!(executor.undo_description(), Some("Action 2".to_string()));
|
||||||
|
|
||||||
|
executor.undo();
|
||||||
|
assert_eq!(executor.redo_description(), Some("Action 2".to_string()));
|
||||||
|
assert_eq!(executor.undo_description(), Some("Action 1".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_new_action_clears_redo() {
|
||||||
|
let document = Document::new("Test");
|
||||||
|
let mut executor = ActionExecutor::new(document);
|
||||||
|
|
||||||
|
executor.execute(Box::new(TestAction::new("Action 1")));
|
||||||
|
executor.execute(Box::new(TestAction::new("Action 2")));
|
||||||
|
executor.undo();
|
||||||
|
|
||||||
|
assert!(executor.can_redo());
|
||||||
|
|
||||||
|
// Execute new action should clear redo stack
|
||||||
|
executor.execute(Box::new(TestAction::new("Action 3")));
|
||||||
|
|
||||||
|
assert!(!executor.can_redo());
|
||||||
|
assert_eq!(executor.undo_depth(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_max_undo_depth() {
|
||||||
|
let document = Document::new("Test");
|
||||||
|
let mut executor = ActionExecutor::new(document);
|
||||||
|
executor.set_max_undo_depth(3);
|
||||||
|
|
||||||
|
executor.execute(Box::new(TestAction::new("Action 1")));
|
||||||
|
executor.execute(Box::new(TestAction::new("Action 2")));
|
||||||
|
executor.execute(Box::new(TestAction::new("Action 3")));
|
||||||
|
executor.execute(Box::new(TestAction::new("Action 4")));
|
||||||
|
|
||||||
|
// Should only keep last 3
|
||||||
|
assert_eq!(executor.undo_depth(), 3);
|
||||||
|
assert_eq!(executor.undo_description(), Some("Action 4".to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
//! Action implementations for document editing
|
||||||
|
//!
|
||||||
|
//! This module contains all the concrete action types that can be executed
|
||||||
|
//! through the action system.
|
||||||
|
|
||||||
|
pub mod move_objects;
|
||||||
|
|
||||||
|
pub use move_objects::MoveObjectsAction;
|
||||||
|
|
@ -0,0 +1,131 @@
|
||||||
|
//! Move objects action
|
||||||
|
//!
|
||||||
|
//! Handles moving one or more objects to new positions.
|
||||||
|
|
||||||
|
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 objects to new positions
|
||||||
|
pub struct MoveObjectsAction {
|
||||||
|
/// Layer ID containing the objects
|
||||||
|
layer_id: Uuid,
|
||||||
|
|
||||||
|
/// Map of object IDs to their old and new positions
|
||||||
|
object_positions: HashMap<Uuid, (Point, Point)>, // (old_pos, new_pos)
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MoveObjectsAction {
|
||||||
|
/// Create a new move objects action
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `layer_id` - The layer containing the objects
|
||||||
|
/// * `object_positions` - Map of object IDs to (old_position, new_position)
|
||||||
|
pub fn new(layer_id: Uuid, object_positions: HashMap<Uuid, (Point, Point)>) -> Self {
|
||||||
|
Self {
|
||||||
|
layer_id,
|
||||||
|
object_positions,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Action for MoveObjectsAction {
|
||||||
|
fn execute(&mut self, document: &mut Document) {
|
||||||
|
let layer = match document.get_layer_mut(&self.layer_id) {
|
||||||
|
Some(l) => l,
|
||||||
|
None => return,
|
||||||
|
};
|
||||||
|
|
||||||
|
if let AnyLayer::Vector(vector_layer) = layer {
|
||||||
|
for (object_id, (_old, new)) in &self.object_positions {
|
||||||
|
vector_layer.modify_object_internal(object_id, |obj| {
|
||||||
|
obj.transform.x = new.x;
|
||||||
|
obj.transform.y = new.y;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rollback(&mut self, document: &mut Document) {
|
||||||
|
let layer = match document.get_layer_mut(&self.layer_id) {
|
||||||
|
Some(l) => l,
|
||||||
|
None => return,
|
||||||
|
};
|
||||||
|
|
||||||
|
if let AnyLayer::Vector(vector_layer) = layer {
|
||||||
|
for (object_id, (old, _new)) in &self.object_positions {
|
||||||
|
vector_layer.modify_object_internal(object_id, |obj| {
|
||||||
|
obj.transform.x = old.x;
|
||||||
|
obj.transform.y = old.y;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn description(&self) -> String {
|
||||||
|
let count = self.object_positions.len();
|
||||||
|
if count == 1 {
|
||||||
|
"Move object".to_string()
|
||||||
|
} else {
|
||||||
|
format!("Move {} objects", count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::layer::VectorLayer;
|
||||||
|
use crate::object::Object;
|
||||||
|
use crate::shape::Shape;
|
||||||
|
use vello::kurbo::{Circle, Shape as KurboShape};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_move_objects_action() {
|
||||||
|
// Create a document with a test object
|
||||||
|
let mut document = Document::new("Test");
|
||||||
|
|
||||||
|
let circle = Circle::new((100.0, 100.0), 50.0);
|
||||||
|
let path = circle.to_path(0.1);
|
||||||
|
let shape = Shape::new(path);
|
||||||
|
let object = Object::new(shape.id).with_position(50.0, 50.0);
|
||||||
|
|
||||||
|
let mut vector_layer = VectorLayer::new("Layer 1");
|
||||||
|
vector_layer.add_shape(shape);
|
||||||
|
let object_id = vector_layer.add_object(object);
|
||||||
|
let layer_id = document.root.add_child(AnyLayer::Vector(vector_layer));
|
||||||
|
|
||||||
|
// Create move action
|
||||||
|
let mut positions = HashMap::new();
|
||||||
|
positions.insert(
|
||||||
|
object_id,
|
||||||
|
(Point::new(50.0, 50.0), Point::new(150.0, 200.0))
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut action = MoveObjectsAction::new(layer_id, positions);
|
||||||
|
|
||||||
|
// Execute
|
||||||
|
action.execute(&mut document);
|
||||||
|
|
||||||
|
// Verify position changed
|
||||||
|
if let Some(AnyLayer::Vector(layer)) = document.get_layer(&layer_id) {
|
||||||
|
let obj = layer.get_object(&object_id).unwrap();
|
||||||
|
assert_eq!(obj.transform.x, 150.0);
|
||||||
|
assert_eq!(obj.transform.y, 200.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rollback
|
||||||
|
action.rollback(&mut document);
|
||||||
|
|
||||||
|
// Verify position restored
|
||||||
|
if let Some(AnyLayer::Vector(layer)) = document.get_layer(&layer_id) {
|
||||||
|
let obj = layer.get_object(&object_id).unwrap();
|
||||||
|
assert_eq!(obj.transform.x, 50.0);
|
||||||
|
assert_eq!(obj.transform.y, 50.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -169,6 +169,29 @@ impl Document {
|
||||||
layer.visible && layer.contains_time(self.current_time)
|
layer.visible && layer.contains_time(self.current_time)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Get a layer by ID
|
||||||
|
pub fn get_layer(&self, id: &Uuid) -> Option<&AnyLayer> {
|
||||||
|
self.root.get_child(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// === MUTATION METHODS (pub(crate) - only accessible to action module) ===
|
||||||
|
|
||||||
|
/// Get mutable access to the root graphics object
|
||||||
|
///
|
||||||
|
/// This method is intentionally `pub(crate)` to ensure mutations
|
||||||
|
/// only happen through the action system.
|
||||||
|
pub(crate) fn root_mut(&mut self) -> &mut GraphicsObject {
|
||||||
|
&mut self.root
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get mutable access to a layer by ID
|
||||||
|
///
|
||||||
|
/// This method is intentionally `pub(crate)` to ensure mutations
|
||||||
|
/// only happen through the action system.
|
||||||
|
pub(crate) fn get_layer_mut(&mut self, id: &Uuid) -> Option<&mut AnyLayer> {
|
||||||
|
self.root.get_child_mut(id)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,255 @@
|
||||||
|
//! Hit testing for selection and interaction
|
||||||
|
//!
|
||||||
|
//! Provides functions for testing if points or rectangles intersect with
|
||||||
|
//! shapes and objects, taking into account transform hierarchies.
|
||||||
|
|
||||||
|
use crate::layer::VectorLayer;
|
||||||
|
use crate::object::Object;
|
||||||
|
use crate::shape::Shape;
|
||||||
|
use uuid::Uuid;
|
||||||
|
use vello::kurbo::{Affine, Point, Rect, Shape as KurboShape};
|
||||||
|
|
||||||
|
/// Hit test a layer at a specific point
|
||||||
|
///
|
||||||
|
/// Tests objects in reverse order (front to back) and returns the first hit.
|
||||||
|
/// Combines parent_transform with object transforms for hierarchical testing.
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `layer` - The vector layer to test
|
||||||
|
/// * `point` - The point to test in screen/canvas space
|
||||||
|
/// * `tolerance` - Additional tolerance in pixels for stroke hit testing
|
||||||
|
/// * `parent_transform` - Transform from parent GraphicsObject(s)
|
||||||
|
///
|
||||||
|
/// # Returns
|
||||||
|
///
|
||||||
|
/// The UUID of the first object hit, or None if no hit
|
||||||
|
pub fn hit_test_layer(
|
||||||
|
layer: &VectorLayer,
|
||||||
|
point: Point,
|
||||||
|
tolerance: f64,
|
||||||
|
parent_transform: Affine,
|
||||||
|
) -> Option<Uuid> {
|
||||||
|
// Test objects in reverse order (back to front in Vec = front to back for hit testing)
|
||||||
|
for object in layer.objects.iter().rev() {
|
||||||
|
// Get the shape for this object
|
||||||
|
let shape = layer.get_shape(&object.shape_id)?;
|
||||||
|
|
||||||
|
// Combine parent transform with object transform
|
||||||
|
let combined_transform = parent_transform * object.to_affine();
|
||||||
|
|
||||||
|
if hit_test_shape(shape, point, tolerance, combined_transform) {
|
||||||
|
return Some(object.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hit test a single shape with a given transform
|
||||||
|
///
|
||||||
|
/// Tests if a point hits the shape, considering both fill and stroke.
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `shape` - The shape to test
|
||||||
|
/// * `point` - The point to test in screen/canvas space
|
||||||
|
/// * `tolerance` - Additional tolerance in pixels for stroke hit testing
|
||||||
|
/// * `transform` - The combined transform to apply to the shape
|
||||||
|
///
|
||||||
|
/// # Returns
|
||||||
|
///
|
||||||
|
/// true if the point hits the shape, false otherwise
|
||||||
|
pub fn hit_test_shape(
|
||||||
|
shape: &Shape,
|
||||||
|
point: Point,
|
||||||
|
tolerance: f64,
|
||||||
|
transform: Affine,
|
||||||
|
) -> bool {
|
||||||
|
// Transform point to shape's local space
|
||||||
|
// We need the inverse transform to go from screen space to shape space
|
||||||
|
let inverse_transform = transform.inverse();
|
||||||
|
let local_point = inverse_transform * point;
|
||||||
|
|
||||||
|
// Check if point is inside filled path
|
||||||
|
if shape.fill_color.is_some() {
|
||||||
|
if shape.path().contains(local_point) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check stroke bounds if has stroke
|
||||||
|
if let Some(stroke_style) = &shape.stroke_style {
|
||||||
|
let stroke_tolerance = stroke_style.width / 2.0 + tolerance;
|
||||||
|
|
||||||
|
// For stroke hit testing, we need to check if the point is within
|
||||||
|
// stroke_tolerance distance of the path
|
||||||
|
// kurbo's winding() method can be used, or we can check bounding box first
|
||||||
|
|
||||||
|
// Quick bounding box check with stroke tolerance
|
||||||
|
let bbox = shape.path().bounding_box();
|
||||||
|
let expanded_bbox = bbox.inflate(stroke_tolerance, stroke_tolerance);
|
||||||
|
|
||||||
|
if !expanded_bbox.contains(local_point) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For more accurate stroke hit testing, we would need to:
|
||||||
|
// 1. Stroke the path with the stroke width
|
||||||
|
// 2. Check if the point is contained in the stroked outline
|
||||||
|
// For now, we do a simpler bounding box check
|
||||||
|
// TODO: Implement accurate stroke hit testing using kurbo's stroke functionality
|
||||||
|
|
||||||
|
// Simple approach: if within expanded bbox, consider it a hit for now
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hit test objects within a rectangle (for marquee selection)
|
||||||
|
///
|
||||||
|
/// Returns all objects whose bounding boxes intersect with the given rectangle.
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `layer` - The vector layer to test
|
||||||
|
/// * `rect` - The selection rectangle in screen/canvas space
|
||||||
|
/// * `parent_transform` - Transform from parent GraphicsObject(s)
|
||||||
|
///
|
||||||
|
/// # Returns
|
||||||
|
///
|
||||||
|
/// Vector of UUIDs for all objects that intersect the rectangle
|
||||||
|
pub fn hit_test_objects_in_rect(
|
||||||
|
layer: &VectorLayer,
|
||||||
|
rect: Rect,
|
||||||
|
parent_transform: Affine,
|
||||||
|
) -> Vec<Uuid> {
|
||||||
|
let mut hits = Vec::new();
|
||||||
|
|
||||||
|
for object in &layer.objects {
|
||||||
|
// Get the shape for this object
|
||||||
|
if let Some(shape) = layer.get_shape(&object.shape_id) {
|
||||||
|
// Combine parent transform with object transform
|
||||||
|
let combined_transform = parent_transform * object.to_affine();
|
||||||
|
|
||||||
|
// Get shape bounding box in local space
|
||||||
|
let bbox = shape.path().bounding_box();
|
||||||
|
|
||||||
|
// Transform bounding box to screen space
|
||||||
|
let transformed_bbox = combined_transform.transform_rect_bbox(bbox);
|
||||||
|
|
||||||
|
// Check if rectangles intersect
|
||||||
|
if rect.intersect(transformed_bbox).area() > 0.0 {
|
||||||
|
hits.push(object.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
hits
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the bounding box of an object in screen space
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `object` - The object to get bounds for
|
||||||
|
/// * `shape` - The shape definition
|
||||||
|
/// * `parent_transform` - Transform from parent GraphicsObject(s)
|
||||||
|
///
|
||||||
|
/// # Returns
|
||||||
|
///
|
||||||
|
/// The bounding box in screen/canvas space
|
||||||
|
pub fn get_object_bounds(
|
||||||
|
object: &Object,
|
||||||
|
shape: &Shape,
|
||||||
|
parent_transform: Affine,
|
||||||
|
) -> Rect {
|
||||||
|
let combined_transform = parent_transform * object.to_affine();
|
||||||
|
let local_bbox = shape.path().bounding_box();
|
||||||
|
combined_transform.transform_rect_bbox(local_bbox)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::shape::ShapeColor;
|
||||||
|
use vello::kurbo::{Circle, Shape as KurboShape};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_hit_test_simple_circle() {
|
||||||
|
let mut layer = VectorLayer::new("Test Layer");
|
||||||
|
|
||||||
|
// Create a circle at (100, 100) with radius 50
|
||||||
|
let circle = Circle::new((100.0, 100.0), 50.0);
|
||||||
|
let path = circle.to_path(0.1);
|
||||||
|
let shape = Shape::new(path).with_fill(ShapeColor::rgb(255, 0, 0));
|
||||||
|
let object = Object::new(shape.id);
|
||||||
|
|
||||||
|
layer.add_shape(shape);
|
||||||
|
layer.add_object(object);
|
||||||
|
|
||||||
|
// Test hit inside circle
|
||||||
|
let hit = hit_test_layer(&layer, Point::new(100.0, 100.0), 0.0, Affine::IDENTITY);
|
||||||
|
assert!(hit.is_some());
|
||||||
|
|
||||||
|
// Test miss outside circle
|
||||||
|
let miss = hit_test_layer(&layer, Point::new(200.0, 200.0), 0.0, Affine::IDENTITY);
|
||||||
|
assert!(miss.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_hit_test_with_transform() {
|
||||||
|
let mut layer = VectorLayer::new("Test Layer");
|
||||||
|
|
||||||
|
// Create a circle at origin
|
||||||
|
let circle = Circle::new((0.0, 0.0), 50.0);
|
||||||
|
let path = circle.to_path(0.1);
|
||||||
|
let shape = Shape::new(path).with_fill(ShapeColor::rgb(255, 0, 0));
|
||||||
|
|
||||||
|
// Create object with translation
|
||||||
|
let object = Object::new(shape.id).with_position(100.0, 100.0);
|
||||||
|
|
||||||
|
layer.add_shape(shape);
|
||||||
|
layer.add_object(object);
|
||||||
|
|
||||||
|
// Test hit at translated position
|
||||||
|
let hit = hit_test_layer(&layer, Point::new(100.0, 100.0), 0.0, Affine::IDENTITY);
|
||||||
|
assert!(hit.is_some());
|
||||||
|
|
||||||
|
// Test miss at origin (where shape is defined, but object is translated)
|
||||||
|
let miss = hit_test_layer(&layer, Point::new(0.0, 0.0), 0.0, Affine::IDENTITY);
|
||||||
|
assert!(miss.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_marquee_selection() {
|
||||||
|
let mut layer = VectorLayer::new("Test Layer");
|
||||||
|
|
||||||
|
// Create two circles
|
||||||
|
let circle1 = Circle::new((50.0, 50.0), 20.0);
|
||||||
|
let path1 = circle1.to_path(0.1);
|
||||||
|
let shape1 = Shape::new(path1).with_fill(ShapeColor::rgb(255, 0, 0));
|
||||||
|
let object1 = Object::new(shape1.id);
|
||||||
|
|
||||||
|
let circle2 = Circle::new((150.0, 150.0), 20.0);
|
||||||
|
let path2 = circle2.to_path(0.1);
|
||||||
|
let shape2 = Shape::new(path2).with_fill(ShapeColor::rgb(0, 255, 0));
|
||||||
|
let object2 = Object::new(shape2.id);
|
||||||
|
|
||||||
|
layer.add_shape(shape1);
|
||||||
|
layer.add_object(object1);
|
||||||
|
layer.add_shape(shape2);
|
||||||
|
layer.add_object(object2);
|
||||||
|
|
||||||
|
// Marquee that contains both circles
|
||||||
|
let rect = Rect::new(0.0, 0.0, 200.0, 200.0);
|
||||||
|
let hits = hit_test_objects_in_rect(&layer, rect, Affine::IDENTITY);
|
||||||
|
assert_eq!(hits.len(), 2);
|
||||||
|
|
||||||
|
// Marquee that contains only first circle
|
||||||
|
let rect = Rect::new(0.0, 0.0, 100.0, 100.0);
|
||||||
|
let hits = hit_test_objects_in_rect(&layer, rect, Affine::IDENTITY);
|
||||||
|
assert_eq!(hits.len(), 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -158,6 +158,68 @@ impl VectorLayer {
|
||||||
pub fn get_object_mut(&mut self, id: &Uuid) -> Option<&mut Object> {
|
pub fn get_object_mut(&mut self, id: &Uuid) -> Option<&mut Object> {
|
||||||
self.objects.iter_mut().find(|o| &o.id == id)
|
self.objects.iter_mut().find(|o| &o.id == id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// === MUTATION METHODS (pub(crate) - only accessible to action module) ===
|
||||||
|
|
||||||
|
/// Add a shape to this layer (internal, for actions only)
|
||||||
|
///
|
||||||
|
/// This method is intentionally `pub(crate)` to ensure mutations
|
||||||
|
/// only happen through the action system.
|
||||||
|
pub(crate) fn add_shape_internal(&mut self, shape: Shape) -> Uuid {
|
||||||
|
let id = shape.id;
|
||||||
|
self.shapes.push(shape);
|
||||||
|
id
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add an object to this layer (internal, for actions only)
|
||||||
|
///
|
||||||
|
/// This method is intentionally `pub(crate)` to ensure mutations
|
||||||
|
/// only happen through the action system.
|
||||||
|
pub(crate) fn add_object_internal(&mut self, object: Object) -> Uuid {
|
||||||
|
let id = object.id;
|
||||||
|
self.objects.push(object);
|
||||||
|
id
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove a shape from this layer (internal, for actions only)
|
||||||
|
///
|
||||||
|
/// Returns the removed shape if found.
|
||||||
|
/// This method is intentionally `pub(crate)` to ensure mutations
|
||||||
|
/// only happen through the action system.
|
||||||
|
pub(crate) fn remove_shape_internal(&mut self, id: &Uuid) -> Option<Shape> {
|
||||||
|
if let Some(index) = self.shapes.iter().position(|s| &s.id == id) {
|
||||||
|
Some(self.shapes.remove(index))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove an object from this layer (internal, for actions only)
|
||||||
|
///
|
||||||
|
/// Returns the removed object if found.
|
||||||
|
/// This method is intentionally `pub(crate)` to ensure mutations
|
||||||
|
/// only happen through the action system.
|
||||||
|
pub(crate) fn remove_object_internal(&mut self, id: &Uuid) -> Option<Object> {
|
||||||
|
if let Some(index) = self.objects.iter().position(|o| &o.id == id) {
|
||||||
|
Some(self.objects.remove(index))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Modify an object in place (internal, for actions only)
|
||||||
|
///
|
||||||
|
/// Applies the given function to the object if found.
|
||||||
|
/// This method is intentionally `pub(crate)` to ensure mutations
|
||||||
|
/// only happen through the action system.
|
||||||
|
pub(crate) fn modify_object_internal<F>(&mut self, id: &Uuid, f: F)
|
||||||
|
where
|
||||||
|
F: FnOnce(&mut Object),
|
||||||
|
{
|
||||||
|
if let Some(object) = self.get_object_mut(id) {
|
||||||
|
f(object);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Audio layer (placeholder for future implementation)
|
/// Audio layer (placeholder for future implementation)
|
||||||
|
|
|
||||||
|
|
@ -11,3 +11,7 @@ pub mod object;
|
||||||
pub mod layer;
|
pub mod layer;
|
||||||
pub mod document;
|
pub mod document;
|
||||||
pub mod renderer;
|
pub mod renderer;
|
||||||
|
pub mod action;
|
||||||
|
pub mod actions;
|
||||||
|
pub mod selection;
|
||||||
|
pub mod hit_test;
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
use vello::kurbo::Shape as KurboShape;
|
||||||
|
|
||||||
/// 2D transform for an object
|
/// 2D transform for an object
|
||||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
|
|
@ -170,6 +171,20 @@ impl Object {
|
||||||
self.transform.set_position(x, y);
|
self.transform.set_position(x, y);
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Convert object transform to affine matrix
|
||||||
|
pub fn to_affine(&self) -> kurbo::Affine {
|
||||||
|
self.transform.to_affine()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the bounding box of this object given its shape
|
||||||
|
///
|
||||||
|
/// Returns the bounding box in the object's parent coordinate space
|
||||||
|
/// (i.e., with the object's transform applied).
|
||||||
|
pub fn bounding_box(&self, shape: &crate::shape::Shape) -> kurbo::Rect {
|
||||||
|
let path_bbox = shape.path().bounding_box();
|
||||||
|
self.to_affine().transform_rect_bbox(path_bbox)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,223 @@
|
||||||
|
//! Selection state management
|
||||||
|
//!
|
||||||
|
//! Tracks selected objects and shapes for editing operations.
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
/// Selection state for the editor
|
||||||
|
///
|
||||||
|
/// Maintains sets of selected objects 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 objects (instances)
|
||||||
|
selected_objects: Vec<Uuid>,
|
||||||
|
|
||||||
|
/// Currently selected shapes (definitions)
|
||||||
|
selected_shapes: Vec<Uuid>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Selection {
|
||||||
|
/// Create a new empty selection
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
selected_objects: Vec::new(),
|
||||||
|
selected_shapes: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add an object to the selection
|
||||||
|
pub fn add_object(&mut self, id: Uuid) {
|
||||||
|
if !self.selected_objects.contains(&id) {
|
||||||
|
self.selected_objects.push(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add a shape to the selection
|
||||||
|
pub fn add_shape(&mut self, id: Uuid) {
|
||||||
|
if !self.selected_shapes.contains(&id) {
|
||||||
|
self.selected_shapes.push(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove an object from the selection
|
||||||
|
pub fn remove_object(&mut self, id: &Uuid) {
|
||||||
|
self.selected_objects.retain(|&x| x != *id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove a shape from the selection
|
||||||
|
pub fn remove_shape(&mut self, id: &Uuid) {
|
||||||
|
self.selected_shapes.retain(|&x| x != *id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Toggle an object's selection state
|
||||||
|
pub fn toggle_object(&mut self, id: Uuid) {
|
||||||
|
if self.contains_object(&id) {
|
||||||
|
self.remove_object(&id);
|
||||||
|
} else {
|
||||||
|
self.add_object(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.add_shape(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear all selections
|
||||||
|
pub fn clear(&mut self) {
|
||||||
|
self.selected_objects.clear();
|
||||||
|
self.selected_shapes.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear only object selections
|
||||||
|
pub fn clear_objects(&mut self) {
|
||||||
|
self.selected_objects.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear only shape selections
|
||||||
|
pub fn clear_shapes(&mut self) {
|
||||||
|
self.selected_shapes.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if an object is selected
|
||||||
|
pub fn contains_object(&self, id: &Uuid) -> bool {
|
||||||
|
self.selected_objects.contains(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if a shape is selected
|
||||||
|
pub fn contains_shape(&self, id: &Uuid) -> bool {
|
||||||
|
self.selected_shapes.contains(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if selection is empty
|
||||||
|
pub fn is_empty(&self) -> bool {
|
||||||
|
self.selected_objects.is_empty() && self.selected_shapes.is_empty()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the selected objects
|
||||||
|
pub fn objects(&self) -> &[Uuid] {
|
||||||
|
&self.selected_objects
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the selected shapes
|
||||||
|
pub fn shapes(&self) -> &[Uuid] {
|
||||||
|
&self.selected_shapes
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the number of selected objects
|
||||||
|
pub fn object_count(&self) -> usize {
|
||||||
|
self.selected_objects.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the number of selected shapes
|
||||||
|
pub fn shape_count(&self) -> usize {
|
||||||
|
self.selected_shapes.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set selection to a single object (clears previous selection)
|
||||||
|
pub fn select_only_object(&mut self, id: Uuid) {
|
||||||
|
self.clear();
|
||||||
|
self.add_object(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 multiple objects (clears previous selection)
|
||||||
|
pub fn select_objects(&mut self, ids: &[Uuid]) {
|
||||||
|
self.clear_objects();
|
||||||
|
for &id in ids {
|
||||||
|
self.add_object(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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_selection_creation() {
|
||||||
|
let selection = Selection::new();
|
||||||
|
assert!(selection.is_empty());
|
||||||
|
assert_eq!(selection.object_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_object(id1);
|
||||||
|
assert_eq!(selection.object_count(), 1);
|
||||||
|
assert!(selection.contains_object(&id1));
|
||||||
|
|
||||||
|
selection.add_object(id2);
|
||||||
|
assert_eq!(selection.object_count(), 2);
|
||||||
|
|
||||||
|
selection.remove_object(&id1);
|
||||||
|
assert_eq!(selection.object_count(), 1);
|
||||||
|
assert!(!selection.contains_object(&id1));
|
||||||
|
assert!(selection.contains_object(&id2));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_toggle() {
|
||||||
|
let mut selection = Selection::new();
|
||||||
|
let id = Uuid::new_v4();
|
||||||
|
|
||||||
|
selection.toggle_object(id);
|
||||||
|
assert!(selection.contains_object(&id));
|
||||||
|
|
||||||
|
selection.toggle_object(id);
|
||||||
|
assert!(!selection.contains_object(&id));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_select_only() {
|
||||||
|
let mut selection = Selection::new();
|
||||||
|
let id1 = Uuid::new_v4();
|
||||||
|
let id2 = Uuid::new_v4();
|
||||||
|
|
||||||
|
selection.add_object(id1);
|
||||||
|
selection.add_object(id2);
|
||||||
|
assert_eq!(selection.object_count(), 2);
|
||||||
|
|
||||||
|
selection.select_only_object(id1);
|
||||||
|
assert_eq!(selection.object_count(), 1);
|
||||||
|
assert!(selection.contains_object(&id1));
|
||||||
|
assert!(!selection.contains_object(&id2));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_clear() {
|
||||||
|
let mut selection = Selection::new();
|
||||||
|
selection.add_object(Uuid::new_v4());
|
||||||
|
selection.add_shape(Uuid::new_v4());
|
||||||
|
|
||||||
|
assert!(!selection.is_empty());
|
||||||
|
|
||||||
|
selection.clear();
|
||||||
|
assert!(selection.is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -299,6 +299,14 @@ impl Shape {
|
||||||
self.fill_rule = rule;
|
self.fill_rule = rule;
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Get the base path (first version) for this shape
|
||||||
|
///
|
||||||
|
/// This is useful for hit testing and bounding box calculations
|
||||||
|
/// when shape morphing is not being considered.
|
||||||
|
pub fn path(&self) -> &BezPath {
|
||||||
|
&self.versions[0].path
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,9 @@
|
||||||
/// Defines the available drawing/editing tools
|
/// Defines the available drawing/editing tools
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use uuid::Uuid;
|
||||||
|
use vello::kurbo::Point;
|
||||||
|
|
||||||
/// Drawing and editing tools
|
/// Drawing and editing tools
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||||
|
|
@ -24,6 +27,86 @@ pub enum Tool {
|
||||||
Eyedropper,
|
Eyedropper,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Tool state tracking for interactive operations
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum ToolState {
|
||||||
|
/// Tool is idle (no operation in progress)
|
||||||
|
Idle,
|
||||||
|
|
||||||
|
/// Drawing a freehand path
|
||||||
|
DrawingPath {
|
||||||
|
points: Vec<Point>,
|
||||||
|
simplify_mode: SimplifyMode,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Dragging selected objects
|
||||||
|
DraggingSelection {
|
||||||
|
start_pos: Point,
|
||||||
|
start_mouse: Point,
|
||||||
|
original_positions: HashMap<Uuid, Point>,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Creating a marquee selection rectangle
|
||||||
|
MarqueeSelecting {
|
||||||
|
start: Point,
|
||||||
|
current: Point,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Creating a rectangle shape
|
||||||
|
CreatingRectangle {
|
||||||
|
start_corner: Point,
|
||||||
|
current_corner: Point,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Creating an ellipse shape
|
||||||
|
CreatingEllipse {
|
||||||
|
center: Point,
|
||||||
|
current_point: Point,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Transforming selected objects (scale, rotate)
|
||||||
|
Transforming {
|
||||||
|
mode: TransformMode,
|
||||||
|
original_transforms: HashMap<Uuid, crate::object::Transform>,
|
||||||
|
pivot: Point,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Path simplification mode for the draw tool
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum SimplifyMode {
|
||||||
|
/// Ramer-Douglas-Peucker corner detection
|
||||||
|
Corners,
|
||||||
|
/// Schneider curve fitting for smooth curves
|
||||||
|
Smooth,
|
||||||
|
/// No simplification (use raw points)
|
||||||
|
Verbatim,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Transform mode for the transform tool
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||||
|
pub enum TransformMode {
|
||||||
|
/// Scale from a corner
|
||||||
|
ScaleCorner { origin: Point },
|
||||||
|
/// Scale along an edge
|
||||||
|
ScaleEdge { axis: Axis, origin: Point },
|
||||||
|
/// Rotate around a pivot
|
||||||
|
Rotate { center: Point },
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Axis for edge scaling
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum Axis {
|
||||||
|
Horizontal,
|
||||||
|
Vertical,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ToolState {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::Idle
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl Tool {
|
impl Tool {
|
||||||
/// Get display name for the tool
|
/// Get display name for the tool
|
||||||
pub fn display_name(self) -> &'static str {
|
pub fn display_name(self) -> &'static str {
|
||||||
|
|
|
||||||
|
|
@ -35,3 +35,4 @@ resvg = { workspace = true }
|
||||||
pollster = { workspace = true }
|
pollster = { workspace = true }
|
||||||
lightningcss = "1.0.0-alpha.68"
|
lightningcss = "1.0.0-alpha.68"
|
||||||
clap = { version = "4.5", features = ["derive"] }
|
clap = { version = "4.5", features = ["derive"] }
|
||||||
|
uuid = { version = "1.0", features = ["v4", "serde"] }
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,759 @@
|
||||||
|
# Tool Integration Implementation Plan
|
||||||
|
*Updated with correct architecture patterns from JS codebase*
|
||||||
|
|
||||||
|
## Architecture Overview
|
||||||
|
|
||||||
|
**Type-Safe Action System**: Document mutations only through `Action` trait
|
||||||
|
- Read: Public via `ActionExecutor::document()`
|
||||||
|
- Write: Only via `pub(crate)` methods in action implementations
|
||||||
|
- Enforcement: Rust's module privacy system
|
||||||
|
|
||||||
|
**Key Corrections**:
|
||||||
|
- ✅ GraphicsObject nesting (recursive hit testing)
|
||||||
|
- ✅ Shape tools create `Shape` + `Object`, add to active `VectorLayer`
|
||||||
|
- ✅ Tools only work on `VectorLayer` (check `active_layer.type`)
|
||||||
|
- ✅ Path fitting uses JS algorithms (RDP or Schneider)
|
||||||
|
- ✅ Paint bucket uses vector flood fill with quadtree
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 1: Action System Foundation
|
||||||
|
|
||||||
|
### 1.1 Create Action System Core
|
||||||
|
**File: `lightningbeam-core/src/action.rs`**
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub trait Action: Send {
|
||||||
|
fn execute(&mut self, document: &mut Document);
|
||||||
|
fn rollback(&mut self, document: &mut Document);
|
||||||
|
fn description(&self) -> String;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct ActionExecutor {
|
||||||
|
document: Document,
|
||||||
|
undo_stack: Vec<Box<dyn Action>>,
|
||||||
|
redo_stack: Vec<Box<dyn Action>>,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Methods:
|
||||||
|
- `document(&self) -> &Document` - Read-only access
|
||||||
|
- `execute(&mut self, Box<dyn Action>)` - Execute + push to undo
|
||||||
|
- `undo(&mut self) -> bool` - Pop and rollback
|
||||||
|
- `redo(&mut self) -> bool` - Re-execute from redo stack
|
||||||
|
|
||||||
|
### 1.2 Update Document for Controlled Access
|
||||||
|
**File: `lightningbeam-core/src/document.rs`**
|
||||||
|
|
||||||
|
Add `pub(crate)` mutation methods:
|
||||||
|
- `root_mut() -> &mut GraphicsObject`
|
||||||
|
- `get_layer_mut(&self, id: &Uuid) -> Option<&mut AnyLayer>`
|
||||||
|
- Keep all fields private
|
||||||
|
- Keep existing public read methods
|
||||||
|
|
||||||
|
### 1.3 Update Layer for Shape Operations
|
||||||
|
**File: `lightningbeam-core/src/layer.rs`**
|
||||||
|
|
||||||
|
Add `pub(crate)` methods to `VectorLayer`:
|
||||||
|
- `add_shape_internal(&mut self, shape: Shape) -> Uuid`
|
||||||
|
- `add_object_internal(&mut self, object: Object) -> Uuid`
|
||||||
|
- `remove_shape_internal(&mut self, id: &Uuid) -> Option<Shape>`
|
||||||
|
- `remove_object_internal(&mut self, id: &Uuid) -> Option<Object>`
|
||||||
|
- `modify_object_internal(&mut self, id: &Uuid, f: impl FnOnce(&mut Object))`
|
||||||
|
|
||||||
|
### 1.4 Integrate ActionExecutor into EditorApp
|
||||||
|
**File: `lightningbeam-editor/src/main.rs`**
|
||||||
|
|
||||||
|
- Replace `document: Document` with `action_executor: ActionExecutor`
|
||||||
|
- Add `active_layer_id: Option<Uuid>` to track current layer
|
||||||
|
- Update `SharedPaneState` to pass `document: &Document` (read-only)
|
||||||
|
- Add `execute_action(&mut self, action: Box<dyn Action>)` method
|
||||||
|
- Wire Ctrl+Z / Ctrl+Shift+Z to undo/redo
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2: Selection System
|
||||||
|
|
||||||
|
### 2.1 Create Selection State
|
||||||
|
**File: `lightningbeam-core/src/selection.rs`**
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub struct Selection {
|
||||||
|
selected_objects: Vec<Uuid>,
|
||||||
|
selected_shapes: Vec<Uuid>,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Methods: `add`, `remove`, `clear`, `contains`, `is_empty`, `objects()`, `shapes()`
|
||||||
|
|
||||||
|
### 2.2 Add to Editor State
|
||||||
|
Add to `EditorApp`:
|
||||||
|
- `selection: Selection`
|
||||||
|
- Pass through `SharedPaneState` (read-only for rendering, mutable for tools)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3: Hit Testing Infrastructure
|
||||||
|
|
||||||
|
### 3.1 Hit Test Module
|
||||||
|
**File: `lightningbeam-core/src/hit_test.rs`**
|
||||||
|
|
||||||
|
**Recursive Hit Testing through GraphicsObject hierarchy:**
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub fn hit_test_layer(
|
||||||
|
layer: &VectorLayer,
|
||||||
|
point: Point,
|
||||||
|
tolerance: f64,
|
||||||
|
parent_transform: Affine,
|
||||||
|
) -> Option<Uuid> {
|
||||||
|
// Hit test objects in this layer
|
||||||
|
for object in layer.objects.iter().rev() { // Back to front
|
||||||
|
let shape = layer.get_shape(&object.shape_id)?;
|
||||||
|
|
||||||
|
// Combine parent transform with object transform
|
||||||
|
let combined_transform = parent_transform * object.to_affine();
|
||||||
|
|
||||||
|
if hit_test_shape(shape, point, tolerance, combined_transform) {
|
||||||
|
return Some(object.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hit_test_shape(
|
||||||
|
shape: &Shape,
|
||||||
|
point: Point,
|
||||||
|
tolerance: f64,
|
||||||
|
transform: Affine,
|
||||||
|
) -> bool {
|
||||||
|
// Transform point to shape's local space
|
||||||
|
let inverse_transform = transform.inverse();
|
||||||
|
let local_point = inverse_transform * point;
|
||||||
|
|
||||||
|
// Check if point is inside path (kurbo's contains())
|
||||||
|
if shape.path.contains(local_point) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check stroke bounds if has stroke
|
||||||
|
if shape.stroke_style.is_some() {
|
||||||
|
let stroke_tolerance = shape.stroke_style.unwrap().width / 2.0 + tolerance;
|
||||||
|
// Check distance to path
|
||||||
|
// Use kurbo path methods for nearest point
|
||||||
|
}
|
||||||
|
|
||||||
|
false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Rectangle Hit Testing:**
|
||||||
|
```rust
|
||||||
|
pub fn hit_test_objects_in_rect(
|
||||||
|
layer: &VectorLayer,
|
||||||
|
rect: Rect,
|
||||||
|
parent_transform: Affine,
|
||||||
|
) -> Vec<Uuid> {
|
||||||
|
let mut hits = Vec::new();
|
||||||
|
|
||||||
|
for object in &layer.objects {
|
||||||
|
let shape = layer.get_shape(&object.shape_id).unwrap();
|
||||||
|
let combined_transform = parent_transform * object.to_affine();
|
||||||
|
let bbox = shape.path.bounding_box();
|
||||||
|
let transformed_bbox = combined_transform.transform_rect_bbox(bbox);
|
||||||
|
|
||||||
|
if rect.intersect(transformed_bbox).area() > 0.0 {
|
||||||
|
hits.push(object.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
hits
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 Bounding Box Calculation
|
||||||
|
Add to `lightningbeam-core/src/object.rs`:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
impl Object {
|
||||||
|
pub fn bounding_box(&self, shape: &Shape) -> Rect {
|
||||||
|
let path_bbox = shape.path.bounding_box();
|
||||||
|
self.to_affine().transform_rect_bbox(path_bbox)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 4: Tool State Management
|
||||||
|
|
||||||
|
### 4.1 Tool State Enum
|
||||||
|
**File: `lightningbeam-core/src/tool.rs`**
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub enum ToolState {
|
||||||
|
Idle,
|
||||||
|
|
||||||
|
DrawingPath {
|
||||||
|
points: Vec<Point>,
|
||||||
|
simplify_mode: SimplifyMode, // "corners" | "smooth" | "verbatim"
|
||||||
|
},
|
||||||
|
|
||||||
|
DraggingSelection {
|
||||||
|
start_pos: Point,
|
||||||
|
start_mouse: Point,
|
||||||
|
original_transforms: HashMap<Uuid, Transform>,
|
||||||
|
},
|
||||||
|
|
||||||
|
MarqueeSelecting {
|
||||||
|
start: Point,
|
||||||
|
current: Point,
|
||||||
|
},
|
||||||
|
|
||||||
|
CreatingRectangle {
|
||||||
|
start_corner: Point,
|
||||||
|
current_corner: Point,
|
||||||
|
},
|
||||||
|
|
||||||
|
CreatingEllipse {
|
||||||
|
center: Point,
|
||||||
|
current_point: Point,
|
||||||
|
},
|
||||||
|
|
||||||
|
Transforming {
|
||||||
|
mode: TransformMode,
|
||||||
|
original_transforms: HashMap<Uuid, Transform>,
|
||||||
|
pivot: Point,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
pub enum SimplifyMode {
|
||||||
|
Corners, // Ramer-Douglas-Peucker
|
||||||
|
Smooth, // Schneider curve fitting
|
||||||
|
Verbatim, // No simplification
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Add to `EditorApp`: `tool_state: ToolState`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 5: Select Tool
|
||||||
|
|
||||||
|
### 5.1 Active Layer Validation
|
||||||
|
**All tools check:**
|
||||||
|
```rust
|
||||||
|
// In Stage.handle_tool_input()
|
||||||
|
let Some(active_layer_id) = shared.active_layer_id else {
|
||||||
|
return None; // No active layer
|
||||||
|
};
|
||||||
|
|
||||||
|
let active_layer = shared.document.get_layer(active_layer_id)?;
|
||||||
|
|
||||||
|
// Only work on VectorLayer
|
||||||
|
let AnyLayer::Vector(vector_layer) = active_layer else {
|
||||||
|
return None; // Not a vector layer
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 Click Selection
|
||||||
|
**Mouse Down:**
|
||||||
|
- Hit test at click position using recursive `hit_test_layer()`
|
||||||
|
- If object found:
|
||||||
|
- If Shift: toggle in selection
|
||||||
|
- Else: replace selection with clicked object
|
||||||
|
- If already selected: enter `DraggingSelection` state
|
||||||
|
- If nothing found: enter `MarqueeSelecting` state
|
||||||
|
|
||||||
|
**Mouse Drag (when dragging selection):**
|
||||||
|
- Calculate delta from start_mouse
|
||||||
|
- Update object positions (temporary, for preview)
|
||||||
|
- Re-render with updated positions
|
||||||
|
|
||||||
|
**Mouse Up:**
|
||||||
|
- If was dragging: create `MoveObjectsAction`
|
||||||
|
- If was marquee: select objects in rectangle
|
||||||
|
|
||||||
|
### 5.3 Move Objects Action
|
||||||
|
**File: `lightningbeam-core/src/actions/move_objects.rs`**
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub struct MoveObjectsAction {
|
||||||
|
layer_id: Uuid,
|
||||||
|
object_transforms: HashMap<Uuid, (Transform, Transform)>, // (old, new)
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Action for MoveObjectsAction {
|
||||||
|
fn execute(&mut self, document: &mut Document) {
|
||||||
|
let layer = document.get_layer_mut(&self.layer_id).unwrap();
|
||||||
|
if let AnyLayer::Vector(vector_layer) = layer {
|
||||||
|
for (object_id, (_old, new)) in &self.object_transforms {
|
||||||
|
vector_layer.modify_object_internal(object_id, |obj| {
|
||||||
|
obj.transform = new.clone();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rollback(&mut self, document: &mut Document) {
|
||||||
|
let layer = document.get_layer_mut(&self.layer_id).unwrap();
|
||||||
|
if let AnyLayer::Vector(vector_layer) = layer {
|
||||||
|
for (object_id, (old, _new)) in &self.object_transforms {
|
||||||
|
vector_layer.modify_object_internal(object_id, |obj| {
|
||||||
|
obj.transform = old.clone();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.4 Selection Rendering
|
||||||
|
In `VelloCallback::prepare()`:
|
||||||
|
- After rendering document
|
||||||
|
- For each selected object ID:
|
||||||
|
- Get object and its shape from active layer
|
||||||
|
- Calculate bounding box (with transform)
|
||||||
|
- Draw selection outline (blue, 2px stroke)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 6: Rectangle & Ellipse Tools
|
||||||
|
|
||||||
|
### 6.1 Add Shape Action
|
||||||
|
**File: `lightningbeam-core/src/actions/add_shape.rs`**
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub struct AddShapeAction {
|
||||||
|
layer_id: Uuid,
|
||||||
|
shape: Shape,
|
||||||
|
object: Object,
|
||||||
|
created_shape_id: Option<Uuid>,
|
||||||
|
created_object_id: Option<Uuid>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Action for AddShapeAction {
|
||||||
|
fn execute(&mut self, document: &mut Document) {
|
||||||
|
let layer = document.get_layer_mut(&self.layer_id).unwrap();
|
||||||
|
if let AnyLayer::Vector(vector_layer) = layer {
|
||||||
|
let shape_id = vector_layer.add_shape_internal(self.shape.clone());
|
||||||
|
let object_id = vector_layer.add_object_internal(self.object.clone());
|
||||||
|
self.created_shape_id = Some(shape_id);
|
||||||
|
self.created_object_id = Some(object_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rollback(&mut self, document: &mut Document) {
|
||||||
|
if let (Some(shape_id), Some(object_id)) = (self.created_shape_id, self.created_object_id) {
|
||||||
|
let layer = document.get_layer_mut(&self.layer_id).unwrap();
|
||||||
|
if let AnyLayer::Vector(vector_layer) = layer {
|
||||||
|
vector_layer.remove_object_internal(&object_id);
|
||||||
|
vector_layer.remove_shape_internal(&shape_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.2 Rectangle Tool
|
||||||
|
**Mouse Down:** Enter `CreatingRectangle { start_corner, current_corner }`
|
||||||
|
|
||||||
|
**Mouse Drag:**
|
||||||
|
- Update current_corner
|
||||||
|
- If Shift: constrain to square (equal width/height)
|
||||||
|
- Create preview path: `Rect::from_points(start, current).to_path()`
|
||||||
|
- Render preview with dashed stroke
|
||||||
|
|
||||||
|
**Mouse Up:**
|
||||||
|
- Create `Shape` with rectangle path
|
||||||
|
- Create `Object` at (0, 0) with shape_id
|
||||||
|
- Return `AddShapeAction { layer_id, shape, object }`
|
||||||
|
|
||||||
|
### 6.3 Ellipse Tool
|
||||||
|
**Mouse Down:** Enter `CreatingEllipse { center, current_point }`
|
||||||
|
|
||||||
|
**Mouse Drag:**
|
||||||
|
- Calculate radii from center to current_point
|
||||||
|
- If Shift: constrain to circle (equal radii)
|
||||||
|
- Create preview: `Circle::new(center, radius).to_path()`
|
||||||
|
- Render preview
|
||||||
|
|
||||||
|
**Mouse Up:**
|
||||||
|
- Create `Shape` with ellipse path
|
||||||
|
- Create `Object` with shape_id
|
||||||
|
- Return `AddShapeAction`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 7: Draw/Pen Tool
|
||||||
|
|
||||||
|
### 7.1 Path Fitting Module
|
||||||
|
**File: `lightningbeam-core/src/path_fitting.rs`**
|
||||||
|
|
||||||
|
**Implement two algorithms from JS:**
|
||||||
|
|
||||||
|
#### A. Ramer-Douglas-Peucker Simplification
|
||||||
|
```rust
|
||||||
|
pub fn simplify_rdp(points: &[Point], tolerance: f64) -> Vec<Point> {
|
||||||
|
// Port from /src/simplify.js
|
||||||
|
// 1. Radial distance filter first
|
||||||
|
// 2. Then Douglas-Peucker recursive simplification
|
||||||
|
// Tolerance: 10 (squared internally)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### B. Schneider Curve Fitting
|
||||||
|
```rust
|
||||||
|
pub fn fit_bezier_curves(points: &[Point], max_error: f64) -> BezPath {
|
||||||
|
// Port from /src/fit-curve.js
|
||||||
|
// Based on Graphics Gems algorithm
|
||||||
|
// 1. Chord-length parameterization
|
||||||
|
// 2. Least-squares fit for control points
|
||||||
|
// 3. Newton-Raphson refinement (max 20 iterations)
|
||||||
|
// 4. Recursive split at max error point if needed
|
||||||
|
// max_error: 30
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.2 Draw Tool Implementation
|
||||||
|
**Mouse Down:** Enter `DrawingPath { points: vec![start], simplify_mode }`
|
||||||
|
|
||||||
|
**Mouse Drag:**
|
||||||
|
- Add point if distance from last point > threshold (2-5 pixels)
|
||||||
|
- Build preview path from points
|
||||||
|
- Render preview
|
||||||
|
|
||||||
|
**Mouse Up:**
|
||||||
|
- Based on `simplify_mode`:
|
||||||
|
- **Corners**: Apply RDP simplification (tolerance=10), then create mid-point Beziers
|
||||||
|
- **Smooth**: Apply Schneider curve fitting (error=30)
|
||||||
|
- **Verbatim**: Use points as-is
|
||||||
|
- Create `Shape` with fitted path
|
||||||
|
- Create `Object` with shape_id
|
||||||
|
- Return `AddShapeAction`
|
||||||
|
|
||||||
|
**Simplify Mode Setting:**
|
||||||
|
Add to `EditorApp`: `pen_simplify_mode: SimplifyMode`
|
||||||
|
Show in info panel / toolbar
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 8: Transform Tool
|
||||||
|
|
||||||
|
### 8.1 Transform Handles
|
||||||
|
In `VelloCallback::prepare()` when `Tool::Transform` and selection non-empty:
|
||||||
|
|
||||||
|
Calculate selection bbox (union of all selected object bboxes):
|
||||||
|
```rust
|
||||||
|
let mut bbox = Rect::ZERO;
|
||||||
|
for object_id in selection.objects() {
|
||||||
|
let object = get_object(object_id);
|
||||||
|
let shape = get_shape(object.shape_id);
|
||||||
|
bbox = bbox.union(object.bounding_box(shape));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Render 8 handles + rotation handle:
|
||||||
|
- 4 corners (8x8 squares) → scale from opposite corner
|
||||||
|
- 4 edge midpoints → scale along axis
|
||||||
|
- 1 rotation handle (circle, 20px above top edge)
|
||||||
|
- Bounding box outline
|
||||||
|
|
||||||
|
### 8.2 Handle Hit Testing
|
||||||
|
```rust
|
||||||
|
fn hit_test_transform_handle(
|
||||||
|
point: Point,
|
||||||
|
bbox: Rect,
|
||||||
|
tolerance: f64,
|
||||||
|
) -> Option<TransformMode> {
|
||||||
|
// Check rotation handle first
|
||||||
|
let rotation_handle = Point::new(bbox.center().x, bbox.min_y() - 20.0);
|
||||||
|
if point.distance(rotation_handle) < tolerance {
|
||||||
|
return Some(TransformMode::Rotate { center: bbox.center() });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check corner handles
|
||||||
|
let corners = [bbox.origin(), /* ... */];
|
||||||
|
for (i, corner) in corners.iter().enumerate() {
|
||||||
|
if point.distance(*corner) < tolerance {
|
||||||
|
let opposite = corners[(i + 2) % 4];
|
||||||
|
return Some(TransformMode::ScaleCorner { origin: opposite });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check edge handles
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.3 Transform Interaction
|
||||||
|
**Mouse Down on handle:**
|
||||||
|
- Enter `Transforming { mode, original_transforms, pivot }`
|
||||||
|
|
||||||
|
**Mouse Drag:**
|
||||||
|
- Calculate new transform based on mode:
|
||||||
|
- **ScaleCorner**: Compute scale from opposite corner
|
||||||
|
- **ScaleEdge**: Scale along one axis
|
||||||
|
- **Rotate**: Compute angle from pivot to cursor
|
||||||
|
- Apply to all selected objects (preview)
|
||||||
|
|
||||||
|
**Mouse Up:**
|
||||||
|
- Create `TransformObjectsAction`
|
||||||
|
- Return for execution
|
||||||
|
|
||||||
|
### 8.4 Transform Action
|
||||||
|
**File: `lightningbeam-core/src/actions/transform.rs`**
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub struct TransformObjectsAction {
|
||||||
|
layer_id: Uuid,
|
||||||
|
object_transforms: HashMap<Uuid, (Transform, Transform)>, // (old, new)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Similar to MoveObjectsAction but updates full Transform struct.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 9: Paint Bucket Tool
|
||||||
|
|
||||||
|
### 9.1 Quadtree for Curve Indexing
|
||||||
|
**File: `lightningbeam-core/src/quadtree.rs`**
|
||||||
|
|
||||||
|
Port from JS (`/src/utils.js`):
|
||||||
|
```rust
|
||||||
|
pub struct Quadtree {
|
||||||
|
bounds: Rect,
|
||||||
|
capacity: usize,
|
||||||
|
curves: Vec<(BezPath, usize)>, // (curve, index)
|
||||||
|
subdivided: bool,
|
||||||
|
// children: [Box<Quadtree>; 4]
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Quadtree {
|
||||||
|
pub fn insert(&mut self, curve: BezPath, index: usize);
|
||||||
|
pub fn query(&self, range: Rect) -> Vec<usize>; // Return curve indices
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 9.2 Vector Flood Fill
|
||||||
|
**File: `lightningbeam-core/src/flood_fill.rs`**
|
||||||
|
|
||||||
|
Port from JS (`/src/utils.js` lines 173-307):
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub struct FloodFillRegion {
|
||||||
|
start_point: Point,
|
||||||
|
epsilon: f64, // Gap closing tolerance (default: 5)
|
||||||
|
canvas_bounds: Rect,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FloodFillRegion {
|
||||||
|
pub fn fill(
|
||||||
|
&self,
|
||||||
|
shapes: &[Shape], // All visible shapes on layer
|
||||||
|
) -> Result<Vec<Point>, String> {
|
||||||
|
// 1. Build quadtree for all curves in all shapes
|
||||||
|
// 2. Stack-based flood fill
|
||||||
|
// 3. For each point:
|
||||||
|
// - Check if near any curve (using quadtree query + projection)
|
||||||
|
// - If near curve (within epsilon): save projection point, stop expanding
|
||||||
|
// - If not near: expand to 4 neighbors
|
||||||
|
// 4. Return boundary points (projections on curves)
|
||||||
|
// 5. If < 10 points found, retry with epsilon=1
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_near_curve(
|
||||||
|
&self,
|
||||||
|
point: Point,
|
||||||
|
shape: &Shape,
|
||||||
|
quadtree: &Quadtree,
|
||||||
|
) -> Option<Point> {
|
||||||
|
let query_bbox = Rect::new(
|
||||||
|
point.x - self.epsilon/2.0,
|
||||||
|
point.y - self.epsilon/2.0,
|
||||||
|
point.x + self.epsilon/2.0,
|
||||||
|
point.y + self.epsilon/2.0,
|
||||||
|
);
|
||||||
|
|
||||||
|
for curve_idx in quadtree.query(query_bbox) {
|
||||||
|
let curve = &shape.curves[curve_idx];
|
||||||
|
let projection = curve.nearest(point, 0.1); // kurbo's nearest point
|
||||||
|
if projection.distance_sq < self.epsilon * self.epsilon {
|
||||||
|
return Some(projection.point);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 9.3 Point Sorting
|
||||||
|
```rust
|
||||||
|
fn sort_points_by_proximity(points: Vec<Point>) -> Vec<Point> {
|
||||||
|
// Port from JS lines 276-307
|
||||||
|
// Greedy nearest-neighbor sort to create coherent path
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 9.4 Paint Bucket Action
|
||||||
|
**File: `lightningbeam-core/src/actions/paint_bucket.rs`**
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub struct PaintBucketAction {
|
||||||
|
layer_id: Uuid,
|
||||||
|
click_point: Point,
|
||||||
|
epsilon: f64,
|
||||||
|
created_shape_id: Option<Uuid>,
|
||||||
|
created_object_id: Option<Uuid>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Action for PaintBucketAction {
|
||||||
|
fn execute(&mut self, document: &mut Document) {
|
||||||
|
let layer = document.get_layer(&self.layer_id).unwrap();
|
||||||
|
let AnyLayer::Vector(vector_layer) = layer else { return };
|
||||||
|
|
||||||
|
// Get all shapes in layer
|
||||||
|
let shapes: Vec<_> = vector_layer.shapes.clone();
|
||||||
|
|
||||||
|
// Perform flood fill
|
||||||
|
let fill_region = FloodFillRegion {
|
||||||
|
start_point: self.click_point,
|
||||||
|
epsilon: self.epsilon,
|
||||||
|
canvas_bounds: Rect::new(0.0, 0.0, document.width, document.height),
|
||||||
|
};
|
||||||
|
|
||||||
|
let boundary_points = fill_region.fill(&shapes)?;
|
||||||
|
|
||||||
|
// Sort points by proximity
|
||||||
|
let sorted_points = sort_points_by_proximity(boundary_points);
|
||||||
|
|
||||||
|
// Fit curve with very low error (1.0) for precision
|
||||||
|
let path = fit_bezier_curves(&sorted_points, 1.0);
|
||||||
|
|
||||||
|
// Create filled shape
|
||||||
|
let shape = Shape::new(path)
|
||||||
|
.with_fill(/* current fill color */)
|
||||||
|
.without_stroke();
|
||||||
|
|
||||||
|
// Create object
|
||||||
|
let object = Object::new(shape.id);
|
||||||
|
|
||||||
|
// Add to layer
|
||||||
|
let layer = document.get_layer_mut(&self.layer_id).unwrap();
|
||||||
|
if let AnyLayer::Vector(vector_layer) = layer {
|
||||||
|
self.created_shape_id = Some(vector_layer.add_shape_internal(shape));
|
||||||
|
self.created_object_id = Some(vector_layer.add_object_internal(object));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rollback(&mut self, document: &mut Document) {
|
||||||
|
// Remove created shape and object
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 9.5 Paint Bucket Tool Handler
|
||||||
|
In `handle_tool_input()` when `Tool::PaintBucket`:
|
||||||
|
|
||||||
|
**Mouse Click:**
|
||||||
|
- Get click position
|
||||||
|
- Create `PaintBucketAction { click_point, epsilon: 5.0 }`
|
||||||
|
- Return action for execution
|
||||||
|
- Tool stays active for multiple fills
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 10: Eyedropper Tool
|
||||||
|
|
||||||
|
### 10.1 Color Sampling
|
||||||
|
In `handle_tool_input()` when `Tool::Eyedropper`:
|
||||||
|
|
||||||
|
**Mouse Click:**
|
||||||
|
- Hit test at cursor position
|
||||||
|
- If object found:
|
||||||
|
- Get object's shape
|
||||||
|
- Read shape's fill_color
|
||||||
|
- Update `fill_color` in EditorApp
|
||||||
|
- Show toast/feedback with sampled color
|
||||||
|
- Tool stays active
|
||||||
|
|
||||||
|
**Visual Feedback:**
|
||||||
|
- Custom cursor showing crosshair
|
||||||
|
- Color preview circle at cursor
|
||||||
|
- Display hex value
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Order
|
||||||
|
|
||||||
|
### Sprint 1: Foundation (3-4 days)
|
||||||
|
- [ ] Action system (ActionExecutor, Action trait)
|
||||||
|
- [ ] Document controlled access (pub(crate) methods)
|
||||||
|
- [ ] Integrate ActionExecutor into EditorApp
|
||||||
|
- [ ] Undo/redo shortcuts (Ctrl+Z, Ctrl+Shift+Z)
|
||||||
|
|
||||||
|
### Sprint 2: Selection (3-4 days)
|
||||||
|
- [ ] Selection state struct
|
||||||
|
- [ ] Recursive hit testing (through GraphicsObject hierarchy)
|
||||||
|
- [ ] Active layer tracking
|
||||||
|
- [ ] Selection rendering
|
||||||
|
- [ ] Click selection
|
||||||
|
|
||||||
|
### Sprint 3: Select Tool (4-5 days)
|
||||||
|
- [ ] Tool state management
|
||||||
|
- [ ] Stage input handling refactor
|
||||||
|
- [ ] Layer type validation
|
||||||
|
- [ ] Drag-to-move (MoveObjectsAction)
|
||||||
|
- [ ] Marquee selection
|
||||||
|
|
||||||
|
### Sprint 4: Shape Tools (4-5 days)
|
||||||
|
- [ ] AddShapeAction
|
||||||
|
- [ ] Rectangle tool (with Shift constraint)
|
||||||
|
- [ ] Ellipse tool (with Shift constraint)
|
||||||
|
- [ ] Preview rendering
|
||||||
|
- [ ] Integration with active layer
|
||||||
|
|
||||||
|
### Sprint 5: Draw Tool (5-6 days)
|
||||||
|
- [ ] RDP simplification algorithm
|
||||||
|
- [ ] Schneider curve fitting algorithm
|
||||||
|
- [ ] Path fitting module
|
||||||
|
- [ ] Draw tool with mode selection
|
||||||
|
- [ ] Preview rendering
|
||||||
|
|
||||||
|
### Sprint 6: Transform Tool (5-6 days)
|
||||||
|
- [ ] Transform handle rendering
|
||||||
|
- [ ] Handle hit testing
|
||||||
|
- [ ] Scale operations
|
||||||
|
- [ ] Rotate operation
|
||||||
|
- [ ] TransformObjectsAction
|
||||||
|
|
||||||
|
### Sprint 7: Paint Bucket (6-7 days)
|
||||||
|
- [ ] Quadtree implementation
|
||||||
|
- [ ] Vector flood fill algorithm
|
||||||
|
- [ ] Point sorting
|
||||||
|
- [ ] Curve fitting integration
|
||||||
|
- [ ] PaintBucketAction
|
||||||
|
|
||||||
|
### Sprint 8: Polish (2-3 days)
|
||||||
|
- [ ] Eyedropper tool
|
||||||
|
- [ ] Tool cursors
|
||||||
|
- [ ] Edge cases and bugs
|
||||||
|
|
||||||
|
**Total: ~6-7 weeks**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Key Architectural Corrections
|
||||||
|
|
||||||
|
✅ **GraphicsObject Nesting**: Hit testing uses recursive transform multiplication through parent hierarchy
|
||||||
|
|
||||||
|
✅ **Shape Creation**: Tools create `Shape` instances, then `Object` instances pointing to them, add both to `VectorLayer`
|
||||||
|
|
||||||
|
✅ **Layer Type Validation**: Check `active_layer` is `VectorLayer` before tool operations
|
||||||
|
|
||||||
|
✅ **Path Fitting**: Port exact JS algorithms (RDP tolerance=10, Schneider error=30)
|
||||||
|
|
||||||
|
✅ **Paint Bucket**: Vector-based flood fill with quadtree-accelerated curve projection
|
||||||
|
|
||||||
|
✅ **Type Safety**: Compile-time enforcement that document mutations only through actions
|
||||||
|
|
@ -4,6 +4,7 @@ use lightningbeam_core::pane::PaneType;
|
||||||
use lightningbeam_core::tool::Tool;
|
use lightningbeam_core::tool::Tool;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use clap::Parser;
|
use clap::Parser;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
mod panes;
|
mod panes;
|
||||||
use panes::{PaneInstance, PaneRenderer, SharedPaneState};
|
use panes::{PaneInstance, PaneRenderer, SharedPaneState};
|
||||||
|
|
@ -230,7 +231,10 @@ struct EditorApp {
|
||||||
menu_system: Option<MenuSystem>, // Native menu system for event checking
|
menu_system: Option<MenuSystem>, // Native menu system for event checking
|
||||||
pending_view_action: Option<MenuAction>, // Pending view action (zoom, recenter) to be handled by hovered pane
|
pending_view_action: Option<MenuAction>, // Pending view action (zoom, recenter) to be handled by hovered pane
|
||||||
theme: Theme, // Theme system for colors and dimensions
|
theme: Theme, // Theme system for colors and dimensions
|
||||||
document: lightningbeam_core::document::Document, // Active document being edited
|
action_executor: lightningbeam_core::action::ActionExecutor, // Action system for undo/redo
|
||||||
|
active_layer_id: Option<Uuid>, // Currently active layer for editing
|
||||||
|
selection: lightningbeam_core::selection::Selection, // Current selection state
|
||||||
|
tool_state: lightningbeam_core::tool::ToolState, // Current tool interaction state
|
||||||
}
|
}
|
||||||
|
|
||||||
impl EditorApp {
|
impl EditorApp {
|
||||||
|
|
@ -259,7 +263,10 @@ impl EditorApp {
|
||||||
let mut vector_layer = VectorLayer::new("Layer 1");
|
let mut vector_layer = VectorLayer::new("Layer 1");
|
||||||
vector_layer.add_shape(shape);
|
vector_layer.add_shape(shape);
|
||||||
vector_layer.add_object(object);
|
vector_layer.add_object(object);
|
||||||
document.root.add_child(AnyLayer::Vector(vector_layer));
|
let layer_id = document.root.add_child(AnyLayer::Vector(vector_layer));
|
||||||
|
|
||||||
|
// Wrap document in ActionExecutor
|
||||||
|
let action_executor = lightningbeam_core::action::ActionExecutor::new(document);
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
layouts,
|
layouts,
|
||||||
|
|
@ -278,7 +285,10 @@ impl EditorApp {
|
||||||
menu_system,
|
menu_system,
|
||||||
pending_view_action: None,
|
pending_view_action: None,
|
||||||
theme,
|
theme,
|
||||||
document,
|
action_executor,
|
||||||
|
active_layer_id: Some(layer_id),
|
||||||
|
selection: lightningbeam_core::selection::Selection::new(),
|
||||||
|
tool_state: lightningbeam_core::tool::ToolState::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -361,12 +371,18 @@ impl EditorApp {
|
||||||
|
|
||||||
// Edit menu
|
// Edit menu
|
||||||
MenuAction::Undo => {
|
MenuAction::Undo => {
|
||||||
println!("Menu: Undo");
|
if self.action_executor.undo() {
|
||||||
// TODO: Implement undo
|
println!("Undid: {}", self.action_executor.redo_description().unwrap_or_default());
|
||||||
|
} else {
|
||||||
|
println!("Nothing to undo");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
MenuAction::Redo => {
|
MenuAction::Redo => {
|
||||||
println!("Menu: Redo");
|
if self.action_executor.redo() {
|
||||||
// TODO: Implement redo
|
println!("Redid: {}", self.action_executor.undo_description().unwrap_or_default());
|
||||||
|
} else {
|
||||||
|
println!("Nothing to redo");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
MenuAction::Cut => {
|
MenuAction::Cut => {
|
||||||
println!("Menu: Cut");
|
println!("Menu: Cut");
|
||||||
|
|
@ -571,6 +587,9 @@ impl eframe::App for EditorApp {
|
||||||
// Registry for view action handlers (two-phase dispatch)
|
// Registry for view action handlers (two-phase dispatch)
|
||||||
let mut pending_handlers: Vec<panes::ViewActionHandler> = Vec::new();
|
let mut pending_handlers: Vec<panes::ViewActionHandler> = Vec::new();
|
||||||
|
|
||||||
|
// Registry for actions to execute after rendering (two-phase dispatch)
|
||||||
|
let mut pending_actions: Vec<Box<dyn lightningbeam_core::action::Action>> = Vec::new();
|
||||||
|
|
||||||
render_layout_node(
|
render_layout_node(
|
||||||
ui,
|
ui,
|
||||||
&mut self.current_layout,
|
&mut self.current_layout,
|
||||||
|
|
@ -591,7 +610,11 @@ impl eframe::App for EditorApp {
|
||||||
&mut fallback_pane_priority,
|
&mut fallback_pane_priority,
|
||||||
&mut pending_handlers,
|
&mut pending_handlers,
|
||||||
&self.theme,
|
&self.theme,
|
||||||
&mut self.document,
|
self.action_executor.document(),
|
||||||
|
&mut self.selection,
|
||||||
|
&self.active_layer_id,
|
||||||
|
&mut self.tool_state,
|
||||||
|
&mut pending_actions,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Execute action on the best handler (two-phase dispatch)
|
// Execute action on the best handler (two-phase dispatch)
|
||||||
|
|
@ -611,6 +634,11 @@ impl eframe::App for EditorApp {
|
||||||
self.pending_view_action = None;
|
self.pending_view_action = None;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Execute all pending actions (two-phase dispatch)
|
||||||
|
for action in pending_actions {
|
||||||
|
self.action_executor.execute(action);
|
||||||
|
}
|
||||||
|
|
||||||
// Set cursor based on hover state
|
// Set cursor based on hover state
|
||||||
if let Some((_, is_horizontal)) = self.hovered_divider {
|
if let Some((_, is_horizontal)) = self.hovered_divider {
|
||||||
if is_horizontal {
|
if is_horizontal {
|
||||||
|
|
@ -669,11 +697,15 @@ fn render_layout_node(
|
||||||
fallback_pane_priority: &mut Option<u32>,
|
fallback_pane_priority: &mut Option<u32>,
|
||||||
pending_handlers: &mut Vec<panes::ViewActionHandler>,
|
pending_handlers: &mut Vec<panes::ViewActionHandler>,
|
||||||
theme: &Theme,
|
theme: &Theme,
|
||||||
document: &mut lightningbeam_core::document::Document,
|
document: &lightningbeam_core::document::Document,
|
||||||
|
selection: &mut lightningbeam_core::selection::Selection,
|
||||||
|
active_layer_id: &Option<Uuid>,
|
||||||
|
tool_state: &mut lightningbeam_core::tool::ToolState,
|
||||||
|
pending_actions: &mut Vec<Box<dyn lightningbeam_core::action::Action>>,
|
||||||
) {
|
) {
|
||||||
match node {
|
match node {
|
||||||
LayoutNode::Pane { name } => {
|
LayoutNode::Pane { name } => {
|
||||||
render_pane(ui, name, rect, selected_pane, layout_action, split_preview_mode, icon_cache, tool_icon_cache, selected_tool, fill_color, stroke_color, pane_instances, path, pending_view_action, fallback_pane_priority, pending_handlers, theme, document);
|
render_pane(ui, name, rect, selected_pane, layout_action, split_preview_mode, icon_cache, tool_icon_cache, selected_tool, fill_color, stroke_color, pane_instances, path, pending_view_action, fallback_pane_priority, pending_handlers, theme, document, selection, active_layer_id, tool_state, pending_actions);
|
||||||
}
|
}
|
||||||
LayoutNode::HorizontalGrid { percent, children } => {
|
LayoutNode::HorizontalGrid { percent, children } => {
|
||||||
// Handle dragging
|
// Handle dragging
|
||||||
|
|
@ -718,6 +750,10 @@ fn render_layout_node(
|
||||||
pending_handlers,
|
pending_handlers,
|
||||||
theme,
|
theme,
|
||||||
document,
|
document,
|
||||||
|
selection,
|
||||||
|
active_layer_id,
|
||||||
|
tool_state,
|
||||||
|
pending_actions,
|
||||||
);
|
);
|
||||||
|
|
||||||
let mut right_path = path.clone();
|
let mut right_path = path.clone();
|
||||||
|
|
@ -743,6 +779,10 @@ fn render_layout_node(
|
||||||
pending_handlers,
|
pending_handlers,
|
||||||
theme,
|
theme,
|
||||||
document,
|
document,
|
||||||
|
selection,
|
||||||
|
active_layer_id,
|
||||||
|
tool_state,
|
||||||
|
pending_actions,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Draw divider with interaction
|
// Draw divider with interaction
|
||||||
|
|
@ -860,6 +900,10 @@ fn render_layout_node(
|
||||||
pending_handlers,
|
pending_handlers,
|
||||||
theme,
|
theme,
|
||||||
document,
|
document,
|
||||||
|
selection,
|
||||||
|
active_layer_id,
|
||||||
|
tool_state,
|
||||||
|
pending_actions,
|
||||||
);
|
);
|
||||||
|
|
||||||
let mut bottom_path = path.clone();
|
let mut bottom_path = path.clone();
|
||||||
|
|
@ -885,6 +929,10 @@ fn render_layout_node(
|
||||||
pending_handlers,
|
pending_handlers,
|
||||||
theme,
|
theme,
|
||||||
document,
|
document,
|
||||||
|
selection,
|
||||||
|
active_layer_id,
|
||||||
|
tool_state,
|
||||||
|
pending_actions,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Draw divider with interaction
|
// Draw divider with interaction
|
||||||
|
|
@ -981,7 +1029,11 @@ fn render_pane(
|
||||||
fallback_pane_priority: &mut Option<u32>,
|
fallback_pane_priority: &mut Option<u32>,
|
||||||
pending_handlers: &mut Vec<panes::ViewActionHandler>,
|
pending_handlers: &mut Vec<panes::ViewActionHandler>,
|
||||||
theme: &Theme,
|
theme: &Theme,
|
||||||
document: &mut lightningbeam_core::document::Document,
|
document: &lightningbeam_core::document::Document,
|
||||||
|
selection: &mut lightningbeam_core::selection::Selection,
|
||||||
|
active_layer_id: &Option<Uuid>,
|
||||||
|
tool_state: &mut lightningbeam_core::tool::ToolState,
|
||||||
|
pending_actions: &mut Vec<Box<dyn lightningbeam_core::action::Action>>,
|
||||||
) {
|
) {
|
||||||
let pane_type = PaneType::from_name(pane_name);
|
let pane_type = PaneType::from_name(pane_name);
|
||||||
|
|
||||||
|
|
@ -1157,6 +1209,10 @@ fn render_pane(
|
||||||
theme,
|
theme,
|
||||||
pending_handlers,
|
pending_handlers,
|
||||||
document,
|
document,
|
||||||
|
selection,
|
||||||
|
active_layer_id,
|
||||||
|
tool_state,
|
||||||
|
pending_actions,
|
||||||
};
|
};
|
||||||
pane_instance.render_header(&mut header_ui, &mut shared);
|
pane_instance.render_header(&mut header_ui, &mut shared);
|
||||||
}
|
}
|
||||||
|
|
@ -1193,6 +1249,10 @@ fn render_pane(
|
||||||
theme,
|
theme,
|
||||||
pending_handlers,
|
pending_handlers,
|
||||||
document,
|
document,
|
||||||
|
selection,
|
||||||
|
active_layer_id,
|
||||||
|
tool_state,
|
||||||
|
pending_actions,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Render pane content (header was already rendered above)
|
// Render pane content (header was already rendered above)
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -43,8 +43,16 @@ pub struct SharedPaneState<'a> {
|
||||||
/// Registry of handlers for the current pending action
|
/// Registry of handlers for the current pending action
|
||||||
/// Panes register themselves here during render, execution happens after
|
/// Panes register themselves here during render, execution happens after
|
||||||
pub pending_handlers: &'a mut Vec<ViewActionHandler>,
|
pub pending_handlers: &'a mut Vec<ViewActionHandler>,
|
||||||
/// Active document being edited
|
/// Active document being edited (read-only, mutations go through actions)
|
||||||
pub document: &'a mut lightningbeam_core::document::Document,
|
pub document: &'a lightningbeam_core::document::Document,
|
||||||
|
/// Current selection state (mutable for tools to modify)
|
||||||
|
pub selection: &'a mut lightningbeam_core::selection::Selection,
|
||||||
|
/// Currently active layer ID
|
||||||
|
pub active_layer_id: &'a Option<uuid::Uuid>,
|
||||||
|
/// Current tool interaction state (mutable for tools to modify)
|
||||||
|
pub tool_state: &'a mut lightningbeam_core::tool::ToolState,
|
||||||
|
/// Actions to execute after rendering completes (two-phase dispatch)
|
||||||
|
pub pending_actions: &'a mut Vec<Box<dyn lightningbeam_core::action::Action>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Trait for pane rendering
|
/// Trait for pane rendering
|
||||||
|
|
|
||||||
|
|
@ -197,11 +197,25 @@ struct VelloCallback {
|
||||||
zoom: f32,
|
zoom: f32,
|
||||||
instance_id: u64,
|
instance_id: u64,
|
||||||
document: lightningbeam_core::document::Document,
|
document: lightningbeam_core::document::Document,
|
||||||
|
tool_state: lightningbeam_core::tool::ToolState,
|
||||||
|
active_layer_id: Option<uuid::Uuid>,
|
||||||
|
drag_delta: Option<vello::kurbo::Vec2>, // Delta for drag preview (world space)
|
||||||
|
selection: lightningbeam_core::selection::Selection,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl VelloCallback {
|
impl VelloCallback {
|
||||||
fn new(rect: egui::Rect, pan_offset: egui::Vec2, zoom: f32, instance_id: u64, document: lightningbeam_core::document::Document) -> Self {
|
fn new(
|
||||||
Self { rect, pan_offset, zoom, instance_id, document }
|
rect: egui::Rect,
|
||||||
|
pan_offset: egui::Vec2,
|
||||||
|
zoom: f32,
|
||||||
|
instance_id: u64,
|
||||||
|
document: lightningbeam_core::document::Document,
|
||||||
|
tool_state: lightningbeam_core::tool::ToolState,
|
||||||
|
active_layer_id: Option<uuid::Uuid>,
|
||||||
|
drag_delta: Option<vello::kurbo::Vec2>,
|
||||||
|
selection: lightningbeam_core::selection::Selection,
|
||||||
|
) -> Self {
|
||||||
|
Self { rect, pan_offset, zoom, instance_id, document, tool_state, active_layer_id, drag_delta, selection }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -260,6 +274,141 @@ impl egui_wgpu::CallbackTrait for VelloCallback {
|
||||||
// Render the document to the scene with camera transform
|
// Render the document to the scene with camera transform
|
||||||
lightningbeam_core::renderer::render_document_with_transform(&self.document, &mut scene, camera_transform);
|
lightningbeam_core::renderer::render_document_with_transform(&self.document, &mut scene, camera_transform);
|
||||||
|
|
||||||
|
// Render drag preview objects with transparency
|
||||||
|
if let (Some(delta), Some(active_layer_id)) = (self.drag_delta, self.active_layer_id) {
|
||||||
|
if let Some(layer) = self.document.get_layer(&active_layer_id) {
|
||||||
|
if let lightningbeam_core::layer::AnyLayer::Vector(vector_layer) = layer {
|
||||||
|
if let lightningbeam_core::tool::ToolState::DraggingSelection { ref original_positions, .. } = self.tool_state {
|
||||||
|
use vello::peniko::{Color, Fill, Brush};
|
||||||
|
|
||||||
|
// Render each object at its preview position (original + delta)
|
||||||
|
for (object_id, original_pos) in original_positions {
|
||||||
|
if let Some(_object) = vector_layer.get_object(object_id) {
|
||||||
|
if let Some(shape) = vector_layer.get_shape(&_object.shape_id) {
|
||||||
|
// New position = original + delta
|
||||||
|
let new_x = original_pos.x + delta.x;
|
||||||
|
let new_y = original_pos.y + delta.y;
|
||||||
|
|
||||||
|
// Build transform for preview position
|
||||||
|
let object_transform = Affine::translate((new_x, new_y));
|
||||||
|
let combined_transform = camera_transform * object_transform;
|
||||||
|
|
||||||
|
// Render shape with semi-transparent fill (light blue, 40% opacity)
|
||||||
|
let alpha_color = Color::rgba8(100, 150, 255, 100);
|
||||||
|
scene.fill(
|
||||||
|
Fill::NonZero,
|
||||||
|
combined_transform,
|
||||||
|
&Brush::Solid(alpha_color),
|
||||||
|
None,
|
||||||
|
shape.path(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render selection overlays (outlines, handles, marquee)
|
||||||
|
if let Some(active_layer_id) = self.active_layer_id {
|
||||||
|
if let Some(layer) = self.document.get_layer(&active_layer_id) {
|
||||||
|
if let lightningbeam_core::layer::AnyLayer::Vector(vector_layer) = layer {
|
||||||
|
use vello::peniko::{Color, Fill};
|
||||||
|
use vello::kurbo::{Circle, Rect as KurboRect, Shape as KurboShape, Stroke};
|
||||||
|
|
||||||
|
let selection_color = Color::rgb8(0, 120, 255); // Blue
|
||||||
|
let stroke_width = 2.0 / self.zoom.max(0.5) as f64;
|
||||||
|
|
||||||
|
// 1. Draw selection outlines around selected objects
|
||||||
|
if !self.selection.is_empty() {
|
||||||
|
for &object_id in self.selection.objects() {
|
||||||
|
if let Some(object) = vector_layer.get_object(&object_id) {
|
||||||
|
if let Some(shape) = vector_layer.get_shape(&object.shape_id) {
|
||||||
|
// Get shape bounding box
|
||||||
|
let bbox = shape.path().bounding_box();
|
||||||
|
|
||||||
|
// Apply object transform and camera transform
|
||||||
|
let object_transform = Affine::translate((object.transform.x, object.transform.y));
|
||||||
|
let combined_transform = camera_transform * object_transform;
|
||||||
|
|
||||||
|
// Create selection rectangle
|
||||||
|
let selection_rect = KurboRect::new(bbox.x0, bbox.y0, bbox.x1, bbox.y1);
|
||||||
|
|
||||||
|
// Draw selection outline
|
||||||
|
scene.stroke(
|
||||||
|
&Stroke::new(stroke_width),
|
||||||
|
combined_transform,
|
||||||
|
selection_color,
|
||||||
|
None,
|
||||||
|
&selection_rect,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Draw corner handles (4 circles at corners)
|
||||||
|
let handle_radius = (6.0 / self.zoom.max(0.5) as f64).max(4.0);
|
||||||
|
let corners = [
|
||||||
|
(bbox.x0, bbox.y0),
|
||||||
|
(bbox.x1, bbox.y0),
|
||||||
|
(bbox.x1, bbox.y1),
|
||||||
|
(bbox.x0, bbox.y1),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (x, y) in corners {
|
||||||
|
let corner_circle = Circle::new((x, y), handle_radius);
|
||||||
|
// Fill with blue
|
||||||
|
scene.fill(
|
||||||
|
Fill::NonZero,
|
||||||
|
combined_transform,
|
||||||
|
selection_color,
|
||||||
|
None,
|
||||||
|
&corner_circle,
|
||||||
|
);
|
||||||
|
// White outline
|
||||||
|
scene.stroke(
|
||||||
|
&Stroke::new(1.0),
|
||||||
|
combined_transform,
|
||||||
|
Color::rgb8(255, 255, 255),
|
||||||
|
None,
|
||||||
|
&corner_circle,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Draw marquee selection rectangle
|
||||||
|
if let lightningbeam_core::tool::ToolState::MarqueeSelecting { ref start, ref current } = self.tool_state {
|
||||||
|
let marquee_rect = KurboRect::new(
|
||||||
|
start.x.min(current.x),
|
||||||
|
start.y.min(current.y),
|
||||||
|
start.x.max(current.x),
|
||||||
|
start.y.max(current.y),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Semi-transparent fill
|
||||||
|
let marquee_fill = Color::rgba8(0, 120, 255, 100);
|
||||||
|
scene.fill(
|
||||||
|
Fill::NonZero,
|
||||||
|
camera_transform,
|
||||||
|
marquee_fill,
|
||||||
|
None,
|
||||||
|
&marquee_rect,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Border stroke
|
||||||
|
scene.stroke(
|
||||||
|
&Stroke::new(1.0),
|
||||||
|
camera_transform,
|
||||||
|
selection_color,
|
||||||
|
None,
|
||||||
|
&marquee_rect,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Render scene to texture using shared renderer
|
// Render scene to texture using shared renderer
|
||||||
if let Some(texture_view) = &instance_resources.texture_view {
|
if let Some(texture_view) = &instance_resources.texture_view {
|
||||||
let render_params = vello::RenderParams {
|
let render_params = vello::RenderParams {
|
||||||
|
|
@ -391,7 +540,174 @@ impl StagePane {
|
||||||
self.pan_offset = mouse_canvas_pos - (world_pos * new_zoom);
|
self.pan_offset = mouse_canvas_pos - (world_pos * new_zoom);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn handle_input(&mut self, ui: &mut egui::Ui, rect: egui::Rect) {
|
fn handle_select_tool(
|
||||||
|
&mut self,
|
||||||
|
ui: &mut egui::Ui,
|
||||||
|
response: &egui::Response,
|
||||||
|
world_pos: egui::Vec2,
|
||||||
|
shift_held: bool,
|
||||||
|
shared: &mut SharedPaneState,
|
||||||
|
) {
|
||||||
|
use lightningbeam_core::tool::ToolState;
|
||||||
|
use lightningbeam_core::layer::AnyLayer;
|
||||||
|
use lightningbeam_core::hit_test;
|
||||||
|
use vello::kurbo::{Point, Rect as KurboRect, Affine};
|
||||||
|
|
||||||
|
// Check if we have an active vector layer
|
||||||
|
let active_layer_id = match shared.active_layer_id {
|
||||||
|
Some(id) => id,
|
||||||
|
None => return, // No active layer
|
||||||
|
};
|
||||||
|
|
||||||
|
let active_layer = match shared.document.get_layer(active_layer_id) {
|
||||||
|
Some(layer) => layer,
|
||||||
|
None => return,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Only work on VectorLayer
|
||||||
|
let vector_layer = match active_layer {
|
||||||
|
AnyLayer::Vector(vl) => vl,
|
||||||
|
_ => return, // Not a vector layer
|
||||||
|
};
|
||||||
|
|
||||||
|
let point = Point::new(world_pos.x as f64, world_pos.y as f64);
|
||||||
|
|
||||||
|
// Mouse down: start interaction (use drag_started for immediate feedback)
|
||||||
|
if response.drag_started() || response.clicked() {
|
||||||
|
// Hit test at click position
|
||||||
|
let hit = hit_test::hit_test_layer(vector_layer, point, 5.0, Affine::IDENTITY);
|
||||||
|
|
||||||
|
if let Some(object_id) = hit {
|
||||||
|
// Object was hit
|
||||||
|
if shift_held {
|
||||||
|
// Shift: toggle selection
|
||||||
|
shared.selection.toggle_object(object_id);
|
||||||
|
} else {
|
||||||
|
// No shift: replace selection
|
||||||
|
if !shared.selection.contains_object(&object_id) {
|
||||||
|
shared.selection.select_only_object(object_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If object is now selected, prepare for dragging
|
||||||
|
if shared.selection.contains_object(&object_id) {
|
||||||
|
// Store original positions of all selected objects
|
||||||
|
let mut original_positions = std::collections::HashMap::new();
|
||||||
|
for &obj_id in shared.selection.objects() {
|
||||||
|
if let Some(obj) = vector_layer.get_object(&obj_id) {
|
||||||
|
original_positions.insert(
|
||||||
|
obj_id,
|
||||||
|
Point::new(obj.transform.x, obj.transform.y),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
*shared.tool_state = ToolState::DraggingSelection {
|
||||||
|
start_pos: point,
|
||||||
|
start_mouse: point,
|
||||||
|
original_positions,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Nothing hit - start marquee selection
|
||||||
|
if !shift_held {
|
||||||
|
shared.selection.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
*shared.tool_state = ToolState::MarqueeSelecting {
|
||||||
|
start: point,
|
||||||
|
current: point,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mouse drag: update tool state
|
||||||
|
if response.dragged() {
|
||||||
|
match shared.tool_state {
|
||||||
|
ToolState::DraggingSelection { .. } => {
|
||||||
|
// Update current position (visual feedback only)
|
||||||
|
// Actual move happens on mouse up
|
||||||
|
}
|
||||||
|
ToolState::MarqueeSelecting { start, .. } => {
|
||||||
|
// Update marquee rectangle
|
||||||
|
*shared.tool_state = ToolState::MarqueeSelecting {
|
||||||
|
start: *start,
|
||||||
|
current: point,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mouse up: finish interaction
|
||||||
|
if response.drag_stopped() || (ui.input(|i| i.pointer.any_released()) && matches!(shared.tool_state, ToolState::DraggingSelection { .. } | ToolState::MarqueeSelecting { .. })) {
|
||||||
|
match shared.tool_state.clone() {
|
||||||
|
ToolState::DraggingSelection { start_mouse, original_positions, .. } => {
|
||||||
|
// Calculate total delta
|
||||||
|
let delta = point - start_mouse;
|
||||||
|
|
||||||
|
if delta.x.abs() > 0.01 || delta.y.abs() > 0.01 {
|
||||||
|
// Create move action with new positions
|
||||||
|
use std::collections::HashMap;
|
||||||
|
let mut object_positions = HashMap::new();
|
||||||
|
|
||||||
|
for (object_id, original_pos) in original_positions {
|
||||||
|
let new_pos = Point::new(
|
||||||
|
original_pos.x + delta.x,
|
||||||
|
original_pos.y + delta.y,
|
||||||
|
);
|
||||||
|
object_positions.insert(object_id, (original_pos, new_pos));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create and submit the action
|
||||||
|
use lightningbeam_core::actions::MoveObjectsAction;
|
||||||
|
let action = MoveObjectsAction::new(*active_layer_id, object_positions);
|
||||||
|
shared.pending_actions.push(Box::new(action));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset tool state
|
||||||
|
*shared.tool_state = ToolState::Idle;
|
||||||
|
}
|
||||||
|
ToolState::MarqueeSelecting { start, current } => {
|
||||||
|
// Create selection rectangle
|
||||||
|
let min_x = start.x.min(current.x);
|
||||||
|
let min_y = start.y.min(current.y);
|
||||||
|
let max_x = start.x.max(current.x);
|
||||||
|
let max_y = start.y.max(current.y);
|
||||||
|
|
||||||
|
let selection_rect = KurboRect::new(min_x, min_y, max_x, max_y);
|
||||||
|
|
||||||
|
// Hit test all objects in rectangle
|
||||||
|
let hits = hit_test::hit_test_objects_in_rect(
|
||||||
|
vector_layer,
|
||||||
|
selection_rect,
|
||||||
|
Affine::IDENTITY,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Add to selection
|
||||||
|
for obj_id in hits {
|
||||||
|
if shift_held {
|
||||||
|
shared.selection.add_object(obj_id);
|
||||||
|
} else {
|
||||||
|
// First hit replaces selection
|
||||||
|
if shared.selection.is_empty() {
|
||||||
|
shared.selection.add_object(obj_id);
|
||||||
|
} else {
|
||||||
|
// Subsequent hits add to selection
|
||||||
|
shared.selection.add_object(obj_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset tool state
|
||||||
|
*shared.tool_state = ToolState::Idle;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_input(&mut self, ui: &mut egui::Ui, rect: egui::Rect, shared: &mut SharedPaneState) {
|
||||||
let response = ui.allocate_rect(rect, egui::Sense::click_and_drag());
|
let response = ui.allocate_rect(rect, egui::Sense::click_and_drag());
|
||||||
|
|
||||||
// Only process input if mouse is over the stage pane
|
// Only process input if mouse is over the stage pane
|
||||||
|
|
@ -404,11 +720,29 @@ impl StagePane {
|
||||||
let scroll_delta = ui.input(|i| i.smooth_scroll_delta);
|
let scroll_delta = ui.input(|i| i.smooth_scroll_delta);
|
||||||
let alt_held = ui.input(|i| i.modifiers.alt);
|
let alt_held = ui.input(|i| i.modifiers.alt);
|
||||||
let ctrl_held = ui.input(|i| i.modifiers.ctrl || i.modifiers.command);
|
let ctrl_held = ui.input(|i| i.modifiers.ctrl || i.modifiers.command);
|
||||||
|
let shift_held = ui.input(|i| i.modifiers.shift);
|
||||||
|
|
||||||
// Get mouse position for zoom-to-cursor
|
// Get mouse position for zoom-to-cursor
|
||||||
let mouse_pos = response.hover_pos().unwrap_or(rect.center());
|
let mouse_pos = response.hover_pos().unwrap_or(rect.center());
|
||||||
let mouse_canvas_pos = mouse_pos - rect.min;
|
let mouse_canvas_pos = mouse_pos - rect.min;
|
||||||
|
|
||||||
|
// Convert screen position to world position (accounting for pan and zoom)
|
||||||
|
let world_pos = (mouse_canvas_pos - self.pan_offset) / self.zoom;
|
||||||
|
|
||||||
|
// Handle tool input (only if not using Alt modifier for panning)
|
||||||
|
if !alt_held {
|
||||||
|
use lightningbeam_core::tool::Tool;
|
||||||
|
|
||||||
|
match *shared.selected_tool {
|
||||||
|
Tool::Select => {
|
||||||
|
self.handle_select_tool(ui, &response, world_pos, shift_held, shared);
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
// Other tools not implemented yet
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Distinguish between mouse wheel (discrete) and trackpad (smooth)
|
// Distinguish between mouse wheel (discrete) and trackpad (smooth)
|
||||||
let mut handled = false;
|
let mut handled = false;
|
||||||
ui.input(|i| {
|
ui.input(|i| {
|
||||||
|
|
@ -463,6 +797,7 @@ impl StagePane {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PaneRenderer for StagePane {
|
impl PaneRenderer for StagePane {
|
||||||
|
|
@ -473,8 +808,8 @@ impl PaneRenderer for StagePane {
|
||||||
_path: &NodePath,
|
_path: &NodePath,
|
||||||
shared: &mut SharedPaneState,
|
shared: &mut SharedPaneState,
|
||||||
) {
|
) {
|
||||||
// Handle input for pan/zoom controls
|
// Handle input for pan/zoom and tool controls
|
||||||
self.handle_input(ui, rect);
|
self.handle_input(ui, rect, shared);
|
||||||
|
|
||||||
// Register handler for pending view actions (two-phase dispatch)
|
// Register handler for pending view actions (two-phase dispatch)
|
||||||
// Priority: Mouse-over (0-99) > Fallback Stage(1000) > Fallback Timeline(1001) etc.
|
// Priority: Mouse-over (0-99) > Fallback Stage(1000) > Fallback Timeline(1001) etc.
|
||||||
|
|
@ -530,8 +865,36 @@ impl PaneRenderer for StagePane {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Calculate drag delta for preview rendering (world space)
|
||||||
|
let drag_delta = if let lightningbeam_core::tool::ToolState::DraggingSelection { ref start_mouse, .. } = shared.tool_state {
|
||||||
|
// Get current mouse position in world coordinates
|
||||||
|
if let Some(mouse_pos) = ui.input(|i| i.pointer.hover_pos()) {
|
||||||
|
let mouse_canvas_pos = mouse_pos - rect.min;
|
||||||
|
let world_mouse = (mouse_canvas_pos - self.pan_offset) / self.zoom;
|
||||||
|
|
||||||
|
let delta_x = world_mouse.x as f64 - start_mouse.x;
|
||||||
|
let delta_y = world_mouse.y as f64 - start_mouse.y;
|
||||||
|
|
||||||
|
Some(vello::kurbo::Vec2::new(delta_x, delta_y))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
// Use egui's custom painting callback for Vello
|
// Use egui's custom painting callback for Vello
|
||||||
let callback = VelloCallback::new(rect, self.pan_offset, self.zoom, self.instance_id, shared.document.clone());
|
let callback = VelloCallback::new(
|
||||||
|
rect,
|
||||||
|
self.pan_offset,
|
||||||
|
self.zoom,
|
||||||
|
self.instance_id,
|
||||||
|
shared.document.clone(),
|
||||||
|
shared.tool_state.clone(),
|
||||||
|
*shared.active_layer_id,
|
||||||
|
drag_delta,
|
||||||
|
shared.selection.clone(),
|
||||||
|
);
|
||||||
|
|
||||||
let cb = egui_wgpu::Callback::new_paint_callback(
|
let cb = egui_wgpu::Callback::new_paint_callback(
|
||||||
rect,
|
rect,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue