work on vector graph
This commit is contained in:
parent
8acac71d86
commit
f16e651610
|
|
@ -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(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 ────────────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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)));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -8,3 +8,5 @@ mod fill;
|
|||
mod editing;
|
||||
#[cfg(test)]
|
||||
mod gap_close;
|
||||
#[cfg(test)]
|
||||
mod region;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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(®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);
|
||||
}
|
||||
}
|
||||
}
|
||||
// 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, ¬es);
|
||||
}
|
||||
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();
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Reference in New Issue