diff --git a/frontend/src/icons.ts b/frontend/src/icons.ts index 0d00a536809..62f9435b84e 100644 --- a/frontend/src/icons.ts +++ b/frontend/src/icons.ts @@ -272,6 +272,7 @@ import NodeNumbersToVec2 from "/../branding/assets/16x16-nodes/node-numbers-to-v import NodeOffsetPath from "/../branding/assets/16x16-nodes/node-offset-path.svg"; import NodeOffsetPoints from "/../branding/assets/16x16-nodes/node-offset-points.svg"; import NodeOpacity from "/../branding/assets/16x16-nodes/node-opacity.svg"; +import NodeOpenEnds from "/../branding/assets/16x16-nodes/node-open-ends.svg"; import NodeOriginsToPolyline from "/../branding/assets/16x16-nodes/node-origins-to-polyline.svg"; import NodePackStrips from "/../branding/assets/16x16-nodes/node-pack-strips.svg"; import NodePassthrough from "/../branding/assets/16x16-nodes/node-passthrough.svg"; @@ -552,6 +553,7 @@ export const ICON_16X16_NODES = { NodeOffsetPath: { svg: NodeOffsetPath, size: 16 }, NodeOffsetPoints: { svg: NodeOffsetPoints, size: 16 }, NodeOpacity: { svg: NodeOpacity, size: 16 }, + NodeOpenEnds: { svg: NodeOpenEnds, size: 16 }, NodeOriginsToPolyline: { svg: NodeOriginsToPolyline, size: 16 }, NodePackStrips: { svg: NodePackStrips, size: 16 }, NodePassthrough: { svg: NodePassthrough, size: 16 }, diff --git a/node-graph/libraries/vector-types/src/vector/vector_attributes.rs b/node-graph/libraries/vector-types/src/vector/vector_attributes.rs index cb7cbfee095..29619764202 100644 --- a/node-graph/libraries/vector-types/src/vector/vector_attributes.rs +++ b/node-graph/libraries/vector-types/src/vector/vector_attributes.rs @@ -430,9 +430,10 @@ impl SegmentGraph { fn construct_face(&self, adjacency: &[Vec], first: FaceSide, faces: &mut Faces, seen: &mut FaceSideSet) -> Option<()> { faces.start_new_face(); + // A tree's one orbit walks every side, so it takes all `2 * len` iterations to come back around let max_iterations = self.len() * 2; let mut side = first; - for _iteration in 1..max_iterations { + for _iteration in 0..max_iterations { if seen.contains(side) { return None; } @@ -1535,4 +1536,14 @@ mod tests { vector.construct_faces(&paths); } + + #[test] + fn a_branching_tree_has_one_orbit_along_both_sides_of_every_segment() { + let points = [DVec2::new(0., 0.), DVec2::new(0., -10.), DVec2::new(-5., -15.), DVec2::new(5., -15.)]; + let (vector, paths) = build_vector(&points, &[(0, 1), (1, 2), (1, 3)]); + + let orbits = SegmentGraph::new(&vector, &paths).face_orbits(); + assert_eq!(orbits.face_count(), 1); + assert_eq!(orbits.face_sides(0).len(), 6); + } } diff --git a/node-graph/nodes/vector/src/vector_nodes.rs b/node-graph/nodes/vector/src/vector_nodes.rs index 6f0f9a8e0fd..f475bf2cd07 100644 --- a/node-graph/nodes/vector/src/vector_nodes.rs +++ b/node-graph/nodes/vector/src/vector_nodes.rs @@ -1527,6 +1527,8 @@ fn points_to_vector( } /// Lists the positions of the anchor points in the vector content, as placed by its transform. Wiring this into the **Translation** of a **Transform** node places a copy of its content at each point. +/// +/// Each point keeps its attributes, so **Read Attribute** can read them alongside the positions. #[node_macro::node(category("Vector: Point"), icon("NodeVectorToPoints"), name("Vector to Points"), path(core_types::vector))] fn vector_to_points( _: impl Ctx, @@ -1535,9 +1537,36 @@ fn vector_to_points( ) -> List { let transform: DAffine2 = content.attribute_cloned_or_default(ATTR_TRANSFORM); let paths = shared_paths_from_item_attributes(&content); - let positions = content.element().anchor_positions(&paths).into_iter().map(|position| transform.transform_point2(position)).collect(); + let vector = content.element(); + + vector.anchor_indices(&paths).into_iter().filter_map(|index| placed_point(vector, index, transform)).collect() +} + +/// Lists the positions of the open ends in the vector content, where a path stops without meeting another, like the tips of a tree's branches. +/// +/// Each point keeps its attributes, so **Read Attribute** can read them alongside the positions. +#[node_macro::node(category("Vector: Point"), icon("NodeOpenEnds"), path(core_types::vector))] +fn open_ends( + _: impl Ctx, + /// The vector content with paths to find the open ends of. + content: Item, +) -> List { + let transform: DAffine2 = content.attribute_cloned_or_default(ATTR_TRANSFORM); + let paths = shared_paths_from_item_attributes(&content); + let vector = content.element(); + + // An open end is the anchor of exactly one segment, unlike a point along a path or a junction + let connectivity = vector.connectivity(&paths); + (0..vector.point_count()) + .filter(|&index| connectivity.spokes(index as u32) == 1) + .filter_map(|index| placed_point(vector, index, transform)) + .collect() +} - List::new_from_elements(positions) +/// The pool point at `index` with its attributes, placed by the transform. +fn placed_point(vector: &Vector, index: usize, transform: DAffine2) -> Option> { + let (position, attributes) = vector.0.clone_item(index)?.into_parts(); + Some(Item::from_parts(transform.transform_point2(position), attributes)) } /// Creates a polyline from a series of vector points, replacing any existing segments that may already exist. @@ -4267,6 +4296,34 @@ mod test { assert_eq!(positions, [DVec2::new(-15., -13.), DVec2::new(25., -13.), DVec2::new(25., 27.), DVec2::new(-15., 27.)]); } + #[test] + fn vector_to_points_and_open_ends_keep_point_attributes() { + use core_types::list::{ATTR_DEPTH, ATTR_HEADING}; + + // A trunk with a branch to the left, placed 100 px to the right + let tree = crate::turtle::turtle_path("F[+F]F", 10., 90., "FG", "f").with_attribute(ATTR_TRANSFORM, DAffine2::from_translation(DVec2::new(100., 0.))); + + let points = super::vector_to_points((), tree.clone()); + assert_eq!(points.iter_element_values().count(), 4); + assert_eq!( + points.iter_attribute_values::(ATTR_DEPTH).map(|depths| depths.copied().collect::>()), + Some(vec![0, 0, 1, 0]) + ); + + // The root and both tips, but not the junction where the branch leaves the trunk + let ends = super::open_ends((), tree); + let positions: Vec = ends.iter_element_values().copied().collect(); + assert_eq!(positions, [DVec2::new(100., 0.), DVec2::new(90., -10.), DVec2::new(100., -20.)]); + assert_eq!( + ends.iter_attribute_values::(ATTR_HEADING).map(|headings| headings.copied().collect::>()), + Some(vec![-90., 180., -90.]) + ); + + // A closed path has no open ends + let square = crate::turtle::turtle_path("F+F+F+F", 10., 90., "FG", "f"); + assert_eq!(super::open_ends((), square).len(), 0); + } + fn convex_hull_of(bezpath: BezPath, transform: DAffine2, curvature: f64) -> Item { let hull = super::convex_hull((), create_vector_item(bezpath, transform), item(curvature)); assert_eq!(hull.attribute_cloned_or_default::(ATTR_TRANSFORM), transform, "the hull should keep its item's transform");