diff --git a/.branding b/.branding index ab01808d9d4..3f83ae52c1d 100644 --- a/.branding +++ b/.branding @@ -1,2 +1,2 @@ -https://github.com/Keavon/graphite-branded-assets/archive/a40077842627afb0c90858b351112a62b340881d.tar.gz -cd454d750d3f47fb2f41abceeacfc0ae59629ca00229dc56df53e3f4db0f6ee7 +https://github.com/Keavon/graphite-branded-assets/archive/ea42bfc35beadaf716bb9efdb036512511dc2c28.tar.gz +1286b27b897d9bad692fc2897eceb25e3dd62476c7be391cf9521917f7fdf0a1 diff --git a/editor/src/messages/portfolio/document/node_graph/document_node_definitions.rs b/editor/src/messages/portfolio/document/node_graph/document_node_definitions.rs index 604695e0a7f..eb95f203b5a 100644 --- a/editor/src/messages/portfolio/document/node_graph/document_node_definitions.rs +++ b/editor/src/messages/portfolio/document/node_graph/document_node_definitions.rs @@ -356,9 +356,9 @@ fn document_node_definitions() -> HashMap] = &[ aliases: &["graphene_core::vector::CentroidNode"], }, NodeReplacement { - node: graphene_std::vector::close_path::IDENTIFIER, - aliases: &["graphene_core::vector::ClosePathNode"], + node: graphene_std::vector::close_paths::IDENTIFIER, + aliases: &["graphene_core::vector::ClosePathNode", "core_types::vector::ClosePathNode"], }, NodeReplacement { node: graphene_std::vector::list_length::IDENTIFIER, @@ -954,8 +954,8 @@ const NODE_REPLACEMENTS: &[NodeReplacement<'static>] = &[ ], }, NodeReplacement { - node: graphene_std::vector::separate_subpaths::IDENTIFIER, - aliases: &["graphene_core::vector::SeparateSubpathsNode"], + node: graphene_std::vector::separate_paths::IDENTIFIER, + aliases: &["graphene_core::vector::SeparateSubpathsNode", "core_types::vector::SeparateSubpathsNode"], }, NodeReplacement { node: graphene_std::vector::solidify_stroke::IDENTIFIER, @@ -2833,6 +2833,29 @@ fn migrate_node(node_id: &NodeId, node: &DocumentNode, network_path: &[NodeId], reset_default_display_name(document, node_id, network_path, "Split Segments"); } + // Separate Subpaths and Close Path became Separate Paths and Close Paths, whose inputs take their current names and descriptions + let pluralized = [ + (graphene_std::vector::separate_paths::IDENTIFIER, "Separate Subpaths"), + (graphene_std::vector::close_paths::IDENTIFIER, "Close Path"), + ]; + if let Some((_, old_default_name)) = pluralized.iter().find(|(identifier, _)| reference == DefinitionIdentifier::ProtoNode(identifier.clone())) { + let mut node_template = resolve_document_node_type(&reference)?.default_node_template(); + let stored = document + .network_interface + .persistent_input_metadata(node_id, 0, network_path) + .map(|metadata| (metadata.input_name.clone(), metadata.input_description.clone())); + let current = node_template + .input_metadata + .first() + .map(|metadata| (metadata.persistent_metadata.input_name.clone(), metadata.persistent_metadata.input_description.clone())); + if stored != current { + node_template.inputs = node.inputs.clone(); + document.network_interface.replace_inputs(node_id, network_path, &mut node_template)?; + } + + reset_default_display_name(document, node_id, network_path, old_default_name); + } + if reference == DefinitionIdentifier::Network("Sample Points".into()) && inputs_count == 5 { let mut node_template = resolve_network_node_type("Sample Polyline")?.default_node_template(); document.network_interface.replace_implementation(node_id, network_path, &mut node_template); @@ -4497,4 +4520,75 @@ mod tests { let outward_wires = opened.network_interface.outward_wires(&[]).and_then(|wires| wires.get(&OutputConnector::node(upstream, 0)).cloned()); assert_eq!(outward_wires.as_deref(), Some([InputConnector::node_at_index(cut_segments, 0)].as_slice())); } + + // Separate Subpaths and Close Path nodes open under their plural names and current input descriptions, still wired to the node feeding them + #[test] + fn renamed_path_nodes_open_under_their_new_names() { + use crate::messages::portfolio::document_storage_io::load_legacy_document; + use crate::test_utils::capture_logged_errors; + use graph_craft::application_io::resource::HashMapResourceStorage; + + let rectangle = resolve_document_node_type(&DefinitionIdentifier::ProtoNode(graphene_std::vector::generator_nodes::rectangle::IDENTIFIER)).expect("the Rectangle definition should exist"); + let renames = [ + ( + graphene_std::vector::separate_paths::IDENTIFIER, + "SeparatePathsNode", + "Separate Paths", + ["core_types::vector::SeparateSubpathsNode", "graphene_core::vector::SeparateSubpathsNode"], + "Separate Subpaths", + "Content", + ), + ( + graphene_std::vector::close_paths::IDENTIFIER, + "ClosePathsNode", + "Close Paths", + ["core_types::vector::ClosePathNode", "graphene_core::vector::ClosePathNode"], + "Close Path", + "Source", + ), + ]; + + for (identifier, identifier_name, title, [current_identifier, oldest_identifier], old_title, input_name) in renames { + let definition = resolve_document_node_type(&DefinitionIdentifier::ProtoNode(identifier.clone())).expect("the renamed node's definition should exist"); + + // The node under its current identifier and its oldest one, both storing their old title + let legacy_node = |old_identifier: &'static str| NodeTemplate { + implementation: NodeTemplateImplementation::ProtoNode(ProtoNodeIdentifier::new(old_identifier)), + inputs: definition.default_node_template().inputs, + display_name: old_title.to_string(), + input_metadata: vec![(input_name, "").into()], + ..Default::default() + }; + let (upstream, current, oldest) = (NodeId(1), NodeId(2), NodeId(3)); + let mut document = DocumentMessageHandler::default(); + document.network_interface.insert_node(upstream, rectangle.default_node_template(), &[]); + document.network_interface.insert_node(current, legacy_node(current_identifier), &[]); + document.network_interface.insert_node(oldest, legacy_node(oldest_identifier), &[]); + document.network_interface.set_input(&InputConnector::node_at_index(current, 0), NodeInput::node(upstream, 0), &[]); + + let serialized = document.serialize_document(); + assert!(!serialized.contains(identifier_name), "the saved document should name only the old {title} identifiers"); + + let (opened, errors) = capture_logged_errors(|| load_legacy_document(serialized, &HashMapResourceStorage::new(), false)); + let opened = opened.expect("the document should open"); + assert!(errors.is_empty(), "opening the document logged errors: {errors:?}"); + + for node_id in [current, oldest] { + let interface = &opened.network_interface; + let node = interface.document_node(&node_id, &[]).expect("the node should still exist"); + assert_eq!(node.implementation, DocumentNodeImplementation::ProtoNode(identifier.clone()), "{title} node {node_id}"); + assert_eq!(interface.display_name(&node_id, &[]), title, "{title} node {node_id}"); + + let expected = definition + .node_template + .input_metadata + .first() + .map(|metadata| (metadata.persistent_metadata.input_name.clone(), metadata.persistent_metadata.input_description.clone())); + assert_eq!(Some(interface.displayed_input_name_and_description(&node_id, 0, &[])), expected, "{title} node {node_id}"); + } + + let current_content = opened.network_interface.document_node(¤t, &[]).map(|node| node.inputs[0].clone()); + assert!(matches!(current_content, Some(NodeInput::Node { node_id, output_index: 0, .. }) if node_id == upstream), "{title}"); + } + } } diff --git a/frontend/src/icons.ts b/frontend/src/icons.ts index eeefc32e764..baa305d55aa 100644 --- a/frontend/src/icons.ts +++ b/frontend/src/icons.ts @@ -147,7 +147,7 @@ import NodeChannelsToImage from "/../branding/assets/16x16-nodes/node-channels-t import NodeCircle from "/../branding/assets/16x16-nodes/node-circle.svg"; import NodeClamp from "/../branding/assets/16x16-nodes/node-clamp.svg"; import NodeClippingMask from "/../branding/assets/16x16-nodes/node-clipping-mask.svg"; -import NodeClosePath from "/../branding/assets/16x16-nodes/node-close-path.svg"; +import NodeClosePaths from "/../branding/assets/16x16-nodes/node-close-paths.svg"; import NodeColorBalance from "/../branding/assets/16x16-nodes/node-color-balance.svg"; import NodeColorLookup from "/../branding/assets/16x16-nodes/node-color-lookup.svg"; import NodeColorOverlay from "/../branding/assets/16x16-nodes/node-color-overlay.svg"; @@ -320,6 +320,7 @@ import NodeRepeatRadial from "/../branding/assets/16x16-nodes/node-repeat-radial import NodeRepeat from "/../branding/assets/16x16-nodes/node-repeat.svg"; import NodeReplaceTransform from "/../branding/assets/16x16-nodes/node-replace-transform.svg"; import NodeResetTransform from "/../branding/assets/16x16-nodes/node-reset-transform.svg"; +import NodeReversePaths from "/../branding/assets/16x16-nodes/node-reverse-paths.svg"; import NodeReverse from "/../branding/assets/16x16-nodes/node-reverse.svg"; import NodeRgbaToColor from "/../branding/assets/16x16-nodes/node-rgba-to-color.svg"; import NodeRoot from "/../branding/assets/16x16-nodes/node-root.svg"; @@ -329,7 +330,7 @@ import NodeSampleImage from "/../branding/assets/16x16-nodes/node-sample-image.s import NodeSamplePolyline from "/../branding/assets/16x16-nodes/node-sample-polyline.svg"; import NodeScatterPoints from "/../branding/assets/16x16-nodes/node-scatter-points.svg"; import NodeSelectiveColor from "/../branding/assets/16x16-nodes/node-selective-color.svg"; -import NodeSeparateSubpaths from "/../branding/assets/16x16-nodes/node-separate-subpaths.svg"; +import NodeSeparatePaths from "/../branding/assets/16x16-nodes/node-separate-paths.svg"; import NodeSerialize from "/../branding/assets/16x16-nodes/node-serialize.svg"; import NodeShift from "/../branding/assets/16x16-nodes/node-shift.svg"; import NodeShuffle from "/../branding/assets/16x16-nodes/node-shuffle.svg"; @@ -428,7 +429,7 @@ export const ICON_16X16_NODES = { NodeCircle: { svg: NodeCircle, size: 16 }, NodeClamp: { svg: NodeClamp, size: 16 }, NodeClippingMask: { svg: NodeClippingMask, size: 16 }, - NodeClosePath: { svg: NodeClosePath, size: 16 }, + NodeClosePaths: { svg: NodeClosePaths, size: 16 }, NodeColorBalance: { svg: NodeColorBalance, size: 16 }, NodeColorLookup: { svg: NodeColorLookup, size: 16 }, NodeColorOverlay: { svg: NodeColorOverlay, size: 16 }, @@ -602,6 +603,7 @@ export const ICON_16X16_NODES = { NodeReplaceTransform: { svg: NodeReplaceTransform, size: 16 }, NodeResetTransform: { svg: NodeResetTransform, size: 16 }, NodeReverse: { svg: NodeReverse, size: 16 }, + NodeReversePaths: { svg: NodeReversePaths, size: 16 }, NodeRgbaToColor: { svg: NodeRgbaToColor, size: 16 }, NodeRoot: { svg: NodeRoot, size: 16 }, NodeRound: { svg: NodeRound, size: 16 }, @@ -610,7 +612,7 @@ export const ICON_16X16_NODES = { NodeSamplePolyline: { svg: NodeSamplePolyline, size: 16 }, NodeScatterPoints: { svg: NodeScatterPoints, size: 16 }, NodeSelectiveColor: { svg: NodeSelectiveColor, size: 16 }, - NodeSeparateSubpaths: { svg: NodeSeparateSubpaths, size: 16 }, + NodeSeparatePaths: { svg: NodeSeparatePaths, size: 16 }, NodeSerialize: { svg: NodeSerialize, size: 16 }, NodeShift: { svg: NodeShift, size: 16 }, NodeShuffle: { svg: NodeShuffle, size: 16 }, diff --git a/node-graph/libraries/vector-types/src/vector/algorithms/weld.rs b/node-graph/libraries/vector-types/src/vector/algorithms/weld.rs index 9f57b6add38..8f6edd5255b 100644 --- a/node-graph/libraries/vector-types/src/vector/algorithms/weld.rs +++ b/node-graph/libraries/vector-types/src/vector/algorithms/weld.rs @@ -1,6 +1,6 @@ use crate::vector::flatten::WorkingVector; use crate::vector::misc::UnweldMethod; -use crate::vector::path::{CurveType, Path, continues_previous_path, curve_types_of, push_paths}; +use crate::vector::path::{CurveType, Path, curve_types_of, path_flows, push_paths}; use crate::vector::vector_types::largest_point_id; use crate::vector::{PointId, SegmentId, Vector}; use core_types::list::{ATTR_ID, List}; @@ -536,19 +536,6 @@ fn separate_segments(vector: &mut Vector, paths: &mut List) { push_paths(paths, segments); } -/// The path flows, each a range of stored paths: a path, with those after it that only carry it on in another curve type. -fn path_flows(paths: &List) -> Vec> { - let mut flows: Vec> = Vec::new(); - for index in 0..paths.len() { - match flows.last_mut() { - Some(flow) if continues_previous_path(paths, index) => flow.end = index + 1, - _ => flows.push(index..index + 1), - } - } - - flows -} - /// The pool points a path flow begins and ends at. fn path_flow_ends(paths: &List, flow: &Range) -> Option<(usize, usize)> { let start = *paths.element(flow.start)?.0.iter_element_values().as_slice().first()?; diff --git a/node-graph/libraries/vector-types/src/vector/path.rs b/node-graph/libraries/vector-types/src/vector/path.rs index 10f3827528b..2325ac18e19 100644 --- a/node-graph/libraries/vector-types/src/vector/path.rs +++ b/node-graph/libraries/vector-types/src/vector/path.rs @@ -234,6 +234,35 @@ pub fn continues_previous_path(paths: &List, index: usize) -> bool { previous_last > 0 && path.0.element(0) == previous.0.element(previous_last) && curve_type_at(paths, index) != curve_type_at(paths, previous_index) } +/// The path flows, each a range of stored paths: a path, with those after it that only carry it on in another curve type. +pub(crate) fn path_flows(paths: &List) -> Vec> { + let mut flows: Vec> = Vec::new(); + for index in 0..paths.len() { + match flows.last_mut() { + Some(flow) if continues_previous_path(paths, index) => flow.end = index + 1, + _ => flows.push(index..index + 1), + } + } + + flows +} + +/// Reverses every path flow so every path runs the other way, swapping the order of a flow's stored paths so each still sets out where the one before it ends. +pub fn reverse_path_flows(paths: &mut List) { + let flows = path_flows(paths); + let curve_types: Vec = curve_types_of(paths).collect(); + + for (path, &curve_type) in paths.iter_element_values_mut().zip(&curve_types) { + let segment_count = path.segment_count(curve_type); + path.reverse_spans(curve_type, 0..segment_count); + } + + if flows.iter().any(|flow| flow.len() > 1) { + let reordered: List = flows.into_iter().flat_map(|flow| flow.rev()).filter_map(|index| paths.clone_item(index)).collect(); + *paths = reordered; + } +} + /// The IDs a weave gives the anchors and spans it adds, each bezpath's continuing on from the latest taken before it. #[derive(Default)] struct WovenNames { @@ -575,6 +604,28 @@ mod tests { assert_eq!(ragged.0.iter_element_values().copied().collect::>(), vec![2, 13, 12, 0, 11, 10, 4, 14]); } + // Every path turns around with its span IDs, and a flow drawn as a line then a curve swaps its two stored paths so it stays one flow + #[test] + fn reversing_path_flows() { + let mut segment_id = SegmentId::ZERO; + let ids: Vec = (0..5).map(|_| segment_id.next_id()).collect(); + let mut paths = List::new(); + push_path(&mut paths, CurveType::Linear, [0, 1], [ids[0]]); + push_path(&mut paths, CurveType::Cubic, [1, 2, 3, 4], [ids[1]]); + push_path(&mut paths, CurveType::Linear, [5, 6, 7, 5], [ids[2], ids[3], ids[4]]); + + reverse_path_flows(&mut paths); + + let references: Vec> = paths.iter_element_values().map(|path| path.references().collect()).collect(); + assert_eq!(references, [vec![4, 3, 2, 1], vec![1, 0], vec![5, 7, 6, 5]]); + assert_eq!(curve_types_of(&paths).collect::>(), [CurveType::Cubic, CurveType::Linear, CurveType::Linear]); + assert!(continues_previous_path(&paths, 1)); + + let id_at = |path: usize, reference: usize| paths.element(path).and_then(|path| path.0.attribute::(ATTR_ID, reference).copied()); + assert_eq!([id_at(0, 0), id_at(1, 0)], [Some(ids[1]), Some(ids[0])]); + assert_eq!([id_at(2, 0), id_at(2, 1), id_at(2, 2)], [Some(ids[4]), Some(ids[3]), Some(ids[2])]); + } + // A reference naming a point the pool does not have drops its segment instead of panicking #[test] fn dangling_reference_skips_its_segment() { diff --git a/node-graph/libraries/vector-types/src/vector/vector_types.rs b/node-graph/libraries/vector-types/src/vector/vector_types.rs index b02f3c5978e..959b08c1fb3 100644 --- a/node-graph/libraries/vector-types/src/vector/vector_types.rs +++ b/node-graph/libraries/vector-types/src/vector/vector_types.rs @@ -1,6 +1,7 @@ use super::addressing; +use super::algorithms::consts::CLOSE_POINT_TOLERANCE; use super::connectivity::Connectivity; -use super::contour::{contour_bezpaths, contours}; +use super::contour::{Contour, contour_bezpaths, contours}; use super::misc::{HandleId, ManipulatorPointId, dvec2_to_point}; use super::path::{ CurveType, Path, append_named_bezpaths, curve_type_at, curve_types_of, no_paths, paths_from_item_attributes, paths_from_list_row_attributes, push_path, shared_paths_from_item_attributes, @@ -661,12 +662,23 @@ impl Vector { } /// Welds every open contour shut with a linear segment from its last point back to its first, stored where the contour runs on into it. + /// A contour already ending on its start, within floating point noise, merges that end into the start instead. pub fn close_subpaths(&mut self, paths: &mut List) { + let mut open: Vec = contours(self.point_count(), paths).into_iter().filter(|contour| !contour.closed).collect(); + + // An end resting on its own start is that point, so it merges in rather than drawing a closing segment of no length + let positions = self.0.iter_element_values().as_slice(); + let rests_on_start = |contour: &&Contour| matches!((positions.get(contour.entry as usize), positions.get(contour.exit as usize)), (Some(entry), Some(exit)) if entry.distance_squared(*exit) <= CLOSE_POINT_TOLERANCE * CLOSE_POINT_TOLERANCE); + let resting: Vec<(u32, u32)> = open.iter().filter(rests_on_start).map(|contour| (contour.exit, contour.entry)).collect(); + if !resting.is_empty() { + self.merge_points(paths, &resting); + open = contours(self.point_count(), paths).into_iter().filter(|contour| !contour.closed).collect(); + } + // A closing segment sits after the contour's last stored path, or before it when the contour crosses that path // backward, since that is the neighbor the contour reads on into. Either way it is read from the contour's exit. - let closings: Vec<(usize, bool, [u32; 2])> = contours(self.point_count(), paths) + let closings: Vec<(usize, bool, [u32; 2])> = open .into_iter() - .filter(|contour| !contour.closed) .filter_map(|contour| { let last = contour.parts.last()?; Some(if last.reversed { @@ -711,6 +723,49 @@ impl Vector { *paths = rebuilt; } + /// Merges the first point of each pair into the second, renaming every reference to it and dropping it from the pool, in O(points + references). + fn merge_points(&mut self, paths: &mut List, merges: &[(u32, u32)]) { + let point_count = self.0.len(); + + // Each point's representative is found by following merges to a point merged into nothing, which settles chains and cycles alike + let mut merged_into: Vec = (0..point_count).collect(); + let representative = |merged_into: &mut Vec, mut point: usize| { + while merged_into[point] != point { + merged_into[point] = merged_into[merged_into[point]]; + point = merged_into[point]; + } + point + }; + for &(from, to) in merges { + let (from, to) = (from as usize, to as usize); + if from >= point_count || to >= point_count { + continue; + } + + let (from, to) = (representative(&mut merged_into, from), representative(&mut merged_into, to)); + merged_into[from] = to; + } + let representatives: Vec = (0..point_count).map(|point| representative(&mut merged_into, point)).collect(); + + // Kept points are renumbered past those dropped, and every reference names its point's representative + let mut renumbered = Vec::with_capacity(point_count); + let mut kept_count = 0; + for (point, &representative) in representatives.iter().enumerate() { + renumbered.push(kept_count); + kept_count += usize::from(representative == point); + } + for path in paths.iter_element_values_mut() { + for reference in path.0.iter_element_values_mut() { + if let Some(&representative) = usize::try_from(*reference).ok().and_then(|point| representatives.get(point)) { + *reference = renumbered[representative] as i64; + } + } + } + + let kept = representatives.iter().enumerate().map(|(point, &representative)| representative == point); + self.0 = std::mem::take(&mut self.0).into_iter().zip(kept).filter_map(|(point, kept)| kept.then_some(point)).collect(); + } + /// Appends another vector's points and paths, remapping IDs that collide with this vector's. pub fn concat(&mut self, paths: &mut List, additional: &Self, additional_paths: &List, transform_of_additional: DAffine2, collision_hash_seed: u64) { let offset = self.0.len() as u32; @@ -1136,4 +1191,52 @@ mod tests { vector.close_subpaths(&mut paths); assert_eq!(paths.len(), 6); } + + // An end resting on its own start, within floating point noise, merges into it instead of adding a closing segment, while a wider gap still gets one + #[test] + fn close_subpaths_merges_an_end_resting_on_its_start() { + for (gap, merges) in [(0., true), (1e-9, true), (1e-3, false)] { + let mut vector = Vector::default(); + for position in [(0., 0.), (10., 0.), (10., 10.), (gap, 0.)] { + vector.0.push(Item::new_from_element(DVec2::from(position))); + } + let mut paths = List::new(); + push_path(&mut paths, CurveType::Linear, [0, 1, 2, 3], []); + + vector.close_subpaths(&mut paths); + + let references: Vec> = paths.iter_element_values().map(|path| path.references().collect()).collect(); + if merges { + assert_eq!(references, [vec![0, 1, 2, 0]], "gap {gap}"); + assert_eq!(vector.point_count(), 3, "gap {gap}"); + } else { + assert_eq!(references, [vec![0, 1, 2, 3], vec![3, 0]], "gap {gap}"); + assert_eq!(vector.point_count(), 4, "gap {gap}"); + } + assert!(contours(vector.point_count(), &paths).iter().all(|contour| contour.closed), "gap {gap}"); + } + } + + // Paths ending where a merged end was still meet the closed path there, at the start it merged into + #[test] + fn close_subpaths_keeps_paths_meeting_a_merged_end() { + let mut vector = Vector::default(); + for position in [(0., 0.), (10., 0.), (10., 10.), (0., 0.), (-10., 0.), (0., -10.)] { + vector.0.push(Item::new_from_element(DVec2::from(position))); + } + let mut paths = List::new(); + for references in [vec![0, 1, 2, 3], vec![4, 3], vec![5, 3]] { + push_path(&mut paths, CurveType::Linear, references, []); + } + + vector.close_subpaths(&mut paths); + + assert_eq!(vector.point_count(), 5); + let references: Vec> = paths.iter_element_values().map(|path| path.references().collect()).collect(); + assert_eq!(references[0], [0, 1, 2, 0]); + assert!( + references.iter().filter(|path| path.contains(&3)).all(|path| path.contains(&0)), + "the side paths end at the merged start" + ); + } } diff --git a/node-graph/nodes/vector/src/vector_nodes.rs b/node-graph/nodes/vector/src/vector_nodes.rs index 2dc37328fce..8bfe10403d1 100644 --- a/node-graph/nodes/vector/src/vector_nodes.rs +++ b/node-graph/nodes/vector/src/vector_nodes.rs @@ -36,7 +36,7 @@ use vector_types::vector::misc::{ use vector_types::vector::style::{DashPattern, Gradient, GradientSettings, Stroke, StrokeAlign, StrokeCap, StrokeJoin}; use vector_types::vector::{ CurveType, Path, PointId, SegmentId, WorkingVector, curve_type_at, edit_item_geometry, edit_item_points, handle_point, no_paths, paths_from_item_attributes, paths_from_list_row_attributes, - push_path, shared_paths_from_item_attributes, span_endpoints, spans, + push_path, reverse_path_flows, shared_paths_from_item_attributes, span_endpoints, spans, }; /// Appends one point to the working form, returning the index that names it. @@ -1893,13 +1893,19 @@ fn solidify_stroke(_: impl Ctx, #[implementations(Grap V::solidify_strokes(content) } -#[node_macro::node(category("Vector: Modifier"), icon("NodeSeparateSubpaths"), path(core_types::vector))] -fn separate_subpaths(_: impl Ctx, #[implementations(Graphic, Vector)] content: Item) -> List { +/// Splits the vector into separate items, one per path. +#[node_macro::node(category("Vector: Modifier"), icon("NodeSeparatePaths"), path(core_types::vector))] +fn separate_paths( + _: impl Ctx, + /// The vector with paths to separate. + #[implementations(Graphic, Vector)] + content: Item, +) -> List { V::expand_vector_items(content, |content| { let content_paths = shared_paths_from_item_attributes(&content); let bezpaths = content.element().stroke_bezpath_iter(&content_paths).collect::>(); - // Pass the original element through unchanged when it has no subpaths, so its attributes + // Pass the original element through unchanged when it has no paths, so its attributes // (such as the layer transform) survive downstream rather than being dropped along with the empty list. if bezpaths.is_empty() { return List::new_from_item(content); @@ -3652,14 +3658,34 @@ fn bevel( }) } -#[node_macro::node(category("Vector: Modifier"), icon("NodeClosePath"), path(core_types::vector))] -fn close_path(_: impl Ctx, #[implementations(Graphic, Vector)] source: Item) -> Item { - V::map_vector_items(source, |mut item| { +/// Closes every open path with a straight segment from its end back to its start, or by joining the two where they already meet. +#[node_macro::node(category("Vector: Modifier"), icon("NodeClosePaths"), path(core_types::vector))] +fn close_paths( + _: impl Ctx, + /// The vector with paths to close. + #[implementations(Graphic, Vector)] + content: Item, +) -> Item { + V::map_vector_items(content, |mut item| { edit_item_geometry(&mut item, |vector, paths| vector.close_subpaths(paths)); item }) } +/// Reverses the direction every path runs in. +#[node_macro::node(category("Vector: Modifier"), icon("NodeReversePaths"), path(core_types::vector))] +fn reverse_paths( + _: impl Ctx, + /// The vector with paths to reverse. + #[implementations(Graphic, Vector)] + content: Item, +) -> Item { + V::map_vector_items(content, |mut item| { + edit_item_geometry(&mut item, |_, paths| reverse_path_flows(paths)); + item + }) +} + #[node_macro::node(category("Vector: Measure"), icon("NodePointInside"), path(core_types::vector))] fn point_inside(_: impl Ctx, source: Item, point: Item) -> Item { let point = point.into_element(); @@ -4679,9 +4705,9 @@ mod test { assert!(*super::path_is_closed((), diamond.clone(), item(0)).element()); assert!(!*super::path_is_closed((), unwelded.clone(), item(0)).element()); - assert_eq!(super::separate_subpaths((), diamond.clone()).len(), 1); - assert_eq!(super::separate_subpaths((), unwelded.clone()).len(), 2); - assert_eq!(super::separate_subpaths((), super::unweld((), diamond.clone(), item(UnweldMethod::All))).len(), 4); + assert_eq!(super::separate_paths((), diamond.clone()).len(), 1); + assert_eq!(super::separate_paths((), unwelded.clone()).len(), 2); + assert_eq!(super::separate_paths((), super::unweld((), diamond.clone(), item(UnweldMethod::All))).len(), 4); // Halfway along the whole contour is the diamond's far tip, while halfway along the first path alone is its left corner let halfway = super::evaluate_path((), diamond, item(0.5), item(false), item(false));