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;
|
let mut j = i + 1;
|
||||||
while j < intersections.len() {
|
while j < intersections.len() {
|
||||||
let dist = (intersections[i].point - intersections[j].point).hypot();
|
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);
|
intersections.remove(j);
|
||||||
} else {
|
} else {
|
||||||
j += 1;
|
j += 1;
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
//! maintained such that wherever two strokes intersect there is a vertex.
|
//! maintained such that wherever two strokes intersect there is a vertex.
|
||||||
|
|
||||||
use crate::shape::{FillRule, ShapeColor, StrokeStyle};
|
use crate::shape::{FillRule, ShapeColor, StrokeStyle};
|
||||||
use kurbo::{BezPath, CubicBez, Point};
|
use kurbo::{BezPath, CubicBez, ParamCurveArclen, Point};
|
||||||
use rstar::{PointDistance, RTree, RTreeObject, AABB};
|
use rstar::{PointDistance, RTree, RTreeObject, AABB};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::fmt;
|
use std::fmt;
|
||||||
|
|
@ -1036,9 +1036,11 @@ impl Dcel {
|
||||||
self.vertices[v1.idx()].outgoing = HalfEdgeId::NONE;
|
self.vertices[v1.idx()].outgoing = HalfEdgeId::NONE;
|
||||||
self.vertices[v2.idx()].outgoing = HalfEdgeId::NONE;
|
self.vertices[v2.idx()].outgoing = HalfEdgeId::NONE;
|
||||||
} else if fwd_next == he_bwd {
|
} else if fwd_next == he_bwd {
|
||||||
// he_fwd → he_bwd is a spur: bwd_prev → fwd_prev
|
// he_fwd → he_bwd is a spur (consecutive in cycle):
|
||||||
self.half_edges[bwd_prev.idx()].next = bwd_next;
|
// ... → fwd_prev → he_fwd → he_bwd → bwd_next → ...
|
||||||
self.half_edges[bwd_next.idx()].prev = bwd_prev;
|
// 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
|
// v2 (origin of he_bwd) becomes isolated
|
||||||
self.vertices[v2.idx()].outgoing = HalfEdgeId::NONE;
|
self.vertices[v2.idx()].outgoing = HalfEdgeId::NONE;
|
||||||
// Update v1's outgoing if needed
|
// Update v1's outgoing if needed
|
||||||
|
|
@ -1046,9 +1048,11 @@ impl Dcel {
|
||||||
self.vertices[v1.idx()].outgoing = bwd_next;
|
self.vertices[v1.idx()].outgoing = bwd_next;
|
||||||
}
|
}
|
||||||
} else if bwd_next == he_fwd {
|
} else if bwd_next == he_fwd {
|
||||||
// Similar spur in the other direction
|
// he_bwd → he_fwd is a spur (consecutive in cycle):
|
||||||
self.half_edges[fwd_prev.idx()].next = fwd_next;
|
// ... → bwd_prev → he_bwd → he_fwd → fwd_next → ...
|
||||||
self.half_edges[fwd_next.idx()].prev = fwd_prev;
|
// 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;
|
self.vertices[v1.idx()].outgoing = HalfEdgeId::NONE;
|
||||||
if self.vertices[v2.idx()].outgoing == he_bwd {
|
if self.vertices[v2.idx()].outgoing == he_bwd {
|
||||||
self.vertices[v2.idx()].outgoing = fwd_next;
|
self.vertices[v2.idx()].outgoing = fwd_next;
|
||||||
|
|
@ -1071,18 +1075,33 @@ impl Dcel {
|
||||||
|
|
||||||
// Reassign all half-edges from dying face to surviving face
|
// Reassign all half-edges from dying face to surviving face
|
||||||
if surviving != dying && !dying.is_none() {
|
if surviving != dying && !dying.is_none() {
|
||||||
// Walk the remaining boundary of the dying face
|
// Find a valid starting half-edge for the walk.
|
||||||
// (After removal, the dying face's half-edges are now part of surviving)
|
// The dying face's outer_half_edge may point to one of the removed half-edges,
|
||||||
if !self.faces[dying.idx()].outer_half_edge.is_none()
|
// so we use a surviving neighbor (fwd_next or bwd_next) that was spliced in.
|
||||||
&& self.faces[dying.idx()].outer_half_edge != he_fwd
|
let dying_ohe = self.faces[dying.idx()].outer_half_edge;
|
||||||
&& self.faces[dying.idx()].outer_half_edge != he_bwd
|
let walk_start = if dying_ohe.is_none() {
|
||||||
{
|
HalfEdgeId::NONE
|
||||||
let start = self.faces[dying.idx()].outer_half_edge;
|
} else if dying_ohe != he_fwd && dying_ohe != he_bwd {
|
||||||
let mut cur = start;
|
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 {
|
loop {
|
||||||
self.half_edges[cur.idx()].face = surviving;
|
self.half_edges[cur.idx()].face = surviving;
|
||||||
cur = self.half_edges[cur.idx()].next;
|
cur = self.half_edges[cur.idx()].next;
|
||||||
if cur == start {
|
if cur == walk_start {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1359,6 +1378,465 @@ impl Dcel {
|
||||||
result
|
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).
|
/// Find which face contains a given point (brute force for now).
|
||||||
/// Returns FaceId(0) (unbounded) if no bounded face contains the point.
|
/// Returns FaceId(0) (unbounded) if no bounded face contains the point.
|
||||||
fn find_face_containing_point(&self, point: Point) -> FaceId {
|
fn find_face_containing_point(&self, point: Point) -> FaceId {
|
||||||
|
|
@ -1737,4 +2215,151 @@ mod tests {
|
||||||
let path = dcel.face_to_bezpath(new_face);
|
let path = dcel.face_to_bezpath(new_face);
|
||||||
assert!(!path.elements().is_empty());
|
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
|
//! Hit testing for selection and interaction
|
||||||
//!
|
//!
|
||||||
//! Provides functions for testing if points or rectangles intersect with
|
//! 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::clip::ClipInstance;
|
||||||
use crate::dcel::{VertexId, EdgeId, FaceId};
|
use crate::dcel::{VertexId, EdgeId, FaceId};
|
||||||
use crate::layer::VectorLayer;
|
use crate::layer::VectorLayer;
|
||||||
use crate::shape::Shape; // TODO: remove after DCEL migration complete
|
use crate::shape::Shape;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
use vello::kurbo::{Affine, BezPath, Point, Rect, Shape as KurboShape};
|
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
|
/// Result of a hit test operation
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub enum HitResult {
|
pub enum HitResult {
|
||||||
/// Hit a shape instance
|
/// Hit a DCEL edge (stroke)
|
||||||
ShapeInstance(Uuid),
|
Edge(EdgeId),
|
||||||
|
/// Hit a DCEL face (fill)
|
||||||
|
Face(FaceId),
|
||||||
/// Hit a clip instance
|
/// Hit a clip instance
|
||||||
ClipInstance(Uuid),
|
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
|
/// # Arguments
|
||||||
///
|
///
|
||||||
|
|
@ -34,15 +44,69 @@ pub enum HitResult {
|
||||||
///
|
///
|
||||||
/// # Returns
|
/// # 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(
|
pub fn hit_test_layer(
|
||||||
_layer: &VectorLayer,
|
layer: &VectorLayer,
|
||||||
_time: f64,
|
time: f64,
|
||||||
_point: Point,
|
point: Point,
|
||||||
_tolerance: f64,
|
tolerance: f64,
|
||||||
_parent_transform: Affine,
|
parent_transform: Affine,
|
||||||
) -> Option<Uuid> {
|
) -> Option<DcelHitResult> {
|
||||||
// TODO: Implement DCEL-based hit testing (faces, edges, vertices)
|
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
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -83,17 +147,73 @@ pub fn hit_test_shape(
|
||||||
false
|
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.
|
/// Selects edges whose both endpoints are inside the rect,
|
||||||
pub fn hit_test_objects_in_rect(
|
/// and faces whose all boundary vertices are inside the rect.
|
||||||
_layer: &VectorLayer,
|
pub fn hit_test_dcel_in_rect(
|
||||||
_time: f64,
|
layer: &VectorLayer,
|
||||||
_rect: Rect,
|
time: f64,
|
||||||
_parent_transform: Affine,
|
rect: Rect,
|
||||||
) -> Vec<Uuid> {
|
parent_transform: Affine,
|
||||||
// TODO: Implement DCEL-based marquee selection
|
) -> DcelMarqueeResult {
|
||||||
Vec::new()
|
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
|
/// Classification of shapes relative to a clipping region
|
||||||
|
|
@ -316,7 +436,7 @@ pub fn hit_test_vector_editing(
|
||||||
// Transform point into layer-local space
|
// Transform point into layer-local space
|
||||||
let local_point = parent_transform.inverse() * point;
|
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)
|
// 1. Control points (only when show_control_points is true, e.g. BezierEdit tool)
|
||||||
if show_control_points {
|
if show_control_points {
|
||||||
|
|
@ -381,7 +501,23 @@ pub fn hit_test_vector_editing(
|
||||||
return Some(VectorEditHit::Curve { edge_id, parameter_t });
|
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
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,24 +1,28 @@
|
||||||
//! Selection state management
|
//! 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 serde::{Deserialize, Serialize};
|
||||||
|
use std::collections::HashSet;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
use vello::kurbo::BezPath;
|
use vello::kurbo::BezPath;
|
||||||
|
|
||||||
/// Selection state for the editor
|
/// Selection state for the editor
|
||||||
///
|
///
|
||||||
/// Maintains sets of selected shape instances, clip instances, and shapes.
|
/// Maintains sets of selected DCEL elements and clip instances.
|
||||||
/// This is separate from the document to make it easy to
|
/// The vertex/edge/face sets implicitly represent a subgraph of the DCEL —
|
||||||
/// pass around for UI rendering without needing mutable access.
|
/// connectivity is determined by shared vertices between edges.
|
||||||
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
||||||
pub struct Selection {
|
pub struct Selection {
|
||||||
/// Currently selected shape instances
|
/// Currently selected vertices
|
||||||
selected_shape_instances: Vec<Uuid>,
|
selected_vertices: HashSet<VertexId>,
|
||||||
|
|
||||||
/// Currently selected shapes (definitions)
|
/// Currently selected edges
|
||||||
selected_shapes: Vec<Uuid>,
|
selected_edges: HashSet<EdgeId>,
|
||||||
|
|
||||||
|
/// Currently selected faces
|
||||||
|
selected_faces: HashSet<FaceId>,
|
||||||
|
|
||||||
/// Currently selected clip instances
|
/// Currently selected clip instances
|
||||||
selected_clip_instances: Vec<Uuid>,
|
selected_clip_instances: Vec<Uuid>,
|
||||||
|
|
@ -28,54 +32,168 @@ impl Selection {
|
||||||
/// Create a new empty selection
|
/// Create a new empty selection
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
Self {
|
Self {
|
||||||
selected_shape_instances: Vec::new(),
|
selected_vertices: HashSet::new(),
|
||||||
selected_shapes: Vec::new(),
|
selected_edges: HashSet::new(),
|
||||||
|
selected_faces: HashSet::new(),
|
||||||
selected_clip_instances: Vec::new(),
|
selected_clip_instances: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Add a shape instance to the selection
|
// -----------------------------------------------------------------------
|
||||||
pub fn add_shape_instance(&mut self, id: Uuid) {
|
// DCEL element selection
|
||||||
if !self.selected_shape_instances.contains(&id) {
|
// -----------------------------------------------------------------------
|
||||||
self.selected_shape_instances.push(id);
|
|
||||||
|
/// 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
|
/// Select a face and all its boundary edges + vertices.
|
||||||
pub fn add_shape(&mut self, id: Uuid) {
|
pub fn select_face(&mut self, face_id: FaceId, dcel: &Dcel) {
|
||||||
if !self.selected_shapes.contains(&id) {
|
if face_id.is_none() || face_id.0 == 0 || dcel.face(face_id).deleted {
|
||||||
self.selected_shapes.push(id);
|
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
|
/// Deselect an edge and its vertices (if they have no other selected edges).
|
||||||
pub fn remove_shape_instance(&mut self, id: &Uuid) {
|
pub fn deselect_edge(&mut self, edge_id: EdgeId, dcel: &Dcel) {
|
||||||
self.selected_shape_instances.retain(|&x| x != *id);
|
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
|
/// Deselect a face (edges/vertices stay if still referenced by other selections).
|
||||||
pub fn remove_shape(&mut self, id: &Uuid) {
|
pub fn deselect_face(&mut self, face_id: FaceId) {
|
||||||
self.selected_shapes.retain(|&x| x != *id);
|
self.selected_faces.remove(&face_id);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Toggle a shape instance's selection state
|
/// Toggle an edge's selection state.
|
||||||
pub fn toggle_shape_instance(&mut self, id: Uuid) {
|
pub fn toggle_edge(&mut self, edge_id: EdgeId, dcel: &Dcel) {
|
||||||
if self.contains_shape_instance(&id) {
|
if self.selected_edges.contains(&edge_id) {
|
||||||
self.remove_shape_instance(&id);
|
self.deselect_edge(edge_id, dcel);
|
||||||
} else {
|
} else {
|
||||||
self.add_shape_instance(id);
|
self.select_edge(edge_id, dcel);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Toggle a shape's selection state
|
/// Toggle a face's selection state.
|
||||||
pub fn toggle_shape(&mut self, id: Uuid) {
|
pub fn toggle_face(&mut self, face_id: FaceId, dcel: &Dcel) {
|
||||||
if self.contains_shape(&id) {
|
if self.selected_faces.contains(&face_id) {
|
||||||
self.remove_shape(&id);
|
self.deselect_face(face_id);
|
||||||
} else {
|
} 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
|
/// Add a clip instance to the selection
|
||||||
pub fn add_clip_instance(&mut self, id: Uuid) {
|
pub fn add_clip_instance(&mut self, id: Uuid) {
|
||||||
if !self.selected_clip_instances.contains(&id) {
|
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
|
/// Check if a clip instance is selected
|
||||||
pub fn contains_clip_instance(&self, id: &Uuid) -> bool {
|
pub fn contains_clip_instance(&self, id: &Uuid) -> bool {
|
||||||
self.selected_clip_instances.contains(id)
|
self.selected_clip_instances.contains(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check if selection is empty
|
/// Clear only clip instance selections
|
||||||
pub fn is_empty(&self) -> bool {
|
pub fn clear_clip_instances(&mut self) {
|
||||||
self.selected_shape_instances.is_empty()
|
self.selected_clip_instances.clear();
|
||||||
&& 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()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get the selected clip instances
|
/// Get the selected clip instances
|
||||||
|
|
@ -171,86 +235,61 @@ impl Selection {
|
||||||
self.selected_clip_instances.len()
|
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)
|
/// Set selection to a single clip instance (clears previous selection)
|
||||||
pub fn select_only_clip_instance(&mut self, id: Uuid) {
|
pub fn select_only_clip_instance(&mut self, id: Uuid) {
|
||||||
self.clear();
|
self.clear();
|
||||||
self.add_clip_instance(id);
|
self.add_clip_instance(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set selection to multiple objects (clears previous selection)
|
/// Set selection to multiple clip instances (clears previous clip 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)
|
|
||||||
pub fn select_clip_instances(&mut self, ids: &[Uuid]) {
|
pub fn select_clip_instances(&mut self, ids: &[Uuid]) {
|
||||||
self.clear_clip_instances();
|
self.clear_clip_instances();
|
||||||
for &id in ids {
|
for &id in ids {
|
||||||
self.add_clip_instance(id);
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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 split of shapes.
|
/// Represents a temporary region-based selection.
|
||||||
///
|
///
|
||||||
/// When a region select is active, shapes that cross the region boundary
|
/// When a region select is active, elements that cross the region boundary
|
||||||
/// are temporarily split into "inside" and "outside" parts. The inside
|
/// are tracked. If the user performs an operation, the selection is
|
||||||
/// parts are selected. If the user performs an operation, the split is
|
/// committed; if they deselect, the original state is restored.
|
||||||
/// committed; if they deselect, the original shapes are restored.
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct RegionSelection {
|
pub struct RegionSelection {
|
||||||
/// The clipping region as a closed BezPath (polygon or rect)
|
/// The clipping region as a closed BezPath (polygon or rect)
|
||||||
pub region_path: BezPath,
|
pub region_path: BezPath,
|
||||||
/// Layer containing the affected shapes
|
/// Layer containing the affected elements
|
||||||
pub layer_id: Uuid,
|
pub layer_id: Uuid,
|
||||||
/// Keyframe time
|
/// Keyframe time
|
||||||
pub time: f64,
|
pub time: f64,
|
||||||
/// Per-shape split results
|
/// Per-shape split results (legacy, kept for compatibility)
|
||||||
pub splits: Vec<ShapeSplit>,
|
pub splits: Vec<()>,
|
||||||
/// Shape IDs that were fully inside the region (not split, just selected)
|
/// IDs that were fully inside the region
|
||||||
pub fully_inside_ids: Vec<Uuid>,
|
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,
|
pub committed: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One shape's split result from a region selection
|
|
||||||
#[derive(Clone, Debug)]
|
|
||||||
pub struct ShapeSplit {
|
|
||||||
/// The original shape (stored for reverting)
|
|
||||||
pub original_shape: Shape,
|
|
||||||
/// UUID for the "inside" portion shape
|
|
||||||
pub inside_shape_id: Uuid,
|
|
||||||
/// The clipped path inside the region
|
|
||||||
pub inside_path: BezPath,
|
|
||||||
/// UUID for the "outside" portion shape
|
|
||||||
pub outside_shape_id: Uuid,
|
|
||||||
/// The clipped path outside the region
|
|
||||||
pub outside_path: BezPath,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
@ -259,67 +298,7 @@ mod tests {
|
||||||
fn test_selection_creation() {
|
fn test_selection_creation() {
|
||||||
let selection = Selection::new();
|
let selection = Selection::new();
|
||||||
assert!(selection.is_empty());
|
assert!(selection.is_empty());
|
||||||
assert_eq!(selection.shape_instance_count(), 0);
|
assert_eq!(selection.clip_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());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -370,54 +349,34 @@ mod tests {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_clear_clip_instances() {
|
fn test_clear() {
|
||||||
let mut selection = Selection::new();
|
let mut selection = Selection::new();
|
||||||
selection.add_clip_instance(Uuid::new_v4());
|
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());
|
assert!(!selection.is_empty());
|
||||||
|
|
||||||
selection.clear();
|
selection.clear();
|
||||||
assert!(selection.is_empty());
|
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,
|
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
|
/// Drawing a region selection rectangle
|
||||||
RegionSelectingRect {
|
RegionSelectingRect {
|
||||||
start: Point,
|
start: Point,
|
||||||
|
|
|
||||||
|
|
@ -1658,37 +1658,8 @@ impl EditorApp {
|
||||||
};
|
};
|
||||||
|
|
||||||
self.clipboard_manager.copy(content);
|
self.clipboard_manager.copy(content);
|
||||||
} else if !self.selection.shape_instances().is_empty() {
|
} else if self.selection.has_dcel_selection() {
|
||||||
let active_layer_id = match self.active_layer_id {
|
// TODO: DCEL copy/paste deferred to Phase 2 (serialize subgraph)
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1736,26 +1707,45 @@ impl EditorApp {
|
||||||
}
|
}
|
||||||
|
|
||||||
self.selection.clear_clip_instances();
|
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 {
|
let active_layer_id = match self.active_layer_id {
|
||||||
Some(id) => id,
|
Some(id) => id,
|
||||||
None => return,
|
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() {
|
||||||
active_layer_id,
|
let document = self.action_executor.document();
|
||||||
shape_ids,
|
if let Some(layer) = document.get_layer(&active_layer_id) {
|
||||||
self.playback_time,
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if let Err(e) = self.action_executor.execute(Box::new(action)) {
|
let action = lightningbeam_core::actions::ModifyDcelAction::new(
|
||||||
eprintln!("Delete shapes failed: {}", e);
|
active_layer_id,
|
||||||
|
self.playback_time,
|
||||||
|
dcel_before.clone(),
|
||||||
|
dcel_after,
|
||||||
|
"Delete selected edges",
|
||||||
|
);
|
||||||
|
|
||||||
|
if let Err(e) = self.action_executor.execute(Box::new(action)) {
|
||||||
|
eprintln!("Delete DCEL edges failed: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
self.selection.clear_shape_instances();
|
self.selection.clear_dcel_selection();
|
||||||
self.selection.clear_shapes();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -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
|
// 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);
|
let _ = (vector_layer, shapes);
|
||||||
|
|
||||||
// Select pasted shapes
|
|
||||||
self.selection.clear_shapes();
|
|
||||||
for id in new_shape_ids {
|
|
||||||
self.selection.add_shape(id);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
ClipboardContent::MidiNotes { .. } => {
|
ClipboardContent::MidiNotes { .. } => {
|
||||||
// MIDI notes are pasted directly in the piano roll pane, not here
|
// MIDI notes are pasted directly in the piano roll pane, not here
|
||||||
|
|
@ -2426,44 +2408,51 @@ impl EditorApp {
|
||||||
// Modify menu
|
// Modify menu
|
||||||
MenuAction::Group => {
|
MenuAction::Group => {
|
||||||
if let Some(layer_id) = self.active_layer_id {
|
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() {
|
||||||
let clip_ids: Vec<uuid::Uuid> = self.selection.clip_instances().to_vec();
|
// TODO: DCEL group deferred to Phase 2 (extract subgraph)
|
||||||
if shape_ids.len() + clip_ids.len() >= 2 {
|
} else {
|
||||||
let instance_id = uuid::Uuid::new_v4();
|
let clip_ids: Vec<uuid::Uuid> = self.selection.clip_instances().to_vec();
|
||||||
let action = lightningbeam_core::actions::GroupAction::new(
|
if clip_ids.len() >= 2 {
|
||||||
layer_id,
|
let instance_id = uuid::Uuid::new_v4();
|
||||||
self.playback_time,
|
let action = lightningbeam_core::actions::GroupAction::new(
|
||||||
shape_ids,
|
layer_id,
|
||||||
clip_ids,
|
self.playback_time,
|
||||||
instance_id,
|
Vec::new(),
|
||||||
);
|
clip_ids,
|
||||||
if let Err(e) = self.action_executor.execute(Box::new(action)) {
|
instance_id,
|
||||||
eprintln!("Failed to group: {}", e);
|
);
|
||||||
} else {
|
if let Err(e) = self.action_executor.execute(Box::new(action)) {
|
||||||
self.selection.clear();
|
eprintln!("Failed to group: {}", e);
|
||||||
self.selection.add_clip_instance(instance_id);
|
} else {
|
||||||
|
self.selection.clear();
|
||||||
|
self.selection.add_clip_instance(instance_id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
let _ = layer_id;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
MenuAction::ConvertToMovieClip => {
|
MenuAction::ConvertToMovieClip => {
|
||||||
if let Some(layer_id) = self.active_layer_id {
|
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() {
|
||||||
let clip_ids: Vec<uuid::Uuid> = self.selection.clip_instances().to_vec();
|
// TODO: DCEL convert-to-movie-clip deferred to Phase 2
|
||||||
if shape_ids.len() + clip_ids.len() >= 1 {
|
} else {
|
||||||
let instance_id = uuid::Uuid::new_v4();
|
let clip_ids: Vec<uuid::Uuid> = self.selection.clip_instances().to_vec();
|
||||||
let action = lightningbeam_core::actions::ConvertToMovieClipAction::new(
|
if clip_ids.len() >= 1 {
|
||||||
layer_id,
|
let instance_id = uuid::Uuid::new_v4();
|
||||||
self.playback_time,
|
let action = lightningbeam_core::actions::ConvertToMovieClipAction::new(
|
||||||
shape_ids,
|
layer_id,
|
||||||
clip_ids,
|
self.playback_time,
|
||||||
instance_id,
|
Vec::new(),
|
||||||
);
|
clip_ids,
|
||||||
if let Err(e) = self.action_executor.execute(Box::new(action)) {
|
instance_id,
|
||||||
eprintln!("Failed to convert to movie clip: {}", e);
|
);
|
||||||
} else {
|
if let Err(e) = self.action_executor.execute(Box::new(action)) {
|
||||||
self.selection.clear();
|
eprintln!("Failed to convert to movie clip: {}", e);
|
||||||
self.selection.add_clip_instance(instance_id);
|
} else {
|
||||||
|
self.selection.clear();
|
||||||
|
self.selection.add_clip_instance(instance_id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,11 +6,8 @@
|
||||||
/// - Shape properties (fill/stroke for selected shapes)
|
/// - Shape properties (fill/stroke for selected shapes)
|
||||||
/// - Document settings (when nothing is selected)
|
/// - Document settings (when nothing is selected)
|
||||||
|
|
||||||
use eframe::egui::{self, DragValue, Sense, Ui};
|
use eframe::egui::{self, DragValue, Ui};
|
||||||
use lightningbeam_core::actions::{
|
use lightningbeam_core::actions::SetDocumentPropertiesAction;
|
||||||
InstancePropertyChange, SetDocumentPropertiesAction, SetInstancePropertiesAction,
|
|
||||||
SetShapePropertiesAction,
|
|
||||||
};
|
|
||||||
use lightningbeam_core::layer::AnyLayer;
|
use lightningbeam_core::layer::AnyLayer;
|
||||||
use lightningbeam_core::shape::ShapeColor;
|
use lightningbeam_core::shape::ShapeColor;
|
||||||
use lightningbeam_core::tool::{SimplifyMode, Tool};
|
use lightningbeam_core::tool::{SimplifyMode, Tool};
|
||||||
|
|
@ -21,8 +18,6 @@ use uuid::Uuid;
|
||||||
pub struct InfopanelPane {
|
pub struct InfopanelPane {
|
||||||
/// Whether the tool options section is expanded
|
/// Whether the tool options section is expanded
|
||||||
tool_section_open: bool,
|
tool_section_open: bool,
|
||||||
/// Whether the transform section is expanded
|
|
||||||
transform_section_open: bool,
|
|
||||||
/// Whether the shape properties section is expanded
|
/// Whether the shape properties section is expanded
|
||||||
shape_section_open: bool,
|
shape_section_open: bool,
|
||||||
}
|
}
|
||||||
|
|
@ -31,7 +26,6 @@ impl InfopanelPane {
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
Self {
|
Self {
|
||||||
tool_section_open: true,
|
tool_section_open: true,
|
||||||
transform_section_open: true,
|
|
||||||
shape_section_open: true,
|
shape_section_open: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -41,24 +35,10 @@ impl InfopanelPane {
|
||||||
struct SelectionInfo {
|
struct SelectionInfo {
|
||||||
/// True if nothing is selected
|
/// True if nothing is selected
|
||||||
is_empty: bool,
|
is_empty: bool,
|
||||||
/// Number of selected shape instances
|
/// Number of selected DCEL elements (edges + faces)
|
||||||
shape_count: usize,
|
dcel_count: usize,
|
||||||
/// Layer ID of selected shapes (assumes single layer selection for now)
|
/// Layer ID of selected elements (assumes single layer selection for now)
|
||||||
layer_id: Option<Uuid>,
|
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)
|
// Shape property values (None = mixed)
|
||||||
fill_color: Option<Option<ShapeColor>>,
|
fill_color: Option<Option<ShapeColor>>,
|
||||||
|
|
@ -70,18 +50,8 @@ impl Default for SelectionInfo {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
is_empty: true,
|
is_empty: true,
|
||||||
shape_count: 0,
|
dcel_count: 0,
|
||||||
layer_id: None,
|
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,
|
fill_color: None,
|
||||||
stroke_color: None,
|
stroke_color: None,
|
||||||
stroke_width: None,
|
stroke_width: None,
|
||||||
|
|
@ -94,17 +64,15 @@ impl InfopanelPane {
|
||||||
fn gather_selection_info(&self, shared: &SharedPaneState) -> SelectionInfo {
|
fn gather_selection_info(&self, shared: &SharedPaneState) -> SelectionInfo {
|
||||||
let mut info = SelectionInfo::default();
|
let mut info = SelectionInfo::default();
|
||||||
|
|
||||||
let selected_instances = shared.selection.shape_instances();
|
let edge_count = shared.selection.selected_edges().len();
|
||||||
info.shape_count = selected_instances.len();
|
let face_count = shared.selection.selected_faces().len();
|
||||||
info.is_empty = info.shape_count == 0;
|
info.dcel_count = edge_count + face_count;
|
||||||
|
info.is_empty = info.dcel_count == 0;
|
||||||
|
|
||||||
if info.is_empty {
|
if info.is_empty {
|
||||||
return info;
|
return info;
|
||||||
}
|
}
|
||||||
|
|
||||||
info.instance_ids = selected_instances.to_vec();
|
|
||||||
|
|
||||||
// Find the layer containing the selected instances
|
|
||||||
let document = shared.action_executor.document();
|
let document = shared.action_executor.document();
|
||||||
let active_layer_id = *shared.active_layer_id;
|
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 Some(layer) = document.get_layer(&layer_id) {
|
||||||
if let AnyLayer::Vector(vector_layer) = layer {
|
if let AnyLayer::Vector(vector_layer) = layer {
|
||||||
// Gather values from all selected instances
|
if let Some(dcel) = vector_layer.dcel_at_time(*shared.playback_time) {
|
||||||
// TODO: DCEL - shape property gathering disabled during migration
|
// Gather stroke properties from selected edges
|
||||||
// (was: get_shape_in_keyframe to gather transform/fill/stroke properties)
|
let mut first_stroke_color: Option<Option<ShapeColor>> = None;
|
||||||
let _ = vector_layer;
|
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
|
// Transform section: deferred to Phase 2 (DCEL elements don't have instance transforms)
|
||||||
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("--");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Render shape properties section (fill/stroke)
|
/// Render shape properties section (fill/stroke)
|
||||||
fn render_shape_section(
|
fn render_shape_section(
|
||||||
&mut self,
|
&mut self,
|
||||||
ui: &mut Ui,
|
ui: &mut Ui,
|
||||||
path: &NodePath,
|
path: &NodePath,
|
||||||
shared: &mut SharedPaneState,
|
_shared: &mut SharedPaneState,
|
||||||
info: &SelectionInfo,
|
info: &SelectionInfo,
|
||||||
) {
|
) {
|
||||||
egui::CollapsingHeader::new("Shape")
|
egui::CollapsingHeader::new("Shape")
|
||||||
|
|
@ -479,54 +293,22 @@ impl InfopanelPane {
|
||||||
self.shape_section_open = true;
|
self.shape_section_open = true;
|
||||||
ui.add_space(4.0);
|
ui.add_space(4.0);
|
||||||
|
|
||||||
let layer_id = match info.layer_id {
|
// Fill color (read-only display for now)
|
||||||
Some(id) => id,
|
|
||||||
None => return,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Fill color
|
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
ui.label("Fill:");
|
ui.label("Fill:");
|
||||||
match info.fill_color {
|
match info.fill_color {
|
||||||
Some(Some(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,
|
color.r, color.g, color.b, color.a,
|
||||||
);
|
);
|
||||||
|
let (rect, _) = ui.allocate_exact_size(
|
||||||
if ui.color_edit_button_srgba(&mut egui_color).changed() {
|
egui::vec2(20.0, 20.0),
|
||||||
let new_color = Some(ShapeColor::new(
|
egui::Sense::hover(),
|
||||||
egui_color.r(),
|
);
|
||||||
egui_color.g(),
|
ui.painter().rect_filled(rect, 2.0, egui_color);
|
||||||
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,
|
|
||||||
);
|
|
||||||
shared.pending_actions.push(Box::new(action));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Some(None) => {
|
Some(None) => {
|
||||||
if ui.button("Add Fill").clicked() {
|
ui.label("None");
|
||||||
// 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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
None => {
|
None => {
|
||||||
ui.label("--");
|
ui.label("--");
|
||||||
|
|
@ -534,49 +316,22 @@ impl InfopanelPane {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Stroke color
|
// Stroke color (read-only display for now)
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
ui.label("Stroke:");
|
ui.label("Stroke:");
|
||||||
match info.stroke_color {
|
match info.stroke_color {
|
||||||
Some(Some(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,
|
color.r, color.g, color.b, color.a,
|
||||||
);
|
);
|
||||||
|
let (rect, _) = ui.allocate_exact_size(
|
||||||
if ui.color_edit_button_srgba(&mut egui_color).changed() {
|
egui::vec2(20.0, 20.0),
|
||||||
let new_color = Some(ShapeColor::new(
|
egui::Sense::hover(),
|
||||||
egui_color.r(),
|
);
|
||||||
egui_color.g(),
|
ui.painter().rect_filled(rect, 2.0, egui_color);
|
||||||
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,
|
|
||||||
);
|
|
||||||
shared.pending_actions.push(Box::new(action));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Some(None) => {
|
Some(None) => {
|
||||||
if ui.button("Add Stroke").clicked() {
|
ui.label("None");
|
||||||
// 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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
None => {
|
None => {
|
||||||
ui.label("--");
|
ui.label("--");
|
||||||
|
|
@ -584,28 +339,12 @@ impl InfopanelPane {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Stroke width
|
// Stroke width (read-only display for now)
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
ui.label("Stroke Width:");
|
ui.label("Stroke Width:");
|
||||||
match info.stroke_width {
|
match info.stroke_width {
|
||||||
Some(mut width) => {
|
Some(width) => {
|
||||||
let response = ui.add(
|
ui.label(format!("{:.1}", width));
|
||||||
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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
None => {
|
None => {
|
||||||
ui.label("--");
|
ui.label("--");
|
||||||
|
|
@ -737,13 +476,8 @@ impl PaneRenderer for InfopanelPane {
|
||||||
// 2. Gather selection info
|
// 2. Gather selection info
|
||||||
let info = self.gather_selection_info(shared);
|
let info = self.gather_selection_info(shared);
|
||||||
|
|
||||||
// 3. Transform section (if shapes selected)
|
// 3. Shape properties section (if DCEL elements selected)
|
||||||
if info.shape_count > 0 {
|
if info.dcel_count > 0 {
|
||||||
self.render_transform_section(ui, path, shared, &info);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. Shape properties section (if shapes selected)
|
|
||||||
if info.shape_count > 0 {
|
|
||||||
self.render_shape_section(ui, path, shared, &info);
|
self.render_shape_section(ui, path, shared, &info);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -753,14 +487,14 @@ impl PaneRenderer for InfopanelPane {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show selection count at bottom
|
// Show selection count at bottom
|
||||||
if info.shape_count > 0 {
|
if info.dcel_count > 0 {
|
||||||
ui.add_space(8.0);
|
ui.add_space(8.0);
|
||||||
ui.separator();
|
ui.separator();
|
||||||
ui.add_space(4.0);
|
ui.add_space(4.0);
|
||||||
ui.label(format!(
|
ui.label(format!(
|
||||||
"{} object{} selected",
|
"{} object{} selected",
|
||||||
info.shape_count,
|
info.dcel_count,
|
||||||
if info.shape_count == 1 { "" } else { "s" }
|
if info.dcel_count == 1 { "" } else { "s" }
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -386,6 +386,8 @@ struct VelloRenderContext {
|
||||||
editing_parent_layer_id: Option<uuid::Uuid>,
|
editing_parent_layer_id: Option<uuid::Uuid>,
|
||||||
/// Active region selection state (for rendering boundary overlay)
|
/// Active region selection state (for rendering boundary overlay)
|
||||||
region_selection: Option<lightningbeam_core::selection::RegionSelection>,
|
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
|
/// 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 selection_color = Color::from_rgb8(0, 120, 255); // Blue
|
||||||
let stroke_width = 2.0 / self.ctx.zoom.max(0.5) as f64;
|
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)
|
// 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) {
|
if !self.ctx.selection.is_empty() && !matches!(self.ctx.selected_tool, Tool::Transform) {
|
||||||
// TODO: DCEL - shape selection outlines disabled during migration
|
// Draw Flash-style stipple pattern on selected edges and faces
|
||||||
// (was: iterate shape_instances, get_shape_in_keyframe, draw bbox outlines)
|
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
|
// Also draw selection outlines for clip instances
|
||||||
for &clip_id in self.ctx.selection.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
|
// 2. Draw marquee selection rectangle
|
||||||
if let lightningbeam_core::tool::ToolState::MarqueeSelecting { ref start, ref current } = self.ctx.tool_state {
|
if let lightningbeam_core::tool::ToolState::MarqueeSelecting { ref start, ref current } = self.ctx.tool_state {
|
||||||
let marquee_rect = KurboRect::new(
|
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 single object: use object-aligned (rotated) bounding box
|
||||||
// For multiple objects: use axis-aligned bounding box (simpler for now)
|
// 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 {
|
if total_selected == 1 {
|
||||||
// Single object - draw rotated bounding box
|
// Single clip instance - draw rotated bounding box
|
||||||
let object_id = if let Some(&id) = self.ctx.selection.shape_instances().iter().next() {
|
let object_id = *self.ctx.selection.clip_instances().iter().next().unwrap();
|
||||||
id
|
|
||||||
} else {
|
|
||||||
*self.ctx.selection.clip_instances().iter().next().unwrap()
|
|
||||||
};
|
|
||||||
|
|
||||||
// TODO: DCEL - single-object transform handles disabled during migration
|
// TODO: DCEL - single-object transform handles disabled during migration
|
||||||
// (was: get_shape_in_keyframe for rotated bbox + handle drawing)
|
// (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))
|
// 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();
|
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 {
|
impl StagePane {
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
let instance_id = INSTANCE_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
let instance_id = INSTANCE_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||||
|
|
@ -2139,7 +2269,7 @@ impl StagePane {
|
||||||
Affine::IDENTITY,
|
Affine::IDENTITY,
|
||||||
false, // Select tool doesn't show control points
|
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 {
|
if let Some(hit) = vector_hit {
|
||||||
match hit {
|
match hit {
|
||||||
VectorEditHit::Vertex { vertex_id } => {
|
VectorEditHit::Vertex { vertex_id } => {
|
||||||
|
|
@ -2147,7 +2277,12 @@ impl StagePane {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
VectorEditHit::Curve { edge_id, parameter_t } => {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
_ => {
|
_ => {
|
||||||
|
|
@ -2171,38 +2306,39 @@ impl StagePane {
|
||||||
let hit_result = if let Some(clip_id) = clip_hit {
|
let hit_result = if let Some(clip_id) = clip_hit {
|
||||||
Some(hit_test::HitResult::ClipInstance(clip_id))
|
Some(hit_test::HitResult::ClipInstance(clip_id))
|
||||||
} else {
|
} 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)
|
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 {
|
if let Some(hit) = hit_result {
|
||||||
match hit {
|
match hit {
|
||||||
hit_test::HitResult::ShapeInstance(object_id) => {
|
hit_test::HitResult::Edge(edge_id) => {
|
||||||
// Shape instance was hit
|
// DCEL edge was hit
|
||||||
if shift_held {
|
if let Some(dcel) = vector_layer.dcel_at_time(*shared.playback_time) {
|
||||||
// Shift: toggle selection
|
if shift_held {
|
||||||
shared.selection.toggle_shape_instance(object_id);
|
shared.selection.toggle_edge(edge_id, dcel);
|
||||||
} else {
|
} else {
|
||||||
// No shift: replace selection
|
shared.selection.clear_dcel_selection();
|
||||||
if !shared.selection.contains_shape_instance(&object_id) {
|
shared.selection.select_edge(edge_id, dcel);
|
||||||
shared.selection.select_only_shape_instance(object_id);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// DCEL element dragging deferred to Phase 3
|
||||||
// If object is now selected, prepare for dragging
|
}
|
||||||
if shared.selection.contains_shape_instance(&object_id) {
|
hit_test::HitResult::Face(face_id) => {
|
||||||
// Store original positions of all selected objects
|
// DCEL face was hit
|
||||||
let original_positions = std::collections::HashMap::new();
|
if let Some(dcel) = vector_layer.dcel_at_time(*shared.playback_time) {
|
||||||
// TODO: DCEL - shape position lookup disabled during migration
|
if shift_held {
|
||||||
// (was: get_shape_in_keyframe to store original positions for drag)
|
shared.selection.toggle_face(face_id, dcel);
|
||||||
|
} else {
|
||||||
*shared.tool_state = ToolState::DraggingSelection {
|
shared.selection.clear_dcel_selection();
|
||||||
start_pos: point,
|
shared.selection.select_face(face_id, dcel);
|
||||||
start_mouse: point,
|
}
|
||||||
original_positions,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
// DCEL element dragging deferred to Phase 3
|
||||||
}
|
}
|
||||||
hit_test::HitResult::ClipInstance(clip_id) => {
|
hit_test::HitResult::ClipInstance(clip_id) => {
|
||||||
// Clip instance was hit
|
// Clip instance was hit
|
||||||
|
|
@ -2255,6 +2391,14 @@ impl StagePane {
|
||||||
// Mouse drag: update tool state
|
// Mouse drag: update tool state
|
||||||
if response.dragged() {
|
if response.dragged() {
|
||||||
match shared.tool_state {
|
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 { .. } => {
|
ToolState::EditingVertex { .. } | ToolState::EditingCurve { .. } => {
|
||||||
// Vector editing - update happens in helper method
|
// Vector editing - update happens in helper method
|
||||||
self.update_vector_editing(point, shared);
|
self.update_vector_editing(point, shared);
|
||||||
|
|
@ -2277,11 +2421,28 @@ impl StagePane {
|
||||||
// Mouse up: finish interaction
|
// Mouse up: finish interaction
|
||||||
let drag_stopped = response.drag_stopped();
|
let drag_stopped = response.drag_stopped();
|
||||||
let pointer_released = ui.input(|i| i.pointer.any_released());
|
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_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 { .. });
|
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() {
|
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 { .. } => {
|
ToolState::EditingVertex { .. } | ToolState::EditingCurve { .. } | ToolState::EditingControlPoint { .. } => {
|
||||||
// Finish vector editing - create action
|
// Finish vector editing - create action
|
||||||
self.finish_vector_editing(active_layer_id, shared);
|
self.finish_vector_editing(active_layer_id, shared);
|
||||||
|
|
@ -2305,8 +2466,7 @@ impl StagePane {
|
||||||
_ => return,
|
_ => return,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Separate shape instances from clip instances
|
// Process clip instance drags
|
||||||
let mut shape_instance_positions = HashMap::new();
|
|
||||||
let mut clip_instance_transforms = HashMap::new();
|
let mut clip_instance_transforms = HashMap::new();
|
||||||
|
|
||||||
for (id, original_pos) in original_positions {
|
for (id, original_pos) in original_positions {
|
||||||
|
|
@ -2315,12 +2475,7 @@ impl StagePane {
|
||||||
original_pos.y + delta.y,
|
original_pos.y + delta.y,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Check if this is a shape instance or clip instance
|
if shared.selection.contains_clip_instance(&id) {
|
||||||
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 let Some(clip_inst) = vector_layer.clip_instances.iter()
|
if let Some(clip_inst) = vector_layer.clip_instances.iter()
|
||||||
.find(|ci| ci.id == id) {
|
.find(|ci| ci.id == id) {
|
||||||
let mut old_transform = clip_inst.transform.clone();
|
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
|
// Create and submit transform action for clip instances
|
||||||
if !clip_instance_transforms.is_empty() {
|
if !clip_instance_transforms.is_empty() {
|
||||||
use lightningbeam_core::actions::TransformClipInstancesAction;
|
use lightningbeam_core::actions::TransformClipInstancesAction;
|
||||||
|
|
@ -2383,8 +2531,8 @@ impl StagePane {
|
||||||
*shared.playback_time,
|
*shared.playback_time,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Hit test shape instances in rectangle
|
// Hit test DCEL elements in rectangle
|
||||||
let shape_hits = hit_test::hit_test_objects_in_rect(
|
let dcel_hits = hit_test::hit_test_dcel_in_rect(
|
||||||
vector_layer,
|
vector_layer,
|
||||||
*shared.playback_time,
|
*shared.playback_time,
|
||||||
selection_rect,
|
selection_rect,
|
||||||
|
|
@ -2393,31 +2541,16 @@ impl StagePane {
|
||||||
|
|
||||||
// Add clip instances to selection
|
// Add clip instances to selection
|
||||||
for clip_id in clip_hits {
|
for clip_id in clip_hits {
|
||||||
if shift_held {
|
shared.selection.add_clip_instance(clip_id);
|
||||||
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
|
// Add DCEL elements to selection
|
||||||
for obj_id in shape_hits {
|
if let Some(dcel) = vector_layer.dcel_at_time(*shared.playback_time) {
|
||||||
if shift_held {
|
for edge_id in dcel_hits.edges {
|
||||||
shared.selection.add_shape_instance(obj_id);
|
shared.selection.select_edge(edge_id, dcel);
|
||||||
} else {
|
}
|
||||||
// First hit replaces selection
|
for face_id in dcel_hits.faces {
|
||||||
if shared.selection.is_empty() {
|
shared.selection.select_face(face_id, dcel);
|
||||||
shared.selection.add_shape_instance(obj_id);
|
|
||||||
} else {
|
|
||||||
// Subsequent hits add to selection
|
|
||||||
shared.selection.add_shape_instance(obj_id);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -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 dcel_after = {
|
||||||
let document = shared.action_executor.document();
|
let document = shared.action_executor.document();
|
||||||
match document.get_layer(&active_layer_id) {
|
match document.get_layer(&active_layer_id) {
|
||||||
|
|
@ -3348,10 +3498,7 @@ impl StagePane {
|
||||||
|
|
||||||
shared.selection.clear();
|
shared.selection.clear();
|
||||||
|
|
||||||
// Select fully-inside shapes directly
|
// TODO: DCEL - region selection element selection deferred to Phase 2
|
||||||
for &id in &classification.fully_inside {
|
|
||||||
shared.selection.add_shape_instance(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
// For intersecting shapes: compute clip and create temporary splits
|
// For intersecting shapes: compute clip and create temporary splits
|
||||||
let splits = Vec::new();
|
let splits = Vec::new();
|
||||||
|
|
@ -4154,7 +4301,7 @@ impl StagePane {
|
||||||
}
|
}
|
||||||
|
|
||||||
// For vector layers: single object uses rotated bbox, multiple objects use axis-aligned bbox
|
// 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 {
|
if total_selected == 1 {
|
||||||
// Single object - rotated bounding box
|
// Single object - rotated bounding box
|
||||||
self.handle_transform_single_object(ui, response, point, &active_layer_id, shared);
|
self.handle_transform_single_object(ui, response, point, &active_layer_id, shared);
|
||||||
|
|
@ -4368,9 +4515,7 @@ impl StagePane {
|
||||||
use vello::kurbo::Affine;
|
use vello::kurbo::Affine;
|
||||||
|
|
||||||
// Get the single selected object (either shape instance or clip instance)
|
// Get the single selected object (either shape instance or clip instance)
|
||||||
let object_id = if let Some(&id) = shared.selection.shape_instances().iter().next() {
|
let object_id = if let Some(&id) = shared.selection.clip_instances().iter().next() {
|
||||||
id
|
|
||||||
} else if let Some(&id) = shared.selection.clip_instances().iter().next() {
|
|
||||||
id
|
id
|
||||||
} else {
|
} else {
|
||||||
return; // No selection, shouldn't happen
|
return; // No selection, shouldn't happen
|
||||||
|
|
@ -5170,19 +5315,16 @@ impl StagePane {
|
||||||
if let Some(active_layer_id) = shared.active_layer_id {
|
if let Some(active_layer_id) = shared.active_layer_id {
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
let mut shape_instance_positions = HashMap::new();
|
|
||||||
let mut clip_instance_transforms = 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 {
|
for (object_id, original_pos) in original_positions {
|
||||||
let new_pos = Point::new(
|
let new_pos = Point::new(
|
||||||
original_pos.x + delta.x,
|
original_pos.x + delta.x,
|
||||||
original_pos.y + delta.y,
|
original_pos.y + delta.y,
|
||||||
);
|
);
|
||||||
|
|
||||||
if shared.selection.contains_shape_instance(&object_id) {
|
if shared.selection.contains_clip_instance(&object_id) {
|
||||||
shape_instance_positions.insert(object_id, (original_pos, new_pos));
|
|
||||||
} else if shared.selection.contains_clip_instance(&object_id) {
|
|
||||||
// For clip instances, get the full transform
|
// For clip instances, get the full transform
|
||||||
if let Some(layer) = shared.action_executor.document().get_layer(active_layer_id) {
|
if let Some(layer) = shared.action_executor.document().get_layer(active_layer_id) {
|
||||||
if let lightningbeam_core::layer::AnyLayer::Vector(vector_layer) = layer {
|
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
|
// Create action for clip instances
|
||||||
if !clip_instance_transforms.is_empty() {
|
if !clip_instance_transforms.is_empty() {
|
||||||
use lightningbeam_core::actions::TransformClipInstancesAction;
|
use lightningbeam_core::actions::TransformClipInstancesAction;
|
||||||
|
|
@ -5247,8 +5382,8 @@ impl StagePane {
|
||||||
*shared.playback_time,
|
*shared.playback_time,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Hit test shape instances in rectangle
|
// Hit test DCEL elements in rectangle
|
||||||
let shape_hits = hit_test::hit_test_objects_in_rect(
|
let dcel_hits = hit_test::hit_test_dcel_in_rect(
|
||||||
vector_layer,
|
vector_layer,
|
||||||
*shared.playback_time,
|
*shared.playback_time,
|
||||||
selection_rect,
|
selection_rect,
|
||||||
|
|
@ -5260,9 +5395,14 @@ impl StagePane {
|
||||||
shared.selection.add_clip_instance(clip_id);
|
shared.selection.add_clip_instance(clip_id);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add shape instances to selection
|
// Add DCEL elements to selection
|
||||||
for obj_id in shape_hits {
|
if let Some(dcel) = vector_layer.dcel_at_time(*shared.playback_time) {
|
||||||
shared.selection.add_shape_instance(obj_id);
|
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_color = egui::Color32::from_rgba_premultiplied(180, 180, 255, 200);
|
||||||
let cp_hover_color = egui::Color32::from_rgb(100, 160, 255);
|
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 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
|
// Determine what's hovered (suppress during active editing to avoid flicker)
|
||||||
let hover_vertex = match hit {
|
let is_editing = matches!(
|
||||||
Some(VectorEditHit::Vertex { vertex_id }) => Some(vertex_id),
|
*shared.tool_state,
|
||||||
_ => None,
|
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 {
|
let hover_cp = if is_editing { None } else {
|
||||||
Some(VectorEditHit::Curve { edge_id, .. }) => Some(edge_id),
|
match hit {
|
||||||
_ => None,
|
Some(VectorEditHit::ControlPoint { edge_id, point_index }) => Some((edge_id, point_index)),
|
||||||
};
|
_ => None,
|
||||||
let hover_cp = match hit {
|
}
|
||||||
Some(VectorEditHit::ControlPoint { edge_id, point_index }) => Some((edge_id, point_index)),
|
|
||||||
_ => None,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if is_bezier_edit_mode {
|
if is_bezier_edit_mode {
|
||||||
|
|
@ -5544,23 +5690,7 @@ impl StagePane {
|
||||||
painter.circle(screen_pos, vertex_hover_radius, vertex_color, vertex_hover_stroke);
|
painter.circle(screen_pos, vertex_hover_radius, vertex_color, vertex_hover_stroke);
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(eid) = hover_edge {
|
// Note: curve hover highlight is now rendered via Vello stipple in the scene
|
||||||
// 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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some((eid, pidx)) = hover_cp {
|
if let Some((eid, pidx)) = hover_cp {
|
||||||
let curve = &dcel.edge(eid).curve;
|
let curve = &dcel.edge(eid).curve;
|
||||||
|
|
@ -5911,6 +6041,16 @@ impl PaneRenderer for StagePane {
|
||||||
None
|
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
|
// Use egui's custom painting callback for Vello
|
||||||
// document_arc() returns Arc<Document> - cheap pointer copy, not deep clone
|
// document_arc() returns Arc<Document> - cheap pointer copy, not deep clone
|
||||||
let callback = VelloCallback { ctx: VelloRenderContext {
|
let callback = VelloCallback { ctx: VelloRenderContext {
|
||||||
|
|
@ -5936,6 +6076,7 @@ impl PaneRenderer for StagePane {
|
||||||
editing_instance_id: shared.editing_instance_id,
|
editing_instance_id: shared.editing_instance_id,
|
||||||
editing_parent_layer_id: shared.editing_parent_layer_id,
|
editing_parent_layer_id: shared.editing_parent_layer_id,
|
||||||
region_selection: shared.region_selection.clone(),
|
region_selection: shared.region_selection.clone(),
|
||||||
|
mouse_world_pos,
|
||||||
}};
|
}};
|
||||||
|
|
||||||
let cb = egui_wgpu::Callback::new_paint_callback(
|
let cb = egui_wgpu::Callback::new_paint_callback(
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue