Compare commits
2 Commits
89721d4c0e
...
06973d185c
| Author | SHA1 | Date |
|---|---|---|
|
|
06973d185c | |
|
|
dc93f78dc7 |
|
|
@ -123,13 +123,13 @@ pub enum ClipboardContent {
|
||||||
|
|
||||||
/// Selected DCEL geometry from a vector layer.
|
/// Selected DCEL geometry from a vector layer.
|
||||||
///
|
///
|
||||||
/// Currently a stub — `data` is opaque bytes whose format is TBD in Phase 2
|
/// `dcel_json` is the serialized subgraph (serde_json of [`crate::dcel2::Dcel`]).
|
||||||
/// once DCEL serialization is implemented. Copy/paste of vector shapes does
|
/// `svg_xml` is an SVG rendering of the same geometry for cross-app paste.
|
||||||
/// nothing until then. Secondary formats (`image/svg+xml`, `image/png`) are
|
|
||||||
/// also deferred to Phase 2.
|
|
||||||
VectorGeometry {
|
VectorGeometry {
|
||||||
/// Opaque DCEL subgraph bytes (format TBD, Phase 2).
|
/// JSON-serialized DCEL subgraph.
|
||||||
data: Vec<u8>,
|
dcel_json: String,
|
||||||
|
/// SVG representation for cross-app paste (e.g. into Inkscape).
|
||||||
|
svg_xml: String,
|
||||||
},
|
},
|
||||||
|
|
||||||
/// MIDI notes from the piano roll.
|
/// MIDI notes from the piano roll.
|
||||||
|
|
@ -225,11 +225,14 @@ impl ClipboardContent {
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── VectorGeometry ──────────────────────────────────────────────
|
// ── VectorGeometry ──────────────────────────────────────────────
|
||||||
// TODO (Phase 2): remap DCEL vertex/edge UUIDs once DCEL serialization
|
// DCEL uses integer indices (not UUIDs), so no remapping is needed.
|
||||||
// is defined.
|
ClipboardContent::VectorGeometry { dcel_json, svg_xml } => (
|
||||||
ClipboardContent::VectorGeometry { data } => {
|
ClipboardContent::VectorGeometry {
|
||||||
(ClipboardContent::VectorGeometry { data: data.clone() }, id_map)
|
dcel_json: dcel_json.clone(),
|
||||||
}
|
svg_xml: svg_xml.clone(),
|
||||||
|
},
|
||||||
|
id_map,
|
||||||
|
),
|
||||||
|
|
||||||
// ── MidiNotes ───────────────────────────────────────────────────
|
// ── MidiNotes ───────────────────────────────────────────────────
|
||||||
ClipboardContent::MidiNotes { notes } => {
|
ClipboardContent::MidiNotes { notes } => {
|
||||||
|
|
@ -540,7 +543,8 @@ impl ClipboardManager {
|
||||||
pub fn copy(&mut self, content: ClipboardContent) {
|
pub fn copy(&mut self, content: ClipboardContent) {
|
||||||
let json = serde_json::to_string(&content).unwrap_or_default();
|
let json = serde_json::to_string(&content).unwrap_or_default();
|
||||||
|
|
||||||
// Build platform entries (custom MIME always present; PNG secondary for raster).
|
// Build platform entries (custom MIME always present; secondary formats for
|
||||||
|
// specific content types: PNG for raster, SVG for vector geometry).
|
||||||
let mut entries: Vec<(&str, Vec<u8>)> =
|
let mut entries: Vec<(&str, Vec<u8>)> =
|
||||||
vec![(LIGHTNINGBEAM_MIME, json.as_bytes().to_vec())];
|
vec![(LIGHTNINGBEAM_MIME, json.as_bytes().to_vec())];
|
||||||
if let ClipboardContent::RasterPixels { pixels, width, height } = &content {
|
if let ClipboardContent::RasterPixels { pixels, width, height } = &content {
|
||||||
|
|
@ -548,6 +552,9 @@ impl ClipboardManager {
|
||||||
entries.push(("image/png", png));
|
entries.push(("image/png", png));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if let ClipboardContent::VectorGeometry { svg_xml, .. } = &content {
|
||||||
|
entries.push(("image/svg+xml", svg_xml.as_bytes().to_vec()));
|
||||||
|
}
|
||||||
|
|
||||||
clipboard_platform::set(
|
clipboard_platform::set(
|
||||||
&entries.iter().map(|(m, d)| (*m, d.as_slice())).collect::<Vec<_>>(),
|
&entries.iter().map(|(m, d)| (*m, d.as_slice())).collect::<Vec<_>>(),
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,280 @@
|
||||||
|
//! DCEL import/extract: merge a serialized DCEL subgraph into a live DCEL,
|
||||||
|
//! or extract a subgraph from selected edges for clipboard copy.
|
||||||
|
//!
|
||||||
|
//! Used by paste to insert clipboard geometry into the current layer,
|
||||||
|
//! and by copy to extract a sub-DCEL from the select-tool selection.
|
||||||
|
|
||||||
|
use super::{Dcel, EdgeId, FaceId};
|
||||||
|
use crate::shape::FillRule;
|
||||||
|
use kurbo::{BezPath, CubicBez, Point, Shape, Vec2};
|
||||||
|
use std::collections::HashSet;
|
||||||
|
|
||||||
|
impl Dcel {
|
||||||
|
/// Import all non-deleted geometry from `source` into `self` using proper
|
||||||
|
/// topological integration.
|
||||||
|
///
|
||||||
|
/// **Phase 1** — build a closed `BezPath` (shifted by `offset`) for every
|
||||||
|
/// filled face in `source`.
|
||||||
|
///
|
||||||
|
/// **Phase 2** — insert each clipboard edge via `insert_stroke`, which
|
||||||
|
/// handles intersections with existing edges, vertex snapping, and all
|
||||||
|
/// topological invariants.
|
||||||
|
///
|
||||||
|
/// **Phase 3** — apply fill colours. Rather than computing an interior
|
||||||
|
/// point for each live DCEL face (which fails for concave faces), we sample
|
||||||
|
/// a dense grid of points *inside* each clipboard `BezPath` and call
|
||||||
|
/// `find_face_at_point` on each. Every live face hit by at least one
|
||||||
|
/// sample point gets the clipboard fill colour. This correctly handles
|
||||||
|
/// the case where one clipboard face is split into several sub-faces at
|
||||||
|
/// intersection points.
|
||||||
|
///
|
||||||
|
/// `offset` is a translation applied to all positions (`Vec2::ZERO` for an
|
||||||
|
/// exact in-place copy).
|
||||||
|
pub fn import_from(&mut self, source: &Dcel, offset: Vec2) {
|
||||||
|
// ── Phase 1: clipboard face → offset BezPath ─────────────────────────
|
||||||
|
let mut fill_targets: Vec<(BezPath, super::ShapeColor, FillRule)> = Vec::new();
|
||||||
|
for (face_idx, face) in source.faces.iter().enumerate() {
|
||||||
|
if face.deleted || face_idx == 0 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let Some(color) = face.fill_color else { continue };
|
||||||
|
if let Some(path) =
|
||||||
|
clipboard_face_to_bezpath(source, FaceId(face_idx as u32), offset)
|
||||||
|
{
|
||||||
|
fill_targets.push((path, color, face.fill_rule));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Phase 2: insert each clipboard edge as a topologically-integrated stroke ──
|
||||||
|
// Record the face count before insertion so Phase 3 can distinguish old
|
||||||
|
// faces (keep their colour) from new faces (receive clipboard colour).
|
||||||
|
let faces_count_before = self.faces.len();
|
||||||
|
|
||||||
|
for edge in &source.edges {
|
||||||
|
if edge.deleted {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let c = edge.curve;
|
||||||
|
let shifted = CubicBez::new(
|
||||||
|
Point::new(c.p0.x + offset.x, c.p0.y + offset.y),
|
||||||
|
Point::new(c.p1.x + offset.x, c.p1.y + offset.y),
|
||||||
|
Point::new(c.p2.x + offset.x, c.p2.y + offset.y),
|
||||||
|
Point::new(c.p3.x + offset.x, c.p3.y + offset.y),
|
||||||
|
);
|
||||||
|
self.insert_stroke(
|
||||||
|
&[shifted],
|
||||||
|
edge.stroke_style.clone(),
|
||||||
|
edge.stroke_color,
|
||||||
|
super::DEFAULT_SNAP_EPSILON,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Phase 3: grid-sample each clipboard BezPath, paint hit live faces ─
|
||||||
|
//
|
||||||
|
// For each clipboard face boundary (already offset), lay an N×N grid
|
||||||
|
// over its bounding box. Every grid point inside the path (non-zero
|
||||||
|
// winding) is handed to `find_face_at_point`.
|
||||||
|
//
|
||||||
|
// Two cases for the returned face:
|
||||||
|
//
|
||||||
|
// a) Non-F0 face created during Phase 2 (new sub-face of an existing
|
||||||
|
// face that got split by a clipboard edge). These have face index
|
||||||
|
// >= `faces_count_before` and receive the clipboard colour.
|
||||||
|
//
|
||||||
|
// b) Still in F0 (unbounded face) — this happens when the clipboard
|
||||||
|
// region extends *outside* all pre-existing faces. The topology
|
||||||
|
// code does not auto-create faces inside F0. In this case we use
|
||||||
|
// the `cycle_he` returned by `find_face_at_point` to explicitly
|
||||||
|
// claim that finite F0 cycle as a new face, then colour it.
|
||||||
|
//
|
||||||
|
// Old faces (index < `faces_count_before`) are left untouched so that
|
||||||
|
// pre-existing geometry keeps its original colour.
|
||||||
|
for (fill_path, color, fill_rule) in &fill_targets {
|
||||||
|
let bbox = fill_path.bounding_box();
|
||||||
|
if bbox.width() < 1e-9 || bbox.height() < 1e-9 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut painted: HashSet<u32> = HashSet::new();
|
||||||
|
|
||||||
|
// N×N interior grid (avoid exact boundary lines).
|
||||||
|
const N: usize = 8;
|
||||||
|
for iy in 1..=N {
|
||||||
|
for ix in 1..=N {
|
||||||
|
let x = bbox.min_x()
|
||||||
|
+ (ix as f64 / (N + 1) as f64) * bbox.width();
|
||||||
|
let y = bbox.min_y()
|
||||||
|
+ (iy as f64 / (N + 1) as f64) * bbox.height();
|
||||||
|
let pt = Point::new(x, y);
|
||||||
|
|
||||||
|
// Only consider points confirmed inside the clipboard face.
|
||||||
|
if fill_path.winding(pt) == 0 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let fq = self.find_face_at_point(pt);
|
||||||
|
let mut fid = fq.face;
|
||||||
|
|
||||||
|
// Case (b): point is in the unbounded face but inside the
|
||||||
|
// clipboard BezPath. The pasted edge(s) already form a
|
||||||
|
// closed cycle in F0 — claim it as a new face now.
|
||||||
|
if fid.0 == 0 && !fq.cycle_he.is_none() {
|
||||||
|
let new_face = self.alloc_face();
|
||||||
|
self.assign_cycle_face(fq.cycle_he, new_face);
|
||||||
|
self.faces[new_face.idx()].outer_half_edge = fq.cycle_he;
|
||||||
|
fid = new_face;
|
||||||
|
}
|
||||||
|
|
||||||
|
if fid.is_none() || fid.0 == 0 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only paint faces that were created during this paste.
|
||||||
|
// Faces that existed before paste keep their original fill.
|
||||||
|
if fid.idx() < faces_count_before {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if painted.insert(fid.0) {
|
||||||
|
self.faces[fid.idx()].fill_color = Some(*color);
|
||||||
|
self.faces[fid.idx()].fill_rule = *fill_rule;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a closed `BezPath` for the outer boundary of a clipboard face,
|
||||||
|
/// with all positions shifted by `offset`.
|
||||||
|
///
|
||||||
|
/// Half-edges are walked CCW (DCEL convention: face is to the left of each
|
||||||
|
/// directed edge). Returns `None` if the face has no outer boundary.
|
||||||
|
fn clipboard_face_to_bezpath(source: &Dcel, face_id: FaceId, offset: Vec2) -> Option<BezPath> {
|
||||||
|
let start_he = source.face(face_id).outer_half_edge;
|
||||||
|
if start_he.is_none() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut path = BezPath::new();
|
||||||
|
let mut first = true;
|
||||||
|
let mut he_id = start_he;
|
||||||
|
let limit = source.half_edges.len() + 1;
|
||||||
|
|
||||||
|
for _ in 0..limit {
|
||||||
|
let he = source.half_edge(he_id);
|
||||||
|
if he.deleted {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let edge = source.edge(he.edge);
|
||||||
|
let c = edge.curve;
|
||||||
|
|
||||||
|
// Forward half-edge → curve goes p0→p3; backward → reversed.
|
||||||
|
let (p0, p1, p2, p3) = if edge.half_edges[0] == he_id {
|
||||||
|
(c.p0, c.p1, c.p2, c.p3)
|
||||||
|
} else {
|
||||||
|
(c.p3, c.p2, c.p1, c.p0)
|
||||||
|
};
|
||||||
|
|
||||||
|
let shift = |p: Point| Point::new(p.x + offset.x, p.y + offset.y);
|
||||||
|
|
||||||
|
if first {
|
||||||
|
path.move_to(shift(p0));
|
||||||
|
first = false;
|
||||||
|
}
|
||||||
|
path.curve_to(shift(p1), shift(p2), shift(p3));
|
||||||
|
|
||||||
|
he_id = he.next;
|
||||||
|
if he_id == start_he {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if first {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
path.close_path();
|
||||||
|
Some(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Extract faces for select-tool copy ───────────────────────────────────────
|
||||||
|
|
||||||
|
/// Extract a sub-DCEL containing the faces adjacent to the given edges.
|
||||||
|
///
|
||||||
|
/// Includes all selected edges, both adjacent faces, and all boundary edges
|
||||||
|
/// of those faces. Used by copy when the select tool has selected strokes.
|
||||||
|
pub fn extract_faces_for_edges(dcel: &Dcel, edge_ids: &HashSet<EdgeId>) -> Dcel {
|
||||||
|
if edge_ids.is_empty() {
|
||||||
|
return Dcel::new();
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut face_set: HashSet<u32> = HashSet::new();
|
||||||
|
for &eid in edge_ids {
|
||||||
|
if eid.is_none() || dcel.edge(eid).deleted {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let [he_fwd, he_bwd] = dcel.edge(eid).half_edges;
|
||||||
|
for he_id in [he_fwd, he_bwd] {
|
||||||
|
if !he_id.is_none() {
|
||||||
|
let face = dcel.half_edge(he_id).face;
|
||||||
|
if !face.is_none() && face.0 != 0 {
|
||||||
|
face_set.insert(face.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if face_set.is_empty() {
|
||||||
|
return Dcel::new();
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut boundary_edge_ids: HashSet<u32> = HashSet::new();
|
||||||
|
for (i, edge) in dcel.edges.iter().enumerate() {
|
||||||
|
if edge.deleted {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let [he_fwd, he_bwd] = edge.half_edges;
|
||||||
|
let face_fwd =
|
||||||
|
if !he_fwd.is_none() { dcel.half_edge(he_fwd).face } else { FaceId::NONE };
|
||||||
|
let face_bwd =
|
||||||
|
if !he_bwd.is_none() { dcel.half_edge(he_bwd).face } else { FaceId::NONE };
|
||||||
|
if (!face_fwd.is_none() && face_set.contains(&face_fwd.0))
|
||||||
|
|| (!face_bwd.is_none() && face_set.contains(&face_bwd.0))
|
||||||
|
{
|
||||||
|
boundary_edge_ids.insert(i as u32);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut extracted = dcel.clone();
|
||||||
|
|
||||||
|
let to_remove: Vec<EdgeId> = extracted
|
||||||
|
.edges
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.filter_map(|(i, e)| {
|
||||||
|
if !e.deleted && !boundary_edge_ids.contains(&(i as u32)) {
|
||||||
|
Some(EdgeId(i as u32))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
for eid in to_remove {
|
||||||
|
if !extracted.edges[eid.idx()].deleted {
|
||||||
|
extracted.remove_edge(eid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (i, face) in extracted.faces.iter_mut().enumerate() {
|
||||||
|
if i == 0 || face.deleted {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if !face_set.contains(&(i as u32)) {
|
||||||
|
face.fill_color = None;
|
||||||
|
face.image_fill = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extracted
|
||||||
|
}
|
||||||
|
|
@ -11,6 +11,9 @@ pub mod topology;
|
||||||
pub mod query;
|
pub mod query;
|
||||||
pub mod stroke;
|
pub mod stroke;
|
||||||
pub mod region;
|
pub mod region;
|
||||||
|
pub mod import;
|
||||||
|
|
||||||
|
pub use import::extract_faces_for_edges;
|
||||||
|
|
||||||
use crate::shape::{FillRule, ShapeColor, StrokeStyle};
|
use crate::shape::{FillRule, ShapeColor, StrokeStyle};
|
||||||
use kurbo::{CubicBez, Point};
|
use kurbo::{CubicBez, Point};
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@
|
||||||
//! different intersection positions for the same crossing.
|
//! different intersection positions for the same crossing.
|
||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
subsegment_cubic, Dcel, EdgeId, FaceId, HalfEdgeId, VertexId,
|
subsegment_cubic, Dcel, EdgeId, FaceId, VertexId,
|
||||||
};
|
};
|
||||||
use crate::curve_intersections::find_curve_intersections;
|
use crate::curve_intersections::find_curve_intersections;
|
||||||
use crate::shape::{ShapeColor, StrokeStyle};
|
use crate::shape::{ShapeColor, StrokeStyle};
|
||||||
|
|
@ -379,6 +379,13 @@ impl Dcel {
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Check against all other edges
|
// 2. Check against all other edges
|
||||||
|
//
|
||||||
|
// Collect (seg_t_on_edge_id, vertex, point, other_tail) for each
|
||||||
|
// intersection so that we can also split edge_id after processing all
|
||||||
|
// other edges. `other_tail` is the sub-edge of the other edge that
|
||||||
|
// starts at `vertex` (going forward) — used for sector-based face repair.
|
||||||
|
let mut edge_id_splits: Vec<(f64, VertexId, Point, EdgeId)> = Vec::new();
|
||||||
|
|
||||||
let edge_count = self.edges.len();
|
let edge_count = self.edges.len();
|
||||||
for other_idx in 0..edge_count {
|
for other_idx in 0..edge_count {
|
||||||
if self.edges[other_idx].deleted {
|
if self.edges[other_idx].deleted {
|
||||||
|
|
@ -417,11 +424,11 @@ impl Dcel {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort by edge_t descending — split from end first
|
// Sort by edge_t descending — split other_id from end first
|
||||||
hits.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap());
|
hits.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap());
|
||||||
|
|
||||||
let mut head_end = 1.0_f64;
|
let mut head_end = 1.0_f64;
|
||||||
for (_seg_t, original_edge_t, point) in hits {
|
for (seg_t, original_edge_t, point) in hits {
|
||||||
let remapped_t = (original_edge_t / head_end)
|
let remapped_t = (original_edge_t / head_end)
|
||||||
.clamp(ENDPOINT_T_MARGIN, 1.0 - ENDPOINT_T_MARGIN);
|
.clamp(ENDPOINT_T_MARGIN, 1.0 - ENDPOINT_T_MARGIN);
|
||||||
|
|
||||||
|
|
@ -431,10 +438,65 @@ impl Dcel {
|
||||||
self.snap_edge_endpoints_to_vertex(new_edge, vertex);
|
self.snap_edge_endpoints_to_vertex(new_edge, vertex);
|
||||||
|
|
||||||
created.push((vertex, new_edge));
|
created.push((vertex, new_edge));
|
||||||
|
// Record this intersection for splitting edge_id below.
|
||||||
|
// `new_edge` is other_tail: the sub-edge of other_id going
|
||||||
|
// forward from vertex, used for sector-based face repair.
|
||||||
|
edge_id_splits.push((seg_t, vertex, point, new_edge));
|
||||||
head_end = original_edge_t;
|
head_end = original_edge_t;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 3. Split edge_id at every intersection point found above.
|
||||||
|
//
|
||||||
|
// We reuse the vertices already created for the other-edge splits so
|
||||||
|
// the two sides of each crossing share exactly one vertex.
|
||||||
|
//
|
||||||
|
// After all splits, each crossing vertex has 4 outgoing half-edges
|
||||||
|
// whose angular ordering was not maintained by the mechanical splices.
|
||||||
|
// Call rebuild_vertex_fan + repair_face_cycles_at_vertex to fix this
|
||||||
|
// (same pattern as the self-intersection case above).
|
||||||
|
if !edge_id_splits.is_empty() {
|
||||||
|
// Sort by seg_t descending — split edge_id from end first so
|
||||||
|
// edge_id always remains the head piece [0 .. current_split_t].
|
||||||
|
edge_id_splits.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap());
|
||||||
|
|
||||||
|
// Collect (vertex, editing_tail, other_tail) for sector-based repair.
|
||||||
|
// editing_tail = sub-edge of edge_id going forward from vertex.
|
||||||
|
// other_tail = sub-edge of the other edge going forward from vertex.
|
||||||
|
let mut touched_info: Vec<(VertexId, EdgeId, EdgeId)> = Vec::new();
|
||||||
|
|
||||||
|
let mut head_end = 1.0_f64;
|
||||||
|
for (original_seg_t, vertex, point, other_tail) in edge_id_splits {
|
||||||
|
if self.edges[edge_id.idx()].deleted {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let remapped_t = (original_seg_t / head_end)
|
||||||
|
.clamp(ENDPOINT_T_MARGIN, 1.0 - ENDPOINT_T_MARGIN);
|
||||||
|
|
||||||
|
let (_, editing_tail) = self.split_edge_at_vertex(edge_id, remapped_t, vertex);
|
||||||
|
// Snap both pieces to the exact intersection point.
|
||||||
|
self.vertices[vertex.idx()].position = point;
|
||||||
|
self.snap_edge_endpoints_to_vertex(edge_id, vertex);
|
||||||
|
self.snap_edge_endpoints_to_vertex(editing_tail, vertex);
|
||||||
|
|
||||||
|
created.push((vertex, editing_tail));
|
||||||
|
// Only the first crossing for each vertex is repaired; extra
|
||||||
|
// crossings at the same vertex are uncommon and fall back to
|
||||||
|
// the basic fan rebuild inside repair_crossing_vertex.
|
||||||
|
if !touched_info.iter().any(|&(v, _, _)| v == vertex) {
|
||||||
|
touched_info.push((vertex, editing_tail, other_tail));
|
||||||
|
}
|
||||||
|
head_end = original_seg_t;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use sector-based face assignment: for each crossing vertex,
|
||||||
|
// rebuild the angular fan and assign faces using the tangent
|
||||||
|
// cross-product rule (editing face wins the overlap region).
|
||||||
|
for (v, editing_tail, other_tail) in touched_info {
|
||||||
|
self.repair_crossing_vertex(v, editing_tail, other_tail);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
created
|
created
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -431,7 +431,7 @@ impl Dcel {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set the face of every half-edge in the cycle starting at `start`.
|
/// Set the face of every half-edge in the cycle starting at `start`.
|
||||||
fn assign_cycle_face(&mut self, start: HalfEdgeId, face: FaceId) {
|
pub(crate) fn assign_cycle_face(&mut self, start: HalfEdgeId, face: FaceId) {
|
||||||
self.half_edges[start.idx()].face = face;
|
self.half_edges[start.idx()].face = face;
|
||||||
let mut cur = self.half_edges[start.idx()].next;
|
let mut cur = self.half_edges[start.idx()].next;
|
||||||
let mut steps = 0;
|
let mut steps = 0;
|
||||||
|
|
@ -908,6 +908,106 @@ impl Dcel {
|
||||||
new_faces
|
new_faces
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Repair face assignments at a vertex where `editing_tail` (a sub-edge of
|
||||||
|
/// the edge being dragged) and `other_tail` (a sub-edge of the other edge)
|
||||||
|
/// meet after a crossing split.
|
||||||
|
///
|
||||||
|
/// The face to the LEFT of each tail's forward direction is used to classify
|
||||||
|
/// sectors at `vertex`. "Editing face wins" on overlap: sectors to the left
|
||||||
|
/// of the editing edge's tangent get `face_a`; remaining sectors to the left
|
||||||
|
/// of the other edge's tangent get `face_b`; all others are exterior (F0).
|
||||||
|
///
|
||||||
|
/// Steps:
|
||||||
|
/// 1. Read face/tangent for each tail's forward half-edge (before they change).
|
||||||
|
/// 2. `rebuild_vertex_fan` — sort all outgoing half-edges CCW.
|
||||||
|
/// 3. For each angular sector, compute the face from tangent cross products.
|
||||||
|
/// 4. `assign_cycle_face(twin(outgoing[i]), sector_face[i])` for each sector.
|
||||||
|
/// 5. If a face appears in two sectors, the second sector gets a new sub-face
|
||||||
|
/// with the same fill colour.
|
||||||
|
pub fn repair_crossing_vertex(
|
||||||
|
&mut self,
|
||||||
|
vertex: VertexId,
|
||||||
|
editing_tail: EdgeId,
|
||||||
|
other_tail: EdgeId,
|
||||||
|
) {
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
// Read face and tangent direction BEFORE any relinking.
|
||||||
|
// editing_tail.half_edges[0] is the forward HE starting at vertex;
|
||||||
|
// face_a = face to the LEFT of the editing edge's forward direction.
|
||||||
|
let he_a = self.edges[editing_tail.idx()].half_edges[0];
|
||||||
|
let face_a = self.half_edges[he_a.idx()].face;
|
||||||
|
let angle_a = self.outgoing_angle(he_a);
|
||||||
|
let ta = (angle_a.cos(), angle_a.sin());
|
||||||
|
|
||||||
|
let he_b = self.edges[other_tail.idx()].half_edges[0];
|
||||||
|
let face_b = self.half_edges[he_b.idx()].face;
|
||||||
|
let angle_b = self.outgoing_angle(he_b);
|
||||||
|
let tb = (angle_b.cos(), angle_b.sin());
|
||||||
|
|
||||||
|
// Sort outgoing half-edges CCW.
|
||||||
|
self.rebuild_vertex_fan(vertex);
|
||||||
|
|
||||||
|
let outgoing = self.vertex_outgoing(vertex);
|
||||||
|
let n = outgoing.len();
|
||||||
|
if n < 2 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assign face to each angular sector.
|
||||||
|
// Sector i lies between outgoing[i] and outgoing[(i+1)%n] (CCW).
|
||||||
|
// After rebuild_vertex_fan, twin(outgoing[i]).next = outgoing[(i+1)%n],
|
||||||
|
// so the cycle starting at twin(outgoing[i]) borders sector i.
|
||||||
|
let mut face_first_cycle: HashMap<u32, HalfEdgeId> = HashMap::new();
|
||||||
|
|
||||||
|
for i in 0..n {
|
||||||
|
let he_i = outgoing[i];
|
||||||
|
let he_next = outgoing[(i + 1) % n];
|
||||||
|
|
||||||
|
// Midpoint direction of the CCW angular gap between outgoing[i] and outgoing[i+1].
|
||||||
|
let ai = self.outgoing_angle(he_i);
|
||||||
|
let an = self.outgoing_angle(he_next);
|
||||||
|
let mut delta = an - ai;
|
||||||
|
if delta <= 0.0 {
|
||||||
|
delta += 2.0 * std::f64::consts::PI;
|
||||||
|
}
|
||||||
|
let mid_angle = ai + delta / 2.0;
|
||||||
|
let d = (mid_angle.cos(), mid_angle.sin());
|
||||||
|
|
||||||
|
// cross(t, d) > 0 ⟺ d is to the LEFT of t ⟺ inside that edge's face.
|
||||||
|
let cross_a = ta.0 * d.1 - ta.1 * d.0;
|
||||||
|
let cross_b = tb.0 * d.1 - tb.1 * d.0;
|
||||||
|
|
||||||
|
let sector_face = if cross_a > 0.0 {
|
||||||
|
face_a // editing face wins; also covers the overlap region
|
||||||
|
} else if cross_b > 0.0 {
|
||||||
|
face_b
|
||||||
|
} else {
|
||||||
|
FaceId(0) // exterior
|
||||||
|
};
|
||||||
|
|
||||||
|
let twin_i = self.half_edges[he_i.idx()].twin;
|
||||||
|
|
||||||
|
if sector_face.0 == 0 {
|
||||||
|
self.assign_cycle_face(twin_i, FaceId(0));
|
||||||
|
} else if face_first_cycle.contains_key(§or_face.0) {
|
||||||
|
// Second occurrence: create a new sub-face with the same fill.
|
||||||
|
let nf = self.alloc_face();
|
||||||
|
let src = sector_face;
|
||||||
|
self.faces[nf.idx()].fill_color = self.faces[src.idx()].fill_color;
|
||||||
|
self.faces[nf.idx()].image_fill = self.faces[src.idx()].image_fill;
|
||||||
|
self.faces[nf.idx()].fill_rule = self.faces[src.idx()].fill_rule;
|
||||||
|
self.faces[nf.idx()].outer_half_edge = twin_i;
|
||||||
|
self.assign_cycle_face(twin_i, nf);
|
||||||
|
} else {
|
||||||
|
face_first_cycle.insert(sector_face.0, twin_i);
|
||||||
|
self.assign_cycle_face(twin_i, sector_face);
|
||||||
|
// Keep outer_half_edge valid for the original face.
|
||||||
|
self.faces[sector_face.idx()].outer_half_edge = twin_i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Merge vertex `v_remove` into `v_keep`. Both must be at the same position
|
/// Merge vertex `v_remove` into `v_keep`. Both must be at the same position
|
||||||
/// (or close enough). All half-edges originating from `v_remove` are re-homed
|
/// (or close enough). All half-edges originating from `v_remove` are re-homed
|
||||||
/// to `v_keep`, and the combined fan is re-sorted by angle.
|
/// to `v_keep`, and the combined fan is re-sorted by angle.
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,7 @@ pub(crate) mod clipboard_platform;
|
||||||
pub mod region_select;
|
pub mod region_select;
|
||||||
pub mod dcel2;
|
pub mod dcel2;
|
||||||
pub use dcel2 as dcel;
|
pub use dcel2 as dcel;
|
||||||
|
pub mod svg_export;
|
||||||
pub mod snap;
|
pub mod snap;
|
||||||
pub mod webcam;
|
pub mod webcam;
|
||||||
pub mod raster_layer;
|
pub mod raster_layer;
|
||||||
|
|
|
||||||
|
|
@ -196,6 +196,14 @@ pub struct Selection {
|
||||||
/// Transient UI state — not persisted.
|
/// Transient UI state — not persisted.
|
||||||
#[serde(skip)]
|
#[serde(skip)]
|
||||||
pub raster_floating: Option<RasterFloatingSelection>,
|
pub raster_floating: Option<RasterFloatingSelection>,
|
||||||
|
|
||||||
|
/// Standalone DCEL subgraph ready for clipboard operations.
|
||||||
|
///
|
||||||
|
/// Set when a region selection is committed (contains the extracted geometry).
|
||||||
|
/// Cleared when the selection is cleared. Used by clipboard_copy_selection
|
||||||
|
/// to avoid re-extracting the geometry from the live DCEL.
|
||||||
|
#[serde(skip)]
|
||||||
|
pub vector_subgraph: Option<Dcel>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Selection {
|
impl Selection {
|
||||||
|
|
@ -208,6 +216,7 @@ impl Selection {
|
||||||
selected_clip_instances: Vec::new(),
|
selected_clip_instances: Vec::new(),
|
||||||
raster_selection: None,
|
raster_selection: None,
|
||||||
raster_floating: None,
|
raster_floating: None,
|
||||||
|
vector_subgraph: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -350,6 +359,7 @@ impl Selection {
|
||||||
self.selected_vertices.clear();
|
self.selected_vertices.clear();
|
||||||
self.selected_edges.clear();
|
self.selected_edges.clear();
|
||||||
self.selected_faces.clear();
|
self.selected_faces.clear();
|
||||||
|
self.vector_subgraph = None;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check if any DCEL elements are selected.
|
/// Check if any DCEL elements are selected.
|
||||||
|
|
@ -443,6 +453,7 @@ impl Selection {
|
||||||
self.selected_clip_instances.clear();
|
self.selected_clip_instances.clear();
|
||||||
self.raster_selection = None;
|
self.raster_selection = None;
|
||||||
self.raster_floating = None;
|
self.raster_floating = None;
|
||||||
|
self.vector_subgraph = None;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check if selection is empty
|
/// Check if selection is empty
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,220 @@
|
||||||
|
//! SVG export from a DCEL subgraph.
|
||||||
|
//!
|
||||||
|
//! Generates a minimal SVG string containing one `<path>` per filled face,
|
||||||
|
//! plus stroked edges. Used as the secondary clipboard format for cross-app paste.
|
||||||
|
|
||||||
|
use crate::dcel2::{Dcel, FaceId, HalfEdgeId};
|
||||||
|
use kurbo::CubicBez;
|
||||||
|
|
||||||
|
/// Convert a DCEL to an SVG string.
|
||||||
|
///
|
||||||
|
/// Each non-unbounded face with a fill color becomes a `<path fill="..."/>`.
|
||||||
|
/// Each edge with a stroke becomes a `<path stroke="..."/>`.
|
||||||
|
/// Coordinates are document-space (no transform applied).
|
||||||
|
pub fn dcel_to_svg(dcel: &Dcel) -> String {
|
||||||
|
// Compute bounding box from vertex positions.
|
||||||
|
let mut min_x = f64::MAX;
|
||||||
|
let mut min_y = f64::MAX;
|
||||||
|
let mut max_x = f64::MIN;
|
||||||
|
let mut max_y = f64::MIN;
|
||||||
|
|
||||||
|
for v in &dcel.vertices {
|
||||||
|
if !v.deleted {
|
||||||
|
min_x = min_x.min(v.position.x);
|
||||||
|
min_y = min_y.min(v.position.y);
|
||||||
|
max_x = max_x.max(v.position.x);
|
||||||
|
max_y = max_y.max(v.position.y);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if min_x == f64::MAX {
|
||||||
|
return r#"<svg xmlns="http://www.w3.org/2000/svg"/>"#.to_string();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add a small margin.
|
||||||
|
let margin = 2.0;
|
||||||
|
let vx = min_x - margin;
|
||||||
|
let vy = min_y - margin;
|
||||||
|
let vw = (max_x - min_x) + margin * 2.0;
|
||||||
|
let vh = (max_y - min_y) + margin * 2.0;
|
||||||
|
|
||||||
|
let mut svg = format!(
|
||||||
|
r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="{vx:.3} {vy:.3} {vw:.3} {vh:.3}">"#
|
||||||
|
);
|
||||||
|
|
||||||
|
// Emit filled faces.
|
||||||
|
for (face_idx, face) in dcel.faces.iter().enumerate() {
|
||||||
|
if face.deleted || face_idx == 0 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let fill_color = match &face.fill_color {
|
||||||
|
Some(c) => format!("rgba({},{},{},{})", c.r, c.g, c.b, c.a as f32 / 255.0),
|
||||||
|
None => continue,
|
||||||
|
};
|
||||||
|
|
||||||
|
let face_id = FaceId(face_idx as u32);
|
||||||
|
let path_d = face_boundary_to_svg_path(dcel, face_id);
|
||||||
|
if path_d.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
svg.push_str(&format!(r#"<path fill="{fill_color}" d="{path_d}"/>"#));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Emit stroked edges.
|
||||||
|
for edge in &dcel.edges {
|
||||||
|
if edge.deleted {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let (stroke_color, stroke_width) = match (&edge.stroke_color, &edge.stroke_style) {
|
||||||
|
(Some(c), Some(s)) => (
|
||||||
|
format!("rgba({},{},{},{})", c.r, c.g, c.b, c.a as f32 / 255.0),
|
||||||
|
s.width,
|
||||||
|
),
|
||||||
|
_ => continue,
|
||||||
|
};
|
||||||
|
|
||||||
|
let path_d = cubic_to_svg_path(&edge.curve);
|
||||||
|
svg.push_str(&format!(
|
||||||
|
r#"<path fill="none" stroke="{stroke_color}" stroke-width="{stroke_width:.3}" d="{path_d}"/>"#
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
svg.push_str("</svg>");
|
||||||
|
svg
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Walk a face's outer boundary half-edges and build an SVG path string.
|
||||||
|
fn face_boundary_to_svg_path(dcel: &Dcel, face_id: FaceId) -> String {
|
||||||
|
let face = dcel.face(face_id);
|
||||||
|
let start_he = face.outer_half_edge;
|
||||||
|
if start_he.is_none() {
|
||||||
|
return String::new();
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut path = String::new();
|
||||||
|
let mut first = true;
|
||||||
|
let mut he_id = start_he;
|
||||||
|
|
||||||
|
// Safety limit to prevent infinite loops on malformed DCELs.
|
||||||
|
let limit = dcel.half_edges.len() + 1;
|
||||||
|
let mut count = 0;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
if count > limit {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
count += 1;
|
||||||
|
|
||||||
|
let he = dcel.half_edge(he_id);
|
||||||
|
if he.deleted {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
let edge = dcel.edge(he.edge);
|
||||||
|
// Determine curve direction: forward half-edge is half_edges[0].
|
||||||
|
let curve = if edge.half_edges[0] == he_id {
|
||||||
|
edge.curve
|
||||||
|
} else {
|
||||||
|
// Reverse the cubic bezier.
|
||||||
|
let c = edge.curve;
|
||||||
|
CubicBez::new(c.p3, c.p2, c.p1, c.p0)
|
||||||
|
};
|
||||||
|
|
||||||
|
if first {
|
||||||
|
path.push_str(&format!("M {:.3} {:.3} ", curve.p0.x, curve.p0.y));
|
||||||
|
first = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
path.push_str(&format!(
|
||||||
|
"C {:.3} {:.3} {:.3} {:.3} {:.3} {:.3} ",
|
||||||
|
curve.p1.x, curve.p1.y,
|
||||||
|
curve.p2.x, curve.p2.y,
|
||||||
|
curve.p3.x, curve.p3.y,
|
||||||
|
));
|
||||||
|
|
||||||
|
he_id = he.next;
|
||||||
|
if he_id == start_he {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !path.is_empty() {
|
||||||
|
path.push('Z');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also handle inner boundaries (holes).
|
||||||
|
for &inner_he_start in &face.inner_half_edges {
|
||||||
|
if inner_he_start.is_none() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let inner = inner_boundary_to_svg_path(dcel, inner_he_start);
|
||||||
|
if !inner.is_empty() {
|
||||||
|
path.push(' ');
|
||||||
|
path.push_str(&inner);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
path
|
||||||
|
}
|
||||||
|
|
||||||
|
fn inner_boundary_to_svg_path(dcel: &Dcel, start_he: HalfEdgeId) -> String {
|
||||||
|
let mut path = String::new();
|
||||||
|
let mut first = true;
|
||||||
|
let mut he_id = start_he;
|
||||||
|
let limit = dcel.half_edges.len() + 1;
|
||||||
|
let mut count = 0;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
if count > limit {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
count += 1;
|
||||||
|
|
||||||
|
let he = dcel.half_edge(he_id);
|
||||||
|
if he.deleted {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
let edge = dcel.edge(he.edge);
|
||||||
|
let curve = if edge.half_edges[0] == he_id {
|
||||||
|
edge.curve
|
||||||
|
} else {
|
||||||
|
let c = edge.curve;
|
||||||
|
CubicBez::new(c.p3, c.p2, c.p1, c.p0)
|
||||||
|
};
|
||||||
|
|
||||||
|
if first {
|
||||||
|
path.push_str(&format!("M {:.3} {:.3} ", curve.p0.x, curve.p0.y));
|
||||||
|
first = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
path.push_str(&format!(
|
||||||
|
"C {:.3} {:.3} {:.3} {:.3} {:.3} {:.3} ",
|
||||||
|
curve.p1.x, curve.p1.y,
|
||||||
|
curve.p2.x, curve.p2.y,
|
||||||
|
curve.p3.x, curve.p3.y,
|
||||||
|
));
|
||||||
|
|
||||||
|
he_id = he.next;
|
||||||
|
if he_id == start_he {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !path.is_empty() {
|
||||||
|
path.push('Z');
|
||||||
|
}
|
||||||
|
path
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert a single cubic bezier to an SVG path string.
|
||||||
|
fn cubic_to_svg_path(curve: &CubicBez) -> String {
|
||||||
|
format!(
|
||||||
|
"M {:.3} {:.3} C {:.3} {:.3} {:.3} {:.3} {:.3} {:.3}",
|
||||||
|
curve.p0.x, curve.p0.y,
|
||||||
|
curve.p1.x, curve.p1.y,
|
||||||
|
curve.p2.x, curve.p2.y,
|
||||||
|
curve.p3.x, curve.p3.y,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -2148,7 +2148,36 @@ impl EditorApp {
|
||||||
|
|
||||||
self.clipboard_manager.copy(content);
|
self.clipboard_manager.copy(content);
|
||||||
} else if self.selection.has_dcel_selection() {
|
} else if self.selection.has_dcel_selection() {
|
||||||
// TODO: DCEL copy/paste deferred to Phase 2 (serialize subgraph)
|
let subgraph = if let Some(dcel) = self.selection.vector_subgraph.take() {
|
||||||
|
// Region selection: the sub-DCEL was pre-extracted on commit.
|
||||||
|
dcel
|
||||||
|
} else {
|
||||||
|
// Select tool: extract faces adjacent to the selected edges from the live DCEL.
|
||||||
|
let active_layer_id = match self.active_layer_id {
|
||||||
|
Some(id) => id,
|
||||||
|
None => return,
|
||||||
|
};
|
||||||
|
let document = self.action_executor.document();
|
||||||
|
let Some(lightningbeam_core::layer::AnyLayer::Vector(vl)) = document.get_layer(&active_layer_id) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Some(live_dcel) = vl.dcel_at_time(self.playback_time) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let selected_edges = self.selection.selected_edges().clone();
|
||||||
|
lightningbeam_core::dcel2::extract_faces_for_edges(live_dcel, &selected_edges)
|
||||||
|
};
|
||||||
|
|
||||||
|
let dcel_json = serde_json::to_string(&subgraph).unwrap_or_default();
|
||||||
|
let svg_xml = lightningbeam_core::svg_export::dcel_to_svg(&subgraph);
|
||||||
|
self.clipboard_manager.copy(
|
||||||
|
lightningbeam_core::clipboard::ClipboardContent::VectorGeometry {
|
||||||
|
dcel_json,
|
||||||
|
svg_xml,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
// Restore the subgraph so a subsequent cut can also delete.
|
||||||
|
self.selection.vector_subgraph = Some(subgraph);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2248,7 +2277,40 @@ impl EditorApp {
|
||||||
None => return,
|
None => return,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Delete selected edges via snapshot-based ModifyDcelAction
|
// Region selection case: faces are selected but no edges.
|
||||||
|
// The inside geometry was already extracted from the live DCEL;
|
||||||
|
// commit the current state (outside + boundary) using the
|
||||||
|
// pre-boundary snapshot as the "before" for undo.
|
||||||
|
if self.selection.selected_edges().is_empty() {
|
||||||
|
if let Some(region_sel) = self.region_selection.take() {
|
||||||
|
// dcel_snapshot = state before boundary was inserted.
|
||||||
|
// Current document DCEL = outside portion only (boundary edges present).
|
||||||
|
// We commit the snapshot as "before" and the current state as "after",
|
||||||
|
// then drop the region selection so it is not merged back.
|
||||||
|
let document = self.action_executor.document();
|
||||||
|
if let Some(lightningbeam_core::layer::AnyLayer::Vector(vl)) =
|
||||||
|
document.get_layer(®ion_sel.layer_id)
|
||||||
|
{
|
||||||
|
if let Some(dcel_after) = vl.dcel_at_time(region_sel.time) {
|
||||||
|
let action = lightningbeam_core::actions::ModifyDcelAction::new(
|
||||||
|
region_sel.layer_id,
|
||||||
|
region_sel.time,
|
||||||
|
region_sel.dcel_snapshot.clone(),
|
||||||
|
dcel_after.clone(),
|
||||||
|
"Cut/delete region selection",
|
||||||
|
);
|
||||||
|
if let Err(e) = self.action_executor.execute(Box::new(action)) {
|
||||||
|
eprintln!("Delete region selection failed: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// region_sel is dropped; the stage pane will see region_selection == None.
|
||||||
|
}
|
||||||
|
self.selection.clear_dcel_selection();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Select-tool case: delete the selected edges.
|
||||||
let edge_ids: Vec<lightningbeam_core::dcel::EdgeId> =
|
let edge_ids: Vec<lightningbeam_core::dcel::EdgeId> =
|
||||||
self.selection.selected_edges().iter().copied().collect();
|
self.selection.selected_edges().iter().copied().collect();
|
||||||
|
|
||||||
|
|
@ -2403,8 +2465,42 @@ impl EditorApp {
|
||||||
self.selection.add_clip_instance(id);
|
self.selection.add_clip_instance(id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ClipboardContent::VectorGeometry { .. } => {
|
ClipboardContent::VectorGeometry { dcel_json, .. } => {
|
||||||
// TODO (Phase 2): paste DCEL subgraph once vector serialization is defined.
|
// Deserialize the subgraph and merge it into the live DCEL.
|
||||||
|
let clipboard_dcel: lightningbeam_core::dcel2::Dcel =
|
||||||
|
match serde_json::from_str(&dcel_json) {
|
||||||
|
Ok(d) => d,
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("Paste: failed to deserialize vector geometry: {e}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let active_layer_id = match self.active_layer_id {
|
||||||
|
Some(id) => id,
|
||||||
|
None => return,
|
||||||
|
};
|
||||||
|
|
||||||
|
let document = self.action_executor.document();
|
||||||
|
let Some(lightningbeam_core::layer::AnyLayer::Vector(vl)) =
|
||||||
|
document.get_layer(&active_layer_id) else { return };
|
||||||
|
let Some(dcel_before) = vl.dcel_at_time(self.playback_time) else { return };
|
||||||
|
|
||||||
|
let mut dcel_after = dcel_before.clone();
|
||||||
|
// Paste with a small nudge so it is visually distinct from the original.
|
||||||
|
let nudge = vello::kurbo::Vec2::new(10.0, 10.0);
|
||||||
|
dcel_after.import_from(&clipboard_dcel, nudge);
|
||||||
|
|
||||||
|
let action = lightningbeam_core::actions::ModifyDcelAction::new(
|
||||||
|
active_layer_id,
|
||||||
|
self.playback_time,
|
||||||
|
dcel_before.clone(),
|
||||||
|
dcel_after,
|
||||||
|
"Paste vector geometry",
|
||||||
|
);
|
||||||
|
if let Err(e) = self.action_executor.execute(Box::new(action)) {
|
||||||
|
eprintln!("Paste vector geometry failed: {e}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
ClipboardContent::Layers { .. } => {
|
ClipboardContent::Layers { .. } => {
|
||||||
// TODO: insert copied layers as siblings at the current selection point.
|
// TODO: insert copied layers as siblings at the current selection point.
|
||||||
|
|
@ -2955,6 +3051,9 @@ impl EditorApp {
|
||||||
if let Some((clip_id, notes)) = midi_update {
|
if let Some((clip_id, notes)) = midi_update {
|
||||||
self.rebuild_midi_cache_entry(clip_id, ¬es);
|
self.rebuild_midi_cache_entry(clip_id, ¬es);
|
||||||
}
|
}
|
||||||
|
// Stale vertex/edge/face IDs from before the undo would
|
||||||
|
// crash selection rendering on the restored (smaller) DCEL.
|
||||||
|
self.selection.clear_dcel_selection();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
MenuAction::Redo => {
|
MenuAction::Redo => {
|
||||||
|
|
@ -2991,6 +3090,7 @@ impl EditorApp {
|
||||||
if let Some((clip_id, notes)) = midi_update {
|
if let Some((clip_id, notes)) = midi_update {
|
||||||
self.rebuild_midi_cache_entry(clip_id, ¬es);
|
self.rebuild_midi_cache_entry(clip_id, ¬es);
|
||||||
}
|
}
|
||||||
|
self.selection.clear_dcel_selection();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
MenuAction::Cut => {
|
MenuAction::Cut => {
|
||||||
|
|
|
||||||
|
|
@ -4981,6 +4981,11 @@ impl StagePane {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Store the extracted DCEL as the clipboard-ready vector subgraph.
|
||||||
|
// This allows clipboard_copy_selection to serialize it without needing
|
||||||
|
// to re-extract geometry from the live DCEL.
|
||||||
|
shared.selection.vector_subgraph = Some(selected_dcel.clone());
|
||||||
|
|
||||||
// Store region selection state with extracted DCEL
|
// Store region selection state with extracted DCEL
|
||||||
*shared.region_selection = Some(lightningbeam_core::selection::RegionSelection {
|
*shared.region_selection = Some(lightningbeam_core::selection::RegionSelection {
|
||||||
region_path,
|
region_path,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue