More work on DCEL correctness
This commit is contained in:
parent
bc7d997cff
commit
dc93f78dc7
|
|
@ -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<u8>,
|
||||
/// 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<u8>)> =
|
||||
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::<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 stroke;
|
||||
pub mod region;
|
||||
pub mod import;
|
||||
|
||||
pub use import::extract_faces_for_edges;
|
||||
|
||||
use crate::shape::{FillRule, ShapeColor, StrokeStyle};
|
||||
use kurbo::{CubicBez, Point};
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<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
|
||||
/// (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.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -159,6 +159,14 @@ pub struct Selection {
|
|||
/// Transient UI state — not persisted.
|
||||
#[serde(skip)]
|
||||
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 {
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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<lightningbeam_core::dcel::EdgeId> =
|
||||
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 => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue