initial vector editing

This commit is contained in:
Skyler Lehmkuhl 2025-12-22 18:34:01 -05:00
parent 2dea1eab9e
commit ffb53884b0
8 changed files with 1748 additions and 6 deletions

View File

@ -7,6 +7,7 @@ pub mod add_clip_instance;
pub mod add_effect; pub mod add_effect;
pub mod add_layer; pub mod add_layer;
pub mod add_shape; pub mod add_shape;
pub mod modify_shape_path;
pub mod move_clip_instances; pub mod move_clip_instances;
pub mod move_objects; pub mod move_objects;
pub mod paint_bucket; pub mod paint_bucket;
@ -24,6 +25,7 @@ pub use add_clip_instance::AddClipInstanceAction;
pub use add_effect::AddEffectAction; pub use add_effect::AddEffectAction;
pub use add_layer::AddLayerAction; pub use add_layer::AddLayerAction;
pub use add_shape::AddShapeAction; pub use add_shape::AddShapeAction;
pub use modify_shape_path::ModifyShapePathAction;
pub use move_clip_instances::MoveClipInstancesAction; pub use move_clip_instances::MoveClipInstancesAction;
pub use move_objects::MoveShapeInstancesAction; pub use move_objects::MoveShapeInstancesAction;
pub use paint_bucket::PaintBucketAction; pub use paint_bucket::PaintBucketAction;

View File

