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
This commit is contained in:
Keavon Chambers
2026-04-01 05:18:56 -07:00
committed by GitHub
parent d41883a942
commit 211b9113a1
3 changed files with 121 additions and 4 deletions

View File

@@ -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<DVec2> {
// 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)