Work on region select

This commit is contained in:
Skyler Lehmkuhl 2026-02-21 06:04:54 -05:00
parent 469849a0d6
commit 2222e68a3e
16 changed files with 2163 additions and 6 deletions

View File

@ -31,6 +31,7 @@ pub mod remove_shapes;
pub mod set_keyframe;
pub mod group_shapes;
pub mod convert_to_movie_clip;
pub mod region_split;
pub use add_clip_instance::AddClipInstanceAction;
pub use add_effect::AddEffectAction;
@ -60,3 +61,4 @@ pub use remove_shapes::RemoveShapesAction;
pub use set_keyframe::SetKeyframeAction;
pub use group_shapes::GroupAction;
pub use convert_to_movie_clip::ConvertToMovieClipAction;
pub use region_split::RegionSplitAction;

View File

@ -0,0 +1,119 @@
//! Region split action
//!
//! Commits a temporary region-based shape split permanently.
//! Replaces original shapes with their inside and outside portions.
use crate::action::Action;
use crate::document::Document;
use crate::layer::AnyLayer;
use crate::shape::Shape;
use uuid::Uuid;
use vello::kurbo::BezPath;
/// One shape split entry for the action
#[derive(Clone, Debug)]
struct SplitEntry {
/// The original shape (for rollback)
original_shape: Shape,
/// The inside portion shape
inside_shape: Shape,
/// The outside portion shape
outside_shape: Shape,
}
/// Action that commits a region split — replacing original shapes with
/// their inside and outside portions.
pub struct RegionSplitAction {
layer_id: Uuid,
time: f64,
splits: Vec<SplitEntry>,
}
impl RegionSplitAction {
/// Create a new region split action.
///
/// Each tuple is (original_shape, inside_path, inside_id, outside_path, outside_id).
pub fn new(
layer_id: Uuid,
time: f64,
split_data: Vec<(Shape, BezPath, Uuid, BezPath, Uuid)>,
) -> Self {
let splits = split_data
.into_iter()
.map(|(original, inside_path, inside_id, outside_path, outside_id)| {
let mut inside_shape = original.clone();
inside_shape.id = inside_id;
inside_shape.versions[0].path = inside_path;
let mut outside_shape = original.clone();
outside_shape.id = outside_id;
outside_shape.versions[0].path = outside_path;
SplitEntry {
original_shape: original,
inside_shape,
outside_shape,
}
})
.collect();
Self {
layer_id,
time,
splits,
}
}
}
impl Action for RegionSplitAction {
fn execute(&mut self, document: &mut Document) -> Result<(), String> {
let layer = document
.get_layer_mut(&self.layer_id)
.ok_or_else(|| format!("Layer {} not found", self.layer_id))?;
let vector_layer = match layer {
AnyLayer::Vector(vl) => vl,
_ => return Err("Not a vector layer".to_string()),
};
for split in &self.splits {
// Remove original
vector_layer.remove_shape_from_keyframe(&split.original_shape.id, self.time);
// Add inside and outside portions
vector_layer.add_shape_to_keyframe(split.inside_shape.clone(), self.time);
vector_layer.add_shape_to_keyframe(split.outside_shape.clone(), self.time);
}
Ok(())
}
fn rollback(&mut self, document: &mut Document) -> Result<(), String> {
let layer = document
.get_layer_mut(&self.layer_id)
.ok_or_else(|| format!("Layer {} not found", self.layer_id))?;
let vector_layer = match layer {
AnyLayer::Vector(vl) => vl,
_ => return Err("Not a vector layer".to_string()),
};
for split in &self.splits {
// Remove inside and outside portions
vector_layer.remove_shape_from_keyframe(&split.inside_shape.id, self.time);
vector_layer.remove_shape_from_keyframe(&split.outside_shape.id, self.time);
// Restore original
vector_layer.add_shape_to_keyframe(split.original_shape.clone(), self.time);
}
Ok(())
}
fn description(&self) -> String {
let count = self.splits.len();
if count == 1 {
"Region split shape".to_string()
} else {
format!("Region split {} shapes", count)
}
}
}

View File

@ -5,10 +5,11 @@
use crate::clip::ClipInstance;
use crate::layer::VectorLayer;
use crate::region_select;
use crate::shape::Shape;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use vello::kurbo::{Affine, Point, Rect, Shape as KurboShape};
use vello::kurbo::{Affine, BezPath, Point, Rect, Shape as KurboShape};
/// Result of a hit test operation
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
@ -119,6 +120,66 @@ pub fn hit_test_objects_in_rect(
hits
}
/// Classification of shapes relative to a clipping region
#[derive(Debug, Clone)]
pub struct ShapeRegionClassification {
/// Shapes entirely inside the region
pub fully_inside: Vec<Uuid>,
/// Shapes whose paths cross the region boundary
pub intersecting: Vec<Uuid>,
/// Shapes with no overlap with the region
pub fully_outside: Vec<Uuid>,
}
/// Classify shapes in a layer relative to a clipping region.
///
/// Uses bounding box fast-rejection, then checks path-region intersection
/// and containment for accurate classification.
pub fn classify_shapes_by_region(
layer: &VectorLayer,
time: f64,
region: &BezPath,
parent_transform: Affine,
) -> ShapeRegionClassification {
let mut result = ShapeRegionClassification {
fully_inside: Vec::new(),
intersecting: Vec::new(),
fully_outside: Vec::new(),
};
let region_bbox = region.bounding_box();
for shape in layer.shapes_at_time(time) {
let combined_transform = parent_transform * shape.transform.to_affine();
let bbox = shape.path().bounding_box();
let transformed_bbox = combined_transform.transform_rect_bbox(bbox);
// Fast rejection: if bounding boxes don't overlap, fully outside
if region_bbox.intersect(transformed_bbox).area() <= 0.0 {
result.fully_outside.push(shape.id);
continue;
}
// Transform the shape path to world space for accurate testing
let world_path = {
let mut p = shape.path().clone();
p.apply_affine(combined_transform);
p
};
// Check if the path crosses the region boundary
if region_select::path_intersects_region(&world_path, region) {
result.intersecting.push(shape.id);
} else if region_select::path_fully_inside_region(&world_path, region) {
result.fully_inside.push(shape.id);
} else {
result.fully_outside.push(shape.id);
}
}
result
}
/// Get the bounding box of a shape in screen space
pub fn get_shape_bounds(
shape: &Shape,

View File

@ -489,14 +489,14 @@ impl VectorLayer {
/// Add a shape to the keyframe at the given time.
/// Creates a keyframe if none exists at that time.
pub(crate) fn add_shape_to_keyframe(&mut self, shape: Shape, time: f64) {
pub fn add_shape_to_keyframe(&mut self, shape: Shape, time: f64) {
let kf = self.ensure_keyframe_at(time);
kf.shapes.push(shape);
}
/// Remove a shape from the keyframe at the given time.
/// Returns the removed shape if found.
pub(crate) fn remove_shape_from_keyframe(&mut self, shape_id: &Uuid, time: f64) -> Option<Shape> {
pub fn remove_shape_from_keyframe(&mut self, shape_id: &Uuid, time: f64) -> Option<Shape> {
let kf = self.keyframe_at_mut(time)?;
let idx = kf.shapes.iter().position(|s| &s.id == shape_id)?;
Some(kf.shapes.remove(idx))

View File

@ -42,3 +42,4 @@ pub mod file_types;
pub mod file_io;
pub mod export;
pub mod clipboard;
pub mod region_select;

File diff suppressed because it is too large Load Diff

View File

@ -2,8 +2,10 @@
//!
//! Tracks selected shape instances, clip instances, and shapes for editing operations.
use crate::shape::Shape;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use vello::kurbo::BezPath;
/// Selection state for the editor
///
@ -212,6 +214,43 @@ impl Selection {
}
}
/// Represents a temporary region-based split of shapes.
///
/// When a region select is active, shapes that cross the region boundary
/// are temporarily split into "inside" and "outside" parts. The inside
/// parts are selected. If the user performs an operation, the split is
/// committed; if they deselect, the original shapes are restored.
#[derive(Clone, Debug)]
pub struct RegionSelection {
/// The clipping region as a closed BezPath (polygon or rect)
pub region_path: BezPath,
/// Layer containing the affected shapes
pub layer_id: Uuid,
/// Keyframe time
pub time: f64,
/// Per-shape split results
pub splits: Vec<ShapeSplit>,
/// Shape IDs that were fully inside the region (not split, just selected)
pub fully_inside_ids: Vec<Uuid>,
/// Whether the split has been committed (via an operation on the selection)
pub committed: bool,
}
/// One shape's split result from a region selection
#[derive(Clone, Debug)]
pub struct ShapeSplit {
/// The original shape (stored for reverting)
pub original_shape: Shape,
/// UUID for the "inside" portion shape
pub inside_shape_id: Uuid,
/// The clipped path inside the region
pub inside_path: BezPath,
/// UUID for the "outside" portion shape
pub outside_shape_id: Uuid,
/// The clipped path outside the region
pub outside_path: BezPath,
}
#[cfg(test)]
mod tests {
use super::*;

View File

@ -33,6 +33,23 @@ pub enum Tool {
BezierEdit,
/// Text tool - add and edit text
Text,
/// Region select tool - select sub-regions of shapes by clipping
RegionSelect,
}
/// Region select mode
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum RegionSelectMode {
/// Rectangular region selection
Rectangle,
/// Freehand lasso region selection
Lasso,
}
impl Default for RegionSelectMode {
fn default() -> Self {
Self::Rectangle
}
}
/// Tool state tracking for interactive operations
@ -117,6 +134,17 @@ pub enum ToolState {
parameter_t: f64, // Parameter where the drag started (0.0-1.0)
},
/// Drawing a region selection rectangle
RegionSelectingRect {
start: Point,
current: Point,
},
/// Drawing a freehand lasso region selection
RegionSelectingLasso {
points: Vec<Point>,
},
/// Editing a control point (BezierEdit tool only)
EditingControlPoint {
shape_id: Uuid, // Which shape is being edited
@ -179,6 +207,7 @@ impl Tool {
Tool::Polygon => "Polygon",
Tool::BezierEdit => "Bezier Edit",
Tool::Text => "Text",
Tool::RegionSelect => "Region Select",
}
}
@ -196,6 +225,7 @@ impl Tool {
Tool::Polygon => "polygon.svg",
Tool::BezierEdit => "bezier_edit.svg",
Tool::Text => "text.svg",
Tool::RegionSelect => "region_select.svg",
}
}
@ -213,6 +243,7 @@ impl Tool {
Tool::Polygon,
Tool::BezierEdit,
Tool::Text,
Tool::RegionSelect,
]
}
@ -230,6 +261,7 @@ impl Tool {
Tool::Polygon => "G",
Tool::BezierEdit => "A",
Tool::Text => "T",
Tool::RegionSelect => "S",
}
}
}

View File

@ -0,0 +1,201 @@
use lightningbeam_core::region_select::*;
use vello::kurbo::{BezPath, Point, Rect, Shape};
#[test]
fn debug_clip_rect_corner() {
// Rectangle from (0,0) to (200,200)
let mut subject = BezPath::new();
subject.move_to(Point::new(0.0, 0.0));
subject.line_to(Point::new(200.0, 0.0));
subject.line_to(Point::new(200.0, 200.0));
subject.line_to(Point::new(0.0, 200.0));
subject.close_path();
// Region: upper-right corner, from (100,0) to (300,100)
// This extends beyond the subject on the right and top
let region = rect_to_path(Rect::new(100.0, 0.0, 300.0, 100.0));
println!("Subject path: {:?}", subject);
println!("Region path: {:?}", region);
let result = clip_path_to_region(&subject, &region);
println!("Inside path: {:?}", result.inside);
println!("Outside path: {:?}", result.outside);
let inside_bb = result.inside.bounding_box();
println!("Inside bounding box: {:?}", inside_bb);
// Expected inside: a rectangle from (100,0) to (200,100)
assert!((inside_bb.x0 - 100.0).abs() < 2.0,
"inside x0 should be ~100, got {}", inside_bb.x0);
assert!((inside_bb.y0 - 0.0).abs() < 2.0,
"inside y0 should be ~0, got {}", inside_bb.y0);
assert!((inside_bb.x1 - 200.0).abs() < 2.0,
"inside x1 should be ~200, got {}", inside_bb.x1);
assert!((inside_bb.y1 - 100.0).abs() < 2.0,
"inside y1 should be ~100, got {}", inside_bb.y1);
// Verify the inside path has the right shape by checking it has ~5 elements
// (MoveTo, 3x LineTo, ClosePath) for a rectangle
let elem_count = result.inside.elements().len();
println!("Inside element count: {}", elem_count);
// Print each element
for (i, el) in result.inside.elements().iter().enumerate() {
println!(" inside[{}]: {:?}", i, el);
}
for (i, el) in result.outside.elements().iter().enumerate() {
println!(" outside[{}]: {:?}", i, el);
}
}
#[test]
fn debug_clip_partial_overlap() {
// When the region is fully contained inside the subject (no edge crossings),
// the path-clipping approach cannot split the subject since no path segments
// cross the region boundary. This is correct — the selection system handles
// this case by classifying the shape as "fully_inside" via hit_test, not
// via path clipping.
let mut subject = BezPath::new();
subject.move_to(Point::new(0.0, 0.0));
subject.line_to(Point::new(200.0, 0.0));
subject.line_to(Point::new(200.0, 200.0));
subject.line_to(Point::new(0.0, 200.0));
subject.close_path();
let region = rect_to_path(Rect::new(50.0, 50.0, 150.0, 150.0));
let result = clip_path_to_region(&subject, &region);
// No intersections found → entire subject classified as outside
assert!(result.inside.elements().is_empty(),
"No edge crossings → inside should be empty (handled by hit_test instead)");
assert!(!result.outside.elements().is_empty());
}
#[test]
fn debug_lasso_extending_beyond_subject() {
// Rectangle subject from (0,0) to (100,100)
let mut subject = BezPath::new();
subject.move_to(Point::new(0.0, 0.0));
subject.line_to(Point::new(100.0, 0.0));
subject.line_to(Point::new(100.0, 100.0));
subject.line_to(Point::new(0.0, 100.0));
subject.close_path();
// Lasso region that extends beyond the subject: a triangle
// covering (20,20) to (150,20) to (80,120)
// This extends beyond the right and bottom of the rectangle
let mut lasso = BezPath::new();
lasso.move_to(Point::new(20.0, 20.0));
lasso.line_to(Point::new(150.0, 20.0));
lasso.line_to(Point::new(80.0, 120.0));
lasso.close_path();
let result = clip_path_to_region(&subject, &lasso);
// The inside should be ONLY the intersection of the rectangle and lasso.
// It should NOT extend beyond the rectangle's bounds.
let inside_bb = result.inside.bounding_box();
println!("Lasso extending beyond: inside bb = {:?}", inside_bb);
for (i, el) in result.inside.elements().iter().enumerate() {
println!(" inside[{}]: {:?}", i, el);
}
// The inside must be contained within the subject rectangle
assert!(inside_bb.x0 >= -1.0, "inside x0={} should be >= 0", inside_bb.x0);
assert!(inside_bb.y0 >= -1.0, "inside y0={} should be >= 0", inside_bb.y0);
assert!(inside_bb.x1 <= 101.0, "inside x1={} should be <= 100", inside_bb.x1);
assert!(inside_bb.y1 <= 101.0, "inside y1={} should be <= 100", inside_bb.y1);
}
#[test]
fn debug_lasso_splits_remainder_into_two() {
// Rectangle subject from (0,0) to (200,200)
let mut subject = BezPath::new();
subject.move_to(Point::new(0.0, 0.0));
subject.line_to(Point::new(200.0, 0.0));
subject.line_to(Point::new(200.0, 200.0));
subject.line_to(Point::new(0.0, 200.0));
subject.close_path();
// Lasso that cuts across the rectangle, with vertices clearly NOT on
// the subject boundary. The lasso is a diamond that extends beyond
// the rect on top and right, splitting the remainder into two pieces:
// upper-left and lower-right.
let mut lasso = BezPath::new();
lasso.move_to(Point::new(-10.0, 100.0)); // left of rect
lasso.line_to(Point::new(100.0, -60.0)); // above rect
lasso.line_to(Point::new(260.0, 100.0)); // right of rect
lasso.line_to(Point::new(100.0, 210.0)); // below rect
lasso.close_path();
let result = clip_path_to_region(&subject, &lasso);
let inside_bb = result.inside.bounding_box();
println!("Lasso splits remainder: inside bb = {:?}", inside_bb);
for (i, el) in result.inside.elements().iter().enumerate() {
println!(" inside[{}]: {:?}", i, el);
}
// The inside must be contained within the subject rectangle bounds
assert!(inside_bb.x0 >= -1.0,
"inside x0={} should be >= 0 (must not extend left of subject)", inside_bb.x0);
assert!(inside_bb.y0 >= -1.0,
"inside y0={} should be >= 0 (must not extend above subject)", inside_bb.y0);
assert!(inside_bb.x1 <= 201.0,
"inside x1={} should be <= 200 (must not extend right of subject)", inside_bb.x1);
assert!(inside_bb.y1 <= 201.0,
"inside y1={} should be <= 200 (must not extend below subject)", inside_bb.y1);
// The outside (remainder) must also be within subject bounds
let outside_bb = result.outside.bounding_box();
println!("Lasso splits remainder: outside bb = {:?}", outside_bb);
for (i, el) in result.outside.elements().iter().enumerate() {
println!(" outside[{}]: {:?}", i, el);
}
assert!(outside_bb.x1 <= 201.0,
"outside x1={} must not extend right of subject (no lasso fill!)", outside_bb.x1);
assert!(outside_bb.y0 >= -1.0,
"outside y0={} must not extend above subject (no lasso fill!)", outside_bb.y0);
// The outside should have multiple separate sub-paths (multiple ClosePath elements)
// instead of one giant path that includes the lasso area
let close_count = result.outside.elements().iter()
.filter(|el| matches!(el, vello::kurbo::PathEl::ClosePath))
.count();
println!("Outside close_path count: {}", close_count);
assert!(close_count >= 2,
"Outside should be split into separate sub-paths, got {} ClosePaths", close_count);
}
#[test]
fn debug_clip_outside_shape_correct() {
// Test the exact scenario the user reported: outside shape should
// correctly include boundary walk points (no diagonal shortcuts)
let mut subject = BezPath::new();
subject.move_to(Point::new(0.0, 0.0));
subject.line_to(Point::new(200.0, 0.0));
subject.line_to(Point::new(200.0, 200.0));
subject.line_to(Point::new(0.0, 200.0));
subject.close_path();
let region = rect_to_path(Rect::new(100.0, 0.0, 300.0, 100.0));
let result = clip_path_to_region(&subject, &region);
// Outside should be an L-shape: (0,0)-(100,0)-(100,100)-(200,100)-(200,200)-(0,200)
let outside_bb = result.outside.bounding_box();
assert!((outside_bb.x0 - 0.0).abs() < 2.0);
assert!((outside_bb.y0 - 0.0).abs() < 2.0);
assert!((outside_bb.x1 - 200.0).abs() < 2.0);
assert!((outside_bb.y1 - 200.0).abs() < 2.0);
// Verify the path includes (200,100) — the critical boundary walk point
let has_200_100 = result.outside.elements().iter().any(|el| {
match *el {
vello::kurbo::PathEl::LineTo(p) => (p.x - 200.0).abs() < 1.0 && (p.y - 100.0).abs() < 1.0,
_ => false,
}
});
assert!(has_200_100, "Outside path must include point (200,100) from boundary walk");
}

View File

@ -0,0 +1,149 @@
use lightningbeam_core::region_select::*;
use vello::kurbo::{BezPath, Point, Rect, Shape};
#[test]
fn test_rect_to_path() {
let rect = Rect::new(10.0, 20.0, 100.0, 200.0);
let path = rect_to_path(rect);
assert!(path.elements().len() >= 5);
}
#[test]
fn test_lasso_to_path() {
let points = vec![
Point::new(0.0, 0.0),
Point::new(100.0, 0.0),
Point::new(100.0, 100.0),
Point::new(0.0, 100.0),
];
let path = lasso_to_path(&points);
assert!(path.elements().len() >= 5);
}
#[test]
fn test_clip_rect_corner() {
// Rectangle from (0,0) to (100,100)
let mut subject = BezPath::new();
subject.move_to(Point::new(0.0, 0.0));
subject.line_to(Point::new(100.0, 0.0));
subject.line_to(Point::new(100.0, 100.0));
subject.line_to(Point::new(0.0, 100.0));
subject.close_path();
// Clip to upper-right corner: region covers (50,0) to (150,50)
let region = rect_to_path(Rect::new(50.0, 0.0, 150.0, 50.0));
let result = clip_path_to_region(&subject, &region);
// Inside should have elements (the upper-right portion)
assert!(
!result.inside.elements().is_empty(),
"inside path should not be empty"
);
// Outside should have elements (the rest of the rectangle)
assert!(
!result.outside.elements().is_empty(),
"outside path should not be empty"
);
// The inside portion should be a roughly rectangular region
// Its bounding box should be approximately (50,0)-(100,50)
let inside_bb = result.inside.bounding_box();
assert!(
(inside_bb.x0 - 50.0).abs() < 2.0,
"inside x0 should be ~50, got {}",
inside_bb.x0
);
assert!(
(inside_bb.y0 - 0.0).abs() < 2.0,
"inside y0 should be ~0, got {}",
inside_bb.y0
);
assert!(
(inside_bb.x1 - 100.0).abs() < 2.0,
"inside x1 should be ~100, got {}",
inside_bb.x1
);
assert!(
(inside_bb.y1 - 50.0).abs() < 2.0,
"inside y1 should be ~50, got {}",
inside_bb.y1
);
}
#[test]
fn test_clip_fully_inside() {
let mut path = BezPath::new();
path.move_to(Point::new(20.0, 20.0));
path.line_to(Point::new(80.0, 20.0));
path.line_to(Point::new(80.0, 80.0));
path.line_to(Point::new(20.0, 80.0));
path.close_path();
let region = rect_to_path(Rect::new(0.0, 0.0, 100.0, 100.0));
let result = clip_path_to_region(&path, &region);
assert!(!result.inside.elements().is_empty());
assert!(result.outside.elements().is_empty());
}
#[test]
fn test_clip_fully_outside() {
let mut path = BezPath::new();
path.move_to(Point::new(200.0, 200.0));
path.line_to(Point::new(300.0, 200.0));
path.line_to(Point::new(300.0, 300.0));
path.close_path();
let region = rect_to_path(Rect::new(0.0, 0.0, 100.0, 100.0));
let result = clip_path_to_region(&path, &region);
assert!(result.inside.elements().is_empty());
assert!(!result.outside.elements().is_empty());
}
#[test]
fn test_path_intersects_region() {
let mut path = BezPath::new();
path.move_to(Point::new(-50.0, 50.0));
path.line_to(Point::new(150.0, 50.0));
let region = rect_to_path(Rect::new(0.0, 0.0, 100.0, 100.0));
assert!(path_intersects_region(&path, &region));
}
#[test]
fn test_path_fully_inside() {
let mut path = BezPath::new();
path.move_to(Point::new(20.0, 20.0));
path.line_to(Point::new(80.0, 20.0));
path.line_to(Point::new(80.0, 80.0));
path.close_path();
let region = rect_to_path(Rect::new(0.0, 0.0, 100.0, 100.0));
assert!(path_fully_inside_region(&path, &region));
assert!(!path_intersects_region(&path, &region));
}
#[test]
fn test_clip_horizontal_line_crossing() {
// A horizontal line crossing through a region
let mut subject = BezPath::new();
subject.move_to(Point::new(-50.0, 50.0));
subject.line_to(Point::new(150.0, 50.0));
let region = rect_to_path(Rect::new(0.0, 0.0, 100.0, 100.0));
let result = clip_path_to_region(&subject, &region);
// Inside should be the segment from x=0 to x=100 at y=50
let inside_bb = result.inside.bounding_box();
assert!(
(inside_bb.x0 - 0.0).abs() < 2.0,
"inside x0 should be ~0, got {}",
inside_bb.x0
);
assert!(
(inside_bb.x1 - 100.0).abs() < 2.0,
"inside x1 should be ~100, got {}",
inside_bb.x1
);
}

View File

@ -43,6 +43,7 @@ impl CustomCursor {
Tool::Polygon => CustomCursor::Polygon,
Tool::BezierEdit => CustomCursor::BezierEdit,
Tool::Text => CustomCursor::Text,
Tool::RegionSelect => CustomCursor::Select, // Reuse select cursor for now
}
}

View File

@ -333,6 +333,7 @@ impl ToolIconCache {
Tool::Polygon => tool_icons::POLYGON,
Tool::BezierEdit => tool_icons::BEZIER_EDIT,
Tool::Text => tool_icons::TEXT,
Tool::RegionSelect => tool_icons::SELECT, // Reuse select icon for now
};
if let Some(texture) = rasterize_svg(svg_data, tool.icon_file(), 180, ctx) {
self.icons.insert(tool, texture);
@ -741,6 +742,9 @@ struct EditorApp {
fill_enabled: bool, // Whether to fill shapes (default: true)
paint_bucket_gap_tolerance: f64, // Fill gap tolerance for paint bucket (default: 5.0)
polygon_sides: u32, // Number of sides for polygon tool (default: 5)
// Region select state
region_selection: Option<lightningbeam_core::selection::RegionSelection>,
region_select_mode: lightningbeam_core::tool::RegionSelectMode,
/// Cache for MIDI event data (keyed by backend midi_clip_id)
/// Prevents repeated backend queries for the same MIDI clip
@ -962,6 +966,8 @@ impl EditorApp {
fill_enabled: true, // Default to filling shapes
paint_bucket_gap_tolerance: 5.0, // Default gap tolerance
polygon_sides: 5, // Default to pentagon
region_selection: None,
region_select_mode: lightningbeam_core::tool::RegionSelectMode::default(),
midi_event_cache: HashMap::new(), // Initialize empty MIDI event cache
audio_duration_cache: HashMap::new(), // Initialize empty audio duration cache
audio_pools_with_new_waveforms: HashSet::new(), // Track pool indices with new raw audio
@ -2003,6 +2009,42 @@ impl EditorApp {
}
}
/// Revert an uncommitted region selection, restoring original shapes
fn revert_region_selection(
region_selection: &mut Option<lightningbeam_core::selection::RegionSelection>,
action_executor: &mut lightningbeam_core::action::ActionExecutor,
selection: &mut lightningbeam_core::selection::Selection,
) {
use lightningbeam_core::layer::AnyLayer;
let region_sel = match region_selection.take() {
Some(rs) => rs,
None => return,
};
if region_sel.committed {
return;
}
let doc = action_executor.document_mut();
let layer = match doc.get_layer_mut(&region_sel.layer_id) {
Some(l) => l,
None => return,
};
let vector_layer = match layer {
AnyLayer::Vector(vl) => vl,
_ => return,
};
for split in &region_sel.splits {
vector_layer.remove_shape_from_keyframe(&split.inside_shape_id, region_sel.time);
vector_layer.remove_shape_from_keyframe(&split.outside_shape_id, region_sel.time);
vector_layer.add_shape_to_keyframe(split.original_shape.clone(), region_sel.time);
}
selection.clear();
}
fn handle_menu_action(&mut self, action: MenuAction) {
match action {
// File menu
@ -4687,6 +4729,8 @@ impl eframe::App for EditorApp {
project_generation: &mut self.project_generation,
script_to_edit: &mut self.script_to_edit,
script_saved: &mut self.script_saved,
region_selection: &mut self.region_selection,
region_select_mode: &mut self.region_select_mode,
};
render_layout_node(
@ -4892,10 +4936,23 @@ impl eframe::App for EditorApp {
self.selected_tool = Tool::BezierEdit;
} else if i.key_pressed(egui::Key::T) {
self.selected_tool = Tool::Text;
} else if i.key_pressed(egui::Key::S) {
self.selected_tool = Tool::RegionSelect;
}
}
});
// Escape key: revert uncommitted region selection
if !wants_keyboard && ctx.input(|i| i.key_pressed(egui::Key::Escape)) {
if self.region_selection.is_some() {
Self::revert_region_selection(
&mut self.region_selection,
&mut self.action_executor,
&mut self.selection,
);
}
}
// F3 debug overlay toggle (works even when text input is active)
if ctx.input(|i| i.key_pressed(egui::Key::F3)) {
self.debug_overlay_visible = !self.debug_overlay_visible;
@ -5004,6 +5061,10 @@ struct RenderContext<'a> {
script_to_edit: &'a mut Option<Uuid>,
/// Script ID just saved (triggers auto-recompile of nodes using it)
script_saved: &'a mut Option<Uuid>,
/// Active region selection (temporary split state)
region_selection: &'a mut Option<lightningbeam_core::selection::RegionSelection>,
/// Region select mode (Rectangle or Lasso)
region_select_mode: &'a mut lightningbeam_core::tool::RegionSelectMode,
}
/// Recursively render a layout node with drag support
@ -5488,6 +5549,8 @@ fn render_pane(
project_generation: ctx.project_generation,
script_to_edit: ctx.script_to_edit,
script_saved: ctx.script_saved,
region_selection: ctx.region_selection,
region_select_mode: ctx.region_select_mode,
editing_clip_id: ctx.editing_clip_id,
editing_instance_id: ctx.editing_instance_id,
editing_parent_layer_id: ctx.editing_parent_layer_id,
@ -5566,6 +5629,8 @@ fn render_pane(
project_generation: ctx.project_generation,
script_to_edit: ctx.script_to_edit,
script_saved: ctx.script_saved,
region_selection: ctx.region_selection,
region_select_mode: ctx.region_select_mode,
editing_clip_id: ctx.editing_clip_id,
editing_instance_id: ctx.editing_instance_id,
editing_parent_layer_id: ctx.editing_parent_layer_id,

View File

@ -206,7 +206,7 @@ impl InfopanelPane {
// Only show tool options for tools that have options
let has_options = matches!(
tool,
Tool::Draw | Tool::Rectangle | Tool::Ellipse | Tool::PaintBucket | Tool::Polygon | Tool::Line
Tool::Draw | Tool::Rectangle | Tool::Ellipse | Tool::PaintBucket | Tool::Polygon | Tool::Line | Tool::RegionSelect
);
if !has_options {
@ -311,6 +311,25 @@ impl InfopanelPane {
});
}
Tool::RegionSelect => {
use lightningbeam_core::tool::RegionSelectMode;
ui.horizontal(|ui| {
ui.label("Mode:");
if ui.selectable_label(
*shared.region_select_mode == RegionSelectMode::Rectangle,
"Rectangle",
).clicked() {
*shared.region_select_mode = RegionSelectMode::Rectangle;
}
if ui.selectable_label(
*shared.region_select_mode == RegionSelectMode::Lasso,
"Lasso",
).clicked() {
*shared.region_select_mode = RegionSelectMode::Lasso;
}
});
}
_ => {}
}

View File

@ -233,6 +233,10 @@ pub struct SharedPaneState<'a> {
pub script_to_edit: &'a mut Option<Uuid>,
/// Script ID that was just saved (triggers auto-recompile of nodes using it)
pub script_saved: &'a mut Option<Uuid>,
/// Active region selection (temporary split state)
pub region_selection: &'a mut Option<lightningbeam_core::selection::RegionSelection>,
/// Region select mode (Rectangle or Lasso)
pub region_select_mode: &'a mut lightningbeam_core::tool::RegionSelectMode,
}
/// Trait for pane rendering

View File

@ -386,6 +386,8 @@ struct VelloRenderContext {
editing_instance_id: Option<uuid::Uuid>,
/// The parent layer ID containing the clip instance being edited
editing_parent_layer_id: Option<uuid::Uuid>,
/// Active region selection state (for rendering boundary overlay)
region_selection: Option<lightningbeam_core::selection::RegionSelection>,
}
/// Callback for Vello rendering within egui
@ -1107,6 +1109,81 @@ impl egui_wgpu::CallbackTrait for VelloCallback {
);
}
// 2b. Draw region selection overlay (rect or lasso)
match &self.ctx.tool_state {
lightningbeam_core::tool::ToolState::RegionSelectingRect { start, current } => {
let region_rect = KurboRect::new(
start.x.min(current.x),
start.y.min(current.y),
start.x.max(current.x),
start.y.max(current.y),
);
// Semi-transparent orange fill
let region_fill = Color::from_rgba8(255, 150, 0, 60);
scene.fill(
Fill::NonZero,
overlay_transform,
region_fill,
None,
&region_rect,
);
// Dashed-like border (solid for now)
let region_stroke_color = Color::from_rgba8(255, 150, 0, 200);
scene.stroke(
&Stroke::new(1.5),
overlay_transform,
region_stroke_color,
None,
&region_rect,
);
}
lightningbeam_core::tool::ToolState::RegionSelectingLasso { points } => {
if points.len() >= 2 {
// Build polyline path
let mut lasso_path = vello::kurbo::BezPath::new();
lasso_path.move_to(points[0]);
for &p in &points[1..] {
lasso_path.line_to(p);
}
// Close back to start
lasso_path.close_path();
// Semi-transparent orange fill
let region_fill = Color::from_rgba8(255, 150, 0, 60);
scene.fill(
Fill::NonZero,
overlay_transform,
region_fill,
None,
&lasso_path,
);
// Border
let region_stroke_color = Color::from_rgba8(255, 150, 0, 200);
scene.stroke(
&Stroke::new(1.5),
overlay_transform,
region_stroke_color,
None,
&lasso_path,
);
}
}
_ => {}
}
// 2c. Draw active region selection boundary
if let Some(ref region_sel) = self.ctx.region_selection {
// Draw the region boundary as a dashed outline
let boundary_color = Color::from_rgba8(255, 150, 0, 150);
scene.stroke(
&Stroke::new(1.0).with_dashes(0.0, &[6.0, 4.0]),
overlay_transform,
boundary_color,
None,
&region_sel.region_path,
);
}
// 3. Draw rectangle creation preview
if let lightningbeam_core::tool::ToolState::CreatingRectangle { ref start_point, ref current_point, centered, constrain_square, .. } = self.ctx.tool_state {
use vello::kurbo::Point;
@ -3681,6 +3758,261 @@ impl StagePane {
}
}
fn handle_region_select_tool(
&mut self,
_ui: &mut egui::Ui,
response: &egui::Response,
world_pos: egui::Vec2,
shared: &mut SharedPaneState,
) {
use lightningbeam_core::tool::{ToolState, RegionSelectMode};
use lightningbeam_core::region_select;
use vello::kurbo::{Point, Rect as KurboRect};
let point = Point::new(world_pos.x as f64, world_pos.y as f64);
let active_layer_id = match *shared.active_layer_id {
Some(id) => id,
None => return,
};
// Mouse down: start region selection
if response.drag_started() {
// Revert any existing uncommitted region selection
Self::revert_region_selection_static(shared);
match *shared.region_select_mode {
RegionSelectMode::Rectangle => {
*shared.tool_state = ToolState::RegionSelectingRect {
start: point,
current: point,
};
}
RegionSelectMode::Lasso => {
*shared.tool_state = ToolState::RegionSelectingLasso {
points: vec![point],
};
}
}
}
// Mouse drag: update region
if response.dragged() {
match shared.tool_state {
ToolState::RegionSelectingRect { ref start, .. } => {
let start = *start;
*shared.tool_state = ToolState::RegionSelectingRect {
start,
current: point,
};
}
ToolState::RegionSelectingLasso { ref mut points } => {
if let Some(last) = points.last() {
if (point.x - last.x).hypot(point.y - last.y) > 3.0 {
points.push(point);
}
}
}
_ => {}
}
}
// Mouse up: execute region selection
if response.drag_stopped() {
let region_path = match &*shared.tool_state {
ToolState::RegionSelectingRect { start, current } => {
let min_x = start.x.min(current.x);
let min_y = start.y.min(current.y);
let max_x = start.x.max(current.x);
let max_y = start.y.max(current.y);
// Ignore tiny drags
if (max_x - min_x) < 2.0 || (max_y - min_y) < 2.0 {
*shared.tool_state = ToolState::Idle;
return;
}
Some(region_select::rect_to_path(KurboRect::new(min_x, min_y, max_x, max_y)))
}
ToolState::RegionSelectingLasso { points } => {
if points.len() >= 3 {
Some(region_select::lasso_to_path(points))
} else {
None
}
}
_ => None,
};
*shared.tool_state = ToolState::Idle;
if let Some(region_path) = region_path {
Self::execute_region_select(shared, region_path, active_layer_id);
}
}
}
/// Execute region selection: classify shapes, clip intersecting ones, create temporary split
fn execute_region_select(
shared: &mut SharedPaneState,
region_path: vello::kurbo::BezPath,
layer_id: uuid::Uuid,
) {
use lightningbeam_core::hit_test;
use lightningbeam_core::layer::AnyLayer;
use lightningbeam_core::region_select;
use lightningbeam_core::selection::ShapeSplit;
use vello::kurbo::Affine;
let time = *shared.playback_time;
// Classify shapes
let classification = {
let document = shared.action_executor.document();
let layer = match document.get_layer(&layer_id) {
Some(l) => l,
None => return,
};
let vector_layer = match layer {
AnyLayer::Vector(vl) => vl,
_ => return,
};
hit_test::classify_shapes_by_region(vector_layer, time, &region_path, Affine::IDENTITY)
};
// If nothing is inside or intersecting, do nothing
if classification.fully_inside.is_empty() && classification.intersecting.is_empty() {
return;
}
shared.selection.clear();
// Select fully-inside shapes directly
for &id in &classification.fully_inside {
shared.selection.add_shape_instance(id);
}
// For intersecting shapes: compute clip and create temporary splits
let mut splits = Vec::new();
// Collect shape data we need before mutating the document
let shape_data: Vec<_> = {
let document = shared.action_executor.document();
let layer = document.get_layer(&layer_id).unwrap();
let vector_layer = match layer {
AnyLayer::Vector(vl) => vl,
_ => return,
};
classification.intersecting.iter().filter_map(|id| {
vector_layer.get_shape_in_keyframe(id, time)
.map(|shape| {
// Transform path to world space for clipping
let mut world_path = shape.path().clone();
world_path.apply_affine(shape.transform.to_affine());
(shape.clone(), world_path)
})
}).collect()
};
for (shape, world_path) in &shape_data {
let clip_result = region_select::clip_path_to_region(world_path, &region_path);
if clip_result.inside.elements().is_empty() {
continue;
}
let inside_id = uuid::Uuid::new_v4();
let outside_id = uuid::Uuid::new_v4();
// Transform clipped paths back to local space
let inv_transform = shape.transform.to_affine().inverse();
let mut inside_path = clip_result.inside;
inside_path.apply_affine(inv_transform);
let mut outside_path = clip_result.outside;
outside_path.apply_affine(inv_transform);
splits.push(ShapeSplit {
original_shape: shape.clone(),
inside_shape_id: inside_id,
inside_path: inside_path.clone(),
outside_shape_id: outside_id,
outside_path: outside_path.clone(),
});
shared.selection.add_shape_instance(inside_id);
}
// Apply temporary split to document
if !splits.is_empty() {
let doc = shared.action_executor.document_mut();
let layer = doc.get_layer_mut(&layer_id).unwrap();
let vector_layer = match layer {
AnyLayer::Vector(vl) => vl,
_ => return,
};
for split in &splits {
// Remove original shape
vector_layer.remove_shape_from_keyframe(&split.original_shape.id, time);
// Add inside shape
let mut inside_shape = split.original_shape.clone();
inside_shape.id = split.inside_shape_id;
inside_shape.versions[0].path = split.inside_path.clone();
vector_layer.add_shape_to_keyframe(inside_shape, time);
// Add outside shape
let mut outside_shape = split.original_shape.clone();
outside_shape.id = split.outside_shape_id;
outside_shape.versions[0].path = split.outside_path.clone();
vector_layer.add_shape_to_keyframe(outside_shape, time);
}
}
// Store region selection state
*shared.region_selection = Some(lightningbeam_core::selection::RegionSelection {
region_path,
layer_id,
time,
splits,
fully_inside_ids: classification.fully_inside,
committed: false,
});
}
/// Revert an uncommitted region selection, restoring original shapes
fn revert_region_selection_static(shared: &mut SharedPaneState) {
use lightningbeam_core::layer::AnyLayer;
let region_sel = match shared.region_selection.take() {
Some(rs) => rs,
None => return,
};
if region_sel.committed {
// Already committed via action system, nothing to revert
return;
}
let doc = shared.action_executor.document_mut();
let layer = match doc.get_layer_mut(&region_sel.layer_id) {
Some(l) => l,
None => return,
};
let vector_layer = match layer {
AnyLayer::Vector(vl) => vl,
_ => return,
};
for split in &region_sel.splits {
// Remove temporary inside/outside shapes
vector_layer.remove_shape_from_keyframe(&split.inside_shape_id, region_sel.time);
vector_layer.remove_shape_from_keyframe(&split.outside_shape_id, region_sel.time);
// Restore original
vector_layer.add_shape_to_keyframe(split.original_shape.clone(), region_sel.time);
}
shared.selection.clear();
}
/// Create a rectangle path centered at origin (easier for curve editing later)
fn create_rectangle_path(width: f64, height: f64) -> vello::kurbo::BezPath {
use vello::kurbo::{BezPath, Point};
@ -5815,6 +6147,9 @@ impl StagePane {
Tool::Eyedropper => {
self.handle_eyedropper_tool(ui, &response, mouse_pos, shared);
}
Tool::RegionSelect => {
self.handle_region_select_tool(ui, &response, world_pos, shared);
}
_ => {
// Other tools not implemented yet
}
@ -6532,6 +6867,7 @@ impl PaneRenderer for StagePane {
editing_clip_id: shared.editing_clip_id,
editing_instance_id: shared.editing_instance_id,
editing_parent_layer_id: shared.editing_parent_layer_id,
region_selection: shared.region_selection.clone(),
}};
let cb = egui_wgpu::Callback::new_paint_callback(

View File

@ -4,7 +4,7 @@
/// Users can click to select tools, which updates the global selected_tool state.
use eframe::egui;
use lightningbeam_core::tool::Tool;
use lightningbeam_core::tool::{Tool, RegionSelectMode};
use super::{NodePath, PaneRenderer, SharedPaneState};
/// Toolbar pane state
@ -83,6 +83,24 @@ impl PaneRenderer for ToolbarPane {
);
}
// Draw sub-tool arrow indicator for tools with modes
let has_sub_tools = matches!(tool, Tool::RegionSelect);
if has_sub_tools {
let arrow_size = 6.0;
let margin = 4.0;
let corner = button_rect.right_bottom() - egui::vec2(margin, margin);
let tri = [
corner,
corner - egui::vec2(arrow_size, 0.0),
corner - egui::vec2(0.0, arrow_size),
];
ui.painter().add(egui::Shape::convex_polygon(
tri.to_vec(),
egui::Color32::from_gray(200),
egui::Stroke::NONE,
));
}
// Make button interactive (include path to ensure unique IDs across panes)
let button_id = ui.id().with(("tool_button", path, *tool as usize));
let response = ui.interact(button_rect, button_id, egui::Sense::click());
@ -92,6 +110,34 @@ impl PaneRenderer for ToolbarPane {
*shared.selected_tool = *tool;
}
// Right-click context menu for tools with sub-options
if has_sub_tools {
response.context_menu(|ui| {
match tool {
Tool::RegionSelect => {
ui.set_min_width(120.0);
if ui.selectable_label(
*shared.region_select_mode == RegionSelectMode::Rectangle,
"Rectangle",
).clicked() {
*shared.region_select_mode = RegionSelectMode::Rectangle;
*shared.selected_tool = Tool::RegionSelect;
ui.close();
}
if ui.selectable_label(
*shared.region_select_mode == RegionSelectMode::Lasso,
"Lasso",
).clicked() {
*shared.region_select_mode = RegionSelectMode::Lasso;
*shared.selected_tool = Tool::RegionSelect;
ui.close();
}
}
_ => {}
}
});
}
if response.hovered() {
ui.painter().rect_stroke(
button_rect,
@ -102,7 +148,16 @@ impl PaneRenderer for ToolbarPane {
}
// Show tooltip with tool name and shortcut (consumes response)
response.on_hover_text(format!("{} ({})", tool.display_name(), tool.shortcut_hint()));
let tooltip = if *tool == Tool::RegionSelect {
let mode = match *shared.region_select_mode {
RegionSelectMode::Rectangle => "Rectangle",
RegionSelectMode::Lasso => "Lasso",
};
format!("{} - {} ({})\nRight-click for options", tool.display_name(), mode, tool.shortcut_hint())
} else {
format!("{} ({})", tool.display_name(), tool.shortcut_hint())
};
response.on_hover_text(tooltip);
// Draw selection border
if is_selected {