work on vector graph

This commit is contained in:
Skyler Lehmkuhl 2026-03-22 18:15:56 -04:00
parent 8acac71d86
commit f16e651610
21 changed files with 1317 additions and 843 deletions

View File

@ -1,17 +1,20 @@
//! Add shape action — inserts strokes into the DCEL.
//! Add shape action — inserts strokes into the VectorGraph.
//!
//! Converts a BezPath into cubic segments and inserts them via
//! `Dcel::insert_stroke()`. Undo is handled by snapshotting the DCEL.
//! `VectorGraph::insert_stroke()`. Undo is handled by snapshotting the graph.
use crate::action::Action;
use crate::dcel::{bezpath_to_cubic_segments, Dcel, FaceId, DEFAULT_SNAP_EPSILON};
use crate::vector_graph::bezpath_to_cubic_segments;
use crate::document::Document;
use crate::layer::AnyLayer;
use crate::shape::{ShapeColor, StrokeStyle};
use kurbo::BezPath;
use crate::shape::{FillRule, ShapeColor, StrokeStyle};
use crate::vector_graph::VectorGraph;
use kurbo::{BezPath, Shape as _};
use uuid::Uuid;
/// Action that inserts a drawn path into a vector layer's DCEL keyframe.
const DEFAULT_SNAP_EPSILON: f64 = 0.5;
/// Action that inserts a drawn path into a vector layer's VectorGraph keyframe.
pub struct AddShapeAction {
layer_id: Uuid,
time: f64,
@ -21,8 +24,8 @@ pub struct AddShapeAction {
fill_color: Option<ShapeColor>,
is_closed: bool,
description_text: String,
/// Snapshot of the DCEL before insertion (for undo).
dcel_before: Option<Dcel>,
/// Snapshot of the graph before insertion (for undo).
graph_before: Option<VectorGraph>,
}
impl AddShapeAction {
@ -44,7 +47,7 @@ impl AddShapeAction {
fill_color,
is_closed,
description_text: "Add shape".to_string(),
dcel_before: None,
graph_before: None,
}
}
@ -66,10 +69,10 @@ impl Action for AddShapeAction {
};
let keyframe = vl.ensure_keyframe_at(self.time);
let dcel = &mut keyframe.dcel;
let graph = &mut keyframe.graph;
// Snapshot for undo
self.dcel_before = Some(dcel.clone());
self.graph_before = Some(graph.clone());
let subpaths = bezpath_to_cubic_segments(&self.path);
@ -77,41 +80,27 @@ impl Action for AddShapeAction {
if segments.is_empty() {
continue;
}
let result = dcel.insert_stroke(
let _new_edges = graph.insert_stroke(
segments,
self.stroke_style.clone(),
self.stroke_color.clone(),
DEFAULT_SNAP_EPSILON,
);
// Apply fill to new faces if this is a closed shape with fill
// Apply fill if this is a closed shape with fill
if self.is_closed {
if let Some(ref fill) = self.fill_color {
if !result.new_faces.is_empty() {
for face_id in &result.new_faces {
dcel.face_mut(*face_id).fill_color = Some(fill.clone());
}
} else if let Some(&first_edge) = result.new_edges.first() {
// Closed shape in F0 — no face was auto-created.
// One half-edge of the first new edge is on the interior cycle.
// Pick the side with positive signed area (CCW winding).
let [he_a, he_b] = dcel.edge(first_edge).half_edges;
let interior_he = if dcel.cycle_signed_area(he_a) > 0.0 {
he_a
} else {
he_b
};
if dcel.half_edge(interior_he).face == FaceId(0) {
let face_id = dcel.create_face_at_cycle(interior_he);
dcel.face_mut(face_id).fill_color = Some(fill.clone());
}
}
// Compute centroid of the path's bounding box and paint-bucket fill
let bbox = self.path.bounding_box();
let centroid = kurbo::Point::new(
(bbox.x0 + bbox.x1) / 2.0,
(bbox.y0 + bbox.y1) / 2.0,
);
graph.paint_bucket(centroid, fill.clone(), FillRule::NonZero, 0.0);
}
}
}
dcel.rebuild_spatial_index();
Ok(())
}
@ -126,10 +115,10 @@ impl Action for AddShapeAction {
};
let keyframe = vl.ensure_keyframe_at(self.time);
keyframe.dcel = self
.dcel_before
keyframe.graph = self
.graph_before
.take()
.ok_or_else(|| "No DCEL snapshot for undo".to_string())?;
.ok_or_else(|| "No graph snapshot for undo".to_string())?;
Ok(())
}

View File

@ -40,7 +40,7 @@ pub use add_clip_instance::AddClipInstanceAction;
pub use add_effect::AddEffectAction;
pub use add_layer::AddLayerAction;
pub use add_shape::AddShapeAction;
pub use modify_shape_path::ModifyDcelAction;
pub use modify_shape_path::ModifyGraphAction;
pub use move_clip_instances::MoveClipInstancesAction;
pub use paint_bucket::PaintBucketAction;
pub use remove_effect::RemoveEffectAction;

View File

@ -1,45 +1,45 @@
//! Modify DCEL action — snapshot-based undo for DCEL editing
//! Modify graph action — snapshot-based undo for VectorGraph editing
use crate::action::Action;
use crate::dcel::Dcel;
use crate::vector_graph::VectorGraph;
use crate::document::Document;
use crate::layer::AnyLayer;
use uuid::Uuid;
/// Action that captures a before/after DCEL snapshot for undo/redo.
/// Action that captures a before/after VectorGraph snapshot for undo/redo.
///
/// Used by vertex editing, curve editing, and control point editing.
/// The caller provides both snapshots (taken before and after the edit).
pub struct ModifyDcelAction {
pub struct ModifyGraphAction {
layer_id: Uuid,
time: f64,
dcel_before: Option<Dcel>,
dcel_after: Option<Dcel>,
graph_before: Option<VectorGraph>,
graph_after: Option<VectorGraph>,
description_text: String,
}
impl ModifyDcelAction {
impl ModifyGraphAction {
pub fn new(
layer_id: Uuid,
time: f64,
dcel_before: Dcel,
dcel_after: Dcel,
graph_before: VectorGraph,
graph_after: VectorGraph,
description: impl Into<String>,
) -> Self {
Self {
layer_id,
time,
dcel_before: Some(dcel_before),
dcel_after: Some(dcel_after),
graph_before: Some(graph_before),
graph_after: Some(graph_after),
description_text: description.into(),
}
}
}
impl Action for ModifyDcelAction {
impl Action for ModifyGraphAction {
fn execute(&mut self, document: &mut Document) -> Result<(), String> {
let dcel_after = self.dcel_after.as_ref()
.ok_or("ModifyDcelAction: no dcel_after snapshot")?
let graph_after = self.graph_after.as_ref()
.ok_or("ModifyGraphAction: no graph_after snapshot")?
.clone();
let layer = document.get_layer_mut(&self.layer_id)
@ -47,7 +47,7 @@ impl Action for ModifyDcelAction {
if let AnyLayer::Vector(vl) = layer {
if let Some(kf) = vl.keyframe_at_mut(self.time) {
kf.dcel = dcel_after;
kf.graph = graph_after;
Ok(())
} else {
Err(format!("No keyframe at time {}", self.time))
@ -58,8 +58,8 @@ impl Action for ModifyDcelAction {
}
fn rollback(&mut self, document: &mut Document) -> Result<(), String> {
let dcel_before = self.dcel_before.as_ref()
.ok_or("ModifyDcelAction: no dcel_before snapshot")?
let graph_before = self.graph_before.as_ref()
.ok_or("ModifyGraphAction: no graph_before snapshot")?
.clone();
let layer = document.get_layer_mut(&self.layer_id)
@ -67,7 +67,7 @@ impl Action for ModifyDcelAction {
if let AnyLayer::Vector(vl) = layer {
if let Some(kf) = vl.keyframe_at_mut(self.time) {
kf.dcel = dcel_before;
kf.graph = graph_before;
Ok(())
} else {
Err(format!("No keyframe at time {}", self.time))

View File

@ -1,23 +1,21 @@
//! Paint bucket fill action — sets fill_color on a DCEL face.
//! Paint bucket fill action — creates a fill region in a VectorGraph.
use crate::action::Action;
use crate::dcel::FaceId;
use crate::document::Document;
use crate::layer::AnyLayer;
use crate::shape::ShapeColor;
use crate::shape::{FillRule, ShapeColor};
use crate::vector_graph::FillId;
use uuid::Uuid;
use vello::kurbo::Point;
/// Action that performs a paint bucket fill on a DCEL face.
/// Action that performs a paint bucket fill on a VectorGraph region.
pub struct PaintBucketAction {
layer_id: Uuid,
time: f64,
click_point: Point,
fill_color: ShapeColor,
/// The face that was hit (resolved during execute)
hit_face: Option<FaceId>,
/// Previous fill color for undo
old_fill_color: Option<Option<ShapeColor>>,
/// The fill that was created (resolved during execute)
hit_fill: Option<FillId>,
}
impl PaintBucketAction {
@ -32,8 +30,7 @@ impl PaintBucketAction {
time,
click_point,
fill_color,
hit_face: None,
old_fill_color: None,
hit_fill: None,
}
}
}
@ -50,45 +47,19 @@ impl Action for PaintBucketAction {
};
let keyframe = vl.ensure_keyframe_at(self.time);
let dcel = &mut keyframe.dcel;
let graph = &mut keyframe.graph;
// Record for debug test generation (if recording is active)
dcel.record_paint_point(self.click_point);
let fill_id = graph
.paint_bucket(self.click_point, self.fill_color.clone(), FillRule::NonZero, 2.0)
.ok_or("No fillable region at click point")?;
// Find the enclosing cycle for the click point
let query = dcel.find_face_at_point(self.click_point);
// Dump cumulative test to stderr after every paint click (if recording)
if dcel.is_recording() {
eprintln!("\n--- DCEL debug test (cumulative, face={:?}) ---", query.face);
dcel.debug_recorder.as_ref().unwrap().dump_test("test_recorded");
eprintln!("--- end test ---\n");
}
if query.cycle_he.is_none() {
// No edges at all — nothing to fill
return Err("No face at click point".to_string());
}
// If the cycle is in F0 (no face created yet), create one now
let face_id = if query.face.0 == 0 {
dcel.create_face_at_cycle(query.cycle_he)
} else {
query.face
};
// Store for undo
self.hit_face = Some(face_id);
self.old_fill_color = Some(dcel.face(face_id).fill_color.clone());
// Apply fill
dcel.face_mut(face_id).fill_color = Some(self.fill_color.clone());
self.hit_fill = Some(fill_id);
Ok(())
}
fn rollback(&mut self, document: &mut Document) -> Result<(), String> {
let face_id = self.hit_face.ok_or("No face to undo")?;
let fill_id = self.hit_fill.ok_or("No fill to undo")?;
let layer = document
.get_layer_mut(&self.layer_id)
@ -100,9 +71,9 @@ impl Action for PaintBucketAction {
};
let keyframe = vl.ensure_keyframe_at(self.time);
let dcel = &mut keyframe.dcel;
let graph = &mut keyframe.graph;
dcel.face_mut(face_id).fill_color = self.old_fill_color.take().unwrap_or(None);
graph.free_fill(fill_id);
Ok(())
}

View File

@ -1,30 +1,30 @@
//! Action that changes the fill of one or more DCEL faces.
//! Action that changes the fill of one or more VectorGraph fills.
//!
//! Handles both solid-colour and gradient fills, clearing the other type so they
//! don't coexist on a face.
//! don't coexist on a fill.
use crate::action::Action;
use crate::dcel::FaceId;
use crate::vector_graph::FillId;
use crate::document::Document;
use crate::gradient::ShapeGradient;
use crate::layer::AnyLayer;
use crate::shape::ShapeColor;
use uuid::Uuid;
/// Snapshot of one face's fill state (both types) for undo.
/// Snapshot of one fill's state (both types) for undo.
#[derive(Clone)]
struct OldFill {
face_id: FaceId,
fill_id: FillId,
color: Option<ShapeColor>,
gradient: Option<ShapeGradient>,
}
/// Action that sets a solid-colour *or* gradient fill on a set of faces,
/// Action that sets a solid-colour *or* gradient fill on a set of fills,
/// clearing the other fill type.
pub struct SetFillPaintAction {
layer_id: Uuid,
time: f64,
face_ids: Vec<FaceId>,
fill_ids: Vec<FillId>,
new_color: Option<ShapeColor>,
new_gradient: Option<ShapeGradient>,
old_fills: Vec<OldFill>,
@ -32,17 +32,17 @@ pub struct SetFillPaintAction {
}
impl SetFillPaintAction {
/// Set a solid fill (clears any gradient on the same faces).
/// Set a solid fill (clears any gradient on the same fills).
pub fn solid(
layer_id: Uuid,
time: f64,
face_ids: Vec<FaceId>,
fill_ids: Vec<FillId>,
color: Option<ShapeColor>,
) -> Self {
Self {
layer_id,
time,
face_ids,
fill_ids,
new_color: color,
new_gradient: None,
old_fills: Vec::new(),
@ -50,17 +50,17 @@ impl SetFillPaintAction {
}
}
/// Set a gradient fill (clears any solid colour on the same faces).
/// Set a gradient fill (clears any solid colour on the same fills).
pub fn gradient(
layer_id: Uuid,
time: f64,
face_ids: Vec<FaceId>,
fill_ids: Vec<FillId>,
gradient: Option<ShapeGradient>,
) -> Self {
Self {
layer_id,
time,
face_ids,
fill_ids,
new_color: None,
new_gradient: gradient,
old_fills: Vec::new(),
@ -68,17 +68,17 @@ impl SetFillPaintAction {
}
}
fn get_dcel_mut<'a>(
fn get_graph_mut<'a>(
document: &'a mut Document,
layer_id: &Uuid,
time: f64,
) -> Result<&'a mut crate::dcel::Dcel, String> {
) -> Result<&'a mut crate::vector_graph::VectorGraph, String> {
let layer = document
.get_layer_mut(layer_id)
.ok_or_else(|| format!("Layer {} not found", layer_id))?;
match layer {
AnyLayer::Vector(vl) => vl
.dcel_at_time_mut(time)
.graph_at_time_mut(time)
.ok_or_else(|| format!("No keyframe at time {}", time)),
_ => Err("Not a vector layer".to_string()),
}
@ -87,36 +87,36 @@ impl SetFillPaintAction {
impl Action for SetFillPaintAction {
fn execute(&mut self, document: &mut Document) -> Result<(), String> {
let dcel = Self::get_dcel_mut(document, &self.layer_id, self.time)?;
let graph = Self::get_graph_mut(document, &self.layer_id, self.time)?;
self.old_fills.clear();
for &fid in &self.face_ids {
let face = dcel.face(fid);
for &fid in &self.fill_ids {
let fill = graph.fill(fid);
self.old_fills.push(OldFill {
face_id: fid,
color: face.fill_color,
gradient: face.gradient_fill.clone(),
fill_id: fid,
color: fill.color,
gradient: fill.gradient_fill.clone(),
});
let face_mut = dcel.face_mut(fid);
let fill_mut = graph.fill_mut(fid);
// Setting a gradient clears solid colour and vice-versa.
if self.new_gradient.is_some() || self.new_color.is_none() {
face_mut.fill_color = self.new_color;
face_mut.gradient_fill = self.new_gradient.clone();
fill_mut.color = self.new_color;
fill_mut.gradient_fill = self.new_gradient.clone();
} else {
face_mut.fill_color = self.new_color;
face_mut.gradient_fill = None;
fill_mut.color = self.new_color;
fill_mut.gradient_fill = None;
}
}
Ok(())
}
fn rollback(&mut self, document: &mut Document) -> Result<(), String> {
let dcel = Self::get_dcel_mut(document, &self.layer_id, self.time)?;
let graph = Self::get_graph_mut(document, &self.layer_id, self.time)?;
for old in &self.old_fills {
let face = dcel.face_mut(old.face_id);
face.fill_color = old.color;
face.gradient_fill = old.gradient.clone();
let fill = graph.fill_mut(old.fill_id);
fill.color = old.color;
fill.gradient_fill = old.gradient.clone();
}
Ok(())
}

View File

@ -1,24 +1,24 @@
//! Set shape properties action — operates on DCEL edge/face IDs.
//! Set shape properties action — operates on VectorGraph edge/fill IDs.
use crate::action::Action;
use crate::dcel::{EdgeId, FaceId};
use crate::vector_graph::{EdgeId, FillId};
use crate::document::Document;
use crate::layer::AnyLayer;
use crate::shape::ShapeColor;
use uuid::Uuid;
/// Action that sets fill/stroke properties on DCEL elements.
/// Action that sets fill/stroke properties on VectorGraph elements.
pub struct SetShapePropertiesAction {
layer_id: Uuid,
time: f64,
change: PropertyChange,
old_edge_values: Vec<(EdgeId, Option<ShapeColor>, Option<f64>)>,
old_face_values: Vec<(FaceId, Option<ShapeColor>)>,
old_fill_values: Vec<(FillId, Option<ShapeColor>)>,
}
enum PropertyChange {
FillColor {
face_ids: Vec<FaceId>,
fill_ids: Vec<FillId>,
color: Option<ShapeColor>,
},
StrokeColor {
@ -35,15 +35,15 @@ impl SetShapePropertiesAction {
pub fn set_fill_color(
layer_id: Uuid,
time: f64,
face_ids: Vec<FaceId>,
fill_ids: Vec<FillId>,
color: Option<ShapeColor>,
) -> Self {
Self {
layer_id,
time,
change: PropertyChange::FillColor { face_ids, color },
change: PropertyChange::FillColor { fill_ids, color },
old_edge_values: Vec::new(),
old_face_values: Vec::new(),
old_fill_values: Vec::new(),
}
}
@ -58,7 +58,7 @@ impl SetShapePropertiesAction {
time,
change: PropertyChange::StrokeColor { edge_ids, color },
old_edge_values: Vec::new(),
old_face_values: Vec::new(),
old_fill_values: Vec::new(),
}
}
@ -73,15 +73,15 @@ impl SetShapePropertiesAction {
time,
change: PropertyChange::StrokeWidth { edge_ids, width },
old_edge_values: Vec::new(),
old_face_values: Vec::new(),
old_fill_values: Vec::new(),
}
}
fn get_dcel_mut<'a>(
fn get_graph_mut<'a>(
document: &'a mut Document,
layer_id: &Uuid,
time: f64,
) -> Result<&'a mut crate::dcel::Dcel, String> {
) -> Result<&'a mut crate::vector_graph::VectorGraph, String> {
let layer = document
.get_layer_mut(layer_id)
.ok_or_else(|| format!("Layer {} not found", layer_id))?;
@ -89,40 +89,40 @@ impl SetShapePropertiesAction {
AnyLayer::Vector(vl) => vl,
_ => return Err("Not a vector layer".to_string()),
};
vl.dcel_at_time_mut(time)
vl.graph_at_time_mut(time)
.ok_or_else(|| format!("No keyframe at time {}", time))
}
}
impl Action for SetShapePropertiesAction {
fn execute(&mut self, document: &mut Document) -> Result<(), String> {
let dcel = Self::get_dcel_mut(document, &self.layer_id, self.time)?;
let graph = Self::get_graph_mut(document, &self.layer_id, self.time)?;
match &self.change {
PropertyChange::FillColor { face_ids, color } => {
self.old_face_values.clear();
for &fid in face_ids {
let face = dcel.face(fid);
self.old_face_values.push((fid, face.fill_color));
dcel.face_mut(fid).fill_color = *color;
PropertyChange::FillColor { fill_ids, color } => {
self.old_fill_values.clear();
for &fid in fill_ids {
let fill = graph.fill(fid);
self.old_fill_values.push((fid, fill.color));
graph.fill_mut(fid).color = *color;
}
}
PropertyChange::StrokeColor { edge_ids, color } => {
self.old_edge_values.clear();
for &eid in edge_ids {
let edge = dcel.edge(eid);
let edge = graph.edge(eid);
let old_width = edge.stroke_style.as_ref().map(|s| s.width);
self.old_edge_values.push((eid, edge.stroke_color, old_width));
dcel.edge_mut(eid).stroke_color = *color;
graph.edge_mut(eid).stroke_color = *color;
}
}
PropertyChange::StrokeWidth { edge_ids, width } => {
self.old_edge_values.clear();
for &eid in edge_ids {
let edge = dcel.edge(eid);
let edge = graph.edge(eid);
let old_width = edge.stroke_style.as_ref().map(|s| s.width);
self.old_edge_values.push((eid, edge.stroke_color, old_width));
if let Some(ref mut style) = dcel.edge_mut(eid).stroke_style {
if let Some(ref mut style) = graph.edge_mut(eid).stroke_style {
style.width = *width;
}
}
@ -133,23 +133,23 @@ impl Action for SetShapePropertiesAction {
}
fn rollback(&mut self, document: &mut Document) -> Result<(), String> {
let dcel = Self::get_dcel_mut(document, &self.layer_id, self.time)?;
let graph = Self::get_graph_mut(document, &self.layer_id, self.time)?;
match &self.change {
PropertyChange::FillColor { .. } => {
for &(fid, old_color) in &self.old_face_values {
dcel.face_mut(fid).fill_color = old_color;
for &(fid, old_color) in &self.old_fill_values {
graph.fill_mut(fid).color = old_color;
}
}
PropertyChange::StrokeColor { .. } => {
for &(eid, old_color, _) in &self.old_edge_values {
dcel.edge_mut(eid).stroke_color = old_color;
graph.edge_mut(eid).stroke_color = old_color;
}
}
PropertyChange::StrokeWidth { .. } => {
for &(eid, _, old_width) in &self.old_edge_values {
if let Some(w) = old_width {
if let Some(ref mut style) = dcel.edge_mut(eid).stroke_style {
if let Some(ref mut style) = graph.edge_mut(eid).stroke_style {
style.width = w;
}
}

View File

@ -170,7 +170,7 @@ impl VectorClip {
// Only process vector layers (skip other layer types)
if let AnyLayer::Vector(vector_layer) = &layer_node.data {
// Calculate bounds from DCEL edges
if let Some(dcel) = vector_layer.dcel_at_time(clip_time) {
if let Some(dcel) = vector_layer.graph_at_time(clip_time) {
use kurbo::Shape as KurboShape;
for edge in &dcel.edges {
if edge.deleted {

View File

@ -1,10 +1,10 @@
//! Hit testing for selection and interaction
//!
//! Provides functions for testing if points or rectangles intersect with
//! DCEL elements and clip instances, taking into account transform hierarchies.
//! vector graph elements and clip instances, taking into account transform hierarchies.
use crate::clip::ClipInstance;
use crate::dcel::{VertexId, EdgeId, FaceId};
use crate::vector_graph::{VertexId, EdgeId, FillId};
use crate::layer::VectorLayer;
use crate::shape::Shape;
use serde::{Deserialize, Serialize};
@ -14,25 +14,25 @@ use vello::kurbo::{Affine, Point, Rect, Shape as KurboShape};
/// Result of a hit test operation
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum HitResult {
/// Hit a DCEL edge (stroke)
/// Hit an edge (stroke)
Edge(EdgeId),
/// Hit a DCEL face (fill)
Face(FaceId),
/// Hit a fill
Fill(FillId),
/// Hit a clip instance
ClipInstance(Uuid),
}
/// Result of a DCEL-only hit test (no clip instances)
/// Result of a graph-only hit test (no clip instances)
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum DcelHitResult {
pub enum GraphHitResult {
Edge(EdgeId),
Face(FaceId),
Fill(FillId),
}
/// Hit test a layer at a specific point, returning edge or face hits.
/// Hit test a layer at a specific point, returning edge or fill hits.
///
/// Tests DCEL edges (strokes) and faces (fills) in the active keyframe.
/// Edge hits take priority over face hits.
/// Tests edges (strokes) and fills in the active keyframe.
/// Edge hits take priority over fill hits.
///
/// # Arguments
///
@ -44,22 +44,22 @@ pub enum DcelHitResult {
///
/// # Returns
///
/// The first DCEL element hit, or None if no hit
/// The first element hit, or None if no hit
pub fn hit_test_layer(
layer: &VectorLayer,
time: f64,
point: Point,
tolerance: f64,
parent_transform: Affine,
) -> Option<DcelHitResult> {
let dcel = layer.dcel_at_time(time)?;
) -> Option<GraphHitResult> {
let graph = layer.graph_at_time(time)?;
// Transform point to local space
let local_point = parent_transform.inverse() * point;
// 1. Check edges (strokes) — priority over faces
// 1. Check edges (strokes) — priority over fills
let mut best_edge: Option<(EdgeId, f64)> = None;
for (i, edge) in dcel.edges.iter().enumerate() {
for (i, edge) in graph.edges.iter().enumerate() {
if edge.deleted {
continue;
}
@ -86,24 +86,24 @@ pub fn hit_test_layer(
}
}
if let Some((edge_id, _)) = best_edge {
return Some(DcelHitResult::Edge(edge_id));
return Some(GraphHitResult::Edge(edge_id));
}
// 2. Check faces (fills)
for (i, face) in dcel.faces.iter().enumerate() {
if face.deleted || i == 0 {
continue; // skip unbounded face
}
if face.fill_color.is_none() && face.image_fill.is_none() && face.gradient_fill.is_none() {
// 2. Check fills
for (i, fill) in graph.fills.iter().enumerate() {
if fill.deleted {
continue;
}
if face.outer_half_edge.is_none() {
if fill.color.is_none() && fill.image_fill.is_none() && fill.gradient_fill.is_none() {
continue;
}
if fill.boundary.is_empty() {
continue;
}
let path = dcel.face_to_bezpath(FaceId(i as u32));
let path = graph.fill_to_bezpath(FillId(i as u32));
if path.winding(local_point) != 0 {
return Some(DcelHitResult::Face(FaceId(i as u32)));
return Some(GraphHitResult::Fill(FillId(i as u32)));
}
}
@ -147,26 +147,26 @@ pub fn hit_test_shape(
false
}
/// Result of DCEL marquee selection
/// Result of graph marquee selection
#[derive(Debug, Default)]
pub struct DcelMarqueeResult {
pub struct GraphMarqueeResult {
pub edges: Vec<EdgeId>,
pub faces: Vec<FaceId>,
pub fills: Vec<FillId>,
}
/// Hit test DCEL elements within a rectangle (for marquee selection).
/// Hit test graph elements within a rectangle (for marquee selection).
///
/// Selects edges whose both endpoints are inside the rect,
/// and faces whose all boundary vertices are inside the rect.
pub fn hit_test_dcel_in_rect(
/// and fills whose all boundary vertices are inside the rect.
pub fn hit_test_graph_in_rect(
layer: &VectorLayer,
time: f64,
rect: Rect,
parent_transform: Affine,
) -> DcelMarqueeResult {
let mut result = DcelMarqueeResult::default();
) -> GraphMarqueeResult {
let mut result = GraphMarqueeResult::default();
let dcel = match layer.dcel_at_time(time) {
let graph = match layer.graph_at_time(time) {
Some(d) => d,
None => return result,
};
@ -175,41 +175,36 @@ pub fn hit_test_dcel_in_rect(
let local_rect = inv.transform_rect_bbox(rect);
// Check edges: both endpoints inside rect
for (i, edge) in dcel.edges.iter().enumerate() {
for (i, edge) in graph.edges.iter().enumerate() {
if edge.deleted {
continue;
}
let [he_fwd, he_bwd] = edge.half_edges;
if he_fwd.is_none() || he_bwd.is_none() {
continue;
}
let v1 = dcel.half_edge(he_fwd).origin;
let v2 = dcel.half_edge(he_bwd).origin;
let v1 = edge.vertices[0];
let v2 = edge.vertices[1];
if v1.is_none() || v2.is_none() {
continue;
}
let p1 = dcel.vertex(v1).position;
let p2 = dcel.vertex(v2).position;
let p1 = graph.vertex(v1).position;
let p2 = graph.vertex(v2).position;
if local_rect.contains(p1) && local_rect.contains(p2) {
result.edges.push(EdgeId(i as u32));
}
}
// Check faces: all boundary vertices inside rect
for (i, face) in dcel.faces.iter().enumerate() {
if face.deleted || i == 0 {
// Check fills: all boundary vertices inside rect
for (i, fill) in graph.fills.iter().enumerate() {
if fill.deleted {
continue;
}
if face.outer_half_edge.is_none() {
if fill.boundary.is_empty() {
continue;
}
let boundary = dcel.face_boundary(FaceId(i as u32));
let all_inside = boundary.iter().all(|&he_id| {
let v = dcel.half_edge(he_id).origin;
!v.is_none() && local_rect.contains(dcel.vertex(v).position)
let boundary_verts = graph.fill_boundary_vertices(FillId(i as u32));
let all_inside = boundary_verts.iter().all(|&v| {
!v.is_none() && local_rect.contains(graph.vertex(v).position)
});
if all_inside && !boundary.is_empty() {
result.faces.push(FaceId(i as u32));
if all_inside && !boundary_verts.is_empty() {
result.fills.push(FillId(i as u32));
}
}
@ -351,7 +346,7 @@ pub enum VectorEditHit {
},
/// Hit shape fill
Fill {
face_id: FaceId,
fill_id: FillId,
},
}
@ -397,7 +392,7 @@ pub fn hit_test_vector_editing(
) -> Option<VectorEditHit> {
use kurbo::ParamCurveNearest;
let dcel = layer.dcel_at_time(time)?;
let graph = layer.graph_at_time(time)?;
// Transform point into layer-local space
let local_point = parent_transform.inverse() * point;
@ -407,7 +402,7 @@ pub fn hit_test_vector_editing(
// 1. Control points (only when show_control_points is true, e.g. BezierEdit tool)
if show_control_points {
let mut best_cp: Option<(EdgeId, u8, f64)> = None;
for (i, edge) in dcel.edges.iter().enumerate() {
for (i, edge) in graph.edges.iter().enumerate() {
if edge.deleted {
continue;
}
@ -434,7 +429,7 @@ pub fn hit_test_vector_editing(
// 2. Vertices
let mut best_vertex: Option<(VertexId, f64)> = None;
for (i, vertex) in dcel.vertices.iter().enumerate() {
for (i, vertex) in graph.vertices.iter().enumerate() {
if vertex.deleted {
continue;
}
@ -451,7 +446,7 @@ pub fn hit_test_vector_editing(
// 3. Curves (edges)
let mut best_curve: Option<(EdgeId, f64, f64)> = None; // (edge_id, t, dist)
for (i, edge) in dcel.edges.iter().enumerate() {
for (i, edge) in graph.edges.iter().enumerate() {
if edge.deleted {
continue;
}
@ -467,20 +462,20 @@ pub fn hit_test_vector_editing(
return Some(VectorEditHit::Curve { edge_id, parameter_t });
}
// 4. Face fill testing
for (i, face) in dcel.faces.iter().enumerate() {
if face.deleted || i == 0 {
// 4. Fill testing
for (i, fill) in graph.fills.iter().enumerate() {
if fill.deleted {
continue;
}
if face.fill_color.is_none() && face.image_fill.is_none() && face.gradient_fill.is_none() {
if fill.color.is_none() && fill.image_fill.is_none() && fill.gradient_fill.is_none() {
continue;
}
if face.outer_half_edge.is_none() {
if fill.boundary.is_empty() {
continue;
}
let path = dcel.face_to_bezpath(FaceId(i as u32));
let path = graph.fill_to_bezpath(FillId(i as u32));
if path.winding(local_point) != 0 {
return Some(VectorEditHit::Fill { face_id: FaceId(i as u32) });
return Some(VectorEditHit::Fill { fill_id: FillId(i as u32) });
}
}
@ -495,16 +490,16 @@ mod tests {
#[test]
fn test_hit_test_simple_circle() {
// TODO: DCEL - rewrite test
// TODO: VectorGraph - rewrite test
}
#[test]
fn test_hit_test_with_transform() {
// TODO: DCEL - rewrite test
// TODO: VectorGraph - rewrite test
}
#[test]
fn test_marquee_selection() {
// TODO: DCEL - rewrite test
// TODO: VectorGraph - rewrite test
}
}

View File

@ -4,7 +4,7 @@
use crate::animation::AnimationData;
use crate::clip::ClipInstance;
use crate::dcel::Dcel;
use crate::vector_graph::VectorGraph;
use crate::effect_layer::EffectLayer;
use crate::object::ShapeInstance;
use crate::raster_layer::RasterLayer;
@ -165,13 +165,13 @@ impl Default for TweenType {
}
}
/// A keyframe containing vector artwork as a DCEL planar subdivision.
/// A keyframe containing vector artwork as a VectorGraph.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct ShapeKeyframe {
/// Time in seconds
pub time: f64,
/// DCEL planar subdivision containing all vector artwork
pub dcel: Dcel,
/// Vector graph containing all vector artwork
pub graph: VectorGraph,
/// What happens between this keyframe and the next
#[serde(default)]
pub tween_after: TweenType,
@ -186,7 +186,7 @@ impl ShapeKeyframe {
pub fn new(time: f64) -> Self {
Self {
time,
dcel: Dcel::new(),
graph: VectorGraph::new(),
tween_after: TweenType::None,
clip_instance_ids: Vec::new(),
}
@ -376,14 +376,14 @@ impl VectorLayer {
self.keyframes.iter().position(|kf| (kf.time - time).abs() < tolerance)
}
/// Get the DCEL at a given time (from the keyframe at-or-before time)
pub fn dcel_at_time(&self, time: f64) -> Option<&Dcel> {
self.keyframe_at(time).map(|kf| &kf.dcel)
/// Get the VectorGraph at a given time (from the keyframe at-or-before time)
pub fn graph_at_time(&self, time: f64) -> Option<&VectorGraph> {
self.keyframe_at(time).map(|kf| &kf.graph)
}
/// Get a mutable DCEL at a given time
pub fn dcel_at_time_mut(&mut self, time: f64) -> Option<&mut Dcel> {
self.keyframe_at_mut(time).map(|kf| &mut kf.dcel)
/// Get a mutable VectorGraph at a given time
pub fn graph_at_time_mut(&mut self, time: f64) -> Option<&mut VectorGraph> {
self.keyframe_at_mut(time).map(|kf| &mut kf.graph)
}
/// Get the duration of the keyframe span starting at-or-before `time`.
@ -433,7 +433,7 @@ impl VectorLayer {
}
// Shape-based methods removed — use DCEL methods instead.
// - shapes_at_time_mut → dcel_at_time_mut
// - shapes_at_time_mut → graph_at_time_mut
// - get_shape_in_keyframe → use DCEL vertex/edge/face accessors
// - get_shape_in_keyframe_mut → use DCEL vertex/edge/face accessors
@ -458,17 +458,17 @@ impl VectorLayer {
return &mut self.keyframes[idx];
}
// Clone DCEL and clip instance IDs from the active keyframe
let (cloned_dcel, cloned_clip_ids) = self
// Clone graph and clip instance IDs from the active keyframe
let (cloned_graph, cloned_clip_ids) = self
.keyframe_at(time)
.map(|kf| {
(kf.dcel.clone(), kf.clip_instance_ids.clone())
(kf.graph.clone(), kf.clip_instance_ids.clone())
})
.unwrap_or_else(|| (Dcel::new(), Vec::new()));
.unwrap_or_else(|| (VectorGraph::new(), Vec::new()));
let insert_idx = self.keyframes.partition_point(|kf| kf.time < time);
let mut kf = ShapeKeyframe::new(time);
kf.dcel = cloned_dcel;
kf.graph = cloned_graph;
kf.clip_instance_ids = cloned_clip_ids;
self.keyframes.insert(insert_idx, kf);
&mut self.keyframes[insert_idx]

View File

@ -341,8 +341,8 @@ pub fn render_layer_isolated(
image_cache,
video_manager,
);
rendered.has_content = vector_layer.dcel_at_time(time)
.map_or(false, |dcel| !dcel.edges.iter().all(|e| e.deleted) || !dcel.faces.iter().skip(1).all(|f| f.deleted))
rendered.has_content = vector_layer.graph_at_time(time)
.map_or(false, |graph| !graph.edges.iter().all(|e| e.deleted) || !graph.fills.iter().all(|f| f.deleted))
|| !vector_layer.clip_instances.is_empty();
}
AnyLayer::Audio(_) => {
@ -1059,11 +1059,11 @@ fn gradient_bbox_endpoints(angle_deg: f32, bbox: kurbo::Rect) -> (kurbo::Point,
(start, end)
}
/// Render a DCEL to a Vello scene.
/// Render a VectorGraph to a Vello scene.
///
/// Walks faces for fills and edges for strokes.
pub fn render_dcel(
dcel: &crate::dcel::Dcel,
/// Walks fills and edges for strokes.
pub fn render_vector_graph(
graph: &crate::vector_graph::VectorGraph,
scene: &mut Scene,
base_transform: Affine,
layer_opacity: f64,
@ -1072,23 +1072,23 @@ pub fn render_dcel(
) {
let opacity_f32 = layer_opacity as f32;
// 1. Render faces (fills)
for (i, face) in dcel.faces.iter().enumerate() {
if face.deleted || i == 0 {
continue; // Skip unbounded face and deleted faces
// 1. Render fills
for (i, fill) in graph.fills.iter().enumerate() {
if fill.deleted {
continue; // Skip deleted fills
}
if face.fill_color.is_none() && face.image_fill.is_none() && face.gradient_fill.is_none() {
if fill.color.is_none() && fill.image_fill.is_none() && fill.gradient_fill.is_none() {
continue; // No fill to render
}
let face_id = crate::dcel::FaceId(i as u32);
let path = dcel.face_to_bezpath_with_holes(face_id);
let fill_rule: Fill = face.fill_rule.into();
let fill_id = crate::vector_graph::FillId(i as u32);
let path = graph.fill_to_bezpath(fill_id);
let fill_rule: Fill = fill.fill_rule.into();
let mut filled = false;
// Image fill
if let Some(image_asset_id) = face.image_fill {
if let Some(image_asset_id) = fill.image_fill {
if let Some(image_asset) = document.get_image_asset(&image_asset_id) {
if let Some(image) = image_cache.get_or_decode(image_asset) {
let image_with_alpha = (*image).clone().with_alpha(opacity_f32);
@ -1100,7 +1100,7 @@ pub fn render_dcel(
// Gradient fill (takes priority over solid colour fill)
if !filled {
if let Some(ref grad) = face.gradient_fill {
if let Some(ref grad) = fill.gradient_fill {
use kurbo::Rect;
use crate::gradient::GradientType;
let bbox: Rect = vello::kurbo::Shape::bounding_box(&path);
@ -1128,7 +1128,7 @@ pub fn render_dcel(
// Solid colour fill
if !filled {
if let Some(fill_color) = &face.fill_color {
if let Some(fill_color) = &fill.color {
let alpha = ((fill_color.a as f32 / 255.0) * opacity_f32 * 255.0) as u8;
let adjusted = crate::shape::ShapeColor::rgba(
fill_color.r,
@ -1142,7 +1142,7 @@ pub fn render_dcel(
}
// 2. Render edges (strokes)
for edge in &dcel.edges {
for edge in &graph.edges {
if edge.deleted {
continue;
}
@ -1195,9 +1195,9 @@ fn render_vector_layer(
render_clip_instance(document, time, clip_instance, layer_opacity, scene, base_transform, &layer.layer.animation_data, image_cache, video_manager, group_end_time);
}
// Render DCEL from active keyframe
if let Some(dcel) = layer.dcel_at_time(time) {
render_dcel(dcel, scene, base_transform, layer_opacity, document, image_cache);
// Render VectorGraph from active keyframe
if let Some(graph) = layer.graph_at_time(time) {
render_vector_graph(graph, scene, base_transform, layer_opacity, document, image_cache);
}
}
@ -1362,29 +1362,29 @@ fn render_background_cpu(
pixmap.fill_rect(bg_rect, &paint, ts_transform, None);
}
/// Render a DCEL to a CPU pixmap.
fn render_dcel_cpu(
dcel: &crate::dcel::Dcel,
/// Render a VectorGraph to a CPU pixmap.
fn render_vector_graph_cpu(
graph: &crate::vector_graph::VectorGraph,
pixmap: &mut tiny_skia::PixmapMut<'_>,
transform: tiny_skia::Transform,
opacity: f32,
_document: &Document,
_image_cache: &mut ImageCache,
) {
// 1. Faces (fills)
for (i, face) in dcel.faces.iter().enumerate() {
if face.deleted || i == 0 {
// 1. Fills
for (i, fill) in graph.fills.iter().enumerate() {
if fill.deleted {
continue;
}
if face.fill_color.is_none() && face.image_fill.is_none() && face.gradient_fill.is_none() {
if fill.color.is_none() && fill.image_fill.is_none() && fill.gradient_fill.is_none() {
continue;
}
let face_id = crate::dcel::FaceId(i as u32);
let path = dcel.face_to_bezpath_with_holes(face_id);
let fill_id = crate::vector_graph::FillId(i as u32);
let path = graph.fill_to_bezpath(fill_id);
let Some(ts_path) = bezpath_to_ts(&path) else { continue };
let fill_type = match face.fill_rule {
let fill_type = match fill.fill_rule {
crate::shape::FillRule::NonZero => tiny_skia::FillRule::Winding,
crate::shape::FillRule::EvenOdd => tiny_skia::FillRule::EvenOdd,
};
@ -1392,7 +1392,7 @@ fn render_dcel_cpu(
let mut filled = false;
// Gradient fill (takes priority over solid)
if let Some(ref grad) = face.gradient_fill {
if let Some(ref grad) = fill.gradient_fill {
let bbox: kurbo::Rect = vello::kurbo::Shape::bounding_box(&path);
let (start, end) = match (grad.start_world, grad.end_world) {
(Some((sx, sy)), Some((ex, ey))) => match grad.kind {
@ -1417,7 +1417,7 @@ fn render_dcel_cpu(
// Solid colour fill
if !filled {
if let Some(fc) = &face.fill_color {
if let Some(fc) = &fill.color {
let paint = solid_paint(fc.r, fc.g, fc.b, fc.a, opacity);
pixmap.fill_path(&ts_path, &paint, fill_type, transform, None);
}
@ -1425,7 +1425,7 @@ fn render_dcel_cpu(
}
// 2. Edges (strokes)
for edge in &dcel.edges {
for edge in &graph.edges {
if edge.deleted {
continue;
}
@ -1481,8 +1481,8 @@ fn render_vector_layer_cpu(
);
}
if let Some(dcel) = layer.dcel_at_time(time) {
render_dcel_cpu(dcel, pixmap, affine_to_ts(base_transform), layer_opacity as f32, document, image_cache);
if let Some(graph) = layer.graph_at_time(time) {
render_vector_graph_cpu(graph, pixmap, affine_to_ts(base_transform), layer_opacity as f32, document, image_cache);
}
}

View File

@ -2,9 +2,9 @@
//!
//! Tracks selected DCEL elements (edges, faces, vertices) and clip instances for editing operations.
use crate::dcel::{Dcel, EdgeId, FaceId, VertexId};
use crate::vector_graph::{VectorGraph, EdgeId, FillId, VertexId};
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use std::collections::{HashMap, HashSet};
use uuid::Uuid;
use vello::kurbo::{Affine, BezPath};
@ -181,8 +181,8 @@ pub struct Selection {
/// Currently selected edges
selected_edges: HashSet<EdgeId>,
/// Currently selected faces
selected_faces: HashSet<FaceId>,
/// Currently selected fills
selected_fills: HashSet<FillId>,
/// Currently selected clip instances
selected_clip_instances: Vec<Uuid>,
@ -203,7 +203,7 @@ pub struct Selection {
/// 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>,
pub vector_subgraph: Option<VectorGraph>,
}
impl Selection {
@ -212,7 +212,7 @@ impl Selection {
Self {
selected_vertices: HashSet::new(),
selected_edges: HashSet::new(),
selected_faces: HashSet::new(),
selected_fills: HashSet::new(),
selected_clip_instances: Vec::new(),
raster_selection: None,
raster_floating: None,
@ -221,94 +221,70 @@ impl Selection {
}
// -----------------------------------------------------------------------
// DCEL element selection
// Geometry element selection (VectorGraph)
// -----------------------------------------------------------------------
/// Select an edge and its endpoint vertices, forming/extending a subgraph.
pub fn select_edge(&mut self, edge_id: EdgeId, dcel: &Dcel) {
if edge_id.is_none() || dcel.edge(edge_id).deleted {
pub fn select_edge(&mut self, edge_id: EdgeId, graph: &VectorGraph) {
if edge_id.is_none() || graph.edge(edge_id).deleted {
return;
}
self.selected_edges.insert(edge_id);
// Add both endpoint vertices
let [he_fwd, he_bwd] = dcel.edge(edge_id).half_edges;
if !he_fwd.is_none() {
let v = dcel.half_edge(he_fwd).origin;
if !v.is_none() {
self.selected_vertices.insert(v);
}
let [v0, v1] = graph.edge(edge_id).vertices;
if !v0.is_none() {
self.selected_vertices.insert(v0);
}
if !he_bwd.is_none() {
let v = dcel.half_edge(he_bwd).origin;
if !v.is_none() {
self.selected_vertices.insert(v);
}
if !v1.is_none() {
self.selected_vertices.insert(v1);
}
}
/// Select a face by ID only, without adding boundary edges or vertices.
/// Select a fill by ID only, without adding boundary edges or vertices.
///
/// Use this when the geometry lives in a separate DCEL (e.g. region selection's
/// `selected_dcel`) so we don't add stale edge/vertex IDs to the selection.
pub fn select_face_id_only(&mut self, face_id: FaceId) {
if !face_id.is_none() && face_id.0 != 0 {
self.selected_faces.insert(face_id);
/// Use this when the geometry lives in a separate graph (e.g. region selection's
/// `selected_graph`) so we don't add stale edge/vertex IDs to the selection.
pub fn select_fill_id_only(&mut self, fill_id: FillId) {
if !fill_id.is_none() {
self.selected_fills.insert(fill_id);
}
}
/// Select a face and all its boundary edges + vertices.
pub fn select_face(&mut self, face_id: FaceId, dcel: &Dcel) {
if face_id.is_none() || face_id.0 == 0 || dcel.face(face_id).deleted {
/// Select a fill and all its boundary edges + vertices.
pub fn select_fill(&mut self, fill_id: FillId, graph: &VectorGraph) {
if fill_id.is_none() || graph.fill(fill_id).deleted {
return;
}
self.selected_faces.insert(face_id);
self.selected_fills.insert(fill_id);
// Add all boundary edges and vertices
let boundary = dcel.face_boundary(face_id);
for he_id in boundary {
let he = dcel.half_edge(he_id);
let edge_id = he.edge;
if !edge_id.is_none() {
self.selected_edges.insert(edge_id);
// Add endpoints
let [he_fwd, he_bwd] = dcel.edge(edge_id).half_edges;
if !he_fwd.is_none() {
let v = dcel.half_edge(he_fwd).origin;
if !v.is_none() {
self.selected_vertices.insert(v);
}
}
if !he_bwd.is_none() {
let v = dcel.half_edge(he_bwd).origin;
if !v.is_none() {
self.selected_vertices.insert(v);
}
}
for eid in graph.fill_boundary_edges(fill_id) {
self.selected_edges.insert(eid);
let [v0, v1] = graph.edge(eid).vertices;
if !v0.is_none() {
self.selected_vertices.insert(v0);
}
if !v1.is_none() {
self.selected_vertices.insert(v1);
}
}
}
/// Deselect an edge and its vertices (if they have no other selected edges).
pub fn deselect_edge(&mut self, edge_id: EdgeId, dcel: &Dcel) {
pub fn deselect_edge(&mut self, edge_id: EdgeId, graph: &VectorGraph) {
self.selected_edges.remove(&edge_id);
// Remove endpoint vertices only if they're not used by other selected edges
let [he_fwd, he_bwd] = dcel.edge(edge_id).half_edges;
for he_id in [he_fwd, he_bwd] {
if he_id.is_none() {
continue;
}
let v = dcel.half_edge(he_id).origin;
let [v0, v1] = graph.edge(edge_id).vertices;
for v in [v0, v1] {
if v.is_none() {
continue;
}
// Check if any other selected edge uses this vertex
let used = self.selected_edges.iter().any(|&eid| {
let e = dcel.edge(eid);
let [a, b] = e.half_edges;
(!a.is_none() && dcel.half_edge(a).origin == v)
|| (!b.is_none() && dcel.half_edge(b).origin == v)
let e = graph.edge(eid);
e.vertices[0] == v || e.vertices[1] == v
});
if !used {
self.selected_vertices.remove(&v);
@ -316,26 +292,26 @@ impl Selection {
}
}
/// Deselect a face (edges/vertices stay if still referenced by other selections).
pub fn deselect_face(&mut self, face_id: FaceId) {
self.selected_faces.remove(&face_id);
/// Deselect a fill (edges/vertices stay if still referenced by other selections).
pub fn deselect_fill(&mut self, fill_id: FillId) {
self.selected_fills.remove(&fill_id);
}
/// Toggle an edge's selection state.
pub fn toggle_edge(&mut self, edge_id: EdgeId, dcel: &Dcel) {
pub fn toggle_edge(&mut self, edge_id: EdgeId, graph: &VectorGraph) {
if self.selected_edges.contains(&edge_id) {
self.deselect_edge(edge_id, dcel);
self.deselect_edge(edge_id, graph);
} else {
self.select_edge(edge_id, dcel);
self.select_edge(edge_id, graph);
}
}
/// Toggle a face's selection state.
pub fn toggle_face(&mut self, face_id: FaceId, dcel: &Dcel) {
if self.selected_faces.contains(&face_id) {
self.deselect_face(face_id);
/// Toggle a fill's selection state.
pub fn toggle_fill(&mut self, fill_id: FillId, graph: &VectorGraph) {
if self.selected_fills.contains(&fill_id) {
self.deselect_fill(fill_id);
} else {
self.select_face(face_id, dcel);
self.select_fill(fill_id, graph);
}
}
@ -344,9 +320,9 @@ impl Selection {
self.selected_edges.contains(edge_id)
}
/// Check if a face is selected.
pub fn contains_face(&self, face_id: &FaceId) -> bool {
self.selected_faces.contains(face_id)
/// Check if a fill is selected.
pub fn contains_fill(&self, fill_id: &FillId) -> bool {
self.selected_fills.contains(fill_id)
}
/// Check if a vertex is selected.
@ -354,17 +330,17 @@ impl Selection {
self.selected_vertices.contains(vertex_id)
}
/// Clear DCEL element selections (edges, faces, vertices).
pub fn clear_dcel_selection(&mut self) {
/// Clear geometry element selections (edges, fills, vertices).
pub fn clear_geometry_selection(&mut self) {
self.selected_vertices.clear();
self.selected_edges.clear();
self.selected_faces.clear();
self.selected_fills.clear();
self.vector_subgraph = None;
}
/// Check if any DCEL elements are selected.
pub fn has_dcel_selection(&self) -> bool {
!self.selected_edges.is_empty() || !self.selected_faces.is_empty()
/// Check if any geometry elements are selected.
pub fn has_geometry_selection(&self) -> bool {
!self.selected_edges.is_empty() || !self.selected_fills.is_empty()
}
/// Get selected edges.
@ -372,9 +348,9 @@ impl Selection {
&self.selected_edges
}
/// Get selected faces.
pub fn selected_faces(&self) -> &HashSet<FaceId> {
&self.selected_faces
/// Get selected fills.
pub fn selected_fills(&self) -> &HashSet<FillId> {
&self.selected_fills
}
/// Get selected vertices.
@ -449,7 +425,7 @@ impl Selection {
pub fn clear(&mut self) {
self.selected_vertices.clear();
self.selected_edges.clear();
self.selected_faces.clear();
self.selected_fills.clear();
self.selected_clip_instances.clear();
self.raster_selection = None;
self.raster_floating = None;
@ -459,7 +435,7 @@ impl Selection {
/// Check if selection is empty
pub fn is_empty(&self) -> bool {
self.selected_edges.is_empty()
&& self.selected_faces.is_empty()
&& self.selected_fills.is_empty()
&& self.selected_clip_instances.is_empty()
}
}
@ -479,25 +455,25 @@ pub struct RegionSelection {
pub layer_id: Uuid,
/// Keyframe time
pub time: f64,
/// Snapshot of the DCEL before region boundary insertion, for revert
pub dcel_snapshot: Dcel,
/// The extracted DCEL containing geometry inside the region
pub selected_dcel: Dcel,
/// Transform applied to the selected DCEL (e.g. from dragging)
/// Snapshot of the graph before region boundary insertion, for revert
pub graph_snapshot: VectorGraph,
/// The extracted graph containing geometry inside the region
pub selected_graph: VectorGraph,
/// Transform applied to the selected graph (e.g. from dragging)
pub transform: Affine,
/// Whether the selection has been committed (via an operation on the selection)
pub committed: bool,
/// Non-boundary vertices that are strictly inside the region (for merge-back).
pub inside_vertices: Vec<VertexId>,
/// Region boundary intersection vertices (for merge-back and fill propagation).
pub boundary_vertices: Vec<VertexId>,
/// IDs of the invisible edges inserted for the region boundary stroke.
/// Removing these during merge-back heals the face splits they created.
/// These exist in the main graph (remainder side). Deleted during merge-back.
pub region_edge_ids: Vec<EdgeId>,
/// Action epoch recorded when this selection was created.
/// Compared against `ActionExecutor::epoch()` on deselect to decide
/// whether merge-back is needed or a clean snapshot restore suffices.
pub action_epoch_at_selection: u64,
/// selected_graph VID → main graph VID for boundary vertices (shared between both graphs).
pub boundary_vertex_map: HashMap<VertexId, VertexId>,
/// selected_graph boundary EID → main graph boundary EID (duplicated edges to skip on merge).
pub boundary_edge_map: HashMap<EdgeId, EdgeId>,
}
#[cfg(test)]
@ -570,23 +546,23 @@ mod tests {
}
#[test]
fn test_dcel_selection_basics() {
fn test_geometry_selection_basics() {
let selection = Selection::new();
assert!(!selection.has_dcel_selection());
assert!(!selection.has_geometry_selection());
assert!(selection.selected_edges().is_empty());
assert!(selection.selected_faces().is_empty());
assert!(selection.selected_fills().is_empty());
assert!(selection.selected_vertices().is_empty());
}
#[test]
fn test_clear_dcel_selection() {
fn test_clear_geometry_selection() {
let mut selection = Selection::new();
// Manually insert for unit test (no DCEL needed)
// Manually insert for unit test (no graph needed)
selection.selected_edges.insert(EdgeId(0));
selection.selected_vertices.insert(VertexId(0));
assert!(selection.has_dcel_selection());
assert!(selection.has_geometry_selection());
selection.clear_dcel_selection();
assert!(!selection.has_dcel_selection());
selection.clear_geometry_selection();
assert!(!selection.has_geometry_selection());
}
}

View File

@ -3,7 +3,7 @@
//! Provides snap-to-geometry queries that find the nearest vertex, edge midpoint,
//! or curve point within a given radius. Priority order: Vertex > Midpoint > Curve.
use crate::dcel::{Dcel, EdgeId, VertexId};
use crate::vector_graph::{VectorGraph, EdgeId, VertexId};
use vello::kurbo::{ParamCurve, ParamCurveNearest, Point};
/// Default snap radius in screen pixels (converted to document space via zoom).
@ -70,7 +70,7 @@ pub struct SnapExclusion {
/// Priority: Vertex > Edge Midpoint > Nearest point on Curve.
/// Returns `None` if nothing is within the configured radius.
pub fn find_snap_target(
dcel: &Dcel,
graph: &VectorGraph,
point: Point,
config: &SnapConfig,
exclusion: &SnapExclusion,
@ -80,7 +80,7 @@ pub fn find_snap_target(
// Phase 1: Vertex snap (highest priority)
if config.snap_to_vertices {
let mut best: Option<(VertexId, Point, f64)> = None;
for (i, vertex) in dcel.vertices.iter().enumerate() {
for (i, vertex) in graph.vertices.iter().enumerate() {
if vertex.deleted {
continue;
}
@ -109,7 +109,7 @@ pub fn find_snap_target(
// Phase 2: Edge midpoint snap
if config.snap_to_midpoints {
let mut best: Option<(EdgeId, Point, f64)> = None;
for (i, edge) in dcel.edges.iter().enumerate() {
for (i, edge) in graph.edges.iter().enumerate() {
if edge.deleted {
continue;
}
@ -139,7 +139,7 @@ pub fn find_snap_target(
// Phase 3: Nearest point on curve
if config.snap_to_curves {
let mut best: Option<(EdgeId, f64, Point, f64)> = None;
for (i, edge) in dcel.edges.iter().enumerate() {
for (i, edge) in graph.edges.iter().enumerate() {
if edge.deleted {
continue;
}
@ -176,21 +176,21 @@ mod tests {
use super::*;
use vello::kurbo::CubicBez;
fn make_dcel_with_edge() -> Dcel {
let mut dcel = Dcel::new();
fn make_graph_with_edge() -> VectorGraph {
let mut graph = VectorGraph::new();
let curve = CubicBez::new(
Point::new(0.0, 0.0),
Point::new(33.0, 0.0),
Point::new(67.0, 0.0),
Point::new(100.0, 0.0),
);
dcel.insert_stroke(&[curve], None, None, 0.5);
dcel
graph.insert_stroke(&[curve], None, None, 0.5);
graph
}
#[test]
fn snap_to_vertex() {
let dcel = make_dcel_with_edge();
let graph = make_graph_with_edge();
let config = SnapConfig {
radius: 5.0,
snap_to_vertices: true,
@ -198,14 +198,14 @@ mod tests {
snap_to_curves: true,
};
let exclusion = SnapExclusion::default();
let result = find_snap_target(&dcel, Point::new(2.0, 0.0), &config, &exclusion);
let result = find_snap_target(&graph, Point::new(2.0, 0.0), &config, &exclusion);
assert!(result.is_some());
assert!(matches!(result.unwrap().target, SnapTarget::Vertex { .. }));
}
#[test]
fn snap_to_midpoint() {
let dcel = make_dcel_with_edge();
let graph = make_graph_with_edge();
let config = SnapConfig {
radius: 5.0,
snap_to_vertices: true,
@ -214,14 +214,14 @@ mod tests {
};
let exclusion = SnapExclusion::default();
// Point near midpoint (50, 0) but far from vertices (0,0) and (100,0)
let result = find_snap_target(&dcel, Point::new(51.0, 0.0), &config, &exclusion);
let result = find_snap_target(&graph, Point::new(51.0, 0.0), &config, &exclusion);
assert!(result.is_some());
assert!(matches!(result.unwrap().target, SnapTarget::Midpoint { .. }));
}
#[test]
fn snap_to_curve() {
let dcel = make_dcel_with_edge();
let graph = make_graph_with_edge();
let config = SnapConfig {
radius: 5.0,
snap_to_vertices: true,
@ -230,14 +230,14 @@ mod tests {
};
let exclusion = SnapExclusion::default();
// Point near t=0.25 on curve (25, 0) — not near a vertex or midpoint
let result = find_snap_target(&dcel, Point::new(25.0, 3.0), &config, &exclusion);
let result = find_snap_target(&graph, Point::new(25.0, 3.0), &config, &exclusion);
assert!(result.is_some());
assert!(matches!(result.unwrap().target, SnapTarget::Curve { .. }));
}
#[test]
fn no_snap_outside_radius() {
let dcel = make_dcel_with_edge();
let graph = make_graph_with_edge();
let config = SnapConfig {
radius: 5.0,
snap_to_vertices: true,
@ -245,13 +245,13 @@ mod tests {
snap_to_curves: true,
};
let exclusion = SnapExclusion::default();
let result = find_snap_target(&dcel, Point::new(50.0, 20.0), &config, &exclusion);
let result = find_snap_target(&graph, Point::new(50.0, 20.0), &config, &exclusion);
assert!(result.is_none());
}
#[test]
fn exclusion_skips_vertex() {
let dcel = make_dcel_with_edge();
let graph = make_graph_with_edge();
let config = SnapConfig {
radius: 5.0,
snap_to_vertices: true,
@ -263,7 +263,7 @@ mod tests {
vertices: vec![VertexId(0)],
edges: vec![],
};
let result = find_snap_target(&dcel, Point::new(2.0, 0.0), &config, &exclusion);
let result = find_snap_target(&graph, Point::new(2.0, 0.0), &config, &exclusion);
assert!(result.is_none());
}
}

View File

@ -203,13 +203,13 @@ pub enum ToolState {
/// Editing a vertex (dragging it and connected edges)
EditingVertex {
vertex_id: crate::dcel::VertexId,
connected_edges: Vec<crate::dcel::EdgeId>, // edges to update when vertex moves
vertex_id: crate::vector_graph::VertexId,
connected_edges: Vec<crate::vector_graph::EdgeId>, // edges to update when vertex moves
},
/// Editing a curve (reshaping with moldCurve algorithm)
EditingCurve {
edge_id: crate::dcel::EdgeId,
edge_id: crate::vector_graph::EdgeId,
original_curve: vello::kurbo::CubicBez,
start_mouse: Point,
parameter_t: f64,
@ -217,7 +217,7 @@ pub enum ToolState {
/// Pending curve interaction: click selects edge, drag starts curve editing
PendingCurveInteraction {
edge_id: crate::dcel::EdgeId,
edge_id: crate::vector_graph::EdgeId,
parameter_t: f64,
start_mouse: Point,
},
@ -235,7 +235,7 @@ pub enum ToolState {
/// Editing a control point (BezierEdit tool only)
EditingControlPoint {
edge_id: crate::dcel::EdgeId,
edge_id: crate::vector_graph::EdgeId,
point_index: u8, // 1 or 2 (p1 or p2 of the cubic bezier)
original_curve: vello::kurbo::CubicBez,
start_pos: Point,

View File

@ -15,6 +15,7 @@ pub mod tests;
use kurbo::{CubicBez, ParamCurve, Point};
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
use std::fmt;
use crate::curve_intersections::find_curve_intersections;
@ -95,15 +96,19 @@ pub struct Edge {
pub deleted: bool,
}
/// A fill: an explicit boundary referencing edges, with a color.
/// A fill: an explicit boundary referencing edges, with visual properties.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Fill {
/// Ordered cycle of directed edge references forming the boundary.
/// `EdgeId::NONE` entries act as separators between outer contour and hole contours.
pub boundary: Vec<(EdgeId, Direction)>,
pub color: ShapeColor,
pub color: Option<ShapeColor>,
pub fill_rule: FillRule,
#[serde(default)]
pub gradient_fill: Option<crate::gradient::ShapeGradient>,
#[serde(default)]
pub image_fill: Option<uuid::Uuid>,
pub deleted: bool,
// TODO: gradient_fill, image_fill
}
// ---------------------------------------------------------------------------
@ -198,13 +203,15 @@ impl VectorGraph {
pub fn alloc_fill(
&mut self,
boundary: Vec<(EdgeId, Direction)>,
color: ShapeColor,
color: impl Into<Option<ShapeColor>>,
fill_rule: FillRule,
) -> FillId {
let fill = Fill {
boundary,
color,
color: color.into(),
fill_rule,
gradient_fill: None,
image_fill: None,
deleted: false,
};
if let Some(idx) = self.free_fills.pop() {
@ -344,6 +351,61 @@ impl VectorGraph {
}
}
// -------------------------------------------------------------------
// Fill / hit-test queries
// -------------------------------------------------------------------
/// Find a fill whose boundary encloses the given point.
/// Returns the smallest (by area) enclosing fill.
pub fn find_fill_at_point(&self, point: Point) -> Option<FillId> {
let mut best: Option<(FillId, f64)> = None;
for (i, fill) in self.fills.iter().enumerate() {
if fill.deleted {
continue;
}
let fid = FillId(i as u32);
let path = self.fill_to_bezpath(fid);
if kurbo::Shape::winding(&path, point) != 0 {
let area = kurbo::Shape::area(&path).abs();
if best.is_none() || area < best.unwrap().1 {
best = Some((fid, area));
}
}
}
best.map(|(fid, _)| fid)
}
/// Get the distinct edge IDs from a fill's boundary (skipping NONE separators).
pub fn fill_boundary_edges(&self, fill_id: FillId) -> Vec<EdgeId> {
let fill = &self.fills[fill_id.idx()];
let mut edges = Vec::new();
for &(eid, _) in &fill.boundary {
if !eid.is_none() && !edges.contains(&eid) {
edges.push(eid);
}
}
edges
}
/// Get the distinct vertex IDs from a fill's boundary edges.
pub fn fill_boundary_vertices(&self, fill_id: FillId) -> Vec<VertexId> {
let mut verts = Vec::new();
for eid in self.fill_boundary_edges(fill_id) {
let e = &self.edges[eid.idx()];
for &vid in &e.vertices {
if !verts.contains(&vid) {
verts.push(vid);
}
}
}
verts
}
/// Alias for `delete_edge_by_user` — removes an edge, handling fill merging/invisibility.
pub fn remove_edge(&mut self, id: EdgeId) {
self.delete_edge_by_user(id);
}
// -------------------------------------------------------------------
// Fill boundary → BezPath (for rendering)
// -------------------------------------------------------------------
@ -1393,6 +1455,223 @@ impl VectorGraph {
None
}
// ── Region selection: extract / merge subgraph ──────────────────────
/// Extract a subgraph containing `inside_edges` and `inside_fills`.
///
/// Boundary edges (`boundary_edge_ids`) are **duplicated** — they exist in
/// both the returned graph and `self`, so both sides have closed fill
/// boundaries when the selection is moved.
///
/// Returns `(new_graph, vertex_map, edge_map)` where the maps go from
/// old (self) IDs to new (returned graph) IDs.
pub fn extract_subgraph(
&mut self,
inside_edges: &HashSet<EdgeId>,
inside_fills: &HashSet<FillId>,
boundary_edge_ids: &HashSet<EdgeId>,
) -> (VectorGraph, HashMap<VertexId, VertexId>, HashMap<EdgeId, EdgeId>) {
let mut new_graph = VectorGraph::new();
let mut vtx_map: HashMap<VertexId, VertexId> = HashMap::new();
let mut edge_map: HashMap<EdgeId, EdgeId> = HashMap::new();
// Collect all edge IDs we need to copy into the new graph
let edges_to_copy: HashSet<EdgeId> = inside_edges
.union(boundary_edge_ids)
.copied()
.collect();
// Collect all vertices referenced by edges we're copying
let mut referenced_vids: HashSet<VertexId> = HashSet::new();
for &eid in &edges_to_copy {
if eid.is_none() || self.edges[eid.idx()].deleted {
continue;
}
for &vid in &self.edges[eid.idx()].vertices {
referenced_vids.insert(vid);
}
}
// Determine which vertices are interior (exclusively owned by the
// extracted subgraph) vs boundary (shared with remaining geometry).
// A vertex is interior if ALL of its incident edges are either in
// inside_edges or boundary_edge_ids.
let mut interior_vertices: HashSet<VertexId> = HashSet::new();
let mut boundary_vertices: HashSet<VertexId> = HashSet::new();
for &vid in &referenced_vids {
let incident = self.edges_at_vertex(vid);
let all_inside = incident.iter().all(|&eid| edges_to_copy.contains(&eid));
if all_inside {
interior_vertices.insert(vid);
} else {
boundary_vertices.insert(vid);
}
}
// Allocate vertices in new graph
for &vid in &referenced_vids {
let pos = self.vertices[vid.idx()].position;
let new_vid = new_graph.alloc_vertex(pos);
vtx_map.insert(vid, new_vid);
}
// Copy edges into new graph
for &eid in &edges_to_copy {
if eid.is_none() || self.edges[eid.idx()].deleted {
continue;
}
let edge = &self.edges[eid.idx()];
let new_v0 = vtx_map[&edge.vertices[0]];
let new_v1 = vtx_map[&edge.vertices[1]];
let new_eid = new_graph.alloc_edge(
edge.curve,
new_v0,
new_v1,
edge.stroke_style.clone(),
edge.stroke_color.clone(),
);
edge_map.insert(eid, new_eid);
}
// Copy inside fills into new graph
for &fid in inside_fills {
if fid.is_none() || self.fills[fid.idx()].deleted {
continue;
}
let fill = &self.fills[fid.idx()];
let new_boundary: Vec<(EdgeId, Direction)> = fill
.boundary
.iter()
.map(|&(eid, dir)| {
if eid.is_none() {
(EdgeId::NONE, dir)
} else if let Some(&new_eid) = edge_map.get(&eid) {
(new_eid, dir)
} else {
// Edge referenced by fill but not in edges_to_copy —
// shouldn't happen if classification is correct, but
// skip gracefully.
(EdgeId::NONE, dir)
}
})
.collect();
let new_fid = new_graph.alloc_fill(
new_boundary,
fill.color,
fill.fill_rule,
);
// Copy gradient and image fill
new_graph.fills[new_fid.idx()].gradient_fill = fill.gradient_fill.clone();
new_graph.fills[new_fid.idx()].image_fill = fill.image_fill;
}
// Remove inside_edges from self (but NOT boundary edges — those are duplicated)
for &eid in inside_edges {
if !eid.is_none() && !self.edges[eid.idx()].deleted {
self.free_edge(eid);
}
}
// Remove inside fills from self
for &fid in inside_fills {
if !fid.is_none() && !self.fills[fid.idx()].deleted {
self.free_fill(fid);
}
}
// Free interior vertices (they have no remaining edges in self)
for &vid in &interior_vertices {
self.free_vertex(vid);
}
(new_graph, vtx_map, edge_map)
}
/// Merge another graph back into `self`, applying `transform` to all geometry.
///
/// `boundary_vertex_map` maps vertex IDs in `other` to existing vertex IDs in
/// `self` (shared boundary vertices that should reconnect rather than duplicate).
///
/// `boundary_edge_map` maps edge IDs in `other` to existing edge IDs in `self`
/// (duplicated boundary edges that should be skipped — `self` already has them).
pub fn merge_subgraph(
&mut self,
other: &VectorGraph,
transform: kurbo::Affine,
boundary_vertex_map: &HashMap<VertexId, VertexId>,
boundary_edge_map: &HashMap<EdgeId, EdgeId>,
) {
let mut vtx_map: HashMap<VertexId, VertexId> = HashMap::new();
let mut edge_map: HashMap<EdgeId, EdgeId> = HashMap::new();
// Map or allocate vertices
for (i, vertex) in other.vertices.iter().enumerate() {
let other_vid = VertexId(i as u32);
if vertex.deleted {
continue;
}
if let Some(&self_vid) = boundary_vertex_map.get(&other_vid) {
vtx_map.insert(other_vid, self_vid);
} else {
let pos = transform * vertex.position;
let new_vid = self.alloc_vertex(pos);
vtx_map.insert(other_vid, new_vid);
}
}
// Map or allocate edges
for (i, edge) in other.edges.iter().enumerate() {
let other_eid = EdgeId(i as u32);
if edge.deleted {
continue;
}
if let Some(&self_eid) = boundary_edge_map.get(&other_eid) {
edge_map.insert(other_eid, self_eid);
} else {
let new_v0 = vtx_map[&edge.vertices[0]];
let new_v1 = vtx_map[&edge.vertices[1]];
// Transform the curve control points
let curve = CubicBez::new(
transform * edge.curve.p0,
transform * edge.curve.p1,
transform * edge.curve.p2,
transform * edge.curve.p3,
);
let new_eid = self.alloc_edge(
curve,
new_v0,
new_v1,
edge.stroke_style.clone(),
edge.stroke_color.clone(),
);
edge_map.insert(other_eid, new_eid);
}
}
// Copy fills
for (_i, fill) in other.fills.iter().enumerate() {
if fill.deleted {
continue;
}
let new_boundary: Vec<(EdgeId, Direction)> = fill
.boundary
.iter()
.map(|&(eid, dir)| {
if eid.is_none() {
(EdgeId::NONE, dir)
} else if let Some(&new_eid) = edge_map.get(&eid) {
(new_eid, dir)
} else {
(EdgeId::NONE, dir)
}
})
.collect();
let new_fid = self.alloc_fill(new_boundary, fill.color, fill.fill_rule);
self.fills[new_fid.idx()].gradient_fill = fill.gradient_fill.clone();
self.fills[new_fid.idx()].image_fill = fill.image_fill;
}
}
}
// ---------------------------------------------------------------------------
@ -1566,3 +1845,65 @@ fn nearest_point_on_cubic(curve: &CubicBez, point: Point) -> (f64, f64) {
let dy = p.y - point.y;
(best_t, (dx * dx + dy * dy).sqrt())
}
/// Convert a BezPath into groups of cubic Bézier segments (one group per subpath).
pub fn bezpath_to_cubic_segments(path: &kurbo::BezPath) -> Vec<Vec<CubicBez>> {
use kurbo::PathEl;
let mut result: Vec<Vec<CubicBez>> = Vec::new();
let mut current: Vec<CubicBez> = Vec::new();
let mut subpath_start = Point::ZERO;
let mut cursor = Point::ZERO;
for el in path.elements() {
match *el {
PathEl::MoveTo(p) => {
if !current.is_empty() {
result.push(std::mem::take(&mut current));
}
subpath_start = p;
cursor = p;
}
PathEl::LineTo(p) => {
let c1 = lerp_point(cursor, p, 1.0 / 3.0);
let c2 = lerp_point(cursor, p, 2.0 / 3.0);
current.push(CubicBez::new(cursor, c1, c2, p));
cursor = p;
}
PathEl::QuadTo(p1, p2) => {
let cp1 = Point::new(
cursor.x + (2.0 / 3.0) * (p1.x - cursor.x),
cursor.y + (2.0 / 3.0) * (p1.y - cursor.y),
);
let cp2 = Point::new(
p2.x + (2.0 / 3.0) * (p1.x - p2.x),
p2.y + (2.0 / 3.0) * (p1.y - p2.y),
);
current.push(CubicBez::new(cursor, cp1, cp2, p2));
cursor = p2;
}
PathEl::CurveTo(p1, p2, p3) => {
current.push(CubicBez::new(cursor, p1, p2, p3));
cursor = p3;
}
PathEl::ClosePath => {
let dist = ((cursor.x - subpath_start.x).powi(2)
+ (cursor.y - subpath_start.y).powi(2))
.sqrt();
if dist > 1e-9 {
let c1 = lerp_point(cursor, subpath_start, 1.0 / 3.0);
let c2 = lerp_point(cursor, subpath_start, 2.0 / 3.0);
current.push(CubicBez::new(cursor, c1, c2, subpath_start));
}
cursor = subpath_start;
if !current.is_empty() {
result.push(std::mem::take(&mut current));
}
}
}
}
if !current.is_empty() {
result.push(current);
}
result
}

View File

@ -91,7 +91,7 @@ fn alloc_fill_with_boundary() {
let fid = g.alloc_fill(boundary, fill_color, FillRule::NonZero);
assert_eq!(g.fill(fid).boundary.len(), 3);
assert_eq!(g.fill(fid).color, fill_color);
assert_eq!(g.fill(fid).color, Some(fill_color));
}
// ── Adjacency ────────────────────────────────────────────────────────────

View File

@ -51,7 +51,7 @@ fn paint_bucket_fills_rectangle() {
let fid = fid.unwrap();
let fill = g.fill(fid);
assert_eq!(fill.boundary.len(), 4, "rectangle boundary should have 4 edges");
assert_eq!(fill.color, ShapeColor::rgb(255, 0, 0));
assert_eq!(fill.color, Some(ShapeColor::rgb(255, 0, 0)));
}
#[test]
@ -148,7 +148,7 @@ fn draw_line_across_fill_splits_it() {
// Both fills should inherit the original color
for (_, fill) in &live_fills {
assert_eq!(fill.color, ShapeColor::rgb(255, 0, 0));
assert_eq!(fill.color, Some(ShapeColor::rgb(255, 0, 0)));
}
}

View File

@ -8,3 +8,5 @@ mod fill;
mod editing;
#[cfg(test)]
mod gap_close;
#[cfg(test)]
mod region;

View File

@ -0,0 +1,270 @@
//! Tests for extract_subgraph and merge_subgraph (region selection support).
use super::super::*;
use kurbo::{Affine, CubicBez, Point};
use std::collections::{HashMap, HashSet};
/// Helper: create a straight-line cubic Bézier from a to b.
fn line(a: Point, b: Point) -> CubicBez {
CubicBez::new(
a,
Point::new(a.x + (b.x - a.x) / 3.0, a.y + (b.y - a.y) / 3.0),
Point::new(a.x + 2.0 * (b.x - a.x) / 3.0, a.y + 2.0 * (b.y - a.y) / 3.0),
b,
)
}
/// Build a triangle graph: 3 vertices, 3 edges, 1 fill.
/// Returns (graph, [v0,v1,v2], [e0,e1,e2], fid).
fn triangle_graph() -> (VectorGraph, [VertexId; 3], [EdgeId; 3], FillId) {
let mut g = VectorGraph::new();
let p0 = Point::new(0.0, 0.0);
let p1 = Point::new(100.0, 0.0);
let p2 = Point::new(50.0, 100.0);
let v0 = g.alloc_vertex(p0);
let v1 = g.alloc_vertex(p1);
let v2 = g.alloc_vertex(p2);
let style = StrokeStyle { width: 1.0, ..Default::default() };
let color = ShapeColor::rgb(0, 0, 0);
let e0 = g.alloc_edge(line(p0, p1), v0, v1, Some(style.clone()), Some(color));
let e1 = g.alloc_edge(line(p1, p2), v1, v2, Some(style.clone()), Some(color));
let e2 = g.alloc_edge(line(p2, p0), v2, v0, Some(style), Some(color));
let boundary = vec![
(e0, Direction::Forward),
(e1, Direction::Forward),
(e2, Direction::Forward),
];
let fid = g.alloc_fill(boundary, ShapeColor::rgb(255, 0, 0), FillRule::NonZero);
(g, [v0, v1, v2], [e0, e1, e2], fid)
}
// ── extract_subgraph ─────────────────────────────────────────────────
#[test]
fn extract_empty_region_returns_empty_graph() {
let (mut g, _, _, _) = triangle_graph();
let orig_edge_count = g.edges.iter().filter(|e| !e.deleted).count();
let (new_g, vtx_map, edge_map) = g.extract_subgraph(
&HashSet::new(),
&HashSet::new(),
&HashSet::new(),
);
// New graph should be empty
assert_eq!(new_g.edges.iter().filter(|e| !e.deleted).count(), 0);
assert_eq!(new_g.fills.iter().filter(|f| !f.deleted).count(), 0);
assert!(vtx_map.is_empty());
assert!(edge_map.is_empty());
// Original should be unchanged
assert_eq!(g.edges.iter().filter(|e| !e.deleted).count(), orig_edge_count);
}
#[test]
fn extract_single_edge_removes_from_original() {
let mut g = VectorGraph::new();
let v0 = g.alloc_vertex(Point::new(0.0, 0.0));
let v1 = g.alloc_vertex(Point::new(100.0, 0.0));
let v2 = g.alloc_vertex(Point::new(200.0, 0.0));
let style = StrokeStyle { width: 1.0, ..Default::default() };
let color = ShapeColor::rgb(0, 0, 0);
let e0 = g.alloc_edge(line(Point::new(0.0, 0.0), Point::new(100.0, 0.0)), v0, v1, Some(style.clone()), Some(color));
let e1 = g.alloc_edge(line(Point::new(100.0, 0.0), Point::new(200.0, 0.0)), v1, v2, Some(style), Some(color));
let mut inside = HashSet::new();
inside.insert(e0);
let (new_g, vtx_map, edge_map) = g.extract_subgraph(
&inside,
&HashSet::new(),
&HashSet::new(),
);
// e0 should be extracted
assert!(g.edge(e0).deleted, "extracted edge should be freed from original");
assert!(!g.edge(e1).deleted, "non-extracted edge should remain");
// v0 is interior (only connected to e0), should be freed
assert!(g.vertex(v0).deleted, "interior vertex should be freed");
// v1 is shared (connected to e1 too), should NOT be freed
assert!(!g.vertex(v1).deleted, "shared vertex should remain");
// New graph should have the edge
assert_eq!(new_g.edges.iter().filter(|e| !e.deleted).count(), 1);
assert!(edge_map.contains_key(&e0));
// New graph should have 2 vertices (v0 and v1 mapped)
assert_eq!(new_g.vertices.iter().filter(|v| !v.deleted).count(), 2);
assert!(vtx_map.contains_key(&v0));
assert!(vtx_map.contains_key(&v1));
}
#[test]
fn extract_fill_duplicates_boundary_edges() {
let (mut g, verts, edges, fid) = triangle_graph();
// Pretend e0 is a boundary edge (from region selection insert_stroke)
let mut boundary_edges = HashSet::new();
boundary_edges.insert(edges[0]);
// e1 and e2 are "inside"
let mut inside_edges = HashSet::new();
inside_edges.insert(edges[1]);
inside_edges.insert(edges[2]);
let mut inside_fills = HashSet::new();
inside_fills.insert(fid);
let (new_g, vtx_map, edge_map) = g.extract_subgraph(
&inside_edges,
&inside_fills,
&boundary_edges,
);
// Boundary edge e0 should still exist in original (duplicated, not removed)
assert!(!g.edge(edges[0]).deleted, "boundary edge should remain in original");
// Inside edges should be removed from original
assert!(g.edge(edges[1]).deleted, "inside edge should be freed from original");
assert!(g.edge(edges[2]).deleted, "inside edge should be freed from original");
// New graph should have 3 edges: e0 (boundary copy) + e1 + e2
assert_eq!(new_g.edges.iter().filter(|e| !e.deleted).count(), 3);
// New graph should have 1 fill
assert_eq!(new_g.fills.iter().filter(|f| !f.deleted).count(), 1);
// The fill's boundary in new graph should reference remapped edges
let new_fill = &new_g.fills[0];
assert_eq!(new_fill.boundary.len(), 3);
for &(eid, _) in &new_fill.boundary {
assert!(!eid.is_none(), "fill boundary should have valid edge IDs");
}
// Fill color should be preserved
assert_eq!(new_fill.color, Some(ShapeColor::rgb(255, 0, 0)));
}
// ── merge_subgraph ───────────────────────────────────────────────────
#[test]
fn merge_round_trip_identity_restores_edges() {
let mut g = VectorGraph::new();
let v0 = g.alloc_vertex(Point::new(0.0, 0.0));
let v1 = g.alloc_vertex(Point::new(100.0, 0.0));
let v2 = g.alloc_vertex(Point::new(200.0, 0.0));
let style = StrokeStyle { width: 1.0, ..Default::default() };
let color = ShapeColor::rgb(0, 0, 0);
let e0 = g.alloc_edge(line(Point::new(0.0, 0.0), Point::new(100.0, 0.0)), v0, v1, Some(style.clone()), Some(color));
let _e1 = g.alloc_edge(line(Point::new(100.0, 0.0), Point::new(200.0, 0.0)), v1, v2, Some(style), Some(color));
let mut inside = HashSet::new();
inside.insert(e0);
let (new_g, vtx_map, edge_map) = g.extract_subgraph(
&inside,
&HashSet::new(),
&HashSet::new(),
);
// Build boundary vertex map (reverse of vtx_map, only for non-deleted vertices in g)
let boundary_vtx_map: HashMap<VertexId, VertexId> = vtx_map.iter()
.filter(|(&old, _)| !g.vertex(old).deleted)
.map(|(&old, &new)| (new, old))
.collect();
// Merge back with identity transform
g.merge_subgraph(&new_g, Affine::IDENTITY, &boundary_vtx_map, &HashMap::new());
// Should have 2 non-deleted edges again
let live_edges = g.edges.iter().filter(|e| !e.deleted).count();
assert_eq!(live_edges, 2, "should have 2 edges after merge-back");
// Should have 3 vertices (v0 was freed then re-added)
let live_verts = g.vertices.iter().filter(|v| !v.deleted).count();
assert_eq!(live_verts, 3, "should have 3 vertices after merge-back");
}
#[test]
fn merge_with_translation_moves_geometry() {
let mut g = VectorGraph::new();
let v0 = g.alloc_vertex(Point::new(0.0, 0.0));
let v1 = g.alloc_vertex(Point::new(100.0, 0.0));
let style = StrokeStyle { width: 1.0, ..Default::default() };
let color = ShapeColor::rgb(0, 0, 0);
let e0 = g.alloc_edge(line(Point::new(0.0, 0.0), Point::new(100.0, 0.0)), v0, v1, Some(style), Some(color));
let mut inside = HashSet::new();
inside.insert(e0);
let (new_g, _vtx_map, _edge_map) = g.extract_subgraph(
&inside,
&HashSet::new(),
&HashSet::new(),
);
// Merge back with a translation of (50, 50)
let transform = Affine::translate((50.0, 50.0));
g.merge_subgraph(&new_g, transform, &HashMap::new(), &HashMap::new());
// The merged edge's vertices should be at (50,50) and (150,50)
let merged_edge = g.edges.iter().find(|e| !e.deleted).unwrap();
let v0_pos = g.vertices[merged_edge.vertices[0].idx()].position;
let v1_pos = g.vertices[merged_edge.vertices[1].idx()].position;
assert!((v0_pos.x - 50.0).abs() < 0.01 && (v0_pos.y - 50.0).abs() < 0.01,
"v0 should be at (50, 50), got ({}, {})", v0_pos.x, v0_pos.y);
assert!((v1_pos.x - 150.0).abs() < 0.01 && (v1_pos.y - 50.0).abs() < 0.01,
"v1 should be at (150, 50), got ({}, {})", v1_pos.x, v1_pos.y);
}
#[test]
fn extract_and_merge_fill_round_trip() {
let (mut g, _verts, edges, fid) = triangle_graph();
// Treat e0 as boundary, e1+e2 as inside, fill as inside
let mut boundary_edges = HashSet::new();
boundary_edges.insert(edges[0]);
let mut inside_edges = HashSet::new();
inside_edges.insert(edges[1]);
inside_edges.insert(edges[2]);
let mut inside_fills = HashSet::new();
inside_fills.insert(fid);
let (new_g, vtx_map, edge_map) = g.extract_subgraph(
&inside_edges,
&inside_fills,
&boundary_edges,
);
// Build maps for merge-back
let boundary_vtx_map: HashMap<VertexId, VertexId> = vtx_map.iter()
.filter(|(&old, _)| !g.vertex(old).deleted)
.map(|(&old, &new)| (new, old))
.collect();
let boundary_edge_map_for_merge: HashMap<EdgeId, EdgeId> = edge_map.iter()
.filter(|(old_eid, _)| boundary_edges.contains(old_eid))
.map(|(&old, &new)| (new, old))
.collect();
// Before merge: original has 1 edge (boundary), extracted has 3 edges + 1 fill
assert_eq!(g.edges.iter().filter(|e| !e.deleted).count(), 1);
assert_eq!(g.fills.iter().filter(|f| !f.deleted).count(), 0);
assert_eq!(new_g.edges.iter().filter(|e| !e.deleted).count(), 3);
assert_eq!(new_g.fills.iter().filter(|f| !f.deleted).count(), 1);
// Merge back
g.merge_subgraph(&new_g, Affine::IDENTITY, &boundary_vtx_map, &boundary_edge_map_for_merge);
// After merge: should have 3 edges (boundary + 2 merged) and 1 fill
assert_eq!(g.edges.iter().filter(|e| !e.deleted).count(), 3);
assert_eq!(g.fills.iter().filter(|f| !f.deleted).count(), 1);
}

View File

@ -2258,29 +2258,20 @@ impl EditorApp {
};
self.clipboard_manager.copy(content);
} else if self.selection.has_dcel_selection() {
} else if self.selection.has_geometry_selection() {
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)
// Select tool: extract faces adjacent to the selected edges.
// TODO: VectorGraph copy — extract_faces_for_edges requires Dcel;
// port to VectorGraph when clipboard is migrated.
return;
};
let dcel_json = serde_json::to_string(&subgraph).unwrap_or_default();
let svg_xml = lightningbeam_core::svg_export::dcel_to_svg(&subgraph);
// TODO: svg_export needs to be ported to VectorGraph
let svg_xml = String::new();
self.clipboard_manager.copy(
lightningbeam_core::clipboard::ClipboardContent::VectorGeometry {
dcel_json,
@ -2381,7 +2372,7 @@ impl EditorApp {
}
self.selection.clear_clip_instances();
} else if self.selection.has_dcel_selection() {
} else if self.selection.has_geometry_selection() {
let active_layer_id = match self.active_layer_id {
Some(id) => id,
None => return,
@ -2397,50 +2388,37 @@ impl EditorApp {
// 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);
}
}
}
// TODO: Region selection delete requires converting Dcel snapshot
// to VectorGraph for ModifyGraphAction. Deferred until RegionSelection
// is migrated from Dcel to VectorGraph.
eprintln!("Region selection delete: not yet ported to VectorGraph");
// region_sel is dropped; the stage pane will see region_selection == None.
}
self.selection.clear_dcel_selection();
self.selection.clear_geometry_selection();
return;
}
// Select-tool case: delete the selected edges.
let edge_ids: Vec<lightningbeam_core::dcel::EdgeId> =
let edge_ids: Vec<lightningbeam_core::vector_graph::EdgeId> =
self.selection.selected_edges().iter().copied().collect();
if !edge_ids.is_empty() {
let document = self.action_executor.document();
if let Some(layer) = document.get_layer(&active_layer_id) {
if let lightningbeam_core::layer::AnyLayer::Vector(vector_layer) = layer {
if let Some(dcel_before) = vector_layer.dcel_at_time(self.playback_time) {
let mut dcel_after = dcel_before.clone();
if let Some(graph_before) = vector_layer.graph_at_time(self.playback_time) {
let mut graph_after = graph_before.clone();
for edge_id in &edge_ids {
if !dcel_after.edge(*edge_id).deleted {
dcel_after.remove_edge(*edge_id);
if !graph_after.edge(*edge_id).deleted {
graph_after.remove_edge(*edge_id);
}
}
let action = lightningbeam_core::actions::ModifyDcelAction::new(
let action = lightningbeam_core::actions::ModifyGraphAction::new(
active_layer_id,
self.playback_time,
dcel_before.clone(),
dcel_after,
graph_before.clone(),
graph_after,
"Delete selected edges",
);
@ -2452,7 +2430,7 @@ impl EditorApp {
}
}
self.selection.clear_dcel_selection();
self.selection.clear_geometry_selection();
}
}
@ -2574,42 +2552,10 @@ impl EditorApp {
self.selection.add_clip_instance(id);
}
}
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::VectorGeometry { .. } => {
// TODO: VectorGraph paste — import_from requires Dcel;
// port when clipboard is migrated from Dcel to VectorGraph.
eprintln!("Paste vector geometry: not yet ported to VectorGraph");
}
ClipboardContent::Layers { .. } => {
// TODO: insert copied layers as siblings at the current selection point.
@ -3160,7 +3106,7 @@ impl EditorApp {
}
// Stale vertex/edge/face IDs from before the undo would
// crash selection rendering on the restored (smaller) DCEL.
self.selection.clear_dcel_selection();
self.selection.clear_geometry_selection();
}
}
MenuAction::Redo => {
@ -3196,7 +3142,7 @@ impl EditorApp {
if let Some((clip_id, notes)) = midi_update {
self.rebuild_midi_cache_entry(clip_id, &notes);
}
self.selection.clear_dcel_selection();
self.selection.clear_geometry_selection();
}
}
MenuAction::Cut => {
@ -3246,7 +3192,7 @@ impl EditorApp {
_ => {
// Existing clip instance grouping fallback (stub)
if let Some(layer_id) = self.active_layer_id {
if self.selection.has_dcel_selection() {
if self.selection.has_geometry_selection() {
// TODO: DCEL group deferred to Phase 2
} else {
let clip_ids: Vec<uuid::Uuid> = self.selection.clip_instances().to_vec();
@ -3270,7 +3216,7 @@ impl EditorApp {
}
MenuAction::ConvertToMovieClip => {
if let Some(layer_id) = self.active_layer_id {
if self.selection.has_dcel_selection() {
if self.selection.has_geometry_selection() {
// TODO: DCEL convert-to-movie-clip deferred to Phase 2
} else {
let clip_ids: Vec<uuid::Uuid> = self.selection.clip_instances().to_vec();

View File

@ -99,7 +99,7 @@ impl InfopanelPane {
let mut info = SelectionInfo::default();
let edge_count = shared.selection.selected_edges().len();
let face_count = shared.selection.selected_faces().len();
let face_count = shared.selection.selected_fills().len();
info.dcel_count = edge_count + face_count;
info.is_empty = info.dcel_count == 0;
@ -115,7 +115,7 @@ impl InfopanelPane {
if let Some(layer) = document.get_layer(&layer_id) {
if let AnyLayer::Vector(vector_layer) = layer {
if let Some(dcel) = vector_layer.dcel_at_time(*shared.playback_time) {
if let Some(graph) = vector_layer.graph_at_time(*shared.playback_time) {
// Gather stroke properties from selected edges
let mut first_stroke_color: Option<Option<ShapeColor>> = None;
let mut first_stroke_width: Option<f64> = None;
@ -123,7 +123,7 @@ impl InfopanelPane {
let mut stroke_width_mixed = false;
for &eid in shared.selection.selected_edges() {
let edge = dcel.edge(eid);
let edge = graph.edge(lightningbeam_core::vector_graph::EdgeId(eid.0));
let sc = edge.stroke_color;
let sw = edge.stroke_style.as_ref().map(|s| s.width);
@ -152,10 +152,10 @@ impl InfopanelPane {
let mut first_fill_gradient: Option<Option<ShapeGradient>> = None;
let mut fill_gradient_mixed = false;
for &fid in shared.selection.selected_faces() {
let face = dcel.face(fid);
let fc = face.fill_color;
let fg = face.gradient_fill.clone();
for &fid in shared.selection.selected_fills() {
let fill = graph.fill(fid);
let fc = fill.color;
let fg = fill.gradient_fill.clone();
match first_fill_color {
None => first_fill_color = Some(fc),
@ -777,8 +777,10 @@ impl InfopanelPane {
None => return,
};
let time = *shared.playback_time;
let face_ids: Vec<_> = shared.selection.selected_faces().iter().copied().collect();
let edge_ids: Vec<_> = shared.selection.selected_edges().iter().copied().collect();
let face_ids: Vec<lightningbeam_core::vector_graph::FillId> = shared.selection.selected_fills()
.iter().map(|fid| lightningbeam_core::vector_graph::FillId(fid.0)).collect();
let edge_ids: Vec<lightningbeam_core::vector_graph::EdgeId> = shared.selection.selected_edges()
.iter().map(|eid| lightningbeam_core::vector_graph::EdgeId(eid.0)).collect();
egui::CollapsingHeader::new("Shape")
.id_salt(("shape", path))

File diff suppressed because it is too large Load Diff