Unify selection systems and make region/lasso cut robust
Collapse the two parallel selection systems into one. The RegionSelect tool (rect + lasso) now cuts the geometry along the region outline and selects the resulting sub-pieces into the standard `Selection` ID-sets, exactly like every other tool. The vestigial floating `RegionSelection` (drag never wired; commit/delete/copy were stubbed) and all its plumbing are removed, so Group, Convert-to-Movie-Clip, Delete, and Properties all operate uniformly from lasso, rect, marquee, and click selections. Region cutting is reworked onto a robust planar arrangement: - Replace fragile incremental "split a fill by one cut edge" logic with planar face re-tracing (`retrace_fills_after_cut` + `trace_faces`), which correctly handles arbitrary holed/concave fills. - `extract_subgraph` no longer frees vertices still referenced by kept boundary edges (fixed Group leaving freed-but-referenced vertices that a later alloc reused and corrupted). - `split_fill_by_*` direction fix (was producing disconnected boundaries rendered as stray diagonals). - `fill_interior_point` (area-centroid + inward-step fallback) for reliable inside/outside classification of non-convex pieces. - Coincident-edge dedupe + degenerate-fill removal (edge-adjacent shapes no longer make zero-area sliver fills). - Dangling-edge pruning, near-coincident endpoint welding, induced- subgraph expansion, and tracking of `split_edge` sub-edges, so self-intersecting freehand lassos cut correctly. Region-select capture is available behind LIGHTNINGBEAM_DUMP_REGION=1 for turning a misbehaving cut into a deterministic test. Extensive regression tests added in vector_graph/tests/region_cut_select.rs.
This commit is contained in:
parent
0d253d9629
commit
39978e59b3
|
|
@ -4,15 +4,61 @@
|
||||||
//!
|
//!
|
||||||
//! A *group* (`is_group = true`) is a static container; a *movie clip* (`is_group =
|
//! A *group* (`is_group = true`) is a static container; a *movie clip* (`is_group =
|
||||||
//! false`) has its own timeline. Both are tweenable via the clip instance's transform.
|
//! false`) has its own timeline. Both are tweenable via the clip instance's transform.
|
||||||
|
//!
|
||||||
|
//! Both the Select tool and the (cut-and-select) RegionSelect tool populate the same
|
||||||
|
//! `Selection` ID sets, so a single entry point — [`extract_geometry_to_clip`] — handles
|
||||||
|
//! Group and Convert-to-Movie-Clip from whatever is selected.
|
||||||
|
|
||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
|
|
||||||
use crate::clip::{ClipInstance, VectorClip};
|
use crate::clip::{ClipInstance, VectorClip};
|
||||||
use crate::document::Document;
|
use crate::document::Document;
|
||||||
use crate::layer::{AnyLayer, ShapeKeyframe, VectorLayer};
|
use crate::layer::{AnyLayer, ShapeKeyframe, VectorLayer};
|
||||||
|
use crate::object::Transform;
|
||||||
use crate::vector_graph::{EdgeId, FillId, VectorGraph};
|
use crate::vector_graph::{EdgeId, FillId, VectorGraph};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
/// Build a clip holding `sub_graph` and place a `ClipInstance` (with `transform`) on the
|
||||||
|
/// layer. Shared by both extraction paths. Does **not** touch the source graph — the
|
||||||
|
/// caller is responsible for having removed the moved geometry first.
|
||||||
|
fn assemble_clip_from_graph(
|
||||||
|
document: &mut Document,
|
||||||
|
layer_id: Uuid,
|
||||||
|
time: f64,
|
||||||
|
sub_graph: VectorGraph,
|
||||||
|
transform: Transform,
|
||||||
|
clip_id: Uuid,
|
||||||
|
instance_id: Uuid,
|
||||||
|
is_group: bool,
|
||||||
|
clip_name: &str,
|
||||||
|
) {
|
||||||
|
let (doc_w, doc_h, doc_dur) = (document.width, document.height, document.duration.max(1.0));
|
||||||
|
|
||||||
|
// A vector layer whose single keyframe holds the extracted graph (in the source's
|
||||||
|
// coordinate space, so an identity/translation placement renders it in place).
|
||||||
|
let mut inner = VectorLayer::new("Layer 1");
|
||||||
|
let mut kf = ShapeKeyframe::new(0.0);
|
||||||
|
kf.graph = sub_graph;
|
||||||
|
inner.keyframes.push(kf);
|
||||||
|
let mut clip = VectorClip::with_id(clip_id, clip_name, doc_w, doc_h, doc_dur);
|
||||||
|
clip.is_group = is_group;
|
||||||
|
clip.layers.add_root(AnyLayer::Vector(inner));
|
||||||
|
document.add_vector_clip(clip);
|
||||||
|
|
||||||
|
let mut instance = ClipInstance::with_id(instance_id, clip_id);
|
||||||
|
instance.transform = transform;
|
||||||
|
if let Some(AnyLayer::Vector(vl)) = document.get_layer_mut(&layer_id) {
|
||||||
|
// Groups gate visibility by the active keyframe's clip_instance_ids; movie
|
||||||
|
// clips render unconditionally.
|
||||||
|
if is_group {
|
||||||
|
if let Some(kf) = vl.keyframe_at_mut(time) {
|
||||||
|
kf.clip_instance_ids.push(instance_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
vl.clip_instances.push(instance);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Extract the selected geometry into a new clip + place a `ClipInstance`. Returns the
|
/// Extract the selected geometry into a new clip + place a `ClipInstance`. Returns the
|
||||||
/// pre-extraction graph snapshot for undo. `clip_id`/`instance_id` are caller-provided
|
/// pre-extraction graph snapshot for undo. `clip_id`/`instance_id` are caller-provided
|
||||||
/// so undo/redo is stable. The selection sets come straight from the editor selection
|
/// so undo/redo is stable. The selection sets come straight from the editor selection
|
||||||
|
|
@ -32,7 +78,6 @@ pub fn extract_geometry_to_clip(
|
||||||
if fills.is_empty() && edges.is_empty() {
|
if fills.is_empty() && edges.is_empty() {
|
||||||
return Err("No geometry selected".to_string());
|
return Err("No geometry selected".to_string());
|
||||||
}
|
}
|
||||||
let (doc_w, doc_h, doc_dur) = (document.width, document.height, document.duration.max(1.0));
|
|
||||||
|
|
||||||
// 1. Extract from the source graph (extract_subgraph removes the moved geometry).
|
// 1. Extract from the source graph (extract_subgraph removes the moved geometry).
|
||||||
let (graph_before, sub_graph) = {
|
let (graph_before, sub_graph) = {
|
||||||
|
|
@ -48,29 +93,11 @@ pub fn extract_geometry_to_clip(
|
||||||
(before, sub)
|
(before, sub)
|
||||||
};
|
};
|
||||||
|
|
||||||
// 2. Build the clip: a vector layer whose single keyframe holds the extracted graph
|
// 2 & 3. Build the clip + place an identity-transform instance (geometry stays put).
|
||||||
// (in the source's coordinate space, so identity placement renders it in place).
|
assemble_clip_from_graph(
|
||||||
let mut inner = VectorLayer::new("Layer 1");
|
document, layer_id, time, sub_graph, Transform::default(),
|
||||||
let mut kf = ShapeKeyframe::new(0.0);
|
clip_id, instance_id, is_group, clip_name,
|
||||||
kf.graph = sub_graph;
|
);
|
||||||
inner.keyframes.push(kf);
|
|
||||||
let mut clip = VectorClip::with_id(clip_id, clip_name, doc_w, doc_h, doc_dur);
|
|
||||||
clip.is_group = is_group;
|
|
||||||
clip.layers.add_root(AnyLayer::Vector(inner));
|
|
||||||
document.add_vector_clip(clip);
|
|
||||||
|
|
||||||
// 3. Place a ClipInstance (identity transform → geometry stays put).
|
|
||||||
let instance = ClipInstance::with_id(instance_id, clip_id);
|
|
||||||
if let Some(AnyLayer::Vector(vl)) = document.get_layer_mut(&layer_id) {
|
|
||||||
// Groups gate visibility by the active keyframe's clip_instance_ids; movie
|
|
||||||
// clips render unconditionally.
|
|
||||||
if is_group {
|
|
||||||
if let Some(kf) = vl.keyframe_at_mut(time) {
|
|
||||||
kf.clip_instance_ids.push(instance_id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
vl.clip_instances.push(instance);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(graph_before)
|
Ok(graph_before)
|
||||||
}
|
}
|
||||||
|
|
@ -96,3 +123,4 @@ pub fn undo_extract_geometry(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,9 +4,8 @@
|
||||||
|
|
||||||
use crate::vector_graph::{VectorGraph, EdgeId, FillId, VertexId};
|
use crate::vector_graph::{VectorGraph, EdgeId, FillId, VertexId};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::collections::{HashMap, HashSet};
|
use std::collections::HashSet;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
use vello::kurbo::{Affine, BezPath};
|
|
||||||
|
|
||||||
/// Shape of a raster pixel selection, in canvas pixel coordinates.
|
/// Shape of a raster pixel selection, in canvas pixel coordinates.
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
|
|
@ -440,42 +439,6 @@ impl Selection {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Represents a temporary region-based selection.
|
|
||||||
///
|
|
||||||
/// When a region select is active, the region boundary is inserted into the
|
|
||||||
/// DCEL as invisible edges, splitting existing geometry. Faces inside the
|
|
||||||
/// region are added to the normal `Selection`. If the user performs an
|
|
||||||
/// operation, the selection is committed; if they deselect, the DCEL is
|
|
||||||
/// restored from the snapshot.
|
|
||||||
#[derive(Clone, Debug)]
|
|
||||||
pub struct RegionSelection {
|
|
||||||
/// The clipping region as a closed BezPath (polygon or rect)
|
|
||||||
pub region_path: BezPath,
|
|
||||||
/// Layer containing the affected elements
|
|
||||||
pub layer_id: Uuid,
|
|
||||||
/// Keyframe time
|
|
||||||
pub time: f64,
|
|
||||||
/// 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,
|
|
||||||
/// IDs of the invisible edges inserted for the region boundary stroke.
|
|
||||||
/// These exist in the main graph (remainder side). Deleted during merge-back.
|
|
||||||
pub region_edge_ids: Vec<EdgeId>,
|
|
||||||
/// 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<VertexId, VertexId>,
|
|
||||||
/// selected_graph boundary EID → main graph boundary EID (duplicated edges to skip on merge).
|
|
||||||
pub boundary_edge_map: HashMap<EdgeId, EdgeId>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
|
||||||
|
|
@ -417,6 +417,91 @@ impl VectorGraph {
|
||||||
self.boundary_to_bezpath(&fill.boundary)
|
self.boundary_to_bezpath(&fill.boundary)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A point guaranteed to lie inside the fill — for point-in-region classification
|
||||||
|
/// (e.g. deciding whether a fill is inside a lasso). Prefers the polygon area-centroid,
|
||||||
|
/// but for a non-convex fill (e.g. an L-shape, where the area-centroid can fall in the
|
||||||
|
/// concavity *outside* the shape) it steps just inward from a boundary edge instead.
|
||||||
|
/// The naive average of boundary-edge midpoints is NOT reliable here — it can land
|
||||||
|
/// outside a non-convex fill and misclassify it.
|
||||||
|
pub fn fill_interior_point(&self, fill_id: FillId) -> Point {
|
||||||
|
let boundary = self.fills[fill_id.idx()].boundary.clone();
|
||||||
|
self.boundary_interior_point(&boundary)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A point guaranteed to lie inside the region enclosed by a `(edge, direction)`
|
||||||
|
/// boundary loop. See [`fill_interior_point`].
|
||||||
|
pub fn boundary_interior_point(&self, boundary: &[(EdgeId, Direction)]) -> Point {
|
||||||
|
use kurbo::{ParamCurve, Shape, Vec2};
|
||||||
|
let path = self.boundary_to_bezpath(boundary);
|
||||||
|
|
||||||
|
// Ordered polygon corners: the directed start point of each boundary edge.
|
||||||
|
let mut pts: Vec<Point> = Vec::new();
|
||||||
|
for &(eid, dir) in boundary {
|
||||||
|
if eid.is_none() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let c = self.edges[eid.idx()].curve;
|
||||||
|
pts.push(match dir {
|
||||||
|
Direction::Forward => c.p0,
|
||||||
|
Direction::Backward => c.p3,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if pts.len() < 3 {
|
||||||
|
if pts.is_empty() {
|
||||||
|
return Point::ZERO;
|
||||||
|
}
|
||||||
|
let (sx, sy) = pts.iter().fold((0.0, 0.0), |(x, y), p| (x + p.x, y + p.y));
|
||||||
|
return Point::new(sx / pts.len() as f64, sy / pts.len() as f64);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shoelace area-centroid.
|
||||||
|
let (mut a2, mut cx, mut cy) = (0.0, 0.0, 0.0);
|
||||||
|
for i in 0..pts.len() {
|
||||||
|
let p0 = pts[i];
|
||||||
|
let p1 = pts[(i + 1) % pts.len()];
|
||||||
|
let cross = p0.x * p1.y - p1.x * p0.y;
|
||||||
|
a2 += cross;
|
||||||
|
cx += (p0.x + p1.x) * cross;
|
||||||
|
cy += (p0.y + p1.y) * cross;
|
||||||
|
}
|
||||||
|
if a2.abs() > 1e-9 {
|
||||||
|
let c = Point::new(cx / (3.0 * a2), cy / (3.0 * a2));
|
||||||
|
if path.winding(c) != 0 {
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: step a small distance inward from a boundary edge midpoint.
|
||||||
|
let (mut minx, mut miny, mut maxx, mut maxy) = (f64::MAX, f64::MAX, f64::MIN, f64::MIN);
|
||||||
|
for p in &pts {
|
||||||
|
minx = minx.min(p.x);
|
||||||
|
miny = miny.min(p.y);
|
||||||
|
maxx = maxx.max(p.x);
|
||||||
|
maxy = maxy.max(p.y);
|
||||||
|
}
|
||||||
|
let eps = ((maxx - minx).min(maxy - miny) * 1e-3).max(1e-4);
|
||||||
|
for &(eid, _) in boundary {
|
||||||
|
if eid.is_none() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let c = self.edges[eid.idx()].curve;
|
||||||
|
let mid = c.eval(0.5);
|
||||||
|
let tangent = c.eval(0.5001) - c.eval(0.4999);
|
||||||
|
let len = tangent.hypot();
|
||||||
|
if len < 1e-12 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let n = Vec2::new(-tangent.y / len, tangent.x / len);
|
||||||
|
for s in [1.0_f64, -1.0] {
|
||||||
|
let cand = mid + n * (s * eps);
|
||||||
|
if path.winding(cand) != 0 {
|
||||||
|
return cand;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pts[0]
|
||||||
|
}
|
||||||
|
|
||||||
// -------------------------------------------------------------------
|
// -------------------------------------------------------------------
|
||||||
// Vertex editing
|
// Vertex editing
|
||||||
// -------------------------------------------------------------------
|
// -------------------------------------------------------------------
|
||||||
|
|
@ -653,6 +738,10 @@ impl VectorGraph {
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut all_new_edges = Vec::new();
|
let mut all_new_edges = Vec::new();
|
||||||
|
// Sub-edges produced when a stroke segment splits an existing edge (incl. an earlier
|
||||||
|
// segment of this same stroke). Tracked separately so they reach the fill re-tracer
|
||||||
|
// without polluting the returned edge list.
|
||||||
|
let mut split_products: Vec<EdgeId> = Vec::new();
|
||||||
let mut prev_end_vertex: Option<VertexId> = None;
|
let mut prev_end_vertex: Option<VertexId> = None;
|
||||||
|
|
||||||
for (seg_idx, seg) in expanded_segments.iter().enumerate() {
|
for (seg_idx, seg) in expanded_segments.iter().enumerate() {
|
||||||
|
|
@ -706,7 +795,14 @@ impl VectorGraph {
|
||||||
let remapped_t = original_edge_t / head_end;
|
let remapped_t = original_edge_t / head_end;
|
||||||
let remapped_t = remapped_t.clamp(ENDPOINT_T_MARGIN, 1.0 - ENDPOINT_T_MARGIN);
|
let remapped_t = remapped_t.clamp(ENDPOINT_T_MARGIN, 1.0 - ENDPOINT_T_MARGIN);
|
||||||
|
|
||||||
let (mid_v, _sub_a, _sub_b) = self.split_edge(eid, remapped_t);
|
let (mid_v, sub_a, sub_b) = self.split_edge(eid, remapped_t);
|
||||||
|
// Track the split products so the fill re-tracer sees them: when a later
|
||||||
|
// stroke segment crosses an earlier one, these sub-edges are part of the
|
||||||
|
// stroke's arrangement but would otherwise be invisible to the re-trace.
|
||||||
|
// (Kept out of `all_new_edges` so the returned edge list stays the stroke's
|
||||||
|
// own edges only.)
|
||||||
|
split_products.push(sub_a);
|
||||||
|
split_products.push(sub_b);
|
||||||
// Snap vertex to intersection point
|
// Snap vertex to intersection point
|
||||||
self.vertices[mid_v.idx()].position = point;
|
self.vertices[mid_v.idx()].position = point;
|
||||||
// Merge with nearby existing vertex if within snap distance
|
// Merge with nearby existing vertex if within snap distance
|
||||||
|
|
@ -791,127 +887,460 @@ impl VectorGraph {
|
||||||
prev_end_vertex = Some(end_v);
|
prev_end_vertex = Some(end_v);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fill splitting pass: for each new edge, check if both endpoints
|
// Weld dangling stroke endpoints onto a near-coincident existing vertex. A
|
||||||
// lie on any fill's boundary — if so, split that fill.
|
// self-intersecting freehand stroke can create an intersection vertex a fraction of
|
||||||
let edges_to_check = all_new_edges.clone();
|
// a pixel away from a segment endpoint that should be the same point; if they don't
|
||||||
for &eid in &edges_to_check {
|
// merge, the stroke's loop is broken by a degree-1 stub and the cut is lost.
|
||||||
let v0 = self.edges[eid.idx()].vertices[0];
|
self.weld_dangling_endpoints(&all_new_edges, snap_epsilon.max(1.0));
|
||||||
let v1 = self.edges[eid.idx()].vertices[1];
|
|
||||||
|
|
||||||
// Find fills where both v0 and v1 appear as boundary vertices
|
// Coincident-edge cleanup: a new edge that lands exactly on an existing edge
|
||||||
let fill_ids: Vec<FillId> = self.fills
|
// between the same two vertices (e.g. drawing a shape whose edge snaps onto an
|
||||||
.iter()
|
// existing one) must not be duplicated — duplicates produce zero-area "sliver"
|
||||||
.enumerate()
|
// fills. Merge such duplicates before splitting/filling.
|
||||||
.filter(|(_, f)| !f.deleted)
|
self.dedupe_coincident_new_edges(&all_new_edges);
|
||||||
.filter(|(_, f)| {
|
|
||||||
let has_v0 = f.boundary.iter().any(|&(be, _)| {
|
|
||||||
let e = &self.edges[be.idx()];
|
|
||||||
e.vertices[0] == v0 || e.vertices[1] == v0
|
|
||||||
});
|
|
||||||
let has_v1 = f.boundary.iter().any(|&(be, _)| {
|
|
||||||
let e = &self.edges[be.idx()];
|
|
||||||
e.vertices[0] == v1 || e.vertices[1] == v1
|
|
||||||
});
|
|
||||||
has_v0 && has_v1
|
|
||||||
})
|
|
||||||
.map(|(i, _)| FillId(i as u32))
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
for fid in fill_ids {
|
// Re-derive the fills touched by the new edges. Rather than incrementally splitting
|
||||||
self.split_fill_by_edge(fid, eid);
|
// along single cut edges (which can't handle a lasso whose path is interrupted by a
|
||||||
}
|
// hole/notch in a non-convex fill), we re-trace the planar faces of the affected
|
||||||
}
|
// sub-arrangement and rebuild the fills from them. This is robust for arbitrary
|
||||||
|
// holed/concave fills (e.g. cutting across geometry left behind by a prior group).
|
||||||
|
// Include split products so a self-crossing stroke's full arrangement is seen.
|
||||||
|
let mut retrace_edges = all_new_edges.clone();
|
||||||
|
retrace_edges.extend(split_products);
|
||||||
|
self.retrace_fills_after_cut(&retrace_edges);
|
||||||
|
|
||||||
|
// Drop any zero-area fills (e.g. slivers left between coincident edges).
|
||||||
|
self.remove_degenerate_fills();
|
||||||
|
|
||||||
all_new_edges
|
all_new_edges
|
||||||
}
|
}
|
||||||
|
|
||||||
/// When a new edge splits a fill (both endpoints on the fill's boundary),
|
/// Merge edges from the latest stroke that are geometrically coincident with an
|
||||||
/// split the fill into two fills.
|
/// existing edge between the same two vertices (drawing a shape whose edge lands exactly
|
||||||
pub fn split_fill_by_edge(
|
/// on an existing edge). Keeps the existing edge, redirects fill references, and frees
|
||||||
&mut self,
|
/// the duplicate — preventing zero-area "sliver" fills between the two copies.
|
||||||
fill_id: FillId,
|
/// Weld degree-1 endpoints of freshly inserted edges onto a near-coincident existing
|
||||||
splitting_edge: EdgeId,
|
/// vertex. A self-intersecting freehand stroke can create an intersection vertex a
|
||||||
) -> Option<(FillId, FillId)> {
|
/// fraction of a pixel from a segment endpoint that should be the same point; if they
|
||||||
let fill = &self.fills[fill_id.idx()];
|
/// don't merge, the stroke's loop is broken by a degree-1 stub and the cut is lost.
|
||||||
if fill.deleted {
|
fn weld_dangling_endpoints(&mut self, new_edges: &[EdgeId], eps: f64) {
|
||||||
return None;
|
// Repeatedly weld the closest dangling new endpoint to its nearest neighbour.
|
||||||
|
loop {
|
||||||
|
let mut to_merge: Option<(VertexId, VertexId)> = None; // (keep, merge)
|
||||||
|
'scan: for &e in new_edges {
|
||||||
|
if self.edges[e.idx()].deleted {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for &v in &self.edges[e.idx()].vertices {
|
||||||
|
if self.vertices[v.idx()].deleted || self.edges_at_vertex(v).len() != 1 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let pv = self.vertices[v.idx()].position;
|
||||||
|
let mut best: Option<(f64, VertexId)> = None;
|
||||||
|
for ui in 0..self.vertices.len() {
|
||||||
|
if ui == v.idx() || self.vertices[ui].deleted {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let pu = self.vertices[ui].position;
|
||||||
|
let d = (pu.x - pv.x).hypot(pu.y - pv.y);
|
||||||
|
if d < eps && best.map_or(true, |(bd, _)| d < bd) {
|
||||||
|
best = Some((d, VertexId(ui as u32)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some((_, keep)) = best {
|
||||||
|
to_merge = Some((keep, v));
|
||||||
|
break 'scan;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
match to_merge {
|
||||||
|
Some((keep, merge)) => self.merge_vertices(keep, merge),
|
||||||
|
None => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Drop any edges that collapsed to zero length (both endpoints welded together).
|
||||||
|
let degenerate: Vec<EdgeId> = self
|
||||||
|
.edges
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.filter(|(_, e)| !e.deleted && e.vertices[0] == e.vertices[1])
|
||||||
|
.map(|(i, _)| EdgeId(i as u32))
|
||||||
|
.collect();
|
||||||
|
for e in degenerate {
|
||||||
|
for fill in &mut self.fills {
|
||||||
|
if !fill.deleted {
|
||||||
|
fill.boundary.retain(|&(fe, _)| fe != e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.free_edge(e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let split_v0 = self.edges[splitting_edge.idx()].vertices[0];
|
fn dedupe_coincident_new_edges(&mut self, new_edges: &[EdgeId]) {
|
||||||
let split_v1 = self.edges[splitting_edge.idx()].vertices[1];
|
for &ne in new_edges {
|
||||||
|
if self.edges[ne.idx()].deleted {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let va = self.edges[ne.idx()].vertices[0];
|
||||||
|
let vb = self.edges[ne.idx()].vertices[1];
|
||||||
|
let candidates: Vec<EdgeId> = self
|
||||||
|
.edges_at_vertex(va)
|
||||||
|
.into_iter()
|
||||||
|
.filter(|&e| e != ne && !self.edges[e.idx()].deleted)
|
||||||
|
.filter(|&e| {
|
||||||
|
let v = self.edges[e.idx()].vertices;
|
||||||
|
(v[0] == va && v[1] == vb) || (v[0] == vb && v[1] == va)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
for c in candidates {
|
||||||
|
if self.edges[c.idx()].deleted {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if self.curves_coincident(ne, c) {
|
||||||
|
self.redirect_edge_in_fills(ne, c);
|
||||||
|
self.free_edge(ne);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Find the positions in the boundary where the splitting edge's
|
/// Whether two edges (already known to share both endpoints) trace the same path.
|
||||||
// endpoint vertices appear as the "arrival" vertex of a directed edge.
|
/// Coincident duplicates in practice are straight collinear segments (a shape edge
|
||||||
let boundary = fill.boundary.clone();
|
/// snapping onto an existing edge), so we treat "both are straight lines between the
|
||||||
|
/// same endpoints" as coincident. Comparing curve `eval(t)` directly is unreliable —
|
||||||
|
/// split sub-edges are non-uniformly parameterised, so equal `t` ≠ equal point.
|
||||||
|
fn curves_coincident(&self, a: EdgeId, b: EdgeId) -> bool {
|
||||||
|
let is_straight = |c: kurbo::CubicBez| {
|
||||||
|
let chord = c.p3 - c.p0;
|
||||||
|
let len = chord.hypot();
|
||||||
|
if len < 1e-9 {
|
||||||
|
return true; // zero-length chord — degenerate, treat as coincident
|
||||||
|
}
|
||||||
|
// Perpendicular distance of each control point from the p0→p3 chord.
|
||||||
|
let dist = |p: Point| ((p - c.p0).cross(chord)).abs() / len;
|
||||||
|
dist(c.p1) < 1e-2 && dist(c.p2) < 1e-2
|
||||||
|
};
|
||||||
|
is_straight(self.edges[a.idx()].curve) && is_straight(self.edges[b.idx()].curve)
|
||||||
|
}
|
||||||
|
|
||||||
// Helper: get the "end" vertex of a directed boundary edge
|
/// Replace every `from` boundary reference with `to`, preserving traversal direction.
|
||||||
let end_vertex = |eid: EdgeId, dir: Direction| -> VertexId {
|
fn redirect_edge_in_fills(&mut self, from: EdgeId, to: EdgeId) {
|
||||||
|
let f = self.edges[from.idx()].vertices;
|
||||||
|
let t = self.edges[to.idx()].vertices;
|
||||||
|
let same_dir = t[0] == f[0] && t[1] == f[1];
|
||||||
|
for fill in &mut self.fills {
|
||||||
|
if fill.deleted {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for entry in &mut fill.boundary {
|
||||||
|
if entry.0 == from {
|
||||||
|
entry.1 = match (entry.1, same_dir) {
|
||||||
|
(Direction::Forward, true) | (Direction::Backward, false) => {
|
||||||
|
Direction::Forward
|
||||||
|
}
|
||||||
|
(Direction::Backward, true) | (Direction::Forward, false) => {
|
||||||
|
Direction::Backward
|
||||||
|
}
|
||||||
|
};
|
||||||
|
entry.0 = to;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drop fills that enclose ~zero area (degenerate slivers from coincident edges).
|
||||||
|
fn remove_degenerate_fills(&mut self) {
|
||||||
|
for i in 0..self.fills.len() {
|
||||||
|
if self.fills[i].deleted {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let mut pts: Vec<Point> = Vec::new();
|
||||||
|
for &(eid, dir) in &self.fills[i].boundary {
|
||||||
|
if eid.is_none() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let c = self.edges[eid.idx()].curve;
|
||||||
|
pts.push(match dir {
|
||||||
|
Direction::Forward => c.p0,
|
||||||
|
Direction::Backward => c.p3,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let area = if pts.len() < 3 {
|
||||||
|
0.0
|
||||||
|
} else {
|
||||||
|
let mut a2 = 0.0;
|
||||||
|
for k in 0..pts.len() {
|
||||||
|
let p0 = pts[k];
|
||||||
|
let p1 = pts[(k + 1) % pts.len()];
|
||||||
|
a2 += p0.x * p1.y - p1.x * p0.y;
|
||||||
|
}
|
||||||
|
(a2 * 0.5).abs()
|
||||||
|
};
|
||||||
|
if area < 1e-6 {
|
||||||
|
self.fills[i].deleted = true;
|
||||||
|
self.free_fills.push(i as u32);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Directed end vertex of a `(edge, direction)` boundary entry.
|
||||||
|
#[inline]
|
||||||
|
fn entry_end_vertex(&self, eid: EdgeId, dir: Direction) -> VertexId {
|
||||||
match dir {
|
match dir {
|
||||||
Direction::Forward => self.edges[eid.idx()].vertices[1],
|
Direction::Forward => self.edges[eid.idx()].vertices[1],
|
||||||
Direction::Backward => self.edges[eid.idx()].vertices[0],
|
Direction::Backward => self.edges[eid.idx()].vertices[0],
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Re-derive the fills touched by a freshly inserted stroke by re-tracing the planar
|
||||||
|
/// faces of the affected sub-arrangement. This replaces incremental "split a fill by a
|
||||||
|
/// cut edge" logic, which can't handle a cut whose path is interrupted by a hole/notch
|
||||||
|
/// in a non-convex fill. Each affected fill is deleted and rebuilt from the traced
|
||||||
|
/// faces that lie inside it (inheriting its colour/rule); faces outside it — or in a
|
||||||
|
/// hole — are dropped.
|
||||||
|
fn retrace_fills_after_cut(&mut self, new_edges: &[EdgeId]) {
|
||||||
|
use kurbo::{ParamCurve, Shape};
|
||||||
|
let new_set: HashSet<EdgeId> = new_edges
|
||||||
|
.iter()
|
||||||
|
.filter(|&&e| !e.is_none() && !self.edges[e.idx()].deleted)
|
||||||
|
.copied()
|
||||||
|
.collect();
|
||||||
|
if new_set.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let new_verts: HashSet<VertexId> =
|
||||||
|
new_set.iter().flat_map(|&e| self.edges[e.idx()].vertices).collect();
|
||||||
|
|
||||||
|
// Affected fills: any non-deleted fill that shares a vertex with a new edge.
|
||||||
|
let affected: Vec<FillId> = (0..self.fills.len())
|
||||||
|
.filter(|&i| !self.fills[i].deleted)
|
||||||
|
.filter(|&i| {
|
||||||
|
self.fills[i].boundary.iter().any(|&(e, _)| {
|
||||||
|
!e.is_none()
|
||||||
|
&& self.edges[e.idx()].vertices.iter().any(|v| new_verts.contains(v))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.map(|i| FillId(i as u32))
|
||||||
|
.collect();
|
||||||
|
if affected.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Snapshot each affected fill's path + attributes before we delete them.
|
||||||
|
let originals: Vec<(kurbo::BezPath, Option<ShapeColor>, FillRule)> = affected
|
||||||
|
.iter()
|
||||||
|
.map(|&f| {
|
||||||
|
(
|
||||||
|
self.fill_to_bezpath(f),
|
||||||
|
self.fills[f.idx()].color,
|
||||||
|
self.fills[f.idx()].fill_rule,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Edge set for the local arrangement: every affected fill's boundary edges plus the
|
||||||
|
// ENTIRE inserted stroke. We include the whole stroke (not just the segments whose
|
||||||
|
// midpoint is inside a fill) because a wiggly freehand lasso has segments that dip
|
||||||
|
// just outside the fill; excluding them would break the inside-arc chain into
|
||||||
|
// dangling fragments that then get pruned away, losing the cut entirely. The stroke
|
||||||
|
// forms closed loops, so it contributes no dangling edges; faces that end up outside
|
||||||
|
// every affected fill are discarded by the classification below.
|
||||||
|
let mut edge_set: HashSet<EdgeId> = HashSet::new();
|
||||||
|
for &f in &affected {
|
||||||
|
for &(e, _) in &self.fills[f.idx()].boundary {
|
||||||
|
if !e.is_none() && !self.edges[e.idx()].deleted {
|
||||||
|
edge_set.insert(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
edge_set.extend(new_set.iter().copied());
|
||||||
|
|
||||||
|
// Expand to the induced subgraph on the covered vertices. A self-intersecting
|
||||||
|
// freehand stroke splits its own edges via `split_edge`, whose sub-edges aren't in
|
||||||
|
// `new_edges`; without them the local arrangement has gaps and the stroke's loop
|
||||||
|
// looks like dangling fragments. Adding every edge whose endpoints are both already
|
||||||
|
// covered closes those gaps (the sub-edges connect already-covered stroke vertices).
|
||||||
|
loop {
|
||||||
|
let verts: HashSet<VertexId> = edge_set
|
||||||
|
.iter()
|
||||||
|
.flat_map(|&e| self.edges[e.idx()].vertices)
|
||||||
|
.collect();
|
||||||
|
let added: Vec<EdgeId> = (0..self.edges.len())
|
||||||
|
.map(|i| EdgeId(i as u32))
|
||||||
|
.filter(|&e| !self.edges[e.idx()].deleted && !edge_set.contains(&e))
|
||||||
|
.filter(|&e| {
|
||||||
|
let [a, b] = self.edges[e.idx()].vertices;
|
||||||
|
verts.contains(&a) && verts.contains(&b)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
if added.is_empty() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
edge_set.extend(added);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prune dangling edges (a vertex with degree < 2 in the local arrangement). They
|
||||||
|
// form spikes, never real face boundaries — a freehand lasso that wiggles or nearly
|
||||||
|
// self-touches leaves such stubs, and tracing them produces a face that runs out and
|
||||||
|
// back along the same edge (a degenerate self-touching boundary). Iterate, since
|
||||||
|
// removing one stub can expose another.
|
||||||
|
loop {
|
||||||
|
let mut degree: HashMap<VertexId, usize> = HashMap::new();
|
||||||
|
for &e in &edge_set {
|
||||||
|
for &v in &self.edges[e.idx()].vertices {
|
||||||
|
*degree.entry(v).or_default() += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let dangling: Vec<EdgeId> = edge_set
|
||||||
|
.iter()
|
||||||
|
.copied()
|
||||||
|
.filter(|&e| {
|
||||||
|
let [a, b] = self.edges[e.idx()].vertices;
|
||||||
|
a == b || degree[&a] < 2 || degree[&b] < 2
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
if dangling.is_empty() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
for e in dangling {
|
||||||
|
edge_set.remove(&e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let faces = self.trace_faces(&edge_set);
|
||||||
|
|
||||||
|
// Replace the affected fills with the re-traced bounded faces that fall inside them.
|
||||||
|
for &f in &affected {
|
||||||
|
self.fills[f.idx()].deleted = true;
|
||||||
|
self.free_fills.push(f.0);
|
||||||
|
}
|
||||||
|
for face in faces {
|
||||||
|
// Only bounded (counter-clockwise, positive-area) faces are real regions; the
|
||||||
|
// outer face is clockwise/negative.
|
||||||
|
if self.face_signed_area(&face) <= 1e-6 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let sample = self.boundary_interior_point(&face);
|
||||||
|
if let Some((_, color, rule)) =
|
||||||
|
originals.iter().find(|(p, _, _)| p.winding(sample) != 0)
|
||||||
|
{
|
||||||
|
self.alloc_fill(face, *color, *rule);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Trace all faces of the planar arrangement formed by `edge_set`, using the standard
|
||||||
|
/// angular next-edge rule (turn to the clockwise-adjacent dart of the twin at each
|
||||||
|
/// vertex). Returns each face as an ordered `(edge, direction)` loop. Bounded faces
|
||||||
|
/// come out counter-clockwise (positive signed area); the outer face clockwise.
|
||||||
|
fn trace_faces(&self, edge_set: &HashSet<EdgeId>) -> Vec<Vec<(EdgeId, Direction)>> {
|
||||||
|
// Outgoing darts per vertex, sorted by outgoing angle (CCW).
|
||||||
|
let mut out: HashMap<VertexId, Vec<(f64, (EdgeId, Direction))>> = HashMap::new();
|
||||||
|
for &e in edge_set {
|
||||||
|
if self.edges[e.idx()].deleted {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let [a, b] = self.edges[e.idx()].vertices;
|
||||||
|
out.entry(a)
|
||||||
|
.or_default()
|
||||||
|
.push((self.dart_angle(e, Direction::Forward), (e, Direction::Forward)));
|
||||||
|
out.entry(b)
|
||||||
|
.or_default()
|
||||||
|
.push((self.dart_angle(e, Direction::Backward), (e, Direction::Backward)));
|
||||||
|
}
|
||||||
|
for darts in out.values_mut() {
|
||||||
|
darts.sort_by(|x, y| x.0.partial_cmp(&y.0).unwrap_or(std::cmp::Ordering::Equal));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut visited: HashSet<(EdgeId, Direction)> = HashSet::new();
|
||||||
|
let mut faces: Vec<Vec<(EdgeId, Direction)>> = Vec::new();
|
||||||
|
let cap = edge_set.len() * 2 + 4;
|
||||||
|
for &e in edge_set {
|
||||||
|
if self.edges[e.idx()].deleted {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for dir in [Direction::Forward, Direction::Backward] {
|
||||||
|
let start = (e, dir);
|
||||||
|
if visited.contains(&start) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let mut face: Vec<(EdgeId, Direction)> = Vec::new();
|
||||||
|
let mut d = start;
|
||||||
|
loop {
|
||||||
|
visited.insert(d);
|
||||||
|
face.push(d);
|
||||||
|
// Next dart: at the end vertex, the dart clockwise-adjacent to the twin.
|
||||||
|
let end_v = self.entry_end_vertex(d.0, d.1);
|
||||||
|
let twin = (
|
||||||
|
d.0,
|
||||||
|
match d.1 {
|
||||||
|
Direction::Forward => Direction::Backward,
|
||||||
|
Direction::Backward => Direction::Forward,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let darts = match out.get(&end_v) {
|
||||||
|
Some(d) => d,
|
||||||
|
None => break,
|
||||||
};
|
};
|
||||||
|
let Some(idx) = darts.iter().position(|&(_, dd)| dd == twin) else {
|
||||||
// Find positions where boundary edges arrive at split_v0 and split_v1
|
break;
|
||||||
let mut pos_v0: Option<usize> = None;
|
};
|
||||||
let mut pos_v1: Option<usize> = None;
|
let next = darts[(idx + darts.len() - 1) % darts.len()].1;
|
||||||
|
if next == start {
|
||||||
for (i, &(eid, dir)) in boundary.iter().enumerate() {
|
|
||||||
let ev = end_vertex(eid, dir);
|
|
||||||
if ev == split_v0 && pos_v0.is_none() {
|
|
||||||
pos_v0 = Some(i);
|
|
||||||
}
|
|
||||||
if ev == split_v1 && pos_v1.is_none() {
|
|
||||||
pos_v1 = Some(i);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let pos_v0 = pos_v0?;
|
|
||||||
let pos_v1 = pos_v1?;
|
|
||||||
|
|
||||||
// Ensure we have two distinct positions
|
|
||||||
if pos_v0 == pos_v1 {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Walk boundary in two halves:
|
|
||||||
// Half A: from pos_v0+1 to pos_v1 (inclusive), then splitting_edge Forward
|
|
||||||
// Half B: from pos_v1+1 to pos_v0 (wrapping), then splitting_edge Backward
|
|
||||||
let n = boundary.len();
|
|
||||||
let color = fill.color;
|
|
||||||
let fill_rule = fill.fill_rule;
|
|
||||||
|
|
||||||
let mut half_a = Vec::new();
|
|
||||||
let mut idx = (pos_v0 + 1) % n;
|
|
||||||
loop {
|
|
||||||
half_a.push(boundary[idx]);
|
|
||||||
if idx == pos_v1 {
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
idx = (idx + 1) % n;
|
if visited.contains(&next) || face.len() > cap {
|
||||||
}
|
|
||||||
half_a.push((splitting_edge, Direction::Forward));
|
|
||||||
|
|
||||||
let mut half_b = Vec::new();
|
|
||||||
idx = (pos_v1 + 1) % n;
|
|
||||||
loop {
|
|
||||||
half_b.push(boundary[idx]);
|
|
||||||
if idx == pos_v0 {
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
idx = (idx + 1) % n;
|
d = next;
|
||||||
|
}
|
||||||
|
if face.len() >= 3 {
|
||||||
|
faces.push(face);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
faces
|
||||||
}
|
}
|
||||||
half_b.push((splitting_edge, Direction::Backward));
|
|
||||||
|
|
||||||
// Delete the original fill
|
/// Outgoing direction angle of a dart at its start vertex.
|
||||||
self.fills[fill_id.idx()].deleted = true;
|
fn dart_angle(&self, e: EdgeId, dir: Direction) -> f64 {
|
||||||
self.free_fills.push(fill_id.0);
|
let c = self.edges[e.idx()].curve;
|
||||||
|
let (base, cands) = match dir {
|
||||||
|
Direction::Forward => (c.p0, [c.p1, c.p2, c.p3]),
|
||||||
|
Direction::Backward => (c.p3, [c.p2, c.p1, c.p0]),
|
||||||
|
};
|
||||||
|
for cand in cands {
|
||||||
|
let d = cand - base;
|
||||||
|
if d.hypot() > 1e-9 {
|
||||||
|
return d.y.atan2(d.x);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
0.0
|
||||||
|
}
|
||||||
|
|
||||||
// Create two new fills
|
/// Signed area of a face given as an ordered `(edge, direction)` loop (CCW positive).
|
||||||
let fill_a = self.alloc_fill(half_a, color, fill_rule);
|
fn face_signed_area(&self, face: &[(EdgeId, Direction)]) -> f64 {
|
||||||
let fill_b = self.alloc_fill(half_b, color, fill_rule);
|
let pts: Vec<Point> = face
|
||||||
|
.iter()
|
||||||
Some((fill_a, fill_b))
|
.map(|&(e, dir)| {
|
||||||
|
let c = self.edges[e.idx()].curve;
|
||||||
|
match dir {
|
||||||
|
Direction::Forward => c.p0,
|
||||||
|
Direction::Backward => c.p3,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
if pts.len() < 3 {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
let mut a2 = 0.0;
|
||||||
|
for i in 0..pts.len() {
|
||||||
|
let p0 = pts[i];
|
||||||
|
let p1 = pts[(i + 1) % pts.len()];
|
||||||
|
a2 += p0.x * p1.y - p1.x * p0.y;
|
||||||
|
}
|
||||||
|
a2 * 0.5
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Merge two fills that share a boundary edge (e.g., after edge deletion).
|
/// Merge two fills that share a boundary edge (e.g., after edge deletion).
|
||||||
|
|
@ -1479,6 +1908,32 @@ impl VectorGraph {
|
||||||
let mut vtx_map: HashMap<VertexId, VertexId> = HashMap::new();
|
let mut vtx_map: HashMap<VertexId, VertexId> = HashMap::new();
|
||||||
let mut edge_map: HashMap<EdgeId, EdgeId> = HashMap::new();
|
let mut edge_map: HashMap<EdgeId, EdgeId> = HashMap::new();
|
||||||
|
|
||||||
|
// Augment the inside set with every boundary edge of an extracted fill. A
|
||||||
|
// selection might not enumerate them all (e.g. lasso/region selection populates
|
||||||
|
// edges differently than `select_fill`); without this an extracted fill would be
|
||||||
|
// copied with `EdgeId::NONE` standing in for a missing edge, and that NONE later
|
||||||
|
// panics any code that indexes `fill.boundary` (e.g. `insert_stroke`).
|
||||||
|
let inside_edges: HashSet<EdgeId> = {
|
||||||
|
let mut s = inside_edges.clone();
|
||||||
|
for &fid in inside_fills {
|
||||||
|
if fid.is_none() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Some(fill) = self.fills.get(fid.idx()) {
|
||||||
|
if fill.deleted {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for &(eid, _) in &fill.boundary {
|
||||||
|
if !eid.is_none() {
|
||||||
|
s.insert(eid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s
|
||||||
|
};
|
||||||
|
let inside_edges = &inside_edges;
|
||||||
|
|
||||||
// Boundary = `explicit_boundary` (e.g. a region/lasso cut the caller knows about)
|
// Boundary = `explicit_boundary` (e.g. a region/lasso cut the caller knows about)
|
||||||
// UNION any inside edge still referenced by a fill we're NOT extracting (a shared
|
// UNION any inside edge still referenced by a fill we're NOT extracting (a shared
|
||||||
// DCEL edge — must be duplicated, not moved, or that fill dangles). Deriving the
|
// DCEL edge — must be duplicated, not moved, or that fill dangles). Deriving the
|
||||||
|
|
@ -1511,16 +1966,21 @@ impl VectorGraph {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine which vertices are interior (exclusively owned by the
|
// Determine which vertices are safe to free from `self`. A vertex can only be
|
||||||
// extracted subgraph) vs boundary (shared with remaining geometry).
|
// freed if EVERY one of its incident edges is actually being removed from `self`,
|
||||||
// A vertex is interior if ALL of its incident edges are either in
|
// i.e. is an inside edge that is NOT a boundary edge. Boundary edges are kept
|
||||||
// inside_edges or boundary_edge_ids.
|
// (duplicated) in `self`, so a vertex touching one is still referenced and must
|
||||||
|
// remain — otherwise it becomes a freed-but-referenced vertex whose slot a later
|
||||||
|
// `alloc_vertex` reuses, corrupting the remaining fill.
|
||||||
let mut interior_vertices: HashSet<VertexId> = HashSet::new();
|
let mut interior_vertices: HashSet<VertexId> = HashSet::new();
|
||||||
let mut boundary_vertices: HashSet<VertexId> = HashSet::new();
|
let mut boundary_vertices: HashSet<VertexId> = HashSet::new();
|
||||||
for &vid in &referenced_vids {
|
for &vid in &referenced_vids {
|
||||||
let incident = self.edges_at_vertex(vid);
|
let incident = self.edges_at_vertex(vid);
|
||||||
let all_inside = incident.iter().all(|&eid| edges_to_copy.contains(&eid));
|
let all_removed = !incident.is_empty()
|
||||||
if all_inside {
|
&& incident
|
||||||
|
.iter()
|
||||||
|
.all(|&eid| inside_edges.contains(&eid) && !boundary_edge_ids.contains(&eid));
|
||||||
|
if all_removed {
|
||||||
interior_vertices.insert(vid);
|
interior_vertices.insert(vid);
|
||||||
} else {
|
} else {
|
||||||
boundary_vertices.insert(vid);
|
boundary_vertices.insert(vid);
|
||||||
|
|
|
||||||
|
|
@ -10,3 +10,5 @@ mod editing;
|
||||||
mod gap_close;
|
mod gap_close;
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod region;
|
mod region;
|
||||||
|
#[cfg(test)]
|
||||||
|
mod region_cut_select;
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,609 @@
|
||||||
|
//! Reproduces the unified region-select behaviour at the graph level: cutting a shape
|
||||||
|
//! along a lasso outline (`insert_stroke`) and classifying which resulting fills/edges
|
||||||
|
//! fall inside the lasso — the same logic the editor's `execute_region_select` runs.
|
||||||
|
//!
|
||||||
|
//! The shape is built exactly as the editor builds a rectangle: `insert_stroke` of a
|
||||||
|
//! closed rect loop, then `paint_bucket` to create the fill (NOT a hand-rolled
|
||||||
|
//! `alloc_fill`), because the bug is in how that traced fill's boundary survives being
|
||||||
|
//! split by the lasso cut.
|
||||||
|
|
||||||
|
use super::super::*;
|
||||||
|
use kurbo::{BezPath, CubicBez, ParamCurve, Point, Shape};
|
||||||
|
|
||||||
|
/// Straight-line cubic 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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn closed_rect_path(min_x: f64, min_y: f64, max_x: f64, max_y: f64) -> BezPath {
|
||||||
|
let mut p = BezPath::new();
|
||||||
|
p.move_to((min_x, min_y));
|
||||||
|
p.line_to((max_x, min_y));
|
||||||
|
p.line_to((max_x, max_y));
|
||||||
|
p.line_to((min_x, max_y));
|
||||||
|
p.close_path();
|
||||||
|
p
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Draw a filled rectangle into an existing graph the way the editor's rectangle tool does.
|
||||||
|
fn draw_filled_rect(g: &mut VectorGraph, min_x: f64, min_y: f64, max_x: f64, max_y: f64) {
|
||||||
|
let style = StrokeStyle { width: 1.0, ..Default::default() };
|
||||||
|
let color = ShapeColor::rgb(0, 0, 0);
|
||||||
|
for segs in bezpath_to_cubic_segments(&closed_rect_path(min_x, min_y, max_x, max_y)) {
|
||||||
|
g.insert_stroke(&segs, Some(style.clone()), Some(color), 0.5);
|
||||||
|
}
|
||||||
|
let centroid = Point::new((min_x + max_x) / 2.0, (min_y + max_y) / 2.0);
|
||||||
|
g.paint_bucket(centroid, ShapeColor::rgb(255, 0, 0), FillRule::NonZero, 0.0)
|
||||||
|
.expect("paint_bucket should create a fill");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a filled rectangle the way the editor's rectangle tool does.
|
||||||
|
fn editor_filled_rect(min_x: f64, min_y: f64, max_x: f64, max_y: f64) -> VectorGraph {
|
||||||
|
let mut g = VectorGraph::new();
|
||||||
|
draw_filled_rect(&mut g, min_x, min_y, max_x, max_y);
|
||||||
|
g
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Closed rectangular lasso: cubic segments (for insert_stroke) + BezPath (for winding).
|
||||||
|
fn rect_lasso(min_x: f64, min_y: f64, max_x: f64, max_y: f64) -> (Vec<CubicBez>, BezPath) {
|
||||||
|
let p = [
|
||||||
|
Point::new(min_x, min_y),
|
||||||
|
Point::new(max_x, min_y),
|
||||||
|
Point::new(max_x, max_y),
|
||||||
|
Point::new(min_x, max_y),
|
||||||
|
];
|
||||||
|
let segs = vec![line(p[0], p[1]), line(p[1], p[2]), line(p[2], p[3]), line(p[3], p[0])];
|
||||||
|
let mut path = BezPath::new();
|
||||||
|
path.move_to(p[0]);
|
||||||
|
for pt in &p[1..] {
|
||||||
|
path.line_to(*pt);
|
||||||
|
}
|
||||||
|
path.close_path();
|
||||||
|
(segs, path)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Classify-against-lasso uses the same guaranteed-interior probe point the editor uses.
|
||||||
|
fn fill_centroid(g: &VectorGraph, fid: FillId) -> Point {
|
||||||
|
g.fill_interior_point(fid)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Directed start/end points of a boundary entry.
|
||||||
|
fn dir_start(g: &VectorGraph, eid: EdgeId, dir: Direction) -> Point {
|
||||||
|
let c = g.edge(eid).curve;
|
||||||
|
match dir {
|
||||||
|
Direction::Forward => c.p0,
|
||||||
|
Direction::Backward => c.p3,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn dir_end(g: &VectorGraph, eid: EdgeId, dir: Direction) -> Point {
|
||||||
|
let c = g.edge(eid).curve;
|
||||||
|
match dir {
|
||||||
|
Direction::Forward => c.p3,
|
||||||
|
Direction::Backward => c.p0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Describe a fill's boundary as an ordered list of (start → end) segments for diagnostics.
|
||||||
|
fn describe_boundary(g: &VectorGraph, fid: FillId) -> Vec<(EdgeId, Point, Point)> {
|
||||||
|
g.fill(fid)
|
||||||
|
.boundary
|
||||||
|
.iter()
|
||||||
|
.filter(|(e, _)| !e.is_none())
|
||||||
|
.map(|&(e, d)| (e, dir_start(g, e, d), dir_end(g, e, d)))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Assert no fill boundary uses the same edge twice (the signature of a degenerate
|
||||||
|
/// "spike" where the trace runs out and back along a dangling edge).
|
||||||
|
fn assert_no_spikes(g: &VectorGraph) {
|
||||||
|
for (fi, f) in g.fills.iter().enumerate() {
|
||||||
|
if f.deleted { continue; }
|
||||||
|
let mut seen = std::collections::HashSet::new();
|
||||||
|
for &(e, _) in &f.boundary {
|
||||||
|
if e.is_none() { continue; }
|
||||||
|
assert!(seen.insert(e.0), "fill {fi} uses edge {} twice (spike)", e.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Assert every non-deleted fill's boundary is a connected closed loop.
|
||||||
|
fn assert_all_fills_connected(g: &VectorGraph) {
|
||||||
|
for (i, f) in g.fills.iter().enumerate() {
|
||||||
|
if f.deleted {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let fid = FillId(i as u32);
|
||||||
|
let b = describe_boundary(g, fid);
|
||||||
|
if b.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let n = b.len();
|
||||||
|
for k in 0..n {
|
||||||
|
let (_, _, end) = b[k];
|
||||||
|
let (_, next_start, _) = b[(k + 1) % n];
|
||||||
|
assert!(
|
||||||
|
(end.x - next_start.x).abs() < 1e-6 && (end.y - next_start.y).abs() < 1e-6,
|
||||||
|
"fill {i} boundary disconnected between edge {k} (ends {end:?}) and \
|
||||||
|
edge {} (starts {next_start:?}); boundary = {b:#?}",
|
||||||
|
(k + 1) % n
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn single_vertical_cut_produces_two_connected_fills() {
|
||||||
|
// Rectangle (0,0)-(200,100), one vertical cut at x=100.
|
||||||
|
let mut g = editor_filled_rect(0.0, 0.0, 200.0, 100.0);
|
||||||
|
let cut = vec![line(Point::new(100.0, -10.0), Point::new(100.0, 110.0))];
|
||||||
|
g.insert_stroke(&cut, None, None, 1.0);
|
||||||
|
|
||||||
|
let live = g.fills.iter().filter(|f| !f.deleted).count();
|
||||||
|
assert_eq!(live, 2, "one cut should split the rect into 2 fills, got {live}");
|
||||||
|
assert_all_fills_connected(&g);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn lasso_over_second_of_two_rects_splits_that_rect() {
|
||||||
|
// Two separate rectangles; lasso a vertical strip over the SECOND one.
|
||||||
|
let mut g = editor_filled_rect(0.0, 0.0, 200.0, 100.0);
|
||||||
|
draw_filled_rect(&mut g, 0.0, 150.0, 200.0, 250.0);
|
||||||
|
assert_eq!(g.fills.iter().filter(|f| !f.deleted).count(), 2, "two rects → two fills");
|
||||||
|
|
||||||
|
let (segs, lasso_path) = rect_lasso(50.0, 120.0, 150.0, 300.0); // crosses rect B only
|
||||||
|
g.insert_stroke(&segs, None, None, 1.0);
|
||||||
|
g.gc_invisible_edges();
|
||||||
|
|
||||||
|
// Rect B should now be split into 3 fills (left/middle/right strips); rect A is untouched.
|
||||||
|
// So 1 (rect A) + 3 (rect B pieces) = 4 fills total.
|
||||||
|
let live = g.fills.iter().filter(|f| !f.deleted).count();
|
||||||
|
let inside_fills: Vec<FillId> = g
|
||||||
|
.fills
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.filter(|(_, f)| !f.deleted)
|
||||||
|
.map(|(i, _)| FillId(i as u32))
|
||||||
|
.filter(|&fid| lasso_path.winding(fill_centroid(&g, fid)) != 0)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let dump = || {
|
||||||
|
g.fills
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.filter(|(_, f)| !f.deleted)
|
||||||
|
.map(|(i, _)| (i, fill_centroid(&g, FillId(i as u32))))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
};
|
||||||
|
|
||||||
|
assert_eq!(live, 4, "rect B should split into 3 (total 4 fills); got {live}: {:#?}", dump());
|
||||||
|
assert_eq!(
|
||||||
|
inside_fills.len(),
|
||||||
|
1,
|
||||||
|
"exactly the center strip of rect B should be inside; got {:#?}",
|
||||||
|
dump()
|
||||||
|
);
|
||||||
|
assert_all_fills_connected(&g);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn lasso_strip_through_side_by_side_second_rect() {
|
||||||
|
// Two rectangles side by side; lasso a vertical strip through the SECOND (right) one.
|
||||||
|
let mut g = editor_filled_rect(0.0, 0.0, 100.0, 100.0);
|
||||||
|
draw_filled_rect(&mut g, 150.0, 0.0, 250.0, 100.0);
|
||||||
|
|
||||||
|
let (segs, lasso_path) = rect_lasso(180.0, -50.0, 220.0, 150.0);
|
||||||
|
g.insert_stroke(&segs, None, None, 1.0);
|
||||||
|
g.gc_invisible_edges();
|
||||||
|
assert_all_fills_connected(&g);
|
||||||
|
|
||||||
|
let inside: Vec<FillId> = g.fills.iter().enumerate()
|
||||||
|
.filter(|(_, f)| !f.deleted).map(|(i, _)| FillId(i as u32))
|
||||||
|
.filter(|&fid| lasso_path.winding(fill_centroid(&g, fid)) != 0).collect();
|
||||||
|
let dump: Vec<_> = g.fills.iter().enumerate().filter(|(_, f)| !f.deleted)
|
||||||
|
.map(|(i, _)| (i, fill_centroid(&g, FillId(i as u32)), describe_boundary(&g, FillId(i as u32)).len())).collect();
|
||||||
|
assert_eq!(inside.len(), 1, "one strip of the right rect should be inside; got {dump:#?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// No live fill may reference a freed edge or vertex (whose slot a later alloc reuses).
|
||||||
|
fn assert_no_freed_but_referenced(g: &VectorGraph) {
|
||||||
|
let freed_v: std::collections::HashSet<u32> = g.free_vertices.iter().copied().collect();
|
||||||
|
let freed_e: std::collections::HashSet<u32> = g.free_edges.iter().copied().collect();
|
||||||
|
for (fi, f) in g.fills.iter().enumerate() {
|
||||||
|
if f.deleted { continue; }
|
||||||
|
for &(e, _) in &f.boundary {
|
||||||
|
if e.is_none() { continue; }
|
||||||
|
assert!(!freed_e.contains(&e.0), "live fill {fi} references freed edge {}", e.0);
|
||||||
|
for &v in &g.edge(e).vertices {
|
||||||
|
assert!(!freed_v.contains(&v.0), "live fill {fi} references freed vertex {}", v.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn extract_after_cut_keeps_remaining_fill_intact() {
|
||||||
|
// Cut a rectangle's corner with a lasso, then extract (Group) the clipped corner. The
|
||||||
|
// extraction must not free vertices/edges still referenced by the L-shaped remainder —
|
||||||
|
// previously it freed the shared cut vertices, and a later `alloc_vertex` reused those
|
||||||
|
// slots and corrupted the remainder (the "second lasso deletes all faces" bug).
|
||||||
|
let mut g = editor_filled_rect(0.0, 0.0, 200.0, 100.0);
|
||||||
|
let (segs, lasso) = rect_lasso(100.0, -50.0, 300.0, 50.0); // clips the top-right corner
|
||||||
|
g.insert_stroke(&segs, None, None, 1.0);
|
||||||
|
g.gc_invisible_edges();
|
||||||
|
|
||||||
|
// Pick the fill inside the lasso (the clipped corner) and extract it.
|
||||||
|
let inside: Vec<FillId> = g.fills.iter().enumerate()
|
||||||
|
.filter(|(_, f)| !f.deleted).map(|(i, _)| FillId(i as u32))
|
||||||
|
.filter(|&fid| lasso.winding(g.fill_interior_point(fid)) != 0).collect();
|
||||||
|
assert_eq!(inside.len(), 1, "one corner fill should be inside the lasso");
|
||||||
|
let inside_fills: HashSet<FillId> = inside.iter().copied().collect();
|
||||||
|
let inside_edges: HashSet<EdgeId> = g.fill(inside[0]).boundary.iter()
|
||||||
|
.filter_map(|&(e, _)| (!e.is_none()).then_some(e)).collect();
|
||||||
|
|
||||||
|
let _ = g.extract_subgraph(&inside_edges, &inside_fills, &HashSet::new());
|
||||||
|
|
||||||
|
assert_no_freed_but_referenced(&g);
|
||||||
|
assert_all_fills_connected(&g);
|
||||||
|
|
||||||
|
// Simulate the next lasso allocating vertices, then re-validate: a corrupted (freed but
|
||||||
|
// referenced) vertex would now be at a bogus position.
|
||||||
|
let (segs2, _) = rect_lasso(20.0, 20.0, 60.0, 80.0);
|
||||||
|
g.insert_stroke(&segs2, None, None, 1.0);
|
||||||
|
g.gc_invisible_edges();
|
||||||
|
assert_all_fills_connected(&g);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn second_stroke_crossing_first_splits_into_quadrants() {
|
||||||
|
// A later stroke that crosses an earlier stroke's edge inside a fill triggers an
|
||||||
|
// edge-domain `split_edge`, whose sub-edges aren't tracked in the second stroke's new
|
||||||
|
// edges. The retrace must still see them (induced-subgraph expansion) or the cut breaks
|
||||||
|
// and unravels. Two crossing cuts through a rectangle must yield four connected quadrants.
|
||||||
|
let mut g = editor_filled_rect(0.0, 0.0, 100.0, 100.0);
|
||||||
|
g.insert_stroke(&[line(Point::new(-10.0, 50.0), Point::new(110.0, 50.0))], None, None, 1.0); // horizontal
|
||||||
|
assert_eq!(g.fills.iter().filter(|f| !f.deleted).count(), 2, "horizontal cut → 2");
|
||||||
|
g.insert_stroke(&[line(Point::new(50.0, -10.0), Point::new(50.0, 110.0))], None, None, 1.0); // vertical, crosses it
|
||||||
|
g.gc_invisible_edges();
|
||||||
|
|
||||||
|
let live: Vec<FillId> = g.fills.iter().enumerate()
|
||||||
|
.filter(|(_, f)| !f.deleted).map(|(i, _)| FillId(i as u32)).collect();
|
||||||
|
assert_eq!(live.len(), 4, "two crossing cuts → four quadrants; got {}", live.len());
|
||||||
|
assert_all_fills_connected(&g);
|
||||||
|
assert_no_spikes(&g);
|
||||||
|
for &fid in &live {
|
||||||
|
assert!((polygon_area(&g, fid) - 2500.0).abs() < 1.0, "each quadrant is 50x50, got {}", polygon_area(&g, fid));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stroke_dead_ending_inside_fill_does_not_spike() {
|
||||||
|
// A stroke (e.g. a freehand lasso that wiggles or nearly self-touches) can leave a
|
||||||
|
// dangling stub: an edge whose inner endpoint has degree 1. Re-tracing must not run out
|
||||||
|
// and back along it (a self-touching "spike" boundary); the fill stays a clean loop.
|
||||||
|
let mut g = editor_filled_rect(0.0, 0.0, 200.0, 100.0);
|
||||||
|
// Open stroke entering the top edge and dead-ending inside at (100,50).
|
||||||
|
let stub = vec![line(Point::new(50.0, -20.0), Point::new(100.0, 50.0))];
|
||||||
|
g.insert_stroke(&stub, None, None, 1.0);
|
||||||
|
g.gc_invisible_edges();
|
||||||
|
|
||||||
|
let live: Vec<FillId> = g.fills.iter().enumerate()
|
||||||
|
.filter(|(_, f)| !f.deleted).map(|(i, _)| FillId(i as u32)).collect();
|
||||||
|
assert_eq!(live.len(), 1, "a dead-end stub must not split the fill; got {}", live.len());
|
||||||
|
assert_all_fills_connected(&g);
|
||||||
|
assert_no_spikes(&g);
|
||||||
|
// The fill is still the full rectangle.
|
||||||
|
assert!((polygon_area(&g, live[0]) - 20000.0).abs() < 1.0,
|
||||||
|
"fill should remain the 200x100 rectangle, area {}", polygon_area(&g, live[0]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn groupfail_two_notched_fills_second_lasso() {
|
||||||
|
// Faithful reconstruction of the captured post-group state (dump_1): two adjacent
|
||||||
|
// fills sharing the x=337 column, each with an invisible notch (the hole left by the
|
||||||
|
// first lasso+group). A second lasso (398,249)-(495,327) clips fill 3's corner.
|
||||||
|
use std::collections::HashMap;
|
||||||
|
let mut g = VectorGraph::new();
|
||||||
|
let vp = [
|
||||||
|
(130.0, 232.0), (337.0, 232.0), (337.0, 362.0), (130.0, 362.0), (337.0, 297.0),
|
||||||
|
(535.0, 297.0), (535.0, 417.0), (337.0, 417.0), (0.0, 0.0), (337.0, 315.0),
|
||||||
|
(220.0, 315.0), (456.0, 315.0), (456.0, 345.0), (337.0, 345.0), (220.0, 345.0),
|
||||||
|
];
|
||||||
|
let vs: Vec<_> = vp.iter().map(|&(x, y)| g.alloc_vertex(Point::new(x, y))).collect();
|
||||||
|
let style = StrokeStyle { width: 3.0, ..Default::default() };
|
||||||
|
let color = ShapeColor::rgb(0, 0, 0);
|
||||||
|
// (name, v_start, v_end, visible)
|
||||||
|
let edge_defs: &[(&str, usize, usize, bool)] = &[
|
||||||
|
("e0", 0, 1, true), ("e1", 4, 5, true), ("e2", 2, 3, true), ("e3", 3, 0, true),
|
||||||
|
("e4", 1, 4, true), ("e5", 7, 2, true), ("e6", 5, 6, true), ("e7", 6, 7, true),
|
||||||
|
("e8", 10, 9, false), ("e9", 12, 13, false), ("e11", 4, 9, true), ("e12", 9, 11, false),
|
||||||
|
("e13", 11, 12, false), ("e15", 13, 2, true), ("e16", 13, 14, false), ("e17", 14, 10, false),
|
||||||
|
];
|
||||||
|
let mut em: HashMap<&str, EdgeId> = HashMap::new();
|
||||||
|
for &(name, a, b, vis) in edge_defs {
|
||||||
|
let (ss, sc) = if vis { (Some(style.clone()), Some(color)) } else { (None, None) };
|
||||||
|
let e = g.alloc_edge(line(vp[a].into(), vp[b].into()), vs[a], vs[b], ss, sc);
|
||||||
|
em.insert(name, e);
|
||||||
|
}
|
||||||
|
let mk = |spec: &[(&str, Direction)]| -> Vec<(EdgeId, Direction)> {
|
||||||
|
spec.iter().map(|&(n, d)| (em[n], d)).collect()
|
||||||
|
};
|
||||||
|
use Direction::{Backward as B, Forward as F};
|
||||||
|
g.alloc_fill(mk(&[("e11", B), ("e4", B), ("e0", B), ("e3", B), ("e2", B), ("e15", B), ("e16", F), ("e17", F), ("e8", F)]),
|
||||||
|
ShapeColor::rgb(100, 100, 255), FillRule::NonZero);
|
||||||
|
g.alloc_fill(mk(&[("e15", F), ("e5", B), ("e7", B), ("e6", B), ("e1", B), ("e11", F), ("e12", F), ("e13", F), ("e9", F)]),
|
||||||
|
ShapeColor::rgb(100, 100, 255), FillRule::NonZero);
|
||||||
|
|
||||||
|
assert_eq!(g.fills.iter().filter(|f| !f.deleted).count(), 2, "starts with 2 fills");
|
||||||
|
let (segs, lasso) = rect_lasso(398.0, 249.0, 495.0, 327.0);
|
||||||
|
g.insert_stroke(&segs, None, None, 1.0);
|
||||||
|
g.gc_invisible_edges();
|
||||||
|
|
||||||
|
let live = g.fills.iter().filter(|f| !f.deleted).count();
|
||||||
|
let inside: Vec<FillId> = g.fills.iter().enumerate()
|
||||||
|
.filter(|(_, f)| !f.deleted).map(|(i, _)| FillId(i as u32))
|
||||||
|
.filter(|&fid| lasso.winding(g.fill_interior_point(fid)) != 0).collect();
|
||||||
|
eprintln!("groupfail: {live} live fills, {} inside lasso", inside.len());
|
||||||
|
for (i, f) in g.fills.iter().enumerate() {
|
||||||
|
if f.deleted { continue; }
|
||||||
|
let fid = FillId(i as u32);
|
||||||
|
eprintln!(" fill {i}: bbox {:?} area {:.0}", fill_bbox(&g, fid), polygon_area(&g, fid));
|
||||||
|
}
|
||||||
|
assert_all_fills_connected(&g);
|
||||||
|
// The two original shapes must survive (no faces wrongly deleted); the lasso adds a cut.
|
||||||
|
assert!(live >= 2, "must keep both shapes plus the cut; got {live}");
|
||||||
|
assert_eq!(inside.len(), 1, "exactly the clipped corner of fill 3 should be selected");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn lasso_across_notched_fill_never_corrupts_boundaries() {
|
||||||
|
// A rectangle with a rectangular notch bitten out of its top edge — the shape a fill
|
||||||
|
// is left as after grouping a lasso selection (the notch is the extracted region's
|
||||||
|
// hole). A second lasso crossing the notch must never produce disconnected/corrupt
|
||||||
|
// fill boundaries (it previously incorporated the lasso's out-of-shape edges, drawing
|
||||||
|
// stray diagonals). It may under-select on such complex geometry, but must stay valid.
|
||||||
|
let mut g = VectorGraph::new();
|
||||||
|
let pts = [
|
||||||
|
Point::new(0.0, 0.0), Point::new(80.0, 0.0), Point::new(80.0, 40.0),
|
||||||
|
Point::new(120.0, 40.0), Point::new(120.0, 0.0), Point::new(200.0, 0.0),
|
||||||
|
Point::new(200.0, 100.0), Point::new(0.0, 100.0),
|
||||||
|
];
|
||||||
|
let style = StrokeStyle { width: 1.0, ..Default::default() };
|
||||||
|
let color = ShapeColor::rgb(0, 0, 0);
|
||||||
|
let v: Vec<_> = pts.iter().map(|&p| g.alloc_vertex(p)).collect();
|
||||||
|
let mut boundary = Vec::new();
|
||||||
|
for i in 0..pts.len() {
|
||||||
|
let e = g.alloc_edge(line(pts[i], pts[(i + 1) % pts.len()]), v[i], v[(i + 1) % pts.len()],
|
||||||
|
Some(style.clone()), Some(color));
|
||||||
|
boundary.push((e, Direction::Forward));
|
||||||
|
}
|
||||||
|
g.alloc_fill(boundary, ShapeColor::rgb(255, 0, 0), FillRule::NonZero);
|
||||||
|
|
||||||
|
// Lasso a rectangle that straddles the notch and the shape body. Inside the fill it
|
||||||
|
// covers (60..140, 0..60) minus the notch (80..120, 0..40) — an arch shape — which the
|
||||||
|
// cut must isolate as one connected fill, with the remainder outside.
|
||||||
|
let (segs, lasso) = rect_lasso(60.0, -20.0, 140.0, 60.0);
|
||||||
|
g.insert_stroke(&segs, None, None, 1.0);
|
||||||
|
g.gc_invisible_edges();
|
||||||
|
|
||||||
|
// Every resulting fill is a valid connected loop (never corrupt).
|
||||||
|
assert_all_fills_connected(&g);
|
||||||
|
|
||||||
|
// Exactly one fill lies inside the lasso, and it is the arch (area = 80*60 - 40*40).
|
||||||
|
let inside: Vec<FillId> = g.fills.iter().enumerate()
|
||||||
|
.filter(|(_, f)| !f.deleted).map(|(i, _)| FillId(i as u32))
|
||||||
|
.filter(|&fid| lasso.winding(g.fill_interior_point(fid)) != 0).collect();
|
||||||
|
assert_eq!(inside.len(), 1, "the arch (lasso ∩ notched fill) should be one selected fill");
|
||||||
|
let area = polygon_area(&g, inside[0]);
|
||||||
|
assert!((area - 3200.0).abs() < 1.0, "arch area should be 3200, got {area}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Absolute polygon area of a fill from its boundary corner points.
|
||||||
|
fn polygon_area(g: &VectorGraph, fid: FillId) -> f64 {
|
||||||
|
let pts: Vec<Point> = g.fill(fid).boundary.iter()
|
||||||
|
.filter(|(e, _)| !e.is_none())
|
||||||
|
.map(|&(e, d)| dir_start(g, e, d))
|
||||||
|
.collect();
|
||||||
|
if pts.len() < 3 { return 0.0; }
|
||||||
|
let mut a2 = 0.0;
|
||||||
|
for i in 0..pts.len() {
|
||||||
|
let p0 = pts[i]; let p1 = pts[(i + 1) % pts.len()];
|
||||||
|
a2 += p0.x * p1.y - p1.x * p0.y;
|
||||||
|
}
|
||||||
|
(a2 * 0.5).abs()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn two_edge_adjacent_rects_make_two_clean_fills() {
|
||||||
|
// Two rectangles sharing the x=100 edge. The second's left edge lands exactly on the
|
||||||
|
// first's right edge; without coincident-edge cleanup this produced duplicate edges and
|
||||||
|
// zero-area "sliver" fills (4 fills instead of 2) before any lasso.
|
||||||
|
let mut g = editor_filled_rect(0.0, 0.0, 100.0, 100.0);
|
||||||
|
draw_filled_rect(&mut g, 100.0, 0.0, 200.0, 100.0);
|
||||||
|
let live: Vec<_> = g.fills.iter().enumerate().filter(|(_, f)| !f.deleted)
|
||||||
|
.map(|(i, _)| (i, fill_bbox(&g, FillId(i as u32)))).collect();
|
||||||
|
assert_eq!(live.len(), 2, "two adjacent rects should make exactly two fills; got {live:?}");
|
||||||
|
assert_all_fills_connected(&g);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn lasso_strip_through_adjacent_second_rect() {
|
||||||
|
// Two rectangles sharing an edge (snapped adjacent), lasso a strip through the second.
|
||||||
|
let mut g = editor_filled_rect(0.0, 0.0, 100.0, 100.0);
|
||||||
|
draw_filled_rect(&mut g, 100.0, 0.0, 200.0, 100.0); // shares the x=100 edge with the first
|
||||||
|
|
||||||
|
let (segs, lasso_path) = rect_lasso(130.0, -50.0, 170.0, 150.0); // strip through 2nd
|
||||||
|
g.insert_stroke(&segs, None, None, 1.0);
|
||||||
|
g.gc_invisible_edges();
|
||||||
|
assert_all_fills_connected(&g);
|
||||||
|
|
||||||
|
let inside: Vec<FillId> = g.fills.iter().enumerate()
|
||||||
|
.filter(|(_, f)| !f.deleted).map(|(i, _)| FillId(i as u32))
|
||||||
|
.filter(|&fid| lasso_path.winding(fill_centroid(&g, fid)) != 0).collect();
|
||||||
|
let dump: Vec<_> = g.fills.iter().enumerate().filter(|(_, f)| !f.deleted)
|
||||||
|
.map(|(i, _)| (i, fill_centroid(&g, FillId(i as u32)), describe_boundary(&g, FillId(i as u32)).len())).collect();
|
||||||
|
assert_eq!(inside.len(), 1, "one strip of the 2nd rect should be inside; got {dump:#?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn lasso_strip_through_overlapping_second_rect() {
|
||||||
|
// Second rectangle overlaps the first; lasso a strip through the second's free part.
|
||||||
|
let mut g = editor_filled_rect(0.0, 0.0, 100.0, 100.0);
|
||||||
|
draw_filled_rect(&mut g, 60.0, 60.0, 200.0, 160.0);
|
||||||
|
|
||||||
|
let (segs, _lasso) = rect_lasso(120.0, 40.0, 160.0, 180.0);
|
||||||
|
g.insert_stroke(&segs, None, None, 1.0);
|
||||||
|
g.gc_invisible_edges();
|
||||||
|
assert_all_fills_connected(&g);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bounding box of a fill's boundary edge endpoints.
|
||||||
|
fn fill_bbox(g: &VectorGraph, fid: FillId) -> (f64, f64, f64, f64) {
|
||||||
|
let (mut minx, mut miny, mut maxx, mut maxy) = (f64::MAX, f64::MAX, f64::MIN, f64::MIN);
|
||||||
|
for &(eid, _) in &g.fill(fid).boundary {
|
||||||
|
if eid.is_none() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for &vid in &g.edge(eid).vertices {
|
||||||
|
let p = g.vertex(vid).position;
|
||||||
|
minx = minx.min(p.x);
|
||||||
|
miny = miny.min(p.y);
|
||||||
|
maxx = maxx.max(p.x);
|
||||||
|
maxy = maxy.max(p.y);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(minx, miny, maxx, maxy)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn lasso_corner_clip_of_second_rect_splits_off_corner() {
|
||||||
|
// Captured repro (/tmp dump): two separate rects; the lasso clips the top-left CORNER
|
||||||
|
// of the second rect, so the cut path turns a corner at a vertex INTERIOR to the rect.
|
||||||
|
let mut g = editor_filled_rect(128.37, 255.97, 290.91, 336.55); // rect 1
|
||||||
|
draw_filled_rect(&mut g, 417.39, 311.62, 620.25, 442.55); // rect 2
|
||||||
|
let (segs, lasso) = rect_lasso(360.12, 277.92, 537.32, 397.14); // clips rect-2 corner
|
||||||
|
g.insert_stroke(&segs, None, None, 1.0);
|
||||||
|
g.gc_invisible_edges();
|
||||||
|
assert_all_fills_connected(&g);
|
||||||
|
|
||||||
|
let inside: Vec<FillId> = g.fills.iter().enumerate()
|
||||||
|
.filter(|(_, f)| !f.deleted).map(|(i, _)| FillId(i as u32))
|
||||||
|
.filter(|&fid| lasso.winding(fill_centroid(&g, fid)) != 0).collect();
|
||||||
|
let dump: Vec<_> = g.fills.iter().enumerate().filter(|(_, f)| !f.deleted)
|
||||||
|
.map(|(i, _)| (i, fill_centroid(&g, FillId(i as u32)), fill_bbox(&g, FillId(i as u32)), describe_boundary(&g, FillId(i as u32)).len())).collect();
|
||||||
|
assert_eq!(inside.len(), 1, "exactly the clipped corner should be inside; fills = {dump:#?}");
|
||||||
|
|
||||||
|
// The selected fill must be the clipped CORNER, not the whole rect 2.
|
||||||
|
let (minx, miny, maxx, maxy) = fill_bbox(&g, inside[0]);
|
||||||
|
let eps = 1.0;
|
||||||
|
assert!(
|
||||||
|
(minx - 417.39).abs() < eps && (miny - 311.62).abs() < eps
|
||||||
|
&& (maxx - 537.32).abs() < eps && (maxy - 397.14).abs() < eps,
|
||||||
|
"expected clipped-corner bbox (417.4,311.6)-(537.3,397.1), got ({minx:.1},{miny:.1})-({maxx:.1},{maxy:.1}) \
|
||||||
|
— whole rect2 is (417,311)-(620,442); the fill wasn't split"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn lasso_over_middle_of_rect_selects_clean_center_strip() {
|
||||||
|
// Rectangle (0,0)-(200,100); lasso a vertical strip (50,-50)-(150,150).
|
||||||
|
let mut g = editor_filled_rect(0.0, 0.0, 200.0, 100.0);
|
||||||
|
let (segs, lasso_path) = rect_lasso(50.0, -50.0, 150.0, 150.0);
|
||||||
|
|
||||||
|
g.insert_stroke(&segs, None, None, 1.0);
|
||||||
|
// The editor runs this right after the cut: the lasso's top/bottom edges and the
|
||||||
|
// out-of-shape extensions of its sides are invisible and unreferenced — they must be
|
||||||
|
// collected so they can't be selected/edited later.
|
||||||
|
g.gc_invisible_edges();
|
||||||
|
for (i, e) in g.edges.iter().enumerate() {
|
||||||
|
if e.deleted || e.stroke_style.is_some() || e.stroke_color.is_some() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let eid = EdgeId(i as u32);
|
||||||
|
let in_a_fill = g
|
||||||
|
.fills
|
||||||
|
.iter()
|
||||||
|
.any(|f| !f.deleted && f.boundary.iter().any(|&(fe, _)| fe == eid));
|
||||||
|
assert!(
|
||||||
|
in_a_fill,
|
||||||
|
"stray invisible edge {i} ({:?}) survived gc — not part of any fill",
|
||||||
|
e.curve
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Classify fills by centroid winding (the editor's logic).
|
||||||
|
let inside_fills: Vec<FillId> = g
|
||||||
|
.fills
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.filter(|(_, f)| !f.deleted)
|
||||||
|
.map(|(i, _)| FillId(i as u32))
|
||||||
|
.filter(|&fid| lasso_path.winding(fill_centroid(&g, fid)) != 0)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let dump = || {
|
||||||
|
g.fills
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.filter(|(_, f)| !f.deleted)
|
||||||
|
.map(|(i, _)| {
|
||||||
|
let fid = FillId(i as u32);
|
||||||
|
(i, fill_centroid(&g, fid), describe_boundary(&g, fid))
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
};
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
inside_fills.len(),
|
||||||
|
1,
|
||||||
|
"expected exactly one inside fill (the center strip); live fills = {:#?}",
|
||||||
|
dump()
|
||||||
|
);
|
||||||
|
|
||||||
|
let fid = inside_fills[0];
|
||||||
|
let boundary = describe_boundary(&g, fid);
|
||||||
|
|
||||||
|
// The center strip is a rectangle: it must have 4 boundary edges that form a
|
||||||
|
// *connected closed loop* (each edge's directed end == the next edge's directed start).
|
||||||
|
// Before the fix, the split produced a 2-edge boundary (left cut + top segment) whose
|
||||||
|
// bbox is still (50,0)-(150,100) but which `fill_to_bezpath` renders as left+top edges
|
||||||
|
// plus a diagonal close — the artifact the user reported.
|
||||||
|
assert_eq!(
|
||||||
|
boundary.len(),
|
||||||
|
4,
|
||||||
|
"center strip should have 4 boundary edges, got {}: {:#?}",
|
||||||
|
boundary.len(),
|
||||||
|
boundary
|
||||||
|
);
|
||||||
|
|
||||||
|
let eps = 1e-6;
|
||||||
|
let n = boundary.len();
|
||||||
|
for i in 0..n {
|
||||||
|
let (_, _, end) = boundary[i];
|
||||||
|
let (_, next_start, _) = boundary[(i + 1) % n];
|
||||||
|
assert!(
|
||||||
|
(end.x - next_start.x).abs() < eps && (end.y - next_start.y).abs() < eps,
|
||||||
|
"boundary is disconnected between edge {i} (ends {end:?}) and edge {} \
|
||||||
|
(starts {next_start:?}); full boundary = {boundary:#?}",
|
||||||
|
(i + 1) % n
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// And the loop should visit the four expected corners.
|
||||||
|
let corners = [
|
||||||
|
Point::new(50.0, 0.0),
|
||||||
|
Point::new(150.0, 0.0),
|
||||||
|
Point::new(150.0, 100.0),
|
||||||
|
Point::new(50.0, 100.0),
|
||||||
|
];
|
||||||
|
for c in corners {
|
||||||
|
let hit = boundary
|
||||||
|
.iter()
|
||||||
|
.any(|&(_, s, _)| (s.x - c.x).abs() < 0.5 && (s.y - c.y).abs() < 0.5);
|
||||||
|
assert!(hit, "center strip boundary should pass through corner {c:?}: {boundary:#?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -984,8 +984,9 @@ struct EditorApp {
|
||||||
snap_enabled: bool, // Whether to snap to geometry (default: true)
|
snap_enabled: bool, // Whether to snap to geometry (default: true)
|
||||||
paint_bucket_gap_tolerance: f64, // Fill gap tolerance for paint bucket (default: 5.0)
|
paint_bucket_gap_tolerance: f64, // Fill gap tolerance for paint bucket (default: 5.0)
|
||||||
polygon_sides: u32, // Number of sides for polygon tool (default: 5)
|
polygon_sides: u32, // Number of sides for polygon tool (default: 5)
|
||||||
// Region select state
|
/// Undo depth before the current region-select session's first cut (see
|
||||||
region_selection: Option<lightningbeam_core::selection::RegionSelection>,
|
/// `resolve_pending_region_cut`). `None` when no region selection is pending.
|
||||||
|
pending_region_cut_base: Option<usize>,
|
||||||
region_select_mode: lightningbeam_core::tool::RegionSelectMode,
|
region_select_mode: lightningbeam_core::tool::RegionSelectMode,
|
||||||
lasso_mode: lightningbeam_core::tool::LassoMode,
|
lasso_mode: lightningbeam_core::tool::LassoMode,
|
||||||
|
|
||||||
|
|
@ -1303,7 +1304,7 @@ impl EditorApp {
|
||||||
snap_enabled: true, // Default to snapping
|
snap_enabled: true, // Default to snapping
|
||||||
paint_bucket_gap_tolerance: 5.0, // Default gap tolerance
|
paint_bucket_gap_tolerance: 5.0, // Default gap tolerance
|
||||||
polygon_sides: 5, // Default to pentagon
|
polygon_sides: 5, // Default to pentagon
|
||||||
region_selection: None,
|
pending_region_cut_base: None,
|
||||||
region_select_mode: lightningbeam_core::tool::RegionSelectMode::default(),
|
region_select_mode: lightningbeam_core::tool::RegionSelectMode::default(),
|
||||||
lasso_mode: lightningbeam_core::tool::LassoMode::default(),
|
lasso_mode: lightningbeam_core::tool::LassoMode::default(),
|
||||||
input_level: 0.0,
|
input_level: 0.0,
|
||||||
|
|
@ -2629,27 +2630,7 @@ impl EditorApp {
|
||||||
None => return,
|
None => return,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Region selection case: faces are selected but no edges.
|
// Delete the selected edges (region/marquee/click all populate the same sets).
|
||||||
// The inside geometry was already extracted from the live DCEL;
|
|
||||||
// commit the current state (outside + boundary) using the
|
|
||||||
// pre-boundary snapshot as the "before" for undo.
|
|
||||||
if self.selection.selected_edges().is_empty() {
|
|
||||||
if let Some(region_sel) = self.region_selection.take() {
|
|
||||||
// dcel_snapshot = state before boundary was inserted.
|
|
||||||
// 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.
|
|
||||||
// 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_geometry_selection();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Select-tool case: delete the selected edges.
|
|
||||||
let edge_ids: Vec<lightningbeam_core::vector_graph::EdgeId> =
|
let edge_ids: Vec<lightningbeam_core::vector_graph::EdgeId> =
|
||||||
self.selection.selected_edges().iter().copied().collect();
|
self.selection.selected_edges().iter().copied().collect();
|
||||||
|
|
||||||
|
|
@ -3036,38 +3017,31 @@ impl EditorApp {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Revert an uncommitted region selection, restoring original shapes
|
/// Resolve a pending region-select cut once its selection has been cleared.
|
||||||
fn revert_region_selection(
|
///
|
||||||
region_selection: &mut Option<lightningbeam_core::selection::RegionSelection>,
|
/// A region/lasso selection cuts the geometry (an undoable `"Region select"` action)
|
||||||
action_executor: &mut lightningbeam_core::action::ActionExecutor,
|
/// and selects the resulting sub-pieces. When that selection is deselected:
|
||||||
selection: &mut lightningbeam_core::selection::Selection,
|
/// - if nothing was edited in between, the cut(s) are healed (undone) so the lasso
|
||||||
) {
|
/// was non-destructive — the shape returns to whole;
|
||||||
use lightningbeam_core::layer::AnyLayer;
|
/// - if anything was edited, everything is kept (the edits — and the cut they rely
|
||||||
|
/// on — stay on the undo stack), i.e. the change is "merged" in place.
|
||||||
let region_sel = match region_selection.take() {
|
/// We tell the two apart by walking the undo stack down to the recorded base depth and
|
||||||
Some(rs) => rs,
|
/// only undoing while the top action is itself a region-select cut; the first non-cut
|
||||||
None => return,
|
/// action (an edit) stops the heal and leaves everything intact.
|
||||||
};
|
fn resolve_pending_region_cut(&mut self) {
|
||||||
|
let Some(base) = self.pending_region_cut_base else { return };
|
||||||
if region_sel.committed {
|
// Only resolve once the region selection has actually been deselected.
|
||||||
|
if self.selection.has_geometry_selection() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
while self.action_executor.undo_depth() > base {
|
||||||
let doc = action_executor.document_mut();
|
if self.action_executor.undo_description().as_deref() == Some("Region select") {
|
||||||
let layer = match doc.get_layer_mut(®ion_sel.layer_id) {
|
let _ = self.action_executor.undo();
|
||||||
Some(l) => l,
|
} else {
|
||||||
None => return,
|
break; // an edit sits on top → the user changed something; keep it all.
|
||||||
};
|
}
|
||||||
let vector_layer = match layer {
|
}
|
||||||
AnyLayer::Vector(vl) => vl,
|
self.pending_region_cut_base = None;
|
||||||
_ => return,
|
|
||||||
};
|
|
||||||
|
|
||||||
// TODO: DCEL - region selection revert disabled during migration
|
|
||||||
// (was: remove/add_shape_from/to_keyframe for splits)
|
|
||||||
let _ = vector_layer;
|
|
||||||
|
|
||||||
selection.clear();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn handle_menu_action(&mut self, action: MenuAction) {
|
fn handle_menu_action(&mut self, action: MenuAction) {
|
||||||
|
|
@ -6563,7 +6537,7 @@ impl eframe::App for EditorApp {
|
||||||
graph_topology_generation: &mut self.graph_topology_generation,
|
graph_topology_generation: &mut self.graph_topology_generation,
|
||||||
script_to_edit: &mut self.script_to_edit,
|
script_to_edit: &mut self.script_to_edit,
|
||||||
script_saved: &mut self.script_saved,
|
script_saved: &mut self.script_saved,
|
||||||
region_selection: &mut self.region_selection,
|
pending_region_cut_base: &mut self.pending_region_cut_base,
|
||||||
region_select_mode: &mut self.region_select_mode,
|
region_select_mode: &mut self.region_select_mode,
|
||||||
lasso_mode: &mut self.lasso_mode,
|
lasso_mode: &mut self.lasso_mode,
|
||||||
pending_graph_loads: &self.pending_graph_loads,
|
pending_graph_loads: &self.pending_graph_loads,
|
||||||
|
|
@ -7002,21 +6976,21 @@ impl eframe::App for EditorApp {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Escape key: cancel floating raster selection or revert uncommitted region selection
|
// Escape key: cancel floating raster selection, or clear the geometry selection
|
||||||
if !wants_keyboard && ctx.input(|i| self.keymap.action_pressed(keymap::AppAction::CancelAction, i)) {
|
if !wants_keyboard && ctx.input(|i| self.keymap.action_pressed(keymap::AppAction::CancelAction, i)) {
|
||||||
if self.selection.raster_floating.is_some() {
|
if self.selection.raster_floating.is_some() {
|
||||||
self.cancel_raster_floating();
|
self.cancel_raster_floating();
|
||||||
} else if self.selection.raster_selection.is_some() {
|
} else if self.selection.raster_selection.is_some() {
|
||||||
self.selection.raster_selection = None;
|
self.selection.raster_selection = None;
|
||||||
} else if self.region_selection.is_some() {
|
} else if self.selection.has_geometry_selection() {
|
||||||
Self::revert_region_selection(
|
self.selection.clear_geometry_selection();
|
||||||
&mut self.region_selection,
|
|
||||||
&mut self.action_executor,
|
|
||||||
&mut self.selection,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// After all input is processed, if a region selection was deselected without any
|
||||||
|
// intervening edit, heal (undo) its cut so the lasso is non-destructive.
|
||||||
|
self.resolve_pending_region_cut();
|
||||||
|
|
||||||
// F3 debug overlay toggle (works even when text input is active)
|
// F3 debug overlay toggle (works even when text input is active)
|
||||||
if ctx.input(|i| self.keymap.action_pressed(keymap::AppAction::ToggleDebugOverlay, i)) {
|
if ctx.input(|i| self.keymap.action_pressed(keymap::AppAction::ToggleDebugOverlay, i)) {
|
||||||
self.debug_overlay_visible = !self.debug_overlay_visible;
|
self.debug_overlay_visible = !self.debug_overlay_visible;
|
||||||
|
|
|
||||||
|
|
@ -325,8 +325,10 @@ pub struct SharedPaneState<'a> {
|
||||||
pub script_to_edit: &'a mut Option<Uuid>,
|
pub script_to_edit: &'a mut Option<Uuid>,
|
||||||
/// Script ID that was just saved (triggers auto-recompile of nodes using it)
|
/// Script ID that was just saved (triggers auto-recompile of nodes using it)
|
||||||
pub script_saved: &'a mut Option<Uuid>,
|
pub script_saved: &'a mut Option<Uuid>,
|
||||||
/// Active region selection (temporary split state)
|
/// Undo-stack depth recorded before the first region-select cut of the current
|
||||||
pub region_selection: &'a mut Option<lightningbeam_core::selection::RegionSelection>,
|
/// selection session. `Some` while a region selection is live and unchanged; lets a
|
||||||
|
/// later deselect heal (undo) the cut if nothing was edited. See `resolve_pending_region_cut`.
|
||||||
|
pub pending_region_cut_base: &'a mut Option<usize>,
|
||||||
/// Region select mode (Rectangle or Lasso)
|
/// Region select mode (Rectangle or Lasso)
|
||||||
pub region_select_mode: &'a mut lightningbeam_core::tool::RegionSelectMode,
|
pub region_select_mode: &'a mut lightningbeam_core::tool::RegionSelectMode,
|
||||||
/// Lasso select sub-mode (Freehand / Polygonal / Magnetic)
|
/// Lasso select sub-mode (Freehand / Polygonal / Magnetic)
|
||||||
|
|
|
||||||
|
|
@ -472,8 +472,6 @@ struct VelloRenderContext {
|
||||||
editing_instance_id: Option<uuid::Uuid>,
|
editing_instance_id: Option<uuid::Uuid>,
|
||||||
/// The parent layer ID containing the clip instance being edited
|
/// The parent layer ID containing the clip instance being edited
|
||||||
editing_parent_layer_id: Option<uuid::Uuid>,
|
editing_parent_layer_id: Option<uuid::Uuid>,
|
||||||
/// Active region selection state (for rendering boundary overlay)
|
|
||||||
region_selection: Option<lightningbeam_core::selection::RegionSelection>,
|
|
||||||
/// Mouse position in document-local (clip-local) world coordinates, for hover hit testing
|
/// Mouse position in document-local (clip-local) world coordinates, for hover hit testing
|
||||||
mouse_world_pos: Option<vello::kurbo::Point>,
|
mouse_world_pos: Option<vello::kurbo::Point>,
|
||||||
/// Latest webcam frame for live preview (if any camera is active)
|
/// Latest webcam frame for live preview (if any camera is active)
|
||||||
|
|
@ -1901,44 +1899,10 @@ 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_vector_graph(
|
|
||||||
®ion_sel.selected_graph,
|
|
||||||
&mut scene,
|
|
||||||
sel_transform,
|
|
||||||
1.0,
|
|
||||||
&self.ctx.document,
|
|
||||||
&mut image_cache,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
drop(image_cache);
|
drop(image_cache);
|
||||||
scene
|
scene
|
||||||
};
|
};
|
||||||
|
|
||||||
// Render region selection fill into the overlay scene.
|
|
||||||
// In HDR mode the main scene-building block returns an empty scene (only layer content
|
|
||||||
// goes through the HDR pipeline), so we must add the selected-DCEL fill here so it
|
|
||||||
// appears underneath the stipple overlay. In legacy mode the render_dcel call inside
|
|
||||||
// the block already handled this, but running it again is harmless since `scene` would
|
|
||||||
// be a fresh empty scene only in HDR mode.
|
|
||||||
if USE_HDR_COMPOSITING {
|
|
||||||
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_vector_graph(
|
|
||||||
®ion_sel.selected_graph,
|
|
||||||
&mut scene,
|
|
||||||
sel_transform,
|
|
||||||
1.0,
|
|
||||||
&self.ctx.document,
|
|
||||||
&mut image_cache,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Render drag preview objects with transparency
|
// Render drag preview objects with transparency
|
||||||
if let (Some(delta), Some(active_layer_id)) = (self.ctx.drag_delta, self.ctx.active_layer_id) {
|
if let (Some(delta), Some(active_layer_id)) = (self.ctx.drag_delta, self.ctx.active_layer_id) {
|
||||||
if let Some(layer) = self.ctx.document.get_layer(&active_layer_id) {
|
if let Some(layer) = self.ctx.document.get_layer(&active_layer_id) {
|
||||||
|
|
@ -2118,50 +2082,6 @@ impl egui_wgpu::CallbackTrait for VelloCallback {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1a. Draw stipple overlay on region-selected graph
|
|
||||||
if let Some(ref region_sel) = self.ctx.region_selection {
|
|
||||||
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 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,
|
|
||||||
stipple_brush,
|
|
||||||
brush_xform,
|
|
||||||
&path,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stipple edges with visible stroke
|
|
||||||
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()
|
|
||||||
.map(|s| s.width)
|
|
||||||
.unwrap_or(2.0);
|
|
||||||
let mut path = vello::kurbo::BezPath::new();
|
|
||||||
path.move_to(edge.curve.p0);
|
|
||||||
path.curve_to(edge.curve.p1, edge.curve.p2, edge.curve.p3);
|
|
||||||
scene.stroke(
|
|
||||||
&vello::kurbo::Stroke::new(width),
|
|
||||||
sel_transform,
|
|
||||||
stipple_brush,
|
|
||||||
brush_xform,
|
|
||||||
&path,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1b. Draw stipple hover highlight on the curve under the mouse
|
// 1b. Draw stipple hover highlight on the curve under the mouse
|
||||||
// During active curve editing, lock highlight to the edited curve
|
// During active curve editing, lock highlight to the edited curve
|
||||||
if matches!(self.ctx.selected_tool, Tool::Select | Tool::BezierEdit) {
|
if matches!(self.ctx.selected_tool, Tool::Select | Tool::BezierEdit) {
|
||||||
|
|
@ -3738,12 +3658,6 @@ impl StagePane {
|
||||||
None => return, // No active layer
|
None => return, // No active layer
|
||||||
};
|
};
|
||||||
|
|
||||||
// Revert any active region selection on mouse press before borrowing the document
|
|
||||||
// immutably, so the two selection modes don't coexist.
|
|
||||||
if self.rsp_primary_pressed(ui) {
|
|
||||||
Self::revert_region_selection_static(shared);
|
|
||||||
}
|
|
||||||
|
|
||||||
let active_layer = match shared.action_executor.document().get_layer(&active_layer_id) {
|
let active_layer = match shared.action_executor.document().get_layer(&active_layer_id) {
|
||||||
Some(layer) => layer,
|
Some(layer) => layer,
|
||||||
None => return,
|
None => return,
|
||||||
|
|
@ -5076,6 +4990,7 @@ impl StagePane {
|
||||||
_ui: &mut egui::Ui,
|
_ui: &mut egui::Ui,
|
||||||
response: &egui::Response,
|
response: &egui::Response,
|
||||||
world_pos: egui::Vec2,
|
world_pos: egui::Vec2,
|
||||||
|
shift_held: bool,
|
||||||
shared: &mut SharedPaneState,
|
shared: &mut SharedPaneState,
|
||||||
) {
|
) {
|
||||||
use lightningbeam_core::tool::{ToolState, RegionSelectMode};
|
use lightningbeam_core::tool::{ToolState, RegionSelectMode};
|
||||||
|
|
@ -5089,12 +5004,13 @@ impl StagePane {
|
||||||
None => return,
|
None => return,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Mouse down: start region selection
|
// Mouse down: start region selection. Clear the existing selection unless shift
|
||||||
|
// is held (additive), mirroring the marquee. Region select populates the same
|
||||||
|
// `Selection` ID sets as every other tool.
|
||||||
if self.rsp_drag_started(response) {
|
if self.rsp_drag_started(response) {
|
||||||
// Revert any existing uncommitted region selection, and clear the
|
if !shift_held {
|
||||||
// regular selection so both selection modes don't coexist.
|
|
||||||
Self::revert_region_selection_static(shared);
|
|
||||||
shared.selection.clear();
|
shared.selection.clear();
|
||||||
|
}
|
||||||
|
|
||||||
match *shared.region_select_mode {
|
match *shared.region_select_mode {
|
||||||
RegionSelectMode::Rectangle => {
|
RegionSelectMode::Rectangle => {
|
||||||
|
|
@ -5165,7 +5081,10 @@ impl StagePane {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Execute region selection: snapshot DCEL, insert region boundary, extract inside geometry
|
/// Execute region selection (rect or lasso): cut the geometry along the region
|
||||||
|
/// outline (an undoable edit) and select the resulting inside sub-pieces into the
|
||||||
|
/// normal `Selection`. This is the single, unified selection path — there is no
|
||||||
|
/// separate floating "region selection" anymore.
|
||||||
fn execute_region_select(
|
fn execute_region_select(
|
||||||
shared: &mut SharedPaneState,
|
shared: &mut SharedPaneState,
|
||||||
region_path: vello::kurbo::BezPath,
|
region_path: vello::kurbo::BezPath,
|
||||||
|
|
@ -5177,8 +5096,8 @@ impl StagePane {
|
||||||
|
|
||||||
let time = *shared.playback_time;
|
let time = *shared.playback_time;
|
||||||
|
|
||||||
use lightningbeam_core::vector_graph::{EdgeId, FillId, VertexId};
|
use lightningbeam_core::vector_graph::{EdgeId, FillId};
|
||||||
use std::collections::{HashMap, HashSet};
|
use std::collections::HashSet;
|
||||||
use vello::kurbo::{ParamCurve, Shape as _};
|
use vello::kurbo::{ParamCurve, Shape as _};
|
||||||
|
|
||||||
// Convert region path line segments to CubicBez for insert_stroke
|
// Convert region path line segments to CubicBez for insert_stroke
|
||||||
|
|
@ -5219,179 +5138,134 @@ impl StagePane {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Do all graph work in a block so the mutable borrow of shared ends
|
// Cut a copy of the graph along the region outline, then classify which resulting
|
||||||
// before we assign to shared.region_selection.
|
// pieces lie inside. We work on a clone so the cut can be committed as a single
|
||||||
let extraction_result = {
|
// undoable action (or discarded entirely if the region caught nothing).
|
||||||
let document = shared.action_executor.document_mut();
|
let (graph_before, graph_after, inside_edges, inside_fills) = {
|
||||||
let graph = match document.get_layer_mut(&layer_id) {
|
let document = shared.action_executor.document();
|
||||||
Some(AnyLayer::Vector(vl)) => match vl.graph_at_time_mut(time) {
|
let graph = match document.get_layer(&layer_id) {
|
||||||
|
Some(AnyLayer::Vector(vl)) => match vl.graph_at_time(time) {
|
||||||
Some(d) => d,
|
Some(d) => d,
|
||||||
None => return,
|
None => return,
|
||||||
},
|
},
|
||||||
_ => return,
|
_ => return,
|
||||||
};
|
};
|
||||||
|
|
||||||
let snapshot = graph.clone();
|
let graph_before = graph.clone();
|
||||||
|
let mut graph_after = graph_before.clone();
|
||||||
|
|
||||||
// Insert region boundary as invisible edges (no stroke style/color)
|
// Debug capture (set LIGHTNINGBEAM_DUMP_REGION=1): write the exact pre-cut graph
|
||||||
let region_edge_ids = graph.insert_stroke(&segments, None, None, 1.0);
|
// + lasso segments to a numbered JSON file under the temp dir, so a misbehaving
|
||||||
|
// region select can be replayed deterministically as a regression test.
|
||||||
|
if std::env::var_os("LIGHTNINGBEAM_DUMP_REGION").is_some() {
|
||||||
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||||
|
static DUMP_N: AtomicUsize = AtomicUsize::new(0);
|
||||||
|
let n = DUMP_N.fetch_add(1, Ordering::Relaxed);
|
||||||
|
let seg_pts: Vec<[[f64; 2]; 4]> = segments
|
||||||
|
.iter()
|
||||||
|
.map(|c| [[c.p0.x, c.p0.y], [c.p1.x, c.p1.y], [c.p2.x, c.p2.y], [c.p3.x, c.p3.y]])
|
||||||
|
.collect();
|
||||||
|
if let Ok(graph_json) = serde_json::to_value(&graph_before) {
|
||||||
|
let dump = serde_json::json!({ "graph": graph_json, "segments": seg_pts });
|
||||||
|
let path = std::env::temp_dir().join(format!("lightningbeam_region_dump_{n}.json"));
|
||||||
|
match serde_json::to_string_pretty(&dump).map(|s| std::fs::write(&path, s)) {
|
||||||
|
Ok(Ok(())) => eprintln!("[region dump] wrote {}", path.display()),
|
||||||
|
e => eprintln!("[region dump] failed: {e:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert the region boundary as invisible edges (no stroke style/color) so any
|
||||||
|
// shape the region crosses is split into inside/outside sub-fills.
|
||||||
|
let region_edge_ids = graph_after.insert_stroke(&segments, None, None, 1.0);
|
||||||
let region_edge_set: HashSet<EdgeId> = region_edge_ids.iter().copied().collect();
|
let region_edge_set: HashSet<EdgeId> = region_edge_ids.iter().copied().collect();
|
||||||
|
|
||||||
// Classify edges: inside vs outside by midpoint winding
|
// The lasso outline portions that DON'T cross geometry leave dangling invisible
|
||||||
let mut inside_edges = HashSet::new();
|
// edges (no stroke, not part of any fill). Drop them so they can't be selected
|
||||||
for (i, edge) in graph.edges.iter().enumerate() {
|
// or edited later; the cut edges (now part of a fill boundary) are kept.
|
||||||
|
graph_after.gc_invisible_edges();
|
||||||
|
|
||||||
|
// Classify edges: inside by midpoint winding (excluding the cut edges themselves).
|
||||||
|
let mut inside_edges: Vec<EdgeId> = Vec::new();
|
||||||
|
for (i, edge) in graph_after.edges.iter().enumerate() {
|
||||||
let eid = EdgeId(i as u32);
|
let eid = EdgeId(i as u32);
|
||||||
if edge.deleted || region_edge_set.contains(&eid) {
|
if edge.deleted || region_edge_set.contains(&eid) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let mid = edge.curve.eval(0.5);
|
let mid = edge.curve.eval(0.5);
|
||||||
if region_path.winding(mid) != 0 {
|
if region_path.winding(mid) != 0 {
|
||||||
inside_edges.insert(eid);
|
inside_edges.push(eid);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Classify fills: inside vs outside by boundary centroid winding
|
// Classify fills: inside if a guaranteed-interior point of the fill is inside
|
||||||
let mut inside_fills = HashSet::new();
|
// the lasso. (A non-convex sub-fill's edge-midpoint average can fall inside the
|
||||||
for (i, fill) in graph.fills.iter().enumerate() {
|
// lasso even when the shape is mostly outside, so use fill_interior_point.)
|
||||||
|
let mut inside_fills: Vec<FillId> = Vec::new();
|
||||||
|
for (i, fill) in graph_after.fills.iter().enumerate() {
|
||||||
let fid = FillId(i as u32);
|
let fid = FillId(i as u32);
|
||||||
if fill.deleted {
|
if fill.deleted {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let centroid = Self::compute_fill_centroid(graph, fid);
|
let probe = graph_after.fill_interior_point(fid);
|
||||||
if region_path.winding(centroid) != 0 {
|
if region_path.winding(probe) != 0 {
|
||||||
inside_fills.insert(fid);
|
inside_fills.push(fid);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// If nothing is inside, restore snapshot and bail
|
(graph_before, graph_after, inside_edges, inside_fills)
|
||||||
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<VertexId, VertexId> = vtx_map
|
|
||||||
.iter()
|
|
||||||
.filter(|(&old_vid, _)| !graph.vertex(old_vid).deleted)
|
|
||||||
.map(|(&old, &new)| (new, old))
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
let boundary_edge_map: HashMap<EdgeId, EdgeId> = 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 {
|
// Nothing inside: don't mutate the graph (avoid littering it with stray cut
|
||||||
|
// edges), leaving the selection as cleared/extended on drag-start.
|
||||||
|
if inside_edges.is_empty() && inside_fills.is_empty() {
|
||||||
#[cfg(debug_assertions)]
|
#[cfg(debug_assertions)]
|
||||||
shared.test_mode.clear_pending_geometry();
|
shared.test_mode.clear_pending_geometry();
|
||||||
return;
|
return;
|
||||||
};
|
}
|
||||||
|
|
||||||
*shared.region_selection = Some(lightningbeam_core::selection::RegionSelection {
|
// Record the undo depth before the first cut of this selection session, so a
|
||||||
region_path: region_path.clone(),
|
// later deselect can heal (undo) the cut(s) if nothing was changed in between.
|
||||||
|
// A shift-additive series of cuts keeps the same base (it accumulates).
|
||||||
|
if shared.pending_region_cut_base.is_none() {
|
||||||
|
*shared.pending_region_cut_base = Some(shared.action_executor.undo_depth());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Commit the cut as one undoable edit.
|
||||||
|
let action = lightningbeam_core::actions::ModifyGraphAction::new(
|
||||||
layer_id,
|
layer_id,
|
||||||
time,
|
time,
|
||||||
graph_snapshot: snapshot,
|
graph_before,
|
||||||
selected_graph,
|
graph_after,
|
||||||
transform: vello::kurbo::Affine::IDENTITY,
|
"Region select",
|
||||||
committed: false,
|
);
|
||||||
region_edge_ids,
|
if let Err(e) = shared.action_executor.execute(Box::new(action)) {
|
||||||
action_epoch_at_selection: shared.action_executor.epoch(),
|
eprintln!("Region select cut failed: {}", e);
|
||||||
boundary_vertex_map,
|
|
||||||
boundary_edge_map,
|
|
||||||
});
|
|
||||||
|
|
||||||
shared.selection.clear_geometry_selection();
|
|
||||||
|
|
||||||
#[cfg(debug_assertions)]
|
#[cfg(debug_assertions)]
|
||||||
shared.test_mode.clear_pending_geometry();
|
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;
|
|
||||||
|
|
||||||
let region_sel = match shared.region_selection.take() {
|
|
||||||
Some(rs) => rs,
|
|
||||||
None => return,
|
|
||||||
};
|
|
||||||
|
|
||||||
if region_sel.committed {
|
|
||||||
// Already committed via action system, nothing to revert
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let no_actions_taken =
|
// Select the inside sub-pieces from the now-current (post-cut) graph. The fill/edge
|
||||||
shared.action_executor.epoch() == region_sel.action_epoch_at_selection;
|
// ids are stable because the committed graph is exactly `graph_after`.
|
||||||
let no_transform = region_sel.transform == vello::kurbo::Affine::IDENTITY;
|
if let Some(AnyLayer::Vector(vl)) = shared.action_executor.document().get_layer(&layer_id) {
|
||||||
|
if let Some(graph) = vl.graph_at_time(time) {
|
||||||
let doc = shared.action_executor.document_mut();
|
for fid in &inside_fills {
|
||||||
if let Some(AnyLayer::Vector(vl)) = doc.get_layer_mut(®ion_sel.layer_id) {
|
shared.selection.select_fill(*fid, graph);
|
||||||
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 {
|
|
||||||
// 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);
|
|
||||||
}
|
}
|
||||||
}
|
for eid in &inside_edges {
|
||||||
|
shared.selection.select_edge(*eid, graph);
|
||||||
// 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,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Clean up invisible edges left from the boundary
|
|
||||||
graph.gc_invisible_edges();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
shared.selection.clear_geometry_selection();
|
if shared.selection.has_geometry_selection() {
|
||||||
|
*shared.focus =
|
||||||
|
lightningbeam_core::selection::FocusSelection::Geometry { layer_id, time };
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(debug_assertions)]
|
||||||
|
shared.test_mode.clear_pending_geometry();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create a rectangle path centered at origin (easier for curve editing later)
|
/// Create a rectangle path centered at origin (easier for curve editing later)
|
||||||
|
|
@ -10874,7 +10748,7 @@ impl StagePane {
|
||||||
self.handle_eyedropper_tool(ui, &response, mouse_pos, shared);
|
self.handle_eyedropper_tool(ui, &response, mouse_pos, shared);
|
||||||
}
|
}
|
||||||
Tool::RegionSelect => {
|
Tool::RegionSelect => {
|
||||||
self.handle_region_select_tool(ui, &response, world_pos, shared);
|
self.handle_region_select_tool(ui, &response, world_pos, shift_held, shared);
|
||||||
}
|
}
|
||||||
Tool::Warp => {
|
Tool::Warp => {
|
||||||
self.handle_raster_warp_tool(ui, &response, world_pos, shared);
|
self.handle_raster_warp_tool(ui, &response, world_pos, shared);
|
||||||
|
|
@ -12112,7 +11986,6 @@ impl PaneRenderer for StagePane {
|
||||||
editing_clip_id: shared.editing_clip_id,
|
editing_clip_id: shared.editing_clip_id,
|
||||||
editing_instance_id: shared.editing_instance_id,
|
editing_instance_id: shared.editing_instance_id,
|
||||||
editing_parent_layer_id: shared.editing_parent_layer_id,
|
editing_parent_layer_id: shared.editing_parent_layer_id,
|
||||||
region_selection: shared.region_selection.clone(),
|
|
||||||
mouse_world_pos,
|
mouse_world_pos,
|
||||||
webcam_frame: shared.webcam_frame.clone(),
|
webcam_frame: shared.webcam_frame.clone(),
|
||||||
pending_raster_dabs: self.pending_raster_dabs.take(),
|
pending_raster_dabs: self.pending_raster_dabs.take(),
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue