Fix tool overlays and snap targets to use upstream Path node geometry, not downstream final geometry (#4114)

* Fix tool overlays and snap targets to use upstream Path node geometry not downstream final geometry

* Snap to upstream Path free points, dedupe the lookup via a helper
This commit is contained in:
Keavon Chambers 2026-05-06 03:00:38 -07:00 committed by GitHub
parent 22a6e0d767
commit ecf94258fa
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 151 additions and 32 deletions

View File

@ -26,7 +26,7 @@ use graphene_std::ContextDependencies;
use graphene_std::math::quad::Quad;
use graphene_std::subpath::Subpath;
use graphene_std::transform::Footprint;
use graphene_std::vector::click_target::{ClickTarget, ClickTargetType};
use graphene_std::vector::click_target::{ClickTarget, ClickTargetType, FreePoint};
use graphene_std::vector::{PointId, Vector, VectorModificationType};
use kurbo::BezPath;
use memo_network::MemoNetwork;
@ -3191,8 +3191,8 @@ impl NodeNetworkInterface {
let nodes = network_metadata
.persistent_metadata
.node_metadata
.iter()
.filter_map(|(node_id, _)| if self.is_layer(node_id, network_path) { Some(*node_id) } else { None })
.keys()
.filter_map(|node_id| if self.is_layer(node_id, network_path) { Some(*node_id) } else { None })
.collect::<Vec<_>>();
let layer_widths = nodes
.iter()
@ -3234,6 +3234,39 @@ impl NodeNetworkInterface {
self.document_metadata.layer_vector_data.get(&layer).map(|arc| arc.as_ref().clone())
}
/// The vector geometry an upstream Path node would surface for editing.
/// This is the result of `compute_modified_vector`, but only if a visible 'Path' node is actually upstream.
/// Useful for tool overlays and snap target collection usages that want to match the Path tool's view
/// (e.g. the pre-solidified centerline for a Solidify Stroke layer) and otherwise do nothing.
pub fn upstream_path_node_vector(&self, layer: LayerNodeIdentifier) -> Option<Vector> {
let graph_layer = graph_modification_utils::NodeGraphLayer::new(layer, self);
graph_layer.upstream_visible_node_id_from_name_in_layer(&DefinitionIdentifier::Network("Path".into()))?;
self.compute_modified_vector(layer)
}
/// Outline targets for the Select tool's hover/selection overlay, mirroring the Path tool's view.
/// Returns `Some` when an upstream Path node exists so the outline matches what the Path tool edits
/// (e.g. the pre-solidified centerline for a Solidify Stroke layer); returns `None` otherwise so the
/// caller can fall back to the layer's recorded `outlines`/`click_targets`.
pub fn path_aware_outline_targets(&self, layer: LayerNodeIdentifier) -> Option<Vec<ClickTargetType>> {
let vector = self.upstream_path_node_vector(layer)?;
let mut targets = Vec::new();
let subpaths: Vec<Subpath<PointId>> = vector.stroke_bezier_paths().collect();
if !subpaths.is_empty() {
targets.push(ClickTargetType::CompoundPath(subpaths));
}
for &point_id in vector.point_domain.ids() {
if !vector.any_connected(point_id) {
let position = vector.point_domain.position_from_id(point_id).unwrap_or_default();
targets.push(ClickTargetType::FreePoint(FreePoint::new(point_id, position)));
}
}
Some(targets)
}
/// Loads the structure of layer nodes from a node graph.
pub fn load_structure(&mut self) {
self.document_metadata.structure = HashMap::from_iter([(LayerNodeIdentifier::ROOT_PARENT, NodeRelations::default())]);

View File

@ -472,11 +472,18 @@ impl SnapManager {
if let Some(ind) = &self.indicator {
for layer in &ind.outline_layers {
let &Some(layer) = layer else { continue };
overlay_context.outline(
snap_data.document.metadata().layer_with_free_points_outline(layer),
snap_data.document.metadata().transform_to_viewport(layer),
None,
);
// Mirror the Path tool's view (e.g. pre-solidified centerline) when an upstream Path node exists,
// otherwise fall back to the layer's recorded outline geometry
if let Some(targets) = snap_data.document.network_interface.path_aware_outline_targets(layer) {
let transform = snap_data.document.metadata().transform_to_viewport_if_feeds(layer, &snap_data.document.network_interface);
overlay_context.outline(targets.iter(), transform, None);
} else {
overlay_context.outline(
snap_data.document.metadata().layer_with_free_points_outline(layer),
snap_data.document.metadata().transform_to_viewport(layer),
None,
);
}
}
if let Some(quad) = ind.target_bounds {
overlay_context.quad(to_viewport * quad, None, None);

View File

@ -74,7 +74,7 @@ impl LayerSnapper {
}
if document.snapping_state.target_enabled(SnapTarget::Path(PathSnapTarget::IntersectionPoint)) || document.snapping_state.target_enabled(SnapTarget::Path(PathSnapTarget::AlongPath)) {
for subpath in document.metadata().layer_outline(layer) {
let mut push_candidates = |subpath: &Subpath<PointId>, transform: DAffine2| {
for (start_index, curve) in subpath.iter().enumerate() {
let document_curve = Affine::new(transform.to_cols_array()) * curve;
let start = subpath.manipulator_groups()[start_index].id;
@ -89,6 +89,22 @@ impl LayerSnapper {
bounds: None,
});
}
};
// Post-solidified outline (the layer's recorded geometry)
for subpath in document.metadata().layer_outline(layer) {
push_candidates(subpath, transform);
}
// Pre-solidified centerline (the Path tool's view) when an upstream Path node exists,
// so a drag can snap to either the visible solidified shape or the editable centerline
if let Some(vector) = document.network_interface.upstream_path_node_vector(layer) {
let path_aware_transform = document.metadata().transform_to_document_if_feeds(layer, &document.network_interface);
if path_aware_transform.is_finite() {
for subpath in vector.stroke_bezier_paths() {
push_candidates(&subpath, path_aware_transform);
}
}
}
}
}
@ -616,10 +632,40 @@ pub fn get_layer_snap_points(layer: LayerNodeIdentifier, snap_data: &SnapData, p
for child in layer.descendants(document.metadata()) {
get_layer_snap_points(child, snap_data, points);
}
} else if document.metadata().layer_outline(layer).next().is_some() {
let to_document = document.metadata().transform_to_document(layer);
for subpath in document.metadata().layer_outline(layer) {
subpath_anchor_snap_points(layer, subpath, snap_data, points, to_document);
} else {
// Post-solidified outline (the layer's recorded geometry)
if document.metadata().layer_outline(layer).next().is_some() {
let to_document = document.metadata().transform_to_document(layer);
for subpath in document.metadata().layer_outline(layer) {
subpath_anchor_snap_points(layer, subpath, snap_data, points, to_document);
}
}
// Pre-solidified centerline (the Path tool's view) when an upstream Path node exists,
// so anchor snaps can also target the editable anchor positions and isolated free points,
// matching what the Path tool's overlay shows
if let Some(vector) = document.network_interface.upstream_path_node_vector(layer) {
let to_document = document.metadata().transform_to_document_if_feeds(layer, &document.network_interface);
for subpath in vector.stroke_bezier_paths() {
subpath_anchor_snap_points(layer, &subpath, snap_data, points, to_document);
}
if document.snapping_state.target_enabled(SnapTarget::Path(PathSnapTarget::AnchorPointWithFreeHandles)) {
for &point_id in vector.point_domain.ids() {
if points.len() >= crate::consts::MAX_LAYER_SNAP_POINTS {
break;
}
if !vector.any_connected(point_id) {
let position = vector.point_domain.position_from_id(point_id).unwrap_or_default();
points.push(SnapCandidatePoint::new(
to_document.transform_point2(position),
SnapSource::Path(PathSnapSource::AnchorPointWithFreeHandles),
SnapTarget::Path(PathSnapTarget::AnchorPointWithFreeHandles),
Some(layer),
));
}
}
}
}
}
}

View File

@ -609,7 +609,10 @@ struct PathToolData {
drill_through_cycle_index: usize,
drill_through_cycle_count: usize,
hovered_layers: Vec<LayerNodeIdentifier>,
ghost_outline: Vec<(Vec<ClickTargetType>, LayerNodeIdentifier)>,
/// Snapshot of each selected layer's outline geometry at drag start, drawn in gray as a "before" reference.
/// The `bool` flags whether the snapshot came from `path_aware_outline_targets`, in which case the draw
/// site uses `transform_to_viewport_if_feeds` to match the Path tool's coordinate space.
ghost_outline: Vec<(Vec<ClickTargetType>, LayerNodeIdentifier, bool)>,
make_path_editable_is_allowed: bool,
}
@ -708,10 +711,15 @@ impl PathToolData {
fn set_ghost_outline(&mut self, shape_editor: &ShapeState, document: &DocumentMessageHandler) {
self.ghost_outline.clear();
for &layer in shape_editor.selected_shape_state.keys() {
// We probably need to collect here
let outline: Vec<ClickTargetType> = document.metadata().layer_with_free_points_outline(layer).cloned().collect();
// Mirror the Path tool's view (e.g. pre-solidified centerline) when an upstream Path node exists,
// otherwise fall back to the layer's recorded outline geometry
let (outline, path_aware) = if let Some(targets) = document.network_interface.path_aware_outline_targets(layer) {
(targets, true)
} else {
(document.metadata().layer_with_free_points_outline(layer).cloned().collect(), false)
};
self.ghost_outline.push((outline, layer));
self.ghost_outline.push((outline, layer, path_aware));
}
}
@ -1712,8 +1720,12 @@ impl Fsm for PathToolFsmState {
(_, PathToolMessage::Overlays { context: mut overlay_context }) => {
// Set this to show ghost line only if drag actually happened
if matches!(self, Self::Dragging(_)) && tool_data.drag_start_pos.distance(input.mouse.position) > DRAG_THRESHOLD {
for (outline, layer) in &tool_data.ghost_outline {
let transform = document.metadata().transform_to_viewport(*layer);
for (outline, layer, path_aware) in &tool_data.ghost_outline {
let transform = if *path_aware {
document.metadata().transform_to_viewport_if_feeds(*layer, &document.network_interface)
} else {
document.metadata().transform_to_viewport(*layer)
};
overlay_context.outline(outline.iter(), transform, Some(COLOR_OVERLAY_GRAY));
}
}
@ -1877,9 +1889,6 @@ impl Fsm for PathToolFsmState {
continue;
}
let layer_to_viewport = document.metadata().transform_to_viewport(hovered_layer);
let outline = document.metadata().layer_with_free_points_outline(hovered_layer);
// Determine highlight color based on drill-through state
let color = match (index, mouse_has_moved) {
// If the layer is the next selected one and mouse has not moved, highlight it blue
@ -1891,7 +1900,14 @@ impl Fsm for PathToolFsmState {
};
// TODO: Make this draw underneath all other overlays
overlay_context.outline(outline, layer_to_viewport, Some(color));
// Mirror the Path tool's view (e.g. pre-solidified centerline) when an upstream Path node exists
if let Some(targets) = document.network_interface.path_aware_outline_targets(hovered_layer) {
let layer_to_viewport = document.metadata().transform_to_viewport_if_feeds(hovered_layer, &document.network_interface);
overlay_context.outline(targets.iter(), layer_to_viewport, Some(color));
} else {
let layer_to_viewport = document.metadata().transform_to_viewport(hovered_layer);
overlay_context.outline(document.metadata().layer_with_free_points_outline(hovered_layer), layer_to_viewport, Some(color));
}
}
}
Self::Drawing { selection_shape } => {

View File

@ -581,6 +581,18 @@ impl SelectToolData {
}
}
/// Draws the hover/selection outline for a layer. When a Path node is upstream, mirrors the Path tool's view
/// (e.g. the pre-solidified centerline for a Solidify Stroke layer); otherwise uses the layer's recorded outlines.
fn draw_layer_outline(overlay_context: &mut OverlayContext, document: &DocumentMessageHandler, layer: LayerNodeIdentifier, color: Option<&str>) {
if let Some(targets) = document.network_interface.path_aware_outline_targets(layer) {
let layer_to_viewport = document.metadata().transform_to_viewport_if_feeds(layer, &document.network_interface);
overlay_context.outline(targets.iter(), layer_to_viewport, color);
} else {
let layer_to_viewport = document.metadata().transform_to_viewport(layer);
overlay_context.outline(document.metadata().layer_with_free_points_outline(layer), layer_to_viewport, color);
}
}
/// Bounding boxes are unfortunately not axis aligned. The bounding boxes are found after a transformation is applied to all of the layers.
/// This uses some rather confusing logic to determine what transform that should be.
pub fn create_bounding_box_transform(document: &DocumentMessageHandler) -> DAffine2 {
@ -626,10 +638,10 @@ impl Fsm for SelectToolFsmState {
.selected_visible_and_unlocked_layers(&document.network_interface)
.filter(|layer| !document.network_interface.is_artboard(&layer.to_node(), &[]))
{
let layer_to_viewport = document.metadata().transform_to_viewport(layer);
overlay_context.outline(document.metadata().layer_with_free_points_outline(layer), layer_to_viewport, None);
draw_layer_outline(&mut overlay_context, document, layer, None);
if document.metadata().is_text_layer(layer) {
let layer_to_viewport = document.metadata().transform_to_viewport(layer);
let transformed_quad = layer_to_viewport * text_bounding_box(layer, document, &cached_data.font_cache);
overlay_context.dashed_quad(transformed_quad, None, None, Some(7.), Some(5.), None);
}
@ -667,14 +679,13 @@ impl Fsm for SelectToolFsmState {
let not_selected_click = click.filter(|&hovered_layer| !document.network_interface.selected_nodes().selected_layers_contains(hovered_layer, document.metadata()));
if let Some(layer) = not_selected_click {
if overlay_context.visibility_settings.hover_outline() && !document.network_interface.is_artboard(&layer.to_node(), &[]) {
let layer_to_viewport = document.metadata().transform_to_viewport(layer);
let mut hover_overlay_draw = |layer: LayerNodeIdentifier, color: Option<&str>| {
if layer.has_children(document.metadata()) {
if let Some(bounds) = document.metadata().bounding_box_viewport(layer) {
overlay_context.quad(Quad::from_box(bounds), color, None);
}
} else {
overlay_context.outline(document.metadata().layer_with_free_points_outline(layer), layer_to_viewport, color);
draw_layer_outline(&mut overlay_context, document, layer, color);
}
};
let layer = match tool_data.nested_selection_behavior {
@ -954,8 +965,7 @@ impl Fsm for SelectToolFsmState {
if overlay_context.visibility_settings.selection_outline() {
// Draws a temporary outline on the layers that will be selected by the current box/lasso area
for layer in layers_to_outline {
let layer_to_viewport = document.metadata().transform_to_viewport(layer);
overlay_context.outline(document.metadata().layer_with_free_points_outline(layer), layer_to_viewport, None);
draw_layer_outline(&mut overlay_context, document, layer, None);
}
}

View File

@ -653,9 +653,16 @@ impl TransformLayerMessageHandler {
fn set_ghost_outline(ghost_outline: &mut Vec<(Vec<ClickTargetType>, DAffine2)>, shape_editor: &ShapeState, document: &DocumentMessageHandler) {
ghost_outline.clear();
for &layer in shape_editor.selected_shape_state.keys() {
// We probably need to collect here
let outline = document.metadata().layer_with_free_points_outline(layer).cloned().collect();
let transform = document.metadata().transform_to_viewport(layer);
// Mirror the Path tool's view (e.g. pre-solidified centerline) when an upstream Path node exists,
// otherwise fall back to the layer's recorded outline geometry
let (outline, transform) = if let Some(targets) = document.network_interface.path_aware_outline_targets(layer) {
(targets, document.metadata().transform_to_viewport_if_feeds(layer, &document.network_interface))
} else {
(
document.metadata().layer_with_free_points_outline(layer).cloned().collect(),
document.metadata().transform_to_viewport(layer),
)
};
ghost_outline.push((outline, transform));
}
}