511 lines
15 KiB
Rust
511 lines
15 KiB
Rust
//! 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.
|
|
|
|
use crate::clip::ClipInstance;
|
|
use crate::dcel::{VertexId, EdgeId, FaceId};
|
|
use crate::layer::VectorLayer;
|
|
use crate::shape::Shape;
|
|
use serde::{Deserialize, Serialize};
|
|
use uuid::Uuid;
|
|
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)
|
|
Edge(EdgeId),
|
|
/// Hit a DCEL face (fill)
|
|
Face(FaceId),
|
|
/// Hit a clip instance
|
|
ClipInstance(Uuid),
|
|
}
|
|
|
|
/// Result of a DCEL-only hit test (no clip instances)
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
pub enum DcelHitResult {
|
|
Edge(EdgeId),
|
|
Face(FaceId),
|
|
}
|
|
|
|
/// Hit test a layer at a specific point, returning edge or face hits.
|
|
///
|
|
/// Tests DCEL edges (strokes) and faces (fills) in the active keyframe.
|
|
/// Edge hits take priority over face hits.
|
|
///
|
|
/// # Arguments
|
|
///
|
|
/// * `layer` - The vector layer to test
|
|
/// * `time` - The current time (for keyframe lookup)
|
|
/// * `point` - The point to test in screen/canvas space
|
|
/// * `tolerance` - Additional tolerance in pixels for stroke hit testing
|
|
/// * `parent_transform` - Transform from parent GraphicsObject(s)
|
|
///
|
|
/// # Returns
|
|
///
|
|
/// The first DCEL 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)?;
|
|
|
|
// Transform point to local space
|
|
let local_point = parent_transform.inverse() * point;
|
|
|
|
// 1. Check edges (strokes) — priority over faces
|
|
let mut best_edge: Option<(EdgeId, f64)> = None;
|
|
for (i, edge) in dcel.edges.iter().enumerate() {
|
|
if edge.deleted {
|
|
continue;
|
|
}
|
|
// Only hit-test edges that have a visible stroke
|
|
if edge.stroke_color.is_none() && edge.stroke_style.is_none() {
|
|
continue;
|
|
}
|
|
|
|
use kurbo::ParamCurveNearest;
|
|
let nearest = edge.curve.nearest(local_point, 0.5);
|
|
let dist = nearest.distance_sq.sqrt();
|
|
|
|
let hit_radius = edge
|
|
.stroke_style
|
|
.as_ref()
|
|
.map(|s| s.width / 2.0)
|
|
.unwrap_or(0.0)
|
|
+ tolerance;
|
|
|
|
if dist < hit_radius {
|
|
if best_edge.is_none() || dist < best_edge.unwrap().1 {
|
|
best_edge = Some((EdgeId(i as u32), dist));
|
|
}
|
|
}
|
|
}
|
|
if let Some((edge_id, _)) = best_edge {
|
|
return Some(DcelHitResult::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() {
|
|
continue;
|
|
}
|
|
if face.outer_half_edge.is_none() {
|
|
continue;
|
|
}
|
|
|
|
let path = dcel.face_to_bezpath(FaceId(i as u32));
|
|
if path.winding(local_point) != 0 {
|
|
return Some(DcelHitResult::Face(FaceId(i as u32)));
|
|
}
|
|
}
|
|
|
|
None
|
|
}
|
|
|
|
/// Hit test a single shape with a given transform
|
|
///
|
|
/// Tests if a point hits the shape, considering both fill and stroke.
|
|
pub fn hit_test_shape(
|
|
shape: &Shape,
|
|
point: Point,
|
|
tolerance: f64,
|
|
transform: Affine,
|
|
) -> bool {
|
|
// Transform point to shape's local space
|
|
let inverse_transform = transform.inverse();
|
|
let local_point = inverse_transform * point;
|
|
|
|
// Check if point is inside filled path
|
|
if shape.fill_color.is_some() {
|
|
if shape.path().contains(local_point) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
// Check stroke bounds if has stroke
|
|
if let Some(stroke_style) = &shape.stroke_style {
|
|
let stroke_tolerance = stroke_style.width / 2.0 + tolerance;
|
|
|
|
let bbox = shape.path().bounding_box();
|
|
let expanded_bbox = bbox.inflate(stroke_tolerance, stroke_tolerance);
|
|
|
|
if !expanded_bbox.contains(local_point) {
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
false
|
|
}
|
|
|
|
/// Result of DCEL marquee selection
|
|
#[derive(Debug, Default)]
|
|
pub struct DcelMarqueeResult {
|
|
pub edges: Vec<EdgeId>,
|
|
pub faces: Vec<FaceId>,
|
|
}
|
|
|
|
/// Hit test DCEL 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(
|
|
layer: &VectorLayer,
|
|
time: f64,
|
|
rect: Rect,
|
|
parent_transform: Affine,
|
|
) -> DcelMarqueeResult {
|
|
let mut result = DcelMarqueeResult::default();
|
|
|
|
let dcel = match layer.dcel_at_time(time) {
|
|
Some(d) => d,
|
|
None => return result,
|
|
};
|
|
|
|
let inv = parent_transform.inverse();
|
|
let local_rect = inv.transform_rect_bbox(rect);
|
|
|
|
// Check edges: both endpoints inside rect
|
|
for (i, edge) in dcel.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;
|
|
if v1.is_none() || v2.is_none() {
|
|
continue;
|
|
}
|
|
let p1 = dcel.vertex(v1).position;
|
|
let p2 = dcel.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 {
|
|
continue;
|
|
}
|
|
if face.outer_half_edge.is_none() {
|
|
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)
|
|
});
|
|
if all_inside && !boundary.is_empty() {
|
|
result.faces.push(FaceId(i as u32));
|
|
}
|
|
}
|
|
|
|
result
|
|
}
|
|
|
|
|
|
/// Get the bounding box of a shape in screen space
|
|
pub fn get_shape_bounds(
|
|
shape: &Shape,
|
|
parent_transform: Affine,
|
|
) -> Rect {
|
|
let combined_transform = parent_transform * shape.transform.to_affine();
|
|
let local_bbox = shape.path().bounding_box();
|
|
combined_transform.transform_rect_bbox(local_bbox)
|
|
}
|
|
|
|
/// Hit test a single clip instance with a given clip bounds
|
|
pub fn hit_test_clip_instance(
|
|
clip_instance: &ClipInstance,
|
|
clip_width: f64,
|
|
clip_height: f64,
|
|
point: Point,
|
|
parent_transform: Affine,
|
|
) -> bool {
|
|
let clip_rect = Rect::new(0.0, 0.0, clip_width, clip_height);
|
|
let combined_transform = parent_transform * clip_instance.transform.to_affine();
|
|
let transformed_rect = combined_transform.transform_rect_bbox(clip_rect);
|
|
transformed_rect.contains(point)
|
|
}
|
|
|
|
/// Get the bounding box of a clip instance in screen space
|
|
pub fn get_clip_instance_bounds(
|
|
clip_instance: &ClipInstance,
|
|
clip_width: f64,
|
|
clip_height: f64,
|
|
parent_transform: Affine,
|
|
) -> Rect {
|
|
let clip_rect = Rect::new(0.0, 0.0, clip_width, clip_height);
|
|
let combined_transform = parent_transform * clip_instance.transform.to_affine();
|
|
combined_transform.transform_rect_bbox(clip_rect)
|
|
}
|
|
|
|
/// Hit test clip instances at a specific point
|
|
pub fn hit_test_clip_instances(
|
|
clip_instances: &[ClipInstance],
|
|
document: &crate::document::Document,
|
|
point: Point,
|
|
parent_transform: Affine,
|
|
timeline_time: f64,
|
|
) -> Option<Uuid> {
|
|
for clip_instance in clip_instances.iter().rev() {
|
|
// Check time bounds: skip clip instances not active at this time
|
|
let clip_duration = document.get_clip_duration(&clip_instance.clip_id).unwrap_or(0.0);
|
|
let instance_end = clip_instance.timeline_start + clip_instance.effective_duration(clip_duration);
|
|
if timeline_time < clip_instance.timeline_start || timeline_time >= instance_end {
|
|
continue;
|
|
}
|
|
|
|
let clip_time = ((timeline_time - clip_instance.timeline_start) * clip_instance.playback_speed) + clip_instance.trim_start;
|
|
|
|
let content_bounds = if let Some(vector_clip) = document.get_vector_clip(&clip_instance.clip_id) {
|
|
vector_clip.calculate_content_bounds(document, clip_time)
|
|
} else if let Some(video_clip) = document.get_video_clip(&clip_instance.clip_id) {
|
|
Rect::new(0.0, 0.0, video_clip.width, video_clip.height)
|
|
} else {
|
|
continue;
|
|
};
|
|
|
|
let clip_transform = parent_transform * clip_instance.transform.to_affine();
|
|
let clip_bbox = clip_transform.transform_rect_bbox(content_bounds);
|
|
|
|
if clip_bbox.contains(point) {
|
|
return Some(clip_instance.id);
|
|
}
|
|
}
|
|
|
|
None
|
|
}
|
|
|
|
/// Hit test clip instances within a rectangle (for marquee selection)
|
|
pub fn hit_test_clip_instances_in_rect(
|
|
clip_instances: &[ClipInstance],
|
|
document: &crate::document::Document,
|
|
rect: Rect,
|
|
parent_transform: Affine,
|
|
timeline_time: f64,
|
|
) -> Vec<Uuid> {
|
|
let mut hits = Vec::new();
|
|
|
|
for clip_instance in clip_instances {
|
|
// Check time bounds: skip clip instances not active at this time
|
|
let clip_duration = document.get_clip_duration(&clip_instance.clip_id).unwrap_or(0.0);
|
|
let instance_end = clip_instance.timeline_start + clip_instance.effective_duration(clip_duration);
|
|
if timeline_time < clip_instance.timeline_start || timeline_time >= instance_end {
|
|
continue;
|
|
}
|
|
|
|
let clip_time = ((timeline_time - clip_instance.timeline_start) * clip_instance.playback_speed) + clip_instance.trim_start;
|
|
|
|
let content_bounds = if let Some(vector_clip) = document.get_vector_clip(&clip_instance.clip_id) {
|
|
vector_clip.calculate_content_bounds(document, clip_time)
|
|
} else if let Some(video_clip) = document.get_video_clip(&clip_instance.clip_id) {
|
|
Rect::new(0.0, 0.0, video_clip.width, video_clip.height)
|
|
} else {
|
|
continue;
|
|
};
|
|
|
|
let clip_transform = parent_transform * clip_instance.transform.to_affine();
|
|
let clip_bbox = clip_transform.transform_rect_bbox(content_bounds);
|
|
|
|
if rect.intersect(clip_bbox).area() > 0.0 {
|
|
hits.push(clip_instance.id);
|
|
}
|
|
}
|
|
|
|
hits
|
|
}
|
|
|
|
/// Result of a vector editing hit test
|
|
///
|
|
/// Represents different types of hits in order of priority:
|
|
/// ControlPoint > Vertex > Curve > Fill
|
|
#[derive(Debug, Clone, Copy)]
|
|
pub enum VectorEditHit {
|
|
/// Hit a control point (BezierEdit tool only)
|
|
ControlPoint {
|
|
edge_id: EdgeId,
|
|
point_index: u8, // 1 = p1, 2 = p2
|
|
},
|
|
/// Hit a vertex (anchor point)
|
|
Vertex {
|
|
vertex_id: VertexId,
|
|
},
|
|
/// Hit a curve segment
|
|
Curve {
|
|
edge_id: EdgeId,
|
|
parameter_t: f64,
|
|
},
|
|
/// Hit shape fill
|
|
Fill {
|
|
face_id: FaceId,
|
|
},
|
|
}
|
|
|
|
/// Tolerances for vector editing hit testing (in screen pixels)
|
|
#[derive(Debug, Clone, Copy)]
|
|
pub struct EditingHitTolerance {
|
|
pub control_point: f64,
|
|
pub vertex: f64,
|
|
pub curve: f64,
|
|
pub fill: f64,
|
|
}
|
|
|
|
impl Default for EditingHitTolerance {
|
|
fn default() -> Self {
|
|
Self {
|
|
control_point: 10.0,
|
|
vertex: 15.0,
|
|
curve: 15.0,
|
|
fill: 0.0,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl EditingHitTolerance {
|
|
pub fn scaled_by_zoom(zoom: f64) -> Self {
|
|
Self {
|
|
control_point: 10.0 / zoom,
|
|
vertex: 15.0 / zoom,
|
|
curve: 15.0 / zoom,
|
|
fill: 0.0,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Hit test for vector editing with priority-based detection
|
|
pub fn hit_test_vector_editing(
|
|
layer: &VectorLayer,
|
|
time: f64,
|
|
point: Point,
|
|
tolerance: &EditingHitTolerance,
|
|
parent_transform: Affine,
|
|
show_control_points: bool,
|
|
) -> Option<VectorEditHit> {
|
|
use kurbo::ParamCurveNearest;
|
|
|
|
let dcel = layer.dcel_at_time(time)?;
|
|
|
|
// Transform point into layer-local space
|
|
let local_point = parent_transform.inverse() * point;
|
|
|
|
// Priority: ControlPoint > Vertex > Curve > Fill
|
|
|
|
// 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() {
|
|
if edge.deleted {
|
|
continue;
|
|
}
|
|
let edge_id = EdgeId(i as u32);
|
|
// Check p1
|
|
let d1 = local_point.distance(edge.curve.p1);
|
|
if d1 < tolerance.control_point {
|
|
if best_cp.is_none() || d1 < best_cp.unwrap().2 {
|
|
best_cp = Some((edge_id, 1, d1));
|
|
}
|
|
}
|
|
// Check p2
|
|
let d2 = local_point.distance(edge.curve.p2);
|
|
if d2 < tolerance.control_point {
|
|
if best_cp.is_none() || d2 < best_cp.unwrap().2 {
|
|
best_cp = Some((edge_id, 2, d2));
|
|
}
|
|
}
|
|
}
|
|
if let Some((edge_id, point_index, _)) = best_cp {
|
|
return Some(VectorEditHit::ControlPoint { edge_id, point_index });
|
|
}
|
|
}
|
|
|
|
// 2. Vertices
|
|
let mut best_vertex: Option<(VertexId, f64)> = None;
|
|
for (i, vertex) in dcel.vertices.iter().enumerate() {
|
|
if vertex.deleted {
|
|
continue;
|
|
}
|
|
let dist = local_point.distance(vertex.position);
|
|
if dist < tolerance.vertex {
|
|
if best_vertex.is_none() || dist < best_vertex.unwrap().1 {
|
|
best_vertex = Some((VertexId(i as u32), dist));
|
|
}
|
|
}
|
|
}
|
|
if let Some((vertex_id, _)) = best_vertex {
|
|
return Some(VectorEditHit::Vertex { vertex_id });
|
|
}
|
|
|
|
// 3. Curves (edges)
|
|
let mut best_curve: Option<(EdgeId, f64, f64)> = None; // (edge_id, t, dist)
|
|
for (i, edge) in dcel.edges.iter().enumerate() {
|
|
if edge.deleted {
|
|
continue;
|
|
}
|
|
let nearest = edge.curve.nearest(local_point, 0.5);
|
|
let dist = nearest.distance_sq.sqrt();
|
|
if dist < tolerance.curve {
|
|
if best_curve.is_none() || dist < best_curve.unwrap().2 {
|
|
best_curve = Some((EdgeId(i as u32), nearest.t, dist));
|
|
}
|
|
}
|
|
}
|
|
if let Some((edge_id, parameter_t, _)) = best_curve {
|
|
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 {
|
|
continue;
|
|
}
|
|
if face.fill_color.is_none() && face.image_fill.is_none() && face.gradient_fill.is_none() {
|
|
continue;
|
|
}
|
|
if face.outer_half_edge.is_none() {
|
|
continue;
|
|
}
|
|
let path = dcel.face_to_bezpath(FaceId(i as u32));
|
|
if path.winding(local_point) != 0 {
|
|
return Some(VectorEditHit::Fill { face_id: FaceId(i as u32) });
|
|
}
|
|
}
|
|
|
|
None
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::shape::ShapeColor;
|
|
use vello::kurbo::{Circle, Shape as KurboShape};
|
|
|
|
#[test]
|
|
fn test_hit_test_simple_circle() {
|
|
// TODO: DCEL - rewrite test
|
|
}
|
|
|
|
#[test]
|
|
fn test_hit_test_with_transform() {
|
|
// TODO: DCEL - rewrite test
|
|
}
|
|
|
|
#[test]
|
|
fn test_marquee_selection() {
|
|
// TODO: DCEL - rewrite test
|
|
}
|
|
}
|