mirror of
https://github.com/GraphiteEditor/Graphite.git
synced 2026-09-15 22:28:10 +08:00
Merge origin/master into the async record refactor
Scaffolding merge for the reconcile; the final series to master is authored fresh. Rank plumbing resolves to our axis-IR model, the node macro and the LaneSource render walk stay ours, master's vector restructure and gradient vocabulary are adopted, and the paint and appearance adoption is deliberately deferred behind our fill and stroke markers.
This commit is contained in:
@@ -24,6 +24,7 @@ repeat-nodes = { workspace = true }
|
||||
dyn-any = { workspace = true }
|
||||
glam = { workspace = true }
|
||||
kurbo = { workspace = true }
|
||||
delaunator = { workspace = true }
|
||||
rand = { workspace = true }
|
||||
rustc-hash = { workspace = true }
|
||||
log = { workspace = true }
|
||||
|
||||
@@ -3,44 +3,12 @@ use core_types::{CacheHash, Ctx};
|
||||
use dyn_any::DynAny;
|
||||
use glam::DVec2;
|
||||
use graphic_types::Vector;
|
||||
use vector_types::subpath;
|
||||
use vector_types::vector::misc::{ArcType, AsU64, GridType};
|
||||
use vector_types::vector::VectorExt;
|
||||
use vector_types::vector::algorithms::shapes;
|
||||
use vector_types::vector::misc::BezierHandles;
|
||||
use vector_types::vector::misc::{ArcType, AsU64, BoxCorners, GridType};
|
||||
use vector_types::vector::misc::{HandleId, SpiralType};
|
||||
use vector_types::vector::{PointId, SegmentId, StrokeId};
|
||||
|
||||
/// Expands the corner-radius lanes to four corners using the CSS
|
||||
/// `border-radius` shorthand rules, then builds the rounded rectangle.
|
||||
/// - `[a]` (also a plain scalar radius) expands to `[a, a, a, a]`
|
||||
/// - `[a, b]` expands to `[a, b, a, b]`
|
||||
/// - `[a, b, c]` expands to `[a, b, c, b]`
|
||||
/// - `[a, b, c, d, …]` truncates to `[a, b, c, d]`
|
||||
/// - `[]` expands to `[0, 0, 0, 0]`
|
||||
fn rounded_rectangle(values: &[f64], size: DVec2, clamped: bool) -> Vector {
|
||||
let radii: [f64; 4] = match values {
|
||||
[] => [0., 0., 0., 0.],
|
||||
&[a] => [a, a, a, a],
|
||||
&[a, b] => [a, b, a, b],
|
||||
&[a, b, c] => [a, b, c, b],
|
||||
&[a, b, c, d, ..] => [a, b, c, d],
|
||||
};
|
||||
|
||||
let clamped_radius = if clamped {
|
||||
// Algorithm follows the CSS spec: <https://drafts.csswg.org/css-backgrounds/#corner-overlap>
|
||||
|
||||
let mut scale_factor: f64 = 1.;
|
||||
for i in 0..4 {
|
||||
let side_length = if i % 2 == 0 { size.x } else { size.y };
|
||||
let adjacent_corner_radius_sum = radii[i] + radii[(i + 1) % 4];
|
||||
if side_length < adjacent_corner_radius_sum {
|
||||
scale_factor = scale_factor.min(side_length / adjacent_corner_radius_sum);
|
||||
}
|
||||
}
|
||||
radii.map(|x| x * scale_factor)
|
||||
} else {
|
||||
radii
|
||||
};
|
||||
Vector::from_subpath(subpath::Subpath::new_rounded_rectangle(size / -2., size / 2., clamped_radius))
|
||||
}
|
||||
use vector_types::vector::{PointId, SegmentId};
|
||||
|
||||
/// Generates a circle shape with a chosen radius.
|
||||
#[node_macro::node(category("Vector: Shape"))]
|
||||
@@ -52,7 +20,7 @@ fn circle(
|
||||
radius: f64,
|
||||
) -> Vector {
|
||||
let radius = radius.abs();
|
||||
Vector::from_subpath(subpath::Subpath::new_ellipse(DVec2::splat(-radius), DVec2::splat(radius)))
|
||||
Vector::from_bezpath(shapes::ellipse_bezpath(DVec2::splat(-radius), DVec2::splat(radius)))
|
||||
}
|
||||
|
||||
/// Generates an arc shape forming a portion of a circle which may be open, closed, or a pie slice.
|
||||
@@ -70,15 +38,11 @@ fn arc(
|
||||
sweep_angle: Angle,
|
||||
arc_type: ArcType,
|
||||
) -> Vector {
|
||||
Vector::from_subpath(subpath::Subpath::new_arc(
|
||||
Vector::from_bezpath(shapes::arc_bezpath(
|
||||
radius,
|
||||
start_angle / 360. * std::f64::consts::TAU,
|
||||
sweep_angle / 360. * std::f64::consts::TAU,
|
||||
match arc_type {
|
||||
ArcType::Open => subpath::ArcType::Open,
|
||||
ArcType::Closed => subpath::ArcType::Closed,
|
||||
ArcType::PieSlice => subpath::ArcType::PieSlice,
|
||||
},
|
||||
arc_type,
|
||||
))
|
||||
}
|
||||
|
||||
@@ -94,7 +58,7 @@ fn spiral(
|
||||
#[default(25)] outer_radius: f64,
|
||||
#[default(90.)] angular_resolution: f64,
|
||||
) -> Vector {
|
||||
Vector::from_subpath(subpath::Subpath::new_spiral(
|
||||
Vector::from_bezpath(shapes::spiral_bezpath(
|
||||
inner_radius,
|
||||
outer_radius,
|
||||
turns,
|
||||
@@ -120,7 +84,7 @@ fn ellipse(
|
||||
let corner1 = -radius;
|
||||
let corner2 = radius;
|
||||
|
||||
let mut ellipse = Vector::from_subpath(subpath::Subpath::new_ellipse(corner1, corner2));
|
||||
let mut ellipse = Vector::from_bezpath(shapes::ellipse_bezpath(corner1, corner2));
|
||||
|
||||
let len = ellipse.segment_domain.ids().len();
|
||||
for i in 0..len {
|
||||
@@ -143,12 +107,43 @@ fn rectangle(
|
||||
#[unit(" px")]
|
||||
#[default(100)]
|
||||
height: f64,
|
||||
_individual_corner_radii: bool, // TODO: Move this to the bottom once we have a migration capability
|
||||
corner_radius: IList<f64>,
|
||||
corner_radius: BoxCorners,
|
||||
#[default(true)] clamped: bool,
|
||||
_individual_corner_radii: bool,
|
||||
) -> Vector {
|
||||
let values: Vec<f64> = (0..corner_radius.len()).map(|index| corner_radius.get(index)).collect();
|
||||
rounded_rectangle(&values, DVec2::new(width, height), clamped)
|
||||
let size = DVec2::new(width, height);
|
||||
let radii = corner_radius.to_corner_values();
|
||||
|
||||
// Scale down overlapping adjacent radii to fit, following the CSS spec: <https://drafts.csswg.org/css-backgrounds/#corner-overlap>
|
||||
let radii = if clamped {
|
||||
let radii = radii.map(|radius| radius.max(0.));
|
||||
|
||||
let mut scale_factor: f64 = 1.;
|
||||
for i in 0..4 {
|
||||
let side_length = if i % 2 == 0 { size.x } else { size.y };
|
||||
let adjacent_corner_radius_sum = radii[i] + radii[(i + 1) % 4];
|
||||
if side_length < adjacent_corner_radius_sum {
|
||||
scale_factor = scale_factor.min((side_length / adjacent_corner_radius_sum).max(0.));
|
||||
}
|
||||
}
|
||||
|
||||
radii.map(|radius| radius * scale_factor)
|
||||
} else {
|
||||
radii
|
||||
};
|
||||
|
||||
Vector::from_bezpath(shapes::rounded_rectangle_bezpath(size / -2., size / 2., radii))
|
||||
}
|
||||
|
||||
/// Builds a set of four corner values, such as a rectangle's corner radii, from a list of one, two, three, or four values.
|
||||
#[node_macro::node(category("Vector: Shape"))]
|
||||
fn box_corners(
|
||||
_: impl Ctx,
|
||||
/// The corner values, filling the four corners clockwise from the top-left. Give one value for all corners, two for opposite pairs, three for top-left, the two sides, then bottom-right, or four for each corner.
|
||||
values: IList<f64>,
|
||||
) -> BoxCorners {
|
||||
let values: Vec<f64> = (0..values.len()).map(|index| values.get(index)).collect();
|
||||
BoxCorners::from(values)
|
||||
}
|
||||
|
||||
/// Generates an regular polygon shape like a triangle, square, pentagon, hexagon, heptagon, octagon, or any higher n-gon.
|
||||
@@ -165,8 +160,7 @@ fn regular_polygon<T: AsU64>(
|
||||
radius: f64,
|
||||
) -> Vector {
|
||||
let points = sides.as_u64();
|
||||
let radius: f64 = radius * 2.;
|
||||
Vector::from_subpath(subpath::Subpath::new_regular_polygon(DVec2::splat(-radius), points, radius))
|
||||
Vector::from_bezpath(shapes::regular_polygon_bezpath(DVec2::ZERO, points, radius))
|
||||
}
|
||||
|
||||
/// Generates an n-pointed star shape with inner and outer points at chosen radii from the center.
|
||||
@@ -186,10 +180,7 @@ fn star<T: AsU64>(
|
||||
radius_2: f64,
|
||||
) -> Vector {
|
||||
let points = sides.as_u64();
|
||||
let diameter: f64 = radius_1 * 2.;
|
||||
let inner_diameter = radius_2 * 2.;
|
||||
|
||||
Vector::from_subpath(subpath::Subpath::new_star_polygon(DVec2::splat(-diameter), points, diameter, inner_diameter))
|
||||
Vector::from_bezpath(shapes::star_polygon_bezpath(DVec2::ZERO, points, radius_1, radius_2))
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
|
||||
@@ -242,11 +233,7 @@ fn qr_code(
|
||||
for x in 0..dimension {
|
||||
if qr_code.get_module(x as i32, y as i32) {
|
||||
let corner1 = DVec2::new(x as f64, y as f64);
|
||||
let corner2 = corner1 + DVec2::splat(1.);
|
||||
vector.append_subpath(
|
||||
subpath::Subpath::from_anchors([corner1, DVec2::new(corner2.x, corner1.y), corner2, DVec2::new(corner1.x, corner2.y)], true),
|
||||
false,
|
||||
);
|
||||
vector.append_bezpath(shapes::rectangle_bezpath(corner1, corner1 + DVec2::splat(1.)));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -273,12 +260,12 @@ fn arrow(
|
||||
#[default(30)] head_width: PixelLength,
|
||||
#[default(20)] head_length: PixelLength,
|
||||
) -> Vector {
|
||||
Vector::from_subpath(subpath::Subpath::new_arrow(DVec2::ZERO, arrow_to, shaft_width, head_width, head_length))
|
||||
Vector::from_bezpath(shapes::arrow_bezpath(DVec2::ZERO, arrow_to, shaft_width, head_width, head_length))
|
||||
}
|
||||
|
||||
#[node_macro::node(category("Vector: Shape"))]
|
||||
fn line(_: impl Ctx, _primary: (), #[default(100., 100.)] line_to: PixelSize) -> Vector {
|
||||
Vector::from_subpath(subpath::Subpath::new_line(DVec2::ZERO, line_to))
|
||||
Vector::from_bezpath(shapes::line_bezpath(DVec2::ZERO, line_to))
|
||||
}
|
||||
|
||||
trait GridSpacing {
|
||||
@@ -309,84 +296,74 @@ fn grid<T: GridSpacing>(
|
||||
#[default(10)] columns: u32,
|
||||
#[default(10)] rows: u32,
|
||||
#[default(30., 30.)] angles: DVec2,
|
||||
#[default(true)] connect_cells: bool,
|
||||
) -> Vector {
|
||||
let (x_spacing, y_spacing) = spacing.as_dvec2().into();
|
||||
let (angle_a, angle_b) = angles.into();
|
||||
|
||||
// Isometric grid spacing based on the two skew angles. Unused for rectangular grids.
|
||||
let tan_a = angle_a.to_radians().tan();
|
||||
let tan_b = angle_b.to_radians().tan();
|
||||
let isometric_spacing = DVec2::new(y_spacing / (tan_a + tan_b), y_spacing);
|
||||
|
||||
// The position of the grid point at column `x`, row `y`.
|
||||
let position = |x: u32, y: u32| -> DVec2 {
|
||||
match grid_type {
|
||||
GridType::Rectangular => DVec2::new(x_spacing * x as f64, y_spacing * y as f64),
|
||||
GridType::Isometric => {
|
||||
// Odd columns are offset vertically so the cells skew into the isometric shape.
|
||||
let a_angles_eaten = x.div_ceil(2) as f64;
|
||||
let b_angles_eaten = (x / 2) as f64;
|
||||
let offset_y_fraction = b_angles_eaten * tan_b - a_angles_eaten * tan_a;
|
||||
DVec2::new(isometric_spacing.x * x as f64, isometric_spacing.y * y as f64 + offset_y_fraction * isometric_spacing.x)
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// When the cells aren't connected, each one is its own closed quadrilateral subpath.
|
||||
// The vertices are ordered counter-clockwise to match the framework's fill winding.
|
||||
if !connect_cells {
|
||||
let mut cells = Vec::new();
|
||||
for y in 0..rows.saturating_sub(1) {
|
||||
for x in 0..columns.saturating_sub(1) {
|
||||
cells.push(vec![position(x, y), position(x + 1, y), position(x + 1, y + 1), position(x, y + 1)]);
|
||||
}
|
||||
}
|
||||
let mut vector = Vector::default();
|
||||
crate::vector_nodes::replace_with_polygons(&mut vector, cells, connect_cells);
|
||||
return vector;
|
||||
}
|
||||
|
||||
let mut vector = Vector::default();
|
||||
let mut segment_id = SegmentId::ZERO;
|
||||
let mut point_id = PointId::ZERO;
|
||||
|
||||
match grid_type {
|
||||
GridType::Rectangular => {
|
||||
// Create rectangular grid points and connect them with line segments
|
||||
for y in 0..rows {
|
||||
for x in 0..columns {
|
||||
// Add current point to the grid
|
||||
let current_index = vector.point_domain.ids().len();
|
||||
vector.point_domain.push(point_id.next_id(), DVec2::new(x_spacing * x as f64, y_spacing * y as f64));
|
||||
for y in 0..rows {
|
||||
for x in 0..columns {
|
||||
// Add the current point to the grid.
|
||||
let current_index = vector.point_domain.ids().len();
|
||||
vector.point_domain.push(point_id.next_id(), position(x, y));
|
||||
|
||||
// Helper function to connect points with line segments
|
||||
let mut push_segment = |to_index: Option<usize>| {
|
||||
if let Some(other_index) = to_index {
|
||||
vector
|
||||
.segment_domain
|
||||
.push(segment_id.next_id(), other_index, current_index, subpath::BezierHandles::Linear, StrokeId::ZERO);
|
||||
}
|
||||
};
|
||||
|
||||
// Connect to the point to the left (horizontal connection)
|
||||
push_segment((x > 0).then(|| current_index - 1));
|
||||
|
||||
// Connect to the point above (vertical connection)
|
||||
push_segment(current_index.checked_sub(columns as usize));
|
||||
// Helper function to connect points with line segments.
|
||||
let mut push_segment = |to_index: Option<usize>| {
|
||||
if let Some(other_index) = to_index {
|
||||
vector.segment_domain.push(segment_id.next_id(), other_index, current_index, BezierHandles::Linear);
|
||||
}
|
||||
}
|
||||
}
|
||||
GridType::Isometric => {
|
||||
// Calculate isometric grid spacing based on angles
|
||||
let tan_a = angle_a.to_radians().tan();
|
||||
let tan_b = angle_b.to_radians().tan();
|
||||
let spacing = DVec2::new(y_spacing / (tan_a + tan_b), y_spacing);
|
||||
};
|
||||
|
||||
// Create isometric grid points and connect them with line segments
|
||||
for y in 0..rows {
|
||||
for x in 0..columns {
|
||||
// Add current point to the grid with offset for odd columns
|
||||
let current_index = vector.point_domain.ids().len();
|
||||
// Connect to the point to the left (horizontal connection).
|
||||
push_segment((x > 0).then(|| current_index - 1));
|
||||
|
||||
let a_angles_eaten = x.div_ceil(2) as f64;
|
||||
let b_angles_eaten = (x / 2) as f64;
|
||||
// Connect to the point directly above (vertical connection).
|
||||
push_segment(current_index.checked_sub(columns as usize));
|
||||
|
||||
let offset_y_fraction = b_angles_eaten * tan_b - a_angles_eaten * tan_a;
|
||||
// Isometric grids additionally connect odd columns diagonally, splitting each cell into triangles.
|
||||
if grid_type == GridType::Isometric && x % 2 == 1 {
|
||||
// Connect to the point diagonally up-right (if not at the right edge).
|
||||
push_segment(current_index.checked_sub(columns as usize - 1).filter(|_| x + 1 < columns));
|
||||
|
||||
let position = DVec2::new(spacing.x * x as f64, spacing.y * y as f64 + offset_y_fraction * spacing.x);
|
||||
vector.point_domain.push(point_id.next_id(), position);
|
||||
|
||||
// Helper function to connect points with line segments
|
||||
let mut push_segment = |to_index: Option<usize>| {
|
||||
if let Some(other_index) = to_index {
|
||||
vector
|
||||
.segment_domain
|
||||
.push(segment_id.next_id(), other_index, current_index, subpath::BezierHandles::Linear, StrokeId::ZERO);
|
||||
}
|
||||
};
|
||||
|
||||
// Connect to the point to the left
|
||||
push_segment((x > 0).then(|| current_index - 1));
|
||||
|
||||
// Connect to the point directly above
|
||||
push_segment(current_index.checked_sub(columns as usize));
|
||||
|
||||
// Additional diagonal connections for odd columns (creates hexagonal pattern)
|
||||
if x % 2 == 1 {
|
||||
// Connect to the point diagonally up-right (if not at right edge)
|
||||
push_segment(current_index.checked_sub(columns as usize - 1).filter(|_| x + 1 < columns));
|
||||
|
||||
// Connect to the point diagonally up-left
|
||||
push_segment(current_index.checked_sub(columns as usize + 1));
|
||||
}
|
||||
}
|
||||
// Connect to the point diagonally up-left.
|
||||
push_segment(current_index.checked_sub(columns as usize + 1));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -397,39 +374,56 @@ fn grid<T: GridSpacing>(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use kurbo::ParamCurve;
|
||||
use vector_types::vector::misc::point_to_dvec2;
|
||||
|
||||
#[test]
|
||||
fn isometric_grid_test() {
|
||||
// Doesn't crash with weird angles
|
||||
grid(&(), (), GridType::Isometric, 0., 5, 5, (0., 0.).into());
|
||||
grid(&(), (), GridType::Isometric, 90., 5, 5, (90., 90.).into());
|
||||
grid(&(), (), GridType::Isometric, 0., 5, 5, (0., 0.).into(), true);
|
||||
grid(&(), (), GridType::Isometric, 90., 5, 5, (90., 90.).into(), true);
|
||||
|
||||
// Works properly
|
||||
let grid = grid(&(), (), GridType::Isometric, 10., 5, 5, (30., 30.).into());
|
||||
let grid = grid(&(), (), GridType::Isometric, 10., 5, 5, (30., 30.).into(), true);
|
||||
assert_eq!(grid.point_domain.ids().len(), 5 * 5);
|
||||
assert_eq!(grid.segment_bezier_iter().count(), 4 * 5 + 4 * 9);
|
||||
for (_, bezier, _, _) in grid.segment_bezier_iter() {
|
||||
assert_eq!(bezier.handles, subpath::BezierHandles::Linear);
|
||||
assert!(
|
||||
((bezier.start - bezier.end).length() - 10.).abs() < 1e-5,
|
||||
"Length of {} should be 10",
|
||||
(bezier.start - bezier.end).length()
|
||||
);
|
||||
assert_eq!(grid.segment_iter().count(), 4 * 5 + 4 * 9);
|
||||
for (_, segment, _, _) in grid.segment_iter() {
|
||||
assert!(matches!(segment, kurbo::PathSeg::Line(_)));
|
||||
let span = point_to_dvec2(segment.start()) - point_to_dvec2(segment.end());
|
||||
assert!((span.length() - 10.).abs() < 1e-5, "Length of {} should be 10", span.length());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skew_isometric_grid_test() {
|
||||
let grid = grid(&(), (), GridType::Isometric, 10., 5, 5, (40., 30.).into());
|
||||
let grid = grid(&(), (), GridType::Isometric, 10., 5, 5, (40., 30.).into(), true);
|
||||
assert_eq!(grid.point_domain.ids().len(), 5 * 5);
|
||||
assert_eq!(grid.segment_bezier_iter().count(), 4 * 5 + 4 * 9);
|
||||
for (_, bezier, _, _) in grid.segment_bezier_iter() {
|
||||
assert_eq!(bezier.handles, subpath::BezierHandles::Linear);
|
||||
let vector = bezier.start - bezier.end;
|
||||
assert_eq!(grid.segment_iter().count(), 4 * 5 + 4 * 9);
|
||||
for (_, segment, _, _) in grid.segment_iter() {
|
||||
assert!(matches!(segment, kurbo::PathSeg::Line(_)));
|
||||
let vector = point_to_dvec2(segment.start()) - point_to_dvec2(segment.end());
|
||||
let angle = (vector.angle_to(DVec2::X).to_degrees() + 180.) % 180.;
|
||||
assert!([90f64, 150., 40.].into_iter().any(|target| (target - angle).abs() < 1e-10), "unexpected angle of {angle}")
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grid_disconnected_cells_test() {
|
||||
// A 3x3 rectangular grid has a 2x2 arrangement of cells, each its own closed quad subpath.
|
||||
let vector = grid(&(), (), GridType::Rectangular, 10., 3, 3, (30., 30.).into(), false);
|
||||
assert_eq!(vector.stroke_manipulator_groups().filter(|(_, closed)| *closed).count(), 4);
|
||||
assert_eq!(vector.point_domain.ids().len(), 4 * 4);
|
||||
assert_eq!(vector.segment_domain.ids().len(), 4 * 4);
|
||||
|
||||
// Each cell winds counter-clockwise (positive signed area), matching the shape generators.
|
||||
for (group, closed) in vector.stroke_manipulator_groups() {
|
||||
assert!(closed);
|
||||
let anchors: Vec<DVec2> = group.iter().map(|g| g.anchor).collect();
|
||||
let signed_area: f64 = (0..anchors.len()).map(|i| anchors[i].perp_dot(anchors[(i + 1) % anchors.len()])).sum::<f64>() / 2.;
|
||||
assert!(signed_area > 0., "grid cell should wind counter-clockwise");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn qr_code_test() {
|
||||
let qr = qr_code(&(), (), "https://graphite.art".to_string(), false, 1., QRCodeErrorCorrectionLevel::Low, true);
|
||||
|
||||
@@ -2,6 +2,7 @@ pub mod generator_nodes;
|
||||
pub mod merge_qr_squares;
|
||||
pub mod vector_modification_nodes;
|
||||
mod vector_nodes;
|
||||
mod voronoi;
|
||||
|
||||
#[macro_use]
|
||||
extern crate log;
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
use glam::DVec2;
|
||||
use graphic_types::Vector;
|
||||
use std::collections::VecDeque;
|
||||
use vector_types::subpath;
|
||||
use vector_types::vector::VectorExt;
|
||||
use vector_types::vector::algorithms::shapes;
|
||||
|
||||
pub fn merge_qr_squares(qr_code: &qrcodegen::QrCode) -> Vector {
|
||||
let mut vector = Vector::default();
|
||||
@@ -106,7 +107,7 @@ pub fn merge_qr_squares(qr_code: &qrcodegen::QrCode) -> Vector {
|
||||
}
|
||||
|
||||
if !simplified.is_empty() {
|
||||
vector.append_subpath(subpath::Subpath::from_anchors(simplified, true), false);
|
||||
vector.append_bezpath(shapes::polyline_bezpath(simplified, true));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
use core_types::attribute::{Attr, EditorLayerPath, RemoveAttr, Transform as TransformAttr};
|
||||
use core_types::gpoll::{GraphError, Interrupt};
|
||||
use core_types::transform::BakeTransform;
|
||||
use core_types::uuid::NodeId;
|
||||
use core_types::{Ctx, ExtractIndex, InjectIndex};
|
||||
use glam::DAffine2;
|
||||
use glam::{DAffine2, DVec2};
|
||||
use graphic_types::Vector;
|
||||
use vector_types::markers::EditorClickTarget;
|
||||
use vector_types::vector::VectorModification;
|
||||
@@ -20,6 +21,10 @@ fn path_modify<'e>(
|
||||
let mut element = element;
|
||||
if ctx.index() == 0 {
|
||||
modification.apply(&mut element);
|
||||
|
||||
// Users draw subpaths in arbitrary winding directions, so normalize them here rather than
|
||||
// letting the drawn direction decide fill insideness downstream
|
||||
element.normalize_winding_directions();
|
||||
}
|
||||
|
||||
// Set the path to the encapsulating subgraph (drop our own trailing entry from `node_path`),
|
||||
@@ -38,14 +43,14 @@ fn path_modify<'e>(
|
||||
Ok((element, Attr(parked.as_slice()), RemoveAttr::new()))
|
||||
}
|
||||
|
||||
/// Applies the vector path's local transformation to its geometry and resets the transform to the identity.
|
||||
/// Bakes the content's transform attribute into its underlying value, resetting the attribute to the identity.
|
||||
#[node_macro::node(category("Vector"))]
|
||||
fn apply_transform(_ctx: impl Ctx, (mut vector, transform): (Vector, Attr<TransformAttr>)) -> (Vector, Attr<TransformAttr>) {
|
||||
fn bake_transform<T: BakeTransform + Clone + Default + Send + Sync + 'static>(
|
||||
_ctx: impl Ctx,
|
||||
#[implementations(Vector, DAffine2, DVec2)] (mut content, transform): (T, Attr<TransformAttr>),
|
||||
) -> (T, Attr<TransformAttr>) {
|
||||
let transform: DAffine2 = *transform;
|
||||
for (_, point) in vector.point_domain.positions_mut() {
|
||||
*point = transform.transform_point2(*point);
|
||||
}
|
||||
vector.segment_domain.transform(transform);
|
||||
content.bake_transform(&transform);
|
||||
|
||||
(vector, Attr(DAffine2::IDENTITY))
|
||||
(content, Attr(DAffine2::IDENTITY))
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
472
node-graph/nodes/vector/src/voronoi.rs
Normal file
472
node-graph/nodes/vector/src/voronoi.rs
Normal file
@@ -0,0 +1,472 @@
|
||||
//! Geometry for the Voronoi and Delaunay nodes.
|
||||
//!
|
||||
//! Both diagrams are derived from a single Delaunay triangulation (computed by the `delaunator` crate). The Voronoi
|
||||
//! diagram is the geometric dual of that triangulation: each Voronoi vertex is the circumcenter of a Delaunay triangle,
|
||||
//! and each Voronoi edge connects the circumcenters of two triangles that share a Delaunay edge.
|
||||
//!
|
||||
//! Each function here reduces a diagram to a set of closed polygons (one per Delaunay triangle or per Voronoi cell). The
|
||||
//! nodes then assemble those polygons into vector geometry, either as separate filled subpaths or as a shared mesh of
|
||||
//! welded points and segments. Voronoi cells around the convex hull are unbounded, so they are clipped to the convex hull
|
||||
//! of the input sites, which also bounds the whole diagram to a finite region.
|
||||
|
||||
use delaunator::{EMPTY, Point, triangulate};
|
||||
use glam::DVec2;
|
||||
|
||||
/// Computes the Delaunay triangulation of `sites`, returning each triangle as a triple of indices into `sites`.
|
||||
///
|
||||
/// Returns an empty vector when there are fewer than three points or they are all colinear (no triangle exists).
|
||||
pub fn delaunay_triangles(sites: &[DVec2]) -> Vec<[usize; 3]> {
|
||||
let points: Vec<Point> = sites.iter().map(|p| Point { x: p.x, y: p.y }).collect();
|
||||
let triangulation = triangulate(&points);
|
||||
triangulation.triangles.chunks_exact(3).map(|t| [t[0], t[1], t[2]]).collect()
|
||||
}
|
||||
|
||||
/// Computes the Voronoi cell of every site, each clipped to the convex hull of `sites`.
|
||||
///
|
||||
/// Returns one closed polygon per site that produces a non-empty cell (degenerate or fully-clipped cells are omitted,
|
||||
/// so the result may be shorter than `sites`). Returns an empty vector when no triangulation exists (fewer than three points or all colinear).
|
||||
pub fn voronoi_cells(sites: &[DVec2]) -> Vec<Vec<DVec2>> {
|
||||
voronoi_cells_per_site(sites).0.into_iter().flatten().collect()
|
||||
}
|
||||
|
||||
/// Applies Lloyd's relaxation: each step moves every interior site to the centroid of its Voronoi cell, yielding a more
|
||||
/// even (centroidal) point distribution. A fractional `iterations` runs the whole-number steps and then blends each site
|
||||
/// partway toward the result of one more step, so the relaxation can be animated smoothly. Returns the sites unchanged when
|
||||
/// `iterations` is 0 or no diagram can be formed.
|
||||
///
|
||||
/// The convex-hull (perimeter) sites are pinned so the point cloud's outline is preserved. Otherwise, clipping the
|
||||
/// unbounded perimeter cells would drag those sites around (inward for the convex hull, or outward into the corners of a
|
||||
/// fixed bounding box), distorting the shape over successive iterations.
|
||||
pub fn relax_sites(sites: &[DVec2], iterations: f64) -> Vec<DVec2> {
|
||||
const MAX_STEPS_FOR_SAFETY: f64 = 1000.;
|
||||
let iterations = iterations.clamp(0., MAX_STEPS_FOR_SAFETY);
|
||||
let whole_steps = iterations.floor();
|
||||
let fraction = iterations - whole_steps;
|
||||
|
||||
let mut current = sites.to_vec();
|
||||
for _ in 0..whole_steps as u32 {
|
||||
current = relax_once(¤t);
|
||||
}
|
||||
|
||||
// Blend each site partway toward one further step for the fractional remainder.
|
||||
if fraction > 0. {
|
||||
let next = relax_once(¤t);
|
||||
for (point, target) in current.iter_mut().zip(next) {
|
||||
*point = point.lerp(target, fraction);
|
||||
}
|
||||
}
|
||||
|
||||
current
|
||||
}
|
||||
|
||||
/// Performs a single Lloyd relaxation step: moves every interior site to its Voronoi cell centroid,
|
||||
/// leaving the pinned convex-hull (perimeter) sites in place.
|
||||
fn relax_once(sites: &[DVec2]) -> Vec<DVec2> {
|
||||
let (cells, is_hull) = voronoi_cells_per_site(sites);
|
||||
let mut relaxed = sites.to_vec();
|
||||
for ((site, cell), on_hull) in relaxed.iter_mut().zip(cells).zip(is_hull) {
|
||||
if on_hull {
|
||||
continue;
|
||||
}
|
||||
if let Some(centroid) = cell.as_deref().and_then(polygon_centroid) {
|
||||
*site = centroid;
|
||||
}
|
||||
}
|
||||
relaxed
|
||||
}
|
||||
|
||||
/// The area-weighted centroid of a simple polygon, or `None` if it has fewer than three vertices or zero area.
|
||||
fn polygon_centroid(polygon: &[DVec2]) -> Option<DVec2> {
|
||||
if polygon.len() < 3 {
|
||||
return None;
|
||||
}
|
||||
let mut double_area = 0.;
|
||||
let mut weighted = DVec2::ZERO;
|
||||
for i in 0..polygon.len() {
|
||||
let a = polygon[i];
|
||||
let b = polygon[(i + 1) % polygon.len()];
|
||||
let cross = a.perp_dot(b);
|
||||
double_area += cross;
|
||||
weighted += (a + b) * cross;
|
||||
}
|
||||
(double_area.abs() >= f64::EPSILON).then(|| weighted / (3. * double_area))
|
||||
}
|
||||
|
||||
/// Computes each site's clipped Voronoi cell, aligned with `sites` (index `i` is the cell of `sites[i]`), together with a
|
||||
/// per-site flag marking the convex-hull (perimeter) sites. A cell is `None` when the site has no incident triangle
|
||||
/// (e.g. a coincident duplicate) or its cell vanishes after clipping.
|
||||
fn voronoi_cells_per_site(sites: &[DVec2]) -> (Vec<Option<Vec<DVec2>>>, Vec<bool>) {
|
||||
let points: Vec<Point> = sites.iter().map(|p| Point { x: p.x, y: p.y }).collect();
|
||||
let triangulation = triangulate(&points);
|
||||
if triangulation.triangles.is_empty() {
|
||||
return (vec![None; sites.len()], vec![false; sites.len()]);
|
||||
}
|
||||
|
||||
let triangles = &triangulation.triangles;
|
||||
let halfedges = &triangulation.halfedges;
|
||||
let hull_indices = &triangulation.hull;
|
||||
|
||||
// Mark which sites lie on the convex hull (the diagram's perimeter).
|
||||
let mut is_hull = vec![false; sites.len()];
|
||||
for &index in hull_indices {
|
||||
is_hull[index] = true;
|
||||
}
|
||||
|
||||
// One Voronoi vertex per Delaunay triangle.
|
||||
let circumcenters: Vec<DVec2> = triangles.chunks_exact(3).map(|t| circumcenter(sites[t[0]], sites[t[1]], sites[t[2]])).collect();
|
||||
|
||||
// The convex hull polygon, which clips the diagram to a finite region.
|
||||
let hull: Vec<DVec2> = hull_indices.iter().map(|&i| sites[i]).collect();
|
||||
|
||||
// `inedges[p]` is a half-edge ending at site `p`, preferring a hull half-edge so a hull cell's walk starts on the boundary.
|
||||
let mut inedges = vec![EMPTY; sites.len()];
|
||||
for edge in 0..triangles.len() {
|
||||
let endpoint = triangles[next_halfedge(edge)];
|
||||
if halfedges[edge] == EMPTY || inedges[endpoint] == EMPTY {
|
||||
inedges[endpoint] = edge;
|
||||
}
|
||||
}
|
||||
|
||||
// Outward ray directions for the two hull edges meeting at each hull site, used to project its unbounded cell outward.
|
||||
// Both are zero for interior sites.
|
||||
let mut ray_in = vec![DVec2::ZERO; sites.len()];
|
||||
let mut ray_out = vec![DVec2::ZERO; sites.len()];
|
||||
if let Some(&last) = hull_indices.last() {
|
||||
let mut previous = last;
|
||||
for ¤t in hull_indices {
|
||||
let p0 = sites[previous];
|
||||
let p1 = sites[current];
|
||||
// Perpendicular to the hull edge `previous -> current`, pointing away from the hull interior.
|
||||
let perpendicular = DVec2::new(p0.y - p1.y, p1.x - p0.x);
|
||||
ray_out[previous] = perpendicular;
|
||||
ray_in[current] = perpendicular;
|
||||
previous = current;
|
||||
}
|
||||
}
|
||||
|
||||
// Length to extend unbounded cell rays so they reach past the hull before clipping trims them back to it.
|
||||
let far = bounding_diagonal(&hull) * 10. + 1.;
|
||||
|
||||
let cells = (0..sites.len())
|
||||
.map(|site| {
|
||||
let mut polygon = cell_polygon(site, halfedges, &circumcenters, &inedges)?;
|
||||
|
||||
// A hull site's cell is unbounded; cap its open ends with far points along the outward hull-edge normals so the
|
||||
// convex-hull clip below closes it off at the boundary.
|
||||
let unbounded = ray_in[site] != DVec2::ZERO || ray_out[site] != DVec2::ZERO;
|
||||
if unbounded {
|
||||
if let Some(&first) = polygon.first() {
|
||||
polygon.insert(0, first + ray_in[site].normalize_or_zero() * far);
|
||||
}
|
||||
if let Some(&last) = polygon.last() {
|
||||
polygon.push(last + ray_out[site].normalize_or_zero() * far);
|
||||
}
|
||||
}
|
||||
|
||||
let clipped = clip_to_convex(&polygon, &hull);
|
||||
(clipped.len() >= 3).then_some(clipped)
|
||||
})
|
||||
.collect();
|
||||
|
||||
(cells, is_hull)
|
||||
}
|
||||
|
||||
/// The circumcenter of a triangle, computed relative to `a` for numerical stability. Falls back to the centroid for a
|
||||
/// degenerate (colinear) triangle.
|
||||
fn circumcenter(a: DVec2, b: DVec2, c: DVec2) -> DVec2 {
|
||||
let d = b - a;
|
||||
let e = c - a;
|
||||
let determinant = d.x * e.y - d.y * e.x;
|
||||
if determinant.abs() < f64::EPSILON {
|
||||
return (a + b + c) / 3.;
|
||||
}
|
||||
let factor = 0.5 / determinant;
|
||||
let bl = d.length_squared();
|
||||
let cl = e.length_squared();
|
||||
DVec2::new(a.x + (e.y * bl - d.y * cl) * factor, a.y + (d.x * cl - e.x * bl) * factor)
|
||||
}
|
||||
|
||||
/// Walks the Delaunay triangles incident to `site` and collects their circumcenters in order, forming the site's
|
||||
/// Voronoi cell polygon. The polygon is closed for interior sites and open (a fan ending at the hull) for hull sites.
|
||||
/// Returns `None` for a site with no incident triangle (e.g. a coincident duplicate point).
|
||||
fn cell_polygon(site: usize, halfedges: &[usize], circumcenters: &[DVec2], inedges: &[usize]) -> Option<Vec<DVec2>> {
|
||||
let start = inedges[site];
|
||||
if start == EMPTY {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut polygon = Vec::new();
|
||||
let mut edge = start;
|
||||
loop {
|
||||
polygon.push(circumcenters[edge / 3]);
|
||||
edge = halfedges[next_halfedge(edge)];
|
||||
if edge == EMPTY || edge == start {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Some(polygon)
|
||||
}
|
||||
|
||||
/// The next half-edge within the same triangle (triangles store three consecutive half-edges).
|
||||
fn next_halfedge(edge: usize) -> usize {
|
||||
if edge % 3 == 2 { edge - 2 } else { edge + 1 }
|
||||
}
|
||||
|
||||
/// Clips `subject` to the convex polygon `clip` using the Sutherland–Hodgman algorithm. The clip polygon may wind either way.
|
||||
/// (The subject doesn't need to be convex.) Returns the clipped polygon (empty if it lies entirely outside the clip region).
|
||||
fn clip_to_convex(subject: &[DVec2], clip: &[DVec2]) -> Vec<DVec2> {
|
||||
if clip.len() < 3 {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
// Normalize the clip polygon to counter-clockwise so "inside" is consistently to the left of each directed edge.
|
||||
let mut clip = clip.to_vec();
|
||||
if signed_area(&clip) < 0. {
|
||||
clip.reverse();
|
||||
}
|
||||
|
||||
let mut output = subject.to_vec();
|
||||
for i in 0..clip.len() {
|
||||
if output.is_empty() {
|
||||
break;
|
||||
}
|
||||
|
||||
let edge_start = clip[i];
|
||||
let edge_end = clip[(i + 1) % clip.len()];
|
||||
let edge = edge_end - edge_start;
|
||||
let inside = |p: DVec2| edge.x * (p.y - edge_start.y) - edge.y * (p.x - edge_start.x) >= 0.;
|
||||
|
||||
let input = std::mem::take(&mut output);
|
||||
for j in 0..input.len() {
|
||||
let current = input[j];
|
||||
let previous = input[(j + input.len() - 1) % input.len()];
|
||||
let current_inside = inside(current);
|
||||
let previous_inside = inside(previous);
|
||||
|
||||
if current_inside {
|
||||
if !previous_inside && let Some(crossing) = line_intersection(previous, current, edge_start, edge_end) {
|
||||
output.push(crossing);
|
||||
}
|
||||
output.push(current);
|
||||
} else if previous_inside && let Some(crossing) = line_intersection(previous, current, edge_start, edge_end) {
|
||||
output.push(crossing);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
output
|
||||
}
|
||||
|
||||
/// The signed area of a polygon (positive for counter-clockwise winding).
|
||||
fn signed_area(polygon: &[DVec2]) -> f64 {
|
||||
let mut area = 0.;
|
||||
for i in 0..polygon.len() {
|
||||
let a = polygon[i];
|
||||
let b = polygon[(i + 1) % polygon.len()];
|
||||
area += a.x * b.y - b.x * a.y;
|
||||
}
|
||||
area / 2.
|
||||
}
|
||||
|
||||
/// The intersection point of the segment `p1 -> p2` with the infinite line through `a` and `b`, or `None` if parallel.
|
||||
fn line_intersection(p1: DVec2, p2: DVec2, a: DVec2, b: DVec2) -> Option<DVec2> {
|
||||
let r = p2 - p1;
|
||||
let s = b - a;
|
||||
let denominator = r.x * s.y - r.y * s.x;
|
||||
if denominator.abs() < f64::EPSILON {
|
||||
return None;
|
||||
}
|
||||
let t = ((a.x - p1.x) * s.y - (a.y - p1.y) * s.x) / denominator;
|
||||
Some(p1 + r * t)
|
||||
}
|
||||
|
||||
/// The diagonal length of the axis-aligned bounding box of `points`.
|
||||
fn bounding_diagonal(points: &[DVec2]) -> f64 {
|
||||
let mut min = DVec2::splat(f64::MAX);
|
||||
let mut max = DVec2::splat(f64::MIN);
|
||||
for &p in points {
|
||||
min = min.min(p);
|
||||
max = max.max(p);
|
||||
}
|
||||
let diagonal = (max - min).length();
|
||||
if diagonal.is_finite() && diagonal > 0. { diagonal } else { 0. }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn square_with_center() -> Vec<DVec2> {
|
||||
vec![DVec2::new(0., 0.), DVec2::new(10., 0.), DVec2::new(10., 10.), DVec2::new(0., 10.), DVec2::new(5., 5.)]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delaunay_triangles_wind_counter_clockwise() {
|
||||
// `delaunator` returns clockwise triangles, so `delaunay_triangles` keeps that order, but `voronoi_cells` are
|
||||
// counter-clockwise. This documents the raw orientation; the Delaunay node reverses it to match the cells.
|
||||
let sites = square_with_center();
|
||||
for t in delaunay_triangles(&sites) {
|
||||
let poly = [sites[t[0]], sites[t[1]], sites[t[2]]];
|
||||
assert!(signed_area(&poly) < 0., "delaunator triangles are expected to be clockwise");
|
||||
}
|
||||
for cell in voronoi_cells(&sites) {
|
||||
assert!(signed_area(&cell) > 0., "voronoi cells are expected to be counter-clockwise");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delaunay_triangulates_square() {
|
||||
let triangles = delaunay_triangles(&square_with_center());
|
||||
// Four corner-to-center triangles tessellate the square.
|
||||
assert_eq!(triangles.len(), 4);
|
||||
for triangle in triangles {
|
||||
for index in triangle {
|
||||
assert!(index < 5);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delaunay_degenerate_inputs_produce_no_triangles() {
|
||||
assert!(delaunay_triangles(&[]).is_empty());
|
||||
assert!(delaunay_triangles(&[DVec2::new(1., 1.)]).is_empty());
|
||||
assert!(delaunay_triangles(&[DVec2::new(0., 0.), DVec2::new(1., 1.)]).is_empty());
|
||||
// Colinear points have no triangulation.
|
||||
let colinear = vec![DVec2::new(0., 0.), DVec2::new(1., 1.), DVec2::new(2., 2.)];
|
||||
assert!(delaunay_triangles(&colinear).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn voronoi_cells_tile_the_hull() {
|
||||
// The clipped cells partition the convex hull, so their (counter-clockwise, positive) areas sum to the hull's area
|
||||
// (100 for the 10x10 square). If the outward projection direction were inverted, the boundary cells would collapse
|
||||
// inward and the total would fall well short of 100.
|
||||
let sites = square_with_center();
|
||||
let total: f64 = voronoi_cells(&sites).iter().map(|cell| signed_area(cell)).sum();
|
||||
assert!((total - 100.).abs() < 1e-6, "cells should tile the hull (area 100), got {total}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn voronoi_cells_stay_within_the_hull() {
|
||||
let sites = square_with_center();
|
||||
let cells = voronoi_cells(&sites);
|
||||
assert!(!cells.is_empty());
|
||||
// Clipping to the hull keeps every vertex inside the input bounds (with a small tolerance for float error).
|
||||
for cell in &cells {
|
||||
assert!(cell.len() >= 3);
|
||||
for &vertex in cell {
|
||||
assert!(vertex.x >= -1e-6 && vertex.x <= 10. + 1e-6, "x out of bounds: {}", vertex.x);
|
||||
assert!(vertex.y >= -1e-6 && vertex.y <= 10. + 1e-6, "y out of bounds: {}", vertex.y);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relaxation_with_zero_iterations_is_identity() {
|
||||
let sites = square_with_center();
|
||||
assert_eq!(relax_sites(&sites, 0.), sites);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relaxation_moves_points_and_keeps_them_in_the_hull() {
|
||||
// Add a point clustered near the center; relaxation should redistribute the points without leaving the hull.
|
||||
let mut sites = square_with_center();
|
||||
sites.push(DVec2::new(5.5, 4.5));
|
||||
let relaxed = relax_sites(&sites, 3.);
|
||||
|
||||
assert_eq!(relaxed.len(), sites.len());
|
||||
assert_ne!(relaxed, sites, "relaxation should move the points");
|
||||
for &point in &relaxed {
|
||||
assert!(point.x >= -1e-6 && point.x <= 10. + 1e-6, "x out of bounds: {}", point.x);
|
||||
assert!(point.y >= -1e-6 && point.y <= 10. + 1e-6, "y out of bounds: {}", point.y);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relaxation_pins_the_convex_hull() {
|
||||
// The four corners form the convex hull and must stay fixed; the interior points must relax.
|
||||
let sites = vec![
|
||||
DVec2::new(0., 0.),
|
||||
DVec2::new(10., 0.),
|
||||
DVec2::new(10., 10.),
|
||||
DVec2::new(0., 10.),
|
||||
DVec2::new(3., 3.),
|
||||
DVec2::new(7., 4.),
|
||||
];
|
||||
let relaxed = relax_sites(&sites, 4.);
|
||||
|
||||
for i in 0..4 {
|
||||
assert_eq!(relaxed[i], sites[i], "convex hull point {i} should be pinned");
|
||||
}
|
||||
assert!(relaxed[4] != sites[4] || relaxed[5] != sites[5], "interior points should relax");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relaxation_interpolates_fractional_iterations() {
|
||||
let sites = vec![
|
||||
DVec2::new(0., 0.),
|
||||
DVec2::new(10., 0.),
|
||||
DVec2::new(10., 10.),
|
||||
DVec2::new(0., 10.),
|
||||
DVec2::new(3., 4.),
|
||||
DVec2::new(7., 6.),
|
||||
];
|
||||
|
||||
// A fractional count lands exactly midway between the two bracketing whole-step results, exercising both the
|
||||
// pure-fraction path (0.5) and the whole-steps-then-fraction path (2.5).
|
||||
for whole in [0., 2.] {
|
||||
let lower = relax_sites(&sites, whole);
|
||||
let upper = relax_sites(&sites, whole + 1.);
|
||||
let half = relax_sites(&sites, whole + 0.5);
|
||||
for i in 0..sites.len() {
|
||||
let expected = (lower[i] + upper[i]) / 2.;
|
||||
assert!((half[i] - expected).length() < 1e-9, "index {i} at {whole}.5: {half:?} vs {expected:?}", half = half[i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relaxation_leaves_degenerate_input_unchanged() {
|
||||
// Fewer than three points cannot form a diagram, so relaxation is a no-op.
|
||||
let sites = vec![DVec2::new(0., 0.), DVec2::new(1., 1.)];
|
||||
assert_eq!(relax_sites(&sites, 5.), sites);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relaxation_clamps_extreme_iteration_counts() {
|
||||
let sites = vec![
|
||||
DVec2::new(0., 0.),
|
||||
DVec2::new(10., 0.),
|
||||
DVec2::new(10., 10.),
|
||||
DVec2::new(0., 10.),
|
||||
DVec2::new(3., 4.),
|
||||
DVec2::new(7., 6.),
|
||||
];
|
||||
// A huge or infinite count must clamp to the converged result rather than hang on a billions-long loop.
|
||||
let converged = relax_sites(&sites, 1000.);
|
||||
assert_eq!(relax_sites(&sites, 1e9), converged);
|
||||
assert_eq!(relax_sites(&sites, f64::INFINITY), converged);
|
||||
// NaN and negative counts resolve to zero steps, leaving the sites unchanged.
|
||||
assert_eq!(relax_sites(&sites, f64::NAN), sites);
|
||||
assert_eq!(relax_sites(&sites, -5.), sites);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn voronoi_center_cell_is_bounded() {
|
||||
// A ring of points around a center yields a finite cell for the center site.
|
||||
let mut sites = vec![DVec2::new(0., 0.)];
|
||||
for i in 0..6 {
|
||||
let angle = i as f64 / 6. * std::f64::consts::TAU;
|
||||
sites.push(DVec2::new(angle.cos() * 10., angle.sin() * 10.));
|
||||
}
|
||||
let cells = voronoi_cells(&sites);
|
||||
assert!(!cells.is_empty());
|
||||
// Every cell is a finite polygon with no runaway coordinates.
|
||||
for cell in &cells {
|
||||
for &vertex in cell {
|
||||
assert!(vertex.length() < 100., "unbounded cell vertex: {vertex:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user