Skip to content

Commit 211b911

Browse files
authored
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
1 parent d41883a commit 211b911

File tree

3 files changed

+122
-5
lines changed

3 files changed

+122
-5
lines changed

editor/src/messages/portfolio/document_migration.rs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1860,6 +1860,19 @@ fn migrate_node(node_id: &NodeId, node: &DocumentNode, network_path: &[NodeId],
18601860
.set_input(&InputConnector::node(*node_id, 1), NodeInput::value(TaggedValue::ScaleType(ScaleType::Magnitude), false), network_path);
18611861
}
18621862

1863+
// Add the "Along Normals" parameter to the "Jitter Points" node
1864+
if reference == DefinitionIdentifier::ProtoNode(graphene_std::vector::jitter_points::IDENTIFIER) && inputs_count == 3 {
1865+
let mut node_template = resolve_document_node_type(&reference)?.default_node_template();
1866+
let old_inputs = document.network_interface.replace_inputs(node_id, network_path, &mut node_template)?;
1867+
1868+
document.network_interface.set_input(&InputConnector::node(*node_id, 0), old_inputs[0].clone(), network_path);
1869+
document.network_interface.set_input(&InputConnector::node(*node_id, 1), old_inputs[1].clone(), network_path);
1870+
document.network_interface.set_input(&InputConnector::node(*node_id, 2), old_inputs[2].clone(), network_path);
1871+
document
1872+
.network_interface
1873+
.set_input(&InputConnector::node(*node_id, 3), NodeInput::value(TaggedValue::Bool(false), false), network_path);
1874+
}
1875+
18631876
// ==================================
18641877
// PUT ALL MIGRATIONS ABOVE THIS LINE
18651878
// ==================================

node-graph/libraries/vector-types/src/vector/vector_attributes.rs

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -436,6 +436,93 @@ impl SegmentDomain {
436436
self.all_connected(point).next().is_some()
437437
}
438438

439+
/// Computes the direction-of-travel tangent at one endpoint of a segment.
440+
/// Uses the "first distinct control point" pattern: iterates through the Bezier control points
441+
/// from the anchor outward, returning the direction to the first one that differs in position.
442+
/// This handles zero-length handles by finding the tangent direction in the limit.
443+
/// Returns `DVec2::ZERO` if all control points coincide (fully degenerate segment).
444+
fn segment_tangent_at_endpoint(&self, segment_index: usize, positions: &[DVec2], at_start: bool) -> DVec2 {
445+
let anchor_start = positions[self.start_point[segment_index]];
446+
let anchor_end = positions[self.end_point[segment_index]];
447+
448+
// Build ordered control points for this segment
449+
let (points, count) = match self.handles[segment_index] {
450+
BezierHandles::Linear => ([anchor_start, anchor_end, DVec2::ZERO, DVec2::ZERO], 2),
451+
BezierHandles::Quadratic { handle } => ([anchor_start, handle, anchor_end, DVec2::ZERO], 3),
452+
BezierHandles::Cubic { handle_start, handle_end } => ([anchor_start, handle_start, handle_end, anchor_end], 4),
453+
};
454+
455+
let not_near = |a: DVec2, b: DVec2| a.distance_squared(b) > f64::EPSILON * 1e3;
456+
457+
if at_start {
458+
let anchor = points[0];
459+
points[1..count].iter().find(|&&p| not_near(p, anchor)).map_or(DVec2::ZERO, |&point| point - anchor)
460+
} else {
461+
let anchor = points[count - 1];
462+
points[..count - 1].iter().rev().find(|&&p| not_near(p, anchor)).map_or(DVec2::ZERO, |&point| anchor - point)
463+
}
464+
}
465+
466+
/// Computes the average tangent direction at a point based on its 1 or 2 connected segments.
467+
/// Returns `None` for points with 0 or 3+ connections (ambiguous or undefined tangent),
468+
/// or if the tangent is degenerate (all control points coincide).
469+
pub fn point_tangent(&self, point_index: usize, positions: &[DVec2]) -> Option<DVec2> {
470+
// Collect connected segments with their relationship to this point (at_start flag)
471+
let mut connections: [(usize, bool); 2] = [(0, false); 2];
472+
let mut connection_count = 0;
473+
474+
for (segment_index, (&start, &end)) in self.start_point.iter().zip(&self.end_point).enumerate() {
475+
// Self-loop segments count as two connections (outgoing and incoming)
476+
let is_start = start == point_index;
477+
let is_end = end == point_index;
478+
479+
if !is_start && !is_end {
480+
continue;
481+
}
482+
483+
if is_start {
484+
if connection_count >= 2 {
485+
return None;
486+
}
487+
connections[connection_count] = (segment_index, true);
488+
connection_count += 1;
489+
}
490+
if is_end {
491+
if connection_count >= 2 {
492+
return None;
493+
}
494+
connections[connection_count] = (segment_index, false);
495+
connection_count += 1;
496+
}
497+
}
498+
499+
if connection_count == 0 {
500+
return None;
501+
}
502+
503+
// Compute the direction-of-travel tangent for the first connection
504+
let (segment_index, at_start) = connections[0];
505+
let tangent1 = self.segment_tangent_at_endpoint(segment_index, positions, at_start).try_normalize();
506+
507+
if connection_count == 1 {
508+
return tangent1;
509+
}
510+
511+
// Compute the direction-of-travel tangent for the second connection
512+
let (segment_index, at_start) = connections[1];
513+
let tangent2 = self.segment_tangent_at_endpoint(segment_index, positions, at_start).try_normalize();
514+
515+
// Average the two normalized tangents
516+
let average = tangent1? + tangent2?;
517+
518+
// If the tangents are nearly opposite (straight-through), use t1 directly
519+
if average.length_squared() < (f64::EPSILON * 1e3).powi(2) {
520+
return tangent1;
521+
}
522+
523+
average.try_normalize()
524+
}
525+
439526
/// Iterates over segments in the domain.
440527
///
441528
/// Tuple is: (id, start point, end point, handles)

node-graph/nodes/vector/src/vector_nodes.rs

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1879,14 +1879,21 @@ async fn spline(_: impl Ctx, content: Table<Vector>) -> Table<Vector> {
18791879
.collect()
18801880
}
18811881

1882+
/// Perturbs the positions of anchor points in vector geometry by random amounts and directions.
18821883
#[node_macro::node(category("Vector: Modifier"), path(core_types::vector))]
18831884
async fn jitter_points(
18841885
_: impl Ctx,
1886+
/// The vector geometry with points to be jittered.
18851887
content: Table<Vector>,
1886-
#[unit(" px")]
1888+
/// The maximum extent of the random distance each point can be offset.
18871889
#[default(5.)]
1890+
#[unit(" px")]
18881891
amount: f64,
1892+
/// Seed used to determine unique variations on all randomized offsets.
18891893
seed: SeedValue,
1894+
/// 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.
1895+
#[default(true)]
1896+
along_normals: bool,
18901897
) -> Table<Vector> {
18911898
content
18921899
.into_iter()
@@ -1897,10 +1904,20 @@ async fn jitter_points(
18971904
let inverse_transform = if transform.matrix2.determinant() != 0. { transform.inverse() } else { Default::default() };
18981905

18991906
let deltas = (0..row.element.point_domain.positions().len())
1900-
.map(|_| {
1901-
let angle = rng.random::<f64>() * TAU;
1902-
1903-
inverse_transform.transform_vector2(DVec2::from_angle(angle) * rng.random::<f64>() * amount)
1907+
.map(|point_index| {
1908+
let normal = if along_normals {
1909+
row.element.segment_domain.point_tangent(point_index, row.element.point_domain.positions()).map(|t| t.perp())
1910+
} else {
1911+
None
1912+
};
1913+
1914+
let offset = if let Some(normal) = normal {
1915+
(rng.random::<f64>() * 2. - 1.) * normal
1916+
} else {
1917+
rng.random::<f64>() * DVec2::from_angle(rng.random::<f64>() * TAU)
1918+
};
1919+
1920+
inverse_transform.transform_vector2(offset * amount)
19041921
})
19051922
.collect::<Vec<_>>();
19061923
let mut already_applied = vec![false; row.element.point_domain.positions().len()];

0 commit comments

Comments
 (0)