Lightningbeam/lightningbeam-ui/lightningbeam-core/src/bezpath_editing.rs

376 lines
12 KiB
Rust

//! 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
}
}