From 211b9113a16212564a88c86a8f2aecc19de35c18 Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Wed, 1 Apr 2026 05:18:56 -0700 Subject: [PATCH] Add the "Along Normals" parameter to the 'Jitter Points' node (#3983) * Add the "Along Normals" parameter to the 'Jitter Points' node * Fix the edge case of a self-loops --- .../messages/portfolio/document_migration.rs | 13 +++ .../src/vector/vector_attributes.rs | 87 +++++++++++++++++++ node-graph/nodes/vector/src/vector_nodes.rs | 25 +++++- 3 files changed, 121 insertions(+), 4 deletions(-) diff --git a/editor/src/messages/portfolio/document_migration.rs b/editor/src/messages/portfolio/document_migration.rs index c82892ad..ff079c85 100644 --- a/editor/src/messages/portfolio/document_migration.rs +++ b/editor/src/messages/portfolio/document_migration.rs @@ -1860,6 +1860,19 @@ fn migrate_node(node_id: &NodeId, node: &DocumentNode, network_path: &[NodeId], .set_input(&InputConnector::node(*node_id, 1), NodeInput::value(TaggedValue::ScaleType(ScaleType::Magnitude), false), network_path); } + // Add the "Along Normals" parameter to the "Jitter Points" node + if reference == DefinitionIdentifier::ProtoNode(graphene_std::vector::jitter_points::IDENTIFIER) && inputs_count == 3 { + let mut node_template = resolve_document_node_type(&reference)?.default_node_template(); + let old_inputs = document.network_interface.replace_inputs(node_id, network_path, &mut node_template)?; + + document.network_interface.set_input(&InputConnector::node(*node_id, 0), old_inputs[0].clone(), network_path); + document.network_interface.set_input(&InputConnector::node(*node_id, 1), old_inputs[1].clone(), network_path); + document.network_interface.set_input(&InputConnector::node(*node_id, 2), old_inputs[2].clone(), network_path); + document + .network_interface + .set_input(&InputConnector::node(*node_id, 3), NodeInput::value(TaggedValue::Bool(false), false), network_path); + } + // ================================== // PUT ALL MIGRATIONS ABOVE THIS LINE // ================================== 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 fdec38cc..765882f9 100644 --- a/node-graph/libraries/vector-types/src/vector/vector_attributes.rs +++ b/node-graph/libraries/vector-types/src/vector/vector_attributes.rs @@ -436,6 +436,93 @@ impl SegmentDomain { self.all_connected(point).next().is_some() } + /// Computes the direction-of-travel tangent at one endpoint of a segment. + /// Uses the "first distinct control point" pattern: iterates through the Bezier control points + /// from the anchor outward, returning the direction to the first one that differs in position. + /// This handles zero-length handles by finding the tangent direction in the limit. + /// Returns `DVec2::ZERO` if all control points coincide (fully degenerate segment). + fn segment_tangent_at_endpoint(&self, segment_index: usize, positions: &[DVec2], at_start: bool) -> DVec2 { + let anchor_start = positions[self.start_point[segment_index]]; + let anchor_end = positions[self.end_point[segment_index]]; + + // Build ordered control points for this segment + let (points, count) = match self.handles[segment_index] { + BezierHandles::Linear => ([anchor_start, anchor_end, DVec2::ZERO, DVec2::ZERO], 2), + BezierHandles::Quadratic { handle } => ([anchor_start, handle, anchor_end, DVec2::ZERO], 3), + BezierHandles::Cubic { handle_start, handle_end } => ([anchor_start, handle_start, handle_end, anchor_end], 4), + }; + + let not_near = |a: DVec2, b: DVec2| a.distance_squared(b) > f64::EPSILON * 1e3; + + if at_start { + let anchor = points[0]; + points[1..count].iter().find(|&&p| not_near(p, anchor)).map_or(DVec2::ZERO, |&point| point - anchor) + } else { + let anchor = points[count - 1]; + points[..count - 1].iter().rev().find(|&&p| not_near(p, anchor)).map_or(DVec2::ZERO, |&point| anchor - point) + } + } + + /// Computes the average tangent direction at a point based on its 1 or 2 connected segments. + /// Returns `None` for points with 0 or 3+ connections (ambiguous or undefined tangent), + /// or if the tangent is degenerate (all control points coincide). + pub fn point_tangent(&self, point_index: usize, positions: &[DVec2]) -> Option { + // Collect connected segments with their relationship to this point (at_start flag) + let mut connections: [(usize, bool); 2] = [(0, false); 2]; + let mut connection_count = 0; + + for (segment_index, (&start, &end)) in self.start_point.iter().zip(&self.end_point).enumerate() { + // Self-loop segments count as two connections (outgoing and incoming) + let is_start = start == point_index; + let is_end = end == point_index; + + if !is_start && !is_end { + continue; + } + + if is_start { + if connection_count >= 2 { + return None; + } + connections[connection_count] = (segment_index, true); + connection_count += 1; + } + if is_end { + if connection_count >= 2 { + return None; + } + connections[connection_count] = (segment_index, false); + connection_count += 1; + } + } + + if connection_count == 0 { + return None; + } + + // Compute the direction-of-travel tangent for the first connection + let (segment_index, at_start) = connections[0]; + let tangent1 = self.segment_tangent_at_endpoint(segment_index, positions, at_start).try_normalize(); + + if connection_count == 1 { + return tangent1; + } + + // Compute the direction-of-travel tangent for the second connection + let (segment_index, at_start) = connections[1]; + let tangent2 = self.segment_tangent_at_endpoint(segment_index, positions, at_start).try_normalize(); + + // Average the two normalized tangents + let average = tangent1? + tangent2?; + + // If the tangents are nearly opposite (straight-through), use t1 directly + if average.length_squared() < (f64::EPSILON * 1e3).powi(2) { + return tangent1; + } + + average.try_normalize() + } + /// Iterates over segments in the domain. /// /// Tuple is: (id, start point, end point, handles) diff --git a/node-graph/nodes/vector/src/vector_nodes.rs b/node-graph/nodes/vector/src/vector_nodes.rs index f33d1a17..69da46a1 100644 --- a/node-graph/nodes/vector/src/vector_nodes.rs +++ b/node-graph/nodes/vector/src/vector_nodes.rs @@ -1879,14 +1879,21 @@ async fn spline(_: impl Ctx, content: Table) -> Table { .collect() } +/// Perturbs the positions of anchor points in vector geometry by random amounts and directions. #[node_macro::node(category("Vector: Modifier"), path(core_types::vector))] async fn jitter_points( _: impl Ctx, + /// The vector geometry with points to be jittered. content: Table, - #[unit(" px")] + /// The maximum extent of the random distance each point can be offset. #[default(5.)] + #[unit(" px")] amount: f64, + /// Seed used to determine unique variations on all randomized offsets. seed: SeedValue, + /// Whether to offset anchor points along their normal direction (perpendicular to the path) or in a random direction. Free-floating and branching points have no normal direction, so they receive a random-angled offset regardless of this setting. + #[default(true)] + along_normals: bool, ) -> Table { content .into_iter() @@ -1897,10 +1904,20 @@ async fn jitter_points( let inverse_transform = if transform.matrix2.determinant() != 0. { transform.inverse() } else { Default::default() }; let deltas = (0..row.element.point_domain.positions().len()) - .map(|_| { - let angle = rng.random::() * TAU; + .map(|point_index| { + let normal = if along_normals { + row.element.segment_domain.point_tangent(point_index, row.element.point_domain.positions()).map(|t| t.perp()) + } else { + None + }; - inverse_transform.transform_vector2(DVec2::from_angle(angle) * rng.random::() * amount) + let offset = if let Some(normal) = normal { + (rng.random::() * 2. - 1.) * normal + } else { + rng.random::() * DVec2::from_angle(rng.random::() * TAU) + }; + + inverse_transform.transform_vector2(offset * amount) }) .collect::>(); let mut already_applied = vec![false; row.element.point_domain.positions().len()];