Work on region select
This commit is contained in:
parent
469849a0d6
commit
2222e68a3e
|
|
@ -31,6 +31,7 @@ pub mod remove_shapes;
|
||||||
pub mod set_keyframe;
|
pub mod set_keyframe;
|
||||||
pub mod group_shapes;
|
pub mod group_shapes;
|
||||||
pub mod convert_to_movie_clip;
|
pub mod convert_to_movie_clip;
|
||||||
|
pub mod region_split;
|
||||||
|
|
||||||
pub use add_clip_instance::AddClipInstanceAction;
|
pub use add_clip_instance::AddClipInstanceAction;
|
||||||
pub use add_effect::AddEffectAction;
|
pub use add_effect::AddEffectAction;
|
||||||
|
|
@ -60,3 +61,4 @@ pub use remove_shapes::RemoveShapesAction;
|
||||||
pub use set_keyframe::SetKeyframeAction;
|
pub use set_keyframe::SetKeyframeAction;
|
||||||
pub use group_shapes::GroupAction;
|
pub use group_shapes::GroupAction;
|
||||||
pub use convert_to_movie_clip::ConvertToMovieClipAction;
|
pub use convert_to_movie_clip::ConvertToMovieClipAction;
|
||||||
|
pub use region_split::RegionSplitAction;
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -5,10 +5,11 @@
|
||||||
|
|
||||||
use crate::clip::ClipInstance;
|
use crate::clip::ClipInstance;
|
||||||
use crate::layer::VectorLayer;
|
use crate::layer::VectorLayer;
|
||||||
|
use crate::region_select;
|
||||||
use crate::shape::Shape;
|
use crate::shape::Shape;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use uuid::Uuid;
|
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
|
/// Result of a hit test operation
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
|
@ -119,6 +120,66 @@ pub fn hit_test_objects_in_rect(
|
||||||
hits
|
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
|
/// Get the bounding box of a shape in screen space
|
||||||
pub fn get_shape_bounds(
|
pub fn get_shape_bounds(
|
||||||
shape: &Shape,
|
shape: &Shape,
|
||||||
|
|
|
||||||
|
|
@ -489,14 +489,14 @@ impl VectorLayer {
|
||||||
|
|
||||||
/// Add a shape to the keyframe at the given time.
|
/// Add a shape to the keyframe at the given time.
|
||||||
/// Creates a keyframe if none exists at that 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);
|
let kf = self.ensure_keyframe_at(time);
|
||||||
kf.shapes.push(shape);
|
kf.shapes.push(shape);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Remove a shape from the keyframe at the given time.
|
/// Remove a shape from the keyframe at the given time.
|
||||||
/// Returns the removed shape if found.
|
/// 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 kf = self.keyframe_at_mut(time)?;
|
||||||
let idx = kf.shapes.iter().position(|s| &s.id == shape_id)?;
|
let idx = kf.shapes.iter().position(|s| &s.id == shape_id)?;
|
||||||
Some(kf.shapes.remove(idx))
|
Some(kf.shapes.remove(idx))
|
||||||
|
|
|
||||||
|
|
@ -42,3 +42,4 @@ pub mod file_types;
|
||||||
pub mod file_io;
|
pub mod file_io;
|
||||||
pub mod export;
|
pub mod export;
|
||||||
pub mod clipboard;
|
pub mod clipboard;
|
||||||
|
pub mod region_select;
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -2,8 +2,10 @@
|
||||||
//!
|
//!
|
||||||
//! Tracks selected shape instances, clip instances, and shapes for editing operations.
|
//! Tracks selected shape instances, clip instances, and shapes for editing operations.
|
||||||
|
|
||||||
|
use crate::shape::Shape;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
use vello::kurbo::BezPath;
|
||||||
|
|
||||||
/// Selection state for the editor
|
/// 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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,23 @@ pub enum Tool {
|
||||||
BezierEdit,
|
BezierEdit,
|
||||||
/// Text tool - add and edit text
|
/// Text tool - add and edit text
|
||||||
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
|
/// 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)
|
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)
|
/// Editing a control point (BezierEdit tool only)
|
||||||
EditingControlPoint {
|
EditingControlPoint {
|
||||||
shape_id: Uuid, // Which shape is being edited
|
shape_id: Uuid, // Which shape is being edited
|
||||||
|
|
@ -179,6 +207,7 @@ impl Tool {
|
||||||
Tool::Polygon => "Polygon",
|
Tool::Polygon => "Polygon",
|
||||||
Tool::BezierEdit => "Bezier Edit",
|
Tool::BezierEdit => "Bezier Edit",
|
||||||
Tool::Text => "Text",
|
Tool::Text => "Text",
|
||||||
|
Tool::RegionSelect => "Region Select",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -196,6 +225,7 @@ impl Tool {
|
||||||
Tool::Polygon => "polygon.svg",
|
Tool::Polygon => "polygon.svg",
|
||||||
Tool::BezierEdit => "bezier_edit.svg",
|
Tool::BezierEdit => "bezier_edit.svg",
|
||||||
Tool::Text => "text.svg",
|
Tool::Text => "text.svg",
|
||||||
|
Tool::RegionSelect => "region_select.svg",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -213,6 +243,7 @@ impl Tool {
|
||||||
Tool::Polygon,
|
Tool::Polygon,
|
||||||
Tool::BezierEdit,
|
Tool::BezierEdit,
|
||||||
Tool::Text,
|
Tool::Text,
|
||||||
|
Tool::RegionSelect,
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -230,6 +261,7 @@ impl Tool {
|
||||||
Tool::Polygon => "G",
|
Tool::Polygon => "G",
|
||||||
Tool::BezierEdit => "A",
|
Tool::BezierEdit => "A",
|
||||||
Tool::Text => "T",
|
Tool::Text => "T",
|
||||||
|
Tool::RegionSelect => "S",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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, ®ion);
|
||||||
|
|
||||||
|
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, ®ion);
|
||||||
|
|
||||||
|
// 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, ®ion);
|
||||||
|
|
||||||
|
// 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");
|
||||||
|
}
|
||||||
|
|
@ -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, ®ion);
|
||||||
|
|
||||||
|
// 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, ®ion);
|
||||||
|
|
||||||
|
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, ®ion);
|
||||||
|
|
||||||
|
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, ®ion));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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, ®ion));
|
||||||
|
assert!(!path_intersects_region(&path, ®ion));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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, ®ion);
|
||||||
|
|
||||||
|
// 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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -43,6 +43,7 @@ impl CustomCursor {
|
||||||
Tool::Polygon => CustomCursor::Polygon,
|
Tool::Polygon => CustomCursor::Polygon,
|
||||||
Tool::BezierEdit => CustomCursor::BezierEdit,
|
Tool::BezierEdit => CustomCursor::BezierEdit,
|
||||||
Tool::Text => CustomCursor::Text,
|
Tool::Text => CustomCursor::Text,
|
||||||
|
Tool::RegionSelect => CustomCursor::Select, // Reuse select cursor for now
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -333,6 +333,7 @@ impl ToolIconCache {
|
||||||
Tool::Polygon => tool_icons::POLYGON,
|
Tool::Polygon => tool_icons::POLYGON,
|
||||||
Tool::BezierEdit => tool_icons::BEZIER_EDIT,
|
Tool::BezierEdit => tool_icons::BEZIER_EDIT,
|
||||||
Tool::Text => tool_icons::TEXT,
|
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) {
|
if let Some(texture) = rasterize_svg(svg_data, tool.icon_file(), 180, ctx) {
|
||||||
self.icons.insert(tool, texture);
|
self.icons.insert(tool, texture);
|
||||||
|
|
@ -741,6 +742,9 @@ struct EditorApp {
|
||||||
fill_enabled: bool, // Whether to fill shapes (default: true)
|
fill_enabled: bool, // Whether to fill shapes (default: true)
|
||||||
paint_bucket_gap_tolerance: f64, // Fill gap tolerance for paint bucket (default: 5.0)
|
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)
|
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)
|
/// Cache for MIDI event data (keyed by backend midi_clip_id)
|
||||||
/// Prevents repeated backend queries for the same MIDI clip
|
/// Prevents repeated backend queries for the same MIDI clip
|
||||||
|
|
@ -962,6 +966,8 @@ impl EditorApp {
|
||||||
fill_enabled: true, // Default to filling shapes
|
fill_enabled: true, // Default to filling shapes
|
||||||
paint_bucket_gap_tolerance: 5.0, // Default gap tolerance
|
paint_bucket_gap_tolerance: 5.0, // Default gap tolerance
|
||||||
polygon_sides: 5, // Default to pentagon
|
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
|
midi_event_cache: HashMap::new(), // Initialize empty MIDI event cache
|
||||||
audio_duration_cache: HashMap::new(), // Initialize empty audio duration 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
|
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(®ion_sel.layer_id) {
|
||||||
|
Some(l) => l,
|
||||||
|
None => return,
|
||||||
|
};
|
||||||
|
let vector_layer = match layer {
|
||||||
|
AnyLayer::Vector(vl) => vl,
|
||||||
|
_ => return,
|
||||||
|
};
|
||||||
|
|
||||||
|
for split in ®ion_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) {
|
fn handle_menu_action(&mut self, action: MenuAction) {
|
||||||
match action {
|
match action {
|
||||||
// File menu
|
// File menu
|
||||||
|
|
@ -4687,6 +4729,8 @@ impl eframe::App for EditorApp {
|
||||||
project_generation: &mut self.project_generation,
|
project_generation: &mut self.project_generation,
|
||||||
script_to_edit: &mut self.script_to_edit,
|
script_to_edit: &mut self.script_to_edit,
|
||||||
script_saved: &mut self.script_saved,
|
script_saved: &mut self.script_saved,
|
||||||
|
region_selection: &mut self.region_selection,
|
||||||
|
region_select_mode: &mut self.region_select_mode,
|
||||||
};
|
};
|
||||||
|
|
||||||
render_layout_node(
|
render_layout_node(
|
||||||
|
|
@ -4892,10 +4936,23 @@ impl eframe::App for EditorApp {
|
||||||
self.selected_tool = Tool::BezierEdit;
|
self.selected_tool = Tool::BezierEdit;
|
||||||
} else if i.key_pressed(egui::Key::T) {
|
} else if i.key_pressed(egui::Key::T) {
|
||||||
self.selected_tool = Tool::Text;
|
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)
|
// F3 debug overlay toggle (works even when text input is active)
|
||||||
if ctx.input(|i| i.key_pressed(egui::Key::F3)) {
|
if ctx.input(|i| i.key_pressed(egui::Key::F3)) {
|
||||||
self.debug_overlay_visible = !self.debug_overlay_visible;
|
self.debug_overlay_visible = !self.debug_overlay_visible;
|
||||||
|
|
@ -5004,6 +5061,10 @@ struct RenderContext<'a> {
|
||||||
script_to_edit: &'a mut Option<Uuid>,
|
script_to_edit: &'a mut Option<Uuid>,
|
||||||
/// Script ID just saved (triggers auto-recompile of nodes using it)
|
/// Script ID just saved (triggers auto-recompile of nodes using it)
|
||||||
script_saved: &'a mut Option<Uuid>,
|
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
|
/// Recursively render a layout node with drag support
|
||||||
|
|
@ -5488,6 +5549,8 @@ fn render_pane(
|
||||||
project_generation: ctx.project_generation,
|
project_generation: ctx.project_generation,
|
||||||
script_to_edit: ctx.script_to_edit,
|
script_to_edit: ctx.script_to_edit,
|
||||||
script_saved: ctx.script_saved,
|
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_clip_id: ctx.editing_clip_id,
|
||||||
editing_instance_id: ctx.editing_instance_id,
|
editing_instance_id: ctx.editing_instance_id,
|
||||||
editing_parent_layer_id: ctx.editing_parent_layer_id,
|
editing_parent_layer_id: ctx.editing_parent_layer_id,
|
||||||
|
|
@ -5566,6 +5629,8 @@ fn render_pane(
|
||||||
project_generation: ctx.project_generation,
|
project_generation: ctx.project_generation,
|
||||||
script_to_edit: ctx.script_to_edit,
|
script_to_edit: ctx.script_to_edit,
|
||||||
script_saved: ctx.script_saved,
|
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_clip_id: ctx.editing_clip_id,
|
||||||
editing_instance_id: ctx.editing_instance_id,
|
editing_instance_id: ctx.editing_instance_id,
|
||||||
editing_parent_layer_id: ctx.editing_parent_layer_id,
|
editing_parent_layer_id: ctx.editing_parent_layer_id,
|
||||||
|
|
|
||||||
|
|
@ -206,7 +206,7 @@ impl InfopanelPane {
|
||||||
// Only show tool options for tools that have options
|
// Only show tool options for tools that have options
|
||||||
let has_options = matches!(
|
let has_options = matches!(
|
||||||
tool,
|
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 {
|
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;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -233,6 +233,10 @@ pub struct SharedPaneState<'a> {
|
||||||
pub script_to_edit: &'a mut Option<Uuid>,
|
pub script_to_edit: &'a mut Option<Uuid>,
|
||||||
/// Script ID that was just saved (triggers auto-recompile of nodes using it)
|
/// Script ID that was just saved (triggers auto-recompile of nodes using it)
|
||||||
pub script_saved: &'a mut Option<Uuid>,
|
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
|
/// Trait for pane rendering
|
||||||
|
|
|
||||||
|
|
@ -386,6 +386,8 @@ struct VelloRenderContext {
|
||||||
editing_instance_id: Option<uuid::Uuid>,
|
editing_instance_id: Option<uuid::Uuid>,
|
||||||
/// The parent layer ID containing the clip instance being edited
|
/// The parent layer ID containing the clip instance being edited
|
||||||
editing_parent_layer_id: Option<uuid::Uuid>,
|
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
|
/// 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,
|
||||||
|
®ion_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,
|
||||||
|
®ion_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,
|
||||||
|
®ion_sel.region_path,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// 3. Draw rectangle creation preview
|
// 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 {
|
if let lightningbeam_core::tool::ToolState::CreatingRectangle { ref start_point, ref current_point, centered, constrain_square, .. } = self.ctx.tool_state {
|
||||||
use vello::kurbo::Point;
|
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, ®ion_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, ®ion_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(®ion_sel.layer_id) {
|
||||||
|
Some(l) => l,
|
||||||
|
None => return,
|
||||||
|
};
|
||||||
|
let vector_layer = match layer {
|
||||||
|
AnyLayer::Vector(vl) => vl,
|
||||||
|
_ => return,
|
||||||
|
};
|
||||||
|
|
||||||
|
for split in ®ion_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)
|
/// Create a rectangle path centered at origin (easier for curve editing later)
|
||||||
fn create_rectangle_path(width: f64, height: f64) -> vello::kurbo::BezPath {
|
fn create_rectangle_path(width: f64, height: f64) -> vello::kurbo::BezPath {
|
||||||
use vello::kurbo::{BezPath, Point};
|
use vello::kurbo::{BezPath, Point};
|
||||||
|
|
@ -5815,6 +6147,9 @@ impl StagePane {
|
||||||
Tool::Eyedropper => {
|
Tool::Eyedropper => {
|
||||||
self.handle_eyedropper_tool(ui, &response, mouse_pos, shared);
|
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
|
// Other tools not implemented yet
|
||||||
}
|
}
|
||||||
|
|
@ -6532,6 +6867,7 @@ impl PaneRenderer for StagePane {
|
||||||
editing_clip_id: shared.editing_clip_id,
|
editing_clip_id: shared.editing_clip_id,
|
||||||
editing_instance_id: shared.editing_instance_id,
|
editing_instance_id: shared.editing_instance_id,
|
||||||
editing_parent_layer_id: shared.editing_parent_layer_id,
|
editing_parent_layer_id: shared.editing_parent_layer_id,
|
||||||
|
region_selection: shared.region_selection.clone(),
|
||||||
}};
|
}};
|
||||||
|
|
||||||
let cb = egui_wgpu::Callback::new_paint_callback(
|
let cb = egui_wgpu::Callback::new_paint_callback(
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
/// Users can click to select tools, which updates the global selected_tool state.
|
/// Users can click to select tools, which updates the global selected_tool state.
|
||||||
|
|
||||||
use eframe::egui;
|
use eframe::egui;
|
||||||
use lightningbeam_core::tool::Tool;
|
use lightningbeam_core::tool::{Tool, RegionSelectMode};
|
||||||
use super::{NodePath, PaneRenderer, SharedPaneState};
|
use super::{NodePath, PaneRenderer, SharedPaneState};
|
||||||
|
|
||||||
/// Toolbar pane state
|
/// 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)
|
// Make button interactive (include path to ensure unique IDs across panes)
|
||||||
let button_id = ui.id().with(("tool_button", path, *tool as usize));
|
let button_id = ui.id().with(("tool_button", path, *tool as usize));
|
||||||
let response = ui.interact(button_rect, button_id, egui::Sense::click());
|
let response = ui.interact(button_rect, button_id, egui::Sense::click());
|
||||||
|
|
@ -92,6 +110,34 @@ impl PaneRenderer for ToolbarPane {
|
||||||
*shared.selected_tool = *tool;
|
*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() {
|
if response.hovered() {
|
||||||
ui.painter().rect_stroke(
|
ui.painter().rect_stroke(
|
||||||
button_rect,
|
button_rect,
|
||||||
|
|
@ -102,7 +148,16 @@ impl PaneRenderer for ToolbarPane {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show tooltip with tool name and shortcut (consumes response)
|
// 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
|
// Draw selection border
|
||||||
if is_selected {
|
if is_selected {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue