initial vector editing
This commit is contained in:
parent
2dea1eab9e
commit
ffb53884b0
|
|
@ -7,6 +7,7 @@ pub mod add_clip_instance;
|
|||
pub mod add_effect;
|
||||
pub mod add_layer;
|
||||
pub mod add_shape;
|
||||
pub mod modify_shape_path;
|
||||
pub mod move_clip_instances;
|
||||
pub mod move_objects;
|
||||
pub mod paint_bucket;
|
||||
|
|
@ -24,6 +25,7 @@ pub use add_clip_instance::AddClipInstanceAction;
|
|||
pub use add_effect::AddEffectAction;
|
||||
pub use add_layer::AddLayerAction;
|
||||
pub use add_shape::AddShapeAction;
|
||||
pub use modify_shape_path::ModifyShapePathAction;
|
||||
pub use move_clip_instances::MoveClipInstancesAction;
|
||||
pub use move_objects::MoveShapeInstancesAction;
|
||||
pub use paint_bucket::PaintBucketAction;
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -344,6 +344,175 @@ pub fn hit_test_clip_instances_in_rect(
|
|||
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)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
|
|
|||
|
|
@ -9,6 +9,8 @@ pub mod animation;
|
|||
pub mod path_interpolation;
|
||||
pub mod path_fitting;
|
||||
pub mod shape;
|
||||
pub mod bezier_vertex;
|
||||
pub mod bezpath_editing;
|
||||
pub mod object;
|
||||
pub mod layer;
|
||||
pub mod layer_tree;
|
||||
|
|
|
|||
|
|
@ -98,6 +98,33 @@ pub enum ToolState {
|
|||
current_point: Point, // Current mouse position (determines radius)
|
||||
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
|
||||
|
|
|
|||
|
|
@ -1911,6 +1911,24 @@ pub struct StagePane {
|
|||
pending_eyedropper_sample: Option<(egui::Pos2, super::ColorMode)>,
|
||||
// Last known viewport rect (for zoom-to-fit calculation)
|
||||
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
|
||||
|
|
@ -1930,6 +1948,7 @@ impl StagePane {
|
|||
instance_id,
|
||||
pending_eyedropper_sample: None,
|
||||
last_viewport_rect: None,
|
||||
shape_editing_cache: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -2021,11 +2040,12 @@ impl StagePane {
|
|||
) {
|
||||
use lightningbeam_core::tool::ToolState;
|
||||
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};
|
||||
|
||||
// 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,
|
||||
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);
|
||||
|
||||
// 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() {
|
||||
// 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
|
||||
// Test clip instances first (they're on top of shapes)
|
||||
let document = shared.action_executor.document();
|
||||
|
|
@ -2149,6 +2199,10 @@ impl StagePane {
|
|||
// Mouse drag: update tool state
|
||||
if response.dragged() {
|
||||
match shared.tool_state {
|
||||
ToolState::EditingVertex { .. } | ToolState::EditingCurve { .. } => {
|
||||
// Vector editing - update happens in helper method
|
||||
self.update_vector_editing(point, shared);
|
||||
}
|
||||
ToolState::DraggingSelection { .. } => {
|
||||
// Update current position (visual feedback only)
|
||||
// Actual move happens on mouse up
|
||||
|
|
@ -2168,9 +2222,14 @@ impl StagePane {
|
|||
let drag_stopped = response.drag_stopped();
|
||||
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_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() {
|
||||
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, .. } => {
|
||||
// Calculate total delta
|
||||
let delta = point - start_mouse;
|
||||
|
|
@ -2179,6 +2238,17 @@ impl StagePane {
|
|||
// Create move actions with new positions
|
||||
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
|
||||
let mut shape_instance_positions = HashMap::new();
|
||||
let mut clip_instance_transforms = HashMap::new();
|
||||
|
|
@ -2213,14 +2283,14 @@ impl StagePane {
|
|||
// Create and submit move action for shape instances
|
||||
if !shape_instance_positions.is_empty() {
|
||||
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));
|
||||
}
|
||||
|
||||
// Create and submit transform action for clip instances
|
||||
if !clip_instance_transforms.is_empty() {
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
|
@ -2237,8 +2307,18 @@ impl StagePane {
|
|||
|
||||
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 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(
|
||||
&vector_layer.clip_instances,
|
||||
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(
|
||||
&mut self,
|
||||
ui: &mut egui::Ui,
|
||||
|
|
@ -4922,6 +5511,9 @@ impl StagePane {
|
|||
Tool::Select => {
|
||||
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 => {
|
||||
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 {
|
||||
fn render_header(&mut self, ui: &mut egui::Ui, shared: &mut SharedPaneState) -> bool {
|
||||
ui.horizontal(|ui| {
|
||||
|
|
@ -5407,6 +6242,9 @@ impl PaneRenderer for StagePane {
|
|||
egui::FontId::proportional(14.0),
|
||||
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 {
|
||||
|
|
|
|||
Loading…
Reference in New Issue