@ -0,0 +1,225 @@
//! Modify shape path action
//!
//! Handles modifying a shape's bezier path (for vector editing operations)
//! with undo/redo support.
use crate::action::Action;
use crate::document::Document;
use crate::layer::AnyLayer;
use uuid::Uuid;
use vello::kurbo::BezPath;
/// Action that modifies a shape's path
///
/// This action is used for vector editing operations like dragging vertices,
/// reshaping curves, or manipulating control points.
pub struct ModifyShapePathAction {
/// Layer containing the shape
layer_id: Uuid,
/// Shape to modify
shape_id: Uuid,
/// The version index being modified (for shapes with multiple versions)
version_index: usize,
/// New path
new_path: BezPath,
/// Old path (stored after first execution for undo)
old_path: Option<BezPath>,
}
impl ModifyShapePathAction {
/// Create a new action to modify a shape's path
///
/// # Arguments
///
/// * `layer_id` - The layer containing the shape
/// * `shape_id` - The shape to modify
/// * `version_index` - The version index to modify (usually 0)
/// * `new_path` - The new path to set
pub fn new(layer_id: Uuid, shape_id: Uuid, version_index: usize, new_path: BezPath) -> Self {
Self {
layer_id,
shape_id,
version_index,
new_path,
old_path: None,
}
}
/// Create action with old path already known (for optimization)
pub fn with_old_path(
layer_id: Uuid,
shape_id: Uuid,
version_index: usize,
old_path: BezPath,
new_path: BezPath,
) -> Self {
Self {
layer_id,
shape_id,
version_index,
new_path,
old_path: Some(old_path),
}
}
}
impl Action for ModifyShapePathAction {
fn execute(&mut self, document: &mut Document) -> Result<(), String> {
if let Some(layer) = document.get_layer_mut(&self.layer_id) {
if let AnyLayer::Vector(vector_layer) = layer {
if let Some(shape) = vector_layer.shapes.get_mut(&self.shape_id) {
// Check if version exists
if self.version_index >= shape.versions.len() {
return Err(format!(
"Version index {} out of bounds (shape has {} versions)",
self.version_index,
shape.versions.len()
));
}
// Store old path if not already stored
if self.old_path.is_none() {
self.old_path = Some(shape.versions[self.version_index].path.clone());
}
// Apply new path
shape.versions[self.version_index].path = self.new_path.clone();
return Ok(());
}
}
}
Err(format!(
"Could not find shape {} in layer {}",
self.shape_id, self.layer_id
))
}
fn rollback(&mut self, document: &mut Document) -> Result<(), String> {
if let Some(old_path) = &self.old_path {
if let Some(layer) = document.get_layer_mut(&self.layer_id) {
if let AnyLayer::Vector(vector_layer) = layer {
if let Some(shape) = vector_layer.shapes.get_mut(&self.shape_id) {
if self.version_index < shape.versions.len() {
shape.versions[self.version_index].path = old_path.clone();
return Ok(());
}
}
}
}
}
Err(format!(
"Could not rollback shape path modification for shape {} in layer {}",
self.shape_id, self.layer_id
))
}
fn description(&self) -> String {
"Modify shape path".to_string()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::layer::VectorLayer;
use crate::shape::Shape;
fn create_test_path() -> BezPath {
let mut path = BezPath::new();
path.move_to((0.0, 0.0));
path.line_to((100.0, 0.0));
path.line_to((100.0, 100.0));
path.line_to((0.0, 100.0));
path.close_path();
path
}
fn create_modified_path() -> BezPath {
let mut path = BezPath::new();
path.move_to((0.0, 0.0));
path.line_to((150.0, 0.0)); // Modified
path.line_to((150.0, 150.0)); // Modified
path.line_to((0.0, 150.0)); // Modified
path.close_path();
path
}
#[test]
fn test_modify_shape_path() {
let mut document = Document::new("Test");
let mut layer = VectorLayer::new("Test Layer");
let shape = Shape::new(create_test_path());
let shape_id = shape.id;
layer.shapes.insert(shape_id, shape);
let layer_id = document.root_mut().add_child(AnyLayer::Vector(layer));
// Verify initial path
if let Some(AnyLayer::Vector(vl)) = document.get_layer_mut(&layer_id) {
let shape = vl.shapes.get(&shape_id).unwrap();
let bbox = shape.versions[0].path.bounding_box();
assert_eq!(bbox.width(), 100.0);
assert_eq!(bbox.height(), 100.0);
}
// Create and execute action
let new_path = create_modified_path();
let mut action = ModifyShapePathAction::new(layer_id, shape_id, 0, new_path);
action.execute(&mut document).unwrap();
// Verify path changed
if let Some(AnyLayer::Vector(vl)) = document.get_layer_mut(&layer_id) {
let shape = vl.shapes.get(&shape_id).unwrap();
let bbox = shape.versions[0].path.bounding_box();
assert_eq!(bbox.width(), 150.0);
assert_eq!(bbox.height(), 150.0);
}
// Rollback
action.rollback(&mut document).unwrap();
// Verify restored
if let Some(AnyLayer::Vector(vl)) = document.get_layer_mut(&layer_id) {
let shape = vl.shapes.get(&shape_id).unwrap();
let bbox = shape.versions[0].path.bounding_box();
assert_eq!(bbox.width(), 100.0);
assert_eq!(bbox.height(), 100.0);
}
}
#[test]
fn test_invalid_version_index() {
let mut document = Document::new("Test");
let mut layer = VectorLayer::new("Test Layer");
let shape = Shape::new(create_test_path());
let shape_id = shape.id;
layer.shapes.insert(shape_id, shape);
let layer_id = document.root_mut().add_child(AnyLayer::Vector(layer));
// Try to modify non-existent version
let new_path = create_modified_path();
let mut action = ModifyShapePathAction::new(layer_id, shape_id, 5, new_path);
let result = action.execute(&mut document);
assert!(result.is_err());
assert!(result.unwrap_err().contains("out of bounds"));
}
#[test]
fn test_description() {
let layer_id = Uuid::new_v4();
let shape_id = Uuid::new_v4();
let action = ModifyShapePathAction::new(layer_id, shape_id, 0, create_test_path());
assert_eq!(action.description(), "Modify shape path");
}
}

View File

@ -0,0 +1,104 @@
//! Bezier vertex and editable curves structures for vector editing
//!
//! Provides data structures for editing bezier paths by extracting
//! vertices (where curves meet) and individual curve segments.
use vello::kurbo::{CubicBez, Point};
/// A vertex in a shape path where curve segments meet
///
/// Vertices are automatically generated from curve endpoints, with nearby
/// endpoints merged together (within VERTEX_MERGE_EPSILON). This allows
/// dragging a single vertex to update all connected curves simultaneously.
#[derive(Debug, Clone)]
pub struct BezierVertex {
/// The point location in local shape space
pub point: Point,
/// Indices of curves that start at this vertex
/// (i.e., curves where p0 == this point)
pub start_curves: Vec<usize>,
/// Indices of curves that end at this vertex
/// (i.e., curves where p3 == this point)
pub end_curves: Vec<usize>,
}
impl BezierVertex {
/// Create a new vertex at the given point
pub fn new(point: Point) -> Self {
Self {
point,
start_curves: Vec::new(),
end_curves: Vec::new(),
}
}
/// Check if this vertex connects to any curves
pub fn is_connected(&self) -> bool {
!self.start_curves.is_empty() || !self.end_curves.is_empty()
}
/// Get total number of curves connected to this vertex
pub fn connection_count(&self) -> usize {
self.start_curves.len() + self.end_curves.len()
}
}
/// Extracted editable bezier curve segments from a BezPath
///
/// This structure represents a BezPath converted into an editable form,
/// with explicit curve segments and auto-generated vertices. This allows
/// for vertex-based and curve-based editing operations.
#[derive(Debug, Clone)]
pub struct EditableBezierCurves {
/// All cubic bezier curves extracted from the path
///
/// All path elements (lines, quadratics, etc.) are converted to cubic beziers
/// for uniform editing. Each CubicBez has four control points: p0, p1, p2, p3.
pub curves: Vec<CubicBez>,
/// Auto-generated vertices from curve endpoints
///
/// Vertices are created by merging nearby endpoints (within epsilon tolerance).
/// Each vertex tracks which curves connect to it via start_curves and end_curves.
pub vertices: Vec<BezierVertex>,
/// Whether the path is closed
///
/// A path is considered closed if the first curve's p0 is within epsilon
/// of the last curve's p3.
pub is_closed: bool,
}
impl EditableBezierCurves {
/// Create a new empty editable curves structure
pub fn new() -> Self {
Self {
curves: Vec::new(),
vertices: Vec::new(),
is_closed: false,
}
}
/// Get the number of curves
pub fn curve_count(&self) -> usize {
self.curves.len()
}
/// Get the number of vertices
pub fn vertex_count(&self) -> usize {
self.vertices.len()
}
/// Check if the structure is empty (no curves)
pub fn is_empty(&self) -> bool {
self.curves.is_empty()
}
}
impl Default for EditableBezierCurves {
fn default() -> Self {
Self::new()
}
}

View File

@ -0,0 +1,375 @@
//! BezPath editing utilities for vector shape manipulation
//!
//! Provides functions to convert BezPath to/from editable bezier curves,
//! generate vertices, and implement curve manipulation algorithms like moldCurve.
use crate::bezier_vertex::{BezierVertex, EditableBezierCurves};
use vello::kurbo::{BezPath, CubicBez, ParamCurve, ParamCurveNearest, PathEl, Point};
/// Tolerance for merging nearby vertices (in pixels)
pub const VERTEX_MERGE_EPSILON: f64 = 1.5;
/// Default epsilon for moldCurve numerical differentiation
const MOLD_CURVE_EPSILON: f64 = 0.01;
/// Extract editable curves and vertices from a BezPath
///
/// Converts all path elements to cubic bezier curves and generates vertices
/// by merging nearby endpoints. This creates a structure suitable for
/// vertex and curve editing operations.
///
/// # Arguments
///
/// * `path` - The BezPath to extract from
///
/// # Returns
///
/// EditableBezierCurves containing curves, vertices, and closure status
pub fn extract_editable_curves(path: &BezPath) -> EditableBezierCurves {
let mut curves = Vec::new();
let mut current_point = Point::ZERO;
let mut start_point = Point::ZERO;
let mut first_point_set = false;
for el in path.elements() {
match el {
PathEl::MoveTo(p) => {
current_point = *p;
start_point = *p;
first_point_set = true;
}
PathEl::LineTo(p) => {
if first_point_set {
curves.push(line_to_cubic(current_point, *p));
current_point = *p;
}
}
PathEl::QuadTo(p1, p2) => {
if first_point_set {
curves.push(quad_to_cubic(current_point, *p1, *p2));
current_point = *p2;
}
}
PathEl::CurveTo(p1, p2, p3) => {
if first_point_set {
curves.push(CubicBez::new(current_point, *p1, *p2, *p3));
current_point = *p3;
}
}
PathEl::ClosePath => {
// Add closing line if needed
if first_point_set && (current_point - start_point).hypot() > 1e-6 {
curves.push(line_to_cubic(current_point, start_point));
current_point = start_point;
}
}
}
}
let vertices = generate_vertices(&curves);
let is_closed = !curves.is_empty()
&& (curves[0].p0 - curves.last().unwrap().p3).hypot() < VERTEX_MERGE_EPSILON;
EditableBezierCurves {
curves,
vertices,
is_closed,
}
}
/// Rebuild a BezPath from editable curves
///
/// Converts the editable curve structure back into a BezPath for rendering.
///
/// # Arguments
///
/// * `editable` - The editable curves structure
///
/// # Returns
///
/// A BezPath ready for rendering
pub fn rebuild_bezpath(editable: &EditableBezierCurves) -> BezPath {
let mut path = BezPath::new();
if editable.curves.is_empty() {
return path;
}
path.move_to(editable.curves[0].p0);
for curve in &editable.curves {
path.curve_to(curve.p1, curve.p2, curve.p3);
}
if editable.is_closed {
path.close_path();
}
path
}
/// Convert a line segment to a cubic bezier curve
///
/// Places control points at 1/3 and 2/3 along the line so the cubic
/// bezier exactly represents the straight line.
fn line_to_cubic(p0: Point, p3: Point) -> CubicBez {
let p1 = Point::new(p0.x + (p3.x - p0.x) / 3.0, p0.y + (p3.y - p0.y) / 3.0);
let p2 = Point::new(
p0.x + 2.0 * (p3.x - p0.x) / 3.0,
p0.y + 2.0 * (p3.y - p0.y) / 3.0,
);
CubicBez::new(p0, p1, p2, p3)
}
/// Convert a quadratic bezier to a cubic bezier
///
/// Uses the standard quadratic-to-cubic conversion formula.
fn quad_to_cubic(p0: Point, p1: Point, p2: Point) -> CubicBez {
// Standard quadratic to cubic conversion formula
let c1 = Point::new(
p0.x + 2.0 * (p1.x - p0.x) / 3.0,
p0.y + 2.0 * (p1.y - p0.y) / 3.0,
);
let c2 = Point::new(
p2.x + 2.0 * (p1.x - p2.x) / 3.0,
p2.y + 2.0 * (p1.y - p2.y) / 3.0,
);
CubicBez::new(p0, c1, c2, p2)
}
/// Generate vertices from curve endpoints
///
/// Creates vertices by merging nearby endpoints (within VERTEX_MERGE_EPSILON).
/// Each vertex tracks which curves start and end at that point.
///
/// # Arguments
///
/// * `curves` - The array of cubic bezier curves
///
/// # Returns
///
/// A vector of BezierVertex structs with connection information
fn generate_vertices(curves: &[CubicBez]) -> Vec<BezierVertex> {
let mut vertices = Vec::new();
for (i, curve) in curves.iter().enumerate() {
// Process start point (p0)
add_or_merge_vertex(&mut vertices, curve.p0, i, true);
// Process end point (p3)
add_or_merge_vertex(&mut vertices, curve.p3, i, false);
}
vertices
}
/// Add a point as a new vertex or merge with existing nearby vertex
///
/// If a vertex already exists within VERTEX_MERGE_EPSILON, the curve
/// is added to that vertex's connection list. Otherwise, a new vertex
/// is created.
fn add_or_merge_vertex(
vertices: &mut Vec<BezierVertex>,
point: Point,
curve_index: usize,
is_start: bool,
) {
// Check if a vertex already exists at this point (within epsilon)
for vertex in vertices.iter_mut() {
let dist = (vertex.point - point).hypot();
if dist < VERTEX_MERGE_EPSILON {
// Merge with existing vertex
if is_start {
if !vertex.start_curves.contains(&curve_index) {
vertex.start_curves.push(curve_index);
}
} else {
if !vertex.end_curves.contains(&curve_index) {
vertex.end_curves.push(curve_index);
}
}
return;
}
}
// Create new vertex
let mut vertex = BezierVertex::new(point);
if is_start {
vertex.start_curves.push(curve_index);
} else {
vertex.end_curves.push(curve_index);
}
vertices.push(vertex);
}
/// Reshape a cubic bezier curve by dragging a point on it (moldCurve algorithm)
///
/// This uses numerical differentiation to calculate how the control points
/// should move to make the curve pass through the mouse position while keeping
/// endpoints fixed. The algorithm is based on the JavaScript UI implementation.
///
/// # Algorithm
///
/// 1. Project old_mouse onto the curve to find the grab parameter t
/// 2. Create offset curves by nudging each control point by epsilon
/// 3. Evaluate offset curves at parameter t to get derivatives
/// 4. Calculate control point adjustments weighted by t
/// 5. Return curve with adjusted control points and same endpoints
///
/// # Arguments
///
/// * `curve` - The original curve
/// * `mouse` - The target position (where we want the curve to go)
/// * `old_mouse` - The starting position (where the drag started)
/// * `epsilon` - Step size for numerical differentiation (optional)
///
/// # Returns
///
/// A new CubicBez with adjusted control points
///
/// # Reference
///
/// Based on `src/main.js` lines 551-602 in the JavaScript UI
pub fn mold_curve(curve: &CubicBez, mouse: &Point, old_mouse: &Point) -> CubicBez {
mold_curve_with_epsilon(curve, mouse, old_mouse, MOLD_CURVE_EPSILON)
}
/// Mold curve with custom epsilon (for testing or fine-tuning)
pub fn mold_curve_with_epsilon(
curve: &CubicBez,
mouse: &Point,
old_mouse: &Point,
epsilon: f64,
) -> CubicBez {
// Step 1: Find the closest point on the curve to old_mouse
let nearest = curve.nearest(*old_mouse, 1e-6);
let t = nearest.t;
let projection = curve.eval(t);
// Step 2: Create offset curves by moving each control point by epsilon
let offset_p1 = Point::new(curve.p1.x + epsilon, curve.p1.y + epsilon);
let offset_p2 = Point::new(curve.p2.x + epsilon, curve.p2.y + epsilon);
let offset_curve_p1 = CubicBez::new(curve.p0, offset_p1, curve.p2, curve.p3);
let offset_curve_p2 = CubicBez::new(curve.p0, curve.p1, offset_p2, curve.p3);
// Step 3: Evaluate offset curves at parameter t
let offset1 = offset_curve_p1.eval(t);
let offset2 = offset_curve_p2.eval(t);
// Step 4: Calculate derivatives (numerical differentiation)
let derivative_p1_x = (offset1.x - projection.x) / epsilon;
let derivative_p1_y = (offset1.y - projection.y) / epsilon;
let derivative_p2_x = (offset2.x - projection.x) / epsilon;
let derivative_p2_y = (offset2.y - projection.y) / epsilon;
// Step 5: Calculate how much to move control points
let delta_x = mouse.x - projection.x;
let delta_y = mouse.y - projection.y;
// Weight by parameter t: p1 affects curve more at t=0, p2 more at t=1
let weight_p1 = 1.0 - t * t; // Stronger near start
let weight_p2 = t * t; // Stronger near end
// Avoid division by zero
let adjust_p1_x = if derivative_p1_x.abs() > 1e-10 {
(delta_x / derivative_p1_x) * weight_p1
} else {
0.0
};
let adjust_p1_y = if derivative_p1_y.abs() > 1e-10 {
(delta_y / derivative_p1_y) * weight_p1
} else {
0.0
};
let adjust_p2_x = if derivative_p2_x.abs() > 1e-10 {
(delta_x / derivative_p2_x) * weight_p2
} else {
0.0
};
let adjust_p2_y = if derivative_p2_y.abs() > 1e-10 {
(delta_y / derivative_p2_y) * weight_p2
} else {
0.0
};
let new_p1 = Point::new(curve.p1.x + adjust_p1_x, curve.p1.y + adjust_p1_y);
let new_p2 = Point::new(curve.p2.x + adjust_p2_x, curve.p2.y + adjust_p2_y);
// Return updated curve with same endpoints
CubicBez::new(curve.p0, new_p1, new_p2, curve.p3)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_line_to_cubic() {
let p0 = Point::new(0.0, 0.0);
let p3 = Point::new(100.0, 100.0);
let cubic = line_to_cubic(p0, p3);
// Check endpoints
assert_eq!(cubic.p0, p0);
assert_eq!(cubic.p3, p3);
// Check that control points are collinear (on the line)
// Middle of line should be at (50, 50)
let mid = cubic.eval(0.5);
assert!((mid.x - 50.0).abs() < 0.01);
assert!((mid.y - 50.0).abs() < 0.01);
}
#[test]
fn test_extract_and_rebuild_bezpath() {
let mut path = BezPath::new();
path.move_to((0.0, 0.0));
path.line_to((100.0, 0.0));
path.line_to((100.0, 100.0));
path.line_to((0.0, 100.0));
path.close_path();
let editable = extract_editable_curves(&path);
assert_eq!(editable.curves.len(), 4); // 4 line segments
assert!(editable.is_closed);
let rebuilt = rebuild_bezpath(&editable);
// Rebuilt path should have same shape
assert!(!rebuilt.is_empty());
}
#[test]
fn test_vertex_generation() {
let curves = vec![
CubicBez::new(
Point::new(0.0, 0.0),
Point::new(33.0, 0.0),
Point::new(66.0, 0.0),
Point::new(100.0, 0.0),
),
CubicBez::new(
Point::new(100.0, 0.0),
Point::new(100.0, 33.0),
Point::new(100.0, 66.0),
Point::new(100.0, 100.0),
),
];
let vertices = generate_vertices(&curves);
// Should have 3 vertices: start of curve 0, junction, end of curve 1
assert_eq!(vertices.len(), 3);
// Middle vertex should connect both curves
let middle_vertex = vertices.iter().find(|v| {
let dist = (v.point - Point::new(100.0, 0.0)).hypot();
dist < 1.0
});
assert!(middle_vertex.is_some());
let middle = middle_vertex.unwrap();
assert_eq!(middle.end_curves.len(), 1); // End of curve 0
assert_eq!(middle.start_curves.len(), 1); // Start of curve 1
}
}

View File

@ -344,6 +344,175 @@ pub fn hit_test_clip_instances_in_rect(
hits hits
} }
/// Result of a vector editing hit test
///
/// Represents different types of hits in order of priority:
/// ControlPoint > Vertex > Curve > Fill
#[derive(Debug, Clone, Copy)]
pub enum VectorEditHit {
/// Hit a control point (BezierEdit tool only)
ControlPoint {
shape_instance_id: Uuid,
curve_index: usize,
point_index: u8, // 1 or 2 (p1 or p2 of cubic bezier)
},
/// Hit a vertex (anchor point)
Vertex {
shape_instance_id: Uuid,
vertex_index: usize,
},
/// Hit a curve segment
Curve {
shape_instance_id: Uuid,
curve_index: usize,
parameter_t: f64, // Where on the curve (0.0 to 1.0)
},
/// Hit shape fill
Fill { shape_instance_id: Uuid },
}
/// Tolerances for vector editing hit testing (in screen pixels)
#[derive(Debug, Clone, Copy)]
pub struct EditingHitTolerance {
/// Tolerance for hitting control points
pub control_point: f64,
/// Tolerance for hitting vertices
pub vertex: f64,
/// Tolerance for hitting curves
pub curve: f64,
/// Tolerance for hitting fill (usually 0.0 for exact containment)
pub fill: f64,
}
impl Default for EditingHitTolerance {
fn default() -> Self {
Self {
control_point: 10.0,
vertex: 15.0,
curve: 15.0,
fill: 0.0,
}
}
}
impl EditingHitTolerance {
/// Create tolerances scaled by zoom factor
///
/// When zoomed in, hit targets appear larger in screen pixels,
/// so we divide by zoom to maintain consistent screen-space sizes.
pub fn scaled_by_zoom(zoom: f64) -> Self {
Self {
control_point: 10.0 / zoom,
vertex: 15.0 / zoom,
curve: 15.0 / zoom,
fill: 0.0,
}
}
}
/// Hit test for vector editing with priority-based detection
///
/// Tests objects in reverse order (front to back) and returns the first hit.
/// Priority order: Control points > Vertices > Curves > Fill
///
/// # Arguments
///
/// * `layer` - The vector layer to test
/// * `point` - The point to test in screen/canvas space
/// * `tolerance` - Hit tolerances for different element types
/// * `parent_transform` - Transform from parent GraphicsObject(s)
/// * `show_control_points` - Whether to test control points (BezierEdit tool)
///
/// # Returns
///
/// The first hit in priority order, or None if no hit
pub fn hit_test_vector_editing(
layer: &VectorLayer,
point: Point,
tolerance: &EditingHitTolerance,
parent_transform: Affine,
show_control_points: bool,
) -> Option<VectorEditHit> {
use crate::bezpath_editing::extract_editable_curves;
use vello::kurbo::{ParamCurve, ParamCurveNearest};
// Test objects in reverse order (front to back for hit testing)
for object in layer.shape_instances.iter().rev() {
// Get the shape for this object
let shape = match layer.get_shape(&object.shape_id) {
Some(s) => s,
None => continue,
};
// Combine parent transform with object transform
let combined_transform = parent_transform * object.to_affine();
let inverse_transform = combined_transform.inverse();
let local_point = inverse_transform * point;
// Extract editable curves and vertices from the shape's path
let editable = extract_editable_curves(shape.path());
// Priority 1: Control points (only in BezierEdit mode)
if show_control_points {
for (i, curve) in editable.curves.iter().enumerate() {
// Test p1 (first control point)
let dist_p1 = (curve.p1 - local_point).hypot();
if dist_p1 < tolerance.control_point {
return Some(VectorEditHit::ControlPoint {
shape_instance_id: object.id,
curve_index: i,
point_index: 1,
});
}
// Test p2 (second control point)
let dist_p2 = (curve.p2 - local_point).hypot();
if dist_p2 < tolerance.control_point {
return Some(VectorEditHit::ControlPoint {
shape_instance_id: object.id,
curve_index: i,
point_index: 2,
});
}
}
}
// Priority 2: Vertices (anchor points)
for (i, vertex) in editable.vertices.iter().enumerate() {
let dist = (vertex.point - local_point).hypot();
if dist < tolerance.vertex {
return Some(VectorEditHit::Vertex {
shape_instance_id: object.id,
vertex_index: i,
});
}
}
// Priority 3: Curves
for (i, curve) in editable.curves.iter().enumerate() {
let nearest = curve.nearest(local_point, 1e-6);
let nearest_point = curve.eval(nearest.t);
let dist = (nearest_point - local_point).hypot();
if dist < tolerance.curve {
return Some(VectorEditHit::Curve {
shape_instance_id: object.id,
curve_index: i,
parameter_t: nearest.t,
});
}
}
// Priority 4: Fill
if shape.fill_color.is_some() && shape.path().contains(local_point) {
return Some(VectorEditHit::Fill {
shape_instance_id: object.id,
});
}
}
None
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;

View File

@ -9,6 +9,8 @@ pub mod animation;
pub mod path_interpolation; pub mod path_interpolation;
pub mod path_fitting; pub mod path_fitting;
pub mod shape; pub mod shape;
pub mod bezier_vertex;
pub mod bezpath_editing;
pub mod object; pub mod object;
pub mod layer; pub mod layer;
pub mod layer_tree; pub mod layer_tree;

View File

@ -98,6 +98,33 @@ pub enum ToolState {
current_point: Point, // Current mouse position (determines radius) current_point: Point, // Current mouse position (determines radius)
num_sides: u32, // Number of sides (from properties, default 5) num_sides: u32, // Number of sides (from properties, default 5)
}, },
/// Editing a vertex (dragging it and connected curves)
EditingVertex {
shape_id: Uuid, // Which shape is being edited
vertex_index: usize, // Which vertex in the vertices array
start_pos: Point, // Vertex position when drag started
start_mouse: Point, // Mouse position when drag started
affected_curve_indices: Vec<usize>, // Which curves connect to this vertex
},
/// Editing a curve (reshaping with moldCurve algorithm)
EditingCurve {
shape_id: Uuid, // Which shape is being edited
curve_index: usize, // Which curve in the curves array
original_curve: vello::kurbo::CubicBez, // The curve when drag started
start_mouse: Point, // Mouse position when drag started
parameter_t: f64, // Parameter where the drag started (0.0-1.0)
},
/// Editing a control point (BezierEdit tool only)
EditingControlPoint {
shape_id: Uuid, // Which shape is being edited
curve_index: usize, // Which curve owns this control point
point_index: u8, // 1 or 2 (p1 or p2 of the cubic bezier)
original_curve: vello::kurbo::CubicBez, // The curve when drag started
start_pos: Point, // Control point position when drag started
},
} }
/// Path simplification mode for the draw tool /// Path simplification mode for the draw tool

View File

@ -1911,6 +1911,24 @@ pub struct StagePane {
pending_eyedropper_sample: Option<(egui::Pos2, super::ColorMode)>, pending_eyedropper_sample: Option<(egui::Pos2, super::ColorMode)>,
// Last known viewport rect (for zoom-to-fit calculation) // Last known viewport rect (for zoom-to-fit calculation)
last_viewport_rect: Option<egui::Rect>, last_viewport_rect: Option<egui::Rect>,
// Vector editing cache
shape_editing_cache: Option<ShapeEditingCache>,
}
/// Cached data for editing a shape
struct ShapeEditingCache {
/// The shape ID being edited
shape_id: uuid::Uuid,
/// The shape instance ID being edited
instance_id: uuid::Uuid,
/// Extracted editable curves and vertices
editable_data: lightningbeam_core::bezier_vertex::EditableBezierCurves,
/// The version index of the shape being edited
version_index: usize,
/// Transform from shape-local to world space
local_to_world: vello::kurbo::Affine,
/// Transform from world to shape-local space
world_to_local: vello::kurbo::Affine,
} }
// Global counter for generating unique instance IDs // Global counter for generating unique instance IDs
@ -1930,6 +1948,7 @@ impl StagePane {
instance_id, instance_id,
pending_eyedropper_sample: None, pending_eyedropper_sample: None,
last_viewport_rect: None, last_viewport_rect: None,
shape_editing_cache: None,
} }
} }
@ -2021,11 +2040,12 @@ impl StagePane {
) { ) {
use lightningbeam_core::tool::ToolState; use lightningbeam_core::tool::ToolState;
use lightningbeam_core::layer::AnyLayer; use lightningbeam_core::layer::AnyLayer;
use lightningbeam_core::hit_test; use lightningbeam_core::hit_test::{self, hit_test_vector_editing, EditingHitTolerance, VectorEditHit};
use lightningbeam_core::bezpath_editing::{extract_editable_curves, mold_curve};
use vello::kurbo::{Point, Rect as KurboRect, Affine}; use vello::kurbo::{Point, Rect as KurboRect, Affine};
// Check if we have an active vector layer // Check if we have an active vector layer
let active_layer_id = match shared.active_layer_id { let active_layer_id = match *shared.active_layer_id {
Some(id) => id, Some(id) => id,
None => return, // No active layer None => return, // No active layer
}; };
@ -2044,7 +2064,37 @@ impl StagePane {
let point = Point::new(world_pos.x as f64, world_pos.y as f64); let point = Point::new(world_pos.x as f64, world_pos.y as f64);
// Mouse down: start interaction (use drag_started for immediate feedback) // Mouse down: start interaction (use drag_started for immediate feedback)
// Scope this section to drop vector_layer borrow before drag handling
if response.drag_started() || response.clicked() { if response.drag_started() || response.clicked() {
// VECTOR EDITING: Check for vertex/curve editing first (higher priority than selection)
let tolerance = EditingHitTolerance::scaled_by_zoom(self.zoom as f64);
let vector_hit = hit_test_vector_editing(
vector_layer,
point,
&tolerance,
Affine::IDENTITY,
false, // Select tool doesn't show control points
);
// Priority 1: Vector editing (vertices and curves)
if let Some(hit) = vector_hit {
match hit {
VectorEditHit::Vertex { shape_instance_id, vertex_index } => {
// Start editing a vertex
self.start_vertex_editing(shape_instance_id, vertex_index, point, active_layer_id, shared);
return;
}
VectorEditHit::Curve { shape_instance_id, curve_index, parameter_t } => {
// Start editing a curve
self.start_curve_editing(shape_instance_id, curve_index, parameter_t, point, active_layer_id, shared);
return;
}
_ => {
// Fill hit - fall through to normal selection
}
}
}
// Priority 2: Normal selection/dragging (no vector element hit)
// Hit test at click position // Hit test at click position
// Test clip instances first (they're on top of shapes) // Test clip instances first (they're on top of shapes)
let document = shared.action_executor.document(); let document = shared.action_executor.document();
@ -2149,6 +2199,10 @@ impl StagePane {
// Mouse drag: update tool state // Mouse drag: update tool state
if response.dragged() { if response.dragged() {
match shared.tool_state { match shared.tool_state {
ToolState::EditingVertex { .. } | ToolState::EditingCurve { .. } => {
// Vector editing - update happens in helper method
self.update_vector_editing(point, shared);
}
ToolState::DraggingSelection { .. } => { ToolState::DraggingSelection { .. } => {
// Update current position (visual feedback only) // Update current position (visual feedback only)
// Actual move happens on mouse up // Actual move happens on mouse up
@ -2168,9 +2222,14 @@ impl StagePane {
let drag_stopped = response.drag_stopped(); let drag_stopped = response.drag_stopped();
let pointer_released = ui.input(|i| i.pointer.any_released()); let pointer_released = ui.input(|i| i.pointer.any_released());
let is_drag_or_marquee = matches!(shared.tool_state, ToolState::DraggingSelection { .. } | ToolState::MarqueeSelecting { .. }); let is_drag_or_marquee = matches!(shared.tool_state, ToolState::DraggingSelection { .. } | ToolState::MarqueeSelecting { .. });
let is_vector_editing = matches!(shared.tool_state, ToolState::EditingVertex { .. } | ToolState::EditingCurve { .. } | ToolState::EditingControlPoint { .. });
if drag_stopped || (pointer_released && is_drag_or_marquee) { if drag_stopped || (pointer_released && (is_drag_or_marquee || is_vector_editing)) {
match shared.tool_state.clone() { match shared.tool_state.clone() {
ToolState::EditingVertex { shape_id, .. } | ToolState::EditingCurve { shape_id, .. } | ToolState::EditingControlPoint { shape_id, .. } => {
// Finish vector editing - create action
self.finish_vector_editing(shape_id, active_layer_id, shared);
}
ToolState::DraggingSelection { start_mouse, original_positions, .. } => { ToolState::DraggingSelection { start_mouse, original_positions, .. } => {
// Calculate total delta // Calculate total delta
let delta = point - start_mouse; let delta = point - start_mouse;
@ -2179,6 +2238,17 @@ impl StagePane {
// Create move actions with new positions // Create move actions with new positions
use std::collections::HashMap; use std::collections::HashMap;
// Get vector layer again (to avoid holding borrow from earlier)
let document = shared.action_executor.document();
let layer = match document.get_layer(&active_layer_id) {
Some(l) => l,
None => return,
};
let vector_layer = match layer {
AnyLayer::Vector(vl) => vl,
_ => return,
};
// Separate shape instances from clip instances // Separate shape instances from clip instances
let mut shape_instance_positions = HashMap::new(); let mut shape_instance_positions = HashMap::new();
let mut clip_instance_transforms = HashMap::new(); let mut clip_instance_transforms = HashMap::new();
@ -2213,14 +2283,14 @@ impl StagePane {
// Create and submit move action for shape instances // Create and submit move action for shape instances
if !shape_instance_positions.is_empty() { if !shape_instance_positions.is_empty() {
use lightningbeam_core::actions::MoveShapeInstancesAction; use lightningbeam_core::actions::MoveShapeInstancesAction;
let action = MoveShapeInstancesAction::new(*active_layer_id, shape_instance_positions); let action = MoveShapeInstancesAction::new(active_layer_id, shape_instance_positions);
shared.pending_actions.push(Box::new(action)); shared.pending_actions.push(Box::new(action));
} }
// Create and submit transform action for clip instances // Create and submit transform action for clip instances
if !clip_instance_transforms.is_empty() { if !clip_instance_transforms.is_empty() {
use lightningbeam_core::actions::TransformClipInstancesAction; use lightningbeam_core::actions::TransformClipInstancesAction;
let action = TransformClipInstancesAction::new(*active_layer_id, clip_instance_transforms); let action = TransformClipInstancesAction::new(active_layer_id, clip_instance_transforms);
shared.pending_actions.push(Box::new(action)); shared.pending_actions.push(Box::new(action));
} }
} }
@ -2237,8 +2307,18 @@ impl StagePane {
let selection_rect = KurboRect::new(min_x, min_y, max_x, max_y); let selection_rect = KurboRect::new(min_x, min_y, max_x, max_y);
// Hit test clip instances in rectangle // Get vector layer again (to avoid holding borrow from earlier)
let document = shared.action_executor.document(); let document = shared.action_executor.document();
let layer = match document.get_layer(&active_layer_id) {
Some(l) => l,
None => return,
};
let vector_layer = match layer {
AnyLayer::Vector(vl) => vl,
_ => return,
};
// Hit test clip instances in rectangle
let clip_hits = hit_test::hit_test_clip_instances_in_rect( let clip_hits = hit_test::hit_test_clip_instances_in_rect(
&vector_layer.clip_instances, &vector_layer.clip_instances,
document, document,
@ -2292,6 +2372,515 @@ impl StagePane {
} }
} }
/// Start editing a vertex - called when user clicks on a vertex
fn start_vertex_editing(
&mut self,
shape_instance_id: uuid::Uuid,
vertex_index: usize,
mouse_pos: vello::kurbo::Point,
active_layer_id: uuid::Uuid,
shared: &mut SharedPaneState,
) {
use lightningbeam_core::bezpath_editing::extract_editable_curves;
use lightningbeam_core::tool::ToolState;
use lightningbeam_core::layer::AnyLayer;
use vello::kurbo::Affine;
// Get the vector layer
let layer = match shared.action_executor.document().get_layer(&active_layer_id) {
Some(l) => l,
None => return,
};
let vector_layer = match layer {
AnyLayer::Vector(vl) => vl,
_ => return,
};
// Get the shape instance
let shape_instance = match vector_layer.get_object(&shape_instance_id) {
Some(obj) => obj,
None => return,
};
// Get the shape definition
let shape = match vector_layer.get_shape(&shape_instance.shape_id) {
Some(s) => s,
None => return,
};
// Extract editable curves
let editable_data = extract_editable_curves(shape.path());
// Validate vertex index
if vertex_index >= editable_data.vertices.len() {
return;
}
let vertex = &editable_data.vertices[vertex_index];
// Build transform matrices
let local_to_world = Affine::translate((shape_instance.transform.x, shape_instance.transform.y))
* Affine::rotate(shape_instance.transform.rotation)
* Affine::scale_non_uniform(shape_instance.transform.scale_x, shape_instance.transform.scale_y);
let world_to_local = local_to_world.inverse();
// Store editing cache
self.shape_editing_cache = Some(ShapeEditingCache {
shape_id: shape_instance.shape_id,
instance_id: shape_instance_id,
editable_data: editable_data.clone(),
version_index: shape.versions.len() - 1,
local_to_world,
world_to_local,
});
// Set tool state
*shared.tool_state = ToolState::EditingVertex {
shape_id: shape_instance.shape_id,
vertex_index,
start_pos: vertex.point,
start_mouse: mouse_pos,
affected_curve_indices: vertex.start_curves.iter()
.chain(vertex.end_curves.iter())
.copied()
.collect(),
};
}
/// Start editing a curve - called when user clicks on a curve
fn start_curve_editing(
&mut self,
shape_instance_id: uuid::Uuid,
curve_index: usize,
parameter_t: f64,
mouse_pos: vello::kurbo::Point,
active_layer_id: uuid::Uuid,
shared: &mut SharedPaneState,
) {
use lightningbeam_core::bezpath_editing::extract_editable_curves;
use lightningbeam_core::tool::ToolState;
use lightningbeam_core::layer::AnyLayer;
use vello::kurbo::Affine;
// Get the vector layer
let layer = match shared.action_executor.document().get_layer(&active_layer_id) {
Some(l) => l,
None => return,
};
let vector_layer = match layer {
AnyLayer::Vector(vl) => vl,
_ => return,
};
// Get the shape instance
let shape_instance = match vector_layer.get_object(&shape_instance_id) {
Some(obj) => obj,
None => return,
};
// Get the shape definition
let shape = match vector_layer.get_shape(&shape_instance.shape_id) {
Some(s) => s,
None => return,
};
// Extract editable curves
let editable_data = extract_editable_curves(shape.path());
// Validate curve index
if curve_index >= editable_data.curves.len() {
return;
}
let original_curve = editable_data.curves[curve_index];
// Build transform matrices
let local_to_world = Affine::translate((shape_instance.transform.x, shape_instance.transform.y))
* Affine::rotate(shape_instance.transform.rotation)
* Affine::scale_non_uniform(shape_instance.transform.scale_x, shape_instance.transform.scale_y);
let world_to_local = local_to_world.inverse();
// Store editing cache
self.shape_editing_cache = Some(ShapeEditingCache {
shape_id: shape_instance.shape_id,
instance_id: shape_instance_id,
editable_data,
version_index: shape.versions.len() - 1,
local_to_world,
world_to_local,
});
// Set tool state
*shared.tool_state = ToolState::EditingCurve {
shape_id: shape_instance.shape_id,
curve_index,
original_curve,
start_mouse: mouse_pos,
parameter_t,
};
}
/// Update vector editing during drag
fn update_vector_editing(
&mut self,
mouse_pos: vello::kurbo::Point,
shared: &mut SharedPaneState,
) {
use lightningbeam_core::bezpath_editing::{mold_curve, rebuild_bezpath};
use lightningbeam_core::tool::ToolState;
use vello::kurbo::Point;
// Clone tool state to get owned values
let tool_state = shared.tool_state.clone();
let cache = match &mut self.shape_editing_cache {
Some(c) => c,
None => return,
};
match tool_state {
ToolState::EditingVertex { vertex_index, start_pos, start_mouse, affected_curve_indices, .. } => {
// Transform mouse position to local space
let local_mouse = cache.world_to_local * mouse_pos;
let local_start_mouse = cache.world_to_local * start_mouse;
// Calculate delta in local space
let delta = local_mouse - local_start_mouse;
let new_vertex_pos = start_pos + delta;
// Update the vertex position
if vertex_index < cache.editable_data.vertices.len() {
cache.editable_data.vertices[vertex_index].point = new_vertex_pos;
}
// Update all affected curves
for &curve_idx in affected_curve_indices.iter() {
if curve_idx >= cache.editable_data.curves.len() {
continue;
}
let curve = &mut cache.editable_data.curves[curve_idx];
let vertex = &cache.editable_data.vertices[vertex_index];
// Check if this curve starts at this vertex
if vertex.start_curves.contains(&curve_idx) {
// Update endpoint p0 and adjacent control point p1
let endpoint_delta = new_vertex_pos - curve.p0;
curve.p0 = new_vertex_pos;
curve.p1 = curve.p1 + endpoint_delta;
}
// Check if this curve ends at this vertex
if vertex.end_curves.contains(&curve_idx) {
// Update endpoint p3 and adjacent control point p2
let endpoint_delta = new_vertex_pos - curve.p3;
curve.p3 = new_vertex_pos;
curve.p2 = curve.p2 + endpoint_delta;
}
}
// Note: We're only updating the cache here. The actual shape path will be updated
// via ModifyShapePathAction when the user releases the mouse button.
// For now, we'll skip live preview since we can't mutate through the vector_layer reference.
}
ToolState::EditingCurve { curve_index, original_curve, start_mouse, .. } => {
// Transform mouse positions to local space
let local_mouse = cache.world_to_local * mouse_pos;
let local_start_mouse = cache.world_to_local * start_mouse;
// Apply moldCurve algorithm
let molded_curve = mold_curve(&original_curve, &local_mouse, &local_start_mouse);
// Update the curve in the cache
if curve_index < cache.editable_data.curves.len() {
cache.editable_data.curves[curve_index] = molded_curve;
}
// Note: We're only updating the cache here. The actual shape path will be updated
// via ModifyShapePathAction when the user releases the mouse button.
}
ToolState::EditingControlPoint { curve_index, point_index, .. } => {
// Transform mouse position to local space
let local_mouse = cache.world_to_local * mouse_pos;
// Calculate new control point position
let new_control_point = local_mouse;
// Update the control point in the cache
if curve_index < cache.editable_data.curves.len() {
let curve = &mut cache.editable_data.curves[curve_index];
match point_index {
1 => curve.p1 = new_control_point,
2 => curve.p2 = new_control_point,
_ => {} // Invalid point index
}
}
// Note: We're only updating the cache here. The actual shape path will be updated
// via ModifyShapePathAction when the user releases the mouse button.
}
_ => {}
}
}
/// Finish vector editing and create action for undo/redo
fn finish_vector_editing(
&mut self,
shape_id: uuid::Uuid,
layer_id: uuid::Uuid,
shared: &mut SharedPaneState,
) {
use lightningbeam_core::bezpath_editing::rebuild_bezpath;
use lightningbeam_core::actions::ModifyShapePathAction;
use lightningbeam_core::tool::ToolState;
let cache = match self.shape_editing_cache.take() {
Some(c) => c,
None => {
*shared.tool_state = ToolState::Idle;
return;
}
};
// Get the original shape to retrieve the old path
let document = shared.action_executor.document();
let layer = match document.get_layer(&layer_id) {
Some(l) => l,
None => {
*shared.tool_state = ToolState::Idle;
return;
}
};
let vector_layer = match layer {
lightningbeam_core::layer::AnyLayer::Vector(vl) => vl,
_ => {
*shared.tool_state = ToolState::Idle;
return;
}
};
let old_path = match vector_layer.get_shape(&shape_id) {
Some(shape) => {
if cache.version_index < shape.versions.len() {
// The shape has been temporarily updated during dragging
// We need to get the original path from history or recreate it
// For now, we'll use the version_index we stored
if let Some(version) = shape.versions.get(cache.version_index) {
version.path.clone()
} else {
// Fallback: use current path
shape.path().clone()
}
} else {
shape.path().clone()
}
}
None => {
*shared.tool_state = ToolState::Idle;
return;
}
};
// Rebuild the new path from edited curves
let new_path = rebuild_bezpath(&cache.editable_data);
// Only create action if the path actually changed
if old_path != new_path {
let action = ModifyShapePathAction::with_old_path(
layer_id,
shape_id,
cache.version_index,
old_path,
new_path,
);
shared.pending_actions.push(Box::new(action));
}
// Reset tool state
*shared.tool_state = ToolState::Idle;
}
/// Handle BezierEdit tool - similar to Select but with control point editing
fn handle_bezier_edit_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::{self, hit_test_vector_editing, EditingHitTolerance, VectorEditHit};
use vello::kurbo::{Point, Affine};
// Check if we have an active vector layer
let active_layer_id = match *shared.active_layer_id {
Some(id) => id,
None => return,
};
let active_layer = match shared.action_executor.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,
};
let point = Point::new(world_pos.x as f64, world_pos.y as f64);
// VECTOR EDITING: Check for control points, vertices, and curves (higher priority than selection)
let tolerance = EditingHitTolerance::scaled_by_zoom(self.zoom as f64);
let vector_hit = hit_test_vector_editing(
vector_layer,
point,
&tolerance,
Affine::IDENTITY,
true, // BezierEdit tool shows control points
);
// Mouse down: start interaction
if response.drag_started() || response.clicked() {
// Priority 1: Vector editing (control points, vertices, and curves)
if let Some(hit) = vector_hit {
match hit {
VectorEditHit::ControlPoint { shape_instance_id, curve_index, point_index } => {
// Start editing a control point
self.start_control_point_editing(shape_instance_id, curve_index, point_index, point, active_layer_id, shared);
return;
}
VectorEditHit::Vertex { shape_instance_id, vertex_index } => {
// Start editing a vertex
self.start_vertex_editing(shape_instance_id, vertex_index, point, active_layer_id, shared);
return;
}
VectorEditHit::Curve { shape_instance_id, curve_index, parameter_t } => {
// Start editing a curve
self.start_curve_editing(shape_instance_id, curve_index, parameter_t, point, active_layer_id, shared);
return;
}
_ => {
// Fill hit - no selection in BezierEdit mode, just ignore
}
}
}
}
// Mouse drag: update tool state
if response.dragged() {
match shared.tool_state {
ToolState::EditingVertex { .. } | ToolState::EditingCurve { .. } | ToolState::EditingControlPoint { .. } => {
// Vector editing - update happens in helper method
self.update_vector_editing(point, shared);
}
_ => {}
}
}
// Mouse up: finish interaction
let drag_stopped = response.drag_stopped();
let pointer_released = ui.input(|i| i.pointer.any_released());
let is_vector_editing = matches!(shared.tool_state, ToolState::EditingVertex { .. } | ToolState::EditingCurve { .. } | ToolState::EditingControlPoint { .. });
if drag_stopped || (pointer_released && is_vector_editing) {
match shared.tool_state.clone() {
ToolState::EditingVertex { shape_id, .. } | ToolState::EditingCurve { shape_id, .. } | ToolState::EditingControlPoint { shape_id, .. } => {
// Finish vector editing - create action
self.finish_vector_editing(shape_id, active_layer_id, shared);
}
_ => {}
}
}
}
/// Start editing a control point - called when user clicks on a control point
fn start_control_point_editing(
&mut self,
shape_instance_id: uuid::Uuid,
curve_index: usize,
point_index: u8,
mouse_pos: vello::kurbo::Point,
active_layer_id: uuid::Uuid,
shared: &mut SharedPaneState,
) {
use lightningbeam_core::bezpath_editing::extract_editable_curves;
use lightningbeam_core::tool::ToolState;
use lightningbeam_core::layer::AnyLayer;
use vello::kurbo::Affine;
// Get the vector layer
let layer = match shared.action_executor.document().get_layer(&active_layer_id) {
Some(l) => l,
None => return,
};
let vector_layer = match layer {
AnyLayer::Vector(vl) => vl,
_ => return,
};
// Get the shape instance
let shape_instance = match vector_layer.get_object(&shape_instance_id) {
Some(obj) => obj,
None => return,
};
// Get the shape definition
let shape = match vector_layer.get_shape(&shape_instance.shape_id) {
Some(s) => s,
None => return,
};
// Extract editable curves
let editable_data = extract_editable_curves(shape.path());
// Validate curve index
if curve_index >= editable_data.curves.len() {
return;
}
let original_curve = editable_data.curves[curve_index];
// Get the control point position
let start_pos = match point_index {
1 => original_curve.p1,
2 => original_curve.p2,
_ => return, // Invalid point index
};
// Build transform matrices
let local_to_world = Affine::translate((shape_instance.transform.x, shape_instance.transform.y))
* Affine::rotate(shape_instance.transform.rotation)
* Affine::scale_non_uniform(shape_instance.transform.scale_x, shape_instance.transform.scale_y);
let world_to_local = local_to_world.inverse();
// Store editing cache
self.shape_editing_cache = Some(ShapeEditingCache {
shape_id: shape_instance.shape_id,
instance_id: shape_instance_id,
editable_data,
version_index: shape.versions.len() - 1,
local_to_world,
world_to_local,
});
// Set tool state
*shared.tool_state = ToolState::EditingControlPoint {
shape_id: shape_instance.shape_id,
curve_index,
point_index,
original_curve,
start_pos,
};
}
fn handle_rectangle_tool( fn handle_rectangle_tool(
&mut self, &mut self,
ui: &mut egui::Ui, ui: &mut egui::Ui,
@ -4922,6 +5511,9 @@ impl StagePane {
Tool::Select => { Tool::Select => {
self.handle_select_tool(ui, &response, world_pos, shift_held, shared); self.handle_select_tool(ui, &response, world_pos, shift_held, shared);
} }
Tool::BezierEdit => {
self.handle_bezier_edit_tool(ui, &response, world_pos, shift_held, shared);
}
Tool::Rectangle => { Tool::Rectangle => {
self.handle_rectangle_tool(ui, &response, world_pos, shift_held, ctrl_held, shared); self.handle_rectangle_tool(ui, &response, world_pos, shift_held, ctrl_held, shared);
} }
@ -5007,8 +5599,251 @@ impl StagePane {
} }
} }
/// Render vector editing overlays (vertices, control points, handles)
fn render_vector_editing_overlays(
&self,
ui: &mut egui::Ui,
rect: egui::Rect,
shared: &SharedPaneState,
) {
use lightningbeam_core::bezpath_editing::extract_editable_curves;
use lightningbeam_core::layer::AnyLayer;
use lightningbeam_core::tool::{Tool, ToolState};
use lightningbeam_core::hit_test::{hit_test_vector_editing, EditingHitTolerance, VectorEditHit};
use vello::kurbo::{Affine, Point};
// Only show overlays for Select and BezierEdit tools
let is_bezier_edit_mode = matches!(*shared.selected_tool, Tool::BezierEdit);
let show_overlays = matches!(*shared.selected_tool, Tool::Select | Tool::BezierEdit);
if !show_overlays {
return;
}
// Get active layer
let active_layer_id = match *shared.active_layer_id {
Some(id) => id,
None => return,
};
let layer = match shared.action_executor.document().get_layer(&active_layer_id) {
Some(AnyLayer::Vector(layer)) => layer,
_ => return,
};
// Get mouse position in world coordinates
let mouse_screen_pos = ui.input(|i| i.pointer.hover_pos()).unwrap_or(rect.center());
let mouse_canvas_pos = mouse_screen_pos - rect.min;
let mouse_world_pos = Point::new(
((mouse_canvas_pos.x - self.pan_offset.x) / self.zoom) as f64,
((mouse_canvas_pos.y - self.pan_offset.y) / self.zoom) as f64,
);
// Helper to convert world coordinates to screen coordinates
let world_to_screen = |world_pos: Point| -> egui::Pos2 {
let screen_x = (world_pos.x as f32 * self.zoom) + self.pan_offset.x + rect.min.x;
let screen_y = (world_pos.y as f32 * self.zoom) + self.pan_offset.y + rect.min.y;
egui::pos2(screen_x, screen_y)
};
let painter = ui.painter();
// Perform hit testing to find what's under the mouse
let tolerance = EditingHitTolerance::scaled_by_zoom(self.zoom as f64);
let hit = hit_test_vector_editing(
layer,
mouse_world_pos,
&tolerance,
Affine::IDENTITY,
is_bezier_edit_mode,
);
if is_bezier_edit_mode {
// BezierEdit mode: Show all vertices and control points for all shapes
// Also highlight the element under the mouse
let (hover_vertex, hover_control_point) = match hit {
Some(VectorEditHit::Vertex { shape_instance_id, vertex_index }) => {
(Some((shape_instance_id, vertex_index)), None)
}
Some(VectorEditHit::ControlPoint { shape_instance_id, curve_index, point_index }) => {
(None, Some((shape_instance_id, curve_index, point_index)))
}
_ => (None, None),
};
for instance in &layer.shape_instances {
let shape = match layer.get_shape(&instance.shape_id) {
Some(s) => s,
None => continue,
};
let local_to_world = instance.to_affine();
let editable = extract_editable_curves(shape.path());
// Determine active element from tool state (being dragged)
let (active_vertex, active_control_point) = match &*shared.tool_state {
ToolState::EditingVertex { shape_id, vertex_index, .. } if *shape_id == instance.shape_id => {
(Some(*vertex_index), None)
}
ToolState::EditingControlPoint { shape_id, curve_index, point_index, .. }
if *shape_id == instance.shape_id => {
(None, Some((*curve_index, *point_index)))
}
_ => (None, None),
};
// Render all vertices
for (i, vertex) in editable.vertices.iter().enumerate() {
let world_pos = local_to_world * vertex.point;
let screen_pos = world_to_screen(world_pos);
let vertex_size = 10.0;
let rect = egui::Rect::from_center_size(
screen_pos,
egui::vec2(vertex_size, vertex_size),
);
// Determine color: orange if active (dragging), yellow if hover, black otherwise
let (fill_color, stroke_width) = if Some(i) == active_vertex {
(egui::Color32::from_rgb(255, 200, 0), 2.0) // Orange if being dragged
} else if hover_vertex == Some((instance.id, i)) {
(egui::Color32::from_rgb(255, 255, 100), 2.0) // Yellow if hovering
} else {
(egui::Color32::from_rgba_premultiplied(0, 0, 0, 170), 1.0)
};
painter.rect_filled(rect, 0.0, fill_color);
painter.rect_stroke(
rect,
0.0,
egui::Stroke::new(stroke_width, egui::Color32::WHITE),
egui::StrokeKind::Middle,
);
}
// Render all control points
for (i, curve) in editable.curves.iter().enumerate() {
let p0_world = local_to_world * curve.p0;
let p1_world = local_to_world * curve.p1;
let p2_world = local_to_world * curve.p2;
let p3_world = local_to_world * curve.p3;
let p0_screen = world_to_screen(p0_world);
let p1_screen = world_to_screen(p1_world);
let p2_screen = world_to_screen(p2_world);
let p3_screen = world_to_screen(p3_world);
// Draw handle lines
painter.line_segment(
[p0_screen, p1_screen],
egui::Stroke::new(1.0, egui::Color32::from_rgb(100, 100, 255)),
);
painter.line_segment(
[p2_screen, p3_screen],
egui::Stroke::new(1.0, egui::Color32::from_rgb(100, 100, 255)),
);
let radius = 6.0;
// p1 control point
let (p1_fill, p1_stroke_width) = if active_control_point == Some((i, 1)) {
(egui::Color32::from_rgb(255, 150, 0), 2.0) // Orange if being dragged
} else if hover_control_point == Some((instance.id, i, 1)) {
(egui::Color32::from_rgb(150, 150, 255), 2.0) // Lighter blue if hovering
} else {
(egui::Color32::from_rgb(100, 100, 255), 1.0)
};
painter.circle_filled(p1_screen, radius, p1_fill);
painter.circle_stroke(p1_screen, radius, egui::Stroke::new(p1_stroke_width, egui::Color32::WHITE));
// p2 control point
let (p2_fill, p2_stroke_width) = if active_control_point == Some((i, 2)) {
(egui::Color32::from_rgb(255, 150, 0), 2.0) // Orange if being dragged
} else if hover_control_point == Some((instance.id, i, 2)) {
(egui::Color32::from_rgb(150, 150, 255), 2.0) // Lighter blue if hovering
} else {
(egui::Color32::from_rgb(100, 100, 255), 1.0)
};
painter.circle_filled(p2_screen, radius, p2_fill);
painter.circle_stroke(p2_screen, radius, egui::Stroke::new(p2_stroke_width, egui::Color32::WHITE));
}
}
} else {
// Select mode: Only show hover highlights based on hit testing
if let Some(hit_result) = hit {
match hit_result {
VectorEditHit::Vertex { shape_instance_id, vertex_index } => {
// Highlight the vertex under the mouse
if let Some(instance) = layer.shape_instances.iter().find(|i| i.id == shape_instance_id) {
if let Some(shape) = layer.get_shape(&instance.shape_id) {
let local_to_world = instance.to_affine();
let editable = extract_editable_curves(shape.path());
if vertex_index < editable.vertices.len() {
let vertex = &editable.vertices[vertex_index];
let world_pos = local_to_world * vertex.point;
let screen_pos = world_to_screen(world_pos);
let vertex_size = 10.0;
let rect = egui::Rect::from_center_size(
screen_pos,
egui::vec2(vertex_size, vertex_size),
);
painter.rect_filled(rect, 0.0, egui::Color32::from_rgb(255, 200, 0));
painter.rect_stroke(
rect,
0.0,
egui::Stroke::new(2.0, egui::Color32::WHITE),
egui::StrokeKind::Middle,
);
}
}
}
}
VectorEditHit::Curve { shape_instance_id, curve_index, .. } => {
// Highlight the curve under the mouse
if let Some(instance) = layer.shape_instances.iter().find(|i| i.id == shape_instance_id) {
if let Some(shape) = layer.get_shape(&instance.shape_id) {
let local_to_world = instance.to_affine();
let editable = extract_editable_curves(shape.path());
if curve_index < editable.curves.len() {
let curve = &editable.curves[curve_index];
let num_samples = 20;
for j in 0..num_samples {
let t1 = j as f64 / num_samples as f64;
let t2 = (j + 1) as f64 / num_samples as f64;
use vello::kurbo::ParamCurve;
let p1_local = curve.eval(t1);
let p2_local = curve.eval(t2);
let p1_world = local_to_world * p1_local;
let p2_world = local_to_world * p2_local;
let p1_screen = world_to_screen(p1_world);
let p2_screen = world_to_screen(p2_world);
painter.line_segment(
[p1_screen, p2_screen],
egui::Stroke::new(3.0, egui::Color32::from_rgb(255, 0, 255)),
);
}
}
}
}
}
_ => {}
}
}
}
}
} }
impl PaneRenderer for StagePane { impl PaneRenderer for StagePane {
fn render_header(&mut self, ui: &mut egui::Ui, shared: &mut SharedPaneState) -> bool { fn render_header(&mut self, ui: &mut egui::Ui, shared: &mut SharedPaneState) -> bool {
ui.horizontal(|ui| { ui.horizontal(|ui| {
@ -5407,6 +6242,9 @@ impl PaneRenderer for StagePane {
egui::FontId::proportional(14.0), egui::FontId::proportional(14.0),
egui::Color32::from_gray(200), egui::Color32::from_gray(200),
); );
// Render vector editing overlays (vertices, control points, etc.)
self.render_vector_editing_overlays(ui, rect, shared);
} }
fn name(&self) -> &str { fn name(&self) -> &str {