From dc93f78dc72ec2b931f402cb826e38bc7bd191dd Mon Sep 17 00:00:00 2001 From: Skyler Lehmkuhl Date: Mon, 9 Mar 2026 13:41:45 -0400 Subject: [PATCH] More work on DCEL correctness --- .../lightningbeam-core/src/clipboard.rs | 31 +- .../lightningbeam-core/src/dcel2/import.rs | 280 ++++++++++++++++++ .../lightningbeam-core/src/dcel2/mod.rs | 3 + .../lightningbeam-core/src/dcel2/stroke.rs | 68 ++++- .../lightningbeam-core/src/dcel2/topology.rs | 102 ++++++- .../lightningbeam-core/src/lib.rs | 1 + .../lightningbeam-core/src/selection.rs | 11 + .../lightningbeam-core/src/svg_export.rs | 220 ++++++++++++++ .../lightningbeam-editor/src/main.rs | 108 ++++++- .../lightningbeam-editor/src/panes/stage.rs | 5 + 10 files changed, 809 insertions(+), 20 deletions(-) create mode 100644 lightningbeam-ui/lightningbeam-core/src/dcel2/import.rs create mode 100644 lightningbeam-ui/lightningbeam-core/src/svg_export.rs diff --git a/lightningbeam-ui/lightningbeam-core/src/clipboard.rs b/lightningbeam-ui/lightningbeam-core/src/clipboard.rs index 083f85f..4355754 100644 --- a/lightningbeam-ui/lightningbeam-core/src/clipboard.rs +++ b/lightningbeam-ui/lightningbeam-core/src/clipboard.rs @@ -123,13 +123,13 @@ pub enum ClipboardContent { /// Selected DCEL geometry from a vector layer. /// - /// Currently a stub — `data` is opaque bytes whose format is TBD in Phase 2 - /// once DCEL serialization is implemented. Copy/paste of vector shapes does - /// nothing until then. Secondary formats (`image/svg+xml`, `image/png`) are - /// also deferred to Phase 2. + /// `dcel_json` is the serialized subgraph (serde_json of [`crate::dcel2::Dcel`]). + /// `svg_xml` is an SVG rendering of the same geometry for cross-app paste. VectorGeometry { - /// Opaque DCEL subgraph bytes (format TBD, Phase 2). - data: Vec, + /// JSON-serialized DCEL subgraph. + dcel_json: String, + /// SVG representation for cross-app paste (e.g. into Inkscape). + svg_xml: String, }, /// MIDI notes from the piano roll. @@ -225,11 +225,14 @@ impl ClipboardContent { } // ── VectorGeometry ────────────────────────────────────────────── - // TODO (Phase 2): remap DCEL vertex/edge UUIDs once DCEL serialization - // is defined. - ClipboardContent::VectorGeometry { data } => { - (ClipboardContent::VectorGeometry { data: data.clone() }, id_map) - } + // DCEL uses integer indices (not UUIDs), so no remapping is needed. + ClipboardContent::VectorGeometry { dcel_json, svg_xml } => ( + ClipboardContent::VectorGeometry { + dcel_json: dcel_json.clone(), + svg_xml: svg_xml.clone(), + }, + id_map, + ), // ── MidiNotes ─────────────────────────────────────────────────── ClipboardContent::MidiNotes { notes } => { @@ -540,7 +543,8 @@ impl ClipboardManager { pub fn copy(&mut self, content: ClipboardContent) { 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)> = vec![(LIGHTNINGBEAM_MIME, json.as_bytes().to_vec())]; if let ClipboardContent::RasterPixels { pixels, width, height } = &content { @@ -548,6 +552,9 @@ impl ClipboardManager { 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( &entries.iter().map(|(m, d)| (*m, d.as_slice())).collect::>(), diff --git a/lightningbeam-ui/lightningbeam-core/src/dcel2/import.rs b/lightningbeam-ui/lightningbeam-core/src/dcel2/import.rs new file mode 100644 index 0000000..113ab43 --- /dev/null +++ b/lightningbeam-ui/lightningbeam-core/src/dcel2/import.rs @@ -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 = 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 { + 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) -> Dcel { + if edge_ids.is_empty() { + return Dcel::new(); + } + + let mut face_set: HashSet = 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 = 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 = 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 +} diff --git a/lightningbeam-ui/lightningbeam-core/src/dcel2/mod.rs b/lightningbeam-ui/lightningbeam-core/src/dcel2/mod.rs index 14e580f..ecc4ad2 100644 --- a/lightningbeam-ui/lightningbeam-core/src/dcel2/mod.rs +++ b/lightningbeam-ui/lightningbeam-core/src/dcel2/mod.rs @@ -11,6 +11,9 @@ pub mod topology; pub mod query; pub mod stroke; pub mod region; +pub mod import; + +pub use import::extract_faces_for_edges; use crate::shape::{FillRule, ShapeColor, StrokeStyle}; use kurbo::{CubicBez, Point}; diff --git a/lightningbeam-ui/lightningbeam-core/src/dcel2/stroke.rs b/lightningbeam-ui/lightningbeam-core/src/dcel2/stroke.rs index 99a4443..48f5893 100644 --- a/lightningbeam-ui/lightningbeam-core/src/dcel2/stroke.rs +++ b/lightningbeam-ui/lightningbeam-core/src/dcel2/stroke.rs @@ -8,7 +8,7 @@ //! different intersection positions for the same crossing. use super::{ - subsegment_cubic, Dcel, EdgeId, FaceId, HalfEdgeId, VertexId, + subsegment_cubic, Dcel, EdgeId, FaceId, VertexId, }; use crate::curve_intersections::find_curve_intersections; use crate::shape::{ShapeColor, StrokeStyle}; @@ -379,6 +379,13 @@ impl Dcel { } // 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(); for other_idx in 0..edge_count { if self.edges[other_idx].deleted { @@ -417,11 +424,11 @@ impl Dcel { 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()); 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) .clamp(ENDPOINT_T_MARGIN, 1.0 - ENDPOINT_T_MARGIN); @@ -431,10 +438,65 @@ impl Dcel { self.snap_edge_endpoints_to_vertex(new_edge, vertex); 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; } } + // 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 } diff --git a/lightningbeam-ui/lightningbeam-core/src/dcel2/topology.rs b/lightningbeam-ui/lightningbeam-core/src/dcel2/topology.rs index fc63d5b..9c9a320 100644 --- a/lightningbeam-ui/lightningbeam-core/src/dcel2/topology.rs +++ b/lightningbeam-ui/lightningbeam-core/src/dcel2/topology.rs @@ -431,7 +431,7 @@ impl Dcel { } /// 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; let mut cur = self.half_edges[start.idx()].next; let mut steps = 0; @@ -908,6 +908,106 @@ impl Dcel { 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 = 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 /// (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. diff --git a/lightningbeam-ui/lightningbeam-core/src/lib.rs b/lightningbeam-ui/lightningbeam-core/src/lib.rs index 858bc07..3556b2f 100644 --- a/lightningbeam-ui/lightningbeam-core/src/lib.rs +++ b/lightningbeam-ui/lightningbeam-core/src/lib.rs @@ -47,6 +47,7 @@ pub(crate) mod clipboard_platform; pub mod region_select; pub mod dcel2; pub use dcel2 as dcel; +pub mod svg_export; pub mod snap; pub mod webcam; pub mod raster_layer; diff --git a/lightningbeam-ui/lightningbeam-core/src/selection.rs b/lightningbeam-ui/lightningbeam-core/src/selection.rs index 08247a7..7b2906b 100644 --- a/lightningbeam-ui/lightningbeam-core/src/selection.rs +++ b/lightningbeam-ui/lightningbeam-core/src/selection.rs @@ -159,6 +159,14 @@ pub struct Selection { /// Transient UI state — not persisted. #[serde(skip)] pub raster_floating: Option, + + /// 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, } impl Selection { @@ -171,6 +179,7 @@ impl Selection { selected_clip_instances: Vec::new(), raster_selection: None, raster_floating: None, + vector_subgraph: None, } } @@ -313,6 +322,7 @@ impl Selection { self.selected_vertices.clear(); self.selected_edges.clear(); self.selected_faces.clear(); + self.vector_subgraph = None; } /// Check if any DCEL elements are selected. @@ -406,6 +416,7 @@ impl Selection { self.selected_clip_instances.clear(); self.raster_selection = None; self.raster_floating = None; + self.vector_subgraph = None; } /// Check if selection is empty diff --git a/lightningbeam-ui/lightningbeam-core/src/svg_export.rs b/lightningbeam-ui/lightningbeam-core/src/svg_export.rs new file mode 100644 index 0000000..527aac3 --- /dev/null +++ b/lightningbeam-ui/lightningbeam-core/src/svg_export.rs @@ -0,0 +1,220 @@ +//! SVG export from a DCEL subgraph. +//! +//! Generates a minimal SVG string containing one `` 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 ``. +/// Each edge with a stroke becomes a ``. +/// 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#""#.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#""# + ); + + // 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#""#)); + } + + // 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#""# + )); + } + + svg.push_str(""); + 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, + ) +} diff --git a/lightningbeam-ui/lightningbeam-editor/src/main.rs b/lightningbeam-ui/lightningbeam-editor/src/main.rs index ce4708a..3091df3 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/main.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/main.rs @@ -2123,7 +2123,36 @@ impl EditorApp { self.clipboard_manager.copy(content); } 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); } } @@ -2223,7 +2252,40 @@ impl EditorApp { 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 = self.selection.selected_edges().iter().copied().collect(); @@ -2378,8 +2440,42 @@ impl EditorApp { self.selection.add_clip_instance(id); } } - ClipboardContent::VectorGeometry { .. } => { - // TODO (Phase 2): paste DCEL subgraph once vector serialization is defined. + ClipboardContent::VectorGeometry { dcel_json, .. } => { + // Deserialize the subgraph and merge it into the live DCEL. + let clipboard_dcel: lightningbeam_core::dcel2::Dcel = + match serde_json::from_str(&dcel_json) { + Ok(d) => d, + Err(e) => { + eprintln!("Paste: failed to deserialize vector geometry: {e}"); + return; + } + }; + + let active_layer_id = match self.active_layer_id { + Some(id) => id, + None => return, + }; + + let document = self.action_executor.document(); + let Some(lightningbeam_core::layer::AnyLayer::Vector(vl)) = + document.get_layer(&active_layer_id) else { return }; + let Some(dcel_before) = vl.dcel_at_time(self.playback_time) else { return }; + + let mut dcel_after = dcel_before.clone(); + // Paste with a small nudge so it is visually distinct from the original. + let nudge = vello::kurbo::Vec2::new(10.0, 10.0); + dcel_after.import_from(&clipboard_dcel, nudge); + + let action = lightningbeam_core::actions::ModifyDcelAction::new( + active_layer_id, + self.playback_time, + dcel_before.clone(), + dcel_after, + "Paste vector geometry", + ); + if let Err(e) = self.action_executor.execute(Box::new(action)) { + eprintln!("Paste vector geometry failed: {e}"); + } } ClipboardContent::Layers { .. } => { // TODO: insert copied layers as siblings at the current selection point. @@ -2902,6 +2998,9 @@ impl EditorApp { if let Some((clip_id, notes)) = midi_update { 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 => { @@ -2938,6 +3037,7 @@ impl EditorApp { if let Some((clip_id, notes)) = midi_update { self.rebuild_midi_cache_entry(clip_id, ¬es); } + self.selection.clear_dcel_selection(); } } MenuAction::Cut => { diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs index 1f99736..c8a293d 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs @@ -4292,6 +4292,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 *shared.region_selection = Some(lightningbeam_core::selection::RegionSelection { region_path,