diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/add_shape.rs b/lightningbeam-ui/lightningbeam-core/src/actions/add_shape.rs index b00370c..bba8cdf 100644 --- a/lightningbeam-ui/lightningbeam-core/src/actions/add_shape.rs +++ b/lightningbeam-ui/lightningbeam-core/src/actions/add_shape.rs @@ -1,17 +1,20 @@ -//! Add shape action — inserts strokes into the DCEL. +//! Add shape action — inserts strokes into the VectorGraph. //! //! Converts a BezPath into cubic segments and inserts them via -//! `Dcel::insert_stroke()`. Undo is handled by snapshotting the DCEL. +//! `VectorGraph::insert_stroke()`. Undo is handled by snapshotting the graph. use crate::action::Action; -use crate::dcel::{bezpath_to_cubic_segments, Dcel, FaceId, DEFAULT_SNAP_EPSILON}; +use crate::vector_graph::bezpath_to_cubic_segments; use crate::document::Document; use crate::layer::AnyLayer; -use crate::shape::{ShapeColor, StrokeStyle}; -use kurbo::BezPath; +use crate::shape::{FillRule, ShapeColor, StrokeStyle}; +use crate::vector_graph::VectorGraph; +use kurbo::{BezPath, Shape as _}; use uuid::Uuid; -/// Action that inserts a drawn path into a vector layer's DCEL keyframe. +const DEFAULT_SNAP_EPSILON: f64 = 0.5; + +/// Action that inserts a drawn path into a vector layer's VectorGraph keyframe. pub struct AddShapeAction { layer_id: Uuid, time: f64, @@ -21,8 +24,8 @@ pub struct AddShapeAction { fill_color: Option, is_closed: bool, description_text: String, - /// Snapshot of the DCEL before insertion (for undo). - dcel_before: Option, + /// Snapshot of the graph before insertion (for undo). + graph_before: Option, } impl AddShapeAction { @@ -44,7 +47,7 @@ impl AddShapeAction { fill_color, is_closed, description_text: "Add shape".to_string(), - dcel_before: None, + graph_before: None, } } @@ -66,10 +69,10 @@ impl Action for AddShapeAction { }; let keyframe = vl.ensure_keyframe_at(self.time); - let dcel = &mut keyframe.dcel; + let graph = &mut keyframe.graph; // Snapshot for undo - self.dcel_before = Some(dcel.clone()); + self.graph_before = Some(graph.clone()); let subpaths = bezpath_to_cubic_segments(&self.path); @@ -77,41 +80,27 @@ impl Action for AddShapeAction { if segments.is_empty() { continue; } - let result = dcel.insert_stroke( + let _new_edges = graph.insert_stroke( segments, self.stroke_style.clone(), self.stroke_color.clone(), DEFAULT_SNAP_EPSILON, ); - // Apply fill to new faces if this is a closed shape with fill + // Apply fill if this is a closed shape with fill if self.is_closed { if let Some(ref fill) = self.fill_color { - if !result.new_faces.is_empty() { - for face_id in &result.new_faces { - dcel.face_mut(*face_id).fill_color = Some(fill.clone()); - } - } else if let Some(&first_edge) = result.new_edges.first() { - // Closed shape in F0 — no face was auto-created. - // One half-edge of the first new edge is on the interior cycle. - // Pick the side with positive signed area (CCW winding). - let [he_a, he_b] = dcel.edge(first_edge).half_edges; - let interior_he = if dcel.cycle_signed_area(he_a) > 0.0 { - he_a - } else { - he_b - }; - if dcel.half_edge(interior_he).face == FaceId(0) { - let face_id = dcel.create_face_at_cycle(interior_he); - dcel.face_mut(face_id).fill_color = Some(fill.clone()); - } - } + // Compute centroid of the path's bounding box and paint-bucket fill + let bbox = self.path.bounding_box(); + let centroid = kurbo::Point::new( + (bbox.x0 + bbox.x1) / 2.0, + (bbox.y0 + bbox.y1) / 2.0, + ); + graph.paint_bucket(centroid, fill.clone(), FillRule::NonZero, 0.0); } } } - dcel.rebuild_spatial_index(); - Ok(()) } @@ -126,10 +115,10 @@ impl Action for AddShapeAction { }; let keyframe = vl.ensure_keyframe_at(self.time); - keyframe.dcel = self - .dcel_before + keyframe.graph = self + .graph_before .take() - .ok_or_else(|| "No DCEL snapshot for undo".to_string())?; + .ok_or_else(|| "No graph snapshot for undo".to_string())?; Ok(()) } diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/mod.rs b/lightningbeam-ui/lightningbeam-core/src/actions/mod.rs index bd0d375..a26b319 100644 --- a/lightningbeam-ui/lightningbeam-core/src/actions/mod.rs +++ b/lightningbeam-ui/lightningbeam-core/src/actions/mod.rs @@ -40,7 +40,7 @@ pub use add_clip_instance::AddClipInstanceAction; pub use add_effect::AddEffectAction; pub use add_layer::AddLayerAction; pub use add_shape::AddShapeAction; -pub use modify_shape_path::ModifyDcelAction; +pub use modify_shape_path::ModifyGraphAction; pub use move_clip_instances::MoveClipInstancesAction; pub use paint_bucket::PaintBucketAction; pub use remove_effect::RemoveEffectAction; diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/modify_shape_path.rs b/lightningbeam-ui/lightningbeam-core/src/actions/modify_shape_path.rs index fa64efe..ff86171 100644 --- a/lightningbeam-ui/lightningbeam-core/src/actions/modify_shape_path.rs +++ b/lightningbeam-ui/lightningbeam-core/src/actions/modify_shape_path.rs @@ -1,45 +1,45 @@ -//! Modify DCEL action — snapshot-based undo for DCEL editing +//! Modify graph action — snapshot-based undo for VectorGraph editing use crate::action::Action; -use crate::dcel::Dcel; +use crate::vector_graph::VectorGraph; use crate::document::Document; use crate::layer::AnyLayer; use uuid::Uuid; -/// Action that captures a before/after DCEL snapshot for undo/redo. +/// Action that captures a before/after VectorGraph snapshot for undo/redo. /// /// Used by vertex editing, curve editing, and control point editing. /// The caller provides both snapshots (taken before and after the edit). -pub struct ModifyDcelAction { +pub struct ModifyGraphAction { layer_id: Uuid, time: f64, - dcel_before: Option, - dcel_after: Option, + graph_before: Option, + graph_after: Option, description_text: String, } -impl ModifyDcelAction { +impl ModifyGraphAction { pub fn new( layer_id: Uuid, time: f64, - dcel_before: Dcel, - dcel_after: Dcel, + graph_before: VectorGraph, + graph_after: VectorGraph, description: impl Into, ) -> Self { Self { layer_id, time, - dcel_before: Some(dcel_before), - dcel_after: Some(dcel_after), + graph_before: Some(graph_before), + graph_after: Some(graph_after), description_text: description.into(), } } } -impl Action for ModifyDcelAction { +impl Action for ModifyGraphAction { fn execute(&mut self, document: &mut Document) -> Result<(), String> { - let dcel_after = self.dcel_after.as_ref() - .ok_or("ModifyDcelAction: no dcel_after snapshot")? + let graph_after = self.graph_after.as_ref() + .ok_or("ModifyGraphAction: no graph_after snapshot")? .clone(); let layer = document.get_layer_mut(&self.layer_id) @@ -47,7 +47,7 @@ impl Action for ModifyDcelAction { if let AnyLayer::Vector(vl) = layer { if let Some(kf) = vl.keyframe_at_mut(self.time) { - kf.dcel = dcel_after; + kf.graph = graph_after; Ok(()) } else { Err(format!("No keyframe at time {}", self.time)) @@ -58,8 +58,8 @@ impl Action for ModifyDcelAction { } fn rollback(&mut self, document: &mut Document) -> Result<(), String> { - let dcel_before = self.dcel_before.as_ref() - .ok_or("ModifyDcelAction: no dcel_before snapshot")? + let graph_before = self.graph_before.as_ref() + .ok_or("ModifyGraphAction: no graph_before snapshot")? .clone(); let layer = document.get_layer_mut(&self.layer_id) @@ -67,7 +67,7 @@ impl Action for ModifyDcelAction { if let AnyLayer::Vector(vl) = layer { if let Some(kf) = vl.keyframe_at_mut(self.time) { - kf.dcel = dcel_before; + kf.graph = graph_before; Ok(()) } else { Err(format!("No keyframe at time {}", self.time)) diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/paint_bucket.rs b/lightningbeam-ui/lightningbeam-core/src/actions/paint_bucket.rs index 67c64f9..943de27 100644 --- a/lightningbeam-ui/lightningbeam-core/src/actions/paint_bucket.rs +++ b/lightningbeam-ui/lightningbeam-core/src/actions/paint_bucket.rs @@ -1,23 +1,21 @@ -//! Paint bucket fill action — sets fill_color on a DCEL face. +//! Paint bucket fill action — creates a fill region in a VectorGraph. use crate::action::Action; -use crate::dcel::FaceId; use crate::document::Document; use crate::layer::AnyLayer; -use crate::shape::ShapeColor; +use crate::shape::{FillRule, ShapeColor}; +use crate::vector_graph::FillId; use uuid::Uuid; use vello::kurbo::Point; -/// Action that performs a paint bucket fill on a DCEL face. +/// Action that performs a paint bucket fill on a VectorGraph region. pub struct PaintBucketAction { layer_id: Uuid, time: f64, click_point: Point, fill_color: ShapeColor, - /// The face that was hit (resolved during execute) - hit_face: Option, - /// Previous fill color for undo - old_fill_color: Option>, + /// The fill that was created (resolved during execute) + hit_fill: Option, } impl PaintBucketAction { @@ -32,8 +30,7 @@ impl PaintBucketAction { time, click_point, fill_color, - hit_face: None, - old_fill_color: None, + hit_fill: None, } } } @@ -50,45 +47,19 @@ impl Action for PaintBucketAction { }; let keyframe = vl.ensure_keyframe_at(self.time); - let dcel = &mut keyframe.dcel; + let graph = &mut keyframe.graph; - // Record for debug test generation (if recording is active) - dcel.record_paint_point(self.click_point); + let fill_id = graph + .paint_bucket(self.click_point, self.fill_color.clone(), FillRule::NonZero, 2.0) + .ok_or("No fillable region at click point")?; - // Find the enclosing cycle for the click point - let query = dcel.find_face_at_point(self.click_point); - - // Dump cumulative test to stderr after every paint click (if recording) - if dcel.is_recording() { - eprintln!("\n--- DCEL debug test (cumulative, face={:?}) ---", query.face); - dcel.debug_recorder.as_ref().unwrap().dump_test("test_recorded"); - eprintln!("--- end test ---\n"); - } - - if query.cycle_he.is_none() { - // No edges at all — nothing to fill - return Err("No face at click point".to_string()); - } - - // If the cycle is in F0 (no face created yet), create one now - let face_id = if query.face.0 == 0 { - dcel.create_face_at_cycle(query.cycle_he) - } else { - query.face - }; - - // Store for undo - self.hit_face = Some(face_id); - self.old_fill_color = Some(dcel.face(face_id).fill_color.clone()); - - // Apply fill - dcel.face_mut(face_id).fill_color = Some(self.fill_color.clone()); + self.hit_fill = Some(fill_id); Ok(()) } fn rollback(&mut self, document: &mut Document) -> Result<(), String> { - let face_id = self.hit_face.ok_or("No face to undo")?; + let fill_id = self.hit_fill.ok_or("No fill to undo")?; let layer = document .get_layer_mut(&self.layer_id) @@ -100,9 +71,9 @@ impl Action for PaintBucketAction { }; let keyframe = vl.ensure_keyframe_at(self.time); - let dcel = &mut keyframe.dcel; + let graph = &mut keyframe.graph; - dcel.face_mut(face_id).fill_color = self.old_fill_color.take().unwrap_or(None); + graph.free_fill(fill_id); Ok(()) } diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/set_fill_paint.rs b/lightningbeam-ui/lightningbeam-core/src/actions/set_fill_paint.rs index a69c02e..d7eaf66 100644 --- a/lightningbeam-ui/lightningbeam-core/src/actions/set_fill_paint.rs +++ b/lightningbeam-ui/lightningbeam-core/src/actions/set_fill_paint.rs @@ -1,30 +1,30 @@ -//! Action that changes the fill of one or more DCEL faces. +//! Action that changes the fill of one or more VectorGraph fills. //! //! Handles both solid-colour and gradient fills, clearing the other type so they -//! don't coexist on a face. +//! don't coexist on a fill. use crate::action::Action; -use crate::dcel::FaceId; +use crate::vector_graph::FillId; use crate::document::Document; use crate::gradient::ShapeGradient; use crate::layer::AnyLayer; use crate::shape::ShapeColor; use uuid::Uuid; -/// Snapshot of one face's fill state (both types) for undo. +/// Snapshot of one fill's state (both types) for undo. #[derive(Clone)] struct OldFill { - face_id: FaceId, + fill_id: FillId, color: Option, gradient: Option, } -/// Action that sets a solid-colour *or* gradient fill on a set of faces, +/// Action that sets a solid-colour *or* gradient fill on a set of fills, /// clearing the other fill type. pub struct SetFillPaintAction { layer_id: Uuid, time: f64, - face_ids: Vec, + fill_ids: Vec, new_color: Option, new_gradient: Option, old_fills: Vec, @@ -32,17 +32,17 @@ pub struct SetFillPaintAction { } impl SetFillPaintAction { - /// Set a solid fill (clears any gradient on the same faces). + /// Set a solid fill (clears any gradient on the same fills). pub fn solid( layer_id: Uuid, time: f64, - face_ids: Vec, + fill_ids: Vec, color: Option, ) -> Self { Self { layer_id, time, - face_ids, + fill_ids, new_color: color, new_gradient: None, old_fills: Vec::new(), @@ -50,17 +50,17 @@ impl SetFillPaintAction { } } - /// Set a gradient fill (clears any solid colour on the same faces). + /// Set a gradient fill (clears any solid colour on the same fills). pub fn gradient( layer_id: Uuid, time: f64, - face_ids: Vec, + fill_ids: Vec, gradient: Option, ) -> Self { Self { layer_id, time, - face_ids, + fill_ids, new_color: None, new_gradient: gradient, old_fills: Vec::new(), @@ -68,17 +68,17 @@ impl SetFillPaintAction { } } - fn get_dcel_mut<'a>( + fn get_graph_mut<'a>( document: &'a mut Document, layer_id: &Uuid, time: f64, - ) -> Result<&'a mut crate::dcel::Dcel, String> { + ) -> Result<&'a mut crate::vector_graph::VectorGraph, String> { let layer = document .get_layer_mut(layer_id) .ok_or_else(|| format!("Layer {} not found", layer_id))?; match layer { AnyLayer::Vector(vl) => vl - .dcel_at_time_mut(time) + .graph_at_time_mut(time) .ok_or_else(|| format!("No keyframe at time {}", time)), _ => Err("Not a vector layer".to_string()), } @@ -87,36 +87,36 @@ impl SetFillPaintAction { impl Action for SetFillPaintAction { fn execute(&mut self, document: &mut Document) -> Result<(), String> { - let dcel = Self::get_dcel_mut(document, &self.layer_id, self.time)?; + let graph = Self::get_graph_mut(document, &self.layer_id, self.time)?; self.old_fills.clear(); - for &fid in &self.face_ids { - let face = dcel.face(fid); + for &fid in &self.fill_ids { + let fill = graph.fill(fid); self.old_fills.push(OldFill { - face_id: fid, - color: face.fill_color, - gradient: face.gradient_fill.clone(), + fill_id: fid, + color: fill.color, + gradient: fill.gradient_fill.clone(), }); - let face_mut = dcel.face_mut(fid); + let fill_mut = graph.fill_mut(fid); // Setting a gradient clears solid colour and vice-versa. if self.new_gradient.is_some() || self.new_color.is_none() { - face_mut.fill_color = self.new_color; - face_mut.gradient_fill = self.new_gradient.clone(); + fill_mut.color = self.new_color; + fill_mut.gradient_fill = self.new_gradient.clone(); } else { - face_mut.fill_color = self.new_color; - face_mut.gradient_fill = None; + fill_mut.color = self.new_color; + fill_mut.gradient_fill = None; } } Ok(()) } fn rollback(&mut self, document: &mut Document) -> Result<(), String> { - let dcel = Self::get_dcel_mut(document, &self.layer_id, self.time)?; + let graph = Self::get_graph_mut(document, &self.layer_id, self.time)?; for old in &self.old_fills { - let face = dcel.face_mut(old.face_id); - face.fill_color = old.color; - face.gradient_fill = old.gradient.clone(); + let fill = graph.fill_mut(old.fill_id); + fill.color = old.color; + fill.gradient_fill = old.gradient.clone(); } Ok(()) } diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/set_shape_properties.rs b/lightningbeam-ui/lightningbeam-core/src/actions/set_shape_properties.rs index d258357..8fd51e2 100644 --- a/lightningbeam-ui/lightningbeam-core/src/actions/set_shape_properties.rs +++ b/lightningbeam-ui/lightningbeam-core/src/actions/set_shape_properties.rs @@ -1,24 +1,24 @@ -//! Set shape properties action — operates on DCEL edge/face IDs. +//! Set shape properties action — operates on VectorGraph edge/fill IDs. use crate::action::Action; -use crate::dcel::{EdgeId, FaceId}; +use crate::vector_graph::{EdgeId, FillId}; use crate::document::Document; use crate::layer::AnyLayer; use crate::shape::ShapeColor; use uuid::Uuid; -/// Action that sets fill/stroke properties on DCEL elements. +/// Action that sets fill/stroke properties on VectorGraph elements. pub struct SetShapePropertiesAction { layer_id: Uuid, time: f64, change: PropertyChange, old_edge_values: Vec<(EdgeId, Option, Option)>, - old_face_values: Vec<(FaceId, Option)>, + old_fill_values: Vec<(FillId, Option)>, } enum PropertyChange { FillColor { - face_ids: Vec, + fill_ids: Vec, color: Option, }, StrokeColor { @@ -35,15 +35,15 @@ impl SetShapePropertiesAction { pub fn set_fill_color( layer_id: Uuid, time: f64, - face_ids: Vec, + fill_ids: Vec, color: Option, ) -> Self { Self { layer_id, time, - change: PropertyChange::FillColor { face_ids, color }, + change: PropertyChange::FillColor { fill_ids, color }, old_edge_values: Vec::new(), - old_face_values: Vec::new(), + old_fill_values: Vec::new(), } } @@ -58,7 +58,7 @@ impl SetShapePropertiesAction { time, change: PropertyChange::StrokeColor { edge_ids, color }, old_edge_values: Vec::new(), - old_face_values: Vec::new(), + old_fill_values: Vec::new(), } } @@ -73,15 +73,15 @@ impl SetShapePropertiesAction { time, change: PropertyChange::StrokeWidth { edge_ids, width }, old_edge_values: Vec::new(), - old_face_values: Vec::new(), + old_fill_values: Vec::new(), } } - fn get_dcel_mut<'a>( + fn get_graph_mut<'a>( document: &'a mut Document, layer_id: &Uuid, time: f64, - ) -> Result<&'a mut crate::dcel::Dcel, String> { + ) -> Result<&'a mut crate::vector_graph::VectorGraph, String> { let layer = document .get_layer_mut(layer_id) .ok_or_else(|| format!("Layer {} not found", layer_id))?; @@ -89,40 +89,40 @@ impl SetShapePropertiesAction { AnyLayer::Vector(vl) => vl, _ => return Err("Not a vector layer".to_string()), }; - vl.dcel_at_time_mut(time) + vl.graph_at_time_mut(time) .ok_or_else(|| format!("No keyframe at time {}", time)) } } impl Action for SetShapePropertiesAction { fn execute(&mut self, document: &mut Document) -> Result<(), String> { - let dcel = Self::get_dcel_mut(document, &self.layer_id, self.time)?; + let graph = Self::get_graph_mut(document, &self.layer_id, self.time)?; match &self.change { - PropertyChange::FillColor { face_ids, color } => { - self.old_face_values.clear(); - for &fid in face_ids { - let face = dcel.face(fid); - self.old_face_values.push((fid, face.fill_color)); - dcel.face_mut(fid).fill_color = *color; + PropertyChange::FillColor { fill_ids, color } => { + self.old_fill_values.clear(); + for &fid in fill_ids { + let fill = graph.fill(fid); + self.old_fill_values.push((fid, fill.color)); + graph.fill_mut(fid).color = *color; } } PropertyChange::StrokeColor { edge_ids, color } => { self.old_edge_values.clear(); for &eid in edge_ids { - let edge = dcel.edge(eid); + let edge = graph.edge(eid); let old_width = edge.stroke_style.as_ref().map(|s| s.width); self.old_edge_values.push((eid, edge.stroke_color, old_width)); - dcel.edge_mut(eid).stroke_color = *color; + graph.edge_mut(eid).stroke_color = *color; } } PropertyChange::StrokeWidth { edge_ids, width } => { self.old_edge_values.clear(); for &eid in edge_ids { - let edge = dcel.edge(eid); + let edge = graph.edge(eid); let old_width = edge.stroke_style.as_ref().map(|s| s.width); self.old_edge_values.push((eid, edge.stroke_color, old_width)); - if let Some(ref mut style) = dcel.edge_mut(eid).stroke_style { + if let Some(ref mut style) = graph.edge_mut(eid).stroke_style { style.width = *width; } } @@ -133,23 +133,23 @@ impl Action for SetShapePropertiesAction { } fn rollback(&mut self, document: &mut Document) -> Result<(), String> { - let dcel = Self::get_dcel_mut(document, &self.layer_id, self.time)?; + let graph = Self::get_graph_mut(document, &self.layer_id, self.time)?; match &self.change { PropertyChange::FillColor { .. } => { - for &(fid, old_color) in &self.old_face_values { - dcel.face_mut(fid).fill_color = old_color; + for &(fid, old_color) in &self.old_fill_values { + graph.fill_mut(fid).color = old_color; } } PropertyChange::StrokeColor { .. } => { for &(eid, old_color, _) in &self.old_edge_values { - dcel.edge_mut(eid).stroke_color = old_color; + graph.edge_mut(eid).stroke_color = old_color; } } PropertyChange::StrokeWidth { .. } => { for &(eid, _, old_width) in &self.old_edge_values { if let Some(w) = old_width { - if let Some(ref mut style) = dcel.edge_mut(eid).stroke_style { + if let Some(ref mut style) = graph.edge_mut(eid).stroke_style { style.width = w; } } diff --git a/lightningbeam-ui/lightningbeam-core/src/clip.rs b/lightningbeam-ui/lightningbeam-core/src/clip.rs index e39e34c..84ee618 100644 --- a/lightningbeam-ui/lightningbeam-core/src/clip.rs +++ b/lightningbeam-ui/lightningbeam-core/src/clip.rs @@ -170,7 +170,7 @@ impl VectorClip { // Only process vector layers (skip other layer types) if let AnyLayer::Vector(vector_layer) = &layer_node.data { // Calculate bounds from DCEL edges - if let Some(dcel) = vector_layer.dcel_at_time(clip_time) { + if let Some(dcel) = vector_layer.graph_at_time(clip_time) { use kurbo::Shape as KurboShape; for edge in &dcel.edges { if edge.deleted { diff --git a/lightningbeam-ui/lightningbeam-core/src/hit_test.rs b/lightningbeam-ui/lightningbeam-core/src/hit_test.rs index 5939b86..66cb18c 100644 --- a/lightningbeam-ui/lightningbeam-core/src/hit_test.rs +++ b/lightningbeam-ui/lightningbeam-core/src/hit_test.rs @@ -1,10 +1,10 @@ //! Hit testing for selection and interaction //! //! Provides functions for testing if points or rectangles intersect with -//! DCEL elements and clip instances, taking into account transform hierarchies. +//! vector graph elements and clip instances, taking into account transform hierarchies. use crate::clip::ClipInstance; -use crate::dcel::{VertexId, EdgeId, FaceId}; +use crate::vector_graph::{VertexId, EdgeId, FillId}; use crate::layer::VectorLayer; use crate::shape::Shape; use serde::{Deserialize, Serialize}; @@ -14,25 +14,25 @@ use vello::kurbo::{Affine, Point, Rect, Shape as KurboShape}; /// Result of a hit test operation #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] pub enum HitResult { - /// Hit a DCEL edge (stroke) + /// Hit an edge (stroke) Edge(EdgeId), - /// Hit a DCEL face (fill) - Face(FaceId), + /// Hit a fill + Fill(FillId), /// Hit a clip instance ClipInstance(Uuid), } -/// Result of a DCEL-only hit test (no clip instances) +/// Result of a graph-only hit test (no clip instances) #[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum DcelHitResult { +pub enum GraphHitResult { Edge(EdgeId), - Face(FaceId), + Fill(FillId), } -/// Hit test a layer at a specific point, returning edge or face hits. +/// Hit test a layer at a specific point, returning edge or fill hits. /// -/// Tests DCEL edges (strokes) and faces (fills) in the active keyframe. -/// Edge hits take priority over face hits. +/// Tests edges (strokes) and fills in the active keyframe. +/// Edge hits take priority over fill hits. /// /// # Arguments /// @@ -44,22 +44,22 @@ pub enum DcelHitResult { /// /// # Returns /// -/// The first DCEL element hit, or None if no hit +/// The first element hit, or None if no hit pub fn hit_test_layer( layer: &VectorLayer, time: f64, point: Point, tolerance: f64, parent_transform: Affine, -) -> Option { - let dcel = layer.dcel_at_time(time)?; +) -> Option { + let graph = layer.graph_at_time(time)?; // Transform point to local space let local_point = parent_transform.inverse() * point; - // 1. Check edges (strokes) — priority over faces + // 1. Check edges (strokes) — priority over fills let mut best_edge: Option<(EdgeId, f64)> = None; - for (i, edge) in dcel.edges.iter().enumerate() { + for (i, edge) in graph.edges.iter().enumerate() { if edge.deleted { continue; } @@ -86,24 +86,24 @@ pub fn hit_test_layer( } } if let Some((edge_id, _)) = best_edge { - return Some(DcelHitResult::Edge(edge_id)); + return Some(GraphHitResult::Edge(edge_id)); } - // 2. Check faces (fills) - for (i, face) in dcel.faces.iter().enumerate() { - if face.deleted || i == 0 { - continue; // skip unbounded face - } - if face.fill_color.is_none() && face.image_fill.is_none() && face.gradient_fill.is_none() { + // 2. Check fills + for (i, fill) in graph.fills.iter().enumerate() { + if fill.deleted { continue; } - if face.outer_half_edge.is_none() { + if fill.color.is_none() && fill.image_fill.is_none() && fill.gradient_fill.is_none() { + continue; + } + if fill.boundary.is_empty() { continue; } - let path = dcel.face_to_bezpath(FaceId(i as u32)); + let path = graph.fill_to_bezpath(FillId(i as u32)); if path.winding(local_point) != 0 { - return Some(DcelHitResult::Face(FaceId(i as u32))); + return Some(GraphHitResult::Fill(FillId(i as u32))); } } @@ -147,26 +147,26 @@ pub fn hit_test_shape( false } -/// Result of DCEL marquee selection +/// Result of graph marquee selection #[derive(Debug, Default)] -pub struct DcelMarqueeResult { +pub struct GraphMarqueeResult { pub edges: Vec, - pub faces: Vec, + pub fills: Vec, } -/// Hit test DCEL elements within a rectangle (for marquee selection). +/// Hit test graph elements within a rectangle (for marquee selection). /// /// Selects edges whose both endpoints are inside the rect, -/// and faces whose all boundary vertices are inside the rect. -pub fn hit_test_dcel_in_rect( +/// and fills whose all boundary vertices are inside the rect. +pub fn hit_test_graph_in_rect( layer: &VectorLayer, time: f64, rect: Rect, parent_transform: Affine, -) -> DcelMarqueeResult { - let mut result = DcelMarqueeResult::default(); +) -> GraphMarqueeResult { + let mut result = GraphMarqueeResult::default(); - let dcel = match layer.dcel_at_time(time) { + let graph = match layer.graph_at_time(time) { Some(d) => d, None => return result, }; @@ -175,41 +175,36 @@ pub fn hit_test_dcel_in_rect( let local_rect = inv.transform_rect_bbox(rect); // Check edges: both endpoints inside rect - for (i, edge) in dcel.edges.iter().enumerate() { + for (i, edge) in graph.edges.iter().enumerate() { if edge.deleted { continue; } - let [he_fwd, he_bwd] = edge.half_edges; - if he_fwd.is_none() || he_bwd.is_none() { - continue; - } - let v1 = dcel.half_edge(he_fwd).origin; - let v2 = dcel.half_edge(he_bwd).origin; + let v1 = edge.vertices[0]; + let v2 = edge.vertices[1]; if v1.is_none() || v2.is_none() { continue; } - let p1 = dcel.vertex(v1).position; - let p2 = dcel.vertex(v2).position; + let p1 = graph.vertex(v1).position; + let p2 = graph.vertex(v2).position; if local_rect.contains(p1) && local_rect.contains(p2) { result.edges.push(EdgeId(i as u32)); } } - // Check faces: all boundary vertices inside rect - for (i, face) in dcel.faces.iter().enumerate() { - if face.deleted || i == 0 { + // Check fills: all boundary vertices inside rect + for (i, fill) in graph.fills.iter().enumerate() { + if fill.deleted { continue; } - if face.outer_half_edge.is_none() { + if fill.boundary.is_empty() { continue; } - let boundary = dcel.face_boundary(FaceId(i as u32)); - let all_inside = boundary.iter().all(|&he_id| { - let v = dcel.half_edge(he_id).origin; - !v.is_none() && local_rect.contains(dcel.vertex(v).position) + let boundary_verts = graph.fill_boundary_vertices(FillId(i as u32)); + let all_inside = boundary_verts.iter().all(|&v| { + !v.is_none() && local_rect.contains(graph.vertex(v).position) }); - if all_inside && !boundary.is_empty() { - result.faces.push(FaceId(i as u32)); + if all_inside && !boundary_verts.is_empty() { + result.fills.push(FillId(i as u32)); } } @@ -351,7 +346,7 @@ pub enum VectorEditHit { }, /// Hit shape fill Fill { - face_id: FaceId, + fill_id: FillId, }, } @@ -397,7 +392,7 @@ pub fn hit_test_vector_editing( ) -> Option { use kurbo::ParamCurveNearest; - let dcel = layer.dcel_at_time(time)?; + let graph = layer.graph_at_time(time)?; // Transform point into layer-local space let local_point = parent_transform.inverse() * point; @@ -407,7 +402,7 @@ pub fn hit_test_vector_editing( // 1. Control points (only when show_control_points is true, e.g. BezierEdit tool) if show_control_points { let mut best_cp: Option<(EdgeId, u8, f64)> = None; - for (i, edge) in dcel.edges.iter().enumerate() { + for (i, edge) in graph.edges.iter().enumerate() { if edge.deleted { continue; } @@ -434,7 +429,7 @@ pub fn hit_test_vector_editing( // 2. Vertices let mut best_vertex: Option<(VertexId, f64)> = None; - for (i, vertex) in dcel.vertices.iter().enumerate() { + for (i, vertex) in graph.vertices.iter().enumerate() { if vertex.deleted { continue; } @@ -451,7 +446,7 @@ pub fn hit_test_vector_editing( // 3. Curves (edges) let mut best_curve: Option<(EdgeId, f64, f64)> = None; // (edge_id, t, dist) - for (i, edge) in dcel.edges.iter().enumerate() { + for (i, edge) in graph.edges.iter().enumerate() { if edge.deleted { continue; } @@ -467,20 +462,20 @@ pub fn hit_test_vector_editing( return Some(VectorEditHit::Curve { edge_id, parameter_t }); } - // 4. Face fill testing - for (i, face) in dcel.faces.iter().enumerate() { - if face.deleted || i == 0 { + // 4. Fill testing + for (i, fill) in graph.fills.iter().enumerate() { + if fill.deleted { continue; } - if face.fill_color.is_none() && face.image_fill.is_none() && face.gradient_fill.is_none() { + if fill.color.is_none() && fill.image_fill.is_none() && fill.gradient_fill.is_none() { continue; } - if face.outer_half_edge.is_none() { + if fill.boundary.is_empty() { continue; } - let path = dcel.face_to_bezpath(FaceId(i as u32)); + let path = graph.fill_to_bezpath(FillId(i as u32)); if path.winding(local_point) != 0 { - return Some(VectorEditHit::Fill { face_id: FaceId(i as u32) }); + return Some(VectorEditHit::Fill { fill_id: FillId(i as u32) }); } } @@ -495,16 +490,16 @@ mod tests { #[test] fn test_hit_test_simple_circle() { - // TODO: DCEL - rewrite test + // TODO: VectorGraph - rewrite test } #[test] fn test_hit_test_with_transform() { - // TODO: DCEL - rewrite test + // TODO: VectorGraph - rewrite test } #[test] fn test_marquee_selection() { - // TODO: DCEL - rewrite test + // TODO: VectorGraph - rewrite test } } diff --git a/lightningbeam-ui/lightningbeam-core/src/layer.rs b/lightningbeam-ui/lightningbeam-core/src/layer.rs index 6778a55..53686ae 100644 --- a/lightningbeam-ui/lightningbeam-core/src/layer.rs +++ b/lightningbeam-ui/lightningbeam-core/src/layer.rs @@ -4,7 +4,7 @@ use crate::animation::AnimationData; use crate::clip::ClipInstance; -use crate::dcel::Dcel; +use crate::vector_graph::VectorGraph; use crate::effect_layer::EffectLayer; use crate::object::ShapeInstance; use crate::raster_layer::RasterLayer; @@ -165,13 +165,13 @@ impl Default for TweenType { } } -/// A keyframe containing vector artwork as a DCEL planar subdivision. +/// A keyframe containing vector artwork as a VectorGraph. #[derive(Clone, Debug, Serialize, Deserialize)] pub struct ShapeKeyframe { /// Time in seconds pub time: f64, - /// DCEL planar subdivision containing all vector artwork - pub dcel: Dcel, + /// Vector graph containing all vector artwork + pub graph: VectorGraph, /// What happens between this keyframe and the next #[serde(default)] pub tween_after: TweenType, @@ -186,7 +186,7 @@ impl ShapeKeyframe { pub fn new(time: f64) -> Self { Self { time, - dcel: Dcel::new(), + graph: VectorGraph::new(), tween_after: TweenType::None, clip_instance_ids: Vec::new(), } @@ -376,14 +376,14 @@ impl VectorLayer { self.keyframes.iter().position(|kf| (kf.time - time).abs() < tolerance) } - /// Get the DCEL at a given time (from the keyframe at-or-before time) - pub fn dcel_at_time(&self, time: f64) -> Option<&Dcel> { - self.keyframe_at(time).map(|kf| &kf.dcel) + /// Get the VectorGraph at a given time (from the keyframe at-or-before time) + pub fn graph_at_time(&self, time: f64) -> Option<&VectorGraph> { + self.keyframe_at(time).map(|kf| &kf.graph) } - /// Get a mutable DCEL at a given time - pub fn dcel_at_time_mut(&mut self, time: f64) -> Option<&mut Dcel> { - self.keyframe_at_mut(time).map(|kf| &mut kf.dcel) + /// Get a mutable VectorGraph at a given time + pub fn graph_at_time_mut(&mut self, time: f64) -> Option<&mut VectorGraph> { + self.keyframe_at_mut(time).map(|kf| &mut kf.graph) } /// Get the duration of the keyframe span starting at-or-before `time`. @@ -433,7 +433,7 @@ impl VectorLayer { } // Shape-based methods removed — use DCEL methods instead. - // - shapes_at_time_mut → dcel_at_time_mut + // - shapes_at_time_mut → graph_at_time_mut // - get_shape_in_keyframe → use DCEL vertex/edge/face accessors // - get_shape_in_keyframe_mut → use DCEL vertex/edge/face accessors @@ -458,17 +458,17 @@ impl VectorLayer { return &mut self.keyframes[idx]; } - // Clone DCEL and clip instance IDs from the active keyframe - let (cloned_dcel, cloned_clip_ids) = self + // Clone graph and clip instance IDs from the active keyframe + let (cloned_graph, cloned_clip_ids) = self .keyframe_at(time) .map(|kf| { - (kf.dcel.clone(), kf.clip_instance_ids.clone()) + (kf.graph.clone(), kf.clip_instance_ids.clone()) }) - .unwrap_or_else(|| (Dcel::new(), Vec::new())); + .unwrap_or_else(|| (VectorGraph::new(), Vec::new())); let insert_idx = self.keyframes.partition_point(|kf| kf.time < time); let mut kf = ShapeKeyframe::new(time); - kf.dcel = cloned_dcel; + kf.graph = cloned_graph; kf.clip_instance_ids = cloned_clip_ids; self.keyframes.insert(insert_idx, kf); &mut self.keyframes[insert_idx] diff --git a/lightningbeam-ui/lightningbeam-core/src/renderer.rs b/lightningbeam-ui/lightningbeam-core/src/renderer.rs index 94bd361..db0021d 100644 --- a/lightningbeam-ui/lightningbeam-core/src/renderer.rs +++ b/lightningbeam-ui/lightningbeam-core/src/renderer.rs @@ -341,8 +341,8 @@ pub fn render_layer_isolated( image_cache, video_manager, ); - rendered.has_content = vector_layer.dcel_at_time(time) - .map_or(false, |dcel| !dcel.edges.iter().all(|e| e.deleted) || !dcel.faces.iter().skip(1).all(|f| f.deleted)) + rendered.has_content = vector_layer.graph_at_time(time) + .map_or(false, |graph| !graph.edges.iter().all(|e| e.deleted) || !graph.fills.iter().all(|f| f.deleted)) || !vector_layer.clip_instances.is_empty(); } AnyLayer::Audio(_) => { @@ -1059,11 +1059,11 @@ fn gradient_bbox_endpoints(angle_deg: f32, bbox: kurbo::Rect) -> (kurbo::Point, (start, end) } -/// Render a DCEL to a Vello scene. +/// Render a VectorGraph to a Vello scene. /// -/// Walks faces for fills and edges for strokes. -pub fn render_dcel( - dcel: &crate::dcel::Dcel, +/// Walks fills and edges for strokes. +pub fn render_vector_graph( + graph: &crate::vector_graph::VectorGraph, scene: &mut Scene, base_transform: Affine, layer_opacity: f64, @@ -1072,23 +1072,23 @@ pub fn render_dcel( ) { let opacity_f32 = layer_opacity as f32; - // 1. Render faces (fills) - for (i, face) in dcel.faces.iter().enumerate() { - if face.deleted || i == 0 { - continue; // Skip unbounded face and deleted faces + // 1. Render fills + for (i, fill) in graph.fills.iter().enumerate() { + if fill.deleted { + continue; // Skip deleted fills } - if face.fill_color.is_none() && face.image_fill.is_none() && face.gradient_fill.is_none() { + if fill.color.is_none() && fill.image_fill.is_none() && fill.gradient_fill.is_none() { continue; // No fill to render } - let face_id = crate::dcel::FaceId(i as u32); - let path = dcel.face_to_bezpath_with_holes(face_id); - let fill_rule: Fill = face.fill_rule.into(); + let fill_id = crate::vector_graph::FillId(i as u32); + let path = graph.fill_to_bezpath(fill_id); + let fill_rule: Fill = fill.fill_rule.into(); let mut filled = false; // Image fill - if let Some(image_asset_id) = face.image_fill { + if let Some(image_asset_id) = fill.image_fill { if let Some(image_asset) = document.get_image_asset(&image_asset_id) { if let Some(image) = image_cache.get_or_decode(image_asset) { let image_with_alpha = (*image).clone().with_alpha(opacity_f32); @@ -1100,7 +1100,7 @@ pub fn render_dcel( // Gradient fill (takes priority over solid colour fill) if !filled { - if let Some(ref grad) = face.gradient_fill { + if let Some(ref grad) = fill.gradient_fill { use kurbo::Rect; use crate::gradient::GradientType; let bbox: Rect = vello::kurbo::Shape::bounding_box(&path); @@ -1128,7 +1128,7 @@ pub fn render_dcel( // Solid colour fill if !filled { - if let Some(fill_color) = &face.fill_color { + if let Some(fill_color) = &fill.color { let alpha = ((fill_color.a as f32 / 255.0) * opacity_f32 * 255.0) as u8; let adjusted = crate::shape::ShapeColor::rgba( fill_color.r, @@ -1142,7 +1142,7 @@ pub fn render_dcel( } // 2. Render edges (strokes) - for edge in &dcel.edges { + for edge in &graph.edges { if edge.deleted { continue; } @@ -1195,9 +1195,9 @@ fn render_vector_layer( render_clip_instance(document, time, clip_instance, layer_opacity, scene, base_transform, &layer.layer.animation_data, image_cache, video_manager, group_end_time); } - // Render DCEL from active keyframe - if let Some(dcel) = layer.dcel_at_time(time) { - render_dcel(dcel, scene, base_transform, layer_opacity, document, image_cache); + // Render VectorGraph from active keyframe + if let Some(graph) = layer.graph_at_time(time) { + render_vector_graph(graph, scene, base_transform, layer_opacity, document, image_cache); } } @@ -1362,29 +1362,29 @@ fn render_background_cpu( pixmap.fill_rect(bg_rect, &paint, ts_transform, None); } -/// Render a DCEL to a CPU pixmap. -fn render_dcel_cpu( - dcel: &crate::dcel::Dcel, +/// Render a VectorGraph to a CPU pixmap. +fn render_vector_graph_cpu( + graph: &crate::vector_graph::VectorGraph, pixmap: &mut tiny_skia::PixmapMut<'_>, transform: tiny_skia::Transform, opacity: f32, _document: &Document, _image_cache: &mut ImageCache, ) { - // 1. Faces (fills) - for (i, face) in dcel.faces.iter().enumerate() { - if face.deleted || i == 0 { + // 1. Fills + for (i, fill) in graph.fills.iter().enumerate() { + if fill.deleted { continue; } - if face.fill_color.is_none() && face.image_fill.is_none() && face.gradient_fill.is_none() { + if fill.color.is_none() && fill.image_fill.is_none() && fill.gradient_fill.is_none() { continue; } - let face_id = crate::dcel::FaceId(i as u32); - let path = dcel.face_to_bezpath_with_holes(face_id); + let fill_id = crate::vector_graph::FillId(i as u32); + let path = graph.fill_to_bezpath(fill_id); let Some(ts_path) = bezpath_to_ts(&path) else { continue }; - let fill_type = match face.fill_rule { + let fill_type = match fill.fill_rule { crate::shape::FillRule::NonZero => tiny_skia::FillRule::Winding, crate::shape::FillRule::EvenOdd => tiny_skia::FillRule::EvenOdd, }; @@ -1392,7 +1392,7 @@ fn render_dcel_cpu( let mut filled = false; // Gradient fill (takes priority over solid) - if let Some(ref grad) = face.gradient_fill { + if let Some(ref grad) = fill.gradient_fill { let bbox: kurbo::Rect = vello::kurbo::Shape::bounding_box(&path); let (start, end) = match (grad.start_world, grad.end_world) { (Some((sx, sy)), Some((ex, ey))) => match grad.kind { @@ -1417,7 +1417,7 @@ fn render_dcel_cpu( // Solid colour fill if !filled { - if let Some(fc) = &face.fill_color { + if let Some(fc) = &fill.color { let paint = solid_paint(fc.r, fc.g, fc.b, fc.a, opacity); pixmap.fill_path(&ts_path, &paint, fill_type, transform, None); } @@ -1425,7 +1425,7 @@ fn render_dcel_cpu( } // 2. Edges (strokes) - for edge in &dcel.edges { + for edge in &graph.edges { if edge.deleted { continue; } @@ -1481,8 +1481,8 @@ fn render_vector_layer_cpu( ); } - if let Some(dcel) = layer.dcel_at_time(time) { - render_dcel_cpu(dcel, pixmap, affine_to_ts(base_transform), layer_opacity as f32, document, image_cache); + if let Some(graph) = layer.graph_at_time(time) { + render_vector_graph_cpu(graph, pixmap, affine_to_ts(base_transform), layer_opacity as f32, document, image_cache); } } diff --git a/lightningbeam-ui/lightningbeam-core/src/selection.rs b/lightningbeam-ui/lightningbeam-core/src/selection.rs index ed4308c..0da1b5b 100644 --- a/lightningbeam-ui/lightningbeam-core/src/selection.rs +++ b/lightningbeam-ui/lightningbeam-core/src/selection.rs @@ -2,9 +2,9 @@ //! //! Tracks selected DCEL elements (edges, faces, vertices) and clip instances for editing operations. -use crate::dcel::{Dcel, EdgeId, FaceId, VertexId}; +use crate::vector_graph::{VectorGraph, EdgeId, FillId, VertexId}; use serde::{Deserialize, Serialize}; -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; use uuid::Uuid; use vello::kurbo::{Affine, BezPath}; @@ -181,8 +181,8 @@ pub struct Selection { /// Currently selected edges selected_edges: HashSet, - /// Currently selected faces - selected_faces: HashSet, + /// Currently selected fills + selected_fills: HashSet, /// Currently selected clip instances selected_clip_instances: Vec, @@ -203,7 +203,7 @@ pub struct Selection { /// Cleared when the selection is cleared. Used by clipboard_copy_selection /// to avoid re-extracting the geometry from the live DCEL. #[serde(skip)] - pub vector_subgraph: Option, + pub vector_subgraph: Option, } impl Selection { @@ -212,7 +212,7 @@ impl Selection { Self { selected_vertices: HashSet::new(), selected_edges: HashSet::new(), - selected_faces: HashSet::new(), + selected_fills: HashSet::new(), selected_clip_instances: Vec::new(), raster_selection: None, raster_floating: None, @@ -221,94 +221,70 @@ impl Selection { } // ----------------------------------------------------------------------- - // DCEL element selection + // Geometry element selection (VectorGraph) // ----------------------------------------------------------------------- /// Select an edge and its endpoint vertices, forming/extending a subgraph. - pub fn select_edge(&mut self, edge_id: EdgeId, dcel: &Dcel) { - if edge_id.is_none() || dcel.edge(edge_id).deleted { + pub fn select_edge(&mut self, edge_id: EdgeId, graph: &VectorGraph) { + if edge_id.is_none() || graph.edge(edge_id).deleted { return; } self.selected_edges.insert(edge_id); // Add both endpoint vertices - let [he_fwd, he_bwd] = dcel.edge(edge_id).half_edges; - if !he_fwd.is_none() { - let v = dcel.half_edge(he_fwd).origin; - if !v.is_none() { - self.selected_vertices.insert(v); - } + let [v0, v1] = graph.edge(edge_id).vertices; + if !v0.is_none() { + self.selected_vertices.insert(v0); } - if !he_bwd.is_none() { - let v = dcel.half_edge(he_bwd).origin; - if !v.is_none() { - self.selected_vertices.insert(v); - } + if !v1.is_none() { + self.selected_vertices.insert(v1); } } - /// Select a face by ID only, without adding boundary edges or vertices. + /// Select a fill by ID only, without adding boundary edges or vertices. /// - /// Use this when the geometry lives in a separate DCEL (e.g. region selection's - /// `selected_dcel`) so we don't add stale edge/vertex IDs to the selection. - pub fn select_face_id_only(&mut self, face_id: FaceId) { - if !face_id.is_none() && face_id.0 != 0 { - self.selected_faces.insert(face_id); + /// Use this when the geometry lives in a separate graph (e.g. region selection's + /// `selected_graph`) so we don't add stale edge/vertex IDs to the selection. + pub fn select_fill_id_only(&mut self, fill_id: FillId) { + if !fill_id.is_none() { + self.selected_fills.insert(fill_id); } } - /// Select a face and all its boundary edges + vertices. - pub fn select_face(&mut self, face_id: FaceId, dcel: &Dcel) { - if face_id.is_none() || face_id.0 == 0 || dcel.face(face_id).deleted { + /// Select a fill and all its boundary edges + vertices. + pub fn select_fill(&mut self, fill_id: FillId, graph: &VectorGraph) { + if fill_id.is_none() || graph.fill(fill_id).deleted { return; } - self.selected_faces.insert(face_id); + self.selected_fills.insert(fill_id); // Add all boundary edges and vertices - let boundary = dcel.face_boundary(face_id); - for he_id in boundary { - let he = dcel.half_edge(he_id); - let edge_id = he.edge; - if !edge_id.is_none() { - self.selected_edges.insert(edge_id); - // Add endpoints - let [he_fwd, he_bwd] = dcel.edge(edge_id).half_edges; - if !he_fwd.is_none() { - let v = dcel.half_edge(he_fwd).origin; - if !v.is_none() { - self.selected_vertices.insert(v); - } - } - if !he_bwd.is_none() { - let v = dcel.half_edge(he_bwd).origin; - if !v.is_none() { - self.selected_vertices.insert(v); - } - } + for eid in graph.fill_boundary_edges(fill_id) { + self.selected_edges.insert(eid); + let [v0, v1] = graph.edge(eid).vertices; + if !v0.is_none() { + self.selected_vertices.insert(v0); + } + if !v1.is_none() { + self.selected_vertices.insert(v1); } } } /// Deselect an edge and its vertices (if they have no other selected edges). - pub fn deselect_edge(&mut self, edge_id: EdgeId, dcel: &Dcel) { + pub fn deselect_edge(&mut self, edge_id: EdgeId, graph: &VectorGraph) { self.selected_edges.remove(&edge_id); // Remove endpoint vertices only if they're not used by other selected edges - let [he_fwd, he_bwd] = dcel.edge(edge_id).half_edges; - for he_id in [he_fwd, he_bwd] { - if he_id.is_none() { - continue; - } - let v = dcel.half_edge(he_id).origin; + let [v0, v1] = graph.edge(edge_id).vertices; + for v in [v0, v1] { if v.is_none() { continue; } // Check if any other selected edge uses this vertex let used = self.selected_edges.iter().any(|&eid| { - let e = dcel.edge(eid); - let [a, b] = e.half_edges; - (!a.is_none() && dcel.half_edge(a).origin == v) - || (!b.is_none() && dcel.half_edge(b).origin == v) + let e = graph.edge(eid); + e.vertices[0] == v || e.vertices[1] == v }); if !used { self.selected_vertices.remove(&v); @@ -316,26 +292,26 @@ impl Selection { } } - /// Deselect a face (edges/vertices stay if still referenced by other selections). - pub fn deselect_face(&mut self, face_id: FaceId) { - self.selected_faces.remove(&face_id); + /// Deselect a fill (edges/vertices stay if still referenced by other selections). + pub fn deselect_fill(&mut self, fill_id: FillId) { + self.selected_fills.remove(&fill_id); } /// Toggle an edge's selection state. - pub fn toggle_edge(&mut self, edge_id: EdgeId, dcel: &Dcel) { + pub fn toggle_edge(&mut self, edge_id: EdgeId, graph: &VectorGraph) { if self.selected_edges.contains(&edge_id) { - self.deselect_edge(edge_id, dcel); + self.deselect_edge(edge_id, graph); } else { - self.select_edge(edge_id, dcel); + self.select_edge(edge_id, graph); } } - /// Toggle a face's selection state. - pub fn toggle_face(&mut self, face_id: FaceId, dcel: &Dcel) { - if self.selected_faces.contains(&face_id) { - self.deselect_face(face_id); + /// Toggle a fill's selection state. + pub fn toggle_fill(&mut self, fill_id: FillId, graph: &VectorGraph) { + if self.selected_fills.contains(&fill_id) { + self.deselect_fill(fill_id); } else { - self.select_face(face_id, dcel); + self.select_fill(fill_id, graph); } } @@ -344,9 +320,9 @@ impl Selection { self.selected_edges.contains(edge_id) } - /// Check if a face is selected. - pub fn contains_face(&self, face_id: &FaceId) -> bool { - self.selected_faces.contains(face_id) + /// Check if a fill is selected. + pub fn contains_fill(&self, fill_id: &FillId) -> bool { + self.selected_fills.contains(fill_id) } /// Check if a vertex is selected. @@ -354,17 +330,17 @@ impl Selection { self.selected_vertices.contains(vertex_id) } - /// Clear DCEL element selections (edges, faces, vertices). - pub fn clear_dcel_selection(&mut self) { + /// Clear geometry element selections (edges, fills, vertices). + pub fn clear_geometry_selection(&mut self) { self.selected_vertices.clear(); self.selected_edges.clear(); - self.selected_faces.clear(); + self.selected_fills.clear(); self.vector_subgraph = None; } - /// Check if any DCEL elements are selected. - pub fn has_dcel_selection(&self) -> bool { - !self.selected_edges.is_empty() || !self.selected_faces.is_empty() + /// Check if any geometry elements are selected. + pub fn has_geometry_selection(&self) -> bool { + !self.selected_edges.is_empty() || !self.selected_fills.is_empty() } /// Get selected edges. @@ -372,9 +348,9 @@ impl Selection { &self.selected_edges } - /// Get selected faces. - pub fn selected_faces(&self) -> &HashSet { - &self.selected_faces + /// Get selected fills. + pub fn selected_fills(&self) -> &HashSet { + &self.selected_fills } /// Get selected vertices. @@ -449,7 +425,7 @@ impl Selection { pub fn clear(&mut self) { self.selected_vertices.clear(); self.selected_edges.clear(); - self.selected_faces.clear(); + self.selected_fills.clear(); self.selected_clip_instances.clear(); self.raster_selection = None; self.raster_floating = None; @@ -459,7 +435,7 @@ impl Selection { /// Check if selection is empty pub fn is_empty(&self) -> bool { self.selected_edges.is_empty() - && self.selected_faces.is_empty() + && self.selected_fills.is_empty() && self.selected_clip_instances.is_empty() } } @@ -479,25 +455,25 @@ pub struct RegionSelection { pub layer_id: Uuid, /// Keyframe time pub time: f64, - /// Snapshot of the DCEL before region boundary insertion, for revert - pub dcel_snapshot: Dcel, - /// The extracted DCEL containing geometry inside the region - pub selected_dcel: Dcel, - /// Transform applied to the selected DCEL (e.g. from dragging) + /// Snapshot of the graph before region boundary insertion, for revert + pub graph_snapshot: VectorGraph, + /// The extracted graph containing geometry inside the region + pub selected_graph: VectorGraph, + /// Transform applied to the selected graph (e.g. from dragging) pub transform: Affine, /// Whether the selection has been committed (via an operation on the selection) pub committed: bool, - /// Non-boundary vertices that are strictly inside the region (for merge-back). - pub inside_vertices: Vec, - /// Region boundary intersection vertices (for merge-back and fill propagation). - pub boundary_vertices: Vec, /// IDs of the invisible edges inserted for the region boundary stroke. - /// Removing these during merge-back heals the face splits they created. + /// These exist in the main graph (remainder side). Deleted during merge-back. pub region_edge_ids: Vec, /// Action epoch recorded when this selection was created. /// Compared against `ActionExecutor::epoch()` on deselect to decide /// whether merge-back is needed or a clean snapshot restore suffices. pub action_epoch_at_selection: u64, + /// selected_graph VID → main graph VID for boundary vertices (shared between both graphs). + pub boundary_vertex_map: HashMap, + /// selected_graph boundary EID → main graph boundary EID (duplicated edges to skip on merge). + pub boundary_edge_map: HashMap, } #[cfg(test)] @@ -570,23 +546,23 @@ mod tests { } #[test] - fn test_dcel_selection_basics() { + fn test_geometry_selection_basics() { let selection = Selection::new(); - assert!(!selection.has_dcel_selection()); + assert!(!selection.has_geometry_selection()); assert!(selection.selected_edges().is_empty()); - assert!(selection.selected_faces().is_empty()); + assert!(selection.selected_fills().is_empty()); assert!(selection.selected_vertices().is_empty()); } #[test] - fn test_clear_dcel_selection() { + fn test_clear_geometry_selection() { let mut selection = Selection::new(); - // Manually insert for unit test (no DCEL needed) + // Manually insert for unit test (no graph needed) selection.selected_edges.insert(EdgeId(0)); selection.selected_vertices.insert(VertexId(0)); - assert!(selection.has_dcel_selection()); + assert!(selection.has_geometry_selection()); - selection.clear_dcel_selection(); - assert!(!selection.has_dcel_selection()); + selection.clear_geometry_selection(); + assert!(!selection.has_geometry_selection()); } } diff --git a/lightningbeam-ui/lightningbeam-core/src/snap.rs b/lightningbeam-ui/lightningbeam-core/src/snap.rs index 36c6cc8..56b79b2 100644 --- a/lightningbeam-ui/lightningbeam-core/src/snap.rs +++ b/lightningbeam-ui/lightningbeam-core/src/snap.rs @@ -3,7 +3,7 @@ //! Provides snap-to-geometry queries that find the nearest vertex, edge midpoint, //! or curve point within a given radius. Priority order: Vertex > Midpoint > Curve. -use crate::dcel::{Dcel, EdgeId, VertexId}; +use crate::vector_graph::{VectorGraph, EdgeId, VertexId}; use vello::kurbo::{ParamCurve, ParamCurveNearest, Point}; /// Default snap radius in screen pixels (converted to document space via zoom). @@ -70,7 +70,7 @@ pub struct SnapExclusion { /// Priority: Vertex > Edge Midpoint > Nearest point on Curve. /// Returns `None` if nothing is within the configured radius. pub fn find_snap_target( - dcel: &Dcel, + graph: &VectorGraph, point: Point, config: &SnapConfig, exclusion: &SnapExclusion, @@ -80,7 +80,7 @@ pub fn find_snap_target( // Phase 1: Vertex snap (highest priority) if config.snap_to_vertices { let mut best: Option<(VertexId, Point, f64)> = None; - for (i, vertex) in dcel.vertices.iter().enumerate() { + for (i, vertex) in graph.vertices.iter().enumerate() { if vertex.deleted { continue; } @@ -109,7 +109,7 @@ pub fn find_snap_target( // Phase 2: Edge midpoint snap if config.snap_to_midpoints { let mut best: Option<(EdgeId, Point, f64)> = None; - for (i, edge) in dcel.edges.iter().enumerate() { + for (i, edge) in graph.edges.iter().enumerate() { if edge.deleted { continue; } @@ -139,7 +139,7 @@ pub fn find_snap_target( // Phase 3: Nearest point on curve if config.snap_to_curves { let mut best: Option<(EdgeId, f64, Point, f64)> = None; - for (i, edge) in dcel.edges.iter().enumerate() { + for (i, edge) in graph.edges.iter().enumerate() { if edge.deleted { continue; } @@ -176,21 +176,21 @@ mod tests { use super::*; use vello::kurbo::CubicBez; - fn make_dcel_with_edge() -> Dcel { - let mut dcel = Dcel::new(); + fn make_graph_with_edge() -> VectorGraph { + let mut graph = VectorGraph::new(); let curve = CubicBez::new( Point::new(0.0, 0.0), Point::new(33.0, 0.0), Point::new(67.0, 0.0), Point::new(100.0, 0.0), ); - dcel.insert_stroke(&[curve], None, None, 0.5); - dcel + graph.insert_stroke(&[curve], None, None, 0.5); + graph } #[test] fn snap_to_vertex() { - let dcel = make_dcel_with_edge(); + let graph = make_graph_with_edge(); let config = SnapConfig { radius: 5.0, snap_to_vertices: true, @@ -198,14 +198,14 @@ mod tests { snap_to_curves: true, }; let exclusion = SnapExclusion::default(); - let result = find_snap_target(&dcel, Point::new(2.0, 0.0), &config, &exclusion); + let result = find_snap_target(&graph, Point::new(2.0, 0.0), &config, &exclusion); assert!(result.is_some()); assert!(matches!(result.unwrap().target, SnapTarget::Vertex { .. })); } #[test] fn snap_to_midpoint() { - let dcel = make_dcel_with_edge(); + let graph = make_graph_with_edge(); let config = SnapConfig { radius: 5.0, snap_to_vertices: true, @@ -214,14 +214,14 @@ mod tests { }; let exclusion = SnapExclusion::default(); // Point near midpoint (50, 0) but far from vertices (0,0) and (100,0) - let result = find_snap_target(&dcel, Point::new(51.0, 0.0), &config, &exclusion); + let result = find_snap_target(&graph, Point::new(51.0, 0.0), &config, &exclusion); assert!(result.is_some()); assert!(matches!(result.unwrap().target, SnapTarget::Midpoint { .. })); } #[test] fn snap_to_curve() { - let dcel = make_dcel_with_edge(); + let graph = make_graph_with_edge(); let config = SnapConfig { radius: 5.0, snap_to_vertices: true, @@ -230,14 +230,14 @@ mod tests { }; let exclusion = SnapExclusion::default(); // Point near t=0.25 on curve (25, 0) — not near a vertex or midpoint - let result = find_snap_target(&dcel, Point::new(25.0, 3.0), &config, &exclusion); + let result = find_snap_target(&graph, Point::new(25.0, 3.0), &config, &exclusion); assert!(result.is_some()); assert!(matches!(result.unwrap().target, SnapTarget::Curve { .. })); } #[test] fn no_snap_outside_radius() { - let dcel = make_dcel_with_edge(); + let graph = make_graph_with_edge(); let config = SnapConfig { radius: 5.0, snap_to_vertices: true, @@ -245,13 +245,13 @@ mod tests { snap_to_curves: true, }; let exclusion = SnapExclusion::default(); - let result = find_snap_target(&dcel, Point::new(50.0, 20.0), &config, &exclusion); + let result = find_snap_target(&graph, Point::new(50.0, 20.0), &config, &exclusion); assert!(result.is_none()); } #[test] fn exclusion_skips_vertex() { - let dcel = make_dcel_with_edge(); + let graph = make_graph_with_edge(); let config = SnapConfig { radius: 5.0, snap_to_vertices: true, @@ -263,7 +263,7 @@ mod tests { vertices: vec![VertexId(0)], edges: vec![], }; - let result = find_snap_target(&dcel, Point::new(2.0, 0.0), &config, &exclusion); + let result = find_snap_target(&graph, Point::new(2.0, 0.0), &config, &exclusion); assert!(result.is_none()); } } diff --git a/lightningbeam-ui/lightningbeam-core/src/tool.rs b/lightningbeam-ui/lightningbeam-core/src/tool.rs index cde31d4..3ec96b7 100644 --- a/lightningbeam-ui/lightningbeam-core/src/tool.rs +++ b/lightningbeam-ui/lightningbeam-core/src/tool.rs @@ -203,13 +203,13 @@ pub enum ToolState { /// Editing a vertex (dragging it and connected edges) EditingVertex { - vertex_id: crate::dcel::VertexId, - connected_edges: Vec, // edges to update when vertex moves + vertex_id: crate::vector_graph::VertexId, + connected_edges: Vec, // edges to update when vertex moves }, /// Editing a curve (reshaping with moldCurve algorithm) EditingCurve { - edge_id: crate::dcel::EdgeId, + edge_id: crate::vector_graph::EdgeId, original_curve: vello::kurbo::CubicBez, start_mouse: Point, parameter_t: f64, @@ -217,7 +217,7 @@ pub enum ToolState { /// Pending curve interaction: click selects edge, drag starts curve editing PendingCurveInteraction { - edge_id: crate::dcel::EdgeId, + edge_id: crate::vector_graph::EdgeId, parameter_t: f64, start_mouse: Point, }, @@ -235,7 +235,7 @@ pub enum ToolState { /// Editing a control point (BezierEdit tool only) EditingControlPoint { - edge_id: crate::dcel::EdgeId, + edge_id: crate::vector_graph::EdgeId, point_index: u8, // 1 or 2 (p1 or p2 of the cubic bezier) original_curve: vello::kurbo::CubicBez, start_pos: Point, diff --git a/lightningbeam-ui/lightningbeam-core/src/vector_graph/mod.rs b/lightningbeam-ui/lightningbeam-core/src/vector_graph/mod.rs index 170c0f6..5478004 100644 --- a/lightningbeam-ui/lightningbeam-core/src/vector_graph/mod.rs +++ b/lightningbeam-ui/lightningbeam-core/src/vector_graph/mod.rs @@ -15,6 +15,7 @@ pub mod tests; use kurbo::{CubicBez, ParamCurve, Point}; use serde::{Deserialize, Serialize}; +use std::collections::{HashMap, HashSet}; use std::fmt; use crate::curve_intersections::find_curve_intersections; @@ -95,15 +96,19 @@ pub struct Edge { pub deleted: bool, } -/// A fill: an explicit boundary referencing edges, with a color. +/// A fill: an explicit boundary referencing edges, with visual properties. #[derive(Clone, Debug, Serialize, Deserialize)] pub struct Fill { /// Ordered cycle of directed edge references forming the boundary. + /// `EdgeId::NONE` entries act as separators between outer contour and hole contours. pub boundary: Vec<(EdgeId, Direction)>, - pub color: ShapeColor, + pub color: Option, pub fill_rule: FillRule, + #[serde(default)] + pub gradient_fill: Option, + #[serde(default)] + pub image_fill: Option, pub deleted: bool, - // TODO: gradient_fill, image_fill } // --------------------------------------------------------------------------- @@ -198,13 +203,15 @@ impl VectorGraph { pub fn alloc_fill( &mut self, boundary: Vec<(EdgeId, Direction)>, - color: ShapeColor, + color: impl Into>, fill_rule: FillRule, ) -> FillId { let fill = Fill { boundary, - color, + color: color.into(), fill_rule, + gradient_fill: None, + image_fill: None, deleted: false, }; if let Some(idx) = self.free_fills.pop() { @@ -344,6 +351,61 @@ impl VectorGraph { } } + // ------------------------------------------------------------------- + // Fill / hit-test queries + // ------------------------------------------------------------------- + + /// Find a fill whose boundary encloses the given point. + /// Returns the smallest (by area) enclosing fill. + pub fn find_fill_at_point(&self, point: Point) -> Option { + let mut best: Option<(FillId, f64)> = None; + for (i, fill) in self.fills.iter().enumerate() { + if fill.deleted { + continue; + } + let fid = FillId(i as u32); + let path = self.fill_to_bezpath(fid); + if kurbo::Shape::winding(&path, point) != 0 { + let area = kurbo::Shape::area(&path).abs(); + if best.is_none() || area < best.unwrap().1 { + best = Some((fid, area)); + } + } + } + best.map(|(fid, _)| fid) + } + + /// Get the distinct edge IDs from a fill's boundary (skipping NONE separators). + pub fn fill_boundary_edges(&self, fill_id: FillId) -> Vec { + let fill = &self.fills[fill_id.idx()]; + let mut edges = Vec::new(); + for &(eid, _) in &fill.boundary { + if !eid.is_none() && !edges.contains(&eid) { + edges.push(eid); + } + } + edges + } + + /// Get the distinct vertex IDs from a fill's boundary edges. + pub fn fill_boundary_vertices(&self, fill_id: FillId) -> Vec { + let mut verts = Vec::new(); + for eid in self.fill_boundary_edges(fill_id) { + let e = &self.edges[eid.idx()]; + for &vid in &e.vertices { + if !verts.contains(&vid) { + verts.push(vid); + } + } + } + verts + } + + /// Alias for `delete_edge_by_user` — removes an edge, handling fill merging/invisibility. + pub fn remove_edge(&mut self, id: EdgeId) { + self.delete_edge_by_user(id); + } + // ------------------------------------------------------------------- // Fill boundary → BezPath (for rendering) // ------------------------------------------------------------------- @@ -1393,6 +1455,223 @@ impl VectorGraph { None } + + // ── Region selection: extract / merge subgraph ────────────────────── + + /// Extract a subgraph containing `inside_edges` and `inside_fills`. + /// + /// Boundary edges (`boundary_edge_ids`) are **duplicated** — they exist in + /// both the returned graph and `self`, so both sides have closed fill + /// boundaries when the selection is moved. + /// + /// Returns `(new_graph, vertex_map, edge_map)` where the maps go from + /// old (self) IDs to new (returned graph) IDs. + pub fn extract_subgraph( + &mut self, + inside_edges: &HashSet, + inside_fills: &HashSet, + boundary_edge_ids: &HashSet, + ) -> (VectorGraph, HashMap, HashMap) { + let mut new_graph = VectorGraph::new(); + let mut vtx_map: HashMap = HashMap::new(); + let mut edge_map: HashMap = HashMap::new(); + + // Collect all edge IDs we need to copy into the new graph + let edges_to_copy: HashSet = inside_edges + .union(boundary_edge_ids) + .copied() + .collect(); + + // Collect all vertices referenced by edges we're copying + let mut referenced_vids: HashSet = HashSet::new(); + for &eid in &edges_to_copy { + if eid.is_none() || self.edges[eid.idx()].deleted { + continue; + } + for &vid in &self.edges[eid.idx()].vertices { + referenced_vids.insert(vid); + } + } + + // Determine which vertices are interior (exclusively owned by the + // extracted subgraph) vs boundary (shared with remaining geometry). + // A vertex is interior if ALL of its incident edges are either in + // inside_edges or boundary_edge_ids. + let mut interior_vertices: HashSet = HashSet::new(); + let mut boundary_vertices: HashSet = HashSet::new(); + for &vid in &referenced_vids { + let incident = self.edges_at_vertex(vid); + let all_inside = incident.iter().all(|&eid| edges_to_copy.contains(&eid)); + if all_inside { + interior_vertices.insert(vid); + } else { + boundary_vertices.insert(vid); + } + } + + // Allocate vertices in new graph + for &vid in &referenced_vids { + let pos = self.vertices[vid.idx()].position; + let new_vid = new_graph.alloc_vertex(pos); + vtx_map.insert(vid, new_vid); + } + + // Copy edges into new graph + for &eid in &edges_to_copy { + if eid.is_none() || self.edges[eid.idx()].deleted { + continue; + } + let edge = &self.edges[eid.idx()]; + let new_v0 = vtx_map[&edge.vertices[0]]; + let new_v1 = vtx_map[&edge.vertices[1]]; + let new_eid = new_graph.alloc_edge( + edge.curve, + new_v0, + new_v1, + edge.stroke_style.clone(), + edge.stroke_color.clone(), + ); + edge_map.insert(eid, new_eid); + } + + // Copy inside fills into new graph + for &fid in inside_fills { + if fid.is_none() || self.fills[fid.idx()].deleted { + continue; + } + let fill = &self.fills[fid.idx()]; + let new_boundary: Vec<(EdgeId, Direction)> = fill + .boundary + .iter() + .map(|&(eid, dir)| { + if eid.is_none() { + (EdgeId::NONE, dir) + } else if let Some(&new_eid) = edge_map.get(&eid) { + (new_eid, dir) + } else { + // Edge referenced by fill but not in edges_to_copy — + // shouldn't happen if classification is correct, but + // skip gracefully. + (EdgeId::NONE, dir) + } + }) + .collect(); + let new_fid = new_graph.alloc_fill( + new_boundary, + fill.color, + fill.fill_rule, + ); + // Copy gradient and image fill + new_graph.fills[new_fid.idx()].gradient_fill = fill.gradient_fill.clone(); + new_graph.fills[new_fid.idx()].image_fill = fill.image_fill; + } + + // Remove inside_edges from self (but NOT boundary edges — those are duplicated) + for &eid in inside_edges { + if !eid.is_none() && !self.edges[eid.idx()].deleted { + self.free_edge(eid); + } + } + + // Remove inside fills from self + for &fid in inside_fills { + if !fid.is_none() && !self.fills[fid.idx()].deleted { + self.free_fill(fid); + } + } + + // Free interior vertices (they have no remaining edges in self) + for &vid in &interior_vertices { + self.free_vertex(vid); + } + + (new_graph, vtx_map, edge_map) + } + + /// Merge another graph back into `self`, applying `transform` to all geometry. + /// + /// `boundary_vertex_map` maps vertex IDs in `other` to existing vertex IDs in + /// `self` (shared boundary vertices that should reconnect rather than duplicate). + /// + /// `boundary_edge_map` maps edge IDs in `other` to existing edge IDs in `self` + /// (duplicated boundary edges that should be skipped — `self` already has them). + pub fn merge_subgraph( + &mut self, + other: &VectorGraph, + transform: kurbo::Affine, + boundary_vertex_map: &HashMap, + boundary_edge_map: &HashMap, + ) { + let mut vtx_map: HashMap = HashMap::new(); + let mut edge_map: HashMap = HashMap::new(); + + // Map or allocate vertices + for (i, vertex) in other.vertices.iter().enumerate() { + let other_vid = VertexId(i as u32); + if vertex.deleted { + continue; + } + if let Some(&self_vid) = boundary_vertex_map.get(&other_vid) { + vtx_map.insert(other_vid, self_vid); + } else { + let pos = transform * vertex.position; + let new_vid = self.alloc_vertex(pos); + vtx_map.insert(other_vid, new_vid); + } + } + + // Map or allocate edges + for (i, edge) in other.edges.iter().enumerate() { + let other_eid = EdgeId(i as u32); + if edge.deleted { + continue; + } + if let Some(&self_eid) = boundary_edge_map.get(&other_eid) { + edge_map.insert(other_eid, self_eid); + } else { + let new_v0 = vtx_map[&edge.vertices[0]]; + let new_v1 = vtx_map[&edge.vertices[1]]; + // Transform the curve control points + let curve = CubicBez::new( + transform * edge.curve.p0, + transform * edge.curve.p1, + transform * edge.curve.p2, + transform * edge.curve.p3, + ); + let new_eid = self.alloc_edge( + curve, + new_v0, + new_v1, + edge.stroke_style.clone(), + edge.stroke_color.clone(), + ); + edge_map.insert(other_eid, new_eid); + } + } + + // Copy fills + for (_i, fill) in other.fills.iter().enumerate() { + if fill.deleted { + continue; + } + let new_boundary: Vec<(EdgeId, Direction)> = fill + .boundary + .iter() + .map(|&(eid, dir)| { + if eid.is_none() { + (EdgeId::NONE, dir) + } else if let Some(&new_eid) = edge_map.get(&eid) { + (new_eid, dir) + } else { + (EdgeId::NONE, dir) + } + }) + .collect(); + let new_fid = self.alloc_fill(new_boundary, fill.color, fill.fill_rule); + self.fills[new_fid.idx()].gradient_fill = fill.gradient_fill.clone(); + self.fills[new_fid.idx()].image_fill = fill.image_fill; + } + } } // --------------------------------------------------------------------------- @@ -1566,3 +1845,65 @@ fn nearest_point_on_cubic(curve: &CubicBez, point: Point) -> (f64, f64) { let dy = p.y - point.y; (best_t, (dx * dx + dy * dy).sqrt()) } + +/// Convert a BezPath into groups of cubic Bézier segments (one group per subpath). +pub fn bezpath_to_cubic_segments(path: &kurbo::BezPath) -> Vec> { + use kurbo::PathEl; + + let mut result: Vec> = Vec::new(); + let mut current: Vec = Vec::new(); + let mut subpath_start = Point::ZERO; + let mut cursor = Point::ZERO; + + for el in path.elements() { + match *el { + PathEl::MoveTo(p) => { + if !current.is_empty() { + result.push(std::mem::take(&mut current)); + } + subpath_start = p; + cursor = p; + } + PathEl::LineTo(p) => { + let c1 = lerp_point(cursor, p, 1.0 / 3.0); + let c2 = lerp_point(cursor, p, 2.0 / 3.0); + current.push(CubicBez::new(cursor, c1, c2, p)); + cursor = p; + } + PathEl::QuadTo(p1, p2) => { + let cp1 = Point::new( + cursor.x + (2.0 / 3.0) * (p1.x - cursor.x), + cursor.y + (2.0 / 3.0) * (p1.y - cursor.y), + ); + let cp2 = Point::new( + p2.x + (2.0 / 3.0) * (p1.x - p2.x), + p2.y + (2.0 / 3.0) * (p1.y - p2.y), + ); + current.push(CubicBez::new(cursor, cp1, cp2, p2)); + cursor = p2; + } + PathEl::CurveTo(p1, p2, p3) => { + current.push(CubicBez::new(cursor, p1, p2, p3)); + cursor = p3; + } + PathEl::ClosePath => { + let dist = ((cursor.x - subpath_start.x).powi(2) + + (cursor.y - subpath_start.y).powi(2)) + .sqrt(); + if dist > 1e-9 { + let c1 = lerp_point(cursor, subpath_start, 1.0 / 3.0); + let c2 = lerp_point(cursor, subpath_start, 2.0 / 3.0); + current.push(CubicBez::new(cursor, c1, c2, subpath_start)); + } + cursor = subpath_start; + if !current.is_empty() { + result.push(std::mem::take(&mut current)); + } + } + } + } + if !current.is_empty() { + result.push(current); + } + result +} diff --git a/lightningbeam-ui/lightningbeam-core/src/vector_graph/tests/basic.rs b/lightningbeam-ui/lightningbeam-core/src/vector_graph/tests/basic.rs index 466bbe4..144670a 100644 --- a/lightningbeam-ui/lightningbeam-core/src/vector_graph/tests/basic.rs +++ b/lightningbeam-ui/lightningbeam-core/src/vector_graph/tests/basic.rs @@ -91,7 +91,7 @@ fn alloc_fill_with_boundary() { let fid = g.alloc_fill(boundary, fill_color, FillRule::NonZero); assert_eq!(g.fill(fid).boundary.len(), 3); - assert_eq!(g.fill(fid).color, fill_color); + assert_eq!(g.fill(fid).color, Some(fill_color)); } // ── Adjacency ──────────────────────────────────────────────────────────── diff --git a/lightningbeam-ui/lightningbeam-core/src/vector_graph/tests/fill.rs b/lightningbeam-ui/lightningbeam-core/src/vector_graph/tests/fill.rs index 4df3263..b177f0b 100644 --- a/lightningbeam-ui/lightningbeam-core/src/vector_graph/tests/fill.rs +++ b/lightningbeam-ui/lightningbeam-core/src/vector_graph/tests/fill.rs @@ -51,7 +51,7 @@ fn paint_bucket_fills_rectangle() { let fid = fid.unwrap(); let fill = g.fill(fid); assert_eq!(fill.boundary.len(), 4, "rectangle boundary should have 4 edges"); - assert_eq!(fill.color, ShapeColor::rgb(255, 0, 0)); + assert_eq!(fill.color, Some(ShapeColor::rgb(255, 0, 0))); } #[test] @@ -148,7 +148,7 @@ fn draw_line_across_fill_splits_it() { // Both fills should inherit the original color for (_, fill) in &live_fills { - assert_eq!(fill.color, ShapeColor::rgb(255, 0, 0)); + assert_eq!(fill.color, Some(ShapeColor::rgb(255, 0, 0))); } } diff --git a/lightningbeam-ui/lightningbeam-core/src/vector_graph/tests/mod.rs b/lightningbeam-ui/lightningbeam-core/src/vector_graph/tests/mod.rs index 429c951..742aa64 100644 --- a/lightningbeam-ui/lightningbeam-core/src/vector_graph/tests/mod.rs +++ b/lightningbeam-ui/lightningbeam-core/src/vector_graph/tests/mod.rs @@ -8,3 +8,5 @@ mod fill; mod editing; #[cfg(test)] mod gap_close; +#[cfg(test)] +mod region; diff --git a/lightningbeam-ui/lightningbeam-core/src/vector_graph/tests/region.rs b/lightningbeam-ui/lightningbeam-core/src/vector_graph/tests/region.rs new file mode 100644 index 0000000..5b8dc19 --- /dev/null +++ b/lightningbeam-ui/lightningbeam-core/src/vector_graph/tests/region.rs @@ -0,0 +1,270 @@ +//! Tests for extract_subgraph and merge_subgraph (region selection support). + +use super::super::*; +use kurbo::{Affine, CubicBez, Point}; +use std::collections::{HashMap, HashSet}; + +/// Helper: create a straight-line cubic Bézier from a to b. +fn line(a: Point, b: Point) -> CubicBez { + CubicBez::new( + a, + Point::new(a.x + (b.x - a.x) / 3.0, a.y + (b.y - a.y) / 3.0), + Point::new(a.x + 2.0 * (b.x - a.x) / 3.0, a.y + 2.0 * (b.y - a.y) / 3.0), + b, + ) +} + +/// Build a triangle graph: 3 vertices, 3 edges, 1 fill. +/// Returns (graph, [v0,v1,v2], [e0,e1,e2], fid). +fn triangle_graph() -> (VectorGraph, [VertexId; 3], [EdgeId; 3], FillId) { + let mut g = VectorGraph::new(); + let p0 = Point::new(0.0, 0.0); + let p1 = Point::new(100.0, 0.0); + let p2 = Point::new(50.0, 100.0); + + let v0 = g.alloc_vertex(p0); + let v1 = g.alloc_vertex(p1); + let v2 = g.alloc_vertex(p2); + + let style = StrokeStyle { width: 1.0, ..Default::default() }; + let color = ShapeColor::rgb(0, 0, 0); + let e0 = g.alloc_edge(line(p0, p1), v0, v1, Some(style.clone()), Some(color)); + let e1 = g.alloc_edge(line(p1, p2), v1, v2, Some(style.clone()), Some(color)); + let e2 = g.alloc_edge(line(p2, p0), v2, v0, Some(style), Some(color)); + + let boundary = vec![ + (e0, Direction::Forward), + (e1, Direction::Forward), + (e2, Direction::Forward), + ]; + let fid = g.alloc_fill(boundary, ShapeColor::rgb(255, 0, 0), FillRule::NonZero); + + (g, [v0, v1, v2], [e0, e1, e2], fid) +} + +// ── extract_subgraph ───────────────────────────────────────────────── + +#[test] +fn extract_empty_region_returns_empty_graph() { + let (mut g, _, _, _) = triangle_graph(); + let orig_edge_count = g.edges.iter().filter(|e| !e.deleted).count(); + + let (new_g, vtx_map, edge_map) = g.extract_subgraph( + &HashSet::new(), + &HashSet::new(), + &HashSet::new(), + ); + + // New graph should be empty + assert_eq!(new_g.edges.iter().filter(|e| !e.deleted).count(), 0); + assert_eq!(new_g.fills.iter().filter(|f| !f.deleted).count(), 0); + assert!(vtx_map.is_empty()); + assert!(edge_map.is_empty()); + + // Original should be unchanged + assert_eq!(g.edges.iter().filter(|e| !e.deleted).count(), orig_edge_count); +} + +#[test] +fn extract_single_edge_removes_from_original() { + let mut g = VectorGraph::new(); + let v0 = g.alloc_vertex(Point::new(0.0, 0.0)); + let v1 = g.alloc_vertex(Point::new(100.0, 0.0)); + let v2 = g.alloc_vertex(Point::new(200.0, 0.0)); + + let style = StrokeStyle { width: 1.0, ..Default::default() }; + let color = ShapeColor::rgb(0, 0, 0); + let e0 = g.alloc_edge(line(Point::new(0.0, 0.0), Point::new(100.0, 0.0)), v0, v1, Some(style.clone()), Some(color)); + let e1 = g.alloc_edge(line(Point::new(100.0, 0.0), Point::new(200.0, 0.0)), v1, v2, Some(style), Some(color)); + + let mut inside = HashSet::new(); + inside.insert(e0); + + let (new_g, vtx_map, edge_map) = g.extract_subgraph( + &inside, + &HashSet::new(), + &HashSet::new(), + ); + + // e0 should be extracted + assert!(g.edge(e0).deleted, "extracted edge should be freed from original"); + assert!(!g.edge(e1).deleted, "non-extracted edge should remain"); + + // v0 is interior (only connected to e0), should be freed + assert!(g.vertex(v0).deleted, "interior vertex should be freed"); + // v1 is shared (connected to e1 too), should NOT be freed + assert!(!g.vertex(v1).deleted, "shared vertex should remain"); + + // New graph should have the edge + assert_eq!(new_g.edges.iter().filter(|e| !e.deleted).count(), 1); + assert!(edge_map.contains_key(&e0)); + + // New graph should have 2 vertices (v0 and v1 mapped) + assert_eq!(new_g.vertices.iter().filter(|v| !v.deleted).count(), 2); + assert!(vtx_map.contains_key(&v0)); + assert!(vtx_map.contains_key(&v1)); +} + +#[test] +fn extract_fill_duplicates_boundary_edges() { + let (mut g, verts, edges, fid) = triangle_graph(); + + // Pretend e0 is a boundary edge (from region selection insert_stroke) + let mut boundary_edges = HashSet::new(); + boundary_edges.insert(edges[0]); + + // e1 and e2 are "inside" + let mut inside_edges = HashSet::new(); + inside_edges.insert(edges[1]); + inside_edges.insert(edges[2]); + + let mut inside_fills = HashSet::new(); + inside_fills.insert(fid); + + let (new_g, vtx_map, edge_map) = g.extract_subgraph( + &inside_edges, + &inside_fills, + &boundary_edges, + ); + + // Boundary edge e0 should still exist in original (duplicated, not removed) + assert!(!g.edge(edges[0]).deleted, "boundary edge should remain in original"); + + // Inside edges should be removed from original + assert!(g.edge(edges[1]).deleted, "inside edge should be freed from original"); + assert!(g.edge(edges[2]).deleted, "inside edge should be freed from original"); + + // New graph should have 3 edges: e0 (boundary copy) + e1 + e2 + assert_eq!(new_g.edges.iter().filter(|e| !e.deleted).count(), 3); + + // New graph should have 1 fill + assert_eq!(new_g.fills.iter().filter(|f| !f.deleted).count(), 1); + + // The fill's boundary in new graph should reference remapped edges + let new_fill = &new_g.fills[0]; + assert_eq!(new_fill.boundary.len(), 3); + for &(eid, _) in &new_fill.boundary { + assert!(!eid.is_none(), "fill boundary should have valid edge IDs"); + } + + // Fill color should be preserved + assert_eq!(new_fill.color, Some(ShapeColor::rgb(255, 0, 0))); +} + +// ── merge_subgraph ─────────────────────────────────────────────────── + +#[test] +fn merge_round_trip_identity_restores_edges() { + let mut g = VectorGraph::new(); + let v0 = g.alloc_vertex(Point::new(0.0, 0.0)); + let v1 = g.alloc_vertex(Point::new(100.0, 0.0)); + let v2 = g.alloc_vertex(Point::new(200.0, 0.0)); + + let style = StrokeStyle { width: 1.0, ..Default::default() }; + let color = ShapeColor::rgb(0, 0, 0); + let e0 = g.alloc_edge(line(Point::new(0.0, 0.0), Point::new(100.0, 0.0)), v0, v1, Some(style.clone()), Some(color)); + let _e1 = g.alloc_edge(line(Point::new(100.0, 0.0), Point::new(200.0, 0.0)), v1, v2, Some(style), Some(color)); + + let mut inside = HashSet::new(); + inside.insert(e0); + + let (new_g, vtx_map, edge_map) = g.extract_subgraph( + &inside, + &HashSet::new(), + &HashSet::new(), + ); + + // Build boundary vertex map (reverse of vtx_map, only for non-deleted vertices in g) + let boundary_vtx_map: HashMap = vtx_map.iter() + .filter(|(&old, _)| !g.vertex(old).deleted) + .map(|(&old, &new)| (new, old)) + .collect(); + + // Merge back with identity transform + g.merge_subgraph(&new_g, Affine::IDENTITY, &boundary_vtx_map, &HashMap::new()); + + // Should have 2 non-deleted edges again + let live_edges = g.edges.iter().filter(|e| !e.deleted).count(); + assert_eq!(live_edges, 2, "should have 2 edges after merge-back"); + + // Should have 3 vertices (v0 was freed then re-added) + let live_verts = g.vertices.iter().filter(|v| !v.deleted).count(); + assert_eq!(live_verts, 3, "should have 3 vertices after merge-back"); +} + +#[test] +fn merge_with_translation_moves_geometry() { + let mut g = VectorGraph::new(); + let v0 = g.alloc_vertex(Point::new(0.0, 0.0)); + let v1 = g.alloc_vertex(Point::new(100.0, 0.0)); + + let style = StrokeStyle { width: 1.0, ..Default::default() }; + let color = ShapeColor::rgb(0, 0, 0); + let e0 = g.alloc_edge(line(Point::new(0.0, 0.0), Point::new(100.0, 0.0)), v0, v1, Some(style), Some(color)); + + let mut inside = HashSet::new(); + inside.insert(e0); + + let (new_g, _vtx_map, _edge_map) = g.extract_subgraph( + &inside, + &HashSet::new(), + &HashSet::new(), + ); + + // Merge back with a translation of (50, 50) + let transform = Affine::translate((50.0, 50.0)); + g.merge_subgraph(&new_g, transform, &HashMap::new(), &HashMap::new()); + + // The merged edge's vertices should be at (50,50) and (150,50) + let merged_edge = g.edges.iter().find(|e| !e.deleted).unwrap(); + let v0_pos = g.vertices[merged_edge.vertices[0].idx()].position; + let v1_pos = g.vertices[merged_edge.vertices[1].idx()].position; + + assert!((v0_pos.x - 50.0).abs() < 0.01 && (v0_pos.y - 50.0).abs() < 0.01, + "v0 should be at (50, 50), got ({}, {})", v0_pos.x, v0_pos.y); + assert!((v1_pos.x - 150.0).abs() < 0.01 && (v1_pos.y - 50.0).abs() < 0.01, + "v1 should be at (150, 50), got ({}, {})", v1_pos.x, v1_pos.y); +} + +#[test] +fn extract_and_merge_fill_round_trip() { + let (mut g, _verts, edges, fid) = triangle_graph(); + + // Treat e0 as boundary, e1+e2 as inside, fill as inside + let mut boundary_edges = HashSet::new(); + boundary_edges.insert(edges[0]); + let mut inside_edges = HashSet::new(); + inside_edges.insert(edges[1]); + inside_edges.insert(edges[2]); + let mut inside_fills = HashSet::new(); + inside_fills.insert(fid); + + let (new_g, vtx_map, edge_map) = g.extract_subgraph( + &inside_edges, + &inside_fills, + &boundary_edges, + ); + + // Build maps for merge-back + let boundary_vtx_map: HashMap = vtx_map.iter() + .filter(|(&old, _)| !g.vertex(old).deleted) + .map(|(&old, &new)| (new, old)) + .collect(); + let boundary_edge_map_for_merge: HashMap = edge_map.iter() + .filter(|(old_eid, _)| boundary_edges.contains(old_eid)) + .map(|(&old, &new)| (new, old)) + .collect(); + + // Before merge: original has 1 edge (boundary), extracted has 3 edges + 1 fill + assert_eq!(g.edges.iter().filter(|e| !e.deleted).count(), 1); + assert_eq!(g.fills.iter().filter(|f| !f.deleted).count(), 0); + assert_eq!(new_g.edges.iter().filter(|e| !e.deleted).count(), 3); + assert_eq!(new_g.fills.iter().filter(|f| !f.deleted).count(), 1); + + // Merge back + g.merge_subgraph(&new_g, Affine::IDENTITY, &boundary_vtx_map, &boundary_edge_map_for_merge); + + // After merge: should have 3 edges (boundary + 2 merged) and 1 fill + assert_eq!(g.edges.iter().filter(|e| !e.deleted).count(), 3); + assert_eq!(g.fills.iter().filter(|f| !f.deleted).count(), 1); +} diff --git a/lightningbeam-ui/lightningbeam-editor/src/main.rs b/lightningbeam-ui/lightningbeam-editor/src/main.rs index 203d8dc..90dddd0 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/main.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/main.rs @@ -2258,29 +2258,20 @@ impl EditorApp { }; self.clipboard_manager.copy(content); - } else if self.selection.has_dcel_selection() { + } else if self.selection.has_geometry_selection() { let subgraph = if let Some(dcel) = self.selection.vector_subgraph.take() { // Region selection: the sub-DCEL was pre-extracted on commit. dcel } else { - // Select tool: extract faces adjacent to the selected edges from the live DCEL. - let active_layer_id = match self.active_layer_id { - Some(id) => id, - None => return, - }; - let document = self.action_executor.document(); - let Some(lightningbeam_core::layer::AnyLayer::Vector(vl)) = document.get_layer(&active_layer_id) else { - return; - }; - let Some(live_dcel) = vl.dcel_at_time(self.playback_time) else { - return; - }; - let selected_edges = self.selection.selected_edges().clone(); - lightningbeam_core::dcel2::extract_faces_for_edges(live_dcel, &selected_edges) + // Select tool: extract faces adjacent to the selected edges. + // TODO: VectorGraph copy — extract_faces_for_edges requires Dcel; + // port to VectorGraph when clipboard is migrated. + return; }; let dcel_json = serde_json::to_string(&subgraph).unwrap_or_default(); - let svg_xml = lightningbeam_core::svg_export::dcel_to_svg(&subgraph); + // TODO: svg_export needs to be ported to VectorGraph + let svg_xml = String::new(); self.clipboard_manager.copy( lightningbeam_core::clipboard::ClipboardContent::VectorGeometry { dcel_json, @@ -2381,7 +2372,7 @@ impl EditorApp { } self.selection.clear_clip_instances(); - } else if self.selection.has_dcel_selection() { + } else if self.selection.has_geometry_selection() { let active_layer_id = match self.active_layer_id { Some(id) => id, None => return, @@ -2397,50 +2388,37 @@ impl EditorApp { // Current document DCEL = outside portion only (boundary edges present). // We commit the snapshot as "before" and the current state as "after", // then drop the region selection so it is not merged back. - let document = self.action_executor.document(); - if let Some(lightningbeam_core::layer::AnyLayer::Vector(vl)) = - document.get_layer(®ion_sel.layer_id) - { - if let Some(dcel_after) = vl.dcel_at_time(region_sel.time) { - let action = lightningbeam_core::actions::ModifyDcelAction::new( - region_sel.layer_id, - region_sel.time, - region_sel.dcel_snapshot.clone(), - dcel_after.clone(), - "Cut/delete region selection", - ); - if let Err(e) = self.action_executor.execute(Box::new(action)) { - eprintln!("Delete region selection failed: {}", e); - } - } - } + // TODO: Region selection delete requires converting Dcel snapshot + // to VectorGraph for ModifyGraphAction. Deferred until RegionSelection + // is migrated from Dcel to VectorGraph. + eprintln!("Region selection delete: not yet ported to VectorGraph"); // region_sel is dropped; the stage pane will see region_selection == None. } - self.selection.clear_dcel_selection(); + self.selection.clear_geometry_selection(); return; } // Select-tool case: delete the selected edges. - let edge_ids: Vec = + let edge_ids: Vec = self.selection.selected_edges().iter().copied().collect(); if !edge_ids.is_empty() { let document = self.action_executor.document(); if let Some(layer) = document.get_layer(&active_layer_id) { if let lightningbeam_core::layer::AnyLayer::Vector(vector_layer) = layer { - if let Some(dcel_before) = vector_layer.dcel_at_time(self.playback_time) { - let mut dcel_after = dcel_before.clone(); + if let Some(graph_before) = vector_layer.graph_at_time(self.playback_time) { + let mut graph_after = graph_before.clone(); for edge_id in &edge_ids { - if !dcel_after.edge(*edge_id).deleted { - dcel_after.remove_edge(*edge_id); + if !graph_after.edge(*edge_id).deleted { + graph_after.remove_edge(*edge_id); } } - let action = lightningbeam_core::actions::ModifyDcelAction::new( + let action = lightningbeam_core::actions::ModifyGraphAction::new( active_layer_id, self.playback_time, - dcel_before.clone(), - dcel_after, + graph_before.clone(), + graph_after, "Delete selected edges", ); @@ -2452,7 +2430,7 @@ impl EditorApp { } } - self.selection.clear_dcel_selection(); + self.selection.clear_geometry_selection(); } } @@ -2574,42 +2552,10 @@ impl EditorApp { self.selection.add_clip_instance(id); } } - ClipboardContent::VectorGeometry { dcel_json, .. } => { - // Deserialize the subgraph and merge it into the live DCEL. - let clipboard_dcel: lightningbeam_core::dcel2::Dcel = - match serde_json::from_str(&dcel_json) { - Ok(d) => d, - Err(e) => { - eprintln!("Paste: failed to deserialize vector geometry: {e}"); - return; - } - }; - - let active_layer_id = match self.active_layer_id { - Some(id) => id, - None => return, - }; - - let document = self.action_executor.document(); - let Some(lightningbeam_core::layer::AnyLayer::Vector(vl)) = - document.get_layer(&active_layer_id) else { return }; - let Some(dcel_before) = vl.dcel_at_time(self.playback_time) else { return }; - - let mut dcel_after = dcel_before.clone(); - // Paste with a small nudge so it is visually distinct from the original. - let nudge = vello::kurbo::Vec2::new(10.0, 10.0); - dcel_after.import_from(&clipboard_dcel, nudge); - - let action = lightningbeam_core::actions::ModifyDcelAction::new( - active_layer_id, - self.playback_time, - dcel_before.clone(), - dcel_after, - "Paste vector geometry", - ); - if let Err(e) = self.action_executor.execute(Box::new(action)) { - eprintln!("Paste vector geometry failed: {e}"); - } + ClipboardContent::VectorGeometry { .. } => { + // TODO: VectorGraph paste — import_from requires Dcel; + // port when clipboard is migrated from Dcel to VectorGraph. + eprintln!("Paste vector geometry: not yet ported to VectorGraph"); } ClipboardContent::Layers { .. } => { // TODO: insert copied layers as siblings at the current selection point. @@ -3160,7 +3106,7 @@ impl EditorApp { } // Stale vertex/edge/face IDs from before the undo would // crash selection rendering on the restored (smaller) DCEL. - self.selection.clear_dcel_selection(); + self.selection.clear_geometry_selection(); } } MenuAction::Redo => { @@ -3196,7 +3142,7 @@ impl EditorApp { if let Some((clip_id, notes)) = midi_update { self.rebuild_midi_cache_entry(clip_id, ¬es); } - self.selection.clear_dcel_selection(); + self.selection.clear_geometry_selection(); } } MenuAction::Cut => { @@ -3246,7 +3192,7 @@ impl EditorApp { _ => { // Existing clip instance grouping fallback (stub) if let Some(layer_id) = self.active_layer_id { - if self.selection.has_dcel_selection() { + if self.selection.has_geometry_selection() { // TODO: DCEL group deferred to Phase 2 } else { let clip_ids: Vec = self.selection.clip_instances().to_vec(); @@ -3270,7 +3216,7 @@ impl EditorApp { } MenuAction::ConvertToMovieClip => { if let Some(layer_id) = self.active_layer_id { - if self.selection.has_dcel_selection() { + if self.selection.has_geometry_selection() { // TODO: DCEL convert-to-movie-clip deferred to Phase 2 } else { let clip_ids: Vec = self.selection.clip_instances().to_vec(); diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/infopanel.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/infopanel.rs index 924db25..194c20d 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/infopanel.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/infopanel.rs @@ -99,7 +99,7 @@ impl InfopanelPane { let mut info = SelectionInfo::default(); let edge_count = shared.selection.selected_edges().len(); - let face_count = shared.selection.selected_faces().len(); + let face_count = shared.selection.selected_fills().len(); info.dcel_count = edge_count + face_count; info.is_empty = info.dcel_count == 0; @@ -115,7 +115,7 @@ impl InfopanelPane { if let Some(layer) = document.get_layer(&layer_id) { if let AnyLayer::Vector(vector_layer) = layer { - if let Some(dcel) = vector_layer.dcel_at_time(*shared.playback_time) { + if let Some(graph) = vector_layer.graph_at_time(*shared.playback_time) { // Gather stroke properties from selected edges let mut first_stroke_color: Option> = None; let mut first_stroke_width: Option = None; @@ -123,7 +123,7 @@ impl InfopanelPane { let mut stroke_width_mixed = false; for &eid in shared.selection.selected_edges() { - let edge = dcel.edge(eid); + let edge = graph.edge(lightningbeam_core::vector_graph::EdgeId(eid.0)); let sc = edge.stroke_color; let sw = edge.stroke_style.as_ref().map(|s| s.width); @@ -152,10 +152,10 @@ impl InfopanelPane { let mut first_fill_gradient: Option> = None; let mut fill_gradient_mixed = false; - for &fid in shared.selection.selected_faces() { - let face = dcel.face(fid); - let fc = face.fill_color; - let fg = face.gradient_fill.clone(); + for &fid in shared.selection.selected_fills() { + let fill = graph.fill(fid); + let fc = fill.color; + let fg = fill.gradient_fill.clone(); match first_fill_color { None => first_fill_color = Some(fc), @@ -777,8 +777,10 @@ impl InfopanelPane { None => return, }; let time = *shared.playback_time; - let face_ids: Vec<_> = shared.selection.selected_faces().iter().copied().collect(); - let edge_ids: Vec<_> = shared.selection.selected_edges().iter().copied().collect(); + let face_ids: Vec = shared.selection.selected_fills() + .iter().map(|fid| lightningbeam_core::vector_graph::FillId(fid.0)).collect(); + let edge_ids: Vec = shared.selection.selected_edges() + .iter().map(|eid| lightningbeam_core::vector_graph::EdgeId(eid.0)).collect(); egui::CollapsingHeader::new("Shape") .id_salt(("shape", path)) diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs index 1820c7b..1531bd2 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs @@ -1643,8 +1643,8 @@ impl egui_wgpu::CallbackTrait for VelloCallback { // Render selected DCEL from active region selection (with transform) if let Some(ref region_sel) = self.ctx.region_selection { let sel_transform = overlay_transform * region_sel.transform; - lightningbeam_core::renderer::render_dcel( - ®ion_sel.selected_dcel, + lightningbeam_core::renderer::render_vector_graph( + ®ion_sel.selected_graph, &mut scene, sel_transform, 1.0, @@ -1667,8 +1667,8 @@ impl egui_wgpu::CallbackTrait for VelloCallback { if let Some(ref region_sel) = self.ctx.region_selection { let sel_transform = overlay_transform * region_sel.transform; let mut image_cache = shared.image_cache.lock().unwrap(); - lightningbeam_core::renderer::render_dcel( - ®ion_sel.selected_dcel, + lightningbeam_core::renderer::render_vector_graph( + ®ion_sel.selected_graph, &mut scene, sel_transform, 1.0, @@ -1743,8 +1743,8 @@ impl egui_wgpu::CallbackTrait for VelloCallback { // NOTE: Skip this if Transform tool is active (it has its own handles) if !self.ctx.selection.is_empty() && !matches!(self.ctx.selected_tool, Tool::Transform) { // Draw Flash-style stipple pattern on selected edges and faces - if self.ctx.selection.has_dcel_selection() { - if let Some(dcel) = vector_layer.dcel_at_time(self.ctx.playback_time) { + if self.ctx.selection.has_geometry_selection() { + if let Some(graph) = vector_layer.graph_at_time(self.ctx.playback_time) { let stipple_brush = selection_stipple_brush(); // brush_transform scales the stipple so 1 pattern pixel = 1 screen pixel. // The shape is in document space, transformed to screen by overlay_transform @@ -1753,11 +1753,11 @@ impl egui_wgpu::CallbackTrait for VelloCallback { let inv_zoom = 1.0 / self.ctx.zoom as f64; let brush_xform = Some(Affine::scale(inv_zoom)); - // Stipple selected faces - for &face_id in self.ctx.selection.selected_faces() { - let face = dcel.face(face_id); - if face.deleted || face_id.0 == 0 { continue; } - let path = dcel.face_to_bezpath_with_holes(face_id); + // Stipple selected fills + for &fill_id in self.ctx.selection.selected_fills() { + let fill = graph.fill(fill_id); + if fill.deleted { continue; } + let path = graph.fill_to_bezpath(fill_id); scene.fill( Fill::NonZero, overlay_transform, @@ -1769,7 +1769,7 @@ impl egui_wgpu::CallbackTrait for VelloCallback { // Stipple selected edges for &edge_id in self.ctx.selection.selected_edges() { - let edge = dcel.edge(edge_id); + let edge = graph.edge(edge_id); if edge.deleted { continue; } let width = edge.stroke_style.as_ref() .map(|s| s.width) @@ -1857,21 +1857,21 @@ impl egui_wgpu::CallbackTrait for VelloCallback { } } - // 1a. Draw stipple overlay on region-selected DCEL + // 1a. Draw stipple overlay on region-selected graph if let Some(ref region_sel) = self.ctx.region_selection { - use lightningbeam_core::dcel::FaceId as DcelFaceId; - let sel_dcel = ®ion_sel.selected_dcel; + use lightningbeam_core::vector_graph::FillId; + let sel_graph = ®ion_sel.selected_graph; let sel_transform = overlay_transform * region_sel.transform; let stipple_brush = selection_stipple_brush(); let inv_zoom = 1.0 / self.ctx.zoom as f64; let brush_xform = Some(Affine::scale(inv_zoom)); - // Stipple faces with visible fill - for (i, face) in sel_dcel.faces.iter().enumerate() { - if face.deleted || i == 0 { continue; } - if face.fill_color.is_none() && face.image_fill.is_none() && face.gradient_fill.is_none() { continue; } - let face_id = DcelFaceId(i as u32); - let path = sel_dcel.face_to_bezpath_with_holes(face_id); + // Stipple fills with visible content + for (i, fill) in sel_graph.fills.iter().enumerate() { + if fill.deleted { continue; } + if fill.color.is_none() && fill.image_fill.is_none() && fill.gradient_fill.is_none() { continue; } + let fill_id = FillId(i as u32); + let path = sel_graph.fill_to_bezpath(fill_id); scene.fill( vello::peniko::Fill::NonZero, sel_transform, @@ -1882,7 +1882,7 @@ impl egui_wgpu::CallbackTrait for VelloCallback { } // Stipple edges with visible stroke - for edge in &sel_dcel.edges { + for edge in &sel_graph.edges { if edge.deleted { continue; } if edge.stroke_style.is_none() && edge.stroke_color.is_none() { continue; } let width = edge.stroke_style.as_ref() @@ -1935,8 +1935,8 @@ impl egui_wgpu::CallbackTrait for VelloCallback { }; if let Some(edge_id) = highlight_edge { - if let Some(dcel) = vector_layer.dcel_at_time(self.ctx.playback_time) { - let edge = dcel.edge(edge_id); + if let Some(graph) = vector_layer.graph_at_time(self.ctx.playback_time) { + let edge = graph.edge(edge_id); if !edge.deleted { let stipple_brush = selection_stipple_brush(); let inv_zoom = 1.0 / self.ctx.zoom as f64; @@ -2064,9 +2064,9 @@ impl egui_wgpu::CallbackTrait for VelloCallback { // For multiple objects: use axis-aligned bounding box (simpler for now) let total_selected = self.ctx.selection.clip_instances().len(); - if self.ctx.selection.has_dcel_selection() { + if self.ctx.selection.has_geometry_selection() { // DCEL selection: compute bbox from selected vertices - if let Some(dcel) = vector_layer.dcel_at_time(self.ctx.playback_time) { + if let Some(graph) = vector_layer.graph_at_time(self.ctx.playback_time) { let mut min_x = f64::INFINITY; let mut min_y = f64::INFINITY; let mut max_x = f64::NEG_INFINITY; @@ -2074,7 +2074,7 @@ impl egui_wgpu::CallbackTrait for VelloCallback { let mut found_any = false; for &vid in self.ctx.selection.selected_vertices() { - let v = dcel.vertex(vid); + let v = graph.vertex(vid); if v.deleted { continue; } min_x = min_x.min(v.position.x); min_y = min_y.min(v.position.y); @@ -2800,7 +2800,7 @@ pub struct StagePane { // Last known viewport rect (for zoom-to-fit calculation) last_viewport_rect: Option, // Vector editing cache - dcel_editing_cache: Option, + graph_editing_cache: Option, // Current snap result (for visual feedback rendering) current_snap: Option, // Raster stroke in progress: (layer_id, time, brush_state, buffer_before) @@ -2975,7 +2975,7 @@ struct GradientState { struct VectorGradientState { layer_id: uuid::Uuid, time: f64, - face_ids: Vec, + fill_ids: Vec, start: egui::Vec2, // World-space drag start end: egui::Vec2, // World-space drag end } @@ -3048,13 +3048,13 @@ static WARP_READBACK_RESULTS: OnceLock hit_test::HitResult::Edge(eid), - hit_test::DcelHitResult::Face(fid) => hit_test::HitResult::Face(fid), + .map(|graph_hit| match graph_hit { + hit_test::GraphHitResult::Edge(eid) => hit_test::HitResult::Edge(eid), + hit_test::GraphHitResult::Fill(fid) => hit_test::HitResult::Fill(fid), }) }; if let Some(hit) = hit_result { match hit { hit_test::HitResult::Edge(edge_id) => { - // DCEL edge was hit - if let Some(dcel) = vector_layer.dcel_at_time(*shared.playback_time) { + if let Some(graph) = vector_layer.graph_at_time(*shared.playback_time) { if shift_held { - shared.selection.toggle_edge(edge_id, dcel); + shared.selection.toggle_edge(edge_id, graph); } else { - shared.selection.clear_dcel_selection(); - shared.selection.select_edge(edge_id, dcel); + shared.selection.clear_geometry_selection(); + shared.selection.select_edge(edge_id, graph); } } if let Some(layer_id) = *shared.active_layer_id { *shared.focus = lightningbeam_core::selection::FocusSelection::Geometry { layer_id, time: *shared.playback_time }; } - // DCEL element dragging deferred to Phase 3 } - hit_test::HitResult::Face(face_id) => { - // DCEL face was hit - if let Some(dcel) = vector_layer.dcel_at_time(*shared.playback_time) { + hit_test::HitResult::Fill(fill_id) => { + if let Some(graph) = vector_layer.graph_at_time(*shared.playback_time) { if shift_held { - shared.selection.toggle_face(face_id, dcel); + shared.selection.toggle_fill(fill_id, graph); } else { - shared.selection.clear_dcel_selection(); - shared.selection.select_face(face_id, dcel); + shared.selection.clear_geometry_selection(); + shared.selection.select_fill(fill_id, graph); } } if let Some(layer_id) = *shared.active_layer_id { *shared.focus = lightningbeam_core::selection::FocusSelection::Geometry { layer_id, time: *shared.playback_time }; } - // DCEL element dragging deferred to Phase 3 } hit_test::HitResult::ClipInstance(clip_id) => { // Clip instance was hit @@ -3717,11 +3713,11 @@ impl StagePane { let document = shared.action_executor.document(); if let Some(layer) = document.get_layer(&active_layer_id) { if let AnyLayer::Vector(vl) = layer { - if let Some(dcel) = vl.dcel_at_time(*shared.playback_time) { + if let Some(graph) = vl.graph_at_time(*shared.playback_time) { if !shift_held { - shared.selection.clear_dcel_selection(); + shared.selection.clear_geometry_selection(); } - shared.selection.select_edge(edge_id, dcel); + shared.selection.select_edge(edge_id, graph); } } } @@ -3819,7 +3815,7 @@ impl StagePane { ); // Hit test DCEL elements in rectangle - let dcel_hits = hit_test::hit_test_dcel_in_rect( + let graph_hits = hit_test::hit_test_graph_in_rect( vector_layer, *shared.playback_time, selection_rect, @@ -3831,18 +3827,18 @@ impl StagePane { shared.selection.add_clip_instance(clip_id); } - // Add DCEL elements to selection - if let Some(dcel) = vector_layer.dcel_at_time(*shared.playback_time) { - for edge_id in dcel_hits.edges { - shared.selection.select_edge(edge_id, dcel); + // Add graph elements to selection + if let Some(graph) = vector_layer.graph_at_time(*shared.playback_time) { + for edge_id in graph_hits.edges { + shared.selection.select_edge(edge_id, graph); } - for face_id in dcel_hits.faces { - shared.selection.select_face(face_id, dcel); + for fill_id in graph_hits.fills { + shared.selection.select_fill(fill_id, graph); } } // Update focus based on what was selected - if shared.selection.has_dcel_selection() { + if shared.selection.has_geometry_selection() { if let Some(layer_id) = *shared.active_layer_id { *shared.focus = lightningbeam_core::selection::FocusSelection::Geometry { layer_id, time: *shared.playback_time }; } @@ -3861,7 +3857,7 @@ impl StagePane { /// Start editing a vertex - called when user clicks on a vertex fn start_vertex_editing( &mut self, - vertex_id: lightningbeam_core::dcel::VertexId, + vertex_id: lightningbeam_core::vector_graph::VertexId, _mouse_pos: vello::kurbo::Point, active_layer_id: uuid::Uuid, shared: &mut SharedPaneState, @@ -3875,27 +3871,20 @@ impl StagePane { Some(AnyLayer::Vector(vl)) => vl, _ => return, }; - let dcel = match layer.dcel_at_time(time) { + let graph = match layer.graph_at_time(time) { Some(d) => d, None => return, }; // Snapshot DCEL for undo - self.dcel_editing_cache = Some(DcelEditingCache { + self.graph_editing_cache = Some(GraphEditingCache { layer_id: active_layer_id, time, - dcel_before: dcel.clone(), + graph_before: graph.clone(), }); - // Find connected edges: iterate outgoing half-edges, collect unique edge IDs - let outgoing = dcel.vertex_outgoing(vertex_id); - let mut connected_edges = Vec::new(); - for he_id in &outgoing { - let edge_id = dcel.half_edge(*he_id).edge; - if !connected_edges.contains(&edge_id) { - connected_edges.push(edge_id); - } - } + // Find connected edges + let connected_edges = graph.edges_at_vertex(vertex_id); *shared.tool_state = ToolState::EditingVertex { vertex_id, @@ -3906,7 +3895,7 @@ impl StagePane { /// Start editing a curve - called when user clicks on a curve fn start_curve_editing( &mut self, - edge_id: lightningbeam_core::dcel::EdgeId, + edge_id: lightningbeam_core::vector_graph::EdgeId, parameter_t: f64, mouse_pos: vello::kurbo::Point, active_layer_id: uuid::Uuid, @@ -3921,18 +3910,18 @@ impl StagePane { Some(AnyLayer::Vector(vl)) => vl, _ => return, }; - let dcel = match layer.dcel_at_time(time) { + let graph = match layer.graph_at_time(time) { Some(d) => d, None => return, }; - let original_curve = dcel.edge(edge_id).curve; + let original_curve = graph.edge(edge_id).curve; // Snapshot DCEL for undo - self.dcel_editing_cache = Some(DcelEditingCache { + self.graph_editing_cache = Some(GraphEditingCache { layer_id: active_layer_id, time, - dcel_before: dcel.clone(), + graph_before: graph.clone(), }); *shared.tool_state = ToolState::EditingCurve { @@ -3955,7 +3944,7 @@ impl StagePane { use lightningbeam_core::tool::ToolState; use vello::kurbo::Vec2; - let cache = match &self.dcel_editing_cache { + let cache = match &self.graph_editing_cache { Some(c) => c, None => return, }; @@ -3972,11 +3961,11 @@ impl StagePane { let skip_snap = matches!(tool_state, ToolState::EditingCurve { .. }); let snap_result = if snap_enabled && !skip_snap { let document = shared.action_executor.document(); - let dcel = match document.get_layer(&layer_id) { - Some(AnyLayer::Vector(vl)) => vl.dcel_at_time(time), + let graph = match document.get_layer(&layer_id) { + Some(AnyLayer::Vector(vl)) => vl.graph_at_time(time), _ => None, }; - dcel.and_then(|dcel| { + graph.and_then(|graph| { let config = SnapConfig::from_screen_radius(SNAP_SCREEN_RADIUS, self.zoom as f64); let exclusion = match &tool_state { ToolState::EditingVertex { vertex_id, connected_edges } => SnapExclusion { @@ -3989,7 +3978,7 @@ impl StagePane { }, _ => SnapExclusion::default(), }; - snap::find_snap_target(dcel, mouse_pos, &config, &exclusion) + snap::find_snap_target(graph, mouse_pos, &config, &exclusion) }) } else { None @@ -4000,8 +3989,8 @@ impl StagePane { // Phase 2: Mutate DCEL with the (possibly snapped) position let document = shared.action_executor.document_mut(); - let dcel = match document.get_layer_mut(&layer_id) { - Some(AnyLayer::Vector(vl)) => match vl.dcel_at_time_mut(time) { + let graph = match document.get_layer_mut(&layer_id) { + Some(AnyLayer::Vector(vl)) => match vl.graph_at_time_mut(time) { Some(d) => d, None => return, }, @@ -4010,33 +3999,31 @@ impl StagePane { match tool_state { ToolState::EditingVertex { vertex_id, connected_edges } => { - let old_pos = dcel.vertex(vertex_id).position; + let old_pos = graph.vertex(vertex_id).position; let delta = Vec2::new(effective_pos.x - old_pos.x, effective_pos.y - old_pos.y); - dcel.vertex_mut(vertex_id).position = effective_pos; + graph.vertex_mut(vertex_id).position = effective_pos; // Update connected edges: shift the adjacent control point by the same delta for &edge_id in &connected_edges { - let edge = dcel.edge(edge_id); - let [he_fwd, _he_bwd] = edge.half_edges; - let fwd_origin = dcel.half_edge(he_fwd).origin; - let mut curve = dcel.edge(edge_id).curve; + let [v0, v1] = graph.edge(edge_id).vertices; + let mut curve = graph.edge(edge_id).curve; - if fwd_origin == vertex_id { + if v0 == vertex_id { curve.p0 = effective_pos; curve.p1 = curve.p1 + delta; } else { curve.p3 = effective_pos; curve.p2 = curve.p2 + delta; } - dcel.edge_mut(edge_id).curve = curve; + graph.edge_mut(edge_id).curve = curve; } } ToolState::EditingCurve { edge_id, original_curve, start_mouse, .. } => { let molded_curve = mold_curve(&original_curve, &effective_pos, &start_mouse); - dcel.edge_mut(edge_id).curve = molded_curve; + graph.edge_mut(edge_id).curve = molded_curve; } ToolState::EditingControlPoint { edge_id, point_index, .. } => { - let curve = &mut dcel.edge_mut(edge_id).curve; + let curve = &mut graph.edge_mut(edge_id).curve; match point_index { 1 => curve.p1 = effective_pos, 2 => curve.p2 = effective_pos, @@ -4053,11 +4040,11 @@ impl StagePane { active_layer_id: uuid::Uuid, shared: &mut SharedPaneState, ) { - use lightningbeam_core::actions::ModifyDcelAction; + use lightningbeam_core::actions::ModifyGraphAction; use lightningbeam_core::layer::AnyLayer; // Consume the cache - let cache = match self.dcel_editing_cache.take() { + let cache = match self.graph_editing_cache.take() { Some(c) => c, None => { *shared.tool_state = lightningbeam_core::tool::ToolState::Idle; @@ -4082,62 +4069,24 @@ impl StagePane { _ => None, }; - if let Some((edge_ids, vertex_ids)) = editing_info { + if let Some((_edge_ids, vertex_ids)) = editing_info { let document = shared.action_executor.document_mut(); if let Some(AnyLayer::Vector(vl)) = document.get_layer_mut(&active_layer_id) { - if let Some(dcel) = vl.dcel_at_time_mut(cache.time) { - // Rebuild fans at the directly edited vertices + if let Some(graph) = vl.graph_at_time_mut(cache.time) { + // VectorGraph doesn't need fan rebuilding or face cycle repair. + // Just update edge curves for moved vertices. for &vid in &vertex_ids { - dcel.rebuild_vertex_fan(vid); - } - // Also rebuild fans at endpoints of connected edges - // (their edge angles changed due to the edit) - for &eid in &edge_ids { - let [fwd, bwd] = dcel.edge(eid).half_edges; - let v1 = dcel.half_edge(fwd).origin; - let v2 = dcel.half_edge(bwd).origin; - if !vertex_ids.contains(&v1) { - dcel.rebuild_vertex_fan(v1); - } - if !vertex_ids.contains(&v2) { - dcel.rebuild_vertex_fan(v2); - } - } - // Repair face cycles at all affected vertices - // (rebuild_vertex_fan may have split cycles without updating faces) - let mut repaired: Vec = Vec::new(); - for &vid in &vertex_ids { - if !repaired.contains(&vid) { - dcel.repair_face_cycles_at_vertex(vid); - repaired.push(vid); - } - } - for &eid in &edge_ids { - let [fwd, bwd] = dcel.edge(eid).half_edges; - let v1 = dcel.half_edge(fwd).origin; - let v2 = dcel.half_edge(bwd).origin; - if !repaired.contains(&v1) { - dcel.repair_face_cycles_at_vertex(v1); - repaired.push(v1); - } - if !repaired.contains(&v2) { - dcel.repair_face_cycles_at_vertex(v2); - repaired.push(v2); - } - } - // Recompute intersections for all moved edges - for &eid in &edge_ids { - dcel.recompute_edge_intersections(eid); + graph.update_edges_for_vertex(vid); } } } } - // Get current DCEL state (after edits + intersection splits) as dcel_after - let dcel_after = { + // Get current DCEL state (after edits + intersection splits) as graph_after + let graph_after = { let document = shared.action_executor.document(); match document.get_layer(&active_layer_id) { - Some(AnyLayer::Vector(vl)) => match vl.dcel_at_time(cache.time) { + Some(AnyLayer::Vector(vl)) => match vl.graph_at_time(cache.time) { Some(d) => d.clone(), None => { *shared.tool_state = lightningbeam_core::tool::ToolState::Idle; @@ -4152,17 +4101,17 @@ impl StagePane { }; // Create the undo action - let action = ModifyDcelAction::new( + let action = ModifyGraphAction::new( cache.layer_id, cache.time, - cache.dcel_before, - dcel_after, + cache.graph_before, + graph_after, "Edit vector path", ); - // Execute via action system (this replaces the DCEL with dcel_after, + // Execute via action system (this replaces the DCEL with graph_after, // which is the same as current state, so it's a no-op — but it registers - // the action in the undo stack with dcel_before for rollback) + // the action in the undo stack with graph_before for rollback) let _ = shared.action_executor.execute(Box::new(action)); // Reset tool state and clear snap indicator @@ -4268,7 +4217,7 @@ impl StagePane { /// Start editing a control point - called when user clicks on a control point fn start_control_point_editing( &mut self, - edge_id: lightningbeam_core::dcel::EdgeId, + edge_id: lightningbeam_core::vector_graph::EdgeId, point_index: u8, _mouse_pos: vello::kurbo::Point, active_layer_id: uuid::Uuid, @@ -4283,12 +4232,12 @@ impl StagePane { Some(AnyLayer::Vector(vl)) => vl, _ => return, }; - let dcel = match layer.dcel_at_time(time) { + let graph = match layer.graph_at_time(time) { Some(d) => d, None => return, }; - let original_curve = dcel.edge(edge_id).curve; + let original_curve = graph.edge(edge_id).curve; let start_pos = match point_index { 1 => original_curve.p1, 2 => original_curve.p2, @@ -4296,10 +4245,10 @@ impl StagePane { }; // Snapshot DCEL for undo - self.dcel_editing_cache = Some(DcelEditingCache { + self.graph_editing_cache = Some(GraphEditingCache { layer_id: active_layer_id, time, - dcel_before: dcel.clone(), + graph_before: graph.clone(), }); *shared.tool_state = ToolState::EditingControlPoint { @@ -4332,14 +4281,14 @@ impl StagePane { }; let time = *shared.playback_time; - let dcel = match shared.action_executor.document().get_layer(&layer_id) { - Some(AnyLayer::Vector(vl)) => vl.dcel_at_time(time), + let graph = match shared.action_executor.document().get_layer(&layer_id) { + Some(AnyLayer::Vector(vl)) => vl.graph_at_time(time), _ => None, }; - let result = dcel.and_then(|dcel| { + let result = graph.and_then(|graph| { let config = SnapConfig::from_screen_radius(SNAP_SCREEN_RADIUS, self.zoom as f64); - snap::find_snap_target(dcel, point, &config, &SnapExclusion::default()) + snap::find_snap_target(graph, point, &config, &SnapExclusion::default()) }); self.current_snap = result; @@ -4967,17 +4916,9 @@ impl StagePane { let time = *shared.playback_time; - // Get mutable DCEL and snapshot it before insertion - let document = shared.action_executor.document_mut(); - let dcel = match document.get_layer_mut(&layer_id) { - Some(AnyLayer::Vector(vl)) => match vl.dcel_at_time_mut(time) { - Some(d) => d, - None => return, - }, - _ => return, - }; - - let snapshot = dcel.clone(); + use lightningbeam_core::vector_graph::{EdgeId, FillId, VertexId}; + use std::collections::{HashMap, HashSet}; + use vello::kurbo::{ParamCurve, Shape as _}; // Convert region path line segments to CubicBez for insert_stroke let segments: Vec<_> = { @@ -5017,102 +4958,132 @@ impl StagePane { return; } - // Capture DCEL snapshot + region path for crash diagnosis (debug builds only) - #[cfg(debug_assertions)] - { - use vello::kurbo::PathEl; - let path_elems: Vec = region_path.elements().iter().map(|el| match el { - PathEl::MoveTo(p) => serde_json::json!({"type": "M", "x": p.x, "y": p.y}), - PathEl::LineTo(p) => serde_json::json!({"type": "L", "x": p.x, "y": p.y}), - PathEl::QuadTo(p1, p2) => serde_json::json!({"type": "Q", "x1": p1.x, "y1": p1.y, "x2": p2.x, "y2": p2.y}), - PathEl::CurveTo(p1, p2, p3) => serde_json::json!({"type": "C", "x1": p1.x, "y1": p1.y, "x2": p2.x, "y2": p2.y, "x3": p3.x, "y3": p3.y}), - PathEl::ClosePath => serde_json::json!({"type": "Z"}), - }).collect(); - let geom = serde_json::json!({ - "region_path": path_elems, - "dcel_snapshot": serde_json::to_value(&snapshot).unwrap_or(serde_json::Value::Null), - }); - shared.test_mode.set_pending_geometry(geom); - } + // Do all graph work in a block so the mutable borrow of shared ends + // before we assign to shared.region_selection. + let extraction_result = { + let document = shared.action_executor.document_mut(); + let graph = match document.get_layer_mut(&layer_id) { + Some(AnyLayer::Vector(vl)) => match vl.graph_at_time_mut(time) { + Some(d) => d, + None => return, + }, + _ => return, + }; - // Insert region boundary as invisible edges (no stroke style/color) - let stroke_result = dcel.insert_stroke(&segments, None, None, 1.0); - let boundary_verts: Vec<_> = stroke_result.new_vertices.clone(); - let region_edge_ids: Vec<_> = stroke_result.new_edges.clone(); + let snapshot = graph.clone(); - // Extract the inside portion; self (dcel) keeps the outside + boundary. - let mut selected_dcel = dcel.extract_region(®ion_path, &boundary_verts); + // Insert region boundary as invisible edges (no stroke style/color) + let region_edge_ids = graph.insert_stroke(&segments, None, None, 1.0); - // Propagate fills ONLY on the extracted DCEL. The remainder (dcel) already - // has correct fills from the original data — its filled faces (e.g., the - // L-shaped remainder) keep their fill, and merged faces from edge removal - // correctly have no fill. Running propagate_fills on the remainder would - // incorrectly add fill to merged faces that span filled and unfilled areas. - selected_dcel.propagate_fills(&snapshot, ®ion_path, &boundary_verts); + let region_edge_set: HashSet = region_edge_ids.iter().copied().collect(); - // Check if the extracted DCEL has any visible content - let has_visible = selected_dcel.edges.iter().any(|e| !e.deleted && (e.stroke_style.is_some() || e.stroke_color.is_some())) - || selected_dcel.faces.iter().enumerate().any(|(i, f)| !f.deleted && i > 0 && (f.fill_color.is_some() || f.image_fill.is_some())); + // Classify edges: inside vs outside by midpoint winding + let mut inside_edges = HashSet::new(); + for (i, edge) in graph.edges.iter().enumerate() { + let eid = EdgeId(i as u32); + if edge.deleted || region_edge_set.contains(&eid) { + continue; + } + let mid = edge.curve.eval(0.5); + if region_path.winding(mid) != 0 { + inside_edges.insert(eid); + } + } - if !has_visible { - // Nothing visible inside — restore snapshot and bail - *dcel = snapshot; + // Classify fills: inside vs outside by boundary centroid winding + let mut inside_fills = HashSet::new(); + for (i, fill) in graph.fills.iter().enumerate() { + let fid = FillId(i as u32); + if fill.deleted { + continue; + } + let centroid = Self::compute_fill_centroid(graph, fid); + if region_path.winding(centroid) != 0 { + inside_fills.insert(fid); + } + } + + // If nothing is inside, restore snapshot and bail + if inside_edges.is_empty() && inside_fills.is_empty() { + *graph = snapshot; + None + } else { + // Extract subgraph (boundary edges get duplicated into both graphs) + let (selected_graph, vtx_map, edge_map) = graph.extract_subgraph( + &inside_edges, + &inside_fills, + ®ion_edge_set, + ); + + // Build boundary maps for merge-back + let boundary_vertex_map: HashMap = vtx_map + .iter() + .filter(|(&old_vid, _)| !graph.vertex(old_vid).deleted) + .map(|(&old, &new)| (new, old)) + .collect(); + + let boundary_edge_map: HashMap = edge_map + .iter() + .filter(|(old_eid, _)| region_edge_set.contains(old_eid)) + .map(|(&old, &new)| (new, old)) + .collect(); + + Some((snapshot, selected_graph, region_edge_ids, boundary_vertex_map, boundary_edge_map)) + } + }; + + let Some((snapshot, selected_graph, region_edge_ids, boundary_vertex_map, boundary_edge_map)) = extraction_result else { #[cfg(debug_assertions)] shared.test_mode.clear_pending_geometry(); return; - } + }; - // Compute inside_vertices: non-deleted verts in selected_dcel that aren't boundary. - let inside_vertices: Vec<_> = selected_dcel - .vertices - .iter() - .enumerate() - .filter_map(|(i, v)| { - if v.deleted { return None; } - let vid = lightningbeam_core::dcel::VertexId(i as u32); - if !boundary_verts.contains(&vid) { Some(vid) } else { None } - }) - .collect(); - - let action_epoch = shared.action_executor.epoch(); - - shared.selection.clear(); - - // Populate global selection with the faces from the extracted DCEL so - // property panels and other tools can see what is selected. We add face - // IDs only (no boundary edges/vertices) because the boundary geometry - // lives in selected_dcel, not in the live DCEL. - for (i, face) in selected_dcel.faces.iter().enumerate() { - if face.deleted || i == 0 { continue; } - if face.fill_color.is_some() || face.image_fill.is_some() { - shared.selection.select_face_id_only(lightningbeam_core::dcel::FaceId(i as u32)); - } - } - - // Store the extracted DCEL as the clipboard-ready vector subgraph. - // This allows clipboard_copy_selection to serialize it without needing - // to re-extract geometry from the live DCEL. - shared.selection.vector_subgraph = Some(selected_dcel.clone()); - - // Store region selection state with extracted DCEL *shared.region_selection = Some(lightningbeam_core::selection::RegionSelection { - region_path, + region_path: region_path.clone(), layer_id, time, - dcel_snapshot: snapshot, - selected_dcel, + graph_snapshot: snapshot, + selected_graph, transform: vello::kurbo::Affine::IDENTITY, committed: false, - inside_vertices, - boundary_vertices: boundary_verts, region_edge_ids, - action_epoch_at_selection: action_epoch, + action_epoch_at_selection: shared.action_executor.epoch(), + boundary_vertex_map, + boundary_edge_map, }); + shared.selection.clear_geometry_selection(); + #[cfg(debug_assertions)] shared.test_mode.clear_pending_geometry(); } + /// Compute the centroid of a fill's boundary edge midpoints. + fn compute_fill_centroid( + graph: &lightningbeam_core::vector_graph::VectorGraph, + fid: lightningbeam_core::vector_graph::FillId, + ) -> vello::kurbo::Point { + use vello::kurbo::{ParamCurve, Point}; + let fill = graph.fill(fid); + let mut sum_x = 0.0; + let mut sum_y = 0.0; + let mut count = 0; + for &(eid, _) in &fill.boundary { + if eid.is_none() { + continue; + } + let mid = graph.edge(eid).curve.eval(0.5); + sum_x += mid.x; + sum_y += mid.y; + count += 1; + } + if count > 0 { + Point::new(sum_x / count as f64, sum_y / count as f64) + } else { + Point::ZERO + } + } + /// Revert an uncommitted region selection, restoring the DCEL from snapshot fn revert_region_selection_static(shared: &mut SharedPaneState) { use lightningbeam_core::layer::AnyLayer; @@ -5129,28 +5100,37 @@ impl StagePane { let no_actions_taken = shared.action_executor.epoch() == region_sel.action_epoch_at_selection; + let no_transform = region_sel.transform == vello::kurbo::Affine::IDENTITY; let doc = shared.action_executor.document_mut(); if let Some(AnyLayer::Vector(vl)) = doc.get_layer_mut(®ion_sel.layer_id) { - if let Some(dcel) = vl.dcel_at_time_mut(region_sel.time) { - if no_actions_taken { - // Nothing changed: restore snapshot cleanly (undo boundary insertion) - *dcel = region_sel.dcel_snapshot; + if let Some(graph) = vl.graph_at_time_mut(region_sel.time) { + if no_actions_taken && no_transform { + // Fast path: nothing changed, restore from snapshot + *graph = region_sel.graph_snapshot; } else { - // Actions were applied to the selection: merge selected_dcel back - let mut merged = region_sel.dcel_snapshot; - merged.merge_back_from_selected( - ®ion_sel.selected_dcel, - ®ion_sel.inside_vertices, - ®ion_sel.boundary_vertices, - ®ion_sel.region_edge_ids, + // Delete the main graph's copy of boundary edges + for &eid in ®ion_sel.region_edge_ids { + if !graph.edge(eid).deleted { + graph.free_edge(eid); + } + } + + // Merge the (possibly transformed) selected_graph back + graph.merge_subgraph( + ®ion_sel.selected_graph, + region_sel.transform, + ®ion_sel.boundary_vertex_map, + ®ion_sel.boundary_edge_map, ); - *dcel = merged; + + // Clean up invisible edges left from the boundary + graph.gc_invisible_edges(); } } } - shared.selection.clear_dcel_selection(); + shared.selection.clear_geometry_selection(); } /// Create a rectangle path centered at origin (easier for curve editing later) @@ -7507,8 +7487,8 @@ impl StagePane { } /// Handle transform tool for DCEL elements (vertices/edges). - /// Uses snapshot-based undo via ModifyDcelAction. - fn handle_transform_dcel( + /// Uses snapshot-based undo via ModifyGraphAction. + fn handle_transform_graph( &mut self, ui: &mut egui::Ui, response: &egui::Response, @@ -7522,7 +7502,7 @@ impl StagePane { let time = *shared.playback_time; // Calculate bounding box of selected DCEL vertices - let selected_verts: Vec = + let selected_verts: Vec = shared.selection.selected_vertices().iter().copied().collect(); if selected_verts.is_empty() { @@ -7532,13 +7512,13 @@ impl StagePane { let bbox = { let document = shared.action_executor.document(); if let Some(AnyLayer::Vector(vl)) = document.get_layer(active_layer_id) { - if let Some(dcel) = vl.dcel_at_time(time) { + if let Some(graph) = vl.graph_at_time(time) { let mut min_x = f64::MAX; let mut min_y = f64::MAX; let mut max_x = f64::MIN; let mut max_y = f64::MIN; for &vid in &selected_verts { - let v = dcel.vertex(vid); + let v = graph.vertex(vid); if v.deleted { continue; } min_x = min_x.min(v.position.x); min_y = min_y.min(v.position.y); @@ -7569,11 +7549,11 @@ impl StagePane { original_bbox, }; - if let Some(ref cache) = self.dcel_editing_cache { - let original_dcel = cache.dcel_before.clone(); - let selected_verts_set: std::collections::HashSet = + if let Some(ref cache) = self.graph_editing_cache { + let original_graph = cache.graph_before.clone(); + let selected_verts_set: std::collections::HashSet = selected_verts.iter().copied().collect(); - let selected_edges: std::collections::HashSet = + let selected_edges: std::collections::HashSet = shared.selection.selected_edges().iter().copied().collect(); let affine = Self::compute_transform_affine( @@ -7582,9 +7562,9 @@ impl StagePane { let document = shared.action_executor.document_mut(); if let Some(AnyLayer::Vector(vl)) = document.get_layer_mut(active_layer_id) { - if let Some(dcel) = vl.dcel_at_time_mut(time) { - Self::apply_dcel_transform( - dcel, &original_dcel, &selected_verts_set, &selected_edges, affine, + if let Some(graph) = vl.graph_at_time_mut(time) { + Self::apply_graph_transform( + graph, &original_graph, &selected_verts_set, &selected_edges, affine, ); } } @@ -7593,18 +7573,18 @@ impl StagePane { // Release: finalize if self.rsp_drag_stopped(response) || (self.rsp_any_released(ui) && matches!(*shared.tool_state, ToolState::Transforming { .. })) { - if let Some(cache) = self.dcel_editing_cache.take() { - let dcel_after = { + if let Some(cache) = self.graph_editing_cache.take() { + let graph_after = { let document = shared.action_executor.document(); match document.get_layer(active_layer_id) { - Some(AnyLayer::Vector(vl)) => vl.dcel_at_time(time).cloned(), + Some(AnyLayer::Vector(vl)) => vl.graph_at_time(time).cloned(), _ => None, } }; - if let Some(dcel_after) = dcel_after { - use lightningbeam_core::actions::ModifyDcelAction; - let action = ModifyDcelAction::new( - cache.layer_id, cache.time, cache.dcel_before, dcel_after, "Transform", + if let Some(graph_after) = graph_after { + use lightningbeam_core::actions::ModifyGraphAction; + let action = ModifyGraphAction::new( + cache.layer_id, cache.time, cache.graph_before, graph_after, "Transform", ); shared.pending_actions.push(Box::new(action)); } @@ -7624,11 +7604,11 @@ impl StagePane { // Snapshot DCEL for undo let document = shared.action_executor.document(); if let Some(AnyLayer::Vector(vl)) = document.get_layer(active_layer_id) { - if let Some(dcel) = vl.dcel_at_time(time) { - self.dcel_editing_cache = Some(DcelEditingCache { + if let Some(graph) = vl.graph_at_time(time) { + self.graph_editing_cache = Some(GraphEditingCache { layer_id: *active_layer_id, time, - dcel_before: dcel.clone(), + graph_before: graph.clone(), }); } } @@ -8290,23 +8270,23 @@ impl StagePane { /// Apply an affine transform to selected DCEL vertices and their connected edge control points. /// Reads original positions from `original_dcel` and writes transformed positions to `dcel`. - fn apply_dcel_transform( - dcel: &mut lightningbeam_core::dcel::Dcel, - original_dcel: &lightningbeam_core::dcel::Dcel, - selected_verts: &std::collections::HashSet, - selected_edges: &std::collections::HashSet, + fn apply_graph_transform( + graph: &mut lightningbeam_core::vector_graph::VectorGraph, + original_graph: &lightningbeam_core::vector_graph::VectorGraph, + selected_verts: &std::collections::HashSet, + selected_edges: &std::collections::HashSet, affine: vello::kurbo::Affine, ) { // Transform selected vertex positions for &vid in selected_verts { - let original_pos = original_dcel.vertex(vid).position; - dcel.vertex_mut(vid).position = affine * original_pos; + let original_pos = original_graph.vertex(vid).position; + graph.vertex_mut(vid).position = affine * original_pos; } // Transform edge curves for selected edges for &eid in selected_edges { - let original_curve = original_dcel.edge(eid).curve; - let edge = dcel.edge_mut(eid); + let original_curve = original_graph.edge(eid).curve; + let edge = graph.edge_mut(eid); edge.curve.p0 = affine * original_curve.p0; edge.curve.p1 = affine * original_curve.p1; edge.curve.p2 = affine * original_curve.p2; @@ -9216,7 +9196,7 @@ impl StagePane { rect: egui::Rect, ) { use lightningbeam_core::layer::AnyLayer; - use lightningbeam_core::dcel2::FaceId; + use lightningbeam_core::vector_graph::FillId; let Some(layer_id) = *shared.active_layer_id else { return }; @@ -9235,23 +9215,25 @@ impl StagePane { let Some(kf) = vl.keyframe_at(*shared.playback_time) else { return }; let point = vello::kurbo::Point::new(click_world.x as f64, click_world.y as f64); - let face_id = kf.dcel.find_face_containing_point(point); + let fill_id = match kf.graph.find_fill_at_point(point) { + Some(fid) => fid, + None => return, // No fill at this point + }; - // Face 0 is the unbounded background face — nothing to fill. - if face_id == FaceId(0) || kf.dcel.face(face_id).deleted { return; } + if kf.graph.fill(fill_id).deleted { return; } - // If the clicked face is already selected, apply to all selected faces; - // otherwise apply only to the clicked face. - let face_ids: Vec = if shared.selection.selected_faces().contains(&face_id) { - shared.selection.selected_faces().iter().cloned().collect() + // If the clicked fill is already selected, apply to all selected fills; + // otherwise apply only to the clicked fill. + let fill_ids: Vec = if shared.selection.selected_fills().contains(&fill_id) { + shared.selection.selected_fills().iter().cloned().collect() } else { - vec![face_id] + vec![fill_id] }; self.vector_gradient_state = Some(VectorGradientState { layer_id, time: *shared.playback_time, - face_ids, + fill_ids, start: click_world, end: click_world, }); @@ -9287,7 +9269,7 @@ impl StagePane { use lightningbeam_core::actions::SetFillPaintAction; let action = SetFillPaintAction::gradient( - gs.layer_id, gs.time, gs.face_ids, Some(gradient), + gs.layer_id, gs.time, gs.fill_ids, Some(gradient), ); if let Err(e) = shared.action_executor.execute(Box::new(action)) { eprintln!("Vector gradient fill: {e}"); @@ -9352,8 +9334,8 @@ impl StagePane { } // For vector layers with DCEL selection, use DCEL-specific transform path - if shared.selection.has_dcel_selection() { - self.handle_transform_dcel(ui, response, point, &active_layer_id, shared); + if shared.selection.has_geometry_selection() { + self.handle_transform_graph(ui, response, point, &active_layer_id, shared); return; } @@ -10450,7 +10432,7 @@ impl StagePane { ); // Hit test DCEL elements in rectangle - let dcel_hits = hit_test::hit_test_dcel_in_rect( + let graph_hits = hit_test::hit_test_graph_in_rect( vector_layer, *shared.playback_time, selection_rect, @@ -10462,20 +10444,20 @@ impl StagePane { shared.selection.add_clip_instance(clip_id); } - // Add DCEL elements to selection - if let Some(dcel) = vector_layer.dcel_at_time(*shared.playback_time) { - for edge_id in dcel_hits.edges { - shared.selection.select_edge(edge_id, dcel); + // Add graph elements to selection + if let Some(graph) = vector_layer.graph_at_time(*shared.playback_time) { + for edge_id in graph_hits.edges { + shared.selection.select_edge(edge_id, graph); } - for face_id in dcel_hits.faces { - shared.selection.select_face(face_id, dcel); + for fill_id in graph_hits.fills { + shared.selection.select_fill(fill_id, graph); } } } } // Update focus based on what was selected - if shared.selection.has_dcel_selection() { + if shared.selection.has_geometry_selection() { if let Some(layer_id) = *shared.active_layer_id { *shared.focus = lightningbeam_core::selection::FocusSelection::Geometry { layer_id, time: *shared.playback_time }; } @@ -10684,63 +10666,63 @@ impl StagePane { // Delete/Backspace: remove selected DCEL elements if ui.input(|i| shared.keymap.action_pressed_with_backspace(crate::keymap::AppAction::StageDelete, i)) { - if shared.selection.has_dcel_selection() { + if shared.selection.has_geometry_selection() { if let Some(active_layer_id) = *shared.active_layer_id { let time = *shared.playback_time; // Collect selected edge IDs before mutating - let selected_edges: Vec = + let selected_edges: Vec = shared.selection.selected_edges().iter().copied().collect(); if !selected_edges.is_empty() { // Snapshot before - let dcel_before = { + let graph_before = { let document = shared.action_executor.document(); match document.get_layer(&active_layer_id) { Some(lightningbeam_core::layer::AnyLayer::Vector(vl)) => { - vl.dcel_at_time(time).cloned() + vl.graph_at_time(time).cloned() } _ => None, } }; - if let Some(dcel_before) = dcel_before { + if let Some(graph_before) = graph_before { // Remove selected edges { let document = shared.action_executor.document_mut(); if let Some(lightningbeam_core::layer::AnyLayer::Vector(vl)) = document.get_layer_mut(&active_layer_id) { - if let Some(dcel) = vl.dcel_at_time_mut(time) { + if let Some(graph) = vl.graph_at_time_mut(time) { for eid in &selected_edges { - dcel.remove_edge(*eid); + graph.remove_edge(*eid); } } } } // Snapshot after - let dcel_after = { + let graph_after = { let document = shared.action_executor.document(); match document.get_layer(&active_layer_id) { Some(lightningbeam_core::layer::AnyLayer::Vector(vl)) => { - vl.dcel_at_time(time).cloned() + vl.graph_at_time(time).cloned() } _ => None, } }; - if let Some(dcel_after) = dcel_after { - use lightningbeam_core::actions::ModifyDcelAction; - let action = ModifyDcelAction::new( + if let Some(graph_after) = graph_after { + use lightningbeam_core::actions::ModifyGraphAction; + let action = ModifyGraphAction::new( active_layer_id, time, - dcel_before, - dcel_after, + graph_before, + graph_after, "Delete", ); shared.pending_actions.push(Box::new(action)); } - shared.selection.clear_dcel_selection(); + shared.selection.clear_geometry_selection(); } } } @@ -10869,7 +10851,7 @@ impl StagePane { ); // Get the DCEL for drawing overlays - let dcel = match layer.dcel_at_time(*shared.playback_time) { + let graph = match layer.graph_at_time(*shared.playback_time) { Some(d) => d, None => return, }; @@ -10911,9 +10893,9 @@ impl StagePane { // BezierEdit mode: Draw all vertices, control points, and tangent lines // Draw control point tangent lines and control points for all edges - for (i, edge) in dcel.edges.iter().enumerate() { + for (i, edge) in graph.edges.iter().enumerate() { if edge.deleted { continue; } - let edge_id = lightningbeam_core::dcel::EdgeId(i as u32); + let edge_id = lightningbeam_core::vector_graph::EdgeId(i as u32); let curve = &edge.curve; // Tangent lines from endpoints to control points @@ -10943,9 +10925,9 @@ impl StagePane { } // Draw vertices on top of everything - for (i, vertex) in dcel.vertices.iter().enumerate() { + for (i, vertex) in graph.vertices.iter().enumerate() { if vertex.deleted { continue; } - let vid = lightningbeam_core::dcel::VertexId(i as u32); + let vid = lightningbeam_core::vector_graph::VertexId(i as u32); let screen_pos = world_to_screen(vertex.position); let is_hovered = hover_vertex == Some(vid); if is_hovered { @@ -10957,7 +10939,7 @@ impl StagePane { } else { // Select mode: Only show hover highlight for the element under the mouse if let Some(vid) = hover_vertex { - let pos = dcel.vertex(vid).position; + let pos = graph.vertex(vid).position; let screen_pos = world_to_screen(pos); painter.circle(screen_pos, vertex_hover_radius, vertex_color, vertex_hover_stroke); } @@ -10965,7 +10947,7 @@ impl StagePane { // Note: curve hover highlight is now rendered via Vello stipple in the scene if let Some((eid, pidx)) = hover_cp { - let curve = &dcel.edge(eid).curve; + let curve = &graph.edge(eid).curve; let cp_pos = if pidx == 1 { curve.p1 } else { curve.p2 }; let screen_pos = world_to_screen(cp_pos); painter.circle_filled(screen_pos, cp_hover_radius, cp_hover_color); @@ -11108,8 +11090,8 @@ impl StagePane { use lightningbeam_core::layer::AnyLayer; if let Some(layer_id) = *shared.active_layer_id { if let Some(AnyLayer::Vector(vl)) = shared.action_executor.document().get_layer(&layer_id) { - if let Some(dcel) = vl.dcel_at_time(*shared.playback_time) { - let edge = dcel.edge(edge_id); + if let Some(graph) = vl.graph_at_time(*shared.playback_time) { + let edge = graph.edge(edge_id); if !edge.deleted { // Draw a small circle at the snap point on the curve painter.circle(screen_pos, 4.0, egui::Color32::TRANSPARENT, vertex_hover_stroke);