Compare commits

..

2 Commits

Author SHA1 Message Date
Skyler Lehmkuhl 06973d185c Merge branch 'rust-ui' of https://git.skyler.io/skyler/Lightningbeam into rust-ui 2026-03-09 13:41:48 -04:00
Skyler Lehmkuhl dc93f78dc7 More work on DCEL correctness 2026-03-09 13:41:45 -04:00
10 changed files with 809 additions and 20 deletions

View File

@ -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<_>>(),

View File

@ -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
}

View File

@ -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};

View File

@ -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
} }

View File

@ -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(&sector_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.

View File

@ -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;

View File

@ -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

View File

@ -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,
)
}

View File

@ -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(&region_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, &notes); self.rebuild_midi_cache_entry(clip_id, &notes);
} }
// 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, &notes); self.rebuild_midi_cache_entry(clip_id, &notes);
} }
self.selection.clear_dcel_selection();
} }
} }
MenuAction::Cut => { MenuAction::Cut => {

View File

@ -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,