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 activeVectorLayer - ✅ Tools only work on
VectorLayer(checkactive_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 accessexecute(&mut self, Box<dyn Action>)- Execute + push to undoundo(&mut self) -> bool- Pop and rollbackredo(&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 GraphicsObjectget_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) -> Uuidadd_object_internal(&mut self, object: Object) -> Uuidremove_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: Documentwithaction_executor: ActionExecutor - Add
active_layer_id: Option<Uuid>to track current layer - Update
SharedPaneStateto passdocument: &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
DraggingSelectionstate
- If nothing found: enter
MarqueeSelectingstate
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
Shapewith rectangle path - Create
Objectat (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
Shapewith ellipse path - Create
Objectwith 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
Shapewith fitted path - Create
Objectwith 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_colorin 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