Lightningbeam/lightningbeam-ui/lightningbeam-editor/TOOL_IMPLEMENTATION_PLAN.md

21 KiB

Tool Integration Implementation Plan

Updated with correct architecture patterns from JS codebase

Architecture Overview

Type-Safe Action System: Document mutations only through Action trait

  • Read: Public via ActionExecutor::document()
  • Write: Only via pub(crate) methods in action implementations
  • Enforcement: Rust's module privacy system

Key Corrections:

  • GraphicsObject nesting (recursive hit testing)
  • Shape tools create Shape + Object, add to active VectorLayer
  • Tools only work on VectorLayer (check active_layer.type)
  • Path fitting uses JS algorithms (RDP or Schneider)
  • Paint bucket uses vector flood fill with quadtree

Phase 1: Action System Foundation

1.1 Create Action System Core

File: lightningbeam-core/src/action.rs

pub trait Action: Send {
    fn execute(&mut self, document: &mut Document);
    fn rollback(&mut self, document: &mut Document);
    fn description(&self) -> String;
}

pub struct ActionExecutor {
    document: Document,
    undo_stack: Vec<Box<dyn Action>>,
    redo_stack: Vec<Box<dyn Action>>,
}

Methods:

  • document(&self) -> &Document - Read-only access
  • execute(&mut self, Box<dyn Action>) - Execute + push to undo
  • undo(&mut self) -> bool - Pop and rollback
  • redo(&mut self) -> bool - Re-execute from redo stack

1.2 Update Document for Controlled Access

File: lightningbeam-core/src/document.rs

Add pub(crate) mutation methods:

  • root_mut() -> &mut GraphicsObject
  • get_layer_mut(&self, id: &Uuid) -> Option<&mut AnyLayer>
  • Keep all fields private
  • Keep existing public read methods

1.3 Update Layer for Shape Operations

File: lightningbeam-core/src/layer.rs

Add pub(crate) methods to VectorLayer:

  • add_shape_internal(&mut self, shape: Shape) -> Uuid
  • add_object_internal(&mut self, object: Object) -> Uuid
  • remove_shape_internal(&mut self, id: &Uuid) -> Option<Shape>
  • remove_object_internal(&mut self, id: &Uuid) -> Option<Object>
  • modify_object_internal(&mut self, id: &Uuid, f: impl FnOnce(&mut Object))

1.4 Integrate ActionExecutor into EditorApp

File: lightningbeam-editor/src/main.rs

  • Replace document: Document with action_executor: ActionExecutor
  • Add active_layer_id: Option<Uuid> to track current layer
  • Update SharedPaneState to pass document: &Document (read-only)
  • Add execute_action(&mut self, action: Box<dyn Action>) method
  • Wire Ctrl+Z / Ctrl+Shift+Z to undo/redo

Phase 2: Selection System

2.1 Create Selection State

File: lightningbeam-core/src/selection.rs

pub struct Selection {
    selected_objects: Vec<Uuid>,
    selected_shapes: Vec<Uuid>,
}

Methods: add, remove, clear, contains, is_empty, objects(), shapes()

2.2 Add to Editor State

Add to EditorApp:

  • selection: Selection
  • Pass through SharedPaneState (read-only for rendering, mutable for tools)

Phase 3: Hit Testing Infrastructure

3.1 Hit Test Module

File: lightningbeam-core/src/hit_test.rs

Recursive Hit Testing through GraphicsObject hierarchy:

pub fn hit_test_layer(
    layer: &VectorLayer,
    point: Point,
    tolerance: f64,
    parent_transform: Affine,
) -> Option<Uuid> {
    // Hit test objects in this layer
    for object in layer.objects.iter().rev() { // Back to front
        let shape = layer.get_shape(&object.shape_id)?;

        // Combine parent transform with object transform
        let combined_transform = parent_transform * object.to_affine();

        if hit_test_shape(shape, point, tolerance, combined_transform) {
            return Some(object.id);
        }
    }
    None
}

fn hit_test_shape(
    shape: &Shape,
    point: Point,
    tolerance: f64,
    transform: Affine,
) -> bool {
    // Transform point to shape's local space
    let inverse_transform = transform.inverse();
    let local_point = inverse_transform * point;

    // Check if point is inside path (kurbo's contains())
    if shape.path.contains(local_point) {
        return true;
    }

    // Check stroke bounds if has stroke
    if shape.stroke_style.is_some() {
        let stroke_tolerance = shape.stroke_style.unwrap().width / 2.0 + tolerance;
        // Check distance to path
        // Use kurbo path methods for nearest point
    }

    false
}

Rectangle Hit Testing:

pub fn hit_test_objects_in_rect(
    layer: &VectorLayer,
    rect: Rect,
    parent_transform: Affine,
) -> Vec<Uuid> {
    let mut hits = Vec::new();

    for object in &layer.objects {
        let shape = layer.get_shape(&object.shape_id).unwrap();
        let combined_transform = parent_transform * object.to_affine();
        let bbox = shape.path.bounding_box();
        let transformed_bbox = combined_transform.transform_rect_bbox(bbox);

        if rect.intersect(transformed_bbox).area() > 0.0 {
            hits.push(object.id);
        }
    }

    hits
}

3.2 Bounding Box Calculation

Add to lightningbeam-core/src/object.rs:

impl Object {
    pub fn bounding_box(&self, shape: &Shape) -> Rect {
        let path_bbox = shape.path.bounding_box();
        self.to_affine().transform_rect_bbox(path_bbox)
    }
}

Phase 4: Tool State Management

4.1 Tool State Enum

File: lightningbeam-core/src/tool.rs

pub enum ToolState {
    Idle,

    DrawingPath {
        points: Vec<Point>,
        simplify_mode: SimplifyMode, // "corners" | "smooth" | "verbatim"
    },

    DraggingSelection {
        start_pos: Point,
        start_mouse: Point,
        original_transforms: HashMap<Uuid, Transform>,
    },

    MarqueeSelecting {
        start: Point,
        current: Point,
    },

    CreatingRectangle {
        start_corner: Point,
        current_corner: Point,
    },

    CreatingEllipse {
        center: Point,
        current_point: Point,
    },

    Transforming {
        mode: TransformMode,
        original_transforms: HashMap<Uuid, Transform>,
        pivot: Point,
    },
}

pub enum SimplifyMode {
    Corners,  // Ramer-Douglas-Peucker
    Smooth,   // Schneider curve fitting
    Verbatim, // No simplification
}

Add to EditorApp: tool_state: ToolState


Phase 5: Select Tool

5.1 Active Layer Validation

All tools check:

// In Stage.handle_tool_input()
let Some(active_layer_id) = shared.active_layer_id else {
    return None; // No active layer
};

let active_layer = shared.document.get_layer(active_layer_id)?;

// Only work on VectorLayer
let AnyLayer::Vector(vector_layer) = active_layer else {
    return None; // Not a vector layer
};

5.2 Click Selection

Mouse Down:

  • Hit test at click position using recursive hit_test_layer()
  • If object found:
    • If Shift: toggle in selection
    • Else: replace selection with clicked object
    • If already selected: enter DraggingSelection state
  • If nothing found: enter MarqueeSelecting state

Mouse Drag (when dragging selection):

  • Calculate delta from start_mouse
  • Update object positions (temporary, for preview)
  • Re-render with updated positions

Mouse Up:

  • If was dragging: create MoveObjectsAction
  • If was marquee: select objects in rectangle

5.3 Move Objects Action

File: lightningbeam-core/src/actions/move_objects.rs

pub struct MoveObjectsAction {
    layer_id: Uuid,
    object_transforms: HashMap<Uuid, (Transform, Transform)>, // (old, new)
}

impl Action for MoveObjectsAction {
    fn execute(&mut self, document: &mut Document) {
        let layer = document.get_layer_mut(&self.layer_id).unwrap();
        if let AnyLayer::Vector(vector_layer) = layer {
            for (object_id, (_old, new)) in &self.object_transforms {
                vector_layer.modify_object_internal(object_id, |obj| {
                    obj.transform = new.clone();
                });
            }
        }
    }

    fn rollback(&mut self, document: &mut Document) {
        let layer = document.get_layer_mut(&self.layer_id).unwrap();
        if let AnyLayer::Vector(vector_layer) = layer {
            for (object_id, (old, _new)) in &self.object_transforms {
                vector_layer.modify_object_internal(object_id, |obj| {
                    obj.transform = old.clone();
                });
            }
        }
    }
}

5.4 Selection Rendering

In VelloCallback::prepare():

  • After rendering document
  • For each selected object ID:
    • Get object and its shape from active layer
    • Calculate bounding box (with transform)
    • Draw selection outline (blue, 2px stroke)

Phase 6: Rectangle & Ellipse Tools

6.1 Add Shape Action

File: lightningbeam-core/src/actions/add_shape.rs

pub struct AddShapeAction {
    layer_id: Uuid,
    shape: Shape,
    object: Object,
    created_shape_id: Option<Uuid>,
    created_object_id: Option<Uuid>,
}

impl Action for AddShapeAction {
    fn execute(&mut self, document: &mut Document) {
        let layer = document.get_layer_mut(&self.layer_id).unwrap();
        if let AnyLayer::Vector(vector_layer) = layer {
            let shape_id = vector_layer.add_shape_internal(self.shape.clone());
            let object_id = vector_layer.add_object_internal(self.object.clone());
            self.created_shape_id = Some(shape_id);
            self.created_object_id = Some(object_id);
        }
    }

    fn rollback(&mut self, document: &mut Document) {
        if let (Some(shape_id), Some(object_id)) = (self.created_shape_id, self.created_object_id) {
            let layer = document.get_layer_mut(&self.layer_id).unwrap();
            if let AnyLayer::Vector(vector_layer) = layer {
                vector_layer.remove_object_internal(&object_id);
                vector_layer.remove_shape_internal(&shape_id);
            }
        }
    }
}

6.2 Rectangle Tool

Mouse Down: Enter CreatingRectangle { start_corner, current_corner }

Mouse Drag:

  • Update current_corner
  • If Shift: constrain to square (equal width/height)
  • Create preview path: Rect::from_points(start, current).to_path()
  • Render preview with dashed stroke

Mouse Up:

  • Create Shape with rectangle path
  • Create Object at (0, 0) with shape_id
  • Return AddShapeAction { layer_id, shape, object }

6.3 Ellipse Tool

Mouse Down: Enter CreatingEllipse { center, current_point }

Mouse Drag:

  • Calculate radii from center to current_point
  • If Shift: constrain to circle (equal radii)
  • Create preview: Circle::new(center, radius).to_path()
  • Render preview

Mouse Up:

  • Create Shape with ellipse path
  • Create Object with shape_id
  • Return AddShapeAction

Phase 7: Draw/Pen Tool

7.1 Path Fitting Module

File: lightningbeam-core/src/path_fitting.rs

Implement two algorithms from JS:

A. Ramer-Douglas-Peucker Simplification

pub fn simplify_rdp(points: &[Point], tolerance: f64) -> Vec<Point> {
    // Port from /src/simplify.js
    // 1. Radial distance filter first
    // 2. Then Douglas-Peucker recursive simplification
    // Tolerance: 10 (squared internally)
}

B. Schneider Curve Fitting

pub fn fit_bezier_curves(points: &[Point], max_error: f64) -> BezPath {
    // Port from /src/fit-curve.js
    // Based on Graphics Gems algorithm
    // 1. Chord-length parameterization
    // 2. Least-squares fit for control points
    // 3. Newton-Raphson refinement (max 20 iterations)
    // 4. Recursive split at max error point if needed
    // max_error: 30
}

7.2 Draw Tool Implementation

Mouse Down: Enter DrawingPath { points: vec![start], simplify_mode }

Mouse Drag:

  • Add point if distance from last point > threshold (2-5 pixels)
  • Build preview path from points
  • Render preview

Mouse Up:

  • Based on simplify_mode:
    • Corners: Apply RDP simplification (tolerance=10), then create mid-point Beziers
    • Smooth: Apply Schneider curve fitting (error=30)
    • Verbatim: Use points as-is
  • Create Shape with fitted path
  • Create Object with shape_id
  • Return AddShapeAction

Simplify Mode Setting: Add to EditorApp: pen_simplify_mode: SimplifyMode Show in info panel / toolbar


Phase 8: Transform Tool

8.1 Transform Handles

In VelloCallback::prepare() when Tool::Transform and selection non-empty:

Calculate selection bbox (union of all selected object bboxes):

let mut bbox = Rect::ZERO;
for object_id in selection.objects() {
    let object = get_object(object_id);
    let shape = get_shape(object.shape_id);
    bbox = bbox.union(object.bounding_box(shape));
}

Render 8 handles + rotation handle:

  • 4 corners (8x8 squares) → scale from opposite corner
  • 4 edge midpoints → scale along axis
  • 1 rotation handle (circle, 20px above top edge)
  • Bounding box outline

8.2 Handle Hit Testing

fn hit_test_transform_handle(
    point: Point,
    bbox: Rect,
    tolerance: f64,
) -> Option<TransformMode> {
    // Check rotation handle first
    let rotation_handle = Point::new(bbox.center().x, bbox.min_y() - 20.0);
    if point.distance(rotation_handle) < tolerance {
        return Some(TransformMode::Rotate { center: bbox.center() });
    }

    // Check corner handles
    let corners = [bbox.origin(), /* ... */];
    for (i, corner) in corners.iter().enumerate() {
        if point.distance(*corner) < tolerance {
            let opposite = corners[(i + 2) % 4];
            return Some(TransformMode::ScaleCorner { origin: opposite });
        }
    }

    // Check edge handles
    // ...
}

8.3 Transform Interaction

Mouse Down on handle:

  • Enter Transforming { mode, original_transforms, pivot }

Mouse Drag:

  • Calculate new transform based on mode:
    • ScaleCorner: Compute scale from opposite corner
    • ScaleEdge: Scale along one axis
    • Rotate: Compute angle from pivot to cursor
  • Apply to all selected objects (preview)

Mouse Up:

  • Create TransformObjectsAction
  • Return for execution

8.4 Transform Action

File: lightningbeam-core/src/actions/transform.rs

pub struct TransformObjectsAction {
    layer_id: Uuid,
    object_transforms: HashMap<Uuid, (Transform, Transform)>, // (old, new)
}

Similar to MoveObjectsAction but updates full Transform struct.


Phase 9: Paint Bucket Tool

9.1 Quadtree for Curve Indexing

File: lightningbeam-core/src/quadtree.rs

Port from JS (/src/utils.js):

pub struct Quadtree {
    bounds: Rect,
    capacity: usize,
    curves: Vec<(BezPath, usize)>, // (curve, index)
    subdivided: bool,
    // children: [Box<Quadtree>; 4]
}

impl Quadtree {
    pub fn insert(&mut self, curve: BezPath, index: usize);
    pub fn query(&self, range: Rect) -> Vec<usize>; // Return curve indices
}

9.2 Vector Flood Fill

File: lightningbeam-core/src/flood_fill.rs

Port from JS (/src/utils.js lines 173-307):

pub struct FloodFillRegion {
    start_point: Point,
    epsilon: f64,           // Gap closing tolerance (default: 5)
    canvas_bounds: Rect,
}

impl FloodFillRegion {
    pub fn fill(
        &self,
        shapes: &[Shape],      // All visible shapes on layer
    ) -> Result<Vec<Point>, String> {
        // 1. Build quadtree for all curves in all shapes
        // 2. Stack-based flood fill
        // 3. For each point:
        //    - Check if near any curve (using quadtree query + projection)
        //    - If near curve (within epsilon): save projection point, stop expanding
        //    - If not near: expand to 4 neighbors
        // 4. Return boundary points (projections on curves)
        // 5. If < 10 points found, retry with epsilon=1
    }

    fn is_near_curve(
        &self,
        point: Point,
        shape: &Shape,
        quadtree: &Quadtree,
    ) -> Option<Point> {
        let query_bbox = Rect::new(
            point.x - self.epsilon/2.0,
            point.y - self.epsilon/2.0,
            point.x + self.epsilon/2.0,
            point.y + self.epsilon/2.0,
        );

        for curve_idx in quadtree.query(query_bbox) {
            let curve = &shape.curves[curve_idx];
            let projection = curve.nearest(point, 0.1); // kurbo's nearest point
            if projection.distance_sq < self.epsilon * self.epsilon {
                return Some(projection.point);
            }
        }
        None
    }
}

9.3 Point Sorting

fn sort_points_by_proximity(points: Vec<Point>) -> Vec<Point> {
    // Port from JS lines 276-307
    // Greedy nearest-neighbor sort to create coherent path
}

9.4 Paint Bucket Action

File: lightningbeam-core/src/actions/paint_bucket.rs

pub struct PaintBucketAction {
    layer_id: Uuid,
    click_point: Point,
    epsilon: f64,
    created_shape_id: Option<Uuid>,
    created_object_id: Option<Uuid>,
}

impl Action for PaintBucketAction {
    fn execute(&mut self, document: &mut Document) {
        let layer = document.get_layer(&self.layer_id).unwrap();
        let AnyLayer::Vector(vector_layer) = layer else { return };

        // Get all shapes in layer
        let shapes: Vec<_> = vector_layer.shapes.clone();

        // Perform flood fill
        let fill_region = FloodFillRegion {
            start_point: self.click_point,
            epsilon: self.epsilon,
            canvas_bounds: Rect::new(0.0, 0.0, document.width, document.height),
        };

        let boundary_points = fill_region.fill(&shapes)?;

        // Sort points by proximity
        let sorted_points = sort_points_by_proximity(boundary_points);

        // Fit curve with very low error (1.0) for precision
        let path = fit_bezier_curves(&sorted_points, 1.0);

        // Create filled shape
        let shape = Shape::new(path)
            .with_fill(/* current fill color */)
            .without_stroke();

        // Create object
        let object = Object::new(shape.id);

        // Add to layer
        let layer = document.get_layer_mut(&self.layer_id).unwrap();
        if let AnyLayer::Vector(vector_layer) = layer {
            self.created_shape_id = Some(vector_layer.add_shape_internal(shape));
            self.created_object_id = Some(vector_layer.add_object_internal(object));
        }
    }

    fn rollback(&mut self, document: &mut Document) {
        // Remove created shape and object
    }
}

9.5 Paint Bucket Tool Handler

In handle_tool_input() when Tool::PaintBucket:

Mouse Click:

  • Get click position
  • Create PaintBucketAction { click_point, epsilon: 5.0 }
  • Return action for execution
  • Tool stays active for multiple fills

Phase 10: Eyedropper Tool

10.1 Color Sampling

In handle_tool_input() when Tool::Eyedropper:

Mouse Click:

  • Hit test at cursor position
  • If object found:
    • Get object's shape
    • Read shape's fill_color
    • Update fill_color in EditorApp
    • Show toast/feedback with sampled color
  • Tool stays active

Visual Feedback:

  • Custom cursor showing crosshair
  • Color preview circle at cursor
  • Display hex value

Implementation Order

Sprint 1: Foundation (3-4 days)

  • Action system (ActionExecutor, Action trait)
  • Document controlled access (pub(crate) methods)
  • Integrate ActionExecutor into EditorApp
  • Undo/redo shortcuts (Ctrl+Z, Ctrl+Shift+Z)

Sprint 2: Selection (3-4 days)

  • Selection state struct
  • Recursive hit testing (through GraphicsObject hierarchy)
  • Active layer tracking
  • Selection rendering
  • Click selection

Sprint 3: Select Tool (4-5 days)

  • Tool state management
  • Stage input handling refactor
  • Layer type validation
  • Drag-to-move (MoveObjectsAction)
  • Marquee selection

Sprint 4: Shape Tools (4-5 days)

  • AddShapeAction
  • Rectangle tool (with Shift constraint)
  • Ellipse tool (with Shift constraint)
  • Preview rendering
  • Integration with active layer

Sprint 5: Draw Tool (5-6 days)

  • RDP simplification algorithm
  • Schneider curve fitting algorithm
  • Path fitting module
  • Draw tool with mode selection
  • Preview rendering

Sprint 6: Transform Tool (5-6 days)

  • Transform handle rendering
  • Handle hit testing
  • Scale operations
  • Rotate operation
  • TransformObjectsAction

Sprint 7: Paint Bucket (6-7 days)

  • Quadtree implementation
  • Vector flood fill algorithm
  • Point sorting
  • Curve fitting integration
  • PaintBucketAction

Sprint 8: Polish (2-3 days)

  • Eyedropper tool
  • Tool cursors
  • Edge cases and bugs

Total: ~6-7 weeks


Key Architectural Corrections

GraphicsObject Nesting: Hit testing uses recursive transform multiplication through parent hierarchy

Shape Creation: Tools create Shape instances, then Object instances pointing to them, add both to VectorLayer

Layer Type Validation: Check active_layer is VectorLayer before tool operations

Path Fitting: Port exact JS algorithms (RDP tolerance=10, Schneider error=30)

Paint Bucket: Vector-based flood fill with quadtree-accelerated curve projection

Type Safety: Compile-time enforcement that document mutations only through actions