mirror of
https://github.com/GraphiteEditor/Graphite.git
synced 2026-09-16 06:38:03 +08:00
* Add a spread method field to GradientRamp, carried at runtime as the gradient item's attribute * Retire the Fill node's spread method input, folding its value into the gradient ramps on document upgrade * Add an Ends spread method radio to the color picker popover, replacing the Gradient tool's control bar radio * Update the demo art
3876 lines
154 KiB
Rust
3876 lines
154 KiB
Rust
use core::cmp::Ordering;
|
||
use core::f64::consts::{PI, TAU};
|
||
use core::hash::{Hash, Hasher};
|
||
use core_types::blending::BlendMode;
|
||
use core_types::bounds::{BoundingBox, RenderBoundingBox};
|
||
use core_types::list::{ATTR_FILL, ATTR_STROKE, Item, ItemAttributeValues, List, ListDyn, NodeIdPath};
|
||
use core_types::registry::types::{Angle, Length, Multiplier, Percentage, PixelLength, Progression, SeedValue};
|
||
use core_types::transform::{Footprint, Transform};
|
||
use core_types::uuid::NodeId;
|
||
use core_types::{
|
||
ATTR_BLEND_MODE, ATTR_CLIPPING_MASK, ATTR_EDITOR_LAYER_PATH, ATTR_EDITOR_MERGED_LAYERS, ATTR_GRADIENT_TYPE, ATTR_OPACITY, ATTR_OPACITY_FILL, ATTR_TRANSFORM, CloneVarArgs, Color, Context, Ctx,
|
||
ExtractAll, OwnedContextImpl,
|
||
};
|
||
use glam::{DAffine2, DMat2, DVec2};
|
||
use graphic_types::Vector;
|
||
use graphic_types::graphic::{bake_paint_transforms, graphic_list_at, has_paint_at, is_paint_present, set_paint_attribute_at};
|
||
use graphic_types::raster_types::{CPU, GPU, Raster};
|
||
use graphic_types::{Graphic, IntoGraphicList};
|
||
use kurbo::simplify::{SimplifyOptions, simplify_bezpath};
|
||
use kurbo::{Affine, BezPath, DEFAULT_ACCURACY, Line, ParamCurve, ParamCurveArclen, PathEl, PathSeg, Shape};
|
||
use rand::{Rng, SeedableRng};
|
||
use std::collections::hash_map::DefaultHasher;
|
||
use std::collections::{HashMap, HashSet};
|
||
use vector_types::GradientType;
|
||
use vector_types::gradient::{build_transform_with_y_preservation, initial_gradient_transform_for_bounding_box};
|
||
use vector_types::subpath::{BezierHandles, ManipulatorGroup};
|
||
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::offset_subpath::offset_bezpath;
|
||
use vector_types::vector::algorithms::spline::{solve_spline_first_handle_closed, solve_spline_first_handle_open};
|
||
use vector_types::vector::misc::{
|
||
CentroidType, ExtrudeJoiningAlgorithm, HandleId, InterpolationDistribution, MergeByDistanceAlgorithm, PointSpacingType, RowsOrColumns, bezpath_from_manipulator_groups,
|
||
bezpath_to_manipulator_groups, handles_to_segment, is_linear, point_to_dvec2, segment_to_handles,
|
||
};
|
||
use vector_types::vector::style::{DashPattern, Gradient, PaintOrder, Stroke, StrokeAlign, StrokeCap, StrokeJoin};
|
||
use vector_types::vector::{FillId, PointId, RegionId, SegmentDomain, SegmentId, StrokeId, VectorExt};
|
||
use vector_types::vector::{PointDomain, RegionDomain};
|
||
|
||
/// Implemented for `List` types that contain vector items reachable via mutable access.
|
||
/// Used by the whole-collection Assign Colors node so it can apply to either `List<Graphic>` or `List<Vector>`.
|
||
trait VectorListIterMut {
|
||
fn for_each_vector_list_mut(&mut self, f: impl FnMut(&mut List<Vector>));
|
||
|
||
fn vector_count(&self) -> usize;
|
||
}
|
||
|
||
impl VectorListIterMut for List<Graphic> {
|
||
fn for_each_vector_list_mut(&mut self, mut f: impl FnMut(&mut List<Vector>)) {
|
||
for graphic in self.iter_element_values_mut() {
|
||
if let Some(vector_list) = graphic.as_vector_mut() {
|
||
f(vector_list);
|
||
};
|
||
}
|
||
}
|
||
|
||
fn vector_count(&self) -> usize {
|
||
self.iter_element_values().filter_map(|element| element.as_vector()).map(|list| list.len()).sum()
|
||
}
|
||
}
|
||
|
||
impl VectorListIterMut for List<Vector> {
|
||
fn for_each_vector_list_mut(&mut self, mut f: impl FnMut(&mut List<Vector>)) {
|
||
f(self);
|
||
}
|
||
|
||
fn vector_count(&self) -> usize {
|
||
self.len()
|
||
}
|
||
}
|
||
|
||
/// Element-level analog of [`VectorListIterMut`] for the element-wise fill and stroke nodes, operating on a
|
||
/// single `Item<Vector>` or `Item<Graphic>`.
|
||
trait VectorItemMut {
|
||
fn for_each_vector_mut(&mut self, f: impl FnMut(&mut Vector, DAffine2));
|
||
|
||
fn set_vector_paint(&mut self, key: &str, paint: List<Graphic>);
|
||
}
|
||
|
||
impl VectorItemMut for Item<Vector> {
|
||
fn for_each_vector_mut(&mut self, mut f: impl FnMut(&mut Vector, DAffine2)) {
|
||
let transform = self.attribute_cloned_or_default::<DAffine2>(ATTR_TRANSFORM);
|
||
f(self.element_mut(), transform);
|
||
}
|
||
|
||
fn set_vector_paint(&mut self, key: &str, paint: List<Graphic>) {
|
||
self.set_attribute(key, paint);
|
||
}
|
||
}
|
||
|
||
impl VectorItemMut for Item<Graphic> {
|
||
fn for_each_vector_mut(&mut self, mut f: impl FnMut(&mut Vector, DAffine2)) {
|
||
let Some(vector_list) = self.element_mut().as_vector_mut() else { return };
|
||
let (elements, transforms) = vector_list.element_and_attribute_slices_mut::<DAffine2>(ATTR_TRANSFORM);
|
||
for (vector, transform) in elements.iter_mut().zip(transforms.iter()) {
|
||
f(vector, *transform);
|
||
}
|
||
}
|
||
|
||
fn set_vector_paint(&mut self, key: &str, paint: List<Graphic>) {
|
||
let Some(vector_list) = self.element_mut().as_vector_mut() else { return };
|
||
for slot in vector_list.iter_attribute_values_mut_or_default::<List<Graphic>>(key) {
|
||
*slot = paint.clone();
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Uniquely sets the fill and/or stroke style of every vector element to individual colors sampled along a chosen gradient.
|
||
#[node_macro::node(category("Vector: Style"), path(graphene_core::vector))]
|
||
async fn assign_colors<T>(
|
||
_: impl Ctx,
|
||
/// The content with vector paths to apply the fill and/or stroke style to.
|
||
#[implementations(List<Graphic>, List<Vector>)]
|
||
#[widget(ParsedWidgetOverride::Hidden)]
|
||
content: T,
|
||
/// Whether to style the fill.
|
||
#[default(true)]
|
||
fill: Item<bool>,
|
||
/// Whether to style the stroke.
|
||
stroke: Item<bool>,
|
||
/// The range of colors to select from.
|
||
#[default(Color::BLACK, Color::WHITE)]
|
||
#[widget(ParsedWidgetOverride::Custom = "assign_colors_gradient")]
|
||
gradient: Item<Gradient>,
|
||
/// Whether to reverse the gradient.
|
||
reverse: Item<bool>,
|
||
/// Whether to randomize the color selection for each element from throughout the gradient.
|
||
randomize: Item<bool>,
|
||
/// The seed used for randomization.
|
||
/// Seed to determine unique variations on the randomized color selection.
|
||
#[widget(ParsedWidgetOverride::Custom = "assign_colors_seed")]
|
||
seed: Item<SeedValue>,
|
||
/// The number of elements to span across the gradient before repeating. A 0 value will span the entire gradient once.
|
||
#[widget(ParsedWidgetOverride::Custom = "assign_colors_repeat_every")]
|
||
repeat_every: Item<u32>,
|
||
) -> T
|
||
where
|
||
T: VectorListIterMut + 'n + Send,
|
||
{
|
||
let (fill, stroke, reverse, randomize) = (fill.into_element(), stroke.into_element(), reverse.into_element(), randomize.into_element());
|
||
let (seed, repeat_every) = (seed.into_element(), repeat_every.into_element());
|
||
|
||
let mut content = content;
|
||
let length = content.vector_count();
|
||
let element = gradient.into_element();
|
||
let gradient = if reverse { element.reversed() } else { element };
|
||
|
||
let mut rng = rand::rngs::StdRng::seed_from_u64(seed.into());
|
||
|
||
let mut i: usize = 0;
|
||
content.for_each_vector_list_mut(|vector_list| {
|
||
for index in 0..vector_list.len() {
|
||
let factor = match randomize {
|
||
true => rng.random::<f64>(),
|
||
false => match repeat_every {
|
||
0 => i as f64 / (length - 1).max(1) as f64,
|
||
1 => 0.,
|
||
_ => i as f64 % repeat_every as f64 / (repeat_every - 1) as f64,
|
||
},
|
||
};
|
||
|
||
let color = gradient.evaluate(factor, Default::default());
|
||
let paint = List::new_from_element(color).into_graphic_list();
|
||
|
||
if fill {
|
||
set_paint_attribute_at(vector_list, index, ATTR_FILL, paint.clone());
|
||
}
|
||
if stroke && vector_list.element(index).is_some_and(|vector| vector.stroke.is_some()) {
|
||
set_paint_attribute_at(vector_list, index, ATTR_STROKE, paint.clone());
|
||
}
|
||
|
||
i += 1;
|
||
}
|
||
});
|
||
|
||
content
|
||
}
|
||
|
||
/// Applies a fill style to the vector content, giving an appearance to the area within the interior of the geometry.
|
||
#[node_macro::node(category("Vector: Style"), path(graphene_core::vector), properties("fill_properties"))]
|
||
async fn fill<V, F: IntoGraphicList + 'n + Send + 'static>(
|
||
_: impl Ctx,
|
||
/// The content with vector paths to apply the fill style to.
|
||
#[implementations(Vector, Vector, Vector, Vector, Vector, Vector, Graphic, Graphic, Graphic, Graphic, Graphic, Graphic)]
|
||
content: Item<V>,
|
||
/// The fill to paint the path with.
|
||
#[default(Color::BLACK)]
|
||
#[implementations(
|
||
List<Graphic>, List<Vector>, List<Color>, List<Gradient>, List<Raster<CPU>>, List<Raster<GPU>>,
|
||
List<Graphic>, List<Vector>, List<Color>, List<Gradient>, List<Raster<CPU>>, List<Raster<GPU>>,
|
||
)]
|
||
fill: F,
|
||
_backup_color: Item<Color>,
|
||
#[default(Color::BLACK, Color::WHITE)] _backup_gradient: Item<Gradient>,
|
||
_gradient_type: Item<GradientType>,
|
||
_has_transform: Item<bool>,
|
||
_transform: Item<DAffine2>,
|
||
) -> Item<V>
|
||
where
|
||
Item<V>: VectorItemMut + 'n + Send,
|
||
{
|
||
let _gradient_type = _gradient_type.into_element();
|
||
let (_has_transform, _transform) = (_has_transform.into_element(), *_transform.element());
|
||
|
||
let mut content = content;
|
||
let mut fill = fill.into_graphic_list();
|
||
|
||
// Stamp the gradient styling inputs onto any gradient paint missing them, whether the paint arrived as a picker value or a wire
|
||
for graphic in fill.iter_element_values_mut() {
|
||
let Graphic::Gradient(gradient) = graphic else { continue };
|
||
|
||
if gradient.iter_attribute_values::<GradientType>(ATTR_GRADIENT_TYPE).is_none() {
|
||
for value in gradient.iter_attribute_values_mut_or_default::<GradientType>(ATTR_GRADIENT_TYPE) {
|
||
*value = _gradient_type;
|
||
}
|
||
}
|
||
|
||
if gradient.iter_attribute_values::<DAffine2>(ATTR_TRANSFORM).is_none() {
|
||
// Without an explicit placement, derive one covering the paint target's bounding box (the CSS `auto` behavior)
|
||
let transform = if _has_transform {
|
||
_transform
|
||
} else {
|
||
let mut bounds: Option<[DVec2; 2]> = None;
|
||
content.for_each_vector_mut(|vector, _| {
|
||
if let Some([min, max]) = vector.bounding_box() {
|
||
bounds = Some(match bounds {
|
||
Some([bmin, bmax]) => [bmin.min(min), bmax.max(max)],
|
||
None => [min, max],
|
||
});
|
||
}
|
||
});
|
||
|
||
// Nudge a degenerate axis so the gradient transform stays invertible, matching the editor's `nonzero_bounding_box`
|
||
let [min, mut max] = bounds.unwrap_or([DVec2::ZERO, DVec2::ONE]);
|
||
if max.x - min.x < 1e-10 {
|
||
max.x = min.x + 1.;
|
||
}
|
||
if max.y - min.y < 1e-10 {
|
||
max.y = min.y + 1.;
|
||
}
|
||
initial_gradient_transform_for_bounding_box([min, max])
|
||
};
|
||
|
||
for value in gradient.iter_attribute_values_mut_or_default::<DAffine2>(ATTR_TRANSFORM) {
|
||
*value = transform;
|
||
}
|
||
}
|
||
}
|
||
|
||
content.set_vector_paint(ATTR_FILL, fill);
|
||
content
|
||
}
|
||
|
||
/// Applies a stroke style to the vector content, giving an appearance to the area within the outline of the geometry.
|
||
#[node_macro::node(category("Vector: Style"), path(graphene_core::vector), properties("stroke_properties"))]
|
||
async fn stroke<V, P: IntoGraphicList + 'n + Send + 'static>(
|
||
_: impl Ctx,
|
||
/// The content with vector paths to apply the stroke style to.
|
||
#[implementations(Vector, Vector, Vector, Vector, Vector, Vector, Graphic, Graphic, Graphic, Graphic, Graphic, Graphic)]
|
||
content: Item<V>,
|
||
/// The stroke paint.
|
||
#[default(Color::BLACK)]
|
||
#[implementations(
|
||
List<Graphic>, List<Vector>, List<Color>, List<Gradient>, List<Raster<CPU>>, List<Raster<GPU>>,
|
||
List<Graphic>, List<Vector>, List<Color>, List<Gradient>, List<Raster<CPU>>, List<Raster<GPU>>,
|
||
)]
|
||
paint: P,
|
||
/// The stroke thickness.
|
||
#[unit(" px")]
|
||
#[default(2.)]
|
||
weight: Item<f64>,
|
||
/// The alignment of stroke to the path's centerline or (for closed shapes) the inside or outside of the shape.
|
||
align: Item<StrokeAlign>,
|
||
/// The shape of the stroke at open endpoints.
|
||
cap: Item<StrokeCap>,
|
||
/// The curvature of the bent stroke at sharp corners.
|
||
join: Item<StrokeJoin>,
|
||
/// The threshold for when a miter-joined stroke is converted to a bevel-joined stroke when a sharp angle becomes pointier than this ratio.
|
||
#[default(4.)]
|
||
miter_limit: Item<f64>,
|
||
// <https://svgwg.org/svg2-draft/painting.html#PaintOrderProperty>
|
||
/// The order to paint the stroke on top of the fill, or the fill on top of the stroke.
|
||
paint_order: Item<PaintOrder>,
|
||
/// The stroke dash pattern. Each length forms a distance in a pattern where the first length is a dash, the second is a gap, and so on. If the list is an odd length, the pattern repeats with solid-gap roles reversed.
|
||
dash_pattern: Item<DashPattern>,
|
||
/// The phase offset distance from the starting point of the dash pattern.
|
||
#[unit(" px")]
|
||
dash_offset: Item<f64>,
|
||
) -> Item<V>
|
||
where
|
||
Item<V>: VectorItemMut + 'n + Send,
|
||
{
|
||
let mut content = content;
|
||
let (weight, align, cap, join, miter_limit, paint_order, dash_offset) = (
|
||
weight.into_element(),
|
||
align.into_element(),
|
||
cap.into_element(),
|
||
join.into_element(),
|
||
miter_limit.into_element(),
|
||
paint_order.into_element(),
|
||
dash_offset.into_element(),
|
||
);
|
||
let dash_lengths = dash_pattern.into_element().clamped_lengths();
|
||
|
||
let stroke = Stroke {
|
||
weight,
|
||
dash_lengths,
|
||
dash_offset,
|
||
cap,
|
||
join,
|
||
join_miter_limit: miter_limit,
|
||
align,
|
||
transform: DAffine2::IDENTITY,
|
||
paint_order,
|
||
};
|
||
|
||
content.for_each_vector_mut(|vector, transform| {
|
||
let mut stroke = stroke.clone();
|
||
stroke.transform *= transform;
|
||
vector.stroke = Some(stroke);
|
||
});
|
||
|
||
let paint = paint.into_graphic_list();
|
||
content.set_vector_paint(ATTR_STROKE, paint);
|
||
content
|
||
}
|
||
|
||
/// Builds a stroke dash pattern from a list of lengths that alternate between dash and gap, starting with a dash.
|
||
#[node_macro::node(category("Vector: Style"), path(graphene_core::vector))]
|
||
fn dash_pattern(
|
||
_: impl Ctx,
|
||
/// The dash and gap lengths, alternating and starting with a dash.
|
||
lengths: List<f64>,
|
||
) -> Item<DashPattern> {
|
||
let lengths: Vec<f64> = lengths.iter_element_values().copied().collect();
|
||
Item::new_from_element(DashPattern::from(lengths))
|
||
}
|
||
|
||
#[node_macro::node(category("Repeat"), name("Copy to Points"), path(core_types::vector))]
|
||
async fn copy_to_points<I: 'n + Send + Clone>(
|
||
_: impl Ctx,
|
||
points: List<Vector>,
|
||
/// Artwork to be copied and placed at each point.
|
||
#[expose]
|
||
#[implementations(List<Graphic>, List<Vector>, List<String>, List<Raster<CPU>>, List<Color>, List<Gradient>)]
|
||
content: List<I>,
|
||
/// Minimum range of randomized sizes given to each placed copy.
|
||
#[default(1)]
|
||
#[range]
|
||
#[soft(0..2)]
|
||
#[unit("x")]
|
||
random_scale_min: Item<Multiplier>,
|
||
/// Maximum range of randomized sizes given to each placed copy.
|
||
#[default(1)]
|
||
#[range]
|
||
#[soft(0..2)]
|
||
#[unit("x")]
|
||
random_scale_max: Item<Multiplier>,
|
||
/// Bias for the probability distribution of randomized sizes (0 is uniform, negatives favor more of small sizes, positives favor more of large sizes).
|
||
#[range]
|
||
#[soft(-50..50)]
|
||
random_scale_bias: Item<f64>,
|
||
/// Seed to determine unique variations on all the randomized copy sizes.
|
||
random_scale_seed: Item<SeedValue>,
|
||
/// Range of randomized angles given to each placed copy, in degrees ranging from furthest clockwise to counterclockwise.
|
||
#[range]
|
||
#[soft(0..360)]
|
||
random_rotation: Item<Angle>,
|
||
/// Seed to determine unique variations on all the randomized copy angles.
|
||
random_rotation_seed: Item<SeedValue>,
|
||
) -> List<I> {
|
||
let (random_scale_min, random_scale_max, random_scale_bias) = (random_scale_min.into_element(), random_scale_max.into_element(), random_scale_bias.into_element());
|
||
let (random_scale_seed, random_rotation, random_rotation_seed) = (random_scale_seed.into_element(), random_rotation.into_element(), random_rotation_seed.into_element());
|
||
|
||
let mut result_list = List::new();
|
||
|
||
let random_scale_difference = random_scale_max - random_scale_min;
|
||
|
||
for row in points.into_iter() {
|
||
let mut scale_rng = rand::rngs::StdRng::seed_from_u64(random_scale_seed.into());
|
||
let mut rotation_rng = rand::rngs::StdRng::seed_from_u64(random_rotation_seed.into());
|
||
|
||
let do_scale = random_scale_difference.abs() > 1e-6;
|
||
let do_rotation = random_rotation.abs() > 1e-6;
|
||
|
||
let points_transform: DAffine2 = row.attribute_cloned_or_default(ATTR_TRANSFORM);
|
||
for &point in row.element().point_domain.positions() {
|
||
let translation = points_transform.transform_point2(point);
|
||
|
||
let rotation = if do_rotation {
|
||
let degrees = (rotation_rng.random::<f64>() - 0.5) * random_rotation;
|
||
degrees / 360. * TAU
|
||
} else {
|
||
0.
|
||
};
|
||
|
||
let scale = if do_scale {
|
||
if random_scale_bias.abs() < 1e-6 {
|
||
// Linear
|
||
random_scale_min + scale_rng.random::<f64>() * random_scale_difference
|
||
} else {
|
||
// Weighted (see <https://www.desmos.com/calculator/gmavd3m9bd>)
|
||
let horizontal_scale_factor = 1. - 2_f64.powf(random_scale_bias);
|
||
let scale_factor = (1. - scale_rng.random::<f64>() * horizontal_scale_factor).log2() / random_scale_bias;
|
||
random_scale_min + scale_factor * random_scale_difference
|
||
}
|
||
} else {
|
||
random_scale_min
|
||
};
|
||
|
||
let transform = DAffine2::from_scale_angle_translation(DVec2::splat(scale), rotation, translation);
|
||
|
||
for row_index in 0..content.len() {
|
||
let Some(mut row) = content.clone_item(row_index) else { continue };
|
||
let row_transform: DAffine2 = row.attribute_cloned_or_default(ATTR_TRANSFORM);
|
||
row.set_attribute(ATTR_TRANSFORM, transform * row_transform);
|
||
|
||
result_list.push(row);
|
||
}
|
||
}
|
||
}
|
||
|
||
result_list
|
||
}
|
||
|
||
#[node_macro::node(category("Vector: Modifier"), path(core_types::vector))]
|
||
async fn round_corners(
|
||
_: impl Ctx,
|
||
source: Item<Vector>,
|
||
#[hard(0..)]
|
||
#[default(10.)]
|
||
radius: Item<PixelLength>,
|
||
#[range]
|
||
#[hard(0..1)]
|
||
#[default(0.5)]
|
||
roundness: Item<f64>,
|
||
#[default(100.)] edge_length_limit: Item<Percentage>,
|
||
#[range]
|
||
#[hard(0..180)]
|
||
#[default(5.)]
|
||
min_angle_threshold: Item<Angle>,
|
||
) -> Item<Vector> {
|
||
let (radius, roundness, edge_length_limit, min_angle_threshold) = (*radius.element(), *roundness.element(), *edge_length_limit.element(), *min_angle_threshold.element());
|
||
let source_transform: DAffine2 = source.attribute_cloned_or_default(ATTR_TRANSFORM);
|
||
let source_transform_inverse = source_transform.inverse();
|
||
let (source, attributes) = source.into_parts();
|
||
|
||
// Flip the roundness to help with user intuition
|
||
let roundness = 1. - roundness;
|
||
// Convert 0-100 to 0-0.5
|
||
let edge_length_limit = edge_length_limit * 0.005;
|
||
|
||
let mut result = Vector {
|
||
stroke: source.stroke.clone(),
|
||
..Default::default()
|
||
};
|
||
|
||
// Grab the initial point ID as a stable starting point
|
||
let mut initial_point_id = source.point_domain.ids().first().copied().unwrap_or(PointId::generate());
|
||
|
||
for mut bezpath in source.stroke_bezpath_iter() {
|
||
bezpath.apply_affine(Affine::new(source_transform.to_cols_array()));
|
||
let (manipulator_groups, is_closed) = bezpath_to_manipulator_groups(&bezpath);
|
||
|
||
// End if not enough points for corner rounding
|
||
if manipulator_groups.len() < 3 {
|
||
result.append_bezpath(bezpath);
|
||
continue;
|
||
}
|
||
|
||
let mut new_manipulator_groups = Vec::new();
|
||
|
||
for i in 0..manipulator_groups.len() {
|
||
// Skip first and last points for open paths
|
||
if !is_closed && (i == 0 || i == manipulator_groups.len() - 1) {
|
||
new_manipulator_groups.push(manipulator_groups[i]);
|
||
continue;
|
||
}
|
||
|
||
// Not the prettiest, but it makes the rest of the logic more readable
|
||
let prev_index = if i == 0 { if is_closed { manipulator_groups.len() - 1 } else { 0 } } else { i - 1 };
|
||
let curr_index = i;
|
||
let next_index = if i == manipulator_groups.len() - 1 { if is_closed { 0 } else { i } } else { i + 1 };
|
||
|
||
let prev = manipulator_groups[prev_index].anchor;
|
||
let curr = manipulator_groups[curr_index].anchor;
|
||
let next = manipulator_groups[next_index].anchor;
|
||
|
||
let dir1 = (curr - prev).normalize_or(DVec2::X);
|
||
let dir2 = (next - curr).normalize_or(DVec2::X);
|
||
|
||
let theta = PI - dir1.angle_to(dir2).abs();
|
||
|
||
// Skip near-straight corners
|
||
if theta > PI - min_angle_threshold.to_radians() {
|
||
new_manipulator_groups.push(manipulator_groups[curr_index]);
|
||
continue;
|
||
}
|
||
|
||
// Calculate L, with limits to avoid extreme values
|
||
let distance_along_edge = radius / (theta / 2.).sin();
|
||
let distance_along_edge = distance_along_edge.min(edge_length_limit * (curr - prev).length().min((next - curr).length())).max(0.01);
|
||
|
||
// Find points on each edge at distance L from corner
|
||
let p1 = curr - dir1 * distance_along_edge;
|
||
let p2 = curr + dir2 * distance_along_edge;
|
||
|
||
// Add first point (coming into the rounded corner)
|
||
new_manipulator_groups.push(ManipulatorGroup {
|
||
anchor: p1,
|
||
in_handle: None,
|
||
out_handle: Some(curr - dir1 * distance_along_edge * roundness),
|
||
id: initial_point_id.next_id(),
|
||
});
|
||
|
||
// Add second point (coming out of the rounded corner)
|
||
new_manipulator_groups.push(ManipulatorGroup {
|
||
anchor: p2,
|
||
in_handle: Some(curr + dir2 * distance_along_edge * roundness),
|
||
out_handle: None,
|
||
id: initial_point_id.next_id(),
|
||
});
|
||
}
|
||
|
||
// One subpath for each shape
|
||
let mut rounded_subpath = bezpath_from_manipulator_groups(&new_manipulator_groups, is_closed);
|
||
rounded_subpath.apply_affine(Affine::new(source_transform_inverse.to_cols_array()));
|
||
result.append_bezpath(rounded_subpath);
|
||
}
|
||
|
||
Item::from_parts(result, attributes)
|
||
}
|
||
|
||
#[node_macro::node(name("Merge by Distance"), category("Vector: Modifier"), path(core_types::vector))]
|
||
pub fn merge_by_distance(
|
||
_: impl Ctx,
|
||
content: Item<Vector>,
|
||
#[default(0.1)]
|
||
#[hard(0.0001..)]
|
||
distance: Item<PixelLength>,
|
||
algorithm: Item<MergeByDistanceAlgorithm>,
|
||
) -> Item<Vector> {
|
||
let mut content = content;
|
||
let (distance, algorithm) = (*distance.element(), *algorithm.element());
|
||
|
||
match algorithm {
|
||
MergeByDistanceAlgorithm::Spatial => {
|
||
let transform: DAffine2 = content.attribute_cloned_or_default(ATTR_TRANSFORM);
|
||
content.element_mut().merge_by_distance_spatial(transform, distance);
|
||
}
|
||
MergeByDistanceAlgorithm::Topological => content.element_mut().merge_by_distance_topological(distance),
|
||
}
|
||
|
||
content
|
||
}
|
||
|
||
pub mod extrude_algorithms {
|
||
use glam::DVec2;
|
||
use kurbo::{ParamCurve, ParamCurveDeriv};
|
||
use vector_types::subpath::BezierHandles;
|
||
use vector_types::vector::StrokeId;
|
||
use vector_types::vector::misc::ExtrudeJoiningAlgorithm;
|
||
|
||
/// Convert [`vector_types::subpath::Bezier`] to [`kurbo::PathSeg`].
|
||
fn bezier_to_path_seg(bezier: vector_types::subpath::Bezier) -> kurbo::PathSeg {
|
||
let [start, end] = [(bezier.start().x, bezier.start().y), (bezier.end().x, bezier.end().y)];
|
||
match bezier.handles {
|
||
BezierHandles::Linear => kurbo::Line::new(start, end).into(),
|
||
BezierHandles::Quadratic { handle } => kurbo::QuadBez::new(start, (handle.x, handle.y), end).into(),
|
||
BezierHandles::Cubic { handle_start, handle_end } => kurbo::CubicBez::new(start, (handle_start.x, handle_start.y), (handle_end.x, handle_end.y), end).into(),
|
||
}
|
||
}
|
||
|
||
/// Convert [`kurbo::CubicBez`] to [`vector_types::subpath::BezierHandles`].
|
||
fn cubic_to_handles(cubic_bez: kurbo::CubicBez) -> BezierHandles {
|
||
BezierHandles::Cubic {
|
||
handle_start: DVec2::new(cubic_bez.p1.x, cubic_bez.p1.y),
|
||
handle_end: DVec2::new(cubic_bez.p2.x, cubic_bez.p2.y),
|
||
}
|
||
}
|
||
|
||
/// Find the `t` values to split (where the tangent changes to be on the other side of the direction).
|
||
fn find_splits(cubic_segment: kurbo::CubicBez, direction: DVec2) -> impl Iterator<Item = f64> {
|
||
let derivative = cubic_segment.deriv();
|
||
let convert = |x: kurbo::Point| DVec2::new(x.x, x.y);
|
||
let derivative_points = [derivative.p0, derivative.p1, derivative.p2].map(convert);
|
||
|
||
let t_squared = derivative_points[0] - 2. * derivative_points[1] + derivative_points[2];
|
||
let t_scalar = -2. * derivative_points[0] + 2. * derivative_points[1];
|
||
let constant = derivative_points[0];
|
||
|
||
kurbo::common::solve_quadratic(constant.perp_dot(direction), t_scalar.perp_dot(direction), t_squared.perp_dot(direction))
|
||
.into_iter()
|
||
.filter(|&t| t > 1e-6 && t < 1. - 1e-6)
|
||
}
|
||
|
||
/// Split so segments no longer have tangents on both sides of the direction vector.
|
||
fn split(vector: &mut graphic_types::Vector, direction: DVec2) {
|
||
let segment_count = vector.segment_domain.ids().len();
|
||
let mut next_point = vector.point_domain.next_id();
|
||
let mut next_segment = vector.segment_domain.next_id();
|
||
|
||
for segment_index in 0..segment_count {
|
||
let (_, _, bezier) = vector.segment_points_from_index(segment_index);
|
||
let mut start_index = vector.segment_domain.start_point()[segment_index];
|
||
let pathseg = bezier_to_path_seg(bezier).to_cubic();
|
||
let mut start_t = 0.;
|
||
|
||
for split_t in find_splits(pathseg, direction) {
|
||
let [first, second] = [pathseg.subsegment(start_t..split_t), pathseg.subsegment(split_t..1.)];
|
||
let [first_handles, second_handles] = [first, second].map(cubic_to_handles);
|
||
let middle_point = next_point.next_id();
|
||
let start_segment = next_segment.next_id();
|
||
|
||
let middle_point_index = vector.point_domain.len();
|
||
vector.point_domain.push(middle_point, DVec2::new(first.end().x, first.end().y));
|
||
vector.segment_domain.push(start_segment, start_index, middle_point_index, first_handles, StrokeId::ZERO);
|
||
vector.segment_domain.set_start_point(segment_index, middle_point_index);
|
||
vector.segment_domain.set_handles(segment_index, second_handles);
|
||
|
||
start_t = split_t;
|
||
start_index = middle_point_index;
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Copy all segments with the offset of `direction`.
|
||
fn offset_copy_all_segments(vector: &mut graphic_types::Vector, direction: DVec2) {
|
||
let points_count = vector.point_domain.ids().len();
|
||
let mut next_point = vector.point_domain.next_id();
|
||
for index in 0..points_count {
|
||
vector.point_domain.push(next_point.next_id(), vector.point_domain.positions()[index] + direction);
|
||
}
|
||
|
||
let segment_count = vector.segment_domain.ids().len();
|
||
let mut next_segment = vector.segment_domain.next_id();
|
||
for index in 0..segment_count {
|
||
vector.segment_domain.push(
|
||
next_segment.next_id(),
|
||
vector.segment_domain.start_point()[index] + points_count,
|
||
vector.segment_domain.end_point()[index] + points_count,
|
||
vector.segment_domain.handles()[index].apply_transformation(|x| x + direction),
|
||
vector.segment_domain.stroke()[index],
|
||
);
|
||
}
|
||
}
|
||
|
||
/// Join points from the original to the copied that are on opposite sides of the direction.
|
||
fn join_extrema_edges(vector: &mut graphic_types::Vector, direction: DVec2) {
|
||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||
enum Found {
|
||
#[default]
|
||
None,
|
||
Positive,
|
||
Negative,
|
||
Both,
|
||
Invalid,
|
||
}
|
||
|
||
impl Found {
|
||
fn update(&mut self, value: f64) {
|
||
*self = match (*self, value > 0.) {
|
||
(Found::None, true) => Found::Positive,
|
||
(Found::None, false) => Found::Negative,
|
||
(Found::Positive, true) | (Found::Negative, false) => Found::Both,
|
||
_ => Found::Invalid,
|
||
};
|
||
}
|
||
}
|
||
|
||
let first_half_points = vector.point_domain.len() / 2;
|
||
let mut points = vec![Found::None; first_half_points];
|
||
let first_half_segments = vector.segment_domain.ids().len() / 2;
|
||
|
||
for segment_id in 0..first_half_segments {
|
||
let index = [vector.segment_domain.start_point()[segment_id], vector.segment_domain.end_point()[segment_id]];
|
||
let position = index.map(|index| vector.point_domain.positions()[index]);
|
||
|
||
if position[0].abs_diff_eq(position[1], 1e-6) {
|
||
continue; // Skip zero length segments
|
||
}
|
||
|
||
points[index[0]].update(direction.perp_dot(position[1] - position[0]));
|
||
points[index[1]].update(direction.perp_dot(position[0] - position[1]));
|
||
}
|
||
|
||
let mut next_segment = vector.segment_domain.next_id();
|
||
for (index, &point) in points.iter().enumerate().take(first_half_points) {
|
||
// Extrema are single connected points or points with both positive and negative values
|
||
if !matches!(point, Found::Both | Found::Positive | Found::Negative) {
|
||
continue;
|
||
}
|
||
|
||
vector
|
||
.segment_domain
|
||
.push(next_segment.next_id(), index, index + first_half_points, BezierHandles::Linear, StrokeId::ZERO);
|
||
}
|
||
}
|
||
|
||
/// Join all points from the original to the copied.
|
||
fn join_all(vector: &mut graphic_types::Vector) {
|
||
let mut next_segment = vector.segment_domain.next_id();
|
||
let first_half = vector.point_domain.len() / 2;
|
||
for index in 0..first_half {
|
||
vector.segment_domain.push(next_segment.next_id(), index, index + first_half, BezierHandles::Linear, StrokeId::ZERO);
|
||
}
|
||
}
|
||
|
||
pub fn extrude(vector: &mut graphic_types::Vector, direction: DVec2, joining_algorithm: ExtrudeJoiningAlgorithm) {
|
||
split(vector, direction);
|
||
offset_copy_all_segments(vector, direction);
|
||
|
||
match joining_algorithm {
|
||
ExtrudeJoiningAlgorithm::Extrema => join_extrema_edges(vector, direction),
|
||
ExtrudeJoiningAlgorithm::All => join_all(vector),
|
||
ExtrudeJoiningAlgorithm::None => {}
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod extrude_tests {
|
||
use glam::DVec2;
|
||
use kurbo::{ParamCurve, ParamCurveDeriv};
|
||
|
||
#[test]
|
||
fn split_cubic() {
|
||
let l1 = kurbo::CubicBez::new((0., 0.), (100., 0.), (100., 100.), (0., 100.));
|
||
assert_eq!(super::find_splits(l1, DVec2::Y).collect::<Vec<f64>>(), vec![0.5]);
|
||
assert!(super::find_splits(l1, DVec2::X).collect::<Vec<f64>>().is_empty());
|
||
|
||
let l2 = kurbo::CubicBez::new((0., 0.), (0., 0.), (100., 0.), (100., 0.));
|
||
assert!(super::find_splits(l2, DVec2::X).collect::<Vec<f64>>().is_empty());
|
||
|
||
let l3 = kurbo::PathSeg::Line(kurbo::Line::new((0., 0.), (100., 0.)));
|
||
assert!(super::find_splits(l3.to_cubic(), DVec2::X).collect::<Vec<f64>>().is_empty());
|
||
|
||
let l4 = kurbo::CubicBez::new((0., 0.), (100., -10.), (100., 110.), (0., 100.));
|
||
let splits = super::find_splits(l4, DVec2::X).map(|t| l4.deriv().eval(t)).collect::<Vec<_>>();
|
||
assert_eq!(splits.len(), 2);
|
||
assert!(splits.iter().all(|&deriv| deriv.y.abs() < 1e-8), "{splits:?}");
|
||
}
|
||
|
||
#[test]
|
||
fn split_vector() {
|
||
let curve = kurbo::PathSeg::Cubic(kurbo::CubicBez::new((0., 0.), (100., -10.), (100., 110.), (0., 100.)));
|
||
let mut vector = graphic_types::Vector::from_bezpath(kurbo::BezPath::from_path_segments([curve].into_iter()));
|
||
super::split(&mut vector, DVec2::X);
|
||
assert_eq!(vector.segment_ids().len(), 3);
|
||
assert_eq!(vector.point_domain.ids().len(), 4);
|
||
}
|
||
}
|
||
}
|
||
|
||
#[node_macro::node(category("Vector: Modifier"), path(core_types::vector))]
|
||
async fn extrude(_: impl Ctx, source: Item<Vector>, direction: Item<DVec2>, joining_algorithm: Item<ExtrudeJoiningAlgorithm>) -> Item<Vector> {
|
||
let mut source = source;
|
||
let (direction, joining_algorithm) = (*direction.element(), *joining_algorithm.element());
|
||
|
||
extrude_algorithms::extrude(source.element_mut(), direction, joining_algorithm);
|
||
source
|
||
}
|
||
|
||
#[node_macro::node(category("Vector: Modifier"), path(core_types::vector))]
|
||
async fn box_warp(_: impl Ctx, content: Item<Vector>, #[expose] rectangle: Item<Vector>) -> Item<Vector> {
|
||
let target_transform: DAffine2 = rectangle.attribute_cloned_or_default(ATTR_TRANSFORM);
|
||
let target = rectangle.into_element();
|
||
|
||
let mut row = content;
|
||
{
|
||
let transform: DAffine2 = row.attribute_cloned_or_default(ATTR_TRANSFORM);
|
||
let vector = std::mem::take(row.element_mut());
|
||
|
||
// Get the bounding box of the source vector geometry
|
||
let source_bbox = vector.bounding_box_with_transform(transform).unwrap_or([DVec2::ZERO, DVec2::ONE]);
|
||
|
||
// Extract first 4 points from target shape to form the quadrilateral
|
||
// Apply the target's transform to get points in world space
|
||
let target_points: Vec<DVec2> = target.point_domain.positions().iter().map(|&p| target_transform.transform_point2(p)).take(4).collect();
|
||
|
||
// If we have fewer than 4 points, use the corners of the source bounding box
|
||
// This handles the degenerative case
|
||
let dst_corners = if target_points.len() >= 4 {
|
||
[target_points[0], target_points[1], target_points[2], target_points[3]]
|
||
} else {
|
||
warn!("Target shape has fewer than 4 points. Using source bounding box instead.");
|
||
[
|
||
source_bbox[0],
|
||
DVec2::new(source_bbox[1].x, source_bbox[0].y),
|
||
source_bbox[1],
|
||
DVec2::new(source_bbox[0].x, source_bbox[1].y),
|
||
]
|
||
};
|
||
|
||
// Apply the warp
|
||
let mut result = vector.clone();
|
||
|
||
// Precompute source bounding box size for normalization
|
||
let source_size = source_bbox[1] - source_bbox[0];
|
||
|
||
// Transform points
|
||
for (_, position) in result.point_domain.positions_mut() {
|
||
// Get the point in world space
|
||
let world_pos = transform.transform_point2(*position);
|
||
|
||
// Normalize coordinates within the source bounding box
|
||
let t = ((world_pos - source_bbox[0]) / source_size).clamp(DVec2::ZERO, DVec2::ONE);
|
||
|
||
// Apply bilinear interpolation
|
||
*position = bilinear_interpolate(t, &dst_corners);
|
||
}
|
||
|
||
// Transform handles in bezier curves
|
||
for (_, handles, _, _) in result.handles_mut() {
|
||
*handles = handles.apply_transformation(|pos| {
|
||
// Get the handle in world space
|
||
let world_pos = transform.transform_point2(pos);
|
||
|
||
// Normalize coordinates within the source bounding box
|
||
let t = ((world_pos - source_bbox[0]) / source_size).clamp(DVec2::ZERO, DVec2::ONE);
|
||
|
||
// Apply bilinear interpolation
|
||
bilinear_interpolate(t, &dst_corners)
|
||
});
|
||
}
|
||
|
||
result.set_stroke_transform(DAffine2::IDENTITY);
|
||
|
||
// Reset the transform since we've applied it directly to the points
|
||
*row.element_mut() = result;
|
||
row.set_attribute(ATTR_TRANSFORM, DAffine2::IDENTITY);
|
||
}
|
||
row
|
||
}
|
||
|
||
// Interpolate within a quadrilateral using normalized coordinates (0-1)
|
||
fn bilinear_interpolate(t: DVec2, quad: &[DVec2; 4]) -> DVec2 {
|
||
let tl = quad[0]; // Top-left
|
||
let tr = quad[1]; // Top-right
|
||
let br = quad[2]; // Bottom-right
|
||
let bl = quad[3]; // Bottom-left
|
||
|
||
// Bilinear interpolation
|
||
tl * (1. - t.x) * (1. - t.y) + tr * t.x * (1. - t.y) + br * t.x * t.y + bl * (1. - t.x) * t.y
|
||
}
|
||
|
||
#[node_macro::node(category("Vector"), path(graphene_core::vector))]
|
||
async fn pack_strips<T: 'n + Send + Clone>(
|
||
_: impl Ctx,
|
||
#[implementations(
|
||
List<Graphic>,
|
||
List<Vector>,
|
||
List<Raster<CPU>>,
|
||
List<Raster<GPU>>,
|
||
)]
|
||
elements: List<T>,
|
||
#[default(0.)]
|
||
#[unit(" px")]
|
||
separation: Item<f64>,
|
||
#[default(1000.)]
|
||
#[unit(" px")]
|
||
strip_max_length: Item<f64>,
|
||
strip_direction: Item<RowsOrColumns>,
|
||
) -> List<T>
|
||
where
|
||
Graphic: From<List<T>>,
|
||
List<T>: BoundingBox,
|
||
{
|
||
let (separation, strip_max_length, strip_direction) = (separation.into_element(), strip_max_length.into_element(), strip_direction.into_element());
|
||
|
||
// Packs shapes using bounds with Best-Fit Decreasing Height (BFDH) algorithm:
|
||
// - Sort shapes by cross-axis size (tallest first for rows, widest first for columns)
|
||
// - For each shape, find the existing strip with minimum remaining space that fits
|
||
// - Create new strip only if no existing strip can accommodate the shape
|
||
|
||
struct Strip {
|
||
along_position: f64,
|
||
cross_position: f64,
|
||
cross_extent: f64,
|
||
}
|
||
|
||
// Prepare the items to be sorted
|
||
let mut items: Vec<(f64, f64, DVec2, Item<T>)> = elements
|
||
.into_iter()
|
||
.map(|row| {
|
||
// Single-item `List` to query its bounding box
|
||
let single = List::new_from_item(row.clone());
|
||
let (w, h, top_left) = match single.bounding_box(DAffine2::IDENTITY, false) {
|
||
RenderBoundingBox::Rectangle([min, max]) => {
|
||
let size = max - min;
|
||
(size.x.max(0.), size.y.max(0.), min)
|
||
}
|
||
_ => (0., 0., DVec2::ZERO),
|
||
};
|
||
let (along, cross) = match strip_direction {
|
||
RowsOrColumns::Rows => (w, h),
|
||
RowsOrColumns::Columns => (h, w),
|
||
};
|
||
(along, cross, top_left, row)
|
||
})
|
||
.collect();
|
||
|
||
// Sort by cross-axis size, largest first
|
||
items.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(Ordering::Equal));
|
||
|
||
let mut result = List::new();
|
||
let mut strips: Vec<Strip> = Vec::new();
|
||
|
||
// This looks n^2 but it is just n*k where k is the number of strips, which is generally much smaller than n
|
||
for (along, cross, top_left, mut row) in items {
|
||
if along <= 0. {
|
||
result.push(row);
|
||
continue;
|
||
}
|
||
|
||
// Find a good strip, minimum remaining space that can fit this item ideally
|
||
let mut best_strip_index = None;
|
||
let mut min_remaining_space = f64::INFINITY;
|
||
|
||
for (index, strip) in strips.iter().enumerate() {
|
||
let remaining_space = strip_max_length - strip.along_position;
|
||
if remaining_space >= along && remaining_space < min_remaining_space {
|
||
min_remaining_space = remaining_space;
|
||
best_strip_index = Some(index);
|
||
}
|
||
}
|
||
|
||
if let Some(strip_index) = best_strip_index {
|
||
// Place on existing strip
|
||
let strip = &mut strips[strip_index];
|
||
|
||
// Update strip cross extent if needed
|
||
if cross > strip.cross_extent {
|
||
strip.cross_extent = cross;
|
||
}
|
||
|
||
let target_position = match strip_direction {
|
||
RowsOrColumns::Rows => DVec2::new(strip.along_position, strip.cross_position),
|
||
RowsOrColumns::Columns => DVec2::new(strip.cross_position, strip.along_position),
|
||
};
|
||
let row_transform: DAffine2 = row.attribute_cloned_or_default(ATTR_TRANSFORM);
|
||
row.set_attribute(ATTR_TRANSFORM, DAffine2::from_translation(target_position - top_left) * row_transform);
|
||
|
||
strip.along_position += along + separation;
|
||
} else {
|
||
// Create new strip
|
||
let new_cross = strips.last().map_or(0., |last| last.cross_position + last.cross_extent + separation);
|
||
|
||
let target_position = match strip_direction {
|
||
RowsOrColumns::Rows => DVec2::new(0., new_cross),
|
||
RowsOrColumns::Columns => DVec2::new(new_cross, 0.),
|
||
};
|
||
let row_transform: DAffine2 = row.attribute_cloned_or_default(ATTR_TRANSFORM);
|
||
row.set_attribute(ATTR_TRANSFORM, DAffine2::from_translation(target_position - top_left) * row_transform);
|
||
|
||
strips.push(Strip {
|
||
along_position: along + separation,
|
||
cross_position: new_cross,
|
||
cross_extent: cross,
|
||
});
|
||
}
|
||
|
||
result.push(row);
|
||
}
|
||
|
||
result
|
||
}
|
||
|
||
/// Automatically constructs tangents (Bézier handles) for anchor points in a vector path.
|
||
#[node_macro::node(category("Vector: Modifier"), name("Auto-Tangents"), path(core_types::vector))]
|
||
async fn auto_tangents(
|
||
_: impl Ctx,
|
||
source: Item<Vector>,
|
||
/// The amount of spread for the auto-tangents, from 0 (sharp corner) to 1 (full spread).
|
||
#[default(0.5)]
|
||
#[range]
|
||
#[soft(0..1)]
|
||
spread: Item<f64>,
|
||
/// If active, existing non-zero handles won't be affected.
|
||
#[default(true)]
|
||
preserve_existing: Item<bool>,
|
||
) -> Item<Vector> {
|
||
let (spread, preserve_existing) = (*spread.element(), *preserve_existing.element());
|
||
|
||
let transform: DAffine2 = source.attribute_cloned_or_default(ATTR_TRANSFORM);
|
||
let (source, attributes) = source.into_parts();
|
||
|
||
let mut result = Vector {
|
||
stroke: source.stroke.clone(),
|
||
..Default::default()
|
||
};
|
||
|
||
for mut subpath in source.stroke_bezier_paths() {
|
||
subpath.apply_transform(transform);
|
||
|
||
let manipulators_list = subpath.manipulator_groups();
|
||
if manipulators_list.len() < 2 {
|
||
// Not enough points for softening or handle removal
|
||
result.append_subpath(subpath, true);
|
||
continue;
|
||
}
|
||
|
||
let mut new_manipulators_list = Vec::with_capacity(manipulators_list.len());
|
||
// Track which manipulator indices were given auto-tangent (colinear) handles
|
||
let mut auto_tangented = vec![false; manipulators_list.len()];
|
||
let is_closed = subpath.closed();
|
||
|
||
for i in 0..manipulators_list.len() {
|
||
let current = &manipulators_list[i];
|
||
let is_endpoint = !is_closed && (i == 0 || i == manipulators_list.len() - 1);
|
||
|
||
if preserve_existing {
|
||
// Check if this point has handles that are meaningfully different from the anchor
|
||
let has_handles = (current.in_handle.is_some() && !current.in_handle.unwrap().abs_diff_eq(current.anchor, 1e-5))
|
||
|| (current.out_handle.is_some() && !current.out_handle.unwrap().abs_diff_eq(current.anchor, 1e-5));
|
||
|
||
// If the point already has handles, keep it as is
|
||
if has_handles {
|
||
new_manipulators_list.push(*current);
|
||
continue;
|
||
}
|
||
}
|
||
|
||
// If spread is 0, remove handles for this point, making it a sharp corner
|
||
if spread == 0. {
|
||
new_manipulators_list.push(ManipulatorGroup {
|
||
anchor: current.anchor,
|
||
in_handle: None,
|
||
out_handle: None,
|
||
id: current.id,
|
||
});
|
||
continue;
|
||
}
|
||
|
||
// Endpoints of open paths get zero-length cubic handles so adjacent segments remain cubic (not quadratic)
|
||
if is_endpoint {
|
||
new_manipulators_list.push(ManipulatorGroup {
|
||
anchor: current.anchor,
|
||
in_handle: Some(current.anchor),
|
||
out_handle: Some(current.anchor),
|
||
id: current.id,
|
||
});
|
||
continue;
|
||
}
|
||
|
||
// Get previous and next points for auto-tangent calculation
|
||
let prev_index = if i == 0 { manipulators_list.len() - 1 } else { i - 1 };
|
||
let next_index = if i == manipulators_list.len() - 1 { 0 } else { i + 1 };
|
||
|
||
let current_position = current.anchor;
|
||
let delta_prev = manipulators_list[prev_index].anchor - current_position;
|
||
let delta_next = manipulators_list[next_index].anchor - current_position;
|
||
|
||
// Calculate normalized directions and distances to adjacent points
|
||
let distance_prev = delta_prev.length();
|
||
let distance_next = delta_next.length();
|
||
|
||
// Check if we have valid directions (e.g., points are not coincident)
|
||
if distance_prev < 1e-5 || distance_next < 1e-5 {
|
||
// Fallback: keep the original manipulator group (which has no active handles here)
|
||
new_manipulators_list.push(*current);
|
||
continue;
|
||
}
|
||
|
||
let direction_prev = delta_prev / distance_prev;
|
||
let direction_next = delta_next / distance_next;
|
||
|
||
// Calculate handle direction as the bisector of the two normalized directions.
|
||
// This ensures the in and out handles are colinear (180° apart) through the anchor.
|
||
let mut handle_direction = (direction_prev - direction_next).try_normalize().unwrap_or_else(|| direction_prev.perp());
|
||
|
||
// Ensure consistent orientation of the handle direction.
|
||
// This makes the `+ handle_direction` for in_handle and `- handle_direction` for out_handle consistent.
|
||
if direction_prev.dot(handle_direction) < 0. {
|
||
handle_direction = -handle_direction;
|
||
}
|
||
|
||
// Calculate handle lengths: 1/3 of distance to adjacent points, scaled by spread
|
||
let in_length = distance_prev / 3. * spread;
|
||
let out_length = distance_next / 3. * spread;
|
||
|
||
// Create new manipulator group with calculated auto-tangents
|
||
new_manipulators_list.push(ManipulatorGroup {
|
||
anchor: current_position,
|
||
in_handle: Some(current_position + handle_direction * in_length),
|
||
out_handle: Some(current_position - handle_direction * out_length),
|
||
id: current.id,
|
||
});
|
||
auto_tangented[i] = true;
|
||
}
|
||
|
||
// Record segment count before appending so we can find the new segment IDs
|
||
let segment_offset = result.segment_domain.ids().len();
|
||
|
||
let mut softened_bezpath = bezpath_from_manipulator_groups(&new_manipulators_list, is_closed);
|
||
softened_bezpath.apply_affine(Affine::new(transform.inverse().to_cols_array()));
|
||
result.append_bezpath(softened_bezpath);
|
||
|
||
// Mark auto-tangented points as having colinear handles
|
||
let segment_ids = result.segment_domain.ids();
|
||
let num_manipulators = new_manipulators_list.len();
|
||
for (i, _) in auto_tangented.iter().enumerate().filter(|&(_, &tangented)| tangented) {
|
||
// For interior point i, the incoming segment is segment_offset + (i - 1) and outgoing is segment_offset + i.
|
||
// For closed paths, point 0's incoming segment is the last one (segment_offset + num_manipulators - 1).
|
||
// For open paths, endpoints are never auto-tangented (the `is_endpoint` check above ensures that),
|
||
// so `i == 0` and `i == num_manipulators - 1` only occur here when the path is closed
|
||
let in_segment_index = if i == 0 { segment_offset + num_manipulators - 1 } else { segment_offset + i - 1 };
|
||
let out_segment_index = if i == num_manipulators - 1 { segment_offset } else { segment_offset + i };
|
||
|
||
if in_segment_index < segment_ids.len() && out_segment_index < segment_ids.len() {
|
||
result
|
||
.colinear_manipulators
|
||
.push([HandleId::end(segment_ids[in_segment_index]), HandleId::primary(segment_ids[out_segment_index])]);
|
||
}
|
||
}
|
||
}
|
||
|
||
Item::from_parts(result, attributes)
|
||
}
|
||
|
||
#[node_macro::node(category("Vector: Modifier"), path(core_types::vector))]
|
||
async fn bounding_box(_: impl Ctx, content: Item<Vector>) -> Item<Vector> {
|
||
let mut content = content;
|
||
let mut result = content
|
||
.element()
|
||
.bounding_box_rect()
|
||
.map(|bbox| {
|
||
let mut vector = Vector::default();
|
||
vector.append_bezpath(bbox.to_path(DEFAULT_ACCURACY));
|
||
vector
|
||
})
|
||
.unwrap_or_default();
|
||
|
||
result.stroke = std::mem::take(&mut content.element_mut().stroke);
|
||
result.set_stroke_transform(DAffine2::IDENTITY);
|
||
|
||
*content.element_mut() = result;
|
||
content
|
||
}
|
||
|
||
#[node_macro::node(category("Vector: Measure"), path(core_types::vector))]
|
||
async fn dimensions(_: impl Ctx, content: Item<Vector>) -> Item<DVec2> {
|
||
let dimensions = content
|
||
.element()
|
||
.bounding_box_with_transform(content.attribute_cloned_or_default(ATTR_TRANSFORM))
|
||
.map(|[top_left, bottom_right]| bottom_right - top_left)
|
||
.unwrap_or_default();
|
||
|
||
Item::new_from_element(dimensions)
|
||
}
|
||
|
||
/// Type-asserts a value to be vector data.
|
||
#[node_macro::node(category("Vector"), name("As Vector"), path(core_types::vector))]
|
||
fn as_vector(_: impl Ctx, value: Item<Vector>) -> Item<Vector> {
|
||
value
|
||
}
|
||
|
||
/// Creates a polyline from a series of vector points, replacing any existing segments and regions that may already exist.
|
||
#[node_macro::node(category("Vector"), name("Points to Polyline"), path(core_types::vector))]
|
||
async fn points_to_polyline(_: impl Ctx, points: Item<Vector>, #[default(true)] closed: Item<bool>) -> Item<Vector> {
|
||
let mut points = points;
|
||
let closed = *closed.element();
|
||
|
||
let vector = points.element_mut();
|
||
|
||
let mut segment_domain = SegmentDomain::new();
|
||
let mut next_id = SegmentId::ZERO;
|
||
|
||
let points_count = vector.point_domain.ids().len();
|
||
|
||
if points_count >= 2 {
|
||
(0..points_count - 1).for_each(|i| {
|
||
segment_domain.push(next_id.next_id(), i, i + 1, BezierHandles::Linear, StrokeId::generate());
|
||
});
|
||
|
||
if closed && points_count != 2 {
|
||
segment_domain.push(next_id.next_id(), points_count - 1, 0, BezierHandles::Linear, StrokeId::generate());
|
||
|
||
vector
|
||
.region_domain
|
||
.push(RegionId::generate(), segment_domain.ids()[0]..=*segment_domain.ids().last().unwrap(), FillId::generate());
|
||
}
|
||
}
|
||
|
||
vector.segment_domain = segment_domain;
|
||
|
||
points
|
||
}
|
||
|
||
/// Evens out the distances between points by applying Lloyd's relaxation, moving every interior point toward the center of its Voronoi cell.
|
||
#[node_macro::node(category("Vector: Modifier"), path(core_types::vector))]
|
||
async fn relax_points(
|
||
_: impl Ctx,
|
||
/// A vector path or point cloud to relax.
|
||
source: Item<Vector>,
|
||
/// The number of relaxation steps to apply. A fractional value runs the whole steps and then blends partway toward one more step, so the amount of relaxation can be animated smoothly.
|
||
#[default(1.)]
|
||
#[hard(0..1000)]
|
||
iterations: Item<f64>,
|
||
) -> Item<Vector> {
|
||
let mut source = source;
|
||
let iterations = *iterations.element();
|
||
|
||
let vector = source.element_mut();
|
||
let relaxed = crate::voronoi::relax_sites(vector.point_domain.positions(), iterations);
|
||
for ((_, position), new_position) in vector.point_domain.positions_mut().zip(relaxed) {
|
||
*position = new_position;
|
||
}
|
||
|
||
source
|
||
}
|
||
|
||
/// Builds a Voronoi diagram from the anchor points. Each point claims the region of space closest to it, and those regions tessellate the plane. Cells around the outside are clipped to the convex hull of the points so the diagram stays finite.
|
||
///
|
||
/// When Connect Cells is off, every cell becomes its own closed, fillable subpath. When on, the cells share their common points and segments, forming a single connected mesh with no fillable regions.
|
||
#[node_macro::node(category("Vector"), path(core_types::vector))]
|
||
async fn voronoi_cells(_: impl Ctx, source: Item<Vector>, connect_cells: Item<bool>) -> Item<Vector> {
|
||
let mut source = source;
|
||
let connect_cells = *connect_cells.element();
|
||
|
||
let vector = source.element_mut();
|
||
let sites = vector.point_domain.positions().to_vec();
|
||
let cells = crate::voronoi::voronoi_cells(&sites);
|
||
if !cells.is_empty() {
|
||
replace_with_polygons(vector, cells, connect_cells);
|
||
}
|
||
|
||
source
|
||
}
|
||
|
||
/// Builds a Delaunay triangulation connecting the anchor points. It is the geometric dual of the **Voronoi** node: a mesh of triangles in which no point lies inside any triangle's circumscribed circle.
|
||
///
|
||
/// When Connect Cells is off, every triangle becomes its own closed, fillable subpath. When on, the triangles share their common points and segments, forming a single connected mesh with no fillable regions.
|
||
#[node_macro::node(category("Vector"), path(core_types::vector))]
|
||
async fn triangulate(_: impl Ctx, source: Item<Vector>, connect_cells: Item<bool>) -> Item<Vector> {
|
||
let mut source = source;
|
||
let connect_cells = *connect_cells.element();
|
||
|
||
let vector = source.element_mut();
|
||
let sites = vector.point_domain.positions().to_vec();
|
||
let triangles = crate::voronoi::delaunay_triangles(&sites);
|
||
if !triangles.is_empty() {
|
||
// `delaunator` emits triangle vertices clockwise; reverse to `[a, c, b]` so triangles wind counter-clockwise to
|
||
// match the Voronoi cells and the rest of the framework's fill winding.
|
||
let polygons = triangles.iter().map(|&[a, b, c]| vec![sites[a], sites[c], sites[b]]).collect();
|
||
replace_with_polygons(vector, polygons, connect_cells);
|
||
}
|
||
|
||
source
|
||
}
|
||
|
||
/// Replaces a vector's geometry (points, segments, and regions) with the given closed polygons, preserving its style.
|
||
///
|
||
/// Without `connect_cells`, each polygon becomes its own closed subpath with a fillable region.
|
||
/// With it, coincident vertices are welded and each shared edge is emitted once, producing a connected mesh with no regions.
|
||
pub(crate) fn replace_with_polygons(vector: &mut Vector, polygons: Vec<Vec<DVec2>>, connect_cells: bool) {
|
||
let mut point_domain = PointDomain::new();
|
||
let mut segment_domain = SegmentDomain::new();
|
||
let mut region_domain = RegionDomain::new();
|
||
let mut next_point = PointId::ZERO;
|
||
let mut next_segment = SegmentId::ZERO;
|
||
let mut next_region = RegionId::ZERO;
|
||
|
||
if !connect_cells {
|
||
for polygon in &polygons {
|
||
if polygon.len() < 3 {
|
||
continue;
|
||
}
|
||
|
||
let base = point_domain.ids().len();
|
||
for &position in polygon {
|
||
point_domain.push(next_point.next_id(), position);
|
||
}
|
||
|
||
let count = polygon.len();
|
||
let mut first_segment = None;
|
||
let mut last_segment = None;
|
||
for i in 0..count {
|
||
let start = base + i;
|
||
let end = base + (i + 1) % count;
|
||
let id = next_segment.next_id();
|
||
first_segment.get_or_insert(id);
|
||
last_segment = Some(id);
|
||
segment_domain.push(id, start, end, BezierHandles::Linear, StrokeId::ZERO);
|
||
}
|
||
|
||
if let (Some(first), Some(last)) = (first_segment, last_segment) {
|
||
region_domain.push(next_region.next_id(), first..=last, FillId::ZERO);
|
||
}
|
||
}
|
||
} else {
|
||
// Weld vertices that fall in the same quantization cell so adjacent polygons share points,
|
||
// and emit each undirected edge only once so adjacent polygons share segments.
|
||
let tolerance = mesh_weld_tolerance(&polygons);
|
||
let mut vertex_lookup: HashMap<(i64, i64), usize> = HashMap::new();
|
||
let mut seen_edges: HashSet<(usize, usize)> = HashSet::new();
|
||
|
||
for polygon in &polygons {
|
||
if polygon.len() < 2 {
|
||
continue;
|
||
}
|
||
|
||
let indices: Vec<usize> = polygon
|
||
.iter()
|
||
.map(|&position| {
|
||
let key = ((position.x / tolerance).round() as i64, (position.y / tolerance).round() as i64);
|
||
*vertex_lookup.entry(key).or_insert_with(|| {
|
||
let index = point_domain.ids().len();
|
||
point_domain.push(next_point.next_id(), position);
|
||
index
|
||
})
|
||
})
|
||
.collect();
|
||
|
||
let count = indices.len();
|
||
for i in 0..count {
|
||
let start = indices[i];
|
||
let end = indices[(i + 1) % count];
|
||
if start == end {
|
||
continue;
|
||
}
|
||
let edge = if start < end { (start, end) } else { (end, start) };
|
||
if seen_edges.insert(edge) {
|
||
segment_domain.push(next_segment.next_id(), start, end, BezierHandles::Linear, StrokeId::ZERO);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
vector.point_domain = point_domain;
|
||
vector.segment_domain = segment_domain;
|
||
vector.region_domain = region_domain;
|
||
}
|
||
|
||
/// The distance below which two mesh vertices are welded into one, scaled to the diagram's size so it tracks coordinate magnitude.
|
||
fn mesh_weld_tolerance(polygons: &[Vec<DVec2>]) -> f64 {
|
||
let mut min = DVec2::splat(f64::MAX);
|
||
let mut max = DVec2::splat(f64::MIN);
|
||
for polygon in polygons {
|
||
for &position in polygon {
|
||
min = min.min(position);
|
||
max = max.max(position);
|
||
}
|
||
}
|
||
|
||
let diagonal = (max - min).length();
|
||
// Floor the tolerance so an extremely tiny diagram can't underflow `diagonal * 1e-6` to zero, which would divide by
|
||
// zero when quantizing vertices and weld everything into a single point.
|
||
if diagonal.is_finite() && diagonal > 0. { (diagonal * 1e-6).max(1e-12) } else { 1e-6 }
|
||
}
|
||
|
||
#[node_macro::node(category("Vector: Modifier"), path(core_types::vector), properties("offset_path_properties"))]
|
||
async fn offset_path(_: impl Ctx, content: Item<Vector>, distance: Item<f64>, join: Item<StrokeJoin>, #[default(4.)] miter_limit: Item<f64>) -> Item<Vector> {
|
||
let mut content = content;
|
||
let (distance, join, miter_limit) = (*distance.element(), *join.element(), *miter_limit.element());
|
||
|
||
let transform_attribute: DAffine2 = content.attribute_cloned_or_default(ATTR_TRANSFORM);
|
||
let transform = Affine::new(transform_attribute.to_cols_array());
|
||
let vector = std::mem::take(content.element_mut());
|
||
|
||
let bezpaths = vector.stroke_bezpath_iter();
|
||
let mut result = Vector {
|
||
stroke: vector.stroke.clone(),
|
||
..Default::default()
|
||
};
|
||
result.set_stroke_transform(DAffine2::IDENTITY);
|
||
|
||
// Perform operation on all subpaths in this shape.
|
||
for mut bezpath in bezpaths {
|
||
bezpath.apply_affine(transform);
|
||
|
||
// Taking the existing stroke data and passing it to Kurbo to generate new paths.
|
||
let mut bezpath_out = offset_bezpath(
|
||
&bezpath,
|
||
-distance,
|
||
match join {
|
||
StrokeJoin::Miter => kurbo::Join::Miter,
|
||
StrokeJoin::Bevel => kurbo::Join::Bevel,
|
||
StrokeJoin::Round => kurbo::Join::Round,
|
||
},
|
||
Some(miter_limit),
|
||
);
|
||
|
||
bezpath_out.apply_affine(transform.inverse());
|
||
|
||
// One closed subpath, open path.
|
||
result.append_bezpath(bezpath_out);
|
||
}
|
||
|
||
*content.element_mut() = result;
|
||
content
|
||
}
|
||
|
||
#[node_macro::node(category("Vector: Modifier"), path(core_types::vector))]
|
||
async fn solidify_stroke<T: 'n + Send>(_: impl Ctx, #[implementations(Graphic, Vector)] content: Item<T>) -> List<Vector>
|
||
where
|
||
List<T>: IntoGraphicList,
|
||
{
|
||
// TODO: Make this node support stroke align, which it currently ignores
|
||
|
||
let graphic_list = List::new_from_item(content).into_graphic_list();
|
||
let flattened: List<Vector> = graphic_list.clone().into_flattened_list();
|
||
|
||
// A fill exists when the canonical attribute carries paint
|
||
let has_fills: Vec<bool> = (0..flattened.len()).map(|index| has_paint_at(&flattened, index, ATTR_FILL)).collect();
|
||
|
||
let mut output: List<Vector> = flattened
|
||
.into_iter()
|
||
.zip(has_fills)
|
||
.flat_map(|(row, has_fill)| {
|
||
let (mut vector, attributes) = row.into_parts();
|
||
|
||
let stroke = vector.stroke.clone().unwrap_or_default();
|
||
let bezpaths = vector.stroke_bezpath_iter();
|
||
let mut solidified_stroke = Vector::default();
|
||
|
||
// Taking the existing stroke data and passing it to kurbo::stroke to generate new fill paths.
|
||
let join = match stroke.join {
|
||
StrokeJoin::Miter => kurbo::Join::Miter,
|
||
StrokeJoin::Bevel => kurbo::Join::Bevel,
|
||
StrokeJoin::Round => kurbo::Join::Round,
|
||
};
|
||
let cap = match stroke.cap {
|
||
StrokeCap::Butt => kurbo::Cap::Butt,
|
||
StrokeCap::Round => kurbo::Cap::Round,
|
||
StrokeCap::Square => kurbo::Cap::Square,
|
||
};
|
||
let dash_offset = stroke.dash_offset;
|
||
let dash_pattern = stroke.dash_lengths;
|
||
let miter_limit = stroke.join_miter_limit;
|
||
let paint_order = stroke.paint_order;
|
||
|
||
let stroke_style = kurbo::Stroke::new(stroke.weight)
|
||
.with_caps(cap)
|
||
.with_join(join)
|
||
.with_dashes(dash_offset, dash_pattern)
|
||
.with_miter_limit(miter_limit);
|
||
|
||
// Pick `stable_dash_order` per subpath: closed subpaths use the default merge so the seam-spanning dash matches Vello/SVG renderers, while open subpaths use stable order so dashes are emitted in path-length sequence
|
||
let stroke_options_default = kurbo::StrokeOpts::default();
|
||
let stroke_options_stable = kurbo::StrokeOpts::default().stable_dash_order(true);
|
||
|
||
// 0.25 is balanced between performance and accuracy of the curve.
|
||
const STROKE_TOLERANCE: f64 = 0.25;
|
||
|
||
for mut path in bezpaths {
|
||
path.apply_affine(Affine::new(stroke.transform.to_cols_array()));
|
||
|
||
let is_closed = matches!(path.elements().last(), Some(kurbo::PathEl::ClosePath));
|
||
let stroke_options = if is_closed { &stroke_options_default } else { &stroke_options_stable };
|
||
|
||
let mut solidified = kurbo::stroke(path, &stroke_style, stroke_options, STROKE_TOLERANCE);
|
||
if stroke.transform.matrix2.determinant() != 0. {
|
||
solidified.apply_affine(Affine::new(stroke.transform.inverse().to_cols_array()));
|
||
}
|
||
|
||
solidified_stroke.append_bezpath(solidified);
|
||
}
|
||
|
||
// If the original vector has a fill, preserve it as a separate item with the stroke cleared.
|
||
let fill_row = has_fill.then(|| {
|
||
vector.stroke = None;
|
||
let mut fill_attributes = attributes.clone();
|
||
// No stroke remains on the fill row
|
||
fill_attributes.remove::<List<Graphic>>(ATTR_STROKE);
|
||
Item::from_parts(vector, fill_attributes)
|
||
});
|
||
|
||
let mut stroke_attributes = attributes;
|
||
// Drop the original fill and use the stroke paint to fill the outlined stroke
|
||
stroke_attributes.remove::<List<Graphic>>(ATTR_FILL);
|
||
stroke_attributes.rename(ATTR_STROKE, ATTR_FILL);
|
||
|
||
let stroke_row = Item::from_parts(solidified_stroke, stroke_attributes);
|
||
|
||
// Ordering based on the paint order. The first item in the `List` is rendered below the second.
|
||
match paint_order {
|
||
PaintOrder::StrokeAbove => fill_row.into_iter().chain(std::iter::once(stroke_row)).collect::<Vec<_>>(),
|
||
PaintOrder::StrokeBelow => std::iter::once(stroke_row).chain(fill_row).collect::<Vec<_>>(),
|
||
}
|
||
})
|
||
.collect();
|
||
|
||
// Snapshot the upstream content so the renderer can recurse into it for editor click-target preservation
|
||
// and surface the original pre-solidified `Vector` to the Path tool for editing.
|
||
if !output.is_empty() {
|
||
// Row 0 carries a composed transform inherited from the flattened input, but the merged_layers
|
||
// already holds the original transforms; pre-compensate by row 0's inverse so the renderer's
|
||
// `upstream_footprint *= row_0_transform` recursion cancels out and leaves the originals intact.
|
||
let mut graphic_list = graphic_list;
|
||
let row_0_transform: DAffine2 = output.attribute_cloned_or_default(ATTR_TRANSFORM, 0);
|
||
if row_0_transform.matrix2.determinant().abs() > f64::EPSILON {
|
||
let inverse = row_0_transform.inverse();
|
||
for transform in graphic_list.iter_attribute_values_mut_or_default::<DAffine2>(ATTR_TRANSFORM) {
|
||
*transform = inverse * *transform;
|
||
}
|
||
}
|
||
|
||
output.set_attribute(ATTR_EDITOR_MERGED_LAYERS, 0, graphic_list);
|
||
}
|
||
|
||
output
|
||
}
|
||
|
||
#[node_macro::node(category("Vector: Modifier"), path(core_types::vector))]
|
||
async fn separate_subpaths(_: impl Ctx, content: Item<Vector>) -> List<Vector> {
|
||
let bezpaths = content.element().stroke_bezpath_iter().collect::<Vec<_>>();
|
||
|
||
// Pass the original element through unchanged when it has no subpaths, so its attributes
|
||
// (such as the layer transform) survive downstream rather than being dropped along with the empty list.
|
||
if bezpaths.is_empty() {
|
||
return List::new_from_item(content);
|
||
}
|
||
|
||
let stroke = content.element().stroke.clone();
|
||
let (_, attributes) = content.into_parts();
|
||
|
||
bezpaths
|
||
.into_iter()
|
||
.map(|bezpath| {
|
||
let mut vector = Vector::default();
|
||
vector.append_bezpath(bezpath);
|
||
vector.stroke = stroke.clone();
|
||
|
||
Item::from_parts(vector, attributes.clone())
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
/// Determines if the subpath at the given index is closed, meaning its ends are connected together forming a loop.
|
||
#[node_macro::node(name("Path is Closed"), category("Vector: Measure"), path(core_types::vector))]
|
||
async fn path_is_closed(
|
||
_: impl Ctx,
|
||
/// The vector content whose subpaths are inspected.
|
||
content: Item<Vector>,
|
||
/// The index of the subpath to check, counting across the element's subpaths.
|
||
index: Item<f64>,
|
||
) -> Item<bool> {
|
||
let index = index.into_element();
|
||
let closed = content.element().build_stroke_path_iter().map(|(_, closed)| closed).nth(index.max(0.) as usize).unwrap_or(false);
|
||
|
||
Item::new_from_element(closed)
|
||
}
|
||
|
||
#[node_macro::node(category("Vector"), path(graphene_core::vector))]
|
||
async fn map_points(ctx: impl Ctx + CloneVarArgs + ExtractAll, content: Item<Vector>, mapped: impl Node<Context<'static>, Output = Item<DVec2>>) -> Item<Vector> {
|
||
let mut content = content;
|
||
|
||
for (index, (_, position)) in content.element_mut().point_domain.positions_mut().enumerate() {
|
||
let owned_ctx = OwnedContextImpl::from(ctx.clone()).with_index(index).with_position(*position);
|
||
|
||
*position = mapped.eval(owned_ctx.into_context()).await.into_element();
|
||
}
|
||
|
||
content
|
||
}
|
||
|
||
/// Combines every vector path across the input into a single compound path.
|
||
#[node_macro::node(category("Vector"), path(graphene_core::vector))]
|
||
pub async fn combine_paths<T: IntoGraphicList>(_: impl Ctx, #[implementations(List<Graphic>, List<Vector>)] content: T) -> Item<Vector> {
|
||
let graphic_list = content.into_graphic_list();
|
||
let flattened = graphic_list.clone().into_flattened_list::<Vector>();
|
||
|
||
// Create a `List` with one empty `Vector` element, then get a mutable reference to it which we append flattened subpaths to
|
||
let mut output_list = List::new_from_element(Vector::default());
|
||
let mut primary_source = None;
|
||
|
||
let output = output_list.element_mut(0).unwrap();
|
||
|
||
// Concatenate every vector element's subpaths into the single output compound path
|
||
for index in 0..flattened.len() {
|
||
let Some(element) = flattened.element(index) else { continue };
|
||
let layer_path: List<NodeId> = flattened.attribute_cloned_or_default::<NodeIdPath>(ATTR_EDITOR_LAYER_PATH, index).0;
|
||
let node_id = layer_path.iter_element_values().next_back().map(|node_id| node_id.0).unwrap_or_default();
|
||
|
||
let mut hasher = DefaultHasher::new();
|
||
(index, node_id).hash(&mut hasher);
|
||
let collision_hash_seed = hasher.finish();
|
||
|
||
let source_transform = flattened.attribute_cloned_or_default(ATTR_TRANSFORM, index);
|
||
output.concat(element, source_transform, collision_hash_seed);
|
||
|
||
// TODO: Make this instead use the first encountered stroke
|
||
// Use the last encountered stroke as the output stroke
|
||
output.stroke = element.stroke.clone();
|
||
|
||
primary_source = Some((index, source_transform));
|
||
}
|
||
|
||
if let Some((primary, source_transform)) = primary_source {
|
||
let source_attributes = flattened.clone_item_attributes(primary);
|
||
let mut attributes = ItemAttributeValues::new();
|
||
|
||
attributes.insert_cloned_from(&source_attributes, ATTR_FILL);
|
||
attributes.insert_cloned_from(&source_attributes, ATTR_STROKE);
|
||
// Adopt the last input item's layer (if any) so the editor can also bucket clicks under a contributing child layer
|
||
attributes.insert_cloned_from(&source_attributes, ATTR_EDITOR_LAYER_PATH);
|
||
bake_paint_transforms(&mut attributes, source_transform);
|
||
|
||
let output = std::mem::take(output_list.element_mut(0).unwrap());
|
||
output_list = List::new_from_item(Item::from_parts(output, attributes));
|
||
}
|
||
|
||
// Preserve a reference to the original upstream `List<Graphic>` so the renderer can recurse into it
|
||
// when collecting metadata, exposing the original child layers' click targets to editor tools.
|
||
// This is the same mechanism Boolean Operation uses to keep its inputs editable after the merge.
|
||
output_list.set_attribute(ATTR_EDITOR_MERGED_LAYERS, 0, graphic_list);
|
||
|
||
output_list.into_iter().next().unwrap_or_default()
|
||
}
|
||
|
||
/// Convert vector geometry into a polyline composed of evenly spaced points.
|
||
#[node_macro::node(category("Vector: Modifier"), path(core_types::vector), properties("sample_polyline_properties"), memoize)]
|
||
async fn sample_polyline(
|
||
_: impl Ctx,
|
||
content: Item<Vector>,
|
||
spacing: Item<PointSpacingType>,
|
||
#[default(100.)]
|
||
#[hard(0..)]
|
||
#[unit(" px")]
|
||
separation: Item<f64>,
|
||
#[default(100)]
|
||
#[hard(2..)]
|
||
quantity: Item<u32>,
|
||
#[hard(0..)]
|
||
#[unit(" px")]
|
||
start_offset: Item<f64>,
|
||
#[hard(0..)]
|
||
#[unit(" px")]
|
||
stop_offset: Item<f64>,
|
||
adaptive_spacing: Item<bool>,
|
||
) -> Item<Vector> {
|
||
let mut content = content;
|
||
let (spacing, separation, quantity) = (*spacing.element(), *separation.element(), *quantity.element());
|
||
let (start_offset, stop_offset, adaptive_spacing) = (*start_offset.element(), *stop_offset.element(), *adaptive_spacing.element());
|
||
|
||
let pathseg_perimeter = |segment: PathSeg| {
|
||
if is_linear(segment) {
|
||
Line::new(segment.start(), segment.end()).perimeter(DEFAULT_ACCURACY)
|
||
} else {
|
||
segment.perimeter(DEFAULT_ACCURACY)
|
||
}
|
||
};
|
||
|
||
let mut result = Vector {
|
||
point_domain: Default::default(),
|
||
segment_domain: Default::default(),
|
||
region_domain: Default::default(),
|
||
colinear_manipulators: Default::default(),
|
||
stroke: std::mem::take(&mut content.element_mut().stroke),
|
||
};
|
||
// Transfer the stroke transform from the input vector content to the result.
|
||
result.set_stroke_transform(content.attribute_cloned_or_default(ATTR_TRANSFORM));
|
||
|
||
for local_bezpath in content.element().stroke_bezpath_iter() {
|
||
// Apply the transform to compute sample locations in world space (for correct distance-based spacing)
|
||
let mut world_bezpath = local_bezpath.clone();
|
||
let transform_attribute: DAffine2 = content.attribute_cloned_or_default(ATTR_TRANSFORM);
|
||
world_bezpath.apply_affine(Affine::new(transform_attribute.to_cols_array()));
|
||
|
||
// Per-segment perimeter lengths (transform-baked) for distance-based spacing
|
||
let segment_lengths: Vec<f64> = world_bezpath.segments().map(pathseg_perimeter).collect();
|
||
|
||
let amount = match spacing {
|
||
PointSpacingType::Separation => separation,
|
||
PointSpacingType::Quantity => quantity as f64,
|
||
};
|
||
|
||
// 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, &segment_lengths) else {
|
||
continue;
|
||
};
|
||
|
||
// Evaluate the sample locations on the untransformed bezpath and append the result
|
||
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);
|
||
|
||
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);
|
||
}
|
||
|
||
*content.element_mut() = result;
|
||
content
|
||
}
|
||
|
||
/// Simplifies vector paths by reducing the number of curve segments while preserving the overall shape within the given tolerance.
|
||
#[node_macro::node(category("Vector: Modifier"), path(core_types::vector))]
|
||
async fn simplify(
|
||
_: impl Ctx,
|
||
/// The vector paths to simplify.
|
||
content: Item<Vector>,
|
||
/// The maximum distance the simplified path may deviate from the original.
|
||
#[default(5.)]
|
||
#[unit(" px")]
|
||
tolerance: Item<Length>,
|
||
) -> Item<Vector> {
|
||
let mut content = content;
|
||
let tolerance = *tolerance.element();
|
||
|
||
if tolerance <= 0. {
|
||
return content;
|
||
}
|
||
|
||
let options = SimplifyOptions::default();
|
||
|
||
let transform_attribute: DAffine2 = content.attribute_cloned_or_default(ATTR_TRANSFORM);
|
||
let transform = Affine::new(transform_attribute.to_cols_array());
|
||
let inverse_transform = transform.inverse();
|
||
|
||
let mut result = Vector {
|
||
stroke: std::mem::take(&mut content.element_mut().stroke),
|
||
..Default::default()
|
||
};
|
||
|
||
for mut bezpath in content.element().stroke_bezpath_iter() {
|
||
bezpath.apply_affine(transform);
|
||
|
||
let mut simplified = simplify_bezpath(bezpath, tolerance, &options);
|
||
|
||
simplified.apply_affine(inverse_transform);
|
||
result.append_bezpath(simplified);
|
||
}
|
||
|
||
*content.element_mut() = result;
|
||
content
|
||
}
|
||
|
||
/// Decimates vector paths into polylines by sampling any curves into line segments, then removing points that don't significantly contribute to the shape using the Ramer-Douglas-Peucker algorithm.
|
||
#[node_macro::node(category("Vector: Modifier"), path(core_types::vector))]
|
||
async fn decimate(
|
||
_: impl Ctx,
|
||
/// The vector paths to decimate.
|
||
content: Item<Vector>,
|
||
/// The maximum distance a point can deviate from the simplified path before it is kept.
|
||
#[default(5.)]
|
||
#[unit(" px")]
|
||
tolerance: Item<Length>,
|
||
) -> Item<Vector> {
|
||
let mut content = content;
|
||
let tolerance = *tolerance.element();
|
||
|
||
// Tolerance of 0 means no simplification is possible, so return immediately
|
||
if tolerance <= 0. {
|
||
return content;
|
||
}
|
||
|
||
// Below this squared length, a line segment is treated as a degenerate point and the distance
|
||
// falls back to a simple point-to-point measurement to avoid division by near-zero.
|
||
const NEAR_ZERO_LENGTH_SQUARED: f64 = 1e-20;
|
||
|
||
fn perpendicular_distance(point: DVec2, line_start: DVec2, line_end: DVec2) -> f64 {
|
||
let line_vector = line_end - line_start;
|
||
let line_length_squared = line_vector.length_squared();
|
||
if line_length_squared < NEAR_ZERO_LENGTH_SQUARED {
|
||
return point.distance(line_start);
|
||
}
|
||
(point - line_start).perp_dot(line_vector).abs() / line_length_squared.sqrt()
|
||
}
|
||
|
||
fn rdp_simplify(points: &[DVec2], tolerance: f64) -> Vec<DVec2> {
|
||
if points.len() < 3 {
|
||
return points.to_vec();
|
||
}
|
||
|
||
let mut keep = vec![false; points.len()];
|
||
keep[0] = true;
|
||
keep[points.len() - 1] = true;
|
||
|
||
let mut stack = vec![(0, points.len() - 1)];
|
||
|
||
while let Some((start_index, end_index)) = stack.pop() {
|
||
let start = points[start_index];
|
||
let end = points[end_index];
|
||
|
||
let mut max_distance = 0.;
|
||
let mut max_index = 0;
|
||
|
||
for (i, &point) in points.iter().enumerate().take(end_index).skip(start_index + 1) {
|
||
let distance = perpendicular_distance(point, start, end);
|
||
if distance > max_distance {
|
||
max_distance = distance;
|
||
max_index = i;
|
||
}
|
||
}
|
||
|
||
if max_distance > tolerance {
|
||
keep[max_index] = true;
|
||
if max_index - start_index > 1 {
|
||
stack.push((start_index, max_index));
|
||
}
|
||
if end_index - max_index > 1 {
|
||
stack.push((max_index, end_index));
|
||
}
|
||
}
|
||
}
|
||
|
||
points.iter().enumerate().filter(|(i, _)| keep[*i]).map(|(_, p)| *p).collect()
|
||
}
|
||
|
||
let transform_attribute: DAffine2 = content.attribute_cloned_or_default(ATTR_TRANSFORM);
|
||
let transform = Affine::new(transform_attribute.to_cols_array());
|
||
let inverse_transform = transform.inverse();
|
||
|
||
let mut result = Vector {
|
||
stroke: std::mem::take(&mut content.element_mut().stroke),
|
||
..Default::default()
|
||
};
|
||
|
||
for mut bezpath in content.element().stroke_bezpath_iter() {
|
||
bezpath.apply_affine(transform);
|
||
|
||
let is_closed = matches!(bezpath.elements().last(), Some(PathEl::ClosePath));
|
||
|
||
// Flatten the bezpath into line segments, then collect the points
|
||
let mut points = Vec::new();
|
||
kurbo::flatten(bezpath, tolerance * 0.5, |el| match el {
|
||
PathEl::MoveTo(p) | PathEl::LineTo(p) => {
|
||
points.push(DVec2::new(p.x, p.y));
|
||
}
|
||
_ => {}
|
||
});
|
||
|
||
// For closed paths, the last point duplicates the first, so remove it
|
||
if is_closed && points.len() > 1 && points.last() == points.first() {
|
||
points.pop();
|
||
}
|
||
|
||
// Apply RDP simplification
|
||
let simplified = rdp_simplify(&points, tolerance);
|
||
if simplified.is_empty() {
|
||
continue;
|
||
}
|
||
|
||
// Reconstruct as a polyline
|
||
let mut new_bezpath = BezPath::new();
|
||
new_bezpath.move_to((simplified[0].x, simplified[0].y));
|
||
for &point in &simplified[1..] {
|
||
new_bezpath.line_to((point.x, point.y));
|
||
}
|
||
if is_closed {
|
||
new_bezpath.close_path();
|
||
}
|
||
|
||
new_bezpath.apply_affine(inverse_transform);
|
||
result.append_bezpath(new_bezpath);
|
||
}
|
||
|
||
*content.element_mut() = result;
|
||
content
|
||
}
|
||
|
||
/// Cuts a path at a given progression from 0 to 1 along the path, creating two new subpaths from the original one (if the path is initially open) or one open subpath (if the path is initially closed).
|
||
///
|
||
/// If multiple subpaths make up the path, the whole number part of the progression value selects the subpath and the decimal part determines the position along it.
|
||
#[node_macro::node(category("Vector: Modifier"), path(graphene_core::vector))]
|
||
async fn cut_path(
|
||
_: impl Ctx,
|
||
/// The path to insert a cut into.
|
||
content: Item<Vector>,
|
||
/// The factor from the start to the end of the path, 0–1 for one subpath, 1–2 for a second subpath, and so on.
|
||
progression: Item<Progression>,
|
||
/// Swap the direction of the path.
|
||
reverse: Item<bool>,
|
||
/// Traverse the path using each segment's Bézier curve parameterization instead of the Euclidean distance. Faster to compute but doesn't respect actual distances.
|
||
parameterized_distance: Item<bool>,
|
||
) -> Item<Vector> {
|
||
let mut content = content;
|
||
let (progression, reverse, parameterized_distance) = (*progression.element(), *reverse.element(), *parameterized_distance.element());
|
||
|
||
let euclidian = !parameterized_distance;
|
||
|
||
let bezpaths = content.element().stroke_bezpath_iter().collect::<Vec<_>>();
|
||
|
||
let bezpath_count = bezpaths.len() as f64;
|
||
let t_value = progression.clamp(0., bezpath_count);
|
||
let t_value = if reverse { bezpath_count - t_value } else { t_value };
|
||
let index = if t_value >= bezpath_count { (bezpath_count - 1.) as usize } else { t_value as usize };
|
||
|
||
if let Some(bezpath) = bezpaths.get(index).cloned() {
|
||
let mut result_vector = Vector {
|
||
stroke: content.element().stroke.clone(),
|
||
..Default::default()
|
||
};
|
||
|
||
for (_, bezpath) in bezpaths.iter().enumerate().filter(|&(i, _)| i != index) {
|
||
result_vector.append_bezpath(bezpath.clone());
|
||
}
|
||
let t = if t_value == bezpath_count { 1. } else { t_value.fract() };
|
||
let t = if euclidian { TValue::Euclidean(t) } else { TValue::Parametric(t) };
|
||
|
||
if let Some((first, second)) = split_bezpath(&bezpath, t) {
|
||
result_vector.append_bezpath(first);
|
||
result_vector.append_bezpath(second);
|
||
} else {
|
||
result_vector.append_bezpath(bezpath);
|
||
}
|
||
|
||
*content.element_mut() = result_vector;
|
||
}
|
||
|
||
content
|
||
}
|
||
|
||
/// Cuts path segments into separate disconnected pieces where each is a distinct subpath.
|
||
#[node_macro::node(category("Vector: Modifier"), path(core_types::vector))]
|
||
async fn cut_segments(_: impl Ctx, content: Item<Vector>) -> Item<Vector> {
|
||
let mut content = content;
|
||
// Iterate through every segment and make a copy of each of its endpoints, then reassign each segment's endpoints to its own unique point copy
|
||
let vector = content.element_mut();
|
||
|
||
let points_count = vector.point_domain.ids().len();
|
||
let segments_count = vector.segment_domain.ids().len();
|
||
|
||
let mut point_usages = vec![0_usize; points_count];
|
||
|
||
// Count how many times each point is used as an endpoint of the segments
|
||
let start_points = vector.segment_domain.start_point().to_vec();
|
||
let end_points = vector.segment_domain.end_point().to_vec();
|
||
for (&start, &end) in start_points.iter().zip(end_points.iter()) {
|
||
point_usages[start] += 1;
|
||
point_usages[end] += 1;
|
||
}
|
||
|
||
let mut new_points = PointDomain::new();
|
||
let mut offset_sum: usize = 0;
|
||
let mut points_with_new_offsets = Vec::with_capacity(points_count);
|
||
|
||
// Build a new point domain with the original points, but with duplications based on their extra usages by the segments
|
||
for (index, (point_id, point)) in vector.point_domain.iter().enumerate() {
|
||
// Ensure at least one usage to preserve free-floating points not connected to any segments
|
||
let usage_count = point_usages[index].max(1);
|
||
|
||
new_points.push_unchecked(point_id, point);
|
||
|
||
for i in 1..usage_count {
|
||
new_points.push_unchecked(point_id.generate_from_hash(i as u64), point);
|
||
}
|
||
|
||
points_with_new_offsets.push(offset_sum);
|
||
offset_sum += usage_count;
|
||
}
|
||
|
||
// Reconcile the segment domain with the new points
|
||
vector.point_domain = new_points;
|
||
for original_segment_index in 0..segments_count {
|
||
let original_point_start_index = start_points[original_segment_index];
|
||
let original_point_end_index = end_points[original_segment_index];
|
||
|
||
point_usages[original_point_start_index] -= 1;
|
||
point_usages[original_point_end_index] -= 1;
|
||
|
||
let start_usage = points_with_new_offsets[original_point_start_index] + point_usages[original_point_start_index];
|
||
let end_usage = points_with_new_offsets[original_point_end_index] + point_usages[original_point_end_index];
|
||
|
||
vector.segment_domain.set_start_point(original_segment_index, start_usage);
|
||
vector.segment_domain.set_end_point(original_segment_index, end_usage);
|
||
}
|
||
|
||
content
|
||
}
|
||
|
||
/// Determines the position of a point on the path, given by its progression from 0 to 1 along the path.
|
||
///
|
||
/// If multiple subpaths make up the path, the whole number part of the progression value selects the subpath and the decimal part determines the position along it.
|
||
#[node_macro::node(name("Position on Path"), category("Vector: Measure"), path(graphene_core::vector))]
|
||
async fn position_on_path(
|
||
_: impl Ctx,
|
||
/// The path to traverse.
|
||
content: Item<Vector>,
|
||
/// The factor from the start to the end of the path, 0–1 for one subpath, 1–2 for a second subpath, and so on.
|
||
progression: Item<Progression>,
|
||
/// Swap the direction of the path.
|
||
reverse: Item<bool>,
|
||
/// Traverse the path using each segment's Bézier curve parameterization instead of the Euclidean distance. Faster to compute but doesn't respect actual distances.
|
||
parameterized_distance: Item<bool>,
|
||
) -> Item<DVec2> {
|
||
let (progression, reverse, parameterized_distance) = (progression.into_element(), reverse.into_element(), parameterized_distance.into_element());
|
||
let euclidian = !parameterized_distance;
|
||
|
||
let transform: DAffine2 = content.attribute_cloned_or_default(ATTR_TRANSFORM);
|
||
let mut bezpaths: Vec<_> = content.element().stroke_bezpath_iter().map(|bezpath| (bezpath, transform)).collect();
|
||
let bezpath_count = bezpaths.len() as f64;
|
||
let progression = progression.clamp(0., bezpath_count);
|
||
let progression = if reverse { bezpath_count - progression } else { progression };
|
||
let index = if progression >= bezpath_count { (bezpath_count - 1.) as usize } else { progression as usize };
|
||
|
||
let position = bezpaths.get_mut(index).map_or(DVec2::ZERO, |(bezpath, transform)| {
|
||
let t = if progression == bezpath_count { 1. } else { progression.fract() };
|
||
let t = if euclidian { TValue::Euclidean(t) } else { TValue::Parametric(t) };
|
||
|
||
bezpath.apply_affine(Affine::new(transform.to_cols_array()));
|
||
|
||
point_to_dvec2(evaluate_bezpath(bezpath, t, None))
|
||
});
|
||
|
||
Item::new_from_element(position)
|
||
}
|
||
|
||
/// Determines the angle of the tangent at a point on the path, given by its progression from 0 to 1 along the path.
|
||
///
|
||
/// If multiple subpaths make up the path, the whole number part of the progression value selects the subpath and the decimal part determines the position along it.
|
||
#[node_macro::node(name("Tangent on Path"), category("Vector: Measure"), path(graphene_core::vector))]
|
||
async fn tangent_on_path(
|
||
_: impl Ctx,
|
||
/// The path to traverse.
|
||
content: Item<Vector>,
|
||
/// The factor from the start to the end of the path, 0–1 for one subpath, 1–2 for a second subpath, and so on.
|
||
progression: Item<Progression>,
|
||
/// Swap the direction of the path.
|
||
reverse: Item<bool>,
|
||
/// Traverse the path using each segment's Bézier curve parameterization instead of the Euclidean distance. Faster to compute but doesn't respect actual distances.
|
||
parameterized_distance: Item<bool>,
|
||
/// Whether the resulting angle should be given in as radians instead of degrees.
|
||
radians: Item<bool>,
|
||
) -> Item<f64> {
|
||
let (progression, reverse, parameterized_distance, radians) = (progression.into_element(), reverse.into_element(), parameterized_distance.into_element(), radians.into_element());
|
||
let euclidian = !parameterized_distance;
|
||
|
||
let transform: DAffine2 = content.attribute_cloned_or_default(ATTR_TRANSFORM);
|
||
let mut bezpaths: Vec<_> = content.element().stroke_bezpath_iter().map(|bezpath| (bezpath, transform)).collect();
|
||
let bezpath_count = bezpaths.len() as f64;
|
||
let progression = progression.clamp(0., bezpath_count);
|
||
let progression = if reverse { bezpath_count - progression } else { progression };
|
||
let index = if progression >= bezpath_count { (bezpath_count - 1.) as usize } else { progression as usize };
|
||
|
||
let angle = bezpaths.get_mut(index).map_or(0., |(bezpath, transform)| {
|
||
let t = if progression == bezpath_count { 1. } else { progression.fract() };
|
||
let t_value = |t: f64| if euclidian { TValue::Euclidean(t) } else { TValue::Parametric(t) };
|
||
|
||
bezpath.apply_affine(Affine::new(transform.to_cols_array()));
|
||
|
||
let mut tangent = point_to_dvec2(tangent_on_bezpath(bezpath, t_value(t), None));
|
||
if tangent == DVec2::ZERO {
|
||
let t = t + if t > 0.5 { -0.001 } else { 0.001 };
|
||
tangent = point_to_dvec2(tangent_on_bezpath(bezpath, t_value(t), None));
|
||
}
|
||
if tangent == DVec2::ZERO {
|
||
return 0.;
|
||
}
|
||
|
||
-tangent.angle_to(if reverse { -DVec2::X } else { DVec2::X })
|
||
});
|
||
|
||
Item::new_from_element(if radians { angle } else { angle.to_degrees() })
|
||
}
|
||
|
||
#[node_macro::node(category("Vector: Modifier"), path(core_types::vector), memoize)]
|
||
async fn scatter_points(
|
||
_: impl Ctx,
|
||
content: Item<Vector>,
|
||
#[unit(" px")]
|
||
#[default(10.)]
|
||
#[range]
|
||
#[hard(0.01..)]
|
||
#[soft(1..100)]
|
||
separation: Item<f64>,
|
||
seed: Item<SeedValue>,
|
||
) -> Item<Vector> {
|
||
let mut content = content;
|
||
let (separation, seed) = (*separation.element(), *seed.element());
|
||
|
||
let mut rng = rand::rngs::StdRng::seed_from_u64(seed.into());
|
||
|
||
let mut result = Vector::default();
|
||
|
||
let path_with_bounding_boxes: Vec<_> = content
|
||
.element()
|
||
.stroke_bezpath_iter()
|
||
.map(|mut bezpath| {
|
||
// TODO: apply transform to points instead of modifying the paths
|
||
bezpath.close_path();
|
||
let bbox = bezpath.bounding_box();
|
||
(bezpath, bbox)
|
||
})
|
||
.collect();
|
||
|
||
for (i, (subpath, _)) in path_with_bounding_boxes.iter().enumerate() {
|
||
if subpath.segments().count() < 2 {
|
||
continue;
|
||
}
|
||
|
||
for point in bezpath_algorithms::poisson_disk_points(i, &path_with_bounding_boxes, separation, || rng.random::<f64>()) {
|
||
result.point_domain.push(PointId::generate(), point);
|
||
}
|
||
}
|
||
|
||
// Transfer the style from the input vector content to the result.
|
||
result.stroke = content.element().stroke.clone();
|
||
result.set_stroke_transform(DAffine2::IDENTITY);
|
||
|
||
*content.element_mut() = result;
|
||
content
|
||
}
|
||
|
||
#[node_macro::node(name("Spline"), category("Vector: Modifier"), path(core_types::vector))]
|
||
async fn spline(_: impl Ctx, content: Item<Vector>) -> Item<Vector> {
|
||
let mut content = content;
|
||
// Exit early if there are no points to generate splines from.
|
||
if content.element().point_domain.positions().is_empty() {
|
||
return content;
|
||
}
|
||
|
||
let mut segment_domain = SegmentDomain::default();
|
||
let mut next_id = SegmentId::ZERO;
|
||
for (manipulator_groups, closed) in content.element().stroke_manipulator_groups() {
|
||
let positions = manipulator_groups.iter().map(|manipulators| manipulators.anchor).collect::<Vec<_>>();
|
||
let closed = closed && positions.len() > 2;
|
||
|
||
// Compute control point handles for Bezier spline.
|
||
let first_handles = if closed {
|
||
solve_spline_first_handle_closed(&positions)
|
||
} else {
|
||
solve_spline_first_handle_open(&positions)
|
||
};
|
||
|
||
let stroke_id = StrokeId::ZERO;
|
||
|
||
// Create segments with computed Bezier handles and add them to the output vector element's segment domain.
|
||
for i in 0..(positions.len() - if closed { 0 } else { 1 }) {
|
||
let next_index = (i + 1) % positions.len();
|
||
|
||
let start_index = content.element().point_domain.resolve_id(manipulator_groups[i].id).unwrap();
|
||
let end_index = content.element().point_domain.resolve_id(manipulator_groups[next_index].id).unwrap();
|
||
|
||
let handle_start = first_handles[i];
|
||
let handle_end = positions[next_index] * 2. - first_handles[next_index];
|
||
let handles = BezierHandles::Cubic { handle_start, handle_end };
|
||
|
||
segment_domain.push(next_id.next_id(), start_index, end_index, handles, stroke_id);
|
||
}
|
||
}
|
||
|
||
content.element_mut().segment_domain = segment_domain;
|
||
content
|
||
}
|
||
|
||
/// 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.
|
||
#[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: Item<Vector>,
|
||
/// The maximum extent of the random distance each point can be offset.
|
||
#[default(5.)]
|
||
#[unit(" px")]
|
||
max_distance: Item<f64>,
|
||
/// Seed used to determine unique variations on all randomized offsets.
|
||
seed: Item<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: Item<bool>,
|
||
) -> Item<Vector> {
|
||
let mut content = content;
|
||
let (max_distance, seed, along_normals) = (*max_distance.element(), *seed.element(), *along_normals.element());
|
||
|
||
let mut rng = rand::rngs::StdRng::seed_from_u64(seed.into());
|
||
let transform_attribute: DAffine2 = content.attribute_cloned_or_default(ATTR_TRANSFORM);
|
||
let inverse_linear = inverse_linear_or_repair(transform_attribute.matrix2);
|
||
|
||
let deltas: Vec<_> = (0..content.element().point_domain.positions().len())
|
||
.map(|point_index| {
|
||
let normal = if along_normals {
|
||
content
|
||
.element()
|
||
.segment_domain
|
||
.point_tangent(point_index, content.element().point_domain.positions())
|
||
.map(|t| -t.perp())
|
||
} else {
|
||
None
|
||
};
|
||
|
||
let offset = if let Some(normal) = normal {
|
||
normal * (rng.random::<f64>() * 2. - 1.)
|
||
} else {
|
||
DVec2::from_angle(rng.random::<f64>() * TAU) * rng.random::<f64>()
|
||
};
|
||
|
||
inverse_linear * offset * max_distance
|
||
})
|
||
.collect();
|
||
|
||
let transform: DAffine2 = content.attribute_cloned_or_default(ATTR_TRANSFORM);
|
||
apply_point_deltas(content.element_mut(), &deltas, transform);
|
||
|
||
content
|
||
}
|
||
|
||
/// Displaces anchor points along their normal direction (perpendicular to the path) by a set distance.
|
||
/// Points with 0 or 3+ segment connections have no well-defined normal and are left in place.
|
||
#[node_macro::node(category("Vector: Modifier"), path(core_types::vector))]
|
||
async fn offset_points(
|
||
_: impl Ctx,
|
||
/// The vector geometry with points to be offset.
|
||
content: Item<Vector>,
|
||
/// The distance to offset each anchor point along its normal. Positive values move outward, negative values move inward.
|
||
#[default(10.)]
|
||
#[unit(" px")]
|
||
distance: Item<f64>,
|
||
) -> Item<Vector> {
|
||
let mut content = content;
|
||
let distance = *distance.element();
|
||
let transform_attribute: DAffine2 = content.attribute_cloned_or_default(ATTR_TRANSFORM);
|
||
let inverse_linear = inverse_linear_or_repair(transform_attribute.matrix2);
|
||
|
||
let deltas: Vec<_> = (0..content.element().point_domain.positions().len())
|
||
.map(|point_index| {
|
||
let Some(normal) = content
|
||
.element()
|
||
.segment_domain
|
||
.point_tangent(point_index, content.element().point_domain.positions())
|
||
.map(|t| -t.perp())
|
||
else {
|
||
return DVec2::ZERO;
|
||
};
|
||
|
||
inverse_linear * normal * distance
|
||
})
|
||
.collect();
|
||
|
||
let transform: DAffine2 = content.attribute_cloned_or_default(ATTR_TRANSFORM);
|
||
apply_point_deltas(content.element_mut(), &deltas, transform);
|
||
|
||
content
|
||
}
|
||
|
||
/// Interpolates the geometry, appearance, and transform between multiple vector layers, producing a single morphed vector shape.
|
||
///
|
||
/// *Progression* morphs through all objects. Interpolation is linear unless *Path* geometry is provided to control the trajectory between key objects. The **Origins to Polyline** node may be used to create a path with anchor points corresponding to each object. Other nodes can modify its path segments.
|
||
#[node_macro::node(category("Vector: Modifier"), path(core_types::vector))]
|
||
async fn morph<I: IntoGraphicList>(
|
||
_: impl Ctx,
|
||
/// The vector objects to interpolate between. Mixed graphic content is deeply flattened to keep only vector elements.
|
||
#[implementations(List<Graphic>, List<Vector>)]
|
||
content: I,
|
||
/// The fractional part `[0, 1)` traverses the morph uniformly along the path. If the control path has multiple subpaths, each added integer selects the next subpath.
|
||
progression: Item<Progression>,
|
||
/// Swap the direction of the progression between objects or along the control path.
|
||
reverse: Item<bool>,
|
||
/// The parameter of change that influences the interpolation speed between each object. Equal slices in this parameter correspond to the rate of progression through the morph. This must be set to a parameter that changes.
|
||
///
|
||
/// "Objects" morphs through each group element at an equal rate. "Distances" keeps constant speed with time between objects proportional to their distances. "Angles" keeps constant rotational speed. "Sizes" keeps constant shrink/growth speed. "Slants" keeps constant shearing angle speed.
|
||
distribution: Item<InterpolationDistribution>,
|
||
/// An optional control path whose anchor points correspond to each object. Curved segments between points will shape the morph trajectory instead of traveling straight. If there is a break between path segments, the separate subpaths are selected by index from the integer part of the progression value. For example, `[1, 2)` morphs along the segments of the second subpath, and so on.
|
||
path: Item<Vector>,
|
||
) -> Item<Vector> {
|
||
/// Promotes a segment's handle pair to cubic-equivalent Bézier control points.
|
||
/// For linear segments (both None), handles are placed at their respective anchors (zero-length)
|
||
/// so that interpolation against another zero-length cubic doesn't introduce unwanted curvature.
|
||
/// For quadratic segments (one handle), degree elevation is applied.
|
||
fn promote_handles_to_cubic(prev_anchor: DVec2, out_handle: Option<DVec2>, in_handle: Option<DVec2>, curr_anchor: DVec2) -> (DVec2, DVec2) {
|
||
match (out_handle, in_handle) {
|
||
(Some(handle_start), Some(handle_end)) => (handle_start, handle_end),
|
||
(None, None) => (prev_anchor, curr_anchor),
|
||
(Some(handle), None) | (None, Some(handle)) => {
|
||
let handle_start = prev_anchor + (handle - prev_anchor) * (2. / 3.);
|
||
let handle_end = curr_anchor + (handle - curr_anchor) * (2. / 3.);
|
||
(handle_start, handle_end)
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Subdivides the last segment of a manipulator group list at its midpoint, adding one new manipulator.
|
||
/// For closed paths, the "last segment" is the closing segment from the last back to the first manipulator.
|
||
fn subdivide_last_manipulator_segment(manips: &mut Vec<ManipulatorGroup<PointId>>, closed: bool) {
|
||
let len = manips.len();
|
||
if len < 2 {
|
||
return;
|
||
}
|
||
|
||
let (prev_index, next_index) = if closed { (len - 1, 0) } else { (len - 2, len - 1) };
|
||
|
||
let prev_anchor = manips[prev_index].anchor;
|
||
let next_anchor = manips[next_index].anchor;
|
||
let (h1, h2) = promote_handles_to_cubic(prev_anchor, manips[prev_index].out_handle, manips[next_index].in_handle, next_anchor);
|
||
|
||
// De Casteljau subdivision at t=0.5
|
||
let m01 = prev_anchor.lerp(h1, 0.5);
|
||
let m12 = h1.lerp(h2, 0.5);
|
||
let m23 = h2.lerp(next_anchor, 0.5);
|
||
let m012 = m01.lerp(m12, 0.5);
|
||
let m123 = m12.lerp(m23, 0.5);
|
||
let mid = m012.lerp(m123, 0.5);
|
||
|
||
manips[prev_index].out_handle = Some(m01);
|
||
manips[next_index].in_handle = Some(m23);
|
||
|
||
let mid_manip = ManipulatorGroup {
|
||
anchor: mid,
|
||
in_handle: Some(m012),
|
||
out_handle: Some(m123),
|
||
id: PointId::ZERO,
|
||
};
|
||
|
||
if closed {
|
||
manips.push(mid_manip);
|
||
} else {
|
||
manips.insert(next_index, mid_manip);
|
||
}
|
||
}
|
||
|
||
/// Constructs BezierHandles from the out_handle of one manipulator and in_handle of the next.
|
||
fn handles_from_manips(out_handle: Option<DVec2>, in_handle: Option<DVec2>) -> BezierHandles {
|
||
match (out_handle, in_handle) {
|
||
(Some(handle_start), Some(handle_end)) => BezierHandles::Cubic { handle_start, handle_end },
|
||
(None, None) => BezierHandles::Linear,
|
||
(Some(handle), None) | (None, Some(handle)) => BezierHandles::Quadratic { handle },
|
||
}
|
||
}
|
||
|
||
/// Pushes a subpath (list of manipulators) directly into a Vector's point, segment, and region domains,
|
||
/// bypassing the BezPath intermediate representation used by `append_bezpath`.
|
||
fn push_manipulators_to_vector(vector: &mut Vector, manips: &[ManipulatorGroup<PointId>], closed: bool, point_id: &mut PointId, segment_id: &mut SegmentId) {
|
||
let Some(first) = manips.first() else { return };
|
||
|
||
let first_point_index = vector.point_domain.ids().len();
|
||
vector.point_domain.push_unchecked(point_id.next_id(), first.anchor);
|
||
let mut prev_point_index = first_point_index;
|
||
let mut first_segment_id = None;
|
||
|
||
for manip_window in manips.windows(2) {
|
||
let point_index = vector.point_domain.ids().len();
|
||
vector.point_domain.push_unchecked(point_id.next_id(), manip_window[1].anchor);
|
||
|
||
let handles = handles_from_manips(manip_window[0].out_handle, manip_window[1].in_handle);
|
||
let seg_id = segment_id.next_id();
|
||
first_segment_id.get_or_insert(seg_id);
|
||
vector.segment_domain.push_unchecked(seg_id, prev_point_index, point_index, handles, StrokeId::ZERO);
|
||
|
||
prev_point_index = point_index;
|
||
}
|
||
|
||
if closed && manips.len() > 1 {
|
||
let handles = handles_from_manips(manips.last().unwrap().out_handle, manips[0].in_handle);
|
||
let closing_seg_id = segment_id.next_id();
|
||
first_segment_id.get_or_insert(closing_seg_id);
|
||
vector.segment_domain.push_unchecked(closing_seg_id, prev_point_index, first_point_index, handles, StrokeId::ZERO);
|
||
|
||
let region_id = vector.region_domain.next_id();
|
||
vector.region_domain.push_unchecked(region_id, first_segment_id.unwrap()..=closing_seg_id, FillId::ZERO);
|
||
}
|
||
}
|
||
|
||
fn lerp_gradient_transform(gradient_list_a: &List<Gradient>, gradient_list_b: &List<Gradient>, time: f64) -> DAffine2 {
|
||
let transform_a = gradient_list_a.attribute_cloned_or_default::<DAffine2>(ATTR_TRANSFORM, 0);
|
||
let transform_b = gradient_list_b.attribute_cloned_or_default::<DAffine2>(ATTR_TRANSFORM, 0);
|
||
|
||
let start_a = transform_a.translation;
|
||
let end_a = transform_a.translation + transform_a.matrix2.x_axis;
|
||
let start_b = transform_b.translation;
|
||
let end_b = transform_b.translation + transform_b.matrix2.x_axis;
|
||
|
||
let start = start_a.lerp(start_b, time);
|
||
let end = end_a.lerp(end_b, time);
|
||
|
||
let metadata_source_transform = if time < 0.5 { transform_a } else { transform_b };
|
||
build_transform_with_y_preservation(metadata_source_transform, start, end)
|
||
}
|
||
|
||
// Lerp between two graphics. Solid color and gradient pairings interpolate; all other pairings step at the midpoint.
|
||
fn lerp_graphic(a: Option<&List<Graphic>>, b: Option<&List<Graphic>>, time: f64) -> Option<List<Graphic>> {
|
||
let transparent = List::new_from_element(Color::TRANSPARENT).into_graphic_list();
|
||
|
||
let a = a.filter(|graphic_list| is_paint_present(graphic_list));
|
||
let b = b.filter(|graphic_list| is_paint_present(graphic_list));
|
||
|
||
let (a, b) = match (a, b) {
|
||
(None, None) => return None,
|
||
(Some(a), None) => (a, &transparent),
|
||
(None, Some(b)) => (&transparent, b),
|
||
(Some(a), Some(b)) => (a, b),
|
||
};
|
||
|
||
// This keeps the gradient metadata attributes
|
||
let gradient_with_stops = |mut gradient_list: List<Gradient>, stops: Gradient| -> Graphic {
|
||
if let Some(target) = gradient_list.element_mut(0) {
|
||
*target = stops;
|
||
} else {
|
||
gradient_list.push(Item::new_from_element(stops));
|
||
}
|
||
Graphic::Gradient(gradient_list)
|
||
};
|
||
|
||
let graphic = match (a.element(0), b.element(0)) {
|
||
(Some(Graphic::Color(color_list_a)), Some(Graphic::Color(color_list_b))) => color_list_a
|
||
.element(0)
|
||
.zip(color_list_b.element(0))
|
||
.map(|(color_a, color_b)| Graphic::from(color_a.lerp(color_b, time as f32))),
|
||
(Some(Graphic::Color(color_list_a)), Some(Graphic::Gradient(gradient_list_b))) => color_list_a.element(0).zip(gradient_list_b.element(0)).map(|(color_a, stops_b)| {
|
||
let solid_to_gradient = stops_b.map_colors(|_| *color_a);
|
||
let stops = solid_to_gradient.lerp(stops_b, time);
|
||
gradient_with_stops(gradient_list_b.clone(), stops)
|
||
}),
|
||
(Some(Graphic::Gradient(gradient_list_a)), Some(Graphic::Color(color_list_b))) => gradient_list_a.element(0).zip(color_list_b.element(0)).map(|(stops_a, color_b)| {
|
||
let gradient_to_solid = stops_a.map_colors(|_| *color_b);
|
||
let stops = stops_a.lerp(&gradient_to_solid, time);
|
||
gradient_with_stops(gradient_list_a.clone(), stops)
|
||
}),
|
||
(Some(Graphic::Gradient(gradient_list_a)), Some(Graphic::Gradient(gradient_list_b))) => gradient_list_a.element(0).zip(gradient_list_b.element(0)).map(|(stops_a, stops_b)| {
|
||
let stops = stops_a.lerp(stops_b, time);
|
||
let metadata_source = if time < 0.5 { gradient_list_a } else { gradient_list_b };
|
||
|
||
let mut gradient_list = metadata_source.clone();
|
||
gradient_list.set_attribute(ATTR_TRANSFORM, 0, lerp_gradient_transform(gradient_list_a, gradient_list_b, time));
|
||
|
||
gradient_with_stops(gradient_list, stops)
|
||
}),
|
||
// Pairings beyond solid colors and gradients (raster, vector, or mixed) can't be interpolated, so step at the midpoint
|
||
_ => return Some(if time < 0.5 { a.clone() } else { b.clone() }),
|
||
};
|
||
|
||
graphic.map(List::new_from_element)
|
||
}
|
||
|
||
let (progression, reverse, distribution) = (progression.into_element(), reverse.into_element(), distribution.into_element());
|
||
|
||
// Preserve original `List<Graphic>` as upstream data so this group layer's nested layers can be edited by the tools.
|
||
let mut graphic_list_content = content.clone().into_graphic_list();
|
||
|
||
// If the input isn't a List<Vector>, we convert it into one by flattening any List<Graphic> content.
|
||
let content = content.into_flattened_list::<Vector>();
|
||
|
||
// Not enough elements to interpolate between, so we return the input as-is
|
||
if content.len() <= 1 {
|
||
return content.into_iter().next().unwrap_or_default();
|
||
}
|
||
|
||
// Build the control path for the morph trajectory.
|
||
// Collect all subpaths from the path input (applying transforms), or build a default polyline from element origins.
|
||
let default_polyline = || {
|
||
let mut default_path = BezPath::new();
|
||
for index in 0..content.len() {
|
||
let transform_attribute: DAffine2 = content.attribute_cloned_or_default(ATTR_TRANSFORM, index);
|
||
let origin = transform_attribute.translation;
|
||
let point = kurbo::Point::new(origin.x, origin.y);
|
||
if index == 0 {
|
||
default_path.move_to(point);
|
||
} else {
|
||
default_path.line_to(point);
|
||
}
|
||
}
|
||
vec![default_path]
|
||
};
|
||
|
||
let control_bezpaths: Vec<BezPath> = {
|
||
// User-provided path: collect all subpaths with the path's transform applied
|
||
let path_transform: DAffine2 = path.attribute_cloned_or_default(ATTR_TRANSFORM);
|
||
let paths: Vec<BezPath> = path
|
||
.element()
|
||
.stroke_bezpath_iter()
|
||
.map(|mut bezpath| {
|
||
bezpath.apply_affine(Affine::new(path_transform.to_cols_array()));
|
||
bezpath
|
||
})
|
||
.collect();
|
||
|
||
// Fall back to default polyline if the provided path has no subpaths
|
||
if paths.is_empty() { default_polyline() } else { paths }
|
||
};
|
||
|
||
// Select which subpath to use based on the integer part of progression (like the 'Position on Path' node)
|
||
let progression = progression.max(0.);
|
||
let subpath_count = control_bezpaths.len() as f64;
|
||
let progression = if reverse { subpath_count - progression } else { progression };
|
||
let clamped_progression = progression.clamp(0., subpath_count);
|
||
let subpath_index = if clamped_progression >= subpath_count { subpath_count - 1. } else { clamped_progression } as usize;
|
||
let fractional_progression = if clamped_progression >= subpath_count { 1. } else { clamped_progression.fract() };
|
||
|
||
let control_bezpath = &control_bezpaths[subpath_index];
|
||
let segment_count = control_bezpath.segments().count();
|
||
|
||
// If the control path has no segments, return the first item
|
||
if segment_count == 0 {
|
||
return content.into_iter().next().unwrap_or_default();
|
||
}
|
||
|
||
// Determine if the selected subpath is closed (has a closing segment connecting its end back to its start)
|
||
let is_closed = control_bezpath.elements().last() == Some(&PathEl::ClosePath);
|
||
|
||
// Number of anchor points (content elements) per subpath: for closed subpaths, the closing
|
||
// segment doesn't add a new anchor, so anchors = segments. For open: anchors = segments + 1.
|
||
let anchor_count = |bp: &BezPath| -> usize {
|
||
let segs = bp.segments().count();
|
||
let closed = bp.elements().last() == Some(&PathEl::ClosePath);
|
||
if closed { segs } else { segs + 1 }
|
||
};
|
||
|
||
// Offset source_index by the number of content elements consumed by previous subpaths,
|
||
// so each subpath morphs through its own slice of content (not always starting from element 0).
|
||
let content_offset: usize = control_bezpaths[..subpath_index].iter().map(&anchor_count).sum();
|
||
let subpath_anchors = anchor_count(control_bezpath);
|
||
let max_content_index = content.len().saturating_sub(1);
|
||
|
||
// Map the fractional progression to a segment index and local blend time using the chosen weights.
|
||
let (local_source_index, time) = if fractional_progression >= 1. {
|
||
(segment_count - 1, 1.)
|
||
} else if matches!(distribution, InterpolationDistribution::Objects) {
|
||
// Fast path for uniform distribution: direct index calculation without allocation or iteration
|
||
let scaled = fractional_progression * segment_count as f64;
|
||
let index = (scaled.ceil() as usize).saturating_sub(1);
|
||
(index, scaled - index as f64)
|
||
} else {
|
||
// Compute segment weights based on the user's chosen spacing metric
|
||
let segment_weights: Vec<f64> = match distribution {
|
||
InterpolationDistribution::Objects => unreachable!(),
|
||
InterpolationDistribution::Distances => control_bezpath.segments().map(|seg| seg.perimeter(DEFAULT_ACCURACY)).collect(),
|
||
InterpolationDistribution::Angles | InterpolationDistribution::Sizes | InterpolationDistribution::Slants => (0..segment_count)
|
||
.map(|i| {
|
||
let source_index = (content_offset + i).min(max_content_index);
|
||
let target_index = if is_closed && i >= subpath_anchors - 1 {
|
||
content_offset
|
||
} else {
|
||
(content_offset + i + 1).min(max_content_index)
|
||
};
|
||
|
||
if content.element(source_index).is_none() || content.element(target_index).is_none() {
|
||
return 0.;
|
||
}
|
||
let source_transform: DAffine2 = content.attribute_cloned_or_default(ATTR_TRANSFORM, source_index);
|
||
let target_transform: DAffine2 = content.attribute_cloned_or_default(ATTR_TRANSFORM, target_index);
|
||
let (s_angle, s_scale, s_skew) = source_transform.decompose_rotation_scale_skew();
|
||
let (t_angle, t_scale, t_skew) = target_transform.decompose_rotation_scale_skew();
|
||
|
||
match distribution {
|
||
InterpolationDistribution::Angles => {
|
||
let mut diff = t_angle - s_angle;
|
||
if diff > PI {
|
||
diff -= TAU;
|
||
} else if diff < -PI {
|
||
diff += TAU;
|
||
}
|
||
diff.abs()
|
||
}
|
||
InterpolationDistribution::Sizes => (t_scale - s_scale).length(),
|
||
InterpolationDistribution::Slants => (t_skew.atan() - s_skew.atan()).abs(),
|
||
_ => unreachable!(),
|
||
}
|
||
})
|
||
.collect(),
|
||
};
|
||
|
||
let total_weight: f64 = segment_weights.iter().sum();
|
||
|
||
// When all weights are zero (all elements identical in the chosen metric), there's zero interval to traverse.
|
||
if total_weight <= f64::EPSILON {
|
||
(0, 0.)
|
||
} else {
|
||
let mut accumulator = 0.;
|
||
let mut found_index = segment_count - 1;
|
||
let mut found_t = 1.;
|
||
for (i, weight) in segment_weights.iter().enumerate() {
|
||
let ratio = weight / total_weight;
|
||
if fractional_progression <= accumulator + ratio {
|
||
found_index = i;
|
||
found_t = if ratio > f64::EPSILON { (fractional_progression - accumulator) / ratio } else { 0. };
|
||
break;
|
||
}
|
||
accumulator += ratio;
|
||
}
|
||
(found_index, found_t)
|
||
}
|
||
};
|
||
|
||
// Convert the blend time to a parametric t for evaluating spatial position on the control path
|
||
let path_segment_index = local_source_index;
|
||
let parametric_t = {
|
||
let segment_index = path_segment_index.min(segment_count - 1);
|
||
let segment = control_bezpath.get_seg(segment_index + 1).unwrap();
|
||
eval_pathseg_euclidean(segment, time, DEFAULT_ACCURACY)
|
||
};
|
||
|
||
let source_index = local_source_index + content_offset;
|
||
|
||
// For closed subpaths, the closing segment wraps target back to the first element of this subpath's slice.
|
||
// For open subpaths, target is simply the next element.
|
||
let target_index = if is_closed && local_source_index >= subpath_anchors - 1 {
|
||
content_offset // Wrap to first element of this subpath's slice
|
||
} else {
|
||
source_index + 1
|
||
};
|
||
|
||
// Clamp to valid content range
|
||
let source_index = source_index.min(max_content_index);
|
||
let target_index = target_index.min(max_content_index);
|
||
|
||
// Use indexed access to borrow only the two elements we need
|
||
let (Some(source_element), Some(target_element)) = (content.element(source_index), content.element(target_index)) else {
|
||
return Item::default();
|
||
};
|
||
|
||
// Lerp blending attributes: opacity/fill interpolate, blend_mode/clip step at the midpoint
|
||
let source_blend_mode: BlendMode = content.attribute_cloned_or_default(ATTR_BLEND_MODE, source_index);
|
||
let target_blend_mode: BlendMode = content.attribute_cloned_or_default(ATTR_BLEND_MODE, target_index);
|
||
let source_opacity: f64 = content.attribute_cloned_or(ATTR_OPACITY, source_index, 1.);
|
||
let target_opacity: f64 = content.attribute_cloned_or(ATTR_OPACITY, target_index, 1.);
|
||
let source_fill: f64 = content.attribute_cloned_or(ATTR_OPACITY_FILL, source_index, 1.);
|
||
let target_fill: f64 = content.attribute_cloned_or(ATTR_OPACITY_FILL, target_index, 1.);
|
||
let source_clip: bool = content.attribute_cloned_or_default(ATTR_CLIPPING_MASK, source_index);
|
||
let target_clip: bool = content.attribute_cloned_or_default(ATTR_CLIPPING_MASK, target_index);
|
||
|
||
let lerped_blend_mode = if time < 0.5 { source_blend_mode } else { target_blend_mode };
|
||
let lerped_opacity = source_opacity + (target_opacity - source_opacity) * time;
|
||
let lerped_fill = source_fill + (target_fill - source_fill) * time;
|
||
let lerped_clip = if time < 0.5 { source_clip } else { target_clip };
|
||
|
||
// Evaluate the spatial position on the control path for the translation component.
|
||
// When the segment has zero arc length (e.g., two objects at the same position), inv_arclen
|
||
// produces NaN (0/0), so we fall back to the segment start point to avoid NaN translation.
|
||
let path_position = {
|
||
let segment_index = path_segment_index.min(segment_count - 1);
|
||
let segment = control_bezpath.get_seg(segment_index + 1).unwrap();
|
||
let parametric_t = if segment.arclen(DEFAULT_ACCURACY) < f64::EPSILON { 0. } else { parametric_t };
|
||
let point = segment.eval(parametric_t);
|
||
DVec2::new(point.x, point.y)
|
||
};
|
||
|
||
// Interpolate rotation, scale, and skew between source and target, but use the path position for translation.
|
||
// This decomposition must match the one used in Stroke::lerp so the renderer's stroke_transform.inverse()
|
||
// correctly cancels the element transform, keeping the stroke uniform when Stroke is after Transform.
|
||
let lerped_transform = {
|
||
let source_transform: DAffine2 = content.attribute_cloned_or_default(ATTR_TRANSFORM, source_index);
|
||
let target_transform: DAffine2 = content.attribute_cloned_or_default(ATTR_TRANSFORM, target_index);
|
||
let (s_angle, s_scale, s_skew) = source_transform.decompose_rotation_scale_skew();
|
||
let (t_angle, t_scale, t_skew) = target_transform.decompose_rotation_scale_skew();
|
||
|
||
let lerp = |a: f64, b: f64| a + (b - a) * time;
|
||
|
||
// Shortest-arc rotation interpolation
|
||
let mut rotation_diff = t_angle - s_angle;
|
||
if rotation_diff > PI {
|
||
rotation_diff -= TAU;
|
||
} else if rotation_diff < -PI {
|
||
rotation_diff += TAU;
|
||
}
|
||
let lerped_angle = s_angle + rotation_diff * time;
|
||
|
||
let trs = DAffine2::from_scale_angle_translation(s_scale.lerp(t_scale, time), lerped_angle, path_position);
|
||
let skew = DAffine2::from_cols_array(&[1., 0., lerp(s_skew, t_skew), 1., 0., 0.]);
|
||
trs * skew
|
||
};
|
||
|
||
// Pre-compensate merged_layers transforms so that when collect_metadata applies
|
||
// the item transform (which will be group_transform * lerped_transform after the
|
||
// pipeline's Transform node runs), the lerped_transform cancels out and children
|
||
// get the correct footprint: parent * group_transform * child_transform.
|
||
// Only pre-compensate if the lerped transform is invertible (non-zero determinant).
|
||
// A zero determinant can occur when interpolated scale passes through zero (e.g., flipped axes),
|
||
// in which case we skip pre-compensation to avoid propagating NaN through merged_layers transforms.
|
||
if lerped_transform.matrix2.determinant().abs() > f64::EPSILON {
|
||
let lerped_inverse = lerped_transform.inverse();
|
||
for transform in graphic_list_content.iter_attribute_values_mut_or_default::<DAffine2>(ATTR_TRANSFORM) {
|
||
*transform = lerped_inverse * *transform;
|
||
}
|
||
}
|
||
|
||
// Fast path: when exactly at either endpoint, clone the corresponding geometry directly
|
||
// instead of extracting manipulator groups, subdividing, interpolating, and rebuilding.
|
||
if time == 0. || time == 1. {
|
||
let endpoint_index = if time == 0. { source_index } else { target_index };
|
||
let endpoint_element = content.element(endpoint_index).unwrap();
|
||
|
||
let mut attributes = content.clone_item_attributes(endpoint_index);
|
||
attributes.insert(ATTR_TRANSFORM, lerped_transform);
|
||
attributes.insert(ATTR_EDITOR_MERGED_LAYERS, graphic_list_content);
|
||
|
||
return Item::from_parts(endpoint_element.clone(), attributes);
|
||
}
|
||
|
||
let stroke = match (source_element.stroke.as_ref(), target_element.stroke.as_ref()) {
|
||
(Some(a), Some(b)) => Some(a.lerp(b, time)),
|
||
(Some(a), None) => {
|
||
if time < 0.5 {
|
||
Some(a.clone())
|
||
} else {
|
||
None
|
||
}
|
||
}
|
||
(None, Some(b)) => {
|
||
if time < 0.5 {
|
||
None
|
||
} else {
|
||
Some(b.clone())
|
||
}
|
||
}
|
||
(None, None) => None,
|
||
};
|
||
let mut vector = Vector { stroke, ..Default::default() };
|
||
|
||
let fill_paint = {
|
||
let source = graphic_list_at(&content, source_index, ATTR_FILL);
|
||
let target = graphic_list_at(&content, target_index, ATTR_FILL);
|
||
lerp_graphic(source.as_deref(), target.as_deref(), time)
|
||
};
|
||
let stroke_paint = {
|
||
let source = graphic_list_at(&content, source_index, ATTR_STROKE);
|
||
let target = graphic_list_at(&content, target_index, ATTR_STROKE);
|
||
lerp_graphic(source.as_deref(), target.as_deref(), time)
|
||
};
|
||
|
||
// Work directly with manipulator groups, bypassing the BezPath intermediate representation.
|
||
// This avoids the full Vector → BezPath → interpolate → BezPath → Vector roundtrip each frame.
|
||
let mut source_subpaths: Vec<_> = source_element.stroke_manipulator_groups().collect();
|
||
let mut target_subpaths: Vec<_> = target_element.stroke_manipulator_groups().collect();
|
||
|
||
// Interpolate geometry in local space (no transform baked in); the lerped transform handles positioning
|
||
let matched_count = source_subpaths.len().min(target_subpaths.len());
|
||
let extra_source = source_subpaths.split_off(matched_count);
|
||
let extra_target = target_subpaths.split_off(matched_count);
|
||
|
||
// Pre-allocate domain storage based on total manipulator counts across all subpaths
|
||
let mut total_points = 0;
|
||
let mut total_segments = 0;
|
||
let mut total_regions = 0;
|
||
for ((source_manips, source_closed), (target_manips, _)) in source_subpaths.iter().zip(target_subpaths.iter()) {
|
||
if source_manips.is_empty() || target_manips.is_empty() {
|
||
continue;
|
||
}
|
||
let manip_count = source_manips.len().max(target_manips.len());
|
||
total_points += manip_count;
|
||
total_segments += if *source_closed { manip_count } else { manip_count.saturating_sub(1) };
|
||
if *source_closed {
|
||
total_regions += 1;
|
||
}
|
||
}
|
||
for (manips, closed) in extra_source.iter().chain(extra_target.iter()) {
|
||
total_points += manips.len();
|
||
total_segments += if *closed { manips.len() } else { manips.len().saturating_sub(1) };
|
||
if *closed {
|
||
total_regions += 1;
|
||
}
|
||
}
|
||
vector.point_domain.reserve(total_points);
|
||
vector.segment_domain.reserve(total_segments);
|
||
vector.region_domain.reserve(total_regions);
|
||
|
||
let mut point_id = PointId::ZERO;
|
||
let mut segment_id = SegmentId::ZERO;
|
||
|
||
for ((mut source_manips, source_closed), (mut target_manips, target_closed)) in source_subpaths.into_iter().zip(target_subpaths) {
|
||
if source_manips.is_empty() || target_manips.is_empty() {
|
||
continue;
|
||
}
|
||
|
||
// Align manipulator counts by subdividing the last segment of the shorter subpath
|
||
let source_count = source_manips.len();
|
||
let target_count = target_manips.len();
|
||
for _ in 0..target_count.saturating_sub(source_count) {
|
||
subdivide_last_manipulator_segment(&mut source_manips, source_closed);
|
||
}
|
||
for _ in 0..source_count.saturating_sub(target_count) {
|
||
subdivide_last_manipulator_segment(&mut target_manips, target_closed);
|
||
}
|
||
|
||
// Build interpolated manipulator groups
|
||
let mut interpolated: Vec<ManipulatorGroup<PointId>> = source_manips
|
||
.iter()
|
||
.zip(target_manips.iter())
|
||
.map(|(s, t)| ManipulatorGroup {
|
||
anchor: s.anchor.lerp(t.anchor, time),
|
||
in_handle: None,
|
||
out_handle: None,
|
||
id: PointId::ZERO,
|
||
})
|
||
.collect();
|
||
|
||
// Interpolate handles per segment, preserving handle type when source and target match
|
||
let segment_count = if source_closed { source_manips.len() } else { source_manips.len().saturating_sub(1) };
|
||
for segment_index in 0..segment_count {
|
||
let next_index = (segment_index + 1) % source_manips.len();
|
||
|
||
let source_out = source_manips[segment_index].out_handle;
|
||
let source_in = source_manips[next_index].in_handle;
|
||
let target_out = target_manips[segment_index].out_handle;
|
||
let target_in = target_manips[next_index].in_handle;
|
||
|
||
match (source_out, source_in, target_out, target_in) {
|
||
// Both linear: no handles needed
|
||
(None, None, None, None) => {}
|
||
// Both cubic: lerp handle pairs directly
|
||
(Some(s_out), Some(s_in), Some(t_out), Some(t_in)) => {
|
||
interpolated[segment_index].out_handle = Some(s_out.lerp(t_out, time));
|
||
interpolated[next_index].in_handle = Some(s_in.lerp(t_in, time));
|
||
}
|
||
// Both quadratic with handle in the same position: lerp the single handle
|
||
(Some(s_out), None, Some(t_out), None) => {
|
||
interpolated[segment_index].out_handle = Some(s_out.lerp(t_out, time));
|
||
}
|
||
(None, Some(s_in), None, Some(t_in)) => {
|
||
interpolated[next_index].in_handle = Some(s_in.lerp(t_in, time));
|
||
}
|
||
// Linear vs. quadratic: elevate the linear side to a zero-length quadratic in the matching position
|
||
(None, None, Some(t_out), None) => {
|
||
interpolated[segment_index].out_handle = Some(source_manips[segment_index].anchor.lerp(t_out, time));
|
||
}
|
||
(None, None, None, Some(t_in)) => {
|
||
interpolated[next_index].in_handle = Some(source_manips[next_index].anchor.lerp(t_in, time));
|
||
}
|
||
(Some(s_out), None, None, None) => {
|
||
interpolated[segment_index].out_handle = Some(s_out.lerp(target_manips[segment_index].anchor, time));
|
||
}
|
||
(None, Some(s_in), None, None) => {
|
||
interpolated[next_index].in_handle = Some(s_in.lerp(target_manips[next_index].anchor, time));
|
||
}
|
||
// Mismatched types: promote both to cubic and lerp
|
||
_ => {
|
||
let (s_h1, s_h2) = promote_handles_to_cubic(source_manips[segment_index].anchor, source_out, source_in, source_manips[next_index].anchor);
|
||
let (t_h1, t_h2) = promote_handles_to_cubic(target_manips[segment_index].anchor, target_out, target_in, target_manips[next_index].anchor);
|
||
interpolated[segment_index].out_handle = Some(s_h1.lerp(t_h1, time));
|
||
interpolated[next_index].in_handle = Some(s_h2.lerp(t_h2, time));
|
||
}
|
||
}
|
||
}
|
||
|
||
push_manipulators_to_vector(&mut vector, &interpolated, source_closed, &mut point_id, &mut segment_id);
|
||
}
|
||
|
||
// Deal with unmatched extra source subpaths by collapsing them toward their end point
|
||
for (mut manips, closed) in extra_source {
|
||
let Some(end) = manips.last().map(|m| m.anchor) else { continue };
|
||
|
||
for manip in &mut manips {
|
||
manip.anchor = manip.anchor.lerp(end, time);
|
||
manip.in_handle = manip.in_handle.map(|h| h.lerp(end, time));
|
||
manip.out_handle = manip.out_handle.map(|h| h.lerp(end, time));
|
||
}
|
||
|
||
push_manipulators_to_vector(&mut vector, &manips, closed, &mut point_id, &mut segment_id);
|
||
}
|
||
|
||
// Deal with unmatched extra target subpaths by expanding them from their start point
|
||
for (mut manips, closed) in extra_target {
|
||
let Some(start) = manips.first().map(|m| m.anchor) else { continue };
|
||
|
||
for manip in &mut manips {
|
||
manip.anchor = start.lerp(manip.anchor, time);
|
||
manip.in_handle = manip.in_handle.map(|h| start.lerp(h, time));
|
||
manip.out_handle = manip.out_handle.map(|h| start.lerp(h, time));
|
||
}
|
||
|
||
push_manipulators_to_vector(&mut vector, &manips, closed, &mut point_id, &mut segment_id);
|
||
}
|
||
|
||
// The result is a synthesis of source and target, so adopt whichever endpoint the result is closer to as
|
||
// the click-target identity (so the editor can route clicks back to one of the contributing layers)
|
||
let primary_index = if time < 0.5 { source_index } else { target_index };
|
||
let mut item = Item::new_from_element(vector);
|
||
item.set_attribute(ATTR_TRANSFORM, lerped_transform);
|
||
|
||
// Propagate each blending/layer column only when the input carries it, so attribute presence stays determined by the graph rather than by runtime values
|
||
if content.attribute::<BlendMode>(ATTR_BLEND_MODE, source_index).is_some() {
|
||
item.set_attribute(ATTR_BLEND_MODE, lerped_blend_mode);
|
||
}
|
||
if content.attribute::<f64>(ATTR_OPACITY, source_index).is_some() {
|
||
item.set_attribute(ATTR_OPACITY, lerped_opacity);
|
||
}
|
||
if content.attribute::<f64>(ATTR_OPACITY_FILL, source_index).is_some() {
|
||
item.set_attribute(ATTR_OPACITY_FILL, lerped_fill);
|
||
}
|
||
if content.attribute::<bool>(ATTR_CLIPPING_MASK, source_index).is_some() {
|
||
item.set_attribute(ATTR_CLIPPING_MASK, lerped_clip);
|
||
}
|
||
if let Some(layer_path) = content.attribute::<NodeIdPath>(ATTR_EDITOR_LAYER_PATH, primary_index) {
|
||
item.set_attribute(ATTR_EDITOR_LAYER_PATH, layer_path.clone());
|
||
}
|
||
item.set_attribute(ATTR_EDITOR_MERGED_LAYERS, graphic_list_content);
|
||
|
||
if let Some(fill) = fill_paint {
|
||
item.set_attribute(ATTR_FILL, fill);
|
||
}
|
||
if let Some(stroke) = stroke_paint {
|
||
item.set_attribute(ATTR_STROKE, stroke);
|
||
}
|
||
|
||
item
|
||
}
|
||
|
||
fn bevel_algorithm(mut vector: Vector, transform: DAffine2, distance: f64) -> Vector {
|
||
// Splits a bézier curve based on a distance measurement
|
||
fn split_distance(bezier: PathSeg, distance: f64, length: f64) -> PathSeg {
|
||
let parametric = eval_pathseg_euclidean(bezier, (distance / length).clamp(0., 1.), DEFAULT_ACCURACY);
|
||
bezier.subsegment(parametric..1.)
|
||
}
|
||
|
||
/// Produces a list that corresponds with the point ID. The value is how many segments are connected.
|
||
fn segments_connected_count(vector: &Vector) -> Vec<usize> {
|
||
// Count the number of segments connecting to each point.
|
||
let mut segments_connected_count = vec![0; vector.point_domain.ids().len()];
|
||
for &point_index in vector.segment_domain.start_point().iter().chain(vector.segment_domain.end_point()) {
|
||
segments_connected_count[point_index] += 1;
|
||
}
|
||
|
||
// Zero out points without exactly two connectors. These are ignored.
|
||
for count in &mut segments_connected_count {
|
||
if *count != 2 {
|
||
*count = 0;
|
||
}
|
||
}
|
||
segments_connected_count
|
||
}
|
||
|
||
/// Updates the index so that it points at a point with the position. If nobody else will look at the index, the original point is updated. Otherwise a new point is created.
|
||
fn create_or_modify_point(point_domain: &mut PointDomain, segments_connected_count: &mut [usize], pos: DVec2, index: &mut usize, next_id: &mut PointId, new_segments: &mut Vec<[usize; 2]>) {
|
||
segments_connected_count[*index] -= 1;
|
||
if segments_connected_count[*index] == 0 {
|
||
// If nobody else is going to look at this point, we're alright to modify it
|
||
point_domain.set_position(*index, pos);
|
||
} else {
|
||
let new_index = point_domain.ids().len();
|
||
let original_index = *index;
|
||
|
||
// Create a new point (since someone will wish to look at the point in the original position in future)
|
||
*index = new_index;
|
||
point_domain.push(next_id.next_id(), pos);
|
||
|
||
// Add a new segment to be created later
|
||
new_segments.push([new_index, original_index]);
|
||
}
|
||
}
|
||
|
||
fn calculate_distance_to_split(bezier1: PathSeg, bezier2: PathSeg, bevel_length: f64) -> f64 {
|
||
if is_linear(bezier1) && is_linear(bezier2) {
|
||
let v1 = (bezier1.end() - bezier1.start()).normalize();
|
||
let v2 = (bezier1.end() - bezier2.end()).normalize();
|
||
|
||
let dot_product = v1.dot(v2);
|
||
let angle_rad = dot_product.acos();
|
||
|
||
return bevel_length / (2. * (angle_rad / 2.).sin());
|
||
}
|
||
|
||
let length1 = bezier1.perimeter(DEFAULT_ACCURACY);
|
||
let length2 = bezier2.perimeter(DEFAULT_ACCURACY);
|
||
|
||
let max_split = length1.min(length2);
|
||
|
||
let mut split_distance = 0.;
|
||
let mut best_diff = f64::MAX;
|
||
let mut current_best_distance = 0.;
|
||
|
||
let clamp_and_round = |value: f64| ((value * 1000.).round() / 1000.).clamp(0., 1.);
|
||
|
||
const INITIAL_SAMPLES: usize = 50;
|
||
for i in 0..=INITIAL_SAMPLES {
|
||
let distance_sample = max_split * (i as f64 / INITIAL_SAMPLES as f64);
|
||
|
||
let x_point_t = eval_pathseg_euclidean(bezier1, 1. - clamp_and_round(distance_sample / length1), DEFAULT_ACCURACY);
|
||
let y_point_t = eval_pathseg_euclidean(bezier2, clamp_and_round(distance_sample / length2), DEFAULT_ACCURACY);
|
||
|
||
let x_point = bezier1.eval(x_point_t);
|
||
let y_point = bezier2.eval(y_point_t);
|
||
|
||
let distance = x_point.distance(y_point);
|
||
let diff = (bevel_length - distance).abs();
|
||
|
||
if diff < best_diff {
|
||
best_diff = diff;
|
||
current_best_distance = distance_sample;
|
||
}
|
||
|
||
if bevel_length - distance < 0. {
|
||
split_distance = distance_sample;
|
||
|
||
if i > 0 {
|
||
let prev_sample = max_split * ((i - 1) as f64 / INITIAL_SAMPLES as f64);
|
||
|
||
const REFINE_STEPS: usize = 10;
|
||
for j in 1..=REFINE_STEPS {
|
||
let refined_sample = prev_sample + (distance_sample - prev_sample) * (j as f64 / REFINE_STEPS as f64);
|
||
|
||
let x_point_t = eval_pathseg_euclidean(bezier1, 1. - (refined_sample / length1).clamp(0., 1.), DEFAULT_ACCURACY);
|
||
let y_point_t = eval_pathseg_euclidean(bezier2, (refined_sample / length2).clamp(0., 1.), DEFAULT_ACCURACY);
|
||
|
||
let x_point = bezier1.eval(x_point_t);
|
||
let y_point = bezier2.eval(y_point_t);
|
||
|
||
let distance = x_point.distance(y_point);
|
||
|
||
if bevel_length - distance < 0. {
|
||
split_distance = refined_sample;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
break;
|
||
}
|
||
}
|
||
|
||
if split_distance == 0. && current_best_distance > 0. {
|
||
split_distance = current_best_distance;
|
||
}
|
||
|
||
split_distance
|
||
}
|
||
|
||
fn sort_segments(segment_domain: &SegmentDomain) -> Vec<usize> {
|
||
let start_points = segment_domain.start_point();
|
||
let end_points = segment_domain.end_point();
|
||
|
||
let mut sorted_segments = vec![0];
|
||
let segment_domain_length = segment_domain.ids().len();
|
||
|
||
for _ in 0..segment_domain_length {
|
||
match sorted_segments.last() {
|
||
Some(&last) => {
|
||
if let Some(index) = start_points.iter().position(|&p| p == end_points[last]) {
|
||
if index == 0 {
|
||
break;
|
||
}
|
||
sorted_segments.push(index);
|
||
}
|
||
}
|
||
None => break,
|
||
}
|
||
}
|
||
|
||
if segment_domain_length != sorted_segments.len() {
|
||
for i in 0..segment_domain_length {
|
||
if !sorted_segments.contains(&i) {
|
||
sorted_segments.push(i);
|
||
}
|
||
}
|
||
}
|
||
|
||
sorted_segments
|
||
}
|
||
|
||
fn update_existing_segments(vector: &mut Vector, transform: DAffine2, distance: f64, segments_connected: &mut [usize]) -> Vec<[usize; 2]> {
|
||
let mut next_id = vector.point_domain.next_id();
|
||
let mut new_segments = Vec::new();
|
||
|
||
let sorted_segments = sort_segments(&vector.segment_domain);
|
||
let segment_domain = &mut vector.segment_domain;
|
||
let segment_domain_length = segment_domain.ids().len();
|
||
|
||
let mut first_original_length = 0.;
|
||
let mut first_length = 0.;
|
||
let mut prev_original_length = 0.;
|
||
let mut prev_length = 0.;
|
||
|
||
for i in 0..segment_domain_length {
|
||
let (index, next_index) = if i == segment_domain_length - 1 { (i, 0) } else { (i, i + 1) };
|
||
let pair_handles_and_points = segment_domain.pair_handles_and_points_mut_by_index(sorted_segments[index], sorted_segments[next_index]);
|
||
let (handles, start_point, end_point, next_handles, next_start_point, next_end_point) = pair_handles_and_points;
|
||
|
||
let start = vector.point_domain.positions()[*start_point];
|
||
let end = vector.point_domain.positions()[*end_point];
|
||
|
||
let mut bezier = handles_to_segment(start, *handles, end);
|
||
bezier = Affine::new(transform.to_cols_array()) * bezier;
|
||
|
||
let next_start = vector.point_domain.positions()[*next_start_point];
|
||
let next_end = vector.point_domain.positions()[*next_end_point];
|
||
|
||
let mut next_bezier = handles_to_segment(next_start, *next_handles, next_end);
|
||
next_bezier = Affine::new(transform.to_cols_array()) * next_bezier;
|
||
|
||
let calculated_split_distance = calculate_distance_to_split(bezier, next_bezier, distance);
|
||
|
||
if is_linear(bezier) {
|
||
bezier = PathSeg::Line(Line::new(bezier.start(), bezier.end()));
|
||
}
|
||
|
||
if is_linear(next_bezier) {
|
||
next_bezier = PathSeg::Line(Line::new(next_bezier.start(), next_bezier.end()));
|
||
}
|
||
|
||
let inverse_transform = if transform.matrix2.determinant() != 0. { transform.inverse() } else { Default::default() };
|
||
|
||
if index == 0 && next_index == 1 {
|
||
first_original_length = bezier.perimeter(DEFAULT_ACCURACY);
|
||
first_length = first_original_length;
|
||
}
|
||
|
||
let (original_length, length) = if index == 0 {
|
||
(bezier.perimeter(DEFAULT_ACCURACY), bezier.perimeter(DEFAULT_ACCURACY))
|
||
} else {
|
||
(prev_original_length, prev_length)
|
||
};
|
||
|
||
let (next_original_length, mut next_length) = if index == segment_domain_length - 1 && next_index == 0 {
|
||
(first_original_length, first_length)
|
||
} else {
|
||
(next_bezier.perimeter(DEFAULT_ACCURACY), next_bezier.perimeter(DEFAULT_ACCURACY))
|
||
};
|
||
|
||
// Only split if the length is big enough to make it worthwhile
|
||
let valid_length = length > 1e-10;
|
||
if segments_connected[*end_point] > 0 && valid_length {
|
||
// Apply the bevel to the end
|
||
let distance = calculated_split_distance.min(original_length.min(next_original_length) / 2.);
|
||
bezier = split_distance(bezier.reverse(), distance, length).reverse();
|
||
|
||
if index == 0 && next_index == 1 {
|
||
first_length = (length - distance).max(0.);
|
||
}
|
||
|
||
// Update the end position
|
||
let pos = inverse_transform.transform_point2(point_to_dvec2(bezier.end()));
|
||
create_or_modify_point(&mut vector.point_domain, segments_connected, pos, end_point, &mut next_id, &mut new_segments);
|
||
}
|
||
|
||
// Update the handles
|
||
*handles = segment_to_handles(&bezier).apply_transformation(|p| inverse_transform.transform_point2(p));
|
||
|
||
// Only split if the length is big enough to make it worthwhile
|
||
let valid_length = next_length > 1e-10;
|
||
if segments_connected[*next_start_point] > 0 && valid_length {
|
||
// Apply the bevel to the start
|
||
let distance = calculated_split_distance.min(next_original_length.min(original_length) / 2.);
|
||
next_bezier = split_distance(next_bezier, distance, next_length);
|
||
next_length = (next_length - distance).max(0.);
|
||
|
||
// Update the start position
|
||
let pos = inverse_transform.transform_point2(point_to_dvec2(next_bezier.start()));
|
||
|
||
create_or_modify_point(&mut vector.point_domain, segments_connected, pos, next_start_point, &mut next_id, &mut new_segments);
|
||
|
||
// Update the handles
|
||
*next_handles = segment_to_handles(&next_bezier).apply_transformation(|p| inverse_transform.transform_point2(p));
|
||
}
|
||
|
||
prev_original_length = next_original_length;
|
||
prev_length = next_length;
|
||
}
|
||
|
||
new_segments
|
||
}
|
||
|
||
fn insert_new_segments(vector: &mut Vector, new_segments: &[[usize; 2]]) {
|
||
let mut next_id = vector.segment_domain.next_id();
|
||
|
||
for &[start, end] in new_segments {
|
||
let handles = BezierHandles::Linear;
|
||
vector.segment_domain.push(next_id.next_id(), start, end, handles, StrokeId::ZERO);
|
||
}
|
||
}
|
||
|
||
if distance > 1. && vector.segment_domain.ids().len() > 1 {
|
||
let mut segments_connected = segments_connected_count(&vector);
|
||
let new_segments = update_existing_segments(&mut vector, transform, distance, &mut segments_connected);
|
||
insert_new_segments(&mut vector, &new_segments);
|
||
}
|
||
|
||
vector
|
||
}
|
||
|
||
#[node_macro::node(category("Vector: Modifier"), path(core_types::vector))]
|
||
fn bevel(_: impl Ctx, source: Item<Vector>, #[default(10.)] distance: Item<Length>) -> Item<Vector> {
|
||
let distance = *distance.element();
|
||
|
||
let transform: DAffine2 = source.attribute_cloned_or_default(ATTR_TRANSFORM);
|
||
let (element, attributes) = source.into_parts();
|
||
|
||
Item::from_parts(bevel_algorithm(element, transform, distance), attributes)
|
||
}
|
||
|
||
#[node_macro::node(category("Vector: Modifier"), path(core_types::vector))]
|
||
fn close_path(_: impl Ctx, source: Item<Vector>) -> Item<Vector> {
|
||
let mut source = source;
|
||
source.element_mut().close_subpaths();
|
||
source
|
||
}
|
||
|
||
#[node_macro::node(category("Vector: Measure"), path(core_types::vector))]
|
||
fn point_inside(_: impl Ctx, source: Item<Vector>, point: Item<DVec2>) -> Item<bool> {
|
||
let point = point.into_element();
|
||
let transform: DAffine2 = source.attribute_cloned_or_default(ATTR_TRANSFORM);
|
||
let inside = source.element().check_point_inside_shape(transform, point);
|
||
|
||
Item::new_from_element(inside)
|
||
}
|
||
|
||
// TODO: Return u32, u64, or usize instead of f64 after #1621 is resolved and has allowed us to implement automatic type conversion in the node graph for nodes with generic type inputs.
|
||
// TODO: (Currently automatic type conversion only works for concrete types, via the Graphene preprocessor and not the full Graphene type system.)
|
||
#[node_macro::node(category("General"), path(graphene_core::vector))]
|
||
async fn list_length(_: impl Ctx, content: ListDyn) -> Item<f64> {
|
||
Item::new_from_element(content.len() as f64)
|
||
}
|
||
|
||
#[node_macro::node(category("Vector: Measure"), path(graphene_core::vector))]
|
||
async fn count_points(_: impl Ctx, content: Item<Vector>) -> Item<f64> {
|
||
let count = content.element().point_domain.positions().len() as f64;
|
||
|
||
Item::new_from_element(count)
|
||
}
|
||
|
||
/// Retrieves the vec2 position (in local space) of the anchor point at the specified index in a `List` of vector elements.
|
||
/// If no value exists at that index, the position (0, 0) is returned.
|
||
#[node_macro::node(category("Vector: Measure"), path(graphene_core::vector))]
|
||
async fn index_points(
|
||
_: impl Ctx,
|
||
/// The vector element or elements containing the anchor points to be retrieved.
|
||
content: List<Vector>,
|
||
/// The index of the points to retrieve, starting from 0 for the first point. Negative indices count backwards from the end, starting from -1 for the last item.
|
||
index: Item<f64>,
|
||
) -> Item<DVec2> {
|
||
let index = index.into_element();
|
||
let points_count = content.iter_element_values().map(|vector| vector.point_domain.positions().len()).sum::<usize>();
|
||
|
||
if points_count == 0 {
|
||
return Item::new_from_element(DVec2::ZERO);
|
||
}
|
||
// Clamp and allow negative indexing from the end
|
||
let index = index as isize;
|
||
let index = if index < 0 {
|
||
(points_count as isize + index).max(0) as usize
|
||
} else {
|
||
(index as usize).min(points_count - 1)
|
||
};
|
||
|
||
// Find the point at the given index across all vector elements
|
||
let mut accumulated = 0;
|
||
for vector in content.iter_element_values() {
|
||
let row_point_count = vector.point_domain.positions().len();
|
||
if index - accumulated < row_point_count {
|
||
return Item::new_from_element(vector.point_domain.positions()[index - accumulated]);
|
||
}
|
||
accumulated += row_point_count;
|
||
}
|
||
|
||
Item::new_from_element(DVec2::ZERO)
|
||
}
|
||
|
||
#[node_macro::node(category("Vector: Measure"), path(core_types::vector))]
|
||
async fn path_length(_: impl Ctx, source: Item<Vector>) -> Item<f64> {
|
||
let transform: DAffine2 = source.attribute_cloned_or_default(ATTR_TRANSFORM);
|
||
let length = source
|
||
.element()
|
||
.stroke_bezpath_iter()
|
||
.map(|mut bezpath| {
|
||
bezpath.apply_affine(Affine::new(transform.to_cols_array()));
|
||
bezpath.perimeter(DEFAULT_ACCURACY)
|
||
})
|
||
.sum::<f64>();
|
||
|
||
Item::new_from_element(length)
|
||
}
|
||
|
||
#[node_macro::node(category("Vector: Measure"), path(core_types::vector))]
|
||
async fn area(ctx: impl Ctx + CloneVarArgs + ExtractAll, content: impl Node<Context<'static>, Output = Item<Vector>>) -> Item<f64> {
|
||
let new_ctx = OwnedContextImpl::from(ctx).with_footprint(Footprint::default()).into_context();
|
||
let vector = content.eval(new_ctx).await;
|
||
|
||
let transform: DAffine2 = vector.attribute_cloned_or_default(ATTR_TRANSFORM);
|
||
let area_scale = transform.matrix2.determinant().abs();
|
||
let area = vector.element().stroke_bezpath_iter().map(|subpath| subpath.area() * area_scale).sum::<f64>();
|
||
|
||
Item::new_from_element(area)
|
||
}
|
||
|
||
#[node_macro::node(category("Vector: Measure"), path(core_types::vector))]
|
||
async fn centroid(ctx: impl Ctx + CloneVarArgs + ExtractAll, content: impl Node<Context<'static>, Output = Item<Vector>>, centroid_type: Item<CentroidType>) -> Item<DVec2> {
|
||
let centroid_type = centroid_type.into_element();
|
||
let new_ctx = OwnedContextImpl::from(ctx).with_footprint(Footprint::default()).into_context();
|
||
let vector = content.eval(new_ctx).await;
|
||
|
||
let transform: DAffine2 = vector.attribute_cloned_or_default(ATTR_TRANSFORM);
|
||
let position = element_centroid(vector.element(), transform, centroid_type);
|
||
|
||
Item::new_from_element(position)
|
||
}
|
||
|
||
/// The area- or length-weighted centroid of one vector element's subpaths, averaging raw point positions when the weights all vanish.
|
||
fn element_centroid(element: &Vector, transform: DAffine2, centroid_type: CentroidType) -> DVec2 {
|
||
// All subpath centroid positions added together as if they were vectors from the origin, weighted by area or length
|
||
let mut centroid = DVec2::ZERO;
|
||
let mut sum = 0.;
|
||
|
||
for subpath in element.stroke_bezier_paths() {
|
||
let partial = match centroid_type {
|
||
CentroidType::Area => subpath.area_centroid_and_area(Some(1e-3), Some(1e-3)).filter(|(_, area)| *area > 0.),
|
||
CentroidType::Length => subpath.length_centroid_and_length(None, true),
|
||
};
|
||
if let Some((subpath_centroid, area_or_length)) = partial {
|
||
sum += area_or_length;
|
||
centroid += area_or_length * transform.transform_point2(subpath_centroid);
|
||
}
|
||
}
|
||
|
||
if sum > 0. {
|
||
return centroid / sum;
|
||
}
|
||
|
||
// Without a summed denominator, return the average of all positions instead
|
||
let positions = element.point_domain.positions();
|
||
if positions.is_empty() {
|
||
return DVec2::ZERO;
|
||
}
|
||
positions.iter().map(|&p| transform.transform_point2(p)).sum::<DVec2>() / (positions.len() as f64)
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod test {
|
||
use super::*;
|
||
use core_types::Node;
|
||
use kurbo::{CubicBez, Ellipse, Point, Rect};
|
||
use std::future::Future;
|
||
use std::pin::Pin;
|
||
use vector_types::vector::algorithms::bezpath_algorithms::{TValue, trim_pathseg};
|
||
use vector_types::vector::misc::pathseg_abs_diff_eq;
|
||
|
||
#[derive(Clone)]
|
||
pub struct FutureWrapperNode<T: Clone>(T);
|
||
|
||
impl<'i, T: 'i + Clone + Send> Node<'i, Footprint> for FutureWrapperNode<T> {
|
||
type Output = Pin<Box<dyn Future<Output = T> + 'i + Send>>;
|
||
fn eval(&'i self, _input: Footprint) -> Self::Output {
|
||
let value = self.0.clone();
|
||
Box::pin(async move { value })
|
||
}
|
||
}
|
||
|
||
fn vector_node_from_bezpath(bezpath: BezPath) -> List<Vector> {
|
||
List::new_from_element(Vector::from_bezpath(bezpath))
|
||
}
|
||
|
||
fn vector_item_from_bezpath(bezpath: BezPath) -> Item<Vector> {
|
||
Item::new_from_element(Vector::from_bezpath(bezpath))
|
||
}
|
||
|
||
fn create_vector_item(bezpath: BezPath, transform: DAffine2) -> Item<Vector> {
|
||
let mut row = Vector::default();
|
||
row.append_bezpath(bezpath);
|
||
Item::new_from_element(row).with_attribute(ATTR_TRANSFORM, transform)
|
||
}
|
||
|
||
fn item<T>(value: T) -> Item<T> {
|
||
Item::new_from_element(value)
|
||
}
|
||
|
||
fn vector_item_from_points(points: &[DVec2]) -> Item<Vector> {
|
||
let mut vector = Vector::default();
|
||
let mut next_point = PointId::ZERO;
|
||
for &position in points {
|
||
vector.point_domain.push(next_point.next_id(), position);
|
||
}
|
||
Item::new_from_element(vector)
|
||
}
|
||
|
||
const SQUARE_WITH_CENTER: [DVec2; 5] = [DVec2::new(0., 0.), DVec2::new(10., 0.), DVec2::new(10., 10.), DVec2::new(0., 10.), DVec2::new(5., 5.)];
|
||
|
||
#[tokio::test]
|
||
async fn offset_path_does_not_duplicate_closing_anchors() {
|
||
// Offsetting closed triangles must not leave each subpath with a redundant start/end anchor (a Kurbo offset
|
||
// contour returns to approximately, not exactly, its start; that near-coincident point must close, not duplicate).
|
||
let delaunay = super::triangulate((), vector_item_from_points(&SQUARE_WITH_CENTER), item(false)).await;
|
||
let offset = super::offset_path((), delaunay, item(0.5), item(StrokeJoin::Miter), item(4.)).await;
|
||
let result = offset.element();
|
||
|
||
let mut subpaths = 0;
|
||
for (group, closed) in result.stroke_manipulator_groups() {
|
||
subpaths += 1;
|
||
assert!(closed, "offset of a closed triangle should stay closed");
|
||
let first = group.first().unwrap().anchor;
|
||
let last = group.last().unwrap().anchor;
|
||
assert!(first.distance(last) > 1e-6, "closed subpath has a duplicated start/end anchor: {first:?} ~= {last:?}");
|
||
}
|
||
assert!(subpaths > 0);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn delaunay_disconnected_cells_make_one_region_per_triangle() {
|
||
let result = super::triangulate((), vector_item_from_points(&SQUARE_WITH_CENTER), item(false)).await;
|
||
let vector = result.element();
|
||
// The square plus its center tessellates into four triangles, each its own closed subpath.
|
||
assert_eq!(vector.region_domain.ids().len(), 4);
|
||
assert_eq!(vector.segment_domain.ids().len(), 4 * 3);
|
||
assert_eq!(vector.point_domain.ids().len(), 4 * 3);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn delaunay_and_voronoi_cells_share_winding() {
|
||
fn signed_area(anchors: &[DVec2]) -> f64 {
|
||
(0..anchors.len()).map(|i| anchors[i].perp_dot(anchors[(i + 1) % anchors.len()])).sum::<f64>() / 2.
|
||
}
|
||
fn subpath_winding_signs(vector: &Vector) -> Vec<f64> {
|
||
vector
|
||
.stroke_manipulator_groups()
|
||
.map(|(group, _)| signed_area(&group.iter().map(|g| g.anchor).collect::<Vec<_>>()).signum())
|
||
.collect()
|
||
}
|
||
|
||
// The Rectangle and Ellipse generators define the framework's fill winding convention; each is built from these
|
||
// subpath constructors (`Subpath::new_rectangle` / `Subpath::new_ellipse`), so their winding is the source of truth.
|
||
use vector_types::subpath::Subpath;
|
||
let rectangle = Vector::from_subpath(Subpath::new_rectangle(DVec2::new(-50., -50.), DVec2::new(50., 50.)));
|
||
let ellipse = Vector::from_subpath(Subpath::new_ellipse(DVec2::new(-50., -25.), DVec2::new(50., 25.)));
|
||
let expected = subpath_winding_signs(&rectangle)[0];
|
||
assert_eq!(subpath_winding_signs(&ellipse)[0], expected, "Rectangle and Ellipse should agree on winding");
|
||
|
||
// Delaunay and Voronoi must emit subpaths that wind the same way as those generators.
|
||
let delaunay = super::triangulate((), vector_item_from_points(&SQUARE_WITH_CENTER), item(false)).await;
|
||
let voronoi = super::voronoi_cells((), vector_item_from_points(&SQUARE_WITH_CENTER), item(false)).await;
|
||
for sign in subpath_winding_signs(delaunay.element()) {
|
||
assert_eq!(sign, expected, "Delaunay subpath winding should match the Rectangle/Ellipse generators");
|
||
}
|
||
for sign in subpath_winding_signs(voronoi.element()) {
|
||
assert_eq!(sign, expected, "Voronoi subpath winding should match the Rectangle/Ellipse generators");
|
||
}
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn delaunay_shared_mesh_welds_points_and_shares_edges() {
|
||
let result = super::triangulate((), vector_item_from_points(&SQUARE_WITH_CENTER), item(true)).await;
|
||
let vector = result.element();
|
||
// The connected mesh reuses the five input points and shares edges, with no fillable regions.
|
||
assert_eq!(vector.region_domain.ids().len(), 0);
|
||
assert_eq!(vector.point_domain.ids().len(), 5);
|
||
// Four hull edges plus four spokes to the center, each emitted once.
|
||
assert_eq!(vector.segment_domain.ids().len(), 8);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn voronoi_disconnected_cells_make_a_region_per_cell() {
|
||
let result = super::voronoi_cells((), vector_item_from_points(&SQUARE_WITH_CENTER), item(false)).await;
|
||
let vector = result.element();
|
||
let regions = vector.region_domain.ids().len();
|
||
assert!(regions > 0, "expected at least one Voronoi region");
|
||
// Every region is a closed subpath, so segments and points come in matched per-region loops.
|
||
assert_eq!(vector.segment_domain.ids().len(), vector.point_domain.ids().len());
|
||
|
||
// Clipping to the convex hull keeps all cell vertices within the input bounds.
|
||
for &position in vector.point_domain.positions() {
|
||
assert!(position.x >= -1e-6 && position.x <= 10. + 1e-6);
|
||
assert!(position.y >= -1e-6 && position.y <= 10. + 1e-6);
|
||
}
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn voronoi_shared_mesh_has_no_regions() {
|
||
let result = super::voronoi_cells((), vector_item_from_points(&SQUARE_WITH_CENTER), item(true)).await;
|
||
let vector = result.element();
|
||
assert_eq!(vector.region_domain.ids().len(), 0);
|
||
assert!(vector.segment_domain.ids().len() > 0);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn voronoi_leaves_degenerate_input_untouched() {
|
||
// Two points cannot form a diagram, so the element passes through unchanged.
|
||
let points = [DVec2::new(0., 0.), DVec2::new(1., 1.)];
|
||
let result = super::voronoi_cells((), vector_item_from_points(&points), item(false)).await;
|
||
let vector = result.element();
|
||
assert_eq!(vector.point_domain.ids().len(), 2);
|
||
assert_eq!(vector.segment_domain.ids().len(), 0);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn relax_points_redistributes_anchors() {
|
||
// Four hull corners plus two off-center interior points.
|
||
let points = [
|
||
DVec2::new(0., 0.),
|
||
DVec2::new(10., 0.),
|
||
DVec2::new(10., 10.),
|
||
DVec2::new(0., 10.),
|
||
DVec2::new(3., 4.),
|
||
DVec2::new(7., 5.),
|
||
];
|
||
let result = super::relax_points((), vector_item_from_points(&points), item(2.)).await;
|
||
let vector = result.element();
|
||
|
||
// Relaxation preserves the point count but repositions the interior anchors within the hull.
|
||
assert_eq!(vector.point_domain.ids().len(), points.len());
|
||
assert_ne!(vector.point_domain.positions(), &points[..]);
|
||
// The convex-hull corners are pinned.
|
||
for i in 0..4 {
|
||
assert_eq!(vector.point_domain.positions()[i], points[i], "hull corner {i} should be pinned");
|
||
}
|
||
for &point in vector.point_domain.positions() {
|
||
assert!(point.x >= -1e-6 && point.x <= 10. + 1e-6);
|
||
assert!(point.y >= -1e-6 && point.y <= 10. + 1e-6);
|
||
}
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn bounding_box() {
|
||
let bounding_box = super::bounding_box((), Item::new_from_element(Vector::from_bezpath(Rect::new(-1., -1., 1., 1.).to_path(DEFAULT_ACCURACY)))).await;
|
||
let bounding_box = bounding_box.element();
|
||
assert_eq!(bounding_box.region_manipulator_groups().count(), 1);
|
||
let manipulator_groups_anchors = bounding_box
|
||
.region_manipulator_groups()
|
||
.next()
|
||
.unwrap()
|
||
.1
|
||
.iter()
|
||
.map(|manipulators| manipulators.anchor)
|
||
.collect::<Vec<DVec2>>();
|
||
|
||
assert_eq!(&manipulator_groups_anchors[..4], &[DVec2::NEG_ONE, DVec2::new(1., -1.), DVec2::ONE, DVec2::new(-1., 1.),]);
|
||
|
||
// Test a rectangular path with non-zero rotation
|
||
let square = Vector::from_bezpath(Rect::new(-1., -1., 1., 1.).to_path(DEFAULT_ACCURACY));
|
||
let mut square = List::new_from_element(square);
|
||
square.with_attribute_mut_or_default(ATTR_TRANSFORM, 0, |t: &mut DAffine2| *t *= DAffine2::from_angle(std::f64::consts::FRAC_PI_4));
|
||
let bounding_box = BoundingBoxNodeMapped { content: FutureWrapperNode(square) }.eval(Footprint::default()).await;
|
||
let bounding_box = bounding_box.element(0).unwrap();
|
||
assert_eq!(bounding_box.region_manipulator_groups().count(), 1);
|
||
let manipulator_groups_anchors = bounding_box
|
||
.region_manipulator_groups()
|
||
.next()
|
||
.unwrap()
|
||
.1
|
||
.iter()
|
||
.map(|manipulators| manipulators.anchor)
|
||
.collect::<Vec<DVec2>>();
|
||
|
||
let expected_bounding_box = [DVec2::NEG_ONE, DVec2::new(1., -1.), DVec2::ONE, DVec2::new(-1., 1.)];
|
||
for i in 0..4 {
|
||
assert_eq!(manipulator_groups_anchors[i], expected_bounding_box[i]);
|
||
}
|
||
}
|
||
#[tokio::test]
|
||
async fn copy_to_points() {
|
||
let points = Rect::new(-10., -10., 10., 10.).to_path(DEFAULT_ACCURACY);
|
||
let element = Rect::new(-1., -1., 1., 1.).to_path(DEFAULT_ACCURACY);
|
||
|
||
let expected_points = Vector::from_bezpath(points.clone()).point_domain.positions().to_vec();
|
||
|
||
let copy_to_points = super::copy_to_points(
|
||
Footprint::default(),
|
||
vector_node_from_bezpath(points),
|
||
vector_node_from_bezpath(element),
|
||
Item::new_from_element(1.),
|
||
Item::new_from_element(1.),
|
||
Item::new_from_element(0.),
|
||
Item::new_from_element(0),
|
||
Item::new_from_element(0.),
|
||
Item::new_from_element(0),
|
||
)
|
||
.await;
|
||
let combined = List::new_from_item(super::combine_paths(Footprint::default(), List::new_from_element(Graphic::Vector(copy_to_points))).await);
|
||
let combined_copy_to_points = combined.element(0).unwrap();
|
||
|
||
assert_eq!(combined_copy_to_points.region_manipulator_groups().count(), expected_points.len());
|
||
|
||
for (index, (_, manipulator_groups)) in combined_copy_to_points.region_manipulator_groups().enumerate() {
|
||
let offset = expected_points[index];
|
||
let manipulator_groups_anchors = manipulator_groups.iter().map(|manipulators| manipulators.anchor).collect::<Vec<DVec2>>();
|
||
assert_eq!(
|
||
&manipulator_groups_anchors,
|
||
&[offset + DVec2::NEG_ONE, offset + DVec2::new(1., -1.), offset + DVec2::ONE, offset + DVec2::new(-1., 1.),]
|
||
);
|
||
}
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn sample_polyline() {
|
||
let path = BezPath::from_vec(vec![PathEl::MoveTo(Point::ZERO), PathEl::CurveTo(Point::ZERO, Point::new(100., 0.), Point::new(100., 0.))]);
|
||
let sample_polyline = super::sample_polyline(
|
||
Footprint::default(),
|
||
vector_item_from_bezpath(path),
|
||
Item::new_from_element(PointSpacingType::Separation),
|
||
Item::new_from_element(30.),
|
||
Item::new_from_element(0),
|
||
Item::new_from_element(0.),
|
||
Item::new_from_element(0.),
|
||
Item::new_from_element(false),
|
||
)
|
||
.await;
|
||
let sample_polyline = sample_polyline.element();
|
||
assert_eq!(sample_polyline.point_domain.positions().len(), 4);
|
||
for (pos, expected) in sample_polyline.point_domain.positions().iter().zip([DVec2::X * 0., DVec2::X * 30., DVec2::X * 60., DVec2::X * 90.]) {
|
||
assert!(pos.distance(expected) < 1e-3, "Expected {expected} found {pos}");
|
||
}
|
||
}
|
||
#[tokio::test]
|
||
async fn sample_polyline_adaptive_spacing() {
|
||
let path = BezPath::from_vec(vec![PathEl::MoveTo(Point::ZERO), PathEl::CurveTo(Point::ZERO, Point::new(100., 0.), Point::new(100., 0.))]);
|
||
let sample_polyline = super::sample_polyline(
|
||
Footprint::default(),
|
||
vector_item_from_bezpath(path),
|
||
Item::new_from_element(PointSpacingType::Separation),
|
||
Item::new_from_element(18.),
|
||
Item::new_from_element(0),
|
||
Item::new_from_element(45.),
|
||
Item::new_from_element(10.),
|
||
Item::new_from_element(true),
|
||
)
|
||
.await;
|
||
let sample_polyline = sample_polyline.element();
|
||
assert_eq!(sample_polyline.point_domain.positions().len(), 4);
|
||
for (pos, expected) in sample_polyline.point_domain.positions().iter().zip([DVec2::X * 45., DVec2::X * 60., DVec2::X * 75., DVec2::X * 90.]) {
|
||
assert!(pos.distance(expected) < 1e-3, "Expected {expected} found {pos}");
|
||
}
|
||
}
|
||
#[tokio::test]
|
||
async fn poisson() {
|
||
let poisson_points = super::scatter_points(
|
||
Footprint::default(),
|
||
vector_item_from_bezpath(Ellipse::from_rect(Rect::new(-50., -50., 50., 50.)).to_path(DEFAULT_ACCURACY)),
|
||
Item::new_from_element(10. * std::f64::consts::SQRT_2),
|
||
Item::new_from_element(0),
|
||
)
|
||
.await;
|
||
let poisson_points = poisson_points.element();
|
||
assert!(
|
||
(20..=40).contains(&poisson_points.point_domain.positions().len()),
|
||
"actual len {}",
|
||
poisson_points.point_domain.positions().len()
|
||
);
|
||
for point in poisson_points.point_domain.positions() {
|
||
assert!(point.length() < 50. + 1., "Expected point in circle {point}")
|
||
}
|
||
}
|
||
#[tokio::test]
|
||
async fn path_length() {
|
||
let bezpath = Rect::new(100., 100., 201., 201.).to_path(DEFAULT_ACCURACY);
|
||
let transform = DAffine2::from_scale(DVec2::new(2., 2.));
|
||
let item = create_vector_item(bezpath, transform);
|
||
|
||
let length = super::path_length(Footprint::default(), item).await;
|
||
|
||
// 101 (each rectangle edge length) * 4 (rectangle perimeter) * 2 (scale)
|
||
assert_eq!(length.into_element(), 101. * 4. * 2.);
|
||
}
|
||
#[tokio::test]
|
||
async fn spline() {
|
||
let spline = super::spline(Footprint::default(), vector_item_from_bezpath(Rect::new(0., 0., 100., 100.).to_path(DEFAULT_ACCURACY))).await;
|
||
let spline = spline.element();
|
||
assert_eq!(spline.stroke_bezpath_iter().count(), 1);
|
||
assert_eq!(spline.point_domain.positions(), &[DVec2::ZERO, DVec2::new(100., 0.), DVec2::new(100., 100.), DVec2::new(0., 100.)]);
|
||
}
|
||
#[tokio::test]
|
||
async fn morph() {
|
||
let mut rectangles = vector_node_from_bezpath(Rect::new(0., 0., 100., 100.).to_path(DEFAULT_ACCURACY));
|
||
let mut second_rectangle = rectangles.clone_item(0).unwrap();
|
||
*second_rectangle.attribute_mut_or_insert_default::<DAffine2>(ATTR_TRANSFORM) *= DAffine2::from_translation((-100., -100.).into());
|
||
rectangles.push(second_rectangle);
|
||
|
||
let morphed = super::morph(
|
||
Footprint::default(),
|
||
rectangles,
|
||
Item::new_from_element(0.5),
|
||
Item::new_from_element(false),
|
||
Item::new_from_element(InterpolationDistribution::default()),
|
||
Item::default(),
|
||
)
|
||
.await;
|
||
let morphed = List::new_from_item(morphed);
|
||
let morphed_element = morphed.element(0).unwrap();
|
||
// Geometry stays in local space (original rectangle coordinates)
|
||
assert_eq!(
|
||
&morphed_element.point_domain.positions()[..4],
|
||
vec![DVec2::new(0., 0.), DVec2::new(100., 0.), DVec2::new(100., 100.), DVec2::new(0., 100.)]
|
||
);
|
||
// The interpolated transform carries the midpoint translation (approximate due to arc-length parameterization)
|
||
assert!((morphed.attribute_cloned_or_default::<DAffine2>(ATTR_TRANSFORM, 0).translation - DVec2::new(-50., -50.)).length() < 1e-3);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn morph_interpolates_fill() {
|
||
let rect = || {
|
||
let mut v = Vector::default();
|
||
v.append_bezpath(Rect::new(0., 0., 100., 100.).to_path(DEFAULT_ACCURACY));
|
||
v
|
||
};
|
||
|
||
let item_a = Item::new_from_element(rect())
|
||
.with_attribute(ATTR_TRANSFORM, DAffine2::IDENTITY)
|
||
.with_attribute(ATTR_FILL, List::new_from_element(Color::RED).into_graphic_list());
|
||
let item_b = Item::new_from_element(rect())
|
||
.with_attribute(ATTR_TRANSFORM, DAffine2::from_translation((-100., -100.).into()))
|
||
.with_attribute(ATTR_FILL, List::new_from_element(Color::BLUE).into_graphic_list());
|
||
|
||
let mut content = List::new_from_item(item_a);
|
||
content.push(item_b);
|
||
|
||
let morphed = super::morph(
|
||
Footprint::default(),
|
||
content,
|
||
Item::new_from_element(0.5),
|
||
Item::new_from_element(false),
|
||
Item::new_from_element(InterpolationDistribution::default()),
|
||
Item::default(),
|
||
)
|
||
.await;
|
||
let morphed = List::new_from_item(morphed);
|
||
|
||
let fill = graphic_list_at(&morphed, 0, ATTR_FILL).expect("Morph should keep the fill paint at the midpoint");
|
||
|
||
// Interpolated color between red and blue should have >0 value on both R and B
|
||
let Some(Graphic::Color(colors)) = fill.element(0) else {
|
||
panic!("Expected a solid color fill, got {:?}", fill.element(0));
|
||
};
|
||
let color = *colors.element(0).expect("Color present");
|
||
assert!(color.r() > 0. && color.b() > 0., "Fill should be a red-to-blue blend, got {color:?}");
|
||
}
|
||
|
||
#[track_caller]
|
||
fn contains_segment(vector: Vector, target: PathSeg) {
|
||
let segments = vector.segment_iter().map(|x| x.1);
|
||
let count = segments
|
||
.filter(|segment| pathseg_abs_diff_eq(*segment, target, 0.01) || pathseg_abs_diff_eq(segment.reverse(), target, 0.01))
|
||
.count();
|
||
|
||
assert_eq!(
|
||
count,
|
||
1,
|
||
"Expected exactly one matching segment for {target:?}, but found {count}. The given segments are: {:#?}",
|
||
vector.segment_iter().collect::<Vec<_>>()
|
||
);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn bevel_rect() {
|
||
let source = Rect::new(0., 0., 100., 100.).to_path(DEFAULT_ACCURACY);
|
||
let beveled = super::bevel(Footprint::default(), vector_item_from_bezpath(source), Item::new_from_element(2_f64.sqrt() * 10.));
|
||
let beveled = beveled.element();
|
||
|
||
assert_eq!(beveled.point_domain.positions().len(), 8);
|
||
assert_eq!(beveled.segment_domain.ids().len(), 8);
|
||
|
||
// Segments
|
||
contains_segment(beveled.clone(), PathSeg::Line(Line::new(Point::new(10., 0.), Point::new(90., 0.))));
|
||
contains_segment(beveled.clone(), PathSeg::Line(Line::new(Point::new(10., 100.), Point::new(90., 100.))));
|
||
contains_segment(beveled.clone(), PathSeg::Line(Line::new(Point::new(0., 10.), Point::new(0., 90.))));
|
||
contains_segment(beveled.clone(), PathSeg::Line(Line::new(Point::new(100., 10.), Point::new(100., 90.))));
|
||
|
||
// Joins
|
||
contains_segment(beveled.clone(), PathSeg::Line(Line::new(Point::new(10., 0.), Point::new(0., 10.))));
|
||
contains_segment(beveled.clone(), PathSeg::Line(Line::new(Point::new(90., 0.), Point::new(100., 10.))));
|
||
contains_segment(beveled.clone(), PathSeg::Line(Line::new(Point::new(100., 90.), Point::new(90., 100.))));
|
||
contains_segment(beveled.clone(), PathSeg::Line(Line::new(Point::new(10., 100.), Point::new(0., 90.))));
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn bevel_open_curve() {
|
||
let curve = PathSeg::Cubic(CubicBez::new(Point::ZERO, Point::new(10., 0.), Point::new(10., 100.), Point::new(100., 0.)));
|
||
|
||
let mut source = BezPath::new();
|
||
source.move_to(Point::new(-100., 0.));
|
||
source.line_to(Point::ZERO);
|
||
source.push(curve.as_path_el());
|
||
|
||
let beveled = super::bevel((), vector_item_from_bezpath(source), Item::new_from_element(2_f64.sqrt() * 10.));
|
||
let beveled = beveled.element();
|
||
|
||
assert_eq!(beveled.point_domain.positions().len(), 4);
|
||
assert_eq!(beveled.segment_domain.ids().len(), 3);
|
||
|
||
// Segments
|
||
contains_segment(beveled.clone(), PathSeg::Line(Line::new(Point::new(-8.2, 0.), Point::new(-100., 0.))));
|
||
let trimmed = trim_pathseg(curve, TValue::Euclidean(8.2 / curve.perimeter(DEFAULT_ACCURACY)), TValue::Parametric(1.)).unwrap();
|
||
contains_segment(beveled.clone(), trimmed);
|
||
|
||
// Join
|
||
contains_segment(beveled.clone(), PathSeg::Line(Line::new(Point::new(-8.2, 0.), trimmed.start())));
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn bevel_with_transform() {
|
||
let curve = PathSeg::Cubic(CubicBez::new(Point::ZERO, Point::new(10., 0.), Point::new(10., 100.), Point::new(100., 0.)));
|
||
|
||
let mut source = BezPath::new();
|
||
source.move_to(Point::new(-100., 0.));
|
||
source.line_to(Point::ZERO);
|
||
source.push(curve.as_path_el());
|
||
|
||
let transform = DAffine2::from_scale_angle_translation(DVec2::splat(10.), 1., DVec2::new(99., 77.));
|
||
let vector_item = Item::new_from_element(Vector::from_bezpath(source)).with_attribute(ATTR_TRANSFORM, transform);
|
||
|
||
let beveled = super::bevel((), vector_item, Item::new_from_element(2_f64.sqrt() * 100.));
|
||
let beveled = beveled.element();
|
||
|
||
assert_eq!(beveled.point_domain.positions().len(), 4);
|
||
assert_eq!(beveled.segment_domain.ids().len(), 3);
|
||
|
||
// Segments
|
||
contains_segment(beveled.clone(), PathSeg::Line(Line::new(Point::new(-8.2, 0.), Point::new(-100., 0.))));
|
||
let trimmed = trim_pathseg(curve, TValue::Euclidean(8.2 / curve.perimeter(DEFAULT_ACCURACY)), TValue::Parametric(1.)).unwrap();
|
||
contains_segment(beveled.clone(), trimmed);
|
||
|
||
// Join
|
||
contains_segment(beveled.clone(), PathSeg::Line(Line::new(Point::new(-8.2, 0.), trimmed.start())));
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn bevel_too_high() {
|
||
let mut source = BezPath::new();
|
||
source.move_to(Point::ZERO);
|
||
source.line_to(Point::new(100., 0.));
|
||
source.line_to(Point::new(100., 100.));
|
||
source.line_to(Point::new(0., 100.));
|
||
|
||
let beveled = super::bevel(Footprint::default(), vector_item_from_bezpath(source), Item::new_from_element(999.));
|
||
let beveled = beveled.element();
|
||
|
||
assert_eq!(beveled.point_domain.positions().len(), 6);
|
||
assert_eq!(beveled.segment_domain.ids().len(), 5);
|
||
|
||
// Segments
|
||
contains_segment(beveled.clone(), PathSeg::Line(Line::new(Point::new(0., 0.), Point::new(50., 0.))));
|
||
contains_segment(beveled.clone(), PathSeg::Line(Line::new(Point::new(100., 50.), Point::new(100., 50.))));
|
||
contains_segment(beveled.clone(), PathSeg::Line(Line::new(Point::new(100., 50.), Point::new(50., 100.))));
|
||
|
||
// Joins
|
||
contains_segment(beveled.clone(), PathSeg::Line(Line::new(Point::new(50., 0.), Point::new(100., 50.))));
|
||
contains_segment(beveled.clone(), PathSeg::Line(Line::new(Point::new(100., 50.), Point::new(50., 100.))));
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn bevel_repeated_point() {
|
||
let line = PathSeg::Line(Line::new(Point::ZERO, Point::new(100., 0.)));
|
||
let point = PathSeg::Cubic(CubicBez::new(Point::new(100., 0.), Point::ZERO, Point::ZERO, Point::new(100., 0.)));
|
||
let curve = PathSeg::Cubic(CubicBez::new(Point::new(100., 0.), Point::new(110., 0.), Point::new(110., 200.), Point::new(200., 0.)));
|
||
|
||
let subpath = BezPath::from_path_segments([line, point, curve].into_iter());
|
||
|
||
let beveled_item = super::bevel(Footprint::default(), vector_item_from_bezpath(subpath), Item::new_from_element(5.));
|
||
let beveled = beveled_item.element();
|
||
|
||
assert_eq!(beveled.point_domain.positions().len(), 6);
|
||
assert_eq!(beveled.segment_domain.ids().len(), 5);
|
||
}
|
||
}
|