Fix 'Jitter Points' and 'Sample Polylines' working incorrectly with X or Y scale of 0 content (#3984)

* Fix NaN points produced by Sample Polylines on 0-scaled input

* Fix Jitter Points inverse transform for zero-scale axes and stop resetting stroke transform

* Remove a couple confusing Debug nodes

* Fix edge case

* Update demo art

* Fix order change in Jitter Points causing different results from earlier

* Fix bug in bisect tool

* Break out functionality into helper functions
This commit is contained in:
Keavon Chambers 2026-04-01 22:51:48 -07:00 committed by GitHub
parent 79bf1ab688
commit 1543d974ac
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 135 additions and 67 deletions

File diff suppressed because one or more lines are too long

View File

@ -155,7 +155,7 @@ fn document_node_definitions() -> HashMap<DefinitionIdentifier, DocumentNodeDefi
// TODO: Auto-generate this from its proto node macro // TODO: Auto-generate this from its proto node macro
DocumentNodeDefinition { DocumentNodeDefinition {
identifier: "Monitor", identifier: "Monitor",
category: "Debug", category: "",
node_template: NodeTemplate { node_template: NodeTemplate {
document_node: DocumentNode { document_node: DocumentNode {
implementation: DocumentNodeImplementation::ProtoNode(memo::monitor::IDENTIFIER), implementation: DocumentNodeImplementation::ProtoNode(memo::monitor::IDENTIFIER),
@ -1530,7 +1530,7 @@ fn document_node_definitions() -> HashMap<DefinitionIdentifier, DocumentNodeDefi
}, },
DocumentNodeDefinition { DocumentNodeDefinition {
identifier: "Extract", identifier: "Extract",
category: "Debug", category: "",
node_template: NodeTemplate { node_template: NodeTemplate {
document_node: DocumentNode { document_node: DocumentNode {
implementation: DocumentNodeImplementation::Extract, implementation: DocumentNodeImplementation::Extract,

View File

@ -1822,6 +1822,9 @@ impl ShapeState {
/// Find the `t` value along the path segment we have clicked upon, together with that segment ID. /// Find the `t` value along the path segment we have clicked upon, together with that segment ID.
fn closest_segment(&self, network_interface: &NodeNetworkInterface, layer: LayerNodeIdentifier, position: glam::DVec2, tolerance: f64) -> Option<ClosestSegment> { fn closest_segment(&self, network_interface: &NodeNetworkInterface, layer: LayerNodeIdentifier, position: glam::DVec2, tolerance: f64) -> Option<ClosestSegment> {
let transform = network_interface.document_metadata().transform_to_viewport_if_feeds(layer, network_interface); let transform = network_interface.document_metadata().transform_to_viewport_if_feeds(layer, network_interface);
if transform.matrix2.determinant() == 0. {
return None;
}
let layer_pos = transform.inverse().transform_point2(position); let layer_pos = transform.inverse().transform_point2(position);
let tolerance = tolerance + 0.5; let tolerance = tolerance + 0.5;

View File

@ -85,17 +85,18 @@ pub fn tangent_on_bezpath(bezpath: &BezPath, t_value: TValue, segments_length: O
} }
} }
pub fn sample_polyline_on_bezpath( /// Computes sample locations along a bezpath, returning parametric `(segment_index, t)` pairs and whether the path was closed.
bezpath: BezPath, /// The `bezpath` is used for euclidean-to-parametric conversion, and `segments_length` provides pre-calculated world-space segment lengths.
/// Callers can evaluate these locations on any bezpath with the same topology (e.g., an untransformed version).
pub fn compute_sample_locations(
bezpath: &BezPath,
point_spacing_type: PointSpacingType, point_spacing_type: PointSpacingType,
amount: f64, amount: f64,
start_offset: f64, start_offset: f64,
stop_offset: f64, stop_offset: f64,
adaptive_spacing: bool, adaptive_spacing: bool,
segments_length: &[f64], segments_length: &[f64],
) -> Option<BezPath> { ) -> Option<(Vec<(usize, f64)>, bool)> {
let mut sample_bezpath = BezPath::new();
let was_closed = matches!(bezpath.elements().last(), Some(PathEl::ClosePath)); let was_closed = matches!(bezpath.elements().last(), Some(PathEl::ClosePath));
// Calculate the total length of the collected segments. // Calculate the total length of the collected segments.
@ -142,7 +143,8 @@ pub fn sample_polyline_on_bezpath(
let sample_count_usize = sample_count as usize; let sample_count_usize = sample_count as usize;
let max_i = if was_closed { sample_count_usize } else { sample_count_usize + 1 }; let max_i = if was_closed { sample_count_usize } else { sample_count_usize + 1 };
// Generate points along the path based on calculated intervals. // Generate sample locations along the path based on calculated intervals.
let mut locations = Vec::with_capacity(max_i);
let mut length_up_to_previous_segment = 0.; let mut length_up_to_previous_segment = 0.;
let mut next_segment_index = 0; let mut next_segment_index = 0;
@ -167,20 +169,11 @@ pub fn sample_polyline_on_bezpath(
let segment = bezpath.get_seg(next_segment_index + 1).unwrap(); let segment = bezpath.get_seg(next_segment_index + 1).unwrap();
let t = eval_pathseg_euclidean(segment, t, DEFAULT_ACCURACY); let t = eval_pathseg_euclidean(segment, t, DEFAULT_ACCURACY);
let point = segment.eval(t);
if sample_bezpath.elements().is_empty() { locations.push((next_segment_index, t));
sample_bezpath.move_to(point)
} else {
sample_bezpath.line_to(point)
}
} }
if was_closed { Some((locations, was_closed))
sample_bezpath.close_path();
}
Some(sample_bezpath)
} }
#[derive(Debug, Clone, Copy)] #[derive(Debug, Clone, Copy)]

View File

@ -6,7 +6,7 @@ use core_types::registry::types::{Angle, Length, Multiplier, Percentage, PixelLe
use core_types::table::{Table, TableRow, TableRowMut}; use core_types::table::{Table, TableRow, TableRowMut};
use core_types::transform::Footprint; use core_types::transform::Footprint;
use core_types::{CloneVarArgs, Color, Context, Ctx, ExtractAll, OwnedContextImpl}; use core_types::{CloneVarArgs, Color, Context, Ctx, ExtractAll, OwnedContextImpl};
use glam::{DAffine2, DVec2}; use glam::{DAffine2, DMat2, DVec2};
use graphic_types::Vector; use graphic_types::Vector;
use graphic_types::raster_types::{CPU, GPU, Raster}; use graphic_types::raster_types::{CPU, GPU, Raster};
use graphic_types::{Graphic, IntoGraphicTable}; use graphic_types::{Graphic, IntoGraphicTable};
@ -16,7 +16,7 @@ use rand::{Rng, SeedableRng};
use std::collections::hash_map::DefaultHasher; use std::collections::hash_map::DefaultHasher;
use vector_types::subpath::{BezierHandles, ManipulatorGroup}; use vector_types::subpath::{BezierHandles, ManipulatorGroup};
use vector_types::vector::PointDomain; use vector_types::vector::PointDomain;
use vector_types::vector::algorithms::bezpath_algorithms::{self, TValue, eval_pathseg_euclidean, evaluate_bezpath, sample_polyline_on_bezpath, split_bezpath, tangent_on_bezpath}; use vector_types::vector::algorithms::bezpath_algorithms::{self, TValue, eval_pathseg_euclidean, evaluate_bezpath, split_bezpath, tangent_on_bezpath};
use vector_types::vector::algorithms::merge_by_distance::MergeByDistanceExt; use vector_types::vector::algorithms::merge_by_distance::MergeByDistanceExt;
use vector_types::vector::algorithms::offset_subpath::offset_bezpath; use vector_types::vector::algorithms::offset_subpath::offset_bezpath;
use vector_types::vector::algorithms::spline::{solve_spline_first_handle_closed, solve_spline_first_handle_open}; use vector_types::vector::algorithms::spline::{solve_spline_first_handle_closed, solve_spline_first_handle_open};
@ -1355,11 +1355,12 @@ async fn sample_polyline(
// Keeps track of the index of the first segment of the next bezpath in order to get lengths of all segments. // Keeps track of the index of the first segment of the next bezpath in order to get lengths of all segments.
let mut next_segment_index = 0; let mut next_segment_index = 0;
for mut bezpath in bezpaths { for local_bezpath in bezpaths {
// Apply the tranformation to the current bezpath to calculate points after transformation. // Apply the transform to compute sample locations in world space (for correct distance-based spacing)
bezpath.apply_affine(Affine::new(row.transform.to_cols_array())); let mut world_bezpath = local_bezpath.clone();
world_bezpath.apply_affine(Affine::new(row.transform.to_cols_array()));
let segment_count = bezpath.segments().count(); let segment_count = world_bezpath.segments().count();
// For the current bezpath we get its segment's length by calculating the start index and end index. // For the current bezpath we get its segment's length by calculating the start index and end index.
let current_bezpath_segments_length = &subpath_segment_lengths[next_segment_index..next_segment_index + segment_count]; let current_bezpath_segments_length = &subpath_segment_lengths[next_segment_index..next_segment_index + segment_count];
@ -1371,14 +1372,30 @@ async fn sample_polyline(
PointSpacingType::Separation => separation, PointSpacingType::Separation => separation,
PointSpacingType::Quantity => quantity as f64, PointSpacingType::Quantity => quantity as f64,
}; };
let Some(mut sample_bezpath) = sample_polyline_on_bezpath(bezpath, spacing, amount, start_offset, stop_offset, adaptive_spacing, current_bezpath_segments_length) else {
// Compute sample locations using world-space distances, then evaluate positions on the untransformed bezpath.
// This avoids needing to invert the transform (which fails when the transform is singular, e.g. zero scale).
let Some((locations, was_closed)) =
bezpath_algorithms::compute_sample_locations(&world_bezpath, spacing, amount, start_offset, stop_offset, adaptive_spacing, current_bezpath_segments_length)
else {
continue; continue;
}; };
// Reverse the transformation applied to the bezpath as the `result` already has the transformation set. // Evaluate the sample locations on the untransformed bezpath and append the result
sample_bezpath.apply_affine(Affine::new(row.transform.to_cols_array()).inverse()); let mut sample_bezpath = BezPath::new();
for &(segment_index, t) in &locations {
let segment = local_bezpath.get_seg(segment_index + 1).unwrap();
let point = segment.eval(t);
// Append the bezpath (subpath) that connects generated points by lines. if sample_bezpath.elements().is_empty() {
sample_bezpath.move_to(point);
} else {
sample_bezpath.line_to(point);
}
}
if was_closed {
sample_bezpath.close_path();
}
result.append_bezpath(sample_bezpath); result.append_bezpath(sample_bezpath);
} }
@ -1879,6 +1896,59 @@ async fn spline(_: impl Ctx, content: Table<Vector>) -> Table<Vector> {
.collect() .collect()
} }
/// Computes the inverse of a transform's linear (matrix2) part, handling singular transforms
/// (e.g. zero scale on one axis) by replacing the collapsed axis with a unit perpendicular
/// so offsets still apply there (visible if the transform is later replaced).
fn inverse_linear_or_repair(linear: DMat2) -> DMat2 {
if linear.determinant() != 0. {
return linear.inverse();
}
let col0 = linear.col(0);
let col1 = linear.col(1);
let col0_exists = col0.length_squared() > (f64::EPSILON * 1e3).powi(2);
let col1_exists = col1.length_squared() > (f64::EPSILON * 1e3).powi(2);
let repaired = match (col0_exists, col1_exists) {
(true, _) => DMat2::from_cols(col0, col0.perp().normalize()),
(false, true) => DMat2::from_cols(col1.perp().normalize(), col1),
(false, false) => DMat2::IDENTITY,
};
repaired.inverse()
}
/// Applies per-point displacement deltas to the point and handle positions of a vector element.
fn apply_point_deltas(element: &mut Vector, deltas: &[DVec2], transform: DAffine2) {
let mut already_applied = vec![false; element.point_domain.positions().len()];
for (handles, start, end) in element.segment_domain.handles_and_points_mut() {
let start_delta = deltas[*start];
let end_delta = deltas[*end];
if !already_applied[*start] {
let start_position = element.point_domain.positions()[*start];
element.point_domain.set_position(*start, start_position + start_delta);
already_applied[*start] = true;
}
if !already_applied[*end] {
let end_position = element.point_domain.positions()[*end];
element.point_domain.set_position(*end, end_position + end_delta);
already_applied[*end] = true;
}
match handles {
BezierHandles::Cubic { handle_start, handle_end } => {
*handle_start += start_delta;
*handle_end += end_delta;
}
BezierHandles::Quadratic { handle } => {
*handle = transform.transform_point2(*handle) + (start_delta + end_delta) / 2.;
}
BezierHandles::Linear => {}
}
}
}
/// Perturbs the positions of anchor points in vector geometry by random amounts and directions. /// Perturbs the positions of anchor points in vector geometry by random amounts and directions.
#[node_macro::node(category("Vector: Modifier"), path(core_types::vector))] #[node_macro::node(category("Vector: Modifier"), path(core_types::vector))]
async fn jitter_points( async fn jitter_points(
@ -1899,11 +1969,9 @@ async fn jitter_points(
.into_iter() .into_iter()
.map(|mut row| { .map(|mut row| {
let mut rng = rand::rngs::StdRng::seed_from_u64(seed.into()); let mut rng = rand::rngs::StdRng::seed_from_u64(seed.into());
let inverse_linear = inverse_linear_or_repair(row.transform.matrix2);
let transform = row.transform; let deltas: Vec<_> = (0..row.element.point_domain.positions().len())
let inverse_transform = if transform.matrix2.determinant() != 0. { transform.inverse() } else { Default::default() };
let deltas = (0..row.element.point_domain.positions().len())
.map(|point_index| { .map(|point_index| {
let normal = if along_normals { let normal = if along_normals {
row.element.segment_domain.point_tangent(point_index, row.element.point_domain.positions()).map(|t| t.perp()) row.element.segment_domain.point_tangent(point_index, row.element.point_domain.positions()).map(|t| t.perp())
@ -1912,44 +1980,17 @@ async fn jitter_points(
}; };
let offset = if let Some(normal) = normal { let offset = if let Some(normal) = normal {
(rng.random::<f64>() * 2. - 1.) * normal normal * (rng.random::<f64>() * 2. - 1.)
} else { } else {
rng.random::<f64>() * DVec2::from_angle(rng.random::<f64>() * TAU) DVec2::from_angle(rng.random::<f64>() * TAU) * rng.random::<f64>()
}; };
inverse_transform.transform_vector2(offset * amount) inverse_linear * (offset * amount)
}) })
.collect::<Vec<_>>(); .collect();
let mut already_applied = vec![false; row.element.point_domain.positions().len()];
for (handles, start, end) in row.element.segment_domain.handles_and_points_mut() { apply_point_deltas(&mut row.element, &deltas, row.transform);
let start_delta = deltas[*start];
let end_delta = deltas[*end];
if !already_applied[*start] {
let start_position = row.element.point_domain.positions()[*start];
row.element.point_domain.set_position(*start, start_position + start_delta);
already_applied[*start] = true;
}
if !already_applied[*end] {
let end_position = row.element.point_domain.positions()[*end];
row.element.point_domain.set_position(*end, end_position + end_delta);
already_applied[*end] = true;
}
match handles {
BezierHandles::Cubic { handle_start, handle_end } => {
*handle_start += start_delta;
*handle_end += end_delta;
}
BezierHandles::Quadratic { handle } => {
*handle = row.transform.transform_point2(*handle) + (start_delta + end_delta) / 2.;
}
BezierHandles::Linear => {}
}
}
row.element.style.set_stroke_transform(DAffine2::IDENTITY);
row row
}) })
.collect() .collect()

View File

@ -214,6 +214,36 @@ document.addEventListener("DOMContentLoaded", () => {
commits = fetched; commits = fetched;
} }
async function extendCommitsForward() {
if (commits.length === 0) return false;
const newest = commits[commits.length - 1];
const since = new Date(newest.date.getTime() + 1000).toISOString();
let /** @type {any[]} */ allRaw = [];
let page = 1;
while (true) {
const raw = await fetchCommitList(since, undefined, page);
if (!raw || raw.length === 0) break;
allRaw = allRaw.concat(raw);
if (raw.length < 100) break;
page++;
}
if (allRaw.length === 0) return false;
let fetched = parseCommits(allRaw);
fetched.reverse();
const existingShas = new Set(commits.map((c) => c.sha));
fetched = fetched.filter((c) => !existingShas.has(c.sha));
if (fetched.length === 0) return false;
commits = [...commits, ...fetched];
return true;
}
async function extendCommitsBackward() { async function extendCommitsBackward() {
if (commits.length === 0) return false; if (commits.length === 0) return false;
@ -340,8 +370,9 @@ document.addEventListener("DOMContentLoaded", () => {
badIndex = currentIndex; badIndex = currentIndex;
boundarySearching = true; boundarySearching = true;
} else { } else {
// Absent at starting commit. The newest commit should have it (user assumes master has it). // Absent at starting commit, so the issue was introduced more recently. Extend the commit list forward (towards HEAD) before narrowing.
goodIndex = currentIndex; goodIndex = currentIndex;
await extendCommitsForward();
badIndex = commits.length - 1; badIndex = commits.length - 1;
bisectPhase = "binary"; bisectPhase = "binary";
} }