From 88bfd8970dfc61bea84169cb05346684ab8049d8 Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Wed, 21 Oct 2026 13:44:42 -0700 Subject: [PATCH 1/5] Add overlays showing each path's start, direction, index, and flows --- editor/src/consts.rs | 17 ++ .../document/document_message_handler.rs | 54 +++-- .../document/overlays/utility_functions.rs | 205 +++++++++++++++++- .../document/overlays/utility_types_native.rs | 21 ++ .../document/overlays/utility_types_web.rs | 21 ++ .../src/components/widgets/NodeSection.svelte | 22 +- .../vector-types/src/vector/contour.rs | 125 ++++++++++- .../vector-types/src/vector/geometry.rs | 5 + 8 files changed, 429 insertions(+), 41 deletions(-) diff --git a/editor/src/consts.rs b/editor/src/consts.rs index e69fb644994..1dbaaca20fd 100644 --- a/editor/src/consts.rs +++ b/editor/src/consts.rs @@ -114,6 +114,23 @@ pub const SEGMENT_OVERLAY_SIZE: f64 = 10.; pub const SEGMENT_SELECTED_THICKNESS: f64 = 3.; pub const HANDLE_LENGTH_FACTOR: f64 = 0.5; +// PATH DIRECTION OVERLAYS +// Paths shorter than this on screen get no direction markers +pub const PATH_MARKERS_MIN_LENGTH: f64 = 24.; +// From an end anchor's center to the middle of the marker beside it +pub const PATH_END_MARKER_DISTANCE: f64 = 7.; +pub const PATH_END_MARKER_SIZE: f64 = 9.; +// Between arrows, stretched or squeezed slightly so a whole number of them fit evenly along each path +pub const PATH_DIRECTION_ARROW_SPACING: f64 = 15.625; +pub const PATH_DIRECTION_CHEVRON_SIZE: f64 = 8.; +// How far to the right of the path its harpoons' shafts sit, apart from the path itself +pub const PATH_DIRECTION_HARPOON_OFFSET: f64 = 3.; +// In viewport pixels, far finer than the arrows need to sit evenly +pub const PATH_DIRECTION_ARC_LENGTH_ACCURACY: f64 = 0.1; +pub const PATH_INDEX_LABEL_OFFSET: f64 = 14.; +pub const PATH_FLOW_JOIN_TICK_LENGTH: f64 = 18.; +pub const PATH_FLOW_JOIN_TICK_THICKNESS: f64 = 2.; + // GRADIENT TOOL pub const GRADIENT_MIDPOINT_DIAMOND_RADIUS: f64 = 4.; pub const GRADIENT_MIDPOINT_MIN: f64 = 0.01; diff --git a/editor/src/messages/portfolio/document/document_message_handler.rs b/editor/src/messages/portfolio/document/document_message_handler.rs index 3767a4a0b3a..4bccfa7b4c7 100644 --- a/editor/src/messages/portfolio/document/document_message_handler.rs +++ b/editor/src/messages/portfolio/document/document_message_handler.rs @@ -1578,6 +1578,9 @@ impl MessageHandler> for DocumentMes responses.add(PortfolioMessage::UpdateDocumentWidgets); } OverlaysType::Handles => visibility_settings.handles = visible, + OverlaysType::PathEnds => visibility_settings.path_ends = visible, + OverlaysType::PathIndex => visibility_settings.path_index = visible, + OverlaysType::PathDirection => visibility_settings.path_direction = visible, } responses.add(EventMessage::ToolAbort); @@ -3525,8 +3528,10 @@ impl DocumentMessageHandler { fn overlays_options(&self) -> Vec { let settings = &self.overlays_visibility_settings; - let checkbox = |name: &str, overlays_type: OverlaysType, checked: bool, disabled: bool| { + // Without a description, a tooltip would only repeat the label beside the checkbox + let checkbox = |name: &str, overlays_type: OverlaysType, checked: bool, disabled: bool, description: &str| { let checkbox_id = CheckboxId::new(); + let tooltip_label = if description.is_empty() { "" } else { name }; vec![ CheckboxInput::new(checked) .disabled(disabled) @@ -3537,31 +3542,50 @@ impl DocumentMessageHandler { } .into() }) + .tooltip_label(tooltip_label) + .tooltip_description(description) .for_label(checkbox_id) .widget_instance(), - TextLabel::new(name).disabled(disabled).for_checkbox(checkbox_id).widget_instance(), + TextLabel::new(name) + .disabled(disabled) + .tooltip_label(tooltip_label) + .tooltip_description(description) + .for_checkbox(checkbox_id) + .widget_instance(), ] }; + let path_ends_description = " + Marks where each path starts and ends, and where its path flows join.\n\ + \n\ + Chevron: the start, pointing the way the path sets out.\n\ + Bar: the end, just before its last anchor. On a closed path, this is where it arrives back at its start.\n\ + Thick tick: an anchor where the path passes from one path flow into the next. + " + .trim(); + let left = [ vec![TextLabel::new("General").widget_instance()], - checkbox("Artboard Name", OverlaysType::ArtboardName, settings.artboard_name, false), - checkbox("G/R/S Measurement", OverlaysType::TransformMeasurement, settings.transform_measurement, false), + checkbox("Artboard Name", OverlaysType::ArtboardName, settings.artboard_name, false, ""), + checkbox("G/R/S Measurement", OverlaysType::TransformMeasurement, settings.transform_measurement, false, ""), vec![TextLabel::new("Pen & Path Tools").widget_instance()], - checkbox("Path", OverlaysType::Path, settings.path, false), - checkbox("Anchors", OverlaysType::Anchors, settings.anchors, false), - checkbox("Handles", OverlaysType::Handles, settings.handles, !settings.anchors), + checkbox("Path", OverlaysType::Path, settings.path, false, ""), + checkbox("Path Ends", OverlaysType::PathEnds, settings.path_ends, !settings.anchors, path_ends_description), + checkbox("Anchors", OverlaysType::Anchors, settings.anchors, false, ""), + checkbox("Handles", OverlaysType::Handles, settings.handles, !settings.anchors, ""), + checkbox("Path Index", OverlaysType::PathIndex, settings.path_index, !settings.anchors, ""), + checkbox("Path Direction", OverlaysType::PathDirection, settings.path_direction, !settings.anchors, ""), ]; let right = [ vec![TextLabel::new("Select Tool").widget_instance()], - checkbox("Quick Measurement", OverlaysType::QuickMeasurement, settings.quick_measurement, false), - checkbox("Transform Cage", OverlaysType::TransformCage, settings.transform_cage, false), - checkbox("Transform Dial", OverlaysType::CompassRose, settings.compass_rose, false), - checkbox("Transform Pivot", OverlaysType::Pivot, settings.pivot, false), - checkbox("Transform Origin", OverlaysType::Origin, settings.origin, false), - checkbox("Hover Outline", OverlaysType::HoverOutline, settings.hover_outline, false), - checkbox("Selection Outline", OverlaysType::SelectionOutline, settings.selection_outline, false), - checkbox("Layer Origin", OverlaysType::LayerOriginCross, settings.layer_origin_cross, false), + checkbox("Quick Measurement", OverlaysType::QuickMeasurement, settings.quick_measurement, false, ""), + checkbox("Transform Cage", OverlaysType::TransformCage, settings.transform_cage, false, ""), + checkbox("Transform Dial", OverlaysType::CompassRose, settings.compass_rose, false, ""), + checkbox("Transform Pivot", OverlaysType::Pivot, settings.pivot, false, ""), + checkbox("Transform Origin", OverlaysType::Origin, settings.origin, false, ""), + checkbox("Hover Outline", OverlaysType::HoverOutline, settings.hover_outline, false, ""), + checkbox("Selection Outline", OverlaysType::SelectionOutline, settings.selection_outline, false, ""), + checkbox("Layer Origin", OverlaysType::LayerOriginCross, settings.layer_origin_cross, false, ""), ]; vec![ diff --git a/editor/src/messages/portfolio/document/overlays/utility_functions.rs b/editor/src/messages/portfolio/document/overlays/utility_functions.rs index 76bc5f2823c..e76ef095c43 100644 --- a/editor/src/messages/portfolio/document/overlays/utility_functions.rs +++ b/editor/src/messages/portfolio/document/overlays/utility_functions.rs @@ -1,5 +1,8 @@ -use super::utility_types::{DrawHandles, OverlayContext}; -use crate::consts::{HIDE_HANDLE_DISTANCE, SNAP_POINT_TOLERANCE}; +use super::utility_types::{DrawHandles, OverlayContext, Pivot}; +use crate::consts::{ + COLOR_OVERLAY_BLUE, HIDE_HANDLE_DISTANCE, PATH_DIRECTION_ARC_LENGTH_ACCURACY, PATH_DIRECTION_ARROW_SPACING, PATH_DIRECTION_CHEVRON_SIZE, PATH_DIRECTION_HARPOON_OFFSET, PATH_END_MARKER_DISTANCE, + PATH_END_MARKER_SIZE, PATH_FLOW_JOIN_TICK_LENGTH, PATH_FLOW_JOIN_TICK_THICKNESS, PATH_INDEX_LABEL_OFFSET, PATH_MARKERS_MIN_LENGTH, SNAP_POINT_TOLERANCE, +}; use crate::messages::portfolio::document::utility_types::document_metadata::LayerNodeIdentifier; use crate::messages::portfolio::document::utility_types::network_interface::NodeNetworkInterface; pub use crate::messages::portfolio::document::utility_types::text_metrics::text_width; @@ -7,9 +10,10 @@ use crate::messages::tool::common_functionality::shape_editor::{SelectedLayerSta use crate::messages::tool::common_functionality::utility_functions::closest_open_path_endpoint; use crate::messages::tool::tool_messages::tool_prelude::DocumentMessageHandler; use glam::{DAffine2, DVec2}; -use graphene_std::vector::misc::{BezierHandles, ManipulatorPointId, point_to_dvec2, segment_to_handles}; +use graphene_std::vector::algorithms::util::pathseg_tangent; +use graphene_std::vector::misc::{BezierHandles, ManipulatorPointId, PathSegPoints, pathseg_points, point_to_dvec2, segment_to_handles}; use graphene_std::vector::{PointId, SegmentId, VectorGeometry}; -use kurbo::{Affine, ParamCurve, PathSeg}; +use kurbo::{Affine, ParamCurve, ParamCurveArclen, ParamCurveArea, PathSeg, Rect, Shape}; use std::collections::HashMap; #[cfg(target_family = "wasm")] use wasm_bindgen::JsCast; @@ -148,6 +152,8 @@ pub fn path_overlays(document: &DocumentMessageHandler, draw_handles: DrawHandle } } + path_direction_overlays(&vector, transform, false, overlay_context); + let is_selected = |point: ManipulatorPointId| selected_shape_state.is_point_selected(point); if display_handles { @@ -222,6 +228,8 @@ pub fn open_path_endpoint_overlays(document: &DocumentMessageHandler, shape_edit let Some(vector) = document.network_interface.compute_modified_vector(layer) else { continue }; let transform = document.metadata().transform_to_viewport_if_feeds(layer, &document.network_interface); + path_direction_overlays(&vector, transform, true, overlay_context); + for id in vector.anchor_endpoints() { if hovered.is_some_and(|(hovered_layer, hovered_id, _)| hovered_layer == layer && hovered_id == id) { continue; @@ -239,6 +247,195 @@ pub fn open_path_endpoint_overlays(document: &DocumentMessageHandler, shape_edit } } +/// Marks how each path of a layer runs, as the overlay settings choose: chevrons at its start and end and along it, its index, and ticks where its path flows join. +/// With `open_paths_only`, closed paths go unmarked, for tools that only show the anchors at open ends. +fn path_direction_overlays(vector: &VectorGeometry, transform: DAffine2, open_paths_only: bool, overlay_context: &mut OverlayContext) { + let settings = overlay_context.visibility_settings; + let (show_ends, show_index, show_direction) = (settings.path_ends(), settings.path_index(), settings.path_direction()); + if !(show_ends || show_index || show_direction) { + return; + } + + let to_viewport = Affine::new(transform.to_cols_array()); + let viewport_size: DVec2 = overlay_context.viewport.size().into(); + let arrow_view = Rect::new(0., 0., viewport_size.x, viewport_size.y).inflate(PATH_DIRECTION_CHEVRON_SIZE, PATH_DIRECTION_CHEVRON_SIZE); + + // Enumerated before filtering so each index is the one Separate Paths gives that path + for (index, contour) in vector.oriented_contours().into_iter().enumerate() { + if open_paths_only && contour.closed { + continue; + } + + let segments: Vec = contour.segments.iter().map(|&segment| to_viewport * segment).collect(); + if segments.iter().map(|&segment| control_polygon_length(segment)).sum::() < PATH_MARKERS_MIN_LENGTH { + continue; + } + // Zero-length segments point nowhere, so the markers at each end go on the outermost segments that do + let has_direction = |segment: &&PathSeg| end_directions(**segment).is_some(); + let Some(&first) = segments.iter().find(has_direction) else { continue }; + let Some(&last) = segments.iter().rfind(has_direction) else { continue }; + let Some((departure, _)) = end_directions(first) else { continue }; + let start = point_to_dvec2(first.start()); + + // Arrows spread evenly over the stretch clear of the end markers, as harpoons barbed on the right of travel so their side shows the winding direction + if show_direction { + // O(segments + arrows in view) arc lengths per frame + let lengths: Vec = segments.iter().map(|segment| segment.arclen(PATH_DIRECTION_ARC_LENGTH_ACCURACY)).collect(); + let total_length: f64 = lengths.iter().sum(); + + let end_clearance = if show_ends { PATH_END_MARKER_DISTANCE + PATH_END_MARKER_SIZE } else { 0. }; + let (arrows_start, arrows_end) = (end_clearance, total_length - end_clearance); + let arrows_length = arrows_end - arrows_start; + + if arrows_length.is_finite() && arrows_length >= PATH_DIRECTION_ARROW_SPACING / 2. { + let arrow_count = (arrows_length / PATH_DIRECTION_ARROW_SPACING).round().max(1.); + let spacing = arrows_length / arrow_count; + + let mut pieces_in_view = Vec::new(); + let mut walked = 0.; + for (&segment, &length) in segments.iter().zip(&lengths) { + collect_pieces_in_view(segment, walked, length, arrow_view, spacing, &mut pieces_in_view); + walked += length; + } + + for (piece, piece_start, piece_length) in pieces_in_view { + // The first of the arrows, centered in each interval of `spacing` along their stretch, that falls on this piece + let mut next_arrow = arrows_start + (((piece_start - arrows_start) / spacing - 0.5).ceil().max(0.) + 0.5) * spacing; + + while next_arrow < (piece_start + piece_length).min(arrows_end) { + // With the viewport's Y axis pointing down, the perpendicular is on the right of travel + if let Some((point, direction)) = point_along(piece, next_arrow - piece_start) { + draw_chevron(overlay_context, point, direction, PATH_DIRECTION_CHEVRON_SIZE, Some(direction.perp())); + } + next_arrow += spacing; + } + } + } + } + + // A chevron just after the start anchor and a bar just before the end, which on a closed path is where it arrives back at its start + if show_ends { + if let Some((point, direction)) = point_along(first, PATH_END_MARKER_DISTANCE) { + draw_chevron(overlay_context, point, direction, PATH_END_MARKER_SIZE, None); + } + + if let Some((point, direction)) = point_along(last.reverse(), PATH_END_MARKER_DISTANCE) { + let across = direction.perp() * PATH_END_MARKER_SIZE / 2.; + overlay_context.line(point - across, point + across, None, None); + } + + // A tick across the path at each anchor where it crosses from one path flow into another + for &join in &contour.flow_joins { + let arriving = join.checked_sub(1).map_or(segments.last(), |previous| segments.get(previous)); + let (Some(&arriving), Some(&departing)) = (arriving, segments.get(join)) else { continue }; + let (Some((_, into_join)), Some((out_of_join, _))) = (end_directions(arriving), end_directions(departing)) else { + continue; + }; + + let along = direction_or(into_join + out_of_join, out_of_join); + let across = along.perp() * PATH_FLOW_JOIN_TICK_LENGTH / 2.; + let point = point_to_dvec2(departing.start()); + overlay_context.line(point - across, point + across, None, Some(PATH_FLOW_JOIN_TICK_THICKNESS)); + } + } + + // The index sits off the start, opposite the segments leaving it, where it covers the least of the path + if show_index { + let arrival = end_directions(last).filter(|_| contour.closed).map(|(_, arrival)| arrival); + let spokes = arrival.map_or(departure, |arrival| departure - arrival); + + // Where the path runs straight through its start, the label goes outside the loop + let clockwise = segments.iter().map(|segment| segment.signed_area()).sum::() > 0.; + let outside = if clockwise { -departure.perp() } else { departure.perp() }; + let away = direction_or(-spokes, outside); + let label_transform = DAffine2::from_translation(start + away * PATH_INDEX_LABEL_OFFSET); + overlay_context.text(&index.to_string(), COLOR_OVERLAY_BLUE, None, label_transform, 0., [Pivot::Middle, Pivot::Middle]); + } + } +} + +/// Collects the parts of a segment that `view` overlaps, each with where it starts along the path and its length, halving any part that crosses the edge of `view` until it's shorter than `min_length`. +/// Costs O(log(length / min_length)) arc lengths per crossing of that edge. +fn collect_pieces_in_view(piece: PathSeg, start: f64, length: f64, view: Rect, min_length: f64, pieces: &mut Vec<(PathSeg, f64, f64)>) { + let bounds = piece.bounding_box(); + if !bounds.overlaps(view) { + return; + } + + if length < min_length || view.contains_rect(bounds) { + pieces.push((piece, start, length)); + return; + } + + let (first_half, second_half) = piece.subdivide(); + let first_length = first_half.arclen(PATH_DIRECTION_ARC_LENGTH_ACCURACY); + collect_pieces_in_view(first_half, start, first_length, view, min_length, pieces); + collect_pieces_in_view(second_half, start + first_length, length - first_length, view, min_length, pieces); +} + +/// Draws a right-angled `>` chevron `size` across, centered on `center` and pointing in `direction`. +/// Given a `barb_side`, it instead draws a one-sided `|\` harpoon: a shaft set off from the path toward that side, with a barb reaching further in. +fn draw_chevron(overlay_context: &mut OverlayContext, center: DVec2, direction: DVec2, size: f64, barb_side: Option) { + let tip = center + direction * size / 4.; + let back = center - direction * size / 4.; + + match barb_side { + Some(side) => { + let (tip, back) = (tip + side * PATH_DIRECTION_HARPOON_OFFSET, back + side * PATH_DIRECTION_HARPOON_OFFSET); + let barb_end = back + side * size / 2.; + + // The shaft matches the barb's length + let shaft_end = tip - direction * tip.distance(barb_end); + + overlay_context.line(shaft_end, tip, None, None); + overlay_context.line(barb_end, tip, None, None); + } + None => { + let spread = direction.perp() * size / 2.; + overlay_context.line(back + spread, tip, None, None); + overlay_context.line(back - spread, tip, None, None); + } + } +} + +/// The point a distance along a segment from its start, or its end if the segment is shorter, with the unit direction the segment runs there. +fn point_along(segment: PathSeg, distance: f64) -> Option<(DVec2, DVec2)> { + let t = segment.inv_arclen(distance, PATH_DIRECTION_ARC_LENGTH_ACCURACY); + let direction = pathseg_tangent(segment, t).try_normalize().or_else(|| end_directions(segment).map(|(departure, _)| departure))?; + + Some((point_to_dvec2(segment.eval(t)), direction)) +} + +/// The unit directions a segment sets out in and arrives from, read from its control points so a handle resting on its anchor still gives one. +fn end_directions(segment: PathSeg) -> Option<(DVec2, DVec2)> { + let PathSegPoints { p0, p1, p2, p3 } = pathseg_points(segment); + let distinct = |point: &DVec2, end: DVec2| point.distance_squared(end) > f64::EPSILON; + + let departure = [p1, p2, Some(p3)].into_iter().flatten().find(|point| distinct(point, p0))? - p0; + let arrival = p3 - [p2, p1, Some(p0)].into_iter().flatten().find(|point| distinct(point, p3))?; + + Some((departure.try_normalize()?, arrival.try_normalize()?)) +} + +/// The unit direction of a vector, or the fallback when the vector is too short to point anywhere reliably. +fn direction_or(vector: DVec2, fallback: DVec2) -> DVec2 { + if vector.length_squared() > 1e-6 { vector.normalize() } else { fallback } +} + +/// The length of a segment's control polygon, which is never shorter than the segment itself. +fn control_polygon_length(segment: PathSeg) -> f64 { + let PathSegPoints { p0, p1, p2, p3 } = pathseg_points(segment); + + let mut previous = p0; + let mut length = 0.; + for point in [p1, p2, Some(p3)].into_iter().flatten() { + length += previous.distance(point); + previous = point; + } + + length +} + pub fn hex_to_rgba_u8(hex: &str) -> [u8; 4] { let hex = hex.trim().trim_start_matches('#'); if hex.len() != 6 && hex.len() != 8 { diff --git a/editor/src/messages/portfolio/document/overlays/utility_types_native.rs b/editor/src/messages/portfolio/document/overlays/utility_types_native.rs index d4dcfdf9b95..78c88c51ecb 100644 --- a/editor/src/messages/portfolio/document/overlays/utility_types_native.rs +++ b/editor/src/messages/portfolio/document/overlays/utility_types_native.rs @@ -60,6 +60,9 @@ pub enum OverlaysType { Path, Anchors, Handles, + PathEnds, + PathIndex, + PathDirection, } // TODO Remove duplicated definition of this in `utility_types_web.rs` @@ -81,6 +84,9 @@ pub struct OverlaysVisibilitySettings { pub path: bool, pub anchors: bool, pub handles: bool, + pub path_ends: bool, + pub path_index: bool, + pub path_direction: bool, } // TODO Remove duplicated definition of this in `utility_types_web.rs` @@ -101,6 +107,9 @@ impl Default for OverlaysVisibilitySettings { path: true, anchors: true, handles: true, + path_ends: true, + path_index: false, + path_direction: false, } } } @@ -162,6 +171,18 @@ impl OverlaysVisibilitySettings { pub fn handles(&self) -> bool { self.all && self.anchors && self.handles } + + pub fn path_ends(&self) -> bool { + self.all && self.anchors && self.path_ends + } + + pub fn path_index(&self) -> bool { + self.all && self.anchors && self.path_index + } + + pub fn path_direction(&self) -> bool { + self.all && self.anchors && self.path_direction + } } #[cfg_attr(feature = "wasm", derive(tsify::Tsify))] diff --git a/editor/src/messages/portfolio/document/overlays/utility_types_web.rs b/editor/src/messages/portfolio/document/overlays/utility_types_web.rs index 54195133852..c1533fedafa 100644 --- a/editor/src/messages/portfolio/document/overlays/utility_types_web.rs +++ b/editor/src/messages/portfolio/document/overlays/utility_types_web.rs @@ -51,6 +51,9 @@ pub enum OverlaysType { Path, Anchors, Handles, + PathEnds, + PathIndex, + PathDirection, } #[cfg_attr(feature = "wasm", derive(tsify::Tsify))] @@ -71,6 +74,9 @@ pub struct OverlaysVisibilitySettings { pub path: bool, pub anchors: bool, pub handles: bool, + pub path_ends: bool, + pub path_index: bool, + pub path_direction: bool, } impl Default for OverlaysVisibilitySettings { @@ -90,6 +96,9 @@ impl Default for OverlaysVisibilitySettings { path: true, anchors: true, handles: true, + path_ends: true, + path_index: false, + path_direction: false, } } } @@ -150,6 +159,18 @@ impl OverlaysVisibilitySettings { pub fn handles(&self) -> bool { self.all && self.anchors && self.handles } + + pub fn path_ends(&self) -> bool { + self.all && self.anchors && self.path_ends + } + + pub fn path_index(&self) -> bool { + self.all && self.anchors && self.path_index + } + + pub fn path_direction(&self) -> bool { + self.all && self.anchors && self.path_direction + } } #[cfg_attr(feature = "wasm", derive(tsify::Tsify))] diff --git a/frontend/src/components/widgets/NodeSection.svelte b/frontend/src/components/widgets/NodeSection.svelte index ea9442f6418..514f39ae010 100644 --- a/frontend/src/components/widgets/NodeSection.svelte +++ b/frontend/src/components/widgets/NodeSection.svelte @@ -90,6 +90,17 @@
{widgetData.name} + { + editor.toggleNodeVisibilityInspector(widgetData.id); + e?.stopPropagation(); + }} + class={widgetData.visible ? "show-only-on-hover" : ""} + /> - { - editor.toggleNodeVisibilityInspector(widgetData.id); - e?.stopPropagation(); - }} - class={widgetData.visible ? "show-only-on-hover" : ""} - /> {#if reorderable}
undefined} role="presentation">
{/if} diff --git a/node-graph/libraries/vector-types/src/vector/contour.rs b/node-graph/libraries/vector-types/src/vector/contour.rs index 7af92e06b4a..bea0338beb2 100644 --- a/node-graph/libraries/vector-types/src/vector/contour.rs +++ b/node-graph/libraries/vector-types/src/vector/contour.rs @@ -1,8 +1,8 @@ use super::misc::dvec2_to_point; -use super::path::{CurveType, Path, append_segment, curve_types_of}; +use super::path::{CurveType, Path, append_segment, curve_types_of, path_flows}; use core_types::list::List; use glam::DVec2; -use kurbo::BezPath; +use kurbo::{BezPath, PathSeg}; /// Paths stitched end to end into one outline, as [`contours`] describes. /// A closed contour arrives back at the point it left from. @@ -183,19 +183,24 @@ fn contours_with(point_count: usize, paths: &List, curve_types: &[CurveTyp found } +/// The segments a contour runs along in order, each beside the stored path it comes from. +fn contour_segments<'a>(pool: &'a List, paths: &'a List, curve_types: &'a [CurveType], contour: &'a Contour) -> impl Iterator + 'a { + contour.parts.iter().flat_map(move |&part| { + let path = paths.element(part as usize); + let curve_type = curve_types.get(part as usize).copied().unwrap_or_default(); + let count = path.map_or(0, |path| path.segment_count(curve_type)); + + (0..count).filter_map(move |span| Some((part, path?.segment(pool, curve_type, span)?))) + }) +} + /// Emits one contour as a bezpath, or `None` when its entry point is not in the pool. fn contour_to_bezpath(pool: &List, paths: &List, curve_types: &[CurveType], contour: &Contour) -> Option { let mut bezpath = BezPath::new(); bezpath.move_to(dvec2_to_point(*pool.element(contour.entry as usize)?)); - for &part in &contour.parts { - let path = paths.element(part as usize)?; - let curve_type = *curve_types.get(part as usize)?; - - for span in 0..path.segment_count(curve_type) { - let Some(segment) = path.segment(pool, curve_type, span) else { continue }; - append_segment(&mut bezpath, segment); - } + for (_, segment) in contour_segments(pool, paths, curve_types, contour) { + append_segment(&mut bezpath, segment); } if contour.closed { @@ -214,12 +219,67 @@ pub fn contour_bezpaths(pool: &List, paths: &List) -> Vec .collect() } +/// One contour's segments in the order and direction it runs along them. +#[derive(Clone, Debug, PartialEq)] +pub struct OrientedContour { + pub segments: Vec, + /// Where in `segments` the contour crosses from one path flow into another. A closed contour lists 0 when it arrives back at its start from another flow. + pub flow_joins: Vec, + pub closed: bool, +} + +/// Every contour as the segments it runs along, in the order [`contours`] finds them, which is also the order Separate Paths splits them into. +pub fn oriented_contours(pool: &List, paths: &List) -> Vec { + let curve_types = curve_types(paths); + + // The path flow each stored path belongs to + let mut flow_of_path = vec![0; paths.len()]; + for (flow, range) in path_flows(paths).into_iter().enumerate() { + if let Some(members) = flow_of_path.get_mut(range) { + members.fill(flow); + } + } + let flow_of = |path: u32| flow_of_path.get(path as usize).copied().unwrap_or_default(); + + contours_with(pool.len(), paths, &curve_types) + .iter() + .map(|contour| { + let mut segments = Vec::new(); + let mut flow_joins = Vec::new(); + let mut previous_flow = None; + for (path, segment) in contour_segments(pool, paths, &curve_types, contour) { + let flow = flow_of(path); + if previous_flow.is_some_and(|previous| previous != flow) { + flow_joins.push(segments.len()); + } + previous_flow = Some(flow); + + segments.push(segment); + } + + if contour.closed + && let (Some(&first), Some(&last)) = (contour.parts.first(), contour.parts.last()) + && flow_of(first) != flow_of(last) + { + flow_joins.insert(0, 0); + } + + OrientedContour { + segments, + flow_joins, + closed: contour.closed, + } + }) + .collect() +} + #[cfg(test)] mod tests { + use super::super::misc::point_to_dvec2; use super::super::path::{pool_and_paths_from_bezpath, push_path}; use super::*; use core_types::list::Item; - use kurbo::PathEl; + use kurbo::{ParamCurve, PathEl}; fn bezpath_of(build: impl FnOnce(&mut BezPath)) -> BezPath { let mut bezpath = BezPath::new(); @@ -427,4 +487,47 @@ mod tests { assert_eq!(stitched(3, &paths), [(vec![1, 2], false)]); assert_eq!(contour_bezpaths(&pool, &paths).len(), 1); } + + // A subpath split across curve types is one path flow, so it runs as one contour with no flow joins + #[test] + fn one_flow_in_several_curve_types_has_no_joins() { + let source = bezpath_of(|bezpath| { + bezpath.move_to((0., 0.)); + bezpath.line_to((10., 0.)); + bezpath.curve_to((15., 5.), (15., 10.), (10., 15.)); + bezpath.close_path(); + }); + let (pool, paths) = pool_and_paths_from_bezpath(&source); + + let oriented = oriented_contours(&pool, &paths); + assert_eq!(oriented.len(), 1); + assert_eq!(oriented[0].segments.len(), 3); + assert!(oriented[0].closed); + assert!(oriented[0].flow_joins.is_empty()); + } + + // Two halves welded at both ends join at each weld, with the contour running along their segments in order + #[test] + fn welded_flows_join_where_they_meet() { + let (pool, paths) = linear(4, &[&[0, 1, 2], &[2, 3, 0]]); + + let oriented = oriented_contours(&pool, &paths); + assert_eq!(oriented.len(), 1); + assert_eq!(oriented[0].flow_joins, [0, 2]); + + let starts: Vec = oriented[0].segments.iter().map(|segment| point_to_dvec2(segment.start())).collect(); + let pool_positions: Vec = [0, 1, 2, 3].iter().map(|&index| *pool.element(index).expect("the point is in the pool")).collect(); + assert_eq!(starts, pool_positions); + } + + // An open contour never joins at its start, only where a welded path carries it on + #[test] + fn an_open_contour_joins_only_inside() { + let (pool, paths) = linear(4, &[&[0, 1, 2], &[3, 0]]); + + let oriented = oriented_contours(&pool, &paths); + assert_eq!(oriented.len(), 1); + assert!(!oriented[0].closed); + assert_eq!(oriented[0].flow_joins, [1]); + } } diff --git a/node-graph/libraries/vector-types/src/vector/geometry.rs b/node-graph/libraries/vector-types/src/vector/geometry.rs index 0c35eb8f289..fd1f1fc5471 100644 --- a/node-graph/libraries/vector-types/src/vector/geometry.rs +++ b/node-graph/libraries/vector-types/src/vector/geometry.rs @@ -1,3 +1,4 @@ +use super::contour::{OrientedContour, oriented_contours}; use super::geometry_cache::SetVectorPaths; use super::misc::{HandleId, ManipulatorGroup, ManipulatorPointId}; use super::path::{Path, paths_from_item_attributes}; @@ -139,6 +140,10 @@ impl VectorGeometry { self.vector.stroke_bezpath_iter(&self.paths) } + pub fn oriented_contours(&self) -> Vec { + oriented_contours(&self.vector.0, &self.paths) + } + pub fn stroke_manipulator_groups(&self) -> impl Iterator, bool)> { self.vector.stroke_manipulator_groups(&self.paths) } From a29447f2e63acb3e83d79232b5eb2b535fe0968f Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Wed, 21 Oct 2026 20:36:02 -0700 Subject: [PATCH 2/5] Fix selection outlines closing the open paths of filled layers --- .../libraries/rendering/src/renderer.rs | 22 ++++++++++++------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/node-graph/libraries/rendering/src/renderer.rs b/node-graph/libraries/rendering/src/renderer.rs index ca33cffdc7a..d8bc4b708db 100644 --- a/node-graph/libraries/rendering/src/renderer.rs +++ b/node-graph/libraries/rendering/src/renderer.rs @@ -2118,18 +2118,23 @@ fn collect_vector_items_metadata<'a>( let source_paths = item.attribute::>(ATTR_PATHS).unwrap_or_else(|| no_paths()); let item_relative_transform = reference_inverse * transform; - // Outlines always use source geometry so the visual outline reflects actual letterforms + // Outlines trace the source geometry as drawn (actual letterforms, open paths left open) let mut outlines_unwrapped = Vec::new(); - extend_targets_from_vector(&mut outlines_unwrapped, appearance, item, item_relative_transform); + extend_targets_from_vector(&mut outlines_unwrapped, appearance, item, item_relative_transform, false); let outlines: Vec> = outlines_unwrapped.into_iter().map(Arc::new).collect(); - // Use click-target override if the item provides one (e.g. 'Text' node's per-glyph bboxes), otherwise the click targets are the outlines + // Use click-target override if the item provides one (e.g. 'Text' node's per-glyph bboxes), otherwise the click targets are the outlines, closed where filled let click_targets = match item.attribute::(ATTR_EDITOR_CLICK_TARGET) { Some(override_path) => { let mut click_targets_unwrapped = Vec::new(); extend_targets_from_bezpaths(&mut click_targets_unwrapped, appearance, &split_bezpath_contours(override_path), item_relative_transform); click_targets_unwrapped.into_iter().map(Arc::new).collect() } + None if is_filled_for_hit_testing(appearance) => { + let mut click_targets_unwrapped = Vec::new(); + extend_targets_from_vector(&mut click_targets_unwrapped, appearance, item, item_relative_transform, true); + click_targets_unwrapped.into_iter().map(Arc::new).collect() + } None => outlines.clone(), }; @@ -2192,16 +2197,16 @@ fn add_vector_item_click_targets(item: ItemRef<'_, Vector>, click_targets: &mut // Use click-target override geometry if the item provides one (e.g. 'Text' node's per-glyph bounding boxes) match item.attribute::(ATTR_EDITOR_CLICK_TARGET) { Some(override_path) => extend_targets_from_bezpaths(click_targets, appearance, &split_bezpath_contours(override_path), transform), - None => extend_targets_from_vector(click_targets, appearance, item, transform), + None => extend_targets_from_vector(click_targets, appearance, item, transform, true), } } -/// Like [`add_vector_item_click_targets`] but on source geometry only, ignoring `editor:click_target`, so outlines reflect actual letterforms. +/// Like [`add_vector_item_click_targets`] but tracing the source geometry as drawn, without `editor:click_target` or closing open paths. fn add_vector_item_outline_targets(item: ItemRef<'_, Vector>, outlines: &mut Vec, inherited_appearance: Option<&Appearance>) { let transform: DAffine2 = item.attribute_cloned_or_default(ATTR_TRANSFORM); let appearance = Appearance::cascade(item.attribute::(ATTR_APPEARANCE), inherited_appearance); - extend_targets_from_vector(outlines, appearance, item, transform); + extend_targets_from_vector(outlines, appearance, item, transform, false); } impl Render for List { @@ -2271,8 +2276,9 @@ impl Render for List { /// Append one multi-contour target (nonzero fill rule, so holes like the inside of an "O" work correctly) /// plus one `FreePoint` per disconnected anchor, placed by the transform. /// The target's path is shared through the item's geometry cache with every copy drawing the same geometry. -fn extend_targets_from_vector(targets: &mut Vec, appearance: Option<&Appearance>, item: ItemRef<'_, Vector>, transform: DAffine2) { - let target_path = item.target_path(is_filled_for_hit_testing(appearance)); +fn extend_targets_from_vector(targets: &mut Vec, appearance: Option<&Appearance>, item: ItemRef<'_, Vector>, transform: DAffine2, close_filled_contours: bool) { + // Click targets close a filled layer's open contours so a click anywhere on the fill hits it + let target_path = item.target_path(close_filled_contours && is_filled_for_hit_testing(appearance)); push_path_target(targets, appearance, target_path, &item.contours(), transform); let Some(geometry) = item.element() else { return }; From 10e940e2413108df6f660caef5a32cd4d0b235f7 Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Wed, 21 Oct 2026 20:51:24 -0700 Subject: [PATCH 3/5] Fix ghost outlines drawing free points as regular blue anchors --- .../portfolio/document/overlays/utility_types_native.rs | 2 +- .../messages/portfolio/document/overlays/utility_types_web.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/editor/src/messages/portfolio/document/overlays/utility_types_native.rs b/editor/src/messages/portfolio/document/overlays/utility_types_native.rs index 78c88c51ecb..0bdfc7172b6 100644 --- a/editor/src/messages/portfolio/document/overlays/utility_types_native.rs +++ b/editor/src/messages/portfolio/document/overlays/utility_types_native.rs @@ -1032,7 +1032,7 @@ impl OverlayContextInternal { for target_type in target_types { match target_type.borrow() { ClickTargetType::FreePoint(point) => { - self.manipulator_anchor(transform.transform_point2(point.position), false, None); + self.manipulator_anchor(transform.transform_point2(point.position), false, color); } ClickTargetType::Path(bezpath) => combined.extend(bezpath.elements().iter().copied()), } diff --git a/editor/src/messages/portfolio/document/overlays/utility_types_web.rs b/editor/src/messages/portfolio/document/overlays/utility_types_web.rs index c1533fedafa..651a605901e 100644 --- a/editor/src/messages/portfolio/document/overlays/utility_types_web.rs +++ b/editor/src/messages/portfolio/document/overlays/utility_types_web.rs @@ -990,7 +990,7 @@ impl OverlayContext { target_types.for_each(|target_type| match target_type.borrow() { ClickTargetType::FreePoint(point) => { - self.manipulator_anchor(transform.transform_point2(point.position), false, None); + self.manipulator_anchor(transform.transform_point2(point.position), false, color); } ClickTargetType::Path(bezpath) => combined.extend(bezpath.elements().iter().copied()), }); From 2ab3a0ea086e322a6f9f6afb2f64b4a2112b3f8e Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Fri, 23 Oct 2026 14:36:38 -0700 Subject: [PATCH 4/5] Add a Point Index overlay and color path overlays apart from points --- editor/src/consts.rs | 4 + .../document/document_message_handler.rs | 2 + .../document/overlays/utility_functions.rs | 261 +++++++++++++++--- .../document/overlays/utility_types_native.rs | 7 + .../document/overlays/utility_types_web.rs | 7 + .../vector-types/src/vector/contour.rs | 3 + node-graph/nodes/gstd/src/lib.rs | 1 + 7 files changed, 244 insertions(+), 41 deletions(-) diff --git a/editor/src/consts.rs b/editor/src/consts.rs index 1dbaaca20fd..59de58c5fe6 100644 --- a/editor/src/consts.rs +++ b/editor/src/consts.rs @@ -128,6 +128,9 @@ pub const PATH_DIRECTION_HARPOON_OFFSET: f64 = 3.; // In viewport pixels, far finer than the arrows need to sit evenly pub const PATH_DIRECTION_ARC_LENGTH_ACCURACY: f64 = 0.1; pub const PATH_INDEX_LABEL_OFFSET: f64 = 14.; +pub const POINT_INDEX_LABEL_OFFSET: f64 = 12.; +// Between a path's start anchor's point index and its path index, side by side +pub const INDEX_LABEL_GAP: f64 = 8.; pub const PATH_FLOW_JOIN_TICK_LENGTH: f64 = 18.; pub const PATH_FLOW_JOIN_TICK_THICKNESS: f64 = 2.; @@ -178,6 +181,7 @@ pub const COLOR_OVERLAY_GREEN: &str = "#63ce63"; pub const COLOR_OVERLAY_GREEN_25: &str = "#63ce6340"; pub const COLOR_OVERLAY_RED: &str = "#ef5454"; pub const COLOR_OVERLAY_RED_25: &str = "#ef545440"; +pub const COLOR_OVERLAY_MAGENTA: &str = "#ba57bc"; pub const COLOR_OVERLAY_GRAY: &str = "#cccccc"; pub const COLOR_OVERLAY_GRAY_25: &str = "#cccccc40"; pub const COLOR_OVERLAY_WHITE: &str = "#ffffff"; diff --git a/editor/src/messages/portfolio/document/document_message_handler.rs b/editor/src/messages/portfolio/document/document_message_handler.rs index 4bccfa7b4c7..780eb16b980 100644 --- a/editor/src/messages/portfolio/document/document_message_handler.rs +++ b/editor/src/messages/portfolio/document/document_message_handler.rs @@ -1578,6 +1578,7 @@ impl MessageHandler> for DocumentMes responses.add(PortfolioMessage::UpdateDocumentWidgets); } OverlaysType::Handles => visibility_settings.handles = visible, + OverlaysType::PointIndex => visibility_settings.point_index = visible, OverlaysType::PathEnds => visibility_settings.path_ends = visible, OverlaysType::PathIndex => visibility_settings.path_index = visible, OverlaysType::PathDirection => visibility_settings.path_direction = visible, @@ -3573,6 +3574,7 @@ impl DocumentMessageHandler { checkbox("Path Ends", OverlaysType::PathEnds, settings.path_ends, !settings.anchors, path_ends_description), checkbox("Anchors", OverlaysType::Anchors, settings.anchors, false, ""), checkbox("Handles", OverlaysType::Handles, settings.handles, !settings.anchors, ""), + checkbox("Point Index", OverlaysType::PointIndex, settings.point_index, !settings.anchors, ""), checkbox("Path Index", OverlaysType::PathIndex, settings.path_index, !settings.anchors, ""), checkbox("Path Direction", OverlaysType::PathDirection, settings.path_direction, !settings.anchors, ""), ]; diff --git a/editor/src/messages/portfolio/document/overlays/utility_functions.rs b/editor/src/messages/portfolio/document/overlays/utility_functions.rs index e76ef095c43..be9457d684b 100644 --- a/editor/src/messages/portfolio/document/overlays/utility_functions.rs +++ b/editor/src/messages/portfolio/document/overlays/utility_functions.rs @@ -1,7 +1,8 @@ use super::utility_types::{DrawHandles, OverlayContext, Pivot}; use crate::consts::{ - COLOR_OVERLAY_BLUE, HIDE_HANDLE_DISTANCE, PATH_DIRECTION_ARC_LENGTH_ACCURACY, PATH_DIRECTION_ARROW_SPACING, PATH_DIRECTION_CHEVRON_SIZE, PATH_DIRECTION_HARPOON_OFFSET, PATH_END_MARKER_DISTANCE, - PATH_END_MARKER_SIZE, PATH_FLOW_JOIN_TICK_LENGTH, PATH_FLOW_JOIN_TICK_THICKNESS, PATH_INDEX_LABEL_OFFSET, PATH_MARKERS_MIN_LENGTH, SNAP_POINT_TOLERANCE, + COLOR_OVERLAY_BLUE, COLOR_OVERLAY_MAGENTA, HIDE_HANDLE_DISTANCE, INDEX_LABEL_GAP, PATH_DIRECTION_ARC_LENGTH_ACCURACY, PATH_DIRECTION_ARROW_SPACING, PATH_DIRECTION_CHEVRON_SIZE, + PATH_DIRECTION_HARPOON_OFFSET, PATH_END_MARKER_DISTANCE, PATH_END_MARKER_SIZE, PATH_FLOW_JOIN_TICK_LENGTH, PATH_FLOW_JOIN_TICK_THICKNESS, PATH_INDEX_LABEL_OFFSET, PATH_MARKERS_MIN_LENGTH, + POINT_INDEX_LABEL_OFFSET, SNAP_POINT_TOLERANCE, }; use crate::messages::portfolio::document::utility_types::document_metadata::LayerNodeIdentifier; use crate::messages::portfolio::document::utility_types::network_interface::NodeNetworkInterface; @@ -12,9 +13,9 @@ use crate::messages::tool::tool_messages::tool_prelude::DocumentMessageHandler; use glam::{DAffine2, DVec2}; use graphene_std::vector::algorithms::util::pathseg_tangent; use graphene_std::vector::misc::{BezierHandles, ManipulatorPointId, PathSegPoints, pathseg_points, point_to_dvec2, segment_to_handles}; -use graphene_std::vector::{PointId, SegmentId, VectorGeometry}; +use graphene_std::vector::{PointId, SegmentId, VectorGeometry, curve_type_at, handle_point, segment_id_of, span_endpoints, spans}; use kurbo::{Affine, ParamCurve, ParamCurveArclen, ParamCurveArea, PathSeg, Rect, Shape}; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; #[cfg(target_family = "wasm")] use wasm_bindgen::JsCast; @@ -72,7 +73,22 @@ pub fn selected_segments_for_layer(vector: &VectorGeometry, state: &SelectedLaye selected_segments } -fn overlay_bezier_handles(segment: PathSeg, segment_id: SegmentId, transform: DAffine2, is_selected: impl Fn(ManipulatorPointId) -> bool, overlay_context: &mut OverlayContext) { +/// A handle drawn by an overlay, in viewport space. +#[derive(Clone, Copy)] +struct DrawnHandle { + point: ManipulatorPointId, + position: DVec2, + /// The point its index label sits away from: its anchor, or the midpoint of both anchors for a quadratic's lone handle. + away_from: DVec2, +} + +fn overlay_bezier_handles( + segment: PathSeg, + segment_id: SegmentId, + transform: DAffine2, + is_selected: impl Fn(ManipulatorPointId) -> bool, + overlay_context: &mut OverlayContext, +) -> [Option; 2] { let segment = Affine::new(transform.to_cols_array()) * segment; let segment_start = point_to_dvec2(segment.start()); let segment_end = point_to_dvec2(segment.end()); @@ -80,21 +96,39 @@ fn overlay_bezier_handles(segment: PathSeg, segment_id: SegmentId, transform: DA match segment_to_handles(&segment) { BezierHandles::Quadratic { handle } if not_under_anchor(handle, segment_start) && not_under_anchor(handle, segment_end) => { + let point = ManipulatorPointId::PrimaryHandle(segment_id); overlay_context.line(handle, segment_start, None, None); overlay_context.line(handle, segment_end, None, None); - overlay_context.manipulator_handle(handle, is_selected(ManipulatorPointId::PrimaryHandle(segment_id)), None); + overlay_context.manipulator_handle(handle, is_selected(point), None); + + let away_from = segment_start.midpoint(segment_end); + [Some(DrawnHandle { point, position: handle, away_from }), None] } BezierHandles::Cubic { handle_start, handle_end } => { + let mut drawn = [None, None]; if not_under_anchor(handle_start, segment_start) { + let point = ManipulatorPointId::PrimaryHandle(segment_id); overlay_context.line(handle_start, segment_start, None, None); - overlay_context.manipulator_handle(handle_start, is_selected(ManipulatorPointId::PrimaryHandle(segment_id)), None); + overlay_context.manipulator_handle(handle_start, is_selected(point), None); + drawn[0] = Some(DrawnHandle { + point, + position: handle_start, + away_from: segment_start, + }); } if not_under_anchor(handle_end, segment_end) { + let point = ManipulatorPointId::EndHandle(segment_id); overlay_context.line(handle_end, segment_end, None, None); - overlay_context.manipulator_handle(handle_end, is_selected(ManipulatorPointId::EndHandle(segment_id)), None); + overlay_context.manipulator_handle(handle_end, is_selected(point), None); + drawn[1] = Some(DrawnHandle { + point, + position: handle_end, + away_from: segment_end, + }); } + drawn } - _ => {} + _ => [None, None], } } @@ -106,7 +140,7 @@ fn overlay_bezier_handle_specific_point( transform: DAffine2, is_selected: impl Fn(ManipulatorPointId) -> bool, overlay_context: &mut OverlayContext, -) { +) -> [Option; 2] { let segment = Affine::new(transform.to_cols_array()) * segment; let segment_start = point_to_dvec2(segment.start()); let segment_end = point_to_dvec2(segment.end()); @@ -114,21 +148,45 @@ fn overlay_bezier_handle_specific_point( match segment_to_handles(&segment) { BezierHandles::Quadratic { handle } if not_under_anchor(handle, segment_start) && not_under_anchor(handle, segment_end) => { + let point = ManipulatorPointId::PrimaryHandle(segment_id); let anchor = if start == point_to_render { segment_start } else { segment_end }; overlay_context.line(handle, anchor, None, None); - overlay_context.manipulator_handle(handle, is_selected(ManipulatorPointId::PrimaryHandle(segment_id)), None); + overlay_context.manipulator_handle(handle, is_selected(point), None); + + [ + Some(DrawnHandle { + point, + position: handle, + away_from: anchor, + }), + None, + ] } BezierHandles::Cubic { handle_start, handle_end } => { + let mut drawn = [None, None]; if not_under_anchor(handle_start, segment_start) && (point_to_render == start) { + let point = ManipulatorPointId::PrimaryHandle(segment_id); overlay_context.line(handle_start, segment_start, None, None); - overlay_context.manipulator_handle(handle_start, is_selected(ManipulatorPointId::PrimaryHandle(segment_id)), None); + overlay_context.manipulator_handle(handle_start, is_selected(point), None); + drawn[0] = Some(DrawnHandle { + point, + position: handle_start, + away_from: segment_start, + }); } if not_under_anchor(handle_end, segment_end) && (point_to_render == end) { + let point = ManipulatorPointId::EndHandle(segment_id); overlay_context.line(handle_end, segment_end, None, None); - overlay_context.manipulator_handle(handle_end, is_selected(ManipulatorPointId::EndHandle(segment_id)), None); + overlay_context.manipulator_handle(handle_end, is_selected(point), None); + drawn[1] = Some(DrawnHandle { + point, + position: handle_end, + away_from: segment_end, + }); } + drawn } - _ => {} + _ => [None, None], } } @@ -152,49 +210,52 @@ pub fn path_overlays(document: &DocumentMessageHandler, draw_handles: DrawHandle } } - path_direction_overlays(&vector, transform, false, overlay_context); + let path_index_labels = path_direction_overlays(&vector, transform, false, overlay_context); let is_selected = |point: ManipulatorPointId| selected_shape_state.is_point_selected(point); + let mut drawn_handles = Vec::new(); if display_handles { let opposite_handles_data = selected_shape_state.selected_points().filter_map(|point_id| vector.adjacent_segment(&point_id)).collect::>(); match draw_handles { DrawHandles::All => { vector.segment_iter().for_each(|(segment_id, segment, _start, _end)| { - overlay_bezier_handles(segment, segment_id, transform, is_selected, overlay_context); + drawn_handles.extend(overlay_bezier_handles(segment, segment_id, transform, is_selected, overlay_context).into_iter().flatten()); }); } DrawHandles::SelectedAnchors(ref selected_segments) => { - let Some(focused_segments) = selected_segments.get(&layer) else { continue }; + let focused_segments = selected_segments.get(&layer).map(Vec::as_slice).unwrap_or_default(); vector .segment_iter() .filter(|(segment_id, ..)| focused_segments.contains(segment_id)) .for_each(|(segment_id, segment, _start, _end)| { - overlay_bezier_handles(segment, segment_id, transform, is_selected, overlay_context); + drawn_handles.extend(overlay_bezier_handles(segment, segment_id, transform, is_selected, overlay_context).into_iter().flatten()); }); for (segment_id, segment, start, end) in vector.segment_iter() { if let Some((corresponding_anchor, _)) = opposite_handles_data.iter().find(|(_, adj_segment_id)| adj_segment_id == &segment_id) { - overlay_bezier_handle_specific_point(segment, segment_id, (start, end), *corresponding_anchor, transform, is_selected, overlay_context); + let drawn = overlay_bezier_handle_specific_point(segment, segment_id, (start, end), *corresponding_anchor, transform, is_selected, overlay_context); + drawn_handles.extend(drawn.into_iter().flatten()); } } } DrawHandles::FrontierHandles(ref segment_endpoints_by_layer) => { - let Some(segment_endpoints) = segment_endpoints_by_layer.get(&layer) else { continue }; - - vector - .segment_iter() - .filter(|(segment_id, ..)| segment_endpoints.contains_key(segment_id)) - .for_each(|(segment_id, segment, start, end)| { - if segment_endpoints.get(&segment_id).unwrap().len() == 1 { - let point_to_render = segment_endpoints.get(&segment_id).unwrap()[0]; - overlay_bezier_handle_specific_point(segment, segment_id, (start, end), point_to_render, transform, is_selected, overlay_context); - } else { - overlay_bezier_handles(segment, segment_id, transform, is_selected, overlay_context); - } - }); + if let Some(segment_endpoints) = segment_endpoints_by_layer.get(&layer) { + vector + .segment_iter() + .filter(|(segment_id, ..)| segment_endpoints.contains_key(segment_id)) + .for_each(|(segment_id, segment, start, end)| { + let drawn = if segment_endpoints.get(&segment_id).unwrap().len() == 1 { + let point_to_render = segment_endpoints.get(&segment_id).unwrap()[0]; + overlay_bezier_handle_specific_point(segment, segment_id, (start, end), point_to_render, transform, is_selected, overlay_context) + } else { + overlay_bezier_handles(segment, segment_id, transform, is_selected, overlay_context) + }; + drawn_handles.extend(drawn.into_iter().flatten()); + }); + } } DrawHandles::None => {} } @@ -205,6 +266,8 @@ pub fn path_overlays(document: &DocumentMessageHandler, draw_handles: DrawHandle overlay_context.manipulator_anchor(transform.transform_point2(position), is_selected(ManipulatorPointId::Anchor(id)), None); } } + + index_label_overlays(&vector, transform, false, &drawn_handles, path_index_labels, overlay_context); } } @@ -228,7 +291,7 @@ pub fn open_path_endpoint_overlays(document: &DocumentMessageHandler, shape_edit let Some(vector) = document.network_interface.compute_modified_vector(layer) else { continue }; let transform = document.metadata().transform_to_viewport_if_feeds(layer, &document.network_interface); - path_direction_overlays(&vector, transform, true, overlay_context); + let path_index_labels = path_direction_overlays(&vector, transform, true, overlay_context); for id in vector.anchor_endpoints() { if hovered.is_some_and(|(hovered_layer, hovered_id, _)| hovered_layer == layer && hovered_id == id) { @@ -238,6 +301,8 @@ pub fn open_path_endpoint_overlays(document: &DocumentMessageHandler, shape_edit overlay_context.manipulator_anchor(transform.transform_point2(position), is_selected(layer, id), None); } + + index_label_overlays(&vector, transform, true, &[], path_index_labels, overlay_context); } // Drawn last so its halo sits above any other endpoint at the same spot @@ -247,13 +312,23 @@ pub fn open_path_endpoint_overlays(document: &DocumentMessageHandler, shape_edit } } -/// Marks how each path of a layer runs, as the overlay settings choose: chevrons at its start and end and along it, its index, and ticks where its path flows join. -/// With `open_paths_only`, closed paths go unmarked, for tools that only show the anchors at open ends. -fn path_direction_overlays(vector: &VectorGeometry, transform: DAffine2, open_paths_only: bool, overlay_context: &mut OverlayContext) { +/// A path's index label, placed off its start anchor where it covers the least of the path. +struct PathIndexLabel { + index: usize, + /// The pool index of the anchor the path starts at, where a point index label may share its spot. + anchor: u32, + position: DVec2, +} + +/// Marks how each path of a layer runs, as the overlay settings choose: chevrons at its start and end and along it, and ticks where its path flows join. +/// Returns each path's index label for [`index_label_overlays`] to draw. With `open_paths_only`, closed paths go unmarked, for tools that only show the anchors at open ends. +fn path_direction_overlays(vector: &VectorGeometry, transform: DAffine2, open_paths_only: bool, overlay_context: &mut OverlayContext) -> Vec { + let mut index_labels = Vec::new(); + let settings = overlay_context.visibility_settings; let (show_ends, show_index, show_direction) = (settings.path_ends(), settings.path_index(), settings.path_direction()); if !(show_ends || show_index || show_direction) { - return; + return index_labels; } let to_viewport = Affine::new(transform.to_cols_array()); @@ -348,9 +423,113 @@ fn path_direction_overlays(vector: &VectorGeometry, transform: DAffine2, open_pa let clockwise = segments.iter().map(|segment| segment.signed_area()).sum::() > 0.; let outside = if clockwise { -departure.perp() } else { departure.perp() }; let away = direction_or(-spokes, outside); - let label_transform = DAffine2::from_translation(start + away * PATH_INDEX_LABEL_OFFSET); - overlay_context.text(&index.to_string(), COLOR_OVERLAY_BLUE, None, label_transform, 0., [Pivot::Middle, Pivot::Middle]); + + index_labels.push(PathIndexLabel { + index, + anchor: contour.entry, + position: start + away * PATH_INDEX_LABEL_OFFSET, + }); + } + } + + index_labels +} + +/// Draws the path index labels, and when the overlay settings show point indices, labels the anchors (or just the endpoints with `endpoints_only`) and drawn handles with their indices in the layer's list of points. +/// A point index label sits off its point away from the segments meeting there, and a path's start anchor shares that spot with the path's index, the two side by side. +fn index_label_overlays(vector: &VectorGeometry, transform: DAffine2, endpoints_only: bool, drawn_handles: &[DrawnHandle], path_index_labels: Vec, overlay_context: &OverlayContext) { + // The size `OverlayContext::text` draws at + const FONT_SIZE: f64 = 12.; + + let draw_label = |text: &str, color: &str, position: DVec2, horizontal_pivot: Pivot| { + overlay_context.text(text, color, None, DAffine2::from_translation(position), 0., [horizontal_pivot, Pivot::Middle]); + }; + + if !overlay_context.visibility_settings.point_index() { + for label in &path_index_labels { + draw_label(&label.index.to_string(), COLOR_OVERLAY_MAGENTA, label.position, Pivot::Middle); } + return; + } + + let mut path_index_labels_at: HashMap> = HashMap::new(); + for label in &path_index_labels { + path_index_labels_at.entry(label.anchor).or_default().push(label); + } + + let to_viewport = Affine::new(transform.to_cols_array()); + + // In one O(segments) pass: the directions segments leave each anchor in (their sum and the first), and each handle's pool index + let mut spokes: HashMap = HashMap::new(); + let mut handle_indices: HashMap = HashMap::new(); + for span in spans(&vector.paths) { + let Some((start, end)) = span_endpoints(&vector.paths, span) else { continue }; + let curve_type = curve_type_at(&vector.paths, span.path as usize); + let Some(segment) = vector + .paths + .element(span.path as usize) + .and_then(|path| path.segment(&vector.vector.0, curve_type, span.index as usize)) + else { + continue; + }; + + if let Some((departure, arrival)) = end_directions(to_viewport * segment) { + for (anchor, spoke) in [(start, departure), (end, -arrival)] { + let (sum, _) = spokes.entry(anchor).or_insert((DVec2::ZERO, spoke)); + *sum += spoke; + } + } + + let segment_id = segment_id_of(&vector.paths, span).unwrap_or_default(); + for (point, primary) in [(ManipulatorPointId::PrimaryHandle(segment_id), true), (ManipulatorPointId::EndHandle(segment_id), false)] { + if let Some(index) = handle_point(&vector.paths, span, primary) { + handle_indices.insert(point, index); + } + } + } + + let endpoints: HashSet = if endpoints_only { vector.anchor_endpoints().collect() } else { HashSet::new() }; + + // Opposite the segments meeting at an anchor, or beside them on the upper side where they run straight through + for index in vector.vector.anchor_indices(&vector.paths) { + let index = index as u32; + let (Some(id), Some(position)) = (vector.point_id_at(index), vector.position(index as usize)) else { + continue; + }; + if endpoints_only && !endpoints.contains(&id) { + continue; + } + + let away = match spokes.get(&index) { + Some(&(sum, first)) => direction_or(-sum, if first.perp().y > 0. { -first.perp() } else { first.perp() }), + None => DVec2::NEG_Y, + }; + let spot = transform.transform_point2(position) + away * POINT_INDEX_LABEL_OFFSET; + let point_index = index.to_string(); + + let Some(path_labels) = path_index_labels_at.remove(&index) else { + draw_label(&point_index, COLOR_OVERLAY_BLUE, spot, Pivot::Middle); + continue; + }; + + // The point index and path index sit either side of the spot, slid outward so the label nearer the anchor clears it as a lone label would + let path_index = path_labels.iter().map(|label| label.index.to_string()).collect::>().join(", "); + let nearer_width = text_width(if away.x < 0. { &path_index } else { &point_index }, FONT_SIZE); + let center = spot + DVec2::X * away.x * (INDEX_LABEL_GAP + nearer_width) / 2.; + draw_label(&point_index, COLOR_OVERLAY_BLUE, center - DVec2::X * INDEX_LABEL_GAP / 2., Pivot::End); + draw_label(&path_index, COLOR_OVERLAY_MAGENTA, center + DVec2::X * INDEX_LABEL_GAP / 2., Pivot::Start); + } + + // Past each handle's tip, away from its anchor + for handle in drawn_handles { + let Some(&index) = handle_indices.get(&handle.point) else { continue }; + let away = direction_or(handle.position - handle.away_from, DVec2::NEG_Y); + draw_label(&index.to_string(), COLOR_OVERLAY_BLUE, handle.position + away * POINT_INDEX_LABEL_OFFSET, Pivot::Middle); + } + + // Any path index whose start anchor has no point index label of its own + for label in path_index_labels_at.into_values().flatten() { + draw_label(&label.index.to_string(), COLOR_OVERLAY_MAGENTA, label.position, Pivot::Middle); } } @@ -387,8 +566,8 @@ fn draw_chevron(overlay_context: &mut OverlayContext, center: DVec2, direction: // The shaft matches the barb's length let shaft_end = tip - direction * tip.distance(barb_end); - overlay_context.line(shaft_end, tip, None, None); - overlay_context.line(barb_end, tip, None, None); + overlay_context.line(shaft_end, tip, Some(COLOR_OVERLAY_MAGENTA), None); + overlay_context.line(barb_end, tip, Some(COLOR_OVERLAY_MAGENTA), None); } None => { let spread = direction.perp() * size / 2.; diff --git a/editor/src/messages/portfolio/document/overlays/utility_types_native.rs b/editor/src/messages/portfolio/document/overlays/utility_types_native.rs index 0bdfc7172b6..a6069f92f57 100644 --- a/editor/src/messages/portfolio/document/overlays/utility_types_native.rs +++ b/editor/src/messages/portfolio/document/overlays/utility_types_native.rs @@ -60,6 +60,7 @@ pub enum OverlaysType { Path, Anchors, Handles, + PointIndex, PathEnds, PathIndex, PathDirection, @@ -84,6 +85,7 @@ pub struct OverlaysVisibilitySettings { pub path: bool, pub anchors: bool, pub handles: bool, + pub point_index: bool, pub path_ends: bool, pub path_index: bool, pub path_direction: bool, @@ -107,6 +109,7 @@ impl Default for OverlaysVisibilitySettings { path: true, anchors: true, handles: true, + point_index: false, path_ends: true, path_index: false, path_direction: false, @@ -172,6 +175,10 @@ impl OverlaysVisibilitySettings { self.all && self.anchors && self.handles } + pub fn point_index(&self) -> bool { + self.all && self.anchors && self.point_index + } + pub fn path_ends(&self) -> bool { self.all && self.anchors && self.path_ends } diff --git a/editor/src/messages/portfolio/document/overlays/utility_types_web.rs b/editor/src/messages/portfolio/document/overlays/utility_types_web.rs index 651a605901e..eb418bd0de2 100644 --- a/editor/src/messages/portfolio/document/overlays/utility_types_web.rs +++ b/editor/src/messages/portfolio/document/overlays/utility_types_web.rs @@ -51,6 +51,7 @@ pub enum OverlaysType { Path, Anchors, Handles, + PointIndex, PathEnds, PathIndex, PathDirection, @@ -74,6 +75,7 @@ pub struct OverlaysVisibilitySettings { pub path: bool, pub anchors: bool, pub handles: bool, + pub point_index: bool, pub path_ends: bool, pub path_index: bool, pub path_direction: bool, @@ -96,6 +98,7 @@ impl Default for OverlaysVisibilitySettings { path: true, anchors: true, handles: true, + point_index: false, path_ends: true, path_index: false, path_direction: false, @@ -160,6 +163,10 @@ impl OverlaysVisibilitySettings { self.all && self.anchors && self.handles } + pub fn point_index(&self) -> bool { + self.all && self.anchors && self.point_index + } + pub fn path_ends(&self) -> bool { self.all && self.anchors && self.path_ends } diff --git a/node-graph/libraries/vector-types/src/vector/contour.rs b/node-graph/libraries/vector-types/src/vector/contour.rs index bea0338beb2..92cf2a2da47 100644 --- a/node-graph/libraries/vector-types/src/vector/contour.rs +++ b/node-graph/libraries/vector-types/src/vector/contour.rs @@ -222,6 +222,8 @@ pub fn contour_bezpaths(pool: &List, paths: &List) -> Vec /// One contour's segments in the order and direction it runs along them. #[derive(Clone, Debug, PartialEq)] pub struct OrientedContour { + /// The pool point the contour sets out from. + pub entry: u32, pub segments: Vec, /// Where in `segments` the contour crosses from one path flow into another. A closed contour lists 0 when it arrives back at its start from another flow. pub flow_joins: Vec, @@ -265,6 +267,7 @@ pub fn oriented_contours(pool: &List, paths: &List) -> Vec Date: Fri, 23 Oct 2026 17:11:26 -0700 Subject: [PATCH 5/5] Fix click targets of filled meshes hitting areas their faces don't fill --- .../libraries/rendering/src/renderer.rs | 6 +- .../vector-types/src/vector/click_target.rs | 79 +++++++++++++++++++ .../vector-types/src/vector/geometry_cache.rs | 20 ++++- 3 files changed, 99 insertions(+), 6 deletions(-) diff --git a/node-graph/libraries/rendering/src/renderer.rs b/node-graph/libraries/rendering/src/renderer.rs index d8bc4b708db..7fa89d1f36f 100644 --- a/node-graph/libraries/rendering/src/renderer.rs +++ b/node-graph/libraries/rendering/src/renderer.rs @@ -131,12 +131,12 @@ impl<'a> ItemRef<'a, Vector> { } } - /// The contours combined into one click target path, closing open ones when `filled`. + /// The contours combined into one click target path, covering what the fill paints when `filled`. fn target_path(self, filled: bool) -> Option> { let vector = self.element()?; match self.attribute::(ATTR_GEOMETRY_CACHE) { Some(cache) => cache.target_path(vector, self.paths(), filled), - None => TargetPath::from_contours(&contour_bezpaths(&vector.0, self.paths()), filled).map(Arc::new), + None => GeometryCache::default().target_path(vector, self.paths(), filled), } } @@ -2277,7 +2277,7 @@ impl Render for List { /// plus one `FreePoint` per disconnected anchor, placed by the transform. /// The target's path is shared through the item's geometry cache with every copy drawing the same geometry. fn extend_targets_from_vector(targets: &mut Vec, appearance: Option<&Appearance>, item: ItemRef<'_, Vector>, transform: DAffine2, close_filled_contours: bool) { - // Click targets close a filled layer's open contours so a click anywhere on the fill hits it + // Click targets cover a filled layer's fill so a click anywhere on it hits let target_path = item.target_path(close_filled_contours && is_filled_for_hit_testing(appearance)); push_path_target(targets, appearance, target_path, &item.contours(), transform); diff --git a/node-graph/libraries/vector-types/src/vector/click_target.rs b/node-graph/libraries/vector-types/src/vector/click_target.rs index c5df36669e6..6d3a096833c 100644 --- a/node-graph/libraries/vector-types/src/vector/click_target.rs +++ b/node-graph/libraries/vector-types/src/vector/click_target.rs @@ -132,6 +132,54 @@ impl TargetPath { Some(Self::new(BezPath::from_vec(elements))) } + /// The contours as outlines plus the faces as the fill region, for a mesh whose fill paints its faces rather than its contours, or None if all are empty. + /// No contour stays closed, so none adds fill that could cancel a face's under the nonzero rule. + pub fn from_contours_and_faces(contours: &[BezPath], faces: &[BezPath]) -> Option { + let mut elements = Vec::new(); + + // A closed contour draws out its closing edge instead, keeping its whole outline without enclosing anything + for contour in contours { + let mut subpath_start = None; + let mut previous_end = None; + for &element in contour.elements() { + match element { + PathEl::ClosePath => { + if let Some(start) = subpath_start + && previous_end != Some(start) + { + elements.push(PathEl::LineTo(start)); + } + previous_end = subpath_start; + } + _ => { + if let PathEl::MoveTo(point) = element { + subpath_start = Some(point); + } + previous_end = element.end_point(); + elements.push(element); + } + } + } + } + + // Each loop of a face is closed so it counts toward the fill region + for face in faces { + let mut loop_open = false; + for &element in face.elements() { + if loop_open && matches!(element, PathEl::MoveTo(_)) { + elements.push(PathEl::ClosePath); + } + loop_open = !matches!(element, PathEl::ClosePath); + elements.push(element); + } + if loop_open { + elements.push(PathEl::ClosePath); + } + } + + (!elements.is_empty()).then(|| Self::new(BezPath::from_vec(elements))) + } + pub fn path(&self) -> &BezPath { &self.path } @@ -498,6 +546,37 @@ mod tests { } } + #[test] + fn a_mesh_target_fills_its_faces_but_not_its_open_contours() { + // An open corner whose closing would enclose a triangle, beside an unclosed square face like the ones meshes construct + let mut corner = BezPath::new(); + corner.move_to((0., 0.)); + corner.line_to((0., 10.)); + corner.line_to((10., 10.)); + let mut face = BezPath::new(); + face.move_to((20., 0.)); + face.line_to((30., 0.)); + face.line_to((30., 10.)); + face.line_to((20., 10.)); + face.line_to((20., 0.)); + + let target = ClickTarget::new_with_shared_path(Arc::new(TargetPath::from_contours_and_faces(&[corner], &[face]).unwrap()), DAffine2::IDENTITY, 2.); + + assert!(target.intersect_point_no_stroke(DVec2::new(25., 5.))); + assert!(!target.intersect_point_no_stroke(DVec2::new(3., 7.))); + assert!(target.intersect_point(DVec2::new(0., 5.), DAffine2::IDENTITY)); + } + + #[test] + fn a_closed_contour_wound_against_its_face_keeps_the_face_filled() { + let contour = rectangle_path(DVec2::ZERO, DVec2::splat(10.)); + let face = contour.reverse_subpaths(); + + let target = ClickTarget::new_with_shared_path(Arc::new(TargetPath::from_contours_and_faces(&[contour], &[face]).unwrap()), DAffine2::IDENTITY, 0.); + + assert!(target.intersect_point_no_stroke(DVec2::splat(5.))); + } + #[test] fn moving_a_target_keeps_its_geometry_shared() { let mut target = ClickTarget::new_with_path(rectangle_path(DVec2::ZERO, DVec2::ONE), 0.); diff --git a/node-graph/libraries/vector-types/src/vector/geometry_cache.rs b/node-graph/libraries/vector-types/src/vector/geometry_cache.rs index 306a65b1f16..6376b35dd36 100644 --- a/node-graph/libraries/vector-types/src/vector/geometry_cache.rs +++ b/node-graph/libraries/vector-types/src/vector/geometry_cache.rs @@ -31,6 +31,10 @@ impl CachedGeometry { fn contours(&self, vector: &Vector, paths: &List) -> &Arc<[BezPath]> { self.contours.get_or_init(|| contour_bezpaths(&vector.0, paths).into()) } + + fn faces(&self, vector: &Vector, paths: &List) -> &Arc<[BezPath]> { + self.faces.get_or_init(|| vector.construct_faces(paths).into()) + } } /// A vector item's cache of geometry derived from its pool and paths. @@ -44,17 +48,27 @@ impl GeometryCache { self.entry(vector, paths).contours(vector, paths).clone() } - /// The contours combined into one click target path by [`TargetPath::from_contours`], built once and reused for as long as neither changes. + /// The contours combined into one click target path, built once and reused for as long as neither changes. + /// When `filled`, it covers what the fill paints: a mesh's faces, or else the contours with the open ones closed. pub fn target_path(&self, vector: &Vector, paths: &List, filled: bool) -> Option> { let entry = self.entry(vector, paths); let slot = if filled { &entry.filled_target_path } else { &entry.unfilled_target_path }; - slot.get_or_init(|| TargetPath::from_contours(entry.contours(vector, paths), filled).map(Arc::new)).clone() + slot.get_or_init(|| { + let contours = entry.contours(vector, paths); + let target_path = if filled && vector.use_face_fill(paths) { + TargetPath::from_contours_and_faces(contours, entry.faces(vector, paths)) + } else { + TargetPath::from_contours(contours, filled) + }; + target_path.map(Arc::new) + }) + .clone() } /// The fillable faces of `vector` and `paths`, constructed once and reused for as long as neither changes. pub fn faces(&self, vector: &Vector, paths: &List) -> Arc<[BezPath]> { - self.entry(vector, paths).faces.get_or_init(|| vector.construct_faces(paths).into()).clone() + self.entry(vector, paths).faces(vector, paths).clone() } /// The entry for this geometry, made most recently used, or a new empty one evicting the least recently used.