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
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 121 additions and 4 deletions

View File

@ -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
// ==================================

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)

View File

@ -1879,14 +1879,21 @@ async fn spline(_: impl Ctx, content: Table<Vector>) -> Table<Vector> {
.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<Vector>,
#[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<Vector> {
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::<f64>() * 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::<f64>() * amount)
let offset = if let Some(normal) = normal {
(rng.random::<f64>() * 2. - 1.) * normal
} else {
rng.random::<f64>() * DVec2::from_angle(rng.random::<f64>() * TAU)
};
inverse_transform.transform_vector2(offset * amount)
})
.collect::<Vec<_>>();
let mut already_applied = vec![false; row.element.point_domain.positions().len()];