Rebuild DCEL after vector edits
This commit is contained in:
parent
99f8dcfcf4
commit
bcf6277329
|
|
@ -259,7 +259,16 @@ fn dedup_intersections(intersections: &mut Vec<Intersection>, tolerance: f64) {
|
|||
let mut j = i + 1;
|
||||
while j < intersections.len() {
|
||||
let dist = (intersections[i].point - intersections[j].point).hypot();
|
||||
if dist < tolerance {
|
||||
// Also check parameter distance — two intersections at the same
|
||||
// spatial location but with very different t-values are distinct
|
||||
// (e.g. a shared vertex vs. a real crossing nearby).
|
||||
let t1_dist = (intersections[i].t1 - intersections[j].t1).abs();
|
||||
let t2_dist = match (intersections[i].t2, intersections[j].t2) {
|
||||
(Some(a), Some(b)) => (a - b).abs(),
|
||||
_ => 0.0,
|
||||
};
|
||||
let param_close = t1_dist < 0.05 && t2_dist < 0.05;
|
||||
if dist < tolerance && param_close {
|
||||
intersections.remove(j);
|
||||
} else {
|
||||
j += 1;
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
//! maintained such that wherever two strokes intersect there is a vertex.
|
||||
|
||||
use crate::shape::{FillRule, ShapeColor, StrokeStyle};
|
||||
use kurbo::{BezPath, CubicBez, Point};
|
||||
use kurbo::{BezPath, CubicBez, ParamCurveArclen, Point};
|
||||
use rstar::{PointDistance, RTree, RTreeObject, AABB};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fmt;
|
||||
|
|
@ -1036,9 +1036,11 @@ impl Dcel {
|
|||
self.vertices[v1.idx()].outgoing = HalfEdgeId::NONE;
|
||||
self.vertices[v2.idx()].outgoing = HalfEdgeId::NONE;
|
||||
} else if fwd_next == he_bwd {
|
||||
// he_fwd → he_bwd is a spur: bwd_prev → fwd_prev
|
||||
self.half_edges[bwd_prev.idx()].next = bwd_next;
|
||||
self.half_edges[bwd_next.idx()].prev = bwd_prev;
|
||||
// he_fwd → he_bwd is a spur (consecutive in cycle):
|
||||
// ... → fwd_prev → he_fwd → he_bwd → bwd_next → ...
|
||||
// Splice both out: fwd_prev → bwd_next
|
||||
self.half_edges[fwd_prev.idx()].next = bwd_next;
|
||||
self.half_edges[bwd_next.idx()].prev = fwd_prev;
|
||||
// v2 (origin of he_bwd) becomes isolated
|
||||
self.vertices[v2.idx()].outgoing = HalfEdgeId::NONE;
|
||||
// Update v1's outgoing if needed
|
||||
|
|
@ -1046,9 +1048,11 @@ impl Dcel {
|
|||
self.vertices[v1.idx()].outgoing = bwd_next;
|
||||
}
|
||||
} else if bwd_next == he_fwd {
|
||||
// Similar spur in the other direction
|
||||
self.half_edges[fwd_prev.idx()].next = fwd_next;
|
||||
self.half_edges[fwd_next.idx()].prev = fwd_prev;
|
||||
// he_bwd → he_fwd is a spur (consecutive in cycle):
|
||||
// ... → bwd_prev → he_bwd → he_fwd → fwd_next → ...
|
||||
// Splice both out: bwd_prev → fwd_next
|
||||
self.half_edges[bwd_prev.idx()].next = fwd_next;
|
||||
self.half_edges[fwd_next.idx()].prev = bwd_prev;
|
||||
self.vertices[v1.idx()].outgoing = HalfEdgeId::NONE;
|
||||
if self.vertices[v2.idx()].outgoing == he_bwd {
|
||||
self.vertices[v2.idx()].outgoing = fwd_next;
|
||||
|
|
@ -1071,18 +1075,33 @@ impl Dcel {
|
|||
|
||||
// Reassign all half-edges from dying face to surviving face
|
||||
if surviving != dying && !dying.is_none() {
|
||||
// Walk the remaining boundary of the dying face
|
||||
// (After removal, the dying face's half-edges are now part of surviving)
|
||||
if !self.faces[dying.idx()].outer_half_edge.is_none()
|
||||
&& self.faces[dying.idx()].outer_half_edge != he_fwd
|
||||
&& self.faces[dying.idx()].outer_half_edge != he_bwd
|
||||
{
|
||||
let start = self.faces[dying.idx()].outer_half_edge;
|
||||
let mut cur = start;
|
||||
// Find a valid starting half-edge for the walk.
|
||||
// The dying face's outer_half_edge may point to one of the removed half-edges,
|
||||
// so we use a surviving neighbor (fwd_next or bwd_next) that was spliced in.
|
||||
let dying_ohe = self.faces[dying.idx()].outer_half_edge;
|
||||
let walk_start = if dying_ohe.is_none() {
|
||||
HalfEdgeId::NONE
|
||||
} else if dying_ohe != he_fwd && dying_ohe != he_bwd {
|
||||
dying_ohe
|
||||
} else {
|
||||
// The outer_half_edge was removed; use a surviving neighbor instead.
|
||||
// After splicing, fwd_next and bwd_next are the half-edges that replaced
|
||||
// the removed ones in the cycle. Pick one that belongs to dying face.
|
||||
if !fwd_next.is_none() && fwd_next != he_fwd && fwd_next != he_bwd {
|
||||
fwd_next
|
||||
} else if !bwd_next.is_none() && bwd_next != he_fwd && bwd_next != he_bwd {
|
||||
bwd_next
|
||||
} else {
|
||||
HalfEdgeId::NONE
|
||||
}
|
||||
};
|
||||
|
||||
if !walk_start.is_none() {
|
||||
let mut cur = walk_start;
|
||||
loop {
|
||||
self.half_edges[cur.idx()].face = surviving;
|
||||
cur = self.half_edges[cur.idx()].next;
|
||||
if cur == start {
|
||||
if cur == walk_start {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
|
@ -1359,6 +1378,465 @@ impl Dcel {
|
|||
result
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// recompute_edge_intersections: find and split new intersections after edit
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// Recompute intersections between `edge_id` and all other non-deleted edges.
|
||||
///
|
||||
/// After a curve edit, the moved edge may now cross other edges. This method
|
||||
/// finds those intersections and splits both the edited edge and the crossed
|
||||
/// edges at each intersection point (mirroring the logic in `insert_stroke`).
|
||||
///
|
||||
/// Returns a list of `(new_vertex, new_edge)` pairs created by splits.
|
||||
pub fn recompute_edge_intersections(
|
||||
&mut self,
|
||||
edge_id: EdgeId,
|
||||
) -> Vec<(VertexId, EdgeId)> {
|
||||
use crate::curve_intersections::find_curve_intersections;
|
||||
|
||||
let mut created = Vec::new();
|
||||
|
||||
if self.edges[edge_id.idx()].deleted {
|
||||
return created;
|
||||
}
|
||||
|
||||
// Collect intersections between the edited edge and every other edge.
|
||||
struct Hit {
|
||||
t_on_edited: f64,
|
||||
t_on_other: f64,
|
||||
other_edge: EdgeId,
|
||||
}
|
||||
|
||||
let edited_curve = self.edges[edge_id.idx()].curve;
|
||||
let mut hits = Vec::new();
|
||||
|
||||
for (idx, e) in self.edges.iter().enumerate() {
|
||||
if e.deleted {
|
||||
continue;
|
||||
}
|
||||
let other_id = EdgeId(idx as u32);
|
||||
if other_id == edge_id {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Approximate arc lengths for scaling the near-endpoint
|
||||
// threshold to a consistent spatial tolerance (pixels).
|
||||
let edited_len = edited_curve.arclen(0.5).max(1.0);
|
||||
let other_len = e.curve.arclen(0.5).max(1.0);
|
||||
let spatial_tol = 1.0_f64; // pixels
|
||||
let t1_tol = spatial_tol / edited_len;
|
||||
let t2_tol = spatial_tol / other_len;
|
||||
|
||||
let intersections = find_curve_intersections(&edited_curve, &e.curve);
|
||||
for inter in intersections {
|
||||
if let Some(t2) = inter.t2 {
|
||||
// Skip intersections where either t is too close to an
|
||||
// endpoint to produce a usable split. The threshold is
|
||||
// scaled by arc length so it corresponds to a consistent
|
||||
// spatial tolerance. This filters:
|
||||
// - Shared-vertex hits (both t near endpoints)
|
||||
// - Spurious near-vertex bbox-overlap false positives
|
||||
// - Hits that would create one-sided splits
|
||||
if inter.t1 < t1_tol || inter.t1 > 1.0 - t1_tol
|
||||
|| t2 < t2_tol || t2 > 1.0 - t2_tol
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
hits.push(Hit {
|
||||
t_on_edited: inter.t1,
|
||||
t_on_other: t2,
|
||||
other_edge: other_id,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
eprintln!("[DCEL] hits after filtering: {}", hits.len());
|
||||
for h in &hits {
|
||||
eprintln!(
|
||||
"[DCEL] edge {:?} t_edited={:.6} t_other={:.6}",
|
||||
h.other_edge, h.t_on_edited, h.t_on_other
|
||||
);
|
||||
}
|
||||
|
||||
if hits.is_empty() {
|
||||
return created;
|
||||
}
|
||||
|
||||
// Group by other_edge, split each from high-t to low-t to avoid param shift.
|
||||
let mut by_other: std::collections::HashMap<u32, Vec<(f64, f64)>> =
|
||||
std::collections::HashMap::new();
|
||||
for h in &hits {
|
||||
by_other
|
||||
.entry(h.other_edge.0)
|
||||
.or_default()
|
||||
.push((h.t_on_other, h.t_on_edited));
|
||||
}
|
||||
|
||||
// Deduplicate within each group: the recursive intersection finder
|
||||
// often returns many near-identical hits for one crossing. Keep one
|
||||
// representative per cluster (using t_on_other distance < 0.1).
|
||||
for splits in by_other.values_mut() {
|
||||
splits.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap());
|
||||
splits.dedup_by(|a, b| (a.0 - b.0).abs() < 0.1);
|
||||
}
|
||||
|
||||
// Track (t_on_edited, vertex_from_other_edge_split) pairs so we can
|
||||
// later split the edited edge and merge each pair of co-located vertices.
|
||||
let mut edited_edge_splits: Vec<(f64, VertexId)> = Vec::new();
|
||||
|
||||
for (other_raw, mut splits) in by_other {
|
||||
let other_edge = EdgeId(other_raw);
|
||||
// Sort descending by t_on_other
|
||||
splits.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap());
|
||||
|
||||
let current_edge = other_edge;
|
||||
// Upper bound of current_edge in original parameter space.
|
||||
// split_edge(edge, t) keeps [0, t] on current_edge, so after
|
||||
// splitting at t_high the edge spans [0, t_high] (reparam to [0,1]).
|
||||
let mut remaining_t_end = 1.0_f64;
|
||||
|
||||
for (t_on_other, t_on_edited) in splits {
|
||||
let t_in_current = t_on_other / remaining_t_end;
|
||||
|
||||
if t_in_current < 0.001 || t_in_current > 0.999 {
|
||||
continue;
|
||||
}
|
||||
|
||||
let (new_vertex, new_edge) = self.split_edge(current_edge, t_in_current);
|
||||
eprintln!(
|
||||
"[DCEL] split other edge {:?} at t_in_current={:.6} (orig t={:.6}) → vtx {:?} pos={:?}",
|
||||
current_edge, t_in_current, t_on_other, new_vertex,
|
||||
self.vertices[new_vertex.idx()].position
|
||||
);
|
||||
created.push((new_vertex, new_edge));
|
||||
edited_edge_splits.push((t_on_edited, new_vertex));
|
||||
|
||||
// After splitting at t_in_current, current_edge is [0, t_on_other]
|
||||
// in original space. Update remaining_t_end for the next iteration.
|
||||
remaining_t_end = t_on_other;
|
||||
let _ = new_edge;
|
||||
}
|
||||
}
|
||||
|
||||
// Now split the edited edge itself at all intersection t-values.
|
||||
// Sort descending by t to avoid parameter shift.
|
||||
edited_edge_splits.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap());
|
||||
eprintln!("[DCEL] edited_edge_splits (sorted desc): {:?}", edited_edge_splits);
|
||||
// Deduplicate near-equal t values (keep the first = highest t)
|
||||
edited_edge_splits.dedup_by(|a, b| (a.0 - b.0).abs() < 0.001);
|
||||
|
||||
let current_edge = edge_id;
|
||||
let mut remaining_t_end = 1.0_f64;
|
||||
|
||||
// Collect crossing pairs: (vertex_on_edited_edge, vertex_on_other_edge)
|
||||
let mut crossing_pairs: Vec<(VertexId, VertexId)> = Vec::new();
|
||||
|
||||
for (t, other_vertex) in &edited_edge_splits {
|
||||
let t_in_current = *t / remaining_t_end;
|
||||
|
||||
if t_in_current < 0.001 || t_in_current > 0.999 {
|
||||
continue;
|
||||
}
|
||||
|
||||
let (new_vertex, new_edge) = self.split_edge(current_edge, t_in_current);
|
||||
eprintln!(
|
||||
"[DCEL] split edited edge at t_in_current={:.6} (orig t={:.6}) → vtx {:?} pos={:?}, paired with {:?}",
|
||||
t_in_current, t, new_vertex,
|
||||
self.vertices[new_vertex.idx()].position,
|
||||
other_vertex
|
||||
);
|
||||
created.push((new_vertex, new_edge));
|
||||
crossing_pairs.push((new_vertex, *other_vertex));
|
||||
remaining_t_end = *t;
|
||||
let _ = new_edge;
|
||||
}
|
||||
|
||||
// Post-process: merge co-located vertex pairs at each crossing point.
|
||||
// Do all vertex merges first (topology only), then reassign faces once.
|
||||
eprintln!("[DCEL] crossing_pairs: {:?}", crossing_pairs);
|
||||
let has_merges = !crossing_pairs.is_empty();
|
||||
for (v_edited, v_other) in &crossing_pairs {
|
||||
if self.vertices[v_edited.idx()].deleted || self.vertices[v_other.idx()].deleted {
|
||||
eprintln!("[DCEL] SKIP merge {:?} {:?} (deleted)", v_edited, v_other);
|
||||
continue;
|
||||
}
|
||||
eprintln!(
|
||||
"[DCEL] merging {:?} (pos={:?}) with {:?} (pos={:?})",
|
||||
v_edited, self.vertices[v_edited.idx()].position,
|
||||
v_other, self.vertices[v_other.idx()].position,
|
||||
);
|
||||
self.merge_vertices_at_crossing(*v_edited, *v_other);
|
||||
}
|
||||
|
||||
// Now that all merges are done, walk all cycles and assign faces.
|
||||
if has_merges {
|
||||
self.reassign_faces_after_merges();
|
||||
}
|
||||
|
||||
// Dump final state
|
||||
eprintln!("[DCEL] after recompute_edge_intersections:");
|
||||
eprintln!("[DCEL] vertices: {}", self.vertices.iter().filter(|v| !v.deleted).count());
|
||||
eprintln!("[DCEL] edges: {}", self.edges.iter().filter(|e| !e.deleted).count());
|
||||
for (i, f) in self.faces.iter().enumerate() {
|
||||
if !f.deleted {
|
||||
let cycle_len = if !f.outer_half_edge.is_none() {
|
||||
self.walk_cycle(f.outer_half_edge).len()
|
||||
} else { 0 };
|
||||
eprintln!("[DCEL] F{}: outer={:?} cycle_len={}", i, f.outer_half_edge, cycle_len);
|
||||
}
|
||||
}
|
||||
|
||||
created
|
||||
}
|
||||
|
||||
/// Compute the outgoing angle (in radians, via atan2) of a half-edge at its
|
||||
/// origin vertex. Used to sort half-edges CCW around a vertex.
|
||||
fn outgoing_angle(&self, he: HalfEdgeId) -> f64 {
|
||||
let he_data = self.half_edge(he);
|
||||
let edge_data = self.edge(he_data.edge);
|
||||
let is_forward = edge_data.half_edges[0] == he;
|
||||
|
||||
let (from, to, fallback) = if is_forward {
|
||||
// Forward half-edge: direction from curve.p0 → curve.p1 (fallback curve.p3)
|
||||
(edge_data.curve.p0, edge_data.curve.p1, edge_data.curve.p3)
|
||||
} else {
|
||||
// Backward half-edge: direction from curve.p3 → curve.p2 (fallback curve.p0)
|
||||
(edge_data.curve.p3, edge_data.curve.p2, edge_data.curve.p0)
|
||||
};
|
||||
|
||||
let dx = to.x - from.x;
|
||||
let dy = to.y - from.y;
|
||||
if dx * dx + dy * dy > 1e-18 {
|
||||
dy.atan2(dx)
|
||||
} else {
|
||||
// Degenerate: control point coincides with endpoint, use far endpoint
|
||||
let dx = fallback.x - from.x;
|
||||
let dy = fallback.y - from.y;
|
||||
dy.atan2(dx)
|
||||
}
|
||||
}
|
||||
|
||||
/// Merge two co-located vertices at a crossing point and relink half-edges.
|
||||
///
|
||||
/// After `split_edge()` creates two separate vertices at the same crossing,
|
||||
/// this merges them into one, sorts the (now valence-4) outgoing half-edges
|
||||
/// by angle, and relinks `next`/`prev` using the standard DCEL vertex rule.
|
||||
///
|
||||
/// Face assignment is NOT done here — call `reassign_faces_after_merges()`
|
||||
/// once after all merges are complete.
|
||||
fn merge_vertices_at_crossing(
|
||||
&mut self,
|
||||
v_keep: VertexId,
|
||||
v_remove: VertexId,
|
||||
) {
|
||||
// Re-home half-edges from v_remove → v_keep
|
||||
for i in 0..self.half_edges.len() {
|
||||
if self.half_edges[i].deleted {
|
||||
continue;
|
||||
}
|
||||
if self.half_edges[i].origin == v_remove {
|
||||
self.half_edges[i].origin = v_keep;
|
||||
}
|
||||
}
|
||||
|
||||
// Collect & sort outgoing half-edges by angle (CCW).
|
||||
// We can't use vertex_outgoing() because the next/prev links
|
||||
// aren't correct for the merged vertex yet.
|
||||
let mut outgoing: Vec<HalfEdgeId> = Vec::new();
|
||||
for i in 0..self.half_edges.len() {
|
||||
if self.half_edges[i].deleted {
|
||||
continue;
|
||||
}
|
||||
if self.half_edges[i].origin == v_keep {
|
||||
outgoing.push(HalfEdgeId(i as u32));
|
||||
}
|
||||
}
|
||||
outgoing.sort_by(|&a, &b| {
|
||||
let angle_a = self.outgoing_angle(a);
|
||||
let angle_b = self.outgoing_angle(b);
|
||||
angle_a.partial_cmp(&angle_b).unwrap()
|
||||
});
|
||||
|
||||
let n = outgoing.len();
|
||||
if n < 2 {
|
||||
self.vertices[v_keep.idx()].outgoing = if n == 1 {
|
||||
outgoing[0]
|
||||
} else {
|
||||
HalfEdgeId::NONE
|
||||
};
|
||||
self.free_vertex(v_remove);
|
||||
return;
|
||||
}
|
||||
|
||||
// Relink next/prev at vertex using the standard DCEL rule:
|
||||
// twin(outgoing[i]).next = outgoing[(i+1) % N]
|
||||
for i in 0..n {
|
||||
let twin_i = self.half_edges[outgoing[i].idx()].twin;
|
||||
let next_out = outgoing[(i + 1) % n];
|
||||
self.half_edges[twin_i.idx()].next = next_out;
|
||||
self.half_edges[next_out.idx()].prev = twin_i;
|
||||
}
|
||||
|
||||
// Cleanup vertex
|
||||
self.vertices[v_keep.idx()].outgoing = outgoing[0];
|
||||
self.free_vertex(v_remove);
|
||||
}
|
||||
|
||||
/// After merging vertices at crossing points, walk all face cycles and
|
||||
/// reassign faces. This must be called once after all merges are done,
|
||||
/// because individual merges can break cycles created by earlier merges.
|
||||
fn reassign_faces_after_merges(&mut self) {
|
||||
let mut visited = vec![false; self.half_edges.len()];
|
||||
let mut cycles: Vec<(HalfEdgeId, Vec<HalfEdgeId>)> = Vec::new();
|
||||
|
||||
// Discover all face cycles by walking from every unvisited half-edge.
|
||||
for i in 0..self.half_edges.len() {
|
||||
if self.half_edges[i].deleted || visited[i] {
|
||||
continue;
|
||||
}
|
||||
let start_he = HalfEdgeId(i as u32);
|
||||
let mut cycle_hes: Vec<HalfEdgeId> = Vec::new();
|
||||
let mut cur = start_he;
|
||||
loop {
|
||||
if visited[cur.idx()] {
|
||||
break;
|
||||
}
|
||||
visited[cur.idx()] = true;
|
||||
cycle_hes.push(cur);
|
||||
cur = self.half_edges[cur.idx()].next;
|
||||
if cur == start_he {
|
||||
break;
|
||||
}
|
||||
if cycle_hes.len() > self.half_edges.len() {
|
||||
debug_assert!(false, "infinite loop in face reassignment cycle walk");
|
||||
break;
|
||||
}
|
||||
}
|
||||
if !cycle_hes.is_empty() {
|
||||
cycles.push((start_he, cycle_hes));
|
||||
}
|
||||
}
|
||||
|
||||
// Collect old face assignments from half-edges (before reassignment).
|
||||
// Each cycle votes on which old face it belongs to.
|
||||
struct CycleInfo {
|
||||
start_he: HalfEdgeId,
|
||||
half_edges: Vec<HalfEdgeId>,
|
||||
face_votes: std::collections::HashMap<u32, usize>,
|
||||
}
|
||||
let cycle_infos: Vec<CycleInfo> = cycles
|
||||
.into_iter()
|
||||
.map(|(start_he, hes)| {
|
||||
let mut face_votes: std::collections::HashMap<u32, usize> =
|
||||
std::collections::HashMap::new();
|
||||
for &he in &hes {
|
||||
let f = self.half_edges[he.idx()].face;
|
||||
if !f.is_none() {
|
||||
*face_votes.entry(f.0).or_insert(0) += 1;
|
||||
}
|
||||
}
|
||||
CycleInfo {
|
||||
start_he,
|
||||
half_edges: hes,
|
||||
face_votes,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Collect all old faces referenced.
|
||||
let mut all_old_faces: std::collections::HashSet<u32> =
|
||||
std::collections::HashSet::new();
|
||||
for c in &cycle_infos {
|
||||
for &f in c.face_votes.keys() {
|
||||
all_old_faces.insert(f);
|
||||
}
|
||||
}
|
||||
|
||||
// For each old face, assign it to the cycle with the most votes.
|
||||
let mut cycle_face_assignment: Vec<Option<FaceId>> =
|
||||
vec![None; cycle_infos.len()];
|
||||
|
||||
for &old_face_raw in &all_old_faces {
|
||||
let mut best_idx: Option<usize> = None;
|
||||
let mut best_count: usize = 0;
|
||||
for (i, c) in cycle_infos.iter().enumerate() {
|
||||
if cycle_face_assignment[i].is_some() {
|
||||
continue;
|
||||
}
|
||||
let count = c.face_votes.get(&old_face_raw).copied().unwrap_or(0);
|
||||
if count > best_count {
|
||||
best_count = count;
|
||||
best_idx = Some(i);
|
||||
}
|
||||
}
|
||||
if let Some(idx) = best_idx {
|
||||
cycle_face_assignment[idx] = Some(FaceId(old_face_raw));
|
||||
}
|
||||
}
|
||||
|
||||
// Any cycle without an assigned face gets a new one, inheriting
|
||||
// fill properties from the old face it voted for most.
|
||||
for i in 0..cycle_infos.len() {
|
||||
if cycle_face_assignment[i].is_none() {
|
||||
// Determine which face to inherit fill from. Check both
|
||||
// the cycle's own old face votes AND the adjacent faces
|
||||
// (via twin half-edges), because at crossings the inside/
|
||||
// outside flips and the cycle's own votes may point to F0.
|
||||
let mut fill_candidates: std::collections::HashMap<u32, usize> =
|
||||
std::collections::HashMap::new();
|
||||
// Own votes
|
||||
for (&face_raw, &count) in &cycle_infos[i].face_votes {
|
||||
*fill_candidates.entry(face_raw).or_insert(0) += count;
|
||||
}
|
||||
// Adjacent faces (twins)
|
||||
for &he in &cycle_infos[i].half_edges {
|
||||
let twin = self.half_edges[he.idx()].twin;
|
||||
let twin_face = self.half_edges[twin.idx()].face;
|
||||
if !twin_face.is_none() {
|
||||
*fill_candidates.entry(twin_face.0).or_insert(0) += 1;
|
||||
}
|
||||
}
|
||||
// Pick the best non-F0 candidate (F0 is unbounded, no fill).
|
||||
let parent_face = fill_candidates
|
||||
.iter()
|
||||
.filter(|(&face_raw, _)| face_raw != 0)
|
||||
.max_by_key(|&(_, &count)| count)
|
||||
.map(|(&face_raw, _)| FaceId(face_raw));
|
||||
|
||||
let f = self.alloc_face();
|
||||
// Copy fill properties from the parent face.
|
||||
if let Some(parent) = parent_face {
|
||||
self.faces[f.idx()].fill_color =
|
||||
self.faces[parent.idx()].fill_color.clone();
|
||||
self.faces[f.idx()].image_fill =
|
||||
self.faces[parent.idx()].image_fill;
|
||||
self.faces[f.idx()].fill_rule =
|
||||
self.faces[parent.idx()].fill_rule;
|
||||
}
|
||||
cycle_face_assignment[i] = Some(f);
|
||||
}
|
||||
}
|
||||
|
||||
// Apply assignments.
|
||||
for (i, cycle) in cycle_infos.iter().enumerate() {
|
||||
let face = cycle_face_assignment[i].unwrap();
|
||||
for &he in &cycle.half_edges {
|
||||
self.half_edges[he.idx()].face = face;
|
||||
}
|
||||
if face.0 == 0 {
|
||||
self.faces[0]
|
||||
.inner_half_edges
|
||||
.retain(|h| !cycle.half_edges.contains(h));
|
||||
self.faces[0].inner_half_edges.push(cycle.start_he);
|
||||
} else {
|
||||
self.faces[face.idx()].outer_half_edge = cycle.start_he;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Find which face contains a given point (brute force for now).
|
||||
/// Returns FaceId(0) (unbounded) if no bounded face contains the point.
|
||||
fn find_face_containing_point(&self, point: Point) -> FaceId {
|
||||
|
|
@ -1737,4 +2215,151 @@ mod tests {
|
|||
let path = dcel.face_to_bezpath(new_face);
|
||||
assert!(!path.elements().is_empty());
|
||||
}
|
||||
|
||||
/// Rectangle ABCD, drag midpoint of AB across BC creating crossing X.
|
||||
/// Two polygons should result: AXCD and a bigon XB (the "XMB" region).
|
||||
#[test]
|
||||
fn test_crossing_creates_two_faces() {
|
||||
let mut dcel = Dcel::new();
|
||||
|
||||
// Rectangle at pixel scale: A=(0,100), B=(100,100), C=(100,0), D=(0,0)
|
||||
let a = dcel.alloc_vertex(Point::new(0.0, 100.0));
|
||||
let b = dcel.alloc_vertex(Point::new(100.0, 100.0));
|
||||
let c = dcel.alloc_vertex(Point::new(100.0, 0.0));
|
||||
let d = dcel.alloc_vertex(Point::new(0.0, 0.0));
|
||||
|
||||
// Build rectangle edges AB, BC, CD, DA
|
||||
let (e_ab, _) = dcel.insert_edge(
|
||||
a, b, FaceId(0),
|
||||
line_curve(Point::new(0.0, 100.0), Point::new(100.0, 100.0)),
|
||||
);
|
||||
let (e_bc, _) = dcel.insert_edge(
|
||||
b, c, FaceId(0),
|
||||
line_curve(Point::new(100.0, 100.0), Point::new(100.0, 0.0)),
|
||||
);
|
||||
let (e_cd, _) = dcel.insert_edge(
|
||||
c, d, FaceId(0),
|
||||
line_curve(Point::new(100.0, 0.0), Point::new(0.0, 0.0)),
|
||||
);
|
||||
let (e_da, _) = dcel.insert_edge(
|
||||
d, a, FaceId(0),
|
||||
line_curve(Point::new(0.0, 0.0), Point::new(0.0, 100.0)),
|
||||
);
|
||||
|
||||
dcel.validate();
|
||||
|
||||
let faces_before = dcel.faces.iter().filter(|f| !f.deleted).count();
|
||||
|
||||
// Simulate dragging midpoint M of AB to (200, 50).
|
||||
// Control points at (180, 50) and (220, 50) — same as user's
|
||||
// coordinates scaled by 100.
|
||||
let new_ab_curve = CubicBez::new(
|
||||
Point::new(0.0, 100.0),
|
||||
Point::new(180.0, 50.0),
|
||||
Point::new(220.0, 50.0),
|
||||
Point::new(100.0, 100.0),
|
||||
);
|
||||
dcel.edges[e_ab.idx()].curve = new_ab_curve;
|
||||
|
||||
// Recompute intersections — this should split AB and BC at the crossing,
|
||||
// merge the co-located vertices, and create the new face.
|
||||
let created = dcel.recompute_edge_intersections(e_ab);
|
||||
|
||||
// Should have created vertices and edges from the splits
|
||||
assert!(
|
||||
!created.is_empty(),
|
||||
"recompute_edge_intersections should have found the crossing"
|
||||
);
|
||||
|
||||
dcel.validate();
|
||||
|
||||
let faces_after = dcel.faces.iter().filter(|f| !f.deleted).count();
|
||||
assert!(
|
||||
faces_after > faces_before,
|
||||
"a new face should have been created for the XMB region \
|
||||
(before: {}, after: {})",
|
||||
faces_before,
|
||||
faces_after
|
||||
);
|
||||
|
||||
let _ = (e_bc, e_cd, e_da);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_two_crossings_creates_three_faces() {
|
||||
let mut dcel = Dcel::new();
|
||||
|
||||
// Rectangle at pixel scale: A=(0,100), B=(100,100), C=(100,0), D=(0,0)
|
||||
let a = dcel.alloc_vertex(Point::new(0.0, 100.0));
|
||||
let b = dcel.alloc_vertex(Point::new(100.0, 100.0));
|
||||
let c = dcel.alloc_vertex(Point::new(100.0, 0.0));
|
||||
let d = dcel.alloc_vertex(Point::new(0.0, 0.0));
|
||||
|
||||
let (e_ab, _) = dcel.insert_edge(
|
||||
a, b, FaceId(0),
|
||||
line_curve(Point::new(0.0, 100.0), Point::new(100.0, 100.0)),
|
||||
);
|
||||
let (e_bc, _) = dcel.insert_edge(
|
||||
b, c, FaceId(0),
|
||||
line_curve(Point::new(100.0, 100.0), Point::new(100.0, 0.0)),
|
||||
);
|
||||
let (e_cd, _) = dcel.insert_edge(
|
||||
c, d, FaceId(0),
|
||||
line_curve(Point::new(100.0, 0.0), Point::new(0.0, 0.0)),
|
||||
);
|
||||
let (e_da, _) = dcel.insert_edge(
|
||||
d, a, FaceId(0),
|
||||
line_curve(Point::new(0.0, 0.0), Point::new(0.0, 100.0)),
|
||||
);
|
||||
|
||||
dcel.validate();
|
||||
let faces_before = dcel.faces.iter().filter(|f| !f.deleted).count();
|
||||
|
||||
// Drag M through CD: curve from A to B that dips below y=0,
|
||||
// crossing CD (y=0 line) twice.
|
||||
let new_ab_curve = CubicBez::new(
|
||||
Point::new(0.0, 100.0),
|
||||
Point::new(30.0, -80.0),
|
||||
Point::new(70.0, -80.0),
|
||||
Point::new(100.0, 100.0),
|
||||
);
|
||||
dcel.edges[e_ab.idx()].curve = new_ab_curve;
|
||||
|
||||
let created = dcel.recompute_edge_intersections(e_ab);
|
||||
|
||||
eprintln!("created: {:?}", created);
|
||||
eprintln!("vertices: {}", dcel.vertices.iter().filter(|v| !v.deleted).count());
|
||||
eprintln!("edges: {}", dcel.edges.iter().filter(|e| !e.deleted).count());
|
||||
eprintln!("faces (non-deleted):");
|
||||
for (i, f) in dcel.faces.iter().enumerate() {
|
||||
if !f.deleted {
|
||||
let cycle_len = if !f.outer_half_edge.is_none() {
|
||||
dcel.walk_cycle(f.outer_half_edge).len()
|
||||
} else {
|
||||
0
|
||||
};
|
||||
eprintln!(" F{}: outer={:?} cycle_len={}", i, f.outer_half_edge, cycle_len);
|
||||
}
|
||||
}
|
||||
|
||||
// Should have 4 splits (2 on CD, 2 on AB)
|
||||
assert!(
|
||||
created.len() >= 4,
|
||||
"expected at least 4 splits, got {}",
|
||||
created.len()
|
||||
);
|
||||
|
||||
dcel.validate();
|
||||
|
||||
let faces_after = dcel.faces.iter().filter(|f| !f.deleted).count();
|
||||
// Before: 2 faces (interior + exterior). After: 4 (AX1D, X1X2M, X2BC + exterior)
|
||||
assert!(
|
||||
faces_after >= faces_before + 2,
|
||||
"should have at least 2 new faces (before: {}, after: {})",
|
||||
faces_before,
|
||||
faces_after
|
||||
);
|
||||
|
||||
let _ = (e_bc, e_cd, e_da);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
//! Hit testing for selection and interaction
|
||||
//!
|
||||
//! Provides functions for testing if points or rectangles intersect with
|
||||
//! shapes and objects, taking into account transform hierarchies.
|
||||
//! DCEL elements and clip instances, taking into account transform hierarchies.
|
||||
|
||||
use crate::clip::ClipInstance;
|
||||
use crate::dcel::{VertexId, EdgeId, FaceId};
|
||||
use crate::layer::VectorLayer;
|
||||
use crate::shape::Shape; // TODO: remove after DCEL migration complete
|
||||
use crate::shape::Shape;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
use vello::kurbo::{Affine, BezPath, Point, Rect, Shape as KurboShape};
|
||||
|
|
@ -14,15 +14,25 @@ use vello::kurbo::{Affine, BezPath, Point, Rect, Shape as KurboShape};
|
|||
/// Result of a hit test operation
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum HitResult {
|
||||
/// Hit a shape instance
|
||||
ShapeInstance(Uuid),
|
||||
/// Hit a DCEL edge (stroke)
|
||||
Edge(EdgeId),
|
||||
/// Hit a DCEL face (fill)
|
||||
Face(FaceId),
|
||||
/// Hit a clip instance
|
||||
ClipInstance(Uuid),
|
||||
}
|
||||
|
||||
/// Hit test a layer at a specific point
|
||||
/// Result of a DCEL-only hit test (no clip instances)
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum DcelHitResult {
|
||||
Edge(EdgeId),
|
||||
Face(FaceId),
|
||||
}
|
||||
|
||||
/// Hit test a layer at a specific point, returning edge or face hits.
|
||||
///
|
||||
/// Tests shapes in the active keyframe in reverse order (front to back) and returns the first hit.
|
||||
/// Tests DCEL edges (strokes) and faces (fills) in the active keyframe.
|
||||
/// Edge hits take priority over face hits.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
|
|
@ -34,15 +44,69 @@ pub enum HitResult {
|
|||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The UUID of the first shape hit, or None if no hit
|
||||
/// The first DCEL element hit, or None if no hit
|
||||
pub fn hit_test_layer(
|
||||
_layer: &VectorLayer,
|
||||
_time: f64,
|
||||
_point: Point,
|
||||
_tolerance: f64,
|
||||
_parent_transform: Affine,
|
||||
) -> Option<Uuid> {
|
||||
// TODO: Implement DCEL-based hit testing (faces, edges, vertices)
|
||||
layer: &VectorLayer,
|
||||
time: f64,
|
||||
point: Point,
|
||||
tolerance: f64,
|
||||
parent_transform: Affine,
|
||||
) -> Option<DcelHitResult> {
|
||||
let dcel = layer.dcel_at_time(time)?;
|
||||
|
||||
// Transform point to local space
|
||||
let local_point = parent_transform.inverse() * point;
|
||||
|
||||
// 1. Check edges (strokes) — priority over faces
|
||||
let mut best_edge: Option<(EdgeId, f64)> = None;
|
||||
for (i, edge) in dcel.edges.iter().enumerate() {
|
||||
if edge.deleted {
|
||||
continue;
|
||||
}
|
||||
// Only hit-test edges that have a visible stroke
|
||||
if edge.stroke_color.is_none() && edge.stroke_style.is_none() {
|
||||
continue;
|
||||
}
|
||||
|
||||
use kurbo::ParamCurveNearest;
|
||||
let nearest = edge.curve.nearest(local_point, 0.5);
|
||||
let dist = nearest.distance_sq.sqrt();
|
||||
|
||||
let hit_radius = edge
|
||||
.stroke_style
|
||||
.as_ref()
|
||||
.map(|s| s.width / 2.0)
|
||||
.unwrap_or(0.0)
|
||||
+ tolerance;
|
||||
|
||||
if dist < hit_radius {
|
||||
if best_edge.is_none() || dist < best_edge.unwrap().1 {
|
||||
best_edge = Some((EdgeId(i as u32), dist));
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some((edge_id, _)) = best_edge {
|
||||
return Some(DcelHitResult::Edge(edge_id));
|
||||
}
|
||||
|
||||
// 2. Check faces (fills)
|
||||
for (i, face) in dcel.faces.iter().enumerate() {
|
||||
if face.deleted || i == 0 {
|
||||
continue; // skip unbounded face
|
||||
}
|
||||
if face.fill_color.is_none() && face.image_fill.is_none() {
|
||||
continue;
|
||||
}
|
||||
if face.outer_half_edge.is_none() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let path = dcel.face_to_bezpath(FaceId(i as u32));
|
||||
if path.winding(local_point) != 0 {
|
||||
return Some(DcelHitResult::Face(FaceId(i as u32)));
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
|
|
@ -83,17 +147,73 @@ pub fn hit_test_shape(
|
|||
false
|
||||
}
|
||||
|
||||
/// Hit test objects within a rectangle (for marquee selection)
|
||||
/// Result of DCEL marquee selection
|
||||
#[derive(Debug, Default)]
|
||||
pub struct DcelMarqueeResult {
|
||||
pub edges: Vec<EdgeId>,
|
||||
pub faces: Vec<FaceId>,
|
||||
}
|
||||
|
||||
/// Hit test DCEL elements within a rectangle (for marquee selection).
|
||||
///
|
||||
/// Returns all shapes in the active keyframe whose bounding boxes intersect with the given rectangle.
|
||||
pub fn hit_test_objects_in_rect(
|
||||
_layer: &VectorLayer,
|
||||
_time: f64,
|
||||
_rect: Rect,
|
||||
_parent_transform: Affine,
|
||||
) -> Vec<Uuid> {
|
||||
// TODO: Implement DCEL-based marquee selection
|
||||
Vec::new()
|
||||
/// Selects edges whose both endpoints are inside the rect,
|
||||
/// and faces whose all boundary vertices are inside the rect.
|
||||
pub fn hit_test_dcel_in_rect(
|
||||
layer: &VectorLayer,
|
||||
time: f64,
|
||||
rect: Rect,
|
||||
parent_transform: Affine,
|
||||
) -> DcelMarqueeResult {
|
||||
let mut result = DcelMarqueeResult::default();
|
||||
|
||||
let dcel = match layer.dcel_at_time(time) {
|
||||
Some(d) => d,
|
||||
None => return result,
|
||||
};
|
||||
|
||||
let inv = parent_transform.inverse();
|
||||
let local_rect = inv.transform_rect_bbox(rect);
|
||||
|
||||
// Check edges: both endpoints inside rect
|
||||
for (i, edge) in dcel.edges.iter().enumerate() {
|
||||
if edge.deleted {
|
||||
continue;
|
||||
}
|
||||
let [he_fwd, he_bwd] = edge.half_edges;
|
||||
if he_fwd.is_none() || he_bwd.is_none() {
|
||||
continue;
|
||||
}
|
||||
let v1 = dcel.half_edge(he_fwd).origin;
|
||||
let v2 = dcel.half_edge(he_bwd).origin;
|
||||
if v1.is_none() || v2.is_none() {
|
||||
continue;
|
||||
}
|
||||
let p1 = dcel.vertex(v1).position;
|
||||
let p2 = dcel.vertex(v2).position;
|
||||
if local_rect.contains(p1) && local_rect.contains(p2) {
|
||||
result.edges.push(EdgeId(i as u32));
|
||||
}
|
||||
}
|
||||
|
||||
// Check faces: all boundary vertices inside rect
|
||||
for (i, face) in dcel.faces.iter().enumerate() {
|
||||
if face.deleted || i == 0 {
|
||||
continue;
|
||||
}
|
||||
if face.outer_half_edge.is_none() {
|
||||
continue;
|
||||
}
|
||||
let boundary = dcel.face_boundary(FaceId(i as u32));
|
||||
let all_inside = boundary.iter().all(|&he_id| {
|
||||
let v = dcel.half_edge(he_id).origin;
|
||||
!v.is_none() && local_rect.contains(dcel.vertex(v).position)
|
||||
});
|
||||
if all_inside && !boundary.is_empty() {
|
||||
result.faces.push(FaceId(i as u32));
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// Classification of shapes relative to a clipping region
|
||||
|
|
@ -316,7 +436,7 @@ pub fn hit_test_vector_editing(
|
|||
// Transform point into layer-local space
|
||||
let local_point = parent_transform.inverse() * point;
|
||||
|
||||
// Priority: ControlPoint > Vertex > Curve
|
||||
// Priority: ControlPoint > Vertex > Curve > Fill
|
||||
|
||||
// 1. Control points (only when show_control_points is true, e.g. BezierEdit tool)
|
||||
if show_control_points {
|
||||
|
|
@ -381,7 +501,23 @@ pub fn hit_test_vector_editing(
|
|||
return Some(VectorEditHit::Curve { edge_id, parameter_t });
|
||||
}
|
||||
|
||||
// 4. Face hit testing skipped for now
|
||||
// 4. Face fill testing
|
||||
for (i, face) in dcel.faces.iter().enumerate() {
|
||||
if face.deleted || i == 0 {
|
||||
continue;
|
||||
}
|
||||
if face.fill_color.is_none() && face.image_fill.is_none() {
|
||||
continue;
|
||||
}
|
||||
if face.outer_half_edge.is_none() {
|
||||
continue;
|
||||
}
|
||||
let path = dcel.face_to_bezpath(FaceId(i as u32));
|
||||
if path.winding(local_point) != 0 {
|
||||
return Some(VectorEditHit::Fill { face_id: FaceId(i as u32) });
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,24 +1,28 @@
|
|||
//! Selection state management
|
||||
//!
|
||||
//! Tracks selected shape instances, clip instances, and shapes for editing operations.
|
||||
//! Tracks selected DCEL elements (edges, faces, vertices) and clip instances for editing operations.
|
||||
|
||||
use crate::shape::Shape;
|
||||
use crate::dcel::{Dcel, EdgeId, FaceId, VertexId};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashSet;
|
||||
use uuid::Uuid;
|
||||
use vello::kurbo::BezPath;
|
||||
|
||||
/// Selection state for the editor
|
||||
///
|
||||
/// Maintains sets of selected shape instances, clip instances, and shapes.
|
||||
/// This is separate from the document to make it easy to
|
||||
/// pass around for UI rendering without needing mutable access.
|
||||
/// Maintains sets of selected DCEL elements and clip instances.
|
||||
/// The vertex/edge/face sets implicitly represent a subgraph of the DCEL —
|
||||
/// connectivity is determined by shared vertices between edges.
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
||||
pub struct Selection {
|
||||
/// Currently selected shape instances
|
||||
selected_shape_instances: Vec<Uuid>,
|
||||
/// Currently selected vertices
|
||||
selected_vertices: HashSet<VertexId>,
|
||||
|
||||
/// Currently selected shapes (definitions)
|
||||
selected_shapes: Vec<Uuid>,
|
||||
/// Currently selected edges
|
||||
selected_edges: HashSet<EdgeId>,
|
||||
|
||||
/// Currently selected faces
|
||||
selected_faces: HashSet<FaceId>,
|
||||
|
||||
/// Currently selected clip instances
|
||||
selected_clip_instances: Vec<Uuid>,
|
||||
|
|
@ -28,54 +32,168 @@ impl Selection {
|
|||
/// Create a new empty selection
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
selected_shape_instances: Vec::new(),
|
||||
selected_shapes: Vec::new(),
|
||||
selected_vertices: HashSet::new(),
|
||||
selected_edges: HashSet::new(),
|
||||
selected_faces: HashSet::new(),
|
||||
selected_clip_instances: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Add a shape instance to the selection
|
||||
pub fn add_shape_instance(&mut self, id: Uuid) {
|
||||
if !self.selected_shape_instances.contains(&id) {
|
||||
self.selected_shape_instances.push(id);
|
||||
// -----------------------------------------------------------------------
|
||||
// DCEL element selection
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// Select an edge and its endpoint vertices, forming/extending a subgraph.
|
||||
pub fn select_edge(&mut self, edge_id: EdgeId, dcel: &Dcel) {
|
||||
if edge_id.is_none() || dcel.edge(edge_id).deleted {
|
||||
return;
|
||||
}
|
||||
self.selected_edges.insert(edge_id);
|
||||
|
||||
// Add both endpoint vertices
|
||||
let [he_fwd, he_bwd] = dcel.edge(edge_id).half_edges;
|
||||
if !he_fwd.is_none() {
|
||||
let v = dcel.half_edge(he_fwd).origin;
|
||||
if !v.is_none() {
|
||||
self.selected_vertices.insert(v);
|
||||
}
|
||||
}
|
||||
if !he_bwd.is_none() {
|
||||
let v = dcel.half_edge(he_bwd).origin;
|
||||
if !v.is_none() {
|
||||
self.selected_vertices.insert(v);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Add a shape definition to the selection
|
||||
pub fn add_shape(&mut self, id: Uuid) {
|
||||
if !self.selected_shapes.contains(&id) {
|
||||
self.selected_shapes.push(id);
|
||||
/// Select a face and all its boundary edges + vertices.
|
||||
pub fn select_face(&mut self, face_id: FaceId, dcel: &Dcel) {
|
||||
if face_id.is_none() || face_id.0 == 0 || dcel.face(face_id).deleted {
|
||||
return;
|
||||
}
|
||||
self.selected_faces.insert(face_id);
|
||||
|
||||
// Add all boundary edges and vertices
|
||||
let boundary = dcel.face_boundary(face_id);
|
||||
for he_id in boundary {
|
||||
let he = dcel.half_edge(he_id);
|
||||
let edge_id = he.edge;
|
||||
if !edge_id.is_none() {
|
||||
self.selected_edges.insert(edge_id);
|
||||
// Add endpoints
|
||||
let [he_fwd, he_bwd] = dcel.edge(edge_id).half_edges;
|
||||
if !he_fwd.is_none() {
|
||||
let v = dcel.half_edge(he_fwd).origin;
|
||||
if !v.is_none() {
|
||||
self.selected_vertices.insert(v);
|
||||
}
|
||||
}
|
||||
if !he_bwd.is_none() {
|
||||
let v = dcel.half_edge(he_bwd).origin;
|
||||
if !v.is_none() {
|
||||
self.selected_vertices.insert(v);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove a shape instance from the selection
|
||||
pub fn remove_shape_instance(&mut self, id: &Uuid) {
|
||||
self.selected_shape_instances.retain(|&x| x != *id);
|
||||
/// Deselect an edge and its vertices (if they have no other selected edges).
|
||||
pub fn deselect_edge(&mut self, edge_id: EdgeId, dcel: &Dcel) {
|
||||
self.selected_edges.remove(&edge_id);
|
||||
|
||||
// Remove endpoint vertices only if they're not used by other selected edges
|
||||
let [he_fwd, he_bwd] = dcel.edge(edge_id).half_edges;
|
||||
for he_id in [he_fwd, he_bwd] {
|
||||
if he_id.is_none() {
|
||||
continue;
|
||||
}
|
||||
let v = dcel.half_edge(he_id).origin;
|
||||
if v.is_none() {
|
||||
continue;
|
||||
}
|
||||
// Check if any other selected edge uses this vertex
|
||||
let used = self.selected_edges.iter().any(|&eid| {
|
||||
let e = dcel.edge(eid);
|
||||
let [a, b] = e.half_edges;
|
||||
(!a.is_none() && dcel.half_edge(a).origin == v)
|
||||
|| (!b.is_none() && dcel.half_edge(b).origin == v)
|
||||
});
|
||||
if !used {
|
||||
self.selected_vertices.remove(&v);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove a shape definition from the selection
|
||||
pub fn remove_shape(&mut self, id: &Uuid) {
|
||||
self.selected_shapes.retain(|&x| x != *id);
|
||||
/// Deselect a face (edges/vertices stay if still referenced by other selections).
|
||||
pub fn deselect_face(&mut self, face_id: FaceId) {
|
||||
self.selected_faces.remove(&face_id);
|
||||
}
|
||||
|
||||
/// Toggle a shape instance's selection state
|
||||
pub fn toggle_shape_instance(&mut self, id: Uuid) {
|
||||
if self.contains_shape_instance(&id) {
|
||||
self.remove_shape_instance(&id);
|
||||
/// Toggle an edge's selection state.
|
||||
pub fn toggle_edge(&mut self, edge_id: EdgeId, dcel: &Dcel) {
|
||||
if self.selected_edges.contains(&edge_id) {
|
||||
self.deselect_edge(edge_id, dcel);
|
||||
} else {
|
||||
self.add_shape_instance(id);
|
||||
self.select_edge(edge_id, dcel);
|
||||
}
|
||||
}
|
||||
|
||||
/// Toggle a shape's selection state
|
||||
pub fn toggle_shape(&mut self, id: Uuid) {
|
||||
if self.contains_shape(&id) {
|
||||
self.remove_shape(&id);
|
||||
/// Toggle a face's selection state.
|
||||
pub fn toggle_face(&mut self, face_id: FaceId, dcel: &Dcel) {
|
||||
if self.selected_faces.contains(&face_id) {
|
||||
self.deselect_face(face_id);
|
||||
} else {
|
||||
self.add_shape(id);
|
||||
self.select_face(face_id, dcel);
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if an edge is selected.
|
||||
pub fn contains_edge(&self, edge_id: &EdgeId) -> bool {
|
||||
self.selected_edges.contains(edge_id)
|
||||
}
|
||||
|
||||
/// Check if a face is selected.
|
||||
pub fn contains_face(&self, face_id: &FaceId) -> bool {
|
||||
self.selected_faces.contains(face_id)
|
||||
}
|
||||
|
||||
/// Check if a vertex is selected.
|
||||
pub fn contains_vertex(&self, vertex_id: &VertexId) -> bool {
|
||||
self.selected_vertices.contains(vertex_id)
|
||||
}
|
||||
|
||||
/// Clear DCEL element selections (edges, faces, vertices).
|
||||
pub fn clear_dcel_selection(&mut self) {
|
||||
self.selected_vertices.clear();
|
||||
self.selected_edges.clear();
|
||||
self.selected_faces.clear();
|
||||
}
|
||||
|
||||
/// Check if any DCEL elements are selected.
|
||||
pub fn has_dcel_selection(&self) -> bool {
|
||||
!self.selected_edges.is_empty() || !self.selected_faces.is_empty()
|
||||
}
|
||||
|
||||
/// Get selected edges.
|
||||
pub fn selected_edges(&self) -> &HashSet<EdgeId> {
|
||||
&self.selected_edges
|
||||
}
|
||||
|
||||
/// Get selected faces.
|
||||
pub fn selected_faces(&self) -> &HashSet<FaceId> {
|
||||
&self.selected_faces
|
||||
}
|
||||
|
||||
/// Get selected vertices.
|
||||
pub fn selected_vertices(&self) -> &HashSet<VertexId> {
|
||||
&self.selected_vertices
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Clip instance selection (unchanged)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// Add a clip instance to the selection
|
||||
pub fn add_clip_instance(&mut self, id: Uuid) {
|
||||
if !self.selected_clip_instances.contains(&id) {
|
||||
|
|
@ -97,68 +215,14 @@ impl Selection {
|
|||
}
|
||||
}
|
||||
|
||||
/// Clear all selections
|
||||
pub fn clear(&mut self) {
|
||||
self.selected_shape_instances.clear();
|
||||
self.selected_shapes.clear();
|
||||
self.selected_clip_instances.clear();
|
||||
}
|
||||
|
||||
/// Clear only object selections
|
||||
pub fn clear_shape_instances(&mut self) {
|
||||
self.selected_shape_instances.clear();
|
||||
}
|
||||
|
||||
/// Clear only shape selections
|
||||
pub fn clear_shapes(&mut self) {
|
||||
self.selected_shapes.clear();
|
||||
}
|
||||
|
||||
/// Clear only clip instance selections
|
||||
pub fn clear_clip_instances(&mut self) {
|
||||
self.selected_clip_instances.clear();
|
||||
}
|
||||
|
||||
/// Check if an object is selected
|
||||
pub fn contains_shape_instance(&self, id: &Uuid) -> bool {
|
||||
self.selected_shape_instances.contains(id)
|
||||
}
|
||||
|
||||
/// Check if a shape is selected
|
||||
pub fn contains_shape(&self, id: &Uuid) -> bool {
|
||||
self.selected_shapes.contains(id)
|
||||
}
|
||||
|
||||
/// Check if a clip instance is selected
|
||||
pub fn contains_clip_instance(&self, id: &Uuid) -> bool {
|
||||
self.selected_clip_instances.contains(id)
|
||||
}
|
||||
|
||||
/// Check if selection is empty
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.selected_shape_instances.is_empty()
|
||||
&& self.selected_shapes.is_empty()
|
||||
&& self.selected_clip_instances.is_empty()
|
||||
}
|
||||
|
||||
/// Get the selected objects
|
||||
pub fn shape_instances(&self) -> &[Uuid] {
|
||||
&self.selected_shape_instances
|
||||
}
|
||||
|
||||
/// Get the selected shapes
|
||||
pub fn shapes(&self) -> &[Uuid] {
|
||||
&self.selected_shapes
|
||||
}
|
||||
|
||||
/// Get the number of selected objects
|
||||
pub fn shape_instance_count(&self) -> usize {
|
||||
self.selected_shape_instances.len()
|
||||
}
|
||||
|
||||
/// Get the number of selected shapes
|
||||
pub fn shape_count(&self) -> usize {
|
||||
self.selected_shapes.len()
|
||||
/// Clear only clip instance selections
|
||||
pub fn clear_clip_instances(&mut self) {
|
||||
self.selected_clip_instances.clear();
|
||||
}
|
||||
|
||||
/// Get the selected clip instances
|
||||
|
|
@ -171,86 +235,61 @@ impl Selection {
|
|||
self.selected_clip_instances.len()
|
||||
}
|
||||
|
||||
/// Set selection to a single object (clears previous selection)
|
||||
pub fn select_only_shape_instance(&mut self, id: Uuid) {
|
||||
self.clear();
|
||||
self.add_shape_instance(id);
|
||||
}
|
||||
|
||||
/// Set selection to a single shape (clears previous selection)
|
||||
pub fn select_only_shape(&mut self, id: Uuid) {
|
||||
self.clear();
|
||||
self.add_shape(id);
|
||||
}
|
||||
|
||||
/// Set selection to a single clip instance (clears previous selection)
|
||||
pub fn select_only_clip_instance(&mut self, id: Uuid) {
|
||||
self.clear();
|
||||
self.add_clip_instance(id);
|
||||
}
|
||||
|
||||
/// Set selection to multiple objects (clears previous selection)
|
||||
pub fn select_shape_instances(&mut self, ids: &[Uuid]) {
|
||||
self.clear_shape_instances();
|
||||
for &id in ids {
|
||||
self.add_shape_instance(id);
|
||||
}
|
||||
}
|
||||
|
||||
/// Set selection to multiple shapes (clears previous selection)
|
||||
pub fn select_shapes(&mut self, ids: &[Uuid]) {
|
||||
self.clear_shapes();
|
||||
for &id in ids {
|
||||
self.add_shape(id);
|
||||
}
|
||||
}
|
||||
|
||||
/// Set selection to multiple clip instances (clears previous selection)
|
||||
/// Set selection to multiple clip instances (clears previous clip selection)
|
||||
pub fn select_clip_instances(&mut self, ids: &[Uuid]) {
|
||||
self.clear_clip_instances();
|
||||
for &id in ids {
|
||||
self.add_clip_instance(id);
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// General
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// Clear all selections
|
||||
pub fn clear(&mut self) {
|
||||
self.selected_vertices.clear();
|
||||
self.selected_edges.clear();
|
||||
self.selected_faces.clear();
|
||||
self.selected_clip_instances.clear();
|
||||
}
|
||||
|
||||
/// Represents a temporary region-based split of shapes.
|
||||
/// Check if selection is empty
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.selected_edges.is_empty()
|
||||
&& self.selected_faces.is_empty()
|
||||
&& self.selected_clip_instances.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// Represents a temporary region-based selection.
|
||||
///
|
||||
/// When a region select is active, shapes that cross the region boundary
|
||||
/// are temporarily split into "inside" and "outside" parts. The inside
|
||||
/// parts are selected. If the user performs an operation, the split is
|
||||
/// committed; if they deselect, the original shapes are restored.
|
||||
/// When a region select is active, elements that cross the region boundary
|
||||
/// are tracked. If the user performs an operation, the selection is
|
||||
/// committed; if they deselect, the original state is restored.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct RegionSelection {
|
||||
/// The clipping region as a closed BezPath (polygon or rect)
|
||||
pub region_path: BezPath,
|
||||
/// Layer containing the affected shapes
|
||||
/// Layer containing the affected elements
|
||||
pub layer_id: Uuid,
|
||||
/// Keyframe time
|
||||
pub time: f64,
|
||||
/// Per-shape split results
|
||||
pub splits: Vec<ShapeSplit>,
|
||||
/// Shape IDs that were fully inside the region (not split, just selected)
|
||||
/// Per-shape split results (legacy, kept for compatibility)
|
||||
pub splits: Vec<()>,
|
||||
/// IDs that were fully inside the region
|
||||
pub fully_inside_ids: Vec<Uuid>,
|
||||
/// Whether the split has been committed (via an operation on the selection)
|
||||
/// Whether the selection has been committed (via an operation on the selection)
|
||||
pub committed: bool,
|
||||
}
|
||||
|
||||
/// One shape's split result from a region selection
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ShapeSplit {
|
||||
/// The original shape (stored for reverting)
|
||||
pub original_shape: Shape,
|
||||
/// UUID for the "inside" portion shape
|
||||
pub inside_shape_id: Uuid,
|
||||
/// The clipped path inside the region
|
||||
pub inside_path: BezPath,
|
||||
/// UUID for the "outside" portion shape
|
||||
pub outside_shape_id: Uuid,
|
||||
/// The clipped path outside the region
|
||||
pub outside_path: BezPath,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
|
@ -259,67 +298,7 @@ mod tests {
|
|||
fn test_selection_creation() {
|
||||
let selection = Selection::new();
|
||||
assert!(selection.is_empty());
|
||||
assert_eq!(selection.shape_instance_count(), 0);
|
||||
assert_eq!(selection.shape_count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_add_remove_objects() {
|
||||
let mut selection = Selection::new();
|
||||
let id1 = Uuid::new_v4();
|
||||
let id2 = Uuid::new_v4();
|
||||
|
||||
selection.add_shape_instance(id1);
|
||||
assert_eq!(selection.shape_instance_count(), 1);
|
||||
assert!(selection.contains_shape_instance(&id1));
|
||||
|
||||
selection.add_shape_instance(id2);
|
||||
assert_eq!(selection.shape_instance_count(), 2);
|
||||
|
||||
selection.remove_shape_instance(&id1);
|
||||
assert_eq!(selection.shape_instance_count(), 1);
|
||||
assert!(!selection.contains_shape_instance(&id1));
|
||||
assert!(selection.contains_shape_instance(&id2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_toggle() {
|
||||
let mut selection = Selection::new();
|
||||
let id = Uuid::new_v4();
|
||||
|
||||
selection.toggle_shape_instance(id);
|
||||
assert!(selection.contains_shape_instance(&id));
|
||||
|
||||
selection.toggle_shape_instance(id);
|
||||
assert!(!selection.contains_shape_instance(&id));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_select_only() {
|
||||
let mut selection = Selection::new();
|
||||
let id1 = Uuid::new_v4();
|
||||
let id2 = Uuid::new_v4();
|
||||
|
||||
selection.add_shape_instance(id1);
|
||||
selection.add_shape_instance(id2);
|
||||
assert_eq!(selection.shape_instance_count(), 2);
|
||||
|
||||
selection.select_only_shape_instance(id1);
|
||||
assert_eq!(selection.shape_instance_count(), 1);
|
||||
assert!(selection.contains_shape_instance(&id1));
|
||||
assert!(!selection.contains_shape_instance(&id2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_clear() {
|
||||
let mut selection = Selection::new();
|
||||
selection.add_shape_instance(Uuid::new_v4());
|
||||
selection.add_shape(Uuid::new_v4());
|
||||
|
||||
assert!(!selection.is_empty());
|
||||
|
||||
selection.clear();
|
||||
assert!(selection.is_empty());
|
||||
assert_eq!(selection.clip_instance_count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -370,54 +349,34 @@ mod tests {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn test_clear_clip_instances() {
|
||||
fn test_clear() {
|
||||
let mut selection = Selection::new();
|
||||
selection.add_clip_instance(Uuid::new_v4());
|
||||
selection.add_clip_instance(Uuid::new_v4());
|
||||
selection.add_shape_instance(Uuid::new_v4());
|
||||
|
||||
assert_eq!(selection.clip_instance_count(), 2);
|
||||
assert_eq!(selection.shape_instance_count(), 1);
|
||||
|
||||
selection.clear_clip_instances();
|
||||
assert_eq!(selection.clip_instance_count(), 0);
|
||||
assert_eq!(selection.shape_instance_count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_clip_instances_getter() {
|
||||
let mut selection = Selection::new();
|
||||
let id1 = Uuid::new_v4();
|
||||
let id2 = Uuid::new_v4();
|
||||
|
||||
selection.add_clip_instance(id1);
|
||||
selection.add_clip_instance(id2);
|
||||
|
||||
let clip_instances = selection.clip_instances();
|
||||
assert_eq!(clip_instances.len(), 2);
|
||||
assert!(clip_instances.contains(&id1));
|
||||
assert!(clip_instances.contains(&id2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mixed_selection() {
|
||||
let mut selection = Selection::new();
|
||||
let shape_instance_id = Uuid::new_v4();
|
||||
let clip_instance_id = Uuid::new_v4();
|
||||
|
||||
selection.add_shape_instance(shape_instance_id);
|
||||
selection.add_clip_instance(clip_instance_id);
|
||||
|
||||
assert_eq!(selection.shape_instance_count(), 1);
|
||||
assert_eq!(selection.clip_instance_count(), 1);
|
||||
assert!(!selection.is_empty());
|
||||
|
||||
selection.clear_shape_instances();
|
||||
assert_eq!(selection.shape_instance_count(), 0);
|
||||
assert_eq!(selection.clip_instance_count(), 1);
|
||||
assert!(!selection.is_empty());
|
||||
|
||||
selection.clear();
|
||||
assert!(selection.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_dcel_selection_basics() {
|
||||
let selection = Selection::new();
|
||||
assert!(!selection.has_dcel_selection());
|
||||
assert!(selection.selected_edges().is_empty());
|
||||
assert!(selection.selected_faces().is_empty());
|
||||
assert!(selection.selected_vertices().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_clear_dcel_selection() {
|
||||
let mut selection = Selection::new();
|
||||
// Manually insert for unit test (no DCEL needed)
|
||||
selection.selected_edges.insert(EdgeId(0));
|
||||
selection.selected_vertices.insert(VertexId(0));
|
||||
assert!(selection.has_dcel_selection());
|
||||
|
||||
selection.clear_dcel_selection();
|
||||
assert!(!selection.has_dcel_selection());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -130,6 +130,13 @@ pub enum ToolState {
|
|||
parameter_t: f64,
|
||||
},
|
||||
|
||||
/// Pending curve interaction: click selects edge, drag starts curve editing
|
||||
PendingCurveInteraction {
|
||||
edge_id: crate::dcel::EdgeId,
|
||||
parameter_t: f64,
|
||||
start_mouse: Point,
|
||||
},
|
||||
|
||||
/// Drawing a region selection rectangle
|
||||
RegionSelectingRect {
|
||||
start: Point,
|
||||
|
|
|
|||
|
|
@ -1658,37 +1658,8 @@ impl EditorApp {
|
|||
};
|
||||
|
||||
self.clipboard_manager.copy(content);
|
||||
} else if !self.selection.shape_instances().is_empty() {
|
||||
let active_layer_id = match self.active_layer_id {
|
||||
Some(id) => id,
|
||||
None => return,
|
||||
};
|
||||
|
||||
let document = self.action_executor.document();
|
||||
let layer = match document.get_layer(&active_layer_id) {
|
||||
Some(l) => l,
|
||||
None => return,
|
||||
};
|
||||
|
||||
let vector_layer = match layer {
|
||||
AnyLayer::Vector(vl) => vl,
|
||||
_ => return,
|
||||
};
|
||||
|
||||
// Gather selected shapes (they now contain their own transforms)
|
||||
let selected_shapes: Vec<_> = self.selection.shapes().iter()
|
||||
.filter_map(|id| vector_layer.shapes.get(id).cloned())
|
||||
.collect();
|
||||
|
||||
if selected_shapes.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let content = ClipboardContent::Shapes {
|
||||
shapes: selected_shapes,
|
||||
};
|
||||
|
||||
self.clipboard_manager.copy(content);
|
||||
} else if self.selection.has_dcel_selection() {
|
||||
// TODO: DCEL copy/paste deferred to Phase 2 (serialize subgraph)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1736,26 +1707,45 @@ impl EditorApp {
|
|||
}
|
||||
|
||||
self.selection.clear_clip_instances();
|
||||
} else if !self.selection.shapes().is_empty() {
|
||||
} else if self.selection.has_dcel_selection() {
|
||||
let active_layer_id = match self.active_layer_id {
|
||||
Some(id) => id,
|
||||
None => return,
|
||||
};
|
||||
|
||||
let shape_ids: Vec<Uuid> = self.selection.shapes().to_vec();
|
||||
// Delete selected edges via snapshot-based ModifyDcelAction
|
||||
let edge_ids: Vec<lightningbeam_core::dcel::EdgeId> =
|
||||
self.selection.selected_edges().iter().copied().collect();
|
||||
|
||||
let action = lightningbeam_core::actions::RemoveShapesAction::new(
|
||||
if !edge_ids.is_empty() {
|
||||
let document = self.action_executor.document();
|
||||
if let Some(layer) = document.get_layer(&active_layer_id) {
|
||||
if let lightningbeam_core::layer::AnyLayer::Vector(vector_layer) = layer {
|
||||
if let Some(dcel_before) = vector_layer.dcel_at_time(self.playback_time) {
|
||||
let mut dcel_after = dcel_before.clone();
|
||||
for edge_id in &edge_ids {
|
||||
if !dcel_after.edge(*edge_id).deleted {
|
||||
dcel_after.remove_edge(*edge_id);
|
||||
}
|
||||
}
|
||||
|
||||
let action = lightningbeam_core::actions::ModifyDcelAction::new(
|
||||
active_layer_id,
|
||||
shape_ids,
|
||||
self.playback_time,
|
||||
dcel_before.clone(),
|
||||
dcel_after,
|
||||
"Delete selected edges",
|
||||
);
|
||||
|
||||
if let Err(e) = self.action_executor.execute(Box::new(action)) {
|
||||
eprintln!("Delete shapes failed: {}", e);
|
||||
eprintln!("Delete DCEL edges failed: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.selection.clear_shape_instances();
|
||||
self.selection.clear_shapes();
|
||||
self.selection.clear_dcel_selection();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1885,17 +1875,9 @@ impl EditorApp {
|
|||
}
|
||||
};
|
||||
|
||||
let new_shape_ids: Vec<Uuid> = shapes.iter().map(|s| s.id).collect();
|
||||
|
||||
// TODO: DCEL - paste shapes disabled during migration
|
||||
// (was: push shapes into kf.shapes)
|
||||
// (was: push shapes into kf.shapes, select pasted shapes)
|
||||
let _ = (vector_layer, shapes);
|
||||
|
||||
// Select pasted shapes
|
||||
self.selection.clear_shapes();
|
||||
for id in new_shape_ids {
|
||||
self.selection.add_shape(id);
|
||||
}
|
||||
}
|
||||
ClipboardContent::MidiNotes { .. } => {
|
||||
// MIDI notes are pasted directly in the piano roll pane, not here
|
||||
|
|
@ -2426,14 +2408,16 @@ impl EditorApp {
|
|||
// Modify menu
|
||||
MenuAction::Group => {
|
||||
if let Some(layer_id) = self.active_layer_id {
|
||||
let shape_ids: Vec<uuid::Uuid> = self.selection.shape_instances().to_vec();
|
||||
if self.selection.has_dcel_selection() {
|
||||
// TODO: DCEL group deferred to Phase 2 (extract subgraph)
|
||||
} else {
|
||||
let clip_ids: Vec<uuid::Uuid> = self.selection.clip_instances().to_vec();
|
||||
if shape_ids.len() + clip_ids.len() >= 2 {
|
||||
if clip_ids.len() >= 2 {
|
||||
let instance_id = uuid::Uuid::new_v4();
|
||||
let action = lightningbeam_core::actions::GroupAction::new(
|
||||
layer_id,
|
||||
self.playback_time,
|
||||
shape_ids,
|
||||
Vec::new(),
|
||||
clip_ids,
|
||||
instance_id,
|
||||
);
|
||||
|
|
@ -2445,17 +2429,21 @@ impl EditorApp {
|
|||
}
|
||||
}
|
||||
}
|
||||
let _ = layer_id;
|
||||
}
|
||||
}
|
||||
MenuAction::ConvertToMovieClip => {
|
||||
if let Some(layer_id) = self.active_layer_id {
|
||||
let shape_ids: Vec<uuid::Uuid> = self.selection.shape_instances().to_vec();
|
||||
if self.selection.has_dcel_selection() {
|
||||
// TODO: DCEL convert-to-movie-clip deferred to Phase 2
|
||||
} else {
|
||||
let clip_ids: Vec<uuid::Uuid> = self.selection.clip_instances().to_vec();
|
||||
if shape_ids.len() + clip_ids.len() >= 1 {
|
||||
if clip_ids.len() >= 1 {
|
||||
let instance_id = uuid::Uuid::new_v4();
|
||||
let action = lightningbeam_core::actions::ConvertToMovieClipAction::new(
|
||||
layer_id,
|
||||
self.playback_time,
|
||||
shape_ids,
|
||||
Vec::new(),
|
||||
clip_ids,
|
||||
instance_id,
|
||||
);
|
||||
|
|
@ -2468,6 +2456,7 @@ impl EditorApp {
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
MenuAction::SendToBack => {
|
||||
println!("Menu: Send to Back");
|
||||
// TODO: Implement send to back
|
||||
|
|
|
|||
|
|
@ -6,11 +6,8 @@
|
|||
/// - Shape properties (fill/stroke for selected shapes)
|
||||
/// - Document settings (when nothing is selected)
|
||||
|
||||
use eframe::egui::{self, DragValue, Sense, Ui};
|
||||
use lightningbeam_core::actions::{
|
||||
InstancePropertyChange, SetDocumentPropertiesAction, SetInstancePropertiesAction,
|
||||
SetShapePropertiesAction,
|
||||
};
|
||||
use eframe::egui::{self, DragValue, Ui};
|
||||
use lightningbeam_core::actions::SetDocumentPropertiesAction;
|
||||
use lightningbeam_core::layer::AnyLayer;
|
||||
use lightningbeam_core::shape::ShapeColor;
|
||||
use lightningbeam_core::tool::{SimplifyMode, Tool};
|
||||
|
|
@ -21,8 +18,6 @@ use uuid::Uuid;
|
|||
pub struct InfopanelPane {
|
||||
/// Whether the tool options section is expanded
|
||||
tool_section_open: bool,
|
||||
/// Whether the transform section is expanded
|
||||
transform_section_open: bool,
|
||||
/// Whether the shape properties section is expanded
|
||||
shape_section_open: bool,
|
||||
}
|
||||
|
|
@ -31,7 +26,6 @@ impl InfopanelPane {
|
|||
pub fn new() -> Self {
|
||||
Self {
|
||||
tool_section_open: true,
|
||||
transform_section_open: true,
|
||||
shape_section_open: true,
|
||||
}
|
||||
}
|
||||
|
|
@ -41,24 +35,10 @@ impl InfopanelPane {
|
|||
struct SelectionInfo {
|
||||
/// True if nothing is selected
|
||||
is_empty: bool,
|
||||
/// Number of selected shape instances
|
||||
shape_count: usize,
|
||||
/// Layer ID of selected shapes (assumes single layer selection for now)
|
||||
/// Number of selected DCEL elements (edges + faces)
|
||||
dcel_count: usize,
|
||||
/// Layer ID of selected elements (assumes single layer selection for now)
|
||||
layer_id: Option<Uuid>,
|
||||
/// Selected shape instance IDs
|
||||
instance_ids: Vec<Uuid>,
|
||||
/// Shape IDs referenced by selected instances
|
||||
shape_ids: Vec<Uuid>,
|
||||
|
||||
// Transform values (None = mixed values across selection)
|
||||
x: Option<f64>,
|
||||
y: Option<f64>,
|
||||
rotation: Option<f64>,
|
||||
scale_x: Option<f64>,
|
||||
scale_y: Option<f64>,
|
||||
skew_x: Option<f64>,
|
||||
skew_y: Option<f64>,
|
||||
opacity: Option<f64>,
|
||||
|
||||
// Shape property values (None = mixed)
|
||||
fill_color: Option<Option<ShapeColor>>,
|
||||
|
|
@ -70,18 +50,8 @@ impl Default for SelectionInfo {
|
|||
fn default() -> Self {
|
||||
Self {
|
||||
is_empty: true,
|
||||
shape_count: 0,
|
||||
dcel_count: 0,
|
||||
layer_id: None,
|
||||
instance_ids: Vec::new(),
|
||||
shape_ids: Vec::new(),
|
||||
x: None,
|
||||
y: None,
|
||||
rotation: None,
|
||||
scale_x: None,
|
||||
scale_y: None,
|
||||
skew_x: None,
|
||||
skew_y: None,
|
||||
opacity: None,
|
||||
fill_color: None,
|
||||
stroke_color: None,
|
||||
stroke_width: None,
|
||||
|
|
@ -94,17 +64,15 @@ impl InfopanelPane {
|
|||
fn gather_selection_info(&self, shared: &SharedPaneState) -> SelectionInfo {
|
||||
let mut info = SelectionInfo::default();
|
||||
|
||||
let selected_instances = shared.selection.shape_instances();
|
||||
info.shape_count = selected_instances.len();
|
||||
info.is_empty = info.shape_count == 0;
|
||||
let edge_count = shared.selection.selected_edges().len();
|
||||
let face_count = shared.selection.selected_faces().len();
|
||||
info.dcel_count = edge_count + face_count;
|
||||
info.is_empty = info.dcel_count == 0;
|
||||
|
||||
if info.is_empty {
|
||||
return info;
|
||||
}
|
||||
|
||||
info.instance_ids = selected_instances.to_vec();
|
||||
|
||||
// Find the layer containing the selected instances
|
||||
let document = shared.action_executor.document();
|
||||
let active_layer_id = *shared.active_layer_id;
|
||||
|
||||
|
|
@ -113,10 +81,56 @@ impl InfopanelPane {
|
|||
|
||||
if let Some(layer) = document.get_layer(&layer_id) {
|
||||
if let AnyLayer::Vector(vector_layer) = layer {
|
||||
// Gather values from all selected instances
|
||||
// TODO: DCEL - shape property gathering disabled during migration
|
||||
// (was: get_shape_in_keyframe to gather transform/fill/stroke properties)
|
||||
let _ = vector_layer;
|
||||
if let Some(dcel) = vector_layer.dcel_at_time(*shared.playback_time) {
|
||||
// Gather stroke properties from selected edges
|
||||
let mut first_stroke_color: Option<Option<ShapeColor>> = None;
|
||||
let mut first_stroke_width: Option<f64> = None;
|
||||
let mut stroke_color_mixed = false;
|
||||
let mut stroke_width_mixed = false;
|
||||
|
||||
for &eid in shared.selection.selected_edges() {
|
||||
let edge = dcel.edge(eid);
|
||||
let sc = edge.stroke_color;
|
||||
let sw = edge.stroke_style.as_ref().map(|s| s.width);
|
||||
|
||||
match first_stroke_color {
|
||||
None => first_stroke_color = Some(sc),
|
||||
Some(prev) if prev != sc => stroke_color_mixed = true,
|
||||
_ => {}
|
||||
}
|
||||
match (first_stroke_width, sw) {
|
||||
(None, _) => first_stroke_width = sw,
|
||||
(Some(prev), Some(cur)) if (prev - cur).abs() > 0.01 => stroke_width_mixed = true,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
if !stroke_color_mixed {
|
||||
info.stroke_color = first_stroke_color;
|
||||
}
|
||||
if !stroke_width_mixed {
|
||||
info.stroke_width = first_stroke_width;
|
||||
}
|
||||
|
||||
// Gather fill properties from selected faces
|
||||
let mut first_fill_color: Option<Option<ShapeColor>> = None;
|
||||
let mut fill_color_mixed = false;
|
||||
|
||||
for &fid in shared.selection.selected_faces() {
|
||||
let face = dcel.face(fid);
|
||||
let fc = face.fill_color;
|
||||
|
||||
match first_fill_color {
|
||||
None => first_fill_color = Some(fc),
|
||||
Some(prev) if prev != fc => fill_color_mixed = true,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
if !fill_color_mixed {
|
||||
info.fill_color = first_fill_color;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -262,214 +276,14 @@ impl InfopanelPane {
|
|||
});
|
||||
}
|
||||
|
||||
/// Render transform properties section
|
||||
fn render_transform_section(
|
||||
&mut self,
|
||||
ui: &mut Ui,
|
||||
path: &NodePath,
|
||||
shared: &mut SharedPaneState,
|
||||
info: &SelectionInfo,
|
||||
) {
|
||||
egui::CollapsingHeader::new("Transform")
|
||||
.id_salt(("transform", path))
|
||||
.default_open(self.transform_section_open)
|
||||
.show(ui, |ui| {
|
||||
self.transform_section_open = true;
|
||||
ui.add_space(4.0);
|
||||
|
||||
let layer_id = match info.layer_id {
|
||||
Some(id) => id,
|
||||
None => return,
|
||||
};
|
||||
|
||||
// Position X
|
||||
self.render_transform_field(
|
||||
ui,
|
||||
"X:",
|
||||
info.x,
|
||||
1.0,
|
||||
f64::NEG_INFINITY..=f64::INFINITY,
|
||||
|value| InstancePropertyChange::X(value),
|
||||
layer_id,
|
||||
&info.instance_ids,
|
||||
shared,
|
||||
);
|
||||
|
||||
// Position Y
|
||||
self.render_transform_field(
|
||||
ui,
|
||||
"Y:",
|
||||
info.y,
|
||||
1.0,
|
||||
f64::NEG_INFINITY..=f64::INFINITY,
|
||||
|value| InstancePropertyChange::Y(value),
|
||||
layer_id,
|
||||
&info.instance_ids,
|
||||
shared,
|
||||
);
|
||||
|
||||
ui.add_space(4.0);
|
||||
|
||||
// Rotation
|
||||
self.render_transform_field(
|
||||
ui,
|
||||
"Rotation:",
|
||||
info.rotation,
|
||||
1.0,
|
||||
-360.0..=360.0,
|
||||
|value| InstancePropertyChange::Rotation(value),
|
||||
layer_id,
|
||||
&info.instance_ids,
|
||||
shared,
|
||||
);
|
||||
|
||||
ui.add_space(4.0);
|
||||
|
||||
// Scale X
|
||||
self.render_transform_field(
|
||||
ui,
|
||||
"Scale X:",
|
||||
info.scale_x,
|
||||
0.01,
|
||||
0.01..=100.0,
|
||||
|value| InstancePropertyChange::ScaleX(value),
|
||||
layer_id,
|
||||
&info.instance_ids,
|
||||
shared,
|
||||
);
|
||||
|
||||
// Scale Y
|
||||
self.render_transform_field(
|
||||
ui,
|
||||
"Scale Y:",
|
||||
info.scale_y,
|
||||
0.01,
|
||||
0.01..=100.0,
|
||||
|value| InstancePropertyChange::ScaleY(value),
|
||||
layer_id,
|
||||
&info.instance_ids,
|
||||
shared,
|
||||
);
|
||||
|
||||
ui.add_space(4.0);
|
||||
|
||||
// Skew X
|
||||
self.render_transform_field(
|
||||
ui,
|
||||
"Skew X:",
|
||||
info.skew_x,
|
||||
1.0,
|
||||
-89.0..=89.0,
|
||||
|value| InstancePropertyChange::SkewX(value),
|
||||
layer_id,
|
||||
&info.instance_ids,
|
||||
shared,
|
||||
);
|
||||
|
||||
// Skew Y
|
||||
self.render_transform_field(
|
||||
ui,
|
||||
"Skew Y:",
|
||||
info.skew_y,
|
||||
1.0,
|
||||
-89.0..=89.0,
|
||||
|value| InstancePropertyChange::SkewY(value),
|
||||
layer_id,
|
||||
&info.instance_ids,
|
||||
shared,
|
||||
);
|
||||
|
||||
ui.add_space(4.0);
|
||||
|
||||
// Opacity
|
||||
self.render_transform_field(
|
||||
ui,
|
||||
"Opacity:",
|
||||
info.opacity,
|
||||
0.01,
|
||||
0.0..=1.0,
|
||||
|value| InstancePropertyChange::Opacity(value),
|
||||
layer_id,
|
||||
&info.instance_ids,
|
||||
shared,
|
||||
);
|
||||
|
||||
ui.add_space(4.0);
|
||||
});
|
||||
}
|
||||
|
||||
/// Render a single transform property field with drag-to-adjust
|
||||
fn render_transform_field<F>(
|
||||
&self,
|
||||
ui: &mut Ui,
|
||||
label: &str,
|
||||
value: Option<f64>,
|
||||
speed: f64,
|
||||
range: std::ops::RangeInclusive<f64>,
|
||||
make_change: F,
|
||||
layer_id: Uuid,
|
||||
instance_ids: &[Uuid],
|
||||
shared: &mut SharedPaneState,
|
||||
) where
|
||||
F: Fn(f64) -> InstancePropertyChange,
|
||||
{
|
||||
ui.horizontal(|ui| {
|
||||
// Label with drag sense for drag-to-adjust
|
||||
let label_response = ui.add(egui::Label::new(label).sense(Sense::drag()));
|
||||
|
||||
match value {
|
||||
Some(mut v) => {
|
||||
// Handle drag on label
|
||||
if label_response.dragged() {
|
||||
let delta = label_response.drag_delta().x as f64 * speed;
|
||||
v = (v + delta).clamp(*range.start(), *range.end());
|
||||
|
||||
// Create action for each selected instance
|
||||
for instance_id in instance_ids {
|
||||
let action = SetInstancePropertiesAction::new(
|
||||
layer_id,
|
||||
*shared.playback_time,
|
||||
*instance_id,
|
||||
make_change(v),
|
||||
);
|
||||
shared.pending_actions.push(Box::new(action));
|
||||
}
|
||||
}
|
||||
|
||||
// DragValue widget
|
||||
let response = ui.add(
|
||||
DragValue::new(&mut v)
|
||||
.speed(speed)
|
||||
.range(range.clone()),
|
||||
);
|
||||
|
||||
if response.changed() {
|
||||
// Create action for each selected instance
|
||||
for instance_id in instance_ids {
|
||||
let action = SetInstancePropertiesAction::new(
|
||||
layer_id,
|
||||
*shared.playback_time,
|
||||
*instance_id,
|
||||
make_change(v),
|
||||
);
|
||||
shared.pending_actions.push(Box::new(action));
|
||||
}
|
||||
}
|
||||
}
|
||||
None => {
|
||||
// Mixed values - show placeholder
|
||||
ui.label("--");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
// Transform section: deferred to Phase 2 (DCEL elements don't have instance transforms)
|
||||
|
||||
/// Render shape properties section (fill/stroke)
|
||||
fn render_shape_section(
|
||||
&mut self,
|
||||
ui: &mut Ui,
|
||||
path: &NodePath,
|
||||
shared: &mut SharedPaneState,
|
||||
_shared: &mut SharedPaneState,
|
||||
info: &SelectionInfo,
|
||||
) {
|
||||
egui::CollapsingHeader::new("Shape")
|
||||
|
|
@ -479,54 +293,22 @@ impl InfopanelPane {
|
|||
self.shape_section_open = true;
|
||||
ui.add_space(4.0);
|
||||
|
||||
let layer_id = match info.layer_id {
|
||||
Some(id) => id,
|
||||
None => return,
|
||||
};
|
||||
|
||||
// Fill color
|
||||
// Fill color (read-only display for now)
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Fill:");
|
||||
match info.fill_color {
|
||||
Some(Some(color)) => {
|
||||
let mut egui_color = egui::Color32::from_rgba_unmultiplied(
|
||||
let egui_color = egui::Color32::from_rgba_unmultiplied(
|
||||
color.r, color.g, color.b, color.a,
|
||||
);
|
||||
|
||||
if ui.color_edit_button_srgba(&mut egui_color).changed() {
|
||||
let new_color = Some(ShapeColor::new(
|
||||
egui_color.r(),
|
||||
egui_color.g(),
|
||||
egui_color.b(),
|
||||
egui_color.a(),
|
||||
));
|
||||
|
||||
// Create action for each selected shape
|
||||
for shape_id in &info.shape_ids {
|
||||
let action = SetShapePropertiesAction::set_fill_color(
|
||||
layer_id,
|
||||
*shape_id,
|
||||
*shared.playback_time,
|
||||
new_color,
|
||||
let (rect, _) = ui.allocate_exact_size(
|
||||
egui::vec2(20.0, 20.0),
|
||||
egui::Sense::hover(),
|
||||
);
|
||||
shared.pending_actions.push(Box::new(action));
|
||||
}
|
||||
}
|
||||
ui.painter().rect_filled(rect, 2.0, egui_color);
|
||||
}
|
||||
Some(None) => {
|
||||
if ui.button("Add Fill").clicked() {
|
||||
// Add default black fill
|
||||
let default_fill = Some(ShapeColor::rgb(0, 0, 0));
|
||||
for shape_id in &info.shape_ids {
|
||||
let action = SetShapePropertiesAction::set_fill_color(
|
||||
layer_id,
|
||||
*shape_id,
|
||||
*shared.playback_time,
|
||||
default_fill,
|
||||
);
|
||||
shared.pending_actions.push(Box::new(action));
|
||||
}
|
||||
}
|
||||
ui.label("None");
|
||||
}
|
||||
None => {
|
||||
ui.label("--");
|
||||
|
|
@ -534,49 +316,22 @@ impl InfopanelPane {
|
|||
}
|
||||
});
|
||||
|
||||
// Stroke color
|
||||
// Stroke color (read-only display for now)
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Stroke:");
|
||||
match info.stroke_color {
|
||||
Some(Some(color)) => {
|
||||
let mut egui_color = egui::Color32::from_rgba_unmultiplied(
|
||||
let egui_color = egui::Color32::from_rgba_unmultiplied(
|
||||
color.r, color.g, color.b, color.a,
|
||||
);
|
||||
|
||||
if ui.color_edit_button_srgba(&mut egui_color).changed() {
|
||||
let new_color = Some(ShapeColor::new(
|
||||
egui_color.r(),
|
||||
egui_color.g(),
|
||||
egui_color.b(),
|
||||
egui_color.a(),
|
||||
));
|
||||
|
||||
// Create action for each selected shape
|
||||
for shape_id in &info.shape_ids {
|
||||
let action = SetShapePropertiesAction::set_stroke_color(
|
||||
layer_id,
|
||||
*shape_id,
|
||||
*shared.playback_time,
|
||||
new_color,
|
||||
let (rect, _) = ui.allocate_exact_size(
|
||||
egui::vec2(20.0, 20.0),
|
||||
egui::Sense::hover(),
|
||||
);
|
||||
shared.pending_actions.push(Box::new(action));
|
||||
}
|
||||
}
|
||||
ui.painter().rect_filled(rect, 2.0, egui_color);
|
||||
}
|
||||
Some(None) => {
|
||||
if ui.button("Add Stroke").clicked() {
|
||||
// Add default black stroke
|
||||
let default_stroke = Some(ShapeColor::rgb(0, 0, 0));
|
||||
for shape_id in &info.shape_ids {
|
||||
let action = SetShapePropertiesAction::set_stroke_color(
|
||||
layer_id,
|
||||
*shape_id,
|
||||
*shared.playback_time,
|
||||
default_stroke,
|
||||
);
|
||||
shared.pending_actions.push(Box::new(action));
|
||||
}
|
||||
}
|
||||
ui.label("None");
|
||||
}
|
||||
None => {
|
||||
ui.label("--");
|
||||
|
|
@ -584,28 +339,12 @@ impl InfopanelPane {
|
|||
}
|
||||
});
|
||||
|
||||
// Stroke width
|
||||
// Stroke width (read-only display for now)
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Stroke Width:");
|
||||
match info.stroke_width {
|
||||
Some(mut width) => {
|
||||
let response = ui.add(
|
||||
DragValue::new(&mut width)
|
||||
.speed(0.1)
|
||||
.range(0.1..=100.0),
|
||||
);
|
||||
|
||||
if response.changed() {
|
||||
for shape_id in &info.shape_ids {
|
||||
let action = SetShapePropertiesAction::set_stroke_width(
|
||||
layer_id,
|
||||
*shape_id,
|
||||
*shared.playback_time,
|
||||
width,
|
||||
);
|
||||
shared.pending_actions.push(Box::new(action));
|
||||
}
|
||||
}
|
||||
Some(width) => {
|
||||
ui.label(format!("{:.1}", width));
|
||||
}
|
||||
None => {
|
||||
ui.label("--");
|
||||
|
|
@ -737,13 +476,8 @@ impl PaneRenderer for InfopanelPane {
|
|||
// 2. Gather selection info
|
||||
let info = self.gather_selection_info(shared);
|
||||
|
||||
// 3. Transform section (if shapes selected)
|
||||
if info.shape_count > 0 {
|
||||
self.render_transform_section(ui, path, shared, &info);
|
||||
}
|
||||
|
||||
// 4. Shape properties section (if shapes selected)
|
||||
if info.shape_count > 0 {
|
||||
// 3. Shape properties section (if DCEL elements selected)
|
||||
if info.dcel_count > 0 {
|
||||
self.render_shape_section(ui, path, shared, &info);
|
||||
}
|
||||
|
||||
|
|
@ -753,14 +487,14 @@ impl PaneRenderer for InfopanelPane {
|
|||
}
|
||||
|
||||
// Show selection count at bottom
|
||||
if info.shape_count > 0 {
|
||||
if info.dcel_count > 0 {
|
||||
ui.add_space(8.0);
|
||||
ui.separator();
|
||||
ui.add_space(4.0);
|
||||
ui.label(format!(
|
||||
"{} object{} selected",
|
||||
info.shape_count,
|
||||
if info.shape_count == 1 { "" } else { "s" }
|
||||
info.dcel_count,
|
||||
if info.dcel_count == 1 { "" } else { "s" }
|
||||
));
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -386,6 +386,8 @@ struct VelloRenderContext {
|
|||
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_world_pos: Option<vello::kurbo::Point>,
|
||||
}
|
||||
|
||||
/// Callback for Vello rendering within egui
|
||||
|
|
@ -887,11 +889,54 @@ impl egui_wgpu::CallbackTrait for VelloCallback {
|
|||
let selection_color = Color::from_rgb8(0, 120, 255); // Blue
|
||||
let stroke_width = 2.0 / self.ctx.zoom.max(0.5) as f64;
|
||||
|
||||
// 1. Draw selection outlines around selected objects
|
||||
// 1. Draw selection stipple overlay on selected DCEL elements + clip outlines
|
||||
// NOTE: Skip this if Transform tool is active (it has its own handles)
|
||||
if !self.ctx.selection.is_empty() && !matches!(self.ctx.selected_tool, Tool::Transform) {
|
||||
// TODO: DCEL - shape selection outlines disabled during migration
|
||||
// (was: iterate shape_instances, get_shape_in_keyframe, draw bbox outlines)
|
||||
// Draw Flash-style stipple pattern on selected edges and faces
|
||||
if self.ctx.selection.has_dcel_selection() {
|
||||
if let Some(dcel) = vector_layer.dcel_at_time(self.ctx.playback_time) {
|
||||
let stipple_brush = selection_stipple_brush();
|
||||
// brush_transform scales the stipple so 1 pattern pixel = 1 screen pixel.
|
||||
// The shape is in document space, transformed to screen by overlay_transform
|
||||
// (which includes zoom). The brush tiles in document space by default,
|
||||
// so we scale it by 1/zoom to make each 2x2 tile = 2x2 screen pixels.
|
||||
let inv_zoom = 1.0 / self.ctx.zoom as f64;
|
||||
let brush_xform = Some(Affine::scale(inv_zoom));
|
||||
|
||||
// Stipple selected faces
|
||||
for &face_id in self.ctx.selection.selected_faces() {
|
||||
let face = dcel.face(face_id);
|
||||
if face.deleted || face_id.0 == 0 { continue; }
|
||||
let path = dcel.face_to_bezpath_with_holes(face_id);
|
||||
scene.fill(
|
||||
Fill::NonZero,
|
||||
overlay_transform,
|
||||
stipple_brush,
|
||||
brush_xform,
|
||||
&path,
|
||||
);
|
||||
}
|
||||
|
||||
// Stipple selected edges
|
||||
for &edge_id in self.ctx.selection.selected_edges() {
|
||||
let edge = dcel.edge(edge_id);
|
||||
if edge.deleted { 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(
|
||||
&Stroke::new(width),
|
||||
overlay_transform,
|
||||
stipple_brush,
|
||||
brush_xform,
|
||||
&path,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Also draw selection outlines for clip instances
|
||||
for &clip_id in self.ctx.selection.clip_instances() {
|
||||
|
|
@ -962,6 +1007,65 @@ impl egui_wgpu::CallbackTrait for VelloCallback {
|
|||
}
|
||||
}
|
||||
|
||||
// 1b. Draw stipple hover highlight on the curve under the mouse
|
||||
// During active curve editing, lock highlight to the edited curve
|
||||
if matches!(self.ctx.selected_tool, Tool::Select | Tool::BezierEdit) {
|
||||
use lightningbeam_core::tool::ToolState;
|
||||
|
||||
// Determine which edge to highlight: active edit takes priority over hover
|
||||
let highlight_edge = match &self.ctx.tool_state {
|
||||
ToolState::EditingCurve { edge_id, .. }
|
||||
| ToolState::PendingCurveInteraction { edge_id, .. } => {
|
||||
Some(*edge_id)
|
||||
}
|
||||
_ => {
|
||||
// Fall back to hover hit test
|
||||
self.ctx.mouse_world_pos.and_then(|mouse_pos| {
|
||||
use lightningbeam_core::hit_test::{hit_test_vector_editing, EditingHitTolerance, VectorEditHit};
|
||||
let is_bezier = matches!(self.ctx.selected_tool, Tool::BezierEdit);
|
||||
let tolerance = EditingHitTolerance::scaled_by_zoom(self.ctx.zoom as f64);
|
||||
let hit = hit_test_vector_editing(
|
||||
vector_layer,
|
||||
self.ctx.playback_time,
|
||||
mouse_pos,
|
||||
&tolerance,
|
||||
Affine::IDENTITY,
|
||||
is_bezier,
|
||||
);
|
||||
match hit {
|
||||
Some(VectorEditHit::Curve { edge_id, .. }) => Some(edge_id),
|
||||
_ => None,
|
||||
}
|
||||
})
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(edge_id) = highlight_edge {
|
||||
if let Some(dcel) = vector_layer.dcel_at_time(self.ctx.playback_time) {
|
||||
let edge = dcel.edge(edge_id);
|
||||
if !edge.deleted {
|
||||
let stipple_brush = selection_stipple_brush();
|
||||
let inv_zoom = 1.0 / self.ctx.zoom as f64;
|
||||
let brush_xform = Some(Affine::scale(inv_zoom));
|
||||
let width = edge.stroke_style.as_ref()
|
||||
.map(|s| s.width + 4.0)
|
||||
.unwrap_or(3.0)
|
||||
.max(3.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(
|
||||
&Stroke::new(width),
|
||||
overlay_transform,
|
||||
stipple_brush,
|
||||
brush_xform,
|
||||
&path,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Draw marquee selection rectangle
|
||||
if let lightningbeam_core::tool::ToolState::MarqueeSelecting { ref start, ref current } = self.ctx.tool_state {
|
||||
let marquee_rect = KurboRect::new(
|
||||
|
|
@ -1371,14 +1475,10 @@ impl egui_wgpu::CallbackTrait for VelloCallback {
|
|||
// For single object: use object-aligned (rotated) bounding box
|
||||
// For multiple objects: use axis-aligned bounding box (simpler for now)
|
||||
|
||||
let total_selected = self.ctx.selection.shape_instances().len() + self.ctx.selection.clip_instances().len();
|
||||
let total_selected = self.ctx.selection.clip_instances().len();
|
||||
if total_selected == 1 {
|
||||
// Single object - draw rotated bounding box
|
||||
let object_id = if let Some(&id) = self.ctx.selection.shape_instances().iter().next() {
|
||||
id
|
||||
} else {
|
||||
*self.ctx.selection.clip_instances().iter().next().unwrap()
|
||||
};
|
||||
// Single clip instance - draw rotated bounding box
|
||||
let object_id = *self.ctx.selection.clip_instances().iter().next().unwrap();
|
||||
|
||||
// TODO: DCEL - single-object transform handles disabled during migration
|
||||
// (was: get_shape_in_keyframe for rotated bbox + handle drawing)
|
||||
|
|
@ -1921,6 +2021,36 @@ static INSTANCE_COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::Atomi
|
|||
// Global storage for eyedropper results (instance_id -> (color, color_mode))
|
||||
static EYEDROPPER_RESULTS: OnceLock<Arc<Mutex<std::collections::HashMap<u64, (egui::Color32, super::ColorMode)>>>> = OnceLock::new();
|
||||
|
||||
/// Cached 2x2 stipple image brush for selection overlay.
|
||||
/// Pattern: [[black, transparent], [transparent, white]]
|
||||
/// Tiled with nearest-neighbor sampling so each pixel stays crisp.
|
||||
static SELECTION_STIPPLE: OnceLock<vello::peniko::ImageBrush> = OnceLock::new();
|
||||
|
||||
fn selection_stipple_brush() -> &'static vello::peniko::ImageBrush {
|
||||
SELECTION_STIPPLE.get_or_init(|| {
|
||||
use vello::peniko::{Blob, Extend, ImageAlphaType, ImageBrush, ImageData, ImageFormat, ImageQuality};
|
||||
// 2x2 RGBA pixels: row-major order
|
||||
// [0,0] = black opaque, [1,0] = transparent
|
||||
// [0,1] = transparent, [1,1] = white opaque
|
||||
let pixels: Vec<u8> = vec![
|
||||
0, 0, 0, 255, // (0,0) black
|
||||
0, 0, 0, 0, // (1,0) transparent
|
||||
0, 0, 0, 0, // (0,1) transparent
|
||||
255, 255, 255, 255, // (1,1) white
|
||||
];
|
||||
let image_data = ImageData {
|
||||
data: Blob::from(pixels),
|
||||
format: ImageFormat::Rgba8,
|
||||
alpha_type: ImageAlphaType::Alpha,
|
||||
width: 2,
|
||||
height: 2,
|
||||
};
|
||||
ImageBrush::new(image_data)
|
||||
.with_extend(Extend::Repeat)
|
||||
.with_quality(ImageQuality::Low)
|
||||
})
|
||||
}
|
||||
|
||||
impl StagePane {
|
||||
pub fn new() -> Self {
|
||||
let instance_id = INSTANCE_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
|
|
@ -2139,7 +2269,7 @@ impl StagePane {
|
|||
Affine::IDENTITY,
|
||||
false, // Select tool doesn't show control points
|
||||
);
|
||||
// Priority 1: Vector editing (vertices and curves)
|
||||
// Priority 1: Vector editing (vertices immediately, curves deferred)
|
||||
if let Some(hit) = vector_hit {
|
||||
match hit {
|
||||
VectorEditHit::Vertex { vertex_id } => {
|
||||
|
|
@ -2147,7 +2277,12 @@ impl StagePane {
|
|||
return;
|
||||
}
|
||||
VectorEditHit::Curve { edge_id, parameter_t } => {
|
||||
self.start_curve_editing(edge_id, parameter_t, point, active_layer_id, shared);
|
||||
// Defer: drag → curve editing, click → edge selection
|
||||
*shared.tool_state = ToolState::PendingCurveInteraction {
|
||||
edge_id,
|
||||
parameter_t,
|
||||
start_mouse: point,
|
||||
};
|
||||
return;
|
||||
}
|
||||
_ => {
|
||||
|
|
@ -2171,38 +2306,39 @@ impl StagePane {
|
|||
let hit_result = if let Some(clip_id) = clip_hit {
|
||||
Some(hit_test::HitResult::ClipInstance(clip_id))
|
||||
} else {
|
||||
// No clip hit, test shape instances
|
||||
// No clip hit, test DCEL edges and faces
|
||||
hit_test::hit_test_layer(vector_layer, *shared.playback_time, point, 5.0, Affine::IDENTITY)
|
||||
.map(|id| hit_test::HitResult::ShapeInstance(id))
|
||||
.map(|dcel_hit| match dcel_hit {
|
||||
hit_test::DcelHitResult::Edge(eid) => hit_test::HitResult::Edge(eid),
|
||||
hit_test::DcelHitResult::Face(fid) => hit_test::HitResult::Face(fid),
|
||||
})
|
||||
};
|
||||
|
||||
if let Some(hit) = hit_result {
|
||||
match hit {
|
||||
hit_test::HitResult::ShapeInstance(object_id) => {
|
||||
// Shape instance was hit
|
||||
hit_test::HitResult::Edge(edge_id) => {
|
||||
// DCEL edge was hit
|
||||
if let Some(dcel) = vector_layer.dcel_at_time(*shared.playback_time) {
|
||||
if shift_held {
|
||||
// Shift: toggle selection
|
||||
shared.selection.toggle_shape_instance(object_id);
|
||||
shared.selection.toggle_edge(edge_id, dcel);
|
||||
} else {
|
||||
// No shift: replace selection
|
||||
if !shared.selection.contains_shape_instance(&object_id) {
|
||||
shared.selection.select_only_shape_instance(object_id);
|
||||
shared.selection.clear_dcel_selection();
|
||||
shared.selection.select_edge(edge_id, dcel);
|
||||
}
|
||||
}
|
||||
|
||||
// If object is now selected, prepare for dragging
|
||||
if shared.selection.contains_shape_instance(&object_id) {
|
||||
// Store original positions of all selected objects
|
||||
let original_positions = std::collections::HashMap::new();
|
||||
// TODO: DCEL - shape position lookup disabled during migration
|
||||
// (was: get_shape_in_keyframe to store original positions for drag)
|
||||
|
||||
*shared.tool_state = ToolState::DraggingSelection {
|
||||
start_pos: point,
|
||||
start_mouse: point,
|
||||
original_positions,
|
||||
};
|
||||
// DCEL element dragging deferred to Phase 3
|
||||
}
|
||||
hit_test::HitResult::Face(face_id) => {
|
||||
// DCEL face was hit
|
||||
if let Some(dcel) = vector_layer.dcel_at_time(*shared.playback_time) {
|
||||
if shift_held {
|
||||
shared.selection.toggle_face(face_id, dcel);
|
||||
} else {
|
||||
shared.selection.clear_dcel_selection();
|
||||
shared.selection.select_face(face_id, dcel);
|
||||
}
|
||||
}
|
||||
// DCEL element dragging deferred to Phase 3
|
||||
}
|
||||
hit_test::HitResult::ClipInstance(clip_id) => {
|
||||
// Clip instance was hit
|
||||
|
|
@ -2255,6 +2391,14 @@ impl StagePane {
|
|||
// Mouse drag: update tool state
|
||||
if response.dragged() {
|
||||
match shared.tool_state {
|
||||
ToolState::PendingCurveInteraction { edge_id, parameter_t, start_mouse } => {
|
||||
// Drag detected — transition to curve editing
|
||||
let edge_id = *edge_id;
|
||||
let parameter_t = *parameter_t;
|
||||
let start_mouse = *start_mouse;
|
||||
self.start_curve_editing(edge_id, parameter_t, start_mouse, active_layer_id, shared);
|
||||
self.update_vector_editing(point, shared);
|
||||
}
|
||||
ToolState::EditingVertex { .. } | ToolState::EditingCurve { .. } => {
|
||||
// Vector editing - update happens in helper method
|
||||
self.update_vector_editing(point, shared);
|
||||
|
|
@ -2277,11 +2421,28 @@ impl StagePane {
|
|||
// Mouse up: finish interaction
|
||||
let drag_stopped = response.drag_stopped();
|
||||
let pointer_released = ui.input(|i| i.pointer.any_released());
|
||||
let is_pending_curve = matches!(shared.tool_state, ToolState::PendingCurveInteraction { .. });
|
||||
let is_drag_or_marquee = matches!(shared.tool_state, ToolState::DraggingSelection { .. } | ToolState::MarqueeSelecting { .. });
|
||||
let is_vector_editing = matches!(shared.tool_state, ToolState::EditingVertex { .. } | ToolState::EditingCurve { .. } | ToolState::EditingControlPoint { .. });
|
||||
|
||||
if drag_stopped || (pointer_released && (is_drag_or_marquee || is_vector_editing)) {
|
||||
if drag_stopped || (pointer_released && (is_drag_or_marquee || is_vector_editing || is_pending_curve)) {
|
||||
match shared.tool_state.clone() {
|
||||
ToolState::PendingCurveInteraction { edge_id, .. } => {
|
||||
// Mouse released without drag — select the edge
|
||||
let shift_held = ui.input(|i| i.modifiers.shift);
|
||||
let document = shared.action_executor.document();
|
||||
if let Some(layer) = document.get_layer(&active_layer_id) {
|
||||
if let AnyLayer::Vector(vl) = layer {
|
||||
if let Some(dcel) = vl.dcel_at_time(*shared.playback_time) {
|
||||
if !shift_held {
|
||||
shared.selection.clear_dcel_selection();
|
||||
}
|
||||
shared.selection.select_edge(edge_id, dcel);
|
||||
}
|
||||
}
|
||||
}
|
||||
*shared.tool_state = ToolState::Idle;
|
||||
}
|
||||
ToolState::EditingVertex { .. } | ToolState::EditingCurve { .. } | ToolState::EditingControlPoint { .. } => {
|
||||
// Finish vector editing - create action
|
||||
self.finish_vector_editing(active_layer_id, shared);
|
||||
|
|
@ -2305,8 +2466,7 @@ impl StagePane {
|
|||
_ => return,
|
||||
};
|
||||
|
||||
// Separate shape instances from clip instances
|
||||
let mut shape_instance_positions = HashMap::new();
|
||||
// Process clip instance drags
|
||||
let mut clip_instance_transforms = HashMap::new();
|
||||
|
||||
for (id, original_pos) in original_positions {
|
||||
|
|
@ -2315,12 +2475,7 @@ impl StagePane {
|
|||
original_pos.y + delta.y,
|
||||
);
|
||||
|
||||
// Check if this is a shape instance or clip instance
|
||||
if shared.selection.contains_shape_instance(&id) {
|
||||
shape_instance_positions.insert(id, (original_pos, new_pos));
|
||||
} else if shared.selection.contains_clip_instance(&id) {
|
||||
// For clip instances, we need to get the full Transform
|
||||
// Find the clip instance in the layer
|
||||
if shared.selection.contains_clip_instance(&id) {
|
||||
if let Some(clip_inst) = vector_layer.clip_instances.iter()
|
||||
.find(|ci| ci.id == id) {
|
||||
let mut old_transform = clip_inst.transform.clone();
|
||||
|
|
@ -2336,13 +2491,6 @@ impl StagePane {
|
|||
}
|
||||
}
|
||||
|
||||
// Create and submit move action for shape instances
|
||||
if !shape_instance_positions.is_empty() {
|
||||
use lightningbeam_core::actions::MoveShapeInstancesAction;
|
||||
let action = MoveShapeInstancesAction::new(active_layer_id, *shared.playback_time, shape_instance_positions);
|
||||
shared.pending_actions.push(Box::new(action));
|
||||
}
|
||||
|
||||
// Create and submit transform action for clip instances
|
||||
if !clip_instance_transforms.is_empty() {
|
||||
use lightningbeam_core::actions::TransformClipInstancesAction;
|
||||
|
|
@ -2383,8 +2531,8 @@ impl StagePane {
|
|||
*shared.playback_time,
|
||||
);
|
||||
|
||||
// Hit test shape instances in rectangle
|
||||
let shape_hits = hit_test::hit_test_objects_in_rect(
|
||||
// Hit test DCEL elements in rectangle
|
||||
let dcel_hits = hit_test::hit_test_dcel_in_rect(
|
||||
vector_layer,
|
||||
*shared.playback_time,
|
||||
selection_rect,
|
||||
|
|
@ -2393,31 +2541,16 @@ impl StagePane {
|
|||
|
||||
// Add clip instances to selection
|
||||
for clip_id in clip_hits {
|
||||
if shift_held {
|
||||
shared.selection.add_clip_instance(clip_id);
|
||||
} else {
|
||||
// First hit replaces selection
|
||||
if shared.selection.is_empty() {
|
||||
shared.selection.add_clip_instance(clip_id);
|
||||
} else {
|
||||
// Subsequent hits add to selection
|
||||
shared.selection.add_clip_instance(clip_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add shape instances to selection
|
||||
for obj_id in shape_hits {
|
||||
if shift_held {
|
||||
shared.selection.add_shape_instance(obj_id);
|
||||
} else {
|
||||
// First hit replaces selection
|
||||
if shared.selection.is_empty() {
|
||||
shared.selection.add_shape_instance(obj_id);
|
||||
} else {
|
||||
// Subsequent hits add to selection
|
||||
shared.selection.add_shape_instance(obj_id);
|
||||
// Add DCEL elements to selection
|
||||
if let Some(dcel) = vector_layer.dcel_at_time(*shared.playback_time) {
|
||||
for edge_id in dcel_hits.edges {
|
||||
shared.selection.select_edge(edge_id, dcel);
|
||||
}
|
||||
for face_id in dcel_hits.faces {
|
||||
shared.selection.select_face(face_id, dcel);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -2605,7 +2738,24 @@ impl StagePane {
|
|||
}
|
||||
};
|
||||
|
||||
// Get current DCEL state (after edits) as dcel_after
|
||||
// If we were editing a curve, recompute intersections before snapshotting.
|
||||
// This detects new crossings between the edited edge and other edges,
|
||||
// splitting them to maintain valid DCEL topology.
|
||||
let editing_edge_id = match &*shared.tool_state {
|
||||
lightningbeam_core::tool::ToolState::EditingCurve { edge_id, .. } => Some(*edge_id),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
if let Some(edge_id) = editing_edge_id {
|
||||
let document = shared.action_executor.document_mut();
|
||||
if let Some(AnyLayer::Vector(vl)) = document.get_layer_mut(&active_layer_id) {
|
||||
if let Some(dcel) = vl.dcel_at_time_mut(cache.time) {
|
||||
dcel.recompute_edge_intersections(edge_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get current DCEL state (after edits + intersection splits) as dcel_after
|
||||
let dcel_after = {
|
||||
let document = shared.action_executor.document();
|
||||
match document.get_layer(&active_layer_id) {
|
||||
|
|
@ -3348,10 +3498,7 @@ impl StagePane {
|
|||
|
||||
shared.selection.clear();
|
||||
|
||||
// Select fully-inside shapes directly
|
||||
for &id in &classification.fully_inside {
|
||||
shared.selection.add_shape_instance(id);
|
||||
}
|
||||
// TODO: DCEL - region selection element selection deferred to Phase 2
|
||||
|
||||
// For intersecting shapes: compute clip and create temporary splits
|
||||
let splits = Vec::new();
|
||||
|
|
@ -4154,7 +4301,7 @@ impl StagePane {
|
|||
}
|
||||
|
||||
// For vector layers: single object uses rotated bbox, multiple objects use axis-aligned bbox
|
||||
let total_selected = shared.selection.shape_instances().len() + shared.selection.clip_instances().len();
|
||||
let total_selected = shared.selection.clip_instances().len();
|
||||
if total_selected == 1 {
|
||||
// Single object - rotated bounding box
|
||||
self.handle_transform_single_object(ui, response, point, &active_layer_id, shared);
|
||||
|
|
@ -4368,9 +4515,7 @@ impl StagePane {
|
|||
use vello::kurbo::Affine;
|
||||
|
||||
// Get the single selected object (either shape instance or clip instance)
|
||||
let object_id = if let Some(&id) = shared.selection.shape_instances().iter().next() {
|
||||
id
|
||||
} else if let Some(&id) = shared.selection.clip_instances().iter().next() {
|
||||
let object_id = if let Some(&id) = shared.selection.clip_instances().iter().next() {
|
||||
id
|
||||
} else {
|
||||
return; // No selection, shouldn't happen
|
||||
|
|
@ -5170,19 +5315,16 @@ impl StagePane {
|
|||
if let Some(active_layer_id) = shared.active_layer_id {
|
||||
use std::collections::HashMap;
|
||||
|
||||
let mut shape_instance_positions = HashMap::new();
|
||||
let mut clip_instance_transforms = HashMap::new();
|
||||
|
||||
// Separate shape instances from clip instances
|
||||
// Process clip instances from drag
|
||||
for (object_id, original_pos) in original_positions {
|
||||
let new_pos = Point::new(
|
||||
original_pos.x + delta.x,
|
||||
original_pos.y + delta.y,
|
||||
);
|
||||
|
||||
if shared.selection.contains_shape_instance(&object_id) {
|
||||
shape_instance_positions.insert(object_id, (original_pos, new_pos));
|
||||
} else if shared.selection.contains_clip_instance(&object_id) {
|
||||
if shared.selection.contains_clip_instance(&object_id) {
|
||||
// For clip instances, get the full transform
|
||||
if let Some(layer) = shared.action_executor.document().get_layer(active_layer_id) {
|
||||
if let lightningbeam_core::layer::AnyLayer::Vector(vector_layer) = layer {
|
||||
|
|
@ -5202,13 +5344,6 @@ impl StagePane {
|
|||
}
|
||||
}
|
||||
|
||||
// Create action for shape instances
|
||||
if !shape_instance_positions.is_empty() {
|
||||
use lightningbeam_core::actions::MoveShapeInstancesAction;
|
||||
let action = MoveShapeInstancesAction::new(*active_layer_id, *shared.playback_time, shape_instance_positions);
|
||||
shared.pending_actions.push(Box::new(action));
|
||||
}
|
||||
|
||||
// Create action for clip instances
|
||||
if !clip_instance_transforms.is_empty() {
|
||||
use lightningbeam_core::actions::TransformClipInstancesAction;
|
||||
|
|
@ -5247,8 +5382,8 @@ impl StagePane {
|
|||
*shared.playback_time,
|
||||
);
|
||||
|
||||
// Hit test shape instances in rectangle
|
||||
let shape_hits = hit_test::hit_test_objects_in_rect(
|
||||
// Hit test DCEL elements in rectangle
|
||||
let dcel_hits = hit_test::hit_test_dcel_in_rect(
|
||||
vector_layer,
|
||||
*shared.playback_time,
|
||||
selection_rect,
|
||||
|
|
@ -5260,9 +5395,14 @@ impl StagePane {
|
|||
shared.selection.add_clip_instance(clip_id);
|
||||
}
|
||||
|
||||
// Add shape instances to selection
|
||||
for obj_id in shape_hits {
|
||||
shared.selection.add_shape_instance(obj_id);
|
||||
// Add DCEL elements to selection
|
||||
if let Some(dcel) = vector_layer.dcel_at_time(*shared.playback_time) {
|
||||
for edge_id in dcel_hits.edges {
|
||||
shared.selection.select_edge(edge_id, dcel);
|
||||
}
|
||||
for face_id in dcel_hits.faces {
|
||||
shared.selection.select_face(face_id, dcel);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -5473,20 +5613,26 @@ impl StagePane {
|
|||
let cp_color = egui::Color32::from_rgba_premultiplied(180, 180, 255, 200);
|
||||
let cp_hover_color = egui::Color32::from_rgb(100, 160, 255);
|
||||
let cp_line_stroke = egui::Stroke::new(1.0, egui::Color32::from_rgba_premultiplied(120, 120, 200, 150));
|
||||
let curve_hover_stroke = egui::Stroke::new(3.0 / self.zoom, egui::Color32::from_rgb(60, 140, 255));
|
||||
|
||||
// Determine what's hovered
|
||||
let hover_vertex = match hit {
|
||||
// Determine what's hovered (suppress during active editing to avoid flicker)
|
||||
let is_editing = matches!(
|
||||
*shared.tool_state,
|
||||
lightningbeam_core::tool::ToolState::EditingCurve { .. }
|
||||
| lightningbeam_core::tool::ToolState::EditingVertex { .. }
|
||||
| lightningbeam_core::tool::ToolState::EditingControlPoint { .. }
|
||||
| lightningbeam_core::tool::ToolState::PendingCurveInteraction { .. }
|
||||
);
|
||||
let hover_vertex = if is_editing { None } else {
|
||||
match hit {
|
||||
Some(VectorEditHit::Vertex { vertex_id }) => Some(vertex_id),
|
||||
_ => None,
|
||||
}
|
||||
};
|
||||
let hover_edge = match hit {
|
||||
Some(VectorEditHit::Curve { edge_id, .. }) => Some(edge_id),
|
||||
_ => None,
|
||||
};
|
||||
let hover_cp = match hit {
|
||||
let hover_cp = if is_editing { None } else {
|
||||
match hit {
|
||||
Some(VectorEditHit::ControlPoint { edge_id, point_index }) => Some((edge_id, point_index)),
|
||||
_ => None,
|
||||
}
|
||||
};
|
||||
|
||||
if is_bezier_edit_mode {
|
||||
|
|
@ -5544,23 +5690,7 @@ impl StagePane {
|
|||
painter.circle(screen_pos, vertex_hover_radius, vertex_color, vertex_hover_stroke);
|
||||
}
|
||||
|
||||
if let Some(eid) = hover_edge {
|
||||
// Highlight the hovered curve by drawing it thicker
|
||||
let curve = &dcel.edge(eid).curve;
|
||||
// Sample points along the curve for drawing
|
||||
let segments = 20;
|
||||
let points: Vec<egui::Pos2> = (0..=segments)
|
||||
.map(|i| {
|
||||
let t = i as f64 / segments as f64;
|
||||
use vello::kurbo::ParamCurve;
|
||||
let p = curve.eval(t);
|
||||
world_to_screen(p)
|
||||
})
|
||||
.collect();
|
||||
for pair in points.windows(2) {
|
||||
painter.line_segment([pair[0], pair[1]], curve_hover_stroke);
|
||||
}
|
||||
}
|
||||
// Note: curve hover highlight is now rendered via Vello stipple in the scene
|
||||
|
||||
if let Some((eid, pidx)) = hover_cp {
|
||||
let curve = &dcel.edge(eid).curve;
|
||||
|
|
@ -5911,6 +6041,16 @@ impl PaneRenderer for StagePane {
|
|||
None
|
||||
};
|
||||
|
||||
// Compute mouse world position for hover hit testing in the Vello callback
|
||||
let mouse_world_pos = ui.input(|i| i.pointer.hover_pos())
|
||||
.filter(|pos| rect.contains(*pos))
|
||||
.map(|pos| {
|
||||
let canvas_pos = pos - rect.min;
|
||||
let doc_pos = (canvas_pos - self.pan_offset) / self.zoom;
|
||||
let local = self.doc_to_clip_local(doc_pos, shared);
|
||||
vello::kurbo::Point::new(local.x as f64, local.y as f64)
|
||||
});
|
||||
|
||||
// Use egui's custom painting callback for Vello
|
||||
// document_arc() returns Arc<Document> - cheap pointer copy, not deep clone
|
||||
let callback = VelloCallback { ctx: VelloRenderContext {
|
||||
|
|
@ -5936,6 +6076,7 @@ impl PaneRenderer for StagePane {
|
|||
editing_instance_id: shared.editing_instance_id,
|
||||
editing_parent_layer_id: shared.editing_parent_layer_id,
|
||||
region_selection: shared.region_selection.clone(),
|
||||
mouse_world_pos,
|
||||
}};
|
||||
|
||||
let cb = egui_wgpu::Callback::new_paint_callback(
|
||||
|
|
|
|||
Loading…
Reference in New Issue